feat(engagement): the in-app channel, core and web (engagement Phase 7)
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

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:
2026-08-31 02:07:10 -05:00
parent 5168446c53
commit 24a3cd85b3
34 changed files with 3153 additions and 112 deletions

View File

@@ -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))

View 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 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)
})

View File

@@ -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

View 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])
})