Files
website/server/test/moderation.model.test.js
wtclaude 99fd9acddb 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>
2026-07-21 00:26:01 -05:00

111 lines
5.0 KiB
JavaScript

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