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>
243 lines
9.9 KiB
JavaScript
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)
|
|
})
|