Files
Module-Rust/server/test/_fakes.js
wtclaude 0670341198 feat(rust): slash commands and the next wipe, server half (phase 16)
Five read-only commands registered with api.registerSlashCommands:
/status, /wipe, /top, /online and /clan (D126). Every refusal is private,
and any answer narrower than public (online names, a clan roster) goes
to the caller alone (D127). No command asks a sidecar.

The next wipe (D128, D130): six nullable columns on rust_servers, a pure
nextWipe(row, now) with the zone arithmetic through Intl, computed on
every read. The public server shape gains nextWipe; the admin shape
gains the stored schedule; PUT /admin/rust/servers/:id takes the six
fields and writes them only when wipeRule is present.

server/commands joins ci/bundle.json, which checkBundle caught.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY
2026-09-25 13:29:59 -05:00

171 lines
8.5 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.
events: { emit: spy(undefined), reconcile: 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 }