Files
electric-cherry/tests/client/moderation-case-page.test.ts
T
2026-10-18 16:20:29 -04:00

680 lines
26 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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<typeof currentPage.subscribe>[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<typeof mount> | 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<T extends HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement>(
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<HTMLSelectElement>(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');
const evidence = document.querySelector('[aria-labelledby="evidence-title"]')!;
expect(
evidence.querySelector('[aria-label="Preserved conversation excerpt"]')?.textContent
).toContain('abusive message');
const technical = [...evidence.querySelectorAll('details')].find(
(node) => node.querySelector('summary')?.textContent === 'Technical details'
)!;
expect(technical.open).toBe(false);
expect(technical.textContent).toContain('Archived chat · theater · match 17');
expect(JSON.parse(technical.querySelector('pre')!.textContent!)).toEqual(
JSON.parse(detail().case.evidence[0].snapshot!)
);
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('summarizes a preserved conversation across midnight and keeps identifiers on demand', () => {
const initial = detail();
const archive = JSON.parse(initial.case.evidence[0].snapshot!);
archive.entries = [
{
...archive.entry,
messageId: 'subject-message',
context: { realTime: '2026-10-16T03:59:00Z', round: 4 },
message: { ...archive.entry.message, message: '<b>First message</b>', channel: 'team' }
},
{
...archive.entry,
messageId: 'witness-message',
context: { realTime: '2026-10-16T04:01:00Z', round: 9 },
message: { ...archive.entry.message, guid: '42', player: 'Witness', message: 'Reply' }
},
{
...archive.entry,
messageId: 'unknown-time',
context: { realTime: null, round: null }
}
];
archive.selectedMessageIds = ['subject-message', 'witness-message'];
archive.highlightedMessageIds = ['subject-message'];
initial.case.evidence[0].snapshot = JSON.stringify(archive);
show(initial);
const evidence = document.querySelector('[aria-labelledby="evidence-title"]')!;
const excerpt = evidence.querySelector('[aria-label="Preserved conversation excerpt"]')!;
const rows = excerpt.querySelectorAll('li');
expect(rows).toHaveLength(3);
expect(rows[0].classList.contains('highlighted')).toBe(true);
expect(rows[1].classList.contains('highlighted')).toBe(false);
expect(rows[0].textContent).toContain('Team chat');
expect(rows[0].textContent).toContain('<b>First message</b>');
expect(rows[0].querySelector('b')).toBeNull();
expect(rows[1].textContent).toContain('Selected context');
expect(rows[2].textContent).toContain('Unknown time');
expect(excerpt.textContent).not.toContain(guid);
expect(excerpt.textContent).not.toContain('subject-message');
expect(excerpt.textContent).not.toContain('Round');
const context = excerpt.previousElementSibling!.textContent!;
expect(context).toContain('Kino Der Toten · Match #17');
expect(context).toContain('Oct 15, 2026 – Oct 16, 2026');
expect(context).toContain('Rounds 4–9 (some unknown)');
expect(context).toContain('Times are estimates.');
const details = excerpt.nextElementSibling as HTMLDetailsElement;
expect(details.open).toBe(false);
expect(details.textContent).toContain(`GUID ${guid}`);
expect(details.textContent).toContain('Message identifier subject-message');
expect(details.textContent).toContain('Message identifier witness-message');
});
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<HTMLSelectElement>('Game').disabled).toBe(true);
expect(field<HTMLInputElement>('Player GUID').readOnly).toBe(true);
expect(field<HTMLInputElement>('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<HTMLSelectElement>('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<HTMLTextAreaElement>('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<HTMLSelectElement>('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<HTMLSelectElement>('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<Response>((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'
});
});