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
306 lines
13 KiB
JavaScript
306 lines
13 KiB
JavaScript
// 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 player’s 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)
|
||
})
|