Files
website/server/test/navOverrides.test.js
wtclaude 32a3ff104a feat(theming): wire the three navs and add the admin nav builder
Phases 6-8 of docs/website/THEMING_AND_NAV.md. The public header, the admin
sidebar and the player portal now read their override row, and /admin/navigation
writes them: rename, reorder by drag, hide, and — on the admin sidebar — move a
row into another existing section.

The merge always runs BEFORE the role and shard-feature filters in the layouts,
which are unchanged and remain the boundary. An override is presentation: it
cannot introduce a route, cannot touch a `roles` or `feature` gate, and a stored
`hidden: false` on a gated item shows nobody anything.

The design scoped these phases as client work, but the server had no way to
store a nav row: updateSettings validates and stringifies theme_visual and
brand_assets and lets everything else through, so a nav object would have been
written as "[object Object]" and read as absent for ever. utils/navOverrides.js
mirrors utils/brandAssets.js — strict on write with the offending key named,
forgiving on read. It validates shape only; whether a `to` exists is settled
client-side at merge time, because the base NAV arrays are client constants and
a server-side copy would be a second source of truth that drifts.

The nav editor cannot be hidden — its own toggle is disabled, the write path
drops `hidden` on that one `to`, and AdminLayout strips it again before merging,
which also covers a row edited straight in the database.

Orders are written only when the sequence actually differs from the code's, and
the comparison is restricted to the rows the editing admin can see, so renaming
one item does not pin the position of every other one and a role- or
feature-gated item missing from their palette is not mistaken for a reorder.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-08 00:02:33 -05:00

172 lines
7.4 KiB
JavaScript

// Point the DB at a closed port before anything builds the pool — this file only
// exercises pure functions, but requiring the util pulls in nothing else.
process.env.DB_HOST = '127.0.0.1'
process.env.DB_PORT = '59999'
const { test } = require('node:test')
const assert = require('node:assert/strict')
// Phases 6-8 of docs/website/THEMING_AND_NAV.md: the server half of the nav
// overrides. The merge itself is the client's (client/src/lib/navOverrides.js,
// tested there); this module decides only what may be *stored*, and the two
// properties worth locking are the asymmetry — strict on write, forgiving on
// read — and the two things a stored row may never carry: a field that is not
// one of the four, and `hidden` on the nav editor's own row.
const { validateNavOverrides, resolveNavOverrides, NAV_KEYS } = require('../src/utils/navOverrides')
// ── validateNavOverrides — strict, and names what it rejected ──────────
test('null and undefined are valid — that is how every override is cleared', () => {
assert.equal(validateNavOverrides(null).ok, true)
assert.equal(validateNavOverrides(undefined).ok, true)
})
test('an empty object is valid — the caller deletes the row instead of storing it', () => {
assert.equal(validateNavOverrides({}).ok, true)
})
test('a non-object is rejected under the key it was written to', () => {
for (const bad of [4, 'x', true, [], [{ to: '/' }]]) {
const check = validateNavOverrides(bad, 'nav_public')
assert.equal(check.ok, false, `${JSON.stringify(bad)} should be rejected`)
assert.match(check.message, /nav_public must be a JSON object/)
}
})
test('a key that is not an app path is rejected and named', () => {
for (const bad of [
'site/news', // relative
'//evil.example/x', // protocol-relative: looks like a path, leaves the origin
'https://evil.example', // scheme
'/site/ news', // whitespace
'/site/"news"', // quotes
'',
]) {
const check = validateNavOverrides({ [bad]: { order: 1 } }, 'nav_public')
assert.equal(check.ok, false, `'${bad}' should be rejected as a key`)
assert.match(check.message, /must be an app path/)
}
})
test('a valid app path is accepted as a key even when no nav declares it', () => {
// Membership is the client's question: applyNavOverrides drops an unknown `to`
// at merge time, so a route deleted in code needs no migration here.
assert.equal(validateNavOverrides({ '/site/gone': { order: 3 } }).ok, true)
})
test('an entry that is not an object is rejected', () => {
for (const bad of ['x', 4, null, []]) {
const check = validateNavOverrides({ '/site/news': bad }, 'nav_public')
assert.equal(check.ok, false, `${JSON.stringify(bad)} should be rejected as an entry`)
assert.match(check.message, /must be an object/)
}
})
test('an unknown field is rejected rather than silently ignored', () => {
// Storing a value that will never apply is a bad admin experience, and a
// field nobody validates is where a future `to`/`roles` would try to sneak in.
for (const field of ['to', 'roles', 'feature', 'icon', 'end', 'href']) {
const check = validateNavOverrides({ '/site/news': { [field]: 'x' } }, 'nav_public')
assert.equal(check.ok, false, `'${field}' should be rejected`)
assert.match(check.message, new RegExp(`Unknown nav field '${field}'`))
}
})
test('each of the four fields is type-checked and named on failure', () => {
const cases = [
[{ label: 4 }, /label must be text/],
[{ label: 'x'.repeat(65) }, /label must be text/],
[{ group: 4 }, /group must be text/],
[{ group: 'x'.repeat(65) }, /group must be text/],
[{ order: '1' }, /order must be a number/],
[{ order: Number.NaN }, /order must be a number/],
[{ order: Number.POSITIVE_INFINITY }, /order must be a number/],
[{ hidden: 'true' }, /hidden must be true or false/],
[{ hidden: 1 }, /hidden must be true or false/],
]
for (const [entry, pattern] of cases) {
const check = validateNavOverrides({ '/site/news': entry }, 'nav_public')
assert.equal(check.ok, false, `${JSON.stringify(entry)} should be rejected`)
assert.match(check.message, pattern)
assert.match(check.message, /\/site\/news/)
}
})
test('all four fields together are accepted', () => {
const check = validateNavOverrides({
'/admin/houses': { label: 'Houses', order: 2, hidden: true, group: 'Moderation' },
})
assert.equal(check.ok, true)
})
test('an absurd number of entries is refused', () => {
const many = {}
for (let i = 0; i < 201; i += 1) many[`/site/p${i}`] = { order: i }
const check = validateNavOverrides(many, 'nav_public')
assert.equal(check.ok, false)
assert.match(check.message, /at most 200 entries/)
})
// ── resolveNavOverrides — forgiving, and drops what would do nothing ───
test('unusable entries are dropped and their neighbours kept', () => {
const out = resolveNavOverrides({
'/site/news': { label: 'Announcements' },
'not-a-path': { label: 'Ignored' },
'/site/wiki': 'garbage',
'/site/market': { hidden: true },
})
assert.deepEqual(out, {
'/site/news': { label: 'Announcements' },
'/site/market': { hidden: true },
})
})
test('a label is trimmed, and a whitespace-only label falls back to the coded one', () => {
assert.deepEqual(resolveNavOverrides({ '/x': { label: ' News ' } }), { '/x': { label: 'News' } })
assert.deepEqual(resolveNavOverrides({ '/x': { label: ' ' } }), {})
})
test('hidden: false is never stored — hiding is subtractive only', () => {
// A stored `false` could read to a later consumer as "force visible", and
// nothing in this layer may un-hide a role- or feature-gated item (§7).
assert.deepEqual(resolveNavOverrides({ '/x': { hidden: false } }), {})
assert.deepEqual(resolveNavOverrides({ '/x': { hidden: false, order: 2 } }), { '/x': { order: 2 } })
})
test('the nav editor cannot be hidden, even by a hand-written row', () => {
// Hiding /admin/navigation would remove the only screen that can un-hide it.
const out = resolveNavOverrides({ '/admin/navigation': { hidden: true, order: 9 } }, 'nav_admin')
assert.deepEqual(out, { '/admin/navigation': { order: 9 } })
// An entry that carried nothing else disappears entirely rather than storing
// an empty object.
assert.deepEqual(resolveNavOverrides({ '/admin/navigation': { hidden: true } }, 'nav_admin'), {})
})
test('the un-hideable rule is scoped to the admin nav', () => {
// The same path in another row is meaningless, but it is also not special:
// the rule protects the admin sidebar, which is the nav that renders it.
assert.deepEqual(resolveNavOverrides({ '/admin/navigation': { hidden: true } }, 'nav_public'), {
'/admin/navigation': { hidden: true },
})
})
test('an entry left with no usable field is dropped, so `{}` is never stored', () => {
assert.deepEqual(resolveNavOverrides({ '/x': {}, '/y': { label: 4 } }), {})
})
test('order survives as a number, including zero and negatives', () => {
const out = resolveNavOverrides({ '/a': { order: 0 }, '/b': { order: -3 }, '/c': { order: 1.5 } })
assert.deepEqual(out, { '/a': { order: 0 }, '/b': { order: -3 }, '/c': { order: 1.5 } })
})
test('a non-object resolves to {} rather than throwing', () => {
for (const bad of [null, undefined, 'x', 4, []]) {
assert.deepEqual(resolveNavOverrides(bad), {})
}
})
test('NAV_KEYS names the three rows the controller validates', () => {
assert.deepEqual(NAV_KEYS, ['nav_public', 'nav_admin', 'nav_player'])
})