A UO guild is a Team. This registers module-uo as the authoritative source of
them (MODULE_API 1.6.0, docs/website/TEAMS.md §2.3) and answers the three
questions core asks, from the board and the roster Protocol 4 put there.
`externalId` is the persistent ServUO `Guild.Id`, which survives a rename -- so
core sees "an id whose name changed" and applies its rename rule rather than an
unrelated new guild appearing beside the old one. That mapping is this module's
to make: only the game knows what identity survives what.
The most important code here is the refusal guard, and it is deliberately
conservative. Core's contract is that module unavailability becomes staleness and
never emptiness, and this module is the only thing that can honour it -- an empty
array from here reads as an authoritative "there are none", and core archives
Teams and departs members from an authoritative answer. Three states refuse: no
uo-link configured, the integration disabled, and the socket not connected.
**The third is the one worth arguing about.** The board is durable and survives an
outage, so serving it while disconnected looks harmless. It is not: core cannot
tell a board five minutes stale from one five days stale, and a complete answer
licenses destruction. There is a test named for that.
A fourth refusal has no equivalent anywhere else: a guild whose roster has not
arrived. Protocol 4's roster comes on its own frames, separately from the
`guild.update` that creates the board row, so there is a real window where a
155-member guild has zero roster rows. The board's own `members` count is the only
thing that distinguishes "the roster is late" from "this guild is empty", and it
is checked -- with the count in the refusal message, because it is the evidence.
The other side is tested too: when the board says zero, an empty roster is the
truth and withholding it would freeze a disbanding guild's membership forever.
Two limitations, both honest and both in the code as comments:
- **`rankLabel` is null.** The wire's roster member is the standard actor object
(`serial`, `name`, `player`, `acct?`, `webId?`) and carries no guild rank.
Inventing a label from the leader flag would be core displaying something this
module made up.
- **One leader, not several.** TEAMS.md §2.5 expects multiple leaders from
`GuildRank.Rank >= 4` and core supports them, but Protocol 4 does not put rank
on the wire, so the only leadership visible here is the board's single
`leader_serial`. Raising it to the full set is a protocol change, not
something this module can fix.
`online` comes from `shard_online` rather than the roster, which carries no
per-member presence and only a board-level count -- the same source the public
"who's online" surface already uses. `userId` prefers the roster's own `web_id`
(what the shard asserted at roster time) and falls back to the `shard_account_links`
join for a member whose row predates their link; resolving it here rather than in
core is the contract, since core reading `shard_account_links` would be core
naming a module's table.
`coreApi` stays `^1.3.0` -- 1.6.0 satisfies it, which is what makes the bump minor.
18 provider tests plus two on the entry point: that all three methods are
registered, and that registration performs no query. The second matters because
register() runs while core's app.js is still being required with the pool pointed
at a dead port, which both routeManifest.js and swagger.js depend on.
`fakeApi` gained `registerTeamProvider` with the same `once` rule core applies --
one provider per deployment, so a second registration has to fail here too rather
than passing a shape core rejects at load.
411 -> 413 tests, all passing.
Refs docs/website/TEAMS.md §2.3, Part 12 phase 2
Co-Authored-By: Claude <noreply@anthropic.com>
123 lines
5.1 KiB
JavaScript
123 lines
5.1 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()) },
|
|
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,
|
|
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 },
|
|
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 }
|