feat(dm-dashboard): Wave 4 — rate limiting, translation API, AI tab navigation

This commit is contained in:
emil
2026-05-15 22:26:41 +03:00
parent 5aec915034
commit 2921e45237
17 changed files with 1084 additions and 16 deletions
+2 -2
View File
@@ -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");
});
+247
View File
@@ -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);
});
});
});
+275
View File
@@ -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);
});
});