Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9ffcc9d65d | ||
| 1285702210 | |||
|
|
d38b751b40 | ||
| e122d55ced |
145
docs/adr/adr-implementation-tracker.md
Normal file
145
docs/adr/adr-implementation-tracker.md
Normal 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
|
||||
@@ -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
4
package-lock.json
generated
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "flyer-crawler",
|
||||
"version": "0.9.73",
|
||||
"version": "0.9.75",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "flyer-crawler",
|
||||
"version": "0.9.73",
|
||||
"version": "0.9.75",
|
||||
"dependencies": {
|
||||
"@bull-board/api": "^6.14.2",
|
||||
"@bull-board/express": "^6.14.2",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "flyer-crawler",
|
||||
"private": true,
|
||||
"version": "0.9.73",
|
||||
"version": "0.9.75",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "concurrently \"npm:start:dev\" \"vite\"",
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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.',
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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:',
|
||||
|
||||
@@ -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',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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,24 @@ 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 = [
|
||||
{ reaction_id: 1, reaction_type: 'like', entity_id: '123' },
|
||||
] as unknown as UserReaction[];
|
||||
vi.mocked(reactionRepo.getReactions).mockResolvedValue(mockReactions);
|
||||
|
||||
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 = [
|
||||
{ reaction_id: 1, reaction_type: 'like' },
|
||||
] as unknown as UserReaction[];
|
||||
vi.mocked(reactionRepo.getReactions).mockResolvedValue(mockReactions);
|
||||
|
||||
const validUuid = '123e4567-e89b-12d3-a456-426614174000';
|
||||
const query = { userId: validUuid, entityType: 'recipe', entityId: '1' };
|
||||
|
||||
@@ -73,7 +77,7 @@ describe('Reaction Routes (/api/reactions)', () => {
|
||||
expect(response.status).toBe(200);
|
||||
expect(reactionRepo.getReactions).toHaveBeenCalledWith(
|
||||
expect.objectContaining(query),
|
||||
expectLogger
|
||||
expectLogger,
|
||||
);
|
||||
});
|
||||
|
||||
@@ -84,10 +88,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 +96,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 +126,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 +145,20 @@ 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,
|
||||
reaction_id: 1,
|
||||
user_id: 'user-123',
|
||||
} as unknown as UserReaction;
|
||||
vi.mocked(reactionRepo.toggleReaction).mockResolvedValue(mockResult);
|
||||
|
||||
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 +166,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 +178,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 +192,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 +233,4 @@ describe('Reaction Routes (/api/reactions)', () => {
|
||||
expect(parseInt(response.headers['ratelimit-limit'])).toBe(150);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user