// ── The in-app channel (ENGAGEMENT.md Phase 7) ───────────────────────────── // // The phase's five acceptance criteria, plus the things building it showed were // worth pinning: // // • one event delivered to `inapp` produces exactly one row // • a duplicate `dedupeKey` is a no-op // • mark-read is idempotent // • a user cannot read another user's row — asserted AT THE ROUTE, which is // what the acceptance line asks for, not only in the model // • `url` is relative-only, by the same character class `pageUrlTemplate` uses // // • the block→column role mapping, which is the whole of how a template with a // subject and a document becomes a row with three fields // • `ctx.inbox.push` honours a preference where one exists and writes where // none does // • `liveChannels` puts `inapp` before `push`, which is what makes the tickle's // deep-link ref resolve on the first pass // // 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 engine = require('../src/engagement/engine') const inappChannel = require('../src/engagement/inappChannel') const pushChannel = require('../src/engagement/pushChannel') const templates = require('../src/engagement/templates') const templateSeeds = require('../src/engagement/templateSeeds') const templatesDb = require('../src/model/engagement/engagementTemplates.db') const settings = require('../src/model/settings/settings.model') const inbox = require('../src/model/userNotifications/userNotifications.db') const recipients = require('../src/model/engagement/engagementRecipients.db') const rulesDb = require('../src/model/engagement/engagementRules.db') const pushDispatch = require('../src/utils/pushDispatch') const notifCtrl = require('../src/router/v1/auth/notifications.controller') const db = require('../src/utils/db') require('../src/engagement') registries.registerCore() after(() => db.close()) const saved = new Map() function patch(mod, name, fn) { if (!saved.has(mod)) saved.set(mod, new Map()) if (!saved.get(mod).has(name)) saved.get(mod).set(name, mod[name]) mod[name] = fn } function restore() { for (const [mod, names] of saved) for (const [name, fn] of names) mod[name] = fn saved.clear() } const TRIGGER = 'team.forum.post' let world beforeEach(() => { world = { rows: [], tickles: [], storedModes: new Map() } // A stand-in for `user_notifications`, keyed the way the UNIQUE index is. patch(inbox, 'insert', async (item) => { const clash = item.dedupeKey && world.rows.some((r) => r.userId === item.userId && r.dedupeKey === item.dedupeKey) if (clash) return { inserted: false, id: null } const row = { id: world.rows.length + 1, ...item } world.rows.push(row) return { inserted: true, id: row.id } }) patch(inbox, 'findByDedupe', async (userId, key) => { if (!key) return null const row = world.rows.find((r) => r.userId === Number(userId) && r.dedupeKey === key) return row ? { id: row.id, title: row.title } : null }) patch(recipients, 'filterActive', async (ids) => ids) patch(recipients, 'storedModes', async () => world.storedModes) patch(pushDispatch, 'publishToUsers', async (streamId, opts) => { world.tickles.push({ streamId, ...opts }) }) patch(rulesDb, 'getById', async () => ({ id: 1, trigger_id: TRIGGER, template_keys: { inapp: 'inapp.event' }, })) // **Stubbed at the MODULE BOUNDARY, not on `templates` itself**, and the // distinction cost four minutes a run to find: `renderInappByKey` calls its // own file-local `ambient()` and `resolveTemplate()`, so patching // `templates.ambient` replaces an export nothing in that path reads, every // call reaches the dead port, and each one waits out the driver's 30-second // connect timeout while still passing. These two are real cross-module calls, // so replacing them is what actually keeps the render off the database. // // A null row is also the path a fresh deployment takes: `resolveTemplate` // falls through to the shipped seed. patch(templatesDb, 'getByKey', async () => null) patch(settings, 'getInstanceName', async () => 'Test Shard') patch(settings, 'getShellBrand', async () => ({ logo: null, theme: null })) }) afterEach(restore) const outboxRow = (over = {}) => ({ id: 1, rule_id: 1, trigger_id: TRIGGER, user_id: 11, channel: 'inapp', subject_key: 'The Silver Hand', scope_key: 'team:1', dedupe_key: 'post:7', payload: { teamName: 'The Silver Hand', authorName: 'Ten', threadTitle: 'Raid', postUrl: '/g/1?thread=7' }, ...over, }) // ── The registration ─────────────────────────────────────────────────────── // The Phase 7 decision `coreChannels.js` deferred in as many words. Opt-OUT for // in-app alone: it wakes no device and leaves no building. test('inapp is the one channel that defaults to instant', () => { assert.equal(channels.defaultMode('inapp'), 'instant') assert.equal(channels.defaultMode('push'), 'off') assert.equal(channels.defaultMode('email'), 'off') }) test('inapp and push both have a deliver now, and push still carries no content', () => { assert.equal(typeof channels.get('inapp').deliver, 'function') assert.equal(typeof channels.get('push').deliver, 'function') assert.equal(channels.get('push').carriesContent, false) }) // ── deliver ──────────────────────────────────────────────────────────────── test('one event delivered to inapp produces exactly one row', async () => { const result = await inappChannel.deliver(outboxRow()) assert.equal(result.ok, true) assert.equal(world.rows.length, 1) assert.equal(world.rows[0].userId, 11) assert.equal(world.rows[0].triggerId, TRIGGER) }) // The acceptance line calls it a no-op; from the recipient's side it is a // delivery, so it reports ok with the reason in the detail rather than putting a // red row in the send log for the mechanism working. test('a duplicate dedupeKey is a no-op that still reports success', async () => { await inappChannel.deliver(outboxRow()) const again = await inappChannel.deliver(outboxRow({ id: 2 })) assert.equal(again.ok, true) assert.match(again.detail, /duplicate/i) assert.equal(world.rows.length, 1) }) test('a row with no dedupe key is never deduped', async () => { await inappChannel.deliver(outboxRow({ dedupe_key: null })) await inappChannel.deliver(outboxRow({ id: 2, dedupe_key: null })) assert.equal(world.rows.length, 2) }) test('a user who can no longer be reached is a terminal failure, not a retry', async () => { patch(recipients, 'filterActive', async () => []) const result = await inappChannel.deliver(outboxRow()) assert.equal(result.ok, false) assert.equal(result.retry, undefined) assert.equal(world.rows.length, 0) }) // A throw would be read by the worker as a transient failure and retried five // times — one unrenderable template becoming five identical send-log rows. test('deliver never throws — a render failure is classified, not propagated', async () => { patch(templates, 'renderInappByKey', async () => { throw new Error('blocks are broken') }) const result = await inappChannel.deliver(outboxRow()) assert.equal(result.ok, false) assert.match(result.detail, /blocks are broken/) }) test('a template that names nothing shipped is terminal and says which key', async () => { patch(rulesDb, 'getById', async () => ({ id: 1, template_keys: { inapp: 'nope.missing' } })) const result = await inappChannel.deliver(outboxRow()) assert.equal(result.ok, false) assert.match(result.detail, /nope\.missing/) }) // ── The block → column role mapping ──────────────────────────────────────── test('the heading becomes the title, the button becomes the url, the rest becomes the body', async () => { const rendered = await templates.renderInappByKey('inapp.event', { title: 'Your house is close to collapsing', intro: 'The Silver Anvil has entered its final decay stage.', actionUrl: '/player/uo/houses', }) assert.equal(rendered.title, 'Your house is close to collapsing') assert.equal(rendered.url, '/player/uo/houses') assert.match(rendered.body, /final decay stage/) // The title and the action are COLUMNS; repeating them in the body would show // the same words twice on one card. assert.doesNotMatch(rendered.body, /close to collapsing/) assert.doesNotMatch(rendered.body, /player\/uo\/houses/) }) // §4.6.1 property 1, for this channel: a trigger with no bespoke template still // renders, because `projection.project` supplies the structural names. test('a trigger that authored nothing still gets a title, from its declaration', async () => { const rendered = await inappChannel.renderItem(TRIGGER, { teamName: 'The Silver Hand' }, 'inapp.event') assert.ok(rendered.title.length > 0) assert.notEqual(rendered.title, 'inapp.event') }) // The seed Phase 5a wrote named `body` and `url` — names nothing supplies, so // every rendering of it would have produced a title and nothing else. test('the shipped inapp seed names only variables the projection actually supplies', () => { const seed = templateSeeds.seedByKey('inapp.event') assert.equal(seed.seedVersion, 2) const names = seed.variables.map((v) => v.name).sort() assert.deepEqual(names, ['actionUrl', 'intro', 'title']) }) // ── url: relative only ───────────────────────────────────────────────────── test('url is relative-only, and a protocol-relative one is dropped rather than stored', () => { const base = 'https://shard.test' assert.equal(templates.relativeUrl('/guilds/4', base), '/guilds/4') assert.equal(templates.relativeUrl('https://shard.test/guilds/4', base), '/guilds/4') assert.equal(templates.relativeUrl('//evil.test/x', base), null) assert.equal(templates.relativeUrl('https://evil.test/x', base), null) assert.equal(templates.relativeUrl('javascript:alert(1)', base), null) assert.equal(templates.relativeUrl('', base), null) }) // ── ctx.inbox.push ───────────────────────────────────────────────────────── test('ctx.inbox.push writes an item for a trigger nothing has registered', async () => { const res = await inappChannel.pushDirect('uo', 11, { triggerId: 'uo.unregistered.thing', title: 'Something happened', body: 'A thing occurred.', url: '/player/uo/houses', }) assert.equal(res.written, true) assert.equal(world.rows[0].url, '/player/uo/houses') }) // The decision: a toggle somebody switched off must not be walkable around by // the module that owns the trigger behind it. test('ctx.inbox.push honours the user’s preference when the trigger IS registered', async () => { world.storedModes = new Map([[11, 'off']]) const res = await inappChannel.pushDirect('uo', 11, { triggerId: TRIGGER, title: 'Hi' }) assert.equal(res.written, false) assert.equal(world.rows.length, 0) }) test('ctx.inbox.push writes for a registered trigger the user has left at the default', async () => { world.storedModes = new Map() const res = await inappChannel.pushDirect('uo', 11, { triggerId: TRIGGER, title: 'Hi' }) assert.equal(res.written, true) }) test('ctx.inbox.push drops an off-site url rather than storing it', async () => { await inappChannel.pushDirect('uo', 11, { triggerId: 'x.y', title: 'Hi', url: 'https://evil.test/steal', }) assert.equal(world.rows[0].url, null) }) test('ctx.inbox.push refuses an item with no title, and never throws', async () => { const res = await inappChannel.pushDirect('uo', 11, { triggerId: 'x.y' }) assert.equal(res.written, false) patch(inbox, 'insert', async () => { throw new Error('table is gone') }) const boom = await inappChannel.pushDirect('uo', 11, { triggerId: 'x.y', title: 'Hi' }) assert.equal(boom.written, false) }) // ── push: the tickle, its ref, and what must never ride on it ────────────── test('the push tickle carries the stream and a ref, and no content whatsoever', async () => { await inappChannel.deliver(outboxRow()) const result = await pushChannel.deliver(outboxRow({ id: 2, channel: 'push' })) assert.equal(result.ok, true) const tickle = world.tickles[0] assert.equal(tickle.streamId, TRIGGER) assert.equal(tickle.ref, 'notification:1') assert.deepEqual(Object.keys(tickle).sort(), ['ref', 'streamId', 'userIds']) assert.deepEqual(tickle.userIds, [11]) }) test('a push row with no inbox row behind it still publishes, with a null ref', async () => { const result = await pushChannel.deliver(outboxRow({ channel: 'push', dedupe_key: null })) assert.equal(result.ok, true) assert.equal(world.tickles[0].ref, null) }) // The ordering is what makes the ref resolve on the first pass: the outbox is // swept `ORDER BY due_at, id`, so the in-app row has to be enqueued first. test('liveChannels enqueues inapp before push, whatever order the rule names them in', () => { assert.deepEqual(engine.liveChannels({ channels: ['push', 'inapp'] }), ['inapp', 'push']) assert.deepEqual(engine.liveChannels({ channels: ['email', 'push'] }), ['email', 'push']) assert.deepEqual(engine.liveChannels({ channels: ['push', 'nope'] }), ['push']) }) // ── The routes: ownership, asserted where the acceptance line asks for it ── function res() { const out = { code: 200, body: null } return { out, status(c) { out.code = c; return this }, json(b) { out.body = b; return this }, } } test('a user cannot mark another user’s notification read — 404 at the route', async () => { // The model is NOT stubbed to "found": it is the real ownership predicate the // route depends on, so the stub answers the way the SQL would. patch(inbox, 'markRead', async (userId, id) => Number(userId) === 11 && Number(id) === 5) patch(inbox, 'unreadCount', async () => 0) const mine = res() await notifCtrl.markRead({ user: { id: 11 }, params: { id: 5 } }, mine) assert.equal(mine.out.code, 200) const theirs = res() await notifCtrl.markRead({ user: { id: 12 }, params: { id: 5 } }, theirs) assert.equal(theirs.out.code, 404) // The same answer whether the row is nobody's or somebody else's: telling them // apart would make this a way to ask whether an id exists. const missing = res() await notifCtrl.markRead({ user: { id: 11 }, params: { id: 999 } }, missing) assert.equal(missing.out.code, 404) }) test('mark-read is idempotent', async () => { let stamps = 0 patch(inbox, 'markRead', async () => { stamps += 1; return true }) patch(inbox, 'unreadCount', async () => 0) await notifCtrl.markRead({ user: { id: 11 }, params: { id: 5 } }, res()) await notifCtrl.markRead({ user: { id: 11 }, params: { id: 5 } }, res()) assert.equal(stamps, 2) // the route is happy to be called twice… // …and the statement behind it only stamps an unread row, which is the half // that makes the second call a no-op. Pinned in the SQL test. }) // There is no route parameter and no query string that names a user, so the // listing cannot be pointed at another account even by a caller who tries. test('the inbox list reads the caller and nothing else', async () => { let askedFor = null patch(inbox, 'list', async (userId, opts) => { askedFor = { userId, opts } return { items: [], hasMore: false } }) patch(inbox, 'unreadCount', async () => 2) const r = res() await notifCtrl.getInbox( { user: { id: 11 }, query: { limit: '10', before: '99', unread: 'true', userId: '12' } }, r, ) assert.equal(askedFor.userId, 11) assert.equal(askedFor.opts.unreadOnly, true) assert.equal(r.out.body.unread, 2) })