consolidate some testing functions

This commit is contained in:
2025-12-23 23:50:03 -08:00
parent f173f805ea
commit 03b5af39e1
24 changed files with 723 additions and 126 deletions

View File

@@ -15,6 +15,11 @@ import { NotFoundError } from '../services/db/errors.db'; // This can stay, it's
import { createTestApp } from '../tests/utils/createTestApp';
import { mockLogger } from '../tests/utils/mockLogger';
// Mock the file upload middleware to allow testing the controller's internal check
vi.mock('../middleware/fileUpload.middleware', () => ({
requireFileUpload: () => (req: Request, res: Response, next: NextFunction) => next(),
}));
vi.mock('../lib/queue', () => ({
serverAdapter: {
getRouter: () => (req: Request, res: Response, next: NextFunction) => next(), // Return a dummy express handler
@@ -256,7 +261,7 @@ describe('Admin Content Management Routes (/api/admin)', () => {
const response = await supertest(app).post('/api/admin/brands/55/logo');
expect(response.status).toBe(400);
expect(response.body.message).toMatch(
/Logo image file is required|The request data is invalid/,
/Logo image file is required|The request data is invalid|Logo image file is missing./,
);
});

View File

@@ -242,6 +242,17 @@ describe('Admin Job Trigger Routes (/api/admin/trigger)', () => {
expect(response.status).toBe(400);
});
it('should return 404 if the queue name is valid but not in the retry map', async () => {
const queueName = 'weekly-analytics-reporting'; // This is in the Zod enum but not the queueMap
const jobId = 'some-job-id';
const response = await supertest(app).post(`/api/admin/jobs/${queueName}/${jobId}/retry`);
// The route throws a NotFoundError, which the error handler should convert to a 404.
expect(response.status).toBe(404);
expect(response.body.message).toBe(`Queue 'weekly-analytics-reporting' not found.`);
});
it('should return 404 if the job ID is not found in the queue', async () => {
vi.mocked(flyerQueue.getJob).mockResolvedValue(undefined);
const response = await supertest(app).post(

View File

@@ -2,7 +2,7 @@
import { Router, NextFunction, Request, Response } from 'express';
import passport from './passport.routes';
import { isAdmin } from './passport.routes'; // Correctly imported
import multer from 'multer'; // --- Zod Schemas for Admin Routes (as per ADR-003) ---
import multer from 'multer';
import { z } from 'zod';
import * as db from '../services/db/index.db';
@@ -32,7 +32,12 @@ import {
weeklyAnalyticsWorker,
} from '../services/queueService.server'; // Import your queues
import { getSimpleWeekAndYear } from '../utils/dateUtils';
import { requiredString, numericIdParam, uuidParamSchema } from '../utils/zodUtils';
import {
requiredString,
numericIdParam,
uuidParamSchema,
optionalNumeric,
} from '../utils/zodUtils';
import { logger } from '../services/logger.server';
const updateCorrectionSchema = numericIdParam('id').extend({
@@ -61,8 +66,8 @@ const updateUserRoleSchema = uuidParamSchema('id', 'A valid user ID is required.
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),
limit: optionalNumeric({ default: 50, integer: true, positive: true }),
offset: optionalNumeric({ default: 0, integer: true, nonnegative: true }),
}),
});

View File

@@ -79,12 +79,6 @@ describe('Admin System Routes (/api/admin/system)', () => {
authenticatedUser: adminUser,
});
// Add a basic error handler to capture errors passed to next(err) and return JSON.
// This prevents unhandled error crashes in tests and ensures we get the 500 response we expect.
app.use((err: any, req: any, res: any, next: any) => {
res.status(err.status || 500).json({ message: err.message, errors: err.errors });
});
beforeEach(() => {
vi.clearAllMocks();
});

View File

@@ -78,6 +78,7 @@ describe('AI Routes (/api/ai)', () => {
vi.mocked(mockLogger.info).mockImplementation(() => {});
vi.mocked(mockLogger.error).mockImplementation(() => {});
vi.mocked(mockLogger.warn).mockImplementation(() => {});
vi.mocked(mockLogger.debug).mockImplementation(() => {}); // Ensure debug is also mocked
});
const app = createTestApp({ router: aiRouter, basePath: '/api/ai' });
@@ -111,10 +112,55 @@ describe('AI Routes (/api/ai)', () => {
});
});
// Add a basic error handler to capture errors passed to next(err) and return JSON.
// This prevents unhandled error crashes in tests and ensures we get the 500 response we expect.
app.use((err: any, req: any, res: any, next: any) => {
res.status(err.status || 500).json({ message: err.message, errors: err.errors });
// New test to cover the router.use diagnostic middleware's catch block and errMsg branches
describe('Diagnostic Middleware Error Handling', () => {
it('should log an error if logger.debug throws an object with a message property', async () => {
const mockErrorObject = { message: 'Mock debug error' };
vi.mocked(mockLogger.debug).mockImplementationOnce(() => {
throw mockErrorObject;
});
// Make any request to trigger the middleware
const response = await supertest(app).get('/api/ai/jobs/job-123/status');
expect(mockLogger.error).toHaveBeenCalledWith(
{ error: mockErrorObject.message }, // errMsg should extract the message
'Failed to log incoming AI request headers',
);
// The request should still proceed, but might fail later if the original flow was interrupted.
// Here, it will likely hit the 404 for job not found.
expect(response.status).toBe(404);
});
it('should log an error if logger.debug throws a primitive string', async () => {
const mockErrorString = 'Mock debug error string';
vi.mocked(mockLogger.debug).mockImplementationOnce(() => {
throw mockErrorString;
});
// Make any request to trigger the middleware
const response = await supertest(app).get('/api/ai/jobs/job-123/status');
expect(mockLogger.error).toHaveBeenCalledWith(
{ error: mockErrorString }, // errMsg should convert to string
'Failed to log incoming AI request headers',
);
expect(response.status).toBe(404);
});
it('should log an error if logger.debug throws null/undefined', async () => {
vi.mocked(mockLogger.debug).mockImplementationOnce(() => {
throw null; // Simulate throwing null
});
const response = await supertest(app).get('/api/ai/jobs/job-123/status');
expect(mockLogger.error).toHaveBeenCalledWith(
{ error: 'An unknown error occurred.' }, // errMsg should handle null/undefined
'Failed to log incoming AI request headers',
);
expect(response.status).toBe(404);
});
});
describe('POST /upload-and-process', () => {
@@ -423,6 +469,52 @@ describe('AI Routes (/api/ai)', () => {
expect(mockedDb.createFlyerAndItems).toHaveBeenCalledTimes(1);
});
it('should handle payload where extractedData is null', async () => {
const payloadWithNullExtractedData = {
checksum: 'null-extracted-data-checksum',
originalFileName: 'flyer-null.jpg',
extractedData: null,
};
const response = await supertest(app)
.post('/api/ai/flyers/process')
.field('data', JSON.stringify(payloadWithNullExtractedData))
.attach('flyerImage', imagePath);
expect(response.status).toBe(201);
expect(mockedDb.createFlyerAndItems).toHaveBeenCalledTimes(1);
// Verify that extractedData was correctly defaulted to an empty object
const flyerDataArg = vi.mocked(mockedDb.createFlyerAndItems).mock.calls[0][0];
expect(flyerDataArg.store_name).toContain('Unknown Store'); // Fallback should be used
expect(mockLogger.warn).toHaveBeenCalledWith(
{ bodyData: expect.any(Object) },
'Missing extractedData in /api/ai/flyers/process payload.',
);
});
it('should handle payload where extractedData is a string', async () => {
const payloadWithStringExtractedData = {
checksum: 'string-extracted-data-checksum',
originalFileName: 'flyer-string.jpg',
extractedData: 'not-an-object',
};
const response = await supertest(app)
.post('/api/ai/flyers/process')
.field('data', JSON.stringify(payloadWithStringExtractedData))
.attach('flyerImage', imagePath);
expect(response.status).toBe(201);
expect(mockedDb.createFlyerAndItems).toHaveBeenCalledTimes(1);
// Verify that extractedData was correctly defaulted to an empty object
const flyerDataArg = vi.mocked(mockedDb.createFlyerAndItems).mock.calls[0][0];
expect(flyerDataArg.store_name).toContain('Unknown Store'); // Fallback should be used
expect(mockLogger.warn).toHaveBeenCalledWith(
{ bodyData: expect.any(Object) },
'Missing extractedData in /api/ai/flyers/process payload.',
);
});
it('should handle payload where extractedData is at the root of the body', async () => {
// This simulates a client sending multipart fields for each property of extractedData
const response = await supertest(app)

View File

@@ -1,7 +1,6 @@
// src/routes/auth.routes.ts
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';
@@ -44,8 +43,6 @@ 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.'),

View File

@@ -54,13 +54,6 @@ describe('Deals Routes (/api/users/deals)', () => {
authenticatedUser: mockUser,
});
const unauthenticatedApp = createTestApp({ router: dealsRouter, basePath });
const errorHandler = (err: any, req: any, res: any, next: any) => {
res.status(err.status || 500).json({ message: err.message, errors: err.errors });
};
// Apply the handler to both app instances
authenticatedApp.use(errorHandler);
unauthenticatedApp.use(errorHandler);
beforeEach(() => {
vi.clearAllMocks();

View File

@@ -3,6 +3,7 @@ import { Router } from 'express';
import * as db from '../services/db/index.db';
import { z } from 'zod';
import { validateRequest } from '../middleware/validation.middleware';
import { optionalNumeric } from '../utils/zodUtils';
const router = Router();
@@ -10,8 +11,8 @@ const router = Router();
const getFlyersSchema = z.object({
query: z.object({
limit: z.coerce.number().int().positive().optional().default(20),
offset: z.coerce.number().int().nonnegative().optional().default(0),
limit: optionalNumeric({ default: 20, integer: true, positive: true }),
offset: optionalNumeric({ default: 0, integer: true, nonnegative: true }),
}),
});

View File

@@ -86,12 +86,6 @@ describe('Gamification Routes (/api/achievements)', () => {
basePath,
authenticatedUser: mockAdminProfile,
});
const errorHandler = (err: any, req: any, res: any, next: any) => {
res.status(err.status || 500).json({ message: err.message, errors: err.errors });
};
unauthenticatedApp.use(errorHandler);
authenticatedApp.use(errorHandler);
adminApp.use(errorHandler);
describe('GET /', () => {
it('should return a list of all achievements (public endpoint)', async () => {

View File

@@ -7,7 +7,7 @@ import { logger } from '../services/logger.server';
import { UserProfile } from '../types';
import { ForeignKeyConstraintError } from '../services/db/errors.db';
import { validateRequest } from '../middleware/validation.middleware';
import { requiredString } from '../utils/zodUtils';
import { requiredString, optionalNumeric } from '../utils/zodUtils';
const router = express.Router();
const adminGamificationRouter = express.Router(); // Create a new router for admin-only routes.
@@ -16,7 +16,7 @@ const adminGamificationRouter = express.Router(); // Create a new router for adm
const leaderboardSchema = z.object({
query: z.object({
limit: z.coerce.number().int().positive().max(50).optional().default(10),
limit: optionalNumeric({ default: 10, integer: true, positive: true, max: 50 }),
}),
});

View File

@@ -22,10 +22,6 @@ import { priceRepo } from '../services/db/price.db';
describe('Price Routes (/api/price-history)', () => {
const app = createTestApp({ router: priceRouter, basePath: '/api/price-history' });
// Add a basic error handler to capture errors passed to next(err) and return JSON.
app.use((err: any, req: any, res: any, next: any) => {
res.status(err.status || 500).json({ message: err.message, errors: err.errors });
});
beforeEach(() => {
vi.clearAllMocks();
});
@@ -100,7 +96,7 @@ describe('Price Routes (/api/price-history)', () => {
.send({ masterItemIds: 'not-an-array' });
expect(response.status).toBe(400);
expect(response.body.errors[0].message).toBe('Expected array, received string');
expect(response.body.errors[0].message).toContain('Expected array, received string');
});
it('should return 400 if masterItemIds contains non-positive integers', async () => {
@@ -127,7 +123,7 @@ describe('Price Routes (/api/price-history)', () => {
expect(response.status).toBe(400);
expect(response.body.errors).toHaveLength(2);
expect(response.body.errors[0].message).toBe('Number must be greater than 0');
expect(response.body.errors[1].message).toContain('Expected number, received string');
expect(response.body.errors[1].message).toBe('Expected number, received string');
});
});
});

View File

@@ -3,16 +3,19 @@ import { Router, Request, Response, NextFunction } from 'express';
import { z } from 'zod';
import { validateRequest } from '../middleware/validation.middleware';
import { priceRepo } from '../services/db/price.db';
import { optionalNumeric } from '../utils/zodUtils';
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.',
}),
limit: z.coerce.number().int().positive().optional().default(1000),
offset: z.coerce.number().int().nonnegative().optional().default(0),
masterItemIds: z
.array(z.number().int().positive('Number must be greater than 0'))
.nonempty({
message: 'masterItemIds must be a non-empty array of positive integers.',
}),
limit: optionalNumeric({ default: 1000, integer: true, positive: true }),
offset: optionalNumeric({ default: 0, integer: true, nonnegative: true }),
}),
});

View File

@@ -3,21 +3,19 @@ import { Router } from 'express';
import { z } from 'zod';
import * as db from '../services/db/index.db';
import { validateRequest } from '../middleware/validation.middleware';
import { requiredString, numericIdParam } from '../utils/zodUtils';
import { requiredString, numericIdParam, optionalNumeric } from '../utils/zodUtils';
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),
minPercentage: optionalNumeric({ default: 50, min: 0, max: 100 }),
}),
});
const bySaleIngredientsSchema = z.object({
query: z.object({
minIngredients: z.coerce.number().int().positive().optional().default(3),
minIngredients: optionalNumeric({ default: 3, integer: true, positive: true }),
}),
});
@@ -40,7 +38,7 @@ router.get(
try {
// Explicitly parse req.query to apply coercion (string -> number) and default values
const { query } = bySalePercentageSchema.parse({ query: req.query });
const recipes = await db.recipeRepo.getRecipesBySalePercentage(query.minPercentage, req.log);
const recipes = await db.recipeRepo.getRecipesBySalePercentage(query.minPercentage!, req.log);
res.json(recipes);
} catch (error) {
req.log.error({ error }, 'Error fetching recipes in /api/recipes/by-sale-percentage:');
@@ -60,7 +58,7 @@ router.get(
// Explicitly parse req.query to apply coercion (string -> number) and default values
const { query } = bySaleIngredientsSchema.parse({ query: req.query });
const recipes = await db.recipeRepo.getRecipesByMinSaleIngredients(
query.minIngredients,
query.minIngredients!,
req.log,
);
res.json(recipes);

View File

@@ -28,12 +28,6 @@ const expectLogger = expect.objectContaining({
describe('Stats Routes (/api/stats)', () => {
const app = createTestApp({ router: statsRouter, basePath: '/api/stats' });
// Add a basic error handler to capture errors passed to next(err) and return JSON.
// This prevents unhandled error crashes in tests and ensures we get the 500 response we expect.
app.use((err: any, req: any, res: any, next: any) => {
res.status(err.status || 500).json({ message: err.message, errors: err.errors });
});
beforeEach(() => {
vi.clearAllMocks();
});

View File

@@ -3,6 +3,7 @@ import { Router, Request, Response, NextFunction } from 'express';
import { z } from 'zod';
import * as db from '../services/db/index.db';
import { validateRequest } from '../middleware/validation.middleware';
import { optionalNumeric } from '../utils/zodUtils';
const router = Router();
@@ -10,8 +11,8 @@ const router = Router();
// Define the query schema separately so we can use it to parse req.query in the handler
const statsQuerySchema = z.object({
days: z.coerce.number().int().min(1).max(365).optional().default(30),
limit: z.coerce.number().int().min(1).max(50).optional().default(10),
days: optionalNumeric({ default: 30, min: 1, max: 365, integer: true }),
limit: optionalNumeric({ default: 10, min: 1, max: 50, integer: true }),
});
const mostFrequentSalesSchema = z.object({
@@ -31,7 +32,7 @@ router.get(
// Even though validateRequest checks validity, it may not mutate req.query with the parsed result.
const { days, limit } = statsQuerySchema.parse(req.query);
const items = await db.adminRepo.getMostFrequentSaleItems(days, limit, req.log);
const items = await db.adminRepo.getMostFrequentSaleItems(days!, limit!, req.log);
res.json(items);
} catch (error) {
req.log.error(

View File

@@ -877,20 +877,41 @@ describe('User Routes (/api/users)', () => {
});
describe('Notification Routes', () => {
it('GET /notifications should return notifications for the user', async () => {
it('GET /notifications should return only unread notifications by default', async () => {
const mockNotifications: Notification[] = [
createMockNotification({ user_id: 'user-123', content: 'Test' }),
];
vi.mocked(db.notificationRepo.getNotificationsForUser).mockResolvedValue(mockNotifications);
const response = await supertest(app).get('/api/users/notifications?limit=10&offset=0');
const response = await supertest(app).get('/api/users/notifications?limit=10');
expect(response.status).toBe(200);
expect(response.body).toEqual(mockNotifications);
expect(db.notificationRepo.getNotificationsForUser).toHaveBeenCalledWith(
'user-123',
10,
0,
0, // default offset
false, // default includeRead
expectLogger,
);
});
it('GET /notifications?includeRead=true should return all notifications', async () => {
const mockNotifications: Notification[] = [
createMockNotification({ user_id: 'user-123', content: 'Read', is_read: true }),
createMockNotification({ user_id: 'user-123', content: 'Unread', is_read: false }),
];
vi.mocked(db.notificationRepo.getNotificationsForUser).mockResolvedValue(mockNotifications);
const response = await supertest(app).get('/api/users/notifications?includeRead=true');
expect(response.status).toBe(200);
expect(response.body).toEqual(mockNotifications);
expect(db.notificationRepo.getNotificationsForUser).toHaveBeenCalledWith(
'user-123',
20, // default limit
0, // default offset
true, // includeRead from query param
expectLogger,
);
});

View File

@@ -4,8 +4,7 @@ import passport from './passport.routes';
import multer from 'multer';
import path from 'path';
import fs from 'node:fs/promises';
import * as bcrypt from 'bcrypt';
import zxcvbn from 'zxcvbn';
import * as bcrypt from 'bcrypt'; // This was a duplicate, fixed.
import { z } from 'zod';
import { logger } from '../services/logger.server';
import { UserProfile } from '../types';
@@ -13,7 +12,12 @@ import { userService } from '../services/userService';
import { ForeignKeyConstraintError } from '../services/db/errors.db';
import { validateRequest } from '../middleware/validation.middleware';
import { validatePasswordStrength } from '../utils/authUtils';
import { requiredString, numericIdParam } from '../utils/zodUtils';
import {
requiredString,
numericIdParam,
optionalNumeric,
optionalBoolean,
} from '../utils/zodUtils';
import * as db from '../services/db/index.db';
const router = express.Router();
@@ -56,8 +60,9 @@ const createShoppingListSchema = z.object({
// Apply the JWT authentication middleware to all routes in this file.
const notificationQuerySchema = z.object({
query: z.object({
limit: z.coerce.number().int().positive().optional().default(20),
offset: z.coerce.number().int().nonnegative().optional().default(0),
limit: optionalNumeric({ default: 20, integer: true, positive: true }),
offset: optionalNumeric({ default: 0, integer: true, nonnegative: true }),
includeRead: optionalBoolean({ default: false }),
}),
});
@@ -136,13 +141,12 @@ router.get(
// Apply ADR-003 pattern for type safety
try {
const { query } = req as unknown as GetNotificationsRequest;
// Explicitly convert to numbers to ensure the repo receives correct types
const limit = query.limit ? Number(query.limit) : 20;
const offset = query.offset ? Number(query.offset) : 0;
const parsedQuery = notificationQuerySchema.parse({ query: req.query }).query;
const notifications = await db.notificationRepo.getNotificationsForUser(
userProfile.user.user_id,
limit,
offset,
parsedQuery.limit!,
parsedQuery.offset!,
parsedQuery.includeRead!,
req.log,
);
res.json(notifications);