import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest'; import express from 'express'; import request from 'supertest'; import recipesRouter from './recipes.routes'; // Mock dependencies vi.mock('../config/database', () => ({ default: { recipe: { findMany: vi.fn(), findUnique: vi.fn(), create: vi.fn(), update: vi.fn(), delete: vi.fn(), count: vi.fn(), }, recipeImage: { create: vi.fn(), }, ingredient: { deleteMany: vi.fn(), }, instruction: { deleteMany: vi.fn(), }, recipeTag: { deleteMany: vi.fn(), }, recipeSection: { deleteMany: vi.fn(), }, ingredientInstructionMapping: { deleteMany: vi.fn(), createMany: vi.fn(), count: vi.fn().mockResolvedValue(0), }, }, })); vi.mock('../services/storage.service', () => ({ StorageService: { getInstance: vi.fn(() => ({ saveFile: vi.fn().mockResolvedValue('/uploads/recipes/test-image.jpg'), deleteFile: vi.fn().mockResolvedValue(undefined), })), }, })); vi.mock('../services/ingredientMatcher.service', () => ({ autoMapIngredients: vi.fn().mockResolvedValue(undefined), generateIngredientMappings: vi.fn().mockResolvedValue([]), saveIngredientMappings: vi.fn().mockResolvedValue(undefined), })); vi.mock('../services/scraper.service', () => ({ ScraperService: vi.fn(() => ({ scrapeRecipe: vi.fn().mockResolvedValue({ success: true, recipe: { title: 'Scraped Recipe', description: 'A scraped recipe', sourceUrl: 'https://example.com/recipe', }, }), })), })); describe('Recipes Routes - Integration Tests', () => { let app: express.Application; beforeEach(() => { vi.clearAllMocks(); app = express(); app.use(express.json()); app.use('/recipes', recipesRouter); }); afterEach(() => { vi.clearAllMocks(); }); describe('GET /recipes', () => { it('should return paginated recipes', async () => { const mockRecipes = [ { id: '1', title: 'Recipe 1', description: 'Description 1', ingredients: [], instructions: [], images: [], tags: [], }, ]; const prisma = await import('../config/database'); vi.mocked(prisma.default.recipe.findMany).mockResolvedValue(mockRecipes as any); vi.mocked(prisma.default.recipe.count).mockResolvedValue(1); const response = await request(app).get('/recipes').expect(200); expect(response.body).toHaveProperty('data'); expect(response.body).toHaveProperty('total', 1); expect(response.body).toHaveProperty('page', 1); expect(response.body.data).toHaveLength(1); }); it('should support search query parameter', async () => { const prisma = await import('../config/database'); vi.mocked(prisma.default.recipe.findMany).mockResolvedValue([]); vi.mocked(prisma.default.recipe.count).mockResolvedValue(0); await request(app).get('/recipes?search=pasta').expect(200); expect(prisma.default.recipe.findMany).toHaveBeenCalledWith( expect.objectContaining({ where: expect.objectContaining({ OR: expect.any(Array), }), }) ); }); it('should support pagination parameters', async () => { const prisma = await import('../config/database'); vi.mocked(prisma.default.recipe.findMany).mockResolvedValue([]); vi.mocked(prisma.default.recipe.count).mockResolvedValue(0); await request(app).get('/recipes?page=2&limit=10').expect(200); expect(prisma.default.recipe.findMany).toHaveBeenCalledWith( expect.objectContaining({ skip: 10, take: 10, }) ); }); it('should support tag query parameter for filtering by tag name', async () => { const prisma = await import('../config/database'); vi.mocked(prisma.default.recipe.findMany).mockResolvedValue([]); vi.mocked(prisma.default.recipe.count).mockResolvedValue(0); await request(app).get('/recipes?tag=italian').expect(200); expect(prisma.default.recipe.findMany).toHaveBeenCalledWith( expect.objectContaining({ where: expect.objectContaining({ tags: { some: { tag: { name: { equals: 'italian', mode: 'insensitive' } } } } }), }) ); }); it('should support combining search and tag parameters', async () => { const prisma = await import('../config/database'); vi.mocked(prisma.default.recipe.findMany).mockResolvedValue([]); vi.mocked(prisma.default.recipe.count).mockResolvedValue(0); await request(app).get('/recipes?search=pasta&tag=dinner').expect(200); expect(prisma.default.recipe.findMany).toHaveBeenCalledWith( expect.objectContaining({ where: expect.objectContaining({ OR: expect.any(Array), tags: expect.any(Object), }), }) ); }); it('should support category filter parameter', async () => { const prisma = await import('../config/database'); vi.mocked(prisma.default.recipe.findMany).mockResolvedValue([]); vi.mocked(prisma.default.recipe.count).mockResolvedValue(0); await request(app).get('/recipes?category=dessert').expect(200); expect(prisma.default.recipe.findMany).toHaveBeenCalledWith( expect.objectContaining({ where: expect.objectContaining({ categories: { has: 'dessert' } }), }) ); }); }); describe('GET /recipes/:id', () => { it('should return single recipe by id', async () => { const mockRecipe = { id: '1', title: 'Test Recipe', description: 'Test Description', ingredients: [], instructions: [], images: [], tags: [], }; const prisma = await import('../config/database'); vi.mocked(prisma.default.recipe.findUnique).mockResolvedValue(mockRecipe as any); const response = await request(app).get('/recipes/1').expect(200); expect(response.body.data).toHaveProperty('title', 'Test Recipe'); }); it('should return recipe with tags in correct format', async () => { const mockRecipe = { id: '1', title: 'Tagged Recipe', description: 'Recipe with tags', ingredients: [], instructions: [], images: [], tags: [ { recipeId: '1', tagId: 't1', tag: { id: 't1', name: 'italian' } }, { recipeId: '1', tagId: 't2', tag: { id: 't2', name: 'dinner' } }, ], }; const prisma = await import('../config/database'); vi.mocked(prisma.default.recipe.findUnique).mockResolvedValue(mockRecipe as any); const response = await request(app).get('/recipes/1').expect(200); expect(response.body.data).toHaveProperty('title', 'Tagged Recipe'); expect(response.body.data.tags).toHaveLength(2); expect(response.body.data.tags[0]).toHaveProperty('tag'); expect(response.body.data.tags[0].tag).toHaveProperty('name', 'italian'); expect(response.body.data.tags[1].tag).toHaveProperty('name', 'dinner'); }); it('should return 404 when recipe not found', async () => { const prisma = await import('../config/database'); vi.mocked(prisma.default.recipe.findUnique).mockResolvedValue(null); const response = await request(app).get('/recipes/nonexistent').expect(404); expect(response.body).toHaveProperty('error', 'Recipe not found'); }); }); describe('POST /recipes', () => { it('should create new recipe', async () => { const newRecipe = { title: 'New Recipe', description: 'New Description', ingredients: [{ name: 'Flour', amount: '2 cups' }], instructions: [{ step: 1, text: 'Mix ingredients' }], }; const mockCreatedRecipe = { id: '1', ...newRecipe, createdAt: new Date(), updatedAt: new Date(), }; const prisma = await import('../config/database'); vi.mocked(prisma.default.recipe.create).mockResolvedValue(mockCreatedRecipe as any); const response = await request(app) .post('/recipes') .send(newRecipe) .expect(201); expect(response.body.data).toHaveProperty('title', 'New Recipe'); expect(prisma.default.recipe.create).toHaveBeenCalled(); }); it('should create recipe with tags', async () => { const newRecipe = { title: 'Tagged Recipe', description: 'Recipe with tags', tags: ['italian', 'dinner', 'quick'], }; const mockCreatedRecipe = { id: '1', ...newRecipe, tags: [ { recipeId: '1', tagId: 't1', tag: { id: 't1', name: 'italian' } }, { recipeId: '1', tagId: 't2', tag: { id: 't2', name: 'dinner' } }, { recipeId: '1', tagId: 't3', tag: { id: 't3', name: 'quick' } }, ], createdAt: new Date(), updatedAt: new Date(), }; const prisma = await import('../config/database'); vi.mocked(prisma.default.recipe.create).mockResolvedValue(mockCreatedRecipe as any); const response = await request(app) .post('/recipes') .send(newRecipe) .expect(201); expect(response.body.data).toHaveProperty('title', 'Tagged Recipe'); expect(response.body.data.tags).toHaveLength(3); expect(prisma.default.recipe.create).toHaveBeenCalledWith( expect.objectContaining({ data: expect.objectContaining({ title: 'Tagged Recipe', tags: expect.objectContaining({ create: expect.arrayContaining([ expect.objectContaining({ tag: expect.objectContaining({ connectOrCreate: expect.objectContaining({ where: { name: 'italian' }, create: { name: 'italian' }, }), }), }), ]), }), }), }) ); }); }); describe('PUT /recipes/:id', () => { it('should update recipe with tags', async () => { const updatedRecipe = { title: 'Updated Recipe', tags: ['vegetarian', 'quick'], }; const mockUpdatedRecipe = { id: '1', title: 'Updated Recipe', tags: [ { recipeId: '1', tagId: 't1', tag: { id: 't1', name: 'vegetarian' } }, { recipeId: '1', tagId: 't2', tag: { id: 't2', name: 'quick' } }, ], }; const prisma = await import('../config/database'); vi.mocked(prisma.default.recipeTag.deleteMany).mockResolvedValue({ count: 0 } as any); vi.mocked(prisma.default.ingredient.deleteMany).mockResolvedValue({ count: 0 } as any); vi.mocked(prisma.default.instruction.deleteMany).mockResolvedValue({ count: 0 } as any); vi.mocked(prisma.default.recipeSection.deleteMany).mockResolvedValue({ count: 0 } as any); vi.mocked(prisma.default.recipe.update).mockResolvedValue(mockUpdatedRecipe as any); const response = await request(app) .put('/recipes/1') .send(updatedRecipe) .expect(200); expect(response.body.data).toHaveProperty('title', 'Updated Recipe'); expect(response.body.data.tags).toHaveLength(2); expect(prisma.default.recipeTag.deleteMany).toHaveBeenCalledWith({ where: { recipeId: '1' }, }); expect(prisma.default.recipe.update).toHaveBeenCalledWith( expect.objectContaining({ where: { id: '1' }, data: expect.objectContaining({ tags: expect.objectContaining({ create: expect.arrayContaining([ expect.objectContaining({ tag: expect.objectContaining({ connectOrCreate: expect.objectContaining({ where: { name: 'vegetarian' }, }), }), }), ]), }), }), }) ); }); it('should update recipe and create new tags if they dont exist', async () => { const updatedRecipe = { title: 'Updated Recipe', tags: ['new-tag', 'another-new-tag'], }; const mockUpdatedRecipe = { id: '1', title: 'Updated Recipe', tags: [ { recipeId: '1', tagId: 't1', tag: { id: 't1', name: 'new-tag' } }, { recipeId: '1', tagId: 't2', tag: { id: 't2', name: 'another-new-tag' } }, ], }; const prisma = await import('../config/database'); vi.mocked(prisma.default.recipe.update).mockResolvedValue(mockUpdatedRecipe as any); const response = await request(app) .put('/recipes/1') .send(updatedRecipe) .expect(200); expect(response.body.data.tags).toHaveLength(2); expect(prisma.default.recipe.update).toHaveBeenCalledWith( expect.objectContaining({ data: expect.objectContaining({ tags: expect.objectContaining({ create: expect.arrayContaining([ expect.objectContaining({ tag: expect.objectContaining({ connectOrCreate: expect.objectContaining({ where: { name: 'new-tag' }, create: { name: 'new-tag' }, }), }), }), expect.objectContaining({ tag: expect.objectContaining({ connectOrCreate: expect.objectContaining({ where: { name: 'another-new-tag' }, create: { name: 'another-new-tag' }, }), }), }), ]), }), }), }) ); }); it('should remove all tags when tags array is empty', async () => { const updatedRecipe = { title: 'Recipe Without Tags', tags: [], }; const mockUpdatedRecipe = { id: '1', title: 'Recipe Without Tags', tags: [], }; const prisma = await import('../config/database'); vi.mocked(prisma.default.recipe.update).mockResolvedValue(mockUpdatedRecipe as any); const response = await request(app) .put('/recipes/1') .send(updatedRecipe) .expect(200); expect(response.body.data.tags).toHaveLength(0); expect(prisma.default.recipeTag.deleteMany).toHaveBeenCalledWith({ where: { recipeId: '1' }, }); }); }); describe('POST /recipes/import', () => { it('should import recipe from URL', async () => { const response = await request(app) .post('/recipes/import') .send({ url: 'https://example.com/recipe' }) .expect(200); expect(response.body.success).toBe(true); expect(response.body.recipe).toHaveProperty('title', 'Scraped Recipe'); }); it('should return 400 when URL is missing', async () => { const response = await request(app).post('/recipes/import').send({}).expect(400); expect(response.body).toHaveProperty('error', 'URL is required'); }); }); describe('DELETE /recipes/:id', () => { it('should delete recipe and associated images', async () => { const mockRecipe = { id: '1', title: 'Recipe to Delete', imageUrl: '/uploads/recipes/main.jpg', images: [{ url: '/uploads/recipes/image1.jpg' }], }; const prisma = await import('../config/database'); vi.mocked(prisma.default.recipe.findUnique).mockResolvedValue(mockRecipe as any); vi.mocked(prisma.default.recipe.delete).mockResolvedValue(mockRecipe as any); const response = await request(app).delete('/recipes/1').expect(200); expect(response.body).toHaveProperty('message', 'Recipe deleted successfully'); expect(prisma.default.recipe.delete).toHaveBeenCalledWith({ where: { id: '1' }, }); }); }); });