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>
This commit is contained in:
365
server/test/engagementInapp.test.js
Normal file
365
server/test/engagementInapp.test.js
Normal file
@@ -0,0 +1,365 @@
|
||||
// ── 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)
|
||||
})
|
||||
Reference in New Issue
Block a user