yap-blog/tests/integration/home.test.ts
matt b7d473e7e5 Add RSS feed, sitemap, robots.txt, and search-engine metadata
A new "Site URL" setting (validated, trailing-slash-normalized, with
SITE_URL env fallback) anchors every absolute URL. On top of it:

- /feed.xml — RSS 2.0 with the 20 newest published posts: excerpt
  description, full sanitized HTML in content:encoded (relative image
  and link URLs rewritten to absolute, since readers resolve nothing),
  categories from tags, dc:creator, and a self atom:link. Autodiscovery
  <link> on every public page and a footer link.
- /sitemap.xml — home, post list, every published post and page
  (lastmod from updatedAt), and publicly visible tags. Rendered per
  request like the rest of the site so it never goes stale; drafts
  never appear.
- /robots.txt — allow all, disallow /admin/ and /api/, sitemap pointer.
  Admin pages also carry noindex meta as a second layer.
- Page metadata: metadataBase + canonical URLs everywhere, Open Graph
  (article type with published/modified times, author, and tags on
  posts; og:image + summary_large_image card when there's a featured
  image), and BlogPosting JSON-LD on post pages.

Gotcha encoded in lib/seo.ts: Next merges metadata shallowly, so pages
setting alternates.canonical alone would wipe the layout's RSS
autodiscovery entry — pageAlternates() always sets both.

Backups gain settings.siteUrl (export v4; older files still import).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-05 13:59:04 -04:00

149 lines
5 KiB
TypeScript

import { beforeEach, describe, expect, it } from "vitest";
import { eq } from "drizzle-orm";
import { db } from "@/db";
import { fontEnum, tags, themeEnum } from "@/db/schema";
import { resolveHomeContent } from "@/lib/services/home";
import { createPage, deletePage, setPageStatus } from "@/lib/services/pages";
import { createPost } from "@/lib/services/posts";
import {
getSettings,
listPublicNav,
saveSettings,
type SettingsInput,
} from "@/lib/services/settings";
import { resetDb } from "../helpers/db";
const settingsInput = (overrides: Partial<SettingsInput> = {}): SettingsInput => ({
siteTitle: "Test Site",
siteUrl: "",
headerText: "",
footerText: "",
postsPerPage: 10,
excerptWords: 40,
homeMode: "posts",
homeTagId: null,
homePageId: null,
theme: "solarized-dark",
font: "geist",
...overrides,
});
beforeEach(resetDb);
describe("home page configuration", () => {
it("defaults to the post list", async () => {
await saveSettings(settingsInput(), []);
const home = await resolveHomeContent(await getSettings());
expect(home.kind).toBe("posts");
});
it("shows a configured tag and falls back when the tag is deleted", async () => {
await createPost({
title: "Tagged",
slug: "",
body: "b",
authorName: "T",
featuredImageUrl: null,
featuredImageAlt: null,
status: "published",
tagIds: [],
newTagNames: ["Featured"],
});
const [tag] = await db.select().from(tags).where(eq(tags.slug, "featured"));
await saveSettings(settingsInput({ homeMode: "tag", homeTagId: tag.id }), []);
const home = await resolveHomeContent(await getSettings());
expect(home).toMatchObject({ kind: "tag", tag: { slug: "featured" } });
// Deleting the tag nulls settings.home_tag_id via ON DELETE SET NULL.
await db.delete(tags).where(eq(tags.id, tag.id));
const settingsAfter = await getSettings();
expect(settingsAfter.homeTagId).toBeNull();
expect((await resolveHomeContent(settingsAfter)).kind).toBe("posts");
});
it("shows a configured page and falls back when it is unpublished or deleted", async () => {
const page = await createPage({
title: "Welcome",
slug: "",
body: "Hello!",
status: "published",
});
await saveSettings(settingsInput({ homeMode: "page", homePageId: page.id }), []);
let home = await resolveHomeContent(await getSettings());
expect(home).toMatchObject({ kind: "page", page: { slug: "welcome" } });
// Unpublished page must not leak through the home route.
await setPageStatus(page.id, "draft");
home = await resolveHomeContent(await getSettings());
expect(home.kind).toBe("posts");
await setPageStatus(page.id, "published");
await deletePage(page.id);
const settingsAfter = await getSettings();
expect(settingsAfter.homePageId).toBeNull();
expect((await resolveHomeContent(settingsAfter)).kind).toBe("posts");
});
it("ignores a stale tag reference when the mode is posts", async () => {
await saveSettings(settingsInput({ homeMode: "posts", homeTagId: null }), []);
expect((await resolveHomeContent(await getSettings())).kind).toBe("posts");
});
});
describe("appearance settings", () => {
it("defaults to solarized-dark and geist on an unseeded database", async () => {
const settings = await getSettings();
expect(settings.theme).toBe("solarized-dark");
expect(settings.font).toBe("geist");
});
it("round-trips every theme in the enum", async () => {
for (const theme of themeEnum.enumValues) {
await saveSettings(settingsInput({ theme }), []);
expect((await getSettings()).theme).toBe(theme);
}
});
it("round-trips every font in the enum", async () => {
for (const font of fontEnum.enumValues) {
await saveSettings(settingsInput({ font }), []);
expect((await getSettings()).font).toBe(font);
}
});
});
describe("navigation", () => {
it("keeps URL items, resolves page items, and hides unpublished pages", async () => {
const page = await createPage({
title: "About",
slug: "",
body: "",
status: "published",
});
await saveSettings(settingsInput(), [
{ label: "All posts", url: "/posts", pageId: null },
{ label: "About", url: null, pageId: page.id },
{ label: "Elsewhere", url: "https://example.com", pageId: null },
]);
let nav = await listPublicNav();
expect(nav).toEqual([
{ label: "All posts", href: "/posts", external: false },
{ label: "About", href: "/pages/about", external: false },
{ label: "Elsewhere", href: "https://example.com", external: true },
]);
await setPageStatus(page.id, "draft");
nav = await listPublicNav();
expect(nav.map((l) => l.label)).toEqual(["All posts", "Elsewhere"]);
// Deleting the page removes its nav item entirely (ON DELETE CASCADE).
await setPageStatus(page.id, "published");
await deletePage(page.id);
nav = await listPublicNav();
expect(nav.map((l) => l.label)).toEqual(["All posts", "Elsewhere"]);
});
});