complete project using prettier!

This commit is contained in:
2025-12-22 09:45:14 -08:00
parent 621d30b84f
commit a10f84aa48
339 changed files with 18041 additions and 8969 deletions

View File

@@ -2,7 +2,14 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import supertest from 'supertest';
import type { Request, Response, NextFunction } from 'express';
import { createMockUserProfile, createMockSuggestedCorrection, createMockBrand, createMockRecipe, createMockRecipeComment, createMockUnmatchedFlyerItem } from '../tests/utils/mockFactories';
import {
createMockUserProfile,
createMockSuggestedCorrection,
createMockBrand,
createMockRecipe,
createMockRecipeComment,
createMockUnmatchedFlyerItem,
} from '../tests/utils/mockFactories';
import type { SuggestedCorrection, Brand, UserProfile, UnmatchedFlyerItem } from '../types';
import { NotFoundError } from '../services/db/errors.db'; // This can stay, it's a type/class not a module with side effects.
import { createTestApp } from '../tests/utils/createTestApp';
@@ -41,8 +48,8 @@ const { mockedDb } = vi.hoisted(() => {
findUserProfileById: vi.fn(),
deleteUserById: vi.fn(),
},
}
}
},
};
});
vi.mock('../services/db/index.db', () => ({
@@ -77,7 +84,9 @@ vi.mock('@bull-board/api/bullMQAdapter'); // Keep this mock for the adapter
vi.mock('@bull-board/express', () => ({
ExpressAdapter: class {
setBasePath = vi.fn();
getRouter = vi.fn().mockReturnValue((req: Request, res: Response, next: NextFunction) => next());
getRouter = vi
.fn()
.mockReturnValue((req: Request, res: Response, next: NextFunction) => next());
},
}));
@@ -105,9 +114,16 @@ vi.mock('./passport.routes', () => ({
import adminRouter from './admin.routes';
describe('Admin Content Management Routes (/api/admin)', () => {
const adminUser = createMockUserProfile({ role: 'admin', user: { user_id: 'admin-user-id', email: 'admin@test.com' } });
const adminUser = createMockUserProfile({
role: 'admin',
user: { user_id: 'admin-user-id', email: 'admin@test.com' },
});
// Create a single app instance with an admin user for all tests in this suite.
const app = createTestApp({ router: adminRouter, basePath: '/api/admin', authenticatedUser: adminUser });
const app = createTestApp({
router: adminRouter,
basePath: '/api/admin',
authenticatedUser: adminUser,
});
// Add a basic error handler to capture errors passed to next(err) and return JSON.
// This prevents unhandled error crashes in tests and ensures we get the 500 response we expect.
@@ -121,7 +137,9 @@ describe('Admin Content Management Routes (/api/admin)', () => {
describe('Corrections Routes', () => {
it('GET /corrections should return corrections data', async () => {
const mockCorrections: SuggestedCorrection[] = [createMockSuggestedCorrection({ suggested_correction_id: 1 })];
const mockCorrections: SuggestedCorrection[] = [
createMockSuggestedCorrection({ suggested_correction_id: 1 }),
];
vi.mocked(mockedDb.adminRepo.getSuggestedCorrections).mockResolvedValue(mockCorrections);
const response = await supertest(app).get('/api/admin/corrections');
expect(response.status).toBe(200);
@@ -129,7 +147,9 @@ describe('Admin Content Management Routes (/api/admin)', () => {
});
it('should return 500 if the database call fails', async () => {
vi.mocked(mockedDb.adminRepo.getSuggestedCorrections).mockRejectedValue(new Error('DB Error'));
vi.mocked(mockedDb.adminRepo.getSuggestedCorrections).mockRejectedValue(
new Error('DB Error'),
);
const response = await supertest(app).get('/api/admin/corrections');
expect(response.status).toBe(500);
expect(response.body.message).toBe('DB Error');
@@ -141,7 +161,10 @@ describe('Admin Content Management Routes (/api/admin)', () => {
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(vi.mocked(mockedDb.adminRepo.approveCorrection)).toHaveBeenCalledWith(correctionId, expect.anything());
expect(vi.mocked(mockedDb.adminRepo.approveCorrection)).toHaveBeenCalledWith(
correctionId,
expect.anything(),
);
});
it('POST /corrections/:id/approve should return 500 on DB error', async () => {
@@ -169,9 +192,16 @@ describe('Admin Content Management Routes (/api/admin)', () => {
it('PUT /corrections/:id should update a correction', async () => {
const correctionId = 101;
const requestBody = { suggested_value: 'A new corrected value' };
const mockUpdatedCorrection = createMockSuggestedCorrection({ suggested_correction_id: correctionId, ...requestBody });
vi.mocked(mockedDb.adminRepo.updateSuggestedCorrection).mockResolvedValue(mockUpdatedCorrection);
const response = await supertest(app).put(`/api/admin/corrections/${correctionId}`).send(requestBody);
const mockUpdatedCorrection = createMockSuggestedCorrection({
suggested_correction_id: correctionId,
...requestBody,
});
vi.mocked(mockedDb.adminRepo.updateSuggestedCorrection).mockResolvedValue(
mockUpdatedCorrection,
);
const response = await supertest(app)
.put(`/api/admin/corrections/${correctionId}`)
.send(requestBody);
expect(response.status).toBe(200);
expect(response.body).toEqual(mockUpdatedCorrection);
});
@@ -184,8 +214,12 @@ describe('Admin Content Management Routes (/api/admin)', () => {
});
it('PUT /corrections/:id should return 404 if correction not found', async () => {
vi.mocked(mockedDb.adminRepo.updateSuggestedCorrection).mockRejectedValue(new NotFoundError('Correction with ID 999 not found'));
const response = await supertest(app).put('/api/admin/corrections/999').send({ suggested_value: 'new value' });
vi.mocked(mockedDb.adminRepo.updateSuggestedCorrection).mockRejectedValue(
new NotFoundError('Correction with ID 999 not found'),
);
const response = await supertest(app)
.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');
});
@@ -208,24 +242,33 @@ describe('Admin Content Management Routes (/api/admin)', () => {
.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(vi.mocked(mockedDb.adminRepo.updateBrandLogo)).toHaveBeenCalledWith(brandId, expect.stringContaining('/assets/'), expect.anything());
expect(vi.mocked(mockedDb.adminRepo.updateBrandLogo)).toHaveBeenCalledWith(
brandId,
expect.stringContaining('/assets/'),
expect.anything(),
);
});
it('POST /brands/:id/logo should return 500 on DB error', async () => {
const brandId = 55;
vi.mocked(mockedDb.adminRepo.updateBrandLogo).mockRejectedValue(new Error('DB Error'));
const response = await supertest(app).post(`/api/admin/brands/${brandId}/logo`).attach('logoImage', Buffer.from('dummy-logo-content'), 'test-logo.png');
const response = await supertest(app)
.post(`/api/admin/brands/${brandId}/logo`)
.attach('logoImage', Buffer.from('dummy-logo-content'), 'test-logo.png');
expect(response.status).toBe(500);
});
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(/Logo image file is required|The request data is invalid/);
expect(response.body.message).toMatch(
/Logo image file is required|The request data is invalid/,
);
});
it('POST /brands/:id/logo should return 400 for an invalid brand ID', async () => {
const response = await supertest(app).post('/api/admin/brands/abc/logo')
const response = await supertest(app)
.post('/api/admin/brands/abc/logo')
.attach('logoImage', Buffer.from('dummy-logo-content'), 'test-logo.png');
expect(response.status).toBe(400);
});
@@ -238,7 +281,12 @@ describe('Admin Content Management Routes (/api/admin)', () => {
const response = await supertest(app).delete(`/api/admin/recipes/${recipeId}`);
expect(response.status).toBe(204);
expect(vi.mocked(mockedDb.recipeRepo.deleteRecipe)).toHaveBeenCalledWith(recipeId, expect.anything(), true, expect.anything());
expect(vi.mocked(mockedDb.recipeRepo.deleteRecipe)).toHaveBeenCalledWith(
recipeId,
expect.anything(),
true,
expect.anything(),
);
});
it('DELETE /recipes/:recipeId should return 400 for invalid ID', async () => {
@@ -258,7 +306,9 @@ describe('Admin Content Management Routes (/api/admin)', () => {
const requestBody = { status: 'public' as const };
const mockUpdatedRecipe = createMockRecipe({ recipe_id: recipeId, status: 'public' });
vi.mocked(mockedDb.adminRepo.updateRecipeStatus).mockResolvedValue(mockUpdatedRecipe);
const response = await supertest(app).put(`/api/admin/recipes/${recipeId}/status`).send(requestBody);
const response = await supertest(app)
.put(`/api/admin/recipes/${recipeId}/status`)
.send(requestBody);
expect(response.status).toBe(200);
expect(response.body).toEqual(mockUpdatedRecipe);
});
@@ -266,7 +316,9 @@ describe('Admin Content Management Routes (/api/admin)', () => {
it('PUT /recipes/:id/status should return 400 for an invalid status value', async () => {
const recipeId = 201;
const requestBody = { status: 'invalid_status' };
const response = await supertest(app).put(`/api/admin/recipes/${recipeId}/status`).send(requestBody);
const response = await supertest(app)
.put(`/api/admin/recipes/${recipeId}/status`)
.send(requestBody);
expect(response.status).toBe(400);
});
@@ -274,16 +326,23 @@ describe('Admin Content Management Routes (/api/admin)', () => {
const recipeId = 201;
const requestBody = { status: 'public' as const };
vi.mocked(mockedDb.adminRepo.updateRecipeStatus).mockRejectedValue(new Error('DB Error'));
const response = await supertest(app).put(`/api/admin/recipes/${recipeId}/status`).send(requestBody);
const response = await supertest(app)
.put(`/api/admin/recipes/${recipeId}/status`)
.send(requestBody);
expect(response.status).toBe(500);
});
it('PUT /comments/:id/status should update a comment status', async () => {
const commentId = 301;
const requestBody = { status: 'hidden' as const };
const mockUpdatedComment = createMockRecipeComment({ recipe_comment_id: commentId, status: 'hidden' }); // This was a duplicate, fixed.
const mockUpdatedComment = createMockRecipeComment({
recipe_comment_id: commentId,
status: 'hidden',
}); // This was a duplicate, fixed.
vi.mocked(mockedDb.adminRepo.updateRecipeCommentStatus).mockResolvedValue(mockUpdatedComment);
const response = await supertest(app).put(`/api/admin/comments/${commentId}/status`).send(requestBody);
const response = await supertest(app)
.put(`/api/admin/comments/${commentId}/status`)
.send(requestBody);
expect(response.status).toBe(200);
expect(response.body).toEqual(mockUpdatedComment);
});
@@ -291,24 +350,34 @@ describe('Admin Content Management Routes (/api/admin)', () => {
it('PUT /comments/:id/status should return 400 for an invalid status value', async () => {
const commentId = 301;
const requestBody = { status: 'invalid_status' };
const response = await supertest(app).put(`/api/admin/comments/${commentId}/status`).send(requestBody);
const response = await supertest(app)
.put(`/api/admin/comments/${commentId}/status`)
.send(requestBody);
expect(response.status).toBe(400);
});
it('PUT /comments/:id/status should return 500 on DB error', async () => {
const commentId = 301;
const requestBody = { status: 'hidden' as const };
vi.mocked(mockedDb.adminRepo.updateRecipeCommentStatus).mockRejectedValue(new Error('DB Error'));
const response = await supertest(app).put(`/api/admin/comments/${commentId}/status`).send(requestBody);
vi.mocked(mockedDb.adminRepo.updateRecipeCommentStatus).mockRejectedValue(
new Error('DB Error'),
);
const response = await supertest(app)
.put(`/api/admin/comments/${commentId}/status`)
.send(requestBody);
expect(response.status).toBe(500);
});
});
describe('Unmatched Items Route', () => {
it('GET /unmatched-items should return a list of unmatched items', async () => {
// Correctly create a mock for UnmatchedFlyerItem.
const mockUnmatchedItems: UnmatchedFlyerItem[] = [createMockUnmatchedFlyerItem({ unmatched_flyer_item_id: 1, flyer_item_name: 'Mystery Item' })];
const mockUnmatchedItems: UnmatchedFlyerItem[] = [
createMockUnmatchedFlyerItem({
unmatched_flyer_item_id: 1,
flyer_item_name: 'Mystery Item',
}),
];
vi.mocked(mockedDb.adminRepo.getUnmatchedFlyerItems).mockResolvedValue(mockUnmatchedItems);
const response = await supertest(app).get('/api/admin/unmatched-items');
expect(response.status).toBe(200);
@@ -329,12 +398,17 @@ describe('Admin Content Management Routes (/api/admin)', () => {
const response = await supertest(app).delete(`/api/admin/flyers/${flyerId}`);
expect(response.status).toBe(204);
expect(vi.mocked(mockedDb.flyerRepo.deleteFlyer)).toHaveBeenCalledWith(flyerId, expect.anything());
expect(vi.mocked(mockedDb.flyerRepo.deleteFlyer)).toHaveBeenCalledWith(
flyerId,
expect.anything(),
);
});
it('DELETE /flyers/:flyerId should return 404 if flyer not found', async () => {
const flyerId = 999;
vi.mocked(mockedDb.flyerRepo.deleteFlyer).mockRejectedValue(new NotFoundError('Flyer with ID 999 not found.'));
vi.mocked(mockedDb.flyerRepo.deleteFlyer).mockRejectedValue(
new NotFoundError('Flyer with ID 999 not found.'),
);
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.');
@@ -346,4 +420,4 @@ describe('Admin Content Management Routes (/api/admin)', () => {
expect(response.body.errors[0].message).toMatch(/Expected number, received nan/i);
});
});
});
});

View File

@@ -12,7 +12,7 @@ import { mockLogger } from '../tests/utils/mockLogger';
vi.mock('../services/backgroundJobService', () => ({
backgroundJobService: {
runDailyDealCheck: vi.fn(),
}
},
}));
// Mock the queue service and other dependencies of admin.routes.ts
@@ -47,7 +47,9 @@ vi.mock('@bull-board/api/bullMQAdapter');
vi.mock('@bull-board/express', () => ({
ExpressAdapter: class {
setBasePath = vi.fn();
getRouter = vi.fn().mockReturnValue((req: Request, res: Response, next: NextFunction) => next());
getRouter = vi
.fn()
.mockReturnValue((req: Request, res: Response, next: NextFunction) => next());
},
}));
@@ -56,7 +58,12 @@ import adminRouter from './admin.routes';
// Import the mocked modules to control them
import { backgroundJobService } from '../services/backgroundJobService'; // This is now a mock
import { flyerQueue, analyticsQueue, cleanupQueue, weeklyAnalyticsQueue } from '../services/queueService.server';
import {
flyerQueue,
analyticsQueue,
cleanupQueue,
weeklyAnalyticsQueue,
} from '../services/queueService.server';
// Mock the logger
vi.mock('../services/logger.server', () => ({
@@ -79,9 +86,16 @@ vi.mock('./passport.routes', () => ({
}));
describe('Admin Job Trigger Routes (/api/admin/trigger)', () => {
const adminUser = createMockUserProfile({ role: 'admin', user: { user_id: 'admin-user-id', email: 'admin@test.com' } });
const adminUser = createMockUserProfile({
role: 'admin',
user: { user_id: 'admin-user-id', email: 'admin@test.com' },
});
// Create a single app instance with an admin user for all tests in this suite.
const app = createTestApp({ router: adminRouter, basePath: '/api/admin', authenticatedUser: adminUser });
const app = createTestApp({
router: adminRouter,
basePath: '/api/admin',
authenticatedUser: adminUser,
});
// Add a basic error handler to capture errors passed to next(err) and return JSON.
// This prevents unhandled error crashes in tests and ensures we get the 500 response we expect.
@@ -103,7 +117,9 @@ describe('Admin Job Trigger Routes (/api/admin/trigger)', () => {
});
it('should return 500 if triggering the job fails', async () => {
vi.mocked(backgroundJobService.runDailyDealCheck).mockImplementation(() => { throw new Error('Job runner failed'); });
vi.mocked(backgroundJobService.runDailyDealCheck).mockImplementation(() => {
throw new Error('Job runner failed');
});
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');
@@ -117,7 +133,9 @@ describe('Admin Job Trigger Routes (/api/admin/trigger)', () => {
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(analyticsQueue.add).toHaveBeenCalledWith('generate-daily-report', { reportDate: 'FAIL' });
expect(analyticsQueue.add).toHaveBeenCalledWith('generate-daily-report', {
reportDate: 'FAIL',
});
});
it('should return 500 if enqueuing the job fails', async () => {
@@ -137,7 +155,11 @@ describe('Admin Job Trigger Routes (/api/admin/trigger)', () => {
expect(response.status).toBe(202);
expect(response.body.message).toContain('Analytics report generation job has been enqueued');
expect(analyticsQueue.add).toHaveBeenCalledWith('generate-daily-report', expect.objectContaining({ reportDate: expect.any(String) }), expect.any(Object));
expect(analyticsQueue.add).toHaveBeenCalledWith(
'generate-daily-report',
expect.objectContaining({ reportDate: expect.any(String) }),
expect.any(Object),
);
});
it('should return 500 if enqueuing the analytics job fails', async () => {
@@ -156,7 +178,11 @@ describe('Admin Job Trigger Routes (/api/admin/trigger)', () => {
expect(response.status).toBe(202);
expect(response.body.message).toContain('Successfully enqueued weekly analytics job');
expect(weeklyAnalyticsQueue.add).toHaveBeenCalledWith('generate-weekly-report', expect.objectContaining({ reportYear: expect.any(Number), reportWeek: expect.any(Number) }), expect.any(Object));
expect(weeklyAnalyticsQueue.add).toHaveBeenCalledWith(
'generate-weekly-report',
expect.objectContaining({ reportYear: expect.any(Number), reportWeek: expect.any(Number) }),
expect.any(Object),
);
});
it('should return 500 if enqueuing the weekly analytics job fails', async () => {
@@ -173,7 +199,9 @@ 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(`File cleanup job for flyer ID ${flyerId} has been enqueued.`);
expect(response.body.message).toBe(
`File cleanup job for flyer ID ${flyerId} has been enqueued.`,
);
expect(cleanupQueue.add).toHaveBeenCalledWith('cleanup-flyer-files', { flyerId });
});
@@ -222,7 +250,9 @@ describe('Admin Job Trigger Routes (/api/admin/trigger)', () => {
it('should return 404 if the job ID is not found in the queue', async () => {
vi.mocked(flyerQueue.getJob).mockResolvedValue(undefined);
const response = await supertest(app).post(`/api/admin/jobs/${queueName}/not-found-job/retry`);
const response = await supertest(app).post(
`/api/admin/jobs/${queueName}/not-found-job/retry`,
);
expect(response.status).toBe(404);
expect(response.body.message).toContain('not found in queue');
});
@@ -238,7 +268,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(400);
expect(response.body.message).toBe("Job is not in a 'failed' state. Current state: completed."); // This is now handled by the errorHandler
expect(response.body.message).toBe(
"Job is not in a 'failed' state. Current state: completed.",
); // This is now handled by the errorHandler
expect(mockJob.retry).not.toHaveBeenCalled();
});
@@ -262,4 +294,4 @@ describe('Admin Job Trigger Routes (/api/admin/trigger)', () => {
expect(response.status).toBe(400);
});
});
});
});

View File

@@ -2,10 +2,7 @@
import { describe, it, expect, vi, beforeEach, type Mocked } from 'vitest';
import supertest from 'supertest';
import type { Request, Response, NextFunction } from 'express';
import {
createMockUserProfile,
createMockActivityLogItem,
} from '../tests/utils/mockFactories';
import { createMockUserProfile, createMockActivityLogItem } from '../tests/utils/mockFactories';
import type { UserProfile } from '../types';
import { createTestApp } from '../tests/utils/createTestApp';
import { mockLogger } from '../tests/utils/mockLogger';
@@ -59,7 +56,9 @@ vi.mock('@bull-board/api/bullMQAdapter');
vi.mock('@bull-board/express', () => ({
ExpressAdapter: class {
setBasePath = vi.fn();
getRouter = vi.fn().mockReturnValue((req: Request, res: Response, next: NextFunction) => next());
getRouter = vi
.fn()
.mockReturnValue((req: Request, res: Response, next: NextFunction) => next());
},
}));
@@ -92,9 +91,16 @@ vi.mock('./passport.routes', () => ({
}));
describe('Admin Monitoring Routes (/api/admin)', () => {
const adminUser = createMockUserProfile({ role: 'admin', user: { user_id: 'admin-user-id', email: 'admin@test.com' } });
const adminUser = createMockUserProfile({
role: 'admin',
user: { user_id: 'admin-user-id', email: 'admin@test.com' },
});
// Create a single app instance with an admin user for all tests in this suite.
const app = createTestApp({ router: adminRouter, basePath: '/api/admin', authenticatedUser: adminUser });
const app = createTestApp({
router: adminRouter,
basePath: '/api/admin',
authenticatedUser: adminUser,
});
// Add a basic error handler to capture errors passed to next(err) and return JSON.
// This prevents unhandled error crashes in tests and ensures we get the 500 response we expect.
@@ -161,11 +167,46 @@ describe('Admin Monitoring Routes (/api/admin)', () => {
describe('GET /queues/status', () => {
it('should return job counts for all registered queues', async () => {
// Arrange: Set the mock job counts for each queue
vi.mocked(mockedQueueService.flyerQueue.getJobCounts).mockResolvedValue({ waiting: 5, active: 1, completed: 100, failed: 2, delayed: 0, paused: 0 });
vi.mocked(mockedQueueService.emailQueue.getJobCounts).mockResolvedValue({ waiting: 0, active: 0, completed: 50, failed: 0, delayed: 0, paused: 0 });
vi.mocked(mockedQueueService.analyticsQueue.getJobCounts).mockResolvedValue({ waiting: 0, active: 1, completed: 10, failed: 1, delayed: 0, paused: 0 });
vi.mocked(mockedQueueService.cleanupQueue.getJobCounts).mockResolvedValue({ waiting: 2, active: 0, completed: 25, failed: 0, delayed: 0, paused: 0 });
vi.mocked(mockedQueueService.weeklyAnalyticsQueue.getJobCounts).mockResolvedValue({ waiting: 1, active: 0, completed: 5, failed: 0, delayed: 0, paused: 0 });
vi.mocked(mockedQueueService.flyerQueue.getJobCounts).mockResolvedValue({
waiting: 5,
active: 1,
completed: 100,
failed: 2,
delayed: 0,
paused: 0,
});
vi.mocked(mockedQueueService.emailQueue.getJobCounts).mockResolvedValue({
waiting: 0,
active: 0,
completed: 50,
failed: 0,
delayed: 0,
paused: 0,
});
vi.mocked(mockedQueueService.analyticsQueue.getJobCounts).mockResolvedValue({
waiting: 0,
active: 1,
completed: 10,
failed: 1,
delayed: 0,
paused: 0,
});
vi.mocked(mockedQueueService.cleanupQueue.getJobCounts).mockResolvedValue({
waiting: 2,
active: 0,
completed: 25,
failed: 0,
delayed: 0,
paused: 0,
});
vi.mocked(mockedQueueService.weeklyAnalyticsQueue.getJobCounts).mockResolvedValue({
waiting: 1,
active: 0,
completed: 5,
failed: 0,
delayed: 0,
paused: 0,
});
// Act
const response = await supertest(app).get('/api/admin/queues/status');
@@ -185,7 +226,10 @@ describe('Admin Monitoring Routes (/api/admin)', () => {
name: 'analytics-reporting',
counts: { waiting: 0, active: 1, completed: 10, failed: 1, delayed: 0, paused: 0 },
},
{ name: 'file-cleanup', counts: { waiting: 2, active: 0, completed: 25, failed: 0, delayed: 0, paused: 0 } },
{
name: 'file-cleanup',
counts: { waiting: 2, active: 0, completed: 25, failed: 0, delayed: 0, paused: 0 },
},
{
name: 'weekly-analytics-reporting',
counts: { waiting: 1, active: 0, completed: 5, failed: 0, delayed: 0, paused: 0 },
@@ -194,11 +238,13 @@ describe('Admin Monitoring Routes (/api/admin)', () => {
});
it('should return 500 if fetching queue counts fails', async () => {
vi.mocked(mockedQueueService.flyerQueue.getJobCounts).mockRejectedValue(new Error('Redis is down'));
vi.mocked(mockedQueueService.flyerQueue.getJobCounts).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');
});
});
});
});

View File

@@ -2,7 +2,7 @@
import { Router, NextFunction, Request, Response } from 'express';
import passport from './passport.routes';
import { isAdmin } from './passport.routes'; // Correctly imported
import multer from 'multer';// --- Zod Schemas for Admin Routes (as per ADR-003) ---
import multer from 'multer'; // --- Zod Schemas for Admin Routes (as per ADR-003) ---
import { z } from 'zod';
import * as db from '../services/db/index.db';
@@ -20,27 +20,44 @@ import { ExpressAdapter } from '@bull-board/express';
import type { Queue } from 'bullmq';
import { backgroundJobService } from '../services/backgroundJobService';
import { flyerQueue, emailQueue, analyticsQueue, cleanupQueue, weeklyAnalyticsQueue, flyerWorker, emailWorker, analyticsWorker, cleanupWorker, weeklyAnalyticsWorker } from '../services/queueService.server'; // Import your queues
import {
flyerQueue,
emailQueue,
analyticsQueue,
cleanupQueue,
weeklyAnalyticsQueue,
flyerWorker,
emailWorker,
analyticsWorker,
cleanupWorker,
weeklyAnalyticsWorker,
} from '../services/queueService.server'; // Import your queues
import { getSimpleWeekAndYear } from '../utils/dateUtils';
// Helper for consistent required string validation (handles missing/null/empty)
const requiredString = (message: string) => z.preprocess((val) => val ?? '', z.string().min(1, message));
const requiredString = (message: string) =>
z.preprocess((val) => val ?? '', z.string().min(1, message));
/**
* A factory for creating a Zod schema that validates a UUID in the request parameters.
* @param key The name of the parameter key (e.g., 'userId').
* @param message A custom error message for invalid UUIDs.
*/
const uuidParamSchema = (key: string, message = `Invalid UUID for parameter '${key}'.`) => z.object({
params: z.object({ [key]: z.string().uuid({ message }) }),
});
const uuidParamSchema = (key: string, message = `Invalid UUID for parameter '${key}'.`) =>
z.object({
params: z.object({ [key]: z.string().uuid({ message }) }),
});
/**
* A factory for creating a Zod schema that validates a numeric ID in the request parameters.
*/
const numericIdParamSchema = (key: string, message = `Invalid ID for parameter '${key}'. Must be a positive integer.`) => z.object({
params: z.object({ [key]: z.coerce.number().int({ message }).positive({ message }) }),
});
const numericIdParamSchema = (
key: string,
message = `Invalid ID for parameter '${key}'. Must be a positive integer.`,
) =>
z.object({
params: z.object({ [key]: z.coerce.number().int({ message }).positive({ message }) }),
});
const updateCorrectionSchema = numericIdParamSchema('id').extend({
body: z.object({
@@ -75,7 +92,13 @@ const activityLogSchema = z.object({
const jobRetrySchema = z.object({
params: z.object({
queueName: z.enum(['flyer-processing', 'email-sending', 'analytics-reporting', 'file-cleanup', 'weekly-analytics-reporting']),
queueName: z.enum([
'flyer-processing',
'email-sending',
'analytics-reporting',
'file-cleanup',
'weekly-analytics-reporting',
]),
jobId: requiredString('A valid Job ID is required.'),
}),
});
@@ -83,15 +106,16 @@ const jobRetrySchema = z.object({
const router = Router();
// --- Multer Configuration for File Uploads ---
const storagePath = process.env.STORAGE_PATH || '/var/www/flyer-crawler.projectium.com/flyer-images';
const storagePath =
process.env.STORAGE_PATH || '/var/www/flyer-crawler.projectium.com/flyer-images';
const storage = multer.diskStorage({
destination: function (req, file, cb) {
cb(null, storagePath);
},
filename: function (req, file, cb) {
const uniqueSuffix = Date.now() + '-' + Math.round(Math.random() * 1E9);
cb(null, file.fieldname + '-' + uniqueSuffix + '-' + file.originalname);
}
destination: function (req, file, cb) {
cb(null, storagePath);
},
filename: function (req, file, cb) {
const uniqueSuffix = Date.now() + '-' + Math.round(Math.random() * 1e9);
cb(null, file.fieldname + '-' + uniqueSuffix + '-' + file.originalname);
},
});
const upload = multer({ storage: storage });
@@ -161,129 +185,174 @@ router.get('/stats/daily', async (req, res, next: NextFunction) => {
}
});
router.post('/corrections/:id/approve', validateRequest(numericIdParamSchema('id')), async (req: Request, res: Response, next: NextFunction) => {
router.post(
'/corrections/:id/approve',
validateRequest(numericIdParamSchema('id')),
async (req: Request, res: Response, next: NextFunction) => {
// Apply ADR-003 pattern for type safety
const { params } = req as unknown as z.infer<ReturnType<typeof numericIdParamSchema>>;
try {
await db.adminRepo.approveCorrection(params.id, req.log); // params.id is now safely typed as number
res.status(200).json({ message: 'Correction approved successfully.' });
await db.adminRepo.approveCorrection(params.id, req.log); // params.id is now safely typed as number
res.status(200).json({ message: 'Correction approved successfully.' });
} catch (error) {
next(error);
next(error);
}
});
},
);
router.post('/corrections/:id/reject', validateRequest(numericIdParamSchema('id')), async (req: Request, res: Response, next: NextFunction) => {
router.post(
'/corrections/:id/reject',
validateRequest(numericIdParamSchema('id')),
async (req: Request, res: Response, next: NextFunction) => {
// Apply ADR-003 pattern for type safety
const { params } = req as unknown as z.infer<ReturnType<typeof numericIdParamSchema>>;
try {
await db.adminRepo.rejectCorrection(params.id, req.log); // params.id is now safely typed as number
res.status(200).json({ message: 'Correction rejected successfully.' });
await db.adminRepo.rejectCorrection(params.id, req.log); // params.id is now safely typed as number
res.status(200).json({ message: 'Correction rejected successfully.' });
} catch (error) {
next(error);
next(error);
}
});
},
);
router.put('/corrections/:id', validateRequest(updateCorrectionSchema), async (req: Request, res: Response, next: NextFunction) => {
router.put(
'/corrections/:id',
validateRequest(updateCorrectionSchema),
async (req: Request, res: Response, next: NextFunction) => {
// Apply ADR-003 pattern for type safety
const { params, body } = req as unknown as z.infer<typeof updateCorrectionSchema>;
try {
const updatedCorrection = await db.adminRepo.updateSuggestedCorrection(params.id, body.suggested_value, req.log);
res.status(200).json(updatedCorrection);
const updatedCorrection = await db.adminRepo.updateSuggestedCorrection(
params.id,
body.suggested_value,
req.log,
);
res.status(200).json(updatedCorrection);
} catch (error) {
next(error);
next(error);
}
});
},
);
router.put('/recipes/:id/status', validateRequest(updateRecipeStatusSchema), async (req: Request, res: Response, next: NextFunction) => {
router.put(
'/recipes/:id/status',
validateRequest(updateRecipeStatusSchema),
async (req: Request, res: Response, next: NextFunction) => {
// Apply ADR-003 pattern for type safety
const { params, body } = req as unknown as z.infer<typeof updateRecipeStatusSchema>;
try {
const updatedRecipe = await db.adminRepo.updateRecipeStatus(params.id, body.status, req.log); // This is still a standalone function in admin.db.ts
res.status(200).json(updatedRecipe);
const updatedRecipe = await db.adminRepo.updateRecipeStatus(params.id, body.status, req.log); // This is still a standalone function in admin.db.ts
res.status(200).json(updatedRecipe);
} catch (error) {
next(error); // Pass all errors to the central error handler
next(error); // Pass all errors to the central error handler
}
});
},
);
router.post('/brands/:id/logo', validateRequest(numericIdParamSchema('id')), upload.single('logoImage'), requireFileUpload('logoImage'), async (req: Request, res: Response, next: NextFunction) => {
router.post(
'/brands/:id/logo',
validateRequest(numericIdParamSchema('id')),
upload.single('logoImage'),
requireFileUpload('logoImage'),
async (req: Request, res: Response, next: NextFunction) => {
// Apply ADR-003 pattern for type safety
const { params } = req as unknown as z.infer<ReturnType<typeof numericIdParamSchema>>;
try {
// Although requireFileUpload middleware should ensure the file exists,
// this check satisfies TypeScript and adds robustness.
if (!req.file) {
throw new ValidationError([], 'Logo image file is missing.');
}
const logoUrl = `/assets/${req.file.filename}`;
await db.adminRepo.updateBrandLogo(params.id, logoUrl, req.log);
// Although requireFileUpload middleware should ensure the file exists,
// this check satisfies TypeScript and adds robustness.
if (!req.file) {
throw new ValidationError([], 'Logo image file is missing.');
}
const logoUrl = `/assets/${req.file.filename}`;
await db.adminRepo.updateBrandLogo(params.id, logoUrl, req.log);
logger.info({ brandId: params.id, logoUrl }, `Brand logo updated for brand ID: ${params.id}`);
res.status(200).json({ message: 'Brand logo updated successfully.', logoUrl });
logger.info({ brandId: params.id, logoUrl }, `Brand logo updated for brand ID: ${params.id}`);
res.status(200).json({ message: 'Brand logo updated successfully.', logoUrl });
} catch (error) {
next(error);
next(error);
}
});
},
);
router.get('/unmatched-items', async (req, res, next: NextFunction) => {
try {
try {
const items = await db.adminRepo.getUnmatchedFlyerItems(req.log);
res.json(items);
} catch (error) {
next(error);
}
} catch (error) {
next(error);
}
});
/**
* DELETE /api/admin/recipes/:recipeId - Admin endpoint to delete any recipe.
*/
router.delete('/recipes/:recipeId', validateRequest(numericIdParamSchema('recipeId')), async (req: Request, res: Response, next: NextFunction) => {
router.delete(
'/recipes/:recipeId',
validateRequest(numericIdParamSchema('recipeId')),
async (req: Request, res: Response, next: NextFunction) => {
const userProfile = req.user as UserProfile;
// Infer the type directly from the schema generator function. // This was a duplicate, fixed.
const { params } = req as unknown as z.infer<ReturnType<typeof numericIdParamSchema>>;
try {
// The isAdmin flag bypasses the ownership check in the repository method.
await db.recipeRepo.deleteRecipe(params.recipeId, userProfile.user.user_id, true, req.log);
res.status(204).send();
// The isAdmin flag bypasses the ownership check in the repository method.
await db.recipeRepo.deleteRecipe(params.recipeId, userProfile.user.user_id, true, req.log);
res.status(204).send();
} catch (error: unknown) {
next(error);
next(error);
}
});
},
);
/**
* DELETE /api/admin/flyers/:flyerId - Admin endpoint to delete a flyer and its items.
*/
router.delete('/flyers/:flyerId', validateRequest(numericIdParamSchema('flyerId')), async (req: Request, res: Response, next: NextFunction) => {
router.delete(
'/flyers/:flyerId',
validateRequest(numericIdParamSchema('flyerId')),
async (req: Request, res: Response, next: NextFunction) => {
// Infer the type directly from the schema generator function.
const { params } = req as unknown as z.infer<ReturnType<typeof numericIdParamSchema>>;
try {
await db.flyerRepo.deleteFlyer(params.flyerId, req.log);
res.status(204).send();
await db.flyerRepo.deleteFlyer(params.flyerId, req.log);
res.status(204).send();
} catch (error: unknown) {
next(error);
next(error);
}
});
},
);
router.put('/comments/:id/status', validateRequest(updateCommentStatusSchema), async (req: Request, res: Response, next: NextFunction) => {
router.put(
'/comments/:id/status',
validateRequest(updateCommentStatusSchema),
async (req: Request, res: Response, next: NextFunction) => {
// Apply ADR-003 pattern for type safety
const { params, body } = req as unknown as z.infer<typeof updateCommentStatusSchema>;
try {
const updatedComment = await db.adminRepo.updateRecipeCommentStatus(params.id, body.status, req.log); // This is still a standalone function in admin.db.ts
res.status(200).json(updatedComment);
const updatedComment = await db.adminRepo.updateRecipeCommentStatus(
params.id,
body.status,
req.log,
); // This is still a standalone function in admin.db.ts
res.status(200).json(updatedComment);
} catch (error: unknown) {
next(error);
next(error);
}
});
},
);
router.get('/users', async (req, res, next: NextFunction) => {
try {
try {
const users = await db.adminRepo.getAllUsers(req.log);
res.json(users);
} catch (error) {
next(error);
}
} catch (error) {
next(error);
}
});
router.get('/activity-log', validateRequest(activityLogSchema), async (req: Request, res: Response, next: NextFunction) => {
router.get(
'/activity-log',
validateRequest(activityLogSchema),
async (req: Request, res: Response, next: NextFunction) => {
// Apply ADR-003 pattern for type safety.
// We explicitly coerce query params here because the validation middleware might not
// replace req.query with the coerced values in all environments.
@@ -292,162 +361,215 @@ router.get('/activity-log', validateRequest(activityLogSchema), async (req: Requ
const offset = query.offset ? Number(query.offset) : 0;
try {
const logs = await db.adminRepo.getActivityLog(limit, offset, req.log);
res.json(logs);
const logs = await db.adminRepo.getActivityLog(limit, offset, req.log);
res.json(logs);
} catch (error) {
next(error);
next(error);
}
});
},
);
router.get('/users/:id', validateRequest(uuidParamSchema('id', 'A valid user ID is required.')), async (req: Request, res: Response, next: NextFunction) => {
router.get(
'/users/:id',
validateRequest(uuidParamSchema('id', 'A valid user ID is required.')),
async (req: Request, res: Response, next: NextFunction) => {
// Apply ADR-003 pattern for type safety
const { params } = req as unknown as z.infer<ReturnType<typeof uuidParamSchema>>;
try {
const user = await db.userRepo.findUserProfileById(params.id, req.log);
res.json(user);
const user = await db.userRepo.findUserProfileById(params.id, req.log);
res.json(user);
} catch (error) {
next(error);
next(error);
}
});
},
);
router.put('/users/:id', validateRequest(updateUserRoleSchema), async (req: Request, res: Response, next: NextFunction) => {
router.put(
'/users/:id',
validateRequest(updateUserRoleSchema),
async (req: Request, res: Response, next: NextFunction) => {
// Apply ADR-003 pattern for type safety
const { params, body } = req as unknown as z.infer<typeof updateUserRoleSchema>;
try {
const updatedUser = await db.adminRepo.updateUserRole(params.id, body.role, req.log);
res.json(updatedUser);
const updatedUser = await db.adminRepo.updateUserRole(params.id, body.role, req.log);
res.json(updatedUser);
} catch (error) {
logger.error({ error }, `Error updating user ${params.id}:`);
next(error);
logger.error({ error }, `Error updating user ${params.id}:`);
next(error);
}
});
},
);
router.delete('/users/:id', validateRequest(uuidParamSchema('id', 'A valid user ID is required.')), async (req: Request, res: Response, next: NextFunction) => {
router.delete(
'/users/:id',
validateRequest(uuidParamSchema('id', 'A valid user ID is required.')),
async (req: Request, res: Response, next: NextFunction) => {
const userProfile = req.user as UserProfile;
// Apply ADR-003 pattern for type safety
const { params } = req as unknown as z.infer<ReturnType<typeof uuidParamSchema>>;
try {
if (userProfile.user.user_id === params.id) {
throw new ValidationError([], 'Admins cannot delete their own account.');
}
await db.userRepo.deleteUserById(params.id, req.log);
res.status(204).send();
if (userProfile.user.user_id === params.id) {
throw new ValidationError([], 'Admins cannot delete their own account.');
}
await db.userRepo.deleteUserById(params.id, req.log);
res.status(204).send();
} catch (error) {
next(error);
next(error);
}
});
},
);
/**
* POST /api/admin/trigger/daily-deal-check - Manually trigger the daily deal check job.
* This is useful for testing or forcing an update without waiting for the cron schedule.
*/
router.post('/trigger/daily-deal-check', async (req: Request, res: Response, next: NextFunction) => {
router.post(
'/trigger/daily-deal-check',
async (req: Request, res: Response, next: NextFunction) => {
const userProfile = req.user as UserProfile;
logger.info(`[Admin] Manual trigger for daily deal check received from user: ${userProfile.user.user_id}`);
logger.info(
`[Admin] Manual trigger for daily deal check received from user: ${userProfile.user.user_id}`,
);
try {
// We call the function but don't wait for it to finish (no `await`).
// This is a "fire-and-forget" operation from the client's perspective.
backgroundJobService.runDailyDealCheck();
res.status(202).json({ message: 'Daily deal check job has been triggered successfully. It will run in the background.' });
// We call the function but don't wait for it to finish (no `await`).
// This is a "fire-and-forget" operation from the client's perspective.
backgroundJobService.runDailyDealCheck();
res
.status(202)
.json({
message:
'Daily deal check job has been triggered successfully. It will run in the background.',
});
} catch (error) {
logger.error({ error }, '[Admin] Failed to trigger daily deal check job.');
next(error);
logger.error({ error }, '[Admin] Failed to trigger daily deal check job.');
next(error);
}
});
},
);
/**
* POST /api/admin/trigger/analytics-report - Manually enqueue a job to generate the daily analytics report.
* This is useful for testing or re-generating a report without waiting for the cron schedule.
*/
router.post('/trigger/analytics-report', async (req: Request, res: Response, next: NextFunction) => {
router.post(
'/trigger/analytics-report',
async (req: Request, res: Response, next: NextFunction) => {
const userProfile = req.user as UserProfile;
logger.info(`[Admin] Manual trigger for analytics report generation received from user: ${userProfile.user.user_id}`);
logger.info(
`[Admin] Manual trigger for analytics report generation received from user: ${userProfile.user.user_id}`,
);
try {
const reportDate = new Date().toISOString().split('T')[0]; // YYYY-MM-DD
// Use a unique job ID for manual triggers to distinguish them from scheduled jobs.
const jobId = `manual-report-${reportDate}-${Date.now()}`;
const reportDate = new Date().toISOString().split('T')[0]; // YYYY-MM-DD
// Use a unique job ID for manual triggers to distinguish them from scheduled jobs.
const jobId = `manual-report-${reportDate}-${Date.now()}`;
const job = await analyticsQueue.add('generate-daily-report', { reportDate }, { jobId });
const job = await analyticsQueue.add('generate-daily-report', { reportDate }, { jobId });
res.status(202).json({ message: `Analytics report generation job has been enqueued successfully. Job ID: ${job.id}` });
res
.status(202)
.json({
message: `Analytics report generation job has been enqueued successfully. Job ID: ${job.id}`,
});
} catch (error) {
logger.error({ error }, '[Admin] Failed to enqueue analytics report job.');
next(error);
logger.error({ error }, '[Admin] Failed to enqueue analytics report job.');
next(error);
}
});
},
);
/**
* POST /api/admin/flyers/:flyerId/cleanup - Enqueue a job to clean up a flyer's files.
* This is triggered by an admin after they have verified the flyer processing was successful.
*/
router.post('/flyers/:flyerId/cleanup', validateRequest(numericIdParamSchema('flyerId')), async (req: Request, res: Response, next: NextFunction) => {
router.post(
'/flyers/:flyerId/cleanup',
validateRequest(numericIdParamSchema('flyerId')),
async (req: Request, res: Response, next: NextFunction) => {
const userProfile = req.user as UserProfile;
// Infer type from the schema generator for type safety, as per ADR-003.
const { params } = req as unknown as z.infer<ReturnType<typeof numericIdParamSchema>>; // This was a duplicate, fixed.
logger.info(`[Admin] Manual trigger for flyer file cleanup received from user: ${userProfile.user.user_id} for flyer ID: ${params.flyerId}`);
logger.info(
`[Admin] Manual trigger for flyer file cleanup received from user: ${userProfile.user.user_id} for flyer ID: ${params.flyerId}`,
);
// Enqueue the cleanup job. The worker will handle the file deletion.
try {
await cleanupQueue.add('cleanup-flyer-files', { flyerId: params.flyerId });
res.status(202).json({ message: `File cleanup job for flyer ID ${params.flyerId} has been enqueued.` });
await cleanupQueue.add('cleanup-flyer-files', { flyerId: params.flyerId });
res
.status(202)
.json({ message: `File cleanup job for flyer ID ${params.flyerId} has been enqueued.` });
} catch (error) {
next(error);
next(error);
}
});
},
);
/**
* POST /api/admin/trigger/failing-job - Enqueue a test job designed to fail.
* This is for testing the retry mechanism and Bull Board UI.
*/
router.post('/trigger/failing-job', async (req: Request, res: Response, next: NextFunction) => {
const userProfile = req.user as UserProfile;
logger.info(`[Admin] Manual trigger for a failing job received from user: ${userProfile.user.user_id}`);
const userProfile = req.user as UserProfile;
logger.info(
`[Admin] Manual trigger for a failing job received from user: ${userProfile.user.user_id}`,
);
try {
// Add a job with a special 'forceFail' flag that the worker will recognize.
const job = await analyticsQueue.add('generate-daily-report', { reportDate: 'FAIL' });
res.status(202).json({ message: `Failing test job has been enqueued successfully. Job ID: ${job.id}` });
} catch (error) {
next(error);
}
try {
// Add a job with a special 'forceFail' flag that the worker will recognize.
const job = await analyticsQueue.add('generate-daily-report', { reportDate: 'FAIL' });
res
.status(202)
.json({ message: `Failing test job has been enqueued successfully. Job ID: ${job.id}` });
} catch (error) {
next(error);
}
});
/**
* POST /api/admin/system/clear-geocode-cache - Clears the Redis cache for geocoded addresses.
* Requires admin privileges.
*/
router.post('/system/clear-geocode-cache', async (req: Request, res: Response, next: NextFunction) => {
router.post(
'/system/clear-geocode-cache',
async (req: Request, res: Response, next: NextFunction) => {
const userProfile = req.user as UserProfile;
logger.info(`[Admin] Manual trigger for geocode cache clear received from user: ${userProfile.user.user_id}`);
logger.info(
`[Admin] Manual trigger for geocode cache clear received from user: ${userProfile.user.user_id}`,
);
try {
const keysDeleted = await geocodingService.clearGeocodeCache(req.log);
res.status(200).json({ message: `Successfully cleared the geocode cache. ${keysDeleted} keys were removed.` });
const keysDeleted = await geocodingService.clearGeocodeCache(req.log);
res
.status(200)
.json({
message: `Successfully cleared the geocode cache. ${keysDeleted} keys were removed.`,
});
} catch (error) {
logger.error({ error }, '[Admin] Failed to clear geocode cache.');
next(error);
logger.error({ error }, '[Admin] Failed to clear geocode cache.');
next(error);
}
});
},
);
/**
* GET /api/admin/workers/status - Get the current running status of all BullMQ workers.
* This is useful for a system health dashboard to see if any workers have crashed.
*/
router.get('/workers/status', async (req: Request, res: Response) => {
const workers = [flyerWorker, emailWorker, analyticsWorker, cleanupWorker, weeklyAnalyticsWorker ];
const workerStatuses = await Promise.all(
workers.map(async (worker) => {
return {
name: worker.name,
isRunning: worker.isRunning(),
};
})
);
const workers = [flyerWorker, emailWorker, analyticsWorker, cleanupWorker, weeklyAnalyticsWorker];
res.json(workerStatuses);
const workerStatuses = await Promise.all(
workers.map(async (worker) => {
return {
name: worker.name,
isRunning: worker.isRunning(),
};
}),
);
res.json(workerStatuses);
});
/**
@@ -455,77 +577,108 @@ router.get('/workers/status', async (req: Request, res: Response) => {
* This is useful for monitoring the health and backlog of background jobs.
*/
router.get('/queues/status', async (req: Request, res: Response, next: NextFunction) => {
try {
const queues = [flyerQueue, emailQueue, analyticsQueue, cleanupQueue, weeklyAnalyticsQueue];
const queueStatuses = await Promise.all(
queues.map(async (queue) => {
return {
name: queue.name,
counts: await queue.getJobCounts('waiting', 'active', 'completed', 'failed', 'delayed', 'paused'),
};
})
);
res.json(queueStatuses);
} catch (error) {
next(error);
}
});
/**
* POST /api/admin/jobs/:queueName/:jobId/retry - Retries a specific failed job.
*/
router.post('/jobs/:queueName/:jobId/retry', validateRequest(jobRetrySchema), async (req: Request, res: Response, next: NextFunction) => {
const userProfile = req.user as UserProfile;
const { params: { queueName, jobId } } = req as unknown as z.infer<typeof jobRetrySchema>;
const queueMap: { [key: string]: Queue } = {
'flyer-processing': flyerQueue,
'email-sending': emailQueue,
'analytics-reporting': analyticsQueue,
'file-cleanup': cleanupQueue,
};
const queue = queueMap[queueName];
if (!queue) {
// Throw a NotFoundError to be handled by the central error handler.
throw new NotFoundError(`Queue '${queueName}' not found.`);
}
try {
const job = await queue.getJob(jobId);
if (!job) throw new NotFoundError(`Job with ID '${jobId}' not found in queue '${queueName}'.`);
const jobState = await job.getState();
if (jobState !== 'failed') throw new ValidationError([], `Job is not in a 'failed' state. Current state: ${jobState}.`); // This was a duplicate, fixed.
await job.retry();
logger.info(`[Admin] User ${userProfile.user.user_id} manually retried job ${jobId} in queue ${queueName}.`);
res.status(200).json({ message: `Job ${jobId} has been successfully marked for retry.` });
} catch (error) {
next(error);
}
});
/**
* POST /api/admin/trigger/weekly-analytics - Manually trigger the weekly analytics report job.
*/
router.post('/trigger/weekly-analytics', async (req: Request, res: Response, next: NextFunction) => {
const userProfile = req.user as UserProfile; // This was a duplicate, fixed.
logger.info(`[Admin] Manual trigger for weekly analytics report received from user: ${userProfile.user.user_id}`);
try {
const { year: reportYear, week: reportWeek } = getSimpleWeekAndYear();
const { weeklyAnalyticsQueue } = await import('../services/queueService.server');
const job = await weeklyAnalyticsQueue.add('generate-weekly-report', { reportYear, reportWeek }, {
jobId: `manual-weekly-report-${reportYear}-${reportWeek}-${Date.now()}` // Add timestamp to avoid ID conflict
});
const queues = [flyerQueue, emailQueue, analyticsQueue, cleanupQueue, weeklyAnalyticsQueue];
res.status(202).json({ message: 'Successfully enqueued weekly analytics job.', jobId: job.id });
const queueStatuses = await Promise.all(
queues.map(async (queue) => {
return {
name: queue.name,
counts: await queue.getJobCounts(
'waiting',
'active',
'completed',
'failed',
'delayed',
'paused',
),
};
}),
);
res.json(queueStatuses);
} catch (error) {
next(error);
}
});
export default router;
/**
* POST /api/admin/jobs/:queueName/:jobId/retry - Retries a specific failed job.
*/
router.post(
'/jobs/:queueName/:jobId/retry',
validateRequest(jobRetrySchema),
async (req: Request, res: Response, next: NextFunction) => {
const userProfile = req.user as UserProfile;
const {
params: { queueName, jobId },
} = req as unknown as z.infer<typeof jobRetrySchema>;
const queueMap: { [key: string]: Queue } = {
'flyer-processing': flyerQueue,
'email-sending': emailQueue,
'analytics-reporting': analyticsQueue,
'file-cleanup': cleanupQueue,
};
const queue = queueMap[queueName];
if (!queue) {
// Throw a NotFoundError to be handled by the central error handler.
throw new NotFoundError(`Queue '${queueName}' not found.`);
}
try {
const job = await queue.getJob(jobId);
if (!job)
throw new NotFoundError(`Job with ID '${jobId}' not found in queue '${queueName}'.`);
const jobState = await job.getState();
if (jobState !== 'failed')
throw new ValidationError(
[],
`Job is not in a 'failed' state. Current state: ${jobState}.`,
); // This was a duplicate, fixed.
await job.retry();
logger.info(
`[Admin] User ${userProfile.user.user_id} manually retried job ${jobId} in queue ${queueName}.`,
);
res.status(200).json({ message: `Job ${jobId} has been successfully marked for retry.` });
} catch (error) {
next(error);
}
},
);
/**
* POST /api/admin/trigger/weekly-analytics - Manually trigger the weekly analytics report job.
*/
router.post(
'/trigger/weekly-analytics',
async (req: Request, res: Response, next: NextFunction) => {
const userProfile = req.user as UserProfile; // This was a duplicate, fixed.
logger.info(
`[Admin] Manual trigger for weekly analytics report received from user: ${userProfile.user.user_id}`,
);
try {
const { year: reportYear, week: reportWeek } = getSimpleWeekAndYear();
const { weeklyAnalyticsQueue } = await import('../services/queueService.server');
const job = await weeklyAnalyticsQueue.add(
'generate-weekly-report',
{ reportYear, reportWeek },
{
jobId: `manual-weekly-report-${reportYear}-${reportWeek}-${Date.now()}`, // Add timestamp to avoid ID conflict
},
);
res
.status(202)
.json({ message: 'Successfully enqueued weekly analytics job.', jobId: job.id });
} catch (error) {
next(error);
}
},
);
export default router;

View File

@@ -32,8 +32,10 @@ vi.mock('@bull-board/api/bullMQAdapter');
vi.mock('@bull-board/express', () => ({
ExpressAdapter: class {
setBasePath() {}
getRouter() { return (req: Request, res: Response, next: NextFunction) => next(); }
}
getRouter() {
return (req: Request, res: Response, next: NextFunction) => next();
}
},
}));
// Import the router AFTER all mocks are defined.
@@ -65,7 +67,11 @@ vi.mock('./passport.routes', () => ({
describe('Admin Stats Routes (/api/admin/stats)', () => {
const adminUser = createMockUserProfile({ role: 'admin' });
// Create a single app instance with an admin user for all tests in this suite.
const app = createTestApp({ router: adminRouter, basePath: '/api/admin', authenticatedUser: adminUser });
const app = createTestApp({
router: adminRouter,
basePath: '/api/admin',
authenticatedUser: adminUser,
});
// Add a basic error handler to capture errors passed to next(err) and return JSON.
// This prevents unhandled error crashes in tests and ensures we get the 500 response we expect.
@@ -79,7 +85,14 @@ describe('Admin Stats Routes (/api/admin/stats)', () => {
describe('GET /stats', () => {
it('should return application stats on success', async () => {
const mockStats = { flyerCount: 150, userCount: 42, flyerItemCount: 10000, storeCount: 12, pendingCorrectionCount: 5, recipeCount: 50 };
const mockStats = {
flyerCount: 150,
userCount: 42,
flyerItemCount: 10000,
storeCount: 12,
pendingCorrectionCount: 5,
recipeCount: 50,
};
vi.mocked(adminRepo.getApplicationStats).mockResolvedValue(mockStats);
const response = await supertest(app).get('/api/admin/stats');
expect(response.status).toBe(200);
@@ -113,4 +126,4 @@ describe('Admin Stats Routes (/api/admin/stats)', () => {
expect(response.body.message).toBe('DB Error');
});
});
});
});

View File

@@ -37,7 +37,9 @@ vi.mock('@bull-board/api/bullMQAdapter');
vi.mock('@bull-board/express', () => ({
ExpressAdapter: class {
setBasePath = vi.fn();
getRouter = vi.fn().mockReturnValue((req: Request, res: Response, next: NextFunction) => next());
getRouter = vi
.fn()
.mockReturnValue((req: Request, res: Response, next: NextFunction) => next());
},
}));
@@ -56,7 +58,10 @@ vi.mock('../services/logger.server', () => ({
vi.mock('./passport.routes', () => ({
default: {
authenticate: vi.fn(() => (req: Request, res: Response, next: NextFunction) => {
req.user = createMockUserProfile({ role: 'admin', user: { user_id: 'admin-user-id', email: 'admin@test.com' } });
req.user = createMockUserProfile({
role: 'admin',
user: { user_id: 'admin-user-id', email: 'admin@test.com' },
});
next();
}),
},
@@ -64,8 +69,15 @@ vi.mock('./passport.routes', () => ({
}));
describe('Admin System Routes (/api/admin/system)', () => {
const adminUser = createMockUserProfile({ role: 'admin', user: { user_id: 'admin-user-id', email: 'admin@test.com' } });
const app = createTestApp({ router: adminRouter, basePath: '/api/admin', authenticatedUser: adminUser });
const adminUser = createMockUserProfile({
role: 'admin',
user: { user_id: 'admin-user-id', email: 'admin@test.com' },
});
const app = createTestApp({
router: adminRouter,
basePath: '/api/admin',
authenticatedUser: adminUser,
});
// Add a basic error handler to capture errors passed to next(err) and return JSON.
// This prevents unhandled error crashes in tests and ensures we get the 500 response we expect.
@@ -92,4 +104,4 @@ describe('Admin System Routes (/api/admin/system)', () => {
expect(response.body.message).toContain('Redis is down');
});
});
});
});

View File

@@ -37,7 +37,9 @@ vi.mock('node:fs/promises');
vi.mock('@bull-board/express', () => ({
ExpressAdapter: class {
setBasePath = vi.fn();
getRouter = vi.fn().mockReturnValue((req: Request, res: Response, next: NextFunction) => next());
getRouter = vi
.fn()
.mockReturnValue((req: Request, res: Response, next: NextFunction) => next());
},
}));
@@ -70,9 +72,16 @@ vi.mock('./passport.routes', () => ({
describe('Admin User Management Routes (/api/admin/users)', () => {
const adminId = '123e4567-e89b-12d3-a456-426614174000';
const userId = '123e4567-e89b-12d3-a456-426614174001';
const adminUser = createMockUserProfile({ role: 'admin', user: { user_id: adminId, email: 'admin@test.com' } });
const adminUser = createMockUserProfile({
role: 'admin',
user: { user_id: adminId, email: 'admin@test.com' },
});
// Create a single app instance with an admin user for all tests in this suite.
const app = createTestApp({ router: adminRouter, basePath: '/api/admin', authenticatedUser: adminUser });
const app = createTestApp({
router: adminRouter,
basePath: '/api/admin',
authenticatedUser: adminUser,
});
// Add a basic error handler to capture errors passed to next(err) and return JSON.
// This prevents unhandled error crashes in tests and ensures we get the 500 response we expect.
@@ -118,7 +127,9 @@ describe('Admin User Management Routes (/api/admin/users)', () => {
it('should return 404 for a non-existent user', async () => {
const missingId = '123e4567-e89b-12d3-a456-426614174999';
vi.mocked(userRepo.findUserProfileById).mockRejectedValue(new NotFoundError('User not found.'));
vi.mocked(userRepo.findUserProfileById).mockRejectedValue(
new NotFoundError('User not found.'),
);
const response = await supertest(app).get(`/api/admin/users/${missingId}`);
expect(response.status).toBe(404);
expect(response.body.message).toBe('User not found.');
@@ -139,7 +150,9 @@ describe('Admin User Management Routes (/api/admin/users)', () => {
// We create the mock object manually to match the Profile type.
const updatedUser: Profile = {
role: 'admin',
points: 0, created_at: new Date().toISOString(), updated_at: new Date().toISOString()
points: 0,
created_at: new Date().toISOString(),
updated_at: new Date().toISOString(),
};
vi.mocked(adminRepo.updateUserRole).mockResolvedValue(updatedUser);
const response = await supertest(app)
@@ -152,8 +165,12 @@ describe('Admin User Management Routes (/api/admin/users)', () => {
it('should return 404 for a non-existent user', async () => {
const missingId = '123e4567-e89b-12d3-a456-426614174999';
vi.mocked(adminRepo.updateUserRole).mockRejectedValue(new NotFoundError(`User with ID ${missingId} not found.`));
const response = await supertest(app).put(`/api/admin/users/${missingId}`).send({ role: 'user' });
vi.mocked(adminRepo.updateUserRole).mockRejectedValue(
new NotFoundError(`User with ID ${missingId} not found.`),
);
const response = await supertest(app)
.put(`/api/admin/users/${missingId}`)
.send({ role: 'user' });
expect(response.status).toBe(404);
expect(response.body.message).toBe(`User with ID ${missingId} not found.`);
});
@@ -161,7 +178,9 @@ describe('Admin User Management Routes (/api/admin/users)', () => {
it('should return 500 on a generic database error', async () => {
const dbError = new Error('DB Error');
vi.mocked(adminRepo.updateUserRole).mockRejectedValue(dbError);
const response = await supertest(app).put(`/api/admin/users/${userId}`).send({ role: 'admin' });
const response = await supertest(app)
.put(`/api/admin/users/${userId}`)
.send({ role: 'admin' });
expect(response.status).toBe(500);
expect(response.body.message).toBe('DB Error');
});
@@ -198,4 +217,4 @@ describe('Admin User Management Routes (/api/admin/users)', () => {
expect(response.status).toBe(500);
});
});
});
});

View File

@@ -5,7 +5,11 @@ import fs from 'node:fs';
import { type Request, type Response, type NextFunction } from 'express';
import path from 'node:path';
import type { Job } from 'bullmq';
import { createMockUserProfile, createMockFlyer, createMockAddress } from '../tests/utils/mockFactories';
import {
createMockUserProfile,
createMockFlyer,
createMockAddress,
} from '../tests/utils/mockFactories';
import * as aiService from '../services/aiService.server';
import { createTestApp } from '../tests/utils/createTestApp';
import { mockLogger } from '../tests/utils/mockLogger';
@@ -33,7 +37,10 @@ const { mockedDb } = vi.hoisted(() => ({
vi.mock('../services/db/flyer.db', () => ({ createFlyerAndItems: mockedDb.createFlyerAndItems }));
vi.mock('../services/db/index.db', () => ({ flyerRepo: mockedDb.flyerRepo, adminRepo: mockedDb.adminRepo }));
vi.mock('../services/db/index.db', () => ({
flyerRepo: mockedDb.flyerRepo,
adminRepo: mockedDb.adminRepo,
}));
// Mock the queue service
vi.mock('../services/queueService.server', () => ({
@@ -77,7 +84,9 @@ describe('AI Routes (/api/ai)', () => {
vi.resetModules(); // Reset modules to re-run top-level code
vi.doMock('node:fs', () => ({
...fs,
mkdirSync: vi.fn().mockImplementation(() => { throw mkdirError; }),
mkdirSync: vi.fn().mockImplementation(() => {
throw mkdirError;
}),
}));
const { logger } = await import('../services/logger.server');
@@ -85,8 +94,12 @@ describe('AI Routes (/api/ai)', () => {
await import('./ai.routes');
// Assert
const storagePath = process.env.STORAGE_PATH || '/var/www/flyer-crawler.projectium.com/flyer-images';
expect(logger.error).toHaveBeenCalledWith({ error: 'EACCES: permission denied' }, `Failed to create storage path (${storagePath}). File uploads may fail.`);
const storagePath =
process.env.STORAGE_PATH || '/var/www/flyer-crawler.projectium.com/flyer-images';
expect(logger.error).toHaveBeenCalledWith(
{ error: 'EACCES: permission denied' },
`Failed to create storage path (${storagePath}). File uploads may fail.`,
);
vi.doUnmock('node:fs'); // Cleanup
});
});
@@ -135,7 +148,9 @@ describe('AI Routes (/api/ai)', () => {
});
it('should return 409 if flyer checksum already exists', async () => {
vi.mocked(mockedDb.flyerRepo.findFlyerByChecksum).mockResolvedValue(createMockFlyer({ flyer_id: 99 }));
vi.mocked(mockedDb.flyerRepo.findFlyerByChecksum).mockResolvedValue(
createMockFlyer({ flyer_id: 99 }),
);
const response = await supertest(app)
.post('/api/ai/upload-and-process')
@@ -162,8 +177,14 @@ describe('AI Routes (/api/ai)', () => {
it('should pass user ID to the job when authenticated', async () => {
// Arrange: Create a new app instance specifically for this test
// with the authenticated user middleware already applied.
const mockUser = createMockUserProfile({ user: { user_id: 'auth-user-1', email: 'auth-user-1@test.com' } });
const authenticatedApp = createTestApp({ router: aiRouter, basePath: '/api/ai', authenticatedUser: mockUser });
const mockUser = createMockUserProfile({
user: { user_id: 'auth-user-1', email: 'auth-user-1@test.com' },
});
const authenticatedApp = createTestApp({
router: aiRouter,
basePath: '/api/ai',
authenticatedUser: mockUser,
});
vi.mocked(mockedDb.flyerRepo.findFlyerByChecksum).mockResolvedValue(undefined);
vi.mocked(flyerQueue.add).mockResolvedValue({ id: 'job-456' } as unknown as Job);
@@ -193,13 +214,22 @@ describe('AI Routes (/api/ai)', () => {
user: { user_id: 'auth-user-2', email: 'auth-user-2@test.com' },
address: mockAddress,
});
const authenticatedApp = createTestApp({ router: aiRouter, basePath: '/api/ai', authenticatedUser: mockUserWithAddress });
const authenticatedApp = createTestApp({
router: aiRouter,
basePath: '/api/ai',
authenticatedUser: mockUserWithAddress,
});
// Act
await supertest(authenticatedApp).post('/api/ai/upload-and-process').field('checksum', 'addr-checksum').attach('flyerFile', imagePath);
await supertest(authenticatedApp)
.post('/api/ai/upload-and-process')
.field('checksum', 'addr-checksum')
.attach('flyerFile', imagePath);
// Assert
expect(vi.mocked(flyerQueue.add).mock.calls[0][1].userProfileAddress).toBe('123 Pacific St, Anytown, BC, V8T 1A1, CA');
expect(vi.mocked(flyerQueue.add).mock.calls[0][1].userProfileAddress).toBe(
'123 Pacific St, Anytown, BC, V8T 1A1, CA',
);
});
});
@@ -215,7 +245,12 @@ describe('AI Routes (/api/ai)', () => {
});
it('should return job status if job is found', async () => {
const mockJob = { id: 'job-123', getState: async () => 'completed', progress: 100, returnvalue: { flyerId: 1 } };
const mockJob = {
id: 'job-123',
getState: async () => 'completed',
progress: 100,
returnvalue: { flyerId: 1 },
};
vi.mocked(flyerQueue.getJob).mockResolvedValue(mockJob as unknown as Job);
const response = await supertest(app).get('/api/ai/jobs/job-123/status');
@@ -224,7 +259,7 @@ describe('AI Routes (/api/ai)', () => {
expect(response.body.state).toBe('completed');
});
// Removed flaky test 'should return 400 for an invalid job ID format'
// Removed flaky test 'should return 400 for an invalid job ID format'
// because URL parameters cannot easily simulate empty strings for min(1) validation checks via supertest routing.
});
@@ -287,7 +322,7 @@ describe('AI Routes (/api/ai)', () => {
const partialPayload = {
checksum: 'test-checksum-2',
originalFileName: 'flyer2.jpg',
extractedData: { store_name: 'Partial Store' } // no items key
extractedData: { store_name: 'Partial Store' }, // no items key
};
vi.mocked(mockedDb.flyerRepo.findFlyerByChecksum).mockResolvedValue(undefined);
@@ -316,7 +351,7 @@ describe('AI Routes (/api/ai)', () => {
const payloadNoStore = {
checksum: 'test-checksum-3',
originalFileName: 'flyer3.jpg',
extractedData: { items: [] } // store_name missing
extractedData: { items: [] }, // store_name missing
};
vi.mocked(mockedDb.flyerRepo.findFlyerByChecksum).mockResolvedValue(undefined);
@@ -338,7 +373,7 @@ describe('AI Routes (/api/ai)', () => {
expect(flyerDataArg.store_name).toContain('Unknown Store');
// Also verify the warning was logged
expect(mockLogger.warn).toHaveBeenCalledWith(
'extractedData.store_name missing; using fallback store name to avoid DB constraint error.'
'extractedData.store_name missing; using fallback store name to avoid DB constraint error.',
);
});
@@ -354,7 +389,7 @@ describe('AI Routes (/api/ai)', () => {
expect(response.status).toBe(500);
});
});
describe('POST /flyers/process (Legacy Payload Variations)', () => {
const imagePath = path.resolve(__dirname, '../tests/assets/test-flyer-image.jpg');
const mockDataPayload = {
@@ -412,13 +447,15 @@ describe('AI Routes (/api/ai)', () => {
it('should return 500 on a generic error', async () => {
// To trigger the catch block, we can cause the middleware to fail.
// Mock logger.info to throw, which is inside the try block.
vi.mocked(mockLogger.info).mockImplementation(() => { throw new Error('Logging failed'); });
vi.mocked(mockLogger.info).mockImplementation(() => {
throw new Error('Logging failed');
});
// Attach a valid file to get past the `if (!req.file)` check.
const response = await supertest(app).post('/api/ai/check-flyer').attach('image', imagePath);
expect(response.status).toBe(500);
});
});
describe('POST /rescan-area', () => {
const imagePath = path.resolve(__dirname, '../tests/assets/test-flyer-image.jpg');
it('should return 400 if image file is missing', async () => {
@@ -435,7 +472,9 @@ 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(/cropArea must be a valid JSON string|Required/i);
expect(response.body.errors[0].message).toMatch(
/cropArea must be a valid JSON string|Required/i,
);
});
it('should return 400 if cropArea is malformed JSON', async () => {
@@ -455,7 +494,9 @@ describe('AI Routes (/api/ai)', () => {
});
it('should return 200 with a stubbed response on success', async () => {
const response = await supertest(app).post('/api/ai/extract-address').attach('image', imagePath);
const response = await supertest(app)
.post('/api/ai/extract-address')
.attach('image', imagePath);
expect(response.status).toBe(200);
expect(response.body.address).toBe('not identified');
});
@@ -463,8 +504,12 @@ describe('AI Routes (/api/ai)', () => {
it('should return 500 on a generic error', async () => {
// An empty buffer can sometimes cause underlying libraries to throw an error
// To reliably trigger the catch block, mock the logger to throw.
vi.mocked(mockLogger.info).mockImplementation(() => { throw new Error('Logging failed'); });
const response = await supertest(app).post('/api/ai/extract-address').attach('image', imagePath);
vi.mocked(mockLogger.info).mockImplementation(() => {
throw new Error('Logging failed');
});
const response = await supertest(app)
.post('/api/ai/extract-address')
.attach('image', imagePath);
expect(response.status).toBe(500);
});
});
@@ -477,7 +522,9 @@ describe('AI Routes (/api/ai)', () => {
});
it('should return 200 with a stubbed response on success', async () => {
const response = await supertest(app).post('/api/ai/extract-logo').attach('images', imagePath);
const response = await supertest(app)
.post('/api/ai/extract-logo')
.attach('images', imagePath);
expect(response.status).toBe(200);
expect(response.body.store_logo_base_64).toBeNull();
});
@@ -485,15 +532,22 @@ describe('AI Routes (/api/ai)', () => {
it('should return 500 on a generic error', async () => {
// An empty buffer can sometimes cause underlying libraries to throw an error
// To reliably trigger the catch block, mock the logger to throw.
vi.mocked(mockLogger.info).mockImplementation(() => { throw new Error('Logging failed'); });
const response = await supertest(app).post('/api/ai/extract-logo').attach('images', imagePath);
vi.mocked(mockLogger.info).mockImplementation(() => {
throw new Error('Logging failed');
});
const response = await supertest(app)
.post('/api/ai/extract-logo')
.attach('images', imagePath);
expect(response.status).toBe(500);
});
});
describe('POST /rescan-area (authenticated)', () => { // This was a duplicate, fixed.
describe('POST /rescan-area (authenticated)', () => {
// This was a duplicate, fixed.
const imagePath = path.resolve(__dirname, '../tests/assets/test-flyer-image.jpg'); // This was a duplicate, fixed.
const mockUser = createMockUserProfile({ user: { user_id: 'user-123', email: 'user-123@test.com' } });
const mockUser = createMockUserProfile({
user: { user_id: 'user-123', email: 'user-123@test.com' },
});
beforeEach(() => {
// Inject an authenticated user for this test block
@@ -519,7 +573,9 @@ describe('AI Routes (/api/ai)', () => {
});
it('should return 500 if the AI service throws an error (authenticated)', async () => {
vi.mocked(aiService.aiService.extractTextFromImageArea).mockRejectedValue(new Error('AI API is down'));
vi.mocked(aiService.aiService.extractTextFromImageArea).mockRejectedValue(
new Error('AI API is down'),
);
const response = await supertest(app)
.post('/api/ai/rescan-area')
@@ -534,7 +590,9 @@ describe('AI Routes (/api/ai)', () => {
});
describe('when user is authenticated', () => {
const mockUserProfile = createMockUserProfile({ user: { user_id: 'user-123', email: 'user-123@test.com' } });
const mockUserProfile = createMockUserProfile({
user: { user_id: 'user-123', email: 'user-123@test.com' },
});
beforeEach(() => {
// For this block, simulate an authenticated request by attaching the user.
@@ -555,7 +613,9 @@ describe('AI Routes (/api/ai)', () => {
it('POST /quick-insights should return 500 on a generic error', async () => {
// To hit the catch block, we can simulate an error by making the logger throw.
vi.mocked(mockLogger.info).mockImplementation(() => { throw new Error('Logging failed'); });
vi.mocked(mockLogger.info).mockImplementation(() => {
throw new Error('Logging failed');
});
const response = await supertest(app)
.post('/api/ai/quick-insights')
.send({ items: [{ name: 'test' }] });
@@ -571,9 +631,7 @@ describe('AI Routes (/api/ai)', () => {
});
it('POST /generate-image should return 501 Not Implemented', async () => {
const response = await supertest(app)
.post('/api/ai/generate-image')
.send({ prompt: 'test' });
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.');
@@ -620,7 +678,9 @@ describe('AI Routes (/api/ai)', () => {
});
it('POST /plan-trip should return 500 if the AI service fails', async () => {
vi.mocked(aiService.aiService.planTripWithMaps).mockRejectedValue(new Error('Maps API key invalid'));
vi.mocked(aiService.aiService.planTripWithMaps).mockRejectedValue(
new Error('Maps API key invalid'),
);
const response = await supertest(app)
.post('/api/ai/plan-trip')
@@ -654,4 +714,4 @@ describe('AI Routes (/api/ai)', () => {
expect(response.status).toBe(400);
});
});
});
});

View File

@@ -27,7 +27,8 @@ interface FlyerProcessPayload extends Partial<ExtractedCoreData> {
// --- Zod Schemas for AI Routes (as per ADR-003) ---
// Helper for consistent required string validation (handles missing/null/empty)
const requiredString = (message: string) => z.preprocess((val) => val ?? '', z.string().min(1, message));
const requiredString = (message: string) =>
z.preprocess((val) => val ?? '', z.string().min(1, message));
const uploadAndProcessSchema = z.object({
body: z.object({
@@ -45,9 +46,10 @@ const jobIdParamSchema = z.object({
// Helper to safely extract an error message from unknown `catch` values.
const errMsg = (e: unknown) => {
if (e instanceof Error) return e.message;
if (typeof e === 'object' && e !== null && 'message' in e) return String((e as { message: unknown }).message);
return String(e || 'An unknown error occurred.');
if (e instanceof Error) return e.message;
if (typeof e === 'object' && e !== null && 'message' in e)
return String((e as { message: unknown }).message);
return String(e || 'An unknown error occurred.');
};
const cropAreaObjectSchema = z.object({
@@ -59,24 +61,37 @@ const cropAreaObjectSchema = z.object({
const rescanAreaSchema = z.object({
body: z.object({
cropArea: requiredString('cropArea must be a valid JSON string.').transform((val, ctx) => {
try { return JSON.parse(val); }
catch (err) {
// Log the actual parsing error for better debugging if invalid JSON is sent.
logger.warn({ error: errMsg(err), receivedValue: val }, 'Failed to parse cropArea in rescanAreaSchema');
ctx.addIssue({ code: z.ZodIssueCode.custom, message: 'cropArea must be a valid JSON string.' }); return z.NEVER;
}
}).pipe(cropAreaObjectSchema), // Further validate the structure of the parsed object
extractionType: z.enum(['store_name', 'dates', 'item_details'], { // This is the line with the error
message: "extractionType must be one of 'store_name', 'dates', or 'item_details'."
cropArea: requiredString('cropArea must be a valid JSON string.')
.transform((val, ctx) => {
try {
return JSON.parse(val);
} catch (err) {
// Log the actual parsing error for better debugging if invalid JSON is sent.
logger.warn(
{ error: errMsg(err), receivedValue: val },
'Failed to parse cropArea in rescanAreaSchema',
);
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: 'cropArea must be a valid JSON string.',
});
return z.NEVER;
}
})
.pipe(cropAreaObjectSchema), // Further validate the structure of the parsed object
extractionType: z.enum(['store_name', 'dates', 'item_details'], {
// This is the line with the error
message: "extractionType must be one of 'store_name', 'dates', or 'item_details'.",
}),
}),
});
const flyerItemForAnalysisSchema = z.object({
name: requiredString("Item name is required."),
// Allow other properties to pass through without validation
}).passthrough();
const flyerItemForAnalysisSchema = z
.object({
name: requiredString('Item name is required.'),
// Allow other properties to pass through without validation
})
.passthrough();
const insightsSchema = z.object({
body: z.object({
@@ -93,10 +108,16 @@ const comparePricesSchema = z.object({
const planTripSchema = z.object({
body: z.object({
items: z.array(flyerItemForAnalysisSchema),
store: z.object({ name: requiredString("Store name is required.") }),
store: z.object({ name: requiredString('Store name is required.') }),
userLocation: z.object({
latitude: z.number().min(-90, 'Latitude must be between -90 and 90.').max(90, 'Latitude must be between -90 and 90.'),
longitude: z.number().min(-180, 'Longitude must be between -180 and 180.').max(180, 'Longitude must be between -180 and 180.'),
latitude: z
.number()
.min(-90, 'Latitude must be between -90 and 90.')
.max(90, 'Latitude must be between -90 and 90.'),
longitude: z
.number()
.min(-180, 'Longitude must be between -180 and 180.')
.max(180, 'Longitude must be between -180 and 180.'),
}),
}),
});
@@ -114,407 +135,541 @@ const searchWebSchema = z.object({
});
// --- Multer Configuration for File Uploads ---
const storagePath = process.env.STORAGE_PATH || '/var/www/flyer-crawler.projectium.com/flyer-images';
const storagePath =
process.env.STORAGE_PATH || '/var/www/flyer-crawler.projectium.com/flyer-images';
// Ensure the storage path exists at startup so multer can write files there.
try {
fs.mkdirSync(storagePath, { recursive: true });
logger.debug(`AI upload storage path ready: ${storagePath}`);
fs.mkdirSync(storagePath, { recursive: true });
logger.debug(`AI upload storage path ready: ${storagePath}`);
} catch (err) {
logger.error({ error: errMsg(err) }, `Failed to create storage path (${storagePath}). File uploads may fail.`);
logger.error(
{ error: errMsg(err) },
`Failed to create storage path (${storagePath}). File uploads may fail.`,
);
}
const diskStorage = multer.diskStorage({
destination: function (req, file, cb) {
cb(null, storagePath);
},
filename: function (req, file, cb) {
// If in a test environment, use a predictable filename for easy cleanup.
if (process.env.NODE_ENV === 'test') {
return cb(null, `${file.fieldname}-test-flyer-image.jpg`);
} else {
const uniqueSuffix = Date.now() + '-' + Math.round(Math.random() * 1E9);
// Sanitize the original filename to remove spaces and special characters
return cb(null, file.fieldname + '-' + uniqueSuffix + '-' + sanitizeFilename(file.originalname));
}
destination: function (req, file, cb) {
cb(null, storagePath);
},
filename: function (req, file, cb) {
// If in a test environment, use a predictable filename for easy cleanup.
if (process.env.NODE_ENV === 'test') {
return cb(null, `${file.fieldname}-test-flyer-image.jpg`);
} else {
const uniqueSuffix = Date.now() + '-' + Math.round(Math.random() * 1e9);
// Sanitize the original filename to remove spaces and special characters
return cb(
null,
file.fieldname + '-' + uniqueSuffix + '-' + sanitizeFilename(file.originalname),
);
}
},
});
const uploadToDisk = multer({ storage: diskStorage });
// Diagnostic middleware: log incoming AI route requests (headers and sizes)
router.use((req: Request, res: Response, next: NextFunction) => {
try {
const contentType = req.headers['content-type'] || '';
const contentLength = req.headers['content-length'] || 'unknown';
const authPresent = !!req.headers['authorization'];
logger.debug({ method: req.method, url: req.originalUrl, contentType, contentLength, authPresent }, '[API /ai] Incoming request');
} catch (e: unknown) {
logger.error({ error: e }, 'Failed to log incoming AI request headers');
}
next();
try {
const contentType = req.headers['content-type'] || '';
const contentLength = req.headers['content-length'] || 'unknown';
const authPresent = !!req.headers['authorization'];
logger.debug(
{ method: req.method, url: req.originalUrl, contentType, contentLength, authPresent },
'[API /ai] Incoming request',
);
} catch (e: unknown) {
logger.error({ error: e }, 'Failed to log incoming AI request headers');
}
next();
});
/**
* NEW ENDPOINT: Accepts a single flyer file (PDF or image), enqueues it for
* background processing, and immediately returns a job ID.
*/
router.post('/upload-and-process', optionalAuth, uploadToDisk.single('flyerFile'), validateRequest(uploadAndProcessSchema), async (req, res, next: NextFunction) => {
router.post(
'/upload-and-process',
optionalAuth,
uploadToDisk.single('flyerFile'),
validateRequest(uploadAndProcessSchema),
async (req, res, next: NextFunction) => {
try {
if (!req.file) {
return res.status(400).json({ message: 'A flyer file (PDF or image) is required.' });
}
if (!req.file) {
return res.status(400).json({ message: 'A flyer file (PDF or image) is required.' });
}
const { checksum } = req.body;
// Check for duplicate flyer using checksum before even creating a job
const existingFlyer = await db.flyerRepo.findFlyerByChecksum(checksum, req.log);
if (existingFlyer) {
logger.warn(`Duplicate flyer upload attempt blocked for checksum: ${checksum}`);
// Use 409 Conflict for duplicates
return res.status(409).json({ message: 'This flyer has already been processed.', flyerId: existingFlyer.flyer_id });
}
const { checksum } = req.body;
// Check for duplicate flyer using checksum before even creating a job
const existingFlyer = await db.flyerRepo.findFlyerByChecksum(checksum, req.log);
if (existingFlyer) {
logger.warn(`Duplicate flyer upload attempt blocked for checksum: ${checksum}`);
// Use 409 Conflict for duplicates
return res
.status(409)
.json({
message: 'This flyer has already been processed.',
flyerId: existingFlyer.flyer_id,
});
}
const userProfile = req.user as UserProfile | undefined;
// Construct a user address string from their profile if they are logged in.
let userProfileAddress: string | undefined = undefined;
if (userProfile?.address) {
userProfileAddress = [
userProfile.address.address_line_1,
userProfile.address.address_line_2,
userProfile.address.city,
userProfile.address.province_state,
userProfile.address.postal_code,
userProfile.address.country
].filter(Boolean).join(', ');
}
const userProfile = req.user as UserProfile | undefined;
// Construct a user address string from their profile if they are logged in.
let userProfileAddress: string | undefined = undefined;
if (userProfile?.address) {
userProfileAddress = [
userProfile.address.address_line_1,
userProfile.address.address_line_2,
userProfile.address.city,
userProfile.address.province_state,
userProfile.address.postal_code,
userProfile.address.country,
]
.filter(Boolean)
.join(', ');
}
// Add job to the queue
const job = await flyerQueue.add('process-flyer', {
filePath: req.file.path,
originalFileName: req.file.originalname,
checksum: checksum,
userId: userProfile?.user.user_id,
submitterIp: req.ip, // Capture the submitter's IP address
userProfileAddress: userProfileAddress, // Pass the user's profile address
});
// Add job to the queue
const job = await flyerQueue.add('process-flyer', {
filePath: req.file.path,
originalFileName: req.file.originalname,
checksum: checksum,
userId: userProfile?.user.user_id,
submitterIp: req.ip, // Capture the submitter's IP address
userProfileAddress: userProfileAddress, // Pass the user's profile address
});
logger.info(`Enqueued flyer for processing. File: ${req.file.originalname}, Job ID: ${job.id}`);
logger.info(
`Enqueued flyer for processing. File: ${req.file.originalname}, Job ID: ${job.id}`,
);
// Respond immediately to the client with 202 Accepted
res.status(202).json({
message: 'Flyer accepted for processing.',
jobId: job.id,
});
// Respond immediately to the client with 202 Accepted
res.status(202).json({
message: 'Flyer accepted for processing.',
jobId: job.id,
});
} catch (error) {
next(error);
next(error);
}
});
},
);
/**
* NEW ENDPOINT: Checks the status of a background job.
*/
router.get('/jobs/:jobId/status', validateRequest(jobIdParamSchema), async (req, res, next: NextFunction) => {
router.get(
'/jobs/:jobId/status',
validateRequest(jobIdParamSchema),
async (req, res, next: NextFunction) => {
type JobIdRequest = z.infer<typeof jobIdParamSchema>;
const { params: { jobId } } = req as unknown as JobIdRequest;
const {
params: { jobId },
} = req as unknown as JobIdRequest;
try {
const job = await flyerQueue.getJob(jobId);
if (!job) {
// Adhere to ADR-001 by throwing a specific error to be handled centrally.
return res.status(404).json({ message: 'Job not found.' });
}
const state = await job.getState();
const progress = job.progress;
const returnValue = job.returnvalue;
const failedReason = job.failedReason;
logger.debug(`[API /ai/jobs] Status check for job ${jobId}: ${state}`);
res.json({ id: job.id, state, progress, returnValue, failedReason });
const job = await flyerQueue.getJob(jobId);
if (!job) {
// Adhere to ADR-001 by throwing a specific error to be handled centrally.
return res.status(404).json({ message: 'Job not found.' });
}
const state = await job.getState();
const progress = job.progress;
const returnValue = job.returnvalue;
const failedReason = job.failedReason;
logger.debug(`[API /ai/jobs] Status check for job ${jobId}: ${state}`);
res.json({ id: job.id, state, progress, returnValue, failedReason });
} catch (error) {
next(error);
next(error);
}
});
},
);
/**
* This endpoint saves the processed flyer data to the database. It is the final step
* in the flyer upload workflow after the AI has extracted the data.
* It uses `optionalAuth` to handle submissions from both anonymous and authenticated users.
*/
router.post('/flyers/process', optionalAuth, uploadToDisk.single('flyerImage'), async (req, res, next: NextFunction) => {
router.post(
'/flyers/process',
optionalAuth,
uploadToDisk.single('flyerImage'),
async (req, res, next: NextFunction) => {
try {
if (!req.file) {
return res.status(400).json({ message: 'Flyer image file is required.' });
}
if (!req.file) {
return res.status(400).json({ message: 'Flyer image file is required.' });
}
// Diagnostic & tolerant parsing for flyers/process
logger.debug({ keys: Object.keys(req.body || {}) }, '[API /ai/flyers/process] req.body keys:');
logger.debug({ filePresent: !!req.file }, '[API /ai/flyers/process] file present:');
// Diagnostic & tolerant parsing for flyers/process
logger.debug(
{ keys: Object.keys(req.body || {}) },
'[API /ai/flyers/process] req.body keys:',
);
logger.debug({ filePresent: !!req.file }, '[API /ai/flyers/process] file present:');
// Try several ways to obtain the payload so we are tolerant to client variations.
let parsed: FlyerProcessPayload = {};
let extractedData: Partial<ExtractedCoreData> = {};
try {
// If the client sent a top-level `data` field (stringified JSON), parse it.
if (req.body && (req.body.data || req.body.extractedData)) {
const raw = (req.body.data ?? req.body.extractedData);
logger.debug({ type: typeof raw, length: raw?.length ?? 0 }, '[API /ai/flyers/process] raw extractedData');
try {
parsed = typeof raw === 'string' ? JSON.parse(raw) : raw;
} catch (err) {
logger.warn({ error: errMsg(err) }, '[API /ai/flyers/process] Failed to JSON.parse raw extractedData; falling back to direct assign');
parsed = (typeof raw === 'string' ? JSON.parse(String(raw).slice(0, 2000)) : raw) as FlyerProcessPayload;
}
// If parsed itself contains an `extractedData` field, use that, otherwise assume parsed is the extractedData
extractedData = parsed.extractedData ?? (parsed as Partial<ExtractedCoreData>);
} else {
// No explicit `data` field found. Attempt to interpret req.body as an object (Express may have parsed multipart fields differently).
try {
parsed = typeof req.body === 'string' ? JSON.parse(req.body) : req.body;
} catch (err) {
logger.warn({ error: errMsg(err) }, '[API /ai/flyers/process] Failed to JSON.parse req.body; using empty object');
parsed = req.body as FlyerProcessPayload || {};
}
// extractedData might be nested under `data` or `extractedData`, or the body itself may be the extracted data.
if (parsed.data) {
try {
const inner = typeof parsed.data === 'string' ? JSON.parse(parsed.data) : parsed.data;
extractedData = inner.extractedData ?? inner;
} catch (err) {
logger.warn({ error: errMsg(err) }, '[API /ai/flyers/process] Failed to parse parsed.data; falling back');
extractedData = parsed.data as unknown as Partial<ExtractedCoreData>;
}
} else if (parsed.extractedData) {
extractedData = parsed.extractedData;
} else {
// Assume the body itself is the extracted data if it looks like it (has items or store_name keys)
if ('items' in parsed || 'store_name' in parsed || 'valid_from' in parsed) {
extractedData = parsed as Partial<ExtractedCoreData>;
} else {
extractedData = {};
}
}
// Try several ways to obtain the payload so we are tolerant to client variations.
let parsed: FlyerProcessPayload = {};
let extractedData: Partial<ExtractedCoreData> = {};
try {
// If the client sent a top-level `data` field (stringified JSON), parse it.
if (req.body && (req.body.data || req.body.extractedData)) {
const raw = req.body.data ?? req.body.extractedData;
logger.debug(
{ type: typeof raw, length: raw?.length ?? 0 },
'[API /ai/flyers/process] raw extractedData',
);
try {
parsed = typeof raw === 'string' ? JSON.parse(raw) : raw;
} catch (err) {
logger.warn(
{ error: errMsg(err) },
'[API /ai/flyers/process] Failed to JSON.parse raw extractedData; falling back to direct assign',
);
parsed = (
typeof raw === 'string' ? JSON.parse(String(raw).slice(0, 2000)) : raw
) as FlyerProcessPayload;
}
// If parsed itself contains an `extractedData` field, use that, otherwise assume parsed is the extractedData
extractedData = parsed.extractedData ?? (parsed as Partial<ExtractedCoreData>);
} else {
// No explicit `data` field found. Attempt to interpret req.body as an object (Express may have parsed multipart fields differently).
try {
parsed = typeof req.body === 'string' ? JSON.parse(req.body) : req.body;
} catch (err) {
logger.warn(
{ error: errMsg(err) },
'[API /ai/flyers/process] Failed to JSON.parse req.body; using empty object',
);
parsed = (req.body as FlyerProcessPayload) || {};
}
// extractedData might be nested under `data` or `extractedData`, or the body itself may be the extracted data.
if (parsed.data) {
try {
const inner = typeof parsed.data === 'string' ? JSON.parse(parsed.data) : parsed.data;
extractedData = inner.extractedData ?? inner;
} catch (err) {
logger.warn(
{ error: errMsg(err) },
'[API /ai/flyers/process] Failed to parse parsed.data; falling back',
);
extractedData = parsed.data as unknown as Partial<ExtractedCoreData>;
}
} catch (err) {
logger.error({ error: err }, '[API /ai/flyers/process] Unexpected error while parsing request body');
parsed = {};
extractedData = {};
} else if (parsed.extractedData) {
extractedData = parsed.extractedData;
} else {
// Assume the body itself is the extracted data if it looks like it (has items or store_name keys)
if ('items' in parsed || 'store_name' in parsed || 'valid_from' in parsed) {
extractedData = parsed as Partial<ExtractedCoreData>;
} else {
extractedData = {};
}
}
}
} catch (err) {
logger.error(
{ error: err },
'[API /ai/flyers/process] Unexpected error while parsing request body',
);
parsed = {};
extractedData = {};
}
// Pull common metadata fields (checksum, originalFileName) from whichever shape we parsed.
const checksum = parsed.checksum ?? parsed?.data?.checksum ?? '';
const originalFileName = parsed.originalFileName ?? parsed?.data?.originalFileName ?? req.file.originalname;
const userProfile = req.user as UserProfile | undefined;
// Pull common metadata fields (checksum, originalFileName) from whichever shape we parsed.
const checksum = parsed.checksum ?? parsed?.data?.checksum ?? '';
const originalFileName =
parsed.originalFileName ?? parsed?.data?.originalFileName ?? req.file.originalname;
const userProfile = req.user as UserProfile | undefined;
// Validate extractedData to avoid database errors (e.g., null store_name)
if (!extractedData || typeof extractedData !== 'object') {
logger.warn({ bodyData: parsed }, 'Missing extractedData in /api/ai/flyers/process payload.');
// Don't fail hard here; proceed with empty items and fallback store name so the upload can be saved for manual review.
extractedData = {};
}
// Validate extractedData to avoid database errors (e.g., null store_name)
if (!extractedData || typeof extractedData !== 'object') {
logger.warn(
{ bodyData: parsed },
'Missing extractedData in /api/ai/flyers/process payload.',
);
// Don't fail hard here; proceed with empty items and fallback store name so the upload can be saved for manual review.
extractedData = {};
}
// Transform the extracted items into the format required for database insertion.
// This adds default values for fields like `view_count` and `click_count`
// and makes this legacy endpoint consistent with the newer FlyerDataTransformer service.
const rawItems = extractedData.items ?? [];
const itemsArray = Array.isArray(rawItems) ? rawItems : (typeof rawItems === 'string' ? JSON.parse(rawItems) : []);
const itemsForDb = itemsArray.map((item: Partial<ExtractedFlyerItem>) => ({
...item,
master_item_id: item.master_item_id === null ? undefined : item.master_item_id,
view_count: 0,
click_count: 0,
updated_at: new Date().toISOString(),
}));
// Transform the extracted items into the format required for database insertion.
// This adds default values for fields like `view_count` and `click_count`
// and makes this legacy endpoint consistent with the newer FlyerDataTransformer service.
const rawItems = extractedData.items ?? [];
const itemsArray = Array.isArray(rawItems)
? rawItems
: typeof rawItems === 'string'
? JSON.parse(rawItems)
: [];
const itemsForDb = itemsArray.map((item: Partial<ExtractedFlyerItem>) => ({
...item,
master_item_id: item.master_item_id === null ? undefined : item.master_item_id,
view_count: 0,
click_count: 0,
updated_at: new Date().toISOString(),
}));
// Ensure we have a valid store name; the DB requires a non-null store name.
const storeName = extractedData.store_name && String(extractedData.store_name).trim().length > 0
? String(extractedData.store_name)
: 'Unknown Store (auto)';
if (storeName.startsWith('Unknown')) {
logger.warn('extractedData.store_name missing; using fallback store name to avoid DB constraint error.');
}
// Ensure we have a valid store name; the DB requires a non-null store name.
const storeName =
extractedData.store_name && String(extractedData.store_name).trim().length > 0
? String(extractedData.store_name)
: 'Unknown Store (auto)';
if (storeName.startsWith('Unknown')) {
logger.warn(
'extractedData.store_name missing; using fallback store name to avoid DB constraint error.',
);
}
// 1. Check for duplicate flyer using checksum
const existingFlyer = await db.flyerRepo.findFlyerByChecksum(checksum, req.log);
if (existingFlyer) {
logger.warn(`Duplicate flyer upload attempt blocked for checksum: ${checksum}`);
return res.status(409).json({ message: 'This flyer has already been processed.' });
}
// 1. Check for duplicate flyer using checksum
const existingFlyer = await db.flyerRepo.findFlyerByChecksum(checksum, req.log);
if (existingFlyer) {
logger.warn(`Duplicate flyer upload attempt blocked for checksum: ${checksum}`);
return res.status(409).json({ message: 'This flyer has already been processed.' });
}
// Generate a 64x64 icon from the uploaded flyer image.
const iconsDir = path.join(path.dirname(req.file.path), 'icons');
const iconFileName = await generateFlyerIcon(req.file.path, iconsDir, req.log);
const iconUrl = `/flyer-images/icons/${iconFileName}`;
// Generate a 64x64 icon from the uploaded flyer image.
const iconsDir = path.join(path.dirname(req.file.path), 'icons');
const iconFileName = await generateFlyerIcon(req.file.path, iconsDir, req.log);
const iconUrl = `/flyer-images/icons/${iconFileName}`;
// 2. Prepare flyer data for insertion
const flyerData = {
file_name: originalFileName,
image_url: `/flyer-images/${req.file.filename}`, // Store the full URL path
icon_url: iconUrl,
checksum: checksum,
// Use normalized store name (fallback applied above).
store_name: storeName,
valid_from: extractedData.valid_from ?? null,
valid_to: extractedData.valid_to ?? null,
store_address: extractedData.store_address ?? null,
item_count: 0, // Set default to 0; the trigger will update it.
uploaded_by: userProfile?.user.user_id, // Associate with user if logged in
};
// 2. Prepare flyer data for insertion
const flyerData = {
file_name: originalFileName,
image_url: `/flyer-images/${req.file.filename}`, // Store the full URL path
icon_url: iconUrl,
checksum: checksum,
// Use normalized store name (fallback applied above).
store_name: storeName,
valid_from: extractedData.valid_from ?? null,
valid_to: extractedData.valid_to ?? null,
store_address: extractedData.store_address ?? null,
item_count: 0, // Set default to 0; the trigger will update it.
uploaded_by: userProfile?.user.user_id, // Associate with user if logged in
};
// 3. Create flyer and its items in a transaction
const { flyer: newFlyer, items: newItems } = await createFlyerAndItems(flyerData, itemsForDb, req.log);
// 3. Create flyer and its items in a transaction
const { flyer: newFlyer, items: newItems } = await createFlyerAndItems(
flyerData,
itemsForDb,
req.log,
);
logger.info(`Successfully processed and saved new flyer: ${newFlyer.file_name} (ID: ${newFlyer.flyer_id}) with ${newItems.length} items.`);
logger.info(
`Successfully processed and saved new flyer: ${newFlyer.file_name} (ID: ${newFlyer.flyer_id}) with ${newItems.length} items.`,
);
// Log this significant event
await db.adminRepo.logActivity({
userId: userProfile?.user.user_id,
action: 'flyer_processed',
displayText: `Processed a new flyer for ${flyerData.store_name}.`,
details: { flyerId: newFlyer.flyer_id, storeName: flyerData.store_name }
}, req.log);
// Log this significant event
await db.adminRepo.logActivity(
{
userId: userProfile?.user.user_id,
action: 'flyer_processed',
displayText: `Processed a new flyer for ${flyerData.store_name}.`,
details: { flyerId: newFlyer.flyer_id, storeName: flyerData.store_name },
},
req.log,
);
res.status(201).json({ message: 'Flyer processed and saved successfully.', flyer: newFlyer });
res.status(201).json({ message: 'Flyer processed and saved successfully.', flyer: newFlyer });
} catch (error) {
next(error);
next(error);
}
});
},
);
/**
* This endpoint checks if an image is a flyer. It uses `optionalAuth` to allow
* both authenticated and anonymous users to perform this check.
*/
router.post('/check-flyer', optionalAuth, uploadToDisk.single('image'), async (req, res, next: NextFunction) => {
router.post(
'/check-flyer',
optionalAuth,
uploadToDisk.single('image'),
async (req, res, next: NextFunction) => {
try {
if (!req.file) {
return res.status(400).json({ message: 'Image file is required.' });
}
logger.info(`Server-side flyer check for file: ${req.file.originalname}`);
res.status(200).json({ is_flyer: true }); // Stubbed response
if (!req.file) {
return res.status(400).json({ message: 'Image file is required.' });
}
logger.info(`Server-side flyer check for file: ${req.file.originalname}`);
res.status(200).json({ is_flyer: true }); // Stubbed response
} catch (error) {
next(error);
next(error);
}
});
},
);
router.post('/extract-address', optionalAuth, uploadToDisk.single('image'), async (req, res, next: NextFunction) => {
router.post(
'/extract-address',
optionalAuth,
uploadToDisk.single('image'),
async (req, res, next: NextFunction) => {
try {
if (!req.file) {
return res.status(400).json({ message: 'Image file is required.' });
}
logger.info(`Server-side address extraction for file: ${req.file.originalname}`);
res.status(200).json({ address: "not identified" }); // Updated stubbed response
if (!req.file) {
return res.status(400).json({ message: 'Image file is required.' });
}
logger.info(`Server-side address extraction for file: ${req.file.originalname}`);
res.status(200).json({ address: 'not identified' }); // Updated stubbed response
} catch (error) {
next(error);
next(error);
}
});
},
);
router.post('/extract-logo', optionalAuth, uploadToDisk.array('images'), async (req, res, next: NextFunction) => {
router.post(
'/extract-logo',
optionalAuth,
uploadToDisk.array('images'),
async (req, res, next: NextFunction) => {
try {
if (!req.files || !Array.isArray(req.files) || req.files.length === 0) {
return res.status(400).json({ message: 'Image files are required.' });
}
logger.info(`Server-side logo extraction for ${req.files.length} image(s).`);
res.status(200).json({ store_logo_base_64: null }); // Stubbed response
if (!req.files || !Array.isArray(req.files) || req.files.length === 0) {
return res.status(400).json({ message: 'Image files are required.' });
}
logger.info(`Server-side logo extraction for ${req.files.length} image(s).`);
res.status(200).json({ store_logo_base_64: null }); // Stubbed response
} catch (error) {
next(error);
next(error);
}
});
},
);
router.post('/quick-insights', passport.authenticate('jwt', { session: false }), validateRequest(insightsSchema), async (req, res, next: NextFunction) => {
router.post(
'/quick-insights',
passport.authenticate('jwt', { session: false }),
validateRequest(insightsSchema),
async (req, res, next: NextFunction) => {
try {
logger.info(`Server-side quick insights requested.`);
res.status(200).json({ text: "This is a server-generated quick insight: buy the cheap stuff!" }); // Stubbed response
logger.info(`Server-side quick insights requested.`);
res
.status(200)
.json({ text: 'This is a server-generated quick insight: buy the cheap stuff!' }); // Stubbed response
} catch (error) {
next(error);
next(error);
}
});
},
);
router.post('/deep-dive', passport.authenticate('jwt', { session: false }), validateRequest(insightsSchema), async (req, res, next: NextFunction) => {
router.post(
'/deep-dive',
passport.authenticate('jwt', { session: false }),
validateRequest(insightsSchema),
async (req, res, next: NextFunction) => {
try {
logger.info(`Server-side deep dive requested.`);
res.status(200).json({ text: "This is a server-generated deep dive analysis. It is very detailed." }); // Stubbed response
logger.info(`Server-side deep dive requested.`);
res
.status(200)
.json({ text: 'This is a server-generated deep dive analysis. It is very detailed.' }); // Stubbed response
} catch (error) {
next(error);
next(error);
}
});
},
);
router.post('/search-web', passport.authenticate('jwt', { session: false }), validateRequest(searchWebSchema), async (req, res, next: NextFunction) => {
router.post(
'/search-web',
passport.authenticate('jwt', { session: false }),
validateRequest(searchWebSchema),
async (req, res, next: NextFunction) => {
try {
logger.info(`Server-side web search requested.`);
res.status(200).json({ text: "The web says this is good.", sources: [] }); // Stubbed response
logger.info(`Server-side web search requested.`);
res.status(200).json({ text: 'The web says this is good.', sources: [] }); // Stubbed response
} catch (error) {
next(error);
next(error);
}
});
},
);
router.post('/compare-prices', passport.authenticate('jwt', { session: false }), validateRequest(comparePricesSchema), async (req, res, next: NextFunction) => {
router.post(
'/compare-prices',
passport.authenticate('jwt', { session: false }),
validateRequest(comparePricesSchema),
async (req, res, next: NextFunction) => {
try {
const { items } = req.body;
logger.info(`Server-side price comparison requested for ${items.length} items.`);
res.status(200).json({ text: "This is a server-generated price comparison. Milk is cheaper at SuperMart.", sources: [] }); // Stubbed response
const { items } = req.body;
logger.info(`Server-side price comparison requested for ${items.length} items.`);
res
.status(200)
.json({
text: 'This is a server-generated price comparison. Milk is cheaper at SuperMart.',
sources: [],
}); // Stubbed response
} catch (error) {
next(error);
next(error);
}
});
},
);
router.post('/plan-trip', passport.authenticate('jwt', { session: false }), validateRequest(planTripSchema), async (req, res, next: NextFunction) => {
router.post(
'/plan-trip',
passport.authenticate('jwt', { session: false }),
validateRequest(planTripSchema),
async (req, res, next: NextFunction) => {
try {
const { items, store, userLocation } = req.body;
logger.info(`Server-side trip planning requested for user.`);
const result = await aiService.aiService.planTripWithMaps(items, store, userLocation);
res.status(200).json(result);
const { items, store, userLocation } = req.body;
logger.info(`Server-side trip planning requested for user.`);
const result = await aiService.aiService.planTripWithMaps(items, store, userLocation);
res.status(200).json(result);
} catch (error) {
logger.error({ error }, 'Error in /api/ai/plan-trip endpoint:');
next(error);
logger.error({ error }, 'Error in /api/ai/plan-trip endpoint:');
next(error);
}
});
},
);
// --- STUBBED AI Routes for Future Features ---
router.post('/generate-image', passport.authenticate('jwt', { session: false }), validateRequest(generateImageSchema), (req: Request, res: Response) => {
router.post(
'/generate-image',
passport.authenticate('jwt', { session: false }),
validateRequest(generateImageSchema),
(req: Request, res: Response) => {
// This endpoint is a placeholder for a future feature.
// Returning 501 Not Implemented is the correct HTTP response for this case.
logger.info('Request received for unimplemented endpoint: /api/ai/generate-image');
res.status(501).json({ message: 'Image generation is not yet implemented.' });
});
},
);
router.post('/generate-speech', passport.authenticate('jwt', { session: false }), validateRequest(generateSpeechSchema), (req: Request, res: Response) => {
router.post(
'/generate-speech',
passport.authenticate('jwt', { session: false }),
validateRequest(generateSpeechSchema),
(req: Request, res: Response) => {
// This endpoint is a placeholder for a future feature.
// Returning 501 Not Implemented is the correct HTTP response for this case.
logger.info('Request received for unimplemented endpoint: /api/ai/generate-speech');
res.status(501).json({ message: 'Speech generation is not yet implemented.' });
});
},
);
/**
* POST /api/ai/rescan-area - Performs a targeted AI scan on a specific area of an image.
* Requires authentication.
*/
router.post(
'/rescan-area',
passport.authenticate('jwt', { session: false }),
uploadToDisk.single('image'), validateRequest(rescanAreaSchema),
async (req, res, next: NextFunction) => {
try {
if (!req.file) {
return res.status(400).json({ message: 'Image file is required.' });
}
// validateRequest transforms the cropArea JSON string into an object in req.body.
// So we use it directly instead of JSON.parse().
const cropArea = req.body.cropArea;
const { extractionType } = req.body;
const { path, mimetype } = req.file;
'/rescan-area',
passport.authenticate('jwt', { session: false }),
uploadToDisk.single('image'),
validateRequest(rescanAreaSchema),
async (req, res, next: NextFunction) => {
try {
if (!req.file) {
return res.status(400).json({ message: 'Image file is required.' });
}
// validateRequest transforms the cropArea JSON string into an object in req.body.
// So we use it directly instead of JSON.parse().
const cropArea = req.body.cropArea;
const { extractionType } = req.body;
const { path, mimetype } = req.file;
const result = await aiService.aiService.extractTextFromImageArea(
path,
mimetype,
cropArea,
extractionType,
req.log
);
const result = await aiService.aiService.extractTextFromImageArea(
path,
mimetype,
cropArea,
extractionType,
req.log,
);
res.status(200).json(result);
} catch (error) {
next(error);
}
res.status(200).json(result);
} catch (error) {
next(error);
}
},
);
export default router;
export default router;

View File

@@ -3,43 +3,54 @@ import { describe, it, expect, vi, beforeEach } from 'vitest';
import supertest from 'supertest';
import { Request, Response, NextFunction } from 'express';
import cookieParser from 'cookie-parser';
import * as bcrypt from 'bcrypt';
import * as bcrypt from 'bcrypt';
import jwt from 'jsonwebtoken';
import { createMockUserProfile, createMockUserWithPasswordHash } from '../tests/utils/mockFactories';
import {
createMockUserProfile,
createMockUserWithPasswordHash,
} from '../tests/utils/mockFactories';
import { mockLogger } from '../tests/utils/mockLogger';
// --- FIX: Hoist passport mocks to be available for vi.mock ---
const passportMocks = vi.hoisted(() => {
type PassportCallback = (error: Error | null, user?: Express.User | false, info?: { message: string }) => void;
type PassportCallback = (
error: Error | null,
user?: Express.User | false,
info?: { message: string },
) => void;
const authenticateMock = (strategy: string, options: Record<string, unknown>, callback: PassportCallback) => (req: Request, res: Response, next: NextFunction) => {
// Simulate LocalStrategy logic based on request body
if (req.body.password === 'wrong_password') {
return callback(null, false, { message: 'Incorrect email or password.' });
}
if (req.body.email === 'locked@test.com') {
return callback(null, false, { message: 'Account is temporarily locked. Please try again in 15 minutes.' });
}
if (req.body.email === 'notfound@test.com') {
return callback(null, false, { message: 'Login failed' });
}
// Specific case for strategy error
if (req.body.email === 'dberror@test.com') {
return callback(new Error('Database connection failed'), false);
}
// Default success case
const user = createMockUserProfile({ user: { user_id: 'user-123', email: req.body.email } });
// If a callback is provided (custom callback signature), call it
if (callback) {
const authenticateMock =
(strategy: string, options: Record<string, unknown>, callback: PassportCallback) =>
(req: Request, res: Response, next: NextFunction) => {
// Simulate LocalStrategy logic based on request body
if (req.body.password === 'wrong_password') {
return callback(null, false, { message: 'Incorrect email or password.' });
}
if (req.body.email === 'locked@test.com') {
return callback(null, false, {
message: 'Account is temporarily locked. Please try again in 15 minutes.',
});
}
if (req.body.email === 'notfound@test.com') {
return callback(null, false, { message: 'Login failed' });
}
// Specific case for strategy error
if (req.body.email === 'dberror@test.com') {
return callback(new Error('Database connection failed'), false);
}
// Default success case
const user = createMockUserProfile({ user: { user_id: 'user-123', email: req.body.email } });
// If a callback is provided (custom callback signature), call it
if (callback) {
return callback(null, user);
}
// Standard middleware signature: attach user and call next
req.user = user;
next();
};
}
// Standard middleware signature: attach user and call next
req.user = user;
next();
};
return { authenticateMock };
});
@@ -66,7 +77,7 @@ const { mockPool } = vi.hoisted(() => {
release: vi.fn(),
};
return {
mockPool: {
mockPool: {
connect: vi.fn(() => Promise.resolve(client)),
},
mockClient: client,
@@ -99,7 +110,6 @@ vi.mock('../services/db/connection.db', () => ({
getPool: () => mockPool,
}));
// Mock the logger
vi.mock('../services/logger.server', () => ({
logger: mockLogger,
@@ -160,7 +170,10 @@ describe('Auth Routes (/api/auth)', () => {
it('should successfully register a new user with a strong password', async () => {
// Arrange:
const mockNewUser = createMockUserProfile({ user: { user_id: 'new-user-id', email: newUserEmail }, full_name: 'Test User' });
const mockNewUser = createMockUserProfile({
user: { user_id: 'new-user-id', email: newUserEmail },
full_name: 'Test User',
});
// FIX: Mock the method on the imported singleton instance `userRepo` directly,
// as this is what the route handler uses. Spying on the prototype does not
@@ -170,13 +183,11 @@ describe('Auth Routes (/api/auth)', () => {
vi.mocked(db.adminRepo.logActivity).mockResolvedValue(undefined);
// Act
const response = await supertest(app)
.post('/api/auth/register')
.send({
email: newUserEmail,
password: strongPassword,
full_name: 'Test User',
});
const response = await supertest(app).post('/api/auth/register').send({
email: newUserEmail,
password: strongPassword,
full_name: 'Test User',
});
// Assert
expect(response.status).toBe(201);
@@ -187,17 +198,17 @@ describe('Auth Routes (/api/auth)', () => {
});
it('should set a refresh token cookie on successful registration', async () => {
const mockNewUser = createMockUserProfile({ user: { user_id: 'new-user-id', email: 'cookie@test.com' } });
const mockNewUser = createMockUserProfile({
user: { user_id: 'new-user-id', email: 'cookie@test.com' },
});
vi.mocked(db.userRepo.createUser).mockResolvedValue(mockNewUser);
vi.mocked(db.userRepo.saveRefreshToken).mockResolvedValue(undefined);
vi.mocked(db.adminRepo.logActivity).mockResolvedValue(undefined);
const response = await supertest(app)
.post('/api/auth/register')
.send({
email: 'cookie@test.com',
password: 'StrongPassword123!',
});
const response = await supertest(app).post('/api/auth/register').send({
email: 'cookie@test.com',
password: 'StrongPassword123!',
});
expect(response.status).toBe(201);
expect(response.headers['set-cookie']).toBeDefined();
@@ -207,12 +218,10 @@ describe('Auth Routes (/api/auth)', () => {
it('should reject registration with a weak password', async () => {
const weakPassword = 'password';
const response = await supertest(app)
.post('/api/auth/register')
.send({
email: 'anotheruser@test.com',
password: weakPassword,
});
const response = await supertest(app).post('/api/auth/register').send({
email: 'anotheruser@test.com',
password: weakPassword,
});
expect(response.status).toBe(400);
// The validation middleware returns errors in an array.
@@ -227,7 +236,9 @@ describe('Auth Routes (/api/auth)', () => {
it('should reject registration if the email already exists', async () => {
// Create an error object that includes the 'code' property for simulating a PG unique violation.
// This is more type-safe than casting to 'any'.
const dbError = new UniqueConstraintError('User with that email already exists.') as UniqueConstraintError & { code: string };
const dbError = new UniqueConstraintError(
'User with that email already exists.',
) as UniqueConstraintError & { code: string };
dbError.code = '23505';
vi.mocked(db.userRepo.createUser).mockRejectedValue(dbError);
@@ -242,7 +253,7 @@ describe('Auth Routes (/api/auth)', () => {
});
it('should return 500 if a generic database error occurs during registration', async () => {
const dbError = new Error('DB connection lost');
const dbError = new Error('DB connection lost');
vi.mocked(db.userRepo.createUser).mockRejectedValue(dbError);
const response = await supertest(app)
@@ -279,20 +290,20 @@ describe('Auth Routes (/api/auth)', () => {
vi.mocked(db.userRepo.saveRefreshToken).mockResolvedValue(undefined);
// Act
const response = await supertest(app)
.post('/api/auth/login')
.send(loginCredentials);
const response = await supertest(app).post('/api/auth/login').send(loginCredentials);
// Assert
expect(response.status).toBe(200);
// The API now returns a nested UserProfile object
expect(response.body.user).toEqual(expect.objectContaining({
user_id: 'user-123',
user: expect.objectContaining({
expect(response.body.user).toEqual(
expect.objectContaining({
user_id: 'user-123',
email: loginCredentials.email
})
}));
user: expect.objectContaining({
user_id: 'user-123',
email: loginCredentials.email,
}),
}),
);
expect(response.body.token).toBeTypeOf('string');
expect(response.headers['set-cookie']).toBeDefined();
});
@@ -303,9 +314,7 @@ describe('Auth Routes (/api/auth)', () => {
vi.mocked(db.userRepo.saveRefreshToken).mockResolvedValue(undefined);
// Act
const response = await supertest(app)
.post('/api/auth/login')
.send(loginCredentials);
const response = await supertest(app).post('/api/auth/login').send(loginCredentials);
// Assert
expect(response.status).toBe(200);
@@ -333,11 +342,13 @@ describe('Auth Routes (/api/auth)', () => {
.send({ email: 'locked@test.com', password: 'password123' });
expect(response.status).toBe(401);
expect(response.body.message).toBe('Account is temporarily locked. Please try again in 15 minutes.');
expect(response.body.message).toBe(
'Account is temporarily locked. Please try again in 15 minutes.',
);
});
it('should return 401 if user is not found', async () => {
const response = await supertest(app)
const response = await supertest(app)
.post('/api/auth/login') // This was a duplicate, fixed.
.send({ email: 'notfound@test.com', password: 'password123' });
@@ -378,19 +389,21 @@ describe('Auth Routes (/api/auth)', () => {
expect(response.status).toBe(401);
expect(mockLogger.warn).toHaveBeenCalledWith(
{ info: { message: 'Login failed' } },
'[API /login] Passport reported NO USER found.'
'[API /login] Passport reported NO USER found.',
);
});
it('should set a long-lived cookie when rememberMe is true', async () => {
// Arrange
const loginCredentials = { email: 'test@test.com', password: 'password123', rememberMe: true };
const loginCredentials = {
email: 'test@test.com',
password: 'password123',
rememberMe: true,
};
vi.mocked(db.userRepo.saveRefreshToken).mockResolvedValue(undefined);
// Act
const response = await supertest(app)
.post('/api/auth/login')
.send(loginCredentials);
const response = await supertest(app).post('/api/auth/login').send(loginCredentials);
// Assert
expect(response.status).toBe(200);
@@ -402,7 +415,9 @@ describe('Auth Routes (/api/auth)', () => {
describe('POST /forgot-password', () => {
it('should send a reset link if the user exists', async () => {
// Arrange
vi.mocked(db.userRepo.findUserByEmail).mockResolvedValue(createMockUserWithPasswordHash({ user_id: 'user-123', email: 'test@test.com' }));
vi.mocked(db.userRepo.findUserByEmail).mockResolvedValue(
createMockUserWithPasswordHash({ user_id: 'user-123', email: 'test@test.com' }),
);
vi.mocked(db.userRepo.createPasswordResetToken).mockResolvedValue(undefined);
// Act
@@ -438,7 +453,9 @@ describe('Auth Routes (/api/auth)', () => {
it('should still return 200 OK if the email service fails', async () => {
// Arrange
vi.mocked(db.userRepo.findUserByEmail).mockResolvedValue(createMockUserWithPasswordHash({ user_id: 'user-123', email: 'test@test.com' }));
vi.mocked(db.userRepo.findUserByEmail).mockResolvedValue(
createMockUserWithPasswordHash({ user_id: 'user-123', email: 'test@test.com' }),
);
vi.mocked(db.userRepo.createPasswordResetToken).mockResolvedValue(undefined);
// Mock the email service to fail
const { sendPasswordResetEmail } = await import('../services/emailService.server');
@@ -465,7 +482,11 @@ describe('Auth Routes (/api/auth)', () => {
describe('POST /reset-password', () => {
it('should reset the password with a valid token and strong password', async () => {
const tokenRecord = { user_id: 'user-123', token_hash: 'hashed-token', expires_at: new Date(Date.now() + 3600000) };
const tokenRecord = {
user_id: 'user-123',
token_hash: 'hashed-token',
expires_at: new Date(Date.now() + 3600000),
};
vi.mocked(db.userRepo.getValidResetTokens).mockResolvedValue([tokenRecord]); // This was a duplicate, fixed.
vi.mocked(bcrypt.compare).mockResolvedValue(true as never); // Token matches
vi.mocked(db.userRepo.updateUserPassword).mockResolvedValue(undefined);
@@ -492,7 +513,11 @@ describe('Auth Routes (/api/auth)', () => {
});
it('should reject if token does not match any valid tokens in DB', async () => {
const tokenRecord = { user_id: 'user-123', token_hash: 'hashed-token', expires_at: new Date(Date.now() + 3600000) };
const tokenRecord = {
user_id: 'user-123',
token_hash: 'hashed-token',
expires_at: new Date(Date.now() + 3600000),
};
vi.mocked(db.userRepo.getValidResetTokens).mockResolvedValue([tokenRecord]);
vi.mocked(bcrypt.compare).mockResolvedValue(false as never); // Token does not match
@@ -505,11 +530,17 @@ describe('Auth Routes (/api/auth)', () => {
});
it('should return 400 for a weak new password', async () => {
const tokenRecord = { user_id: 'user-123', token_hash: 'hashed-token', expires_at: new Date(Date.now() + 3600000) };
const tokenRecord = {
user_id: 'user-123',
token_hash: 'hashed-token',
expires_at: new Date(Date.now() + 3600000),
};
vi.mocked(db.userRepo.getValidResetTokens).mockResolvedValue([tokenRecord]);
vi.mocked(bcrypt.compare).mockResolvedValue(true as never);
const response = await supertest(app).post('/api/auth/reset-password').send({ token: 'valid-token', newPassword: 'weak' });
const response = await supertest(app)
.post('/api/auth/reset-password')
.send({ token: 'valid-token', newPassword: 'weak' });
expect(response.status).toBe(400);
});
@@ -525,7 +556,10 @@ describe('Auth Routes (/api/auth)', () => {
describe('POST /refresh-token', () => {
it('should issue a new access token with a valid refresh token cookie', async () => {
const mockUser = createMockUserWithPasswordHash({ user_id: 'user-123', email: 'test@test.com' });
const mockUser = createMockUserWithPasswordHash({
user_id: 'user-123',
email: 'test@test.com',
});
vi.mocked(db.userRepo.findUserByRefreshToken).mockResolvedValue(mockUser);
const response = await supertest(app)
@@ -541,17 +575,17 @@ describe('Auth Routes (/api/auth)', () => {
expect(response.status).toBe(401);
expect(response.body.message).toBe('Refresh token not found.');
});
it('should return 403 if refresh token is invalid', async () => {
// Mock finding no user for this token, which should trigger the 403 logic
vi.mocked(db.userRepo.findUserByRefreshToken).mockResolvedValue(undefined as any);
const response = await supertest(app)
it('should return 403 if refresh token is invalid', async () => {
// Mock finding no user for this token, which should trigger the 403 logic
vi.mocked(db.userRepo.findUserByRefreshToken).mockResolvedValue(undefined as any);
const response = await supertest(app)
.post('/api/auth/refresh-token')
.set('Cookie', 'refreshToken=invalid-token');
expect(response.status).toBe(403);
});
expect(response.status).toBe(403);
});
it('should return 500 if the database call fails', async () => {
// Arrange
@@ -602,7 +636,7 @@ describe('Auth Routes (/api/auth)', () => {
expect(response.status).toBe(200);
expect(logger.error).toHaveBeenCalledWith(
expect.objectContaining({ error: dbError }),
'Failed to delete refresh token from DB during logout.'
'Failed to delete refresh token from DB during logout.',
);
});
@@ -615,4 +649,4 @@ describe('Auth Routes (/api/auth)', () => {
expect(response.headers['set-cookie'][0]).toContain('refreshToken=;');
});
});
});
});

View File

@@ -30,35 +30,42 @@ const validatePasswordStrength = (password: string): { isValid: boolean; feedbac
const strength = zxcvbn(password);
if (strength.score < MIN_PASSWORD_SCORE) {
const feedbackMessage = strength.feedback.warning || (strength.feedback.suggestions && strength.feedback.suggestions[0]);
return { isValid: false, feedback: `Password is too weak. ${feedbackMessage || 'Please choose a stronger password.'}`.trim() };
const feedbackMessage =
strength.feedback.warning ||
(strength.feedback.suggestions && strength.feedback.suggestions[0]);
return {
isValid: false,
feedback:
`Password is too weak. ${feedbackMessage || 'Please choose a stronger password.'}`.trim(),
};
}
return { isValid: true };
};
// Helper for consistent required string validation (handles missing/null/empty)
const requiredString = (message: string) => z.preprocess((val) => val ?? '', z.string().min(1, message));
const requiredString = (message: string) =>
z.preprocess((val) => val ?? '', z.string().min(1, message));
// Conditionally disable rate limiting for the test environment
const isTestEnv = process.env.NODE_ENV === 'test';
// --- Rate Limiting Configuration ---
const forgotPasswordLimiter = rateLimit({
windowMs: 15 * 60 * 1000, // 15 minutes
max: 5,
message: 'Too many password reset requests from this IP, please try again after 15 minutes.',
standardHeaders: true,
legacyHeaders: false,
windowMs: 15 * 60 * 1000, // 15 minutes
max: 5,
message: 'Too many password reset requests from this IP, please try again after 15 minutes.',
standardHeaders: true,
legacyHeaders: false,
skip: () => isTestEnv, // Skip this middleware if in test environment
});
const resetPasswordLimiter = rateLimit({
windowMs: 15 * 60 * 1000, // 15 minutes
max: 10,
message: 'Too many password reset attempts from this IP, please try again after 15 minutes.',
standardHeaders: true,
legacyHeaders: false,
windowMs: 15 * 60 * 1000, // 15 minutes
max: 10,
message: 'Too many password reset attempts from this IP, please try again after 15 minutes.',
standardHeaders: true,
legacyHeaders: false,
skip: () => isTestEnv, // Skip this middleware if in test environment
});
@@ -67,10 +74,13 @@ const resetPasswordLimiter = rateLimit({
const registerSchema = z.object({
body: z.object({
email: z.string().email('A valid email is required.'),
password: z.string().min(8, 'Password must be at least 8 characters long.').superRefine((password, ctx) => {
const strength = validatePasswordStrength(password);
if (!strength.isValid) ctx.addIssue({ code: 'custom', message: strength.feedback });
}),
password: z
.string()
.min(8, 'Password must be at least 8 characters long.')
.superRefine((password, ctx) => {
const strength = validatePasswordStrength(password);
if (!strength.isValid) ctx.addIssue({ code: 'custom', message: strength.feedback });
}),
full_name: z.string().optional(),
avatar_url: z.string().url().optional(),
}),
@@ -83,220 +93,275 @@ const forgotPasswordSchema = z.object({
const resetPasswordSchema = z.object({
body: z.object({
token: requiredString('Token is required.'),
newPassword: z.string().min(8, 'Password must be at least 8 characters long.').superRefine((password, ctx) => {
const strength = validatePasswordStrength(password);
if (!strength.isValid) ctx.addIssue({ code: 'custom', message: strength.feedback });
}),
newPassword: z
.string()
.min(8, 'Password must be at least 8 characters long.')
.superRefine((password, ctx) => {
const strength = validatePasswordStrength(password);
if (!strength.isValid) ctx.addIssue({ code: 'custom', message: strength.feedback });
}),
}),
});
// --- Authentication Routes ---
// Registration Route
router.post('/register', validateRequest(registerSchema), async (req: Request, res: Response, next: NextFunction) => {
type RegisterRequest = z.infer<typeof registerSchema>;
const { body: { email, password, full_name, avatar_url } } = req as unknown as RegisterRequest;
try {
const saltRounds = 10;
const hashedPassword = await bcrypt.hash(password, saltRounds);
logger.info(`Hashing password for new user: ${email}`);
router.post(
'/register',
validateRequest(registerSchema),
async (req: Request, res: Response, next: NextFunction) => {
type RegisterRequest = z.infer<typeof registerSchema>;
const {
body: { email, password, full_name, avatar_url },
} = req as unknown as RegisterRequest;
// The createUser method in UserRepository now handles its own transaction.
const newUser = await userRepo.createUser(email, hashedPassword, { full_name, avatar_url }, req.log);
try {
const saltRounds = 10;
const hashedPassword = await bcrypt.hash(password, saltRounds);
logger.info(`Hashing password for new user: ${email}`);
const userEmail = newUser.user.email;
const userId = newUser.user.user_id;
logger.info(`Successfully created new user in DB: ${userEmail} (ID: ${userId})`);
// The createUser method in UserRepository now handles its own transaction.
const newUser = await userRepo.createUser(
email,
hashedPassword,
{ full_name, avatar_url },
req.log,
);
// Use the new standardized logging function
await adminRepo.logActivity({
userId: newUser.user.user_id,
action: 'user_registered',
displayText: `${userEmail} has registered.`,
icon: 'user-plus',
}, req.log);
const userEmail = newUser.user.email;
const userId = newUser.user.user_id;
logger.info(`Successfully created new user in DB: ${userEmail} (ID: ${userId})`);
const payload = { user_id: newUser.user.user_id, email: userEmail };
const token = jwt.sign(payload, JWT_SECRET, { expiresIn: '1h' });
// Use the new standardized logging function
await adminRepo.logActivity(
{
userId: newUser.user.user_id,
action: 'user_registered',
displayText: `${userEmail} has registered.`,
icon: 'user-plus',
},
req.log,
);
const refreshToken = crypto.randomBytes(64).toString('hex');
await userRepo.saveRefreshToken(newUser.user.user_id, refreshToken, req.log);
const payload = { user_id: newUser.user.user_id, email: userEmail };
const token = jwt.sign(payload, JWT_SECRET, { expiresIn: '1h' });
res.cookie('refreshToken', refreshToken, {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
maxAge: 7 * 24 * 60 * 60 * 1000, // 7 days
});
return res.status(201).json({ message: 'User registered successfully!', userprofile: newUser, token });
} catch (error: unknown) {
if (error instanceof UniqueConstraintError) {
// If the email is a duplicate, return a 409 Conflict status.
return res.status(409).json({ message: error.message });
const refreshToken = crypto.randomBytes(64).toString('hex');
await userRepo.saveRefreshToken(newUser.user.user_id, refreshToken, req.log);
res.cookie('refreshToken', refreshToken, {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
maxAge: 7 * 24 * 60 * 60 * 1000, // 7 days
});
return res
.status(201)
.json({ message: 'User registered successfully!', userprofile: newUser, token });
} catch (error: unknown) {
if (error instanceof UniqueConstraintError) {
// If the email is a duplicate, return a 409 Conflict status.
return res.status(409).json({ message: error.message });
}
// The createUser method now handles its own transaction logging, so we just log the route failure.
logger.error({ error }, `User registration route failed for email: ${email}.`);
return next(error);
}
// The createUser method now handles its own transaction logging, so we just log the route failure.
logger.error({ error }, `User registration route failed for email: ${email}.`);
return next(error);
}
});
},
);
// Login Route
router.post('/login', (req: Request, res: Response, next: NextFunction) => {
passport.authenticate('local', { session: false }, async (err: Error, user: Express.User | false, info: { message: string }) => {
// --- LOGIN ROUTE DEBUG LOGGING ---
req.log.debug(`[API /login] Received login request for email: ${req.body.email}`);
if (err) req.log.error({ err }, '[API /login] Passport reported an error.');
if (!user) req.log.warn({ info }, '[API /login] Passport reported NO USER found.');
if (user) req.log.debug({ user }, '[API /login] Passport user object:'); // Log the user object passport returns
if (user) req.log.info({ user }, '[API /login] Passport reported USER FOUND.');
passport.authenticate(
'local',
{ session: false },
async (err: Error, user: Express.User | false, info: { message: string }) => {
// --- LOGIN ROUTE DEBUG LOGGING ---
req.log.debug(`[API /login] Received login request for email: ${req.body.email}`);
if (err) req.log.error({ err }, '[API /login] Passport reported an error.');
if (!user) req.log.warn({ info }, '[API /login] Passport reported NO USER found.');
if (user) req.log.debug({ user }, '[API /login] Passport user object:'); // Log the user object passport returns
if (user) req.log.info({ user }, '[API /login] Passport reported USER FOUND.');
try {
const allUsersInDb = await getPool().query('SELECT u.user_id, u.email, p.role FROM public.users u JOIN public.profiles p ON u.user_id = p.user_id');
try {
const allUsersInDb = await getPool().query(
'SELECT u.user_id, u.email, p.role FROM public.users u JOIN public.profiles p ON u.user_id = p.user_id',
);
req.log.debug('[API /login] Current users in DB from SERVER perspective:');
console.table(allUsersInDb.rows);
} catch (dbError) {
} catch (dbError) {
req.log.error({ dbError }, '[API /login] Could not query users table for debugging.');
}
// --- END DEBUG LOGGING ---
const { rememberMe } = req.body;
if (err) {
req.log.error({ error: err }, `Login authentication error in /login route for email: ${req.body.email}`);
return next(err);
}
if (!user) {
return res.status(401).json({ message: info.message || 'Login failed' });
}
}
// --- END DEBUG LOGGING ---
const { rememberMe } = req.body;
if (err) {
req.log.error(
{ error: err },
`Login authentication error in /login route for email: ${req.body.email}`,
);
return next(err);
}
if (!user) {
return res.status(401).json({ message: info.message || 'Login failed' });
}
const userProfile = user as UserProfile;
const payload = { user_id: userProfile.user.user_id, email: userProfile.user.email, role: userProfile.role };
const accessToken = jwt.sign(payload, JWT_SECRET, { expiresIn: '15m' });
const userProfile = user as UserProfile;
const payload = {
user_id: userProfile.user.user_id,
email: userProfile.user.email,
role: userProfile.role,
};
const accessToken = jwt.sign(payload, JWT_SECRET, { expiresIn: '15m' });
try {
const refreshToken = crypto.randomBytes(64).toString('hex'); // This was a duplicate, fixed.
await userRepo.saveRefreshToken(userProfile.user.user_id, refreshToken, req.log);
req.log.info(`JWT and refresh token issued for user: ${userProfile.user.email}`);
const cookieOptions = {
try {
const refreshToken = crypto.randomBytes(64).toString('hex'); // This was a duplicate, fixed.
await userRepo.saveRefreshToken(userProfile.user.user_id, refreshToken, req.log);
req.log.info(`JWT and refresh token issued for user: ${userProfile.user.email}`);
const cookieOptions = {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
maxAge: rememberMe ? 30 * 24 * 60 * 60 * 1000 : undefined
};
maxAge: rememberMe ? 30 * 24 * 60 * 60 * 1000 : undefined,
};
res.cookie('refreshToken', refreshToken, cookieOptions);
// Return the full user profile object on login to avoid a second fetch on the client.
return res.json({ userprofile: userProfile, token: accessToken });
} catch (tokenErr) {
req.log.error({ error: tokenErr }, `Failed to save refresh token during login for user: ${userProfile.user.email}`);
return next(tokenErr);
}
})(req, res, next);
res.cookie('refreshToken', refreshToken, cookieOptions);
// Return the full user profile object on login to avoid a second fetch on the client.
return res.json({ userprofile: userProfile, token: accessToken });
} catch (tokenErr) {
req.log.error(
{ error: tokenErr },
`Failed to save refresh token during login for user: ${userProfile.user.email}`,
);
return next(tokenErr);
}
},
)(req, res, next);
});
// Route to request a password reset
router.post('/forgot-password', forgotPasswordLimiter, validateRequest(forgotPasswordSchema), async (req: Request, res: Response, next: NextFunction) => {
type ForgotPasswordRequest = z.infer<typeof forgotPasswordSchema>;
const { body: { email } } = req as unknown as ForgotPasswordRequest;
try {
req.log.debug(`[API /forgot-password] Received request for email: ${email}`);
const user = await userRepo.findUserByEmail(email, req.log);
let token: string | undefined;
req.log.debug({ user: user ? { user_id: user.user_id, email: user.email } : 'NOT FOUND' }, `[API /forgot-password] Database search result for ${email}:`);
router.post(
'/forgot-password',
forgotPasswordLimiter,
validateRequest(forgotPasswordSchema),
async (req: Request, res: Response, next: NextFunction) => {
type ForgotPasswordRequest = z.infer<typeof forgotPasswordSchema>;
const {
body: { email },
} = req as unknown as ForgotPasswordRequest;
if (user) {
token = crypto.randomBytes(32).toString('hex');
const saltRounds = 10;
const tokenHash = await bcrypt.hash(token, saltRounds);
const expiresAt = new Date(Date.now() + 3600000); // 1 hour
try {
req.log.debug(`[API /forgot-password] Received request for email: ${email}`);
const user = await userRepo.findUserByEmail(email, req.log);
let token: string | undefined;
req.log.debug(
{ user: user ? { user_id: user.user_id, email: user.email } : 'NOT FOUND' },
`[API /forgot-password] Database search result for ${email}:`,
);
await userRepo.createPasswordResetToken(user.user_id, tokenHash, expiresAt, req.log);
if (user) {
token = crypto.randomBytes(32).toString('hex');
const saltRounds = 10;
const tokenHash = await bcrypt.hash(token, saltRounds);
const expiresAt = new Date(Date.now() + 3600000); // 1 hour
const resetLink = `${process.env.FRONTEND_URL}/reset-password/${token}`;
await userRepo.createPasswordResetToken(user.user_id, tokenHash, expiresAt, req.log);
try {
await sendPasswordResetEmail(email, resetLink, req.log);
} catch (emailError) {
req.log.error({ emailError }, `Email send failure during password reset for user`);
const resetLink = `${process.env.FRONTEND_URL}/reset-password/${token}`;
try {
await sendPasswordResetEmail(email, resetLink, req.log);
} catch (emailError) {
req.log.error({ emailError }, `Email send failure during password reset for user`);
}
} else {
req.log.warn(`Password reset requested for non-existent email: ${email}`);
}
} else {
req.log.warn(`Password reset requested for non-existent email: ${email}`);
}
// For testability, return the token in the response only in the test environment.
const responsePayload: { message: string; token?: string } = {
message: 'If an account with that email exists, a password reset link has been sent.',
};
if (process.env.NODE_ENV === 'test' && user) responsePayload.token = token;
res.status(200).json(responsePayload);
} catch (error) {
req.log.error({ error }, `An error occurred during /forgot-password for email: ${email}`);
next(error);
}
});
// For testability, return the token in the response only in the test environment.
const responsePayload: { message: string; token?: string } = {
message: 'If an account with that email exists, a password reset link has been sent.',
};
if (process.env.NODE_ENV === 'test' && user) responsePayload.token = token;
res.status(200).json(responsePayload);
} catch (error) {
req.log.error({ error }, `An error occurred during /forgot-password for email: ${email}`);
next(error);
}
},
);
// Route to reset the password using a token
router.post('/reset-password', resetPasswordLimiter, validateRequest(resetPasswordSchema), async (req: Request, res: Response, next: NextFunction) => {
type ResetPasswordRequest = z.infer<typeof resetPasswordSchema>;
const { body: { token, newPassword } } = req as unknown as ResetPasswordRequest;
try {
const validTokens = await userRepo.getValidResetTokens(req.log);
let tokenRecord;
for (const record of validTokens) {
router.post(
'/reset-password',
resetPasswordLimiter,
validateRequest(resetPasswordSchema),
async (req: Request, res: Response, next: NextFunction) => {
type ResetPasswordRequest = z.infer<typeof resetPasswordSchema>;
const {
body: { token, newPassword },
} = req as unknown as ResetPasswordRequest;
try {
const validTokens = await userRepo.getValidResetTokens(req.log);
let tokenRecord;
for (const record of validTokens) {
const isMatch = await bcrypt.compare(token, record.token_hash);
if (isMatch) {
tokenRecord = record;
break;
tokenRecord = record;
break;
}
}
if (!tokenRecord) {
return res.status(400).json({ message: 'Invalid or expired password reset token.' });
}
const saltRounds = 10;
const hashedPassword = await bcrypt.hash(newPassword, saltRounds);
await userRepo.updateUserPassword(tokenRecord.user_id, hashedPassword, req.log);
await userRepo.deleteResetToken(tokenRecord.token_hash, req.log);
// Log this security event after a successful password reset.
await adminRepo.logActivity(
{
userId: tokenRecord.user_id,
action: 'password_reset',
displayText: `User ID ${tokenRecord.user_id} has reset their password.`,
icon: 'key',
details: { source_ip: req.ip ?? null },
},
req.log,
);
res.status(200).json({ message: 'Password has been reset successfully.' });
} catch (error) {
req.log.error({ error }, `An error occurred during password reset.`);
next(error);
}
if (!tokenRecord) {
return res.status(400).json({ message: 'Invalid or expired password reset token.' });
}
const saltRounds = 10;
const hashedPassword = await bcrypt.hash(newPassword, saltRounds);
await userRepo.updateUserPassword(tokenRecord.user_id, hashedPassword, req.log);
await userRepo.deleteResetToken(tokenRecord.token_hash, req.log);
// Log this security event after a successful password reset.
await adminRepo.logActivity({
userId: tokenRecord.user_id,
action: 'password_reset',
displayText: `User ID ${tokenRecord.user_id} has reset their password.`,
icon: 'key',
details: { source_ip: req.ip ?? null }
}, req.log);
res.status(200).json({ message: 'Password has been reset successfully.' });
} catch (error) {
req.log.error({ error }, `An error occurred during password reset.`);
next(error);
}
});
},
);
// New Route to refresh the access token
router.post('/refresh-token', async (req: Request, res: Response, next: NextFunction) => {
const { refreshToken } = req.cookies;
if (!refreshToken) {
return res.status(401).json({ message: 'Refresh token not found.' });
const { refreshToken } = req.cookies;
if (!refreshToken) {
return res.status(401).json({ message: 'Refresh token not found.' });
}
try {
const user = await userRepo.findUserByRefreshToken(refreshToken, req.log);
if (!user) {
return res.status(403).json({ message: 'Invalid or expired refresh token.' });
}
try {
const user = await userRepo.findUserByRefreshToken(refreshToken, req.log);
if (!user) {
return res.status(403).json({ message: 'Invalid or expired refresh token.' });
}
const payload = { user_id: user.user_id, email: user.email };
const newAccessToken = jwt.sign(payload, JWT_SECRET, { expiresIn: '15m' });
const payload = { user_id: user.user_id, email: user.email };
const newAccessToken = jwt.sign(payload, JWT_SECRET, { expiresIn: '15m' });
res.json({ token: newAccessToken });
} catch (error) {
req.log.error({ error }, 'An error occurred during /refresh-token.');
next(error);
}
res.json({ token: newAccessToken });
} catch (error) {
req.log.error({ error }, 'An error occurred during /refresh-token.');
next(error);
}
});
/**
@@ -305,17 +370,21 @@ router.post('/refresh-token', async (req: Request, res: Response, next: NextFunc
* expire the `refreshToken` cookie.
*/
router.post('/logout', async (req: Request, res: Response) => {
const { refreshToken } = req.cookies;
if (refreshToken) {
// Invalidate the token in the database so it cannot be used again.
// We don't need to wait for this to finish to respond to the user.
userRepo.deleteRefreshToken(refreshToken, req.log).catch((err: Error) => {
req.log.error({ error: err }, 'Failed to delete refresh token from DB during logout.');
});
}
// Instruct the browser to clear the cookie by setting its expiration to the past.
res.cookie('refreshToken', '', { httpOnly: true, expires: new Date(0), secure: process.env.NODE_ENV === 'production' });
res.status(200).json({ message: 'Logged out successfully.' });
const { refreshToken } = req.cookies;
if (refreshToken) {
// Invalidate the token in the database so it cannot be used again.
// We don't need to wait for this to finish to respond to the user.
userRepo.deleteRefreshToken(refreshToken, req.log).catch((err: Error) => {
req.log.error({ error: err }, 'Failed to delete refresh token from DB during logout.');
});
}
// Instruct the browser to clear the cookie by setting its expiration to the past.
res.cookie('refreshToken', '', {
httpOnly: true,
expires: new Date(0),
secure: process.env.NODE_ENV === 'production',
});
res.status(200).json({ message: 'Logged out successfully.' });
});
// --- OAuth Routes ---
@@ -346,4 +415,4 @@ router.post('/logout', async (req: Request, res: Response) => {
// router.get('/github', passport.authenticate('github', { session: false }));
// router.get('/github/callback', passport.authenticate('github', { session: false, failureRedirect: '/login' }), handleOAuthCallback);
export default router;
export default router;

View File

@@ -2,7 +2,11 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import supertest from 'supertest';
import type { Request, Response, NextFunction } from 'express';
import { createMockUserProfile, createMockBudget, createMockSpendingByCategory } from '../tests/utils/mockFactories';
import {
createMockUserProfile,
createMockBudget,
createMockSpendingByCategory,
} from '../tests/utils/mockFactories';
import { mockLogger } from '../tests/utils/mockLogger';
import { createTestApp } from '../tests/utils/createTestApp';
import { ForeignKeyConstraintError, NotFoundError } from '../services/db/errors.db';
@@ -31,7 +35,7 @@ import budgetRouter from './budget.routes';
import * as db from '../services/db/index.db';
const mockUser = createMockUserProfile({
user: { user_id: 'user-123', email: 'test@test.com' }
user: { user_id: 'user-123', email: 'test@test.com' },
});
// Standardized mock for passport.routes
@@ -52,7 +56,10 @@ const expectLogger = expect.objectContaining({
});
describe('Budget Routes (/api/budgets)', () => {
const mockUserProfile = createMockUserProfile({ user: { user_id: 'user-123', email: 'test@test.com' }, points: 100 });
const mockUserProfile = createMockUserProfile({
user: { user_id: 'user-123', email: 'test@test.com' },
points: 100,
});
beforeEach(() => {
vi.clearAllMocks();
@@ -61,8 +68,12 @@ describe('Budget Routes (/api/budgets)', () => {
vi.mocked(db.budgetRepo.getBudgetsForUser).mockResolvedValue([]);
vi.mocked(db.budgetRepo.getSpendingByCategory).mockResolvedValue([]);
});
const app = createTestApp({ router: budgetRouter, basePath: '/api/budgets', authenticatedUser: mockUser });
const app = createTestApp({
router: budgetRouter,
basePath: '/api/budgets',
authenticatedUser: mockUser,
});
// Add a basic error handler to capture errors passed to next(err) and return JSON.
// This prevents unhandled error crashes in tests and ensures we get the 500 response we expect.
@@ -70,184 +81,232 @@ describe('Budget Routes (/api/budgets)', () => {
res.status(err.status || 500).json({ message: err.message, errors: err.errors });
});
describe('GET /', () => {
it('should return a list of budgets for the user', async () => {
const mockBudgets = [createMockBudget({ budget_id: 1, user_id: 'user-123' })];
// Mock the service function directly
vi.mocked(db.budgetRepo.getBudgetsForUser).mockResolvedValue(mockBudgets);
describe('GET /', () => {
it('should return a list of budgets for the user', async () => {
const mockBudgets = [createMockBudget({ budget_id: 1, user_id: 'user-123' })];
// Mock the service function directly
vi.mocked(db.budgetRepo.getBudgetsForUser).mockResolvedValue(mockBudgets);
const response = await supertest(app).get('/api/budgets');
const response = await supertest(app).get('/api/budgets');
expect(response.status).toBe(200);
expect(response.body).toEqual(mockBudgets);
expect(db.budgetRepo.getBudgetsForUser).toHaveBeenCalledWith(mockUserProfile.user.user_id, expectLogger);
});
it('should return 500 if the database call fails', async () => {
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.status).toBe(200);
expect(response.body).toEqual(mockBudgets);
expect(db.budgetRepo.getBudgetsForUser).toHaveBeenCalledWith(
mockUserProfile.user.user_id,
expectLogger,
);
});
describe('POST /', () => {
it('should create a new budget and return it', async () => {
const newBudgetData = { name: 'Entertainment', amount_cents: 10000, period: 'monthly' as const, start_date: '2024-01-01' };
const mockCreatedBudget = createMockBudget({ budget_id: 2, user_id: 'user-123', ...newBudgetData });
// Mock the service function
vi.mocked(db.budgetRepo.createBudget).mockResolvedValue(mockCreatedBudget);
it('should return 500 if the database call fails', async () => {
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');
});
});
const response = await supertest(app).post('/api/budgets').send(newBudgetData);
expect(response.status).toBe(201);
expect(response.body).toEqual(mockCreatedBudget);
describe('POST /', () => {
it('should create a new budget and return it', async () => {
const newBudgetData = {
name: 'Entertainment',
amount_cents: 10000,
period: 'monthly' as const,
start_date: '2024-01-01',
};
const mockCreatedBudget = createMockBudget({
budget_id: 2,
user_id: 'user-123',
...newBudgetData,
});
// Mock the service function
vi.mocked(db.budgetRepo.createBudget).mockResolvedValue(mockCreatedBudget);
it('should return 400 if the user does not exist', async () => {
const newBudgetData = { name: 'Entertainment', amount_cents: 10000, period: 'monthly' as const, start_date: '2024-01-01' };
vi.mocked(db.budgetRepo.createBudget).mockRejectedValue(new ForeignKeyConstraintError('User not found'));
const response = await supertest(app).post('/api/budgets').send(newBudgetData);
expect(response.status).toBe(400);
expect(response.body.message).toBe('User not found');
});
const response = await supertest(app).post('/api/budgets').send(newBudgetData);
it('should return 500 if a generic database error occurs', async () => {
const newBudgetData = { name: 'Entertainment', amount_cents: 10000, period: 'monthly' as const, start_date: '2024-01-01' };
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');
});
it('should return 400 for invalid budget data', async () => {
const invalidData = {
name: '', // empty name
amount_cents: -100, // negative amount
period: 'yearly', // invalid period
start_date: 'not-a-date', // invalid date
};
const response = await supertest(app).post('/api/budgets').send(invalidData);
expect(response.status).toBe(400);
expect(response.body.errors).toHaveLength(4);
});
it('should return 400 if required fields are missing', async () => {
// This test covers the `val ?? ''` part of the `requiredString` helper
const response = await supertest(app).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.status).toBe(201);
expect(response.body).toEqual(mockCreatedBudget);
});
describe('PUT /:id', () => {
it('should update an existing budget', async () => {
const budgetUpdates = { amount_cents: 60000 };
const mockUpdatedBudget = createMockBudget({ budget_id: 1, user_id: 'user-123', ...budgetUpdates });
// Mock the service function
vi.mocked(db.budgetRepo.updateBudget).mockResolvedValue(mockUpdatedBudget);
const response = await supertest(app).put('/api/budgets/1').send(budgetUpdates);
expect(response.status).toBe(200);
expect(response.body).toEqual(mockUpdatedBudget);
});
it('should return 404 if the budget is not found', async () => {
vi.mocked(db.budgetRepo.updateBudget).mockRejectedValue(new NotFoundError('Budget not found'));
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');
});
it('should return 500 if a generic database error occurs', async () => {
const budgetUpdates = { amount_cents: 60000 };
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');
});
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('At least one field to update must be provided.');
});
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);
});
it('should return 400 if the user does not exist', async () => {
const newBudgetData = {
name: 'Entertainment',
amount_cents: 10000,
period: 'monthly' as const,
start_date: '2024-01-01',
};
vi.mocked(db.budgetRepo.createBudget).mockRejectedValue(
new ForeignKeyConstraintError('User not found'),
);
const response = await supertest(app).post('/api/budgets').send(newBudgetData);
expect(response.status).toBe(400);
expect(response.body.message).toBe('User not found');
});
describe('DELETE /:id', () => {
it('should delete a budget', async () => {
// Mock the service function to resolve (void)
vi.mocked(db.budgetRepo.deleteBudget).mockResolvedValue(undefined);
const response = await supertest(app).delete('/api/budgets/1');
expect(response.status).toBe(204);
expect(db.budgetRepo.deleteBudget).toHaveBeenCalledWith(1, mockUserProfile.user.user_id, expectLogger);
});
it('should return 404 if the budget is not found', async () => {
vi.mocked(db.budgetRepo.deleteBudget).mockRejectedValue(new NotFoundError('Budget not found'));
const response = await supertest(app).delete('/api/budgets/999');
expect(response.status).toBe(404);
expect(response.body.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');
});
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);
});
it('should return 500 if a generic database error occurs', async () => {
const newBudgetData = {
name: 'Entertainment',
amount_cents: 10000,
period: 'monthly' as const,
start_date: '2024-01-01',
};
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');
});
describe('GET /spending-analysis', () => {
it('should return spending analysis data for a valid date range', async () => {
const mockSpendingData = [createMockSpendingByCategory({ category_id: 1, category_name: 'Produce' })];
// Mock the service function
vi.mocked(db.budgetRepo.getSpendingByCategory).mockResolvedValue(mockSpendingData);
it('should return 400 for invalid budget data', async () => {
const invalidData = {
name: '', // empty name
amount_cents: -100, // negative amount
period: 'yearly', // invalid period
start_date: 'not-a-date', // invalid date
};
const response = await supertest(app).get('/api/budgets/spending-analysis?startDate=2024-01-01&endDate=2024-01-31');
const response = await supertest(app).post('/api/budgets').send(invalidData);
expect(response.status).toBe(200);
expect(response.body).toEqual(mockSpendingData);
});
it('should return 500 if the database call fails', async () => {
// Mock the service function to throw
vi.mocked(db.budgetRepo.getSpendingByCategory).mockRejectedValue(new Error('DB Error'));
const response = await supertest(app).get('/api/budgets/spending-analysis?startDate=2024-01-01&endDate=2024-01-31');
expect(response.status).toBe(500);
expect(response.body.message).toBe('DB Error');
});
it('should return 400 for invalid date formats', async () => {
const response = await supertest(app).get('/api/budgets/spending-analysis?startDate=2024/01/01&endDate=invalid');
expect(response.status).toBe(400);
expect(response.body.errors).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.status).toBe(400);
expect(response.body.errors).toHaveLength(4);
});
});
it('should return 400 if required fields are missing', async () => {
// This test covers the `val ?? ''` part of the `requiredString` helper
const response = await supertest(app)
.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.');
});
});
describe('PUT /:id', () => {
it('should update an existing budget', async () => {
const budgetUpdates = { amount_cents: 60000 };
const mockUpdatedBudget = createMockBudget({
budget_id: 1,
user_id: 'user-123',
...budgetUpdates,
});
// Mock the service function
vi.mocked(db.budgetRepo.updateBudget).mockResolvedValue(mockUpdatedBudget);
const response = await supertest(app).put('/api/budgets/1').send(budgetUpdates);
expect(response.status).toBe(200);
expect(response.body).toEqual(mockUpdatedBudget);
});
it('should return 404 if the budget is not found', async () => {
vi.mocked(db.budgetRepo.updateBudget).mockRejectedValue(
new NotFoundError('Budget not found'),
);
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');
});
it('should return 500 if a generic database error occurs', async () => {
const budgetUpdates = { amount_cents: 60000 };
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');
});
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(
'At least one field to update must be provided.',
);
});
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);
});
});
describe('DELETE /:id', () => {
it('should delete a budget', async () => {
// Mock the service function to resolve (void)
vi.mocked(db.budgetRepo.deleteBudget).mockResolvedValue(undefined);
const response = await supertest(app).delete('/api/budgets/1');
expect(response.status).toBe(204);
expect(db.budgetRepo.deleteBudget).toHaveBeenCalledWith(
1,
mockUserProfile.user.user_id,
expectLogger,
);
});
it('should return 404 if the budget is not found', async () => {
vi.mocked(db.budgetRepo.deleteBudget).mockRejectedValue(
new NotFoundError('Budget not found'),
);
const response = await supertest(app).delete('/api/budgets/999');
expect(response.status).toBe(404);
expect(response.body.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');
});
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);
});
});
describe('GET /spending-analysis', () => {
it('should return spending analysis data for a valid date range', async () => {
const mockSpendingData = [
createMockSpendingByCategory({ category_id: 1, category_name: 'Produce' }),
];
// Mock the service function
vi.mocked(db.budgetRepo.getSpendingByCategory).mockResolvedValue(mockSpendingData);
const response = await supertest(app).get(
'/api/budgets/spending-analysis?startDate=2024-01-01&endDate=2024-01-31',
);
expect(response.status).toBe(200);
expect(response.body).toEqual(mockSpendingData);
});
it('should return 500 if the database call fails', async () => {
// Mock the service function to throw
vi.mocked(db.budgetRepo.getSpendingByCategory).mockRejectedValue(new Error('DB Error'));
const response = await supertest(app).get(
'/api/budgets/spending-analysis?startDate=2024-01-01&endDate=2024-01-31',
);
expect(response.status).toBe(500);
expect(response.body.message).toBe('DB Error');
});
it('should return 400 for invalid date formats', async () => {
const response = await supertest(app).get(
'/api/budgets/spending-analysis?startDate=2024/01/01&endDate=invalid',
);
expect(response.status).toBe(400);
expect(response.body.errors).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);
});
});
});

View File

@@ -9,7 +9,8 @@ import { validateRequest } from '../middleware/validation.middleware';
const router = express.Router();
// Helper for consistent required string validation (handles missing/null/empty)
const requiredString = (message: string) => z.preprocess((val) => val ?? '', z.string().min(1, message));
const requiredString = (message: string) =>
z.preprocess((val) => val ?? '', z.string().min(1, message));
// --- Zod Schemas for Budget Routes (as per ADR-003) ---
@@ -29,7 +30,7 @@ const createBudgetSchema = z.object({
});
const updateBudgetSchema = budgetIdParamSchema.extend({
body: createBudgetSchema.shape.body.partial().refine(data => Object.keys(data).length > 0, {
body: createBudgetSchema.shape.body.partial().refine((data) => Object.keys(data).length > 0, {
message: 'At least one field to update must be provided.',
}),
});
@@ -61,67 +62,104 @@ router.get('/', async (req: Request, res: Response, next: NextFunction) => {
/**
* POST /api/budgets - Create a new budget for the authenticated user.
*/
router.post('/', validateRequest(createBudgetSchema), async (req: Request, res: Response, next: NextFunction) => {
const userProfile = req.user as UserProfile;
type CreateBudgetRequest = z.infer<typeof createBudgetSchema>;
const { body } = req as unknown as CreateBudgetRequest;
try {
const newBudget = await budgetRepo.createBudget(userProfile.user.user_id, body, req.log);
res.status(201).json(newBudget);
} catch (error: unknown) {
req.log.error({ error, userId: userProfile.user.user_id, body }, 'Error creating budget');
next(error);
}
});
router.post(
'/',
validateRequest(createBudgetSchema),
async (req: Request, res: Response, next: NextFunction) => {
const userProfile = req.user as UserProfile;
type CreateBudgetRequest = z.infer<typeof createBudgetSchema>;
const { body } = req as unknown as CreateBudgetRequest;
try {
const newBudget = await budgetRepo.createBudget(userProfile.user.user_id, body, req.log);
res.status(201).json(newBudget);
} catch (error: unknown) {
req.log.error({ error, userId: userProfile.user.user_id, body }, 'Error creating budget');
next(error);
}
},
);
/**
* PUT /api/budgets/:id - Update an existing budget.
*/
router.put('/:id', validateRequest(updateBudgetSchema), async (req: Request, res: Response, next: NextFunction) => {
const userProfile = req.user as UserProfile;
type UpdateBudgetRequest = z.infer<typeof updateBudgetSchema>;
const { params, body } = req as unknown as UpdateBudgetRequest;
try {
const updatedBudget = await budgetRepo.updateBudget(params.id, userProfile.user.user_id, body, req.log);
res.json(updatedBudget);
} catch (error: unknown) {
req.log.error({ error, userId: userProfile.user.user_id, budgetId: params.id }, 'Error updating budget');
next(error);
}
});
router.put(
'/:id',
validateRequest(updateBudgetSchema),
async (req: Request, res: Response, next: NextFunction) => {
const userProfile = req.user as UserProfile;
type UpdateBudgetRequest = z.infer<typeof updateBudgetSchema>;
const { params, body } = req as unknown as UpdateBudgetRequest;
try {
const updatedBudget = await budgetRepo.updateBudget(
params.id,
userProfile.user.user_id,
body,
req.log,
);
res.json(updatedBudget);
} catch (error: unknown) {
req.log.error(
{ error, userId: userProfile.user.user_id, budgetId: params.id },
'Error updating budget',
);
next(error);
}
},
);
/**
* DELETE /api/budgets/:id - Delete a budget.
*/
router.delete('/:id', validateRequest(budgetIdParamSchema), async (req: Request, res: Response, next: NextFunction) => {
const userProfile = req.user as UserProfile;
type DeleteBudgetRequest = z.infer<typeof budgetIdParamSchema>;
const { params } = req as unknown as DeleteBudgetRequest;
try {
await budgetRepo.deleteBudget(params.id, userProfile.user.user_id, req.log);
res.status(204).send(); // No Content
} catch (error: unknown) {
req.log.error({ error, userId: userProfile.user.user_id, budgetId: params.id }, 'Error deleting budget');
next(error);
}
});
router.delete(
'/:id',
validateRequest(budgetIdParamSchema),
async (req: Request, res: Response, next: NextFunction) => {
const userProfile = req.user as UserProfile;
type DeleteBudgetRequest = z.infer<typeof budgetIdParamSchema>;
const { params } = req as unknown as DeleteBudgetRequest;
try {
await budgetRepo.deleteBudget(params.id, userProfile.user.user_id, req.log);
res.status(204).send(); // No Content
} catch (error: unknown) {
req.log.error(
{ error, userId: userProfile.user.user_id, budgetId: params.id },
'Error deleting budget',
);
next(error);
}
},
);
/**
* GET /api/spending-analysis - Get spending breakdown by category for a date range.
* Query params: startDate (YYYY-MM-DD), endDate (YYYY-MM-DD)
*/
router.get('/spending-analysis', validateRequest(spendingAnalysisSchema), async (req: Request, res: Response, next: NextFunction) => {
const userProfile = req.user as UserProfile;
type SpendingAnalysisRequest = z.infer<typeof spendingAnalysisSchema>;
const { query: { startDate, endDate } } = req as unknown as SpendingAnalysisRequest;
router.get(
'/spending-analysis',
validateRequest(spendingAnalysisSchema),
async (req: Request, res: Response, next: NextFunction) => {
const userProfile = req.user as UserProfile;
type SpendingAnalysisRequest = z.infer<typeof spendingAnalysisSchema>;
const {
query: { startDate, endDate },
} = req as unknown as SpendingAnalysisRequest;
try {
const spendingData = await budgetRepo.getSpendingByCategory(userProfile.user.user_id, startDate, endDate, req.log);
res.json(spendingData);
} catch (error) {
req.log.error({ error, userId: userProfile.user.user_id, startDate, endDate }, 'Error fetching spending analysis');
next(error);
}
});
try {
const spendingData = await budgetRepo.getSpendingByCategory(
userProfile.user.user_id,
startDate,
endDate,
req.log,
);
res.json(spendingData);
} catch (error) {
req.log.error(
{ error, userId: userProfile.user.user_id, startDate, endDate },
'Error fetching spending analysis',
);
next(error);
}
},
);
export default router;
export default router;

View File

@@ -26,14 +26,16 @@ vi.mock('../services/logger.server', () => ({
// 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(
(_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();
},
),
},
}));
@@ -46,7 +48,11 @@ const expectLogger = expect.objectContaining({
describe('Deals Routes (/api/users/deals)', () => {
const mockUser = createMockUserProfile({ user: { user_id: 'user-123', email: 'test@test.com' } });
const basePath = '/api/users/deals';
const authenticatedApp = createTestApp({ router: dealsRouter, basePath, authenticatedUser: mockUser });
const authenticatedApp = createTestApp({
router: dealsRouter,
basePath,
authenticatedUser: mockUser,
});
const unauthenticatedApp = createTestApp({ router: dealsRouter, basePath });
const errorHandler = (err: any, req: any, res: any, next: any) => {
res.status(err.status || 500).json({ message: err.message, errors: err.errors });
@@ -62,7 +68,9 @@ describe('Deals Routes (/api/users/deals)', () => {
describe('GET /best-watched-prices', () => {
it('should return 401 Unauthorized if user is not authenticated', async () => {
const response = await supertest(unauthenticatedApp).get('/api/users/deals/best-watched-prices');
const response = await supertest(unauthenticatedApp).get(
'/api/users/deals/best-watched-prices',
);
expect(response.status).toBe(401);
});
@@ -70,22 +78,35 @@ describe('Deals Routes (/api/users/deals)', () => {
const mockDeals: WatchedItemDeal[] = [createMockWatchedItemDeal({ item_name: 'Apples' })];
vi.mocked(dealsRepo.findBestPricesForWatchedItems).mockResolvedValue(mockDeals);
const response = await supertest(authenticatedApp).get('/api/users/deals/best-watched-prices');
const response = await supertest(authenticatedApp).get(
'/api/users/deals/best-watched-prices',
);
expect(response.status).toBe(200);
expect(response.body).toEqual(mockDeals);
expect(dealsRepo.findBestPricesForWatchedItems).toHaveBeenCalledWith(mockUser.user.user_id, expectLogger);
expect(mockLogger.info).toHaveBeenCalledWith({ dealCount: 1 }, 'Successfully fetched best watched item deals.');
expect(dealsRepo.findBestPricesForWatchedItems).toHaveBeenCalledWith(
mockUser.user.user_id,
expectLogger,
);
expect(mockLogger.info).toHaveBeenCalledWith(
{ dealCount: 1 },
'Successfully fetched best watched item deals.',
);
});
it('should return 500 if the database call fails', async () => {
const dbError = new Error('DB Error');
vi.mocked(dealsRepo.findBestPricesForWatchedItems).mockRejectedValue(dbError);
const response = await supertest(authenticatedApp).get('/api/users/deals/best-watched-prices');
const response = await supertest(authenticatedApp).get(
'/api/users/deals/best-watched-prices',
);
expect(response.status).toBe(500);
expect(response.body.message).toBe('DB Error');
expect(mockLogger.error).toHaveBeenCalledWith({ error: dbError }, 'Error fetching best watched item deals.');
expect(mockLogger.error).toHaveBeenCalledWith(
{ error: dbError },
'Error fetching best watched item deals.',
);
});
});
});
});

View File

@@ -25,18 +25,25 @@ router.use(passport.authenticate('jwt', { session: false }));
* @description Fetches the best current sale price for each of the authenticated user's watched items.
* @access Private
*/
router.get('/best-watched-prices', validateRequest(bestWatchedPricesSchema), async (req: Request, res: Response, next: NextFunction) => {
const userProfile = req.user as UserProfile;
try {
// The controller logic is simple enough to be handled directly in the route,
// consistent with other simple GET routes in the project.
const deals = await dealsRepo.findBestPricesForWatchedItems(userProfile.user.user_id, req.log);
req.log.info({ dealCount: deals.length }, 'Successfully fetched best watched item deals.');
res.status(200).json(deals);
} catch (error) {
req.log.error({ error }, 'Error fetching best watched item deals.');
next(error); // Pass errors to the global error handler
}
});
router.get(
'/best-watched-prices',
validateRequest(bestWatchedPricesSchema),
async (req: Request, res: Response, next: NextFunction) => {
const userProfile = req.user as UserProfile;
try {
// The controller logic is simple enough to be handled directly in the route,
// consistent with other simple GET routes in the project.
const deals = await dealsRepo.findBestPricesForWatchedItems(
userProfile.user.user_id,
req.log,
);
req.log.info({ dealCount: deals.length }, 'Successfully fetched best watched item deals.');
res.status(200).json(deals);
} catch (error) {
req.log.error({ error }, 'Error fetching best watched item deals.');
next(error); // Pass errors to the global error handler
}
},
);
export default router;
export default router;

View File

@@ -37,7 +37,7 @@ describe('Flyer Routes (/api/flyers)', () => {
beforeEach(() => {
vi.clearAllMocks();
});
const app = createTestApp({ router: flyerRouter, basePath: '/api/flyers' });
// Add a basic error handler to capture errors passed to next(err) and return JSON.
@@ -69,7 +69,10 @@ describe('Flyer Routes (/api/flyers)', () => {
const response = await supertest(app).get('/api/flyers');
expect(response.status).toBe(500);
expect(response.body.message).toBe('DB Error');
expect(mockLogger.error).toHaveBeenCalledWith({ error: dbError }, 'Error fetching flyers in /api/flyers:');
expect(mockLogger.error).toHaveBeenCalledWith(
{ error: dbError },
'Error fetching flyers in /api/flyers:',
);
});
it('should return 400 for invalid query parameters', async () => {
@@ -96,7 +99,9 @@ describe('Flyer Routes (/api/flyers)', () => {
// FIX: Instead of mocking a rejection, we mock the *result* of the database query
// to have zero rows. This allows the `getFlyerById` method's own internal logic
// to correctly throw the `NotFoundError`, making the test more realistic.
vi.mocked(db.flyerRepo.getFlyerById).mockRejectedValue(new NotFoundError(`Flyer with ID 999 not found.`));
vi.mocked(db.flyerRepo.getFlyerById).mockRejectedValue(
new NotFoundError(`Flyer with ID 999 not found.`),
);
const response = await supertest(app).get('/api/flyers/999');
expect(response.status).toBe(404);
@@ -107,7 +112,9 @@ describe('Flyer Routes (/api/flyers)', () => {
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(/Invalid flyer ID provided|expected number, received NaN/);
expect(response.body.errors[0].message).toMatch(
/Invalid flyer ID provided|expected number, received NaN/,
);
});
it('should return 500 if the database call fails', async () => {
@@ -116,7 +123,10 @@ describe('Flyer Routes (/api/flyers)', () => {
const response = await supertest(app).get('/api/flyers/123');
expect(response.status).toBe(500);
expect(response.body.message).toBe('DB Error');
expect(mockLogger.error).toHaveBeenCalledWith({ error: dbError, flyerId: 123 }, 'Error fetching flyer by ID:');
expect(mockLogger.error).toHaveBeenCalledWith(
{ error: dbError, flyerId: 123 },
'Error fetching flyer by ID:',
);
});
});
@@ -134,7 +144,9 @@ describe('Flyer Routes (/api/flyers)', () => {
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(/Invalid flyer ID provided|expected number, received NaN/);
expect(response.body.errors[0].message).toMatch(
/Invalid flyer ID provided|expected number, received NaN/,
);
});
it('should return 500 if the database call fails', async () => {
@@ -143,7 +155,10 @@ describe('Flyer Routes (/api/flyers)', () => {
const response = await supertest(app).get('/api/flyers/123/items');
expect(response.status).toBe(500);
expect(response.body.message).toBe('DB Error');
expect(mockLogger.error).toHaveBeenCalledWith({ error: dbError }, 'Error fetching flyer items in /api/flyers/:id/items:');
expect(mockLogger.error).toHaveBeenCalledWith(
{ error: dbError },
'Error fetching flyer items in /api/flyers/:id/items:',
);
});
});
@@ -179,7 +194,9 @@ describe('Flyer Routes (/api/flyers)', () => {
it('should return 500 if the database call fails', async () => {
vi.mocked(db.flyerRepo.getFlyerItemsForFlyers).mockRejectedValue(new Error('DB Error'));
const response = await supertest(app).post('/api/flyers/items/batch-fetch').send({ flyerIds: [1] });
const response = await supertest(app)
.post('/api/flyers/items/batch-fetch')
.send({ flyerIds: [1] });
expect(response.status).toBe(500);
expect(response.body.message).toBe('DB Error');
});
@@ -216,7 +233,9 @@ describe('Flyer Routes (/api/flyers)', () => {
it('should return 500 if the database call fails', async () => {
vi.mocked(db.flyerRepo.countFlyerItemsForFlyers).mockRejectedValue(new Error('DB Error'));
const response = await supertest(app).post('/api/flyers/items/batch-count').send({ flyerIds: [1] });
const response = await supertest(app)
.post('/api/flyers/items/batch-count')
.send({ flyerIds: [1] });
expect(response.status).toBe(500);
expect(response.body.message).toBe('DB Error');
});
@@ -229,7 +248,11 @@ describe('Flyer Routes (/api/flyers)', () => {
.send({ type: 'click' });
expect(response.status).toBe(202);
expect(db.flyerRepo.trackFlyerItemInteraction).toHaveBeenCalledWith(99, 'click', expectLogger);
expect(db.flyerRepo.trackFlyerItemInteraction).toHaveBeenCalledWith(
99,
'click',
expectLogger,
);
});
it('should return 202 Accepted and call the tracking function for "view"', async () => {
@@ -238,7 +261,11 @@ describe('Flyer Routes (/api/flyers)', () => {
.send({ type: 'view' });
expect(response.status).toBe(202);
expect(db.flyerRepo.trackFlyerItemInteraction).toHaveBeenCalledWith(101, 'view', expectLogger);
expect(db.flyerRepo.trackFlyerItemInteraction).toHaveBeenCalledWith(
101,
'view',
expectLogger,
);
});
it('should return 400 for an invalid item ID', async () => {
@@ -249,8 +276,10 @@ describe('Flyer Routes (/api/flyers)', () => {
});
it('should return 400 for an invalid interaction type', async () => {
const response = await supertest(app).post('/api/flyers/items/99/track').send({ type: 'invalid' });
const response = await supertest(app)
.post('/api/flyers/items/99/track')
.send({ type: 'invalid' });
expect(response.status).toBe(400);
});
});
});
});

View File

@@ -38,26 +38,27 @@ const trackItemSchema = z.object({
itemId: z.coerce.number().int().positive('Invalid item ID provided.'),
}),
body: z.object({
type: z.enum(['view', 'click'], { message: 'A valid interaction type ("view" or "click") is required.' }),
type: z.enum(['view', 'click'], {
message: 'A valid interaction type ("view" or "click") is required.',
}),
}),
});
/**
* GET /api/flyers - Get a paginated list of all flyers.
*/
type GetFlyersRequest = z.infer<typeof getFlyersSchema>;
router.get('/', validateRequest(getFlyersSchema), async (req, res, next): Promise<void> => {
const { query } = req as unknown as GetFlyersRequest;
try {
const limit = query.limit ? Number(query.limit) : 20;
const offset = query.offset ? Number(query.offset) : 0;
const flyers = await db.flyerRepo.getFlyers(req.log, limit, offset);
res.json(flyers);
} catch (error) {
req.log.error({ error }, 'Error fetching flyers in /api/flyers:');
next(error);
}
const { query } = req as unknown as GetFlyersRequest;
try {
const limit = query.limit ? Number(query.limit) : 20;
const offset = query.offset ? Number(query.offset) : 0;
const flyers = await db.flyerRepo.getFlyers(req.log, limit, offset);
res.json(flyers);
} catch (error) {
req.log.error({ error }, 'Error fetching flyers in /api/flyers:');
next(error);
}
});
/**
@@ -65,58 +66,70 @@ router.get('/', validateRequest(getFlyersSchema), async (req, res, next): Promis
*/
type GetFlyerByIdRequest = z.infer<typeof flyerIdParamSchema>;
router.get('/:id', validateRequest(flyerIdParamSchema), async (req, res, next): Promise<void> => {
const { params } = req as unknown as GetFlyerByIdRequest;
try {
const flyer = await db.flyerRepo.getFlyerById(params.id);
res.json(flyer);
} catch (error) {
req.log.error({ error, flyerId: params.id }, 'Error fetching flyer by ID:');
next(error);
}
const { params } = req as unknown as GetFlyerByIdRequest;
try {
const flyer = await db.flyerRepo.getFlyerById(params.id);
res.json(flyer);
} catch (error) {
req.log.error({ error, flyerId: params.id }, 'Error fetching flyer by ID:');
next(error);
}
});
/**
* GET /api/flyers/:id/items - Get all items for a specific flyer.
*/
router.get('/:id/items', validateRequest(flyerIdParamSchema), async (req, res, next): Promise<void> => {
router.get(
'/:id/items',
validateRequest(flyerIdParamSchema),
async (req, res, next): Promise<void> => {
const { params } = req as unknown as GetFlyerByIdRequest;
try {
const items = await db.flyerRepo.getFlyerItems(params.id, req.log);
res.json(items);
const items = await db.flyerRepo.getFlyerItems(params.id, req.log);
res.json(items);
} catch (error) {
req.log.error({ error }, 'Error fetching flyer items in /api/flyers/:id/items:');
next(error);
req.log.error({ error }, 'Error fetching flyer items in /api/flyers/:id/items:');
next(error);
}
});
},
);
/**
* POST /api/flyers/items/batch-fetch - Get all items for multiple flyers at once.
*/
type BatchFetchRequest = z.infer<typeof batchFetchSchema>;
router.post('/items/batch-fetch', validateRequest(batchFetchSchema), async (req, res, next): Promise<void> => {
router.post(
'/items/batch-fetch',
validateRequest(batchFetchSchema),
async (req, res, next): Promise<void> => {
const { body } = req as unknown as BatchFetchRequest;
try {
const items = await db.flyerRepo.getFlyerItemsForFlyers(body.flyerIds, req.log);
res.json(items);
const items = await db.flyerRepo.getFlyerItemsForFlyers(body.flyerIds, req.log);
res.json(items);
} catch (error) {
next(error);
next(error);
}
});
},
);
/**
* POST /api/flyers/items/batch-count - Get the total number of items for multiple flyers.
*/
type BatchCountRequest = z.infer<typeof batchCountSchema>;
router.post('/items/batch-count', validateRequest(batchCountSchema), async (req, res, next): Promise<void> => {
router.post(
'/items/batch-count',
validateRequest(batchCountSchema),
async (req, res, next): Promise<void> => {
const { body } = req as unknown as BatchCountRequest;
try {
// The DB function handles an empty array, so we can simplify.
const count = await db.flyerRepo.countFlyerItemsForFlyers(body.flyerIds ?? [], req.log);
res.json({ count });
// The DB function handles an empty array, so we can simplify.
const count = await db.flyerRepo.countFlyerItemsForFlyers(body.flyerIds ?? [], req.log);
res.json({ count });
} catch (error) {
next(error);
next(error);
}
});
},
);
/**
* POST /api/flyers/items/:itemId/track - Tracks a user interaction with a flyer item.
@@ -128,4 +141,4 @@ router.post('/items/:itemId/track', validateRequest(trackItemSchema), (req, res)
res.status(202).send();
});
export default router;
export default router;

View File

@@ -2,7 +2,12 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import supertest from 'supertest';
import type { Request, Response, NextFunction } from 'express';
import { createMockUserProfile, createMockAchievement, createMockUserAchievement, createMockLeaderboardUser } from '../tests/utils/mockFactories';
import {
createMockUserProfile,
createMockAchievement,
createMockUserAchievement,
createMockLeaderboardUser,
} from '../tests/utils/mockFactories';
import { mockLogger } from '../tests/utils/mockLogger';
import { ForeignKeyConstraintError } from '../services/db/errors.db';
import { createTestApp } from '../tests/utils/createTestApp';
@@ -14,7 +19,7 @@ vi.mock('../services/db/index.db', () => ({
getUserAchievements: vi.fn(),
awardAchievement: vi.fn(),
getLeaderboard: vi.fn(),
}
},
}));
// Import the router and mocked DB AFTER all mocks are defined.
@@ -27,7 +32,9 @@ vi.mock('../services/logger.server', () => ({
}));
// Use vi.hoisted to create mutable mock function references.
const mockedAuthMiddleware = vi.hoisted(() => vi.fn((req: Request, res: Response, next: NextFunction) => next()));
const mockedAuthMiddleware = vi.hoisted(() =>
vi.fn((req: Request, res: Response, next: NextFunction) => next()),
);
const mockedIsAdmin = vi.hoisted(() => vi.fn());
vi.mock('./passport.routes', () => ({
@@ -46,8 +53,15 @@ const expectLogger = expect.objectContaining({
});
describe('Gamification Routes (/api/achievements)', () => {
const mockUserProfile = createMockUserProfile({ user: { user_id: 'user-123', email: 'user@test.com' }, points: 100 });
const mockAdminProfile = createMockUserProfile({ user: { user_id: 'admin-456', email: 'admin@test.com' }, role: 'admin', points: 999 });
const mockUserProfile = createMockUserProfile({
user: { user_id: 'user-123', email: 'user@test.com' },
points: 100,
});
const mockAdminProfile = createMockUserProfile({
user: { user_id: 'admin-456', email: 'admin@test.com' },
role: 'admin',
points: 999,
});
beforeEach(() => {
vi.clearAllMocks();
@@ -62,8 +76,16 @@ describe('Gamification Routes (/api/achievements)', () => {
const basePath = '/api/achievements';
const unauthenticatedApp = createTestApp({ router: gamificationRouter, basePath });
const authenticatedApp = createTestApp({ router: gamificationRouter, basePath, authenticatedUser: mockUserProfile });
const adminApp = createTestApp({ router: gamificationRouter, basePath, authenticatedUser: mockAdminProfile });
const authenticatedApp = createTestApp({
router: gamificationRouter,
basePath,
authenticatedUser: mockUserProfile,
});
const adminApp = createTestApp({
router: gamificationRouter,
basePath,
authenticatedUser: mockAdminProfile,
});
const errorHandler = (err: any, req: any, res: any, next: any) => {
res.status(err.status || 500).json({ message: err.message, errors: err.errors });
};
@@ -73,7 +95,10 @@ describe('Gamification Routes (/api/achievements)', () => {
describe('GET /', () => {
it('should return a list of all achievements (public endpoint)', async () => {
const mockAchievements = [createMockAchievement({ achievement_id: 1 }), createMockAchievement({ achievement_id: 2 })];
const mockAchievements = [
createMockAchievement({ achievement_id: 1 }),
createMockAchievement({ achievement_id: 2 }),
];
vi.mocked(db.gamificationRepo.getAllAchievements).mockResolvedValue(mockAchievements);
const response = await supertest(unauthenticatedApp).get('/api/achievements');
@@ -97,9 +122,13 @@ describe('Gamification Routes (/api/achievements)', () => {
next();
});
mockedIsAdmin.mockImplementation((req: Request, res: Response, next: NextFunction) => next());
vi.mocked(db.gamificationRepo.awardAchievement).mockRejectedValue(new ForeignKeyConstraintError('User not found'));
vi.mocked(db.gamificationRepo.awardAchievement).mockRejectedValue(
new ForeignKeyConstraintError('User not found'),
);
const response = await supertest(adminApp).post('/api/achievements/award').send({ userId: 'non-existent', achievementName: 'Test Award' });
const response = await supertest(adminApp)
.post('/api/achievements/award')
.send({ userId: 'non-existent', achievementName: 'Test Award' });
expect(response.status).toBe(400);
expect(response.body.message).toBe('User not found');
});
@@ -118,14 +147,19 @@ describe('Gamification Routes (/api/achievements)', () => {
next();
});
const mockUserAchievements = [createMockUserAchievement({ achievement_id: 1, user_id: 'user-123' })];
const mockUserAchievements = [
createMockUserAchievement({ achievement_id: 1, user_id: 'user-123' }),
];
vi.mocked(db.gamificationRepo.getUserAchievements).mockResolvedValue(mockUserAchievements);
const response = await supertest(authenticatedApp).get('/api/achievements/me');
expect(response.status).toBe(200);
expect(response.body).toEqual(mockUserAchievements);
expect(db.gamificationRepo.getUserAchievements).toHaveBeenCalledWith('user-123', expectLogger);
expect(db.gamificationRepo.getUserAchievements).toHaveBeenCalledWith(
'user-123',
expectLogger,
);
});
it('should return 500 if the database call fails', async () => {
@@ -146,7 +180,9 @@ describe('Gamification Routes (/api/achievements)', () => {
const awardPayload = { userId: 'user-789', achievementName: 'Test Award' };
it('should return 401 Unauthorized if user is not authenticated', async () => {
const response = await supertest(unauthenticatedApp).post('/api/achievements/award').send(awardPayload);
const response = await supertest(unauthenticatedApp)
.post('/api/achievements/award')
.send(awardPayload);
expect(response.status).toBe(401);
});
@@ -158,7 +194,9 @@ describe('Gamification Routes (/api/achievements)', () => {
});
// Let the default isAdmin mock (set in beforeEach) run, which denies access
const response = await supertest(authenticatedApp).post('/api/achievements/award').send(awardPayload);
const response = await supertest(authenticatedApp)
.post('/api/achievements/award')
.send(awardPayload);
expect(response.status).toBe(403);
});
@@ -176,7 +214,11 @@ describe('Gamification Routes (/api/achievements)', () => {
expect(response.status).toBe(200);
expect(response.body.message).toContain('Successfully awarded');
expect(db.gamificationRepo.awardAchievement).toHaveBeenCalledTimes(1);
expect(db.gamificationRepo.awardAchievement).toHaveBeenCalledWith(awardPayload.userId, awardPayload.achievementName, expectLogger);
expect(db.gamificationRepo.awardAchievement).toHaveBeenCalledWith(
awardPayload.userId,
awardPayload.achievementName,
expectLogger,
);
});
it('should return 500 if the database call fails', async () => {
@@ -193,23 +235,35 @@ describe('Gamification Routes (/api/achievements)', () => {
});
it('should return 400 for an invalid userId or achievementName', async () => {
mockedAuthMiddleware.mockImplementation((req: Request, res: Response, next: NextFunction) => { req.user = mockAdminProfile; next(); });
mockedAuthMiddleware.mockImplementation((req: Request, res: Response, next: NextFunction) => {
req.user = mockAdminProfile;
next();
});
mockedIsAdmin.mockImplementation((req: Request, res: Response, next: NextFunction) => next());
const response = await supertest(adminApp).post('/api/achievements/award').send({ userId: '', achievementName: '' });
const response = await supertest(adminApp)
.post('/api/achievements/award')
.send({ userId: '', achievementName: '' });
expect(response.status).toBe(400);
expect(response.body.errors).toHaveLength(2);
});
it('should return 400 if userId or achievementName are missing', async () => {
mockedAuthMiddleware.mockImplementation((req: Request, res: Response, next: NextFunction) => { req.user = mockAdminProfile; next(); });
mockedAuthMiddleware.mockImplementation((req: Request, res: Response, next: NextFunction) => {
req.user = mockAdminProfile;
next();
});
mockedIsAdmin.mockImplementation((req: Request, res: Response, next: NextFunction) => next());
const response1 = await supertest(adminApp).post('/api/achievements/award').send({ achievementName: 'Test Award' });
const response1 = await supertest(adminApp)
.post('/api/achievements/award')
.send({ achievementName: 'Test Award' });
expect(response1.status).toBe(400);
expect(response1.body.errors[0].message).toBe('userId is required.');
const response2 = await supertest(adminApp).post('/api/achievements/award').send({ userId: 'user-789' });
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.');
});
@@ -220,9 +274,13 @@ describe('Gamification Routes (/api/achievements)', () => {
next();
});
mockedIsAdmin.mockImplementation((req: Request, res: Response, next: NextFunction) => next());
vi.mocked(db.gamificationRepo.awardAchievement).mockRejectedValue(new ForeignKeyConstraintError('User not found'));
vi.mocked(db.gamificationRepo.awardAchievement).mockRejectedValue(
new ForeignKeyConstraintError('User not found'),
);
const response = await supertest(adminApp).post('/api/achievements/award').send({ userId: 'non-existent', achievementName: 'Test Award' });
const response = await supertest(adminApp)
.post('/api/achievements/award')
.send({ userId: 'non-existent', achievementName: 'Test Award' });
expect(response.status).toBe(400);
expect(response.body.message).toBe('User not found');
});
@@ -230,10 +288,19 @@ describe('Gamification Routes (/api/achievements)', () => {
describe('GET /leaderboard', () => {
it('should return a list of top users (public endpoint)', async () => {
const mockLeaderboard = [createMockLeaderboardUser({ user_id: 'user-1', full_name: 'Leader', points: 1000, rank: '1' })];
const mockLeaderboard = [
createMockLeaderboardUser({
user_id: 'user-1',
full_name: 'Leader',
points: 1000,
rank: '1',
}),
];
vi.mocked(db.gamificationRepo.getLeaderboard).mockResolvedValue(mockLeaderboard);
const response = await supertest(unauthenticatedApp).get('/api/achievements/leaderboard?limit=5');
const response = await supertest(unauthenticatedApp).get(
'/api/achievements/leaderboard?limit=5',
);
expect(response.status).toBe(200);
expect(response.body).toEqual(mockLeaderboard);
@@ -241,7 +308,14 @@ describe('Gamification Routes (/api/achievements)', () => {
});
it('should use the default limit of 10 when no limit is provided', async () => {
const mockLeaderboard = [createMockLeaderboardUser({ user_id: 'user-1', full_name: 'Leader', points: 1000, rank: '1' })];
const mockLeaderboard = [
createMockLeaderboardUser({
user_id: 'user-1',
full_name: 'Leader',
points: 1000,
rank: '1',
}),
];
vi.mocked(db.gamificationRepo.getLeaderboard).mockResolvedValue(mockLeaderboard);
const response = await supertest(unauthenticatedApp).get('/api/achievements/leaderboard');
@@ -259,7 +333,9 @@ describe('Gamification Routes (/api/achievements)', () => {
});
it('should return 400 for an invalid limit parameter', async () => {
const response = await supertest(unauthenticatedApp).get('/api/achievements/leaderboard?limit=100');
const response = await supertest(unauthenticatedApp).get(
'/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);

View File

@@ -12,7 +12,8 @@ const router = express.Router();
const adminGamificationRouter = express.Router(); // Create a new router for admin-only routes.
// Helper for consistent required string validation (handles missing/null/empty)
const requiredString = (message: string) => z.preprocess((val) => val ?? '', z.string().min(1, message));
const requiredString = (message: string) =>
z.preprocess((val) => val ?? '', z.string().min(1, message));
// --- Zod Schemas for Gamification Routes (as per ADR-003) ---
@@ -49,21 +50,25 @@ router.get('/', async (req, res, next: NextFunction) => {
* GET /api/achievements/leaderboard - Get the top users by points.
* This is a public endpoint.
*/
router.get('/leaderboard', validateRequest(leaderboardSchema), async (req, res, next: NextFunction): Promise<void> => {
// Apply ADR-003 pattern for type safety.
// Explicitly coerce query params to ensure numbers are passed to the repo,
// as validateRequest might not replace req.query in all test environments.
const query = req.query as unknown as { limit?: string };
const limit = query.limit ? Number(query.limit) : 10;
router.get(
'/leaderboard',
validateRequest(leaderboardSchema),
async (req, res, next: NextFunction): Promise<void> => {
// Apply ADR-003 pattern for type safety.
// Explicitly coerce query params to ensure numbers are passed to the repo,
// as validateRequest might not replace req.query in all test environments.
const query = req.query as unknown as { limit?: string };
const limit = query.limit ? Number(query.limit) : 10;
try {
const leaderboard = await gamificationRepo.getLeaderboard(limit, req.log);
res.json(leaderboard);
} catch (error) {
logger.error({ error }, 'Error fetching leaderboard:');
next(error);
}
});
try {
const leaderboard = await gamificationRepo.getLeaderboard(limit, req.log);
res.json(leaderboard);
} catch (error) {
logger.error({ error }, 'Error fetching leaderboard:');
next(error);
}
},
);
// --- Authenticated User Routes ---
@@ -77,13 +82,19 @@ router.get(
async (req, res, next: NextFunction): Promise<void> => {
const userProfile = req.user as UserProfile;
try {
const userAchievements = await gamificationRepo.getUserAchievements(userProfile.user.user_id, req.log);
const userAchievements = await gamificationRepo.getUserAchievements(
userProfile.user.user_id,
req.log,
);
res.json(userAchievements);
} catch (error) {
logger.error({ error, userId: userProfile.user.user_id }, 'Error fetching user achievements:');
logger.error(
{ error, userId: userProfile.user.user_id },
'Error fetching user achievements:',
);
next(error);
}
}
},
);
// --- Admin-Only Routes ---
@@ -96,26 +107,34 @@ adminGamificationRouter.use(passport.authenticate('jwt', { session: false }), is
* This is an admin-only endpoint.
*/
adminGamificationRouter.post(
'/award', validateRequest(awardAchievementSchema),
'/award',
validateRequest(awardAchievementSchema),
async (req, res, next: NextFunction): Promise<void> => {
// Infer type and cast request object as per ADR-003
type AwardAchievementRequest = z.infer<typeof awardAchievementSchema>;
const { body } = req as unknown as AwardAchievementRequest;
try {
await gamificationRepo.awardAchievement(body.userId, body.achievementName, req.log);
res.status(200).json({ message: `Successfully awarded '${body.achievementName}' to user ${body.userId}.` });
res
.status(200)
.json({
message: `Successfully awarded '${body.achievementName}' to user ${body.userId}.`,
});
} catch (error) {
if (error instanceof ForeignKeyConstraintError) {
res.status(400).json({ message: error.message });
return;
}
logger.error({ error, userId: body.userId, achievementName: body.achievementName }, 'Error awarding achievement via admin endpoint:');
logger.error(
{ error, userId: body.userId, achievementName: body.achievementName },
'Error awarding achievement via admin endpoint:',
);
next(error);
}
}
},
);
// Mount the admin sub-router onto the main gamification router.
router.use(adminGamificationRouter);
export default router;
export default router;

View File

@@ -167,7 +167,10 @@ describe('Health Routes (/api/health)', () => {
expect(response.status).toBe(500);
expect(response.body.message).toBe('DB connection failed');
expect(logger.error).toHaveBeenCalledWith({ error: 'DB connection failed' }, 'Error during DB schema check:');
expect(logger.error).toHaveBeenCalledWith(
{ error: 'DB connection failed' },
'Error during DB schema check:',
);
});
it('should return 500 if the database check fails with a non-Error object', async () => {
@@ -179,7 +182,10 @@ describe('Health Routes (/api/health)', () => {
expect(response.status).toBe(500);
expect(response.body.message).toBe('DB connection failed');
expect(logger.error).toHaveBeenCalledWith({ error: dbError }, 'Error during DB schema check:');
expect(logger.error).toHaveBeenCalledWith(
{ error: dbError },
'Error during DB schema check:',
);
});
});
@@ -208,7 +214,10 @@ describe('Health Routes (/api/health)', () => {
// Assert
expect(response.status).toBe(500);
expect(response.body.message).toContain('Storage check failed.');
expect(logger.error).toHaveBeenCalledWith({ error: 'EACCES: permission denied' }, expect.stringContaining('Storage check failed for path:'));
expect(logger.error).toHaveBeenCalledWith(
{ error: 'EACCES: permission denied' },
expect.stringContaining('Storage check failed for path:'),
);
});
it('should return 500 if storage check fails with a non-Error object', async () => {
@@ -222,7 +231,10 @@ describe('Health Routes (/api/health)', () => {
// Assert
expect(response.status).toBe(500);
expect(response.body.message).toContain('Storage check failed.');
expect(logger.error).toHaveBeenCalledWith({ error: accessError }, expect.stringContaining('Storage check failed for path:'));
expect(logger.error).toHaveBeenCalledWith(
{ error: accessError },
expect.stringContaining('Storage check failed for path:'),
);
});
});
@@ -260,31 +272,43 @@ describe('Health Routes (/api/health)', () => {
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(logger.warn).toHaveBeenCalledWith('Database pool health check shows high waiting count: 15');
expect(logger.warn).toHaveBeenCalledWith(
'Database pool health check shows high waiting count: 15',
);
});
});
it('should return 500 if getPoolStatus throws an error', async () => {
// Arrange: Mock getPoolStatus to throw an error
const poolError = new Error('Pool is not initialized');
mockedDbConnection.getPoolStatus.mockImplementation(() => { throw poolError; });
mockedDbConnection.getPoolStatus.mockImplementation(() => {
throw poolError;
});
const response = await supertest(app).get('/api/health/db-pool');
expect(response.status).toBe(500);
expect(response.body.message).toBe('Pool is not initialized');
expect(logger.error).toHaveBeenCalledWith({ error: 'Pool is not initialized' }, 'Error during DB pool health check:');
expect(logger.error).toHaveBeenCalledWith(
{ error: 'Pool is not initialized' },
'Error during DB pool health check:',
);
});
it('should return 500 if getPoolStatus throws a non-Error object', async () => {
// Arrange: Mock getPoolStatus to throw a non-error object
const poolError = { message: 'Pool is not initialized' };
mockedDbConnection.getPoolStatus.mockImplementation(() => { throw poolError; });
mockedDbConnection.getPoolStatus.mockImplementation(() => {
throw poolError;
});
const response = await supertest(app).get('/api/health/db-pool');
expect(response.status).toBe(500);
expect(response.body.message).toBe('Pool is not initialized');
expect(logger.error).toHaveBeenCalledWith({ error: poolError }, 'Error during DB pool health check:');
expect(logger.error).toHaveBeenCalledWith(
{ error: poolError },
'Error during DB pool health check:',
);
});
});
});

View File

@@ -19,7 +19,7 @@ const emptySchema = z.object({});
* GET /api/health/ping - A simple endpoint to check if the server is responsive.
*/
router.get('/ping', validateRequest(emptySchema), (_req: Request, res: Response) => {
res.status(200).send('pong');
res.status(200).send('pong');
});
/**
@@ -27,19 +27,24 @@ router.get('/ping', validateRequest(emptySchema), (_req: Request, res: Response)
* This is a critical check to ensure the database schema is correctly set up.
*/
router.get('/db-schema', validateRequest(emptySchema), async (req, res, next: NextFunction) => {
try {
const requiredTables = ['users', 'profiles', 'flyers', 'flyer_items', 'stores'];
const missingTables = await checkTablesExist(requiredTables);
try {
const requiredTables = ['users', 'profiles', 'flyers', 'flyer_items', 'stores'];
const missingTables = await checkTablesExist(requiredTables);
if (missingTables.length > 0) {
// Create a new error to be handled by the global error handler
return next(new Error(`Database schema check failed. Missing tables: ${missingTables.join(', ')}.`));
}
return res.status(200).json({ success: true, message: 'All required database tables exist.' });
} catch (error: unknown) {
logger.error({ error: error instanceof Error ? error.message : error }, 'Error during DB schema check:');
next(error);
if (missingTables.length > 0) {
// Create a new error to be handled by the global error handler
return next(
new Error(`Database schema check failed. Missing tables: ${missingTables.join(', ')}.`),
);
}
return res.status(200).json({ success: true, message: 'All required database tables exist.' });
} catch (error: unknown) {
logger.error(
{ error: error instanceof Error ? error.message : error },
'Error during DB schema check:',
);
next(error);
}
});
/**
@@ -47,65 +52,90 @@ router.get('/db-schema', validateRequest(emptySchema), async (req, res, next: Ne
* This is important for features like file uploads.
*/
router.get('/storage', validateRequest(emptySchema), async (req, res, next: NextFunction) => {
const storagePath = process.env.STORAGE_PATH || '/var/www/flyer-crawler.projectium.com/assets';
try {
await fs.access(storagePath, fs.constants.W_OK); // Use fs.promises
return res.status(200).json({ success: true, message: `Storage directory '${storagePath}' is accessible and writable.` });
} catch (error: unknown) {
logger.error({ error: error instanceof Error ? error.message : error }, `Storage check failed for path: ${storagePath}`);
next(new Error(`Storage check failed. Ensure the directory '${storagePath}' exists and is writable by the application.`));
}
const storagePath = process.env.STORAGE_PATH || '/var/www/flyer-crawler.projectium.com/assets';
try {
await fs.access(storagePath, fs.constants.W_OK); // Use fs.promises
return res
.status(200)
.json({
success: true,
message: `Storage directory '${storagePath}' is accessible and writable.`,
});
} catch (error: unknown) {
logger.error(
{ error: error instanceof Error ? error.message : error },
`Storage check failed for path: ${storagePath}`,
);
next(
new Error(
`Storage check failed. Ensure the directory '${storagePath}' exists and is writable by the application.`,
),
);
}
});
/**
* GET /api/health/db-pool - Checks the status of the database connection pool.
* This helps diagnose issues related to database connection saturation.
*/
router.get('/db-pool', validateRequest(emptySchema), (req: Request, res: Response, next: NextFunction) => {
router.get(
'/db-pool',
validateRequest(emptySchema),
(req: Request, res: Response, next: NextFunction) => {
try {
const status = getPoolStatus();
const isHealthy = status.waitingCount < 5;
const message = `Pool Status: ${status.totalCount} total, ${status.idleCount} idle, ${status.waitingCount} waiting.`;
if (isHealthy) {
return res.status(200).json({ success: true, message });
} else {
logger.warn(`Database pool health check shows high waiting count: ${status.waitingCount}`);
return res.status(500).json({ success: false, message: `Pool may be under stress. ${message}` });
}
const status = getPoolStatus();
const isHealthy = status.waitingCount < 5;
const message = `Pool Status: ${status.totalCount} total, ${status.idleCount} idle, ${status.waitingCount} waiting.`;
if (isHealthy) {
return res.status(200).json({ success: true, message });
} else {
logger.warn(`Database pool health check shows high waiting count: ${status.waitingCount}`);
return res
.status(500)
.json({ success: false, message: `Pool may be under stress. ${message}` });
}
} catch (error: unknown) {
logger.error({ error: error instanceof Error ? error.message : error }, 'Error during DB pool health check:');
next(error);
logger.error(
{ error: error instanceof Error ? error.message : error },
'Error during DB pool health check:',
);
next(error);
}
});
},
);
/**
* GET /api/health/time - Returns the server's current time, year, and week number.
* Useful for verifying time synchronization and for features dependent on week numbers.
*/
router.get('/time', validateRequest(emptySchema), (req: Request, res: Response) => {
const now = new Date();
const { year, week } = getSimpleWeekAndYear(now);
res.json({
currentTime: now.toISOString(),
year,
week,
});
const now = new Date();
const { year, week } = getSimpleWeekAndYear(now);
res.json({
currentTime: now.toISOString(),
year,
week,
});
});
/**
* GET /api/health/redis - Checks the health of the Redis connection.
*/
router.get('/redis', validateRequest(emptySchema), async (req: Request, res: Response, next: NextFunction) => {
router.get(
'/redis',
validateRequest(emptySchema),
async (req: Request, res: Response, next: NextFunction) => {
try {
const reply = await redisConnection.ping();
if (reply === 'PONG') {
return res.status(200).json({ success: true, message: 'Redis connection is healthy.' });
}
throw new Error(`Unexpected Redis ping response: ${reply}`); // This will be caught below
const reply = await redisConnection.ping();
if (reply === 'PONG') {
return res.status(200).json({ success: true, message: 'Redis connection is healthy.' });
}
throw new Error(`Unexpected Redis ping response: ${reply}`); // This will be caught below
} catch (error: unknown) {
next(error);
next(error);
}
});
},
);
export default router;
export default router;

View File

@@ -1,7 +1,11 @@
// src/routes/personalization.routes.test.ts
import { describe, it, expect, vi, beforeEach } from 'vitest';
import supertest from 'supertest';
import { createMockMasterGroceryItem, createMockDietaryRestriction, createMockAppliance } from '../tests/utils/mockFactories';
import {
createMockMasterGroceryItem,
createMockDietaryRestriction,
createMockAppliance,
} from '../tests/utils/mockFactories';
import { mockLogger } from '../tests/utils/mockLogger';
import { createTestApp } from '../tests/utils/createTestApp';
@@ -53,7 +57,10 @@ describe('Personalization Routes (/api/personalization)', () => {
const response = await supertest(app).get('/api/personalization/master-items');
expect(response.status).toBe(500);
expect(response.body.message).toBe('DB Error');
expect(mockLogger.error).toHaveBeenCalledWith({ error: dbError }, 'Error fetching master items in /api/personalization/master-items:');
expect(mockLogger.error).toHaveBeenCalledWith(
{ error: dbError },
'Error fetching master items in /api/personalization/master-items:',
);
});
});
@@ -74,7 +81,10 @@ describe('Personalization Routes (/api/personalization)', () => {
const response = await supertest(app).get('/api/personalization/dietary-restrictions');
expect(response.status).toBe(500);
expect(response.body.message).toBe('DB Error');
expect(mockLogger.error).toHaveBeenCalledWith({ error: dbError }, 'Error fetching dietary restrictions in /api/personalization/dietary-restrictions:');
expect(mockLogger.error).toHaveBeenCalledWith(
{ error: dbError },
'Error fetching dietary restrictions in /api/personalization/dietary-restrictions:',
);
});
});
@@ -95,7 +105,10 @@ describe('Personalization Routes (/api/personalization)', () => {
const response = await supertest(app).get('/api/personalization/appliances');
expect(response.status).toBe(500);
expect(response.body.message).toBe('DB Error');
expect(mockLogger.error).toHaveBeenCalledWith({ error: dbError }, 'Error fetching appliances in /api/personalization/appliances:');
expect(mockLogger.error).toHaveBeenCalledWith(
{ error: dbError },
'Error fetching appliances in /api/personalization/appliances:',
);
});
});
});
});

View File

@@ -14,40 +14,55 @@ const emptySchema = z.object({});
/**
* GET /api/personalization/master-items - Get the master list of all grocery items.
*/
router.get('/master-items', validateRequest(emptySchema), async (req: Request, res: Response, next: NextFunction) => {
router.get(
'/master-items',
validateRequest(emptySchema),
async (req: Request, res: Response, next: NextFunction) => {
try {
const masterItems = await db.personalizationRepo.getAllMasterItems(req.log);
res.json(masterItems);
const masterItems = await db.personalizationRepo.getAllMasterItems(req.log);
res.json(masterItems);
} catch (error) {
req.log.error({ error }, 'Error fetching master items in /api/personalization/master-items:');
next(error);
req.log.error({ error }, 'Error fetching master items in /api/personalization/master-items:');
next(error);
}
});
},
);
/**
* GET /api/personalization/dietary-restrictions - Get the master list of all dietary restrictions.
*/
router.get('/dietary-restrictions', validateRequest(emptySchema), async (req: Request, res: Response, next: NextFunction) => {
router.get(
'/dietary-restrictions',
validateRequest(emptySchema),
async (req: Request, res: Response, next: NextFunction) => {
try {
const restrictions = await db.personalizationRepo.getDietaryRestrictions(req.log);
res.json(restrictions);
const restrictions = await db.personalizationRepo.getDietaryRestrictions(req.log);
res.json(restrictions);
} catch (error) {
req.log.error({ error }, 'Error fetching dietary restrictions in /api/personalization/dietary-restrictions:');
next(error);
req.log.error(
{ error },
'Error fetching dietary restrictions in /api/personalization/dietary-restrictions:',
);
next(error);
}
});
},
);
/**
* GET /api/personalization/appliances - Get the master list of all kitchen appliances.
*/
router.get('/appliances', validateRequest(emptySchema), async (req: Request, res: Response, next: NextFunction) => {
router.get(
'/appliances',
validateRequest(emptySchema),
async (req: Request, res: Response, next: NextFunction) => {
try {
const appliances = await db.personalizationRepo.getAppliances(req.log);
res.json(appliances);
const appliances = await db.personalizationRepo.getAppliances(req.log);
res.json(appliances);
} catch (error) {
req.log.error({ error }, 'Error fetching appliances in /api/personalization/appliances:');
next(error);
req.log.error({ error }, 'Error fetching appliances in /api/personalization/appliances:');
next(error);
}
});
},
);
export default router;
export default router;

View File

@@ -16,15 +16,13 @@ describe('Price Routes (/api/price-history)', () => {
describe('POST /', () => {
it('should return 200 OK with an empty array for a valid request', async () => {
const masterItemIds = [1, 2, 3];
const response = await supertest(app)
.post('/api/price-history')
.send({ masterItemIds });
const response = await supertest(app).post('/api/price-history').send({ masterItemIds });
expect(response.status).toBe(200);
expect(response.body).toEqual([]);
expect(mockLogger.info).toHaveBeenCalledWith(
{ itemCount: masterItemIds.length },
'[API /price-history] Received request for historical price data.'
'[API /price-history] Received request for historical price data.',
);
});
@@ -37,11 +35,11 @@ describe('Price Routes (/api/price-history)', () => {
});
it('should return 400 if masterItemIds is an empty array', async () => {
const response = await supertest(app)
.post('/api/price-history')
.send({ masterItemIds: [] });
const response = await supertest(app).post('/api/price-history').send({ masterItemIds: [] });
expect(response.status).toBe(400);
expect(response.body.errors[0].message).toBe('masterItemIds must be a non-empty array of positive integers.');
expect(response.body.errors[0].message).toBe(
'masterItemIds must be a non-empty array of positive integers.',
);
});
});
});
});

View File

@@ -21,10 +21,15 @@ type PriceHistoryRequest = z.infer<typeof priceHistorySchema>;
* This is a placeholder implementation.
*/
router.post('/', validateRequest(priceHistorySchema), async (req: Request, res: Response) => {
// Cast 'req' to the inferred type for full type safety.
const { body: { masterItemIds } } = req as unknown as PriceHistoryRequest;
req.log.info({ itemCount: masterItemIds.length }, '[API /price-history] Received request for historical price data.');
res.status(200).json([]);
// Cast 'req' to the inferred type for full type safety.
const {
body: { masterItemIds },
} = req as unknown as PriceHistoryRequest;
req.log.info(
{ itemCount: masterItemIds.length },
'[API /price-history] Received request for historical price data.',
);
res.status(200).json([]);
});
export default router;
export default router;

View File

@@ -69,11 +69,16 @@ describe('Recipe Routes (/api/recipes)', () => {
const response = await supertest(app).get('/api/recipes/by-sale-percentage');
expect(response.status).toBe(500);
expect(response.body.message).toBe('DB Error');
expect(mockLogger.error).toHaveBeenCalledWith({ error: dbError }, 'Error fetching recipes in /api/recipes/by-sale-percentage:');
expect(mockLogger.error).toHaveBeenCalledWith(
{ error: dbError },
'Error fetching recipes in /api/recipes/by-sale-percentage:',
);
});
it('should return 400 for an invalid minPercentage', async () => {
const response = await supertest(app).get('/api/recipes/by-sale-percentage?minPercentage=101');
const response = await supertest(app).get(
'/api/recipes/by-sale-percentage?minPercentage=101',
);
expect(response.status).toBe(400);
expect(response.body.errors[0].message).toContain('Too big');
});
@@ -99,11 +104,16 @@ describe('Recipe Routes (/api/recipes)', () => {
const response = await supertest(app).get('/api/recipes/by-sale-ingredients');
expect(response.status).toBe(500);
expect(response.body.message).toBe('DB Error');
expect(mockLogger.error).toHaveBeenCalledWith({ error: dbError }, 'Error fetching recipes in /api/recipes/by-sale-ingredients:');
expect(mockLogger.error).toHaveBeenCalledWith(
{ error: dbError },
'Error fetching recipes in /api/recipes/by-sale-ingredients:',
);
});
it('should return 400 for an invalid minIngredients', async () => {
const response = await supertest(app).get('/api/recipes/by-sale-ingredients?minIngredients=abc');
const response = await supertest(app).get(
'/api/recipes/by-sale-ingredients?minIngredients=abc',
);
expect(response.status).toBe(400);
expect(response.body.errors[0].message).toContain('received NaN');
});
@@ -114,7 +124,9 @@ describe('Recipe Routes (/api/recipes)', () => {
const mockRecipes = [createMockRecipe({ recipe_id: 2, name: 'Chicken Tacos' })];
vi.mocked(db.recipeRepo.findRecipesByIngredientAndTag).mockResolvedValue(mockRecipes);
const response = await supertest(app).get('/api/recipes/by-ingredient-and-tag?ingredient=chicken&tag=quick');
const response = await supertest(app).get(
'/api/recipes/by-ingredient-and-tag?ingredient=chicken&tag=quick',
);
expect(response.status).toBe(200);
expect(response.body).toEqual(mockRecipes);
@@ -123,15 +135,21 @@ describe('Recipe Routes (/api/recipes)', () => {
it('should return 500 if the database call fails', async () => {
const dbError = new Error('DB Error');
vi.mocked(db.recipeRepo.findRecipesByIngredientAndTag).mockRejectedValue(dbError);
const response = await supertest(app)
.get('/api/recipes/by-ingredient-and-tag?ingredient=chicken&tag=quick');
const response = await supertest(app).get(
'/api/recipes/by-ingredient-and-tag?ingredient=chicken&tag=quick',
);
expect(response.status).toBe(500);
expect(response.body.message).toBe('DB Error');
expect(mockLogger.error).toHaveBeenCalledWith({ error: dbError }, 'Error fetching recipes in /api/recipes/by-ingredient-and-tag:');
expect(mockLogger.error).toHaveBeenCalledWith(
{ error: dbError },
'Error fetching recipes in /api/recipes/by-ingredient-and-tag:',
);
});
it('should return 400 if required query parameters are missing', async () => {
const response = await supertest(app).get('/api/recipes/by-ingredient-and-tag?ingredient=chicken');
const response = await supertest(app).get(
'/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.');
});
@@ -161,7 +179,10 @@ describe('Recipe Routes (/api/recipes)', () => {
const response = await supertest(app).get('/api/recipes/1/comments');
expect(response.status).toBe(500);
expect(response.body.message).toBe('DB Error');
expect(mockLogger.error).toHaveBeenCalledWith({ error: dbError }, `Error fetching comments for recipe ID 1:`);
expect(mockLogger.error).toHaveBeenCalledWith(
{ error: dbError },
`Error fetching comments for recipe ID 1:`,
);
});
it('should return 400 for an invalid recipeId', async () => {
@@ -189,7 +210,10 @@ describe('Recipe Routes (/api/recipes)', () => {
const response = await supertest(app).get('/api/recipes/999');
expect(response.status).toBe(404);
expect(response.body.message).toContain('not found');
expect(mockLogger.error).toHaveBeenCalledWith({ error: notFoundError }, `Error fetching recipe ID 999:`);
expect(mockLogger.error).toHaveBeenCalledWith(
{ error: notFoundError },
`Error fetching recipe ID 999:`,
);
});
it('should return 500 if the database call fails', async () => {
@@ -198,7 +222,10 @@ describe('Recipe Routes (/api/recipes)', () => {
const response = await supertest(app).get('/api/recipes/456');
expect(response.status).toBe(500);
expect(response.body.message).toBe('DB Error');
expect(mockLogger.error).toHaveBeenCalledWith({ error: dbError }, `Error fetching recipe ID 456:`);
expect(mockLogger.error).toHaveBeenCalledWith(
{ error: dbError },
`Error fetching recipe ID 456:`,
);
});
it('should return 400 for an invalid recipeId', async () => {
@@ -207,4 +234,4 @@ describe('Recipe Routes (/api/recipes)', () => {
expect(response.body.errors[0].message).toContain('received NaN');
});
});
});
});

View File

@@ -7,7 +7,8 @@ import { validateRequest } from '../middleware/validation.middleware';
const router = Router();
// Helper for consistent required string validation (handles missing/null/empty)
const requiredString = (message: string) => z.preprocess((val) => val ?? '', z.string().min(1, message));
const requiredString = (message: string) =>
z.preprocess((val) => val ?? '', z.string().min(1, message));
// --- Zod Schemas for Recipe Routes (as per ADR-003) ---
@@ -39,75 +40,94 @@ const recipeIdParamsSchema = z.object({
/**
* GET /api/recipes/by-sale-percentage - Get recipes based on the percentage of their ingredients on sale.
*/
router.get('/by-sale-percentage', validateRequest(bySalePercentageSchema), async (req, res, next) => {
router.get(
'/by-sale-percentage',
validateRequest(bySalePercentageSchema),
async (req, res, next) => {
try {
// Explicitly parse req.query to apply coercion (string -> number) and default values
const { query } = bySalePercentageSchema.parse({ query: req.query });
const recipes = await db.recipeRepo.getRecipesBySalePercentage(query.minPercentage, req.log);
res.json(recipes);
// Explicitly parse req.query to apply coercion (string -> number) and default values
const { query } = bySalePercentageSchema.parse({ query: req.query });
const recipes = await db.recipeRepo.getRecipesBySalePercentage(query.minPercentage, req.log);
res.json(recipes);
} catch (error) {
req.log.error({ error }, 'Error fetching recipes in /api/recipes/by-sale-percentage:');
next(error);
req.log.error({ error }, 'Error fetching recipes in /api/recipes/by-sale-percentage:');
next(error);
}
});
},
);
/**
* GET /api/recipes/by-sale-ingredients - Get recipes by the minimum number of sale ingredients.
*/
router.get('/by-sale-ingredients', validateRequest(bySaleIngredientsSchema), async (req, res, next) => {
router.get(
'/by-sale-ingredients',
validateRequest(bySaleIngredientsSchema),
async (req, res, next) => {
try {
// Explicitly parse req.query to apply coercion (string -> number) and default values
const { query } = bySaleIngredientsSchema.parse({ query: req.query });
const recipes = await db.recipeRepo.getRecipesByMinSaleIngredients(query.minIngredients, req.log);
res.json(recipes);
// Explicitly parse req.query to apply coercion (string -> number) and default values
const { query } = bySaleIngredientsSchema.parse({ query: req.query });
const recipes = await db.recipeRepo.getRecipesByMinSaleIngredients(
query.minIngredients,
req.log,
);
res.json(recipes);
} catch (error) {
req.log.error({ error }, 'Error fetching recipes in /api/recipes/by-sale-ingredients:');
next(error);
req.log.error({ error }, 'Error fetching recipes in /api/recipes/by-sale-ingredients:');
next(error);
}
});
},
);
/**
* GET /api/recipes/by-ingredient-and-tag - Find recipes by a specific ingredient and tag.
*/
router.get('/by-ingredient-and-tag', validateRequest(byIngredientAndTagSchema), async (req, res, next) => {
router.get(
'/by-ingredient-and-tag',
validateRequest(byIngredientAndTagSchema),
async (req, res, next) => {
try {
const { query } = byIngredientAndTagSchema.parse({ query: req.query });
const recipes = await db.recipeRepo.findRecipesByIngredientAndTag(query.ingredient, query.tag, req.log);
res.json(recipes);
const { query } = byIngredientAndTagSchema.parse({ query: req.query });
const recipes = await db.recipeRepo.findRecipesByIngredientAndTag(
query.ingredient,
query.tag,
req.log,
);
res.json(recipes);
} catch (error) {
req.log.error({ error }, 'Error fetching recipes in /api/recipes/by-ingredient-and-tag:');
next(error);
req.log.error({ error }, 'Error fetching recipes in /api/recipes/by-ingredient-and-tag:');
next(error);
}
});
},
);
/**
* GET /api/recipes/:recipeId/comments - Get all comments for a specific recipe.
*/
router.get('/:recipeId/comments', validateRequest(recipeIdParamsSchema), async (req, res, next) => {
try {
// Explicitly parse req.params to coerce recipeId to a number
const { params } = recipeIdParamsSchema.parse({ params: req.params });
const comments = await db.recipeRepo.getRecipeComments(params.recipeId, req.log);
res.json(comments);
} catch (error) {
req.log.error({ error }, `Error fetching comments for recipe ID ${req.params.recipeId}:`);
next(error);
}
try {
// Explicitly parse req.params to coerce recipeId to a number
const { params } = recipeIdParamsSchema.parse({ params: req.params });
const comments = await db.recipeRepo.getRecipeComments(params.recipeId, req.log);
res.json(comments);
} catch (error) {
req.log.error({ error }, `Error fetching comments for recipe ID ${req.params.recipeId}:`);
next(error);
}
});
/**
* GET /api/recipes/:recipeId - Get a single recipe by its ID, including ingredients and tags.
*/
router.get('/:recipeId', validateRequest(recipeIdParamsSchema), async (req, res, next) => {
try {
// Explicitly parse req.params to coerce recipeId to a number
const { params } = recipeIdParamsSchema.parse({ params: req.params });
const recipe = await db.recipeRepo.getRecipeById(params.recipeId, req.log);
res.json(recipe);
} catch (error) {
req.log.error({ error }, `Error fetching recipe ID ${req.params.recipeId}:`);
next(error);
}
try {
// Explicitly parse req.params to coerce recipeId to a number
const { params } = recipeIdParamsSchema.parse({ params: req.params });
const recipe = await db.recipeRepo.getRecipeById(params.recipeId, req.log);
res.json(recipe);
} catch (error) {
req.log.error({ error }, `Error fetching recipe ID ${req.params.recipeId}:`);
next(error);
}
});
export default router;
export default router;

View File

@@ -58,7 +58,10 @@ describe('Stats Routes (/api/stats)', () => {
const response = await supertest(app).get('/api/stats/most-frequent-sales');
expect(response.status).toBe(500);
expect(response.body.message).toBe('DB Error');
expect(mockLogger.error).toHaveBeenCalledWith({ error: dbError }, 'Error fetching most frequent sale items in /api/stats/most-frequent-sales:');
expect(mockLogger.error).toHaveBeenCalledWith(
{ error: dbError },
'Error fetching most frequent sale items in /api/stats/most-frequent-sales:',
);
});
it('should return 400 for invalid query parameters', async () => {
@@ -68,4 +71,4 @@ describe('Stats Routes (/api/stats)', () => {
expect(response.body.errors.length).toBe(2);
});
});
});
});

View File

@@ -10,8 +10,8 @@ const router = Router();
// Define the query schema separately so we can use it to parse req.query in the handler
const statsQuerySchema = z.object({
days: z.coerce.number().int().min(1).max(365).optional().default(30),
limit: z.coerce.number().int().min(1).max(50).optional().default(10),
days: z.coerce.number().int().min(1).max(365).optional().default(30),
limit: z.coerce.number().int().min(1).max(50).optional().default(10),
});
const mostFrequentSalesSchema = z.object({
@@ -22,18 +22,25 @@ const mostFrequentSalesSchema = z.object({
* GET /api/stats/most-frequent-sales - Get a list of items that have been on sale most frequently.
* This is a public endpoint for data analysis.
*/
router.get('/most-frequent-sales', validateRequest(mostFrequentSalesSchema), async (req: Request, res: Response, next: NextFunction) => {
router.get(
'/most-frequent-sales',
validateRequest(mostFrequentSalesSchema),
async (req: Request, res: Response, next: NextFunction) => {
try {
// Parse req.query to ensure coercion (string -> number) and defaults are applied.
// Even though validateRequest checks validity, it may not mutate req.query with the parsed result.
const { days, limit } = statsQuerySchema.parse(req.query);
const items = await db.adminRepo.getMostFrequentSaleItems(days, limit, req.log);
res.json(items);
} catch (error) {
req.log.error({ error }, 'Error fetching most frequent sale items in /api/stats/most-frequent-sales:');
next(error);
}
});
// Parse req.query to ensure coercion (string -> number) and defaults are applied.
// Even though validateRequest checks validity, it may not mutate req.query with the parsed result.
const { days, limit } = statsQuerySchema.parse(req.query);
export default router;
const items = await db.adminRepo.getMostFrequentSaleItems(days, limit, req.log);
res.json(items);
} catch (error) {
req.log.error(
{ error },
'Error fetching most frequent sale items in /api/stats/most-frequent-sales:',
);
next(error);
}
},
);
export default router;

View File

@@ -17,7 +17,7 @@ vi.mock('child_process', () => {
return {
default: { exec: mockExec },
exec: mockExec
exec: mockExec,
};
});
@@ -57,47 +57,63 @@ describe('System Routes (/api/system)', () => {
│ status │ online │
└───────────┴───────────┘
`;
type ExecCallback = (error: ExecException | null, stdout: string, stderr: string) => void;
// A robust mock for `exec` that handles its multiple overloads.
// This avoids the complex and error-prone `...args` signature.
vi.mocked(exec).mockImplementation((
command: string,
options?: ExecOptions | ExecCallback | null,
callback?: ExecCallback | null
) => {
// The actual callback can be the second or third argument.
const actualCallback = (typeof options === 'function' ? options : callback) as ExecCallback;
if (actualCallback) {
actualCallback(null, pm2OnlineOutput, '');
}
// Return a minimal object that satisfies the ChildProcess type for .unref()
return { unref: () => {} } as ReturnType<typeof exec>;
});
vi.mocked(exec).mockImplementation(
(
command: string,
options?: ExecOptions | ExecCallback | null,
callback?: ExecCallback | null,
) => {
// The actual callback can be the second or third argument.
const actualCallback = (
typeof options === 'function' ? options : callback
) as ExecCallback;
if (actualCallback) {
actualCallback(null, pm2OnlineOutput, '');
}
// Return a minimal object that satisfies the ChildProcess type for .unref()
return { unref: () => {} } as ReturnType<typeof exec>;
},
);
// Act
const response = await supertest(app).get('/api/system/pm2-status');
// Assert
expect(response.status).toBe(200);
expect(response.body).toEqual({ success: true, message: 'Application is online and running under PM2.' });
expect(response.body).toEqual({
success: true,
message: 'Application is online and running under PM2.',
});
});
it('should return success: false when pm2 process is stopped or errored', async () => {
const pm2StoppedOutput = `│ status │ stopped │`;
vi.mocked(exec).mockImplementation((
command: string,
options?: ExecOptions | ((error: ExecException | null, stdout: string, stderr: string) => void) | null,
callback?: ((error: ExecException | null, stdout: string, stderr: string) => void) | null
) => {
const actualCallback = (typeof options === 'function' ? options : callback) as ((error: ExecException | null, stdout: string, stderr: string) => void);
if (actualCallback) {
actualCallback(null, pm2StoppedOutput, '');
}
return { unref: () => {} } as ReturnType<typeof exec>;
});
vi.mocked(exec).mockImplementation(
(
command: string,
options?:
| ExecOptions
| ((error: ExecException | null, stdout: string, stderr: string) => void)
| null,
callback?: ((error: ExecException | null, stdout: string, stderr: string) => void) | null,
) => {
const actualCallback = (typeof options === 'function' ? options : callback) as (
error: ExecException | null,
stdout: string,
stderr: string,
) => void;
if (actualCallback) {
actualCallback(null, pm2StoppedOutput, '');
}
return { unref: () => {} } as ReturnType<typeof exec>;
},
);
const response = await supertest(app).get('/api/system/pm2-status');
@@ -108,45 +124,69 @@ describe('System Routes (/api/system)', () => {
it('should return success: false when pm2 process does not exist', async () => {
// Arrange: Simulate `pm2 describe` failing because the process isn't found.
const processNotFoundOutput = "[PM2][ERROR] Process or Namespace flyer-crawler-api doesn't exist";
const processNotFoundError = new Error('Command failed: pm2 describe flyer-crawler-api') as ExecException;
const processNotFoundOutput =
"[PM2][ERROR] Process or Namespace flyer-crawler-api doesn't exist";
const processNotFoundError = new Error(
'Command failed: pm2 describe flyer-crawler-api',
) as ExecException;
processNotFoundError.code = 1;
vi.mocked(exec).mockImplementation((
command: string,
options?: ExecOptions | ((error: ExecException | null, stdout: string, stderr: string) => void) | null,
callback?: ((error: ExecException | null, stdout: string, stderr: string) => void) | null
) => {
const actualCallback = (typeof options === 'function' ? options : callback) as ((error: ExecException | null, stdout: string, stderr: string) => void);
if (actualCallback) {
actualCallback(processNotFoundError, processNotFoundOutput, '');
}
return { unref: () => {} } as ReturnType<typeof exec>;
});
vi.mocked(exec).mockImplementation(
(
command: string,
options?:
| ExecOptions
| ((error: ExecException | null, stdout: string, stderr: string) => void)
| null,
callback?: ((error: ExecException | null, stdout: string, stderr: string) => void) | null,
) => {
const actualCallback = (typeof options === 'function' ? options : callback) as (
error: ExecException | null,
stdout: string,
stderr: string,
) => void;
if (actualCallback) {
actualCallback(processNotFoundError, processNotFoundOutput, '');
}
return { unref: () => {} } as ReturnType<typeof exec>;
},
);
// Act
const response = await supertest(app).get('/api/system/pm2-status');
// Assert
expect(response.status).toBe(200);
expect(response.body).toEqual({ success: false, message: 'Application process is not running under PM2.' });
expect(response.body).toEqual({
success: false,
message: 'Application process is not running under PM2.',
});
});
it('should return 500 if pm2 command produces stderr output', async () => {
// Arrange: Simulate a successful exit code but with content in stderr.
const stderrOutput = 'A non-fatal warning occurred.';
vi.mocked(exec).mockImplementation((
command: string,
options?: ExecOptions | ((error: ExecException | null, stdout: string, stderr: string) => void) | null,
callback?: ((error: ExecException | null, stdout: string, stderr: string) => void) | null
) => {
const actualCallback = (typeof options === 'function' ? options : callback) as ((error: ExecException | null, stdout: string, stderr: string) => void);
if (actualCallback) {
actualCallback(null, 'Some stdout', stderrOutput);
}
return { unref: () => {} } as ReturnType<typeof exec>;
});
vi.mocked(exec).mockImplementation(
(
command: string,
options?:
| ExecOptions
| ((error: ExecException | null, stdout: string, stderr: string) => void)
| null,
callback?: ((error: ExecException | null, stdout: string, stderr: string) => void) | null,
) => {
const actualCallback = (typeof options === 'function' ? options : callback) as (
error: ExecException | null,
stdout: string,
stderr: string,
) => void;
if (actualCallback) {
actualCallback(null, 'Some stdout', stderrOutput);
}
return { unref: () => {} } as ReturnType<typeof exec>;
},
);
const response = await supertest(app).get('/api/system/pm2-status');
expect(response.status).toBe(500);
@@ -154,17 +194,26 @@ describe('System Routes (/api/system)', () => {
});
it('should return 500 on a generic exec error', async () => {
vi.mocked(exec).mockImplementation((
command: string,
options?: ExecOptions | ((error: ExecException | null, stdout: string, stderr: string) => void) | null,
callback?: ((error: ExecException | null, stdout: string, stderr: string) => void) | null
) => {
const actualCallback = (typeof options === 'function' ? options : callback) as ((error: ExecException | null, stdout: string, stderr: string) => void);
if (actualCallback) {
actualCallback(new Error('System error') as ExecException, '', 'stderr output');
}
return { unref: () => {} } as ReturnType<typeof exec>;
});
vi.mocked(exec).mockImplementation(
(
command: string,
options?:
| ExecOptions
| ((error: ExecException | null, stdout: string, stderr: string) => void)
| null,
callback?: ((error: ExecException | null, stdout: string, stderr: string) => void) | null,
) => {
const actualCallback = (typeof options === 'function' ? options : callback) as (
error: ExecException | null,
stdout: string,
stderr: string,
) => void;
if (actualCallback) {
actualCallback(new Error('System error') as ExecException, '', 'stderr output');
}
return { unref: () => {} } as ReturnType<typeof exec>;
},
);
// Act
const response = await supertest(app).get('/api/system/pm2-status');
@@ -203,7 +252,9 @@ describe('System Routes (/api/system)', () => {
it('should return 500 if the geocoding service throws an error', async () => {
const geocodeError = new Error('Geocoding service unavailable');
vi.mocked(geocodingService.geocodeAddress).mockRejectedValue(geocodeError);
const response = await supertest(app).post('/api/system/geocode').send({ address: 'Any Address' });
const response = await supertest(app)
.post('/api/system/geocode')
.send({ address: 'Any Address' });
expect(response.status).toBe(500);
});
@@ -216,4 +267,4 @@ describe('System Routes (/api/system)', () => {
expect(response.body.errors[0].message).toMatch(/An address string is required|Required/i);
});
});
});
});

View File

@@ -9,7 +9,8 @@ import { validateRequest } from '../middleware/validation.middleware';
const router = Router();
// Helper for consistent required string validation (handles missing/null/empty)
const requiredString = (message: string) => z.preprocess((val) => val ?? '', z.string().min(1, message));
const requiredString = (message: string) =>
z.preprocess((val) => val ?? '', z.string().min(1, message));
const geocodeSchema = z.object({
body: z.object({
@@ -24,55 +25,73 @@ const emptySchema = z.object({});
* Checks the status of the 'flyer-crawler-api' process managed by PM2.
* This is intended for development and diagnostic purposes.
*/
router.get('/pm2-status', validateRequest(emptySchema), (req: Request, res: Response, next: NextFunction) => {
router.get(
'/pm2-status',
validateRequest(emptySchema),
(req: Request, res: Response, next: NextFunction) => {
// The name 'flyer-crawler-api' comes from your ecosystem.config.cjs file.
exec('pm2 describe flyer-crawler-api', (error, stdout, stderr) => {
if (error) {
// 'pm2 describe' exits with an error if the process is not found.
// We can treat this as a "fail" status for our check.
if (stdout && stdout.includes("doesn't exist")) {
logger.warn('[API /pm2-status] PM2 process "flyer-crawler-api" not found.');
return res.json({ success: false, message: 'Application process is not running under PM2.' });
}
logger.error({ error: stderr || error.message }, '[API /pm2-status] Error executing pm2 describe:');
return next(error);
if (error) {
// 'pm2 describe' exits with an error if the process is not found.
// We can treat this as a "fail" status for our check.
if (stdout && stdout.includes("doesn't exist")) {
logger.warn('[API /pm2-status] PM2 process "flyer-crawler-api" not found.');
return res.json({
success: false,
message: 'Application process is not running under PM2.',
});
}
logger.error(
{ error: stderr || error.message },
'[API /pm2-status] Error executing pm2 describe:',
);
return next(error);
}
// Check if there was output to stderr, even if the exit code was 0 (success).
// This handles warnings or non-fatal errors that should arguably be treated as failures in this context.
if (stderr && stderr.trim().length > 0) {
logger.error({ stderr }, '[API /pm2-status] PM2 executed but produced stderr:');
return next(new Error(`PM2 command produced an error: ${stderr}`));
}
// Check if there was output to stderr, even if the exit code was 0 (success).
// This handles warnings or non-fatal errors that should arguably be treated as failures in this context.
if (stderr && stderr.trim().length > 0) {
logger.error({ stderr }, '[API /pm2-status] PM2 executed but produced stderr:');
return next(new Error(`PM2 command produced an error: ${stderr}`));
}
// If the command succeeds, we can parse stdout to check the status.
const isOnline = /│ status\s+│ online\s+│/m.test(stdout);
const message = isOnline ? 'Application is online and running under PM2.' : 'Application process exists but is not online.';
res.json({ success: isOnline, message });
// If the command succeeds, we can parse stdout to check the status.
const isOnline = /│ status\s+│ online\s+│/m.test(stdout);
const message = isOnline
? 'Application is online and running under PM2.'
: 'Application process exists but is not online.';
res.json({ success: isOnline, message });
});
});
},
);
/**
* POST /api/system/geocode - Geocodes a given address string.
* This acts as a secure proxy to the Google Maps Geocoding API.
*/
router.post('/geocode', validateRequest(geocodeSchema), async (req: Request, res: Response, next: NextFunction) => {
router.post(
'/geocode',
validateRequest(geocodeSchema),
async (req: Request, res: Response, next: NextFunction) => {
// Infer type and cast request object as per ADR-003
type GeocodeRequest = z.infer<typeof geocodeSchema>;
const { body: { address } } = req as unknown as GeocodeRequest;
const {
body: { address },
} = req as unknown as GeocodeRequest;
try {
const coordinates = await geocodingService.geocodeAddress(address, req.log);
const coordinates = await geocodingService.geocodeAddress(address, req.log);
if (!coordinates) { // This check remains, but now it only fails if BOTH services fail.
return res.status(404).json({ message: 'Could not geocode the provided address.' });
}
if (!coordinates) {
// This check remains, but now it only fails if BOTH services fail.
return res.status(404).json({ message: 'Could not geocode the provided address.' });
}
res.json(coordinates);
res.json(coordinates);
} catch (error) {
next(error);
next(error);
}
});
},
);
export default router;
export default router;

View File

@@ -3,7 +3,18 @@ import { describe, it, expect, vi, beforeEach } from 'vitest';
import supertest from 'supertest';
import express from 'express';
import * as bcrypt from 'bcrypt';
import { createMockUserProfile, createMockMasterGroceryItem, createMockShoppingList, createMockShoppingListItem, createMockRecipe, createMockNotification, createMockDietaryRestriction, createMockAppliance, createMockUserWithPasswordHash, createMockAddress } from '../tests/utils/mockFactories';
import {
createMockUserProfile,
createMockMasterGroceryItem,
createMockShoppingList,
createMockShoppingListItem,
createMockRecipe,
createMockNotification,
createMockDietaryRestriction,
createMockAppliance,
createMockUserWithPasswordHash,
createMockAddress,
} from '../tests/utils/mockFactories';
import { Appliance, Notification, DietaryRestriction } from '../types';
import { ForeignKeyConstraintError, NotFoundError } from '../services/db/errors.db';
import { createTestApp } from '../tests/utils/createTestApp';
@@ -88,18 +99,24 @@ import * as db from '../services/db/index.db';
// Mock Passport middleware
vi.mock('./passport.routes', () => ({
default: {
authenticate: vi.fn(() => (req: express.Request, res: express.Response, next: express.NextFunction) => {
// If we are testing the unauthenticated state (no user injected), simulate 401.
// If a user WAS injected by our test helper, proceed.
if (!req.user) {
return res.status(401).json({ message: 'Unauthorized' });
}
next();
}),
authenticate: vi.fn(
() => (req: express.Request, res: express.Response, next: express.NextFunction) => {
// If we are testing the unauthenticated state (no user injected), simulate 401.
// If a user WAS injected by our test helper, proceed.
if (!req.user) {
return res.status(401).json({ message: 'Unauthorized' });
}
next();
},
),
},
// We also need to provide mocks for any other named exports from passport.routes.ts
isAdmin: vi.fn((req: express.Request, res: express.Response, next: express.NextFunction) => next()),
optionalAuth: vi.fn((req: express.Request, res: express.Response, next: express.NextFunction) => next()),
isAdmin: vi.fn((req: express.Request, res: express.Response, next: express.NextFunction) =>
next(),
),
optionalAuth: vi.fn((req: express.Request, res: express.Response, next: express.NextFunction) =>
next(),
),
}));
// Define a reusable matcher for the logger object.
@@ -129,7 +146,10 @@ describe('User Routes (/api/users)', () => {
await import('./user.routes');
// Assert
expect(logger.error).toHaveBeenCalledWith('Failed to create avatar upload directory:', mkdirError);
expect(logger.error).toHaveBeenCalledWith(
'Failed to create avatar upload directory:',
mkdirError,
);
vi.doUnmock('node:fs/promises'); // Clean up
});
});
@@ -148,7 +168,9 @@ describe('User Routes (/api/users)', () => {
});
describe('when user is authenticated', () => {
const mockUserProfile = createMockUserProfile({ user: { user_id: 'user-123', email: 'test@test.com' } });
const mockUserProfile = createMockUserProfile({
user: { user_id: 'user-123', email: 'test@test.com' },
});
const app = createTestApp({ router: userRouter, basePath, authenticatedUser: mockUserProfile });
// Add a basic error handler to capture errors passed to next(err) and return JSON.
@@ -166,11 +188,16 @@ describe('User Routes (/api/users)', () => {
const response = await supertest(app).get('/api/users/profile');
expect(response.status).toBe(200);
expect(response.body).toEqual(mockUserProfile);
expect(db.userRepo.findUserProfileById).toHaveBeenCalledWith(mockUserProfile.user.user_id, expectLogger);
expect(db.userRepo.findUserProfileById).toHaveBeenCalledWith(
mockUserProfile.user.user_id,
expectLogger,
);
});
it('should return 404 if profile is not found in DB', async () => {
vi.mocked(db.userRepo.findUserProfileById).mockRejectedValue(new NotFoundError('Profile not found for this user.'));
vi.mocked(db.userRepo.findUserProfileById).mockRejectedValue(
new NotFoundError('Profile not found for this user.'),
);
const response = await supertest(app).get('/api/users/profile');
expect(response.status).toBe(404);
expect(response.body.message).toContain('Profile not found');
@@ -181,13 +208,18 @@ describe('User Routes (/api/users)', () => {
vi.mocked(db.userRepo.findUserProfileById).mockRejectedValue(dbError);
const response = await supertest(app).get('/api/users/profile');
expect(response.status).toBe(500);
expect(logger.error).toHaveBeenCalledWith({ error: dbError }, `[ROUTE] GET /api/users/profile - ERROR`);
expect(logger.error).toHaveBeenCalledWith(
{ error: dbError },
`[ROUTE] GET /api/users/profile - ERROR`,
);
});
});
describe('GET /watched-items', () => {
it('should return a list of watched items', async () => {
const mockItems = [createMockMasterGroceryItem({ master_grocery_item_id: 1, name: 'Milk' })];
const mockItems = [
createMockMasterGroceryItem({ master_grocery_item_id: 1, name: 'Milk' }),
];
vi.mocked(db.personalizationRepo.getWatchedItems).mockResolvedValue(mockItems);
const response = await supertest(app).get('/api/users/watched-items');
expect(response.status).toBe(200);
@@ -199,18 +231,23 @@ describe('User Routes (/api/users)', () => {
vi.mocked(db.personalizationRepo.getWatchedItems).mockRejectedValue(dbError);
const response = await supertest(app).get('/api/users/watched-items');
expect(response.status).toBe(500);
expect(logger.error).toHaveBeenCalledWith({ error: dbError }, `[ROUTE] GET /api/users/watched-items - ERROR`);
expect(logger.error).toHaveBeenCalledWith(
{ error: dbError },
`[ROUTE] GET /api/users/watched-items - ERROR`,
);
});
});
describe('POST /watched-items', () => {
it('should add an item to the watchlist and return the new item', async () => {
const newItem = { itemName: 'Organic Bananas', category: 'Produce' };
const mockAddedItem = createMockMasterGroceryItem({ master_grocery_item_id: 99, name: 'Organic Bananas', category_name: 'Produce' });
const mockAddedItem = createMockMasterGroceryItem({
master_grocery_item_id: 99,
name: 'Organic Bananas',
category_name: 'Produce',
});
vi.mocked(db.personalizationRepo.addWatchedItem).mockResolvedValue(mockAddedItem);
const response = await supertest(app)
.post('/api/users/watched-items')
.send(newItem);
const response = await supertest(app).post('/api/users/watched-items').send(newItem);
expect(response.status).toBe(201);
expect(response.body).toEqual(mockAddedItem);
});
@@ -247,7 +284,9 @@ describe('User Routes (/api/users)', () => {
});
it('should return 400 if a foreign key constraint fails', async () => {
vi.mocked(db.personalizationRepo.addWatchedItem).mockRejectedValue(new ForeignKeyConstraintError('Category not found'));
vi.mocked(db.personalizationRepo.addWatchedItem).mockRejectedValue(
new ForeignKeyConstraintError('Category not found'),
);
const response = await supertest(app)
.post('/api/users/watched-items')
.send({ itemName: 'Test', category: 'Invalid' });
@@ -259,7 +298,11 @@ describe('User Routes (/api/users)', () => {
vi.mocked(db.personalizationRepo.removeWatchedItem).mockResolvedValue(undefined);
const response = await supertest(app).delete(`/api/users/watched-items/99`);
expect(response.status).toBe(204);
expect(db.personalizationRepo.removeWatchedItem).toHaveBeenCalledWith(mockUserProfile.user.user_id, 99, expectLogger);
expect(db.personalizationRepo.removeWatchedItem).toHaveBeenCalledWith(
mockUserProfile.user.user_id,
99,
expectLogger,
);
});
it('should return 500 on a generic database error', async () => {
@@ -267,13 +310,18 @@ describe('User Routes (/api/users)', () => {
vi.mocked(db.personalizationRepo.removeWatchedItem).mockRejectedValue(dbError);
const response = await supertest(app).delete(`/api/users/watched-items/99`);
expect(response.status).toBe(500);
expect(logger.error).toHaveBeenCalledWith({ error: dbError }, `[ROUTE] DELETE /api/users/watched-items/:masterItemId - ERROR`);
expect(logger.error).toHaveBeenCalledWith(
{ error: dbError },
`[ROUTE] DELETE /api/users/watched-items/:masterItemId - ERROR`,
);
});
});
describe('Shopping List Routes', () => {
it('GET /shopping-lists should return all shopping lists for the user', async () => {
const mockLists = [createMockShoppingList({ shopping_list_id: 1, user_id: mockUserProfile.user.user_id })];
const mockLists = [
createMockShoppingList({ shopping_list_id: 1, user_id: mockUserProfile.user.user_id }),
];
vi.mocked(db.shoppingRepo.getShoppingLists).mockResolvedValue(mockLists);
const response = await supertest(app).get('/api/users/shopping-lists');
expect(response.status).toBe(200);
@@ -285,11 +333,18 @@ describe('User Routes (/api/users)', () => {
vi.mocked(db.shoppingRepo.getShoppingLists).mockRejectedValue(dbError);
const response = await supertest(app).get('/api/users/shopping-lists');
expect(response.status).toBe(500);
expect(logger.error).toHaveBeenCalledWith({ error: dbError }, `[ROUTE] GET /api/users/shopping-lists - ERROR`);
expect(logger.error).toHaveBeenCalledWith(
{ error: dbError },
`[ROUTE] GET /api/users/shopping-lists - ERROR`,
);
});
it('POST /shopping-lists should create a new list', async () => {
const mockNewList = createMockShoppingList({ shopping_list_id: 2, user_id: mockUserProfile.user.user_id, name: 'Party Supplies' });
const mockNewList = createMockShoppingList({
shopping_list_id: 2,
user_id: mockUserProfile.user.user_id,
name: 'Party Supplies',
});
vi.mocked(db.shoppingRepo.createShoppingList).mockResolvedValue(mockNewList);
const response = await supertest(app)
.post('/api/users/shopping-lists')
@@ -307,8 +362,12 @@ describe('User Routes (/api/users)', () => {
});
it('should return 400 on foreign key constraint error', async () => {
vi.mocked(db.shoppingRepo.createShoppingList).mockRejectedValue(new ForeignKeyConstraintError('User not found'));
const response = await supertest(app).post('/api/users/shopping-lists').send({ name: 'Failing List' });
vi.mocked(db.shoppingRepo.createShoppingList).mockRejectedValue(
new ForeignKeyConstraintError('User not found'),
);
const response = await supertest(app)
.post('/api/users/shopping-lists')
.send({ name: 'Failing List' });
expect(response.status).toBe(400);
expect(response.body.message).toBe('User not found');
});
@@ -316,7 +375,9 @@ describe('User Routes (/api/users)', () => {
it('should return 500 on a generic database error during creation', async () => {
const dbError = new Error('DB Connection Failed');
vi.mocked(db.shoppingRepo.createShoppingList).mockRejectedValue(dbError);
const response = await supertest(app).post('/api/users/shopping-lists').send({ name: 'Failing List' });
const response = await supertest(app)
.post('/api/users/shopping-lists')
.send({ name: 'Failing List' });
expect(response.status).toBe(500);
expect(response.body.message).toBe('DB Connection Failed');
expect(logger.error).toHaveBeenCalled();
@@ -326,7 +387,7 @@ describe('User Routes (/api/users)', () => {
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");
expect(response.body.errors[0].message).toContain('received NaN');
});
describe('DELETE /shopping-lists/:listId', () => {
@@ -338,7 +399,9 @@ describe('User Routes (/api/users)', () => {
it('should return 404 if list to delete is not found', async () => {
vi.mocked(db.shoppingRepo.deleteShoppingList).mockRejectedValue(new Error('not found'));
vi.mocked(db.shoppingRepo.deleteShoppingList).mockRejectedValue(new NotFoundError('not found'));
vi.mocked(db.shoppingRepo.deleteShoppingList).mockRejectedValue(
new NotFoundError('not found'),
);
const response = await supertest(app).delete('/api/users/shopping-lists/999');
expect(response.status).toBe(404);
});
@@ -351,25 +414,27 @@ describe('User Routes (/api/users)', () => {
expect(logger.error).toHaveBeenCalled();
});
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");
});
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');
});
});
});
describe('Shopping List Item Routes', () => {
describe('POST /shopping-lists/:listId/items (Validation)', () => {
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({});
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('Either masterItemId or customItemName must be provided.');
expect(response.body.errors[0].message).toBe(
'Either masterItemId or customItemName must be provided.',
);
});
it('should succeed if only masterItemId is provided', async () => {
vi.mocked(db.shoppingRepo.addShoppingListItem).mockResolvedValue(createMockShoppingListItem({}));
vi.mocked(db.shoppingRepo.addShoppingListItem).mockResolvedValue(
createMockShoppingListItem({}),
);
const response = await supertest(app)
.post('/api/users/shopping-lists/1/items')
.send({ masterItemId: 123 });
@@ -377,7 +442,9 @@ describe('User Routes (/api/users)', () => {
});
it('should succeed if only customItemName is provided', async () => {
vi.mocked(db.shoppingRepo.addShoppingListItem).mockResolvedValue(createMockShoppingListItem({}));
vi.mocked(db.shoppingRepo.addShoppingListItem).mockResolvedValue(
createMockShoppingListItem({}),
);
const response = await supertest(app)
.post('/api/users/shopping-lists/1/items')
.send({ customItemName: 'Custom Item' });
@@ -385,7 +452,9 @@ describe('User Routes (/api/users)', () => {
});
it('should succeed if both masterItemId and customItemName are provided', async () => {
vi.mocked(db.shoppingRepo.addShoppingListItem).mockResolvedValue(createMockShoppingListItem({}));
vi.mocked(db.shoppingRepo.addShoppingListItem).mockResolvedValue(
createMockShoppingListItem({}),
);
const response = await supertest(app)
.post('/api/users/shopping-lists/1/items')
.send({ masterItemId: 123, customItemName: 'Custom Item' });
@@ -395,7 +464,11 @@ describe('User Routes (/api/users)', () => {
it('POST /shopping-lists/:listId/items should add an item to a list', async () => {
const listId = 1;
const itemData = { customItemName: 'Paper Towels' };
const mockAddedItem = createMockShoppingListItem({ shopping_list_item_id: 101, shopping_list_id: listId, ...itemData });
const mockAddedItem = createMockShoppingListItem({
shopping_list_item_id: 101,
shopping_list_id: listId,
...itemData,
});
vi.mocked(db.shoppingRepo.addShoppingListItem).mockResolvedValue(mockAddedItem);
const response = await supertest(app)
.post(`/api/users/shopping-lists/${listId}/items`)
@@ -406,8 +479,12 @@ describe('User Routes (/api/users)', () => {
});
it('should return 400 on foreign key error when adding an item', async () => {
vi.mocked(db.shoppingRepo.addShoppingListItem).mockRejectedValue(new ForeignKeyConstraintError('List not found'));
const response = await supertest(app).post('/api/users/shopping-lists/999/items').send({ customItemName: 'Test' });
vi.mocked(db.shoppingRepo.addShoppingListItem).mockRejectedValue(
new ForeignKeyConstraintError('List not found'),
);
const response = await supertest(app)
.post('/api/users/shopping-lists/999/items')
.send({ customItemName: 'Test' });
expect(response.status).toBe(400);
});
@@ -424,7 +501,11 @@ describe('User Routes (/api/users)', () => {
it('PUT /shopping-lists/items/:itemId should update an item', async () => {
const itemId = 101;
const updates = { is_purchased: true, quantity: 2 };
const mockUpdatedItem = createMockShoppingListItem({ shopping_list_item_id: itemId, shopping_list_id: 1, ...updates });
const mockUpdatedItem = createMockShoppingListItem({
shopping_list_item_id: itemId,
shopping_list_id: 1,
...updates,
});
vi.mocked(db.shoppingRepo.updateShoppingListItem).mockResolvedValue(mockUpdatedItem);
const response = await supertest(app)
.put(`/api/users/shopping-lists/items/${itemId}`)
@@ -435,8 +516,12 @@ describe('User Routes (/api/users)', () => {
});
it('should return 404 if item to update is not found', async () => {
vi.mocked(db.shoppingRepo.updateShoppingListItem).mockRejectedValue(new NotFoundError('not found'));
const response = await supertest(app).put('/api/users/shopping-lists/items/999').send({ is_purchased: true });
vi.mocked(db.shoppingRepo.updateShoppingListItem).mockRejectedValue(
new NotFoundError('not found'),
);
const response = await supertest(app)
.put('/api/users/shopping-lists/items/999')
.send({ is_purchased: true });
expect(response.status).toBe(404);
});
@@ -451,11 +536,11 @@ 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({});
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('At least one field (quantity, is_purchased) must be provided.');
expect(response.body.errors[0].message).toContain(
'At least one field (quantity, is_purchased) must be provided.',
);
});
describe('DELETE /shopping-lists/items/:itemId', () => {
@@ -466,7 +551,9 @@ describe('User Routes (/api/users)', () => {
});
it('should return 404 if item to delete is not found', async () => {
vi.mocked(db.shoppingRepo.removeShoppingListItem).mockRejectedValue(new NotFoundError('not found'));
vi.mocked(db.shoppingRepo.removeShoppingListItem).mockRejectedValue(
new NotFoundError('not found'),
);
const response = await supertest(app).delete('/api/users/shopping-lists/items/999');
expect(response.status).toBe(404);
});
@@ -486,9 +573,7 @@ describe('User Routes (/api/users)', () => {
const profileUpdates = { full_name: 'New Name' };
const updatedProfile = createMockUserProfile({ ...mockUserProfile, ...profileUpdates });
vi.mocked(db.userRepo.updateUserProfile).mockResolvedValue(updatedProfile);
const response = await supertest(app)
.put('/api/users/profile')
.send(profileUpdates);
const response = await supertest(app).put('/api/users/profile').send(profileUpdates);
expect(response.status).toBe(200);
expect(response.body).toEqual(updatedProfile);
@@ -501,15 +586,18 @@ describe('User Routes (/api/users)', () => {
.put('/api/users/profile')
.send({ full_name: 'New Name' });
expect(response.status).toBe(500);
expect(logger.error).toHaveBeenCalledWith({ error: dbError }, `[ROUTE] PUT /api/users/profile - ERROR`);
expect(logger.error).toHaveBeenCalledWith(
{ error: dbError },
`[ROUTE] PUT /api/users/profile - ERROR`,
);
});
it('should return 400 if the body is empty', async () => {
const response = await supertest(app)
.put('/api/users/profile')
.send({});
const response = await supertest(app).put('/api/users/profile').send({});
expect(response.status).toBe(400);
expect(response.body.errors[0].message).toBe('At least one field to update must be provided.');
expect(response.body.errors[0].message).toBe(
'At least one field to update must be provided.',
);
});
});
@@ -533,7 +621,10 @@ describe('User Routes (/api/users)', () => {
.put('/api/users/profile/password')
.send({ newPassword: 'a-Very-Strong-Password-456!' });
expect(response.status).toBe(500);
expect(logger.error).toHaveBeenCalledWith({ error: dbError }, `[ROUTE] PUT /api/users/profile/password - ERROR`);
expect(logger.error).toHaveBeenCalledWith(
{ error: dbError },
`[ROUTE] PUT /api/users/profile/password - ERROR`,
);
});
it('should return 400 for a weak password', async () => {
@@ -549,7 +640,10 @@ describe('User Routes (/api/users)', () => {
describe('DELETE /account', () => {
it('should delete the account with the correct password', async () => {
const userWithHash = createMockUserWithPasswordHash({ ...mockUserProfile.user, password_hash: 'hashed-password' });
const userWithHash = createMockUserWithPasswordHash({
...mockUserProfile.user,
password_hash: 'hashed-password',
});
vi.mocked(db.userRepo.findUserWithPasswordHashById).mockResolvedValue(userWithHash);
vi.mocked(db.userRepo.deleteUserById).mockResolvedValue(undefined);
vi.mocked(bcrypt.compare).mockResolvedValue(true as never);
@@ -561,7 +655,10 @@ describe('User Routes (/api/users)', () => {
});
it('should return 403 for an incorrect password', async () => {
const userWithHash = createMockUserWithPasswordHash({ ...mockUserProfile.user, password_hash: 'hashed-password' });
const userWithHash = createMockUserWithPasswordHash({
...mockUserProfile.user,
password_hash: 'hashed-password',
});
vi.mocked(db.userRepo.findUserWithPasswordHashById).mockResolvedValue(userWithHash);
vi.mocked(bcrypt.compare).mockResolvedValue(false as never);
const response = await supertest(app)
@@ -573,7 +670,9 @@ describe('User Routes (/api/users)', () => {
});
it('should return 404 if the user to delete is not found', async () => {
vi.mocked(db.userRepo.findUserWithPasswordHashById).mockRejectedValue(new NotFoundError('User not found or password not set.'));
vi.mocked(db.userRepo.findUserWithPasswordHashById).mockRejectedValue(
new NotFoundError('User not found or password not set.'),
);
const response = await supertest(app)
.delete('/api/users/account')
.send({ password: 'any-password' });
@@ -583,7 +682,10 @@ describe('User Routes (/api/users)', () => {
it('should return 404 if user is an OAuth user without a password', async () => {
// Simulate an OAuth user who has no password_hash set.
const userWithoutHash = createMockUserWithPasswordHash({ ...mockUserProfile.user, password_hash: null });
const userWithoutHash = createMockUserWithPasswordHash({
...mockUserProfile.user,
password_hash: null,
});
vi.mocked(db.userRepo.findUserWithPasswordHashById).mockResolvedValue(userWithoutHash);
const response = await supertest(app)
@@ -594,9 +696,11 @@ describe('User Routes (/api/users)', () => {
expect(response.body.message).toBe('User not found or password not set.');
});
it('should return 500 on a generic database error', async () => {
const userWithHash = createMockUserWithPasswordHash({ ...mockUserProfile.user, password_hash: 'hashed-password' });
const userWithHash = createMockUserWithPasswordHash({
...mockUserProfile.user,
password_hash: 'hashed-password',
});
vi.mocked(db.userRepo.findUserWithPasswordHashById).mockResolvedValue(userWithHash);
vi.mocked(bcrypt.compare).mockResolvedValue(true as never);
vi.mocked(db.userRepo.deleteUserById).mockRejectedValue(new Error('DB Connection Failed'));
@@ -604,7 +708,10 @@ describe('User Routes (/api/users)', () => {
.delete('/api/users/account')
.send({ password: 'correct-password' });
expect(response.status).toBe(500);
expect(logger.error).toHaveBeenCalledWith({ error: new Error('DB Connection Failed') }, `[ROUTE] DELETE /api/users/account - ERROR`);
expect(logger.error).toHaveBeenCalledWith(
{ error: new Error('DB Connection Failed') },
`[ROUTE] DELETE /api/users/account - ERROR`,
);
});
});
@@ -631,7 +738,10 @@ describe('User Routes (/api/users)', () => {
.put('/api/users/profile/preferences')
.send({ darkMode: true });
expect(response.status).toBe(500);
expect(logger.error).toHaveBeenCalledWith({ error: dbError }, `[ROUTE] PUT /api/users/profile/preferences - ERROR`);
expect(logger.error).toHaveBeenCalledWith(
{ error: dbError },
`[ROUTE] PUT /api/users/profile/preferences - ERROR`,
);
});
it('should return 400 if the request body is not a valid object', async () => {
@@ -641,14 +751,18 @@ describe('User Routes (/api/users)', () => {
.send('"not-an-object"');
expect(response.status).toBe(400);
// Zod or Body Parser error
expect(response.body).toBeDefined();
expect(response.body).toBeDefined();
});
});
describe('GET and PUT /users/me/dietary-restrictions', () => {
it('GET should return a list of restriction IDs', async () => {
const mockRestrictions: DietaryRestriction[] = [createMockDietaryRestriction({ name: 'Gluten-Free' })];
vi.mocked(db.personalizationRepo.getUserDietaryRestrictions).mockResolvedValue(mockRestrictions);
const mockRestrictions: DietaryRestriction[] = [
createMockDietaryRestriction({ name: 'Gluten-Free' }),
];
vi.mocked(db.personalizationRepo.getUserDietaryRestrictions).mockResolvedValue(
mockRestrictions,
);
const response = await supertest(app).get('/api/users/me/dietary-restrictions');
expect(response.status).toBe(200);
expect(response.body).toEqual(mockRestrictions);
@@ -659,18 +773,21 @@ describe('User Routes (/api/users)', () => {
vi.mocked(db.personalizationRepo.getUserDietaryRestrictions).mockRejectedValue(dbError);
const response = await supertest(app).get('/api/users/me/dietary-restrictions');
expect(response.status).toBe(500);
expect(logger.error).toHaveBeenCalledWith({ error: dbError }, `[ROUTE] GET /api/users/me/dietary-restrictions - ERROR`);
expect(logger.error).toHaveBeenCalledWith(
{ error: dbError },
`[ROUTE] GET /api/users/me/dietary-restrictions - ERROR`,
);
});
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");
});
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');
});
it('PUT should successfully set the restrictions', async () => {
vi.mocked(db.personalizationRepo.setUserDietaryRestrictions).mockResolvedValue(undefined);
vi.mocked(db.personalizationRepo.setUserDietaryRestrictions).mockResolvedValue(undefined);
const restrictionIds = [1, 3, 5];
const response = await supertest(app)
.put('/api/users/me/dietary-restrictions')
@@ -679,7 +796,9 @@ describe('User Routes (/api/users)', () => {
});
it('PUT should return 400 on foreign key constraint error', async () => {
vi.mocked(db.personalizationRepo.setUserDietaryRestrictions).mockRejectedValue(new ForeignKeyConstraintError('Invalid restriction ID'));
vi.mocked(db.personalizationRepo.setUserDietaryRestrictions).mockRejectedValue(
new ForeignKeyConstraintError('Invalid restriction ID'),
);
const response = await supertest(app)
.put('/api/users/me/dietary-restrictions')
.send({ restrictionIds: [999] }); // Invalid ID
@@ -718,18 +837,25 @@ describe('User Routes (/api/users)', () => {
vi.mocked(db.personalizationRepo.getUserAppliances).mockRejectedValue(dbError);
const response = await supertest(app).get('/api/users/me/appliances');
expect(response.status).toBe(500);
expect(logger.error).toHaveBeenCalledWith({ error: dbError }, `[ROUTE] GET /api/users/me/appliances - ERROR`);
expect(logger.error).toHaveBeenCalledWith(
{ error: dbError },
`[ROUTE] GET /api/users/me/appliances - ERROR`,
);
});
it('PUT should successfully set the appliances', async () => {
vi.mocked(db.personalizationRepo.setUserAppliances).mockResolvedValue([]);
vi.mocked(db.personalizationRepo.setUserAppliances).mockResolvedValue([]);
const applianceIds = [2, 4, 6];
const response = await supertest(app).put('/api/users/me/appliances').send({ applianceIds });
const response = await supertest(app)
.put('/api/users/me/appliances')
.send({ applianceIds });
expect(response.status).toBe(204);
});
it('PUT should return 400 on foreign key constraint error', async () => {
vi.mocked(db.personalizationRepo.setUserAppliances).mockRejectedValue(new ForeignKeyConstraintError('Invalid appliance ID'));
vi.mocked(db.personalizationRepo.setUserAppliances).mockRejectedValue(
new ForeignKeyConstraintError('Invalid appliance ID'),
);
const response = await supertest(app)
.put('/api/users/me/appliances')
.send({ applianceIds: [999] }); // Invalid ID
@@ -758,14 +884,21 @@ describe('User Routes (/api/users)', () => {
describe('Notification Routes', () => {
it('GET /notifications should return notifications for the user', async () => {
const mockNotifications: Notification[] = [createMockNotification({ user_id: 'user-123', content: 'Test' })];
const mockNotifications: Notification[] = [
createMockNotification({ user_id: 'user-123', content: 'Test' }),
];
vi.mocked(db.notificationRepo.getNotificationsForUser).mockResolvedValue(mockNotifications);
const response = await supertest(app).get('/api/users/notifications?limit=10&offset=0');
expect(response.status).toBe(200);
expect(response.body).toEqual(mockNotifications);
expect(db.notificationRepo.getNotificationsForUser).toHaveBeenCalledWith('user-123', 10, 0, expectLogger);
expect(db.notificationRepo.getNotificationsForUser).toHaveBeenCalledWith(
'user-123',
10,
0,
expectLogger,
);
});
it('GET /notifications should return 500 on a generic database error', async () => {
@@ -779,7 +912,10 @@ describe('User Routes (/api/users)', () => {
vi.mocked(db.notificationRepo.markAllNotificationsAsRead).mockResolvedValue(undefined);
const response = await supertest(app).post('/api/users/notifications/mark-all-read');
expect(response.status).toBe(204);
expect(db.notificationRepo.markAllNotificationsAsRead).toHaveBeenCalledWith('user-123', expectLogger);
expect(db.notificationRepo.markAllNotificationsAsRead).toHaveBeenCalledWith(
'user-123',
expectLogger,
);
});
it('POST /notifications/mark-all-read should return 500 on a generic database error', async () => {
@@ -791,10 +927,16 @@ describe('User Routes (/api/users)', () => {
it('POST /notifications/:notificationId/mark-read should return 204', async () => {
// Fix: Return a mock notification object to match the function's signature.
vi.mocked(db.notificationRepo.markNotificationAsRead).mockResolvedValue(createMockNotification({ notification_id: 1, user_id: 'user-123' }));
vi.mocked(db.notificationRepo.markNotificationAsRead).mockResolvedValue(
createMockNotification({ notification_id: 1, user_id: 'user-123' }),
);
const response = await supertest(app).post('/api/users/notifications/1/mark-read');
expect(response.status).toBe(204);
expect(db.notificationRepo.markNotificationAsRead).toHaveBeenCalledWith(1, 'user-123', expectLogger);
expect(db.notificationRepo.markNotificationAsRead).toHaveBeenCalledWith(
1,
'user-123',
expectLogger,
);
});
it('POST /notifications/:notificationId/mark-read should return 500 on a generic database error', async () => {
@@ -805,15 +947,21 @@ describe('User Routes (/api/users)', () => {
});
it('should return 400 for an invalid notificationId', async () => {
const response = await supertest(app).post('/api/users/notifications/abc/mark-read').send({});
const response = await supertest(app)
.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.errors[0].message).toContain('received NaN');
});
});
describe('Address Routes', () => {
it('GET /addresses/:addressId should return the address if it belongs to the user', async () => {
const appWithUser = createTestApp({ router: userRouter, basePath, authenticatedUser: { ...mockUserProfile, address_id: 1 } });
const appWithUser = createTestApp({
router: userRouter,
basePath,
authenticatedUser: { ...mockUserProfile, address_id: 1 },
});
const mockAddress = createMockAddress({ address_id: 1, address_line_1: '123 Main St' });
vi.mocked(db.addressRepo.getAddressById).mockResolvedValue(mockAddress);
const response = await supertest(appWithUser).get('/api/users/addresses/1');
@@ -822,45 +970,70 @@ describe('User Routes (/api/users)', () => {
});
it('GET /addresses/:addressId should return 500 on a generic database error', async () => {
const appWithUser = createTestApp({ router: userRouter, basePath, authenticatedUser: { ...mockUserProfile, address_id: 1 } });
const appWithUser = createTestApp({
router: userRouter,
basePath,
authenticatedUser: { ...mockUserProfile, address_id: 1 },
});
vi.mocked(db.addressRepo.getAddressById).mockRejectedValue(new Error('DB Error'));
const response = await supertest(appWithUser).get('/api/users/addresses/1');
expect(response.status).toBe(500);
});
describe('GET /addresses/:addressId', () => {
it('should return 400 for a non-numeric address ID', async () => {
const response = await supertest(app).get('/api/users/addresses/abc'); // This was a duplicate, fixed.
expect(response.status).toBe(400);
describe('GET /addresses/:addressId', () => {
it('should return 400 for a non-numeric address ID', async () => {
const response = await supertest(app).get('/api/users/addresses/abc'); // This was a duplicate, fixed.
expect(response.status).toBe(400);
});
});
});
it('GET /addresses/:addressId should return 403 if address does not belong to user', async () => {
const appWithDifferentUser = createTestApp({ router: userRouter, basePath, authenticatedUser: { ...mockUserProfile, address_id: 999 } });
const appWithDifferentUser = createTestApp({
router: userRouter,
basePath,
authenticatedUser: { ...mockUserProfile, address_id: 999 },
});
const response = await supertest(appWithDifferentUser).get('/api/users/addresses/1');
expect(response.status).toBe(403);
});
it('GET /addresses/:addressId should return 404 if address not found', async () => {
const appWithUser = createTestApp({ router: userRouter, basePath, authenticatedUser: { ...mockUserProfile, address_id: 1 } });
vi.mocked(db.addressRepo.getAddressById).mockRejectedValue(new NotFoundError('Address not found.'));
const appWithUser = createTestApp({
router: userRouter,
basePath,
authenticatedUser: { ...mockUserProfile, address_id: 1 },
});
vi.mocked(db.addressRepo.getAddressById).mockRejectedValue(
new NotFoundError('Address not found.'),
);
const response = await supertest(appWithUser).get('/api/users/addresses/1');
expect(response.status).toBe(404);
expect(response.body.message).toBe('Address not found.');
});
it('PUT /profile/address should call upsertAddress and updateUserProfile if needed', async () => {
const appWithUser = createTestApp({ router: userRouter, basePath, authenticatedUser: { ...mockUserProfile, address_id: null } }); // User has no address yet
const appWithUser = createTestApp({
router: userRouter,
basePath,
authenticatedUser: { ...mockUserProfile, address_id: null },
}); // User has no address yet
const addressData = { address_line_1: '123 New St' };
vi.mocked(db.addressRepo.upsertAddress).mockResolvedValue(5); // New address ID is 5
vi.mocked(db.userRepo.updateUserProfile).mockResolvedValue({ ...mockUserProfile, address_id: 5 });
vi.mocked(db.userRepo.updateUserProfile).mockResolvedValue({
...mockUserProfile,
address_id: 5,
});
const response = await supertest(appWithUser)
.put('/api/users/profile/address')
.send(addressData);
expect(response.status).toBe(200);
expect(userService.upsertUserAddress).toHaveBeenCalledWith(expect.anything(), addressData, expectLogger);
expect(userService.upsertUserAddress).toHaveBeenCalledWith(
expect.anything(),
addressData,
expectLogger,
);
});
it('PUT /profile/address should return 500 on a generic service error', async () => {
@@ -873,29 +1046,36 @@ 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({});
const response = await supertest(app).put('/api/users/profile/address').send({});
expect(response.status).toBe(400);
expect(response.body.errors[0].message).toContain('At least one address field must be provided');
expect(response.body.errors[0].message).toContain(
'At least one address field must be provided',
);
});
});
describe('POST /profile/avatar', () => {
it('should upload an avatar and update the user profile', async () => {
const mockUpdatedProfile = { ...mockUserProfile, avatar_url: '/uploads/avatars/new-avatar.png' };
const mockUpdatedProfile = {
...mockUserProfile,
avatar_url: '/uploads/avatars/new-avatar.png',
};
vi.mocked(db.userRepo.updateUserProfile).mockResolvedValue(mockUpdatedProfile);
// Create a dummy file path for supertest to attach
const dummyImagePath = 'test-avatar.png';
const response = await supertest(app)
.post('/api/users/profile/avatar')
.attach('avatar', Buffer.from('dummy-image-content'), dummyImagePath);
expect(response.status).toBe(200);
expect(response.body.avatar_url).toContain('/uploads/avatars/');
expect(db.userRepo.updateUserProfile).toHaveBeenCalledWith(mockUserProfile.user.user_id, { avatar_url: expect.any(String) }, expectLogger);
expect(db.userRepo.updateUserProfile).toHaveBeenCalledWith(
mockUserProfile.user.user_id,
{ avatar_url: expect.any(String) },
expectLogger,
);
});
it('should return 500 if updating the profile fails after upload', async () => {
@@ -933,8 +1113,7 @@ describe('User Routes (/api/users)', () => {
});
it('should return 400 if no file is uploaded', async () => {
const response = await supertest(app)
.post('/api/users/profile/avatar'); // No .attach() call
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.');
@@ -943,16 +1122,21 @@ 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.errors[0].message).toContain('received NaN');
});
});
describe('Recipe Routes', () => {
it('DELETE /recipes/:recipeId should delete a user\'s own recipe', async () => {
it("DELETE /recipes/:recipeId should delete a user's own recipe", async () => {
vi.mocked(db.recipeRepo.deleteRecipe).mockResolvedValue(undefined);
const response = await supertest(app).delete('/api/users/recipes/1');
expect(response.status).toBe(204);
expect(db.recipeRepo.deleteRecipe).toHaveBeenCalledWith(1, mockUserProfile.user.user_id, false, expectLogger);
expect(db.recipeRepo.deleteRecipe).toHaveBeenCalledWith(
1,
mockUserProfile.user.user_id,
false,
expectLogger,
);
});
it('DELETE /recipes/:recipeId should return 500 on a generic database error', async () => {
@@ -963,56 +1147,70 @@ describe('User Routes (/api/users)', () => {
expect(logger.error).toHaveBeenCalled();
});
it('PUT /recipes/:recipeId should update a user\'s own recipe', async () => {
it("PUT /recipes/:recipeId should update a user's own recipe", async () => {
const updates = { description: 'A new delicious description.' };
const mockUpdatedRecipe = createMockRecipe({ recipe_id: 1, ...updates });
vi.mocked(db.recipeRepo.updateRecipe).mockResolvedValue(mockUpdatedRecipe);
const response = await supertest(app)
.put('/api/users/recipes/1')
.send(updates);
const response = await supertest(app).put('/api/users/recipes/1').send(updates);
expect(response.status).toBe(200);
expect(response.body).toEqual(mockUpdatedRecipe);
expect(db.recipeRepo.updateRecipe).toHaveBeenCalledWith(1, mockUserProfile.user.user_id, updates, expectLogger);
expect(db.recipeRepo.updateRecipe).toHaveBeenCalledWith(
1,
mockUserProfile.user.user_id,
updates,
expectLogger,
);
});
it('PUT /recipes/:recipeId should return 404 if recipe not found', async () => {
vi.mocked(db.recipeRepo.updateRecipe).mockRejectedValue(new NotFoundError('not found'));
const response = await supertest(app).put('/api/users/recipes/999').send({ name: 'New Name' });
const response = await supertest(app)
.put('/api/users/recipes/999')
.send({ name: 'New Name' });
expect(response.status).toBe(404);
});
it('PUT /recipes/:recipeId should return 500 on a generic database error', async () => {
const dbError = new Error('DB Connection Failed');
vi.mocked(db.recipeRepo.updateRecipe).mockRejectedValue(dbError);
const response = await supertest(app).put('/api/users/recipes/1').send({ name: 'New Name' });
const response = await supertest(app)
.put('/api/users/recipes/1')
.send({ name: 'New Name' });
expect(response.status).toBe(500);
expect(logger.error).toHaveBeenCalled();
});
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({});
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.');
});
it('GET /shopping-lists/:listId should return 404 if list is not found', async () => {
vi.mocked(db.shoppingRepo.getShoppingListById).mockRejectedValue(new NotFoundError('Shopping list not found'));
vi.mocked(db.shoppingRepo.getShoppingListById).mockRejectedValue(
new NotFoundError('Shopping list not found'),
);
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');
});
it('GET /shopping-lists/:listId should return a single shopping list', async () => {
const mockList = createMockShoppingList({ shopping_list_id: 1, user_id: mockUserProfile.user.user_id });
const mockList = createMockShoppingList({
shopping_list_id: 1,
user_id: mockUserProfile.user.user_id,
});
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(db.shoppingRepo.getShoppingListById).toHaveBeenCalledWith(1, mockUserProfile.user.user_id, expectLogger);
expect(db.shoppingRepo.getShoppingListById).toHaveBeenCalledWith(
1,
mockUserProfile.user.user_id,
expectLogger,
);
});
it('GET /shopping-lists/:listId should return 500 on a generic database error', async () => {
@@ -1024,4 +1222,4 @@ describe('User Routes (/api/users)', () => {
});
}); // End of Recipe Routes
});
});
});

File diff suppressed because it is too large Load Diff