// src/tests/integration/recipe.integration.test.ts import { describe, it, expect, beforeAll, afterAll, vi, afterEach } from 'vitest'; import supertest from 'supertest'; import { createAndLoginUser } from '../utils/testHelpers'; import { cleanupDb } from '../utils/cleanup'; import type { UserProfile, Recipe, RecipeComment } from '../../types'; import { getPool } from '../../services/db/connection.db'; import { aiService } from '../../services/aiService.server'; /** * @vitest-environment node */ describe('Recipe API Routes Integration Tests', () => { let request: ReturnType; let testUser: UserProfile; let authToken: string; let testRecipe: Recipe; const createdUserIds: string[] = []; const createdRecipeIds: number[] = []; beforeAll(async () => { vi.stubEnv('FRONTEND_URL', 'https://example.com'); const app = (await import('../../../server')).default; request = supertest(app); // Create a user to own the recipe and perform authenticated actions const { user, token } = await createAndLoginUser({ email: `recipe-user-${Date.now()}@example.com`, fullName: 'Recipe Test User', request, }); testUser = user; authToken = token; createdUserIds.push(user.user.user_id); // Mock the AI service method using spyOn to preserve other exports like DuplicateFlyerError vi.spyOn(aiService, 'generateRecipeSuggestion').mockResolvedValue('Default Mock Suggestion'); // Create a recipe owned by the test user const recipeRes = await getPool().query( `INSERT INTO public.recipes (name, instructions, user_id, status, description) VALUES ('Integration Test Recipe', '1. Do this. 2. Do that.', $1, 'public', 'A test recipe description.') RETURNING *`, [testUser.user.user_id], ); testRecipe = recipeRes.rows[0]; createdRecipeIds.push(testRecipe.recipe_id); }); afterEach(() => { vi.clearAllMocks(); // Reset the mock to its default state for the next test vi.mocked(aiService.generateRecipeSuggestion).mockResolvedValue('Default Mock Suggestion'); }); afterAll(async () => { vi.unstubAllEnvs(); // Clean up all created resources await cleanupDb({ userIds: createdUserIds, recipeIds: createdRecipeIds, }); }); describe('GET /api/recipes/:recipeId', () => { it('should fetch a single public recipe by its ID', async () => { const response = await request.get(`/api/recipes/${testRecipe.recipe_id}`); expect(response.status).toBe(200); expect(response.body.data).toBeDefined(); expect(response.body.data.recipe_id).toBe(testRecipe.recipe_id); expect(response.body.data.name).toBe('Integration Test Recipe'); }); it('should return 404 for a non-existent recipe ID', async () => { const response = await request.get('/api/v1/recipes/999999'); expect(response.status).toBe(404); }); }); it('should allow an authenticated user to create a new recipe', async () => { const newRecipeData = { name: 'My New Awesome Recipe', instructions: '1. Be awesome. 2. Make recipe.', description: 'A recipe created during an integration test.', }; const response = await request .post('/api/v1/users/recipes') .set('Authorization', `Bearer ${authToken}`) .send(newRecipeData); // Assert the response from the POST request expect(response.status).toBe(201); const createdRecipe: Recipe = response.body.data; expect(createdRecipe).toBeDefined(); expect(createdRecipe.recipe_id).toBeTypeOf('number'); expect(createdRecipe.name).toBe(newRecipeData.name); expect(createdRecipe.user_id).toBe(testUser.user.user_id); // Add the new recipe ID to the cleanup array to ensure it's deleted after tests createdRecipeIds.push(createdRecipe.recipe_id); // Verify the recipe can be fetched from the public endpoint const verifyResponse = await request.get(`/api/recipes/${createdRecipe.recipe_id}`); expect(verifyResponse.status).toBe(200); expect(verifyResponse.body.data.name).toBe(newRecipeData.name); }); it('should allow an authenticated user to update their own recipe', async () => { const recipeUpdates = { name: 'Updated Integration Test Recipe', instructions: '1. Do the new thing. 2. Do the other new thing.', }; const response = await request .put(`/api/users/recipes/${testRecipe.recipe_id}`) // Authenticated recipe update endpoint .set('Authorization', `Bearer ${authToken}`) .send(recipeUpdates); // Assert the response from the PUT request expect(response.status).toBe(200); const updatedRecipe: Recipe = response.body.data; expect(updatedRecipe.name).toBe(recipeUpdates.name); expect(updatedRecipe.instructions).toBe(recipeUpdates.instructions); // Verify the changes were persisted by fetching the recipe again const verifyResponse = await request.get(`/api/recipes/${testRecipe.recipe_id}`); expect(verifyResponse.status).toBe(200); expect(verifyResponse.body.data.name).toBe(recipeUpdates.name); }); it("should prevent a user from updating another user's recipe", async () => { // Create a second user who will try to update the first user's recipe const { user: otherUser, token: otherToken } = await createAndLoginUser({ email: `recipe-other-${Date.now()}@example.com`, fullName: 'Other Recipe User', request, }); createdUserIds.push(otherUser.user.user_id); // Attempt to update the testRecipe (owned by testUser) using otherUser's token const response = await request .put(`/api/users/recipes/${testRecipe.recipe_id}`) .set('Authorization', `Bearer ${otherToken}`) .send({ name: 'Hacked Recipe Name' }); // Should return 404 because the recipe doesn't belong to this user expect(response.status).toBe(404); }); it('should allow an authenticated user to delete their own recipe', async () => { // Create a recipe specifically for deletion const createRes = await request .post('/api/v1/users/recipes') .set('Authorization', `Bearer ${authToken}`) .send({ name: 'Recipe To Delete', instructions: 'This recipe will be deleted.', description: 'A temporary recipe.', }); expect(createRes.status).toBe(201); const recipeToDelete: Recipe = createRes.body.data; // Delete the recipe const deleteRes = await request .delete(`/api/users/recipes/${recipeToDelete.recipe_id}`) .set('Authorization', `Bearer ${authToken}`); expect(deleteRes.status).toBe(204); // Verify it's actually deleted by trying to fetch it const verifyRes = await request.get(`/api/recipes/${recipeToDelete.recipe_id}`); expect(verifyRes.status).toBe(404); }); it("should prevent a user from deleting another user's recipe", async () => { // Create a second user who will try to delete the first user's recipe const { user: otherUser, token: otherToken } = await createAndLoginUser({ email: `recipe-deleter-${Date.now()}@example.com`, fullName: 'Deleter User', request, }); createdUserIds.push(otherUser.user.user_id); // Attempt to delete the testRecipe (owned by testUser) using otherUser's token const response = await request .delete(`/api/users/recipes/${testRecipe.recipe_id}`) .set('Authorization', `Bearer ${otherToken}`); // Should return 404 because the recipe doesn't belong to this user expect(response.status).toBe(404); // Verify the recipe still exists const verifyRes = await request.get(`/api/recipes/${testRecipe.recipe_id}`); expect(verifyRes.status).toBe(200); }); it('should allow an authenticated user to post a comment on a recipe', async () => { const commentContent = 'This is a great recipe! Thanks for sharing.'; const response = await request .post(`/api/recipes/${testRecipe.recipe_id}/comments`) .set('Authorization', `Bearer ${authToken}`) .send({ content: commentContent }); expect(response.status).toBe(201); const comment: RecipeComment = response.body.data; expect(comment.content).toBe(commentContent); expect(comment.recipe_id).toBe(testRecipe.recipe_id); expect(comment.user_id).toBe(testUser.user.user_id); expect(comment.recipe_comment_id).toBeDefined(); }); it('should allow an authenticated user to fork a recipe', async () => { const response = await request .post(`/api/recipes/${testRecipe.recipe_id}/fork`) .set('Authorization', `Bearer ${authToken}`); expect(response.status).toBe(201); const forkedRecipe: Recipe = response.body.data; // The forked recipe should have a new ID but reference the original expect(forkedRecipe.recipe_id).not.toBe(testRecipe.recipe_id); expect(forkedRecipe.original_recipe_id).toBe(testRecipe.recipe_id); expect(forkedRecipe.user_id).toBe(testUser.user.user_id); // The name should include "(Fork)" suffix expect(forkedRecipe.name).toContain('Fork'); // Track for cleanup createdRecipeIds.push(forkedRecipe.recipe_id); }); it('should allow forking seed recipes (null user_id)', async () => { // First, find or create a seed recipe (one with null user_id) let seedRecipeId: number; const seedRecipeResult = await getPool().query( `SELECT recipe_id FROM public.recipes WHERE user_id IS NULL LIMIT 1`, ); if (seedRecipeResult.rows.length > 0) { seedRecipeId = seedRecipeResult.rows[0].recipe_id; } else { // Create a seed recipe if none exist const createSeedResult = await getPool().query( `INSERT INTO public.recipes (name, instructions, user_id, status, description) VALUES ('Seed Recipe for Fork Test', 'Seed recipe instructions.', NULL, 'public', 'A seed recipe.') RETURNING recipe_id`, ); seedRecipeId = createSeedResult.rows[0].recipe_id; createdRecipeIds.push(seedRecipeId); } // Fork the seed recipe - this should succeed const response = await request .post(`/api/recipes/${seedRecipeId}/fork`) .set('Authorization', `Bearer ${authToken}`); // Forking should work - seed recipes should be forkable expect(response.status).toBe(201); const forkedRecipe: Recipe = response.body.data; expect(forkedRecipe.original_recipe_id).toBe(seedRecipeId); expect(forkedRecipe.user_id).toBe(testUser.user.user_id); // Track for cleanup createdRecipeIds.push(forkedRecipe.recipe_id); }); describe('GET /api/recipes/:recipeId/comments', () => { it('should return comments for a recipe', async () => { // First add a comment await request .post(`/api/recipes/${testRecipe.recipe_id}/comments`) .set('Authorization', `Bearer ${authToken}`) .send({ content: 'Test comment for GET request' }); // Now fetch comments const response = await request.get(`/api/recipes/${testRecipe.recipe_id}/comments`); expect(response.status).toBe(200); expect(response.body.success).toBe(true); expect(response.body.data).toBeInstanceOf(Array); expect(response.body.data.length).toBeGreaterThan(0); // Verify comment structure const comment = response.body.data[0]; expect(comment).toHaveProperty('recipe_comment_id'); expect(comment).toHaveProperty('content'); expect(comment).toHaveProperty('user_id'); expect(comment).toHaveProperty('recipe_id'); }); it('should return empty array for recipe with no comments', async () => { // Create a recipe specifically with no comments const createRes = await request .post('/api/v1/users/recipes') .set('Authorization', `Bearer ${authToken}`) .send({ name: 'Recipe With No Comments', instructions: 'No comments here.', description: 'Testing empty comments.', }); const noCommentsRecipe: Recipe = createRes.body.data; createdRecipeIds.push(noCommentsRecipe.recipe_id); // Fetch comments for this recipe const response = await request.get(`/api/recipes/${noCommentsRecipe.recipe_id}/comments`); expect(response.status).toBe(200); expect(response.body.success).toBe(true); expect(response.body.data).toEqual([]); }); }); describe('POST /api/recipes/suggest', () => { it('should return a recipe suggestion based on ingredients', async () => { const ingredients = ['chicken', 'rice', 'broccoli']; const mockSuggestion = 'Chicken and Broccoli Stir-fry with Rice'; vi.mocked(aiService.generateRecipeSuggestion).mockResolvedValue(mockSuggestion); const response = await request .post('/api/v1/recipes/suggest') .set('Authorization', `Bearer ${authToken}`) .send({ ingredients }); expect(response.status).toBe(200); expect(response.body.data).toEqual({ suggestion: mockSuggestion }); expect(aiService.generateRecipeSuggestion).toHaveBeenCalledWith( ingredients, expect.anything(), ); }); }); });