feat(housekeeping): define typed poll query contract
This commit is contained in:
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;
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user