Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
034887069c | ||
| 84b5e0e15e | |||
| dc0f774699 | |||
|
|
1195b7e87f | ||
| e9889f1f1e | |||
| 3c7f6429aa |
4
package-lock.json
generated
4
package-lock.json
generated
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "flyer-crawler",
|
||||
"version": "0.0.7",
|
||||
"version": "0.0.9",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "flyer-crawler",
|
||||
"version": "0.0.7",
|
||||
"version": "0.0.9",
|
||||
"dependencies": {
|
||||
"@bull-board/api": "^6.14.2",
|
||||
"@bull-board/express": "^6.14.2",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "flyer-crawler",
|
||||
"private": true,
|
||||
"version": "0.0.7",
|
||||
"version": "0.0.9",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "concurrently \"npm:start:dev\" \"vite\"",
|
||||
|
||||
@@ -1,135 +1,108 @@
|
||||
// src/routes/system.routes.test.ts
|
||||
import { describe, it, expect, vi, beforeEach, afterAll } from 'vitest';
|
||||
import { describe, it, expect, vi, beforeEach, afterAll, beforeAll } from 'vitest';
|
||||
import supertest from 'supertest';
|
||||
import { exec, type ExecException, type ExecOptions } from 'child_process'; // Keep this for mocking
|
||||
import { geocodingService } from '../services/geocodingService.server';
|
||||
import { createTestApp } from '../tests/utils/createTestApp';
|
||||
import { mockLogger } from '../tests/utils/mockLogger';
|
||||
import express, { Express } from 'express';
|
||||
import * as childProcess from 'child_process';
|
||||
|
||||
// =============================================================================
|
||||
// MOCKS CONFIGURATION
|
||||
// =============================================================================
|
||||
// -----------------------------------------------------------------------------
|
||||
// 1. Mocks Configuration
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
// 1. Mock child_process
|
||||
// FIX: Use the simple factory pattern for child_process to avoid default export issues.
|
||||
// We also add logging here to catch any un-mocked usages immediately.
|
||||
vi.mock('child_process', () => {
|
||||
const defaultMockExec = (command: string, ...args: any[]) => {
|
||||
console.log(`[MOCK:child_process] Global exec hit for command: "${command}"`);
|
||||
const callback = args.find((arg) => typeof arg === 'function');
|
||||
if (callback) {
|
||||
// Defensive: Run callback async to prevent Zalgo and stack overflows
|
||||
process.nextTick(() => {
|
||||
callback(null, 'PM2 OK', '');
|
||||
});
|
||||
}
|
||||
return {
|
||||
unref: () => {
|
||||
/* no-op */
|
||||
},
|
||||
};
|
||||
};
|
||||
// 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: defaultMockExec },
|
||||
exec: defaultMockExec,
|
||||
};
|
||||
});
|
||||
|
||||
// 2. Mock Geocoding
|
||||
// Mock Geocoding Service
|
||||
vi.mock('../services/geocodingService.server', () => ({
|
||||
geocodingService: {
|
||||
geocodeAddress: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
// 3. Mock Logger
|
||||
vi.mock('../services/logger.server', () => ({
|
||||
logger: mockLogger,
|
||||
}));
|
||||
// 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 all mocks are defined.
|
||||
// Import the router AFTER mocks
|
||||
import systemRouter from './system.routes';
|
||||
import { geocodingService } from '../services/geocodingService.server';
|
||||
|
||||
// =============================================================================
|
||||
// TEST SUITE
|
||||
// =============================================================================
|
||||
// -----------------------------------------------------------------------------
|
||||
// 2. Test Suite
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
describe('System Routes (/api/system)', () => {
|
||||
console.log('[TEST SUITE] initializing System Routes tests...');
|
||||
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);
|
||||
|
||||
// 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) => {
|
||||
console.error('[TEST SUITE] Error caught in test app error handler:', err.message);
|
||||
res.status(err.status || 500).json({ message: err.message, errors: err.errors });
|
||||
// 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 });
|
||||
});
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
// We cast here to get type-safe access to mock functions like .mockImplementation
|
||||
vi.clearAllMocks();
|
||||
console.log('[TEST SUITE] Mocks cleared.');
|
||||
|
||||
// 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();
|
||||
console.log('[TEST SUITE] Mocks restored. Suite finished.');
|
||||
});
|
||||
|
||||
describe('GET /pm2-status', () => {
|
||||
// Helper function to set up the mock for `child_process.exec` for each test case.
|
||||
// This avoids repeating the complex mock implementation in every test.
|
||||
const setupExecMock = (error: ExecException | null, stdout: string, stderr: string) => {
|
||||
console.log('[TEST SETUP] Configuring exec mock implementation');
|
||||
|
||||
vi.mocked(exec).mockImplementation(
|
||||
(
|
||||
command: string,
|
||||
optionsOrCb?:
|
||||
| ExecOptions
|
||||
| ((error: ExecException | null, stdout: string, stderr: string) => void)
|
||||
| null,
|
||||
cb?: ((error: ExecException | null, stdout: string, stderr: string) => void) | null,
|
||||
) => {
|
||||
console.log(`[MOCK EXEC] Command received: "${command}"`);
|
||||
|
||||
// Normalize arguments: options is optional
|
||||
let callback:
|
||||
| ((error: ExecException | null, stdout: string, stderr: string) => void)
|
||||
| undefined;
|
||||
|
||||
if (typeof optionsOrCb === 'function') {
|
||||
callback = optionsOrCb;
|
||||
} else if (typeof cb === 'function') {
|
||||
callback = cb;
|
||||
}
|
||||
|
||||
if (callback) {
|
||||
const safeCallback = callback;
|
||||
// Defensive: Execute callback asynchronously (nextTick) to simulate real I/O
|
||||
// This prevents the "synchronous callback" issue which can confuse test runners and async/await
|
||||
process.nextTick(() => {
|
||||
console.log(
|
||||
`[MOCK EXEC] Invoking callback for "${command}" | Error: ${!!error} | Stdout len: ${stdout.length}`,
|
||||
);
|
||||
try {
|
||||
safeCallback(error, stdout, stderr);
|
||||
} catch (err) {
|
||||
console.error('[MOCK EXEC] CRITICAL: Error inside exec callback:', err);
|
||||
}
|
||||
});
|
||||
} else {
|
||||
console.warn(`[MOCK EXEC] No callback provided for "${command}"`);
|
||||
}
|
||||
|
||||
return {
|
||||
unref: () => {
|
||||
/* no-op */
|
||||
},
|
||||
} as ReturnType<typeof exec>;
|
||||
},
|
||||
);
|
||||
// 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;
|
||||
});
|
||||
};
|
||||
|
||||
const testCases = [
|
||||
@@ -138,10 +111,10 @@ describe('System Routes (/api/system)', () => {
|
||||
mock: {
|
||||
error: null,
|
||||
stdout: `
|
||||
┌─ PM2 info ────────────────┐
|
||||
│ status │ online │
|
||||
└───────────┴───────────┘
|
||||
`,
|
||||
┌─ PM2 info ────────────────┐
|
||||
│ status │ online │
|
||||
└───────────┴───────────┘
|
||||
`,
|
||||
stderr: '',
|
||||
},
|
||||
expectedStatus: 200,
|
||||
@@ -156,7 +129,7 @@ describe('System Routes (/api/system)', () => {
|
||||
{
|
||||
description: 'should return success: false when pm2 process does not exist',
|
||||
mock: {
|
||||
error: Object.assign(new Error('Command failed'), { code: 1 }),
|
||||
error: new Error('Command failed'),
|
||||
stdout: "[PM2][ERROR] Process or Namespace flyer-crawler-api doesn't exist",
|
||||
stderr: '',
|
||||
},
|
||||
@@ -177,72 +150,58 @@ describe('System Routes (/api/system)', () => {
|
||||
},
|
||||
];
|
||||
|
||||
it.each(testCases)(
|
||||
'$description',
|
||||
async ({ mock, expectedStatus, expectedBody, description }) => {
|
||||
console.log(`[TEST CASE] Starting: ${description}`);
|
||||
it.each(testCases)('$description', async ({ mock, expectedStatus, expectedBody }) => {
|
||||
mockExecOutcome(mock.error, mock.stdout, mock.stderr);
|
||||
|
||||
// Arrange
|
||||
setupExecMock(mock.error as ExecException | null, mock.stdout, mock.stderr);
|
||||
const response = await supertest(app).get('/api/system/pm2-status');
|
||||
|
||||
// Act
|
||||
console.log('[TEST CASE] Sending request...');
|
||||
const response = await supertest(app).get('/api/system/pm2-status');
|
||||
console.log(`[TEST CASE] Response received: status=${response.status}`);
|
||||
|
||||
// Assert
|
||||
expect(response.status).toBe(expectedStatus);
|
||||
expect(response.body).toEqual(expectedBody);
|
||||
console.log(`[TEST CASE] Completed: ${description}`);
|
||||
},
|
||||
);
|
||||
expect(response.status).toBe(expectedStatus);
|
||||
expect(response.body).toEqual(expectedBody);
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST /geocode', () => {
|
||||
it('should return geocoded coordinates for a valid address', async () => {
|
||||
console.log('[TEST CASE] POST /geocode - valid address');
|
||||
// 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 () => {
|
||||
console.log('[TEST CASE] POST /geocode - address not found');
|
||||
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 () => {
|
||||
console.log('[TEST CASE] POST /geocode - service error');
|
||||
const geocodeError = new Error('Geocoding service unavailable');
|
||||
vi.mocked(geocodingService.geocodeAddress).mockRejectedValue(geocodeError);
|
||||
vi.mocked(geocodingService.geocodeAddress).mockRejectedValue(
|
||||
new Error('Service unavailable'),
|
||||
);
|
||||
|
||||
const response = await supertest(app)
|
||||
.post('/api/system/geocode')
|
||||
.send({ address: 'Any Address' });
|
||||
|
||||
expect(response.status).toBe(500);
|
||||
});
|
||||
|
||||
it('should return 400 if the address is missing from the body', async () => {
|
||||
console.log('[TEST CASE] POST /geocode - validation error');
|
||||
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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// src/routes/system.ts
|
||||
// src/routes/system.routests
|
||||
import { Router, Request, Response, NextFunction } from 'express';
|
||||
import { exec } from 'child_process';
|
||||
import { z } from 'zod';
|
||||
@@ -8,7 +8,7 @@ import { validateRequest } from '../middleware/validation.middleware';
|
||||
|
||||
const router = Router();
|
||||
|
||||
// Helper for consistent required string validation (handles missing/null/empty)
|
||||
// Validation Schemas
|
||||
const requiredString = (message: string) =>
|
||||
z.preprocess((val) => val ?? '', z.string().min(1, message));
|
||||
|
||||
@@ -18,100 +18,79 @@ const geocodeSchema = z.object({
|
||||
}),
|
||||
});
|
||||
|
||||
// An empty schema for routes that do not expect any input, to maintain a consistent validation pattern.
|
||||
const emptySchema = z.object({});
|
||||
|
||||
/**
|
||||
* Checks the status of the 'flyer-crawler-api' process managed by PM2.
|
||||
* This is intended for development and diagnostic purposes.
|
||||
* GET /pm2-status
|
||||
* Checks if 'flyer-crawler-api' is online via PM2.
|
||||
*/
|
||||
router.get(
|
||||
'/pm2-status',
|
||||
validateRequest(emptySchema),
|
||||
(req: Request, res: Response, next: NextFunction) => {
|
||||
// DEBUG: Add logging to trace entry into the route
|
||||
console.log(`[API /pm2-status] Received request. Executing PM2 check...`);
|
||||
// Using simple exec command
|
||||
const cmd = 'pm2 describe flyer-crawler-api';
|
||||
|
||||
// The name 'flyer-crawler-api' comes from your ecosystem.config.cjs file.
|
||||
exec('pm2 describe flyer-crawler-api', (error, stdout, stderr) => {
|
||||
// DEBUG: Log callback entry
|
||||
console.log(
|
||||
`[API /pm2-status] PM2 check complete. Error: ${!!error}, Stderr Len: ${stderr?.length}`,
|
||||
);
|
||||
exec(cmd, (error, stdout, stderr) => {
|
||||
if (res.headersSent) return;
|
||||
|
||||
// The "doesn't exist" message can appear in stdout or stderr depending on PM2 version and context.
|
||||
const processNotFound =
|
||||
stdout?.includes("doesn't exist") || stderr?.includes("doesn't exist");
|
||||
// Handle "process not found" case which might come as an error code OR text
|
||||
const output = (stdout || '') + (stderr || '');
|
||||
const processNotFound = output.includes("doesn't exist");
|
||||
|
||||
if (error) {
|
||||
// 'pm2 describe' exits with an error if the process is not found.
|
||||
// We can treat this as a "fail" status for our check.
|
||||
if (processNotFound) {
|
||||
logger.warn('[API /pm2-status] PM2 process "flyer-crawler-api" not found.');
|
||||
logger.warn('[API /pm2-status] PM2 process not found.');
|
||||
return res.json({
|
||||
success: false,
|
||||
message: 'Application process is not running under PM2.',
|
||||
});
|
||||
}
|
||||
logger.error(
|
||||
{ error: stderr || error.message },
|
||||
'[API /pm2-status] Error executing pm2 describe:',
|
||||
);
|
||||
// DEBUG: Explicit log for test debugging
|
||||
console.error('[API /pm2-status] Exec failed:', error);
|
||||
|
||||
logger.error({ err: error.message }, '[API /pm2-status] Exec error');
|
||||
return next(error);
|
||||
}
|
||||
|
||||
// Check if there was output to stderr, even if the exit code was 0 (success).
|
||||
// This handles warnings or non-fatal errors that should arguably be treated as failures in this context.
|
||||
// Treat stderr as error if it contains text (PM2 often outputs warnings here)
|
||||
if (stderr && stderr.trim().length > 0) {
|
||||
logger.error({ stderr }, '[API /pm2-status] PM2 executed but produced stderr:');
|
||||
console.error('[API /pm2-status] STDERR present:', stderr);
|
||||
// Special case: if it's just a warning we might want to ignore, but sticking to defensive rules:
|
||||
logger.error({ stderr }, '[API /pm2-status] Stderr output');
|
||||
return next(new Error(`PM2 command produced an error: ${stderr}`));
|
||||
}
|
||||
|
||||
// If the command succeeds, we can parse stdout to check the status.
|
||||
const isOnline = /│ status\s+│ online\s+│/m.test(stdout);
|
||||
// Check for online status in the table output
|
||||
const isOnline = /│ status\s+│ online\s+│/m.test(stdout || '');
|
||||
const message = isOnline
|
||||
? 'Application is online and running under PM2.'
|
||||
: 'Application process exists but is not online.';
|
||||
|
||||
console.log(`[API /pm2-status] Success. Online: ${isOnline}`);
|
||||
res.json({ success: isOnline, message });
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* POST /api/system/geocode - Geocodes a given address string.
|
||||
* This acts as a secure proxy to the Google Maps Geocoding API.
|
||||
* POST /geocode
|
||||
* Proxies geocoding requests securely.
|
||||
*/
|
||||
router.post(
|
||||
'/geocode',
|
||||
validateRequest(geocodeSchema),
|
||||
async (req: Request, res: Response, next: NextFunction) => {
|
||||
// Infer type and cast request object as per ADR-003
|
||||
type GeocodeRequest = z.infer<typeof geocodeSchema>;
|
||||
const {
|
||||
body: { address },
|
||||
} = req as unknown as GeocodeRequest;
|
||||
|
||||
// DEBUG
|
||||
console.log(`[API /geocode] Request for address: "${address}"`);
|
||||
|
||||
try {
|
||||
const coordinates = await geocodingService.geocodeAddress(address, req.log);
|
||||
|
||||
if (!coordinates) {
|
||||
console.log('[API /geocode] Address not found.');
|
||||
// This check remains, but now it only fails if BOTH services fail.
|
||||
return res.status(404).json({ message: 'Could not geocode the provided address.' });
|
||||
}
|
||||
|
||||
console.log('[API /geocode] Success:', coordinates);
|
||||
res.json(coordinates);
|
||||
} catch (error) {
|
||||
console.error('[API /geocode] Error:', error);
|
||||
next(error);
|
||||
}
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user