Compare commits

...

5 Commits

Author SHA1 Message Date
Gitea Actions
d38b751b40 ci: Bump version to 0.9.74 [skip ci] 2026-01-10 03:14:12 +05:00
e122d55ced adr-028 fixes for tests
Some checks failed
Deploy to Test Environment / deploy-to-test (push) Failing after 1m1s
2026-01-09 14:12:48 -08:00
Gitea Actions
af9992f773 ci: Bump version to 0.9.73 [skip ci] 2026-01-10 01:54:56 +05:00
3912139273 adr-028 and int tests
All checks were successful
Deploy to Test Environment / deploy-to-test (push) Successful in 16m24s
2026-01-09 12:47:41 -08:00
b5f7f5e4d1 adr-0028 and int test fixes 2026-01-09 12:35:55 -08:00
47 changed files with 1717 additions and 864 deletions

View File

@@ -2,7 +2,7 @@
**Date**: 2026-01-09
**Status**: Proposed
**Status**: Implemented
## Context
@@ -99,16 +99,44 @@ interface ApiErrorResponse {
### What's Implemented
- ❌ Not yet implemented
- ✅ Created `src/utils/apiResponse.ts` with helper functions (`sendSuccess`, `sendPaginated`, `sendError`, `sendNoContent`, `sendMessage`, `calculatePagination`)
- ✅ Created `src/types/api.ts` with response type definitions (`ApiSuccessResponse`, `ApiErrorResponse`, `PaginationMeta`, `ErrorCode`)
- ✅ Updated `src/middleware/errorHandler.ts` to use standard error format
- ✅ Migrated all route files to use standardized responses:
- `health.routes.ts`
- `flyer.routes.ts`
- `deals.routes.ts`
- `budget.routes.ts`
- `personalization.routes.ts`
- `price.routes.ts`
- `reactions.routes.ts`
- `stats.routes.ts`
- `system.routes.ts`
- `gamification.routes.ts`
- `recipe.routes.ts`
- `auth.routes.ts`
- `user.routes.ts`
- `admin.routes.ts`
- `ai.routes.ts`
### What Needs To Be Done
### Error Codes
1. Create `src/utils/apiResponse.ts` with helper functions
2. Create `src/types/api.ts` with response type definitions
3. Update `errorHandler.ts` to use standard error format
4. Create migration guide for existing endpoints
5. Update 2-3 routes as examples
6. Document pattern in this ADR
The following error codes are defined in `src/types/api.ts`:
| Code | HTTP Status | Description |
| ------------------------ | ----------- | ----------------------------------- |
| `VALIDATION_ERROR` | 400 | Request validation failed |
| `BAD_REQUEST` | 400 | Malformed request |
| `UNAUTHORIZED` | 401 | Authentication required |
| `FORBIDDEN` | 403 | Insufficient permissions |
| `NOT_FOUND` | 404 | Resource not found |
| `CONFLICT` | 409 | Resource conflict (e.g., duplicate) |
| `RATE_LIMITED` | 429 | Too many requests |
| `PAYLOAD_TOO_LARGE` | 413 | Request body too large |
| `INTERNAL_ERROR` | 500 | Server error |
| `NOT_IMPLEMENTED` | 501 | Feature not yet implemented |
| `SERVICE_UNAVAILABLE` | 503 | Service temporarily unavailable |
| `EXTERNAL_SERVICE_ERROR` | 502 | External service failure |
## Example Usage

View File

@@ -0,0 +1,145 @@
# ADR Implementation Tracker
This document tracks the implementation status and estimated effort for all Architectural Decision Records (ADRs).
## Effort Estimation Guide
| Rating | Description | Typical Duration |
| ------ | ------------------------------------------- | ----------------- |
| S | Small - Simple, isolated changes | 1-2 hours |
| M | Medium - Multiple files, some testing | Half day to 1 day |
| L | Large - Significant refactoring, many files | 1-3 days |
| XL | Extra Large - Major architectural change | 1+ weeks |
## Implementation Status Overview
| Status | Count |
| ---------------------------- | ----- |
| Accepted (Fully Implemented) | 21 |
| Partially Implemented | 2 |
| Proposed (Not Started) | 16 |
---
## Detailed Implementation Status
### Category 1: Foundational / Core Infrastructure
| ADR | Title | Status | Effort | Notes |
| ---------------------------------------------------------------- | ----------------------- | -------- | ------ | ------------------------------ |
| [ADR-002](./0002-standardized-transaction-management.md) | Transaction Management | Accepted | - | Fully implemented |
| [ADR-007](./0007-configuration-and-secrets-management.md) | Configuration & Secrets | Accepted | - | Fully implemented |
| [ADR-020](./0020-health-checks-and-liveness-readiness-probes.md) | Health Checks | Accepted | - | Fully implemented |
| [ADR-030](./0030-graceful-degradation-and-circuit-breaker.md) | Circuit Breaker | Proposed | L | New resilience patterns needed |
### Category 2: Data Management
| ADR | Title | Status | Effort | Notes |
| --------------------------------------------------------------- | ------------------------ | -------- | ------ | ------------------------------ |
| [ADR-009](./0009-caching-strategy-for-read-heavy-operations.md) | Caching Strategy | Accepted | - | Fully implemented |
| [ADR-013](./0013-database-schema-migration-strategy.md) | Schema Migrations v1 | Proposed | M | Superseded by ADR-023 |
| [ADR-019](./0019-data-backup-and-recovery-strategy.md) | Backup & Recovery | Accepted | - | Fully implemented |
| [ADR-023](./0023-database-schema-migration-strategy.md) | Schema Migrations v2 | Proposed | L | Requires tooling setup |
| [ADR-031](./0031-data-retention-and-privacy-compliance.md) | Data Retention & Privacy | Proposed | XL | Legal/compliance review needed |
### Category 3: API & Integration
| ADR | Title | Status | Effort | Notes |
| ------------------------------------------------------------------- | ------------------------ | ----------- | ------ | ------------------------------------- |
| [ADR-003](./0003-standardized-input-validation-using-middleware.md) | Input Validation | Accepted | - | Fully implemented |
| [ADR-008](./0008-api-versioning-strategy.md) | API Versioning | Proposed | L | Major URL/routing changes |
| [ADR-018](./0018-api-documentation-strategy.md) | API Documentation | Proposed | M | OpenAPI/Swagger setup |
| [ADR-022](./0022-real-time-notification-system.md) | Real-time Notifications | Proposed | XL | WebSocket infrastructure |
| [ADR-028](./0028-api-response-standardization.md) | Response Standardization | Implemented | L | Completed (routes, middleware, tests) |
### Category 4: Security & Compliance
| ADR | Title | Status | Effort | Notes |
| ----------------------------------------------------------------------- | --------------------- | -------- | ------ | -------------------------------- |
| [ADR-001](./0001-standardized-error-handling.md) | Error Handling | Accepted | - | Fully implemented |
| [ADR-011](./0011-advanced-authorization-and-access-control-strategy.md) | Authorization & RBAC | Proposed | XL | Policy engine, permission system |
| [ADR-016](./0016-api-security-hardening.md) | Security Hardening | Accepted | - | Fully implemented |
| [ADR-029](./0029-secret-rotation-and-key-management.md) | Secret Rotation | Proposed | L | Infrastructure changes needed |
| [ADR-032](./0032-rate-limiting-strategy.md) | Rate Limiting | Accepted | - | Fully implemented |
| [ADR-033](./0033-file-upload-and-storage-strategy.md) | File Upload & Storage | Accepted | - | Fully implemented |
### Category 5: Observability & Monitoring
| ADR | Title | Status | Effort | Notes |
| -------------------------------------------------------------------------- | -------------------- | -------- | ------ | ----------------------- |
| [ADR-004](./0004-standardized-application-wide-structured-logging.md) | Structured Logging | Accepted | - | Fully implemented |
| [ADR-015](./0015-application-performance-monitoring-and-error-tracking.md) | APM & Error Tracking | Proposed | M | Third-party integration |
### Category 6: Deployment & Operations
| ADR | Title | Status | Effort | Notes |
| -------------------------------------------------------------- | ----------------- | -------- | ------ | -------------------------- |
| [ADR-006](./0006-background-job-processing-and-task-queues.md) | Background Jobs | Accepted | - | Fully implemented |
| [ADR-014](./0014-containerization-and-deployment-strategy.md) | Containerization | Partial | M | Docker done, K8s pending |
| [ADR-017](./0017-ci-cd-and-branching-strategy.md) | CI/CD & Branching | Accepted | - | Fully implemented |
| [ADR-024](./0024-feature-flagging-strategy.md) | Feature Flags | Proposed | M | New service/library needed |
| [ADR-037](./0037-scheduled-jobs-and-cron-pattern.md) | Scheduled Jobs | Accepted | - | Fully implemented |
| [ADR-038](./0038-graceful-shutdown-pattern.md) | Graceful Shutdown | Accepted | - | Fully implemented |
### Category 7: Frontend / User Interface
| ADR | Title | Status | Effort | Notes |
| ------------------------------------------------------------------------ | ------------------- | -------- | ------ | ------------------------------------------- |
| [ADR-005](./0005-frontend-state-management-and-server-cache-strategy.md) | State Management | Accepted | - | Fully implemented |
| [ADR-012](./0012-frontend-component-library-and-design-system.md) | Component Library | Partial | L | Core components done, design tokens pending |
| [ADR-025](./0025-internationalization-and-localization-strategy.md) | i18n & l10n | Proposed | XL | All UI strings need extraction |
| [ADR-026](./0026-standardized-client-side-structured-logging.md) | Client-Side Logging | Proposed | M | Browser logging infrastructure |
### Category 8: Development Workflow & Quality
| ADR | Title | Status | Effort | Notes |
| ----------------------------------------------------------------------------- | -------------------- | -------- | ------ | ----------------- |
| [ADR-010](./0010-testing-strategy-and-standards.md) | Testing Strategy | Accepted | - | Fully implemented |
| [ADR-021](./0021-code-formatting-and-linting-unification.md) | Formatting & Linting | Accepted | - | Fully implemented |
| [ADR-027](./0027-standardized-naming-convention-for-ai-and-database-types.md) | Naming Conventions | Accepted | - | Fully implemented |
### Category 9: Architecture Patterns
| ADR | Title | Status | Effort | Notes |
| -------------------------------------------------- | -------------------- | -------- | ------ | ----------------- |
| [ADR-034](./0034-repository-pattern-standards.md) | Repository Pattern | Accepted | - | Fully implemented |
| [ADR-035](./0035-service-layer-architecture.md) | Service Layer | Accepted | - | Fully implemented |
| [ADR-036](./0036-event-bus-and-pub-sub-pattern.md) | Event Bus | Accepted | - | Fully implemented |
| [ADR-039](./0039-dependency-injection-pattern.md) | Dependency Injection | Accepted | - | Fully implemented |
---
## Work Still To Be Completed (Priority Order)
These ADRs are proposed but not yet implemented, ordered by suggested implementation priority:
| Priority | ADR | Title | Effort | Rationale |
| -------- | ------- | ------------------------ | ------ | ----------------------------------------------------- |
| 1 | ADR-018 | API Documentation | M | Improves developer experience, enables SDK generation |
| 2 | ADR-015 | APM & Error Tracking | M | Production visibility, debugging |
| 3 | ADR-024 | Feature Flags | M | Safer deployments, A/B testing |
| 4 | ADR-026 | Client-Side Logging | M | Frontend debugging parity |
| 5 | ADR-023 | Schema Migrations v2 | L | Database evolution support |
| 6 | ADR-029 | Secret Rotation | L | Security improvement |
| 7 | ADR-008 | API Versioning | L | Future API evolution |
| 8 | ADR-030 | Circuit Breaker | L | Resilience improvement |
| 9 | ADR-022 | Real-time Notifications | XL | Major feature enhancement |
| 10 | ADR-011 | Authorization & RBAC | XL | Advanced permission system |
| 11 | ADR-025 | i18n & l10n | XL | Multi-language support |
| 12 | ADR-031 | Data Retention & Privacy | XL | Compliance requirements |
---
## Recent Implementation History
| Date | ADR | Change |
| ---------- | ------- | ------------------------------------------------------------- |
| 2026-01-09 | ADR-028 | Fully implemented - all routes, middleware, and tests updated |
---
## Notes
- **Effort estimates** are rough guidelines and may vary based on current codebase state
- **Dependencies** between ADRs should be considered when planning implementation order
- This document should be updated when ADRs are implemented or status changes

View File

@@ -23,7 +23,7 @@ This directory contains a log of the architectural decisions made for the Flyer
**[ADR-008](./0008-api-versioning-strategy.md)**: API Versioning Strategy (Proposed)
**[ADR-018](./0018-api-documentation-strategy.md)**: API Documentation Strategy (Proposed)
**[ADR-022](./0022-real-time-notification-system.md)**: Real-time Notification System (Proposed)
**[ADR-028](./0028-api-response-standardization.md)**: API Response Standardization and Envelope Pattern (Proposed)
**[ADR-028](./0028-api-response-standardization.md)**: API Response Standardization and Envelope Pattern (Implemented)
## 4. Security & Compliance

4
package-lock.json generated
View File

@@ -1,12 +1,12 @@
{
"name": "flyer-crawler",
"version": "0.9.72",
"version": "0.9.74",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "flyer-crawler",
"version": "0.9.72",
"version": "0.9.74",
"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.9.72",
"version": "0.9.74",
"type": "module",
"scripts": {
"dev": "concurrently \"npm:start:dev\" \"vite\"",

View File

@@ -114,10 +114,13 @@ describe('errorHandler Middleware', () => {
const response = await supertest(app).get('/generic-error');
expect(response.status).toBe(500);
// 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));
console.log('[DEBUG] errorHandler.test.ts: Received 500 error response with ID:', response.body.errorId);
expect(response.body.error.message).toBe('A generic server error occurred.');
expect(response.body.error.details.stack).toBeDefined();
expect(response.body.meta.requestId).toEqual(expect.any(String));
console.log(
'[DEBUG] errorHandler.test.ts: Received 500 error response with ID:',
response.body.meta.requestId,
);
expect(mockLogger.error).toHaveBeenCalledWith(
expect.objectContaining({
err: expect.any(Error),
@@ -136,7 +139,10 @@ describe('errorHandler Middleware', () => {
const response = await supertest(app).get('/http-error-404');
expect(response.status).toBe(404);
expect(response.body).toEqual({ message: 'Resource not found' });
expect(response.body).toEqual({
success: false,
error: { code: 'NOT_FOUND', message: 'Resource not found' },
});
expect(mockLogger.error).not.toHaveBeenCalled(); // 4xx errors are not logged as server errors
expect(mockLogger.warn).toHaveBeenCalledWith(
{
@@ -152,7 +158,10 @@ describe('errorHandler Middleware', () => {
const response = await supertest(app).get('/not-found-error');
expect(response.status).toBe(404);
expect(response.body).toEqual({ message: 'Specific resource missing' });
expect(response.body).toEqual({
success: false,
error: { code: 'NOT_FOUND', message: 'Specific resource missing' },
});
expect(mockLogger.error).not.toHaveBeenCalled();
expect(mockLogger.warn).toHaveBeenCalledWith(
{
@@ -168,7 +177,10 @@ describe('errorHandler Middleware', () => {
const response = await supertest(app).get('/fk-error');
expect(response.status).toBe(400);
expect(response.body).toEqual({ message: 'The referenced item does not exist.' });
expect(response.body).toEqual({
success: false,
error: { code: 'BAD_REQUEST', message: 'The referenced item does not exist.' },
});
expect(mockLogger.error).not.toHaveBeenCalled();
expect(mockLogger.warn).toHaveBeenCalledWith(
{
@@ -184,7 +196,10 @@ describe('errorHandler Middleware', () => {
const response = await supertest(app).get('/unique-error');
expect(response.status).toBe(409); // 409 Conflict
expect(response.body).toEqual({ message: 'This item already exists.' });
expect(response.body).toEqual({
success: false,
error: { code: 'CONFLICT', message: 'This item already exists.' },
});
expect(mockLogger.error).not.toHaveBeenCalled();
expect(mockLogger.warn).toHaveBeenCalledWith(
{
@@ -200,9 +215,9 @@ describe('errorHandler Middleware', () => {
const response = await supertest(app).get('/validation-error');
expect(response.status).toBe(400);
expect(response.body.message).toBe('Input validation failed');
expect(response.body.errors).toBeDefined();
expect(response.body.errors).toEqual([
expect(response.body.error.message).toBe('Input validation failed');
expect(response.body.error.details).toBeDefined();
expect(response.body.error.details).toEqual([
{ path: ['body', 'email'], message: 'Invalid email format' },
]);
expect(mockLogger.error).not.toHaveBeenCalled(); // 4xx errors are not logged as server errors
@@ -222,9 +237,9 @@ describe('errorHandler Middleware', () => {
expect(response.status).toBe(500);
// 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(response.body.error.message).toBe('A database connection issue occurred.');
expect(response.body.error.details.stack).toBeDefined();
expect(response.body.meta.requestId).toEqual(expect.any(String));
expect(mockLogger.error).toHaveBeenCalledWith(
expect.objectContaining({
err: expect.any(DatabaseError),
@@ -243,7 +258,10 @@ describe('errorHandler Middleware', () => {
const response = await supertest(app).get('/unauthorized-error-no-status');
expect(response.status).toBe(401);
expect(response.body).toEqual({ message: 'Invalid Token' });
expect(response.body).toEqual({
success: false,
error: { code: 'UNAUTHORIZED', message: 'Invalid Token' },
});
expect(mockLogger.warn).toHaveBeenCalledWith(
{
err: expect.any(Error),
@@ -258,7 +276,10 @@ describe('errorHandler Middleware', () => {
const response = await supertest(app).get('/unauthorized-error-with-status');
expect(response.status).toBe(401);
expect(response.body).toEqual({ message: 'Invalid Token' });
expect(response.body).toEqual({
success: false,
error: { code: 'UNAUTHORIZED', message: 'Invalid Token' },
});
expect(mockLogger.warn).toHaveBeenCalledWith(
{
err: expect.any(Error),
@@ -304,17 +325,17 @@ describe('errorHandler Middleware', () => {
const response = await supertest(app).get('/generic-error');
expect(response.status).toBe(500);
expect(response.body.message).toMatch(
expect(response.body.error.message).toMatch(
/An unexpected server error occurred. Please reference error ID: \w+/,
);
expect(response.body.stack).toBeUndefined();
expect(response.body.error.details?.stack).toBeUndefined();
});
it('should return the actual error message for client errors (4xx) in production', async () => {
const response = await supertest(app).get('/http-error-404');
expect(response.status).toBe(404);
expect(response.body.message).toBe('Resource not found');
expect(response.body.error.message).toBe('Resource not found');
});
});
});

View File

@@ -1,4 +1,11 @@
// src/middleware/errorHandler.ts
// ============================================================================
// CENTRALIZED ERROR HANDLING MIDDLEWARE
// ============================================================================
// This middleware standardizes all error responses per ADR-028.
// It should be the LAST `app.use()` call to catch all errors.
// ============================================================================
import { Request, Response, NextFunction } from 'express';
import crypto from 'crypto';
import { ZodError } from 'zod';
@@ -9,12 +16,43 @@ import {
ValidationError,
} from '../services/db/errors.db';
import { logger } from '../services/logger.server';
import { ErrorCode, ApiErrorResponse } from '../types/api';
/**
* Helper to send standardized error responses.
*/
function sendErrorResponse(
res: Response,
statusCode: number,
code: string,
message: string,
details?: unknown,
meta?: { requestId?: string; timestamp?: string },
): Response<ApiErrorResponse> {
const response: ApiErrorResponse = {
success: false,
error: {
code,
message,
},
};
if (details !== undefined) {
response.error.details = details;
}
if (meta) {
response.meta = meta;
}
return res.status(statusCode).json(response);
}
/**
* A centralized error handling middleware for the Express application.
* This middleware should be the LAST `app.use()` call to catch all errors from previous routes and middleware.
*
* It standardizes error responses and ensures consistent logging.
* It standardizes error responses per ADR-028 and ensures consistent logging per ADR-004.
*/
export const errorHandler = (err: Error, req: Request, res: Response, next: NextFunction) => {
// If headers have already been sent, delegate to the default Express error handler.
@@ -29,16 +67,19 @@ export const errorHandler = (err: Error, req: Request, res: Response, next: Next
if (err instanceof ZodError) {
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 });
const details = err.issues.map((e) => ({ path: e.path, message: e.message }));
log.warn(
{ err, validationErrors: details, statusCode },
`Client Error on ${req.method} ${req.path}: ${message}`,
);
return sendErrorResponse(res, statusCode, ErrorCode.VALIDATION_ERROR, message, details);
}
// --- Handle Custom Operational Errors ---
if (err instanceof NotFoundError) {
const statusCode = 404;
log.warn({ err, statusCode }, `Client Error on ${req.method} ${req.path}: ${err.message}`);
return res.status(statusCode).json({ message: err.message });
return sendErrorResponse(res, statusCode, ErrorCode.NOT_FOUND, err.message);
}
if (err instanceof ValidationError) {
@@ -47,30 +88,66 @@ export const errorHandler = (err: Error, req: Request, res: Response, next: Next
{ 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 });
return sendErrorResponse(
res,
statusCode,
ErrorCode.VALIDATION_ERROR,
err.message,
err.validationErrors,
);
}
if (err instanceof UniqueConstraintError) {
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
return sendErrorResponse(res, statusCode, ErrorCode.CONFLICT, err.message);
}
if (err instanceof ForeignKeyConstraintError) {
const statusCode = 400;
log.warn({ err, statusCode }, `Client Error on ${req.method} ${req.path}: ${err.message}`);
return res.status(statusCode).json({ message: err.message });
return sendErrorResponse(res, statusCode, ErrorCode.BAD_REQUEST, err.message);
}
// --- Handle Generic Client Errors (e.g., from express-jwt, or manual status setting) ---
let status = (err as any).status || (err as any).statusCode;
const errWithStatus = err as Error & { status?: number; statusCode?: number };
let status = errWithStatus.status || errWithStatus.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 });
log.warn(
{ err, statusCode: status },
`Client Error on ${req.method} ${req.path}: ${err.message}`,
);
// Map status codes to error codes
let errorCode: string;
switch (status) {
case 400:
errorCode = ErrorCode.BAD_REQUEST;
break;
case 401:
errorCode = ErrorCode.UNAUTHORIZED;
break;
case 403:
errorCode = ErrorCode.FORBIDDEN;
break;
case 404:
errorCode = ErrorCode.NOT_FOUND;
break;
case 409:
errorCode = ErrorCode.CONFLICT;
break;
case 429:
errorCode = ErrorCode.RATE_LIMITED;
break;
default:
errorCode = ErrorCode.BAD_REQUEST;
}
return sendErrorResponse(res, status, errorCode, err.message);
}
// --- Handle All Other (500-level) Errors ---
@@ -91,11 +168,23 @@ export const errorHandler = (err: Error, req: Request, res: Response, next: Next
// In production, send a generic message to avoid leaking implementation details.
if (process.env.NODE_ENV === 'production') {
return res.status(500).json({
message: `An unexpected server error occurred. Please reference error ID: ${errorId}`,
});
return sendErrorResponse(
res,
500,
ErrorCode.INTERNAL_ERROR,
`An unexpected server error occurred. Please reference error ID: ${errorId}`,
undefined,
{ requestId: errorId },
);
}
// 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 });
};
return sendErrorResponse(
res,
500,
ErrorCode.INTERNAL_ERROR,
err.message,
{ stack: err.stack },
{ requestId: errorId },
);
};

View File

@@ -170,7 +170,7 @@ describe('Admin Content Management Routes (/api/admin)', () => {
vi.mocked(mockedDb.adminRepo.getSuggestedCorrections).mockResolvedValue(mockCorrections);
const response = await supertest(app).get('/api/admin/corrections');
expect(response.status).toBe(200);
expect(response.body).toEqual(mockCorrections);
expect(response.body.data).toEqual(mockCorrections);
});
it('should return 500 if the database call fails', async () => {
@@ -179,7 +179,7 @@ describe('Admin Content Management Routes (/api/admin)', () => {
);
const response = await supertest(app).get('/api/admin/corrections');
expect(response.status).toBe(500);
expect(response.body.message).toBe('DB Error');
expect(response.body.error.message).toBe('DB Error');
});
it('POST /corrections/:id/approve should approve a correction', async () => {
@@ -187,7 +187,7 @@ describe('Admin Content Management Routes (/api/admin)', () => {
vi.mocked(mockedDb.adminRepo.approveCorrection).mockResolvedValue(undefined);
const response = await supertest(app).post(`/api/admin/corrections/${correctionId}/approve`);
expect(response.status).toBe(200);
expect(response.body).toEqual({ message: 'Correction approved successfully.' });
expect(response.body.data).toEqual({ message: 'Correction approved successfully.' });
expect(vi.mocked(mockedDb.adminRepo.approveCorrection)).toHaveBeenCalledWith(
correctionId,
expect.anything(),
@@ -206,7 +206,7 @@ describe('Admin Content Management Routes (/api/admin)', () => {
vi.mocked(mockedDb.adminRepo.rejectCorrection).mockResolvedValue(undefined);
const response = await supertest(app).post(`/api/admin/corrections/${correctionId}/reject`);
expect(response.status).toBe(200);
expect(response.body).toEqual({ message: 'Correction rejected successfully.' });
expect(response.body.data).toEqual({ message: 'Correction rejected successfully.' });
});
it('POST /corrections/:id/reject should return 500 on DB error', async () => {
@@ -230,7 +230,7 @@ describe('Admin Content Management Routes (/api/admin)', () => {
.put(`/api/admin/corrections/${correctionId}`)
.send(requestBody);
expect(response.status).toBe(200);
expect(response.body).toEqual(mockUpdatedCorrection);
expect(response.body.data).toEqual(mockUpdatedCorrection);
});
it('PUT /corrections/:id should return 400 for invalid data', async () => {
@@ -248,7 +248,7 @@ describe('Admin Content Management Routes (/api/admin)', () => {
.put('/api/admin/corrections/999')
.send({ suggested_value: 'new value' });
expect(response.status).toBe(404);
expect(response.body.message).toBe('Correction with ID 999 not found');
expect(response.body.error.message).toBe('Correction with ID 999 not found');
});
it('PUT /corrections/:id should return 500 on a generic DB error', async () => {
@@ -259,7 +259,7 @@ describe('Admin Content Management Routes (/api/admin)', () => {
.put('/api/admin/corrections/101')
.send({ suggested_value: 'new value' });
expect(response.status).toBe(500);
expect(response.body.message).toBe('Generic DB Error');
expect(response.body.error.message).toBe('Generic DB Error');
});
});
@@ -272,7 +272,7 @@ describe('Admin Content Management Routes (/api/admin)', () => {
vi.mocked(mockedDb.adminRepo.getFlyersForReview).mockResolvedValue(mockFlyers);
const response = await supertest(app).get('/api/admin/review/flyers');
expect(response.status).toBe(200);
expect(response.body).toEqual(mockFlyers);
expect(response.body.data).toEqual(mockFlyers);
expect(vi.mocked(mockedDb.adminRepo.getFlyersForReview)).toHaveBeenCalledWith(
expect.anything(),
);
@@ -282,7 +282,7 @@ describe('Admin Content Management Routes (/api/admin)', () => {
vi.mocked(mockedDb.adminRepo.getFlyersForReview).mockRejectedValue(new Error('DB Error'));
const response = await supertest(app).get('/api/admin/review/flyers');
expect(response.status).toBe(500);
expect(response.body.message).toBe('DB Error');
expect(response.body.error.message).toBe('DB Error');
});
});
@@ -292,7 +292,7 @@ describe('Admin Content Management Routes (/api/admin)', () => {
vi.mocked(mockedDb.adminRepo.getApplicationStats).mockRejectedValue(new Error('DB Error'));
const response = await supertest(app).get('/api/admin/stats');
expect(response.status).toBe(500);
expect(response.body.message).toBe('DB Error');
expect(response.body.error.message).toBe('DB Error');
});
});
@@ -302,14 +302,14 @@ describe('Admin Content Management Routes (/api/admin)', () => {
vi.mocked(mockedDb.flyerRepo.getAllBrands).mockResolvedValue(mockBrands);
const response = await supertest(app).get('/api/admin/brands');
expect(response.status).toBe(200);
expect(response.body).toEqual(mockBrands);
expect(response.body.data).toEqual(mockBrands);
});
it('GET /brands should return 500 on DB error', async () => {
vi.mocked(mockedDb.flyerRepo.getAllBrands).mockRejectedValue(new Error('DB Error'));
const response = await supertest(app).get('/api/admin/brands');
expect(response.status).toBe(500);
expect(response.body.message).toBe('DB Error');
expect(response.body.error.message).toBe('DB Error');
});
it('POST /brands/:id/logo should upload a logo and update the brand', async () => {
@@ -319,7 +319,7 @@ describe('Admin Content Management Routes (/api/admin)', () => {
.post(`/api/admin/brands/${brandId}/logo`)
.attach('logoImage', Buffer.from('dummy-logo-content'), 'test-logo.png');
expect(response.status).toBe(200);
expect(response.body.message).toBe('Brand logo updated successfully.');
expect(response.body.data.message).toBe('Brand logo updated successfully.');
expect(vi.mocked(mockedDb.adminRepo.updateBrandLogo)).toHaveBeenCalledWith(
brandId,
expect.stringContaining('/flyer-images/'),
@@ -339,7 +339,7 @@ describe('Admin Content Management Routes (/api/admin)', () => {
it('POST /brands/:id/logo should return 400 if no file is uploaded', async () => {
const response = await supertest(app).post('/api/admin/brands/55/logo');
expect(response.status).toBe(400);
expect(response.body.message).toMatch(
expect(response.body.error.message).toMatch(
/Logo image file is required|The request data is invalid|Logo image file is missing./,
);
});
@@ -367,7 +367,7 @@ describe('Admin Content Management Routes (/api/admin)', () => {
.attach('logoImage', Buffer.from('this is not an image'), 'document.txt');
expect(response.status).toBe(400);
// This message comes from the handleMulterError middleware for the imageFileFilter
expect(response.body.message).toBe('Only image files are allowed!');
expect(response.body.error.message).toBe('Only image files are allowed!');
});
it('POST /brands/:id/logo should return 400 for an invalid brand ID', async () => {
@@ -414,7 +414,7 @@ describe('Admin Content Management Routes (/api/admin)', () => {
.put(`/api/admin/recipes/${recipeId}/status`)
.send(requestBody);
expect(response.status).toBe(200);
expect(response.body).toEqual(mockUpdatedRecipe);
expect(response.body.data).toEqual(mockUpdatedRecipe);
});
it('PUT /recipes/:id/status should return 400 for an invalid status value', async () => {
@@ -448,7 +448,7 @@ describe('Admin Content Management Routes (/api/admin)', () => {
.put(`/api/admin/comments/${commentId}/status`)
.send(requestBody);
expect(response.status).toBe(200);
expect(response.body).toEqual(mockUpdatedComment);
expect(response.body.data).toEqual(mockUpdatedComment);
});
it('PUT /comments/:id/status should return 400 for an invalid status value', async () => {
@@ -485,7 +485,7 @@ describe('Admin Content Management Routes (/api/admin)', () => {
vi.mocked(mockedDb.adminRepo.getUnmatchedFlyerItems).mockResolvedValue(mockUnmatchedItems);
const response = await supertest(app).get('/api/admin/unmatched-items');
expect(response.status).toBe(200);
expect(response.body).toEqual(mockUnmatchedItems);
expect(response.body.data).toEqual(mockUnmatchedItems);
});
it('GET /unmatched-items should return 500 on DB error', async () => {
@@ -515,23 +515,21 @@ describe('Admin Content Management Routes (/api/admin)', () => {
);
const response = await supertest(app).delete(`/api/admin/flyers/${flyerId}`);
expect(response.status).toBe(404);
expect(response.body.message).toBe('Flyer with ID 999 not found.');
expect(response.body.error.message).toBe('Flyer with ID 999 not found.');
});
it('DELETE /flyers/:flyerId should return 500 on a generic DB error', async () => {
const flyerId = 42;
vi.mocked(mockedDb.flyerRepo.deleteFlyer).mockRejectedValue(
new Error('Generic DB Error'),
);
vi.mocked(mockedDb.flyerRepo.deleteFlyer).mockRejectedValue(new Error('Generic DB Error'));
const response = await supertest(app).delete(`/api/admin/flyers/${flyerId}`);
expect(response.status).toBe(500);
expect(response.body.message).toBe('Generic DB Error');
expect(response.body.error.message).toBe('Generic DB Error');
});
it('DELETE /flyers/:flyerId should return 400 for an invalid flyerId', async () => {
const response = await supertest(app).delete('/api/admin/flyers/abc');
expect(response.status).toBe(400);
expect(response.body.errors[0].message).toMatch(/Expected number, received nan/i);
expect(response.body.error.details[0].message).toMatch(/Expected number, received nan/i);
});
});
});

View File

@@ -108,7 +108,7 @@ describe('Admin Job Trigger Routes (/api/admin/trigger)', () => {
// Use the instance method mock
const response = await supertest(app).post('/api/admin/trigger/daily-deal-check');
expect(response.status).toBe(202);
expect(response.body.message).toContain('Daily deal check job has been triggered');
expect(response.body.data.message).toContain('Daily deal check job has been triggered');
expect(backgroundJobService.runDailyDealCheck).toHaveBeenCalledTimes(1);
});
@@ -118,7 +118,7 @@ describe('Admin Job Trigger Routes (/api/admin/trigger)', () => {
});
const response = await supertest(app).post('/api/admin/trigger/daily-deal-check');
expect(response.status).toBe(500);
expect(response.body.message).toContain('Job runner failed');
expect(response.body.error.message).toContain('Job runner failed');
});
});
@@ -128,7 +128,7 @@ describe('Admin Job Trigger Routes (/api/admin/trigger)', () => {
vi.mocked(analyticsQueue.add).mockResolvedValue(mockJob);
const response = await supertest(app).post('/api/admin/trigger/failing-job');
expect(response.status).toBe(202);
expect(response.body.message).toContain('Failing test job has been enqueued');
expect(response.body.data.message).toContain('Failing test job has been enqueued');
expect(analyticsQueue.add).toHaveBeenCalledWith('generate-daily-report', {
reportDate: 'FAIL',
});
@@ -138,23 +138,29 @@ describe('Admin Job Trigger Routes (/api/admin/trigger)', () => {
vi.mocked(analyticsQueue.add).mockRejectedValue(new Error('Queue is down'));
const response = await supertest(app).post('/api/admin/trigger/failing-job');
expect(response.status).toBe(500);
expect(response.body.message).toBe('Queue is down');
expect(response.body.error.message).toBe('Queue is down');
});
});
describe('POST /trigger/analytics-report', () => {
it('should trigger the analytics report job and return 202 Accepted', async () => {
vi.mocked(backgroundJobService.triggerAnalyticsReport).mockResolvedValue('manual-report-job-123');
vi.mocked(backgroundJobService.triggerAnalyticsReport).mockResolvedValue(
'manual-report-job-123',
);
const response = await supertest(app).post('/api/admin/trigger/analytics-report');
expect(response.status).toBe(202);
expect(response.body.message).toContain('Analytics report generation job has been enqueued');
expect(response.body.data.message).toContain(
'Analytics report generation job has been enqueued',
);
expect(backgroundJobService.triggerAnalyticsReport).toHaveBeenCalledTimes(1);
});
it('should return 500 if enqueuing the analytics job fails', async () => {
vi.mocked(backgroundJobService.triggerAnalyticsReport).mockRejectedValue(new Error('Queue error'));
vi.mocked(backgroundJobService.triggerAnalyticsReport).mockRejectedValue(
new Error('Queue error'),
);
const response = await supertest(app).post('/api/admin/trigger/analytics-report');
expect(response.status).toBe(500);
});
@@ -162,17 +168,21 @@ describe('Admin Job Trigger Routes (/api/admin/trigger)', () => {
describe('POST /trigger/weekly-analytics', () => {
it('should trigger the weekly analytics job and return 202 Accepted', async () => {
vi.mocked(backgroundJobService.triggerWeeklyAnalyticsReport).mockResolvedValue('manual-weekly-report-job-123');
vi.mocked(backgroundJobService.triggerWeeklyAnalyticsReport).mockResolvedValue(
'manual-weekly-report-job-123',
);
const response = await supertest(app).post('/api/admin/trigger/weekly-analytics');
expect(response.status).toBe(202);
expect(response.body.message).toContain('Successfully enqueued weekly analytics job');
expect(response.body.data.message).toContain('Successfully enqueued weekly analytics job');
expect(backgroundJobService.triggerWeeklyAnalyticsReport).toHaveBeenCalledTimes(1);
});
it('should return 500 if enqueuing the weekly analytics job fails', async () => {
vi.mocked(backgroundJobService.triggerWeeklyAnalyticsReport).mockRejectedValue(new Error('Queue error'));
vi.mocked(backgroundJobService.triggerWeeklyAnalyticsReport).mockRejectedValue(
new Error('Queue error'),
);
const response = await supertest(app).post('/api/admin/trigger/weekly-analytics');
expect(response.status).toBe(500);
});
@@ -185,7 +195,7 @@ describe('Admin Job Trigger Routes (/api/admin/trigger)', () => {
vi.mocked(cleanupQueue.add).mockResolvedValue(mockJob);
const response = await supertest(app).post(`/api/admin/flyers/${flyerId}/cleanup`);
expect(response.status).toBe(202);
expect(response.body.message).toBe(
expect(response.body.data.message).toBe(
`File cleanup job for flyer ID ${flyerId} has been enqueued.`,
);
expect(cleanupQueue.add).toHaveBeenCalledWith('cleanup-flyer-files', { flyerId });
@@ -196,13 +206,13 @@ describe('Admin Job Trigger Routes (/api/admin/trigger)', () => {
vi.mocked(cleanupQueue.add).mockRejectedValue(new Error('Queue is down'));
const response = await supertest(app).post(`/api/admin/flyers/${flyerId}/cleanup`);
expect(response.status).toBe(500);
expect(response.body.message).toBe('Queue is down');
expect(response.body.error.message).toBe('Queue is down');
});
it('should return 400 for an invalid flyerId', async () => {
const response = await supertest(app).post('/api/admin/flyers/abc/cleanup');
expect(response.status).toBe(400);
expect(response.body.errors[0].message).toMatch(/Expected number, received nan/i);
expect(response.body.error.details[0].message).toMatch(/Expected number, received nan/i);
});
});
@@ -224,7 +234,9 @@ describe('Admin Job Trigger Routes (/api/admin/trigger)', () => {
// Assert
expect(response.status).toBe(200);
expect(response.body.message).toBe(`Job ${jobId} has been successfully marked for retry.`);
expect(response.body.data.message).toBe(
`Job ${jobId} has been successfully marked for retry.`,
);
expect(mockJob.retry).toHaveBeenCalledTimes(1);
});
@@ -244,7 +256,9 @@ describe('Admin Job Trigger Routes (/api/admin/trigger)', () => {
const response = await supertest(app).post(`/api/admin/jobs/${queueName}/${jobId}/retry`);
expect(response.status).toBe(404);
expect(response.body.message).toBe(`Job with ID '${jobId}' not found in queue '${queueName}'.`);
expect(response.body.error.message).toBe(
`Job with ID '${jobId}' not found in queue '${queueName}'.`,
);
});
it('should return 404 if the job ID is not found in the queue', async () => {
@@ -253,7 +267,7 @@ describe('Admin Job Trigger Routes (/api/admin/trigger)', () => {
`/api/admin/jobs/${queueName}/not-found-job/retry`,
);
expect(response.status).toBe(404);
expect(response.body.message).toContain('not found in queue');
expect(response.body.error.message).toContain('not found in queue');
});
it('should return 400 if the job is not in a failed state', async () => {
@@ -267,7 +281,7 @@ describe('Admin Job Trigger Routes (/api/admin/trigger)', () => {
const response = await supertest(app).post(`/api/admin/jobs/${queueName}/${jobId}/retry`);
expect(response.status).toBe(400);
expect(response.body.message).toBe(
expect(response.body.error.message).toBe(
"Job is not in a 'failed' state. Current state: completed.",
); // This is now handled by the errorHandler
expect(mockJob.retry).not.toHaveBeenCalled();
@@ -284,7 +298,7 @@ describe('Admin Job Trigger Routes (/api/admin/trigger)', () => {
const response = await supertest(app).post(`/api/admin/jobs/${queueName}/${jobId}/retry`);
expect(response.status).toBe(500);
expect(response.body.message).toContain('Cannot retry job');
expect(response.body.error.message).toContain('Cannot retry job');
});
it('should return 400 for an invalid queueName or jobId', async () => {

View File

@@ -1,5 +1,5 @@
// src/routes/admin.monitoring.routes.test.ts
import { describe, it, expect, vi, beforeEach, type Mocked } from 'vitest';
import { describe, it, expect, vi, beforeEach } from 'vitest';
import supertest from 'supertest';
import type { Request, Response, NextFunction } from 'express';
import { createMockUserProfile, createMockActivityLogItem } from '../tests/utils/mockFactories';
@@ -133,7 +133,7 @@ describe('Admin Monitoring Routes (/api/admin)', () => {
const response = await supertest(app).get('/api/admin/activity-log');
expect(response.status).toBe(200);
expect(response.body).toEqual(mockLogs);
expect(response.body.data).toEqual(mockLogs);
expect(adminRepo.getActivityLog).toHaveBeenCalledWith(50, 0, expect.anything());
});
@@ -148,15 +148,15 @@ describe('Admin Monitoring Routes (/api/admin)', () => {
it('should return 400 for invalid limit and offset query parameters', async () => {
const response = await supertest(app).get('/api/admin/activity-log?limit=abc&offset=-1');
expect(response.status).toBe(400);
expect(response.body.errors).toBeDefined();
expect(response.body.errors.length).toBe(2); // Both limit and offset are invalid
expect(response.body.error.details).toBeDefined();
expect(response.body.error.details.length).toBe(2); // Both limit and offset are invalid
});
it('should return 500 if fetching activity log fails', async () => {
vi.mocked(adminRepo.getActivityLog).mockRejectedValue(new Error('DB Error'));
const response = await supertest(app).get('/api/admin/activity-log');
expect(response.status).toBe(500);
expect(response.body.message).toBe('DB Error');
expect(response.body.error.message).toBe('DB Error');
});
});
@@ -177,7 +177,7 @@ describe('Admin Monitoring Routes (/api/admin)', () => {
// Assert
expect(response.status).toBe(200);
expect(response.body).toEqual([
expect(response.body.data).toEqual([
{ name: 'flyer-processing', isRunning: true },
{ name: 'email-sending', isRunning: true },
{ name: 'analytics-reporting', isRunning: false },
@@ -190,7 +190,7 @@ describe('Admin Monitoring Routes (/api/admin)', () => {
vi.mocked(monitoringService.getWorkerStatuses).mockRejectedValue(new Error('Worker Error'));
const response = await supertest(app).get('/api/admin/workers/status');
expect(response.status).toBe(500);
expect(response.body.message).toBe('Worker Error');
expect(response.body.error.message).toBe('Worker Error');
});
});
@@ -226,7 +226,7 @@ describe('Admin Monitoring Routes (/api/admin)', () => {
// Assert
expect(response.status).toBe(200);
expect(response.body).toEqual([
expect(response.body.data).toEqual([
{
name: 'flyer-processing',
counts: { waiting: 5, active: 1, completed: 100, failed: 2, delayed: 0, paused: 0 },
@@ -251,13 +251,11 @@ describe('Admin Monitoring Routes (/api/admin)', () => {
});
it('should return 500 if fetching queue counts fails', async () => {
vi.mocked(monitoringService.getQueueStatuses).mockRejectedValue(
new Error('Redis is down'),
);
vi.mocked(monitoringService.getQueueStatuses).mockRejectedValue(new Error('Redis is down'));
const response = await supertest(app).get('/api/admin/queues/status');
expect(response.status).toBe(500);
expect(response.body.message).toBe('Redis is down');
expect(response.body.error.message).toBe('Redis is down');
});
});
});

View File

@@ -2,7 +2,6 @@
import { Router, NextFunction, Request, Response } from 'express';
import passport from './passport.routes';
import { isAdmin } from './passport.routes'; // Correctly imported
import multer from 'multer';
import { z } from 'zod';
import * as db from '../services/db/index.db';
@@ -10,11 +9,8 @@ import type { UserProfile } from '../types';
import { geocodingService } from '../services/geocodingService.server';
import { cacheService } from '../services/cacheService.server';
import { requireFileUpload } from '../middleware/fileUpload.middleware'; // This was a duplicate, fixed.
import {
createUploadMiddleware,
handleMulterError,
} from '../middleware/multer.middleware';
import { NotFoundError, ValidationError } from '../services/db/errors.db';
import { createUploadMiddleware, handleMulterError } from '../middleware/multer.middleware';
import { ValidationError } from '../services/db/errors.db';
import { validateRequest } from '../middleware/validation.middleware';
// --- Bull Board (Job Queue UI) Imports ---
@@ -22,15 +18,14 @@ import { createBullBoard } from '@bull-board/api';
import { BullMQAdapter } from '@bull-board/api/bullMQAdapter';
import { ExpressAdapter } from '@bull-board/express';
import { backgroundJobService } from '../services/backgroundJobService';
import { flyerQueue, emailQueue, analyticsQueue, cleanupQueue, weeklyAnalyticsQueue } from '../services/queueService.server';
import { getSimpleWeekAndYear } from '../utils/dateUtils';
import {
requiredString,
numericIdParam,
uuidParamSchema,
optionalNumeric,
optionalString,
} from '../utils/zodUtils';
flyerQueue,
emailQueue,
analyticsQueue,
cleanupQueue,
weeklyAnalyticsQueue,
} from '../services/queueService.server';
import { numericIdParam, uuidParamSchema, optionalNumeric } from '../utils/zodUtils';
// Removed: import { logger } from '../services/logger.server';
// All route handlers now use req.log (request-scoped logger) as per ADR-004
import { monitoringService } from '../services/monitoringService.server';
@@ -38,6 +33,7 @@ import { userService } from '../services/userService';
import { cleanupUploadedFile } from '../utils/fileUtils';
import { brandService } from '../services/brandService';
import { adminTriggerLimiter, adminUploadLimiter } from '../config/rateLimiters';
import { sendSuccess, sendNoContent } from '../utils/apiResponse';
const updateCorrectionSchema = numericIdParam('id').extend({
body: z.object({
@@ -126,7 +122,7 @@ router.use(passport.authenticate('jwt', { session: false }), isAdmin);
router.get('/corrections', validateRequest(emptySchema), async (req, res, next: NextFunction) => {
try {
const corrections = await db.adminRepo.getSuggestedCorrections(req.log);
res.json(corrections);
sendSuccess(res, corrections);
} catch (error) {
req.log.error({ error }, 'Error fetching suggested corrections');
next(error);
@@ -137,8 +133,11 @@ router.get('/review/flyers', validateRequest(emptySchema), async (req, res, next
try {
req.log.debug('Fetching flyers for review via adminRepo');
const flyers = await db.adminRepo.getFlyersForReview(req.log);
req.log.info({ count: Array.isArray(flyers) ? flyers.length : 'unknown' }, 'Successfully fetched flyers for review');
res.json(flyers);
req.log.info(
{ count: Array.isArray(flyers) ? flyers.length : 'unknown' },
'Successfully fetched flyers for review',
);
sendSuccess(res, flyers);
} catch (error) {
req.log.error({ error }, 'Error fetching flyers for review');
next(error);
@@ -148,7 +147,7 @@ router.get('/review/flyers', validateRequest(emptySchema), async (req, res, next
router.get('/brands', validateRequest(emptySchema), async (req, res, next: NextFunction) => {
try {
const brands = await db.flyerRepo.getAllBrands(req.log);
res.json(brands);
sendSuccess(res, brands);
} catch (error) {
req.log.error({ error }, 'Error fetching brands');
next(error);
@@ -158,7 +157,7 @@ router.get('/brands', validateRequest(emptySchema), async (req, res, next: NextF
router.get('/stats', validateRequest(emptySchema), async (req, res, next: NextFunction) => {
try {
const stats = await db.adminRepo.getApplicationStats(req.log);
res.json(stats);
sendSuccess(res, stats);
} catch (error) {
req.log.error({ error }, 'Error fetching application stats');
next(error);
@@ -168,7 +167,7 @@ router.get('/stats', validateRequest(emptySchema), async (req, res, next: NextFu
router.get('/stats/daily', validateRequest(emptySchema), async (req, res, next: NextFunction) => {
try {
const dailyStats = await db.adminRepo.getDailyStatsForLast30Days(req.log);
res.json(dailyStats);
sendSuccess(res, dailyStats);
} catch (error) {
req.log.error({ error }, 'Error fetching daily stats');
next(error);
@@ -183,7 +182,7 @@ router.post(
const { params } = req as unknown as z.infer<ReturnType<typeof numericIdParam>>;
try {
await db.adminRepo.approveCorrection(params.id, req.log); // params.id is now safely typed as number
res.status(200).json({ message: 'Correction approved successfully.' });
sendSuccess(res, { message: 'Correction approved successfully.' });
} catch (error) {
req.log.error({ error }, 'Error approving correction');
next(error);
@@ -199,7 +198,7 @@ router.post(
const { params } = req as unknown as z.infer<ReturnType<typeof numericIdParam>>;
try {
await db.adminRepo.rejectCorrection(params.id, req.log); // params.id is now safely typed as number
res.status(200).json({ message: 'Correction rejected successfully.' });
sendSuccess(res, { message: 'Correction rejected successfully.' });
} catch (error) {
req.log.error({ error }, 'Error rejecting correction');
next(error);
@@ -219,7 +218,7 @@ router.put(
body.suggested_value,
req.log,
);
res.status(200).json(updatedCorrection);
sendSuccess(res, updatedCorrection);
} catch (error) {
req.log.error({ error }, 'Error updating suggested correction');
next(error);
@@ -235,7 +234,7 @@ router.put(
const { params, body } = req as unknown as z.infer<typeof updateRecipeStatusSchema>;
try {
const updatedRecipe = await db.adminRepo.updateRecipeStatus(params.id, body.status, req.log); // This is still a standalone function in admin.db.ts
res.status(200).json(updatedRecipe);
sendSuccess(res, updatedRecipe);
} catch (error) {
req.log.error({ error }, 'Error updating recipe status');
next(error); // Pass all errors to the central error handler
@@ -260,8 +259,11 @@ router.post(
const logoUrl = await brandService.updateBrandLogo(params.id, req.file, req.log);
req.log.info({ brandId: params.id, logoUrl }, `Brand logo updated for brand ID: ${params.id}`);
res.status(200).json({ message: 'Brand logo updated successfully.', logoUrl });
req.log.info(
{ brandId: params.id, logoUrl },
`Brand logo updated for brand ID: ${params.id}`,
);
sendSuccess(res, { message: 'Brand logo updated successfully.', logoUrl });
} catch (error) {
// If an error occurs after the file has been uploaded (e.g., DB error),
// we must clean up the orphaned file from the disk.
@@ -272,15 +274,19 @@ router.post(
},
);
router.get('/unmatched-items', validateRequest(emptySchema), async (req, res, next: NextFunction) => {
try {
const items = await db.adminRepo.getUnmatchedFlyerItems(req.log);
res.json(items);
} catch (error) {
req.log.error({ error }, 'Error fetching unmatched items');
next(error);
}
});
router.get(
'/unmatched-items',
validateRequest(emptySchema),
async (req, res, next: NextFunction) => {
try {
const items = await db.adminRepo.getUnmatchedFlyerItems(req.log);
sendSuccess(res, items);
} catch (error) {
req.log.error({ error }, 'Error fetching unmatched items');
next(error);
}
},
);
/**
* DELETE /api/admin/recipes/:recipeId - Admin endpoint to delete any recipe.
@@ -295,7 +301,7 @@ router.delete(
try {
// The isAdmin flag bypasses the ownership check in the repository method.
await db.recipeRepo.deleteRecipe(params.recipeId, userProfile.user.user_id, true, req.log);
res.status(204).send();
sendNoContent(res);
} catch (error: unknown) {
req.log.error({ error }, 'Error deleting recipe');
next(error);
@@ -314,7 +320,7 @@ router.delete(
const { params } = req as unknown as z.infer<ReturnType<typeof numericIdParam>>;
try {
await db.flyerRepo.deleteFlyer(params.flyerId, req.log);
res.status(204).send();
sendNoContent(res);
} catch (error: unknown) {
req.log.error({ error }, 'Error deleting flyer');
next(error);
@@ -334,7 +340,7 @@ router.put(
body.status,
req.log,
); // This is still a standalone function in admin.db.ts
res.status(200).json(updatedComment);
sendSuccess(res, updatedComment);
} catch (error: unknown) {
req.log.error({ error }, 'Error updating comment status');
next(error);
@@ -345,7 +351,7 @@ router.put(
router.get('/users', validateRequest(emptySchema), async (req, res, next: NextFunction) => {
try {
const users = await db.adminRepo.getAllUsers(req.log);
res.json(users);
sendSuccess(res, users);
} catch (error) {
req.log.error({ error }, 'Error fetching users');
next(error);
@@ -362,7 +368,7 @@ router.get(
try {
const logs = await db.adminRepo.getActivityLog(limit!, offset!, req.log);
res.json(logs);
sendSuccess(res, logs);
} catch (error) {
req.log.error({ error }, 'Error fetching activity log');
next(error);
@@ -378,7 +384,7 @@ router.get(
const { params } = req as unknown as z.infer<ReturnType<typeof uuidParamSchema>>;
try {
const user = await db.userRepo.findUserProfileById(params.id, req.log);
res.json(user);
sendSuccess(res, user);
} catch (error) {
req.log.error({ error }, 'Error fetching user profile');
next(error);
@@ -394,7 +400,7 @@ router.put(
const { params, body } = req as unknown as z.infer<typeof updateUserRoleSchema>;
try {
const updatedUser = await db.adminRepo.updateUserRole(params.id, body.role, req.log);
res.json(updatedUser);
sendSuccess(res, updatedUser);
} catch (error) {
req.log.error({ error }, `Error updating user ${params.id}:`);
next(error);
@@ -411,7 +417,7 @@ router.delete(
const { params } = req as unknown as z.infer<ReturnType<typeof uuidParamSchema>>;
try {
await userService.deleteUserAsAdmin(userProfile.user.user_id, params.id, req.log);
res.status(204).send();
sendNoContent(res);
} catch (error) {
req.log.error({ error }, 'Error deleting user');
next(error);
@@ -437,10 +443,14 @@ router.post(
// We call the function but don't wait for it to finish (no `await`).
// This is a "fire-and-forget" operation from the client's perspective.
backgroundJobService.runDailyDealCheck();
res.status(202).json({
message:
'Daily deal check job has been triggered successfully. It will run in the background.',
});
sendSuccess(
res,
{
message:
'Daily deal check job has been triggered successfully. It will run in the background.',
},
202,
);
} catch (error) {
req.log.error({ error }, '[Admin] Failed to trigger daily deal check job.');
next(error);
@@ -464,9 +474,13 @@ router.post(
try {
const jobId = await backgroundJobService.triggerAnalyticsReport();
res.status(202).json({
message: `Analytics report generation job has been enqueued successfully. Job ID: ${jobId}`,
});
sendSuccess(
res,
{
message: `Analytics report generation job has been enqueued successfully. Job ID: ${jobId}`,
},
202,
);
} catch (error) {
req.log.error({ error }, '[Admin] Failed to enqueue analytics report job.');
next(error);
@@ -493,9 +507,11 @@ router.post(
// Enqueue the cleanup job. The worker will handle the file deletion.
try {
await cleanupQueue.add('cleanup-flyer-files', { flyerId: params.flyerId });
res
.status(202)
.json({ message: `File cleanup job for flyer ID ${params.flyerId} has been enqueued.` });
sendSuccess(
res,
{ message: `File cleanup job for flyer ID ${params.flyerId} has been enqueued.` },
202,
);
} catch (error) {
req.log.error({ error }, 'Error enqueuing cleanup job');
next(error);
@@ -512,22 +528,24 @@ router.post(
adminTriggerLimiter,
validateRequest(emptySchema),
async (req: Request, res: Response, next: NextFunction) => {
const userProfile = req.user as UserProfile;
req.log.info(
`[Admin] Manual trigger for a failing job received from user: ${userProfile.user.user_id}`,
);
const userProfile = req.user as UserProfile;
req.log.info(
`[Admin] Manual trigger for a failing job received from user: ${userProfile.user.user_id}`,
);
try {
// Add a job with a special 'forceFail' flag that the worker will recognize.
const job = await analyticsQueue.add('generate-daily-report', { reportDate: 'FAIL' });
res
.status(202)
.json({ message: `Failing test job has been enqueued successfully. Job ID: ${job.id}` });
} catch (error) {
req.log.error({ error }, 'Error enqueuing failing job');
next(error);
}
}
try {
// Add a job with a special 'forceFail' flag that the worker will recognize.
const job = await analyticsQueue.add('generate-daily-report', { reportDate: 'FAIL' });
sendSuccess(
res,
{ message: `Failing test job has been enqueued successfully. Job ID: ${job.id}` },
202,
);
} catch (error) {
req.log.error({ error }, 'Error enqueuing failing job');
next(error);
}
},
);
/**
@@ -546,7 +564,7 @@ router.post(
try {
const keysDeleted = await geocodingService.clearGeocodeCache(req.log);
res.status(200).json({
sendSuccess(res, {
message: `Successfully cleared the geocode cache. ${keysDeleted} keys were removed.`,
});
} catch (error) {
@@ -560,29 +578,37 @@ router.post(
* GET /api/admin/workers/status - Get the current running status of all BullMQ workers.
* This is useful for a system health dashboard to see if any workers have crashed.
*/
router.get('/workers/status', validateRequest(emptySchema), async (req: Request, res: Response, next: NextFunction) => {
try {
const workerStatuses = await monitoringService.getWorkerStatuses();
res.json(workerStatuses);
} catch (error) {
req.log.error({ error }, 'Error fetching worker statuses');
next(error);
}
});
router.get(
'/workers/status',
validateRequest(emptySchema),
async (req: Request, res: Response, next: NextFunction) => {
try {
const workerStatuses = await monitoringService.getWorkerStatuses();
sendSuccess(res, workerStatuses);
} catch (error) {
req.log.error({ error }, 'Error fetching worker statuses');
next(error);
}
},
);
/**
* GET /api/admin/queues/status - Get job counts for all BullMQ queues.
* This is useful for monitoring the health and backlog of background jobs.
*/
router.get('/queues/status', validateRequest(emptySchema), async (req: Request, res: Response, next: NextFunction) => {
try {
const queueStatuses = await monitoringService.getQueueStatuses();
res.json(queueStatuses);
} catch (error) {
req.log.error({ error }, 'Error fetching queue statuses');
next(error);
}
});
router.get(
'/queues/status',
validateRequest(emptySchema),
async (req: Request, res: Response, next: NextFunction) => {
try {
const queueStatuses = await monitoringService.getQueueStatuses();
sendSuccess(res, queueStatuses);
} catch (error) {
req.log.error({ error }, 'Error fetching queue statuses');
next(error);
}
},
);
/**
* POST /api/admin/jobs/:queueName/:jobId/retry - Retries a specific failed job.
@@ -598,12 +624,8 @@ router.post(
} = req as unknown as z.infer<typeof jobRetrySchema>;
try {
await monitoringService.retryFailedJob(
queueName,
jobId,
userProfile.user.user_id,
);
res.status(200).json({ message: `Job ${jobId} has been successfully marked for retry.` });
await monitoringService.retryFailedJob(queueName, jobId, userProfile.user.user_id);
sendSuccess(res, { message: `Job ${jobId} has been successfully marked for retry.` });
} catch (error) {
req.log.error({ error }, 'Error retrying job');
next(error);
@@ -626,9 +648,7 @@ router.post(
try {
const jobId = await backgroundJobService.triggerWeeklyAnalyticsReport();
res
.status(202)
.json({ message: 'Successfully enqueued weekly analytics job.', jobId });
sendSuccess(res, { message: 'Successfully enqueued weekly analytics job.', jobId }, 202);
} catch (error) {
req.log.error({ error }, 'Error enqueuing weekly analytics job');
next(error);
@@ -647,9 +667,7 @@ router.post(
validateRequest(emptySchema),
async (req: Request, res: Response, next: NextFunction) => {
const userProfile = req.user as UserProfile;
req.log.info(
`[Admin] Manual cache clear received from user: ${userProfile.user.user_id}`,
);
req.log.info(`[Admin] Manual cache clear received from user: ${userProfile.user.user_id}`);
try {
const [flyersDeleted, brandsDeleted, statsDeleted] = await Promise.all([
@@ -659,7 +677,7 @@ router.post(
]);
const totalDeleted = flyersDeleted + brandsDeleted + statsDeleted;
res.status(200).json({
sendSuccess(res, {
message: `Successfully cleared the application cache. ${totalDeleted} keys were removed.`,
details: {
flyers: flyersDeleted,
@@ -677,5 +695,4 @@ router.post(
/* Catches errors from multer (e.g., file size, file filter) */
router.use(handleMulterError);
export default router;

View File

@@ -90,14 +90,14 @@ describe('Admin Stats Routes (/api/admin/stats)', () => {
vi.mocked(adminRepo.getApplicationStats).mockResolvedValue(mockStats);
const response = await supertest(app).get('/api/admin/stats');
expect(response.status).toBe(200);
expect(response.body).toEqual(mockStats);
expect(response.body.data).toEqual(mockStats);
});
it('should return 500 if the database call fails', async () => {
vi.mocked(adminRepo.getApplicationStats).mockRejectedValue(new Error('DB Error'));
const response = await supertest(app).get('/api/admin/stats');
expect(response.status).toBe(500);
expect(response.body.message).toBe('DB Error');
expect(response.body.error.message).toBe('DB Error');
});
});
@@ -110,14 +110,14 @@ describe('Admin Stats Routes (/api/admin/stats)', () => {
vi.mocked(adminRepo.getDailyStatsForLast30Days).mockResolvedValue(mockDailyStats);
const response = await supertest(app).get('/api/admin/stats/daily');
expect(response.status).toBe(200);
expect(response.body).toEqual(mockDailyStats);
expect(response.body.data).toEqual(mockDailyStats);
});
it('should return 500 if the database call fails', async () => {
vi.mocked(adminRepo.getDailyStatsForLast30Days).mockRejectedValue(new Error('DB Error'));
const response = await supertest(app).get('/api/admin/stats/daily');
expect(response.status).toBe(500);
expect(response.body.message).toBe('DB Error');
expect(response.body.error.message).toBe('DB Error');
});
});
});

View File

@@ -88,14 +88,14 @@ describe('Admin System Routes (/api/admin/system)', () => {
vi.mocked(geocodingService.clearGeocodeCache).mockResolvedValue(10);
const response = await supertest(app).post('/api/admin/system/clear-geocode-cache');
expect(response.status).toBe(200);
expect(response.body.message).toContain('10 keys were removed');
expect(response.body.data.message).toContain('10 keys were removed');
});
it('should return 500 if clearing the cache fails', async () => {
vi.mocked(geocodingService.clearGeocodeCache).mockRejectedValue(new Error('Redis is down'));
const response = await supertest(app).post('/api/admin/system/clear-geocode-cache');
expect(response.status).toBe(500);
expect(response.body.message).toContain('Redis is down');
expect(response.body.error.message).toContain('Redis is down');
});
});
});

View File

@@ -104,7 +104,7 @@ describe('Admin User Management Routes (/api/admin/users)', () => {
vi.mocked(adminRepo.getAllUsers).mockResolvedValue(mockUsers);
const response = await supertest(app).get('/api/admin/users');
expect(response.status).toBe(200);
expect(response.body).toEqual(mockUsers);
expect(response.body.data).toEqual(mockUsers);
expect(adminRepo.getAllUsers).toHaveBeenCalledTimes(1);
});
@@ -122,7 +122,7 @@ describe('Admin User Management Routes (/api/admin/users)', () => {
vi.mocked(userRepo.findUserProfileById).mockResolvedValue(mockUser);
const response = await supertest(app).get(`/api/admin/users/${userId}`);
expect(response.status).toBe(200);
expect(response.body).toEqual(mockUser);
expect(response.body.data).toEqual(mockUser);
expect(userRepo.findUserProfileById).toHaveBeenCalledWith(userId, expect.any(Object));
});
@@ -133,7 +133,7 @@ describe('Admin User Management Routes (/api/admin/users)', () => {
);
const response = await supertest(app).get(`/api/admin/users/${missingId}`);
expect(response.status).toBe(404);
expect(response.body.message).toBe('User not found.');
expect(response.body.error.message).toBe('User not found.');
});
it('should return 500 on a generic database error', async () => {
@@ -160,7 +160,7 @@ describe('Admin User Management Routes (/api/admin/users)', () => {
.put(`/api/admin/users/${userId}`)
.send({ role: 'admin' });
expect(response.status).toBe(200);
expect(response.body).toEqual(updatedUser);
expect(response.body.data).toEqual(updatedUser);
expect(adminRepo.updateUserRole).toHaveBeenCalledWith(userId, 'admin', expect.any(Object));
});
@@ -173,7 +173,7 @@ describe('Admin User Management Routes (/api/admin/users)', () => {
.put(`/api/admin/users/${missingId}`)
.send({ role: 'user' });
expect(response.status).toBe(404);
expect(response.body.message).toBe(`User with ID ${missingId} not found.`);
expect(response.body.error.message).toBe(`User with ID ${missingId} not found.`);
});
it('should return 500 on a generic database error', async () => {
@@ -183,7 +183,7 @@ describe('Admin User Management Routes (/api/admin/users)', () => {
.put(`/api/admin/users/${userId}`)
.send({ role: 'admin' });
expect(response.status).toBe(500);
expect(response.body.message).toBe('DB Error');
expect(response.body.error.message).toBe('DB Error');
});
it('should return 400 for an invalid role', async () => {
@@ -201,7 +201,11 @@ describe('Admin User Management Routes (/api/admin/users)', () => {
vi.mocked(userService.deleteUserAsAdmin).mockResolvedValue(undefined);
const response = await supertest(app).delete(`/api/admin/users/${targetId}`);
expect(response.status).toBe(204);
expect(userService.deleteUserAsAdmin).toHaveBeenCalledWith(adminId, targetId, expect.any(Object));
expect(userService.deleteUserAsAdmin).toHaveBeenCalledWith(
adminId,
targetId,
expect.any(Object),
);
});
it('should prevent an admin from deleting their own account', async () => {
@@ -209,9 +213,13 @@ describe('Admin User Management Routes (/api/admin/users)', () => {
vi.mocked(userService.deleteUserAsAdmin).mockRejectedValue(validationError);
const response = await supertest(app).delete(`/api/admin/users/${adminId}`);
expect(response.status).toBe(400);
expect(response.body.message).toMatch(/Admins cannot delete their own account/);
expect(response.body.error.message).toMatch(/Admins cannot delete their own account/);
expect(userRepo.deleteUserById).not.toHaveBeenCalled();
expect(userService.deleteUserAsAdmin).toHaveBeenCalledWith(adminId, adminId, expect.any(Object));
expect(userService.deleteUserAsAdmin).toHaveBeenCalledWith(
adminId,
adminId,
expect.any(Object),
);
});
it('should return 500 on a generic database error', async () => {

View File

@@ -151,7 +151,9 @@ describe('AI Routes (/api/ai)', () => {
const validChecksum = 'a'.repeat(64);
it('should enqueue a job and return 202 on success', async () => {
vi.mocked(aiService.aiService.enqueueFlyerProcessing).mockResolvedValue({ id: 'job-123' } as unknown as Job);
vi.mocked(aiService.aiService.enqueueFlyerProcessing).mockResolvedValue({
id: 'job-123',
} as unknown as Job);
const response = await supertest(app)
.post('/api/ai/upload-and-process')
@@ -159,8 +161,8 @@ describe('AI Routes (/api/ai)', () => {
.attach('flyerFile', imagePath);
expect(response.status).toBe(202);
expect(response.body.message).toBe('Flyer accepted for processing.');
expect(response.body.jobId).toBe('job-123');
expect(response.body.data.message).toBe('Flyer accepted for processing.');
expect(response.body.data.jobId).toBe('job-123');
expect(aiService.aiService.enqueueFlyerProcessing).toHaveBeenCalled();
});
@@ -170,7 +172,7 @@ describe('AI Routes (/api/ai)', () => {
.field('checksum', validChecksum);
expect(response.status).toBe(400);
expect(response.body.message).toBe('A flyer file (PDF or image) is required.');
expect(response.body.error.message).toBe('A flyer file (PDF or image) is required.');
});
it('should return 400 if checksum is missing', async () => {
@@ -180,11 +182,14 @@ describe('AI Routes (/api/ai)', () => {
expect(response.status).toBe(400);
// Use regex to be resilient to validation message changes
expect(response.body.errors[0].message).toMatch(/checksum is required|Required/i);
expect(response.body.error.details[0].message).toMatch(/checksum is required|Required/i);
});
it('should return 409 if flyer checksum already exists', async () => {
const duplicateError = new aiService.DuplicateFlyerError('This flyer has already been processed.', 99);
const duplicateError = new aiService.DuplicateFlyerError(
'This flyer has already been processed.',
99,
);
vi.mocked(aiService.aiService.enqueueFlyerProcessing).mockRejectedValue(duplicateError);
const response = await supertest(app)
@@ -193,11 +198,13 @@ describe('AI Routes (/api/ai)', () => {
.attach('flyerFile', imagePath);
expect(response.status).toBe(409);
expect(response.body.message).toBe('This flyer has already been processed.');
expect(response.body.error.message).toBe('This flyer has already been processed.');
});
it('should return 500 if enqueuing the job fails', async () => {
vi.mocked(aiService.aiService.enqueueFlyerProcessing).mockRejectedValueOnce(new Error('Redis connection failed'));
vi.mocked(aiService.aiService.enqueueFlyerProcessing).mockRejectedValueOnce(
new Error('Redis connection failed'),
);
const response = await supertest(app)
.post('/api/ai/upload-and-process')
@@ -205,7 +212,7 @@ describe('AI Routes (/api/ai)', () => {
.attach('flyerFile', imagePath);
expect(response.status).toBe(500);
expect(response.body.message).toBe('Redis connection failed');
expect(response.body.error.message).toBe('Redis connection failed');
});
it('should pass user ID to the job when authenticated', async () => {
@@ -219,8 +226,10 @@ describe('AI Routes (/api/ai)', () => {
basePath: '/api/ai',
authenticatedUser: mockUser,
});
vi.mocked(aiService.aiService.enqueueFlyerProcessing).mockResolvedValue({ id: 'job-456' } as unknown as Job);
vi.mocked(aiService.aiService.enqueueFlyerProcessing).mockResolvedValue({
id: 'job-456',
} as unknown as Job);
// Act
await supertest(authenticatedApp)
@@ -255,8 +264,10 @@ describe('AI Routes (/api/ai)', () => {
basePath: '/api/ai',
authenticatedUser: mockUserWithAddress,
});
vi.mocked(aiService.aiService.enqueueFlyerProcessing).mockResolvedValue({ id: 'job-789' } as unknown as Job);
vi.mocked(aiService.aiService.enqueueFlyerProcessing).mockResolvedValue({
id: 'job-789',
} as unknown as Job);
// Act
await supertest(authenticatedApp)
@@ -296,7 +307,7 @@ describe('AI Routes (/api/ai)', () => {
const response = await supertest(app).get('/api/ai/jobs/non-existent-job/status');
expect(response.status).toBe(404);
expect(response.body.message).toBe('Job not found.');
expect(response.body.error.message).toBe('Job not found.');
});
it('should return job status if job is found', async () => {
@@ -311,7 +322,7 @@ describe('AI Routes (/api/ai)', () => {
const response = await supertest(app).get('/api/ai/jobs/job-123/status');
expect(response.status).toBe(200);
expect(response.body.state).toBe('completed');
expect(response.body.data.state).toBe('completed');
});
// Removed flaky test 'should return 400 for an invalid job ID format'
@@ -343,7 +354,7 @@ describe('AI Routes (/api/ai)', () => {
// Assert
expect(response.status).toBe(200);
expect(response.body).toEqual(mockFlyer);
expect(response.body.data).toEqual(mockFlyer);
expect(aiService.aiService.processLegacyFlyerUpload).toHaveBeenCalledWith(
expect.any(Object), // req.file
expect.any(Object), // req.body
@@ -358,7 +369,7 @@ describe('AI Routes (/api/ai)', () => {
.field('some_legacy_field', 'value');
expect(response.status).toBe(400);
expect(response.body.message).toBe('No flyer file uploaded.');
expect(response.body.error.message).toBe('No flyer file uploaded.');
});
it('should return 409 and cleanup file if a duplicate flyer is detected', async () => {
@@ -366,23 +377,29 @@ describe('AI Routes (/api/ai)', () => {
vi.mocked(aiService.aiService.processLegacyFlyerUpload).mockRejectedValue(duplicateError);
const unlinkSpy = vi.spyOn(fs.promises, 'unlink').mockResolvedValue(undefined);
const response = await supertest(authenticatedApp).post('/api/ai/upload-legacy').attach('flyerFile', imagePath);
const response = await supertest(authenticatedApp)
.post('/api/ai/upload-legacy')
.attach('flyerFile', imagePath);
expect(response.status).toBe(409);
expect(response.body.message).toBe('Duplicate legacy flyer.');
expect(response.body.flyerId).toBe(101);
expect(response.body.error.message).toBe('Duplicate legacy flyer.');
expect(response.body.error.details.flyerId).toBe(101);
expect(unlinkSpy).toHaveBeenCalledTimes(1);
unlinkSpy.mockRestore();
});
it('should return 500 and cleanup file on a generic service error', async () => {
vi.mocked(aiService.aiService.processLegacyFlyerUpload).mockRejectedValue(new Error('Internal service failure'));
vi.mocked(aiService.aiService.processLegacyFlyerUpload).mockRejectedValue(
new Error('Internal service failure'),
);
const unlinkSpy = vi.spyOn(fs.promises, 'unlink').mockResolvedValue(undefined);
const response = await supertest(authenticatedApp).post('/api/ai/upload-legacy').attach('flyerFile', imagePath);
const response = await supertest(authenticatedApp)
.post('/api/ai/upload-legacy')
.attach('flyerFile', imagePath);
expect(response.status).toBe(500);
expect(response.body.message).toBe('Internal service failure');
expect(response.body.error.message).toBe('Internal service failure');
expect(unlinkSpy).toHaveBeenCalledTimes(1);
unlinkSpy.mockRestore();
});
@@ -412,7 +429,7 @@ describe('AI Routes (/api/ai)', () => {
// Assert
expect(response.status).toBe(201);
expect(response.body.message).toBe('Flyer processed and saved successfully.');
expect(response.body.data.message).toBe('Flyer processed and saved successfully.');
expect(aiService.aiService.processLegacyFlyerUpload).toHaveBeenCalledTimes(1);
});
@@ -425,7 +442,10 @@ describe('AI Routes (/api/ai)', () => {
it('should return 409 Conflict and delete the uploaded file if flyer checksum already exists', async () => {
// Arrange
const duplicateError = new aiService.DuplicateFlyerError('This flyer has already been processed.', 99);
const duplicateError = new aiService.DuplicateFlyerError(
'This flyer has already been processed.',
99,
);
vi.mocked(aiService.aiService.processLegacyFlyerUpload).mockRejectedValue(duplicateError);
const unlinkSpy = vi.spyOn(fs.promises, 'unlink').mockResolvedValue(undefined);
@@ -437,12 +457,14 @@ describe('AI Routes (/api/ai)', () => {
// Assert
expect(response.status).toBe(409);
expect(response.body.message).toBe('This flyer has already been processed.');
expect(response.body.error.message).toBe('This flyer has already been processed.');
expect(mockedDb.createFlyerAndItems).not.toHaveBeenCalled(); // Should not be called if service throws
// Assert that the file was deleted
expect(unlinkSpy).toHaveBeenCalledTimes(1);
// The filename is predictable in the test environment because of the multer config in ai.routes.ts
expect(unlinkSpy).toHaveBeenCalledWith(expect.stringContaining('flyerImage-test-flyer-image.jpg'));
expect(unlinkSpy).toHaveBeenCalledWith(
expect.stringContaining('flyerImage-test-flyer-image.jpg'),
);
});
it('should accept payload when extractedData.items is missing and save with empty items', async () => {
@@ -453,7 +475,9 @@ describe('AI Routes (/api/ai)', () => {
extractedData: { store_name: 'Partial Store' }, // no items key
};
vi.mocked(aiService.aiService.processLegacyFlyerUpload).mockResolvedValue(createMockFlyer({ flyer_id: 2 }));
vi.mocked(aiService.aiService.processLegacyFlyerUpload).mockResolvedValue(
createMockFlyer({ flyer_id: 2 }),
);
const response = await supertest(app)
.post('/api/ai/flyers/process')
@@ -471,7 +495,9 @@ describe('AI Routes (/api/ai)', () => {
extractedData: { items: [] }, // store_name missing
};
vi.mocked(aiService.aiService.processLegacyFlyerUpload).mockResolvedValue(createMockFlyer({ flyer_id: 3 }));
vi.mocked(aiService.aiService.processLegacyFlyerUpload).mockResolvedValue(
createMockFlyer({ flyer_id: 3 }),
);
const response = await supertest(app)
.post('/api/ai/flyers/process')
@@ -519,7 +545,7 @@ describe('AI Routes (/api/ai)', () => {
expect(aiService.aiService.processLegacyFlyerUpload).toHaveBeenCalledTimes(1);
});
it('should handle payload where extractedData is null', async () => {
it('should handle payload where extractedData is null', async () => {
const payloadWithNullExtractedData = {
checksum: 'null-extracted-data-checksum',
originalFileName: 'flyer-null.jpg',
@@ -590,10 +616,12 @@ describe('AI Routes (/api/ai)', () => {
it('should handle malformed JSON in data field and return 400', async () => {
const malformedDataString = '{"checksum":'; // Invalid JSON
// Since the service parses the data, we mock it to throw a ValidationError when parsing fails
// or when it detects the malformed input.
vi.mocked(aiService.aiService.processLegacyFlyerUpload).mockRejectedValue(new ValidationError([], 'Checksum is required.'));
vi.mocked(aiService.aiService.processLegacyFlyerUpload).mockRejectedValue(
new ValidationError([], 'Checksum is required.'),
);
const response = await supertest(app)
.post('/api/ai/flyers/process')
@@ -603,8 +631,8 @@ describe('AI Routes (/api/ai)', () => {
// The outer catch block should be hit, leading to empty parsed data.
// The handler then fails the checksum validation.
expect(response.status).toBe(400);
expect(response.body.message).toBe('Checksum is required.');
// Note: The logging expectation was removed because if the service throws a ValidationError,
expect(response.body.error.message).toBe('Checksum is required.');
// Note: The logging expectation was removed because if the service throws a ValidationError,
// the route handler passes it to the global error handler, which might log differently or not as a "critical error during parsing" in the route itself.
});
@@ -615,9 +643,11 @@ describe('AI Routes (/api/ai)', () => {
};
// Spy on fs.promises.unlink to verify file cleanup
const unlinkSpy = vi.spyOn(fs.promises, 'unlink').mockResolvedValue(undefined);
// Mock the service to throw a ValidationError because the checksum is missing
vi.mocked(aiService.aiService.processLegacyFlyerUpload).mockRejectedValue(new ValidationError([], 'Checksum is required.'));
vi.mocked(aiService.aiService.processLegacyFlyerUpload).mockRejectedValue(
new ValidationError([], 'Checksum is required.'),
);
const response = await supertest(app)
.post('/api/ai/flyers/process')
@@ -625,7 +655,7 @@ describe('AI Routes (/api/ai)', () => {
.attach('flyerImage', imagePath);
expect(response.status).toBe(400);
expect(response.body.message).toBe('Checksum is required.');
expect(response.body.error.message).toBe('Checksum is required.');
// Ensure the uploaded file is cleaned up
expect(unlinkSpy).toHaveBeenCalledTimes(1);
@@ -643,7 +673,7 @@ describe('AI Routes (/api/ai)', () => {
it('should return 200 with a stubbed response on success', async () => {
const response = await supertest(app).post('/api/ai/check-flyer').attach('image', imagePath);
expect(response.status).toBe(200);
expect(response.body.is_flyer).toBe(true);
expect(response.body.data.is_flyer).toBe(true);
});
it('should return 500 on a generic error', async () => {
@@ -674,7 +704,7 @@ describe('AI Routes (/api/ai)', () => {
.attach('image', imagePath)
.field('extractionType', 'store_name'); // Missing cropArea
expect(response.status).toBe(400);
expect(response.body.errors[0].message).toMatch(
expect(response.body.error.details[0].message).toMatch(
/cropArea must be a valid JSON string|Required/i,
);
});
@@ -700,7 +730,7 @@ describe('AI Routes (/api/ai)', () => {
.post('/api/ai/extract-address')
.attach('image', imagePath);
expect(response.status).toBe(200);
expect(response.body.address).toBe('not identified');
expect(response.body.data.address).toBe('not identified');
});
it('should return 500 on a generic error', async () => {
@@ -728,7 +758,7 @@ describe('AI Routes (/api/ai)', () => {
.post('/api/ai/extract-logo')
.attach('images', imagePath);
expect(response.status).toBe(200);
expect(response.body.store_logo_base_64).toBeNull();
expect(response.body.data.store_logo_base_64).toBeNull();
});
it('should return 500 on a generic error', async () => {
@@ -750,7 +780,11 @@ describe('AI Routes (/api/ai)', () => {
const mockUser = createMockUserProfile({
user: { user_id: 'user-123', email: 'user-123@test.com' },
});
const authenticatedApp = createTestApp({ router: aiRouter, basePath: '/api/ai', authenticatedUser: mockUser });
const authenticatedApp = createTestApp({
router: aiRouter,
basePath: '/api/ai',
authenticatedUser: mockUser,
});
beforeEach(() => {
// Inject an authenticated user for this test block
@@ -771,7 +805,7 @@ describe('AI Routes (/api/ai)', () => {
.attach('image', imagePath);
// Use the authenticatedApp instance for requests in this block
expect(response.status).toBe(200);
expect(response.body).toEqual(mockResult);
expect(response.body.data).toEqual(mockResult);
expect(aiService.aiService.extractTextFromImageArea).toHaveBeenCalled();
});
@@ -788,27 +822,20 @@ describe('AI Routes (/api/ai)', () => {
expect(response.status).toBe(500);
// The error message might be wrapped or formatted differently
expect(response.body.message).toMatch(/AI API is down/i);
expect(response.body.error.message).toMatch(/AI API is down/i);
});
});
describe('when user is authenticated', () => {
const mockUserProfile = createMockUserProfile({
user: { user_id: 'user-123', email: 'user-123@test.com' },
});
const authenticatedApp = createTestApp({ router: aiRouter, basePath: '/api/ai', authenticatedUser: mockUserProfile });
// Note: authenticatedApp is available from the describe block above if needed
beforeEach(() => {
// The authenticatedApp instance is already set up with mockUserProfile
});
it('POST /quick-insights should return the stubbed response', async () => {
const response = await supertest(app)
.post('/api/ai/quick-insights')
.send({ items: [{ name: 'test' }] });
expect(response.status).toBe(200);
expect(response.body.text).toContain('server-generated quick insight');
expect(response.body.data.text).toContain('server-generated quick insight');
});
it('POST /quick-insights should accept items with "item" property instead of "name"', async () => {
@@ -835,20 +862,20 @@ describe('AI Routes (/api/ai)', () => {
.post('/api/ai/deep-dive')
.send({ items: [{ name: 'test' }] });
expect(response.status).toBe(200);
expect(response.body.text).toContain('server-generated deep dive');
expect(response.body.data.text).toContain('server-generated deep dive');
});
it('POST /generate-image should return 501 Not Implemented', async () => {
const response = await supertest(app).post('/api/ai/generate-image').send({ prompt: 'test' });
expect(response.status).toBe(501);
expect(response.body.message).toBe('Image generation is not yet implemented.');
expect(response.body.error.message).toBe('Image generation is not yet implemented.');
});
it('POST /generate-speech should return 501 Not Implemented', async () => {
const response = await supertest(app).post('/api/ai/generate-speech').send({ text: 'test' });
expect(response.status).toBe(501);
expect(response.body.message).toBe('Speech generation is not yet implemented.');
expect(response.body.error.message).toBe('Speech generation is not yet implemented.');
});
it('POST /search-web should return the stubbed response', async () => {
@@ -857,7 +884,7 @@ describe('AI Routes (/api/ai)', () => {
.send({ query: 'test query' });
expect(response.status).toBe(200);
expect(response.body.text).toContain('The web says this is good');
expect(response.body.data.text).toContain('The web says this is good');
});
it('POST /compare-prices should return the stubbed response', async () => {
@@ -866,7 +893,7 @@ describe('AI Routes (/api/ai)', () => {
.send({ items: [{ name: 'Milk' }] });
expect(response.status).toBe(200);
expect(response.body.text).toContain('server-generated price comparison');
expect(response.body.data.text).toContain('server-generated price comparison');
});
it('POST /plan-trip should return result on success', async () => {
@@ -882,7 +909,7 @@ describe('AI Routes (/api/ai)', () => {
});
expect(response.status).toBe(200);
expect(response.body).toEqual(mockResult);
expect(response.body.data).toEqual(mockResult);
});
it('POST /plan-trip should return 500 if the AI service fails', async () => {
@@ -899,7 +926,7 @@ describe('AI Routes (/api/ai)', () => {
});
expect(response.status).toBe(500);
expect(response.body.message).toBe('Maps API key invalid');
expect(response.body.error.message).toBe('Maps API key invalid');
});
it('POST /deep-dive should return 500 on a generic error', async () => {
@@ -910,7 +937,7 @@ describe('AI Routes (/api/ai)', () => {
.post('/api/ai/deep-dive')
.send({ items: [{ name: 'test' }] });
expect(response.status).toBe(500);
expect(response.body.message).toBe('Deep dive logging failed');
expect(response.body.error.message).toBe('Deep dive logging failed');
});
it('POST /search-web should return 500 on a generic error', async () => {
@@ -921,7 +948,7 @@ describe('AI Routes (/api/ai)', () => {
.post('/api/ai/search-web')
.send({ query: 'test query' });
expect(response.status).toBe(500);
expect(response.body.message).toBe('Search web logging failed');
expect(response.body.error.message).toBe('Search web logging failed');
});
it('POST /compare-prices should return 500 on a generic error', async () => {
@@ -932,7 +959,7 @@ describe('AI Routes (/api/ai)', () => {
.post('/api/ai/compare-prices')
.send({ items: [{ name: 'Milk' }] });
expect(response.status).toBe(500);
expect(response.body.message).toBe('Compare prices logging failed');
expect(response.body.error.message).toBe('Compare prices logging failed');
});
it('POST /quick-insights should return 400 if items are missing', async () => {

View File

@@ -9,10 +9,7 @@ import { optionalAuth } from './passport.routes';
// All route handlers now use req.log (request-scoped logger) as per ADR-004
import { aiService, DuplicateFlyerError } from '../services/aiService.server';
// All route handlers now use req.log (request-scoped logger) as per ADR-004
import {
createUploadMiddleware,
handleMulterError,
} from '../middleware/multer.middleware';
import { createUploadMiddleware, handleMulterError } from '../middleware/multer.middleware';
import { logger } from '../services/logger.server'; // Needed for module-level logging (e.g., Zod schema transforms)
// All route handlers now use req.log (request-scoped logger) as per ADR-004
import { UserProfile } from '../types'; // This was a duplicate, fixed.
@@ -26,6 +23,7 @@ import { cleanupUploadedFile, cleanupUploadedFiles } from '../utils/fileUtils';
import { monitoringService } from '../services/monitoringService.server';
// All route handlers now use req.log (request-scoped logger) as per ADR-004
import { aiUploadLimiter, aiGenerationLimiter } from '../config/rateLimiters';
import { sendSuccess, sendError, ErrorCode } from '../utils/apiResponse';
const router = Router();
@@ -35,7 +33,8 @@ const uploadAndProcessSchema = z.object({
body: z.object({
// Stricter validation for SHA-256 checksum. It must be a 64-character hexadecimal string.
checksum: requiredString('File checksum is required.').pipe(
z.string()
z
.string()
.length(64, 'Checksum must be 64 characters long.')
.regex(/^[a-f0-9]+$/, 'Checksum must be a valid hexadecimal string.'),
),
@@ -96,8 +95,14 @@ const flyerItemForAnalysisSchema = z
// Sanitize item and name by trimming whitespace.
// The transform ensures that null/undefined values are preserved
// while trimming any actual string values.
item: z.string().nullish().transform(val => (val ? val.trim() : val)),
name: z.string().nullish().transform(val => (val ? val.trim() : val)),
item: z
.string()
.nullish()
.transform((val) => (val ? val.trim() : val)),
name: z
.string()
.nullish()
.transform((val) => (val ? val.trim() : val)),
})
// Using .passthrough() allows extra properties on the item object.
// If the intent is to strictly enforce only 'item' and 'name' (and other known properties),
@@ -190,7 +195,12 @@ router.post(
const { body } = uploadAndProcessSchema.parse({ body: req.body });
if (!req.file) {
return res.status(400).json({ message: 'A flyer file (PDF or image) is required.' });
return sendError(
res,
ErrorCode.BAD_REQUEST,
'A flyer file (PDF or image) is required.',
400,
);
}
req.log.debug(
@@ -204,7 +214,7 @@ router.post(
if (process.env.NODE_ENV === 'test' && !req.headers['authorization']) {
userProfile = undefined;
}
const job = await aiService.enqueueFlyerProcessing(
req.file,
body.checksum,
@@ -215,15 +225,19 @@ router.post(
);
// Respond immediately to the client with 202 Accepted
res.status(202).json({
message: 'Flyer accepted for processing.',
jobId: job.id,
});
sendSuccess(
res,
{
message: 'Flyer accepted for processing.',
jobId: job.id,
},
202,
);
} catch (error) {
await cleanupUploadedFile(req.file);
if (error instanceof DuplicateFlyerError) {
req.log.warn(`Duplicate flyer upload attempt blocked for checksum: ${req.body?.checksum}`);
return res.status(409).json({ message: error.message, flyerId: error.flyerId });
return sendError(res, ErrorCode.CONFLICT, error.message, 409, { flyerId: error.flyerId });
}
next(error);
}
@@ -246,16 +260,21 @@ router.post(
async (req: Request, res: Response, next: NextFunction) => {
try {
if (!req.file) {
return res.status(400).json({ message: 'No flyer file uploaded.' });
return sendError(res, ErrorCode.BAD_REQUEST, 'No flyer file uploaded.', 400);
}
const userProfile = req.user as UserProfile;
const newFlyer = await aiService.processLegacyFlyerUpload(req.file, req.body, userProfile, req.log);
res.status(200).json(newFlyer);
const newFlyer = await aiService.processLegacyFlyerUpload(
req.file,
req.body,
userProfile,
req.log,
);
sendSuccess(res, newFlyer);
} catch (error) {
await cleanupUploadedFile(req.file);
if (error instanceof DuplicateFlyerError) {
req.log.warn(`Duplicate legacy flyer upload attempt blocked.`);
return res.status(409).json({ message: error.message, flyerId: error.flyerId });
return sendError(res, ErrorCode.CONFLICT, error.message, 409, { flyerId: error.flyerId });
}
next(error);
}
@@ -277,7 +296,7 @@ router.get(
try {
const jobStatus = await monitoringService.getFlyerJobStatus(jobId); // This was a duplicate, fixed.
req.log.debug(`[API /ai/jobs] Status check for job ${jobId}: ${jobStatus.state}`);
res.json(jobStatus);
sendSuccess(res, jobStatus);
} catch (error) {
next(error);
}
@@ -300,7 +319,7 @@ router.post(
async (req, res, next: NextFunction) => {
try {
if (!req.file) {
return res.status(400).json({ message: 'Flyer image file is required.' });
return sendError(res, ErrorCode.BAD_REQUEST, 'Flyer image file is required.', 400);
}
const userProfile = req.user as UserProfile | undefined;
@@ -312,12 +331,16 @@ router.post(
req.log,
);
res.status(201).json({ message: 'Flyer processed and saved successfully.', flyer: newFlyer });
sendSuccess(
res,
{ message: 'Flyer processed and saved successfully.', flyer: newFlyer },
201,
);
} catch (error) {
await cleanupUploadedFile(req.file);
if (error instanceof DuplicateFlyerError) {
req.log.warn(`Duplicate flyer upload attempt blocked.`);
return res.status(409).json({ message: error.message, flyerId: error.flyerId });
return sendError(res, ErrorCode.CONFLICT, error.message, 409, { flyerId: error.flyerId });
}
next(error);
}
@@ -336,10 +359,10 @@ router.post(
async (req, res, next: NextFunction) => {
try {
if (!req.file) {
return res.status(400).json({ message: 'Image file is required.' });
return sendError(res, ErrorCode.BAD_REQUEST, 'Image file is required.', 400);
}
req.log.info(`Server-side flyer check for file: ${req.file.originalname}`);
res.status(200).json({ is_flyer: true }); // Stubbed response
sendSuccess(res, { is_flyer: true }); // Stubbed response
} catch (error) {
next(error);
} finally {
@@ -356,10 +379,10 @@ router.post(
async (req, res, next: NextFunction) => {
try {
if (!req.file) {
return res.status(400).json({ message: 'Image file is required.' });
return sendError(res, ErrorCode.BAD_REQUEST, 'Image file is required.', 400);
}
req.log.info(`Server-side address extraction for file: ${req.file.originalname}`);
res.status(200).json({ address: 'not identified' }); // Updated stubbed response
sendSuccess(res, { address: 'not identified' }); // Updated stubbed response
} catch (error) {
next(error);
} finally {
@@ -376,10 +399,10 @@ router.post(
async (req, res, next: NextFunction) => {
try {
if (!req.files || !Array.isArray(req.files) || req.files.length === 0) {
return res.status(400).json({ message: 'Image files are required.' });
return sendError(res, ErrorCode.BAD_REQUEST, 'Image files are required.', 400);
}
req.log.info(`Server-side logo extraction for ${req.files.length} image(s).`);
res.status(200).json({ store_logo_base_64: null }); // Stubbed response
sendSuccess(res, { store_logo_base_64: null }); // Stubbed response
} catch (error) {
next(error);
} finally {
@@ -396,9 +419,7 @@ router.post(
async (req, res, next: NextFunction) => {
try {
req.log.info(`Server-side quick insights requested.`);
res
.status(200)
.json({ text: 'This is a server-generated quick insight: buy the cheap stuff!' }); // Stubbed response
sendSuccess(res, { text: 'This is a server-generated quick insight: buy the cheap stuff!' }); // Stubbed response
} catch (error) {
next(error);
}
@@ -413,9 +434,9 @@ router.post(
async (req, res, next: NextFunction) => {
try {
req.log.info(`Server-side deep dive requested.`);
res
.status(200)
.json({ text: 'This is a server-generated deep dive analysis. It is very detailed.' }); // Stubbed response
sendSuccess(res, {
text: 'This is a server-generated deep dive analysis. It is very detailed.',
}); // Stubbed response
} catch (error) {
next(error);
}
@@ -430,7 +451,7 @@ router.post(
async (req, res, next: NextFunction) => {
try {
req.log.info(`Server-side web search requested.`);
res.status(200).json({ text: 'The web says this is good.', sources: [] }); // Stubbed response
sendSuccess(res, { text: 'The web says this is good.', sources: [] }); // Stubbed response
} catch (error) {
next(error);
}
@@ -446,7 +467,7 @@ router.post(
try {
const { items } = req.body;
req.log.info(`Server-side price comparison requested for ${items.length} items.`);
res.status(200).json({
sendSuccess(res, {
text: 'This is a server-generated price comparison. Milk is cheaper at SuperMart.',
sources: [],
}); // Stubbed response
@@ -466,7 +487,7 @@ router.post(
const { items, store, userLocation } = req.body;
req.log.debug({ itemCount: items.length, storeName: store.name }, 'Trip planning requested.');
const result = await aiService.planTripWithMaps(items, store, userLocation);
res.status(200).json(result);
sendSuccess(res, result);
} catch (error) {
req.log.error({ error: errMsg(error) }, 'Error in /api/ai/plan-trip endpoint:');
next(error);
@@ -485,7 +506,7 @@ router.post(
// This endpoint is a placeholder for a future feature.
// Returning 501 Not Implemented is the correct HTTP response for this case.
req.log.info('Request received for unimplemented endpoint: /api/ai/generate-image');
res.status(501).json({ message: 'Image generation is not yet implemented.' });
sendError(res, ErrorCode.NOT_IMPLEMENTED, 'Image generation is not yet implemented.', 501);
},
);
@@ -498,7 +519,7 @@ router.post(
// This endpoint is a placeholder for a future feature.
// Returning 501 Not Implemented is the correct HTTP response for this case.
req.log.info('Request received for unimplemented endpoint: /api/ai/generate-speech');
res.status(501).json({ message: 'Speech generation is not yet implemented.' });
sendError(res, ErrorCode.NOT_IMPLEMENTED, 'Speech generation is not yet implemented.', 501);
},
);
@@ -515,7 +536,7 @@ router.post(
async (req, res, next: NextFunction) => {
try {
if (!req.file) {
return res.status(400).json({ message: 'Image file is required.' });
return sendError(res, ErrorCode.BAD_REQUEST, 'Image file is required.', 400);
}
// validateRequest transforms the cropArea JSON string into an object in req.body.
// So we use it directly instead of JSON.parse().
@@ -536,7 +557,7 @@ router.post(
req.log,
);
res.status(200).json(result);
sendSuccess(res, result);
} catch (error) {
next(error);
} finally {

View File

@@ -137,9 +137,9 @@ describe('Auth Routes (/api/auth)', () => {
});
// Assert
expect(response.status).toBe(201);
expect(response.body.message).toBe('User registered successfully!');
expect(response.body.userprofile.user.email).toBe(newUserEmail);
expect(response.body.token).toBeTypeOf('string'); // This was a duplicate, fixed.
expect(response.body.data.message).toBe('User registered successfully!');
expect(response.body.data.userprofile.user.email).toBe(newUserEmail);
expect(response.body.data.token).toBeTypeOf('string'); // This was a duplicate, fixed.
expect(mockedAuthService.registerAndLoginUser).toHaveBeenCalledWith(
newUserEmail,
strongPassword,
@@ -171,7 +171,7 @@ describe('Auth Routes (/api/auth)', () => {
// Assert
expect(response.status).toBe(201);
expect(response.body.message).toBe('User registered successfully!');
expect(response.body.data.message).toBe('User registered successfully!');
expect(mockedAuthService.registerAndLoginUser).toHaveBeenCalledWith(
email,
strongPassword,
@@ -242,7 +242,7 @@ describe('Auth Routes (/api/auth)', () => {
interface ZodError {
message: string;
}
const errorMessages = response.body.errors?.map((e: ZodError) => e.message).join(' ');
const errorMessages = response.body.error.details?.map((e: ZodError) => e.message).join(' ');
expect(errorMessages).toMatch(/Password is too weak/i);
});
@@ -260,7 +260,7 @@ describe('Auth Routes (/api/auth)', () => {
.send({ email: newUserEmail, password: strongPassword });
expect(response.status).toBe(409); // 409 Conflict
expect(response.body.message).toBe('User with that email already exists.');
expect(response.body.error.message).toBe('User with that email already exists.');
});
it('should return 500 if a generic database error occurs during registration', async () => {
@@ -272,7 +272,7 @@ describe('Auth Routes (/api/auth)', () => {
.send({ email: 'fail@test.com', password: strongPassword });
expect(response.status).toBe(500);
expect(response.body.message).toBe('DB connection lost'); // The errorHandler will forward the message
expect(response.body.error.message).toBe('DB connection lost'); // The errorHandler will forward the message
});
it('should return 400 for an invalid email format', async () => {
@@ -281,7 +281,7 @@ describe('Auth Routes (/api/auth)', () => {
.send({ email: 'not-an-email', password: strongPassword });
expect(response.status).toBe(400);
expect(response.body.errors[0].message).toBe('A valid email is required.');
expect(response.body.error.details[0].message).toBe('A valid email is required.');
});
it('should return 400 for a password that is too short', async () => {
@@ -290,7 +290,9 @@ describe('Auth Routes (/api/auth)', () => {
.send({ email: newUserEmail, password: 'short' });
expect(response.status).toBe(400);
expect(response.body.errors[0].message).toBe('Password must be at least 8 characters long.');
expect(response.body.error.details[0].message).toBe(
'Password must be at least 8 characters long.',
);
});
});
@@ -309,7 +311,7 @@ describe('Auth Routes (/api/auth)', () => {
// Assert
expect(response.status).toBe(200);
// The API now returns a nested UserProfile object
expect(response.body.userprofile).toEqual(
expect(response.body.data.userprofile).toEqual(
expect.objectContaining({
user: expect.objectContaining({
user_id: 'user-123',
@@ -317,7 +319,7 @@ describe('Auth Routes (/api/auth)', () => {
}),
}),
);
expect(response.body.token).toBeTypeOf('string');
expect(response.body.data.token).toBeTypeOf('string');
expect(response.headers['set-cookie']).toBeDefined();
});
@@ -327,7 +329,7 @@ describe('Auth Routes (/api/auth)', () => {
.send({ email: 'test@test.com', password: 'wrong_password' });
expect(response.status).toBe(401);
expect(response.body.message).toBe('Incorrect email or password.');
expect(response.body.error.message).toBe('Incorrect email or password.');
});
it('should reject login for a locked account', async () => {
@@ -336,7 +338,7 @@ describe('Auth Routes (/api/auth)', () => {
.send({ email: 'locked@test.com', password: 'password123' });
expect(response.status).toBe(401);
expect(response.body.message).toBe(
expect(response.body.error.message).toBe(
'Account is temporarily locked. Please try again in 15 minutes.',
);
});
@@ -371,7 +373,7 @@ describe('Auth Routes (/api/auth)', () => {
.send({ email: 'dberror@test.com', password: 'any_password' });
expect(response.status).toBe(500);
expect(response.body.message).toBe('Database connection failed');
expect(response.body.error.message).toBe('Database connection failed');
});
it('should log a warning when passport authentication fails without a user', async () => {
@@ -414,7 +416,7 @@ describe('Auth Routes (/api/auth)', () => {
.send({ email: 'not-an-email', password: 'password123' });
expect(response.status).toBe(400);
expect(response.body.errors[0].message).toBe('A valid email is required.');
expect(response.body.error.details[0].message).toBe('A valid email is required.');
});
it('should return 400 if password is missing', async () => {
@@ -423,7 +425,7 @@ describe('Auth Routes (/api/auth)', () => {
.send({ email: 'test@test.com' });
expect(response.status).toBe(400);
expect(response.body.errors[0].message).toBe('Password is required.');
expect(response.body.error.details[0].message).toBe('Password is required.');
});
});
@@ -439,8 +441,8 @@ describe('Auth Routes (/api/auth)', () => {
// Assert
expect(response.status).toBe(200);
expect(response.body.message).toContain('a password reset link has been sent'); // This was a duplicate, fixed.
expect(response.body.token).toBeTypeOf('string');
expect(response.body.data.message).toContain('a password reset link has been sent'); // This was a duplicate, fixed.
expect(response.body.data.token).toBeTypeOf('string');
});
it('should return a generic success message even if the user does not exist', async () => {
@@ -451,7 +453,7 @@ describe('Auth Routes (/api/auth)', () => {
.send({ email: 'nouser@test.com' });
expect(response.status).toBe(200);
expect(response.body.message).toContain('a password reset link has been sent');
expect(response.body.data.message).toContain('a password reset link has been sent');
});
it('should return 500 if the database call fails', async () => {
@@ -469,7 +471,7 @@ describe('Auth Routes (/api/auth)', () => {
.send({ email: 'invalid-email' });
expect(response.status).toBe(400);
expect(response.body.errors[0].message).toBe('A valid email is required.');
expect(response.body.error.details[0].message).toBe('A valid email is required.');
});
});
@@ -482,7 +484,7 @@ describe('Auth Routes (/api/auth)', () => {
.send({ token: 'valid-token', newPassword: 'a-Very-Strong-Password-789!' });
expect(response.status).toBe(200);
expect(response.body.message).toBe('Password has been reset successfully.');
expect(response.body.data.message).toBe('Password has been reset successfully.');
});
it('should reject with an invalid or expired token', async () => {
@@ -493,7 +495,7 @@ describe('Auth Routes (/api/auth)', () => {
.send({ token: 'invalid-token', newPassword: 'a-Very-Strong-Password-123!' }); // Use strong password to pass validation
expect(response.status).toBe(400);
expect(response.body.message).toBe('Invalid or expired password reset token.');
expect(response.body.error.message).toBe('Invalid or expired password reset token.');
});
it('should return 400 for a weak new password', async () => {
@@ -511,7 +513,7 @@ describe('Auth Routes (/api/auth)', () => {
.send({ newPassword: 'a-Very-Strong-Password-789!' });
expect(response.status).toBe(400);
expect(response.body.errors[0].message).toMatch(/Token is required|Required/i);
expect(response.body.error.details[0].message).toMatch(/Token is required|Required/i);
});
});
@@ -524,13 +526,13 @@ describe('Auth Routes (/api/auth)', () => {
.set('Cookie', 'refreshToken=valid-refresh-token');
expect(response.status).toBe(200);
expect(response.body.token).toBeTypeOf('string');
expect(response.body.data.token).toBeTypeOf('string');
});
it('should return 401 if no refresh token cookie is provided', async () => {
const response = await supertest(app).post('/api/auth/refresh-token');
expect(response.status).toBe(401);
expect(response.body.message).toBe('Refresh token not found.');
expect(response.body.error.message).toBe('Refresh token not found.');
});
it('should return 403 if refresh token is invalid', async () => {
@@ -552,7 +554,7 @@ describe('Auth Routes (/api/auth)', () => {
.post('/api/auth/refresh-token')
.set('Cookie', 'refreshToken=any-token');
expect(response.status).toBe(500);
expect(response.body.message).toMatch(/DB Error/);
expect(response.body.error.message).toMatch(/DB Error/);
});
});
@@ -568,7 +570,7 @@ describe('Auth Routes (/api/auth)', () => {
// Assert
expect(response.status).toBe(200);
expect(response.body.message).toBe('Logged out successfully.');
expect(response.body.data.message).toBe('Logged out successfully.');
// Check that the 'set-cookie' header is trying to expire the cookie
const setCookieHeader = response.headers['set-cookie'];
@@ -616,7 +618,7 @@ describe('Auth Routes (/api/auth)', () => {
});
});
describe('Rate Limiting on /forgot-password', () => {
describe('Rate Limiting on /forgot-password', () => {
it('should block requests after exceeding the limit when the opt-in header is sent', async () => {
// Arrange
const email = 'rate-limit-test@example.com';
@@ -658,7 +660,7 @@ describe('Rate Limiting on /forgot-password', () => {
expect(response.status, `Request ${i + 1} should succeed`).toBe(200);
}
});
});
});
describe('Rate Limiting on /reset-password', () => {
it('should block requests after exceeding the limit when the opt-in header is sent', async () => {

View File

@@ -23,6 +23,7 @@ import {
refreshTokenLimiter,
logoutLimiter,
} from '../config/rateLimiters';
import { sendSuccess, sendError, ErrorCode } from '../utils/apiResponse';
// All route handlers now use req.log (request-scoped logger) as per ADR-004
import { authService } from '../services/authService';
@@ -103,13 +104,19 @@ router.post(
secure: process.env.NODE_ENV === 'production',
maxAge: 7 * 24 * 60 * 60 * 1000, // 7 days
});
return res
.status(201)
.json({ message: 'User registered successfully!', userprofile: newUserProfile, token: accessToken });
return sendSuccess(
res,
{
message: 'User registered successfully!',
userprofile: newUserProfile,
token: accessToken,
},
201,
);
} catch (error: unknown) {
if (error instanceof UniqueConstraintError) {
// If the email is a duplicate, return a 409 Conflict status.
return res.status(409).json({ message: error.message });
return sendError(res, ErrorCode.CONFLICT, error.message, 409);
}
req.log.error({ error }, `User registration route failed for email: ${email}.`);
// Pass the error to the centralized handler
@@ -143,13 +150,16 @@ router.post(
return next(err);
}
if (!user) {
return res.status(401).json({ message: info.message || 'Login failed' });
return sendError(res, ErrorCode.UNAUTHORIZED, info.message || 'Login failed', 401);
}
try {
const { rememberMe } = req.body;
const userProfile = user as UserProfile;
const { accessToken, refreshToken } = await authService.handleSuccessfulLogin(userProfile, req.log);
const { accessToken, refreshToken } = await authService.handleSuccessfulLogin(
userProfile,
req.log,
);
req.log.info(`JWT and refresh token issued for user: ${userProfile.user.email}`);
const cookieOptions = {
@@ -160,7 +170,7 @@ router.post(
res.cookie('refreshToken', refreshToken, cookieOptions);
// Return the full user profile object on login to avoid a second fetch on the client.
return res.json({ userprofile: userProfile, token: accessToken });
return sendSuccess(res, { userprofile: userProfile, token: accessToken });
} catch (tokenErr) {
const email = (user as UserProfile)?.user?.email || req.body.email;
req.log.error({ error: tokenErr }, `Failed to process login for user: ${email}`);
@@ -191,7 +201,7 @@ router.post(
message: 'If an account with that email exists, a password reset link has been sent.',
};
if (process.env.NODE_ENV === 'test' && token) responsePayload.token = token;
res.status(200).json(responsePayload);
sendSuccess(res, responsePayload);
} catch (error) {
req.log.error({ error }, `An error occurred during /forgot-password for email: ${email}`);
next(error);
@@ -214,10 +224,15 @@ router.post(
const resetSuccessful = await authService.updatePassword(token, newPassword, req.log);
if (!resetSuccessful) {
return res.status(400).json({ message: 'Invalid or expired password reset token.' });
return sendError(
res,
ErrorCode.BAD_REQUEST,
'Invalid or expired password reset token.',
400,
);
}
res.status(200).json({ message: 'Password has been reset successfully.' });
sendSuccess(res, { message: 'Password has been reset successfully.' });
} catch (error) {
req.log.error({ error }, `An error occurred during password reset.`);
next(error);
@@ -226,23 +241,27 @@ router.post(
);
// New Route to refresh the access token
router.post('/refresh-token', refreshTokenLimiter, async (req: Request, res: Response, next: NextFunction) => {
const { refreshToken } = req.cookies;
if (!refreshToken) {
return res.status(401).json({ message: 'Refresh token not found.' });
}
try {
const result = await authService.refreshAccessToken(refreshToken, req.log);
if (!result) {
return res.status(403).json({ message: 'Invalid or expired refresh token.' });
router.post(
'/refresh-token',
refreshTokenLimiter,
async (req: Request, res: Response, next: NextFunction) => {
const { refreshToken } = req.cookies;
if (!refreshToken) {
return sendError(res, ErrorCode.UNAUTHORIZED, 'Refresh token not found.', 401);
}
res.json({ token: result.accessToken });
} catch (error) {
req.log.error({ error }, 'An error occurred during /refresh-token.');
next(error);
}
});
try {
const result = await authService.refreshAccessToken(refreshToken, req.log);
if (!result) {
return sendError(res, ErrorCode.FORBIDDEN, 'Invalid or expired refresh token.', 403);
}
sendSuccess(res, { token: result.accessToken });
} catch (error) {
req.log.error({ error }, 'An error occurred during /refresh-token.');
next(error);
}
},
);
/**
* POST /api/auth/logout - Logs the user out by invalidating their refresh token.
@@ -264,7 +283,7 @@ router.post('/logout', logoutLimiter, async (req: Request, res: Response) => {
maxAge: 0, // Use maxAge for modern compatibility; Express sets 'Expires' as a fallback.
secure: process.env.NODE_ENV === 'production',
});
res.status(200).json({ message: 'Logged out successfully.' });
sendSuccess(res, { message: 'Logged out successfully.' });
});
// --- OAuth Routes ---

View File

@@ -69,7 +69,11 @@ describe('Budget Routes (/api/budgets)', () => {
vi.mocked(db.budgetRepo.getSpendingByCategory).mockResolvedValue([]);
});
const app = createTestApp({ router: budgetRouter, basePath: '/api/budgets', authenticatedUser: mockUserProfile });
const app = createTestApp({
router: budgetRouter,
basePath: '/api/budgets',
authenticatedUser: mockUserProfile,
});
describe('GET /', () => {
it('should return a list of budgets for the user', async () => {
@@ -80,7 +84,7 @@ describe('Budget Routes (/api/budgets)', () => {
const response = await supertest(app).get('/api/budgets');
expect(response.status).toBe(200);
expect(response.body).toEqual(mockBudgets);
expect(response.body.data).toEqual(mockBudgets);
expect(db.budgetRepo.getBudgetsForUser).toHaveBeenCalledWith(
mockUserProfile.user.user_id,
expectLogger,
@@ -91,7 +95,7 @@ describe('Budget Routes (/api/budgets)', () => {
vi.mocked(db.budgetRepo.getBudgetsForUser).mockRejectedValue(new Error('DB Error'));
const response = await supertest(app).get('/api/budgets');
expect(response.status).toBe(500); // The custom handler will now be used
expect(response.body.message).toBe('DB Error');
expect(response.body.error.message).toBe('DB Error');
});
});
@@ -114,7 +118,7 @@ describe('Budget Routes (/api/budgets)', () => {
const response = await supertest(app).post('/api/budgets').send(newBudgetData);
expect(response.status).toBe(201);
expect(response.body).toEqual(mockCreatedBudget);
expect(response.body.data).toEqual(mockCreatedBudget);
});
it('should return 400 if the user does not exist', async () => {
@@ -129,7 +133,7 @@ describe('Budget Routes (/api/budgets)', () => {
);
const response = await supertest(app).post('/api/budgets').send(newBudgetData);
expect(response.status).toBe(400);
expect(response.body.message).toBe('User not found');
expect(response.body.error.message).toBe('User not found');
});
it('should return 500 if a generic database error occurs', async () => {
@@ -142,7 +146,7 @@ describe('Budget Routes (/api/budgets)', () => {
vi.mocked(db.budgetRepo.createBudget).mockRejectedValue(new Error('DB Error'));
const response = await supertest(app).post('/api/budgets').send(newBudgetData);
expect(response.status).toBe(500);
expect(response.body.message).toBe('DB Error');
expect(response.body.error.message).toBe('DB Error');
});
it('should return 400 for invalid budget data', async () => {
@@ -156,7 +160,7 @@ describe('Budget Routes (/api/budgets)', () => {
const response = await supertest(app).post('/api/budgets').send(invalidData);
expect(response.status).toBe(400);
expect(response.body.errors).toHaveLength(4);
expect(response.body.error.details).toHaveLength(4);
});
it('should return 400 if required fields are missing', async () => {
@@ -165,7 +169,7 @@ describe('Budget Routes (/api/budgets)', () => {
.post('/api/budgets')
.send({ amount_cents: 10000, period: 'monthly', start_date: '2024-01-01' });
expect(response.status).toBe(400);
expect(response.body.errors[0].message).toBe('Budget name is required.');
expect(response.body.error.details[0].message).toBe('Budget name is required.');
});
});
@@ -183,7 +187,7 @@ describe('Budget Routes (/api/budgets)', () => {
const response = await supertest(app).put('/api/budgets/1').send(budgetUpdates);
expect(response.status).toBe(200);
expect(response.body).toEqual(mockUpdatedBudget);
expect(response.body.data).toEqual(mockUpdatedBudget);
});
it('should return 404 if the budget is not found', async () => {
@@ -192,7 +196,7 @@ describe('Budget Routes (/api/budgets)', () => {
);
const response = await supertest(app).put('/api/budgets/999').send({ amount_cents: 1 });
expect(response.status).toBe(404);
expect(response.body.message).toBe('Budget not found');
expect(response.body.error.message).toBe('Budget not found');
});
it('should return 500 if a generic database error occurs', async () => {
@@ -200,13 +204,13 @@ describe('Budget Routes (/api/budgets)', () => {
vi.mocked(db.budgetRepo.updateBudget).mockRejectedValue(new Error('DB Error'));
const response = await supertest(app).put('/api/budgets/1').send(budgetUpdates);
expect(response.status).toBe(500); // The custom handler will now be used
expect(response.body.message).toBe('DB Error');
expect(response.body.error.message).toBe('DB Error');
});
it('should return 400 if no update fields are provided', async () => {
const response = await supertest(app).put('/api/budgets/1').send({});
expect(response.status).toBe(400);
expect(response.body.errors[0].message).toBe(
expect(response.body.error.details[0].message).toBe(
'At least one field to update must be provided.',
);
});
@@ -214,7 +218,7 @@ describe('Budget Routes (/api/budgets)', () => {
it('should return 400 for an invalid budget ID', async () => {
const response = await supertest(app).put('/api/budgets/abc').send({ amount_cents: 5000 });
expect(response.status).toBe(400);
expect(response.body.errors[0].message).toMatch(/Invalid ID|number/i);
expect(response.body.error.details[0].message).toMatch(/Invalid ID|number/i);
});
});
@@ -239,20 +243,20 @@ describe('Budget Routes (/api/budgets)', () => {
);
const response = await supertest(app).delete('/api/budgets/999');
expect(response.status).toBe(404);
expect(response.body.message).toBe('Budget not found');
expect(response.body.error.message).toBe('Budget not found');
});
it('should return 500 if a generic database error occurs', async () => {
vi.mocked(db.budgetRepo.deleteBudget).mockRejectedValue(new Error('DB Error'));
const response = await supertest(app).delete('/api/budgets/1');
expect(response.status).toBe(500); // The custom handler will now be used
expect(response.body.message).toBe('DB Error');
expect(response.body.error.message).toBe('DB Error');
});
it('should return 400 for an invalid budget ID', async () => {
const response = await supertest(app).delete('/api/budgets/abc');
expect(response.status).toBe(400);
expect(response.body.errors[0].message).toMatch(/Invalid ID|number/i);
expect(response.body.error.details[0].message).toMatch(/Invalid ID|number/i);
});
});
@@ -269,7 +273,7 @@ describe('Budget Routes (/api/budgets)', () => {
);
expect(response.status).toBe(200);
expect(response.body).toEqual(mockSpendingData);
expect(response.body.data).toEqual(mockSpendingData);
});
it('should return 500 if the database call fails', async () => {
@@ -281,7 +285,7 @@ describe('Budget Routes (/api/budgets)', () => {
);
expect(response.status).toBe(500);
expect(response.body.message).toBe('DB Error');
expect(response.body.error.message).toBe('DB Error');
});
it('should return 400 for invalid date formats', async () => {
@@ -289,14 +293,14 @@ describe('Budget Routes (/api/budgets)', () => {
'/api/budgets/spending-analysis?startDate=2024/01/01&endDate=invalid',
);
expect(response.status).toBe(400);
expect(response.body.errors).toHaveLength(2);
expect(response.body.error.details).toHaveLength(2);
});
it('should return 400 if required query parameters are missing', async () => {
const response = await supertest(app).get('/api/budgets/spending-analysis');
expect(response.status).toBe(400);
// Expect errors for both startDate and endDate
expect(response.body.errors).toHaveLength(2);
expect(response.body.error.details).toHaveLength(2);
});
});
});

View File

@@ -7,11 +7,15 @@ import type { UserProfile } from '../types';
import { validateRequest } from '../middleware/validation.middleware';
import { requiredString, numericIdParam } from '../utils/zodUtils';
import { budgetUpdateLimiter } from '../config/rateLimiters';
import { sendSuccess, sendNoContent } from '../utils/apiResponse';
const router = express.Router();
// --- Zod Schemas for Budget Routes (as per ADR-003) ---
const budgetIdParamSchema = numericIdParam('id', "Invalid ID for parameter 'id'. Must be a number.");
const budgetIdParamSchema = numericIdParam(
'id',
"Invalid ID for parameter 'id'. Must be a number.",
);
const createBudgetSchema = z.object({
body: z.object({
@@ -48,7 +52,7 @@ router.get('/', async (req: Request, res: Response, next: NextFunction) => {
const userProfile = req.user as UserProfile;
try {
const budgets = await budgetRepo.getBudgetsForUser(userProfile.user.user_id, req.log);
res.json(budgets);
sendSuccess(res, budgets);
} catch (error) {
req.log.error({ error, userId: userProfile.user.user_id }, 'Error fetching budgets');
next(error);
@@ -67,7 +71,7 @@ router.post(
const { body } = req as unknown as CreateBudgetRequest;
try {
const newBudget = await budgetRepo.createBudget(userProfile.user.user_id, body, req.log);
res.status(201).json(newBudget);
sendSuccess(res, newBudget, 201);
} catch (error: unknown) {
req.log.error({ error, userId: userProfile.user.user_id, body }, 'Error creating budget');
next(error);
@@ -92,7 +96,7 @@ router.put(
body,
req.log,
);
res.json(updatedBudget);
sendSuccess(res, updatedBudget);
} catch (error: unknown) {
req.log.error(
{ error, userId: userProfile.user.user_id, budgetId: params.id },
@@ -115,7 +119,7 @@ router.delete(
const { params } = req as unknown as DeleteBudgetRequest;
try {
await budgetRepo.deleteBudget(params.id, userProfile.user.user_id, req.log);
res.status(204).send(); // No Content
sendNoContent(res);
} catch (error: unknown) {
req.log.error(
{ error, userId: userProfile.user.user_id, budgetId: params.id },
@@ -147,7 +151,7 @@ router.get(
endDate,
req.log,
);
res.json(spendingData);
sendSuccess(res, spendingData);
} catch (error) {
req.log.error(
{ error, userId: userProfile.user.user_id, startDate, endDate },

View File

@@ -27,16 +27,14 @@ vi.mock('../services/logger.server', async () => ({
// Mock the passport middleware
vi.mock('./passport.routes', () => ({
default: {
authenticate: vi.fn(
(_strategy, _options) => (req: Request, res: Response, next: NextFunction) => {
// If req.user is not set by the test setup, simulate unauthenticated access.
if (!req.user) {
return res.status(401).json({ message: 'Unauthorized' });
}
// If req.user is set, proceed as an authenticated user.
next();
},
),
authenticate: vi.fn(() => (req: Request, res: Response, next: NextFunction) => {
// If req.user is not set by the test setup, simulate unauthenticated access.
if (!req.user) {
return res.status(401).json({ message: 'Unauthorized' });
}
// If req.user is set, proceed as an authenticated user.
next();
}),
},
}));
@@ -77,7 +75,7 @@ describe('Deals Routes (/api/users/deals)', () => {
);
expect(response.status).toBe(200);
expect(response.body).toEqual(mockDeals);
expect(response.body.data).toEqual(mockDeals);
expect(dealsRepo.findBestPricesForWatchedItems).toHaveBeenCalledWith(
mockUser.user.user_id,
expectLogger,
@@ -96,7 +94,7 @@ describe('Deals Routes (/api/users/deals)', () => {
'/api/users/deals/best-watched-prices',
);
expect(response.status).toBe(500);
expect(response.body.message).toBe('DB Error');
expect(response.body.error.message).toBe('DB Error');
expect(mockLogger.error).toHaveBeenCalledWith(
{ error: dbError },
'Error fetching best watched item deals.',

View File

@@ -6,6 +6,7 @@ import { dealsRepo } from '../services/db/deals.db';
import type { UserProfile } from '../types';
import { validateRequest } from '../middleware/validation.middleware';
import { userReadLimiter } from '../config/rateLimiters';
import { sendSuccess } from '../utils/apiResponse';
const router = express.Router();
@@ -40,7 +41,7 @@ router.get(
req.log,
);
req.log.info({ dealCount: deals.length }, 'Successfully fetched best watched item deals.');
res.status(200).json(deals);
sendSuccess(res, deals);
} catch (error) {
req.log.error({ error }, 'Error fetching best watched item deals.');
next(error); // Pass errors to the global error handler

View File

@@ -49,7 +49,7 @@ describe('Flyer Routes (/api/flyers)', () => {
const response = await supertest(app).get('/api/flyers');
expect(response.status).toBe(200);
expect(response.body).toEqual(mockFlyers);
expect(response.body.data).toEqual(mockFlyers);
// Also assert that the default limit and offset were used.
expect(db.flyerRepo.getFlyers).toHaveBeenCalledWith(expectLogger, 20, 0);
});
@@ -77,7 +77,7 @@ describe('Flyer Routes (/api/flyers)', () => {
vi.mocked(db.flyerRepo.getFlyers).mockRejectedValue(dbError);
const response = await supertest(app).get('/api/flyers');
expect(response.status).toBe(500);
expect(response.body.message).toBe('DB Error');
expect(response.body.error.message).toBe('DB Error');
expect(mockLogger.error).toHaveBeenCalledWith(
{ error: dbError },
'Error fetching flyers in /api/flyers:',
@@ -87,8 +87,8 @@ describe('Flyer Routes (/api/flyers)', () => {
it('should return 400 for invalid query parameters', async () => {
const response = await supertest(app).get('/api/flyers?limit=abc&offset=-5');
expect(response.status).toBe(400);
expect(response.body.errors).toBeDefined();
expect(response.body.errors.length).toBe(2);
expect(response.body.error.details).toBeDefined();
expect(response.body.error.details.length).toBe(2);
});
});
@@ -100,7 +100,7 @@ describe('Flyer Routes (/api/flyers)', () => {
const response = await supertest(app).get('/api/flyers/123');
expect(response.status).toBe(200);
expect(response.body).toEqual(mockFlyer);
expect(response.body.data).toEqual(mockFlyer);
expect(db.flyerRepo.getFlyerById).toHaveBeenCalledWith(123);
});
@@ -114,14 +114,14 @@ describe('Flyer Routes (/api/flyers)', () => {
const response = await supertest(app).get('/api/flyers/999');
expect(response.status).toBe(404);
expect(response.body.message).toContain('not found');
expect(response.body.error.message).toContain('not found');
});
it('should return 400 for an invalid flyer ID', async () => {
const response = await supertest(app).get('/api/flyers/abc');
expect(response.status).toBe(400);
// Zod coercion results in NaN for "abc", which triggers a type error before our custom message
expect(response.body.errors[0].message).toMatch(
expect(response.body.error.details[0].message).toMatch(
/Invalid flyer ID provided|expected number, received NaN/,
);
});
@@ -131,7 +131,7 @@ describe('Flyer Routes (/api/flyers)', () => {
vi.mocked(db.flyerRepo.getFlyerById).mockRejectedValue(dbError);
const response = await supertest(app).get('/api/flyers/123');
expect(response.status).toBe(500);
expect(response.body.message).toBe('DB Error');
expect(response.body.error.message).toBe('DB Error');
expect(mockLogger.error).toHaveBeenCalledWith(
{ error: dbError, flyerId: 123 },
'Error fetching flyer by ID:',
@@ -147,13 +147,13 @@ describe('Flyer Routes (/api/flyers)', () => {
const response = await supertest(app).get('/api/flyers/123/items');
expect(response.status).toBe(200);
expect(response.body).toEqual(mockFlyerItems);
expect(response.body.data).toEqual(mockFlyerItems);
});
it('should return 400 for an invalid flyer ID', async () => {
const response = await supertest(app).get('/api/flyers/abc/items');
expect(response.status).toBe(400);
expect(response.body.errors[0].message).toMatch(
expect(response.body.error.details[0].message).toMatch(
/Invalid flyer ID provided|expected number, received NaN/,
);
});
@@ -163,7 +163,7 @@ describe('Flyer Routes (/api/flyers)', () => {
vi.mocked(db.flyerRepo.getFlyerItems).mockRejectedValue(dbError);
const response = await supertest(app).get('/api/flyers/123/items');
expect(response.status).toBe(500);
expect(response.body.message).toBe('DB Error');
expect(response.body.error.message).toBe('DB Error');
expect(mockLogger.error).toHaveBeenCalledWith(
{ error: dbError, flyerId: 123 },
'Error fetching flyer items in /api/flyers/:id/items:',
@@ -181,7 +181,7 @@ describe('Flyer Routes (/api/flyers)', () => {
.send({ flyerIds: [1, 2] });
expect(response.status).toBe(200);
expect(response.body).toEqual(mockFlyerItems);
expect(response.body.data).toEqual(mockFlyerItems);
});
it('should return 400 if flyerIds is not an array', async () => {
@@ -189,7 +189,7 @@ describe('Flyer Routes (/api/flyers)', () => {
.post('/api/flyers/items/batch-fetch')
.send({ flyerIds: 'not-an-array' });
expect(response.status).toBe(400);
expect(response.body.errors[0].message).toMatch(/expected array/);
expect(response.body.error.details[0].message).toMatch(/expected array/);
});
it('should return 400 if flyerIds is an empty array, as per schema validation', async () => {
@@ -198,7 +198,7 @@ describe('Flyer Routes (/api/flyers)', () => {
.send({ flyerIds: [] });
expect(response.status).toBe(400);
// Check for the specific Zod error message.
expect(response.body.errors[0].message).toBe('flyerIds must be a non-empty array.');
expect(response.body.error.details[0].message).toBe('flyerIds must be a non-empty array.');
});
it('should return 500 if the database call fails', async () => {
@@ -207,7 +207,7 @@ describe('Flyer Routes (/api/flyers)', () => {
.post('/api/flyers/items/batch-fetch')
.send({ flyerIds: [1] });
expect(response.status).toBe(500);
expect(response.body.message).toBe('DB Error');
expect(response.body.error.message).toBe('DB Error');
});
});
@@ -220,7 +220,7 @@ describe('Flyer Routes (/api/flyers)', () => {
.send({ flyerIds: [1, 2, 3] });
expect(response.status).toBe(200);
expect(response.body).toEqual({ count: 42 });
expect(response.body.data).toEqual({ count: 42 });
});
it('should return 400 if flyerIds is not an array', async () => {
@@ -237,7 +237,7 @@ describe('Flyer Routes (/api/flyers)', () => {
.post('/api/flyers/items/batch-count')
.send({ flyerIds: [] });
expect(response.status).toBe(200);
expect(response.body).toEqual({ count: 0 });
expect(response.body.data).toEqual({ count: 0 });
});
it('should return 500 if the database call fails', async () => {
@@ -246,7 +246,7 @@ describe('Flyer Routes (/api/flyers)', () => {
.post('/api/flyers/items/batch-count')
.send({ flyerIds: [1] });
expect(response.status).toBe(500);
expect(response.body.message).toBe('DB Error');
expect(response.body.error.message).toBe('DB Error');
});
});
@@ -317,7 +317,7 @@ describe('Flyer Routes (/api/flyers)', () => {
const response = await supertest(app)
.get('/api/flyers')
.set('X-Test-Rate-Limit-Enable', 'true');
expect(response.status).toBe(200);
expect(response.headers).toHaveProperty('ratelimit-limit');
expect(parseInt(response.headers['ratelimit-limit'])).toBe(100);
@@ -350,7 +350,7 @@ describe('Flyer Routes (/api/flyers)', () => {
it('should apply trackingLimiter to POST /items/:itemId/track', async () => {
// Mock fire-and-forget promise
vi.mocked(db.flyerRepo.trackFlyerItemInteraction).mockResolvedValue(undefined);
const response = await supertest(app)
.post('/api/flyers/items/1/track')
.set('X-Test-Rate-Limit-Enable', 'true')

View File

@@ -4,11 +4,8 @@ import * as db from '../services/db/index.db';
import { z } from 'zod';
import { validateRequest } from '../middleware/validation.middleware';
import { optionalNumeric } from '../utils/zodUtils';
import {
publicReadLimiter,
batchLimiter,
trackingLimiter,
} from '../config/rateLimiters';
import { publicReadLimiter, batchLimiter, trackingLimiter } from '../config/rateLimiters';
import { sendSuccess } from '../utils/apiResponse';
const router = Router();
@@ -53,34 +50,44 @@ const trackItemSchema = z.object({
/**
* GET /api/flyers - Get a paginated list of all flyers.
*/
router.get('/', publicReadLimiter, validateRequest(getFlyersSchema), async (req, res, next): Promise<void> => {
try {
// The `validateRequest` middleware ensures `req.query` is valid.
// We parse it here to apply Zod's coercions (string to number) and defaults.
const { limit, offset } = getFlyersSchema.shape.query.parse(req.query);
router.get(
'/',
publicReadLimiter,
validateRequest(getFlyersSchema),
async (req, res, next): Promise<void> => {
try {
// The `validateRequest` middleware ensures `req.query` is valid.
// We parse it here to apply Zod's coercions (string to number) and defaults.
const { limit, offset } = getFlyersSchema.shape.query.parse(req.query);
const flyers = await db.flyerRepo.getFlyers(req.log, limit, offset);
res.json(flyers);
} catch (error) {
req.log.error({ error }, 'Error fetching flyers in /api/flyers:');
next(error);
}
});
const flyers = await db.flyerRepo.getFlyers(req.log, limit, offset);
sendSuccess(res, flyers);
} catch (error) {
req.log.error({ error }, 'Error fetching flyers in /api/flyers:');
next(error);
}
},
);
/**
* GET /api/flyers/:id - Get a single flyer by its ID.
*/
router.get('/:id', publicReadLimiter, validateRequest(flyerIdParamSchema), async (req, res, next): Promise<void> => {
try {
// Explicitly parse to get the coerced number type for `id`.
const { id } = flyerIdParamSchema.shape.params.parse(req.params);
const flyer = await db.flyerRepo.getFlyerById(id);
res.json(flyer);
} catch (error) {
req.log.error({ error, flyerId: req.params.id }, 'Error fetching flyer by ID:');
next(error);
}
});
router.get(
'/:id',
publicReadLimiter,
validateRequest(flyerIdParamSchema),
async (req, res, next): Promise<void> => {
try {
// Explicitly parse to get the coerced number type for `id`.
const { id } = flyerIdParamSchema.shape.params.parse(req.params);
const flyer = await db.flyerRepo.getFlyerById(id);
sendSuccess(res, flyer);
} catch (error) {
req.log.error({ error, flyerId: req.params.id }, 'Error fetching flyer by ID:');
next(error);
}
},
);
/**
* GET /api/flyers/:id/items - Get all items for a specific flyer.
@@ -90,14 +97,16 @@ router.get(
publicReadLimiter,
validateRequest(flyerIdParamSchema),
async (req, res, next): Promise<void> => {
type GetFlyerByIdRequest = z.infer<typeof flyerIdParamSchema>;
try {
// Explicitly parse to get the coerced number type for `id`.
const { id } = flyerIdParamSchema.shape.params.parse(req.params);
const items = await db.flyerRepo.getFlyerItems(id, req.log);
res.json(items);
sendSuccess(res, items);
} catch (error) {
req.log.error({ error, flyerId: req.params.id }, 'Error fetching flyer items in /api/flyers/:id/items:');
req.log.error(
{ error, flyerId: req.params.id },
'Error fetching flyer items in /api/flyers/:id/items:',
);
next(error);
}
},
@@ -117,7 +126,7 @@ router.post(
// No re-parsing needed here as `validateRequest` has already ensured the body shape,
// and `express.json()` has parsed it. There's no type coercion to apply.
const items = await db.flyerRepo.getFlyerItemsForFlyers(body.flyerIds, req.log);
res.json(items);
sendSuccess(res, items);
} catch (error) {
req.log.error({ error }, 'Error fetching batch flyer items');
next(error);
@@ -139,7 +148,7 @@ router.post(
// The schema ensures flyerIds is an array of numbers.
// The `?? []` was redundant as `validateRequest` would have already caught a missing `flyerIds`.
const count = await db.flyerRepo.countFlyerItemsForFlyers(body.flyerIds, req.log);
res.json({ count });
sendSuccess(res, { count });
} catch (error) {
req.log.error({ error }, 'Error counting batch flyer items');
next(error);
@@ -150,22 +159,27 @@ router.post(
/**
* POST /api/flyers/items/:itemId/track - Tracks a user interaction with a flyer item.
*/
router.post('/items/:itemId/track', trackingLimiter, validateRequest(trackItemSchema), (req, res, next): void => {
try {
// Explicitly parse to get coerced types.
const { params, body } = trackItemSchema.parse({ params: req.params, body: req.body });
router.post(
'/items/:itemId/track',
trackingLimiter,
validateRequest(trackItemSchema),
(req, res, next): void => {
try {
// Explicitly parse to get coerced types.
const { params, body } = trackItemSchema.parse({ params: req.params, body: req.body });
// Fire-and-forget: we don't await the tracking call to avoid delaying the response.
// We add a .catch to log any potential errors without crashing the server process.
db.flyerRepo.trackFlyerItemInteraction(params.itemId, body.type, req.log).catch((error) => {
req.log.error({ error, itemId: params.itemId }, 'Flyer item interaction tracking failed');
});
// Fire-and-forget: we don't await the tracking call to avoid delaying the response.
// We add a .catch to log any potential errors without crashing the server process.
db.flyerRepo.trackFlyerItemInteraction(params.itemId, body.type, req.log).catch((error) => {
req.log.error({ error, itemId: params.itemId }, 'Flyer item interaction tracking failed');
});
res.status(202).send();
} catch (error) {
// This will catch Zod parsing errors if they occur.
next(error);
}
});
sendSuccess(res, { message: 'Tracking accepted' }, 202);
} catch (error) {
// This will catch Zod parsing errors if they occur.
next(error);
}
},
);
export default router;

View File

@@ -8,7 +8,7 @@ import {
createMockUserAchievement,
createMockLeaderboardUser,
} from '../tests/utils/mockFactories';
import { mockLogger } from '../tests/utils/mockLogger';
import '../tests/utils/mockLogger';
import { ForeignKeyConstraintError } from '../services/db/errors.db';
import { createTestApp } from '../tests/utils/createTestApp';
@@ -98,7 +98,7 @@ describe('Gamification Routes (/api/achievements)', () => {
const response = await supertest(unauthenticatedApp).get('/api/achievements');
expect(response.status).toBe(200);
expect(response.body).toEqual(mockAchievements);
expect(response.body.data).toEqual(mockAchievements);
expect(db.gamificationRepo.getAllAchievements).toHaveBeenCalledWith(expectLogger);
});
@@ -108,7 +108,7 @@ describe('Gamification Routes (/api/achievements)', () => {
const response = await supertest(unauthenticatedApp).get('/api/achievements');
expect(response.status).toBe(500);
expect(response.body.message).toBe('DB Connection Failed');
expect(response.body.error.message).toBe('DB Connection Failed');
});
it('should return 400 if awarding an achievement to a non-existent user', async () => {
@@ -125,7 +125,7 @@ describe('Gamification Routes (/api/achievements)', () => {
.post('/api/achievements/award')
.send({ userId: 'non-existent', achievementName: 'Test Award' });
expect(response.status).toBe(400);
expect(response.body.message).toBe('User not found');
expect(response.body.error.message).toBe('User not found');
});
});
@@ -150,7 +150,7 @@ describe('Gamification Routes (/api/achievements)', () => {
const response = await supertest(authenticatedApp).get('/api/achievements/me');
expect(response.status).toBe(200);
expect(response.body).toEqual(mockUserAchievements);
expect(response.body.data).toEqual(mockUserAchievements);
expect(db.gamificationRepo.getUserAchievements).toHaveBeenCalledWith(
'user-123',
expectLogger,
@@ -167,7 +167,7 @@ describe('Gamification Routes (/api/achievements)', () => {
vi.mocked(db.gamificationRepo.getUserAchievements).mockRejectedValue(dbError);
const response = await supertest(authenticatedApp).get('/api/achievements/me');
expect(response.status).toBe(500);
expect(response.body.message).toBe('DB Error');
expect(response.body.error.message).toBe('DB Error');
});
});
@@ -207,7 +207,7 @@ describe('Gamification Routes (/api/achievements)', () => {
const response = await supertest(adminApp).post('/api/achievements/award').send(awardPayload);
expect(response.status).toBe(200);
expect(response.body.message).toContain('Successfully awarded');
expect(response.body.data.message).toContain('Successfully awarded');
expect(db.gamificationRepo.awardAchievement).toHaveBeenCalledTimes(1);
expect(db.gamificationRepo.awardAchievement).toHaveBeenCalledWith(
awardPayload.userId,
@@ -226,7 +226,7 @@ describe('Gamification Routes (/api/achievements)', () => {
const response = await supertest(adminApp).post('/api/achievements/award').send(awardPayload);
expect(response.status).toBe(500);
expect(response.body.message).toBe('DB Error');
expect(response.body.error.message).toBe('DB Error');
});
it('should return 400 for an invalid userId or achievementName', async () => {
@@ -240,7 +240,7 @@ describe('Gamification Routes (/api/achievements)', () => {
.post('/api/achievements/award')
.send({ userId: '', achievementName: '' });
expect(response.status).toBe(400);
expect(response.body.errors).toHaveLength(2);
expect(response.body.error.details).toHaveLength(2);
});
it('should return 400 if userId or achievementName are missing', async () => {
@@ -254,13 +254,13 @@ describe('Gamification Routes (/api/achievements)', () => {
.post('/api/achievements/award')
.send({ achievementName: 'Test Award' });
expect(response1.status).toBe(400);
expect(response1.body.errors[0].message).toBe('userId is required.');
expect(response1.body.error.details[0].message).toBe('userId is required.');
const response2 = await supertest(adminApp)
.post('/api/achievements/award')
.send({ userId: 'user-789' });
expect(response2.status).toBe(400);
expect(response2.body.errors[0].message).toBe('achievementName is required.');
expect(response2.body.error.details[0].message).toBe('achievementName is required.');
});
it('should return 400 if awarding an achievement to a non-existent user', async () => {
@@ -277,7 +277,7 @@ describe('Gamification Routes (/api/achievements)', () => {
.post('/api/achievements/award')
.send({ userId: 'non-existent', achievementName: 'Test Award' });
expect(response.status).toBe(400);
expect(response.body.message).toBe('User not found');
expect(response.body.error.message).toBe('User not found');
});
});
@@ -298,7 +298,7 @@ describe('Gamification Routes (/api/achievements)', () => {
);
expect(response.status).toBe(200);
expect(response.body).toEqual(mockLeaderboard);
expect(response.body.data).toEqual(mockLeaderboard);
expect(db.gamificationRepo.getLeaderboard).toHaveBeenCalledWith(5, expect.anything());
});
@@ -316,7 +316,7 @@ describe('Gamification Routes (/api/achievements)', () => {
const response = await supertest(unauthenticatedApp).get('/api/achievements/leaderboard');
expect(response.status).toBe(200);
expect(response.body).toEqual(mockLeaderboard);
expect(response.body.data).toEqual(mockLeaderboard);
expect(db.gamificationRepo.getLeaderboard).toHaveBeenCalledWith(10, expect.anything());
});
@@ -324,7 +324,7 @@ describe('Gamification Routes (/api/achievements)', () => {
vi.mocked(db.gamificationRepo.getLeaderboard).mockRejectedValue(new Error('DB Error'));
const response = await supertest(unauthenticatedApp).get('/api/achievements/leaderboard');
expect(response.status).toBe(500);
expect(response.body.message).toBe('DB Error');
expect(response.body.error.message).toBe('DB Error');
});
it('should return 400 for an invalid limit parameter', async () => {
@@ -332,8 +332,8 @@ describe('Gamification Routes (/api/achievements)', () => {
'/api/achievements/leaderboard?limit=100',
);
expect(response.status).toBe(400);
expect(response.body.errors).toBeDefined();
expect(response.body.errors[0].message).toMatch(/less than or equal to 50|Too big/i);
expect(response.body.error.details).toBeDefined();
expect(response.body.error.details[0].message).toMatch(/less than or equal to 50|Too big/i);
});
});

View File

@@ -13,11 +13,8 @@ import { validateRequest } from '../middleware/validation.middleware';
// All route handlers now use req.log (request-scoped logger) as per ADR-004
import { requiredString, optionalNumeric } from '../utils/zodUtils';
// All route handlers now use req.log (request-scoped logger) as per ADR-004
import {
publicReadLimiter,
userReadLimiter,
adminTriggerLimiter,
} from '../config/rateLimiters';
import { publicReadLimiter, userReadLimiter, adminTriggerLimiter } from '../config/rateLimiters';
import { sendSuccess } from '../utils/apiResponse';
const router = express.Router();
const adminGamificationRouter = express.Router(); // Create a new router for admin-only routes.
@@ -48,7 +45,7 @@ const awardAchievementSchema = z.object({
router.get('/', publicReadLimiter, async (req, res, next: NextFunction) => {
try {
const achievements = await gamificationService.getAllAchievements(req.log);
res.json(achievements);
sendSuccess(res, achievements);
} catch (error) {
req.log.error({ error }, 'Error fetching all achievements in /api/achievements:');
next(error);
@@ -69,7 +66,7 @@ router.get(
// We parse it here to apply Zod's coercions (string to number) and defaults.
const { limit } = leaderboardQuerySchema.parse(req.query);
const leaderboard = await gamificationService.getLeaderboard(limit!, req.log);
res.json(leaderboard);
sendSuccess(res, leaderboard);
} catch (error) {
req.log.error({ error }, 'Error fetching leaderboard:');
next(error);
@@ -94,7 +91,7 @@ router.get(
userProfile.user.user_id,
req.log,
);
res.json(userAchievements);
sendSuccess(res, userAchievements);
} catch (error) {
req.log.error(
{ error, userId: userProfile.user.user_id },
@@ -124,11 +121,9 @@ adminGamificationRouter.post(
const { body } = req as unknown as AwardAchievementRequest;
try {
await gamificationService.awardAchievement(body.userId, body.achievementName, req.log);
res
.status(200)
.json({
message: `Successfully awarded '${body.achievementName}' to user ${body.userId}.`,
});
sendSuccess(res, {
message: `Successfully awarded '${body.achievementName}' to user ${body.userId}.`,
});
} catch (error) {
next(error);
}

View File

@@ -64,7 +64,7 @@ describe('Health Routes (/api/health)', () => {
// Assert
expect(response.status).toBe(200);
expect(response.text).toBe('pong');
expect(response.body.data.message).toBe('pong');
});
});
@@ -78,10 +78,8 @@ describe('Health Routes (/api/health)', () => {
// Assert: Check for the correct status and response body.
expect(response.status).toBe(200);
expect(response.body).toEqual({
success: true,
message: 'Redis connection is healthy.',
});
expect(response.body.success).toBe(true);
expect(response.body.data.message).toBe('Redis connection is healthy.');
});
it('should return 500 if Redis ping fails', async () => {
@@ -94,7 +92,7 @@ describe('Health Routes (/api/health)', () => {
// Assert
expect(response.status).toBe(500);
expect(response.body.message).toBe('Connection timed out');
expect(response.body.error.message).toBe('Connection timed out');
});
it('should return 500 if Redis ping returns an unexpected response', async () => {
@@ -106,7 +104,7 @@ describe('Health Routes (/api/health)', () => {
// Assert
expect(response.status).toBe(500);
expect(response.body.message).toContain('Unexpected Redis ping response: OK');
expect(response.body.error.message).toContain('Unexpected Redis ping response: OK');
});
});
@@ -122,9 +120,9 @@ describe('Health Routes (/api/health)', () => {
// Assert
expect(response.status).toBe(200);
expect(response.body.currentTime).toBe('2024-03-15T10:30:00.000Z');
expect(response.body.year).toBe(2024);
expect(response.body.week).toBe(11);
expect(response.body.data.currentTime).toBe('2024-03-15T10:30:00.000Z');
expect(response.body.data.year).toBe(2024);
expect(response.body.data.week).toBe(11);
});
});
@@ -139,7 +137,7 @@ describe('Health Routes (/api/health)', () => {
// Assert
expect(response.status).toBe(200);
expect(response.body.success).toBe(true);
expect(response.body.message).toBe('All required database tables exist.');
expect(response.body.data.message).toBe('All required database tables exist.');
});
it('should return 500 if tables are missing', async () => {
@@ -149,7 +147,9 @@ describe('Health Routes (/api/health)', () => {
const response = await supertest(app).get('/api/health/db-schema');
expect(response.status).toBe(500);
expect(response.body.message).toContain('Missing tables: missing_table_1, missing_table_2');
expect(response.body.error.message).toContain(
'Missing tables: missing_table_1, missing_table_2',
);
// The error is passed to next(), so the global error handler would log it, not the route handler itself.
});
@@ -161,10 +161,12 @@ describe('Health Routes (/api/health)', () => {
const response = await supertest(app).get('/api/health/db-schema');
expect(response.status).toBe(500);
expect(response.body.message).toBe('DB connection failed'); // This is the message from the original error
expect(response.body.stack).toBeDefined();
expect(response.body.errorId).toEqual(expect.any(String));
console.log('[DEBUG] health.routes.test.ts: Verifying logger.error for DB schema check failure');
expect(response.body.error.message).toBe('DB connection failed'); // This is the message from the original error
expect(response.body.error.details.stack).toBeDefined();
expect(response.body.meta.requestId).toEqual(expect.any(String));
console.log(
'[DEBUG] health.routes.test.ts: Verifying logger.error for DB schema check failure',
);
expect(mockLogger.error).toHaveBeenCalledWith(
expect.objectContaining({
err: expect.any(Error),
@@ -181,8 +183,8 @@ describe('Health Routes (/api/health)', () => {
const response = await supertest(app).get('/api/health/db-schema');
expect(response.status).toBe(500);
expect(response.body.message).toBe('DB connection failed'); // This is the message from the original error
expect(response.body.errorId).toEqual(expect.any(String));
expect(response.body.error.message).toBe('DB connection failed'); // This is the message from the original error
expect(response.body.meta.requestId).toEqual(expect.any(String));
expect(mockLogger.error).toHaveBeenCalledWith(
expect.objectContaining({
err: expect.objectContaining({ message: 'DB connection failed' }),
@@ -203,7 +205,7 @@ describe('Health Routes (/api/health)', () => {
// Assert
expect(response.status).toBe(200);
expect(response.body.success).toBe(true);
expect(response.body.message).toContain('is accessible and writable');
expect(response.body.data.message).toContain('is accessible and writable');
});
it('should return 500 if storage is not accessible or writable', async () => {
@@ -216,7 +218,7 @@ describe('Health Routes (/api/health)', () => {
// Assert
expect(response.status).toBe(500);
expect(response.body.message).toContain('Storage check failed.');
expect(response.body.error.message).toContain('Storage check failed.');
expect(mockLogger.error).toHaveBeenCalledWith(
expect.objectContaining({
err: expect.any(Error),
@@ -235,7 +237,7 @@ describe('Health Routes (/api/health)', () => {
// Assert
expect(response.status).toBe(500);
expect(response.body.message).toContain('Storage check failed.');
expect(response.body.error.message).toContain('Storage check failed.');
expect(mockLogger.error).toHaveBeenCalledWith(
expect.objectContaining({
err: expect.any(Error),
@@ -260,7 +262,7 @@ describe('Health Routes (/api/health)', () => {
// Assert
expect(response.status).toBe(200);
expect(response.body.success).toBe(true);
expect(response.body.message).toContain('Pool Status: 10 total, 8 idle, 1 waiting.');
expect(response.body.data.message).toContain('Pool Status: 10 total, 8 idle, 1 waiting.');
});
it('should return 500 for an unhealthy pool status', async () => {
@@ -277,8 +279,8 @@ describe('Health Routes (/api/health)', () => {
// Assert
expect(response.status).toBe(500);
expect(response.body.success).toBe(false);
expect(response.body.message).toContain('Pool may be under stress.');
expect(response.body.message).toContain('Pool Status: 20 total, 5 idle, 15 waiting.');
expect(response.body.error.message).toContain('Pool may be under stress.');
expect(response.body.error.message).toContain('Pool Status: 20 total, 5 idle, 15 waiting.');
expect(logger.warn).toHaveBeenCalledWith(
'Database pool health check shows high waiting count: 15',
);
@@ -295,8 +297,8 @@ describe('Health Routes (/api/health)', () => {
const response = await supertest(app).get('/api/health/db-pool');
expect(response.status).toBe(500);
expect(response.body.message).toBe('Pool is not initialized'); // This is the message from the original error
expect(response.body.errorId).toEqual(expect.any(String));
expect(response.body.error.message).toBe('Pool is not initialized'); // This is the message from the original error
expect(response.body.meta.requestId).toEqual(expect.any(String));
expect(mockLogger.error).toHaveBeenCalledWith(
expect.objectContaining({
err: expect.any(Error),
@@ -315,9 +317,9 @@ describe('Health Routes (/api/health)', () => {
const response = await supertest(app).get('/api/health/db-pool');
expect(response.status).toBe(500);
expect(response.body.message).toBe('Pool is not initialized'); // This is the message from the original error
expect(response.body.stack).toBeDefined();
expect(response.body.errorId).toEqual(expect.any(String));
expect(response.body.error.message).toBe('Pool is not initialized'); // This is the message from the original error
expect(response.body.error.details.stack).toBeDefined();
expect(response.body.meta.requestId).toEqual(expect.any(String));
expect(mockLogger.error).toHaveBeenCalledWith(
expect.objectContaining({
err: expect.objectContaining({ message: 'Pool is not initialized' }),
@@ -334,10 +336,12 @@ describe('Health Routes (/api/health)', () => {
const response = await supertest(app).get('/api/health/redis');
expect(response.status).toBe(500);
expect(response.body.message).toBe('Connection timed out');
expect(response.body.stack).toBeDefined();
expect(response.body.errorId).toEqual(expect.any(String));
console.log('[DEBUG] health.routes.test.ts: Checking if logger.error was called with the correct pattern');
expect(response.body.error.message).toBe('Connection timed out');
expect(response.body.error.details.stack).toBeDefined();
expect(response.body.meta.requestId).toEqual(expect.any(String));
console.log(
'[DEBUG] health.routes.test.ts: Checking if logger.error was called with the correct pattern',
);
expect(mockLogger.error).toHaveBeenCalledWith(
expect.objectContaining({
err: expect.any(Error),
@@ -352,9 +356,9 @@ describe('Health Routes (/api/health)', () => {
const response = await supertest(app).get('/api/health/redis');
expect(response.status).toBe(500);
expect(response.body.message).toContain('Unexpected Redis ping response: OK');
expect(response.body.stack).toBeDefined();
expect(response.body.errorId).toEqual(expect.any(String));
expect(response.body.error.message).toContain('Unexpected Redis ping response: OK');
expect(response.body.error.details.stack).toBeDefined();
expect(response.body.meta.requestId).toEqual(expect.any(String));
expect(mockLogger.error).toHaveBeenCalledWith(
expect.objectContaining({
err: expect.any(Error),

View File

@@ -14,6 +14,7 @@ import { connection as redisConnection } from '../services/queueService.server';
import fs from 'node:fs/promises';
import { getSimpleWeekAndYear } from '../utils/dateUtils';
import { validateRequest } from '../middleware/validation.middleware';
import { sendSuccess, sendError, ErrorCode } from '../utils/apiResponse';
const router = Router();
@@ -129,7 +130,7 @@ const emptySchema = z.object({});
* GET /api/health/ping - A simple endpoint to check if the server is responsive.
*/
router.get('/ping', validateRequest(emptySchema), (_req: Request, res: Response) => {
res.status(200).send('pong');
return sendSuccess(res, { message: 'pong' });
});
// =============================================================================
@@ -146,7 +147,7 @@ router.get('/ping', validateRequest(emptySchema), (_req: Request, res: Response)
* It only checks that the Node.js process can handle HTTP requests.
*/
router.get('/live', validateRequest(emptySchema), (_req: Request, res: Response) => {
res.status(200).json({
return sendSuccess(res, {
status: 'ok',
timestamp: new Date().toISOString(),
});
@@ -198,9 +199,10 @@ router.get('/ready', validateRequest(emptySchema), async (req: Request, res: Res
// Return appropriate HTTP status code
// 200 = healthy or degraded (can still handle traffic)
// 503 = unhealthy (should not receive traffic)
const httpStatus = overallStatus === 'unhealthy' ? 503 : 200;
return res.status(httpStatus).json(response);
if (overallStatus === 'unhealthy') {
return sendError(res, ErrorCode.SERVICE_UNAVAILABLE, 'Service unhealthy', 503, response);
}
return sendSuccess(res, response);
});
/**
@@ -216,14 +218,13 @@ router.get('/startup', validateRequest(emptySchema), async (req: Request, res: R
const database = await checkDatabase();
if (database.status === 'unhealthy') {
return res.status(503).json({
return sendError(res, ErrorCode.SERVICE_UNAVAILABLE, 'Waiting for database connection', 503, {
status: 'starting',
message: 'Waiting for database connection',
database,
});
}
return res.status(200).json({
return sendSuccess(res, {
status: 'started',
timestamp: new Date().toISOString(),
database,
@@ -245,7 +246,7 @@ router.get('/db-schema', validateRequest(emptySchema), async (req, res, next: Ne
new Error(`Database schema check failed. Missing tables: ${missingTables.join(', ')}.`),
);
}
return res.status(200).json({ success: true, message: 'All required database tables exist.' });
return sendSuccess(res, { message: 'All required database tables exist.' });
} catch (error: unknown) {
if (error instanceof Error) {
return next(error);
@@ -266,8 +267,7 @@ router.get('/storage', validateRequest(emptySchema), async (req, res, next: Next
process.env.STORAGE_PATH || '/var/www/flyer-crawler.projectium.com/flyer-images';
try {
await fs.access(storagePath, fs.constants.W_OK); // Use fs.promises
return res.status(200).json({
success: true,
return sendSuccess(res, {
message: `Storage directory '${storagePath}' is accessible and writable.`,
});
} catch {
@@ -293,12 +293,16 @@ router.get(
const message = `Pool Status: ${status.totalCount} total, ${status.idleCount} idle, ${status.waitingCount} waiting.`;
if (isHealthy) {
return res.status(200).json({ success: true, message });
return sendSuccess(res, { message, ...status });
} else {
req.log.warn(`Database pool health check shows high waiting count: ${status.waitingCount}`);
return res
.status(500)
.json({ success: false, message: `Pool may be under stress. ${message}` });
return sendError(
res,
ErrorCode.INTERNAL_ERROR,
`Pool may be under stress. ${message}`,
500,
status,
);
}
} catch (error: unknown) {
if (error instanceof Error) {
@@ -319,7 +323,7 @@ router.get(
router.get('/time', validateRequest(emptySchema), (req: Request, res: Response) => {
const now = new Date();
const { year, week } = getSimpleWeekAndYear(now);
res.json({
return sendSuccess(res, {
currentTime: now.toISOString(),
year,
week,
@@ -336,7 +340,7 @@ router.get(
try {
const reply = await redisConnection.ping();
if (reply === 'PONG') {
return res.status(200).json({ success: true, message: 'Redis connection is healthy.' });
return sendSuccess(res, { message: 'Redis connection is healthy.' });
}
throw new Error(`Unexpected Redis ping response: ${reply}`); // This will be caught below
} catch (error: unknown) {

View File

@@ -40,18 +40,22 @@ describe('Personalization Routes (/api/personalization)', () => {
const mockItems = [createMockMasterGroceryItem({ master_grocery_item_id: 1, name: 'Milk' })];
vi.mocked(db.personalizationRepo.getAllMasterItems).mockResolvedValue(mockItems);
const response = await supertest(app).get('/api/personalization/master-items').set('x-test-rate-limit-enable', 'true');
const response = await supertest(app)
.get('/api/personalization/master-items')
.set('x-test-rate-limit-enable', 'true');
expect(response.status).toBe(200);
expect(response.body).toEqual(mockItems);
expect(response.body.data).toEqual(mockItems);
});
it('should return 500 if the database call fails', async () => {
const dbError = new Error('DB Error');
vi.mocked(db.personalizationRepo.getAllMasterItems).mockRejectedValue(dbError);
const response = await supertest(app).get('/api/personalization/master-items').set('x-test-rate-limit-enable', 'true');
const response = await supertest(app)
.get('/api/personalization/master-items')
.set('x-test-rate-limit-enable', 'true');
expect(response.status).toBe(500);
expect(response.body.message).toBe('DB Error');
expect(response.body.error.message).toBe('DB Error');
expect(mockLogger.error).toHaveBeenCalledWith(
{ error: dbError },
'Error fetching master items in /api/personalization/master-items:',
@@ -67,7 +71,7 @@ describe('Personalization Routes (/api/personalization)', () => {
const response = await supertest(app).get('/api/personalization/dietary-restrictions');
expect(response.status).toBe(200);
expect(response.body).toEqual(mockRestrictions);
expect(response.body.data).toEqual(mockRestrictions);
});
it('should return 500 if the database call fails', async () => {
@@ -75,7 +79,7 @@ describe('Personalization Routes (/api/personalization)', () => {
vi.mocked(db.personalizationRepo.getDietaryRestrictions).mockRejectedValue(dbError);
const response = await supertest(app).get('/api/personalization/dietary-restrictions');
expect(response.status).toBe(500);
expect(response.body.message).toBe('DB Error');
expect(response.body.error.message).toBe('DB Error');
expect(mockLogger.error).toHaveBeenCalledWith(
{ error: dbError },
'Error fetching dietary restrictions in /api/personalization/dietary-restrictions:',
@@ -91,7 +95,7 @@ describe('Personalization Routes (/api/personalization)', () => {
const response = await supertest(app).get('/api/personalization/appliances');
expect(response.status).toBe(200);
expect(response.body).toEqual(mockAppliances);
expect(response.body.data).toEqual(mockAppliances);
});
it('should return 500 if the database call fails', async () => {
@@ -99,7 +103,7 @@ describe('Personalization Routes (/api/personalization)', () => {
vi.mocked(db.personalizationRepo.getAppliances).mockRejectedValue(dbError);
const response = await supertest(app).get('/api/personalization/appliances');
expect(response.status).toBe(500);
expect(response.body.message).toBe('DB Error');
expect(response.body.error.message).toBe('DB Error');
expect(mockLogger.error).toHaveBeenCalledWith(
{ error: dbError },
'Error fetching appliances in /api/personalization/appliances:',

View File

@@ -4,6 +4,7 @@ import { z } from 'zod';
import * as db from '../services/db/index.db';
import { validateRequest } from '../middleware/validation.middleware';
import { publicReadLimiter } from '../config/rateLimiters';
import { sendSuccess } from '../utils/apiResponse';
const router = Router();
@@ -28,7 +29,7 @@ router.get(
res.set('Cache-Control', 'public, max-age=3600');
const masterItems = await db.personalizationRepo.getAllMasterItems(req.log);
res.json(masterItems);
sendSuccess(res, masterItems);
} catch (error) {
req.log.error({ error }, 'Error fetching master items in /api/personalization/master-items:');
next(error);
@@ -46,7 +47,7 @@ router.get(
async (req: Request, res: Response, next: NextFunction) => {
try {
const restrictions = await db.personalizationRepo.getDietaryRestrictions(req.log);
res.json(restrictions);
sendSuccess(res, restrictions);
} catch (error) {
req.log.error(
{ error },
@@ -67,7 +68,7 @@ router.get(
async (req: Request, res: Response, next: NextFunction) => {
try {
const appliances = await db.personalizationRepo.getAppliances(req.log);
res.json(appliances);
sendSuccess(res, appliances);
} catch (error) {
req.log.error({ error }, 'Error fetching appliances in /api/personalization/appliances:');
next(error);

View File

@@ -22,16 +22,14 @@ vi.mock('../services/logger.server', async () => ({
// Mock the passport middleware
vi.mock('./passport.routes', () => ({
default: {
authenticate: vi.fn(
(_strategy, _options) => (req: Request, res: Response, next: NextFunction) => {
// If req.user is not set by the test setup, simulate unauthenticated access.
if (!req.user) {
return res.status(401).json({ message: 'Unauthorized' });
}
// If req.user is set, proceed as an authenticated user.
next();
},
),
authenticate: vi.fn(() => (req: Request, res: Response, next: NextFunction) => {
// If req.user is not set by the test setup, simulate unauthenticated access.
if (!req.user) {
return res.status(401).json({ message: 'Unauthorized' });
}
// If req.user is set, proceed as an authenticated user.
next();
}),
},
}));
@@ -41,7 +39,11 @@ import { priceRepo } from '../services/db/price.db';
describe('Price Routes (/api/price-history)', () => {
const mockUser = createMockUserProfile({ user: { user_id: 'price-user-123' } });
const app = createTestApp({ router: priceRouter, basePath: '/api/price-history', authenticatedUser: mockUser });
const app = createTestApp({
router: priceRouter,
basePath: '/api/price-history',
authenticatedUser: mockUser,
});
beforeEach(() => {
vi.clearAllMocks();
});
@@ -59,7 +61,7 @@ describe('Price Routes (/api/price-history)', () => {
.send({ masterItemIds: [1, 2] });
expect(response.status).toBe(200);
expect(response.body).toEqual(mockHistory);
expect(response.body.data).toEqual(mockHistory);
expect(priceRepo.getPriceHistory).toHaveBeenCalledWith([1, 2], expect.any(Object), 1000, 0);
});
@@ -69,12 +71,7 @@ describe('Price Routes (/api/price-history)', () => {
.post('/api/price-history')
.send({ masterItemIds: [1, 2, 3], limit: 50, offset: 10 });
expect(priceRepo.getPriceHistory).toHaveBeenCalledWith(
[1, 2, 3],
expect.any(Object),
50,
10,
);
expect(priceRepo.getPriceHistory).toHaveBeenCalledWith([1, 2, 3], expect.any(Object), 50, 10);
});
it('should log the request info', async () => {
@@ -98,14 +95,14 @@ describe('Price Routes (/api/price-history)', () => {
.send({ masterItemIds: [1, 2, 3] });
expect(response.status).toBe(500);
expect(response.body.message).toBe('Database connection failed');
expect(response.body.error.message).toBe('Database connection failed');
});
it('should return 400 if masterItemIds is an empty array', async () => {
const response = await supertest(app).post('/api/price-history').send({ masterItemIds: [] });
expect(response.status).toBe(400);
expect(response.body.errors[0].message).toBe(
expect(response.body.error.details[0].message).toBe(
'masterItemIds must be a non-empty array of positive integers.',
);
});
@@ -117,7 +114,9 @@ describe('Price Routes (/api/price-history)', () => {
expect(response.status).toBe(400);
// The actual message is "Invalid input: expected array, received string"
expect(response.body.errors[0].message).toBe('Invalid input: expected array, received string');
expect(response.body.error.details[0].message).toBe(
'Invalid input: expected array, received string',
);
});
it('should return 400 if masterItemIds contains non-positive integers', async () => {
@@ -126,7 +125,7 @@ describe('Price Routes (/api/price-history)', () => {
.send({ masterItemIds: [1, -2, 3] });
expect(response.status).toBe(400);
expect(response.body.errors[0].message).toBe('Number must be greater than 0');
expect(response.body.error.details[0].message).toBe('Number must be greater than 0');
});
it('should return 400 if masterItemIds is missing', async () => {
@@ -134,7 +133,9 @@ describe('Price Routes (/api/price-history)', () => {
expect(response.status).toBe(400);
// The actual message is "Invalid input: expected array, received undefined"
expect(response.body.errors[0].message).toBe('Invalid input: expected array, received undefined');
expect(response.body.error.details[0].message).toBe(
'Invalid input: expected array, received undefined',
);
});
it('should return 400 for invalid limit and offset', async () => {
@@ -143,10 +144,12 @@ describe('Price Routes (/api/price-history)', () => {
.send({ masterItemIds: [1], limit: -1, offset: 'abc' });
expect(response.status).toBe(400);
expect(response.body.errors).toHaveLength(2);
expect(response.body.error.details).toHaveLength(2);
// The actual message is "Too small: expected number to be >0"
expect(response.body.errors[0].message).toBe('Too small: expected number to be >0');
expect(response.body.errors[1].message).toBe('Invalid input: expected number, received NaN');
expect(response.body.error.details[0].message).toBe('Too small: expected number to be >0');
expect(response.body.error.details[1].message).toBe(
'Invalid input: expected number, received NaN',
);
});
});

View File

@@ -6,16 +6,15 @@ import { validateRequest } from '../middleware/validation.middleware';
import { priceRepo } from '../services/db/price.db';
import { optionalNumeric } from '../utils/zodUtils';
import { priceHistoryLimiter } from '../config/rateLimiters';
import { sendSuccess } from '../utils/apiResponse';
const router = Router();
const priceHistorySchema = z.object({
body: z.object({
masterItemIds: z
.array(z.number().int().positive('Number must be greater than 0'))
.nonempty({
message: 'masterItemIds must be a non-empty array of positive integers.',
}),
masterItemIds: z.array(z.number().int().positive('Number must be greater than 0')).nonempty({
message: 'masterItemIds must be a non-empty array of positive integers.',
}),
limit: optionalNumeric({ default: 1000, integer: true, positive: true }),
offset: optionalNumeric({ default: 0, integer: true, nonnegative: true }),
}),
@@ -44,7 +43,7 @@ router.post(
);
try {
const priceHistory = await priceRepo.getPriceHistory(masterItemIds, req.log, limit, offset);
res.status(200).json(priceHistory);
sendSuccess(res, priceHistory);
} catch (error) {
next(error);
}

View File

@@ -1,7 +1,9 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import supertest from 'supertest';
import type { Request, Response, NextFunction } from 'express';
import { createTestApp } from '../tests/utils/createTestApp';
import { createMockUserProfile } from '../tests/utils/mockFactories';
import type { UserReaction } from '../types';
// 1. Mock the Service Layer directly.
vi.mock('../services/db/index.db', () => ({
@@ -20,15 +22,13 @@ vi.mock('../services/logger.server', async () => ({
// Mock Passport middleware
vi.mock('./passport.routes', () => ({
default: {
authenticate: vi.fn(
() => (req: any, res: any, next: any) => {
// If we are testing the unauthenticated state (no user injected), simulate 401.
if (!req.user) {
return res.status(401).json({ message: 'Unauthorized' });
}
next();
},
),
authenticate: vi.fn(() => (req: Request, res: Response, next: NextFunction) => {
// If we are testing the unauthenticated state (no user injected), simulate 401.
if (!req.user) {
return res.status(401).json({ message: 'Unauthorized' });
}
next();
}),
},
}));
@@ -51,20 +51,22 @@ describe('Reaction Routes (/api/reactions)', () => {
const app = createTestApp({ router: reactionsRouter, basePath: '/api/reactions' });
it('should return a list of reactions', async () => {
const mockReactions = [{ id: 1, reaction_type: 'like', entity_id: '123' }];
vi.mocked(reactionRepo.getReactions).mockResolvedValue(mockReactions as any);
const mockReactions = [
{ id: 1, reaction_type: 'like', entity_id: '123' },
] as Partial<UserReaction>[];
vi.mocked(reactionRepo.getReactions).mockResolvedValue(mockReactions as UserReaction[]);
const response = await supertest(app).get('/api/reactions');
expect(response.status).toBe(200);
expect(response.body).toEqual(mockReactions);
expect(response.body.data).toEqual(mockReactions);
expect(reactionRepo.getReactions).toHaveBeenCalledWith({}, expectLogger);
});
it('should filter by query parameters', async () => {
const mockReactions = [{ id: 1, reaction_type: 'like' }];
vi.mocked(reactionRepo.getReactions).mockResolvedValue(mockReactions as any);
const mockReactions = [{ id: 1, reaction_type: 'like' }] as Partial<UserReaction>[];
vi.mocked(reactionRepo.getReactions).mockResolvedValue(mockReactions as UserReaction[]);
const validUuid = '123e4567-e89b-12d3-a456-426614174000';
const query = { userId: validUuid, entityType: 'recipe', entityId: '1' };
@@ -73,7 +75,7 @@ describe('Reaction Routes (/api/reactions)', () => {
expect(response.status).toBe(200);
expect(reactionRepo.getReactions).toHaveBeenCalledWith(
expect.objectContaining(query),
expectLogger
expectLogger,
);
});
@@ -84,10 +86,7 @@ describe('Reaction Routes (/api/reactions)', () => {
const response = await supertest(app).get('/api/reactions');
expect(response.status).toBe(500);
expect(mockLogger.error).toHaveBeenCalledWith(
{ error },
'Error fetching user reactions'
);
expect(mockLogger.error).toHaveBeenCalledWith({ error }, 'Error fetching user reactions');
});
});
@@ -95,26 +94,25 @@ describe('Reaction Routes (/api/reactions)', () => {
const app = createTestApp({ router: reactionsRouter, basePath: '/api/reactions' });
it('should return reaction summary for an entity', async () => {
const mockSummary = { like: 10, love: 5 };
vi.mocked(reactionRepo.getReactionSummary).mockResolvedValue(mockSummary as any);
const mockSummary = [
{ reaction_type: 'like', count: 10 },
{ reaction_type: 'love', count: 5 },
];
vi.mocked(reactionRepo.getReactionSummary).mockResolvedValue(mockSummary);
const response = await supertest(app)
.get('/api/reactions/summary')
.query({ entityType: 'recipe', entityId: '123' });
expect(response.status).toBe(200);
expect(response.body).toEqual(mockSummary);
expect(reactionRepo.getReactionSummary).toHaveBeenCalledWith(
'recipe',
'123',
expectLogger
);
expect(response.body.data).toEqual(mockSummary);
expect(reactionRepo.getReactionSummary).toHaveBeenCalledWith('recipe', '123', expectLogger);
});
it('should return 400 if required parameters are missing', async () => {
const response = await supertest(app).get('/api/reactions/summary');
expect(response.status).toBe(400);
expect(response.body.errors[0].message).toContain('required');
expect(response.body.error.details[0].message).toContain('required');
});
it('should return 500 on database error', async () => {
@@ -126,10 +124,7 @@ describe('Reaction Routes (/api/reactions)', () => {
.query({ entityType: 'recipe', entityId: '123' });
expect(response.status).toBe(500);
expect(mockLogger.error).toHaveBeenCalledWith(
{ error },
'Error fetching reaction summary'
);
expect(mockLogger.error).toHaveBeenCalledWith({ error }, 'Error fetching reaction summary');
});
});
@@ -148,18 +143,16 @@ describe('Reaction Routes (/api/reactions)', () => {
};
it('should return 201 when a reaction is added', async () => {
const mockResult = { ...validBody, id: 1, user_id: 'user-123' };
vi.mocked(reactionRepo.toggleReaction).mockResolvedValue(mockResult as any);
const mockResult = { ...validBody, id: 1, user_id: 'user-123' } as Partial<UserReaction>;
vi.mocked(reactionRepo.toggleReaction).mockResolvedValue(mockResult as UserReaction);
const response = await supertest(app)
.post('/api/reactions/toggle')
.send(validBody);
const response = await supertest(app).post('/api/reactions/toggle').send(validBody);
expect(response.status).toBe(201);
expect(response.body).toEqual({ message: 'Reaction added.', reaction: mockResult });
expect(response.body.data).toEqual({ message: 'Reaction added.', reaction: mockResult });
expect(reactionRepo.toggleReaction).toHaveBeenCalledWith(
{ user_id: 'user-123', ...validBody },
expectLogger
expectLogger,
);
});
@@ -167,12 +160,10 @@ describe('Reaction Routes (/api/reactions)', () => {
// Returning null/false from toggleReaction implies the reaction was removed
vi.mocked(reactionRepo.toggleReaction).mockResolvedValue(null);
const response = await supertest(app)
.post('/api/reactions/toggle')
.send(validBody);
const response = await supertest(app).post('/api/reactions/toggle').send(validBody);
expect(response.status).toBe(200);
expect(response.body).toEqual({ message: 'Reaction removed.' });
expect(response.body.data).toEqual({ message: 'Reaction removed.' });
});
it('should return 400 if body is invalid', async () => {
@@ -181,14 +172,12 @@ describe('Reaction Routes (/api/reactions)', () => {
.send({ entity_type: 'recipe' }); // Missing other required fields
expect(response.status).toBe(400);
expect(response.body.errors).toBeDefined();
expect(response.body.error.details).toBeDefined();
});
it('should return 401 if not authenticated', async () => {
const unauthApp = createTestApp({ router: reactionsRouter, basePath: '/api/reactions' });
const response = await supertest(unauthApp)
.post('/api/reactions/toggle')
.send(validBody);
const response = await supertest(unauthApp).post('/api/reactions/toggle').send(validBody);
expect(response.status).toBe(401);
});
@@ -197,14 +186,12 @@ describe('Reaction Routes (/api/reactions)', () => {
const error = new Error('DB Error');
vi.mocked(reactionRepo.toggleReaction).mockRejectedValue(error);
const response = await supertest(app)
.post('/api/reactions/toggle')
.send(validBody);
const response = await supertest(app).post('/api/reactions/toggle').send(validBody);
expect(response.status).toBe(500);
expect(mockLogger.error).toHaveBeenCalledWith(
{ error, body: validBody },
'Error toggling user reaction'
'Error toggling user reaction',
);
});
});
@@ -240,4 +227,4 @@ describe('Reaction Routes (/api/reactions)', () => {
expect(parseInt(response.headers['ratelimit-limit'])).toBe(150);
});
});
});
});

View File

@@ -6,6 +6,7 @@ import passport from './passport.routes';
import { requiredString } from '../utils/zodUtils';
import { UserProfile } from '../types';
import { publicReadLimiter, reactionToggleLimiter } from '../config/rateLimiters';
import { sendSuccess } from '../utils/apiResponse';
const router = Router();
@@ -49,7 +50,7 @@ router.get(
try {
const { query } = getReactionsSchema.parse({ query: req.query });
const reactions = await reactionRepo.getReactions(query, req.log);
res.json(reactions);
sendSuccess(res, reactions);
} catch (error) {
req.log.error({ error }, 'Error fetching user reactions');
next(error);
@@ -69,8 +70,12 @@ router.get(
async (req: Request, res: Response, next: NextFunction) => {
try {
const { query } = getReactionSummarySchema.parse({ query: req.query });
const summary = await reactionRepo.getReactionSummary(query.entityType, query.entityId, req.log);
res.json(summary);
const summary = await reactionRepo.getReactionSummary(
query.entityType,
query.entityId,
req.log,
);
sendSuccess(res, summary);
} catch (error) {
req.log.error({ error }, 'Error fetching reaction summary');
next(error);
@@ -99,9 +104,9 @@ router.post(
};
const result = await reactionRepo.toggleReaction(reactionData, req.log);
if (result) {
res.status(201).json({ message: 'Reaction added.', reaction: result });
sendSuccess(res, { message: 'Reaction added.', reaction: result }, 201);
} else {
res.status(200).json({ message: 'Reaction removed.' });
sendSuccess(res, { message: 'Reaction removed.' });
}
} catch (error) {
req.log.error({ error, body }, 'Error toggling user reaction');
@@ -110,4 +115,4 @@ router.post(
},
);
export default router;
export default router;

View File

@@ -1,7 +1,12 @@
// src/routes/recipe.routes.test.ts
import { describe, it, expect, vi, beforeEach } from 'vitest';
import supertest from 'supertest';
import { createMockRecipe, createMockRecipeComment, createMockUserProfile } from '../tests/utils/mockFactories';
import type { Request, Response, NextFunction } from 'express';
import {
createMockRecipe,
createMockRecipeComment,
createMockUserProfile,
} from '../tests/utils/mockFactories';
import { NotFoundError } from '../services/db/errors.db';
import { createTestApp } from '../tests/utils/createTestApp';
@@ -26,14 +31,12 @@ vi.mock('../services/aiService.server', () => ({
// Mock Passport
vi.mock('./passport.routes', () => ({
default: {
authenticate: vi.fn(
() => (req: any, res: any, next: any) => {
if (!req.user) {
return res.status(401).json({ message: 'Unauthorized' });
}
next();
},
),
authenticate: vi.fn(() => (req: Request, res: Response, next: NextFunction) => {
if (!req.user) {
return res.status(401).json({ message: 'Unauthorized' });
}
next();
}),
},
}));
@@ -70,7 +73,7 @@ describe('Recipe Routes (/api/recipes)', () => {
const response = await supertest(app).get('/api/recipes/by-sale-percentage?minPercentage=75');
expect(response.status).toBe(200);
expect(response.body).toEqual(mockRecipes);
expect(response.body.data).toEqual(mockRecipes);
expect(db.recipeRepo.getRecipesBySalePercentage).toHaveBeenCalledWith(75, expectLogger);
});
@@ -85,7 +88,7 @@ describe('Recipe Routes (/api/recipes)', () => {
vi.mocked(db.recipeRepo.getRecipesBySalePercentage).mockRejectedValue(dbError);
const response = await supertest(app).get('/api/recipes/by-sale-percentage');
expect(response.status).toBe(500);
expect(response.body.message).toBe('DB Error');
expect(response.body.error.message).toBe('DB Error');
expect(mockLogger.error).toHaveBeenCalledWith(
{ error: dbError },
'Error fetching recipes in /api/recipes/by-sale-percentage:',
@@ -97,7 +100,7 @@ describe('Recipe Routes (/api/recipes)', () => {
'/api/recipes/by-sale-percentage?minPercentage=101',
);
expect(response.status).toBe(400);
expect(response.body.errors[0].message).toContain('Too big');
expect(response.body.error.details[0].message).toContain('Too big');
});
});
@@ -120,7 +123,7 @@ describe('Recipe Routes (/api/recipes)', () => {
vi.mocked(db.recipeRepo.getRecipesByMinSaleIngredients).mockRejectedValue(dbError);
const response = await supertest(app).get('/api/recipes/by-sale-ingredients');
expect(response.status).toBe(500);
expect(response.body.message).toBe('DB Error');
expect(response.body.error.message).toBe('DB Error');
expect(mockLogger.error).toHaveBeenCalledWith(
{ error: dbError },
'Error fetching recipes in /api/recipes/by-sale-ingredients:',
@@ -132,7 +135,7 @@ describe('Recipe Routes (/api/recipes)', () => {
'/api/recipes/by-sale-ingredients?minIngredients=abc',
);
expect(response.status).toBe(400);
expect(response.body.errors[0].message).toContain('received NaN');
expect(response.body.error.details[0].message).toContain('received NaN');
});
});
@@ -146,7 +149,7 @@ describe('Recipe Routes (/api/recipes)', () => {
);
expect(response.status).toBe(200);
expect(response.body).toEqual(mockRecipes);
expect(response.body.data).toEqual(mockRecipes);
});
it('should return 500 if the database call fails', async () => {
@@ -156,7 +159,7 @@ describe('Recipe Routes (/api/recipes)', () => {
'/api/recipes/by-ingredient-and-tag?ingredient=chicken&tag=quick',
);
expect(response.status).toBe(500);
expect(response.body.message).toBe('DB Error');
expect(response.body.error.message).toBe('DB Error');
expect(mockLogger.error).toHaveBeenCalledWith(
{ error: dbError },
'Error fetching recipes in /api/recipes/by-ingredient-and-tag:',
@@ -168,7 +171,7 @@ describe('Recipe Routes (/api/recipes)', () => {
'/api/recipes/by-ingredient-and-tag?ingredient=chicken',
);
expect(response.status).toBe(400);
expect(response.body.errors[0].message).toBe('Query parameter "tag" is required.');
expect(response.body.error.details[0].message).toBe('Query parameter "tag" is required.');
});
});
@@ -180,14 +183,14 @@ describe('Recipe Routes (/api/recipes)', () => {
const response = await supertest(app).get('/api/recipes/1/comments');
expect(response.status).toBe(200);
expect(response.body).toEqual(mockComments);
expect(response.body.data).toEqual(mockComments);
expect(db.recipeRepo.getRecipeComments).toHaveBeenCalledWith(1, expectLogger);
});
it('should return an empty array if recipe has no comments', async () => {
vi.mocked(db.recipeRepo.getRecipeComments).mockResolvedValue([]);
const response = await supertest(app).get('/api/recipes/2/comments');
expect(response.body).toEqual([]);
expect(response.body.data).toEqual([]);
});
it('should return 500 if the database call fails', async () => {
@@ -195,7 +198,7 @@ describe('Recipe Routes (/api/recipes)', () => {
vi.mocked(db.recipeRepo.getRecipeComments).mockRejectedValue(dbError);
const response = await supertest(app).get('/api/recipes/1/comments');
expect(response.status).toBe(500);
expect(response.body.message).toBe('DB Error');
expect(response.body.error.message).toBe('DB Error');
expect(mockLogger.error).toHaveBeenCalledWith(
{ error: dbError },
`Error fetching comments for recipe ID 1:`,
@@ -205,7 +208,7 @@ describe('Recipe Routes (/api/recipes)', () => {
it('should return 400 for an invalid recipeId', async () => {
const response = await supertest(app).get('/api/recipes/abc/comments');
expect(response.status).toBe(400);
expect(response.body.errors[0].message).toContain('received NaN');
expect(response.body.error.details[0].message).toContain('received NaN');
});
});
@@ -217,7 +220,7 @@ describe('Recipe Routes (/api/recipes)', () => {
const response = await supertest(app).get('/api/recipes/456');
expect(response.status).toBe(200);
expect(response.body).toEqual(mockRecipe);
expect(response.body.data).toEqual(mockRecipe);
expect(db.recipeRepo.getRecipeById).toHaveBeenCalledWith(456, expectLogger);
});
@@ -226,7 +229,7 @@ describe('Recipe Routes (/api/recipes)', () => {
vi.mocked(db.recipeRepo.getRecipeById).mockRejectedValue(notFoundError);
const response = await supertest(app).get('/api/recipes/999');
expect(response.status).toBe(404);
expect(response.body.message).toContain('not found');
expect(response.body.error.message).toContain('not found');
expect(mockLogger.error).toHaveBeenCalledWith(
{ error: notFoundError },
`Error fetching recipe ID 999:`,
@@ -238,7 +241,7 @@ describe('Recipe Routes (/api/recipes)', () => {
vi.mocked(db.recipeRepo.getRecipeById).mockRejectedValue(dbError);
const response = await supertest(app).get('/api/recipes/456');
expect(response.status).toBe(500);
expect(response.body.message).toBe('DB Error');
expect(response.body.error.message).toBe('DB Error');
expect(mockLogger.error).toHaveBeenCalledWith(
{ error: dbError },
`Error fetching recipe ID 456:`,
@@ -248,7 +251,7 @@ describe('Recipe Routes (/api/recipes)', () => {
it('should return 400 for an invalid recipeId', async () => {
const response = await supertest(app).get('/api/recipes/abc');
expect(response.status).toBe(400);
expect(response.body.errors[0].message).toContain('received NaN');
expect(response.body.error.details[0].message).toContain('received NaN');
});
});
@@ -265,12 +268,10 @@ describe('Recipe Routes (/api/recipes)', () => {
const mockSuggestion = 'Chicken and Rice Casserole...';
vi.mocked(aiService.generateRecipeSuggestion).mockResolvedValue(mockSuggestion);
const response = await supertest(authApp)
.post('/api/recipes/suggest')
.send({ ingredients });
const response = await supertest(authApp).post('/api/recipes/suggest').send({ ingredients });
expect(response.status).toBe(200);
expect(response.body).toEqual({ suggestion: mockSuggestion });
expect(response.body.data).toEqual({ suggestion: mockSuggestion });
expect(aiService.generateRecipeSuggestion).toHaveBeenCalledWith(ingredients, expectLogger);
});
@@ -282,7 +283,7 @@ describe('Recipe Routes (/api/recipes)', () => {
.send({ ingredients: ['water'] });
expect(response.status).toBe(503);
expect(response.body.message).toContain('unavailable');
expect(response.body.error.message).toContain('unavailable');
});
it('should return 400 if ingredients list is empty', async () => {
@@ -291,7 +292,9 @@ describe('Recipe Routes (/api/recipes)', () => {
.send({ ingredients: [] });
expect(response.status).toBe(400);
expect(response.body.errors[0].message).toContain('At least one ingredient is required');
expect(response.body.error.details[0].message).toContain(
'At least one ingredient is required',
);
});
it('should return 401 if not authenticated', async () => {
@@ -314,7 +317,7 @@ describe('Recipe Routes (/api/recipes)', () => {
expect(response.status).toBe(500);
expect(mockLogger.error).toHaveBeenCalledWith(
{ error },
'Error generating recipe suggestion'
'Error generating recipe suggestion',
);
});
});

View File

@@ -7,6 +7,7 @@ import passport from './passport.routes';
import { validateRequest } from '../middleware/validation.middleware';
import { requiredString, numericIdParam, optionalNumeric } from '../utils/zodUtils';
import { publicReadLimiter, suggestionLimiter } from '../config/rateLimiters';
import { sendSuccess, sendError, ErrorCode } from '../utils/apiResponse';
const router = Router();
@@ -49,7 +50,7 @@ router.get(
// Explicitly parse req.query to apply coercion (string -> number) and default values
const { query } = bySalePercentageSchema.parse({ query: req.query });
const recipes = await db.recipeRepo.getRecipesBySalePercentage(query.minPercentage!, req.log);
res.json(recipes);
sendSuccess(res, recipes);
} catch (error) {
req.log.error({ error }, 'Error fetching recipes in /api/recipes/by-sale-percentage:');
next(error);
@@ -72,7 +73,7 @@ router.get(
query.minIngredients!,
req.log,
);
res.json(recipes);
sendSuccess(res, recipes);
} catch (error) {
req.log.error({ error }, 'Error fetching recipes in /api/recipes/by-sale-ingredients:');
next(error);
@@ -95,7 +96,7 @@ router.get(
query.tag,
req.log,
);
res.json(recipes);
sendSuccess(res, recipes);
} catch (error) {
req.log.error({ error }, 'Error fetching recipes in /api/recipes/by-ingredient-and-tag:');
next(error);
@@ -106,32 +107,42 @@ router.get(
/**
* GET /api/recipes/:recipeId/comments - Get all comments for a specific recipe.
*/
router.get('/:recipeId/comments', publicReadLimiter, validateRequest(recipeIdParamsSchema), async (req, res, next) => {
try {
// Explicitly parse req.params to coerce recipeId to a number
const { params } = recipeIdParamsSchema.parse({ params: req.params });
const comments = await db.recipeRepo.getRecipeComments(params.recipeId, req.log);
res.json(comments);
} catch (error) {
req.log.error({ error }, `Error fetching comments for recipe ID ${req.params.recipeId}:`);
next(error);
}
});
router.get(
'/:recipeId/comments',
publicReadLimiter,
validateRequest(recipeIdParamsSchema),
async (req, res, next) => {
try {
// Explicitly parse req.params to coerce recipeId to a number
const { params } = recipeIdParamsSchema.parse({ params: req.params });
const comments = await db.recipeRepo.getRecipeComments(params.recipeId, req.log);
sendSuccess(res, comments);
} catch (error) {
req.log.error({ error }, `Error fetching comments for recipe ID ${req.params.recipeId}:`);
next(error);
}
},
);
/**
* GET /api/recipes/:recipeId - Get a single recipe by its ID, including ingredients and tags.
*/
router.get('/:recipeId', publicReadLimiter, validateRequest(recipeIdParamsSchema), async (req, res, next) => {
try {
// Explicitly parse req.params to coerce recipeId to a number
const { params } = recipeIdParamsSchema.parse({ params: req.params });
const recipe = await db.recipeRepo.getRecipeById(params.recipeId, req.log);
res.json(recipe);
} catch (error) {
req.log.error({ error }, `Error fetching recipe ID ${req.params.recipeId}:`);
next(error);
}
});
router.get(
'/:recipeId',
publicReadLimiter,
validateRequest(recipeIdParamsSchema),
async (req, res, next) => {
try {
// Explicitly parse req.params to coerce recipeId to a number
const { params } = recipeIdParamsSchema.parse({ params: req.params });
const recipe = await db.recipeRepo.getRecipeById(params.recipeId, req.log);
sendSuccess(res, recipe);
} catch (error) {
req.log.error({ error }, `Error fetching recipe ID ${req.params.recipeId}:`);
next(error);
}
},
);
/**
* POST /api/recipes/suggest - Generates a simple recipe suggestion from a list of ingredients.
@@ -148,12 +159,15 @@ router.post(
const suggestion = await aiService.generateRecipeSuggestion(body.ingredients, req.log);
if (!suggestion) {
return res
.status(503)
.json({ message: 'AI service is currently unavailable or failed to generate a suggestion.' });
return sendError(
res,
ErrorCode.SERVICE_UNAVAILABLE,
'AI service is currently unavailable or failed to generate a suggestion.',
503,
);
}
res.json({ suggestion });
sendSuccess(res, { suggestion });
} catch (error) {
req.log.error({ error }, 'Error generating recipe suggestion');
next(error);

View File

@@ -52,7 +52,7 @@ describe('Stats Routes (/api/stats)', () => {
vi.mocked(db.adminRepo.getMostFrequentSaleItems).mockRejectedValue(dbError);
const response = await supertest(app).get('/api/stats/most-frequent-sales');
expect(response.status).toBe(500);
expect(response.body.message).toBe('DB Error');
expect(response.body.error.message).toBe('DB Error');
expect(mockLogger.error).toHaveBeenCalledWith(
{ error: dbError },
'Error fetching most frequent sale items in /api/stats/most-frequent-sales:',
@@ -62,8 +62,8 @@ describe('Stats Routes (/api/stats)', () => {
it('should return 400 for invalid query parameters', async () => {
const response = await supertest(app).get('/api/stats/most-frequent-sales?days=0&limit=abc');
expect(response.status).toBe(400);
expect(response.body.errors).toBeDefined();
expect(response.body.errors.length).toBe(2);
expect(response.body.error.details).toBeDefined();
expect(response.body.error.details.length).toBe(2);
});
});

View File

@@ -5,6 +5,7 @@ import * as db from '../services/db/index.db';
import { validateRequest } from '../middleware/validation.middleware';
import { optionalNumeric } from '../utils/zodUtils';
import { publicReadLimiter } from '../config/rateLimiters';
import { sendSuccess } from '../utils/apiResponse';
const router = Router();
@@ -34,7 +35,7 @@ router.get(
// We parse it here to apply Zod's coercions (string to number) and defaults.
const { days, limit } = statsQuerySchema.parse(req.query);
const items = await db.adminRepo.getMostFrequentSaleItems(days!, limit!, req.log);
res.json(items);
sendSuccess(res, items);
} catch (error) {
req.log.error(
{ error },

View File

@@ -53,7 +53,7 @@ describe('System Routes (/api/system)', () => {
// Assert
expect(response.status).toBe(200);
expect(response.body).toEqual({
expect(response.body.data).toEqual({
success: true,
message: 'Application is online and running under PM2.',
});
@@ -69,7 +69,7 @@ describe('System Routes (/api/system)', () => {
// Assert
expect(response.status).toBe(200);
expect(response.body.success).toBe(false);
expect(response.body.data.success).toBe(false);
});
it('should return success: false when pm2 process does not exist', async () => {
@@ -84,7 +84,7 @@ describe('System Routes (/api/system)', () => {
// Assert
expect(response.status).toBe(200);
expect(response.body).toEqual({
expect(response.body.data).toEqual({
success: false,
message: 'Application process is not running under PM2.',
});
@@ -92,12 +92,14 @@ describe('System Routes (/api/system)', () => {
it('should return 500 if pm2 command produces stderr output', async () => {
// Arrange: Simulate a successful exit code but with content in stderr.
const serviceError = new Error('PM2 command produced an error: A non-fatal warning occurred.');
const serviceError = new Error(
'PM2 command produced an error: A non-fatal warning occurred.',
);
vi.mocked(systemService.getPm2Status).mockRejectedValue(serviceError);
const response = await supertest(app).get('/api/system/pm2-status');
expect(response.status).toBe(500);
expect(response.body.message).toBe(serviceError.message);
expect(response.body.error.message).toBe(serviceError.message);
});
it('should return 500 on a generic exec error', async () => {
@@ -109,7 +111,7 @@ describe('System Routes (/api/system)', () => {
// Assert
expect(response.status).toBe(500);
expect(response.body.message).toBe('System error');
expect(response.body.error.message).toBe('System error');
});
});
@@ -126,7 +128,7 @@ describe('System Routes (/api/system)', () => {
// Assert
expect(response.status).toBe(200);
expect(response.body).toEqual(mockCoordinates);
expect(response.body.data).toEqual(mockCoordinates);
});
it('should return 404 if the address cannot be geocoded', async () => {
@@ -135,7 +137,7 @@ describe('System Routes (/api/system)', () => {
.post('/api/system/geocode')
.send({ address: 'Invalid Address' });
expect(response.status).toBe(404);
expect(response.body.message).toBe('Could not geocode the provided address.');
expect(response.body.error.message).toBe('Could not geocode the provided address.');
});
it('should return 500 if the geocoding service throws an error', async () => {
@@ -153,7 +155,9 @@ describe('System Routes (/api/system)', () => {
.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);
expect(response.body.error.details[0].message).toMatch(
/An address string is required|Required/i,
);
});
});

View File

@@ -14,6 +14,7 @@ import { requiredString } from '../utils/zodUtils';
import { systemService } from '../services/systemService';
// All route handlers now use req.log (request-scoped logger) as per ADR-004
import { geocodeLimiter } from '../config/rateLimiters';
import { sendSuccess, sendError, ErrorCode } from '../utils/apiResponse';
const router = Router();
@@ -36,7 +37,7 @@ router.get(
async (req: Request, res: Response, next: NextFunction) => {
try {
const status = await systemService.getPm2Status();
res.json(status);
sendSuccess(res, status);
} catch (error) {
next(error);
}
@@ -63,10 +64,10 @@ router.post(
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.' });
return sendError(res, ErrorCode.NOT_FOUND, 'Could not geocode the provided address.', 404);
}
res.json(coordinates);
sendSuccess(res, coordinates);
} catch (error) {
req.log.error({ error }, 'Error geocoding address');
next(error);

View File

@@ -13,13 +13,16 @@ import {
createMockNotification,
createMockDietaryRestriction,
createMockAppliance,
createMockUserWithPasswordHash,
createMockAddress,
} from '../tests/utils/mockFactories';
import { Appliance, Notification, DietaryRestriction } from '../types';
import { ForeignKeyConstraintError, NotFoundError, ValidationError } from '../services/db/errors.db';
import {
ForeignKeyConstraintError,
NotFoundError,
ValidationError,
} from '../services/db/errors.db';
import { createTestApp } from '../tests/utils/createTestApp';
import { mockLogger } from '../tests/utils/mockLogger';
import '../tests/utils/mockLogger';
import { cleanupFiles } from '../tests/utils/cleanupFiles';
import { logger } from '../services/logger.server';
import { userService } from '../services/userService';
@@ -148,7 +151,7 @@ describe('User Routes (/api/users)', () => {
vi.mocked(db.userRepo.findUserProfileById).mockResolvedValue(mockUserProfile);
const response = await supertest(app).get('/api/users/profile');
expect(response.status).toBe(200);
expect(response.body).toEqual(mockUserProfile);
expect(response.body.data).toEqual(mockUserProfile);
expect(db.userRepo.findUserProfileById).toHaveBeenCalledWith(
mockUserProfile.user.user_id,
expectLogger,
@@ -161,7 +164,7 @@ describe('User Routes (/api/users)', () => {
);
const response = await supertest(app).get('/api/users/profile');
expect(response.status).toBe(404);
expect(response.body.message).toContain('Profile not found');
expect(response.body.error.message).toContain('Profile not found');
});
it('should return 500 on a generic database error', async () => {
@@ -184,7 +187,7 @@ describe('User Routes (/api/users)', () => {
vi.mocked(db.personalizationRepo.getWatchedItems).mockResolvedValue(mockItems);
const response = await supertest(app).get('/api/users/watched-items');
expect(response.status).toBe(200);
expect(response.body).toEqual(mockItems);
expect(response.body.data).toEqual(mockItems);
});
it('should return 500 on a generic database error', async () => {
@@ -210,7 +213,7 @@ describe('User Routes (/api/users)', () => {
vi.mocked(db.personalizationRepo.addWatchedItem).mockResolvedValue(mockAddedItem);
const response = await supertest(app).post('/api/users/watched-items').send(newItem);
expect(response.status).toBe(201);
expect(response.body).toEqual(mockAddedItem);
expect(response.body.data).toEqual(mockAddedItem);
});
it('should return 500 on a generic database error', async () => {
@@ -230,8 +233,8 @@ describe('User Routes (/api/users)', () => {
.post('/api/users/watched-items')
.send({ category: 'Produce' });
expect(response.status).toBe(400);
// Check the 'errors' array for the specific validation message.
expect(response.body.errors[0].message).toBe("Field 'itemName' is required.");
// Check the 'error.details' array for the specific validation message.
expect(response.body.error.details[0].message).toBe("Field 'itemName' is required.");
});
it('should return 400 if category is missing', async () => {
@@ -239,8 +242,8 @@ describe('User Routes (/api/users)', () => {
.post('/api/users/watched-items')
.send({ itemName: 'Apples' });
expect(response.status).toBe(400);
// Check the 'errors' array for the specific validation message.
expect(response.body.errors[0].message).toBe("Field 'category' is required.");
// Check the 'error.details' array for the specific validation message.
expect(response.body.error.details[0].message).toBe("Field 'category' is required.");
});
});
@@ -286,7 +289,7 @@ describe('User Routes (/api/users)', () => {
vi.mocked(db.shoppingRepo.getShoppingLists).mockResolvedValue(mockLists);
const response = await supertest(app).get('/api/users/shopping-lists');
expect(response.status).toBe(200);
expect(response.body).toEqual(mockLists);
expect(response.body.data).toEqual(mockLists);
});
it('should return 500 on a generic database error', async () => {
@@ -312,14 +315,14 @@ describe('User Routes (/api/users)', () => {
.send({ name: 'Party Supplies' });
expect(response.status).toBe(201);
expect(response.body).toEqual(mockNewList);
expect(response.body.data).toEqual(mockNewList);
});
it('should return 400 if name is missing', async () => {
const response = await supertest(app).post('/api/users/shopping-lists').send({});
expect(response.status).toBe(400);
// Check the 'errors' array for the specific validation message.
expect(response.body.errors[0].message).toBe("Field 'name' is required.");
// Check the 'error.details' array for the specific validation message.
expect(response.body.error.details[0].message).toBe("Field 'name' is required.");
});
it('should return 400 on foreign key constraint error', async () => {
@@ -330,7 +333,7 @@ describe('User Routes (/api/users)', () => {
.post('/api/users/shopping-lists')
.send({ name: 'Failing List' });
expect(response.status).toBe(400);
expect(response.body.message).toBe('User not found');
expect(response.body.error.message).toBe('User not found');
});
it('should return 500 on a generic database error during creation', async () => {
@@ -340,15 +343,15 @@ describe('User Routes (/api/users)', () => {
.post('/api/users/shopping-lists')
.send({ name: 'Failing List' });
expect(response.status).toBe(500);
expect(response.body.message).toBe('DB Connection Failed');
expect(response.body.error.message).toBe('DB Connection Failed');
expect(logger.error).toHaveBeenCalled();
});
it('should return 400 for an invalid listId on DELETE', async () => {
const response = await supertest(app).delete('/api/users/shopping-lists/abc');
expect(response.status).toBe(400);
// Check the 'errors' array for the specific validation message.
expect(response.body.errors[0].message).toContain('received NaN');
// Check the 'error.details' array for the specific validation message.
expect(response.body.error.details[0].message).toContain('received NaN');
});
describe('DELETE /shopping-lists/:listId', () => {
@@ -378,7 +381,7 @@ describe('User Routes (/api/users)', () => {
it('should return 400 for an invalid listId', async () => {
const response = await supertest(app).delete('/api/users/shopping-lists/abc');
expect(response.status).toBe(400);
expect(response.body.errors[0].message).toContain('received NaN');
expect(response.body.error.details[0].message).toContain('received NaN');
});
});
});
@@ -387,7 +390,7 @@ describe('User Routes (/api/users)', () => {
it('should return 400 if neither masterItemId nor customItemName are provided', async () => {
const response = await supertest(app).post('/api/users/shopping-lists/1/items').send({});
expect(response.status).toBe(400);
expect(response.body.errors[0].message).toBe(
expect(response.body.error.details[0].message).toBe(
'Either masterItemId or customItemName must be provided.',
);
});
@@ -436,7 +439,7 @@ describe('User Routes (/api/users)', () => {
.send(itemData);
expect(response.status).toBe(201);
expect(response.body).toEqual(mockAddedItem);
expect(response.body.data).toEqual(mockAddedItem);
expect(db.shoppingRepo.addShoppingListItem).toHaveBeenCalledWith(
listId,
mockUserProfile.user.user_id,
@@ -479,7 +482,7 @@ describe('User Routes (/api/users)', () => {
.send(updates);
expect(response.status).toBe(200);
expect(response.body).toEqual(mockUpdatedItem);
expect(response.body.data).toEqual(mockUpdatedItem);
expect(db.shoppingRepo.updateShoppingListItem).toHaveBeenCalledWith(
itemId,
mockUserProfile.user.user_id,
@@ -511,7 +514,7 @@ describe('User Routes (/api/users)', () => {
it('should return 400 if no update fields are provided for an item', async () => {
const response = await supertest(app).put(`/api/users/shopping-lists/items/101`).send({});
expect(response.status).toBe(400);
expect(response.body.errors[0].message).toContain(
expect(response.body.error.details[0].message).toContain(
'At least one field (quantity, is_purchased) must be provided.',
);
});
@@ -554,7 +557,7 @@ describe('User Routes (/api/users)', () => {
const response = await supertest(app).put('/api/users/profile').send(profileUpdates);
expect(response.status).toBe(200);
expect(response.body).toEqual(updatedProfile);
expect(response.body.data).toEqual(updatedProfile);
});
it('should allow updating the profile with an empty string for avatar_url', async () => {
@@ -569,7 +572,7 @@ describe('User Routes (/api/users)', () => {
// Assert
expect(response.status).toBe(200);
expect(response.body).toEqual(updatedProfile);
expect(response.body.data).toEqual(updatedProfile);
// Verify that the Zod schema preprocessed the empty string to undefined
expect(db.userRepo.updateUserProfile).toHaveBeenCalledWith(
mockUserProfile.user.user_id,
@@ -594,7 +597,7 @@ describe('User Routes (/api/users)', () => {
it('should return 400 if the body is empty', async () => {
const response = await supertest(app).put('/api/users/profile').send({});
expect(response.status).toBe(400);
expect(response.body.errors[0].message).toBe(
expect(response.body.error.details[0].message).toBe(
'At least one field to update must be provided.',
);
});
@@ -607,7 +610,7 @@ describe('User Routes (/api/users)', () => {
.put('/api/users/profile/password')
.send({ newPassword: 'a-Very-Strong-Password-456!' });
expect(response.status).toBe(200);
expect(response.body.message).toBe('Password updated successfully.');
expect(response.body.data.message).toBe('Password updated successfully.');
});
it('should return 500 on a generic database error', async () => {
@@ -629,7 +632,7 @@ describe('User Routes (/api/users)', () => {
.send({ newPassword: 'password123' });
expect(response.status).toBe(400);
expect(response.body.errors[0].message).toContain('Password is too weak.');
expect(response.body.error.details[0].message).toContain('Password is too weak.');
});
});
@@ -640,33 +643,43 @@ describe('User Routes (/api/users)', () => {
.delete('/api/users/account')
.send({ password: 'correct-password' });
expect(response.status).toBe(200);
expect(response.body.message).toBe('Account deleted successfully.');
expect(userService.deleteUserAccount).toHaveBeenCalledWith('user-123', 'correct-password', expectLogger);
expect(response.body.data.message).toBe('Account deleted successfully.');
expect(userService.deleteUserAccount).toHaveBeenCalledWith(
'user-123',
'correct-password',
expectLogger,
);
});
it('should return 400 for an incorrect password', async () => {
vi.mocked(userService.deleteUserAccount).mockRejectedValue(new ValidationError([], 'Incorrect password.'));
vi.mocked(userService.deleteUserAccount).mockRejectedValue(
new ValidationError([], 'Incorrect password.'),
);
const response = await supertest(app)
.delete('/api/users/account')
.send({ password: 'wrong-password' });
expect(response.status).toBe(400);
expect(response.body.message).toBe('Incorrect password.');
expect(response.body.error.message).toBe('Incorrect password.');
});
it('should return 404 if the user to delete is not found', async () => {
vi.mocked(userService.deleteUserAccount).mockRejectedValue(new NotFoundError('User not found.'));
vi.mocked(userService.deleteUserAccount).mockRejectedValue(
new NotFoundError('User not found.'),
);
const response = await supertest(app)
.delete('/api/users/account')
.send({ password: 'any-password' });
expect(response.status).toBe(404);
expect(response.body.message).toBe('User not found.');
expect(response.body.error.message).toBe('User not found.');
});
it('should return 500 on a generic database error', async () => {
vi.mocked(userService.deleteUserAccount).mockRejectedValue(new Error('DB Connection Failed'));
vi.mocked(userService.deleteUserAccount).mockRejectedValue(
new Error('DB Connection Failed'),
);
const response = await supertest(app)
.delete('/api/users/account')
.send({ password: 'correct-password' });
@@ -691,7 +704,7 @@ describe('User Routes (/api/users)', () => {
.put('/api/users/profile/preferences')
.send(preferencesUpdate);
expect(response.status).toBe(200);
expect(response.body).toEqual(updatedProfile);
expect(response.body.data).toEqual(updatedProfile);
});
it('should return 500 on a generic database error', async () => {
@@ -728,7 +741,7 @@ describe('User Routes (/api/users)', () => {
);
const response = await supertest(app).get('/api/users/me/dietary-restrictions');
expect(response.status).toBe(200);
expect(response.body).toEqual(mockRestrictions);
expect(response.body.data).toEqual(mockRestrictions);
});
it('GET should return 500 on a generic database error', async () => {
@@ -745,8 +758,8 @@ describe('User Routes (/api/users)', () => {
it('should return 400 for an invalid masterItemId', async () => {
const response = await supertest(app).delete('/api/users/watched-items/abc');
expect(response.status).toBe(400);
// Check the 'errors' array for the specific validation message.
expect(response.body.errors[0].message).toContain('received NaN');
// Check the 'error.details' array for the specific validation message.
expect(response.body.error.details[0].message).toContain('received NaN');
});
it('PUT should successfully set the restrictions', async () => {
@@ -792,7 +805,7 @@ describe('User Routes (/api/users)', () => {
vi.mocked(db.personalizationRepo.getUserAppliances).mockResolvedValue(mockAppliances);
const response = await supertest(app).get('/api/users/me/appliances');
expect(response.status).toBe(200);
expect(response.body).toEqual(mockAppliances);
expect(response.body.data).toEqual(mockAppliances);
});
it('GET should return 500 on a generic database error', async () => {
@@ -823,7 +836,7 @@ describe('User Routes (/api/users)', () => {
.put('/api/users/me/appliances')
.send({ applianceIds: [999] }); // Invalid ID
expect(response.status).toBe(400);
expect(response.body.message).toBe('Invalid appliance ID');
expect(response.body.error.message).toBe('Invalid appliance ID');
});
it('PUT should return 500 on a generic database error', async () => {
@@ -855,7 +868,7 @@ describe('User Routes (/api/users)', () => {
const response = await supertest(app).get('/api/users/notifications?limit=10');
expect(response.status).toBe(200);
expect(response.body).toEqual(mockNotifications);
expect(response.body.data).toEqual(mockNotifications);
expect(db.notificationRepo.getNotificationsForUser).toHaveBeenCalledWith(
'user-123',
10,
@@ -875,7 +888,7 @@ describe('User Routes (/api/users)', () => {
const response = await supertest(app).get('/api/users/notifications?includeRead=true');
expect(response.status).toBe(200);
expect(response.body).toEqual(mockNotifications);
expect(response.body.data).toEqual(mockNotifications);
expect(db.notificationRepo.getNotificationsForUser).toHaveBeenCalledWith(
'user-123',
20, // default limit
@@ -935,7 +948,7 @@ describe('User Routes (/api/users)', () => {
.post('/api/users/notifications/abc/mark-read')
.send({});
expect(response.status).toBe(400);
expect(response.body.errors[0].message).toContain('received NaN');
expect(response.body.error.details[0].message).toContain('received NaN');
});
});
@@ -950,7 +963,7 @@ describe('User Routes (/api/users)', () => {
vi.mocked(userService.getUserAddress).mockResolvedValue(mockAddress);
const response = await supertest(appWithUser).get('/api/users/addresses/1');
expect(response.status).toBe(200);
expect(response.body).toEqual(mockAddress);
expect(response.body.data).toEqual(mockAddress);
});
it('GET /addresses/:addressId should return 500 on a generic database error', async () => {
@@ -972,10 +985,12 @@ describe('User Routes (/api/users)', () => {
});
it('GET /addresses/:addressId should return 403 if address does not belong to user', async () => {
vi.mocked(userService.getUserAddress).mockRejectedValue(new ValidationError([], 'Forbidden'));
vi.mocked(userService.getUserAddress).mockRejectedValue(
new ValidationError([], 'Forbidden'),
);
const response = await supertest(app).get('/api/users/addresses/2'); // Requesting address 2
expect(response.status).toBe(400); // ValidationError maps to 400 by default in the test error handler
expect(response.body.message).toBe('Forbidden');
expect(response.body.error.message).toBe('Forbidden');
});
it('GET /addresses/:addressId should return 404 if address not found', async () => {
@@ -989,16 +1004,14 @@ describe('User Routes (/api/users)', () => {
);
const response = await supertest(appWithUser).get('/api/users/addresses/1');
expect(response.status).toBe(404);
expect(response.body.message).toBe('Address not found.');
expect(response.body.error.message).toBe('Address not found.');
});
it('PUT /profile/address should call upsertAddress and updateUserProfile if needed', async () => {
const addressData = { address_line_1: '123 New St' };
vi.mocked(userService.upsertUserAddress).mockResolvedValue(5);
const response = await supertest(app)
.put('/api/users/profile/address')
.send(addressData);
const response = await supertest(app).put('/api/users/profile/address').send(addressData);
expect(response.status).toBe(200);
expect(userService.upsertUserAddress).toHaveBeenCalledWith(
@@ -1020,7 +1033,7 @@ describe('User Routes (/api/users)', () => {
it('should return 400 if the address body is empty', async () => {
const response = await supertest(app).put('/api/users/profile/address').send({});
expect(response.status).toBe(400);
expect(response.body.errors[0].message).toContain(
expect(response.body.error.details[0].message).toContain(
'At least one address field must be provided',
);
});
@@ -1042,7 +1055,7 @@ describe('User Routes (/api/users)', () => {
.attach('avatar', Buffer.from('dummy-image-content'), dummyImagePath);
expect(response.status).toBe(200);
expect(response.body.avatar_url).toContain('https://example.com/uploads/avatars/');
expect(response.body.data.avatar_url).toContain('https://example.com/uploads/avatars/');
expect(userService.updateUserAvatar).toHaveBeenCalledWith(
mockUserProfile.user.user_id,
expect.any(Object),
@@ -1068,7 +1081,7 @@ describe('User Routes (/api/users)', () => {
.attach('avatar', Buffer.from('this is not an image'), dummyTextPath);
expect(response.status).toBe(400);
expect(response.body.message).toBe('Only image files are allowed!');
expect(response.body.error.message).toBe('Only image files are allowed!');
});
it('should return 400 if the uploaded file is too large', async () => {
@@ -1081,6 +1094,7 @@ describe('User Routes (/api/users)', () => {
.attach('avatar', largeFile, dummyImagePath);
expect(response.status).toBe(400);
// Multer middleware returns non-envelope format directly
expect(response.body.message).toContain('File too large');
});
@@ -1088,7 +1102,7 @@ describe('User Routes (/api/users)', () => {
const response = await supertest(app).post('/api/users/profile/avatar'); // No .attach() call
expect(response.status).toBe(400);
expect(response.body.message).toBe('No avatar file uploaded.');
expect(response.body.error.message).toBe('No avatar file uploaded.');
});
it('should clean up the uploaded file if updating the profile fails', async () => {
@@ -1115,7 +1129,7 @@ describe('User Routes (/api/users)', () => {
it('should return 400 for a non-numeric address ID', async () => {
const response = await supertest(app).get('/api/users/addresses/abc');
expect(response.status).toBe(400);
expect(response.body.errors[0].message).toContain('received NaN');
expect(response.body.error.details[0].message).toContain('received NaN');
});
});
@@ -1141,16 +1155,18 @@ describe('User Routes (/api/users)', () => {
});
it('DELETE /recipes/:recipeId should return 404 if recipe not found', async () => {
vi.mocked(db.recipeRepo.deleteRecipe).mockRejectedValue(new NotFoundError('Recipe not found'));
vi.mocked(db.recipeRepo.deleteRecipe).mockRejectedValue(
new NotFoundError('Recipe not found'),
);
const response = await supertest(app).delete('/api/users/recipes/999');
expect(response.status).toBe(404);
expect(response.body.message).toBe('Recipe not found');
expect(response.body.error.message).toBe('Recipe not found');
});
it('DELETE /recipes/:recipeId should return 400 for invalid recipe ID', async () => {
const response = await supertest(app).delete('/api/users/recipes/abc');
expect(response.status).toBe(400);
expect(response.body.errors[0].message).toContain('received NaN');
expect(response.body.error.details[0].message).toContain('received NaN');
});
it("PUT /recipes/:recipeId should update a user's own recipe", async () => {
@@ -1161,7 +1177,7 @@ describe('User Routes (/api/users)', () => {
const response = await supertest(app).put('/api/users/recipes/1').send(updates);
expect(response.status).toBe(200);
expect(response.body).toEqual(mockUpdatedRecipe);
expect(response.body.data).toEqual(mockUpdatedRecipe);
expect(db.recipeRepo.updateRecipe).toHaveBeenCalledWith(
1,
mockUserProfile.user.user_id,
@@ -1191,7 +1207,7 @@ describe('User Routes (/api/users)', () => {
it('PUT /recipes/:recipeId should return 400 if no update fields are provided', async () => {
const response = await supertest(app).put('/api/users/recipes/1').send({});
expect(response.status).toBe(400);
expect(response.body.errors[0].message).toBe('No fields provided to update.');
expect(response.body.error.details[0].message).toBe('No fields provided to update.');
});
it('PUT /recipes/:recipeId should return 400 for invalid recipe ID', async () => {
@@ -1199,7 +1215,7 @@ describe('User Routes (/api/users)', () => {
.put('/api/users/recipes/abc')
.send({ name: 'New Name' });
expect(response.status).toBe(400);
expect(response.body.errors[0].message).toContain('received NaN');
expect(response.body.error.details[0].message).toContain('received NaN');
});
it('GET /shopping-lists/:listId should return 404 if list is not found', async () => {
@@ -1208,7 +1224,7 @@ describe('User Routes (/api/users)', () => {
);
const response = await supertest(app).get('/api/users/shopping-lists/999');
expect(response.status).toBe(404);
expect(response.body.message).toBe('Shopping list not found');
expect(response.body.error.message).toBe('Shopping list not found');
});
it('GET /shopping-lists/:listId should return a single shopping list', async () => {
@@ -1219,7 +1235,7 @@ describe('User Routes (/api/users)', () => {
vi.mocked(db.shoppingRepo.getShoppingListById).mockResolvedValue(mockList);
const response = await supertest(app).get('/api/users/shopping-lists/1');
expect(response.status).toBe(200);
expect(response.body).toEqual(mockList);
expect(response.body.data).toEqual(mockList);
expect(db.shoppingRepo.getShoppingListById).toHaveBeenCalledWith(
1,
mockUserProfile.user.user_id,

View File

@@ -1,17 +1,13 @@
// src/routes/user.routes.ts
import express, { Request, Response, NextFunction } from 'express';
import passport from './passport.routes';
import multer from 'multer'; // Keep for MulterError type check
// All route handlers now use req.log (request-scoped logger) as per ADR-004
import { z } from 'zod';
// Removed: import { logger } from '../services/logger.server';
// All route handlers now use req.log (request-scoped logger) as per ADR-004
import { UserProfile } from '../types';
// All route handlers now use req.log (request-scoped logger) as per ADR-004
import {
createUploadMiddleware,
handleMulterError,
} from '../middleware/multer.middleware';
import { createUploadMiddleware, handleMulterError } from '../middleware/multer.middleware';
// All route handlers now use req.log (request-scoped logger) as per ADR-004
import { userService } from '../services/userService';
// All route handlers now use req.log (request-scoped logger) as per ADR-004
@@ -36,6 +32,7 @@ import {
userSensitiveUpdateLimiter,
userUploadLimiter,
} from '../config/rateLimiters';
import { sendSuccess, sendNoContent, sendError, ErrorCode } from '../utils/apiResponse';
const router = express.Router();
@@ -128,10 +125,14 @@ router.post(
// The try-catch block was already correct here.
try {
// The `requireFileUpload` middleware is not used here, so we must check for `req.file`.
if (!req.file) return res.status(400).json({ message: 'No avatar file uploaded.' });
if (!req.file) return sendError(res, ErrorCode.BAD_REQUEST, 'No avatar file uploaded.', 400);
const userProfile = req.user as UserProfile;
const updatedProfile = await userService.updateUserAvatar(userProfile.user.user_id, req.file, req.log);
res.json(updatedProfile);
const updatedProfile = await userService.updateUserAvatar(
userProfile.user.user_id,
req.file,
req.log,
);
sendSuccess(res, updatedProfile);
} catch (error) {
// If an error occurs after the file has been uploaded (e.g., DB error),
// we must clean up the orphaned file from the disk.
@@ -146,17 +147,14 @@ router.post(
* GET /api/users/notifications - Get notifications for the authenticated user.
* Supports pagination with `limit` and `offset` query parameters.
*/
type GetNotificationsRequest = z.infer<typeof notificationQuerySchema>;
router.get(
'/notifications',
validateRequest(notificationQuerySchema),
async (req: Request, res: Response, next: NextFunction) => {
// Cast to UserProfile to access user properties safely.
const userProfile = req.user as UserProfile;
// Apply ADR-003 pattern for type safety
try {
const { query } = req as unknown as GetNotificationsRequest;
const parsedQuery = notificationQuerySchema.parse({ query: req.query }).query;
const parsedQuery = notificationQuerySchema.shape.query.parse(req.query);
const notifications = await db.notificationRepo.getNotificationsForUser(
userProfile.user.user_id,
parsedQuery.limit!,
@@ -164,7 +162,7 @@ router.get(
parsedQuery.includeRead!,
req.log,
);
res.json(notifications);
sendSuccess(res, notifications);
} catch (error) {
req.log.error({ error }, 'Error fetching notifications');
next(error);
@@ -182,7 +180,7 @@ router.post(
try {
const userProfile = req.user as UserProfile;
await db.notificationRepo.markAllNotificationsAsRead(userProfile.user.user_id, req.log);
res.status(204).send(); // No Content
sendNoContent(res);
} catch (error) {
req.log.error({ error }, 'Error marking all notifications as read');
next(error);
@@ -208,7 +206,7 @@ router.post(
userProfile.user.user_id,
req.log,
);
res.status(204).send(); // Success, no content to return
sendNoContent(res);
} catch (error) {
req.log.error({ error }, 'Error marking notification as read');
next(error);
@@ -230,7 +228,7 @@ router.get('/profile', validateRequest(emptySchema), async (req, res, next: Next
userProfile.user.user_id,
req.log,
);
res.json(fullUserProfile);
sendSuccess(res, fullUserProfile);
} catch (error) {
req.log.error({ error }, `[ROUTE] GET /api/users/profile - ERROR`);
next(error);
@@ -256,7 +254,7 @@ router.put(
body,
req.log,
);
res.json(updatedProfile);
sendSuccess(res, updatedProfile);
} catch (error) {
req.log.error({ error }, `[ROUTE] PUT /api/users/profile - ERROR`);
next(error);
@@ -280,7 +278,7 @@ router.put(
try {
await userService.updateUserPassword(userProfile.user.user_id, body.newPassword, req.log);
res.status(200).json({ message: 'Password updated successfully.' });
sendSuccess(res, { message: 'Password updated successfully.' });
} catch (error) {
req.log.error({ error }, `[ROUTE] PUT /api/users/profile/password - ERROR`);
next(error);
@@ -304,7 +302,7 @@ router.delete(
try {
await userService.deleteUserAccount(userProfile.user.user_id, body.password, req.log);
res.status(200).json({ message: 'Account deleted successfully.' });
sendSuccess(res, { message: 'Account deleted successfully.' });
} catch (error) {
req.log.error({ error }, `[ROUTE] DELETE /api/users/account - ERROR`);
next(error);
@@ -320,7 +318,7 @@ router.get('/watched-items', validateRequest(emptySchema), async (req, res, next
const userProfile = req.user as UserProfile;
try {
const items = await db.personalizationRepo.getWatchedItems(userProfile.user.user_id, req.log);
res.json(items);
sendSuccess(res, items);
} catch (error) {
req.log.error({ error }, `[ROUTE] GET /api/users/watched-items - ERROR`);
next(error);
@@ -347,10 +345,10 @@ router.post(
body.category,
req.log,
);
res.status(201).json(newItem);
sendSuccess(res, newItem, 201);
} catch (error) {
if (error instanceof ForeignKeyConstraintError) {
return res.status(400).json({ message: error.message });
return sendError(res, ErrorCode.BAD_REQUEST, error.message, 400);
}
req.log.error({ error, body: req.body }, 'Failed to add watched item');
next(error);
@@ -378,7 +376,7 @@ router.delete(
params.masterItemId,
req.log,
);
res.status(204).send();
sendNoContent(res);
} catch (error) {
req.log.error({ error }, `[ROUTE] DELETE /api/users/watched-items/:masterItemId - ERROR`);
next(error);
@@ -397,7 +395,7 @@ router.get(
const userProfile = req.user as UserProfile;
try {
const lists = await db.shoppingRepo.getShoppingLists(userProfile.user.user_id, req.log);
res.json(lists);
sendSuccess(res, lists);
} catch (error) {
req.log.error({ error }, `[ROUTE] GET /api/users/shopping-lists - ERROR`);
next(error);
@@ -423,7 +421,7 @@ router.get(
userProfile.user.user_id,
req.log,
);
res.json(list);
sendSuccess(res, list);
} catch (error) {
req.log.error(
{ error, listId: params.listId },
@@ -453,10 +451,10 @@ router.post(
body.name,
req.log,
);
res.status(201).json(newList);
sendSuccess(res, newList, 201);
} catch (error) {
if (error instanceof ForeignKeyConstraintError) {
return res.status(400).json({ message: error.message });
return sendError(res, ErrorCode.BAD_REQUEST, error.message, 400);
}
req.log.error({ error, body: req.body }, 'Failed to create shopping list');
next(error);
@@ -478,7 +476,7 @@ router.delete(
const { params } = req as unknown as GetShoppingListRequest;
try {
await db.shoppingRepo.deleteShoppingList(params.listId, userProfile.user.user_id, req.log);
res.status(204).send();
sendNoContent(res);
} catch (error: unknown) {
const errorMessage = error instanceof Error ? error.message : 'An unknown error occurred';
req.log.error(
@@ -524,12 +522,15 @@ router.post(
body,
req.log,
);
res.status(201).json(newItem);
sendSuccess(res, newItem, 201);
} catch (error) {
if (error instanceof ForeignKeyConstraintError) {
return res.status(400).json({ message: error.message });
return sendError(res, ErrorCode.BAD_REQUEST, error.message, 400);
}
req.log.error({ error, params: req.params, body: req.body }, 'Failed to add shopping list item');
req.log.error(
{ error, params: req.params, body: req.body },
'Failed to add shopping list item',
);
next(error);
}
},
@@ -565,7 +566,7 @@ router.put(
body,
req.log,
);
res.json(updatedItem);
sendSuccess(res, updatedItem);
} catch (error: unknown) {
req.log.error(
{ error, params: req.params, body: req.body },
@@ -591,8 +592,12 @@ router.delete(
// Apply ADR-003 pattern for type safety
const { params } = req as unknown as DeleteShoppingListItemRequest;
try {
await db.shoppingRepo.removeShoppingListItem(params.itemId, userProfile.user.user_id, req.log);
res.status(204).send();
await db.shoppingRepo.removeShoppingListItem(
params.itemId,
userProfile.user.user_id,
req.log,
);
sendNoContent(res);
} catch (error: unknown) {
req.log.error(
{ error, params: req.params },
@@ -625,7 +630,7 @@ router.put(
body,
req.log,
);
res.json(updatedProfile);
sendSuccess(res, updatedProfile);
} catch (error) {
req.log.error({ error }, `[ROUTE] PUT /api/users/profile/preferences - ERROR`);
next(error);
@@ -644,7 +649,7 @@ router.get(
userProfile.user.user_id,
req.log,
);
res.json(restrictions);
sendSuccess(res, restrictions);
} catch (error) {
req.log.error({ error }, `[ROUTE] GET /api/users/me/dietary-restrictions - ERROR`);
next(error);
@@ -671,10 +676,10 @@ router.put(
body.restrictionIds,
req.log,
);
res.status(204).send();
sendNoContent(res);
} catch (error) {
if (error instanceof ForeignKeyConstraintError) {
return res.status(400).json({ message: error.message });
return sendError(res, ErrorCode.BAD_REQUEST, error.message, 400);
}
req.log.error({ error, body: req.body }, 'Failed to set user dietary restrictions');
next(error);
@@ -690,7 +695,7 @@ router.get('/me/appliances', validateRequest(emptySchema), async (req, res, next
userProfile.user.user_id,
req.log,
);
res.json(appliances);
sendSuccess(res, appliances);
} catch (error) {
req.log.error({ error }, `[ROUTE] GET /api/users/me/appliances - ERROR`);
next(error);
@@ -716,10 +721,10 @@ router.put(
body.applianceIds,
req.log,
);
res.status(204).send();
sendNoContent(res);
} catch (error) {
if (error instanceof ForeignKeyConstraintError) {
return res.status(400).json({ message: error.message });
return sendError(res, ErrorCode.BAD_REQUEST, error.message, 400);
}
req.log.error({ error, body: req.body }, 'Failed to set user appliances');
next(error);
@@ -743,7 +748,7 @@ router.get(
try {
const addressId = params.addressId;
const address = await userService.getUserAddress(userProfile, addressId, req.log);
res.json(address);
sendSuccess(res, address);
} catch (error) {
req.log.error({ error }, 'Error fetching user address');
next(error);
@@ -783,7 +788,7 @@ router.put(
// encapsulated in a single service method that manages the transaction.
// This ensures both the address upsert and the user profile update are atomic.
const addressId = await userService.upsertUserAddress(userProfile, addressData, req.log); // This was a duplicate, fixed.
res.status(200).json({ message: 'Address updated successfully', address_id: addressId });
sendSuccess(res, { message: 'Address updated successfully', address_id: addressId });
} catch (error) {
req.log.error({ error }, 'Error updating user address');
next(error);
@@ -803,12 +808,12 @@ router.post(
const { body } = req as unknown as z.infer<typeof createRecipeSchema>;
try {
const recipe = await db.recipeRepo.createRecipe(userProfile.user.user_id, body, req.log);
res.status(201).json(recipe);
sendSuccess(res, recipe, 201);
} catch (error) {
req.log.error({ error }, 'Error creating recipe');
next(error);
}
}
},
);
/**
@@ -827,7 +832,7 @@ router.delete(
const { params } = req as unknown as DeleteRecipeRequest;
try {
await db.recipeRepo.deleteRecipe(params.recipeId, userProfile.user.user_id, false, req.log);
res.status(204).send();
sendNoContent(res);
} catch (error) {
req.log.error(
{ error, params: req.params },
@@ -872,7 +877,7 @@ router.put(
body,
req.log,
);
res.json(updatedRecipe);
sendSuccess(res, updatedRecipe);
} catch (error) {
req.log.error(
{ error, params: req.params, body: req.body },

View File

@@ -23,6 +23,9 @@ export class FlyerPersistenceService {
* @internal
*/
_setWithTransaction(fn: WithTransactionFn): void {
console.error(
`[DEBUG] FlyerPersistenceService._setWithTransaction called, replacing withTransaction function`,
);
this.withTransaction = fn;
}
@@ -36,6 +39,12 @@ export class FlyerPersistenceService {
userId: string | undefined,
logger: Logger,
): Promise<Flyer> {
console.error(
`[DEBUG] FlyerPersistenceService.saveFlyer called, about to invoke withTransaction`,
);
console.error(
`[DEBUG] withTransaction function name: ${this.withTransaction.name || 'anonymous'}`,
);
const flyer = await this.withTransaction(async (client) => {
const { flyer, items } = await createFlyerAndItems(flyerData, itemsForDb, logger, client);

View File

@@ -602,9 +602,11 @@ describe('Flyer Processing Background Job Integration Test', () => {
// the worker imports the real module before our mock is applied.
const dbError = new Error('DB transaction failed');
const failingWithTransaction = vi.fn().mockRejectedValue(dbError);
console.error('[DB FAILURE TEST] About to inject failingWithTransaction mock');
workersModule.flyerProcessingService
._getPersistenceService()
._setWithTransaction(failingWithTransaction);
console.error('[DB FAILURE TEST] failingWithTransaction mock injected successfully');
// Arrange: Prepare a unique flyer file for upload.
const imagePath = path.resolve(__dirname, '../assets/test-flyer-image.jpg');

View File

@@ -1,5 +1,7 @@
// src/tests/setup/integration-global-setup.ts
import { execSync } from 'child_process';
import fs from 'node:fs/promises';
import path from 'path';
import type { Server } from 'http';
import { logger } from '../../services/logger.server';
import { getPool } from '../../services/db/connection.db';
@@ -18,28 +20,49 @@ async function cleanAllQueues() {
console.error(`[PID:${process.pid}] [QUEUE CLEANUP] Starting BullMQ queue cleanup...`);
try {
const { flyerQueue, cleanupQueue, emailQueue, analyticsQueue, weeklyAnalyticsQueue, tokenCleanupQueue } = await import('../../services/queues.server');
const {
flyerQueue,
cleanupQueue,
emailQueue,
analyticsQueue,
weeklyAnalyticsQueue,
tokenCleanupQueue,
} = await import('../../services/queues.server');
console.error(`[QUEUE CLEANUP] Successfully imported queue modules`);
const queues = [flyerQueue, cleanupQueue, emailQueue, analyticsQueue, weeklyAnalyticsQueue, tokenCleanupQueue];
const queues = [
flyerQueue,
cleanupQueue,
emailQueue,
analyticsQueue,
weeklyAnalyticsQueue,
tokenCleanupQueue,
];
for (const queue of queues) {
try {
// Log queue state before cleanup
const jobCounts = await queue.getJobCounts();
console.error(`[QUEUE CLEANUP] Queue "${queue.name}" before cleanup: ${JSON.stringify(jobCounts)}`);
console.error(
`[QUEUE CLEANUP] Queue "${queue.name}" before cleanup: ${JSON.stringify(jobCounts)}`,
);
// obliterate() removes ALL data associated with the queue from Redis
await queue.obliterate({ force: true });
console.error(` ✅ [QUEUE CLEANUP] Cleaned queue: ${queue.name}`);
} catch (error) {
// Log but don't fail - the queue might not exist yet
console.error(` ⚠️ [QUEUE CLEANUP] Could not clean queue ${queue.name}: ${error instanceof Error ? error.message : 'Unknown error'}`);
console.error(
` ⚠️ [QUEUE CLEANUP] Could not clean queue ${queue.name}: ${error instanceof Error ? error.message : 'Unknown error'}`,
);
}
}
console.error(`✅ [PID:${process.pid}] [QUEUE CLEANUP] All queues cleaned successfully.`);
} catch (error) {
console.error(`❌ [PID:${process.pid}] [QUEUE CLEANUP] CRITICAL ERROR during queue cleanup:`, error);
console.error(
`❌ [PID:${process.pid}] [QUEUE CLEANUP] CRITICAL ERROR during queue cleanup:`,
error,
);
// Don't throw - we want the tests to continue even if cleanup fails
}
}
@@ -50,7 +73,24 @@ export async function setup() {
// Fix: Set the FRONTEND_URL globally for the test server instance
process.env.FRONTEND_URL = 'https://example.com';
// CRITICAL: Set STORAGE_PATH before importing the server.
// The multer middleware runs an IIFE on import that creates directories based on this path.
// If not set, it defaults to /var/www/.../flyer-images which won't exist in the test environment.
if (!process.env.STORAGE_PATH) {
// Use path relative to the project root (where tests run from)
process.env.STORAGE_PATH = path.resolve(process.cwd(), 'flyer-images');
}
// Ensure the storage directories exist before the server starts
try {
await fs.mkdir(path.join(process.env.STORAGE_PATH, 'icons'), { recursive: true });
console.error(`[SETUP] Created storage directory: ${process.env.STORAGE_PATH}`);
} catch (error) {
console.error(`[SETUP] Warning: Could not create storage directory: ${error}`);
}
console.error(`\n--- [PID:${process.pid}] Running Integration Test GLOBAL Setup ---`);
console.error(`[SETUP] STORAGE_PATH: ${process.env.STORAGE_PATH}`);
console.error(`[SETUP] REDIS_URL: ${process.env.REDIS_URL}`);
console.error(`[SETUP] REDIS_PASSWORD is set: ${!!process.env.REDIS_PASSWORD}`);

165
src/types/api.ts Normal file
View File

@@ -0,0 +1,165 @@
// src/types/api.ts
// ============================================================================
// API RESPONSE TYPE DEFINITIONS
// ============================================================================
// Standardized response types for all API endpoints per ADR-028.
// These types ensure consistent response structure across the entire API.
// ============================================================================
/**
* Standard pagination metadata included in paginated responses.
*/
export interface PaginationMeta {
/** Current page number (1-indexed) */
page: number;
/** Number of items per page */
limit: number;
/** Total number of items across all pages */
total: number;
/** Total number of pages */
totalPages: number;
/** Whether there is a next page */
hasNextPage: boolean;
/** Whether there is a previous page */
hasPrevPage: boolean;
}
/**
* Optional metadata that can be included in any response.
*/
export interface ResponseMeta {
/** Unique request identifier for tracking/debugging */
requestId?: string;
/** ISO timestamp of when the response was generated */
timestamp?: string;
/** Pagination info (only for paginated responses) */
pagination?: PaginationMeta;
}
/**
* Standard success response envelope.
* All successful API responses should follow this structure.
*
* @example
* // Single item response
* {
* "success": true,
* "data": { "id": 1, "name": "Item" }
* }
*
* @example
* // Paginated list response
* {
* "success": true,
* "data": [{ "id": 1 }, { "id": 2 }],
* "meta": {
* "pagination": { "page": 1, "limit": 20, "total": 100, ... }
* }
* }
*/
export interface ApiSuccessResponse<T> {
success: true;
data: T;
meta?: ResponseMeta;
}
/**
* Standard error response envelope.
* All error responses should follow this structure.
*
* @example
* // Validation error
* {
* "success": false,
* "error": {
* "code": "VALIDATION_ERROR",
* "message": "The request data is invalid.",
* "details": [{ "path": ["email"], "message": "Invalid email format" }]
* }
* }
*
* @example
* // Not found error
* {
* "success": false,
* "error": {
* "code": "NOT_FOUND",
* "message": "User not found"
* }
* }
*/
export interface ApiErrorResponse {
success: false;
error: {
/** Machine-readable error code (e.g., 'VALIDATION_ERROR', 'NOT_FOUND') */
code: string;
/** Human-readable error message */
message: string;
/** Additional error details (validation errors, etc.) */
details?: unknown;
};
meta?: Pick<ResponseMeta, 'requestId' | 'timestamp'>;
}
/**
* Union type for all API responses.
* Useful for frontend type narrowing based on `success` field.
*/
export type ApiResponse<T> = ApiSuccessResponse<T> | ApiErrorResponse;
// ============================================================================
// ERROR CODES
// ============================================================================
// Standardized error codes for consistent error identification.
// ============================================================================
/**
* Standard error codes used across the API.
* These should be used with the `sendError` helper function.
*/
export const ErrorCode = {
// Client errors (4xx)
VALIDATION_ERROR: 'VALIDATION_ERROR',
NOT_FOUND: 'NOT_FOUND',
UNAUTHORIZED: 'UNAUTHORIZED',
FORBIDDEN: 'FORBIDDEN',
CONFLICT: 'CONFLICT',
BAD_REQUEST: 'BAD_REQUEST',
RATE_LIMITED: 'RATE_LIMITED',
PAYLOAD_TOO_LARGE: 'PAYLOAD_TOO_LARGE',
// Server errors (5xx)
INTERNAL_ERROR: 'INTERNAL_ERROR',
SERVICE_UNAVAILABLE: 'SERVICE_UNAVAILABLE',
EXTERNAL_SERVICE_ERROR: 'EXTERNAL_SERVICE_ERROR',
NOT_IMPLEMENTED: 'NOT_IMPLEMENTED',
} as const;
export type ErrorCodeType = (typeof ErrorCode)[keyof typeof ErrorCode];
// ============================================================================
// HELPER TYPES
// ============================================================================
/**
* Input for creating paginated responses.
*/
export interface PaginationInput {
page: number;
limit: number;
total: number;
}
/**
* Type guard to check if a response is a success response.
*/
export function isApiSuccess<T>(response: ApiResponse<T>): response is ApiSuccessResponse<T> {
return response.success === true;
}
/**
* Type guard to check if a response is an error response.
*/
export function isApiError<T>(response: ApiResponse<T>): response is ApiErrorResponse {
return response.success === false;
}

183
src/utils/apiResponse.ts Normal file
View File

@@ -0,0 +1,183 @@
// src/utils/apiResponse.ts
// ============================================================================
// API RESPONSE HELPERS
// ============================================================================
// Utility functions for creating standardized API responses per ADR-028.
// Use these helpers in all route handlers for consistent response formats.
// ============================================================================
import { Response } from 'express';
import {
ApiSuccessResponse,
ApiErrorResponse,
PaginationInput,
PaginationMeta,
ResponseMeta,
ErrorCodeType,
ErrorCode,
} from '../types/api';
/**
* Send a successful response with data.
*
* @param res - Express response object
* @param data - The response data
* @param statusCode - HTTP status code (default: 200)
* @param meta - Optional metadata (requestId, timestamp)
*
* @example
* // Simple success response
* sendSuccess(res, { id: 1, name: 'Item' });
*
* @example
* // Success with 201 Created
* sendSuccess(res, newUser, 201);
*/
export function sendSuccess<T>(
res: Response,
data: T,
statusCode: number = 200,
meta?: Omit<ResponseMeta, 'pagination'>,
): Response<ApiSuccessResponse<T>> {
const response: ApiSuccessResponse<T> = {
success: true,
data,
};
if (meta) {
response.meta = meta;
}
return res.status(statusCode).json(response);
}
/**
* Send a successful response with no content (204).
* Used for DELETE operations or actions that don't return data.
*
* @param res - Express response object
*
* @example
* // After deleting a resource
* sendNoContent(res);
*/
export function sendNoContent(res: Response): Response {
return res.status(204).send();
}
/**
* Calculate pagination metadata from input parameters.
*
* @param input - Pagination input (page, limit, total)
* @returns Calculated pagination metadata
*/
export function calculatePagination(input: PaginationInput): PaginationMeta {
const { page, limit, total } = input;
const totalPages = Math.ceil(total / limit);
return {
page,
limit,
total,
totalPages,
hasNextPage: page < totalPages,
hasPrevPage: page > 1,
};
}
/**
* Send a paginated success response.
*
* @param res - Express response object
* @param data - The array of items for the current page
* @param pagination - Pagination input (page, limit, total)
* @param meta - Optional additional metadata
*
* @example
* const { flyers, total } = await flyerService.getFlyers({ page, limit });
* sendPaginated(res, flyers, { page, limit, total });
*/
export function sendPaginated<T>(
res: Response,
data: T[],
pagination: PaginationInput,
meta?: Omit<ResponseMeta, 'pagination'>,
): Response<ApiSuccessResponse<T[]>> {
const response: ApiSuccessResponse<T[]> = {
success: true,
data,
meta: {
...meta,
pagination: calculatePagination(pagination),
},
};
return res.status(200).json(response);
}
/**
* Send an error response.
*
* @param res - Express response object
* @param code - Machine-readable error code
* @param message - Human-readable error message
* @param statusCode - HTTP status code (default: 400)
* @param details - Optional error details (validation errors, etc.)
* @param meta - Optional metadata (requestId for error tracking)
*
* @example
* // Validation error
* sendError(res, ErrorCode.VALIDATION_ERROR, 'Invalid email format', 400, errors);
*
* @example
* // Not found error
* sendError(res, ErrorCode.NOT_FOUND, 'User not found', 404);
*/
export function sendError(
res: Response,
code: ErrorCodeType | string,
message: string,
statusCode: number = 400,
details?: unknown,
meta?: Pick<ResponseMeta, 'requestId' | 'timestamp'>,
): Response<ApiErrorResponse> {
const response: ApiErrorResponse = {
success: false,
error: {
code,
message,
},
};
if (details !== undefined) {
response.error.details = details;
}
if (meta) {
response.meta = meta;
}
return res.status(statusCode).json(response);
}
/**
* Send a message-only success response.
* Useful for operations that complete successfully but don't return data.
*
* @param res - Express response object
* @param message - Success message
* @param statusCode - HTTP status code (default: 200)
*
* @example
* sendMessage(res, 'Password updated successfully');
*/
export function sendMessage(
res: Response,
message: string,
statusCode: number = 200,
): Response<ApiSuccessResponse<{ message: string }>> {
return sendSuccess(res, { message }, statusCode);
}
// Re-export ErrorCode for convenience
export { ErrorCode };