Compare commits
20 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
695bbb61b9 | ||
| 877c971833 | |||
| ed3af07aab | |||
|
|
dd4b34edfa | ||
| 91fa2f0516 | |||
|
|
aefd57e57b | ||
| 2ca4eb47ac | |||
| a4fe30da22 | |||
|
|
abab7fd25e | ||
| 53dd26d2d9 | |||
| ab3da0336c | |||
|
|
ed6d6349a2 | ||
| d4db2a709a | |||
| 508583809b | |||
|
|
6b1f7e7590 | ||
| 07bb31f4fb | |||
| a42fb76da8 | |||
|
|
08c320423c | ||
| d2498065ed | |||
| 56dc96f418 |
@@ -283,7 +283,7 @@ jobs:
|
||||
echo "WARNING: No schema hash found in the test database."
|
||||
echo "This is expected for a first-time deployment. The hash will be set after a successful deployment."
|
||||
echo "--- Debug: Dumping schema_info table ---"
|
||||
PGPASSWORD="$DB_PASSWORD" psql -v ON_ERROR_STOP=0 -h "$DB_HOST" -p 5432 -U "$DB_USER" -d "$DB_NAME" -c "SELECT * FROM public.schema_info;" || true
|
||||
PGPASSWORD="$DB_PASSWORD" psql -v ON_ERROR_STOP=0 -h "$DB_HOST" -p 5432 -U "$DB_USER" -d "$DB_NAME" -P pager=off -c "SELECT * FROM public.schema_info;" || true
|
||||
echo "----------------------------------------"
|
||||
# We allow the deployment to continue, but a manual schema update is required.
|
||||
# You could choose to fail here by adding `exit 1`.
|
||||
|
||||
4
package-lock.json
generated
4
package-lock.json
generated
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "flyer-crawler",
|
||||
"version": "0.1.7",
|
||||
"version": "0.1.14",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "flyer-crawler",
|
||||
"version": "0.1.7",
|
||||
"version": "0.1.14",
|
||||
"dependencies": {
|
||||
"@bull-board/api": "^6.14.2",
|
||||
"@bull-board/express": "^6.14.2",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "flyer-crawler",
|
||||
"private": true,
|
||||
"version": "0.1.7",
|
||||
"version": "0.1.14",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "concurrently \"npm:start:dev\" \"vite\"",
|
||||
|
||||
@@ -15,16 +15,19 @@ import type { Logger } from 'pino';
|
||||
// Create a mock logger that we can inject into requests and assert against.
|
||||
// We only mock the methods we intend to spy on. The rest of the complex Pino
|
||||
// Logger type is satisfied by casting, which is a common and clean testing practice.
|
||||
const mockLogger = {
|
||||
error: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
info: vi.fn(),
|
||||
debug: vi.fn(),
|
||||
fatal: vi.fn(),
|
||||
trace: vi.fn(),
|
||||
silent: vi.fn(),
|
||||
child: vi.fn().mockReturnThis(),
|
||||
} as unknown as Logger;
|
||||
const { mockLogger } = vi.hoisted(() => {
|
||||
const mockLogger = {
|
||||
error: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
info: vi.fn(),
|
||||
debug: vi.fn(),
|
||||
fatal: vi.fn(),
|
||||
trace: vi.fn(),
|
||||
silent: vi.fn(),
|
||||
child: vi.fn().mockReturnThis(),
|
||||
};
|
||||
return { mockLogger };
|
||||
});
|
||||
|
||||
// Mock the global logger as a fallback, though our tests will focus on req.log
|
||||
vi.mock('../services/logger.server', () => ({ logger: mockLogger }));
|
||||
@@ -37,7 +40,7 @@ const app = express();
|
||||
app.use(express.json());
|
||||
// Add a middleware to inject our mock logger into each request as `req.log`
|
||||
app.use((req: Request, res: Response, next: NextFunction) => {
|
||||
req.log = mockLogger;
|
||||
req.log = mockLogger as unknown as Logger;
|
||||
next();
|
||||
});
|
||||
|
||||
@@ -106,7 +109,10 @@ describe('errorHandler Middleware', () => {
|
||||
it('should return a generic 500 error for a standard Error object', async () => {
|
||||
const response = await supertest(app).get('/generic-error');
|
||||
expect(response.status).toBe(500);
|
||||
expect(response.body).toEqual({ message: 'A generic server error occurred.' });
|
||||
// In test/dev, we now expect a stack trace for 5xx errors.
|
||||
expect(response.body.message).toBe('A generic server error occurred.');
|
||||
expect(response.body.stack).toBeDefined();
|
||||
expect(response.body.errorId).toEqual(expect.any(String));
|
||||
expect(mockLogger.error).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
err: expect.any(Error),
|
||||
@@ -116,7 +122,7 @@ describe('errorHandler Middleware', () => {
|
||||
expect.stringMatching(/Unhandled API Error \(ID: \w+\)/),
|
||||
);
|
||||
expect(consoleErrorSpy).toHaveBeenCalledWith(
|
||||
expect.stringContaining('--- [TEST] UNHANDLED ERROR ---'),
|
||||
expect.stringMatching(/--- \[TEST\] UNHANDLED ERROR \(ID: \w+\) ---/),
|
||||
expect.any(Error),
|
||||
);
|
||||
});
|
||||
@@ -130,15 +136,11 @@ describe('errorHandler Middleware', () => {
|
||||
expect(mockLogger.warn).toHaveBeenCalledWith(
|
||||
{
|
||||
err: expect.any(Error),
|
||||
validationErrors: undefined,
|
||||
statusCode: 404,
|
||||
},
|
||||
'Client Error on GET /http-error-404: Resource not found',
|
||||
);
|
||||
expect(consoleErrorSpy).toHaveBeenCalledWith(
|
||||
expect.stringContaining('--- [TEST] UNHANDLED ERROR ---'),
|
||||
expect.any(Error),
|
||||
);
|
||||
expect(consoleErrorSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should handle a NotFoundError with a 404 status', async () => {
|
||||
@@ -150,15 +152,11 @@ describe('errorHandler Middleware', () => {
|
||||
expect(mockLogger.warn).toHaveBeenCalledWith(
|
||||
{
|
||||
err: expect.any(NotFoundError),
|
||||
validationErrors: undefined,
|
||||
statusCode: 404,
|
||||
},
|
||||
'Client Error on GET /not-found-error: Specific resource missing',
|
||||
);
|
||||
expect(consoleErrorSpy).toHaveBeenCalledWith(
|
||||
expect.stringContaining('--- [TEST] UNHANDLED ERROR ---'),
|
||||
expect.any(NotFoundError),
|
||||
);
|
||||
expect(consoleErrorSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should handle a ForeignKeyConstraintError with a 400 status and the specific error message', async () => {
|
||||
@@ -170,15 +168,11 @@ describe('errorHandler Middleware', () => {
|
||||
expect(mockLogger.warn).toHaveBeenCalledWith(
|
||||
{
|
||||
err: expect.any(ForeignKeyConstraintError),
|
||||
validationErrors: undefined,
|
||||
statusCode: 400,
|
||||
},
|
||||
'Client Error on GET /fk-error: The referenced item does not exist.',
|
||||
);
|
||||
expect(consoleErrorSpy).toHaveBeenCalledWith(
|
||||
expect.stringContaining('--- [TEST] UNHANDLED ERROR ---'),
|
||||
expect.any(ForeignKeyConstraintError),
|
||||
);
|
||||
expect(consoleErrorSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should handle a UniqueConstraintError with a 409 status and the specific error message', async () => {
|
||||
@@ -190,15 +184,11 @@ describe('errorHandler Middleware', () => {
|
||||
expect(mockLogger.warn).toHaveBeenCalledWith(
|
||||
{
|
||||
err: expect.any(UniqueConstraintError),
|
||||
validationErrors: undefined,
|
||||
statusCode: 409,
|
||||
},
|
||||
'Client Error on GET /unique-error: This item already exists.',
|
||||
);
|
||||
expect(consoleErrorSpy).toHaveBeenCalledWith(
|
||||
expect.stringContaining('--- [TEST] UNHANDLED ERROR ---'),
|
||||
expect.any(UniqueConstraintError),
|
||||
);
|
||||
expect(consoleErrorSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should handle a ValidationError with a 400 status and include the validation errors array', async () => {
|
||||
@@ -219,17 +209,17 @@ describe('errorHandler Middleware', () => {
|
||||
},
|
||||
'Client Error on GET /validation-error: Input validation failed',
|
||||
);
|
||||
expect(consoleErrorSpy).toHaveBeenCalledWith(
|
||||
expect.stringContaining('--- [TEST] UNHANDLED ERROR ---'),
|
||||
expect.any(ValidationError),
|
||||
);
|
||||
expect(consoleErrorSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should handle a DatabaseError with a 500 status and a generic message', async () => {
|
||||
const response = await supertest(app).get('/db-error-500');
|
||||
|
||||
expect(response.status).toBe(500);
|
||||
expect(response.body).toEqual({ message: 'A database connection issue occurred.' });
|
||||
// In test/dev, we now expect a stack trace for 5xx errors.
|
||||
expect(response.body.message).toBe('A database connection issue occurred.');
|
||||
expect(response.body.stack).toBeDefined();
|
||||
expect(response.body.errorId).toEqual(expect.any(String));
|
||||
expect(mockLogger.error).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
err: expect.any(DatabaseError),
|
||||
@@ -239,7 +229,7 @@ describe('errorHandler Middleware', () => {
|
||||
expect.stringMatching(/Unhandled API Error \(ID: \w+\)/),
|
||||
);
|
||||
expect(consoleErrorSpy).toHaveBeenCalledWith(
|
||||
expect.stringContaining('--- [TEST] UNHANDLED ERROR ---'),
|
||||
expect.stringMatching(/--- \[TEST\] UNHANDLED ERROR \(ID: \w+\) ---/),
|
||||
expect.any(DatabaseError),
|
||||
);
|
||||
});
|
||||
@@ -249,8 +239,14 @@ describe('errorHandler Middleware', () => {
|
||||
|
||||
expect(response.status).toBe(401);
|
||||
expect(response.body).toEqual({ message: 'Invalid Token' });
|
||||
// 4xx errors log as warn
|
||||
expect(mockLogger.warn).toHaveBeenCalled();
|
||||
expect(mockLogger.warn).toHaveBeenCalledWith(
|
||||
{
|
||||
err: expect.any(Error),
|
||||
statusCode: 401,
|
||||
},
|
||||
'Client Error on GET /unauthorized-error-no-status: Invalid Token',
|
||||
);
|
||||
expect(consoleErrorSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should handle an UnauthorizedError with explicit status', async () => {
|
||||
@@ -258,6 +254,14 @@ describe('errorHandler Middleware', () => {
|
||||
|
||||
expect(response.status).toBe(401);
|
||||
expect(response.body).toEqual({ message: 'Invalid Token' });
|
||||
expect(mockLogger.warn).toHaveBeenCalledWith(
|
||||
{
|
||||
err: expect.any(Error),
|
||||
statusCode: 401,
|
||||
},
|
||||
'Client Error on GET /unauthorized-error-with-status: Invalid Token',
|
||||
);
|
||||
expect(consoleErrorSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should call next(err) if headers have already been sent', () => {
|
||||
@@ -302,6 +306,7 @@ describe('errorHandler Middleware', () => {
|
||||
expect(response.body.message).toMatch(
|
||||
/An unexpected server error occurred. Please reference error ID: \w+/,
|
||||
);
|
||||
expect(response.body.stack).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should return the actual error message for client errors (4xx) in production', async () => {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
// src/middleware/errorHandler.ts
|
||||
import { Request, Response, NextFunction } from 'express';
|
||||
import crypto from 'crypto';
|
||||
import { ZodError } from 'zod';
|
||||
import {
|
||||
ForeignKeyConstraintError,
|
||||
@@ -24,45 +25,77 @@ export const errorHandler = (err: Error, req: Request, res: Response, next: Next
|
||||
// Use the request-scoped logger if available, otherwise fall back to the global logger.
|
||||
const log = req.log || logger;
|
||||
|
||||
// --- Handle Zod Validation Errors ---
|
||||
// --- Handle Zod Validation Errors (from validateRequest middleware) ---
|
||||
if (err instanceof ZodError) {
|
||||
log.warn({ err: err.flatten() }, 'Request validation failed');
|
||||
return res.status(400).json({
|
||||
message: 'The request data is invalid.',
|
||||
errors: err.issues.map((e) => ({ path: e.path, message: e.message })),
|
||||
});
|
||||
const statusCode = 400;
|
||||
const message = 'The request data is invalid.';
|
||||
const errors = err.issues.map((e) => ({ path: e.path, message: e.message }));
|
||||
log.warn({ err, validationErrors: errors, statusCode }, `Client Error on ${req.method} ${req.path}: ${message}`);
|
||||
return res.status(statusCode).json({ message, errors });
|
||||
}
|
||||
|
||||
// --- Handle Custom Operational Errors ---
|
||||
if (err instanceof NotFoundError) {
|
||||
log.info({ err }, 'Resource not found');
|
||||
return res.status(404).json({ message: err.message });
|
||||
const statusCode = 404;
|
||||
log.warn({ err, statusCode }, `Client Error on ${req.method} ${req.path}: ${err.message}`);
|
||||
return res.status(statusCode).json({ message: err.message });
|
||||
}
|
||||
|
||||
if (err instanceof ValidationError) {
|
||||
log.warn({ err }, 'Validation error occurred');
|
||||
return res.status(400).json({ message: err.message, errors: err.validationErrors });
|
||||
const statusCode = 400;
|
||||
log.warn(
|
||||
{ err, validationErrors: err.validationErrors, statusCode },
|
||||
`Client Error on ${req.method} ${req.path}: ${err.message}`,
|
||||
);
|
||||
return res.status(statusCode).json({ message: err.message, errors: err.validationErrors });
|
||||
}
|
||||
|
||||
if (err instanceof UniqueConstraintError) {
|
||||
log.warn({ err }, 'Constraint error occurred');
|
||||
return res.status(409).json({ message: err.message }); // Use 409 Conflict for unique constraints
|
||||
const statusCode = 409;
|
||||
log.warn({ err, statusCode }, `Client Error on ${req.method} ${req.path}: ${err.message}`);
|
||||
return res.status(statusCode).json({ message: err.message }); // Use 409 Conflict for unique constraints
|
||||
}
|
||||
|
||||
if (err instanceof ForeignKeyConstraintError) {
|
||||
log.warn({ err }, 'Foreign key constraint violation');
|
||||
return res.status(400).json({ message: err.message });
|
||||
const statusCode = 400;
|
||||
log.warn({ err, statusCode }, `Client Error on ${req.method} ${req.path}: ${err.message}`);
|
||||
return res.status(statusCode).json({ message: err.message });
|
||||
}
|
||||
|
||||
// --- Handle Generic Errors ---
|
||||
// Log the full error object for debugging. The pino logger will handle redaction.
|
||||
log.error({ err }, 'An unhandled error occurred in an Express route');
|
||||
// --- Handle Generic Client Errors (e.g., from express-jwt, or manual status setting) ---
|
||||
let status = (err as any).status || (err as any).statusCode;
|
||||
// Default UnauthorizedError to 401 if no status is present, a common case for express-jwt.
|
||||
if (err.name === 'UnauthorizedError' && !status) {
|
||||
status = 401;
|
||||
}
|
||||
if (status && status >= 400 && status < 500) {
|
||||
log.warn({ err, statusCode: status }, `Client Error on ${req.method} ${req.path}: ${err.message}`);
|
||||
return res.status(status).json({ message: err.message });
|
||||
}
|
||||
|
||||
// --- Handle All Other (500-level) Errors ---
|
||||
const errorId = crypto.randomBytes(4).toString('hex');
|
||||
log.error(
|
||||
{
|
||||
err,
|
||||
errorId,
|
||||
req: { method: req.method, url: req.url, headers: req.headers, body: req.body },
|
||||
},
|
||||
`Unhandled API Error (ID: ${errorId})`,
|
||||
);
|
||||
|
||||
// Also log to console in test environment for visibility in test runners
|
||||
if (process.env.NODE_ENV === 'test') {
|
||||
console.error(`--- [TEST] UNHANDLED ERROR (ID: ${errorId}) ---`, err);
|
||||
}
|
||||
|
||||
// In production, send a generic message to avoid leaking implementation details.
|
||||
if (process.env.NODE_ENV === 'production') {
|
||||
return res.status(500).json({ message: 'An internal server error occurred.' });
|
||||
return res.status(500).json({
|
||||
message: `An unexpected server error occurred. Please reference error ID: ${errorId}`,
|
||||
});
|
||||
}
|
||||
|
||||
// In development, send more details for easier debugging.
|
||||
return res.status(500).json({ message: err.message, stack: err.stack });
|
||||
// In non-production environments (dev, test, etc.), send more details for easier debugging.
|
||||
return res.status(500).json({ message: err.message, stack: err.stack, errorId });
|
||||
};
|
||||
@@ -13,7 +13,6 @@ import {
|
||||
import type { SuggestedCorrection, Brand, UserProfile, UnmatchedFlyerItem } from '../types';
|
||||
import { NotFoundError } from '../services/db/errors.db'; // This can stay, it's a type/class not a module with side effects.
|
||||
import { createTestApp } from '../tests/utils/createTestApp';
|
||||
import { mockLogger } from '../tests/utils/mockLogger';
|
||||
|
||||
// Mock the file upload middleware to allow testing the controller's internal check
|
||||
vi.mock('../middleware/fileUpload.middleware', () => ({
|
||||
@@ -96,8 +95,9 @@ vi.mock('@bull-board/express', () => ({
|
||||
}));
|
||||
|
||||
// Mock the logger
|
||||
vi.mock('../services/logger.server', () => ({
|
||||
logger: mockLogger,
|
||||
vi.mock('../services/logger.server', async () => ({
|
||||
// Use async import to avoid hoisting issues with mockLogger
|
||||
logger: (await import('../tests/utils/mockLogger')).mockLogger,
|
||||
}));
|
||||
|
||||
// Mock the passport middleware
|
||||
|
||||
@@ -6,7 +6,6 @@ import { createMockUserProfile } from '../tests/utils/mockFactories';
|
||||
import type { Job } from 'bullmq';
|
||||
import type { UserProfile } from '../types';
|
||||
import { createTestApp } from '../tests/utils/createTestApp';
|
||||
import { mockLogger } from '../tests/utils/mockLogger';
|
||||
|
||||
// Mock the background job service to control its methods.
|
||||
vi.mock('../services/backgroundJobService', () => ({
|
||||
@@ -66,8 +65,9 @@ import {
|
||||
} from '../services/queueService.server';
|
||||
|
||||
// Mock the logger
|
||||
vi.mock('../services/logger.server', () => ({
|
||||
logger: mockLogger,
|
||||
vi.mock('../services/logger.server', async () => ({
|
||||
// Use async import to avoid hoisting issues with mockLogger
|
||||
logger: (await import('../tests/utils/mockLogger')).mockLogger,
|
||||
}));
|
||||
|
||||
// Mock the passport middleware
|
||||
|
||||
@@ -5,7 +5,6 @@ import type { Request, Response, NextFunction } from 'express';
|
||||
import { createMockUserProfile } from '../tests/utils/mockFactories';
|
||||
import type { UserProfile } from '../types';
|
||||
import { createTestApp } from '../tests/utils/createTestApp';
|
||||
import { mockLogger } from '../tests/utils/mockLogger';
|
||||
|
||||
vi.mock('../services/db/index.db', () => ({
|
||||
adminRepo: {
|
||||
@@ -45,8 +44,9 @@ import adminRouter from './admin.routes';
|
||||
import { adminRepo } from '../services/db/index.db';
|
||||
|
||||
// Mock the logger
|
||||
vi.mock('../services/logger.server', () => ({
|
||||
logger: mockLogger,
|
||||
vi.mock('../services/logger.server', async () => ({
|
||||
// Use async import to avoid hoisting issues with mockLogger
|
||||
logger: (await import('../tests/utils/mockLogger')).mockLogger,
|
||||
}));
|
||||
|
||||
// Mock the passport middleware
|
||||
|
||||
@@ -4,7 +4,6 @@ import supertest from 'supertest';
|
||||
import type { Request, Response, NextFunction } from 'express';
|
||||
import { createMockUserProfile } from '../tests/utils/mockFactories';
|
||||
import { createTestApp } from '../tests/utils/createTestApp';
|
||||
import { mockLogger } from '../tests/utils/mockLogger';
|
||||
|
||||
// Mock dependencies
|
||||
vi.mock('../services/geocodingService.server', () => ({
|
||||
@@ -50,8 +49,9 @@ import adminRouter from './admin.routes';
|
||||
import { geocodingService } from '../services/geocodingService.server';
|
||||
|
||||
// Mock the logger
|
||||
vi.mock('../services/logger.server', () => ({
|
||||
logger: mockLogger,
|
||||
vi.mock('../services/logger.server', async () => ({
|
||||
// Use async import to avoid hoisting issues with mockLogger
|
||||
logger: (await import('../tests/utils/mockLogger')).mockLogger,
|
||||
}));
|
||||
|
||||
// Mock the passport middleware
|
||||
|
||||
@@ -6,7 +6,6 @@ import { createMockUserProfile, createMockAdminUserView } from '../tests/utils/m
|
||||
import type { UserProfile, Profile } from '../types';
|
||||
import { NotFoundError } from '../services/db/errors.db';
|
||||
import { createTestApp } from '../tests/utils/createTestApp';
|
||||
import { mockLogger } from '../tests/utils/mockLogger';
|
||||
|
||||
vi.mock('../services/db/index.db', () => ({
|
||||
adminRepo: {
|
||||
@@ -44,8 +43,9 @@ vi.mock('@bull-board/express', () => ({
|
||||
}));
|
||||
|
||||
// Mock the logger
|
||||
vi.mock('../services/logger.server', () => ({
|
||||
logger: mockLogger,
|
||||
vi.mock('../services/logger.server', async () => ({
|
||||
// Use async import to avoid hoisting issues with mockLogger
|
||||
logger: (await import('../tests/utils/mockLogger')).mockLogger,
|
||||
}));
|
||||
|
||||
// Import the router AFTER all mocks are defined.
|
||||
|
||||
@@ -55,8 +55,9 @@ import aiRouter from './ai.routes';
|
||||
import { flyerQueue } from '../services/queueService.server';
|
||||
|
||||
// Mock the logger to keep test output clean
|
||||
vi.mock('../services/logger.server', () => ({
|
||||
logger: mockLogger,
|
||||
vi.mock('../services/logger.server', async () => ({
|
||||
// Use async import to avoid hoisting issues with mockLogger
|
||||
logger: (await import('../tests/utils/mockLogger')).mockLogger,
|
||||
}));
|
||||
|
||||
// Mock the passport module to control authentication for different tests.
|
||||
|
||||
@@ -9,7 +9,6 @@ import {
|
||||
createMockUserProfile,
|
||||
createMockUserWithPasswordHash,
|
||||
} from '../tests/utils/mockFactories';
|
||||
import { mockLogger } from '../tests/utils/mockLogger';
|
||||
|
||||
// --- FIX: Hoist passport mocks to be available for vi.mock ---
|
||||
const passportMocks = vi.hoisted(() => {
|
||||
@@ -111,8 +110,9 @@ vi.mock('../services/db/connection.db', () => ({
|
||||
}));
|
||||
|
||||
// Mock the logger
|
||||
vi.mock('../services/logger.server', () => ({
|
||||
logger: mockLogger,
|
||||
vi.mock('../services/logger.server', async () => ({
|
||||
// Use async import to avoid hoisting issues with mockLogger
|
||||
logger: (await import('../tests/utils/mockLogger')).mockLogger,
|
||||
}));
|
||||
|
||||
// Mock the email service
|
||||
@@ -144,6 +144,8 @@ import { UniqueConstraintError } from '../services/db/errors.db'; // Import actu
|
||||
import express from 'express';
|
||||
import { errorHandler } from '../middleware/errorHandler'; // Assuming this exists
|
||||
|
||||
const { mockLogger } = await import('../tests/utils/mockLogger');
|
||||
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
app.use(cookieParser()); // Mount BEFORE router
|
||||
|
||||
@@ -7,7 +7,6 @@ import {
|
||||
createMockBudget,
|
||||
createMockSpendingByCategory,
|
||||
} from '../tests/utils/mockFactories';
|
||||
import { mockLogger } from '../tests/utils/mockLogger';
|
||||
import { createTestApp } from '../tests/utils/createTestApp';
|
||||
import { ForeignKeyConstraintError, NotFoundError } from '../services/db/errors.db';
|
||||
// 1. Mock the Service Layer directly.
|
||||
@@ -26,8 +25,9 @@ vi.mock('../services/db/index.db', () => ({
|
||||
}));
|
||||
|
||||
// Mock the logger to keep test output clean
|
||||
vi.mock('../services/logger.server', () => ({
|
||||
logger: mockLogger,
|
||||
vi.mock('../services/logger.server', async () => ({
|
||||
// Use async import to avoid hoisting issues with mockLogger
|
||||
logger: (await import('../tests/utils/mockLogger')).mockLogger,
|
||||
}));
|
||||
|
||||
// Import the router and mocked DB AFTER all mocks are defined.
|
||||
|
||||
@@ -4,7 +4,6 @@ import supertest from 'supertest';
|
||||
import type { Request, Response, NextFunction } from 'express';
|
||||
import { createMockUserProfile, createMockWatchedItemDeal } from '../tests/utils/mockFactories';
|
||||
import type { WatchedItemDeal } from '../types';
|
||||
import { mockLogger } from '../tests/utils/mockLogger';
|
||||
import { createTestApp } from '../tests/utils/createTestApp';
|
||||
|
||||
// 1. Mock the Service Layer directly.
|
||||
@@ -17,10 +16,12 @@ vi.mock('../services/db/deals.db', () => ({
|
||||
// Import the router and mocked repo AFTER all mocks are defined.
|
||||
import dealsRouter from './deals.routes';
|
||||
import { dealsRepo } from '../services/db/deals.db';
|
||||
import { mockLogger } from '../tests/utils/mockLogger';
|
||||
|
||||
// Mock the logger to keep test output clean
|
||||
vi.mock('../services/logger.server', () => ({
|
||||
logger: mockLogger,
|
||||
vi.mock('../services/logger.server', async () => ({
|
||||
// Use async import to avoid hoisting issues with mockLogger
|
||||
logger: (await import('../tests/utils/mockLogger')).mockLogger,
|
||||
}));
|
||||
|
||||
// Mock the passport middleware
|
||||
|
||||
@@ -23,8 +23,9 @@ import * as db from '../services/db/index.db';
|
||||
import { mockLogger } from '../tests/utils/mockLogger';
|
||||
|
||||
// Mock the logger to keep test output clean
|
||||
vi.mock('../services/logger.server', () => ({
|
||||
logger: mockLogger,
|
||||
vi.mock('../services/logger.server', async () => ({
|
||||
// Use async import to avoid hoisting issues with mockLogger
|
||||
logger: (await import('../tests/utils/mockLogger')).mockLogger,
|
||||
}));
|
||||
|
||||
// Define a reusable matcher for the logger object.
|
||||
|
||||
@@ -27,8 +27,9 @@ import gamificationRouter from './gamification.routes';
|
||||
import * as db from '../services/db/index.db';
|
||||
|
||||
// Mock the logger to keep test output clean
|
||||
vi.mock('../services/logger.server', () => ({
|
||||
logger: mockLogger,
|
||||
vi.mock('../services/logger.server', async () => ({
|
||||
// Use async import to avoid hoisting issues with mockLogger
|
||||
logger: (await import('../tests/utils/mockLogger')).mockLogger,
|
||||
}));
|
||||
|
||||
// Use vi.hoisted to create mutable mock function references.
|
||||
|
||||
@@ -32,8 +32,9 @@ import healthRouter from './health.routes';
|
||||
import * as dbConnection from '../services/db/connection.db';
|
||||
|
||||
// Mock the logger to keep test output clean.
|
||||
vi.mock('../services/logger.server', () => ({
|
||||
logger: mockLogger,
|
||||
vi.mock('../services/logger.server', async () => ({
|
||||
// Use async import to avoid hoisting issues with mockLogger
|
||||
logger: (await import('../tests/utils/mockLogger')).mockLogger,
|
||||
}));
|
||||
|
||||
// Cast the mocked import to a Mocked type for type-safe access to mock functions.
|
||||
|
||||
@@ -56,7 +56,6 @@ import {
|
||||
createMockUserProfile,
|
||||
createMockUserWithPasswordHash,
|
||||
} from '../tests/utils/mockFactories';
|
||||
import { mockLogger } from '../tests/utils/mockLogger';
|
||||
|
||||
// Mock dependencies before importing the passport configuration
|
||||
vi.mock('../services/db/index.db', () => ({
|
||||
@@ -74,9 +73,10 @@ vi.mock('../services/db/index.db', () => ({
|
||||
|
||||
const mockedDb = db as Mocked<typeof db>;
|
||||
|
||||
vi.mock('../services/logger.server', () => ({
|
||||
// This mock is used by the module under test and can be imported in the test file.
|
||||
logger: mockLogger,
|
||||
vi.mock('../services/logger.server', async () => ({
|
||||
// Use async import to avoid hoisting issues with mockLogger
|
||||
// Note: We need to await the import inside the factory
|
||||
logger: (await import('../tests/utils/mockLogger')).mockLogger,
|
||||
}));
|
||||
|
||||
// Mock bcrypt for password comparisons
|
||||
|
||||
@@ -6,7 +6,6 @@ import {
|
||||
createMockDietaryRestriction,
|
||||
createMockAppliance,
|
||||
} from '../tests/utils/mockFactories';
|
||||
import { mockLogger } from '../tests/utils/mockLogger';
|
||||
import { createTestApp } from '../tests/utils/createTestApp';
|
||||
|
||||
// 1. Mock the Service Layer directly.
|
||||
@@ -21,10 +20,12 @@ vi.mock('../services/db/index.db', () => ({
|
||||
// Import the router and mocked DB AFTER all mocks are defined.
|
||||
import personalizationRouter from './personalization.routes';
|
||||
import * as db from '../services/db/index.db';
|
||||
import { mockLogger } from '../tests/utils/mockLogger';
|
||||
|
||||
// Mock the logger to keep test output clean
|
||||
vi.mock('../services/logger.server', () => ({
|
||||
logger: mockLogger,
|
||||
vi.mock('../services/logger.server', async () => ({
|
||||
// Use async import to avoid hoisting issues with mockLogger
|
||||
logger: (await import('../tests/utils/mockLogger')).mockLogger,
|
||||
}));
|
||||
|
||||
describe('Personalization Routes (/api/personalization)', () => {
|
||||
|
||||
@@ -12,8 +12,9 @@ vi.mock('../services/db/price.db', () => ({
|
||||
}));
|
||||
|
||||
// Mock the logger to keep test output clean
|
||||
vi.mock('../services/logger.server', () => ({
|
||||
logger: mockLogger,
|
||||
vi.mock('../services/logger.server', async () => ({
|
||||
// Use async import to avoid hoisting issues with mockLogger
|
||||
logger: (await import('../tests/utils/mockLogger')).mockLogger,
|
||||
}));
|
||||
|
||||
// Import the router AFTER other setup.
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
// src/routes/recipe.routes.test.ts
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import supertest from 'supertest';
|
||||
import { mockLogger } from '../tests/utils/mockLogger';
|
||||
import { createMockRecipe, createMockRecipeComment } from '../tests/utils/mockFactories';
|
||||
import { NotFoundError } from '../services/db/errors.db';
|
||||
import { createTestApp } from '../tests/utils/createTestApp';
|
||||
@@ -20,10 +19,12 @@ vi.mock('../services/db/index.db', () => ({
|
||||
// Import the router and mocked DB AFTER all mocks are defined.
|
||||
import recipeRouter from './recipe.routes';
|
||||
import * as db from '../services/db/index.db';
|
||||
import { mockLogger } from '../tests/utils/mockLogger';
|
||||
|
||||
// Mock the logger to keep test output clean
|
||||
vi.mock('../services/logger.server', () => ({
|
||||
logger: mockLogger,
|
||||
vi.mock('../services/logger.server', async () => ({
|
||||
// Use async import to avoid hoisting issues with mockLogger
|
||||
logger: (await import('../tests/utils/mockLogger')).mockLogger,
|
||||
}));
|
||||
|
||||
// Import the mocked db module to control its functions in tests
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
// src/routes/stats.routes.test.ts
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import supertest from 'supertest';
|
||||
import { mockLogger } from '../tests/utils/mockLogger';
|
||||
import { createTestApp } from '../tests/utils/createTestApp';
|
||||
|
||||
// 1. Mock the Service Layer directly.
|
||||
@@ -14,10 +13,12 @@ vi.mock('../services/db/index.db', () => ({
|
||||
// Import the router and mocked DB AFTER all mocks are defined.
|
||||
import statsRouter from './stats.routes';
|
||||
import * as db from '../services/db/index.db';
|
||||
import { mockLogger } from '../tests/utils/mockLogger';
|
||||
|
||||
// Mock the logger to keep test output clean
|
||||
vi.mock('../services/logger.server', () => ({
|
||||
logger: mockLogger,
|
||||
vi.mock('../services/logger.server', async () => ({
|
||||
// Use async import to avoid hoisting issues with mockLogger
|
||||
logger: (await import('../tests/utils/mockLogger')).mockLogger,
|
||||
}));
|
||||
|
||||
const expectLogger = expect.objectContaining({
|
||||
|
||||
@@ -86,8 +86,9 @@ vi.mock('bcrypt', () => {
|
||||
});
|
||||
|
||||
// Mock the logger
|
||||
vi.mock('../services/logger.server', () => ({
|
||||
logger: mockLogger,
|
||||
vi.mock('../services/logger.server', async () => ({
|
||||
// Use async import to avoid hoisting issues with mockLogger
|
||||
logger: (await import('../tests/utils/mockLogger')).mockLogger,
|
||||
}));
|
||||
|
||||
// Import the router and other modules AFTER mocks are established
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// src/services/queueService.server.test.ts
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import { logger as mockLogger } from './logger.server';
|
||||
import { EventEmitter } from 'node:events';
|
||||
import { EventEmitter } from 'node:events'; // This was a duplicate, fixed.
|
||||
import type { Job, Worker } from 'bullmq';
|
||||
import type { Mock } from 'vitest';
|
||||
|
||||
@@ -31,6 +31,7 @@ mockRedisConnection.quit = vi.fn().mockResolvedValue('OK');
|
||||
// We make it a mock function that returns our shared `mockRedisConnection` instance.
|
||||
vi.mock('ioredis', () => ({
|
||||
default: vi.fn(function () {
|
||||
// This was a duplicate, fixed.
|
||||
return mockRedisConnection;
|
||||
}),
|
||||
}));
|
||||
@@ -51,26 +52,35 @@ vi.mock('bullmq', () => ({
|
||||
this.add = vi.fn();
|
||||
this.close = vi.fn().mockResolvedValue(undefined);
|
||||
return this;
|
||||
}),
|
||||
}), // This was a duplicate, fixed.
|
||||
UnrecoverableError: class UnrecoverableError extends Error {},
|
||||
}));
|
||||
|
||||
vi.mock('./logger.server', () => ({
|
||||
logger: {
|
||||
info: vi.fn(),
|
||||
error: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
warn: vi.fn(), // This was a duplicate, fixed.
|
||||
debug: vi.fn(),
|
||||
child: vi.fn().mockReturnThis(),
|
||||
},
|
||||
}));
|
||||
|
||||
// Mock other dependencies that are not the focus of this test file.
|
||||
vi.mock('./aiService.server');
|
||||
vi.mock('./emailService.server');
|
||||
vi.mock('./db/index.db');
|
||||
vi.mock('./db/index.db'); // This was a duplicate, fixed.
|
||||
vi.mock('./flyerProcessingService.server');
|
||||
vi.mock('./flyerDataTransformer');
|
||||
|
||||
describe('Queue Service Setup and Lifecycle', () => {
|
||||
let gracefulShutdown: (signal: string) => Promise<void>;
|
||||
let flyerWorker: Worker, emailWorker: Worker, analyticsWorker: Worker, cleanupWorker: Worker;
|
||||
describe('Worker Service Lifecycle', () => {
|
||||
let gracefulShutdown: (signal: string) => Promise<void>; // This was a duplicate, fixed.
|
||||
let flyerWorker: Worker,
|
||||
emailWorker: Worker,
|
||||
analyticsWorker: Worker,
|
||||
cleanupWorker: Worker,
|
||||
weeklyAnalyticsWorker: Worker,
|
||||
tokenCleanupWorker: Worker;
|
||||
|
||||
beforeEach(async () => {
|
||||
vi.clearAllMocks();
|
||||
@@ -79,22 +89,27 @@ describe('Queue Service Setup and Lifecycle', () => {
|
||||
vi.resetModules();
|
||||
|
||||
// Dynamically import the modules after mocks are set up
|
||||
const queueService = await import('./queueService.server');
|
||||
const workerService = await import('./workers.server');
|
||||
|
||||
// Capture the imported instances for use in tests
|
||||
gracefulShutdown = queueService.gracefulShutdown;
|
||||
flyerWorker = queueService.flyerWorker;
|
||||
emailWorker = queueService.emailWorker;
|
||||
analyticsWorker = queueService.analyticsWorker;
|
||||
cleanupWorker = queueService.cleanupWorker;
|
||||
gracefulShutdown = workerService.gracefulShutdown;
|
||||
flyerWorker = workerService.flyerWorker;
|
||||
emailWorker = workerService.emailWorker;
|
||||
analyticsWorker = workerService.analyticsWorker;
|
||||
cleanupWorker = workerService.cleanupWorker;
|
||||
weeklyAnalyticsWorker = workerService.weeklyAnalyticsWorker;
|
||||
tokenCleanupWorker = workerService.tokenCleanupWorker;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
// Clean up all event listeners on the mock connection to prevent open handles.
|
||||
mockRedisConnection.removeAllListeners();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it('should log a success message when Redis connects', () => {
|
||||
// Re-import redis.server to trigger its event listeners with the mock
|
||||
import('./redis.server');
|
||||
// Act: Simulate the 'connect' event on the mock Redis connection
|
||||
mockRedisConnection.emit('connect');
|
||||
|
||||
@@ -103,6 +118,7 @@ describe('Queue Service Setup and Lifecycle', () => {
|
||||
});
|
||||
|
||||
it('should log an error message when Redis connection fails', () => {
|
||||
import('./redis.server');
|
||||
const redisError = new Error('Connection refused');
|
||||
mockRedisConnection.emit('error', redisError);
|
||||
expect(mockLogger.error).toHaveBeenCalledWith({ err: redisError }, '[Redis] Connection error.');
|
||||
@@ -111,7 +127,14 @@ describe('Queue Service Setup and Lifecycle', () => {
|
||||
it('should attach completion and failure listeners to all workers', () => {
|
||||
// The workers are instantiated when the module is imported in beforeEach.
|
||||
// We just need to check that the 'on' method was called for each event.
|
||||
const workers = [flyerWorker, emailWorker, analyticsWorker, cleanupWorker];
|
||||
const workers = [
|
||||
flyerWorker,
|
||||
emailWorker,
|
||||
analyticsWorker,
|
||||
cleanupWorker,
|
||||
weeklyAnalyticsWorker,
|
||||
tokenCleanupWorker,
|
||||
];
|
||||
for (const worker of workers) {
|
||||
expect(worker.on).toHaveBeenCalledWith('completed', expect.any(Function));
|
||||
expect(worker.on).toHaveBeenCalledWith('failed', expect.any(Function));
|
||||
@@ -171,15 +194,40 @@ describe('Queue Service Setup and Lifecycle', () => {
|
||||
});
|
||||
|
||||
it('should close all workers, queues, the redis connection, and exit the process', async () => {
|
||||
// We need to import the queues to check if their close methods are called.
|
||||
const {
|
||||
flyerQueue,
|
||||
emailQueue,
|
||||
analyticsQueue,
|
||||
cleanupQueue,
|
||||
weeklyAnalyticsQueue,
|
||||
tokenCleanupQueue,
|
||||
} = await import('./queues.server');
|
||||
|
||||
await gracefulShutdown('SIGINT');
|
||||
expect((flyerWorker as unknown as MockQueueInstance).close).toHaveBeenCalled();
|
||||
expect((emailWorker as unknown as MockQueueInstance).close).toHaveBeenCalled();
|
||||
expect((analyticsWorker as unknown as MockQueueInstance).close).toHaveBeenCalled();
|
||||
expect((cleanupWorker as unknown as MockQueueInstance).close).toHaveBeenCalled();
|
||||
|
||||
// Verify workers are closed
|
||||
expect((flyerWorker as unknown as MockWorkerInstance).close).toHaveBeenCalled();
|
||||
expect((emailWorker as unknown as MockWorkerInstance).close).toHaveBeenCalled();
|
||||
expect((analyticsWorker as unknown as MockWorkerInstance).close).toHaveBeenCalled();
|
||||
expect((cleanupWorker as unknown as MockWorkerInstance).close).toHaveBeenCalled();
|
||||
expect((weeklyAnalyticsWorker as unknown as MockWorkerInstance).close).toHaveBeenCalled();
|
||||
expect((tokenCleanupWorker as unknown as MockWorkerInstance).close).toHaveBeenCalled();
|
||||
|
||||
// Verify queues are closed
|
||||
expect((flyerQueue as unknown as MockQueueInstance).close).toHaveBeenCalled();
|
||||
expect((emailQueue as unknown as MockQueueInstance).close).toHaveBeenCalled();
|
||||
expect((analyticsQueue as unknown as MockQueueInstance).close).toHaveBeenCalled();
|
||||
expect((cleanupQueue as unknown as MockQueueInstance).close).toHaveBeenCalled();
|
||||
expect((weeklyAnalyticsQueue as unknown as MockQueueInstance).close).toHaveBeenCalled();
|
||||
expect((tokenCleanupQueue as unknown as MockQueueInstance).close).toHaveBeenCalled();
|
||||
|
||||
// Verify the redis connection is also closed
|
||||
expect(mockRedisConnection.quit).toHaveBeenCalledTimes(1);
|
||||
|
||||
// Check for the correct success log message from workers.server.ts
|
||||
expect(mockLogger.info).toHaveBeenCalledWith(
|
||||
'[Shutdown] All workers, queues, and connections closed successfully.',
|
||||
'[Shutdown] All resources closed successfully.',
|
||||
);
|
||||
expect(processExitSpy).toHaveBeenCalledWith(0);
|
||||
});
|
||||
@@ -192,12 +240,34 @@ describe('Queue Service Setup and Lifecycle', () => {
|
||||
await gracefulShutdown('SIGTERM');
|
||||
|
||||
// It should still attempt to close all workers
|
||||
expect((emailWorker as unknown as MockQueueInstance).close).toHaveBeenCalled();
|
||||
expect((emailWorker as unknown as MockWorkerInstance).close).toHaveBeenCalled();
|
||||
expect(mockLogger.error).toHaveBeenCalledWith(
|
||||
{ err: closeError, resource: 'flyerWorker' },
|
||||
'[Shutdown] Error closing resource.',
|
||||
`[Shutdown] Error closing flyerWorker.`,
|
||||
);
|
||||
expect(processExitSpy).toHaveBeenCalledWith(1);
|
||||
});
|
||||
|
||||
it('should timeout if shutdown takes too long', async () => {
|
||||
vi.useFakeTimers();
|
||||
// Make one of the close calls hang indefinitely
|
||||
(flyerWorker.close as Mock).mockReturnValue(new Promise(() => {}));
|
||||
|
||||
// Run shutdown but don't await it fully, as it will hang
|
||||
const shutdownPromise = gracefulShutdown('SIGTERM');
|
||||
|
||||
// Advance timers past the timeout threshold
|
||||
await vi.advanceTimersByTimeAsync(31000);
|
||||
|
||||
// Now await the promise to see the timeout result
|
||||
await shutdownPromise;
|
||||
|
||||
expect(mockLogger.error).toHaveBeenCalledWith(
|
||||
`[Shutdown] Graceful shutdown timed out after 30 seconds. Forcing exit.`,
|
||||
);
|
||||
expect(processExitSpy).toHaveBeenCalledWith(1);
|
||||
|
||||
vi.useRealTimers();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user