- Fix auth anti-patterns (JWT, OAuth, logout, middleware, url.origin) - Extend DB schema with tier, npcs, counters, translations, notes, initiative - Expand Open5e TypeScript interfaces (Monster, Spell) to full V2 - Build OpenRouter API client for FREE tier - Build Kimi API client for PRO tier - Add comprehensive tests for all modules
296 lines
8.5 KiB
TypeScript
296 lines
8.5 KiB
TypeScript
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
|
import { generateNPC, KimiClientError } from "./kimi";
|
|
import type { NPCParams, Open5eMonster } from "./types";
|
|
|
|
const validNPCResponse = {
|
|
name: "Tharok Stonehelm",
|
|
race: "dwarf",
|
|
role: "fighter",
|
|
level: 3,
|
|
hp: 45,
|
|
ac: 18,
|
|
cr: "2",
|
|
speed: "25 ft.",
|
|
appearance: "A stout dwarf with a braided red beard and burn scars across his forearms.",
|
|
trait: "Never breaks a promise, no matter the cost.",
|
|
motivation: "Seeking to reclaim his ancestral forge from a fire giant.",
|
|
secret: "He forged the weapon that killed his own brother by accident.",
|
|
history: "Once a royal smith, exiled after a catastrophic forging accident.",
|
|
};
|
|
|
|
function mockFetchResponse(body: unknown, status = 200) {
|
|
return Promise.resolve({
|
|
ok: status >= 200 && status < 300,
|
|
status,
|
|
statusText: status === 429 ? "Too Many Requests" : "Error",
|
|
text: () => Promise.resolve(JSON.stringify(body)),
|
|
json: () => Promise.resolve(body),
|
|
} as Response);
|
|
}
|
|
|
|
function mockFetchTextResponse(text: string, status = 200) {
|
|
return Promise.resolve({
|
|
ok: status >= 200 && status < 300,
|
|
status,
|
|
statusText: "OK",
|
|
text: () => Promise.resolve(text),
|
|
json: () => Promise.resolve(text).then((t) => JSON.parse(t)),
|
|
} as Response);
|
|
}
|
|
|
|
describe("generateNPC", () => {
|
|
const originalEnv = process.env;
|
|
|
|
beforeEach(() => {
|
|
vi.resetAllMocks();
|
|
process.env = { ...originalEnv, KIMI_API_KEY: "test-api-key" };
|
|
globalThis.fetch = vi.fn();
|
|
});
|
|
|
|
afterEach(() => {
|
|
process.env = originalEnv;
|
|
vi.restoreAllMocks();
|
|
});
|
|
|
|
it("generates an NPC successfully", async () => {
|
|
vi.mocked(globalThis.fetch).mockReturnValue(
|
|
mockFetchResponse({
|
|
choices: [
|
|
{
|
|
message: {
|
|
content: JSON.stringify(validNPCResponse),
|
|
},
|
|
},
|
|
],
|
|
})
|
|
);
|
|
|
|
const params: NPCParams = {
|
|
race: "dwarf",
|
|
role: "fighter",
|
|
level: 3,
|
|
tone: "heroic",
|
|
};
|
|
|
|
const result = await generateNPC(params);
|
|
|
|
expect(result).toEqual(validNPCResponse);
|
|
expect(globalThis.fetch).toHaveBeenCalledTimes(1);
|
|
|
|
const callArgs = vi.mocked(globalThis.fetch).mock.calls[0];
|
|
const requestInit = callArgs[1] as RequestInit;
|
|
const body = JSON.parse(requestInit.body as string);
|
|
|
|
expect(body.model).toBe("moonshot-v1-8k");
|
|
expect(body.messages[0].role).toBe("system");
|
|
expect(body.messages[1].role).toBe("user");
|
|
expect(body.response_format.type).toBe("json_schema");
|
|
expect(body.temperature).toBe(0.7);
|
|
expect(requestInit.headers).toMatchObject({
|
|
"Content-Type": "application/json",
|
|
Authorization: "Bearer test-api-key",
|
|
});
|
|
});
|
|
|
|
it("includes Open5e reference in the prompt when provided", async () => {
|
|
vi.mocked(globalThis.fetch).mockReturnValue(
|
|
mockFetchResponse({
|
|
choices: [
|
|
{
|
|
message: {
|
|
content: JSON.stringify(validNPCResponse),
|
|
},
|
|
},
|
|
],
|
|
})
|
|
);
|
|
|
|
const reference: Open5eMonster = {
|
|
name: "Dwarf Warrior",
|
|
size: "Medium",
|
|
type: "humanoid",
|
|
armor_class: 16,
|
|
hit_points: 30,
|
|
challenge_rating_decimal: 1,
|
|
speed: { walk: "25 ft." },
|
|
actions: [{ name: "War Pick", desc: "Melee Weapon Attack: +4 to hit" }],
|
|
};
|
|
|
|
const params: NPCParams = {
|
|
race: "dwarf",
|
|
role: "fighter",
|
|
level: 3,
|
|
tone: "heroic",
|
|
};
|
|
|
|
await generateNPC(params, reference);
|
|
|
|
const callArgs = vi.mocked(globalThis.fetch).mock.calls[0];
|
|
const body = JSON.parse((callArgs[1] as RequestInit).body as string);
|
|
const userContent = body.messages[1].content as string;
|
|
|
|
expect(userContent).toContain("Dwarf Warrior");
|
|
expect(userContent).toContain("AC: 16");
|
|
expect(userContent).toContain("HP: 30");
|
|
expect(userContent).toContain("War Pick");
|
|
});
|
|
|
|
it("throws KimiClientError when KIMI_API_KEY is missing", async () => {
|
|
delete process.env.KIMI_API_KEY;
|
|
|
|
await expect(
|
|
generateNPC({ race: "elf", role: "wizard", level: 5, tone: "dark" })
|
|
).rejects.toThrow(KimiClientError);
|
|
|
|
await expect(
|
|
generateNPC({ race: "elf", role: "wizard", level: 5, tone: "dark" })
|
|
).rejects.toThrow("KIMI_API_KEY is not configured");
|
|
|
|
expect(globalThis.fetch).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("throws KimiClientError on API error (500)", async () => {
|
|
vi.mocked(globalThis.fetch).mockReturnValue(
|
|
mockFetchResponse({ error: { message: "Internal server error" } }, 500)
|
|
);
|
|
|
|
await expect(
|
|
generateNPC({ race: "elf", role: "wizard", level: 5, tone: "dark" })
|
|
).rejects.toThrow(KimiClientError);
|
|
|
|
await expect(
|
|
generateNPC({ race: "elf", role: "wizard", level: 5, tone: "dark" })
|
|
).rejects.toThrow("Kimi API error: 500 Error");
|
|
});
|
|
|
|
it("throws KimiClientError on rate limit (429)", async () => {
|
|
vi.mocked(globalThis.fetch).mockReturnValue(
|
|
mockFetchResponse({ error: { message: "Rate limit exceeded" } }, 429)
|
|
);
|
|
|
|
await expect(
|
|
generateNPC({ race: "elf", role: "wizard", level: 5, tone: "dark" })
|
|
).rejects.toThrow(KimiClientError);
|
|
|
|
await expect(
|
|
generateNPC({ race: "elf", role: "wizard", level: 5, tone: "dark" })
|
|
).rejects.toThrow("Kimi API rate limit exceeded");
|
|
});
|
|
|
|
it("throws KimiClientError when API returns invalid JSON", async () => {
|
|
vi.mocked(globalThis.fetch).mockReturnValue(
|
|
mockFetchTextResponse("This is not json")
|
|
);
|
|
|
|
await expect(
|
|
generateNPC({ race: "elf", role: "wizard", level: 5, tone: "dark" })
|
|
).rejects.toThrow("Kimi API returned invalid JSON");
|
|
});
|
|
|
|
it("throws KimiClientError when response fails zod validation", async () => {
|
|
const invalidResponse = { ...validNPCResponse, name: 123 };
|
|
|
|
vi.mocked(globalThis.fetch).mockReturnValue(
|
|
mockFetchResponse({
|
|
choices: [
|
|
{
|
|
message: {
|
|
content: JSON.stringify(invalidResponse),
|
|
},
|
|
},
|
|
],
|
|
})
|
|
);
|
|
|
|
await expect(
|
|
generateNPC({ race: "elf", role: "wizard", level: 5, tone: "dark" })
|
|
).rejects.toThrow(KimiClientError);
|
|
|
|
await expect(
|
|
generateNPC({ race: "elf", role: "wizard", level: 5, tone: "dark" })
|
|
).rejects.toThrow("validation failed");
|
|
});
|
|
|
|
it("throws KimiClientError when API returns empty content", async () => {
|
|
vi.mocked(globalThis.fetch).mockReturnValue(
|
|
mockFetchResponse({
|
|
choices: [
|
|
{
|
|
message: {
|
|
content: null,
|
|
},
|
|
},
|
|
],
|
|
})
|
|
);
|
|
|
|
await expect(
|
|
generateNPC({ race: "elf", role: "wizard", level: 5, tone: "dark" })
|
|
).rejects.toThrow("Kimi API returned empty content");
|
|
});
|
|
|
|
it("throws KimiClientError on request timeout", async () => {
|
|
vi.mocked(globalThis.fetch).mockImplementation(
|
|
() =>
|
|
new Promise((_, reject) => {
|
|
setTimeout(() => reject(new Error("AbortError")), 50);
|
|
})
|
|
);
|
|
|
|
await expect(
|
|
generateNPC({ race: "elf", role: "wizard", level: 5, tone: "dark" })
|
|
).rejects.toThrow(KimiClientError);
|
|
});
|
|
|
|
it("throws KimiClientError when API returns error in response body", async () => {
|
|
vi.mocked(globalThis.fetch).mockReturnValue(
|
|
mockFetchResponse({
|
|
error: { message: "Invalid API key" },
|
|
})
|
|
);
|
|
|
|
await expect(
|
|
generateNPC({ race: "elf", role: "wizard", level: 5, tone: "dark" })
|
|
).rejects.toThrow("Invalid API key");
|
|
});
|
|
|
|
it("validates params with zod before making request", async () => {
|
|
await expect(
|
|
generateNPC({
|
|
race: "elf",
|
|
role: "wizard",
|
|
level: 25,
|
|
tone: "dark",
|
|
} as NPCParams)
|
|
).rejects.toThrow();
|
|
|
|
expect(globalThis.fetch).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("handles markdown-wrapped JSON gracefully", async () => {
|
|
vi.mocked(globalThis.fetch).mockReturnValue(
|
|
mockFetchTextResponse(
|
|
'```json\n' + JSON.stringify(validNPCResponse) + '\n```'
|
|
)
|
|
);
|
|
|
|
await expect(
|
|
generateNPC({ race: "elf", role: "wizard", level: 5, tone: "dark" })
|
|
).rejects.toThrow("Kimi API returned invalid JSON");
|
|
});
|
|
|
|
it("preserves statusCode on API errors", async () => {
|
|
vi.mocked(globalThis.fetch).mockReturnValue(
|
|
mockFetchResponse({ error: { message: "Bad request" } }, 400)
|
|
);
|
|
|
|
try {
|
|
await generateNPC({ race: "elf", role: "wizard", level: 5, tone: "dark" });
|
|
expect.fail("Should have thrown");
|
|
} catch (error) {
|
|
expect(error).toBeInstanceOf(KimiClientError);
|
|
expect((error as KimiClientError).statusCode).toBe(400);
|
|
}
|
|
});
|
|
});
|