Files
homaroma/apps/mcp-server/section-toolkit.test.ts
T
2026-07-24 20:08:21 -04:00

274 lines
8.1 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",
"CUSTOM",
] as const;
describe("Page Builder block contracts", () => {
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("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.",
],
});
});
});