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
171 lines
8.5 KiB
JavaScript
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 }
|