Compare commits

...

6 Commits

Author SHA1 Message Date
Gitea Actions
b2e086d5ba ci: Bump version to 0.2.34 [skip ci] 2025-12-30 08:44:55 +05:00
07a9787570 fix unit tests
All checks were successful
Deploy to Test Environment / deploy-to-test (push) Successful in 12m5s
2025-12-29 19:44:25 -08:00
Gitea Actions
4bf5dc3d58 ci: Bump version to 0.2.33 [skip ci] 2025-12-30 08:02:02 +05:00
be3d269928 fix unit tests
All checks were successful
Deploy to Test Environment / deploy-to-test (push) Successful in 12m3s
2025-12-29 19:01:21 -08:00
Gitea Actions
80a53fae94 ci: Bump version to 0.2.32 [skip ci] 2025-12-30 07:27:55 +05:00
e15d2b6c2f fix unit tests
All checks were successful
Deploy to Test Environment / deploy-to-test (push) Successful in 12m4s
2025-12-29 18:27:30 -08:00
11 changed files with 164 additions and 268 deletions

4
package-lock.json generated
View File

@@ -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",

View File

@@ -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\"",

View File

@@ -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+\) ---/),

View File

@@ -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-]+\)/),
); );
}); });
}); });

View File

@@ -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');

View File

@@ -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(

View File

@@ -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);

View File

@@ -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 () => {

View File

@@ -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'),
); );
}); });
}); });

View File

@@ -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);

View File

@@ -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