moar fixes + unit test review of routes
All checks were successful
Deploy to Test Environment / deploy-to-test (push) Successful in 55m32s
All checks were successful
Deploy to Test Environment / deploy-to-test (push) Successful in 55m32s
This commit is contained in:
@@ -218,6 +218,20 @@ describe('Admin Content Management Routes (/api/admin)', () => {
|
||||
});
|
||||
|
||||
describe('Recipe and Comment Routes', () => {
|
||||
it('DELETE /recipes/:recipeId should delete a recipe', async () => {
|
||||
const recipeId = 300;
|
||||
vi.mocked(mockedDb.recipeRepo.deleteRecipe).mockResolvedValue(undefined);
|
||||
|
||||
const response = await supertest(app).delete(`/api/admin/recipes/${recipeId}`);
|
||||
expect(response.status).toBe(204);
|
||||
expect(vi.mocked(mockedDb.recipeRepo.deleteRecipe)).toHaveBeenCalledWith(recipeId, expect.anything(), true, expect.anything());
|
||||
});
|
||||
|
||||
it('DELETE /recipes/:recipeId should return 400 for invalid ID', async () => {
|
||||
const response = await supertest(app).delete('/api/admin/recipes/abc');
|
||||
expect(response.status).toBe(400);
|
||||
});
|
||||
|
||||
it('PUT /recipes/:id/status should update a recipe status', async () => {
|
||||
const recipeId = 201;
|
||||
const requestBody = { status: 'public' as const };
|
||||
|
||||
@@ -65,7 +65,7 @@ vi.mock('@bull-board/express', () => ({
|
||||
|
||||
// Import the mocked modules to control them
|
||||
import { backgroundJobService } from '../services/backgroundJobService'; // This is now a mock
|
||||
import { flyerQueue, analyticsQueue, cleanupQueue } from '../services/queueService.server';
|
||||
import { flyerQueue, analyticsQueue, cleanupQueue, weeklyAnalyticsQueue } from '../services/queueService.server';
|
||||
|
||||
// Mock the logger
|
||||
vi.mock('../services/logger.server', () => ({
|
||||
@@ -137,6 +137,44 @@ describe('Admin Job Trigger Routes (/api/admin/trigger)', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST /trigger/analytics-report', () => {
|
||||
it('should trigger the analytics report job and return 202 Accepted', async () => {
|
||||
const mockJob = { id: 'manual-report-job-123' } as Job;
|
||||
vi.mocked(analyticsQueue.add).mockResolvedValue(mockJob);
|
||||
|
||||
const response = await supertest(app).post('/api/admin/trigger/analytics-report');
|
||||
|
||||
expect(response.status).toBe(202);
|
||||
expect(response.body.message).toContain('Analytics report generation job has been enqueued');
|
||||
expect(analyticsQueue.add).toHaveBeenCalledWith('generate-daily-report', expect.objectContaining({ reportDate: expect.any(String) }), expect.any(Object));
|
||||
});
|
||||
|
||||
it('should return 500 if enqueuing the analytics job fails', async () => {
|
||||
vi.mocked(analyticsQueue.add).mockRejectedValue(new Error('Queue error'));
|
||||
const response = await supertest(app).post('/api/admin/trigger/analytics-report');
|
||||
expect(response.status).toBe(500);
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST /trigger/weekly-analytics', () => {
|
||||
it('should trigger the weekly analytics job and return 202 Accepted', async () => {
|
||||
const mockJob = { id: 'manual-weekly-report-job-123' } as Job;
|
||||
vi.mocked(weeklyAnalyticsQueue.add).mockResolvedValue(mockJob);
|
||||
|
||||
const response = await supertest(app).post('/api/admin/trigger/weekly-analytics');
|
||||
|
||||
expect(response.status).toBe(202);
|
||||
expect(response.body.message).toContain('Successfully enqueued weekly analytics job');
|
||||
expect(weeklyAnalyticsQueue.add).toHaveBeenCalledWith('generate-weekly-report', expect.objectContaining({ reportYear: expect.any(Number), reportWeek: expect.any(Number) }), expect.any(Object));
|
||||
});
|
||||
|
||||
it('should return 500 if enqueuing the weekly analytics job fails', async () => {
|
||||
vi.mocked(weeklyAnalyticsQueue.add).mockRejectedValue(new Error('Queue error'));
|
||||
const response = await supertest(app).post('/api/admin/trigger/weekly-analytics');
|
||||
expect(response.status).toBe(500);
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST /flyers/:flyerId/cleanup', () => {
|
||||
it('should enqueue a cleanup job for a valid flyer ID', async () => {
|
||||
const flyerId = 789;
|
||||
|
||||
@@ -523,8 +523,27 @@ describe('AI Routes (/api/ai)', () => {
|
||||
expect(response.body.message).toBe('Speech generation is not yet implemented.');
|
||||
});
|
||||
|
||||
it('POST /plan-trip should return 500 if the AI service fails', async () => {
|
||||
vi.mocked(aiService.aiService.planTripWithMaps).mockRejectedValue(new Error('Maps API key invalid'));
|
||||
it('POST /search-web should return the stubbed response', async () => {
|
||||
const response = await supertest(app)
|
||||
.post('/api/ai/search-web')
|
||||
.send({ query: 'test query' });
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body.text).toContain('The web says this is good');
|
||||
});
|
||||
|
||||
it('POST /compare-prices should return the stubbed response', async () => {
|
||||
const response = await supertest(app)
|
||||
.post('/api/ai/compare-prices')
|
||||
.send({ items: [{ name: 'Milk' }] });
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body.text).toContain('server-generated price comparison');
|
||||
});
|
||||
|
||||
it('POST /plan-trip should return result on success', async () => {
|
||||
const mockResult = { text: 'Trip plan', sources: [] };
|
||||
vi.mocked(aiService.aiService.planTripWithMaps).mockResolvedValue(mockResult);
|
||||
|
||||
const response = await supertest(app)
|
||||
.post('/api/ai/plan-trip')
|
||||
@@ -534,8 +553,8 @@ describe('AI Routes (/api/ai)', () => {
|
||||
userLocation: { latitude: 0, longitude: 0 },
|
||||
});
|
||||
|
||||
expect(response.status).toBe(500);
|
||||
expect(response.body.message).toBe('Maps API key invalid');
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body).toEqual(mockResult);
|
||||
});
|
||||
|
||||
it('POST /plan-trip should return 500 if the AI service fails', async () => {
|
||||
@@ -552,5 +571,25 @@ describe('AI Routes (/api/ai)', () => {
|
||||
expect(response.status).toBe(500);
|
||||
expect(response.body.message).toBe('Maps API key invalid');
|
||||
});
|
||||
|
||||
it('POST /quick-insights should return 400 if items are missing', async () => {
|
||||
const response = await supertest(app).post('/api/ai/quick-insights').send({});
|
||||
expect(response.status).toBe(400);
|
||||
});
|
||||
|
||||
it('POST /search-web should return 400 if query is missing', async () => {
|
||||
const response = await supertest(app).post('/api/ai/search-web').send({});
|
||||
expect(response.status).toBe(400);
|
||||
});
|
||||
|
||||
it('POST /compare-prices should return 400 if items are missing', async () => {
|
||||
const response = await supertest(app).post('/api/ai/compare-prices').send({});
|
||||
expect(response.status).toBe(400);
|
||||
});
|
||||
|
||||
it('POST /plan-trip should return 400 if required fields are missing', async () => {
|
||||
const response = await supertest(app).post('/api/ai/plan-trip').send({ items: [] });
|
||||
expect(response.status).toBe(400);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -185,6 +185,24 @@ describe('Auth Routes (/api/auth)', () => {
|
||||
expect(db.userRepo.createUser).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should set a refresh token cookie on successful registration', async () => {
|
||||
const mockNewUser = createMockUserProfile({ user_id: 'new-user-id', user: { user_id: 'new-user-id', email: 'cookie@test.com' } });
|
||||
vi.mocked(db.userRepo.createUser).mockResolvedValue(mockNewUser);
|
||||
vi.mocked(db.userRepo.saveRefreshToken).mockResolvedValue(undefined);
|
||||
vi.mocked(db.adminRepo.logActivity).mockResolvedValue(undefined);
|
||||
|
||||
const response = await supertest(app)
|
||||
.post('/api/auth/register')
|
||||
.send({
|
||||
email: 'cookie@test.com',
|
||||
password: 'StrongPassword123!',
|
||||
});
|
||||
|
||||
expect(response.status).toBe(201);
|
||||
expect(response.headers['set-cookie']).toBeDefined();
|
||||
expect(response.headers['set-cookie'][0]).toContain('refreshToken=');
|
||||
});
|
||||
|
||||
it('should reject registration with a weak password', async () => {
|
||||
const weakPassword = 'password';
|
||||
|
||||
@@ -444,6 +462,19 @@ describe('Auth Routes (/api/auth)', () => {
|
||||
expect(response.body.message).toBe('Invalid or expired password reset token.');
|
||||
});
|
||||
|
||||
it('should reject if token does not match any valid tokens in DB', async () => {
|
||||
const tokenRecord = { user_id: 'user-123', token_hash: 'hashed-token', expires_at: new Date(Date.now() + 3600000) };
|
||||
vi.mocked(db.userRepo.getValidResetTokens).mockResolvedValue([tokenRecord]);
|
||||
vi.mocked(bcrypt.compare).mockResolvedValue(false as never); // Token does not match
|
||||
|
||||
const response = await supertest(app)
|
||||
.post('/api/auth/reset-password')
|
||||
.send({ token: 'wrong-token', newPassword: 'a-Very-Strong-Password-123!' });
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
expect(response.body.message).toBe('Invalid or expired password reset token.');
|
||||
});
|
||||
|
||||
it('should return 400 for a weak new password', async () => {
|
||||
const tokenRecord = { user_id: 'user-123', token_hash: 'hashed-token', expires_at: new Date(Date.now() + 3600000) };
|
||||
vi.mocked(db.userRepo.getValidResetTokens).mockResolvedValue([tokenRecord]);
|
||||
|
||||
@@ -167,6 +167,12 @@ describe('Budget Routes (/api/budgets)', () => {
|
||||
expect(response.status).toBe(400);
|
||||
expect(response.body.errors[0].message).toBe('At least one field to update must be provided.');
|
||||
});
|
||||
|
||||
it('should return 400 for an invalid budget ID', async () => {
|
||||
const response = await supertest(app).put('/api/budgets/abc').send({ amount_cents: 5000 });
|
||||
expect(response.status).toBe(400);
|
||||
expect(response.body.errors[0].message).toMatch(/Invalid ID|number/i);
|
||||
});
|
||||
});
|
||||
|
||||
describe('DELETE /:id', () => {
|
||||
@@ -193,6 +199,12 @@ describe('Budget Routes (/api/budgets)', () => {
|
||||
expect(response.status).toBe(500); // The custom handler will now be used
|
||||
expect(response.body.message).toBe('DB Error');
|
||||
});
|
||||
|
||||
it('should return 400 for an invalid budget ID', async () => {
|
||||
const response = await supertest(app).delete('/api/budgets/abc');
|
||||
expect(response.status).toBe(400);
|
||||
expect(response.body.errors[0].message).toMatch(/Invalid ID|number/i);
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /spending-analysis', () => {
|
||||
@@ -222,5 +234,12 @@ describe('Budget Routes (/api/budgets)', () => {
|
||||
expect(response.status).toBe(400);
|
||||
expect(response.body.errors).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('should return 400 if required query parameters are missing', async () => {
|
||||
const response = await supertest(app).get('/api/budgets/spending-analysis');
|
||||
expect(response.status).toBe(400);
|
||||
// Expect errors for both startDate and endDate
|
||||
expect(response.body.errors).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -73,14 +73,17 @@ describe('Deals Routes (/api/users/deals)', () => {
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body).toEqual(mockDeals);
|
||||
expect(dealsRepo.findBestPricesForWatchedItems).toHaveBeenCalledWith(mockUser.user_id, expectLogger);
|
||||
expect(mockLogger.info).toHaveBeenCalledWith({ dealCount: 1 }, 'Successfully fetched best watched item deals.');
|
||||
});
|
||||
|
||||
it('should return 500 if the database call fails', async () => {
|
||||
vi.mocked(dealsRepo.findBestPricesForWatchedItems).mockRejectedValue(new Error('DB Error'));
|
||||
const dbError = new Error('DB Error');
|
||||
vi.mocked(dealsRepo.findBestPricesForWatchedItems).mockRejectedValue(dbError);
|
||||
|
||||
const response = await supertest(authenticatedApp).get('/api/users/deals/best-watched-prices');
|
||||
expect(response.status).toBe(500);
|
||||
expect(response.body.message).toBe('DB Error');
|
||||
expect(mockLogger.error).toHaveBeenCalledWith({ error: dbError }, 'Error fetching best watched item deals.');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -65,10 +65,12 @@ describe('Flyer Routes (/api/flyers)', () => {
|
||||
});
|
||||
|
||||
it('should return 500 if the database call fails', async () => {
|
||||
vi.mocked(db.flyerRepo.getFlyers).mockRejectedValue(new Error('DB Error'));
|
||||
const dbError = new Error('DB Error');
|
||||
vi.mocked(db.flyerRepo.getFlyers).mockRejectedValue(dbError);
|
||||
const response = await supertest(app).get('/api/flyers');
|
||||
expect(response.status).toBe(500);
|
||||
expect(response.body.message).toBe('DB Error');
|
||||
expect(mockLogger.error).toHaveBeenCalledWith({ error: dbError }, 'Error fetching flyers in /api/flyers:');
|
||||
});
|
||||
|
||||
it('should return 400 for invalid query parameters', async () => {
|
||||
@@ -110,10 +112,12 @@ describe('Flyer Routes (/api/flyers)', () => {
|
||||
});
|
||||
|
||||
it('should return 500 if the database call fails', async () => {
|
||||
vi.mocked(db.flyerRepo.getFlyerById).mockRejectedValue(new Error('DB Error'));
|
||||
const dbError = new Error('DB Error');
|
||||
vi.mocked(db.flyerRepo.getFlyerById).mockRejectedValue(dbError);
|
||||
const response = await supertest(app).get('/api/flyers/123');
|
||||
expect(response.status).toBe(500);
|
||||
expect(response.body.message).toBe('DB Error');
|
||||
expect(mockLogger.error).toHaveBeenCalledWith({ error: dbError, flyerId: 123 }, 'Error fetching flyer by ID:');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -135,10 +139,12 @@ describe('Flyer Routes (/api/flyers)', () => {
|
||||
});
|
||||
|
||||
it('should return 500 if the database call fails', async () => {
|
||||
vi.mocked(db.flyerRepo.getFlyerItems).mockRejectedValue(new Error('DB Error'));
|
||||
const dbError = new Error('DB Error');
|
||||
vi.mocked(db.flyerRepo.getFlyerItems).mockRejectedValue(dbError);
|
||||
const response = await supertest(app).get('/api/flyers/123/items');
|
||||
expect(response.status).toBe(500);
|
||||
expect(response.body.message).toBe('DB Error');
|
||||
expect(mockLogger.error).toHaveBeenCalledWith({ error: dbError }, 'Error fetching flyer items in /api/flyers/:id/items:');
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -229,6 +229,17 @@ describe('Gamification Routes (/api/achievements)', () => {
|
||||
expect(db.gamificationRepo.getLeaderboard).toHaveBeenCalledWith(5, expect.anything());
|
||||
});
|
||||
|
||||
it('should use the default limit of 10 when no limit is provided', async () => {
|
||||
const mockLeaderboard = [createMockLeaderboardUser({ user_id: 'user-1', full_name: 'Leader', points: 1000, rank: '1' })];
|
||||
vi.mocked(db.gamificationRepo.getLeaderboard).mockResolvedValue(mockLeaderboard);
|
||||
|
||||
const response = await supertest(unauthenticatedApp).get('/api/achievements/leaderboard');
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body).toEqual(mockLeaderboard);
|
||||
expect(db.gamificationRepo.getLeaderboard).toHaveBeenCalledWith(10, expect.anything());
|
||||
});
|
||||
|
||||
it('should return 500 if the database call fails', async () => {
|
||||
vi.mocked(db.gamificationRepo.getLeaderboard).mockRejectedValue(new Error('DB Error'));
|
||||
const response = await supertest(unauthenticatedApp).get('/api/achievements/leaderboard');
|
||||
|
||||
@@ -35,6 +35,7 @@ vi.mock('../services/logger.server', () => ({
|
||||
debug: vi.fn(),
|
||||
error: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
child: vi.fn().mockReturnThis(), // Add child mock for req.log
|
||||
},
|
||||
}));
|
||||
|
||||
@@ -43,9 +44,17 @@ const mockedRedisConnection = redisConnection as Mocked<typeof redisConnection>;
|
||||
const mockedDbConnection = dbConnection as Mocked<typeof dbConnection>;
|
||||
const mockedFs = fs as Mocked<typeof fs>;
|
||||
|
||||
const { logger } = await import('../services/logger.server');
|
||||
|
||||
// 2. Create a minimal Express app to host the router for testing.
|
||||
const app = createTestApp({ router: healthRouter, basePath: '/api/health' });
|
||||
|
||||
// Add a basic error handler to capture errors passed to next(err) and return JSON.
|
||||
// This prevents unhandled error crashes in tests and ensures we get the 500 response we expect.
|
||||
app.use((err: any, req: any, res: any, next: any) => {
|
||||
res.status(err.status || 500).json({ message: err.message, errors: err.errors });
|
||||
});
|
||||
|
||||
describe('Health Routes (/api/health)', () => {
|
||||
beforeEach(() => {
|
||||
// Clear mock history before each test to ensure isolation.
|
||||
@@ -149,6 +158,7 @@ describe('Health Routes (/api/health)', () => {
|
||||
|
||||
expect(response.status).toBe(500);
|
||||
expect(response.body.message).toContain('Missing tables: missing_table_1, missing_table_2');
|
||||
// The error is passed to next(), so the global error handler would log it, not the route handler itself.
|
||||
});
|
||||
|
||||
it('should return 500 if the database check fails', async () => {
|
||||
@@ -160,6 +170,7 @@ describe('Health Routes (/api/health)', () => {
|
||||
|
||||
expect(response.status).toBe(500);
|
||||
expect(response.body.message).toBe('DB connection failed');
|
||||
expect(logger.error).toHaveBeenCalledWith({ error: 'DB connection failed' }, 'Error during DB schema check:');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -188,6 +199,7 @@ describe('Health Routes (/api/health)', () => {
|
||||
// Assert
|
||||
expect(response.status).toBe(500);
|
||||
expect(response.body.message).toContain('Storage check failed.');
|
||||
expect(logger.error).toHaveBeenCalledWith({ error: 'EACCES: permission denied' }, expect.stringContaining('Storage check failed for path:'));
|
||||
});
|
||||
});
|
||||
|
||||
@@ -225,6 +237,7 @@ describe('Health Routes (/api/health)', () => {
|
||||
expect(response.body.success).toBe(false);
|
||||
expect(response.body.message).toContain('Pool may be under stress.');
|
||||
expect(response.body.message).toContain('Pool Status: 20 total, 5 idle, 15 waiting.');
|
||||
expect(logger.warn).toHaveBeenCalledWith('Database pool health check shows high waiting count: 15');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -237,5 +250,6 @@ describe('Health Routes (/api/health)', () => {
|
||||
|
||||
expect(response.status).toBe(500);
|
||||
expect(response.body.message).toBe('Pool is not initialized');
|
||||
expect(logger.error).toHaveBeenCalledWith({ error: 'Pool is not initialized' }, 'Error during DB pool health check:');
|
||||
});
|
||||
});
|
||||
@@ -88,7 +88,7 @@ vi.mock('passport', () => {
|
||||
});
|
||||
|
||||
// Now, import the passport configuration which will use our mocks
|
||||
import passport, { isAdmin, optionalAuth } from './passport.routes';
|
||||
import passport, { isAdmin, optionalAuth, mockAuth } from './passport.routes';
|
||||
import { logger } from '../services/logger.server';
|
||||
|
||||
describe('Passport Configuration', () => {
|
||||
@@ -426,6 +426,24 @@ describe('Passport Configuration', () => {
|
||||
expect(mockNext).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('should log info and call next() if authentication provides an info Error object', () => {
|
||||
// Arrange
|
||||
const mockReq = {} as Request;
|
||||
const mockInfoError = new Error('Token is malformed');
|
||||
// Mock passport.authenticate to call its callback with an info object
|
||||
vi.mocked(passport.authenticate).mockImplementation(
|
||||
(_strategy, _options, callback) => () => callback?.(null, false, mockInfoError)
|
||||
);
|
||||
|
||||
// Act
|
||||
optionalAuth(mockReq, mockRes as Response, mockNext);
|
||||
|
||||
// Assert
|
||||
// info.message is 'Token is malformed'
|
||||
expect(logger.info).toHaveBeenCalledWith({ info: 'Token is malformed' }, '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;
|
||||
@@ -444,62 +462,44 @@ describe('Passport Configuration', () => {
|
||||
});
|
||||
});
|
||||
|
||||
// ... (Keep other describe blocks: LocalStrategy, isAdmin Middleware, optionalAuth Middleware)
|
||||
// I'm omitting them here for brevity as they didn't have specific failures related to the hoisting issue,
|
||||
// but they should be preserved in the final file.
|
||||
describe('isAdmin Middleware', () => {
|
||||
describe('mockAuth Middleware', () => {
|
||||
const mockNext: NextFunction = vi.fn();
|
||||
let mockRes: Partial<Response>;
|
||||
let originalNodeEnv: string | undefined;
|
||||
|
||||
beforeEach(() => {
|
||||
mockRes = {
|
||||
status: vi.fn().mockReturnThis(),
|
||||
json: vi.fn(),
|
||||
};
|
||||
mockRes = { status: vi.fn().mockReturnThis(), json: vi.fn() };
|
||||
originalNodeEnv = process.env.NODE_ENV;
|
||||
});
|
||||
|
||||
it('should call next() if user has "admin" role', () => {
|
||||
const mockReq: Partial<Request> = {
|
||||
user: {
|
||||
user_id: 'admin-id',
|
||||
role: 'admin',
|
||||
points: 100,
|
||||
user: { user_id: 'admin-id', email: 'admin@test.com' }
|
||||
}
|
||||
};
|
||||
isAdmin(mockReq as Request, mockRes as Response, mockNext);
|
||||
afterEach(() => {
|
||||
process.env.NODE_ENV = originalNodeEnv;
|
||||
});
|
||||
|
||||
it('should attach a mock admin user to req when NODE_ENV is "test"', () => {
|
||||
// Arrange
|
||||
process.env.NODE_ENV = 'test';
|
||||
const mockReq = {} as Request;
|
||||
|
||||
// Act
|
||||
mockAuth(mockReq, mockRes as Response, mockNext);
|
||||
|
||||
// Assert
|
||||
expect(mockReq.user).toBeDefined();
|
||||
expect((mockReq.user as UserProfile).role).toBe('admin');
|
||||
expect(mockNext).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('should return 403 Forbidden if user does not have "admin" role', () => {
|
||||
const mockReq: Partial<Request> = {
|
||||
user: {
|
||||
user_id: 'user-id',
|
||||
role: 'user',
|
||||
points: 50,
|
||||
user: { user_id: 'user-id', email: 'user@test.com' }
|
||||
}
|
||||
};
|
||||
isAdmin(mockReq as Request, mockRes as Response, mockNext);
|
||||
expect(mockRes.status).toHaveBeenCalledWith(403);
|
||||
});
|
||||
});
|
||||
|
||||
describe('optionalAuth Middleware', () => {
|
||||
const mockNext: NextFunction = vi.fn();
|
||||
let mockRes: Partial<Response>;
|
||||
beforeEach(() => {
|
||||
mockRes = { status: vi.fn().mockReturnThis(), json: vi.fn() };
|
||||
});
|
||||
|
||||
it('should populate req.user and call next() if authentication succeeds', () => {
|
||||
it('should do nothing and call next() when NODE_ENV is not "test"', () => {
|
||||
// Arrange
|
||||
process.env.NODE_ENV = 'production';
|
||||
const mockReq = {} as Request;
|
||||
const mockUser = createMockUserProfile({ user_id: 'user-123' });
|
||||
vi.mocked(passport.authenticate).mockImplementation(
|
||||
(_strategy, _options, callback) => () => callback?.(null, mockUser, undefined)
|
||||
);
|
||||
optionalAuth(mockReq, mockRes as Response, mockNext);
|
||||
expect(mockReq.user).toEqual(mockUser);
|
||||
|
||||
// Act
|
||||
mockAuth(mockReq, mockRes as Response, mockNext);
|
||||
|
||||
// Assert
|
||||
expect(mockReq.user).toBeUndefined();
|
||||
expect(mockNext).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -48,17 +48,12 @@ describe('Personalization Routes (/api/personalization)', () => {
|
||||
});
|
||||
|
||||
it('should return 500 if the database call fails', async () => {
|
||||
vi.mocked(db.personalizationRepo.getAllMasterItems).mockRejectedValue(new Error('DB Error'));
|
||||
const dbError = new Error('DB Error');
|
||||
vi.mocked(db.personalizationRepo.getAllMasterItems).mockRejectedValue(dbError);
|
||||
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');
|
||||
expect(mockLogger.error).toHaveBeenCalledWith({ error: dbError }, 'Error fetching master items in /api/personalization/master-items:');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -74,10 +69,12 @@ describe('Personalization Routes (/api/personalization)', () => {
|
||||
});
|
||||
|
||||
it('should return 500 if the database call fails', async () => {
|
||||
vi.mocked(db.personalizationRepo.getDietaryRestrictions).mockRejectedValue(new Error('DB Error'));
|
||||
const dbError = new Error('DB Error');
|
||||
vi.mocked(db.personalizationRepo.getDietaryRestrictions).mockRejectedValue(dbError);
|
||||
const response = await supertest(app).get('/api/personalization/dietary-restrictions');
|
||||
expect(response.status).toBe(500);
|
||||
expect(response.body.message).toBe('DB Error');
|
||||
expect(mockLogger.error).toHaveBeenCalledWith({ error: dbError }, 'Error fetching dietary restrictions in /api/personalization/dietary-restrictions:');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -93,10 +90,12 @@ describe('Personalization Routes (/api/personalization)', () => {
|
||||
});
|
||||
|
||||
it('should return 500 if the database call fails', async () => {
|
||||
vi.mocked(db.personalizationRepo.getAppliances).mockRejectedValue(new Error('DB Error'));
|
||||
const dbError = new Error('DB Error');
|
||||
vi.mocked(db.personalizationRepo.getAppliances).mockRejectedValue(dbError);
|
||||
const response = await supertest(app).get('/api/personalization/appliances');
|
||||
expect(response.status).toBe(500);
|
||||
expect(response.body.message).toBe('DB Error');
|
||||
expect(mockLogger.error).toHaveBeenCalledWith({ error: dbError }, 'Error fetching appliances in /api/personalization/appliances:');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -9,9 +9,16 @@ vi.mock('../services/logger.server', () => ({
|
||||
logger: {
|
||||
info: vi.fn(),
|
||||
error: vi.fn(),
|
||||
// The test app setup injects a child logger into `req.log`.
|
||||
// We need to mock `child()` to return the mock logger itself
|
||||
// so that `req.log.info()` calls `logger.info()`.
|
||||
child: vi.fn().mockReturnThis(),
|
||||
},
|
||||
}));
|
||||
|
||||
// Import the mocked logger to make assertions on it
|
||||
import { logger } from '../services/logger.server';
|
||||
|
||||
describe('Price Routes (/api/price-history)', () => {
|
||||
const app = createTestApp({ router: priceRouter, basePath: '/api/price-history' });
|
||||
beforeEach(() => {
|
||||
@@ -20,12 +27,17 @@ describe('Price Routes (/api/price-history)', () => {
|
||||
|
||||
describe('POST /', () => {
|
||||
it('should return 200 OK with an empty array for a valid request', async () => {
|
||||
const masterItemIds = [1, 2, 3];
|
||||
const response = await supertest(app)
|
||||
.post('/api/price-history')
|
||||
.send({ masterItemIds: [1, 2, 3] });
|
||||
.send({ masterItemIds });
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body).toEqual([]);
|
||||
expect(logger.info).toHaveBeenCalledWith(
|
||||
{ itemCount: masterItemIds.length },
|
||||
'[API /price-history] Received request for historical price data.'
|
||||
);
|
||||
});
|
||||
|
||||
it('should return 400 if masterItemIds is not an array', async () => {
|
||||
|
||||
@@ -63,10 +63,12 @@ describe('Recipe Routes (/api/recipes)', () => {
|
||||
});
|
||||
|
||||
it('should return 500 if the database call fails', async () => {
|
||||
vi.mocked(db.recipeRepo.getRecipesBySalePercentage).mockRejectedValue(new Error('DB Error'));
|
||||
const dbError = new Error('DB Error');
|
||||
vi.mocked(db.recipeRepo.getRecipesBySalePercentage).mockRejectedValue(dbError);
|
||||
const response = await supertest(app).get('/api/recipes/by-sale-percentage');
|
||||
expect(response.status).toBe(500);
|
||||
expect(response.body.message).toBe('DB Error');
|
||||
expect(mockLogger.error).toHaveBeenCalledWith({ error: dbError }, 'Error fetching recipes in /api/recipes/by-sale-percentage:');
|
||||
});
|
||||
|
||||
it('should return 400 for an invalid minPercentage', async () => {
|
||||
@@ -91,10 +93,12 @@ describe('Recipe Routes (/api/recipes)', () => {
|
||||
});
|
||||
|
||||
it('should return 500 if the database call fails', async () => {
|
||||
vi.mocked(db.recipeRepo.getRecipesByMinSaleIngredients).mockRejectedValue(new Error('DB Error'));
|
||||
const dbError = new Error('DB Error');
|
||||
vi.mocked(db.recipeRepo.getRecipesByMinSaleIngredients).mockRejectedValue(dbError);
|
||||
const response = await supertest(app).get('/api/recipes/by-sale-ingredients');
|
||||
expect(response.status).toBe(500);
|
||||
expect(response.body.message).toBe('DB Error');
|
||||
expect(mockLogger.error).toHaveBeenCalledWith({ error: dbError }, 'Error fetching recipes in /api/recipes/by-sale-ingredients:');
|
||||
});
|
||||
|
||||
it('should return 400 for an invalid minIngredients', async () => {
|
||||
@@ -116,11 +120,13 @@ describe('Recipe Routes (/api/recipes)', () => {
|
||||
});
|
||||
|
||||
it('should return 500 if the database call fails', async () => {
|
||||
vi.mocked(db.recipeRepo.findRecipesByIngredientAndTag).mockRejectedValue(new Error('DB Error'));
|
||||
const dbError = new Error('DB Error');
|
||||
vi.mocked(db.recipeRepo.findRecipesByIngredientAndTag).mockRejectedValue(dbError);
|
||||
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');
|
||||
expect(mockLogger.error).toHaveBeenCalledWith({ error: dbError }, 'Error fetching recipes in /api/recipes/by-ingredient-and-tag:');
|
||||
});
|
||||
|
||||
it('should return 400 if required query parameters are missing', async () => {
|
||||
@@ -149,10 +155,12 @@ describe('Recipe Routes (/api/recipes)', () => {
|
||||
});
|
||||
|
||||
it('should return 500 if the database call fails', async () => {
|
||||
vi.mocked(db.recipeRepo.getRecipeComments).mockRejectedValue(new Error('DB Error'));
|
||||
const dbError = new Error('DB Error');
|
||||
vi.mocked(db.recipeRepo.getRecipeComments).mockRejectedValue(dbError);
|
||||
const response = await supertest(app).get('/api/recipes/1/comments');
|
||||
expect(response.status).toBe(500);
|
||||
expect(response.body.message).toBe('DB Error');
|
||||
expect(mockLogger.error).toHaveBeenCalledWith({ error: dbError }, `Error fetching comments for recipe ID 1:`);
|
||||
});
|
||||
|
||||
it('should return 400 for an invalid recipeId', async () => {
|
||||
@@ -175,17 +183,21 @@ describe('Recipe Routes (/api/recipes)', () => {
|
||||
});
|
||||
|
||||
it('should return 404 if the recipe is not found', async () => {
|
||||
vi.mocked(db.recipeRepo.getRecipeById).mockRejectedValue(new NotFoundError('Recipe not found'));
|
||||
const notFoundError = new NotFoundError('Recipe not found');
|
||||
vi.mocked(db.recipeRepo.getRecipeById).mockRejectedValue(notFoundError);
|
||||
const response = await supertest(app).get('/api/recipes/999');
|
||||
expect(response.status).toBe(404);
|
||||
expect(response.body.message).toContain('not found');
|
||||
expect(mockLogger.error).toHaveBeenCalledWith({ error: notFoundError }, `Error fetching recipe ID 999:`);
|
||||
});
|
||||
|
||||
it('should return 500 if the database call fails', async () => {
|
||||
vi.mocked(db.recipeRepo.getRecipeById).mockRejectedValue(new Error('DB Error'));
|
||||
const dbError = new Error('DB Error');
|
||||
vi.mocked(db.recipeRepo.getRecipeById).mockRejectedValue(dbError);
|
||||
const response = await supertest(app).get('/api/recipes/456');
|
||||
expect(response.status).toBe(500);
|
||||
expect(response.body.message).toBe('DB Error');
|
||||
expect(mockLogger.error).toHaveBeenCalledWith({ error: dbError }, `Error fetching recipe ID 456:`);
|
||||
});
|
||||
|
||||
it('should return 400 for an invalid recipeId', async () => {
|
||||
|
||||
@@ -43,8 +43,7 @@ type BySalePercentageRequest = z.infer<typeof bySalePercentageSchema>;
|
||||
router.get('/by-sale-percentage', validateRequest(bySalePercentageSchema), async (req, res, next) => {
|
||||
try {
|
||||
const { query } = req as unknown as BySalePercentageRequest;
|
||||
const minPercentage = query.minPercentage !== undefined ? Number(query.minPercentage) : 50;
|
||||
const recipes = await db.recipeRepo.getRecipesBySalePercentage(minPercentage, req.log);
|
||||
const recipes = await db.recipeRepo.getRecipesBySalePercentage(query.minPercentage, req.log);
|
||||
res.json(recipes);
|
||||
} catch (error) {
|
||||
req.log.error({ error }, 'Error fetching recipes in /api/recipes/by-sale-percentage:');
|
||||
@@ -59,8 +58,7 @@ type BySaleIngredientsRequest = z.infer<typeof bySaleIngredientsSchema>;
|
||||
router.get('/by-sale-ingredients', validateRequest(bySaleIngredientsSchema), async (req, res, next) => {
|
||||
try {
|
||||
const { query } = req as unknown as BySaleIngredientsRequest;
|
||||
const minIngredients = query.minIngredients !== undefined ? Number(query.minIngredients) : 3;
|
||||
const recipes = await db.recipeRepo.getRecipesByMinSaleIngredients(minIngredients, req.log);
|
||||
const recipes = await db.recipeRepo.getRecipesByMinSaleIngredients(query.minIngredients, req.log);
|
||||
res.json(recipes);
|
||||
} catch (error) {
|
||||
req.log.error({ error }, 'Error fetching recipes in /api/recipes/by-sale-ingredients:');
|
||||
@@ -90,8 +88,7 @@ type RecipeIdRequest = z.infer<typeof recipeIdParamsSchema>;
|
||||
router.get('/:recipeId/comments', validateRequest(recipeIdParamsSchema), async (req, res, next) => {
|
||||
try {
|
||||
const { params } = req as unknown as RecipeIdRequest;
|
||||
const recipeId = Number(params.recipeId);
|
||||
const comments = await db.recipeRepo.getRecipeComments(recipeId, req.log);
|
||||
const comments = await db.recipeRepo.getRecipeComments(params.recipeId, req.log);
|
||||
res.json(comments);
|
||||
} catch (error) {
|
||||
req.log.error({ error }, `Error fetching comments for recipe ID ${req.params.recipeId}:`);
|
||||
@@ -105,8 +102,7 @@ router.get('/:recipeId/comments', validateRequest(recipeIdParamsSchema), async (
|
||||
router.get('/:recipeId', validateRequest(recipeIdParamsSchema), async (req, res, next) => {
|
||||
try {
|
||||
const { params } = req as unknown as RecipeIdRequest;
|
||||
const recipeId = Number(params.recipeId);
|
||||
const recipe = await db.recipeRepo.getRecipeById(recipeId, req.log);
|
||||
const recipe = await db.recipeRepo.getRecipeById(params.recipeId, req.log);
|
||||
res.json(recipe);
|
||||
} catch (error) {
|
||||
req.log.error({ error }, `Error fetching recipe ID ${req.params.recipeId}:`);
|
||||
|
||||
@@ -53,10 +53,12 @@ describe('Stats Routes (/api/stats)', () => {
|
||||
});
|
||||
|
||||
it('should return 500 if the database call fails', async () => {
|
||||
vi.mocked(db.adminRepo.getMostFrequentSaleItems).mockRejectedValue(new Error('DB Error'));
|
||||
const dbError = new Error('DB Error');
|
||||
vi.mocked(db.adminRepo.getMostFrequentSaleItems).mockRejectedValue(dbError);
|
||||
const response = await supertest(app).get('/api/stats/most-frequent-sales');
|
||||
expect(response.status).toBe(500);
|
||||
expect(response.body.message).toBe('DB Error');
|
||||
expect(mockLogger.error).toHaveBeenCalledWith({ error: dbError }, 'Error fetching most frequent sale items in /api/stats/most-frequent-sales:');
|
||||
});
|
||||
|
||||
it('should return 400 for invalid query parameters', async () => {
|
||||
|
||||
@@ -25,9 +25,7 @@ type MostFrequentSalesRequest = z.infer<typeof mostFrequentSalesSchema>;
|
||||
router.get('/most-frequent-sales', validateRequest(mostFrequentSalesSchema), async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const { query } = req as unknown as MostFrequentSalesRequest;
|
||||
const days = query.days !== undefined ? Number(query.days) : 30;
|
||||
const limit = query.limit !== undefined ? Number(query.limit) : 10;
|
||||
const items = await db.adminRepo.getMostFrequentSaleItems(days, limit, req.log);
|
||||
const items = await db.adminRepo.getMostFrequentSaleItems(query.days, query.limit, req.log);
|
||||
res.json(items);
|
||||
} catch (error) {
|
||||
req.log.error({ error }, 'Error fetching most frequent sale items in /api/stats/most-frequent-sales:');
|
||||
|
||||
@@ -41,6 +41,12 @@ vi.mock('../services/logger.server', () => ({
|
||||
|
||||
describe('System Routes (/api/system)', () => {
|
||||
const app = createTestApp({ router: systemRouter, basePath: '/api/system' });
|
||||
|
||||
// Add a basic error handler to capture errors passed to next(err) and return JSON.
|
||||
app.use((err: any, req: any, res: any, next: any) => {
|
||||
res.status(err.status || 500).json({ message: err.message, errors: err.errors });
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
// We cast here to get type-safe access to mock functions like .mockImplementation
|
||||
vi.clearAllMocks();
|
||||
@@ -103,6 +109,53 @@ describe('System Routes (/api/system)', () => {
|
||||
expect(response.body.success).toBe(false);
|
||||
});
|
||||
|
||||
it('should return success: false when pm2 process does not exist', async () => {
|
||||
// Arrange: Simulate `pm2 describe` failing because the process isn't found.
|
||||
const processNotFoundOutput = "[PM2][ERROR] Process or Namespace flyer-crawler-api doesn't exist";
|
||||
const processNotFoundError = new Error('Command failed: pm2 describe flyer-crawler-api') as ExecException;
|
||||
processNotFoundError.code = 1;
|
||||
|
||||
vi.mocked(exec).mockImplementation((
|
||||
command: string,
|
||||
options?: ExecOptions | ((error: ExecException | null, stdout: string, stderr: string) => void) | null,
|
||||
callback?: ((error: ExecException | null, stdout: string, stderr: string) => void) | null
|
||||
) => {
|
||||
const actualCallback = (typeof options === 'function' ? options : callback) as ((error: ExecException | null, stdout: string, stderr: string) => void);
|
||||
if (actualCallback) {
|
||||
actualCallback(processNotFoundError, processNotFoundOutput, '');
|
||||
}
|
||||
return { unref: () => {} } as ReturnType<typeof exec>;
|
||||
});
|
||||
|
||||
// Act
|
||||
const response = await supertest(app).get('/api/system/pm2-status');
|
||||
|
||||
// Assert
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body).toEqual({ success: false, message: 'Application process is not running under PM2.' });
|
||||
});
|
||||
|
||||
it('should return 500 if pm2 command produces stderr output', async () => {
|
||||
// Arrange: Simulate a successful exit code but with content in stderr.
|
||||
const stderrOutput = 'A non-fatal warning occurred.';
|
||||
|
||||
vi.mocked(exec).mockImplementation((
|
||||
command: string,
|
||||
options?: ExecOptions | ((error: ExecException | null, stdout: string, stderr: string) => void) | null,
|
||||
callback?: ((error: ExecException | null, stdout: string, stderr: string) => void) | null
|
||||
) => {
|
||||
const actualCallback = (typeof options === 'function' ? options : callback) as ((error: ExecException | null, stdout: string, stderr: string) => void);
|
||||
if (actualCallback) {
|
||||
actualCallback(null, 'Some stdout', stderrOutput);
|
||||
}
|
||||
return { unref: () => {} } as ReturnType<typeof exec>;
|
||||
});
|
||||
|
||||
const response = await supertest(app).get('/api/system/pm2-status');
|
||||
expect(response.status).toBe(500);
|
||||
expect(response.body.message).toBe(`PM2 command produced an error: ${stderrOutput}`);
|
||||
});
|
||||
|
||||
it('should return 500 on a generic exec error', async () => {
|
||||
vi.mocked(exec).mockImplementation((
|
||||
command: string,
|
||||
|
||||
@@ -10,6 +10,7 @@ import { Appliance, Notification, DietaryRestriction } from '../types';
|
||||
import { ForeignKeyConstraintError, NotFoundError } from '../services/db/errors.db';
|
||||
import { createTestApp } from '../tests/utils/createTestApp';
|
||||
|
||||
import { logger } from '../services/logger.server';
|
||||
// 1. Mock the Service Layer directly.
|
||||
// The user.routes.ts file imports from '.../db/index.db'. We need to mock that module.
|
||||
vi.mock('../services/db/index.db', () => ({
|
||||
@@ -81,6 +82,7 @@ vi.mock('../services/logger.server', () => ({
|
||||
debug: vi.fn(),
|
||||
error: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
child: vi.fn().mockReturnThis(),
|
||||
},
|
||||
}));
|
||||
|
||||
@@ -160,6 +162,7 @@ describe('User Routes (/api/users)', () => {
|
||||
vi.mocked(db.userRepo.findUserProfileById).mockRejectedValue(dbError);
|
||||
const response = await supertest(app).get('/api/users/profile');
|
||||
expect(response.status).toBe(500);
|
||||
expect(logger.error).toHaveBeenCalledWith({ error: dbError }, `[ROUTE] GET /api/users/profile - ERROR`);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -177,6 +180,7 @@ describe('User Routes (/api/users)', () => {
|
||||
vi.mocked(db.personalizationRepo.getWatchedItems).mockRejectedValue(dbError);
|
||||
const response = await supertest(app).get('/api/users/watched-items');
|
||||
expect(response.status).toBe(500);
|
||||
expect(logger.error).toHaveBeenCalledWith({ error: dbError }, `[ROUTE] GET /api/users/watched-items - ERROR`);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -199,6 +203,7 @@ describe('User Routes (/api/users)', () => {
|
||||
.post('/api/users/watched-items')
|
||||
.send({ itemName: 'Test', category: 'Produce' });
|
||||
expect(response.status).toBe(500);
|
||||
expect(logger.error).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -243,6 +248,7 @@ describe('User Routes (/api/users)', () => {
|
||||
vi.mocked(db.personalizationRepo.removeWatchedItem).mockRejectedValue(dbError);
|
||||
const response = await supertest(app).delete(`/api/users/watched-items/99`);
|
||||
expect(response.status).toBe(500);
|
||||
expect(logger.error).toHaveBeenCalledWith({ error: dbError }, `[ROUTE] DELETE /api/users/watched-items/:masterItemId - ERROR`);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -260,6 +266,7 @@ describe('User Routes (/api/users)', () => {
|
||||
vi.mocked(db.shoppingRepo.getShoppingLists).mockRejectedValue(dbError);
|
||||
const response = await supertest(app).get('/api/users/shopping-lists');
|
||||
expect(response.status).toBe(500);
|
||||
expect(logger.error).toHaveBeenCalledWith({ error: dbError }, `[ROUTE] GET /api/users/shopping-lists - ERROR`);
|
||||
});
|
||||
|
||||
it('POST /shopping-lists should create a new list', async () => {
|
||||
@@ -293,6 +300,7 @@ describe('User Routes (/api/users)', () => {
|
||||
const response = await supertest(app).post('/api/users/shopping-lists').send({ name: 'Failing List' });
|
||||
expect(response.status).toBe(500);
|
||||
expect(response.body.message).toBe('DB Connection Failed');
|
||||
expect(logger.error).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should return 400 for an invalid listId on DELETE', async () => {
|
||||
@@ -321,6 +329,7 @@ describe('User Routes (/api/users)', () => {
|
||||
vi.mocked(db.shoppingRepo.deleteShoppingList).mockRejectedValue(dbError);
|
||||
const response = await supertest(app).delete('/api/users/shopping-lists/1');
|
||||
expect(response.status).toBe(500);
|
||||
expect(logger.error).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should return 400 for an invalid listId', async () => {
|
||||
@@ -330,7 +339,41 @@ describe('User Routes (/api/users)', () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
describe('Shopping List Item Routes', () => { it('POST /shopping-lists/:listId/items should add an item to a list', async () => {
|
||||
describe('Shopping List Item Routes', () => {
|
||||
describe('POST /shopping-lists/:listId/items (Validation)', () => {
|
||||
it('should return 400 if neither masterItemId nor customItemName are provided', async () => {
|
||||
const response = await supertest(app)
|
||||
.post('/api/users/shopping-lists/1/items')
|
||||
.send({});
|
||||
expect(response.status).toBe(400);
|
||||
expect(response.body.errors[0].message).toBe('Either masterItemId or customItemName must be provided.');
|
||||
});
|
||||
|
||||
it('should succeed if only masterItemId is provided', async () => {
|
||||
vi.mocked(db.shoppingRepo.addShoppingListItem).mockResolvedValue(createMockShoppingListItem({}));
|
||||
const response = await supertest(app)
|
||||
.post('/api/users/shopping-lists/1/items')
|
||||
.send({ masterItemId: 123 });
|
||||
expect(response.status).toBe(201);
|
||||
});
|
||||
|
||||
it('should succeed if only customItemName is provided', async () => {
|
||||
vi.mocked(db.shoppingRepo.addShoppingListItem).mockResolvedValue(createMockShoppingListItem({}));
|
||||
const response = await supertest(app)
|
||||
.post('/api/users/shopping-lists/1/items')
|
||||
.send({ customItemName: 'Custom Item' });
|
||||
expect(response.status).toBe(201);
|
||||
});
|
||||
|
||||
it('should succeed if both masterItemId and customItemName are provided', async () => {
|
||||
vi.mocked(db.shoppingRepo.addShoppingListItem).mockResolvedValue(createMockShoppingListItem({}));
|
||||
const response = await supertest(app)
|
||||
.post('/api/users/shopping-lists/1/items')
|
||||
.send({ masterItemId: 123, customItemName: 'Custom Item' });
|
||||
expect(response.status).toBe(201);
|
||||
});
|
||||
});
|
||||
it('POST /shopping-lists/:listId/items should add an item to a list', async () => {
|
||||
const listId = 1;
|
||||
const itemData = { customItemName: 'Paper Towels' };
|
||||
const mockAddedItem = createMockShoppingListItem({ shopping_list_item_id: 101, shopping_list_id: listId, ...itemData });
|
||||
@@ -356,6 +399,7 @@ describe('User Routes (/api/users)', () => {
|
||||
.post('/api/users/shopping-lists/1/items')
|
||||
.send({ customItemName: 'Test' });
|
||||
expect(response.status).toBe(500);
|
||||
expect(logger.error).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('PUT /shopping-lists/items/:itemId should update an item', async () => {
|
||||
@@ -384,6 +428,15 @@ describe('User Routes (/api/users)', () => {
|
||||
.put('/api/users/shopping-lists/items/101')
|
||||
.send({ is_purchased: true });
|
||||
expect(response.status).toBe(500);
|
||||
expect(logger.error).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should return 400 if no update fields are provided for an item', async () => {
|
||||
const response = await supertest(app)
|
||||
.put(`/api/users/shopping-lists/items/101`)
|
||||
.send({});
|
||||
expect(response.status).toBe(400);
|
||||
expect(response.body.errors[0].message).toContain('At least one field (quantity, is_purchased) must be provided.');
|
||||
});
|
||||
|
||||
describe('DELETE /shopping-lists/items/:itemId', () => {
|
||||
@@ -404,6 +457,7 @@ describe('User Routes (/api/users)', () => {
|
||||
vi.mocked(db.shoppingRepo.removeShoppingListItem).mockRejectedValue(dbError);
|
||||
const response = await supertest(app).delete('/api/users/shopping-lists/items/101');
|
||||
expect(response.status).toBe(500);
|
||||
expect(logger.error).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -428,6 +482,7 @@ describe('User Routes (/api/users)', () => {
|
||||
.put('/api/users/profile')
|
||||
.send({ full_name: 'New Name' });
|
||||
expect(response.status).toBe(500);
|
||||
expect(logger.error).toHaveBeenCalledWith({ error: dbError }, `[ROUTE] PUT /api/users/profile - ERROR`);
|
||||
});
|
||||
|
||||
it('should return 400 if the body is empty', async () => {
|
||||
@@ -459,6 +514,7 @@ describe('User Routes (/api/users)', () => {
|
||||
.put('/api/users/profile/password')
|
||||
.send({ newPassword: 'a-Very-Strong-Password-456!' });
|
||||
expect(response.status).toBe(500);
|
||||
expect(logger.error).toHaveBeenCalledWith({ error: dbError }, `[ROUTE] PUT /api/users/profile/password - ERROR`);
|
||||
});
|
||||
|
||||
it('should return 400 for a weak password', async () => {
|
||||
@@ -506,6 +562,20 @@ describe('User Routes (/api/users)', () => {
|
||||
expect(response.body.message).toBe('User not found or password not set.');
|
||||
});
|
||||
|
||||
it('should return 404 if user is an OAuth user without a password', async () => {
|
||||
// Simulate an OAuth user who has no password_hash set.
|
||||
const userWithoutHash = createMockUserWithPasswordHash({ ...mockUserProfile.user, password_hash: null });
|
||||
vi.mocked(db.userRepo.findUserWithPasswordHashById).mockResolvedValue(userWithoutHash);
|
||||
|
||||
const response = await supertest(app)
|
||||
.delete('/api/users/account')
|
||||
.send({ password: 'any-password' });
|
||||
|
||||
expect(response.status).toBe(404);
|
||||
expect(response.body.message).toBe('User not found or password not set.');
|
||||
});
|
||||
|
||||
|
||||
it('should return 500 on a generic database error', async () => {
|
||||
const userWithHash = createMockUserWithPasswordHash({ ...mockUserProfile.user, password_hash: 'hashed-password' });
|
||||
vi.mocked(db.userRepo.findUserWithPasswordHashById).mockResolvedValue(userWithHash);
|
||||
@@ -515,6 +585,7 @@ describe('User Routes (/api/users)', () => {
|
||||
.delete('/api/users/account')
|
||||
.send({ password: 'correct-password' });
|
||||
expect(response.status).toBe(500);
|
||||
expect(logger.error).toHaveBeenCalledWith({ error: new Error('DB Connection Failed') }, `[ROUTE] DELETE /api/users/account - ERROR`);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -541,6 +612,7 @@ describe('User Routes (/api/users)', () => {
|
||||
.put('/api/users/profile/preferences')
|
||||
.send({ darkMode: true });
|
||||
expect(response.status).toBe(500);
|
||||
expect(logger.error).toHaveBeenCalledWith({ error: dbError }, `[ROUTE] PUT /api/users/profile/preferences - ERROR`);
|
||||
});
|
||||
|
||||
it('should return 400 if the request body is not a valid object', async () => {
|
||||
@@ -568,6 +640,7 @@ describe('User Routes (/api/users)', () => {
|
||||
vi.mocked(db.personalizationRepo.getUserDietaryRestrictions).mockRejectedValue(dbError);
|
||||
const response = await supertest(app).get('/api/users/me/dietary-restrictions');
|
||||
expect(response.status).toBe(500);
|
||||
expect(logger.error).toHaveBeenCalledWith({ error: dbError }, `[ROUTE] GET /api/users/me/dietary-restrictions - ERROR`);
|
||||
});
|
||||
|
||||
it('should return 400 for an invalid masterItemId', async () => {
|
||||
@@ -601,6 +674,14 @@ describe('User Routes (/api/users)', () => {
|
||||
.put('/api/users/me/dietary-restrictions')
|
||||
.send({ restrictionIds: [1] });
|
||||
expect(response.status).toBe(500);
|
||||
expect(logger.error).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('PUT should return 400 if restrictionIds is not an array', async () => {
|
||||
const response = await supertest(app)
|
||||
.put('/api/users/me/dietary-restrictions')
|
||||
.send({ restrictionIds: 'not-an-array' });
|
||||
expect(response.status).toBe(400);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -618,6 +699,7 @@ describe('User Routes (/api/users)', () => {
|
||||
vi.mocked(db.personalizationRepo.getUserAppliances).mockRejectedValue(dbError);
|
||||
const response = await supertest(app).get('/api/users/me/appliances');
|
||||
expect(response.status).toBe(500);
|
||||
expect(logger.error).toHaveBeenCalledWith({ error: dbError }, `[ROUTE] GET /api/users/me/appliances - ERROR`);
|
||||
});
|
||||
|
||||
it('PUT should successfully set the appliances', async () => {
|
||||
@@ -643,6 +725,14 @@ describe('User Routes (/api/users)', () => {
|
||||
.put('/api/users/me/appliances')
|
||||
.send({ applianceIds: [1] });
|
||||
expect(response.status).toBe(500);
|
||||
expect(logger.error).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('PUT should return 400 if applianceIds is not an array', async () => {
|
||||
const response = await supertest(app)
|
||||
.put('/api/users/me/appliances')
|
||||
.send({ applianceIds: 'not-an-array' });
|
||||
expect(response.status).toBe(400);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -702,6 +792,23 @@ describe('User Routes (/api/users)', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('Address Routes', () => {
|
||||
it('GET /addresses/:addressId should return the address if it belongs to the user', async () => {
|
||||
const appWithUser = createTestApp({ router: userRouter, basePath, authenticatedUser: { ...mockUserProfile, address_id: 1 } });
|
||||
const mockAddress = { address_id: 1, address_line_1: '123 Main St' };
|
||||
vi.mocked(db.addressRepo.getAddressById).mockResolvedValue(mockAddress as any);
|
||||
const response = await supertest(appWithUser).get('/api/users/addresses/1');
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body).toEqual(mockAddress);
|
||||
});
|
||||
|
||||
it('GET /addresses/:addressId should return 500 on a generic database error', async () => {
|
||||
const appWithUser = createTestApp({ router: userRouter, basePath, authenticatedUser: { ...mockUserProfile, address_id: 1 } });
|
||||
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);
|
||||
});
|
||||
|
||||
describe('GET /addresses/:addressId', () => {
|
||||
it('should return 400 for a non-numeric address ID', async () => {
|
||||
const response = await supertest(app).get('/api/users/addresses/abc'); // This was a duplicate, fixed.
|
||||
@@ -709,7 +816,6 @@ 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 = createTestApp({ router: userRouter, basePath, authenticatedUser: { ...mockUserProfile, address_id: 999 } });
|
||||
const response = await supertest(appWithDifferentUser).get('/api/users/addresses/1');
|
||||
@@ -747,6 +853,13 @@ describe('User Routes (/api/users)', () => {
|
||||
expect(response.status).toBe(500);
|
||||
});
|
||||
|
||||
it('should return 400 if the address body is empty', async () => {
|
||||
const response = await supertest(app)
|
||||
.put('/api/users/profile/address')
|
||||
.send({});
|
||||
expect(response.status).toBe(400);
|
||||
expect(response.body.errors[0].message).toContain('At least one address field must be provided');
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST /profile/avatar', () => {
|
||||
@@ -815,6 +928,7 @@ describe('User Routes (/api/users)', () => {
|
||||
vi.mocked(db.recipeRepo.deleteRecipe).mockRejectedValue(dbError);
|
||||
const response = await supertest(app).delete('/api/users/recipes/1');
|
||||
expect(response.status).toBe(500);
|
||||
expect(logger.error).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('PUT /recipes/:recipeId should update a user\'s own recipe', async () => {
|
||||
@@ -842,6 +956,15 @@ describe('User Routes (/api/users)', () => {
|
||||
vi.mocked(db.recipeRepo.updateRecipe).mockRejectedValue(dbError);
|
||||
const response = await supertest(app).put('/api/users/recipes/1').send({ name: 'New Name' });
|
||||
expect(response.status).toBe(500);
|
||||
expect(logger.error).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('PUT /recipes/:recipeId should return 400 if no update fields are provided', async () => {
|
||||
const response = await supertest(app)
|
||||
.put('/api/users/recipes/1')
|
||||
.send({});
|
||||
expect(response.status).toBe(400);
|
||||
expect(response.body.errors[0].message).toBe('No fields provided to update.');
|
||||
});
|
||||
|
||||
it('GET /shopping-lists/:listId should return 404 if list is not found', async () => {
|
||||
@@ -851,11 +974,21 @@ describe('User Routes (/api/users)', () => {
|
||||
expect(response.body.message).toBe('Shopping list not found');
|
||||
});
|
||||
|
||||
it('GET /shopping-lists/:listId should return a single shopping list', async () => {
|
||||
const mockList = createMockShoppingList({ shopping_list_id: 1, user_id: mockUserProfile.user_id });
|
||||
vi.mocked(db.shoppingRepo.getShoppingListById).mockResolvedValue(mockList);
|
||||
const response = await supertest(app).get('/api/users/shopping-lists/1');
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body).toEqual(mockList);
|
||||
expect(db.shoppingRepo.getShoppingListById).toHaveBeenCalledWith(1, mockUserProfile.user_id, expectLogger);
|
||||
});
|
||||
|
||||
it('GET /shopping-lists/:listId should return 500 on a generic database error', async () => {
|
||||
const dbError = new Error('DB Connection Failed');
|
||||
vi.mocked(db.shoppingRepo.getShoppingListById).mockRejectedValue(dbError);
|
||||
const response = await supertest(app).get('/api/users/shopping-lists/1');
|
||||
expect(response.status).toBe(500);
|
||||
expect(logger.error).toHaveBeenCalled();
|
||||
});
|
||||
}); // End of Recipe Routes
|
||||
});
|
||||
|
||||
@@ -146,9 +146,7 @@ router.get(
|
||||
// Apply ADR-003 pattern for type safety
|
||||
try {
|
||||
const { query } = req as unknown as GetNotificationsRequest;
|
||||
const limit = query.limit ? Number(query.limit) : 20;
|
||||
const offset = query.offset ? Number(query.offset) : 0;
|
||||
const notifications = await db.notificationRepo.getNotificationsForUser(user.user_id, limit, offset, req.log);
|
||||
const notifications = await db.notificationRepo.getNotificationsForUser(user.user_id, query.limit, query.offset, req.log);
|
||||
res.json(notifications);
|
||||
} catch (error) {
|
||||
next(error);
|
||||
@@ -420,7 +418,7 @@ router.delete('/shopping-lists/:listId', validateRequest(shoppingListIdSchema),
|
||||
const addShoppingListItemSchema = shoppingListIdSchema.extend({
|
||||
body: z.object({
|
||||
masterItemId: z.number().int().positive().optional(),
|
||||
customItemName: requiredString('customItemName required?'),
|
||||
customItemName: z.string().min(1, 'customItemName cannot be empty if provided').optional(),
|
||||
}).refine(data => data.masterItemId || data.customItemName, { message: 'Either masterItemId or customItemName must be provided.' }),
|
||||
});
|
||||
type AddShoppingListItemRequest = z.infer<typeof addShoppingListItemSchema>;
|
||||
|
||||
Reference in New Issue
Block a user