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:
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user