feat: add cookbooks, multiple categories, and image management
Some checks failed
CI Pipeline / Lint Code (push) Has been cancelled
CI Pipeline / Test API Package (push) Has been cancelled
CI Pipeline / Test Web Package (push) Has been cancelled
CI Pipeline / Test Shared Package (push) Has been cancelled
CI Pipeline / Build All Packages (push) Has been cancelled
CI Pipeline / Generate Coverage Report (push) Has been cancelled
Docker Build & Deploy / Build Docker Images (push) Has been cancelled
Docker Build & Deploy / Push Docker Images (push) Has been cancelled
Docker Build & Deploy / Deploy to Staging (push) Has been cancelled
Docker Build & Deploy / Deploy to Production (push) Has been cancelled
E2E Tests / End-to-End Tests (push) Has been cancelled
E2E Tests / E2E Tests (Mobile) (push) Has been cancelled
Security Scanning / NPM Audit (push) Has been cancelled
Security Scanning / Dependency License Check (push) Has been cancelled
Security Scanning / Code Quality Scan (push) Has been cancelled
Security Scanning / Docker Image Security (push) Has been cancelled
Security Scanning / Security Summary (push) Has been cancelled
Some checks failed
CI Pipeline / Lint Code (push) Has been cancelled
CI Pipeline / Test API Package (push) Has been cancelled
CI Pipeline / Test Web Package (push) Has been cancelled
CI Pipeline / Test Shared Package (push) Has been cancelled
CI Pipeline / Build All Packages (push) Has been cancelled
CI Pipeline / Generate Coverage Report (push) Has been cancelled
Docker Build & Deploy / Build Docker Images (push) Has been cancelled
Docker Build & Deploy / Push Docker Images (push) Has been cancelled
Docker Build & Deploy / Deploy to Staging (push) Has been cancelled
Docker Build & Deploy / Deploy to Production (push) Has been cancelled
E2E Tests / End-to-End Tests (push) Has been cancelled
E2E Tests / E2E Tests (Mobile) (push) Has been cancelled
Security Scanning / NPM Audit (push) Has been cancelled
Security Scanning / Dependency License Check (push) Has been cancelled
Security Scanning / Code Quality Scan (push) Has been cancelled
Security Scanning / Docker Image Security (push) Has been cancelled
Security Scanning / Security Summary (push) Has been cancelled
Major features added: - Cookbook management with CRUD operations - Auto-filter cookbooks by categories and tags - Multiple categories per recipe (changed from single category) - Image upload and URL download for cookbooks - Improved image management UI Database changes: - Changed Recipe.category (string) to Recipe.categories (string array) - Added Cookbook and CookbookRecipe models - Added Tag and RecipeTag models for recipe tagging Backend changes: - Added cookbooks API routes with image upload - Added tags API routes - Added auto-filter functionality to add recipes to cookbooks automatically - Added downloadAndSaveImage() to StorageService for URL downloads - Updated recipes routes to support multiple categories Frontend changes: - Added Cookbooks page with grid view - Added CookbookDetail page with filtering - Added EditCookbook page with image upload/download - Updated recipe forms to use chip-based UI for multiple categories - Improved image upload UX with separate file upload and URL download - Added remove image functionality with immediate save 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -3,6 +3,8 @@ import cors from 'cors';
|
||||
import dotenv from 'dotenv';
|
||||
import path from 'path';
|
||||
import recipesRoutes from './routes/recipes.routes';
|
||||
import cookbooksRoutes from './routes/cookbooks.routes';
|
||||
import tagsRoutes from './routes/tags.routes';
|
||||
|
||||
dotenv.config();
|
||||
|
||||
@@ -22,6 +24,8 @@ app.use('/uploads', express.static(uploadsPath));
|
||||
|
||||
// Routes
|
||||
app.use('/api/recipes', recipesRoutes);
|
||||
app.use('/api/cookbooks', cookbooksRoutes);
|
||||
app.use('/api/tags', tagsRoutes);
|
||||
|
||||
// Health check
|
||||
app.get('/health', (req, res) => {
|
||||
|
||||
287
packages/api/src/routes/cookbook-integration.test.ts
Normal file
287
packages/api/src/routes/cookbook-integration.test.ts
Normal file
@@ -0,0 +1,287 @@
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||
import express from 'express';
|
||||
import request from 'supertest';
|
||||
import cookbooksRouter from './cookbooks.routes';
|
||||
import tagsRouter from './tags.routes';
|
||||
|
||||
// Mock the database
|
||||
vi.mock('../config/database', () => ({
|
||||
default: {
|
||||
cookbook: {
|
||||
findMany: vi.fn(),
|
||||
findUnique: vi.fn(),
|
||||
create: vi.fn(),
|
||||
update: vi.fn(),
|
||||
delete: vi.fn(),
|
||||
},
|
||||
cookbookRecipe: {
|
||||
findUnique: vi.fn(),
|
||||
create: vi.fn(),
|
||||
delete: vi.fn(),
|
||||
},
|
||||
tag: {
|
||||
findMany: vi.fn(),
|
||||
findFirst: vi.fn(),
|
||||
findUnique: vi.fn(),
|
||||
create: vi.fn(),
|
||||
delete: vi.fn(),
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
describe('Cookbook & Tags - Integration Tests', () => {
|
||||
let app: express.Application;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
app = express();
|
||||
app.use(express.json());
|
||||
app.use('/cookbooks', cookbooksRouter);
|
||||
app.use('/tags', tagsRouter);
|
||||
});
|
||||
|
||||
describe('Complete Cookbook Workflow', () => {
|
||||
it('should create a cookbook, add recipes, and retrieve it', async () => {
|
||||
const prisma = await import('../config/database');
|
||||
|
||||
// Step 1: Create a cookbook
|
||||
const newCookbook = {
|
||||
name: 'Summer BBQ',
|
||||
description: 'Perfect recipes for outdoor grilling',
|
||||
};
|
||||
|
||||
const createdCookbook = {
|
||||
id: 'cb-summer',
|
||||
...newCookbook,
|
||||
coverImageUrl: null,
|
||||
createdAt: new Date('2025-06-01'),
|
||||
updatedAt: new Date('2025-06-01'),
|
||||
};
|
||||
|
||||
vi.mocked(prisma.default.cookbook.create).mockResolvedValue(createdCookbook as any);
|
||||
|
||||
const createResponse = await request(app).post('/cookbooks').send(newCookbook).expect(201);
|
||||
|
||||
expect(createResponse.body.data.id).toBe('cb-summer');
|
||||
expect(createResponse.body.data.name).toBe('Summer BBQ');
|
||||
|
||||
// Step 2: Add a recipe to the cookbook
|
||||
vi.mocked(prisma.default.cookbookRecipe.findUnique).mockResolvedValue(null);
|
||||
vi.mocked(prisma.default.cookbookRecipe.create).mockResolvedValue({
|
||||
id: 'cbr1',
|
||||
cookbookId: 'cb-summer',
|
||||
recipeId: 'recipe-bbq-ribs',
|
||||
addedAt: new Date(),
|
||||
} as any);
|
||||
|
||||
const addRecipeResponse = await request(app)
|
||||
.post('/cookbooks/cb-summer/recipes/recipe-bbq-ribs')
|
||||
.expect(201);
|
||||
|
||||
expect(addRecipeResponse.body.data.cookbookId).toBe('cb-summer');
|
||||
expect(addRecipeResponse.body.data.recipeId).toBe('recipe-bbq-ribs');
|
||||
|
||||
// Step 3: Retrieve the cookbook with its recipes
|
||||
const cookbookWithRecipes = {
|
||||
...createdCookbook,
|
||||
recipes: [
|
||||
{
|
||||
recipe: {
|
||||
id: 'recipe-bbq-ribs',
|
||||
title: 'BBQ Ribs',
|
||||
description: 'Tender, fall-off-the-bone ribs',
|
||||
images: [],
|
||||
tags: [
|
||||
{ tag: { id: 't1', name: 'BBQ' } },
|
||||
{ tag: { id: 't2', name: 'Summer' } },
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
vi.mocked(prisma.default.cookbook.findUnique).mockResolvedValue(cookbookWithRecipes as any);
|
||||
|
||||
const getResponse = await request(app).get('/cookbooks/cb-summer').expect(200);
|
||||
|
||||
expect(getResponse.body.data.name).toBe('Summer BBQ');
|
||||
expect(getResponse.body.data.recipes).toHaveLength(1);
|
||||
expect(getResponse.body.data.recipes[0].title).toBe('BBQ Ribs');
|
||||
expect(getResponse.body.data.recipes[0].tags).toEqual(['BBQ', 'Summer']);
|
||||
});
|
||||
|
||||
it('should prevent adding the same recipe twice to a cookbook', async () => {
|
||||
const prisma = await import('../config/database');
|
||||
|
||||
// First addition succeeds
|
||||
vi.mocked(prisma.default.cookbookRecipe.findUnique).mockResolvedValueOnce(null);
|
||||
vi.mocked(prisma.default.cookbookRecipe.create).mockResolvedValue({
|
||||
id: 'cbr1',
|
||||
cookbookId: 'cb1',
|
||||
recipeId: 'r1',
|
||||
addedAt: new Date(),
|
||||
} as any);
|
||||
|
||||
await request(app).post('/cookbooks/cb1/recipes/r1').expect(201);
|
||||
|
||||
// Second addition fails
|
||||
vi.mocked(prisma.default.cookbookRecipe.findUnique).mockResolvedValueOnce({
|
||||
id: 'cbr1',
|
||||
cookbookId: 'cb1',
|
||||
recipeId: 'r1',
|
||||
addedAt: new Date(),
|
||||
} as any);
|
||||
|
||||
await request(app).post('/cookbooks/cb1/recipes/r1').expect(400);
|
||||
});
|
||||
|
||||
it('should remove a recipe from a cookbook', async () => {
|
||||
const prisma = await import('../config/database');
|
||||
|
||||
vi.mocked(prisma.default.cookbookRecipe.delete).mockResolvedValue({} as any);
|
||||
|
||||
const response = await request(app).delete('/cookbooks/cb1/recipes/r1').expect(200);
|
||||
|
||||
expect(response.body.message).toBe('Recipe removed from cookbook');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Complete Tags Workflow', () => {
|
||||
it('should create tags and handle case-insensitive duplicates', async () => {
|
||||
const prisma = await import('../config/database');
|
||||
|
||||
// Create first tag
|
||||
vi.mocked(prisma.default.tag.findFirst).mockResolvedValueOnce(null);
|
||||
vi.mocked(prisma.default.tag.create).mockResolvedValue({
|
||||
id: 't1',
|
||||
name: 'Italian',
|
||||
} as any);
|
||||
|
||||
const firstResponse = await request(app).post('/tags').send({ name: 'Italian' }).expect(200);
|
||||
|
||||
expect(firstResponse.body.data.name).toBe('Italian');
|
||||
|
||||
// Try to create duplicate with different case - should return existing
|
||||
vi.mocked(prisma.default.tag.findFirst).mockResolvedValueOnce({
|
||||
id: 't1',
|
||||
name: 'Italian',
|
||||
} as any);
|
||||
|
||||
const duplicateResponse = await request(app).post('/tags').send({ name: 'italian' }).expect(200);
|
||||
|
||||
expect(duplicateResponse.body.data.id).toBe('t1');
|
||||
expect(duplicateResponse.body.data.name).toBe('Italian'); // Original casing preserved
|
||||
});
|
||||
|
||||
it('should list all tags sorted alphabetically', async () => {
|
||||
const prisma = await import('../config/database');
|
||||
|
||||
const mockTags = [
|
||||
{ id: 't1', name: 'BBQ', _count: { recipes: 5 } },
|
||||
{ id: 't2', name: 'Dessert', _count: { recipes: 10 } },
|
||||
{ id: 't3', name: 'Italian', _count: { recipes: 15 } },
|
||||
];
|
||||
|
||||
vi.mocked(prisma.default.tag.findMany).mockResolvedValue(mockTags as any);
|
||||
|
||||
const response = await request(app).get('/tags').expect(200);
|
||||
|
||||
expect(response.body.data).toHaveLength(3);
|
||||
expect(response.body.data[0].name).toBe('BBQ');
|
||||
expect(response.body.data[1].name).toBe('Dessert');
|
||||
expect(response.body.data[2].name).toBe('Italian');
|
||||
});
|
||||
|
||||
it('should only allow deletion of unused tags', async () => {
|
||||
const prisma = await import('../config/database');
|
||||
|
||||
// Try to delete used tag
|
||||
vi.mocked(prisma.default.tag.findUnique).mockResolvedValueOnce({
|
||||
id: 't1',
|
||||
name: 'Italian',
|
||||
_count: { recipes: 5 },
|
||||
} as any);
|
||||
|
||||
const usedTagResponse = await request(app).delete('/tags/t1').expect(400);
|
||||
|
||||
expect(usedTagResponse.body.error).toContain('used by 5 recipe(s)');
|
||||
|
||||
// Delete unused tag
|
||||
vi.mocked(prisma.default.tag.findUnique).mockResolvedValueOnce({
|
||||
id: 't2',
|
||||
name: 'Unused',
|
||||
_count: { recipes: 0 },
|
||||
} as any);
|
||||
vi.mocked(prisma.default.tag.delete).mockResolvedValue({} as any);
|
||||
|
||||
const unusedTagResponse = await request(app).delete('/tags/t2').expect(200);
|
||||
|
||||
expect(unusedTagResponse.body.message).toBe('Tag deleted successfully');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Cookbook and Tags Combined Workflow', () => {
|
||||
it('should create cookbook, create tags, and retrieve cookbook with tagged recipes', async () => {
|
||||
const prisma = await import('../config/database');
|
||||
|
||||
// Create tags
|
||||
vi.mocked(prisma.default.tag.findFirst).mockResolvedValueOnce(null);
|
||||
vi.mocked(prisma.default.tag.create).mockResolvedValueOnce({
|
||||
id: 't1',
|
||||
name: 'Quick',
|
||||
} as any);
|
||||
|
||||
await request(app).post('/tags').send({ name: 'Quick' }).expect(200);
|
||||
|
||||
vi.mocked(prisma.default.tag.findFirst).mockResolvedValueOnce(null);
|
||||
vi.mocked(prisma.default.tag.create).mockResolvedValueOnce({
|
||||
id: 't2',
|
||||
name: 'Healthy',
|
||||
} as any);
|
||||
|
||||
await request(app).post('/tags').send({ name: 'Healthy' }).expect(200);
|
||||
|
||||
// Create cookbook
|
||||
vi.mocked(prisma.default.cookbook.create).mockResolvedValue({
|
||||
id: 'cb1',
|
||||
name: 'Weeknight Dinners',
|
||||
description: 'Quick and healthy meals',
|
||||
coverImageUrl: null,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
} as any);
|
||||
|
||||
await request(app)
|
||||
.post('/cookbooks')
|
||||
.send({ name: 'Weeknight Dinners', description: 'Quick and healthy meals' })
|
||||
.expect(201);
|
||||
|
||||
// Retrieve cookbook with tagged recipes
|
||||
vi.mocked(prisma.default.cookbook.findUnique).mockResolvedValue({
|
||||
id: 'cb1',
|
||||
name: 'Weeknight Dinners',
|
||||
description: 'Quick and healthy meals',
|
||||
coverImageUrl: null,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
recipes: [
|
||||
{
|
||||
recipe: {
|
||||
id: 'r1',
|
||||
title: 'Stir Fry',
|
||||
tags: [
|
||||
{ tag: { id: 't1', name: 'Quick' } },
|
||||
{ tag: { id: 't2', name: 'Healthy' } },
|
||||
],
|
||||
images: [],
|
||||
},
|
||||
},
|
||||
],
|
||||
} as any);
|
||||
|
||||
const response = await request(app).get('/cookbooks/cb1').expect(200);
|
||||
|
||||
expect(response.body.data.recipes[0].tags).toEqual(['Quick', 'Healthy']);
|
||||
});
|
||||
});
|
||||
});
|
||||
270
packages/api/src/routes/cookbooks.routes.test.ts
Normal file
270
packages/api/src/routes/cookbooks.routes.test.ts
Normal file
@@ -0,0 +1,270 @@
|
||||
import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest';
|
||||
import express from 'express';
|
||||
import request from 'supertest';
|
||||
import cookbooksRouter from './cookbooks.routes';
|
||||
|
||||
// Mock the database
|
||||
vi.mock('../config/database', () => ({
|
||||
default: {
|
||||
cookbook: {
|
||||
findMany: vi.fn(),
|
||||
findUnique: vi.fn(),
|
||||
create: vi.fn(),
|
||||
update: vi.fn(),
|
||||
delete: vi.fn(),
|
||||
},
|
||||
cookbookRecipe: {
|
||||
findUnique: vi.fn(),
|
||||
create: vi.fn(),
|
||||
delete: vi.fn(),
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
describe('Cookbooks Routes - Unit Tests', () => {
|
||||
let app: express.Application;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
app = express();
|
||||
app.use(express.json());
|
||||
app.use('/cookbooks', cookbooksRouter);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('GET /cookbooks', () => {
|
||||
it('should return all cookbooks with recipe counts', async () => {
|
||||
const mockCookbooks = [
|
||||
{
|
||||
id: 'cb1',
|
||||
name: 'Family Favorites',
|
||||
description: 'Our favorite family recipes',
|
||||
coverImageUrl: null,
|
||||
createdAt: new Date('2025-01-01'),
|
||||
updatedAt: new Date('2025-01-01'),
|
||||
_count: { recipes: 5 },
|
||||
},
|
||||
{
|
||||
id: 'cb2',
|
||||
name: 'Holiday Recipes',
|
||||
description: 'Recipes for holidays',
|
||||
coverImageUrl: '/uploads/holiday.jpg',
|
||||
createdAt: new Date('2025-01-02'),
|
||||
updatedAt: new Date('2025-01-02'),
|
||||
_count: { recipes: 3 },
|
||||
},
|
||||
];
|
||||
|
||||
const prisma = await import('../config/database');
|
||||
vi.mocked(prisma.default.cookbook.findMany).mockResolvedValue(mockCookbooks as any);
|
||||
|
||||
const response = await request(app).get('/cookbooks').expect(200);
|
||||
|
||||
expect(response.body.data).toHaveLength(2);
|
||||
expect(response.body.data[0]).toEqual({
|
||||
id: 'cb1',
|
||||
name: 'Family Favorites',
|
||||
description: 'Our favorite family recipes',
|
||||
coverImageUrl: null,
|
||||
recipeCount: 5,
|
||||
createdAt: mockCookbooks[0].createdAt.toISOString(),
|
||||
updatedAt: mockCookbooks[0].updatedAt.toISOString(),
|
||||
});
|
||||
expect(prisma.default.cookbook.findMany).toHaveBeenCalledWith({
|
||||
include: {
|
||||
_count: {
|
||||
select: { recipes: true },
|
||||
},
|
||||
},
|
||||
orderBy: { updatedAt: 'desc' },
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle errors gracefully', async () => {
|
||||
const prisma = await import('../config/database');
|
||||
vi.mocked(prisma.default.cookbook.findMany).mockRejectedValue(new Error('Database error'));
|
||||
|
||||
const response = await request(app).get('/cookbooks').expect(500);
|
||||
|
||||
expect(response.body.error).toBe('Failed to fetch cookbooks');
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /cookbooks/:id', () => {
|
||||
it('should return a cookbook with its recipes', async () => {
|
||||
const mockCookbook = {
|
||||
id: 'cb1',
|
||||
name: 'Family Favorites',
|
||||
description: 'Our favorite family recipes',
|
||||
coverImageUrl: null,
|
||||
createdAt: new Date('2025-01-01'),
|
||||
updatedAt: new Date('2025-01-01'),
|
||||
recipes: [
|
||||
{
|
||||
recipe: {
|
||||
id: 'r1',
|
||||
title: 'Pasta Carbonara',
|
||||
description: 'Classic Italian pasta',
|
||||
images: [],
|
||||
tags: [
|
||||
{ tag: { id: 't1', name: 'Italian' } },
|
||||
{ tag: { id: 't2', name: 'Pasta' } },
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const prisma = await import('../config/database');
|
||||
vi.mocked(prisma.default.cookbook.findUnique).mockResolvedValue(mockCookbook as any);
|
||||
|
||||
const response = await request(app).get('/cookbooks/cb1').expect(200);
|
||||
|
||||
expect(response.body.data.id).toBe('cb1');
|
||||
expect(response.body.data.recipes).toHaveLength(1);
|
||||
expect(response.body.data.recipes[0].tags).toEqual(['Italian', 'Pasta']);
|
||||
});
|
||||
|
||||
it('should return 404 if cookbook not found', async () => {
|
||||
const prisma = await import('../config/database');
|
||||
vi.mocked(prisma.default.cookbook.findUnique).mockResolvedValue(null);
|
||||
|
||||
const response = await request(app).get('/cookbooks/nonexistent').expect(404);
|
||||
|
||||
expect(response.body.error).toBe('Cookbook not found');
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST /cookbooks', () => {
|
||||
it('should create a new cookbook', async () => {
|
||||
const newCookbook = {
|
||||
name: 'Quick Meals',
|
||||
description: 'Fast recipes for busy weeknights',
|
||||
coverImageUrl: '/uploads/quick-meals.jpg',
|
||||
};
|
||||
|
||||
const createdCookbook = {
|
||||
id: 'cb-new',
|
||||
...newCookbook,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
};
|
||||
|
||||
const prisma = await import('../config/database');
|
||||
vi.mocked(prisma.default.cookbook.create).mockResolvedValue(createdCookbook as any);
|
||||
|
||||
const response = await request(app).post('/cookbooks').send(newCookbook).expect(201);
|
||||
|
||||
expect(response.body.data.id).toBe('cb-new');
|
||||
expect(response.body.data.name).toBe('Quick Meals');
|
||||
expect(prisma.default.cookbook.create).toHaveBeenCalledWith({
|
||||
data: newCookbook,
|
||||
});
|
||||
});
|
||||
|
||||
it('should return 400 if name is missing', async () => {
|
||||
const response = await request(app)
|
||||
.post('/cookbooks')
|
||||
.send({ description: 'Missing name' })
|
||||
.expect(400);
|
||||
|
||||
expect(response.body.error).toBe('Name is required');
|
||||
});
|
||||
});
|
||||
|
||||
describe('PUT /cookbooks/:id', () => {
|
||||
it('should update a cookbook', async () => {
|
||||
const updates = {
|
||||
name: 'Updated Name',
|
||||
description: 'Updated description',
|
||||
};
|
||||
|
||||
const updatedCookbook = {
|
||||
id: 'cb1',
|
||||
...updates,
|
||||
coverImageUrl: null,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
};
|
||||
|
||||
const prisma = await import('../config/database');
|
||||
vi.mocked(prisma.default.cookbook.update).mockResolvedValue(updatedCookbook as any);
|
||||
|
||||
const response = await request(app).put('/cookbooks/cb1').send(updates).expect(200);
|
||||
|
||||
expect(response.body.data.name).toBe('Updated Name');
|
||||
expect(prisma.default.cookbook.update).toHaveBeenCalledWith({
|
||||
where: { id: 'cb1' },
|
||||
data: updates,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('DELETE /cookbooks/:id', () => {
|
||||
it('should delete a cookbook', async () => {
|
||||
const prisma = await import('../config/database');
|
||||
vi.mocked(prisma.default.cookbook.delete).mockResolvedValue({} as any);
|
||||
|
||||
const response = await request(app).delete('/cookbooks/cb1').expect(200);
|
||||
|
||||
expect(response.body.message).toBe('Cookbook deleted successfully');
|
||||
expect(prisma.default.cookbook.delete).toHaveBeenCalledWith({
|
||||
where: { id: 'cb1' },
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST /cookbooks/:id/recipes/:recipeId', () => {
|
||||
it('should add a recipe to a cookbook', async () => {
|
||||
const prisma = await import('../config/database');
|
||||
vi.mocked(prisma.default.cookbookRecipe.findUnique).mockResolvedValue(null);
|
||||
vi.mocked(prisma.default.cookbookRecipe.create).mockResolvedValue({
|
||||
id: 'cbr1',
|
||||
cookbookId: 'cb1',
|
||||
recipeId: 'r1',
|
||||
addedAt: new Date(),
|
||||
} as any);
|
||||
|
||||
const response = await request(app).post('/cookbooks/cb1/recipes/r1').expect(201);
|
||||
|
||||
expect(response.body.data.cookbookId).toBe('cb1');
|
||||
expect(response.body.data.recipeId).toBe('r1');
|
||||
});
|
||||
|
||||
it('should return 400 if recipe is already in cookbook', async () => {
|
||||
const prisma = await import('../config/database');
|
||||
vi.mocked(prisma.default.cookbookRecipe.findUnique).mockResolvedValue({
|
||||
id: 'cbr1',
|
||||
cookbookId: 'cb1',
|
||||
recipeId: 'r1',
|
||||
addedAt: new Date(),
|
||||
} as any);
|
||||
|
||||
const response = await request(app).post('/cookbooks/cb1/recipes/r1').expect(400);
|
||||
|
||||
expect(response.body.error).toBe('Recipe already in cookbook');
|
||||
});
|
||||
});
|
||||
|
||||
describe('DELETE /cookbooks/:id/recipes/:recipeId', () => {
|
||||
it('should remove a recipe from a cookbook', async () => {
|
||||
const prisma = await import('../config/database');
|
||||
vi.mocked(prisma.default.cookbookRecipe.delete).mockResolvedValue({} as any);
|
||||
|
||||
const response = await request(app).delete('/cookbooks/cb1/recipes/r1').expect(200);
|
||||
|
||||
expect(response.body.message).toBe('Recipe removed from cookbook');
|
||||
expect(prisma.default.cookbookRecipe.delete).toHaveBeenCalledWith({
|
||||
where: {
|
||||
cookbookId_recipeId: {
|
||||
cookbookId: 'cb1',
|
||||
recipeId: 'r1',
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
370
packages/api/src/routes/cookbooks.routes.ts
Normal file
370
packages/api/src/routes/cookbooks.routes.ts
Normal file
@@ -0,0 +1,370 @@
|
||||
import { Router, Request, Response } from 'express';
|
||||
import multer from 'multer';
|
||||
import prisma from '../config/database';
|
||||
import { StorageService } from '../services/storage.service';
|
||||
|
||||
const router = Router();
|
||||
const upload = multer({
|
||||
storage: multer.memoryStorage(),
|
||||
limits: {
|
||||
fileSize: 20 * 1024 * 1024, // 20MB limit
|
||||
},
|
||||
fileFilter: (req, file, cb) => {
|
||||
if (!file.originalname.match(/\.(jpg|jpeg|png|gif|webp)$/i)) {
|
||||
return cb(new Error('Only image files are allowed!'));
|
||||
}
|
||||
cb(null, true);
|
||||
},
|
||||
});
|
||||
const storageService = StorageService.getInstance();
|
||||
|
||||
// Helper function to apply cookbook filters to existing recipes
|
||||
async function applyFiltersToExistingRecipes(cookbookId: string) {
|
||||
try {
|
||||
const cookbook = await prisma.cookbook.findUnique({
|
||||
where: { id: cookbookId }
|
||||
});
|
||||
|
||||
if (!cookbook) return;
|
||||
|
||||
// If no filters are set, nothing to do
|
||||
if (cookbook.autoFilterCategories.length === 0 && cookbook.autoFilterTags.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Build query to find matching recipes
|
||||
const whereConditions: any[] = [];
|
||||
|
||||
if (cookbook.autoFilterCategories.length > 0) {
|
||||
whereConditions.push({
|
||||
categories: {
|
||||
hasSome: cookbook.autoFilterCategories
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (cookbook.autoFilterTags.length > 0) {
|
||||
whereConditions.push({
|
||||
tags: {
|
||||
some: {
|
||||
tag: {
|
||||
name: { in: cookbook.autoFilterTags }
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Find matching recipes
|
||||
const matchingRecipes = await prisma.recipe.findMany({
|
||||
where: {
|
||||
OR: whereConditions
|
||||
},
|
||||
select: { id: true }
|
||||
});
|
||||
|
||||
// Add each matching recipe to the cookbook
|
||||
for (const recipe of matchingRecipes) {
|
||||
try {
|
||||
await prisma.cookbookRecipe.create({
|
||||
data: {
|
||||
cookbookId: cookbookId,
|
||||
recipeId: recipe.id
|
||||
}
|
||||
});
|
||||
} catch (error: any) {
|
||||
// Ignore unique constraint violations (recipe already in cookbook)
|
||||
if (error.code !== 'P2002') {
|
||||
console.error(`Error adding recipe ${recipe.id} to cookbook:`, error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`Applied filters to cookbook ${cookbook.name}: added ${matchingRecipes.length} recipes`);
|
||||
} catch (error) {
|
||||
console.error('Error in applyFiltersToExistingRecipes:', error);
|
||||
}
|
||||
}
|
||||
|
||||
// Get all cookbooks with recipe count
|
||||
router.get('/', async (req: Request, res: Response) => {
|
||||
try {
|
||||
const cookbooks = await prisma.cookbook.findMany({
|
||||
include: {
|
||||
_count: {
|
||||
select: { recipes: true }
|
||||
}
|
||||
},
|
||||
orderBy: { updatedAt: 'desc' }
|
||||
});
|
||||
|
||||
const response = cookbooks.map(cookbook => ({
|
||||
id: cookbook.id,
|
||||
name: cookbook.name,
|
||||
description: cookbook.description,
|
||||
coverImageUrl: cookbook.coverImageUrl,
|
||||
autoFilterCategories: cookbook.autoFilterCategories,
|
||||
autoFilterTags: cookbook.autoFilterTags,
|
||||
recipeCount: cookbook._count.recipes,
|
||||
createdAt: cookbook.createdAt,
|
||||
updatedAt: cookbook.updatedAt
|
||||
}));
|
||||
|
||||
res.json({ data: response });
|
||||
} catch (error) {
|
||||
console.error('Error fetching cookbooks:', error);
|
||||
res.status(500).json({ error: 'Failed to fetch cookbooks' });
|
||||
}
|
||||
});
|
||||
|
||||
// Get a single cookbook with all its recipes
|
||||
router.get('/:id', async (req: Request, res: Response) => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
|
||||
const cookbook = await prisma.cookbook.findUnique({
|
||||
where: { id },
|
||||
include: {
|
||||
recipes: {
|
||||
include: {
|
||||
recipe: {
|
||||
include: {
|
||||
images: true,
|
||||
tags: {
|
||||
include: {
|
||||
tag: true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
orderBy: { addedAt: 'desc' }
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
if (!cookbook) {
|
||||
return res.status(404).json({ error: 'Cookbook not found' });
|
||||
}
|
||||
|
||||
const response = {
|
||||
id: cookbook.id,
|
||||
name: cookbook.name,
|
||||
description: cookbook.description,
|
||||
coverImageUrl: cookbook.coverImageUrl,
|
||||
autoFilterCategories: cookbook.autoFilterCategories,
|
||||
autoFilterTags: cookbook.autoFilterTags,
|
||||
createdAt: cookbook.createdAt,
|
||||
updatedAt: cookbook.updatedAt,
|
||||
recipes: cookbook.recipes.map(cr => ({
|
||||
...cr.recipe,
|
||||
tags: cr.recipe.tags.map(rt => rt.tag.name)
|
||||
}))
|
||||
};
|
||||
|
||||
res.json({ data: response });
|
||||
} catch (error) {
|
||||
console.error('Error fetching cookbook:', error);
|
||||
res.status(500).json({ error: 'Failed to fetch cookbook' });
|
||||
}
|
||||
});
|
||||
|
||||
// Create a new cookbook
|
||||
router.post('/', async (req: Request, res: Response) => {
|
||||
try {
|
||||
const { name, description, coverImageUrl, autoFilterCategories, autoFilterTags } = req.body;
|
||||
|
||||
if (!name) {
|
||||
return res.status(400).json({ error: 'Name is required' });
|
||||
}
|
||||
|
||||
const cookbook = await prisma.cookbook.create({
|
||||
data: {
|
||||
name,
|
||||
description,
|
||||
coverImageUrl,
|
||||
autoFilterCategories: autoFilterCategories || [],
|
||||
autoFilterTags: autoFilterTags || []
|
||||
}
|
||||
});
|
||||
|
||||
// Apply filters to existing recipes
|
||||
await applyFiltersToExistingRecipes(cookbook.id);
|
||||
|
||||
res.status(201).json({ data: cookbook });
|
||||
} catch (error) {
|
||||
console.error('Error creating cookbook:', error);
|
||||
res.status(500).json({ error: 'Failed to create cookbook' });
|
||||
}
|
||||
});
|
||||
|
||||
// Update a cookbook
|
||||
router.put('/:id', async (req: Request, res: Response) => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
const { name, description, coverImageUrl, autoFilterCategories, autoFilterTags } = req.body;
|
||||
|
||||
const updateData: any = {};
|
||||
if (name !== undefined) updateData.name = name;
|
||||
if (description !== undefined) updateData.description = description;
|
||||
if (coverImageUrl !== undefined) updateData.coverImageUrl = coverImageUrl;
|
||||
if (autoFilterCategories !== undefined) updateData.autoFilterCategories = autoFilterCategories;
|
||||
if (autoFilterTags !== undefined) updateData.autoFilterTags = autoFilterTags;
|
||||
|
||||
const cookbook = await prisma.cookbook.update({
|
||||
where: { id },
|
||||
data: updateData
|
||||
});
|
||||
|
||||
// Apply filters to existing recipes if filters were updated
|
||||
if (autoFilterCategories !== undefined || autoFilterTags !== undefined) {
|
||||
await applyFiltersToExistingRecipes(id);
|
||||
}
|
||||
|
||||
res.json({ data: cookbook });
|
||||
} catch (error) {
|
||||
console.error('Error updating cookbook:', error);
|
||||
res.status(500).json({ error: 'Failed to update cookbook' });
|
||||
}
|
||||
});
|
||||
|
||||
// Delete a cookbook
|
||||
router.delete('/:id', async (req: Request, res: Response) => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
|
||||
await prisma.cookbook.delete({
|
||||
where: { id }
|
||||
});
|
||||
|
||||
res.json({ message: 'Cookbook deleted successfully' });
|
||||
} catch (error) {
|
||||
console.error('Error deleting cookbook:', error);
|
||||
res.status(500).json({ error: 'Failed to delete cookbook' });
|
||||
}
|
||||
});
|
||||
|
||||
// Add a recipe to a cookbook
|
||||
router.post('/:id/recipes/:recipeId', async (req: Request, res: Response) => {
|
||||
try {
|
||||
const { id, recipeId } = req.params;
|
||||
|
||||
// Check if recipe is already in cookbook
|
||||
const existing = await prisma.cookbookRecipe.findUnique({
|
||||
where: {
|
||||
cookbookId_recipeId: {
|
||||
cookbookId: id,
|
||||
recipeId
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
if (existing) {
|
||||
return res.status(400).json({ error: 'Recipe already in cookbook' });
|
||||
}
|
||||
|
||||
const cookbookRecipe = await prisma.cookbookRecipe.create({
|
||||
data: {
|
||||
cookbookId: id,
|
||||
recipeId
|
||||
}
|
||||
});
|
||||
|
||||
res.status(201).json({ data: cookbookRecipe });
|
||||
} catch (error) {
|
||||
console.error('Error adding recipe to cookbook:', error);
|
||||
res.status(500).json({ error: 'Failed to add recipe to cookbook' });
|
||||
}
|
||||
});
|
||||
|
||||
// Remove a recipe from a cookbook
|
||||
router.delete('/:id/recipes/:recipeId', async (req: Request, res: Response) => {
|
||||
try {
|
||||
const { id, recipeId } = req.params;
|
||||
|
||||
await prisma.cookbookRecipe.delete({
|
||||
where: {
|
||||
cookbookId_recipeId: {
|
||||
cookbookId: id,
|
||||
recipeId
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
res.json({ message: 'Recipe removed from cookbook' });
|
||||
} catch (error) {
|
||||
console.error('Error removing recipe from cookbook:', error);
|
||||
res.status(500).json({ error: 'Failed to remove recipe from cookbook' });
|
||||
}
|
||||
});
|
||||
|
||||
// Upload cookbook cover image
|
||||
router.post('/:id/image', upload.single('image'), async (req: Request, res: Response) => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
|
||||
if (!req.file) {
|
||||
return res.status(400).json({ error: 'No image provided' });
|
||||
}
|
||||
|
||||
// Delete old cover image if it exists
|
||||
const cookbook = await prisma.cookbook.findUnique({
|
||||
where: { id }
|
||||
});
|
||||
|
||||
if (cookbook?.coverImageUrl) {
|
||||
await storageService.deleteFile(cookbook.coverImageUrl);
|
||||
}
|
||||
|
||||
// Save new image
|
||||
const imageUrl = await storageService.saveFile(req.file, 'cookbooks');
|
||||
|
||||
// Update cookbook with new image URL
|
||||
const updated = await prisma.cookbook.update({
|
||||
where: { id },
|
||||
data: { coverImageUrl: imageUrl }
|
||||
});
|
||||
|
||||
res.json({ data: { url: imageUrl }, message: 'Image uploaded successfully' });
|
||||
} catch (error) {
|
||||
console.error('Error uploading cookbook image:', error);
|
||||
res.status(500).json({ error: 'Failed to upload image' });
|
||||
}
|
||||
});
|
||||
|
||||
// Download and save image from URL
|
||||
router.post('/:id/image-from-url', async (req: Request, res: Response) => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
const { url } = req.body;
|
||||
|
||||
if (!url) {
|
||||
return res.status(400).json({ error: 'URL is required' });
|
||||
}
|
||||
|
||||
// Delete old cover image if it exists
|
||||
const cookbook = await prisma.cookbook.findUnique({
|
||||
where: { id }
|
||||
});
|
||||
|
||||
if (cookbook?.coverImageUrl) {
|
||||
await storageService.deleteFile(cookbook.coverImageUrl);
|
||||
}
|
||||
|
||||
// Download and save image from URL
|
||||
const imageUrl = await storageService.downloadAndSaveImage(url, 'cookbooks');
|
||||
|
||||
// Update cookbook with new image URL
|
||||
await prisma.cookbook.update({
|
||||
where: { id },
|
||||
data: { coverImageUrl: imageUrl }
|
||||
});
|
||||
|
||||
res.json({ data: { url: imageUrl }, message: 'Image downloaded and saved successfully' });
|
||||
} catch (error) {
|
||||
console.error('Error downloading cookbook image:', error);
|
||||
res.status(500).json({ error: 'Failed to download image from URL' });
|
||||
}
|
||||
});
|
||||
|
||||
export default router;
|
||||
@@ -23,6 +23,82 @@ const upload = multer({
|
||||
const storageService = StorageService.getInstance();
|
||||
const scraperService = new ScraperService();
|
||||
|
||||
// Helper function to auto-add recipe to cookbooks based on their filters
|
||||
async function autoAddToCookbooks(recipeId: string) {
|
||||
try {
|
||||
// Get the recipe with its category and tags
|
||||
const recipe = await prisma.recipe.findUnique({
|
||||
where: { id: recipeId },
|
||||
include: {
|
||||
tags: {
|
||||
include: {
|
||||
tag: true
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
if (!recipe) return;
|
||||
|
||||
const recipeTags = recipe.tags.map(rt => rt.tag.name);
|
||||
const recipeCategories = recipe.categories || [];
|
||||
|
||||
// Get all cookbooks with auto-filters
|
||||
const cookbooks = await prisma.cookbook.findMany({
|
||||
where: {
|
||||
OR: [
|
||||
{ autoFilterCategories: { isEmpty: false } },
|
||||
{ autoFilterTags: { isEmpty: false } }
|
||||
]
|
||||
}
|
||||
});
|
||||
|
||||
// Check each cookbook to see if recipe matches
|
||||
for (const cookbook of cookbooks) {
|
||||
let shouldAdd = false;
|
||||
|
||||
// Check if any recipe category matches any of the cookbook's filter categories
|
||||
if (cookbook.autoFilterCategories.length > 0 && recipeCategories.length > 0) {
|
||||
const hasMatchingCategory = recipeCategories.some(cat =>
|
||||
cookbook.autoFilterCategories.includes(cat)
|
||||
);
|
||||
if (hasMatchingCategory) {
|
||||
shouldAdd = true;
|
||||
}
|
||||
}
|
||||
|
||||
// Check if recipe has any of the cookbook's filter tags
|
||||
if (cookbook.autoFilterTags.length > 0 && recipeTags.length > 0) {
|
||||
const hasMatchingTag = cookbook.autoFilterTags.some(filterTag =>
|
||||
recipeTags.includes(filterTag)
|
||||
);
|
||||
if (hasMatchingTag) {
|
||||
shouldAdd = true;
|
||||
}
|
||||
}
|
||||
|
||||
// Add recipe to cookbook if it matches and isn't already added
|
||||
if (shouldAdd) {
|
||||
try {
|
||||
await prisma.cookbookRecipe.create({
|
||||
data: {
|
||||
cookbookId: cookbook.id,
|
||||
recipeId: recipeId
|
||||
}
|
||||
});
|
||||
} catch (error: any) {
|
||||
// Ignore unique constraint violations (recipe already in cookbook)
|
||||
if (error.code !== 'P2002') {
|
||||
console.error(`Error auto-adding recipe to cookbook ${cookbook.name}:`, error);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error in autoAddToCookbooks:', error);
|
||||
}
|
||||
}
|
||||
|
||||
// Get all recipes
|
||||
router.get('/', async (req, res) => {
|
||||
try {
|
||||
@@ -39,7 +115,11 @@ router.get('/', async (req, res) => {
|
||||
];
|
||||
}
|
||||
if (cuisine) where.cuisine = cuisine;
|
||||
if (category) where.category = category;
|
||||
if (category) {
|
||||
where.categories = {
|
||||
has: category as string
|
||||
};
|
||||
}
|
||||
|
||||
const [recipes, total] = await Promise.all([
|
||||
prisma.recipe.findMany({
|
||||
@@ -250,6 +330,9 @@ router.post('/', async (req, res) => {
|
||||
// Automatically generate ingredient-instruction mappings
|
||||
await autoMapIngredients(recipe.id);
|
||||
|
||||
// Auto-add to cookbooks based on filters
|
||||
await autoAddToCookbooks(recipe.id);
|
||||
|
||||
res.status(201).json({ data: recipe });
|
||||
} catch (error) {
|
||||
console.error('Error creating recipe:', error);
|
||||
@@ -343,6 +426,9 @@ router.put('/:id', async (req, res) => {
|
||||
// Regenerate ingredient-instruction mappings
|
||||
await autoMapIngredients(req.params.id);
|
||||
|
||||
// Auto-add to cookbooks based on filters
|
||||
await autoAddToCookbooks(req.params.id);
|
||||
|
||||
res.json({ data: recipe });
|
||||
} catch (error) {
|
||||
console.error('Error updating recipe:', error);
|
||||
|
||||
182
packages/api/src/routes/tags.routes.test.ts
Normal file
182
packages/api/src/routes/tags.routes.test.ts
Normal file
@@ -0,0 +1,182 @@
|
||||
import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest';
|
||||
import express from 'express';
|
||||
import request from 'supertest';
|
||||
import tagsRouter from './tags.routes';
|
||||
|
||||
// Mock the database
|
||||
vi.mock('../config/database', () => ({
|
||||
default: {
|
||||
tag: {
|
||||
findMany: vi.fn(),
|
||||
findFirst: vi.fn(),
|
||||
findUnique: vi.fn(),
|
||||
create: vi.fn(),
|
||||
delete: vi.fn(),
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
describe('Tags Routes - Unit Tests', () => {
|
||||
let app: express.Application;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
app = express();
|
||||
app.use(express.json());
|
||||
app.use('/tags', tagsRouter);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('GET /tags', () => {
|
||||
it('should return all tags with recipe counts', async () => {
|
||||
const mockTags = [
|
||||
{
|
||||
id: 't1',
|
||||
name: 'Italian',
|
||||
_count: { recipes: 12 },
|
||||
},
|
||||
{
|
||||
id: 't2',
|
||||
name: 'Quick',
|
||||
_count: { recipes: 8 },
|
||||
},
|
||||
{
|
||||
id: 't3',
|
||||
name: 'Vegetarian',
|
||||
_count: { recipes: 15 },
|
||||
},
|
||||
];
|
||||
|
||||
const prisma = await import('../config/database');
|
||||
vi.mocked(prisma.default.tag.findMany).mockResolvedValue(mockTags as any);
|
||||
|
||||
const response = await request(app).get('/tags').expect(200);
|
||||
|
||||
expect(response.body.data).toHaveLength(3);
|
||||
expect(response.body.data[0]).toEqual({
|
||||
id: 't1',
|
||||
name: 'Italian',
|
||||
recipeCount: 12,
|
||||
});
|
||||
expect(prisma.default.tag.findMany).toHaveBeenCalledWith({
|
||||
include: {
|
||||
_count: {
|
||||
select: { recipes: true },
|
||||
},
|
||||
},
|
||||
orderBy: { name: 'asc' },
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle errors gracefully', async () => {
|
||||
const prisma = await import('../config/database');
|
||||
vi.mocked(prisma.default.tag.findMany).mockRejectedValue(new Error('Database error'));
|
||||
|
||||
const response = await request(app).get('/tags').expect(500);
|
||||
|
||||
expect(response.body.error).toBe('Failed to fetch tags');
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST /tags', () => {
|
||||
it('should create a new tag if it does not exist', async () => {
|
||||
const newTag = { name: 'Dessert' };
|
||||
const createdTag = {
|
||||
id: 't-new',
|
||||
name: 'Dessert',
|
||||
};
|
||||
|
||||
const prisma = await import('../config/database');
|
||||
vi.mocked(prisma.default.tag.findFirst).mockResolvedValue(null);
|
||||
vi.mocked(prisma.default.tag.create).mockResolvedValue(createdTag as any);
|
||||
|
||||
const response = await request(app).post('/tags').send(newTag).expect(200);
|
||||
|
||||
expect(response.body.data.id).toBe('t-new');
|
||||
expect(response.body.data.name).toBe('Dessert');
|
||||
expect(prisma.default.tag.findFirst).toHaveBeenCalledWith({
|
||||
where: {
|
||||
name: {
|
||||
equals: 'Dessert',
|
||||
mode: 'insensitive',
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(prisma.default.tag.create).toHaveBeenCalledWith({
|
||||
data: { name: 'Dessert' },
|
||||
});
|
||||
});
|
||||
|
||||
it('should return existing tag if it already exists (case-insensitive)', async () => {
|
||||
const newTag = { name: 'dessert' };
|
||||
const existingTag = {
|
||||
id: 't-existing',
|
||||
name: 'Dessert',
|
||||
};
|
||||
|
||||
const prisma = await import('../config/database');
|
||||
vi.mocked(prisma.default.tag.findFirst).mockResolvedValue(existingTag as any);
|
||||
|
||||
const response = await request(app).post('/tags').send(newTag).expect(200);
|
||||
|
||||
expect(response.body.data.id).toBe('t-existing');
|
||||
expect(response.body.data.name).toBe('Dessert');
|
||||
expect(prisma.default.tag.create).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should return 400 if tag name is missing', async () => {
|
||||
const response = await request(app).post('/tags').send({}).expect(400);
|
||||
|
||||
expect(response.body.error).toBe('Tag name is required');
|
||||
});
|
||||
});
|
||||
|
||||
describe('DELETE /tags/:id', () => {
|
||||
it('should delete a tag that is not used by any recipes', async () => {
|
||||
const mockTag = {
|
||||
id: 't1',
|
||||
name: 'Unused',
|
||||
_count: { recipes: 0 },
|
||||
};
|
||||
|
||||
const prisma = await import('../config/database');
|
||||
vi.mocked(prisma.default.tag.findUnique).mockResolvedValue(mockTag as any);
|
||||
vi.mocked(prisma.default.tag.delete).mockResolvedValue({} as any);
|
||||
|
||||
const response = await request(app).delete('/tags/t1').expect(200);
|
||||
|
||||
expect(response.body.message).toBe('Tag deleted successfully');
|
||||
expect(prisma.default.tag.delete).toHaveBeenCalledWith({
|
||||
where: { id: 't1' },
|
||||
});
|
||||
});
|
||||
|
||||
it('should return 404 if tag does not exist', async () => {
|
||||
const prisma = await import('../config/database');
|
||||
vi.mocked(prisma.default.tag.findUnique).mockResolvedValue(null);
|
||||
|
||||
const response = await request(app).delete('/tags/nonexistent').expect(404);
|
||||
|
||||
expect(response.body.error).toBe('Tag not found');
|
||||
});
|
||||
|
||||
it('should return 400 if tag is used by recipes', async () => {
|
||||
const mockTag = {
|
||||
id: 't1',
|
||||
name: 'Italian',
|
||||
_count: { recipes: 5 },
|
||||
};
|
||||
|
||||
const prisma = await import('../config/database');
|
||||
vi.mocked(prisma.default.tag.findUnique).mockResolvedValue(mockTag as any);
|
||||
|
||||
const response = await request(app).delete('/tags/t1').expect(400);
|
||||
|
||||
expect(response.body.error).toBe('Cannot delete tag "Italian" as it is used by 5 recipe(s)');
|
||||
expect(prisma.default.tag.delete).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
100
packages/api/src/routes/tags.routes.ts
Normal file
100
packages/api/src/routes/tags.routes.ts
Normal file
@@ -0,0 +1,100 @@
|
||||
import { Router, Request, Response } from 'express';
|
||||
import prisma from '../config/database';
|
||||
|
||||
const router = Router();
|
||||
|
||||
// Get all tags
|
||||
router.get('/', async (req: Request, res: Response) => {
|
||||
try {
|
||||
const tags = await prisma.tag.findMany({
|
||||
include: {
|
||||
_count: {
|
||||
select: { recipes: true }
|
||||
}
|
||||
},
|
||||
orderBy: { name: 'asc' }
|
||||
});
|
||||
|
||||
const response = tags.map(tag => ({
|
||||
id: tag.id,
|
||||
name: tag.name,
|
||||
recipeCount: tag._count.recipes
|
||||
}));
|
||||
|
||||
res.json({ data: response });
|
||||
} catch (error) {
|
||||
console.error('Error fetching tags:', error);
|
||||
res.status(500).json({ error: 'Failed to fetch tags' });
|
||||
}
|
||||
});
|
||||
|
||||
// Get or create a tag by name
|
||||
router.post('/', async (req: Request, res: Response) => {
|
||||
try {
|
||||
const { name } = req.body;
|
||||
|
||||
if (!name) {
|
||||
return res.status(400).json({ error: 'Tag name is required' });
|
||||
}
|
||||
|
||||
// Try to find existing tag (case-insensitive)
|
||||
let tag = await prisma.tag.findFirst({
|
||||
where: {
|
||||
name: {
|
||||
equals: name,
|
||||
mode: 'insensitive'
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// If not found, create it
|
||||
if (!tag) {
|
||||
tag = await prisma.tag.create({
|
||||
data: { name }
|
||||
});
|
||||
}
|
||||
|
||||
res.json({ data: tag });
|
||||
} catch (error) {
|
||||
console.error('Error creating/finding tag:', error);
|
||||
res.status(500).json({ error: 'Failed to create/find tag' });
|
||||
}
|
||||
});
|
||||
|
||||
// Delete a tag (only if not used by any recipe)
|
||||
router.delete('/:id', async (req: Request, res: Response) => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
|
||||
// Check if tag is used by any recipes
|
||||
const tag = await prisma.tag.findUnique({
|
||||
where: { id },
|
||||
include: {
|
||||
_count: {
|
||||
select: { recipes: true }
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
if (!tag) {
|
||||
return res.status(404).json({ error: 'Tag not found' });
|
||||
}
|
||||
|
||||
if (tag._count.recipes > 0) {
|
||||
return res.status(400).json({
|
||||
error: `Cannot delete tag "${tag.name}" as it is used by ${tag._count.recipes} recipe(s)`
|
||||
});
|
||||
}
|
||||
|
||||
await prisma.tag.delete({
|
||||
where: { id }
|
||||
});
|
||||
|
||||
res.json({ message: 'Tag deleted successfully' });
|
||||
} catch (error) {
|
||||
console.error('Error deleting tag:', error);
|
||||
res.status(500).json({ error: 'Failed to delete tag' });
|
||||
}
|
||||
});
|
||||
|
||||
export default router;
|
||||
@@ -1,5 +1,6 @@
|
||||
import fs from 'fs/promises';
|
||||
import path from 'path';
|
||||
import axios from 'axios';
|
||||
import { storageConfig } from '../config/storage';
|
||||
|
||||
export class StorageService {
|
||||
@@ -63,4 +64,55 @@ export class StorageService {
|
||||
throw new Error('S3 storage not yet implemented');
|
||||
}
|
||||
}
|
||||
|
||||
async downloadAndSaveImage(url: string, folder: string = 'images'): Promise<string> {
|
||||
try {
|
||||
// Download the image
|
||||
const response = await axios.get(url, {
|
||||
responseType: 'arraybuffer',
|
||||
timeout: 30000,
|
||||
maxContentLength: 20 * 1024 * 1024, // 20MB limit
|
||||
});
|
||||
|
||||
// Get the file extension from content-type or URL
|
||||
let extension = 'jpg';
|
||||
const contentType = response.headers['content-type'];
|
||||
if (contentType) {
|
||||
const match = contentType.match(/image\/(jpeg|jpg|png|gif|webp)/);
|
||||
if (match) {
|
||||
extension = match[1] === 'jpeg' ? 'jpg' : match[1];
|
||||
}
|
||||
} else {
|
||||
// Try to get extension from URL
|
||||
const urlMatch = url.match(/\.(jpg|jpeg|png|gif|webp)(\?|$)/i);
|
||||
if (urlMatch) {
|
||||
extension = urlMatch[1].toLowerCase();
|
||||
}
|
||||
}
|
||||
|
||||
// Create a file object similar to multer's format
|
||||
const buffer = Buffer.from(response.data);
|
||||
const filename = `${Date.now()}.${extension}`;
|
||||
|
||||
// Save using local storage
|
||||
if (storageConfig.type === 'local') {
|
||||
const basePath = storageConfig.localPath || './uploads';
|
||||
const folderPath = path.join(basePath, folder);
|
||||
await fs.mkdir(folderPath, { recursive: true });
|
||||
|
||||
const filePath = path.join(folderPath, filename);
|
||||
await fs.writeFile(filePath, buffer);
|
||||
|
||||
return `/uploads/${folder}/${filename}`;
|
||||
} else if (storageConfig.type === 's3') {
|
||||
// TODO: Implement S3 upload
|
||||
throw new Error('S3 storage not yet implemented');
|
||||
}
|
||||
|
||||
throw new Error('Invalid storage type');
|
||||
} catch (error) {
|
||||
console.error('Error downloading image from URL:', error);
|
||||
throw new Error('Failed to download image from URL');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user