Files
Randify.pro/src/lib/ai/kimi.test.ts
T
emil 25f43ec4e2 feat(dm-dashboard): Wave 1 — auth fixes, DB schema, Open5e interfaces, AI clients
- 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
2026-05-15 21:28:42 +03:00

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);
}
});
});