Files
website/server/test/engagementInapp.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

366 lines
16 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// ── 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 users 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 users 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)
})