Files
website/server/test/moderation.test.js
Claude b0c0d1fe9b Add moderation dashboard, user history & notes (Phase 6a)
Surface the Discord bot's moderation data on the admin panel: a read-only
staff dashboard over the existing mod_actions log, per-user history, staff
notes, and a new moderator role. No bot changes.

Schema
- users.role ENUM gains 'moderator' (CREATE + idempotent ALTER for existing DBs)
- new server-owned mod_notes table (staff_only/admin_only visibility)

Server
- model/moderation: read mod_actions via the shared pool (documented read-only
  cross of the bot/server ownership boundary), correlate accounts through
  user_identities (provider='discord'), flag automated actions via
  staff_user_id === bot_config.application_id; pure reshaping helpers isolated
  in moderation.pure.js so they unit-test without opening a DB pool
- model/modNotes: list/add with role-gated admin_only visibility
- admin/moderation.controller + routes under /api/v1/admin/moderation/* gated by
  requireRole('admin','moderator'); admin_only note writes require admin
- allow assigning 'moderator' in the user create/update validators

Client
- /admin/moderation overview (window tiles, type-filterable recent feed, user
  lookup) and /user/:discordId history (tabs + notes with add-note)
- RoleGate; AdminLayout filters nav and confines moderators to their section
- moderator badge + action-type/auto badges

Deferred (see plan): 6b bot event capture (joins/leaves/filter/spam), 6c appeals
(needs public accounts), 6d /internal/mod-reverse bot reversal callback.

Verified: 116 server unit tests, client build, DB-backed model smoke, full
HTTP/RBAC e2e, and a browser click-through of the dashboard.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019rao86n5cXpwAyjdBFEshV
2026-07-05 10:16:34 -05:00

74 lines
3.2 KiB
JavaScript

// Unit tests for the moderation dashboard's pure reshaping/annotation logic.
// DB-free (like the rest of this suite) — the SQL layer is exercised manually
// against a dev database per the plan's verification steps.
const { test } = require('node:test')
const assert = require('node:assert/strict')
const moderation = require('../src/model/moderation/moderation.pure')
test('reshapeWindows: folds rows into windows and zero-fills missing types', () => {
const rows = [
{ action_type: 'ban', d1: 1, d7: 3, d30: 5 },
{ action_type: 'warn', d1: 0, d7: 2, d30: 9 },
]
const { windows } = moderation.reshapeWindows(rows)
assert.deepEqual(windows['24h'], { ban: 1, kick: 0, mute: 0, warn: 0 })
assert.deepEqual(windows['7d'], { ban: 3, kick: 0, mute: 0, warn: 2 })
assert.deepEqual(windows['30d'], { ban: 5, kick: 0, mute: 0, warn: 9 })
})
test('reshapeWindows: coerces string/decimal SUM results to numbers', () => {
const { windows } = moderation.reshapeWindows([{ action_type: 'mute', d1: '2', d7: '2', d30: '4' }])
assert.strictEqual(windows['24h'].mute, 2)
assert.strictEqual(windows['30d'].mute, 4)
})
test('reshapeWindows: ignores unknown action types (e.g. future enum values)', () => {
const { windows } = moderation.reshapeWindows([{ action_type: 'filter_hit', d1: 9, d7: 9, d30: 9 }])
assert.deepEqual(windows['24h'], { ban: 0, kick: 0, mute: 0, warn: 0 })
})
test('reshapeWindows: empty input yields all-zero windows', () => {
const { windows } = moderation.reshapeWindows([])
assert.deepEqual(windows, {
'24h': { ban: 0, kick: 0, mute: 0, warn: 0 },
'7d': { ban: 0, kick: 0, mute: 0, warn: 0 },
'30d': { ban: 0, kick: 0, mute: 0, warn: 0 },
})
})
test('annotate: flags automated when staff id matches the bot application id', () => {
const [row] = moderation.annotate([{ staff_user_id: '999', target_site_user_id: null }], '999')
assert.equal(row.is_automated, true)
})
test('annotate: string/number snowflake mismatch still matches (coerced)', () => {
// mod_actions stores staff_user_id as VARCHAR, but bot_config.application_id
// could arrive as a number — the compare must coerce both sides.
const [row] = moderation.annotate([{ staff_user_id: 999, target_site_user_id: null }], '999')
assert.equal(row.is_automated, true)
})
test('annotate: staff action (id differs from bot) is not automated', () => {
const [row] = moderation.annotate([{ staff_user_id: '111', target_site_user_id: null }], '999')
assert.equal(row.is_automated, false)
})
test('annotate: no bot application id configured means nothing is automated', () => {
const [row] = moderation.annotate([{ staff_user_id: '999', target_site_user_id: null }], null)
assert.equal(row.is_automated, false)
})
test('annotate: folds joined identity columns into linked_account', () => {
const [row] = moderation.annotate(
[{ staff_user_id: '1', target_site_user_id: 7, target_site_username: 'perry' }],
null,
)
assert.deepEqual(row.linked_account, { id: 7, username: 'perry' })
})
test('annotate: no linked identity yields null linked_account', () => {
const [row] = moderation.annotate([{ staff_user_id: '1', target_site_user_id: null }], null)
assert.equal(row.linked_account, null)
})