feat: improve recipe import UX and add comprehensive test coverage

## 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>
This commit is contained in:
Paul R Kartchner
2026-01-16 22:00:56 -07:00
parent 1551392c81
commit b4be894470
7 changed files with 900 additions and 45 deletions

View File

@@ -214,6 +214,32 @@ describe('Recipes Routes - Integration Tests', () => {
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);
@@ -251,6 +277,188 @@ describe('Recipes Routes - Integration Tests', () => {
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', () => {