2026-04-21 10:42:46 +02:00
|
|
|
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<Buffer> {
|
|
|
|
|
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();
|
2026-04-21 10:44:48 +02:00
|
|
|
fd.append('photo', new Blob([new Uint8Array(await makeJpeg())], { type: 'image/jpeg' }), 'x.jpg');
|
2026-04-21 10:42:46 +02:00
|
|
|
// 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();
|
|
|
|
|
});
|
|
|
|
|
|
2026-04-21 13:31:34 +02:00
|
|
|
it('413 when file exceeds 20 MB', async () => {
|
|
|
|
|
const big = Buffer.alloc(21 * 1024 * 1024);
|
2026-04-21 10:42:46 +02:00
|
|
|
const fd = new FormData();
|
2026-04-21 10:44:48 +02:00
|
|
|
fd.append('photo', new Blob([new Uint8Array(big)], { type: 'image/jpeg' }));
|
2026-04-21 10:42:46 +02:00
|
|
|
// 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();
|
2026-04-21 10:44:48 +02:00
|
|
|
fd.append('photo', new Blob([new Uint8Array(Buffer.from('hi'))], { type: 'text/plain' }));
|
2026-04-21 10:42:46 +02:00
|
|
|
// 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();
|
2026-04-21 10:44:48 +02:00
|
|
|
fd.append('photo', new Blob([new Uint8Array(await makeJpeg())], { type: 'image/jpeg' }));
|
2026-04-21 10:42:46 +02:00
|
|
|
// 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();
|
2026-04-21 10:44:48 +02:00
|
|
|
fd.append('photo', new Blob([new Uint8Array(await makeJpeg())], { type: 'image/jpeg' }));
|
2026-04-21 10:42:46 +02:00
|
|
|
// 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();
|
2026-04-21 10:44:48 +02:00
|
|
|
fd.append('photo', new Blob([new Uint8Array(await makeJpeg())], { type: 'image/jpeg' }));
|
2026-04-21 10:42:46 +02:00
|
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
|
|
|
await POST(mkEvent(fd, ip) as any);
|
|
|
|
|
}
|
|
|
|
|
const last = new FormData();
|
2026-04-21 10:44:48 +02:00
|
|
|
last.append('photo', new Blob([new Uint8Array(await makeJpeg())], { type: 'image/jpeg' }));
|
2026-04-21 10:42:46 +02:00
|
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
|
|
|
const res = await POST(mkEvent(last, ip) as any);
|
|
|
|
|
expect(res.status).toBe(429);
|
|
|
|
|
});
|
|
|
|
|
});
|