// 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 } 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'; // 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', ); return { ...actual, generateFlyerIcon: vi.fn().mockResolvedValue('mock-icon-safe.webp'), }; }); // FIX: Mock storageService to return valid URLs (for DB) and write files to disk (for test verification) 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'); // Match the directory used in the test helpers const uploadDir = pathModule.join(process.cwd(), 'flyer-images'); 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`); 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 `https://example.com/uploads/${name}`; }, ), delete: vi.fn().mockResolvedValue(undefined), }, }; }); /** * @vitest-environment node */ // CRITICAL: These mock functions must be declared with vi.hoisted() to ensure they're available // at the module level BEFORE any imports are resolved. const { mockExtractCoreData } = vi.hoisted(() => { return { mockExtractCoreData: vi.fn(), }; }); // CRITICAL: Mock the aiService module BEFORE any other imports that depend on it. // This ensures workers get the mocked version, not the real one. // We use a partial mock that only overrides extractCoreDataFromFlyerImage. vi.mock('../../services/aiService.server', async (importOriginal) => { const actual = await importOriginal(); // Create a proxy around the actual aiService that intercepts extractCoreDataFromFlyerImage const proxiedAiService = new Proxy(actual.aiService, { get(target, prop) { if (prop === 'extractCoreDataFromFlyerImage') { return mockExtractCoreData; } // For all other properties/methods, return the original return target[prop as keyof typeof target]; }, }); return { ...actual, aiService: proxiedAiService, }; }); // NOTE: We no longer mock connection.db at the module level because vi.mock() doesn't work // across module boundaries (the worker imports the real module before our mock is applied). // Instead, we use dependency injection via FlyerPersistenceService._setWithTransaction(). describe('Flyer Processing Background Job Integration Test', () => { let request: ReturnType; const createdUserIds: string[] = []; const createdFlyerIds: number[] = []; const createdFilePaths: string[] = []; const createdStoreIds: number[] = []; let workersModule: typeof import('../../services/workers.server'); 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. // NEW: Import workers to start them IN-PROCESS. // This ensures they run in the same memory space as our mocks. console.error('[TEST SETUP] Starting in-process workers...'); workersModule = await import('../../services/workers.server'); const appModule = await import('../../../server'); const app = appModule.default; request = supertest(app); }); // 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'); // 1. Reset AI Service Mock to default success state mockExtractCoreData.mockReset(); mockExtractCoreData.mockResolvedValue({ 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', }, ], }); // 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. if (workersModule) { const { withTransaction } = await import('../../services/db/connection.db'); workersModule.flyerProcessingService ._getPersistenceService() ._setWithTransaction(withTransaction); console.error('[TEST SETUP] withTransaction 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. if (workersModule) { console.error('[TEST TEARDOWN] Closing in-process workers...'); await workersModule.closeWorkers(); // Give workers a moment to fully release resources await new Promise((resolve) => setTimeout(resolve, 100)); } // 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/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; // 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/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?.state); return statusResponse.body; }, (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: 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 and derived files for cleanup const uploadDir = testStoragePath; createdFilePaths.push(path.join(uploadDir, uniqueFileName)); const iconFileName = `icon-${path.parse(uniqueFileName).name}.webp`; createdFilePaths.push(path.join(uploadDir, 'icons', iconFileName)); // 2. Act: Upload the file and wait for processing const uploadResponse = await request .post('/api/ai/upload-and-process') .set('Authorization', `Bearer ${token}`) .field('baseUrl', 'https://example.com') .field('checksum', checksum) .attach('flyerFile', imageWithExifBuffer, uniqueFileName); const { jobId } = uploadResponse.body; expect(jobId).toBeTypeOf('string'); // Poll for job completion using the new utility. const jobStatus = await poll( async () => { const statusResponse = await request .get(`/api/ai/jobs/${jobId}/status`) .set('Authorization', `Bearer ${token}`); return statusResponse.body; }, (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); } const savedImagePath = path.join(uploadDir, path.basename(savedFlyer!.image_url)); createdFilePaths.push(savedImagePath); // Add final path for cleanup const savedImageBuffer = await fs.readFile(savedImagePath); const parser = exifParser.create(savedImageBuffer); const exifResult = parser.parse(); console.error('[TEST] savedImagePath during EXIF data stripping: ', savedImagePath); 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(); }, 240000); it('should strip metadata from uploaded PNG images during processing', async () => { // 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 files for cleanup const uploadDir = testStoragePath; createdFilePaths.push(path.join(uploadDir, uniqueFileName)); const iconFileName = `icon-${path.parse(uniqueFileName).name}.webp`; createdFilePaths.push(path.join(uploadDir, 'icons', iconFileName)); // 2. Act: Upload the file and wait for processing const uploadResponse = await request .post('/api/ai/upload-and-process') .set('Authorization', `Bearer ${token}`) .field('baseUrl', 'https://example.com') .field('checksum', checksum) .attach('flyerFile', imageWithMetadataBuffer, uniqueFileName); const { jobId } = uploadResponse.body; expect(jobId).toBeTypeOf('string'); // Poll for job completion using the new utility. const jobStatus = await poll( async () => { const statusResponse = await request .get(`/api/ai/jobs/${jobId}/status`) .set('Authorization', `Bearer ${token}`); return statusResponse.body; }, (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); } const savedImagePath = path.join(uploadDir, path.basename(savedFlyer!.image_url)); createdFilePaths.push(savedImagePath); // Add final path for cleanup console.error('[TEST] savedImagePath during PNG metadata stripping: ', savedImagePath); const savedImageMetadata = await sharp(savedImagePath).metadata(); // The test should fail here initially because PNGs are not processed. // The `exif` property should be undefined after the fix. expect(savedImageMetadata.exif).toBeUndefined(); }, 240000); it('should handle a failure from the AI service gracefully', async () => { // Arrange: Mock the AI service to throw an error for this specific test. const aiError = new Error('AI model failed to extract data.'); // Update the spy implementation to reject mockExtractCoreData.mockRejectedValue(aiError); // 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/ai/upload-and-process') .field('baseUrl', 'https://example.com') .field('checksum', checksum) .attach('flyerFile', uniqueContent, uniqueFileName); const { jobId } = uploadResponse.body; 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/ai/jobs/${jobId}/status`); return statusResponse.body; }, (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(); }, 240000); it('should handle a database failure during flyer creation', async () => { // Arrange: Inject a failing withTransaction function via dependency injection. // This is the correct approach because vi.mock() doesn't work across module boundaries - // the worker imports the real module before our mock is applied. const dbError = new Error('DB transaction failed'); const failingWithTransaction = vi.fn().mockRejectedValue(dbError); workersModule.flyerProcessingService ._getPersistenceService() ._setWithTransaction(failingWithTransaction); // 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/ai/upload-and-process') .field('baseUrl', 'https://example.com') .field('checksum', checksum) .attach('flyerFile', uniqueContent, uniqueFileName); const { jobId } = uploadResponse.body; 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/ai/jobs/${jobId}/status`); return statusResponse.body; }, (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(); }, 240000); it('should NOT clean up temporary files when a job fails, to allow for manual inspection', async () => { // Arrange: Mock the AI service to throw an error, causing the job to fail. const aiError = new Error('Simulated AI failure for cleanup test.'); mockExtractCoreData.mockRejectedValue(aiError); // 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/ai/upload-and-process') .field('baseUrl', 'https://example.com') .field('checksum', checksum) .attach('flyerFile', uniqueContent, uniqueFileName); const { jobId } = uploadResponse.body; 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/ai/jobs/${jobId}/status`); return statusResponse.body; }, (status) => status.state === 'failed', // We expect this one to fail { 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. // We check for its existence. If it doesn't exist, fs.access will throw an error. await expect( fs.access(tempFilePath), 'Expected temporary file to exist after job failure, but it was deleted.', ); }, 240000); });