import { afterEach, expect, it, vi } from 'vitest'; import { flushSync, mount, tick, unmount } from 'svelte'; import { fromStore, writable } from 'svelte/store'; import CaseFollowUp from '../../src/lib/ui/components/moderation/CaseFollowUp.svelte'; import CaseUpdates from '../../src/lib/ui/components/moderation/CaseUpdates.svelte'; import type { ModerationCase, ModerationCaseAction, ModerationCaseNotification } from '../../src/lib/moderation.js'; let component: ReturnType | undefined; afterEach(async () => { if (component) await unmount(component); component = undefined; document.body.replaceChildren(); vi.unstubAllGlobals(); }); const item: ModerationCase = { id: 'case-1', game: 't5', guid: '123', title: 'Chat review', status: 'closed', priority: 'normal', assignee: null, assigneeId: null, actor: 'Moderator One', createdAt: '2026-10-16T10:00:00Z', updatedAt: '2026-10-16T10:00:00Z', revision: 6, evidence: [] }; const mute: ModerationCaseAction = { id: 'mute-1', type: 'restriction', kind: 'mute', reason: 'Earlier chat abuse', actor: 'Moderator One', createdAt: '2026-10-16T10:00:00Z', state: 'active', expiresAt: '2026-10-17T10:00:00Z' }; function show(initial = item, permitted = true) { const saved = vi.fn(); component = mount(CaseFollowUp, { target: document.body, props: { item: initial, actions: [mute, { ...mute, id: 'permanent', expiresAt: null }], checkedAt: '2026-10-17T12:00:00Z', permitted, onsubmitted: saved } }); flushSync(); return saved; } const button = (label: string, dialog = false) => [...document.querySelectorAll(dialog ? 'dialog button' : 'button')].find( (node) => node.textContent?.trim() === label )! as HTMLButtonElement; 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('schedules a closed case using a browser-local date converted to canonical ISO', async () => { const fetch = vi.fn().mockResolvedValue(response({ ok: true, id: 'case-1' })); vi.stubGlobal('fetch', fetch); const saved = show(); button('Schedule review').click(); await tick(); enter('Review date (browser local time)', '2026-10-19T14:30'); enter('Follow-up reason', 'Check for additional reports'); button('Save follow-up').click(); await vi.waitFor(() => expect(saved).toHaveBeenCalledOnce()); expect(JSON.parse(fetch.mock.calls[0][1].body)).toEqual({ op: 'schedule-case-review', id: 'case-1', revision: 6, reviewAt: new Date('2026-10-19T14:30').toISOString(), reason: 'Check for additional reports', idempotencyKey: expect.any(String) }); }); it('uses the linked finite mute ID to schedule an exact expiration review', async () => { const fetch = vi.fn().mockResolvedValue(response({ ok: true, id: 'case-1' })); vi.stubGlobal('fetch', fetch); const saved = show(); button('Schedule review').click(); await tick(); expect( [...field('Review timing').options].map((option) => option.value) ).toEqual(['custom', 'mute-1']); select('Review timing', 'mute-1'); enter('Follow-up reason', 'Review after mute expires'); button('Save follow-up').click(); await vi.waitFor(() => expect(saved).toHaveBeenCalledOnce()); const payload = JSON.parse(fetch.mock.calls[0][1].body); expect(payload).toMatchObject({ op: 'schedule-case-review', restrictionId: 'mute-1', revision: 6 }); expect(payload).not.toHaveProperty('reviewAt'); }); it('shows overdue reviews and clears the date with a recorded result', async () => { const fetch = vi.fn().mockResolvedValue(response({ ok: true, id: 'case-1' })); vi.stubGlobal('fetch', fetch); const saved = show({ ...item, reviewAt: '2026-10-17T10:00:00Z', reviewReason: 'Check once the mute ends', reviewSanctionId: 'mute-1' }); expect(document.body.textContent).toContain('Overdue review'); expect(document.body.textContent).toContain('Review after linked mute expires.'); button('Complete or clear review').click(); await tick(); enter('Review result or clearing reason', 'Review complete; no further reports'); button('Complete or clear review', true).click(); await vi.waitFor(() => expect(saved).toHaveBeenCalledOnce()); expect(JSON.parse(fetch.mock.calls[0][1].body)).toMatchObject({ op: 'schedule-case-review', id: 'case-1', revision: 6, reviewAt: null, reason: 'Review complete; no further reports' }); }); it('shows overdue follow-up details without edit controls for read-only viewers', () => { show({ ...item, reviewAt: '2026-10-17T10:00:00Z', reviewReason: 'Review reports' }, false); expect(document.body.textContent).toContain('Overdue review'); expect(document.body.textContent).toContain('Review reports'); expect(button('Reschedule review')).toBeUndefined(); expect(button('Complete or clear review')).toBeUndefined(); }); const notification = (id: number): ModerationCaseNotification => ({ id, caseId: 'case-1', caseTitle: item.title, kind: id === 3 ? 'assignment' : 'evidence', createdAt: '2026-10-17T10:00:00Z', readAt: null }); it('acknowledges only loaded updates and preserves the read-through ID on an uncertain retry', async () => { const fetch = vi .fn() .mockRejectedValueOnce(new Error('Connection lost')) .mockResolvedValue(response({ ok: true, id: 'case-1' })); vi.stubGlobal('fetch', fetch); const values = writable([notification(3), notification(9)]); const state = fromStore(values); const saved = vi.fn(); component = mount(CaseUpdates, { target: document.body, props: { caseId: 'case-1', get notifications() { return state.current; }, unread: 2, permitted: true, onsubmitted: saved } }); flushSync(); expect(document.body.textContent).toContain('Assignment changed'); expect(document.body.textContent).toContain('Evidence or note added'); button('Mark updates as seen').click(); await vi.waitFor(() => expect(button('Retry same request')).toBeDefined()); values.set([notification(12), notification(9), notification(3)]); flushSync(); button('Retry same request').click(); await vi.waitFor(() => expect(saved).toHaveBeenCalledOnce()); expect(fetch.mock.calls[1][1].body).toBe(fetch.mock.calls[0][1].body); expect(JSON.parse(fetch.mock.calls[0][1].body)).toEqual({ op: 'read-case-updates', id: 'case-1', throughId: 9, idempotencyKey: expect.any(String) }); }); it('does not offer notification acknowledgement to read-only viewers', () => { component = mount(CaseUpdates, { target: document.body, props: { caseId: 'case-1', notifications: [notification(3)], unread: 1, permitted: false, onsubmitted: vi.fn() } }); flushSync(); expect(document.body.textContent).toContain('1 unread'); expect(button('Mark updates as seen')).toBeUndefined(); });