290 lines
15 KiB
JavaScript
290 lines
15 KiB
JavaScript
const assert = require("node:assert/strict");
|
|
const fs = require("node:fs");
|
|
const path = require("node:path");
|
|
const vm = require("node:vm");
|
|
const { test } = require("node:test");
|
|
const ts = require("typescript");
|
|
|
|
function loadModule(relativePath, mocks = {}, globals = {}) {
|
|
const source = fs.readFileSync(path.join(__dirname, "..", relativePath), "utf8");
|
|
const compiled = ts.transpileModule(source, {
|
|
compilerOptions: { target: ts.ScriptTarget.ES2020, module: ts.ModuleKind.CommonJS, jsx: ts.JsxEmit.ReactJSX },
|
|
}).outputText;
|
|
const loadedModule = { exports: {} };
|
|
vm.runInNewContext(compiled, {
|
|
module: loadedModule, exports: loadedModule.exports, process, URLSearchParams, URL,
|
|
require: (name) => name in mocks ? mocks[name] : require(name),
|
|
...globals,
|
|
});
|
|
return loadedModule.exports;
|
|
}
|
|
|
|
const page = {
|
|
title: "Page title",
|
|
id: 1,
|
|
meta: { slug: "test-slug", seo_title: "Standard title", search_description: "Standard description" },
|
|
cover_image: { url: "https://cms.shiytech.cn/media/cover.png", title: "Cover" },
|
|
summary: "Detail summary",
|
|
intro: "Blog introduction",
|
|
published_at: "2026-10-10T23:00:00Z",
|
|
client_name: "Client",
|
|
industry: "Manufacturing",
|
|
tags: ["Django"],
|
|
body: [],
|
|
seo_title_override: "Custom title | Shiytech",
|
|
seo_description_override: "Custom description",
|
|
canonical_url: "https://www.shiytech.cn/products",
|
|
og_image: { url: "https://cms.shiytech.cn/media/share.png", title: "Share image" },
|
|
schema_json: { "@type": "CollectionPage" },
|
|
};
|
|
|
|
test("content services request covers, tags and column intros", async () => {
|
|
for (const [name, list, detail] of [
|
|
["product", "getProductList", "getProductBySlug"],
|
|
["solution", "getSolutionList", "getSolutionBySlug"],
|
|
["case", "getCaseStudyList", "getCaseStudyBySlug"],
|
|
["blog", "getBlogList", "getBlogBySlug"],
|
|
]) {
|
|
const calls = [];
|
|
const service = loadModule(`services/${name}.service.ts`, { "./api-client": { fetchAPI: async (url) => { calls.push(url); return { items: [page] }; } } });
|
|
await service[list]();
|
|
await service[detail]("a&b");
|
|
for (const url of calls) {
|
|
const query = new URLSearchParams(url.split("?")[1]);
|
|
assert.ok(query.get("fields").split(",").includes("cover_image"));
|
|
if (name === "blog") assert.ok(query.get("fields").split(",").includes("tags"));
|
|
}
|
|
assert.equal(new URLSearchParams(calls[1].split("?")[1]).get("slug"), "a&b");
|
|
}
|
|
let query;
|
|
const { getColumnPage } = loadModule("services/page.service.ts", { "./api-client": { fetchAPI: async (url) => { query = new URLSearchParams(url.split("?")[1]); return { items: [] }; } } });
|
|
assert.equal(await getColumnPage("blog.BlogIndexPage", "a&b"), null);
|
|
assert.equal(query.get("fields"), "intro");
|
|
assert.equal(query.get("slug"), "a&b");
|
|
});
|
|
|
|
test("content presentation handles covers, dates, tags and column fallback", async () => {
|
|
const content = loadModule("components/layout/PageContent.tsx", { "@/services/page.service": { getColumnPage: async () => ({ title: "Column", intro: "Introduction" }) } });
|
|
assert.equal(content.PageCover({ image: null }), null);
|
|
assert.equal(content.PageCover({ image: { url: "/cover.png", title: "Cover" } }).props.children.props.alt, "Cover");
|
|
assert.equal(content.PublicationDate({ value: "invalid" }), null);
|
|
assert.equal(content.PublicationDate({ value: "2026-10-10T23:00:00Z" }).props.children, "2026年10月11日");
|
|
assert.equal(content.BlogTags({ tags: [] }), null);
|
|
assert.equal(content.BlogTags({ tags: ["Django"] }).props.children[0].props.children[1], "Django");
|
|
const header = await content.ColumnHeading({ type: "blog.BlogIndexPage", slug: "blog", fallback: "Fallback" });
|
|
assert.equal(header.props.children[0].props.children, "Column");
|
|
assert.equal(header.props.children[1].props.children, "Introduction");
|
|
const empty = loadModule("components/layout/PageContent.tsx", { "@/services/page.service": { getColumnPage: async () => { throw new Error("offline"); } } });
|
|
assert.equal((await empty.ColumnHeading({ fallback: "Fallback" })).props.children[0].props.children, "Fallback");
|
|
});
|
|
|
|
test("SEO metadata includes absolute title, canonical and social image", () => {
|
|
const { createPageMetadata } = loadModule("services/seo.service.ts", { "./api-client": {} });
|
|
const metadata = createPageMetadata(page, "Fallback");
|
|
assert.equal(metadata.title.absolute, page.seo_title_override);
|
|
assert.equal(metadata.description, page.seo_description_override);
|
|
assert.equal(metadata.alternates.canonical, page.canonical_url);
|
|
assert.equal(metadata.openGraph.images[0].url, page.og_image.url);
|
|
assert.equal(metadata.twitter.card, "summary_large_image");
|
|
});
|
|
|
|
test("SEO falls back to standard Wagtail fields and handles missing pages", () => {
|
|
const { createPageMetadata } = loadModule("services/seo.service.ts", { "./api-client": {} });
|
|
const metadata = createPageMetadata({ ...page, seo_title_override: "", seo_description_override: "", og_image: null }, "Fallback");
|
|
assert.equal(metadata.title.absolute, page.meta.seo_title);
|
|
assert.equal(metadata.description, page.meta.search_description);
|
|
assert.equal(metadata.twitter.card, "summary");
|
|
assert.equal(createPageMetadata(null, "Fallback").title, "Fallback");
|
|
});
|
|
|
|
test("SEO lookup requests every SEO field and encodes slugs", async () => {
|
|
let requested;
|
|
const { getPageSEO, SEO_FIELDS } = loadModule("services/seo.service.ts", {
|
|
"./api-client": { fetchAPI: async (url) => { requested = url; return { items: [page] }; } },
|
|
});
|
|
assert.equal(await getPageSEO("products.ProductPage", "a&b"), page);
|
|
const query = new URLSearchParams(requested.split("?")[1]);
|
|
assert.equal(query.get("slug"), "a&b");
|
|
assert.equal(query.get("fields"), SEO_FIELDS);
|
|
for (const field of ["seo_title", "search_description", "og_image", "canonical_url", "schema_json"]) {
|
|
assert.ok(SEO_FIELDS.split(",").includes(field));
|
|
}
|
|
});
|
|
|
|
test("JSON-LD escapes script boundaries and suppresses empty data", async () => {
|
|
const schema = { "@type": "WebPage", description: "</script><script>alert(1)</script>" };
|
|
const { default: PageSEO } = loadModule("components/layout/PageSEO.tsx", {
|
|
"@/services/seo.service": { getPageSEO: async () => ({ schema_json: schema }) },
|
|
});
|
|
const element = await PageSEO({ type: "home.HomePage" });
|
|
const json = element.props.dangerouslySetInnerHTML.__html;
|
|
assert.ok(!json.includes("<"));
|
|
assert.deepEqual(JSON.parse(json), schema);
|
|
const empty = loadModule("components/layout/PageSEO.tsx", {
|
|
"@/services/seo.service": { getPageSEO: async () => ({ schema_json: {} }) },
|
|
});
|
|
assert.equal(await empty.default({ type: "home.HomePage" }), null);
|
|
});
|
|
|
|
test("page fetches share the publish cache tag without changing snippet tags", async () => {
|
|
const calls = [];
|
|
const { fetchAPI } = loadModule("services/api-client.ts", {}, {
|
|
fetch: async (url, options) => { calls.push(options); return { ok: true, json: async () => ({}) }; },
|
|
});
|
|
await fetchAPI("/api/v2/pages/?type=home.HomePage", { tags: ["home-page", "wagtail-pages"] });
|
|
assert.equal(calls[0].next.tags.join(","), "wagtail-pages,home-page");
|
|
await fetchAPI("/api/v1/custom/core/site-settings/", { tags: ["site-settings"] });
|
|
assert.equal(calls[1].next.tags.join(","), "site-settings");
|
|
});
|
|
|
|
test("API client separates container internal and browser addresses", async () => {
|
|
const env = { NEXT_PUBLIC_API_URL: "http://localhost:18000", API_INTERNAL_URL: "http://backend:8000" };
|
|
for (const browser of [false, true]) {
|
|
let requestedURL;
|
|
const globals = {
|
|
process: { env },
|
|
fetch: async (url) => { requestedURL = url; return { ok: true, json: async () => ({}) }; },
|
|
...(browser ? { window: {} } : {}),
|
|
};
|
|
const { fetchAPI } = loadModule("services/api-client.ts", {}, globals);
|
|
await fetchAPI("/api/v2/pages/");
|
|
assert.equal(requestedURL, `${browser ? env.NEXT_PUBLIC_API_URL : env.API_INTERNAL_URL}/api/v2/pages/`);
|
|
}
|
|
});
|
|
|
|
test("authenticated webhook expires shared cache immediately and rejects unauthorized requests", async () => {
|
|
const calls = [];
|
|
const { POST } = loadModule("app/api/revalidate/route.ts", {
|
|
"next/cache": { revalidateTag: (tag, profile) => calls.push({ tag, profile }) },
|
|
}, { process: { env: { REVALIDATE_SECRET: "test-secret" } } });
|
|
const request = (authorization) => ({
|
|
headers: { get: () => authorization },
|
|
json: async () => ({ tags: ["wagtail-pages"] }),
|
|
});
|
|
assert.equal((await POST(request("Bearer test-secret"))).status, 200);
|
|
assert.equal(calls[0].tag, "wagtail-pages");
|
|
assert.equal(calls[0].profile.expire, 0);
|
|
assert.equal((await POST(request("Bearer invalid"))).status, 401);
|
|
assert.equal(calls.length, 1);
|
|
});
|
|
|
|
test("all CMS page routes wire metadata and JSON-LD to the correct page model", async () => {
|
|
const routes = [
|
|
["app/page.tsx", "home.HomePage", undefined],
|
|
["app/products/page.tsx", "products.ProductIndexPage", "products"],
|
|
["app/solutions/page.tsx", "solutions.SolutionIndexPage", "solutions"],
|
|
["app/cases/page.tsx", "cases.CaseStudyIndexPage", "cases"],
|
|
["app/blog/page.tsx", "blog.BlogIndexPage", "blog"],
|
|
["app/contact/page.tsx", "core.ContactPage", "contact"],
|
|
["app/privacy-policy/page.tsx", "core.SimpleContentPage", "privacy-policy"],
|
|
["app/products/[slug]/page.tsx", "products.ProductPage", "test-slug"],
|
|
["app/solutions/[slug]/page.tsx", "solutions.SolutionPage", "test-slug"],
|
|
["app/cases/[slug]/page.tsx", "cases.CaseStudyPage", "test-slug"],
|
|
["app/blog/[slug]/page.tsx", "blog.BlogPage", "test-slug"],
|
|
];
|
|
const { createPageMetadata } = loadModule("services/seo.service.ts", { "./api-client": {} });
|
|
const PageSEO = () => null;
|
|
const content = { ColumnHeading: () => null, PageCover: () => null, PublicationDate: () => null, BlogTags: () => null };
|
|
const previews = [];
|
|
const service = {
|
|
getHomePage: async () => page, getContactPage: async () => page,
|
|
getProductList: async () => [page], getSolutionList: async () => [page],
|
|
getCaseStudyList: async () => [page], getBlogList: async () => [page],
|
|
getProductBySlug: async () => page, getSolutionBySlug: async () => page,
|
|
getCaseStudyBySlug: async () => page, getBlogBySlug: async () => page,
|
|
getSimpleContentPageBySlug: async () => page,
|
|
};
|
|
const mocks = {
|
|
"@/components/layout/PageSEO": { default: PageSEO },
|
|
"@/components/layout/PageContent": content,
|
|
"@/blocks/BlockRenderer": { BlockRenderer: () => null },
|
|
"@/services/preview.service": { getPreviewOrFallback: async (type, fields, fallback) => { previews.push(fields); return fallback(); } },
|
|
};
|
|
for (const name of ["page", "product", "solution", "case", "blog", "legal"]) {
|
|
mocks[`@/services/${name}.service`] = service;
|
|
}
|
|
function findElement(element, type) {
|
|
if (!element || typeof element !== "object") return null;
|
|
if (element.type === type) return element;
|
|
const children = Array.isArray(element) ? element : element.props?.children;
|
|
for (const child of Array.isArray(children) ? children : [children]) {
|
|
const found = findElement(child, type);
|
|
if (found) return found;
|
|
}
|
|
return null;
|
|
}
|
|
for (const [file, expectedType, expectedSlug] of routes) {
|
|
const calls = [];
|
|
const route = loadModule(file, {
|
|
...mocks,
|
|
"@/services/seo.service": {
|
|
createPageMetadata,
|
|
getPageSEO: async (type, slug) => { calls.push({ type, slug }); return page; },
|
|
},
|
|
});
|
|
const props = { params: Promise.resolve({ slug: "test-slug" }) };
|
|
assert.equal((await route.generateMetadata(props)).title.absolute, page.seo_title_override, file);
|
|
assert.equal(calls[0].type, expectedType, file);
|
|
assert.equal(calls[0].slug, expectedSlug, file);
|
|
const tree = await route.default(props);
|
|
const element = findElement(tree, PageSEO);
|
|
assert.ok(element, file);
|
|
assert.equal(element.props.type, expectedType, file);
|
|
assert.equal(element.props.slug, expectedSlug, file);
|
|
if (/^(products|solutions|cases|blog)\./.test(expectedType)) {
|
|
assert.equal(findElement(tree, content.PageCover).props.image, page.cover_image, file);
|
|
if (expectedType.endsWith("IndexPage")) {
|
|
const heading = findElement(tree, content.ColumnHeading);
|
|
assert.equal(heading.props.type, expectedType, file);
|
|
assert.equal(heading.props.slug, expectedSlug, file);
|
|
} else {
|
|
assert.ok(previews.at(-1).split(",").includes("cover_image"), file);
|
|
}
|
|
if (/^(blog|cases)\./.test(expectedType)) assert.equal(findElement(tree, content.PublicationDate).props.value, page.published_at, file);
|
|
if (expectedType.startsWith("blog.")) {
|
|
assert.equal(findElement(tree, content.BlogTags).props.tags, page.tags, file);
|
|
if (!expectedType.endsWith("IndexPage")) assert.ok(previews.at(-1).split(",").includes("tags"), file);
|
|
}
|
|
}
|
|
}
|
|
});
|
|
|
|
test("feature grid displays configured icon text", () => {
|
|
const { default: FeatureGrid } = loadModule("blocks/feature-grid/FeatureGrid.tsx");
|
|
const tree = FeatureGrid({ value: { items: [{ icon: "S", title: "Speed", description: "Fast" }] } });
|
|
const item = tree.props.children.props.children[1].props.children[0];
|
|
assert.equal(item.props.children[0].props.children, "S");
|
|
});
|
|
|
|
test("footer renders configured navigation, contact and social fields with CMS QR URL", async () => {
|
|
const settings = { company_name: "Company", contact_phone: "123456", contact_email: "hello@example.com", address: "Address", icp_number: "测试备案号", icp_url: "https://beian.miit.gov.cn/", wechat_account: "Account", weibo_url: "https://weibo.com/example", wechat_qrcode: { url: "/media/qr.png", title: "QR" } };
|
|
const { default: Footer } = loadModule("components/layout/Footer.tsx", {
|
|
"@/services/site-settings.service": { getSiteSettings: async () => settings },
|
|
"@/services/navigation.service": { getNavigationMenu: async (name) => { assert.equal(name, "footer"); return { items: [{ label: "Contact", url: "/contact", open_in_new_tab: true }] }; } },
|
|
}, { process: { env: { NEXT_PUBLIC_API_URL: "https://cms.example.com" } } });
|
|
const elements = [];
|
|
function collect(element) {
|
|
if (Array.isArray(element)) { element.forEach(collect); return; }
|
|
if (!element || typeof element !== "object") return;
|
|
elements.push(element);
|
|
collect(element.props?.children);
|
|
}
|
|
collect(await Footer());
|
|
for (const href of ["/contact", "tel:123456", "mailto:hello@example.com", settings.weibo_url]) assert.ok(elements.some((element) => element.props?.href === href));
|
|
const contact = elements.find((element) => element.props?.href === "/contact");
|
|
assert.equal(contact.props.target, "_blank");
|
|
assert.equal(contact.props.rel, "noreferrer");
|
|
assert.ok(elements.some((element) => element.props?.src === "https://cms.example.com/media/qr.png"));
|
|
assert.ok(elements.some((element) => element.props?.children === settings.address));
|
|
assert.ok(elements.some((element) => element.props?.children === settings.wechat_account));
|
|
assert.ok(elements.some((element) => element.props?.href === settings.icp_url));
|
|
assert.ok(elements.some((element) => element.props?.children === settings.icp_number));
|
|
const fallback = loadModule("components/layout/Footer.tsx", {
|
|
"@/services/site-settings.service": { getSiteSettings: async () => null },
|
|
"@/services/navigation.service": { getNavigationMenu: async () => null },
|
|
});
|
|
elements.length = 0;
|
|
collect(await fallback.default());
|
|
assert.ok(elements.some((element) => element.props?.href === "/privacy-policy"));
|
|
assert.ok(!elements.some((element) => element.props?.href === settings.icp_url));
|
|
}); |