fix tests ugh
All checks were successful
Deploy to Web Server flyer-crawler.projectium.com / deploy (push) Successful in 5m57s

This commit is contained in:
2025-12-09 17:38:23 -08:00
parent 6354189d5c
commit b3372b6fa3
3 changed files with 350 additions and 236 deletions

View File

@@ -2,6 +2,8 @@
//
// 2024-07-30: Fixed `FlyerDataTransformer` mock to be a constructible class. The previous mock was not a constructor,
// causing a `TypeError` when `FlyerProcessingService` tried to instantiate it with `new`.
// 2024-12-09: Fixed duplicate imports of FlyerProcessingService and FlyerJobData. Consolidated imports to use
// FlyerJobData from types file and FlyerProcessingService from server file.
// --- END FIX REGISTRY ---
// src/services/flyerProcessingService.server.test.ts
import { describe, it, expect, vi, beforeEach, type Mocked } from 'vitest';
@@ -28,6 +30,7 @@ vi.mock('node:fs/promises', async (importOriginal) => {
};
});
// Import service and dependencies (FlyerJobData already imported from types above)
import { FlyerProcessingService } from './flyerProcessingService.server';
import * as aiService from './aiService.server';
import * as db from './db/index.db';
@@ -258,4 +261,94 @@ describe('FlyerProcessingService', () => {
});
});
describe('_convertPdfToImages (private method)', () => {
it('should call pdftocairo and return sorted image paths on success', async () => {
const job = createMockJob({ filePath: '/tmp/test.pdf' });
// Mock readdir to return unsorted Dirent-like objects
mocks.readdir.mockResolvedValue([
{ name: 'test-10.jpg' },
{ name: 'test-1.jpg' },
{ name: 'test-2.jpg' },
{ name: 'other-file.txt' },
] as Dirent[]);
// Access and call the private method for testing
const imagePaths = await (service as any)._convertPdfToImages('/tmp/test.pdf', job);
expect(mocks.execAsync).toHaveBeenCalledWith(
'pdftocairo -jpeg -r 150 "/tmp/test.pdf" "/tmp/test"'
);
expect(job.updateProgress).toHaveBeenCalledWith({ message: 'Converting PDF to images...' });
// Verify that the paths are correctly sorted numerically
expect(imagePaths).toEqual([
'/tmp/test-1.jpg',
'/tmp/test-2.jpg',
'/tmp/test-10.jpg',
]);
});
it('should throw PdfConversionError if no images are generated', async () => {
const job = createMockJob({ filePath: '/tmp/empty.pdf' });
// Mock readdir to return no matching files
mocks.readdir.mockResolvedValue([]);
await expect((service as any)._convertPdfToImages('/tmp/empty.pdf', job))
.rejects.toThrow('PDF conversion resulted in 0 images for file: /tmp/empty.pdf');
});
it('should re-throw an error if the exec command fails', async () => {
const job = createMockJob({ filePath: '/tmp/bad.pdf' });
const commandError = new Error('pdftocairo not found');
mocks.execAsync.mockRejectedValue(commandError);
await expect((service as any)._convertPdfToImages('/tmp/bad.pdf', job))
.rejects.toThrow(commandError);
});
});
describe('_saveProcessedFlyerData (private method)', () => {
it('should transform data, create flyer in DB, and log activity', async () => {
// Arrange
const mockExtractedData = {
store_name: 'Test 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 }],
};
const mockImagePaths = [{ path: '/tmp/flyer.jpg', mimetype: 'image/jpeg' }];
const mockJobData = {
originalFileName: 'flyer.jpg',
checksum: 'checksum-123',
userId: 'user-abc',
};
// The transformer is already spied on in beforeEach, we can just check its call.
const transformerSpy = vi.spyOn(FlyerDataTransformer.prototype, 'transform');
// The DB create function is also mocked in beforeEach.
const mockNewFlyer = { flyer_id: 1, file_name: 'flyer.jpg', store_name: 'Test Store' };
vi.mocked(createFlyerAndItems).mockResolvedValue({ flyer: mockNewFlyer, items: [] } as any);
// Act: Access and call the private method for testing
const result = await (service as any)._saveProcessedFlyerData(mockExtractedData, mockImagePaths, mockJobData);
// Assert
// 1. Transformer was called correctly
expect(transformerSpy).toHaveBeenCalledWith(mockExtractedData, mockImagePaths, mockJobData.originalFileName, mockJobData.checksum, mockJobData.userId);
// 2. DB function was called with the transformed data
const transformedData = await transformerSpy.mock.results[0].value;
expect(createFlyerAndItems).toHaveBeenCalledWith(transformedData.flyerData, transformedData.itemsForDb);
// 3. Activity was logged
expect(mockedDb.adminRepo.logActivity).toHaveBeenCalledWith(expect.objectContaining({
action: 'flyer_processed',
details: { flyerId: mockNewFlyer.flyer_id, storeName: mockNewFlyer.store_name }
}));
// 4. The method returned the new flyer
expect(result).toEqual(mockNewFlyer);
});
});
});

View File

@@ -1,260 +1,280 @@
// --- FIX REGISTRY ---
//
// 2024-07-30: Fixed `FlyerDataTransformer` mock to be a constructible class. The previous mock was not a constructor,
// causing a `TypeError` when `FlyerProcessingService` tried to instantiate it with `new`.
// --- END FIX REGISTRY ---
// src/services/flyerProcessingService.server.test.ts
import { describe, it, expect, vi, beforeEach, type Mocked } from 'vitest';
import { Job } from 'bullmq';
// src/services/flyerProcessingService.server.ts
import type { Job, JobsOptions } from 'bullmq';
import path from 'path';
import type { Dirent } from 'node:fs';
import type { FlyerJobData } from './flyerProcessingService.types';
import { z } from 'zod';
// 1. Create hoisted mocks FIRST
const mocks = vi.hoisted(() => ({
unlink: vi.fn(),
readdir: vi.fn(),
execAsync: vi.fn(),
}));
// 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,
};
});
import { FlyerProcessingService, type FlyerJobData } from './flyerProcessingService.server';
import * as aiService from './aiService.server';
import * as db from './db/index.db';
import { logger } from './logger.server';
import type { AIService } from './aiService.server';
import type * as db from './db/index.db';
import { createFlyerAndItems } from './db/flyer.db';
import * as imageProcessor from '../utils/imageProcessor';
import { PdfConversionError, AiDataValidationError } from './processingErrors';
import { FlyerDataTransformer } from './flyerDataTransformer';
// Mock dependencies
vi.mock('./aiService.server', () => ({
aiService: {
extractCoreDataFromFlyerImage: vi.fn(),
},
}));
vi.mock('./db/flyer.db', () => ({
createFlyerAndItems: vi.fn(),
}));
vi.mock('./db/index.db', () => ({
personalizationRepo: { getAllMasterItems: vi.fn() },
adminRepo: { logActivity: vi.fn() },
}));
vi.mock('../utils/imageProcessor', () => ({
generateFlyerIcon: vi.fn().mockResolvedValue('icon-test.webp'),
}));
vi.mock('./logger.server', () => ({
logger: { info: vi.fn(), error: vi.fn(), warn: vi.fn(), debug: vi.fn() }
}));
// --- Start: Interfaces for Dependency Injection ---
const mockedAiService = aiService as Mocked<typeof aiService>;
const mockedDb = db as Mocked<typeof db>;
const mockedImageProcessor = imageProcessor as Mocked<typeof imageProcessor>;
export interface IFileSystem {
readdir(path: string, options: { withFileTypes: true }): Promise<Dirent[]>;
unlink(path: string): Promise<void>;
}
describe('FlyerProcessingService', () => {
let service: FlyerProcessingService;
const mockCleanupQueue = {
add: vi.fn(),
};
export interface ICommandExecutor {
(command: string): Promise<{ stdout: string; stderr: string }>;
}
beforeEach(() => {
vi.clearAllMocks();
export interface FlyerJobData {
filePath: string;
originalFileName: string;
checksum: string;
userId?: string;
submitterIp?: string;
userProfileAddress?: string;
}
// 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: 'test.jpg', icon_url: 'icon.webp', checksum: 'checksum-123', store_name: 'Mock Store' } as any,
itemsForDb: [],
interface CleanupJobData {
flyerId: number;
// An array of absolute file paths to be deleted. Made optional for manual cleanup triggers.
paths?: string[];
}
/**
* Defines the contract for a queue that can have cleanup jobs added to it.
* This is used for dependency injection to avoid circular dependencies.
*/
interface ICleanupQueue {
add(name: string, data: CleanupJobData, opts?: JobsOptions): Promise<Job<CleanupJobData>>;
}
// --- Zod Schemas for AI Response Validation (exported for the transformer) ---
const ExtractedFlyerItemSchema = z.object({
item: z.string(),
price_display: z.string(),
price_in_cents: z.number().nullable(),
quantity: z.string(),
category_name: z.string(),
master_item_id: z.number().nullish(), // .nullish() allows null or undefined
});
export const AiFlyerDataSchema = z.object({
store_name: z.string().min(1, { message: "Store name cannot be empty" }),
valid_from: z.string().nullable(),
valid_to: z.string().nullable(),
store_address: z.string().nullable(),
items: z.array(ExtractedFlyerItemSchema),
});
/**
* This class encapsulates the business logic for processing a flyer from a file.
* It handles PDF conversion, AI data extraction, and saving the results to the database.
*/
export class FlyerProcessingService {
constructor(
private ai: AIService,
private database: typeof db,
private fs: IFileSystem,
private exec: ICommandExecutor,
private cleanupQueue: ICleanupQueue,
private transformer: FlyerDataTransformer,
) {}
/**
* Converts a PDF file to a series of JPEG images using an external tool.
* @param filePath The path to the PDF file.
* @param job The BullMQ job instance for progress updates.
* @returns A promise that resolves to an array of paths to the created image files.
*/
private async _convertPdfToImages(filePath: string, job: Job<FlyerJobData>): Promise<string[]> {
logger.info(`[Worker] Starting PDF conversion for: ${filePath}`);
await job.updateProgress({ message: 'Converting PDF to images...' });
const outputDir = path.dirname(filePath);
const outputFilePrefix = path.join(outputDir, path.basename(filePath, '.pdf'));
logger.debug(`[Worker] PDF output directory: ${outputDir}`);
logger.debug(`[Worker] PDF output file prefix: ${outputFilePrefix}`);
const command = `pdftocairo -jpeg -r 150 "${filePath}" "${outputFilePrefix}"`;
logger.info(`[Worker] Executing PDF conversion command: ${command}`);
const { stdout, stderr } = await this.exec(command);
if (stdout) logger.debug(`[Worker] pdftocairo stdout for ${filePath}:`, { stdout });
if (stderr) logger.warn(`[Worker] pdftocairo stderr for ${filePath}:`, { stderr });
logger.debug(`[Worker] Reading contents of output directory: ${outputDir}`);
const filesInDir = await this.fs.readdir(outputDir, { withFileTypes: true });
logger.debug(`[Worker] Found ${filesInDir.length} total entries in output directory.`);
const generatedImages = filesInDir
.filter(f => f.name.startsWith(path.basename(outputFilePrefix)) && f.name.endsWith('.jpg'))
.sort((a, b) => a.name.localeCompare(b.name, undefined, { numeric: true }));
logger.debug(`[Worker] Filtered down to ${generatedImages.length} generated JPGs.`, {
imageNames: generatedImages.map(f => f.name),
});
// Default mock implementation for the promisified exec
mocks.execAsync.mockResolvedValue({ stdout: 'success', stderr: '' });
if (generatedImages.length === 0) {
const errorMessage = `PDF conversion resulted in 0 images for file: ${filePath}. The PDF might be blank or corrupt.`;
logger.error(`[Worker] PdfConversionError: ${errorMessage}`, { stderr });
throw new PdfConversionError(errorMessage, stderr);
}
// Default mock for readdir returns an empty array of Dirent-like objects.
mocks.readdir.mockResolvedValue([]);
return generatedImages.map(img => path.join(outputDir, img.name));
}
// Mock the file system adapter that will be passed to the service
const mockFs = {
readdir: mocks.readdir,
unlink: mocks.unlink,
};
/**
* Prepares the input images for the AI service. If the input is a PDF, it's converted to images.
* @param filePath The path to the original uploaded file.
* @param job The BullMQ job instance.
* @returns An object containing the final image paths for the AI and a list of any newly created image files.
*/
private async _prepareImageInputs(filePath: string, job: Job<FlyerJobData>): Promise<{ imagePaths: { path: string; mimetype: string }[], createdImagePaths: string[] }> {
const fileExt = path.extname(filePath).toLowerCase();
// Instantiate the service with all its dependencies mocked
service = new FlyerProcessingService(
mockedAiService.aiService,
mockedDb,
mockFs,
mocks.execAsync,
mockCleanupQueue,
new FlyerDataTransformer()
if (fileExt === '.pdf') {
const createdImagePaths = await this._convertPdfToImages(filePath, job);
const imagePaths = createdImagePaths.map(p => ({ path: p, mimetype: 'image/jpeg' }));
logger.info(`[Worker] Converted PDF to ${imagePaths.length} images.`);
return { imagePaths, createdImagePaths };
} else {
logger.info(`[Worker] Processing as a single image file: ${filePath}`);
const imagePaths = [{ path: filePath, mimetype: `image/${fileExt.slice(1)}` }];
return { imagePaths, createdImagePaths: [] };
}
}
/**
* Calls the AI service to extract structured data from the flyer images.
* @param imagePaths An array of paths and mimetypes for the images.
* @param jobData The data from the BullMQ job.
* @returns A promise that resolves to the validated, structured flyer data.
*/
private async _extractFlyerDataWithAI(imagePaths: { path: string; mimetype: string }[], jobData: FlyerJobData) {
logger.info(`[Worker] Starting AI data extraction for job ${jobData.checksum}.`);
const { submitterIp, userProfileAddress } = jobData;
const masterItems = await this.database.personalizationRepo.getAllMasterItems();
logger.debug(`[Worker] Retrieved ${masterItems.length} master items for AI matching.`);
const extractedData = await this.ai.extractCoreDataFromFlyerImage(
imagePaths,
masterItems,
submitterIp,
userProfileAddress
);
// Provide default successful mock implementations for dependencies
vi.mocked(mockedAiService.aiService.extractCoreDataFromFlyerImage).mockResolvedValue({
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 }],
const validationResult = AiFlyerDataSchema.safeParse(extractedData);
if (!validationResult.success) {
const errors = validationResult.error.flatten();
logger.error('[Worker] AI response failed validation.', {
errors,
rawData: extractedData,
});
throw new AiDataValidationError('AI response validation failed. The returned data structure is incorrect.', errors, extractedData);
}
logger.info(`[Worker] AI extracted ${extractedData.items.length} items.`);
return validationResult.data;
}
/**
* Saves the extracted flyer data to the database.
* @param extractedData The structured data from the AI.
* @param imagePaths The paths to the flyer images.
* @param jobData The data from the BullMQ job.
* @returns A promise that resolves to the newly created flyer record.
*/
private async _saveProcessedFlyerData(
extractedData: z.infer<typeof AiFlyerDataSchema>,
imagePaths: { path: string; mimetype: string }[],
jobData: FlyerJobData
) {
logger.info(`[Worker] Preparing to save extracted data to database for job ${jobData.checksum}.`);
// 1. Transform the AI data into database-ready records.
const { flyerData, itemsForDb } = await this.transformer.transform(
extractedData,
imagePaths,
jobData.originalFileName,
jobData.checksum,
jobData.userId
);
// 2. Save the transformed data to the database.
const { flyer: newFlyer } = await createFlyerAndItems(flyerData, itemsForDb);
logger.info(`[Worker] Successfully saved new flyer ID: ${newFlyer.flyer_id}`);
await this.database.adminRepo.logActivity({ userId: jobData.userId, action: 'flyer_processed', displayText: `Processed a new flyer for ${flyerData.store_name}.`, details: { flyerId: newFlyer.flyer_id, storeName: flyerData.store_name } });
return newFlyer;
}
/**
* Enqueues a job to clean up temporary files associated with a flyer upload.
* @param flyerId The ID of the processed flyer.
* @param paths An array of file paths to be deleted.
*/
private async _enqueueCleanup(flyerId: number, paths: string[]): Promise<void> {
if (paths.length === 0) return;
await this.cleanupQueue.add('cleanup-flyer-files', { flyerId, paths }, {
jobId: `cleanup-flyer-${flyerId}`,
removeOnComplete: true,
});
vi.mocked(createFlyerAndItems).mockResolvedValue({
flyer: { flyer_id: 1, file_name: 'test.jpg', image_url: 'test.jpg', item_count: 1, created_at: new Date().toISOString() } as any,
items: [],
});
mockedImageProcessor.generateFlyerIcon.mockResolvedValue('icon-test.jpg');
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([]);
});
logger.info(`[Worker] Enqueued cleanup job for flyer ${flyerId}.`);
}
const createMockJob = (data: Partial<FlyerJobData>): Job<FlyerJobData> => {
return {
id: 'job-1',
data: {
filePath: '/tmp/flyer.jpg',
originalFileName: 'flyer.jpg',
checksum: 'checksum-123',
...data,
},
updateProgress: vi.fn(),
opts: { attempts: 3 },
attemptsMade: 1,
} as unknown as Job<FlyerJobData>;
};
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' });
async processJob(job: Job<FlyerJobData>) {
const { filePath, originalFileName } = job.data;
const createdImagePaths: string[] = [];
let newFlyerId: number | undefined;
const result = await service.processJob(job);
logger.info(`[Worker] Picked up job ${job.id} for file: ${originalFileName} (Checksum: ${job.data.checksum})`);
expect(result).toEqual({ flyerId: 1 });
expect(mockedAiService.aiService.extractCoreDataFromFlyerImage).toHaveBeenCalledTimes(1);
expect(createFlyerAndItems).toHaveBeenCalledTimes(1);
expect(mockedDb.adminRepo.logActivity).toHaveBeenCalledTimes(1);
expect(mocks.execAsync).not.toHaveBeenCalled();
expect(mockCleanupQueue.add).toHaveBeenCalledWith(
'cleanup-flyer-files',
{ flyerId: 1, paths: ['/tmp/flyer.jpg'] },
expect.any(Object)
);
});
try {
await job.updateProgress({ message: 'Starting process...' });
const { imagePaths, createdImagePaths: tempImagePaths } = await this._prepareImageInputs(filePath, job);
createdImagePaths.push(...tempImagePaths);
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' });
await job.updateProgress({ message: 'Extracting data...' });
const extractedData = await this._extractFlyerDataWithAI(imagePaths, job.data);
// Mock readdir to return Dirent-like objects for the converted files
mocks.readdir.mockResolvedValue([
{ name: 'flyer-1.jpg' },
{ name: 'flyer-2.jpg' },
] as Dirent[]);
await job.updateProgress({ message: 'Saving to database...' });
const newFlyer = await this._saveProcessedFlyerData(extractedData, imagePaths, job.data);
await service.processJob(job);
// Verify that pdftocairo was called
expect(mocks.execAsync).toHaveBeenCalledWith(
expect.stringContaining('pdftocairo -jpeg -r 150')
);
// Verify AI service was called with the converted images
expect(mockedAiService.aiService.extractCoreDataFromFlyerImage).toHaveBeenCalledWith(
expect.arrayContaining([
expect.objectContaining({ path: expect.stringContaining('flyer-1.jpg') }),
expect.objectContaining({ path: expect.stringContaining('flyer-2.jpg') }),
]),
expect.any(Array),
undefined,
undefined
);
expect(createFlyerAndItems).toHaveBeenCalledTimes(1);
// Verify cleanup job includes original PDF and both generated images
expect(mockCleanupQueue.add).toHaveBeenCalledWith(
'cleanup-flyer-files',
{ flyerId: 1, paths: ['/tmp/flyer.pdf', expect.stringContaining('flyer-1.jpg'), expect.stringContaining('flyer-2.jpg')] },
expect.any(Object)
);
});
it('should throw an error and not enqueue cleanup if the AI service fails', async () => {
const job = createMockJob({});
const aiError = new Error('AI model exploded');
vi.mocked(mockedAiService.aiService.extractCoreDataFromFlyerImage).mockRejectedValue(aiError);
await expect(service.processJob(job)).rejects.toThrow('AI model exploded');
expect(job.updateProgress).toHaveBeenCalledWith({ message: 'Error: AI model exploded' });
expect(mockCleanupQueue.add).not.toHaveBeenCalled();
});
it('should throw an error if the database service fails', async () => {
const job = createMockJob({});
const dbError = new Error('Database transaction failed');
vi.mocked(createFlyerAndItems).mockRejectedValue(dbError);
await expect(service.processJob(job)).rejects.toThrow('Database transaction failed');
expect(job.updateProgress).toHaveBeenCalledWith({ message: 'Error: Database transaction failed' });
expect(mockCleanupQueue.add).not.toHaveBeenCalled();
});
it('should log a warning and not enqueue cleanup if the job fails but a flyer ID was somehow generated', async () => {
const job = createMockJob({});
vi.mocked(createFlyerAndItems).mockRejectedValue(new Error('DB Error'));
await expect(service.processJob(job)).rejects.toThrow();
expect(mockCleanupQueue.add).not.toHaveBeenCalled();
});
});
describe('_convertPdfToImages (private method)', () => {
it('should call pdftocairo and return sorted image paths on success', async () => {
const job = createMockJob({ filePath: '/tmp/test.pdf' });
// Mock readdir to return unsorted Dirent-like objects
mocks.readdir.mockResolvedValue([
{ name: 'test-10.jpg' },
{ name: 'test-1.jpg' },
{ name: 'test-2.jpg' },
{ name: 'other-file.txt' },
] as Dirent[]);
// Access and call the private method for testing
const imagePaths = await (service as any)._convertPdfToImages('/tmp/test.pdf', job);
expect(mocks.execAsync).toHaveBeenCalledWith(
'pdftocairo -jpeg -r 150 "/tmp/test.pdf" "/tmp/test"'
);
expect(job.updateProgress).toHaveBeenCalledWith({ message: 'Converting PDF to images...' });
// Verify that the paths are correctly sorted numerically
expect(imagePaths).toEqual([
'/tmp/test-1.jpg',
'/tmp/test-2.jpg',
'/tmp/test-10.jpg',
]);
});
it('should throw PdfConversionError if no images are generated', async () => {
const job = createMockJob({ filePath: '/tmp/empty.pdf' });
// Mock readdir to return no matching files
mocks.readdir.mockResolvedValue([]);
await expect((service as any)._convertPdfToImages('/tmp/empty.pdf', job))
.rejects.toThrow('PDF conversion resulted in 0 images for file: /tmp/empty.pdf');
});
it('should re-throw an error if the exec command fails', async () => {
const job = createMockJob({ filePath: '/tmp/bad.pdf' });
const commandError = new Error('pdftocairo not found');
mocks.execAsync.mockRejectedValue(commandError);
await expect((service as any)._convertPdfToImages('/tmp/bad.pdf', job))
.rejects.toThrow(commandError);
});
});
});
newFlyerId = newFlyer.flyer_id;
logger.info(`[Worker] Job ${job.id} for ${originalFileName} processed successfully. Flyer ID: ${newFlyerId}`);
return { flyerId: newFlyer.flyer_id };
} catch (error: unknown) {
let errorMessage = 'An unknown error occurred';
if (error instanceof PdfConversionError) {
errorMessage = error.message;
logger.error(`[Worker] PDF Conversion failed for job ${job.id}.`, {
error: errorMessage,
stderr: error.stderr,
jobData: job.data,
});
} else if (error instanceof AiDataValidationError) {
errorMessage = error.message;
logger.error(`[Worker] AI Data Validation failed for job ${job.id}.`, {
error: errorMessage,
validationErrors: error.validationErrors,
rawData: error.rawData,
jobData: job.data,
});
} else if (error instanceof Error) {
errorMessage = error.message;
logger.error(`[Worker] A generic error occurred in job ${job.id}. Attempt ${job.attemptsMade}/${job.opts.attempts}.`, {
error: errorMessage, stack: error.stack, jobData: job.data,
});
}
await job.updateProgress({ message: `Error: ${errorMessage}` });
throw error;
} finally {
if (newFlyerId) {
const pathsToClean = [filePath, ...createdImagePaths];
await this._enqueueCleanup(newFlyerId, pathsToClean);
} else {
logger.warn(`[Worker] Job ${job.id} for ${originalFileName} failed. Temporary files will NOT be cleaned up to allow for manual inspection.`);
}
}
}
}

View File

@@ -1,3 +1,4 @@
// src/services/flyerProcessingService.types.ts
export interface FlyerJobData {
filePath: string;
originalFileName: string;