feat(housekeeping): complete article workflow
This commit is contained in:
1 parent
fc050bd3f8
commit
fa33b4498e
9 files changed
+595
-3
No files matched your search
@@ -14,6 +14,7 @@ interface QueryDefinition {
|
||||
readonly statement: string;
|
||||
readonly href: string;
|
||||
readonly appendId?: boolean;
|
||||
readonly privatePayload?: (row: RawRow) => unknown;
|
||||
}
|
||||
|
||||
export const CONTENT_QUERY_DEFINITIONS = {
|
||||
@@ -25,9 +26,15 @@ export const CONTENT_QUERY_DEFINITIONS = {
|
||||
},
|
||||
"content.editorial.article-detail": {
|
||||
statement:
|
||||
"SELECT id, title, slug AS description, 'published' AS status, updated_at FROM website_articles ORDER BY created_at DESC",
|
||||
"SELECT id, title, slug AS description, slug, image, short_story, full_story, 'published' AS status, updated_at FROM website_articles ORDER BY created_at DESC",
|
||||
href: "/ase-next/content/editorial/articles/",
|
||||
appendId: true,
|
||||
privatePayload: (row) => ({
|
||||
slug: value(row.slug),
|
||||
image: value(row.image),
|
||||
shortStory: value(row.short_story),
|
||||
fullStory: value(row.full_story),
|
||||
}),
|
||||
},
|
||||
"content.editorial.tags": {
|
||||
statement:
|
||||
@@ -188,6 +195,9 @@ function mapRows(
|
||||
status: value(row.status) || undefined,
|
||||
updatedAt: updatedAt(row.updated_at),
|
||||
href: definition.appendId ? definition.href + id : definition.href,
|
||||
...(definition.privatePayload
|
||||
? { privatePayload: definition.privatePayload(row) }
|
||||
: {}),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
@@ -118,6 +118,41 @@ describe("Content query", () => {
|
||||
expect(emailTemplates.statement).not.toMatch(/\bbody\b|private_payload/iu);
|
||||
});
|
||||
|
||||
it("loads the editable payload only for an individual article", async () => {
|
||||
queryMocks.execute
|
||||
.mockResolvedValueOnce([[{ total: 1 }]])
|
||||
.mockResolvedValueOnce([
|
||||
[
|
||||
{
|
||||
id: 7,
|
||||
title: "Launch day",
|
||||
description: "launch-day",
|
||||
status: "published",
|
||||
updated_at: null,
|
||||
slug: "launch-day",
|
||||
image: "/media/launch.webp",
|
||||
short_story: "Short launch summary",
|
||||
full_story: "<p>Complete launch story</p>",
|
||||
},
|
||||
],
|
||||
]);
|
||||
|
||||
const result = await loadProductionContentQuery({
|
||||
routeId: "content.editorial.article-detail",
|
||||
params: { id: "7" },
|
||||
list: { search: "", pageSize: 25, offset: 0 },
|
||||
});
|
||||
|
||||
expect(result.items[0]?.privatePayload).toEqual({
|
||||
slug: "launch-day",
|
||||
image: "/media/launch.webp",
|
||||
shortStory: "Short launch summary",
|
||||
fullStory: "<p>Complete launch story</p>",
|
||||
});
|
||||
expect(
|
||||
CONTENT_QUERY_DEFINITIONS["content.editorial.articles"].statement,
|
||||
).not.toMatch(/short_story|full_story/iu);
|
||||
});
|
||||
it("fails closed before production adapters are invoked", async () => {
|
||||
const load = vi.fn(async () => ready);
|
||||
const query = createContentQuery({ load });
|
||||
@@ -162,6 +197,32 @@ describe("Content query", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("fails closed when an article detail payload is incomplete", async () => {
|
||||
const query = createContentQuery({
|
||||
load: async () => ({
|
||||
kind: "editorial",
|
||||
items: [
|
||||
{
|
||||
id: "7",
|
||||
title: "Launch day",
|
||||
privatePayload: { slug: "launch-day" },
|
||||
},
|
||||
],
|
||||
total: 1,
|
||||
partialDependencies: [],
|
||||
}),
|
||||
});
|
||||
|
||||
const result = await query.run(context([PERMS.NEWS_VIEW]), {
|
||||
routeId: "content.editorial.article-detail",
|
||||
params: { id: "7" },
|
||||
});
|
||||
|
||||
expect(result).toMatchObject({
|
||||
ok: false,
|
||||
error: { code: "DEPENDENCY_UNAVAILABLE" },
|
||||
});
|
||||
});
|
||||
it("rejects a fake partial result and maps complete adapter failure", async () => {
|
||||
const malformed = createContentQuery({
|
||||
load: async () =>
|
||||
|
||||
@@ -24,6 +24,26 @@ export interface ContentQueryInput {
|
||||
readonly list?: ContentQueryListInput;
|
||||
}
|
||||
|
||||
export interface ContentArticlePrivatePayload {
|
||||
readonly slug: string;
|
||||
readonly image: string;
|
||||
readonly shortStory: string;
|
||||
readonly fullStory: string;
|
||||
}
|
||||
|
||||
export function isContentArticlePrivatePayload(
|
||||
value: unknown,
|
||||
): value is ContentArticlePrivatePayload {
|
||||
if (value === null || typeof value !== "object") return false;
|
||||
const payload = value as Record<string, unknown>;
|
||||
return (
|
||||
typeof payload.slug === "string" &&
|
||||
typeof payload.image === "string" &&
|
||||
typeof payload.shortStory === "string" &&
|
||||
typeof payload.fullStory === "string"
|
||||
);
|
||||
}
|
||||
|
||||
export interface ContentQueryItem {
|
||||
readonly id: string;
|
||||
readonly title: string;
|
||||
@@ -100,7 +120,7 @@ function isValidData(
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
return data.items.every(
|
||||
const validItems = data.items.every(
|
||||
(item) =>
|
||||
typeof item.id === "string" &&
|
||||
item.id.length > 0 &&
|
||||
@@ -108,6 +128,14 @@ function isValidData(
|
||||
item.title.length > 0 &&
|
||||
(item.href === undefined || isSafeHousekeepingHref(item.href)),
|
||||
);
|
||||
if (!validItems) return false;
|
||||
if (input.routeId !== "content.editorial.article-detail") return true;
|
||||
return (
|
||||
data.items.length <= 1 &&
|
||||
data.items.every((item) =>
|
||||
isContentArticlePrivatePayload(item.privatePayload),
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
export function createContentQuery(
|
||||
|
||||
Reference in new issue
Block a user