1404 lines
33 KiB
TypeScript
1404 lines
33 KiB
TypeScript
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
import { PERMS } from "@/lib/permission-slugs";
|
|
import type { HousekeepingCapabilityContext } from "../../../foundation/contracts";
|
|
import { CONTENT_ROUTE_IDS } from "../routes";
|
|
import {
|
|
type ContentQueryData,
|
|
type ContentQueryInput,
|
|
createContentQuery,
|
|
type NormalizedContentQueryInput,
|
|
} from "./content-queries";
|
|
import {
|
|
CONTENT_QUERY_DEFINITIONS,
|
|
loadProductionContentQuery,
|
|
} from "./content-queries-production";
|
|
|
|
const queryMocks = vi.hoisted(() => ({
|
|
execute: vi.fn(),
|
|
join: vi.fn((chunks: unknown[], separator: unknown) => ({
|
|
chunks,
|
|
separator,
|
|
})),
|
|
raw: vi.fn((statement: string) => ({ statement })),
|
|
tagged: vi.fn((strings: TemplateStringsArray, ...values: unknown[]) => ({
|
|
strings: Array.from(strings),
|
|
values,
|
|
})),
|
|
}));
|
|
vi.mock("drizzle-orm", () => ({
|
|
sql: Object.assign(queryMocks.tagged, {
|
|
join: queryMocks.join,
|
|
raw: queryMocks.raw,
|
|
}),
|
|
}));
|
|
vi.mock("@/lib/db", () => ({ db: { execute: queryMocks.execute } }));
|
|
|
|
function context(granted: readonly string[]): HousekeepingCapabilityContext {
|
|
const permissions = new Set(granted);
|
|
return {
|
|
actor: { id: 42, username: "operator", rank: 7 },
|
|
isSuperAdmin: false,
|
|
has: (slug) => permissions.has(slug),
|
|
hasAny: (...slugs) => slugs.some((slug) => permissions.has(slug)),
|
|
hasAll: (...slugs) => slugs.every((slug) => permissions.has(slug)),
|
|
};
|
|
}
|
|
|
|
const ready: ContentQueryData = {
|
|
kind: "editorial",
|
|
items: [
|
|
{
|
|
id: "18446744073709551615",
|
|
title: "Published article",
|
|
status: "published",
|
|
href: "/ase-next/content/editorial/articles/18446744073709551615",
|
|
},
|
|
],
|
|
total: 1,
|
|
partialDependencies: [],
|
|
};
|
|
const normalizedResponsePage = {
|
|
responseQuestionId: "",
|
|
responsePageSize: 25,
|
|
responseOffset: 0,
|
|
} as const;
|
|
|
|
const pollDetail = {
|
|
kind: "poll-detail" as const,
|
|
description: "Complete poll",
|
|
showResults: true,
|
|
multipleChoice: false,
|
|
startsAt: "2026-09-02T18:00:00.000Z",
|
|
endsAt: null,
|
|
questionCount: 1,
|
|
voterCount: 2,
|
|
answerCount: 2,
|
|
questions: [
|
|
{
|
|
id: "21",
|
|
question: "Favourite colour?",
|
|
type: "single" as const,
|
|
sortOrder: 0,
|
|
options: ["Red", "Blue"],
|
|
answerCount: 2,
|
|
choiceResults: [{ option: "Red", count: 2 }],
|
|
},
|
|
],
|
|
textResponses: {
|
|
questionId: null,
|
|
items: [],
|
|
total: 0,
|
|
pageSize: 25,
|
|
offset: 0,
|
|
},
|
|
};
|
|
describe("Content query", () => {
|
|
beforeEach(() => queryMocks.execute.mockReset());
|
|
|
|
it("reports totals beyond the former 500-row adapter cap", async () => {
|
|
const pageRows = Array.from({ length: 25 }, (_, index) => ({
|
|
id: index + 1,
|
|
title: `Article ${index + 1}`,
|
|
status: "published",
|
|
updated_at: null,
|
|
}));
|
|
queryMocks.execute
|
|
.mockResolvedValueOnce([[{ total: 600 }]])
|
|
.mockResolvedValueOnce([pageRows]);
|
|
const result = await loadProductionContentQuery({
|
|
routeId: "content.editorial.articles",
|
|
params: {},
|
|
list: { ...normalizedResponsePage, search: "", pageSize: 25, offset: 0 },
|
|
});
|
|
expect(result.total).toBe(600);
|
|
expect(result.items).toHaveLength(25);
|
|
expect(queryMocks.execute).toHaveBeenCalledTimes(2);
|
|
const pageQuery = queryMocks.execute.mock.calls[1]?.[0];
|
|
expect(JSON.stringify(pageQuery)).toContain("25");
|
|
expect(JSON.stringify(pageQuery)).toContain("0");
|
|
});
|
|
|
|
it("binds search, limit, and offset into the bounded page query", async () => {
|
|
queryMocks.execute
|
|
.mockResolvedValueOnce([[{ total: 1 }]])
|
|
.mockResolvedValueOnce([
|
|
[{ id: 9, title: "Launch", status: "published", updated_at: null }],
|
|
]);
|
|
await loadProductionContentQuery({
|
|
routeId: "content.editorial.articles",
|
|
params: {},
|
|
list: {
|
|
...normalizedResponsePage,
|
|
search: "Launch",
|
|
pageSize: 7,
|
|
offset: 14,
|
|
},
|
|
});
|
|
const serialized = JSON.stringify(queryMocks.execute.mock.calls);
|
|
expect(serialized).toContain("%launch%");
|
|
expect(serialized).toContain("7");
|
|
expect(serialized).toContain("14");
|
|
});
|
|
|
|
it("searches event titles and projected event-type descriptions", async () => {
|
|
queryMocks.execute
|
|
.mockResolvedValueOnce([[{ total: 0 }]])
|
|
.mockResolvedValueOnce([[]]);
|
|
await loadProductionContentQuery({
|
|
routeId: "content.engagement.events",
|
|
params: {},
|
|
list: {
|
|
...normalizedResponsePage,
|
|
search: "launch",
|
|
pageSize: 25,
|
|
offset: 0,
|
|
},
|
|
});
|
|
const serialized = JSON.stringify(queryMocks.execute.mock.calls.slice(-2));
|
|
expect(serialized).toContain("COALESCE(title");
|
|
expect(serialized).toContain("COALESCE(description");
|
|
});
|
|
|
|
it("never selects email template bodies into summary query results", () => {
|
|
const emailTemplates =
|
|
CONTENT_QUERY_DEFINITIONS["content.help.email-templates"];
|
|
expect(emailTemplates.statement).not.toMatch(/\bbody\b|private_payload/iu);
|
|
});
|
|
|
|
it("loads the editable payload only for an individual article", async () => {
|
|
queryMocks.execute
|
|
.mockResolvedValueOnce([[{ total: 1 }]])
|
|
.mockResolvedValueOnce([
|
|
[
|
|
{
|
|
id: 7,
|
|
title: "Launch day",
|
|
description: "launch-day",
|
|
status: "published",
|
|
updated_at: null,
|
|
slug: "launch-day",
|
|
image: "/media/launch.webp",
|
|
short_story: "Short launch summary",
|
|
full_story: "<p>Complete launch story</p>",
|
|
},
|
|
],
|
|
]);
|
|
|
|
const result = await loadProductionContentQuery({
|
|
routeId: "content.editorial.article-detail",
|
|
params: { id: "7" },
|
|
list: { ...normalizedResponsePage, search: "", pageSize: 25, offset: 0 },
|
|
});
|
|
|
|
expect(result.items[0]?.privatePayload).toEqual({
|
|
slug: "launch-day",
|
|
image: "/media/launch.webp",
|
|
shortStory: "Short launch summary",
|
|
fullStory: "<p>Complete launch story</p>",
|
|
});
|
|
expect(
|
|
CONTENT_QUERY_DEFINITIONS["content.editorial.articles"].statement,
|
|
).not.toMatch(/short_story|full_story/iu);
|
|
});
|
|
it("loads event summaries with operational metadata and a bound status filter", async () => {
|
|
queryMocks.execute
|
|
.mockResolvedValueOnce([[{ total: 1 }]])
|
|
.mockResolvedValueOnce([
|
|
[
|
|
{
|
|
id: 11,
|
|
title: "Battle Ball",
|
|
status: "published",
|
|
updated_at: new Date("2026-09-01T08:00:00.000Z"),
|
|
type_id: 2,
|
|
type_name: "Tournament",
|
|
type_color: "#ff9900",
|
|
starts_at: new Date("2026-09-02T18:00:00.000Z"),
|
|
ends_at: new Date("2026-09-02T19:00:00.000Z"),
|
|
max_players: 20,
|
|
registration_count: 12,
|
|
},
|
|
],
|
|
]);
|
|
|
|
const result = await loadProductionContentQuery({
|
|
routeId: "content.engagement.events",
|
|
params: {},
|
|
list: {
|
|
...normalizedResponsePage,
|
|
search: "",
|
|
status: "published",
|
|
pageSize: 25,
|
|
offset: 0,
|
|
},
|
|
});
|
|
|
|
expect(result.items[0]?.privatePayload).toEqual({
|
|
kind: "event-summary",
|
|
typeId: "2",
|
|
typeName: "Tournament",
|
|
typeColor: "#ff9900",
|
|
startsAt: "2026-09-02T18:00:00.000Z",
|
|
endsAt: "2026-09-02T19:00:00.000Z",
|
|
maxPlayers: 20,
|
|
registrationCount: 12,
|
|
});
|
|
expect(JSON.stringify(queryMocks.execute.mock.calls)).toContain(
|
|
"published",
|
|
);
|
|
});
|
|
|
|
it("loads active event types for event creation", async () => {
|
|
queryMocks.execute.mockResolvedValueOnce([
|
|
[
|
|
{
|
|
id: 2,
|
|
name: "Tournament",
|
|
slug: "tournament",
|
|
description: "Ranked competition",
|
|
color: "#ff9900",
|
|
icon: "trophy",
|
|
is_active: 1,
|
|
min_rank: 1,
|
|
},
|
|
],
|
|
]);
|
|
|
|
const result = await loadProductionContentQuery({
|
|
routeId: "content.engagement.event-create",
|
|
params: {},
|
|
list: { ...normalizedResponsePage, search: "", pageSize: 25, offset: 0 },
|
|
});
|
|
|
|
expect(result.items[0]).toMatchObject({
|
|
id: "2",
|
|
title: "Tournament",
|
|
privatePayload: {
|
|
kind: "event-type",
|
|
slug: "tournament",
|
|
isActive: true,
|
|
},
|
|
});
|
|
});
|
|
it("loads bounded poll summaries with operator counts and bound filters", async () => {
|
|
queryMocks.execute
|
|
.mockResolvedValueOnce([[{ total: 1 }]])
|
|
.mockResolvedValueOnce([
|
|
[
|
|
{
|
|
id: 7,
|
|
title: "Autumn plans",
|
|
status: "active",
|
|
updated_at: new Date("2026-09-01T08:00:00.000Z"),
|
|
show_results: 1,
|
|
multiple_choice: 0,
|
|
starts_at: new Date("2026-09-02T18:00:00.000Z"),
|
|
ends_at: null,
|
|
question_count: 3,
|
|
voter_count: 12,
|
|
answer_count: 30,
|
|
},
|
|
],
|
|
]);
|
|
|
|
const result = await loadProductionContentQuery({
|
|
routeId: "content.engagement.polls",
|
|
params: {},
|
|
list: {
|
|
...normalizedResponsePage,
|
|
search: "Autumn",
|
|
status: "active",
|
|
pageSize: 25,
|
|
offset: 0,
|
|
},
|
|
});
|
|
|
|
expect(result.items[0]?.privatePayload).toEqual({
|
|
kind: "poll-summary",
|
|
showResults: true,
|
|
multipleChoice: false,
|
|
startsAt: "2026-09-02T18:00:00.000Z",
|
|
endsAt: null,
|
|
questionCount: 3,
|
|
voterCount: 12,
|
|
answerCount: 30,
|
|
});
|
|
const sql = JSON.stringify(queryMocks.execute.mock.calls);
|
|
expect(sql).toContain("%autumn%");
|
|
expect(sql).toContain("active");
|
|
});
|
|
|
|
it("returns the stable poll-create payload without database access", async () => {
|
|
const result = await loadProductionContentQuery({
|
|
routeId: "content.engagement.poll-create",
|
|
params: {},
|
|
list: { ...normalizedResponsePage, search: "", pageSize: 25, offset: 0 },
|
|
});
|
|
|
|
expect(result).toEqual({
|
|
kind: "engagement",
|
|
items: [
|
|
{
|
|
id: "create",
|
|
title: "Create poll",
|
|
href: "/ase-next/content/engagement/polls/create",
|
|
privatePayload: { kind: "poll-create" },
|
|
},
|
|
],
|
|
total: 1,
|
|
partialDependencies: [],
|
|
});
|
|
expect(queryMocks.execute).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("loads selected poll results from bounded grouped aggregates and response pages", async () => {
|
|
queryMocks.execute
|
|
.mockResolvedValueOnce([
|
|
[
|
|
{
|
|
id: 7,
|
|
title: "Autumn plans",
|
|
description: "Complete poll",
|
|
status: "active",
|
|
updated_at: new Date("2026-09-01T08:00:00.000Z"),
|
|
show_results: 1,
|
|
multiple_choice: 0,
|
|
starts_at: new Date("2026-09-02T18:00:00.000Z"),
|
|
ends_at: null,
|
|
question_count: 2,
|
|
voter_count: 12,
|
|
answer_count: 30,
|
|
},
|
|
],
|
|
])
|
|
.mockResolvedValueOnce([
|
|
[
|
|
{
|
|
id: 21,
|
|
question: "Favourite colour?",
|
|
type: "single",
|
|
sort_order: 0,
|
|
options: "Red\nBlue",
|
|
},
|
|
{
|
|
id: 22,
|
|
question: "What should improve?",
|
|
type: "text",
|
|
sort_order: 1,
|
|
options: "",
|
|
},
|
|
],
|
|
])
|
|
.mockResolvedValueOnce([
|
|
[
|
|
{ question_id: 21, answer: "Red", answer_count: 3 },
|
|
{ question_id: 21, answer: "Blue", answer_count: 1 },
|
|
],
|
|
])
|
|
.mockResolvedValueOnce([[{ total: 51 }]])
|
|
.mockResolvedValueOnce([
|
|
[
|
|
{
|
|
id: 1,
|
|
question_id: 22,
|
|
user_id: 9,
|
|
username: "Alice",
|
|
answer: "More events",
|
|
created_at: new Date("2026-09-02T20:00:00.000Z"),
|
|
},
|
|
{
|
|
id: 2,
|
|
question_id: 22,
|
|
user_id: 10,
|
|
username: null,
|
|
answer: "Better prizes",
|
|
created_at: new Date("2026-09-02T19:00:00.000Z"),
|
|
},
|
|
],
|
|
]);
|
|
|
|
const result = await loadProductionContentQuery({
|
|
routeId: "content.engagement.poll-detail",
|
|
params: { id: "7" },
|
|
list: {
|
|
...normalizedResponsePage,
|
|
search: "",
|
|
pageSize: 25,
|
|
offset: 0,
|
|
responseQuestionId: "22",
|
|
responsePageSize: 25,
|
|
responseOffset: 25,
|
|
},
|
|
});
|
|
|
|
expect(result.items[0]?.privatePayload).toMatchObject({
|
|
kind: "poll-detail",
|
|
questions: [
|
|
{
|
|
id: "21",
|
|
options: ["Red", "Blue"],
|
|
answerCount: 4,
|
|
choiceResults: [
|
|
{ option: "Red", count: 3 },
|
|
{ option: "Blue", count: 1 },
|
|
],
|
|
},
|
|
{
|
|
id: "22",
|
|
type: "text",
|
|
options: [],
|
|
answerCount: 0,
|
|
choiceResults: [],
|
|
},
|
|
],
|
|
textResponses: {
|
|
questionId: "22",
|
|
total: 51,
|
|
pageSize: 25,
|
|
offset: 25,
|
|
items: [
|
|
{ userId: "9", username: "Alice", answer: "More events" },
|
|
{ userId: "10", username: null, answer: "Better prizes" },
|
|
],
|
|
},
|
|
});
|
|
const sql = JSON.stringify(queryMocks.execute.mock.calls);
|
|
expect(sql).toContain("LIMIT 101");
|
|
expect(sql).toContain("LIMIT 10001");
|
|
expect(sql).toContain("7");
|
|
expect(sql).toContain("22");
|
|
expect(sql).toContain("25");
|
|
expect(queryMocks.execute).toHaveBeenCalledTimes(5);
|
|
});
|
|
|
|
it("rejects a poll detail with 101 questions before loading results", async () => {
|
|
queryMocks.execute
|
|
.mockResolvedValueOnce([
|
|
[{ id: 7, title: "Oversized poll", status: "draft" }],
|
|
])
|
|
.mockResolvedValueOnce([
|
|
Array.from({ length: 101 }, (_, index) => ({
|
|
id: index + 1,
|
|
question: `Question ${index + 1}`,
|
|
type: "single",
|
|
sort_order: index,
|
|
options: "Yes\nNo",
|
|
})),
|
|
]);
|
|
|
|
await expect(
|
|
loadProductionContentQuery({
|
|
routeId: "content.engagement.poll-detail",
|
|
params: { id: "7" },
|
|
list: {
|
|
...normalizedResponsePage,
|
|
search: "",
|
|
pageSize: 25,
|
|
offset: 0,
|
|
},
|
|
}),
|
|
).rejects.toThrow("poll question limit");
|
|
expect(queryMocks.execute).toHaveBeenCalledTimes(2);
|
|
});
|
|
|
|
it("rejects 10001 grouped poll result rows before aggregating them", async () => {
|
|
queryMocks.execute
|
|
.mockResolvedValueOnce([
|
|
[{ id: 7, title: "Oversized results", status: "active" }],
|
|
])
|
|
.mockResolvedValueOnce([
|
|
[
|
|
{
|
|
id: 21,
|
|
question: "Favourite colour?",
|
|
type: "single",
|
|
sort_order: 0,
|
|
options: "Red\nBlue",
|
|
},
|
|
],
|
|
])
|
|
.mockResolvedValueOnce([
|
|
Array.from({ length: 10_001 }, (_, index) => ({
|
|
question_id: 21,
|
|
answer: `Choice ${index + 1}`,
|
|
answer_count: 1,
|
|
})),
|
|
]);
|
|
|
|
await expect(
|
|
loadProductionContentQuery({
|
|
routeId: "content.engagement.poll-detail",
|
|
params: { id: "7" },
|
|
list: {
|
|
...normalizedResponsePage,
|
|
search: "",
|
|
pageSize: 25,
|
|
offset: 0,
|
|
},
|
|
}),
|
|
).rejects.toThrow("poll aggregate limit");
|
|
expect(queryMocks.execute).toHaveBeenCalledTimes(3);
|
|
});
|
|
|
|
it("rejects a poll question with more than 100 parsed options", async () => {
|
|
queryMocks.execute
|
|
.mockResolvedValueOnce([
|
|
[{ id: 7, title: "Oversized options", status: "draft" }],
|
|
])
|
|
.mockResolvedValueOnce([
|
|
[
|
|
{
|
|
id: 21,
|
|
question: "Choose one",
|
|
type: "single",
|
|
sort_order: 0,
|
|
options: Array.from(
|
|
{ length: 101 },
|
|
(_, index) => `Option ${index + 1}`,
|
|
).join("\n"),
|
|
},
|
|
],
|
|
]);
|
|
|
|
await expect(
|
|
loadProductionContentQuery({
|
|
routeId: "content.engagement.poll-detail",
|
|
params: { id: "7" },
|
|
list: {
|
|
...normalizedResponsePage,
|
|
search: "",
|
|
pageSize: 25,
|
|
offset: 0,
|
|
},
|
|
}),
|
|
).rejects.toThrow("poll option limit");
|
|
expect(queryMocks.execute).toHaveBeenCalledTimes(2);
|
|
});
|
|
|
|
it.each([
|
|
["a foreign question", "999"],
|
|
["a non-text question", "21"],
|
|
])(
|
|
"falls back to the first text question for %s",
|
|
async (_label, requestedId) => {
|
|
queryMocks.execute
|
|
.mockResolvedValueOnce([
|
|
[{ id: 7, title: "Response fallback", status: "active" }],
|
|
])
|
|
.mockResolvedValueOnce([
|
|
[
|
|
{
|
|
id: 21,
|
|
question: "Favourite colour?",
|
|
type: "single",
|
|
sort_order: 0,
|
|
options: "Red\nBlue",
|
|
},
|
|
{
|
|
id: 22,
|
|
question: "What should improve?",
|
|
type: "text",
|
|
sort_order: 1,
|
|
options: "",
|
|
},
|
|
],
|
|
])
|
|
.mockResolvedValueOnce([[]])
|
|
.mockResolvedValueOnce([[{ total: 0 }]])
|
|
.mockResolvedValueOnce([[]]);
|
|
|
|
const result = await loadProductionContentQuery({
|
|
routeId: "content.engagement.poll-detail",
|
|
params: { id: "7" },
|
|
list: {
|
|
...normalizedResponsePage,
|
|
search: "",
|
|
pageSize: 25,
|
|
offset: 0,
|
|
responseQuestionId: requestedId,
|
|
},
|
|
});
|
|
|
|
expect(result.items[0]?.privatePayload).toMatchObject({
|
|
textResponses: { questionId: "22" },
|
|
});
|
|
expect(JSON.stringify(queryMocks.execute.mock.calls.slice(3))).toContain(
|
|
"22",
|
|
);
|
|
expect(queryMocks.execute).toHaveBeenCalledTimes(5);
|
|
},
|
|
);
|
|
|
|
it("returns a valid empty detail result when the selected poll is missing", async () => {
|
|
queryMocks.execute.mockResolvedValueOnce([[]]);
|
|
|
|
await expect(
|
|
loadProductionContentQuery({
|
|
routeId: "content.engagement.poll-detail",
|
|
params: { id: "7" },
|
|
list: {
|
|
...normalizedResponsePage,
|
|
search: "",
|
|
pageSize: 25,
|
|
offset: 0,
|
|
},
|
|
}),
|
|
).resolves.toEqual({
|
|
kind: "engagement",
|
|
items: [],
|
|
total: 0,
|
|
partialDependencies: [],
|
|
});
|
|
});
|
|
|
|
it("loads only the selected event with all related operator data", async () => {
|
|
queryMocks.execute
|
|
.mockResolvedValueOnce([
|
|
[
|
|
{
|
|
id: 11,
|
|
title: "Battle Ball",
|
|
status: "published",
|
|
updated_at: new Date("2026-09-01T08:00:00.000Z"),
|
|
description: "Complete event",
|
|
type_id: 2,
|
|
host_user_id: 5,
|
|
room_id: 99,
|
|
starts_at: new Date("2026-09-02T18:00:00.000Z"),
|
|
ends_at: null,
|
|
max_players: 20,
|
|
is_recurring: 0,
|
|
recurrence_rule: null,
|
|
image: "/media/event.webp",
|
|
},
|
|
],
|
|
])
|
|
.mockResolvedValueOnce([
|
|
[
|
|
{
|
|
id: 2,
|
|
name: "Tournament",
|
|
slug: "tournament",
|
|
description: "Ranked competition",
|
|
color: "#ff9900",
|
|
icon: "trophy",
|
|
is_active: 1,
|
|
min_rank: 1,
|
|
},
|
|
],
|
|
])
|
|
.mockResolvedValueOnce([
|
|
[
|
|
{
|
|
id: 4,
|
|
position: 1,
|
|
prize_type: "badge",
|
|
badge_code: "WIN1",
|
|
credits: 0,
|
|
pixels: 0,
|
|
points: 0,
|
|
item_id: null,
|
|
description: "Winner badge",
|
|
},
|
|
],
|
|
])
|
|
.mockResolvedValueOnce([
|
|
[
|
|
{
|
|
id: 6,
|
|
user_id: 9,
|
|
position: 1,
|
|
username: "Alice",
|
|
created_at: new Date("2026-09-02T20:00:00.000Z"),
|
|
},
|
|
],
|
|
])
|
|
.mockResolvedValueOnce([
|
|
[
|
|
{
|
|
id: 8,
|
|
user_id: 10,
|
|
username: "Bob",
|
|
created_at: new Date("2026-09-01T12:00:00.000Z"),
|
|
},
|
|
],
|
|
]);
|
|
|
|
const result = await loadProductionContentQuery({
|
|
routeId: "content.engagement.event-detail",
|
|
params: { id: "11" },
|
|
list: { ...normalizedResponsePage, search: "", pageSize: 25, offset: 0 },
|
|
});
|
|
|
|
expect(result.items).toHaveLength(1);
|
|
expect(result.items[0]?.privatePayload).toMatchObject({
|
|
kind: "event-detail",
|
|
description: "Complete event",
|
|
eventTypes: [{ id: "2", name: "Tournament" }],
|
|
prizes: [{ id: "4", prizeType: "badge" }],
|
|
winners: [{ userId: "9", username: "Alice" }],
|
|
registrations: [{ userId: "10", username: "Bob" }],
|
|
});
|
|
const sql = JSON.stringify(queryMocks.execute.mock.calls);
|
|
expect(sql).toContain("11");
|
|
expect(queryMocks.execute).toHaveBeenCalledTimes(5);
|
|
});
|
|
|
|
it("fails closed before production adapters are invoked", async () => {
|
|
const load = vi.fn(async () => ready);
|
|
const query = createContentQuery({ load });
|
|
|
|
const result = await query.run(context([]), {
|
|
routeId: "content.editorial.articles",
|
|
list: { search: "", pageSize: 25, offset: 0 },
|
|
});
|
|
|
|
expect(result).toMatchObject({
|
|
ok: false,
|
|
error: { code: "FORBIDDEN" },
|
|
});
|
|
expect(load).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("bounds list input, preserves canonical BIGINT strings, and returns real adapter data", async () => {
|
|
const load = vi.fn(async (_input: ContentQueryInput) => ready);
|
|
const query = createContentQuery({ load });
|
|
|
|
const result = await query.run(context([PERMS.NEWS_VIEW]), {
|
|
routeId: "content.editorial.articles",
|
|
list: {
|
|
search: " launch ",
|
|
pageSize: 1000,
|
|
offset: 999_999,
|
|
},
|
|
});
|
|
|
|
expect(load).toHaveBeenCalledWith({
|
|
routeId: "content.editorial.articles",
|
|
params: {},
|
|
list: {
|
|
search: "launch",
|
|
pageSize: 100,
|
|
offset: 100_000,
|
|
responseQuestionId: "",
|
|
responsePageSize: 25,
|
|
responseOffset: 0,
|
|
},
|
|
});
|
|
expect(result).toMatchObject({
|
|
ok: true,
|
|
data: {
|
|
items: [{ id: "18446744073709551615" }],
|
|
total: 1,
|
|
partialDependencies: [],
|
|
},
|
|
});
|
|
});
|
|
|
|
it("normalizes bounded poll response-page input before invoking the adapter", async () => {
|
|
const load = vi.fn(async () => ({
|
|
kind: "engagement" as const,
|
|
items: [],
|
|
total: 0,
|
|
partialDependencies: [],
|
|
}));
|
|
const query = createContentQuery({ load });
|
|
|
|
await query.run(context([PERMS.POLLS_VIEW, PERMS.POLLS_EDIT]), {
|
|
routeId: "content.engagement.poll-detail",
|
|
params: { id: "7" },
|
|
list: {
|
|
responseQuestionId: " 22 ",
|
|
responsePageSize: 50,
|
|
responseOffset: 100_000,
|
|
},
|
|
});
|
|
|
|
expect(load).toHaveBeenCalledWith({
|
|
routeId: "content.engagement.poll-detail",
|
|
params: { id: "7" },
|
|
list: {
|
|
search: "",
|
|
pageSize: 25,
|
|
offset: 0,
|
|
responseQuestionId: "22",
|
|
responsePageSize: 50,
|
|
responseOffset: 100_000,
|
|
},
|
|
});
|
|
});
|
|
|
|
it("requires response-page defaults in normalized adapter inputs", () => {
|
|
const incomplete: NormalizedContentQueryInput = {
|
|
routeId: "content.engagement.events",
|
|
params: {},
|
|
// @ts-expect-error Normalized adapter inputs must always include response paging.
|
|
list: { search: "", pageSize: 25, offset: 0 },
|
|
};
|
|
expect(incomplete.routeId).toBe("content.engagement.events");
|
|
});
|
|
|
|
it.each([
|
|
["mismatched id", "8", pollDetail],
|
|
[
|
|
"too many questions",
|
|
"7",
|
|
{ ...pollDetail, questions: Array(101).fill(pollDetail.questions[0]) },
|
|
],
|
|
[
|
|
"invalid response user",
|
|
"7",
|
|
{
|
|
...pollDetail,
|
|
textResponses: {
|
|
questionId: "22",
|
|
items: [
|
|
{
|
|
id: "1",
|
|
questionId: "22",
|
|
userId: "0",
|
|
username: null,
|
|
answer: "Text",
|
|
createdAt: "2026-09-02T18:00:00.000Z",
|
|
},
|
|
],
|
|
total: 1,
|
|
pageSize: 25,
|
|
offset: 0,
|
|
},
|
|
},
|
|
],
|
|
] as const)(
|
|
"fails closed for poll detail with %s",
|
|
async (_label, itemId, payload) => {
|
|
const query = createContentQuery({
|
|
load: async () => ({
|
|
kind: "engagement" as const,
|
|
items: [{ id: itemId, title: "Poll", privatePayload: payload }],
|
|
total: 1,
|
|
partialDependencies: [],
|
|
}),
|
|
});
|
|
const result = await query.run(
|
|
context([PERMS.POLLS_VIEW, PERMS.POLLS_EDIT]),
|
|
{
|
|
routeId: "content.engagement.poll-detail",
|
|
params: { id: "7" },
|
|
},
|
|
);
|
|
expect(result).toMatchObject({
|
|
ok: false,
|
|
error: { code: "DEPENDENCY_UNAVAILABLE" },
|
|
});
|
|
},
|
|
);
|
|
|
|
it("fails closed when poll creation does not return the exact create payload", async () => {
|
|
const query = createContentQuery({
|
|
load: async () => ({
|
|
kind: "engagement" as const,
|
|
items: [
|
|
{ id: "7", title: "Poll", privatePayload: { kind: "poll-create" } },
|
|
],
|
|
total: 1,
|
|
partialDependencies: [],
|
|
}),
|
|
});
|
|
expect(
|
|
await query.run(context([PERMS.POLLS_EDIT]), {
|
|
routeId: "content.engagement.poll-create",
|
|
}),
|
|
).toMatchObject({
|
|
ok: false,
|
|
error: { code: "DEPENDENCY_UNAVAILABLE" },
|
|
});
|
|
});
|
|
|
|
it("accepts an empty poll response page past its total", async () => {
|
|
const query = createContentQuery({
|
|
load: async () => ({
|
|
kind: "engagement" as const,
|
|
items: [
|
|
{
|
|
id: "7",
|
|
title: "Poll",
|
|
privatePayload: {
|
|
...pollDetail,
|
|
textResponses: {
|
|
questionId: null,
|
|
items: [],
|
|
total: 1,
|
|
pageSize: 25,
|
|
offset: 100_000,
|
|
},
|
|
},
|
|
},
|
|
],
|
|
total: 1,
|
|
partialDependencies: [],
|
|
}),
|
|
});
|
|
expect(
|
|
await query.run(context([PERMS.POLLS_VIEW, PERMS.POLLS_EDIT]), {
|
|
routeId: "content.engagement.poll-detail",
|
|
params: { id: "7" },
|
|
}),
|
|
).toMatchObject({ ok: true });
|
|
});
|
|
|
|
it.each([
|
|
["an offset at the response total", 1, 1, 1],
|
|
["a response page extending beyond its total", 3, 2, 2],
|
|
])("fails closed for %s", async (_label, total, offset, itemCount) => {
|
|
const items = Array.from({ length: itemCount }, (_, index) => ({
|
|
id: String(index + 1),
|
|
questionId: "22",
|
|
userId: String(index + 1),
|
|
username: null,
|
|
answer: "Text",
|
|
createdAt: "2026-09-02T18:00:00.000Z",
|
|
}));
|
|
const query = createContentQuery({
|
|
load: async () => ({
|
|
kind: "engagement" as const,
|
|
items: [
|
|
{
|
|
id: "7",
|
|
title: "Poll",
|
|
privatePayload: {
|
|
...pollDetail,
|
|
questions: [
|
|
{
|
|
...pollDetail.questions[0],
|
|
id: "22",
|
|
type: "text" as const,
|
|
options: [],
|
|
answerCount: total,
|
|
choiceResults: [],
|
|
},
|
|
],
|
|
textResponses: {
|
|
questionId: "22",
|
|
items,
|
|
total,
|
|
pageSize: 25,
|
|
offset,
|
|
},
|
|
},
|
|
},
|
|
],
|
|
total: 1,
|
|
partialDependencies: [],
|
|
}),
|
|
});
|
|
|
|
expect(
|
|
await query.run(context([PERMS.POLLS_VIEW, PERMS.POLLS_EDIT]), {
|
|
routeId: "content.engagement.poll-detail",
|
|
params: { id: "7" },
|
|
}),
|
|
).toMatchObject({
|
|
ok: false,
|
|
error: { code: "DEPENDENCY_UNAVAILABLE" },
|
|
});
|
|
});
|
|
|
|
it("fails closed when an article detail payload is incomplete", async () => {
|
|
const query = createContentQuery({
|
|
load: async () => ({
|
|
kind: "editorial",
|
|
items: [
|
|
{
|
|
id: "7",
|
|
title: "Launch day",
|
|
privatePayload: { slug: "launch-day" },
|
|
},
|
|
],
|
|
total: 1,
|
|
partialDependencies: [],
|
|
}),
|
|
});
|
|
|
|
const result = await query.run(context([PERMS.NEWS_VIEW]), {
|
|
routeId: "content.editorial.article-detail",
|
|
params: { id: "7" },
|
|
});
|
|
|
|
expect(result).toMatchObject({
|
|
ok: false,
|
|
error: { code: "DEPENDENCY_UNAVAILABLE" },
|
|
});
|
|
});
|
|
it("normalizes a bounded event status before invoking the adapter", async () => {
|
|
const load = vi.fn(async () => ({
|
|
kind: "engagement" as const,
|
|
items: [],
|
|
total: 0,
|
|
partialDependencies: [],
|
|
}));
|
|
const query = createContentQuery({ load });
|
|
|
|
await query.run(context([PERMS.EVENTS_VIEW]), {
|
|
routeId: "content.engagement.events",
|
|
list: {
|
|
status: ` PUBLISHED${"x".repeat(40)} `,
|
|
} as ContentQueryInput["list"] & { status: string },
|
|
});
|
|
|
|
expect(load).toHaveBeenCalledWith({
|
|
routeId: "content.engagement.events",
|
|
params: {},
|
|
list: {
|
|
search: "",
|
|
status: `published${"x".repeat(23)}`,
|
|
pageSize: 25,
|
|
offset: 0,
|
|
responseQuestionId: "",
|
|
responsePageSize: 25,
|
|
responseOffset: 0,
|
|
},
|
|
});
|
|
});
|
|
|
|
it.each([
|
|
{
|
|
label: "missing payload",
|
|
items: [{ id: "7", title: "Launch day", status: "published" }],
|
|
},
|
|
{
|
|
label: "more than one selected event",
|
|
items: [
|
|
{ id: "7", title: "Launch day", status: "published" },
|
|
{ id: "8", title: "After party", status: "draft" },
|
|
],
|
|
},
|
|
])("fails closed for an event detail with $label", async ({ items }) => {
|
|
const query = createContentQuery({
|
|
load: async () => ({
|
|
kind: "engagement",
|
|
items,
|
|
total: items.length,
|
|
partialDependencies: [],
|
|
}),
|
|
});
|
|
|
|
const result = await query.run(context([PERMS.EVENTS_EDIT]), {
|
|
routeId: "content.engagement.event-detail",
|
|
params: { id: "7" },
|
|
});
|
|
|
|
expect(result).toMatchObject({
|
|
ok: false,
|
|
error: { code: "DEPENDENCY_UNAVAILABLE" },
|
|
});
|
|
});
|
|
it("rejects a fake partial result and maps complete adapter failure", async () => {
|
|
const malformed = createContentQuery({
|
|
load: async () =>
|
|
({
|
|
kind: "media",
|
|
items: [],
|
|
total: 0,
|
|
partialDependencies: ["imaginary"],
|
|
}) as ContentQueryData,
|
|
});
|
|
const unavailable = createContentQuery({
|
|
load: async () => {
|
|
throw new Error("database unavailable");
|
|
},
|
|
});
|
|
|
|
expect(
|
|
await malformed.run(context([PERMS.PAGES_VIEW]), {
|
|
routeId: "content.media.library",
|
|
}),
|
|
).toMatchObject({
|
|
ok: false,
|
|
error: { code: "DEPENDENCY_UNAVAILABLE" },
|
|
});
|
|
expect(
|
|
await unavailable.run(context([PERMS.PAGES_VIEW]), {
|
|
routeId: "content.media.library",
|
|
}),
|
|
).toMatchObject({
|
|
ok: false,
|
|
error: { code: "DEPENDENCY_UNAVAILABLE" },
|
|
});
|
|
});
|
|
|
|
it("has an authorized production query path for every Content route", async () => {
|
|
const load = vi.fn(async (input: ContentQueryInput) => {
|
|
const isPollCreate = input.routeId === "content.engagement.poll-create";
|
|
return {
|
|
kind: input.routeId.split(".")[1] as ContentQueryData["kind"],
|
|
items: isPollCreate
|
|
? [
|
|
{
|
|
id: "create",
|
|
title: "Create poll",
|
|
privatePayload: { kind: "poll-create" },
|
|
},
|
|
]
|
|
: [],
|
|
total: isPollCreate ? 1 : 0,
|
|
partialDependencies: [],
|
|
};
|
|
});
|
|
const query = createContentQuery({ load });
|
|
const all = context(Object.values(PERMS));
|
|
|
|
for (const routeId of CONTENT_ROUTE_IDS) {
|
|
const result = await query.run(all, {
|
|
routeId,
|
|
...(routeId === "content.engagement.event-detail" ||
|
|
routeId === "content.engagement.poll-detail"
|
|
? { params: { id: "1" } }
|
|
: {}),
|
|
});
|
|
expect(result.ok, routeId).toBe(true);
|
|
}
|
|
expect(load).toHaveBeenCalledTimes(CONTENT_ROUTE_IDS.length);
|
|
});
|
|
it("loads more than one page of active event types for event creation", async () => {
|
|
queryMocks.execute.mockResolvedValueOnce([
|
|
Array.from({ length: 30 }, (_, index) => ({
|
|
id: index + 1,
|
|
name: `Type ${index + 1}`,
|
|
slug: `type-${index + 1}`,
|
|
description: "Event category",
|
|
color: "#ff9900",
|
|
icon: "calendar",
|
|
is_active: 1,
|
|
min_rank: 1,
|
|
})),
|
|
]);
|
|
|
|
const result = await loadProductionContentQuery({
|
|
routeId: "content.engagement.event-create",
|
|
params: {},
|
|
list: { ...normalizedResponsePage, search: "", pageSize: 25, offset: 0 },
|
|
});
|
|
|
|
expect(result.items).toHaveLength(30);
|
|
expect(result.total).toBe(30);
|
|
expect(JSON.stringify(queryMocks.execute.mock.calls)).toContain("501");
|
|
});
|
|
|
|
it("bounds every event-detail dependency and fails closed on overflow", async () => {
|
|
queryMocks.execute
|
|
.mockResolvedValueOnce([
|
|
[
|
|
{
|
|
id: 11,
|
|
title: "Battle Ball",
|
|
type_id: 2,
|
|
host_user_id: 5,
|
|
starts_at: new Date("2026-09-02T18:00:00.000Z"),
|
|
},
|
|
],
|
|
])
|
|
.mockResolvedValueOnce([
|
|
Array.from({ length: 501 }, (_, index) => ({
|
|
id: index + 1,
|
|
name: `Type ${index + 1}`,
|
|
slug: `type-${index + 1}`,
|
|
description: "Event category",
|
|
color: "#ff9900",
|
|
icon: "calendar",
|
|
is_active: 1,
|
|
min_rank: 1,
|
|
})),
|
|
]);
|
|
|
|
await expect(
|
|
loadProductionContentQuery({
|
|
routeId: "content.engagement.event-detail",
|
|
params: { id: "11" },
|
|
list: {
|
|
...normalizedResponsePage,
|
|
search: "",
|
|
pageSize: 25,
|
|
offset: 0,
|
|
},
|
|
}),
|
|
).rejects.toThrow("event type limit");
|
|
|
|
const sql = JSON.stringify(queryMocks.execute.mock.calls);
|
|
expect(sql).toContain("501");
|
|
expect(queryMocks.execute).toHaveBeenCalledTimes(2);
|
|
});
|
|
|
|
it("applies explicit limits to all event-detail dependency queries", async () => {
|
|
queryMocks.execute
|
|
.mockResolvedValueOnce([
|
|
[
|
|
{
|
|
id: 11,
|
|
title: "Battle Ball",
|
|
type_id: 2,
|
|
host_user_id: 5,
|
|
starts_at: new Date("2026-09-02T18:00:00.000Z"),
|
|
},
|
|
],
|
|
])
|
|
.mockResolvedValueOnce([[]])
|
|
.mockResolvedValueOnce([[]])
|
|
.mockResolvedValueOnce([[]])
|
|
.mockResolvedValueOnce([[]]);
|
|
|
|
await loadProductionContentQuery({
|
|
routeId: "content.engagement.event-detail",
|
|
params: { id: "11" },
|
|
list: { ...normalizedResponsePage, search: "", pageSize: 25, offset: 0 },
|
|
});
|
|
|
|
const sql = JSON.stringify(queryMocks.execute.mock.calls);
|
|
expect(sql).toContain("101");
|
|
expect(sql).toContain("501");
|
|
expect(sql).toContain("1001");
|
|
});
|
|
|
|
it.each([
|
|
{
|
|
label: "an invalid requested identifier",
|
|
requestedId: "0",
|
|
itemId: "7",
|
|
total: 1,
|
|
startsAt: "2026-09-02T18:00:00.000Z",
|
|
},
|
|
{
|
|
label: "a mismatched selected identifier",
|
|
requestedId: "7",
|
|
itemId: "8",
|
|
total: 1,
|
|
startsAt: "2026-09-02T18:00:00.000Z",
|
|
},
|
|
{
|
|
label: "an invalid start date",
|
|
requestedId: "7",
|
|
itemId: "7",
|
|
total: 1,
|
|
startsAt: "tomorrow",
|
|
},
|
|
{
|
|
label: "an inconsistent total",
|
|
requestedId: "7",
|
|
itemId: "7",
|
|
total: 0,
|
|
startsAt: "2026-09-02T18:00:00.000Z",
|
|
},
|
|
])(
|
|
"fails closed for event detail data with $label",
|
|
async ({ requestedId, itemId, total, startsAt }) => {
|
|
const query = createContentQuery({
|
|
load: async () => ({
|
|
kind: "engagement",
|
|
items: [
|
|
{
|
|
id: itemId,
|
|
title: "Battle Ball",
|
|
privatePayload: {
|
|
kind: "event-detail",
|
|
description: "Complete event",
|
|
typeId: "2",
|
|
hostUserId: "5",
|
|
roomId: null,
|
|
startsAt,
|
|
endsAt: null,
|
|
maxPlayers: null,
|
|
isRecurring: false,
|
|
recurrenceRule: null,
|
|
image: null,
|
|
eventTypes: [],
|
|
prizes: [],
|
|
winners: [],
|
|
registrations: [],
|
|
},
|
|
},
|
|
],
|
|
total,
|
|
partialDependencies: [],
|
|
}),
|
|
});
|
|
|
|
const result = await query.run(context([PERMS.EVENTS_EDIT]), {
|
|
routeId: "content.engagement.event-detail",
|
|
params: { id: requestedId },
|
|
});
|
|
|
|
expect(result).toMatchObject({
|
|
ok: false,
|
|
error: { code: "DEPENDENCY_UNAVAILABLE" },
|
|
});
|
|
},
|
|
);
|
|
|
|
it("rejects malformed event summary and event type identifiers", async () => {
|
|
const summaryQuery = createContentQuery({
|
|
load: async () => ({
|
|
kind: "engagement",
|
|
items: [
|
|
{
|
|
id: "11",
|
|
title: "Battle Ball",
|
|
privatePayload: {
|
|
kind: "event-summary",
|
|
typeId: "0",
|
|
typeName: "Tournament",
|
|
typeColor: "#ff9900",
|
|
startsAt: "not-a-date",
|
|
endsAt: null,
|
|
maxPlayers: null,
|
|
registrationCount: 0,
|
|
},
|
|
},
|
|
],
|
|
total: 1,
|
|
partialDependencies: [],
|
|
}),
|
|
});
|
|
|
|
expect(
|
|
await summaryQuery.run(context([PERMS.EVENTS_VIEW]), {
|
|
routeId: "content.engagement.events",
|
|
}),
|
|
).toMatchObject({
|
|
ok: false,
|
|
error: { code: "DEPENDENCY_UNAVAILABLE" },
|
|
});
|
|
|
|
const typeQuery = createContentQuery({
|
|
load: async () => ({
|
|
kind: "engagement",
|
|
items: [
|
|
{
|
|
id: "0",
|
|
title: "Tournament",
|
|
privatePayload: {
|
|
kind: "event-type",
|
|
name: "Tournament",
|
|
slug: "tournament",
|
|
description: "Ranked competition",
|
|
color: "#ff9900",
|
|
icon: "calendar",
|
|
isActive: true,
|
|
minRank: 1,
|
|
},
|
|
},
|
|
],
|
|
total: 1,
|
|
partialDependencies: [],
|
|
}),
|
|
});
|
|
|
|
expect(
|
|
await typeQuery.run(context([PERMS.EVENTS_EDIT]), {
|
|
routeId: "content.engagement.event-types",
|
|
}),
|
|
).toMatchObject({
|
|
ok: false,
|
|
error: { code: "DEPENDENCY_UNAVAILABLE" },
|
|
});
|
|
});
|
|
});
|