feat(modules): the three de-entanglement registries, with core as the registrant
Phase 2 PR 4 of docs/website/MODULE_SYSTEM.md §2.7. Adds server/src/modules/registries.js and moves core's own notification streams, announce leg and users-detail routes behind it, so the three seams §1.8 and §1.9 named are exercised on every boot before any module depends on them. Registering is validate-then-commit per registrant: the loader stages what a module claims and the second pass commits it, so a module that throws halfway through register() — or fails checkDeclared after it — leaves nothing behind. That is the registry-side twin of PR 2's second-pass mount rule. Four decisions, all the recommended option: - announce legs became a child table. `announce_job_legs` replaces the towncrier_*/discord_* column groups, so the leg set is data: core registers `discord`, module-uo will register `towncrier`, and a module cannot ALTER a core table to add its own. Backfill is guarded on information_schema (a SELECT of a dropped column is a parse error, not a runtime one) and the columns go with DROP COLUMN IF EXISTS. Verified against the live dev DB: three legacy jobs migrated faithfully, three replays, no duplicates. - `mapEvent` dropped from registerNotificationStreams. §1.8 already inverts the push path so a module owns fromShardEvent and calls core's publish() with a stream id it resolved; a second mapping mechanism was a leftover. The public safety filter, the kinds it reads and the streams it protects now live in one file and move together. - core registers through the same staging area a module uses, via an explicit registries.registerCore() in app.js before modules.load(). - core's six /admin/users/:id/shard/* paths now go through the `admin.users.detail` slot, and getUser moved back to admin.controller.js. Found on the way, and the reason two build tools changed: - scripts/routeManifest.js could not decode a parameterised mount. Its unwinder expected `(?:([^\/]+?))`; express 4.22 emits `(?:\/([^/]+?))` with the separator inside the group. The branch had never run. It threw rather than guessing, which is what it is for. - swagger-autogen cannot follow a route into an extension slot — the slot's router is created by declareSlot() and filled later, so there is no literal mount for a static parse. Regenerating deleted 407 lines and printed `Swagger-autogen: Success`, the spike's exact failure (MODULE_API.md §7.4). swagger/slotSpecs.js generates a fragment per filled slot and re-roots it at the prefix the router actually hangs at in the live app — read from the express stack via routeManifest's own mountPath, so the manifest and the spec cannot disagree. swagger/mergeSpec.js is the merge helper core owes for module fragments anyway (§6.1a), proved here against core's own slot first. 884 tests pass (856 before). routes.manifest.json is unchanged at 229 routes. The OpenAPI spec diff is two lines of intent: the retry endpoint's summary, and its `leg` no longer being a fixed enum. Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -75,7 +75,7 @@ test('salesForAccounts keeps only sales owned by the given accounts, newest 50',
|
||||
})
|
||||
|
||||
// ── Controller: unknown user → 404 ─────────────────────────────────────────
|
||||
for (const handler of ['getUser', 'listAccounts', 'getSales', 'getHouses', 'getOnline']) {
|
||||
for (const handler of ['listAccounts', 'getSales', 'getHouses', 'getOnline']) {
|
||||
test(`${handler} returns 404 when the user does not exist`, async () => {
|
||||
users.getById = async () => null
|
||||
const res = mockRes()
|
||||
@@ -144,10 +144,7 @@ test('a user with no linked accounts yields empty sales/houses/online', async ()
|
||||
assert.deepEqual(online.body, [])
|
||||
})
|
||||
|
||||
test('getUser returns the sanitized user row', async () => {
|
||||
users.getById = async () => ({ id: 7, username: 'bob', role: 'player', status: 'active' })
|
||||
const res = mockRes()
|
||||
await ctrl.getUser({ params: { id: '7' } }, res)
|
||||
assert.equal(res.statusCode, 200)
|
||||
assert.equal(res.body.username, 'bob')
|
||||
})
|
||||
// getUser is NOT here any more: reading a user is core semantics that had ended
|
||||
// up in this controller by proximity, and PR 4 moved it back to
|
||||
// admin.controller.js behind the extension slot (MODULE_SYSTEM.md §1.9). It is
|
||||
// covered by test/adminUsers.test.js.
|
||||
|
||||
65
server/test/adminUsers.test.js
Normal file
65
server/test/adminUsers.test.js
Normal file
@@ -0,0 +1,65 @@
|
||||
// ── Admin · Users: the core half of /admin/users/:id ───────────────────────
|
||||
//
|
||||
// `getUser` lived in usersShard.controller.js until Phase 2 PR 4, purely because
|
||||
// the detail page it backs is mostly shard panels. MODULE_SYSTEM.md §1.9 called
|
||||
// that out as core semantics that ended up in the UO controller by proximity, and
|
||||
// it moved back to admin.controller.js — the shard panels around it are now an
|
||||
// extension slot, so this handler has to stand on its own when they leave.
|
||||
//
|
||||
// Point the DB at a closed port BEFORE requiring anything that builds the pool,
|
||||
// so any stray query fails fast instead of hanging the runner.
|
||||
process.env.DB_HOST = '127.0.0.1'
|
||||
process.env.DB_PORT = '59999'
|
||||
|
||||
const { test, after, afterEach } = require('node:test')
|
||||
const assert = require('node:assert/strict')
|
||||
|
||||
const ctrl = require('../src/router/v1/admin/admin.controller')
|
||||
const users = require('../src/model/users/users.model')
|
||||
const db = require('../src/utils/db')
|
||||
|
||||
after(() => db.close())
|
||||
|
||||
function mockRes() {
|
||||
return {
|
||||
statusCode: 200,
|
||||
body: null,
|
||||
status(c) {
|
||||
this.statusCode = c
|
||||
return this
|
||||
},
|
||||
json(b) {
|
||||
this.body = b
|
||||
return this
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
const originalGetById = users.getById
|
||||
afterEach(() => {
|
||||
users.getById = originalGetById
|
||||
})
|
||||
|
||||
test('getUser returns the sanitized user row', async () => {
|
||||
users.getById = async () => ({ id: 7, username: 'bob', role: 'player', status: 'active' })
|
||||
const res = mockRes()
|
||||
await ctrl.getUser({ params: { id: '7' } }, res)
|
||||
assert.equal(res.statusCode, 200)
|
||||
assert.equal(res.body.username, 'bob')
|
||||
})
|
||||
|
||||
test('getUser returns 404 when the user does not exist', async () => {
|
||||
users.getById = async () => null
|
||||
const res = mockRes()
|
||||
await ctrl.getUser({ params: { id: '404' } }, res)
|
||||
assert.equal(res.statusCode, 404)
|
||||
})
|
||||
|
||||
test('getUser 500s rather than throwing when the model fails', async () => {
|
||||
users.getById = async () => {
|
||||
throw new Error('pool down')
|
||||
}
|
||||
const res = mockRes()
|
||||
await ctrl.getUser({ params: { id: '7' } }, res)
|
||||
assert.equal(res.statusCode, 500)
|
||||
})
|
||||
@@ -2,10 +2,14 @@ const { test } = require('node:test')
|
||||
const assert = require('node:assert/strict')
|
||||
|
||||
const logic = require('../src/model/announceJobs/announceJobs.logic')
|
||||
// Each leg owns its own text-building and result classification since PR 4 — a
|
||||
// leg is a registration now, not a branch in the worker (MODULE_SYSTEM.md §1.8).
|
||||
const townCrier = require('../src/utils/shardAnnounce')
|
||||
const discord = require('../src/utils/discordAnnounce')
|
||||
|
||||
// ── buildTownCrierText ───────────────────────────────────────────────────────
|
||||
test('buildTownCrierText produces title, excerpt, and URL lines', () => {
|
||||
const lines = logic.buildTownCrierText(
|
||||
const lines = townCrier.buildTownCrierText(
|
||||
{ id: 7, title: 'Server Update', excerpt: 'Big things afoot.', body: null },
|
||||
{ baseUrl: 'https://uom.example' },
|
||||
)
|
||||
@@ -13,7 +17,7 @@ test('buildTownCrierText produces title, excerpt, and URL lines', () => {
|
||||
})
|
||||
|
||||
test('buildTownCrierText falls back to a stripped body when excerpt is empty', () => {
|
||||
const lines = logic.buildTownCrierText(
|
||||
const lines = townCrier.buildTownCrierText(
|
||||
{ id: 1, title: 'T', excerpt: '', body: '<p>Hello <b>world</b></p>' },
|
||||
{ baseUrl: 'https://uom.example' },
|
||||
)
|
||||
@@ -22,17 +26,17 @@ test('buildTownCrierText falls back to a stripped body when excerpt is empty', (
|
||||
|
||||
test('buildTownCrierText clamps each line to the sidecar per-line cap', () => {
|
||||
const longTitle = 'x'.repeat(500)
|
||||
const lines = logic.buildTownCrierText(
|
||||
const lines = townCrier.buildTownCrierText(
|
||||
{ id: 1, title: longTitle, excerpt: 'y'.repeat(500), body: null },
|
||||
{ baseUrl: 'https://uom.example' },
|
||||
)
|
||||
for (const line of lines) assert.ok(line.length <= logic.MAX_LINE_LEN, `line too long: ${line.length}`)
|
||||
for (const line of lines) assert.ok(line.length <= townCrier.MAX_LINE_LEN, `line too long: ${line.length}`)
|
||||
assert.ok(lines[0].endsWith('…'))
|
||||
assert.ok(lines.length <= logic.MAX_LINES)
|
||||
assert.ok(lines.length <= townCrier.MAX_LINES)
|
||||
})
|
||||
|
||||
test('buildTownCrierText omits the excerpt line when there is no excerpt or body', () => {
|
||||
const lines = logic.buildTownCrierText(
|
||||
const lines = townCrier.buildTownCrierText(
|
||||
{ id: 1, title: 'Only a title', excerpt: null, body: null },
|
||||
{ baseUrl: 'https://uom.example' },
|
||||
)
|
||||
@@ -40,27 +44,27 @@ test('buildTownCrierText omits the excerpt line when there is no excerpt or body
|
||||
})
|
||||
|
||||
// ── classifyTownCrier ────────────────────────────────────────────────────────
|
||||
test('classifyTownCrier: 2xx is done', () => {
|
||||
assert.equal(logic.classifyTownCrier({ ok: true, status: 200 }).outcome, 'done')
|
||||
test('town crier classify: 2xx is done', () => {
|
||||
assert.equal(townCrier.classify({ ok: true, status: 200 }).outcome, 'done')
|
||||
})
|
||||
|
||||
test('classifyTownCrier: over-cap / auth / protocol errors are terminal (no retry)', () => {
|
||||
test('town crier classify: over-cap / auth / protocol errors are terminal (no retry)', () => {
|
||||
for (const status of [400, 401, 409]) {
|
||||
assert.equal(logic.classifyTownCrier({ ok: false, status }).outcome, 'terminal', `status ${status}`)
|
||||
assert.equal(townCrier.classify({ ok: false, status }).outcome, 'terminal', `status ${status}`)
|
||||
}
|
||||
})
|
||||
|
||||
test('classifyTownCrier: shard-transient and network errors retry', () => {
|
||||
test('town crier classify: shard-transient and network errors retry', () => {
|
||||
for (const status of [503, 504, 500, 0]) {
|
||||
assert.equal(logic.classifyTownCrier({ ok: false, status }).outcome, 'retry', `status ${status}`)
|
||||
assert.equal(townCrier.classify({ ok: false, status }).outcome, 'retry', `status ${status}`)
|
||||
}
|
||||
})
|
||||
|
||||
// ── classifyDiscord ──────────────────────────────────────────────────────────
|
||||
test('classifyDiscord: ok is done, every failure retries', () => {
|
||||
assert.equal(logic.classifyDiscord({ ok: true, status: 200 }).outcome, 'done')
|
||||
test('discord classify: ok is done, every failure retries', () => {
|
||||
assert.equal(discord.classify({ ok: true, status: 200 }).outcome, 'done')
|
||||
for (const status of [400, 503, 0]) {
|
||||
assert.equal(logic.classifyDiscord({ ok: false, status }).outcome, 'retry', `status ${status}`)
|
||||
assert.equal(discord.classify({ ok: false, status }).outcome, 'retry', `status ${status}`)
|
||||
}
|
||||
})
|
||||
|
||||
@@ -75,12 +79,27 @@ test('scheduleAfter returns increasing delays then null at the attempt cap', ()
|
||||
})
|
||||
|
||||
// ── rollupStatus ─────────────────────────────────────────────────────────────
|
||||
test('rollupStatus derives the parent status from the two legs', () => {
|
||||
assert.equal(logic.rollupStatus('done', 'done'), 'done')
|
||||
assert.equal(logic.rollupStatus('failed', 'failed'), 'failed')
|
||||
assert.equal(logic.rollupStatus('pending', 'pending'), 'pending')
|
||||
// One terminal, the other not matching → partial.
|
||||
assert.equal(logic.rollupStatus('done', 'pending'), 'partial')
|
||||
assert.equal(logic.rollupStatus('pending', 'failed'), 'partial')
|
||||
assert.equal(logic.rollupStatus('done', 'failed'), 'partial')
|
||||
// Takes the LIST of leg statuses, not two named legs: which legs exist is what a
|
||||
// module decides (MODULE_SYSTEM.md §1.8).
|
||||
test('rollupStatus derives the parent status from its legs', () => {
|
||||
assert.equal(logic.rollupStatus(['done', 'done']), 'done')
|
||||
assert.equal(logic.rollupStatus(['failed', 'failed']), 'failed')
|
||||
assert.equal(logic.rollupStatus(['pending', 'pending']), 'pending')
|
||||
// One terminal, the others not matching → partial.
|
||||
assert.equal(logic.rollupStatus(['done', 'pending']), 'partial')
|
||||
assert.equal(logic.rollupStatus(['pending', 'failed']), 'partial')
|
||||
assert.equal(logic.rollupStatus(['done', 'failed']), 'partial')
|
||||
})
|
||||
|
||||
test('rollupStatus generalises past two legs', () => {
|
||||
assert.equal(logic.rollupStatus(['done', 'done', 'done']), 'done')
|
||||
assert.equal(logic.rollupStatus(['done', 'done', 'pending']), 'partial')
|
||||
assert.equal(logic.rollupStatus(['pending', 'pending', 'pending']), 'pending')
|
||||
assert.equal(logic.rollupStatus(['failed', 'failed', 'failed']), 'failed')
|
||||
// A single leg is not a special case.
|
||||
assert.equal(logic.rollupStatus(['pending']), 'pending')
|
||||
assert.equal(logic.rollupStatus(['done']), 'done')
|
||||
// No legs registered at all: nothing is left to deliver, so the job is done
|
||||
// rather than pending forever on a leg that does not exist.
|
||||
assert.equal(logic.rollupStatus([]), 'done')
|
||||
})
|
||||
|
||||
206
server/test/announceLegs.test.js
Normal file
206
server/test/announceLegs.test.js
Normal file
@@ -0,0 +1,206 @@
|
||||
// ── The announcement pipeline, once legs became registrations ──────────────
|
||||
//
|
||||
// Phase 2 PR 4 (docs/website/MODULE_SYSTEM.md §1.8): `announce_jobs`' two
|
||||
// hardcoded leg column groups became `announce_job_legs` rows, and which legs
|
||||
// exist is what modules/registries.js answers. These tests are about that
|
||||
// property specifically — that nothing in the worker or the model knows the word
|
||||
// "towncrier", and a leg nobody registered is handled rather than assumed away.
|
||||
//
|
||||
// Point the DB at a closed port BEFORE requiring anything; every DB call the
|
||||
// model makes is stubbed.
|
||||
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 announceJobs = require('../src/model/announceJobs/announceJobs.model')
|
||||
const announceDb = require('../src/model/announceJobs/announceJobs.db')
|
||||
const worker = require('../src/utils/announceWorker')
|
||||
const posts = require('../src/model/posts/posts.model')
|
||||
const db = require('../src/utils/db')
|
||||
|
||||
after(() => db.close())
|
||||
|
||||
const originals = {
|
||||
create: announceDb.create,
|
||||
findById: announceDb.findById,
|
||||
findByPostId: announceDb.findByPostId,
|
||||
findDue: announceDb.findDue,
|
||||
updateLeg: announceDb.updateLeg,
|
||||
ensureLegs: announceDb.ensureLegs,
|
||||
setStatus: announceDb.setStatus,
|
||||
linkAnnounceJob: posts.linkAnnounceJob,
|
||||
markAnnounced: posts.markAnnounced,
|
||||
getById: posts.getById,
|
||||
}
|
||||
afterEach(() => Object.assign(announceDb, {
|
||||
create: originals.create,
|
||||
findById: originals.findById,
|
||||
findByPostId: originals.findByPostId,
|
||||
findDue: originals.findDue,
|
||||
updateLeg: originals.updateLeg,
|
||||
ensureLegs: originals.ensureLegs,
|
||||
setStatus: originals.setStatus,
|
||||
}) && Object.assign(posts, {
|
||||
linkAnnounceJob: originals.linkAnnounceJob,
|
||||
markAnnounced: originals.markAnnounced,
|
||||
getById: originals.getById,
|
||||
}))
|
||||
|
||||
// Two legs that record what they were asked to do, standing in for core's
|
||||
// discord and a module's own.
|
||||
function fakeLegs() {
|
||||
const calls = []
|
||||
return {
|
||||
calls,
|
||||
a: { leg: 'discord', label: 'Discord #news', dispatch: async (p) => { calls.push(['discord', p.id]); return { ok: true } }, classify: (r) => (r.ok ? { outcome: 'done' } : { outcome: 'retry', error: 'x' }) },
|
||||
b: { leg: 'rust.motd', label: 'Server MOTD', dispatch: async (p) => { calls.push(['rust.motd', p.id]); return { ok: false, status: 503 } }, classify: (r) => (r.ok ? { outcome: 'done' } : { outcome: 'retry', error: 'down' }) },
|
||||
}
|
||||
}
|
||||
|
||||
beforeEach(() => registries._reset())
|
||||
|
||||
function register(...legs) {
|
||||
const api = registries.stage('core')
|
||||
for (const l of legs) api.registerAnnounceLeg(l)
|
||||
registries.apply(api.staged)
|
||||
}
|
||||
|
||||
// ── Enqueue ────────────────────────────────────────────────────────────────
|
||||
|
||||
test('enqueue creates one leg row per REGISTERED leg, whatever they are', () => {
|
||||
const legs = fakeLegs()
|
||||
register(legs.a, legs.b)
|
||||
|
||||
let created = null
|
||||
announceDb.create = async (postId, ids) => { created = { postId, ids }; return 42 }
|
||||
posts.linkAnnounceJob = async () => {}
|
||||
|
||||
return announceJobs.enqueue(9).then((jobId) => {
|
||||
assert.equal(jobId, 42)
|
||||
assert.deepEqual(created, { postId: 9, ids: ['discord', 'rust.motd'] })
|
||||
})
|
||||
})
|
||||
|
||||
test('with no legs registered, a job is created with none — and rolls up done', async () => {
|
||||
let created = null
|
||||
announceDb.create = async (postId, ids) => { created = { postId, ids }; return 1 }
|
||||
posts.linkAnnounceJob = async () => {}
|
||||
await announceJobs.enqueue(9)
|
||||
assert.deepEqual(created.ids, [])
|
||||
|
||||
announceDb.findById = async () => ({ id: 1, post_id: 9, status: 'pending', legs: [] })
|
||||
const statuses = []
|
||||
announceDb.setStatus = async (_id, s) => statuses.push(s)
|
||||
posts.markAnnounced = async () => {}
|
||||
const job = await announceJobs.refreshStatus(1)
|
||||
assert.equal(job.status, 'done')
|
||||
assert.deepEqual(statuses, ['done'])
|
||||
})
|
||||
|
||||
// ── The worker dispatches through the registration ─────────────────────────
|
||||
|
||||
test('the worker dispatches each due leg through whatever registered it', async () => {
|
||||
const legs = fakeLegs()
|
||||
register(legs.a, legs.b)
|
||||
posts.getById = async () => ({ id: 5, title: 't', excerpt: 'e', body: null })
|
||||
|
||||
const updates = []
|
||||
announceDb.findDue = async () => [{
|
||||
id: 1,
|
||||
post_id: 5,
|
||||
status: 'pending',
|
||||
legs: [
|
||||
{ leg: 'discord', status: 'pending', attempts: 0, next_attempt_at: null },
|
||||
{ leg: 'rust.motd', status: 'pending', attempts: 0, next_attempt_at: null },
|
||||
],
|
||||
}]
|
||||
announceDb.updateLeg = async (jobId, leg, fields) => updates.push([leg, fields.status])
|
||||
announceDb.findById = async () => ({ id: 1, post_id: 5, status: 'pending', legs: [] })
|
||||
announceDb.setStatus = async () => {}
|
||||
posts.markAnnounced = async () => {}
|
||||
|
||||
await worker.tick(new Date())
|
||||
|
||||
assert.deepEqual(legs.calls, [['discord', 5], ['rust.motd', 5]])
|
||||
// discord delivered; rust.motd got a 503, so it is rescheduled, not failed.
|
||||
assert.deepEqual(updates, [['discord', 'done'], ['rust.motd', 'pending']])
|
||||
})
|
||||
|
||||
test('a leg row nobody registers any more is left alone, not failed', async () => {
|
||||
// Its module was uninstalled. Failing it would roll the job up terminal on the
|
||||
// strength of a leg that no longer exists, and reinstalling should resume it.
|
||||
register(fakeLegs().a)
|
||||
let touched = false
|
||||
announceDb.updateLeg = async () => { touched = true }
|
||||
posts.getById = async () => { throw new Error('must not even look up the post') }
|
||||
|
||||
await worker.processLeg({ id: 1, post_id: 5, legs: [{ leg: 'gone.leg', status: 'pending', attempts: 0 }] }, 'gone.leg')
|
||||
assert.equal(touched, false)
|
||||
})
|
||||
|
||||
test('isLegDue reads the row, not a leg-prefixed column', () => {
|
||||
const past = new Date(Date.now() - 1000)
|
||||
const future = new Date(Date.now() + 60_000)
|
||||
assert.equal(worker.isLegDue({ status: 'pending', next_attempt_at: null }, new Date()), true)
|
||||
assert.equal(worker.isLegDue({ status: 'pending', next_attempt_at: past }, new Date()), true)
|
||||
assert.equal(worker.isLegDue({ status: 'pending', next_attempt_at: future }, new Date()), false)
|
||||
assert.equal(worker.isLegDue({ status: 'done', next_attempt_at: null }, new Date()), false)
|
||||
assert.equal(worker.isLegDue({ status: 'failed', next_attempt_at: null }, new Date()), false)
|
||||
})
|
||||
|
||||
// ── Retry, and the label the panel renders ─────────────────────────────────
|
||||
|
||||
test('resetLeg refuses a leg nobody registered', async () => {
|
||||
register(fakeLegs().a)
|
||||
await assert.rejects(() => announceJobs.resetLeg(5, 'rust.motd'), /unknown announce leg/)
|
||||
})
|
||||
|
||||
test('resetLeg creates the row when a module was installed after the job', async () => {
|
||||
// Otherwise the retry button could never deliver a newly-installed module's
|
||||
// leg on an already-announced post: the worker only sees rows that exist.
|
||||
const legs = fakeLegs()
|
||||
register(legs.a, legs.b)
|
||||
const ensured = []
|
||||
announceDb.findByPostId = async () => ({ id: 1, post_id: 5, status: 'partial', legs: [{ leg: 'discord', status: 'done' }] })
|
||||
announceDb.ensureLegs = async (jobId, ids) => ensured.push([jobId, ids])
|
||||
announceDb.updateLeg = async () => {}
|
||||
announceDb.findById = async () => ({ id: 1, post_id: 5, status: 'partial', legs: [{ leg: 'discord', status: 'done' }, { leg: 'rust.motd', status: 'pending' }] })
|
||||
announceDb.setStatus = async () => {}
|
||||
|
||||
const job = await announceJobs.resetLeg(5, 'rust.motd')
|
||||
assert.deepEqual(ensured, [[1, ['rust.motd']]])
|
||||
assert.deepEqual(job.legs.map((l) => l.label), ['Discord #news', 'Server MOTD'])
|
||||
})
|
||||
|
||||
test('a leg’s label comes from its registration, and an orphan keeps its id', async () => {
|
||||
register(fakeLegs().a)
|
||||
announceDb.findByPostId = async () => ({
|
||||
id: 1,
|
||||
post_id: 5,
|
||||
status: 'partial',
|
||||
legs: [{ leg: 'discord', status: 'done' }, { leg: 'gone.leg', status: 'pending' }],
|
||||
})
|
||||
const job = await announceJobs.getByPostId(5)
|
||||
assert.deepEqual(job.legs.map((l) => [l.leg, l.label]), [
|
||||
['discord', 'Discord #news'],
|
||||
['gone.leg', 'gone.leg'],
|
||||
])
|
||||
})
|
||||
|
||||
// ── recordOutcome reads attempts off the row ───────────────────────────────
|
||||
|
||||
test('recordOutcome takes the attempt count from the leg row', async () => {
|
||||
register(fakeLegs().a)
|
||||
const updates = []
|
||||
announceDb.updateLeg = async (jobId, leg, fields) => updates.push(fields)
|
||||
announceDb.findById = async () => ({ id: 1, post_id: 5, status: 'pending', legs: [{ leg: 'discord', status: 'pending' }] })
|
||||
announceDb.setStatus = async () => {}
|
||||
|
||||
const job = { id: 1, post_id: 5, legs: [{ leg: 'discord', status: 'pending', attempts: 2 }] }
|
||||
await announceJobs.recordOutcome(job, 'discord', { outcome: 'retry', error: 'nope' })
|
||||
assert.equal(updates[0].attempts, 3)
|
||||
assert.ok(updates[0].nextAttemptAt instanceof Date)
|
||||
})
|
||||
@@ -27,8 +27,16 @@ const assert = require('node:assert/strict')
|
||||
const express = require('express')
|
||||
|
||||
const db = require('../src/utils/db')
|
||||
const registries = require('../src/modules/registries')
|
||||
const { startApp } = require('./_helper')
|
||||
|
||||
// Requiring the real admin router declares the `admin.users.detail` extension
|
||||
// slot exactly the way production does (users.router.js, at require time). Doing
|
||||
// it here rather than calling declareSlot by hand matters: one test below builds
|
||||
// the real tier routers, and a hand-declared slot would collide with that
|
||||
// require's own declaration.
|
||||
require('../src/router/v1/admin')
|
||||
|
||||
after(() => db.close())
|
||||
|
||||
let tmpRoot
|
||||
@@ -42,6 +50,11 @@ const emptyTiers = () => ({
|
||||
|
||||
function freshLoader(dir, tiers = emptyTiers()) {
|
||||
process.env.MODULES_DIR = dir
|
||||
// The registries are process-global (there is one core), so hand the process
|
||||
// back between tests. Without this a module's staged registrations from a
|
||||
// previous test would still be committed, and every collision assertion below
|
||||
// would be asserting against the wrong history.
|
||||
registries._reset()
|
||||
delete require.cache[require.resolve('../src/modules/loader')]
|
||||
// eslint-disable-next-line global-require
|
||||
const loader = require('../src/modules/loader')
|
||||
@@ -493,13 +506,10 @@ test('ctx exposes exactly the documented surface, and is frozen', () => {
|
||||
assert.equal(probe.mutable, false, 'ctx members must be frozen')
|
||||
})
|
||||
|
||||
test('the register calls PR 4 and PR 5 own throw rather than silently accepting', () => {
|
||||
// An accepting no-op would let a module believe it had registered a
|
||||
// notification stream or a boot hook and fail silently at the far end.
|
||||
test('the register calls PR 5 owns throw rather than silently accepting', () => {
|
||||
// An accepting no-op would let a module believe it had registered a boot hook
|
||||
// and fail silently at the far end.
|
||||
for (const [call, pr] of [
|
||||
['registerExtension', 4],
|
||||
['registerNotificationStreams', 4],
|
||||
['registerAnnounceLeg', 4],
|
||||
['onBoot', 5],
|
||||
['onShutdown', 5],
|
||||
]) {
|
||||
@@ -511,3 +521,56 @@ test('the register calls PR 4 and PR 5 own throw rather than silently accepting'
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
// ── Staged registrations are committed only for a module that survives ─────
|
||||
|
||||
test('a module that fails AFTER registering leaves nothing in the registries', () => {
|
||||
// The registry-side twin of the second-pass mount rule. register() runs before
|
||||
// checkDeclared, so a module can stage a stream catalog and then be rejected —
|
||||
// and a half-registered catalog is worse than a missing one, because it is a
|
||||
// subscribable stream nothing will ever publish to.
|
||||
writeModule('halfway', {
|
||||
manifest: { mounts: { public: ['/declared'] } },
|
||||
server: `module.exports = (ctx, api) => {
|
||||
api.registerNotificationStreams([{ id: 'halfway.thing', label: 'Thing' }])
|
||||
api.registerAnnounceLeg({ leg: 'halfway.leg', label: 'L', dispatch: async () => ({}), classify: () => ({}) })
|
||||
// declared /declared and never registered it → rejected by checkDeclared
|
||||
}`,
|
||||
})
|
||||
const loader = freshLoader(tmpRoot)
|
||||
|
||||
assert.match(stateOf(loader, 'halfway').reason, /declared public\/declared but never registered it/)
|
||||
assert.equal(registries.isValidStream('halfway.thing'), false)
|
||||
assert.equal(registries.announceLeg('halfway.leg'), null)
|
||||
})
|
||||
|
||||
test('a module colliding with an already-registered name fails alone, unmounted', () => {
|
||||
const tiers = emptyTiers()
|
||||
writeModule('first', {
|
||||
manifest: { mounts: { public: ['/first'] } },
|
||||
server: `module.exports = (ctx, api) => {
|
||||
api.registerRoutes({ public: { '/first': ctx.express.Router() } })
|
||||
api.registerNotificationStreams([{ id: 'first.shared', label: 'Shared' }])
|
||||
}`,
|
||||
})
|
||||
writeModule('second', {
|
||||
manifest: { mounts: { public: ['/second'] } },
|
||||
server: `module.exports = (ctx, api) => {
|
||||
api.registerRoutes({ public: { '/second': ctx.express.Router() } })
|
||||
api.registerNotificationStreams([{ id: 'second.ok', label: 'Ok' }, { id: 'first.shared', label: 'Mine' }])
|
||||
}`,
|
||||
})
|
||||
const loader = freshLoader(tmpRoot, tiers)
|
||||
|
||||
assert.equal(stateOf(loader, 'first').state, 'registered')
|
||||
assert.match(stateOf(loader, 'second').reason, /already registered by "first"/)
|
||||
// Not even the claim that did not collide.
|
||||
assert.equal(registries.isValidStream('second.ok'), false)
|
||||
// And the loser is not mounted at all. Asked of the live router the way the
|
||||
// prefix-ownership check asks it, rather than by counting layers — one mount
|
||||
// produces two (the dispatch guard, then the module's router).
|
||||
const claims = (prefix) =>
|
||||
tiers.public.stack.some((l) => l.regexp && !l.regexp.fast_slash && l.match(prefix))
|
||||
assert.equal(claims('/first'), true)
|
||||
assert.equal(claims('/second'), false)
|
||||
})
|
||||
|
||||
216
server/test/moduleRegistries.test.js
Normal file
216
server/test/moduleRegistries.test.js
Normal file
@@ -0,0 +1,216 @@
|
||||
// ── 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 core AND the not-yet-extracted shard content', () => {
|
||||
registries.registerCore()
|
||||
|
||||
const ids = registries.allStreams().map((s) => s.id)
|
||||
// Core's one stream first, then the seven that leave with module-uo.
|
||||
assert.equal(ids[0], 'news.post')
|
||||
assert.equal(ids.length, 8)
|
||||
assert.ok(ids.includes('vendor.sale'))
|
||||
|
||||
assert.deepEqual(registries.announceLegIds(), ['discord', 'towncrier'])
|
||||
assert.equal(registries.slotFilledBy('admin.users.detail'), 'core')
|
||||
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', () => {
|
||||
registries.registerCore()
|
||||
const personal = registries.allStreams().find((s) => s.id === 'vendor.sale')
|
||||
// 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('vendor.sale'))
|
||||
assert.equal(registries.isValidStream('vendor.sale'), 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/)
|
||||
|
||||
registries.registerCore() // core fills it
|
||||
assert.match(
|
||||
tryApply('uo', (a) => a.registerExtension('admin.users.detail', router)),
|
||||
/already filled by "core"/,
|
||||
)
|
||||
})
|
||||
|
||||
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 */
|
||||
|
||||
const [slot] = registries.filledSlots()
|
||||
assert.ok(slot, 'app.js must have registered core, filling the slot')
|
||||
assert.equal(slot.slot, 'admin.users.detail')
|
||||
assert.ok(slot.specFile, 'core names the file its slot router is generated from')
|
||||
assert.equal(findMountPrefix(app._router.stack, slot.router), '/api/v1/admin/users/:id')
|
||||
})
|
||||
@@ -6,8 +6,11 @@ process.env.DB_PORT = '59999'
|
||||
const { test, after } = require('node:test')
|
||||
const assert = require('node:assert/strict')
|
||||
|
||||
const { mapShardEvent, createTracker } = require('../src/config/notificationStreams')
|
||||
const { mapShardEvent, createTracker } = require('../src/config/shardStreams')
|
||||
const pushDispatch = require('../src/utils/pushDispatch')
|
||||
// fromShardEvent moved out of pushDispatch in PR 4: core publishes, the shard
|
||||
// side resolves an event to a stream and an owner (MODULE_SYSTEM.md §1.8).
|
||||
const shardPush = require('../src/utils/shardPush')
|
||||
const db = require('../src/utils/db')
|
||||
|
||||
after(() => db.close())
|
||||
@@ -202,14 +205,14 @@ test('fromShardEvent resolves a personal event to the owning user, or drops it i
|
||||
}
|
||||
const deps = { shardLinks, pushDevices, fetchImpl, tracker: createTracker() }
|
||||
|
||||
await pushDispatch.fromShardEvent({ kind: 'vendor.sale', ownerAcct: 'mine', t: 1 }, deps)
|
||||
await shardPush.fromShardEvent({ kind: 'vendor.sale', ownerAcct: 'mine', t: 1 }, deps)
|
||||
assert.deepEqual(userStreamCalls, [[42, 'vendor.sale']])
|
||||
assert.equal(calls.length, 1)
|
||||
|
||||
// Unlinked account → nobody to notify → no publish.
|
||||
userStreamCalls.length = 0
|
||||
calls.length = 0
|
||||
await pushDispatch.fromShardEvent({ kind: 'vendor.sale', ownerAcct: 'stranger', t: 2 }, deps)
|
||||
await shardPush.fromShardEvent({ kind: 'vendor.sale', ownerAcct: 'stranger', t: 2 }, deps)
|
||||
assert.equal(userStreamCalls.length, 0)
|
||||
assert.equal(calls.length, 0)
|
||||
})
|
||||
@@ -226,7 +229,7 @@ test('fromShardEvent fans a public shard event to the stream’s subscribers', a
|
||||
},
|
||||
endpointsForUserStream: async () => [],
|
||||
}
|
||||
await pushDispatch.fromShardEvent(
|
||||
await shardPush.fromShardEvent(
|
||||
{ kind: 'server.hello', bootId: 'b1' },
|
||||
{ pushDevices, fetchImpl, tracker: createTracker() },
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user