// ── 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') })