Files
flyer-crawler.projectium.com/src/services/emailService.server.test.ts
Torben Sorensen cf476e7afc
All checks were successful
Deploy to Test Environment / deploy-to-test (push) Successful in 18m47s
ADR-022 - websocket notificaitons - also more test fixes with stores
2026-01-19 10:53:42 -08:00

304 lines
9.9 KiB
TypeScript

// src/services/emailService.server.test.ts
import { describe, it, expect, vi, beforeEach } from 'vitest';
// Use vi.hoisted to define mocks that need to be available inside vi.mock factories.
// This prevents "Cannot access before initialization" errors.
const mocks = vi.hoisted(() => ({
sendMail: vi.fn().mockImplementation((mailOptions) => {
console.log('[TEST DEBUG] mockSendMail called with:', mailOptions?.to);
return Promise.resolve({
messageId: 'default-id',
message: Buffer.from('mock-email-content'),
});
}),
}));
vi.mock('nodemailer', () => {
return {
// Mock the default export which is what the service uses
default: {
// The createTransport function returns an object with a sendMail method
createTransport: vi.fn(() => ({ sendMail: mocks.sendMail })),
},
};
});
// Mock the logger to prevent console output during tests
vi.mock('./logger.server', () => ({
logger: {
info: vi.fn(),
debug: vi.fn(),
error: vi.fn(),
child: vi.fn().mockReturnThis(),
},
}));
// Now that mocks are set up, we can import the service under test.
import {
sendPasswordResetEmail,
sendWelcomeEmail,
sendDealNotificationEmail,
processEmailJob,
} from './emailService.server';
import type { WatchedItemDeal } from '../types';
import { createMockWatchedItemDeal } from '../tests/utils/mockFactories';
import { logger } from './logger.server';
import type { Job } from 'bullmq';
import type { EmailJobData } from '../types/job-data';
describe('Email Service (Server)', () => {
beforeEach(async () => {
console.log('[TEST SETUP] Setting up Email Service mocks');
vi.clearAllMocks();
vi.stubEnv('FRONTEND_URL', 'https://test.flyer.com');
// Reset to default successful implementation
mocks.sendMail.mockImplementation((mailOptions: { to: string }) => {
console.log('[TEST DEBUG] mockSendMail (default) called with:', mailOptions?.to);
return Promise.resolve({
messageId: 'default-mock-id',
message: Buffer.from('mock-email-content'),
});
});
});
afterEach(() => {
vi.unstubAllEnvs();
});
describe('sendPasswordResetEmail', () => {
it('should call sendMail with the correct recipient, subject, and constructed link', async () => {
const to = 'test@example.com';
const token = 'mock-token-123';
const expectedResetUrl = `https://test.flyer.com/reset-password?token=${token}`;
await sendPasswordResetEmail(to, token, logger);
expect(mocks.sendMail).toHaveBeenCalledTimes(1);
const mailOptions = mocks.sendMail.mock.calls[0][0] as {
to: string;
subject: string;
text: string;
html: string;
};
expect(mailOptions.to).toBe(to);
expect(mailOptions.subject).toBe('Your Password Reset Request');
expect(mailOptions.text).toContain(expectedResetUrl);
expect(mailOptions.html).toContain(`href="${expectedResetUrl}"`);
});
});
describe('sendWelcomeEmail', () => {
it('should send a personalized welcome email when a name is provided', async () => {
const to = 'new.user@example.com';
const name = 'Jane Doe';
mocks.sendMail.mockResolvedValue({ messageId: 'test-id', message: Buffer.from('content') });
await sendWelcomeEmail(to, name, logger);
expect(mocks.sendMail).toHaveBeenCalledTimes(1);
const mailOptions = mocks.sendMail.mock.calls[0][0] as {
to: string;
subject: string;
text: string;
html: string;
};
expect(mailOptions.to).toBe(to);
expect(mailOptions.subject).toBe('Welcome to Flyer Crawler!');
expect(mailOptions.text).toContain('Hello Jane Doe,');
// Check for the key content instead of the exact HTML structure
expect(mailOptions.html).toContain('Hello Jane Doe,');
});
it('should send a generic welcome email when a name is not provided', async () => {
const to = 'another.user@example.com';
mocks.sendMail.mockResolvedValue({ messageId: 'test-id', message: Buffer.from('content') });
await sendWelcomeEmail(to, null, logger);
expect(mocks.sendMail).toHaveBeenCalledTimes(1);
const mailOptions = mocks.sendMail.mock.calls[0][0] as {
to: string;
subject: string;
text: string;
html: string;
};
expect(mailOptions.text).toContain('Hello there,');
// Check for the key content instead of the exact HTML structure
expect(mailOptions.html).toContain('Hello there,');
});
});
describe('sendDealNotificationEmail', () => {
const mockDeals = [
createMockWatchedItemDeal({
item_name: 'Apples',
best_price_in_cents: 199,
store: {
store_id: 1,
name: 'Green Grocer',
logo_url: null,
locations: [],
},
}),
createMockWatchedItemDeal({
item_name: 'Milk',
best_price_in_cents: 350,
store: {
store_id: 2,
name: 'Dairy Farm',
logo_url: null,
locations: [],
},
}),
];
it('should send a personalized email with a list of deals', async () => {
const to = 'deal.hunter@example.com';
const name = 'Deal Hunter';
await sendDealNotificationEmail(
to,
name,
mockDeals as Partial<WatchedItemDeal>[] as WatchedItemDeal[],
logger,
);
expect(mocks.sendMail).toHaveBeenCalledTimes(1);
const mailOptions = mocks.sendMail.mock.calls[0][0] as {
to: string;
subject: string;
text: string;
html: string;
};
expect(mailOptions.to).toBe(to);
expect(mailOptions.subject).toBe('New Deals Found on Your Watched Items!');
// FIX: Use `stringContaining` to check for key parts of the HTML without being brittle about whitespace.
// The actual HTML is a multi-line template string with tags like <h1>, <ul>, and <li>.
expect(mailOptions.html).toEqual(expect.stringContaining('<h1>Hi Deal Hunter,</h1>'));
// Check for key content without being brittle about exact whitespace/newlines
expect(mailOptions.html).toContain('<strong>Apples</strong>');
expect(mailOptions.html).toContain('is on sale for');
expect(mailOptions.html).toContain('<strong>$1.99</strong>');
expect(mailOptions.html).toContain('Green Grocer');
expect(mailOptions.html).toContain('<strong>Milk</strong>');
expect(mailOptions.html).toContain('<strong>$3.50</strong>');
expect(mailOptions.html).toContain('Dairy Farm');
expect(mailOptions.html).toEqual(
expect.stringContaining('<p>Check them out on the deals page!</p>'),
);
});
it('should send a generic email when name is null', async () => {
const to = 'anonymous.user@example.com';
await sendDealNotificationEmail(
to,
null,
mockDeals as Partial<WatchedItemDeal>[] as WatchedItemDeal[],
logger,
);
expect(mocks.sendMail).toHaveBeenCalledTimes(1);
const mailOptions = mocks.sendMail.mock.calls[0][0] as {
to: string;
subject: string;
html: string;
};
expect(mailOptions.to).toBe(to);
expect(mailOptions.html).toContain('Hi there,');
});
it('should log an error if sendMail fails', async () => {
const to = 'fail@example.com';
const name = 'Failure';
const emailError = new Error('SMTP Connection Failed');
mocks.sendMail.mockRejectedValue(emailError);
await expect(
sendDealNotificationEmail(
to,
name,
mockDeals as Partial<WatchedItemDeal>[] as WatchedItemDeal[],
logger,
),
).rejects.toThrow(emailError);
expect(logger.error).toHaveBeenCalledWith(
{ err: emailError, to, subject: 'New Deals Found on Your Watched Items!' },
'Failed to send email.',
);
});
});
describe('processEmailJob', () => {
const mockJobData: EmailJobData = {
to: 'job@example.com',
subject: 'Job Email',
html: '<p>Job</p>',
text: 'Job',
};
const createMockJob = (data: EmailJobData): Job<EmailJobData> =>
({
id: 'job-123',
name: 'email-job',
data,
attemptsMade: 1,
}) as unknown as Job<EmailJobData>;
it('should call sendMail with job data and log success', async () => {
const job = createMockJob(mockJobData);
mocks.sendMail.mockResolvedValue({ messageId: 'job-test-id' });
await processEmailJob(job);
expect(mocks.sendMail).toHaveBeenCalledTimes(1);
const mailOptions = mocks.sendMail.mock.calls[0][0];
expect(mailOptions.to).toBe(mockJobData.to);
expect(mailOptions.subject).toBe(mockJobData.subject);
expect(logger.info).toHaveBeenCalledWith('Picked up email job.');
expect(logger.info).toHaveBeenCalledWith(
{ to: 'job@example.com', subject: 'Job Email', messageId: 'job-test-id' },
'Email sent successfully.',
);
});
it('should log an error and re-throw if sendMail fails', async () => {
const job = createMockJob(mockJobData);
const emailError = new Error('SMTP Connection Failed');
mocks.sendMail.mockRejectedValue(emailError);
await expect(processEmailJob(job)).rejects.toThrow(emailError);
expect(logger.error).toHaveBeenCalledWith(
{ err: emailError, jobData: mockJobData, attemptsMade: 1 },
'Email job failed.',
);
});
it('should handle non-Error objects thrown during processing', async () => {
const job = createMockJob(mockJobData);
const emailErrorString = 'SMTP Connection Failed as a string';
mocks.sendMail.mockRejectedValue(emailErrorString);
await expect(processEmailJob(job)).rejects.toThrow(emailErrorString);
expect(logger.error).toHaveBeenCalledWith(
{
err: expect.objectContaining({ message: emailErrorString }),
jobData: mockJobData,
attemptsMade: 1,
},
'Email job failed.',
);
});
});
});