Files
Module-uo/server/test/shardState.model.test.js
wtclaude 6b99d7e220
All checks were successful
PR Checks / client-build (pull_request) Successful in 17s
PR Checks / server-tests (pull_request) Successful in 8m47s
test(server): port core's UO suite onto the ctx harness
22 test files moved from core, plus the two that were split out of files core
keeps. 351 tests pass.

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

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

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

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-11 12:07:15 -05:00

254 lines
12 KiB
JavaScript

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