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