SEO - New Settings > SEO admin page (seo_settings in app_config): indexing switch, Google/Bing verification, X handle, JSON-LD identity (Person/Organization, sameAs), per-locale keywords, readiness checklist and open links for sitemap.xml / robots.txt / manifest. - robots.txt is now dynamic: disallows admin, api, success and coming-soon paths; blocks everything while indexing is off or maintenance is on. - sitemap.xml carries hreflang alternates per URL, lists only categories with published projects, and is empty while hidden. - Metadata: robots + verification meta, og:locale in de_DE/en_US/ar_AR form, alternateLocale, twitter site/creator, project cover as OG image with article type, noindex on /success and /coming-soon. - JSON-LD: WebSite + publisher graph on all public pages, CreativeWork per project (view-mode independent). Security - Maintenance bypass now requires a correctly signed admin cookie; the middleware previously only checked the cookie existed. Token helpers moved to lib/admin-session-token.ts (shared by proxy.ts and lib/admin-auth.ts). - Media uploads: magic-byte validation against the declared type, SVG sanitization (script/handlers/foreignObject/javascript: rejected), upload folder sanitized, kind inferred from the real file. - Media route: fixed prefix-based path check that accepted sibling directories, unknown extensions return 404, nosniff header, CSP sandbox on SVG, gif content type added. - External media URLs: protocol-relative (//host) URLs rejected. Portfolio - Project and category slugs share /portfolio/[slug]; saving now rejects a slug already used on the other side instead of silently shadowing it. Tooling/docs - Lint: ignore scripts/legacy-prisma-seed.cjs, drop unused import. - New docs/SEO.md; FEATURES, ARCHITECTURE (Drizzle instead of Prisma), admin spec and CLAUDE.md updated. - Tests for all of the above (unit + integration); suite green.
219 lines
8.8 KiB
TypeScript
219 lines
8.8 KiB
TypeScript
import { describe, expect, it } from "vitest";
|
|
|
|
import { buildDefaultSeoSettings } from "@/lib/seo-settings";
|
|
import { buildDefaultSiteSettings } from "@/lib/site-settings";
|
|
import {
|
|
applyTitleTemplateFn,
|
|
buildAppMetadataFromConfig,
|
|
buildLocaleAlternates,
|
|
buildLocalizedMetadataFromConfig,
|
|
buildProjectJsonLd,
|
|
buildSiteJsonLd,
|
|
serializeJsonLd,
|
|
} from "@/lib/metadata";
|
|
|
|
const noBindings = {
|
|
siteLogoLight: null,
|
|
siteLogoDark: null,
|
|
favicon: null,
|
|
defaultOgImage: null,
|
|
};
|
|
|
|
describe("applyTitleTemplateFn", () => {
|
|
it("substitutes page title and site name", () => {
|
|
expect(applyTitleTemplateFn("About", "{pageTitle} | {siteName}", "Studio")).toBe("About | Studio");
|
|
});
|
|
|
|
it("replaces every site-name token but only the first page-title token", () => {
|
|
expect(applyTitleTemplateFn("P", "{siteName} {pageTitle} {siteName}", "S")).toBe("S P S");
|
|
});
|
|
|
|
it("falls back to a default template when the token is missing", () => {
|
|
expect(applyTitleTemplateFn("About", "Just Site", "Studio")).toBe("About | Studio");
|
|
});
|
|
});
|
|
|
|
describe("buildLocaleAlternates", () => {
|
|
it("builds canonical, hreflang, and x-default against the default locale", () => {
|
|
const alt = buildLocaleAlternates("/about", "ar");
|
|
expect(alt.canonical).toBe("https://mohfarawati.de/about");
|
|
expect(alt.languages.ar).toBe("https://mohfarawati.de/about");
|
|
expect(alt.languages.de).toBe("https://mohfarawati.de/de/about");
|
|
expect(alt.languages.en).toBe("https://mohfarawati.de/en/about");
|
|
expect(alt.languages["x-default"]).toBe("https://mohfarawati.de/about");
|
|
});
|
|
|
|
it("shifts prefixes when the default locale changes", () => {
|
|
const alt = buildLocaleAlternates("/about", "en");
|
|
expect(alt.canonical).toBe("https://mohfarawati.de/about");
|
|
expect(alt.languages.en).toBe("https://mohfarawati.de/about");
|
|
expect(alt.languages.de).toBe("https://mohfarawati.de/de/about");
|
|
});
|
|
});
|
|
|
|
describe("buildAppMetadataFromConfig", () => {
|
|
it("uses summary twitter card and omits og images when unset", () => {
|
|
const settings = buildDefaultSiteSettings("Studio");
|
|
const metadata = buildAppMetadataFromConfig(settings, noBindings);
|
|
expect(metadata.title).toBe("Studio");
|
|
expect(metadata.twitter).toMatchObject({ card: "summary" });
|
|
expect(metadata.openGraph?.images).toBeUndefined();
|
|
expect(metadata.metadataBase?.toString()).toBe("https://mohfarawati.de/");
|
|
});
|
|
|
|
it("uses summary_large_image and a versioned favicon when bindings exist", () => {
|
|
const settings = buildDefaultSiteSettings("Studio");
|
|
const metadata = buildAppMetadataFromConfig(settings, {
|
|
...noBindings,
|
|
favicon: { assetId: "f", url: "/uploads/media/site-settings/favicon.svg", version: "v9" },
|
|
defaultOgImage: { assetId: "og", url: "/uploads/media/site-settings/og.png", version: "v9" },
|
|
});
|
|
expect(metadata.twitter).toMatchObject({ card: "summary_large_image" });
|
|
expect(metadata.icons).toMatchObject({ icon: [{ url: "/favicon.ico?v=v9" }] });
|
|
});
|
|
});
|
|
|
|
describe("buildLocalizedMetadataFromConfig", () => {
|
|
it("applies the title template and localized description by default", () => {
|
|
const settings = buildDefaultSiteSettings("Studio");
|
|
settings.locales.en.siteDescription = "English description";
|
|
const metadata = buildLocalizedMetadataFromConfig({
|
|
settings,
|
|
bindings: noBindings,
|
|
locale: "en",
|
|
pathname: "/about",
|
|
title: "About",
|
|
});
|
|
expect(metadata.title).toBe("About | Studio");
|
|
expect(metadata.description).toBe("English description");
|
|
expect(metadata.openGraph?.locale).toBe("en_US");
|
|
});
|
|
|
|
it("can skip the title template (homepage)", () => {
|
|
const settings = buildDefaultSiteSettings("Studio");
|
|
const metadata = buildLocalizedMetadataFromConfig({
|
|
settings,
|
|
bindings: noBindings,
|
|
locale: "ar",
|
|
pathname: "/",
|
|
title: "الرئيسية",
|
|
applyTitleTemplate: false,
|
|
});
|
|
expect(metadata.title).toBe("الرئيسية");
|
|
});
|
|
|
|
it("prefers an explicit description over the locale default", () => {
|
|
const settings = buildDefaultSiteSettings("Studio");
|
|
const metadata = buildLocalizedMetadataFromConfig({
|
|
settings,
|
|
bindings: noBindings,
|
|
locale: "de",
|
|
pathname: "/x",
|
|
title: "T",
|
|
description: " Custom desc ",
|
|
});
|
|
expect(metadata.description).toBe("Custom desc");
|
|
});
|
|
});
|
|
|
|
describe("seo-aware metadata", () => {
|
|
const settings = buildDefaultSiteSettings("Studio");
|
|
|
|
it("indexes by default and emits verification + twitter handle when configured", () => {
|
|
const seo = {
|
|
...buildDefaultSeoSettings(),
|
|
googleSiteVerification: "g123",
|
|
bingSiteVerification: "b456",
|
|
twitterHandle: "@moh",
|
|
};
|
|
const metadata = buildAppMetadataFromConfig(settings, noBindings, seo);
|
|
expect(metadata.robots).toMatchObject({ index: true, follow: true });
|
|
expect(metadata.verification).toEqual({ google: "g123", other: { "msvalidate.01": "b456" } });
|
|
expect(metadata.twitter).toMatchObject({ site: "@moh", creator: "@moh" });
|
|
expect(metadata.openGraph).toMatchObject({ locale: "de_DE", alternateLocale: ["en_US", "ar_AR"] });
|
|
});
|
|
|
|
it("emits noindex everywhere when indexing is disabled", () => {
|
|
const seo = { ...buildDefaultSeoSettings(), allowIndexing: false };
|
|
expect(buildAppMetadataFromConfig(settings, noBindings, seo).robots).toMatchObject({ index: false });
|
|
const page = buildLocalizedMetadataFromConfig({ settings, bindings: noBindings, seo, locale: "en", pathname: "/about", title: "About" });
|
|
expect(page.robots).toMatchObject({ index: false, follow: false });
|
|
});
|
|
|
|
it("supports per-page noindex, article type and a page-specific image", () => {
|
|
const published = new Date("2026-01-02T00:00:00Z");
|
|
const page = buildLocalizedMetadataFromConfig({
|
|
settings,
|
|
bindings: { ...noBindings, defaultOgImage: { assetId: "a", url: "/og.png", version: "1" } },
|
|
locale: "en",
|
|
pathname: "/portfolio/x",
|
|
title: "X",
|
|
image: "/uploads/media/covers/x.png",
|
|
type: "article",
|
|
publishedTime: published,
|
|
});
|
|
expect(page.robots).toMatchObject({ index: true });
|
|
expect(page.openGraph).toMatchObject({
|
|
type: "article",
|
|
publishedTime: published.toISOString(),
|
|
images: [{ url: "https://mohfarawati.de/uploads/media/covers/x.png", alt: "X" }],
|
|
});
|
|
|
|
const thanks = buildLocalizedMetadataFromConfig({ settings, bindings: noBindings, locale: "en", pathname: "/success", title: "Thanks", noIndex: true });
|
|
expect(thanks.robots).toMatchObject({ index: false });
|
|
});
|
|
|
|
it("falls back to the default og image when no page image is given", () => {
|
|
const page = buildLocalizedMetadataFromConfig({
|
|
settings,
|
|
bindings: { ...noBindings, defaultOgImage: { assetId: "a", url: "/og.png", version: "1" } },
|
|
locale: "de",
|
|
pathname: "/",
|
|
title: "Home",
|
|
});
|
|
expect(page.openGraph).toMatchObject({ images: [{ url: "https://mohfarawati.de/og.png", alt: "Home" }] });
|
|
});
|
|
});
|
|
|
|
describe("json-ld", () => {
|
|
const settings = buildDefaultSiteSettings("Studio");
|
|
|
|
it("builds a WebSite + Person graph linked by @id", () => {
|
|
const seo = { ...buildDefaultSeoSettings(), structuredDataName: "Moh", structuredDataJobTitle: "Designer", sameAs: ["https://x.com/moh"] };
|
|
const graph = buildSiteJsonLd({ settings, seo, bindings: noBindings, locale: "de" })["@graph"] as Array<Record<string, unknown>>;
|
|
expect(graph[0]).toMatchObject({ "@type": "WebSite", publisher: { "@id": "https://mohfarawati.de/#person" } });
|
|
expect(graph[1]).toMatchObject({ "@type": "Person", name: "Moh", jobTitle: "Designer", sameAs: ["https://x.com/moh"] });
|
|
});
|
|
|
|
it("uses Organization shape when configured", () => {
|
|
const seo = { ...buildDefaultSeoSettings(), structuredDataType: "Organization" as const, structuredDataJobTitle: "Studio" };
|
|
const graph = buildSiteJsonLd({ settings, seo, bindings: noBindings, locale: "en" })["@graph"] as Array<Record<string, unknown>>;
|
|
expect(graph[1]).toMatchObject({ "@type": "Organization", slogan: "Studio" });
|
|
});
|
|
|
|
it("builds a CreativeWork per project with localized url", () => {
|
|
const work = buildProjectJsonLd({
|
|
settings,
|
|
seo: buildDefaultSeoSettings(),
|
|
locale: "en",
|
|
pathname: "/portfolio/x",
|
|
title: "X",
|
|
description: "D",
|
|
image: "/c.png",
|
|
datePublished: new Date("2026-01-01T00:00:00Z"),
|
|
clientName: "ACME",
|
|
});
|
|
expect(work).toMatchObject({
|
|
"@type": "CreativeWork",
|
|
url: "https://mohfarawati.de/en/portfolio/x",
|
|
image: "https://mohfarawati.de/c.png",
|
|
sourceOrganization: { "@type": "Organization", name: "ACME" },
|
|
author: { "@id": "https://mohfarawati.de/#person" },
|
|
});
|
|
});
|
|
|
|
it("escapes < so the payload cannot close the script tag", () => {
|
|
expect(serializeJsonLd({ name: "</script><script>alert(1)</script>" })).not.toContain("</script>");
|
|
});
|
|
});
|