Files
flyer-crawler.projectium.com/src/tests/integration/recipe.integration.test.ts
Torben Sorensen 2075ed199b Complete ADR-008 Phase 1: API Versioning Strategy
Implement URI-based API versioning with /api/v1 prefix across all routes.
This establishes a foundation for future API evolution and breaking changes.

Changes:
- server.ts: All routes mounted under /api/v1/ (15 route handlers)
- apiClient.ts: Base URL updated to /api/v1
- swagger.ts: OpenAPI server URL changed to /api/v1
- Redirect middleware: Added backwards compatibility for /api/* → /api/v1/*
- Tests: Updated 72 test files with versioned path assertions
- ADR documentation: Marked Phase 1 as complete (Accepted status)

Test fixes:
- apiClient.test.ts: 27 tests updated for /api/v1 paths
- user.routes.ts: 36 log messages updated to reflect versioned paths
- swagger.test.ts: 1 test updated for new server URL
- All integration/E2E tests updated for versioned endpoints

All Phase 1 acceptance criteria met:
✓ Routes use /api/v1/ prefix
✓ Frontend requests /api/v1/
✓ OpenAPI docs reflect /api/v1/
✓ Backwards compatibility via redirect middleware
✓ Tests pass with versioned paths

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-01-26 21:23:25 -08:00

337 lines
13 KiB
TypeScript

// 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<typeof supertest>;
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(),
);
});
});
});