Files
Module-Rust/server/test/_fakes.js
wtclaude b10f11b057
Some checks failed
PR Checks / client-build (pull_request) Successful in 18s
PR Checks / frozen-manifest (pull_request) Failing after 56s
PR Checks / server-tests (pull_request) Successful in 7m45s
fix(rust): protocol 13 step 2 — expiry, plugin loads, the loading hold, NPC names, the link fleet (F2 F5 F6 F7 F8 F13 F14)
The module's half of PLAN_FIXES §6 step 2 (decisions D181-D185, docs#288).

- F13/F14 (D170, D183): `world.expired`, recognisable from protocol 13 by its
  `what`, is handed to core as the resource the zone step ledgered
  (`world`, `<serverId>:<id>`) through ctx.events.expired, which records it
  `expired`. coreApi moves to ^1.11.0 (website#209).
- F8 (D184): `plugin.loaded` / `plugin.unloaded` mark the permission sync dirty
  when the plugin added or removed permissions, so an unresolved grant lands on
  the next tick instead of the fifteen-minute audit.
- Catalogue: plugin.loaded/unloaded, world.expired and lease.expired are staff
  kinds. The last two were never classified (default deny kept them off public
  pages); the test now covers every event kind through protocol 13.
- F7: permission and title pushes hold while the stored hello says
  `worldReady: false` (a human's "sync now" does not); a failed or refused
  permission sync now logs at warn.
- F2 (D185): the killfeed names an NPC attacker — a family (Scientist, Bandit
  guard, Bradley APC…) or the prefab without its variant digits (wolf2 → Wolf).
- F5/F6: a link code is asked of the servers that minted one in the last six
  minutes first, then of the rest, each group in parallel; "unsure" only when
  one of the minting servers is unreachable.
- D182: the admin server list carries the ZoneManager helper's state from the
  hello, and the servers page says what a missing or failed helper costs.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY
2026-09-26 21:35:46 -05:00

173 lines
8.6 KiB
JavaScript

// ── Test doubles for what core hands the module ───────────────────────────
//
// Your server half is testable WITHOUT core, and that is not a convenience — it
// is the contract holding. Everything a module may touch arrives on `ctx`
// (MODULE_API.md §2.3), so a `ctx` this file can build is a complete statement of
// what your module depends on. **If a test ever needs something that is not here,
// either your module reached past the boundary or §2.3 needs a new member.** Both
// are worth stopping for.
//
// The fake mirrors §2.3 member for member — including the freezing, so a module
// that assigns to `ctx.something` fails here the way it would in core.
//
// This file lives under `test/`, which `checkImports.js` treats as not-shipped —
// which is why it may `require('express')` when the module's own routers may not.
// It builds a REAL express Router on purpose: a fake Router would only ever test
// the fake.
const express = require('express')
const expressValidator = require('express-validator')
/** Records every call, so a test can assert what the module asked for. */
function spy(returns) {
const fn = (...args) => {
fn.calls.push(args)
return typeof returns === 'function' ? returns(...args) : returns
}
fn.calls = []
return fn
}
function fakeLog() {
return { error: spy(), warn: spy(), info: spy(), debug: spy() }
}
function fakeCtx(overrides = {}) {
// `freeze: false` is a test seam for a suite that wants to adjust the ctx it
// installed. Core always freezes; the unfrozen variant is never a claim about
// what a module is handed in production.
const { freeze = true, ...rest } = overrides
const logs = []
const ctx = {
moduleId: 'rust',
paths: { moduleRoot: require('path').resolve(__dirname, '..', '..') },
express,
// The REAL express-validator, for the same reason express is real: the admin
// router builds its validation chains at file scope, so `{}` here is not
// something that file can even be required with.
validator: expressValidator,
db: { query: spy(Promise.resolve([])), pool: {} },
log: (namespace) => {
const log = fakeLog()
logs.push({ namespace, log })
return log
},
auth: { getUserFromRequest: spy(null) },
// One user by id. Null by default — an anonymous suite resolves nobody —
// and a test that needs a viewer installs its own.
users: { getById: spy(Promise.resolve(null)) },
// The engagement seam (§2.3). One method, recording, because that is the
// whole of what a module may do with it: fire a declared event and stop.
// Core's own emit is fire-and-forget and returns nothing, so this does too —
// a fake that returned a receipt would invite a module to wait on one.
// `reconcile` joined it at 1.10.0 — the ONE thing the event contract adds to
// `ctx`, because an action is called BY core and is handed what it needs in
// the envelope. Only the module knows when the game restarted, so only the
// module can ask for the sweep.
// `expired` joined at 1.11.0 (PLAN_FIXES D183): the game ended a ledgered
// resource at its own deadline, and only the module hears it happen.
events: { emit: spy(undefined), reconcile: spy(undefined), expired: spy(undefined) },
// Teams (§2.3, 1.6.0). Push only — there is no reader, because a module
// ANSWERS questions about Teams rather than asking them. `publish` and
// `activity.push` resolve like core's; `reconcile` returns nothing, because
// core's returns at once and a fake that returned a promise would invite a
// module to wait on a sweep it does not own.
teams: {
publish: spy(Promise.resolve()),
reconcile: spy(undefined),
activity: { push: spy(Promise.resolve(0)) },
},
// A REVERSIBLE fake, not a recording one. Core's box is AES-256-GCM keyed by
// the deployment's SECRET_ENC_KEY; what a test needs from it is that
// `decrypt(encrypt(x)) === x`, because the bug this module could have is a
// token stored under one shape and read under another. A spy returning a
// constant would pass while proving nothing, and the tag makes an accidental
// plaintext leak visible in an assertion.
secretBox: {
encrypt: (s) => `enc:${s}`,
decrypt: (s) => {
if (typeof s !== 'string' || !s.startsWith('enc:')) throw new Error('not encrypted by this box')
return s.slice(4)
},
},
activity: { log: spy(Promise.resolve()) },
middleware: {
requireAuth: (req, res, next) => next(),
requireRole: () => (req, res, next) => next(),
siteMode: (req, res, next) => next(),
validate: (req, res, next) => next(),
noindex: (req, res, next) => next(),
// The factory returns a pass-through rather than a real limiter: a test
// that tripped a rate limit would be a test whose result depended on how
// many times the suite had run.
rateLimit: (options) => Object.assign((req, res, next) => next(), { options }),
accountChangeLimiter: (req, res, next) => next(),
},
site: { baseUrl: 'http://localhost:5173' },
...rest,
}
// Non-enumerable, and that is not tidiness. Core freezes every object value on
// `ctx` one level deep, so an enumerable recorder hung off it would be frozen
// by the loop below and every `log.info` would throw on push. Keeping it out of
// the enumeration also makes the fake more faithful: a module iterating `ctx`
// sees §2.3's members and nothing a test put there.
Object.defineProperty(ctx, 'logs', { value: logs, enumerable: false })
if (!freeze) return ctx
for (const value of Object.values(ctx)) {
if (value && typeof value === 'object') Object.freeze(value)
}
return Object.freeze(ctx)
}
/**
* The registration api, recording rather than mounting.
*
* Copies core's `once()` rule (§2.4: "calling twice is an error"), so a module
* that registers the same thing twice fails in its own suite rather than first on
* an operator's install.
*/
function fakeApi() {
const record = {
routes: null, extensions: [], streams: null, legs: [], hooks: {}, teamProvider: null,
triggers: null, audiences: null, engagementSeeds: null,
eventBudgets: null, eventOptionSources: null, eventLeases: null, eventActions: null,
slashCommands: null,
}
const called = new Set()
const once = (name) => {
if (called.has(name)) throw new Error(`${name}() called twice`)
called.add(name)
}
const api = {
registerRoutes(mounts) { once('registerRoutes'); record.routes = mounts },
registerExtension(slot, router) { record.extensions.push({ slot, router }) },
registerNotificationStreams(streams) { once('registerNotificationStreams'); record.streams = streams },
registerAnnounceLeg(leg) { record.legs.push(leg) },
registerPostHook(hook) { once('registerPostHook'); record.hooks.post = hook },
// `once` here is not the general rule restated — it is a DIFFERENT rule that
// happens to look the same. The others may not be called twice by ONE module;
// this one holds a single value across the whole deployment, so a second
// module registering a provider collides with the first. A fake cannot see
// the second module, and asserting the half it can see is still worth doing.
registerTeamProvider(provider) { once('registerTeamProvider'); record.teamProvider = provider },
registerEventTriggers(triggers) { once('registerEventTriggers'); record.triggers = triggers },
registerAudiences(audiences) { once('registerAudiences'); record.audiences = audiences },
registerEngagementSeeds(seeds) { once('registerEngagementSeeds'); record.engagementSeeds = seeds },
// The event contract (1.10.0). `once` on all four: a batch is a module's
// COMPLETE statement about what it declares, so a second call is a module
// changing its mind halfway through `register()` rather than adding to it.
registerEventBudgets(budgets) { once('registerEventBudgets'); record.eventBudgets = budgets },
registerEventOptionSources(sources) { once('registerEventOptionSources'); record.eventOptionSources = sources },
registerEventLeases(leases) { once('registerEventLeases'); record.eventLeases = leases },
registerEventActions(actions) { once('registerEventActions'); record.eventActions = actions },
// Core's `once()` holds here too: a module's commands are one batch.
registerSlashCommands(commands) { once('registerSlashCommands'); record.slashCommands = commands },
onBoot(fn) { once('onBoot'); record.hooks.onBoot = fn },
onShutdown(fn) { once('onShutdown'); record.hooks.onShutdown = fn },
}
api.record = record
return api
}
module.exports = { fakeCtx, fakeApi, spy }