Compare commits
10 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4bf5dc3d58 | ||
| be3d269928 | |||
|
|
80a53fae94 | ||
| e15d2b6c2f | |||
|
|
7a52bf499e | ||
| 2489ec8d2d | |||
|
|
4a4f349805 | ||
| 517a268307 | |||
|
|
a94b2a97b1 | ||
| 542cdfbb82 |
4
package-lock.json
generated
4
package-lock.json
generated
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "flyer-crawler",
|
||||
"version": "0.2.28",
|
||||
"version": "0.2.33",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "flyer-crawler",
|
||||
"version": "0.2.28",
|
||||
"version": "0.2.33",
|
||||
"dependencies": {
|
||||
"@bull-board/api": "^6.14.2",
|
||||
"@bull-board/express": "^6.14.2",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "flyer-crawler",
|
||||
"private": true,
|
||||
"version": "0.2.28",
|
||||
"version": "0.2.33",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "concurrently \"npm:start:dev\" \"vite\"",
|
||||
|
||||
@@ -1,51 +1,15 @@
|
||||
// src/routes/system.routes.test.ts
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import supertest from 'supertest';
|
||||
import { exec, type ExecException, type ExecOptions } from 'child_process';
|
||||
import { geocodingService } from '../services/geocodingService.server';
|
||||
import { createTestApp } from '../tests/utils/createTestApp';
|
||||
|
||||
// FIX: Mock util.promisify to correctly handle child_process.exec's (err, stdout, stderr) signature.
|
||||
// This is required because the standard util.promisify relies on internal symbols on the real exec function,
|
||||
// which are missing on our Vitest mock. Without this, promisify(mockExec) drops the stdout/stderr arguments.
|
||||
vi.mock('util', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('util')>();
|
||||
return {
|
||||
...actual,
|
||||
default: actual,
|
||||
promisify: (fn: Function) => {
|
||||
return (...args: any[]) => {
|
||||
return new Promise((resolve, reject) => {
|
||||
fn(...args, (err: Error | null, stdout: unknown, stderr: unknown) => {
|
||||
if (err) {
|
||||
// Attach stdout/stderr to the error object to mimic child_process.exec behavior
|
||||
Object.assign(err, { stdout, stderr });
|
||||
reject(err);
|
||||
} else {
|
||||
resolve({ stdout, stderr });
|
||||
}
|
||||
});
|
||||
});
|
||||
};
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
// 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: () => {} };
|
||||
});
|
||||
|
||||
return {
|
||||
default: { exec: mockExec },
|
||||
exec: mockExec,
|
||||
};
|
||||
});
|
||||
|
||||
// 1. Mock the Service Layer
|
||||
// This decouples the route test from the service's implementation details.
|
||||
vi.mock('../services/systemService', () => ({
|
||||
systemService: {
|
||||
getPm2Status: vi.fn(),
|
||||
},
|
||||
}));
|
||||
// 2. Mock Geocoding
|
||||
vi.mock('../services/geocodingService.server', () => ({
|
||||
geocodingService: {
|
||||
@@ -65,46 +29,24 @@ vi.mock('../services/logger.server', () => ({
|
||||
}));
|
||||
|
||||
// Import the router AFTER all mocks are defined to ensure systemService picks up the mocked util.promisify
|
||||
import { systemService } from '../services/systemService';
|
||||
import systemRouter from './system.routes';
|
||||
import { geocodingService } from '../services/geocodingService.server';
|
||||
|
||||
describe('System Routes (/api/system)', () => {
|
||||
const app = createTestApp({ router: systemRouter, basePath: '/api/system' });
|
||||
|
||||
beforeEach(() => {
|
||||
// We cast here to get type-safe access to mock functions like .mockImplementation
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('GET /pm2-status', () => {
|
||||
it('should return success: true when pm2 process is online', async () => {
|
||||
// Arrange: Simulate a successful `pm2 describe` output for an online process.
|
||||
const pm2OnlineOutput = `
|
||||
┌─ PM2 info ────────────────┐
|
||||
│ status │ online │
|
||||
└───────────┴───────────┘
|
||||
`;
|
||||
|
||||
type ExecCallback = (error: ExecException | null, stdout: string, stderr: string) => void;
|
||||
|
||||
// A robust mock for `exec` that handles its multiple overloads.
|
||||
// This avoids the complex and error-prone `...args` signature.
|
||||
vi.mocked(exec).mockImplementation(
|
||||
(
|
||||
command: string,
|
||||
options?: ExecOptions | ExecCallback | null,
|
||||
callback?: ExecCallback | null,
|
||||
) => {
|
||||
// The actual callback can be the second or third argument.
|
||||
const actualCallback = (
|
||||
typeof options === 'function' ? options : callback
|
||||
) as ExecCallback;
|
||||
if (actualCallback) {
|
||||
actualCallback(null, pm2OnlineOutput, '');
|
||||
}
|
||||
// Return a minimal object that satisfies the ChildProcess type for .unref()
|
||||
return { unref: () => {} } as ReturnType<typeof exec>;
|
||||
},
|
||||
);
|
||||
vi.mocked(systemService.getPm2Status).mockResolvedValue({
|
||||
success: true,
|
||||
message: 'Application is online and running under PM2.',
|
||||
});
|
||||
|
||||
// Act
|
||||
const response = await supertest(app).get('/api/system/pm2-status');
|
||||
@@ -118,28 +60,10 @@ describe('System Routes (/api/system)', () => {
|
||||
});
|
||||
|
||||
it('should return success: false when pm2 process is stopped or errored', async () => {
|
||||
const pm2StoppedOutput = `│ status │ stopped │`;
|
||||
|
||||
vi.mocked(exec).mockImplementation(
|
||||
(
|
||||
command: string,
|
||||
options?:
|
||||
| ExecOptions
|
||||
| ((error: ExecException | null, stdout: string, stderr: string) => void)
|
||||
| null,
|
||||
callback?: ((error: ExecException | null, stdout: string, stderr: string) => void) | null,
|
||||
) => {
|
||||
const actualCallback = (typeof options === 'function' ? options : callback) as (
|
||||
error: ExecException | null,
|
||||
stdout: string,
|
||||
stderr: string,
|
||||
) => void;
|
||||
if (actualCallback) {
|
||||
actualCallback(null, pm2StoppedOutput, '');
|
||||
}
|
||||
return { unref: () => {} } as ReturnType<typeof exec>;
|
||||
},
|
||||
);
|
||||
vi.mocked(systemService.getPm2Status).mockResolvedValue({
|
||||
success: false,
|
||||
message: 'Application process exists but is not online.',
|
||||
});
|
||||
|
||||
const response = await supertest(app).get('/api/system/pm2-status');
|
||||
|
||||
@@ -150,33 +74,10 @@ describe('System Routes (/api/system)', () => {
|
||||
|
||||
it('should return success: false when pm2 process does not exist', async () => {
|
||||
// Arrange: Simulate `pm2 describe` failing because the process isn't found.
|
||||
const processNotFoundOutput =
|
||||
"[PM2][ERROR] Process or Namespace flyer-crawler-api doesn't exist";
|
||||
const processNotFoundError = new Error(
|
||||
'Command failed: pm2 describe flyer-crawler-api',
|
||||
) as ExecException;
|
||||
processNotFoundError.code = 1;
|
||||
|
||||
vi.mocked(exec).mockImplementation(
|
||||
(
|
||||
command: string,
|
||||
options?:
|
||||
| ExecOptions
|
||||
| ((error: ExecException | null, stdout: string, stderr: string) => void)
|
||||
| null,
|
||||
callback?: ((error: ExecException | null, stdout: string, stderr: string) => void) | null,
|
||||
) => {
|
||||
const actualCallback = (typeof options === 'function' ? options : callback) as (
|
||||
error: ExecException | null,
|
||||
stdout: string,
|
||||
stderr: string,
|
||||
) => void;
|
||||
if (actualCallback) {
|
||||
actualCallback(processNotFoundError, processNotFoundOutput, '');
|
||||
}
|
||||
return { unref: () => {} } as ReturnType<typeof exec>;
|
||||
},
|
||||
);
|
||||
vi.mocked(systemService.getPm2Status).mockResolvedValue({
|
||||
success: false,
|
||||
message: 'Application process is not running under PM2.',
|
||||
});
|
||||
|
||||
// Act
|
||||
const response = await supertest(app).get('/api/system/pm2-status');
|
||||
@@ -191,55 +92,17 @@ describe('System Routes (/api/system)', () => {
|
||||
|
||||
it('should return 500 if pm2 command produces stderr output', async () => {
|
||||
// Arrange: Simulate a successful exit code but with content in stderr.
|
||||
const stderrOutput = 'A non-fatal warning occurred.';
|
||||
|
||||
vi.mocked(exec).mockImplementation(
|
||||
(
|
||||
command: string,
|
||||
options?:
|
||||
| ExecOptions
|
||||
| ((error: ExecException | null, stdout: string, stderr: string) => void)
|
||||
| null,
|
||||
callback?: ((error: ExecException | null, stdout: string, stderr: string) => void) | null,
|
||||
) => {
|
||||
const actualCallback = (typeof options === 'function' ? options : callback) as (
|
||||
error: ExecException | null,
|
||||
stdout: string,
|
||||
stderr: string,
|
||||
) => void;
|
||||
if (actualCallback) {
|
||||
actualCallback(null, 'Some stdout', stderrOutput);
|
||||
}
|
||||
return { unref: () => {} } as ReturnType<typeof exec>;
|
||||
},
|
||||
);
|
||||
const serviceError = new Error('PM2 command produced an error: A non-fatal warning occurred.');
|
||||
vi.mocked(systemService.getPm2Status).mockRejectedValue(serviceError);
|
||||
|
||||
const response = await supertest(app).get('/api/system/pm2-status');
|
||||
expect(response.status).toBe(500);
|
||||
expect(response.body.message).toBe(`PM2 command produced an error: ${stderrOutput}`);
|
||||
expect(response.body.message).toBe(serviceError.message);
|
||||
});
|
||||
|
||||
it('should return 500 on a generic exec error', async () => {
|
||||
vi.mocked(exec).mockImplementation(
|
||||
(
|
||||
command: string,
|
||||
options?:
|
||||
| ExecOptions
|
||||
| ((error: ExecException | null, stdout: string, stderr: string) => void)
|
||||
| null,
|
||||
callback?: ((error: ExecException | null, stdout: string, stderr: string) => void) | null,
|
||||
) => {
|
||||
const actualCallback = (typeof options === 'function' ? options : callback) as (
|
||||
error: ExecException | null,
|
||||
stdout: string,
|
||||
stderr: string,
|
||||
) => void;
|
||||
if (actualCallback) {
|
||||
actualCallback(new Error('System error') as ExecException, '', 'stderr output');
|
||||
}
|
||||
return { unref: () => {} } as ReturnType<typeof exec>;
|
||||
},
|
||||
);
|
||||
const serviceError = new Error('System error');
|
||||
vi.mocked(systemService.getPm2Status).mockRejectedValue(serviceError);
|
||||
|
||||
// Act
|
||||
const response = await supertest(app).get('/api/system/pm2-status');
|
||||
|
||||
@@ -127,12 +127,18 @@ describe('AnalyticsService', () => {
|
||||
throw new Error('Processing failed');
|
||||
}); // "Successfully generated..."
|
||||
|
||||
// Get the promise from the service method.
|
||||
const promise = service.processWeeklyReportJob(job);
|
||||
|
||||
// Advance timers to trigger the part of the code that throws.
|
||||
await vi.advanceTimersByTimeAsync(30000);
|
||||
|
||||
// Now, assert that the promise rejects as expected.
|
||||
// This structure avoids an unhandled promise rejection that can occur
|
||||
// when awaiting a rejecting promise inside a helper function without a try/catch.
|
||||
await expect(promise).rejects.toThrow('Processing failed');
|
||||
|
||||
// Verify the side effect (error logging) after the rejection is confirmed.
|
||||
expect(mockLoggerInstance.error).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
err: expect.any(Error),
|
||||
|
||||
@@ -1,70 +1,17 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
|
||||
// Set environment variables before importing the service
|
||||
process.env.JWT_SECRET = 'test-secret';
|
||||
process.env.FRONTEND_URL = 'http://localhost:3000';
|
||||
|
||||
import { authService } from './authService';
|
||||
import * as bcrypt from 'bcrypt';
|
||||
import jwt from 'jsonwebtoken';
|
||||
import crypto from 'crypto';
|
||||
import { userRepo, adminRepo } from './db/index.db';
|
||||
import { UniqueConstraintError } from './db/errors.db';
|
||||
import { logger } from './logger.server';
|
||||
import { sendPasswordResetEmail } from './emailService.server';
|
||||
import type { UserProfile } from '../types';
|
||||
|
||||
// Mock dependencies
|
||||
vi.mock('bcrypt');
|
||||
vi.mock('jsonwebtoken');
|
||||
vi.mock('crypto', () => ({
|
||||
default: {
|
||||
randomBytes: vi.fn().mockReturnValue({
|
||||
toString: vi.fn().mockReturnValue('mocked-random-string'),
|
||||
}),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('./db/index.db', () => ({
|
||||
userRepo: {
|
||||
createUser: vi.fn(),
|
||||
saveRefreshToken: vi.fn(),
|
||||
findUserByEmail: vi.fn(),
|
||||
createPasswordResetToken: vi.fn(),
|
||||
getValidResetTokens: vi.fn(),
|
||||
updateUserPassword: vi.fn(),
|
||||
deleteResetToken: vi.fn(),
|
||||
findUserByRefreshToken: vi.fn(),
|
||||
findUserProfileById: vi.fn(),
|
||||
deleteRefreshToken: vi.fn(),
|
||||
},
|
||||
adminRepo: {
|
||||
logActivity: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('./logger.server', () => ({
|
||||
logger: {
|
||||
info: vi.fn(),
|
||||
error: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
debug: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('./emailService.server', () => ({
|
||||
sendPasswordResetEmail: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('./db/connection.db', () => ({
|
||||
getPool: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../utils/authUtils', () => ({
|
||||
validatePasswordStrength: vi.fn(),
|
||||
}));
|
||||
import type * as jsonwebtoken from 'jsonwebtoken';
|
||||
|
||||
describe('AuthService', () => {
|
||||
let authService: typeof import('./authService').authService;
|
||||
let bcrypt: typeof import('bcrypt');
|
||||
let jwt: typeof jsonwebtoken & { default: typeof jsonwebtoken };
|
||||
let userRepo: typeof import('./db/index.db').userRepo;
|
||||
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 = {
|
||||
user_id: 'user-123',
|
||||
@@ -76,8 +23,53 @@ describe('AuthService', () => {
|
||||
role: 'user',
|
||||
} as unknown as UserProfile;
|
||||
|
||||
beforeEach(() => {
|
||||
beforeEach(async () => {
|
||||
vi.clearAllMocks();
|
||||
vi.resetModules();
|
||||
|
||||
// Set environment variables before any modules are imported
|
||||
process.env.JWT_SECRET = 'test-secret';
|
||||
process.env.FRONTEND_URL = 'http://localhost:3000';
|
||||
|
||||
// Mock all dependencies before dynamically importing the service
|
||||
// Core modules like bcrypt, jsonwebtoken, and crypto are now mocked globally in tests-setup-unit.ts
|
||||
vi.mock('bcrypt');
|
||||
vi.mock('./db/index.db', () => ({
|
||||
userRepo: {
|
||||
createUser: vi.fn(),
|
||||
saveRefreshToken: vi.fn(),
|
||||
findUserByEmail: vi.fn(),
|
||||
createPasswordResetToken: vi.fn(),
|
||||
getValidResetTokens: vi.fn(),
|
||||
updateUserPassword: vi.fn(),
|
||||
deleteResetToken: vi.fn(),
|
||||
findUserByRefreshToken: vi.fn(),
|
||||
findUserProfileById: vi.fn(),
|
||||
deleteRefreshToken: vi.fn(),
|
||||
},
|
||||
adminRepo: {
|
||||
logActivity: vi.fn(),
|
||||
},
|
||||
}));
|
||||
vi.mock('./logger.server', () => ({
|
||||
logger: { info: vi.fn(), error: vi.fn(), warn: vi.fn(), debug: vi.fn() },
|
||||
}));
|
||||
vi.mock('./emailService.server', () => ({
|
||||
sendPasswordResetEmail: vi.fn(),
|
||||
}));
|
||||
vi.mock('./db/connection.db', () => ({ getPool: vi.fn() }));
|
||||
vi.mock('../utils/authUtils', () => ({ validatePasswordStrength: vi.fn() }));
|
||||
|
||||
// Dynamically import modules to get the mocked versions and the service instance
|
||||
authService = (await import('./authService')).authService;
|
||||
bcrypt = await import('bcrypt');
|
||||
jwt = (await import('jsonwebtoken')) as typeof jwt;
|
||||
const dbModule = await import('./db/index.db');
|
||||
userRepo = dbModule.userRepo;
|
||||
adminRepo = dbModule.adminRepo;
|
||||
logger = (await import('./logger.server')).logger;
|
||||
sendPasswordResetEmail = (await import('./emailService.server')).sendPasswordResetEmail;
|
||||
UniqueConstraintError = (await import('./db/errors.db')).UniqueConstraintError;
|
||||
});
|
||||
|
||||
describe('registerUser', () => {
|
||||
@@ -140,7 +132,10 @@ describe('AuthService', () => {
|
||||
// Mock registerUser logic (since we can't easily spy on the same class instance method without prototype spying, we rely on the underlying calls)
|
||||
vi.mocked(bcrypt.hash).mockImplementation(async () => 'hashed-password');
|
||||
vi.mocked(userRepo.createUser).mockResolvedValue(mockUserProfile);
|
||||
vi.mocked(jwt.sign).mockImplementation(() => 'access-token' as any);
|
||||
// FIX: The global mock for jsonwebtoken provides a `default` export.
|
||||
// The code under test (`authService`) uses `import jwt from 'jsonwebtoken'`, so it gets the default export.
|
||||
// We must mock `jwt.default.sign` to affect the code under test.
|
||||
vi.mocked(jwt.default.sign).mockImplementation(() => 'access-token');
|
||||
|
||||
const result = await authService.registerAndLoginUser(
|
||||
'test@example.com',
|
||||
@@ -165,11 +160,14 @@ describe('AuthService', () => {
|
||||
|
||||
describe('generateAuthTokens', () => {
|
||||
it('should generate access and refresh tokens', () => {
|
||||
vi.mocked(jwt.sign).mockImplementation(() => 'access-token' as any);
|
||||
// FIX: The global mock for jsonwebtoken provides a `default` export.
|
||||
// The code under test (`authService`) uses `import jwt from 'jsonwebtoken'`, so it gets the default export.
|
||||
// We must mock `jwt.default.sign` to affect the code under test.
|
||||
vi.mocked(jwt.default.sign).mockImplementation(() => 'access-token');
|
||||
|
||||
const result = authService.generateAuthTokens(mockUserProfile);
|
||||
|
||||
expect(jwt.sign).toHaveBeenCalledWith(
|
||||
expect(vi.mocked(jwt.default.sign)).toHaveBeenCalledWith(
|
||||
{
|
||||
user_id: 'user-123',
|
||||
email: 'test@example.com',
|
||||
@@ -227,7 +225,7 @@ describe('AuthService', () => {
|
||||
});
|
||||
|
||||
it('should log warning and return undefined for non-existent user', async () => {
|
||||
vi.mocked(userRepo.findUserByEmail).mockResolvedValue(null);
|
||||
vi.mocked(userRepo.findUserByEmail).mockResolvedValue(undefined);
|
||||
|
||||
const result = await authService.resetPassword('unknown@example.com', reqLog);
|
||||
|
||||
@@ -295,7 +293,7 @@ describe('AuthService', () => {
|
||||
});
|
||||
|
||||
it('should return null if token not found', async () => {
|
||||
vi.mocked(userRepo.findUserByRefreshToken).mockResolvedValue(null);
|
||||
vi.mocked(userRepo.findUserByRefreshToken).mockResolvedValue(undefined);
|
||||
|
||||
const result = await authService.getUserByRefreshToken('invalid-token', reqLog);
|
||||
|
||||
@@ -322,7 +320,10 @@ describe('AuthService', () => {
|
||||
it('should return new access token if user found', async () => {
|
||||
vi.mocked(userRepo.findUserByRefreshToken).mockResolvedValue({ user_id: 'user-123' } as any);
|
||||
vi.mocked(userRepo.findUserProfileById).mockResolvedValue(mockUserProfile);
|
||||
vi.mocked(jwt.sign).mockImplementation(() => 'new-access-token' as any);
|
||||
// FIX: The global mock for jsonwebtoken provides a `default` export.
|
||||
// The code under test (`authService`) uses `import jwt from 'jsonwebtoken'`, so it gets the default export.
|
||||
// We must mock `jwt.default.sign` to affect the code under test.
|
||||
vi.mocked(jwt.default.sign).mockImplementation(() => 'new-access-token');
|
||||
|
||||
const result = await authService.refreshAccessToken('valid-token', reqLog);
|
||||
|
||||
@@ -330,7 +331,7 @@ describe('AuthService', () => {
|
||||
});
|
||||
|
||||
it('should return null if user not found', async () => {
|
||||
vi.mocked(userRepo.findUserByRefreshToken).mockResolvedValue(null);
|
||||
vi.mocked(userRepo.findUserByRefreshToken).mockResolvedValue(undefined);
|
||||
const result = await authService.refreshAccessToken('invalid-token', reqLog);
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
@@ -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 },
|
||||
],
|
||||
}));
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -242,7 +242,10 @@ export class FlyerProcessingService {
|
||||
// Mark subsequent critical stages as skipped
|
||||
for (let i = errorStageIndex + 1; i < stagesToReport.length; i++) {
|
||||
if (stagesToReport[i].critical) {
|
||||
stagesToReport[i] = { ...stagesToReport[i], status: 'skipped' };
|
||||
// When a stage is skipped, we don't need its previous 'detail' property.
|
||||
// This creates a clean 'skipped' state object by removing `detail` and keeping the rest.
|
||||
const { detail, ...restOfStage } = stagesToReport[i];
|
||||
stagesToReport[i] = { ...restOfStage, status: 'skipped' };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { exec, type ExecException } from 'child_process';
|
||||
// src/services/systemService.test.ts
|
||||
import { describe, it, expect, vi, beforeEach, Mock } from 'vitest';
|
||||
import { logger } from './logger.server';
|
||||
import type { ExecException } from 'child_process';
|
||||
|
||||
// Mock logger
|
||||
vi.mock('./logger.server', () => ({
|
||||
@@ -11,43 +12,19 @@ vi.mock('./logger.server', () => ({
|
||||
},
|
||||
}));
|
||||
|
||||
// Mock util.promisify to handle child_process.exec signature
|
||||
vi.mock('util', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('util')>();
|
||||
return {
|
||||
...actual,
|
||||
promisify: (fn: Function) => {
|
||||
return (...args: any[]) => {
|
||||
return new Promise((resolve, reject) => {
|
||||
fn(...args, (err: Error | null, stdout: string, stderr: string) => {
|
||||
if (err) {
|
||||
// Attach stdout/stderr to error for the catch block in service
|
||||
Object.assign(err, { stdout, stderr });
|
||||
reject(err);
|
||||
} else {
|
||||
resolve({ stdout, stderr });
|
||||
}
|
||||
});
|
||||
});
|
||||
};
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
// Mock child_process
|
||||
vi.mock('child_process', () => {
|
||||
const mockExec = vi.fn();
|
||||
return {
|
||||
exec: mockExec,
|
||||
};
|
||||
});
|
||||
|
||||
// Import service AFTER mocks to ensure top-level promisify uses the mock
|
||||
import { systemService } from './systemService';
|
||||
// Import the class, not the singleton instance, to apply Dependency Injection
|
||||
import { SystemService } from './systemService';
|
||||
|
||||
describe('SystemService', () => {
|
||||
let systemService: SystemService;
|
||||
let mockExecAsync: Mock;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
// Create a mock function for our dependency
|
||||
mockExecAsync = vi.fn();
|
||||
// Instantiate the service with the mock dependency
|
||||
systemService = new SystemService(mockExecAsync);
|
||||
});
|
||||
|
||||
describe('getPm2Status', () => {
|
||||
@@ -59,10 +36,7 @@ describe('SystemService', () => {
|
||||
│ 0 │ flyer-crawler-api │ online │
|
||||
└────┴──────────────────────┴──────────┘
|
||||
`;
|
||||
vi.mocked(exec).mockImplementation((cmd, callback: any) => {
|
||||
callback(null, stdout, '');
|
||||
return {} as any;
|
||||
});
|
||||
mockExecAsync.mockResolvedValue({ stdout, stderr: '' });
|
||||
|
||||
const result = await systemService.getPm2Status();
|
||||
|
||||
@@ -80,10 +54,7 @@ describe('SystemService', () => {
|
||||
│ 0 │ flyer-crawler-api │ stopped │
|
||||
└────┴──────────────────────┴──────────┘
|
||||
`;
|
||||
vi.mocked(exec).mockImplementation((cmd, callback: any) => {
|
||||
callback(null, stdout, '');
|
||||
return {} as any;
|
||||
});
|
||||
mockExecAsync.mockResolvedValue({ stdout, stderr: '' });
|
||||
|
||||
const result = await systemService.getPm2Status();
|
||||
|
||||
@@ -94,12 +65,11 @@ describe('SystemService', () => {
|
||||
});
|
||||
|
||||
it('should throw error if stderr has content', async () => {
|
||||
vi.mocked(exec).mockImplementation((cmd, callback: any) => {
|
||||
callback(null, 'some stdout', 'some stderr warning');
|
||||
return {} as any;
|
||||
});
|
||||
mockExecAsync.mockResolvedValue({ stdout: 'some stdout', stderr: 'some stderr warning' });
|
||||
|
||||
await expect(systemService.getPm2Status()).rejects.toThrow('PM2 command produced an error: some stderr warning');
|
||||
await expect(systemService.getPm2Status()).rejects.toThrow(
|
||||
'PM2 command produced an error: some stderr warning',
|
||||
);
|
||||
});
|
||||
|
||||
it('should return success: false when process does not exist', async () => {
|
||||
@@ -107,10 +77,7 @@ describe('SystemService', () => {
|
||||
error.code = 1;
|
||||
error.stderr = "[PM2][ERROR] Process or Namespace flyer-crawler-api doesn't exist";
|
||||
|
||||
vi.mocked(exec).mockImplementation((cmd, callback: any) => {
|
||||
callback(error, '', error.stderr);
|
||||
return {} as any;
|
||||
});
|
||||
mockExecAsync.mockRejectedValue(error);
|
||||
|
||||
const result = await systemService.getPm2Status();
|
||||
|
||||
@@ -119,7 +86,7 @@ describe('SystemService', () => {
|
||||
message: 'Application process is not running under PM2.',
|
||||
});
|
||||
expect(logger.warn).toHaveBeenCalledWith(
|
||||
expect.stringContaining('PM2 process "flyer-crawler-api" not found')
|
||||
expect.stringContaining('PM2 process "flyer-crawler-api" not found'),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,14 +1,24 @@
|
||||
// src/services/systemService.ts
|
||||
import { exec } from 'child_process';
|
||||
import { exec as nodeExec, type ExecException } from 'child_process';
|
||||
import { promisify } from 'util';
|
||||
import { logger } from './logger.server';
|
||||
|
||||
const execAsync = promisify(exec);
|
||||
// Define a type for the exec function for better type safety and testability.
|
||||
// It matches the signature of a promisified child_process.exec.
|
||||
export type ExecAsync = (
|
||||
command: string,
|
||||
) => Promise<{ stdout: string; stderr: string }>;
|
||||
|
||||
export class SystemService {
|
||||
private execAsync: ExecAsync;
|
||||
|
||||
constructor(execAsync: ExecAsync) {
|
||||
this.execAsync = execAsync;
|
||||
}
|
||||
|
||||
class SystemService {
|
||||
async getPm2Status(): Promise<{ success: boolean; message: string }> {
|
||||
try {
|
||||
const { stdout, stderr } = await execAsync('pm2 describe flyer-crawler-api');
|
||||
const { stdout, stderr } = await this.execAsync('pm2 describe flyer-crawler-api');
|
||||
|
||||
// If the command runs but produces output on stderr, treat it as an error.
|
||||
// This handles cases where pm2 might issue warnings but still exit 0.
|
||||
@@ -21,7 +31,7 @@ class SystemService {
|
||||
? 'Application is online and running under PM2.'
|
||||
: 'Application process exists but is not online.';
|
||||
return { success: isOnline, message };
|
||||
} catch (error: any) {
|
||||
} catch (error: ExecException | any) {
|
||||
// If the command fails (non-zero exit code), check if it's because the process doesn't exist.
|
||||
// This is a normal "not found" case, not a system error.
|
||||
// The error message can be in stdout or stderr depending on the pm2 version.
|
||||
@@ -40,4 +50,6 @@ class SystemService {
|
||||
}
|
||||
}
|
||||
|
||||
export const systemService = new SystemService();
|
||||
// Instantiate the service with the real dependency for the application
|
||||
const realExecAsync = promisify(nodeExec);
|
||||
export const systemService = new SystemService(realExecAsync);
|
||||
@@ -116,6 +116,66 @@ afterEach(cleanup);
|
||||
// By placing mocks here, they are guaranteed to be hoisted and applied
|
||||
// before any test files are executed, preventing initialization errors.
|
||||
|
||||
// --- Centralized Core Node/NPM Module Mocks ---
|
||||
|
||||
// Mock 'util' to correctly handle the (err, stdout, stderr) signature of child_process.exec
|
||||
// when it's promisified. The standard util.promisify doesn't work on a simple vi.fn() mock.
|
||||
vi.mock('util', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('util')>();
|
||||
return {
|
||||
...actual,
|
||||
promisify: (fn: Function) => {
|
||||
return (...args: any[]) => {
|
||||
return new Promise((resolve, reject) => {
|
||||
fn(...args, (err: Error | null, stdout: string, stderr: string) => {
|
||||
if (err) {
|
||||
// Attach stdout/stderr to the error object to mimic child_process.exec behavior
|
||||
Object.assign(err, { stdout, stderr });
|
||||
reject(err);
|
||||
} else {
|
||||
resolve({ stdout, stderr });
|
||||
}
|
||||
});
|
||||
});
|
||||
};
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
// Mock 'child_process' using the robust `importOriginal` pattern.
|
||||
// This is needed because some services (systemService, workers.server) import it at the top level.
|
||||
vi.mock('child_process', () => {
|
||||
return {
|
||||
__esModule: true, // Handle CJS/ESM interop
|
||||
exec: vi.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
// Mock 'jsonwebtoken'. The `default` key is crucial because the code under test
|
||||
// uses `import jwt from 'jsonwebtoken'`, which imports the default export.
|
||||
vi.mock('jsonwebtoken', () => ({
|
||||
default: {
|
||||
sign: vi.fn(),
|
||||
verify: vi.fn(),
|
||||
},
|
||||
// Also mock named exports for completeness.
|
||||
sign: vi.fn(),
|
||||
verify: vi.fn(),
|
||||
}));
|
||||
|
||||
// Mock 'bcrypt'. The service uses `import * as bcrypt from 'bcrypt'`.
|
||||
vi.mock('bcrypt');
|
||||
|
||||
// Mock 'crypto'. The service uses `import crypto from 'crypto'`.
|
||||
vi.mock('crypto', () => ({
|
||||
default: {
|
||||
randomBytes: vi.fn().mockReturnValue({
|
||||
toString: vi.fn().mockReturnValue('mocked-random-string'),
|
||||
}),
|
||||
randomUUID: vi.fn().mockReturnValue('mocked-random-string'),
|
||||
},
|
||||
}));
|
||||
|
||||
// --- Global Mocks ---
|
||||
|
||||
// 1. Define the mock pool instance logic OUTSIDE the factory so it can be used
|
||||
|
||||
Reference in New Issue
Block a user