From b31b9fd26c80c9a7c3e52f1ef2d734b94a1beef3 Mon Sep 17 00:00:00 2001 From: Markus Graf Date: Sun, 29 Mar 2026 11:53:57 +0200 Subject: [PATCH] test(02-01): add failing tests for config loader and Gemini API client - Config loader: fetch, error handling, caching (6 tests) - Gemini text: response extraction, request body, retry, failure (4 tests) - Gemini image: blob extraction, reference images, responseModalities, failure (4 tests) - Rate limiting: under limit, at limit, increment, daily reset (4 tests) Co-Authored-By: Claude Opus 4.6 (1M context) --- src/api/config.test.ts | 86 +++++++++++++ src/api/gemini.test.ts | 270 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 356 insertions(+) create mode 100644 src/api/config.test.ts create mode 100644 src/api/gemini.test.ts diff --git a/src/api/config.test.ts b/src/api/config.test.ts new file mode 100644 index 0000000..b755cd3 --- /dev/null +++ b/src/api/config.test.ts @@ -0,0 +1,86 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { type GeminiConfig, loadConfig, resetConfigCache } from "./config"; + +describe("Config loader", () => { + beforeEach(() => { + resetConfigCache(); + vi.restoreAllMocks(); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it("loadConfig returns GeminiConfig with all required fields", async () => { + const mockConfig: GeminiConfig = { + geminiApiKey: "test-key-123", + geminiModel: "gemini-2.5-flash", + imageModel: "gemini-3.1-flash-preview-image", + }; + vi.spyOn(globalThis, "fetch").mockResolvedValueOnce( + new Response(JSON.stringify(mockConfig), { status: 200 }), + ); + + const result = await loadConfig(); + expect(result).toEqual(mockConfig); + expect(result?.geminiApiKey).toBe("test-key-123"); + expect(result?.geminiModel).toBe("gemini-2.5-flash"); + expect(result?.imageModel).toBe("gemini-3.1-flash-preview-image"); + }); + + it("loadConfig returns null when fetch fails (404)", async () => { + vi.spyOn(globalThis, "fetch").mockResolvedValueOnce( + new Response("Not Found", { status: 404 }), + ); + + const result = await loadConfig(); + expect(result).toBeNull(); + }); + + it("loadConfig returns null when JSON is malformed", async () => { + vi.spyOn(globalThis, "fetch").mockResolvedValueOnce( + new Response("not-json{{{", { status: 200 }), + ); + + const result = await loadConfig(); + expect(result).toBeNull(); + }); + + it("loadConfig returns null when required fields are missing", async () => { + vi.spyOn(globalThis, "fetch").mockResolvedValueOnce( + new Response(JSON.stringify({ geminiApiKey: "key" }), { status: 200 }), + ); + + const result = await loadConfig(); + expect(result).toBeNull(); + }); + + it("loadConfig caches result after first successful call", async () => { + const mockConfig: GeminiConfig = { + geminiApiKey: "test-key", + geminiModel: "gemini-2.5-flash", + imageModel: "gemini-3.1-flash-preview-image", + }; + const fetchSpy = vi + .spyOn(globalThis, "fetch") + .mockResolvedValueOnce( + new Response(JSON.stringify(mockConfig), { status: 200 }), + ); + + const first = await loadConfig(); + const second = await loadConfig(); + + expect(first).toEqual(mockConfig); + expect(second).toEqual(mockConfig); + expect(fetchSpy).toHaveBeenCalledTimes(1); + }); + + it("loadConfig returns null when fetch throws network error", async () => { + vi.spyOn(globalThis, "fetch").mockRejectedValueOnce( + new Error("Network error"), + ); + + const result = await loadConfig(); + expect(result).toBeNull(); + }); +}); diff --git a/src/api/gemini.test.ts b/src/api/gemini.test.ts new file mode 100644 index 0000000..48c85ae --- /dev/null +++ b/src/api/gemini.test.ts @@ -0,0 +1,270 @@ +import "fake-indexeddb/auto"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { openDB } from "../storage/db"; +import type { GeminiConfig } from "./config"; +import { + checkRateLimit, + generateImage, + generateText, + incrementApiCall, +} from "./gemini"; + +const TEST_CONFIG: GeminiConfig = { + geminiApiKey: "test-api-key", + geminiModel: "gemini-2.5-flash", + imageModel: "gemini-3.1-flash-preview-image", +}; + +let testCounter = 0; + +function makeGeminiTextResponse(text: string) { + return { + candidates: [ + { + content: { + parts: [{ text }], + }, + }, + ], + }; +} + +// Minimal 1x1 red PNG as base64 +const TINY_PNG_BASE64 = + "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8/5+hHgAHggJ/PchI7wAAAABJRU5ErkJggg=="; + +function makeGeminiImageResponse(base64: string) { + return { + candidates: [ + { + content: { + parts: [ + { + inlineData: { + mimeType: "image/png", + data: base64, + }, + }, + ], + }, + }, + ], + }; +} + +describe("generateText", () => { + beforeEach(() => { + vi.restoreAllMocks(); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it("returns extracted text from Gemini response", async () => { + vi.spyOn(globalThis, "fetch").mockResolvedValueOnce( + new Response(JSON.stringify(makeGeminiTextResponse("Hello World")), { + status: 200, + }), + ); + + const result = await generateText("prompt", "system prompt", TEST_CONFIG); + expect(result).toBe("Hello World"); + }); + + it("sends correct request body with model, contents and systemInstruction", async () => { + const fetchSpy = vi + .spyOn(globalThis, "fetch") + .mockResolvedValueOnce( + new Response( + JSON.stringify(makeGeminiTextResponse("response")), + { status: 200 }, + ), + ); + + await generateText("my prompt", "my system prompt", TEST_CONFIG); + + expect(fetchSpy).toHaveBeenCalledTimes(1); + const [url, options] = fetchSpy.mock.calls[0]; + expect(url).toContain("gemini-2.5-flash"); + expect(url).toContain("generateContent"); + expect(url).toContain("key=test-api-key"); + + const body = JSON.parse(options?.body as string); + expect(body.contents[0].parts[0].text).toBe("my prompt"); + expect(body.systemInstruction.parts[0].text).toBe("my system prompt"); + }); + + it("retries on 500 error up to 3 times with backoff", async () => { + const fetchSpy = vi + .spyOn(globalThis, "fetch") + .mockResolvedValueOnce(new Response("error", { status: 500 })) + .mockResolvedValueOnce(new Response("error", { status: 500 })) + .mockResolvedValueOnce( + new Response( + JSON.stringify(makeGeminiTextResponse("success after retries")), + { status: 200 }, + ), + ); + + vi.useFakeTimers(); + const promise = generateText("prompt", "system", TEST_CONFIG); + // Advance through retry delays + await vi.advanceTimersByTimeAsync(1000); + await vi.advanceTimersByTimeAsync(2000); + const result = await promise; + vi.useRealTimers(); + + expect(result).toBe("success after retries"); + expect(fetchSpy).toHaveBeenCalledTimes(3); + }); + + it("returns null after 3 failed attempts", async () => { + vi.spyOn(globalThis, "fetch") + .mockResolvedValueOnce(new Response("error", { status: 500 })) + .mockResolvedValueOnce(new Response("error", { status: 500 })) + .mockResolvedValueOnce(new Response("error", { status: 500 })); + + vi.useFakeTimers(); + const promise = generateText("prompt", "system", TEST_CONFIG); + await vi.advanceTimersByTimeAsync(1000); + await vi.advanceTimersByTimeAsync(2000); + await vi.advanceTimersByTimeAsync(4000); + const result = await promise; + vi.useRealTimers(); + + expect(result).toBeNull(); + }); +}); + +describe("generateImage", () => { + beforeEach(() => { + vi.restoreAllMocks(); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it("returns Blob from base64 image in Gemini response", async () => { + vi.spyOn(globalThis, "fetch").mockResolvedValueOnce( + new Response( + JSON.stringify(makeGeminiImageResponse(TINY_PNG_BASE64)), + { status: 200 }, + ), + ); + + const result = await generateImage("a forest", [], TEST_CONFIG); + expect(result).toBeInstanceOf(Blob); + expect(result?.type).toBe("image/png"); + }); + + it("sends reference images as inlineData parts", async () => { + const fetchSpy = vi + .spyOn(globalThis, "fetch") + .mockResolvedValueOnce( + new Response( + JSON.stringify(makeGeminiImageResponse(TINY_PNG_BASE64)), + { status: 200 }, + ), + ); + + // Create a small test blob + const refBlob = new Blob(["fake-image-data"], { type: "image/png" }); + await generateImage("a forest", [refBlob], TEST_CONFIG); + + const [url, options] = fetchSpy.mock.calls[0]; + expect(url).toContain("gemini-3.1-flash-preview-image"); + const body = JSON.parse(options?.body as string); + // First part should be inlineData (reference image), last part should be text prompt + const parts = body.contents[0].parts; + expect(parts[0].inlineData).toBeDefined(); + expect(parts[0].inlineData.mimeType).toBe("image/png"); + expect(parts[parts.length - 1].text).toBe("a forest"); + }); + + it("sets responseModalities to ['IMAGE'] in generationConfig", async () => { + const fetchSpy = vi + .spyOn(globalThis, "fetch") + .mockResolvedValueOnce( + new Response( + JSON.stringify(makeGeminiImageResponse(TINY_PNG_BASE64)), + { status: 200 }, + ), + ); + + await generateImage("a tree", [], TEST_CONFIG); + + const body = JSON.parse(fetchSpy.mock.calls[0][1]?.body as string); + expect(body.generationConfig.responseModalities).toEqual(["IMAGE"]); + }); + + it("returns null on failure", async () => { + vi.spyOn(globalThis, "fetch").mockResolvedValueOnce( + new Response("error", { status: 500 }), + ); + + const result = await generateImage("a tree", [], TEST_CONFIG); + expect(result).toBeNull(); + }); +}); + +describe("Rate limiting", () => { + let db: IDBDatabase; + + beforeEach(async () => { + vi.restoreAllMocks(); + testCounter++; + db = await openDB(`zauberwald-rate-test-${testCounter}`); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it("checkRateLimit returns true when under limit (0 calls today)", async () => { + const canCallText = await checkRateLimit(db, "text"); + const canCallImage = await checkRateLimit(db, "image"); + expect(canCallText).toBe(true); + expect(canCallImage).toBe(true); + }); + + it("checkRateLimit returns false when at limit", async () => { + await incrementApiCall(db, "text"); + await incrementApiCall(db, "image"); + + const canCallText = await checkRateLimit(db, "text"); + const canCallImage = await checkRateLimit(db, "image"); + expect(canCallText).toBe(false); + expect(canCallImage).toBe(false); + }); + + it("incrementApiCall increments correct counter", async () => { + await incrementApiCall(db, "text"); + + const canCallText = await checkRateLimit(db, "text"); + const canCallImage = await checkRateLimit(db, "image"); + expect(canCallText).toBe(false); + expect(canCallImage).toBe(true); + }); + + it("rate limit resets when lastApiCallDate differs from today", async () => { + // Increment to hit limit + await incrementApiCall(db, "text"); + await incrementApiCall(db, "image"); + + // Manually set lastApiCallDate to yesterday + const { getSettings, saveSettings } = await import("../storage/db"); + const settings = await getSettings(db); + if (settings) { + settings.lastApiCallDate = "2020-01-01"; + await saveSettings(db, settings); + } + + // Should be reset now + const canCallText = await checkRateLimit(db, "text"); + const canCallImage = await checkRateLimit(db, "image"); + expect(canCallText).toBe(true); + expect(canCallImage).toBe(true); + }); +});