// src/services/backgroundJobService.test.ts import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; // 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(), }, })); // 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 { 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 { user_id: 'user-1', email: 'user1@test.com', full_name: 'User One', master_item_id: 1, item_name: 'Apples', best_price_in_cents: 199, store_name: 'Green Grocer', flyer_id: 101, valid_to: '2024-10-20' }, // Deals for user-2 { user_id: 'user-2', email: 'user2@test.com', full_name: 'User Two', 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', master_item_id: 3, item_name: 'Bread', best_price_in_cents: 250, store_name: 'Bakery', flyer_id: 103, valid_to: '2024-10-22' }, ]; // This mockLogger is for the service instance, not the global one used by cron.schedule const mockServiceLogger = { info: vi.fn(), error: vi.fn(), warn: vi.fn(), debug: vi.fn(), }; // Instantiate the service with mock dependencies for each test run const service = new BackgroundJobService(mockPersonalizationRepo as any, mockNotificationRepo as any, 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', }, { user_id: 'user-2', content: 'You have 2 new deal(s) on your watched items!', link_url: '/dashboard/deals', } ])); }); 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( expect.stringContaining('Failed to process deals for user user-1'), expect.any(Object) ); // 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); expect(mockNotificationRepo.createBulkNotifications.mock.calls[0][0]).toHaveLength(1); // Only one notification created expect(mockNotificationRepo.createBulkNotifications.mock.calls[0][0][0].user_id).toBe('user-2'); }); 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'); expect(mockServiceLogger.error).toHaveBeenCalledWith( '[BackgroundJob] A critical error occurred during the daily deal check:', expect.any(Object) ); }); 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( '[BackgroundJob] A critical error occurred during the daily deal check:', { error: dbError } ); }); }); describe('startBackgroundJobs', () => { const mockBackgroundJobService = { runDailyDealCheck: vi.fn(), } as unknown as BackgroundJobService; const mockAnalyticsQueue = { add: vi.fn(), } as unknown as Queue; const mockWeeklyAnalyticsQueue = { add: vi.fn(), } as unknown as Queue; beforeEach(() => { vi.clearAllMocks(); // Clear global mock logger calls too mockCronSchedule.mockClear(); vi.mocked(mockBackgroundJobService.runDailyDealCheck).mockClear(); vi.mocked(mockAnalyticsQueue.add).mockClear(); vi.mocked(mockWeeklyAnalyticsQueue.add).mockClear(); }); it('should schedule three cron jobs with the correct schedules', () => { startBackgroundJobs(mockBackgroundJobService, mockAnalyticsQueue, mockWeeklyAnalyticsQueue); expect(mockCronSchedule).toHaveBeenCalledTimes(3); 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)); }); it('should call runDailyDealCheck when the first cron job function is executed', async () => { startBackgroundJobs(mockBackgroundJobService, mockAnalyticsQueue, mockWeeklyAnalyticsQueue); // 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); const dailyDealCheckCallback = mockCronSchedule.mock.calls[0][1]; await dailyDealCheckCallback(); expect(mockBackgroundJobService.runDailyDealCheck).toHaveBeenCalledTimes(1); expect(globalMockLogger.error).toHaveBeenCalledWith( '[BackgroundJob] Cron job for daily deal check failed unexpectedly.', { error: jobError } ); // It should run again, proving the lock was released in the finally block await dailyDealCheckCallback(); expect(mockBackgroundJobService.runDailyDealCheck).toHaveBeenCalledTimes(2); }); 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); 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 enqueue an analytics job when the second cron job function is executed', async () => { startBackgroundJobs(mockBackgroundJobService, mockAnalyticsQueue, mockWeeklyAnalyticsQueue); 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); const analyticsJobCallback = mockCronSchedule.mock.calls[1][1]; await analyticsJobCallback(); expect(mockAnalyticsQueue.add).toHaveBeenCalledTimes(1); expect(globalMockLogger.error).toHaveBeenCalledWith('[BackgroundJob] Failed to enqueue daily analytics job.', { error: queueError }); }); it('should enqueue a weekly analytics job when the third cron job function is executed', async () => { startBackgroundJobs(mockBackgroundJobService, mockAnalyticsQueue, mockWeeklyAnalyticsQueue); // 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); const weeklyAnalyticsJobCallback = mockCronSchedule.mock.calls[2][1]; await weeklyAnalyticsJobCallback(); expect(globalMockLogger.error).toHaveBeenCalledWith('[BackgroundJob] Failed to enqueue weekly analytics job.', { error: queueError }); }); }); });