474 lines
15 KiB
TypeScript
474 lines
15 KiB
TypeScript
import { describe, expect, test } from "bun:test";
|
|
import {
|
|
getPageBlockSchema,
|
|
handleSectionTool,
|
|
validatePayloadAgainstMetadata,
|
|
} from "./section-toolkit";
|
|
|
|
const websiteBlockTypes = [
|
|
"HERO",
|
|
"ANNOUNCEMENT_BAR",
|
|
"NEWSLETTER",
|
|
"EDITORIAL_TILE",
|
|
"EDITORIAL_CAROUSEL",
|
|
"PRODUCTS",
|
|
"CATEGORIES",
|
|
"TEXT",
|
|
"BANNER",
|
|
"HERO_ARTICLE",
|
|
"RICH_TEXT",
|
|
"ARTICLE_HEADER",
|
|
"ARTICLE_MEDIA",
|
|
"ARTICLE_GALLERY",
|
|
"ARTICLE_FACTS",
|
|
"ARTICLE_VIDEO",
|
|
"ARTICLE_REFERENCES",
|
|
"ARTICLE_TABLE",
|
|
"ARTICLE_DOWNLOAD",
|
|
"ARTICLE_CTA",
|
|
"SCENT_NOTES_BREAKDOWN",
|
|
"QUOTE_CALLOUT",
|
|
"PRODUCT_CAROUSEL",
|
|
"ACCORDION",
|
|
"CONTACT_INFO",
|
|
"CONTACT_FORM",
|
|
"HERO_V2",
|
|
"USP_BAR",
|
|
"HERO_MINIMAL",
|
|
"HERO_SPLIT_MINIMAL",
|
|
"HERO_VIDEO_MINIMAL",
|
|
"BANNER_MINIMAL",
|
|
"ANNOUNCEMENT_BAR_MINIMAL",
|
|
"USP_MINIMAL",
|
|
"CUSTOM",
|
|
] as const;
|
|
|
|
const minimalBlockTypes = [
|
|
"HERO_MINIMAL",
|
|
"HERO_SPLIT_MINIMAL",
|
|
"HERO_VIDEO_MINIMAL",
|
|
"BANNER_MINIMAL",
|
|
"ANNOUNCEMENT_BAR_MINIMAL",
|
|
"USP_MINIMAL",
|
|
] as const;
|
|
|
|
describe("Page Builder block contracts", () => {
|
|
test("reusable payload examples do not carry another tenant's identity", () => {
|
|
for (const type of websiteBlockTypes) {
|
|
const schema = getPageBlockSchema(type);
|
|
const examples = JSON.stringify({
|
|
minimalPayload: schema.minimalPayload,
|
|
recommendedPayload: schema.recommendedPayload,
|
|
legacyExample: schema.legacyExample,
|
|
});
|
|
|
|
expect(examples).not.toMatch(/homaroma|ddc|kitizone|pkh|@homaroma|hello@homaroma\.shop/i);
|
|
}
|
|
|
|
const contact = getPageBlockSchema("CONTACT_INFO");
|
|
expect(contact.warnings).toContain("Replace sample contact details with the active tenant's verified address, email, LINE ID, and business hours before writing.");
|
|
expect(contact.recommendedPayload).toMatchObject({ email: "hello@example.com", line: "@your-line-id" });
|
|
});
|
|
|
|
for (const type of websiteBlockTypes) {
|
|
test(`${type} exposes a valid recommended payload`, () => {
|
|
const schema = getPageBlockSchema(type);
|
|
|
|
expect(schema.renderability.websitePageRenderable).toBe(true);
|
|
expect(schema.recommendedPayload).not.toBeNull();
|
|
expect(validatePayloadAgainstMetadata(type, schema.recommendedPayload).valid).toBe(true);
|
|
});
|
|
}
|
|
|
|
test("minimal blocks accept backend-valid payloads with optional copy omitted", () => {
|
|
expect(validatePayloadAgainstMetadata("HERO_MINIMAL", { imageUrl: "/hero.jpg", title: { th: "Hero" } }).valid).toBe(true);
|
|
expect(validatePayloadAgainstMetadata("HERO_SPLIT_MINIMAL", { imageUrl: "/hero.jpg", title: { th: "Hero" } }).valid).toBe(true);
|
|
expect(validatePayloadAgainstMetadata("HERO_VIDEO_MINIMAL", { videoUrl: "/hero.mp4" }).valid).toBe(true);
|
|
expect(validatePayloadAgainstMetadata("BANNER_MINIMAL", { title: { th: "Banner" } }).valid).toBe(true);
|
|
expect(validatePayloadAgainstMetadata("ANNOUNCEMENT_BAR_MINIMAL", { slides: [{ th: "Announcement" }] }).valid).toBe(true);
|
|
expect(validatePayloadAgainstMetadata("USP_MINIMAL", { items: [{ th: "Benefit" }] }).valid).toBe(true);
|
|
});
|
|
|
|
test("minimal blocks reject missing required fields and malformed items", () => {
|
|
for (const type of minimalBlockTypes) {
|
|
expect(validatePayloadAgainstMetadata(type, {}).valid).toBe(false);
|
|
}
|
|
expect(validatePayloadAgainstMetadata("USP_MINIMAL", { items: [{ icon: "leaf", title: { th: "Benefit" } }] }).valid).toBe(false);
|
|
expect(validatePayloadAgainstMetadata("ANNOUNCEMENT_BAR_MINIMAL", { slides: [] }).valid).toBe(false);
|
|
});
|
|
|
|
test("minimal blocks pass page creation and section addition preflight", async () => {
|
|
const calls: Array<{ url: string; payload: any }> = [];
|
|
const api = {
|
|
post: async (url: string, payload: any) => {
|
|
calls.push({ url, payload });
|
|
return { data: { pageId: 50, sectionId: 120, section: { id: 120, type: payload.section?.type ?? payload.type } } };
|
|
},
|
|
} as any;
|
|
|
|
for (const type of minimalBlockTypes) {
|
|
const payload = getPageBlockSchema(type).recommendedPayload;
|
|
const create = await handleSectionTool(api, "create_page_with_section", {
|
|
title: "Minimal page", slug: `minimal-${type.toLowerCase()}`, type, payload,
|
|
});
|
|
const add = await handleSectionTool(api, "add_page_section", { pageId: 50, type, payload });
|
|
|
|
expect(create && "isError" in create ? create.isError : false).toBe(false);
|
|
expect(add && "isError" in add ? add.isError : false).toBe(false);
|
|
}
|
|
|
|
expect(calls).toHaveLength(minimalBlockTypes.length * 2);
|
|
expect(calls.filter((call) => call.url === "/pages/with-section")).toHaveLength(minimalBlockTypes.length);
|
|
expect(calls.filter((call) => call.url === "/pages/50/sections")).toHaveLength(minimalBlockTypes.length);
|
|
});
|
|
|
|
test("HERO_V2 rejects the legacy empty payload before backend writes", () => {
|
|
const result = validatePayloadAgainstMetadata("HERO_V2", {});
|
|
|
|
expect(result.valid).toBe(false);
|
|
expect(result.missingFields).toEqual(["title", "imageUrl"]);
|
|
});
|
|
|
|
test("USP_BAR rejects an empty payload because the website renders nothing", () => {
|
|
const result = validatePayloadAgainstMetadata("USP_BAR", {});
|
|
|
|
expect(result.valid).toBe(false);
|
|
expect(result.missingFields).toContain("items");
|
|
});
|
|
|
|
test("CUSTOM rejects component IDs missing from the website registry", () => {
|
|
const result = validatePayloadAgainstMetadata("CUSTOM", {
|
|
componentId: "UnknownComponent",
|
|
props: {},
|
|
});
|
|
|
|
expect(result.valid).toBe(false);
|
|
expect(result.validationIssues).toContainEqual({
|
|
path: "componentId",
|
|
message: "Unsupported website custom component. Use BakeryCategories or StoryFeatureMosaic.",
|
|
code: "custom",
|
|
});
|
|
});
|
|
|
|
test("ARTICLE_MEDIA requires imageAlt when imageUrl is provided", () => {
|
|
const result = validatePayloadAgainstMetadata("ARTICLE_MEDIA", {
|
|
body: { th: "ข้อความภาษาไทย" },
|
|
imageUrl: "/uploads/article.jpg",
|
|
});
|
|
|
|
expect(result.valid).toBe(false);
|
|
expect(result.validationIssues).toContainEqual({
|
|
path: "imageAlt",
|
|
message: "imageAlt is required when imageUrl is provided",
|
|
code: "custom",
|
|
});
|
|
});
|
|
|
|
test("ARTICLE_GALLERY rejects fewer than two images", () => {
|
|
const result = validatePayloadAgainstMetadata("ARTICLE_GALLERY", {
|
|
images: [{ imageUrl: "/uploads/one.jpg", alt: { th: "ภาพเดียว" } }],
|
|
});
|
|
|
|
expect(result.valid).toBe(false);
|
|
expect(result.validationIssues.some((issue) => issue.path === "images")).toBe(true);
|
|
});
|
|
|
|
test("ARTICLE_VIDEO rejects non-HTTPS URLs", () => {
|
|
const result = validatePayloadAgainstMetadata("ARTICLE_VIDEO", {
|
|
videoUrl: "http://example.com/video.mp4",
|
|
});
|
|
|
|
expect(result.valid).toBe(false);
|
|
expect(result.validationIssues.some((issue) => issue.path === "videoUrl")).toBe(true);
|
|
});
|
|
|
|
test("ARTICLE_TABLE requires every row to match the column count", () => {
|
|
const result = validatePayloadAgainstMetadata("ARTICLE_TABLE", {
|
|
columns: [{ th: "A" }, { th: "B" }],
|
|
rows: [{ label: { th: "Row" }, cells: [{ th: "Only one" }] }],
|
|
});
|
|
|
|
expect(result.valid).toBe(false);
|
|
expect(result.validationIssues.some((issue) => issue.path === "rows.0.cells")).toBe(true);
|
|
});
|
|
|
|
test("ARTICLE_CTA requires secondaryLink with secondaryText", () => {
|
|
const result = validatePayloadAgainstMetadata("ARTICLE_CTA", {
|
|
title: { th: "ติดต่อเรา" },
|
|
primaryText: { th: "สอบถาม" },
|
|
primaryLink: "/contact",
|
|
secondaryText: { th: "สินค้า" },
|
|
});
|
|
|
|
expect(result.valid).toBe(false);
|
|
expect(result.validationIssues.some((issue) => issue.path === "secondaryLink")).toBe(true);
|
|
});
|
|
|
|
test("create_page_with_section rejects invalid payloads without calling backend", async () => {
|
|
let postCalls = 0;
|
|
const api = {
|
|
post: async () => {
|
|
postCalls += 1;
|
|
throw new Error("Backend must not be called");
|
|
},
|
|
} as any;
|
|
|
|
const result = await handleSectionTool(api, "create_page_with_section", {
|
|
title: "Invalid Hero",
|
|
slug: "invalid-hero",
|
|
type: "HERO_V2",
|
|
payload: {},
|
|
});
|
|
|
|
expect(result && "isError" in result ? result.isError : false).toBe(true);
|
|
expect(postCalls).toBe(0);
|
|
});
|
|
|
|
test("create_page_with_section uses one atomic backend call and returns compact output", async () => {
|
|
const calls: Array<{ url: string; payload: unknown }> = [];
|
|
const api = {
|
|
post: async (url: string, payload: unknown) => {
|
|
calls.push({ url, payload });
|
|
return {
|
|
data: {
|
|
success: true,
|
|
pageId: 50,
|
|
sectionId: 120,
|
|
page: { id: 50, slug: "hero-v2" },
|
|
section: { id: 120, type: "HERO_V2" },
|
|
},
|
|
};
|
|
},
|
|
} as any;
|
|
const payload = getPageBlockSchema("HERO_V2").recommendedPayload;
|
|
|
|
const result = await handleSectionTool(api, "create_page_with_section", {
|
|
title: "Hero V2",
|
|
slug: "hero-v2",
|
|
type: "HERO_V2",
|
|
payload,
|
|
isPublished: false,
|
|
});
|
|
const response = JSON.parse(result?.content[0]?.text ?? "{}");
|
|
|
|
expect(calls).toHaveLength(1);
|
|
expect(calls[0]?.url).toBe("/pages/with-section");
|
|
expect(response).toEqual({
|
|
success: true,
|
|
tool: "create_page_with_section",
|
|
pageId: 50,
|
|
pageSlug: "hero-v2",
|
|
sectionId: 120,
|
|
type: "HERO_V2",
|
|
preview: {
|
|
lang: "th",
|
|
previewPath: "/th/hero-v2",
|
|
previewUrl: "/th/hero-v2",
|
|
},
|
|
warnings: [
|
|
"contentAlignment must be left, center, or right. Use an image URL allowed by apps/website.",
|
|
],
|
|
});
|
|
});
|
|
|
|
test("add_page_section rejects invalid payloads without calling backend", async () => {
|
|
let postCalls = 0;
|
|
const api = {
|
|
post: async () => {
|
|
postCalls += 1;
|
|
throw new Error("Backend must not be called");
|
|
},
|
|
} as any;
|
|
|
|
const result = await handleSectionTool(api, "add_page_section", {
|
|
pageId: 15,
|
|
type: "HERO_V2",
|
|
payload: {},
|
|
});
|
|
|
|
expect(result && "isError" in result ? result.isError : false).toBe(true);
|
|
expect(postCalls).toBe(0);
|
|
});
|
|
|
|
test("add_page_section uses the existing-page endpoint and returns compact output", async () => {
|
|
const calls: Array<{ url: string; payload: unknown }> = [];
|
|
const api = {
|
|
post: async (url: string, payload: unknown) => {
|
|
calls.push({ url, payload });
|
|
return {
|
|
data: {
|
|
success: true,
|
|
pageId: 15,
|
|
pageSlug: "fine-blanking-ddc-3-in-1",
|
|
sectionId: 36,
|
|
section: { id: 36, type: "HERO_ARTICLE", order: 2 },
|
|
},
|
|
};
|
|
},
|
|
} as any;
|
|
const payload = getPageBlockSchema("HERO_ARTICLE").recommendedPayload;
|
|
|
|
const result = await handleSectionTool(api, "add_page_section", {
|
|
pageId: 15,
|
|
pageSlug: "fine-blanking-ddc-3-in-1",
|
|
type: "HERO_ARTICLE",
|
|
payload,
|
|
order: 2,
|
|
});
|
|
const response = JSON.parse(result?.content[0]?.text ?? "{}");
|
|
|
|
expect(calls).toEqual([{
|
|
url: "/pages/15/sections",
|
|
payload: {
|
|
type: "HERO_ARTICLE",
|
|
payload,
|
|
isActive: true,
|
|
order: 2,
|
|
},
|
|
}]);
|
|
expect(response).toEqual({
|
|
success: true,
|
|
tool: "add_page_section",
|
|
pageId: 15,
|
|
pageSlug: "fine-blanking-ddc-3-in-1",
|
|
preview: {
|
|
lang: "th",
|
|
previewPath: "/th/fine-blanking-ddc-3-in-1",
|
|
previewUrl: "/th/fine-blanking-ddc-3-in-1",
|
|
},
|
|
sectionId: 36,
|
|
type: "HERO_ARTICLE",
|
|
order: 2,
|
|
warnings: [
|
|
"overlayColor is accepted by backend schema but is not used by apps/website HeroArticleSection today.",
|
|
],
|
|
});
|
|
});
|
|
|
|
test("get_page_section_translation requests one language with source fallback", async () => {
|
|
const calls: string[] = [];
|
|
const api = {
|
|
get: async (url: string) => {
|
|
calls.push(url);
|
|
return {
|
|
data: {
|
|
id: 41,
|
|
type: "ARTICLE_REFERENCES",
|
|
payload: {
|
|
title: "参考资料",
|
|
items: [{ title: "精密冲裁技术", note: null, url: "https://example.com" }],
|
|
},
|
|
locale: {
|
|
lang: "zh",
|
|
fallback: true,
|
|
missingPaths: [],
|
|
fallbackPaths: ["items[0].note"],
|
|
},
|
|
updatedAt: "2026-07-24T10:00:00.000Z",
|
|
},
|
|
};
|
|
},
|
|
} as any;
|
|
|
|
const result = await handleSectionTool(api, "get_page_section_translation", {
|
|
sectionId: 41,
|
|
lang: "zh",
|
|
});
|
|
const response = JSON.parse(result?.content[0]?.text ?? "{}");
|
|
|
|
expect(calls).toEqual([
|
|
"/pages/sections/41?lang=zh&view=resolved&fallback=true",
|
|
]);
|
|
expect(response).toEqual({
|
|
sectionId: 41,
|
|
sectionType: "ARTICLE_REFERENCES",
|
|
lang: "zh",
|
|
payload: {
|
|
title: "参考资料",
|
|
items: [{ title: "精密冲裁技术", note: null, url: "https://example.com" }],
|
|
},
|
|
missingPaths: [],
|
|
fallbackPaths: ["items[0].note"],
|
|
updatedAt: "2026-07-24T10:00:00.000Z",
|
|
});
|
|
});
|
|
|
|
test("update_section_translation sends only translation fields and version", async () => {
|
|
const calls: Array<{ url: string; payload: unknown }> = [];
|
|
const api = {
|
|
patch: async (url: string, payload: unknown) => {
|
|
calls.push({ url, payload });
|
|
return {
|
|
data: {
|
|
success: true,
|
|
sectionId: 41,
|
|
sectionType: "ARTICLE_REFERENCES",
|
|
lang: "zh",
|
|
translatedPaths: ["title", "items[0].title"],
|
|
preservedOtherLanguages: true,
|
|
updatedAt: "2026-07-24T10:01:00.000Z",
|
|
},
|
|
};
|
|
},
|
|
} as any;
|
|
const translations = {
|
|
title: "参考资料",
|
|
items: [{ title: "精密冲裁技术" }],
|
|
};
|
|
|
|
const result = await handleSectionTool(api, "update_section_translation", {
|
|
sectionId: 41,
|
|
lang: "zh",
|
|
translations,
|
|
expectedUpdatedAt: "2026-07-24T10:00:00.000Z",
|
|
});
|
|
const response = JSON.parse(result?.content[0]?.text ?? "{}");
|
|
|
|
expect(calls).toEqual([{
|
|
url: "/pages/sections/41/translations/zh",
|
|
payload: {
|
|
translations,
|
|
expectedUpdatedAt: "2026-07-24T10:00:00.000Z",
|
|
},
|
|
}]);
|
|
expect(response.preservedOtherLanguages).toBe(true);
|
|
expect(response.translatedPaths).toEqual(["title", "items[0].title"]);
|
|
});
|
|
|
|
test("get_page_translation_status requests a compact language audit", async () => {
|
|
const calls: string[] = [];
|
|
const api = {
|
|
get: async (url: string) => {
|
|
calls.push(url);
|
|
return {
|
|
data: {
|
|
success: true,
|
|
pageId: 15,
|
|
lang: "zh",
|
|
complete: false,
|
|
translatedSections: 8,
|
|
totalSections: 9,
|
|
missingSections: [{
|
|
sectionId: 41,
|
|
type: "ARTICLE_REFERENCES",
|
|
order: 9,
|
|
complete: false,
|
|
missingPaths: ["items[0].note"],
|
|
}],
|
|
},
|
|
};
|
|
},
|
|
} as any;
|
|
|
|
const result = await handleSectionTool(api, "get_page_translation_status", {
|
|
pageId: 15,
|
|
lang: "zh",
|
|
});
|
|
const response = JSON.parse(result?.content[0]?.text ?? "{}");
|
|
|
|
expect(calls).toEqual(["/pages/translation-status/15?lang=zh"]);
|
|
expect(response.totalSections).toBe(9);
|
|
expect(response.missingSections[0].sectionId).toBe(41);
|
|
});
|
|
});
|