Files
website/server/test/notificationChannelPrefs.test.js
wtclaude 1d4cd4adae
All checks were successful
PR Checks / client-build (pull_request) Successful in 26s
PR Checks / bot-tests (pull_request) Successful in 28s
PR Checks / server-tests (pull_request) Successful in 13m3s
feat(engagement): the admin ceiling and core's news.post emitter (Phase 11a)
Core's half of ENGAGEMENT.md Phase 11a: the two decisions the org lead settled
before any code that land in core rather than in module-uo. Pairs with
Module-uo#22 and docs#194.

## Decision 1 -- a seventh ceiling, `admin`, as a child of `staff`

Phase 11's operator-facing triggers (uo.audit.staff_action, uo.economy.milestone,
uo.world.saved) are described as admin-audience everywhere, and the narrowest
value the lattice had was `staff` -- which ceilings.js defines as admin, editor
AND moderator. Ceilinging them there would have let an operator save a rule that
mails the staff audit digest to every moderator in it.

`admin` is the ONLY genuine refinement in the tree -- every admin is staff, which
is exactly the containment every other pair of branches lacks -- so it is a child
rather than a seventh leaf, and permits/meet/meetAll needed no change beyond the
new PARENT entry.

**The one non-obvious consequence, and the reason for ROLE_CEILINGS.**
notificationChannelPrefs' `visibleTo` asked `item.ceiling !== 'staff'`. That was
correct while `staff` was the only role-gated value, and the day `admin` arrived
it would have silently published every admin-ceilinged id -- the staff audit
digest, the economy thresholds -- to every player's preferences screen by name.
It now reads a TABLE (`ceilings.reachableBy`), so a ceiling added without an entry
fails closed instead. An EDITOR is the viewer that tells the two rules apart, and
the new tests use one.

MODULE_API_VERSION -> 1.8.0 on both halves. Additive: every declaration valid
under 1.7.0 is valid now and no stored value changes.

## Decision 5 -- 7.1 Q9: news.post gets an emitter, and it REPLACES the tickle

`news.post` has been a declared payload contract with no caller since Phase 2, so
a rule naming it could never fire. utils/newsNotify.js is the caller;
announceIfNewlyPublished now calls it instead of pushDispatch.publish, gated on
the same enqueueIfNeeded job id -- the single "newly published news" transition
signal, not re-derived.

**News push therefore stops on upgrade** until an operator enables the seeded
rule. That is the org lead's decision, taken over keeping the raw call beside the
emit "for one release": an exception with a deadline nobody owns, which Phase 6
already refused for Teams. The Rules screen gains a second migration notice
naming news, and Phase 13's release note carries it as an upgrade step.

**The seed needed its own one-shot key, and this is the trap worth recording.**
`engagement_team_rules_seeded` is already stamped on every deployment that has
booted since Phase 6, and the guard reads its presence -- so appending news to
RULES would have seeded it on fresh installs only, and on exactly the upgrades
that lose their raw push, never. One key per seed GROUP is now the rule;
seedGroup() is the shared implementation and seedCoreRules() is what boot calls.

Also fixes news.post's `postUrl` example, which named `/news/<slug>` -- a path
App.jsx does not mount. An example is what the template editor previews and
test-sends with, so a wrong one is a preview that looks right and a mail that is
not. It is `/site/news`, the list, which is what the Discord and town-crier
announcements have always linked.

1550 tests pass (16 new), 327 client tests pass, client builds, check:modules
clean -- core still names no module identifier with module-uo now registering 24
UO-named triggers.

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

433 lines
18 KiB
JavaScript

// ── Per-channel notification preferences (ENGAGEMENT.md Phase 3) ───────────
//
// The phase's acceptance criteria, one test apiece:
//
// • the shipped Android app's flat `{streams:[…]}` PUT still round-trips,
// INCLUDING the empty-array case its DTO comment warns about
// • a per-channel PUT sets `email` without touching `push`
// • a fresh user's email mode defaults `off`, and so does push
//
// …plus the two properties that make the projection safe to ship: the legacy
// wire shape is pinned BYTE-FOR-BYTE (the app cannot be changed from this side),
// and the invariant the two endpoints jointly maintain — a push pref with mode
// <> 'off' iff a `notification_subscriptions` row — is asserted from both
// directions rather than only from the one the code happens to take.
//
// Point the DB at a closed port before requiring anything: the registries reach
// utils/discordAnnounce, which builds the pool at require time.
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 registries = require('../src/modules/registries')
const channels = require('../src/engagement/channels')
const prefs = require('../src/model/notificationChannelPrefs/notificationChannelPrefs.model')
const prefsDb = require('../src/model/notificationChannelPrefs/notificationChannelPrefs.db')
const subs = require('../src/model/notificationSubs/notificationSubs.model')
const subsDb = require('../src/model/notificationSubs/notificationSubs.db')
const notifCtrl = require('../src/router/v1/auth/notifications.controller')
const db = require('../src/utils/db')
after(() => db.close())
const USER = 7
const PLAYER = { id: USER, role: 'player' }
const ADMIN = { id: USER, role: 'admin' }
// Phase 11 added the `admin` ceiling beneath `staff`, so the interesting viewer
// is no longer "player vs staff" but the one INSIDE `staff` and outside `admin`.
const EDITOR = { id: USER, role: 'editor' }
// ── In-memory stand-ins for the two tables ─────────────────────────────────
//
// Both are stubbed at the `.db` layer, so the model's own fan-out logic — the
// part this phase actually adds — runs for real against them.
let prefRows // Map "<user> <id> <channel>" -> mode
let subRows // Set "<user> <id>"
function installStubs() {
prefRows = new Map()
subRows = new Set()
prefsDb.listByUser = async (userId) =>
[...prefRows.entries()]
.filter(([k]) => k.startsWith(`${userId} `))
.map(([k, mode]) => {
const [, streamId, channel] = k.split(' ')
return { stream_id: streamId, channel, mode }
})
.sort((a, b) => a.stream_id.localeCompare(b.stream_id) || a.channel.localeCompare(b.channel))
prefsDb.upsert = async (userId, streamId, channel, mode) => {
prefRows.set(`${userId} ${streamId} ${channel}`, mode)
}
prefsDb.offPushExcept = async (userId, keep) => {
for (const [k, mode] of prefRows.entries()) {
const [u, streamId, channel] = k.split(' ')
if (Number(u) !== userId || channel !== 'push' || mode === 'off') continue
if (!keep.includes(streamId)) prefRows.set(k, 'off')
}
}
subsDb.listByUser = async (userId) =>
[...subRows]
.filter((k) => k.startsWith(`${userId} `))
.map((k) => ({ stream_id: k.split(' ')[1] }))
.sort((a, b) => a.stream_id.localeCompare(b.stream_id))
subsDb.replaceForUser = async (userId, streams) => {
for (const k of [...subRows]) if (k.startsWith(`${userId} `)) subRows.delete(k)
for (const s of streams) subRows.add(`${userId} ${s}`)
}
subsDb.addForUser = async (userId, streamId) => subRows.add(`${userId} ${streamId}`)
subsDb.removeForUser = async (userId, streamId) => subRows.delete(`${userId} ${streamId}`)
}
// The channel registry is populated by requiring the subsystem's door, exactly
// as app.js does. Requiring `channels` alone gets the empty map — that is the
// design, and doing it the other way here would hide a boot-order regression.
function registerChannels() {
channels._reset()
delete require.cache[require.resolve('../src/engagement/coreChannels')]
// eslint-disable-next-line global-require
require('../src/engagement/coreChannels')
}
beforeEach(() => {
registries._reset()
registries.registerCore()
registerChannels()
installStubs()
})
afterEach(() => {
registries._reset()
channels._reset()
})
const mockRes = () => ({
statusCode: 200,
body: null,
status(c) { this.statusCode = c; return this },
json(b) { this.body = b; return this },
})
const item = (surface, id) => surface.items.find((i) => i.id === id)
// ── Acceptance: the shipped app's wire shape ───────────────────────────────
test('the legacy subscriptions PUT round-trips byte-for-byte', async () => {
const put = mockRes()
await notifCtrl.putSubscriptions({ user: PLAYER, body: { streams: ['news.post', 'team.forum.post'] } }, put)
// Byte-for-byte: the response is `{ streams: [...] }` and nothing else. The
// Android DTO is frozen, so an extra key is as much a break as a missing one.
assert.deepEqual(Object.keys(put.body), ['streams'])
assert.deepEqual(put.body.streams.slice().sort(), ['news.post', 'team.forum.post'])
const get = mockRes()
await notifCtrl.getSubscriptions({ user: PLAYER }, get)
assert.deepEqual(Object.keys(get.body), ['streams'])
assert.deepEqual(get.body.streams, ['news.post', 'team.forum.post'])
})
test('the empty-array case its DTO comment warns about still clears the set', async () => {
await notifCtrl.putSubscriptions({ user: PLAYER, body: { streams: ['news.post'] } }, mockRes())
const cleared = mockRes()
await notifCtrl.putSubscriptions({ user: PLAYER, body: { streams: [] } }, cleared)
assert.deepEqual(cleared.body, { streams: [] })
// And the projection cleared with it — the failure this test exists to catch
// is a channel-prefs row left at 'instant' after the app said "none", which
// would resurrect the subscription the next time anything read the new table.
const surface = await prefs.getForUser(USER, PLAYER)
assert.equal(item(surface, 'news.post').modes.push, 'off')
assert.equal(subRows.size, 0)
})
test('unknown stream ids are still dropped, and are not mirrored either', async () => {
const res = mockRes()
await notifCtrl.putSubscriptions({ user: PLAYER, body: { streams: ['news.post', 'no.such.stream'] } }, res)
assert.deepEqual(res.body, { streams: ['news.post'] })
const surface = await prefs.getForUser(USER, PLAYER)
assert.equal(item(surface, 'no.such.stream'), undefined)
assert.deepEqual([...subRows], [`${USER} news.post`])
})
// ── Acceptance: defaults ───────────────────────────────────────────────────
test("a fresh user's modes are the channel defaults — push and email off, in-app on", async () => {
const surface = await prefs.getForUser(USER, PLAYER)
const news = item(surface, 'news.post')
// **`inapp` is 'instant' from Phase 7**, and it is the only one that is.
// Settled by the org lead 2026-08-31: the argument for opt-IN was that push
// wakes a device somebody is holding and email leaves the building, and an
// inbox item does neither — it is a row on a page the user chose to open. Left
// 'off' the surface ships dead, because no rule could reach anybody until
// every user found a toggle for a channel they had never seen deliver
// anything.
assert.deepEqual(news.modes, { push: 'off', email: 'off', inapp: 'instant' })
assert.equal(prefRows.size, 0, 'reading preferences must not write rows')
// The acceptance line in ENGAGEMENT.md originally said push defaults
// 'instant'. It cannot: `notification_subscriptions` is opt-IN, so that would
// have projected the whole catalog into the legacy GET for every existing
// user and switched every toggle on in the shipped app. Settled 'off' by the
// org lead; this assertion is what stops it drifting back.
const legacy = mockRes()
await notifCtrl.getSubscriptions({ user: PLAYER }, legacy)
assert.deepEqual(legacy.body, { streams: [] })
})
test('a mode with no stored row reads as the channel default, not as a hardcoded off', async () => {
// Prove the default is READ from the registry rather than assumed: re-register
// `inapp` with a different default and the same fresh user reads it back.
channels._reset()
channels.registerDeliveryChannel({
id: 'inapp', label: 'On the site', carriesContent: true, defaultMode: 'instant', supportsDigest: false,
})
const surface = await prefs.getForUser(USER, PLAYER)
assert.equal(item(surface, 'news.post').modes.inapp, 'instant')
})
// ── Acceptance: the sparse per-channel PUT ─────────────────────────────────
test('a per-channel PUT sets email without touching push', async () => {
await notifCtrl.putSubscriptions({ user: PLAYER, body: { streams: ['news.post'] } }, mockRes())
const res = mockRes()
await notifCtrl.putChannelPrefs(
{ user: PLAYER, body: { prefs: [{ id: 'news.post', channel: 'email', mode: 'digest' }] } },
res,
)
const news = item(res.body, 'news.post')
assert.equal(news.modes.email, 'digest')
assert.equal(news.modes.push, 'instant', 'the push mode must survive an email-only write')
// …and the legacy endpoint agrees, which is the whole point of the projection.
const legacy = mockRes()
await notifCtrl.getSubscriptions({ user: PLAYER }, legacy)
assert.deepEqual(legacy.body, { streams: ['news.post'] })
})
test('a push write through the channels endpoint fans out to the old table', async () => {
await notifCtrl.putChannelPrefs(
{ user: PLAYER, body: { prefs: [{ id: 'team.forum.post', channel: 'push', mode: 'instant' }] } },
mockRes(),
)
assert.deepEqual([...subRows], [`${USER} team.forum.post`])
await notifCtrl.putChannelPrefs(
{ user: PLAYER, body: { prefs: [{ id: 'team.forum.post', channel: 'push', mode: 'off' }] } },
mockRes(),
)
assert.deepEqual([...subRows], [])
// 'off' is STORED, not deleted: it is a statement the user made, and folding it
// back into "never said" is only harmless while push's default happens to be
// off. The two are different the moment that default changes.
assert.equal(prefRows.get(`${USER} team.forum.post push`), 'off')
})
test('entries the catalog cannot accept are dropped, not refused', async () => {
const res = mockRes()
await notifCtrl.putChannelPrefs(
{
user: PLAYER,
body: {
prefs: [
{ id: 'no.such.id', channel: 'email', mode: 'instant' },
{ id: 'news.post', channel: 'carrier.pigeon', mode: 'instant' },
{ id: 'news.post', channel: 'push', mode: 'digest' }, // push has no digest
{ id: 'news.post', channel: 'email', mode: 'instant' }, // the one good row
],
},
},
res,
)
assert.equal(res.statusCode, 200)
assert.equal(item(res.body, 'news.post').modes.email, 'instant')
assert.equal(item(res.body, 'news.post').modes.push, 'off')
assert.equal(prefRows.size, 1, 'only the accepted pair was written')
})
test('the last entry wins when a body names the same pair twice', async () => {
const res = mockRes()
await notifCtrl.putChannelPrefs(
{
user: PLAYER,
body: {
prefs: [
{ id: 'news.post', channel: 'email', mode: 'instant' },
{ id: 'news.post', channel: 'email', mode: 'digest' },
],
},
},
res,
)
assert.equal(item(res.body, 'news.post').modes.email, 'digest')
})
// ── The catalog: one namespace, two facets ─────────────────────────────────
test('a trigger-only id gets email and in-app, and no push toggle', async () => {
const api = registries.stage('uo')
api.registerEventTriggers([{
id: 'uo.house.idoc_warning',
label: 'House approaching collapse',
ceiling: 'owner',
variables: [{ name: 'house', type: 'string', required: true, example: 'The Silver Anvil' }],
}])
registries.apply(api.staged)
const surface = await prefs.getForUser(USER, PLAYER)
const idoc = item(surface, 'uo.house.idoc_warning')
assert.ok(idoc, 'a trigger-only id is subscribable')
assert.deepEqual(idoc.channels, ['email', 'inapp'])
assert.equal('push' in idoc.modes, false, 'there is nothing registered to push it')
// A push entry for it is therefore inapplicable and dropped.
await notifCtrl.putChannelPrefs(
{ user: PLAYER, body: { prefs: [{ id: 'uo.house.idoc_warning', channel: 'push', mode: 'instant' }] } },
mockRes(),
)
assert.equal(subRows.size, 0)
assert.equal(prefRows.size, 0)
})
test('an id that is both a stream and a trigger appears once, with all three channels', async () => {
// Core's five trigger ids ARE its five stream ids — the same-owner upgrade the
// one-namespace rule exists for, exercised on every boot.
const surface = await prefs.getForUser(USER, PLAYER)
const news = surface.items.filter((i) => i.id === 'news.post')
assert.equal(news.length, 1)
assert.deepEqual(news[0].channels, ['push', 'email', 'inapp'])
assert.equal(news[0].ceiling, 'authenticated', 'the trigger facet supplies the ceiling')
})
test('a staff-ceilinged trigger is not offered to a player, and is to staff', async () => {
const api = registries.stage('uo')
api.registerEventTriggers([{
id: 'uo.cheat.detected',
label: 'Cheat detected',
ceiling: 'staff',
variables: [{ name: 'character', type: 'string', required: true, example: 'Darrow' }],
}])
registries.apply(api.staged)
const asPlayer = await prefs.getForUser(USER, PLAYER)
assert.equal(item(asPlayer, 'uo.cheat.detected'), undefined, 'a player is not told it exists')
const asAdmin = await prefs.getForUser(USER, ADMIN)
assert.ok(item(asAdmin, 'uo.cheat.detected'))
// And the filter is a gate, not just a display rule: a player who knows the id
// still cannot store a preference for it.
const res = mockRes()
await notifCtrl.putChannelPrefs(
{ user: PLAYER, body: { prefs: [{ id: 'uo.cheat.detected', channel: 'email', mode: 'instant' }] } },
res,
)
assert.equal(prefRows.size, 0)
})
// **The Phase 11 ceiling, and the reason `visibleTo` stopped asking about `staff`
// by name.** Its rule used to be `ceiling !== 'staff'`, which was correct while
// `staff` was the only role-gated value and would have silently published every
// admin-ceilinged id to every player the day `admin` arrived. An editor is the
// viewer that tells the two apart: inside `staff`, outside `admin`.
test('an admin-ceilinged trigger is hidden from a player AND from an editor', async () => {
const api = registries.stage('uo')
api.registerEventTriggers([{
id: 'uo.audit.staff_action',
label: 'A staff member acted in game',
ceiling: 'admin',
audience: 'admin',
variables: [{ name: 'action', type: 'string', required: true, example: 'set' }],
}])
registries.apply(api.staged)
const asPlayer = await prefs.getForUser(USER, PLAYER)
assert.equal(item(asPlayer, 'uo.audit.staff_action'), undefined, 'a player is not told it exists')
// The one an `!== staff` test would have got wrong: an editor IS staff, and a
// digest of what staff did in game is not for them.
const asEditor = await prefs.getForUser(USER, EDITOR)
assert.equal(item(asEditor, 'uo.audit.staff_action'), undefined, 'an editor is not told either')
const asAdmin = await prefs.getForUser(USER, ADMIN)
assert.ok(item(asAdmin, 'uo.audit.staff_action'), 'an admin is')
// A gate, not a display rule: an editor who knows the id still cannot store a
// preference for it.
await notifCtrl.putChannelPrefs(
{ user: EDITOR, body: { prefs: [{ id: 'uo.audit.staff_action', channel: 'email', mode: 'instant' }] } },
mockRes(),
)
assert.equal(prefRows.size, 0)
})
// The counterpart, so the generalisation did not quietly hide more than it should.
test('a staff-ceilinged trigger is still offered to an editor', async () => {
const api = registries.stage('uo')
api.registerEventTriggers([{
id: 'uo.page.new',
label: 'A player opened a help page',
ceiling: 'staff',
audience: 'staff',
variables: [{ name: 'pageType', type: 'string', required: true, example: 'Stuck' }],
}])
registries.apply(api.staged)
const asEditor = await prefs.getForUser(USER, EDITOR)
assert.ok(item(asEditor, 'uo.page.new'), 'an editor is inside the staff ceiling')
assert.equal(item(await prefs.getForUser(USER, PLAYER), 'uo.page.new'), undefined)
})
// ── The channel registry itself ────────────────────────────────────────────
test('the registry refuses a channel that under-declares', async () => {
channels._reset()
const ok = { id: 'x', label: 'X', carriesContent: true, defaultMode: 'off', supportsDigest: false }
assert.throws(() => channels.registerDeliveryChannel({ ...ok, carriesContent: undefined }), /carriesContent/)
assert.throws(() => channels.registerDeliveryChannel({ ...ok, supportsDigest: undefined }), /supportsDigest/)
assert.throws(() => channels.registerDeliveryChannel({ ...ok, defaultMode: 'sometimes' }), /defaultMode/)
assert.throws(() => channels.registerDeliveryChannel({ ...ok, id: 'Not An Id' }), /invalid id/)
// A channel that cannot batch cannot default to batching — the failure this
// prevents is a stored 'digest' row no delivery path can ever honour.
assert.throws(
() => channels.registerDeliveryChannel({ ...ok, defaultMode: 'digest', supportsDigest: false }),
/supportsDigest/,
)
channels.registerDeliveryChannel(ok)
assert.throws(() => channels.registerDeliveryChannel(ok), /already registered/)
})
test('push is content-free and instant-only, by declaration', async () => {
assert.equal(channels.get('push').carriesContent, false)
assert.deepEqual(channels.modesFor('push'), ['off', 'instant'])
assert.deepEqual(channels.modesFor('email'), ['off', 'instant', 'digest'])
})
test('an unregistered channel reads as off and accepts nothing', async () => {
// A stored row can name a channel that is no longer registered (a downgrade).
// It must read as off, never throw and never be on by accident.
assert.equal(channels.defaultMode('discord.dm'), 'off')
assert.deepEqual(channels.modesFor('discord.dm'), [])
assert.equal(channels.acceptsMode('discord.dm', 'instant'), false)
})