869 lines
33 KiB
TypeScript
869 lines
33 KiB
TypeScript
// src/services/flyerProcessingService.server.test.ts
|
|
import { describe, it, expect, vi, beforeEach, type Mocked } from 'vitest';
|
|
import { Job, UnrecoverableError } from 'bullmq';
|
|
import path from 'node:path';
|
|
import type { FlyerInsert } from '../types';
|
|
import type { CleanupJobData, FlyerJobData } from '../types/job-data';
|
|
import { getFlyerBaseUrl } from '../tests/utils/testHelpers';
|
|
|
|
const FLYER_BASE_URL = getFlyerBaseUrl();
|
|
|
|
// 1. Create hoisted mocks FIRST
|
|
const mocks = vi.hoisted(() => ({
|
|
unlink: vi.fn(),
|
|
rename: vi.fn(),
|
|
readdir: vi.fn(),
|
|
execAsync: vi.fn(),
|
|
mockAdminLogActivity: vi.fn(),
|
|
// Shared mock logger for verifying calls
|
|
sharedMockLogger: {
|
|
info: vi.fn(),
|
|
error: vi.fn(),
|
|
warn: vi.fn(),
|
|
debug: vi.fn(),
|
|
child: vi.fn().mockReturnThis(),
|
|
},
|
|
}));
|
|
|
|
// 2. Mock modules using the hoisted variables
|
|
vi.mock('util', () => ({ promisify: () => mocks.execAsync }));
|
|
vi.mock('node:fs/promises', async (importOriginal) => {
|
|
const actual = await importOriginal<typeof import('node:fs/promises')>();
|
|
return {
|
|
...actual,
|
|
default: actual, // Ensure default export exists
|
|
unlink: mocks.unlink,
|
|
readdir: mocks.readdir,
|
|
rename: mocks.rename,
|
|
};
|
|
});
|
|
|
|
// Import service and dependencies (FlyerJobData already imported from types above)
|
|
import { FlyerProcessingService } from './flyerProcessingService.server';
|
|
import * as db from './db/index.db';
|
|
import { createMockFlyer } from '../tests/utils/mockFactories';
|
|
import { FlyerDataTransformer } from './flyerDataTransformer';
|
|
import {
|
|
AiDataValidationError,
|
|
PdfConversionError,
|
|
UnsupportedFileTypeError,
|
|
DatabaseError,
|
|
} from './processingErrors';
|
|
import { NotFoundError } from './db/errors.db';
|
|
import { FlyerFileHandler } from './flyerFileHandler.server';
|
|
import { FlyerAiProcessor } from './flyerAiProcessor.server';
|
|
import type { IFileSystem } from './flyerFileHandler.server';
|
|
import { generateFlyerIcon } from '../utils/imageProcessor';
|
|
import type { AIService } from './aiService.server';
|
|
import { FlyerPersistenceService } from './flyerPersistenceService.server';
|
|
|
|
// Mock image processor functions
|
|
vi.mock('../utils/imageProcessor', () => ({
|
|
generateFlyerIcon: vi.fn(),
|
|
}));
|
|
|
|
// Mock dependencies
|
|
vi.mock('./aiService.server', () => ({
|
|
aiService: {
|
|
extractCoreDataFromFlyerImage: vi.fn(),
|
|
},
|
|
}));
|
|
vi.mock('./db/index.db', () => ({
|
|
personalizationRepo: { getAllMasterItems: vi.fn() },
|
|
adminRepo: { logActivity: vi.fn() },
|
|
flyerRepo: { getFlyerById: vi.fn() },
|
|
withTransaction: vi.fn(),
|
|
}));
|
|
vi.mock('./db/admin.db', () => ({
|
|
AdminRepository: vi.fn().mockImplementation(function () {
|
|
return { logActivity: mocks.mockAdminLogActivity };
|
|
}),
|
|
}));
|
|
// Use the hoisted shared mock logger instance so tests can verify calls
|
|
vi.mock('./logger.server', () => ({
|
|
logger: mocks.sharedMockLogger,
|
|
createScopedLogger: vi.fn(() => mocks.sharedMockLogger),
|
|
}));
|
|
vi.mock('./flyerFileHandler.server');
|
|
vi.mock('./flyerAiProcessor.server');
|
|
vi.mock('./flyerPersistenceService.server');
|
|
|
|
const mockedDb = db as Mocked<typeof db>;
|
|
|
|
describe('FlyerProcessingService', () => {
|
|
let service: FlyerProcessingService;
|
|
let mockFileHandler: Mocked<FlyerFileHandler>;
|
|
let mockAiProcessor: Mocked<FlyerAiProcessor>;
|
|
let mockPersistenceService: Mocked<FlyerPersistenceService>;
|
|
const mockCleanupQueue = {
|
|
add: vi.fn(),
|
|
};
|
|
|
|
beforeEach(() => {
|
|
vi.clearAllMocks();
|
|
|
|
// Provide a default mock implementation for withTransaction that just executes the callback.
|
|
// This is needed for the happy path tests. Tests for transaction failures will override this.
|
|
vi.mocked(mockedDb.withTransaction).mockImplementation(async (callback: any) => callback({}));
|
|
|
|
// Spy on the real transformer's method and provide a mock implementation.
|
|
// This is more robust than mocking the entire class constructor.
|
|
vi.spyOn(FlyerDataTransformer.prototype, 'transform').mockResolvedValue({
|
|
flyerData: {
|
|
file_name: 'test.jpg',
|
|
image_url: 'https://example.com/test.jpg',
|
|
icon_url: 'https://example.com/icon.webp',
|
|
store_name: 'Mock Store',
|
|
// Add required fields for FlyerInsert type
|
|
status: 'processed',
|
|
item_count: 0,
|
|
valid_from: '2024-01-01',
|
|
valid_to: '2024-01-07',
|
|
} as FlyerInsert, // Cast is okay here as it's a mock value
|
|
itemsForDb: [],
|
|
});
|
|
|
|
// Default mock for readdir returns an empty array of Dirent-like objects.
|
|
mocks.readdir.mockResolvedValue([]);
|
|
|
|
// Mock the file system adapter that will be passed to the service
|
|
const mockFs: IFileSystem = {
|
|
readdir: mocks.readdir,
|
|
unlink: mocks.unlink,
|
|
rename: mocks.rename,
|
|
};
|
|
|
|
mockFileHandler = new FlyerFileHandler(mockFs, vi.fn()) as Mocked<FlyerFileHandler>;
|
|
mockAiProcessor = new FlyerAiProcessor(
|
|
{} as AIService,
|
|
mockedDb.personalizationRepo,
|
|
) as Mocked<FlyerAiProcessor>;
|
|
mockPersistenceService = new FlyerPersistenceService() as Mocked<FlyerPersistenceService>;
|
|
|
|
// Instantiate the service with all its dependencies mocked
|
|
service = new FlyerProcessingService(
|
|
mockFileHandler,
|
|
mockAiProcessor,
|
|
mockFs,
|
|
mockCleanupQueue,
|
|
new FlyerDataTransformer(),
|
|
mockPersistenceService,
|
|
);
|
|
|
|
// Provide default successful mock implementations for dependencies
|
|
mockAiProcessor.extractAndValidateData.mockResolvedValue({
|
|
data: {
|
|
store_name: 'Mock Store',
|
|
valid_from: '2024-01-01',
|
|
valid_to: '2024-01-07',
|
|
store_address: '123 Mock St',
|
|
items: [
|
|
{
|
|
item: 'Test Item',
|
|
price_display: '$1.99',
|
|
price_in_cents: 199,
|
|
quantity: 'each',
|
|
category_name: 'Test Category',
|
|
master_item_id: 1,
|
|
},
|
|
],
|
|
},
|
|
needsReview: false,
|
|
});
|
|
mockFileHandler.prepareImageInputs.mockResolvedValue({
|
|
imagePaths: [{ path: '/tmp/flyer.jpg', mimetype: 'image/jpeg' }],
|
|
createdImagePaths: [],
|
|
});
|
|
|
|
mockPersistenceService.saveFlyer.mockResolvedValue(
|
|
createMockFlyer({
|
|
flyer_id: 1,
|
|
file_name: 'test.jpg',
|
|
image_url: 'https://example.com/test.jpg',
|
|
item_count: 1,
|
|
}),
|
|
);
|
|
vi.mocked(mockedDb.adminRepo.logActivity).mockResolvedValue();
|
|
// FIX: Provide a default mock for getAllMasterItems to prevent a TypeError on `.length`.
|
|
vi.mocked(mockedDb.personalizationRepo.getAllMasterItems).mockResolvedValue({
|
|
items: [],
|
|
total: 0,
|
|
});
|
|
});
|
|
beforeEach(() => {
|
|
vi.mocked(generateFlyerIcon).mockResolvedValue('icon-flyer.webp');
|
|
});
|
|
|
|
const createMockJob = (data: Partial<FlyerJobData>): Job<FlyerJobData> => {
|
|
return {
|
|
id: 'job-1',
|
|
data: {
|
|
filePath: '/tmp/flyer.jpg',
|
|
originalFileName: 'flyer.jpg',
|
|
checksum: 'checksum-123',
|
|
baseUrl: 'https://example.com',
|
|
...data,
|
|
},
|
|
updateProgress: vi.fn(),
|
|
opts: { attempts: 3 },
|
|
attemptsMade: 1,
|
|
} as unknown as Job<FlyerJobData>;
|
|
};
|
|
|
|
const createMockCleanupJob = (data: CleanupJobData): Job<CleanupJobData> => {
|
|
return {
|
|
id: `cleanup-job-${data.flyerId}`,
|
|
data,
|
|
opts: { attempts: 3 },
|
|
attemptsMade: 1,
|
|
updateProgress: vi.fn(),
|
|
} as unknown as Job<CleanupJobData>;
|
|
};
|
|
|
|
describe('processJob (Orchestrator)', () => {
|
|
it('should process an image file successfully and enqueue a cleanup job', async () => {
|
|
const job = createMockJob({ filePath: '/tmp/flyer.jpg', originalFileName: 'flyer.jpg' });
|
|
|
|
// Arrange: Mock dependencies to simulate a successful run
|
|
mockFileHandler.prepareImageInputs.mockResolvedValue({
|
|
imagePaths: [{ path: '/tmp/flyer-processed.jpeg', mimetype: 'image/jpeg' }],
|
|
createdImagePaths: ['/tmp/flyer-processed.jpeg'],
|
|
});
|
|
vi.mocked(generateFlyerIcon).mockResolvedValue('icon-flyer.webp');
|
|
|
|
const result = await service.processJob(job);
|
|
|
|
expect(result).toEqual({ flyerId: 1 });
|
|
|
|
// 1. File handler was called
|
|
expect(mockFileHandler.prepareImageInputs).toHaveBeenCalledWith(
|
|
job.data.filePath,
|
|
job,
|
|
expect.any(Object),
|
|
);
|
|
|
|
// 2. Optimization was called
|
|
expect(mockFileHandler.optimizeImages).toHaveBeenCalledWith(
|
|
expect.any(Array),
|
|
expect.any(Object),
|
|
);
|
|
|
|
// 3. AI processor was called
|
|
expect(mockAiProcessor.extractAndValidateData).toHaveBeenCalledTimes(1);
|
|
|
|
// 4. Icon was generated from the processed image
|
|
expect(generateFlyerIcon).toHaveBeenCalledWith(
|
|
'/tmp/flyer-processed.jpeg',
|
|
path.join('/tmp', 'icons'),
|
|
expect.any(Object),
|
|
);
|
|
|
|
// 5. Transformer was called with the correct filenames
|
|
expect(FlyerDataTransformer.prototype.transform).toHaveBeenCalledWith(
|
|
expect.any(Object), // aiResult
|
|
'flyer.jpg', // originalFileName
|
|
'flyer-processed.jpeg', // imageFileName
|
|
'icon-flyer.webp', // iconFileName
|
|
'checksum-123', // checksum
|
|
undefined, // userId
|
|
expect.any(Object), // logger
|
|
'https://example.com', // baseUrl
|
|
);
|
|
|
|
// 6. Persistence service was called
|
|
expect(mockPersistenceService.saveFlyer).toHaveBeenCalledWith(
|
|
expect.any(Object), // flyerData
|
|
[], // itemsForDb
|
|
undefined, // userId
|
|
expect.any(Object), // logger
|
|
);
|
|
|
|
// 7. Cleanup job was enqueued with all generated files
|
|
expect(mockCleanupQueue.add).toHaveBeenCalledWith(
|
|
'cleanup-flyer-files',
|
|
expect.objectContaining({
|
|
flyerId: 1,
|
|
paths: expect.arrayContaining([
|
|
expect.stringContaining('flyer.jpg'), // original job path
|
|
expect.stringContaining('flyer-processed.jpeg'), // from prepareImageInputs
|
|
expect.stringContaining('icon-flyer.webp'), // from generateFlyerIcon
|
|
]),
|
|
}),
|
|
expect.any(Object),
|
|
);
|
|
});
|
|
|
|
it('should convert a PDF, process its images, and enqueue a cleanup job for all files', async () => {
|
|
const job = createMockJob({ filePath: '/tmp/flyer.pdf', originalFileName: 'flyer.pdf' });
|
|
|
|
// Mock the file handler to return multiple created paths
|
|
const createdPaths = ['/tmp/flyer-1.jpg', '/tmp/flyer-2.jpg'];
|
|
mockFileHandler.prepareImageInputs.mockResolvedValue({
|
|
imagePaths: [
|
|
{ path: '/tmp/flyer-1.jpg', mimetype: 'image/jpeg' },
|
|
{ path: '/tmp/flyer-2.jpg', mimetype: 'image/jpeg' },
|
|
],
|
|
createdImagePaths: createdPaths,
|
|
});
|
|
vi.mocked(generateFlyerIcon).mockResolvedValue('icon-flyer-1.webp');
|
|
|
|
await service.processJob(job);
|
|
|
|
// Verify transaction and inner calls
|
|
expect(mockFileHandler.prepareImageInputs).toHaveBeenCalledWith(
|
|
'/tmp/flyer.pdf',
|
|
job,
|
|
expect.any(Object),
|
|
);
|
|
expect(mockAiProcessor.extractAndValidateData).toHaveBeenCalledTimes(1);
|
|
// Verify icon generation was called for the first page
|
|
expect(generateFlyerIcon).toHaveBeenCalledWith(
|
|
'/tmp/flyer-1.jpg',
|
|
path.join('/tmp', 'icons'),
|
|
expect.any(Object),
|
|
);
|
|
// Verify cleanup job includes original PDF and all generated/processed images
|
|
expect(mockCleanupQueue.add).toHaveBeenCalledWith(
|
|
'cleanup-flyer-files',
|
|
expect.objectContaining({
|
|
flyerId: 1,
|
|
paths: expect.arrayContaining([
|
|
expect.stringContaining('flyer.pdf'), // original job path
|
|
expect.stringContaining('flyer-1.jpg'), // from prepareImageInputs
|
|
expect.stringContaining('flyer-2.jpg'), // from prepareImageInputs
|
|
expect.stringContaining('icon-flyer-1.webp'), // from generateFlyerIcon
|
|
]),
|
|
}),
|
|
expect.any(Object),
|
|
);
|
|
});
|
|
|
|
it('should throw an error and not enqueue cleanup if the AI service fails', async () => {
|
|
const job = createMockJob({});
|
|
const { logger } = await import('./logger.server');
|
|
const aiError = new Error('AI model exploded');
|
|
mockAiProcessor.extractAndValidateData.mockRejectedValue(aiError);
|
|
|
|
await expect(service.processJob(job)).rejects.toThrow('AI model exploded');
|
|
|
|
expect(job.updateProgress).toHaveBeenCalledWith({
|
|
errorCode: 'UNKNOWN_ERROR',
|
|
message: 'AI model exploded',
|
|
stages: [
|
|
{
|
|
name: 'Preparing Inputs',
|
|
status: 'completed',
|
|
critical: true,
|
|
detail: '1 page(s) ready for AI.',
|
|
},
|
|
{
|
|
name: 'Image Optimization',
|
|
status: 'completed',
|
|
critical: true,
|
|
detail: 'Compressing and resizing images...',
|
|
},
|
|
{
|
|
name: 'Extracting Data with AI',
|
|
status: 'failed',
|
|
critical: true,
|
|
detail: 'AI model exploded',
|
|
},
|
|
{ name: 'Transforming AI Data', status: 'skipped', critical: true },
|
|
{ name: 'Saving to Database', status: 'skipped', critical: true },
|
|
],
|
|
});
|
|
expect(mockCleanupQueue.add).not.toHaveBeenCalled();
|
|
expect(logger.warn).toHaveBeenCalledWith(
|
|
'Job failed. Temporary files will NOT be cleaned up to allow for manual inspection.',
|
|
);
|
|
});
|
|
|
|
it('should throw UnrecoverableError for quota issues and not enqueue cleanup', async () => {
|
|
const job = createMockJob({});
|
|
// Simulate an AI error that contains a keyword for unrecoverable errors
|
|
const quotaError = new Error('AI model quota exceeded');
|
|
const { logger } = await import('./logger.server');
|
|
mockAiProcessor.extractAndValidateData.mockRejectedValue(quotaError);
|
|
|
|
await expect(service.processJob(job)).rejects.toThrow(UnrecoverableError);
|
|
|
|
expect(job.updateProgress).toHaveBeenCalledWith({
|
|
errorCode: 'QUOTA_EXCEEDED',
|
|
message: 'An AI quota has been exceeded. Please try again later.',
|
|
stages: [
|
|
{
|
|
name: 'Preparing Inputs',
|
|
status: 'completed',
|
|
critical: true,
|
|
detail: '1 page(s) ready for AI.',
|
|
},
|
|
{
|
|
name: 'Image Optimization',
|
|
status: 'completed',
|
|
critical: true,
|
|
detail: 'Compressing and resizing images...',
|
|
},
|
|
{
|
|
name: 'Extracting Data with AI',
|
|
status: 'failed',
|
|
critical: true,
|
|
detail: 'AI model quota exceeded',
|
|
},
|
|
{ name: 'Transforming AI Data', status: 'skipped', critical: true },
|
|
{ name: 'Saving to Database', status: 'skipped', critical: true },
|
|
],
|
|
});
|
|
expect(mockCleanupQueue.add).not.toHaveBeenCalled();
|
|
expect(logger.warn).toHaveBeenCalledWith(
|
|
'Job failed. Temporary files will NOT be cleaned up to allow for manual inspection.',
|
|
);
|
|
});
|
|
|
|
it('should throw PdfConversionError and not enqueue cleanup if PDF conversion fails', async () => {
|
|
const job = createMockJob({ filePath: '/tmp/bad.pdf', originalFileName: 'bad.pdf' });
|
|
const { logger } = await import('./logger.server');
|
|
const conversionError = new PdfConversionError('Conversion failed', 'pdftocairo error');
|
|
mockFileHandler.prepareImageInputs.mockRejectedValue(conversionError);
|
|
|
|
await expect(service.processJob(job)).rejects.toThrow(conversionError);
|
|
|
|
// Use `toHaveBeenLastCalledWith` to check only the final error payload, ignoring earlier progress updates.
|
|
expect(job.updateProgress).toHaveBeenLastCalledWith({
|
|
errorCode: 'PDF_CONVERSION_FAILED',
|
|
message:
|
|
'The uploaded PDF could not be processed. It might be blank, corrupt, or password-protected.', // This was a duplicate, fixed.
|
|
stderr: 'pdftocairo error',
|
|
stages: [
|
|
{
|
|
name: 'Preparing Inputs',
|
|
status: 'failed',
|
|
critical: true,
|
|
detail:
|
|
'The uploaded PDF could not be processed. It might be blank, corrupt, or password-protected.',
|
|
},
|
|
{ name: 'Image Optimization', status: 'skipped', critical: true },
|
|
{ name: 'Extracting Data with AI', status: 'skipped', critical: true },
|
|
{ name: 'Transforming AI Data', status: 'skipped', critical: true },
|
|
{ name: 'Saving to Database', status: 'skipped', critical: true },
|
|
],
|
|
});
|
|
expect(mockCleanupQueue.add).not.toHaveBeenCalled();
|
|
expect(logger.warn).toHaveBeenCalledWith(
|
|
'Job failed. Temporary files will NOT be cleaned up to allow for manual inspection.',
|
|
);
|
|
});
|
|
|
|
it('should throw AiDataValidationError and not enqueue cleanup if AI validation fails', async () => {
|
|
const { logger } = await import('./logger.server');
|
|
const job = createMockJob({});
|
|
const validationError = new AiDataValidationError('Validation failed', {}, {});
|
|
mockAiProcessor.extractAndValidateData.mockRejectedValue(validationError);
|
|
|
|
await expect(service.processJob(job)).rejects.toThrow(validationError);
|
|
|
|
// Verify the specific error handling logic in the catch block
|
|
expect(logger.error).toHaveBeenCalledWith(
|
|
{
|
|
err: validationError,
|
|
errorCode: 'AI_VALIDATION_FAILED',
|
|
message:
|
|
"The AI couldn't read the flyer's format. Please try a clearer image or a different flyer.",
|
|
validationErrors: {},
|
|
rawData: {},
|
|
stages: expect.any(Array), // Stages will be dynamically generated
|
|
},
|
|
'A known processing error occurred: AiDataValidationError',
|
|
);
|
|
// Use `toHaveBeenLastCalledWith` to check only the final error payload.
|
|
// FIX: The payload from AiDataValidationError includes validationErrors and rawData.
|
|
expect(job.updateProgress).toHaveBeenLastCalledWith({
|
|
errorCode: 'AI_VALIDATION_FAILED',
|
|
message:
|
|
"The AI couldn't read the flyer's format. Please try a clearer image or a different flyer.", // This was a duplicate, fixed.
|
|
validationErrors: {},
|
|
rawData: {},
|
|
stages: [
|
|
{
|
|
name: 'Preparing Inputs',
|
|
status: 'completed',
|
|
critical: true,
|
|
detail: '1 page(s) ready for AI.',
|
|
},
|
|
{
|
|
name: 'Image Optimization',
|
|
status: 'completed',
|
|
critical: true,
|
|
detail: 'Compressing and resizing images...',
|
|
},
|
|
{
|
|
name: 'Extracting Data with AI',
|
|
status: 'failed',
|
|
critical: true,
|
|
detail:
|
|
"The AI couldn't read the flyer's format. Please try a clearer image or a different flyer.",
|
|
},
|
|
{ name: 'Transforming AI Data', status: 'skipped', critical: true },
|
|
{ name: 'Saving to Database', status: 'skipped', critical: true },
|
|
],
|
|
});
|
|
expect(mockCleanupQueue.add).not.toHaveBeenCalled();
|
|
expect(logger.warn).toHaveBeenCalledWith(
|
|
'Job failed. Temporary files will NOT be cleaned up to allow for manual inspection.',
|
|
);
|
|
});
|
|
|
|
it('should handle convertible image types and include original and converted files in cleanup', async () => {
|
|
const job = createMockJob({ filePath: '/tmp/flyer.gif', originalFileName: 'flyer.gif' });
|
|
const convertedPath = '/tmp/flyer-converted.png';
|
|
|
|
// Mock the file handler to return the converted path
|
|
mockFileHandler.prepareImageInputs.mockResolvedValue({
|
|
imagePaths: [{ path: convertedPath, mimetype: 'image/png' }],
|
|
createdImagePaths: [convertedPath],
|
|
});
|
|
vi.mocked(generateFlyerIcon).mockResolvedValue('icon-flyer-converted.webp');
|
|
|
|
await service.processJob(job);
|
|
|
|
// Verify transaction and inner calls
|
|
expect(mockFileHandler.prepareImageInputs).toHaveBeenCalledWith(
|
|
'/tmp/flyer.gif',
|
|
job,
|
|
expect.any(Object),
|
|
);
|
|
expect(mockAiProcessor.extractAndValidateData).toHaveBeenCalledTimes(1);
|
|
// Verify icon generation was called for the converted image
|
|
expect(generateFlyerIcon).toHaveBeenCalledWith(
|
|
convertedPath,
|
|
path.join('/tmp', 'icons'),
|
|
expect.any(Object),
|
|
);
|
|
expect(mockCleanupQueue.add).toHaveBeenCalledWith(
|
|
'cleanup-flyer-files',
|
|
expect.objectContaining({
|
|
flyerId: 1,
|
|
paths: expect.arrayContaining([
|
|
expect.stringContaining('flyer.gif'), // original job path
|
|
expect.stringContaining('flyer-converted.png'), // from prepareImageInputs
|
|
expect.stringContaining('icon-flyer-converted.webp'), // from generateFlyerIcon
|
|
]),
|
|
}),
|
|
expect.any(Object),
|
|
);
|
|
});
|
|
|
|
it('should throw an error and not enqueue cleanup if the database service fails', async () => {
|
|
const job = createMockJob({});
|
|
const { logger } = await import('./logger.server');
|
|
const dbError = new DatabaseError('Database transaction failed');
|
|
|
|
mockPersistenceService.saveFlyer.mockRejectedValue(dbError);
|
|
|
|
// The service wraps the generic DB error in a DatabaseError.
|
|
await expect(service.processJob(job)).rejects.toThrow(DatabaseError);
|
|
|
|
// The final progress update should reflect the structured DatabaseError.
|
|
expect(job.updateProgress).toHaveBeenLastCalledWith({
|
|
errorCode: 'DATABASE_ERROR',
|
|
message: 'A database operation failed. Please try again later.',
|
|
stages: [
|
|
{
|
|
name: 'Preparing Inputs',
|
|
status: 'completed',
|
|
critical: true,
|
|
detail: '1 page(s) ready for AI.',
|
|
},
|
|
{
|
|
name: 'Image Optimization',
|
|
status: 'completed',
|
|
critical: true,
|
|
detail: 'Compressing and resizing images...',
|
|
},
|
|
{
|
|
name: 'Extracting Data with AI',
|
|
status: 'completed',
|
|
critical: true,
|
|
detail: 'Communicating with AI model...',
|
|
},
|
|
{ name: 'Transforming AI Data', status: 'completed', critical: true },
|
|
{
|
|
name: 'Saving to Database',
|
|
status: 'failed',
|
|
critical: true,
|
|
detail: 'A database operation failed. Please try again later.',
|
|
},
|
|
],
|
|
});
|
|
expect(mockCleanupQueue.add).not.toHaveBeenCalled();
|
|
expect(logger.warn).toHaveBeenCalledWith(
|
|
'Job failed. Temporary files will NOT be cleaned up to allow for manual inspection.',
|
|
);
|
|
});
|
|
|
|
it('should throw UnsupportedFileTypeError for an unsupported file type', async () => {
|
|
const job = createMockJob({
|
|
filePath: '/tmp/document.txt',
|
|
originalFileName: 'document.txt',
|
|
});
|
|
const fileTypeError = new UnsupportedFileTypeError(
|
|
'Unsupported file type: .txt. Supported types are PDF, JPG, PNG, WEBP, HEIC, HEIF, GIF, TIFF, SVG, BMP.',
|
|
);
|
|
mockFileHandler.prepareImageInputs.mockRejectedValue(fileTypeError);
|
|
const { logger } = await import('./logger.server');
|
|
|
|
const reportErrorSpy = vi.spyOn(service as any, '_reportErrorAndThrow');
|
|
|
|
await expect(service.processJob(job)).rejects.toThrow(UnsupportedFileTypeError);
|
|
|
|
expect(reportErrorSpy).toHaveBeenCalledWith(
|
|
fileTypeError,
|
|
job,
|
|
expect.any(Object),
|
|
expect.any(Array),
|
|
);
|
|
expect(mockCleanupQueue.add).not.toHaveBeenCalled();
|
|
expect(logger.warn).toHaveBeenCalledWith(
|
|
'Job failed. Temporary files will NOT be cleaned up to allow for manual inspection.',
|
|
);
|
|
});
|
|
|
|
it('should delegate to _reportErrorAndThrow if icon generation fails', async () => {
|
|
const job = createMockJob({});
|
|
const { logger } = await import('./logger.server');
|
|
const iconGenError = new Error('Icon generation failed.');
|
|
vi.mocked(generateFlyerIcon).mockRejectedValue(iconGenError);
|
|
|
|
const reportErrorSpy = vi.spyOn(service as any, '_reportErrorAndThrow');
|
|
|
|
await expect(service.processJob(job)).rejects.toThrow('Icon generation failed.');
|
|
|
|
expect(reportErrorSpy).toHaveBeenCalledWith(
|
|
iconGenError,
|
|
job,
|
|
expect.any(Object),
|
|
expect.any(Array),
|
|
);
|
|
expect(mockCleanupQueue.add).not.toHaveBeenCalled();
|
|
expect(logger.warn).toHaveBeenCalledWith(
|
|
'Job failed. Temporary files will NOT be cleaned up to allow for manual inspection.',
|
|
);
|
|
});
|
|
});
|
|
|
|
describe('_reportErrorAndThrow (Error Reporting Logic)', () => {
|
|
it('should update progress with a generic error and re-throw', async () => {
|
|
const { logger } = await import('./logger.server');
|
|
const job = createMockJob({});
|
|
const genericError = new Error('A standard failure');
|
|
const initialStages = [
|
|
{ name: 'Stage 1', status: 'completed', critical: true, detail: 'Done' },
|
|
{ name: 'Stage 2', status: 'in-progress', critical: true, detail: 'Working...' },
|
|
{ name: 'Stage 3', status: 'pending', critical: true, detail: 'Waiting...' },
|
|
];
|
|
const privateMethod = (service as any)._reportErrorAndThrow;
|
|
|
|
await expect(privateMethod(genericError, job, logger, initialStages)).rejects.toThrow(
|
|
genericError,
|
|
);
|
|
|
|
expect(job.updateProgress).toHaveBeenCalledWith({
|
|
errorCode: 'UNKNOWN_ERROR',
|
|
message: 'A standard failure',
|
|
stages: [
|
|
{ name: 'Stage 1', status: 'completed', critical: true, detail: 'Done' },
|
|
{ name: 'Stage 2', status: 'failed', critical: true, detail: 'A standard failure' },
|
|
{ name: 'Stage 3', status: 'skipped', critical: true },
|
|
],
|
|
});
|
|
});
|
|
|
|
it('should use toErrorPayload for FlyerProcessingError instances', async () => {
|
|
const { logger } = await import('./logger.server');
|
|
const job = createMockJob({});
|
|
const validationError = new AiDataValidationError(
|
|
'Validation failed',
|
|
{ foo: 'bar' },
|
|
{ raw: 'data' },
|
|
);
|
|
const initialStages = [
|
|
{ name: 'Extracting Data with AI', status: 'in-progress', critical: true, detail: '...' },
|
|
];
|
|
const privateMethod = (service as any)._reportErrorAndThrow;
|
|
|
|
await expect(privateMethod(validationError, job, logger, initialStages)).rejects.toThrow(
|
|
validationError,
|
|
);
|
|
|
|
expect(job.updateProgress).toHaveBeenCalledWith({
|
|
errorCode: 'AI_VALIDATION_FAILED',
|
|
message:
|
|
"The AI couldn't read the flyer's format. Please try a clearer image or a different flyer.",
|
|
validationErrors: { foo: 'bar' },
|
|
rawData: { raw: 'data' },
|
|
stages: [
|
|
{
|
|
name: 'Extracting Data with AI',
|
|
status: 'failed',
|
|
critical: true,
|
|
detail:
|
|
"The AI couldn't read the flyer's format. Please try a clearer image or a different flyer.",
|
|
},
|
|
],
|
|
});
|
|
});
|
|
|
|
it('should throw UnrecoverableError for quota messages', async () => {
|
|
const { logger } = await import('./logger.server');
|
|
const job = createMockJob({});
|
|
const quotaError = new Error('RESOURCE_EXHAUSTED');
|
|
const privateMethod = (service as any)._reportErrorAndThrow;
|
|
|
|
await expect(privateMethod(quotaError, job, logger, [])).rejects.toThrow(UnrecoverableError);
|
|
|
|
expect(job.updateProgress).toHaveBeenCalledWith({
|
|
errorCode: 'QUOTA_EXCEEDED',
|
|
message: 'An AI quota has been exceeded. Please try again later.',
|
|
stages: [],
|
|
});
|
|
});
|
|
|
|
it('should wrap and throw non-Error objects', async () => {
|
|
const { logger } = await import('./logger.server');
|
|
const job = createMockJob({});
|
|
const nonError = 'just a string error';
|
|
const privateMethod = (service as any)._reportErrorAndThrow;
|
|
|
|
await expect(privateMethod(nonError, job, logger, [])).rejects.toThrow('just a string error');
|
|
});
|
|
|
|
it('should correctly identify the failed stage based on error code', async () => {
|
|
const { logger } = await import('./logger.server');
|
|
const job = createMockJob({});
|
|
const pdfError = new PdfConversionError('PDF failed');
|
|
const initialStages = [
|
|
{ name: 'Preparing Inputs', status: 'in-progress', critical: true, detail: '...' },
|
|
{ name: 'Extracting Data with AI', status: 'pending', critical: true, detail: '...' },
|
|
];
|
|
const privateMethod = (service as any)._reportErrorAndThrow;
|
|
|
|
await expect(privateMethod(pdfError, job, logger, initialStages)).rejects.toThrow(pdfError);
|
|
|
|
expect(job.updateProgress).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
stages: [
|
|
{
|
|
name: 'Preparing Inputs',
|
|
status: 'failed',
|
|
critical: true,
|
|
detail: expect.any(String),
|
|
},
|
|
{ name: 'Extracting Data with AI', status: 'skipped', critical: true },
|
|
],
|
|
}),
|
|
);
|
|
});
|
|
});
|
|
|
|
describe('processCleanupJob', () => {
|
|
it('should delete all files successfully', async () => {
|
|
const job = createMockCleanupJob({ flyerId: 1, paths: ['/tmp/file1', '/tmp/file2'] });
|
|
mocks.unlink.mockResolvedValue(undefined);
|
|
|
|
const result = await service.processCleanupJob(job);
|
|
|
|
expect(mocks.unlink).toHaveBeenCalledTimes(2);
|
|
expect(mocks.unlink).toHaveBeenCalledWith('/tmp/file1');
|
|
expect(mocks.unlink).toHaveBeenCalledWith('/tmp/file2');
|
|
expect(result).toEqual({ status: 'success', deletedCount: 2 });
|
|
});
|
|
|
|
it('should handle ENOENT errors gracefully and still succeed', async () => {
|
|
const job = createMockCleanupJob({ flyerId: 1, paths: ['/tmp/file1', '/tmp/file2'] });
|
|
const enoentError: NodeJS.ErrnoException = new Error('File not found');
|
|
enoentError.code = 'ENOENT';
|
|
|
|
mocks.unlink.mockResolvedValueOnce(undefined).mockRejectedValueOnce(enoentError);
|
|
|
|
const result = await service.processCleanupJob(job);
|
|
|
|
expect(mocks.unlink).toHaveBeenCalledTimes(2);
|
|
expect(result).toEqual({ status: 'success', deletedCount: 2 });
|
|
// Check that the warning was logged
|
|
const { logger } = await import('./logger.server');
|
|
expect(logger.warn).toHaveBeenCalledWith(
|
|
'File not found during cleanup (already deleted?): /tmp/file2',
|
|
);
|
|
});
|
|
|
|
it('should throw an aggregate error if a non-ENOENT error occurs', async () => {
|
|
const job = createMockCleanupJob({
|
|
flyerId: 1,
|
|
paths: ['/tmp/file1', '/tmp/permission-denied'],
|
|
});
|
|
const permissionError: NodeJS.ErrnoException = new Error('Permission denied');
|
|
permissionError.code = 'EACCES';
|
|
|
|
mocks.unlink.mockResolvedValueOnce(undefined).mockRejectedValueOnce(permissionError);
|
|
|
|
await expect(service.processCleanupJob(job)).rejects.toThrow(
|
|
'Failed to delete 1 file(s): /tmp/permission-denied',
|
|
);
|
|
|
|
// Check that the error was logged
|
|
const { logger } = await import('./logger.server');
|
|
expect(logger.error).toHaveBeenCalledWith(
|
|
expect.objectContaining({ err: permissionError, path: '/tmp/permission-denied' }),
|
|
'Failed to delete temporary file.',
|
|
);
|
|
});
|
|
|
|
it('should skip processing and return "skipped" if paths array is empty and paths cannot be derived', async () => {
|
|
const job = createMockCleanupJob({ flyerId: 1, paths: [] });
|
|
// Mock that the flyer cannot be found in the DB, so paths cannot be derived.
|
|
vi.mocked(mockedDb.flyerRepo.getFlyerById).mockRejectedValue(new NotFoundError('Not found'));
|
|
|
|
const result = await service.processCleanupJob(job);
|
|
|
|
expect(mocks.unlink).not.toHaveBeenCalled();
|
|
expect(result).toEqual({ status: 'skipped', reason: 'no paths derived' });
|
|
const { logger } = await import('./logger.server');
|
|
// Check for both warnings: the attempt to derive, and the final skip message.
|
|
expect(logger.warn).toHaveBeenCalledWith(
|
|
'Cleanup job for flyer 1 received no paths. Attempting to derive paths from DB.',
|
|
);
|
|
expect(logger.warn).toHaveBeenCalledWith(
|
|
'Job received no paths and could not derive any from the database. Skipping.',
|
|
);
|
|
});
|
|
|
|
it('should derive paths from DB and delete files if job paths are empty', async () => {
|
|
const storagePath = path.join('/var', 'www', 'app', 'flyer-images');
|
|
const expectedImagePath = path.join(storagePath, 'flyer-abc.jpg');
|
|
const expectedIconPath = path.join(storagePath, 'icons', 'icon-flyer-abc.webp');
|
|
|
|
const job = createMockCleanupJob({ flyerId: 1, paths: [] }); // Empty paths
|
|
const mockFlyer = createMockFlyer({
|
|
image_url: `${FLYER_BASE_URL}/flyer-images/flyer-abc.jpg`,
|
|
icon_url: `${FLYER_BASE_URL}/flyer-images/icons/icon-flyer-abc.webp`,
|
|
});
|
|
// Mock DB call to return a flyer
|
|
vi.mocked(mockedDb.flyerRepo.getFlyerById).mockResolvedValue(mockFlyer);
|
|
mocks.unlink.mockResolvedValue(undefined);
|
|
|
|
// Mock process.env.STORAGE_PATH
|
|
vi.stubEnv('STORAGE_PATH', storagePath);
|
|
|
|
const result = await service.processCleanupJob(job);
|
|
|
|
expect(result).toEqual({ status: 'success', deletedCount: 2 });
|
|
expect(mocks.unlink).toHaveBeenCalledTimes(2);
|
|
expect(mocks.unlink).toHaveBeenCalledWith(expectedImagePath);
|
|
expect(mocks.unlink).toHaveBeenCalledWith(expectedIconPath);
|
|
const { logger } = await import('./logger.server');
|
|
expect(logger.warn).toHaveBeenCalledWith(
|
|
'Cleanup job for flyer 1 received no paths. Attempting to derive paths from DB.',
|
|
);
|
|
});
|
|
});
|
|
});
|