test(server): unit-test pages, shardState, and moderation model logic
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>
This commit is contained in:
110
server/test/moderation.model.test.js
Normal file
110
server/test/moderation.model.test.js
Normal file
@@ -0,0 +1,110 @@
|
||||
// Point the DB pool at a dead port before it's built; the db modules are
|
||||
// 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 moderation MODEL's orchestration (the pure helpers are covered
|
||||
// separately in moderation.test.js). What the model adds on top:
|
||||
// - summary() merges five independent window feeds (mod actions + member
|
||||
// joins/leaves + invite joins + filter/spam hits) into one windows object;
|
||||
// - userSummary() zero-fills per-type counts but total_actions sums EVERY row,
|
||||
// including action types not in the fixed count set;
|
||||
// - a missing/failing bot config degrades to "nothing automated", never throws.
|
||||
const moderationDb = require('../src/model/moderation/moderation.db')
|
||||
const botConfigDb = require('../src/model/botConfig/botConfig.db')
|
||||
const moderation = require('../src/model/moderation/moderation.model')
|
||||
|
||||
const saved = {}
|
||||
function patch(mod, name, fn) {
|
||||
saved[`${mod === moderationDb ? 'm' : 'b'}:${name}`] = mod[name]
|
||||
mod[name] = fn
|
||||
}
|
||||
afterEach(() => {
|
||||
for (const key of Object.keys(saved)) {
|
||||
const [tag, name] = key.split(':')
|
||||
const mod = tag === 'm' ? moderationDb : botConfigDb
|
||||
mod[name] = saved[key]
|
||||
delete saved[key]
|
||||
}
|
||||
})
|
||||
|
||||
// ── summary() merges every feed into the three windows ──────────────────
|
||||
test('summary folds mod-action counts and all four event feeds into each window', async () => {
|
||||
patch(moderationDb, 'countsByWindow', async () => [{ action_type: 'ban', d1: 1, d7: 2, d30: 3 }])
|
||||
patch(moderationDb, 'memberCountsByWindow', async () => [
|
||||
{ event_type: 'join', d1: 5, d7: 6, d30: 7 },
|
||||
{ event_type: 'leave', d1: 1, d7: 1, d30: 1 },
|
||||
])
|
||||
patch(moderationDb, 'inviteJoinCountsByWindow', async () => ({ d1: 2, d7: 2, d30: 2 }))
|
||||
patch(moderationDb, 'tableCountsByWindow', async (table) =>
|
||||
table === 'filter_hits' ? { d1: 9, d7: 9, d30: 9 } : { d1: 4, d7: 4, d30: 4 },
|
||||
)
|
||||
|
||||
const { windows } = await moderation.summary()
|
||||
assert.equal(windows['24h'].ban, 1)
|
||||
assert.equal(windows['7d'].ban, 2)
|
||||
assert.equal(windows['24h'].joins, 5)
|
||||
assert.equal(windows['24h'].leaves, 1)
|
||||
assert.equal(windows['24h'].invite_joins, 2)
|
||||
assert.equal(windows['24h'].filter_hits, 9)
|
||||
assert.equal(windows['24h'].spam_hits, 4)
|
||||
assert.equal(windows['30d'].joins, 7)
|
||||
})
|
||||
|
||||
test('summary zero-fills a feed that returned no rows for a window', async () => {
|
||||
patch(moderationDb, 'countsByWindow', async () => [])
|
||||
patch(moderationDb, 'memberCountsByWindow', async () => []) // no join/leave rows
|
||||
patch(moderationDb, 'inviteJoinCountsByWindow', async () => null)
|
||||
patch(moderationDb, 'tableCountsByWindow', async () => null)
|
||||
|
||||
const { windows } = await moderation.summary()
|
||||
assert.equal(windows['24h'].joins, 0)
|
||||
assert.equal(windows['7d'].filter_hits, 0)
|
||||
assert.equal(windows['30d'].ban, 0)
|
||||
})
|
||||
|
||||
// ── userSummary() count/total semantics ─────────────────────────────────
|
||||
test('userSummary zero-fills known types but total_actions sums every row', async () => {
|
||||
patch(moderationDb, 'userCounts', async () => [
|
||||
{ action_type: 'ban', c: '2' },
|
||||
{ action_type: 'warn', c: 3 },
|
||||
{ action_type: 'note', c: 5 }, // NOT in zeroCounts — excluded from counts, still in total
|
||||
])
|
||||
patch(moderationDb, 'latestTag', async () => 'Griefer#1')
|
||||
patch(moderationDb, 'linkedAccount', async () => ({ id: 9, username: 'griefer' }))
|
||||
|
||||
const out = await moderation.userSummary('123')
|
||||
assert.equal(out.counts.ban, 2)
|
||||
assert.equal(out.counts.warn, 3)
|
||||
assert.equal(out.counts.kick, 0) // zero-filled
|
||||
assert.equal(out.counts.note, undefined) // unknown type not surfaced as a count
|
||||
assert.equal(out.total_actions, 10) // 2 + 3 + 5 — total includes the unknown type
|
||||
assert.equal(out.tag, 'Griefer#1')
|
||||
assert.deepEqual(out.linked_account, { id: 9, username: 'griefer' })
|
||||
})
|
||||
|
||||
// ── automated-action annotation depends on bot config, which may be absent ──
|
||||
test('recent flags actions taken by the bot application id as automated', async () => {
|
||||
patch(botConfigDb, 'get', async () => ({ application_id: '999' }))
|
||||
patch(moderationDb, 'recentActions', async () => [
|
||||
{ id: 1, staff_user_id: '999' }, // the bot
|
||||
{ id: 2, staff_user_id: '42' }, // a human mod
|
||||
])
|
||||
const rows = await moderation.recent({})
|
||||
assert.equal(rows[0].is_automated, true)
|
||||
assert.equal(rows[1].is_automated, false)
|
||||
})
|
||||
|
||||
test('recent degrades to nothing-automated when bot config lookup throws', async () => {
|
||||
patch(botConfigDb, 'get', async () => {
|
||||
throw new Error('bot config table missing')
|
||||
})
|
||||
patch(moderationDb, 'recentActions', async () => [{ id: 1, staff_user_id: '999' }])
|
||||
const rows = await moderation.recent({})
|
||||
assert.equal(rows[0].is_automated, false) // appId resolved to null, not a crash
|
||||
})
|
||||
242
server/test/pages.model.test.js
Normal file
242
server/test/pages.model.test.js
Normal file
@@ -0,0 +1,242 @@
|
||||
// Point the DB pool at a dead port before it's built; every pages.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 CMS pages model against an in-memory fake by monkeypatching
|
||||
// pages.db (no DB). The point is to lock the rules the model owns and the API
|
||||
// surface must not be able to bypass:
|
||||
// - slug validation + reserved-name guard, and slug immutability after create;
|
||||
// - block validation/sanitization on every write (the authoritative gate);
|
||||
// - the `protected` asymmetry: ON via a normal update, OFF only via unprotect();
|
||||
// - published_at stamped once, on the first publish;
|
||||
// - draft pages invisible to public (getBySlug) reads;
|
||||
// - a duplicate slug surfaces as a 409, not a raw DB error.
|
||||
const pagesDb = require('../src/model/pages/pages.db')
|
||||
const pages = require('../src/model/pages/pages.model')
|
||||
|
||||
let rows
|
||||
let nextId
|
||||
const saved = {}
|
||||
|
||||
// A row as pages.db would return it (snake_case columns). blocks stored as JSON.
|
||||
function seed(row) {
|
||||
const full = {
|
||||
id: nextId++,
|
||||
slug: 'seed',
|
||||
title: 'Seed',
|
||||
status: 'draft',
|
||||
blocks: '[]',
|
||||
seo_title: null,
|
||||
meta_description: null,
|
||||
og_image: null,
|
||||
canonical_url: null,
|
||||
robots: null,
|
||||
layout: 'default',
|
||||
show_in_nav: 0,
|
||||
nav_group: null,
|
||||
nav_order: null,
|
||||
protected: 0,
|
||||
author_id: 1,
|
||||
created_at: new Date(),
|
||||
updated_at: new Date(),
|
||||
published_at: null,
|
||||
...row,
|
||||
}
|
||||
rows.push(full)
|
||||
return full
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
rows = []
|
||||
nextId = 1
|
||||
for (const k of ['listSummaries', 'findById', 'findBySlug', 'insert', 'update', 'remove']) saved[k] = pagesDb[k]
|
||||
|
||||
pagesDb.listSummaries = async () => rows.slice()
|
||||
pagesDb.findById = async (id) => rows.find((r) => r.id === id) || null
|
||||
pagesDb.findBySlug = async (slug) => rows.find((r) => r.slug === slug) || null
|
||||
pagesDb.insert = async (row) => {
|
||||
if (rows.some((r) => r.slug === row.slug)) {
|
||||
const err = new Error('dup')
|
||||
err.code = 'ER_DUP_ENTRY'
|
||||
throw err
|
||||
}
|
||||
const id = nextId++
|
||||
rows.push({ id, created_at: new Date(), updated_at: new Date(), published_at: null, ...row })
|
||||
return id
|
||||
}
|
||||
pagesDb.update = async (id, fields) => {
|
||||
const row = rows.find((r) => r.id === id)
|
||||
if (row) Object.assign(row, fields)
|
||||
}
|
||||
pagesDb.remove = async (id) => {
|
||||
const i = rows.findIndex((r) => r.id === id)
|
||||
if (i >= 0) rows.splice(i, 1)
|
||||
}
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
for (const k of Object.keys(saved)) pagesDb[k] = saved[k]
|
||||
})
|
||||
|
||||
// ── create: slug rules ─────────────────────────────────────────────────
|
||||
test('create rejects a slug with illegal characters', async () => {
|
||||
await assert.rejects(
|
||||
() => pages.create({ slug: 'Not A Slug', title: 'T' }, 1),
|
||||
(e) => e.code === 'invalid_slug' && e.status === 400,
|
||||
)
|
||||
})
|
||||
|
||||
test('create rejects a reserved slug that would shadow a named route', async () => {
|
||||
await assert.rejects(
|
||||
() => pages.create({ slug: 'admin', title: 'T' }, 1),
|
||||
(e) => e.code === 'reserved_slug' && e.status === 400,
|
||||
)
|
||||
})
|
||||
|
||||
test('create requires a non-empty title within the length limit', async () => {
|
||||
await assert.rejects(() => pages.create({ slug: 'ok', title: ' ' }, 1), (e) => e.code === 'invalid_title')
|
||||
await assert.rejects(() => pages.create({ slug: 'ok', title: 'x'.repeat(201) }, 1), (e) => e.code === 'invalid_title')
|
||||
})
|
||||
|
||||
test('create trims the title and defaults status to draft (no published_at)', async () => {
|
||||
const page = await pages.create({ slug: 'welcome', title: ' Welcome ' }, 7)
|
||||
assert.equal(page.title, 'Welcome')
|
||||
assert.equal(page.status, 'draft')
|
||||
assert.equal(page.publishedAt, null)
|
||||
assert.equal(page.authorId, 7)
|
||||
})
|
||||
|
||||
test('creating with status=published stamps published_at', async () => {
|
||||
const page = await pages.create({ slug: 'live', title: 'Live', status: 'published' }, 1)
|
||||
assert.equal(page.status, 'published')
|
||||
assert.ok(page.publishedAt instanceof Date)
|
||||
})
|
||||
|
||||
test('a duplicate slug surfaces as a 409 slug_taken, not a raw DB error', async () => {
|
||||
await pages.create({ slug: 'dup', title: 'First' }, 1)
|
||||
await assert.rejects(
|
||||
() => pages.create({ slug: 'dup', title: 'Second' }, 1),
|
||||
(e) => e.code === 'slug_taken' && e.status === 409,
|
||||
)
|
||||
})
|
||||
|
||||
// ── create: block gate ──────────────────────────────────────────────────
|
||||
test('create rejects invalid blocks (the authoritative validation gate)', async () => {
|
||||
await assert.rejects(
|
||||
() => pages.create({ slug: 'bad', title: 'T', blocks: [{ type: 'does-not-exist' }] }, 1),
|
||||
(e) => e.code === 'invalid_blocks' && Array.isArray(e.errors) && e.errors.length > 0,
|
||||
)
|
||||
})
|
||||
|
||||
// ── update: slug immutability ───────────────────────────────────────────
|
||||
test('update rejects changing the slug after creation', async () => {
|
||||
const p = seed({ slug: 'fixed' })
|
||||
await assert.rejects(
|
||||
() => pages.update(p.id, { slug: 'renamed' }),
|
||||
(e) => e.code === 'slug_immutable' && e.status === 400,
|
||||
)
|
||||
})
|
||||
|
||||
test('update tolerates the same slug being echoed back (no-op, not a rejection)', async () => {
|
||||
const p = seed({ slug: 'same' })
|
||||
const out = await pages.update(p.id, { slug: 'same', title: 'Updated' })
|
||||
assert.equal(out.title, 'Updated')
|
||||
})
|
||||
|
||||
test('update on a missing page is a 404', async () => {
|
||||
await assert.rejects(() => pages.update(999, { title: 'x' }), (e) => e.code === 'not_found' && e.status === 404)
|
||||
})
|
||||
|
||||
// ── update: publish stamping is once-only ───────────────────────────────
|
||||
test('publishing stamps published_at once and does not re-stamp on a later edit', async () => {
|
||||
const p = seed({ slug: 'draft-first' })
|
||||
const published = await pages.update(p.id, { status: 'published' })
|
||||
const firstStamp = published.publishedAt
|
||||
assert.ok(firstStamp instanceof Date)
|
||||
|
||||
// A later edit that keeps it published must not move published_at.
|
||||
await pages.update(p.id, { title: 'Edited' })
|
||||
const again = await pages.getById(p.id)
|
||||
assert.deepEqual(again.publishedAt, firstStamp)
|
||||
})
|
||||
|
||||
// ── the protected asymmetry (a security boundary) ───────────────────────
|
||||
test('update can turn protection ON', async () => {
|
||||
const p = seed({ slug: 'guard', protected: 0 })
|
||||
const out = await pages.update(p.id, { settings: { protected: true } })
|
||||
assert.equal(out.settings.protected, true)
|
||||
})
|
||||
|
||||
test('update CANNOT turn protection OFF — that requires the unprotect endpoint', async () => {
|
||||
const p = seed({ slug: 'guarded', protected: 1 })
|
||||
await assert.rejects(
|
||||
() => pages.update(p.id, { settings: { protected: false } }),
|
||||
(e) => e.code === 'unprotect_required' && e.status === 403,
|
||||
)
|
||||
})
|
||||
|
||||
test('setting protected=false on an already-unprotected page is a harmless no-op', async () => {
|
||||
const p = seed({ slug: 'open', protected: 0 })
|
||||
const out = await pages.update(p.id, { settings: { protected: false } })
|
||||
assert.equal(out.settings.protected, false)
|
||||
})
|
||||
|
||||
test('unprotect() is the only path that clears protection', async () => {
|
||||
const p = seed({ slug: 'locked', protected: 1 })
|
||||
const out = await pages.unprotect(p.id)
|
||||
assert.equal(out.settings.protected, false)
|
||||
})
|
||||
|
||||
// ── delete guard ────────────────────────────────────────────────────────
|
||||
test('a protected page cannot be deleted', async () => {
|
||||
const p = seed({ slug: 'keep', protected: 1 })
|
||||
await assert.rejects(() => pages.remove(p.id), (e) => e.code === 'page_protected' && e.status === 403)
|
||||
assert.ok(rows.find((r) => r.id === p.id), 'row still present')
|
||||
})
|
||||
|
||||
test('an unprotected page deletes', async () => {
|
||||
const p = seed({ slug: 'trash', protected: 0 })
|
||||
const out = await pages.remove(p.id)
|
||||
assert.equal(out.id, p.id)
|
||||
assert.equal(rows.find((r) => r.id === p.id), undefined)
|
||||
})
|
||||
|
||||
// ── public read hides drafts ────────────────────────────────────────────
|
||||
test('getBySlug hides a draft from the public but an admin can include it', async () => {
|
||||
seed({ slug: 'hidden', status: 'draft' })
|
||||
assert.equal(await pages.getBySlug('hidden'), null) // public: indistinguishable from missing
|
||||
const asAdmin = await pages.getBySlug('hidden', { includeUnpublished: true })
|
||||
assert.equal(asAdmin.slug, 'hidden')
|
||||
})
|
||||
|
||||
test('getBySlug returns a published page to the public', async () => {
|
||||
seed({ slug: 'shown', status: 'published' })
|
||||
const out = await pages.getBySlug('shown')
|
||||
assert.equal(out.slug, 'shown')
|
||||
})
|
||||
|
||||
// ── field-mapping validation ────────────────────────────────────────────
|
||||
test('update rejects an unknown layout and an out-of-range metadata string', async () => {
|
||||
const p = seed({ slug: 'meta' })
|
||||
await assert.rejects(() => pages.update(p.id, { settings: { layout: 'fancy' } }), (e) => e.code === 'invalid_settings')
|
||||
await assert.rejects(
|
||||
() => pages.update(p.id, { metadata: { seoTitle: 'x'.repeat(201) } }),
|
||||
(e) => e.code === 'invalid_metadata',
|
||||
)
|
||||
})
|
||||
|
||||
test('serialize maps DB columns to the grouped API shape and coerces flags to booleans', async () => {
|
||||
const p = seed({ slug: 'shape', show_in_nav: 1, protected: 1, nav_group: 'main', nav_order: 3 })
|
||||
const out = await pages.getById(p.id)
|
||||
assert.equal(out.settings.showInNav, true)
|
||||
assert.equal(out.settings.protected, true)
|
||||
assert.equal(out.settings.navGroup, 'main')
|
||||
assert.equal(out.settings.navOrder, 3)
|
||||
assert.equal(out.metadata.seoTitle, null)
|
||||
})
|
||||
253
server/test/shardState.model.test.js
Normal file
253
server/test/shardState.model.test.js
Normal file
@@ -0,0 +1,253 @@
|
||||
// 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
|
||||
})
|
||||
Reference in New Issue
Block a user