feat(housekeeping): complete content events workflow
This commit is contained in:
1 parent
f2e67ebace
commit
d6235c37a1
42 files changed
+2868
-307
No files matched your search
@@ -10,6 +10,11 @@ import type {
|
||||
|
||||
type RawRow = Readonly<Record<string, unknown>>;
|
||||
|
||||
const EVENT_TYPE_LIMIT = 500;
|
||||
const EVENT_PRIZE_LIMIT = 100;
|
||||
const EVENT_WINNER_LIMIT = 500;
|
||||
const EVENT_REGISTRATION_LIMIT = 1_000;
|
||||
|
||||
interface QueryDefinition {
|
||||
readonly statement: string;
|
||||
readonly href: string;
|
||||
@@ -70,20 +75,16 @@ export const CONTENT_QUERY_DEFINITIONS = {
|
||||
},
|
||||
"content.engagement.events": {
|
||||
statement:
|
||||
"SELECT id, title, status, updated_at FROM website_events ORDER BY starts_at DESC",
|
||||
href: "/ase-next/content/engagement/events/",
|
||||
appendId: true,
|
||||
},
|
||||
"content.engagement.event-detail": {
|
||||
statement:
|
||||
"SELECT id, title, status, updated_at FROM website_events ORDER BY starts_at DESC",
|
||||
"SELECT e.id, e.title, COALESCE(et.name, 'Unknown type') AS description, e.status, e.updated_at, e.type_id, COALESCE(et.name, 'Unknown type') AS type_name, COALESCE(et.color, '#6b7280') AS type_color, e.starts_at, e.ends_at, e.max_players, (SELECT COUNT(*) FROM website_event_registrations er WHERE er.event_id = e.id) AS registration_count FROM website_events e LEFT JOIN website_event_types et ON et.id = e.type_id ORDER BY e.starts_at DESC",
|
||||
href: "/ase-next/content/engagement/events/",
|
||||
appendId: true,
|
||||
privatePayload: eventSummaryPayload,
|
||||
},
|
||||
"content.engagement.event-types": {
|
||||
statement:
|
||||
"SELECT id, name AS title, CASE WHEN is_active = 1 THEN 'active' ELSE 'inactive' END AS status, NULL AS updated_at FROM website_event_types ORDER BY name",
|
||||
"SELECT id, name AS title, CASE WHEN is_active = 1 THEN 'active' ELSE 'inactive' END AS status, NULL AS updated_at, name, slug, description, color, icon, is_active, min_rank FROM website_event_types ORDER BY name",
|
||||
href: "/ase-next/content/engagement/events/types",
|
||||
privatePayload: eventTypePayload,
|
||||
},
|
||||
"content.engagement.polls": {
|
||||
statement:
|
||||
@@ -129,7 +130,6 @@ export const CONTENT_QUERY_DEFINITIONS = {
|
||||
const EMPTY_ROUTES = new Set<ContentRouteId>([
|
||||
"content.editorial.article-create",
|
||||
"content.media.ad-create",
|
||||
"content.engagement.event-create",
|
||||
"content.engagement.poll-create",
|
||||
"content.help.question-create",
|
||||
]);
|
||||
@@ -141,6 +141,18 @@ function rows(result: unknown): readonly RawRow[] {
|
||||
return result[0] as readonly RawRow[];
|
||||
}
|
||||
|
||||
function limitedEventRows(
|
||||
result: unknown,
|
||||
limit: number,
|
||||
label: string,
|
||||
): readonly RawRow[] {
|
||||
const loaded = rows(result);
|
||||
if (loaded.length > limit) {
|
||||
throw new Error(`invalid Content ${label} limit`);
|
||||
}
|
||||
return loaded;
|
||||
}
|
||||
|
||||
function value(value: unknown, fallback = ""): string {
|
||||
return typeof value === "string"
|
||||
? value
|
||||
@@ -155,6 +167,46 @@ function updatedAt(value: unknown): string | null {
|
||||
return Number.isFinite(parsed.getTime()) ? parsed.toISOString() : null;
|
||||
}
|
||||
|
||||
function requiredDate(value: unknown): string {
|
||||
const normalized = updatedAt(value);
|
||||
if (!normalized) throw new Error("invalid Content date");
|
||||
return normalized;
|
||||
}
|
||||
|
||||
function integer(value: unknown, fallback = 0): number {
|
||||
const parsed = Number(value);
|
||||
return Number.isSafeInteger(parsed) && parsed >= 0 ? parsed : fallback;
|
||||
}
|
||||
|
||||
function nullableIdentifier(value: unknown): string | null {
|
||||
return value == null ? null : String(value);
|
||||
}
|
||||
|
||||
function eventTypePayload(row: RawRow) {
|
||||
return {
|
||||
kind: "event-type" as const,
|
||||
name: value(row.name ?? row.title),
|
||||
slug: value(row.slug),
|
||||
description: value(row.description),
|
||||
color: value(row.color, "#3b82f6"),
|
||||
icon: value(row.icon, "calendar"),
|
||||
isActive: Number(row.is_active) === 1,
|
||||
minRank: integer(row.min_rank),
|
||||
};
|
||||
}
|
||||
|
||||
function eventSummaryPayload(row: RawRow) {
|
||||
return {
|
||||
kind: "event-summary" as const,
|
||||
typeId: value(row.type_id),
|
||||
typeName: value(row.type_name, "Unknown type"),
|
||||
typeColor: value(row.type_color, "#6b7280"),
|
||||
startsAt: requiredDate(row.starts_at),
|
||||
endsAt: updatedAt(row.ends_at),
|
||||
maxPlayers: row.max_players == null ? null : integer(row.max_players),
|
||||
registrationCount: integer(row.registration_count),
|
||||
};
|
||||
}
|
||||
function response(
|
||||
input: NormalizedContentQueryInput,
|
||||
items: readonly ContentQueryItem[],
|
||||
@@ -237,6 +289,9 @@ async function databaseRoute(
|
||||
: sql`LOWER(COALESCE(title, '')) LIKE ${pattern}`,
|
||||
);
|
||||
}
|
||||
if (input.list.status) {
|
||||
filters.push(sql`LOWER(COALESCE(status, '')) = ${input.list.status}`);
|
||||
}
|
||||
if (input.params.id) filters.push(sql`CAST(id AS CHAR) = ${input.params.id}`);
|
||||
const filtered = filters.length
|
||||
? sql`${sql.raw(selection)} HAVING ${sql.join(filters, sql` AND `)}`
|
||||
@@ -263,6 +318,134 @@ async function databaseRoute(
|
||||
partialDependencies: [],
|
||||
};
|
||||
}
|
||||
async function eventCreate(
|
||||
input: NormalizedContentQueryInput,
|
||||
): Promise<ContentQueryData> {
|
||||
const [{ sql }, { db }] = await Promise.all([
|
||||
import("drizzle-orm"),
|
||||
import("@/lib/db"),
|
||||
]);
|
||||
const rawRows = limitedEventRows(
|
||||
await db.execute(
|
||||
sql`SELECT id, name, slug, description, color, icon, is_active, min_rank FROM website_event_types WHERE is_active = 1 ORDER BY name LIMIT ${EVENT_TYPE_LIMIT + 1}`,
|
||||
),
|
||||
EVENT_TYPE_LIMIT,
|
||||
"event type",
|
||||
);
|
||||
const items = rawRows.map((row) => ({
|
||||
id: value(row.id),
|
||||
title: value(row.name, "Untitled event type"),
|
||||
status: "active",
|
||||
href: "/ase-next/content/engagement/events/create",
|
||||
privatePayload: eventTypePayload(row),
|
||||
}));
|
||||
return {
|
||||
kind: contentRouteGroup(input.routeId),
|
||||
items,
|
||||
total: items.length,
|
||||
partialDependencies: [],
|
||||
};
|
||||
}
|
||||
|
||||
async function eventDetail(
|
||||
input: NormalizedContentQueryInput,
|
||||
): Promise<ContentQueryData> {
|
||||
const id = Number(input.params.id);
|
||||
if (!Number.isSafeInteger(id) || id <= 0) return response(input, []);
|
||||
const [{ sql }, { db }] = await Promise.all([
|
||||
import("drizzle-orm"),
|
||||
import("@/lib/db"),
|
||||
]);
|
||||
const [event] = rows(
|
||||
await db.execute(
|
||||
sql`SELECT id, title, description, type_id, host_user_id, room_id, starts_at, ends_at, max_players, is_recurring, recurrence_rule, status, image, updated_at FROM website_events WHERE id = ${id} LIMIT 1`,
|
||||
),
|
||||
);
|
||||
if (!event) return response(input, []);
|
||||
const typeRows = limitedEventRows(
|
||||
await db.execute(
|
||||
sql`SELECT id, name, slug, description, color, icon, is_active, min_rank FROM website_event_types ORDER BY is_active DESC, name LIMIT ${EVENT_TYPE_LIMIT + 1}`,
|
||||
),
|
||||
EVENT_TYPE_LIMIT,
|
||||
"event type",
|
||||
);
|
||||
const prizeRows = limitedEventRows(
|
||||
await db.execute(
|
||||
sql`SELECT id, position, prize_type, badge_code, credits, pixels, points, item_id, description FROM website_event_prizes WHERE event_id = ${id} ORDER BY position, id LIMIT ${EVENT_PRIZE_LIMIT + 1}`,
|
||||
),
|
||||
EVENT_PRIZE_LIMIT,
|
||||
"event prize",
|
||||
);
|
||||
const winnerRows = limitedEventRows(
|
||||
await db.execute(
|
||||
sql`SELECT ew.id, ew.user_id, ew.position, COALESCE(u.username, CONCAT('User #', ew.user_id)) AS username, ew.created_at FROM website_event_winners ew LEFT JOIN users u ON u.id = ew.user_id WHERE ew.event_id = ${id} ORDER BY ew.position, ew.id LIMIT ${EVENT_WINNER_LIMIT + 1}`,
|
||||
),
|
||||
EVENT_WINNER_LIMIT,
|
||||
"event winner",
|
||||
);
|
||||
const registrationRows = limitedEventRows(
|
||||
await db.execute(
|
||||
sql`SELECT er.id, er.user_id, COALESCE(u.username, CONCAT('User #', er.user_id)) AS username, er.created_at FROM website_event_registrations er LEFT JOIN users u ON u.id = er.user_id WHERE er.event_id = ${id} ORDER BY er.created_at DESC LIMIT ${EVENT_REGISTRATION_LIMIT + 1}`,
|
||||
),
|
||||
EVENT_REGISTRATION_LIMIT,
|
||||
"event registration",
|
||||
);
|
||||
const item: ContentQueryItem = {
|
||||
id: value(event.id),
|
||||
title: value(event.title, "Untitled event"),
|
||||
status: value(event.status, "draft"),
|
||||
updatedAt: updatedAt(event.updated_at),
|
||||
href: `/ase-next/content/engagement/events/${value(event.id)}`,
|
||||
privatePayload: {
|
||||
kind: "event-detail",
|
||||
description: value(event.description),
|
||||
typeId: value(event.type_id),
|
||||
hostUserId: value(event.host_user_id),
|
||||
roomId: nullableIdentifier(event.room_id),
|
||||
startsAt: requiredDate(event.starts_at),
|
||||
endsAt: updatedAt(event.ends_at),
|
||||
maxPlayers: event.max_players == null ? null : integer(event.max_players),
|
||||
isRecurring: Number(event.is_recurring) === 1,
|
||||
recurrenceRule:
|
||||
event.recurrence_rule == null ? null : value(event.recurrence_rule),
|
||||
image: event.image == null ? null : value(event.image),
|
||||
eventTypes: typeRows.map((row) => ({
|
||||
id: value(row.id),
|
||||
...eventTypePayload(row),
|
||||
})),
|
||||
prizes: prizeRows.map((row) => ({
|
||||
id: value(row.id),
|
||||
position: integer(row.position),
|
||||
prizeType: value(row.prize_type),
|
||||
badgeCode: nullableIdentifier(row.badge_code),
|
||||
credits: integer(row.credits),
|
||||
pixels: integer(row.pixels),
|
||||
points: integer(row.points),
|
||||
itemId: nullableIdentifier(row.item_id),
|
||||
description: value(row.description),
|
||||
})),
|
||||
winners: winnerRows.map((row) => ({
|
||||
id: value(row.id),
|
||||
userId: value(row.user_id),
|
||||
position: integer(row.position),
|
||||
username: value(row.username),
|
||||
createdAt: requiredDate(row.created_at),
|
||||
})),
|
||||
registrations: registrationRows.map((row) => ({
|
||||
id: value(row.id),
|
||||
userId: value(row.user_id),
|
||||
username: value(row.username),
|
||||
createdAt: requiredDate(row.created_at),
|
||||
})),
|
||||
},
|
||||
};
|
||||
return {
|
||||
kind: "engagement",
|
||||
items: [item],
|
||||
total: 1,
|
||||
partialDependencies: [],
|
||||
};
|
||||
}
|
||||
|
||||
async function mediaLibrary(
|
||||
input: NormalizedContentQueryInput,
|
||||
@@ -409,6 +592,12 @@ export async function loadProductionContentQuery(
|
||||
input: NormalizedContentQueryInput,
|
||||
): Promise<ContentQueryData> {
|
||||
if (EMPTY_ROUTES.has(input.routeId)) return response(input, []);
|
||||
if (input.routeId === "content.engagement.event-create") {
|
||||
return eventCreate(input);
|
||||
}
|
||||
if (input.routeId === "content.engagement.event-detail") {
|
||||
return eventDetail(input);
|
||||
}
|
||||
if (input.routeId === "content.media.library") return mediaLibrary(input);
|
||||
if (input.routeId.startsWith("content.brand.")) return brand(input);
|
||||
if (
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
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";
|
||||
@@ -58,6 +58,8 @@ const ready: ContentQueryData = {
|
||||
};
|
||||
|
||||
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,
|
||||
@@ -98,7 +100,7 @@ describe("Content query", () => {
|
||||
expect(serialized).toContain("14");
|
||||
});
|
||||
|
||||
it("searches only projected aliases for routes without descriptions", async () => {
|
||||
it("searches event titles and projected event-type descriptions", async () => {
|
||||
queryMocks.execute
|
||||
.mockResolvedValueOnce([[{ total: 0 }]])
|
||||
.mockResolvedValueOnce([[]]);
|
||||
@@ -109,7 +111,7 @@ describe("Content query", () => {
|
||||
});
|
||||
const serialized = JSON.stringify(queryMocks.execute.mock.calls.slice(-2));
|
||||
expect(serialized).toContain("COALESCE(title");
|
||||
expect(serialized).not.toContain("COALESCE(description");
|
||||
expect(serialized).toContain("COALESCE(description");
|
||||
});
|
||||
|
||||
it("never selects email template bodies into summary query results", () => {
|
||||
@@ -153,6 +155,174 @@ describe("Content query", () => {
|
||||
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: { 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: { search: "", pageSize: 25, offset: 0 },
|
||||
});
|
||||
|
||||
expect(result.items[0]).toMatchObject({
|
||||
id: "2",
|
||||
title: "Tournament",
|
||||
privatePayload: {
|
||||
kind: "event-type",
|
||||
slug: "tournament",
|
||||
isActive: true,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
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: { 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 });
|
||||
@@ -223,6 +393,66 @@ describe("Content query", () => {
|
||||
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,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
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 () =>
|
||||
@@ -268,9 +498,250 @@ describe("Content query", () => {
|
||||
const all = context(Object.values(PERMS));
|
||||
|
||||
for (const routeId of CONTENT_ROUTE_IDS) {
|
||||
const result = await query.run(all, { routeId });
|
||||
const result = await query.run(all, {
|
||||
routeId,
|
||||
...(routeId === "content.engagement.event-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: { 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: { 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: { 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" },
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
|
||||
export interface ContentQueryListInput {
|
||||
readonly search?: string;
|
||||
readonly status?: string;
|
||||
readonly pageSize?: number;
|
||||
readonly offset?: number;
|
||||
}
|
||||
@@ -43,6 +44,210 @@ export function isContentArticlePrivatePayload(
|
||||
typeof payload.fullStory === "string"
|
||||
);
|
||||
}
|
||||
export interface ContentEventTypePayload {
|
||||
readonly kind: "event-type";
|
||||
readonly name: string;
|
||||
readonly slug: string;
|
||||
readonly description: string;
|
||||
readonly color: string;
|
||||
readonly icon: string;
|
||||
readonly isActive: boolean;
|
||||
readonly minRank: number;
|
||||
}
|
||||
|
||||
export interface ContentEventSummaryPayload {
|
||||
readonly kind: "event-summary";
|
||||
readonly typeId: string;
|
||||
readonly typeName: string;
|
||||
readonly typeColor: string;
|
||||
readonly startsAt: string;
|
||||
readonly endsAt: string | null;
|
||||
readonly maxPlayers: number | null;
|
||||
readonly registrationCount: number;
|
||||
}
|
||||
|
||||
export interface ContentEventPrizePayload {
|
||||
readonly id: string;
|
||||
readonly position: number;
|
||||
readonly prizeType: string;
|
||||
readonly badgeCode: string | null;
|
||||
readonly credits: number;
|
||||
readonly pixels: number;
|
||||
readonly points: number;
|
||||
readonly itemId: string | null;
|
||||
readonly description: string;
|
||||
}
|
||||
|
||||
export interface ContentEventWinnerPayload {
|
||||
readonly id: string;
|
||||
readonly userId: string;
|
||||
readonly position: number;
|
||||
readonly username: string;
|
||||
readonly createdAt: string;
|
||||
}
|
||||
|
||||
export interface ContentEventRegistrationPayload {
|
||||
readonly id: string;
|
||||
readonly userId: string;
|
||||
readonly username: string;
|
||||
readonly createdAt: string;
|
||||
}
|
||||
|
||||
export interface ContentEventDetailPayload {
|
||||
readonly kind: "event-detail";
|
||||
readonly description: string;
|
||||
readonly typeId: string;
|
||||
readonly hostUserId: string;
|
||||
readonly roomId: string | null;
|
||||
readonly startsAt: string;
|
||||
readonly endsAt: string | null;
|
||||
readonly maxPlayers: number | null;
|
||||
readonly isRecurring: boolean;
|
||||
readonly recurrenceRule: string | null;
|
||||
readonly image: string | null;
|
||||
readonly eventTypes: readonly (ContentEventTypePayload & {
|
||||
readonly id: string;
|
||||
})[];
|
||||
readonly prizes: readonly ContentEventPrizePayload[];
|
||||
readonly winners: readonly ContentEventWinnerPayload[];
|
||||
readonly registrations: readonly ContentEventRegistrationPayload[];
|
||||
}
|
||||
|
||||
function object(value: unknown): Record<string, unknown> | null {
|
||||
return value !== null && typeof value === "object"
|
||||
? (value as Record<string, unknown>)
|
||||
: null;
|
||||
}
|
||||
|
||||
function stringOrNull(value: unknown): value is string | null {
|
||||
return value === null || typeof value === "string";
|
||||
}
|
||||
|
||||
function positiveIdentifier(value: unknown): value is string {
|
||||
return typeof value === "string" && /^[1-9]\d*$/u.test(value);
|
||||
}
|
||||
|
||||
function canonicalDate(value: unknown): value is string {
|
||||
if (typeof value !== "string") return false;
|
||||
const parsed = new Date(value);
|
||||
return Number.isFinite(parsed.getTime()) && parsed.toISOString() === value;
|
||||
}
|
||||
|
||||
function dateOrNull(value: unknown): value is string | null {
|
||||
return value === null || canonicalDate(value);
|
||||
}
|
||||
|
||||
function safeCount(value: unknown): value is number {
|
||||
return Number.isSafeInteger(value) && Number(value) >= 0;
|
||||
}
|
||||
|
||||
function positiveCount(value: unknown): value is number {
|
||||
return safeCount(value) && Number(value) > 0;
|
||||
}
|
||||
|
||||
export function isContentEventTypePayload(
|
||||
value: unknown,
|
||||
): value is ContentEventTypePayload {
|
||||
const payload = object(value);
|
||||
return (
|
||||
payload?.kind === "event-type" &&
|
||||
typeof payload.name === "string" &&
|
||||
typeof payload.slug === "string" &&
|
||||
typeof payload.description === "string" &&
|
||||
typeof payload.color === "string" &&
|
||||
typeof payload.icon === "string" &&
|
||||
typeof payload.isActive === "boolean" &&
|
||||
safeCount(payload.minRank)
|
||||
);
|
||||
}
|
||||
|
||||
export function isContentEventSummaryPayload(
|
||||
value: unknown,
|
||||
): value is ContentEventSummaryPayload {
|
||||
const payload = object(value);
|
||||
return (
|
||||
payload?.kind === "event-summary" &&
|
||||
positiveIdentifier(payload.typeId) &&
|
||||
typeof payload.typeName === "string" &&
|
||||
typeof payload.typeColor === "string" &&
|
||||
canonicalDate(payload.startsAt) &&
|
||||
dateOrNull(payload.endsAt) &&
|
||||
(payload.maxPlayers === null || positiveCount(payload.maxPlayers)) &&
|
||||
safeCount(payload.registrationCount)
|
||||
);
|
||||
}
|
||||
|
||||
function isContentEventPrizePayload(
|
||||
value: unknown,
|
||||
): value is ContentEventPrizePayload {
|
||||
const payload = object(value);
|
||||
return (
|
||||
positiveIdentifier(payload?.id) &&
|
||||
positiveCount(payload.position) &&
|
||||
typeof payload.prizeType === "string" &&
|
||||
stringOrNull(payload.badgeCode) &&
|
||||
safeCount(payload.credits) &&
|
||||
safeCount(payload.pixels) &&
|
||||
safeCount(payload.points) &&
|
||||
(payload.itemId === null || positiveIdentifier(payload.itemId)) &&
|
||||
typeof payload.description === "string"
|
||||
);
|
||||
}
|
||||
|
||||
function isContentEventWinnerPayload(
|
||||
value: unknown,
|
||||
): value is ContentEventWinnerPayload {
|
||||
const payload = object(value);
|
||||
return (
|
||||
positiveIdentifier(payload?.id) &&
|
||||
positiveIdentifier(payload.userId) &&
|
||||
positiveCount(payload.position) &&
|
||||
typeof payload.username === "string" &&
|
||||
canonicalDate(payload.createdAt)
|
||||
);
|
||||
}
|
||||
|
||||
function isContentEventRegistrationPayload(
|
||||
value: unknown,
|
||||
): value is ContentEventRegistrationPayload {
|
||||
const payload = object(value);
|
||||
return (
|
||||
positiveIdentifier(payload?.id) &&
|
||||
positiveIdentifier(payload.userId) &&
|
||||
typeof payload.username === "string" &&
|
||||
canonicalDate(payload.createdAt)
|
||||
);
|
||||
}
|
||||
|
||||
export function isContentEventDetailPayload(
|
||||
value: unknown,
|
||||
): value is ContentEventDetailPayload {
|
||||
const payload = object(value);
|
||||
return (
|
||||
payload?.kind === "event-detail" &&
|
||||
typeof payload.description === "string" &&
|
||||
positiveIdentifier(payload.typeId) &&
|
||||
positiveIdentifier(payload.hostUserId) &&
|
||||
(payload.roomId === null || positiveIdentifier(payload.roomId)) &&
|
||||
canonicalDate(payload.startsAt) &&
|
||||
dateOrNull(payload.endsAt) &&
|
||||
(payload.maxPlayers === null || positiveCount(payload.maxPlayers)) &&
|
||||
typeof payload.isRecurring === "boolean" &&
|
||||
stringOrNull(payload.recurrenceRule) &&
|
||||
stringOrNull(payload.image) &&
|
||||
Array.isArray(payload.eventTypes) &&
|
||||
payload.eventTypes.every(
|
||||
(type) =>
|
||||
positiveIdentifier(object(type)?.id) && isContentEventTypePayload(type),
|
||||
) &&
|
||||
Array.isArray(payload.prizes) &&
|
||||
payload.prizes.every(isContentEventPrizePayload) &&
|
||||
Array.isArray(payload.winners) &&
|
||||
payload.winners.every(isContentEventWinnerPayload) &&
|
||||
Array.isArray(payload.registrations) &&
|
||||
payload.registrations.every(isContentEventRegistrationPayload)
|
||||
);
|
||||
}
|
||||
|
||||
export interface ContentQueryItem {
|
||||
readonly id: string;
|
||||
@@ -66,6 +271,7 @@ export interface NormalizedContentQueryInput {
|
||||
readonly params: Readonly<Record<string, string>>;
|
||||
readonly list: Readonly<{
|
||||
search: string;
|
||||
status?: string;
|
||||
pageSize: number;
|
||||
offset: number;
|
||||
}>;
|
||||
@@ -87,6 +293,11 @@ function boundedInteger(
|
||||
}
|
||||
|
||||
function normalizeInput(input: ContentQueryInput): NormalizedContentQueryInput {
|
||||
const status = String(input.list?.status ?? "")
|
||||
.normalize("NFC")
|
||||
.trim()
|
||||
.toLocaleLowerCase()
|
||||
.slice(0, 32);
|
||||
return {
|
||||
routeId: input.routeId,
|
||||
params: Object.fromEntries(
|
||||
@@ -100,6 +311,7 @@ function normalizeInput(input: ContentQueryInput): NormalizedContentQueryInput {
|
||||
.normalize("NFC")
|
||||
.trim()
|
||||
.slice(0, 128),
|
||||
...(status ? { status } : {}),
|
||||
pageSize: boundedInteger(input.list?.pageSize, 25, 1, 100),
|
||||
offset: boundedInteger(input.list?.offset, 0, 0, 100_000),
|
||||
},
|
||||
@@ -129,13 +341,56 @@ function isValidData(
|
||||
(item.href === undefined || isSafeHousekeepingHref(item.href)),
|
||||
);
|
||||
if (!validItems) return false;
|
||||
if (input.routeId !== "content.editorial.article-detail") return true;
|
||||
return (
|
||||
data.items.length <= 1 &&
|
||||
data.items.every((item) =>
|
||||
isContentArticlePrivatePayload(item.privatePayload),
|
||||
)
|
||||
);
|
||||
if (input.routeId === "content.editorial.article-detail") {
|
||||
return (
|
||||
data.items.length <= 1 &&
|
||||
data.items.every((item) =>
|
||||
isContentArticlePrivatePayload(item.privatePayload),
|
||||
)
|
||||
);
|
||||
}
|
||||
if (input.routeId === "content.engagement.events") {
|
||||
return (
|
||||
data.items.length <= input.list.pageSize &&
|
||||
data.items.length <= data.total &&
|
||||
data.items.every(
|
||||
(item) =>
|
||||
positiveIdentifier(item.id) &&
|
||||
isContentEventSummaryPayload(item.privatePayload),
|
||||
)
|
||||
);
|
||||
}
|
||||
if (
|
||||
input.routeId === "content.engagement.event-create" ||
|
||||
input.routeId === "content.engagement.event-types"
|
||||
) {
|
||||
return (
|
||||
data.items.length <= data.total &&
|
||||
(input.routeId === "content.engagement.event-create"
|
||||
? data.items.length === data.total
|
||||
: data.items.length <= input.list.pageSize) &&
|
||||
data.items.every(
|
||||
(item) =>
|
||||
positiveIdentifier(item.id) &&
|
||||
isContentEventTypePayload(item.privatePayload),
|
||||
)
|
||||
);
|
||||
}
|
||||
if (input.routeId === "content.engagement.event-detail") {
|
||||
const requestedId = input.params.id;
|
||||
return (
|
||||
positiveIdentifier(requestedId) &&
|
||||
data.items.length <= 1 &&
|
||||
data.total === data.items.length &&
|
||||
data.items.every(
|
||||
(item) =>
|
||||
item.id === requestedId &&
|
||||
positiveIdentifier(item.id) &&
|
||||
isContentEventDetailPayload(item.privatePayload),
|
||||
)
|
||||
);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
export function createContentQuery(
|
||||
|
||||
Reference in new issue
Block a user