Files
website/server/test/pages.model.test.js
wtclaude 99fd9acddb test(server): unit-test pages, shardState, and moderation model logic
Add meaningful unit tests for three server models with untested business
logic, each against an in-memory fake db (no DB required):

- pages.model: slug validation + reserved-name guard, slug immutability,
  the protected ON-via-update / OFF-only-via-unprotect asymmetry, publish
  stamping, draft invisibility to public reads, dup-slug → 409, block gate.
- shardState.model: partial-refresh field dropping (vitals must not clobber
  login fields), is_idoc derivation, economy clamp/ordering/Number coercion,
  presence zero-snapshot defaults, payload-fallback shaping, and the
  camelCase read-shaping contract the site + Android client depend on.
- moderation.model: five-feed window merge, userSummary count/total
  semantics (total sums unknown types too), and graceful degradation when
  bot config is missing.

Lifts: pages.model 23%→82%, moderation.model 27%→85%,
shardState.model 33%→62% line coverage.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-21 00:26:01 -05:00

243 lines
9.9 KiB
JavaScript

// Point the DB pool at a dead port before it's built; every pages.db method is
// monkeypatched below, and pool.close() at the end lets the process exit cleanly.
process.env.DB_HOST = '127.0.0.1'
process.env.DB_PORT = '59999'
const { test, beforeEach, afterEach, after } = require('node:test')
const assert = require('node:assert/strict')
const pool = require('../src/utils/db')
after(() => pool.close())
// Unit-test the CMS pages model against an in-memory fake by monkeypatching
// pages.db (no DB). The point is to lock the rules the model owns and the API
// surface must not be able to bypass:
// - slug validation + reserved-name guard, and slug immutability after create;
// - block validation/sanitization on every write (the authoritative gate);
// - the `protected` asymmetry: ON via a normal update, OFF only via unprotect();
// - published_at stamped once, on the first publish;
// - draft pages invisible to public (getBySlug) reads;
// - a duplicate slug surfaces as a 409, not a raw DB error.
const pagesDb = require('../src/model/pages/pages.db')
const pages = require('../src/model/pages/pages.model')
let rows
let nextId
const saved = {}
// A row as pages.db would return it (snake_case columns). blocks stored as JSON.
function seed(row) {
const full = {
id: nextId++,
slug: 'seed',
title: 'Seed',
status: 'draft',
blocks: '[]',
seo_title: null,
meta_description: null,
og_image: null,
canonical_url: null,
robots: null,
layout: 'default',
show_in_nav: 0,
nav_group: null,
nav_order: null,
protected: 0,
author_id: 1,
created_at: new Date(),
updated_at: new Date(),
published_at: null,
...row,
}
rows.push(full)
return full
}
beforeEach(() => {
rows = []
nextId = 1
for (const k of ['listSummaries', 'findById', 'findBySlug', 'insert', 'update', 'remove']) saved[k] = pagesDb[k]
pagesDb.listSummaries = async () => rows.slice()
pagesDb.findById = async (id) => rows.find((r) => r.id === id) || null
pagesDb.findBySlug = async (slug) => rows.find((r) => r.slug === slug) || null
pagesDb.insert = async (row) => {
if (rows.some((r) => r.slug === row.slug)) {
const err = new Error('dup')
err.code = 'ER_DUP_ENTRY'
throw err
}
const id = nextId++
rows.push({ id, created_at: new Date(), updated_at: new Date(), published_at: null, ...row })
return id
}
pagesDb.update = async (id, fields) => {
const row = rows.find((r) => r.id === id)
if (row) Object.assign(row, fields)
}
pagesDb.remove = async (id) => {
const i = rows.findIndex((r) => r.id === id)
if (i >= 0) rows.splice(i, 1)
}
})
afterEach(() => {
for (const k of Object.keys(saved)) pagesDb[k] = saved[k]
})
// ── create: slug rules ─────────────────────────────────────────────────
test('create rejects a slug with illegal characters', async () => {
await assert.rejects(
() => pages.create({ slug: 'Not A Slug', title: 'T' }, 1),
(e) => e.code === 'invalid_slug' && e.status === 400,
)
})
test('create rejects a reserved slug that would shadow a named route', async () => {
await assert.rejects(
() => pages.create({ slug: 'admin', title: 'T' }, 1),
(e) => e.code === 'reserved_slug' && e.status === 400,
)
})
test('create requires a non-empty title within the length limit', async () => {
await assert.rejects(() => pages.create({ slug: 'ok', title: ' ' }, 1), (e) => e.code === 'invalid_title')
await assert.rejects(() => pages.create({ slug: 'ok', title: 'x'.repeat(201) }, 1), (e) => e.code === 'invalid_title')
})
test('create trims the title and defaults status to draft (no published_at)', async () => {
const page = await pages.create({ slug: 'welcome', title: ' Welcome ' }, 7)
assert.equal(page.title, 'Welcome')
assert.equal(page.status, 'draft')
assert.equal(page.publishedAt, null)
assert.equal(page.authorId, 7)
})
test('creating with status=published stamps published_at', async () => {
const page = await pages.create({ slug: 'live', title: 'Live', status: 'published' }, 1)
assert.equal(page.status, 'published')
assert.ok(page.publishedAt instanceof Date)
})
test('a duplicate slug surfaces as a 409 slug_taken, not a raw DB error', async () => {
await pages.create({ slug: 'dup', title: 'First' }, 1)
await assert.rejects(
() => pages.create({ slug: 'dup', title: 'Second' }, 1),
(e) => e.code === 'slug_taken' && e.status === 409,
)
})
// ── create: block gate ──────────────────────────────────────────────────
test('create rejects invalid blocks (the authoritative validation gate)', async () => {
await assert.rejects(
() => pages.create({ slug: 'bad', title: 'T', blocks: [{ type: 'does-not-exist' }] }, 1),
(e) => e.code === 'invalid_blocks' && Array.isArray(e.errors) && e.errors.length > 0,
)
})
// ── update: slug immutability ───────────────────────────────────────────
test('update rejects changing the slug after creation', async () => {
const p = seed({ slug: 'fixed' })
await assert.rejects(
() => pages.update(p.id, { slug: 'renamed' }),
(e) => e.code === 'slug_immutable' && e.status === 400,
)
})
test('update tolerates the same slug being echoed back (no-op, not a rejection)', async () => {
const p = seed({ slug: 'same' })
const out = await pages.update(p.id, { slug: 'same', title: 'Updated' })
assert.equal(out.title, 'Updated')
})
test('update on a missing page is a 404', async () => {
await assert.rejects(() => pages.update(999, { title: 'x' }), (e) => e.code === 'not_found' && e.status === 404)
})
// ── update: publish stamping is once-only ───────────────────────────────
test('publishing stamps published_at once and does not re-stamp on a later edit', async () => {
const p = seed({ slug: 'draft-first' })
const published = await pages.update(p.id, { status: 'published' })
const firstStamp = published.publishedAt
assert.ok(firstStamp instanceof Date)
// A later edit that keeps it published must not move published_at.
await pages.update(p.id, { title: 'Edited' })
const again = await pages.getById(p.id)
assert.deepEqual(again.publishedAt, firstStamp)
})
// ── the protected asymmetry (a security boundary) ───────────────────────
test('update can turn protection ON', async () => {
const p = seed({ slug: 'guard', protected: 0 })
const out = await pages.update(p.id, { settings: { protected: true } })
assert.equal(out.settings.protected, true)
})
test('update CANNOT turn protection OFF — that requires the unprotect endpoint', async () => {
const p = seed({ slug: 'guarded', protected: 1 })
await assert.rejects(
() => pages.update(p.id, { settings: { protected: false } }),
(e) => e.code === 'unprotect_required' && e.status === 403,
)
})
test('setting protected=false on an already-unprotected page is a harmless no-op', async () => {
const p = seed({ slug: 'open', protected: 0 })
const out = await pages.update(p.id, { settings: { protected: false } })
assert.equal(out.settings.protected, false)
})
test('unprotect() is the only path that clears protection', async () => {
const p = seed({ slug: 'locked', protected: 1 })
const out = await pages.unprotect(p.id)
assert.equal(out.settings.protected, false)
})
// ── delete guard ────────────────────────────────────────────────────────
test('a protected page cannot be deleted', async () => {
const p = seed({ slug: 'keep', protected: 1 })
await assert.rejects(() => pages.remove(p.id), (e) => e.code === 'page_protected' && e.status === 403)
assert.ok(rows.find((r) => r.id === p.id), 'row still present')
})
test('an unprotected page deletes', async () => {
const p = seed({ slug: 'trash', protected: 0 })
const out = await pages.remove(p.id)
assert.equal(out.id, p.id)
assert.equal(rows.find((r) => r.id === p.id), undefined)
})
// ── public read hides drafts ────────────────────────────────────────────
test('getBySlug hides a draft from the public but an admin can include it', async () => {
seed({ slug: 'hidden', status: 'draft' })
assert.equal(await pages.getBySlug('hidden'), null) // public: indistinguishable from missing
const asAdmin = await pages.getBySlug('hidden', { includeUnpublished: true })
assert.equal(asAdmin.slug, 'hidden')
})
test('getBySlug returns a published page to the public', async () => {
seed({ slug: 'shown', status: 'published' })
const out = await pages.getBySlug('shown')
assert.equal(out.slug, 'shown')
})
// ── field-mapping validation ────────────────────────────────────────────
test('update rejects an unknown layout and an out-of-range metadata string', async () => {
const p = seed({ slug: 'meta' })
await assert.rejects(() => pages.update(p.id, { settings: { layout: 'fancy' } }), (e) => e.code === 'invalid_settings')
await assert.rejects(
() => pages.update(p.id, { metadata: { seoTitle: 'x'.repeat(201) } }),
(e) => e.code === 'invalid_metadata',
)
})
test('serialize maps DB columns to the grouped API shape and coerces flags to booleans', async () => {
const p = seed({ slug: 'shape', show_in_nav: 1, protected: 1, nav_group: 'main', nav_order: 3 })
const out = await pages.getById(p.id)
assert.equal(out.settings.showInNav, true)
assert.equal(out.settings.protected, true)
assert.equal(out.settings.navGroup, 'main')
assert.equal(out.settings.navOrder, 3)
assert.equal(out.metadata.seoTitle, null)
})