704 lines
29 KiB
TypeScript
704 lines
29 KiB
TypeScript
// 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<typeof import('../../utils/imageProcessor')>(
|
|
'../../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<typeof import('../../services/aiService.server')>();
|
|
|
|
// 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<typeof supertest>;
|
|
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);
|
|
});
|