import { afterEach, expect, it, vi } from 'vitest'; import { flushSync, mount, tick, unmount } from 'svelte'; import CaseRelationships from '../../src/lib/ui/components/moderation/CaseRelationships.svelte'; import ModerationDialog from '../../src/lib/ui/components/moderation/ModerationDialog.svelte'; import type { ModerationCaseRelationship } from '../../src/lib/moderation.js'; let component: ReturnType | undefined; afterEach(async () => { if (component) await unmount(component); component = undefined; document.body.replaceChildren(); vi.unstubAllGlobals(); }); const candidate = { id: 'case-2', title: 'Earlier report', status: 'open' as const, game: 't5' as const, guid: '123', sharedMatch: true }; const relationship: ModerationCaseRelationship = { id: 'relationship-1', relation: 'appeal_of', direction: 'incoming', case: candidate, actor: 'Moderator One', createdAt: '2026-10-17T10:00:00Z' }; function show(permitted = true, relationships: ModerationCaseRelationship[] = []) { const saved = vi.fn(); component = mount(CaseRelationships, { target: document.body, props: { caseId: 'case-1', revision: 6, relationships, candidates: [candidate], permitted, onsubmitted: saved } }); flushSync(); return saved; } const button = (label: string) => [...document.querySelectorAll('button')].find((node) => node.textContent?.trim() === label)!; function field( label: string ): T { const node = [...document.querySelectorAll('dialog label')].find( (node) => node.textContent?.replace('*', '').trim() === label )!; return document.querySelector(`[aria-labelledby="${node.id}"]`)! as T; } function enter(label: string, value: string) { const node = field(label); node.value = value; node.dispatchEvent(new Event('input', { bubbles: true })); flushSync(); } function select(label: string, value: string) { const node = field(label); const query = node.querySelector.bind(node); node.querySelector = ((selector: string) => selector === ':checked' ? (node.selectedOptions[0] ?? null) : query(selector)) as typeof node.querySelector; node.value = value; node.dispatchEvent(new Event('change', { bubbles: true })); flushSync(); } const response = (value: unknown, status = 200) => new Response(JSON.stringify(value), { status }); it.each(['candidate', 'url'])( 'links a %s with an explicit relationship and reason', async (source) => { const fetch = vi.fn().mockResolvedValue(response({ ok: true, id: 'case-1' })); vi.stubGlobal('fetch', fetch); const saved = show(); expect(document.body.textContent).toContain('Shared archived match'); button('Link case').click(); await tick(); if (source === 'candidate') { select('Related case', 'case-2'); select('Relationship', 'duplicate_of'); } else enter('Case ID or URL', 'https://example.com/moderation/cases/case-2?view=history'); enter('Relationship reason', 'Both reports refer to the same exchange'); button('Link cases').click(); await vi.waitFor(() => expect(saved).toHaveBeenCalledOnce()); expect(JSON.parse(fetch.mock.calls[0][1].body)).toEqual({ op: 'link-case', id: 'case-1', revision: 6, relatedCaseId: 'case-2', relation: source === 'candidate' ? 'duplicate_of' : 'same_incident', reason: 'Both reports refer to the same exchange', idempotencyKey: expect.any(String) }); } ); it('displays inverse relationships and removes links only with a recorded reason', async () => { const fetch = vi.fn().mockResolvedValue(response({ ok: true, id: 'case-1' })); vi.stubGlobal('fetch', fetch); const saved = show(true, [relationship]); expect(document.body.textContent).toContain('Appealed by'); button('Remove link').click(); await tick(); expect(field('Relationship reason').required).toBe(true); enter('Relationship reason', 'Linked to the wrong incident'); button('Remove relationship').click(); await vi.waitFor(() => expect(saved).toHaveBeenCalledOnce()); expect(JSON.parse(fetch.mock.calls[0][1].body)).toMatchObject({ op: 'unlink-case', id: 'case-1', revision: 6, relationshipId: 'relationship-1', reason: 'Linked to the wrong incident' }); }); it('lets read-only viewers inspect candidate hints and inverse relationships', () => { show(false, [ relationship, { ...relationship, id: 'relationship-2', relation: 'duplicate_of' } ]); expect(document.body.textContent).toContain('Appealed by'); expect(document.body.textContent).toContain('Has duplicate'); expect(document.querySelector('a[href="/moderation/cases/case-2"]')).not.toBeNull(); expect(button('Link case')).toBeUndefined(); expect(button('Remove link')).toBeUndefined(); }); it('shows existing cases in the generic case creation dialog', async () => { const fetch = vi.fn().mockResolvedValue(response({ cases: [candidate], total: 31 })); vi.stubGlobal('fetch', fetch); component = mount(ModerationDialog, { target: document.body, props: { open: true, mode: 'case', coverage: [], admin: false, seed: { game: 't5', guid: '123' }, onsubmitted: vi.fn() } }); flushSync(); await vi.waitFor(() => expect(document.body.textContent).toContain('31 existing cases')); expect(fetch.mock.calls[0][0]).toBe('/api/moderation?view=cases&game=t5&guid=123'); expect(document.querySelector('a[href="/moderation/cases/case-2"]')).not.toBeNull(); expect(document.body.textContent).toContain('View all cases for this player'); }); it('allows case creation when duplicate lookup fails', async () => { const fetch = vi .fn() .mockRejectedValueOnce(new Error('Lookup unavailable')) .mockResolvedValue(response({ ok: true, id: 'new-case' })); vi.stubGlobal('fetch', fetch); const saved = vi.fn(); component = mount(ModerationDialog, { target: document.body, props: { open: true, mode: 'case', coverage: [], admin: false, seed: { game: 't5', guid: '123' }, onsubmitted: saved } }); flushSync(); await vi.waitFor(() => expect(document.body.textContent).toContain('Existing cases could not be loaded') ); enter('Case title', 'Distinct incident'); button('Save').click(); await vi.waitFor(() => expect(saved).toHaveBeenCalledOnce()); expect(JSON.parse(fetch.mock.calls[1][1].body)).toMatchObject({ op: 'create-case', game: 't5', guid: '123', title: 'Distinct incident' }); });