Files
Module-uo/server/test/entry.test.js
wtclaude 268449f2a6
Some checks failed
PR Checks / client-build (pull_request) Successful in 16s
PR Checks / server-tests (pull_request) Successful in 21s
PR Checks / frozen-manifest (pull_request) Failing after 44s
feat(teams): answer core's Team provider from the guild board
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>
2026-08-17 15:31:58 -05:00

146 lines
6.4 KiB
JavaScript

// The entry point's contract with core (MODULE_API.md §2.2).
//
// Slice 0 registers nothing, so there is very little behaviour to assert — and
// the rules that DO apply are the ones that would otherwise be discovered on an
// operator's install: registering synchronously, never awaiting, never touching
// a database, never mutating what it was handed. Those hold for every slice
// after this one too, which is why they are tested against the entry point
// rather than against whatever it happens to register today.
const test = require('node:test')
const assert = require('node:assert')
const register = require('../index')
const { fakeCtx, fakeApi } = require('./_fakes')
test('exports a single register function', () => {
assert.strictEqual(typeof register, 'function')
})
test('registers synchronously and returns nothing to await', () => {
const result = register(fakeCtx(), fakeApi())
// Not `assert.strictEqual(result, undefined)` alone: a module that returned a
// promise would be a module whose registration core silently never waits for.
assert.ok(!result || typeof result.then !== 'function', 'register() must not return a thenable')
})
test('touches no database at registration time', () => {
const ctx = fakeCtx()
register(ctx, fakeApi())
assert.deepStrictEqual(ctx.db.query.calls, [], 'register() queried the database')
})
test('registers exactly what module.json declares', () => {
// The loader compares these two in BOTH directions and rejects a mismatch
// either way, so a prefix registered without being declared and a prefix
// declared without being registered are both module-breaking. Asserting
// against the manifest rather than a literal list means the test cannot drift
// from the file core actually reads.
const api = fakeApi()
register(fakeCtx(), api)
const manifest = require('../../module.json')
for (const tier of ['public', 'admin', 'player']) {
assert.deepStrictEqual(
Object.keys(api.record.routes[tier]).sort(),
[...manifest.mounts[tier]].sort(),
`${tier} mounts disagree with module.json`,
)
for (const router of Object.values(api.record.routes[tier])) {
assert.strictEqual(typeof router, 'function', `${tier} router is not a router`)
}
}
assert.deepStrictEqual(api.record.extensions.map((e) => e.slot), manifest.extensions)
assert.deepStrictEqual(api.record.legs.map((l) => l.leg), ['towncrier'])
assert.ok(api.record.streams.length > 0)
assert.strictEqual(typeof api.record.hooks.onBoot, 'function')
assert.strictEqual(typeof api.record.hooks.onShutdown, 'function')
})
test('every registered stream is namespaced or grandfathered', () => {
// Core rejects a stream id that carries neither this module's prefix nor a
// §6.5 grandfathered name. The seven legacy ids are stored in
// `notification_subs` and read by a shipped Android client, so they are
// allowlisted rather than renamed — but a NEW id must be namespaced, and this
// is where that is caught before an install refuses to load the module.
// Copied from core's loader (LEGACY_STREAM_IDS), deliberately rather than
// imported — this repo has no dependency on core's source, and a copy that
// drifts is caught by the module failing to load, which is the failure this
// test exists to move earlier.
const GRANDFATHERED = new Set([
'server.status', 'idoc.warning', 'champ.start', 'governor.election',
'vendor.sale', 'house.idoc', 'account.login',
])
const api = fakeApi()
register(fakeCtx(), api)
for (const s of api.record.streams) {
assert.ok(
s.id.startsWith('uo.') || GRANDFATHERED.has(s.id),
`stream "${s.id}" is neither namespaced "uo." nor grandfathered`,
)
assert.ok(s.label && s.description, `stream "${s.id}" is missing its wire shape`)
assert.strictEqual(typeof s.personal, 'boolean')
assert.strictEqual(typeof s.requiresLinkedAccount, 'boolean')
}
})
test('registers a Team provider with all three methods', () => {
// Core requires all three: a provider that could list Teams but not their
// members would leave core holding Teams it can never populate, which is not
// the same as a call that fails. Asserted here so a refactor that drops one
// fails in this suite rather than at load on an operator's install.
const api = fakeApi()
register(fakeCtx(), api)
const provider = api.record.teamProvider
assert.ok(provider, 'a UO guild is a Team; something has to answer for them')
for (const method of ['getTeams', 'getTeamMembers', 'getTeamLeaders']) {
assert.strictEqual(typeof provider[method], 'function', `${method} is missing`)
}
})
test('registration does not call the provider, or touch the database', async () => {
// register() runs while core's app.js is still being required, with the pool
// pointed at a dead port — routeManifest.js and swagger.js both depend on that.
// Registration is a CLAIM; core does not ask anything until it reconciles,
// which is after onBoot.
const ctx = fakeCtx()
let queried = false
const frozen = Object.freeze({ ...ctx, db: Object.freeze({ query: async () => { queried = true; return [] } }) })
const api = fakeApi()
register(frozen, api)
assert.equal(queried, false, 'a query at registration time would hang the manifest and the spec build')
})
test('takes a frozen ctx and does not try to write to it', () => {
const ctx = fakeCtx()
assert.ok(Object.isFrozen(ctx))
// Core freezes one level deep; a module that assigned to ctx would throw here
// in strict mode and fail silently outside it. Either way it must not.
assert.doesNotThrow(() => register(ctx, fakeApi()))
})
test('logs through ctx.log, never through console', () => {
const ctx = fakeCtx()
register(ctx, fakeApi())
assert.strictEqual(ctx.logs.length, 1, 'expected exactly one logger to be taken')
const { log } = ctx.logs[0]
assert.strictEqual(log.info.calls.length, 1)
assert.strictEqual(log.info.calls[0][0], 'registered')
})
test('carries no hidden state between calls', () => {
// Core calls register() exactly once, and the `once()` guard that enforces
// that lives in core's `api` — not here. What this asserts is the module's
// own half of it: registering into a second `api` produces the same result as
// the first, so nothing is memoised at file scope where a re-register would
// silently do less than it appears to.
const first = fakeApi()
const second = fakeApi()
register(fakeCtx(), first)
register(fakeCtx(), second)
assert.deepStrictEqual(second.record, first.record)
})