import { describe, it, expect, vi, beforeEach } from 'vitest'; import sharp from 'sharp'; const { mockExtract } = vi.hoisted(() => ({ mockExtract: vi.fn() })); vi.mock('$lib/server/ai/gemini-client', () => ({ extractRecipeFromImage: mockExtract, GeminiError: class GeminiError extends Error { constructor( public readonly code: string, message: string ) { super(message); this.name = 'GeminiError'; } } })); import { POST } from '../../src/routes/api/recipes/extract-from-photo/+server'; import { GeminiError } from '$lib/server/ai/gemini-client'; async function makeJpeg(): Promise { return sharp({ create: { width: 100, height: 100, channels: 3, background: '#888' } }) .jpeg() .toBuffer(); } function mkEvent(body: FormData, ip = '1.2.3.4') { return { request: new Request('http://test/api/recipes/extract-from-photo', { method: 'POST', body }), getClientAddress: () => ip }; } const validAiResponse = { title: 'Testrezept', servings_default: 4, servings_unit: 'Portionen', prep_time_min: 10, cook_time_min: 20, total_time_min: null, ingredients: [{ quantity: 1, unit: null, name: 'Apfel', note: null }], steps: [{ text: 'Apfel schälen.' }] }; beforeEach(() => { mockExtract.mockReset(); process.env.GEMINI_API_KEY = 'test-key'; }); describe('POST /api/recipes/extract-from-photo', () => { it('happy path: 200 with recipe shape', async () => { mockExtract.mockResolvedValueOnce(validAiResponse); const fd = new FormData(); fd.append('photo', new Blob([new Uint8Array(await makeJpeg())], { type: 'image/jpeg' }), 'x.jpg'); // eslint-disable-next-line @typescript-eslint/no-explicit-any const res = await POST(mkEvent(fd) as any); expect(res.status).toBe(200); const body = await res.json(); expect(body.recipe.title).toBe('Testrezept'); expect(typeof body.recipe.description).toBe('string'); expect(body.recipe.description.length).toBeGreaterThan(0); expect(body.recipe.image_path).toBeNull(); expect(body.recipe.ingredients[0].raw_text).toContain('Apfel'); expect(body.recipe.id).toBeNull(); }); it('413 when file exceeds 20 MB', async () => { const big = Buffer.alloc(21 * 1024 * 1024); const fd = new FormData(); fd.append('photo', new Blob([new Uint8Array(big)], { type: 'image/jpeg' })); // eslint-disable-next-line @typescript-eslint/no-explicit-any const res = await POST(mkEvent(fd, '1.1.1.1') as any); expect(res.status).toBe(413); expect((await res.json()).code).toBe('PAYLOAD_TOO_LARGE'); }); it('415 when content-type not in whitelist', async () => { const fd = new FormData(); fd.append('photo', new Blob([new Uint8Array(Buffer.from('hi'))], { type: 'text/plain' })); // eslint-disable-next-line @typescript-eslint/no-explicit-any const res = await POST(mkEvent(fd, '2.2.2.2') as any); expect(res.status).toBe(415); expect((await res.json()).code).toBe('UNSUPPORTED_MEDIA_TYPE'); }); it('400 when no photo field', async () => { const fd = new FormData(); // eslint-disable-next-line @typescript-eslint/no-explicit-any const res = await POST(mkEvent(fd, '3.3.3.3') as any); expect(res.status).toBe(400); }); it('422 NO_RECIPE_IN_IMAGE when 0 ingredients AND 0 steps', async () => { mockExtract.mockResolvedValueOnce({ ...validAiResponse, ingredients: [], steps: [] }); const fd = new FormData(); fd.append('photo', new Blob([new Uint8Array(await makeJpeg())], { type: 'image/jpeg' })); // eslint-disable-next-line @typescript-eslint/no-explicit-any const res = await POST(mkEvent(fd, '4.4.4.4') as any); expect(res.status).toBe(422); expect((await res.json()).code).toBe('NO_RECIPE_IN_IMAGE'); }); it('503 AI_NOT_CONFIGURED when GeminiError thrown', async () => { mockExtract.mockRejectedValueOnce( new GeminiError('AI_NOT_CONFIGURED', 'no key') ); const fd = new FormData(); fd.append('photo', new Blob([new Uint8Array(await makeJpeg())], { type: 'image/jpeg' })); // eslint-disable-next-line @typescript-eslint/no-explicit-any const res = await POST(mkEvent(fd, '5.5.5.5') as any); expect(res.status).toBe(503); expect((await res.json()).code).toBe('AI_NOT_CONFIGURED'); }); it('429 when rate limit exceeded for same IP', async () => { mockExtract.mockResolvedValue(validAiResponse); const ip = '9.9.9.9'; for (let i = 0; i < 10; i++) { const fd = new FormData(); fd.append('photo', new Blob([new Uint8Array(await makeJpeg())], { type: 'image/jpeg' })); // eslint-disable-next-line @typescript-eslint/no-explicit-any await POST(mkEvent(fd, ip) as any); } const last = new FormData(); last.append('photo', new Blob([new Uint8Array(await makeJpeg())], { type: 'image/jpeg' })); // eslint-disable-next-line @typescript-eslint/no-explicit-any const res = await POST(mkEvent(last, ip) as any); expect(res.status).toBe(429); }); });