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:
@@ -475,11 +475,21 @@ test('two sweepers racing one due row: exactly one claim wins', async () => {
|
||||
// ── The send log ───────────────────────────────────────────────────────────
|
||||
|
||||
test('a row whose channel has no deliver() finishes failed, and the send log says why', async () => {
|
||||
// `inapp`, because as of Phase 6 `email` DOES deliver. The inbox arrives in
|
||||
// Phase 7, and until then recording 'sent' would be a lie in the one table
|
||||
// whose purpose is answering "did they get it".
|
||||
addRule({ channels: ['inapp'] })
|
||||
optIn(10, 'uo.house.idoc_warning', 'inapp')
|
||||
// **A channel registered for this test, because as of Phase 7 all three of
|
||||
// core's deliver.** It used to name `inapp` (and `email` before that), which
|
||||
// meant the assertion moved every time a phase gave a channel behaviour. The
|
||||
// property under test was never about a particular channel: it is that the
|
||||
// worker does not record 'sent' for a sink it cannot reach, because that would
|
||||
// be a lie in the one table whose purpose is answering "did they get it".
|
||||
channels.registerDeliveryChannel({
|
||||
id: 'nosink',
|
||||
label: 'No sink',
|
||||
carriesContent: true,
|
||||
defaultMode: 'off',
|
||||
supportsDigest: false,
|
||||
})
|
||||
addRule({ channels: ['nosink'] })
|
||||
optIn(10, 'uo.house.idoc_warning', 'nosink')
|
||||
await engine.dispatch(event(), T0)
|
||||
await worker.tick(later(1000))
|
||||
|
||||
|
||||
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)
|
||||
})
|
||||
@@ -157,11 +157,18 @@ test('unknown stream ids are still dropped, and are not mirrored either', async
|
||||
|
||||
// ── Acceptance: defaults ───────────────────────────────────────────────────
|
||||
|
||||
test("a fresh user's modes are the channel defaults, and all three are off", async () => {
|
||||
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')
|
||||
|
||||
assert.deepEqual(news.modes, { push: 'off', email: 'off', inapp: 'off' })
|
||||
// **`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
|
||||
|
||||
203
server/test/userNotificationsSql.test.js
Normal file
203
server/test/userNotificationsSql.test.js
Normal file
@@ -0,0 +1,203 @@
|
||||
// ── The inbox's raw SQL, against a real MariaDB ────────────────────────────
|
||||
//
|
||||
// ENGAGEMENT.md Phase 7. `engagementInapp.test.js` stubs the table and exercises
|
||||
// everything the channel DECIDES. It cannot prove the three statements whose
|
||||
// correctness is a server contract rather than a reading of this code:
|
||||
//
|
||||
// • **`UNIQUE (user_id, dedupe_key)` must admit many NULLs.** The whole
|
||||
// "this item does not dedupe" case rests on it, and a unique index that
|
||||
// rejected a second NULL would mean the second un-keyed notification any
|
||||
// user ever received was silently dropped. It is standard SQL and it is also
|
||||
// exactly the kind of assumption Phase 4a's `foundRows` defect was.
|
||||
// • **`INSERT IGNORE` on a duplicate reports `affectedRows = 0`** — the value
|
||||
// `insert()` returns `inserted: false` from, and therefore the value that
|
||||
// decides whether the send log says "delivered" or "duplicate".
|
||||
// • **`read_at IS NULL` in the mark-read predicate is what makes it
|
||||
// idempotent**: the timestamp must not move on a second call.
|
||||
//
|
||||
// Plus the prune's one policy: it deletes read rows and leaves unread ones,
|
||||
// however old.
|
||||
//
|
||||
// **It SKIPS when there is no database**, exactly as `engagementEngineSql`
|
||||
// does and for its reason: CI runs the suite with the pool pointed at a dead
|
||||
// port, and a file that failed there would make every PR red for a reason
|
||||
// unrelated to itself. Run it against this machine's container with:
|
||||
//
|
||||
// DB_HOST=127.0.0.1 DB_PORT=3307 DB_USER=... DB_PASSWORD=... \
|
||||
// node --test test/userNotificationsSql.test.js
|
||||
//
|
||||
// It creates a throwaway database named after the process and drops it again, so
|
||||
// it can never touch a real schema.
|
||||
|
||||
const { test, before, after } = require('node:test')
|
||||
const assert = require('node:assert/strict')
|
||||
const mariadb = require('mariadb')
|
||||
|
||||
// Verbatim from schema.sql, minus the FK to `users` — the point of this file is
|
||||
// the index semantics, and a foreign key would mean seeding an accounts table
|
||||
// that has nothing to do with any of them.
|
||||
const SCHEMA = `
|
||||
CREATE TABLE user_notifications (
|
||||
id BIGINT AUTO_INCREMENT PRIMARY KEY,
|
||||
user_id INT NOT NULL,
|
||||
trigger_id VARCHAR(96) NOT NULL,
|
||||
title VARCHAR(300) NOT NULL,
|
||||
body TEXT NULL,
|
||||
url VARCHAR(500) NULL,
|
||||
dedupe_key VARCHAR(190) NULL,
|
||||
read_at DATETIME NULL,
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
UNIQUE KEY uq_un_dedupe (user_id, dedupe_key),
|
||||
INDEX idx_un_unread (user_id, read_at, created_at),
|
||||
INDEX idx_un_prune (created_at)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
`
|
||||
|
||||
// The statements under test, verbatim from `userNotifications.db.js`. Duplicated
|
||||
// rather than required for `engagementEngineSql`'s reason: requiring the model
|
||||
// would drag in `utils/db`'s pool, which the harness has pointed at a dead port.
|
||||
const INSERT = `
|
||||
INSERT IGNORE INTO user_notifications (user_id, trigger_id, title, body, url, dedupe_key)
|
||||
VALUES (?, ?, ?, ?, ?, ?)`
|
||||
|
||||
const MARK_READ = `
|
||||
UPDATE user_notifications SET read_at = NOW() WHERE id = ? AND user_id = ? AND read_at IS NULL`
|
||||
|
||||
const PRUNE = `
|
||||
DELETE FROM user_notifications
|
||||
WHERE read_at IS NOT NULL AND created_at < (NOW() - INTERVAL ? DAY)
|
||||
LIMIT ?`
|
||||
|
||||
const DB = `rg_inbox_test_${process.pid}`
|
||||
let pool = null
|
||||
let available = false
|
||||
|
||||
const opts = () => ({
|
||||
host: process.env.DB_HOST || '127.0.0.1',
|
||||
port: Number(process.env.DB_PORT) || 3306,
|
||||
user: process.env.DB_USER || 'root',
|
||||
password: process.env.DB_PASSWORD || '',
|
||||
})
|
||||
|
||||
before(async () => {
|
||||
const admin = mariadb.createPool({
|
||||
...opts(),
|
||||
connectionLimit: 1,
|
||||
connectTimeout: 2000,
|
||||
initializationTimeout: 2000,
|
||||
})
|
||||
try {
|
||||
await admin.query(`CREATE DATABASE ${DB}`)
|
||||
available = true
|
||||
} catch {
|
||||
available = false
|
||||
} finally {
|
||||
await admin.end().catch(() => {})
|
||||
}
|
||||
if (!available) return
|
||||
|
||||
pool = mariadb.createPool({
|
||||
...opts(),
|
||||
database: DB,
|
||||
connectionLimit: 3,
|
||||
multipleStatements: true,
|
||||
bigIntAsNumber: true,
|
||||
insertIdAsNumber: true,
|
||||
})
|
||||
await pool.query(SCHEMA)
|
||||
})
|
||||
|
||||
after(async () => {
|
||||
if (pool) {
|
||||
await pool.query(`DROP DATABASE IF EXISTS ${DB}`).catch(() => {})
|
||||
await pool.end().catch(() => {})
|
||||
}
|
||||
})
|
||||
|
||||
// Checked INSIDE each test, never as a `{ skip }` option — the trap
|
||||
// `engagementEngineSql` documents and this file fell into anyway: the option is
|
||||
// evaluated when the file is READ, which is before `before()` has had a chance
|
||||
// to find out whether there is a database, so every test skips unconditionally.
|
||||
// It looks exactly like a passing suite.
|
||||
const SKIP = 'no database reachable - set DB_HOST/DB_PORT/DB_USER/DB_PASSWORD to run'
|
||||
const needDb = (t) => {
|
||||
if (available) return false
|
||||
t.skip(SKIP)
|
||||
return true
|
||||
}
|
||||
|
||||
const write = (userId, key, over = {}) =>
|
||||
pool.query(INSERT, [userId, over.trigger || 't.x', over.title || 'Hi', null, null, key])
|
||||
|
||||
test('a duplicate (user, dedupe key) is ignored and reports affectedRows 0', async (t) => {
|
||||
if (needDb(t)) return
|
||||
const first = await write(901, 'evt:1')
|
||||
assert.equal(first.affectedRows, 1)
|
||||
const second = await write(901, 'evt:1')
|
||||
assert.equal(second.affectedRows, 0)
|
||||
|
||||
// Scoped to the USER, not global: one event legitimately reaches fifty people,
|
||||
// and a global unique key would admit the first and drop forty-nine — the
|
||||
// defect Phase 4a found in §4.2a's outbox index, in a second place.
|
||||
const other = await write(902, 'evt:1')
|
||||
assert.equal(other.affectedRows, 1)
|
||||
})
|
||||
|
||||
test('a NULL dedupe key never collides, however many there are', async (t) => {
|
||||
if (needDb(t)) return
|
||||
for (let i = 0; i < 3; i += 1) {
|
||||
const res = await write(903, null)
|
||||
assert.equal(res.affectedRows, 1)
|
||||
}
|
||||
const rows = await pool.query('SELECT COUNT(*) AS n FROM user_notifications WHERE user_id = 903')
|
||||
assert.equal(Number(rows[0].n), 3)
|
||||
})
|
||||
|
||||
test('mark-read stamps once and a second call moves nothing', async (t) => {
|
||||
if (needDb(t)) return
|
||||
const ins = await write(904, 'evt:read')
|
||||
const id = ins.insertId
|
||||
|
||||
const first = await pool.query(MARK_READ, [id, 904])
|
||||
assert.equal(first.affectedRows, 1)
|
||||
const [after1] = await pool.query('SELECT read_at FROM user_notifications WHERE id = ?', [id])
|
||||
|
||||
// A second later, so a re-stamp would be visible rather than equal by accident.
|
||||
await pool.query('UPDATE user_notifications SET read_at = read_at - INTERVAL 1 SECOND WHERE id = ?', [id])
|
||||
const [before2] = await pool.query('SELECT read_at FROM user_notifications WHERE id = ?', [id])
|
||||
|
||||
const second = await pool.query(MARK_READ, [id, 904])
|
||||
assert.equal(second.affectedRows, 0)
|
||||
const [after2] = await pool.query('SELECT read_at FROM user_notifications WHERE id = ?', [id])
|
||||
assert.deepEqual(after2.read_at, before2.read_at)
|
||||
assert.notDeepEqual(after1.read_at, before2.read_at) // the shift really happened
|
||||
})
|
||||
|
||||
test('mark-read scoped to the owner matches nothing for anyone else', async (t) => {
|
||||
if (needDb(t)) return
|
||||
const ins = await write(905, 'evt:owner')
|
||||
const wrong = await pool.query(MARK_READ, [ins.insertId, 906])
|
||||
assert.equal(wrong.affectedRows, 0)
|
||||
const [row] = await pool.query('SELECT read_at FROM user_notifications WHERE id = ?', [ins.insertId])
|
||||
assert.equal(row.read_at, null)
|
||||
})
|
||||
|
||||
test('the prune drops old READ rows and keeps unread ones however old', async (t) => {
|
||||
if (needDb(t)) return
|
||||
const old = await write(907, 'evt:old')
|
||||
const oldUnread = await write(907, 'evt:old-unread')
|
||||
const recent = await write(907, 'evt:recent')
|
||||
await pool.query(
|
||||
'UPDATE user_notifications SET created_at = NOW() - INTERVAL 200 DAY, read_at = NOW() WHERE id = ?',
|
||||
[old.insertId],
|
||||
)
|
||||
await pool.query('UPDATE user_notifications SET created_at = NOW() - INTERVAL 200 DAY WHERE id = ?', [
|
||||
oldUnread.insertId,
|
||||
])
|
||||
await pool.query('UPDATE user_notifications SET read_at = NOW() WHERE id = ?', [recent.insertId])
|
||||
|
||||
const res = await pool.query(PRUNE, [90, 1000])
|
||||
assert.equal(res.affectedRows, 1)
|
||||
const rows = await pool.query('SELECT id FROM user_notifications WHERE user_id = 907 ORDER BY id')
|
||||
assert.deepEqual(rows.map((r) => Number(r.id)), [oldUnread.insertId, recent.insertId])
|
||||
})
|
||||
Reference in New Issue
Block a user