Files
flyer-crawler.projectium.com/src/services/backgroundJobService.test.ts
Torben Sorensen 93497bf7c7
All checks were successful
Deploy to Test Environment / deploy-to-test (push) Successful in 12m2s
unit test fixes
2025-12-27 11:00:19 -08:00

671 lines
24 KiB
TypeScript

// src/services/backgroundJobService.test.ts
import { describe, it, expect, vi, beforeEach, afterEach, Mocked } from 'vitest';
import type { Logger } from 'pino';
// Use vi.hoisted to ensure the mock variable is available when vi.mock is executed.
const { mockCronSchedule } = vi.hoisted(() => {
return { mockCronSchedule: vi.fn() };
});
vi.mock('node-cron', () => ({ default: { schedule: mockCronSchedule } }));
// Mock the logger.server module globally so cron.schedule callbacks use this mock.
vi.mock('../services/logger.server', () => ({
logger: {
info: vi.fn(),
error: vi.fn(),
warn: vi.fn(),
debug: vi.fn(),
fatal: vi.fn(),
trace: vi.fn(),
silent: vi.fn(),
},
}));
// Mock the date utility to control the output for the weekly analytics job
vi.mock('../utils/dateUtils', () => ({
getSimpleWeekAndYear: vi.fn(() => ({ year: 2024, week: 42 })),
}));
import { BackgroundJobService, startBackgroundJobs } from './backgroundJobService';
import type { Queue } from 'bullmq';
import type { PersonalizationRepository } from './db/personalization.db';
import type { NotificationRepository } from './db/notification.db';
import { createMockWatchedItemDeal } from '../tests/utils/mockFactories';
import { logger as globalMockLogger } from '../services/logger.server'; // Import the mocked logger
describe('Background Job Service', () => {
// Create mock dependencies that will be injected into the service
const mockPersonalizationRepo = {
getBestSalePricesForAllUsers: vi.fn(),
};
const mockNotificationRepo = {
createBulkNotifications: vi.fn(),
};
const mockEmailQueue = {
add: vi.fn(),
};
beforeEach(() => {
vi.clearAllMocks();
});
afterEach(() => {
vi.useRealTimers();
});
describe('runDailyDealCheck', () => {
// Mock data representing the result of the new single database query
const mockDealsForAllUsers = [
// Deals for user-1
{
...createMockWatchedItemDeal({
master_item_id: 1,
item_name: 'Apples',
best_price_in_cents: 199,
store_name: 'Green Grocer',
flyer_id: 101,
valid_to: '2024-10-20',
}),
user_id: 'user-1',
email: 'user1@test.com',
full_name: 'User One',
},
// Deals for user-2
{
...createMockWatchedItemDeal({
master_item_id: 2,
item_name: 'Milk',
best_price_in_cents: 450,
store_name: 'Dairy Farm',
flyer_id: 102,
valid_to: '2024-10-21',
}),
user_id: 'user-2',
email: 'user2@test.com',
full_name: 'User Two',
},
{
...createMockWatchedItemDeal({
master_item_id: 3,
item_name: 'Bread',
best_price_in_cents: 250,
store_name: 'Bakery',
flyer_id: 103,
valid_to: '2024-10-22',
}),
user_id: 'user-2',
email: 'user2@test.com',
full_name: 'User Two',
},
];
// Helper to create a type-safe mock logger
const createMockLogger = (): Logger =>
({
info: vi.fn(),
error: vi.fn(),
warn: vi.fn(),
debug: vi.fn(),
child: vi.fn(() => createMockLogger()),
}) as unknown as Logger;
// Instantiate the service with mock dependencies for each test run
const mockServiceLogger = createMockLogger();
const service = new BackgroundJobService(
mockPersonalizationRepo as unknown as PersonalizationRepository,
mockNotificationRepo as unknown as NotificationRepository,
mockEmailQueue as unknown as Queue,
mockServiceLogger,
);
it('should do nothing if no deals are found for any user', async () => {
mockPersonalizationRepo.getBestSalePricesForAllUsers.mockResolvedValue([]);
await service.runDailyDealCheck();
expect(mockServiceLogger.info).toHaveBeenCalledWith(
'[BackgroundJob] Starting daily deal check for all users...',
);
expect(mockServiceLogger.info).toHaveBeenCalledWith(
'[BackgroundJob] No deals found for any watched items. Skipping.',
);
expect(mockPersonalizationRepo.getBestSalePricesForAllUsers).toHaveBeenCalledTimes(1);
expect(mockEmailQueue.add).not.toHaveBeenCalled();
expect(mockNotificationRepo.createBulkNotifications).not.toHaveBeenCalled();
});
it('should create notifications and enqueue emails when deals are found', async () => {
mockPersonalizationRepo.getBestSalePricesForAllUsers.mockResolvedValue(mockDealsForAllUsers);
await service.runDailyDealCheck();
// Check that it fetched all deals once
expect(mockPersonalizationRepo.getBestSalePricesForAllUsers).toHaveBeenCalledTimes(1);
// Check that email jobs were enqueued for both users
expect(mockEmailQueue.add).toHaveBeenCalledTimes(2);
const firstEmailJob = mockEmailQueue.add.mock.calls[0];
expect(firstEmailJob[1].to).toBe('user1@test.com');
const secondEmailJob = mockEmailQueue.add.mock.calls[1];
expect(secondEmailJob[1].to).toBe('user2@test.com');
// Check the content for the user with multiple deals
expect(secondEmailJob[1].html).toContain('Milk');
expect(secondEmailJob[1].html).toContain('Bread');
// Check that in-app notifications were created for both users
expect(mockNotificationRepo.createBulkNotifications).toHaveBeenCalledTimes(1);
const notificationPayload = mockNotificationRepo.createBulkNotifications.mock.calls[0][0];
expect(notificationPayload).toHaveLength(2);
// Use expect.arrayContaining to be order-agnostic.
expect(notificationPayload).toEqual(
expect.arrayContaining([
{
user_id: 'user-1',
content: 'You have 1 new deal(s) on your watched items!',
link_url: '/dashboard/deals',
updated_at: expect.any(String),
},
{
user_id: 'user-2',
content: 'You have 2 new deal(s) on your watched items!',
link_url: '/dashboard/deals',
updated_at: expect.any(String),
},
]),
);
});
it('should handle and log errors for individual users without stopping the process', async () => {
mockPersonalizationRepo.getBestSalePricesForAllUsers.mockResolvedValue(mockDealsForAllUsers);
// Simulate the email queue failing for the first user but succeeding for the second
mockEmailQueue.add
.mockRejectedValueOnce(new Error('Email queue is down'))
.mockResolvedValueOnce({ id: 'job-2' });
await service.runDailyDealCheck();
// Check that it logged the error for user 1
expect(mockServiceLogger.error).toHaveBeenCalledWith(
{ err: expect.any(Error) },
expect.stringContaining('Failed to process deals for user user-1'),
);
// Check that it still processed user 2 successfully
// The email queue add should be attempted for both users.
expect(mockEmailQueue.add).toHaveBeenCalledTimes(2);
expect(mockNotificationRepo.createBulkNotifications).toHaveBeenCalledTimes(1);
const notificationPayload = mockNotificationRepo.createBulkNotifications.mock.calls[0][0];
expect(notificationPayload).toHaveLength(1); // Only one notification created
expect(notificationPayload[0]).toEqual({
user_id: 'user-2',
content: 'You have 2 new deal(s) on your watched items!',
link_url: '/dashboard/deals',
updated_at: expect.any(String),
});
});
it('should log a critical error if getBestSalePricesForAllUsers fails', async () => {
mockPersonalizationRepo.getBestSalePricesForAllUsers.mockRejectedValue(
new Error('Critical DB Failure'),
);
await expect(service.runDailyDealCheck()).rejects.toThrow('Critical DB Failure'); // This was a duplicate, fixed.
expect(mockServiceLogger.error).toHaveBeenCalledWith(
{ err: expect.any(Error) },
'[BackgroundJob] A critical error occurred during the daily deal check',
);
});
it('should log a critical error if createBulkNotifications fails', async () => {
// Arrange
mockPersonalizationRepo.getBestSalePricesForAllUsers.mockResolvedValue(mockDealsForAllUsers);
const dbError = new Error('Bulk insert failed');
mockNotificationRepo.createBulkNotifications.mockRejectedValue(dbError);
// Act & Assert
await expect(service.runDailyDealCheck()).rejects.toThrow(dbError);
expect(mockServiceLogger.error).toHaveBeenCalledWith(
{ err: dbError },
'[BackgroundJob] A critical error occurred during the daily deal check',
);
});
});
describe('startBackgroundJobs', () => {
const mockBackgroundJobService = {
runDailyDealCheck: vi.fn(),
} as unknown as Mocked<BackgroundJobService>;
const mockAnalyticsQueue = {
add: vi.fn(),
} as unknown as Mocked<Queue>;
const mockWeeklyAnalyticsQueue = {
add: vi.fn(),
} as unknown as Mocked<Queue>;
const mockTokenCleanupQueue = {
add: vi.fn(),
} as unknown as Mocked<Queue>;
beforeEach(() => {
vi.clearAllMocks(); // Clear global mock logger calls too
mockCronSchedule.mockClear();
vi.mocked(mockBackgroundJobService.runDailyDealCheck).mockClear();
vi.mocked(mockAnalyticsQueue.add).mockClear();
vi.mocked(mockTokenCleanupQueue.add).mockClear();
vi.mocked(mockWeeklyAnalyticsQueue.add).mockClear();
});
it('should schedule three cron jobs with the correct schedules', () => {
startBackgroundJobs(
mockBackgroundJobService,
mockAnalyticsQueue,
mockWeeklyAnalyticsQueue,
mockTokenCleanupQueue,
globalMockLogger,
);
expect(mockCronSchedule).toHaveBeenCalledTimes(4);
expect(mockCronSchedule).toHaveBeenCalledWith('0 2 * * *', expect.any(Function));
expect(mockCronSchedule).toHaveBeenCalledWith('0 3 * * *', expect.any(Function));
expect(mockCronSchedule).toHaveBeenCalledWith('0 4 * * 0', expect.any(Function));
expect(mockCronSchedule).toHaveBeenCalledWith('0 5 * * *', expect.any(Function));
});
it('should call runDailyDealCheck when the first cron job function is executed', async () => {
startBackgroundJobs(
mockBackgroundJobService,
mockAnalyticsQueue,
mockWeeklyAnalyticsQueue,
mockTokenCleanupQueue,
globalMockLogger,
);
// Get the callback function for the first cron job
const dailyDealCheckCallback = mockCronSchedule.mock.calls[0][1];
await dailyDealCheckCallback();
expect(mockBackgroundJobService.runDailyDealCheck).toHaveBeenCalledTimes(1);
});
it('should log an error and release the lock if runDailyDealCheck fails', async () => {
const jobError = new Error('Cron job failed');
vi.mocked(mockBackgroundJobService.runDailyDealCheck).mockRejectedValue(jobError);
startBackgroundJobs(
mockBackgroundJobService,
mockAnalyticsQueue,
mockWeeklyAnalyticsQueue,
mockTokenCleanupQueue,
globalMockLogger,
);
const dailyDealCheckCallback = mockCronSchedule.mock.calls[0][1];
await dailyDealCheckCallback();
expect(mockBackgroundJobService.runDailyDealCheck).toHaveBeenCalledTimes(1);
expect(globalMockLogger.error).toHaveBeenCalledWith(
{ err: jobError },
'[BackgroundJob] Cron job for daily deal check failed unexpectedly.',
);
// It should run again, proving the lock was released in the finally block
await dailyDealCheckCallback();
expect(mockBackgroundJobService.runDailyDealCheck).toHaveBeenCalledTimes(2);
});
it('should handle errors in the daily deal check cron wrapper', async () => {
// Arrange: Mock the service to throw a non-Error object
const jobError = 'a string error';
vi.mocked(mockBackgroundJobService.runDailyDealCheck).mockRejectedValue(jobError);
startBackgroundJobs(
mockBackgroundJobService,
mockAnalyticsQueue,
mockWeeklyAnalyticsQueue,
mockTokenCleanupQueue,
globalMockLogger,
);
// Act
const dailyDealCheckCallback = mockCronSchedule.mock.calls[0][1];
await dailyDealCheckCallback();
// Assert: Verify it hits the internal catch block
expect(globalMockLogger.error).toHaveBeenCalledWith(
{ err: jobError },
'[BackgroundJob] Cron job for daily deal check failed unexpectedly.',
);
});
it('should prevent runDailyDealCheck from running if it is already in progress', async () => {
// Use fake timers to control promise resolution
vi.useFakeTimers();
// Make the first call hang indefinitely
vi.mocked(mockBackgroundJobService.runDailyDealCheck).mockReturnValue(new Promise(() => {}));
startBackgroundJobs(
mockBackgroundJobService,
mockAnalyticsQueue,
mockWeeklyAnalyticsQueue,
mockTokenCleanupQueue,
globalMockLogger,
);
const dailyDealCheckCallback = mockCronSchedule.mock.calls[0][1];
// Trigger the job once, it will hang
const firstCall = dailyDealCheckCallback();
// Trigger it a second time immediately
const secondCall = dailyDealCheckCallback();
await Promise.all([firstCall, secondCall]);
// The service method should only have been called once
expect(mockBackgroundJobService.runDailyDealCheck).toHaveBeenCalledTimes(1);
});
it('should handle unhandled rejections in the daily deal check cron wrapper', async () => {
// Use fake timers to control promise resolution
vi.useFakeTimers();
// Make the first call hang indefinitely to keep the lock active
vi.mocked(mockBackgroundJobService.runDailyDealCheck).mockReturnValue(new Promise(() => {}));
// Make logger.warn throw an error. This is outside the main try/catch in the cron job.
const warnError = new Error('Logger warn failed');
vi.mocked(globalMockLogger.warn).mockImplementation(() => {
throw warnError;
});
startBackgroundJobs(
mockBackgroundJobService,
mockAnalyticsQueue,
mockWeeklyAnalyticsQueue,
mockTokenCleanupQueue,
globalMockLogger,
);
const dailyDealCheckCallback = mockCronSchedule.mock.calls[0][1];
// Trigger the job once, it will hang and set the lock. Then trigger it a second time
// to enter the `if (isDailyDealCheckRunning)` block and call the throwing logger.warn.
await Promise.allSettled([dailyDealCheckCallback(), dailyDealCheckCallback()]);
// The outer catch block should have been called with the error from logger.warn
expect(globalMockLogger.error).toHaveBeenCalledWith(
{ err: warnError },
'[BackgroundJob] Unhandled rejection in daily deal check cron wrapper.',
);
});
it('should enqueue an analytics job when the second cron job function is executed', async () => {
startBackgroundJobs(
mockBackgroundJobService,
mockAnalyticsQueue,
mockWeeklyAnalyticsQueue,
mockTokenCleanupQueue,
globalMockLogger,
);
const analyticsJobCallback = mockCronSchedule.mock.calls[1][1];
await analyticsJobCallback();
expect(mockAnalyticsQueue.add).toHaveBeenCalledWith(
'generate-daily-report',
expect.any(Object),
expect.any(Object),
);
});
it('should log an error if enqueuing the analytics job fails', async () => {
const queueError = new Error('Redis is down');
vi.mocked(mockAnalyticsQueue.add).mockRejectedValue(queueError);
startBackgroundJobs(
mockBackgroundJobService,
mockAnalyticsQueue,
mockWeeklyAnalyticsQueue,
mockTokenCleanupQueue,
globalMockLogger,
);
const analyticsJobCallback = mockCronSchedule.mock.calls[1][1];
await analyticsJobCallback();
expect(mockAnalyticsQueue.add).toHaveBeenCalledTimes(1);
expect(globalMockLogger.error).toHaveBeenCalledWith(
{ err: queueError },
'[BackgroundJob] Failed to enqueue daily analytics job.',
);
});
it('should handle errors in the analytics report cron wrapper', async () => {
// Arrange: Mock the queue to throw
const queueError = 'a string error';
vi.mocked(mockAnalyticsQueue.add).mockRejectedValue(queueError);
startBackgroundJobs(
mockBackgroundJobService,
mockAnalyticsQueue,
mockWeeklyAnalyticsQueue,
mockTokenCleanupQueue,
globalMockLogger,
);
// Act
const analyticsJobCallback = mockCronSchedule.mock.calls[1][1];
await analyticsJobCallback();
// Assert
expect(globalMockLogger.error).toHaveBeenCalledWith(
{ err: queueError },
'[BackgroundJob] Failed to enqueue daily analytics job.',
);
});
it('should handle unhandled rejections in the analytics report cron wrapper', async () => {
const infoError = new Error('Logger info failed');
startBackgroundJobs(
mockBackgroundJobService,
mockAnalyticsQueue,
mockWeeklyAnalyticsQueue,
mockTokenCleanupQueue,
globalMockLogger,
);
// Make logger.info throw, which is outside the try/catch in the cron job.
const infoSpy = vi.spyOn(globalMockLogger, 'info').mockImplementation(() => {
throw infoError;
});
const analyticsJobCallback = mockCronSchedule.mock.calls[1][1];
await analyticsJobCallback();
expect(globalMockLogger.error).toHaveBeenCalledWith(
{ err: infoError }, // The implementation uses `err` key here
'[BackgroundJob] Unhandled rejection in analytics report cron wrapper.',
);
infoSpy.mockRestore();
});
it('should enqueue a weekly analytics job when the third cron job function is executed', async () => {
startBackgroundJobs(
mockBackgroundJobService,
mockAnalyticsQueue,
mockWeeklyAnalyticsQueue,
mockTokenCleanupQueue,
globalMockLogger,
);
// The weekly job is the third one scheduled
const weeklyAnalyticsJobCallback = mockCronSchedule.mock.calls[2][1];
await weeklyAnalyticsJobCallback();
expect(mockWeeklyAnalyticsQueue.add).toHaveBeenCalledWith(
'generate-weekly-report',
{ reportYear: 2024, reportWeek: 42 }, // Values from the mocked dateUtils
{ jobId: 'weekly-report-2024-42' },
);
});
it('should log an error if enqueuing the weekly analytics job fails', async () => {
const queueError = new Error('Redis is down for weekly job');
vi.mocked(mockWeeklyAnalyticsQueue.add).mockRejectedValue(queueError);
startBackgroundJobs(
mockBackgroundJobService,
mockAnalyticsQueue,
mockWeeklyAnalyticsQueue,
mockTokenCleanupQueue,
globalMockLogger,
);
const weeklyAnalyticsJobCallback = mockCronSchedule.mock.calls[2][1];
await weeklyAnalyticsJobCallback();
expect(globalMockLogger.error).toHaveBeenCalledWith(
{ err: queueError },
'[BackgroundJob] Failed to enqueue weekly analytics job.',
);
});
it('should handle errors in the weekly analytics report cron wrapper', async () => {
const queueError = 'a string error';
vi.mocked(mockWeeklyAnalyticsQueue.add).mockRejectedValue(queueError);
startBackgroundJobs(
mockBackgroundJobService,
mockAnalyticsQueue,
mockWeeklyAnalyticsQueue,
mockTokenCleanupQueue,
globalMockLogger,
);
// Act
const weeklyAnalyticsJobCallback = mockCronSchedule.mock.calls[2][1];
await weeklyAnalyticsJobCallback();
// Assert
expect(globalMockLogger.error).toHaveBeenCalledWith(
{ err: queueError },
'[BackgroundJob] Failed to enqueue weekly analytics job.',
);
});
it('should handle unhandled rejections in the weekly analytics report cron wrapper', async () => {
const infoError = new Error('Logger info failed');
startBackgroundJobs(
mockBackgroundJobService,
mockAnalyticsQueue,
mockWeeklyAnalyticsQueue,
mockTokenCleanupQueue,
globalMockLogger,
);
const infoSpy = vi.spyOn(globalMockLogger, 'info').mockImplementation(() => {
throw infoError;
});
const weeklyAnalyticsJobCallback = mockCronSchedule.mock.calls[2][1];
await weeklyAnalyticsJobCallback();
expect(globalMockLogger.error).toHaveBeenCalledWith(
{ err: infoError },
'[BackgroundJob] Unhandled rejection in weekly analytics report cron wrapper.',
);
infoSpy.mockRestore();
});
it('should enqueue a token cleanup job when the fourth cron job function is executed', async () => {
startBackgroundJobs(
mockBackgroundJobService,
mockAnalyticsQueue,
mockWeeklyAnalyticsQueue,
mockTokenCleanupQueue,
globalMockLogger,
);
const tokenCleanupCallback = mockCronSchedule.mock.calls[3][1];
await tokenCleanupCallback();
expect(mockTokenCleanupQueue.add).toHaveBeenCalledWith(
'cleanup-tokens',
expect.any(Object),
expect.any(Object),
);
});
it('should log an error if enqueuing the token cleanup job fails', async () => {
const queueError = new Error('Redis is down for token cleanup');
vi.mocked(mockTokenCleanupQueue.add).mockRejectedValue(queueError);
startBackgroundJobs(
mockBackgroundJobService,
mockAnalyticsQueue,
mockWeeklyAnalyticsQueue,
mockTokenCleanupQueue,
globalMockLogger,
);
const tokenCleanupCallback = mockCronSchedule.mock.calls[3][1];
await tokenCleanupCallback();
expect(globalMockLogger.error).toHaveBeenCalledWith(
{ err: queueError },
'[BackgroundJob] Failed to enqueue token cleanup job.',
);
});
it('should handle errors in the token cleanup cron wrapper', async () => {
const queueError = 'a string error';
vi.mocked(mockTokenCleanupQueue.add).mockRejectedValue(queueError);
startBackgroundJobs(
mockBackgroundJobService,
mockAnalyticsQueue,
mockWeeklyAnalyticsQueue,
mockTokenCleanupQueue,
globalMockLogger,
);
// Act
const tokenCleanupCallback = mockCronSchedule.mock.calls[3][1];
await tokenCleanupCallback();
// Assert
expect(globalMockLogger.error).toHaveBeenCalledWith(
{ err: queueError },
'[BackgroundJob] Failed to enqueue token cleanup job.',
);
});
it('should handle unhandled rejections in the token cleanup cron wrapper', async () => {
const infoError = new Error('Logger info failed');
startBackgroundJobs(
mockBackgroundJobService,
mockAnalyticsQueue,
mockWeeklyAnalyticsQueue,
mockTokenCleanupQueue,
globalMockLogger,
);
const infoSpy = vi.spyOn(globalMockLogger, 'info').mockImplementation(() => {
throw infoError;
});
const tokenCleanupCallback = mockCronSchedule.mock.calls[3][1];
await tokenCleanupCallback();
expect(globalMockLogger.error).toHaveBeenCalledWith(
{ err: infoError },
'[BackgroundJob] Unhandled rejection in token cleanup cron wrapper.',
);
infoSpy.mockRestore();
});
it('should log a critical error if scheduling fails', () => {
mockCronSchedule.mockImplementation(() => {
throw new Error('Scheduling failed');
});
startBackgroundJobs(
mockBackgroundJobService,
mockAnalyticsQueue,
mockWeeklyAnalyticsQueue,
mockTokenCleanupQueue,
globalMockLogger,
);
expect(globalMockLogger.error).toHaveBeenCalledWith(
{ err: expect.any(Error) },
'[BackgroundJob] Failed to schedule a cron job. This is a critical setup error.',
);
});
});
});