Files
Integration-kit/template/server/test/entry.test.js
wtclaude f89044b42e
Some checks failed
PR Checks / prose (pull_request) Successful in 12s
PR Checks / template (pull_request) Failing after 29s
feat(kit): the event contract, taught and built (chapter 5)
The fifth chapter, and the template code it teaches out of. Events is the first
thing in the book that goes the other way — chapters 1-4 move data out of the
game and onto a page; an event changes a live world on a schedule, unattended.

**Chapter 5** covers the four declarations (budgets, option sources, leases,
actions), leads with the lease because EVENTS.md §H is right that it is the
primitive that travels and the spawn is the special case, and gives one section
each to the four things that are invisible until an outage: the envelope's
failure default, the idempotency passthrough, recording a resource before
confirming it, and under-declaring `cost`.

**Chapters 3 and 4 gain one section each** for the command plane, because
without them chapter 5 teaches a module to send an idempotency key to a sidecar
the book never told anyone to build a command path in. Both say at the top that
they are skippable until you want chapter 5.

**The template ships one of each declaration**, with `server/sidecarClient.js`
as the near end — a real timeout, a real key passthrough, a simulated transport
in one function marked for replacement. That file is named for the filename
`noGameConnection.test.js` already anticipated, so the test stays green now and
fires correctly the moment `deliver()` becomes a request.

Two things writing it found, both now in the chapter and beside the code:

  * **An idempotency key belongs on a command, never on a question.** The first
    draft keyed every call including the reads; an at-most-once store then
    answers every future read with the first one's reply, forever. The lease
    applied correctly and the module could no longer see it. Hence `ask` and
    `send` as two functions.

  * **A refusal's reason goes in `error`; core reads no other name.** The first
    draft used `detail`, on the strength of the one place EVENTS.md §H mentions
    it, and every refusal it produced was anonymous on the run console.

Proved by running the template's real declarations through core's real registry
at `edge` (all four accepted) and its real envelopes through the real
`events/dispatch.js` classifier.

**CI is RED on `checkCoreApi` and that is the mechanism working.** The template
now declares `coreApi: ^1.10.0` and `ci/core-ref.json` pins the engagement
cutover, where `main` is still 1.9.0. Equality is the check, a bump is meant to
turn this repo red until someone re-reads the chapters, and the pin move rides
in the events cutover (EVENTS_PLAN.md P16) as its own commit. Do not "fix" it.

Refs EVENTS_PLAN.md Phase 15, EVENTS.md §F, MODULE_API.md 1.10.0.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016wDDVXWMDz82WqE1i969r4
2026-09-08 18:10:18 -05:00

354 lines
16 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// ── 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 modules 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 modules triggers, and its own or cores 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)
})