Files
flyer-crawler.projectium.com/src/routes/recipe.routes.test.ts
Torben Sorensen 2a5cc5bb51
Some checks failed
Deploy to Test Environment / deploy-to-test (push) Failing after 1m17s
unit test repairs
2026-01-12 08:10:37 -08:00

386 lines
15 KiB
TypeScript

// src/routes/recipe.routes.test.ts
import { describe, it, expect, vi, beforeEach } from 'vitest';
import supertest from 'supertest';
import type { Request, Response, NextFunction } from 'express';
import {
createMockRecipe,
createMockRecipeComment,
createMockUserProfile,
} from '../tests/utils/mockFactories';
import { NotFoundError } from '../services/db/errors.db';
import { createTestApp } from '../tests/utils/createTestApp';
// 1. Mock the Service Layer directly.
vi.mock('../services/db/index.db', () => ({
recipeRepo: {
getRecipesBySalePercentage: vi.fn(),
getRecipesByMinSaleIngredients: vi.fn(),
getRecipeById: vi.fn(),
findRecipesByIngredientAndTag: vi.fn(),
getRecipeComments: vi.fn(),
},
}));
// Mock AI Service
vi.mock('../services/aiService.server', () => ({
aiService: {
generateRecipeSuggestion: vi.fn(),
},
}));
// Mock Passport
vi.mock('../config/passport', () => ({
default: {
authenticate: vi.fn(() => (req: Request, res: Response, next: NextFunction) => {
if (!req.user) {
return res.status(401).json({ message: 'Unauthorized' });
}
next();
}),
},
}));
// Import the router and mocked DB AFTER all mocks are defined.
import recipeRouter from './recipe.routes';
import * as db from '../services/db/index.db';
import { aiService } from '../services/aiService.server';
import { mockLogger } from '../tests/utils/mockLogger';
// Mock the logger to keep test output clean
vi.mock('../services/logger.server', async () => ({
// Use async import to avoid hoisting issues with mockLogger
logger: (await import('../tests/utils/mockLogger')).mockLogger,
}));
// Import the mocked db module to control its functions in tests
const expectLogger = expect.objectContaining({
info: expect.any(Function),
error: expect.any(Function),
});
describe('Recipe Routes (/api/recipes)', () => {
const app = createTestApp({ router: recipeRouter, basePath: '/api/recipes' });
beforeEach(() => {
vi.clearAllMocks();
});
describe('GET /by-sale-percentage', () => {
it('should return recipes based on sale percentage', async () => {
const mockRecipes = [createMockRecipe({ recipe_id: 1, name: 'Pasta' })];
vi.mocked(db.recipeRepo.getRecipesBySalePercentage).mockResolvedValue(mockRecipes);
const response = await supertest(app).get('/api/recipes/by-sale-percentage?minPercentage=75');
expect(response.status).toBe(200);
expect(response.body.data).toEqual(mockRecipes);
expect(db.recipeRepo.getRecipesBySalePercentage).toHaveBeenCalledWith(75, expectLogger);
});
it('should use the default minPercentage of 50 when none is provided', async () => {
vi.mocked(db.recipeRepo.getRecipesBySalePercentage).mockResolvedValue([]);
await supertest(app).get('/api/recipes/by-sale-percentage');
expect(db.recipeRepo.getRecipesBySalePercentage).toHaveBeenCalledWith(50, expectLogger);
});
it('should return 500 if the database call fails', async () => {
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.error.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 () => {
const response = await supertest(app).get(
'/api/recipes/by-sale-percentage?minPercentage=101',
);
expect(response.status).toBe(400);
expect(response.body.error.details[0].message).toContain('Too big');
});
});
describe('GET /by-sale-ingredients', () => {
it('should return recipes with default minIngredients', async () => {
vi.mocked(db.recipeRepo.getRecipesByMinSaleIngredients).mockResolvedValue([]);
const response = await supertest(app).get('/api/recipes/by-sale-ingredients');
expect(response.status).toBe(200);
expect(db.recipeRepo.getRecipesByMinSaleIngredients).toHaveBeenCalledWith(3, expectLogger);
});
it('should use provided minIngredients query parameter', async () => {
vi.mocked(db.recipeRepo.getRecipesByMinSaleIngredients).mockResolvedValue([]);
await supertest(app).get('/api/recipes/by-sale-ingredients?minIngredients=5');
expect(db.recipeRepo.getRecipesByMinSaleIngredients).toHaveBeenCalledWith(5, expectLogger);
});
it('should return 500 if the database call fails', async () => {
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.error.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 () => {
const response = await supertest(app).get(
'/api/recipes/by-sale-ingredients?minIngredients=abc',
);
expect(response.status).toBe(400);
expect(response.body.error.details[0].message).toContain('received NaN');
});
});
describe('GET /by-ingredient-and-tag', () => {
it('should return recipes for a given ingredient and tag', async () => {
const mockRecipes = [createMockRecipe({ recipe_id: 2, name: 'Chicken Tacos' })];
vi.mocked(db.recipeRepo.findRecipesByIngredientAndTag).mockResolvedValue(mockRecipes);
const response = await supertest(app).get(
'/api/recipes/by-ingredient-and-tag?ingredient=chicken&tag=quick',
);
expect(response.status).toBe(200);
expect(response.body.data).toEqual(mockRecipes);
});
it('should return 500 if the database call fails', async () => {
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.error.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 () => {
const response = await supertest(app).get(
'/api/recipes/by-ingredient-and-tag?ingredient=chicken',
);
expect(response.status).toBe(400);
expect(response.body.error.details[0].message).toBe('Query parameter "tag" is required.');
});
});
describe('GET /:recipeId/comments', () => {
it('should return comments for a specific recipe', async () => {
const mockComments = [createMockRecipeComment({ recipe_id: 1, content: 'Great recipe!' })];
vi.mocked(db.recipeRepo.getRecipeComments).mockResolvedValue(mockComments);
const response = await supertest(app).get('/api/recipes/1/comments');
expect(response.status).toBe(200);
expect(response.body.data).toEqual(mockComments);
expect(db.recipeRepo.getRecipeComments).toHaveBeenCalledWith(1, expectLogger);
});
it('should return an empty array if recipe has no comments', async () => {
vi.mocked(db.recipeRepo.getRecipeComments).mockResolvedValue([]);
const response = await supertest(app).get('/api/recipes/2/comments');
expect(response.body.data).toEqual([]);
});
it('should return 500 if the database call fails', async () => {
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.error.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 () => {
const response = await supertest(app).get('/api/recipes/abc/comments');
expect(response.status).toBe(400);
expect(response.body.error.details[0].message).toContain('received NaN');
});
});
describe('GET /:recipeId', () => {
it('should return a single recipe on success', async () => {
const mockRecipe = createMockRecipe({ recipe_id: 456, name: 'Specific Recipe' });
vi.mocked(db.recipeRepo.getRecipeById).mockResolvedValue(mockRecipe);
const response = await supertest(app).get('/api/recipes/456');
expect(response.status).toBe(200);
expect(response.body.data).toEqual(mockRecipe);
expect(db.recipeRepo.getRecipeById).toHaveBeenCalledWith(456, expectLogger);
});
it('should return 404 if the recipe is not found', async () => {
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.error.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 () => {
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.error.message).toBe('DB Error');
expect(mockLogger.error).toHaveBeenCalledWith(
{ error: dbError },
`Error fetching recipe ID 456:`,
);
});
it('should return 400 for an invalid recipeId', async () => {
const response = await supertest(app).get('/api/recipes/abc');
expect(response.status).toBe(400);
expect(response.body.error.details[0].message).toContain('received NaN');
});
});
describe('POST /suggest', () => {
const mockUser = createMockUserProfile({ user: { user_id: 'user-123' } });
const authApp = createTestApp({
router: recipeRouter,
basePath: '/api/recipes',
authenticatedUser: mockUser,
});
it('should return a recipe suggestion', async () => {
const ingredients = ['chicken', 'rice'];
const mockSuggestion = 'Chicken and Rice Casserole...';
vi.mocked(aiService.generateRecipeSuggestion).mockResolvedValue(mockSuggestion);
const response = await supertest(authApp).post('/api/recipes/suggest').send({ ingredients });
expect(response.status).toBe(200);
expect(response.body.data).toEqual({ suggestion: mockSuggestion });
expect(aiService.generateRecipeSuggestion).toHaveBeenCalledWith(ingredients, expectLogger);
});
it('should return 503 if AI service returns null', async () => {
vi.mocked(aiService.generateRecipeSuggestion).mockResolvedValue(null);
const response = await supertest(authApp)
.post('/api/recipes/suggest')
.send({ ingredients: ['water'] });
expect(response.status).toBe(503);
expect(response.body.error.message).toContain('unavailable');
});
it('should return 400 if ingredients list is empty', async () => {
const response = await supertest(authApp)
.post('/api/recipes/suggest')
.send({ ingredients: [] });
expect(response.status).toBe(400);
expect(response.body.error.details[0].message).toContain(
'At least one ingredient is required',
);
});
it('should return 401 if not authenticated', async () => {
const unauthApp = createTestApp({ router: recipeRouter, basePath: '/api/recipes' });
const response = await supertest(unauthApp)
.post('/api/recipes/suggest')
.send({ ingredients: ['chicken'] });
expect(response.status).toBe(401);
});
it('should return 500 on service error', async () => {
const error = new Error('AI Error');
vi.mocked(aiService.generateRecipeSuggestion).mockRejectedValue(error);
const response = await supertest(authApp)
.post('/api/recipes/suggest')
.send({ ingredients: ['chicken'] });
expect(response.status).toBe(500);
expect(mockLogger.error).toHaveBeenCalledWith(
{ error },
'Error generating recipe suggestion',
);
});
});
describe('Rate Limiting on /suggest', () => {
const mockUser = createMockUserProfile({ user: { user_id: 'rate-limit-user' } });
const authApp = createTestApp({
router: recipeRouter,
basePath: '/api/recipes',
authenticatedUser: mockUser,
});
it('should block requests after exceeding the limit when the opt-in header is sent', async () => {
// Arrange
const maxRequests = 20; // Limit is 20 per 15 mins
const ingredients = ['chicken', 'rice'];
vi.mocked(aiService.generateRecipeSuggestion).mockResolvedValue('A tasty suggestion');
// Act: Make maxRequests calls
for (let i = 0; i < maxRequests; i++) {
const response = await supertest(authApp)
.post('/api/recipes/suggest')
.set('X-Test-Rate-Limit-Enable', 'true')
.send({ ingredients });
expect(response.status).not.toBe(429);
}
// Act: Make one more call
const blockedResponse = await supertest(authApp)
.post('/api/recipes/suggest')
.set('X-Test-Rate-Limit-Enable', 'true')
.send({ ingredients });
// Assert
expect(blockedResponse.status).toBe(429);
expect(blockedResponse.text).toContain('Too many AI generation requests');
});
it('should NOT block requests when the opt-in header is not sent', async () => {
const maxRequests = 22;
const ingredients = ['beef', 'potatoes'];
vi.mocked(aiService.generateRecipeSuggestion).mockResolvedValue('Another suggestion');
for (let i = 0; i < maxRequests; i++) {
const response = await supertest(authApp)
.post('/api/recipes/suggest')
.send({ ingredients });
expect(response.status).not.toBe(429);
}
});
});
describe('Rate Limiting on Public Routes', () => {
it('should apply publicReadLimiter to GET /:recipeId', async () => {
vi.mocked(db.recipeRepo.getRecipeById).mockResolvedValue(createMockRecipe({}));
const response = await supertest(app)
.get('/api/recipes/1')
.set('X-Test-Rate-Limit-Enable', 'true');
expect(response.status).toBe(200);
expect(response.headers).toHaveProperty('ratelimit-limit');
expect(parseInt(response.headers['ratelimit-limit'])).toBe(100);
});
});
});