// Point the DB pool at a dead port before it's built; every db method is // monkeypatched below, and pool.close() at the end lets the process exit cleanly. process.env.DB_HOST = '127.0.0.1' process.env.DB_PORT = '59999' const { test, beforeEach, afterEach, after } = require('node:test') const assert = require('node:assert/strict') const pool = require('../src/utils/db') after(() => pool.close()) // 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('../src/model/shardState/shardState.db') const shardState = require('../src/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 })