Files
website/server/test/appeals.test.js
Claude 028ba8c5e4
All checks were successful
PR Checks / server-tests (pull_request) Successful in 9m59s
PR Checks / client-build (pull_request) Successful in 9m32s
PR Checks / bot-install (pull_request) Successful in 9m37s
feat(moderation): appeals (6c) + Discord reversal on approve (6d)
Players whose linked Discord identity was banned or muted can now submit
an appeal from the portal and track it; staff get a queue in the admin
moderation section to claim and resolve (approve/deny) appeals. Approving
a ban/mute appeal best-effort asks the Discord bot to reverse the action
(unban / clear timeout) via the internal API and posts a mod-log embed; a
down bot never fails the resolution (reversal_status is recorded).

- Schema: new server-owned `appeals` table (no cross-owner FK to
  mod_actions; existence validated in app code).
- Server: model/appeals/* + player appeals controller (submit/mine/
  eligible/withdraw) and admin queue handlers (list/claim/resolve/
  per-user) under the existing admin+moderator gate; one-active-appeal
  enforced app-side; eligibility keyed on the caller's linked Discord id.
- 6d: bot POST /internal/mod-reverse (+ modLog.postReversal) and
  server botInternalClient.reverseModAction, wired into resolve().
- Client: admin Appeals queue + resolve modal, ModerationUser appeals
  tab, player Appeals page (submit/withdraw), nav + routes + api methods.
- Docs: swagger annotations + component schemas, regenerated output.
- Tests: appeals controller + pure suites (server npm test 224 green).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XmHdsbnLzDMAVQkAoTQSBe
2026-07-18 22:01:06 -05:00

306 lines
13 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// Controller-level tests for moderation appeals (Phase 6c/6d). Following the
// existing suite's convention (see playerAccounts.test.js / moderation.test.js),
// these are DB-free: the DB is pointed at a closed port before anything builds
// the pool, and the model/db/bot-client seams are stubbed per-test so the
// controllers' branching logic (ownership, duplicate, type, reversal wiring) is
// exercised without a live database or bot. The SQL itself is verified manually
// against a dev DB per the plan's verification steps.
process.env.DB_HOST = '127.0.0.1'
process.env.DB_PORT = '59999'
const { test, afterEach, after } = require('node:test')
const assert = require('node:assert/strict')
const playerAppeals = require('../src/router/v1/player/appeals.controller')
const modCtrl = require('../src/router/v1/admin/moderation.controller')
const appealsModel = require('../src/model/appeals/appeals.model')
const appealsDb = require('../src/model/appeals/appeals.db')
const userIdentities = require('../src/model/userIdentities/userIdentities.model')
const botInternalClient = require('../src/utils/botInternalClient')
const activity = require('../src/model/activity/activity.model')
const db = require('../src/utils/db')
after(() => db.close())
// ── tiny stub harness (restore all patched methods after each test) ──────────
const saved = new Map()
function stub(obj, prop, fn) {
if (!saved.has(obj)) saved.set(obj, {})
const bag = saved.get(obj)
if (!(prop in bag)) bag[prop] = obj[prop]
obj[prop] = fn
}
afterEach(() => {
for (const [obj, bag] of saved) for (const k of Object.keys(bag)) obj[k] = bag[k]
saved.clear()
})
function mockRes() {
return {
statusCode: 200,
body: null,
status(c) {
this.statusCode = c
return this
},
json(b) {
this.body = b
return this
},
}
}
// activity.log already swallows its own errors, but stub it everywhere so a
// resolve/claim never reaches the (closed) DB.
function silenceActivity() {
stub(activity, 'log', async () => {})
}
// ── POST /player/appeals ─────────────────────────────────────────────────────
test('player submit: happy path returns 201 with the created appeal', async () => {
stub(appealsDb, 'getModAction', async () => ({ id: 340, action_type: 'ban', target_user_id: '123' }))
stub(userIdentities, 'listForUser', async () => [{ provider: 'discord', subject: '123' }])
stub(appealsModel, 'activeForAction', async () => null)
let submitted = null
stub(appealsModel, 'submit', async (arg) => {
submitted = arg
return { id: 12, status: 'pending', action_type: 'ban' }
})
const req = { user: { id: 42 }, body: { mod_action_id: 340, submitted_text: 'please review' } }
const res = mockRes()
await playerAppeals.create(req, res)
assert.equal(res.statusCode, 201)
assert.equal(res.body.id, 12)
assert.equal(submitted.discordUserId, '123')
assert.equal(submitted.actionType, 'ban')
assert.equal(submitted.userId, 42)
})
test('player submit: action not belonging to the caller is 403', async () => {
stub(appealsDb, 'getModAction', async () => ({ id: 340, action_type: 'ban', target_user_id: '999' }))
stub(userIdentities, 'listForUser', async () => [{ provider: 'discord', subject: '123' }])
const req = { user: { id: 42 }, body: { mod_action_id: 340, submitted_text: 'x' } }
const res = mockRes()
await playerAppeals.create(req, res)
assert.equal(res.statusCode, 403)
})
test('player submit: an existing active appeal is 409', async () => {
stub(appealsDb, 'getModAction', async () => ({ id: 340, action_type: 'mute', target_user_id: '123' }))
stub(userIdentities, 'listForUser', async () => [{ provider: 'discord', subject: '123' }])
stub(appealsModel, 'activeForAction', async () => ({ id: 5, status: 'pending' }))
const req = { user: { id: 42 }, body: { mod_action_id: 340, submitted_text: 'x' } }
const res = mockRes()
await playerAppeals.create(req, res)
assert.equal(res.statusCode, 409)
})
test('player submit: a non-ban/mute action is 400', async () => {
stub(appealsDb, 'getModAction', async () => ({ id: 340, action_type: 'warn', target_user_id: '123' }))
stub(userIdentities, 'listForUser', async () => [{ provider: 'discord', subject: '123' }])
const req = { user: { id: 42 }, body: { mod_action_id: 340, submitted_text: 'x' } }
const res = mockRes()
await playerAppeals.create(req, res)
assert.equal(res.statusCode, 400)
})
test('player submit: unknown mod_action is 404', async () => {
stub(appealsDb, 'getModAction', async () => null)
const req = { user: { id: 42 }, body: { mod_action_id: 9999, submitted_text: 'x' } }
const res = mockRes()
await playerAppeals.create(req, res)
assert.equal(res.statusCode, 404)
})
// ── GET /player/appeals/eligible ─────────────────────────────────────────────
test('player eligible: no linked Discord returns [] (not an error)', async () => {
stub(userIdentities, 'listForUser', async () => [{ provider: 'google', subject: 'g1' }])
const req = { user: { id: 42 } }
const res = mockRes()
await playerAppeals.listEligible(req, res)
assert.equal(res.statusCode, 200)
assert.deepEqual(res.body, [])
})
test('player eligible: linked Discord returns the eligible actions', async () => {
stub(userIdentities, 'listForUser', async () => [{ provider: 'discord', subject: '123' }])
stub(appealsDb, 'eligibleActions', async (subject) => {
assert.equal(subject, '123')
return [{ id: 340, action_type: 'ban' }]
})
const req = { user: { id: 42 } }
const res = mockRes()
await playerAppeals.listEligible(req, res)
assert.equal(res.statusCode, 200)
assert.equal(res.body.length, 1)
})
// ── POST /player/appeals/:id/withdraw ────────────────────────────────────────
test('player withdraw: owner + non-terminal flips to withdrawn', async () => {
stub(appealsModel, 'getById', async () => ({ id: 12, user_id: 42, status: 'pending' }))
stub(appealsModel, 'withdraw', async (id) => ({ id, status: 'withdrawn' }))
const req = { user: { id: 42 }, params: { id: '12' } }
const res = mockRes()
await playerAppeals.withdraw(req, res)
assert.equal(res.statusCode, 200)
assert.equal(res.body.status, 'withdrawn')
})
test('player withdraw: another players appeal is 404 (never confirmed)', async () => {
stub(appealsModel, 'getById', async () => ({ id: 12, user_id: 99, status: 'pending' }))
const req = { user: { id: 42 }, params: { id: '12' } }
const res = mockRes()
await playerAppeals.withdraw(req, res)
assert.equal(res.statusCode, 404)
})
test('player withdraw: an already-resolved appeal is 409', async () => {
stub(appealsModel, 'getById', async () => ({ id: 12, user_id: 42, status: 'approved' }))
const req = { user: { id: 42 }, params: { id: '12' } }
const res = mockRes()
await playerAppeals.withdraw(req, res)
assert.equal(res.statusCode, 409)
})
// ── GET /admin/moderation/appeals ────────────────────────────────────────────
test('staff queue: default status filter is pending + under_review', async () => {
let passed = null
stub(appealsModel, 'queue', async (opts) => {
passed = opts
return []
})
const req = { query: {} }
const res = mockRes()
await modCtrl.getAppeals(req, res)
assert.deepEqual(passed.statuses, ['pending', 'under_review'])
})
test('staff queue: ?status=all expands to every status', async () => {
let passed = null
stub(appealsModel, 'queue', async (opts) => {
passed = opts
return []
})
const req = { query: { status: 'all' } }
const res = mockRes()
await modCtrl.getAppeals(req, res)
assert.deepEqual(passed.statuses, ['pending', 'under_review', 'approved', 'denied', 'withdrawn'])
})
// ── POST /admin/moderation/appeals/:id/claim ─────────────────────────────────
test('staff claim: a pending appeal moves to under_review and stamps the handler', async () => {
silenceActivity()
stub(appealsModel, 'getById', async () => ({ id: 12, status: 'pending', discord_user_id: '123' }))
let claimArgs = null
stub(appealsModel, 'claim', async (id, args) => {
claimArgs = { id, ...args }
return { id, status: 'under_review' }
})
const req = { user: { id: 7, username: 'modperson' }, params: { id: '12' } }
const res = mockRes()
await modCtrl.claimAppeal(req, res)
assert.equal(res.statusCode, 200)
assert.equal(res.body.status, 'under_review')
assert.equal(claimArgs.handlerUserId, 7)
assert.equal(claimArgs.handlerTag, 'modperson')
})
test('staff claim: a non-pending appeal is 409', async () => {
stub(appealsModel, 'getById', async () => ({ id: 12, status: 'under_review' }))
const req = { user: { id: 7, username: 'm' }, params: { id: '12' } }
const res = mockRes()
await modCtrl.claimAppeal(req, res)
assert.equal(res.statusCode, 409)
})
// ── POST /admin/moderation/appeals/:id/resolve ───────────────────────────────
test('staff resolve: approving a ban calls the bot and records reversal_status=done on ok', async () => {
silenceActivity()
stub(appealsModel, 'getById', async () => ({ id: 12, status: 'under_review', action_type: 'ban', discord_user_id: '123' }))
let reverseArgs = null
stub(botInternalClient, 'reverseModAction', async (arg) => {
reverseArgs = arg
return { ok: true, status: 200 }
})
let resolveOpts = null
stub(appealsModel, 'resolve', async (id, opts) => {
resolveOpts = opts
return { id, status: 'approved', reversal_status: opts.reversalStatus }
})
const req = { user: { id: 7, username: 'm' }, params: { id: '12' }, body: { status: 'approved' } }
const res = mockRes()
await modCtrl.resolveAppeal(req, res)
assert.equal(res.statusCode, 200)
assert.equal(reverseArgs.actionType, 'ban')
assert.equal(reverseArgs.discordUserId, '123')
assert.equal(resolveOpts.reversalStatus, 'done')
assert.equal(res.body.reversal_status, 'done')
assert.equal(res.body.reversal.attempted, true)
assert.equal(res.body.reversal.ok, true)
assert.equal(res.body.reversal.reversal_status, 'done')
})
test('staff resolve: approving a mute with a bot failure records reversal_status=failed', async () => {
silenceActivity()
stub(appealsModel, 'getById', async () => ({ id: 13, status: 'under_review', action_type: 'mute', discord_user_id: '123' }))
stub(botInternalClient, 'reverseModAction', async () => ({ ok: false, status: 503, error: 'bot responded 503' }))
let resolveOpts = null
stub(appealsModel, 'resolve', async (id, opts) => {
resolveOpts = opts
return { id, status: 'approved', reversal_status: opts.reversalStatus }
})
const req = { user: { id: 7, username: 'm' }, params: { id: '13' }, body: { status: 'approved' } }
const res = mockRes()
await modCtrl.resolveAppeal(req, res)
assert.equal(res.statusCode, 200)
assert.equal(resolveOpts.reversalStatus, 'failed')
assert.equal(res.body.reversal.ok, false)
assert.equal(res.body.reversal.error, 'bot responded 503')
})
test('staff resolve: denying never calls the bot and leaves reversal_status=none', async () => {
silenceActivity()
stub(appealsModel, 'getById', async () => ({ id: 14, status: 'pending', action_type: 'ban', discord_user_id: '123' }))
let botCalled = false
stub(botInternalClient, 'reverseModAction', async () => {
botCalled = true
return { ok: true, status: 200 }
})
let resolveOpts = null
stub(appealsModel, 'resolve', async (id, opts) => {
resolveOpts = opts
return { id, status: 'denied', reversal_status: opts.reversalStatus }
})
const req = { user: { id: 7, username: 'm' }, params: { id: '14' }, body: { status: 'denied' } }
const res = mockRes()
await modCtrl.resolveAppeal(req, res)
assert.equal(res.statusCode, 200)
assert.equal(botCalled, false)
assert.equal(resolveOpts.reversalStatus, 'none')
assert.equal(res.body.reversal.attempted, false)
})
test('staff resolve: an already-resolved appeal is 409', async () => {
stub(appealsModel, 'getById', async () => ({ id: 15, status: 'approved', action_type: 'ban' }))
const req = { user: { id: 7, username: 'm' }, params: { id: '15' }, body: { status: 'denied' } }
const res = mockRes()
await modCtrl.resolveAppeal(req, res)
assert.equal(res.statusCode, 409)
})
test('staff resolve: an unknown appeal is 404', async () => {
stub(appealsModel, 'getById', async () => null)
const req = { user: { id: 7, username: 'm' }, params: { id: '999' }, body: { status: 'denied' } }
const res = mockRes()
await modCtrl.resolveAppeal(req, res)
assert.equal(res.statusCode, 404)
})