Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b2e086d5ba | ||
| 07a9787570 | |||
|
|
4bf5dc3d58 | ||
| be3d269928 | |||
|
|
80a53fae94 | ||
| e15d2b6c2f |
4
package-lock.json
generated
4
package-lock.json
generated
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "flyer-crawler",
|
"name": "flyer-crawler",
|
||||||
"version": "0.2.31",
|
"version": "0.2.34",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "flyer-crawler",
|
"name": "flyer-crawler",
|
||||||
"version": "0.2.31",
|
"version": "0.2.34",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@bull-board/api": "^6.14.2",
|
"@bull-board/api": "^6.14.2",
|
||||||
"@bull-board/express": "^6.14.2",
|
"@bull-board/express": "^6.14.2",
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "flyer-crawler",
|
"name": "flyer-crawler",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "0.2.31",
|
"version": "0.2.34",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "concurrently \"npm:start:dev\" \"vite\"",
|
"dev": "concurrently \"npm:start:dev\" \"vite\"",
|
||||||
|
|||||||
@@ -113,13 +113,14 @@ describe('errorHandler Middleware', () => {
|
|||||||
expect(response.body.message).toBe('A generic server error occurred.');
|
expect(response.body.message).toBe('A generic server error occurred.');
|
||||||
expect(response.body.stack).toBeDefined();
|
expect(response.body.stack).toBeDefined();
|
||||||
expect(response.body.errorId).toEqual(expect.any(String));
|
expect(response.body.errorId).toEqual(expect.any(String));
|
||||||
|
console.log('[DEBUG] errorHandler.test.ts: Received 500 error response with ID:', response.body.errorId);
|
||||||
expect(mockLogger.error).toHaveBeenCalledWith(
|
expect(mockLogger.error).toHaveBeenCalledWith(
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
err: expect.any(Error),
|
err: expect.any(Error),
|
||||||
errorId: expect.any(String),
|
errorId: expect.any(String),
|
||||||
req: expect.objectContaining({ method: 'GET', url: '/generic-error' }),
|
req: expect.objectContaining({ method: 'GET', url: '/generic-error' }),
|
||||||
}),
|
}),
|
||||||
expect.stringMatching(/Unhandled API Error \(ID: \w+\)/),
|
expect.stringMatching(/Unhandled API Error \(ID: [\w-]+\)/),
|
||||||
);
|
);
|
||||||
expect(consoleErrorSpy).toHaveBeenCalledWith(
|
expect(consoleErrorSpy).toHaveBeenCalledWith(
|
||||||
expect.stringMatching(/--- \[TEST\] UNHANDLED ERROR \(ID: \w+\) ---/),
|
expect.stringMatching(/--- \[TEST\] UNHANDLED ERROR \(ID: \w+\) ---/),
|
||||||
@@ -226,7 +227,7 @@ describe('errorHandler Middleware', () => {
|
|||||||
errorId: expect.any(String),
|
errorId: expect.any(String),
|
||||||
req: expect.objectContaining({ method: 'GET', url: '/db-error-500' }),
|
req: expect.objectContaining({ method: 'GET', url: '/db-error-500' }),
|
||||||
}),
|
}),
|
||||||
expect.stringMatching(/Unhandled API Error \(ID: \w+\)/),
|
expect.stringMatching(/Unhandled API Error \(ID: [\w-]+\)/),
|
||||||
);
|
);
|
||||||
expect(consoleErrorSpy).toHaveBeenCalledWith(
|
expect(consoleErrorSpy).toHaveBeenCalledWith(
|
||||||
expect.stringMatching(/--- \[TEST\] UNHANDLED ERROR \(ID: \w+\) ---/),
|
expect.stringMatching(/--- \[TEST\] UNHANDLED ERROR \(ID: \w+\) ---/),
|
||||||
|
|||||||
@@ -164,11 +164,12 @@ describe('Health Routes (/api/health)', () => {
|
|||||||
expect(response.body.message).toBe('DB connection failed'); // This is the message from the original error
|
expect(response.body.message).toBe('DB connection failed'); // This is the message from the original error
|
||||||
expect(response.body.stack).toBeDefined();
|
expect(response.body.stack).toBeDefined();
|
||||||
expect(response.body.errorId).toEqual(expect.any(String));
|
expect(response.body.errorId).toEqual(expect.any(String));
|
||||||
|
console.log('[DEBUG] health.routes.test.ts: Verifying logger.error for DB schema check failure');
|
||||||
expect(mockLogger.error).toHaveBeenCalledWith(
|
expect(mockLogger.error).toHaveBeenCalledWith(
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
err: expect.any(Error),
|
err: expect.any(Error),
|
||||||
}),
|
}),
|
||||||
expect.stringMatching(/Unhandled API Error \(ID: \w+\)/),
|
expect.stringMatching(/Unhandled API Error \(ID: [\w-]+\)/),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -186,7 +187,7 @@ describe('Health Routes (/api/health)', () => {
|
|||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
err: expect.objectContaining({ message: 'DB connection failed' }),
|
err: expect.objectContaining({ message: 'DB connection failed' }),
|
||||||
}),
|
}),
|
||||||
expect.stringMatching(/Unhandled API Error \(ID: \w+\)/),
|
expect.stringMatching(/Unhandled API Error \(ID: [\w-]+\)/),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -220,7 +221,7 @@ describe('Health Routes (/api/health)', () => {
|
|||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
err: expect.any(Error),
|
err: expect.any(Error),
|
||||||
}),
|
}),
|
||||||
expect.stringMatching(/Unhandled API Error \(ID: \w+\)/),
|
expect.stringMatching(/Unhandled API Error \(ID: [\w-]+\)/),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -239,7 +240,7 @@ describe('Health Routes (/api/health)', () => {
|
|||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
err: expect.any(Error),
|
err: expect.any(Error),
|
||||||
}),
|
}),
|
||||||
expect.stringMatching(/Unhandled API Error \(ID: \w+\)/),
|
expect.stringMatching(/Unhandled API Error \(ID: [\w-]+\)/),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -300,7 +301,7 @@ describe('Health Routes (/api/health)', () => {
|
|||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
err: expect.any(Error),
|
err: expect.any(Error),
|
||||||
}),
|
}),
|
||||||
expect.stringMatching(/Unhandled API Error \(ID: \w+\)/),
|
expect.stringMatching(/Unhandled API Error \(ID: [\w-]+\)/),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -321,7 +322,7 @@ describe('Health Routes (/api/health)', () => {
|
|||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
err: expect.objectContaining({ message: 'Pool is not initialized' }),
|
err: expect.objectContaining({ message: 'Pool is not initialized' }),
|
||||||
}),
|
}),
|
||||||
expect.stringMatching(/Unhandled API Error \(ID: \w+\)/),
|
expect.stringMatching(/Unhandled API Error \(ID: [\w-]+\)/),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -336,11 +337,12 @@ describe('Health Routes (/api/health)', () => {
|
|||||||
expect(response.body.message).toBe('Connection timed out');
|
expect(response.body.message).toBe('Connection timed out');
|
||||||
expect(response.body.stack).toBeDefined();
|
expect(response.body.stack).toBeDefined();
|
||||||
expect(response.body.errorId).toEqual(expect.any(String));
|
expect(response.body.errorId).toEqual(expect.any(String));
|
||||||
|
console.log('[DEBUG] health.routes.test.ts: Checking if logger.error was called with the correct pattern');
|
||||||
expect(mockLogger.error).toHaveBeenCalledWith(
|
expect(mockLogger.error).toHaveBeenCalledWith(
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
err: expect.any(Error),
|
err: expect.any(Error),
|
||||||
}),
|
}),
|
||||||
expect.stringMatching(/Unhandled API Error \(ID: \w+\)/),
|
expect.stringMatching(/Unhandled API Error \(ID: [\w-]+\)/),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -357,7 +359,7 @@ describe('Health Routes (/api/health)', () => {
|
|||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
err: expect.any(Error),
|
err: expect.any(Error),
|
||||||
}),
|
}),
|
||||||
expect.stringMatching(/Unhandled API Error \(ID: \w+\)/),
|
expect.stringMatching(/Unhandled API Error \(ID: [\w-]+\)/),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,47 +1,15 @@
|
|||||||
// src/routes/system.routes.test.ts
|
// src/routes/system.routes.test.ts
|
||||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||||
import supertest from 'supertest';
|
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';
|
import { createTestApp } from '../tests/utils/createTestApp';
|
||||||
|
|
||||||
// FIX: Mock util.promisify to correctly handle child_process.exec's (err, stdout, stderr) signature.
|
// 1. Mock the Service Layer
|
||||||
// This is required because the standard util.promisify relies on internal symbols on the real exec function,
|
// This decouples the route test from the service's implementation details.
|
||||||
// which are missing on our Vitest mock. Without this, promisify(mockExec) drops the stdout/stderr arguments.
|
vi.mock('../services/systemService', () => ({
|
||||||
vi.mock('util', async (importOriginal) => {
|
systemService: {
|
||||||
const actual = await importOriginal<typeof import('util')>();
|
getPm2Status: vi.fn(),
|
||||||
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 });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
};
|
|
||||||
},
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
// 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 {
|
|
||||||
...actual,
|
|
||||||
// We provide a basic mock function that will be implemented in each test.
|
|
||||||
exec: vi.fn(),
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
// 2. Mock Geocoding
|
// 2. Mock Geocoding
|
||||||
vi.mock('../services/geocodingService.server', () => ({
|
vi.mock('../services/geocodingService.server', () => ({
|
||||||
geocodingService: {
|
geocodingService: {
|
||||||
@@ -61,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 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 systemRouter from './system.routes';
|
||||||
|
import { geocodingService } from '../services/geocodingService.server';
|
||||||
|
|
||||||
describe('System Routes (/api/system)', () => {
|
describe('System Routes (/api/system)', () => {
|
||||||
const app = createTestApp({ router: systemRouter, basePath: '/api/system' });
|
const app = createTestApp({ router: systemRouter, basePath: '/api/system' });
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
// We cast here to get type-safe access to mock functions like .mockImplementation
|
|
||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('GET /pm2-status', () => {
|
describe('GET /pm2-status', () => {
|
||||||
it('should return success: true when pm2 process is online', async () => {
|
it('should return success: true when pm2 process is online', async () => {
|
||||||
// Arrange: Simulate a successful `pm2 describe` output for an online process.
|
// Arrange: Simulate a successful `pm2 describe` output for an online process.
|
||||||
const pm2OnlineOutput = `
|
vi.mocked(systemService.getPm2Status).mockResolvedValue({
|
||||||
┌─ PM2 info ────────────────┐
|
success: true,
|
||||||
│ status │ online │
|
message: 'Application is online and running under PM2.',
|
||||||
└───────────┴───────────┘
|
});
|
||||||
`;
|
|
||||||
|
|
||||||
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>;
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
// Act
|
// Act
|
||||||
const response = await supertest(app).get('/api/system/pm2-status');
|
const response = await supertest(app).get('/api/system/pm2-status');
|
||||||
@@ -114,28 +60,10 @@ describe('System Routes (/api/system)', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('should return success: false when pm2 process is stopped or errored', async () => {
|
it('should return success: false when pm2 process is stopped or errored', async () => {
|
||||||
const pm2StoppedOutput = `│ status │ stopped │`;
|
vi.mocked(systemService.getPm2Status).mockResolvedValue({
|
||||||
|
success: false,
|
||||||
vi.mocked(exec).mockImplementation(
|
message: 'Application process exists but is not online.',
|
||||||
(
|
});
|
||||||
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>;
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
const response = await supertest(app).get('/api/system/pm2-status');
|
const response = await supertest(app).get('/api/system/pm2-status');
|
||||||
|
|
||||||
@@ -146,33 +74,10 @@ describe('System Routes (/api/system)', () => {
|
|||||||
|
|
||||||
it('should return success: false when pm2 process does not exist', async () => {
|
it('should return success: false when pm2 process does not exist', async () => {
|
||||||
// Arrange: Simulate `pm2 describe` failing because the process isn't found.
|
// Arrange: Simulate `pm2 describe` failing because the process isn't found.
|
||||||
const processNotFoundOutput =
|
vi.mocked(systemService.getPm2Status).mockResolvedValue({
|
||||||
"[PM2][ERROR] Process or Namespace flyer-crawler-api doesn't exist";
|
success: false,
|
||||||
const processNotFoundError = new Error(
|
message: 'Application process is not running under PM2.',
|
||||||
'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>;
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
// Act
|
// Act
|
||||||
const response = await supertest(app).get('/api/system/pm2-status');
|
const response = await supertest(app).get('/api/system/pm2-status');
|
||||||
@@ -187,55 +92,17 @@ describe('System Routes (/api/system)', () => {
|
|||||||
|
|
||||||
it('should return 500 if pm2 command produces stderr output', async () => {
|
it('should return 500 if pm2 command produces stderr output', async () => {
|
||||||
// Arrange: Simulate a successful exit code but with content in stderr.
|
// Arrange: Simulate a successful exit code but with content in stderr.
|
||||||
const stderrOutput = 'A non-fatal warning occurred.';
|
const serviceError = new Error('PM2 command produced an error: A non-fatal warning occurred.');
|
||||||
|
vi.mocked(systemService.getPm2Status).mockRejectedValue(serviceError);
|
||||||
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 response = await supertest(app).get('/api/system/pm2-status');
|
const response = await supertest(app).get('/api/system/pm2-status');
|
||||||
expect(response.status).toBe(500);
|
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 () => {
|
it('should return 500 on a generic exec error', async () => {
|
||||||
vi.mocked(exec).mockImplementation(
|
const serviceError = new Error('System error');
|
||||||
(
|
vi.mocked(systemService.getPm2Status).mockRejectedValue(serviceError);
|
||||||
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>;
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
// Act
|
// Act
|
||||||
const response = await supertest(app).get('/api/system/pm2-status');
|
const response = await supertest(app).get('/api/system/pm2-status');
|
||||||
|
|||||||
@@ -127,19 +127,16 @@ describe('AnalyticsService', () => {
|
|||||||
throw new Error('Processing failed');
|
throw new Error('Processing failed');
|
||||||
}); // "Successfully generated..."
|
}); // "Successfully generated..."
|
||||||
|
|
||||||
// Wrap the async operation that is expected to reject in a function.
|
// Get the promise from the service method.
|
||||||
// This prevents an "unhandled rejection" error by ensuring the `expect.rejects`
|
const promise = service.processWeeklyReportJob(job);
|
||||||
// is actively waiting for the promise to reject when the timers are advanced.
|
|
||||||
const testFunction = async () => {
|
|
||||||
const promise = service.processWeeklyReportJob(job);
|
|
||||||
// Advance timers to trigger the part of the code that throws.
|
|
||||||
await vi.advanceTimersByTimeAsync(30000);
|
|
||||||
// Await the promise to allow the rejection to be caught by `expect.rejects`.
|
|
||||||
await promise;
|
|
||||||
};
|
|
||||||
|
|
||||||
// Now, assert that the entire operation rejects as expected.
|
// Advance timers to trigger the part of the code that throws.
|
||||||
await expect(testFunction()).rejects.toThrow('Processing failed');
|
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.
|
// Verify the side effect (error logging) after the rejection is confirmed.
|
||||||
expect(mockLoggerInstance.error).toHaveBeenCalledWith(
|
expect(mockLoggerInstance.error).toHaveBeenCalledWith(
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||||
import type { UserProfile } from '../types';
|
import type { UserProfile } from '../types';
|
||||||
|
import type * as jsonwebtoken from 'jsonwebtoken';
|
||||||
|
|
||||||
describe('AuthService', () => {
|
describe('AuthService', () => {
|
||||||
let authService: typeof import('./authService').authService;
|
let authService: typeof import('./authService').authService;
|
||||||
let bcrypt: typeof import('bcrypt');
|
let bcrypt: typeof import('bcrypt');
|
||||||
let jwt: typeof import('jsonwebtoken');
|
let jwt: typeof jsonwebtoken & { default: typeof jsonwebtoken };
|
||||||
let userRepo: typeof import('./db/index.db').userRepo;
|
let userRepo: typeof import('./db/index.db').userRepo;
|
||||||
let adminRepo: typeof import('./db/index.db').adminRepo;
|
let adminRepo: typeof import('./db/index.db').adminRepo;
|
||||||
let logger: typeof import('./logger.server').logger;
|
let logger: typeof import('./logger.server').logger;
|
||||||
@@ -31,18 +32,8 @@ describe('AuthService', () => {
|
|||||||
process.env.FRONTEND_URL = 'http://localhost:3000';
|
process.env.FRONTEND_URL = 'http://localhost:3000';
|
||||||
|
|
||||||
// Mock all dependencies before dynamically importing the service
|
// 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('bcrypt');
|
||||||
vi.mock('jsonwebtoken', () => ({
|
|
||||||
sign: vi.fn(),
|
|
||||||
verify: vi.fn(),
|
|
||||||
}));
|
|
||||||
vi.mock('crypto', () => ({
|
|
||||||
default: {
|
|
||||||
randomBytes: vi.fn().mockReturnValue({
|
|
||||||
toString: vi.fn().mockReturnValue('mocked-random-string'),
|
|
||||||
}),
|
|
||||||
},
|
|
||||||
}));
|
|
||||||
vi.mock('./db/index.db', () => ({
|
vi.mock('./db/index.db', () => ({
|
||||||
userRepo: {
|
userRepo: {
|
||||||
createUser: vi.fn(),
|
createUser: vi.fn(),
|
||||||
@@ -72,7 +63,7 @@ describe('AuthService', () => {
|
|||||||
// Dynamically import modules to get the mocked versions and the service instance
|
// Dynamically import modules to get the mocked versions and the service instance
|
||||||
authService = (await import('./authService')).authService;
|
authService = (await import('./authService')).authService;
|
||||||
bcrypt = await import('bcrypt');
|
bcrypt = await import('bcrypt');
|
||||||
jwt = await import('jsonwebtoken');
|
jwt = (await import('jsonwebtoken')) as typeof jwt;
|
||||||
const dbModule = await import('./db/index.db');
|
const dbModule = await import('./db/index.db');
|
||||||
userRepo = dbModule.userRepo;
|
userRepo = dbModule.userRepo;
|
||||||
adminRepo = dbModule.adminRepo;
|
adminRepo = dbModule.adminRepo;
|
||||||
@@ -141,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)
|
// 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(bcrypt.hash).mockImplementation(async () => 'hashed-password');
|
||||||
vi.mocked(userRepo.createUser).mockResolvedValue(mockUserProfile);
|
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(
|
const result = await authService.registerAndLoginUser(
|
||||||
'test@example.com',
|
'test@example.com',
|
||||||
@@ -166,11 +160,14 @@ describe('AuthService', () => {
|
|||||||
|
|
||||||
describe('generateAuthTokens', () => {
|
describe('generateAuthTokens', () => {
|
||||||
it('should generate access and refresh tokens', () => {
|
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);
|
const result = authService.generateAuthTokens(mockUserProfile);
|
||||||
|
|
||||||
expect(jwt.sign).toHaveBeenCalledWith(
|
expect(vi.mocked(jwt.default.sign)).toHaveBeenCalledWith(
|
||||||
{
|
{
|
||||||
user_id: 'user-123',
|
user_id: 'user-123',
|
||||||
email: 'test@example.com',
|
email: 'test@example.com',
|
||||||
@@ -323,7 +320,10 @@ describe('AuthService', () => {
|
|||||||
it('should return new access token if user found', async () => {
|
it('should return new access token if user found', async () => {
|
||||||
vi.mocked(userRepo.findUserByRefreshToken).mockResolvedValue({ user_id: 'user-123' } as any);
|
vi.mocked(userRepo.findUserByRefreshToken).mockResolvedValue({ user_id: 'user-123' } as any);
|
||||||
vi.mocked(userRepo.findUserProfileById).mockResolvedValue(mockUserProfile);
|
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);
|
const result = await authService.refreshAccessToken('valid-token', reqLog);
|
||||||
|
|
||||||
|
|||||||
@@ -190,7 +190,10 @@ describe('Worker Service Lifecycle', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
processExitSpy.mockRestore();
|
if (processExitSpy && typeof processExitSpy.mockRestore === 'function') {
|
||||||
|
console.log('[DEBUG] queueService.server.test.ts: Restoring process.exit spy');
|
||||||
|
processExitSpy.mockRestore();
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should close all workers, queues, the redis connection, and exit the process', async () => {
|
it('should close all workers, queues, the redis connection, and exit the process', async () => {
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
// src/services/systemService.test.ts
|
// src/services/systemService.test.ts
|
||||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
import { describe, it, expect, vi, beforeEach, Mock } from 'vitest';
|
||||||
import { exec, type ExecException } from 'child_process';
|
|
||||||
import { logger } from './logger.server';
|
import { logger } from './logger.server';
|
||||||
|
import type { ExecException } from 'child_process';
|
||||||
|
|
||||||
// Mock logger
|
// Mock logger
|
||||||
vi.mock('./logger.server', () => ({
|
vi.mock('./logger.server', () => ({
|
||||||
@@ -12,48 +12,19 @@ vi.mock('./logger.server', () => ({
|
|||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
|
|
||||||
// Mock util.promisify to handle child_process.exec signature
|
// Import the class, not the singleton instance, to apply Dependency Injection
|
||||||
vi.mock('util', async (importOriginal) => {
|
import { SystemService } from './systemService';
|
||||||
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
|
|
||||||
// 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 {
|
|
||||||
__esModule: true, // Explicitly mark as an ES module
|
|
||||||
exec: vi.fn(),
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
// Import service AFTER mocks to ensure top-level promisify uses the mock
|
|
||||||
import { systemService } from './systemService';
|
|
||||||
|
|
||||||
describe('SystemService', () => {
|
describe('SystemService', () => {
|
||||||
|
let systemService: SystemService;
|
||||||
|
let mockExecAsync: Mock;
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.clearAllMocks();
|
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', () => {
|
describe('getPm2Status', () => {
|
||||||
@@ -65,10 +36,7 @@ describe('SystemService', () => {
|
|||||||
│ 0 │ flyer-crawler-api │ online │
|
│ 0 │ flyer-crawler-api │ online │
|
||||||
└────┴──────────────────────┴──────────┘
|
└────┴──────────────────────┴──────────┘
|
||||||
`;
|
`;
|
||||||
vi.mocked(exec).mockImplementation((cmd, callback: any) => {
|
mockExecAsync.mockResolvedValue({ stdout, stderr: '' });
|
||||||
callback(null, stdout, '');
|
|
||||||
return {} as any;
|
|
||||||
});
|
|
||||||
|
|
||||||
const result = await systemService.getPm2Status();
|
const result = await systemService.getPm2Status();
|
||||||
|
|
||||||
@@ -86,10 +54,7 @@ describe('SystemService', () => {
|
|||||||
│ 0 │ flyer-crawler-api │ stopped │
|
│ 0 │ flyer-crawler-api │ stopped │
|
||||||
└────┴──────────────────────┴──────────┘
|
└────┴──────────────────────┴──────────┘
|
||||||
`;
|
`;
|
||||||
vi.mocked(exec).mockImplementation((cmd, callback: any) => {
|
mockExecAsync.mockResolvedValue({ stdout, stderr: '' });
|
||||||
callback(null, stdout, '');
|
|
||||||
return {} as any;
|
|
||||||
});
|
|
||||||
|
|
||||||
const result = await systemService.getPm2Status();
|
const result = await systemService.getPm2Status();
|
||||||
|
|
||||||
@@ -100,12 +65,11 @@ describe('SystemService', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('should throw error if stderr has content', async () => {
|
it('should throw error if stderr has content', async () => {
|
||||||
vi.mocked(exec).mockImplementation((cmd, callback: any) => {
|
mockExecAsync.mockResolvedValue({ stdout: 'some stdout', stderr: 'some stderr warning' });
|
||||||
callback(null, 'some stdout', 'some stderr warning');
|
|
||||||
return {} as any;
|
|
||||||
});
|
|
||||||
|
|
||||||
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 () => {
|
it('should return success: false when process does not exist', async () => {
|
||||||
@@ -113,10 +77,7 @@ describe('SystemService', () => {
|
|||||||
error.code = 1;
|
error.code = 1;
|
||||||
error.stderr = "[PM2][ERROR] Process or Namespace flyer-crawler-api doesn't exist";
|
error.stderr = "[PM2][ERROR] Process or Namespace flyer-crawler-api doesn't exist";
|
||||||
|
|
||||||
vi.mocked(exec).mockImplementation((cmd, callback: any) => {
|
mockExecAsync.mockRejectedValue(error);
|
||||||
callback(error, '', error.stderr);
|
|
||||||
return {} as any;
|
|
||||||
});
|
|
||||||
|
|
||||||
const result = await systemService.getPm2Status();
|
const result = await systemService.getPm2Status();
|
||||||
|
|
||||||
@@ -125,7 +86,7 @@ describe('SystemService', () => {
|
|||||||
message: 'Application process is not running under PM2.',
|
message: 'Application process is not running under PM2.',
|
||||||
});
|
});
|
||||||
expect(logger.warn).toHaveBeenCalledWith(
|
expect(logger.warn).toHaveBeenCalledWith(
|
||||||
expect.stringContaining('PM2 process "flyer-crawler-api" not found')
|
expect.stringContaining('PM2 process "flyer-crawler-api" not found'),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,16 +1,24 @@
|
|||||||
// src/services/systemService.ts
|
// src/services/systemService.ts
|
||||||
import { exec } from 'child_process';
|
import { exec as nodeExec, type ExecException } from 'child_process';
|
||||||
import { promisify } from 'util';
|
import { promisify } from 'util';
|
||||||
import { logger } from './logger.server';
|
import { logger } from './logger.server';
|
||||||
|
|
||||||
const execAsync = promisify(exec);
|
// Define a type for the exec function for better type safety and testability.
|
||||||
logger.debug({ typeOfExec: typeof exec, isExecFunction: typeof exec === 'function' }, 'SystemService: Initializing execAsync');
|
// It matches the signature of a promisified child_process.exec.
|
||||||
logger.debug({ typeOfPromisify: typeof promisify, isPromisifyFunction: typeof promisify === 'function' }, 'SystemService: Initializing execAsync');
|
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 }> {
|
async getPm2Status(): Promise<{ success: boolean; message: string }> {
|
||||||
try {
|
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.
|
// 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.
|
// This handles cases where pm2 might issue warnings but still exit 0.
|
||||||
@@ -23,7 +31,7 @@ class SystemService {
|
|||||||
? 'Application is online and running under PM2.'
|
? 'Application is online and running under PM2.'
|
||||||
: 'Application process exists but is not online.';
|
: 'Application process exists but is not online.';
|
||||||
return { success: isOnline, message };
|
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.
|
// 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.
|
// 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.
|
// The error message can be in stdout or stderr depending on the pm2 version.
|
||||||
@@ -42,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,61 @@ afterEach(cleanup);
|
|||||||
// By placing mocks here, they are guaranteed to be hoisted and applied
|
// By placing mocks here, they are guaranteed to be hoisted and applied
|
||||||
// before any test files are executed, preventing initialization errors.
|
// 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 '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().mockImplementation((encoding) => {
|
||||||
|
const id = 'mocked_random_id';
|
||||||
|
console.log(`[DEBUG] tests-setup-unit.ts: crypto.randomBytes mock returning "${id}" for encoding "${encoding}"`);
|
||||||
|
return id;
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
randomUUID: vi.fn().mockReturnValue('mocked_random_id'),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
// --- Global Mocks ---
|
// --- Global Mocks ---
|
||||||
|
|
||||||
// 1. Define the mock pool instance logic OUTSIDE the factory so it can be used
|
// 1. Define the mock pool instance logic OUTSIDE the factory so it can be used
|
||||||
|
|||||||
Reference in New Issue
Block a user