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