import sitemap from "../app/sitemap"; import { buildBreadcrumbList, buildCaseJsonLd, buildOfferServicesJsonLd, buildPageSocialMetadata, buildPersonJsonLd, buildProfilePageJsonLd, buildServiceJsonLd, buildWebSiteJsonLd, getCaseImageUrl, SEO_IDS, twitterHandle, } from "./seo"; import { caseStudies, isClientCase, serviceOffers, servicePages, siteName, siteUrl, } from "./site-content"; const representativeService = servicePages[0]; if (!representativeService) throw new Error("Missing representative service"); it("uses one canonical identity across website, profile and service", () => { expect(SEO_IDS).toEqual({ website: `${siteUrl}/#website`, person: `${siteUrl}/#person`, profilePage: `${siteUrl}/curriculo#profile-page`, }); expect(buildWebSiteJsonLd().publisher).toEqual({ "@id": SEO_IDS.person }); expect(buildProfilePageJsonLd()).toMatchObject({ "@id": SEO_IDS.profilePage, isPartOf: { "@id": SEO_IDS.website }, mainEntity: { "@id": SEO_IDS.person }, }); expect(buildServiceJsonLd(representativeService).provider).toEqual({ "@id": SEO_IDS.person, }); }); it("keeps the root graph limited to verified website and person fields", () => { const website = buildWebSiteJsonLd(); const person = buildPersonJsonLd(); expect(Object.keys(website).sort()).toEqual( ["@id", "@type", "inLanguage", "name", "publisher", "url"].sort(), ); expect(Object.keys(person).sort()).toEqual( [ "@id", "@type", "homeLocation", "image", "jobTitle", "knowsAbout", "name", "sameAs", "url", ].sort(), ); expect(website).toMatchObject({ "@type": "WebSite", "@id": SEO_IDS.website, url: siteUrl, name: siteName, }); expect(person).toMatchObject({ "@type": "Person", "@id": SEO_IDS.person, image: `${siteUrl}/edev.jpg`, jobTitle: "Engenheiro da Computação e Desenvolvedor de Software", homeLocation: { "@type": "Place", name: "Garuva, Santa Catarina, Brasil" }, // só o LinkedIn: a conta do GitHub foi suspensa (404) e o Instagram fica fora por política sameAs: ["https://www.linkedin.com/in/evandrodevbr/"], }); expect(JSON.stringify([website, person])).not.toMatch( /ProfessionalService|LocalBusiness|GeoCoordinates|PostalAddress|Instagram|telephone|address|geo|priceRange|OfferCatalog|hasOfferCatalog|offers/i, ); }); it("builds a canonical service whose service area is the visible one: Garuva, Joinville, Santa Catarina and Brazil", () => { const service = representativeService; const pageUrl = `${siteUrl}/servicos/${service.slug}`; expect(buildServiceJsonLd(service)).toEqual({ "@type": "Service", "@id": `${pageUrl}#service`, url: pageUrl, name: service.navLabel, description: service.metaDescription, provider: { "@id": SEO_IDS.person }, areaServed: [ { "@type": "City", name: "Garuva" }, { "@type": "City", name: "Joinville" }, { "@type": "AdministrativeArea", name: "Santa Catarina" }, { "@type": "Country", name: "Brasil" }, ], }); // a mesma área das três ofertas da home: um valor só em todo o JSON-LD for (const offer of buildOfferServicesJsonLd()) { expect(offer.areaServed).toEqual(buildServiceJsonLd(service).areaServed); } }); it("describes the three offers as Service nodes tied to the person, with the service area and the page of each offer", () => { const services = buildOfferServicesJsonLd(); expect(services).toHaveLength(3); // as mesmas três ofertas da home, com o mesmo nome e a página de cada uma (a revisão aponta para /ia) expect(services.map(({ name }) => name)).toEqual([ "Site que traz pedidos pelo WhatsApp", "Sistema para tirar a operação da planilha", "Revisão de sistema que já existe", ]); expect(services.map(({ url }) => url)).toEqual([ `${siteUrl}/servicos/criacao-de-sites`, `${siteUrl}/servicos/engenharia-de-software`, `${siteUrl}/ia`, ]); expect(services.map(({ url }) => url)).toEqual( serviceOffers.map(({ href }) => `${siteUrl}${href}`), ); for (const service of services) { expect(service["@type"]).toBe("Service"); expect(service.provider).toEqual({ "@id": SEO_IDS.person }); expect(service.areaServed.map(({ name }) => name)).toEqual([ "Garuva", "Joinville", "Santa Catarina", "Brasil", ]); expect(service.description.length).toBeGreaterThan(30); } // ids próprios e estáveis, que não repetem o `#service` que a página de cada serviço emite const ids = services.map((service) => service["@id"]); expect(new Set(ids).size).toBe(3); expect(ids).toEqual([ `${siteUrl}/#service-site`, `${siteUrl}/#service-sistema`, `${siteUrl}/#service-revisao`, ]); // política de docs/seo-geo-operations.md: nenhum tipo de negócio local e nada que o site não publique expect(JSON.stringify(services)).not.toMatch( /ProfessionalService|LocalBusiness|OfferCatalog|price|priceCurrency|availability|telephone|email|streetAddress|PostalAddress|geo|aggregateRating|review/i, ); }); function getCase(id: string) { const caseStudy = caseStudies.find((item) => item.id === id); if (!caseStudy) throw new Error(`Missing case ${id}`); return caseStudy; } it("describes a client case as a CreativeWork made by the person for the client", () => { const caseStudy = getCase("sev-modaintima"); const pageUrl = `${siteUrl}/cases/${caseStudy.id}`; expect(buildCaseJsonLd(caseStudy)).toEqual({ "@type": "CreativeWork", "@id": `${pageUrl}#creative-work`, name: "SEV Moda Íntima: Catálogo digital e loja virtual", description: caseStudy.metaDescription, url: pageUrl, image: `${pageUrl}/opengraph-image/${caseStudy.id}`, inLanguage: "pt-BR", creator: { "@id": SEO_IDS.person }, sourceOrganization: { "@type": "Organization", name: "SEV Moda Íntima", url: "https://sevmodaintima.com.br", }, keywords: "Next.js 16, TypeScript, PostgreSQL, Prisma, Tailwind CSS 4, Framer Motion", }); }); it("builds a CreativeWork for every case, with the client only on client work", () => { for (const caseStudy of caseStudies) { const work = buildCaseJsonLd(caseStudy); expect(work).toMatchObject({ "@type": "CreativeWork", url: `${siteUrl}/cases/${caseStudy.id}`, image: getCaseImageUrl(caseStudy.id), creator: { "@id": SEO_IDS.person }, }); expect(work.name).toContain(caseStudy.title); expect(work.description.length).toBeGreaterThan(40); expect(work.keywords).toBe(caseStudy.stack.join(", ")); expect("sourceOrganization" in work).toBe(isClientCase(caseStudy)); } }); it("omits the client address when a client case has no public site", () => { const work = buildCaseJsonLd(getCase("as-servicos")) as { url: string; sourceOrganization?: Record; }; expect(work.sourceOrganization).toStrictEqual({ "@type": "Organization", name: "AS Serviços", }); expect(work.url).toBe(`${siteUrl}/cases/as-servicos`); }); it("builds contiguous breadcrumbs from readonly tuples", () => { const pageUrl = `${siteUrl}/servicos/x`; const items = [ ["Início", siteUrl], ["Serviços", `${siteUrl}/servicos`], ["X", pageUrl], ] as const; const breadcrumb = buildBreadcrumbList(pageUrl, items); expect(breadcrumb["@id"]).toBe(`${pageUrl}#breadcrumb`); 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: "X", item: pageUrl }, ]); }); it("keeps profile data timeless and sitemap output deterministic", () => { expect(buildProfilePageJsonLd()).not.toHaveProperty("dateModified"); const expectedUrls = [ siteUrl, `${siteUrl}/curriculo`, `${siteUrl}/privacidade`, `${siteUrl}/servicos`, `${siteUrl}/cases`, `${siteUrl}/ia`, ...servicePages.map(({ slug }) => `${siteUrl}/servicos/${slug}`), ...caseStudies.map(({ id }) => `${siteUrl}/cases/${id}`), ]; const first = sitemap(); const second = sitemap(); expect(first.map(({ url }) => url)).toEqual(expectedUrls); expect(second).toEqual(first); expect(first).toHaveLength(21); expect( first.filter(({ url }) => url === `${siteUrl}/privacidade`), ).toHaveLength(1); }); it("lists the client cases and the AI page, each with a fixed modification date", () => { const entries = sitemap(); const urls = entries.map(({ url }) => url); expect(urls).toContain(`${siteUrl}/cases/sev-modaintima`); expect(urls).toContain(`${siteUrl}/cases/lavacao-047`); expect(urls).toContain(`${siteUrl}/ia`); for (const entry of entries) { expect(entry.lastModified).toBeInstanceOf(Date); expect(Number.isNaN(new Date(entry.lastModified ?? "").getTime())).toBe( false, ); } // a data é a do conteúdo de cada rota, não uma data única para o mapa inteiro expect( new Set(entries.map(({ lastModified }) => String(lastModified))).size, ).toBeGreaterThan(1); }); it("gives every page a complete social card, so it never inherits the home one", () => { const url = `${siteUrl}/cases`; expect( buildPageSocialMetadata({ url, title: "Título", description: "Descrição", type: "website", }), ).toEqual({ alternates: { canonical: url }, openGraph: { locale: "pt_BR", title: "Título", description: "Descrição", url, siteName, type: "website", }, twitter: { card: "summary_large_image", title: "Título", description: "Descrição", creator: twitterHandle, }, }); });