feat(dm-dashboard): Wave 5 — NPC generation API, history API, quota/tier badges, translate button
This commit is contained in:
@@ -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