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) <noreply@anthropic.com>
This commit is contained in:
2026-03-29 11:53:57 +02:00
co-authored by Claude Opus 4.6
parent 24bcbd1bba
commit b31b9fd26c
2 changed files with 356 additions and 0 deletions
+86
View File
@@ -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();
});
});
+270
View File
@@ -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);
});
});