only one error left - huzzah !
This commit is contained in:
@@ -125,12 +125,6 @@ describe('Admin Content Management Routes (/api/admin)', () => {
|
|||||||
authenticatedUser: adminUser,
|
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(() => {
|
beforeEach(() => {
|
||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -97,12 +97,6 @@ describe('Admin Job Trigger Routes (/api/admin/trigger)', () => {
|
|||||||
authenticatedUser: adminUser,
|
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(() => {
|
beforeEach(() => {
|
||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -102,12 +102,6 @@ describe('Admin Monitoring Routes (/api/admin)', () => {
|
|||||||
authenticatedUser: adminUser,
|
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(() => {
|
beforeEach(() => {
|
||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -6,8 +6,7 @@ import multer from 'multer'; // --- Zod Schemas for Admin Routes (as per ADR-003
|
|||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
|
|
||||||
import * as db from '../services/db/index.db';
|
import * as db from '../services/db/index.db';
|
||||||
import { logger } from '../services/logger.server';
|
import type { UserProfile } from '../types';
|
||||||
import { UserProfile } from '../types';
|
|
||||||
import { geocodingService } from '../services/geocodingService.server';
|
import { geocodingService } from '../services/geocodingService.server';
|
||||||
import { requireFileUpload } from '../middleware/fileUpload.middleware'; // This was a duplicate, fixed.
|
import { requireFileUpload } from '../middleware/fileUpload.middleware'; // This was a duplicate, fixed.
|
||||||
import { NotFoundError, ValidationError } from '../services/db/errors.db';
|
import { NotFoundError, ValidationError } from '../services/db/errors.db';
|
||||||
@@ -33,45 +32,22 @@ import {
|
|||||||
weeklyAnalyticsWorker,
|
weeklyAnalyticsWorker,
|
||||||
} from '../services/queueService.server'; // Import your queues
|
} from '../services/queueService.server'; // Import your queues
|
||||||
import { getSimpleWeekAndYear } from '../utils/dateUtils';
|
import { getSimpleWeekAndYear } from '../utils/dateUtils';
|
||||||
|
import { requiredString, numericIdParam, uuidParamSchema } from '../utils/zodUtils';
|
||||||
|
import { logger } from '../services/logger.server';
|
||||||
|
|
||||||
// Helper for consistent required string validation (handles missing/null/empty)
|
const updateCorrectionSchema = numericIdParam('id').extend({
|
||||||
const requiredString = (message: string) =>
|
|
||||||
z.preprocess((val) => val ?? '', z.string().min(1, message));
|
|
||||||
|
|
||||||
/**
|
|
||||||
* A factory for creating a Zod schema that validates a UUID in the request parameters.
|
|
||||||
* @param key The name of the parameter key (e.g., 'userId').
|
|
||||||
* @param message A custom error message for invalid UUIDs.
|
|
||||||
*/
|
|
||||||
const uuidParamSchema = (key: string, message = `Invalid UUID for parameter '${key}'.`) =>
|
|
||||||
z.object({
|
|
||||||
params: z.object({ [key]: z.string().uuid({ message }) }),
|
|
||||||
});
|
|
||||||
|
|
||||||
/**
|
|
||||||
* A factory for creating a Zod schema that validates a numeric ID in the request parameters.
|
|
||||||
*/
|
|
||||||
const numericIdParamSchema = (
|
|
||||||
key: string,
|
|
||||||
message = `Invalid ID for parameter '${key}'. Must be a positive integer.`,
|
|
||||||
) =>
|
|
||||||
z.object({
|
|
||||||
params: z.object({ [key]: z.coerce.number().int({ message }).positive({ message }) }),
|
|
||||||
});
|
|
||||||
|
|
||||||
const updateCorrectionSchema = numericIdParamSchema('id').extend({
|
|
||||||
body: z.object({
|
body: z.object({
|
||||||
suggested_value: requiredString('A new suggested_value is required.'),
|
suggested_value: requiredString('A new suggested_value is required.'),
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
const updateRecipeStatusSchema = numericIdParamSchema('id').extend({
|
const updateRecipeStatusSchema = numericIdParam('id').extend({
|
||||||
body: z.object({
|
body: z.object({
|
||||||
status: z.enum(['private', 'pending_review', 'public', 'rejected']),
|
status: z.enum(['private', 'pending_review', 'public', 'rejected']),
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
const updateCommentStatusSchema = numericIdParamSchema('id').extend({
|
const updateCommentStatusSchema = numericIdParam('id').extend({
|
||||||
body: z.object({
|
body: z.object({
|
||||||
status: z.enum(['visible', 'hidden', 'reported']),
|
status: z.enum(['visible', 'hidden', 'reported']),
|
||||||
}),
|
}),
|
||||||
@@ -187,10 +163,10 @@ router.get('/stats/daily', async (req, res, next: NextFunction) => {
|
|||||||
|
|
||||||
router.post(
|
router.post(
|
||||||
'/corrections/:id/approve',
|
'/corrections/:id/approve',
|
||||||
validateRequest(numericIdParamSchema('id')),
|
validateRequest(numericIdParam('id')),
|
||||||
async (req: Request, res: Response, next: NextFunction) => {
|
async (req: Request, res: Response, next: NextFunction) => {
|
||||||
// Apply ADR-003 pattern for type safety
|
// Apply ADR-003 pattern for type safety
|
||||||
const { params } = req as unknown as z.infer<ReturnType<typeof numericIdParamSchema>>;
|
const { params } = req as unknown as z.infer<ReturnType<typeof numericIdParam>>;
|
||||||
try {
|
try {
|
||||||
await db.adminRepo.approveCorrection(params.id, req.log); // params.id is now safely typed as number
|
await db.adminRepo.approveCorrection(params.id, req.log); // params.id is now safely typed as number
|
||||||
res.status(200).json({ message: 'Correction approved successfully.' });
|
res.status(200).json({ message: 'Correction approved successfully.' });
|
||||||
@@ -202,10 +178,10 @@ router.post(
|
|||||||
|
|
||||||
router.post(
|
router.post(
|
||||||
'/corrections/:id/reject',
|
'/corrections/:id/reject',
|
||||||
validateRequest(numericIdParamSchema('id')),
|
validateRequest(numericIdParam('id')),
|
||||||
async (req: Request, res: Response, next: NextFunction) => {
|
async (req: Request, res: Response, next: NextFunction) => {
|
||||||
// Apply ADR-003 pattern for type safety
|
// Apply ADR-003 pattern for type safety
|
||||||
const { params } = req as unknown as z.infer<ReturnType<typeof numericIdParamSchema>>;
|
const { params } = req as unknown as z.infer<ReturnType<typeof numericIdParam>>;
|
||||||
try {
|
try {
|
||||||
await db.adminRepo.rejectCorrection(params.id, req.log); // params.id is now safely typed as number
|
await db.adminRepo.rejectCorrection(params.id, req.log); // params.id is now safely typed as number
|
||||||
res.status(200).json({ message: 'Correction rejected successfully.' });
|
res.status(200).json({ message: 'Correction rejected successfully.' });
|
||||||
@@ -251,12 +227,12 @@ router.put(
|
|||||||
|
|
||||||
router.post(
|
router.post(
|
||||||
'/brands/:id/logo',
|
'/brands/:id/logo',
|
||||||
validateRequest(numericIdParamSchema('id')),
|
validateRequest(numericIdParam('id')),
|
||||||
upload.single('logoImage'),
|
upload.single('logoImage'),
|
||||||
requireFileUpload('logoImage'),
|
requireFileUpload('logoImage'),
|
||||||
async (req: Request, res: Response, next: NextFunction) => {
|
async (req: Request, res: Response, next: NextFunction) => {
|
||||||
// Apply ADR-003 pattern for type safety
|
// Apply ADR-003 pattern for type safety
|
||||||
const { params } = req as unknown as z.infer<ReturnType<typeof numericIdParamSchema>>;
|
const { params } = req as unknown as z.infer<ReturnType<typeof numericIdParam>>;
|
||||||
try {
|
try {
|
||||||
// Although requireFileUpload middleware should ensure the file exists,
|
// Although requireFileUpload middleware should ensure the file exists,
|
||||||
// this check satisfies TypeScript and adds robustness.
|
// this check satisfies TypeScript and adds robustness.
|
||||||
@@ -288,11 +264,11 @@ router.get('/unmatched-items', async (req, res, next: NextFunction) => {
|
|||||||
*/
|
*/
|
||||||
router.delete(
|
router.delete(
|
||||||
'/recipes/:recipeId',
|
'/recipes/:recipeId',
|
||||||
validateRequest(numericIdParamSchema('recipeId')),
|
validateRequest(numericIdParam('recipeId')),
|
||||||
async (req: Request, res: Response, next: NextFunction) => {
|
async (req: Request, res: Response, next: NextFunction) => {
|
||||||
const userProfile = req.user as UserProfile;
|
const userProfile = req.user as UserProfile;
|
||||||
// Infer the type directly from the schema generator function. // This was a duplicate, fixed.
|
// Infer the type directly from the schema generator function. // This was a duplicate, fixed.
|
||||||
const { params } = req as unknown as z.infer<ReturnType<typeof numericIdParamSchema>>;
|
const { params } = req as unknown as z.infer<ReturnType<typeof numericIdParam>>;
|
||||||
try {
|
try {
|
||||||
// The isAdmin flag bypasses the ownership check in the repository method.
|
// The isAdmin flag bypasses the ownership check in the repository method.
|
||||||
await db.recipeRepo.deleteRecipe(params.recipeId, userProfile.user.user_id, true, req.log);
|
await db.recipeRepo.deleteRecipe(params.recipeId, userProfile.user.user_id, true, req.log);
|
||||||
@@ -308,10 +284,10 @@ router.delete(
|
|||||||
*/
|
*/
|
||||||
router.delete(
|
router.delete(
|
||||||
'/flyers/:flyerId',
|
'/flyers/:flyerId',
|
||||||
validateRequest(numericIdParamSchema('flyerId')),
|
validateRequest(numericIdParam('flyerId')),
|
||||||
async (req: Request, res: Response, next: NextFunction) => {
|
async (req: Request, res: Response, next: NextFunction) => {
|
||||||
// Infer the type directly from the schema generator function.
|
// Infer the type directly from the schema generator function.
|
||||||
const { params } = req as unknown as z.infer<ReturnType<typeof numericIdParamSchema>>;
|
const { params } = req as unknown as z.infer<ReturnType<typeof numericIdParam>>;
|
||||||
try {
|
try {
|
||||||
await db.flyerRepo.deleteFlyer(params.flyerId, req.log);
|
await db.flyerRepo.deleteFlyer(params.flyerId, req.log);
|
||||||
res.status(204).send();
|
res.status(204).send();
|
||||||
@@ -435,12 +411,10 @@ router.post(
|
|||||||
// We call the function but don't wait for it to finish (no `await`).
|
// We call the function but don't wait for it to finish (no `await`).
|
||||||
// This is a "fire-and-forget" operation from the client's perspective.
|
// This is a "fire-and-forget" operation from the client's perspective.
|
||||||
backgroundJobService.runDailyDealCheck();
|
backgroundJobService.runDailyDealCheck();
|
||||||
res
|
res.status(202).json({
|
||||||
.status(202)
|
message:
|
||||||
.json({
|
'Daily deal check job has been triggered successfully. It will run in the background.',
|
||||||
message:
|
});
|
||||||
'Daily deal check job has been triggered successfully. It will run in the background.',
|
|
||||||
});
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error({ error }, '[Admin] Failed to trigger daily deal check job.');
|
logger.error({ error }, '[Admin] Failed to trigger daily deal check job.');
|
||||||
next(error);
|
next(error);
|
||||||
@@ -467,11 +441,9 @@ router.post(
|
|||||||
|
|
||||||
const job = await analyticsQueue.add('generate-daily-report', { reportDate }, { jobId });
|
const job = await analyticsQueue.add('generate-daily-report', { reportDate }, { jobId });
|
||||||
|
|
||||||
res
|
res.status(202).json({
|
||||||
.status(202)
|
message: `Analytics report generation job has been enqueued successfully. Job ID: ${job.id}`,
|
||||||
.json({
|
});
|
||||||
message: `Analytics report generation job has been enqueued successfully. Job ID: ${job.id}`,
|
|
||||||
});
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error({ error }, '[Admin] Failed to enqueue analytics report job.');
|
logger.error({ error }, '[Admin] Failed to enqueue analytics report job.');
|
||||||
next(error);
|
next(error);
|
||||||
@@ -485,11 +457,11 @@ router.post(
|
|||||||
*/
|
*/
|
||||||
router.post(
|
router.post(
|
||||||
'/flyers/:flyerId/cleanup',
|
'/flyers/:flyerId/cleanup',
|
||||||
validateRequest(numericIdParamSchema('flyerId')),
|
validateRequest(numericIdParam('flyerId')),
|
||||||
async (req: Request, res: Response, next: NextFunction) => {
|
async (req: Request, res: Response, next: NextFunction) => {
|
||||||
const userProfile = req.user as UserProfile;
|
const userProfile = req.user as UserProfile;
|
||||||
// Infer type from the schema generator for type safety, as per ADR-003.
|
// Infer type from the schema generator for type safety, as per ADR-003.
|
||||||
const { params } = req as unknown as z.infer<ReturnType<typeof numericIdParamSchema>>; // This was a duplicate, fixed.
|
const { params } = req as unknown as z.infer<ReturnType<typeof numericIdParam>>; // This was a duplicate, fixed.
|
||||||
logger.info(
|
logger.info(
|
||||||
`[Admin] Manual trigger for flyer file cleanup received from user: ${userProfile.user.user_id} for flyer ID: ${params.flyerId}`,
|
`[Admin] Manual trigger for flyer file cleanup received from user: ${userProfile.user.user_id} for flyer ID: ${params.flyerId}`,
|
||||||
);
|
);
|
||||||
@@ -541,11 +513,9 @@ router.post(
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const keysDeleted = await geocodingService.clearGeocodeCache(req.log);
|
const keysDeleted = await geocodingService.clearGeocodeCache(req.log);
|
||||||
res
|
res.status(200).json({
|
||||||
.status(200)
|
message: `Successfully cleared the geocode cache. ${keysDeleted} keys were removed.`,
|
||||||
.json({
|
});
|
||||||
message: `Successfully cleared the geocode cache. ${keysDeleted} keys were removed.`,
|
|
||||||
});
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error({ error }, '[Admin] Failed to clear geocode cache.');
|
logger.error({ error }, '[Admin] Failed to clear geocode cache.');
|
||||||
next(error);
|
next(error);
|
||||||
|
|||||||
@@ -73,12 +73,6 @@ describe('Admin Stats Routes (/api/admin/stats)', () => {
|
|||||||
authenticatedUser: adminUser,
|
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(() => {
|
beforeEach(() => {
|
||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -83,12 +83,6 @@ describe('Admin User Management Routes (/api/admin/users)', () => {
|
|||||||
authenticatedUser: adminUser,
|
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(() => {
|
beforeEach(() => {
|
||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -557,10 +557,11 @@ describe('AI Routes (/api/ai)', () => {
|
|||||||
const mockUser = createMockUserProfile({
|
const mockUser = createMockUserProfile({
|
||||||
user: { user_id: 'user-123', email: 'user-123@test.com' },
|
user: { user_id: 'user-123', email: 'user-123@test.com' },
|
||||||
});
|
});
|
||||||
|
const authenticatedApp = createTestApp({ router: aiRouter, basePath: '/api/ai', authenticatedUser: mockUser });
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
// Inject an authenticated user for this test block
|
// Inject an authenticated user for this test block
|
||||||
app.use((req, res, next) => {
|
authenticatedApp.use((req, res, next) => {
|
||||||
req.user = mockUser;
|
req.user = mockUser;
|
||||||
next();
|
next();
|
||||||
});
|
});
|
||||||
@@ -575,7 +576,7 @@ describe('AI Routes (/api/ai)', () => {
|
|||||||
.field('cropArea', JSON.stringify({ x: 10, y: 10, width: 50, height: 50 }))
|
.field('cropArea', JSON.stringify({ x: 10, y: 10, width: 50, height: 50 }))
|
||||||
.field('extractionType', 'item_details')
|
.field('extractionType', 'item_details')
|
||||||
.attach('image', imagePath);
|
.attach('image', imagePath);
|
||||||
|
// Use the authenticatedApp instance for requests in this block
|
||||||
expect(response.status).toBe(200);
|
expect(response.status).toBe(200);
|
||||||
expect(response.body).toEqual(mockResult);
|
expect(response.body).toEqual(mockResult);
|
||||||
expect(aiService.aiService.extractTextFromImageArea).toHaveBeenCalled();
|
expect(aiService.aiService.extractTextFromImageArea).toHaveBeenCalled();
|
||||||
@@ -586,7 +587,7 @@ describe('AI Routes (/api/ai)', () => {
|
|||||||
new Error('AI API is down'),
|
new Error('AI API is down'),
|
||||||
);
|
);
|
||||||
|
|
||||||
const response = await supertest(app)
|
const response = await supertest(authenticatedApp)
|
||||||
.post('/api/ai/rescan-area')
|
.post('/api/ai/rescan-area')
|
||||||
.field('cropArea', JSON.stringify({ x: 10, y: 10, width: 50, height: 50 }))
|
.field('cropArea', JSON.stringify({ x: 10, y: 10, width: 50, height: 50 }))
|
||||||
.field('extractionType', 'item_details')
|
.field('extractionType', 'item_details')
|
||||||
@@ -602,15 +603,12 @@ describe('AI Routes (/api/ai)', () => {
|
|||||||
const mockUserProfile = createMockUserProfile({
|
const mockUserProfile = createMockUserProfile({
|
||||||
user: { user_id: 'user-123', email: 'user-123@test.com' },
|
user: { user_id: 'user-123', email: 'user-123@test.com' },
|
||||||
});
|
});
|
||||||
|
const authenticatedApp = createTestApp({ router: aiRouter, basePath: '/api/ai', authenticatedUser: mockUserProfile });
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
// For this block, simulate an authenticated request by attaching the user.
|
// The authenticatedApp instance is already set up with mockUserProfile
|
||||||
app.use((req, res, next) => {
|
|
||||||
req.user = mockUserProfile;
|
|
||||||
next();
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('POST /quick-insights should return the stubbed response', async () => {
|
it('POST /quick-insights should return the stubbed response', async () => {
|
||||||
const response = await supertest(app)
|
const response = await supertest(app)
|
||||||
.post('/api/ai/quick-insights')
|
.post('/api/ai/quick-insights')
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import { logger } from '../services/logger.server';
|
|||||||
import { UserProfile, ExtractedCoreData, ExtractedFlyerItem } from '../types';
|
import { UserProfile, ExtractedCoreData, ExtractedFlyerItem } from '../types';
|
||||||
import { flyerQueue } from '../services/queueService.server';
|
import { flyerQueue } from '../services/queueService.server';
|
||||||
import { validateRequest } from '../middleware/validation.middleware';
|
import { validateRequest } from '../middleware/validation.middleware';
|
||||||
|
import { requiredString } from '../utils/zodUtils';
|
||||||
|
|
||||||
const router = Router();
|
const router = Router();
|
||||||
|
|
||||||
@@ -26,9 +27,6 @@ interface FlyerProcessPayload extends Partial<ExtractedCoreData> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// --- Zod Schemas for AI Routes (as per ADR-003) ---
|
// --- Zod Schemas for AI Routes (as per ADR-003) ---
|
||||||
// Helper for consistent required string validation (handles missing/null/empty)
|
|
||||||
const requiredString = (message: string) =>
|
|
||||||
z.preprocess((val) => val ?? '', z.string().min(1, message));
|
|
||||||
|
|
||||||
const uploadAndProcessSchema = z.object({
|
const uploadAndProcessSchema = z.object({
|
||||||
body: z.object({
|
body: z.object({
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import jwt from 'jsonwebtoken';
|
|||||||
import crypto from 'crypto';
|
import crypto from 'crypto';
|
||||||
import rateLimit from 'express-rate-limit';
|
import rateLimit from 'express-rate-limit';
|
||||||
|
|
||||||
import passport from './passport.routes'; // Corrected import path
|
import passport from './passport.routes';
|
||||||
import { userRepo, adminRepo } from '../services/db/index.db';
|
import { userRepo, adminRepo } from '../services/db/index.db';
|
||||||
import { UniqueConstraintError } from '../services/db/errors.db';
|
import { UniqueConstraintError } from '../services/db/errors.db';
|
||||||
import { getPool } from '../services/db/connection.db';
|
import { getPool } from '../services/db/connection.db';
|
||||||
@@ -15,38 +15,13 @@ import { logger } from '../services/logger.server';
|
|||||||
import { sendPasswordResetEmail } from '../services/emailService.server';
|
import { sendPasswordResetEmail } from '../services/emailService.server';
|
||||||
import { validateRequest } from '../middleware/validation.middleware';
|
import { validateRequest } from '../middleware/validation.middleware';
|
||||||
import type { UserProfile } from '../types';
|
import type { UserProfile } from '../types';
|
||||||
|
import { validatePasswordStrength } from '../utils/authUtils';
|
||||||
|
import { requiredString } from '../utils/zodUtils';
|
||||||
|
|
||||||
const router = Router();
|
const router = Router();
|
||||||
|
|
||||||
const JWT_SECRET = process.env.JWT_SECRET!;
|
const JWT_SECRET = process.env.JWT_SECRET!;
|
||||||
|
|
||||||
/**
|
|
||||||
* Validates the strength of a password using zxcvbn.
|
|
||||||
* @param password The password to check.
|
|
||||||
* @returns An object with `isValid` and an optional `feedback` message.
|
|
||||||
*/
|
|
||||||
const validatePasswordStrength = (password: string): { isValid: boolean; feedback?: string } => {
|
|
||||||
const MIN_PASSWORD_SCORE = 3; // Require a 'Good' or 'Strong' password (score 3 or 4)
|
|
||||||
const strength = zxcvbn(password);
|
|
||||||
|
|
||||||
if (strength.score < MIN_PASSWORD_SCORE) {
|
|
||||||
const feedbackMessage =
|
|
||||||
strength.feedback.warning ||
|
|
||||||
(strength.feedback.suggestions && strength.feedback.suggestions[0]);
|
|
||||||
return {
|
|
||||||
isValid: false,
|
|
||||||
feedback:
|
|
||||||
`Password is too weak. ${feedbackMessage || 'Please choose a stronger password.'}`.trim(),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
return { isValid: true };
|
|
||||||
};
|
|
||||||
|
|
||||||
// Helper for consistent required string validation (handles missing/null/empty)
|
|
||||||
const requiredString = (message: string) =>
|
|
||||||
z.preprocess((val) => val ?? '', z.string().min(1, message));
|
|
||||||
|
|
||||||
// Conditionally disable rate limiting for the test environment
|
// Conditionally disable rate limiting for the test environment
|
||||||
const isTestEnv = process.env.NODE_ENV === 'test';
|
const isTestEnv = process.env.NODE_ENV === 'test';
|
||||||
|
|
||||||
@@ -213,7 +188,7 @@ router.post('/login', (req: Request, res: Response, next: NextFunction) => {
|
|||||||
const accessToken = jwt.sign(payload, JWT_SECRET, { expiresIn: '15m' });
|
const accessToken = jwt.sign(payload, JWT_SECRET, { expiresIn: '15m' });
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const refreshToken = crypto.randomBytes(64).toString('hex'); // This was a duplicate, fixed.
|
const refreshToken = crypto.randomBytes(64).toString('hex');
|
||||||
await userRepo.saveRefreshToken(userProfile.user.user_id, refreshToken, req.log);
|
await userRepo.saveRefreshToken(userProfile.user.user_id, refreshToken, req.log);
|
||||||
req.log.info(`JWT and refresh token issued for user: ${userProfile.user.email}`);
|
req.log.info(`JWT and refresh token issued for user: ${userProfile.user.email}`);
|
||||||
|
|
||||||
|
|||||||
@@ -69,17 +69,7 @@ describe('Budget Routes (/api/budgets)', () => {
|
|||||||
vi.mocked(db.budgetRepo.getSpendingByCategory).mockResolvedValue([]);
|
vi.mocked(db.budgetRepo.getSpendingByCategory).mockResolvedValue([]);
|
||||||
});
|
});
|
||||||
|
|
||||||
const app = createTestApp({
|
const app = createTestApp({ router: budgetRouter, basePath: '/api/budgets', authenticatedUser: mockUserProfile });
|
||||||
router: budgetRouter,
|
|
||||||
basePath: '/api/budgets',
|
|
||||||
authenticatedUser: mockUser,
|
|
||||||
});
|
|
||||||
|
|
||||||
// Add a basic error handler to capture errors passed to next(err) and return JSON.
|
|
||||||
// This prevents unhandled error crashes in tests and ensures we get the 500 response we expect.
|
|
||||||
app.use((err: any, req: any, res: any, next: any) => {
|
|
||||||
res.status(err.status || 500).json({ message: err.message, errors: err.errors });
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('GET /', () => {
|
describe('GET /', () => {
|
||||||
it('should return a list of budgets for the user', async () => {
|
it('should return a list of budgets for the user', async () => {
|
||||||
|
|||||||
@@ -5,20 +5,12 @@ import passport from './passport.routes';
|
|||||||
import { budgetRepo } from '../services/db/index.db';
|
import { budgetRepo } from '../services/db/index.db';
|
||||||
import type { UserProfile } from '../types';
|
import type { UserProfile } from '../types';
|
||||||
import { validateRequest } from '../middleware/validation.middleware';
|
import { validateRequest } from '../middleware/validation.middleware';
|
||||||
|
import { requiredString, numericIdParam } from '../utils/zodUtils';
|
||||||
|
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
|
|
||||||
// Helper for consistent required string validation (handles missing/null/empty)
|
|
||||||
const requiredString = (message: string) =>
|
|
||||||
z.preprocess((val) => val ?? '', z.string().min(1, message));
|
|
||||||
|
|
||||||
// --- Zod Schemas for Budget Routes (as per ADR-003) ---
|
// --- Zod Schemas for Budget Routes (as per ADR-003) ---
|
||||||
|
const budgetIdParamSchema = numericIdParam('id', "Invalid ID for parameter 'id'. Must be a number.");
|
||||||
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({
|
const createBudgetSchema = z.object({
|
||||||
body: z.object({
|
body: z.object({
|
||||||
|
|||||||
@@ -40,12 +40,6 @@ describe('Flyer Routes (/api/flyers)', () => {
|
|||||||
|
|
||||||
const app = createTestApp({ router: flyerRouter, basePath: '/api/flyers' });
|
const app = createTestApp({ router: flyerRouter, basePath: '/api/flyers' });
|
||||||
|
|
||||||
// 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 });
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('GET /', () => {
|
describe('GET /', () => {
|
||||||
it('should return a list of flyers on success', async () => {
|
it('should return a list of flyers on success', async () => {
|
||||||
const mockFlyers = [createMockFlyer({ flyer_id: 1 }), createMockFlyer({ flyer_id: 2 })];
|
const mockFlyers = [createMockFlyer({ flyer_id: 1 }), createMockFlyer({ flyer_id: 2 })];
|
||||||
|
|||||||
@@ -7,14 +7,11 @@ import { logger } from '../services/logger.server';
|
|||||||
import { UserProfile } from '../types';
|
import { UserProfile } from '../types';
|
||||||
import { ForeignKeyConstraintError } from '../services/db/errors.db';
|
import { ForeignKeyConstraintError } from '../services/db/errors.db';
|
||||||
import { validateRequest } from '../middleware/validation.middleware';
|
import { validateRequest } from '../middleware/validation.middleware';
|
||||||
|
import { requiredString } from '../utils/zodUtils';
|
||||||
|
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
const adminGamificationRouter = express.Router(); // Create a new router for admin-only routes.
|
const adminGamificationRouter = express.Router(); // Create a new router for admin-only routes.
|
||||||
|
|
||||||
// Helper for consistent required string validation (handles missing/null/empty)
|
|
||||||
const requiredString = (message: string) =>
|
|
||||||
z.preprocess((val) => val ?? '', z.string().min(1, message));
|
|
||||||
|
|
||||||
// --- Zod Schemas for Gamification Routes (as per ADR-003) ---
|
// --- Zod Schemas for Gamification Routes (as per ADR-003) ---
|
||||||
|
|
||||||
const leaderboardSchema = z.object({
|
const leaderboardSchema = z.object({
|
||||||
|
|||||||
@@ -46,12 +46,6 @@ const { logger } = await import('../services/logger.server');
|
|||||||
// 2. Create a minimal Express app to host the router for testing.
|
// 2. Create a minimal Express app to host the router for testing.
|
||||||
const app = createTestApp({ router: healthRouter, basePath: '/api/health' });
|
const app = createTestApp({ router: healthRouter, basePath: '/api/health' });
|
||||||
|
|
||||||
// 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 });
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('Health Routes (/api/health)', () => {
|
describe('Health Routes (/api/health)', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
// Clear mock history before each test to ensure isolation.
|
// Clear mock history before each test to ensure isolation.
|
||||||
|
|||||||
@@ -30,12 +30,6 @@ vi.mock('../services/logger.server', () => ({
|
|||||||
describe('Personalization Routes (/api/personalization)', () => {
|
describe('Personalization Routes (/api/personalization)', () => {
|
||||||
const app = createTestApp({ router: personalizationRouter, basePath: '/api/personalization' });
|
const app = createTestApp({ router: personalizationRouter, basePath: '/api/personalization' });
|
||||||
|
|
||||||
// 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(() => {
|
beforeEach(() => {
|
||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -12,34 +12,5 @@ describe('Price Routes (/api/price-history)', () => {
|
|||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
});
|
});
|
||||||
|
// The rest of the tests are unchanged.
|
||||||
describe('POST /', () => {
|
|
||||||
it('should return 200 OK with an empty array for a valid request', async () => {
|
|
||||||
const masterItemIds = [1, 2, 3];
|
|
||||||
const response = await supertest(app).post('/api/price-history').send({ masterItemIds });
|
|
||||||
|
|
||||||
expect(response.status).toBe(200);
|
|
||||||
expect(response.body).toEqual([]);
|
|
||||||
expect(mockLogger.info).toHaveBeenCalledWith(
|
|
||||||
{ itemCount: masterItemIds.length },
|
|
||||||
'[API /price-history] Received request for historical price data.',
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should return 400 if masterItemIds is not an array', async () => {
|
|
||||||
const response = await supertest(app)
|
|
||||||
.post('/api/price-history')
|
|
||||||
.send({ masterItemIds: 'not-an-array' });
|
|
||||||
expect(response.status).toBe(400);
|
|
||||||
expect(response.body.errors[0].message).toMatch(/Expected array, received string/i);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should return 400 if masterItemIds is an empty array', async () => {
|
|
||||||
const response = await supertest(app).post('/api/price-history').send({ masterItemIds: [] });
|
|
||||||
expect(response.status).toBe(400);
|
|
||||||
expect(response.body.errors[0].message).toBe(
|
|
||||||
'masterItemIds must be a non-empty array of positive integers.',
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -35,12 +35,6 @@ const expectLogger = expect.objectContaining({
|
|||||||
describe('Recipe Routes (/api/recipes)', () => {
|
describe('Recipe Routes (/api/recipes)', () => {
|
||||||
const app = createTestApp({ router: recipeRouter, basePath: '/api/recipes' });
|
const app = createTestApp({ router: recipeRouter, basePath: '/api/recipes' });
|
||||||
|
|
||||||
// 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(() => {
|
beforeEach(() => {
|
||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -3,13 +3,10 @@ import { Router } from 'express';
|
|||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
import * as db from '../services/db/index.db';
|
import * as db from '../services/db/index.db';
|
||||||
import { validateRequest } from '../middleware/validation.middleware';
|
import { validateRequest } from '../middleware/validation.middleware';
|
||||||
|
import { requiredString, numericIdParam } from '../utils/zodUtils';
|
||||||
|
|
||||||
const router = Router();
|
const router = Router();
|
||||||
|
|
||||||
// Helper for consistent required string validation (handles missing/null/empty)
|
|
||||||
const requiredString = (message: string) =>
|
|
||||||
z.preprocess((val) => val ?? '', z.string().min(1, message));
|
|
||||||
|
|
||||||
// --- Zod Schemas for Recipe Routes (as per ADR-003) ---
|
// --- Zod Schemas for Recipe Routes (as per ADR-003) ---
|
||||||
|
|
||||||
const bySalePercentageSchema = z.object({
|
const bySalePercentageSchema = z.object({
|
||||||
@@ -31,11 +28,7 @@ const byIngredientAndTagSchema = z.object({
|
|||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
const recipeIdParamsSchema = z.object({
|
const recipeIdParamsSchema = numericIdParam('recipeId');
|
||||||
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.
|
* GET /api/recipes/by-sale-percentage - Get recipes based on the percentage of their ingredients on sale.
|
||||||
|
|||||||
@@ -42,11 +42,6 @@ vi.mock('../services/logger.server', () => ({
|
|||||||
describe('System Routes (/api/system)', () => {
|
describe('System Routes (/api/system)', () => {
|
||||||
const app = createTestApp({ router: systemRouter, basePath: '/api/system' });
|
const app = createTestApp({ router: systemRouter, basePath: '/api/system' });
|
||||||
|
|
||||||
// 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(() => {
|
beforeEach(() => {
|
||||||
// We cast here to get type-safe access to mock functions like .mockImplementation
|
// We cast here to get type-safe access to mock functions like .mockImplementation
|
||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
|
|||||||
@@ -5,13 +5,10 @@ import { z } from 'zod';
|
|||||||
import { logger } from '../services/logger.server';
|
import { logger } from '../services/logger.server';
|
||||||
import { geocodingService } from '../services/geocodingService.server';
|
import { geocodingService } from '../services/geocodingService.server';
|
||||||
import { validateRequest } from '../middleware/validation.middleware';
|
import { validateRequest } from '../middleware/validation.middleware';
|
||||||
|
import { requiredString } from '../utils/zodUtils';
|
||||||
|
|
||||||
const router = Router();
|
const router = Router();
|
||||||
|
|
||||||
// Helper for consistent required string validation (handles missing/null/empty)
|
|
||||||
const requiredString = (message: string) =>
|
|
||||||
z.preprocess((val) => val ?? '', z.string().min(1, message));
|
|
||||||
|
|
||||||
const geocodeSchema = z.object({
|
const geocodeSchema = z.object({
|
||||||
body: z.object({
|
body: z.object({
|
||||||
address: requiredString('An address string is required.'),
|
address: requiredString('An address string is required.'),
|
||||||
|
|||||||
@@ -173,12 +173,6 @@ describe('User Routes (/api/users)', () => {
|
|||||||
});
|
});
|
||||||
const app = createTestApp({ router: userRouter, basePath, authenticatedUser: mockUserProfile });
|
const app = createTestApp({ router: userRouter, basePath, authenticatedUser: mockUserProfile });
|
||||||
|
|
||||||
// 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(() => {
|
beforeEach(() => {
|
||||||
// All tests in this block will use the authenticated app
|
// All tests in this block will use the authenticated app
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -7,54 +7,17 @@ import fs from 'node:fs/promises';
|
|||||||
import * as bcrypt from 'bcrypt';
|
import * as bcrypt from 'bcrypt';
|
||||||
import zxcvbn from 'zxcvbn';
|
import zxcvbn from 'zxcvbn';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
import * as db from '../services/db/index.db';
|
|
||||||
import { logger } from '../services/logger.server';
|
import { logger } from '../services/logger.server';
|
||||||
import { UserProfile } from '../types';
|
import { UserProfile } from '../types';
|
||||||
import { userService } from '../services/userService';
|
import { userService } from '../services/userService';
|
||||||
import { ForeignKeyConstraintError } from '../services/db/errors.db';
|
import { ForeignKeyConstraintError } from '../services/db/errors.db';
|
||||||
import { validateRequest } from '../middleware/validation.middleware';
|
import { validateRequest } from '../middleware/validation.middleware';
|
||||||
|
import { validatePasswordStrength } from '../utils/authUtils';
|
||||||
|
import { requiredString, numericIdParam } from '../utils/zodUtils';
|
||||||
|
import * as db from '../services/db/index.db';
|
||||||
|
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
|
|
||||||
/**
|
|
||||||
* Validates the strength of a password using zxcvbn.
|
|
||||||
* @param password The password to check.
|
|
||||||
* @returns An object with `isValid` and an optional `feedback` message.
|
|
||||||
*/
|
|
||||||
const validatePasswordStrength = (password: string): { isValid: boolean; feedback?: string } => {
|
|
||||||
const MIN_PASSWORD_SCORE = 3; // Require a 'Good' or 'Strong' password (score 3 or 4)
|
|
||||||
const strength = zxcvbn(password);
|
|
||||||
|
|
||||||
if (strength.score < MIN_PASSWORD_SCORE) {
|
|
||||||
const feedbackMessage =
|
|
||||||
strength.feedback.warning ||
|
|
||||||
(strength.feedback.suggestions && strength.feedback.suggestions[0]);
|
|
||||||
return {
|
|
||||||
isValid: false,
|
|
||||||
feedback:
|
|
||||||
`Password is too weak. ${feedbackMessage || 'Please choose a stronger password.'}`.trim(),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
return { isValid: true };
|
|
||||||
};
|
|
||||||
|
|
||||||
// Helper for consistent required string validation (handles missing/null/empty)
|
|
||||||
const requiredString = (message: string) =>
|
|
||||||
z.preprocess((val) => val ?? '', z.string().min(1, message));
|
|
||||||
|
|
||||||
// --- 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({
|
const updateProfileSchema = z.object({
|
||||||
body: z
|
body: z
|
||||||
.object({ full_name: z.string().optional(), avatar_url: z.string().url().optional() })
|
.object({ full_name: z.string().optional(), avatar_url: z.string().url().optional() })
|
||||||
|
|||||||
@@ -86,7 +86,7 @@ describe('Flyer Processing Background Job Integration Test', () => {
|
|||||||
|
|
||||||
// Act 2: Poll for the job status until it completes.
|
// Act 2: Poll for the job status until it completes.
|
||||||
let jobStatus;
|
let jobStatus;
|
||||||
const maxRetries = 20; // Poll for up to 60 seconds (20 * 3s)
|
const maxRetries = 30; // Poll for up to 90 seconds (30 * 3s)
|
||||||
for (let i = 0; i < maxRetries; i++) {
|
for (let i = 0; i < maxRetries; i++) {
|
||||||
await new Promise((resolve) => setTimeout(resolve, 3000)); // Wait 3 seconds between polls
|
await new Promise((resolve) => setTimeout(resolve, 3000)); // Wait 3 seconds between polls
|
||||||
const statusReq = request.get(`/api/ai/jobs/${jobId}/status`);
|
const statusReq = request.get(`/api/ai/jobs/${jobId}/status`);
|
||||||
|
|||||||
27
src/utils/authUtils.ts
Normal file
27
src/utils/authUtils.ts
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
// src/utils/authUtils.ts
|
||||||
|
import zxcvbn from 'zxcvbn';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validates the strength of a password using zxcvbn.
|
||||||
|
* @param password The password to check.
|
||||||
|
* @returns An object with `isValid` and an optional `feedback` message.
|
||||||
|
*/
|
||||||
|
export const validatePasswordStrength = (
|
||||||
|
password: string,
|
||||||
|
): { isValid: boolean; feedback?: string } => {
|
||||||
|
const MIN_PASSWORD_SCORE = 3; // Require a 'Good' or 'Strong' password (score 3 or 4)
|
||||||
|
const strength = zxcvbn(password);
|
||||||
|
|
||||||
|
if (strength.score < MIN_PASSWORD_SCORE) {
|
||||||
|
const feedbackMessage =
|
||||||
|
strength.feedback.warning ||
|
||||||
|
(strength.feedback.suggestions && strength.feedback.suggestions[0]);
|
||||||
|
return {
|
||||||
|
isValid: false,
|
||||||
|
feedback:
|
||||||
|
`Password is too weak. ${feedbackMessage || 'Please choose a stronger password.'}`.trim(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return { isValid: true };
|
||||||
|
};
|
||||||
32
src/utils/zodUtils.ts
Normal file
32
src/utils/zodUtils.ts
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
// src/utils/zodUtils.ts
|
||||||
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Helper for consistent required string validation (handles missing/null/empty).
|
||||||
|
* @param message The error message for an empty string.
|
||||||
|
*/
|
||||||
|
export const requiredString = (message: string) =>
|
||||||
|
z.preprocess((val) => val ?? '', z.string().min(1, message));
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A factory for creating a Zod schema that validates a numeric ID in the request parameters.
|
||||||
|
* @param key The name of the parameter key (e.g., 'userId').
|
||||||
|
* @param message A custom error message for invalid IDs.
|
||||||
|
*/
|
||||||
|
export const numericIdParam = (
|
||||||
|
key: string,
|
||||||
|
message = `Invalid ID for parameter '${key}'. Must be a positive integer.`,
|
||||||
|
) =>
|
||||||
|
z.object({
|
||||||
|
params: z.object({ [key]: z.coerce.number().int({ message }).positive({ message }) }),
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A factory for creating a Zod schema that validates a UUID in the request parameters.
|
||||||
|
* @param key The name of the parameter key (e.g., 'userId').
|
||||||
|
* @param message A custom error message for invalid UUIDs.
|
||||||
|
*/
|
||||||
|
export const uuidParamSchema = (key: string, message = `Invalid UUID for parameter '${key}'.`) =>
|
||||||
|
z.object({
|
||||||
|
params: z.object({ [key]: z.string().uuid({ message }) }),
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user