The UO half of protocol 6 part b. No route added, no schema change, no
MODULE_API bump.
`uo.playercaps.skillcap` is the one lease, and the catalog is short because
ServUO made it short: of the 158 non-Bridge `Config.Get` call sites in
`Scripts/`, roughly eight are read live. This one is read inside
`CharacterCreation.cs`'s per-character path, so it is both live and observable --
which is what "proven" has to mean, since the failure an allowlist exists to
prevent is a key that applies cleanly and changes nothing.
Its `apply()` sends a DURATION rather than the deadline: an absolute time
computed here and honoured there is measured against two clocks, and a shard
running ten minutes fast would restore a ten-minute lease the instant it took it.
Its `restore()` turns `lease.drifted` into `{ drifted: true, current }` rather
than an error, because core records drift as a distinct successful outcome and an
error would put the row on the retry ladder. Its `inForce()` asks whether the
shard still HOLDS the lease, never whether the value still matches -- see the
core PR.
`uo.participation.open` / `.collect` count who took part and file them on the
success envelope. `open` is the one resource in this module that must NOT
reconcile by boot stamp: every other resource here lives in shard memory, so a
changed bootId IS the proof it is gone, while the participation ledger is written
into the world save precisely so it survives that restart. It asks instead.
Co-Authored-By: Claude <noreply@anthropic.com>
158 lines
7.5 KiB
JavaScript
158 lines
7.5 KiB
JavaScript
// Test doubles for what core hands the module.
|
|
//
|
|
// The module's server half is testable WITHOUT core, and that is not a
|
|
// convenience — it is the contract holding. Everything the module may touch
|
|
// arrives on `ctx` (MODULE_API.md §2.3), so a `ctx` this file can build is a
|
|
// complete statement of the module's dependencies. If a test ever needs
|
|
// something that is not here, either the module reached past the boundary or
|
|
// §2.3 needs a member; both are worth stopping for.
|
|
//
|
|
// `fakeCtx` 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.
|
|
|
|
const express = require('express')
|
|
|
|
/** Records every call, so a test can assert what a 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() {
|
|
const log = { error: spy(), warn: spy(), info: spy(), debug: spy() }
|
|
return log
|
|
}
|
|
|
|
function fakeCtx(overrides = {}) {
|
|
// `freeze: false` is for _setup.js, which installs one process-wide ctx a test
|
|
// may adjust. Core always freezes; the unfrozen variant is a test seam and
|
|
// never a claim about what a module is handed in production.
|
|
const { freeze = true, ...rest } = overrides
|
|
const logs = []
|
|
const ctx = {
|
|
moduleId: 'uo',
|
|
paths: { moduleRoot: require('path').resolve(__dirname, '..', '..') },
|
|
express,
|
|
validator: require('express-validator'),
|
|
db: { query: spy(Promise.resolve([])), pool: {} },
|
|
log: (namespace) => {
|
|
const log = fakeLog()
|
|
logs.push({ namespace, log })
|
|
return log
|
|
},
|
|
settings: { get: spy(Promise.resolve(null)), set: spy(Promise.resolve()), getInstanceName: spy(Promise.resolve('Test')) },
|
|
auth: { getUserFromRequest: spy(null) },
|
|
push: { publish: spy(Promise.resolve()) },
|
|
// MODULE_API 1.7.0. Both are fire-and-forget and return undefined by
|
|
// contract — a module gets no delivery answer back, deliberately — so the
|
|
// spies return undefined rather than a promise, which is what core does.
|
|
events: { emit: spy(undefined), reconcile: spy(undefined) },
|
|
inbox: { push: spy(undefined) },
|
|
secretBox: { encrypt: spy('enc'), decrypt: spy('dec') },
|
|
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(),
|
|
// API 1.1.0. 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(),
|
|
},
|
|
uploads: { upload: {}, UPLOAD_DIR: '/tmp', MIME_EXT: {} },
|
|
posts: { listAll: spy(Promise.resolve([])), getById: spy(Promise.resolve(null)), linkAnnounceJob: spy(Promise.resolve()), markAnnounced: spy(Promise.resolve()) },
|
|
// The three §2.3 members API 1.1.0 added for this extraction.
|
|
activity: { log: spy(Promise.resolve()) },
|
|
users: { getById: spy(Promise.resolve(null)) },
|
|
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` call would throw on push — which is
|
|
// how this was found. Keeping it off the enumeration also makes the fake more
|
|
// faithful: a module iterating `ctx` sees exactly §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") because a
|
|
* module that registers the same thing twice must fail in its own test suite
|
|
* and not first on an operator's install.
|
|
*/
|
|
function fakeApi() {
|
|
const record = {
|
|
routes: null,
|
|
extensions: [],
|
|
streams: null,
|
|
legs: [],
|
|
teamProvider: null,
|
|
slashCommands: [],
|
|
triggers: null,
|
|
audiences: null,
|
|
eventActions: null,
|
|
eventBudgets: null,
|
|
eventOptionSources: null,
|
|
hooks: {},
|
|
}
|
|
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) },
|
|
// MODULE_API 1.6.0. `once` because core holds a single provider per
|
|
// deployment — a second registration is a collision there, so it has to be
|
|
// one here too, or this suite would pass a shape core rejects at load.
|
|
registerTeamProvider(provider) { once('registerTeamProvider'); record.teamProvider = provider },
|
|
// MODULE_API 1.6.0, live since phase 7. `once` for the same reason core
|
|
// takes it: a second call is a module changing its mind halfway through
|
|
// register(), which core rejects.
|
|
registerSlashCommands(commands) { once('registerSlashCommands'); record.slashCommands = commands },
|
|
// MODULE_API 1.7.0, live since ENGAGEMENT.md Phase 11. `once` on both, for
|
|
// the reason above: core stages a registrant's whole batch and applies it as
|
|
// one, so a second call is a module changing its mind mid-register().
|
|
registerEventTriggers(triggers) { once('registerEventTriggers'); record.triggers = triggers },
|
|
registerAudiences(audiences) { once('registerAudiences'); record.audiences = audiences },
|
|
// MODULE_API 1.9.0 (ENGAGEMENT.md Phase 11b). `once` again, and here it is
|
|
// load-bearing rather than tidy: a rule belongs to exactly ONE named group,
|
|
// and merging two calls would make "which group is this rule in" — the
|
|
// question the one-shot seed guard answers — unanswerable.
|
|
registerEngagementSeeds(seeds) { once('registerEngagementSeeds'); record.engagementSeeds = seeds },
|
|
// MODULE_API 1.10.0 (EVENTS.md F, EVENTS_PLAN.md Phases 7 and 9). `once` on
|
|
// all three, matching core: it stages a registrant's whole batch and applies
|
|
// it as one, so a second call is a module changing its mind mid-register().
|
|
registerEventActions(actions) { once('registerEventActions'); record.eventActions = actions },
|
|
registerEventBudgets(budgets) { once('registerEventBudgets'); record.eventBudgets = budgets },
|
|
registerEventOptionSources(sources) { once('registerEventOptionSources'); record.eventOptionSources = sources },
|
|
// And the fourth, from Phase 11b. `once` for the same reason, and present here
|
|
// for a second one: a verb this module calls and this fake does not have is a
|
|
// TypeError in `entry.test.js` rather than a surprise at somebody's boot.
|
|
registerEventLeases(leases) { once('registerEventLeases'); record.eventLeases = leases },
|
|
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 }
|