// src/tests/integration/flyer-processing.integration.test.ts import { describe, it, expect, beforeAll, afterAll, vi, beforeEach } from 'vitest'; import supertest from 'supertest'; import fs from 'node:fs/promises'; import path from 'path'; import * as db from '../../services/db/index.db'; import { generateFileChecksum } from '../../utils/checksum'; import { logger } from '../../services/logger.server'; import type { UserProfile } from '../../types'; import { createAndLoginUser, getFlyerBaseUrl } from '../utils/testHelpers'; import { cleanupDb } from '../utils/cleanup'; import { poll } from '../utils/poll'; import { cleanupFiles } from '../utils/cleanupFiles'; import piexif from 'piexifjs'; import exifParser from 'exif-parser'; import sharp from 'sharp'; const FLYER_BASE_URL = getFlyerBaseUrl(); // NOTE: STORAGE_PATH is set via the CI environment (deploy-to-test.yml). // This ensures multer and flyerProcessingService use the test runner's directory // instead of the production path (/var/www/.../flyer-images). // The testStoragePath variable is used for constructing paths in test assertions. const testStoragePath = process.env.STORAGE_PATH || path.resolve(__dirname, '../../../flyer-images'); // Mock the image processor to ensure safe filenames for DB constraints vi.mock('../../utils/imageProcessor', async () => { const actual = await vi.importActual( '../../utils/imageProcessor', ); // eslint-disable-next-line @typescript-eslint/no-require-imports const pathModule = require('path'); return { ...actual, // Return a realistic icon filename based on the source file generateFlyerIcon: vi.fn().mockImplementation(async (sourcePath: string) => { const baseName = pathModule.parse(pathModule.basename(sourcePath)).name; return `icon-${baseName}.webp`; }), }; }); // FIX: Mock storageService to return valid URLs (for DB) and write files to disk (for test verification) // NOTE: We use process.env.STORAGE_PATH which is set by the global setup to the temp directory. vi.mock('../../services/storage/storageService', () => { // eslint-disable-next-line @typescript-eslint/no-require-imports const fsModule = require('node:fs/promises'); // eslint-disable-next-line @typescript-eslint/no-require-imports const pathModule = require('path'); return { storageService: { upload: vi .fn() .mockImplementation( async ( fileData: Buffer | string | { name?: string; path?: string }, fileName?: string, ) => { const name = fileName || (fileData && typeof fileData === 'object' && 'name' in fileData && fileData.name) || (typeof fileData === 'string' ? pathModule.basename(fileData) : `upload-${Date.now()}.jpg`); // Use the STORAGE_PATH from the environment (set by global setup to temp directory) const uploadDir = process.env.STORAGE_PATH || pathModule.join(process.cwd(), 'flyer-images'); await fsModule.mkdir(uploadDir, { recursive: true }); const destPath = pathModule.join(uploadDir, name); let content: Buffer = Buffer.from(''); if (Buffer.isBuffer(fileData)) { content = Buffer.from(fileData); } else if (typeof fileData === 'string') { try { content = await fsModule.readFile(fileData); } catch { /* ignore */ } } else if ( fileData && typeof fileData === 'object' && 'path' in fileData && fileData.path ) { try { content = await fsModule.readFile(fileData.path); } catch { /* ignore */ } } await fsModule.writeFile(destPath, content); // Return a valid URL to satisfy the 'url_check' DB constraint return `${FLYER_BASE_URL}/flyer-images/${name}`; }, ), delete: vi.fn().mockResolvedValue(undefined), }, }; }); /** * @vitest-environment node */ // NOTE ON MOCKING STRATEGY: // Vitest creates separate module instances for test files vs global setup, which breaks // dependency injection approaches. For failure tests, we use vi.spyOn(aiService, ...) // which modifies the actual singleton object and works across module boundaries. // For happy path tests, the beforeEach hook sets up default mocks via DI which still works // because the workers are already loaded with the same module instance. import type { AiProcessorResult } from '../../services/flyerAiProcessor.server'; describe('Flyer Processing Background Job Integration Test', () => { let request: ReturnType; const createdUserIds: string[] = []; const createdFlyerIds: number[] = []; const createdFilePaths: string[] = []; const createdStoreIds: number[] = []; // IMPORTANT: We get flyerProcessingService from monitoringService rather than importing // workers.server.ts directly. This ensures we get the SAME instance that the workers use, // since monitoringService is already imported by the server (via ai.routes.ts). // Importing workers.server.ts directly creates a NEW module instance with different objects. let flyerProcessingService: typeof import('../../services/workers.server').flyerProcessingService; const originalFrontendUrl = process.env.FRONTEND_URL; beforeAll(async () => { // FIX: Stub FRONTEND_URL to ensure valid absolute URLs (http://...) are generated // for the database, satisfying the 'url_check' constraint. // IMPORTANT: This must run BEFORE the app is imported so workers inherit the env var. vi.stubEnv('FRONTEND_URL', 'https://example.com'); // STORAGE_PATH is primarily set via CI environment (deploy-to-test.yml). // This stubEnv call serves as a fallback for local development runs. // It ensures multer and flyerProcessingService use the test directory, not production path. vi.stubEnv('STORAGE_PATH', testStoragePath); console.error('[TEST SETUP] STORAGE_PATH:', testStoragePath); process.env.FRONTEND_URL = 'https://example.com'; console.error('[TEST SETUP] FRONTEND_URL stubbed to:', process.env.FRONTEND_URL); // NOTE: The aiService mock is now set up via vi.mock() at the module level (above). // This ensures workers get the mocked version when they import aiService. const appModule = await import('../../../server'); const app = appModule.default; request = supertest(app); // CRITICAL: Import flyerProcessingService from monitoringService, NOT from workers.server. // The server has already imported monitoringService (via ai.routes.ts), which imports workers.server. // By importing from monitoringService, we get the SAME flyerProcessingService instance // that the workers are using. This allows our mock injections to work correctly. const monitoringModule = await import('../../services/monitoringService.server'); flyerProcessingService = monitoringModule.flyerProcessingService; console.error( '[TEST SETUP] Got flyerProcessingService from monitoringService (shared instance)', ); }); // Helper function to create default mock AI response const createDefaultMockAiResult = (): AiProcessorResult => ({ data: { store_name: 'Mock Store', valid_from: '2025-01-01', valid_to: '2025-01-07', store_address: '123 Mock St', items: [ { item: 'Mocked Integration Item', price_display: '$1.99', price_in_cents: 199, quantity: 'each', category_name: 'Mock Category', }, ], }, needsReview: false, }); // FIX: Reset mocks before each test to ensure isolation. // This prevents "happy path" mocks from leaking into error handling tests and vice versa. beforeEach(async () => { console.error('[TEST SETUP] Resetting mocks before test execution'); if (flyerProcessingService) { // 1. Reset AI Processor to default success state via dependency injection // This replaces the vi.mock approach which didn't work across module boundaries flyerProcessingService ._getAiProcessor() ._setExtractAndValidateData(async () => createDefaultMockAiResult()); console.error('[TEST SETUP] AI processor mock set to default success state via DI'); // 2. Restore withTransaction to real implementation via dependency injection // This ensures that unless a test specifically injects a mock, the DB logic works as expected. const { withTransaction } = await import('../../services/db/connection.db'); flyerProcessingService._getPersistenceService()._setWithTransaction(withTransaction); console.error('[TEST SETUP] withTransaction restored to real implementation via DI'); // 3. Restore cleanup queue to real implementation // Some tests replace it with a no-op to prevent file cleanup during verification const { cleanupQueue } = await import('../../services/queues.server'); flyerProcessingService._setCleanupQueue(cleanupQueue); console.error('[TEST SETUP] cleanupQueue restored to real implementation via DI'); } }); afterAll(async () => { // Restore original value process.env.FRONTEND_URL = originalFrontendUrl; vi.unstubAllEnvs(); // Clean up env stubs vi.restoreAllMocks(); // Restore the AI spy // CRITICAL: Close workers FIRST before any cleanup to ensure no pending jobs // are trying to access files or databases during cleanup. // This prevents the Node.js async hooks crash that occurs when fs operations // are rejected during process shutdown. // NOTE: We import workers.server here for the closeWorkers function. // This is safe because the server has already loaded this module. try { console.error('[TEST TEARDOWN] Closing in-process workers...'); const { closeWorkers } = await import('../../services/workers.server'); await closeWorkers(); // Give workers a moment to fully release resources await new Promise((resolve) => setTimeout(resolve, 100)); } catch (error) { console.error('[TEST TEARDOWN] Error closing workers:', error); } // Close the shared redis connection used by the workers/queues const { connection } = await import('../../services/redis.server'); await connection.quit(); // Use the centralized cleanup utility. await cleanupDb({ userIds: createdUserIds, flyerIds: createdFlyerIds, storeIds: createdStoreIds, }); // Use the centralized file cleanup utility. await cleanupFiles(createdFilePaths); // Final delay to let any remaining async operations settle // This helps prevent the Node.js async context assertion failure await new Promise((resolve) => setTimeout(resolve, 50)); }); /** * This is the new end-to-end test for the background job processing flow. * It uploads a file, polls for completion, and verifies the result in the database. */ const runBackgroundProcessingTest = async (user?: UserProfile, token?: string) => { console.error( `[TEST START] runBackgroundProcessingTest. User: ${user?.user.email ?? 'ANONYMOUS'}`, ); // Arrange: Load a mock flyer PDF. console.error('[TEST] about to read test-flyer-image.jpg'); const imagePath = path.resolve(__dirname, '../assets/test-flyer-image.jpg'); const imageBuffer = await fs.readFile(imagePath); // Create a unique buffer and filename for each test run to ensure a unique checksum. // This prevents a 409 Conflict error when the second test runs. const uniqueContent = Buffer.concat([imageBuffer, Buffer.from(Date.now().toString())]); const uniqueFileName = `test-flyer-image-${Date.now()}.jpg`; const mockImageFile = new File([new Uint8Array(uniqueContent)], uniqueFileName, { type: 'image/jpeg', }); const checksum = await generateFileChecksum(mockImageFile); console.error('[TEST] mockImageFile created with uniqueFileName: ', uniqueFileName); console.error('[TEST DATA] Generated checksum for test:', checksum); // Track created files for cleanup const uploadDir = testStoragePath; createdFilePaths.push(path.join(uploadDir, uniqueFileName)); console.error('[TEST] createdFilesPaths after 1st push: ', createdFilePaths); // The icon name is derived from the original filename. const iconFileName = `icon-${path.parse(uniqueFileName).name}.webp`; createdFilePaths.push(path.join(uploadDir, 'icons', iconFileName)); // Act 1: Upload the file to start the background job. const testBaseUrl = 'https://example.com'; console.error('[TEST ACTION] Uploading file with baseUrl:', testBaseUrl); const uploadReq = request .post('/api/v1/ai/upload-and-process') .field('checksum', checksum) // Pass the baseUrl directly in the form data to ensure the worker receives it, // bypassing issues with vi.stubEnv in multi-threaded test environments. .field('baseUrl', testBaseUrl) .attach('flyerFile', uniqueContent, uniqueFileName); if (token) { uploadReq.set('Authorization', `Bearer ${token}`); } const uploadResponse = await uploadReq; console.error('[TEST RESPONSE] Upload status:', uploadResponse.status); console.error('[TEST RESPONSE] Upload body:', JSON.stringify(uploadResponse.body)); const { jobId } = uploadResponse.body.data; // Assert 1: Check that a job ID was returned. expect(jobId).toBeTypeOf('string'); // Act 2: Poll for job completion using the new utility. const jobStatus = await poll( async () => { const statusReq = request.get(`/api/v1/ai/jobs/${jobId}/status`); if (token) { statusReq.set('Authorization', `Bearer ${token}`); } const statusResponse = await statusReq; console.error(`[TEST POLL] Job ${jobId} current state:`, statusResponse.body?.data?.state); return statusResponse.body.data; }, (status) => status.state === 'completed' || status.state === 'failed', { timeout: 210000, interval: 3000, description: 'flyer processing' }, ); // Assert 2: Check that the job completed successfully. if (jobStatus?.state === 'failed') { console.error('[DEBUG] Job failed with reason:', jobStatus.failedReason); console.error('[DEBUG] Job stack trace:', jobStatus.stacktrace); console.error('[DEBUG] Job return value:', JSON.stringify(jobStatus.returnValue, null, 2)); console.error('[DEBUG] Full Job Status:', JSON.stringify(jobStatus, null, 2)); } expect(jobStatus?.state).toBe('completed'); const flyerId = jobStatus?.returnValue?.flyerId; expect(flyerId).toBeTypeOf('number'); createdFlyerIds.push(flyerId); // Track for cleanup // Assert 3: Verify the flyer and its items were actually saved in the database. const savedFlyer = await db.flyerRepo.findFlyerByChecksum(checksum, logger); expect(savedFlyer).toBeDefined(); expect(savedFlyer?.flyer_id).toBe(flyerId); if (savedFlyer?.store_id) { createdStoreIds.push(savedFlyer.store_id); } expect(savedFlyer?.file_name).toBe(uniqueFileName); // Also add the final processed image path to the cleanup list. // This is important because JPEGs are re-processed to strip EXIF data, creating a new file. const savedImagePath = path.join(uploadDir, path.basename(savedFlyer!.image_url)); createdFilePaths.push(savedImagePath); const items = await db.flyerRepo.getFlyerItems(flyerId, logger); // The stubbed AI response returns items, so we expect them to be here. expect(items.length).toBeGreaterThan(0); expect(items[0].item).toBeTypeOf('string'); // Assert 4: Verify user association is correct. if (token) { expect(savedFlyer?.uploaded_by).toBe(user?.user.user_id); } else { expect(savedFlyer?.uploaded_by).toBe(null); } }; it('should successfully process a flyer for an AUTHENTICATED user via the background queue', async () => { // Arrange: Create a new user specifically for this test. const email = `auth-flyer-user-${Date.now()}@example.com`; const { user: authUser, token } = await createAndLoginUser({ email, fullName: 'Flyer Uploader', request, }); createdUserIds.push(authUser.user.user_id); // Track for cleanup // Act & Assert await runBackgroundProcessingTest(authUser, token); }, 240000); // Increase timeout to 240 seconds for this long-running test it('should successfully process a flyer for an ANONYMOUS user via the background queue', async () => { // Act & Assert: Call the test helper without a user or token. await runBackgroundProcessingTest(); }, 240000); // Increase timeout to 240 seconds for this long-running test it('should strip EXIF data from uploaded JPEG images during processing', async () => { // Arrange: Spy on the cleanup queue to prevent file deletion before we can verify. // We use vi.spyOn instead of DI because the worker uses a different module instance // due to Vitest's VM isolation. Spying on the queue's add method works across boundaries. const { cleanupQueue } = await import('../../services/queues.server'); // Drain the cleanup queue and pause it to prevent any jobs from being processed during this test. // The cleanup worker runs in a separate module instance, so we need to pause at the queue level. await cleanupQueue.drain(); await cleanupQueue.pause(); console.error('[EXIF TEST DEBUG] Cleanup queue drained and paused'); const cleanupQueueSpy = vi .spyOn(cleanupQueue, 'add') .mockResolvedValue({ id: 'noop-spy' } as never); // Arrange: Create a user for this test const { user: authUser, token } = await createAndLoginUser({ email: `exif-user-${Date.now()}@example.com`, fullName: 'EXIF Tester', request, }); createdUserIds.push(authUser.user.user_id); // 1. Create an image buffer with EXIF data const imagePath = path.resolve(__dirname, '../assets/test-flyer-image.jpg'); const imageBuffer = await fs.readFile(imagePath); const jpegDataAsString = imageBuffer.toString('binary'); const exifObj = { '0th': { [piexif.ImageIFD.Software]: 'Gemini Code Assist Test' }, Exif: { [piexif.ExifIFD.DateTimeOriginal]: '2025:12:25 10:00:00' }, }; const exifBytes = piexif.dump(exifObj); const jpegWithExif = piexif.insert(exifBytes, jpegDataAsString); const imageWithExifBuffer = Buffer.from(jpegWithExif, 'binary'); const uniqueFileName = `test-flyer-with-exif-${Date.now()}.jpg`; const mockImageFile = new File([new Uint8Array(imageWithExifBuffer)], uniqueFileName, { type: 'image/jpeg', }); const checksum = await generateFileChecksum(mockImageFile); // Track original file for cleanup - the actual processed filename will be determined // after the job completes by looking at the saved flyer record const uploadDir = testStoragePath; createdFilePaths.push(path.join(uploadDir, uniqueFileName)); // 2. Act: Upload the file and wait for processing const uploadResponse = await request .post('/api/v1/ai/upload-and-process') .set('Authorization', `Bearer ${token}`) .field('baseUrl', 'https://example.com') .field('checksum', checksum) .attach('flyerFile', imageWithExifBuffer, uniqueFileName); const { jobId } = uploadResponse.body.data; expect(jobId).toBeTypeOf('string'); // Poll for job completion using the new utility. const jobStatus = await poll( async () => { const statusResponse = await request .get(`/api/v1/ai/jobs/${jobId}/status`) .set('Authorization', `Bearer ${token}`); return statusResponse.body.data; }, (status) => status.state === 'completed' || status.state === 'failed', { timeout: 180000, interval: 3000, description: 'EXIF stripping job' }, ); // 3. Assert if (jobStatus?.state === 'failed') { console.error('[DEBUG] EXIF test job failed:', jobStatus.failedReason); console.error('[DEBUG] Job stack trace:', jobStatus.stacktrace); console.error('[DEBUG] Job return value:', JSON.stringify(jobStatus.returnValue, null, 2)); } expect(jobStatus?.state).toBe('completed'); const flyerId = jobStatus?.returnValue?.flyerId; expect(flyerId).toBeTypeOf('number'); createdFlyerIds.push(flyerId); // 4. Verify EXIF data is stripped from the saved file const savedFlyer = await db.flyerRepo.findFlyerByChecksum(checksum, logger); expect(savedFlyer).toBeDefined(); if (savedFlyer?.store_id) { createdStoreIds.push(savedFlyer.store_id); } // Extract the actual processed filename from the saved flyer's image_url // The URL format is: ${FLYER_BASE_URL}/flyer-images/filename.ext const imageUrlPath = new URL(savedFlyer!.image_url).pathname; const processedFileName = path.basename(imageUrlPath); const savedImagePath = path.join(uploadDir, processedFileName); console.error('[TEST] savedImagePath during EXIF data stripping: ', savedImagePath); // Track the processed file for cleanup createdFilePaths.push(savedImagePath); // Also track the icon if it exists const iconFileName = `icon-${path.parse(processedFileName).name}.webp`; createdFilePaths.push(path.join(uploadDir, 'icons', iconFileName)); const savedImageBuffer = await fs.readFile(savedImagePath); const parser = exifParser.create(savedImageBuffer); const exifResult = parser.parse(); console.error('[TEST] exifResult.tags: ', exifResult.tags); // The `tags` object will be empty if no EXIF data is found. expect(exifResult.tags).toEqual({}); expect(exifResult.tags.Software).toBeUndefined(); // Cleanup: Restore the spy and resume the queue cleanupQueueSpy.mockRestore(); await cleanupQueue.resume(); console.error('[EXIF TEST DEBUG] Cleanup queue resumed'); }, 240000); it('should strip metadata from uploaded PNG images during processing', async () => { // Arrange: Spy on the cleanup queue to prevent file deletion before we can verify. // We use vi.spyOn instead of DI because the worker uses a different module instance // due to Vitest's VM isolation. Spying on the queue's add method works across boundaries. const { cleanupQueue } = await import('../../services/queues.server'); // Drain the cleanup queue and pause it to prevent any jobs from being processed during this test. // We need to drain first because there might be jobs already in the queue from setup or previous tests. await cleanupQueue.drain(); await cleanupQueue.pause(); console.error('[PNG TEST DEBUG] Cleanup queue drained and paused'); const cleanupQueueSpy = vi.spyOn(cleanupQueue, 'add').mockImplementation(async (...args) => { console.error( '[PNG TEST DEBUG] cleanupQueue.add was called via spy! Args:', JSON.stringify(args), ); return { id: 'noop-spy' } as never; }); console.error('[PNG TEST DEBUG] Cleanup queue.add spied to return no-op'); console.error('[PNG TEST DEBUG] testStoragePath:', testStoragePath); console.error('[PNG TEST DEBUG] process.env.STORAGE_PATH:', process.env.STORAGE_PATH); // Arrange: Create a user for this test const { user: authUser, token } = await createAndLoginUser({ email: `png-meta-user-${Date.now()}@example.com`, fullName: 'PNG Metadata Tester', request, }); createdUserIds.push(authUser.user.user_id); // 1. Create a PNG image buffer with custom metadata using sharp const imagePath = path.resolve(__dirname, '../assets/test-flyer-image.jpg'); const imageWithMetadataBuffer = await sharp(imagePath) .png() // Convert to PNG .withMetadata({ exif: { IFD0: { Copyright: 'Gemini Code Assist PNG Test', }, }, }) .toBuffer(); const uniqueFileName = `test-flyer-with-metadata-${Date.now()}.png`; const mockImageFile = new File([new Uint8Array(imageWithMetadataBuffer)], uniqueFileName, { type: 'image/png', }); const checksum = await generateFileChecksum(mockImageFile); // Track original file for cleanup - the actual processed filename will be determined // after the job completes by looking at the saved flyer record const uploadDir = testStoragePath; createdFilePaths.push(path.join(uploadDir, uniqueFileName)); // 2. Act: Upload the file and wait for processing const uploadResponse = await request .post('/api/v1/ai/upload-and-process') .set('Authorization', `Bearer ${token}`) .field('baseUrl', 'https://example.com') .field('checksum', checksum) .attach('flyerFile', imageWithMetadataBuffer, uniqueFileName); const { jobId } = uploadResponse.body.data; expect(jobId).toBeTypeOf('string'); // Debug: Check files right after upload const filesAfterUpload = await fs.readdir(uploadDir); console.error('[PNG TEST DEBUG] Files right after upload:', filesAfterUpload); // Poll for job completion using the new utility. const jobStatus = await poll( async () => { const statusResponse = await request .get(`/api/v1/ai/jobs/${jobId}/status`) .set('Authorization', `Bearer ${token}`); return statusResponse.body.data; }, (status) => status.state === 'completed' || status.state === 'failed', { timeout: 180000, interval: 3000, description: 'PNG metadata stripping job' }, ); // 3. Assert job completion if (jobStatus?.state === 'failed') { console.error('[DEBUG] PNG metadata test job failed:', jobStatus.failedReason); console.error('[DEBUG] Job stack trace:', jobStatus.stacktrace); console.error('[DEBUG] Job return value:', JSON.stringify(jobStatus.returnValue, null, 2)); } expect(jobStatus?.state).toBe('completed'); const flyerId = jobStatus?.returnValue?.flyerId; expect(flyerId).toBeTypeOf('number'); createdFlyerIds.push(flyerId); // 4. Verify metadata is stripped from the saved file const savedFlyer = await db.flyerRepo.findFlyerByChecksum(checksum, logger); expect(savedFlyer).toBeDefined(); if (savedFlyer?.store_id) { createdStoreIds.push(savedFlyer.store_id); } // Extract the actual processed filename from the saved flyer's image_url // The URL format is: ${FLYER_BASE_URL}/flyer-images/filename.ext const imageUrlPath = new URL(savedFlyer!.image_url).pathname; const processedFileName = path.basename(imageUrlPath); const savedImagePath = path.join(uploadDir, processedFileName); console.error('[TEST] savedImagePath during PNG metadata stripping: ', savedImagePath); // Track the processed file for cleanup createdFilePaths.push(savedImagePath); // Also track the icon if it exists const iconFileName = `icon-${path.parse(processedFileName).name}.webp`; createdFilePaths.push(path.join(uploadDir, 'icons', iconFileName)); // Debug: List files in the upload directory to verify what exists const filesInUploadDir = await fs.readdir(uploadDir); console.error('[PNG TEST DEBUG] Files in upload directory:', filesInUploadDir); console.error('[PNG TEST DEBUG] Looking for file:', processedFileName); console.error('[PNG TEST DEBUG] Full path:', savedImagePath); // Check if the file exists before trying to read metadata try { await fs.access(savedImagePath); console.error('[PNG TEST DEBUG] File exists at path'); // Verify the file is actually readable const fileStats = await fs.stat(savedImagePath); console.error('[PNG TEST DEBUG] File stats:', { size: fileStats.size, isFile: fileStats.isFile(), }); } catch (err) { console.error('[PNG TEST DEBUG] File does NOT exist at path!', err); // List all files that might be the processed file const matchingFiles = filesInUploadDir.filter((f) => f.includes('-processed.')); console.error('[PNG TEST DEBUG] Files containing "-processed.":', matchingFiles); } // Small delay to ensure file is fully written await new Promise((resolve) => setTimeout(resolve, 100)); const savedImageMetadata = await sharp(savedImagePath).metadata(); // The `exif` property should be undefined after stripping. expect(savedImageMetadata.exif).toBeUndefined(); // Cleanup: Restore the spy and resume the queue cleanupQueueSpy.mockRestore(); await cleanupQueue.resume(); console.error('[PNG TEST DEBUG] Cleanup queue resumed'); }, 240000); // TODO: This test cannot inject mocks into the worker's service instance because Vitest's // globalSetup runs in a separate Node.js context from test files. The flyerProcessingService // singleton is created in the globalSetup context, while tests run in a different context. // To fix this, we'd need either: // 1. A test-only API endpoint to inject mocks into the running server // 2. A file-based or Redis-based mock injection mechanism // 3. Running tests in the same process as the server (not supported by Vitest globalSetup) it.todo( 'should handle a failure from the AI service gracefully - requires mock injection mechanism', async () => { // Arrange: Use the global flyerProcessingService singleton to inject a failing AI function. // This works because workers.server.ts stores the service instance on `global.__flyerProcessingService_singleton__`, // which is shared across all module contexts (test file, global setup, and worker). // We access the FlyerAiProcessor through the service and use its DI method. const { flyerProcessingService } = await import('../../services/workers.server'); const aiProcessor = flyerProcessingService._getAiProcessor(); const aiError = new Error('AI model failed to extract data.'); aiProcessor._setExtractAndValidateData(async () => { console.error('[AI FAILURE TEST] Mock AI function called - throwing error'); throw aiError; }); console.error('[AI FAILURE TEST] AI processor mock function injected via DI'); // Arrange: Prepare a unique flyer file for upload. const imagePath = path.resolve(__dirname, '../assets/test-flyer-image.jpg'); const imageBuffer = await fs.readFile(imagePath); const uniqueContent = Buffer.concat([ imageBuffer, Buffer.from(`ai-error-test-${Date.now()}`), ]); const uniqueFileName = `ai-error-test-${Date.now()}.jpg`; const mockImageFile = new File([new Uint8Array(uniqueContent)], uniqueFileName, { type: 'image/jpeg', }); const checksum = await generateFileChecksum(mockImageFile); // Track created files for cleanup const uploadDir = testStoragePath; createdFilePaths.push(path.join(uploadDir, uniqueFileName)); // Act 1: Upload the file to start the background job. const uploadResponse = await request .post('/api/v1/ai/upload-and-process') .field('baseUrl', 'https://example.com') .field('checksum', checksum) .attach('flyerFile', uniqueContent, uniqueFileName); const { jobId } = uploadResponse.body.data; expect(jobId).toBeTypeOf('string'); // Act 2: Poll for job completion using the new utility. const jobStatus = await poll( async () => { const statusResponse = await request.get(`/api/v1/ai/jobs/${jobId}/status`); return statusResponse.body.data; }, (status) => status.state === 'completed' || status.state === 'failed', { timeout: 180000, interval: 3000, description: 'AI failure test job' }, ); // Assert 1: Check that the job failed. if (jobStatus?.state === 'failed') { console.error('[TEST DEBUG] AI Failure Test - Job Failed Reason:', jobStatus.failedReason); console.error('[TEST DEBUG] AI Failure Test - Job Stack:', jobStatus.stacktrace); } expect(jobStatus?.state).toBe('failed'); expect(jobStatus?.failedReason).toContain('AI model failed to extract data.'); // Assert 2: Verify the flyer was NOT saved in the database. const savedFlyer = await db.flyerRepo.findFlyerByChecksum(checksum, logger); expect(savedFlyer).toBeUndefined(); // Cleanup: Reset the DI function to restore normal behavior aiProcessor._setExtractAndValidateData(null); console.error('[AI FAILURE TEST] AI processor DI function reset'); }, 240000, ); // TODO: Same issue as AI failure test - cannot inject mocks across Vitest's globalSetup boundary. it.todo( 'should handle a database failure during flyer creation - requires mock injection mechanism', async () => { // Arrange: Use the global flyerProcessingService singleton for DI. // Same approach as the AI failure test - access through global singleton. const { flyerProcessingService } = await import('../../services/workers.server'); const aiProcessor = flyerProcessingService._getAiProcessor(); // Mock AI to return valid data (we need AI to succeed but DB to fail) aiProcessor._setExtractAndValidateData(async () => { console.error('[DB FAILURE TEST] Mock AI function called - returning valid data'); return { data: { store_name: 'DB Failure Test Store', valid_from: '2025-01-01', valid_to: '2025-01-07', store_address: '123 Test St', items: [{ item: 'Test Item', price_display: '$1.99', price_in_cents: 199 }], }, needsReview: false, }; }); console.error('[DB FAILURE TEST] AI processor mock function injected'); // Inject a failing withTransaction function const dbError = new Error('DB transaction failed'); const failingWithTransaction = vi.fn().mockRejectedValue(dbError); console.error('[DB FAILURE TEST] About to inject failingWithTransaction mock'); flyerProcessingService._getPersistenceService()._setWithTransaction(failingWithTransaction); console.error('[DB FAILURE TEST] failingWithTransaction mock injected successfully'); // Arrange: Prepare a unique flyer file for upload. const imagePath = path.resolve(__dirname, '../assets/test-flyer-image.jpg'); const imageBuffer = await fs.readFile(imagePath); const uniqueContent = Buffer.concat([ imageBuffer, Buffer.from(`db-error-test-${Date.now()}`), ]); const uniqueFileName = `db-error-test-${Date.now()}.jpg`; const mockImageFile = new File([new Uint8Array(uniqueContent)], uniqueFileName, { type: 'image/jpeg', }); const checksum = await generateFileChecksum(mockImageFile); // Track created files for cleanup const uploadDir = testStoragePath; createdFilePaths.push(path.join(uploadDir, uniqueFileName)); // Act 1: Upload the file to start the background job. const uploadResponse = await request .post('/api/v1/ai/upload-and-process') .field('baseUrl', 'https://example.com') .field('checksum', checksum) .attach('flyerFile', uniqueContent, uniqueFileName); const { jobId } = uploadResponse.body.data; expect(jobId).toBeTypeOf('string'); // Act 2: Poll for job completion using the new utility. const jobStatus = await poll( async () => { const statusResponse = await request.get(`/api/v1/ai/jobs/${jobId}/status`); return statusResponse.body.data; }, (status) => status.state === 'completed' || status.state === 'failed', { timeout: 180000, interval: 3000, description: 'DB failure test job' }, ); // Assert 1: Check that the job failed. expect(jobStatus?.state).toBe('failed'); expect(jobStatus?.failedReason).toContain('DB transaction failed'); // Assert 2: Verify the flyer was NOT saved in the database. const savedFlyer = await db.flyerRepo.findFlyerByChecksum(checksum, logger); expect(savedFlyer).toBeUndefined(); // Cleanup: Reset the DI functions to restore normal behavior aiProcessor._setExtractAndValidateData(null); flyerProcessingService._getPersistenceService()._setWithTransaction(null); console.error('[DB FAILURE TEST] DI functions reset'); }, 240000, ); // TODO: Same issue as AI failure test - cannot inject mocks across Vitest's globalSetup boundary. it.todo( 'should NOT clean up temporary files when a job fails - requires mock injection mechanism', async () => { // Arrange: Use the global flyerProcessingService singleton for DI. // Same approach as the AI failure test - access through global singleton. const { flyerProcessingService } = await import('../../services/workers.server'); const aiProcessor = flyerProcessingService._getAiProcessor(); const aiError = new Error('Simulated AI failure for cleanup test.'); aiProcessor._setExtractAndValidateData(async () => { console.error('[CLEANUP TEST] Mock AI function called - throwing error'); throw aiError; }); console.error('[CLEANUP TEST] AI processor mock function injected via DI'); // Arrange: Prepare a unique flyer file for upload. const imagePath = path.resolve(__dirname, '../assets/test-flyer-image.jpg'); const imageBuffer = await fs.readFile(imagePath); const uniqueContent = Buffer.concat([imageBuffer, Buffer.from(`cleanup-test-${Date.now()}`)]); const uniqueFileName = `cleanup-test-${Date.now()}.jpg`; const mockImageFile = new File([new Uint8Array(uniqueContent)], uniqueFileName, { type: 'image/jpeg', }); const checksum = await generateFileChecksum(mockImageFile); // Track the path of the file that will be created in the uploads directory. const uploadDir = testStoragePath; const tempFilePath = path.join(uploadDir, uniqueFileName); createdFilePaths.push(tempFilePath); // Act 1: Upload the file to start the background job. const uploadResponse = await request .post('/api/v1/ai/upload-and-process') .field('baseUrl', 'https://example.com') .field('checksum', checksum) .attach('flyerFile', uniqueContent, uniqueFileName); const { jobId } = uploadResponse.body.data; expect(jobId).toBeTypeOf('string'); // Act 2: Poll for job completion using the new utility. const jobStatus = await poll( async () => { const statusResponse = await request.get(`/api/v1/ai/jobs/${jobId}/status`); return statusResponse.body.data; }, (status) => status.state === 'completed' || status.state === 'failed', { timeout: 180000, interval: 3000, description: 'file cleanup failure test job' }, ); // Assert 1: Check that the job actually failed. expect(jobStatus?.state).toBe('failed'); expect(jobStatus?.failedReason).toContain('Simulated AI failure for cleanup test.'); // Assert 2: Verify the temporary file was NOT deleted. // fs.access throws if the file doesn't exist, so we expect it NOT to throw. await expect(fs.access(tempFilePath)).resolves.toBeUndefined(); // Cleanup: Reset the DI function to restore normal behavior aiProcessor._setExtractAndValidateData(null); console.error('[CLEANUP TEST] AI processor DI function reset'); }, 240000, ); });