${escapeHtml(spell.casting_time || "—")}
@@ -437,9 +491,13 @@ import DmButton from "./DmButton.astro";
}
if (state.selectedItem) {
+ const type = state.tab === "monsters" ? "creature" : "spell";
+ const cached = getCachedTranslation(state.selectedItem.key, type);
+ const isTranslated = cached !== null;
+ const item = mergeWithTranslation(state.selectedItem, cached);
detailContent.innerHTML = state.tab === "monsters"
- ? renderMonsterDetail(state.selectedItem)
- : renderSpellDetail(state.selectedItem);
+ ? renderMonsterDetail(item as Monster, isTranslated)
+ : renderSpellDetail(item as Spell, isTranslated);
detailOverlay.classList.remove("hidden");
detailClose.focus();
} else {
@@ -491,6 +549,55 @@ import DmButton from "./DmButton.astro";
manager.search();
});
+ detailContent.addEventListener("click", (e) => {
+ const btn = (e.target as HTMLElement).closest("[data-translate-btn]") as HTMLButtonElement | null;
+ if (!btn || btn.disabled) return;
+ const slug = btn.dataset.slug;
+ const type = btn.dataset.type as "creature" | "spell";
+ if (!slug || !type) return;
+ handleTranslate(slug, type, btn);
+ });
+
+ async function handleTranslate(slug: string, type: "creature" | "spell", btn: HTMLButtonElement) {
+ const originalText = btn.textContent?.trim() ?? "Перевести";
+ btn.textContent = "Перевод...";
+ btn.disabled = true;
+
+ try {
+ const res = await fetch(`/api/dm/translate?slug=${encodeURIComponent(slug)}&type=${type}`);
+ const data = await res.json().catch(() => ({}));
+
+ if (!res.ok || data.error) {
+ throw new Error(data.error || "Ошибка перевода");
+ }
+
+ const translated = data.translated as Monster | Spell;
+ setCachedTranslation(slug, type, translated);
+
+ btn.textContent = "Переведено";
+ btn.disabled = true;
+
+ // Re-render detail with translation
+ const state = manager.state;
+ if (state.selectedItem && state.selectedItem.key === slug) {
+ const cached = getCachedTranslation(slug, type);
+ const isTranslated = cached !== null;
+ const item = mergeWithTranslation(state.selectedItem, cached);
+ detailContent.innerHTML = state.tab === "monsters"
+ ? renderMonsterDetail(item as Monster, isTranslated)
+ : renderSpellDetail(item as Spell, isTranslated);
+ }
+ } catch {
+ btn.textContent = "Ошибка перевода";
+ btn.disabled = false;
+ setTimeout(() => {
+ if (btn.textContent === "Ошибка перевода") {
+ btn.textContent = originalText;
+ }
+ }, 3000);
+ }
+ }
+
detailClose.addEventListener("click", closeDetail);
detailBg.addEventListener("click", closeDetail);
detailOverlay.addEventListener("click", (e) => {
diff --git a/src/components/dm/QuotaBadge.astro b/src/components/dm/QuotaBadge.astro
new file mode 100644
index 0000000..5131acd
--- /dev/null
+++ b/src/components/dm/QuotaBadge.astro
@@ -0,0 +1,27 @@
+---
+export interface Props {
+ remaining: number;
+ limit: number;
+}
+
+const { remaining, limit } = Astro.props;
+
+const percentage = limit > 0 ? remaining / limit : 0;
+
+let textColor = "text-[var(--text-secondary)]";
+if (remaining === 0) {
+ textColor = "text-[var(--danger)]";
+} else if (percentage < 0.25) {
+ textColor = "text-[var(--accent)]";
+} else if (percentage > 0.5) {
+ textColor = "text-[var(--success)]";
+}
+---
+
+
+ {remaining} / {limit}
+
diff --git a/src/components/dm/TierBadge.astro b/src/components/dm/TierBadge.astro
new file mode 100644
index 0000000..deb074e
--- /dev/null
+++ b/src/components/dm/TierBadge.astro
@@ -0,0 +1,19 @@
+---
+import { dmTranslations as T } from "../../i18n/dm-translations";
+
+export interface Props {
+ tier: 'free' | 'pro';
+}
+
+const { tier } = Astro.props;
+---
+
+{tier === 'pro' ? (
+
+ {T.badgePro}
+
+) : (
+
+ {T.badgeFree}
+
+)}
diff --git a/src/i18n/dm-translations.ts b/src/i18n/dm-translations.ts
index 14ccc06..4c2dc64 100644
--- a/src/i18n/dm-translations.ts
+++ b/src/i18n/dm-translations.ts
@@ -51,6 +51,12 @@ export const dmTranslations = {
loading: "Загрузка...",
searching: "Поиск...",
+ // Translation
+ translate: "Перевести",
+ translating: "Перевод...",
+ translated: "Переведено",
+ translateError: "Ошибка перевода",
+
// Open5e tabs
monsters: "Монстры",
spells: "Заклинания",
diff --git a/src/lib/ai/openrouter.test.ts b/src/lib/ai/openrouter.test.ts
index 2e43f01..2be36f8 100644
--- a/src/lib/ai/openrouter.test.ts
+++ b/src/lib/ai/openrouter.test.ts
@@ -1,15 +1,13 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
-import {
- generateNPC,
- type NPCParams,
- type NPCResult,
-} from "./openrouter";
+import { generateNPC } from "./openrouter";
+import { type NPCParams, type NPCResult, type Open5eMonster } from "@/lib/ai/types";
describe("generateNPC", () => {
const validNPCResponse: NPCResult = {
name: "Gorath the Grim",
race: "Half-Orc",
role: "Mercenary Captain",
+ level: 5,
hp: 45,
ac: 16,
cr: "2",
@@ -48,7 +46,7 @@ describe("generateNPC", () => {
})
) as unknown as typeof fetch;
- const params: NPCParams = { theme: "dark fantasy", role: "villain" };
+ const params: NPCParams = { theme: "dark fantasy", role: "villain", level: 5, race: "human", tone: "dark" };
const result = await generateNPC(params);
expect(result).toEqual(validNPCResponse);
@@ -88,16 +86,14 @@ describe("generateNPC", () => {
})
) as unknown as typeof fetch;
- const reference = {
+ const reference: Open5eMonster = {
name: "Goblin",
- key: "goblin",
- challenge_rating_decimal: "0.25",
- type: "humanoid",
+ challenge_rating_decimal: 0.25,
hit_points: 7,
armor_class: 15,
};
- await generateNPC({ role: "minion" }, reference);
+ await generateNPC({ role: "minion" } as NPCParams, reference);
const body = JSON.parse(
(global.fetch as ReturnType
).mock.calls[0][1].body
@@ -112,7 +108,7 @@ describe("generateNPC", () => {
vi.unstubAllEnvs();
vi.stubEnv("OPENROUTER_API_KEY", "");
- await expect(generateNPC({})).rejects.toThrow(
+ await expect(generateNPC({} as NPCParams)).rejects.toThrow(
"OPENROUTER_API_KEY is not set"
);
});
@@ -127,7 +123,7 @@ describe("generateNPC", () => {
})
) as unknown as typeof fetch;
- await expect(generateNPC({})).rejects.toThrow(
+ await expect(generateNPC({} as NPCParams)).rejects.toThrow(
"Rate limited by OpenRouter (429)"
);
});
@@ -142,7 +138,7 @@ describe("generateNPC", () => {
})
) as unknown as typeof fetch;
- await expect(generateNPC({})).rejects.toThrow(
+ await expect(generateNPC({} as NPCParams)).rejects.toThrow(
"OpenRouter API error 500"
);
});
@@ -165,7 +161,7 @@ describe("generateNPC", () => {
})
) as unknown as typeof fetch;
- await expect(generateNPC({})).rejects.toThrow(
+ await expect(generateNPC({} as NPCParams)).rejects.toThrow(
"Invalid JSON in OpenRouter response"
);
});
@@ -190,7 +186,7 @@ describe("generateNPC", () => {
})
) as unknown as typeof fetch;
- await expect(generateNPC({})).rejects.toThrow(
+ await expect(generateNPC({} as NPCParams)).rejects.toThrow(
"NPC schema validation failed"
);
});
@@ -215,7 +211,7 @@ describe("generateNPC", () => {
});
}) as unknown as typeof fetch;
- const promise = generateNPC({});
+ const promise = generateNPC({} as NPCParams);
vi.advanceTimersByTime(11_000);
await expect(promise).rejects.toThrow(
@@ -242,7 +238,7 @@ describe("generateNPC", () => {
})
) as unknown as typeof fetch;
- await generateNPC({}, undefined, "custom-model:free");
+ await generateNPC({} as NPCParams, undefined, "custom-model:free");
const body = JSON.parse(
(global.fetch as ReturnType).mock.calls[0][1].body
diff --git a/src/lib/ai/openrouter.ts b/src/lib/ai/openrouter.ts
index e5b15c5..5f5889a 100644
--- a/src/lib/ai/openrouter.ts
+++ b/src/lib/ai/openrouter.ts
@@ -1,46 +1,7 @@
import { z } from "zod";
+import { npcResultSchema, type NPCResult, type NPCParams, type Open5eMonster } from "@/lib/ai/types";
import type { Monster } from "@/lib/open5e/client";
-export type Open5eMonster = Monster;
-
-export interface NPCParams {
- theme?: string;
- setting?: string;
- role?: string;
- level?: number;
- race?: string;
-}
-
-export interface NPCResult {
- name: string;
- race: string;
- role: string;
- hp: number;
- ac: number;
- cr: string;
- speed: string;
- appearance: string;
- trait: string;
- motivation: string;
- secret: string;
- history: string;
-}
-
-const npcResultSchema = z.object({
- name: z.string(),
- race: z.string(),
- role: z.string(),
- hp: z.number(),
- ac: z.number(),
- cr: z.string(),
- speed: z.string(),
- appearance: z.string(),
- trait: z.string(),
- motivation: z.string(),
- secret: z.string(),
- history: z.string(),
-});
-
const OPENROUTER_API_URL = "https://openrouter.ai/api/v1/chat/completions";
const DEFAULT_MODEL = "llama-3.3-70b:free";
const TIMEOUT_MS = 10_000;
@@ -49,11 +10,12 @@ function buildSystemPrompt(): string {
return "You are a creative D&D NPC generator. Respond with valid JSON only, no markdown, no code fences, no explanatory text.";
}
-function buildUserPrompt(params: NPCParams, reference?: Monster): string {
+function buildUserPrompt(params: NPCParams, reference?: Open5eMonster): string {
const schema = JSON.stringify({
name: "string (unique name)",
race: "string (e.g. Human, Elf, Orc)",
role: "string (e.g. Merchant, Bandit, Wizard)",
+ level: "number (party level)",
hp: "number (hit points)",
ac: "number (armor class)",
cr: "string (challenge rating, e.g. '1/4', '5')",
@@ -169,7 +131,7 @@ export async function translateOpen5eContent(
export async function generateNPC(
params: NPCParams,
- reference?: Monster,
+ reference?: Open5eMonster,
model: string = DEFAULT_MODEL
): Promise {
const apiKey = process.env.OPENROUTER_API_KEY;
diff --git a/src/lib/ai/types.ts b/src/lib/ai/types.ts
index becddbb..c77a46a 100644
--- a/src/lib/ai/types.ts
+++ b/src/lib/ai/types.ts
@@ -5,6 +5,8 @@ export const npcParamsSchema = z.object({
role: z.string(),
level: z.number().int().min(1).max(20),
tone: z.string(),
+ theme: z.string().optional(),
+ setting: z.string().optional(),
});
export type NPCParams = z.infer;
@@ -35,7 +37,7 @@ export interface Open5eMonster {
alignment?: string;
armor_class?: number;
hit_points?: number;
- speed?: Record | string;
+ speed?: Record | string;
strength?: number;
dexterity?: number;
constitution?: number;
diff --git a/src/pages/api/dm/ai/generate.ts b/src/pages/api/dm/ai/generate.ts
new file mode 100644
index 0000000..7919a70
--- /dev/null
+++ b/src/pages/api/dm/ai/generate.ts
@@ -0,0 +1,160 @@
+import type { APIRoute } from "astro";
+import { jsonResponse, handleCorsPreflight, createCorsResponse } from "@/lib/cors";
+import { checkRateLimit, incrementGenerationCounter, getRetryAfterSeconds } from "@/lib/rate-limit";
+import { generateNPC as generateOpenRouterNPC } from "@/lib/ai/openrouter";
+import { generateNPC as generateKimiNPC } from "@/lib/ai/kimi";
+import type { Open5eMonster } from "@/lib/ai/types";
+import { searchMonsters } from "@/lib/open5e/client";
+import { db } from "@/db/client";
+import { npcs } from "@/db/schema";
+import { npcParamsSchema, npcResultSchema, type NPCResult } from "@/lib/ai/types";
+import { z } from "zod";
+
+export const prerender = false;
+
+const generateRequestSchema = npcParamsSchema.extend({
+ useOpen5eReference: z.boolean().optional(),
+});
+
+function getOrigin(request: Request): string | null {
+ return request.headers.get("origin");
+}
+
+function requireAuth(locals: App.Locals, origin: string | null): Response | null {
+ if (!locals.user) {
+ return jsonResponse({ error: "Unauthorized" }, 401, origin);
+ }
+ return null;
+}
+
+async function fetchOpen5eReference(role: string): Promise {
+ try {
+ const monsters = await searchMonsters(role);
+ if (monsters.length > 0) {
+ return monsters[0] as unknown as Open5eMonster;
+ }
+ } catch {
+ return null;
+ }
+ return null;
+}
+
+async function saveNPC(
+ userId: number,
+ npc: NPCResult,
+ tone: string
+): Promise<{ id: number }> {
+ const result = await db
+ .insert(npcs)
+ .values({
+ userId,
+ name: npc.name,
+ race: npc.race,
+ role: npc.role,
+ level: npc.level ?? null,
+ tone,
+ content: npc as unknown as Record,
+ })
+ .returning({ id: npcs.id });
+
+ return result[0];
+}
+
+export const POST: APIRoute = async ({ request, locals }) => {
+ const origin = getOrigin(request);
+
+ const authError = requireAuth(locals, origin);
+ if (authError) return authError;
+
+ const user = locals.user!;
+ const tier = user.tier;
+
+ const rateLimit = await checkRateLimit(user.id, tier);
+ if (!rateLimit.allowed) {
+ return createCorsResponse(
+ JSON.stringify({ error: "Rate limit exceeded" }),
+ 429,
+ origin,
+ { "Content-Type": "application/json", "Retry-After": String(getRetryAfterSeconds()) }
+ );
+ }
+
+ let body: unknown;
+ try {
+ body = await request.json();
+ } catch {
+ return jsonResponse({ error: "Invalid JSON" }, 400, origin);
+ }
+
+ const parsed = generateRequestSchema.safeParse(body);
+ if (!parsed.success) {
+ return jsonResponse(
+ { error: "Validation failed", details: parsed.error.format() },
+ 400,
+ origin
+ );
+ }
+
+ const { race, role, level, tone, useOpen5eReference } = parsed.data;
+
+ let reference: Open5eMonster | null = null;
+ if (useOpen5eReference) {
+ reference = await fetchOpen5eReference(role);
+ }
+
+ const aiClient = tier === "pro" ? generateKimiNPC : generateOpenRouterNPC;
+ const modelName = tier === "pro" ? "moonshot-v1-8k" : "llama-3.3-70b:free";
+
+ let npc: NPCResult;
+ try {
+ npc = await aiClient({ race, role, level, tone }, reference ?? undefined);
+ } catch (err) {
+ const message = err instanceof Error ? err.message : String(err);
+ return jsonResponse(
+ { error: "AI generation failed", details: message },
+ 502,
+ origin
+ );
+ }
+
+ const validation = npcResultSchema.safeParse(npc);
+ if (!validation.success) {
+ return jsonResponse(
+ { error: "Invalid AI response", details: validation.error.message },
+ 502,
+ origin
+ );
+ }
+
+ let savedId: number;
+ try {
+ const saved = await saveNPC(user.id, npc, tone);
+ savedId = saved.id;
+ } catch (err) {
+ const message = err instanceof Error ? err.message : String(err);
+ return jsonResponse(
+ { error: "Failed to save NPC", details: message },
+ 500,
+ origin
+ );
+ }
+
+ try {
+ await incrementGenerationCounter(user.id, modelName);
+ } catch {
+ }
+
+ const response: { npc: NPCResult & { id: number }; reference?: Open5eMonster } = {
+ npc: { ...npc, id: savedId },
+ };
+ if (reference) {
+ response.reference = reference;
+ }
+
+ return jsonResponse(response, 200, origin);
+};
+
+export const OPTIONS: APIRoute = async ({ request }) => {
+ const origin = getOrigin(request);
+ return handleCorsPreflight(origin);
+};
diff --git a/src/pages/api/dm/ai/history.ts b/src/pages/api/dm/ai/history.ts
new file mode 100644
index 0000000..7717fc2
--- /dev/null
+++ b/src/pages/api/dm/ai/history.ts
@@ -0,0 +1,60 @@
+import type { APIRoute } from "astro";
+import { jsonResponse, handleCorsPreflight } from "@/lib/cors";
+import { db } from "@/db/client";
+import { npcs } from "@/db/schema";
+import { eq, desc, sql, count } from "drizzle-orm";
+
+export const prerender = false;
+
+const DEFAULT_LIMIT = 20;
+const MAX_LIMIT = 100;
+
+function getOrigin(request: Request): string | null {
+ return request.headers.get("origin");
+}
+
+function parsePagination(url: URL): { limit: number; offset: number } {
+ const rawLimit = url.searchParams.get("limit");
+ const rawOffset = url.searchParams.get("offset");
+
+ const limit = Math.min(
+ Math.max(parseInt(rawLimit ?? String(DEFAULT_LIMIT), 10) || DEFAULT_LIMIT, 1),
+ MAX_LIMIT
+ );
+ const offset = Math.max(parseInt(rawOffset ?? "0", 10) || 0, 0);
+
+ return { limit, offset };
+}
+
+export const GET: APIRoute = async ({ request, locals, url }) => {
+ const origin = getOrigin(request);
+
+ if (!locals.user) {
+ return jsonResponse({ error: "Unauthorized" }, 401, origin);
+ }
+
+ const { limit, offset } = parsePagination(url);
+
+ const [items, countResult] = await Promise.all([
+ db
+ .select()
+ .from(npcs)
+ .where(eq(npcs.userId, locals.user.id))
+ .orderBy(desc(npcs.createdAt))
+ .limit(limit)
+ .offset(offset),
+ db
+ .select({ total: count() })
+ .from(npcs)
+ .where(eq(npcs.userId, locals.user.id)),
+ ]);
+
+ const total = countResult[0]?.total ?? 0;
+
+ return jsonResponse({ items, total }, 200, origin);
+};
+
+export const OPTIONS: APIRoute = async ({ request }) => {
+ const origin = getOrigin(request);
+ return handleCorsPreflight(origin);
+};
diff --git a/src/pages/dm/index.astro b/src/pages/dm/index.astro
index 857397c..8660225 100644
--- a/src/pages/dm/index.astro
+++ b/src/pages/dm/index.astro
@@ -9,12 +9,19 @@ import InitiativeTracker from "@/components/dm/InitiativeTracker.astro";
import Open5eReference from "@/components/dm/Open5eReference.astro";
import NotesPanel from "@/components/dm/NotesPanel.astro";
import { dmTranslations as T } from "@/i18n/dm-translations";
+import { getRemainingQuota, TIER_LIMITS } from "@/lib/rate-limit";
+
+const user = Astro.locals.user;
+let quota = null;
+if (user) {
+ quota = await getRemainingQuota(user.id, user.tier);
+}
---
-
+
diff --git a/src/pages/ru/dm/index.astro b/src/pages/ru/dm/index.astro
index 2aafd98..c4afc78 100644
--- a/src/pages/ru/dm/index.astro
+++ b/src/pages/ru/dm/index.astro
@@ -9,12 +9,19 @@ import InitiativeTracker from "@/components/dm/InitiativeTracker.astro";
import Open5eReference from "@/components/dm/Open5eReference.astro";
import NotesPanel from "@/components/dm/NotesPanel.astro";
import { dmTranslations as T } from "@/i18n/dm-translations";
+import { getRemainingQuota, TIER_LIMITS } from "@/lib/rate-limit";
+
+const user = Astro.locals.user;
+let quota = null;
+if (user) {
+ quota = await getRemainingQuota(user.id, user.tier);
+}
---
-
+
diff --git a/tests/ai-generate-api.test.ts b/tests/ai-generate-api.test.ts
new file mode 100644
index 0000000..b6adc1f
--- /dev/null
+++ b/tests/ai-generate-api.test.ts
@@ -0,0 +1,425 @@
+import { describe, it, expect, vi, beforeEach } from "vitest";
+
+const mockUser = {
+ id: 1,
+ vkId: null,
+ yandexId: null,
+ email: null,
+ name: "Test User",
+ avatar: null,
+ tier: "free" as const,
+ boostyVerifiedAt: null,
+ createdAt: new Date("2024-01-01"),
+};
+
+const mockProUser = {
+ ...mockUser,
+ id: 2,
+ tier: "pro" as const,
+};
+
+function mockRequest(
+ url: string,
+ origin: string,
+ method = "GET",
+ body?: unknown
+): Request {
+ return {
+ url,
+ method,
+ headers: {
+ get(name: string) {
+ if (name.toLowerCase() === "origin") return origin;
+ return null;
+ },
+ },
+ json: async () => body,
+ } as unknown as Request;
+}
+
+var mockOpenRouterNPC = vi.fn();
+var mockKimiNPC = vi.fn();
+var mockSearchMonsters = vi.fn();
+var mockCheckRateLimit = vi.fn();
+var mockIncrementCounter = vi.fn();
+
+vi.mock("@/lib/ai/openrouter", () => ({
+ generateNPC: (...args: unknown[]) => mockOpenRouterNPC(...args),
+}));
+
+vi.mock("@/lib/ai/kimi", () => ({
+ generateNPC: (...args: unknown[]) => mockKimiNPC(...args),
+}));
+
+vi.mock("@/lib/open5e/client", () => ({
+ searchMonsters: (...args: unknown[]) => mockSearchMonsters(...args),
+}));
+
+vi.mock("@/lib/rate-limit", () => ({
+ checkRateLimit: (...args: unknown[]) => mockCheckRateLimit(...args),
+ incrementGenerationCounter: (...args: unknown[]) => mockIncrementCounter(...args),
+ getRetryAfterSeconds: () => 3600,
+ TIER_LIMITS: { free: 7, pro: 100 },
+}));
+
+vi.mock("@/db/client", () => ({
+ db: {
+ insert: vi.fn(() => ({
+ values: vi.fn(() => ({
+ returning: vi.fn(() => Promise.resolve([{ id: 42 }])),
+ })),
+ })),
+ },
+}));
+
+describe("AI Generate NPC API", () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ mockOpenRouterNPC = vi.fn();
+ mockKimiNPC = vi.fn();
+ mockSearchMonsters = vi.fn();
+ mockCheckRateLimit = vi.fn();
+ mockIncrementCounter = vi.fn();
+ });
+
+ it("returns 401 when unauthenticated", async () => {
+ const { POST } = await import("../src/pages/api/dm/ai/generate");
+ const request = mockRequest(
+ "https://dm.randify.pro/api/dm/ai/generate",
+ "https://randify.pro",
+ "POST",
+ { race: "Human", role: "Merchant", level: 5, tone: "friendly" }
+ );
+ const response = await POST!({
+ request,
+ locals: { user: null },
+ url: new URL(request.url),
+ ...({} as any),
+ });
+ expect(response.status).toBe(401);
+ const body = await response.json();
+ expect(body.error).toBe("Unauthorized");
+ });
+
+ it("returns 429 when rate limited", async () => {
+ mockCheckRateLimit.mockResolvedValue({
+ allowed: false,
+ remaining: 0,
+ resetAt: new Date(Date.now() + 3600000),
+ });
+
+ const { POST } = await import("../src/pages/api/dm/ai/generate");
+ const request = mockRequest(
+ "https://dm.randify.pro/api/dm/ai/generate",
+ "https://randify.pro",
+ "POST",
+ { race: "Human", role: "Merchant", level: 5, tone: "friendly" }
+ );
+ const response = await POST!({
+ request,
+ locals: { user: mockUser },
+ url: new URL(request.url),
+ ...({} as any),
+ });
+ expect(response.status).toBe(429);
+ const body = await response.json();
+ expect(body.error).toBe("Rate limit exceeded");
+ expect(response.headers.get("Retry-After")).toBe("3600");
+ });
+
+ it("returns 400 for invalid JSON", async () => {
+ mockCheckRateLimit.mockResolvedValue({
+ allowed: true,
+ remaining: 5,
+ resetAt: new Date(Date.now() + 3600000),
+ });
+
+ const { POST } = await import("../src/pages/api/dm/ai/generate");
+ const request = {
+ ...mockRequest(
+ "https://dm.randify.pro/api/dm/ai/generate",
+ "https://randify.pro",
+ "POST"
+ ),
+ json: async () => {
+ throw new Error("Invalid JSON");
+ },
+ } as unknown as Request;
+
+ const response = await POST!({
+ request,
+ locals: { user: mockUser },
+ url: new URL(request.url),
+ ...({} as any),
+ });
+ expect(response.status).toBe(400);
+ const body = await response.json();
+ expect(body.error).toBe("Invalid JSON");
+ });
+
+ it("returns 400 for validation failure", async () => {
+ mockCheckRateLimit.mockResolvedValue({
+ allowed: true,
+ remaining: 5,
+ resetAt: new Date(Date.now() + 3600000),
+ });
+
+ const { POST } = await import("../src/pages/api/dm/ai/generate");
+ const request = mockRequest(
+ "https://dm.randify.pro/api/dm/ai/generate",
+ "https://randify.pro",
+ "POST",
+ { race: "Human", level: 5 }
+ );
+
+ const response = await POST!({
+ request,
+ locals: { user: mockUser },
+ url: new URL(request.url),
+ ...({} as any),
+ });
+ expect(response.status).toBe(400);
+ const body = await response.json();
+ expect(body.error).toBe("Validation failed");
+ });
+
+ it("happy path for free user without Open5e reference", async () => {
+ mockCheckRateLimit.mockResolvedValue({
+ allowed: true,
+ remaining: 5,
+ resetAt: new Date(Date.now() + 3600000),
+ });
+
+ const generatedNPC = {
+ name: "Gorath",
+ race: "Orc",
+ role: "Bandit",
+ level: 3,
+ hp: 45,
+ ac: 14,
+ cr: "1/2",
+ speed: "30 ft.",
+ appearance: "Scarred and muscular",
+ trait: "Greedy",
+ motivation: "Gold",
+ secret: "Works for a dragon",
+ history: "Former soldier",
+ };
+
+ mockOpenRouterNPC.mockResolvedValue(generatedNPC);
+
+ const { POST } = await import("../src/pages/api/dm/ai/generate");
+ const request = mockRequest(
+ "https://dm.randify.pro/api/dm/ai/generate",
+ "https://randify.pro",
+ "POST",
+ { race: "Orc", role: "Bandit", level: 3, tone: "grim" }
+ );
+
+ const response = await POST!({
+ request,
+ locals: { user: mockUser },
+ url: new URL(request.url),
+ ...({} as any),
+ });
+
+ expect(response.status).toBe(200);
+ const body = await response.json();
+ expect(body.npc.name).toBe("Gorath");
+ expect(body.npc.id).toBe(42);
+ expect(body.reference).toBeUndefined();
+ expect(mockOpenRouterNPC).toHaveBeenCalledWith(
+ { race: "Orc", role: "Bandit", level: 3, tone: "grim" },
+ undefined
+ );
+ expect(mockKimiNPC).not.toHaveBeenCalled();
+ });
+
+ it("happy path for pro user with Open5e reference", async () => {
+ mockCheckRateLimit.mockResolvedValue({
+ allowed: true,
+ remaining: 95,
+ resetAt: new Date(Date.now() + 3600000),
+ });
+
+ const referenceMonster = {
+ name: "Bandit",
+ key: "bandit",
+ challenge_rating_decimal: "0.125",
+ type: "humanoid",
+ hit_points: 11,
+ armor_class: 12,
+ };
+
+ const generatedNPC = {
+ name: "Slythe",
+ race: "Human",
+ role: "Rogue",
+ level: 2,
+ hp: 20,
+ ac: 15,
+ cr: "1/4",
+ speed: "30 ft.",
+ appearance: "Slim and quick",
+ trait: "Cunning",
+ motivation: "Revenge",
+ secret: "Is a noble in disguise",
+ history: "Street urchin turned thief",
+ };
+
+ mockSearchMonsters.mockResolvedValue([referenceMonster]);
+ mockKimiNPC.mockResolvedValue(generatedNPC);
+
+ const { POST } = await import("../src/pages/api/dm/ai/generate");
+ const request = mockRequest(
+ "https://dm.randify.pro/api/dm/ai/generate",
+ "https://randify.pro",
+ "POST",
+ { race: "Human", role: "Rogue", level: 2, tone: "dark", useOpen5eReference: true }
+ );
+
+ const response = await POST!({
+ request,
+ locals: { user: mockProUser },
+ url: new URL(request.url),
+ ...({} as any),
+ });
+
+ expect(response.status).toBe(200);
+ const body = await response.json();
+ expect(body.npc.name).toBe("Slythe");
+ expect(body.reference).toEqual(referenceMonster);
+ expect(mockKimiNPC).toHaveBeenCalledWith(
+ { race: "Human", role: "Rogue", level: 2, tone: "dark" },
+ referenceMonster
+ );
+ expect(mockOpenRouterNPC).not.toHaveBeenCalled();
+ });
+
+ it("continues without reference when Open5e fetch fails", async () => {
+ mockCheckRateLimit.mockResolvedValue({
+ allowed: true,
+ remaining: 5,
+ resetAt: new Date(Date.now() + 3600000),
+ });
+
+ mockSearchMonsters.mockRejectedValue(new Error("Open5e API error"));
+
+ const generatedNPC = {
+ name: "Mira",
+ race: "Elf",
+ role: "Wizard",
+ level: 5,
+ hp: 30,
+ ac: 12,
+ cr: "2",
+ speed: "30 ft.",
+ appearance: "Elegant with silver hair",
+ trait: "Curious",
+ motivation: "Knowledge",
+ secret: "Seeks immortality",
+ history: "Apprentice to a lich",
+ };
+
+ mockOpenRouterNPC.mockResolvedValue(generatedNPC);
+
+ const { POST } = await import("../src/pages/api/dm/ai/generate");
+ const request = mockRequest(
+ "https://dm.randify.pro/api/dm/ai/generate",
+ "https://randify.pro",
+ "POST",
+ { race: "Elf", role: "Wizard", level: 5, tone: "mysterious", useOpen5eReference: true }
+ );
+
+ const response = await POST!({
+ request,
+ locals: { user: mockUser },
+ url: new URL(request.url),
+ ...({} as any),
+ });
+
+ expect(response.status).toBe(200);
+ const body = await response.json();
+ expect(body.npc.name).toBe("Mira");
+ expect(body.reference).toBeUndefined();
+ });
+
+ it("returns 502 when AI response is invalid", async () => {
+ mockCheckRateLimit.mockResolvedValue({
+ allowed: true,
+ remaining: 5,
+ resetAt: new Date(Date.now() + 3600000),
+ });
+
+ mockOpenRouterNPC.mockResolvedValue({
+ name: "Bad",
+ race: "Orc",
+ });
+
+ const { POST } = await import("../src/pages/api/dm/ai/generate");
+ const request = mockRequest(
+ "https://dm.randify.pro/api/dm/ai/generate",
+ "https://randify.pro",
+ "POST",
+ { race: "Orc", role: "Bandit", level: 3, tone: "grim" }
+ );
+
+ const response = await POST!({
+ request,
+ locals: { user: mockUser },
+ url: new URL(request.url),
+ ...({} as any),
+ });
+
+ expect(response.status).toBe(502);
+ const body = await response.json();
+ expect(body.error).toBe("Invalid AI response");
+ });
+
+ it("returns 502 when AI generation throws", async () => {
+ mockCheckRateLimit.mockResolvedValue({
+ allowed: true,
+ remaining: 5,
+ resetAt: new Date(Date.now() + 3600000),
+ });
+
+ mockOpenRouterNPC.mockRejectedValue(new Error("OpenRouter API error"));
+
+ const { POST } = await import("../src/pages/api/dm/ai/generate");
+ const request = mockRequest(
+ "https://dm.randify.pro/api/dm/ai/generate",
+ "https://randify.pro",
+ "POST",
+ { race: "Orc", role: "Bandit", level: 3, tone: "grim" }
+ );
+
+ const response = await POST!({
+ request,
+ locals: { user: mockUser },
+ url: new URL(request.url),
+ ...({} as any),
+ });
+
+ expect(response.status).toBe(502);
+ const body = await response.json();
+ expect(body.error).toBe("AI generation failed");
+ expect(body.details).toBe("OpenRouter API error");
+ });
+
+ it("OPTIONS returns 204 with CORS headers", async () => {
+ const { OPTIONS } = await import("../src/pages/api/dm/ai/generate");
+ const request = mockRequest(
+ "https://dm.randify.pro/api/dm/ai/generate",
+ "https://randify.pro",
+ "OPTIONS"
+ );
+ const response = await OPTIONS!({
+ request,
+ locals: { user: null },
+ url: new URL(request.url),
+ ...({} as any),
+ });
+ expect(response.status).toBe(204);
+ expect(response.headers.get("Access-Control-Allow-Origin")).toBe("https://randify.pro");
+ });
+});
diff --git a/tests/history-api.test.ts b/tests/history-api.test.ts
new file mode 100644
index 0000000..ebd2ed6
--- /dev/null
+++ b/tests/history-api.test.ts
@@ -0,0 +1,355 @@
+import { describe, it, expect, vi, beforeEach } from "vitest";
+
+const mockUser = {
+ id: 1,
+ vkId: null,
+ yandexId: null,
+ email: null,
+ name: "Test User",
+ avatar: null,
+ tier: "free" as const,
+ boostyVerifiedAt: null,
+ createdAt: new Date("2024-01-01"),
+};
+
+function mockRequest(
+ url: string,
+ origin: string,
+ method = "GET"
+): Request {
+ return {
+ url,
+ method,
+ headers: {
+ get(name: string) {
+ if (name.toLowerCase() === "origin") return origin;
+ return null;
+ },
+ },
+ } as unknown as Request;
+}
+
+interface NpcItem {
+ id: number;
+ userId: number;
+ name: string;
+ race: string | null;
+ role: string | null;
+ level: number | null;
+ tone: string | null;
+ content: unknown;
+ createdAt: Date;
+}
+
+function createMockDb() {
+ let npcsStore: NpcItem[] = [];
+
+ function evaluateCondition(item: NpcItem, condition: unknown): boolean {
+ if (!condition || typeof condition !== "object") return true;
+ const c = condition as Record;
+ if (c.type === "eq") {
+ const colName = (c.column as string).replace(/_([a-z])/g, (_, letter) => letter.toUpperCase());
+ const itemValue = (item as unknown as Record)[colName];
+ return itemValue === c.value;
+ }
+ if (c.type === "and") {
+ const conditions = c.conditions as unknown[];
+ return conditions.every((sub) => evaluateCondition(item, sub));
+ }
+ return true;
+ }
+
+ return {
+ reset() {
+ npcsStore = [];
+ },
+ _setStore(store: NpcItem[]) {
+ npcsStore = store;
+ },
+ select: vi.fn((selectArg?: { total: unknown }) => {
+ if (selectArg && "total" in selectArg) {
+ return {
+ from: vi.fn(() => ({
+ where: vi.fn((condition: unknown) => {
+ const filtered = npcsStore.filter((item) => evaluateCondition(item, condition));
+ return Promise.resolve([{ total: filtered.length }]);
+ }),
+ })),
+ };
+ }
+ return {
+ from: vi.fn(() => ({
+ where: vi.fn((condition: unknown) => {
+ const filtered = npcsStore.filter((item) => evaluateCondition(item, condition));
+ const ordered = filtered.slice().sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime());
+ return {
+ orderBy: vi.fn(() => ({
+ limit: vi.fn((n: number) => ({
+ offset: vi.fn((o: number) => Promise.resolve(ordered.slice(o, o + n))),
+ })),
+ })),
+ };
+ }),
+ })),
+ };
+ }),
+ };
+}
+
+let mockDb = createMockDb();
+
+vi.mock("drizzle-orm", async (importOriginal) => {
+ const actual = await importOriginal();
+ return {
+ ...actual,
+ eq: (column: { name: string }, value: unknown) => ({ type: "eq", column: column.name, value }),
+ and: (...conditions: unknown[]) => ({ type: "and", conditions }),
+ desc: (column: { name: string }) => ({ type: "desc", column: column.name, direction: "desc" }),
+ count: () => ({ getSQL: () => ({ type: "count" }) }),
+ sql: Object.assign(
+ (strings: TemplateStringsArray, ...values: unknown[]) => ({ type: "sql", strings, values }),
+ { raw: (value: unknown) => ({ type: "raw", value }) }
+ ),
+ };
+});
+
+vi.mock("../src/db/client", () => ({
+ db: mockDb,
+}));
+
+describe("AI History API", () => {
+ beforeEach(() => {
+ mockDb.reset();
+ vi.clearAllMocks();
+ });
+
+ it("GET returns 401 when unauthenticated", async () => {
+ const { GET } = await import("../src/pages/api/dm/ai/history");
+ const request = mockRequest(
+ "https://dm.randify.pro/api/dm/ai/history",
+ "https://randify.pro"
+ );
+ const response = await GET!({
+ request,
+ locals: { user: null },
+ url: new URL(request.url),
+ ...({} as any),
+ });
+ expect(response.status).toBe(401);
+ const body = await response.json();
+ expect(body.error).toBe("Unauthorized");
+ });
+
+ it("OPTIONS returns 204 with CORS headers", async () => {
+ const { OPTIONS } = await import("../src/pages/api/dm/ai/history");
+ const request = mockRequest(
+ "https://dm.randify.pro/api/dm/ai/history",
+ "https://randify.pro",
+ "OPTIONS"
+ );
+ const response = await OPTIONS!({
+ request,
+ locals: { user: null },
+ url: new URL(request.url),
+ ...({} as any),
+ });
+ expect(response.status).toBe(204);
+ expect(response.headers.get("Access-Control-Allow-Origin")).toBe(
+ "https://randify.pro"
+ );
+ });
+
+ it("GET returns user's NPC history ordered by createdAt DESC", async () => {
+ const { GET } = await import("../src/pages/api/dm/ai/history");
+ const now = new Date();
+ const earlier = new Date(now.getTime() - 3600_000);
+ mockDb._setStore([
+ {
+ id: 1,
+ userId: 1,
+ name: "NPC Older",
+ race: "Human",
+ role: "Merchant",
+ level: 5,
+ tone: "Friendly",
+ content: { description: "Older NPC" },
+ createdAt: earlier,
+ },
+ {
+ id: 2,
+ userId: 1,
+ name: "NPC Newer",
+ race: "Elf",
+ role: "Warrior",
+ level: 10,
+ tone: "Grim",
+ content: { description: "Newer NPC" },
+ createdAt: now,
+ },
+ ]);
+ const request = mockRequest(
+ "https://dm.randify.pro/api/dm/ai/history",
+ "https://randify.pro"
+ );
+ const response = await GET!({
+ request,
+ locals: { user: mockUser },
+ url: new URL(request.url),
+ ...({} as any),
+ });
+ expect(response.status).toBe(200);
+ const body = await response.json();
+ expect(body.items).toHaveLength(2);
+ expect(body.total).toBe(2);
+ expect(body.items[0].name).toBe("NPC Newer");
+ expect(body.items[1].name).toBe("NPC Older");
+ });
+
+ it("GET paginates with limit and offset", async () => {
+ const { GET } = await import("../src/pages/api/dm/ai/history");
+ const now = new Date();
+ mockDb._setStore(
+ Array.from({ length: 5 }, (_, i) => ({
+ id: i + 1,
+ userId: 1,
+ name: `NPC ${i + 1}`,
+ race: "Human",
+ role: "Warrior",
+ level: i + 1,
+ tone: "Neutral",
+ content: { description: `NPC ${i + 1}` },
+ createdAt: new Date(now.getTime() - i * 60_000),
+ }))
+ );
+ const request = mockRequest(
+ "https://dm.randify.pro/api/dm/ai/history?limit=2&offset=1",
+ "https://randify.pro"
+ );
+ const response = await GET!({
+ request,
+ locals: { user: mockUser },
+ url: new URL(request.url),
+ ...({} as any),
+ });
+ expect(response.status).toBe(200);
+ const body = await response.json();
+ expect(body.items).toHaveLength(2);
+ expect(body.total).toBe(5);
+ expect(body.items[0].name).toBe("NPC 2");
+ expect(body.items[1].name).toBe("NPC 3");
+ });
+
+ it("GET returns empty state when user has no NPCs", async () => {
+ const { GET } = await import("../src/pages/api/dm/ai/history");
+ mockDb._setStore([]);
+ const request = mockRequest(
+ "https://dm.randify.pro/api/dm/ai/history",
+ "https://randify.pro"
+ );
+ const response = await GET!({
+ request,
+ locals: { user: mockUser },
+ url: new URL(request.url),
+ ...({} as any),
+ });
+ expect(response.status).toBe(200);
+ const body = await response.json();
+ expect(body.items).toEqual([]);
+ expect(body.total).toBe(0);
+ });
+
+ it("GET never returns other users' NPCs", async () => {
+ const { GET } = await import("../src/pages/api/dm/ai/history");
+ mockDb._setStore([
+ {
+ id: 1,
+ userId: 2,
+ name: "Other User NPC",
+ race: "Orc",
+ role: "Boss",
+ level: 20,
+ tone: "Evil",
+ content: { description: "Should not appear" },
+ createdAt: new Date(),
+ },
+ ]);
+ const request = mockRequest(
+ "https://dm.randify.pro/api/dm/ai/history",
+ "https://randify.pro"
+ );
+ const response = await GET!({
+ request,
+ locals: { user: mockUser },
+ url: new URL(request.url),
+ ...({} as any),
+ });
+ expect(response.status).toBe(200);
+ const body = await response.json();
+ expect(body.items).toEqual([]);
+ expect(body.total).toBe(0);
+ });
+
+ it("GET clamps limit above MAX_LIMIT to MAX_LIMIT", async () => {
+ const { GET } = await import("../src/pages/api/dm/ai/history");
+ const now = new Date();
+ mockDb._setStore(
+ Array.from({ length: 101 }, (_, i) => ({
+ id: i + 1,
+ userId: 1,
+ name: `NPC ${i + 1}`,
+ race: "Human",
+ role: "Warrior",
+ level: 1,
+ tone: "Neutral",
+ content: { description: `NPC ${i + 1}` },
+ createdAt: new Date(now.getTime() - i * 1000),
+ }))
+ );
+ const request = mockRequest(
+ "https://dm.randify.pro/api/dm/ai/history?limit=200",
+ "https://randify.pro"
+ );
+ const response = await GET!({
+ request,
+ locals: { user: mockUser },
+ url: new URL(request.url),
+ ...({} as any),
+ });
+ expect(response.status).toBe(200);
+ const body = await response.json();
+ expect(body.items).toHaveLength(100);
+ expect(body.total).toBe(101);
+ });
+
+ it("GET handles negative offset by clamping to 0", async () => {
+ const { GET } = await import("../src/pages/api/dm/ai/history");
+ const now = new Date();
+ mockDb._setStore([
+ {
+ id: 1,
+ userId: 1,
+ name: "NPC 1",
+ race: "Human",
+ role: "Warrior",
+ level: 1,
+ tone: "Neutral",
+ content: { description: "NPC 1" },
+ createdAt: now,
+ },
+ ]);
+ const request = mockRequest(
+ "https://dm.randify.pro/api/dm/ai/history?offset=-5",
+ "https://randify.pro"
+ );
+ const response = await GET!({
+ request,
+ locals: { user: mockUser },
+ url: new URL(request.url),
+ ...({} as any),
+ });
+ expect(response.status).toBe(200);
+ const body = await response.json();
+ expect(body.items).toHaveLength(1);
+ expect(body.items[0].name).toBe("NPC 1");
+ });
+});