fix(housekeeping): harden poll response paging
This commit is contained in:
1 parent
cbc3075e5a
commit
de94b47cae
2 files changed
+111
-13
No files matched your search
@@ -6,6 +6,7 @@ import {
|
|||||||
type ContentQueryData,
|
type ContentQueryData,
|
||||||
type ContentQueryInput,
|
type ContentQueryInput,
|
||||||
createContentQuery,
|
createContentQuery,
|
||||||
|
type NormalizedContentQueryInput,
|
||||||
} from "./content-queries";
|
} from "./content-queries";
|
||||||
import {
|
import {
|
||||||
CONTENT_QUERY_DEFINITIONS,
|
CONTENT_QUERY_DEFINITIONS,
|
||||||
@@ -56,6 +57,11 @@ const ready: ContentQueryData = {
|
|||||||
total: 1,
|
total: 1,
|
||||||
partialDependencies: [],
|
partialDependencies: [],
|
||||||
};
|
};
|
||||||
|
const normalizedResponsePage = {
|
||||||
|
responseQuestionId: "",
|
||||||
|
responsePageSize: 25,
|
||||||
|
responseOffset: 0,
|
||||||
|
} as const;
|
||||||
|
|
||||||
const pollDetail = {
|
const pollDetail = {
|
||||||
kind: "poll-detail" as const,
|
kind: "poll-detail" as const,
|
||||||
@@ -102,7 +108,7 @@ describe("Content query", () => {
|
|||||||
const result = await loadProductionContentQuery({
|
const result = await loadProductionContentQuery({
|
||||||
routeId: "content.editorial.articles",
|
routeId: "content.editorial.articles",
|
||||||
params: {},
|
params: {},
|
||||||
list: { search: "", pageSize: 25, offset: 0 },
|
list: { ...normalizedResponsePage, search: "", pageSize: 25, offset: 0 },
|
||||||
});
|
});
|
||||||
expect(result.total).toBe(600);
|
expect(result.total).toBe(600);
|
||||||
expect(result.items).toHaveLength(25);
|
expect(result.items).toHaveLength(25);
|
||||||
@@ -121,7 +127,12 @@ describe("Content query", () => {
|
|||||||
await loadProductionContentQuery({
|
await loadProductionContentQuery({
|
||||||
routeId: "content.editorial.articles",
|
routeId: "content.editorial.articles",
|
||||||
params: {},
|
params: {},
|
||||||
list: { search: "Launch", pageSize: 7, offset: 14 },
|
list: {
|
||||||
|
...normalizedResponsePage,
|
||||||
|
search: "Launch",
|
||||||
|
pageSize: 7,
|
||||||
|
offset: 14,
|
||||||
|
},
|
||||||
});
|
});
|
||||||
const serialized = JSON.stringify(queryMocks.execute.mock.calls);
|
const serialized = JSON.stringify(queryMocks.execute.mock.calls);
|
||||||
expect(serialized).toContain("%launch%");
|
expect(serialized).toContain("%launch%");
|
||||||
@@ -136,7 +147,12 @@ describe("Content query", () => {
|
|||||||
await loadProductionContentQuery({
|
await loadProductionContentQuery({
|
||||||
routeId: "content.engagement.events",
|
routeId: "content.engagement.events",
|
||||||
params: {},
|
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));
|
const serialized = JSON.stringify(queryMocks.execute.mock.calls.slice(-2));
|
||||||
expect(serialized).toContain("COALESCE(title");
|
expect(serialized).toContain("COALESCE(title");
|
||||||
@@ -171,7 +187,7 @@ describe("Content query", () => {
|
|||||||
const result = await loadProductionContentQuery({
|
const result = await loadProductionContentQuery({
|
||||||
routeId: "content.editorial.article-detail",
|
routeId: "content.editorial.article-detail",
|
||||||
params: { id: "7" },
|
params: { id: "7" },
|
||||||
list: { search: "", pageSize: 25, offset: 0 },
|
list: { ...normalizedResponsePage, search: "", pageSize: 25, offset: 0 },
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(result.items[0]?.privatePayload).toEqual({
|
expect(result.items[0]?.privatePayload).toEqual({
|
||||||
@@ -208,7 +224,13 @@ describe("Content query", () => {
|
|||||||
const result = await loadProductionContentQuery({
|
const result = await loadProductionContentQuery({
|
||||||
routeId: "content.engagement.events",
|
routeId: "content.engagement.events",
|
||||||
params: {},
|
params: {},
|
||||||
list: { search: "", status: "published", pageSize: 25, offset: 0 },
|
list: {
|
||||||
|
...normalizedResponsePage,
|
||||||
|
search: "",
|
||||||
|
status: "published",
|
||||||
|
pageSize: 25,
|
||||||
|
offset: 0,
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(result.items[0]?.privatePayload).toEqual({
|
expect(result.items[0]?.privatePayload).toEqual({
|
||||||
@@ -245,7 +267,7 @@ describe("Content query", () => {
|
|||||||
const result = await loadProductionContentQuery({
|
const result = await loadProductionContentQuery({
|
||||||
routeId: "content.engagement.event-create",
|
routeId: "content.engagement.event-create",
|
||||||
params: {},
|
params: {},
|
||||||
list: { search: "", pageSize: 25, offset: 0 },
|
list: { ...normalizedResponsePage, search: "", pageSize: 25, offset: 0 },
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(result.items[0]).toMatchObject({
|
expect(result.items[0]).toMatchObject({
|
||||||
@@ -335,7 +357,7 @@ describe("Content query", () => {
|
|||||||
const result = await loadProductionContentQuery({
|
const result = await loadProductionContentQuery({
|
||||||
routeId: "content.engagement.event-detail",
|
routeId: "content.engagement.event-detail",
|
||||||
params: { id: "11" },
|
params: { id: "11" },
|
||||||
list: { search: "", pageSize: 25, offset: 0 },
|
list: { ...normalizedResponsePage, search: "", pageSize: 25, offset: 0 },
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(result.items).toHaveLength(1);
|
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([
|
it.each([
|
||||||
["mismatched id", "8", pollDetail],
|
["mismatched id", "8", pollDetail],
|
||||||
[
|
[
|
||||||
@@ -543,6 +575,64 @@ describe("Content query", () => {
|
|||||||
}),
|
}),
|
||||||
).toMatchObject({ ok: true });
|
).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 () => {
|
it("fails closed when an article detail payload is incomplete", async () => {
|
||||||
const query = createContentQuery({
|
const query = createContentQuery({
|
||||||
load: async () => ({
|
load: async () => ({
|
||||||
@@ -716,7 +806,7 @@ describe("Content query", () => {
|
|||||||
const result = await loadProductionContentQuery({
|
const result = await loadProductionContentQuery({
|
||||||
routeId: "content.engagement.event-create",
|
routeId: "content.engagement.event-create",
|
||||||
params: {},
|
params: {},
|
||||||
list: { search: "", pageSize: 25, offset: 0 },
|
list: { ...normalizedResponsePage, search: "", pageSize: 25, offset: 0 },
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(result.items).toHaveLength(30);
|
expect(result.items).toHaveLength(30);
|
||||||
@@ -754,7 +844,12 @@ describe("Content query", () => {
|
|||||||
loadProductionContentQuery({
|
loadProductionContentQuery({
|
||||||
routeId: "content.engagement.event-detail",
|
routeId: "content.engagement.event-detail",
|
||||||
params: { id: "11" },
|
params: { id: "11" },
|
||||||
list: { search: "", pageSize: 25, offset: 0 },
|
list: {
|
||||||
|
...normalizedResponsePage,
|
||||||
|
search: "",
|
||||||
|
pageSize: 25,
|
||||||
|
offset: 0,
|
||||||
|
},
|
||||||
}),
|
}),
|
||||||
).rejects.toThrow("event type limit");
|
).rejects.toThrow("event type limit");
|
||||||
|
|
||||||
@@ -784,7 +879,7 @@ describe("Content query", () => {
|
|||||||
await loadProductionContentQuery({
|
await loadProductionContentQuery({
|
||||||
routeId: "content.engagement.event-detail",
|
routeId: "content.engagement.event-detail",
|
||||||
params: { id: "11" },
|
params: { id: "11" },
|
||||||
list: { search: "", pageSize: 25, offset: 0 },
|
list: { ...normalizedResponsePage, search: "", pageSize: 25, offset: 0 },
|
||||||
});
|
});
|
||||||
|
|
||||||
const sql = JSON.stringify(queryMocks.execute.mock.calls);
|
const sql = JSON.stringify(queryMocks.execute.mock.calls);
|
||||||
|
|||||||
@@ -417,6 +417,9 @@ export function isContentPollDetailPayload(
|
|||||||
!safeCount(responses.offset) ||
|
!safeCount(responses.offset) ||
|
||||||
responses.items.length > responses.pageSize ||
|
responses.items.length > responses.pageSize ||
|
||||||
responses.total < responses.items.length ||
|
responses.total < responses.items.length ||
|
||||||
|
(responses.items.length > 0 &&
|
||||||
|
(responses.offset >= responses.total ||
|
||||||
|
responses.items.length > responses.total - responses.offset)) ||
|
||||||
!responses.items.every(isContentPollTextResponsePayload)
|
!responses.items.every(isContentPollTextResponsePayload)
|
||||||
)
|
)
|
||||||
return false;
|
return false;
|
||||||
@@ -458,9 +461,9 @@ export interface NormalizedContentQueryInput {
|
|||||||
status?: string;
|
status?: string;
|
||||||
pageSize: number;
|
pageSize: number;
|
||||||
offset: number;
|
offset: number;
|
||||||
responseQuestionId?: string;
|
responseQuestionId: string;
|
||||||
responsePageSize?: number;
|
responsePageSize: number;
|
||||||
responseOffset?: number;
|
responseOffset: number;
|
||||||
}>;
|
}>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in new issue
Block a user