A notification feature is mostly things that correctly do NOT happen, and each of these is invisible until it goes wrong in production: a departed member and a revoked guest are not recipients; a mute subtracts per Team and leaves the user's other Teams alone; the author of a post never receives the notification about it; forums switched off silences the forum streams including the digest; a Team's first roster wakes nobody; a failed send does not stamp `last_digest_at`. Two real defects came out of writing them. `Number(null)` is 0 and 0 is an integer, so a null in a caller's id list survived `filter(Number.isInteger)` and rode into an IN clause as user id 0. No row has id 0, so it was harmless — which is exactly why it would never have been noticed. Fixed in all three places that filter ids. `recipientIds: db.recipientIds` in the model captured the function OBJECT at require time, so the layer below could never be substituted. That is not only untestable; it means the model was not really the seam it claimed to be. Wrapped so `db.x` resolves at call time. The registries catalog assertion is now an exact five-element list, so a shard-content stream creeping back into core's registration fails here rather than shipping. Co-Authored-By: Claude <noreply@anthropic.com>
242 lines
11 KiB
JavaScript
242 lines
11 KiB
JavaScript
// ── The three de-entanglement registries ───────────────────────────────────
|
||
//
|
||
// Phase 2 PR 4 of docs/website/MODULE_SYSTEM.md §2.7. The properties worth a test
|
||
// are the ones nobody exercises by hand: what happens when two registrants want
|
||
// the same name, and what is left behind when one of them fails halfway.
|
||
//
|
||
// Point the DB at a closed port BEFORE requiring anything — registerCore() pulls
|
||
// in the announce legs, which pull in models that build a pool at require time.
|
||
// No query is ever run.
|
||
process.env.DB_HOST = '127.0.0.1'
|
||
process.env.DB_PORT = '59999'
|
||
|
||
const { test, beforeEach, after } = require('node:test')
|
||
const assert = require('node:assert/strict')
|
||
|
||
const registries = require('../src/modules/registries')
|
||
const db = require('../src/utils/db')
|
||
|
||
// Declares `admin.users.detail` the way production does — at require time, in the
|
||
// router that owns the resource.
|
||
require('../src/router/v1/admin')
|
||
|
||
after(() => db.close())
|
||
|
||
beforeEach(() => registries._reset())
|
||
|
||
const stream = (id, over = {}) => ({ id, label: id, ...over })
|
||
const leg = (id, over = {}) => ({ leg: id, label: id, dispatch: async () => ({ ok: true }), classify: () => ({ outcome: 'done' }), ...over })
|
||
|
||
/** Register a batch as `owner` and return the error message, or null on success. */
|
||
function tryApply(owner, build) {
|
||
const api = registries.stage(owner)
|
||
try {
|
||
build(api)
|
||
registries.apply(api.staged)
|
||
return null
|
||
} catch (err) {
|
||
return err.message
|
||
}
|
||
}
|
||
|
||
// ── Core goes through the same door ────────────────────────────────────────
|
||
|
||
test('registerCore registers exactly what core owns, and nothing else', () => {
|
||
registries.registerCore()
|
||
|
||
// Five streams, one leg, no filled slot. Before Phase 3 this was eight streams,
|
||
// two legs and a core-filled `admin.users.detail` — core was holding shard
|
||
// CONTENT so the seam would be exercised on every boot before a module first
|
||
// used it. module-uo registers all of it now, through the same door.
|
||
//
|
||
// The four `team.*` streams arrived with Teams phase 6 and ARE core's: a module
|
||
// supplies who is in a Team, but who may be told about it is the access
|
||
// resolver's answer. Asserted as an exact list so a shard-content stream
|
||
// creeping back into core's registration fails here rather than shipping.
|
||
assert.deepEqual(registries.allStreams().map((s) => s.id), [
|
||
'news.post',
|
||
'team.member.joined',
|
||
'team.leadership.changed',
|
||
'team.forum.post',
|
||
'team.announcement',
|
||
])
|
||
assert.deepEqual(registries.announceLegIds(), ['discord'])
|
||
assert.equal(registries.slotFilledBy('admin.users.detail'), null)
|
||
assert.equal(registries.isCoreRegistered(), true)
|
||
})
|
||
|
||
test('registerCore is idempotent — a second call registers nothing twice', () => {
|
||
registries.registerCore()
|
||
const before = registries.allStreams().length
|
||
registries.registerCore()
|
||
assert.equal(registries.allStreams().length, before)
|
||
})
|
||
|
||
test('the wire shape of a stream survives registration', () => {
|
||
// Asserted against a REGISTERED stream rather than a core one, because the
|
||
// shape is what a module hands over and core republishes. `vendor.sale` used
|
||
// to be the subject here and is module-uo's now; a synthetic registration
|
||
// tests the same contract without core needing a personal stream of its own.
|
||
assert.equal(tryApply('uo', (api) => api.registerNotificationStreams([{
|
||
id: 'uo.vendorsale',
|
||
label: 'Vendor sales',
|
||
description: 'One of your vendors sold something.',
|
||
personal: true,
|
||
requiresLinkedAccount: true,
|
||
}])), null)
|
||
|
||
const personal = registries.allStreams().find((s) => s.id === 'uo.vendorsale')
|
||
// Two booleans, not the contract's single `scope`: this object is the body of
|
||
// GET /auth/me/notifications/streams and a shipped Android client reads both.
|
||
assert.equal(personal.personal, true)
|
||
assert.equal(personal.requiresLinkedAccount, true)
|
||
assert.ok(personal.description.length > 0)
|
||
assert.ok(registries.personalStreams().has('uo.vendorsale'))
|
||
assert.equal(registries.isValidStream('uo.vendorsale'), true)
|
||
assert.equal(registries.isValidStream('nope.nope'), false)
|
||
})
|
||
|
||
// ── Namespacing ────────────────────────────────────────────────────────────
|
||
|
||
test('a module’s stream ids must carry its module id', () => {
|
||
assert.equal(tryApply('rust', (api) => api.registerNotificationStreams([stream('rust.raid')])), null)
|
||
assert.match(
|
||
tryApply('rust', (api) => api.registerNotificationStreams([stream('raid.started')])),
|
||
/not namespaced "rust\."/,
|
||
)
|
||
})
|
||
|
||
test('the seven pre-module-system stream ids are grandfathered to uo alone', () => {
|
||
// Renaming them would be a data migration (notification_subs rows) and a break
|
||
// for a shipped Android client — the same reasoning as the loader's legacy
|
||
// table prefixes.
|
||
assert.equal(tryApply('uo', (api) => api.registerNotificationStreams([stream('vendor.sale')])), null)
|
||
registries._reset()
|
||
assert.match(
|
||
tryApply('rust', (api) => api.registerNotificationStreams([stream('vendor.sale')])),
|
||
/not namespaced "rust\."/,
|
||
)
|
||
})
|
||
|
||
test('an announce leg must be namespaced too, with towncrier grandfathered to uo', () => {
|
||
assert.equal(tryApply('uo', (api) => api.registerAnnounceLeg(leg('towncrier'))), null)
|
||
registries._reset()
|
||
assert.equal(tryApply('rust', (api) => api.registerAnnounceLeg(leg('rust.motd'))), null)
|
||
registries._reset()
|
||
assert.match(
|
||
tryApply('rust', (api) => api.registerAnnounceLeg(leg('towncrier'))),
|
||
/not namespaced "rust\."/,
|
||
)
|
||
})
|
||
|
||
// ── Collisions name the holder ─────────────────────────────────────────────
|
||
|
||
test('a stream core already registered is refused, naming core', () => {
|
||
registries.registerCore()
|
||
assert.match(
|
||
tryApply('uo', (api) => api.registerNotificationStreams([stream('news.post')])),
|
||
/already registered by "core"/,
|
||
)
|
||
})
|
||
|
||
test('two modules cannot register the same stream or leg', () => {
|
||
assert.equal(tryApply('aaa', (api) => api.registerNotificationStreams([stream('aaa.thing')])), null)
|
||
// A second module can only reach it via its own namespace, so collide on a
|
||
// grandfathered id, which is the realistic case.
|
||
assert.match(
|
||
tryApply('aaa', (api) => api.registerNotificationStreams([stream('aaa.thing')])),
|
||
/already registered by "aaa"/,
|
||
)
|
||
assert.equal(tryApply('bbb', (api) => api.registerAnnounceLeg(leg('bbb.x'))), null)
|
||
assert.match(tryApply('bbb', (api) => api.registerAnnounceLeg(leg('bbb.x'))), /already registered by "bbb"/)
|
||
})
|
||
|
||
test('a batch cannot claim the same name twice', () => {
|
||
assert.match(
|
||
tryApply('aaa', (api) => api.registerNotificationStreams([stream('aaa.x'), stream('aaa.x')])),
|
||
/registered twice/,
|
||
)
|
||
})
|
||
|
||
// ── Validate-then-commit ───────────────────────────────────────────────────
|
||
|
||
test('a batch whose LAST claim collides commits none of the earlier ones', () => {
|
||
// The property the whole staging design exists for. A half-registered catalog
|
||
// is worse than a missing one: a subscribable stream nothing will publish to.
|
||
registries.registerCore()
|
||
const before = registries.allStreams().length
|
||
|
||
const err = tryApply('uo', (api) => {
|
||
api.registerNotificationStreams([stream('uo.first'), stream('uo.second')])
|
||
api.registerAnnounceLeg(leg('uo.leg'))
|
||
api.registerNotificationStreams([stream('news.post')]) // collides with core
|
||
})
|
||
|
||
assert.match(err, /already registered by "core"/)
|
||
assert.equal(registries.allStreams().length, before, 'uo.first / uo.second must not be registered')
|
||
assert.equal(registries.isValidStream('uo.first'), false)
|
||
assert.equal(registries.announceLeg('uo.leg'), null)
|
||
})
|
||
|
||
test('staging alone changes nothing — only apply() commits', () => {
|
||
const api = registries.stage('uo')
|
||
api.registerNotificationStreams([stream('uo.staged')])
|
||
assert.equal(registries.isValidStream('uo.staged'), false)
|
||
registries.apply(api.staged)
|
||
assert.equal(registries.isValidStream('uo.staged'), true)
|
||
})
|
||
|
||
// ── Shape checks fire at the call ──────────────────────────────────────────
|
||
|
||
test('a malformed claim throws where the registrant made it, not at apply()', () => {
|
||
const api = registries.stage('uo')
|
||
assert.throws(() => api.registerNotificationStreams([stream('nodots')]), /bad stream id/)
|
||
assert.throws(() => api.registerNotificationStreams([{ id: 'uo.x' }]), /has no label/)
|
||
assert.throws(() => api.registerNotificationStreams('not an array'), /expected an array/)
|
||
assert.throws(() => api.registerAnnounceLeg(leg('uo.x', { dispatch: null })), /has no dispatch/)
|
||
assert.throws(() => api.registerAnnounceLeg(leg('uo.x', { classify: null })), /has no classify/)
|
||
assert.throws(() => api.registerAnnounceLeg({ leg: 'NOPE' }), /bad leg id/)
|
||
})
|
||
|
||
// ── Extension slots ────────────────────────────────────────────────────────
|
||
|
||
test('only a declared slot can be filled, and only once', () => {
|
||
const router = () => {}
|
||
const api = registries.stage('uo')
|
||
assert.throws(() => api.registerExtension('admin.invented', router), /unknown extension slot/)
|
||
assert.throws(() => api.registerExtension('admin.users.detail', 'not a router'), /is not a router/)
|
||
|
||
// Core no longer fills it — module-uo does. Two registrants racing for the
|
||
// same slot is still the case worth testing, so the first fill is a module's.
|
||
assert.equal(tryApply('uo', (a) => a.registerExtension('admin.users.detail', router)), null)
|
||
assert.match(
|
||
tryApply('rust', (a) => a.registerExtension('admin.users.detail', router)),
|
||
/already filled by "uo"/,
|
||
)
|
||
})
|
||
|
||
test('a slot cannot be declared twice', () => {
|
||
assert.throws(() => registries.declareSlot('admin.users.detail'), /already declared/)
|
||
})
|
||
|
||
// ── The slot's spec, which static analysis cannot see ──────────────────────
|
||
|
||
test('the filled slot’s router is findable in the live app, at the resource path', () => {
|
||
// Guards swagger/slotSpecs.js: it recovers each slot's mount prefix from the
|
||
// live stack rather than a hardcoded table. If this stops working, the six
|
||
// slot routes vanish from swagger-output.json with `Success` printed — the
|
||
// exact silent failure the spike hit (MODULE_API.md §7.4).
|
||
/* eslint-disable global-require */
|
||
const app = require('../src/app')
|
||
const { findMountPrefix } = require('../swagger/slotSpecs')
|
||
/* eslint-enable global-require */
|
||
|
||
// The slot is DECLARED by core and filled by whichever module is installed —
|
||
// none, in core's own test run. What must keep working regardless is the
|
||
// recovery of its mount prefix from the live stack, because that is what
|
||
// slotSpecs.js needs and what fails silently when it breaks.
|
||
const router = registries.declaredSlotRouter('admin.users.detail')
|
||
assert.ok(router, 'core declares the slot at require time, filled or not')
|
||
assert.equal(findMountPrefix(app._router.stack, router), '/api/v1/admin/users/:id')
|
||
})
|