// ── The registration handshake ──────────────────────────────────────────── // // The one suite every module should have, whatever else it does. Core validates // all of this at boot and refuses to mount a module that fails — so testing it // here is the difference between finding out in half a second and finding out on // an operator's install. const test = require('node:test') const assert = require('node:assert') const { fakeCtx, fakeApi } = require('./_fakes') const manifest = require('../../module.json') /** A fresh registration. `core.js` holds a module-level `ctx`, so reset it. */ function register(ctx = fakeCtx()) { require('../core')._reset() const api = fakeApi() require('../index')(ctx, api) return { api, ctx } } test('registers exactly the mounts module.json declares', () => { const { api } = register() // Core compares these two and rejects a mismatch in EITHER direction: a prefix // declared and never registered is as fatal as a route registered and never // declared. Asserting it against the manifest rather than against a literal is // what keeps the test true after you add a prefix. assert.deepStrictEqual( Object.keys(api.record.routes).sort(), Object.keys(manifest.mounts).sort(), ) for (const [tier, prefixes] of Object.entries(manifest.mounts)) { assert.deepStrictEqual(Object.keys(api.record.routes[tier]).sort(), [...prefixes].sort()) } }) test('every registered mount is a real express router', () => { const { api } = register() for (const byPrefix of Object.values(api.record.routes)) { for (const [prefix, router] of Object.entries(byPrefix)) { assert.strictEqual(typeof router, 'function', `${prefix} is not a router`) assert.ok(router.stack, `${prefix} has no middleware stack`) } } }) test('prefixes are one segment, lowercase, no parameters', () => { // §2.4's rule, restated where a typo is cheap to find. Core enforces it, and a // module that fails it does not mount at all. for (const prefixes of Object.values(manifest.mounts)) { for (const prefix of prefixes) { assert.match(prefix, /^\/[a-z0-9][a-z0-9-]*$/, `illegal mount prefix ${prefix}`) } } }) test('registration touches no database and awaits nothing', () => { const ctx = fakeCtx() register(ctx) // §2.2's first rule. Core requires `app.js` with the pool pointed at a dead // port in two build tools, so a query here would hang both — and the symptom // is a build that never finishes rather than an error naming this module. assert.deepStrictEqual(ctx.db.query.calls, []) }) test('registers both lifecycle hooks', () => { const { api } = register() assert.strictEqual(typeof api.record.hooks.onBoot, 'function') assert.strictEqual(typeof api.record.hooks.onShutdown, 'function') }) test('registers a Team provider, with the three methods core requires', () => { const { api } = register() const provider = api.record.teamProvider assert.ok(provider, 'no Team provider was registered') // All three are required. A provider that could list Teams but not their // members would leave core holding Teams it can never populate — which is not // the same as a call that fails, and core refuses the registration rather than // discovering it at the first sync. for (const method of ['getTeams', 'getTeamMembers', 'getTeamLeaders']) { assert.strictEqual(typeof provider[method], 'function', `provider.${method} is missing`) } // Optional, and asserted because THIS module supplies them. Delete the members // and delete these two lines with them; do not leave a test claiming a contract // you no longer meet. assert.strictEqual(typeof provider.projectRoster, 'function') assert.strictEqual(typeof provider.pageUrlTemplate, 'string') }) test('the Team provider is claimed, not called, at registration time', () => { const ctx = fakeCtx() const { api } = register(ctx) // Registration may not touch the database (§2.2) and every provider method // reads one. That is legal precisely because core does not call any of them // until it reconciles, which is after `onBoot` — so holding the object is the // whole of what happens here. assert.deepStrictEqual(ctx.db.query.calls, []) assert.ok(api.record.teamProvider) }) test('pageUrlTemplate points at a route this module registers', () => { const { api } = register() const template = api.record.teamProvider.pageUrlTemplate // A relative path — core refuses one naming its own host, since there is no // reason for a module to redirect the site's outbound mail. assert.match(template, /^\/[^/]/) assert.ok(template.includes('{externalId}'), 'core substitutes {externalId}; nothing else is a link') // And it must be under this module's own namespace, because that is where core // mounts every route this module registers. Nothing checks the two halves // against each other — the client registers the route, the server declares the // link — so this is the seam where a wrong answer becomes mail linking at a 404. assert.ok(template.startsWith(`/${manifest.id}/`), 'the template is not under this module’s route namespace') }) test('the manifest declares what the loader requires', () => { assert.match(manifest.id, /^[a-z][a-z0-9-]{1,31}$/) assert.match(manifest.version, /^\d+\.\d+\.\d+/) assert.ok(manifest.coreApi, 'coreApi is required — it is the version check') // Declaring a schema without a purge is refused: a module that can create // tables and cannot drop them leaves an operator with orphaned data. if (manifest.schema) assert.ok(manifest.purge, 'a schema fragment requires a purge file') // The chunk must be in a SUBDIRECTORY — the directory it sits in is what core // serves, so an entry in the module root would publish the whole module. if (manifest.client) assert.ok(manifest.client.entry.includes('/'), 'client.entry must be in a subdirectory') }) // ── The engagement seam ─────────────────────────────────────────────────── // // Core validates most of what is declared here at registration, and a module // that gets it wrong does not load. These tests are mostly NOT that validator // restated: they are the rules a module can satisfy at boot and still have got // WRONG in a way whose only symptom is mail somebody received. Where one does // overlap core — the namespacing and subjectKey assertions below — it is because // `npm test` is a cheaper place to meet the failure than a first boot, and the // message here names the field. test('every declared trigger is namespaced, ceilinged, and carries examples', () => { const { api } = register() const triggers = api.record.triggers assert.ok(Array.isArray(triggers) && triggers.length, 'no triggers were declared') for (const t of triggers) { // Trigger ids and notification-stream ids are ONE namespace, so an id must // carry this module's own prefix or it is a claim on somebody else's. assert.ok(t.id.startsWith(`${manifest.id}.`), `${t.id} is not namespaced`) // `ceiling` is required and has no default: there is no safe value to guess. assert.ok(t.ceiling, `${t.id} declares no ceiling`) // Core refuses this one too; failing it here just costs less. What the rule // protects is the cooldown key — "once per world", not "once per user" — and // a subjectKey naming nothing would key every subject on `undefined`. const names = t.variables.map((v) => v.name) assert.ok(names.includes(t.subjectKey), `${t.id}: subjectKey "${t.subjectKey}" is not a variable`) for (const v of t.variables) { // Not decoration: the example is what makes a template previewable and // test-sendable without waiting for a real game event. assert.ok('example' in v, `${t.id}.${v.name} has no example`) // The type set is closed. A payload that needs a structure has outgrown // interpolation, and a template cannot walk one. assert.ok( ['string', 'int', 'float', 'boolean', 'datetime', 'url'].includes(v.type), `${t.id}.${v.name} has type "${v.type}"`, ) // A url is site-relative, because it ends up in an href in a mail somebody // opens days later. if (v.type === 'url') assert.match(v.example, /^\/[^/]/, `${t.id}.${v.name} must be site-relative`) } } }) test('an audience resolves to nobody rather than to everybody when it fails', async () => { const ctx = fakeCtx({ db: { query: () => Promise.reject(new Error('database is down')), pool: {} } }) const { api } = register(ctx) const audience = api.record.audiences[0] // The one behaviour worth a test of its own. Core treats a throw the same way, // so this is not core's guard restated — it is the module choosing the same // answer deliberately, and the reason is that the alternatives are both worse: // "everyone" mails the wrong people and a stale answer mails yesterday's. assert.deepStrictEqual(await audience.resolve({ clanId: 'clan-1' }), []) }) test('a seeded rule names only this module’s triggers, and its own or core’s templates', () => { const { api } = register() const seeds = api.record.engagementSeeds const ownKeys = new Set(seeds.templates.map((t) => t.key)) const coreKeys = new Set(['notify.event', 'inapp.event', 'notify.digest']) for (const t of seeds.templates) { // The key column is UNIQUE across the whole table, so an unprefixed // `notify.event` from a module would collide with core's body and win. assert.ok(t.key.startsWith(`${manifest.id}.`), `template ${t.key} is not namespaced`) // `protected` means "the system breaks without this body" — true of a // password reset and of nothing a module ships. Core refuses a module // template that sets it, because it would take an operator's delete button // away. assert.ok(!('protected' in t), `template ${t.key} may not mark itself protected`) } for (const group of seeds.ruleGroups) { for (const rule of group.rules) { assert.ok( api.record.triggers.some((t) => t.id === rule.trigger_id), `${rule.name} names a trigger this module does not declare`, ) for (const key of Object.values(rule.template_keys)) { assert.ok(ownKeys.has(key) || coreKeys.has(key), `${rule.name} names an unknown template ${key}`) } // `enabled` is not a parameter, and a value passed for it is ignored // rather than refused. Passing one anyway states an intention the platform // will not honour, so the honest thing is not to write it. assert.ok(!('enabled' in rule), `${rule.name} may not seed itself enabled`) } } }) test('every seeded body is a shape the block registry will accept', () => { const { api } = register() // The gap this exists for: `registerEngagementSeeds` checks that `blocks` is a // non-empty array and stops. The BODY is validated by core's block registry, // which runs in the template editor and in the renderer — so a malformed block // registers, seeds, and first shows itself when an operator opens the body or a // rule fires. Core is not here to ask, so assert the two rules that are easy to // get wrong and impossible to notice. const HEADING_LEVELS = ['h1', 'h2', 'h3'] for (const t of api.record.engagementSeeds.templates) { const ids = new Set() for (const block of t.blocks) { // Every block carries its own id, unique within the body: it is how the // editor addresses one block, and how `inapp.event`'s renderer maps blocks // onto the inbox row's columns by role. assert.ok(block.id && typeof block.id === 'string', `${t.key}: a block has no id`) assert.ok(!ids.has(block.id), `${t.key}: two blocks share the id "${block.id}"`) ids.add(block.id) assert.ok(block.type.startsWith('email.'), `${t.key}: ${block.type} is not an email block`) // A heading's `level` is a SIZE token, not a number. `{ level: 2 }` reads // perfectly and is refused, and it renders at the default size in any // preview that skips validation — which is the whole trap. if (block.type === 'email.heading') { assert.ok( HEADING_LEVELS.includes(block.props.level), `${t.key}: heading level "${block.props.level}" must be one of ${HEADING_LEVELS.join(', ')}`, ) } } } }) test('the world event fires on the transition and not on the poll', async () => { const boot = require('../boot') // Online already, and reporting online again. The refresh writes, and nothing // is announced: this runs every thirty seconds, and a rule on an event fired // every thirty seconds mails somebody every thirty seconds. Core's cooldown // would hold — but leaning on it means emitting "still up" and calling it news. const steady = fakeCtx({ db: { query: () => Promise.resolve([{ online: 1 }]), pool: {} } }) require('../core')._reset() require('../core').init(steady) await boot.refresh() assert.deepStrictEqual(steady.events.emit.calls, []) // Offline before, online now. One emit, with the declared payload. const flipped = fakeCtx({ db: { query: () => Promise.resolve([{ online: 0 }]), pool: {} } }) require('../core')._reset() require('../core').init(flipped) await boot.refresh() assert.strictEqual(flipped.events.emit.calls.length, 1) const [triggerId, envelope] = flipped.events.emit.calls[0] assert.strictEqual(triggerId, 'examplegame.world.status_changed') assert.strictEqual(envelope.data.status, 'online') // A fresh install, where there is no previous row at all. Not a change — and // announcing it would tell everyone the world came online the first time an // operator started the site. const fresh = fakeCtx({ db: { query: () => Promise.resolve([]), pool: {} } }) require('../core')._reset() require('../core').init(fresh) await boot.refresh() assert.deepStrictEqual(fresh.events.emit.calls, []) }) test('the four event declarations are registered, each exactly once', () => { const { api } = register() // Every one of the four is optional (§F), so this asserts what THIS module // chose rather than what core requires. What it is really checking is that // `index.js` still hands core the arrays `config/eventActions.js` exports — // the failure it catches is a rename on one side and not the other, which // costs a deployment a capability with nothing red anywhere. assert.ok(Array.isArray(api.record.eventBudgets)) assert.ok(Array.isArray(api.record.eventOptionSources)) assert.ok(Array.isArray(api.record.eventLeases)) assert.ok(Array.isArray(api.record.eventActions)) // `once` on all four: a batch is a module's COMPLETE statement about what it // declares. `fakeApi` throws on a second call, so registering twice fails here. assert.ok(api.record.eventActions.length > 0) }) test('an action may only spend a budget dimension some module declared', () => { const { api } = register() // Core refuses a `cost()` naming an undeclared dimension at save, at the dry // run and at dispatch, because the fix is a module's declaration rather than a // deployment's cap. This module declares everything it spends, so the check is // local; a module spending another module's dimension would have to loosen it. const declared = new Set(api.record.eventBudgets.map((b) => b.id)) for (const action of api.record.eventActions) { const sample = Object.fromEntries(action.params.map((p) => [p.name, p.example])) for (const dimension of Object.keys(action.cost(sample))) { assert.ok(declared.has(dimension), `${action.id} spends undeclared ${dimension}`) } } }) test('a game restart asks core to reconcile, and a first sighting does not', () => { const boot = require('../boot') const sidecar = require('../sidecarClient') const ctx = fakeCtx() require('../core')._reset() require('../core').init(ctx) // First observation is not a restart. Treating it as one would sweep every // ledgered resource on every website deploy, for no news. boot.checkForRestart() assert.deepStrictEqual(ctx.events.reconcile.calls, []) // Same boot id: still nothing. boot.checkForRestart() assert.deepStrictEqual(ctx.events.reconcile.calls, []) // The game came back as something else. Core cannot see this and must be told. sidecar.simulateRestart() boot.checkForRestart() assert.strictEqual(ctx.events.reconcile.calls.length, 1) // And only once for one restart. boot.checkForRestart() assert.strictEqual(ctx.events.reconcile.calls.length, 1) })