fix(housekeeping): harden poll response paging

This commit is contained in:
Simo committed 2026-09-02 18:02:54 +02:00
1 parent cbc3075e5a
commit de94b47cae
2 files changed
+111 -13

No files matched your search

@@ -6,6 +6,7 @@ import {
type ContentQueryData,
type ContentQueryInput,
createContentQuery,
type NormalizedContentQueryInput,
} from "./content-queries";
import {
CONTENT_QUERY_DEFINITIONS,
@@ -56,6 +57,11 @@ const ready: ContentQueryData = {
total: 1,
partialDependencies: [],
};
const normalizedResponsePage = {
responseQuestionId: "",
responsePageSize: 25,
responseOffset: 0,
} as const;
const pollDetail = {
kind: "poll-detail" as const,
@@ -102,7 +108,7 @@ describe("Content query", () => {
const result = await loadProductionContentQuery({
routeId: "content.editorial.articles",
params: {},
list: { search: "", pageSize: 25, offset: 0 },
list: { ...normalizedResponsePage, search: "", pageSize: 25, offset: 0 },
});
expect(result.total).toBe(600);
expect(result.items).toHaveLength(25);
@@ -121,7 +127,12 @@ describe("Content query", () => {
await loadProductionContentQuery({
routeId: "content.editorial.articles",
params: {},
list: { search: "Launch", pageSize: 7, offset: 14 },
list: {
...normalizedResponsePage,
search: "Launch",
pageSize: 7,
offset: 14,
},
});
const serialized = JSON.stringify(queryMocks.execute.mock.calls);
expect(serialized).toContain("%launch%");
@@ -136,7 +147,12 @@ describe("Content query", () => {
await loadProductionContentQuery({
routeId: "content.engagement.events",
params: {},
list: { search: "launch", pageSize: 25, offset: 0 },
list: {
...normalizedResponsePage,
search: "launch",
pageSize: 25,
offset: 0,
},
});
const serialized = JSON.stringify(queryMocks.execute.mock.calls.slice(-2));
expect(serialized).toContain("COALESCE(title");
@@ -171,7 +187,7 @@ describe("Content query", () => {
const result = await loadProductionContentQuery({
routeId: "content.editorial.article-detail",
params: { id: "7" },
list: { search: "", pageSize: 25, offset: 0 },
list: { ...normalizedResponsePage, search: "", pageSize: 25, offset: 0 },
});
expect(result.items[0]?.privatePayload).toEqual({
@@ -208,7 +224,13 @@ describe("Content query", () => {
const result = await loadProductionContentQuery({
routeId: "content.engagement.events",
params: {},
list: { search: "", status: "published", pageSize: 25, offset: 0 },
list: {
...normalizedResponsePage,
search: "",
status: "published",
pageSize: 25,
offset: 0,
},
});
expect(result.items[0]?.privatePayload).toEqual({
@@ -245,7 +267,7 @@ describe("Content query", () => {
const result = await loadProductionContentQuery({
routeId: "content.engagement.event-create",
params: {},
list: { search: "", pageSize: 25, offset: 0 },
list: { ...normalizedResponsePage, search: "", pageSize: 25, offset: 0 },
});
expect(result.items[0]).toMatchObject({
@@ -335,7 +357,7 @@ describe("Content query", () => {
const result = await loadProductionContentQuery({
routeId: "content.engagement.event-detail",
params: { id: "11" },
list: { search: "", pageSize: 25, offset: 0 },
list: { ...normalizedResponsePage, search: "", pageSize: 25, offset: 0 },
});
expect(result.items).toHaveLength(1);
@@ -436,6 +458,16 @@ describe("Content query", () => {
});
});
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],
[
@@ -543,6 +575,64 @@ describe("Content query", () => {
}),
).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 () => ({
@@ -716,7 +806,7 @@ describe("Content query", () => {
const result = await loadProductionContentQuery({
routeId: "content.engagement.event-create",
params: {},
list: { search: "", pageSize: 25, offset: 0 },
list: { ...normalizedResponsePage, search: "", pageSize: 25, offset: 0 },
});
expect(result.items).toHaveLength(30);
@@ -754,7 +844,12 @@ describe("Content query", () => {
loadProductionContentQuery({
routeId: "content.engagement.event-detail",
params: { id: "11" },
list: { search: "", pageSize: 25, offset: 0 },
list: {
...normalizedResponsePage,
search: "",
pageSize: 25,
offset: 0,
},
}),
).rejects.toThrow("event type limit");
@@ -784,7 +879,7 @@ describe("Content query", () => {
await loadProductionContentQuery({
routeId: "content.engagement.event-detail",
params: { id: "11" },
list: { search: "", pageSize: 25, offset: 0 },
list: { ...normalizedResponsePage, search: "", pageSize: 25, offset: 0 },
});
const sql = JSON.stringify(queryMocks.execute.mock.calls);
@@ -417,6 +417,9 @@ export function isContentPollDetailPayload(
!safeCount(responses.offset) ||
responses.items.length > responses.pageSize ||
responses.total < responses.items.length ||
(responses.items.length > 0 &&
(responses.offset >= responses.total ||
responses.items.length > responses.total - responses.offset)) ||
!responses.items.every(isContentPollTextResponsePayload)
)
return false;
@@ -458,9 +461,9 @@ export interface NormalizedContentQueryInput {
status?: string;
pageSize: number;
offset: number;
responseQuestionId?: string;
responsePageSize?: number;
responseOffset?: number;
responseQuestionId: string;
responsePageSize: number;
responseOffset: number;
}>;
}