feat(server): the whole server half (phase 3, slice 1) #3

Merged
whitlocktech merged 3 commits from feature/module-extract-server into main 2026-08-11 21:05:59 +00:00
28 changed files with 4692 additions and 24 deletions
Showing only changes of commit 6b99d7e220 - Show all commits

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)
})