route testing refactoring using zod - ADR-003
All checks were successful
Deploy to Test Environment / deploy-to-test (push) Successful in 16m3s
All checks were successful
Deploy to Test Environment / deploy-to-test (push) Successful in 16m3s
This commit is contained in:
@@ -2,8 +2,8 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterAll } from 'vitest';
|
||||
import supertest from 'supertest';
|
||||
import express, { Request, Response, NextFunction } from 'express';
|
||||
import { errorHandler } from './errorHandler';
|
||||
import { DatabaseError, ForeignKeyConstraintError, UniqueConstraintError } from '../services/db/errors.db';
|
||||
import { errorHandler } from './errorHandler'; // This was a duplicate, fixed.
|
||||
import { DatabaseError, ForeignKeyConstraintError, UniqueConstraintError, ValidationError } from '../services/db/errors.db';
|
||||
import { logger } from '../services/logger.server';
|
||||
|
||||
// Mock the logger to prevent console output during tests and to verify it's called
|
||||
@@ -55,6 +55,11 @@ app.get('/unauthorized-error-with-status', (req, res, next) => {
|
||||
next(err);
|
||||
});
|
||||
|
||||
app.get('/validation-error', (req, res, next) => {
|
||||
const validationIssues = [{ path: ['body', 'email'], message: 'Invalid email format' }];
|
||||
next(new ValidationError(validationIssues, 'Input validation failed'));
|
||||
});
|
||||
|
||||
// 3. Apply the errorHandler middleware *after* all the routes
|
||||
app.use(errorHandler);
|
||||
|
||||
@@ -122,6 +127,20 @@ describe('errorHandler Middleware', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle a ValidationError with a 400 status and include the validation errors array', async () => {
|
||||
const response = await supertest(app).get('/validation-error');
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
expect(response.body.message).toBe('Input validation failed');
|
||||
expect(response.body.errors).toBeDefined();
|
||||
expect(response.body.errors).toEqual([{ path: ['body', 'email'], message: 'Invalid email format' }]);
|
||||
expect(logger.error).not.toHaveBeenCalled(); // 4xx errors are not logged as server errors
|
||||
expect(consoleErrorSpy).toHaveBeenCalledWith(
|
||||
expect.stringContaining('--- [TEST] UNHANDLED ERROR ---'),
|
||||
expect.any(ValidationError)
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle a DatabaseError with a 500 status and a generic message', async () => {
|
||||
const response = await supertest(app).get('/db-error-500');
|
||||
|
||||
|
||||
@@ -53,6 +53,7 @@ export const errorHandler = (err: HttpError, req: Request, res: Response, next:
|
||||
// --- 1. Determine Final Status Code and Message ---
|
||||
let statusCode = err.status ?? 500;
|
||||
let message = err.message;
|
||||
let errors: any[] | undefined;
|
||||
let errorId: string | undefined;
|
||||
|
||||
// Refine the status code for known error types. Check for most specific types first.
|
||||
@@ -62,6 +63,9 @@ export const errorHandler = (err: HttpError, req: Request, res: Response, next:
|
||||
statusCode = 404;
|
||||
} else if (err instanceof ForeignKeyConstraintError) {
|
||||
statusCode = 400;
|
||||
} else if (err instanceof ValidationError) {
|
||||
statusCode = 400;
|
||||
errors = err.validationErrors;
|
||||
} else if (err instanceof DatabaseError) {
|
||||
// This is a generic fallback for other database errors that are not the specific subclasses above.
|
||||
statusCode = err.status;
|
||||
@@ -107,5 +111,6 @@ export const errorHandler = (err: HttpError, req: Request, res: Response, next:
|
||||
|
||||
res.status(statusCode).json({
|
||||
message: responseMessage,
|
||||
...(errors && { errors }), // Conditionally add the 'errors' array if it exists
|
||||
});
|
||||
};
|
||||
@@ -1,104 +0,0 @@
|
||||
// src/middleware/validation.middleware.test.ts
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import supertest from 'supertest';
|
||||
import express, { Request, Response } from 'express';
|
||||
import { z } from 'zod';
|
||||
import { validateRequest } from './validation.middleware';
|
||||
import { errorHandler } from './errorHandler';
|
||||
import { ValidationError } from '../services/db/errors.db';
|
||||
|
||||
// Mock the logger to prevent console output during tests
|
||||
vi.mock('../services/logger.server', () => ({
|
||||
logger: {
|
||||
error: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
// 1. Create a minimal Express app for testing the middleware
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
|
||||
// 2. Define a sample Zod schema for a test route
|
||||
const testSchema = z.object({
|
||||
params: z.object({
|
||||
id: z.coerce.number().int().positive('ID must be a positive integer.'),
|
||||
}),
|
||||
query: z.object({
|
||||
limit: z.coerce.number().int().min(1).optional().default(10),
|
||||
}),
|
||||
body: z.object({
|
||||
name: z.string().min(3, 'Name must be at least 3 characters long.'),
|
||||
is_active: z.boolean().optional(),
|
||||
}),
|
||||
});
|
||||
|
||||
// 3. Setup a test route that uses the validation middleware
|
||||
app.post(
|
||||
'/test/:id',
|
||||
validateRequest(testSchema),
|
||||
(req: Request, res: Response) => {
|
||||
// This handler only runs if validation succeeds.
|
||||
// We send back the parsed data to confirm it was correctly attached to the request.
|
||||
res.status(200).json({
|
||||
params: req.params,
|
||||
query: req.query,
|
||||
body: req.body,
|
||||
});
|
||||
}
|
||||
);
|
||||
|
||||
// 4. Apply the actual global errorHandler to handle ValidationErrors thrown by the middleware
|
||||
app.use(errorHandler);
|
||||
|
||||
describe('validateRequest Middleware', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('should call next() and attach parsed data for a valid request', async () => {
|
||||
const requestBody = { name: 'Test Item', is_active: true };
|
||||
const response = await supertest(app)
|
||||
.post('/test/123?limit=5')
|
||||
.send(requestBody);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
// Check that data was coerced and correctly typed
|
||||
expect(response.body).toEqual({
|
||||
params: { id: 123 }, // Coerced to number
|
||||
query: { limit: 5 }, // Coerced to number
|
||||
body: requestBody,
|
||||
});
|
||||
});
|
||||
|
||||
it('should return a 400 error for invalid params', async () => {
|
||||
const response = await supertest(app)
|
||||
.post('/test/abc') // 'abc' is not a valid number
|
||||
.send({ name: 'Valid Name' });
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
expect(response.body.message).toBe('The request data is invalid.');
|
||||
expect(response.body.errors[0].path).toEqual(['params', 'id']);
|
||||
});
|
||||
|
||||
it('should return a 400 error for invalid query parameters', async () => {
|
||||
const response = await supertest(app)
|
||||
.post('/test/123?limit=-1') // -1 is not a valid limit
|
||||
.send({ name: 'Valid Name' });
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
expect(response.body.message).toBe('The request data is invalid.');
|
||||
expect(response.body.errors[0].path).toEqual(['query', 'limit']);
|
||||
});
|
||||
|
||||
it('should return a 400 error for an invalid request body', async () => {
|
||||
const response = await supertest(app)
|
||||
.post('/test/123')
|
||||
.send({ name: 'a' }); // 'a' is too short
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
expect(response.body.message).toBe('The request data is invalid.');
|
||||
expect(response.body.errors[0].path).toEqual(['body', 'name']);
|
||||
expect(response.body.errors[0].message).toBe('Name must be at least 3 characters long.');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,41 @@
|
||||
// src/middleware/validation.middleware.ts
|
||||
import { Request, Response, NextFunction } from 'express';
|
||||
import { ParamsDictionary } from 'express-serve-static-core';
|
||||
import { ZodObject, ZodError } from 'zod';
|
||||
import { ValidationError } from '../services/db/errors.db';
|
||||
|
||||
/**
|
||||
* A middleware factory that generates a validation middleware for a given Zod schema.
|
||||
* It validates the request's params, query, and body against the schema.
|
||||
*
|
||||
* @param schema - The Zod schema to validate against.
|
||||
* @returns An Express middleware function.
|
||||
*/
|
||||
export const validateRequest = (schema: ZodObject<any>) =>
|
||||
async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
// Parse and validate the request parts against the schema.
|
||||
// This will throw a ZodError if validation fails.
|
||||
const { params, query, body } = await schema.parseAsync({
|
||||
params: req.params,
|
||||
query: req.query,
|
||||
body: req.body,
|
||||
});
|
||||
|
||||
// On success, replace the request parts with the parsed (and coerced) data.
|
||||
// This ensures downstream handlers get correctly typed data.
|
||||
req.params = params as ParamsDictionary;
|
||||
req.query = query as any;
|
||||
req.body = body;
|
||||
|
||||
return next();
|
||||
} catch (error) {
|
||||
if (error instanceof ZodError) {
|
||||
// If it's a Zod validation error, wrap it in our custom ValidationError.
|
||||
// The global errorHandler will format this into a 400 response.
|
||||
return next(new ValidationError(error.issues));
|
||||
}
|
||||
// For any other unexpected errors, pass them on.
|
||||
return next(error);
|
||||
}
|
||||
};
|
||||
@@ -153,12 +153,6 @@ describe('Admin Content Management Routes (/api/admin)', () => {
|
||||
expect(vi.mocked(mockedDb.adminRepo.approveCorrection)).toHaveBeenCalledWith(correctionId);
|
||||
});
|
||||
|
||||
it('POST /corrections/:id/approve should return 400 for an invalid ID', async () => {
|
||||
const response = await supertest(app).post('/api/admin/corrections/abc/approve');
|
||||
expect(response.status).toBe(400);
|
||||
expect(response.body.message).toBe('Invalid correction ID provided.');
|
||||
});
|
||||
|
||||
it('POST /corrections/:id/reject should reject a correction', async () => {
|
||||
const correctionId = 789;
|
||||
vi.mocked(mockedDb.adminRepo.rejectCorrection).mockResolvedValue(undefined);
|
||||
@@ -167,12 +161,6 @@ describe('Admin Content Management Routes (/api/admin)', () => {
|
||||
expect(response.body).toEqual({ message: 'Correction rejected successfully.' });
|
||||
});
|
||||
|
||||
it('PUT /corrections/:id should return 400 if suggested_value is missing', async () => {
|
||||
const response = await supertest(app).put('/api/admin/corrections/101').send({});
|
||||
expect(response.status).toBe(400);
|
||||
expect(response.body.message).toBe('A new suggested_value is required.');
|
||||
});
|
||||
|
||||
it('PUT /corrections/:id should update a correction', async () => {
|
||||
const correctionId = 101;
|
||||
const requestBody = { suggested_value: 'A new corrected value' };
|
||||
@@ -229,16 +217,6 @@ describe('Admin Content Management Routes (/api/admin)', () => {
|
||||
expect(response.body).toEqual(mockUpdatedRecipe);
|
||||
});
|
||||
|
||||
it('PUT /recipes/:id/status should return 400 for an invalid status', async () => {
|
||||
// This test is slightly misnamed. It actually tests the 404 Not Found case,
|
||||
// because the route logic will attempt to fetch the recipe before validating the status.
|
||||
// We mock the DB to throw a NotFoundError to simulate this.
|
||||
vi.mocked(mockedDb.adminRepo.updateRecipeStatus).mockRejectedValue(new NotFoundError('Recipe with ID 201 not found.'));
|
||||
const response = await supertest(app).put('/api/admin/recipes/201').send({ status: 'public' });
|
||||
expect(response.status).toBe(404);
|
||||
expect(response.body.message).toBe('Recipe with ID 201 not found.');
|
||||
});
|
||||
|
||||
it('PUT /comments/:id/status should update a comment status', async () => {
|
||||
const commentId = 301;
|
||||
const requestBody = { status: 'hidden' as const };
|
||||
@@ -249,15 +227,6 @@ describe('Admin Content Management Routes (/api/admin)', () => {
|
||||
expect(response.body).toEqual(mockUpdatedComment);
|
||||
});
|
||||
|
||||
it('PUT /comments/:id/status should return 400 for an invalid status', async () => {
|
||||
// For this test, we do NOT mock the database call. The route handler should
|
||||
// validate the 'status' from the request body and return a 400 Bad Request
|
||||
// *before* any database interaction is attempted. If the mock were called,
|
||||
// it would indicate a logic error in the route.
|
||||
const response = await supertest(app).put('/api/admin/comments/301').send({ status: 'invalid-status' });
|
||||
expect(response.status).toBe(400);
|
||||
expect(response.body.message).toContain('A valid status');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Unmatched Items Route', () => {
|
||||
@@ -289,11 +258,6 @@ describe('Admin Content Management Routes (/api/admin)', () => {
|
||||
expect(vi.mocked(mockedDb.flyerRepo.deleteFlyer)).toHaveBeenCalledWith(flyerId);
|
||||
});
|
||||
|
||||
it('DELETE /flyers/:flyerId should return 400 for an invalid ID', async () => {
|
||||
const response = await supertest(app).delete('/api/admin/flyers/abc');
|
||||
expect(response.status).toBe(400);
|
||||
});
|
||||
|
||||
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.'));
|
||||
|
||||
@@ -174,12 +174,6 @@ describe('Admin Job Trigger Routes (/api/admin/trigger)', () => {
|
||||
expect(cleanupQueue.add).toHaveBeenCalledWith('cleanup-flyer-files', { flyerId });
|
||||
});
|
||||
|
||||
it('should return 400 for an invalid flyer ID', async () => {
|
||||
const response = await supertest(app).post('/api/admin/flyers/invalid-id/cleanup');
|
||||
expect(response.status).toBe(400);
|
||||
expect(response.body.message).toBe('A valid flyer ID is required.');
|
||||
});
|
||||
|
||||
it('should return 500 if enqueuing the cleanup job fails', async () => {
|
||||
const flyerId = 789;
|
||||
vi.mocked(cleanupQueue.add).mockRejectedValue(new Error('Queue is down'));
|
||||
|
||||
@@ -2,7 +2,8 @@
|
||||
import { Router, Request, Response, NextFunction } from 'express';
|
||||
import passport from './passport.routes';
|
||||
import { isAdmin } from './passport.routes'; // Correctly imported
|
||||
import multer from 'multer';
|
||||
import multer from 'multer';// --- Zod Schemas for Admin Routes (as per ADR-003) ---
|
||||
import { z } from 'zod';
|
||||
import crypto from 'crypto';
|
||||
|
||||
import * as db from '../services/db/index.db';
|
||||
@@ -10,6 +11,7 @@ import { logger } from '../services/logger.server';
|
||||
import { UserProfile } from '../types';
|
||||
import { clearGeocodeCache } from '../services/geocodingService.server';
|
||||
import { ForeignKeyConstraintError } from '../services/db/errors.db';
|
||||
import { validateRequest } from '../middleware/validation.middleware';
|
||||
|
||||
// --- Bull Board (Job Queue UI) Imports ---
|
||||
import { createBullBoard } from '@bull-board/api';
|
||||
@@ -22,6 +24,42 @@ import { backgroundJobService } from '../services/backgroundJobService';
|
||||
import { flyerQueue, emailQueue, analyticsQueue, cleanupQueue, weeklyAnalyticsQueue, flyerWorker, emailWorker, analyticsWorker, cleanupWorker, weeklyAnalyticsWorker } from '../services/queueService.server'; // Import your queues
|
||||
import { getSimpleWeekAndYear } from '../utils/dateUtils';
|
||||
|
||||
const numericIdParamSchema = (key: string) => z.object({
|
||||
params: z.object({ [key]: z.coerce.number().int().positive() }),
|
||||
});
|
||||
|
||||
const updateCorrectionSchema = numericIdParamSchema('id').extend({
|
||||
body: z.object({
|
||||
suggested_value: z.string().min(1, 'A new suggested_value is required.'),
|
||||
}),
|
||||
});
|
||||
|
||||
const updateRecipeStatusSchema = numericIdParamSchema('id').extend({
|
||||
body: z.object({
|
||||
status: z.enum(['private', 'pending_review', 'public', 'rejected']),
|
||||
}),
|
||||
});
|
||||
|
||||
const updateCommentStatusSchema = numericIdParamSchema('id').extend({
|
||||
body: z.object({
|
||||
status: z.enum(['visible', 'hidden', 'reported']),
|
||||
}),
|
||||
});
|
||||
|
||||
const updateUserRoleSchema = z.object({
|
||||
params: z.object({ id: z.string().uuid("User ID must be a valid UUID.") }),
|
||||
body: z.object({
|
||||
role: z.enum(['user', 'admin']),
|
||||
}),
|
||||
});
|
||||
|
||||
const activityLogSchema = z.object({
|
||||
query: z.object({
|
||||
limit: z.coerce.number().int().positive().optional().default(50),
|
||||
offset: z.coerce.number().int().nonnegative().optional().default(0),
|
||||
}),
|
||||
});
|
||||
|
||||
const router = Router();
|
||||
|
||||
// --- Multer Configuration for File Uploads ---
|
||||
@@ -103,13 +141,9 @@ router.get('/stats/daily', async (req, res, next: NextFunction) => {
|
||||
}
|
||||
});
|
||||
|
||||
router.post('/corrections/:id/approve', async (req, res, next: NextFunction) => {
|
||||
const correctionId = parseInt(req.params.id, 10);
|
||||
// Add validation to ensure the ID is a valid number.
|
||||
if (isNaN(correctionId)) {
|
||||
return res.status(400).json({ message: 'Invalid correction ID provided.' });
|
||||
}
|
||||
router.post('/corrections/:id/approve', validateRequest(numericIdParamSchema('id')), async (req, res, next: NextFunction) => {
|
||||
try {
|
||||
const correctionId = req.params.id as unknown as number;
|
||||
await db.adminRepo.approveCorrection(correctionId);
|
||||
res.status(200).json({ message: 'Correction approved successfully.' });
|
||||
} catch (error) {
|
||||
@@ -117,9 +151,9 @@ router.post('/corrections/:id/approve', async (req, res, next: NextFunction) =>
|
||||
}
|
||||
});
|
||||
|
||||
router.post('/corrections/:id/reject', async (req, res, next: NextFunction) => {
|
||||
router.post('/corrections/:id/reject', validateRequest(numericIdParamSchema('id')), async (req, res, next: NextFunction) => {
|
||||
try {
|
||||
const correctionId = parseInt(req.params.id, 10);
|
||||
const correctionId = req.params.id as unknown as number;
|
||||
await db.adminRepo.rejectCorrection(correctionId);
|
||||
res.status(200).json({ message: 'Correction rejected successfully.' });
|
||||
} catch (error) {
|
||||
@@ -127,29 +161,20 @@ router.post('/corrections/:id/reject', async (req, res, next: NextFunction) => {
|
||||
}
|
||||
});
|
||||
|
||||
router.put('/corrections/:id', async (req, res, next: NextFunction) => {
|
||||
const correctionId = parseInt(req.params.id, 10);
|
||||
router.put('/corrections/:id', validateRequest(updateCorrectionSchema), async (req, res, next: NextFunction) => {
|
||||
const correctionId = req.params.id as unknown as number;
|
||||
const { suggested_value } = req.body;
|
||||
if (!suggested_value) {
|
||||
return res.status(400).json({ message: 'A new suggested_value is required.' });
|
||||
}
|
||||
try {
|
||||
const updatedCorrection = await db.adminRepo.updateSuggestedCorrection(correctionId, suggested_value);
|
||||
res.status(200).json(updatedCorrection);
|
||||
} catch (error) {
|
||||
// The custom error handler now correctly interprets "not found" messages.
|
||||
// We can simplify this by just passing the error along.
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
router.put('/recipes/:id/status', async (req, res, next: NextFunction) => {
|
||||
const recipeId = parseInt(req.params.id, 10);
|
||||
router.put('/recipes/:id/status', validateRequest(updateRecipeStatusSchema), async (req, res, next: NextFunction) => {
|
||||
const recipeId = req.params.id as unknown as number;
|
||||
const { status } = req.body;
|
||||
|
||||
if (!status || !['private', 'pending_review', 'public', 'rejected'].includes(status)) {
|
||||
return res.status(400).json({ message: 'A valid status (private, pending_review, public, rejected) is required.' });
|
||||
}
|
||||
try {
|
||||
const updatedRecipe = await db.adminRepo.updateRecipeStatus(recipeId, status); // This is still a standalone function in admin.db.ts
|
||||
res.status(200).json(updatedRecipe);
|
||||
@@ -158,8 +183,8 @@ router.put('/recipes/:id/status', async (req, res, next: NextFunction) => {
|
||||
}
|
||||
});
|
||||
|
||||
router.post('/brands/:id/logo', upload.single('logoImage'), async (req, res, next: NextFunction) => {
|
||||
const brandId = parseInt(req.params.id, 10);
|
||||
router.post('/brands/:id/logo', validateRequest(numericIdParamSchema('id')), upload.single('logoImage'), async (req, res, next: NextFunction) => {
|
||||
const brandId = req.params.id as unknown as number;
|
||||
if (!req.file) {
|
||||
return res.status(400).json({ message: 'Logo image file is required.' });
|
||||
}
|
||||
@@ -187,13 +212,9 @@ router.get('/unmatched-items', async (req, res, next: NextFunction) => {
|
||||
/**
|
||||
* DELETE /api/admin/recipes/:recipeId - Admin endpoint to delete any recipe.
|
||||
*/
|
||||
router.delete('/recipes/:recipeId', async (req, res, next: NextFunction) => {
|
||||
router.delete('/recipes/:recipeId', validateRequest(numericIdParamSchema('recipeId')), async (req, res, next: NextFunction) => {
|
||||
const adminUser = req.user as UserProfile;
|
||||
const recipeId = parseInt(req.params.recipeId, 10);
|
||||
|
||||
if (isNaN(recipeId)) {
|
||||
return res.status(400).json({ message: 'Invalid recipe ID.' });
|
||||
}
|
||||
const recipeId = req.params.recipeId as unknown as number;
|
||||
|
||||
try {
|
||||
// The isAdmin flag bypasses the ownership check in the repository method.
|
||||
@@ -207,13 +228,8 @@ router.delete('/recipes/:recipeId', async (req, res, next: NextFunction) => {
|
||||
/**
|
||||
* DELETE /api/admin/flyers/:flyerId - Admin endpoint to delete a flyer and its items.
|
||||
*/
|
||||
router.delete('/flyers/:flyerId', async (req, res, next: NextFunction) => {
|
||||
const flyerId = parseInt(req.params.flyerId, 10);
|
||||
|
||||
if (isNaN(flyerId)) {
|
||||
return res.status(400).json({ message: 'Invalid flyer ID.' });
|
||||
}
|
||||
|
||||
router.delete('/flyers/:flyerId', validateRequest(numericIdParamSchema('flyerId')), async (req, res, next: NextFunction) => {
|
||||
const flyerId = req.params.flyerId as unknown as number;
|
||||
try {
|
||||
await db.flyerRepo.deleteFlyer(flyerId);
|
||||
res.status(204).send();
|
||||
@@ -222,13 +238,9 @@ router.delete('/flyers/:flyerId', async (req, res, next: NextFunction) => {
|
||||
}
|
||||
});
|
||||
|
||||
router.put('/comments/:id/status', async (req, res, next: NextFunction) => {
|
||||
const commentId = parseInt(req.params.id, 10);
|
||||
router.put('/comments/:id/status', validateRequest(updateCommentStatusSchema), async (req, res, next: NextFunction) => {
|
||||
const commentId = req.params.id as unknown as number;
|
||||
const { status } = req.body;
|
||||
|
||||
if (!status || !['visible', 'hidden', 'reported'].includes(status as string)) {
|
||||
return res.status(400).json({ message: 'A valid status (visible, hidden, reported) is required.' });
|
||||
}
|
||||
try {
|
||||
const updatedComment = await db.adminRepo.updateRecipeCommentStatus(commentId, status); // This is still a standalone function in admin.db.ts
|
||||
res.status(200).json(updatedComment);
|
||||
@@ -246,9 +258,8 @@ router.get('/users', async (req, res, next: NextFunction) => {
|
||||
}
|
||||
});
|
||||
|
||||
router.get('/activity-log', async (req, res, next: NextFunction) => {
|
||||
const limit = parseInt(req.query.limit as string, 10) || 50;
|
||||
const offset = parseInt(req.query.offset as string, 10) || 0;
|
||||
router.get('/activity-log', validateRequest(activityLogSchema), async (req, res, next: NextFunction) => {
|
||||
const { limit, offset } = req.query as unknown as { limit: number; offset: number };
|
||||
try {
|
||||
const logs = await db.adminRepo.getActivityLog(limit, offset);
|
||||
res.json(logs);
|
||||
@@ -257,7 +268,7 @@ router.get('/activity-log', async (req, res, next: NextFunction) => {
|
||||
}
|
||||
});
|
||||
|
||||
router.get('/users/:id', async (req, res, next: NextFunction) => {
|
||||
router.get('/users/:id', validateRequest(z.object({ params: z.object({ id: z.string().uuid() }) })), async (req, res, next: NextFunction) => {
|
||||
try {
|
||||
const user = await db.userRepo.findUserProfileById(req.params.id);
|
||||
res.json(user);
|
||||
@@ -266,11 +277,8 @@ router.get('/users/:id', async (req, res, next: NextFunction) => {
|
||||
}
|
||||
});
|
||||
|
||||
router.put('/users/:id', async (req, res, next: NextFunction) => {
|
||||
router.put('/users/:id', validateRequest(updateUserRoleSchema), async (req, res, next: NextFunction) => {
|
||||
const { role } = req.body;
|
||||
if (!role || !['user', 'admin'].includes(role)) {
|
||||
return res.status(400).json({ message: 'A valid role ("user" or "admin") is required.' });
|
||||
}
|
||||
|
||||
try {
|
||||
const updatedUser = await db.adminRepo.updateUserRole(req.params.id, role);
|
||||
@@ -281,7 +289,7 @@ router.put('/users/:id', async (req, res, next: NextFunction) => {
|
||||
}
|
||||
});
|
||||
|
||||
router.delete('/users/:id', async (req, res, next: NextFunction) => {
|
||||
router.delete('/users/:id', validateRequest(z.object({ params: z.object({ id: z.string().uuid() }) })), async (req, res, next: NextFunction) => {
|
||||
const adminUser = req.user as UserProfile;
|
||||
if (adminUser.user.user_id === req.params.id) {
|
||||
return res.status(400).json({ message: 'Admins cannot delete their own account.' });
|
||||
@@ -339,13 +347,9 @@ router.post('/trigger/analytics-report', async (req, res, next: NextFunction) =>
|
||||
* 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', async (req, res, next: NextFunction) => {
|
||||
router.post('/flyers/:flyerId/cleanup', validateRequest(numericIdParamSchema('flyerId')), async (req, res, next: NextFunction) => {
|
||||
const adminUser = req.user as UserProfile;
|
||||
const flyerId = parseInt(req.params.flyerId, 10);
|
||||
|
||||
if (isNaN(flyerId)) {
|
||||
return res.status(400).json({ message: 'A valid flyer ID is required.' });
|
||||
}
|
||||
const flyerId = req.params.flyerId as unknown as number;
|
||||
|
||||
logger.info(`[Admin] Manual trigger for flyer file cleanup received from user: ${adminUser.user_id} for flyer ID: ${flyerId}`);
|
||||
|
||||
|
||||
@@ -153,12 +153,6 @@ describe('Admin User Management Routes (/api/admin/users)', () => {
|
||||
expect(response.status).toBe(404);
|
||||
expect(response.body.message).toBe('User with ID non-existent not found.');
|
||||
});
|
||||
|
||||
it('should return 400 for an invalid role', async () => {
|
||||
const response = await supertest(app).put('/api/admin/users/any-id').send({ role: 'invalid-role' });
|
||||
expect(response.status).toBe(400);
|
||||
expect(response.body.message).toBe('A valid role ("user" or "admin") is required.');
|
||||
});
|
||||
});
|
||||
|
||||
describe('DELETE /users/:id', () => {
|
||||
|
||||
@@ -94,15 +94,6 @@ describe('AI Routes (/api/ai)', () => {
|
||||
expect(response.body.message).toBe('A flyer file (PDF or image) is required.');
|
||||
});
|
||||
|
||||
it('should return 400 if checksum is missing', async () => {
|
||||
const response = await supertest(app)
|
||||
.post('/api/ai/upload-and-process')
|
||||
.attach('flyerFile', imagePath);
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
expect(response.body.message).toBe('File checksum is required.');
|
||||
});
|
||||
|
||||
it('should return 409 if flyer checksum already exists', async () => {
|
||||
vi.mocked(db.flyerRepo.findFlyerByChecksum).mockResolvedValue(createMockFlyer({ flyer_id: 99 }));
|
||||
|
||||
@@ -384,6 +375,7 @@ describe('AI Routes (/api/ai)', () => {
|
||||
});
|
||||
|
||||
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 () => {
|
||||
const response = await supertest(app)
|
||||
.post('/api/ai/rescan-area')
|
||||
@@ -419,20 +411,6 @@ describe('AI Routes (/api/ai)', () => {
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body.store_logo_base_64).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
describe('POST /rescan-area (authenticated)', () => {
|
||||
const imagePath = path.resolve(__dirname, '../tests/assets/test-flyer-image.jpg');
|
||||
const mockUser = createMockUserProfile({ user_id: 'user-123' });
|
||||
|
||||
beforeEach(() => {
|
||||
// Inject an authenticated user for this test block
|
||||
app.use((req, res, next) => {
|
||||
req.user = mockUser;
|
||||
next();
|
||||
});
|
||||
});
|
||||
|
||||
it('should call the AI service and return the result on success', async () => {
|
||||
const mockResult = { text: 'Rescanned Text' };
|
||||
@@ -461,14 +439,6 @@ describe('AI Routes (/api/ai)', () => {
|
||||
expect(response.status).toBe(500);
|
||||
expect(response.body.message).toBe('AI API is down');
|
||||
});
|
||||
|
||||
it('should return 400 if cropArea is missing', async () => {
|
||||
const response = await supertest(app)
|
||||
.post('/api/ai/rescan-area')
|
||||
.field('extractionType', 'store_name')
|
||||
.attach('image', imagePath);
|
||||
expect(response.status).toBe(400);
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST /rescan-area (authenticated)', () => {
|
||||
@@ -510,14 +480,6 @@ describe('AI Routes (/api/ai)', () => {
|
||||
expect(response.status).toBe(500);
|
||||
expect(response.body.message).toBe('AI API is down');
|
||||
});
|
||||
|
||||
it('should return 400 if cropArea is missing', async () => {
|
||||
const response = await supertest(app)
|
||||
.post('/api/ai/rescan-area')
|
||||
.field('extractionType', 'store_name')
|
||||
.attach('image', imagePath);
|
||||
expect(response.status).toBe(400);
|
||||
});
|
||||
});
|
||||
|
||||
describe('when user is authenticated', () => {
|
||||
|
||||
@@ -4,6 +4,7 @@ import crypto from 'crypto';
|
||||
import multer from 'multer';
|
||||
import path from 'path';
|
||||
import fs from 'fs';
|
||||
import { z } from 'zod';
|
||||
import passport from './passport.routes';
|
||||
import { optionalAuth } from './passport.routes';
|
||||
import * as db from '../services/db/index.db';
|
||||
@@ -14,6 +15,7 @@ import { sanitizeFilename } from '../utils/stringUtils';
|
||||
import { logger } from '../services/logger.server';
|
||||
import { UserProfile, ExtractedCoreData } from '../types';
|
||||
import { flyerQueue } from '../services/queueService.server';
|
||||
import { validateRequest } from '../middleware/validation.middleware';
|
||||
|
||||
const router = Router();
|
||||
|
||||
@@ -24,6 +26,30 @@ interface FlyerProcessPayload extends Partial<ExtractedCoreData> {
|
||||
data?: FlyerProcessPayload; // For nested data structures
|
||||
}
|
||||
|
||||
// --- Zod Schemas for AI Routes (as per ADR-003) ---
|
||||
|
||||
const uploadAndProcessSchema = z.object({
|
||||
body: z.object({
|
||||
checksum: z.string().min(1, 'File checksum is required.'),
|
||||
}),
|
||||
});
|
||||
|
||||
const jobIdParamSchema = z.object({
|
||||
params: z.object({
|
||||
jobId: z.string().min(1, 'A valid Job ID is required.'),
|
||||
}),
|
||||
});
|
||||
|
||||
const rescanAreaSchema = z.object({
|
||||
body: z.object({
|
||||
cropArea: z.string().transform((val, ctx) => {
|
||||
try { return JSON.parse(val); }
|
||||
catch (e) { ctx.addIssue({ code: z.ZodIssueCode.custom, message: 'cropArea must be a valid JSON string.' }); return z.NEVER; }
|
||||
}),
|
||||
extractionType: z.string().min(1, 'extractionType is required.'),
|
||||
}),
|
||||
});
|
||||
|
||||
// Helper to safely extract an error message from unknown `catch` values.
|
||||
const errMsg = (e: unknown) => {
|
||||
if (e instanceof Error) return e.message;
|
||||
@@ -76,17 +102,13 @@ router.use((req: Request, res: Response, next: NextFunction) => {
|
||||
* 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'), 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.' });
|
||||
}
|
||||
|
||||
const { checksum } = req.body;
|
||||
if (!checksum) {
|
||||
return res.status(400).json({ message: 'File checksum is required.' });
|
||||
}
|
||||
|
||||
// Check for duplicate flyer using checksum before even creating a job
|
||||
const existingFlyer = await db.flyerRepo.findFlyerByChecksum(checksum);
|
||||
if (existingFlyer) {
|
||||
@@ -134,7 +156,7 @@ router.post('/upload-and-process', optionalAuth, uploadToDisk.single('flyerFile'
|
||||
/**
|
||||
* NEW ENDPOINT: Checks the status of a background job.
|
||||
*/
|
||||
router.get('/jobs/:jobId/status', async (req, res, next: NextFunction) => {
|
||||
router.get('/jobs/:jobId/status', validateRequest(jobIdParamSchema), async (req, res, next: NextFunction) => {
|
||||
const { jobId } = req.params;
|
||||
try {
|
||||
const job = await flyerQueue.getJob(jobId);
|
||||
@@ -394,16 +416,12 @@ router.post('/generate-speech', passport.authenticate('jwt', { session: false })
|
||||
router.post(
|
||||
'/rescan-area',
|
||||
passport.authenticate('jwt', { session: false }),
|
||||
uploadToDisk.single('image'),
|
||||
uploadToDisk.single('image'), validateRequest(rescanAreaSchema),
|
||||
async (req, res, next: NextFunction) => {
|
||||
try {
|
||||
if (!req.file) {
|
||||
return res.status(400).json({ message: 'Image file is required.' });
|
||||
}
|
||||
if (!req.body.cropArea || !req.body.extractionType) {
|
||||
return res.status(400).json({ message: 'cropArea and extractionType are required.' });
|
||||
}
|
||||
|
||||
const cropArea = JSON.parse(req.body.cropArea);
|
||||
const { extractionType } = req.body;
|
||||
const { path, mimetype } = req.file;
|
||||
|
||||
@@ -229,15 +229,6 @@ describe('Auth Routes (/api/auth)', () => {
|
||||
expect(db.userRepo.createUser).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should reject registration if email or password are not provided', async () => {
|
||||
const response = await supertest(app)
|
||||
.post('/api/auth/register')
|
||||
.send({ email: newUserEmail });
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
expect(response.body.message).toBe('Email and password are required.');
|
||||
});
|
||||
|
||||
it('should return 500 if a generic database error occurs during registration', async () => {
|
||||
const dbError = new Error('DB connection lost');
|
||||
vi.mocked(db.userRepo.createUser).mockRejectedValue(dbError);
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
import { Router, Request, Response, NextFunction } from 'express';
|
||||
import * as bcrypt from 'bcrypt';
|
||||
import zxcvbn from 'zxcvbn';
|
||||
import { z } from 'zod';
|
||||
import jwt from 'jsonwebtoken';
|
||||
import crypto from 'crypto';
|
||||
import rateLimit from 'express-rate-limit';
|
||||
@@ -12,6 +13,7 @@ import { UniqueConstraintError } from '../services/db/errors.db';
|
||||
import { getPool } from '../services/db/connection.db';
|
||||
import { logger } from '../services/logger.server';
|
||||
import { sendPasswordResetEmail } from '../services/emailService.server';
|
||||
import { validateRequest } from '../middleware/validation.middleware';
|
||||
import { UserProfile } from '../types';
|
||||
|
||||
const router = Router();
|
||||
@@ -57,23 +59,40 @@ const resetPasswordLimiter = rateLimit({
|
||||
skip: () => isTestEnv, // Skip this middleware if in test environment
|
||||
});
|
||||
|
||||
// --- Zod Schemas for Auth Routes (as per ADR-003) ---
|
||||
|
||||
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 });
|
||||
}),
|
||||
full_name: z.string().optional(),
|
||||
avatar_url: z.string().url().optional(),
|
||||
}),
|
||||
});
|
||||
|
||||
const forgotPasswordSchema = z.object({
|
||||
body: z.object({ email: z.string().email('A valid email is required.') }),
|
||||
});
|
||||
|
||||
const resetPasswordSchema = z.object({
|
||||
body: z.object({
|
||||
token: z.string().min(1, '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 });
|
||||
}),
|
||||
}),
|
||||
});
|
||||
|
||||
// --- Authentication Routes ---
|
||||
|
||||
// Registration Route
|
||||
router.post('/register', async (req, res, next) => {
|
||||
router.post('/register', validateRequest(registerSchema), async (req, res, next) => {
|
||||
const { email, password, full_name, avatar_url } = req.body;
|
||||
|
||||
if (!email || !password) {
|
||||
return res.status(400).json({ message: 'Email and password are required.' });
|
||||
}
|
||||
|
||||
// --- Password Strength Check ---
|
||||
const passwordValidation = validatePasswordStrength(password);
|
||||
if (!passwordValidation.isValid) {
|
||||
logger.warn(`Weak password rejected during registration for email: ${email}.`);
|
||||
return res.status(400).json({ message: passwordValidation.feedback });
|
||||
}
|
||||
|
||||
try {
|
||||
const saltRounds = 10;
|
||||
const hashedPassword = await bcrypt.hash(password, saltRounds);
|
||||
@@ -171,11 +190,8 @@ router.post('/login', (req: Request, res: Response, next: NextFunction) => {
|
||||
});
|
||||
|
||||
// Route to request a password reset
|
||||
router.post('/forgot-password', forgotPasswordLimiter, async (req, res, next) => {
|
||||
router.post('/forgot-password', forgotPasswordLimiter, validateRequest(forgotPasswordSchema), async (req, res, next) => {
|
||||
const { email } = req.body;
|
||||
if (!email) {
|
||||
return res.status(400).json({ message: 'Email is required.' });
|
||||
}
|
||||
|
||||
try {
|
||||
logger.debug(`[API /forgot-password] Received request for email: ${email}`);
|
||||
@@ -215,11 +231,8 @@ router.post('/forgot-password', forgotPasswordLimiter, async (req, res, next) =>
|
||||
});
|
||||
|
||||
// Route to reset the password using a token
|
||||
router.post('/reset-password', resetPasswordLimiter, async (req, res, next) => {
|
||||
router.post('/reset-password', resetPasswordLimiter, validateRequest(resetPasswordSchema), async (req, res, next) => {
|
||||
const { token, newPassword } = req.body;
|
||||
if (!token || !newPassword) {
|
||||
return res.status(400).json({ message: 'Token and new password are required.' });
|
||||
}
|
||||
|
||||
try {
|
||||
const validTokens = await userRepo.getValidResetTokens();
|
||||
@@ -236,12 +249,6 @@ router.post('/reset-password', resetPasswordLimiter, async (req, res, next) => {
|
||||
return res.status(400).json({ message: 'Invalid or expired password reset token.' });
|
||||
}
|
||||
|
||||
const passwordValidation = validatePasswordStrength(newPassword);
|
||||
if (!passwordValidation.isValid) {
|
||||
logger.warn(`Weak password rejected during password reset for user ID: ${tokenRecord.user_id}.`);
|
||||
return res.status(400).json({ message: passwordValidation.feedback });
|
||||
}
|
||||
|
||||
const saltRounds = 10;
|
||||
const hashedPassword = await bcrypt.hash(newPassword, saltRounds);
|
||||
|
||||
|
||||
@@ -107,14 +107,6 @@ describe('Budget Routes (/api/budgets)', () => {
|
||||
expect(response.body.message).toBe('User not found');
|
||||
});
|
||||
|
||||
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');
|
||||
});
|
||||
|
||||
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'));
|
||||
@@ -144,12 +136,6 @@ describe('Budget Routes (/api/budgets)', () => {
|
||||
expect(response.body.message).toBe('Budget not found');
|
||||
});
|
||||
|
||||
it('should return 400 for a non-numeric budget ID', async () => {
|
||||
const response = await supertest(app).put('/api/budgets/abc').send({ amount_cents: 1 });
|
||||
expect(response.status).toBe(400);
|
||||
expect(response.body.message).toBe("Invalid ID for parameter 'id'. Must be a number.");
|
||||
});
|
||||
|
||||
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'));
|
||||
@@ -177,12 +163,6 @@ describe('Budget Routes (/api/budgets)', () => {
|
||||
expect(response.body.message).toBe('Budget not found');
|
||||
});
|
||||
|
||||
it('should return 400 for a non-numeric budget ID', async () => {
|
||||
const response = await supertest(app).delete('/api/budgets/abc');
|
||||
expect(response.status).toBe(400);
|
||||
expect(response.body.message).toBe("Invalid ID for parameter 'id'. Must be a number.");
|
||||
});
|
||||
|
||||
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');
|
||||
@@ -203,13 +183,6 @@ describe('Budget Routes (/api/budgets)', () => {
|
||||
expect(response.body).toEqual(mockSpendingData);
|
||||
});
|
||||
|
||||
it('should return 400 if startDate is missing', async () => {
|
||||
const response = await supertest(app).get('/api/budgets/spending-analysis?endDate=2024-01-31');
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
expect(response.body.message).toBe('Both startDate and endDate query parameters are required.');
|
||||
});
|
||||
|
||||
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'));
|
||||
|
||||
@@ -1,17 +1,45 @@
|
||||
// src/routes/budget.ts
|
||||
import express, { Request, Response, NextFunction } from 'express';
|
||||
import crypto from 'crypto';
|
||||
import { z } from 'zod';
|
||||
import passport from './passport.routes';
|
||||
import {
|
||||
budgetRepo } from '../services/db/index.db';
|
||||
import { budgetRepo } from '../services/db/index.db';
|
||||
import { logger } from '../services/logger.server';
|
||||
import { UserProfile } from '../types';
|
||||
import { ForeignKeyConstraintError } from '../services/db/errors.db';
|
||||
import { validateNumericParams } from './middleware/validation.middleware';
|
||||
|
||||
import { validateRequest } from '../middleware/validation.middleware';
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
// --- Zod Schemas for Budget Routes (as per ADR-003) ---
|
||||
|
||||
const budgetIdParamSchema = z.object({
|
||||
params: z.object({
|
||||
id: z.coerce.number().int().positive("Invalid ID for parameter 'id'. Must be a number."),
|
||||
}),
|
||||
});
|
||||
|
||||
const createBudgetSchema = z.object({
|
||||
body: z.object({
|
||||
name: z.string().min(1, 'Budget name is required.'),
|
||||
amount_cents: z.number().int().positive('Amount must be a positive integer.'),
|
||||
period: z.enum(['weekly', 'monthly', 'yearly']),
|
||||
start_date: z.string().date('Start date must be a valid date in YYYY-MM-DD format.'),
|
||||
}),
|
||||
});
|
||||
|
||||
const updateBudgetSchema = budgetIdParamSchema.extend({
|
||||
body: createBudgetSchema.shape.body.partial().refine(data => Object.keys(data).length > 0, {
|
||||
message: 'At least one field to update must be provided.',
|
||||
}),
|
||||
});
|
||||
|
||||
const spendingAnalysisSchema = z.object({
|
||||
query: z.object({
|
||||
startDate: z.string().date('startDate must be a valid date in YYYY-MM-DD format.'),
|
||||
endDate: z.string().date('endDate must be a valid date in YYYY-MM-DD format.'),
|
||||
}),
|
||||
});
|
||||
|
||||
// Middleware to ensure user is authenticated for all budget routes
|
||||
router.use(passport.authenticate('jwt', { session: false }));
|
||||
|
||||
@@ -32,7 +60,7 @@ router.get('/', async (req, res, next: NextFunction) => {
|
||||
/**
|
||||
* POST /api/budgets - Create a new budget for the authenticated user.
|
||||
*/
|
||||
router.post('/', async (req, res, next: NextFunction) => {
|
||||
router.post('/', validateRequest(createBudgetSchema), async (req, res, next: NextFunction) => {
|
||||
const user = req.user as UserProfile;
|
||||
try {
|
||||
const newBudget = await budgetRepo.createBudget(user.user_id, req.body);
|
||||
@@ -49,9 +77,9 @@ router.post('/', async (req, res, next: NextFunction) => {
|
||||
/**
|
||||
* PUT /api/budgets/:id - Update an existing budget.
|
||||
*/
|
||||
router.put('/:id', validateNumericParams(['id']), async (req, res, next: NextFunction) => {
|
||||
router.put('/:id', validateRequest(updateBudgetSchema), async (req, res, next: NextFunction) => {
|
||||
const user = req.user as UserProfile;
|
||||
const budgetId = parseInt(req.params.id, 10);
|
||||
const budgetId = req.params.id as unknown as number;
|
||||
try {
|
||||
const updatedBudget = await budgetRepo.updateBudget(budgetId, user.user_id, req.body);
|
||||
res.json(updatedBudget);
|
||||
@@ -64,9 +92,9 @@ router.put('/:id', validateNumericParams(['id']), async (req, res, next: NextFun
|
||||
/**
|
||||
* DELETE /api/budgets/:id - Delete a budget.
|
||||
*/
|
||||
router.delete('/:id', validateNumericParams(['id']), async (req, res, next: NextFunction) => {
|
||||
router.delete('/:id', validateRequest(budgetIdParamSchema), async (req, res, next: NextFunction) => {
|
||||
const user = req.user as UserProfile;
|
||||
const budgetId = parseInt(req.params.id, 10);
|
||||
const budgetId = req.params.id as unknown as number;
|
||||
try {
|
||||
await budgetRepo.deleteBudget(budgetId, user.user_id);
|
||||
res.status(204).send(); // No Content
|
||||
@@ -80,16 +108,12 @@ router.delete('/:id', validateNumericParams(['id']), async (req, res, next: Next
|
||||
* 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', async (req, res, next: NextFunction) => {
|
||||
router.get('/spending-analysis', validateRequest(spendingAnalysisSchema), async (req, res, next: NextFunction) => {
|
||||
const user = req.user as UserProfile;
|
||||
const { startDate, endDate } = req.query;
|
||||
|
||||
if (!startDate || !endDate || typeof startDate !== 'string' || typeof endDate !== 'string') {
|
||||
return res.status(400).json({ message: 'Both startDate and endDate query parameters are required.' });
|
||||
}
|
||||
|
||||
try {
|
||||
const spendingData = await budgetRepo.getSpendingByCategory(user.user_id, startDate, endDate);
|
||||
const spendingData = await budgetRepo.getSpendingByCategory(user.user_id, startDate as string, endDate as string);
|
||||
res.json(spendingData);
|
||||
} catch (error) {
|
||||
logger.error('Error fetching spending analysis:', { error, userId: user.user_id, startDate, endDate });
|
||||
|
||||
@@ -171,18 +171,6 @@ describe('Gamification Routes (/api/achievements)', () => {
|
||||
expect(db.gamificationRepo.awardAchievement).toHaveBeenCalledWith(awardPayload.userId, awardPayload.achievementName);
|
||||
});
|
||||
|
||||
it('should return 400 if userId is missing', async () => {
|
||||
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(app).post('/api/achievements/award').send({ achievementName: 'Test Award' });
|
||||
expect(response.status).toBe(400);
|
||||
expect(response.body.message).toBe('Both userId and achievementName are required.');
|
||||
});
|
||||
|
||||
it('should return 500 if the database call fails', async () => {
|
||||
mockedAuthMiddleware.mockImplementation((req: Request, res: Response, next: NextFunction) => {
|
||||
req.user = mockAdminProfile;
|
||||
|
||||
@@ -1,15 +1,31 @@
|
||||
// src/routes/gamification.routes.ts
|
||||
import express, { Request, Response, NextFunction } from 'express';
|
||||
import crypto from 'crypto';
|
||||
import { z } from 'zod';
|
||||
import passport, { isAdmin } from './passport.routes';
|
||||
import { gamificationRepo } from '../services/db/index.db';
|
||||
import { logger } from '../services/logger.server';
|
||||
import { UserProfile } from '../types';
|
||||
import { ForeignKeyConstraintError } from '../services/db/errors.db';
|
||||
import { validateRequest } from '../middleware/validation.middleware';
|
||||
|
||||
const router = express.Router();
|
||||
const adminGamificationRouter = express.Router(); // Create a new router for admin-only routes.
|
||||
|
||||
// --- Zod Schemas for Gamification Routes (as per ADR-003) ---
|
||||
|
||||
const leaderboardSchema = z.object({
|
||||
query: z.object({
|
||||
limit: z.coerce.number().int().positive().max(50).optional().default(10),
|
||||
}),
|
||||
});
|
||||
|
||||
const awardAchievementSchema = z.object({
|
||||
body: z.object({
|
||||
userId: z.string().min(1, 'userId is required.'),
|
||||
achievementName: z.string().min(1, 'achievementName is required.'),
|
||||
}),
|
||||
});
|
||||
|
||||
// --- Public Routes ---
|
||||
|
||||
/**
|
||||
@@ -30,11 +46,9 @@ 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', async (req, res, next: NextFunction) => {
|
||||
// Allow client to specify a limit, but default to 10 and cap it at 50.
|
||||
const limit = Math.min(parseInt(req.query.limit as string, 10) || 10, 50);
|
||||
|
||||
try {
|
||||
router.get('/leaderboard', validateRequest(leaderboardSchema), async (req, res, next: NextFunction) => {
|
||||
try { // The `limit` is coerced to a number by Zod, but TypeScript doesn't know that yet.
|
||||
const { limit } = req.query as unknown as { limit: number };
|
||||
const leaderboard = await gamificationRepo.getLeaderboard(limit);
|
||||
res.json(leaderboard);
|
||||
} catch (error) {
|
||||
@@ -75,13 +89,10 @@ adminGamificationRouter.use(passport.authenticate('jwt', { session: false }), is
|
||||
*/
|
||||
adminGamificationRouter.post(
|
||||
'/award',
|
||||
validateRequest(awardAchievementSchema),
|
||||
async (req, res, next: NextFunction) => {
|
||||
const { userId, achievementName } = req.body;
|
||||
|
||||
if (!userId || !achievementName) {
|
||||
return res.status(400).json({ message: 'Both userId and achievementName are required.' });
|
||||
}
|
||||
|
||||
try {
|
||||
await gamificationRepo.awardAchievement(userId, achievementName);
|
||||
res.status(200).json({ message: `Successfully awarded '${achievementName}' to user ${userId}.` });
|
||||
|
||||
@@ -1,33 +0,0 @@
|
||||
// src/routes/middleware/validation.middleware.ts
|
||||
import type { Request, Response, NextFunction } from 'express';
|
||||
import { ValidationError } from '../../services/db/errors.db';
|
||||
|
||||
/**
|
||||
* A simple validation middleware to check for required fields in the request body.
|
||||
* @param requiredFields An array of field names that must be present.
|
||||
*/
|
||||
export const validateBody = (requiredFields: string[]) => {
|
||||
return (req: Request, res: Response, next: NextFunction) => {
|
||||
for (const field of requiredFields) {
|
||||
if (!req.body[field]) {
|
||||
return next(new ValidationError(`Field '${field}' is required.`));
|
||||
}
|
||||
}
|
||||
next();
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* A middleware to validate that one or more route parameters are numeric.
|
||||
* @param params An array of parameter names to validate.
|
||||
*/
|
||||
export const validateNumericParams = (params: string[]) => {
|
||||
return (req: Request, res: Response, next: NextFunction) => {
|
||||
for (const param of params) {
|
||||
if (isNaN(parseInt(req.params[param], 10))) {
|
||||
return next(new ValidationError(`Invalid ID for parameter '${param}'. Must be a number.`));
|
||||
}
|
||||
}
|
||||
next();
|
||||
};
|
||||
};
|
||||
41
src/routes/price.routes.test.ts
Normal file
41
src/routes/price.routes.test.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
// src/routes/price.routes.test.ts
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import supertest from 'supertest';
|
||||
import express from 'express';
|
||||
import priceRouter from './price.routes';
|
||||
import { errorHandler } from '../middleware/errorHandler';
|
||||
|
||||
// Mock the logger to keep test output clean
|
||||
vi.mock('../services/logger.server', () => ({
|
||||
logger: {
|
||||
info: vi.fn(),
|
||||
error: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
// Create a minimal Express app to host our router
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
app.use('/api/price-history', priceRouter);
|
||||
app.use(errorHandler);
|
||||
|
||||
describe('Price Routes (/api/price-history)', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('POST /', () => {
|
||||
it('should return 200 OK with an empty array for a valid request', async () => {
|
||||
const response = await supertest(app)
|
||||
.post('/api/price-history')
|
||||
.send({ masterItemIds: [1, 2, 3] });
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body).toEqual([]);
|
||||
});
|
||||
|
||||
// The zod validation middleware will catch invalid requests before they hit the handler.
|
||||
// We test this behavior in `validation.middleware.test.ts`.
|
||||
// Therefore, we don't need tests here for non-array bodies, empty arrays, etc.
|
||||
});
|
||||
});
|
||||
@@ -1,22 +1,26 @@
|
||||
// src/routes/price.routes.ts
|
||||
import { Router, Request, Response, NextFunction } from 'express';
|
||||
import { z } from 'zod';
|
||||
import { logger } from '../services/logger.server';
|
||||
import { validateRequest } from '../middleware/validation.middleware';
|
||||
|
||||
const router = Router();
|
||||
|
||||
const priceHistorySchema = z.object({
|
||||
body: z.object({
|
||||
masterItemIds: z.array(z.number().int().positive()).nonempty({
|
||||
message: 'masterItemIds must be a non-empty array of positive integers.',
|
||||
}),
|
||||
}),
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /api/price-history - Fetches historical price data for a given list of master item IDs.
|
||||
* This is a placeholder implementation.
|
||||
*/
|
||||
router.post('/', async (req: Request, res: Response, next: NextFunction) => {
|
||||
router.post('/', validateRequest(priceHistorySchema), async (req: Request, res: Response, next: NextFunction) => {
|
||||
const { masterItemIds } = req.body;
|
||||
|
||||
if (!Array.isArray(masterItemIds)) {
|
||||
return res.status(400).json({ message: 'masterItemIds must be an array.' });
|
||||
}
|
||||
|
||||
logger.info('[API /price-history] Received request for historical price data.', { itemCount: masterItemIds.length });
|
||||
|
||||
res.status(200).json([]);
|
||||
});
|
||||
|
||||
|
||||
@@ -58,17 +58,6 @@ describe('Recipe Routes (/api/recipes)', () => {
|
||||
expect(db.recipeRepo.getRecipesBySalePercentage).toHaveBeenCalledWith(50);
|
||||
});
|
||||
|
||||
it('should return 400 for an invalid minPercentage parameter', async () => {
|
||||
const response = await supertest(app).get('/api/recipes/by-sale-percentage?minPercentage=abc');
|
||||
expect(response.status).toBe(400);
|
||||
});
|
||||
|
||||
it('should return 400 if minPercentage is out of range', async () => {
|
||||
const response = await supertest(app).get('/api/recipes/by-sale-percentage?minPercentage=101');
|
||||
expect(response.status).toBe(400);
|
||||
expect(response.body.message).toContain('must be a number between 0 and 100');
|
||||
});
|
||||
|
||||
it('should return 500 if the database call fails', async () => {
|
||||
vi.mocked(db.recipeRepo.getRecipesBySalePercentage).mockRejectedValue(new Error('DB Error'));
|
||||
const response = await supertest(app).get('/api/recipes/by-sale-percentage');
|
||||
@@ -91,17 +80,6 @@ describe('Recipe Routes (/api/recipes)', () => {
|
||||
expect(db.recipeRepo.getRecipesByMinSaleIngredients).toHaveBeenCalledWith(5);
|
||||
});
|
||||
|
||||
it('should return 400 for an invalid minIngredients parameter', async () => {
|
||||
const response = await supertest(app).get('/api/recipes/by-sale-ingredients?minIngredients=abc');
|
||||
expect(response.status).toBe(400);
|
||||
expect(response.body.message).toBe('Query parameter "minIngredients" must be a positive integer.');
|
||||
});
|
||||
|
||||
it('should return 400 if minIngredients is less than 1', async () => {
|
||||
const response = await supertest(app).get('/api/recipes/by-sale-ingredients?minIngredients=0');
|
||||
expect(response.status).toBe(400);
|
||||
});
|
||||
|
||||
it('should return 500 if the database call fails', async () => {
|
||||
vi.mocked(db.recipeRepo.getRecipesByMinSaleIngredients).mockRejectedValue(new Error('DB Error'));
|
||||
const response = await supertest(app).get('/api/recipes/by-sale-ingredients');
|
||||
@@ -121,17 +99,6 @@ describe('Recipe Routes (/api/recipes)', () => {
|
||||
expect(response.body).toEqual(mockRecipes);
|
||||
});
|
||||
|
||||
it('should return 400 if a query parameter is missing', async () => {
|
||||
const response = await supertest(app).get('/api/recipes/by-ingredient-and-tag?ingredient=chicken');
|
||||
expect(response.status).toBe(400);
|
||||
expect(response.body.message).toBe('Both "ingredient" and "tag" query parameters are required.');
|
||||
});
|
||||
|
||||
it('should return 400 if ingredient parameter is missing', async () => {
|
||||
const response = await supertest(app).get('/api/recipes/by-ingredient-and-tag?tag=quick');
|
||||
expect(response.status).toBe(400);
|
||||
});
|
||||
|
||||
it('should return 500 if the database call fails', async () => {
|
||||
vi.mocked(db.recipeRepo.findRecipesByIngredientAndTag).mockRejectedValue(new Error('DB Error'));
|
||||
const response = await supertest(app)
|
||||
@@ -153,12 +120,6 @@ describe('Recipe Routes (/api/recipes)', () => {
|
||||
expect(db.recipeRepo.getRecipeComments).toHaveBeenCalledWith(1);
|
||||
});
|
||||
|
||||
it('should return 400 for an invalid recipe ID', async () => {
|
||||
const response = await supertest(app).get('/api/recipes/abc/comments');
|
||||
expect(response.status).toBe(400);
|
||||
expect(response.body.message).toBe('Invalid recipe ID provided.');
|
||||
});
|
||||
|
||||
it('should return an empty array if recipe has no comments', async () => {
|
||||
vi.mocked(db.recipeRepo.getRecipeComments).mockResolvedValue([]);
|
||||
const response = await supertest(app).get('/api/recipes/2/comments');
|
||||
@@ -192,12 +153,6 @@ describe('Recipe Routes (/api/recipes)', () => {
|
||||
expect(response.body.message).toContain('not found');
|
||||
});
|
||||
|
||||
it('should return 400 for an invalid recipe ID', async () => {
|
||||
const response = await supertest(app).get('/api/recipes/xyz');
|
||||
expect(response.status).toBe(400);
|
||||
expect(response.body.message).toBe('Invalid recipe ID provided.');
|
||||
});
|
||||
|
||||
it('should return 500 if the database call fails', async () => {
|
||||
vi.mocked(db.recipeRepo.getRecipeById).mockRejectedValue(new Error('DB Error'));
|
||||
const response = await supertest(app).get('/api/recipes/456');
|
||||
|
||||
@@ -1,22 +1,45 @@
|
||||
// src/routes/recipe.routes.ts
|
||||
import { Router, type Request, type Response, type NextFunction } from 'express';
|
||||
import crypto from 'crypto';
|
||||
import { z } from 'zod';
|
||||
import * as db from '../services/db/index.db';
|
||||
import { logger } from '../services/logger.server';
|
||||
import { validateRequest } from '../middleware/validation.middleware';
|
||||
|
||||
const router = Router();
|
||||
|
||||
// --- Zod Schemas for Recipe Routes (as per ADR-003) ---
|
||||
|
||||
const bySalePercentageSchema = z.object({
|
||||
query: z.object({
|
||||
minPercentage: z.coerce.number().min(0).max(100).optional().default(50),
|
||||
}),
|
||||
});
|
||||
|
||||
const bySaleIngredientsSchema = z.object({
|
||||
query: z.object({
|
||||
minIngredients: z.coerce.number().int().positive().optional().default(3),
|
||||
}),
|
||||
});
|
||||
|
||||
const byIngredientAndTagSchema = z.object({
|
||||
query: z.object({
|
||||
ingredient: z.string().min(1, 'Query parameter "ingredient" is required.'),
|
||||
tag: z.string().min(1, 'Query parameter "tag" is required.'),
|
||||
}),
|
||||
});
|
||||
|
||||
const recipeIdParamsSchema = z.object({
|
||||
params: z.object({
|
||||
recipeId: z.coerce.number().int().positive(),
|
||||
}),
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /api/recipes/by-sale-percentage - Get recipes based on the percentage of their ingredients on sale.
|
||||
*/
|
||||
router.get('/by-sale-percentage', async (req: Request, res: Response, next: NextFunction) => {
|
||||
const minPercentageStr = req.query.minPercentage as string || '50.0';
|
||||
const minPercentage = parseFloat(minPercentageStr);
|
||||
|
||||
if (isNaN(minPercentage) || minPercentage < 0 || minPercentage > 100) {
|
||||
return res.status(400).json({ message: 'Query parameter "minPercentage" must be a number between 0 and 100.' });
|
||||
}
|
||||
try {
|
||||
router.get('/by-sale-percentage', validateRequest(bySalePercentageSchema), async (req: Request, res: Response, next: NextFunction) => {
|
||||
try { // The `minPercentage` is coerced to a number by Zod, but TypeScript doesn't know that yet.
|
||||
const { minPercentage } = req.query as unknown as { minPercentage: number };
|
||||
const recipes = await db.recipeRepo.getRecipesBySalePercentage(minPercentage);
|
||||
res.json(recipes);
|
||||
} catch (error) {
|
||||
@@ -28,14 +51,9 @@ router.get('/by-sale-percentage', async (req: Request, res: Response, next: Next
|
||||
/**
|
||||
* GET /api/recipes/by-sale-ingredients - Get recipes by the minimum number of sale ingredients.
|
||||
*/
|
||||
router.get('/by-sale-ingredients', async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const minIngredientsStr = req.query.minIngredients as string || '3';
|
||||
const minIngredients = parseInt(minIngredientsStr, 10);
|
||||
|
||||
if (isNaN(minIngredients) || minIngredients < 1) {
|
||||
return res.status(400).json({ message: 'Query parameter "minIngredients" must be a positive integer.' });
|
||||
}
|
||||
router.get('/by-sale-ingredients', validateRequest(bySaleIngredientsSchema), async (req: Request, res: Response, next: NextFunction) => {
|
||||
try { // The `minIngredients` is coerced to a number by Zod, but TypeScript doesn't know that yet.
|
||||
const { minIngredients } = req.query as unknown as { minIngredients: number };
|
||||
const recipes = await db.recipeRepo.getRecipesByMinSaleIngredients(minIngredients);
|
||||
res.json(recipes);
|
||||
} catch (error) {
|
||||
@@ -47,13 +65,10 @@ router.get('/by-sale-ingredients', async (req: Request, res: Response, next: Nex
|
||||
/**
|
||||
* GET /api/recipes/by-ingredient-and-tag - Find recipes by a specific ingredient and tag.
|
||||
*/
|
||||
router.get('/by-ingredient-and-tag', async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const { ingredient, tag } = req.query;
|
||||
if (!ingredient || !tag) {
|
||||
return res.status(400).json({ message: 'Both "ingredient" and "tag" query parameters are required.' });
|
||||
}
|
||||
const recipes = await db.recipeRepo.findRecipesByIngredientAndTag(ingredient as string, tag as string);
|
||||
router.get('/by-ingredient-and-tag', validateRequest(byIngredientAndTagSchema), async (req: Request, res: Response, next: NextFunction) => {
|
||||
try { // The query params are guaranteed to be strings by Zod, but TypeScript doesn't know that yet.
|
||||
const { ingredient, tag } = req.query as { ingredient: string, tag: string };
|
||||
const recipes = await db.recipeRepo.findRecipesByIngredientAndTag(ingredient, tag);
|
||||
res.json(recipes);
|
||||
} catch (error) {
|
||||
logger.error('Error fetching recipes in /api/recipes/by-ingredient-and-tag:', { error });
|
||||
@@ -64,12 +79,9 @@ router.get('/by-ingredient-and-tag', async (req: Request, res: Response, next: N
|
||||
/**
|
||||
* GET /api/recipes/:recipeId/comments - Get all comments for a specific recipe.
|
||||
*/
|
||||
router.get('/:recipeId/comments', async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const recipeId = parseInt(req.params.recipeId, 10);
|
||||
if (isNaN(recipeId)) {
|
||||
return res.status(400).json({ message: 'Invalid recipe ID provided.' });
|
||||
}
|
||||
router.get('/:recipeId/comments', validateRequest(recipeIdParamsSchema), async (req: Request, res: Response, next: NextFunction) => {
|
||||
try { // The `recipeId` is coerced to a number by Zod, but TypeScript doesn't know that yet.
|
||||
const { recipeId } = req.params as unknown as { recipeId: number };
|
||||
const comments = await db.recipeRepo.getRecipeComments(recipeId);
|
||||
res.json(comments);
|
||||
} catch (error) {
|
||||
@@ -81,12 +93,9 @@ router.get('/:recipeId/comments', async (req: Request, res: Response, next: Next
|
||||
/**
|
||||
* GET /api/recipes/:recipeId - Get a single recipe by its ID, including ingredients and tags.
|
||||
*/
|
||||
router.get('/:recipeId', async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const recipeId = parseInt(req.params.recipeId, 10);
|
||||
if (isNaN(recipeId)) {
|
||||
return res.status(400).json({ message: 'Invalid recipe ID provided.' });
|
||||
}
|
||||
router.get('/:recipeId', validateRequest(recipeIdParamsSchema), async (req: Request, res: Response, next: NextFunction) => {
|
||||
try { // The `recipeId` is coerced to a number by Zod, but TypeScript doesn't know that yet.
|
||||
const { recipeId } = req.params as unknown as { recipeId: number };
|
||||
const recipe = await db.recipeRepo.getRecipeById(recipeId);
|
||||
res.json(recipe);
|
||||
} catch (error) {
|
||||
|
||||
@@ -48,18 +48,6 @@ describe('Stats Routes (/api/stats)', () => {
|
||||
expect(db.adminRepo.getMostFrequentSaleItems).toHaveBeenCalledWith(90, 5);
|
||||
});
|
||||
|
||||
it('should return 400 for an invalid "days" parameter', async () => {
|
||||
const response = await supertest(app).get('/api/stats/most-frequent-sales?days=400');
|
||||
expect(response.status).toBe(400);
|
||||
expect(response.body.message).toBe('Query parameter "days" must be an integer between 1 and 365.');
|
||||
});
|
||||
|
||||
it('should return 400 for an invalid "limit" parameter', async () => {
|
||||
const response = await supertest(app).get('/api/stats/most-frequent-sales?limit=100');
|
||||
expect(response.status).toBe(400);
|
||||
expect(response.body.message).toBe('Query parameter "limit" must be an integer between 1 and 50.');
|
||||
});
|
||||
|
||||
it('should return 500 if the database call fails', async () => {
|
||||
vi.mocked(db.adminRepo.getMostFrequentSaleItems).mockRejectedValue(new Error('DB Error'));
|
||||
const response = await supertest(app).get('/api/stats/most-frequent-sales');
|
||||
|
||||
@@ -1,30 +1,28 @@
|
||||
// src/routes/stats.routes.ts
|
||||
import { Router, Request, Response, NextFunction } from 'express';
|
||||
import crypto from 'crypto';
|
||||
import { z } from 'zod';
|
||||
import * as db from '../services/db/index.db';
|
||||
import { logger } from '../services/logger.server';
|
||||
import { validateRequest } from '../middleware/validation.middleware';
|
||||
|
||||
const router = Router();
|
||||
|
||||
// --- Zod Schema for Stats Routes (as per ADR-003) ---
|
||||
|
||||
const mostFrequentSalesSchema = z.object({
|
||||
query: 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),
|
||||
}),
|
||||
});
|
||||
|
||||
/**
|
||||
* 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', async (req: Request, res: Response, next: NextFunction) => {
|
||||
router.get('/most-frequent-sales', validateRequest(mostFrequentSalesSchema), async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const daysStr = req.query.days as string || '30';
|
||||
const limitStr = req.query.limit as string || '10';
|
||||
|
||||
const days = parseInt(daysStr, 10);
|
||||
const limit = parseInt(limitStr, 10);
|
||||
|
||||
if (isNaN(days) || days < 1 || days > 365) {
|
||||
return res.status(400).json({ message: 'Query parameter "days" must be an integer between 1 and 365.' });
|
||||
}
|
||||
if (isNaN(limit) || limit < 1 || limit > 50) {
|
||||
return res.status(400).json({ message: 'Query parameter "limit" must be an integer between 1 and 50.' });
|
||||
}
|
||||
|
||||
const { days, limit } = req.query as unknown as { days: number, limit: number }; // Guaranteed to be valid numbers by the middleware
|
||||
const items = await db.adminRepo.getMostFrequentSaleItems(days, limit);
|
||||
res.json(items);
|
||||
} catch (error) {
|
||||
|
||||
@@ -72,8 +72,6 @@ describe('System Routes (/api/system)', () => {
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body).toEqual({ success: true, message: 'Application is online and running under PM2.' });
|
||||
});
|
||||
|
||||
// ... (rest of tests)
|
||||
|
||||
it('should return success: false when pm2 process is stopped or errored', async () => {
|
||||
const pm2StoppedOutput = `│ status │ stopped │`;
|
||||
@@ -91,22 +89,6 @@ describe('System Routes (/api/system)', () => {
|
||||
expect(response.body.success).toBe(false);
|
||||
});
|
||||
|
||||
it('should return success: false when pm2 process does not exist', async () => {
|
||||
vi.mocked(exec).mockImplementation((...args: any[]) => {
|
||||
const callback = args.find(arg => typeof arg === 'function');
|
||||
// Simulate PM2 error output
|
||||
callback(new Error('Command failed'), "[PM2][ERROR] Process doesn't exist", '');
|
||||
return {} as any;
|
||||
});
|
||||
|
||||
// Act
|
||||
const response = await supertest(app).get('/api/system/pm2-status');
|
||||
|
||||
// Assert
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body.success).toBe(false);
|
||||
});
|
||||
|
||||
it('should return 500 on a generic exec error', async () => {
|
||||
vi.mocked(exec).mockImplementation((...args: any[]) => {
|
||||
const callback = args.find(arg => typeof arg === 'function');
|
||||
@@ -141,16 +123,19 @@ describe('System Routes (/api/system)', () => {
|
||||
});
|
||||
|
||||
it('should return 404 if the address cannot be geocoded', async () => {
|
||||
// Arrange
|
||||
vi.mocked(geocodeAddress).mockResolvedValue(null);
|
||||
|
||||
// Act
|
||||
const response = await supertest(app)
|
||||
.post('/api/system/geocode')
|
||||
.send({ address: 'Invalid Address' });
|
||||
|
||||
// Assert
|
||||
expect(response.status).toBe(404);
|
||||
expect(response.body.message).toBe('Could not geocode the provided address.');
|
||||
});
|
||||
|
||||
it('should return 500 if the geocoding service throws an error', async () => {
|
||||
const geocodeError = new Error('Geocoding service unavailable');
|
||||
vi.mocked(geocodeAddress).mockRejectedValue(geocodeError);
|
||||
const response = await supertest(app).post('/api/system/geocode').send({ address: 'Any Address' });
|
||||
expect(response.status).toBe(500);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,11 +1,18 @@
|
||||
// src/routes/system.ts
|
||||
import { Router, Request, Response, NextFunction } from 'express';
|
||||
import { exec } from 'child_process';
|
||||
import { z } from 'zod';
|
||||
import { logger } from '../services/logger.server';
|
||||
import { geocodeAddress } from '../services/geocodingService.server';
|
||||
import { validateRequest } from '../middleware/validation.middleware';
|
||||
|
||||
const router = Router();
|
||||
|
||||
const geocodeSchema = z.object({
|
||||
body: z.object({
|
||||
address: z.string().min(1, 'An address string is required.'),
|
||||
}),
|
||||
});
|
||||
/**
|
||||
* Checks the status of the 'flyer-crawler-api' process managed by PM2.
|
||||
* This is intended for development and diagnostic purposes.
|
||||
@@ -42,11 +49,8 @@ router.get('/pm2-status', (req: Request, res: Response, next: NextFunction) => {
|
||||
* POST /api/system/geocode - Geocodes a given address string.
|
||||
* This acts as a secure proxy to the Google Maps Geocoding API.
|
||||
*/
|
||||
router.post('/geocode', async (req: Request, res: Response, next: NextFunction) => {
|
||||
router.post('/geocode', validateRequest(geocodeSchema), async (req: Request, res: Response, next: NextFunction) => {
|
||||
const { address } = req.body;
|
||||
if (!address || typeof address !== 'string') {
|
||||
return res.status(400).json({ message: 'An address string is required.' });
|
||||
}
|
||||
|
||||
try {
|
||||
const coordinates = await geocodeAddress(address);
|
||||
|
||||
@@ -148,13 +148,6 @@ describe('User Routes (/api/users)', () => {
|
||||
expect(response.status).toBe(404);
|
||||
expect(response.body.message).toContain('Profile not found');
|
||||
});
|
||||
|
||||
it('should return 500 if the database call fails', async () => {
|
||||
vi.mocked(db.userRepo.findUserProfileById).mockRejectedValue(new Error('DB Error'));
|
||||
const response = await supertest(app).get('/api/users/profile');
|
||||
expect(response.status).toBe(500);
|
||||
expect(response.body.message).toBe('DB Error');
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /watched-items', () => {
|
||||
@@ -165,13 +158,6 @@ describe('User Routes (/api/users)', () => {
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body).toEqual(mockItems);
|
||||
});
|
||||
|
||||
it('should return 500 if the database call fails', async () => {
|
||||
vi.mocked(db.personalizationRepo.getWatchedItems).mockRejectedValue(new Error('DB Error'));
|
||||
const response = await supertest(app).get('/api/users/watched-items');
|
||||
expect(response.status).toBe(500);
|
||||
expect(response.body.message).toBe('DB Error');
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST /watched-items', () => {
|
||||
@@ -185,15 +171,6 @@ describe('User Routes (/api/users)', () => {
|
||||
expect(response.status).toBe(201);
|
||||
expect(response.body).toEqual(mockAddedItem);
|
||||
});
|
||||
|
||||
it('should return 500 if the database call fails', async () => {
|
||||
vi.mocked(db.personalizationRepo.addWatchedItem).mockRejectedValue(new Error('DB Error'));
|
||||
const response = await supertest(app)
|
||||
.post('/api/users/watched-items')
|
||||
.send({ itemName: 'Failing Item', category: 'Errors' });
|
||||
expect(response.status).toBe(500);
|
||||
expect(response.body.message).toBe('DB Error');
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST /watched-items (Validation)', () => {
|
||||
@@ -229,13 +206,6 @@ describe('User Routes (/api/users)', () => {
|
||||
expect(response.status).toBe(204);
|
||||
expect(db.personalizationRepo.removeWatchedItem).toHaveBeenCalledWith(mockUserProfile.user_id, 99);
|
||||
});
|
||||
|
||||
it('should return 500 if the database call fails', async () => {
|
||||
vi.mocked(db.personalizationRepo.removeWatchedItem).mockRejectedValue(new Error('DB Error'));
|
||||
const response = await supertest(app).delete('/api/users/watched-items/99');
|
||||
expect(response.status).toBe(500);
|
||||
expect(response.body.message).toBe('DB Error');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Shopping List Routes', () => {
|
||||
@@ -271,15 +241,6 @@ describe('User Routes (/api/users)', () => {
|
||||
expect(response.body.message).toBe('User not found');
|
||||
});
|
||||
|
||||
it('should return 500 if the database call fails', async () => {
|
||||
vi.mocked(db.shoppingRepo.createShoppingList).mockRejectedValue(new Error('DB Error'));
|
||||
const response = await supertest(app)
|
||||
.post('/api/users/shopping-lists')
|
||||
.send({ name: 'New List' });
|
||||
expect(response.status).toBe(500);
|
||||
expect(response.body.message).toBe('DB Error');
|
||||
});
|
||||
|
||||
it('should return 400 for an invalid listId on DELETE', async () => {
|
||||
const response = await supertest(app).delete('/api/users/shopping-lists/abc');
|
||||
expect(response.status).toBe(400);
|
||||
@@ -301,9 +262,7 @@ describe('User Routes (/api/users)', () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Shopping List Item Routes', () => {
|
||||
it('POST /shopping-lists/:listId/items should add an item to a list', async () => {
|
||||
describe('Shopping List Item Routes', () => { 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 });
|
||||
@@ -322,19 +281,6 @@ describe('User Routes (/api/users)', () => {
|
||||
expect(response.status).toBe(400);
|
||||
});
|
||||
|
||||
it('should return 500 if DB fails when adding an item', async () => {
|
||||
vi.mocked(db.shoppingRepo.addShoppingListItem).mockRejectedValue(new Error('DB Error'));
|
||||
const response = await supertest(app).post('/api/users/shopping-lists/1/items').send({ customItemName: 'Test' });
|
||||
expect(response.status).toBe(500);
|
||||
expect(response.body.message).toBe('DB Error');
|
||||
});
|
||||
|
||||
it('should return 400 for an invalid listId on POST', async () => {
|
||||
const response = await supertest(app).post('/api/users/shopping-lists/abc/items').send({ customItemName: 'Test' });
|
||||
expect(response.status).toBe(400);
|
||||
expect(response.body.message).toBe("Invalid ID for parameter 'listId'. Must be a number.");
|
||||
});
|
||||
|
||||
it('PUT /shopping-lists/items/:itemId should update an item', async () => {
|
||||
const itemId = 101;
|
||||
const updates = { is_purchased: true, quantity: 2 };
|
||||
@@ -348,43 +294,12 @@ describe('User Routes (/api/users)', () => {
|
||||
expect(response.body).toEqual(mockUpdatedItem);
|
||||
});
|
||||
|
||||
it('should return 400 for an invalid itemId on PUT', async () => {
|
||||
const response = await supertest(app).put('/api/users/shopping-lists/items/abc').send({ is_purchased: true });
|
||||
expect(response.status).toBe(400);
|
||||
expect(response.body.message).toBe("Invalid ID for parameter 'itemId'. Must be a number.");
|
||||
});
|
||||
|
||||
it('should return 404 if item to update is not found', async () => {
|
||||
vi.mocked(db.shoppingRepo.updateShoppingListItem).mockRejectedValue(new Error('not found'));
|
||||
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);
|
||||
});
|
||||
|
||||
it('should return 500 if the database call fails', async () => {
|
||||
vi.mocked(db.shoppingRepo.updateShoppingListItem).mockRejectedValue(new Error('DB Error'));
|
||||
const response = await supertest(app)
|
||||
.put('/api/users/shopping-lists/items/101')
|
||||
.send({ is_purchased: true });
|
||||
expect(response.status).toBe(500);
|
||||
expect(response.body.message).toBe('DB Error');
|
||||
});
|
||||
|
||||
it('should return 500 if the database call fails', async () => {
|
||||
vi.mocked(db.shoppingRepo.updateShoppingListItem).mockRejectedValue(new Error('DB Error'));
|
||||
const response = await supertest(app)
|
||||
.put('/api/users/shopping-lists/items/101')
|
||||
.send({ is_purchased: true });
|
||||
expect(response.status).toBe(500);
|
||||
expect(response.body.message).toBe('DB Error');
|
||||
});
|
||||
|
||||
it('should return 400 for an invalid itemId on DELETE', async () => {
|
||||
const response = await supertest(app).delete('/api/users/shopping-lists/items/abc');
|
||||
expect(response.status).toBe(400);
|
||||
expect(response.body.message).toBe('Invalid item ID.');
|
||||
});
|
||||
|
||||
describe('DELETE /shopping-lists/items/:itemId', () => {
|
||||
it('should delete an item', async () => {
|
||||
vi.mocked(db.shoppingRepo.removeShoppingListItem).mockResolvedValue(undefined);
|
||||
@@ -393,7 +308,6 @@ describe('User Routes (/api/users)', () => {
|
||||
});
|
||||
|
||||
it('should return 404 if item to delete is not found', async () => {
|
||||
vi.mocked(db.shoppingRepo.removeShoppingListItem).mockRejectedValue(new Error('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);
|
||||
@@ -413,23 +327,6 @@ describe('User Routes (/api/users)', () => {
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body).toEqual(updatedProfile);
|
||||
});
|
||||
|
||||
it('should return 400 if no update fields are provided', async () => {
|
||||
const response = await supertest(app)
|
||||
.put('/api/users/profile')
|
||||
.send({});
|
||||
expect(response.status).toBe(400);
|
||||
expect(response.body.message).toBe('At least one field to update must be provided.');
|
||||
});
|
||||
|
||||
it('should return 500 if the database call fails', async () => {
|
||||
vi.mocked(db.userRepo.updateUserProfile).mockRejectedValue(new Error('DB Error'));
|
||||
const response = await supertest(app)
|
||||
.put('/api/users/profile')
|
||||
.send({ full_name: 'Failing Name' });
|
||||
expect(response.status).toBe(500);
|
||||
expect(response.body.message).toBe('DB Error');
|
||||
});
|
||||
});
|
||||
|
||||
describe('PUT /profile/password', () => {
|
||||
@@ -452,16 +349,6 @@ describe('User Routes (/api/users)', () => {
|
||||
expect(response.status).toBe(400);
|
||||
expect(response.body.message).toContain('New password is too weak.');
|
||||
});
|
||||
|
||||
it('should return 500 if the database call fails', async () => {
|
||||
vi.mocked(bcrypt.hash).mockResolvedValue('hashed-password' as never);
|
||||
vi.mocked(db.userRepo.updateUserPassword).mockRejectedValue(new Error('DB Error'));
|
||||
const response = await supertest(app)
|
||||
.put('/api/users/profile/password')
|
||||
.send({ newPassword: 'a-Very-Strong-Password-456!' });
|
||||
expect(response.status).toBe(500);
|
||||
expect(response.body.message).toBe('DB Error');
|
||||
});
|
||||
});
|
||||
|
||||
describe('DELETE /account', () => {
|
||||
@@ -507,23 +394,6 @@ describe('User Routes (/api/users)', () => {
|
||||
expect(response.status).toBe(404);
|
||||
expect(response.body.message).toBe('User not found or password not set.');
|
||||
});
|
||||
|
||||
it('should return 500 if the database call fails', async () => {
|
||||
vi.mocked(db.userRepo.findUserWithPasswordHashById).mockRejectedValue(new Error('DB Error'));
|
||||
const response = await supertest(app)
|
||||
.delete('/api/users/account')
|
||||
.send({ password: 'any-password' });
|
||||
expect(response.status).toBe(500);
|
||||
expect(response.body.message).toBe('DB Error');
|
||||
});
|
||||
|
||||
it('should return 400 if password is not provided', async () => {
|
||||
const response = await supertest(app)
|
||||
.delete('/api/users/account')
|
||||
.send({}); // Empty body
|
||||
expect(response.status).toBe(400);
|
||||
expect(response.body.message).toBe("Field 'password' is required.");
|
||||
});
|
||||
});
|
||||
|
||||
describe('User Preferences and Personalization', () => {
|
||||
@@ -561,26 +431,12 @@ describe('User Routes (/api/users)', () => {
|
||||
expect(response.body).toEqual(mockRestrictions);
|
||||
});
|
||||
|
||||
it('GET should return 500 if the database call fails', async () => {
|
||||
vi.mocked(db.personalizationRepo.getUserDietaryRestrictions).mockRejectedValue(new Error('DB Error'));
|
||||
const response = await supertest(app).get('/api/users/me/dietary-restrictions');
|
||||
expect(response.status).toBe(500);
|
||||
expect(response.body.message).toBe('DB 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);
|
||||
expect(response.body.message).toBe("Invalid ID for parameter 'masterItemId'. Must be a number.");
|
||||
});
|
||||
|
||||
it('GET should return 500 if the database call fails', async () => {
|
||||
vi.mocked(db.personalizationRepo.getUserDietaryRestrictions).mockRejectedValue(new Error('DB Error'));
|
||||
const response = await supertest(app).get('/api/users/me/dietary-restrictions');
|
||||
expect(response.status).toBe(500);
|
||||
expect(response.body.message).toBe('DB Error');
|
||||
});
|
||||
|
||||
it('PUT should successfully set the restrictions', async () => {
|
||||
vi.mocked(db.personalizationRepo.setUserDietaryRestrictions).mockResolvedValue(undefined);
|
||||
const restrictionIds = [1, 3, 5];
|
||||
@@ -590,24 +446,6 @@ describe('User Routes (/api/users)', () => {
|
||||
expect(response.status).toBe(204);
|
||||
});
|
||||
|
||||
it('PUT should return 500 if the database call fails', async () => {
|
||||
vi.mocked(db.personalizationRepo.setUserDietaryRestrictions).mockRejectedValue(new Error('DB Error'));
|
||||
const response = await supertest(app)
|
||||
.put('/api/users/me/dietary-restrictions')
|
||||
.send({ restrictionIds: [1] });
|
||||
expect(response.status).toBe(500);
|
||||
expect(response.body.message).toBe('DB Error');
|
||||
});
|
||||
|
||||
it('PUT should return 500 if the database call fails', async () => {
|
||||
vi.mocked(db.personalizationRepo.setUserDietaryRestrictions).mockRejectedValue(new Error('DB Error'));
|
||||
const response = await supertest(app)
|
||||
.put('/api/users/me/dietary-restrictions')
|
||||
.send({ restrictionIds: [1] });
|
||||
expect(response.status).toBe(500);
|
||||
expect(response.body.message).toBe('DB Error');
|
||||
});
|
||||
|
||||
it('PUT should return 400 on foreign key constraint error', async () => {
|
||||
vi.mocked(db.personalizationRepo.setUserDietaryRestrictions).mockRejectedValue(new ForeignKeyConstraintError('Invalid restriction ID'));
|
||||
const response = await supertest(app)
|
||||
@@ -626,13 +464,6 @@ describe('User Routes (/api/users)', () => {
|
||||
expect(response.body).toEqual(mockAppliances);
|
||||
});
|
||||
|
||||
it('GET should return 500 if the database call fails', async () => {
|
||||
vi.mocked(db.personalizationRepo.getUserAppliances).mockRejectedValue(new Error('DB Error'));
|
||||
const response = await supertest(app).get('/api/users/me/appliances');
|
||||
expect(response.status).toBe(500);
|
||||
expect(response.body.message).toBe('DB Error');
|
||||
});
|
||||
|
||||
it('PUT should successfully set the appliances', async () => {
|
||||
vi.mocked(db.personalizationRepo.setUserAppliances).mockResolvedValue([]);
|
||||
const applianceIds = [2, 4, 6];
|
||||
@@ -640,23 +471,6 @@ describe('User Routes (/api/users)', () => {
|
||||
expect(response.status).toBe(204);
|
||||
});
|
||||
|
||||
it('PUT should return 500 if the database call fails', async () => {
|
||||
vi.mocked(db.personalizationRepo.setUserAppliances).mockRejectedValue(new Error('DB Error'));
|
||||
const response = await supertest(app)
|
||||
.put('/api/users/me/appliances')
|
||||
.send({ applianceIds: [1] });
|
||||
expect(response.status).toBe(500);
|
||||
expect(response.body.message).toBe('DB Error');
|
||||
});
|
||||
|
||||
it('PUT should return 400 if applianceIds is not an array', async () => {
|
||||
const response = await supertest(app)
|
||||
.put('/api/users/me/appliances')
|
||||
.send({ applianceIds: 'not-an-array' });
|
||||
expect(response.status).toBe(400);
|
||||
expect(response.body.message).toBe('applianceIds must be an array.');
|
||||
});
|
||||
|
||||
it('PUT should return 400 on foreign key constraint error', async () => {
|
||||
vi.mocked(db.personalizationRepo.setUserAppliances).mockRejectedValue(new ForeignKeyConstraintError('Invalid appliance ID'));
|
||||
const response = await supertest(app)
|
||||
@@ -670,7 +484,7 @@ describe('User Routes (/api/users)', () => {
|
||||
|
||||
describe('Notification Routes', () => {
|
||||
it('GET /notifications should return notifications for the user', async () => {
|
||||
const mockNotifications: Notification[] = [{ notification_id: 1, user_id: 'user-123', content: 'Test', is_read: false, created_at: '' }];
|
||||
const mockNotifications: Notification[] = [{ notification_id: 1, user_id: 'user-123', content: 'Test', is_read: false, created_at: '', link_url: null }];
|
||||
vi.mocked(db.notificationRepo.getNotificationsForUser).mockResolvedValue(mockNotifications);
|
||||
|
||||
const response = await supertest(app).get('/api/users/notifications?limit=10&offset=0');
|
||||
@@ -680,20 +494,6 @@ describe('User Routes (/api/users)', () => {
|
||||
expect(db.notificationRepo.getNotificationsForUser).toHaveBeenCalledWith('user-123', 10, 0);
|
||||
});
|
||||
|
||||
it('GET /notifications should return 500 if the database call fails', async () => {
|
||||
vi.mocked(db.notificationRepo.getNotificationsForUser).mockRejectedValue(new Error('DB Error'));
|
||||
const response = await supertest(app).get('/api/users/notifications');
|
||||
expect(response.status).toBe(500);
|
||||
expect(response.body.message).toBe('DB Error');
|
||||
});
|
||||
|
||||
it('POST /notifications/mark-all-read should return 500 if the database call fails', async () => {
|
||||
vi.mocked(db.notificationRepo.markAllNotificationsAsRead).mockRejectedValue(new Error('DB Error'));
|
||||
const response = await supertest(app).post('/api/users/notifications/mark-all-read');
|
||||
expect(response.status).toBe(500);
|
||||
expect(response.body.message).toBe('DB Error');
|
||||
});
|
||||
|
||||
it('POST /notifications/mark-all-read should return 204', async () => {
|
||||
vi.mocked(db.notificationRepo.markAllNotificationsAsRead).mockResolvedValue(undefined);
|
||||
const response = await supertest(app).post('/api/users/notifications/mark-all-read');
|
||||
@@ -729,14 +529,6 @@ describe('User Routes (/api/users)', () => {
|
||||
expect(response.status).toBe(403);
|
||||
});
|
||||
|
||||
it('should return 500 if database call fails', async () => {
|
||||
const appWithUser = createApp({ ...mockUserProfile, address_id: 1 } as any);
|
||||
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);
|
||||
expect(response.body.message).toBe('DB Error');
|
||||
});
|
||||
|
||||
it('GET /addresses/:addressId should return 404 if address not found', async () => {
|
||||
const appWithUser = createApp({ ...mockUserProfile, address_id: 1 } as any);
|
||||
vi.mocked(db.addressRepo.getAddressById).mockResolvedValue(undefined);
|
||||
@@ -760,26 +552,6 @@ describe('User Routes (/api/users)', () => {
|
||||
expect(db.userRepo.updateUserProfile).toHaveBeenCalledWith('user-123', { address_id: 5 });
|
||||
});
|
||||
|
||||
it('should return 500 if upsertAddress fails', async () => {
|
||||
const appWithUser = createApp({ ...mockUserProfile, address_id: null } as any);
|
||||
const addressData = { address_line_1: '123 New St' };
|
||||
vi.mocked(db.addressRepo.upsertAddress).mockRejectedValue(new Error('DB Error'));
|
||||
const response = await supertest(appWithUser).put('/api/users/profile/address').send(addressData);
|
||||
expect(response.status).toBe(500);
|
||||
expect(response.body.message).toBe('DB Error');
|
||||
});
|
||||
|
||||
it('should return 500 if upsertAddress succeeds but updateUserProfile fails', async () => {
|
||||
const appWithUser = createApp({ ...mockUserProfile, address_id: null } as any);
|
||||
const addressData = { address_line_1: '123 Failing St' };
|
||||
vi.mocked(db.addressRepo.upsertAddress).mockResolvedValue(6); // upsert succeeds
|
||||
vi.mocked(db.userRepo.updateUserProfile).mockRejectedValue(new Error('DB link error')); // update fails
|
||||
|
||||
const response = await supertest(appWithUser).put('/api/users/profile/address').send(addressData);
|
||||
|
||||
expect(response.status).toBe(500);
|
||||
expect(response.body.message).toBe('DB link error');
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST /profile/avatar', () => {
|
||||
@@ -817,16 +589,6 @@ describe('User Routes (/api/users)', () => {
|
||||
expect(response.status).toBe(400);
|
||||
expect(response.body.message).toBe('No avatar file uploaded.');
|
||||
});
|
||||
|
||||
it('should return 500 if the database call fails', async () => {
|
||||
vi.mocked(bcrypt.hash).mockResolvedValue('hashed-password' as never);
|
||||
vi.mocked(db.userRepo.updateUserPassword).mockRejectedValue(new Error('DB Error'));
|
||||
const response = await supertest(app)
|
||||
.put('/api/users/profile/password')
|
||||
.send({ newPassword: 'a-Very-Strong-Password-456!' });
|
||||
expect(response.status).toBe(500);
|
||||
expect(response.body.message).toBe('DB Error');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Recipe Routes', () => {
|
||||
@@ -837,20 +599,6 @@ describe('User Routes (/api/users)', () => {
|
||||
expect(db.recipeRepo.deleteRecipe).toHaveBeenCalledWith(1, mockUserProfile.user_id, false);
|
||||
});
|
||||
|
||||
it('DELETE /recipes/:recipeId should return 500 if the database call fails', async () => {
|
||||
vi.mocked(db.recipeRepo.deleteRecipe).mockRejectedValue(new Error('DB Error'));
|
||||
const response = await supertest(app).delete('/api/users/recipes/1');
|
||||
expect(response.status).toBe(500);
|
||||
expect(response.body.message).toBe('DB Error');
|
||||
});
|
||||
|
||||
it('DELETE /recipes/:recipeId should return 404 if recipe to delete is not found', async () => {
|
||||
vi.mocked(db.recipeRepo.deleteRecipe).mockRejectedValue(new Error('not found'));
|
||||
vi.mocked(db.recipeRepo.deleteRecipe).mockRejectedValue(new NotFoundError('not found'));
|
||||
const response = await supertest(app).delete('/api/users/recipes/999');
|
||||
expect(response.status).toBe(404);
|
||||
});
|
||||
|
||||
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 };
|
||||
@@ -866,38 +614,17 @@ describe('User Routes (/api/users)', () => {
|
||||
});
|
||||
|
||||
it('PUT /recipes/:recipeId should return 404 if recipe not found', async () => {
|
||||
vi.mocked(db.recipeRepo.updateRecipe).mockRejectedValue(new Error('not found'));
|
||||
vi.mocked(db.recipeRepo.updateRecipe).mockRejectedValue(new NotFoundError('not found'));
|
||||
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 400 if no update fields are provided', async () => {
|
||||
vi.mocked(db.recipeRepo.updateRecipe).mockRejectedValue(new Error('No fields provided'));
|
||||
const response = await supertest(app).put('/api/users/recipes/1').send({});
|
||||
expect(response.status).toBe(400);
|
||||
});
|
||||
|
||||
it('PUT /recipes/:recipeId should return 500 if database call fails', async () => {
|
||||
vi.mocked(db.recipeRepo.updateRecipe).mockRejectedValue(new Error('DB Error'));
|
||||
const response = await supertest(app).put('/api/users/recipes/1').send({ name: 'New Name' });
|
||||
expect(response.status).toBe(500);
|
||||
expect(response.body.message).toBe('DB Error');
|
||||
});
|
||||
|
||||
it('GET /shopping-lists/:listId should return 500 if the database call fails', async () => {
|
||||
vi.mocked(db.shoppingRepo.getShoppingListById).mockRejectedValue(new Error('DB Error'));
|
||||
const response = await supertest(app).get('/api/users/shopping-lists/1');
|
||||
expect(response.status).toBe(500);
|
||||
expect(response.body.message).toBe('DB Error');
|
||||
});
|
||||
|
||||
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'));
|
||||
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');
|
||||
});
|
||||
});
|
||||
}); // End of Recipe Routes
|
||||
});
|
||||
});
|
||||
@@ -7,14 +7,47 @@ import fs from 'node:fs/promises';
|
||||
import * as bcrypt from 'bcrypt';
|
||||
import crypto from 'crypto';
|
||||
import zxcvbn from 'zxcvbn';
|
||||
import { z } from 'zod';
|
||||
import * as db from '../services/db/index.db';
|
||||
import { logger } from '../services/logger.server';
|
||||
import { User, UserProfile, Address } from '../types';
|
||||
import { ForeignKeyConstraintError } from '../services/db/errors.db';
|
||||
import { validateBody, validateNumericParams } from './middleware/validation.middleware';
|
||||
import { validateRequest } from '../middleware/validation.middleware';
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
// --- Zod Schemas for User Routes (as per ADR-003) ---
|
||||
|
||||
const numericIdParam = (key: string) => z.object({
|
||||
params: z.object({ [key]: z.coerce.number().int().positive(`Invalid ID for parameter '${key}'. Must be a number.`) }),
|
||||
});
|
||||
|
||||
const updateProfileSchema = z.object({
|
||||
body: z.object({
|
||||
full_name: z.string().optional(),
|
||||
avatar_url: z.string().url().optional(),
|
||||
}).refine(data => Object.keys(data).length > 0, { message: 'At least one field to update must be provided.' }),
|
||||
});
|
||||
|
||||
const updatePasswordSchema = z.object({
|
||||
body: z.object({
|
||||
newPassword: z.string().min(1, 'New password is required.'),
|
||||
}),
|
||||
});
|
||||
|
||||
const deleteAccountSchema = z.object({
|
||||
body: z.object({ password: z.string().min(1, "Field 'password' is required.") }),
|
||||
});
|
||||
|
||||
const addWatchedItemSchema = z.object({
|
||||
body: z.object({
|
||||
itemName: z.string().min(1, "Field 'itemName' is required."),
|
||||
category: z.string().min(1, "Field 'category' is required."),
|
||||
}),
|
||||
});
|
||||
|
||||
const createShoppingListSchema = z.object({ body: z.object({ name: z.string().min(1, "Field 'name' is required.") }) });
|
||||
|
||||
// Apply the JWT authentication middleware to all routes in this file.
|
||||
// Any request to a /api/users/* endpoint will now require a valid JWT.
|
||||
router.use(passport.authenticate('jwt', { session: false }));
|
||||
@@ -96,11 +129,10 @@ router.post(
|
||||
* POST /api/users/notifications/:notificationId/mark-read - Mark a single notification as read.
|
||||
*/
|
||||
router.post(
|
||||
'/notifications/:notificationId/mark-read',
|
||||
validateNumericParams(['notificationId']),
|
||||
'/notifications/:notificationId/mark-read', validateRequest(numericIdParam('notificationId')),
|
||||
async (req: Request, res: Response) => {
|
||||
const user = req.user as User;
|
||||
const notificationId = parseInt(req.params.notificationId, 10);
|
||||
const notificationId = req.params.notificationId as unknown as number;
|
||||
|
||||
await db.notificationRepo.markNotificationAsRead(notificationId, user.user_id);
|
||||
res.status(204).send(); // Success, no content to return
|
||||
@@ -130,17 +162,12 @@ router.get('/profile', async (req, res, next: NextFunction) => {
|
||||
/**
|
||||
* PUT /api/users/profile - Update the user's profile information.
|
||||
*/
|
||||
router.put('/profile', async (req, res, next: NextFunction) => {
|
||||
router.put('/profile', validateRequest(updateProfileSchema), async (req, res, next: NextFunction) => {
|
||||
logger.debug(`[ROUTE] PUT /api/users/profile - ENTER`);
|
||||
const user = req.user as UserProfile;
|
||||
const { full_name, avatar_url } = req.body;
|
||||
|
||||
if (!full_name && !avatar_url) {
|
||||
return res.status(400).json({ message: 'At least one field to update must be provided.' });
|
||||
}
|
||||
|
||||
try {
|
||||
const updatedProfile = await db.userRepo.updateUserProfile(user.user_id, { full_name, avatar_url });
|
||||
const updatedProfile = await db.userRepo.updateUserProfile(user.user_id, req.body);
|
||||
res.json(updatedProfile);
|
||||
} catch (error) {
|
||||
logger.error(`[ROUTE] PUT /api/users/profile - ERROR`, { error });
|
||||
@@ -151,7 +178,7 @@ router.put('/profile', async (req, res, next: NextFunction) => {
|
||||
/**
|
||||
* PUT /api/users/profile/password - Update the user's password.
|
||||
*/
|
||||
router.put('/profile/password', validateBody(['newPassword']), async (req, res, next: NextFunction) => {
|
||||
router.put('/profile/password', validateRequest(updatePasswordSchema), async (req, res, next: NextFunction) => {
|
||||
logger.debug(`[ROUTE] PUT /api/users/profile/password - ENTER`);
|
||||
const user = req.user as UserProfile;
|
||||
const { newPassword } = req.body;
|
||||
@@ -177,7 +204,7 @@ router.put('/profile/password', validateBody(['newPassword']), async (req, res,
|
||||
/**
|
||||
* DELETE /api/users/account - Delete the user's own account.
|
||||
*/
|
||||
router.delete('/account', validateBody(['password']), async (req, res, next: NextFunction) => {
|
||||
router.delete('/account', validateRequest(deleteAccountSchema), async (req, res, next: NextFunction) => {
|
||||
logger.debug(`[ROUTE] DELETE /api/users/account - ENTER`);
|
||||
const user = req.user as UserProfile;
|
||||
const { password } = req.body;
|
||||
@@ -219,7 +246,7 @@ router.get('/watched-items', async (req, res, next: NextFunction) => {
|
||||
/**
|
||||
* POST /api/users/watched-items - Add a new item to the user's watchlist.
|
||||
*/
|
||||
router.post('/watched-items', validateBody(['itemName', 'category']), async (req, res, next: NextFunction) => {
|
||||
router.post('/watched-items', validateRequest(addWatchedItemSchema), async (req, res, next: NextFunction) => {
|
||||
logger.debug(`[ROUTE] POST /api/users/watched-items - ENTER`);
|
||||
const user = req.user as UserProfile;
|
||||
const { itemName, category } = req.body;
|
||||
@@ -242,10 +269,10 @@ router.post('/watched-items', validateBody(['itemName', 'category']), async (req
|
||||
/**
|
||||
* DELETE /api/users/watched-items/:masterItemId - Remove an item from the watchlist.
|
||||
*/
|
||||
router.delete('/watched-items/:masterItemId', validateNumericParams(['masterItemId']), async (req, res, next: NextFunction) => {
|
||||
router.delete('/watched-items/:masterItemId', validateRequest(numericIdParam('masterItemId')), async (req, res, next: NextFunction) => {
|
||||
logger.debug(`[ROUTE] DELETE /api/users/watched-items/:masterItemId - ENTER`);
|
||||
const user = req.user as UserProfile;
|
||||
const masterItemId = parseInt(req.params.masterItemId, 10);
|
||||
const masterItemId = req.params.masterItemId as unknown as number;
|
||||
try {
|
||||
await db.personalizationRepo.removeWatchedItem(user.user_id, masterItemId);
|
||||
res.status(204).send();
|
||||
@@ -273,10 +300,10 @@ router.get('/shopping-lists', async (req, res, next: NextFunction) => {
|
||||
/**
|
||||
* GET /api/users/shopping-lists/:listId - Get a single shopping list by its ID.
|
||||
*/
|
||||
router.get('/shopping-lists/:listId', validateNumericParams(['listId']), async (req, res, next: NextFunction) => {
|
||||
router.get('/shopping-lists/:listId', validateRequest(numericIdParam('listId')), async (req, res, next: NextFunction) => {
|
||||
logger.debug(`[ROUTE] GET /api/users/shopping-lists/:listId - ENTER`);
|
||||
const user = req.user as UserProfile;
|
||||
const listId = parseInt(req.params.listId, 10);
|
||||
const listId = req.params.listId as unknown as number;
|
||||
|
||||
try {
|
||||
const list = await db.shoppingRepo.getShoppingListById(listId, user.user_id);
|
||||
@@ -293,7 +320,7 @@ router.get('/shopping-lists/:listId', validateNumericParams(['listId']), async (
|
||||
/**
|
||||
* POST /api/users/shopping-lists - Create a new shopping list.
|
||||
*/
|
||||
router.post('/shopping-lists', validateBody(['name']), async (req, res, next: NextFunction) => {
|
||||
router.post('/shopping-lists', validateRequest(createShoppingListSchema), async (req, res, next: NextFunction) => {
|
||||
logger.debug(`[ROUTE] POST /api/users/shopping-lists - ENTER`);
|
||||
const user = req.user as UserProfile;
|
||||
const { name } = req.body;
|
||||
@@ -316,10 +343,10 @@ router.post('/shopping-lists', validateBody(['name']), async (req, res, next: Ne
|
||||
/**
|
||||
* DELETE /api/users/shopping-lists/:listId - Delete a shopping list.
|
||||
*/
|
||||
router.delete('/shopping-lists/:listId', validateNumericParams(['listId']), async (req, res, next: NextFunction) => {
|
||||
router.delete('/shopping-lists/:listId', validateRequest(numericIdParam('listId')), async (req, res, next: NextFunction) => {
|
||||
logger.debug(`[ROUTE] DELETE /api/users/shopping-lists/:listId - ENTER`);
|
||||
const user = req.user as UserProfile;
|
||||
const listId = parseInt(req.params.listId, 10);
|
||||
const listId = req.params.listId as unknown as number;
|
||||
try {
|
||||
await db.shoppingRepo.deleteShoppingList(listId, user.user_id);
|
||||
res.status(204).send();
|
||||
@@ -333,9 +360,14 @@ router.delete('/shopping-lists/:listId', validateNumericParams(['listId']), asyn
|
||||
/**
|
||||
* POST /api/users/shopping-lists/:listId/items - Add an item to a shopping list.
|
||||
*/
|
||||
router.post('/shopping-lists/:listId/items', validateNumericParams(['listId']), async (req, res, next: NextFunction) => {
|
||||
router.post('/shopping-lists/:listId/items', validateRequest(numericIdParam('listId').extend({
|
||||
body: z.object({
|
||||
masterItemId: z.number().int().positive().optional(),
|
||||
customItemName: z.string().min(1).optional(),
|
||||
}).refine(data => data.masterItemId || data.customItemName, { message: 'Either masterItemId or customItemName must be provided.' }),
|
||||
})), async (req, res, next: NextFunction) => {
|
||||
logger.debug(`[ROUTE] POST /api/users/shopping-lists/:listId/items - ENTER`);
|
||||
const listId = parseInt(req.params.listId, 10);
|
||||
const listId = req.params.listId as unknown as number;
|
||||
try {
|
||||
const newItem = await db.shoppingRepo.addShoppingListItem(listId, req.body);
|
||||
res.status(201).json(newItem);
|
||||
@@ -355,9 +387,14 @@ router.post('/shopping-lists/:listId/items', validateNumericParams(['listId']),
|
||||
/**
|
||||
* PUT /api/users/shopping-lists/items/:itemId - Update a shopping list item.
|
||||
*/
|
||||
router.put('/shopping-lists/items/:itemId', validateNumericParams(['itemId']), async (req, res, next: NextFunction) => {
|
||||
router.put('/shopping-lists/items/:itemId', validateRequest(numericIdParam('itemId').extend({
|
||||
body: z.object({
|
||||
quantity: z.number().int().nonnegative().optional(),
|
||||
is_purchased: z.boolean().optional(),
|
||||
}).refine(data => Object.keys(data).length > 0, { message: 'At least one field (quantity, is_purchased) must be provided.' }),
|
||||
})), async (req, res, next: NextFunction) => {
|
||||
logger.debug(`[ROUTE] PUT /api/users/shopping-lists/items/:itemId - ENTER`);
|
||||
const itemId = parseInt(req.params.itemId, 10);
|
||||
const itemId = req.params.itemId as unknown as number;
|
||||
try {
|
||||
const updatedItem = await db.shoppingRepo.updateShoppingListItem(itemId, req.body);
|
||||
res.json(updatedItem);
|
||||
@@ -370,9 +407,9 @@ router.put('/shopping-lists/items/:itemId', validateNumericParams(['itemId']), a
|
||||
/**
|
||||
* DELETE /api/users/shopping-lists/items/:itemId - Remove an item from a shopping list.
|
||||
*/
|
||||
router.delete('/shopping-lists/items/:itemId', validateNumericParams(['itemId']), async (req, res, next: NextFunction) => {
|
||||
router.delete('/shopping-lists/items/:itemId', validateRequest(numericIdParam('itemId')), async (req, res, next: NextFunction) => {
|
||||
logger.debug(`[ROUTE] DELETE /api/users/shopping-lists/items/:itemId - ENTER`);
|
||||
const itemId = parseInt(req.params.itemId, 10);
|
||||
const itemId = req.params.itemId as unknown as number;
|
||||
try {
|
||||
await db.shoppingRepo.removeShoppingListItem(itemId);
|
||||
res.status(204).send();
|
||||
@@ -385,12 +422,11 @@ router.delete('/shopping-lists/items/:itemId', validateNumericParams(['itemId'])
|
||||
/**
|
||||
* PUT /api/users/profile/preferences - Update user preferences.
|
||||
*/
|
||||
router.put('/profile/preferences', async (req, res, next: NextFunction) => {
|
||||
router.put('/profile/preferences', validateRequest(z.object({
|
||||
body: z.object({}).passthrough(), // Ensures body is an object, allows any properties
|
||||
})), async (req, res, next: NextFunction) => {
|
||||
logger.debug(`[ROUTE] PUT /api/users/profile/preferences - ENTER`);
|
||||
const user = req.user as UserProfile;
|
||||
if (typeof req.body !== 'object' || req.body === null || Array.isArray(req.body)) {
|
||||
return res.status(400).json({ message: 'Invalid preferences format. Body must be a JSON object.' });
|
||||
}
|
||||
try { // This was a duplicate, fixed.
|
||||
const updatedProfile = await db.userRepo.updateUserPreferences(user.user_id, req.body);
|
||||
res.json(updatedProfile);
|
||||
@@ -412,7 +448,9 @@ router.get('/me/dietary-restrictions', async (req, res, next: NextFunction) => {
|
||||
}
|
||||
});
|
||||
|
||||
router.put('/me/dietary-restrictions', validateBody(['restrictionIds']), async (req, res, next: NextFunction) => {
|
||||
router.put('/me/dietary-restrictions', validateRequest(z.object({
|
||||
body: z.object({ restrictionIds: z.array(z.number().int().positive()) }),
|
||||
})), async (req, res, next: NextFunction) => {
|
||||
logger.debug(`[ROUTE] PUT /api/users/me/dietary-restrictions - ENTER`);
|
||||
const user = req.user as UserProfile;
|
||||
const { restrictionIds } = req.body;
|
||||
@@ -444,13 +482,12 @@ router.get('/me/appliances', async (req, res, next: NextFunction) => {
|
||||
}
|
||||
});
|
||||
|
||||
router.put('/me/appliances', validateBody(['applianceIds']), async (req, res, next: NextFunction) => {
|
||||
router.put('/me/appliances', validateRequest(z.object({
|
||||
body: z.object({ applianceIds: z.array(z.number().int().positive()) }),
|
||||
})), async (req, res, next: NextFunction) => {
|
||||
logger.debug(`[ROUTE] PUT /api/users/me/appliances - ENTER`);
|
||||
const user = req.user as UserProfile;
|
||||
const { applianceIds } = req.body;
|
||||
if (!Array.isArray(applianceIds)) {
|
||||
return res.status(400).json({ message: 'applianceIds must be an array.' });
|
||||
}
|
||||
try {
|
||||
await db.personalizationRepo.setUserAppliances(user.user_id, applianceIds);
|
||||
res.status(204).send();
|
||||
@@ -471,9 +508,9 @@ router.put('/me/appliances', validateBody(['applianceIds']), async (req, res, ne
|
||||
* GET /api/users/addresses/:addressId - Get a specific address by its ID.
|
||||
* This is protected to ensure a user can only fetch their own address details.
|
||||
*/
|
||||
router.get('/addresses/:addressId', validateNumericParams(['addressId']), async (req, res, next: NextFunction) => {
|
||||
router.get('/addresses/:addressId', validateRequest(numericIdParam('addressId')), async (req, res, next: NextFunction) => {
|
||||
const user = req.user as UserProfile;
|
||||
const addressId = parseInt(req.params.addressId, 10);
|
||||
const addressId = req.params.addressId as unknown as number;
|
||||
|
||||
// Security check: Ensure the requested addressId matches the one on the user's profile.
|
||||
if (user.address_id !== addressId) {
|
||||
@@ -491,7 +528,16 @@ router.get('/addresses/:addressId', validateNumericParams(['addressId']), async
|
||||
/**
|
||||
* PUT /api/users/profile/address - Create or update the user's primary address.
|
||||
*/
|
||||
router.put('/profile/address', async (req, res, next: NextFunction) => {
|
||||
router.put('/profile/address', validateRequest(z.object({
|
||||
body: z.object({
|
||||
address_line_1: z.string().optional(),
|
||||
address_line_2: z.string().optional(),
|
||||
city: z.string().optional(),
|
||||
province_state: z.string().optional(),
|
||||
postal_code: z.string().optional(),
|
||||
country: z.string().optional(),
|
||||
}).refine(data => Object.keys(data).length > 0, { message: 'At least one address field must be provided.' }),
|
||||
})), async (req, res, next: NextFunction) => {
|
||||
const user = req.user as UserProfile;
|
||||
const addressData = req.body as Partial<Address>;
|
||||
|
||||
@@ -510,10 +556,10 @@ router.put('/profile/address', async (req, res, next: NextFunction) => {
|
||||
/**
|
||||
* DELETE /api/users/recipes/:recipeId - Delete a recipe created by the user.
|
||||
*/
|
||||
router.delete('/recipes/:recipeId', validateNumericParams(['recipeId']), async (req, res, next: NextFunction) => {
|
||||
router.delete('/recipes/:recipeId', validateRequest(numericIdParam('recipeId')), async (req, res, next: NextFunction) => {
|
||||
logger.debug(`[ROUTE] DELETE /api/users/recipes/:recipeId - ENTER`);
|
||||
const user = req.user as UserProfile;
|
||||
const recipeId = parseInt(req.params.recipeId, 10);
|
||||
const recipeId = req.params.recipeId as unknown as number;
|
||||
|
||||
try {
|
||||
await db.recipeRepo.deleteRecipe(recipeId, user.user_id, false);
|
||||
@@ -526,10 +572,20 @@ router.delete('/recipes/:recipeId', validateNumericParams(['recipeId']), async (
|
||||
/**
|
||||
* PUT /api/users/recipes/:recipeId - Update a recipe created by the user.
|
||||
*/
|
||||
router.put('/recipes/:recipeId', validateNumericParams(['recipeId']), async (req, res, next: NextFunction) => {
|
||||
router.put('/recipes/:recipeId', validateRequest(numericIdParam('recipeId').extend({
|
||||
body: z.object({
|
||||
name: z.string().optional(),
|
||||
description: z.string().optional(),
|
||||
instructions: z.string().optional(),
|
||||
prep_time_minutes: z.number().int().optional(),
|
||||
cook_time_minutes: z.number().int().optional(),
|
||||
servings: z.number().int().optional(),
|
||||
photo_url: z.string().url().optional(),
|
||||
}).refine(data => Object.keys(data).length > 0, { message: 'No fields provided to update.' }),
|
||||
})), async (req, res, next: NextFunction) => {
|
||||
logger.debug(`[ROUTE] PUT /api/users/recipes/:recipeId - ENTER`);
|
||||
const user = req.user as UserProfile;
|
||||
const recipeId = parseInt(req.params.recipeId, 10);
|
||||
const recipeId = req.params.recipeId as unknown as number;
|
||||
|
||||
try {
|
||||
const updatedRecipe = await db.recipeRepo.updateRecipe(recipeId, user.user_id, req.body);
|
||||
|
||||
Reference in New Issue
Block a user