import { render, screen, within } from "@testing-library/react"; import { SEO_IDS } from "@/lib/seo"; import { caseStudies, getRelatedCases, getServicePage, isClientCase, servicePages, siteUrl, } from "@/lib/site-content"; import ServicesIndexPage from "../page"; import ServicePage, { generateMetadata, generateStaticParams } from "./page"; jest.mock("next/navigation", () => ({ notFound: jest.fn() })); async function renderService(slug: string) { const ui = await ServicePage({ params: Promise.resolve({ slug }) }); return render(ui); } function isBefore(first: Element, second: Element) { return Boolean( first.compareDocumentPosition(second) & Node.DOCUMENT_POSITION_FOLLOWING, ); } 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[] { const scripts = container.querySelectorAll( '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; 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.metaTitle, description: service.metaDescription, alternates: { canonical: url }, openGraph: { title: service.metaTitle, description: service.metaDescription, url, }, twitter: { title: service.metaTitle, description: service.metaDescription, }, }); expect(metadata).not.toHaveProperty("keywords"); }); it("renders answer-first consulting, reciprocal case links and minimal schema", async () => { const { container } = await renderService(service.slug); 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/), ); expect(externalLink.getAttribute("href")).not.toMatch(/github/i); } const hrefs = screen .getAllByRole("link") .map((link) => link.getAttribute("href") ?? ""); expect(hrefs).not.toContain("/#contact"); expect(container.textContent).not.toMatch( /Exemplos ilustrativos|não projetos já entregues|Sem promessas|indispon|Ver código/i, ); 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: "Trilha de navegação", }); 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(); 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` }, ], }); }); it("shows the client projects with covers on the hub, without disclaimers", () => { const { container } = render(); for (const id of [ "sev-modaintima", "lavacao-047", "sc-plus", "as-servicos", ]) { const link = container.querySelector(`a[href="/cases/${id}"]`); expect( link?.closest("article")?.querySelector(".ds-bframe img"), ).not.toBeNull(); } expect(container.textContent).not.toMatch( /sem promessas|Demonstração publicada|limites|indispon/i, ); const hrefs = screen .getAllByRole("link") .map((link) => link.getAttribute("href") ?? ""); expect(hrefs).not.toContain("/#contact"); expect(hrefs.some((href) => /github/i.test(href))).toBe(false); }); it("shows the client cases first, right after the hero, with the first cover in a frame", async () => { const { container } = await renderService("criacao-de-sites"); const siteService = getServicePage("criacao-de-sites"); if (!siteService) throw new Error("Missing service"); const casesHeading = screen.getByRole("heading", { level: 2, name: "Onde já apliquei isso", }); expect( isBefore(screen.getByRole("heading", { level: 1 }), casesHeading), ).toBe(true); expect( isBefore( casesHeading, screen.getByRole("heading", { level: 2, name: "Para quem faz sentido" }), ), ).toBe(true); const caseLinks = screen.getAllByRole("link", { name: /^Ler case:/ }); expect(caseLinks.map((link) => link.getAttribute("href"))).toEqual( getRelatedCases(siteService.relatedCaseIds).map(({ id }) => `/cases/${id}`), ); expect(caseLinks.map((link) => link.getAttribute("href"))).toEqual([ "/cases/sev-modaintima", "/cases/lavacao-047", "/cases/sc-plus", "/cases/as-servicos", ]); // pôster do primeiro case numa moldura no topo, no lugar do fundo verde const hero = container.querySelector("section"); const heroFrame = hero?.querySelector(".ds-bframe"); expect(heroFrame).not.toBeNull(); expect(heroFrame?.querySelector("img")).toHaveAttribute( "alt", "Captura de tela do site SEV Moda Íntima", ); expect(hero?.querySelector("a[href='/cases/sev-modaintima']")).not.toBeNull(); }); it("hides the related block and the hero frame when a service has no case", async () => { const { container } = await renderService("tauri-rust"); expect( screen.queryByRole("heading", { name: "Onde já apliquei isso" }), ).toBeNull(); expect(screen.queryByRole("link", { name: /^Ler case:/ })).toBeNull(); expect( screen.queryByRole("link", { name: "Ver projetos parecidos" }), ).toBeNull(); expect(container.querySelector(".ds-bframe")).toBeNull(); // GCC Installer (Python e Tkinter) não pertence a um serviço de Tauri e Rust expect(container.textContent).not.toMatch(/GCC|Tkinter/); }); it("labels the Twins Lab prototype as a laboratory project on the IoT service", async () => { const { container } = await renderService("integracao-hardware-software-iot"); const caseLinks = screen.getAllByRole("link", { name: /^Ler case:/ }); expect(caseLinks.map((link) => link.getAttribute("href"))).toEqual([ "/cases/twins-lab", ]); expect(screen.getByText("Twins Lab · Projeto próprio")).toBeVisible(); expect(screen.queryByText(/Protótipo de laboratório/)).toBeNull(); // sem captura gravada, não há moldura nem imagem expect(container.querySelector(".ds-bframe")).toBeNull(); }); it("labels only the laboratory projects as 'Projeto próprio'", async () => { for (const slug of ["criacao-de-sites", "integracao-hardware-software-iot"]) { const { unmount } = await renderService(slug); const related = getRelatedCases(getServicePage(slug)?.relatedCaseIds ?? []); expect(related.length).toBeGreaterThan(0); const labels = screen.queryAllByText(/· Projeto próprio$/); expect(labels).toHaveLength(related.filter((c) => !isClientCase(c)).length); unmount(); } }); it("says in one sentence, per case, why it matters to the service", async () => { for (const { slug, relatedCaseIds, caseRelevance } of servicePages) { if (relatedCaseIds.length === 0) continue; const { unmount } = await renderService(slug); for (const id of relatedCaseIds) { const sentence = caseRelevance[id]; expect(sentence).toBeTruthy(); const card = screen.getByText(sentence).closest("article"); expect(card).toContainElement( screen.getByRole("link", { name: `Ler case: ${caseStudies.find((c) => c.id === id)?.title}`, }), ); } unmount(); } }); it("cites the Apolo Energia experience in text on the IoT and architecture pages, with no link", async () => { for (const slug of [ "integracao-hardware-software-iot", "arquitetura-de-software", ]) { const note = getServicePage(slug)?.experienceNote ?? ""; const { container, unmount } = await renderService(slug); const paragraph = screen.getByText(/Apolo Energia/); expect(paragraph).toBeVisible(); expect(paragraph.textContent).toContain(note); expect(paragraph.textContent).toMatch(/Desde 2022/); expect(paragraph.closest("a")).toBeNull(); expect(paragraph.querySelector("a")).toBeNull(); // dentro do bloco "Onde já apliquei isso", antes dos cards const block = screen.getByRole("heading", { name: "Onde já apliquei isso", }); expect(isBefore(block, paragraph)).toBe(true); expect( isBefore( paragraph, container.querySelector("article.ds-card") as Element, ), ).toBe(true); const hrefs = screen .getAllByRole("link") .map((link) => link.getAttribute("href") ?? ""); expect(hrefs.some((href) => /apolo/i.test(href))).toBe(false); unmount(); } }); it("does not mention Apolo Energia on the other service pages", async () => { for (const slug of [ "criacao-de-sites", "engenharia-de-software", "consultoria-nextjs", "tauri-rust", ]) { const { container, unmount } = await renderService(slug); expect(container.textContent).not.toMatch(/Apolo/); unmount(); } }); it("puts the FAQ before the next step and ends every service page with a contact button", async () => { for (const { slug, faq, ctaLabel } of servicePages) { const { container, unmount } = await renderService(slug); const nextStep = screen.getByRole("heading", { level: 2, name: "Conte o que você precisa resolver.", }); if (faq?.length) { const faqHeading = screen.getByRole("heading", { level: 2, name: "Perguntas frequentes", }); expect(isBefore(faqHeading, nextStep)).toBe(true); } // o botão de contato vem depois das dúvidas: o último elemento da página é a seção do próximo passo const last = container.querySelector("main")?.lastElementChild; expect(last).toContainElement(nextStep); const cta = within(last as HTMLElement).getByRole("link", { name: `${ctaLabel} no WhatsApp`, }); expect(new URL(cta.getAttribute("href") ?? "").origin).toBe( "https://wa.me", ); for (const question of faq ?? []) { expect( isBefore(screen.getByRole("heading", { name: question.question }), cta), ).toBe(true); } unmount(); } }); it("renders the offer block only when some field is filled in", async () => { const empty = await renderService("criacao-de-sites"); expect( screen.queryByRole("heading", { name: "Investimento e prazo" }), ).toBeNull(); empty.unmount(); const offerService = getServicePage("criacao-de-sites"); if (!offerService) throw new Error("Missing service"); offerService.typicalTimeline = "Texto de prazo de teste"; try { await renderService("criacao-de-sites"); expect( screen.getByRole("heading", { level: 2, name: "Investimento e prazo" }), ).toBeVisible(); expect(screen.getByText("Prazo típico")).toBeVisible(); expect(screen.getByText("Texto de prazo de teste")).toBeVisible(); expect(screen.queryByText("A partir de")).toBeNull(); expect(screen.queryByText("Garantia")).toBeNull(); } finally { delete offerService.typicalTimeline; } }); it("keeps related case ids resolving to real cases", () => { for (const id of service.relatedCaseIds) { expect(caseStudies.some((item) => item.id === id)).toBe(true); } });