Refactor tests and improve error handling across various services
All checks were successful
Deploy to Test Environment / deploy-to-test (push) Successful in 10m38s
All checks were successful
Deploy to Test Environment / deploy-to-test (push) Successful in 10m38s
- Updated `useAuth` tests to use async functions for JSON responses to avoid promise resolution issues. - Changed `AdminBrandManager` tests to use `mockImplementation` for consistent mock behavior. - Enhanced `ProfileManager.Authenticated` tests to ensure proper error handling and assertions for partial updates. - Modified `SystemCheck` tests to prevent memory leaks by using `mockImplementation` for API calls. - Improved error handling in `ai.routes.ts` by refining validation schemas and adding error extraction utility. - Updated `auth.routes.test.ts` to inject mock logger for better error tracking. - Refined `flyer.routes.ts` to ensure proper validation and error handling for flyer ID parameters. - Enhanced `admin.db.ts` to ensure specific errors are re-thrown for better error management. - Updated `budget.db.test.ts` to improve mock behavior and ensure accurate assertions. - Refined `flyer.db.ts` to improve error handling for race conditions during store creation. - Enhanced `notification.db.test.ts` to ensure specific error types are tested correctly. - Updated `recipe.db.test.ts` to ensure proper handling of not found errors. - Improved `user.db.ts` to ensure consistent error handling for user retrieval. - Enhanced `flyerProcessingService.server.test.ts` to ensure accurate assertions on transformed data. - Updated `logger.server.ts` to disable transport in test environments to prevent issues. - Refined `queueService.workers.test.ts` to ensure accurate mocking of email service. - Improved `userService.test.ts` to ensure proper mock implementations for repository classes. - Enhanced `checksum.test.ts` to ensure reliable file content creation in tests. - Updated `pdfConverter.test.ts` to reset shared state objects and mock implementations before each test.
This commit is contained in:
@@ -30,7 +30,9 @@ interface FlyerProcessPayload extends Partial<ExtractedCoreData> {
|
||||
|
||||
const uploadAndProcessSchema = z.object({
|
||||
body: z.object({
|
||||
checksum: z.string().min(1, 'File checksum is required.'),
|
||||
checksum: z.string().refine(val => val && val.length > 0, {
|
||||
message: 'File checksum is required.',
|
||||
}),
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -40,9 +42,18 @@ const jobIdParamSchema = z.object({
|
||||
}),
|
||||
});
|
||||
|
||||
// Helper to safely extract an error message from unknown `catch` values.
|
||||
const errMsg = (e: unknown) => {
|
||||
if (e instanceof Error) return e.message;
|
||||
if (typeof e === 'object' && e !== null && 'message' in e) return String((e as { message: unknown }).message);
|
||||
return String(e || 'An unknown error occurred.');
|
||||
};
|
||||
|
||||
const rescanAreaSchema = z.object({
|
||||
body: z.object({
|
||||
cropArea: z.string().transform((val, ctx) => {
|
||||
cropArea: z.string().refine(val => val && val.length > 0, {
|
||||
message: 'cropArea must be a valid JSON string.',
|
||||
}).transform((val, ctx) => {
|
||||
try { return JSON.parse(val); }
|
||||
catch (err) {
|
||||
// Log the actual parsing error for better debugging if invalid JSON is sent.
|
||||
@@ -50,7 +61,9 @@ const rescanAreaSchema = z.object({
|
||||
ctx.addIssue({ code: z.ZodIssueCode.custom, message: 'cropArea must be a valid JSON string.' }); return z.NEVER;
|
||||
}
|
||||
}),
|
||||
extractionType: z.string().min(1, 'extractionType is required.'),
|
||||
extractionType: z.string().refine(val => val && val.length > 0, {
|
||||
message: 'extractionType is required.',
|
||||
}),
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -89,13 +102,6 @@ const searchWebSchema = z.object({
|
||||
body: z.object({ query: z.string().min(1, 'A search query is required.') }),
|
||||
});
|
||||
|
||||
// Helper to safely extract an error message from unknown `catch` values.
|
||||
const errMsg = (e: unknown) => {
|
||||
if (e instanceof Error) return e.message;
|
||||
if (typeof e === 'object' && e !== null && 'message' in e) return String((e as { message: unknown }).message);
|
||||
return String(e || 'An unknown error occurred.');
|
||||
};
|
||||
|
||||
// --- Multer Configuration for File Uploads ---
|
||||
const storagePath = process.env.STORAGE_PATH || '/var/www/flyer-crawler.projectium.com/flyer-images';
|
||||
|
||||
@@ -104,20 +110,20 @@ try {
|
||||
fs.mkdirSync(storagePath, { recursive: true });
|
||||
logger.debug(`AI upload storage path ready: ${storagePath}`);
|
||||
} catch (err) {
|
||||
logger.error({ error: err }, `Failed to create storage path (${storagePath}). File uploads may fail.`);
|
||||
logger.error({ error: errMsg(err) }, `Failed to create storage path (${storagePath}). File uploads may fail.`);
|
||||
}
|
||||
const diskStorage = multer.diskStorage({
|
||||
const diskStorage = multer.diskStorage({
|
||||
destination: function (req, file, cb) {
|
||||
cb(null, storagePath);
|
||||
},
|
||||
filename: function (req, file, cb) {
|
||||
// If in a test environment, use a predictable filename for easy cleanup.
|
||||
if (process.env.NODE_ENV === 'test') {
|
||||
cb(null, `${file.fieldname}-test-flyer-image.jpg`);
|
||||
return cb(null, `${file.fieldname}-test-flyer-image.jpg`);
|
||||
} else {
|
||||
const uniqueSuffix = Date.now() + '-' + Math.round(Math.random() * 1E9);
|
||||
// Sanitize the original filename to remove spaces and special characters
|
||||
cb(null, file.fieldname + '-' + uniqueSuffix + '-' + sanitizeFilename(file.originalname));
|
||||
return cb(null, file.fieldname + '-' + uniqueSuffix + '-' + sanitizeFilename(file.originalname));
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -131,7 +137,7 @@ router.use((req: Request, res: Response, next: NextFunction) => {
|
||||
const contentLength = req.headers['content-length'] || 'unknown';
|
||||
const authPresent = !!req.headers['authorization'];
|
||||
logger.debug({ method: req.method, url: req.originalUrl, contentType, contentLength, authPresent }, '[API /ai] Incoming request');
|
||||
} catch (e) {
|
||||
} catch (e: unknown) {
|
||||
logger.error({ error: e }, 'Failed to log incoming AI request headers');
|
||||
}
|
||||
next();
|
||||
@@ -202,7 +208,6 @@ router.get('/jobs/:jobId/status', validateRequest(jobIdParamSchema), async (req,
|
||||
const job = await flyerQueue.getJob(jobId);
|
||||
if (!job) {
|
||||
// Adhere to ADR-001 by throwing a specific error to be handled centrally.
|
||||
throw new NotFoundError('Job not found.');
|
||||
return res.status(404).json({ message: 'Job not found.' });
|
||||
}
|
||||
const state = await job.getState();
|
||||
@@ -246,7 +251,7 @@ router.post('/flyers/process', optionalAuth, uploadToDisk.single('flyerImage'),
|
||||
} catch (err) {
|
||||
logger.warn({ error: errMsg(err) }, '[API /ai/flyers/process] Failed to JSON.parse raw extractedData; falling back to direct assign');
|
||||
parsed = (typeof raw === 'string' ? JSON.parse(String(raw).slice(0, 2000)) : raw) as FlyerProcessPayload;
|
||||
}
|
||||
}
|
||||
// If parsed itself contains an `extractedData` field, use that, otherwise assume parsed is the extractedData
|
||||
extractedData = parsed.extractedData ?? (parsed as Partial<ExtractedCoreData>);
|
||||
} else {
|
||||
@@ -255,7 +260,7 @@ router.post('/flyers/process', optionalAuth, uploadToDisk.single('flyerImage'),
|
||||
parsed = typeof req.body === 'string' ? JSON.parse(req.body) : req.body;
|
||||
} catch (err) {
|
||||
logger.warn({ error: errMsg(err) }, '[API /ai/flyers/process] Failed to JSON.parse req.body; using empty object');
|
||||
parsed = req.body || {};
|
||||
parsed = req.body as FlyerProcessPayload || {};
|
||||
}
|
||||
// extractedData might be nested under `data` or `extractedData`, or the body itself may be the extracted data.
|
||||
if (parsed.data) {
|
||||
@@ -264,7 +269,7 @@ router.post('/flyers/process', optionalAuth, uploadToDisk.single('flyerImage'),
|
||||
extractedData = inner.extractedData ?? inner;
|
||||
} catch (err) {
|
||||
logger.warn({ error: errMsg(err) }, '[API /ai/flyers/process] Failed to parse parsed.data; falling back');
|
||||
extractedData = parsed.data as Partial<ExtractedCoreData>;
|
||||
extractedData = parsed.data as unknown as Partial<ExtractedCoreData>;
|
||||
}
|
||||
} else if (parsed.extractedData) {
|
||||
extractedData = parsed.extractedData;
|
||||
@@ -473,7 +478,9 @@ router.post(
|
||||
if (!req.file) {
|
||||
return res.status(400).json({ message: 'Image file is required.' });
|
||||
}
|
||||
const cropArea = JSON.parse(req.body.cropArea);
|
||||
// validateRequest transforms the cropArea JSON string into an object in req.body.
|
||||
// So we use it directly instead of JSON.parse().
|
||||
const cropArea = req.body.cropArea;
|
||||
const { extractionType } = req.body;
|
||||
const { path, mimetype } = req.file;
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// src/routes/auth.routes.test.ts
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import supertest from 'supertest';
|
||||
import { Request, Response, NextFunction } from 'express';
|
||||
import { Request, Response, NextFunction, RequestHandler } from 'express';
|
||||
import cookieParser from 'cookie-parser';
|
||||
import * as bcrypt from 'bcrypt';
|
||||
import { createMockUserProfile, createMockUserWithPasswordHash } from '../tests/utils/mockFactories';
|
||||
@@ -137,6 +137,13 @@ import { errorHandler } from '../middleware/errorHandler'; // Assuming this exis
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
app.use(cookieParser()); // Mount BEFORE router
|
||||
|
||||
// Middleware to inject the mock logger into req
|
||||
app.use((req, res, next) => {
|
||||
req.log = mockLogger;
|
||||
next();
|
||||
});
|
||||
|
||||
app.use('/api/auth', authRouter);
|
||||
app.use(errorHandler); // Mount AFTER router
|
||||
|
||||
@@ -517,7 +524,10 @@ describe('Auth Routes (/api/auth)', () => {
|
||||
|
||||
// Assert
|
||||
expect(response.status).toBe(200);
|
||||
expect(logger.error).toHaveBeenCalledWith('Failed to delete refresh token from DB during logout.', { error: dbError });
|
||||
expect(logger.error).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ error: dbError }),
|
||||
'Failed to delete refresh token from DB during logout.'
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -174,7 +174,7 @@ describe('Flyer Routes (/api/flyers)', () => {
|
||||
|
||||
describe('POST /items/batch-count', () => {
|
||||
it('should return the count of items for multiple flyers', async () => {
|
||||
vi.mocked(db.flyerRepo.countFlyerItemsForFlyers).mockResolvedValue(42);
|
||||
vi.mocked(db.flyerRepo.countFlyerItemsForFlyers).mockResolvedValueOnce(42);
|
||||
|
||||
const response = await supertest(app)
|
||||
.post('/api/flyers/items/batch-count')
|
||||
@@ -192,6 +192,8 @@ describe('Flyer Routes (/api/flyers)', () => {
|
||||
});
|
||||
|
||||
it('should return a count of 0 if flyerIds is empty', async () => {
|
||||
vi.mocked(db.flyerRepo.countFlyerItemsForFlyers).mockResolvedValueOnce(0);
|
||||
|
||||
const response = await supertest(app)
|
||||
.post('/api/flyers/items/batch-count')
|
||||
.send({ flyerIds: [] });
|
||||
|
||||
@@ -17,7 +17,7 @@ const getFlyersSchema = z.object({
|
||||
|
||||
const flyerIdParamSchema = z.object({
|
||||
params: z.object({
|
||||
id: z.coerce.number().int().positive('Invalid flyer ID provided.'),
|
||||
id: z.coerce.number().int('Invalid flyer ID provided.').positive('Invalid flyer ID provided.'),
|
||||
}),
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user