feat(server): the whole server half (phase 3, slice 1) #3
@@ -45,11 +45,18 @@ const { isBuiltin } = require('module')
|
|||||||
const MODULE_ROOT = path.resolve(__dirname, '..', '..')
|
const MODULE_ROOT = path.resolve(__dirname, '..', '..')
|
||||||
const SERVER_ROOT = path.join(MODULE_ROOT, 'server')
|
const SERVER_ROOT = path.join(MODULE_ROOT, 'server')
|
||||||
|
|
||||||
// Dependencies this half is allowed to resolve for itself. Empty, and that is
|
// Packages the SHIPPED half may resolve for itself: this package's declared
|
||||||
// the design: everything the server half needs comes from `ctx` (§2.3). A new
|
// `dependencies`, and nothing else. Read from package.json rather than listed
|
||||||
// entry here is a real decision — it becomes a package an operator's install
|
// here, so adding one is a visible, reviewable edit to the manifest that also
|
||||||
// has to carry — so it should be argued for in a PR, not added in passing.
|
// changes what CI installs and what the release tarball carries.
|
||||||
const ALLOWED_PACKAGES = new Set([])
|
//
|
||||||
|
// 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'])
|
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 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 isShipped = (file) => !NOT_SHIPPED.some((d) => file.startsWith(d + path.sep))
|
||||||
|
|
||||||
const devDependencies = new Set(
|
const manifest = JSON.parse(fs.readFileSync(path.join(SERVER_ROOT, 'package.json'), 'utf8'))
|
||||||
Object.keys(JSON.parse(fs.readFileSync(path.join(SERVER_ROOT, 'package.json'), 'utf8')).devDependencies || {}),
|
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
|
// `require('x')`, `from 'x'`, `import('x')`. Deliberately textual: parsing would
|
||||||
// need a dependency, and a specifier this pattern misses is a specifier written
|
// 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
|
* 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.
|
* 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 = []
|
const violations = []
|
||||||
for (const file of walk(root)) {
|
for (const file of walk(root)) {
|
||||||
const source = stripCommentsAndTemplates(fs.readFileSync(file, 'utf8'))
|
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('@')
|
const pkg = specifier.startsWith('@')
|
||||||
? specifier.split('/').slice(0, 2).join('/')
|
? specifier.split('/').slice(0, 2).join('/')
|
||||||
: specifier.split('/')[0]
|
: 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
|
// The `node:` prefix can only ever name a builtin, so it never reaches
|
||||||
// node_modules and is safe whatever this Node version enumerates.
|
// node_modules and is safe whatever this Node version enumerates.
|
||||||
const builtin = isBuiltin(specifier) || specifier.startsWith('node:')
|
const builtin = isBuiltin(specifier) || specifier.startsWith('node:')
|
||||||
|
|||||||
@@ -28,6 +28,10 @@ function fakeLog() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function fakeCtx(overrides = {}) {
|
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 logs = []
|
||||||
const ctx = {
|
const ctx = {
|
||||||
moduleId: 'uo',
|
moduleId: 'uo',
|
||||||
@@ -50,10 +54,19 @@ function fakeCtx(overrides = {}) {
|
|||||||
siteMode: (req, res, next) => next(),
|
siteMode: (req, res, next) => next(),
|
||||||
validate: (req, res, next) => next(),
|
validate: (req, res, next) => next(),
|
||||||
noindex: (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: {} },
|
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()) },
|
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
|
// 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
|
// `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
|
// faithful: a module iterating `ctx` sees exactly §2.3's members and nothing
|
||||||
// a test put there.
|
// a test put there.
|
||||||
Object.defineProperty(ctx, 'logs', { value: logs, enumerable: false })
|
Object.defineProperty(ctx, 'logs', { value: logs, enumerable: false })
|
||||||
|
if (!freeze) return ctx
|
||||||
for (const value of Object.values(ctx)) {
|
for (const value of Object.values(ctx)) {
|
||||||
if (value && typeof value === 'object') Object.freeze(value)
|
if (value && typeof value === 'object') Object.freeze(value)
|
||||||
}
|
}
|
||||||
|
|||||||
32
server/test/_helper.js
Normal file
32
server/test/_helper.js
Normal 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
27
server/test/_setup.js
Normal 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 }
|
||||||
154
server/test/adminUserShard.test.js
Normal file
154
server/test/adminUserShard.test.js
Normal 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 user’s 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 user’s 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 user’s 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.
|
||||||
242
server/test/atlasController.test.js
Normal file
242
server/test/atlasController.test.js
Normal 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 operator’s 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)
|
||||||
|
})
|
||||||
227
server/test/clilocParse.test.js
Normal file
227
server/test/clilocParse.test.js
Normal 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), '')
|
||||||
|
})
|
||||||
194
server/test/clilocSource.test.js
Normal file
194
server/test/clilocSource.test.js
Normal 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), [])
|
||||||
|
})
|
||||||
@@ -30,14 +30,59 @@ test('touches no database at registration time', () => {
|
|||||||
assert.deepStrictEqual(ctx.db.query.calls, [], 'register() queried the database')
|
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()
|
const api = fakeApi()
|
||||||
register(fakeCtx(), api)
|
register(fakeCtx(), api)
|
||||||
assert.strictEqual(api.record.routes, null)
|
|
||||||
assert.strictEqual(api.record.streams, null)
|
const manifest = require('../../module.json')
|
||||||
assert.deepStrictEqual(api.record.extensions, [])
|
for (const tier of ['public', 'admin', 'player']) {
|
||||||
assert.deepStrictEqual(api.record.legs, [])
|
assert.deepStrictEqual(
|
||||||
assert.deepStrictEqual(api.record.hooks, {})
|
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', () => {
|
test('takes a frozen ctx and does not try to write to it', () => {
|
||||||
|
|||||||
@@ -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', () => {
|
test('the manifest claims a coherent bundle', () => {
|
||||||
// Nothing to check yet — slice 0 registers neither. The assertion that matters
|
// `capabilities` is published by GET /api/v1/public/modules and is what a
|
||||||
// is that the manifest does not quietly claim capabilities the module does not
|
// client feature-detects against — the SPA and the Android app both read it —
|
||||||
// serve, since `GET /api/v1/public/modules` publishes them to clients.
|
// so it must not claim something the module does not serve. Checked as a shape
|
||||||
assert.deepStrictEqual(manifest.capabilities || [], [])
|
// rather than a list: which capabilities exist is a product decision, that
|
||||||
assert.deepStrictEqual(manifest.mounts || {}, {})
|
// they are non-empty opaque strings is the contract.
|
||||||
assert.deepStrictEqual(manifest.extensions || [], [])
|
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'])
|
||||||
})
|
})
|
||||||
|
|||||||
77
server/test/newsGump.test.js
Normal file
77
server/test/newsGump.test.js
Normal 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)
|
||||||
|
})
|
||||||
109
server/test/publicShardOnline.test.js
Normal file
109
server/test/publicShardOnline.test.js
Normal 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`)
|
||||||
|
})
|
||||||
227
server/test/shardBroadcast.visibility.test.js
Normal file
227
server/test/shardBroadcast.visibility.test.js
Normal 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(), [])
|
||||||
|
})
|
||||||
415
server/test/shardControllerPublic.test.js
Normal file
415
server/test/shardControllerPublic.test.js
Normal 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')
|
||||||
|
})
|
||||||
71
server/test/shardIngest.champsPages.test.js
Normal file
71
server/test/shardIngest.champsPages.test.js
Normal 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])
|
||||||
|
})
|
||||||
114
server/test/shardIngest.market.test.js
Normal file
114
server/test/shardIngest.market.test.js
Normal 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'])
|
||||||
|
})
|
||||||
111
server/test/shardIngest.points.test.js
Normal file
111
server/test/shardIngest.points.test.js
Normal 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)
|
||||||
|
})
|
||||||
121
server/test/shardIngest.protocol2.test.js
Normal file
121
server/test/shardIngest.protocol2.test.js
Normal 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)
|
||||||
|
})
|
||||||
161
server/test/shardIngest.ruleset.test.js
Normal file
161
server/test/shardIngest.ruleset.test.js
Normal 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')
|
||||||
|
})
|
||||||
263
server/test/shardMarket.model.test.js
Normal file
263
server/test/shardMarket.model.test.js
Normal 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')
|
||||||
|
})
|
||||||
77
server/test/shardState.governorTerms.test.js
Normal file
77
server/test/shardState.governorTerms.test.js
Normal 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()
|
||||||
|
})
|
||||||
253
server/test/shardState.model.test.js
Normal file
253
server/test/shardState.model.test.js
Normal 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
|
||||||
|
})
|
||||||
151
server/test/shardStreams.test.js
Normal file
151
server/test/shardStreams.test.js
Normal 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)
|
||||||
|
})
|
||||||
426
server/test/shardVisibility.test.js
Normal file
426
server/test/shardVisibility.test.js
Normal 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')
|
||||||
|
})
|
||||||
601
server/test/spawnAtlas.parse.test.js
Normal file
601
server/test/spawnAtlas.parse.test.js
Normal 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's Legacy"), "Mondain's Legacy")
|
||||||
|
assert.equal(decodeEntities('a & b'), 'a & b')
|
||||||
|
assert.equal(decodeEntities('<tag>'), '<tag>')
|
||||||
|
assert.equal(decodeEntities('AB'), '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'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)
|
||||||
|
})
|
||||||
399
server/test/spawnAtlas.source.test.js
Normal file
399
server/test/spawnAtlas.source.test.js
Normal 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 type’s 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'])
|
||||||
|
})
|
||||||
70
server/test/townCrier.test.js
Normal file
70
server/test/townCrier.test.js
Normal 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}`)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
74
server/test/uoLinkClient.test.js
Normal file
74
server/test/uoLinkClient.test.js
Normal 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)
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user