// 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) })