From de94b47caee950a8e9ba485d399f7d146733a7d9 Mon Sep 17 00:00:00 2001 From: simoleo89 Date: Wed, 2 Sep 2026 18:02:54 +0200 Subject: [PATCH] fix(housekeeping): harden poll response paging --- .../content/queries/content-queries.test.ts | 115 ++++++++++++++++-- .../content/queries/content-queries.ts | 9 +- 2 files changed, 111 insertions(+), 13 deletions(-) diff --git a/src/features/housekeeping/domains/content/queries/content-queries.test.ts b/src/features/housekeeping/domains/content/queries/content-queries.test.ts index 9a735152..cf7e5271 100644 --- a/src/features/housekeeping/domains/content/queries/content-queries.test.ts +++ b/src/features/housekeeping/domains/content/queries/content-queries.test.ts @@ -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); diff --git a/src/features/housekeeping/domains/content/queries/content-queries.ts b/src/features/housekeeping/domains/content/queries/content-queries.ts index eb1346a2..d06df47e 100644 --- a/src/features/housekeeping/domains/content/queries/content-queries.ts +++ b/src/features/housekeeping/domains/content/queries/content-queries.ts @@ -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; }>; }