complete project using prettier!
This commit is contained in:
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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=;');
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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.',
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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:',
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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:',
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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.',
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user