129 lines
4.5 KiB
TypeScript
129 lines
4.5 KiB
TypeScript
|
|
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||
|
|
|
||
|
|
const mockGenerateContent = vi.fn();
|
||
|
|
|
||
|
|
vi.mock('@google/generative-ai', async () => {
|
||
|
|
const actual = await vi.importActual<typeof import('@google/generative-ai')>(
|
||
|
|
'@google/generative-ai'
|
||
|
|
);
|
||
|
|
return {
|
||
|
|
...actual,
|
||
|
|
GoogleGenerativeAI: vi.fn().mockImplementation(() => ({
|
||
|
|
getGenerativeModel: () => ({ generateContent: mockGenerateContent })
|
||
|
|
}))
|
||
|
|
};
|
||
|
|
});
|
||
|
|
|
||
|
|
// $env/dynamic/private is mocked via SvelteKit's own mock; here we lean on
|
||
|
|
// process.env (the client falls back to it).
|
||
|
|
import {
|
||
|
|
extractRecipeFromImage,
|
||
|
|
GeminiError
|
||
|
|
} from '../../src/lib/server/ai/gemini-client';
|
||
|
|
|
||
|
|
beforeEach(() => {
|
||
|
|
mockGenerateContent.mockReset();
|
||
|
|
process.env.GEMINI_API_KEY = 'test-key';
|
||
|
|
process.env.GEMINI_MODEL = 'gemini-2.5-flash';
|
||
|
|
process.env.GEMINI_TIMEOUT_MS = '5000';
|
||
|
|
});
|
||
|
|
|
||
|
|
const validResponse = {
|
||
|
|
title: 'Apfelkuchen',
|
||
|
|
servings_default: 8,
|
||
|
|
servings_unit: 'Stück',
|
||
|
|
prep_time_min: 20,
|
||
|
|
cook_time_min: 45,
|
||
|
|
total_time_min: null,
|
||
|
|
ingredients: [{ quantity: 500, unit: 'g', name: 'Mehl', note: null }],
|
||
|
|
steps: [{ text: 'Ofen auf 180 °C vorheizen.' }]
|
||
|
|
};
|
||
|
|
|
||
|
|
describe('extractRecipeFromImage', () => {
|
||
|
|
it('happy path: returns parsed recipe data', async () => {
|
||
|
|
mockGenerateContent.mockResolvedValueOnce({
|
||
|
|
response: { text: () => JSON.stringify(validResponse) }
|
||
|
|
});
|
||
|
|
const result = await extractRecipeFromImage(Buffer.from('fake'), 'image/jpeg');
|
||
|
|
expect(result.title).toBe('Apfelkuchen');
|
||
|
|
expect(result.ingredients).toHaveLength(1);
|
||
|
|
});
|
||
|
|
|
||
|
|
it('retries once on schema-invalid JSON, then succeeds', async () => {
|
||
|
|
mockGenerateContent
|
||
|
|
.mockResolvedValueOnce({ response: { text: () => '{"title": "no arrays"}' } })
|
||
|
|
.mockResolvedValueOnce({
|
||
|
|
response: { text: () => JSON.stringify(validResponse) }
|
||
|
|
});
|
||
|
|
const result = await extractRecipeFromImage(Buffer.from('fake'), 'image/jpeg');
|
||
|
|
expect(result.title).toBe('Apfelkuchen');
|
||
|
|
expect(mockGenerateContent).toHaveBeenCalledTimes(2);
|
||
|
|
});
|
||
|
|
|
||
|
|
it('throws AI_FAILED after schema-invalid + retry also invalid', async () => {
|
||
|
|
mockGenerateContent
|
||
|
|
.mockResolvedValueOnce({ response: { text: () => '{}' } })
|
||
|
|
.mockResolvedValueOnce({
|
||
|
|
response: { text: () => '{"title": "still bad"}' }
|
||
|
|
});
|
||
|
|
await expect(
|
||
|
|
extractRecipeFromImage(Buffer.from('fake'), 'image/jpeg')
|
||
|
|
).rejects.toMatchObject({ code: 'AI_FAILED' });
|
||
|
|
});
|
||
|
|
|
||
|
|
it('throws AI_RATE_LIMITED without retry on 429', async () => {
|
||
|
|
const err = new Error('429 Too Many Requests') as Error & { status?: number };
|
||
|
|
err.status = 429;
|
||
|
|
mockGenerateContent.mockRejectedValueOnce(err);
|
||
|
|
await expect(
|
||
|
|
extractRecipeFromImage(Buffer.from('fake'), 'image/jpeg')
|
||
|
|
).rejects.toMatchObject({ code: 'AI_RATE_LIMITED' });
|
||
|
|
expect(mockGenerateContent).toHaveBeenCalledTimes(1);
|
||
|
|
});
|
||
|
|
|
||
|
|
it('retries once on 5xx, then succeeds', async () => {
|
||
|
|
const err = new Error('500 Server Error') as Error & { status?: number };
|
||
|
|
err.status = 500;
|
||
|
|
mockGenerateContent
|
||
|
|
.mockRejectedValueOnce(err)
|
||
|
|
.mockResolvedValueOnce({
|
||
|
|
response: { text: () => JSON.stringify(validResponse) }
|
||
|
|
});
|
||
|
|
const result = await extractRecipeFromImage(Buffer.from('fake'), 'image/jpeg');
|
||
|
|
expect(result.title).toBe('Apfelkuchen');
|
||
|
|
expect(mockGenerateContent).toHaveBeenCalledTimes(2);
|
||
|
|
});
|
||
|
|
|
||
|
|
it('throws AI_FAILED after 5xx + retry also fails', async () => {
|
||
|
|
const err = new Error('500') as Error & { status?: number };
|
||
|
|
err.status = 500;
|
||
|
|
mockGenerateContent.mockRejectedValue(err);
|
||
|
|
await expect(
|
||
|
|
extractRecipeFromImage(Buffer.from('fake'), 'image/jpeg')
|
||
|
|
).rejects.toMatchObject({ code: 'AI_FAILED' });
|
||
|
|
expect(mockGenerateContent).toHaveBeenCalledTimes(2);
|
||
|
|
});
|
||
|
|
|
||
|
|
it('throws AI_TIMEOUT when generateContent never resolves', async () => {
|
||
|
|
process.env.GEMINI_TIMEOUT_MS = '50';
|
||
|
|
mockGenerateContent.mockImplementation(
|
||
|
|
() => new Promise(() => {}) // never resolves
|
||
|
|
);
|
||
|
|
await expect(
|
||
|
|
extractRecipeFromImage(Buffer.from('fake'), 'image/jpeg')
|
||
|
|
).rejects.toMatchObject({ code: 'AI_TIMEOUT' });
|
||
|
|
});
|
||
|
|
|
||
|
|
it('throws AI_NOT_CONFIGURED when GEMINI_API_KEY is empty', async () => {
|
||
|
|
process.env.GEMINI_API_KEY = '';
|
||
|
|
await expect(
|
||
|
|
extractRecipeFromImage(Buffer.from('fake'), 'image/jpeg')
|
||
|
|
).rejects.toMatchObject({ code: 'AI_NOT_CONFIGURED' });
|
||
|
|
});
|
||
|
|
|
||
|
|
it('GeminiError has a code property', () => {
|
||
|
|
const e = new GeminiError('AI_FAILED', 'x');
|
||
|
|
expect(e.code).toBe('AI_FAILED');
|
||
|
|
});
|
||
|
|
});
|