25 of 82 test files left with the module. Three that core keeps needed splitting rather than moving, and the split is the boundary in each case. announceJobs.test.js keeps the announce PIPELINE -- the shared backoff schedule, the parent-status rollup, core's Discord leg -- and loses the town-crier text building and classification, which are a module's leg. pushDispatch.test.js keeps the SSRF guard and publish() delivering a content-free tickle, and loses mapShardEvent and the shard fan-out, which are a module's catalog. playerRouteAccess.test.js is the one worth explaining. It guards a real past bug -- an admin 403'd off their own characters -- and it did so through /player/shard/accounts, which is now module-owned. The guarantee it protects is CORE's, though: /player/* is role-agnostic self-service, staff are a superset of players. So it stays here and asserts that through /player/appeals, a core route with the same gate. Moving it would have left core with no test of its own tier rule, which is precisely what regressed once before. The remaining updates are core's own tests catching up: ctx has four more members, registerCore now registers only what core owns (one stream, one leg, no filled slot), and the extension-slot test asks for the DECLARED slot's router rather than the filled one, since core declares it and a module fills it. The gated-surface floor drops from >100 to >50 -- it is there so a filter matching nothing fails loudly, not to track core's exact route count. 616 core tests and 160 client tests pass; the module's own suite is 351. Co-Authored-By: Claude <noreply@anthropic.com>
231 lines
11 KiB
JavaScript
231 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()
|
||
|
||
// One stream, 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.
|
||
assert.deepEqual(registries.allStreams().map((s) => s.id), ['news.post'])
|
||
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')
|
||
})
|