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
@@ -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" },
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user