Files
website/server/test/publicBrand.test.js
wtclaude 3d6b2e23a7 feat(theming): server-resolved theme engine and admin appearance UI
Phases 3-4 of docs/website/THEMING_AND_NAV.md. Three presets, the curated font
shortlist, and /admin/appearance to drive them.

The design put the presets in theme.css as [data-theme] blocks. That does not
work: SiteContext writes --accent as an inline style on <html>, which beats any
attribute-selector block, so a preset's accent would have been painted over by
BRAND_ACCENT_COLOR while getPublic().brand.accent -- the value the Android app
themes itself from -- reported the other one.

Presets now live in server/src/config/themePresets.js. themeResolve.js layers
:root <- preset <- custom per field into a token map, getPublic() returns it as
`theme`, and the client writes it onto <html>. One authority for the merge, and
brand.accent is by construction the accent the site paints. theme.css's :root is
untouched, so an instance with no row gets no theme block and renders as today.

Also: presets carry the full 15-token palette (eight would have left Fantasy
with blue-grey borders); the option catalog is served from
GET /settings/theme/options so the form cannot offer what the server rejects;
validation is strict on write and forgiving on read; and the Discord bot now
fetches the effective accent instead of its boot-time env copy.

Fixes a Phase 0 bug in passing: settings/nav.controller.js imported the logger
factory rather than calling it, so a DB fault would have thrown a TypeError
inside the catch instead of returning 500.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-07 19:16:23 -05:00

179 lines
7.8 KiB
JavaScript

// Point the DB at a closed port before the pool is built; getPublic() is fully
// monkeypatched below so no query runs, and db.close() releases the pool at the
// end so the process exits 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')
// Lock the /public/settings brand contract the mobile app themes itself from
// (§8.6 of the Android plan). Exercises settings.getPublic() against an in-memory
// fake by monkeypatching settings.db — no DB. The brand block is sourced from the
// BRAND_* config defaults, with admin site_title / contact_email overriding.
const settingsDb = require('../src/model/settings/settings.db')
const settings = require('../src/model/settings/settings.model')
const brand = require('../src/config/brand')
const db = require('../src/utils/db')
after(() => db.close())
let savedGetAll
beforeEach(() => {
savedGetAll = settingsDb.getAll
settingsDb.getAll = async () => [] // no stored settings → pure BRAND_* defaults
})
afterEach(() => {
settingsDb.getAll = savedGetAll
})
const THEMING_FIELDS = ['name', 'shortName', 'tagline', 'description', 'contactEmail', 'url', 'accent', 'logo', 'hero', 'favicon']
test('getPublic exposes the full brand theming contract the app depends on', async () => {
const pub = await settings.getPublic()
assert.ok(pub.brand, 'brand block present')
for (const key of THEMING_FIELDS) {
assert.ok(key in pub.brand, `brand.${key} present`)
}
// Defaults flow from BRAND_* config when nothing is stored.
assert.equal(pub.brand.name, brand.name)
assert.equal(pub.brand.accent, brand.accent)
assert.equal(pub.brand.logo, brand.logo)
assert.equal(pub.brand.hero, brand.hero)
assert.equal(pub.brand.favicon, brand.favicon)
// Never leak the Discord-only integer accent form to a public client.
assert.equal(pub.brand.accentInt, undefined)
})
test('admin site_title / contact_email override the brand defaults', async () => {
settingsDb.getAll = async () => [
{ key: 'site_title', value: 'My Shard' },
{ key: 'contact_email', value: 'hi@shard.tld' },
]
const pub = await settings.getPublic()
assert.equal(pub.brand.name, 'My Shard') // site_title overrides brand.name
assert.equal(pub.brand.contactEmail, 'hi@shard.tld') // contact_email overrides
assert.equal(pub.brand.accent, brand.accent) // colors still from config
})
// ── Effective theming (THEMING_AND_NAV.md §4.5) ───────────────────────
//
// brand.accent and the asset fields are a cross-repo contract: the Android app
// themes its whole Material palette from brand.accent and the Discord bot
// colors its embeds from it. Resolving the EFFECTIVE value here is what lets
// both track admin theming with no client change — so these tests are really
// about the app and the bot, not about the website.
test('no theme row leaves the brand block exactly as env defines it', async () => {
// Explicitly re-asserted next to the theming cases: this is the acceptance
// criterion the whole feature rests on, and it is the assertion a future
// change to the resolver would break first.
const pub = await settings.getPublic()
assert.equal(pub.theme, undefined, 'no theme block at all when untouched')
assert.equal(pub.brand.accent, brand.accent)
assert.equal(pub.brand.logo, brand.logo)
assert.equal(pub.brand.hero, brand.hero)
assert.equal(pub.brand.favicon, brand.favicon)
})
test('a theme preset overrides brand.accent and ships the token block', async () => {
settingsDb.getAll = async () => [{ key: 'theme_visual', value: JSON.stringify({ preset: 'fantasy' }) }]
const pub = await settings.getPublic()
assert.equal(pub.brand.accent, '#c9973f', 'the app sees the themed accent, not BRAND_ACCENT_COLOR')
assert.equal(pub.theme['--accent'], '#c9973f')
assert.equal(pub.theme['--bg'], '#1a120b')
// Assets are a different key and must not move with the theme.
assert.equal(pub.brand.logo, brand.logo)
assert.equal(pub.brand.hero, brand.hero)
})
test('a custom accent beats the preset accent in brand.accent', async () => {
settingsDb.getAll = async () => [
{ key: 'theme_visual', value: JSON.stringify({ preset: 'fantasy', custom: { colors: { accent: '#123456' } } }) },
]
const pub = await settings.getPublic()
assert.equal(pub.brand.accent, '#123456')
assert.equal(pub.theme['--accent'], '#123456')
})
test('a malformed theme row reads as absent, not as an error', async () => {
for (const value of ['{oops', '"x"', '{"preset":"parchment"}']) {
settingsDb.getAll = async () => [{ key: 'theme_visual', value }]
const pub = await settings.getPublic()
assert.equal(pub.theme, undefined, value)
assert.equal(pub.brand.accent, brand.accent, value)
}
})
test('brand_assets overrides one asset without disturbing the others', async () => {
settingsDb.getAll = async () => [
{ key: 'brand_assets', value: JSON.stringify({ favicon: '/uploads/1234-abcd.png' }) },
]
const pub = await settings.getPublic()
assert.equal(pub.brand.favicon, '/uploads/1234-abcd.png')
assert.equal(pub.brand.logo, brand.logo, 'logo still from env')
assert.equal(pub.brand.hero, brand.hero, 'hero still from env')
})
test('a malformed brand_assets row falls back to env for every asset', async () => {
settingsDb.getAll = async () => [{ key: 'brand_assets', value: 'not json' }]
const pub = await settings.getPublic()
assert.equal(pub.brand.logo, brand.logo)
assert.equal(pub.brand.hero, brand.hero)
assert.equal(pub.brand.favicon, brand.favicon)
})
test('the Discord-only integer accent is still never exposed, themed or not', async () => {
settingsDb.getAll = async () => [{ key: 'theme_visual', value: JSON.stringify({ preset: 'modern' }) }]
const pub = await settings.getPublic()
assert.equal(pub.brand.accentInt, undefined)
})
// The push relay block the app's embedded distributor discovers its ntfy base
// URL from (M7 Part 2). Null when nothing is configured; NTFY_PUBLIC_URL wins,
// else the first NTFY_ALLOWED_ORIGINS entry; NTFY_BASE_URL is never surfaced.
test('push.ntfyUrl is null when no ntfy env is configured', async () => {
const saved = { pub: process.env.NTFY_PUBLIC_URL, allow: process.env.NTFY_ALLOWED_ORIGINS, base: process.env.NTFY_BASE_URL }
delete process.env.NTFY_PUBLIC_URL
delete process.env.NTFY_ALLOWED_ORIGINS
process.env.NTFY_BASE_URL = 'http://ntfy:80' // internal-only, must NOT leak
try {
const pub = await settings.getPublic()
assert.ok(pub.push, 'push block present')
assert.equal(pub.push.ntfyUrl, null)
} finally {
restoreNtfyEnv(saved)
}
})
test('push.ntfyUrl prefers NTFY_PUBLIC_URL and trims a trailing slash', async () => {
const saved = { pub: process.env.NTFY_PUBLIC_URL, allow: process.env.NTFY_ALLOWED_ORIGINS, base: process.env.NTFY_BASE_URL }
process.env.NTFY_PUBLIC_URL = 'https://ntfy.shard.tld/'
process.env.NTFY_ALLOWED_ORIGINS = 'https://other.tld'
try {
const pub = await settings.getPublic()
assert.equal(pub.push.ntfyUrl, 'https://ntfy.shard.tld')
} finally {
restoreNtfyEnv(saved)
}
})
test('push.ntfyUrl falls back to the first NTFY_ALLOWED_ORIGINS entry', async () => {
const saved = { pub: process.env.NTFY_PUBLIC_URL, allow: process.env.NTFY_ALLOWED_ORIGINS, base: process.env.NTFY_BASE_URL }
delete process.env.NTFY_PUBLIC_URL
process.env.NTFY_ALLOWED_ORIGINS = 'https://ntfy.shard.tld, https://second.tld'
try {
const pub = await settings.getPublic()
assert.equal(pub.push.ntfyUrl, 'https://ntfy.shard.tld')
} finally {
restoreNtfyEnv(saved)
}
})
function restoreNtfyEnv(saved) {
for (const [name, val] of [['NTFY_PUBLIC_URL', saved.pub], ['NTFY_ALLOWED_ORIGINS', saved.allow], ['NTFY_BASE_URL', saved.base]]) {
if (val === undefined) delete process.env[name]
else process.env[name] = val
}
}