feat(dm-dashboard): Wave 5 — NPC generation API, history API, quota/tier badges, translate button
This commit is contained in:
@@ -394,7 +394,7 @@
|
|||||||
"plan_name": "dm-dashboard-ai",
|
"plan_name": "dm-dashboard-ai",
|
||||||
"status": "active",
|
"status": "active",
|
||||||
"started_at": "2026-05-15T17:58:09.859Z",
|
"started_at": "2026-05-15T17:58:09.859Z",
|
||||||
"updated_at": "2026-05-15T19:25:18.776Z",
|
"updated_at": "2026-05-15T19:37:40.096Z",
|
||||||
"session_ids": [
|
"session_ids": [
|
||||||
"ses_1d3921469ffeQox08ZjNWWJq7u"
|
"ses_1d3921469ffeQox08ZjNWWJq7u"
|
||||||
],
|
],
|
||||||
@@ -407,12 +407,12 @@
|
|||||||
"task_key": "final-wave:f1",
|
"task_key": "final-wave:f1",
|
||||||
"task_label": "F1",
|
"task_label": "F1",
|
||||||
"task_title": "**Plan Compliance Audit** — `oracle`",
|
"task_title": "**Plan Compliance Audit** — `oracle`",
|
||||||
"session_id": "ses_1d2eae77effe0UMe22r0JJWWK1",
|
"session_id": "ses_1d2e49248ffelY8jx06oXxpWMG",
|
||||||
"agent": "Sisyphus-Junior",
|
"agent": "Sisyphus-Junior",
|
||||||
"category": "quick",
|
"category": "deep",
|
||||||
"started_at": "2026-05-15T18:24:29.818Z",
|
"started_at": "2026-05-15T18:24:29.818Z",
|
||||||
"status": "running",
|
"status": "running",
|
||||||
"updated_at": "2026-05-15T19:25:18.777Z"
|
"updated_at": "2026-05-15T19:37:40.097Z"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -420,7 +420,7 @@
|
|||||||
"active_plan": "/home/emil/Desktop/Coding/AI/Randify.pro/.sisyphus/plans/dm-dashboard-ai.md",
|
"active_plan": "/home/emil/Desktop/Coding/AI/Randify.pro/.sisyphus/plans/dm-dashboard-ai.md",
|
||||||
"started_at": "2026-05-15T17:58:09.859Z",
|
"started_at": "2026-05-15T17:58:09.859Z",
|
||||||
"status": "active",
|
"status": "active",
|
||||||
"updated_at": "2026-05-15T19:25:18.776Z",
|
"updated_at": "2026-05-15T19:37:40.096Z",
|
||||||
"session_ids": [
|
"session_ids": [
|
||||||
"ses_1d3921469ffeQox08ZjNWWJq7u"
|
"ses_1d3921469ffeQox08ZjNWWJq7u"
|
||||||
],
|
],
|
||||||
@@ -433,12 +433,12 @@
|
|||||||
"task_key": "final-wave:f1",
|
"task_key": "final-wave:f1",
|
||||||
"task_label": "F1",
|
"task_label": "F1",
|
||||||
"task_title": "**Plan Compliance Audit** — `oracle`",
|
"task_title": "**Plan Compliance Audit** — `oracle`",
|
||||||
"session_id": "ses_1d2eae77effe0UMe22r0JJWWK1",
|
"session_id": "ses_1d2e49248ffelY8jx06oXxpWMG",
|
||||||
"agent": "Sisyphus-Junior",
|
"agent": "Sisyphus-Junior",
|
||||||
"category": "quick",
|
"category": "deep",
|
||||||
"started_at": "2026-05-15T18:24:29.818Z",
|
"started_at": "2026-05-15T18:24:29.818Z",
|
||||||
"status": "running",
|
"status": "running",
|
||||||
"updated_at": "2026-05-15T19:25:18.777Z"
|
"updated_at": "2026-05-15T19:37:40.097Z"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"agent": "atlas"
|
"agent": "atlas"
|
||||||
|
|||||||
@@ -205,7 +205,7 @@ Task: Generate and apply Drizzle migration for updated DB schema (Task 2 complet
|
|||||||
- `getRemainingQuota(userId, tier)` — same aggregation logic, lightweight query for UI badges.
|
- `getRemainingQuota(userId, tier)` — same aggregation logic, lightweight query for UI badges.
|
||||||
- `incrementGenerationCounter(userId, model)` — upserts per-model counter row using `onConflictDoUpdate` with composite unique target `(userId, hourWindow, model)`.
|
- `incrementGenerationCounter(userId, model)` — upserts per-model counter row using `onConflictDoUpdate` with composite unique target `(userId, hourWindow, model)`.
|
||||||
- `getRetryAfterSeconds()` — computes seconds until next hour boundary for 429 `Retry-After` header.
|
- `getRetryAfterSeconds()` — computes seconds until next hour boundary for 429 `Retry-After` header.
|
||||||
- FREE tier hard limit: 7/hour. PRO tier: 100/hour advisory (not hard-blocked, allows minor burst).
|
- FREE tier hard limit: 7/hour. PRO tier: 100/hour advisory (not hard-blocked).
|
||||||
|
|
||||||
2. **Created `src/pages/api/dm/quota.ts`**
|
2. **Created `src/pages/api/dm/quota.ts`**
|
||||||
- `GET` returns `{ remaining, resetAt, limit, tier }` for authenticated user.
|
- `GET` returns `{ remaining, resetAt, limit, tier }` for authenticated user.
|
||||||
@@ -325,3 +325,60 @@ Task: Generate and apply Drizzle migration for updated DB schema (Task 2 complet
|
|||||||
### Verification
|
### Verification
|
||||||
|
|
||||||
- `npx tsc --noEmit`: 0 errors
|
- `npx tsc --noEmit`: 0 errors
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# AI NPC Generation API Route — Learnings
|
||||||
|
|
||||||
|
## Date: 2026-05-15
|
||||||
|
|
||||||
|
### What Was Done
|
||||||
|
|
||||||
|
1. **Created `src/pages/api/dm/ai/generate.ts`**
|
||||||
|
- POST endpoint with `prerender = false` for middleware auth.
|
||||||
|
- Request body validated with Zod: `npcParamsSchema.extend({ useOpen5eReference: z.boolean().optional() })`.
|
||||||
|
- Flow:
|
||||||
|
1. Auth check (`requireAuth` helper) → 401 if unauthenticated.
|
||||||
|
2. Rate limit check (`checkRateLimit(user.id, tier)`) → 429 with `Retry-After` header if blocked.
|
||||||
|
3. Open5e reference fetch (`searchMonsters(role)`) only when `useOpen5eReference` is true. Failures are caught and the request continues without reference.
|
||||||
|
4. AI client selection: `generateKimiNPC` for PRO tier, `generateOpenRouterNPC` for FREE tier.
|
||||||
|
5. AI generation with params + optional reference.
|
||||||
|
6. Defensive Zod validation of AI response using `npcResultSchema` from shared types.
|
||||||
|
7. Save NPC to `npcs` table via `db.insert().values().returning()`.
|
||||||
|
8. Increment generation counter via `incrementGenerationCounter(user.id, modelName)`.
|
||||||
|
9. Return `{ npc: NPCResult & { id }, reference?: Monster }`.
|
||||||
|
- OPTIONS handler for CORS preflight.
|
||||||
|
|
||||||
|
2. **Created `tests/ai-generate-api.test.ts`**
|
||||||
|
- 10 tests covering:
|
||||||
|
- 401 unauthenticated
|
||||||
|
- 429 rate limited with `Retry-After` header assertion
|
||||||
|
- 400 invalid JSON
|
||||||
|
- 400 validation failure (missing required fields)
|
||||||
|
- 200 happy path for free user without Open5e reference
|
||||||
|
- 200 happy path for pro user with Open5e reference
|
||||||
|
- 200 graceful degradation when Open5e fetch fails
|
||||||
|
- 502 invalid AI response (schema validation failure)
|
||||||
|
- 502 AI generation throws (API error)
|
||||||
|
- 204 OPTIONS with CORS headers
|
||||||
|
|
||||||
|
3. **Mocking strategy**
|
||||||
|
- Mocked `@/lib/ai/openrouter`, `@/lib/ai/kimi`, `@/lib/open5e/client`, `@/lib/rate-limit`, and `@/db/client` using `vi.mock()` with `var`-declared mock functions.
|
||||||
|
- Used `vi.fn()` reassignment in `beforeEach` to reset mocks between tests.
|
||||||
|
- Dynamic imports (`await import("../src/pages/api/dm/ai/generate")`) inside each test to avoid vitest module caching issues with mocked dependencies.
|
||||||
|
|
||||||
|
### Key Findings
|
||||||
|
|
||||||
|
- **`jsonResponse` does not accept extra headers.** The helper in `src/lib/cors.ts` only takes 3 arguments: data, status, origin. For the 429 response that needs a `Retry-After` header, `createCorsResponse` must be used directly with `JSON.stringify` and explicit `Content-Type`.
|
||||||
|
- **Schema inconsistency between OpenRouter and shared types.** `src/lib/ai/types.ts` `npcResultSchema` requires `level: z.number().int()`, but `src/lib/ai/openrouter.ts`'s internal schema omits `level`. This means an OpenRouter response without `level` will pass OpenRouter's own validation but fail the defensive validation in the API route. All mock responses in tests include `level` to avoid this.
|
||||||
|
- **`var` declarations for `vi.mock` factories.** Vitest hoists `vi.mock` calls, so variables referenced inside the factory must be declared with `var` (not `const`/`let`) to avoid `ReferenceError` from hoisting.
|
||||||
|
- **Graceful degradation for Open5e.** If `searchMonsters` throws or returns empty results, the route continues without reference rather than failing the entire request. This is better UX since the reference is optional enrichment, not a hard dependency.
|
||||||
|
- **Counter increment is non-fatal.** If `incrementGenerationCounter` throws (e.g., DB transient error), the catch block is empty and the successful NPC response is still returned. The user got their content; analytics can tolerate a dropped counter.
|
||||||
|
|
||||||
|
### Verification
|
||||||
|
|
||||||
|
- `npx vitest run tests/ai-generate-api.test.ts`: 10/10 passed
|
||||||
|
- `npx vitest run` (full suite): 306/306 passed in 22 test files (1 pre-existing broken test file `tests/history-api.test.ts` with parse error, unrelated)
|
||||||
|
- `npx tsc --noEmit`: 0 errors in project code (1 pre-existing parse error in `tests/history-api.test.ts`)
|
||||||
|
- `npm run build`: fails due to pre-existing auth env validation at build-time (`JWT_SECRET` missing) — unrelated to this change
|
||||||
|
|
||||||
|
|||||||
@@ -820,7 +820,7 @@ Max Concurrent: 6 (Wave 6)
|
|||||||
- Files: `src/pages/dm/index.astro`, `src/components/dm/DmSidebar.astro`, `src/i18n/dm-translations.ts`
|
- Files: `src/pages/dm/index.astro`, `src/components/dm/DmSidebar.astro`, `src/i18n/dm-translations.ts`
|
||||||
- Pre-commit: `npm run build`
|
- Pre-commit: `npm run build`
|
||||||
|
|
||||||
- [ ] **10. Build NPC Generation API Route**
|
- [x] **10. Build NPC Generation API Route**
|
||||||
|
|
||||||
**What to do**:
|
**What to do**:
|
||||||
- Create `src/pages/api/dm/ai/generate.ts`: POST endpoint for NPC generation.
|
- Create `src/pages/api/dm/ai/generate.ts`: POST endpoint for NPC generation.
|
||||||
@@ -911,7 +911,7 @@ Max Concurrent: 6 (Wave 6)
|
|||||||
- Files: `src/pages/api/dm/ai/generate.ts`
|
- Files: `src/pages/api/dm/ai/generate.ts`
|
||||||
- Pre-commit: `npm test src/pages/api/dm/ai/generate.test.ts`
|
- Pre-commit: `npm test src/pages/api/dm/ai/generate.test.ts`
|
||||||
|
|
||||||
- [ ] **11. Build Generation History API Route**
|
- [x] **11. Build Generation History API Route**
|
||||||
|
|
||||||
**What to do**:
|
**What to do**:
|
||||||
- Create `src/pages/api/dm/ai/history.ts`: GET endpoint for user's generation history.
|
- Create `src/pages/api/dm/ai/history.ts`: GET endpoint for user's generation history.
|
||||||
@@ -1414,7 +1414,7 @@ Max Concurrent: 6 (Wave 6)
|
|||||||
- Message: `feat(ui): add generation history panel`
|
- Message: `feat(ui): add generation history panel`
|
||||||
- Files: `src/components/dm/GenerationHistory.astro`
|
- Files: `src/components/dm/GenerationHistory.astro`
|
||||||
|
|
||||||
- [ ] **19. Build Quota/Tier Badge Header Components**
|
- [x] **19. Build Quota/Tier Badge Header Components**
|
||||||
|
|
||||||
**What to do**:
|
**What to do**:
|
||||||
- Create `src/components/dm/QuotaBadge.astro`: Shows remaining generations this hour (e.g., "5 / 7").
|
- Create `src/components/dm/QuotaBadge.astro`: Shows remaining generations this hour (e.g., "5 / 7").
|
||||||
@@ -1657,7 +1657,7 @@ Max Concurrent: 6 (Wave 6)
|
|||||||
- Message: `feat(pro): add FREE to PRO data import flow`
|
- Message: `feat(pro): add FREE to PRO data import flow`
|
||||||
- Files: `src/components/dm/ImportModal.astro` (new), related updates
|
- Files: `src/components/dm/ImportModal.astro` (new), related updates
|
||||||
|
|
||||||
- [ ] **23. Add Translate Button to Open5e Reference Cards**
|
- [x] **23. Add Translate Button to Open5e Reference Cards**
|
||||||
|
|
||||||
**What to do**:
|
**What to do**:
|
||||||
- Update `src/components/dm/Open5eReference.astro`: Add "Перевести" button to monster/spell/item detail cards.
|
- Update `src/components/dm/Open5eReference.astro`: Add "Перевести" button to monster/spell/item detail cards.
|
||||||
|
|||||||
@@ -1,11 +1,15 @@
|
|||||||
---
|
---
|
||||||
import { dmTranslations as T } from "../../i18n/dm-translations";
|
import { dmTranslations as T } from "../../i18n/dm-translations";
|
||||||
|
import TierBadge from "./TierBadge.astro";
|
||||||
|
import QuotaBadge from "./QuotaBadge.astro";
|
||||||
|
|
||||||
export interface Props {
|
export interface Props {
|
||||||
user?: {
|
user?: {
|
||||||
name?: string;
|
name?: string;
|
||||||
avatar?: string;
|
avatar?: string;
|
||||||
tier?: 'free' | 'pro';
|
tier?: 'free' | 'pro';
|
||||||
|
remaining?: number;
|
||||||
|
limit?: number;
|
||||||
} | null;
|
} | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -27,14 +31,9 @@ const { user } = Astro.props;
|
|||||||
<span class="text-sm font-medium text-[var(--text-primary)] truncate">
|
<span class="text-sm font-medium text-[var(--text-primary)] truncate">
|
||||||
{user.name || "Пользователь"}
|
{user.name || "Пользователь"}
|
||||||
</span>
|
</span>
|
||||||
{user.tier === 'pro' ? (
|
<TierBadge tier={user.tier ?? 'free'} />
|
||||||
<span data-testid="tier-badge" data-tier="pro" class="inline-flex items-center px-1.5 py-0.5 rounded text-[10px] font-bold uppercase tracking-wide bg-[var(--accent)] text-white">
|
{typeof user.remaining === 'number' && typeof user.limit === 'number' && (
|
||||||
{T.badgePro}
|
<QuotaBadge remaining={user.remaining} limit={user.limit} />
|
||||||
</span>
|
|
||||||
) : (
|
|
||||||
<span data-testid="tier-badge" data-tier="free" class="inline-flex items-center px-1.5 py-0.5 rounded text-[10px] font-bold uppercase tracking-wide border border-[var(--text-muted)] text-[var(--text-muted)]">
|
|
||||||
{T.badgeFree}
|
|
||||||
</span>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -230,6 +230,58 @@ import DmButton from "./DmButton.astro";
|
|||||||
return div.innerHTML;
|
return div.innerHTML;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const TRANSLATION_CACHE_KEY = "o5-translations";
|
||||||
|
|
||||||
|
function loadTranslationCache(): Record<string, Monster | Spell> {
|
||||||
|
try {
|
||||||
|
const raw = localStorage.getItem(TRANSLATION_CACHE_KEY);
|
||||||
|
return raw ? JSON.parse(raw) : {};
|
||||||
|
} catch {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function saveTranslationCache(cache: Record<string, Monster | Spell>) {
|
||||||
|
try {
|
||||||
|
localStorage.setItem(TRANSLATION_CACHE_KEY, JSON.stringify(cache));
|
||||||
|
} catch {
|
||||||
|
/* ignore quota errors */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getCachedTranslation(key: string, type: "creature" | "spell"): Monster | Spell | null {
|
||||||
|
const cache = loadTranslationCache();
|
||||||
|
return cache[`${type}:${key}`] ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function setCachedTranslation(key: string, type: "creature" | "spell", data: Monster | Spell) {
|
||||||
|
const cache = loadTranslationCache();
|
||||||
|
cache[`${type}:${key}`] = data;
|
||||||
|
saveTranslationCache(cache);
|
||||||
|
}
|
||||||
|
|
||||||
|
function mergeWithTranslation<T extends Monster | Spell>(item: T, translated: T | null): T {
|
||||||
|
if (!translated) return item;
|
||||||
|
return { ...item, ...translated };
|
||||||
|
}
|
||||||
|
|
||||||
|
function translateButtonHtml(key: string, type: "creature" | "spell", isTranslated: boolean): string {
|
||||||
|
const label = isTranslated ? "Переведено" : "Перевести";
|
||||||
|
const disabled = isTranslated ? "disabled" : "";
|
||||||
|
return `
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
data-translate-btn
|
||||||
|
data-slug="${escapeHtml(key)}"
|
||||||
|
data-type="${type}"
|
||||||
|
${disabled}
|
||||||
|
class="mt-3 inline-flex items-center justify-center px-4 py-1.5 text-sm font-semibold rounded-lg bg-[var(--bg-card)] text-[var(--text-primary)] border border-[var(--border-color)] hover:border-[var(--gold)] hover:text-[var(--gold)] focus:outline-none focus-visible:ring-2 focus-visible:ring-[var(--accent)] focus-visible:ring-offset-2 focus-visible:ring-offset-[var(--bg-primary)] transition-all duration-[var(--transition-base)] cursor-pointer disabled:opacity-50 disabled:cursor-not-allowed active:scale-[0.97]"
|
||||||
|
>
|
||||||
|
${label}
|
||||||
|
</button>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
function renderMonsterCard(monster: Monster): string {
|
function renderMonsterCard(monster: Monster): string {
|
||||||
const cr = Open5eUIManager.formatCr(monster.challenge_rating_decimal ?? "");
|
const cr = Open5eUIManager.formatCr(monster.challenge_rating_decimal ?? "");
|
||||||
return `
|
return `
|
||||||
@@ -268,11 +320,12 @@ import DmButton from "./DmButton.astro";
|
|||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderMonsterDetail(monster: Monster): string {
|
function renderMonsterDetail(monster: Monster, isTranslated: boolean): string {
|
||||||
const cr = Open5eUIManager.formatCr(monster.challenge_rating_decimal ?? "");
|
const cr = Open5eUIManager.formatCr(monster.challenge_rating_decimal ?? "");
|
||||||
let html = `
|
let html = `
|
||||||
<h2 class="text-xl font-bold text-[var(--text-primary)] pr-8">${escapeHtml(monster.name)}</h2>
|
<h2 class="text-xl font-bold text-[var(--text-primary)] pr-8">${escapeHtml(monster.name)}</h2>
|
||||||
<p class="text-sm text-[var(--text-secondary)] mt-1">${escapeHtml(monster.type || "—")} · ОП ${cr}</p>
|
<p class="text-sm text-[var(--text-secondary)] mt-1">${escapeHtml(monster.type || "—")} · ОП ${cr}</p>
|
||||||
|
${translateButtonHtml(monster.key, "creature", isTranslated)}
|
||||||
<div class="grid grid-cols-3 gap-3 mt-4">
|
<div class="grid grid-cols-3 gap-3 mt-4">
|
||||||
<div class="rounded-lg bg-[var(--bg-secondary)] p-3 text-center">
|
<div class="rounded-lg bg-[var(--bg-secondary)] p-3 text-center">
|
||||||
<p class="text-lg font-bold text-[var(--accent)]">${monster.hit_points ?? "—"}</p>
|
<p class="text-lg font-bold text-[var(--accent)]">${monster.hit_points ?? "—"}</p>
|
||||||
@@ -315,11 +368,12 @@ import DmButton from "./DmButton.astro";
|
|||||||
return html;
|
return html;
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderSpellDetail(spell: Spell): string {
|
function renderSpellDetail(spell: Spell, isTranslated: boolean): string {
|
||||||
const level = Open5eUIManager.formatSpellLevel(spell.level ?? 0);
|
const level = Open5eUIManager.formatSpellLevel(spell.level ?? 0);
|
||||||
let html = `
|
let html = `
|
||||||
<h2 class="text-xl font-bold text-[var(--text-primary)] pr-8">${escapeHtml(spell.name)}</h2>
|
<h2 class="text-xl font-bold text-[var(--text-primary)] pr-8">${escapeHtml(spell.name)}</h2>
|
||||||
<p class="text-sm text-[var(--text-secondary)] mt-1">${escapeHtml(spell.school || "—")} · ${level}</p>
|
<p class="text-sm text-[var(--text-secondary)] mt-1">${escapeHtml(spell.school || "—")} · ${level}</p>
|
||||||
|
${translateButtonHtml(spell.key, "spell", isTranslated)}
|
||||||
<div class="grid grid-cols-2 gap-3 mt-4">
|
<div class="grid grid-cols-2 gap-3 mt-4">
|
||||||
<div class="rounded-lg bg-[var(--bg-secondary)] p-3 text-center">
|
<div class="rounded-lg bg-[var(--bg-secondary)] p-3 text-center">
|
||||||
<p class="text-sm font-bold text-[var(--accent)]">${escapeHtml(spell.casting_time || "—")}</p>
|
<p class="text-sm font-bold text-[var(--accent)]">${escapeHtml(spell.casting_time || "—")}</p>
|
||||||
@@ -437,9 +491,13 @@ import DmButton from "./DmButton.astro";
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (state.selectedItem) {
|
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"
|
detailContent.innerHTML = state.tab === "monsters"
|
||||||
? renderMonsterDetail(state.selectedItem)
|
? renderMonsterDetail(item as Monster, isTranslated)
|
||||||
: renderSpellDetail(state.selectedItem);
|
: renderSpellDetail(item as Spell, isTranslated);
|
||||||
detailOverlay.classList.remove("hidden");
|
detailOverlay.classList.remove("hidden");
|
||||||
detailClose.focus();
|
detailClose.focus();
|
||||||
} else {
|
} else {
|
||||||
@@ -491,6 +549,55 @@ import DmButton from "./DmButton.astro";
|
|||||||
manager.search();
|
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);
|
detailClose.addEventListener("click", closeDetail);
|
||||||
detailBg.addEventListener("click", closeDetail);
|
detailBg.addEventListener("click", closeDetail);
|
||||||
detailOverlay.addEventListener("click", (e) => {
|
detailOverlay.addEventListener("click", (e) => {
|
||||||
|
|||||||
@@ -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)]";
|
||||||
|
}
|
||||||
|
---
|
||||||
|
|
||||||
|
<span
|
||||||
|
data-testid="quota-badge"
|
||||||
|
class={`inline-flex items-center px-1.5 py-0.5 rounded text-[10px] font-bold uppercase tracking-wide bg-[var(--bg-card)] border border-[var(--border-gold-strong)] ${textColor}`}
|
||||||
|
title={`${remaining} / ${limit} осталось в этом часе`}
|
||||||
|
>
|
||||||
|
{remaining} / {limit}
|
||||||
|
</span>
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
---
|
||||||
|
import { dmTranslations as T } from "../../i18n/dm-translations";
|
||||||
|
|
||||||
|
export interface Props {
|
||||||
|
tier: 'free' | 'pro';
|
||||||
|
}
|
||||||
|
|
||||||
|
const { tier } = Astro.props;
|
||||||
|
---
|
||||||
|
|
||||||
|
{tier === 'pro' ? (
|
||||||
|
<span data-testid="tier-badge" data-tier="pro" class="inline-flex items-center px-1.5 py-0.5 rounded text-[10px] font-bold uppercase tracking-wide bg-[var(--accent)] text-white">
|
||||||
|
{T.badgePro}
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<span data-testid="tier-badge" data-tier="free" class="inline-flex items-center px-1.5 py-0.5 rounded text-[10px] font-bold uppercase tracking-wide border border-[var(--text-muted)] text-[var(--text-muted)]">
|
||||||
|
{T.badgeFree}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
@@ -51,6 +51,12 @@ export const dmTranslations = {
|
|||||||
loading: "Загрузка...",
|
loading: "Загрузка...",
|
||||||
searching: "Поиск...",
|
searching: "Поиск...",
|
||||||
|
|
||||||
|
// Translation
|
||||||
|
translate: "Перевести",
|
||||||
|
translating: "Перевод...",
|
||||||
|
translated: "Переведено",
|
||||||
|
translateError: "Ошибка перевода",
|
||||||
|
|
||||||
// Open5e tabs
|
// Open5e tabs
|
||||||
monsters: "Монстры",
|
monsters: "Монстры",
|
||||||
spells: "Заклинания",
|
spells: "Заклинания",
|
||||||
|
|||||||
@@ -1,15 +1,13 @@
|
|||||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||||
import {
|
import { generateNPC } from "./openrouter";
|
||||||
generateNPC,
|
import { type NPCParams, type NPCResult, type Open5eMonster } from "@/lib/ai/types";
|
||||||
type NPCParams,
|
|
||||||
type NPCResult,
|
|
||||||
} from "./openrouter";
|
|
||||||
|
|
||||||
describe("generateNPC", () => {
|
describe("generateNPC", () => {
|
||||||
const validNPCResponse: NPCResult = {
|
const validNPCResponse: NPCResult = {
|
||||||
name: "Gorath the Grim",
|
name: "Gorath the Grim",
|
||||||
race: "Half-Orc",
|
race: "Half-Orc",
|
||||||
role: "Mercenary Captain",
|
role: "Mercenary Captain",
|
||||||
|
level: 5,
|
||||||
hp: 45,
|
hp: 45,
|
||||||
ac: 16,
|
ac: 16,
|
||||||
cr: "2",
|
cr: "2",
|
||||||
@@ -48,7 +46,7 @@ describe("generateNPC", () => {
|
|||||||
})
|
})
|
||||||
) as unknown as typeof fetch;
|
) 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);
|
const result = await generateNPC(params);
|
||||||
|
|
||||||
expect(result).toEqual(validNPCResponse);
|
expect(result).toEqual(validNPCResponse);
|
||||||
@@ -88,16 +86,14 @@ describe("generateNPC", () => {
|
|||||||
})
|
})
|
||||||
) as unknown as typeof fetch;
|
) as unknown as typeof fetch;
|
||||||
|
|
||||||
const reference = {
|
const reference: Open5eMonster = {
|
||||||
name: "Goblin",
|
name: "Goblin",
|
||||||
key: "goblin",
|
challenge_rating_decimal: 0.25,
|
||||||
challenge_rating_decimal: "0.25",
|
|
||||||
type: "humanoid",
|
|
||||||
hit_points: 7,
|
hit_points: 7,
|
||||||
armor_class: 15,
|
armor_class: 15,
|
||||||
};
|
};
|
||||||
|
|
||||||
await generateNPC({ role: "minion" }, reference);
|
await generateNPC({ role: "minion" } as NPCParams, reference);
|
||||||
|
|
||||||
const body = JSON.parse(
|
const body = JSON.parse(
|
||||||
(global.fetch as ReturnType<typeof vi.fn>).mock.calls[0][1].body
|
(global.fetch as ReturnType<typeof vi.fn>).mock.calls[0][1].body
|
||||||
@@ -112,7 +108,7 @@ describe("generateNPC", () => {
|
|||||||
vi.unstubAllEnvs();
|
vi.unstubAllEnvs();
|
||||||
vi.stubEnv("OPENROUTER_API_KEY", "");
|
vi.stubEnv("OPENROUTER_API_KEY", "");
|
||||||
|
|
||||||
await expect(generateNPC({})).rejects.toThrow(
|
await expect(generateNPC({} as NPCParams)).rejects.toThrow(
|
||||||
"OPENROUTER_API_KEY is not set"
|
"OPENROUTER_API_KEY is not set"
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
@@ -127,7 +123,7 @@ describe("generateNPC", () => {
|
|||||||
})
|
})
|
||||||
) as unknown as typeof fetch;
|
) as unknown as typeof fetch;
|
||||||
|
|
||||||
await expect(generateNPC({})).rejects.toThrow(
|
await expect(generateNPC({} as NPCParams)).rejects.toThrow(
|
||||||
"Rate limited by OpenRouter (429)"
|
"Rate limited by OpenRouter (429)"
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
@@ -142,7 +138,7 @@ describe("generateNPC", () => {
|
|||||||
})
|
})
|
||||||
) as unknown as typeof fetch;
|
) as unknown as typeof fetch;
|
||||||
|
|
||||||
await expect(generateNPC({})).rejects.toThrow(
|
await expect(generateNPC({} as NPCParams)).rejects.toThrow(
|
||||||
"OpenRouter API error 500"
|
"OpenRouter API error 500"
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
@@ -165,7 +161,7 @@ describe("generateNPC", () => {
|
|||||||
})
|
})
|
||||||
) as unknown as typeof fetch;
|
) as unknown as typeof fetch;
|
||||||
|
|
||||||
await expect(generateNPC({})).rejects.toThrow(
|
await expect(generateNPC({} as NPCParams)).rejects.toThrow(
|
||||||
"Invalid JSON in OpenRouter response"
|
"Invalid JSON in OpenRouter response"
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
@@ -190,7 +186,7 @@ describe("generateNPC", () => {
|
|||||||
})
|
})
|
||||||
) as unknown as typeof fetch;
|
) as unknown as typeof fetch;
|
||||||
|
|
||||||
await expect(generateNPC({})).rejects.toThrow(
|
await expect(generateNPC({} as NPCParams)).rejects.toThrow(
|
||||||
"NPC schema validation failed"
|
"NPC schema validation failed"
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
@@ -215,7 +211,7 @@ describe("generateNPC", () => {
|
|||||||
});
|
});
|
||||||
}) as unknown as typeof fetch;
|
}) as unknown as typeof fetch;
|
||||||
|
|
||||||
const promise = generateNPC({});
|
const promise = generateNPC({} as NPCParams);
|
||||||
vi.advanceTimersByTime(11_000);
|
vi.advanceTimersByTime(11_000);
|
||||||
|
|
||||||
await expect(promise).rejects.toThrow(
|
await expect(promise).rejects.toThrow(
|
||||||
@@ -242,7 +238,7 @@ describe("generateNPC", () => {
|
|||||||
})
|
})
|
||||||
) as unknown as typeof fetch;
|
) as unknown as typeof fetch;
|
||||||
|
|
||||||
await generateNPC({}, undefined, "custom-model:free");
|
await generateNPC({} as NPCParams, undefined, "custom-model:free");
|
||||||
|
|
||||||
const body = JSON.parse(
|
const body = JSON.parse(
|
||||||
(global.fetch as ReturnType<typeof vi.fn>).mock.calls[0][1].body
|
(global.fetch as ReturnType<typeof vi.fn>).mock.calls[0][1].body
|
||||||
|
|||||||
@@ -1,46 +1,7 @@
|
|||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
import { npcResultSchema, type NPCResult, type NPCParams, type Open5eMonster } from "@/lib/ai/types";
|
||||||
import type { Monster } from "@/lib/open5e/client";
|
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 OPENROUTER_API_URL = "https://openrouter.ai/api/v1/chat/completions";
|
||||||
const DEFAULT_MODEL = "llama-3.3-70b:free";
|
const DEFAULT_MODEL = "llama-3.3-70b:free";
|
||||||
const TIMEOUT_MS = 10_000;
|
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.";
|
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({
|
const schema = JSON.stringify({
|
||||||
name: "string (unique name)",
|
name: "string (unique name)",
|
||||||
race: "string (e.g. Human, Elf, Orc)",
|
race: "string (e.g. Human, Elf, Orc)",
|
||||||
role: "string (e.g. Merchant, Bandit, Wizard)",
|
role: "string (e.g. Merchant, Bandit, Wizard)",
|
||||||
|
level: "number (party level)",
|
||||||
hp: "number (hit points)",
|
hp: "number (hit points)",
|
||||||
ac: "number (armor class)",
|
ac: "number (armor class)",
|
||||||
cr: "string (challenge rating, e.g. '1/4', '5')",
|
cr: "string (challenge rating, e.g. '1/4', '5')",
|
||||||
@@ -169,7 +131,7 @@ export async function translateOpen5eContent(
|
|||||||
|
|
||||||
export async function generateNPC(
|
export async function generateNPC(
|
||||||
params: NPCParams,
|
params: NPCParams,
|
||||||
reference?: Monster,
|
reference?: Open5eMonster,
|
||||||
model: string = DEFAULT_MODEL
|
model: string = DEFAULT_MODEL
|
||||||
): Promise<NPCResult> {
|
): Promise<NPCResult> {
|
||||||
const apiKey = process.env.OPENROUTER_API_KEY;
|
const apiKey = process.env.OPENROUTER_API_KEY;
|
||||||
|
|||||||
+3
-1
@@ -5,6 +5,8 @@ export const npcParamsSchema = z.object({
|
|||||||
role: z.string(),
|
role: z.string(),
|
||||||
level: z.number().int().min(1).max(20),
|
level: z.number().int().min(1).max(20),
|
||||||
tone: z.string(),
|
tone: z.string(),
|
||||||
|
theme: z.string().optional(),
|
||||||
|
setting: z.string().optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
export type NPCParams = z.infer<typeof npcParamsSchema>;
|
export type NPCParams = z.infer<typeof npcParamsSchema>;
|
||||||
@@ -35,7 +37,7 @@ export interface Open5eMonster {
|
|||||||
alignment?: string;
|
alignment?: string;
|
||||||
armor_class?: number;
|
armor_class?: number;
|
||||||
hit_points?: number;
|
hit_points?: number;
|
||||||
speed?: Record<string, string> | string;
|
speed?: Record<string, string | number | null> | string;
|
||||||
strength?: number;
|
strength?: number;
|
||||||
dexterity?: number;
|
dexterity?: number;
|
||||||
constitution?: number;
|
constitution?: number;
|
||||||
|
|||||||
@@ -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<Open5eMonster | null> {
|
||||||
|
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<string, unknown>,
|
||||||
|
})
|
||||||
|
.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);
|
||||||
|
};
|
||||||
@@ -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);
|
||||||
|
};
|
||||||
@@ -9,12 +9,19 @@ import InitiativeTracker from "@/components/dm/InitiativeTracker.astro";
|
|||||||
import Open5eReference from "@/components/dm/Open5eReference.astro";
|
import Open5eReference from "@/components/dm/Open5eReference.astro";
|
||||||
import NotesPanel from "@/components/dm/NotesPanel.astro";
|
import NotesPanel from "@/components/dm/NotesPanel.astro";
|
||||||
import { dmTranslations as T } from "@/i18n/dm-translations";
|
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);
|
||||||
|
}
|
||||||
---
|
---
|
||||||
|
|
||||||
<DmLayout>
|
<DmLayout>
|
||||||
<!-- Sidebar slot (desktop only) -->
|
<!-- Sidebar slot (desktop only) -->
|
||||||
<div slot="sidebar">
|
<div slot="sidebar">
|
||||||
<DmSidebar user={Astro.locals.user} />
|
<DmSidebar user={user ? { ...user, remaining: quota?.remaining ?? undefined, limit: quota ? TIER_LIMITS[user.tier] : undefined } : null} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Main slot (dice + initiative) -->
|
<!-- Main slot (dice + initiative) -->
|
||||||
|
|||||||
@@ -9,12 +9,19 @@ import InitiativeTracker from "@/components/dm/InitiativeTracker.astro";
|
|||||||
import Open5eReference from "@/components/dm/Open5eReference.astro";
|
import Open5eReference from "@/components/dm/Open5eReference.astro";
|
||||||
import NotesPanel from "@/components/dm/NotesPanel.astro";
|
import NotesPanel from "@/components/dm/NotesPanel.astro";
|
||||||
import { dmTranslations as T } from "@/i18n/dm-translations";
|
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);
|
||||||
|
}
|
||||||
---
|
---
|
||||||
|
|
||||||
<DmLayout>
|
<DmLayout>
|
||||||
<!-- Sidebar slot (desktop only) -->
|
<!-- Sidebar slot (desktop only) -->
|
||||||
<div slot="sidebar">
|
<div slot="sidebar">
|
||||||
<DmSidebar />
|
<DmSidebar user={user ? { ...user, remaining: quota?.remaining ?? undefined, limit: quota ? TIER_LIMITS[user.tier] : undefined } : null} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Main slot (dice + initiative) -->
|
<!-- Main slot (dice + initiative) -->
|
||||||
|
|||||||
@@ -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");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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<string, unknown>;
|
||||||
|
if (c.type === "eq") {
|
||||||
|
const colName = (c.column as string).replace(/_([a-z])/g, (_, letter) => letter.toUpperCase());
|
||||||
|
const itemValue = (item as unknown as Record<string, unknown>)[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<typeof import("drizzle-orm")>();
|
||||||
|
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");
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user