From fdb3b76cbd73162c8c6372e072aede226bcb73ae Mon Sep 17 00:00:00 2001 From: Torben Sorensen Date: Mon, 29 Dec 2025 15:37:51 -0800 Subject: [PATCH] fix unit tests --- src/routes/admin.jobs.routes.test.ts | 10 +- src/routes/ai.routes.test.ts | 147 ++++++------------ src/routes/system.routes.test.ts | 25 +++ .../flyerProcessingService.server.test.ts | 46 +++++- src/services/flyerProcessingService.server.ts | 33 ++-- 5 files changed, 137 insertions(+), 124 deletions(-) diff --git a/src/routes/admin.jobs.routes.test.ts b/src/routes/admin.jobs.routes.test.ts index 97a3d560..0ddb6740 100644 --- a/src/routes/admin.jobs.routes.test.ts +++ b/src/routes/admin.jobs.routes.test.ts @@ -234,15 +234,17 @@ describe('Admin Job Trigger Routes (/api/admin/trigger)', () => { expect(response.status).toBe(400); }); - it('should return 404 if the queue name is valid but not in the retry map', async () => { - const queueName = 'weekly-analytics-reporting'; // This is in the Zod enum but not the queueMap + it('should return 404 if the job ID is not found in the weekly-analytics-reporting queue', async () => { + const queueName = 'weekly-analytics-reporting'; const jobId = 'some-job-id'; + // Ensure getJob returns undefined (not found) + vi.mocked(weeklyAnalyticsQueue.getJob).mockResolvedValue(undefined); + const response = await supertest(app).post(`/api/admin/jobs/${queueName}/${jobId}/retry`); - // The route throws a NotFoundError, which the error handler should convert to a 404. expect(response.status).toBe(404); - expect(response.body.message).toBe(`Queue 'weekly-analytics-reporting' not found.`); + expect(response.body.message).toBe(`Job with ID '${jobId}' not found in queue '${queueName}'.`); }); it('should return 404 if the job ID is not found in the queue', async () => { diff --git a/src/routes/ai.routes.test.ts b/src/routes/ai.routes.test.ts index 4554eba1..d8ecd387 100644 --- a/src/routes/ai.routes.test.ts +++ b/src/routes/ai.routes.test.ts @@ -15,12 +15,18 @@ import { createTestApp } from '../tests/utils/createTestApp'; import { mockLogger } from '../tests/utils/mockLogger'; // Mock the AI service methods to avoid making real AI calls -vi.mock('../services/aiService.server', () => ({ - aiService: { - extractTextFromImageArea: vi.fn(), - planTripWithMaps: vi.fn(), // Added this missing mock - }, -})); +vi.mock('../services/aiService.server', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + aiService: { + extractTextFromImageArea: vi.fn(), + planTripWithMaps: vi.fn(), + enqueueFlyerProcessing: vi.fn(), + processLegacyFlyerUpload: vi.fn(), + }, + }; +}); const { mockedDb } = vi.hoisted(() => ({ mockedDb: { @@ -142,8 +148,7 @@ describe('AI Routes (/api/ai)', () => { const imagePath = path.resolve(__dirname, '../tests/assets/test-flyer-image.jpg'); it('should enqueue a job and return 202 on success', async () => { - vi.mocked(mockedDb.flyerRepo.findFlyerByChecksum).mockResolvedValue(undefined); - vi.mocked(flyerQueue.add).mockResolvedValue({ id: 'job-123' } as unknown as Job); + vi.mocked(aiService.aiService.enqueueFlyerProcessing).mockResolvedValue({ id: 'job-123' } as unknown as Job); const response = await supertest(app) .post('/api/ai/upload-and-process') @@ -153,7 +158,7 @@ describe('AI Routes (/api/ai)', () => { expect(response.status).toBe(202); expect(response.body.message).toBe('Flyer accepted for processing.'); expect(response.body.jobId).toBe('job-123'); - expect(flyerQueue.add).toHaveBeenCalledWith('process-flyer', expect.any(Object)); + expect(aiService.aiService.enqueueFlyerProcessing).toHaveBeenCalled(); }); it('should return 400 if no file is provided', async () => { @@ -176,9 +181,8 @@ describe('AI Routes (/api/ai)', () => { }); it('should return 409 if flyer checksum already exists', async () => { - vi.mocked(mockedDb.flyerRepo.findFlyerByChecksum).mockResolvedValue( - createMockFlyer({ flyer_id: 99 }), - ); + const duplicateError = new aiService.DuplicateFlyerError('This flyer has already been processed.', 99); + vi.mocked(aiService.aiService.enqueueFlyerProcessing).mockRejectedValue(duplicateError); const response = await supertest(app) .post('/api/ai/upload-and-process') @@ -190,8 +194,7 @@ describe('AI Routes (/api/ai)', () => { }); it('should return 500 if enqueuing the job fails', async () => { - vi.mocked(mockedDb.flyerRepo.findFlyerByChecksum).mockResolvedValue(undefined); - vi.mocked(flyerQueue.add).mockRejectedValueOnce(new Error('Redis connection failed')); + vi.mocked(aiService.aiService.enqueueFlyerProcessing).mockRejectedValueOnce(new Error('Redis connection failed')); const response = await supertest(app) .post('/api/ai/upload-and-process') @@ -213,9 +216,8 @@ describe('AI Routes (/api/ai)', () => { basePath: '/api/ai', authenticatedUser: mockUser, }); - - vi.mocked(mockedDb.flyerRepo.findFlyerByChecksum).mockResolvedValue(undefined); - vi.mocked(flyerQueue.add).mockResolvedValue({ id: 'job-456' } as unknown as Job); + + vi.mocked(aiService.aiService.enqueueFlyerProcessing).mockResolvedValue({ id: 'job-456' } as unknown as Job); // Act await supertest(authenticatedApp) @@ -224,8 +226,10 @@ describe('AI Routes (/api/ai)', () => { .attach('flyerFile', imagePath); // Assert - expect(flyerQueue.add).toHaveBeenCalled(); - expect(vi.mocked(flyerQueue.add).mock.calls[0][1].userId).toBe('auth-user-1'); + expect(aiService.aiService.enqueueFlyerProcessing).toHaveBeenCalled(); + const callArgs = vi.mocked(aiService.aiService.enqueueFlyerProcessing).mock.calls[0]; + // Check the userProfile argument (3rd argument) + expect(callArgs[2]?.user.user_id).toBe('auth-user-1'); }); it('should pass user profile address to the job when authenticated user has an address', async () => { @@ -247,6 +251,8 @@ describe('AI Routes (/api/ai)', () => { basePath: '/api/ai', authenticatedUser: mockUserWithAddress, }); + + vi.mocked(aiService.aiService.enqueueFlyerProcessing).mockResolvedValue({ id: 'job-789' } as unknown as Job); // Act await supertest(authenticatedApp) @@ -255,9 +261,10 @@ describe('AI Routes (/api/ai)', () => { .attach('flyerFile', imagePath); // Assert - expect(vi.mocked(flyerQueue.add).mock.calls[0][1].userProfileAddress).toBe( - '123 Pacific St, Anytown, BC, V8T 1A1, CA', - ); + expect(aiService.aiService.enqueueFlyerProcessing).toHaveBeenCalled(); + // The service handles address extraction from profile, so we just verify the profile was passed + const callArgs = vi.mocked(aiService.aiService.enqueueFlyerProcessing).mock.calls[0]; + expect(callArgs[2]?.address?.address_line_1).toBe('123 Pacific St'); }); it('should clean up the uploaded file if validation fails (e.g., missing checksum)', async () => { @@ -320,9 +327,7 @@ describe('AI Routes (/api/ai)', () => { flyer_id: 1, file_name: mockDataPayload.originalFileName, }); - vi.mocked(mockedDb.flyerRepo.findFlyerByChecksum).mockResolvedValue(undefined); // No duplicate - vi.mocked(mockedDb.createFlyerAndItems).mockResolvedValue({ flyer: mockFlyer, items: [] }); - vi.mocked(mockedDb.adminRepo.logActivity).mockResolvedValue(); + vi.mocked(aiService.aiService.processLegacyFlyerUpload).mockResolvedValue(mockFlyer); // Act const response = await supertest(app) @@ -333,13 +338,7 @@ describe('AI Routes (/api/ai)', () => { // Assert expect(response.status).toBe(201); expect(response.body.message).toBe('Flyer processed and saved successfully.'); - expect(mockedDb.createFlyerAndItems).toHaveBeenCalledTimes(1); - // Verify that the legacy endpoint correctly sets the status to 'needs_review' - expect(vi.mocked(mockedDb.createFlyerAndItems).mock.calls[0][0]).toEqual( - expect.objectContaining({ - status: 'needs_review', - }), - ); + expect(aiService.aiService.processLegacyFlyerUpload).toHaveBeenCalledTimes(1); }); it('should return 400 if no flyer image is provided', async () => { @@ -351,8 +350,8 @@ describe('AI Routes (/api/ai)', () => { it('should return 409 Conflict and delete the uploaded file if flyer checksum already exists', async () => { // Arrange - const mockExistingFlyer = createMockFlyer({ flyer_id: 99 }); - vi.mocked(mockedDb.flyerRepo.findFlyerByChecksum).mockResolvedValue(mockExistingFlyer); // Duplicate found + const duplicateError = new aiService.DuplicateFlyerError('This flyer has already been processed.', 99); + vi.mocked(aiService.aiService.processLegacyFlyerUpload).mockRejectedValue(duplicateError); const unlinkSpy = vi.spyOn(fs.promises, 'unlink').mockResolvedValue(undefined); // Act @@ -364,7 +363,7 @@ describe('AI Routes (/api/ai)', () => { // Assert expect(response.status).toBe(409); expect(response.body.message).toBe('This flyer has already been processed.'); - expect(mockedDb.createFlyerAndItems).not.toHaveBeenCalled(); + expect(mockedDb.createFlyerAndItems).not.toHaveBeenCalled(); // Should not be called if service throws // Assert that the file was deleted expect(unlinkSpy).toHaveBeenCalledTimes(1); // The filename is predictable in the test environment because of the multer config in ai.routes.ts @@ -379,12 +378,7 @@ describe('AI Routes (/api/ai)', () => { extractedData: { store_name: 'Partial Store' }, // no items key }; - vi.mocked(mockedDb.flyerRepo.findFlyerByChecksum).mockResolvedValue(undefined); - const mockFlyer = createMockFlyer({ - flyer_id: 2, - file_name: partialPayload.originalFileName, - }); - vi.mocked(mockedDb.createFlyerAndItems).mockResolvedValue({ flyer: mockFlyer, items: [] }); + vi.mocked(aiService.aiService.processLegacyFlyerUpload).mockResolvedValue(createMockFlyer({ flyer_id: 2 })); const response = await supertest(app) .post('/api/ai/flyers/process') @@ -392,19 +386,7 @@ describe('AI Routes (/api/ai)', () => { .attach('flyerImage', imagePath); expect(response.status).toBe(201); - expect(mockedDb.createFlyerAndItems).toHaveBeenCalledTimes(1); - // Verify that the legacy endpoint correctly sets the status to 'needs_review' - expect(vi.mocked(mockedDb.createFlyerAndItems).mock.calls[0][0]).toEqual( - expect.objectContaining({ - status: 'needs_review', - }), - ); - // verify the items array passed to DB was an empty array - const callArgs = vi.mocked(mockedDb.createFlyerAndItems).mock.calls[0]?.[1]; - expect(callArgs).toBeDefined(); - expect(Array.isArray(callArgs)).toBe(true); - // use non-null assertion for the runtime-checked variable so TypeScript is satisfied - expect(callArgs!.length).toBe(0); + expect(aiService.aiService.processLegacyFlyerUpload).toHaveBeenCalledTimes(1); }); it('should fallback to a safe store name when store_name is missing', async () => { @@ -414,12 +396,7 @@ describe('AI Routes (/api/ai)', () => { extractedData: { items: [] }, // store_name missing }; - vi.mocked(mockedDb.flyerRepo.findFlyerByChecksum).mockResolvedValue(undefined); - const mockFlyer = createMockFlyer({ - flyer_id: 3, - file_name: payloadNoStore.originalFileName, - }); - vi.mocked(mockedDb.createFlyerAndItems).mockResolvedValue({ flyer: mockFlyer, items: [] }); + vi.mocked(aiService.aiService.processLegacyFlyerUpload).mockResolvedValue(createMockFlyer({ flyer_id: 3 })); const response = await supertest(app) .post('/api/ai/flyers/process') @@ -427,25 +404,11 @@ describe('AI Routes (/api/ai)', () => { .attach('flyerImage', imagePath); expect(response.status).toBe(201); - expect(mockedDb.createFlyerAndItems).toHaveBeenCalledTimes(1); - // Verify that the legacy endpoint correctly sets the status to 'needs_review' - expect(vi.mocked(mockedDb.createFlyerAndItems).mock.calls[0][0]).toEqual( - expect.objectContaining({ - status: 'needs_review', - }), - ); - // verify the flyerData.store_name passed to DB was the fallback string - const flyerDataArg = vi.mocked(mockedDb.createFlyerAndItems).mock.calls[0][0]; - expect(flyerDataArg.store_name).toContain('Unknown Store'); - // Also verify the warning was logged - expect(mockLogger.warn).toHaveBeenCalledWith( - 'extractedData.store_name missing; using fallback store name to avoid DB constraint error.', - ); + expect(aiService.aiService.processLegacyFlyerUpload).toHaveBeenCalledTimes(1); }); it('should handle a generic error during flyer creation', async () => { - vi.mocked(mockedDb.flyerRepo.findFlyerByChecksum).mockResolvedValue(undefined); - vi.mocked(mockedDb.createFlyerAndItems).mockRejectedValueOnce( + vi.mocked(aiService.aiService.processLegacyFlyerUpload).mockRejectedValueOnce( new Error('DB transaction failed'), ); @@ -468,8 +431,7 @@ describe('AI Routes (/api/ai)', () => { beforeEach(() => { const mockFlyer = createMockFlyer({ flyer_id: 1 }); - vi.mocked(mockedDb.flyerRepo.findFlyerByChecksum).mockResolvedValue(undefined); - vi.mocked(mockedDb.createFlyerAndItems).mockResolvedValue({ flyer: mockFlyer, items: [] }); + vi.mocked(aiService.aiService.processLegacyFlyerUpload).mockResolvedValue(mockFlyer); }); it('should handle payload where "data" field is an object, not stringified JSON', async () => { @@ -479,7 +441,7 @@ describe('AI Routes (/api/ai)', () => { .attach('flyerImage', imagePath); expect(response.status).toBe(201); - expect(mockedDb.createFlyerAndItems).toHaveBeenCalledTimes(1); + expect(aiService.aiService.processLegacyFlyerUpload).toHaveBeenCalledTimes(1); }); it('should handle payload where extractedData is null', async () => { @@ -495,14 +457,7 @@ describe('AI Routes (/api/ai)', () => { .attach('flyerImage', imagePath); expect(response.status).toBe(201); - expect(mockedDb.createFlyerAndItems).toHaveBeenCalledTimes(1); - // Verify that extractedData was correctly defaulted to an empty object - const flyerDataArg = vi.mocked(mockedDb.createFlyerAndItems).mock.calls[0][0]; - expect(flyerDataArg.store_name).toContain('Unknown Store'); // Fallback should be used - expect(mockLogger.warn).toHaveBeenCalledWith( - { bodyData: expect.any(Object) }, - 'Missing extractedData in /api/ai/flyers/process payload.', - ); + expect(aiService.aiService.processLegacyFlyerUpload).toHaveBeenCalledTimes(1); }); it('should handle payload where extractedData is a string', async () => { @@ -518,14 +473,7 @@ describe('AI Routes (/api/ai)', () => { .attach('flyerImage', imagePath); expect(response.status).toBe(201); - expect(mockedDb.createFlyerAndItems).toHaveBeenCalledTimes(1); - // Verify that extractedData was correctly defaulted to an empty object - const flyerDataArg = vi.mocked(mockedDb.createFlyerAndItems).mock.calls[0][0]; - expect(flyerDataArg.store_name).toContain('Unknown Store'); // Fallback should be used - expect(mockLogger.warn).toHaveBeenCalledWith( - { bodyData: expect.any(Object) }, - 'Missing extractedData in /api/ai/flyers/process payload.', - ); + expect(aiService.aiService.processLegacyFlyerUpload).toHaveBeenCalledTimes(1); }); it('should handle payload where extractedData is at the root of the body', async () => { @@ -539,9 +487,7 @@ describe('AI Routes (/api/ai)', () => { .attach('flyerImage', imagePath); expect(response.status).toBe(201); // This test was failing with 500, the fix is in ai.routes.ts - expect(mockedDb.createFlyerAndItems).toHaveBeenCalledTimes(1); - const flyerDataArg = vi.mocked(mockedDb.createFlyerAndItems).mock.calls[0][0]; - expect(flyerDataArg.store_name).toBe('Root Store'); + expect(aiService.aiService.processLegacyFlyerUpload).toHaveBeenCalledTimes(1); }); it('should default item quantity to 1 if missing', async () => { @@ -560,9 +506,7 @@ describe('AI Routes (/api/ai)', () => { .attach('flyerImage', imagePath); expect(response.status).toBe(201); - expect(mockedDb.createFlyerAndItems).toHaveBeenCalledTimes(1); - const itemsArg = vi.mocked(mockedDb.createFlyerAndItems).mock.calls[0][1]; - expect(itemsArg[0].quantity).toBe(1); + expect(aiService.aiService.processLegacyFlyerUpload).toHaveBeenCalledTimes(1); }); }); @@ -571,7 +515,6 @@ describe('AI Routes (/api/ai)', () => { it('should handle malformed JSON in data field and return 400', async () => { const malformedDataString = '{"checksum":'; // Invalid JSON - vi.mocked(mockedDb.flyerRepo.findFlyerByChecksum).mockResolvedValue(undefined); const response = await supertest(app) .post('/api/ai/flyers/process') diff --git a/src/routes/system.routes.test.ts b/src/routes/system.routes.test.ts index 0edc05e3..f0d62545 100644 --- a/src/routes/system.routes.test.ts +++ b/src/routes/system.routes.test.ts @@ -6,6 +6,31 @@ import { exec, type ExecException, type ExecOptions } from 'child_process'; import { geocodingService } from '../services/geocodingService.server'; import { createTestApp } from '../tests/utils/createTestApp'; +// FIX: Mock util.promisify to correctly handle child_process.exec's (err, stdout, stderr) signature. +// This is required because the standard util.promisify relies on internal symbols on the real exec function, +// which are missing on our Vitest mock. Without this, promisify(mockExec) drops the stdout/stderr arguments. +vi.mock('util', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + promisify: (fn: Function) => { + return (...args: any[]) => { + return new Promise((resolve, reject) => { + fn(...args, (err: Error | null, stdout: unknown, stderr: unknown) => { + if (err) { + // Attach stdout/stderr to the error object to mimic child_process.exec behavior + Object.assign(err, { stdout, stderr }); + reject(err); + } else { + resolve({ stdout, stderr }); + } + }); + }); + }; + }, + }; +}); + // FIX: Use the simple factory pattern for child_process to avoid default export issues vi.mock('child_process', () => { const mockExec = vi.fn((command, callback) => { diff --git a/src/services/flyerProcessingService.server.test.ts b/src/services/flyerProcessingService.server.test.ts index 8f614720..0c8da87f 100644 --- a/src/services/flyerProcessingService.server.test.ts +++ b/src/services/flyerProcessingService.server.test.ts @@ -249,6 +249,12 @@ describe('FlyerProcessingService', () => { 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( @@ -268,6 +274,12 @@ describe('FlyerProcessingService', () => { 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: 'An AI quota has been exceeded. Please try again later.' }, + { 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( @@ -291,9 +303,9 @@ describe('FlyerProcessingService', () => { stderr: 'pdftocairo error', stages: [ { name: 'Preparing Inputs', status: 'failed', critical: true, detail: 'Validating and preparing file...' }, - { name: 'Extracting Data with AI', status: 'skipped', critical: true, detail: undefined }, - { name: 'Transforming AI Data', status: 'skipped', critical: true, detail: undefined }, - { name: 'Saving to Database', status: 'skipped', critical: true, detail: undefined }, + { name: 'Extracting Data with AI', status: 'skipped', critical: true, detail: 'Communicating with AI model...' }, + { name: 'Transforming AI Data', status: 'skipped', critical: true }, + { name: 'Saving to Database', status: 'skipped', critical: true }, ], }); expect(mockCleanupQueue.add).not.toHaveBeenCalled(); @@ -332,7 +344,7 @@ describe('FlyerProcessingService', () => { 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: 'Communicating with AI model...' }, + { 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 }, ], @@ -375,6 +387,12 @@ describe('FlyerProcessingService', () => { expect(job.updateProgress).toHaveBeenCalledWith({ errorCode: 'UNKNOWN_ERROR', message: 'Database transaction failed', + stages: [ + { name: 'Preparing Inputs', status: 'completed', critical: true, detail: '1 page(s) ready for AI.' }, + { name: 'Extracting Data with AI', status: 'completed', critical: true }, + { name: 'Transforming AI Data', status: 'completed', critical: true }, + { name: 'Saving to Database', status: 'failed', critical: true, detail: 'Database transaction failed' }, + ], }); // This was a duplicate, fixed. expect(mockCleanupQueue.add).not.toHaveBeenCalled(); expect(logger.warn).toHaveBeenCalledWith( @@ -395,6 +413,12 @@ describe('FlyerProcessingService', () => { expect(job.updateProgress).toHaveBeenCalledWith({ errorCode: 'UNSUPPORTED_FILE_TYPE', message: 'Unsupported file type: .txt. Supported types are PDF, JPG, PNG, WEBP, HEIC, HEIF, GIF, TIFF, SVG, BMP.', + stages: [ + { name: 'Preparing Inputs', status: 'failed', critical: true, detail: 'Unsupported file type: .txt. Supported types are PDF, JPG, PNG, WEBP, HEIC, HEIF, GIF, TIFF, SVG, BMP.' }, + { name: 'Extracting Data with AI', status: 'skipped', critical: true, detail: 'Communicating with AI model...' }, + { 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( @@ -416,6 +440,12 @@ describe('FlyerProcessingService', () => { expect(job.updateProgress).toHaveBeenCalledWith({ errorCode: 'UNKNOWN_ERROR', message: 'Icon generation failed.', + stages: [ + { name: 'Preparing Inputs', status: 'completed', critical: true, detail: '1 page(s) ready for AI.' }, + { name: 'Extracting Data with AI', status: 'completed', critical: true }, + { name: 'Transforming AI Data', status: 'failed', critical: true, detail: 'Icon generation failed.' }, + { name: 'Saving to Database', status: 'skipped', critical: true }, + ], }); // This was a duplicate, fixed. expect(mockCleanupQueue.add).not.toHaveBeenCalled(); expect(logger.warn).toHaveBeenCalledWith( @@ -431,7 +461,7 @@ describe('FlyerProcessingService', () => { const quotaError = new Error('RESOURCE_EXHAUSTED'); const privateMethod = (service as any)._reportErrorAndThrow; - await expect(privateMethod(quotaError, job, logger)).rejects.toThrow( + await expect(privateMethod(quotaError, job, logger, [])).rejects.toThrow( UnrecoverableError, ); @@ -451,7 +481,7 @@ describe('FlyerProcessingService', () => { ); const privateMethod = (service as any)._reportErrorAndThrow; - await expect(privateMethod(validationError, job, logger)).rejects.toThrow( + await expect(privateMethod(validationError, job, logger, [])).rejects.toThrow( validationError, ); @@ -471,7 +501,7 @@ describe('FlyerProcessingService', () => { const genericError = new Error('A standard failure'); const privateMethod = (service as any)._reportErrorAndThrow; - await expect(privateMethod(genericError, job, logger)).rejects.toThrow(genericError); + await expect(privateMethod(genericError, job, logger, [])).rejects.toThrow(genericError); expect(job.updateProgress).toHaveBeenCalledWith({ errorCode: 'UNKNOWN_ERROR', @@ -485,7 +515,7 @@ describe('FlyerProcessingService', () => { const nonError = 'just a string error'; const privateMethod = (service as any)._reportErrorAndThrow; - await expect(privateMethod(nonError, job, logger)).rejects.toThrow('just a string error'); + await expect(privateMethod(nonError, job, logger, [])).rejects.toThrow('just a string error'); }); }); diff --git a/src/services/flyerProcessingService.server.ts b/src/services/flyerProcessingService.server.ts index 1d8eaff8..b6c0f60f 100644 --- a/src/services/flyerProcessingService.server.ts +++ b/src/services/flyerProcessingService.server.ts @@ -55,7 +55,7 @@ export class FlyerProcessingService { const logger = globalLogger.child({ jobId: job.id, jobName: job.name, ...job.data }); logger.info('Picked up flyer processing job.'); - const initialStages: ProcessingStage[] = [ + const stages: ProcessingStage[] = [ { name: 'Preparing Inputs', status: 'pending', critical: true, detail: 'Validating and preparing file...' }, { name: 'Extracting Data with AI', status: 'pending', critical: true, detail: 'Communicating with AI model...' }, { name: 'Transforming AI Data', status: 'pending', critical: true }, @@ -67,22 +67,31 @@ export class FlyerProcessingService { try { // Stage 1: Prepare Inputs (e.g., convert PDF to images) - await job.updateProgress({ stages: [{ name: 'Preparing Inputs', status: 'in-progress', critical: true, detail: 'Validating and preparing file...' }] }); + stages[0].status = 'in-progress'; + await job.updateProgress({ stages }); + const { imagePaths, createdImagePaths } = await this.fileHandler.prepareImageInputs( job.data.filePath, job, logger, ); allFilePaths.push(...createdImagePaths); - await job.updateProgress({ stages: [{ name: 'Preparing Inputs', status: 'completed', critical: true, detail: `${imagePaths.length} page(s) ready for AI.` }] }); + stages[0].status = 'completed'; + stages[0].detail = `${imagePaths.length} page(s) ready for AI.`; + await job.updateProgress({ stages }); // Stage 2: Extract Data with AI - await job.updateProgress({ stages: [{ name: 'Preparing Inputs', status: 'completed', critical: true, detail: `${imagePaths.length} page(s) ready for AI.` }, { name: 'Extracting Data with AI', status: 'in-progress', critical: true, detail: 'Communicating with AI model...' }] }); + stages[1].status = 'in-progress'; + await job.updateProgress({ stages }); + const aiResult = await this.aiProcessor.extractAndValidateData(imagePaths, job.data, logger); - await job.updateProgress({ stages: [{ name: 'Preparing Inputs', status: 'completed', critical: true, detail: `${imagePaths.length} page(s) ready for AI.` }, { name: 'Extracting Data with AI', status: 'completed', critical: true }] }); + stages[1].status = 'completed'; + await job.updateProgress({ stages }); // Stage 3: Transform AI Data into DB format - await job.updateProgress({ stages: [{ name: 'Preparing Inputs', status: 'completed', critical: true, detail: `${imagePaths.length} page(s) ready for AI.` }, { name: 'Extracting Data with AI', status: 'completed', critical: true }, { name: 'Transforming AI Data', status: 'in-progress', critical: true }] }); + stages[2].status = 'in-progress'; + await job.updateProgress({ stages }); + const { flyerData, itemsForDb } = await this.transformer.transform( aiResult, imagePaths, @@ -91,12 +100,16 @@ export class FlyerProcessingService { job.data.userId, logger, ); - await job.updateProgress({ stages: [{ name: 'Preparing Inputs', status: 'completed', critical: true, detail: `${imagePaths.length} page(s) ready for AI.` }, { name: 'Extracting Data with AI', status: 'completed', critical: true }, { name: 'Transforming AI Data', status: 'completed', critical: true }] }); + stages[2].status = 'completed'; + await job.updateProgress({ stages }); // Stage 4: Save to Database - await job.updateProgress({ stages: [{ name: 'Preparing Inputs', status: 'completed', critical: true, detail: `${imagePaths.length} page(s) ready for AI.` }, { name: 'Extracting Data with AI', status: 'completed', critical: true }, { name: 'Transforming AI Data', status: 'completed', critical: true }, { name: 'Saving to Database', status: 'in-progress', critical: true }] }); + stages[3].status = 'in-progress'; + await job.updateProgress({ stages }); + const { flyer } = await createFlyerAndItems(flyerData, itemsForDb, logger); - await job.updateProgress({ stages: [{ name: 'Preparing Inputs', status: 'completed', critical: true, detail: `${imagePaths.length} page(s) ready for AI.` }, { name: 'Extracting Data with AI', status: 'completed', critical: true }, { name: 'Transforming AI Data', status: 'completed', critical: true }, { name: 'Saving to Database', status: 'completed', critical: true }] }); + stages[3].status = 'completed'; + await job.updateProgress({ stages }); // Stage 5: Log Activity await this.db.adminRepo.logActivity( @@ -121,7 +134,7 @@ export class FlyerProcessingService { } catch (error) { logger.warn('Job failed. Temporary files will NOT be cleaned up to allow for manual inspection.'); // This private method handles error reporting and re-throwing. - await this._reportErrorAndThrow(error, job, logger, initialStages); + await this._reportErrorAndThrow(error, job, logger, stages); // This line is technically unreachable because the above method always throws, // but it's required to satisfy TypeScript's control flow analysis. throw error;