Compare commits

...

2 Commits

Author SHA1 Message Date
Gitea Actions
7a52bf499e ci: Bump version to 0.2.31 [skip ci] 2025-12-30 06:58:25 +05:00
2489ec8d2d fix unit tests
All checks were successful
Deploy to Test Environment / deploy-to-test (push) Successful in 12m3s
2025-12-29 17:57:40 -08:00
7 changed files with 109 additions and 82 deletions

4
package-lock.json generated
View File

@@ -1,12 +1,12 @@
{
"name": "flyer-crawler",
"version": "0.2.30",
"version": "0.2.31",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "flyer-crawler",
"version": "0.2.30",
"version": "0.2.31",
"dependencies": {
"@bull-board/api": "^6.14.2",
"@bull-board/express": "^6.14.2",

View File

@@ -1,7 +1,7 @@
{
"name": "flyer-crawler",
"private": true,
"version": "0.2.30",
"version": "0.2.31",
"type": "module",
"scripts": {
"dev": "concurrently \"npm:start:dev\" \"vite\"",

View File

@@ -30,17 +30,15 @@ vi.mock('util', async (importOriginal) => {
};
});
// FIX: Use the simple factory pattern for child_process to avoid default export issues
vi.mock('child_process', () => {
const mockExec = vi.fn((command, callback) => {
if (typeof callback === 'function') {
callback(null, 'PM2 OK', '');
}
return { unref: () => {} };
});
// The `importOriginal` pattern is the robust way to mock built-in Node modules.
// It preserves the module's original structure, preventing "No default export" errors
// that can occur with simple factory mocks when using ESM-based test runners like Vitest.
vi.mock('child_process', async (importOriginal) => {
const actual = await importOriginal<typeof import('child_process')>();
return {
exec: mockExec,
...actual,
// We provide a basic mock function that will be implemented in each test.
exec: vi.fn(),
};
});

View File

@@ -1,5 +1,4 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { UniqueConstraintError } from './db/errors.db';
import type { UserProfile } from '../types';
describe('AuthService', () => {
@@ -10,6 +9,7 @@ describe('AuthService', () => {
let adminRepo: typeof import('./db/index.db').adminRepo;
let logger: typeof import('./logger.server').logger;
let sendPasswordResetEmail: typeof import('./emailService.server').sendPasswordResetEmail;
let UniqueConstraintError: typeof import('./db/errors.db').UniqueConstraintError;
const reqLog = {}; // Mock request logger object
const mockUser = {
@@ -32,7 +32,10 @@ describe('AuthService', () => {
// Mock all dependencies before dynamically importing the service
vi.mock('bcrypt');
vi.mock('jsonwebtoken');
vi.mock('jsonwebtoken', () => ({
sign: vi.fn(),
verify: vi.fn(),
}));
vi.mock('crypto', () => ({
default: {
randomBytes: vi.fn().mockReturnValue({
@@ -75,6 +78,7 @@ describe('AuthService', () => {
adminRepo = dbModule.adminRepo;
logger = (await import('./logger.server')).logger;
sendPasswordResetEmail = (await import('./emailService.server')).sendPasswordResetEmail;
UniqueConstraintError = (await import('./db/errors.db')).UniqueConstraintError;
});
describe('registerUser', () => {

View File

@@ -409,24 +409,18 @@ describe('FlyerProcessingService', () => {
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(job.updateProgress).toHaveBeenCalledWith({
errorCode: 'UNSUPPORTED_FILE_TYPE',
message: 'Unsupported file type: .txt. Supported types are PDF, JPG, PNG, WEBP, HEIC, HEIF, GIF, TIFF, SVG, BMP.',
stages: [
{ name: 'Preparing Inputs', status: 'failed', critical: true, detail: 'Unsupported file type: .txt. Supported types are PDF, JPG, PNG, WEBP, HEIC, HEIF, GIF, TIFF, SVG, BMP.' },
{ name: 'Extracting Data with AI', status: 'skipped', critical: true, detail: 'Communicating with AI model...' },
{ name: 'Transforming AI Data', status: 'skipped', critical: true },
{ name: 'Saving to Database', status: 'skipped', critical: true },
],
});
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 throw an error and not enqueue cleanup if icon generation fails', async () => {
it('should delegate to _reportErrorAndThrow if icon generation fails', async () => {
const job = createMockJob({});
const { logger } = await import('./logger.server');
const iconError = new Error('Icon generation failed.');
@@ -435,18 +429,11 @@ describe('FlyerProcessingService', () => {
// bubbling up from the icon generation step.
vi.spyOn(FlyerDataTransformer.prototype, 'transform').mockRejectedValue(iconError);
const reportErrorSpy = vi.spyOn(service as any, '_reportErrorAndThrow');
await expect(service.processJob(job)).rejects.toThrow('Icon generation failed.');
expect(job.updateProgress).toHaveBeenCalledWith({
errorCode: 'UNKNOWN_ERROR',
message: 'Icon generation failed.',
stages: [
{ name: 'Preparing Inputs', status: 'completed', critical: true, detail: '1 page(s) ready for AI.' },
{ name: 'Extracting Data with AI', status: 'completed', critical: true, detail: 'Communicating with AI model...' },
{ name: 'Transforming AI Data', status: 'failed', critical: true, detail: 'Icon generation failed.' },
{ name: 'Saving to Database', status: 'skipped', critical: true },
],
}); // This was a duplicate, fixed.
expect(reportErrorSpy).toHaveBeenCalledWith(iconError, 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.',
@@ -454,8 +441,58 @@ describe('FlyerProcessingService', () => {
});
});
describe('_reportErrorAndThrow (private method)', () => {
it('should update progress and throw UnrecoverableError for quota messages', async () => {
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');
@@ -472,53 +509,35 @@ describe('FlyerProcessingService', () => {
});
});
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 privateMethod = (service as any)._reportErrorAndThrow;
await expect(privateMethod(validationError, job, logger, [])).rejects.toThrow(
validationError,
);
// The payload should now come from the error's `toErrorPayload` method
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: [],
});
});
it('should update progress and re-throw standard errors', async () => {
const { logger } = await import('./logger.server');
const job = createMockJob({});
const genericError = new Error('A standard failure');
const privateMethod = (service as any)._reportErrorAndThrow;
await expect(privateMethod(genericError, job, logger, [])).rejects.toThrow(genericError);
expect(job.updateProgress).toHaveBeenCalledWith({
errorCode: 'UNKNOWN_ERROR',
message: 'A standard failure', // This was a duplicate, fixed.
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');
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 },
],
}));
});
});

View File

@@ -36,10 +36,14 @@ vi.mock('util', async (importOriginal) => {
});
// Mock child_process
vi.mock('child_process', async (importOriginal) => {
const actual = await importOriginal<typeof import('child_process')>();
// Node.js built-in modules like 'child_process' are CommonJS modules.
// When mocked in an ESM context (like Vitest), they might sometimes
// be interpreted as having a default export if not explicitly handled.
// By providing `__esModule: true` and explicitly defining `exec`,
// we ensure Vitest correctly resolves the named import.
vi.mock('child_process', () => {
return {
...actual,
__esModule: true, // Explicitly mark as an ES module
exec: vi.fn(),
};
});

View File

@@ -4,6 +4,8 @@ import { promisify } from 'util';
import { logger } from './logger.server';
const execAsync = promisify(exec);
logger.debug({ typeOfExec: typeof exec, isExecFunction: typeof exec === 'function' }, 'SystemService: Initializing execAsync');
logger.debug({ typeOfPromisify: typeof promisify, isPromisifyFunction: typeof promisify === 'function' }, 'SystemService: Initializing execAsync');
class SystemService {
async getPm2Status(): Promise<{ success: boolean; message: string }> {