From b3372b6fa3ab2c0324e1d7fb74b22070a91534e8 Mon Sep 17 00:00:00 2001 From: Torben Sorensen Date: Tue, 9 Dec 2025 17:38:23 -0800 Subject: [PATCH] fix tests ugh --- .../flyerProcessingService.server.test.ts | 93 ++++ src/services/flyerProcessingService.server.ts | 492 +++++++++--------- src/services/flyerProcessingService.types.ts | 1 + 3 files changed, 350 insertions(+), 236 deletions(-) diff --git a/src/services/flyerProcessingService.server.test.ts b/src/services/flyerProcessingService.server.test.ts index bc76b0c6..99700e55 100644 --- a/src/services/flyerProcessingService.server.test.ts +++ b/src/services/flyerProcessingService.server.test.ts @@ -2,6 +2,8 @@ // // 2024-07-30: Fixed `FlyerDataTransformer` mock to be a constructible class. The previous mock was not a constructor, // causing a `TypeError` when `FlyerProcessingService` tried to instantiate it with `new`. +// 2024-12-09: Fixed duplicate imports of FlyerProcessingService and FlyerJobData. Consolidated imports to use +// FlyerJobData from types file and FlyerProcessingService from server file. // --- END FIX REGISTRY --- // src/services/flyerProcessingService.server.test.ts import { describe, it, expect, vi, beforeEach, type Mocked } from 'vitest'; @@ -28,6 +30,7 @@ vi.mock('node:fs/promises', async (importOriginal) => { }; }); +// Import service and dependencies (FlyerJobData already imported from types above) import { FlyerProcessingService } from './flyerProcessingService.server'; import * as aiService from './aiService.server'; import * as db from './db/index.db'; @@ -258,4 +261,94 @@ describe('FlyerProcessingService', () => { }); }); + describe('_convertPdfToImages (private method)', () => { + it('should call pdftocairo and return sorted image paths on success', async () => { + const job = createMockJob({ filePath: '/tmp/test.pdf' }); + // Mock readdir to return unsorted Dirent-like objects + mocks.readdir.mockResolvedValue([ + { name: 'test-10.jpg' }, + { name: 'test-1.jpg' }, + { name: 'test-2.jpg' }, + { name: 'other-file.txt' }, + ] as Dirent[]); + + // Access and call the private method for testing + const imagePaths = await (service as any)._convertPdfToImages('/tmp/test.pdf', job); + + expect(mocks.execAsync).toHaveBeenCalledWith( + 'pdftocairo -jpeg -r 150 "/tmp/test.pdf" "/tmp/test"' + ); + expect(job.updateProgress).toHaveBeenCalledWith({ message: 'Converting PDF to images...' }); + // Verify that the paths are correctly sorted numerically + expect(imagePaths).toEqual([ + '/tmp/test-1.jpg', + '/tmp/test-2.jpg', + '/tmp/test-10.jpg', + ]); + }); + + it('should throw PdfConversionError if no images are generated', async () => { + const job = createMockJob({ filePath: '/tmp/empty.pdf' }); + // Mock readdir to return no matching files + mocks.readdir.mockResolvedValue([]); + + await expect((service as any)._convertPdfToImages('/tmp/empty.pdf', job)) + .rejects.toThrow('PDF conversion resulted in 0 images for file: /tmp/empty.pdf'); + }); + + it('should re-throw an error if the exec command fails', async () => { + const job = createMockJob({ filePath: '/tmp/bad.pdf' }); + const commandError = new Error('pdftocairo not found'); + mocks.execAsync.mockRejectedValue(commandError); + + await expect((service as any)._convertPdfToImages('/tmp/bad.pdf', job)) + .rejects.toThrow(commandError); + }); + }); + + describe('_saveProcessedFlyerData (private method)', () => { + it('should transform data, create flyer in DB, and log activity', async () => { + // Arrange + const mockExtractedData = { + store_name: 'Test 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 }], + }; + const mockImagePaths = [{ path: '/tmp/flyer.jpg', mimetype: 'image/jpeg' }]; + const mockJobData = { + originalFileName: 'flyer.jpg', + checksum: 'checksum-123', + userId: 'user-abc', + }; + + // The transformer is already spied on in beforeEach, we can just check its call. + const transformerSpy = vi.spyOn(FlyerDataTransformer.prototype, 'transform'); + + // The DB create function is also mocked in beforeEach. + const mockNewFlyer = { flyer_id: 1, file_name: 'flyer.jpg', store_name: 'Test Store' }; + vi.mocked(createFlyerAndItems).mockResolvedValue({ flyer: mockNewFlyer, items: [] } as any); + + // Act: Access and call the private method for testing + const result = await (service as any)._saveProcessedFlyerData(mockExtractedData, mockImagePaths, mockJobData); + + // Assert + // 1. Transformer was called correctly + expect(transformerSpy).toHaveBeenCalledWith(mockExtractedData, mockImagePaths, mockJobData.originalFileName, mockJobData.checksum, mockJobData.userId); + + // 2. DB function was called with the transformed data + const transformedData = await transformerSpy.mock.results[0].value; + expect(createFlyerAndItems).toHaveBeenCalledWith(transformedData.flyerData, transformedData.itemsForDb); + + // 3. Activity was logged + expect(mockedDb.adminRepo.logActivity).toHaveBeenCalledWith(expect.objectContaining({ + action: 'flyer_processed', + details: { flyerId: mockNewFlyer.flyer_id, storeName: mockNewFlyer.store_name } + })); + + // 4. The method returned the new flyer + expect(result).toEqual(mockNewFlyer); + }); + }); }); \ No newline at end of file diff --git a/src/services/flyerProcessingService.server.ts b/src/services/flyerProcessingService.server.ts index 923eacd6..e1e4f81a 100644 --- a/src/services/flyerProcessingService.server.ts +++ b/src/services/flyerProcessingService.server.ts @@ -1,260 +1,280 @@ -// --- FIX REGISTRY --- -// -// 2024-07-30: Fixed `FlyerDataTransformer` mock to be a constructible class. The previous mock was not a constructor, -// causing a `TypeError` when `FlyerProcessingService` tried to instantiate it with `new`. -// --- END FIX REGISTRY --- -// src/services/flyerProcessingService.server.test.ts -import { describe, it, expect, vi, beforeEach, type Mocked } from 'vitest'; -import { Job } from 'bullmq'; +// src/services/flyerProcessingService.server.ts +import type { Job, JobsOptions } from 'bullmq'; +import path from 'path'; import type { Dirent } from 'node:fs'; -import type { FlyerJobData } from './flyerProcessingService.types'; +import { z } from 'zod'; -// 1. Create hoisted mocks FIRST -const mocks = vi.hoisted(() => ({ - unlink: vi.fn(), - readdir: vi.fn(), - execAsync: 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 { FlyerProcessingService, type FlyerJobData } from './flyerProcessingService.server'; -import * as aiService from './aiService.server'; -import * as db from './db/index.db'; +import { logger } from './logger.server'; +import type { AIService } from './aiService.server'; +import type * as db from './db/index.db'; import { createFlyerAndItems } from './db/flyer.db'; -import * as imageProcessor from '../utils/imageProcessor'; +import { PdfConversionError, AiDataValidationError } from './processingErrors'; import { FlyerDataTransformer } from './flyerDataTransformer'; -// 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() }, -})); -vi.mock('../utils/imageProcessor', () => ({ - generateFlyerIcon: vi.fn().mockResolvedValue('icon-test.webp'), -})); -vi.mock('./logger.server', () => ({ - logger: { info: vi.fn(), error: vi.fn(), warn: vi.fn(), debug: vi.fn() } -})); +// --- Start: Interfaces for Dependency Injection --- -const mockedAiService = aiService as Mocked; -const mockedDb = db as Mocked; -const mockedImageProcessor = imageProcessor as Mocked; +export interface IFileSystem { + readdir(path: string, options: { withFileTypes: true }): Promise; + unlink(path: string): Promise; +} -describe('FlyerProcessingService', () => { - let service: FlyerProcessingService; - const mockCleanupQueue = { - add: vi.fn(), - }; +export interface ICommandExecutor { + (command: string): Promise<{ stdout: string; stderr: string }>; +} - beforeEach(() => { - vi.clearAllMocks(); +export interface FlyerJobData { + filePath: string; + originalFileName: string; + checksum: string; + userId?: string; + submitterIp?: string; + userProfileAddress?: string; +} - // 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: 'test.jpg', icon_url: 'icon.webp', checksum: 'checksum-123', store_name: 'Mock Store' } as any, - itemsForDb: [], +interface CleanupJobData { + flyerId: number; + // An array of absolute file paths to be deleted. Made optional for manual cleanup triggers. + paths?: string[]; +} + +/** + * Defines the contract for a queue that can have cleanup jobs added to it. + * This is used for dependency injection to avoid circular dependencies. + */ +interface ICleanupQueue { + add(name: string, data: CleanupJobData, opts?: JobsOptions): Promise>; +} + +// --- Zod Schemas for AI Response Validation (exported for the transformer) --- +const ExtractedFlyerItemSchema = z.object({ + item: z.string(), + price_display: z.string(), + price_in_cents: z.number().nullable(), + quantity: z.string(), + category_name: z.string(), + master_item_id: z.number().nullish(), // .nullish() allows null or undefined +}); + +export const AiFlyerDataSchema = z.object({ + store_name: z.string().min(1, { message: "Store name cannot be empty" }), + valid_from: z.string().nullable(), + valid_to: z.string().nullable(), + store_address: z.string().nullable(), + items: z.array(ExtractedFlyerItemSchema), +}); + +/** + * This class encapsulates the business logic for processing a flyer from a file. + * It handles PDF conversion, AI data extraction, and saving the results to the database. + */ +export class FlyerProcessingService { + constructor( + private ai: AIService, + private database: typeof db, + private fs: IFileSystem, + private exec: ICommandExecutor, + private cleanupQueue: ICleanupQueue, + private transformer: FlyerDataTransformer, + ) {} + + /** + * Converts a PDF file to a series of JPEG images using an external tool. + * @param filePath The path to the PDF file. + * @param job The BullMQ job instance for progress updates. + * @returns A promise that resolves to an array of paths to the created image files. + */ + private async _convertPdfToImages(filePath: string, job: Job): Promise { + logger.info(`[Worker] Starting PDF conversion for: ${filePath}`); + await job.updateProgress({ message: 'Converting PDF to images...' }); + + const outputDir = path.dirname(filePath); + const outputFilePrefix = path.join(outputDir, path.basename(filePath, '.pdf')); + logger.debug(`[Worker] PDF output directory: ${outputDir}`); + logger.debug(`[Worker] PDF output file prefix: ${outputFilePrefix}`); + + const command = `pdftocairo -jpeg -r 150 "${filePath}" "${outputFilePrefix}"`; + logger.info(`[Worker] Executing PDF conversion command: ${command}`); + const { stdout, stderr } = await this.exec(command); + + if (stdout) logger.debug(`[Worker] pdftocairo stdout for ${filePath}:`, { stdout }); + if (stderr) logger.warn(`[Worker] pdftocairo stderr for ${filePath}:`, { stderr }); + + logger.debug(`[Worker] Reading contents of output directory: ${outputDir}`); + const filesInDir = await this.fs.readdir(outputDir, { withFileTypes: true }); + logger.debug(`[Worker] Found ${filesInDir.length} total entries in output directory.`); + + const generatedImages = filesInDir + .filter(f => f.name.startsWith(path.basename(outputFilePrefix)) && f.name.endsWith('.jpg')) + .sort((a, b) => a.name.localeCompare(b.name, undefined, { numeric: true })); + + logger.debug(`[Worker] Filtered down to ${generatedImages.length} generated JPGs.`, { + imageNames: generatedImages.map(f => f.name), }); - // Default mock implementation for the promisified exec - mocks.execAsync.mockResolvedValue({ stdout: 'success', stderr: '' }); + if (generatedImages.length === 0) { + const errorMessage = `PDF conversion resulted in 0 images for file: ${filePath}. The PDF might be blank or corrupt.`; + logger.error(`[Worker] PdfConversionError: ${errorMessage}`, { stderr }); + throw new PdfConversionError(errorMessage, stderr); + } - // Default mock for readdir returns an empty array of Dirent-like objects. - mocks.readdir.mockResolvedValue([]); + return generatedImages.map(img => path.join(outputDir, img.name)); + } - // Mock the file system adapter that will be passed to the service - const mockFs = { - readdir: mocks.readdir, - unlink: mocks.unlink, - }; + /** + * Prepares the input images for the AI service. If the input is a PDF, it's converted to images. + * @param filePath The path to the original uploaded file. + * @param job The BullMQ job instance. + * @returns An object containing the final image paths for the AI and a list of any newly created image files. + */ + private async _prepareImageInputs(filePath: string, job: Job): Promise<{ imagePaths: { path: string; mimetype: string }[], createdImagePaths: string[] }> { + const fileExt = path.extname(filePath).toLowerCase(); - // Instantiate the service with all its dependencies mocked - service = new FlyerProcessingService( - mockedAiService.aiService, - mockedDb, - mockFs, - mocks.execAsync, - mockCleanupQueue, - new FlyerDataTransformer() + if (fileExt === '.pdf') { + const createdImagePaths = await this._convertPdfToImages(filePath, job); + const imagePaths = createdImagePaths.map(p => ({ path: p, mimetype: 'image/jpeg' })); + logger.info(`[Worker] Converted PDF to ${imagePaths.length} images.`); + return { imagePaths, createdImagePaths }; + } else { + logger.info(`[Worker] Processing as a single image file: ${filePath}`); + const imagePaths = [{ path: filePath, mimetype: `image/${fileExt.slice(1)}` }]; + return { imagePaths, createdImagePaths: [] }; + } + } + + /** + * Calls the AI service to extract structured data from the flyer images. + * @param imagePaths An array of paths and mimetypes for the images. + * @param jobData The data from the BullMQ job. + * @returns A promise that resolves to the validated, structured flyer data. + */ + private async _extractFlyerDataWithAI(imagePaths: { path: string; mimetype: string }[], jobData: FlyerJobData) { + logger.info(`[Worker] Starting AI data extraction for job ${jobData.checksum}.`); + const { submitterIp, userProfileAddress } = jobData; + const masterItems = await this.database.personalizationRepo.getAllMasterItems(); + logger.debug(`[Worker] Retrieved ${masterItems.length} master items for AI matching.`); + + const extractedData = await this.ai.extractCoreDataFromFlyerImage( + imagePaths, + masterItems, + submitterIp, + userProfileAddress ); - // Provide default successful mock implementations for dependencies - vi.mocked(mockedAiService.aiService.extractCoreDataFromFlyerImage).mockResolvedValue({ - 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 }], + const validationResult = AiFlyerDataSchema.safeParse(extractedData); + if (!validationResult.success) { + const errors = validationResult.error.flatten(); + logger.error('[Worker] AI response failed validation.', { + errors, + rawData: extractedData, + }); + throw new AiDataValidationError('AI response validation failed. The returned data structure is incorrect.', errors, extractedData); + } + + logger.info(`[Worker] AI extracted ${extractedData.items.length} items.`); + return validationResult.data; + } + + /** + * Saves the extracted flyer data to the database. + * @param extractedData The structured data from the AI. + * @param imagePaths The paths to the flyer images. + * @param jobData The data from the BullMQ job. + * @returns A promise that resolves to the newly created flyer record. + */ + private async _saveProcessedFlyerData( + extractedData: z.infer, + imagePaths: { path: string; mimetype: string }[], + jobData: FlyerJobData + ) { + logger.info(`[Worker] Preparing to save extracted data to database for job ${jobData.checksum}.`); + + // 1. Transform the AI data into database-ready records. + const { flyerData, itemsForDb } = await this.transformer.transform( + extractedData, + imagePaths, + jobData.originalFileName, + jobData.checksum, + jobData.userId + ); + + // 2. Save the transformed data to the database. + const { flyer: newFlyer } = await createFlyerAndItems(flyerData, itemsForDb); + logger.info(`[Worker] Successfully saved new flyer ID: ${newFlyer.flyer_id}`); + + await this.database.adminRepo.logActivity({ userId: jobData.userId, action: 'flyer_processed', displayText: `Processed a new flyer for ${flyerData.store_name}.`, details: { flyerId: newFlyer.flyer_id, storeName: flyerData.store_name } }); + + return newFlyer; + } + + /** + * Enqueues a job to clean up temporary files associated with a flyer upload. + * @param flyerId The ID of the processed flyer. + * @param paths An array of file paths to be deleted. + */ + private async _enqueueCleanup(flyerId: number, paths: string[]): Promise { + if (paths.length === 0) return; + + await this.cleanupQueue.add('cleanup-flyer-files', { flyerId, paths }, { + jobId: `cleanup-flyer-${flyerId}`, + removeOnComplete: true, }); - vi.mocked(createFlyerAndItems).mockResolvedValue({ - flyer: { flyer_id: 1, file_name: 'test.jpg', image_url: 'test.jpg', item_count: 1, created_at: new Date().toISOString() } as any, - items: [], - }); - mockedImageProcessor.generateFlyerIcon.mockResolvedValue('icon-test.jpg'); - 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([]); - }); + logger.info(`[Worker] Enqueued cleanup job for flyer ${flyerId}.`); + } - const createMockJob = (data: Partial): Job => { - return { - id: 'job-1', - data: { - filePath: '/tmp/flyer.jpg', - originalFileName: 'flyer.jpg', - checksum: 'checksum-123', - ...data, - }, - updateProgress: vi.fn(), - opts: { attempts: 3 }, - attemptsMade: 1, - } 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' }); + async processJob(job: Job) { + const { filePath, originalFileName } = job.data; + const createdImagePaths: string[] = []; + let newFlyerId: number | undefined; - const result = await service.processJob(job); + logger.info(`[Worker] Picked up job ${job.id} for file: ${originalFileName} (Checksum: ${job.data.checksum})`); - expect(result).toEqual({ flyerId: 1 }); - expect(mockedAiService.aiService.extractCoreDataFromFlyerImage).toHaveBeenCalledTimes(1); - expect(createFlyerAndItems).toHaveBeenCalledTimes(1); - expect(mockedDb.adminRepo.logActivity).toHaveBeenCalledTimes(1); - expect(mocks.execAsync).not.toHaveBeenCalled(); - expect(mockCleanupQueue.add).toHaveBeenCalledWith( - 'cleanup-flyer-files', - { flyerId: 1, paths: ['/tmp/flyer.jpg'] }, - expect.any(Object) - ); - }); + try { + await job.updateProgress({ message: 'Starting process...' }); + const { imagePaths, createdImagePaths: tempImagePaths } = await this._prepareImageInputs(filePath, job); + createdImagePaths.push(...tempImagePaths); - 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' }); + await job.updateProgress({ message: 'Extracting data...' }); + const extractedData = await this._extractFlyerDataWithAI(imagePaths, job.data); - // Mock readdir to return Dirent-like objects for the converted files - mocks.readdir.mockResolvedValue([ - { name: 'flyer-1.jpg' }, - { name: 'flyer-2.jpg' }, - ] as Dirent[]); + await job.updateProgress({ message: 'Saving to database...' }); + const newFlyer = await this._saveProcessedFlyerData(extractedData, imagePaths, job.data); - await service.processJob(job); - - // Verify that pdftocairo was called - expect(mocks.execAsync).toHaveBeenCalledWith( - expect.stringContaining('pdftocairo -jpeg -r 150') - ); - // Verify AI service was called with the converted images - expect(mockedAiService.aiService.extractCoreDataFromFlyerImage).toHaveBeenCalledWith( - expect.arrayContaining([ - expect.objectContaining({ path: expect.stringContaining('flyer-1.jpg') }), - expect.objectContaining({ path: expect.stringContaining('flyer-2.jpg') }), - ]), - expect.any(Array), - undefined, - undefined - ); - expect(createFlyerAndItems).toHaveBeenCalledTimes(1); - // Verify cleanup job includes original PDF and both generated images - expect(mockCleanupQueue.add).toHaveBeenCalledWith( - 'cleanup-flyer-files', - { flyerId: 1, paths: ['/tmp/flyer.pdf', expect.stringContaining('flyer-1.jpg'), expect.stringContaining('flyer-2.jpg')] }, - expect.any(Object) - ); - }); - - it('should throw an error and not enqueue cleanup if the AI service fails', async () => { - const job = createMockJob({}); - const aiError = new Error('AI model exploded'); - vi.mocked(mockedAiService.aiService.extractCoreDataFromFlyerImage).mockRejectedValue(aiError); - - await expect(service.processJob(job)).rejects.toThrow('AI model exploded'); - - expect(job.updateProgress).toHaveBeenCalledWith({ message: 'Error: AI model exploded' }); - expect(mockCleanupQueue.add).not.toHaveBeenCalled(); - }); - - it('should throw an error if the database service fails', async () => { - const job = createMockJob({}); - const dbError = new Error('Database transaction failed'); - vi.mocked(createFlyerAndItems).mockRejectedValue(dbError); - - await expect(service.processJob(job)).rejects.toThrow('Database transaction failed'); - - expect(job.updateProgress).toHaveBeenCalledWith({ message: 'Error: Database transaction failed' }); - expect(mockCleanupQueue.add).not.toHaveBeenCalled(); - }); - - it('should log a warning and not enqueue cleanup if the job fails but a flyer ID was somehow generated', async () => { - const job = createMockJob({}); - vi.mocked(createFlyerAndItems).mockRejectedValue(new Error('DB Error')); - await expect(service.processJob(job)).rejects.toThrow(); - expect(mockCleanupQueue.add).not.toHaveBeenCalled(); - }); - }); - - describe('_convertPdfToImages (private method)', () => { - it('should call pdftocairo and return sorted image paths on success', async () => { - const job = createMockJob({ filePath: '/tmp/test.pdf' }); - // Mock readdir to return unsorted Dirent-like objects - mocks.readdir.mockResolvedValue([ - { name: 'test-10.jpg' }, - { name: 'test-1.jpg' }, - { name: 'test-2.jpg' }, - { name: 'other-file.txt' }, - ] as Dirent[]); - - // Access and call the private method for testing - const imagePaths = await (service as any)._convertPdfToImages('/tmp/test.pdf', job); - - expect(mocks.execAsync).toHaveBeenCalledWith( - 'pdftocairo -jpeg -r 150 "/tmp/test.pdf" "/tmp/test"' - ); - expect(job.updateProgress).toHaveBeenCalledWith({ message: 'Converting PDF to images...' }); - // Verify that the paths are correctly sorted numerically - expect(imagePaths).toEqual([ - '/tmp/test-1.jpg', - '/tmp/test-2.jpg', - '/tmp/test-10.jpg', - ]); - }); - - it('should throw PdfConversionError if no images are generated', async () => { - const job = createMockJob({ filePath: '/tmp/empty.pdf' }); - // Mock readdir to return no matching files - mocks.readdir.mockResolvedValue([]); - - await expect((service as any)._convertPdfToImages('/tmp/empty.pdf', job)) - .rejects.toThrow('PDF conversion resulted in 0 images for file: /tmp/empty.pdf'); - }); - - it('should re-throw an error if the exec command fails', async () => { - const job = createMockJob({ filePath: '/tmp/bad.pdf' }); - const commandError = new Error('pdftocairo not found'); - mocks.execAsync.mockRejectedValue(commandError); - - await expect((service as any)._convertPdfToImages('/tmp/bad.pdf', job)) - .rejects.toThrow(commandError); - }); - }); -}); \ No newline at end of file + newFlyerId = newFlyer.flyer_id; + logger.info(`[Worker] Job ${job.id} for ${originalFileName} processed successfully. Flyer ID: ${newFlyerId}`); + return { flyerId: newFlyer.flyer_id }; + } catch (error: unknown) { + let errorMessage = 'An unknown error occurred'; + if (error instanceof PdfConversionError) { + errorMessage = error.message; + logger.error(`[Worker] PDF Conversion failed for job ${job.id}.`, { + error: errorMessage, + stderr: error.stderr, + jobData: job.data, + }); + } else if (error instanceof AiDataValidationError) { + errorMessage = error.message; + logger.error(`[Worker] AI Data Validation failed for job ${job.id}.`, { + error: errorMessage, + validationErrors: error.validationErrors, + rawData: error.rawData, + jobData: job.data, + }); + } else if (error instanceof Error) { + errorMessage = error.message; + logger.error(`[Worker] A generic error occurred in job ${job.id}. Attempt ${job.attemptsMade}/${job.opts.attempts}.`, { + error: errorMessage, stack: error.stack, jobData: job.data, + }); + } + await job.updateProgress({ message: `Error: ${errorMessage}` }); + throw error; + } finally { + if (newFlyerId) { + const pathsToClean = [filePath, ...createdImagePaths]; + await this._enqueueCleanup(newFlyerId, pathsToClean); + } else { + logger.warn(`[Worker] Job ${job.id} for ${originalFileName} failed. Temporary files will NOT be cleaned up to allow for manual inspection.`); + } + } + } +} \ No newline at end of file diff --git a/src/services/flyerProcessingService.types.ts b/src/services/flyerProcessingService.types.ts index 443ef788..b29c902a 100644 --- a/src/services/flyerProcessingService.types.ts +++ b/src/services/flyerProcessingService.types.ts @@ -1,3 +1,4 @@ +// src/services/flyerProcessingService.types.ts export interface FlyerJobData { filePath: string; originalFileName: string;