All checks were successful
Deploy to Test Environment / deploy-to-test (push) Successful in 18m46s
306 lines
9.8 KiB
TypeScript
306 lines
9.8 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>'));
|
|
expect(mailOptions.html).toEqual(
|
|
expect.stringContaining(
|
|
'<li>\n <strong>Apples</strong> is on sale for \n <strong>$1.99</strong> \n at Green Grocer!\n </li>',
|
|
),
|
|
);
|
|
expect(mailOptions.html).toEqual(
|
|
expect.stringContaining(
|
|
'<li>\n <strong>Milk</strong> is on sale for \n <strong>$3.50</strong> \n at Dairy Farm!\n </li>',
|
|
),
|
|
);
|
|
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.',
|
|
);
|
|
});
|
|
});
|
|
});
|