Files
website/server/test/moderation.test.js
Claude 3027bb0400 Capture member/filter/spam events for the dashboard (Phase 6b)
Light up the moderation dashboard's previously-empty widgets by persisting the
event streams the bot only reacted to in-memory before.

Schema (bot-owned)
- member_events: join/leave, with invite_code/inviter_* for best-effort invite
  attribution on joins
- filter_hits: word / foreign-invite filter deletions (matched + action_taken)
- spam_hits: rate_limit / mass_mention / mass_emoji detections

Bot
- new models memberEvents/filterHits/spamHits
- guildMemberAdd records the join with invite attribution; new inviteTracker.js
  keeps an invite-use cache (GuildInvites intent + inviteCreate/inviteDelete) and
  diffs it on join to find which invite was used — best-effort, never blocks
  auto-role
- new guildMemberRemove records leaves
- messageFilter records filter/spam hits alongside the existing warn/mute;
  inviteFilter now returns the offending code; detectSpam identifies which spam
  rule tripped (preserving the rate-limit-first side-effect order)
- mod_actions still logs the resulting warn/mute — the new tables are additive

Server
- summary extended with joins/leaves/invite_joins/filter_hits/spam_hits per window
- new feeds: /api/v1/admin/moderation/{members,filter-hits,spam-hits}

Client
- overview now shows 8 tiles (mod actions + joins/leaves/filter/spam, joins tile
  notes "N via invite") plus an Events panel with Members/Filter/Spam tabs;
  removed the coming-soon note

Verified: 119 server unit tests, client build, 14-check DB-backed smoke, and a
browser click-through of every tile and events tab (incl. invite attribution).

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

90 lines
3.8 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)
})
test('windowValue: picks the right column per window key and coerces to number', () => {
const row = { d1: '2', d7: 5, d30: '11' }
assert.strictEqual(moderation.windowValue(row, '24h'), 2)
assert.strictEqual(moderation.windowValue(row, '7d'), 5)
assert.strictEqual(moderation.windowValue(row, '30d'), 11)
})
test('windowValue: null row (no rows in window) yields 0', () => {
assert.strictEqual(moderation.windowValue(null, '24h'), 0)
assert.strictEqual(moderation.windowValue(undefined, '30d'), 0)
})
test('windowValue: null sum column yields 0', () => {
assert.strictEqual(moderation.windowValue({ d1: null, d7: null, d30: null }, '7d'), 0)
})