Files
flyer-crawler.projectium.com/src/services/backgroundJobService.test.ts
Torben Sorensen edb0f8a38c
Some checks failed
Deploy to Test Environment / deploy-to-test (push) Has been cancelled
Refactor database tests to improve type safety and error handling
- Updated mock implementations in various database service tests to use specific types (Pool, PoolClient) instead of 'any'.
- Enhanced error handling in tests by explicitly defining error types and codes, improving clarity and maintainability.
- Removed unnecessary eslint-disable comments related to 'any' usage.
- Cleaned up transaction mock implementations to ensure proper type casting.
- Adjusted error handling in the UserRepository to provide more specific error messages for foreign key constraints.
- Improved test assertions to ensure they are more robust and type-safe.
2025-12-14 13:29:38 -08:00

301 lines
13 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 type { WatchedItemDeal } from '../types';
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' },
];
// 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',
},
{
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(
{ 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);
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(
{ error: 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>;
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, globalMockLogger);
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, 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, 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 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, 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 enqueue an analytics job when the second cron job function is executed', async () => {
startBackgroundJobs(mockBackgroundJobService, mockAnalyticsQueue, mockWeeklyAnalyticsQueue, 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, 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 enqueue a weekly analytics job when the third cron job function is executed', async () => {
startBackgroundJobs(mockBackgroundJobService, mockAnalyticsQueue, mockWeeklyAnalyticsQueue, 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, globalMockLogger);
const weeklyAnalyticsJobCallback = mockCronSchedule.mock.calls[2][1];
await weeklyAnalyticsJobCallback();
expect(globalMockLogger.error).toHaveBeenCalledWith({ err: queueError }, '[BackgroundJob] Failed to enqueue weekly analytics job.');
});
});
});