Separate moderation case status from decisions
CI / checks (push) Failing after 7m24s

This commit is contained in:
2026-10-17 21:30:43 -04:00
parent 48653af3dc
commit a7f1c65d17
22 changed files with 357 additions and 222 deletions
+51 -53
View File
@@ -211,7 +211,7 @@ it('preserves original evidence context, linked restrictions, and staff history
expect(document.querySelector('a[href^="/matches/17"]')).not.toBeNull();
for (const label of [
'Edit case',
'Review case',
'Change status',
'Reopen case',
'Restrict player',
'Revoke restriction',
@@ -251,12 +251,12 @@ it('locks case restrictions to the recorded identity and revision without closin
durationMinutes: 60
});
expect(fetch.mock.calls[1][0]).toBe('/api/moderation/cases/case-1');
expect(button('Review case')).toBeDefined();
expect(button('Change status')).toBeDefined();
});
it('records specific outcomes and reopens a closed case with a reason and the latest revision', async () => {
it('closes and reopens through lifecycle status with a reason and the latest revision', async () => {
const closed = detail();
closed.case.status = 'warning_issued';
closed.case.status = 'closed';
closed.case.revision = 5;
const reopened = detail();
reopened.case.status = 'open';
@@ -269,37 +269,34 @@ it('records specific outcomes and reopens a closed case with a reason and the la
.mockResolvedValueOnce(response(reopened));
vi.stubGlobal('fetch', fetch);
show();
button('Review case').click();
button('Change status').click();
await tick();
expect([...field<HTMLSelectElement>('Outcome').options].map((option) => option.value)).toEqual([
'resolved',
'dismissed',
'warning_issued',
'restriction_applied',
'no_action',
'duplicate',
'appeal_upheld',
'appeal_denied'
expect([...field<HTMLSelectElement>('Status').options].map((option) => option.value)).toEqual([
'open',
'in_review',
'closed'
]);
select('Outcome', 'warning_issued');
enter(field('Review reason'), 'Warning was sufficient');
select('Status', 'closed');
enter(field('Change reason'), 'Warning was sufficient');
submit();
await vi.waitFor(() => expect(button('Reopen case')).toBeDefined());
await vi.waitFor(() => expect(document.querySelector('dialog')?.open).not.toBe(true));
expect(JSON.parse(fetch.mock.calls[0][1].body)).toMatchObject({
op: 'resolve-case',
op: 'update-case',
id: 'case-1',
revision: 4,
status: 'warning_issued',
status: 'closed',
reason: 'Warning was sufficient'
});
expect(button('Restrict player')).toBeUndefined();
button('Reopen case').click();
button('Change status').click();
await tick();
enter(field('Reopening reason'), 'New evidence received');
select('Status', 'open');
enter(field('Change reason'), 'New evidence received');
submit();
await vi.waitFor(() => expect(button('Review case')).toBeDefined());
await vi.waitFor(() => expect(fetch).toHaveBeenCalledTimes(4));
expect(JSON.parse(fetch.mock.calls[2][1].body)).toMatchObject({
op: 'reopen-case',
op: 'update-case',
status: 'open',
id: 'case-1',
revision: 5,
reason: 'New evidence received'
@@ -307,7 +304,7 @@ it('records specific outcomes and reopens a closed case with a reason and the la
expect(document.body.textContent).toContain('Check the earlier warning.');
});
it('edits priority, assignment, and review workflow with a required transition reason', async () => {
it('edits priority and assignment separately from lifecycle status', async () => {
const fetch = vi
.fn()
.mockResolvedValueOnce(response({ ok: true, id: 'case-1' }))
@@ -319,8 +316,8 @@ it('edits priority, assignment, and review workflow with a required transition r
enter(field('Case title'), 'Urgent report');
select('Priority', 'urgent');
select('Assigned to', 'staff-2');
select('Workflow status', 'escalated');
expect(field<HTMLTextAreaElement>('Change reason').required).toBe(true);
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());
@@ -331,14 +328,13 @@ it('edits priority, assignment, and review workflow with a required transition r
title: 'Urgent report',
priority: 'urgent',
assigneeId: 'staff-2',
status: 'escalated',
reason: 'Needs administrator review'
});
});
it('preserves an unavailable assignee when editing metadata on a closed case', async () => {
const initial = detail();
initial.case.status = 'no_action';
initial.case.status = 'closed';
initial.case.assigneeId = 'disabled-staff';
initial.case.assignee = 'Former staff';
const fetch = vi
@@ -431,13 +427,11 @@ it('links a prior mute with its recorded outcome without issuing another restric
expect(button('Link existing action').disabled).toBe(true);
});
it.each([
['warning_issued', 'action:warning-1', 'action', 'warning-1'],
['restriction_applied', 'restriction:restriction-1', 'restriction', 'restriction-1']
] as const)(
'references a linked action when recording %s',
async (outcome, selection, type, id) => {
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',
@@ -476,31 +470,34 @@ it.each([
.mockResolvedValue(response(initial));
vi.stubGlobal('fetch', fetch);
show(initial);
button('Review case').click();
button('Change status').click();
await tick();
select('Outcome', outcome);
const options = [...field<HTMLSelectElement>('Supporting action').options].map(
(option) => option.value
);
expect(options).toEqual(['', selection]);
select('Supporting action', selection);
enter(field('Review reason'), 'Reviewed the recorded action');
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: 'resolve-case',
op: 'update-case',
id: 'case-1',
revision: 4,
status: outcome,
actionReference: { type, id }
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.status = 'escalated';
updated.case.title = 'Updated case after concurrent edit';
updated.case.status = 'in_review';
const fetch = vi
.fn()
.mockResolvedValueOnce(
@@ -509,28 +506,29 @@ it('retains a conflicted review and lets staff refresh before making another dec
.mockResolvedValue(response(updated));
vi.stubGlobal('fetch', fetch);
show();
button('Review case').click();
button('Change status').click();
await tick();
select('Outcome', 'appeal_denied');
enter(field('Review reason'), 'Evidence supports the restriction');
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>('Outcome').value).toBe('appeal_denied');
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('.case-summary')?.textContent).toContain('Escalated')
expect(document.querySelector('h1')?.textContent).toBe('Updated case after concurrent edit')
);
button('Review case').click();
button('Change status').click();
await tick();
enter(field('Review reason'), 'Reviewed new context');
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);
+1 -1
View File
@@ -21,7 +21,7 @@ const item: ModerationCase = {
game: 't5',
guid: '123',
title: 'Chat review',
status: 'resolved',
status: 'closed',
priority: 'normal',
assignee: null,
assigneeId: null,
+2 -2
View File
@@ -353,7 +353,7 @@ it('links case records to workspaces and preserves case filters while paging', (
actor: 'Moderator',
createdAt: '2026-10-16T12:00:00Z'
},
status: 'escalated',
status: 'in_review',
priority: 'high',
assigneeId: 'staff-1',
assignee: 'Moderator',
@@ -369,7 +369,7 @@ it('links case records to workspaces and preserves case filters while paging', (
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('In progress');
expect(document.querySelector('#moderation-results')?.textContent).toContain('Moderator');
expect(document.querySelector('#moderation-results')?.textContent).toContain(
'The threat remains ambiguous.'