246 lines
7.6 KiB
TypeScript
246 lines
7.6 KiB
TypeScript
import { render, screen, within } from "@testing-library/react";
|
|
import type { ReactNode } from "react";
|
|
import { SEO_IDS } from "@/lib/seo";
|
|
import { caseStudies, getServicePage, siteUrl } from "@/lib/site-content";
|
|
import ServicesIndexPage from "../page";
|
|
import ServicePage, { generateMetadata, generateStaticParams } from "./page";
|
|
|
|
jest.mock("next/navigation", () => ({ notFound: jest.fn() }));
|
|
|
|
jest.mock("@/app/components/cinematic/HeroBackground", () => ({
|
|
__esModule: true,
|
|
default: () => null,
|
|
}));
|
|
|
|
jest.mock("@/app/components/cinematic/MagneticButton", () => ({
|
|
__esModule: true,
|
|
default: ({
|
|
as = "button",
|
|
strength: _strength,
|
|
children,
|
|
...props
|
|
}: {
|
|
as?: string;
|
|
strength?: number;
|
|
children?: ReactNode;
|
|
} & Record<string, unknown>) => {
|
|
const React = require("react");
|
|
return React.createElement(as, props, children);
|
|
},
|
|
}));
|
|
|
|
const service = getServicePage("consultoria-nextjs");
|
|
if (!service) throw new Error("Missing representative service");
|
|
|
|
const publicServiceSlugs = [
|
|
"criacao-de-sites",
|
|
"engenharia-de-software",
|
|
"consultoria-nextjs",
|
|
"tauri-rust",
|
|
"integracao-hardware-software-iot",
|
|
"arquitetura-de-software",
|
|
] as const;
|
|
const publicServiceHrefs = publicServiceSlugs.map(
|
|
(slug) => `/servicos/${slug}`,
|
|
);
|
|
|
|
function readJsonLdEntities(container: HTMLElement): Record<string, unknown>[] {
|
|
const scripts = container.querySelectorAll<HTMLScriptElement>(
|
|
'script[type="application/ld+json"]',
|
|
);
|
|
if (scripts.length === 0) throw new Error("Missing JSON-LD");
|
|
|
|
return Array.from(scripts).flatMap((script) => {
|
|
if (!script.textContent) throw new Error("Empty JSON-LD");
|
|
const value = JSON.parse(script.textContent) as Record<string, unknown>;
|
|
return Array.isArray(value["@graph"]) ? value["@graph"] : [value];
|
|
});
|
|
}
|
|
|
|
it("keeps static params and route metadata canonical", async () => {
|
|
const staticSlugs = generateStaticParams().map(({ slug }) => slug);
|
|
expect(staticSlugs).toHaveLength(6);
|
|
expect(new Set(staticSlugs).size).toBe(6);
|
|
expect([...staticSlugs].sort()).toEqual([...publicServiceSlugs].sort());
|
|
|
|
const metadata = await generateMetadata({
|
|
params: Promise.resolve({ slug: service.slug }),
|
|
});
|
|
const url = `${siteUrl}/servicos/${service.slug}`;
|
|
|
|
expect(metadata).toMatchObject({
|
|
title: service.seoTitle,
|
|
description: service.metaDescription,
|
|
alternates: { canonical: url },
|
|
openGraph: {
|
|
title: service.seoTitle,
|
|
description: service.metaDescription,
|
|
url,
|
|
},
|
|
twitter: {
|
|
title: service.seoTitle,
|
|
description: service.metaDescription,
|
|
},
|
|
});
|
|
expect(metadata).not.toHaveProperty("keywords");
|
|
});
|
|
|
|
it("renders answer-first consulting, reciprocal case links and minimal schema", async () => {
|
|
const ui = await ServicePage({
|
|
params: Promise.resolve({ slug: service.slug }),
|
|
});
|
|
const { container } = render(ui);
|
|
|
|
const answer = screen.getByText(service.answer);
|
|
expect(answer).toBeVisible();
|
|
for (const item of [
|
|
...service.outcomes,
|
|
...service.examples,
|
|
...service.process,
|
|
]) {
|
|
expect(screen.getByText(item)).toBeVisible();
|
|
}
|
|
for (const detail of service.technicalDetails) {
|
|
expect(screen.getByText(detail)).not.toBeVisible();
|
|
}
|
|
|
|
const primaryCtas = screen.getAllByRole("link", { name: /WhatsApp/i });
|
|
for (const cta of primaryCtas) {
|
|
const destination = new URL(cta.getAttribute("href") ?? "");
|
|
expect(destination.origin).toBe("https://wa.me");
|
|
expect(destination.searchParams.get("text")).toContain(service.navLabel);
|
|
expect(cta).toHaveAttribute("target", "_blank");
|
|
expect(cta).toHaveAttribute("rel", expect.stringContaining("noopener"));
|
|
}
|
|
|
|
const expectedCaseLinks = service.relatedCaseIds
|
|
.map((id) => {
|
|
const caseStudy = caseStudies.find((item) => item.id === id);
|
|
if (!caseStudy) throw new Error(`Missing related case ${id}`);
|
|
return {
|
|
name: `Ler case: ${caseStudy.title}`,
|
|
href: `/cases/${id}`,
|
|
};
|
|
})
|
|
.sort((a, b) => a.href.localeCompare(b.href));
|
|
const internalCaseLinks = screen.getAllByRole("link", {
|
|
name: /^Ler case:/,
|
|
});
|
|
expect(
|
|
internalCaseLinks
|
|
.map((link) => ({
|
|
name: link.textContent,
|
|
href: link.getAttribute("href"),
|
|
}))
|
|
.sort((a, b) => (a.href ?? "").localeCompare(b.href ?? "")),
|
|
).toEqual(expectedCaseLinks);
|
|
for (const internalLink of internalCaseLinks) {
|
|
const firstExternalLink = internalLink
|
|
.closest("article")
|
|
?.querySelector('a[target="_blank"]');
|
|
if (firstExternalLink) {
|
|
expect(
|
|
internalLink.compareDocumentPosition(firstExternalLink) &
|
|
Node.DOCUMENT_POSITION_FOLLOWING,
|
|
).toBeTruthy();
|
|
}
|
|
}
|
|
|
|
const externalLinks = container.querySelectorAll('a[target="_blank"]');
|
|
expect(externalLinks.length).toBeGreaterThan(0);
|
|
for (const externalLink of externalLinks) {
|
|
expect(externalLink).toHaveAttribute(
|
|
"rel",
|
|
expect.stringMatching(/noreferrer/),
|
|
);
|
|
}
|
|
|
|
const graph = readJsonLdEntities(container);
|
|
expect(graph.map((node) => node["@type"])).toEqual([
|
|
"Service",
|
|
"BreadcrumbList",
|
|
]);
|
|
expect(JSON.stringify(graph)).not.toMatch(
|
|
/Article|FAQPage|ProfessionalService/,
|
|
);
|
|
const serviceSchema = graph.find((node) => node["@type"] === "Service");
|
|
expect(serviceSchema).toMatchObject({
|
|
provider: { "@id": SEO_IDS.person },
|
|
});
|
|
const breadcrumb = graph.find(
|
|
(node) => node["@type"] === "BreadcrumbList",
|
|
) as {
|
|
itemListElement: Array<{
|
|
"@type": string;
|
|
position: number;
|
|
name: string;
|
|
item: string;
|
|
}>;
|
|
};
|
|
expect(breadcrumb.itemListElement).toEqual([
|
|
{ "@type": "ListItem", position: 1, name: "Início", item: siteUrl },
|
|
{
|
|
"@type": "ListItem",
|
|
position: 2,
|
|
name: "Serviços",
|
|
item: `${siteUrl}/servicos`,
|
|
},
|
|
{
|
|
"@type": "ListItem",
|
|
position: 3,
|
|
name: service.navLabel,
|
|
item: `${siteUrl}/servicos/${service.slug}`,
|
|
},
|
|
]);
|
|
|
|
const visibleBreadcrumb = screen.getByRole("navigation", {
|
|
name: "Breadcrumb",
|
|
});
|
|
expect(
|
|
within(visibleBreadcrumb)
|
|
.getAllByRole("link")
|
|
.map((link) => [link.textContent, link.getAttribute("href")]),
|
|
).toEqual([
|
|
["Início", "/"],
|
|
["Serviços", "/servicos"],
|
|
[service.navLabel, `/servicos/${service.slug}`],
|
|
]);
|
|
});
|
|
|
|
it("keeps hub cards and structured items aligned", () => {
|
|
const { container } = render(<ServicesIndexPage />);
|
|
|
|
const graph = readJsonLdEntities(container);
|
|
expect(graph.map((node) => node["@type"])).toEqual([
|
|
"CollectionPage",
|
|
"ItemList",
|
|
"BreadcrumbList",
|
|
]);
|
|
const itemList = graph[1] as {
|
|
itemListElement: Array<{ position: number; url: string; name: string }>;
|
|
};
|
|
expect(itemList.itemListElement.map(({ position }) => position)).toEqual([
|
|
1, 2, 3, 4, 5, 6,
|
|
]);
|
|
const structuredUrls = itemList.itemListElement.map(({ url }) => url);
|
|
expect(structuredUrls).toHaveLength(6);
|
|
expect(new Set(structuredUrls).size).toBe(6);
|
|
expect([...structuredUrls].sort()).toEqual(
|
|
publicServiceHrefs.map((href) => `${siteUrl}${href}`).sort(),
|
|
);
|
|
const visibleCards = itemList.itemListElement.map(({ name }) =>
|
|
screen.getByRole("link", { name }),
|
|
);
|
|
const visibleHrefs = visibleCards.map((link) => link.getAttribute("href"));
|
|
expect(visibleHrefs).toHaveLength(6);
|
|
expect(new Set(visibleHrefs).size).toBe(6);
|
|
expect([...visibleHrefs].sort()).toEqual([...publicServiceHrefs].sort());
|
|
expect(graph[2]).toMatchObject({
|
|
"@type": "BreadcrumbList",
|
|
itemListElement: [
|
|
{ position: 1, name: "Início", item: siteUrl },
|
|
{ position: 2, name: "Serviços", item: `${siteUrl}/servicos` },
|
|
],
|
|
});
|
|
});
|