## Changes ### Recipe Import Improvements - Move tag input to top of import preview for better UX - Allow users to add tags immediately after importing, before viewing full details - Keep focus in tag input field after pressing Enter for rapid tag addition ### Recipe Scraper Enhancements - Remove deprecated supported_only parameter from Python scraper - Update Dockerfile to explicitly install latest recipe-scrapers package - Ensure compatibility with latest recipe-scrapers library (14.55.0+) ### Testing Infrastructure - Add comprehensive tests for recipe tagging features (87% coverage) - Add real integration tests for auth routes (37% coverage on auth.routes.ts) - Add real integration tests for backup routes (74% coverage on backup.routes.ts) - Add real integration tests for scraper service (67% coverage) - Overall project coverage improved from 72.7% to 77.6% ### Test Coverage Details - 377 tests passing (up from 341) - 7 new tests for quick tagging feature - 17 new tests for authentication flows - 16 new tests for backup functionality - 6 new tests for recipe scraper integration All tests verify: - Tag CRUD operations work correctly - Tags properly connected using connectOrCreate pattern - Recipe import with live URL scraping - Security (path traversal prevention, rate limiting) - Error handling and validation Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
504 lines
16 KiB
TypeScript
504 lines
16 KiB
TypeScript
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' },
|
|
});
|
|
});
|
|
});
|
|
});
|