Compare commits

...

10 Commits

Author SHA1 Message Date
Gitea Actions
034887069c ci: Bump version to 0.0.9 [skip ci] 2025-12-23 07:23:30 +05:00
84b5e0e15e Merge branch 'main' of https://gitea.projectium.com/torbo/flyer-crawler.projectium.com
Some checks failed
Deploy to Test Environment / deploy-to-test (push) Has been cancelled
2025-12-22 18:22:23 -08:00
dc0f774699 try to stop system.route test crashes fuck sakes 2025-12-22 18:21:39 -08:00
Gitea Actions
1195b7e87f ci: Bump version to 0.0.8 [skip ci] 2025-12-23 04:45:54 +05:00
e9889f1f1e Merge branch 'main' of https://gitea.projectium.com/torbo/flyer-crawler.projectium.com
All checks were successful
Deploy to Test Environment / deploy-to-test (push) Successful in 15m41s
2025-12-22 15:45:11 -08:00
3c7f6429aa try to stop system.route test crashes 2025-12-22 15:43:59 -08:00
Gitea Actions
0db90dfaa6 ci: Bump version to 0.0.7 [skip ci] 2025-12-23 04:33:19 +05:00
b7a1294ae6 fix to versioning
Some checks are pending
Deploy to Test Environment / deploy-to-test (push) Has started running
2025-12-22 15:32:43 -08:00
Gitea Actions
be652f9790 ci: Bump version to 0.0.6 [skip ci] 2025-12-23 04:17:17 +05:00
1a3e6a9ab5 unit test fixes
Some checks failed
Deploy to Test Environment / deploy-to-test (push) Failing after 38s
2025-12-22 15:11:18 -08:00
8 changed files with 139 additions and 104 deletions

View File

@@ -51,7 +51,14 @@ jobs:
# Bump the patch version number. This creates a new commit and a new tag.
# The commit message includes [skip ci] to prevent this push from triggering another workflow run.
npm version patch -m "ci: Bump version to %s [skip ci]"
# If the tag already exists (e.g. re-running a failed job), we skip the conflicting version.
if ! npm version patch -m "ci: Bump version to %s [skip ci]"; then
echo "⚠️ Version bump failed (likely tag exists). Attempting to skip to next version..."
# Bump package.json to the conflicting version without git tagging
npm version patch --no-git-tag-version > /dev/null
# Bump again to the next version, forcing it because the directory is now dirty
npm version patch -m "ci: Bump version to %s [skip ci]" --force
fi
# Push the new commit and the new tag back to the main branch.
git push --follow-tags

4
package-lock.json generated
View File

@@ -1,12 +1,12 @@
{
"name": "flyer-crawler",
"version": "0.0.5",
"version": "0.0.9",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "flyer-crawler",
"version": "0.0.5",
"version": "0.0.9",
"dependencies": {
"@bull-board/api": "^6.14.2",
"@bull-board/express": "^6.14.2",

View File

@@ -1,7 +1,7 @@
{
"name": "flyer-crawler",
"private": true,
"version": "0.0.5",
"version": "0.0.9",
"type": "module",
"scripts": {
"dev": "concurrently \"npm:start:dev\" \"vite\"",

View File

@@ -192,7 +192,7 @@ describe('Auth Routes (/api/auth)', () => {
// Assert
expect(response.status).toBe(201);
expect(response.body.message).toBe('User registered successfully!');
expect(response.body.user.email).toBe(newUserEmail);
expect(response.body.userprofile.user.email).toBe(newUserEmail);
expect(response.body.token).toBeTypeOf('string'); // This was a duplicate, fixed.
expect(db.userRepo.createUser).toHaveBeenCalled();
});
@@ -295,7 +295,7 @@ describe('Auth Routes (/api/auth)', () => {
// Assert
expect(response.status).toBe(200);
// The API now returns a nested UserProfile object
expect(response.body.user).toEqual(
expect(response.body.userprofile).toEqual(
expect.objectContaining({
user_id: 'user-123',
user: expect.objectContaining({

View File

@@ -1,76 +1,108 @@
// src/routes/system.routes.test.ts
import { describe, it, expect, vi, beforeEach } 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';
// 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: () => {} };
});
// -----------------------------------------------------------------------------
// 1. Mocks Configuration
// -----------------------------------------------------------------------------
return {
default: { exec: mockExec },
exec: mockExec,
};
});
// 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() }),
},
}));
// 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';
// -----------------------------------------------------------------------------
// 2. Test Suite
// -----------------------------------------------------------------------------
describe('System Routes (/api/system)', () => {
const app = createTestApp({ router: systemRouter, basePath: '/api/system' });
let app: Express;
// Create a typed reference to the mocked exec function
const mockExec = vi.mocked(childProcess.exec);
// 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 });
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 });
});
});
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 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) => {
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(error, stdout, stderr);
return { unref: () => {} } 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 = [
@@ -79,10 +111,10 @@ describe('System Routes (/api/system)', () => {
mock: {
error: null,
stdout: `
┌─ PM2 info ────────────────┐
│ status │ online │
└───────────┴───────────┘
`,
┌─ PM2 info ────────────────┐
│ status │ online │
└───────────┴───────────┘
`,
stderr: '',
},
expectedStatus: 200,
@@ -97,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: '',
},
@@ -119,13 +151,10 @@ describe('System Routes (/api/system)', () => {
];
it.each(testCases)('$description', async ({ mock, expectedStatus, expectedBody }) => {
// Arrange
setupExecMock(mock.error as ExecException | null, mock.stdout, mock.stderr);
mockExecOutcome(mock.error, mock.stdout, mock.stderr);
// Act
const response = await supertest(app).get('/api/system/pm2-status');
// Assert
expect(response.status).toBe(expectedStatus);
expect(response.body).toEqual(expectedBody);
});
@@ -133,35 +162,37 @@ describe('System Routes (/api/system)', () => {
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 () => {
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);
});
@@ -169,9 +200,8 @@ 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);
});
});
});

View File

@@ -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,66 +18,65 @@ 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) => {
// The name 'flyer-crawler-api' comes from your ecosystem.config.cjs file.
exec('pm2 describe flyer-crawler-api', (error, stdout, stderr) => {
// 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");
// Using simple exec command
const cmd = 'pm2 describe flyer-crawler-api';
exec(cmd, (error, stdout, stderr) => {
if (res.headersSent) return;
// 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:',
);
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:');
// 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.';
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 },
@@ -87,7 +86,6 @@ router.post(
const coordinates = await geocodingService.geocodeAddress(address, req.log);
if (!coordinates) {
// This check remains, but now it only fails if BOTH services fail.
return res.status(404).json({ message: 'Could not geocode the provided address.' });
}

View File

@@ -128,7 +128,7 @@ export class AIService {
// This preserves the dependency injection pattern used throughout the class.
this.aiClient = genAI
? {
generateContent: (request) => {
generateContent: async (request) => {
// The model name is now injected here, into every call, as the new SDK requires.
// Architectural guard clause: All requests from this service must have content.
// This prevents sending invalid requests to the API and satisfies TypeScript's strictness.

View File

@@ -87,7 +87,7 @@ describe('Geocoding Service', () => {
// Assert
expect(result).toEqual(coordinates);
expect(logger.error).toHaveBeenCalledWith(
{ err: expect.any(Error), cacheKey: expect.any(String) },
{ err: 'Redis down', cacheKey: expect.any(String) },
'Redis GET or JSON.parse command failed. Proceeding without cache.',
);
expect(mockGoogleService.geocode).toHaveBeenCalled(); // Should still proceed to fetch
@@ -107,7 +107,7 @@ describe('Geocoding Service', () => {
expect(mocks.mockRedis.get).toHaveBeenCalledWith(cacheKey);
// The service should log the JSON parsing error and continue
expect(logger.error).toHaveBeenCalledWith(
{ err: expect.any(SyntaxError), cacheKey: expect.any(String) },
{ err: expect.any(String), cacheKey: expect.any(String) },
'Redis GET or JSON.parse command failed. Proceeding without cache.',
);
expect(mockGoogleService.geocode).toHaveBeenCalledTimes(1);
@@ -185,7 +185,7 @@ describe('Geocoding Service', () => {
// Assert
expect(result).toEqual(coordinates);
expect(logger.error).toHaveBeenCalledWith(
{ err: expect.any(Error) },
{ err: 'Network Error' },
expect.stringContaining('An error occurred while calling the Google Maps Geocoding API'),
);
expect(mockNominatimService.geocode).toHaveBeenCalledWith(address, logger);
@@ -223,7 +223,7 @@ describe('Geocoding Service', () => {
expect(mockGoogleService.geocode).toHaveBeenCalledTimes(1);
expect(mocks.mockRedis.set).toHaveBeenCalledTimes(1);
expect(logger.error).toHaveBeenCalledWith(
{ err: expect.any(Error), cacheKey: expect.any(String) },
{ err: 'Redis SET failed', cacheKey: expect.any(String) },
'Redis SET command failed. Result will not be cached.',
);
});
@@ -271,7 +271,7 @@ describe('Geocoding Service', () => {
// Act & Assert
await expect(geocodingService.clearGeocodeCache(logger)).rejects.toThrow(redisError);
expect(logger.error).toHaveBeenCalledWith(
{ err: redisError },
{ err: redisError.message },
'Failed to clear geocode cache from Redis.',
);
expect(mocks.mockRedis.del).not.toHaveBeenCalled();