test(server): port core's UO suite onto the ctx harness
All checks were successful
PR Checks / client-build (pull_request) Successful in 17s
PR Checks / server-tests (pull_request) Successful in 8m47s

22 test files moved from core, plus the two that were split out of files core
keeps. 351 tests pass.

One change runs through every moved test, and it is the boundary rather than a
chore: core internals can no longer be stubbed by requiring them, because there
are none to require. `../utils/db` and `../model/settings` do not exist here.
What a test controls instead is the ctx core would have handed over, installed
once by test/_setup.js -- which is a better seam anyway, since it is exactly the
surface the contract promises and nothing wider.

The ctx _setup installs is deliberately unfrozen. Core freezes what it hands a
module and entry.test.js still asserts against a frozen one; but a test that
needs settings.get to return a path has to be able to say so.

Two tests changed SHAPE, and that is the boundary too. fromShardEvent used to
assert through publish() into pushDevices and a captured fetch -- which
endpoints were hit, how many requests went out. None of that is this module's
any more: publish is ctx.push.publish, and the device registry and the relay are
behind it. Reaching for them from here would be reaching past ctx. What remains
is what the module owns and is the part worth guarding: a game account resolves
to a website user, a personal target that resolves to nobody is dropped rather
than published, and a sensitive kind never reaches publish at all.

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2026-08-11 12:07:15 -05:00
committed by Claude
parent 740a677f92
commit 6b99d7e220
28 changed files with 4692 additions and 24 deletions

View File

@@ -45,11 +45,18 @@ const { isBuiltin } = require('module')
const MODULE_ROOT = path.resolve(__dirname, '..', '..')
const SERVER_ROOT = path.join(MODULE_ROOT, 'server')
// Dependencies this half is allowed to resolve for itself. Empty, and that is
// the design: everything the server half needs comes from `ctx` (§2.3). A new
// entry here is a real decision — it becomes a package an operator's install
// has to carry — so it should be argued for in a PR, not added in passing.
const ALLOWED_PACKAGES = new Set([])
// Packages the SHIPPED half may resolve for itself: this package's declared
// `dependencies`, and nothing else. Read from package.json rather than listed
// here, so adding one is a visible, reviewable edit to the manifest that also
// changes what CI installs and what the release tarball carries.
//
// Adding a dependency is a real decision. §2.7 permits a module its own, and the
// release tarball carries `server/node_modules` because an operator never builds
// — so every entry is weight in the artifact and a package the operator's
// deployment now runs. Anything core already owns must come from `ctx` instead:
// a second express is a second Router prototype, a second express-rate-limit is
// a second store, and a limit enforced by two independent counters is not the
// limit either of them states.
const SKIP_DIRS = new Set(['node_modules', 'coverage', '.git'])
@@ -58,9 +65,9 @@ const SKIP_DIRS = new Set(['node_modules', 'coverage', '.git'])
const NOT_SHIPPED = [path.join(SERVER_ROOT, 'test'), path.join(SERVER_ROOT, 'scripts')]
const isShipped = (file) => !NOT_SHIPPED.some((d) => file.startsWith(d + path.sep))
const devDependencies = new Set(
Object.keys(JSON.parse(fs.readFileSync(path.join(SERVER_ROOT, 'package.json'), 'utf8')).devDependencies || {}),
)
const manifest = JSON.parse(fs.readFileSync(path.join(SERVER_ROOT, 'package.json'), 'utf8'))
const dependencies = new Set(Object.keys(manifest.dependencies || {}))
const devDependencies = new Set(Object.keys(manifest.devDependencies || {}))
// `require('x')`, `from 'x'`, `import('x')`. Deliberately textual: parsing would
// need a dependency, and a specifier this pattern misses is a specifier written
@@ -135,7 +142,7 @@ function* walk(dir) {
* has never been shown to fail is a check nobody knows the state of — and this
* one guards the acceptance criterion for the whole contract.
*/
function scan(root, moduleRoot = MODULE_ROOT, { shipped = isShipped, dev = devDependencies } = {}) {
function scan(root, moduleRoot = MODULE_ROOT, { shipped = isShipped, deps = dependencies, dev = devDependencies } = {}) {
const violations = []
for (const file of walk(root)) {
const source = stripCommentsAndTemplates(fs.readFileSync(file, 'utf8'))
@@ -151,7 +158,7 @@ function scan(root, moduleRoot = MODULE_ROOT, { shipped = isShipped, dev = devDe
const pkg = specifier.startsWith('@')
? specifier.split('/').slice(0, 2).join('/')
: specifier.split('/')[0]
const allowed = ALLOWED_PACKAGES.has(pkg) || (!shipped(file) && dev.has(pkg))
const allowed = deps.has(pkg) || (!shipped(file) && dev.has(pkg))
// The `node:` prefix can only ever name a builtin, so it never reaches
// node_modules and is safe whatever this Node version enumerates.
const builtin = isBuiltin(specifier) || specifier.startsWith('node:')

View File

@@ -28,6 +28,10 @@ function fakeLog() {
}
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',
@@ -50,10 +54,19 @@ function fakeCtx(overrides = {}) {
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()) },
...overrides,
// 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
@@ -62,6 +75,7 @@ function fakeCtx(overrides = {}) {
// 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)
}

32
server/test/_helper.js Normal file
View File

@@ -0,0 +1,32 @@
// Test helper: start a throwaway Express app on an ephemeral port and return its
// base URL + a close(). Uses the built-in fetch (Node 18+) so tests need no
// extra HTTP dependency. Tests here exercise middleware in isolation and do NOT
// touch the database.
// The module takes express off ctx in shipped code; a test may resolve it
// directly, because test/ never runs inside core's process (checkImports.js
// allows devDependencies there). Same express either way — this repo pins the
// version core declares.
const express = require('express')
async function startApp(configure) {
const app = express()
app.use(express.json())
configure(app)
const server = await new Promise((resolve) => {
const s = app.listen(0, '127.0.0.1', () => resolve(s))
})
const { port } = server.address()
return {
url: `http://127.0.0.1:${port}`,
// `server.close()` stops accepting and waits for open connections to end on
// their own — and node's global fetch keeps its sockets alive, so nothing
// ever ends them. The listener then outlives the test that made it, which
// used to be invisible because the pool held the process open anyway.
close: () => new Promise((resolve) => {
server.closeAllConnections()
server.close(resolve)
}),
}
}
module.exports = { startApp }

27
server/test/_setup.js Normal file
View File

@@ -0,0 +1,27 @@
// Initialise `core` once, before any test file is required.
//
// Loaded via `node --test --require ./test/_setup.js`, the same arrangement
// core's own suite uses. It exists because of the one structural difference
// between testing this module and testing the code when it lived in core:
// nothing here can be stubbed by monkey-patching a core module, because there
// are no core modules to patch. `require('../utils/db')` does not exist. What a
// test controls instead is the `ctx` core would have handed over — which is a
// better seam anyway, since it is exactly the surface the contract promises and
// nothing wider.
//
// The ctx installed here is deliberately NOT frozen. Core freezes what it hands
// a module, and `entry.test.js` asserts the module behaves against a frozen one;
// but a test that needs `settings.get` to return a particular value has to be
// able to say so, and a frozen ctx would mean re-initialising core per test. The
// mutable copy is a test seam, not a claim about what core does.
const core = require('../core')
const { fakeCtx } = require('./_fakes')
const ctx = fakeCtx({ freeze: false })
core.init(ctx)
// Exposed so a test can reach the same object it is running against, e.g.
// `testCtx.settings.get = async () => '/srv/uo'`. There is one ctx per process,
// as there is in core, so a test that changes a member should put it back.
module.exports = { ctx }

View File

@@ -0,0 +1,154 @@
// Ported from core in Phase 3 (MODULE_SYSTEM.md §2.7.1). One change runs through
// every moved test: core internals can no longer be stubbed by requiring them,
// because there are none to require — `../utils/db` and `../model/settings` do
// not exist here. What a test controls instead is the `ctx` core would have
// handed over, installed once by `test/_setup.js`, which is the seam the
// contract actually promises.
// Point the DB at a closed port BEFORE requiring anything that builds the pool,
// so any stray query fails fast instead of hanging the runner. These tests stub
// every model method the controller touches, so the DB is never actually hit.
const { test, after, afterEach } = require('node:test')
const assert = require('node:assert/strict')
const ctrl = require('../router/admin/usersShard.controller')
const { ctx } = require('./_setup')
const users = ctx.users
const shardLinks = require('../model/shardLinks/shardLinks.model')
const shardState = require('../model/shardState/shardState.model')
const shardEvents = require('../model/shardEvents/shardEvents.model')
const { salesForAccounts } = require('../utils/shardSales')
function mockRes() {
return {
statusCode: 200,
body: null,
status(c) {
this.statusCode = c
return this
},
json(b) {
this.body = b
return this
},
}
}
// Save/restore the originals so each test's monkeypatches don't leak.
const originals = {
getById: users.getById,
listForUser: shardLinks.listForUser,
listHousesForAccounts: shardState.listHousesForAccounts,
listOnlineForAccounts: shardState.listOnlineForAccounts,
eventsList: shardEvents.list,
}
afterEach(() => {
users.getById = originals.getById
shardLinks.listForUser = originals.listForUser
shardState.listHousesForAccounts = originals.listHousesForAccounts
shardState.listOnlineForAccounts = originals.listOnlineForAccounts
shardEvents.list = originals.eventsList
})
// ── salesForAccounts util ──────────────────────────────────────────────────
test('salesForAccounts returns [] for an empty account set without hitting the log', async () => {
let called = false
shardEvents.list = async () => {
called = true
return []
}
assert.deepEqual(await salesForAccounts([]), [])
assert.equal(called, false)
})
test('salesForAccounts keeps only sales owned by the given accounts, newest 50', async () => {
const events = []
// 60 sales owned by "mine", plus some owned by "other".
for (let i = 0; i < 60; i++) {
events.push({ t: i, payload: { ownerAcct: 'mine', itemType: 'sword', amount: 1, price: 10, commission: 1 } })
}
events.push({ t: 999, payload: { ownerAcct: 'other', itemType: 'shield', amount: 1, price: 5 } })
shardEvents.list = async () => events
const rows = await salesForAccounts(['mine'])
assert.equal(rows.length, 50) // capped
assert.ok(rows.every((r) => r.ownerAcct === 'mine')) // never leaks "other"
assert.deepEqual(Object.keys(rows[0]).sort(), ['amount', 'commission', 'itemType', 'ownerAcct', 'price', 't'])
})
// ── Controller: unknown user → 404 ─────────────────────────────────────────
for (const handler of ['listAccounts', 'getSales', 'getHouses', 'getOnline']) {
test(`${handler} returns 404 when the user does not exist`, async () => {
users.getById = async () => null
const res = mockRes()
await ctrl[handler]({ params: { id: '404' } }, res)
assert.equal(res.statusCode, 404)
})
}
// ── Controller: scoping to the user's accounts ─────────────────────────────
test('listAccounts returns the users linked accounts', async () => {
users.getById = async () => ({ id: 7, username: 'bob', role: 'player' })
shardLinks.listForUser = async (id) => {
assert.equal(id, 7)
return [{ account: 'acctA' }, { account: 'acctB' }]
}
const res = mockRes()
await ctrl.listAccounts({ params: { id: '7' } }, res)
assert.equal(res.statusCode, 200)
assert.deepEqual(res.body, [{ account: 'acctA' }, { account: 'acctB' }])
})
test('getHouses passes exactly the users accounts to the model', async () => {
users.getById = async () => ({ id: 7 })
shardLinks.listForUser = async () => [{ account: 'acctA' }, { account: 'acctB' }]
let received = null
shardState.listHousesForAccounts = async (accounts) => {
received = accounts
return [{ serial: '0x1', isIdoc: true }]
}
const res = mockRes()
await ctrl.getHouses({ params: { id: '7' } }, res)
assert.deepEqual(received, ['acctA', 'acctB'])
assert.deepEqual(res.body, [{ serial: '0x1', isIdoc: true }])
})
test('getOnline passes exactly the users accounts to the model', async () => {
users.getById = async () => ({ id: 7 })
shardLinks.listForUser = async () => [{ account: 'acctA' }]
let received = null
shardState.listOnlineForAccounts = async (accounts) => {
received = accounts
return [{ serial: '0x2', name: 'Zoe' }]
}
const res = mockRes()
await ctrl.getOnline({ params: { id: '7' } }, res)
assert.deepEqual(received, ['acctA'])
assert.deepEqual(res.body, [{ serial: '0x2', name: 'Zoe' }])
})
test('a user with no linked accounts yields empty sales/houses/online', async () => {
users.getById = async () => ({ id: 7 })
shardLinks.listForUser = async () => []
shardState.listHousesForAccounts = async (a) => (a.length ? [{}] : [])
shardState.listOnlineForAccounts = async (a) => (a.length ? [{}] : [])
shardEvents.list = async () => [{ payload: { ownerAcct: 'someoneElse' } }]
const sales = mockRes()
const houses = mockRes()
const online = mockRes()
await ctrl.getSales({ params: { id: '7' } }, sales)
await ctrl.getHouses({ params: { id: '7' } }, houses)
await ctrl.getOnline({ params: { id: '7' } }, online)
assert.deepEqual(sales.body, [])
assert.deepEqual(houses.body, [])
assert.deepEqual(online.body, [])
})
// getUser is NOT here any more: reading a user is core semantics that had ended
// up in this controller by proximity, and PR 4 moved it back to
// admin.controller.js behind the extension slot (MODULE_SYSTEM.md §1.9). It is
// covered by test/adminUsers.test.js.

View File

@@ -0,0 +1,242 @@
// Ported from core in Phase 3 (MODULE_SYSTEM.md §2.7.1). One change runs through
// every moved test: core internals can no longer be stubbed by requiring them,
// because there are none to require — `../utils/db` and `../model/settings` do
// not exist here. What a test controls instead is the `ctx` core would have
// handed over, installed once by `test/_setup.js`, which is the seam the
// contract actually promises.
// Point the DB at a closed port BEFORE requiring the controllers (their models
// build the pool). Every model call is monkeypatched, so no query runs;
// db.close() at the end releases the pool so the process exits cleanly.
const { test, after, afterEach } = require('node:test')
const assert = require('node:assert/strict')
// The spawn atlas API, public and admin. What is worth asserting here is not the
// SQL (that is the parser suite's job) but the contracts the two surfaces make:
//
// • the public reads project through the visibility framework — v3.md §3.6.1's
// standing rule is that a read path returning shard data and not calling
// projectFeature is a bug, and `atlas` declaring no sensitive fields TODAY is
// exactly why the call has to be there before one does;
// • the public /meta route reports the game world only, never the operator's
// filesystem — the ServUO path, the per-file hashes and any pending refresh
// stay on the admin route;
// • a missing creature is a 404, not an empty 200;
// • an unreadable ServUO tree is a 200 carrying `status: 'unavailable'`, NOT a
// 500. The refresh contract reports outcomes rather than throwing (so boot is
// never blocked by a bad tree), and the admin needs to be told what is wrong
// with their path;
// • a model failure degrades to a 500 rather than a thrown/uncaught error.
const pub = require('../router/public/atlas.controller')
const admin = require('../router/admin/shardAtlas.controller')
const atlas = require('../model/shardAtlas/shardAtlas.model')
const { ctx } = require('./_setup')
const activity = ctx.activity
const visibility = require('../utils/shardVisibility')
// Stub the visibility MODEL rather than the util's exports: project() calls the
// module-internal getConfig, which an exports-level stub would not intercept — it
// would hit the closed DB port and cost a ~10s pool timeout per test before
// falling back to these same defaults.
const visibilityModel = require('../model/shardVisibility/shardVisibility.model')
visibilityModel.listAll = async () => [] // no overrides ⇒ compiled defaults
visibility.viewerLevel = async (req) => req?.viewerLevel || 'anonymous'
// The admin controller logs every action; keep it off the DB.
activity.log = async () => {}
function mockRes() {
return {
statusCode: 200,
body: null,
status(c) {
this.statusCode = c
return this
},
json(b) {
this.body = b
return this
},
}
}
const originals = {
searchCreatures: atlas.searchCreatures,
getCreature: atlas.getCreature,
listRegions: atlas.listRegions,
listLandmarks: atlas.listLandmarks,
listChampions: atlas.listChampions,
publicMeta: atlas.publicMeta,
status: atlas.status,
refresh: atlas.refresh,
approvePending: atlas.approvePending,
rejectPending: atlas.rejectPending,
setServuoPath: atlas.setServuoPath,
}
afterEach(() => Object.assign(atlas, originals))
// ── Public reads ────────────────────────────────────────────────────────
test('getCreatures passes the search through and returns the page shape', async () => {
let seen = null
atlas.searchCreatures = async (opts) => {
seen = opts
return { total: 1, limit: 50, offset: 0, creatures: [{ slug: 'lizardman', name: 'Lizardman' }] }
}
const res = mockRes()
await pub.getCreatures({ query: { q: ' lizard ', facet: 'Felucca', limit: '10', offset: '20' } }, res)
assert.deepEqual(seen, { q: 'lizard', facet: 'Felucca', limit: 10, offset: 20 })
assert.equal(res.body.total, 1)
assert.equal(res.body.creatures[0].slug, 'lizardman')
})
test('getCreatures falls back to the documented defaults when nothing is passed', async () => {
let seen = null
atlas.searchCreatures = async (opts) => {
seen = opts
return { total: 0, limit: 50, offset: 0, creatures: [] }
}
await pub.getCreatures({ query: {} }, mockRes())
assert.deepEqual(seen, { q: '', facet: '', limit: 50, offset: 0 })
})
test('an unknown creature is a 404, not an empty 200', async () => {
atlas.getCreature = async () => null
const res = mockRes()
await pub.getCreature({ params: { slug: 'nosuchthing' }, query: {} }, res)
assert.equal(res.statusCode, 404)
})
test('getCreature returns places and spawners, and `points` stays the COUNT', async () => {
atlas.getCreature = async () => ({
slug: 'lizardman',
name: 'Lizardman',
total: 214,
points: 62,
places: [{ facet: 'Trammel', label: 'Shrines', spawners: 7, maxAlive: 21 }],
spawners: [{ id: 1, facet: 'Trammel', label: 'Shrines', x: 1, y: 2 }],
spawnersTruncated: false,
alsoHere: [],
})
const res = mockRes()
await pub.getCreature({ params: { slug: 'lizardman' }, query: {} }, res)
// The list route uses `points` as a number; the detail route must not quietly
// turn the same key into an array.
assert.equal(typeof res.body.points, 'number')
assert.ok(Array.isArray(res.body.spawners))
assert.equal(res.body.places[0].label, 'Shrines')
})
// ── The projection rule (§3.6.1) ────────────────────────────────────────
test('public reads run through projectFeature, so a locked field can never survive', async () => {
// `atlas` declares no sensitive fields, so nothing here is stripped by a
// FEATURE rule. acct/webId are stripped anyway — they are locked by meaning,
// for every feature, and this is what proves the read path projects at all.
atlas.searchCreatures = async () => ({
total: 1,
limit: 50,
offset: 0,
creatures: [{ slug: 'lizardman', name: 'Lizardman', acct: 'someacct', ownerWebId: 7 }],
})
const res = mockRes()
await pub.getCreatures({ query: {}, viewerLevel: 'anonymous' }, res)
const row = res.body.creatures[0]
assert.equal(row.name, 'Lizardman')
assert.ok(!('acct' in row), 'acct must never reach an anonymous caller')
assert.ok(!('ownerWebId' in row), 'a flattened webId spelling is locked too')
})
test('getMeta reports the game world only — never the operators filesystem', async () => {
// The model is what enforces this; the assertion documents the boundary so a
// future "just return status() here" shortcut fails loudly.
atlas.publicMeta = async () => ({
importedAt: '2026-07-28T00:00:00.000Z',
generatedAt: '2026-07-28T00:00:00.000Z',
counts: { points: 6455, creatures: 800 },
facets: ['Felucca', 'Trammel'],
})
const res = mockRes()
await pub.getMeta({ query: {} }, res)
assert.deepEqual(Object.keys(res.body).sort(), ['counts', 'facets', 'generatedAt', 'importedAt'])
assert.ok(!('path' in res.body))
assert.ok(!('pending' in res.body))
})
test('a model failure degrades to a 500 rather than throwing', async () => {
atlas.listChampions = async () => {
throw new Error('table is gone')
}
const res = mockRes()
await pub.getChampions({ query: {} }, res)
assert.equal(res.statusCode, 500)
})
// ── Admin ───────────────────────────────────────────────────────────────
test('an unreadable tree answers 200 with the reason, not a 500', async () => {
atlas.refresh = async () => ({ status: 'unavailable', reason: 'no Spawns directory', path: '/bad' })
const res = mockRes()
await admin.importAtlas({ body: {}, user: { id: 1 } }, res)
assert.equal(res.statusCode, 200)
assert.equal(res.body.status, 'unavailable')
assert.equal(res.body.reason, 'no Spawns directory')
})
test('import passes `force` through and coerces it to a boolean', async () => {
let seen = null
atlas.refresh = async (opts) => {
seen = opts
return { status: 'unchanged' }
}
await admin.importAtlas({ body: { force: true }, user: { id: 1 } }, mockRes())
assert.deepEqual(seen, { force: true })
})
test('approve applies a staged refresh (facet loss included)', async () => {
let called = false
atlas.approvePending = async () => {
called = true
return { status: 'imported', removedFacets: ['Malas'], counts: { points: 6162 } }
}
const res = mockRes()
await admin.approve({ user: { id: 1 } }, res)
assert.ok(called)
assert.equal(res.body.status, 'imported')
})
test('rejecting when nothing is staged is a 404', async () => {
atlas.rejectPending = async () => ({ status: 'none' })
const res = mockRes()
await admin.reject({ user: { id: 1 } }, res)
assert.equal(res.statusCode, 404)
})
test('setPath trims, persists, and answers with fresh status — it does not import', async () => {
let saved = null
let imported = false
atlas.setServuoPath = async (value) => {
saved = value
}
atlas.refresh = async () => {
imported = true
return { status: 'imported' }
}
atlas.status = async () => ({ configured: true, path: '/srv/servuo', treeReadable: true })
const res = mockRes()
await admin.setPath({ body: { path: ' /srv/servuo ' }, user: { id: 3 } }, res)
assert.equal(saved, '/srv/servuo')
assert.equal(imported, false, 'changing the path must not reload the atlas as a side effect')
assert.equal(res.body.path, '/srv/servuo')
})
test('setPath accepts a blank path (clearing it turns the atlas off)', async () => {
let saved = 'unset'
atlas.setServuoPath = async (value) => {
saved = value
}
atlas.status = async () => ({ configured: false, path: '' })
const res = mockRes()
await admin.setPath({ body: {}, user: { id: 3 } }, res)
assert.equal(saved, '')
assert.equal(res.statusCode, 200)
})

View File

@@ -0,0 +1,227 @@
const { test } = require('node:test')
const assert = require('node:assert/strict')
const {
ClilocFormatError,
parseCliloc,
parseClilocBinary,
parseClilocText,
isCompressedCliloc,
displayText,
isPlaceholderOnly,
} = require('../utils/clilocParse')
// These parsers are pure and fs-free precisely so this suite can run in CI,
// where there is no UO client and no converted cliloc file. Every fixture below
// is built from the real layout, and the strings are verbatim entries from a
// real Cliloc.enu (123,490 entries) rather than invented ones.
// ── Fixture builders ───────────────────────────────────────────────────────
/** Build a plain-format cliloc buffer: 6-byte header, then records. */
function buildBinary(entries, { header1 = 2, header2 = 1 } = {}) {
const parts = [Buffer.alloc(6)]
parts[0].writeInt32LE(header1, 0)
parts[0].writeUInt16LE(header2, 4)
for (const e of entries) {
const text = Buffer.from(e.text, 'utf8')
const head = Buffer.alloc(7)
head.writeInt32LE(e.number, 0)
head.writeUInt8(e.flag ?? 0, 4)
head.writeUInt16LE(text.length, 5)
parts.push(head, text)
}
return Buffer.concat(parts)
}
// ── Binary ─────────────────────────────────────────────────────────────────
test('parseClilocBinary: reads a plain-format table', () => {
const buf = buildBinary([
{ number: 1015012, text: 'Greater Heal' },
{ number: 1023721, text: 'quarter staff' },
{ number: 1025913, flag: 1, text: 'bonnet' },
])
assert.deepEqual(parseClilocBinary(buf), [
{ number: 1015012, flag: 0, text: 'Greater Heal' },
{ number: 1023721, flag: 0, text: 'quarter staff' },
{ number: 1025913, flag: 1, text: 'bonnet' },
])
})
test('parseClilocBinary: length is UNSIGNED 16-bit', () => {
// ServUO's own SDK reads this field into a signed short, which turns any
// string over 32 KB into a negative length. Real tables top out around 12 KB
// so nothing is broken today, but the field is written unsigned and reading it
// that way costs nothing.
const text = 'x'.repeat(40000)
const [entry] = parseClilocBinary(buildBinary([{ number: 1000000, text }]))
assert.equal(entry.text.length, 40000)
})
test('parseClilocBinary: multi-byte UTF-8 survives (length is in BYTES)', () => {
const [entry] = parseClilocBinary(buildBinary([{ number: 1000000, text: 'Ilshenar — Ver Lor Reg' }]))
assert.equal(entry.text, 'Ilshenar — Ver Lor Reg')
})
test('parseClilocBinary: a truncated record body throws rather than importing short', () => {
// The realistic corruption is a half-copied file. It must fail loudly: a
// silently short table renders as "some items named, some not", which is
// indistinguishable from having no table at all.
const buf = buildBinary([{ number: 1023721, text: 'quarter staff' }])
const truncated = buf.subarray(0, buf.length - 4)
assert.throws(() => parseClilocBinary(truncated), (err) => {
assert.ok(err instanceof ClilocFormatError)
assert.equal(err.code, 'TRUNCATED')
return true
})
})
test('parseClilocBinary: a truncated record HEADER throws too', () => {
const buf = Buffer.concat([buildBinary([{ number: 1023721, text: 'quarter staff' }]), Buffer.alloc(3)])
assert.throws(() => parseClilocBinary(buf), (err) => err.code === 'TRUNCATED')
})
test('parseClilocBinary: an empty table (header only) is valid', () => {
assert.deepEqual(parseClilocBinary(buildBinary([])), [])
})
// ── Compressed detection ───────────────────────────────────────────────────
test('isCompressedCliloc: recognises the Mythic marker', () => {
// Every cliloc the client ships opens with a DWORD whose high byte is 0x8E.
// Real first bytes of Cliloc.enu (e8 79 67 8e) and Cliloc.deu (99 5d 26 8e).
assert.equal(isCompressedCliloc(Buffer.from([0xe8, 0x79, 0x67, 0x8e])), true)
assert.equal(isCompressedCliloc(Buffer.from([0x99, 0x5d, 0x26, 0x8e])), true)
assert.equal(isCompressedCliloc(buildBinary([])), false)
})
test('parseCliloc: a compressed file is rejected by NAME, not parsed into nonsense', () => {
// This is the whole reason the marker check exists. Without it the plain
// parser reads compressed bytes as ~19k records of negative ids and 60 KB
// "strings" before dying somewhere in the middle — and the resulting error
// names truncation, which is the wrong problem to hand an operator.
const compressed = Buffer.concat([Buffer.from([0xe8, 0x79, 0x67, 0x8e]), Buffer.alloc(64, 0x41)])
assert.throws(() => parseCliloc(compressed), (err) => {
assert.equal(err.code, 'COMPRESSED')
assert.match(err.message, /CLILOCS\.md/)
return true
})
})
// ── Text ───────────────────────────────────────────────────────────────────
test('parseClilocText: tab-delimited, skipping a header row', () => {
const entries = parseClilocText('number\ttext\n1023721\tquarter staff\n1015012\tGreater Heal\n')
assert.deepEqual(entries, [
{ number: 1023721, flag: 0, text: 'quarter staff' },
{ number: 1015012, flag: 0, text: 'Greater Heal' },
])
})
test('parseClilocText: splits on the FIRST separator only', () => {
// Cliloc text is full of commas. Splitting on all of them would truncate every
// such entry at its first one.
const [entry] = parseClilocText('1044000,a scroll of magery, unfinished\n')
assert.equal(entry.text, 'a scroll of magery, unfinished')
})
test('parseClilocText: unwraps quoted CSV fields and doubled quotes', () => {
const [entry] = parseClilocText('1023721,"a ""quarter"" staff, plain"\n')
assert.equal(entry.text, 'a "quarter" staff, plain')
})
test('parseClilocText: reads an optional flag column', () => {
const [entry] = parseClilocText('1025913\t1\tbonnet\n')
assert.deepEqual(entry, { number: 1025913, flag: 1, text: 'bonnet' })
})
test('parseClilocText: text that is itself a number stays the text', () => {
// `number,text` where text is "100" is indistinguishable from `number,flag`
// with an empty text. Keeping it as the text is the safer miss — the other way
// silently deletes a real entry.
const [entry] = parseClilocText('1000000,100\n')
assert.equal(entry.text, '100')
})
test('parseClilocText: blank lines and # comments are ignored', () => {
const entries = parseClilocText('# exported by hand\n\n1023721\tquarter staff\n\n')
assert.equal(entries.length, 1)
})
test('parseClilocText: a file with no entries is an error, not an empty table', () => {
assert.throws(() => parseClilocText('nothing here\nnor here\n'), (err) => err.code === 'EMPTY')
})
test('parseClilocText: an empty leading field is skipped, not imported as id 0', () => {
// `Number('')` is 0, not NaN, so a line that merely starts with a separator
// would otherwise become a bogus cliloc 0.
assert.throws(() => parseClilocText('\tstray text\n,another\n'), (err) => err.code === 'EMPTY')
})
test('parseClilocText: keeps entries whose text is EMPTY', () => {
// About half of a real table is empty strings (unused ids). They must survive
// parsing — the import layer decides whether to store them, and both input
// formats have to agree on what the file contained.
const entries = parseClilocText('1005008\t\n1023721\tquarter staff\n')
assert.equal(entries.length, 2)
assert.deepEqual(entries[0], { number: 1005008, flag: 0, text: '' })
})
// ── Sniffing ───────────────────────────────────────────────────────────────
test('parseCliloc: sniffs binary vs text from the header, not the extension', () => {
assert.equal(parseCliloc(buildBinary([{ number: 1023721, text: 'quarter staff' }]))[0].text, 'quarter staff')
assert.equal(parseCliloc(Buffer.from('1023721\tquarter staff\n'))[0].text, 'quarter staff')
})
test('parseCliloc: a binary-looking header that is not 2/1 falls through to text', () => {
// The recoverable guess: a mis-sniffed text file says "no entries found",
// while a mis-sniffed binary yields plausible nonsense.
assert.throws(() => parseCliloc(Buffer.from([9, 0, 0, 0, 9, 0, 65, 66])), (err) => err.code === 'EMPTY')
})
// ── Display ────────────────────────────────────────────────────────────────
test('displayText: drops interpolated arguments we never receive', () => {
// The bridge sends a cliloc id, never the property packet that carries the
// arguments, so a name containing them has to be reduced to what is knowable.
assert.equal(displayText('cold damage ~1_val~%'), 'cold damage')
assert.equal(displayText('~1_NAME~ the ~2_TITLE~'), 'the')
})
test('displayText: a string that is nothing but arguments resolves to nothing', () => {
assert.equal(displayText('[~1_stuff~]'), '')
assert.equal(isPlaceholderOnly('[~1_stuff~]'), true)
assert.equal(isPlaceholderOnly('quarter staff'), false)
})
test('displayText: a trailing % is only stripped when a placeholder was removed', () => {
// "cold damage ~1_val~%" loses its % because that % was the unit belonging to
// the number we never had. A string that genuinely ends in one keeps it.
assert.equal(displayText('50%'), '50%')
assert.equal(displayText('cold damage ~1_val~%'), 'cold damage')
})
test('displayText: ordinary names pass through untouched', () => {
assert.equal(displayText('quarter staff'), 'quarter staff')
assert.equal(displayText('a scroll of magery, unfinished'), 'a scroll of magery, unfinished')
assert.equal(displayText(' spiked collar '), 'spiked collar')
})
test('displayText: punctuation is only tidied when a placeholder was removed', () => {
// A shard's custom "Runic Gateway Sigil (v2)" came back as "(v2" while the
// bracket trim was unconditional. A string with no placeholder has no debris
// to clean, so it is left alone apart from whitespace.
assert.equal(displayText('Runic Gateway Sigil (v2)'), 'Runic Gateway Sigil (v2)')
assert.equal(displayText('scroll of power - greater'), 'scroll of power - greater')
assert.equal(displayText('[Companion] Great Dane'), '[Companion] Great Dane')
// …but the debris a placeholder leaves behind is still cleaned.
assert.equal(displayText('[~1_stuff~]'), '')
assert.equal(displayText('cold damage ~1_val~%'), 'cold damage')
})
test('displayText: null and undefined are empty, not "null"', () => {
assert.equal(displayText(null), '')
assert.equal(displayText(undefined), '')
})

View File

@@ -0,0 +1,194 @@
const { test } = require('node:test')
const assert = require('node:assert/strict')
const fs = require('node:fs')
const os = require('node:os')
const path = require('node:path')
const {
ClilocSourceError,
CUSTOM_DIR,
resolveBase,
listCustom,
readSources,
hashSources,
sameSources,
missingSources,
readCliloc,
} = require('../utils/clilocSource')
// The fs layer, exercised against real temp directories rather than mocks —
// the behaviours that matter here (which file wins, what a directory listing
// yields, what happens when one vanishes) are precisely the ones a mock would
// define away.
function tmpdir() {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'cliloc-'))
return dir
}
const tsv = (entries) => entries.map(([n, t]) => `${n}\t${t}`).join('\n') + '\n'
function write(dir, name, contents) {
const file = path.join(dir, name)
fs.mkdirSync(path.dirname(file), { recursive: true })
fs.writeFileSync(file, contents)
return file
}
// ── Resolution ─────────────────────────────────────────────────────────────
test('resolveBase: a directory picks the most specific candidate', () => {
const dir = tmpdir()
// A converted file sitting next to the client's own compressed one must win —
// otherwise pointing at a client folder finds the file that will be rejected.
write(dir, 'cliloc.enu', 'ignored')
write(dir, 'clilocs.tsv', tsv([[1023721, 'quarter staff']]))
assert.equal(path.basename(resolveBase(dir).base), 'clilocs.tsv')
})
test('resolveBase: a file path roots overlays at its DIRECTORY', () => {
// An operator who pointed at a file should not have to re-point at its folder
// just to add a custom/ directory beside it.
const dir = tmpdir()
const file = write(dir, 'clilocs.tsv', tsv([[1, 'a']]))
assert.deepEqual(resolveBase(file), { root: dir, base: file })
})
test('resolveBase: a missing path and an empty path are different errors', () => {
assert.throws(() => resolveBase(''), (err) => err.code === 'NO_PATH')
assert.throws(() => resolveBase(path.join(tmpdir(), 'nope')), (err) => err.code === 'NOT_FOUND')
})
test('resolveBase: a directory with no cliloc file names what it looked for', () => {
assert.throws(() => resolveBase(tmpdir()), (err) => {
assert.ok(err instanceof ClilocSourceError)
assert.equal(err.code, 'NO_FILE')
assert.match(err.message, /clilocs\.tsv/)
return true
})
})
// ── Overlays ───────────────────────────────────────────────────────────────
test('listCustom: no overlay directory is normal, not an error', () => {
const dir = tmpdir()
write(dir, 'clilocs.tsv', tsv([[1, 'a']]))
assert.deepEqual(listCustom(dir), [])
})
test('listCustom: sorted, and only recognised extensions', () => {
const dir = tmpdir()
write(dir, 'clilocs.tsv', tsv([[1, 'a']]))
write(dir, `${CUSTOM_DIR}/b.tsv`, tsv([[2, 'b']]))
write(dir, `${CUSTOM_DIR}/a.csv`, tsv([[3, 'c']]))
write(dir, `${CUSTOM_DIR}/notes.md`, 'ignore me')
assert.deepEqual(listCustom(dir).map((f) => path.basename(f)), ['a.csv', 'b.tsv'])
})
test('readSources: base first, then overlays, with root-relative labels', () => {
const dir = tmpdir()
write(dir, 'clilocs.tsv', tsv([[1, 'a']]))
write(dir, `${CUSTOM_DIR}/shard.tsv`, tsv([[2, 'b']]))
const { files } = readSources(dir)
// Forward-slashed so the same directory read on Windows and Linux fingerprints
// identically — otherwise every boot on one of them looks like a change.
assert.deepEqual(files.map((f) => [f.label, f.kind]), [
['clilocs.tsv', 'base'],
['custom/shard.tsv', 'custom'],
])
})
// ── Merging ────────────────────────────────────────────────────────────────
test('readCliloc: an overlay ADDS ids the base never had', () => {
// The whole point: shards add items, and those carry cliloc ids no stock
// client table has.
const dir = tmpdir()
write(dir, 'clilocs.tsv', tsv([[1023721, 'quarter staff']]))
write(dir, `${CUSTOM_DIR}/shard.tsv`, tsv([[1180001, 'Runic Gateway Sigil']]))
const byNumber = new Map(readCliloc(dir).entries.map((e) => [e.number, e.text]))
assert.equal(byNumber.get(1023721), 'quarter staff')
assert.equal(byNumber.get(1180001), 'Runic Gateway Sigil')
})
test('readCliloc: an overlay OVERRIDES a stock id', () => {
const dir = tmpdir()
write(dir, 'clilocs.tsv', tsv([[1023721, 'quarter staff']]))
write(dir, `${CUSTOM_DIR}/shard.tsv`, tsv([[1023721, 'gnarled staff of testing']]))
const byNumber = new Map(readCliloc(dir).entries.map((e) => [e.number, e.text]))
assert.equal(byNumber.get(1023721), 'gnarled staff of testing')
})
test('readCliloc: later overlays beat earlier ones, deterministically', () => {
const dir = tmpdir()
write(dir, 'clilocs.tsv', tsv([[7, 'base']]))
write(dir, `${CUSTOM_DIR}/01-first.tsv`, tsv([[7, 'first']]))
write(dir, `${CUSTOM_DIR}/02-second.tsv`, tsv([[7, 'second']]))
const byNumber = new Map(readCliloc(dir).entries.map((e) => [e.number, e.text]))
assert.equal(byNumber.get(7), 'second')
})
test('readCliloc: reports what each source contributed', () => {
// An operator who adds an overlay wants to see it took effect; "overrode: 0"
// on a file meant to re-label stock items says it did not.
const dir = tmpdir()
write(dir, 'clilocs.tsv', tsv([[1, 'a'], [2, 'b']]))
write(dir, `${CUSTOM_DIR}/shard.tsv`, tsv([[2, 'B!'], [3, 'c']]))
const { source } = readCliloc(dir)
assert.deepEqual(source.sources, [
{ label: 'clilocs.tsv', kind: 'base', entries: 2, added: 2, overrode: 0 },
{ label: 'custom/shard.tsv', kind: 'custom', entries: 2, added: 1, overrode: 1 },
])
})
test('readCliloc: a malformed overlay names the file it came from', () => {
// "Which of my six overlay files is broken" is otherwise a guessing game.
const dir = tmpdir()
write(dir, 'clilocs.tsv', tsv([[1, 'a']]))
write(dir, `${CUSTOM_DIR}/broken.tsv`, 'no separators here\nnor here\n')
assert.throws(() => readCliloc(dir), (err) => {
assert.equal(err.code, 'EMPTY')
assert.match(err.message, /^custom\/broken\.tsv: /)
return true
})
})
test('readCliloc: a compressed BASE is still rejected by name', () => {
const dir = tmpdir()
write(dir, 'cliloc.enu', Buffer.concat([Buffer.from([0xe8, 0x79, 0x67, 0x8e]), Buffer.alloc(32, 0x41)]))
assert.throws(() => readCliloc(dir), (err) => err.code === 'COMPRESSED')
})
// ── Drift over the SET ─────────────────────────────────────────────────────
test('hashSources: fingerprints every source, and counts the overlays', () => {
const dir = tmpdir()
write(dir, 'clilocs.tsv', tsv([[1, 'a']]))
write(dir, `${CUSTOM_DIR}/shard.tsv`, tsv([[2, 'b']]))
const fp = hashSources(dir)
assert.deepEqual(Object.keys(fp.hashes).sort(), ['clilocs.tsv', 'custom/shard.tsv'])
assert.equal(fp.customCount, 1)
})
test('sameSources: adding or editing an overlay counts as drift', () => {
const dir = tmpdir()
write(dir, 'clilocs.tsv', tsv([[1, 'a']]))
const before = hashSources(dir).hashes
write(dir, `${CUSTOM_DIR}/shard.tsv`, tsv([[2, 'b']]))
const added = hashSources(dir).hashes
assert.equal(sameSources(before, added), false, 'a new overlay is drift')
write(dir, `${CUSTOM_DIR}/shard.tsv`, tsv([[2, 'b changed']]))
const edited = hashSources(dir).hashes
assert.equal(sameSources(added, edited), false, 'an edited overlay is drift')
assert.equal(sameSources(edited, hashSources(dir).hashes), true, 'an untouched set is not')
})
test('missingSources: a vanished source is detected, an added one is not "missing"', () => {
const loaded = { 'clilocs.tsv': 'aaa', 'custom/shard.tsv': 'bbb' }
assert.deepEqual(missingSources({ 'clilocs.tsv': 'aaa' }, loaded), ['custom/shard.tsv'])
assert.deepEqual(missingSources({ ...loaded, 'custom/new.tsv': 'ccc' }, loaded), [])
// Nothing loaded yet (a first import) is not a vanished source.
assert.deepEqual(missingSources({ 'clilocs.tsv': 'aaa' }, null), [])
})

View File

@@ -30,14 +30,59 @@ test('touches no database at registration time', () => {
assert.deepStrictEqual(ctx.db.query.calls, [], 'register() queried the database')
})
test('registers nothing in slice 0', () => {
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)
assert.strictEqual(api.record.routes, null)
assert.strictEqual(api.record.streams, null)
assert.deepStrictEqual(api.record.extensions, [])
assert.deepStrictEqual(api.record.legs, [])
assert.deepStrictEqual(api.record.hooks, {})
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('takes a frozen ctx and does not try to write to it', () => {

View File

@@ -71,11 +71,17 @@ test('declared mounts are single lowercase segments in known tiers', () => {
}
})
test('notification stream ids and announce legs stay namespaced or grandfathered', () => {
// Nothing to check yet — slice 0 registers neither. The assertion that matters
// is that the manifest does not quietly claim capabilities the module does not
// serve, since `GET /api/v1/public/modules` publishes them to clients.
assert.deepStrictEqual(manifest.capabilities || [], [])
assert.deepStrictEqual(manifest.mounts || {}, {})
assert.deepStrictEqual(manifest.extensions || [], [])
test('the manifest claims a coherent bundle', () => {
// `capabilities` is published by GET /api/v1/public/modules and is what a
// client feature-detects against — the SPA and the Android app both read it —
// so it must not claim something the module does not serve. Checked as a shape
// rather than a list: which capabilities exist is a product decision, that
// they are non-empty opaque strings is the contract.
for (const c of manifest.capabilities || []) {
assert.match(c, /^[a-z][a-z0-9-]*$/, `capability "${c}" is not an opaque lowercase id`)
}
// Declaring a mount is what makes the prefix this module's; the loader checks
// the declaration against what register() actually registers (entry.test.js).
assert.ok(Object.keys(manifest.mounts).length > 0, 'a module that mounts nothing serves nothing')
assert.deepStrictEqual(manifest.extensions, ['admin.users.detail'])
})

View File

@@ -0,0 +1,77 @@
// Ported from core in Phase 3 (MODULE_SYSTEM.md §2.7.1). One change runs through
// every moved test: core internals can no longer be stubbed by requiring them,
// because there are none to require — `../utils/db` and `../model/settings` do
// not exist here. What a test controls instead is the `ctx` core would have
// handed over, installed once by `test/_setup.js`, which is the seam the
// contract actually promises.
const { test, beforeEach, afterEach } = require('node:test')
const assert = require('node:assert/strict')
// Exercise the News-gump sync decisions against a fake sidecar client by
// monkeypatching the shared modules newsGump require()s (same instance) — no DB,
// no network.
const uoLinkClient = require('../utils/uoLinkClient')
const { ctx } = require('./_setup')
const settings = ctx.settings
const newsGump = require('../utils/newsGump')
let calls
const saved = {}
beforeEach(() => {
calls = { post: [], del: [] }
saved.postNews = uoLinkClient.postNews
saved.deleteNews = uoLinkClient.deleteNews
saved.get = settings.get
uoLinkClient.postNews = async (article) => { calls.post.push(article); return { ok: true, status: 200 } }
uoLinkClient.deleteNews = async (id) => { calls.del.push(id); return { ok: true, status: 200 } }
settings.get = async () => null // no gump image configured
})
afterEach(() => {
uoLinkClient.postNews = saved.postNews
uoLinkClient.deleteNews = saved.deleteNews
settings.get = saved.get
})
const newsPost = (over = {}) => ({ id: 42, category: 'news', published: true, title: 'Double XP Weekend', excerpt: 'Starts Friday.', body: null, ...over })
test('buildArticle centres the title, links the news list, and respects announce', async () => {
const a = await newsGump.buildArticle(newsPost(), { announce: false })
assert.equal(a.id, '42')
assert.match(a.body, /<CENTER>Double XP Weekend<\/CENTER>/)
assert.match(a.body, /Starts Friday\./)
assert.match(a.url, /\/site\/news$/)
assert.equal(a.announce, false)
})
test('a fresh publish into news pushes with announce=true', async () => {
await newsGump.syncPost(newsPost(), { wasPublished: false, wasNews: false })
assert.equal(calls.post.length, 1)
assert.equal(calls.post[0].announce, true)
assert.equal(calls.del.length, 0)
})
test('an edit of already-published news refreshes silently (announce=false)', async () => {
await newsGump.syncPost(newsPost({ title: 'Edited' }), { wasPublished: true, wasNews: true })
assert.equal(calls.post.length, 1)
assert.equal(calls.post[0].announce, false)
})
test('unpublishing published news pulls the article from the gump', async () => {
await newsGump.syncPost(newsPost({ published: false }), { wasPublished: true, wasNews: true })
assert.equal(calls.post.length, 0)
assert.deepEqual(calls.del, ['42'])
})
test('a draft never-published news post does nothing', async () => {
await newsGump.syncPost(newsPost({ published: false }), { wasPublished: false, wasNews: false })
assert.equal(calls.post.length, 0)
assert.equal(calls.del.length, 0)
})
test('a non-news post (e.g. screenshot) is never pushed', async () => {
await newsGump.syncPost(newsPost({ category: 'screenshot' }), { wasPublished: false, wasNews: false })
assert.equal(calls.post.length, 0)
})

View File

@@ -0,0 +1,109 @@
// Ported from core in Phase 3 (MODULE_SYSTEM.md §2.7.1). One change runs through
// every moved test: core internals can no longer be stubbed by requiring them,
// because there are none to require — `../utils/db` and `../model/settings` do
// not exist here. What a test controls instead is the `ctx` core would have
// handed over, installed once by `test/_setup.js`, which is the seam the
// contract actually promises.
// Staff-location visibility on GET /public/shard/online. The endpoint is
// token-free, so it inspects the caller's session (getUserFromRequest) and only
// includes each staff member's in-game location (map/x/y/z) for admins and
// moderators. Players and the public still see who is online, but not where.
//
// Point the DB at a closed port BEFORE requiring anything that builds the pool,
// so any stray query fails fast instead of hanging. The model + auth are stubbed,
// so the DB is never actually hit.
const { test, after, afterEach } = require('node:test')
const assert = require('node:assert/strict')
const ctrl = require('../router/public/shard.controller')
const shardState = require('../model/shardState/shardState.model')
const { ctx } = require('./_setup')
const auth = ctx.auth
function mockRes() {
return {
statusCode: 200,
body: null,
status(c) {
this.statusCode = c
return this
},
json(b) {
this.body = b
return this
},
}
}
// One online staff member with a location the model would return.
const ONLINE_ROW = { serial: '0x1', name: 'Lady Mod', map: 'Felucca', x: 1495, y: 1628, z: 10 }
const originals = {
listOnlineLinked: shardState.listOnlineLinked,
getUserFromRequest: auth.getUserFromRequest,
}
afterEach(() => {
shardState.listOnlineLinked = originals.listOnlineLinked
auth.getUserFromRequest = originals.getUserFromRequest
})
// Stub the model to return the staff member, and the session to the given viewer.
function setup(viewer) {
shardState.listOnlineLinked = async () => [ONLINE_ROW]
auth.getUserFromRequest = () => viewer
}
const LOCATION_KEYS = ['map', 'x', 'y', 'z']
for (const role of ['admin', 'moderator']) {
test(`getOnline includes location for a ${role}`, async () => {
setup({ id: 1, username: 'staff', role })
const res = mockRes()
await ctrl.getOnline({}, res)
assert.equal(res.statusCode, 200)
assert.equal(res.body.length, 1)
const entry = res.body[0]
assert.equal(entry.name, 'Lady Mod')
assert.equal(entry.serial, '0x1')
assert.equal(entry.map, 'Felucca')
assert.equal(entry.x, 1495)
assert.equal(entry.y, 1628)
assert.equal(entry.z, 10)
})
}
test('getOnline omits location for a logged-in player', async () => {
setup({ id: 2, username: 'joe', role: 'player' })
const res = mockRes()
await ctrl.getOnline({}, res)
assert.equal(res.statusCode, 200)
const entry = res.body[0]
// Still shows they are online…
assert.equal(entry.name, 'Lady Mod')
assert.equal(entry.serial, '0x1')
// …but the location fields are absent entirely (not null/placeholder).
for (const k of LOCATION_KEYS) assert.ok(!(k in entry), `expected "${k}" to be omitted`)
})
test('getOnline omits location for an unauthenticated request', async () => {
setup(null) // getUserFromRequest returns null for anon callers
const res = mockRes()
await ctrl.getOnline({}, res)
assert.equal(res.statusCode, 200)
const entry = res.body[0]
assert.equal(entry.name, 'Lady Mod')
assert.equal(entry.serial, '0x1')
for (const k of LOCATION_KEYS) assert.ok(!(k in entry), `expected "${k}" to be omitted`)
})
// An editor is staff but not admin/moderator — they should not see location.
test('getOnline omits location for an editor', async () => {
setup({ id: 3, username: 'ed', role: 'editor' })
const res = mockRes()
await ctrl.getOnline({}, res)
const entry = res.body[0]
for (const k of LOCATION_KEYS) assert.ok(!(k in entry), `expected "${k}" to be omitted`)
})

View File

@@ -0,0 +1,227 @@
// Ported from core in Phase 3 (MODULE_SYSTEM.md §2.7.1). One change runs through
// every moved test: core internals can no longer be stubbed by requiring them,
// because there are none to require — `../utils/db` and `../model/settings` do
// not exist here. What a test controls instead is the `ctx` core would have
// handed over, installed once by `test/_setup.js`, which is the seam the
// contract actually promises.
const { test, after, afterEach, beforeEach } = require('node:test')
const assert = require('node:assert/strict')
const { EventEmitter } = require('node:events')
// The SSE fan-out is the security boundary (docs/link/v3.md §3.6). Before v3 it
// was a static kind allowlist; now each subscriber carries the audience rung it
// resolved to at subscribe time, and every frame is gated + field-projected per
// viewer. These tests pin the properties that must hold no matter how the config
// is set:
//
// - the admin channel always gets the frame verbatim;
// - a public subscriber never receives an unmapped kind;
// - acct / webId never reach a public subscriber, at any rung below admin;
// - two subscribers at different rungs get different frames from one event;
// - a viewer's rung is frozen at subscribe time, not re-read per frame;
// - if the visibility config can't be read, nothing goes out on public.
const broadcast = require('../utils/shardBroadcast')
const visibility = require('../utils/shardVisibility')
const model = require('../model/shardVisibility/shardVisibility.model')
const shardLinks = require('../model/shardLinks/shardLinks.model')
const originals = {
listAll: model.listAll,
listForUser: shardLinks.listForUser,
getConfig: visibility.getConfig,
viewerLevel: visibility.viewerLevel,
}
beforeEach(() => {
model.listAll = async () => []
shardLinks.listForUser = async () => []
visibility.invalidate()
})
afterEach(() => {
broadcast.closeAll()
model.listAll = originals.listAll
shardLinks.listForUser = originals.listForUser
visibility.getConfig = originals.getConfig
visibility.viewerLevel = originals.viewerLevel
visibility.invalidate()
})
// A fake req/res pair that records everything written to the stream.
function fakeClient() {
const req = new EventEmitter()
const writes = []
const res = {
writeHead() {},
write(chunk) {
writes.push(chunk)
},
end() {},
on() {},
}
// Frames only — drop the SSE comments/retry preamble and keepalive pings.
const frames = () =>
writes
.filter((w) => w.startsWith('data: '))
.map((w) => JSON.parse(w.slice('data: '.length).trim()))
return { req, res, frames }
}
async function subscribeAt(level, channel = 'public') {
const client = fakeClient()
visibility.viewerLevel = async () => level
await broadcast.subscribe(client.req, client.res, channel)
return client
}
const GUILD_FRAME = {
kind: 'guild.update',
id: 7,
name: 'The Nameless',
abbr: 'TN',
leader: { serial: '0x1A2B', name: 'Darrow', acct: 'whitlocktech', webId: '42', player: true },
}
test('the admin channel receives the frame verbatim, acct and webId included', async () => {
const admin = await subscribeAt('admin', 'admin')
await broadcast.broadcast(GUILD_FRAME)
const [frame] = admin.frames()
assert.deepEqual(frame, GUILD_FRAME)
})
test('a public subscriber never sees acct or webId, at any rung below admin', async () => {
for (const level of ['anonymous', 'logged_in', 'player', 'staff']) {
const client = await subscribeAt(level)
await broadcast.broadcast(GUILD_FRAME)
const [frame] = client.frames()
assert.ok(frame, `${level} should receive the guild frame`)
assert.equal(frame.leader.name, 'Darrow')
assert.equal('acct' in frame.leader, false, `${level} must not see acct`)
assert.equal('webId' in frame.leader, false, `${level} must not see webId`)
broadcast.closeAll()
}
})
test('an unmapped kind reaches the admin channel and nobody else', async () => {
const anon = await subscribeAt('anonymous')
const staff = await subscribeAt('staff')
const admin = await subscribeAt('admin', 'admin')
for (const kind of ['audit.command', 'cheat.fastwalk', 'account.login.attempt', 'vendor.sale']) {
await broadcast.broadcast({ kind, secret: true })
}
assert.deepEqual(anon.frames(), [])
assert.deepEqual(staff.frames(), [])
assert.equal(admin.frames().length, 4)
})
test('one event yields different frames for subscribers at different rungs', async () => {
model.listAll = async () => [
{
feature: 'guilds',
enabled: true,
audience: 'anonymous',
stream: true,
fieldRules: { abbr: 'staff' },
},
]
visibility.invalidate()
const anon = await subscribeAt('anonymous')
const staff = await subscribeAt('staff')
await broadcast.broadcast(GUILD_FRAME)
assert.equal('abbr' in anon.frames()[0], false)
assert.equal(staff.frames()[0].abbr, 'TN')
// Both still lose the locked fields.
assert.equal('acct' in staff.frames()[0].leader, false)
})
test('raising a feature audience cuts off the lower rungs mid-stream', async () => {
const anon = await subscribeAt('anonymous')
const player = await subscribeAt('player')
await broadcast.broadcast(GUILD_FRAME)
assert.equal(anon.frames().length, 1)
assert.equal(player.frames().length, 1)
// Config changes DO take effect live — only the viewer's rung is frozen.
model.listAll = async () => [
{ feature: 'guilds', enabled: true, audience: 'player', stream: true, fieldRules: {} },
]
visibility.invalidate()
await broadcast.broadcast(GUILD_FRAME)
assert.equal(anon.frames().length, 1, 'anonymous stops receiving')
assert.equal(player.frames().length, 2, 'player keeps receiving')
})
test("a subscriber's rung is frozen at subscribe time", async () => {
const client = await subscribeAt('anonymous')
// Even if the resolver would now say "admin", the open connection must not
// gain privilege — its level was captured when it subscribed.
visibility.viewerLevel = async () => 'admin'
await broadcast.broadcast({ kind: 'audit.command', command: 'ban' })
assert.deepEqual(client.frames(), [])
})
test('an unresolvable viewer subscribes as anonymous, not as privileged', async () => {
const client = fakeClient()
visibility.viewerLevel = async () => {
throw new Error('session lookup exploded')
}
await broadcast.subscribe(client.req, client.res, 'public')
await broadcast.broadcast({ kind: 'audit.command', command: 'ban' })
assert.deepEqual(client.frames(), [])
// ...but it still receives ordinary public traffic.
await broadcast.broadcast({ kind: 'champ.update', serial: '0x1' })
assert.equal(client.frames().length, 1)
})
test('an unreadable visibility config withholds every public frame', async () => {
const client = await subscribeAt('anonymous')
visibility.getConfig = async () => {
throw new Error('db down')
}
await broadcast.broadcast(GUILD_FRAME)
assert.deepEqual(client.frames(), [])
})
test('a disabled feature stops its kinds without touching others', async () => {
model.listAll = async () => [
{ feature: 'champs', enabled: false, audience: 'anonymous', stream: true, fieldRules: {} },
]
visibility.invalidate()
const client = await subscribeAt('anonymous')
await broadcast.broadcast({ kind: 'champ.update', serial: '0x1' })
await broadcast.broadcast({ kind: 'guild.update', id: 7 })
const kinds = client.frames().map((f) => f.kind)
assert.deepEqual(kinds, ['guild.update'])
})
test('a dead client is dropped rather than repeatedly retried', async () => {
const client = fakeClient()
visibility.viewerLevel = async () => 'anonymous'
await broadcast.subscribe(client.req, client.res, 'public')
assert.equal(broadcast.stats().publicClients, 1)
client.res.write = () => {
throw new Error('EPIPE')
}
await broadcast.broadcast({ kind: 'champ.update', serial: '0x1' })
assert.equal(broadcast.stats().publicClients, 0)
})
test('broadcast is a no-op for a malformed event', async () => {
const client = await subscribeAt('anonymous')
await broadcast.broadcast(null)
await broadcast.broadcast({})
assert.deepEqual(client.frames(), [])
})

View File

@@ -0,0 +1,415 @@
// Ported from core in Phase 3 (MODULE_SYSTEM.md §2.7.1). One change runs through
// every moved test: core internals can no longer be stubbed by requiring them,
// because there are none to require — `../utils/db` and `../model/settings` do
// not exist here. What a test controls instead is the `ctx` core would have
// handed over, installed once by `test/_setup.js`, which is the seam the
// contract actually promises.
// Point the DB at a closed port BEFORE requiring the controller (its models build
// the pool). Every model call is monkeypatched, so no query runs; db.close() at
// the end releases the pool so the process exits cleanly.
const { test, after, afterEach } = require('node:test')
const assert = require('node:assert/strict')
// Unit-test the public shard controller's SECURITY BOUNDARIES and shaping — the
// bits that decide what the anonymous public may and may not see:
// - getFeed serves only kinds on the public allowlist (staff audit / cheat /
// login events are stored for the admin channel and must never leak here);
// - getHouses exposes only IDOC houses and only their location — owner, price,
// co-owners and decay detail are staff-only and must be stripped;
// - getStatus assembles the connection/economy summary;
// - a model failure degrades to a 500, never a thrown/uncaught error.
const ctrl = require('../router/public/shard.controller')
const shardEvents = require('../model/shardEvents/shardEvents.model')
const shardState = require('../model/shardState/shardState.model')
const uoLinkConfig = require('../model/uoLinkConfig/uoLinkConfig.model')
const broadcast = require('../utils/shardBroadcast')
const visibility = require('../utils/shardVisibility')
// The controller now resolves the visibility config and the caller's rung on
// every read. Stub the MODEL rather than the util's exports: getConfig() and
// project() call the module-internal getConfig, which an exports-level stub does
// not intercept — it would still hit the closed DB port and cost a ~10s pool
// timeout per test before falling back to these same defaults.
const visibilityModel = require('../model/shardVisibility/shardVisibility.model')
visibilityModel.listAll = async () => [] // no overrides ⇒ compiled defaults
visibility.viewerLevel = async (req) => req?.viewerLevel || 'anonymous'
const DEFAULTS = visibility.compileDefaults()
function mockRes() {
return {
statusCode: 200,
body: null,
status(c) {
this.statusCode = c
return this
},
json(b) {
this.body = b
return this
},
}
}
const originals = {
eventsList: shardEvents.list,
listIdoc: shardState.listIdoc,
onlineCount: shardState.onlineCount,
latestEconomy: shardState.latestEconomy,
getRuleset: shardState.getRuleset,
getSafe: uoLinkConfig.getSafe,
}
afterEach(() => {
shardEvents.list = originals.eventsList
shardState.listIdoc = originals.listIdoc
shardState.onlineCount = originals.onlineCount
shardState.latestEconomy = originals.latestEconomy
shardState.getRuleset = originals.getRuleset
uoLinkConfig.getSafe = originals.getSafe
})
// ── getFeed: the public-safe allowlist is a security boundary ───────────
test('getFeed refuses a kind that is not on the public allowlist (returns [], no query)', async () => {
let queried = false
shardEvents.list = async () => {
queried = true
return [{ kind: 'staff.audit' }]
}
const res = mockRes()
await ctrl.getFeed({ query: { kind: 'staff.audit' } }, res) // an admin-only kind
assert.deepEqual(res.body, [])
assert.equal(queried, false, 'a disallowed kind is rejected before any DB read')
})
test('getFeed serves a specific kind when it IS public-safe', async () => {
const publicKind = [...broadcast.PUBLIC_KINDS][0]
let seen
shardEvents.list = async (opts) => {
seen = opts
return [{ kind: publicKind }]
}
const res = mockRes()
await ctrl.getFeed({ query: { kind: publicKind, limit: 5 } }, res)
assert.equal(seen.kind, publicKind)
assert.equal(seen.limit, 5)
assert.equal(res.body[0].kind, publicKind)
})
test('getFeed with no kind restricts the query to the kinds THIS viewer may read', async () => {
let seen
shardEvents.list = async (opts) => {
seen = opts
return []
}
await ctrl.getFeed({ query: {} }, mockRes())
// Resolved from the LIVE config, not the module-load PUBLIC_KINDS constant, so
// an admin re-gating a feature takes effect on the stored history too.
assert.deepEqual(new Set(seen.kinds), new Set(visibility.visibleKinds('anonymous', DEFAULTS)))
// Sanity: a known admin-only kind is absent from what the public feed queries.
assert.ok(!seen.kinds.includes('staff.audit'))
// The `stream` flag governs SSE fan-out only, so a feature whose live firehose
// ships off is still readable from history — the one way this set is WIDER
// than PUBLIC_KINDS.
for (const kind of broadcast.PUBLIC_KINDS) assert.ok(seen.kinds.includes(kind))
assert.ok(seen.kinds.includes('vendor.listing'))
assert.ok(!broadcast.PUBLIC_KINDS.has('vendor.listing'))
})
test('getFeed projects each row against ITS OWN kind\'s feature', async () => {
shardEvents.list = async () => [
{
id: 1,
kind: 'player.death',
payload: { kind: 'player.death', actor: { serial: '0x1', name: 'Doomed', acct: 'secret', webId: 99 } },
},
{
id: 2,
kind: 'guild.join',
payload: { kind: 'guild.join', actor: { serial: '0x2', name: 'Joiner', acct: 'secret2', webId: 98 } },
},
]
const res = mockRes()
await ctrl.getFeed({ query: {} }, res)
for (const row of res.body) {
assert.equal(row.payload.actor.acct, undefined, `${row.kind} leaked acct`)
assert.equal(row.payload.actor.webId, undefined, `${row.kind} leaked webId`)
assert.ok(row.payload.actor.name, 'the in-game name is still public')
}
})
test('getFeed serves nothing when the viewer may read no kinds at all', async () => {
let queried = false
shardEvents.list = async () => {
queried = true
return [{ kind: 'staff.audit' }]
}
const allGated = Object.fromEntries(
Object.entries(DEFAULTS).map(([name, f]) => [name, { ...f, enabled: false }]),
)
visibility.getConfig = async () => allGated
const res = mockRes()
await ctrl.getFeed({ query: {} }, res)
visibility.getConfig = async () => DEFAULTS
assert.deepEqual(res.body, [])
// An empty allowlist must never fall through to an unfiltered "give me
// everything" query.
assert.equal(queried, false)
})
// ── getHouses: the public house view must strip owner/price ─────────────
test('getHouses exposes only IDOC location fields and strips owner/price/decay', async () => {
shardState.listIdoc = async () => [
{
serial: 1,
name: 'Keep',
region: 'Britain',
map: 'Felucca',
x: 1,
y: 2,
z: 3,
// The following are staff-only and must NOT appear in the public payload:
ownerName: 'Lord British',
ownerAcct: 'secret',
price: 999999,
coOwners: 'a,b',
decay: 'IDOC',
},
]
const res = mockRes()
await ctrl.getHouses({}, res)
const [h] = res.body
assert.deepEqual(Object.keys(h).sort(), ['isIdoc', 'map', 'name', 'region', 'serial', 'x', 'y', 'z'])
assert.equal(h.isIdoc, true)
assert.equal(h.ownerName, undefined)
assert.equal(h.price, undefined)
assert.equal(h.coOwners, undefined)
})
// ── getIdoc: the flattened owner fields are a security boundary too ──────
test('getIdoc never serves the owner game account to a viewer below admin', async () => {
shardState.listIdoc = async () => [
{
serial: '0x1',
name: 'Marble Tower',
region: 'Britain',
map: 'Felucca',
x: 1,
y: 2,
z: 3,
ownerSerial: '0x2A01',
ownerName: 'Sir Cadmus',
ownerAcct: 'cadmus_acct', // flattened spelling of the locked `acct`
price: 1250000,
isIdoc: true,
},
]
for (const level of ['anonymous', 'logged_in', 'player', 'staff']) {
const res = mockRes()
await ctrl.getIdoc({ viewerLevel: level }, res)
assert.equal(res.body[0].ownerAcct, undefined, `${level} saw the owner's game account`)
}
const res = mockRes()
await ctrl.getIdoc({ viewerLevel: 'admin' }, res)
assert.equal(res.body[0].ownerAcct, 'cadmus_acct', 'admin still sees it')
})
test('getIdoc gates owner identity and price at `staff`, but never the location', async () => {
shardState.listIdoc = async () => [
{ serial: '0x1', name: 'Marble Tower', region: 'Britain', map: 'Felucca', x: 1, y: 2, z: 3,
ownerSerial: '0x2A01', ownerName: 'Sir Cadmus', price: 1250000, isIdoc: true },
]
const anon = mockRes()
await ctrl.getIdoc({ viewerLevel: 'anonymous' }, anon)
assert.equal(anon.body[0].ownerName, undefined)
assert.equal(anon.body[0].ownerSerial, undefined)
assert.equal(anon.body[0].price, undefined)
// The public IDOC board still renders: name, region and location survive.
assert.equal(anon.body[0].name, 'Marble Tower')
assert.equal(anon.body[0].region, 'Britain')
assert.equal(anon.body[0].map, 'Felucca')
const staff = mockRes()
await ctrl.getIdoc({ viewerLevel: 'staff' }, staff)
assert.equal(staff.body[0].ownerName, 'Sir Cadmus')
assert.equal(staff.body[0].price, 1250000)
})
test('getIdoc preserves Date columns rather than flattening them to {}', async () => {
const when = new Date('2026-07-06T19:32:29.000Z')
shardState.listIdoc = async () => [
{ serial: '0x1', name: 'Marble Tower', isIdoc: true, lastRefreshed: when, updatedAt: when },
]
const res = mockRes()
await ctrl.getIdoc({ viewerLevel: 'anonymous' }, res)
assert.ok(res.body[0].updatedAt instanceof Date, 'a Date must survive projection intact')
assert.equal(res.body[0].updatedAt.toISOString(), when.toISOString())
})
// ── getRuleset: "never published" is a real answer ──────────────────────
test('getRuleset serves null when the shard has never published a ruleset', async () => {
shardState.getRuleset = async () => null
const res = mockRes()
await ctrl.getRuleset({ viewerLevel: 'anonymous' }, res)
// Deliberately null, not {} — the page says "not published yet" rather than
// rendering an empty ruleset as though the shard had no rules.
assert.equal(res.body, null)
assert.equal(res.statusCode, 200)
})
test('getRuleset serves the published ruleset whole, nested blocks intact', async () => {
shardState.getRuleset = async () => ({
kind: 'world.ruleset',
rev: '1a2b3c4d',
shard: 'UOMysticmoon',
expansion: 'EJ',
systems: { cityLoyalty: true, vvv: true, factions: false },
caps: { skill: 1000, totalSkill: 7000, stat: 225 },
champions: { powerScrolls: 6, rankThresholds: [5, 10, 13] },
})
const res = mockRes()
await ctrl.getRuleset({ viewerLevel: 'anonymous' }, res)
assert.equal(res.body.expansion, 'EJ')
assert.equal(res.body.systems.vvv, true)
assert.equal(res.body.caps.totalSkill, 7000)
// Arrays must survive projection as arrays, not become objects.
assert.deepEqual(res.body.champions.rankThresholds, [5, 10, 13])
})
// §3.6.1's rule: a read path that returns shard data and does not project is a
// bug. The ruleset frame carries no actor today, but it goes through the same
// gate — so a future block that does cannot leak.
test('getRuleset projects: acct/webId never survive below admin', async () => {
shardState.getRuleset = async () => ({
expansion: 'EJ',
connect: 'play.example.com,2593',
owner: { name: 'Lord British', acct: 'lb_acct', webId: 7 },
})
for (const level of ['anonymous', 'logged_in', 'player', 'staff']) {
const res = mockRes()
await ctrl.getRuleset({ viewerLevel: level }, res)
assert.equal(res.body.owner.acct, undefined, `${level} saw acct`)
assert.equal(res.body.owner.webId, undefined, `${level} saw webId`)
// `connect` defaults to the anonymous rung: an operator who published it
// meant it to be readable.
assert.equal(res.body.connect, 'play.example.com,2593')
}
})
// ── points boards ──────────────────────────────────────────────────────
const BOARD = {
system: 'QueensLoyalty',
nameString: "Queen's Loyalty",
nameNumber: 1114938,
maxPoints: 30000,
players: 842,
top: [
{ rank: 1, serial: '0x1A2B', name: 'Darrow', points: 29500 },
{ rank: 2, serial: '0x1A2C', name: 'Mireille', points: 21000 },
],
}
test('getPointsBoards serves every board with its ranked list intact', async () => {
shardState.listPointsBoards = async () => [BOARD]
const res = mockRes()
await ctrl.getPointsBoards({ viewerLevel: 'anonymous' }, res)
assert.equal(res.body.length, 1)
assert.equal(res.body[0].system, 'QueensLoyalty')
// The ranked list is an ARRAY through projection, not an object keyed 0/1 —
// the same trap the ruleset's rankThresholds assertion guards.
assert.ok(Array.isArray(res.body[0].top))
assert.equal(res.body[0].top[1].name, 'Mireille')
})
test('getPointsBoards serves an empty list before the shard has published any', async () => {
shardState.listPointsBoards = async () => []
const res = mockRes()
await ctrl.getPointsBoards({ viewerLevel: 'anonymous' }, res)
assert.deepEqual(res.body, [])
assert.equal(res.statusCode, 200)
})
// §3.6.1's rule again: a shard read that does not project is a bug. Boards carry
// no actor today — they write entries inline as {serial, name} precisely so they
// never carry acct/webId — but the gate is what keeps that true if the shape grows.
test('getPointsBoard projects: acct/webId never survive below admin', async () => {
shardState.getPointsBoard = async () => ({
system: 'QueensLoyalty',
top: [{ rank: 1, name: 'Darrow', acct: 'darrow_acct', webId: 9, points: 1 }],
})
for (const level of ['anonymous', 'logged_in', 'player', 'staff']) {
const res = mockRes()
await ctrl.getPointsBoard({ params: { system: 'QueensLoyalty' }, viewerLevel: level }, res)
assert.equal(res.body.top[0].acct, undefined, `${level} saw acct`)
assert.equal(res.body.top[0].webId, undefined, `${level} saw webId`)
assert.equal(res.body.top[0].name, 'Darrow', 'the ranked name is public by default')
}
})
// "No such system" and "a board nobody has scored in" are different answers.
test('getPointsBoard 404s for a system the shard has never published', async () => {
shardState.getPointsBoard = async () => null
const res = mockRes()
await ctrl.getPointsBoard({ params: { system: 'NoSuchSystem' }, viewerLevel: 'anonymous' }, res)
assert.equal(res.statusCode, 404)
})
test('getPointsBoard rejects a malformed system name before touching the model', async () => {
let queried = false
shardState.getPointsBoard = async () => { queried = true; return null }
for (const system of ['../etc', 'a'.repeat(64), '', 'has space', '1leading']) {
const res = mockRes()
await ctrl.getPointsBoard({ params: { system }, viewerLevel: 'anonymous' }, res)
assert.equal(res.statusCode, 400, `${JSON.stringify(system)} should be rejected`)
}
assert.equal(queried, false, 'a malformed name must never reach the query')
})
test('getPointsBoards degrades to a 500 when the model fails, without throwing', async () => {
shardState.listPointsBoards = async () => {
throw new Error('pool down')
}
const res = mockRes()
await ctrl.getPointsBoards({ viewerLevel: 'anonymous' }, res)
assert.equal(res.statusCode, 500)
assert.equal(res.body.message, 'Internal Server Error')
})
test('getRuleset degrades to a 500 when the model fails, without throwing', async () => {
shardState.getRuleset = async () => {
throw new Error('pool down')
}
const res = mockRes()
await ctrl.getRuleset({ viewerLevel: 'anonymous' }, res)
assert.equal(res.statusCode, 500)
assert.equal(res.body.message, 'Internal Server Error')
})
// ── getStatus assembles the summary ─────────────────────────────────────
test('getStatus merges the sidecar config with the online count and latest economy', async () => {
uoLinkConfig.getSafe = async () => ({
enabled: true,
status: 'connected',
pluginConnected: true,
lastEventAt: 'ts',
})
shardState.onlineCount = async () => 12
shardState.latestEconomy = async () => ({ gold: 100, accounts: 3, t: 1 })
const res = mockRes()
await ctrl.getStatus({}, res)
assert.equal(res.body.enabled, true)
assert.equal(res.body.onlineCount, 12)
assert.equal(res.body.economy.gold, 100)
})
test('getStatus degrades to a 500 when a model call fails, without throwing', async () => {
uoLinkConfig.getSafe = async () => {
throw new Error('pool down')
}
const res = mockRes()
await ctrl.getStatus({}, res) // must resolve, not reject
assert.equal(res.statusCode, 500)
assert.equal(res.body.message, 'Internal Server Error')
})

View File

@@ -0,0 +1,71 @@
const { test, beforeEach } = require('node:test')
const assert = require('node:assert/strict')
const shardIngest = require('../utils/shardIngest')
// Build a set of stub deps that record the champ/page/state calls the dispatcher
// makes, plus a spy shardEvents.append and broadcast. Only the methods the tested
// kinds touch need to be real; the rest are no-op async so ingest() never throws.
function makeDeps() {
const calls = { champUpsert: [], champRemove: [], pageUpsert: [], pageRemove: [], appended: [], broadcast: [] }
const noop = async () => {}
return {
calls,
shardEvents: { append: async (row) => { calls.appended.push(row); return true } },
shardState: {
upsertChamp: async (ev) => { calls.champUpsert.push(ev) },
removeChamp: async (serial) => { calls.champRemove.push(serial) },
upsertPage: async (ev) => { calls.pageUpsert.push(ev) },
removePage: async (id) => { calls.pageRemove.push(id) },
// Unused by these kinds but present so any stray routing is a no-op.
clearOnline: noop, upsertOnline: noop, setOffline: noop, upsertHouse: noop, addEconomySample: noop,
},
uoLinkConfig: { recordStatus: noop },
broadcast: (ev) => { calls.broadcast.push(ev) },
// No-op push fan-out so ingest() stays hermetic (no real relay/DB).
pushDispatch: async () => {},
log: { warn() {}, info() {}, error() {} },
}
}
beforeEach(() => shardIngest.reset())
test('champ.update routes to shardState.upsertChamp and is not written to the event log', async () => {
const deps = makeDeps()
const ev = { kind: 'champ.update', serial: '0x1', category: 'champion', name: 'Abyss', status: 'active', t: 1 }
const r = await shardIngest.ingest(ev, deps)
assert.equal(deps.calls.champUpsert.length, 1)
assert.equal(deps.calls.champUpsert[0].serial, '0x1')
assert.equal(r.logged, false) // champ.* is state-only, not appended to shard_events
assert.equal(deps.calls.appended.length, 0)
assert.equal(deps.calls.broadcast.length, 1) // still broadcast live
})
test('champ.remove routes to shardState.removeChamp', async () => {
const deps = makeDeps()
await shardIngest.ingest({ kind: 'champ.remove', serial: '0x2', t: 2 }, deps)
assert.deepEqual(deps.calls.champRemove, ['0x2'])
})
test('page.new and page.updated upsert the page; page.closed removes it', async () => {
const deps = makeDeps()
await shardIngest.ingest({ kind: 'page.new', pageId: '0x24C', type: 'Bug', sender: { name: 'Al' }, t: 3 }, deps)
await shardIngest.ingest({ kind: 'page.updated', pageId: '0x24C', handled: true, t: 4 }, deps)
await shardIngest.ingest({ kind: 'page.closed', pageId: '0x24C', t: 5 }, deps)
assert.equal(deps.calls.pageUpsert.length, 2)
assert.deepEqual(deps.calls.pageRemove, ['0x24C'])
})
test('admin.audit is appended to the event log (moderation history)', async () => {
const deps = makeDeps()
const r = await shardIngest.ingest({ kind: 'admin.audit', action: 'ban', actor: 'web:jane', target: 'griefer', t: 6 }, deps)
assert.equal(r.logged, true)
assert.equal(deps.calls.appended.length, 1)
assert.equal(deps.calls.appended[0].kind, 'admin.audit')
})
test('champ.remove without a serial is a harmless no-op', async () => {
const deps = makeDeps()
await shardIngest.ingest({ kind: 'champ.remove', t: 7 }, deps)
assert.deepEqual(deps.calls.champRemove, [undefined])
})

View File

@@ -0,0 +1,114 @@
const { test, beforeEach } = require('node:test')
const assert = require('node:assert/strict')
const shardIngest = require('../utils/shardIngest')
// Protocol 3.0 vendor.listing / vendor.listing.remove routing. Same shape as
// shardIngest.points.test.js: stubbed deps, asserting where the dispatcher sends
// the frame and whether it is appended to the event log.
function makeDeps() {
const calls = { upserts: [], removes: [], appended: [], broadcast: [] }
const noop = async () => {}
return {
calls,
shardEvents: { append: async (row) => { calls.appended.push(row); return true } },
shardState: {
// Present so any stray routing is a harmless no-op rather than a crash.
clearOnline: noop, upsertOnline: noop, setOffline: noop, upsertHouse: noop,
addEconomySample: noop, setRuleset: noop, upsertPointsBoard: noop,
},
shardMarket: {
upsertVendor: async (ev) => { calls.upserts.push(ev) },
removeVendor: async (serial) => { calls.removes.push(serial) },
},
shardLinks: { removeByAccount: noop },
uoLinkConfig: { recordStatus: noop },
broadcast: (ev) => { calls.broadcast.push(ev) },
pushDispatch: async () => {},
log: { warn() {}, info() {}, error() {} },
}
}
const FRAME = {
kind: 'vendor.listing',
t: 1000,
serial: '0x40001234',
shopName: "Darrow's Bargains",
ownerSerial: '0x1A2B',
ownerName: 'Darrow',
location: { map: 'Trammel', x: 1421, y: 1699, z: 0, region: 'Britain', house: "Darrow's Villa" },
count: 2,
total: 2,
truncated: false,
items: [
{ serial: '0x40012ABC', itemId: 3922, hue: 0, amount: 1, price: 25000, name: null, cliloc: 1023721 },
{ serial: '0x40012ABD', itemId: 7026, hue: 1157, amount: 3, price: 500, name: 'a shard sigil', cliloc: 1041243 },
],
}
beforeEach(() => shardIngest.reset())
test('vendor.listing routes to the market model with the whole frame', async () => {
const deps = makeDeps()
await shardIngest.ingest(FRAME, deps)
assert.equal(deps.calls.upserts.length, 1)
const stored = deps.calls.upserts[0]
assert.equal(stored.serial, '0x40001234')
assert.equal(stored.location.region, 'Britain')
assert.equal(stored.items.length, 2)
})
test('vendor.listing.remove routes to removeVendor with the serial', async () => {
const deps = makeDeps()
await shardIngest.ingest({ kind: 'vendor.listing.remove', t: 2000, serial: '0x40001234' }, deps)
assert.deepEqual(deps.calls.removes, ['0x40001234'])
assert.equal(deps.calls.upserts.length, 0)
})
// The market IS the state. One frame carries up to 250 listings and the sweep
// re-emits a shop on any price change, so logging would turn shard_events into a
// price history nobody reads — the strongest case of the three v3 kinds.
test('neither market kind is appended to the event log', async () => {
const deps = makeDeps()
const a = await shardIngest.ingest(FRAME, deps)
const b = await shardIngest.ingest({ kind: 'vendor.listing.remove', serial: '0x40001234' }, deps)
assert.equal(a.logged, false)
assert.equal(b.logged, false)
assert.equal(deps.calls.appended.length, 0)
assert.equal(shardIngest.LOGGED_KINDS.has('vendor.listing'), false)
assert.equal(shardIngest.LOGGED_KINDS.has('vendor.listing.remove'), false)
})
// Broadcast is unconditional at this layer — whether it actually reaches anyone
// is shardBroadcast's call, and the market feature ships with its stream off.
test('vendor.listing is handed to the broadcaster', async () => {
const deps = makeDeps()
await shardIngest.ingest(FRAME, deps)
assert.equal(deps.calls.broadcast.length, 1)
assert.equal(deps.calls.broadcast[0].kind, 'vendor.listing')
})
test('a backfilled vendor.listing still stores but does not broadcast', async () => {
const deps = makeDeps()
await shardIngest.ingest(FRAME, { ...deps, fromBackfill: true })
assert.equal(deps.calls.upserts.length, 1)
assert.equal(deps.calls.broadcast.length, 0)
})
// The reconnect backfill replays the whole index through this path, so a single
// bad vendor must not abort it.
test('an upsertVendor failure does not throw or stop the broadcast', async () => {
const deps = makeDeps()
deps.shardMarket.upsertVendor = async () => { throw new Error('db down') }
const r = await shardIngest.ingest(FRAME, deps)
assert.equal(r.logged, false)
assert.equal(deps.calls.broadcast.length, 1)
})
// Each vendor is its own row; the frame is authoritative for that vendor only.
test('two vendors are stored independently', async () => {
const deps = makeDeps()
await shardIngest.ingest(FRAME, deps)
await shardIngest.ingest({ ...FRAME, serial: '0x40009999', shopName: 'Second Shop' }, deps)
assert.deepEqual(deps.calls.upserts.map((v) => v.serial), ['0x40001234', '0x40009999'])
})

View File

@@ -0,0 +1,111 @@
const { test, beforeEach } = require('node:test')
const assert = require('node:assert/strict')
const shardIngest = require('../utils/shardIngest')
// Protocol 3.0 points.board routing. Same shape as shardIngest.ruleset.test.js:
// stubbed deps, asserting where the dispatcher sends the frame and whether it is
// appended to the event log.
function makeDeps() {
const calls = { boards: [], appended: [], broadcast: [] }
const noop = async () => {}
return {
calls,
shardEvents: { append: async (row) => { calls.appended.push(row); return true } },
shardState: {
upsertPointsBoard: async (ev) => { calls.boards.push(ev) },
// Present so any stray routing is a harmless no-op.
clearOnline: noop, upsertOnline: noop, setOffline: noop, upsertHouse: noop,
addEconomySample: noop, setRuleset: noop,
},
shardLinks: { removeByAccount: noop },
uoLinkConfig: { recordStatus: noop },
broadcast: (ev) => { calls.broadcast.push(ev) },
pushDispatch: async () => {},
log: { warn() {}, info() {}, error() {} },
}
}
const FRAME = {
kind: 'points.board',
t: 1000,
system: 'QueensLoyalty',
nameString: "Queen's Loyalty",
nameNumber: 1114938,
maxPoints: 30000,
showOnGump: true,
players: 842,
top: [
{ rank: 1, serial: '0x1A2B', name: 'Darrow', points: 29500 },
{ rank: 2, serial: '0x1A2C', name: 'Mireille', points: 21000 },
],
}
beforeEach(() => shardIngest.reset())
test('points.board routes to upsertPointsBoard with the whole frame', async () => {
const deps = makeDeps()
await shardIngest.ingest(FRAME, deps)
assert.equal(deps.calls.boards.length, 1)
const stored = deps.calls.boards[0]
assert.equal(stored.system, 'QueensLoyalty')
assert.equal(stored.nameNumber, 1114938)
assert.equal(stored.players, 842)
// The ranked list must survive intact — the read model serves it from the payload.
assert.equal(stored.top.length, 2)
assert.equal(stored.top[0].name, 'Darrow')
})
// A board is state, not an event. The shard emits a frame every time anyone's
// score moves the top ten, so logging would grow shard_events without bound for
// something whose only interesting value is its latest version — the same call
// guild.update already makes.
test('points.board is NOT appended to the event log', async () => {
const deps = makeDeps()
const r = await shardIngest.ingest(FRAME, deps)
assert.equal(r.logged, false)
assert.equal(deps.calls.appended.length, 0)
assert.equal(shardIngest.LOGGED_KINDS.has('points.board'), false)
})
test('points.board is broadcast (the leaderboards page updates live)', async () => {
const deps = makeDeps()
await shardIngest.ingest(FRAME, deps)
assert.equal(deps.calls.broadcast.length, 1)
assert.equal(deps.calls.broadcast[0].kind, 'points.board')
})
test('a backfilled points.board still stores but does not broadcast', async () => {
const deps = makeDeps()
await shardIngest.ingest(FRAME, { ...deps, fromBackfill: true })
assert.equal(deps.calls.boards.length, 1)
assert.equal(deps.calls.broadcast.length, 0)
})
// Each system is its own row, so two systems must not collide — this is the whole
// reason the frame is per-system rather than one board of everything.
test('two systems are stored independently', async () => {
const deps = makeDeps()
await shardIngest.ingest(FRAME, deps)
await shardIngest.ingest({ ...FRAME, system: 'CleanUpBritannia', nameString: null }, deps)
assert.deepEqual(deps.calls.boards.map((b) => b.system), ['QueensLoyalty', 'CleanUpBritannia'])
})
// A re-emitted board is an overwrite of one row, never an append.
test('a repeated points.board overwrites rather than accumulating', async () => {
const deps = makeDeps()
await shardIngest.ingest(FRAME, deps)
await shardIngest.ingest({ ...FRAME, t: 2000, players: 843 }, deps)
assert.equal(deps.calls.appended.length, 0)
assert.equal(deps.calls.boards.length, 2) // two writes...
assert.equal(deps.calls.boards[1].system, 'QueensLoyalty') // ...of the same row
})
// A model write that throws must not kill the feed.
test('an upsertPointsBoard failure does not throw or stop the broadcast', async () => {
const deps = makeDeps()
deps.shardState.upsertPointsBoard = async () => { throw new Error('db down') }
const r = await shardIngest.ingest(FRAME, deps)
assert.equal(r.logged, false)
assert.equal(deps.calls.broadcast.length, 1)
})

View File

@@ -0,0 +1,121 @@
const { test, beforeEach } = require('node:test')
const assert = require('node:assert/strict')
const shardIngest = require('../utils/shardIngest')
// Stub deps recording the Protocol 2.0 board calls the dispatcher makes. Only the
// methods the tested kinds touch need to be real; the rest are no-op async so
// ingest() never throws on an unrelated kind.
function makeDeps() {
const calls = {
guildUpsert: [], guildRemove: [],
governorUpsert: [],
presenceSet: [],
houseRegistry: [], houseRemove: [],
linkRemove: [],
appended: [], broadcast: [],
}
const noop = async () => {}
return {
calls,
shardEvents: { append: async (row) => { calls.appended.push(row); return true } },
shardState: {
upsertGuild: async (ev) => { calls.guildUpsert.push(ev) },
removeGuild: async (id) => { calls.guildRemove.push(id) },
upsertGovernor: async (ev) => { calls.governorUpsert.push(ev) },
setPresence: async (ev) => { calls.presenceSet.push(ev) },
upsertHouseRegistry: async (ev) => { calls.houseRegistry.push(ev) },
removeHouse: async (serial) => { calls.houseRemove.push(serial) },
// Present so any stray routing is a harmless no-op.
clearOnline: noop, upsertOnline: noop, setOffline: noop, upsertHouse: noop,
addEconomySample: noop,
},
shardLinks: { removeByAccount: async (account) => { calls.linkRemove.push(account) } },
uoLinkConfig: { recordStatus: noop },
broadcast: (ev) => { calls.broadcast.push(ev) },
// No-op push fan-out so ingest() stays hermetic (no real relay/DB).
pushDispatch: async () => {},
log: { warn() {}, info() {}, error() {} },
}
}
beforeEach(() => shardIngest.reset())
test('guild.update routes to upsertGuild and is not logged; guild.remove routes to removeGuild', async () => {
const deps = makeDeps()
const r = await shardIngest.ingest({ kind: 'guild.update', id: 1042, name: 'TSH', t: 1 }, deps)
assert.equal(deps.calls.guildUpsert.length, 1)
assert.equal(deps.calls.guildUpsert[0].id, 1042)
assert.equal(r.logged, false) // board state, not appended to shard_events
await shardIngest.ingest({ kind: 'guild.remove', id: 1042, t: 2 }, deps)
assert.deepEqual(deps.calls.guildRemove, [1042])
})
test('guild.join is appended to the event log (real-time joins feed) and broadcast', async () => {
const deps = makeDeps()
const r = await shardIngest.ingest(
{ kind: 'guild.join', id: 1042, who: { name: 'Bran' }, t: 3 }, deps)
assert.equal(r.logged, true)
assert.equal(deps.calls.appended.length, 1)
assert.equal(deps.calls.appended[0].kind, 'guild.join')
assert.equal(deps.calls.broadcast.length, 1)
})
test('city.update routes to upsertGovernor (which also captures term history)', async () => {
const deps = makeDeps()
await shardIngest.ingest(
{ kind: 'city.update', city: 'Britain', governor: { serial: '0x1', name: 'Darrow' }, t: 4 }, deps)
assert.equal(deps.calls.governorUpsert.length, 1)
assert.equal(deps.calls.governorUpsert[0].city, 'Britain')
})
test('presence.online routes to setPresence and is not logged', async () => {
const deps = makeDeps()
const r = await shardIngest.ingest(
{ kind: 'presence.online', count: 42, byRegion: { Britain: 18 }, t: 5 }, deps)
assert.equal(deps.calls.presenceSet.length, 1)
assert.equal(deps.calls.presenceSet[0].count, 42)
assert.equal(r.logged, false)
})
test('house.update routes to upsertHouseRegistry; house.remove routes to removeHouse', async () => {
const deps = makeDeps()
await shardIngest.ingest({ kind: 'house.update', serial: '0x40001234', name: 'Anvil', t: 6 }, deps)
assert.equal(deps.calls.houseRegistry.length, 1)
assert.equal(deps.calls.houseRegistry[0].serial, '0x40001234')
await shardIngest.ingest({ kind: 'house.remove', serial: '0x40001234', t: 7 }, deps)
assert.deepEqual(deps.calls.houseRemove, ['0x40001234'])
})
test('region.enter is broadcast-only — not logged, no state side effect', async () => {
const deps = makeDeps()
const r = await shardIngest.ingest(
{ kind: 'region.enter', from: 'Britain', to: 'Despise', who: { name: 'Darrow' }, t: 8 }, deps)
assert.equal(r.logged, false)
assert.equal(deps.calls.appended.length, 0)
assert.equal(deps.calls.broadcast.length, 1) // still surfaced live
})
test('account.unlinked reconciles the local link mirror and is logged', async () => {
const deps = makeDeps()
const r = await shardIngest.ingest(
{ kind: 'account.unlinked', origin: 'in-game', account: 'bob', websiteUserId: '9931', t: 9 }, deps)
assert.deepEqual(deps.calls.linkRemove, ['bob']) // mirror dropped
assert.equal(r.logged, true) // provisioning audit trail
assert.equal(deps.calls.appended[0].kind, 'account.unlinked')
})
test('account.audit is logged (provisioning history) but has no state side effect', async () => {
const deps = makeDeps()
const r = await shardIngest.ingest(
{ kind: 'account.audit', origin: 'web', action: 'create', actor: 'web:jane', target: 'bob', t: 10 }, deps)
assert.equal(r.logged, true)
assert.equal(deps.calls.linkRemove.length, 0)
assert.equal(deps.calls.appended[0].kind, 'account.audit')
})
test('account.audit / account.unlinked are NOT on the public SSE allowlist', () => {
const broadcast = require('../utils/shardBroadcast')
assert.equal(broadcast.PUBLIC_KINDS.has('account.audit'), false)
assert.equal(broadcast.PUBLIC_KINDS.has('account.unlinked'), false)
})

View File

@@ -0,0 +1,161 @@
const { test, beforeEach } = require('node:test')
const assert = require('node:assert/strict')
const shardIngest = require('../utils/shardIngest')
// Protocol 3.0 world.ruleset routing. Same shape as shardIngest.protocol2.test.js:
// stubbed deps, asserting where the dispatcher sends the frame and whether it is
// appended to the event log.
function makeDeps() {
const calls = { rulesetSet: [], appended: [], broadcast: [] }
const noop = async () => {}
return {
calls,
shardEvents: { append: async (row) => { calls.appended.push(row); return true } },
shardState: {
setRuleset: async (ev) => { calls.rulesetSet.push(ev) },
// Present so any stray routing is a harmless no-op.
clearOnline: noop, upsertOnline: noop, setOffline: noop, upsertHouse: noop,
addEconomySample: noop,
},
shardLinks: { removeByAccount: noop },
uoLinkConfig: { recordStatus: noop },
broadcast: (ev) => { calls.broadcast.push(ev) },
pushDispatch: async () => {},
log: { warn() {}, info() {}, error() {} },
}
}
const FRAME = {
kind: 'world.ruleset',
t: 1000,
rev: '1a2b3c4d',
shard: 'UOMysticmoon',
expansion: 'EJ',
systems: { cityLoyalty: true, vvv: true, factions: false },
caps: { skill: 1000, totalSkill: 7000 },
}
beforeEach(() => shardIngest.reset())
test('world.ruleset routes to setRuleset with the whole frame', async () => {
const deps = makeDeps()
await shardIngest.ingest(FRAME, deps)
assert.equal(deps.calls.rulesetSet.length, 1)
const stored = deps.calls.rulesetSet[0]
assert.equal(stored.rev, '1a2b3c4d')
assert.equal(stored.expansion, 'EJ')
// The nested blocks must survive intact — the read model serves the frame whole.
assert.equal(stored.systems.vvv, true)
assert.equal(stored.caps.totalSkill, 7000)
})
// The shard re-emits world.ruleset on EVERY sidecar connect. Logging it would put
// a duplicate row in shard_events per reconnect, and server.hello already marks
// each of those — so this assertion is the guard on that decision.
test('world.ruleset is NOT appended to the event log', async () => {
const deps = makeDeps()
const r = await shardIngest.ingest(FRAME, deps)
assert.equal(r.logged, false)
assert.equal(deps.calls.appended.length, 0)
assert.equal(shardIngest.LOGGED_KINDS.has('world.ruleset'), false)
})
test('world.ruleset is broadcast (the rules page updates live)', async () => {
const deps = makeDeps()
await shardIngest.ingest(FRAME, deps)
assert.equal(deps.calls.broadcast.length, 1)
assert.equal(deps.calls.broadcast[0].kind, 'world.ruleset')
})
// A backfill replay must reach the store but must NOT re-animate the live ticker.
test('a backfilled world.ruleset still stores but does not broadcast', async () => {
const deps = makeDeps()
await shardIngest.ingest(FRAME, { ...deps, fromBackfill: true })
assert.equal(deps.calls.rulesetSet.length, 1)
assert.equal(deps.calls.broadcast.length, 0)
})
// A re-emitted identical ruleset is an overwrite, not an append: two ingests of
// the same rev leave one stored frame's worth of state, never a growing log.
test('a repeated world.ruleset overwrites rather than accumulating', async () => {
const deps = makeDeps()
await shardIngest.ingest(FRAME, deps)
await shardIngest.ingest({ ...FRAME, t: 2000 }, deps)
assert.equal(deps.calls.appended.length, 0)
assert.equal(deps.calls.rulesetSet.length, 2) // two writes...
assert.equal(deps.calls.rulesetSet[1].rev, '1a2b3c4d') // ...of the same singleton
})
// A model write that throws must not kill the feed — ingest swallows it and the
// frame is still broadcast.
test('a setRuleset failure does not throw or stop the broadcast', async () => {
const deps = makeDeps()
deps.shardState.setRuleset = async () => { throw new Error('db down') }
const r = await shardIngest.ingest(FRAME, deps)
assert.equal(r.logged, false)
assert.equal(deps.calls.broadcast.length, 1)
})
// ── Shard name fallback ────────────────────────────────────────────────────
//
// ServUO ships Server.cfg with `Name=My Shard`. An operator who never edited it
// publishes that verbatim, which says "unnamed" rather than naming anything — so
// the site answers with its own instance name instead of printing the stock
// default under a header carrying the real one.
//
// Applied at INGEST, not on read, because world.ruleset is also broadcast live:
// the same object goes to the SSE fan-out, so a read-time fix would be undone by
// the next reconnect's frame. These tests assert both halves.
function withSettings(deps, name) {
return { ...deps, settings: { getInstanceName: async () => name } }
}
test('the stock ServUO shard name is replaced with the instance name', async () => {
const deps = makeDeps()
await shardIngest.ingest({ ...FRAME, shard: 'My Shard' }, withSettings(deps, 'UOMysticmoon'))
assert.equal(deps.calls.rulesetSet[0].shard, 'UOMysticmoon')
})
test('the substituted name reaches the live broadcast, not just the store', async () => {
const deps = makeDeps()
await shardIngest.ingest({ ...FRAME, shard: 'My Shard' }, withSettings(deps, 'UOMysticmoon'))
assert.equal(deps.calls.broadcast.length, 1)
assert.equal(deps.calls.broadcast[0].shard, 'UOMysticmoon')
})
test('a missing or blank shard name gets the same treatment', async () => {
for (const shard of [undefined, null, '', ' ']) {
const deps = makeDeps()
await shardIngest.ingest({ ...FRAME, shard }, withSettings(deps, 'UOMysticmoon'))
assert.equal(deps.calls.rulesetSet[0].shard, 'UOMysticmoon')
}
})
// The match is on the whole value, case- and padding-insensitive. A shard that
// deliberately calls itself "My Shard Reborn" has named itself and keeps it.
test('a real name that merely contains the stock one is left alone', async () => {
const deps = makeDeps()
await shardIngest.ingest({ ...FRAME, shard: 'My Shard Reborn' }, withSettings(deps, 'UOMysticmoon'))
assert.equal(deps.calls.rulesetSet[0].shard, 'My Shard Reborn')
const padded = makeDeps()
await shardIngest.ingest({ ...FRAME, shard: ' MY SHARD ' }, withSettings(padded, 'UOMysticmoon'))
assert.equal(padded.calls.rulesetSet[0].shard, 'UOMysticmoon')
})
test('a shard that named itself is never overridden by the brand', async () => {
const deps = makeDeps()
await shardIngest.ingest(FRAME, withSettings(deps, 'Some Other Brand'))
assert.equal(deps.calls.rulesetSet[0].shard, 'UOMysticmoon')
})
// The settings read is a DB call on a path that must never fail ingest.
test('a settings read failure leaves the frame storable', async () => {
const deps = makeDeps()
const boom = { ...deps, settings: { getInstanceName: async () => { throw new Error('db down') } } }
await shardIngest.ingest({ ...FRAME, shard: 'My Shard' }, boom)
assert.equal(deps.calls.rulesetSet.length, 1)
assert.equal(deps.calls.rulesetSet[0].shard, 'My Shard')
})

View File

@@ -0,0 +1,263 @@
// Ported from core in Phase 3 (MODULE_SYSTEM.md §2.7.1). One change runs through
// every moved test: core internals can no longer be stubbed by requiring them,
// because there are none to require — `../utils/db` and `../model/settings` do
// not exist here. What a test controls instead is the `ctx` core would have
// handed over, installed once by `test/_setup.js`, which is the seam the
// contract actually promises.
// Point the DB at a closed port BEFORE requiring anything that builds a pool.
// Nothing here reaches the database: these are the model's PURE parts — the
// flatten/shape rules the frame passes through on the way in and out — plus the
// visibility projection over the shapes they produce.
const { test, after } = require('node:test')
const assert = require('node:assert/strict')
const market = require('../model/shardMarket/shardMarket.model')
const clilocs = require('../model/shardClilocs/shardClilocs.model')
const clilocDb = require('../model/shardClilocs/shardClilocs.db')
const visibility = require('../utils/shardVisibility')
// Stand in for the cliloc table. Without this each unresolved lookup waits out
// the pool's 10s acquire timeout against the dead port — the model swallows the
// failure exactly as it would in production (an operator who never converted a
// cliloc file is in a supported state), so the RESULT is the same either way;
// this only stops the suite spending half a minute proving it.
const TABLE = new Map([[1023721, 'quarter staff']])
clilocDb.lookup = async (numbers) =>
numbers.filter((n) => TABLE.has(n)).map((n) => ({ number: n, text: TABLE.get(n) }))
const FRAME = {
kind: 'vendor.listing',
t: 1000,
serial: '0x40001234',
shopName: "Darrow's Bargains",
ownerSerial: '0x1A2B',
ownerName: 'Darrow',
location: { map: 'Trammel', x: 1421, y: 1699, z: 0, region: 'Britain', house: "Darrow's Villa" },
count: 2,
total: 2,
truncated: false,
items: [],
}
// ── flattenFrame ───────────────────────────────────────────────────────────
test('flattenFrame lifts the nested location into columns', () => {
const v = market.flattenFrame(FRAME)
assert.equal(v.serial, '0x40001234')
assert.equal(v.map, 'Trammel')
assert.equal(v.x, 1421)
assert.equal(v.region, 'Britain')
assert.equal(v.house, "Darrow's Villa")
})
// A vendor standing in the street has no house, and a frame from an older plugin
// may have no location at all. Neither is an error.
test('flattenFrame tolerates a missing location entirely', () => {
const v = market.flattenFrame({ serial: '0x1', shopName: null })
assert.equal(v.map, null)
assert.equal(v.x, null)
assert.equal(v.region, null)
assert.equal(v.house, null)
})
// `total` is what the SHOP holds; `count` is what the frame carried. A truncated
// shop must not report its published slice as its size, or the page says
// "showing 250 of 250" for a vendor holding three thousand stacks.
test('flattenFrame keeps the shop total separate from the published count', () => {
const v = market.flattenFrame({ ...FRAME, count: 250, total: 3104, truncated: true })
assert.equal(v.itemTotal, 3104)
assert.equal(v.truncated, true)
})
// An older plugin sends no `total`. Falling back to `count` is right — it is the
// only number available and it is correct whenever nothing was truncated.
test('flattenFrame falls back to count when total is absent', () => {
const v = market.flattenFrame({ ...FRAME, count: 7, total: undefined })
assert.equal(v.itemTotal, 7)
})
test('flattenFrame clips over-length strings rather than letting the insert fail', () => {
const v = market.flattenFrame({ ...FRAME, ownerName: 'x'.repeat(200) })
assert.equal(v.ownerName.length, 64)
})
// ── shapeItems ─────────────────────────────────────────────────────────────
//
// resolveMany never throws and, with no cliloc table reachable, resolves nothing
// — which is exactly the state of a shard whose operator never converted one, so
// these run against the real function rather than a stub.
test('shapeItems prefers the item\'s literal name over its cliloc', async () => {
const items = await market.shapeItems({
items: [{ serial: '0x1', itemId: 3922, price: 100, name: 'a shard sigil', cliloc: 1023721 }],
})
assert.equal(items[0].displayName, 'a shard sigil')
// The cliloc is kept regardless, so a later import can still re-resolve it.
assert.equal(items[0].cliloc, 1023721)
})
test('shapeItems resolves the cliloc when the item has no literal name', async () => {
const items = await market.shapeItems({
items: [{ serial: '0x1', itemId: 3922, price: 100, name: null, cliloc: 1023721 }],
})
assert.equal(items[0].displayName, 'quarter staff')
})
// The supported state for a shard whose operator never converted a cliloc file:
// no name, not a fabricated one. Clients render the item id, exactly as they did
// before the table existed.
test('shapeItems leaves displayName null for an unknown cliloc', async () => {
const items = await market.shapeItems({
items: [{ serial: '0x1', itemId: 3922, price: 100, name: null, cliloc: 9999999 }],
})
assert.equal(items[0].displayName, null)
})
// Unpriced rows are inventory, not listings. The shard drops them too; enforcing
// it here as well means a plugin that stops doing so cannot put un-buyable rows
// on the market page.
test('shapeItems drops unpriced listings', async () => {
const items = await market.shapeItems({
items: [
{ serial: '0x1', itemId: 1, price: 0 },
{ serial: '0x2', itemId: 2, price: -1 },
{ serial: '0x3', itemId: 3, price: 5 },
],
})
assert.deepEqual(items.map((i) => i.serial), ['0x3'])
})
test('shapeItems caps a pathological frame', async () => {
const many = Array.from({ length: market.MAX_ITEMS_PER_VENDOR + 50 }, (_, i) => ({
serial: `0x${i}`,
itemId: 1,
price: 1,
}))
const items = await market.shapeItems({ items: many })
assert.equal(items.length, market.MAX_ITEMS_PER_VENDOR)
})
test('shapeItems tolerates a frame with no items array', async () => {
assert.deepEqual(await market.shapeItems({}), [])
})
// ── Visibility projection ──────────────────────────────────────────────────
//
// The regression that matters. Part A pre-wired `market.ownerName` and
// `market.location` before the frame existed, and the sibling rule it pre-wired
// for leaderboards (`characterName`) turned out to be INERT because projectValue
// matches literal JSON keys. These assert the market rules actually bite — on the
// read model AND on the wire frame, which is why both carry the same key names.
const config = visibility.compileDefaults()
const listing = market.shapeListing({
serial: '0x40012ABC',
item_id: 3922,
hue: 0,
amount: 1,
price: 25000,
name: null,
cliloc: 1023721,
display_name: 'quarter staff',
child: 0,
vendor_serial: '0x40001234',
shop_name: "Darrow's Bargains",
owner_serial: '0x1A2B',
owner_name: 'Darrow',
map: 'Trammel',
x: 1421,
y: 1699,
z: 0,
region: 'Britain',
house: "Darrow's Villa",
updated_at: new Date(0),
})
test('market defaults expose owner and location (they are already public in game)', () => {
const out = visibility.projectFeature('market', listing, 'anonymous', config)
assert.equal(out.vendor.ownerName, 'Darrow')
assert.equal(out.vendor.location.region, 'Britain')
})
test('tightening market.ownerName hides it from below that rung', () => {
const tightened = { ...config, market: { ...config.market, fields: { ...config.market.fields, ownerName: 'staff' } } }
const anon = visibility.projectFeature('market', listing, 'anonymous', tightened)
const staff = visibility.projectFeature('market', listing, 'staff', tightened)
assert.equal('ownerName' in anon.vendor, false)
assert.equal(staff.vendor.ownerName, 'Darrow')
// The shop name is a separate field and must survive — hiding the owner is not
// the same as hiding the shop.
assert.equal(anon.vendor.shopName, "Darrow's Bargains")
})
// The whole reason `location` is one nested object: a single rule has to take the
// facet, the coordinates, the region and the house together. Five flat keys would
// be five rules that drift apart.
test('tightening market.location hides the whole location object at once', () => {
const tightened = { ...config, market: { ...config.market, fields: { ...config.market.fields, location: 'player' } } }
const anon = visibility.projectFeature('market', listing, 'anonymous', tightened)
const player = visibility.projectFeature('market', listing, 'player', tightened)
assert.equal('location' in anon.vendor, false)
assert.equal(player.vendor.location.map, 'Trammel')
})
// The same rules must bite on the LIVE frame, not just the stored read model —
// the market's SSE stream is off by default but an admin can turn it on, and a
// field rule that only worked on one of the two paths is exactly the leak §3.6.1
// records.
test('the same rules apply to the raw vendor.listing frame', () => {
const tightened = { ...config, market: { ...config.market, fields: { ...config.market.fields, ownerName: 'admin', location: 'admin' } } }
const out = visibility.projectFeature('market', FRAME, 'anonymous', tightened)
assert.equal('ownerName' in out, false)
assert.equal('location' in out, false)
assert.equal(out.shopName, "Darrow's Bargains")
})
// Rule 1 is not configurable and does not depend on the market rules at all: a
// frame that somehow carried an account name must never publish it.
test('acct and webId are stripped from a market payload regardless of config', () => {
const out = visibility.projectFeature(
'market',
{ serial: '0x1', ownerAcct: 'darrow', webId: '42', shopName: 'Shop' },
'staff',
config,
)
assert.equal('ownerAcct' in out, false)
assert.equal('webId' in out, false)
assert.equal(out.shopName, 'Shop')
})
// Both kinds must be attributed to a feature, or rule 2 makes them admin-only by
// omission — which would be a silent failure rather than a loud one.
test('both market kinds are mapped to the market feature', () => {
assert.equal(visibility.KIND_FEATURE.get('vendor.listing'), 'market')
assert.equal(visibility.KIND_FEATURE.get('vendor.listing.remove'), 'market')
})
// The market's live firehose is off by default (a page of whole vendor
// inventories is the site's biggest bandwidth item and no page needs it live),
// but the REST reads are unaffected — which is what `visibleKinds` ignoring the
// stream flag encodes.
test('market kinds are stream-suppressed by default but still readable', () => {
assert.equal(visibility.DEFAULT_STREAM_OFF.has('market'), true)
assert.equal(visibility.kindVisibleTo('vendor.listing', 'anonymous', config), false)
assert.equal(visibility.PUBLIC_KINDS.has('vendor.listing'), false)
assert.ok(visibility.visibleKinds('anonymous', config).includes('vendor.listing'))
})
test('an admin who enables the stream gets the frames', () => {
const on = { ...config, market: { ...config.market, stream: true } }
assert.equal(visibility.kindVisibleTo('vendor.listing', 'anonymous', on), true)
})
// Guards the stub above against silently doing nothing: if the model stopped
// going through db.lookup, every shapeItems assertion would still "pass" by
// resolving nothing, which is also what a real miss looks like.
test('the cliloc resolver is the path shapeItems resolves through', async () => {
const found = await clilocs.resolveMany([1023721])
assert.equal(found.get(1023721), 'quarter staff')
})

View File

@@ -0,0 +1,77 @@
const { test, beforeEach } = require('node:test')
const assert = require('node:assert/strict')
// Term capture lives in the model (shardState.model.upsertGovernor →
// recordGovernorTransition) and talks to the db module. We exercise the real
// logic against an in-memory fake by monkeypatching the shared db module object
// (same instance the model require()s) — no DB, no mocking library.
const db = require('../model/shardState/shardState.db')
const model = require('../model/shardState/shardState.model')
let terms // in-memory shard_governor_terms
let nextId
const saved = {}
beforeEach(() => {
terms = []
nextId = 1
for (const k of ['currentGovernorTerm', 'closeGovernorTerm', 'openGovernorTerm', 'upsertGovernor']) {
saved[k] = db[k]
}
db.currentGovernorTerm = async (city) =>
terms.find((t) => t.city === city && t.ended_at === null) || null
db.closeGovernorTerm = async (id, endedAt) => {
const row = terms.find((t) => t.id === id)
if (row) row.ended_at = endedAt
}
db.openGovernorTerm = async ({ city, serial, name, acct, webId, startedAt }) => {
terms.push({ id: nextId++, city, governor_serial: serial, governor_name: name,
governor_acct: acct, governor_web_id: webId, started_at: startedAt, ended_at: null })
}
db.upsertGovernor = async () => {} // snapshot write — irrelevant to term capture
})
function restore() {
for (const k of Object.keys(saved)) db[k] = saved[k]
}
test('a repeated city.update with the same governor does NOT open a second term', async () => {
await model.upsertGovernor({ city: 'Britain', governor: { serial: '0x1', name: 'Darrow' }, t: 100 })
await model.upsertGovernor({ city: 'Britain', governor: { serial: '0x1', name: 'Darrow' }, t: 200 })
const open = terms.filter((t) => t.ended_at === null)
assert.equal(terms.length, 1)
assert.equal(open.length, 1)
assert.equal(open[0].governor_serial, '0x1')
assert.equal(open[0].started_at, 100)
restore()
})
test('a governor change closes the old term and opens a new one', async () => {
await model.upsertGovernor({ city: 'Britain', governor: { serial: '0x1', name: 'Darrow' }, t: 100 })
await model.upsertGovernor({ city: 'Britain', governor: { serial: '0x2', name: 'Mira' }, t: 300 })
assert.equal(terms.length, 2)
const [first, second] = terms
assert.equal(first.governor_serial, '0x1')
assert.equal(first.ended_at, 300) // closed at the transition time
assert.equal(second.governor_serial, '0x2')
assert.equal(second.ended_at, null) // now current
assert.equal(second.started_at, 300)
restore()
})
test('a seat going vacant closes the term without opening a new one', async () => {
await model.upsertGovernor({ city: 'Britain', governor: { serial: '0x1', name: 'Darrow' }, t: 100 })
await model.upsertGovernor({ city: 'Britain', governor: null, t: 400 })
assert.equal(terms.length, 1)
assert.equal(terms[0].ended_at, 400)
restore()
})
test('terms are tracked independently per city', async () => {
await model.upsertGovernor({ city: 'Britain', governor: { serial: '0x1' }, t: 100 })
await model.upsertGovernor({ city: 'Minoc', governor: { serial: '0x9' }, t: 120 })
await model.upsertGovernor({ city: 'Britain', governor: { serial: '0x1' }, t: 200 }) // dup, no-op
assert.equal(terms.length, 2)
assert.equal(terms.filter((t) => t.ended_at === null).length, 2)
restore()
})

View File

@@ -0,0 +1,253 @@
// Ported from core in Phase 3 (MODULE_SYSTEM.md §2.7.1). One change runs through
// every moved test: core internals can no longer be stubbed by requiring them,
// because there are none to require — `../utils/db` and `../model/settings` do
// not exist here. What a test controls instead is the `ctx` core would have
// handed over, installed once by `test/_setup.js`, which is the seam the
// contract actually promises.
const { test, beforeEach, afterEach, after } = require('node:test')
const assert = require('node:assert/strict')
// Unit-test the shard-state model's mapping/derivation rules against a fake
// shardState.db (no DB). These are the transforms the ingest dispatcher and the
// public read endpoints both depend on:
// - a partial online refresh (char.vitals) only writes the keys it carries, so
// it never clobbers login-only fields with undefined;
// - is_idoc is DERIVED from the decay stage, not trusted from the wire;
// - the economy series is clamped, returned oldest→newest, and gold coerced to
// a JS number (mariadb hands back BigInt-ish strings for large gold totals);
// - an empty presence table reads as a well-formed zero snapshot, not null;
// - champ/guild/governor rows fall back to hoisted columns when payload is absent;
// - remove/upsert guard against missing identifiers instead of hitting the DB.
const db = require('../model/shardState/shardState.db')
const shardState = require('../model/shardState/shardState.model')
// Records the (id, fields) the model hands to each db write, and serves canned
// rows back for reads.
let calls
const saved = {}
const DB_KEYS = [
'upsertOnline', 'removeOnline', 'clearOnline', 'insertEconomy', 'listEconomy', 'latestEconomy',
'upsertHouse', 'removeHouse', 'listIdocHouses', 'listRegistryHouses', 'setPresence', 'latestPresence',
'upsertChamp', 'removeChamp', 'listChamps', 'upsertGuild', 'removeGuild', 'listGuilds',
'upsertGovernor', 'listGovernors', 'listGovernorTerms', 'listOnline', 'listOnlineLinked', 'listPages',
]
beforeEach(() => {
calls = {}
for (const k of DB_KEYS) {
saved[k] = db[k]
calls[k] = []
db[k] = async (...args) => {
calls[k].push(args)
}
}
})
afterEach(() => {
for (const k of DB_KEYS) db[k] = saved[k]
})
// ── partial online refresh must not clobber ─────────────────────────────
test('upsertOnline drops undefined keys so a vitals refresh keeps login fields', async () => {
// A char.vitals event carries hits but not name/acct — those must not be sent as
// undefined columns (which would overwrite the login row).
await shardState.upsertOnline({ serial: 5, hits: 40, hitsMax: 100 })
const [serial, fields] = calls.upsertOnline[0]
assert.equal(serial, 5)
assert.deepEqual(fields, { hits: 40, hits_max: 100 })
assert.ok(!('name' in fields), 'name not written when absent from the event')
})
test('upsertOnline maps camelCase vitals to snake_case columns', async () => {
await shardState.upsertOnline({ serial: 9, name: 'Bob', webId: 3, hitsMax: 90, manaMax: 50, stamMax: 70 })
const [, fields] = calls.upsertOnline[0]
assert.equal(fields.web_id, 3)
assert.equal(fields.hits_max, 90)
assert.equal(fields.mana_max, 50)
assert.equal(fields.stam_max, 70)
})
test('upsertOnline ignores an event with no serial (never touches the DB)', async () => {
await shardState.upsertOnline({ name: 'Nobody' })
await shardState.upsertOnline(null)
assert.equal(calls.upsertOnline.length, 0)
})
// ── is_idoc is derived, not trusted ─────────────────────────────────────
test('upsertHouse derives is_idoc=1 only for the IDOC stage (case-insensitive)', async () => {
await shardState.upsertHouse({ serial: 1, stage: 'IDOC' })
await shardState.upsertHouse({ serial: 2, stage: 'idoc' })
await shardState.upsertHouse({ serial: 3, stage: 'Slightly' })
assert.equal(calls.upsertHouse[0][1].is_idoc, 1)
assert.equal(calls.upsertHouse[1][1].is_idoc, 1)
assert.equal(calls.upsertHouse[2][1].is_idoc, 0)
})
test('upsertHouseRegistry writes in_registry=1 and flattens the owner actor', async () => {
await shardState.upsertHouseRegistry({ serial: 7, name: 'Keep', owner: { serial: 20, acct: 'a', name: 'Liege' } })
const [serial, fields] = calls.upsertHouse[0]
assert.equal(serial, 7)
assert.equal(fields.in_registry, 1)
assert.equal(fields.owner_serial, 20)
assert.equal(fields.owner_name, 'Liege')
})
test('upsertHouseRegistry tolerates an abandoned house (null owner)', async () => {
await shardState.upsertHouseRegistry({ serial: 8, name: 'Ruin', owner: null })
const [, fields] = calls.upsertHouse[0]
assert.equal(fields.owner_serial, null)
assert.equal(fields.owner_name, null)
assert.equal(fields.in_registry, 1)
})
// ── economy series shaping ──────────────────────────────────────────────
test('listEconomy clamps the limit, reverses to oldest→newest, and coerces gold to Number', async () => {
// db.listEconomy returns newest-first; the model reverses for charting.
db.listEconomy = async (n) => {
assert.equal(n, 1000, 'limit is clamped to MAX_ECONOMY')
return [
{ accounts: 3, gold: '9000000000', t: 30 },
{ accounts: 2, gold: '20', t: 20 },
{ accounts: 1, gold: null, t: 10 },
]
}
const out = await shardState.listEconomy(999999)
assert.deepEqual(out.map((r) => r.t), [10, 20, 30], 'oldest first')
assert.equal(out[2].gold, 9000000000)
assert.equal(typeof out[2].gold, 'number')
assert.equal(out[0].gold, null, 'null gold stays null, not 0')
})
test('listEconomy floors a non-positive limit to the default', async () => {
let seen
db.listEconomy = async (n) => {
seen = n
return []
}
await shardState.listEconomy(0)
assert.equal(seen, 100)
})
// ── presence defaults ───────────────────────────────────────────────────
test('latestPresence returns a well-formed zero snapshot when nothing is stored', async () => {
db.latestPresence = async () => null
const out = await shardState.latestPresence()
assert.deepEqual(out, { count: 0, byFacet: {}, byRegion: {}, t: null })
})
test('latestPresence parses JSON string columns from the DB', async () => {
db.latestPresence = async () => ({ count: '12', by_facet: '{"felucca":5}', by_region: '{"Britain":3}', t: '99' })
const out = await shardState.latestPresence()
assert.equal(out.count, 12)
assert.deepEqual(out.byFacet, { felucca: 5 })
assert.equal(out.t, 99)
})
// ── payload fallback shaping ────────────────────────────────────────────
test('listChamps returns the stored payload verbatim when present', async () => {
const payload = { kind: 'champ.update', serial: 1, name: 'Barracoon', custom: 'field' }
db.listChamps = async () => [{ serial: 1, payload: JSON.stringify(payload) }]
const out = await shardState.listChamps()
assert.deepEqual(out[0], payload)
})
test('listChamps falls back to hoisted columns for a legacy row with no payload', async () => {
db.listChamps = async () => [{ serial: 2, name: 'Rikktor', active: 1, boss_up: 0, payload: null }]
const out = await shardState.listChamps()
assert.equal(out[0].kind, 'champ.update')
assert.equal(out[0].name, 'Rikktor')
assert.equal(out[0].active, true)
assert.equal(out[0].bossUp, false)
})
test('listGuilds falls back to a shaped leader object when payload is absent', async () => {
db.listGuilds = async () => [{ id: 1, name: 'Order', leader_serial: 5, leader_name: 'Cap', payload: null }]
const out = await shardState.listGuilds()
assert.equal(out[0].leader.serial, 5)
assert.equal(out[0].leader.name, 'Cap')
})
// ── guards against missing identifiers ──────────────────────────────────
test('remove helpers are no-ops on a falsy id (never call the DB)', async () => {
await shardState.removeChamp(undefined)
await shardState.removeHouse('')
await shardState.removeGuild(null)
assert.equal(calls.removeChamp.length, 0)
assert.equal(calls.removeHouse.length, 0)
assert.equal(calls.removeGuild.length, 0)
})
test('removeGuild treats id 0 as a real id (0 != null) but skips null/undefined', async () => {
await shardState.removeGuild(0)
assert.equal(calls.removeGuild.length, 1, 'guild id 0 is valid')
})
test('upsertChamp/upsertGuild/upsertGovernor ignore events missing their key', async () => {
await shardState.upsertChamp({ name: 'no serial' })
await shardState.upsertGuild({ name: 'no id' })
await shardState.upsertGovernor({ governor: {} }) // no city
assert.equal(calls.upsertChamp.length, 0)
assert.equal(calls.upsertGuild.length, 0)
assert.equal(calls.upsertGovernor.length, 0)
})
// ── read-shaping locks the camelCase API/app contract ───────────────────
// A field-name regression in these serializers silently breaks the public site
// and the Android client, so pin the shapes the read endpoints emit.
test('listOnline maps snake_case columns to the camelCase player shape', async () => {
db.listOnline = async () => [
{ serial: 1, name: 'A', acct: 'acc', web_id: 7, hits: 10, hits_max: 100, mana_max: 50, stam_max: 60, updated_at: 'ts' },
]
const [p] = await shardState.listOnline()
assert.equal(p.webId, 7)
assert.equal(p.hitsMax, 100)
assert.equal(p.manaMax, 50)
assert.equal(p.stamMax, 60)
assert.equal(p.updatedAt, 'ts')
assert.ok(!('web_id' in p), 'no snake_case leaks into the API shape')
})
test('listIdoc shapes houses and coerces isIdoc/price', async () => {
db.listIdocHouses = async () => [{ serial: 3, is_idoc: 1, price: '5000', in_registry: 1, owner_serial: 2 }]
const [h] = await shardState.listIdoc()
assert.equal(h.isIdoc, true)
assert.equal(h.price, 5000)
assert.equal(typeof h.price, 'number')
assert.equal(h.inRegistry, true)
})
test('listPages folds the sender columns into a nested actor and coerces flags', async () => {
db.listPages = async () => [
{ page_id: 42, type: 'gm', sender_name: 'Help', sender_acct: 'x', web_id: 9, handled: 0, sent_ms: '1234', payload: null },
]
const [pg] = await shardState.listPages()
assert.equal(pg.pageId, 42)
assert.deepEqual(pg.sender, { serial: 42, name: 'Help', acct: 'x', webId: 9 })
assert.equal(pg.handled, false)
assert.equal(pg.sentMs, 1234)
})
test('listGovernors falls back to a shaped governor object when payload is absent', async () => {
db.listGovernors = async () => [
{ city: 'Britain', governor_serial: 5, governor_name: 'Lord', governor_acct: 'a', election_phase: 'none', payload: null },
]
const [g] = await shardState.listGovernors()
assert.equal(g.kind, 'city.update')
assert.equal(g.city, 'Britain')
assert.equal(g.governor.name, 'Lord')
assert.equal(g.governorElect, null)
})
test('listGovernorHistory coerces started/ended timestamps to numbers and clamps the limit', async () => {
let seenLimit
db.listGovernorTerms = async (city, n) => {
seenLimit = n
return [{ city, governor_serial: 1, governor_name: 'X', started_at: '100', ended_at: null, votes: 3 }]
}
const out = await shardState.listGovernorHistory('Trinsic', 99999)
assert.equal(seenLimit, 500) // clamped to the 500 max
assert.equal(out[0].startedAt, 100)
assert.equal(typeof out[0].startedAt, 'number')
assert.equal(out[0].endedAt, null) // an open term stays null, not coerced to 0
})

View File

@@ -0,0 +1,151 @@
// `mapShardEvent` and the shard-event push fan-out, moved out of core's
// pushDispatch.test.js in Phase 3 (MODULE_SYSTEM.md §2.7.1).
//
// Core keeps the push INFRASTRUCTURE and its tests — the SSRF guard on an ntfy
// endpoint, and `publish()` sending a content-free tickle. What is here is the
// CATALOG and the mapping into it: which shard event becomes which stream, which
// kinds are owner-keyed, and which must never produce a public target. That last
// one is a security boundary and it is module-owned by design (MODULE_API.md
// §2.4) — the kinds, the streams and the public-safety filter are one file that
// moves together.
const { test } = require('node:test')
const assert = require('node:assert/strict')
const { fakeCtx } = require('./_fakes')
require('../core').init(fakeCtx())
const { mapShardEvent, createTracker } = require('../config/shardStreams')
const shardPush = require('../utils/shardPush')
// Moved with the fromShardEvent tests. They set NTFY_* because the push
// dispatcher they hand results to reads them — core's variables, read by core's
// code, which is why this stays a plain env helper rather than becoming
// something on ctx: the module never reads these itself.
// ── mapShardEvent ───────────────────────────────────────────────────────────
test('server.hello / shutdown / crashed map to the public server.status stream', () => {
const t = createTracker()
assert.deepEqual(mapShardEvent({ kind: 'server.hello', bootId: 'b1' }, t), [
{ streamId: 'server.status', ref: 'up:b1' },
])
assert.deepEqual(mapShardEvent({ kind: 'server.shutdown' }, t), [{ streamId: 'server.status', ref: 'down' }])
assert.deepEqual(mapShardEvent({ kind: 'server.crashed' }, t), [{ streamId: 'server.status', ref: 'down' }])
})
test('house.decay INTO idoc yields the public idoc.warning AND the owner-keyed house.idoc', () => {
const t = createTracker()
const out = mapShardEvent({ kind: 'house.decay', to: 'IDOC', serial: '0x40', ownerAcct: 'bob' }, t)
assert.deepEqual(out, [
{ streamId: 'idoc.warning', ref: '0x40' },
{ streamId: 'house.idoc', ref: '0x40', ownerAccount: 'bob' },
])
// A non-IDOC decay stage produces nothing.
assert.deepEqual(mapShardEvent({ kind: 'house.decay', to: 'Fairly', serial: '0x41' }, t), [])
})
test('champ.update fires champ.start only on the inactive→active transition', () => {
const t = createTracker()
// First sight active → start.
assert.deepEqual(mapShardEvent({ kind: 'champ.update', serial: 'c1', active: true }, t), [
{ streamId: 'champ.start', ref: 'c1' },
])
// Still active → no re-fire.
assert.deepEqual(mapShardEvent({ kind: 'champ.update', serial: 'c1', active: true }, t), [])
// Goes inactive, then active again → fires again.
assert.deepEqual(mapShardEvent({ kind: 'champ.update', serial: 'c1', active: false }, t), [])
assert.deepEqual(mapShardEvent({ kind: 'champ.update', serial: 'c1', active: true }, t), [
{ streamId: 'champ.start', ref: 'c1' },
])
})
test('city.update fires governor.election only on a real governor change, never on first sight', () => {
const t = createTracker()
// First sight of the city → no election (could be a reconnect snapshot).
assert.deepEqual(mapShardEvent({ kind: 'city.update', city: 'Britain', governor: { serial: '0x1' } }, t), [])
// Same governor → nothing.
assert.deepEqual(mapShardEvent({ kind: 'city.update', city: 'Britain', governor: { serial: '0x1' } }, t), [])
// New governor → election.
assert.deepEqual(mapShardEvent({ kind: 'city.update', city: 'Britain', governor: { serial: '0x2' } }, t), [
{ streamId: 'governor.election', ref: 'Britain' },
])
})
test('personal streams are owner-keyed and sensitive kinds never yield a public target', () => {
const t = createTracker()
const sale = mapShardEvent({ kind: 'vendor.sale', ownerAcct: 'bob', t: 7 }, t)
assert.deepEqual(sale, [{ streamId: 'vendor.sale', ref: '7', ownerAccount: 'bob' }])
const login = mapShardEvent({ kind: 'account.login.attempt', acct: 'bob', ip: '1.2.3.4', t: 9 }, t)
assert.deepEqual(login, [{ streamId: 'account.login', ref: '9', ownerAccount: 'bob' }])
// Every personal target carries an ownerAccount (never a bare public push).
for (const target of [...sale, ...login]) assert.ok(target.ownerAccount, 'personal target must be owner-keyed')
// A truly sensitive, unmapped kind produces nothing at all.
assert.deepEqual(mapShardEvent({ kind: 'cheat.fastwalk', acct: 'bob' }, t), [])
assert.deepEqual(mapShardEvent({ kind: 'admin.audit', actor: 'staff' }, t), [])
})
// ── fromShardEvent (owner resolution) ───────────────────────────────────────
//
// **These two tests changed shape in the move, and the change is the boundary.**
// In core they asserted through `publish()` into `pushDevices` and a captured
// fetch — which endpoints were hit, how many requests went out. None of that is
// this module's any more: `publish` is `ctx.push.publish`, core's, and the device
// registry and the relay are behind it. Reaching for them from here would mean
// reaching past `ctx`, which is exactly what §5.1 forbids.
//
// What remains is what the module actually owns, and it is the part worth
// guarding: a game account resolves to a website user through `shardLinks`, a
// personal target that resolves to nobody is dropped rather than published, and
// a public target publishes with no owner. Core's own pushDispatch tests still
// cover the fan-out on the other side of the seam.
/** Record what the module asked core to publish. */
function capturePublish() {
const calls = []
return { calls, publish: async (streamId, opts) => { calls.push({ streamId, ...opts }) } }
}
test('fromShardEvent resolves a personal event to the owning user, or drops it if unlinked', async () => {
const { calls, publish } = capturePublish()
const shardLinks = { getByAccount: async (acct) => (acct === 'mine' ? { userId: 42 } : null) }
const deps = { shardLinks, publish, tracker: createTracker() }
await shardPush.fromShardEvent({ kind: 'vendor.sale', ownerAcct: 'mine', t: 1 }, deps)
assert.equal(calls.length, 1)
assert.equal(calls[0].streamId, 'vendor.sale')
assert.equal(calls[0].ownerUserId, 42, 'the game account must resolve to the website user')
// Unlinked account → nobody to notify → nothing published. Not an error: a
// player who never linked their account is the ordinary case, not a fault.
calls.length = 0
await shardPush.fromShardEvent({ kind: 'vendor.sale', ownerAcct: 'stranger', t: 2 }, deps)
assert.equal(calls.length, 0)
})
test('fromShardEvent publishes a public shard event with no owner', async () => {
const { calls, publish } = capturePublish()
await shardPush.fromShardEvent(
{ kind: 'server.hello', bootId: 'b1' },
{ publish, tracker: createTracker(), shardLinks: { getByAccount: async () => null } },
)
assert.equal(calls.length, 1)
assert.equal(calls[0].streamId, 'server.status')
assert.equal(calls[0].ownerUserId, undefined, 'a public target must not be owner-keyed')
})
test('a sensitive kind never reaches publish at all', async () => {
// The public-safety filter is module-internal by design (MODULE_API.md §2.4):
// the kinds, the streams and the filter are one file that moves together, so
// core never holds a rule about data only this module defines. Which makes
// this the right side of the boundary for the test too.
const { calls, publish } = capturePublish()
const deps = { publish, tracker: createTracker(), shardLinks: { getByAccount: async () => null } }
for (const kind of ['cheat.fastwalk', 'admin.audit']) {
await shardPush.fromShardEvent({ kind, acct: 'bob', actor: 'staff' }, deps)
}
assert.equal(calls.length, 0)
})

View File

@@ -0,0 +1,426 @@
// Ported from core in Phase 3 (MODULE_SYSTEM.md §2.7.1). One change runs through
// every moved test: core internals can no longer be stubbed by requiring them,
// because there are none to require — `../utils/db` and `../model/settings` do
// not exist here. What a test controls instead is the `ctx` core would have
// handed over, installed once by `test/_setup.js`, which is the seam the
// contract actually promises.
// Point the DB at a closed port BEFORE requiring anything that builds a pool.
// Every DB call this suite would make is monkeypatched.
const { test, after, afterEach, beforeEach } = require('node:test')
const assert = require('node:assert/strict')
// Unit-test the visibility framework's INVARIANTS — the rules that make it a
// security boundary rather than a convenience filter (docs/link/v3.md §3):
//
// 1. acct / webId are admin-only ALWAYS and cannot be configured down.
// 2. A kind absent from KIND_FEATURE reaches nobody below admin (fail closed).
// 3. The compiled defaults reproduce pre-v3 behavior, so installing this
// module changes nothing until an admin edits the config.
// 4. The ladder is ordered and each rung implies the ones below it.
const visibility = require('../utils/shardVisibility')
const model = require('../model/shardVisibility/shardVisibility.model')
const shardLinks = require('../model/shardLinks/shardLinks.model')
const originals = { listAll: model.listAll, listForUser: shardLinks.listForUser }
// Default both DB reads to "no rows" so a test that doesn't care never blocks on
// the dead pool (each such call would otherwise burn the 10s acquire timeout).
// Tests that exercise stored config or a DB failure override these.
beforeEach(() => {
model.listAll = async () => []
shardLinks.listForUser = async () => []
visibility.invalidate()
})
afterEach(() => {
model.listAll = originals.listAll
shardLinks.listForUser = originals.listForUser
visibility.invalidate()
})
// Stub the stored config; the framework merges rows over compiled defaults.
function withRows(rows) {
model.listAll = async () => rows
visibility.invalidate()
}
// ── The ladder ─────────────────────────────────────────────────────────────
test('ladder is ordered and each rung implies the ones below it', () => {
assert.deepEqual(visibility.LADDER, ['anonymous', 'logged_in', 'player', 'staff', 'admin'])
for (let i = 0; i < visibility.LADDER.length; i += 1) {
for (let j = 0; j <= i; j += 1) {
assert.equal(visibility.meets(visibility.LADDER[i], visibility.LADDER[j]), true)
}
for (let j = i + 1; j < visibility.LADDER.length; j += 1) {
assert.equal(visibility.meets(visibility.LADDER[i], visibility.LADDER[j]), false)
}
}
})
test('an unknown rung always loses, on BOTH sides of the comparison', () => {
assert.equal(visibility.isLevel('not-a-rung'), false)
// An unknown REQUIREMENT is satisfied by nobody below admin...
for (const level of ['anonymous', 'logged_in', 'player', 'staff']) {
assert.equal(visibility.meets(level, 'not-a-rung'), false, `${level} vs unknown requirement`)
}
assert.equal(visibility.meets('admin', 'not-a-rung'), true)
// ...and an unknown VIEWER level grants nothing. This is the direction that
// matters: a shared admin fallback would have made a garbage viewer level
// pass every gate.
for (const required of visibility.LADDER.slice(1)) {
assert.equal(visibility.meets('not-a-rung', required), false, `unknown viewer vs ${required}`)
assert.equal(visibility.meets(undefined, required), false, `undefined viewer vs ${required}`)
assert.equal(visibility.meets(null, required), false, `null viewer vs ${required}`)
}
})
test('an unknown viewer level cannot see a gated kind or a locked field', async () => {
const config = await visibility.getConfig()
assert.equal(visibility.kindVisibleTo('champ.update', 'not-a-rung', config), true) // anonymous-tier: fine
assert.equal(visibility.kindVisibleTo('audit.command', 'not-a-rung', config), false)
const out = visibility.projectFeature(
'guilds',
{ leader: { name: 'Darrow', acct: 'whitlocktech', webId: '42' } },
'not-a-rung',
config,
)
assert.equal('acct' in out.leader, false)
assert.equal('webId' in out.leader, false)
})
// ── Rule 1: locked fields ──────────────────────────────────────────────────
test('acct and webId are stripped below admin regardless of feature config', () => {
const config = visibility.compileDefaults()
const frame = {
kind: 'guild.update',
name: 'The Nameless',
leader: { serial: '0x1A2B', name: 'Darrow', acct: 'whitlocktech', webId: '42', player: true },
}
for (const level of ['anonymous', 'logged_in', 'player', 'staff']) {
const out = visibility.projectFeature('guilds', frame, level, config)
assert.equal(out.leader.name, 'Darrow', `${level} keeps the character name`)
assert.equal(out.leader.serial, '0x1A2B')
assert.equal('acct' in out.leader, false, `${level} must not see acct`)
assert.equal('webId' in out.leader, false, `${level} must not see webId`)
}
const asAdmin = visibility.projectFeature('guilds', frame, 'admin', config)
assert.equal(asAdmin.leader.acct, 'whitlocktech')
assert.equal(asAdmin.leader.webId, '42')
})
test('a stored rule trying to loosen a locked field is ignored', async () => {
withRows([
{ feature: 'guilds', enabled: true, audience: 'anonymous', stream: true, fieldRules: { acct: 'anonymous', webId: 'anonymous' } },
])
const config = await visibility.getConfig()
const out = visibility.projectFeature(
'guilds',
{ leader: { name: 'Darrow', acct: 'whitlocktech', webId: '42' } },
'anonymous',
config,
)
assert.equal('acct' in out.leader, false)
assert.equal('webId' in out.leader, false)
})
test('rule 1 matches FLATTENED spellings, not just the two canonical keys', () => {
const config = visibility.compileDefaults()
// shapeHouse/shapeGuild flatten the actor into `<role>Acct` / `<role>WebId`.
// An exact-key check missed every one of these, which is how GET
// /public/shard/idoc served the owner's game account to anonymous callers.
const row = {
serial: '0x1',
name: 'Marble Tower',
ownerAcct: 'cadmus_acct',
leaderWebId: 42,
governorAcct: 'blackthorn_acct',
}
const out = visibility.projectFeature('houses', row, 'staff', config)
assert.equal('ownerAcct' in out, false, 'staff must not see a flattened acct')
assert.equal('leaderWebId' in out, false)
assert.equal('governorAcct' in out, false)
assert.equal(out.name, 'Marble Tower', 'ordinary fields are untouched')
const asAdmin = visibility.projectFeature('houses', row, 'admin', config)
assert.equal(asAdmin.ownerAcct, 'cadmus_acct')
})
// ── Protocol 3.0 leaderboards ──────────────────────────────────────────────
//
// The leaderboards field rule is spelled `name` because that is the key
// points.board actually puts a ranked character's name under. v3.md §7.4 calls it
// "characterName", which describes the meaning — and projectValue matches on the
// literal key, so a rule under that spelling would have been silently inert. This
// is the same failure mode §3.6.1 records for the flattened `ownerAcct`, and this
// test is the guard on it: if someone renames the rule back, an admin who tightens
// character names would get no enforcement and no error.
test('a tightened leaderboards name rule actually strips ranked character names', async () => {
withRows([
{ feature: 'leaderboards', enabled: true, audience: 'anonymous', stream: true, fieldRules: { name: 'logged_in' } },
])
const config = await visibility.getConfig()
const board = {
system: 'QueensLoyalty',
nameString: "Queen's Loyalty",
top: [{ rank: 1, serial: '0x1A2B', name: 'Darrow', points: 29500 }],
}
const anon = visibility.projectFeature('leaderboards', board, 'anonymous', config)
assert.equal('name' in anon.top[0], false, 'anonymous must not see the ranked name')
assert.equal(anon.top[0].points, 29500, 'the rest of the entry survives')
// The BOARD's own display name is a different key and must not be caught by it.
assert.equal(anon.nameString, "Queen's Loyalty")
const member = visibility.projectFeature('leaderboards', board, 'logged_in', config)
assert.equal(member.top[0].name, 'Darrow')
})
// Default config: boards are public, exactly as v3.md §7 specifies.
test('leaderboards are anonymous-visible by default, names included', () => {
const config = visibility.compileDefaults()
const out = visibility.projectFeature(
'leaderboards',
{ top: [{ rank: 1, name: 'Darrow', points: 1 }] },
'anonymous',
config,
)
assert.equal(out.top[0].name, 'Darrow')
assert.equal(visibility.kindVisibleTo('points.board', 'anonymous', config), true)
})
test('isLockedField locks acct/webId and their suffixed forms, and nothing else', () => {
for (const key of ['acct', 'webId', 'WEBID', 'ownerAcct', 'leaderWebId', 'governorAcct']) {
assert.equal(visibility.isLockedField(key), true, `${key} must be locked`)
}
// Must not over-match: these are ordinary public fields.
for (const key of ['name', 'serial', 'ownerName', 'price', 'contact', 'region']) {
assert.equal(visibility.isLockedField(key), false, `${key} must stay configurable`)
}
})
test('a Date survives projection instead of collapsing to {}', () => {
const config = visibility.compileDefaults()
const when = new Date('2026-07-06T19:32:29.000Z')
// The DB-backed read models carry real Date columns; rebuilding one key-by-key
// yields `{}` because a Date has no enumerable own properties.
const out = visibility.projectFeature('houses', { name: 'Keep', updatedAt: when }, 'anonymous', config)
assert.ok(out.updatedAt instanceof Date)
assert.equal(out.updatedAt.toISOString(), when.toISOString())
})
test('visibleKinds tracks live config and stays independent of the stream flag', async () => {
const config = visibility.compileDefaults()
assert.ok(visibleIncludes(config, 'anonymous', 'guild.update'))
// `stream: false` suppresses SSE fan-out only — the stored history stays readable.
assert.ok(visibleIncludes(config, 'anonymous', 'vendor.listing'))
assert.equal(visibility.kindVisibleTo('vendor.listing', 'anonymous', config), false)
const gated = { ...config, guilds: { ...config.guilds, audience: 'staff' } }
assert.equal(visibleIncludes(gated, 'anonymous', 'guild.update'), false)
assert.ok(visibleIncludes(gated, 'staff', 'guild.update'))
const off = { ...config, guilds: { ...config.guilds, enabled: false } }
assert.equal(visibleIncludes(off, 'admin', 'guild.update'), false)
// Rule 2 still holds: an unmapped kind is in nobody's readable set.
assert.equal(visibleIncludes(config, 'admin', 'staff.audit'), false)
})
const visibleIncludes = (config, level, kind) => visibility.visibleKinds(level, config).includes(kind)
test('projection recurses into arrays and nested actors', () => {
const config = visibility.compileDefaults()
const rows = [
{ city: 'Britain', governor: { name: 'A', acct: 'a', webId: '1' } },
{ city: 'Vesper', governor: { name: 'B', acct: 'b' } },
]
const out = visibility.projectFeature('governors', rows, 'anonymous', config)
assert.equal(out.length, 2)
assert.equal(out[0].governor.name, 'A')
assert.equal('acct' in out[0].governor, false)
assert.equal('webId' in out[0].governor, false)
assert.equal('acct' in out[1].governor, false)
})
// ── Rule 2: fail closed on unmapped kinds ──────────────────────────────────
test('an unmapped kind reaches nobody below admin', async () => {
const config = await visibility.getConfig()
for (const kind of ['audit.command', 'cheat.fastwalk', 'account.login.attempt', 'gold.change', 'made.up.kind']) {
for (const level of ['anonymous', 'logged_in', 'player', 'staff']) {
assert.equal(visibility.kindVisibleTo(kind, level, config), false, `${kind} @ ${level}`)
}
assert.equal(visibility.kindVisibleTo(kind, 'admin', config), true, `${kind} @ admin`)
}
})
test('the full house registry stays off the kind map (owner/price are staff-only)', () => {
assert.equal(visibility.KIND_FEATURE.has('house.update'), false)
assert.equal(visibility.KIND_FEATURE.has('house.remove'), false)
// house.decay — the IDOC signal the public page renders — IS mapped.
assert.equal(visibility.KIND_FEATURE.get('house.decay'), 'houses')
})
test('vendor.sale is not public (sales are owner-private)', async () => {
const config = await visibility.getConfig()
assert.equal(visibility.kindVisibleTo('vendor.sale', 'anonymous', config), false)
assert.equal(visibility.PUBLIC_KINDS.has('vendor.sale'), false)
})
// ── Rule 3: defaults reproduce pre-v3 behavior ─────────────────────────────
// The exact allowlist that shipped in shardBroadcast.js before v3. If a change
// makes the derived PUBLIC_KINDS differ from this, it is a deliberate widening
// or narrowing of what anonymous visitors see and must be reviewed as such.
const PRE_V3_PUBLIC_KINDS = [
'player.death',
'player.murdered',
'mob.killed',
'house.decay',
'quest.complete',
'skill.gain',
'fame.change',
'karma.change',
'mob.login',
'mob.logout',
'economy.supply',
'server.hello',
'server.shutdown',
'server.crashed',
'champ.update',
'champ.remove',
'guild.update',
'guild.remove',
'guild.join',
'city.update',
'presence.online',
'region.enter',
]
// The kinds v3 deliberately ADDS to the anonymous set. vendor.listing is
// pointedly not among them (its feature ships with stream off).
const V3_ADDED_PUBLIC_KINDS = ['world.ruleset', 'points.board']
test('derived PUBLIC_KINDS is exactly the pre-v3 allowlist plus the v3 additions', () => {
assert.deepEqual(
[...visibility.PUBLIC_KINDS].sort(),
[...PRE_V3_PUBLIC_KINDS, ...V3_ADDED_PUBLIC_KINDS].sort(),
)
})
test('no pre-v3 public kind was dropped', () => {
for (const kind of PRE_V3_PUBLIC_KINDS) {
assert.equal(visibility.PUBLIC_KINDS.has(kind), true, `${kind} fell out of the public set`)
}
})
test('the market stream is off by default but its REST feature is not', async () => {
const config = await visibility.getConfig()
assert.equal(config.market.enabled, true)
assert.equal(config.market.audience, 'anonymous')
assert.equal(config.market.stream, false)
assert.equal(visibility.kindVisibleTo('vendor.listing', 'anonymous', config), false)
assert.equal(visibility.PUBLIC_KINDS.has('vendor.listing'), false)
})
test('presence location defaults to staff, matching the old admin/moderator gate', async () => {
const config = await visibility.getConfig()
assert.equal(config.presence.fields.location, 'staff')
assert.equal(visibility.meets('player', 'staff'), false)
assert.equal(visibility.meets('staff', 'staff'), true)
})
test('every mapped kind names a real feature', () => {
for (const [kind, feature] of visibility.KIND_FEATURE) {
assert.equal(visibility.isFeature(feature), true, `${kind} → unknown feature ${feature}`)
}
})
// ── Config merge ───────────────────────────────────────────────────────────
test('a disabled feature is invisible to everyone below admin', async () => {
withRows([{ feature: 'champs', enabled: false, audience: 'anonymous', stream: true, fieldRules: {} }])
const config = await visibility.getConfig()
assert.equal(config.champs.enabled, false)
assert.equal(visibility.kindVisibleTo('champ.update', 'anonymous', config), false)
assert.equal(visibility.kindVisibleTo('champ.update', 'staff', config), false)
assert.equal(visibility.visibleFeatures('staff', config).includes('champs'), false)
})
test('raising a feature audience gates the lower rungs out', async () => {
withRows([{ feature: 'guilds', enabled: true, audience: 'player', stream: true, fieldRules: {} }])
const config = await visibility.getConfig()
assert.equal(visibility.kindVisibleTo('guild.update', 'anonymous', config), false)
assert.equal(visibility.kindVisibleTo('guild.update', 'logged_in', config), false)
assert.equal(visibility.kindVisibleTo('guild.update', 'player', config), true)
assert.equal(visibility.kindVisibleTo('guild.update', 'staff', config), true)
})
test('an unknown stored feature name is ignored, not resurrected', async () => {
withRows([{ feature: 'sekrit', enabled: true, audience: 'anonymous', stream: true, fieldRules: {} }])
const config = await visibility.getConfig()
assert.equal('sekrit' in config, false)
assert.deepEqual(Object.keys(config).sort(), [...visibility.FEATURE_NAMES].sort())
})
test('an invalid stored rung falls back to the default rather than failing open', async () => {
withRows([{ feature: 'houses', enabled: true, audience: 'nonsense', stream: true, fieldRules: { owner: 'nonsense' } }])
const config = await visibility.getConfig()
assert.equal(config.houses.audience, 'anonymous') // the compiled default
assert.equal(config.houses.fields.owner, 'staff') // the compiled default
})
test('a DB failure degrades to compiled defaults, not to everything-public', async () => {
model.listAll = async () => {
throw new Error('db down')
}
visibility.invalidate()
const config = await visibility.getConfig()
assert.deepEqual(Object.keys(config).sort(), [...visibility.FEATURE_NAMES].sort())
assert.equal(config.presence.fields.location, 'staff')
assert.equal(visibility.kindVisibleTo('audit.command', 'anonymous', config), false)
})
// ── Viewer level ───────────────────────────────────────────────────────────
test('viewerLevel resolves the ladder from role and link status', async () => {
shardLinks.listForUser = async () => []
assert.equal(await visibility.viewerLevel({}), 'anonymous')
visibility.forgetUser(1)
assert.equal(await visibility.viewerLevel({ user: { id: 1, role: 'admin' } }), 'admin')
visibility.forgetUser(2)
assert.equal(await visibility.viewerLevel({ user: { id: 2, role: 'moderator' } }), 'staff')
// A member with no linked game account sits at logged_in...
visibility.forgetUser(3)
assert.equal(await visibility.viewerLevel({ user: { id: 3, role: 'player' } }), 'logged_in')
// ...and reaches `player` once a link exists.
shardLinks.listForUser = async () => [{ account: 'whitlocktech' }]
visibility.forgetUser(4)
assert.equal(await visibility.viewerLevel({ user: { id: 4, role: 'player' } }), 'player')
})
test('editor is a content role and gets no shard privilege', async () => {
// Mapping editor to `staff` here would silently widen what editors can see;
// today's modAccess gate is admin|moderator only.
shardLinks.listForUser = async () => []
visibility.forgetUser(5)
assert.equal(await visibility.viewerLevel({ user: { id: 5, role: 'editor' } }), 'logged_in')
})
test('a link lookup failure downgrades rather than escalating', async () => {
shardLinks.listForUser = async () => {
throw new Error('db down')
}
visibility.forgetUser(6)
assert.equal(await visibility.viewerLevel({ user: { id: 6, role: 'player' } }), 'logged_in')
})

View File

@@ -0,0 +1,601 @@
const { test } = require('node:test')
const assert = require('node:assert/strict')
const {
parseXml,
parseObjects2,
parsePoints,
parseRegions,
parseLocations,
parseChampions,
buildPlacementIndex,
resolveRegion,
facetKey,
buildFacetIndex,
resolveFacetName,
slugify,
decodeEntities,
} = require('../utils/spawnAtlasParse')
// These parsers are pure and fs-free precisely so this suite can run in CI,
// where there is no ServUO tree. Every fixture below is a literal excerpt of a
// real shard file, trimmed — not invented shapes.
// ── parseObjects2 ──────────────────────────────────────────────────────────
test('parseObjects2: single type', () => {
const types = parseObjects2('Jacob:MX=1:SB=0:RT=0:TO=0:KL=0:RK=0:CA=1:DN=-1:DX=-1:SP=1:PR=-1')
assert.deepEqual(types, [{ type: 'Jacob', max: 1 }])
})
test('parseObjects2: splits six types on :OBJ= and keeps each MX', () => {
// Verbatim from trammel.xml — the case that a naive split(':') destroys.
const raw =
'Gazer:MX=1:SB=0:RT=0:TO=0:KL=0:RK=0:CA=1:DN=-1:DX=-1:SP=1:PR=-1' +
':OBJ=Giantspider:MX=1:SB=0:RT=0:TO=0:KL=0:RK=0:CA=1:DN=-1:DX=-1:SP=1:PR=-1' +
':OBJ=Harpy:MX=1:SB=0:RT=0:TO=0:KL=0:RK=0:CA=1:DN=-1:DX=-1:SP=1:PR=-1' +
':OBJ=Headlessone:MX=1:SB=0:RT=0:TO=0:KL=0:RK=0:CA=1:DN=-1:DX=-1:SP=1:PR=-1' +
':OBJ=Lizardman:MX=3:SB=0:RT=0:TO=0:KL=0:RK=0:CA=1:DN=-1:DX=-1:SP=1:PR=-1' +
':OBJ=Mongbat:MX=1:SB=0:RT=0:TO=0:KL=0:RK=0:CA=1:DN=-1:DX=-1:SP=1:PR=-1'
const types = parseObjects2(raw)
assert.equal(types.length, 6)
assert.deepEqual(
types.map((t) => t.type),
['Gazer', 'Giantspider', 'Harpy', 'Headlessone', 'Lizardman', 'Mongbat'],
)
// MX is per type, not per spawner: the lizardman entry carries 3.
assert.equal(types.find((t) => t.type === 'Lizardman').max, 3)
assert.equal(types.find((t) => t.type === 'Gazer').max, 1)
})
test('parseObjects2: empty and whitespace values yield no types', () => {
assert.deepEqual(parseObjects2(''), [])
assert.deepEqual(parseObjects2(' '), [])
assert.deepEqual(parseObjects2(null), [])
assert.deepEqual(parseObjects2(undefined), [])
})
test('parseObjects2: strips XmlSpawner property directives after "/"', () => {
// Left in place these become creatures that do not exist.
assert.deepEqual(parseObjects2('Agralem/Name/Agralem:MX=1'), [{ type: 'Agralem', max: 1 }])
assert.deepEqual(parseObjects2('alchemist/z/-50:MX=1'), [{ type: 'alchemist', max: 1 }])
assert.deepEqual(parseObjects2('GargishRefugee/hue/34532'), [
{ type: 'GargishRefugee', max: 1 },
])
})
test('parseObjects2: strips argument lists after ","', () => {
assert.deepEqual(parseObjects2('Fairy,{RND,4,8}:MX=1'), [{ type: 'Fairy', max: 1 }])
assert.deepEqual(parseObjects2('GargishRouser,1'), [{ type: 'GargishRouser', max: 1 }])
assert.deepEqual(parseObjects2('greatape,true'), [{ type: 'greatape', max: 1 }])
})
test('parseObjects2: a directive-laden token slugs the same as the bare one', () => {
// The bug this closes: `Fairy` and `Fairy,{RND,4,8}` slugged apart and showed
// as two different creatures on the same page.
const bare = parseObjects2('Fairy:MX=1')[0]
const decorated = parseObjects2('Fairy,{RND,4,8}:MX=1')[0]
assert.equal(slugify(decorated.type), slugify(bare.type))
})
test('parseObjects2: strips a long EQUIP directive chain containing "<" and ">"', () => {
const raw =
'xmlquestnpc/UNEQUIP,Innertorso/UNEQUIP,MiddleTorso/EQUIP/<robe/loottype/blessed' +
'/itemid/8259>/blessed/true/name/lord blackthorne/z/:MX=1'
assert.deepEqual(parseObjects2(raw), [{ type: 'xmlquestnpc', max: 1 }])
})
test('parseObjects2: a token that is only a directive yields nothing', () => {
assert.deepEqual(parseObjects2('/Name/Foo:MX=1'), [])
assert.deepEqual(parseObjects2(',1:MX=1'), [])
})
test('parseObjects2: a type with no MX token defaults to 1', () => {
assert.deepEqual(parseObjects2('Orc'), [{ type: 'Orc', max: 1 }])
assert.deepEqual(parseObjects2('Orc:SB=0:RT=0'), [{ type: 'Orc', max: 1 }])
})
// ── parsePoints ────────────────────────────────────────────────────────────
const POINTS_XML = `<Spawns>
<Points>
<Name>CovetousSpawner26</Name>
<UniqueId>001a34e5-0efa-46de-9c93-b6a163d96370</UniqueId>
<Map>Trammel</Map>
<X>5412</X>
<Y>1970</Y>
<Width>10</Width>
<Height>10</Height>
<Range>5</Range>
<MaxCount>3</MaxCount>
<MinDelay>5</MinDelay>
<MaxDelay>10</MaxDelay>
<ProximityTriggerSound>500</ProximityTriggerSound>
<TODStart>0</TODStart>
<TODEnd>0</TODEnd>
<TODMode>0</TODMode>
<IsRunning>True</IsRunning>
<Objects2>Lizardman:MX=3:SB=0</Objects2>
</Points>
<Points>
<Name>Disabled</Name>
<Map>Felucca</Map>
<X>100</X>
<Y>200</Y>
<MaxCount>1</MaxCount>
<IsRunning>False</IsRunning>
<Objects2>Orc:MX=1</Objects2>
</Points>
</Spawns>`
test('parsePoints: reads the kept fields and drops the rest', () => {
const points = parsePoints(POINTS_XML)
assert.equal(points.length, 2)
const covetous = points[0]
assert.equal(covetous.name, 'CovetousSpawner26')
assert.equal(covetous.facet, 'Trammel')
assert.equal(covetous.x, 5412)
assert.equal(covetous.y, 1970)
assert.equal(covetous.width, 10)
assert.equal(covetous.range, 5)
assert.equal(covetous.maxCount, 3)
// Delays are normalised to seconds; this record carries no DelayInSec, which
// means minutes.
assert.equal(covetous.minDelay, 300)
assert.equal(covetous.maxDelay, 600)
assert.deepEqual(covetous.types, [{ type: 'Lizardman', max: 3 }])
// Dropped fields must not survive into the artifact — this is what keeps it
// under 1 MB.
assert.equal(covetous.uniqueId, undefined)
assert.equal(covetous.proximityTriggerSound, undefined)
})
test('parsePoints: IsRunning is parsed so the build can drop dead spawners', () => {
const points = parsePoints(POINTS_XML)
assert.equal(points[0].running, true)
assert.equal(points[1].running, false)
})
test('parsePoints: facet comes from <Map>, never the file name', () => {
// Eodon.xml holds TerMur points; a file-name assumption would mislabel every
// one of them.
const points = parsePoints(
'<Spawns><Points><Name>a</Name><Map>TerMur</Map><X>1</X><Y>2</Y></Points></Spawns>',
)
assert.equal(points[0].facet, 'TerMur')
})
test('parsePoints: a record with no <Map> is skipped rather than misfiled', () => {
const points = parsePoints('<Spawns><Points><Name>a</Name><X>1</X><Y>2</Y></Points></Spawns>')
assert.deepEqual(points, [])
})
test('parsePoints: empty document yields no points', () => {
assert.deepEqual(parsePoints('<Spawns></Spawns>'), [])
assert.deepEqual(parsePoints(''), [])
})
// ── parseRegions ───────────────────────────────────────────────────────────
const REGIONS_XML = `<?xml version="1.0" encoding="utf-8"?>
<ServerRegions>
<Facet name="Felucca">
<region type="GuardedRegion" priority="50" name="Moongates">
<!-- britain -->
<rect x="1330" y="1991" width="13" height="13" />
<rect x="761" y="741" width="19" height="21" />
</region>
<region type="MondainRegion" priority="50" name="Prism of Light">
<rect x="6400" y="0" width="221" height="255" />
<go x="6474" y="188" z="0" />
<music name="Dungeon9" />
<region type="CrystalField" name="Crystal Field">
<rect x="6506" y="83" width="7" height="7" />
<zrange min="-4" />
</region>
<region type="IcyRiver">
<rect x="6576" y="73" width="10" height="31" />
</region>
</region>
<region type="TownRegion" priority="10" name="Music Only">
<music name="Britain" />
</region>
</Facet>
</ServerRegions>`
test('parseRegions: flattens nested regions and collects rects', () => {
const regions = parseRegions(REGIONS_XML)
const byName = new Map(regions.map((r) => [r.name, r]))
assert.ok(byName.has('Moongates'))
assert.ok(byName.has('Prism of Light'))
assert.equal(byName.get('Moongates').rects.length, 2)
assert.deepEqual(byName.get('Moongates').rects[0], {
x: 1330,
y: 1991,
width: 13,
height: 13,
})
assert.equal(byName.get('Moongates').facet, 'Felucca')
assert.equal(byName.get('Moongates').type, 'GuardedRegion')
})
test('parseRegions: a nested child records its parent', () => {
const regions = parseRegions(REGIONS_XML)
const crystal = regions.find((r) => r.name === 'Crystal Field')
assert.ok(crystal, 'the nested named region should be indexed')
assert.equal(crystal.parent, 'Prism of Light')
assert.equal(crystal.facet, 'Felucca')
})
test('parseRegions: a child with no priority inherits its parent', () => {
// Defaulting to 0 instead would sort this specific room below every
// top-level region that contains it.
const crystal = parseRegions(REGIONS_XML).find((r) => r.name === 'Crystal Field')
assert.equal(crystal.priority, 50)
})
test('parseRegions: unnamed regions are skipped but still walked', () => {
const regions = parseRegions(REGIONS_XML)
// IcyRiver has a type but no name — it cannot label anything.
assert.equal(regions.some((r) => r.type === 'IcyRiver'), false)
})
test('parseRegions: a named region with no rects is not indexed', () => {
// It can never contain a point, so indexing it only costs scan time.
assert.equal(parseRegions(REGIONS_XML).some((r) => r.name === 'Music Only'), false)
})
// ── parseLocations ─────────────────────────────────────────────────────────
const LOCATIONS_XML = `<?xml version="1.0" encoding="utf-8" standalone="yes" ?>
<places>
<parent name="Trammel">
<parent name="Dungeons">
<parent name="Covetous">
<child name="Entrance" x="2499" y="919" z="0" />
<child name="Level 1" x="5456" y="1863" z="0" />
</parent>
<parent name="Despise">
<child name="Level 3" x="5407" y="859" z="45" />
</parent>
</parent>
</parent>
</places>`
test('parseLocations: flattens to points carrying their group', () => {
const landmarks = parseLocations(LOCATIONS_XML)
assert.equal(landmarks.length, 3)
const level1 = landmarks.find((l) => l.name === 'Level 1')
assert.equal(level1.x, 5456)
assert.equal(level1.y, 1863)
assert.equal(level1.z, 0)
assert.equal(level1.facet, 'Trammel')
// "Covetous" is the useful label, not "Level 1".
assert.equal(level1.group, 'Covetous')
// The facet-level parent is dropped from the path.
assert.deepEqual(level1.path, ['Dungeons', 'Covetous'])
})
// ── Facet canonicalisation ─────────────────────────────────────────────────
// Facets are NOT a fixed list — a shard may add, replace or rename them when its
// maps are updated, so nothing may hardcode the stock six. Reconciliation is by
// matching against whatever the shard's own files declare.
test('facetKey: collapses spelling differences to one key', () => {
assert.equal(facetKey('Ter Mur'), facetKey('TerMur'))
assert.equal(facetKey('ter-mur'), facetKey('TerMur'))
assert.equal(facetKey('Felucca'), 'felucca')
assert.equal(facetKey(''), '')
assert.equal(facetKey(null), '')
})
test('facetKey: distinct facets keep distinct keys', () => {
assert.notEqual(facetKey('Felucca'), facetKey('Trammel'))
})
test('resolveFacetName: matches a loose spelling to the discovered canonical', () => {
// The canonical set comes from the shard's own spawn/region data, not a table.
const index = buildFacetIndex(['TerMur', 'Tokuno', 'Felucca'])
assert.equal(resolveFacetName('Ter Mur', index), 'TerMur')
assert.equal(resolveFacetName('Tokuno Islands', index), 'Tokuno')
assert.equal(resolveFacetName('felucca', index), 'Felucca')
})
test('resolveFacetName: works for facets that do not exist in stock UO', () => {
// The whole point: a shard running its own maps gets the same treatment as
// the stock ones, with no entry anywhere naming them.
const index = buildFacetIndex(['Sosaria', 'The Underdark'])
assert.equal(resolveFacetName('sosaria', index), 'Sosaria')
assert.equal(resolveFacetName('The Underdark', index), 'The Underdark')
assert.equal(resolveFacetName('the-underdark', index), 'The Underdark')
// Same shape as the real `Tokuno Islands` → `Tokuno` case.
assert.equal(resolveFacetName('Sosaria Isles', index), 'Sosaria')
})
test('resolveFacetName: a merely similar name is NOT forced to match', () => {
// "Underdark Isles" is not a prefix of "The Underdark" in either direction.
// Keeping its own name is right — a wrong match would silently file a real
// custom facet's landmarks under the wrong facet.
const index = buildFacetIndex(['The Underdark'])
assert.equal(resolveFacetName('Underdark Isles', index), 'Underdark Isles')
})
test('resolveFacetName: prefers the longer match when several could prefix', () => {
const index = buildFacetIndex(['Tokuno', 'TokunoDeep'])
assert.equal(resolveFacetName('TokunoDeep Reaches', index), 'TokunoDeep')
})
test('resolveFacetName: an unmatched facet keeps its own name', () => {
// Inventing a match would be worse than leaving a real custom facet alone.
const index = buildFacetIndex(['Felucca'])
assert.equal(resolveFacetName('Ilshenar', index), 'Ilshenar')
assert.equal(resolveFacetName('', index), '')
assert.equal(resolveFacetName(null, index), '')
})
test('buildFacetIndex: first spelling wins and is stable', () => {
const index = buildFacetIndex(['TerMur', 'Ter Mur', 'ter-mur'])
assert.equal(index.size, 1)
assert.equal(resolveFacetName('Ter Mur', index), 'TerMur')
})
test('parsePoints and parseRegions report facet names verbatim', () => {
// <Map> and <Facet name> are the authority; they are never rewritten.
const points = parsePoints(
'<Spawns><Points><Name>a</Name><Map>Sosaria</Map><X>1</X><Y>2</Y></Points></Spawns>',
)
assert.equal(points[0].facet, 'Sosaria')
const regions = parseRegions(
'<ServerRegions><Facet name="Sosaria"><region name="Town" priority="1">' +
'<rect x="0" y="0" width="10" height="10"/></region></Facet></ServerRegions>',
)
assert.equal(regions[0].facet, 'Sosaria')
})
test('placement index buckets two spellings of one facet together', () => {
// This is the bug the key exists to prevent: unreconciled, the landmark bucket
// is keyed apart from the points looking it up, the fallback never fires, and
// every unregioned spawn on that facet silently reads "Wilderness".
const index = buildPlacementIndex(
[],
[{ facet: 'Ter Mur', name: 'Bank', group: 'Holy City', path: [], x: 1000, y: 1000, z: 0 }],
)
assert.equal(resolveRegion(1000, 1000, 'TerMur', index).landmark, 'Holy City')
})
// ── parseChampions ─────────────────────────────────────────────────────────
const CHAMPIONS_XML = `<?xml version="1.0" encoding="UTF-8"?>
<championSystem>
<!-- comment describing the schema -->
<spawn name="Deceit" group="FelDungeons" type="UnholyTerror">
<location x="5178" y="708" z="20" map="Felucca" radius="60" />
</spawn>
<spawn name="Wandering" group="FelDungeons">
<location x="100" y="200" z="0" map="Felucca" radius="40" />
</spawn>
</championSystem>`
test('parseChampions: reads altar name, type and location', () => {
const champs = parseChampions(CHAMPIONS_XML)
assert.equal(champs.length, 2)
assert.deepEqual(champs[0], {
name: 'Deceit',
group: 'FelDungeons',
type: 'UnholyTerror',
randomType: false,
facet: 'Felucca',
x: 5178,
y: 708,
z: 20,
radius: 60,
})
})
test('parseChampions: a spawn with no type is flagged random, not blank', () => {
const champs = parseChampions(CHAMPIONS_XML)
assert.equal(champs[1].randomType, true)
assert.equal(champs[1].type, '')
})
// ── resolveRegion ──────────────────────────────────────────────────────────
function fixtureIndex() {
const regions = [
{
facet: 'Felucca',
name: 'Britain',
type: 'TownRegion',
priority: 10,
parent: null,
rects: [{ x: 1000, y: 1000, width: 500, height: 500 }],
},
{
facet: 'Felucca',
name: 'Britain Bank',
type: 'TownRegion',
priority: 50,
parent: 'Britain',
rects: [{ x: 1400, y: 1400, width: 20, height: 20 }],
},
{
facet: 'Felucca',
name: 'Wide Low Priority',
type: 'TownRegion',
priority: 10,
parent: null,
rects: [{ x: 1000, y: 1000, width: 2000, height: 2000 }],
},
]
const landmarks = [
{ facet: 'Felucca', name: 'Level 1', group: 'Covetous', path: [], x: 5000, y: 5000, z: 0 },
{ facet: 'Felucca', name: 'Far Away', group: 'Vesper', path: [], x: 9000, y: 9000, z: 0 },
]
return buildPlacementIndex(regions, landmarks)
}
test('resolveRegion: a contained point takes the region name', () => {
const result = resolveRegion(1100, 1100, 'Felucca', fixtureIndex())
assert.equal(result.region, 'Britain')
assert.equal(result.label, 'Britain')
assert.equal(result.landmark, null)
})
test('resolveRegion: higher priority wins over a containing region', () => {
const result = resolveRegion(1410, 1410, 'Felucca', fixtureIndex())
assert.equal(result.region, 'Britain Bank')
})
test('resolveRegion: equal priority breaks toward the smaller rect', () => {
// Both "Britain" (500x500) and "Wide Low Priority" (2000x2000) contain this
// point at priority 10; the specific one must win.
const result = resolveRegion(1200, 1200, 'Felucca', fixtureIndex())
assert.equal(result.region, 'Britain')
})
test('resolveRegion: rects are half-open — the far edge is outside', () => {
const index = fixtureIndex()
// Britain spans x 1000..1499. 1499 is in, 1500 belongs to the next region.
assert.equal(resolveRegion(1499, 1499, 'Felucca', index).region, 'Britain')
assert.equal(resolveRegion(1500, 1500, 'Felucca', index).region, 'Wide Low Priority')
})
test('resolveRegion: falls back to the nearest landmark group', () => {
const result = resolveRegion(5050, 5050, 'Felucca', fixtureIndex())
assert.equal(result.region, null)
assert.equal(result.landmark, 'Covetous')
assert.equal(result.label, 'Covetous')
})
test('resolveRegion: a landmark beyond the radius yields Wilderness', () => {
// Without the radius cap the nearest landmark is always *some* landmark, and
// open countryside would get labelled with a dungeon across the map.
const result = resolveRegion(7000, 7000, 'Felucca', fixtureIndex())
assert.equal(result.landmark, null)
assert.equal(result.label, 'Wilderness')
})
test('resolveRegion: the radius is configurable', () => {
const wide = resolveRegion(7000, 7000, 'Felucca', fixtureIndex(), { landmarkRadius: 5000 })
assert.equal(wide.label, 'Covetous')
})
test('resolveRegion: an unknown facet degrades to Wilderness, not a throw', () => {
const result = resolveRegion(1100, 1100, 'Malas', fixtureIndex())
assert.equal(result.label, 'Wilderness')
assert.equal(result.region, null)
})
test('resolveRegion: does not leak across facets', () => {
const index = buildPlacementIndex(
[
{
facet: 'Trammel',
name: 'Britain',
type: 'TownRegion',
priority: 10,
parent: null,
rects: [{ x: 1000, y: 1000, width: 500, height: 500 }],
},
],
[],
)
assert.equal(resolveRegion(1100, 1100, 'Trammel', index).region, 'Britain')
assert.equal(resolveRegion(1100, 1100, 'Felucca', index).region, null)
})
// ── Tokenizer edge cases ───────────────────────────────────────────────────
test('parseXml: skips comments, declarations and DOCTYPE', () => {
const root = parseXml(
'<?xml version="1.0"?><!DOCTYPE r><r><!-- <fake a="b"/> --><a x="1"/></r>',
)
assert.equal(root.name, 'r')
assert.equal(root.children.length, 1)
assert.equal(root.children[0].name, 'a')
assert.equal(root.children[0].attrs.x, '1')
})
test('parseXml: a ">" inside an attribute value does not end the tag', () => {
const root = parseXml('<r><a name="1 > 0" b="2"/></r>')
assert.equal(root.children[0].attrs.name, '1 > 0')
assert.equal(root.children[0].attrs.b, '2')
})
test('parseXml: single-quoted attributes are read', () => {
const root = parseXml("<r><a name='Mondain' /></r>")
assert.equal(root.children[0].attrs.name, 'Mondain')
})
test('parseXml: a stray closing tag is ignored, not fatal', () => {
// Hand-maintained shard config: one malformed element should degrade to a
// missing element, not abort an otherwise good build.
const root = parseXml('<r><a/></b><c/></r>')
assert.equal(root.name, 'r')
assert.deepEqual(root.children.map((n) => n.name), ['a', 'c'])
})
test('parseXml: empty or element-free input yields null', () => {
assert.equal(parseXml(''), null)
assert.equal(parseXml('<!-- only a comment -->'), null)
})
test('decodeEntities: named, numeric and hex refs', () => {
assert.equal(decodeEntities("Mondain&apos;s Legacy"), "Mondain's Legacy")
assert.equal(decodeEntities('a &amp; b'), 'a & b')
assert.equal(decodeEntities('&lt;tag&gt;'), '<tag>')
assert.equal(decodeEntities('&#65;&#x42;'), 'AB')
// An unknown entity is left alone rather than silently eaten.
assert.equal(decodeEntities('&nosuch;'), '&nosuch;')
})
test('parseXml: decodes entities in attribute values', () => {
const root = parseXml('<r><region name="Mondain&apos;s Legacy" /></r>')
assert.equal(root.children[0].attrs.name, "Mondain's Legacy")
})
// ── slugify ────────────────────────────────────────────────────────────────
test('slugify: produces URL-safe keys', () => {
assert.equal(slugify('Lizardman'), 'lizardman')
assert.equal(slugify('Giant Spider'), 'giant-spider')
assert.equal(slugify("Mondain's Legacy"), 'mondain-s-legacy')
assert.equal(slugify(' Orc '), 'orc')
})
// ── Respawn delays: the unit is per record ──────────────────────────────────
// XmlSpawner writes minutes by default and switches to seconds only when a
// delay does not divide into whole minutes, flagged by DelayInSec. `5` therefore
// means five MINUTES on one spawner and five SECONDS on the next, and a reader
// assuming either unit is wrong about the other — silently, since both are
// plausible respawn times.
const DELAY_XML = `<Spawns>
<Points>
<Name>Minutes</Name>
<Map>Sosaria</Map>
<X>1</X><Y>1</Y>
<MinDelay>5</MinDelay>
<MaxDelay>10</MaxDelay>
<IsRunning>True</IsRunning>
<Objects2>Orc:MX=1</Objects2>
</Points>
<Points>
<Name>Seconds</Name>
<Map>Sosaria</Map>
<X>2</X><Y>2</Y>
<DelayInSec>True</DelayInSec>
<MinDelay>5</MinDelay>
<MaxDelay>10</MaxDelay>
<IsRunning>True</IsRunning>
<Objects2>Orc:MX=1</Objects2>
</Points>
</Spawns>`
test('parsePoints: DelayInSec decides the unit, and both come out in seconds', () => {
const [minutes, seconds] = parsePoints(DELAY_XML)
assert.equal(minutes.minDelay, 300)
assert.equal(minutes.maxDelay, 600)
assert.equal(seconds.minDelay, 5)
assert.equal(seconds.maxDelay, 10)
})

View File

@@ -0,0 +1,399 @@
// Ported from core in Phase 3 (MODULE_SYSTEM.md §2.7.1). One change runs through
// every moved test: core internals can no longer be stubbed by requiring them,
// because there are none to require — `../utils/db` and `../model/settings` do
// not exist here. What a test controls instead is the `ctx` core would have
// handed over, installed once by `test/_setup.js`, which is the seam the
// contract actually promises.
const fs = require('fs')
const os = require('os')
const path = require('path')
const { test, after, beforeEach } = require('node:test')
const assert = require('node:assert/strict')
const {
AtlasSourceError,
aggregateCreatures,
displayName,
sameSources,
hashSources,
buildAtlas,
PARSER_VERSION,
} = require('../utils/spawnAtlasSource')
const shardAtlas = require('../model/shardAtlas/shardAtlas.model')
const atlasDb = require('../model/shardAtlas/shardAtlas.db')
const { ctx } = require('./_setup')
const settings = ctx.settings
// ── A tiny synthetic ServUO tree ───────────────────────────────────────────
//
// Deliberately uses facets that do NOT exist in stock UO. The atlas must not
// contain a built-in facet list anywhere: a shard may add facets, replace them
// outright, or rename them when its maps are updated, and everything has to keep
// working with no code change.
function writeTree(root, { facets = ['Sosaria'], includeChampions = true } = {}) {
fs.mkdirSync(path.join(root, 'Spawns'), { recursive: true })
fs.mkdirSync(path.join(root, 'Data', 'Locations'), { recursive: true })
fs.mkdirSync(path.join(root, 'Config'), { recursive: true })
for (const facet of facets) {
fs.writeFileSync(
path.join(root, 'Spawns', `${facet}.xml`),
`<Spawns>
<Points><Name>${facet}A</Name><Map>${facet}</Map><X>1100</X><Y>1100</Y>
<MaxCount>3</MaxCount><IsRunning>True</IsRunning>
<Objects2>Lizardman:MX=3:SB=0:OBJ=Orc:MX=1:SB=0</Objects2></Points>
<Points><Name>${facet}B</Name><Map>${facet}</Map><X>9000</X><Y>9000</Y>
<MaxCount>1</MaxCount><IsRunning>True</IsRunning>
<Objects2>lizardman:MX=2:SB=0</Objects2></Points>
<Points><Name>${facet}Off</Name><Map>${facet}</Map><X>1</X><Y>1</Y>
<MaxCount>1</MaxCount><IsRunning>False</IsRunning>
<Objects2>Ghost:MX=1</Objects2></Points>
</Spawns>`,
'utf8',
)
// The location file names its facet differently from <Map>, the real
// `Ter Mur` / `Tokuno Islands` drift.
fs.writeFileSync(
path.join(root, 'Data', 'Locations', `${facet.toLowerCase()}.xml`),
`<places><parent name="${facet} Isles"><parent name="Deep Cave">
<child name="Level 1" x="9010" y="9010" z="0" /></parent></parent></places>`,
'utf8',
)
}
fs.writeFileSync(
path.join(root, 'Data', 'Regions.xml'),
`<ServerRegions>${facets
.map(
(facet) => `<Facet name="${facet}">
<region type="TownRegion" priority="10" name="${facet} City">
<rect x="1000" y="1000" width="500" height="500" />
</region></Facet>`,
)
.join('')}</ServerRegions>`,
'utf8',
)
if (includeChampions) {
fs.writeFileSync(
path.join(root, 'Config', 'ChampionSpawns.xml'),
`<championSystem><spawn name="Deep" group="G" type="Terror">
<location x="1100" y="1100" z="0" map="${facets[0]}" radius="40" />
</spawn></championSystem>`,
'utf8',
)
}
}
function tempTree(options) {
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'atlas-test-'))
writeTree(root, options)
return root
}
// ── buildAtlas against a custom-facet tree ─────────────────────────────────
test('buildAtlas: works entirely on facets that do not exist in stock UO', () => {
const root = tempTree({ facets: ['Sosaria', 'Underdark'] })
const atlas = buildAtlas(root)
assert.deepEqual(atlas.facets, ['Sosaria', 'Underdark'])
assert.equal(atlas.meta.counts.facets, 2)
})
test('buildAtlas: reconciles a location file that spells the facet differently', () => {
// "Sosaria Isles" inside the file vs <Map>Sosaria</Map> — the same drift that
// silently emptied the Ter Mur / Tokuno landmark buckets.
const root = tempTree({ facets: ['Sosaria'] })
const atlas = buildAtlas(root)
assert.deepEqual([...new Set(atlas.landmarks.map((l) => l.facet))], ['Sosaria'])
// And the fallback actually fires, rather than the point reading Wilderness.
const far = atlas.points.find((p) => p.name === 'SosariaB')
assert.equal(far.landmark, 'Deep Cave')
assert.equal(far.label, 'Deep Cave')
})
test('buildAtlas: resolves a contained point to its region', () => {
const atlas = buildAtlas(tempTree({ facets: ['Sosaria'] }))
const inCity = atlas.points.find((p) => p.name === 'SosariaA')
assert.equal(inCity.region, 'Sosaria City')
assert.equal(inCity.label, 'Sosaria City')
})
test('buildAtlas: drops spawners that are switched off in-world', () => {
const atlas = buildAtlas(tempTree({ facets: ['Sosaria'] }))
assert.equal(atlas.points.some((p) => p.name === 'SosariaOff'), false)
assert.equal(atlas.meta.counts.pointsDisabled, 1)
})
test('buildAtlas: a champion altar resolves through the same placement index', () => {
const atlas = buildAtlas(tempTree({ facets: ['Sosaria'] }))
assert.equal(atlas.champions[0].label, 'Sosaria City')
assert.equal(atlas.champions[0].facet, 'Sosaria')
})
test('buildAtlas: a tree with no champion file still builds', () => {
const atlas = buildAtlas(tempTree({ facets: ['Sosaria'], includeChampions: false }))
assert.deepEqual(atlas.champions, [])
})
test('buildAtlas: missing path and empty path raise typed errors', () => {
assert.throws(() => buildAtlas(''), (err) => err instanceof AtlasSourceError && err.code === 'NO_PATH')
assert.throws(
() => buildAtlas(path.join(os.tmpdir(), 'definitely-not-a-servuo-tree-xyz')),
(err) => err instanceof AtlasSourceError && err.code === 'NOT_FOUND',
)
})
test('buildAtlas: a directory with no spawn files raises rather than building empty', () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'atlas-empty-'))
fs.mkdirSync(path.join(root, 'Data'), { recursive: true })
fs.writeFileSync(path.join(root, 'Data', 'Regions.xml'), '<ServerRegions/>', 'utf8')
assert.throws(() => buildAtlas(root), (err) => err.code === 'NO_SPAWNS')
})
// ── Hashing ────────────────────────────────────────────────────────────────
test('hashSources: stable across reads, changes when a file changes', () => {
const root = tempTree({ facets: ['Sosaria'] })
const first = hashSources(root)
assert.ok(sameSources(first, hashSources(root)))
fs.appendFileSync(path.join(root, 'Spawns', 'Sosaria.xml'), '<!-- edit -->', 'utf8')
assert.equal(sameSources(first, hashSources(root)), false)
})
test('sameSources: a missing or extra file is a difference', () => {
assert.equal(sameSources({ a: '1' }, { a: '1', b: '2' }), false)
assert.equal(sameSources({ a: '1' }, { a: '2' }), false)
assert.equal(sameSources({ a: '1' }, { a: '1' }), true)
assert.equal(sameSources(null, { a: '1' }), false)
assert.equal(sameSources({ a: '1' }, null), false)
})
// ── Aggregation ────────────────────────────────────────────────────────────
const POINTS = [
{ facet: 'Sosaria', types: [{ type: 'Lizardman', max: 3 }, { type: 'Orc', max: 1 }] },
{ facet: 'Sosaria', types: [{ type: 'Lizardman', max: 2 }] },
{ facet: 'Underdark', types: [{ type: 'lizardman', max: 5 }] },
]
test('aggregateCreatures: sums each types own max and counts per facet', () => {
const lizardman = aggregateCreatures(POINTS).find((c) => c.slug === 'lizardman')
assert.equal(lizardman.total, 10)
assert.equal(lizardman.points, 3)
assert.deepEqual(lizardman.facets, { Sosaria: 2, Underdark: 1 })
})
test('aggregateCreatures: differing case collapses to one creature', () => {
const creatures = aggregateCreatures(POINTS)
assert.equal(creatures.filter((c) => c.slug === 'lizardman').length, 1)
assert.deepEqual(creatures.map((c) => c.slug), ['lizardman', 'orc'])
assert.equal(Object.hasOwn(creatures[0], 'spellings'), false)
})
test('displayName: most common wins, ties break to the capitalised form', () => {
assert.equal(displayName(new Map([['lizardman', 9], ['Lizardman', 2]])), 'lizardman')
assert.equal(displayName(new Map([['lizardman', 5], ['Lizardman', 5]])), 'Lizardman')
// Deterministic regardless of insertion order — a committed artifact is gone,
// but a spurious diff in the DB on every restart would be just as wrong.
assert.equal(
displayName(new Map([['abc', 1], ['abd', 1]])),
displayName(new Map([['abd', 1], ['abc', 1]])),
)
})
test('pointTypeRows: collapses a repeated type to the larger max', () => {
// The primary key is (point_id, slug), so a duplicate would otherwise fail the
// insert and take the whole transaction with it.
const rows = shardAtlas.pointTypeRows([
{ types: [{ type: 'Orc', max: 1 }, { type: 'orc', max: 4 }, { type: 'Rat', max: 2 }] },
])
assert.deepEqual(rows.sort(), [[1, 'orc', 4], [1, 'rat', 2]].sort())
})
test('pointTypeRows: point ids are 1-based and line up with insert order', () => {
const rows = shardAtlas.pointTypeRows([
{ types: [{ type: 'A', max: 1 }] },
{ types: [{ type: 'B', max: 1 }] },
])
assert.deepEqual(rows, [[1, 'a', 1], [2, 'b', 1]])
})
// ── The refresh decision ───────────────────────────────────────────────────
//
// The boot path's two contracts: it never blocks startup, and it never applies a
// facet removal on its own.
let applied
let pendingRow
let facetsInDb
let metaRow
beforeEach(() => {
applied = null
pendingRow = null
facetsInDb = []
metaRow = null
atlasDb.replaceAtlas = async (atlas) => {
applied = atlas
return { points: atlas.points.length, creatures: atlas.creatures.length }
}
atlasDb.getMeta = async () => metaRow
atlasDb.getFacets = async () => facetsInDb
atlasDb.getPending = async () => pendingRow
atlasDb.setPending = async (payload, status) => {
pendingRow = { ...payload, status }
}
atlasDb.clearPending = async () => {
pendingRow = null
}
settings.get = async () => ''
process.env.SERVUO_PATH = ''
})
test('refresh: no configured path is skipped, not an error', async () => {
const result = await shardAtlas.refresh()
assert.equal(result.status, 'skipped')
})
test('refresh: an unreadable tree reports unavailable rather than throwing', async () => {
const result = await shardAtlas.refresh({ path: path.join(os.tmpdir(), 'no-such-tree-abc') })
assert.equal(result.status, 'unavailable')
assert.equal(result.code, 'NOT_FOUND')
})
test('refresh: a fresh database imports', async () => {
const root = tempTree({ facets: ['Sosaria'] })
const result = await shardAtlas.refresh({ path: root })
assert.equal(result.status, 'imported')
assert.ok(applied)
assert.deepEqual(result.addedFacets, ['Sosaria'])
})
test('refresh: an unchanged tree parses nothing and writes nothing', async () => {
const root = tempTree({ facets: ['Sosaria'] })
metaRow = buildAtlas(root).meta
const result = await shardAtlas.refresh({ path: root })
assert.equal(result.status, 'unchanged')
assert.equal(applied, null)
})
// The hash gate alone would strand an install whose maps never change on
// whatever an older build derived: a corrected parse would ship and never reach
// the data, because the only thing compared is the tree.
test('refresh: an unchanged tree is REIMPORTED when the parser has moved on', async () => {
const root = tempTree({ facets: ['Sosaria'] })
metaRow = { ...buildAtlas(root).meta, parserVersion: PARSER_VERSION - 1 }
const result = await shardAtlas.refresh({ path: root })
assert.equal(result.status, 'imported')
assert.ok(applied)
})
test('refresh: an atlas imported before parser versions existed is stale', async () => {
const root = tempTree({ facets: ['Sosaria'] })
const meta = buildAtlas(root).meta
delete meta.parserVersion
metaRow = meta
const result = await shardAtlas.refresh({ path: root })
assert.equal(result.status, 'imported')
})
test('refresh: --force reimports an unchanged tree', async () => {
const root = tempTree({ facets: ['Sosaria'] })
metaRow = buildAtlas(root).meta
const result = await shardAtlas.refresh({ path: root, force: true })
assert.equal(result.status, 'imported')
assert.ok(applied)
})
test('refresh: a NEW facet applies straight away', async () => {
// Additions cannot destroy anything an operator would miss.
const root = tempTree({ facets: ['Sosaria', 'Underdark'] })
facetsInDb = ['Sosaria']
const result = await shardAtlas.refresh({ path: root })
assert.equal(result.status, 'imported')
assert.deepEqual(result.addedFacets, ['Underdark'])
})
test('refresh: a REMOVED facet is staged, not applied', async () => {
const root = tempTree({ facets: ['Sosaria'] })
facetsInDb = ['Sosaria', 'Underdark']
const result = await shardAtlas.refresh({ path: root })
assert.equal(result.status, 'needsReview')
assert.deepEqual(result.removedFacets, ['Underdark'])
// The critical part: the existing atlas was left alone.
assert.equal(applied, null)
assert.equal(pendingRow.status, 'pending')
})
test('refresh: approving applies the removal', async () => {
const root = tempTree({ facets: ['Sosaria'] })
facetsInDb = ['Sosaria', 'Underdark']
await shardAtlas.refresh({ path: root })
assert.equal(applied, null)
const result = await shardAtlas.approvePending({ path: root })
assert.equal(result.status, 'imported')
assert.ok(applied)
assert.deepEqual(result.removedFacets, ['Underdark'])
})
test('refresh: a rejected refresh does not re-prompt while the tree is unchanged', async () => {
const root = tempTree({ facets: ['Sosaria'] })
facetsInDb = ['Sosaria', 'Underdark']
await shardAtlas.refresh({ path: root })
await shardAtlas.rejectPending()
assert.equal(pendingRow.status, 'rejected')
const again = await shardAtlas.refresh({ path: root })
assert.equal(again.status, 'unchanged')
assert.equal(applied, null)
})
test('refresh: changing the tree asks again after a rejection', async () => {
const root = tempTree({ facets: ['Sosaria'] })
facetsInDb = ['Sosaria', 'Underdark']
await shardAtlas.refresh({ path: root })
await shardAtlas.rejectPending()
fs.appendFileSync(path.join(root, 'Spawns', 'Sosaria.xml'), '<!-- changed -->', 'utf8')
const again = await shardAtlas.refresh({ path: root })
assert.equal(again.status, 'needsReview')
})
test('refreshOnBoot: never throws, whatever goes wrong', async () => {
atlasDb.getMeta = async () => {
throw new Error('database is on fire')
}
atlasDb.getFacets = async () => {
throw new Error('still on fire')
}
atlasDb.replaceAtlas = async () => {
throw new Error('and the import too')
}
process.env.SERVUO_PATH = tempTree({ facets: ['Sosaria'] })
const result = await shardAtlas.refreshOnBoot()
assert.equal(result.status, 'failed')
})
test('refreshOnBoot: a missing tree is survivable, not fatal', async () => {
process.env.SERVUO_PATH = path.join(os.tmpdir(), 'nope-not-here-xyz')
const result = await shardAtlas.refreshOnBoot()
assert.equal(result.status, 'unavailable')
})
test('refresh: an explicit path overrides the configured one', async () => {
const configured = tempTree({ facets: ['Configured'] })
const override = tempTree({ facets: ['Override'] })
settings.get = async () => configured
const result = await shardAtlas.refresh({ path: override })
assert.equal(result.status, 'imported')
assert.deepEqual(result.addedFacets, ['Override'])
})

View File

@@ -0,0 +1,70 @@
// The town-crier leg's own tests, moved out of core's announceJobs.test.js in
// Phase 3 (MODULE_SYSTEM.md §2.7.1).
//
// Core keeps what it owns there — the backoff schedule, the parent-status rollup
// and the Discord leg — because those are the announce PIPELINE. What is here is
// this module's LEG: how a post becomes town-crier lines, and how the sidecar's
// answers classify into done / retry / terminal. The split is the same one the
// registry makes.
const { test } = require('node:test')
const assert = require('node:assert/strict')
const { fakeCtx } = require('./_fakes')
require('../core').init(fakeCtx())
const townCrier = require('../utils/shardAnnounce')
// ── buildTownCrierText ───────────────────────────────────────────────────────
test('buildTownCrierText produces title, excerpt, and URL lines', () => {
const lines = townCrier.buildTownCrierText(
{ id: 7, title: 'Server Update', excerpt: 'Big things afoot.', body: null },
{ baseUrl: 'https://uom.example' },
)
assert.deepEqual(lines, ['Server Update', 'Big things afoot.', 'https://uom.example/site/news'])
})
test('buildTownCrierText falls back to a stripped body when excerpt is empty', () => {
const lines = townCrier.buildTownCrierText(
{ id: 1, title: 'T', excerpt: '', body: '<p>Hello <b>world</b></p>' },
{ baseUrl: 'https://uom.example' },
)
assert.equal(lines[1], 'Hello world')
})
test('buildTownCrierText clamps each line to the sidecar per-line cap', () => {
const longTitle = 'x'.repeat(500)
const lines = townCrier.buildTownCrierText(
{ id: 1, title: longTitle, excerpt: 'y'.repeat(500), body: null },
{ baseUrl: 'https://uom.example' },
)
for (const line of lines) assert.ok(line.length <= townCrier.MAX_LINE_LEN, `line too long: ${line.length}`)
assert.ok(lines[0].endsWith('…'))
assert.ok(lines.length <= townCrier.MAX_LINES)
})
test('buildTownCrierText omits the excerpt line when there is no excerpt or body', () => {
const lines = townCrier.buildTownCrierText(
{ id: 1, title: 'Only a title', excerpt: null, body: null },
{ baseUrl: 'https://uom.example' },
)
assert.deepEqual(lines, ['Only a title', 'https://uom.example/site/news'])
})
// ── classifyTownCrier ────────────────────────────────────────────────────────
test('town crier classify: 2xx is done', () => {
assert.equal(townCrier.classify({ ok: true, status: 200 }).outcome, 'done')
})
test('town crier classify: over-cap / auth / protocol errors are terminal (no retry)', () => {
for (const status of [400, 401, 409]) {
assert.equal(townCrier.classify({ ok: false, status }).outcome, 'terminal', `status ${status}`)
}
})
test('town crier classify: shard-transient and network errors retry', () => {
for (const status of [503, 504, 500, 0]) {
assert.equal(townCrier.classify({ ok: false, status }).outcome, 'retry', `status ${status}`)
}
})

View File

@@ -0,0 +1,74 @@
// Ported from core in Phase 3 (MODULE_SYSTEM.md §2.7.1). One change runs through
// every moved test: core internals can no longer be stubbed by requiring them,
// because there are none to require — `../utils/db` and `../model/settings` do
// not exist here. What a test controls instead is the `ctx` core would have
// handed over, installed once by `test/_setup.js`, which is the seam the
// contract actually promises.
// Point the DB at a closed port BEFORE requiring the modules (they build the pool).
// The uoLinkConfig model is monkeypatched so no query runs.
const { test, after, afterEach } = require('node:test')
const assert = require('node:assert/strict')
// The uo-link REST client's headline contract (see its module header and
// CLAUDE.md): it NEVER throws — every call resolves to { ok, data, status, error }
// so a public page or an admin poll degrades to "shard unavailable" instead of
// 500ing. The regression these tests lock down: resolveConfig() decrypts the
// stored auth token, and secretBox.decrypt THROWS when the ciphertext can't be
// authenticated (SECRET_ENC_KEY rotated, or a DB dump restored under a different
// key). It used to run OUTSIDE call()'s try, so that throw escaped the client and
// 500'd every live-shard route.
const uoLinkClient = require('../utils/uoLinkClient')
const uoLinkConfig = require('../model/uoLinkConfig/uoLinkConfig.model')
const origGetWithToken = uoLinkConfig.getWithToken
afterEach(() => {
uoLinkConfig.getWithToken = origGetWithToken
uoLinkClient.invalidateConfig() // drop the 5s config cache between cases
})
test('an undecryptable stored token resolves to { ok: false } instead of throwing', async () => {
uoLinkConfig.getWithToken = async () => {
// Exactly what crypto's Decipheriv.final() raises on a bad key / tampered blob.
throw new Error('Unsupported state or unable to authenticate data')
}
uoLinkClient.invalidateConfig()
const result = await uoLinkClient.health()
assert.equal(result.ok, false, 'must report failure, not throw')
assert.equal(result.status, 0)
assert.match(result.error, /unreadable/i, 'distinguishes config failure from a dead sidecar')
})
test('every read helper stays on the { ok:false } contract when config is unreadable', async () => {
uoLinkConfig.getWithToken = async () => {
throw new Error('Unsupported state or unable to authenticate data')
}
uoLinkClient.invalidateConfig()
// The routes that regressed: character sheet, roster and vendor lookups, which
// are reachable from both /admin/shard/* and the player-facing /player/shard/*.
for (const call of [
() => uoLinkClient.getCharBySerial('0x1'),
() => uoLinkClient.getRoster('someacct'),
() => uoLinkClient.getVendors('someacct'),
]) {
const result = await call()
assert.equal(result.ok, false)
assert.equal(result.status, 0)
}
})
test('a missing/blank config still reports "not configured" (unchanged behaviour)', async () => {
uoLinkConfig.getWithToken = async () => null
uoLinkClient.invalidateConfig()
const result = await uoLinkClient.health()
assert.equal(result.ok, false)
assert.equal(result.status, 0)
assert.match(result.error, /not configured/i)
})