feat(dm-dashboard): Wave 4 — rate limiting, translation API, AI tab navigation
This commit is contained in:
@@ -394,7 +394,7 @@
|
||||
"plan_name": "dm-dashboard-ai",
|
||||
"status": "active",
|
||||
"started_at": "2026-05-15T17:58:09.859Z",
|
||||
"updated_at": "2026-05-15T18:58:35.401Z",
|
||||
"updated_at": "2026-05-15T19:25:18.776Z",
|
||||
"session_ids": [
|
||||
"ses_1d3921469ffeQox08ZjNWWJq7u"
|
||||
],
|
||||
@@ -407,12 +407,12 @@
|
||||
"task_key": "final-wave:f1",
|
||||
"task_label": "F1",
|
||||
"task_title": "**Plan Compliance Audit** — `oracle`",
|
||||
"session_id": "ses_1d3077a47ffeDo8hOHOTlgJSpJ",
|
||||
"session_id": "ses_1d2eae77effe0UMe22r0JJWWK1",
|
||||
"agent": "Sisyphus-Junior",
|
||||
"category": "unspecified-high",
|
||||
"category": "quick",
|
||||
"started_at": "2026-05-15T18:24:29.818Z",
|
||||
"status": "running",
|
||||
"updated_at": "2026-05-15T18:58:35.402Z"
|
||||
"updated_at": "2026-05-15T19:25:18.777Z"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -420,7 +420,7 @@
|
||||
"active_plan": "/home/emil/Desktop/Coding/AI/Randify.pro/.sisyphus/plans/dm-dashboard-ai.md",
|
||||
"started_at": "2026-05-15T17:58:09.859Z",
|
||||
"status": "active",
|
||||
"updated_at": "2026-05-15T18:58:35.401Z",
|
||||
"updated_at": "2026-05-15T19:25:18.776Z",
|
||||
"session_ids": [
|
||||
"ses_1d3921469ffeQox08ZjNWWJq7u"
|
||||
],
|
||||
@@ -433,12 +433,12 @@
|
||||
"task_key": "final-wave:f1",
|
||||
"task_label": "F1",
|
||||
"task_title": "**Plan Compliance Audit** — `oracle`",
|
||||
"session_id": "ses_1d3077a47ffeDo8hOHOTlgJSpJ",
|
||||
"session_id": "ses_1d2eae77effe0UMe22r0JJWWK1",
|
||||
"agent": "Sisyphus-Junior",
|
||||
"category": "unspecified-high",
|
||||
"category": "quick",
|
||||
"started_at": "2026-05-15T18:24:29.818Z",
|
||||
"status": "running",
|
||||
"updated_at": "2026-05-15T18:58:35.402Z"
|
||||
"updated_at": "2026-05-15T19:25:18.777Z"
|
||||
}
|
||||
},
|
||||
"agent": "atlas"
|
||||
|
||||
@@ -191,3 +191,137 @@ Task: Generate and apply Drizzle migration for updated DB schema (Task 2 complet
|
||||
- `npx vitest run`: 271/271 tests passed (19 test files)
|
||||
- `npx vitest run tests/notes-api.test.ts`: 19/19 passed
|
||||
- `npx vitest run tests/initiative-api.test.ts`: 20/20 passed
|
||||
|
||||
---
|
||||
|
||||
# Rate Limiting Service for AI Generation — Learnings
|
||||
|
||||
## Date: 2026-05-15
|
||||
|
||||
### What Was Done
|
||||
|
||||
1. **Created `src/lib/rate-limit.ts`**
|
||||
- `checkRateLimit(userId, tier)` — aggregates `sum(count)` across ALL models for the user's current hour window (`date_trunc('hour', now())`). Returns `{ allowed, remaining, resetAt }`.
|
||||
- `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)`.
|
||||
- `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).
|
||||
|
||||
2. **Created `src/pages/api/dm/quota.ts`**
|
||||
- `GET` returns `{ remaining, resetAt, limit, tier }` for authenticated user.
|
||||
- `OPTIONS` handler for CORS preflight.
|
||||
- `prerender = false` required for middleware auth to populate `locals.user`.
|
||||
|
||||
3. **CORS fix (`src/lib/cors.ts`)**
|
||||
- Added `PUT` and `DELETE` to `Access-Control-Allow-Methods`. Already verified in existing `tests/cors.test.ts`.
|
||||
|
||||
4. **Tests (`tests/rate-limit.test.ts`)**
|
||||
- 14 tests covering: within limit, at limit, over limit, pro advisory behavior, resetAt correctness, tier differentiation, increment upsert, Retry-After logic.
|
||||
- Mock DB pattern: mock `@/db/client` with `select` returning configurable total, `insert` capturing values and resolving `onConflictDoUpdate`.
|
||||
- `vi.mock("drizzle-orm", ...)` needed to mock `eq`, `and`, and `sql` template tag so Drizzle query chains don't crash in vitest.
|
||||
|
||||
### Key Findings
|
||||
|
||||
- **Aggregating vs per-model limits**: The `generationCounters` schema has a composite unique index on `(userId, hourWindow, model)`, so counters are stored per-model. The rate limit check must `sum(count)` across all models to enforce a per-user total limit, while `incrementGenerationCounter` still tracks per-model for analytics.
|
||||
- **`sql` template tag mocking**: In vitest, `` sql`expr` `` is a template tag function. The mock must accept `(strings: TemplateStringsArray, ...values)` and return a serializable object, otherwise Drizzle's internal SQL object breaks mocked query chains.
|
||||
- **File write stability**: The `write` tool occasionally fails or reverts for existing files in this workspace. Using `bash` with `cat > file << 'EOF'` is more reliable for overwriting.
|
||||
- **`getRetryAfterSeconds` at hour boundary**: At exactly 00:00 of an hour, the "next hour" is 3600 seconds away, not 0. This is correct behavior for `Retry-After` since the new hour window just started.
|
||||
|
||||
### Verification
|
||||
|
||||
- `npx tsc --noEmit`: 0 errors
|
||||
- `npx vitest run tests/rate-limit.test.ts`: 14/14 passed
|
||||
- `npx vitest run` (full suite): 285/285 passed (20 test files)
|
||||
|
||||
---
|
||||
|
||||
# Rate Limiting Service for AI Generation Routes — Learnings
|
||||
|
||||
## Date: 2026-05-15
|
||||
|
||||
### Patterns Applied
|
||||
|
||||
1. **Rate limiting service (`src/lib/rate-limit.ts`)**
|
||||
- `checkRateLimit(userId, tier)` queries the current hour's generation count BEFORE the AI API call to avoid wasting provider quota.
|
||||
- `incrementGenerationCounter(userId, model)` upserts the counter AFTER a successful AI response using PostgreSQL `ON CONFLICT DO UPDATE`.
|
||||
- `getRemainingQuota(userId, tier)` provides a lightweight query for UI badges.
|
||||
- `getRetryAfterSeconds()` computes seconds until the next hour for 429 `Retry-After` headers.
|
||||
- Tier limits are exported as `TIER_LIMITS` (`free: 7`, `pro: 100`).
|
||||
|
||||
2. **Hourly reset via `date_trunc('hour', now())`**
|
||||
- The `hourWindow` column uses `sql`date_trunc('hour', now())`` in both SELECT and INSERT/UPSERT queries.
|
||||
- This ensures counters reset at the top of each hour, not on a rolling window.
|
||||
|
||||
3. **FREE hard-block vs PRO advisory**
|
||||
- FREE tier: `allowed = count < limit` — hard-blocked at the limit.
|
||||
- PRO tier: `allowed = true` always — advisory only, allows minor burst over 100/hour.
|
||||
- The `remaining` value can go negative for PRO to signal advisory overage.
|
||||
|
||||
4. **Race-safe upsert with Drizzle `onConflictDoUpdate`**
|
||||
- Instead of read-then-write (race-prone), the insert uses `onConflictDoUpdate` with the unique index columns as target.
|
||||
- The `set` clause increments `count` atomically: `sql`${generationCounters.count} + 1``.
|
||||
|
||||
5. **Quota API route (`src/pages/api/dm/quota.ts`)**
|
||||
- Exposes `GET /api/dm/quota` returning `{ remaining, resetAt, limit, tier }`.
|
||||
- Requires auth (`locals.user`).
|
||||
- Follows DM API route pattern: CORS headers, `prerender = false`, `OPTIONS` preflight handler.
|
||||
|
||||
6. **CORS methods fix**
|
||||
- Added `PUT` and `DELETE` to `Access-Control-Allow-Methods` in `src/lib/cors.ts`.
|
||||
- Updated `tests/cors.test.ts` assertions to match.
|
||||
- Required for notes/initiative API routes that use PUT/DELETE.
|
||||
|
||||
### Testing Notes
|
||||
|
||||
- Mocking `drizzle-orm` in vitest: mocking `eq`, `and`, and `sql` to return plain objects allows the mock DB to evaluate `where` conditions without complex SQL parsing.
|
||||
- Dynamic imports inside tests (`await import("@/lib/rate-limit")`) avoid vitest module caching issues when combined with `vi.mock`.
|
||||
- The `getRetryAfterSeconds` test uses `vi.useFakeTimers()` to verify exact hour-boundary behavior.
|
||||
|
||||
### Verification
|
||||
|
||||
- `npx vitest run tests/rate-limit.test.ts`: 14/14 passed
|
||||
- `npx vitest run tests/cors.test.ts`: 12/12 passed
|
||||
- `npx vitest run` (full suite): 285/285 passed (20 test files)
|
||||
- `npx tsc --noEmit`: 0 errors
|
||||
- `npm run build`: fails due to pre-existing auth env validation (`JWT_SECRET` etc. missing in build env) — not related to rate-limiting changes
|
||||
|
||||
### Key Findings
|
||||
|
||||
- **Concurrent agent modification**: During implementation, `src/lib/rate-limit.ts` and `tests/rate-limit.test.ts` were repeatedly overwritten by another concurrent agent. The final accepted implementation aggregates counts across all models per user/hour (using `coalesce(sum(count), 0)`) rather than per-model. This aligns with the task requirement `checkRateLimit(userId, tier)` which does not accept a model parameter.
|
||||
- **Do not use `vi.mock` factory with top-level variables**: The factory is hoisted before variable initialization, causing `ReferenceError` for `const`/`let` bindings. Use dynamic imports for the module under test, or define mock objects inside the factory.
|
||||
|
||||
---
|
||||
|
||||
# DM Dashboard AI Tab Navigation — Learnings
|
||||
|
||||
## Date: 2026-05-15
|
||||
|
||||
### What Was Done
|
||||
|
||||
1. **Added AI ("ИИ") tab to DM Dashboard navigation**
|
||||
- Updated `src/i18n/dm-translations.ts` with new keys:
|
||||
- `ai`, `anchorAi`, `tabAi` — tab/anchor labels
|
||||
- `aiSignInCta: "Войдите, чтобы использовать ИИ"` — unauthenticated CTA message
|
||||
- `aiPlaceholder: "Генератор контента с помощью ИИ"` — authenticated placeholder
|
||||
- Updated `src/components/dm/DmTabs.astro` — added `{ id: "ai", label: T.ai, hash: "#ai" }` as 5th tab in both server-rendered array and client-side script array.
|
||||
- Updated `src/components/dm/DmSidebar.astro` — added `{ id: "ai", label: T.ai, icon: "✦" }` to the tools navigation list.
|
||||
- Updated `src/layouts/DmLayout.astro` — extended `tabIds` array in mobile visibility script from `["dice", "initiative", "reference", "notes"]` to include `"ai"`.
|
||||
|
||||
2. **Added AI section to DM index pages**
|
||||
- `src/pages/dm/index.astro` — added `<section id="ai" class="dm-tab-section">` inside the `main` slot, after initiative.
|
||||
- `src/pages/ru/dm/index.astro` — mirrored the same section.
|
||||
- Section uses `Astro.locals.user` to conditionally render:
|
||||
- Authenticated: placeholder text inside `DmCard`
|
||||
- Unauthenticated: sign-in CTA text inside `DmCard`
|
||||
- Section follows the exact same heading pattern (orange `bg-[var(--accent)]` bar + `<h2>`) as dice and initiative.
|
||||
|
||||
### Key Findings
|
||||
|
||||
- **DmTabs client-side array must stay in sync with server-side array.** The `<script>` block in `DmTabs.astro` has a hardcoded `tabs` array that drives hash validation, keyboard navigation, and event dispatching. Adding the server tab without updating the client array breaks mobile tab switching.
|
||||
- **DmLayout mobile visibility script must include the new tab ID.** The `tabIds` array in `DmLayout.astro` controls which sections are shown/hidden on mobile via `updateMobileSections()`. Missing `"ai"` here would cause the section to never appear on mobile, even when the tab is active.
|
||||
- **Both EN and RU DM index pages must be updated.** `src/pages/dm/index.astro` and `src/pages/ru/dm/index.astro` are separate files with identical structure. Changes must be mirrored.
|
||||
- **RU page pre-existing inconsistency:** `ru/dm/index.astro` passes no `user` prop to `DmSidebar` (pre-existing). I left it untouched per scope discipline, but `Astro.locals.user` is still available for the AI section conditional.
|
||||
|
||||
### Verification
|
||||
|
||||
- `npx tsc --noEmit`: 0 errors
|
||||
|
||||
@@ -690,7 +690,7 @@ Max Concurrent: 6 (Wave 6)
|
||||
- Files: `src/lib/ai/kimi.ts`
|
||||
- Pre-commit: `npm test src/lib/ai/`
|
||||
|
||||
- [ ] **8. Build Rate Limiting Service/Middleware**
|
||||
- [x] **8. Build Rate Limiting Service/Middleware**
|
||||
|
||||
**What to do**:
|
||||
- Create `src/lib/rate-limit.ts`: Service for per-user generation counters.
|
||||
@@ -968,7 +968,7 @@ Max Concurrent: 6 (Wave 6)
|
||||
- Files: `src/pages/api/dm/ai/history.ts`
|
||||
- Pre-commit: `npm test src/pages/api/dm/ai/history.test.ts`
|
||||
|
||||
- [ ] **12. Build Translation API Route**
|
||||
- [x] **12. Build Translation API Route**
|
||||
|
||||
**What to do**:
|
||||
- Create `src/pages/api/dm/translate.ts`: GET endpoint for Open5e content translation.
|
||||
@@ -1143,7 +1143,7 @@ Max Concurrent: 6 (Wave 6)
|
||||
- Files: `src/pages/api/dm/notes.ts`, `src/pages/api/dm/initiative.ts`
|
||||
- Pre-commit: `npm test src/pages/api/dm/notes.test.ts src/pages/api/dm/initiative.test.ts`
|
||||
|
||||
- [ ] **15. Add AI Tab to Navigation**
|
||||
- [x] **15. Add AI Tab to Navigation**
|
||||
|
||||
**What to do**:
|
||||
- Update `src/components/dm/DmTabs.astro`: Add "ИИ" tab with icon (✦) as 5th tab after dice, initiative, reference, notes.
|
||||
|
||||
@@ -51,6 +51,7 @@ const { user } = Astro.props;
|
||||
{ id: "initiative", label: T.initiative, icon: "⚔️" },
|
||||
{ id: "reference", label: T.reference, icon: "📖" },
|
||||
{ id: "notes", label: T.notes, icon: "📝" },
|
||||
{ id: "ai", label: T.ai, icon: "✦" },
|
||||
].map((item) => (
|
||||
<a
|
||||
href={`#${item.id}`}
|
||||
|
||||
@@ -10,6 +10,7 @@ const tabs = [
|
||||
{ id: "initiative", label: T.initiative, hash: "#initiative" },
|
||||
{ id: "reference", label: T.reference, hash: "#reference" },
|
||||
{ id: "notes", label: T.notes, hash: "#notes" },
|
||||
{ id: "ai", label: T.ai, hash: "#ai" },
|
||||
] as const;
|
||||
|
||||
const { activeTab } = Astro.props;
|
||||
@@ -66,7 +67,7 @@ if (activeTab && tabs.some((t) => t.id === activeTab)) {
|
||||
|
||||
<script>
|
||||
(function () {
|
||||
const tabs = ["dice", "initiative", "reference", "notes"] as const;
|
||||
const tabs = ["dice", "initiative", "reference", "notes", "ai"] as const;
|
||||
type TabId = (typeof tabs)[number];
|
||||
|
||||
const tabList = document.querySelector<HTMLElement>(".dm-tabs");
|
||||
|
||||
@@ -10,16 +10,19 @@ export const dmTranslations = {
|
||||
initiative: "Инициатива",
|
||||
reference: "Справочник",
|
||||
notes: "Заметки",
|
||||
ai: "ИИ",
|
||||
anchorDice: "Кубики",
|
||||
anchorInitiative: "Инициатива",
|
||||
anchorReference: "Справочник",
|
||||
anchorNotes: "Заметки",
|
||||
anchorAi: "ИИ",
|
||||
|
||||
// Tab labels
|
||||
tabDice: "Кубики",
|
||||
tabInitiative: "Инициатива",
|
||||
tabReference: "Справочник",
|
||||
tabNotes: "Заметки",
|
||||
tabAi: "✦ ИИ",
|
||||
|
||||
// Sidebar sections
|
||||
sidebarTools: "ИНСТРУМЕНТЫ",
|
||||
@@ -75,6 +78,11 @@ export const dmTranslations = {
|
||||
initiativeHelp: "Бросок d20 + модификатор, или введите итог вручную",
|
||||
ariaCombatants: "Участники боя",
|
||||
|
||||
// AI
|
||||
aiTitle: "Генератор ИИ",
|
||||
aiSignInCta: "Войдите, чтобы использовать ИИ",
|
||||
aiPlaceholder: "Генератор ИИ появится здесь",
|
||||
|
||||
// Auth
|
||||
loginVk: "Войти через VK",
|
||||
loginYandex: "Войти через Яндекс",
|
||||
|
||||
@@ -106,7 +106,7 @@ const {
|
||||
|
||||
<script>
|
||||
(function () {
|
||||
const tabIds = ["dice", "initiative", "reference", "notes"];
|
||||
const tabIds = ["dice", "initiative", "reference", "notes", "ai"];
|
||||
|
||||
function getHash() {
|
||||
const raw = window.location.hash.replace("#", "");
|
||||
|
||||
@@ -85,6 +85,88 @@ function buildUserPrompt(params: NPCParams, reference?: Monster): string {
|
||||
return prompt;
|
||||
}
|
||||
|
||||
export async function translateOpen5eContent(
|
||||
content: Record<string, unknown>,
|
||||
type: "creature" | "spell",
|
||||
model: string = DEFAULT_MODEL
|
||||
): Promise<Record<string, unknown>> {
|
||||
const apiKey = process.env.OPENROUTER_API_KEY;
|
||||
if (!apiKey) {
|
||||
throw new Error("OPENROUTER_API_KEY is not set");
|
||||
}
|
||||
|
||||
const controller = new AbortController();
|
||||
const timeoutId = setTimeout(() => controller.abort(), TIMEOUT_MS);
|
||||
|
||||
const systemPrompt =
|
||||
"You are a D&D content translator. Translate the provided JSON object into Russian. " +
|
||||
"Preserve the exact JSON structure and all keys. " +
|
||||
"Translate only human-readable text fields (names, descriptions, labels, etc.). " +
|
||||
"Keep numeric values, slugs, URLs, and mechanical identifiers unchanged. " +
|
||||
"Respond with valid JSON only, no markdown, no code fences, no explanatory text.";
|
||||
|
||||
const userPrompt = `Translate this D&D ${type} into Russian. Preserve JSON structure exactly. Only translate text values.\n\n${JSON.stringify(content, null, 2)}`;
|
||||
|
||||
try {
|
||||
const response = await fetch(OPENROUTER_API_URL, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${apiKey}`,
|
||||
"HTTP-Referer": process.env.PUBLIC_APP_URL || "https://randify.pro",
|
||||
"X-Title": "Randify.pro DM Dashboard",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
model,
|
||||
messages: [
|
||||
{ role: "system", content: systemPrompt },
|
||||
{ role: "user", content: userPrompt },
|
||||
],
|
||||
temperature: 0.3,
|
||||
}),
|
||||
signal: controller.signal,
|
||||
});
|
||||
|
||||
if (response.status === 429) {
|
||||
throw new Error("Rate limited by OpenRouter (429)");
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
const text = await response.text();
|
||||
throw new Error(`OpenRouter API error ${response.status}: ${text}`);
|
||||
}
|
||||
|
||||
const data = (await response.json()) as {
|
||||
choices?: Array<{ message?: { content?: string } }>;
|
||||
};
|
||||
|
||||
const responseContent = data.choices?.[0]?.message?.content;
|
||||
if (!responseContent) {
|
||||
throw new Error("Empty response from OpenRouter");
|
||||
}
|
||||
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(responseContent);
|
||||
} catch {
|
||||
throw new Error(`Invalid JSON in OpenRouter response: ${responseContent}`);
|
||||
}
|
||||
|
||||
if (typeof parsed !== "object" || parsed === null) {
|
||||
throw new Error("OpenRouter response is not a JSON object");
|
||||
}
|
||||
|
||||
return parsed as Record<string, unknown>;
|
||||
} catch (err) {
|
||||
if (err instanceof Error && err.name === "AbortError") {
|
||||
throw new Error("OpenRouter request timed out after 10s");
|
||||
}
|
||||
throw err;
|
||||
} finally {
|
||||
clearTimeout(timeoutId);
|
||||
}
|
||||
}
|
||||
|
||||
export async function generateNPC(
|
||||
params: NPCParams,
|
||||
reference?: Monster,
|
||||
|
||||
+1
-1
@@ -22,7 +22,7 @@ export function getCorsHeaders(origin: string | null): Record<string, string> {
|
||||
const allowedOrigin = origin && isAllowedOrigin(origin) ? origin : "";
|
||||
return {
|
||||
"Access-Control-Allow-Origin": allowedOrigin,
|
||||
"Access-Control-Allow-Methods": "GET, POST, OPTIONS",
|
||||
"Access-Control-Allow-Methods": "GET, POST, PUT, DELETE, OPTIONS",
|
||||
"Access-Control-Allow-Headers": "Content-Type, Authorization",
|
||||
"Access-Control-Allow-Credentials": "true",
|
||||
"Vary": "Origin",
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
import { db } from "@/db/client";
|
||||
import { generationCounters } from "@/db/schema";
|
||||
import { eq, and, sql } from "drizzle-orm";
|
||||
|
||||
export const TIER_LIMITS = {
|
||||
free: 7,
|
||||
pro: 100,
|
||||
} as const;
|
||||
|
||||
export type Tier = "free" | "pro";
|
||||
|
||||
export interface RateLimitResult {
|
||||
allowed: boolean;
|
||||
remaining: number;
|
||||
resetAt: Date;
|
||||
}
|
||||
|
||||
export interface QuotaResult {
|
||||
remaining: number;
|
||||
resetAt: Date;
|
||||
}
|
||||
|
||||
function getNextHourResetAt(): Date {
|
||||
const now = new Date();
|
||||
return new Date(
|
||||
now.getFullYear(),
|
||||
now.getMonth(),
|
||||
now.getDate(),
|
||||
now.getHours() + 1,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
);
|
||||
}
|
||||
|
||||
async function getCurrentTotalCount(userId: number): Promise<number> {
|
||||
const [row] = await db
|
||||
.select({
|
||||
total: sql<number>`coalesce(sum(${generationCounters.count}), 0)`,
|
||||
})
|
||||
.from(generationCounters)
|
||||
.where(
|
||||
and(
|
||||
eq(generationCounters.userId, userId),
|
||||
eq(generationCounters.hourWindow, sql`date_trunc('hour', now())`)
|
||||
)
|
||||
);
|
||||
|
||||
return row?.total ?? 0;
|
||||
}
|
||||
|
||||
export async function checkRateLimit(
|
||||
userId: number,
|
||||
tier: Tier
|
||||
): Promise<RateLimitResult> {
|
||||
const limit = TIER_LIMITS[tier];
|
||||
const count = await getCurrentTotalCount(userId);
|
||||
const remaining = limit - count;
|
||||
|
||||
const allowed = tier === "pro" ? true : count < limit;
|
||||
|
||||
return { allowed, remaining, resetAt: getNextHourResetAt() };
|
||||
}
|
||||
|
||||
export async function getRemainingQuota(
|
||||
userId: number,
|
||||
tier: Tier
|
||||
): Promise<QuotaResult> {
|
||||
const limit = TIER_LIMITS[tier];
|
||||
const count = await getCurrentTotalCount(userId);
|
||||
const remaining = limit - count;
|
||||
|
||||
return { remaining, resetAt: getNextHourResetAt() };
|
||||
}
|
||||
|
||||
export async function incrementGenerationCounter(
|
||||
userId: number,
|
||||
model: string
|
||||
): Promise<void> {
|
||||
await db
|
||||
.insert(generationCounters)
|
||||
.values({
|
||||
userId,
|
||||
hourWindow: sql`date_trunc('hour', now())`,
|
||||
count: 1,
|
||||
model,
|
||||
})
|
||||
.onConflictDoUpdate({
|
||||
target: [
|
||||
generationCounters.userId,
|
||||
generationCounters.hourWindow,
|
||||
generationCounters.model,
|
||||
],
|
||||
set: { count: sql`${generationCounters.count} + 1` },
|
||||
});
|
||||
}
|
||||
|
||||
export function getRetryAfterSeconds(): number {
|
||||
const resetAt = getNextHourResetAt();
|
||||
return Math.max(0, Math.ceil((resetAt.getTime() - Date.now()) / 1000));
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import type { APIRoute } from "astro";
|
||||
import { jsonResponse, handleCorsPreflight } from "@/lib/cors";
|
||||
import { getRemainingQuota, TIER_LIMITS } from "@/lib/rate-limit";
|
||||
|
||||
export const prerender = false;
|
||||
|
||||
function getOrigin(request: Request): string | null {
|
||||
return request.headers.get("origin");
|
||||
}
|
||||
|
||||
export const GET: APIRoute = async ({ request, locals }) => {
|
||||
const origin = getOrigin(request);
|
||||
|
||||
if (!locals.user) {
|
||||
return jsonResponse({ error: "Unauthorized" }, 401, origin);
|
||||
}
|
||||
|
||||
const tier = locals.user.tier as "free" | "pro";
|
||||
const quota = await getRemainingQuota(locals.user.id, tier);
|
||||
const limit = TIER_LIMITS[tier];
|
||||
|
||||
return jsonResponse(
|
||||
{
|
||||
remaining: quota.remaining,
|
||||
resetAt: quota.resetAt.toISOString(),
|
||||
limit,
|
||||
tier,
|
||||
},
|
||||
200,
|
||||
origin
|
||||
);
|
||||
};
|
||||
|
||||
export const OPTIONS: APIRoute = async ({ request }) => {
|
||||
const origin = getOrigin(request);
|
||||
return handleCorsPreflight(origin);
|
||||
};
|
||||
@@ -0,0 +1,111 @@
|
||||
import type { APIRoute } from "astro";
|
||||
import { jsonResponse, handleCorsPreflight } from "@/lib/cors";
|
||||
import { db } from "@/db/client";
|
||||
import { translations } from "@/db/schema";
|
||||
import { eq, and } from "drizzle-orm";
|
||||
import { getMonster, getSpell } from "@/lib/open5e/client";
|
||||
import { translateOpen5eContent } from "@/lib/ai/openrouter";
|
||||
|
||||
export const prerender = false;
|
||||
|
||||
const ALLOWED_TYPES = new Set(["creature", "spell"]);
|
||||
|
||||
function getOrigin(request: Request): string | null {
|
||||
return request.headers.get("origin");
|
||||
}
|
||||
|
||||
export const GET: APIRoute = async ({ request }) => {
|
||||
const origin = getOrigin(request);
|
||||
const url = new URL(request.url);
|
||||
const slug = url.searchParams.get("slug");
|
||||
const type = url.searchParams.get("type");
|
||||
|
||||
if (!slug || !type) {
|
||||
return jsonResponse(
|
||||
{ error: "Missing required query parameters: slug and type" },
|
||||
400,
|
||||
origin
|
||||
);
|
||||
}
|
||||
|
||||
if (!ALLOWED_TYPES.has(type)) {
|
||||
return jsonResponse(
|
||||
{ error: `Invalid type. Allowed: creature, spell` },
|
||||
400,
|
||||
origin
|
||||
);
|
||||
}
|
||||
|
||||
const cached = await db
|
||||
.select()
|
||||
.from(translations)
|
||||
.where(
|
||||
and(
|
||||
eq(translations.slug, slug),
|
||||
eq(translations.type, type),
|
||||
eq(translations.language, "ru")
|
||||
)
|
||||
)
|
||||
.orderBy(translations.updatedAt);
|
||||
|
||||
if (cached.length > 0 && cached[0].content) {
|
||||
return jsonResponse(
|
||||
{ translated: cached[0].content, cached: true },
|
||||
200,
|
||||
origin
|
||||
);
|
||||
}
|
||||
|
||||
let original: Record<string, unknown>;
|
||||
try {
|
||||
if (type === "creature") {
|
||||
const monster = await getMonster(slug);
|
||||
original = monster as unknown as Record<string, unknown>;
|
||||
} else {
|
||||
const spell = await getSpell(slug);
|
||||
original = spell as unknown as Record<string, unknown>;
|
||||
}
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
return jsonResponse(
|
||||
{ error: "Failed to fetch original content from Open5e", details: message },
|
||||
502,
|
||||
origin
|
||||
);
|
||||
}
|
||||
|
||||
let translated: Record<string, unknown>;
|
||||
try {
|
||||
translated = await translateOpen5eContent(original, type as "creature" | "spell");
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
return jsonResponse(
|
||||
{ error: "Translation failed", details: message },
|
||||
502,
|
||||
origin
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
await db.insert(translations).values({
|
||||
slug,
|
||||
type,
|
||||
language: "ru",
|
||||
content: translated,
|
||||
});
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
return jsonResponse(
|
||||
{ error: "Failed to cache translation", details: message },
|
||||
500,
|
||||
origin
|
||||
);
|
||||
}
|
||||
|
||||
return jsonResponse({ translated, cached: false }, 200, origin);
|
||||
};
|
||||
|
||||
export const OPTIONS: APIRoute = async ({ request }) => {
|
||||
const origin = getOrigin(request);
|
||||
return handleCorsPreflight(origin);
|
||||
};
|
||||
@@ -40,6 +40,56 @@ import { dmTranslations as T } from "@/i18n/dm-translations";
|
||||
</div>
|
||||
<InitiativeTracker />
|
||||
</section>
|
||||
|
||||
<!-- AI Generator -->
|
||||
<section id="ai" class="dm-tab-section">
|
||||
<div class="flex items-center gap-3 mb-3">
|
||||
<span class="w-1 h-6 bg-[var(--accent)] rounded-full" aria-hidden="true"></span>
|
||||
<h2 class="text-lg font-bold text-[var(--text-primary)]">
|
||||
{T.ai}
|
||||
</h2>
|
||||
</div>
|
||||
{Astro.locals.user ? (
|
||||
<DmCard>
|
||||
<div class="p-6 text-center text-[var(--text-secondary)]">
|
||||
<p>{T.aiPlaceholder}</p>
|
||||
</div>
|
||||
</DmCard>
|
||||
) : (
|
||||
<DmCard>
|
||||
<div class="p-6 text-center">
|
||||
<p class="text-[var(--text-secondary)] mb-4">{T.aiSignInCta}</p>
|
||||
<a
|
||||
href="/api/auth/login/vk"
|
||||
class="inline-flex items-center justify-center rounded-lg px-5 py-2.5 text-sm font-medium bg-[#0077FF] text-white hover:bg-[#0066CC] transition-colors"
|
||||
>
|
||||
{T.loginVk}
|
||||
</a>
|
||||
</div>
|
||||
</DmCard>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
{Astro.locals.user ? (
|
||||
<DmCard>
|
||||
<div class="p-6 text-center text-[var(--text-secondary)]">
|
||||
<p>{T.aiPlaceholder}</p>
|
||||
</div>
|
||||
</DmCard>
|
||||
) : (
|
||||
<DmCard>
|
||||
<div class="p-6 text-center">
|
||||
<p class="text-[var(--text-secondary)] mb-4">{T.aiSignInCta}</p>
|
||||
<a
|
||||
href="/api/auth/login/vk"
|
||||
class="inline-flex items-center justify-center rounded-lg px-5 py-2.5 text-sm font-medium bg-[#0077FF] text-white hover:bg-[#0066CC] transition-colors"
|
||||
>
|
||||
{T.loginVk}
|
||||
</a>
|
||||
</div>
|
||||
</DmCard>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<!-- Context slot (reference + notes, desktop only) -->
|
||||
|
||||
@@ -40,6 +40,27 @@ import { dmTranslations as T } from "@/i18n/dm-translations";
|
||||
</div>
|
||||
<InitiativeTracker />
|
||||
</section>
|
||||
|
||||
<!-- AI Generator -->
|
||||
<section id="ai" class="dm-tab-section">
|
||||
<div class="flex items-center gap-3 mb-3">
|
||||
<span class="w-1 h-6 bg-[var(--accent)] rounded-full" aria-hidden="true"></span>
|
||||
<h2 class="text-lg font-bold text-[var(--text-primary)]">
|
||||
{T.ai}
|
||||
</h2>
|
||||
</div>
|
||||
<DmCard>
|
||||
{Astro.locals.user ? (
|
||||
<div class="p-6 text-center text-[var(--text-secondary)]">
|
||||
<p>{T.aiPlaceholder}</p>
|
||||
</div>
|
||||
) : (
|
||||
<div class="p-6 text-center">
|
||||
<p class="text-[var(--text-secondary)]">{T.aiSignInCta}</p>
|
||||
</div>
|
||||
)}
|
||||
</DmCard>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<!-- Context slot (reference + notes, desktop only) -->
|
||||
|
||||
+2
-2
@@ -11,7 +11,7 @@ describe("CORS utility", () => {
|
||||
it("returns headers for allowed origin randify.pro", () => {
|
||||
const headers = getCorsHeaders("https://randify.pro");
|
||||
expect(headers["Access-Control-Allow-Origin"]).toBe("https://randify.pro");
|
||||
expect(headers["Access-Control-Allow-Methods"]).toBe("GET, POST, OPTIONS");
|
||||
expect(headers["Access-Control-Allow-Methods"]).toBe("GET, POST, PUT, DELETE, OPTIONS");
|
||||
expect(headers["Access-Control-Allow-Headers"]).toBe("Content-Type, Authorization");
|
||||
expect(headers["Access-Control-Allow-Credentials"]).toBe("true");
|
||||
expect(headers["Vary"]).toBe("Origin");
|
||||
@@ -48,7 +48,7 @@ describe("CORS utility", () => {
|
||||
const response = handleCorsPreflight("https://randify.pro");
|
||||
expect(response.status).toBe(204);
|
||||
expect(response.headers.get("Access-Control-Allow-Origin")).toBe("https://randify.pro");
|
||||
expect(response.headers.get("Access-Control-Allow-Methods")).toBe("GET, POST, OPTIONS");
|
||||
expect(response.headers.get("Access-Control-Allow-Methods")).toBe("GET, POST, PUT, DELETE, OPTIONS");
|
||||
expect(response.headers.get("Access-Control-Allow-Credentials")).toBe("true");
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,247 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
|
||||
let mockSelectTotal = 0;
|
||||
let mockInsertedValues: unknown[] = [];
|
||||
|
||||
const mockDb = {
|
||||
reset() {
|
||||
mockSelectTotal = 0;
|
||||
mockInsertedValues = [];
|
||||
},
|
||||
setTotal(n: number) {
|
||||
mockSelectTotal = n;
|
||||
},
|
||||
getInsertedValues() {
|
||||
return mockInsertedValues;
|
||||
},
|
||||
select: vi.fn(() => ({
|
||||
from: vi.fn(() => ({
|
||||
where: vi.fn(() => Promise.resolve([{ total: mockSelectTotal }])),
|
||||
})),
|
||||
})),
|
||||
insert: vi.fn(() => ({
|
||||
values: vi.fn((vals: unknown) => ({
|
||||
onConflictDoUpdate: vi.fn(() => {
|
||||
mockInsertedValues.push(vals);
|
||||
return Promise.resolve();
|
||||
}),
|
||||
})),
|
||||
})),
|
||||
};
|
||||
|
||||
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,
|
||||
}),
|
||||
sql: (strings: TemplateStringsArray, ...values: unknown[]) => {
|
||||
return {
|
||||
type: "sql",
|
||||
raw: strings,
|
||||
values,
|
||||
} as unknown as ReturnType<typeof actual.sql>;
|
||||
},
|
||||
getTableColumns: actual.getTableColumns,
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("@/db/client", () => ({
|
||||
db: mockDb,
|
||||
}));
|
||||
|
||||
describe("Rate Limit Service", () => {
|
||||
beforeEach(() => {
|
||||
mockDb.reset();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
async function importRateLimit() {
|
||||
const mod = await import("@/lib/rate-limit");
|
||||
return mod;
|
||||
}
|
||||
|
||||
describe("checkRateLimit", () => {
|
||||
it("allows request when under limit (free tier)", async () => {
|
||||
const { checkRateLimit } = await importRateLimit();
|
||||
mockDb.setTotal(3);
|
||||
|
||||
const result = await checkRateLimit(1, "free");
|
||||
|
||||
expect(result.allowed).toBe(true);
|
||||
expect(result.remaining).toBe(4);
|
||||
});
|
||||
|
||||
it("blocks request when at limit (free tier)", async () => {
|
||||
const { checkRateLimit } = await importRateLimit();
|
||||
mockDb.setTotal(7);
|
||||
|
||||
const result = await checkRateLimit(1, "free");
|
||||
|
||||
expect(result.allowed).toBe(false);
|
||||
expect(result.remaining).toBe(0);
|
||||
});
|
||||
|
||||
it("blocks request when over limit (free tier)", async () => {
|
||||
const { checkRateLimit } = await importRateLimit();
|
||||
mockDb.setTotal(10);
|
||||
|
||||
const result = await checkRateLimit(1, "free");
|
||||
|
||||
expect(result.allowed).toBe(false);
|
||||
expect(result.remaining).toBe(-3);
|
||||
});
|
||||
|
||||
it("allows request for pro tier even when over 100", async () => {
|
||||
const { checkRateLimit } = await importRateLimit();
|
||||
mockDb.setTotal(150);
|
||||
|
||||
const result = await checkRateLimit(1, "pro");
|
||||
|
||||
expect(result.allowed).toBe(true);
|
||||
expect(result.remaining).toBe(-50);
|
||||
});
|
||||
|
||||
it("resetAt is top of next hour", async () => {
|
||||
const { checkRateLimit } = await importRateLimit();
|
||||
mockDb.setTotal(0);
|
||||
|
||||
const result = await checkRateLimit(1, "free");
|
||||
const now = new Date();
|
||||
const expectedReset = new Date(
|
||||
now.getFullYear(),
|
||||
now.getMonth(),
|
||||
now.getDate(),
|
||||
now.getHours() + 1,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
);
|
||||
|
||||
expect(result.resetAt.getTime()).toBe(expectedReset.getTime());
|
||||
});
|
||||
});
|
||||
|
||||
describe("getRemainingQuota", () => {
|
||||
it("returns remaining quota for free tier", async () => {
|
||||
const { getRemainingQuota } = await importRateLimit();
|
||||
mockDb.setTotal(2);
|
||||
|
||||
const result = await getRemainingQuota(1, "free");
|
||||
|
||||
expect(result.remaining).toBe(5);
|
||||
});
|
||||
|
||||
it("returns remaining quota for pro tier", async () => {
|
||||
const { getRemainingQuota } = await importRateLimit();
|
||||
mockDb.setTotal(50);
|
||||
|
||||
const result = await getRemainingQuota(1, "pro");
|
||||
|
||||
expect(result.remaining).toBe(50);
|
||||
});
|
||||
|
||||
it("resetAt is top of next hour", async () => {
|
||||
const { getRemainingQuota } = await importRateLimit();
|
||||
mockDb.setTotal(0);
|
||||
|
||||
const result = await getRemainingQuota(1, "free");
|
||||
const now = new Date();
|
||||
const expectedReset = new Date(
|
||||
now.getFullYear(),
|
||||
now.getMonth(),
|
||||
now.getDate(),
|
||||
now.getHours() + 1,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
);
|
||||
|
||||
expect(result.resetAt.getTime()).toBe(expectedReset.getTime());
|
||||
});
|
||||
});
|
||||
|
||||
describe("incrementGenerationCounter", () => {
|
||||
it("inserts counter values with model", async () => {
|
||||
const { incrementGenerationCounter } = await importRateLimit();
|
||||
await incrementGenerationCounter(1, "moonshot-v1-8k");
|
||||
|
||||
expect(mockDb.insert).toHaveBeenCalledTimes(1);
|
||||
const valuesCall = (mockDb.insert.mock.results[0].value as ReturnType<typeof mockDb.insert>).values;
|
||||
expect(valuesCall).toHaveBeenCalledTimes(1);
|
||||
const inserted = valuesCall.mock.calls[0][0] as {
|
||||
userId: number;
|
||||
model: string;
|
||||
count: number;
|
||||
};
|
||||
expect(inserted.userId).toBe(1);
|
||||
expect(inserted.model).toBe("moonshot-v1-8k");
|
||||
expect(inserted.count).toBe(1);
|
||||
});
|
||||
|
||||
it("calls onConflictDoUpdate for upsert", async () => {
|
||||
const { incrementGenerationCounter } = await importRateLimit();
|
||||
await incrementGenerationCounter(2, "llama-3.3-70b:free");
|
||||
|
||||
const valuesResult = (mockDb.insert.mock.results[0].value as ReturnType<typeof mockDb.insert>).values;
|
||||
const onConflictCall = (valuesResult.mock.results[0].value as { onConflictDoUpdate: ReturnType<typeof vi.fn> }).onConflictDoUpdate;
|
||||
expect(onConflictCall).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getRetryAfterSeconds", () => {
|
||||
it("returns positive seconds until next hour", async () => {
|
||||
const { getRetryAfterSeconds } = await importRateLimit();
|
||||
const seconds = getRetryAfterSeconds();
|
||||
expect(seconds).toBeGreaterThan(0);
|
||||
expect(seconds).toBeLessThanOrEqual(3600);
|
||||
});
|
||||
|
||||
it("returns 3600 at exact hour boundary", async () => {
|
||||
const { getRetryAfterSeconds } = await importRateLimit();
|
||||
const now = new Date();
|
||||
const msToNextHour =
|
||||
new Date(
|
||||
now.getFullYear(),
|
||||
now.getMonth(),
|
||||
now.getDate(),
|
||||
now.getHours() + 1,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
).getTime() - now.getTime();
|
||||
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date(Date.now() + msToNextHour));
|
||||
expect(getRetryAfterSeconds()).toBe(3600);
|
||||
vi.useRealTimers();
|
||||
});
|
||||
});
|
||||
|
||||
describe("tier differentiation", () => {
|
||||
it("free limit is 7", async () => {
|
||||
const { checkRateLimit, TIER_LIMITS } = await importRateLimit();
|
||||
expect(TIER_LIMITS.free).toBe(7);
|
||||
mockDb.setTotal(6);
|
||||
const result = await checkRateLimit(1, "free");
|
||||
expect(result.allowed).toBe(true);
|
||||
expect(result.remaining).toBe(1);
|
||||
});
|
||||
|
||||
it("pro limit is 100", async () => {
|
||||
const { checkRateLimit, TIER_LIMITS } = await importRateLimit();
|
||||
expect(TIER_LIMITS.pro).toBe(100);
|
||||
mockDb.setTotal(99);
|
||||
const result = await checkRateLimit(1, "pro");
|
||||
expect(result.allowed).toBe(true);
|
||||
expect(result.remaining).toBe(1);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,275 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
function createMockDb() {
|
||||
let translationsStore: Array<{
|
||||
id: number;
|
||||
slug: string;
|
||||
type: string;
|
||||
language: string;
|
||||
content: Record<string, unknown> | null;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
}> = [];
|
||||
let nextId = 1;
|
||||
|
||||
function evaluateCondition(item: typeof translationsStore[0], 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 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() {
|
||||
translationsStore = [];
|
||||
nextId = 1;
|
||||
},
|
||||
select: vi.fn(() => ({
|
||||
from: vi.fn(() => ({
|
||||
where: vi.fn((condition: unknown) => ({
|
||||
orderBy: vi.fn(() => {
|
||||
const filtered = translationsStore.filter((item) => evaluateCondition(item, condition));
|
||||
return Promise.resolve(filtered.sort((a, b) => a.updatedAt.getTime() - b.updatedAt.getTime()));
|
||||
}),
|
||||
})),
|
||||
orderBy: vi.fn(() => Promise.resolve(translationsStore)),
|
||||
})),
|
||||
})),
|
||||
insert: vi.fn(() => ({
|
||||
values: vi.fn((vals: { slug: string; type: string; language: string; content: Record<string, unknown> | null }) => ({
|
||||
returning: vi.fn(() => {
|
||||
const row = {
|
||||
id: nextId++,
|
||||
...vals,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
};
|
||||
translationsStore.push(row);
|
||||
return Promise.resolve([row]);
|
||||
}),
|
||||
})),
|
||||
})),
|
||||
_store: translationsStore,
|
||||
_setStore(store: typeof translationsStore) {
|
||||
translationsStore = store;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
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 }),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("../src/db/client", () => ({
|
||||
db: mockDb,
|
||||
}));
|
||||
|
||||
const mockMonster = {
|
||||
name: "Goblin",
|
||||
key: "goblin",
|
||||
challenge_rating_decimal: "1/4",
|
||||
type: "humanoid",
|
||||
hit_points: 7,
|
||||
armor_class: 15,
|
||||
};
|
||||
|
||||
const mockSpell = {
|
||||
name: "Fireball",
|
||||
key: "fireball",
|
||||
level: 3,
|
||||
school: "evocation",
|
||||
};
|
||||
|
||||
const mockTranslatedMonster = {
|
||||
name: "Гоблин",
|
||||
key: "goblin",
|
||||
challenge_rating_decimal: "1/4",
|
||||
type: "гуманоид",
|
||||
hit_points: 7,
|
||||
armor_class: 15,
|
||||
};
|
||||
|
||||
const mockTranslatedSpell = {
|
||||
name: "Огненный шар",
|
||||
key: "fireball",
|
||||
level: 3,
|
||||
school: "эвокация",
|
||||
};
|
||||
|
||||
vi.mock("../src/lib/open5e/client", () => ({
|
||||
getMonster: vi.fn(async (slug: string) => {
|
||||
if (slug === "goblin") return mockMonster;
|
||||
throw new Error("Monster not found");
|
||||
}),
|
||||
getSpell: vi.fn(async (slug: string) => {
|
||||
if (slug === "fireball") return mockSpell;
|
||||
throw new Error("Spell not found");
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("../src/lib/ai/openrouter", () => ({
|
||||
translateOpen5eContent: vi.fn(async (_content: Record<string, unknown>, type: string) => {
|
||||
if (type === "creature") return mockTranslatedMonster;
|
||||
if (type === "spell") return mockTranslatedSpell;
|
||||
throw new Error("Unknown type");
|
||||
}),
|
||||
}));
|
||||
|
||||
describe("Translate API", () => {
|
||||
beforeEach(() => {
|
||||
mockDb.reset();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("OPTIONS returns 204 with CORS headers", async () => {
|
||||
const { OPTIONS } = await import("../src/pages/api/dm/translate");
|
||||
const request = mockRequest("https://dm.randify.pro/api/dm/translate?slug=goblin&type=creature", "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("returns 400 when slug is missing", async () => {
|
||||
const { GET } = await import("../src/pages/api/dm/translate");
|
||||
const request = mockRequest("https://dm.randify.pro/api/dm/translate?type=creature", "https://randify.pro");
|
||||
const response = await GET!({ request, locals: { user: null }, url: new URL(request.url), ...({} as any) });
|
||||
expect(response.status).toBe(400);
|
||||
const body = await response.json();
|
||||
expect(body.error).toContain("Missing required query parameters");
|
||||
});
|
||||
|
||||
it("returns 400 when type is missing", async () => {
|
||||
const { GET } = await import("../src/pages/api/dm/translate");
|
||||
const request = mockRequest("https://dm.randify.pro/api/dm/translate?slug=goblin", "https://randify.pro");
|
||||
const response = await GET!({ request, locals: { user: null }, url: new URL(request.url), ...({} as any) });
|
||||
expect(response.status).toBe(400);
|
||||
const body = await response.json();
|
||||
expect(body.error).toContain("Missing required query parameters");
|
||||
});
|
||||
|
||||
it("returns 400 for invalid type", async () => {
|
||||
const { GET } = await import("../src/pages/api/dm/translate");
|
||||
const request = mockRequest("https://dm.randify.pro/api/dm/translate?slug=goblin&type=invalid", "https://randify.pro");
|
||||
const response = await GET!({ request, locals: { user: null }, url: new URL(request.url), ...({} as any) });
|
||||
expect(response.status).toBe(400);
|
||||
const body = await response.json();
|
||||
expect(body.error).toContain("Invalid type");
|
||||
});
|
||||
|
||||
it("returns cached translation on cache hit (creature)", async () => {
|
||||
const { GET } = await import("../src/pages/api/dm/translate");
|
||||
mockDb._setStore([
|
||||
{ id: 1, slug: "goblin", type: "creature", language: "ru", content: mockTranslatedMonster, createdAt: new Date(), updatedAt: new Date() },
|
||||
]);
|
||||
const request = mockRequest("https://dm.randify.pro/api/dm/translate?slug=goblin&type=creature", "https://randify.pro");
|
||||
const response = await GET!({ request, locals: { user: null }, url: new URL(request.url), ...({} as any) });
|
||||
expect(response.status).toBe(200);
|
||||
const body = await response.json();
|
||||
expect(body.cached).toBe(true);
|
||||
expect(body.translated).toEqual(mockTranslatedMonster);
|
||||
});
|
||||
|
||||
it("returns cached translation on cache hit (spell)", async () => {
|
||||
const { GET } = await import("../src/pages/api/dm/translate");
|
||||
mockDb._setStore([
|
||||
{ id: 1, slug: "fireball", type: "spell", language: "ru", content: mockTranslatedSpell, createdAt: new Date(), updatedAt: new Date() },
|
||||
]);
|
||||
const request = mockRequest("https://dm.randify.pro/api/dm/translate?slug=fireball&type=spell", "https://randify.pro");
|
||||
const response = await GET!({ request, locals: { user: null }, url: new URL(request.url), ...({} as any) });
|
||||
expect(response.status).toBe(200);
|
||||
const body = await response.json();
|
||||
expect(body.cached).toBe(true);
|
||||
expect(body.translated).toEqual(mockTranslatedSpell);
|
||||
});
|
||||
|
||||
it("fetches, translates, caches, and returns on cache miss (creature)", async () => {
|
||||
const { getMonster } = await import("../src/lib/open5e/client");
|
||||
const { translateOpen5eContent } = await import("../src/lib/ai/openrouter");
|
||||
const { GET } = await import("../src/pages/api/dm/translate");
|
||||
|
||||
const request = mockRequest("https://dm.randify.pro/api/dm/translate?slug=goblin&type=creature", "https://randify.pro");
|
||||
const response = await GET!({ request, locals: { user: null }, url: new URL(request.url), ...({} as any) });
|
||||
expect(response.status).toBe(200);
|
||||
const body = await response.json();
|
||||
expect(body.cached).toBe(false);
|
||||
expect(body.translated).toEqual(mockTranslatedMonster);
|
||||
expect(getMonster).toHaveBeenCalledWith("goblin");
|
||||
expect(translateOpen5eContent).toHaveBeenCalledWith(mockMonster, "creature");
|
||||
});
|
||||
|
||||
it("fetches, translates, caches, and returns on cache miss (spell)", async () => {
|
||||
const { getSpell } = await import("../src/lib/open5e/client");
|
||||
const { translateOpen5eContent } = await import("../src/lib/ai/openrouter");
|
||||
const { GET } = await import("../src/pages/api/dm/translate");
|
||||
|
||||
const request = mockRequest("https://dm.randify.pro/api/dm/translate?slug=fireball&type=spell", "https://randify.pro");
|
||||
const response = await GET!({ request, locals: { user: null }, url: new URL(request.url), ...({} as any) });
|
||||
expect(response.status).toBe(200);
|
||||
const body = await response.json();
|
||||
expect(body.cached).toBe(false);
|
||||
expect(body.translated).toEqual(mockTranslatedSpell);
|
||||
expect(getSpell).toHaveBeenCalledWith("fireball");
|
||||
expect(translateOpen5eContent).toHaveBeenCalledWith(mockSpell, "spell");
|
||||
});
|
||||
|
||||
it("returns 502 when Open5e fetch fails", async () => {
|
||||
const { GET } = await import("../src/pages/api/dm/translate");
|
||||
const request = mockRequest("https://dm.randify.pro/api/dm/translate?slug=unknown&type=creature", "https://randify.pro");
|
||||
const response = await GET!({ request, locals: { user: null }, url: new URL(request.url), ...({} as any) });
|
||||
expect(response.status).toBe(502);
|
||||
const body = await response.json();
|
||||
expect(body.error).toBe("Failed to fetch original content from Open5e");
|
||||
expect(body.details).toContain("Monster not found");
|
||||
});
|
||||
|
||||
it("returns 502 when translation API fails", async () => {
|
||||
const { translateOpen5eContent } = await import("../src/lib/ai/openrouter");
|
||||
vi.mocked(translateOpen5eContent).mockRejectedValueOnce(new Error("OpenRouter rate limit"));
|
||||
|
||||
const { GET } = await import("../src/pages/api/dm/translate");
|
||||
const request = mockRequest("https://dm.randify.pro/api/dm/translate?slug=goblin&type=creature", "https://randify.pro");
|
||||
const response = await GET!({ request, locals: { user: null }, url: new URL(request.url), ...({} as any) });
|
||||
expect(response.status).toBe(502);
|
||||
const body = await response.json();
|
||||
expect(body.error).toBe("Translation failed");
|
||||
expect(body.details).toContain("OpenRouter rate limit");
|
||||
});
|
||||
|
||||
it("does not require authentication", async () => {
|
||||
const { GET } = await import("../src/pages/api/dm/translate");
|
||||
mockDb._setStore([
|
||||
{ id: 1, slug: "goblin", type: "creature", language: "ru", content: mockTranslatedMonster, createdAt: new Date(), updatedAt: new Date() },
|
||||
]);
|
||||
const request = mockRequest("https://dm.randify.pro/api/dm/translate?slug=goblin&type=creature", "https://randify.pro");
|
||||
const response = await GET!({ request, locals: { user: null }, url: new URL(request.url), ...({} as any) });
|
||||
expect(response.status).toBe(200);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user