Add meaningful unit tests for three server models with untested business logic, each against an in-memory fake db (no DB required): - pages.model: slug validation + reserved-name guard, slug immutability, the protected ON-via-update / OFF-only-via-unprotect asymmetry, publish stamping, draft invisibility to public reads, dup-slug → 409, block gate. - shardState.model: partial-refresh field dropping (vitals must not clobber login fields), is_idoc derivation, economy clamp/ordering/Number coercion, presence zero-snapshot defaults, payload-fallback shaping, and the camelCase read-shaping contract the site + Android client depend on. - moderation.model: five-feed window merge, userSummary count/total semantics (total sums unknown types too), and graceful degradation when bot config is missing. Lifts: pages.model 23%→82%, moderation.model 27%→85%, shardState.model 33%→62% line coverage. Co-Authored-By: Claude <noreply@anthropic.com>
254 lines
11 KiB
JavaScript
254 lines
11 KiB
JavaScript
// 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
|
|
})
|