Files
website/server/test/notificationChannelPrefs.test.js
wtclaude 24a3cd85b3
All checks were successful
PR Checks / client-build (pull_request) Successful in 37s
PR Checks / server-tests (pull_request) Successful in 3m27s
PR Checks / bot-tests (pull_request) Successful in 8m36s
feat(engagement): the in-app channel, core and web (engagement Phase 7)
ENGAGEMENT.md Phase 7. `user_notifications`, the in-app DeliveryChannel, the
four inbox routes, and the web surface — plus the two pieces earlier phases
assigned here that Phase 7's own acceptance line omits.

Four decisions settled by the org lead before any code:

1. `inapp` defaults to `instant` — the only channel that does. Push wakes a
   device somebody is holding and email leaves the building, so both are asked
   for; an inbox item is a row on a page the user chose to open. Left `off` the
   channel ships dead.
2. The phase takes push's `deliver` (§2603) and the web per-channel preferences
   screen (Phase 3's as-built), neither of which its own bullets mention.
3. The inbox takes `/auth/me/notifications` and `/account/notifications`; the
   preferences screen moves to `…/settings`. The plain word belongs to the
   content, which is what the bell opens.
4. `ctx.inbox.push` honours the user's in-app preference when `triggerId` names
   a registered trigger, and writes when it does not.

Server
- `user_notifications` + `model/userNotifications/`. The dedupe UNIQUE is scoped
  to the USER, narrower than the outbox's `(rule, user, channel)`: an inbox has
  no channel dimension, so two rows for one event would be one item shown twice.
- `engagement/inappChannel.js` — renders by block ROLE (first heading → title,
  first button → url, the rest → body) and inserts. `pushChannel.js` — a
  content-free `{stream, ref}` tickle whose ref deep-links the inbox row.
- `engine.liveChannels` orders `inapp` first (`CHANNEL_ORDER`) so that ref
  resolves on the first sweep. An ordering, not a dependency.
- `templates.renderInappByKey` + `resolveTemplate` extracted from `renderByKey`,
  so both channels take the same fallback chain.
- `inapp.event` seed → seedVersion 2: it named `body`/`url`, which nothing
  supplies. Renamed to the structural vocabulary the projection fills in.
- `utils/userNotificationsPrune.js` — nightly, READ items only, horizon in
  `settings.user_notifications_retain_days` (default 90).
- `GET /auth/me/notifications`, `…/unread-count`, `POST …/:id/read`,
  `POST …/read-all`. Swagger + route manifest + four component schemas.

Web
- `NotificationBell` in all three headers, polling its badge once a minute and
  pausing while the tab is hidden. `PlayerInbox` at `/account/notifications`.
- The preferences screen becomes a channel matrix over
  `/auth/me/notifications/channels` — a strict superset of the push-only stream
  list it replaces. The two legacy endpoints are untouched, so the shipped
  Android app keeps its wire shape.
- Staff get the same two screens at `/admin/notifications…`: `RequirePlayer`
  keeps them out of `/account`, so without this the inbox was unreachable for
  every non-player account. `lib/notificationPaths.js` is the one mapping.

Verified: 28 new server tests (5 of them against a real MariaDB, for the three
index/statement properties that are a server contract rather than a reading of
this code) + 3 client. Server suite green, client 327 green. A live rig walked
the whole path: two rules on one event produced three outbox rows and exactly
one inbox item, the tickle carried `ref: notification:2`, and the retention
sweep dropped an aged read row while keeping an equally aged unread one.

Docs: RunicGateway/docs#TBD, RunicGateway/runicgateway.com#TBD

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

376 lines
16 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' }
// ── 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 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)
})