feat(housekeeping): define typed poll query contract

This commit is contained in:
Simo committed 2026-09-02 17:52:41 +02:00
1 parent e69633b753
commit cbc3075e5a
4 files changed
+432 -8

No files matched your search

@@ -92,5 +92,8 @@ export function parseContentListInput(
status: text(searchParams.status, 32).toLocaleLowerCase(),
pageSize: bounded(searchParams.pageSize, 25, 1, 100),
offset: bounded(searchParams.offset, 0, 0, 100_000),
responseQuestionId: text(searchParams.responseQuestionId, 32),
responsePageSize: bounded(searchParams.responsePageSize, 25, 1, 50),
responseOffset: bounded(searchParams.responseOffset, 0, 0, 100_000),
};
}
@@ -482,6 +482,9 @@ describe("Content actionable form wiring", () => {
status: "published",
pageSize: 25,
offset: 25,
responseQuestionId: "",
responsePageSize: 25,
responseOffset: 0,
});
const html = renderToStaticMarkup(
@@ -57,6 +57,35 @@ const ready: ContentQueryData = {
partialDependencies: [],
};
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());
@@ -355,7 +384,14 @@ describe("Content query", () => {
expect(load).toHaveBeenCalledWith({
routeId: "content.editorial.articles",
params: {},
list: { search: "launch", pageSize: 100, offset: 100_000 },
list: {
search: "launch",
pageSize: 100,
offset: 100_000,
responseQuestionId: "",
responsePageSize: 25,
responseOffset: 0,
},
});
expect(result).toMatchObject({
ok: true,
@@ -367,6 +403,146 @@ describe("Content query", () => {
});
});
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.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("fails closed when an article detail payload is incomplete", async () => {
const query = createContentQuery({
load: async () => ({
@@ -417,6 +593,9 @@ describe("Content query", () => {
status: `published${"x".repeat(23)}`,
pageSize: 25,
offset: 0,
responseQuestionId: "",
responsePageSize: 25,
responseOffset: 0,
},
});
});
@@ -488,19 +667,31 @@ describe("Content query", () => {
});
it("has an authorized production query path for every Content route", async () => {
const load = vi.fn(async (input: ContentQueryInput) => ({
kind: input.routeId.split(".")[1] as ContentQueryData["kind"],
items: [],
total: 0,
partialDependencies: [],
}));
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.event-detail" ||
routeId === "content.engagement.poll-detail"
? { params: { id: "1" } }
: {}),
});
@@ -1,3 +1,7 @@
import {
POLL_QUESTION_TYPES,
type PollQuestionType,
} from "@/lib/polls/poll-semantics";
import { authorizeHousekeeping } from "../../../foundation/authorization";
import {
fail,
@@ -17,6 +21,9 @@ export interface ContentQueryListInput {
readonly status?: string;
readonly pageSize?: number;
readonly offset?: number;
readonly responseQuestionId?: string;
readonly responsePageSize?: number;
readonly responseOffset?: number;
}
export interface ContentQueryInput {
@@ -248,6 +255,183 @@ export function isContentEventDetailPayload(
payload.registrations.every(isContentEventRegistrationPayload)
);
}
export type ContentPollQuestionType = PollQuestionType;
export interface ContentPollCreatePayload {
readonly kind: "poll-create";
}
export interface ContentPollSummaryPayload {
readonly kind: "poll-summary";
readonly showResults: boolean;
readonly multipleChoice: boolean;
readonly startsAt: string | null;
readonly endsAt: string | null;
readonly questionCount: number;
readonly voterCount: number;
readonly answerCount: number;
}
export interface ContentPollChoiceResultPayload {
readonly option: string;
readonly count: number;
}
export interface ContentPollQuestionPayload {
readonly id: string;
readonly question: string;
readonly type: ContentPollQuestionType;
readonly sortOrder: number;
readonly options: readonly string[];
readonly answerCount: number;
readonly choiceResults: readonly ContentPollChoiceResultPayload[];
}
export interface ContentPollTextResponsePayload {
readonly id: string;
readonly questionId: string;
readonly userId: string;
readonly username: string | null;
readonly answer: string;
readonly createdAt: string;
}
export interface ContentPollTextResponsePagePayload {
readonly questionId: string | null;
readonly items: readonly ContentPollTextResponsePayload[];
readonly total: number;
readonly pageSize: number;
readonly offset: number;
}
export interface ContentPollDetailPayload {
readonly kind: "poll-detail";
readonly description: string;
readonly showResults: boolean;
readonly multipleChoice: boolean;
readonly startsAt: string | null;
readonly endsAt: string | null;
readonly questionCount: number;
readonly voterCount: number;
readonly answerCount: number;
readonly questions: readonly ContentPollQuestionPayload[];
readonly textResponses: ContentPollTextResponsePagePayload;
}
function isContentPollQuestionPayload(
value: unknown,
): value is ContentPollQuestionPayload {
const payload = object(value);
const options = payload?.options;
const choiceResults = payload?.choiceResults;
return (
positiveIdentifier(payload?.id) &&
typeof payload.question === "string" &&
typeof payload.type === "string" &&
POLL_QUESTION_TYPES.includes(payload.type as ContentPollQuestionType) &&
safeCount(payload.sortOrder) &&
Array.isArray(options) &&
options.length <= 100 &&
options.every((option) => typeof option === "string") &&
safeCount(payload.answerCount) &&
Array.isArray(choiceResults) &&
choiceResults.length <= 100 &&
choiceResults.every((choice) => {
const result = object(choice);
return (
typeof result?.option === "string" &&
options.includes(result.option) &&
safeCount(result.count)
);
})
);
}
function isContentPollTextResponsePayload(
value: unknown,
): value is ContentPollTextResponsePayload {
const payload = object(value);
return (
positiveIdentifier(payload?.id) &&
positiveIdentifier(payload.questionId) &&
positiveIdentifier(payload.userId) &&
stringOrNull(payload.username) &&
typeof payload.answer === "string" &&
canonicalDate(payload.createdAt)
);
}
export function isContentPollCreatePayload(
value: unknown,
): value is ContentPollCreatePayload {
return object(value)?.kind === "poll-create";
}
export function isContentPollSummaryPayload(
value: unknown,
): value is ContentPollSummaryPayload {
const payload = object(value);
return (
payload?.kind === "poll-summary" &&
typeof payload.showResults === "boolean" &&
typeof payload.multipleChoice === "boolean" &&
dateOrNull(payload.startsAt) &&
dateOrNull(payload.endsAt) &&
safeCount(payload.questionCount) &&
safeCount(payload.voterCount) &&
safeCount(payload.answerCount)
);
}
export function isContentPollDetailPayload(
value: unknown,
): value is ContentPollDetailPayload {
const payload = object(value);
if (
payload?.kind !== "poll-detail" ||
typeof payload.description !== "string" ||
typeof payload.showResults !== "boolean" ||
typeof payload.multipleChoice !== "boolean" ||
!dateOrNull(payload.startsAt) ||
!dateOrNull(payload.endsAt) ||
!safeCount(payload.questionCount) ||
!safeCount(payload.voterCount) ||
!safeCount(payload.answerCount) ||
!Array.isArray(payload.questions) ||
payload.questions.length > 100 ||
!payload.questions.every(isContentPollQuestionPayload) ||
payload.questionCount !== payload.questions.length
)
return false;
const responses = object(payload.textResponses);
if (
!responses ||
(responses.questionId !== null &&
!positiveIdentifier(responses.questionId)) ||
!Array.isArray(responses.items) ||
responses.items.length > 50 ||
!safeCount(responses.total) ||
!positiveCount(responses.pageSize) ||
responses.pageSize > 50 ||
!safeCount(responses.offset) ||
responses.items.length > responses.pageSize ||
responses.total < responses.items.length ||
!responses.items.every(isContentPollTextResponsePayload)
)
return false;
if (responses.questionId === null) return responses.items.length === 0;
return (
payload.questions.some(
(question) =>
question.id === responses.questionId && question.type === "text",
) &&
responses.items.every(
(response) => response.questionId === responses.questionId,
)
);
}
export interface ContentQueryItem {
readonly id: string;
@@ -274,6 +458,9 @@ export interface NormalizedContentQueryInput {
status?: string;
pageSize: number;
offset: number;
responseQuestionId?: string;
responsePageSize?: number;
responseOffset?: number;
}>;
}
@@ -314,6 +501,12 @@ function normalizeInput(input: ContentQueryInput): NormalizedContentQueryInput {
...(status ? { status } : {}),
pageSize: boundedInteger(input.list?.pageSize, 25, 1, 100),
offset: boundedInteger(input.list?.offset, 0, 0, 100_000),
responseQuestionId: String(input.list?.responseQuestionId ?? "")
.normalize("NFC")
.trim()
.slice(0, 32),
responsePageSize: boundedInteger(input.list?.responsePageSize, 25, 1, 50),
responseOffset: boundedInteger(input.list?.responseOffset, 0, 0, 100_000),
},
};
}
@@ -390,6 +583,40 @@ function isValidData(
)
);
}
if (input.routeId === "content.engagement.polls") {
return (
data.items.length <= input.list.pageSize &&
data.items.length <= data.total &&
data.items.every(
(item) =>
positiveIdentifier(item.id) &&
isContentPollSummaryPayload(item.privatePayload),
)
);
}
if (input.routeId === "content.engagement.poll-create") {
return (
data.total === 1 &&
data.items.length === 1 &&
data.items[0]?.id === "create" &&
isContentPollCreatePayload(data.items[0]?.privatePayload)
);
}
if (input.routeId === "content.engagement.poll-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) &&
isContentPollDetailPayload(item.privatePayload),
)
);
}
return true;
}