// ── 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 " " -> mode let subRows // Set " " 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) })