Commit e398f7a9 by PLN (Algolia)

test: blog folder model, bilingual accessors, token gates

The 8x6nki / szm8ld share-id gates stay explicit. New coverage:
available-language reporting, requested-language fetch with the
untranslated banner flag, prod filtering still keyed on NODE_ENV, and
the language-nav banner/switcher data. The both-languages path runs on
a synthetic _fixture-bilingual post (removed after the suite) — real
posts are single-language by design.
parent 70acab2f
import fs from "fs";
import path from "path";
import {
BLOG_LANGS,
DEFAULT_LANG,
getAllBlogSlugs,
getBlogLanguages,
getBlogPost,
getBlogPosts,
getAllBlogDraftSlugs,
getAllBlogDrafts,
getAllBlogDraftParams,
getDraftBlogPost,
getBlogPost,
} from "@/lib/blog";
import { buildLanguageNav } from "@/app/blog/language-nav";
import { parseShareId, shareTokenOf, getDraftPost } from "@/lib/content";
// The unified/shiki markdown chain is ESM-only and does not load under jest
// (see jest.config.js note); these tests exercise the draft accessors, not
// (see jest.config.js note); these tests exercise the folder accessors, not
// rendering, so stub the renderer.
jest.mock("@/lib/markdown", () => ({
renderMarkdown: jest.fn(async (source) => source),
......@@ -36,23 +42,55 @@ async function withProdEnv(fn) {
const ALL_BLOG_SLUGS = ["hello-world-again", "hello-world", "next"];
/**
* A synthetic bilingual post — the real posts are single-language by design
* (prose belongs to the owner, via git), so the both-languages path needs a
* fixture. The underscore prefix keeps it clearly synthetic; it lives only
* for this suite and is removed afterwards.
*/
const FIXTURE_SLUG = "_fixture-bilingual";
const FIXTURE_DIR = path.join(process.cwd(), "content", "blog", FIXTURE_SLUG);
beforeAll(() => {
fs.rmSync(FIXTURE_DIR, { recursive: true, force: true });
fs.mkdirSync(FIXTURE_DIR, { recursive: true });
fs.writeFileSync(
path.join(FIXTURE_DIR, "meta.md"),
"---\ntitle: Bilingual Fixture\ndate: '2026-01-01'\ndescription: Fixture description.\ndraft: false\n---\n",
);
fs.writeFileSync(
path.join(FIXTURE_DIR, "fr.md"),
"---\ntitle: Sujet bilingue\n---\n\nCorps français.\n",
);
fs.writeFileSync(
path.join(FIXTURE_DIR, "en.md"),
"---\ntitle: Bilingual Fixture (EN)\ndescription: English description.\n---\n\nEnglish body.\n",
);
});
afterAll(() => {
fs.rmSync(FIXTURE_DIR, { recursive: true, force: true });
});
describe("blog public accessors (prod rule untouched)", () => {
it("list drafts in dev (NODE_ENV=test)", () => {
it("lists posts in dev (NODE_ENV=test)", () => {
expect(getAllBlogSlugs()).toEqual(expect.arrayContaining(ALL_BLOG_SLUGS));
expect(getBlogPosts().map((p) => p.slug)).toEqual(
expect.arrayContaining(ALL_BLOG_SLUGS),
);
});
it("filter drafts out in prod — /blog/[slug] never renders them", async () => {
it("filters drafts out in prod — /blog/[slug] never renders them", async () => {
await withProdEnv(() => {
expect(getAllBlogSlugs()).toEqual([]);
expect(getBlogPosts()).toEqual([]);
// The synthetic fixture is a published post, so it stays; every real
// post is a draft and must vanish from the public surface.
expect(getAllBlogSlugs()).toEqual([FIXTURE_SLUG]);
expect(getBlogPosts().map((p) => p.slug)).toEqual([FIXTURE_SLUG]);
});
});
it("single-item getter never bypasses the rule in prod", async () => {
// It reads any file by design (posts exist only via getAllBlogSlugs);
// It reads any folder by design (posts exist only via getAllBlogSlugs);
// the route-level guarantee is that prod params are empty, verified above.
await withProdEnv(async () => {
const post = await getBlogPost("hello-world");
......@@ -60,6 +98,95 @@ describe("blog public accessors (prod rule untouched)", () => {
expect(post.title).toBe("Hello World");
});
});
it("lists newest-first with the default language's titles", () => {
const posts = getBlogPosts();
const dates = posts.filter((p) => ALL_BLOG_SLUGS.includes(p.slug)).map((p) => p.date);
expect(dates).toEqual([...dates].sort().reverse());
const again = posts.find((p) => p.slug === "hello-world-again");
expect(again.title).toBe("Hello World");
expect(again.languages).toEqual(["fr"]);
});
it("defaults to fr", () => {
expect(DEFAULT_LANG).toBe("fr");
expect(BLOG_LANGS).toEqual(["fr", "en"]);
});
});
describe("bilingual accessors", () => {
it("reports available languages from the folder contents", () => {
expect(getBlogLanguages("hello-world")).toEqual(["fr"]);
expect(getBlogLanguages("next")).toEqual(["en"]);
expect(getBlogLanguages(FIXTURE_SLUG)).toEqual(["fr", "en"]);
});
it("fetches the requested language when it exists", async () => {
const post = await getBlogPost(FIXTURE_SLUG, "en");
expect(post.lang).toBe("en");
expect(post.untranslated).toBeNull();
expect(post.title).toBe("Bilingual Fixture (EN)");
expect(post.contentHtml).toContain("English body.");
});
it("keeps meta frontmatter (date, description, draft) across languages", async () => {
const post = await getBlogPost(FIXTURE_SLUG, "en");
expect(post.date).toBe("2026-01-01");
expect(post.draft).toBe(false);
// Localized description wins over the meta one; meta fills the rest.
expect(post.description).toBe("English description.");
expect(post.languages).toEqual(["fr", "en"]);
});
it("falls back to the available language and flags the banner", async () => {
// fr requested on an fr-only post: no banner.
const fr = await getBlogPost("hello-world");
expect(fr.lang).toBe("fr");
expect(fr.untranslated).toBeNull();
// en requested on an fr-only post: fr renders, banner data present.
const en = await getBlogPost("hello-world", "en");
expect(en.lang).toBe("fr");
expect(en.untranslated).toBe("en");
expect(en.title).toBe("Hello World");
// fr requested on an en-only post: en renders, banner data present.
const next = await getBlogPost("next");
expect(next.lang).toBe("en");
expect(next.untranslated).toBe("fr");
expect(next.title).toBe("NextJs Tutorial");
});
it("throws for unknown slugs", async () => {
await expect(getBlogPost("no-such-post")).rejects.toThrow(/No blog post found/);
});
});
describe("buildLanguageNav — banner and switcher data", () => {
it("offers both languages when the post is bilingual", () => {
const post = { slug: "foo", lang: "fr", untranslated: null, languages: ["fr", "en"] };
const nav = buildLanguageNav(post);
expect(nav.banner).toBeNull();
expect(nav.switcher).toEqual([
{ lang: "fr", href: "/blog/foo" },
{ lang: "en", href: "/blog/foo/en" },
]);
});
it("links the available language when the requested one is missing", () => {
const frMissing = buildLanguageNav({
slug: "next", lang: "en", untranslated: "fr", languages: ["en"],
});
expect(frMissing.switcher).toEqual([]);
expect(frMissing.banner.text).toBe("Cet article n'existe pas (encore) en français.");
expect(frMissing.banner.href).toBe("/blog/next/en");
const enMissing = buildLanguageNav({
slug: "foo", lang: "fr", untranslated: "en", languages: ["fr"],
});
expect(enMissing.banner.text).toBe("This post doesn't exist in English (yet).");
expect(enMissing.banner.href).toBe("/blog/foo");
});
});
describe("draft accessors bypass draftsVisible()", () => {
......@@ -89,13 +216,13 @@ describe("draft accessors bypass draftsVisible()", () => {
expect(ids.every((id) => parseShareId(id) !== null)).toBe(true);
});
it("getDraftPost refuses non-drafts", async () => {
it("getDraftPost (flat collections) refuses non-drafts", async () => {
await expect(getDraftPost("poems", "berg")).rejects.toThrow(/not a draft/);
});
it("getDraftPost renders a draft regardless of NODE_ENV", async () => {
it("getDraftBlogPost renders a draft regardless of NODE_ENV", async () => {
await withProdEnv(async () => {
const post = await getDraftPost("blog", "hello-world", { plainDescription: true });
const post = await getDraftBlogPost("hello-world-8x6nki");
expect(post.draft).toBe(true);
expect(typeof post.contentHtml).toBe("string");
});
......@@ -103,7 +230,7 @@ describe("draft accessors bypass draftsVisible()", () => {
});
describe("getDraftBlogPost — share id gate", () => {
it("renders when the token matches the file frontmatter", async () => {
it("renders when the token matches meta.md frontmatter", async () => {
const post = await getDraftBlogPost("hello-world-8x6nki");
expect(post).not.toBeNull();
expect(post.slug).toBe("hello-world");
......@@ -121,6 +248,7 @@ describe("getDraftBlogPost — share id gate", () => {
});
it("rejects non-draft slugs", async () => {
expect(await getDraftBlogPost(`${FIXTURE_SLUG}-abc123`)).toBeNull();
await expect(getDraftBlogPost("berg-nopass")).resolves.toBeNull();
});
});
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment