// src/services/workers.server.test.ts import { describe, it, expect, vi, beforeEach } from 'vitest'; import type { Job } from 'bullmq'; // --- Hoisted Mocks --- const mocks = vi.hoisted(() => { // This object will store the processor functions captured from the worker constructors. const capturedProcessors: Record Promise> = {}; return { sendEmail: vi.fn(), unlink: vi.fn(), processFlyerJob: vi.fn(), capturedProcessors, deleteExpiredResetTokens: vi.fn(), // Mock the Worker constructor to capture the processor function. It must be a // `function` and not an arrow function so it can be called with `new`. MockWorker: vi.fn(function (name: string, processor: (job: Job) => Promise) { if (processor) { capturedProcessors[name] = processor; } // Return a mock worker instance, though it's not used in this test file. return { on: vi.fn(), close: vi.fn() }; }), }; }); // --- Mock Modules --- vi.mock('./emailService.server', async (importOriginal) => { const actual = await importOriginal(); return { ...actual, // We only need to mock the specific function being called by the worker. // The rest of the module can retain its original implementation if needed elsewhere. sendEmail: mocks.sendEmail, }; }); // The workers use an `fsAdapter`. We can mock the underlying `fsPromises` // that the adapter is built from in queueService.server.ts. vi.mock('node:fs/promises', () => ({ default: { unlink: mocks.unlink, // Add other fs functions if needed by other tests readdir: vi.fn(), }, })); vi.mock('./logger.server', () => ({ logger: { info: vi.fn(), error: vi.fn(), warn: vi.fn(), debug: vi.fn(), child: vi.fn().mockReturnThis(), }, })); vi.mock('./db/index.db', () => ({ userRepo: { deleteExpiredResetTokens: mocks.deleteExpiredResetTokens, }, })); // Mock bullmq to capture the processor functions passed to the Worker constructor import { logger as mockLogger } from './logger.server'; vi.mock('bullmq', () => ({ Worker: mocks.MockWorker, // FIX: Use a standard function for the mock constructor to allow `new Queue(...)` to work. Queue: vi.fn(function () { return { add: vi.fn() }; }), })); // Mock flyerProcessingService.server as flyerWorker depends on it vi.mock('./flyerProcessingService.server', () => ({ FlyerProcessingService: class { processJob = mocks.processFlyerJob; }, })); // Mock flyerDataTransformer as it's a dependency of FlyerProcessingService vi.mock('./flyerDataTransformer', () => ({ FlyerDataTransformer: class { transform = vi.fn(); // Mock transform method }, })); // Helper to create a mock BullMQ Job object const createMockJob = (data: T): Job => { return { id: 'job-1', data, updateProgress: vi.fn().mockResolvedValue(undefined), log: vi.fn().mockResolvedValue(undefined), opts: { attempts: 3 }, attemptsMade: 1, trace: vi.fn().mockResolvedValue(undefined), moveToCompleted: vi.fn().mockResolvedValue(undefined), moveToFailed: vi.fn().mockResolvedValue(undefined), } as unknown as Job; }; describe('Queue Workers', () => { // These will hold the captured processor functions for each test. let flyerProcessor: (job: Job) => Promise; let emailProcessor: (job: Job) => Promise; let analyticsProcessor: (job: Job) => Promise; let cleanupProcessor: (job: Job) => Promise; let weeklyAnalyticsProcessor: (job: Job) => Promise; let tokenCleanupProcessor: (job: Job) => Promise; beforeEach(async () => { vi.clearAllMocks(); // Reset default mock implementations for hoisted mocks mocks.sendEmail.mockResolvedValue(undefined); mocks.unlink.mockResolvedValue(undefined); mocks.processFlyerJob.mockResolvedValue({ flyerId: 123 }); // Default success for flyer processing mocks.deleteExpiredResetTokens.mockResolvedValue(5); // Reset modules to re-evaluate the workers.server.ts file with fresh mocks. // This ensures that new worker instances are created and their processors are captured for each test. vi.resetModules(); // Dynamically import the module under test AFTER mocks are reset. // This will trigger the instantiation of the workers, and our mocked Worker constructor will capture the processors. await import('./workers.server'); // Re-capture the processors for each test to ensure isolation. flyerProcessor = mocks.capturedProcessors['flyer-processing']; emailProcessor = mocks.capturedProcessors['email-sending']; analyticsProcessor = mocks.capturedProcessors['analytics-reporting']; cleanupProcessor = mocks.capturedProcessors['file-cleanup']; weeklyAnalyticsProcessor = mocks.capturedProcessors['weekly-analytics-reporting']; tokenCleanupProcessor = mocks.capturedProcessors['token-cleanup']; }); describe('flyerWorker', () => { it('should call flyerProcessingService.processJob with the job data', async () => { const jobData = { filePath: '/tmp/flyer.pdf', originalFileName: 'flyer.pdf', checksum: 'abc', }; const job = createMockJob(jobData); await flyerProcessor(job); expect(mocks.processFlyerJob).toHaveBeenCalledTimes(1); expect(mocks.processFlyerJob).toHaveBeenCalledWith(job); }); it('should re-throw an error if flyerProcessingService.processJob fails', async () => { const job = createMockJob({ filePath: '/tmp/fail.pdf', originalFileName: 'fail.pdf', checksum: 'def', }); const processingError = new Error('Flyer processing failed'); mocks.processFlyerJob.mockRejectedValue(processingError); await expect(flyerProcessor(job)).rejects.toThrow('Flyer processing failed'); }); }); describe('emailWorker', () => { it('should call emailService.sendEmail with the job data', async () => { const jobData = { to: 'test@example.com', subject: 'Test Email', html: '

Hello

', text: 'Hello', }; const job = createMockJob(jobData); await emailProcessor(job); expect(mocks.sendEmail).toHaveBeenCalledTimes(1); // The implementation passes the logger as the second argument expect(mocks.sendEmail).toHaveBeenCalledWith(jobData, expect.anything()); }); it('should log and re-throw an error if sendEmail fails with a non-Error object', async () => { const job = createMockJob({ to: 'fail@example.com', subject: 'fail', html: '', text: '' }); const emailError = 'SMTP server is down'; // Reject with a string mocks.sendEmail.mockRejectedValue(emailError); await expect(emailProcessor(job)).rejects.toThrow(emailError); // The worker should wrap the string in an Error object for logging expect(mockLogger.error).toHaveBeenCalledWith( { err: new Error(emailError), jobData: job.data }, `[EmailWorker] Job ${job.id} failed. Attempt ${job.attemptsMade}/${job.opts.attempts}.`, ); }); it('should re-throw an error if sendEmail fails', async () => { const job = createMockJob({ to: 'fail@example.com', subject: 'fail', html: '', text: '' }); const emailError = new Error('SMTP server is down'); mocks.sendEmail.mockRejectedValue(emailError); await expect(emailProcessor(job)).rejects.toThrow('SMTP server is down'); expect(mockLogger.error).toHaveBeenCalledWith( { err: emailError, jobData: job.data }, `[EmailWorker] Job ${job.id} failed. Attempt ${job.attemptsMade}/${job.opts.attempts}.`, ); }); }); describe('analyticsWorker', () => { it('should complete successfully for a valid report date', async () => { vi.useFakeTimers(); const job = createMockJob({ reportDate: '2024-01-01' }); const promise = analyticsProcessor(job); // Advance timers to simulate the 10-second task completing await vi.advanceTimersByTimeAsync(10000); await promise; // Wait for the promise to resolve // No error should be thrown expect(true).toBe(true); vi.useRealTimers(); }); it('should throw an error if reportDate is "FAIL"', async () => { const job = createMockJob({ reportDate: 'FAIL' }); await expect(analyticsProcessor(job)).rejects.toThrow( 'This is a test failure for the analytics job.', ); }); }); describe('cleanupWorker', () => { it('should call unlink for each path provided in the job data', async () => { const jobData = { flyerId: 123, paths: ['/tmp/file1.jpg', '/tmp/file2.pdf'], }; const job = createMockJob(jobData); mocks.unlink.mockResolvedValue(undefined); await cleanupProcessor(job); expect(mocks.unlink).toHaveBeenCalledTimes(2); expect(mocks.unlink).toHaveBeenCalledWith('/tmp/file1.jpg'); expect(mocks.unlink).toHaveBeenCalledWith('/tmp/file2.pdf'); }); it('should not throw an error if a file is already deleted (ENOENT)', async () => { const jobData = { flyerId: 123, paths: ['/tmp/existing.jpg', '/tmp/already-deleted.jpg'], }; const job = createMockJob(jobData); // Use the built-in NodeJS.ErrnoException type for mock system errors. const enoentError: NodeJS.ErrnoException = new Error('File not found'); enoentError.code = 'ENOENT'; // First call succeeds, second call fails with ENOENT mocks.unlink.mockResolvedValueOnce(undefined).mockRejectedValueOnce(enoentError); // The processor should complete without throwing await expect(cleanupProcessor(job)).resolves.toBeUndefined(); expect(mocks.unlink).toHaveBeenCalledTimes(2); }); it('should re-throw an error for issues other than ENOENT (e.g., permissions)', async () => { const jobData = { flyerId: 123, paths: ['/tmp/protected-file.jpg'], }; const job = createMockJob(jobData); // Use the built-in NodeJS.ErrnoException type for mock system errors. const permissionError: NodeJS.ErrnoException = new Error('Permission denied'); permissionError.code = 'EACCES'; mocks.unlink.mockRejectedValue(permissionError); await expect(cleanupProcessor(job)).rejects.toThrow('Permission denied'); // Verify the error was logged by the worker's catch block expect(mockLogger.error).toHaveBeenCalledWith( { err: permissionError }, expect.stringContaining( `[CleanupWorker] Job ${job.id} for flyer ${job.data.flyerId} failed.`, ), ); }); }); describe('weeklyAnalyticsWorker', () => { it('should complete successfully for a valid report date', async () => { vi.useFakeTimers(); const job = createMockJob({ reportYear: 2024, reportWeek: 1 }); const promise = weeklyAnalyticsProcessor(job); // Advance timers to simulate the 30-second task completing await vi.advanceTimersByTimeAsync(30000); await promise; // Wait for the promise to resolve // No error should be thrown expect(true).toBe(true); vi.useRealTimers(); }); it('should re-throw an error if the job fails', async () => { vi.useFakeTimers(); const job = createMockJob({ reportYear: 2024, reportWeek: 1 }); // Mock the internal logic to throw an error const originalSetTimeout = setTimeout; vi.spyOn(global, 'setTimeout').mockImplementation((callback, ms) => { if (ms === 30000) { // Target the simulated delay throw new Error('Weekly analytics job failed'); } return originalSetTimeout(callback, ms); }); await expect(weeklyAnalyticsProcessor(job)).rejects.toThrow('Weekly analytics job failed'); vi.useRealTimers(); vi.restoreAllMocks(); // Restore setTimeout mock }); }); describe('tokenCleanupWorker', () => { it('should call userRepo.deleteExpiredResetTokens and return the count', async () => { const job = createMockJob({ timestamp: new Date().toISOString() }); mocks.deleteExpiredResetTokens.mockResolvedValue(10); const result = await tokenCleanupProcessor(job); expect(mocks.deleteExpiredResetTokens).toHaveBeenCalledTimes(1); expect(result).toEqual({ deletedCount: 10 }); }); it('should re-throw an error if the database call fails', async () => { const job = createMockJob({ timestamp: new Date().toISOString() }); const dbError = new Error('DB cleanup failed'); mocks.deleteExpiredResetTokens.mockRejectedValue(dbError); await expect(tokenCleanupProcessor(job)).rejects.toThrow(dbError); }); }); });