import { afterEach, beforeEach, expect, it, vi } from 'vitest'; import { flushSync, mount, tick, unmount } from 'svelte'; import { fromStore, writable } from 'svelte/store'; import ModerationPage from '../../src/routes/moderation/+page.svelte'; import type { ModerationOverview } from '../../src/lib/moderation.js'; import type { UserRole } from '../../src/lib/auth.js'; const navigation = vi.hoisted(() => ({ goto: vi.fn(), before: [] as (() => void)[] })); const currentPage = writable({ url: new URL('http://localhost/moderation'), 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', () => ({ goto: navigation.goto, beforeNavigate: (fn: () => void) => navigation.before.push(fn) })); vi.mock('../../src/lib/ui/poll.js', () => ({ pollVisible: () => () => {} })); let component: ReturnType | undefined; beforeEach(() => { navigation.goto.mockReset().mockResolvedValue(undefined); navigation.before.length = 0; }); afterEach(async () => { if (component) await unmount(component); component = undefined; document.body.replaceChildren(); vi.unstubAllGlobals(); }); const guid = '18446744073709551615'; function overview(view: ModerationOverview['query']['view'] = 'players'): ModerationOverview { return { query: { view, q: '', game: 't5', guid, offset: 0 }, players: [ { game: 't5', guid, name: 'Dempsey', aliases: ['Tank'], firstSeen: '2026-10-01T12:00:00Z', lastSeen: '2026-10-15T12:00:00Z', servers: ['t5_zombie_theater_1'], restrictions: 1 } ], sanctions: [ { id: 'restriction-1', game: 't5', guid, kind: 'ban', scope: 'game', server: null, reason: 'Repeated abuse', note: 'Reviewed chat', createdAt: '2026-10-15T12:00:00Z', expiresAt: '2026-10-16T12:00:00Z', revokedAt: null, actor: 'Other moderator', actorId: 'staff-2', revision: 1, active: true } ], cases: [], events: [], actions: [], coverage: [ { server: 't5_zombie_theater_1', game: 't5', connectedAt: '2026-10-15T12:00:00Z', revision: 1, desiredRevision: 2, state: 'pending', capabilities: ['ban', 'mute'] } ], total: 1, limit: 25, checkedAt: '2026-10-15T12:00:00Z' }; } function show(initial = overview(), role: UserRole = 'moderator', extra = '') { const data = writable({ moderation: initial }); const state = fromStore(data); const updatePage = (next: ModerationOverview) => currentPage.set({ url: new URL( `http://localhost/moderation?view=${next.query.view}&game=t5&guid=${guid}${extra}` ), data: { user: { id: 'staff-1', role } } }); updatePage(initial); component = mount(ModerationPage, { target: document.body, props: { get data() { return state.current; } } }); flushSync(); return (next: ModerationOverview) => { updatePage(next); data.set({ moderation: next }); flushSync(); }; } const button = (label: string) => [...document.querySelectorAll('button')].find((node) => node.textContent?.trim() === label)!; const enter = (node: HTMLInputElement | HTMLTextAreaElement, value: string) => { node.value = value; node.dispatchEvent(new Event('input', { bubbles: true })); flushSync(); }; const submitDialog = () => document .querySelector('dialog form')! .dispatchEvent(new Event('submit', { bubbles: true, cancelable: true })); it('limits moderator restrictions and preserves the decimal identity from player context', async () => { const fetch = vi .fn() .mockResolvedValueOnce(new Response(JSON.stringify({ ok: true, id: 'sanction' }))) .mockResolvedValue(new Response(JSON.stringify(overview()))); vi.stubGlobal('fetch', fetch); show(); button('Restrict player').click(); await tick(); const dialog = document.querySelector('dialog')!; expect(dialog.open).toBe(true); expect(dialog.querySelector('input[name="target-guid"]')!.value).toBe(guid); expect(dialog.textContent).not.toContain('Permanent'); expect(dialog.textContent).not.toContain('Entire fleet'); enter(dialog.querySelector('textarea')!, 'Repeated abuse'); submitDialog(); await vi.waitFor(() => expect(fetch).toHaveBeenCalled()); expect(JSON.parse(fetch.mock.calls[0][1].body)).toMatchObject({ op: 'create-sanction', guid, game: 't5', reason: 'Repeated abuse', scope: 'game', durationMinutes: 60 }); expect(JSON.parse(fetch.mock.calls[0][1].body).idempotencyKey).toEqual(expect.any(String)); await vi.waitFor(() => expect(navigation.goto).toHaveBeenCalled()); const destination = new URL(navigation.goto.mock.calls[0][0], 'http://localhost'); expect(destination.searchParams.get('view')).toBe('restrictions'); expect(destination.searchParams.get('guid')).toBe(guid); expect(navigation.goto.mock.calls[0][1].invalidateAll).toBe(true); }); it('keeps unknown submissions immutable and retries the same idempotent request', async () => { const fetch = vi .fn() .mockRejectedValueOnce(new Error('Connection lost')) .mockResolvedValueOnce(new Response(JSON.stringify({ ok: true, id: 'note-1' }))) .mockResolvedValue(new Response(JSON.stringify(overview()))); vi.stubGlobal('fetch', fetch); show(); button('Add private note').click(); await tick(); enter(document.querySelector('dialog textarea')!, 'Investigate a report'); submitDialog(); await vi.waitFor(() => expect(document.body.textContent).toContain('The outcome is not yet known') ); 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); }); it('retains records on refresh failure and ignores a stale response after navigation', 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('Moderation updates paused.') ); expect(document.querySelector('#moderation-results')?.textContent).toContain('Dempsey'); button('Retry').click(); navigation.before.forEach((fn) => fn()); update(overview('restrictions')); resolve(new Response(JSON.stringify(overview()))); await tick(); await tick(); expect( document.querySelector('#moderation-tab-restrictions')?.getAttribute('aria-selected') ).toBe('true'); expect(document.querySelector('#moderation-results')?.textContent).toContain('Repeated abuse'); }); it('shows enforcement pending honestly and keeps super viewer access read-only', () => { show(overview('restrictions'), 'super_viewer'); expect(document.body.textContent).toContain('Policy pending'); expect(document.body.textContent).toContain('0 of 1 servers current'); expect(document.body.textContent).toContain('Read-only moderation access'); expect(button('Restrict player')).toBeUndefined(); expect(button('Revoke restriction')).toBeUndefined(); expect(document.querySelector('dialog')).toBeNull(); }); it('does not offer revoking another moderator restriction', () => { show(overview('restrictions')); expect(button('Revoke restriction')).toBeUndefined(); }); it('limits revoke controls to the current role even for restrictions created by that staff member', () => { const initial = overview('restrictions'); const own = { ...initial.sanctions[0], actorId: 'staff-1' }; initial.sanctions = [ { ...own, id: 'permitted', expiresAt: '2026-10-22T12:00:00Z' }, { ...own, id: 'fleet', scope: 'fleet' }, { ...own, id: 'permanent', expiresAt: null }, { ...own, id: 'longer', expiresAt: '2026-10-23T12:00:00Z' } ]; show(initial); const controls = () => [...document.querySelectorAll('button')].filter( (node) => node.textContent?.trim() === 'Revoke restriction' ); expect(controls()).toHaveLength(1); currentPage.update((value) => ({ ...value, data: { user: { id: 'staff-1', role: 'admin' } } })); flushSync(); expect(controls()).toHaveLength(4); }); it('disables unsupported live actions and requires an explicit game for unscoped identities', async () => { const data = overview(); data.query.game = ''; show(data); button('Live action').click(); await tick(); const game = document.querySelector('dialog select')!; expect(game.value).toBe(''); expect( document.querySelector('dialog button[type="submit"]')!.disabled ).toBe(true); expect(document.querySelector('dialog')!.textContent).toMatch( /Unavailable actions\s+cannot be sent/ ); }); it('preserves player and game filters when paging and resets pagination when applying search', () => { const data = overview(); data.total = 60; show(data); button('Next').click(); let destination = new URL(navigation.goto.mock.calls.at(-1)![0], 'http://localhost'); expect(destination.searchParams.get('offset')).toBe('25'); expect(destination.searchParams.get('guid')).toBe(guid); expect(destination.searchParams.get('game')).toBe('t5'); enter(document.querySelector('input[name="q"]')!, 'Tank'); document .querySelector('form.filters')! .dispatchEvent(new Event('submit', { bubbles: true, cancelable: true })); destination = new URL(navigation.goto.mock.calls.at(-1)![0], 'http://localhost'); expect(destination.searchParams.has('offset')).toBe(false); expect(destination.searchParams.get('q')).toBe('Tank'); }); it('copies selected archived chat evidence separately from staff notes', async () => { const fetch = vi .fn() .mockResolvedValueOnce(new Response(JSON.stringify({ ok: true, id: 'case' }))) .mockResolvedValue(new Response(JSON.stringify(overview()))); vi.stubGlobal('fetch', fetch); show(overview(), 'moderator', '&matchId=17&message=abusive%20message'); button('Open case').click(); await tick(); const inputs = document.querySelectorAll('dialog input'); enter(inputs[inputs.length - 1], 'Chat report'); enter(document.querySelector('dialog textarea')!, 'Investigate context'); submitDialog(); await vi.waitFor(() => expect(fetch).toHaveBeenCalled()); expect(JSON.parse(fetch.mock.calls[0][1].body)).toMatchObject({ op: 'create-case', title: 'Chat report', note: 'Investigate context', evidence: { matchId: 17, message: 'abusive message' } }); }); it('opens a newly created case page after preserving selected evidence', async () => { const fetch = vi .fn() .mockResolvedValue(new Response(JSON.stringify({ ok: true, id: 'new-case' }))); vi.stubGlobal('fetch', fetch); show(); button('Open case').click(); await tick(); const inputs = document.querySelectorAll('dialog input'); enter(inputs[inputs.length - 1], 'Appeal review'); submitDialog(); await vi.waitFor(() => expect(navigation.goto).toHaveBeenCalledWith('/moderation/cases/new-case', { invalidateAll: true }) ); }); it('links case records to workspaces and preserves case filters while paging', () => { const data = overview('cases'); data.query = { ...data.query, caseStatus: 'active', priority: 'high', assignedTo: 'me', review: 'due' }; data.cases = [ { id: 'case-1', game: 't5', guid, title: 'Chat investigation', reviewAt: '2026-10-14T12:00:00Z', unreadUpdates: 2, assessment: { revision: 3, summary: 'The threat remains ambiguous.', rules: [], findings: [ { category: 'threats', finding: 'inconclusive', detail: 'Context is insufficient.' } ], actor: 'Moderator', createdAt: '2026-10-16T12:00:00Z' }, status: 'escalated', priority: 'high', assigneeId: 'staff-1', assignee: 'Moderator', actor: 'Reporter', createdAt: '2026-10-15T12:00:00Z', updatedAt: '2026-10-16T12:00:00Z', revision: 3, evidence: [] } ]; data.total = 40; show(data, 'moderator', '&caseStatus=active&priority=high&assignedTo=me&review=due'); expect(document.querySelector('a[href="/moderation/cases/case-1"]')?.textContent).toBe( 'Chat investigation' ); expect(document.querySelector('#moderation-results')?.textContent).toContain('Escalated'); expect(document.querySelector('#moderation-results')?.textContent).toContain('Moderator'); expect(document.querySelector('#moderation-results')?.textContent).toContain( 'The threat remains ambiguous.' ); expect(document.querySelector('[aria-label="Assessment findings"]')?.textContent).toMatch( /Threats\s*ยท\s*Inconclusive/ ); button('Next').click(); const destination = new URL(navigation.goto.mock.calls.at(-1)![0], 'http://localhost'); expect(destination.searchParams.get('caseStatus')).toBe('active'); expect(destination.searchParams.get('priority')).toBe('high'); expect(destination.searchParams.get('assignedTo')).toBe('me'); expect(destination.searchParams.get('review')).toBe('due'); expect(document.querySelector('#moderation-results')?.textContent).toContain('Overdue review'); expect(document.querySelector('#moderation-results')?.textContent).toContain( '2 unread updates' ); expect(destination.searchParams.get('offset')).toBe('25'); button('Reset').click(); const reset = new URL(navigation.goto.mock.calls.at(-1)![0], 'http://localhost'); for (const filter of ['caseStatus', 'priority', 'assignedTo', 'review', 'offset']) expect(reset.searchParams.has(filter)).toBe(false); }); it('shows personal evidence and assignment notifications with links into the actionable queue', () => { const data = overview('cases'); data.unreadCount = 2; data.notifications = [ { id: 1, caseId: 'follow-up', caseTitle: 'Review context', kind: 'evidence', createdAt: data.checkedAt, readAt: null }, { id: 2, caseId: 'appeal', caseTitle: 'Review appeal', kind: 'assignment', createdAt: data.checkedAt, readAt: null } ]; show(data); const notifications = document.querySelector('[aria-label="Your case notifications"]')!; expect(notifications.textContent).toContain('Evidence or note added'); expect(notifications.textContent).toContain('Assignment changed'); expect(notifications.querySelector('a[href="/moderation/cases/follow-up"]')).not.toBeNull(); expect( document.querySelector('a[href="/moderation?view=cases&review=updates"]') ).not.toBeNull(); expect(document.querySelector('a[href="/moderation?view=cases&review=due"]')).not.toBeNull(); });