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