// One fixture per rule: a valid post broken in exactly one way, and a test // that the check rejects it. Run with `pnpm test`. import assert from "node:assert/strict"; import { describe, test } from "node:test"; import { checkPost, checkPosts, type RuleContext, type RulePost, } from "./rules"; const categories = [ { slug: "alpha", name: "Alpha", description: "First", intro: "First category.", }, { slug: "beta", name: "Beta", description: "Second", intro: "Second category.", }, ]; const authors = [ { slug: "org", name: "Org", kind: "organization" as const, bio: "Org.", url: "https://example.com", sameAs: [], }, ]; const ctx: RuleContext = { categories, authors, full: false, now: new Date("2026-10-16T12:00:00Z"), }; // Deterministic filler with a vocabulary per seed, so two fixtures never share 5-word runs function filler(seed: number, n: number): string { let x = seed * 7919 + 13; const out: string[] = []; for (let i = 0; i < n; i++) { x = (x * 48271) % 2147483647; out.push(`w${seed}x${x % 997}`); } return out.join(" "); } function body( seed: number, opts: { headings?: string[]; answerWords?: number; sectionWords?: number; extra?: string; } = {}, ) { const headings = opts.headings ?? [ `What is topic ${seed}?`, `How does part ${seed} behave?`, `Setting up piece ${seed}`, `When not to use item ${seed}`, ]; const answer = `Bank statement keyword ${filler(seed, (opts.answerWords ?? 45) - 3)}.`; const sections = headings.map( (h, i) => `## ${h}\n\n${filler(seed * 10 + i, 40)}.\n\n${filler(seed * 100 + i, opts.sectionWords ?? 380)}.`, ); return [answer, ...sections, opts.extra ?? ""].join("\n\n"); } function post(seed: number, over: Partial = {}): RulePost { return { slug: `bank-statement-keyword-${seed}`, title: `Bank statement keyword guide ${seed}`, description: `A complete walk through the bank statement keyword topic number ${seed}, written so that the description lands inside the allowed range.`, category: "alpha", primaryKeyword: "bank statement keyword", length: "full", author: "org", draft: false, body: body(seed), createdAt: "2026-10-16T00:00:00Z", publishedAt: "2026-10-16T00:00:00Z", updatedAt: null, ...over, }; } const rules = (p: RulePost) => checkPost(p, ctx).map((f) => f.rule); describe("a valid post", () => { test("passes every per-post rule", () => assert.deepEqual(checkPost(post(1), ctx), [])); }); describe("per-post rules reject their fixture", () => { test("reserved slug", () => assert.ok( rules( post(1, { slug: "recent", primaryKeyword: "recent", title: "Recent posts on bank statement keyword", }), ).includes("reserved-slug"), )); test("keyword missing from title", () => assert.ok( rules(post(1, { title: "Something else entirely" })).includes( "keyword-placement", ), )); test("keyword missing from slug", () => assert.ok( rules(post(1, { slug: "other-slug" })).includes("keyword-placement"), )); test("keyword words missing from description", () => assert.ok( rules(post(1, { description: "x".repeat(130) })).includes( "keyword-placement", ), )); test("title too long", () => assert.ok( rules( post(1, { title: `Bank statement keyword ${"x".repeat(60)}` }), ).includes("title-length"), )); test("description too short", () => assert.ok( rules(post(1, { description: "bank statement keyword" })).includes( "description-length", ), )); test("top-level heading in body", () => assert.ok( rules(post(1, { body: `# Title\n\n${body(1)}` })).includes( "one-top-heading", ), )); test("unknown category", () => assert.ok(rules(post(1, { category: "nope" })).includes("known-category"))); test("unknown author", () => assert.ok(rules(post(1, { author: "nobody" })).includes("known-author"))); test("http source", () => assert.ok( rules( post(1, { body: body(1, { extra: "See [the spec](http://example.com/spec)." }), }), ).includes("sources-are-links"), )); test("vague link text", () => assert.ok( rules( post(1, { body: body(1, { extra: "See [here](https://example.com/spec)." }), }), ).includes("sources-are-links"), )); test("image without alt", () => assert.ok( rules( post(1, { body: body(1, { extra: "![](https://example.com/a.png)" }) }), ).includes("images-described"), )); test("published in the future", () => assert.ok( rules(post(1, { publishedAt: "2027-01-01T00:00:00Z" })).includes( "honest-dates", ), )); test("updated before published", () => assert.ok( rules(post(1, { updatedAt: "2026-01-01T00:00:00Z" })).includes( "honest-dates", ), )); test("opening too short", () => assert.ok( rules(post(1, { body: body(1, { answerWords: 20 }) })).includes( "answer-first", ), )); test("opening too long", () => assert.ok( rules(post(1, { body: body(1, { answerWords: 80 }) })).includes( "answer-first", ), )); test("too few question headings", () => assert.ok( rules( post(1, { body: body(1, { headings: ["One a", "Two b", "Three c", "Four d?"] }), }), ).includes("question-headings"), )); test("too few sections", () => assert.ok( rules( post(1, { body: body(1, { headings: ["One?", "Two?", "Three"], sectionWords: 520, }), }), ).includes("sections"), )); test("duplicate anchors", () => assert.ok( rules( post(1, { body: body(1, { headings: ["Same one?", "Same one?", "Three?", "Four"], }), }), ).includes("sections"), )); test("too short", () => assert.ok( rules(post(1, { body: body(1, { sectionWords: 100 }) })).includes( "minimum-length", ), )); test("short posts have a lower floor", () => assert.ok( !rules( post(1, { length: "short", body: body(1, { sectionWords: 200 }) }), ).includes("minimum-length"), )); test("drafts skip the length rule", () => assert.ok( !rules( post(1, { draft: true, body: body(1, { sectionWords: 10 }) }), ).includes("minimum-length"), )); }); const linked = (seed: number, to: number, over: Partial = {}) => post(seed, { primaryKeyword: `bank statement keyword ${seed}`, slug: `bank-statement-keyword-${seed}`, title: `Bank statement keyword ${seed}`, body: body(seed, { extra: `Read [the guide to bank statement keyword ${to}](/blog/bank-statement-keyword-${to}).`, }).replace("Bank statement keyword", `Bank statement keyword ${seed}`), ...over, }); describe("whole-blog rules reject their fixture", () => { const full = { ...ctx, full: true }; const blogOf = (...posts: RulePost[]) => checkPosts(posts, full).findings.map((f) => f.rule); test("a linked ring passes", () => assert.deepEqual( checkPosts([linked(1, 2), linked(2, 3), linked(3, 1)], full).findings, [], )); test("unique slugs", () => assert.ok( blogOf(linked(1, 2), linked(2, 1), { ...linked(3, 1), slug: "bank-statement-keyword-1", }).includes("unique-slugs"), )); test("one owner per keyword", () => assert.ok( blogOf(linked(1, 2), linked(2, 1), { ...linked(3, 1), primaryKeyword: "bank statement keyword 1", }).includes("one-owner-per-keyword"), )); test("mapped keyword without a post", () => assert.ok( checkPosts([linked(1, 2), linked(2, 1)], { ...full, keywordMap: [{ keyword: "missing keyword", slug: "missing-keyword" }], }).findings.some((f) => f.rule === "one-owner-per-keyword"), )); test("distinct headings", () => { const h = ["Shared heading?", "Other one?", "Third", "Fourth"]; const a = { ...linked(1, 2), body: body(1, { headings: h, extra: "[two](/blog/bank-statement-keyword-2)", }).replace("Bank statement keyword", "Bank statement keyword 1"), }; const b = { ...linked(2, 1), body: body(2, { headings: h, extra: "[one](/blog/bank-statement-keyword-1)", }).replace("Bank statement keyword", "Bank statement keyword 2"), }; assert.ok(blogOf(a, b).includes("distinct-headings")); }); test("repeated sections", () => { const a = linked(1, 2); const b = { ...linked(2, 1), body: a.body.replace( /^.*$/m, "Bank statement keyword 2 " + filler(2, 42), ), }; assert.ok(blogOf(a, b).includes("no-repeated-sections")); }); test("broken internal link", () => assert.ok(blogOf(linked(1, 9), linked(2, 1)).includes("links-resolve"))); test("unknown page link", () => assert.ok( blogOf( linked(1, 2, { body: linked(1, 2).body + "\n\n[pricing](/pricing)" }), linked(2, 1), ).includes("links-resolve"), )); test("orphan", () => assert.ok( blogOf(linked(1, 2), linked(2, 1), linked(3, 1)).includes("no-orphans"), )); test("unbalanced categories", () => assert.ok( blogOf( linked(1, 2), linked(2, 3), linked(3, 4), linked(4, 5), linked(5, 6, { category: "beta" }), linked(6, 1, { category: "beta" }), linked(7, 1), linked(8, 7), linked(9, 8), ).includes("balanced-categories"), )); });