Refactor tests and improve error handling across various services
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:
2025-12-15 16:40:13 -08:00
parent 0c590675b3
commit d5f185ad99
32 changed files with 430 additions and 182 deletions

View File

@@ -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;

View 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.'
);
});
});
});

View File

@@ -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: [] });

View File

@@ -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.'),
}),
});