|
|
|
|
@@ -1,198 +1,263 @@
|
|
|
|
|
// src/routes/system.routes.test.ts
|
|
|
|
|
import { describe, it, expect, vi, beforeEach, afterAll, beforeAll } from 'vitest';
|
|
|
|
|
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
|
|
|
import supertest from 'supertest';
|
|
|
|
|
import express, { Express } from 'express';
|
|
|
|
|
import * as childProcess from 'child_process';
|
|
|
|
|
import systemRouter from './system.routes'; // This was a duplicate, fixed.
|
|
|
|
|
import { exec, type ExecException, type ExecOptions } from 'child_process';
|
|
|
|
|
import { geocodingService } from '../services/geocodingService.server';
|
|
|
|
|
import { createTestApp } from '../tests/utils/createTestApp';
|
|
|
|
|
|
|
|
|
|
// -----------------------------------------------------------------------------
|
|
|
|
|
// 1. Mocks Configuration
|
|
|
|
|
// -----------------------------------------------------------------------------
|
|
|
|
|
// 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: () => {} };
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// Mock Logger: Use a completely silent, lightweight object to prevent memory bloat
|
|
|
|
|
vi.mock('../services/logger.server', () => ({
|
|
|
|
|
logger: {
|
|
|
|
|
info: vi.fn(),
|
|
|
|
|
warn: vi.fn(),
|
|
|
|
|
error: vi.fn(),
|
|
|
|
|
debug: vi.fn(),
|
|
|
|
|
child: () => ({ info: vi.fn(), warn: vi.fn(), error: vi.fn() }),
|
|
|
|
|
},
|
|
|
|
|
}));
|
|
|
|
|
return {
|
|
|
|
|
default: { exec: mockExec },
|
|
|
|
|
exec: mockExec,
|
|
|
|
|
};
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// Mock Geocoding Service
|
|
|
|
|
// 2. Mock Geocoding
|
|
|
|
|
vi.mock('../services/geocodingService.server', () => ({
|
|
|
|
|
geocodingService: {
|
|
|
|
|
geocodeAddress: vi.fn(),
|
|
|
|
|
},
|
|
|
|
|
}));
|
|
|
|
|
|
|
|
|
|
// Mock child_process safely
|
|
|
|
|
// We use importActual to ensure we don't break 'spawn', 'fork' etc. which might be used by internal tools
|
|
|
|
|
vi.mock('child_process', async (importOriginal) => {
|
|
|
|
|
const actual = await importOriginal<typeof import('child_process')>();
|
|
|
|
|
const mockExec = vi.fn();
|
|
|
|
|
return {
|
|
|
|
|
...actual,
|
|
|
|
|
exec: mockExec,
|
|
|
|
|
default: {
|
|
|
|
|
...actual,
|
|
|
|
|
exec: mockExec,
|
|
|
|
|
},
|
|
|
|
|
};
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// Import the router AFTER mocks
|
|
|
|
|
import systemRouter from './system.routes';
|
|
|
|
|
import { geocodingService } from '../services/geocodingService.server';
|
|
|
|
|
|
|
|
|
|
// -----------------------------------------------------------------------------
|
|
|
|
|
// 2. Test Suite
|
|
|
|
|
// -----------------------------------------------------------------------------
|
|
|
|
|
// 3. Mock Logger
|
|
|
|
|
vi.mock('../services/logger.server', () => ({
|
|
|
|
|
logger: {
|
|
|
|
|
info: vi.fn(),
|
|
|
|
|
debug: vi.fn(),
|
|
|
|
|
error: vi.fn(),
|
|
|
|
|
warn: vi.fn(),
|
|
|
|
|
child: vi.fn().mockReturnThis(),
|
|
|
|
|
},
|
|
|
|
|
}));
|
|
|
|
|
|
|
|
|
|
describe('System Routes (/api/system)', () => {
|
|
|
|
|
let app: Express;
|
|
|
|
|
// Create a typed reference to the mocked exec function
|
|
|
|
|
const mockExec = vi.mocked(childProcess.exec);
|
|
|
|
|
const app = createTestApp({ router: systemRouter, basePath: '/api/system' });
|
|
|
|
|
|
|
|
|
|
beforeAll(() => {
|
|
|
|
|
// Create a minimal express app for isolation
|
|
|
|
|
app = express();
|
|
|
|
|
app.use(express.json());
|
|
|
|
|
app.use('/api/system', systemRouter);
|
|
|
|
|
|
|
|
|
|
// Basic error handler
|
|
|
|
|
app.use((err: any, req: express.Request, res: express.Response, next: express.NextFunction) => {
|
|
|
|
|
res.status(err.status || 500).json({ message: err.message, errors: err.errors });
|
|
|
|
|
});
|
|
|
|
|
// Add a basic error handler to capture errors passed to next(err) and return JSON.
|
|
|
|
|
app.use((err: any, req: any, res: any, next: any) => {
|
|
|
|
|
res.status(err.status || 500).json({ message: err.message, errors: err.errors });
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
beforeEach(() => {
|
|
|
|
|
// We cast here to get type-safe access to mock functions like .mockImplementation
|
|
|
|
|
vi.clearAllMocks();
|
|
|
|
|
|
|
|
|
|
// Default safe implementation for exec to prevent timeouts or hangs
|
|
|
|
|
mockExec.mockImplementation((command, options, callback) => {
|
|
|
|
|
// Normalize args: options is optional
|
|
|
|
|
let cb: any = callback;
|
|
|
|
|
if (typeof options === 'function') {
|
|
|
|
|
cb = options;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (cb) {
|
|
|
|
|
// Run async to simulate IO
|
|
|
|
|
const timer = setTimeout(() => {
|
|
|
|
|
cb(null, 'MOCK_STDOUT', '');
|
|
|
|
|
}, 0);
|
|
|
|
|
// Return a mock ChildProcess object
|
|
|
|
|
return { unref: () => clearTimeout(timer), kill: () => {} } as any;
|
|
|
|
|
}
|
|
|
|
|
return { unref: () => {}, kill: () => {} } as any;
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
afterAll(() => {
|
|
|
|
|
vi.restoreAllMocks();
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
describe('GET /pm2-status', () => {
|
|
|
|
|
// Helper to configure the specific exec outcome for a test
|
|
|
|
|
const mockExecOutcome = (error: Error | null, stdout: string, stderr: string) => {
|
|
|
|
|
mockExec.mockImplementation((cmd, options, callback) => {
|
|
|
|
|
const cb = typeof options === 'function' ? options : callback;
|
|
|
|
|
if (cb) {
|
|
|
|
|
setTimeout(() => cb(error as any, stdout, stderr), 0);
|
|
|
|
|
}
|
|
|
|
|
return { unref: () => {} } as any;
|
|
|
|
|
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>;
|
|
|
|
|
},
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
// Act
|
|
|
|
|
const response = await supertest(app).get('/api/system/pm2-status');
|
|
|
|
|
|
|
|
|
|
// Assert
|
|
|
|
|
expect(response.status).toBe(200);
|
|
|
|
|
expect(response.body).toEqual({
|
|
|
|
|
success: true,
|
|
|
|
|
message: 'Application is online and running under PM2.',
|
|
|
|
|
});
|
|
|
|
|
};
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
const testCases = [
|
|
|
|
|
{
|
|
|
|
|
description: 'should return success: true when pm2 process is online',
|
|
|
|
|
mock: {
|
|
|
|
|
error: null,
|
|
|
|
|
stdout: `
|
|
|
|
|
┌─ PM2 info ────────────────┐
|
|
|
|
|
│ status │ online │
|
|
|
|
|
└───────────┴───────────┘
|
|
|
|
|
`,
|
|
|
|
|
stderr: '',
|
|
|
|
|
},
|
|
|
|
|
expectedStatus: 200,
|
|
|
|
|
expectedBody: { success: true, message: 'Application is online and running under PM2.' },
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
description: 'should return success: false when pm2 process is stopped',
|
|
|
|
|
mock: { error: null, stdout: '│ status │ stopped │', stderr: '' },
|
|
|
|
|
expectedStatus: 200,
|
|
|
|
|
expectedBody: { success: false, message: 'Application process exists but is not online.' },
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
description: 'should return success: false when pm2 process does not exist',
|
|
|
|
|
mock: {
|
|
|
|
|
error: new Error('Command failed'),
|
|
|
|
|
stdout: "[PM2][ERROR] Process or Namespace flyer-crawler-api doesn't exist",
|
|
|
|
|
stderr: '',
|
|
|
|
|
},
|
|
|
|
|
expectedStatus: 200,
|
|
|
|
|
expectedBody: { success: false, message: 'Application process is not running under PM2.' },
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
description: 'should return 500 if pm2 command produces stderr output',
|
|
|
|
|
mock: { error: null, stdout: 'Some stdout', stderr: 'A non-fatal warning occurred.' },
|
|
|
|
|
expectedStatus: 500,
|
|
|
|
|
expectedBody: { message: 'PM2 command produced an error: A non-fatal warning occurred.' },
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
description: 'should return 500 on a generic exec error',
|
|
|
|
|
mock: { error: new Error('System error'), stdout: '', stderr: 'stderr output' },
|
|
|
|
|
expectedStatus: 500,
|
|
|
|
|
expectedBody: { message: 'System error' },
|
|
|
|
|
},
|
|
|
|
|
];
|
|
|
|
|
it('should return success: false when pm2 process is stopped or errored', async () => {
|
|
|
|
|
const pm2StoppedOutput = `│ status │ stopped │`;
|
|
|
|
|
|
|
|
|
|
it.each(testCases)('$description', async ({ mock, expectedStatus, expectedBody }) => {
|
|
|
|
|
mockExecOutcome(mock.error, mock.stdout, mock.stderr);
|
|
|
|
|
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>;
|
|
|
|
|
},
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
const response = await supertest(app).get('/api/system/pm2-status');
|
|
|
|
|
|
|
|
|
|
expect(response.status).toBe(expectedStatus);
|
|
|
|
|
expect(response.body).toEqual(expectedBody);
|
|
|
|
|
// Assert
|
|
|
|
|
expect(response.status).toBe(200);
|
|
|
|
|
expect(response.body.success).toBe(false);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
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>;
|
|
|
|
|
},
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
// Act
|
|
|
|
|
const response = await supertest(app).get('/api/system/pm2-status');
|
|
|
|
|
|
|
|
|
|
// Assert
|
|
|
|
|
expect(response.status).toBe(200);
|
|
|
|
|
expect(response.body).toEqual({
|
|
|
|
|
success: false,
|
|
|
|
|
message: 'Application process is not running under PM2.',
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
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 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}`);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
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>;
|
|
|
|
|
},
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
// Act
|
|
|
|
|
const response = await supertest(app).get('/api/system/pm2-status');
|
|
|
|
|
|
|
|
|
|
// Assert
|
|
|
|
|
expect(response.status).toBe(500);
|
|
|
|
|
expect(response.body.message).toBe('System error');
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
describe('POST /geocode', () => {
|
|
|
|
|
it('should return geocoded coordinates for a valid address', async () => {
|
|
|
|
|
// Arrange
|
|
|
|
|
const mockCoordinates = { lat: 48.4284, lng: -123.3656 };
|
|
|
|
|
vi.mocked(geocodingService.geocodeAddress).mockResolvedValue(mockCoordinates);
|
|
|
|
|
|
|
|
|
|
// Act
|
|
|
|
|
const response = await supertest(app)
|
|
|
|
|
.post('/api/system/geocode')
|
|
|
|
|
.send({ address: 'Victoria, BC' });
|
|
|
|
|
|
|
|
|
|
// Assert
|
|
|
|
|
expect(response.status).toBe(200);
|
|
|
|
|
expect(response.body).toEqual(mockCoordinates);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('should return 404 if the address cannot be geocoded', async () => {
|
|
|
|
|
vi.mocked(geocodingService.geocodeAddress).mockResolvedValue(null);
|
|
|
|
|
|
|
|
|
|
const response = await supertest(app)
|
|
|
|
|
.post('/api/system/geocode')
|
|
|
|
|
.send({ address: 'Invalid Address' });
|
|
|
|
|
|
|
|
|
|
expect(response.status).toBe(404);
|
|
|
|
|
expect(response.body.message).toBe('Could not geocode the provided address.');
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('should return 500 if the geocoding service throws an error', async () => {
|
|
|
|
|
vi.mocked(geocodingService.geocodeAddress).mockRejectedValue(
|
|
|
|
|
new Error('Service unavailable'),
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
const geocodeError = new Error('Geocoding service unavailable');
|
|
|
|
|
vi.mocked(geocodingService.geocodeAddress).mockRejectedValue(geocodeError);
|
|
|
|
|
const response = await supertest(app)
|
|
|
|
|
.post('/api/system/geocode')
|
|
|
|
|
.send({ address: 'Any Address' });
|
|
|
|
|
|
|
|
|
|
expect(response.status).toBe(500);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
@@ -200,8 +265,9 @@ describe('System Routes (/api/system)', () => {
|
|
|
|
|
const response = await supertest(app)
|
|
|
|
|
.post('/api/system/geocode')
|
|
|
|
|
.send({ not_address: 'Victoria, BC' });
|
|
|
|
|
|
|
|
|
|
expect(response.status).toBe(400);
|
|
|
|
|
// Zod validation error message can vary slightly depending on configuration or version
|
|
|
|
|
expect(response.body.errors[0].message).toMatch(/An address string is required|Required/i);
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|