import { describe, it, expect, vi, beforeEach } from 'vitest'; const mockGenerateContent = vi.fn(); vi.mock('@google/generative-ai', async () => { const actual = await vi.importActual( '@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'); }); });