// src/services/flyerProcessingService.server.test.ts import { describe, it, expect, vi, beforeEach, type Mocked } from 'vitest'; import { Job, UnrecoverableError } from 'bullmq'; import { AiFlyerDataSchema } from '../types/ai'; import type { FlyerInsert } from '../types'; import type { CleanupJobData, FlyerJobData } from '../types/job-data'; // 1. Create hoisted mocks FIRST const mocks = vi.hoisted(() => ({ unlink: vi.fn(), readdir: vi.fn(), execAsync: vi.fn(), mockAdminLogActivity: vi.fn(), })); // 2. Mock modules using the hoisted variables vi.mock('util', () => ({ promisify: () => mocks.execAsync })); vi.mock('node:fs/promises', async (importOriginal) => { const actual = await importOriginal(); return { ...actual, default: actual, // Ensure default export exists unlink: mocks.unlink, readdir: mocks.readdir, }; }); // Import service and dependencies (FlyerJobData already imported from types above) import { FlyerProcessingService } from './flyerProcessingService.server'; import * as db from './db/index.db'; import { createFlyerAndItems } from './db/flyer.db'; import { createMockFlyer } from '../tests/utils/mockFactories'; import { FlyerDataTransformer } from './flyerDataTransformer'; import { AiDataValidationError, PdfConversionError, UnsupportedFileTypeError, TransformationError, DatabaseError, } from './processingErrors'; import { NotFoundError } from './db/errors.db'; import { FlyerFileHandler } from './flyerFileHandler.server'; import { FlyerAiProcessor } from './flyerAiProcessor.server'; import type { IFileSystem, ICommandExecutor } from './flyerFileHandler.server'; import { generateFlyerIcon } from '../utils/imageProcessor'; import type { AIService } from './aiService.server'; // Mock image processor functions vi.mock('../utils/imageProcessor', () => ({ generateFlyerIcon: vi.fn(), })); // Mock dependencies vi.mock('./aiService.server', () => ({ aiService: { extractCoreDataFromFlyerImage: vi.fn(), }, })); vi.mock('./db/flyer.db', () => ({ createFlyerAndItems: vi.fn(), })); vi.mock('./db/index.db', () => ({ personalizationRepo: { getAllMasterItems: vi.fn() }, adminRepo: { logActivity: vi.fn() }, flyerRepo: { getFlyerById: vi.fn() }, withTransaction: vi.fn(), })); vi.mock('./db/admin.db', () => ({ AdminRepository: vi.fn().mockImplementation(function () { return { logActivity: mocks.mockAdminLogActivity }; }), })); vi.mock('./logger.server', () => ({ logger: { info: vi.fn(), error: vi.fn(), warn: vi.fn(), debug: vi.fn(), child: vi.fn().mockReturnThis(), }, })); vi.mock('./flyerFileHandler.server'); vi.mock('./flyerAiProcessor.server'); const mockedDb = db as Mocked; describe('FlyerProcessingService', () => { let service: FlyerProcessingService; let mockFileHandler: Mocked; let mockAiProcessor: Mocked; const mockCleanupQueue = { add: vi.fn(), }; beforeEach(() => { vi.clearAllMocks(); // Provide a default mock implementation for withTransaction that just executes the callback. // This is needed for the happy path tests. Tests for transaction failures will override this. vi.mocked(mockedDb.withTransaction).mockImplementation(async (callback: any) => callback({})); // Spy on the real transformer's method and provide a mock implementation. // This is more robust than mocking the entire class constructor. vi.spyOn(FlyerDataTransformer.prototype, 'transform').mockResolvedValue({ flyerData: { file_name: 'test.jpg', image_url: 'https://example.com/test.jpg', icon_url: 'https://example.com/icon.webp', store_name: 'Mock Store', // Add required fields for FlyerInsert type status: 'processed', item_count: 0, valid_from: '2024-01-01', valid_to: '2024-01-07', } as FlyerInsert, // Cast is okay here as it's a mock value itemsForDb: [], }); // Default mock for readdir returns an empty array of Dirent-like objects. mocks.readdir.mockResolvedValue([]); // Mock the file system adapter that will be passed to the service const mockFs: IFileSystem = { readdir: mocks.readdir, unlink: mocks.unlink, }; mockFileHandler = new FlyerFileHandler(mockFs, vi.fn()) as Mocked; mockAiProcessor = new FlyerAiProcessor( {} as AIService, mockedDb.personalizationRepo, ) as Mocked; // Instantiate the service with all its dependencies mocked service = new FlyerProcessingService( mockFileHandler, mockAiProcessor, mockFs, mockCleanupQueue, new FlyerDataTransformer(), ); // Provide default successful mock implementations for dependencies mockAiProcessor.extractAndValidateData.mockResolvedValue({ data: { store_name: 'Mock Store', valid_from: '2024-01-01', valid_to: '2024-01-07', store_address: '123 Mock St', items: [ { item: 'Test Item', price_display: '$1.99', price_in_cents: 199, quantity: 'each', category_name: 'Test Category', master_item_id: 1, }, ], }, needsReview: false, }); mockFileHandler.prepareImageInputs.mockResolvedValue({ imagePaths: [{ path: '/tmp/flyer.jpg', mimetype: 'image/jpeg' }], createdImagePaths: [], }); vi.mocked(createFlyerAndItems).mockResolvedValue({ flyer: createMockFlyer({ flyer_id: 1, file_name: 'test.jpg', image_url: 'https://example.com/test.jpg', item_count: 1, }), items: [], }); vi.mocked(mockedDb.adminRepo.logActivity).mockResolvedValue(); // FIX: Provide a default mock for getAllMasterItems to prevent a TypeError on `.length`. vi.mocked(mockedDb.personalizationRepo.getAllMasterItems).mockResolvedValue([]); }); beforeEach(() => { vi.mocked(generateFlyerIcon).mockResolvedValue('icon-flyer.webp'); }); const createMockJob = (data: Partial): Job => { return { id: 'job-1', data: { filePath: '/tmp/flyer.jpg', originalFileName: 'flyer.jpg', checksum: 'checksum-123', baseUrl: 'https://example.com', ...data, }, updateProgress: vi.fn(), opts: { attempts: 3 }, attemptsMade: 1, } as unknown as Job; }; const createMockCleanupJob = (data: CleanupJobData): Job => { return { id: `cleanup-job-${data.flyerId}`, data, opts: { attempts: 3 }, attemptsMade: 1, updateProgress: vi.fn(), } as unknown as Job; }; describe('processJob (Orchestrator)', () => { it('should process an image file successfully and enqueue a cleanup job', async () => { const job = createMockJob({ filePath: '/tmp/flyer.jpg', originalFileName: 'flyer.jpg' }); // Arrange: Mock dependencies to simulate a successful run mockFileHandler.prepareImageInputs.mockResolvedValue({ imagePaths: [{ path: '/tmp/flyer-processed.jpeg', mimetype: 'image/jpeg' }], createdImagePaths: ['/tmp/flyer-processed.jpeg'], }); vi.mocked(generateFlyerIcon).mockResolvedValue('icon-flyer.webp'); const result = await service.processJob(job); expect(result).toEqual({ flyerId: 1 }); // 1. File handler was called expect(mockFileHandler.prepareImageInputs).toHaveBeenCalledWith(job.data.filePath, job, expect.any(Object)); // 2. AI processor was called expect(mockAiProcessor.extractAndValidateData).toHaveBeenCalledTimes(1); // 3. Icon was generated from the processed image expect(generateFlyerIcon).toHaveBeenCalledWith('/tmp/flyer-processed.jpeg', '/tmp/icons', expect.any(Object)); // 4. Transformer was called with the correct filenames expect(FlyerDataTransformer.prototype.transform).toHaveBeenCalledWith( expect.any(Object), // aiResult 'flyer.jpg', // originalFileName 'flyer-processed.jpeg', // imageFileName 'icon-flyer.webp', // iconFileName 'checksum-123', // checksum undefined, // userId expect.any(Object), // logger 'https://example.com', // baseUrl ); // 5. DB transaction was initiated expect(mockedDb.withTransaction).toHaveBeenCalledTimes(1); expect(createFlyerAndItems).toHaveBeenCalledTimes(1); expect(mocks.mockAdminLogActivity).toHaveBeenCalledTimes(1); // 6. Cleanup job was enqueued with all generated files expect(mockCleanupQueue.add).toHaveBeenCalledWith( 'cleanup-flyer-files', { flyerId: 1, paths: [ '/tmp/flyer.jpg', // original job path '/tmp/flyer-processed.jpeg', // from prepareImageInputs '/tmp/icons/icon-flyer.webp', // from generateFlyerIcon ], }, expect.any(Object), ); }); it('should convert a PDF, process its images, and enqueue a cleanup job for all files', async () => { const job = createMockJob({ filePath: '/tmp/flyer.pdf', originalFileName: 'flyer.pdf' }); // Mock the file handler to return multiple created paths const createdPaths = ['/tmp/flyer-1.jpg', '/tmp/flyer-2.jpg']; mockFileHandler.prepareImageInputs.mockResolvedValue({ imagePaths: [ { path: '/tmp/flyer-1.jpg', mimetype: 'image/jpeg' }, { path: '/tmp/flyer-2.jpg', mimetype: 'image/jpeg' }, ], createdImagePaths: createdPaths, }); vi.mocked(generateFlyerIcon).mockResolvedValue('icon-flyer-1.webp'); await service.processJob(job); // Verify transaction and inner calls expect(mockedDb.withTransaction).toHaveBeenCalledTimes(1); expect(mockFileHandler.prepareImageInputs).toHaveBeenCalledWith('/tmp/flyer.pdf', job, expect.any(Object)); expect(mockAiProcessor.extractAndValidateData).toHaveBeenCalledTimes(1); expect(createFlyerAndItems).toHaveBeenCalledTimes(1); // Verify icon generation was called for the first page expect(generateFlyerIcon).toHaveBeenCalledWith('/tmp/flyer-1.jpg', '/tmp/icons', expect.any(Object)); // Verify cleanup job includes original PDF and all generated/processed images expect(mockCleanupQueue.add).toHaveBeenCalledWith( 'cleanup-flyer-files', { flyerId: 1, paths: [ '/tmp/flyer.pdf', // original job path '/tmp/flyer-1.jpg', // from prepareImageInputs '/tmp/flyer-2.jpg', // from prepareImageInputs '/tmp/icons/icon-flyer-1.webp', // from generateFlyerIcon ], }, expect.any(Object), ); }); it('should throw an error and not enqueue cleanup if the AI service fails', async () => { const job = createMockJob({}); const { logger } = await import('./logger.server'); const aiError = new Error('AI model exploded'); mockAiProcessor.extractAndValidateData.mockRejectedValue(aiError); await expect(service.processJob(job)).rejects.toThrow('AI model exploded'); expect(job.updateProgress).toHaveBeenCalledWith({ errorCode: 'UNKNOWN_ERROR', message: 'AI model exploded', stages: [ { name: 'Preparing Inputs', status: 'completed', critical: true, detail: '1 page(s) ready for AI.' }, { name: 'Extracting Data with AI', status: 'failed', critical: true, detail: 'AI model exploded' }, { name: 'Transforming AI Data', status: 'skipped', critical: true }, { name: 'Saving to Database', status: 'skipped', critical: true }, ], }); // This was a duplicate, fixed. expect(mockCleanupQueue.add).not.toHaveBeenCalled(); expect(logger.warn).toHaveBeenCalledWith( 'Job failed. Temporary files will NOT be cleaned up to allow for manual inspection.', ); }); it('should throw UnrecoverableError for quota issues and not enqueue cleanup', async () => { const job = createMockJob({}); // Simulate an AI error that contains a keyword for unrecoverable errors const quotaError = new Error('AI model quota exceeded'); const { logger } = await import('./logger.server'); mockAiProcessor.extractAndValidateData.mockRejectedValue(quotaError); await expect(service.processJob(job)).rejects.toThrow(UnrecoverableError); expect(job.updateProgress).toHaveBeenCalledWith({ errorCode: 'QUOTA_EXCEEDED', message: 'An AI quota has been exceeded. Please try again later.', stages: [ { name: 'Preparing Inputs', status: 'completed', critical: true, detail: '1 page(s) ready for AI.' }, { name: 'Extracting Data with AI', status: 'failed', critical: true, detail: 'AI model quota exceeded' }, { name: 'Transforming AI Data', status: 'skipped', critical: true }, { name: 'Saving to Database', status: 'skipped', critical: true }, ], }); expect(mockCleanupQueue.add).not.toHaveBeenCalled(); expect(logger.warn).toHaveBeenCalledWith( 'Job failed. Temporary files will NOT be cleaned up to allow for manual inspection.', ); }); it('should throw PdfConversionError and not enqueue cleanup if PDF conversion fails', async () => { const job = createMockJob({ filePath: '/tmp/bad.pdf', originalFileName: 'bad.pdf' }); const { logger } = await import('./logger.server'); const conversionError = new PdfConversionError('Conversion failed', 'pdftocairo error'); mockFileHandler.prepareImageInputs.mockRejectedValue(conversionError); await expect(service.processJob(job)).rejects.toThrow(conversionError); // Use `toHaveBeenLastCalledWith` to check only the final error payload, ignoring earlier progress updates. expect(job.updateProgress).toHaveBeenLastCalledWith({ errorCode: 'PDF_CONVERSION_FAILED', message: 'The uploaded PDF could not be processed. It might be blank, corrupt, or password-protected.', // This was a duplicate, fixed. stderr: 'pdftocairo error', stages: [ { name: 'Preparing Inputs', status: 'failed', critical: true, detail: 'The uploaded PDF could not be processed. It might be blank, corrupt, or password-protected.' }, { name: 'Extracting Data with AI', status: 'skipped', critical: true }, { name: 'Transforming AI Data', status: 'skipped', critical: true }, { name: 'Saving to Database', status: 'skipped', critical: true }, ], }); expect(mockCleanupQueue.add).not.toHaveBeenCalled(); expect(logger.warn).toHaveBeenCalledWith( 'Job failed. Temporary files will NOT be cleaned up to allow for manual inspection.', ); }); it('should throw AiDataValidationError and not enqueue cleanup if AI validation fails', async () => { const { logger } = await import('./logger.server'); const job = createMockJob({}); const validationError = new AiDataValidationError('Validation failed', {}, {}); mockAiProcessor.extractAndValidateData.mockRejectedValue(validationError); await expect(service.processJob(job)).rejects.toThrow(validationError); // Verify the specific error handling logic in the catch block expect(logger.error).toHaveBeenCalledWith( { err: validationError, errorCode: 'AI_VALIDATION_FAILED', message: "The AI couldn't read the flyer's format. Please try a clearer image or a different flyer.", validationErrors: {}, rawData: {}, stages: expect.any(Array), // Stages will be dynamically generated }, 'A known processing error occurred: AiDataValidationError', ); // Use `toHaveBeenLastCalledWith` to check only the final error payload. // FIX: The payload from AiDataValidationError includes validationErrors and rawData. expect(job.updateProgress).toHaveBeenLastCalledWith({ errorCode: 'AI_VALIDATION_FAILED', message: "The AI couldn't read the flyer's format. Please try a clearer image or a different flyer.", // This was a duplicate, fixed. validationErrors: {}, rawData: {}, stages: [ { name: 'Preparing Inputs', status: 'completed', critical: true, detail: '1 page(s) ready for AI.' }, { name: 'Extracting Data with AI', status: 'failed', critical: true, detail: "The AI couldn't read the flyer's format. Please try a clearer image or a different flyer." }, { name: 'Transforming AI Data', status: 'skipped', critical: true }, { name: 'Saving to Database', status: 'skipped', critical: true }, ], }); expect(mockCleanupQueue.add).not.toHaveBeenCalled(); expect(logger.warn).toHaveBeenCalledWith( 'Job failed. Temporary files will NOT be cleaned up to allow for manual inspection.', ); }); it('should handle convertible image types and include original and converted files in cleanup', async () => { const job = createMockJob({ filePath: '/tmp/flyer.gif', originalFileName: 'flyer.gif' }); const convertedPath = '/tmp/flyer-converted.png'; // Mock the file handler to return the converted path mockFileHandler.prepareImageInputs.mockResolvedValue({ imagePaths: [{ path: convertedPath, mimetype: 'image/png' }], createdImagePaths: [convertedPath], }); vi.mocked(generateFlyerIcon).mockResolvedValue('icon-flyer-converted.webp'); await service.processJob(job); // Verify transaction and inner calls expect(mockedDb.withTransaction).toHaveBeenCalledTimes(1); expect(mockFileHandler.prepareImageInputs).toHaveBeenCalledWith('/tmp/flyer.gif', job, expect.any(Object)); expect(mockAiProcessor.extractAndValidateData).toHaveBeenCalledTimes(1); // Verify icon generation was called for the converted image expect(generateFlyerIcon).toHaveBeenCalledWith(convertedPath, '/tmp/icons', expect.any(Object)); expect(mockCleanupQueue.add).toHaveBeenCalledWith( 'cleanup-flyer-files', { flyerId: 1, paths: [ '/tmp/flyer.gif', // original job path convertedPath, // from prepareImageInputs '/tmp/icons/icon-flyer-converted.webp', // from generateFlyerIcon ], }, expect.any(Object), ); }); it('should throw an error and not enqueue cleanup if the database service fails', async () => { const job = createMockJob({}); const { logger } = await import('./logger.server'); const dbError = new Error('Database transaction failed'); // To test the DB failure, we make the transaction itself fail when called. // This is more realistic than mocking the inner function `createFlyerAndItems`. vi.mocked(mockedDb.withTransaction).mockRejectedValue(dbError); // The service wraps the generic DB error in a DatabaseError. await expect(service.processJob(job)).rejects.toThrow(DatabaseError); // The final progress update should reflect the structured DatabaseError. expect(job.updateProgress).toHaveBeenLastCalledWith({ errorCode: 'DATABASE_ERROR', message: 'A database operation failed. Please try again later.', stages: [ { name: 'Preparing Inputs', status: 'completed', critical: true, detail: '1 page(s) ready for AI.' }, { name: 'Extracting Data with AI', status: 'completed', critical: true, detail: 'Communicating with AI model...' }, { name: 'Transforming AI Data', status: 'completed', critical: true }, { name: 'Saving to Database', status: 'failed', critical: true, detail: 'A database operation failed. Please try again later.' }, ], }); expect(mockCleanupQueue.add).not.toHaveBeenCalled(); expect(logger.warn).toHaveBeenCalledWith( 'Job failed. Temporary files will NOT be cleaned up to allow for manual inspection.', ); }); it('should throw UnsupportedFileTypeError for an unsupported file type', async () => { const job = createMockJob({ filePath: '/tmp/document.txt', originalFileName: 'document.txt', }); const fileTypeError = new UnsupportedFileTypeError('Unsupported file type: .txt. Supported types are PDF, JPG, PNG, WEBP, HEIC, HEIF, GIF, TIFF, SVG, BMP.'); mockFileHandler.prepareImageInputs.mockRejectedValue(fileTypeError); const { logger } = await import('./logger.server'); const reportErrorSpy = vi.spyOn(service as any, '_reportErrorAndThrow'); await expect(service.processJob(job)).rejects.toThrow(UnsupportedFileTypeError); expect(reportErrorSpy).toHaveBeenCalledWith(fileTypeError, job, expect.any(Object), expect.any(Array)); expect(mockCleanupQueue.add).not.toHaveBeenCalled(); expect(logger.warn).toHaveBeenCalledWith( 'Job failed. Temporary files will NOT be cleaned up to allow for manual inspection.', ); }); it('should delegate to _reportErrorAndThrow if icon generation fails', async () => { const job = createMockJob({}); const { logger } = await import('./logger.server'); const iconGenError = new Error('Icon generation failed.'); vi.mocked(generateFlyerIcon).mockRejectedValue(iconGenError); const reportErrorSpy = vi.spyOn(service as any, '_reportErrorAndThrow'); await expect(service.processJob(job)).rejects.toThrow('Icon generation failed.'); expect(reportErrorSpy).toHaveBeenCalledWith(iconGenError, job, expect.any(Object), expect.any(Array)); expect(mockCleanupQueue.add).not.toHaveBeenCalled(); expect(logger.warn).toHaveBeenCalledWith( 'Job failed. Temporary files will NOT be cleaned up to allow for manual inspection.', ); }); }); describe('_reportErrorAndThrow (Error Reporting Logic)', () => { it('should update progress with a generic error and re-throw', async () => { const { logger } = await import('./logger.server'); const job = createMockJob({}); const genericError = new Error('A standard failure'); const initialStages = [ { name: 'Stage 1', status: 'completed', critical: true, detail: 'Done' }, { name: 'Stage 2', status: 'in-progress', critical: true, detail: 'Working...' }, { name: 'Stage 3', status: 'pending', critical: true, detail: 'Waiting...' }, ]; const privateMethod = (service as any)._reportErrorAndThrow; await expect(privateMethod(genericError, job, logger, initialStages)).rejects.toThrow(genericError); expect(job.updateProgress).toHaveBeenCalledWith({ errorCode: 'UNKNOWN_ERROR', message: 'A standard failure', stages: [ { name: 'Stage 1', status: 'completed', critical: true, detail: 'Done' }, { name: 'Stage 2', status: 'failed', critical: true, detail: 'A standard failure' }, { name: 'Stage 3', status: 'skipped', critical: true }, ], }); }); it('should use toErrorPayload for FlyerProcessingError instances', async () => { const { logger } = await import('./logger.server'); const job = createMockJob({}); const validationError = new AiDataValidationError( 'Validation failed', { foo: 'bar' }, { raw: 'data' }, ); const initialStages = [ { name: 'Extracting Data with AI', status: 'in-progress', critical: true, detail: '...' }, ]; const privateMethod = (service as any)._reportErrorAndThrow; await expect(privateMethod(validationError, job, logger, initialStages)).rejects.toThrow(validationError); expect(job.updateProgress).toHaveBeenCalledWith({ errorCode: 'AI_VALIDATION_FAILED', message: "The AI couldn't read the flyer's format. Please try a clearer image or a different flyer.", validationErrors: { foo: 'bar' }, rawData: { raw: 'data' }, stages: [ { name: 'Extracting Data with AI', status: 'failed', critical: true, detail: "The AI couldn't read the flyer's format. Please try a clearer image or a different flyer." }, ], }); }); it('should throw UnrecoverableError for quota messages', async () => { const { logger } = await import('./logger.server'); const job = createMockJob({}); const quotaError = new Error('RESOURCE_EXHAUSTED'); const privateMethod = (service as any)._reportErrorAndThrow; await expect(privateMethod(quotaError, job, logger, [])).rejects.toThrow( UnrecoverableError, ); expect(job.updateProgress).toHaveBeenCalledWith({ errorCode: 'QUOTA_EXCEEDED', message: 'An AI quota has been exceeded. Please try again later.', stages: [], }); }); it('should wrap and throw non-Error objects', async () => { const { logger } = await import('./logger.server'); const job = createMockJob({}); const nonError = 'just a string error'; const privateMethod = (service as any)._reportErrorAndThrow; await expect(privateMethod(nonError, job, logger, [])).rejects.toThrow( 'just a string error', ); }); it('should correctly identify the failed stage based on error code', async () => { const { logger } = await import('./logger.server'); const job = createMockJob({}); const pdfError = new PdfConversionError('PDF failed'); const initialStages = [ { name: 'Preparing Inputs', status: 'in-progress', critical: true, detail: '...' }, { name: 'Extracting Data with AI', status: 'pending', critical: true, detail: '...' }, ]; const privateMethod = (service as any)._reportErrorAndThrow; await expect(privateMethod(pdfError, job, logger, initialStages)).rejects.toThrow(pdfError); expect(job.updateProgress).toHaveBeenCalledWith(expect.objectContaining({ stages: [ { name: 'Preparing Inputs', status: 'failed', critical: true, detail: expect.any(String) }, { name: 'Extracting Data with AI', status: 'skipped', critical: true }, ], })); }); }); describe('processCleanupJob', () => { it('should delete all files successfully', async () => { const job = createMockCleanupJob({ flyerId: 1, paths: ['/tmp/file1', '/tmp/file2'] }); mocks.unlink.mockResolvedValue(undefined); const result = await service.processCleanupJob(job); expect(mocks.unlink).toHaveBeenCalledTimes(2); expect(mocks.unlink).toHaveBeenCalledWith('/tmp/file1'); expect(mocks.unlink).toHaveBeenCalledWith('/tmp/file2'); expect(result).toEqual({ status: 'success', deletedCount: 2 }); }); it('should handle ENOENT errors gracefully and still succeed', async () => { const job = createMockCleanupJob({ flyerId: 1, paths: ['/tmp/file1', '/tmp/file2'] }); const enoentError: NodeJS.ErrnoException = new Error('File not found'); enoentError.code = 'ENOENT'; mocks.unlink.mockResolvedValueOnce(undefined).mockRejectedValueOnce(enoentError); const result = await service.processCleanupJob(job); expect(mocks.unlink).toHaveBeenCalledTimes(2); expect(result).toEqual({ status: 'success', deletedCount: 2 }); // Check that the warning was logged const { logger } = await import('./logger.server'); expect(logger.warn).toHaveBeenCalledWith( 'File not found during cleanup (already deleted?): /tmp/file2', ); }); it('should throw an aggregate error if a non-ENOENT error occurs', async () => { const job = createMockCleanupJob({ flyerId: 1, paths: ['/tmp/file1', '/tmp/permission-denied'], }); const permissionError: NodeJS.ErrnoException = new Error('Permission denied'); permissionError.code = 'EACCES'; mocks.unlink.mockResolvedValueOnce(undefined).mockRejectedValueOnce(permissionError); await expect(service.processCleanupJob(job)).rejects.toThrow( 'Failed to delete 1 file(s): /tmp/permission-denied', ); // Check that the error was logged const { logger } = await import('./logger.server'); expect(logger.error).toHaveBeenCalledWith( expect.objectContaining({ err: permissionError, path: '/tmp/permission-denied' }), 'Failed to delete temporary file.', ); }); it('should skip processing and return "skipped" if paths array is empty and paths cannot be derived', async () => { const job = createMockCleanupJob({ flyerId: 1, paths: [] }); // Mock that the flyer cannot be found in the DB, so paths cannot be derived. vi.mocked(mockedDb.flyerRepo.getFlyerById).mockRejectedValue(new NotFoundError('Not found')); const result = await service.processCleanupJob(job); expect(mocks.unlink).not.toHaveBeenCalled(); expect(result).toEqual({ status: 'skipped', reason: 'no paths derived' }); const { logger } = await import('./logger.server'); // Check for both warnings: the attempt to derive, and the final skip message. expect(logger.warn).toHaveBeenCalledWith( 'Cleanup job for flyer 1 received no paths. Attempting to derive paths from DB.', ); expect(logger.warn).toHaveBeenCalledWith( 'Job received no paths and could not derive any from the database. Skipping.', ); }); it('should derive paths from DB and delete files if job paths are empty', async () => { const job = createMockCleanupJob({ flyerId: 1, paths: [] }); // Empty paths const mockFlyer = createMockFlyer({ image_url: 'https://example.com/flyer-images/flyer-abc.jpg', icon_url: 'https://example.com/flyer-images/icons/icon-flyer-abc.webp', }); // Mock DB call to return a flyer vi.mocked(mockedDb.flyerRepo.getFlyerById).mockResolvedValue(mockFlyer); mocks.unlink.mockResolvedValue(undefined); // Mock process.env.STORAGE_PATH vi.stubEnv('STORAGE_PATH', '/var/www/app/flyer-images'); const result = await service.processCleanupJob(job); expect(result).toEqual({ status: 'success', deletedCount: 2 }); expect(mocks.unlink).toHaveBeenCalledTimes(2); expect(mocks.unlink).toHaveBeenCalledWith('/var/www/app/flyer-images/flyer-abc.jpg'); expect(mocks.unlink).toHaveBeenCalledWith('/var/www/app/flyer-images/icons/icon-flyer-abc.webp'); const { logger } = await import('./logger.server'); expect(logger.warn).toHaveBeenCalledWith( 'Cleanup job for flyer 1 received no paths. Attempting to derive paths from DB.', ); }); }); });