import { afterEach, beforeEach, expect, it, vi } from 'vitest'; import { flushSync, mount, tick, unmount } from 'svelte'; import { fromStore, writable } from 'svelte/store'; import CasePage from '../../src/routes/moderation/cases/[id]/+page.svelte'; import type { ModerationCaseDetail } from '../../src/lib/moderation.js'; import type { UserRole } from '../../src/lib/auth.js'; const navigation = vi.hoisted(() => ({ before: [] as (() => void)[] })); const currentPage = writable({ url: new URL('http://localhost/moderation/cases/case-1'), data: { user: { id: 'staff-1', role: 'moderator' as UserRole } } }); vi.mock('$app/stores', () => ({ page: { subscribe: (run: Parameters[0]) => currentPage.subscribe(run) }, navigating: writable(null) })); vi.mock('$app/navigation', () => ({ beforeNavigate: (fn: () => void) => navigation.before.push(fn) })); vi.mock('../../src/lib/ui/poll.js', () => ({ pollVisible: () => () => {} })); let component: ReturnType | undefined; beforeEach(() => { navigation.before.length = 0; }); afterEach(async () => { if (component) await unmount(component); component = undefined; document.body.replaceChildren(); vi.unstubAllGlobals(); }); const guid = '18446744073709551615'; function detail(): ModerationCaseDetail { return { case: { id: 'case-1', game: 't5', guid, title: 'Repeated chat abuse', status: 'in_review', priority: 'high', assigneeId: 'staff-1', assignee: 'Moderator One', actor: 'Reporter', createdAt: '2026-10-15T12:00:00Z', updatedAt: '2026-10-16T12:00:00Z', revision: 4, evidence: [ { id: 'evidence-1', text: 'abusive message', source: 'Archived chat · theater · match 17', actor: 'Reporter', createdAt: '2026-10-16T12:00:00Z', snapshot: JSON.stringify({ matchId: 17, match: { game: 't5', server: 't5_zombie_theater_1', started_at: '2026-10-15T12:00:00Z' }, entry: { type: 'message', message: { guid, player: 'Tank', time: '12:34', message: 'abusive message' }, context: { realTime: '2026-10-15T12:12:34Z', round: 9 } } }) }, { id: 'note-1', text: 'Check the earlier warning.', source: 'Staff note', actor: 'Moderator One', createdAt: '2026-10-16T12:00:00Z' } ] }, player: { game: 't5', guid, name: 'Dempsey', aliases: ['Tank'], firstSeen: null, lastSeen: null, servers: [], restrictions: 1 }, sanctions: [ { id: 'restriction-1', game: 't5', guid, kind: 'mute', scope: 'game', server: null, reason: 'Prior warning ignored', note: '', createdAt: '2026-10-15T12:00:00Z', expiresAt: '2026-10-16T12:00:00Z', revokedAt: null, actorId: 'staff-1', actor: 'Moderator One', revision: 2, active: true, caseId: 'case-1' } ], events: [ { id: 1, game: 't5', guid, action: 'case.opened', subjectId: 'case-1', actor: 'Reporter', detail: 'Repeated chat abuse', createdAt: '2026-10-15T12:00:00Z' } ], coverage: [ { server: 't5_zombie_theater_1', game: 't5', connectedAt: null, revision: 1, desiredRevision: 2, state: 'pending', capabilities: ['ban', 'mute'] } ], assignees: [ { id: 'staff-1', username: 'Moderator One' }, { id: 'staff-2', username: 'Moderator Two' } ], checkedAt: '2026-10-16T12:00:00Z' }; } function show(initial = detail(), role: UserRole = 'moderator') { const data = writable({ moderationCase: initial }); const state = fromStore(data); currentPage.set({ url: new URL(`http://localhost/moderation/cases/${initial.case.id}`), data: { user: { id: 'staff-1', role } } }); component = mount(CasePage, { target: document.body, props: { get data() { return state.current; } } }); flushSync(); return (next: ModerationCaseDetail) => { data.set({ moderationCase: next }); flushSync(); }; } const button = (label: string) => [...document.querySelectorAll('button')].find((node) => node.textContent?.trim() === label)!; function field( label: string ): T { const labelNode = [...document.querySelectorAll('dialog label')].find( (node) => node.textContent?.replace('*', '').trim() === label )!; return document.querySelector(`[aria-labelledby="${labelNode.id}"]`)! as T; } function enter(node: HTMLInputElement | HTMLTextAreaElement, value: string) { node.value = value; node.dispatchEvent(new Event('input', { bubbles: true })); flushSync(); } function select(label: string, value: string) { const node = field(label); // happy-dom's :checked selector matches inputs but not selected options. const originalQuery = node.querySelector.bind(node); node.querySelector = ((selector: string) => selector === ':checked' ? (node.selectedOptions[0] ?? null) : originalQuery(selector)) as typeof node.querySelector; node.value = value; node.dispatchEvent(new Event('change', { bubbles: true })); flushSync(); } function submit() { document .querySelector('dialog form')! .dispatchEvent(new Event('submit', { bubbles: true, cancelable: true })); } function response(value: unknown, status = 200) { return new Response(JSON.stringify(value), { status }); } it('preserves original evidence context, linked restrictions, and staff history for read-only viewers', () => { show(detail(), 'super_viewer'); expect(document.body.textContent).toContain('Read-only moderation access'); expect(document.body.textContent).toContain('Round 9'); expect(document.body.textContent).toContain('Original log time · 12:34'); expect(document.body.textContent).toContain('Check the earlier warning.'); expect(document.body.textContent).toContain('Prior warning ignored'); expect(document.body.textContent).toContain('Policy pending'); expect(document.querySelector('a[href^="/matches/17"]')).not.toBeNull(); for (const label of [ 'Edit case', 'Change status', 'Reopen case', 'Restrict player', 'Revoke restriction', 'Add case note', 'Attach evidence', 'Link existing action' ]) expect(button(label)).toBeUndefined(); expect(document.querySelector('dialog')).toBeNull(); }); it('locks case restrictions to the recorded identity and revision without closing the case', async () => { const updated = detail(); updated.case.revision = 5; const fetch = vi .fn() .mockResolvedValueOnce(response({ ok: true, id: 'new-restriction' })) .mockResolvedValue(response(updated)); vi.stubGlobal('fetch', fetch); show(); button('Restrict player').click(); await tick(); expect(field('Game').disabled).toBe(true); expect(field('Player GUID').readOnly).toBe(true); expect(field('Player GUID').value).toBe(guid); expect(document.querySelector('dialog')!.textContent).toContain('does not close the case'); enter(field('Player-visible reason'), 'Repeated abuse'); submit(); await vi.waitFor(() => expect(fetch).toHaveBeenCalledTimes(2)); expect(JSON.parse(fetch.mock.calls[0][1].body)).toMatchObject({ op: 'create-sanction', game: 't5', guid, caseId: 'case-1', caseRevision: 4, reason: 'Repeated abuse', durationMinutes: 60 }); expect(fetch.mock.calls[1][0]).toBe('/api/moderation/cases/case-1'); expect(button('Change status')).toBeDefined(); }); it('closes and reopens through lifecycle status with a reason and the latest revision', async () => { const closed = detail(); closed.case.status = 'closed'; closed.case.revision = 5; const reopened = detail(); reopened.case.status = 'open'; reopened.case.revision = 6; const fetch = vi .fn() .mockResolvedValueOnce(response({ ok: true, id: 'case-1' })) .mockResolvedValueOnce(response(closed)) .mockResolvedValueOnce(response({ ok: true, id: 'case-1' })) .mockResolvedValueOnce(response(reopened)); vi.stubGlobal('fetch', fetch); show(); button('Change status').click(); await tick(); expect([...field('Status').options].map((option) => option.value)).toEqual([ 'open', 'in_review', 'closed' ]); select('Status', 'closed'); enter(field('Change reason'), 'Warning was sufficient'); submit(); await vi.waitFor(() => expect(document.querySelector('dialog')?.open).not.toBe(true)); expect(JSON.parse(fetch.mock.calls[0][1].body)).toMatchObject({ op: 'update-case', id: 'case-1', revision: 4, status: 'closed', reason: 'Warning was sufficient' }); expect(button('Restrict player')).toBeUndefined(); button('Change status').click(); await tick(); select('Status', 'open'); enter(field('Change reason'), 'New evidence received'); submit(); await vi.waitFor(() => expect(fetch).toHaveBeenCalledTimes(4)); expect(JSON.parse(fetch.mock.calls[2][1].body)).toMatchObject({ op: 'update-case', status: 'open', id: 'case-1', revision: 5, reason: 'New evidence received' }); expect(document.body.textContent).toContain('Check the earlier warning.'); }); it('edits priority and assignment separately from lifecycle status', async () => { const fetch = vi .fn() .mockResolvedValueOnce(response({ ok: true, id: 'case-1' })) .mockResolvedValue(response(detail())); vi.stubGlobal('fetch', fetch); show(); button('Edit case').click(); await tick(); enter(field('Case title'), 'Urgent report'); select('Priority', 'urgent'); select('Assigned to', 'staff-2'); expect(document.querySelector('dialog')?.textContent).not.toContain('Workflow status'); expect(field('Change reason').required).toBe(false); enter(field('Change reason'), 'Needs administrator review'); submit(); await vi.waitFor(() => expect(fetch).toHaveBeenCalled()); expect(JSON.parse(fetch.mock.calls[0][1].body)).toMatchObject({ op: 'update-case', id: 'case-1', revision: 4, title: 'Urgent report', priority: 'urgent', assigneeId: 'staff-2', reason: 'Needs administrator review' }); }); it('preserves an unavailable assignee when editing metadata on a closed case', async () => { const initial = detail(); initial.case.status = 'closed'; initial.case.assigneeId = 'disabled-staff'; initial.case.assignee = 'Former staff'; const fetch = vi .fn() .mockResolvedValueOnce(response({ ok: true, id: 'case-1' })) .mockResolvedValue(response(initial)); vi.stubGlobal('fetch', fetch); show(initial); button('Edit case').click(); await tick(); expect(field('Assigned to').value).toBe('disabled-staff'); expect(document.querySelector('dialog')!.textContent).toContain('Former staff (unavailable)'); enter(field('Case title'), 'Clarified title'); submit(); await vi.waitFor(() => expect(fetch).toHaveBeenCalled()); const payload = JSON.parse(fetch.mock.calls[0][1].body); expect(payload.title).toBe('Clarified title'); expect(payload).not.toHaveProperty('assigneeId'); expect(payload).not.toHaveProperty('status'); }); it('attaches archived evidence by match and exact message with revision checks', async () => { const fetch = vi .fn() .mockResolvedValueOnce(response({ ok: true, id: 'case-1' })) .mockResolvedValue(response(detail())); vi.stubGlobal('fetch', fetch); show(); button('Attach evidence').click(); await tick(); enter(field('Match ID'), '17'); enter(field('Exact archived message'), ' abusive message '); submit(); await vi.waitFor(() => expect(fetch).toHaveBeenCalled()); expect(JSON.parse(fetch.mock.calls[0][1].body)).toMatchObject({ op: 'attach-evidence', id: 'case-1', revision: 4, evidence: { matchId: 17, message: ' abusive message ' } }); }); it('links a prior mute with its recorded outcome without issuing another restriction', async () => { const initial = detail(); const prior = { id: 'earlier-mute', type: 'restriction' as const, kind: 'mute', reason: 'Already applied before the report', actor: 'Moderator Two', createdAt: '2026-10-14T10:00:00Z', state: 'expired', expiresAt: '2026-10-14T11:00:00Z' }; initial.availableActions = [prior]; initial.linkedActions = []; const updated = detail(); updated.linkedActions = [prior]; updated.availableActions = []; updated.case.revision = 5; const fetch = vi .fn() .mockResolvedValueOnce(response({ ok: true, id: 'case-1' })) .mockResolvedValue(response(updated)); vi.stubGlobal('fetch', fetch); show(initial); button('Link existing action').click(); await tick(); select('Existing action', 'restriction:earlier-mute'); expect(document.querySelector('dialog[open]')?.textContent).toContain('Expired'); expect(document.querySelector('dialog[open]')?.textContent).toContain('Moderator Two'); enter(field('Link reason'), 'This mute addressed the reported chat'); button('Link action').click(); await vi.waitFor(() => expect(fetch).toHaveBeenCalledTimes(2)); expect(JSON.parse(fetch.mock.calls[0][1].body)).toEqual({ op: 'link-case-action', id: 'case-1', revision: 4, actionType: 'restriction', actionId: 'earlier-mute', reason: 'This mute addressed the reported chat', idempotencyKey: expect.any(String) }); expect(document.querySelector('[aria-labelledby="actions-title"]')?.textContent).toContain( 'Already applied before the report' ); expect(document.querySelector('[aria-labelledby="actions-title"]')?.textContent).toContain( 'Expired' ); expect(button('Link existing action').disabled).toBe(true); }); it.each(['in_review', 'closed'] as const)( 'changes status to %s without replacing findings or issuing an action', async (status) => { const initial = detail(); initial.case.status = 'open'; initial.linkedActions = [ { id: 'warning-1', type: 'action', kind: 'warn', reason: 'Confirmed warning', actor: 'Moderator One', createdAt: '2026-10-15T10:00:00Z', state: 'confirmed', expiresAt: null }, { id: 'failed-warning', type: 'action', kind: 'warn', reason: 'Undelivered warning', actor: 'Moderator One', createdAt: '2026-10-15T09:00:00Z', state: 'failed', expiresAt: null }, { id: 'restriction-1', type: 'restriction', kind: 'mute', reason: 'Prior mute', actor: 'Moderator One', createdAt: '2026-10-15T11:00:00Z', state: 'expired', expiresAt: '2026-10-15T12:00:00Z' } ]; const fetch = vi .fn() .mockResolvedValueOnce(response({ ok: true, id: 'case-1' })) .mockResolvedValue(response(initial)); vi.stubGlobal('fetch', fetch); show(initial); button('Change status').click(); await tick(); expect(button('Save').disabled).toBe(true); select('Status', status); await tick(); expect(button('Save').disabled).toBe(false); expect(document.querySelector('dialog')?.textContent).not.toContain('Supporting action'); enter(field('Change reason'), 'Reviewed the recorded action'); submit(); await vi.waitFor(() => expect(fetch).toHaveBeenCalledTimes(2)); expect(JSON.parse(fetch.mock.calls[0][1].body)).toMatchObject({ op: 'update-case', id: 'case-1', revision: 4, status }); expect(JSON.parse(fetch.mock.calls[0][1].body)).not.toHaveProperty('actionReference'); expect(document.querySelector('[aria-labelledby="actions-title"]')?.textContent).toContain( 'Confirmed warning' ); } ); it('retains a conflicted review and lets staff refresh before making another decision', async () => { const updated = detail(); updated.case.revision = 8; updated.case.title = 'Updated case after concurrent edit'; updated.case.status = 'in_review'; const fetch = vi .fn() .mockResolvedValueOnce( response({ error: 'This case changed. Reload it before making another change.' }, 409) ) .mockResolvedValue(response(updated)); vi.stubGlobal('fetch', fetch); show(); button('Change status').click(); await tick(); select('Status', 'closed'); enter(field('Change reason'), 'Evidence supports the restriction'); submit(); await vi.waitFor(() => expect(document.querySelector('dialog [role="alert"]')?.textContent).toContain( 'This case changed.' ) ); expect(field('Status').value).toBe('closed'); expect(document.querySelector('dialog')!.open).toBe(true); expect(button('Reopen case')).toBeUndefined(); button('Cancel').click(); await tick(); button('Refresh').click(); await vi.waitFor(() => expect(document.querySelector('h1')?.textContent).toBe('Updated case after concurrent edit') ); button('Change status').click(); await tick(); select('Status', 'closed'); enter(field('Change reason'), 'Reviewed new context'); submit(); await vi.waitFor(() => expect(fetch.mock.calls.length).toBeGreaterThanOrEqual(3)); expect(JSON.parse(fetch.mock.calls[2][1].body).revision).toBe(8); }); it('retries uncertain case notes with the exact same idempotent request', async () => { const fetch = vi .fn() .mockRejectedValueOnce(new Error('Connection lost')) .mockResolvedValueOnce(response({ ok: true, id: 'case-1' })) .mockResolvedValue(response(detail())); vi.stubGlobal('fetch', fetch); show(); button('Add case note').click(); await tick(); enter(field('Private staff note'), 'Follow up on appeal'); submit(); await vi.waitFor(() => expect(document.querySelector('dialog fieldset')?.hasAttribute('disabled')).toBe(true) ); button('Retry same request').click(); await vi.waitFor(() => expect(fetch.mock.calls.length).toBeGreaterThanOrEqual(2)); expect(fetch.mock.calls[1][1].body).toBe(fetch.mock.calls[0][1].body); expect(JSON.parse(fetch.mock.calls[0][1].body)).toMatchObject({ op: 'case-note', id: 'case-1', revision: 4, text: 'Follow up on appeal', idempotencyKey: expect.any(String) }); }); it('keeps last loaded records when refresh fails and rejects an old response after changing cases', async () => { let resolve!: (response: Response) => void; const fetch = vi .fn() .mockRejectedValueOnce(new Error('Offline')) .mockImplementationOnce( () => new Promise((done) => { resolve = done; }) ); vi.stubGlobal('fetch', fetch); const update = show(); button('Refresh').click(); await vi.waitFor(() => expect(document.body.textContent).toContain('Case updates paused.')); expect(document.querySelector('h1')!.textContent).toBe('Repeated chat abuse'); button('Retry').click(); navigation.before.forEach((fn) => fn()); const next = detail(); next.case.id = 'case-2'; next.case.title = 'Another case'; update(next); resolve(response(detail())); await tick(); await tick(); expect(document.querySelector('h1')!.textContent).toBe('Another case'); }); it('enforces role and ownership limits on linked restriction revocation', async () => { const initial = detail(); initial.sanctions[0].actorId = 'staff-2'; const fetch = vi .fn() .mockResolvedValueOnce(response({ ok: true, id: 'restriction-1' })) .mockResolvedValue(response(initial)); vi.stubGlobal('fetch', fetch); show(initial); expect(button('Revoke restriction')).toBeUndefined(); currentPage.update((value) => ({ ...value, data: { user: { id: 'staff-1', role: 'admin' } } })); flushSync(); button('Revoke restriction').click(); await tick(); enter(field('Review reason'), 'Appeal upheld'); submit(); await vi.waitFor(() => expect(fetch).toHaveBeenCalled()); expect(JSON.parse(fetch.mock.calls[0][1].body)).toMatchObject({ op: 'revoke-sanction', id: 'restriction-1', revision: 2, reason: 'Appeal upheld' }); });