680 lines
26 KiB
TypeScript
680 lines
26 KiB
TypeScript
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'
|
||
});
|
||
});
|