/*
RFC-0492/RFC-0498: JSON-LD test — surface pages emit industry Service node
where Service is required (website-local depth-1, website-service depth-1, website-local depth-5)
and no Service nodes where Service is prohibited (depth-0, 2, 3, 4). Org-level Service nodes
and services ItemList are suppressed for all surface pages.
- Not a contract source of truth — the module under test owns the authoritative behavior; fixtures are minimal scaffolding only.
- RFC-0492: initial JSON-LD test for industry Service node emission and org-level suppression.
- RFC-0498: extend tests for website-service depth-1, website-local depth-5, and prohibited depths (0, 2, 3, 4).
*/
import { describe, expect, it } from "vitest";
import { buildJsonLd } from "../jsonld.ts";
import type { SemanticPageModel, SemanticOrganization } from "../models.ts";
function makeOrg(): SemanticOrganization {
return {
name: "Test Org",
description: "Test organization",
url: "https://example.com",
services: [
{ id: "webdesign", name: "Webdesign", description: "Webdesign service" },
{ id: "seo", name: "SEO", description: "SEO service" },
],
};
}
function makeModel(overrides: Partial = {}): SemanticPageModel {
return {
type: "home",
lang: "de",
url: "https://example.com/de/website/elektriker",
title: "Website für Elektriker",
description: "Professionelle Websites für Elektriker",
breadcrumbs: [],
blocks: [],
organization: makeOrg(),
...overrides,
};
}
describe("RFC-0492/RFC-0498: JSON-LD Service node for surface pages", () => {
it("emits industry-specific Service node for depth-1 website-local page", () => {
const model = makeModel({
surfaceId: "website-local",
depth: 1,
industryService: {
serviceType: "Website für Elektriker",
description: "Professionelle Websites für Elektriker",
},
});
const doc = buildJsonLd(model);
const serviceNodes = doc["@graph"].filter(
(n) => (n as { "@type"?: string | string[] })["@type"] === "Service",
);
expect(serviceNodes.length).toBe(1);
const node = serviceNodes[0] as Record;
expect(node["serviceType"]).toBe("Website für Elektriker");
expect(node["name"]).toBe("Website für Elektriker");
});
it("suppresses org-level Service nodes for depth-1 website-local page", () => {
const model = makeModel({
surfaceId: "website-local",
depth: 1,
industryService: {
serviceType: "Website für Elektriker",
},
});
const doc = buildJsonLd(model);
const serviceNodes = doc["@graph"].filter(
(n) => (n as { "@type"?: string | string[] })["@type"] === "Service",
);
// Should have exactly 1 (the industry node), not 2 (org-level services)
expect(serviceNodes.length).toBe(1);
const node = serviceNodes[0] as Record;
expect(node["serviceType"]).toBe("Website für Elektriker");
// Should NOT have org-level service names
expect(node["name"]).not.toBe("Webdesign");
expect(node["name"]).not.toBe("SEO");
});
it("emits org-level Service nodes for non-surface pages", () => {
const model = makeModel({
surfaceId: "other-surface",
depth: 1,
});
const doc = buildJsonLd(model);
const serviceNodes = doc["@graph"].filter(
(n) => (n as { "@type"?: string | string[] })["@type"] === "Service",
);
// Should have org-level services (Webdesign, SEO)
expect(serviceNodes.length).toBe(2);
const names = serviceNodes.map((n) => (n as Record)["name"]);
expect(names).toContain("Webdesign");
expect(names).toContain("SEO");
});
it("emits industry-specific Service node for website-service depth-1 page", () => {
const model = makeModel({
surfaceId: "website-service",
depth: 1,
industryService: {
serviceType: "Website für Elektriker — SEO",
description: "Professionelle SEO für Elektriker",
},
});
const doc = buildJsonLd(model);
const serviceNodes = doc["@graph"].filter(
(n) => (n as { "@type"?: string | string[] })["@type"] === "Service",
);
expect(serviceNodes.length).toBe(1);
const node = serviceNodes[0] as Record;
expect(node["serviceType"]).toBe("Website für Elektriker — SEO");
});
it("emits industry-specific Service node with areaServed for website-local depth-5 page", () => {
const model = makeModel({
surfaceId: "website-local",
depth: 5,
industryService: {
serviceType: "Website für Elektriker — SEO",
description: "Professionelle SEO für Elektriker",
areaServed: "Stuttgart",
},
});
const doc = buildJsonLd(model);
const serviceNodes = doc["@graph"].filter(
(n) => (n as { "@type"?: string | string[] })["@type"] === "Service",
);
expect(serviceNodes.length).toBe(1);
const node = serviceNodes[0] as Record;
expect(node["serviceType"]).toBe("Website für Elektriker — SEO");
const areaServed = node["areaServed"] as Record;
expect(areaServed).toBeDefined();
expect(areaServed["@type"]).toBe("City");
expect(areaServed["name"]).toBe("Stuttgart");
});
it("emits no Service nodes for depth-0 website-local page (Service prohibited)", () => {
const model = makeModel({
surfaceId: "website-local",
depth: 0,
});
const doc = buildJsonLd(model);
const serviceNodes = doc["@graph"].filter(
(n) => (n as { "@type"?: string | string[] })["@type"] === "Service",
);
expect(serviceNodes.length).toBe(0);
});
it("emits no Service nodes for depth-4 website-local page (Service prohibited)", () => {
const model = makeModel({
surfaceId: "website-local",
depth: 4,
});
const doc = buildJsonLd(model);
const serviceNodes = doc["@graph"].filter(
(n) => (n as { "@type"?: string | string[] })["@type"] === "Service",
);
// RFC-0498: Service is prohibited on depth-4 — no Service nodes at all
expect(serviceNodes.length).toBe(0);
});
it("suppresses org-level services ItemList for all surface pages", () => {
const model = makeModel({
surfaceId: "website-local",
depth: 1,
industryService: {
serviceType: "Website für Elektriker",
},
});
const doc = buildJsonLd(model);
const itemListNodes = doc["@graph"].filter(
(n) => (n as { "@type"?: string | string[] })["@type"] === "ItemList",
);
// The org-level services ItemList should NOT be present
const servicesList = itemListNodes.find((n) => {
const node = n as Record;
const id = node["@id"] as string | undefined;
return id?.includes("services");
});
expect(servicesList).toBeUndefined();
});
});
describe("ProfilePage mainEntity (Google Search Console fix)", () => {
it("sets mainEntity to extraGraphNodes[0] @id for AI-agent profile pages (page.people empty)", () => {
const model = makeModel({
type: "person",
url: "https://example.com/team/ki-agenten/test-bot/",
title: "Test Bot",
people: [],
extraGraphNodes: [
{
"@type": "SoftwareApplication",
"@id": "https://example.com/team/ki-agenten/test-bot/#software",
name: "Test Bot",
},
],
});
const doc = buildJsonLd(model);
const webPageNode = doc["@graph"].find((n) => {
const types = (n as Record)["@type"];
if (Array.isArray(types)) return types.includes("ProfilePage");
return types === "ProfilePage";
}) as Record | undefined;
expect(webPageNode).toBeDefined();
const mainEntity = webPageNode!["mainEntity"] as Record;
expect(mainEntity).toBeDefined();
expect(mainEntity["@id"]).toBe("https://example.com/team/ki-agenten/test-bot/#software");
});
it("sets mainEntity to extraGraphNodes[0] @id for human profile pages with extended Person node", () => {
const model = makeModel({
type: "person",
url: "https://example.com/team/jane-doe/",
title: "Jane Doe",
people: [{ name: "Jane Doe" }],
extraGraphNodes: [
{
"@type": "Person",
"@id": "https://example.com/team/jane-doe/#person",
name: "Jane Doe",
},
],
});
const doc = buildJsonLd(model);
const webPageNode = doc["@graph"].find((n) => {
const types = (n as Record)["@type"];
if (Array.isArray(types)) return types.includes("ProfilePage");
return types === "ProfilePage";
}) as Record | undefined;
expect(webPageNode).toBeDefined();
const mainEntity = webPageNode!["mainEntity"] as Record;
expect(mainEntity).toBeDefined();
expect(mainEntity["@id"]).toBe("https://example.com/team/jane-doe/#person");
});
it("falls back to ids.person when no extraGraphNodes", () => {
const model = makeModel({
type: "person",
url: "https://example.com/team/jane-doe/",
title: "Jane Doe",
people: [{ name: "Jane Doe" }],
});
const doc = buildJsonLd(model);
const webPageNode = doc["@graph"].find((n) => {
const types = (n as Record)["@type"];
if (Array.isArray(types)) return types.includes("ProfilePage");
return types === "ProfilePage";
}) as Record | undefined;
expect(webPageNode).toBeDefined();
const mainEntity = webPageNode!["mainEntity"] as Record;
expect(mainEntity).toBeDefined();
expect(mainEntity["@id"]).toBe("https://example.com/#/schema/person/jane-doe");
});
});