unit test fixes + error refactor
All checks were successful
Deploy to Test Environment / deploy-to-test (push) Successful in 10m20s
All checks were successful
Deploy to Test Environment / deploy-to-test (push) Successful in 10m20s
This commit is contained in:
516
src/middleware/admin.routes.ts
Normal file
516
src/middleware/admin.routes.ts
Normal file
@@ -0,0 +1,516 @@
|
||||
// src/routes/admin.routes.ts
|
||||
import { Router, Request, Response, NextFunction } from 'express';
|
||||
import passport from '../routes/passport.routes';
|
||||
import { isAdmin } from '../routes/passport.routes'; // Correctly imported
|
||||
import multer from 'multer';
|
||||
import crypto from 'crypto';
|
||||
|
||||
import * as db from '../services/db/index.db';
|
||||
import { logger } from '../services/logger.server';
|
||||
import { UserProfile } from '../types';
|
||||
import { clearGeocodeCache } from '../services/geocodingService.server';
|
||||
import { ForeignKeyConstraintError } from '../services/db/errors.db';
|
||||
|
||||
// --- Bull Board (Job Queue UI) Imports ---
|
||||
import { createBullBoard } from '@bull-board/api';
|
||||
import { BullMQAdapter } from '@bull-board/api/bullMQAdapter';
|
||||
import { ExpressAdapter } from '@bull-board/express';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
|
||||
import type { Queue } from 'bullmq';
|
||||
import { backgroundJobService } from '../services/backgroundJobService';
|
||||
import { flyerQueue, emailQueue, analyticsQueue, cleanupQueue, weeklyAnalyticsQueue, flyerWorker, emailWorker, analyticsWorker, cleanupWorker, weeklyAnalyticsWorker } from '../services/queueService.server'; // Import your queues
|
||||
import { getSimpleWeekAndYear } from '../utils/dateUtils';
|
||||
|
||||
const router = Router();
|
||||
|
||||
// --- Multer Configuration for File Uploads ---
|
||||
const storagePath = process.env.STORAGE_PATH || '/var/www/flyer-crawler.projectium.com/flyer-images';
|
||||
const storage = multer.diskStorage({
|
||||
destination: function (req, file, cb) {
|
||||
cb(null, storagePath);
|
||||
},
|
||||
filename: function (req, file, cb) {
|
||||
const uniqueSuffix = Date.now() + '-' + Math.round(Math.random() * 1E9);
|
||||
cb(null, file.fieldname + '-' + uniqueSuffix + '-' + file.originalname);
|
||||
}
|
||||
});
|
||||
const upload = multer({ storage: storage });
|
||||
|
||||
// --- Bull Board (Job Queue UI) Setup ---
|
||||
const serverAdapter = new ExpressAdapter();
|
||||
serverAdapter.setBasePath('/api/admin/jobs'); // Set the base path for the UI
|
||||
|
||||
createBullBoard({
|
||||
queues: [
|
||||
new BullMQAdapter(flyerQueue),
|
||||
new BullMQAdapter(emailQueue),
|
||||
new BullMQAdapter(analyticsQueue),
|
||||
new BullMQAdapter(cleanupQueue),
|
||||
new BullMQAdapter(weeklyAnalyticsQueue), // Add the weekly analytics queue to the board
|
||||
],
|
||||
options: {
|
||||
uiConfig: {
|
||||
boardTitle: 'Bull Dashboard',
|
||||
},
|
||||
},
|
||||
serverAdapter: serverAdapter,
|
||||
});
|
||||
|
||||
// Mount the Bull Board UI router. This must be done BEFORE the isAdmin middleware
|
||||
// so the UI's own assets can be served, but the routes within it will be protected
|
||||
// by the router-level `isAdmin` middleware below.
|
||||
router.use('/jobs', serverAdapter.getRouter());
|
||||
|
||||
// --- Middleware for all admin routes ---
|
||||
router.use(passport.authenticate('jwt', { session: false }), isAdmin);
|
||||
|
||||
// --- Admin Routes ---
|
||||
|
||||
router.get('/corrections', async (req, res, next: NextFunction) => {
|
||||
try {
|
||||
const corrections = await db.adminRepo.getSuggestedCorrections();
|
||||
res.json(corrections);
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
router.get('/brands', async (req, res, next: NextFunction) => {
|
||||
try {
|
||||
const brands = await db.flyerRepo.getAllBrands();
|
||||
res.json(brands);
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
router.get('/stats', async (req, res, next: NextFunction) => {
|
||||
try {
|
||||
const stats = await db.adminRepo.getApplicationStats();
|
||||
res.json(stats);
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
router.get('/stats/daily', async (req, res, next: NextFunction) => {
|
||||
try {
|
||||
const dailyStats = await db.adminRepo.getDailyStatsForLast30Days();
|
||||
res.json(dailyStats);
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
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.' });
|
||||
}
|
||||
try {
|
||||
await db.adminRepo.approveCorrection(correctionId);
|
||||
res.status(200).json({ message: 'Correction approved successfully.' });
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
router.post('/corrections/:id/reject', async (req, res, next: NextFunction) => {
|
||||
try {
|
||||
const correctionId = parseInt(req.params.id, 10);
|
||||
await db.adminRepo.rejectCorrection(correctionId);
|
||||
res.status(200).json({ message: 'Correction rejected successfully.' });
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
router.put('/corrections/:id', async (req, res, next: NextFunction) => {
|
||||
const correctionId = parseInt(req.params.id, 10);
|
||||
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) {
|
||||
if (error instanceof Error && error.message.includes('not found')) {
|
||||
return res.status(404).json({ message: error.message });
|
||||
}
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
router.put('/recipes/:id/status', async (req, res, next: NextFunction) => {
|
||||
const recipeId = parseInt(req.params.id, 10);
|
||||
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);
|
||||
} catch (error) {
|
||||
if (error instanceof Error && error.message.includes('not found')) {
|
||||
return res.status(404).json({ message: error.message });
|
||||
}
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
router.post('/brands/:id/logo', upload.single('logoImage'), async (req, res, next: NextFunction) => {
|
||||
const brandId = parseInt(req.params.id, 10);
|
||||
if (!req.file) {
|
||||
return res.status(400).json({ message: 'Logo image file is required.' });
|
||||
}
|
||||
|
||||
try {
|
||||
const logoUrl = `/assets/${req.file.filename}`;
|
||||
await db.adminRepo.updateBrandLogo(brandId, logoUrl);
|
||||
|
||||
logger.info(`Brand logo updated for brand ID: ${brandId}`, { brandId, logoUrl });
|
||||
res.status(200).json({ message: 'Brand logo updated successfully.', logoUrl });
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
router.get('/unmatched-items', async (req, res, next: NextFunction) => {
|
||||
try {
|
||||
const items = await db.adminRepo.getUnmatchedFlyerItems();
|
||||
res.json(items);
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* DELETE /api/admin/recipes/:recipeId - Admin endpoint to delete any recipe.
|
||||
*/
|
||||
router.delete('/recipes/: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.' });
|
||||
}
|
||||
|
||||
try {
|
||||
// The isAdmin flag bypasses the ownership check in the repository method.
|
||||
await db.recipeRepo.deleteRecipe(recipeId, adminUser.user_id, true);
|
||||
res.status(204).send();
|
||||
} catch (error) {
|
||||
if (error instanceof Error && error.message.includes('not found')) {
|
||||
return res.status(404).json({ message: error.message });
|
||||
}
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* 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.' });
|
||||
}
|
||||
|
||||
try {
|
||||
await db.flyerRepo.deleteFlyer(flyerId);
|
||||
res.status(204).send();
|
||||
} catch (error) {
|
||||
if (error instanceof Error && error.message.includes('not found')) {
|
||||
return res.status(404).json({ message: error.message });
|
||||
}
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
router.put('/comments/:id/status', async (req, res, next: NextFunction) => {
|
||||
const commentId = parseInt(req.params.id, 10);
|
||||
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);
|
||||
} catch (error) {
|
||||
if (error instanceof Error && error.message.includes('not found')) {
|
||||
return res.status(404).json({ message: error.message });
|
||||
}
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
router.get('/users', async (req, res, next: NextFunction) => {
|
||||
try {
|
||||
const users = await db.adminRepo.getAllUsers();
|
||||
res.json(users);
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
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;
|
||||
try {
|
||||
const logs = await db.adminRepo.getActivityLog(limit, offset);
|
||||
res.json(logs);
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
router.get('/users/:id', async (req, res, next: NextFunction) => {
|
||||
try {
|
||||
const user = await db.userRepo.findUserProfileById(req.params.id);
|
||||
if (!user) {
|
||||
return res.status(404).json({ message: 'User not found.' });
|
||||
}
|
||||
res.json(user);
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
router.put('/users/:id', 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);
|
||||
res.json(updatedUser);
|
||||
} catch (error) {
|
||||
if (error instanceof ForeignKeyConstraintError) { // This error is thrown by the DB layer
|
||||
return res.status(404).json({ message: `User with ID ${req.params.id} not found.` });
|
||||
} else if (error instanceof Error && error.message.includes('not found')) { // This handles the generic "not found" from the repo
|
||||
return res.status(404).json({ message: error.message });
|
||||
}
|
||||
logger.error(`Error updating user ${req.params.id}:`, { error });
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
router.delete('/users/:id', 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.' });
|
||||
}
|
||||
try {
|
||||
await db.userRepo.deleteUserById(req.params.id);
|
||||
res.status(204).send();
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /api/admin/trigger/daily-deal-check - Manually trigger the daily deal check job.
|
||||
* This is useful for testing or forcing an update without waiting for the cron schedule.
|
||||
*/
|
||||
router.post('/trigger/daily-deal-check', async (req, res, next: NextFunction) => {
|
||||
const adminUser = req.user as UserProfile;
|
||||
logger.info(`[Admin] Manual trigger for daily deal check received from user: ${adminUser.user_id}`);
|
||||
|
||||
try {
|
||||
// We call the function but don't wait for it to finish (no `await`).
|
||||
// This is a "fire-and-forget" operation from the client's perspective.
|
||||
backgroundJobService.runDailyDealCheck();
|
||||
res.status(202).json({ message: 'Daily deal check job has been triggered successfully. It will run in the background.' });
|
||||
} catch (error) {
|
||||
logger.error('[Admin] Failed to trigger daily deal check job.', { error });
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /api/admin/trigger/analytics-report - Manually enqueue a job to generate the daily analytics report.
|
||||
* This is useful for testing or re-generating a report without waiting for the cron schedule.
|
||||
*/
|
||||
router.post('/trigger/analytics-report', async (req, res, next: NextFunction) => {
|
||||
const adminUser = req.user as UserProfile;
|
||||
logger.info(`[Admin] Manual trigger for analytics report generation received from user: ${adminUser.user_id}`);
|
||||
|
||||
try {
|
||||
const reportDate = new Date().toISOString().split('T')[0]; // YYYY-MM-DD
|
||||
// Use a unique job ID for manual triggers to distinguish them from scheduled jobs.
|
||||
const jobId = `manual-report-${reportDate}-${Date.now()}`;
|
||||
|
||||
const job = await analyticsQueue.add('generate-daily-report', { reportDate }, { jobId });
|
||||
|
||||
res.status(202).json({ message: `Analytics report generation job has been enqueued successfully. Job ID: ${job.id}` });
|
||||
} catch (error) {
|
||||
logger.error('[Admin] Failed to enqueue analytics report job.', { error });
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /api/admin/flyers/:flyerId/cleanup - Enqueue a job to clean up a flyer's files.
|
||||
* This is triggered by an admin after they have verified the flyer processing was successful.
|
||||
*/
|
||||
router.post('/flyers/:flyerId/cleanup', 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.' });
|
||||
}
|
||||
|
||||
logger.info(`[Admin] Manual trigger for flyer file cleanup received from user: ${adminUser.user_id} for flyer ID: ${flyerId}`);
|
||||
|
||||
// Enqueue the cleanup job. The worker will handle the file deletion.
|
||||
try {
|
||||
await cleanupQueue.add('cleanup-flyer-files', { flyerId });
|
||||
res.status(202).json({ message: `File cleanup job for flyer ID ${flyerId} has been enqueued.` });
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /api/admin/trigger/failing-job - Enqueue a test job designed to fail.
|
||||
* This is for testing the retry mechanism and Bull Board UI.
|
||||
*/
|
||||
router.post('/trigger/failing-job', async (req, res, next: NextFunction) => {
|
||||
const adminUser = req.user as UserProfile;
|
||||
logger.info(`[Admin] Manual trigger for a failing job received from user: ${adminUser.user_id}`);
|
||||
|
||||
try {
|
||||
// Add a job with a special 'forceFail' flag that the worker will recognize.
|
||||
const job = await analyticsQueue.add('generate-daily-report', { reportDate: 'FAIL' });
|
||||
res.status(202).json({ message: `Failing test job has been enqueued successfully. Job ID: ${job.id}` });
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /api/admin/system/clear-geocode-cache - Clears the Redis cache for geocoded addresses.
|
||||
* Requires admin privileges.
|
||||
*/
|
||||
router.post('/system/clear-geocode-cache', async (req, res, next: NextFunction) => {
|
||||
const adminUser = req.user as UserProfile;
|
||||
logger.info(`[Admin] Manual trigger for geocode cache clear received from user: ${adminUser.user_id}`);
|
||||
|
||||
try {
|
||||
const keysDeleted = await clearGeocodeCache();
|
||||
res.status(200).json({ message: `Successfully cleared the geocode cache. ${keysDeleted} keys were removed.` });
|
||||
} catch (error) {
|
||||
logger.error('[Admin] Failed to clear geocode cache.', { error });
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /api/admin/workers/status - Get the current running status of all BullMQ workers.
|
||||
* This is useful for a system health dashboard to see if any workers have crashed.
|
||||
*/
|
||||
router.get('/workers/status', async (req, res) => {
|
||||
const workers = [flyerWorker, emailWorker, analyticsWorker, cleanupWorker, weeklyAnalyticsWorker ];
|
||||
|
||||
const workerStatuses = await Promise.all(
|
||||
workers.map(async (worker) => {
|
||||
return {
|
||||
name: worker.name,
|
||||
isRunning: worker.isRunning(),
|
||||
};
|
||||
})
|
||||
);
|
||||
|
||||
res.json(workerStatuses);
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /api/admin/queues/status - Get job counts for all BullMQ queues.
|
||||
* This is useful for monitoring the health and backlog of background jobs.
|
||||
*/
|
||||
router.get('/queues/status', async (req, res, next: NextFunction) => {
|
||||
try {
|
||||
const queues = [flyerQueue, emailQueue, analyticsQueue, cleanupQueue, weeklyAnalyticsQueue];
|
||||
|
||||
const queueStatuses = await Promise.all(
|
||||
queues.map(async (queue) => {
|
||||
return {
|
||||
name: queue.name,
|
||||
counts: await queue.getJobCounts('waiting', 'active', 'completed', 'failed', 'delayed', 'paused'),
|
||||
};
|
||||
})
|
||||
);
|
||||
res.json(queueStatuses);
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /api/admin/jobs/:queueName/:jobId/retry - Retries a specific failed job.
|
||||
*/
|
||||
router.post('/jobs/:queueName/:jobId/retry', async (req, res, next: NextFunction) => {
|
||||
const { queueName, jobId } = req.params;
|
||||
const adminUser = req.user as UserProfile;
|
||||
|
||||
const queueMap: { [key: string]: Queue } = {
|
||||
'flyer-processing': flyerQueue,
|
||||
'email-sending': emailQueue,
|
||||
'analytics-reporting': analyticsQueue,
|
||||
'file-cleanup': cleanupQueue,
|
||||
};
|
||||
|
||||
const queue = queueMap[queueName];
|
||||
|
||||
if (!queue) {
|
||||
return res.status(404).json({ message: `Queue '${queueName}' not found.` });
|
||||
}
|
||||
|
||||
try {
|
||||
const job = await queue.getJob(jobId);
|
||||
if (!job) {
|
||||
return res.status(404).json({ message: `Job with ID '${jobId}' not found in queue '${queueName}'.` });
|
||||
}
|
||||
|
||||
const jobState = await job.getState();
|
||||
if (jobState !== 'failed') {
|
||||
return res.status(400).json({ message: `Job is not in a 'failed' state. Current state: ${jobState}.` });
|
||||
}
|
||||
|
||||
await job.retry();
|
||||
logger.info(`[Admin] User ${adminUser.user_id} manually retried job ${jobId} in queue ${queueName}.`);
|
||||
res.status(200).json({ message: `Job ${jobId} has been successfully marked for retry.` });
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /api/admin/trigger/weekly-analytics - Manually trigger the weekly analytics report job.
|
||||
*/
|
||||
router.post('/trigger/weekly-analytics', async (req, res, next: NextFunction) => {
|
||||
const adminUser = req.user as UserProfile;
|
||||
logger.info(`[Admin] Manual trigger for weekly analytics report received from user: ${adminUser.user_id}`);
|
||||
|
||||
try {
|
||||
const { year: reportYear, week: reportWeek } = getSimpleWeekAndYear();
|
||||
const { weeklyAnalyticsQueue } = await import('../services/queueService.server');
|
||||
const job = await weeklyAnalyticsQueue.add('generate-weekly-report', { reportYear, reportWeek }, {
|
||||
jobId: `manual-weekly-report-${reportYear}-${reportWeek}-${Date.now()}` // Add timestamp to avoid ID conflict
|
||||
});
|
||||
|
||||
res.status(202).json({ message: 'Successfully enqueued weekly analytics job.', jobId: job.id });
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
export default router;
|
||||
@@ -75,9 +75,9 @@ describe('errorHandler Middleware', () => {
|
||||
const response = await supertest(app).get('/generic-error');
|
||||
|
||||
expect(response.status).toBe(500);
|
||||
expect(response.body).toEqual({ message: 'An unexpected server error occurred.' });
|
||||
expect(response.body).toEqual({ message: 'A generic server error occurred.' });
|
||||
expect(logger.error).toHaveBeenCalledWith(
|
||||
expect.stringContaining('Unhandled API Error:'),
|
||||
expect.stringMatching(/Unhandled API Error \(ID: \w+\):/),
|
||||
expect.objectContaining({ error: expect.any(String), path: '/generic-error', method: 'GET' })
|
||||
);
|
||||
expect(consoleErrorSpy).toHaveBeenCalledWith(
|
||||
@@ -126,9 +126,9 @@ describe('errorHandler Middleware', () => {
|
||||
const response = await supertest(app).get('/db-error-500');
|
||||
|
||||
expect(response.status).toBe(500);
|
||||
expect(response.body).toEqual({ message: 'An unexpected server error occurred.' });
|
||||
expect(response.body).toEqual({ message: 'A database connection issue occurred.' });
|
||||
expect(logger.error).toHaveBeenCalledWith(
|
||||
expect.stringContaining('Unhandled API Error:'),
|
||||
expect.stringMatching(/Unhandled API Error \(ID: \w+\):/),
|
||||
expect.objectContaining({ error: expect.any(String), path: '/db-error-500', method: 'GET' })
|
||||
);
|
||||
expect(consoleErrorSpy).toHaveBeenCalledWith(
|
||||
@@ -157,4 +157,21 @@ describe('errorHandler Middleware', () => {
|
||||
expect(logger.error).not.toHaveBeenCalled(); // Should not log if delegated
|
||||
expect(consoleErrorSpy).not.toHaveBeenCalled(); // Should not log if delegated
|
||||
});
|
||||
|
||||
describe('when NODE_ENV is "production"', () => {
|
||||
beforeEach(() => {
|
||||
process.env.NODE_ENV = 'production';
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
process.env.NODE_ENV = 'test'; // Reset for other test files
|
||||
});
|
||||
|
||||
it('should return a generic message with an error ID for a 500 error', async () => {
|
||||
const response = await supertest(app).get('/generic-error');
|
||||
|
||||
expect(response.status).toBe(500);
|
||||
expect(response.body.message).toMatch(/An unexpected server error occurred. Please reference error ID: \w+/);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,7 @@
|
||||
// src/middleware/errorHandler.ts
|
||||
import { Request, Response, NextFunction } from 'express';
|
||||
import { DatabaseError, UniqueConstraintError, ForeignKeyConstraintError } from '../services/db/errors.db';
|
||||
import crypto from 'crypto';
|
||||
import { logger } from '../services/logger.server';
|
||||
|
||||
interface HttpError extends Error {
|
||||
@@ -35,16 +36,25 @@ export const errorHandler = (err: HttpError, req: Request, res: Response, next:
|
||||
console.error('--- [TEST] UNHANDLED ERROR ---', err);
|
||||
}
|
||||
|
||||
let errorId: string | undefined;
|
||||
// Log the full error details for debugging, especially for server errors.
|
||||
if (statusCode >= 500) {
|
||||
logger.error('Unhandled API Error:', { error: err.stack || err.message, path: req.path, method: req.method });
|
||||
errorId = crypto.randomBytes(4).toString('hex');
|
||||
logger.error(`Unhandled API Error (ID: ${errorId}):`, {
|
||||
error: err.stack || err.message,
|
||||
path: req.path,
|
||||
method: req.method,
|
||||
body: req.body,
|
||||
});
|
||||
}
|
||||
|
||||
// In production, send a generic message for 5xx errors.
|
||||
// In dev/test, send the actual error message for easier debugging.
|
||||
const responseMessage = (statusCode >= 500 && process.env.NODE_ENV === 'production')
|
||||
? `An unexpected server error occurred. Please reference error ID: ${errorId}`
|
||||
: message;
|
||||
|
||||
res.status(statusCode).json({
|
||||
// In production, send a generic message for 5xx errors.
|
||||
// In dev/test, send the actual error message for easier debugging.
|
||||
message: (statusCode >= 500 && process.env.NODE_ENV === 'production')
|
||||
? 'An unexpected server error occurred.'
|
||||
: message,
|
||||
message: responseMessage,
|
||||
});
|
||||
};
|
||||
@@ -140,6 +140,7 @@ describe('Admin Content Management Routes (/api/admin)', () => {
|
||||
vi.mocked(mockedDb.adminRepo.getSuggestedCorrections).mockRejectedValue(new Error('DB Error'));
|
||||
const response = await supertest(app).get('/api/admin/corrections');
|
||||
expect(response.status).toBe(500);
|
||||
expect(response.body.message).toBe('DB Error');
|
||||
});
|
||||
|
||||
it('POST /corrections/:id/approve should approve a correction', async () => {
|
||||
@@ -185,6 +186,7 @@ describe('Admin Content Management Routes (/api/admin)', () => {
|
||||
vi.mocked(mockedDb.adminRepo.updateSuggestedCorrection).mockRejectedValue(new Error('Correction with ID 999 not found'));
|
||||
const response = await supertest(app).put('/api/admin/corrections/999').send({ suggested_value: 'new value' });
|
||||
expect(response.status).toBe(404);
|
||||
expect(response.body.message).toBe('Correction with ID 999 not found');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -232,6 +234,7 @@ describe('Admin Content Management Routes (/api/admin)', () => {
|
||||
vi.mocked(mockedDb.adminRepo.updateRecipeStatus).mockRejectedValue(new Error('Recipe with ID 201 not found.'));
|
||||
const response = await supertest(app).put('/api/admin/recipes/201').send({ status: 'invalid-status' });
|
||||
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 () => {
|
||||
@@ -294,6 +297,7 @@ describe('Admin Content Management Routes (/api/admin)', () => {
|
||||
vi.mocked(mockedDb.flyerRepo.deleteFlyer).mockRejectedValue(new Error('Flyer with ID 999 not found.'));
|
||||
const response = await supertest(app).delete(`/api/admin/flyers/${flyerId}`);
|
||||
expect(response.status).toBe(404);
|
||||
expect(response.body.message).toBe('Flyer with ID 999 not found.');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -159,6 +159,7 @@ describe('Admin Job Trigger Routes (/api/admin/trigger)', () => {
|
||||
vi.mocked(analyticsQueue.add).mockRejectedValue(new Error('Queue is down'));
|
||||
const response = await supertest(app).post('/api/admin/trigger/failing-job');
|
||||
expect(response.status).toBe(500);
|
||||
expect(response.body.message).toBe('Queue is down');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -184,6 +185,7 @@ describe('Admin Job Trigger Routes (/api/admin/trigger)', () => {
|
||||
vi.mocked(cleanupQueue.add).mockRejectedValue(new Error('Queue is down'));
|
||||
const response = await supertest(app).post(`/api/admin/flyers/${flyerId}/cleanup`);
|
||||
expect(response.status).toBe(500);
|
||||
expect(response.body.message).toBe('Queue is down');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -248,7 +250,7 @@ describe('Admin Job Trigger Routes (/api/admin/trigger)', () => {
|
||||
const response = await supertest(app).post(`/api/admin/jobs/${queueName}/${jobId}/retry`);
|
||||
|
||||
expect(response.status).toBe(500);
|
||||
expect(response.body.message).toBe('Cannot retry job');
|
||||
expect(response.body.message).toContain('Cannot retry job');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -209,6 +209,7 @@ describe('Admin Monitoring Routes (/api/admin)', () => {
|
||||
|
||||
const response = await supertest(app).get('/api/admin/queues/status');
|
||||
expect(response.status).toBe(500);
|
||||
expect(response.body.message).toBe('Redis is down');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -138,7 +138,9 @@ router.put('/corrections/:id', async (req, res, next: NextFunction) => {
|
||||
res.status(200).json(updatedCorrection);
|
||||
} catch (error) {
|
||||
if (error instanceof Error && error.message.includes('not found')) {
|
||||
return res.status(404).json({ message: error.message });
|
||||
const err = new Error(error.message);
|
||||
(err as any).status = 404;
|
||||
return next(err);
|
||||
}
|
||||
next(error);
|
||||
}
|
||||
@@ -156,7 +158,9 @@ router.put('/recipes/:id/status', async (req, res, next: NextFunction) => {
|
||||
res.status(200).json(updatedRecipe);
|
||||
} catch (error) {
|
||||
if (error instanceof Error && error.message.includes('not found')) {
|
||||
return res.status(404).json({ message: error.message });
|
||||
const err = new Error(error.message);
|
||||
(err as any).status = 404;
|
||||
return next(err);
|
||||
}
|
||||
next(error);
|
||||
}
|
||||
@@ -205,7 +209,9 @@ router.delete('/recipes/:recipeId', async (req, res, next: NextFunction) => {
|
||||
res.status(204).send();
|
||||
} catch (error) {
|
||||
if (error instanceof Error && error.message.includes('not found')) {
|
||||
return res.status(404).json({ message: error.message });
|
||||
const err = new Error(error.message);
|
||||
(err as any).status = 404;
|
||||
return next(err);
|
||||
}
|
||||
next(error);
|
||||
}
|
||||
@@ -226,7 +232,9 @@ router.delete('/flyers/:flyerId', async (req, res, next: NextFunction) => {
|
||||
res.status(204).send();
|
||||
} catch (error) {
|
||||
if (error instanceof Error && error.message.includes('not found')) {
|
||||
return res.status(404).json({ message: error.message });
|
||||
const err = new Error(error.message);
|
||||
(err as any).status = 404;
|
||||
return next(err);
|
||||
}
|
||||
next(error);
|
||||
}
|
||||
@@ -244,7 +252,9 @@ router.put('/comments/:id/status', async (req, res, next: NextFunction) => {
|
||||
res.status(200).json(updatedComment);
|
||||
} catch (error) {
|
||||
if (error instanceof Error && error.message.includes('not found')) {
|
||||
return res.status(404).json({ message: error.message });
|
||||
const err = new Error(error.message);
|
||||
(err as any).status = 404;
|
||||
return next(err);
|
||||
}
|
||||
next(error);
|
||||
}
|
||||
@@ -292,11 +302,6 @@ router.put('/users/:id', async (req, res, next: NextFunction) => {
|
||||
const updatedUser = await db.adminRepo.updateUserRole(req.params.id, role);
|
||||
res.json(updatedUser);
|
||||
} catch (error) {
|
||||
if (error instanceof ForeignKeyConstraintError) { // This error is thrown by the DB layer
|
||||
return res.status(404).json({ message: `User with ID ${req.params.id} not found.` });
|
||||
} else if (error instanceof Error && error.message.includes('not found')) { // This handles the generic "not found" from the repo
|
||||
return res.status(404).json({ message: error.message });
|
||||
}
|
||||
logger.error(`Error updating user ${req.params.id}:`, { error });
|
||||
next(error);
|
||||
}
|
||||
@@ -513,25 +518,4 @@ router.post('/trigger/weekly-analytics', async (req, res, next: NextFunction) =>
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Admin-specific error handling middleware.
|
||||
* This should be the last middleware added to this router.
|
||||
* It catches any errors passed by `next(error)` in the routes above.
|
||||
*/
|
||||
router.use((error: Error, req: Request, res: Response, next: NextFunction) => {
|
||||
// Generate a unique ID for this specific error occurrence.
|
||||
const errorId = crypto.randomBytes(4).toString('hex');
|
||||
|
||||
// Log the full error details on the server for debugging.
|
||||
logger.error(`[API /admin] Error ID: ${errorId} - An unhandled error occurred in the admin router.`, {
|
||||
error: error.message,
|
||||
stack: error.stack,
|
||||
url: req.originalUrl,
|
||||
method: req.method,
|
||||
});
|
||||
|
||||
// Send a generic, safe response to the client.
|
||||
res.status(500).json({ message: `An internal server error occurred. Please try again later. If the problem persists, please reference error ID: ${errorId}` });
|
||||
});
|
||||
|
||||
export default router;
|
||||
@@ -101,6 +101,13 @@ describe('Admin Stats Routes (/api/admin/stats)', () => {
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body).toEqual(mockStats);
|
||||
});
|
||||
|
||||
it('should return 500 if the database call fails', async () => {
|
||||
vi.mocked(adminRepo.getApplicationStats).mockRejectedValue(new Error('DB Error'));
|
||||
const response = await supertest(app).get('/api/admin/stats');
|
||||
expect(response.status).toBe(500);
|
||||
expect(response.body.message).toBe('DB Error');
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /stats/daily', () => {
|
||||
@@ -114,5 +121,12 @@ describe('Admin Stats Routes (/api/admin/stats)', () => {
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body).toEqual(mockDailyStats);
|
||||
});
|
||||
|
||||
it('should return 500 if the database call fails', async () => {
|
||||
vi.mocked(adminRepo.getDailyStatsForLast30Days).mockRejectedValue(new Error('DB Error'));
|
||||
const response = await supertest(app).get('/api/admin/stats/daily');
|
||||
expect(response.status).toBe(500);
|
||||
expect(response.body.message).toBe('DB Error');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -76,7 +76,7 @@ describe('Admin System Routes (/api/admin/system)', () => {
|
||||
vi.mocked(clearGeocodeCache).mockRejectedValue(new Error('Redis is down'));
|
||||
const response = await supertest(app).post('/api/admin/system/clear-geocode-cache');
|
||||
expect(response.status).toBe(500);
|
||||
expect(response.body.message).toBe('Redis is down');
|
||||
expect(response.body.message).toContain('Redis is down');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -150,6 +150,7 @@ describe('Admin User Management Routes (/api/admin/users)', () => {
|
||||
vi.mocked(mockedDb.adminRepo.updateUserRole).mockRejectedValue(new Error('User with ID non-existent not found.'));
|
||||
const response = await supertest(app).put('/api/admin/users/non-existent').send({ role: 'user' });
|
||||
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 () => {
|
||||
|
||||
@@ -125,6 +125,7 @@ describe('AI Routes (/api/ai)', () => {
|
||||
.attach('flyerFile', imagePath);
|
||||
|
||||
expect(response.status).toBe(500);
|
||||
expect(response.body.message).toBe('Redis connection failed');
|
||||
});
|
||||
|
||||
it('should pass user ID to the job when authenticated', async () => {
|
||||
@@ -458,7 +459,7 @@ describe('AI Routes (/api/ai)', () => {
|
||||
.attach('image', imagePath);
|
||||
|
||||
expect(response.status).toBe(500);
|
||||
expect(response.body.message).toContain('AI API is down');
|
||||
expect(response.body.message).toBe('AI API is down');
|
||||
});
|
||||
|
||||
it('should return 400 if cropArea is missing', async () => {
|
||||
@@ -507,7 +508,7 @@ describe('AI Routes (/api/ai)', () => {
|
||||
.attach('image', imagePath);
|
||||
|
||||
expect(response.status).toBe(500);
|
||||
expect(response.body.message).toContain('AI API is down');
|
||||
expect(response.body.message).toBe('AI API is down');
|
||||
});
|
||||
|
||||
it('should return 400 if cropArea is missing', async () => {
|
||||
@@ -574,7 +575,7 @@ describe('AI Routes (/api/ai)', () => {
|
||||
});
|
||||
|
||||
expect(response.status).toBe(500);
|
||||
expect(response.body.message).toContain('Maps API key invalid');
|
||||
expect(response.body.message).toBe('Maps API key invalid');
|
||||
});
|
||||
|
||||
it('POST /plan-trip should return 500 if the AI service fails', async () => {
|
||||
@@ -589,7 +590,7 @@ describe('AI Routes (/api/ai)', () => {
|
||||
});
|
||||
|
||||
expect(response.status).toBe(500);
|
||||
expect(response.body.message).toContain('Maps API key invalid');
|
||||
expect(response.body.message).toBe('Maps API key invalid');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -423,26 +423,4 @@ router.post(
|
||||
}
|
||||
);
|
||||
|
||||
/**
|
||||
* AI-specific error handling middleware.
|
||||
* This should be the last middleware added to this router.
|
||||
* It catches any errors passed by `next(error)` in the routes above.
|
||||
*/
|
||||
router.use((error: Error, req: Request, res: Response, next: NextFunction) => {
|
||||
// Generate a unique ID for this specific error occurrence.
|
||||
const errorId = crypto.randomBytes(4).toString('hex');
|
||||
|
||||
// Log the full error details on the server for debugging.
|
||||
// Including the errorId makes it easy to correlate a client-side error with server logs.
|
||||
logger.error(`[API /ai] Error ID: ${errorId} - An unhandled error occurred in the AI router.`, {
|
||||
error: error.message,
|
||||
stack: error.stack,
|
||||
url: req.originalUrl,
|
||||
method: req.method,
|
||||
});
|
||||
|
||||
// Send a generic, safe response to the client.
|
||||
res.status(500).json({ message: `An internal server error occurred. Please try again later. If the problem persists, please reference error ID: ${errorId}` });
|
||||
});
|
||||
|
||||
export default router;
|
||||
@@ -495,6 +495,7 @@ describe('Auth Routes (/api/auth)', () => {
|
||||
.post('/api/auth/refresh-token')
|
||||
.set('Cookie', 'refreshToken=any-token');
|
||||
expect(response.status).toBe(500);
|
||||
expect(response.body.message).toBe('DB Error');
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -265,7 +265,7 @@ router.post('/reset-password', resetPasswordLimiter, async (req, res, next) => {
|
||||
});
|
||||
|
||||
// New Route to refresh the access token
|
||||
router.post('/refresh-token', async (req: Request, res: Response) => {
|
||||
router.post('/refresh-token', async (req: Request, res: Response, next: NextFunction) => {
|
||||
const { refreshToken } = req.cookies;
|
||||
if (!refreshToken) {
|
||||
return res.status(401).json({ message: 'Refresh token not found.' });
|
||||
@@ -283,9 +283,7 @@ router.post('/refresh-token', async (req: Request, res: Response) => {
|
||||
res.json({ token: newAccessToken });
|
||||
} catch (error) {
|
||||
logger.error('An error occurred during /refresh-token.', { error });
|
||||
// Unlike other routes, we don't call next(error) here to avoid a server crash
|
||||
// and instead send a generic 500 error to the client.
|
||||
res.status(500).json({ message: 'An internal error occurred while refreshing the token.' });
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -120,6 +120,7 @@ describe('Budget Routes (/api/budgets)', () => {
|
||||
vi.mocked(db.budgetRepo.createBudget).mockRejectedValue(new Error('DB Error'));
|
||||
const response = await supertest(app).post('/api/budgets').send(newBudgetData);
|
||||
expect(response.status).toBe(500);
|
||||
expect(response.body.message).toBe('DB Error');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -140,6 +141,7 @@ describe('Budget Routes (/api/budgets)', () => {
|
||||
vi.mocked(db.budgetRepo.updateBudget).mockRejectedValue(new Error('Budget not found'));
|
||||
const response = await supertest(app).put('/api/budgets/999').send({ amount_cents: 1 });
|
||||
expect(response.status).toBe(404);
|
||||
expect(response.body.message).toBe('Budget not found');
|
||||
});
|
||||
|
||||
it('should return 400 for a non-numeric budget ID', async () => {
|
||||
@@ -172,6 +174,7 @@ describe('Budget Routes (/api/budgets)', () => {
|
||||
vi.mocked(db.budgetRepo.deleteBudget).mockRejectedValue(new Error('Budget not found'));
|
||||
const response = await supertest(app).delete('/api/budgets/999');
|
||||
expect(response.status).toBe(404);
|
||||
expect(response.body.message).toBe('Budget not found');
|
||||
});
|
||||
|
||||
it('should return 400 for a non-numeric budget ID', async () => {
|
||||
@@ -214,6 +217,7 @@ describe('Budget Routes (/api/budgets)', () => {
|
||||
const response = await supertest(app).get('/api/budgets/spending-analysis?startDate=2024-01-01&endDate=2024-01-31');
|
||||
|
||||
expect(response.status).toBe(500);
|
||||
expect(response.body.message).toBe('DB Error');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -57,7 +57,9 @@ router.put('/:id', validateNumericParams(['id']), async (req, res, next: NextFun
|
||||
res.json(updatedBudget);
|
||||
} catch (error) {
|
||||
if (error instanceof Error && error.message.includes('not found')) {
|
||||
return res.status(404).json({ message: error.message });
|
||||
const err = new Error(error.message);
|
||||
(err as any).status = 404;
|
||||
return next(err);
|
||||
}
|
||||
logger.error('Error updating budget:', { error, userId: user.user_id, budgetId });
|
||||
next(error);
|
||||
@@ -75,7 +77,9 @@ router.delete('/:id', validateNumericParams(['id']), async (req, res, next: Next
|
||||
res.status(204).send(); // No Content
|
||||
} catch (error) {
|
||||
if (error instanceof Error && error.message.includes('not found')) {
|
||||
return res.status(404).json({ message: error.message });
|
||||
const err = new Error(error.message);
|
||||
(err as any).status = 404;
|
||||
return next(err);
|
||||
}
|
||||
logger.error('Error deleting budget:', { error, userId: user.user_id, budgetId });
|
||||
next(error);
|
||||
@@ -103,25 +107,4 @@ router.get('/spending-analysis', async (req, res, next: NextFunction) => {
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Budget-specific error handling middleware.
|
||||
* This should be the last middleware added to this router.
|
||||
* It catches any errors passed by `next(error)` in the routes above.
|
||||
*/
|
||||
router.use((error: Error, req: Request, res: Response, next: NextFunction) => {
|
||||
// Generate a unique ID for this specific error occurrence.
|
||||
const errorId = crypto.randomBytes(4).toString('hex');
|
||||
|
||||
// Log the full error details on the server for debugging.
|
||||
logger.error(`[API /budgets] Error ID: ${errorId} - An unhandled error occurred in the budget router.`, {
|
||||
error: error.message,
|
||||
stack: error.stack,
|
||||
url: req.originalUrl,
|
||||
method: req.method,
|
||||
});
|
||||
|
||||
// Send a generic, safe response to the client.
|
||||
res.status(500).json({ message: `An internal server error occurred. Please try again later. If the problem persists, please reference error ID: ${errorId}` });
|
||||
});
|
||||
|
||||
export default router;
|
||||
@@ -108,25 +108,4 @@ router.post('/items/:itemId/track', (req: Request, res: Response) => {
|
||||
res.status(202).send();
|
||||
});
|
||||
|
||||
/**
|
||||
* Flyer-specific error handling middleware.
|
||||
* This should be the last middleware added to this router.
|
||||
* It catches any errors passed by `next(error)` in the routes above.
|
||||
*/
|
||||
router.use((error: Error, req: Request, res: Response, next: NextFunction) => {
|
||||
// Generate a unique ID for this specific error occurrence.
|
||||
const errorId = crypto.randomBytes(4).toString('hex');
|
||||
|
||||
// Log the full error details on the server for debugging.
|
||||
logger.error(`[API /flyers] Error ID: ${errorId} - An unhandled error occurred in the flyer router.`, {
|
||||
error: error.message,
|
||||
stack: error.stack,
|
||||
url: req.originalUrl,
|
||||
method: req.method,
|
||||
});
|
||||
|
||||
// Send a generic, safe response to the client.
|
||||
res.status(500).json({ message: `An internal server error occurred. Please try again later. If the problem persists, please reference error ID: ${errorId}` });
|
||||
});
|
||||
|
||||
export default router;
|
||||
@@ -98,25 +98,4 @@ adminGamificationRouter.post(
|
||||
// Mount the admin sub-router onto the main gamification router.
|
||||
router.use(adminGamificationRouter);
|
||||
|
||||
/**
|
||||
* Gamification-specific error handling middleware.
|
||||
* This should be the last middleware added to this router.
|
||||
* It catches any errors passed by `next(error)` in the routes above.
|
||||
*/
|
||||
router.use((error: Error, req: Request, res: Response, next: NextFunction) => {
|
||||
// Generate a unique ID for this specific error occurrence.
|
||||
const errorId = crypto.randomBytes(4).toString('hex');
|
||||
|
||||
// Log the full error details on the server for debugging.
|
||||
logger.error(`[API /achievements] Error ID: ${errorId} - An unhandled error occurred in the gamification router.`, {
|
||||
error: error.message,
|
||||
stack: error.stack,
|
||||
url: req.originalUrl,
|
||||
method: req.method,
|
||||
});
|
||||
|
||||
// Send a generic, safe response to the client.
|
||||
res.status(500).json({ message: `An internal server error occurred. Please try again later. If the problem persists, please reference error ID: ${errorId}` });
|
||||
});
|
||||
|
||||
export default router;
|
||||
@@ -96,11 +96,7 @@ describe('Health Routes (/api/health)', () => {
|
||||
|
||||
// Assert
|
||||
expect(response.status).toBe(500);
|
||||
expect(response.body).toEqual({
|
||||
success: false,
|
||||
message: 'Failed to connect to Redis.',
|
||||
error: 'Connection timed out',
|
||||
});
|
||||
expect(response.body.message).toBe('Connection timed out');
|
||||
});
|
||||
|
||||
it('should return 500 if Redis ping returns an unexpected response', async () => {
|
||||
@@ -112,10 +108,7 @@ describe('Health Routes (/api/health)', () => {
|
||||
|
||||
// Assert
|
||||
expect(response.status).toBe(500);
|
||||
expect(response.body.success).toBe(false);
|
||||
expect(response.body.success).toBe(false);
|
||||
expect(response.body.message).toBe('Failed to connect to Redis.');
|
||||
expect(response.body.error).toContain('Unexpected Redis ping response: OK');
|
||||
expect(response.body.message).toContain('Unexpected Redis ping response: OK');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -158,7 +151,6 @@ describe('Health Routes (/api/health)', () => {
|
||||
const response = await supertest(app).get('/api/health/db-schema');
|
||||
|
||||
expect(response.status).toBe(500);
|
||||
expect(response.body.success).toBe(false);
|
||||
expect(response.body.message).toContain('Missing tables: missing_table_1, missing_table_2');
|
||||
});
|
||||
|
||||
@@ -170,7 +162,7 @@ describe('Health Routes (/api/health)', () => {
|
||||
const response = await supertest(app).get('/api/health/db-schema');
|
||||
|
||||
expect(response.status).toBe(500);
|
||||
expect(response.body.message).toBe('An error occurred while checking the database schema.');
|
||||
expect(response.body.message).toBe('DB connection failed');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -198,7 +190,6 @@ describe('Health Routes (/api/health)', () => {
|
||||
|
||||
// Assert
|
||||
expect(response.status).toBe(500);
|
||||
expect(response.body.success).toBe(false);
|
||||
expect(response.body.message).toContain('Storage check failed.');
|
||||
});
|
||||
});
|
||||
@@ -248,6 +239,6 @@ describe('Health Routes (/api/health)', () => {
|
||||
const response = await supertest(app).get('/api/health/db-pool');
|
||||
|
||||
expect(response.status).toBe(500);
|
||||
expect(response.body.message).toBe('An error occurred while checking the database pool status.');
|
||||
expect(response.body.message).toBe('Pool is not initialized');
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,5 @@
|
||||
// src/routes/health.routes.ts
|
||||
import { Router, Request, Response } from 'express';
|
||||
import { Router, Request, Response, NextFunction } from 'express';
|
||||
import { checkTablesExist, getPoolStatus } from '../services/db/connection.db';
|
||||
import { logger } from '../services/logger.server';
|
||||
import { connection as redisConnection } from '../services/queueService.server';
|
||||
@@ -12,33 +12,34 @@ router.get('/ping', (_req: Request, res: Response) => {
|
||||
res.status(200).send('pong');
|
||||
});
|
||||
|
||||
router.get('/db-schema', async (req, res) => {
|
||||
router.get('/db-schema', async (req, res, next: NextFunction) => {
|
||||
try {
|
||||
const requiredTables = ['users', 'profiles', 'flyers', 'flyer_items', 'stores'];
|
||||
const missingTables = await checkTablesExist(requiredTables);
|
||||
|
||||
if (missingTables.length > 0) {
|
||||
return res.status(500).json({ success: false, message: `Database schema check failed. Missing tables: ${missingTables.join(', ')}.` });
|
||||
// Create a new error to be handled by the global error handler
|
||||
return next(new Error(`Database schema check failed. Missing tables: ${missingTables.join(', ')}.`));
|
||||
}
|
||||
return res.status(200).json({ success: true, message: 'All required database tables exist.' });
|
||||
} catch (error: unknown) {
|
||||
logger.error('Error during DB schema check:', { error: error instanceof Error ? error.message : error });
|
||||
return res.status(500).json({ success: false, message: 'An error occurred while checking the database schema.' }); // No change to next(error) as this is a health check, not a standard API route
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
router.get('/storage', async (req, res) => {
|
||||
router.get('/storage', async (req, res, next: NextFunction) => {
|
||||
const storagePath = process.env.STORAGE_PATH || '/var/www/flyer-crawler.projectium.com/assets';
|
||||
try {
|
||||
await fs.access(storagePath, fs.constants.W_OK); // Use fs.promises
|
||||
return res.status(200).json({ success: true, message: `Storage directory '${storagePath}' is accessible and writable.` });
|
||||
} catch (error: unknown) {
|
||||
logger.error(`Storage check failed for path: ${storagePath}`, { error: error instanceof Error ? error.message : error });
|
||||
return res.status(500).json({ success: false, message: `Storage check failed. Ensure the directory '${storagePath}' exists and is writable by the application.` });
|
||||
next(new Error(`Storage check failed. Ensure the directory '${storagePath}' exists and is writable by the application.`));
|
||||
}
|
||||
});
|
||||
|
||||
router.get('/db-pool', (req: Request, res: Response) => {
|
||||
router.get('/db-pool', (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const status = getPoolStatus();
|
||||
const isHealthy = status.waitingCount < 5;
|
||||
@@ -52,7 +53,7 @@ router.get('/db-pool', (req: Request, res: Response) => {
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
logger.error('Error during DB pool health check:', { error: error instanceof Error ? error.message : error });
|
||||
return res.status(500).json({ success: false, message: 'An error occurred while checking the database pool status.' }); // No change to next(error) as this is a health check
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -69,7 +70,7 @@ router.get('/time', (req: Request, res: Response) => {
|
||||
/**
|
||||
* GET /api/health/redis - Checks the health of the Redis connection.
|
||||
*/
|
||||
router.get('/redis', async (req: Request, res: Response) => {
|
||||
router.get('/redis', async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const reply = await redisConnection.ping();
|
||||
if (reply === 'PONG') {
|
||||
@@ -77,8 +78,7 @@ router.get('/redis', async (req: Request, res: Response) => {
|
||||
}
|
||||
throw new Error(`Unexpected Redis ping response: ${reply}`); // This will be caught below
|
||||
} catch (error: unknown) {
|
||||
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||
return res.status(500).json({ success: false, message: 'Failed to connect to Redis.', error: errorMessage });
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ import supertest from 'supertest';
|
||||
import express, { Request, Response, NextFunction } from 'express';
|
||||
import personalizationRouter from './personalization.routes';
|
||||
import { createMockMasterGroceryItem, createMockDietaryRestriction, createMockAppliance } from '../tests/utils/mockFactories';
|
||||
import { errorHandler } from '../middleware/errorHandler';
|
||||
|
||||
// 1. Mock the Service Layer directly.
|
||||
vi.mock('../services/db/index.db', () => ({
|
||||
@@ -29,11 +30,7 @@ const app = express();
|
||||
app.use(express.json());
|
||||
// Mount the router under its designated base path
|
||||
app.use('/api/personalization', personalizationRouter);
|
||||
|
||||
// Add a generic error handler to catch errors passed via next()
|
||||
app.use((err: Error, req: Request, res: Response, next: NextFunction) => {
|
||||
res.status(500).json({ message: err.message || 'Internal Server Error' });
|
||||
});
|
||||
app.use(errorHandler);
|
||||
|
||||
describe('Personalization Routes (/api/personalization)', () => {
|
||||
beforeEach(() => {
|
||||
@@ -55,12 +52,14 @@ describe('Personalization Routes (/api/personalization)', () => {
|
||||
vi.mocked(db.personalizationRepo.getAllMasterItems).mockRejectedValue(new Error('DB Error'));
|
||||
const response = await supertest(app).get('/api/personalization/master-items');
|
||||
expect(response.status).toBe(500);
|
||||
expect(response.body.message).toBe('DB Error');
|
||||
});
|
||||
|
||||
it('should return 500 if the database call fails for dietary restrictions', async () => {
|
||||
vi.mocked(db.personalizationRepo.getDietaryRestrictions).mockRejectedValue(new Error('DB Error'));
|
||||
const response = await supertest(app).get('/api/personalization/dietary-restrictions');
|
||||
expect(response.status).toBe(500);
|
||||
expect(response.body.message).toBe('DB Error');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -79,6 +78,7 @@ describe('Personalization Routes (/api/personalization)', () => {
|
||||
vi.mocked(db.personalizationRepo.getDietaryRestrictions).mockRejectedValue(new Error('DB Error'));
|
||||
const response = await supertest(app).get('/api/personalization/dietary-restrictions');
|
||||
expect(response.status).toBe(500);
|
||||
expect(response.body.message).toBe('DB Error');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -97,6 +97,7 @@ describe('Personalization Routes (/api/personalization)', () => {
|
||||
vi.mocked(db.personalizationRepo.getAppliances).mockRejectedValue(new Error('DB Error'));
|
||||
const response = await supertest(app).get('/api/personalization/appliances');
|
||||
expect(response.status).toBe(500);
|
||||
expect(response.body.message).toBe('DB Error');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -72,6 +72,7 @@ describe('Recipe Routes (/api/recipes)', () => {
|
||||
vi.mocked(db.recipeRepo.getRecipesBySalePercentage).mockRejectedValue(new Error('DB Error'));
|
||||
const response = await supertest(app).get('/api/recipes/by-sale-percentage');
|
||||
expect(response.status).toBe(500);
|
||||
expect(response.body.message).toBe('DB Error');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -104,6 +105,7 @@ describe('Recipe Routes (/api/recipes)', () => {
|
||||
vi.mocked(db.recipeRepo.getRecipesByMinSaleIngredients).mockRejectedValue(new Error('DB Error'));
|
||||
const response = await supertest(app).get('/api/recipes/by-sale-ingredients');
|
||||
expect(response.status).toBe(500);
|
||||
expect(response.body.message).toBe('DB Error');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -134,6 +136,7 @@ describe('Recipe Routes (/api/recipes)', () => {
|
||||
const response = await supertest(app)
|
||||
.get('/api/recipes/by-ingredient-and-tag?ingredient=chicken&tag=quick');
|
||||
expect(response.status).toBe(500);
|
||||
expect(response.body.message).toBe('DB Error');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -165,6 +168,7 @@ describe('Recipe Routes (/api/recipes)', () => {
|
||||
vi.mocked(db.recipeRepo.getRecipeComments).mockRejectedValue(new Error('DB Error'));
|
||||
const response = await supertest(app).get('/api/recipes/1/comments');
|
||||
expect(response.status).toBe(500);
|
||||
expect(response.body.message).toBe('DB Error');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -197,6 +201,7 @@ describe('Recipe Routes (/api/recipes)', () => {
|
||||
vi.mocked(db.recipeRepo.getRecipeById).mockRejectedValue(new Error('DB Error'));
|
||||
const response = await supertest(app).get('/api/recipes/456');
|
||||
expect(response.status).toBe(500);
|
||||
expect(response.body.message).toBe('DB Error');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -81,25 +81,4 @@ router.get('/:recipeId', async (req: Request, res: Response, next: NextFunction)
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Recipe-specific error handling middleware.
|
||||
* This should be the last middleware added to this router.
|
||||
* It catches any errors passed by `next(error)` in the routes above.
|
||||
*/
|
||||
router.use((error: Error, req: Request, res: Response, next: NextFunction) => {
|
||||
// Generate a unique ID for this specific error occurrence.
|
||||
const errorId = crypto.randomBytes(4).toString('hex');
|
||||
|
||||
// Log the full error details on the server for debugging.
|
||||
logger.error(`[API /recipes] Error ID: ${errorId} - An unhandled error occurred in the recipe router.`, {
|
||||
error: error.message,
|
||||
stack: error.stack,
|
||||
url: req.originalUrl,
|
||||
method: req.method,
|
||||
});
|
||||
|
||||
// Send a generic, safe response to the client.
|
||||
res.status(500).json({ message: `An internal server error occurred. Please try again later. If the problem persists, please reference error ID: ${errorId}` });
|
||||
});
|
||||
|
||||
export default router;
|
||||
@@ -33,25 +33,4 @@ router.get('/most-frequent-sales', async (req: Request, res: Response, next: Nex
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Stats-specific error handling middleware.
|
||||
* This should be the last middleware added to this router.
|
||||
* It catches any errors passed by `next(error)` in the routes above.
|
||||
*/
|
||||
router.use((error: Error, req: Request, res: Response, next: NextFunction) => {
|
||||
// Generate a unique ID for this specific error occurrence.
|
||||
const errorId = crypto.randomBytes(4).toString('hex');
|
||||
|
||||
// Log the full error details on the server for debugging.
|
||||
logger.error(`[API /stats] Error ID: ${errorId} - An unhandled error occurred in the stats router.`, {
|
||||
error: error.message,
|
||||
stack: error.stack,
|
||||
url: req.originalUrl,
|
||||
method: req.method,
|
||||
});
|
||||
|
||||
// Send a generic, safe response to the client.
|
||||
res.status(500).json({ message: `An internal server error occurred. Please try again later. If the problem persists, please reference error ID: ${errorId}` });
|
||||
});
|
||||
|
||||
export default router;
|
||||
@@ -5,6 +5,7 @@ import express from 'express';
|
||||
import systemRouter from './system.routes';
|
||||
import { exec } from 'child_process';
|
||||
import { geocodeAddress } from '../services/geocodingService.server';
|
||||
import { errorHandler } from '../middleware/errorHandler';
|
||||
|
||||
// FIX: Use the simple factory pattern for child_process to avoid default export issues
|
||||
vi.mock('child_process', () => {
|
||||
@@ -40,6 +41,7 @@ vi.mock('../services/logger.server', () => ({
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
app.use('/api/system', systemRouter);
|
||||
app.use(errorHandler);
|
||||
|
||||
describe('System Routes (/api/system)', () => {
|
||||
beforeEach(() => {
|
||||
@@ -118,6 +120,7 @@ describe('System Routes (/api/system)', () => {
|
||||
|
||||
// Assert
|
||||
expect(response.status).toBe(500);
|
||||
expect(response.body.message).toBe('System error');
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// src/routes/system.ts
|
||||
import { Router, Request, Response } from 'express';
|
||||
import { Router, Request, Response, NextFunction } from 'express';
|
||||
import { exec } from 'child_process';
|
||||
import { logger } from '../services/logger.server';
|
||||
import { geocodeAddress } from '../services/geocodingService.server';
|
||||
@@ -10,7 +10,7 @@ const router = Router();
|
||||
* Checks the status of the 'flyer-crawler-api' process managed by PM2.
|
||||
* This is intended for development and diagnostic purposes.
|
||||
*/
|
||||
router.get('/pm2-status', (req: Request, res: Response) => {
|
||||
router.get('/pm2-status', (req: Request, res: Response, next: NextFunction) => {
|
||||
// The name 'flyer-crawler-api' comes from your ecosystem.config.cjs file.
|
||||
exec('pm2 describe flyer-crawler-api', (error, stdout, stderr) => {
|
||||
if (error) {
|
||||
@@ -21,14 +21,14 @@ router.get('/pm2-status', (req: Request, res: Response) => {
|
||||
return res.json({ success: false, message: 'Application process is not running under PM2.' });
|
||||
}
|
||||
logger.error('[API /pm2-status] Error executing pm2 describe:', { error: stderr || error.message });
|
||||
return res.status(500).json({ success: false, message: 'Failed to query PM2 status.' });
|
||||
return next(error);
|
||||
}
|
||||
|
||||
// Check if there was output to stderr, even if the exit code was 0 (success).
|
||||
// This handles warnings or non-fatal errors that should arguably be treated as failures in this context.
|
||||
if (stderr && stderr.trim().length > 0) {
|
||||
logger.error('[API /pm2-status] PM2 executed but produced stderr:', { stderr });
|
||||
return res.status(500).json({ success: false, message: 'Failed to query PM2 status.' });
|
||||
return next(new Error(`PM2 command produced an error: ${stderr}`));
|
||||
}
|
||||
|
||||
// If the command succeeds, we can parse stdout to check the status.
|
||||
@@ -42,19 +42,23 @@ router.get('/pm2-status', (req: Request, res: Response) => {
|
||||
* 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) => {
|
||||
router.post('/geocode', 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.' });
|
||||
}
|
||||
|
||||
const coordinates = await geocodeAddress(address);
|
||||
try {
|
||||
const coordinates = await geocodeAddress(address);
|
||||
|
||||
if (!coordinates) { // This check remains, but now it only fails if BOTH services fail.
|
||||
return res.status(404).json({ message: 'Could not geocode the provided address.' });
|
||||
if (!coordinates) { // This check remains, but now it only fails if BOTH services fail.
|
||||
return res.status(404).json({ message: 'Could not geocode the provided address.' });
|
||||
}
|
||||
|
||||
res.json(coordinates);
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
|
||||
res.json(coordinates);
|
||||
});
|
||||
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@ import userRouter from './user.routes';
|
||||
import { createMockUserProfile, createMockMasterGroceryItem, createMockShoppingList, createMockShoppingListItem, createMockRecipe } from '../tests/utils/mockFactories';
|
||||
import { Appliance, Notification } from '../types';
|
||||
import { ForeignKeyConstraintError } from '../services/db/errors.db';
|
||||
import { errorHandler } from '../middleware/errorHandler';
|
||||
|
||||
// 1. Mock the Service Layer directly.
|
||||
// The user.routes.ts file imports from '.../db/index.db'. We need to mock that module.
|
||||
@@ -108,12 +109,7 @@ const createApp = (authenticatedUser?: any) => {
|
||||
}
|
||||
|
||||
app.use('/api/users', userRouter);
|
||||
|
||||
// Add a generic error handler to catch errors passed via next()
|
||||
app.use((err: Error, req: Request, res: Response, next: NextFunction) => {
|
||||
res.status(500).json({ message: err.message || 'Internal Server Error' });
|
||||
});
|
||||
|
||||
app.use(errorHandler);
|
||||
return app;
|
||||
};
|
||||
|
||||
@@ -123,9 +119,9 @@ describe('User Routes (/api/users)', () => {
|
||||
});
|
||||
|
||||
describe('when user is not authenticated', () => {
|
||||
it('GET /profile should return 401 because the mockAuth middleware is not active for this test block', async () => {
|
||||
it('GET /profile should return 401', async () => {
|
||||
const app = createApp(); // No user injected
|
||||
const response = await supertest(app).get('/api/users/watched-items');
|
||||
const response = await supertest(app).get('/api/users/profile');
|
||||
expect(response.status).toBe(401);
|
||||
});
|
||||
});
|
||||
@@ -157,6 +153,7 @@ describe('User Routes (/api/users)', () => {
|
||||
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');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -173,6 +170,7 @@ describe('User Routes (/api/users)', () => {
|
||||
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');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -194,6 +192,7 @@ describe('User Routes (/api/users)', () => {
|
||||
.post('/api/users/watched-items')
|
||||
.send({ itemName: 'Failing Item', category: 'Errors' });
|
||||
expect(response.status).toBe(500);
|
||||
expect(response.body.message).toBe('DB Error');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -235,6 +234,7 @@ describe('User Routes (/api/users)', () => {
|
||||
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');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -277,6 +277,7 @@ describe('User Routes (/api/users)', () => {
|
||||
.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 () => {
|
||||
@@ -324,6 +325,7 @@ describe('User Routes (/api/users)', () => {
|
||||
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 () => {
|
||||
@@ -363,6 +365,7 @@ describe('User Routes (/api/users)', () => {
|
||||
.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 () => {
|
||||
@@ -371,6 +374,7 @@ describe('User Routes (/api/users)', () => {
|
||||
.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 () => {
|
||||
@@ -421,6 +425,7 @@ describe('User Routes (/api/users)', () => {
|
||||
.put('/api/users/profile')
|
||||
.send({ full_name: 'Failing Name' });
|
||||
expect(response.status).toBe(500);
|
||||
expect(response.body.message).toBe('DB Error');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -452,6 +457,7 @@ describe('User Routes (/api/users)', () => {
|
||||
.put('/api/users/profile/password')
|
||||
.send({ newPassword: 'a-Very-Strong-Password-456!' });
|
||||
expect(response.status).toBe(500);
|
||||
expect(response.body.message).toBe('DB Error');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -505,6 +511,7 @@ describe('User Routes (/api/users)', () => {
|
||||
.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 () => {
|
||||
@@ -555,6 +562,7 @@ describe('User Routes (/api/users)', () => {
|
||||
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 () => {
|
||||
@@ -567,6 +575,7 @@ describe('User Routes (/api/users)', () => {
|
||||
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 () => {
|
||||
@@ -584,6 +593,7 @@ describe('User Routes (/api/users)', () => {
|
||||
.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 () => {
|
||||
@@ -592,6 +602,7 @@ describe('User Routes (/api/users)', () => {
|
||||
.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 () => {
|
||||
@@ -616,6 +627,7 @@ describe('User Routes (/api/users)', () => {
|
||||
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 () => {
|
||||
@@ -631,6 +643,7 @@ describe('User Routes (/api/users)', () => {
|
||||
.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 () => {
|
||||
@@ -668,12 +681,14 @@ describe('User Routes (/api/users)', () => {
|
||||
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 () => {
|
||||
@@ -706,27 +721,28 @@ describe('User Routes (/api/users)', () => {
|
||||
|
||||
describe('Address Routes', () => {
|
||||
it('GET /addresses/:addressId should return 403 if address does not belong to user', async () => {
|
||||
const appWithDifferentUser = createApp({ ...mockUserProfile, address_id: 999 });
|
||||
const appWithDifferentUser = createApp({ ...mockUserProfile, address_id: 999 } as any);
|
||||
const response = await supertest(appWithDifferentUser).get('/api/users/addresses/1');
|
||||
expect(response.status).toBe(403);
|
||||
});
|
||||
|
||||
it('should return 500 if database call fails', async () => {
|
||||
const appWithUser = createApp({ ...mockUserProfile, address_id: 1 });
|
||||
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 });
|
||||
const appWithUser = createApp({ ...mockUserProfile, address_id: 1 } as any);
|
||||
vi.mocked(db.addressRepo.getAddressById).mockResolvedValue(undefined);
|
||||
const response = await supertest(appWithUser).get('/api/users/addresses/1');
|
||||
expect(response.status).toBe(404);
|
||||
});
|
||||
|
||||
it('PUT /profile/address should call upsertAddress and updateUserProfile if needed', async () => {
|
||||
const appWithUser = createApp({ ...mockUserProfile, address_id: null }); // User has no address yet
|
||||
const appWithUser = createApp({ ...mockUserProfile, address_id: null } as any); // User has no address yet
|
||||
const addressData = { address_line_1: '123 New St' };
|
||||
vi.mocked(db.addressRepo.upsertAddress).mockResolvedValue(5); // New address ID is 5
|
||||
vi.mocked(db.userRepo.updateUserProfile).mockResolvedValue({} as any);
|
||||
@@ -742,15 +758,16 @@ describe('User Routes (/api/users)', () => {
|
||||
});
|
||||
|
||||
it('should return 500 if upsertAddress fails', async () => {
|
||||
const appWithUser = createApp({ ...mockUserProfile, address_id: null });
|
||||
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 });
|
||||
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
|
||||
@@ -758,6 +775,7 @@ describe('User Routes (/api/users)', () => {
|
||||
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');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -804,6 +822,7 @@ describe('User Routes (/api/users)', () => {
|
||||
.put('/api/users/profile/password')
|
||||
.send({ newPassword: 'a-Very-Strong-Password-456!' });
|
||||
expect(response.status).toBe(500);
|
||||
expect(response.body.message).toBe('DB Error');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -819,6 +838,7 @@ describe('User Routes (/api/users)', () => {
|
||||
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 () => {
|
||||
@@ -857,12 +877,14 @@ describe('User Routes (/api/users)', () => {
|
||||
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 () => {
|
||||
|
||||
@@ -325,7 +325,9 @@ router.delete('/shopping-lists/:listId', validateNumericParams(['listId']), asyn
|
||||
res.status(204).send();
|
||||
} catch (error) {
|
||||
if (error instanceof Error && error.message.includes('not found')) {
|
||||
return res.status(404).json({ message: error.message });
|
||||
const err = new Error(error.message);
|
||||
(err as any).status = 404;
|
||||
return next(err);
|
||||
}
|
||||
const errorMessage = error instanceof Error ? error.message : 'An unknown error occurred';
|
||||
logger.error(`[ROUTE] DELETE /api/users/shopping-lists/:listId - ERROR`, { errorMessage, params: req.params });
|
||||
@@ -366,7 +368,9 @@ router.put('/shopping-lists/items/:itemId', validateNumericParams(['itemId']), a
|
||||
res.json(updatedItem);
|
||||
} catch (error) {
|
||||
if (error instanceof Error && error.message.includes('not found')) {
|
||||
return res.status(404).json({ message: error.message });
|
||||
const err = new Error(error.message);
|
||||
(err as any).status = 404;
|
||||
return next(err);
|
||||
}
|
||||
logger.error(`[ROUTE] PUT /api/users/shopping-lists/items/:itemId - ERROR`, { error, params: req.params, body: req.body });
|
||||
next(error);
|
||||
@@ -384,7 +388,9 @@ router.delete('/shopping-lists/items/:itemId', validateNumericParams(['itemId'])
|
||||
res.status(204).send();
|
||||
} catch (error) {
|
||||
if (error instanceof Error && error.message.includes('not found')) {
|
||||
return res.status(404).json({ message: error.message });
|
||||
const err = new Error(error.message);
|
||||
(err as any).status = 404;
|
||||
return next(err);
|
||||
}
|
||||
logger.error(`[ROUTE] DELETE /api/users/shopping-lists/items/:itemId - ERROR`, { error, params: req.params });
|
||||
next(error);
|
||||
@@ -534,7 +540,9 @@ router.delete('/recipes/:recipeId', validateNumericParams(['recipeId']), async (
|
||||
res.status(204).send();
|
||||
} catch (error) {
|
||||
if (error instanceof Error && error.message.includes('not found')) {
|
||||
return res.status(404).json({ message: error.message });
|
||||
const err = new Error(error.message);
|
||||
(err as any).status = 404;
|
||||
return next(err);
|
||||
}
|
||||
next(error);
|
||||
}
|
||||
@@ -554,7 +562,9 @@ router.put('/recipes/:recipeId', validateNumericParams(['recipeId']), async (req
|
||||
res.json(updatedRecipe);
|
||||
} catch (error) {
|
||||
if (error instanceof Error && error.message.includes('not found')) {
|
||||
return res.status(404).json({ message: error.message });
|
||||
const err = new Error(error.message);
|
||||
(err as any).status = 404;
|
||||
return next(err);
|
||||
} else if (error instanceof Error && error.message.includes('No fields provided')) {
|
||||
return res.status(400).json({ message: error.message });
|
||||
}
|
||||
@@ -576,26 +586,4 @@ router.use((err: Error, req: Request, res: Response, next: NextFunction) => {
|
||||
next(err); // Pass on to the next error handler if it's not a multer error we handle.
|
||||
});
|
||||
|
||||
/**
|
||||
* User-specific error handling middleware.
|
||||
* This should be the last middleware added to this router.
|
||||
* It catches any errors passed by `next(error)` in the routes above.
|
||||
*/
|
||||
router.use((error: Error, req: Request, res: Response, next: NextFunction) => {
|
||||
// Generate a unique ID for this specific error occurrence.
|
||||
const errorId = crypto.randomBytes(4).toString('hex');
|
||||
|
||||
// Log the full error details on the server for debugging.
|
||||
logger.error(`[API /users] Error ID: ${errorId} - An unhandled error occurred in the user router.`, {
|
||||
error: error.message,
|
||||
stack: error.stack,
|
||||
url: req.originalUrl,
|
||||
method: req.method,
|
||||
});
|
||||
|
||||
// Send a generic, safe response to the client.
|
||||
res.status(500).json({ message: `An internal server error occurred. Please try again later. If the problem persists, please reference error ID: ${errorId}` });
|
||||
});
|
||||
|
||||
|
||||
export default router;
|
||||
Reference in New Issue
Block a user