more unit test fixes + some integration
All checks were successful
Deploy to Test Environment / deploy-to-test (push) Successful in 1h21m56s

This commit is contained in:
2025-12-21 14:54:41 -08:00
parent 0cf4ca02b7
commit 15f759cbc4
44 changed files with 836 additions and 150 deletions

View File

@@ -144,6 +144,13 @@ describe('Admin Content Management Routes (/api/admin)', () => {
expect(vi.mocked(mockedDb.adminRepo.approveCorrection)).toHaveBeenCalledWith(correctionId, expect.anything());
});
it('POST /corrections/:id/approve should return 500 on DB error', async () => {
const correctionId = 123;
vi.mocked(mockedDb.adminRepo.approveCorrection).mockRejectedValue(new Error('DB Error'));
const response = await supertest(app).post(`/api/admin/corrections/${correctionId}/approve`);
expect(response.status).toBe(500);
});
it('POST /corrections/:id/reject should reject a correction', async () => {
const correctionId = 789;
vi.mocked(mockedDb.adminRepo.rejectCorrection).mockResolvedValue(undefined);
@@ -152,6 +159,13 @@ describe('Admin Content Management Routes (/api/admin)', () => {
expect(response.body).toEqual({ message: 'Correction rejected successfully.' });
});
it('POST /corrections/:id/reject should return 500 on DB error', async () => {
const correctionId = 789;
vi.mocked(mockedDb.adminRepo.rejectCorrection).mockRejectedValue(new Error('DB Error'));
const response = await supertest(app).post(`/api/admin/corrections/${correctionId}/reject`);
expect(response.status).toBe(500);
});
it('PUT /corrections/:id should update a correction', async () => {
const correctionId = 101;
const requestBody = { suggested_value: 'A new corrected value' };
@@ -197,6 +211,13 @@ describe('Admin Content Management Routes (/api/admin)', () => {
expect(vi.mocked(mockedDb.adminRepo.updateBrandLogo)).toHaveBeenCalledWith(brandId, expect.stringContaining('/assets/'), expect.anything());
});
it('POST /brands/:id/logo should return 500 on DB error', async () => {
const brandId = 55;
vi.mocked(mockedDb.adminRepo.updateBrandLogo).mockRejectedValue(new Error('DB Error'));
const response = await supertest(app).post(`/api/admin/brands/${brandId}/logo`).attach('logoImage', Buffer.from('dummy-logo-content'), 'test-logo.png');
expect(response.status).toBe(500);
});
it('POST /brands/:id/logo should return 400 if no file is uploaded', async () => {
const response = await supertest(app).post('/api/admin/brands/55/logo');
expect(response.status).toBe(400);
@@ -225,6 +246,13 @@ describe('Admin Content Management Routes (/api/admin)', () => {
expect(response.status).toBe(400);
});
it('DELETE /recipes/:recipeId should return 500 on DB error', async () => {
const recipeId = 300;
vi.mocked(mockedDb.recipeRepo.deleteRecipe).mockRejectedValue(new Error('DB Error'));
const response = await supertest(app).delete(`/api/admin/recipes/${recipeId}`);
expect(response.status).toBe(500);
});
it('PUT /recipes/:id/status should update a recipe status', async () => {
const recipeId = 201;
const requestBody = { status: 'public' as const };
@@ -242,6 +270,14 @@ describe('Admin Content Management Routes (/api/admin)', () => {
expect(response.status).toBe(400);
});
it('PUT /recipes/:id/status should return 500 on DB error', async () => {
const recipeId = 201;
const requestBody = { status: 'public' as const };
vi.mocked(mockedDb.adminRepo.updateRecipeStatus).mockRejectedValue(new Error('DB Error'));
const response = await supertest(app).put(`/api/admin/recipes/${recipeId}/status`).send(requestBody);
expect(response.status).toBe(500);
});
it('PUT /comments/:id/status should update a comment status', async () => {
const commentId = 301;
const requestBody = { status: 'hidden' as const };
@@ -259,6 +295,14 @@ describe('Admin Content Management Routes (/api/admin)', () => {
expect(response.status).toBe(400);
});
it('PUT /comments/:id/status should return 500 on DB error', async () => {
const commentId = 301;
const requestBody = { status: 'hidden' as const };
vi.mocked(mockedDb.adminRepo.updateRecipeCommentStatus).mockRejectedValue(new Error('DB Error'));
const response = await supertest(app).put(`/api/admin/comments/${commentId}/status`).send(requestBody);
expect(response.status).toBe(500);
});
});
describe('Unmatched Items Route', () => {
@@ -270,6 +314,12 @@ describe('Admin Content Management Routes (/api/admin)', () => {
expect(response.status).toBe(200);
expect(response.body).toEqual(mockUnmatchedItems);
});
it('GET /unmatched-items should return 500 on DB error', async () => {
vi.mocked(mockedDb.adminRepo.getUnmatchedFlyerItems).mockRejectedValue(new Error('DB Error'));
const response = await supertest(app).get('/api/admin/unmatched-items');
expect(response.status).toBe(500);
});
});
describe('Flyer Routes', () => {

View File

@@ -97,6 +97,13 @@ describe('Admin User Management Routes (/api/admin/users)', () => {
expect(response.body).toEqual(mockUsers);
expect(adminRepo.getAllUsers).toHaveBeenCalledTimes(1);
});
it('should return 500 if the database call fails', async () => {
const dbError = new Error('DB Error');
vi.mocked(adminRepo.getAllUsers).mockRejectedValue(dbError);
const response = await supertest(app).get('/api/admin/users');
expect(response.status).toBe(500);
});
});
describe('GET /users/:id', () => {
@@ -116,6 +123,13 @@ describe('Admin User Management Routes (/api/admin/users)', () => {
expect(response.status).toBe(404);
expect(response.body.message).toBe('User not found.');
});
it('should return 500 on a generic database error', async () => {
const dbError = new Error('DB Error');
vi.mocked(userRepo.findUserProfileById).mockRejectedValue(dbError);
const response = await supertest(app).get(`/api/admin/users/${userId}`);
expect(response.status).toBe(500);
});
});
describe('PUT /users/:id', () => {
@@ -141,6 +155,14 @@ describe('Admin User Management Routes (/api/admin/users)', () => {
expect(response.body.message).toBe(`User with ID ${missingId} not found.`);
});
it('should return 500 on a generic database error', async () => {
const dbError = new Error('DB Error');
vi.mocked(adminRepo.updateUserRole).mockRejectedValue(dbError);
const response = await supertest(app).put(`/api/admin/users/${userId}`).send({ role: 'admin' });
expect(response.status).toBe(500);
expect(response.body.message).toBe('DB Error');
});
it('should return 400 for an invalid role', async () => {
const response = await supertest(app)
.put(`/api/admin/users/${userId}`)
@@ -164,5 +186,13 @@ describe('Admin User Management Routes (/api/admin/users)', () => {
expect(response.body.message).toMatch(/Admins cannot delete their own account/);
expect(userRepo.deleteUserById).not.toHaveBeenCalled();
});
it('should return 500 on a generic database error', async () => {
const targetId = '123e4567-e89b-12d3-a456-426614174999';
const dbError = new Error('DB Error');
vi.mocked(userRepo.deleteUserById).mockRejectedValue(dbError);
const response = await supertest(app).delete(`/api/admin/users/${targetId}`);
expect(response.status).toBe(500);
});
});
});

View File

@@ -1,6 +1,7 @@
// src/routes/ai.routes.test.ts
import { describe, it, expect, vi, beforeEach } from 'vitest';
import supertest from 'supertest';
import fs from 'node:fs';
import { type Request, type Response, type NextFunction } from 'express';
import path from 'node:path';
import type { Job } from 'bullmq';
@@ -69,6 +70,30 @@ describe('AI Routes (/api/ai)', () => {
});
const app = createTestApp({ router: aiRouter, basePath: '/api/ai' });
describe('Module-level error handling', () => {
it('should log an error if storage path creation fails', async () => {
// Arrange
const mkdirError = new Error('EACCES: permission denied');
vi.resetModules(); // Reset modules to re-run top-level code
vi.doMock('node:fs', () => ({
default: {
...fs, // Keep other fs functions
mkdirSync: vi.fn().mockImplementation(() => {
throw mkdirError;
}),
},
}));
const { logger } = await import('../services/logger.server');
// Act: Dynamically import the router to trigger the mkdirSync call
await import('./ai.routes');
// Assert
expect(logger.error).toHaveBeenCalledWith({ error: 'EACCES: permission denied' }, `Failed to create storage path (/var/www/flyer-crawler.projectium.com/flyer-images). File uploads may fail.`);
vi.doUnmock('node:fs'); // Cleanup
});
});
// Add a basic error handler to capture errors passed to next(err) and return JSON.
// This prevents unhandled error crashes in tests and ensures we get the 500 response we expect.
app.use((err: any, req: any, res: any, next: any) => {
@@ -314,6 +339,10 @@ describe('AI Routes (/api/ai)', () => {
// verify the flyerData.store_name passed to DB was the fallback string
const flyerDataArg = vi.mocked(mockedDb.createFlyerAndItems).mock.calls[0][0];
expect(flyerDataArg.store_name).toContain('Unknown Store');
// Also verify the warning was logged
expect(mockLogger.warn).toHaveBeenCalledWith(
'extractedData.store_name missing; using fallback store name to avoid DB constraint error.'
);
});
it('should handle a generic error during flyer creation', async () => {
@@ -382,6 +411,14 @@ describe('AI Routes (/api/ai)', () => {
expect(response.status).toBe(200);
expect(response.body.is_flyer).toBe(true);
});
it('should return 500 on a generic error', async () => {
// To trigger the catch block, we can cause the middleware to fail.
// A simple way is to mock the service to throw an error.
vi.mocked(aiService.aiService.extractTextFromImageArea).mockRejectedValue(new Error('Generic Error')); // Not used by route, but triggers catch
const response = await supertest(app).post('/api/ai/check-flyer').attach('image', Buffer.from('')); // Empty buffer might cause issues
expect(response.status).toBe(500);
});
});
describe('POST /rescan-area', () => {
@@ -402,6 +439,14 @@ describe('AI Routes (/api/ai)', () => {
expect(response.status).toBe(400);
expect(response.body.errors[0].message).toMatch(/cropArea must be a valid JSON string|Required/i);
});
it('should return 400 if cropArea is malformed JSON', async () => {
const response = await supertest(app)
.post('/api/ai/rescan-area')
.attach('image', imagePath)
.field('cropArea', '{ "x": 0, "y": 0, "width": 10, "height": 10'); // Malformed
expect(response.status).toBe(400);
});
});
describe('POST /extract-address', () => {
@@ -416,6 +461,12 @@ describe('AI Routes (/api/ai)', () => {
expect(response.status).toBe(200);
expect(response.body.address).toBe('not identified');
});
it('should return 500 on a generic error', async () => {
// An empty buffer can sometimes cause underlying libraries to throw an error
const response = await supertest(app).post('/api/ai/extract-address').attach('image', Buffer.from(''));
expect(response.status).toBe(500);
});
});
describe('POST /extract-logo', () => {
@@ -430,6 +481,12 @@ describe('AI Routes (/api/ai)', () => {
expect(response.status).toBe(200);
expect(response.body.store_logo_base_64).toBeNull();
});
it('should return 500 on a generic error', async () => {
// An empty buffer can sometimes cause underlying libraries to throw an error
const response = await supertest(app).post('/api/ai/extract-logo').attach('images', Buffer.from(''));
expect(response.status).toBe(500);
});
});
describe('POST /rescan-area (authenticated)', () => { // This was a duplicate, fixed.
@@ -494,6 +551,15 @@ describe('AI Routes (/api/ai)', () => {
expect(response.body.text).toContain('server-generated quick insight');
});
it('POST /quick-insights should return 500 on a generic error', async () => {
// To hit the catch block, we can simulate an error by making the logger throw.
vi.mocked(mockLogger.info).mockImplementation(() => { throw new Error('Logging failed'); });
const response = await supertest(app)
.post('/api/ai/quick-insights')
.send({ items: [] });
expect(response.status).toBe(500);
});
it('POST /deep-dive should return the stubbed response', async () => {
const response = await supertest(app)
.post('/api/ai/deep-dive')

View File

@@ -32,6 +32,8 @@ const requiredString = (message: string) => z.preprocess((val) => val ?? '', z.s
const uploadAndProcessSchema = z.object({
body: z.object({
checksum: requiredString('File checksum is required.'),
// Potential improvement: If checksum is always a specific format (e.g., SHA-256),
// you could add `.length(64).regex(/^[a-f0-9]+$/)` for stricter validation.
}),
});
@@ -48,6 +50,13 @@ const errMsg = (e: unknown) => {
return String(e || 'An unknown error occurred.');
};
const cropAreaObjectSchema = z.object({
x: z.number(),
y: z.number(),
width: z.number().positive('Crop area width must be positive.'),
height: z.number().positive('Crop area height must be positive.'),
});
const rescanAreaSchema = z.object({
body: z.object({
cropArea: requiredString('cropArea must be a valid JSON string.').transform((val, ctx) => {
@@ -57,30 +66,37 @@ const rescanAreaSchema = z.object({
logger.warn({ error: errMsg(err), receivedValue: val }, 'Failed to parse cropArea in rescanAreaSchema');
ctx.addIssue({ code: z.ZodIssueCode.custom, message: 'cropArea must be a valid JSON string.' }); return z.NEVER;
}
}).pipe(cropAreaObjectSchema), // Further validate the structure of the parsed object
extractionType: z.enum(['store_name', 'dates', 'item_details'], { // This is the line with the error
message: "extractionType must be one of 'store_name', 'dates', or 'item_details'."
}),
extractionType: requiredString('extractionType is required.'),
}),
});
const flyerItemForAnalysisSchema = z.object({
name: requiredString("Item name is required."),
// Allow other properties to pass through without validation
}).passthrough();
const insightsSchema = z.object({
body: z.object({
items: z.array(z.any()), // Define more strictly if item structure is known
items: z.array(flyerItemForAnalysisSchema).nonempty("The 'items' array cannot be empty."),
}),
});
const comparePricesSchema = z.object({
body: z.object({
items: z.array(z.any()), // Define more strictly if item structure is known
items: z.array(flyerItemForAnalysisSchema).nonempty("The 'items' array cannot be empty."),
}),
});
const planTripSchema = z.object({
body: z.object({
items: z.array(z.any()),
store: z.object({ name: z.string() }),
items: z.array(flyerItemForAnalysisSchema),
store: z.object({ name: requiredString("Store name is required.") }),
userLocation: z.object({
latitude: z.number(),
longitude: z.number(),
latitude: z.number().min(-90, 'Latitude must be between -90 and 90.').max(90, 'Latitude must be between -90 and 90.'),
longitude: z.number().min(-180, 'Longitude must be between -180 and 180.').max(180, 'Longitude must be between -180 and 180.'),
}),
}),
});

View File

@@ -134,6 +134,13 @@ describe('Budget Routes (/api/budgets)', () => {
expect(response.status).toBe(400);
expect(response.body.errors).toHaveLength(4);
});
it('should return 400 if required fields are missing', async () => {
// This test covers the `val ?? ''` part of the `requiredString` helper
const response = await supertest(app).post('/api/budgets').send({ amount_cents: 10000, period: 'monthly', start_date: '2024-01-01' });
expect(response.status).toBe(400);
expect(response.body.errors[0].message).toBe('Budget name is required.');
});
});
describe('PUT /:id', () => {

View File

@@ -223,7 +223,7 @@ describe('Flyer Routes (/api/flyers)', () => {
});
describe('POST /items/:itemId/track', () => {
it('should return 202 Accepted and call the tracking function', async () => {
it('should return 202 Accepted and call the tracking function for "click"', async () => {
const response = await supertest(app)
.post('/api/flyers/items/99/track')
.send({ type: 'click' });
@@ -232,6 +232,15 @@ describe('Flyer Routes (/api/flyers)', () => {
expect(db.flyerRepo.trackFlyerItemInteraction).toHaveBeenCalledWith(99, 'click', expectLogger);
});
it('should return 202 Accepted and call the tracking function for "view"', async () => {
const response = await supertest(app)
.post('/api/flyers/items/101/track')
.send({ type: 'view' });
expect(response.status).toBe(202);
expect(db.flyerRepo.trackFlyerItemInteraction).toHaveBeenCalledWith(101, 'view', expectLogger);
});
it('should return 400 for an invalid item ID', async () => {
const response = await supertest(app)
.post('/api/flyers/items/abc/track')

View File

@@ -201,6 +201,19 @@ describe('Gamification Routes (/api/achievements)', () => {
expect(response.body.errors).toHaveLength(2);
});
it('should return 400 if userId or achievementName are missing', async () => {
mockedAuthMiddleware.mockImplementation((req: Request, res: Response, next: NextFunction) => { req.user = mockAdminProfile; next(); });
mockedIsAdmin.mockImplementation((req: Request, res: Response, next: NextFunction) => next());
const response1 = await supertest(adminApp).post('/api/achievements/award').send({ achievementName: 'Test Award' });
expect(response1.status).toBe(400);
expect(response1.body.errors[0].message).toBe('userId is required.');
const response2 = await supertest(adminApp).post('/api/achievements/award').send({ userId: 'user-789' });
expect(response2.status).toBe(400);
expect(response2.body.errors[0].message).toBe('achievementName is required.');
});
it('should return 400 if awarding an achievement to a non-existent user', async () => {
mockedAuthMiddleware.mockImplementation((req: Request, res: Response, next: NextFunction) => {
req.user = mockAdminProfile;

View File

@@ -169,6 +169,18 @@ describe('Health Routes (/api/health)', () => {
expect(response.body.message).toBe('DB connection failed');
expect(logger.error).toHaveBeenCalledWith({ error: 'DB connection failed' }, 'Error during DB schema check:');
});
it('should return 500 if the database check fails with a non-Error object', async () => {
// Arrange: Mock the service function to reject with a non-error object
const dbError = { message: 'DB connection failed' };
mockedDbConnection.checkTablesExist.mockRejectedValue(dbError);
const response = await supertest(app).get('/api/health/db-schema');
expect(response.status).toBe(500);
expect(response.body.message).toBe('DB connection failed');
expect(logger.error).toHaveBeenCalledWith({ error: dbError }, 'Error during DB schema check:');
});
});
describe('GET /storage', () => {
@@ -198,6 +210,20 @@ describe('Health Routes (/api/health)', () => {
expect(response.body.message).toContain('Storage check failed.');
expect(logger.error).toHaveBeenCalledWith({ error: 'EACCES: permission denied' }, expect.stringContaining('Storage check failed for path:'));
});
it('should return 500 if storage check fails with a non-Error object', async () => {
// Arrange: Mock fs.access to reject with a non-error object.
const accessError = { message: 'EACCES: permission denied' };
mockedFs.access.mockRejectedValue(accessError);
// Act
const response = await supertest(app).get('/api/health/storage');
// Assert
expect(response.status).toBe(500);
expect(response.body.message).toContain('Storage check failed.');
expect(logger.error).toHaveBeenCalledWith({ error: accessError }, expect.stringContaining('Storage check failed for path:'));
});
});
describe('GET /db-pool', () => {
@@ -249,4 +275,16 @@ describe('Health Routes (/api/health)', () => {
expect(response.body.message).toBe('Pool is not initialized');
expect(logger.error).toHaveBeenCalledWith({ error: 'Pool is not initialized' }, 'Error during DB pool health check:');
});
it('should return 500 if getPoolStatus throws a non-Error object', async () => {
// Arrange: Mock getPoolStatus to throw a non-error object
const poolError = { message: 'Pool is not initialized' };
mockedDbConnection.getPoolStatus.mockImplementation(() => { throw poolError; });
const response = await supertest(app).get('/api/health/db-pool');
expect(response.status).toBe(500);
expect(response.body.message).toBe('Pool is not initialized');
expect(logger.error).toHaveBeenCalledWith({ error: poolError }, 'Error during DB pool health check:');
});
});

View File

@@ -497,6 +497,23 @@ describe('Passport Configuration', () => {
expect(mockNext).toHaveBeenCalledTimes(1);
});
it('should log info.toString() if info object has no message property', () => {
// Arrange
const mockReq = {} as Request;
const mockInfo = { custom: 'some info' };
// Mock passport.authenticate to call its callback with a custom info object
vi.mocked(passport.authenticate).mockImplementation(
(_strategy, _options, callback) => () => callback?.(null, false, mockInfo as any)
);
// Act
optionalAuth(mockReq, mockRes as Response, mockNext);
// Assert
expect(logger.info).toHaveBeenCalledWith({ info: mockInfo.toString() }, 'Optional auth info:');
expect(mockNext).toHaveBeenCalledTimes(1);
});
it('should call next() and not populate user if passport returns an error', () => {
// Arrange
const mockReq = {} as Request;

View File

@@ -109,6 +109,29 @@ const expectLogger = expect.objectContaining({
error: expect.any(Function),
});
describe('User Routes (/api/users)', () => {
// This test needs to be separate because the code it tests runs on module load.
describe('Avatar Upload Directory Creation', () => {
it('should log an error if avatar directory creation fails', async () => {
// Arrange
const mkdirError = new Error('EACCES: permission denied');
// Reset modules to force re-import with a new mock implementation
vi.resetModules();
// Set up the mock *before* the module is re-imported
vi.doMock('node:fs/promises', () => ({
// We only need to mock mkdir for this test.
mkdir: vi.fn().mockRejectedValue(mkdirError),
}));
const { logger } = await import('../services/logger.server');
// Act: Dynamically import the router to trigger the top-level fs.mkdir call
await import('./user.routes');
// Assert
expect(logger.error).toHaveBeenCalledWith('Failed to create avatar upload directory:', mkdirError);
vi.doUnmock('node:fs/promises'); // Clean up
});
});
beforeEach(() => {
vi.clearAllMocks();
});
@@ -894,6 +917,19 @@ describe('User Routes (/api/users)', () => {
expect(response.body.message).toBe('Only image files are allowed!');
});
it('should return 400 if the uploaded file is too large', async () => {
// This test relies on the `fileSize` limit set in the multer config in user.routes.ts
const largeFile = Buffer.alloc(2 * 1024 * 1024, 'a'); // 2MB file, assuming limit is smaller
const dummyImagePath = 'large-avatar.png';
const response = await supertest(app)
.post('/api/users/profile/avatar')
.attach('avatar', largeFile, dummyImagePath);
expect(response.status).toBe(400);
expect(response.body.message).toContain('File too large');
});
it('should return 400 if no file is uploaded', async () => {
const response = await supertest(app)
.post('/api/users/profile/avatar'); // No .attach() call

View File

@@ -103,6 +103,7 @@ const avatarStorage = multer.diskStorage({
const avatarUpload = multer({
storage: avatarStorage,
limits: { fileSize: 1 * 1024 * 1024 }, // 1MB file size limit
fileFilter: (req, file, cb) => {
if (file.mimetype.startsWith('image/')) {
cb(null, true);