// src/tests/integration/flyer-processing.integration.test.ts import { describe, it, expect, beforeAll, afterAll } from 'vitest'; import supertest from 'supertest'; import app from '../../../server'; import fs from 'node:fs/promises'; import path from 'path'; import * as db from '../../services/db/index.db'; import { getPool } from '../../services/db/connection.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 { cleanupFiles } from '../utils/cleanupFiles'; import piexif from 'piexifjs'; import exifParser from 'exif-parser'; import sharp from 'sharp'; /** * @vitest-environment node */ const request = supertest(app); describe('Flyer Processing Background Job Integration Test', () => { const createdUserIds: string[] = []; const createdFlyerIds: number[] = []; const createdFilePaths: string[] = []; beforeAll(async () => { // This setup is now simpler as the worker handles fetching master items. }); afterAll(async () => { // Use the centralized cleanup utility. await cleanupDb({ userIds: createdUserIds, flyerIds: createdFlyerIds, }); // Use the centralized file cleanup utility. await cleanupFiles(createdFilePaths); }); /** * 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) => { // Arrange: Load a mock flyer PDF. 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([uniqueContent], uniqueFileName, { type: 'image/jpeg' }); const checksum = await generateFileChecksum(mockImageFile); // Track created files for cleanup const uploadDir = path.resolve(__dirname, '../../../flyer-images'); createdFilePaths.push(path.join(uploadDir, uniqueFileName)); // 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 uploadReq = request .post('/api/ai/upload-and-process') .field('checksum', checksum) .attach('flyerFile', uniqueContent, uniqueFileName); if (token) { uploadReq.set('Authorization', `Bearer ${token}`); } const uploadResponse = await uploadReq; const { jobId } = uploadResponse.body; // Assert 1: Check that a job ID was returned. expect(jobId).toBeTypeOf('string'); // Act 2: Poll for the job status until it completes. let jobStatus; const maxRetries = 30; // Poll for up to 90 seconds (30 * 3s) for (let i = 0; i < maxRetries; i++) { await new Promise((resolve) => setTimeout(resolve, 3000)); // Wait 3 seconds between polls const statusReq = request.get(`/api/ai/jobs/${jobId}/status`); if (token) { statusReq.set('Authorization', `Bearer ${token}`); } const statusResponse = await statusReq; jobStatus = statusResponse.body; if (jobStatus.state === 'completed' || jobStatus.state === 'failed') { break; } } // 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] 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); 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 ({ onTestFinished, }) => { // 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 // Use a cleanup function to delete the user even if the test fails. onTestFinished(async () => { await getPool().query('DELETE FROM public.users WHERE user_id = $1', [authUser.user.user_id]); }); // Act & Assert await runBackgroundProcessingTest(authUser, token); }, 120000); // Increase timeout to 120 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(); }, 120000); // Increase timeout to 120 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([imageWithExifBuffer], uniqueFileName, { type: 'image/jpeg' }); const checksum = await generateFileChecksum(mockImageFile); // Track original and derived files for cleanup const uploadDir = path.resolve(__dirname, '../../../flyer-images'); 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('checksum', checksum) .attach('flyerFile', imageWithExifBuffer, uniqueFileName); const { jobId } = uploadResponse.body; expect(jobId).toBeTypeOf('string'); // Poll for job completion let jobStatus; const maxRetries = 30; // Poll for up to 90 seconds for (let i = 0; i < maxRetries; i++) { await new Promise((resolve) => setTimeout(resolve, 3000)); const statusResponse = await request .get(`/api/ai/jobs/${jobId}/status`) .set('Authorization', `Bearer ${token}`); jobStatus = statusResponse.body; if (jobStatus.state === 'completed' || jobStatus.state === 'failed') { break; } } // 3. Assert if (jobStatus?.state === 'failed') { console.error('[DEBUG] EXIF test job failed:', jobStatus.failedReason); } 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(); 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(); // The `tags` object will be empty if no EXIF data is found. expect(exifResult.tags).toEqual({}); expect(exifResult.tags.Software).toBeUndefined(); }, 120000, ); 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([Buffer.from(imageWithMetadataBuffer)], uniqueFileName, { type: 'image/png' }); const checksum = await generateFileChecksum(mockImageFile); // Track files for cleanup const uploadDir = path.resolve(__dirname, '../../../flyer-images'); 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('checksum', checksum) .attach('flyerFile', imageWithMetadataBuffer, uniqueFileName); const { jobId } = uploadResponse.body; expect(jobId).toBeTypeOf('string'); // Poll for job completion let jobStatus; const maxRetries = 30; for (let i = 0; i < maxRetries; i++) { await new Promise((resolve) => setTimeout(resolve, 3000)); const statusResponse = await request .get(`/api/ai/jobs/${jobId}/status`) .set('Authorization', `Bearer ${token}`); jobStatus = statusResponse.body; if (jobStatus.state === 'completed' || jobStatus.state === 'failed') { break; } } // 3. Assert job completion if (jobStatus?.state === 'failed') { console.error('[DEBUG] PNG metadata test job failed:', jobStatus.failedReason); } 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(); const savedImagePath = path.join(uploadDir, path.basename(savedFlyer!.image_url)); createdFilePaths.push(savedImagePath); // Add final path for cleanup 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(); }, 120000, ); });