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.", ], }); }); 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); }); });