This commit is contained in:
@@ -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);
|
||||
|
||||
@@ -21,7 +21,7 @@ const item: ModerationCase = {
|
||||
game: 't5',
|
||||
guid: '123',
|
||||
title: 'Chat review',
|
||||
status: 'resolved',
|
||||
status: 'closed',
|
||||
priority: 'normal',
|
||||
assignee: null,
|
||||
assigneeId: null,
|
||||
|
||||
@@ -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.'
|
||||
|
||||
@@ -94,7 +94,7 @@ it('preserves current and historical assessments with case revisions, including
|
||||
};
|
||||
await mutate(assess(id, 3, updated), 'admin');
|
||||
const data = await detail(id, 'super_viewer');
|
||||
expect(data.case).toMatchObject({ revision: 4, status: 'no_action' });
|
||||
expect(data.case).toMatchObject({ revision: 4, status: 'closed' });
|
||||
expect(data.assessment).toEqual({
|
||||
...updated,
|
||||
revision: 4,
|
||||
@@ -253,8 +253,8 @@ it('adds assessment storage to an existing database without altering case record
|
||||
)
|
||||
.run(clock, clock);
|
||||
const before = legacy.prepare('SELECT * FROM moderation_cases').all();
|
||||
runMigrations(legacy, migrations);
|
||||
runMigrations(legacy, migrations);
|
||||
runMigrations(legacy, migrations.slice(0, 32));
|
||||
runMigrations(legacy, migrations.slice(0, 32));
|
||||
expect(legacy.prepare('SELECT * FROM moderation_cases').all()).toMatchObject(before);
|
||||
const insert = legacy.prepare(
|
||||
'INSERT INTO moderation_case_assessments VALUES(?,?,?,?,?,?)'
|
||||
|
||||
@@ -164,7 +164,7 @@ it('links historical live actions to closed cases and exposes their real deliver
|
||||
const before = enforcement();
|
||||
await mutate(link(id, 2, 'action', warningId));
|
||||
const data = await detail(id, 'super_viewer');
|
||||
expect(data.case).toMatchObject({ status: 'no_action', revision: 3 });
|
||||
expect(data.case).toMatchObject({ status: 'closed', revision: 3 });
|
||||
expect(data.linkedActions).toMatchObject([{ id: warningId, kind: 'warn', state: 'confirmed' }]);
|
||||
expect(data.availableActions).toMatchObject([{ id: failedId, state: 'failed' }]);
|
||||
expect(data.events.map((event) => event.action)).toContain('action.confirmed');
|
||||
@@ -246,7 +246,7 @@ it('requires a linked confirmed warning for a cited warning outcome and preserve
|
||||
const before = enforcement();
|
||||
await mutate(resolve(5, 'action', confirmed));
|
||||
const data = await detail(id);
|
||||
expect(data.case).toMatchObject({ status: 'warning_issued', revision: 6 });
|
||||
expect(data.case).toMatchObject({ status: 'closed', revision: 6 });
|
||||
expect(JSON.parse(data.events[0].detail)).toEqual({
|
||||
reason: 'Decision after evidence review',
|
||||
actionReference: { type: 'action', id: confirmed }
|
||||
@@ -268,7 +268,7 @@ it('allows a historical expired restriction created from the original case to su
|
||||
reason: 'Earlier mute addressed the incident',
|
||||
actionReference: { type: 'restriction', id: sanction.id }
|
||||
});
|
||||
expect((await detail(id)).case.status).toBe('restriction_applied');
|
||||
expect((await detail(id)).case.status).toBe('closed');
|
||||
expect(enforcement()).toEqual(before);
|
||||
});
|
||||
|
||||
|
||||
@@ -26,8 +26,8 @@ it('filters case queues by workflow, priority and ownership while prioritizing a
|
||||
for (const [title, priority, status, owner] of [
|
||||
['Normal report', 'normal', 'open', null],
|
||||
['Urgent review', 'urgent', 'in_review', 'first'],
|
||||
['High escalation', 'high', 'escalated', 'second'],
|
||||
['Closed urgent appeal', 'urgent', 'appeal_upheld', 'first']
|
||||
['High escalation', 'high', 'in_review', 'second'],
|
||||
['Closed urgent appeal', 'urgent', 'closed', 'first']
|
||||
]) {
|
||||
const { id } = mutateModeration('first', {
|
||||
op: 'create-case',
|
||||
@@ -46,7 +46,7 @@ it('filters case queues by workflow, priority and ownership while prioritizing a
|
||||
expect(read().cases.map((item) => item.id)).toEqual([ids[1], ids[2], ids[0], ids[3]]);
|
||||
expect(read('caseStatus=active').total).toBe(3);
|
||||
expect(read('caseStatus=closed').cases.map((item) => item.id)).toEqual([ids[3]]);
|
||||
expect(read('caseStatus=escalated&priority=high').cases.map((item) => item.id)).toEqual([
|
||||
expect(read('caseStatus=in_review&priority=high').cases.map((item) => item.id)).toEqual([
|
||||
ids[2]
|
||||
]);
|
||||
expect(read('assignedTo=me').cases.map((item) => item.id)).toEqual([ids[1], ids[3]]);
|
||||
|
||||
@@ -156,7 +156,7 @@ it('enforces case existence, staff access, revisions, self-link rejection and su
|
||||
reason: 'Review complete'
|
||||
});
|
||||
await mutate(link(a.id, 2, b.id));
|
||||
expect((await detail(a.id)).case).toMatchObject({ status: 'no_action', revision: 3 });
|
||||
expect((await detail(a.id)).case).toMatchObject({ status: 'closed', revision: 3 });
|
||||
await expect(detail(a.id, 'viewer')).rejects.toMatchObject({ status: 403 });
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,157 @@
|
||||
import Database from 'better-sqlite3';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { afterEach, beforeEach, expect, it, vi } from 'vitest';
|
||||
import { moderationCaseOutcomes, moderationCaseStatuses } from '../../src/lib/moderation.js';
|
||||
import { newFixture, type Fixture } from '../helpers/fixture.js';
|
||||
|
||||
let fixture: Fixture;
|
||||
beforeEach(async () => {
|
||||
vi.resetModules();
|
||||
fixture = await newFixture([]);
|
||||
});
|
||||
afterEach(async () => fixture.cleanup());
|
||||
|
||||
it('migrates every legacy state while preserving assignments, decisions, evidence, actions and relationships', async () => {
|
||||
const { migrations } = await import('../../src/lib/server/db/migrations.js');
|
||||
const { runMigrations } = await import('../../src/lib/server/db/migration-runner.js');
|
||||
const handle = new Database(':memory:');
|
||||
handle.pragma('foreign_keys = ON');
|
||||
try {
|
||||
runMigrations(handle, migrations.slice(0, 35));
|
||||
handle.exec(`INSERT INTO auth_users(id,username,display_name,password_hash,role,created_at,updated_at)
|
||||
VALUES('staff','staff','Staff','unused','moderator',0,0)`);
|
||||
const statuses = ['open', 'in_review', 'escalated', ...moderationCaseOutcomes];
|
||||
for (const status of statuses) {
|
||||
handle
|
||||
.prepare(
|
||||
`INSERT INTO moderation_cases(id,game,guid,title,status,actor_id,actor,created_at,updated_at,revision,assignee_id,review_at)
|
||||
VALUES(?,'t5','123','Existing case',?,'staff','Staff','2026-10-17','2026-10-17',7,'staff','2026-11-01')`
|
||||
)
|
||||
.run(status, status);
|
||||
}
|
||||
handle.exec(`
|
||||
INSERT INTO moderation_events(game,guid,actor,action,subject_id,detail,created_at)
|
||||
VALUES('t5','123','Staff','case.warning_issued','warning_issued','Original decision','2026-10-17');
|
||||
INSERT INTO moderation_evidence VALUES('e','warning_issued','Original excerpt','Archived chat','{"entry":"unchanged"}','staff','Staff','2026-10-17');
|
||||
INSERT INTO moderation_sanctions(id,game,guid,kind,scope,reason,note,actor_id,actor,created_at,case_id)
|
||||
VALUES('s','t5','123','mute','game','Original action','','staff','Staff','2026-10-17','warning_issued');
|
||||
UPDATE moderation_cases SET review_sanction_id='s' WHERE id='warning_issued';
|
||||
INSERT INTO moderation_case_action_links VALUES('warning_issued','restriction','s','Staff','2026-10-17');
|
||||
INSERT INTO moderation_case_relationships VALUES('r','open','warning_issued','same_incident','staff','Staff','2026-10-17',NULL);
|
||||
INSERT INTO moderation_case_assessments VALUES('warning_issued',6,'{"summary":"Existing finding"}','staff','Staff','2026-10-17');
|
||||
INSERT INTO moderation_case_notifications(user_id,case_id,kind,created_at) VALUES('staff','warning_issued','evidence','2026-10-17');
|
||||
`);
|
||||
const children = [
|
||||
'moderation_evidence',
|
||||
'moderation_sanctions',
|
||||
'moderation_case_action_links',
|
||||
'moderation_case_relationships',
|
||||
'moderation_case_assessments',
|
||||
'moderation_case_notifications'
|
||||
];
|
||||
const before = children.map((table) => handle.prepare(`SELECT * FROM ${table}`).all());
|
||||
runMigrations(handle, migrations);
|
||||
runMigrations(handle, migrations);
|
||||
expect(children.map((table) => handle.prepare(`SELECT * FROM ${table}`).all())).toEqual(
|
||||
before
|
||||
);
|
||||
for (const old of statuses) {
|
||||
const expected =
|
||||
old === 'open'
|
||||
? 'open'
|
||||
: ['in_review', 'escalated'].includes(old)
|
||||
? 'in_review'
|
||||
: 'closed';
|
||||
expect(
|
||||
handle
|
||||
.prepare(
|
||||
'SELECT status,revision,assignee_id,review_at FROM moderation_cases WHERE id=?'
|
||||
)
|
||||
.get(old)
|
||||
).toEqual({
|
||||
status: expected,
|
||||
revision: old === expected ? 7 : 8,
|
||||
assignee_id: 'staff',
|
||||
review_at: '2026-11-01'
|
||||
});
|
||||
}
|
||||
const events = handle
|
||||
.prepare("SELECT detail FROM moderation_events WHERE action='case.status_migrated'")
|
||||
.all() as { detail: string }[];
|
||||
expect(events.map((event) => JSON.parse(event.detail).from).sort()).toEqual(
|
||||
['escalated', ...moderationCaseOutcomes].sort()
|
||||
);
|
||||
expect(
|
||||
handle
|
||||
.prepare("SELECT detail FROM moderation_events WHERE action='case.warning_issued'")
|
||||
.get()
|
||||
).toEqual({ detail: 'Original decision' });
|
||||
expect(() =>
|
||||
handle.exec("UPDATE moderation_cases SET status='warning_issued' WHERE id='open'")
|
||||
).toThrow('CHECK');
|
||||
expect(handle.pragma('foreign_key_check')).toEqual([]);
|
||||
} finally {
|
||||
handle.close();
|
||||
}
|
||||
});
|
||||
|
||||
it('changes lifecycle independently of ownership, findings and enforcement with revision and retry protection', async () => {
|
||||
const { db } = await import('../../src/lib/server/db/index.js');
|
||||
const { mutateModeration } = await import('../../src/lib/server/moderation/actions.js');
|
||||
const { moderationCaseDetail } = await import('../../src/lib/server/moderation/cases.js');
|
||||
const handle = db();
|
||||
for (const role of ['moderator', 'super_viewer'])
|
||||
handle
|
||||
.prepare(
|
||||
`INSERT INTO auth_users(id,username,display_name,password_hash,role,created_at,updated_at)
|
||||
VALUES(?,?,?,'unused',?,0,0)`
|
||||
)
|
||||
.run(role, role, role, role);
|
||||
const mutate = (input: Record<string, unknown>, actor = 'moderator') =>
|
||||
mutateModeration(actor, { idempotencyKey: randomUUID(), ...input });
|
||||
const { id } = mutate({
|
||||
op: 'create-case',
|
||||
game: 't5',
|
||||
guid: '123',
|
||||
title: 'Reported conduct',
|
||||
assigneeId: 'moderator'
|
||||
});
|
||||
mutate({
|
||||
op: 'assess-case',
|
||||
id,
|
||||
revision: 1,
|
||||
summary: 'Ambiguous context',
|
||||
rules: [],
|
||||
findings: [{ category: 'threats', finding: 'inconclusive', detail: 'Need more context' }]
|
||||
});
|
||||
const original = moderationCaseDetail('moderator', id);
|
||||
let revision = 2;
|
||||
for (const status of ['in_review', 'closed', 'open', 'closed', 'in_review']) {
|
||||
const request = {
|
||||
op: 'update-case',
|
||||
id,
|
||||
revision,
|
||||
status,
|
||||
reason: 'Workflow changed',
|
||||
idempotencyKey: randomUUID()
|
||||
};
|
||||
expect(() => mutate({ ...request, reason: '' })).toThrow('Status change reason');
|
||||
expect(() => mutate({ ...request, revision: revision - 1 })).toThrow('changed');
|
||||
expect(() => mutate(request, 'super_viewer')).toThrow('Moderation access');
|
||||
mutate(request);
|
||||
mutate(request);
|
||||
revision++;
|
||||
const data = moderationCaseDetail('moderator', id);
|
||||
expect(data.case).toMatchObject({ status, revision, assigneeId: 'moderator' });
|
||||
expect(data.assessmentHistory).toEqual(original.assessmentHistory);
|
||||
expect(data.case.evidence).toEqual(original.case.evidence);
|
||||
expect(data.sanctions).toEqual(original.sanctions);
|
||||
expect(data.events[0]).toMatchObject({ action: 'case.updated' });
|
||||
}
|
||||
for (const status of [...moderationCaseOutcomes, 'assigned', 'escalated'])
|
||||
expect(() =>
|
||||
mutate({ op: 'update-case', id, revision, status, reason: 'Invalid state' })
|
||||
).toThrow('Choose Open');
|
||||
expect(moderationCaseStatuses).toEqual(['open', 'in_review', 'closed']);
|
||||
expect(handle.prepare('SELECT COUNT(*) AS n FROM moderation_actions').get()).toEqual({ n: 0 });
|
||||
});
|
||||
@@ -134,8 +134,8 @@ it('upgrades legacy cases without changing evidence, events, requests, or restri
|
||||
.all()
|
||||
).toEqual(
|
||||
['dismissed', 'open', 'resolved'].map((status) => ({
|
||||
status,
|
||||
revision: 7,
|
||||
status: status === 'open' ? 'open' : 'closed',
|
||||
revision: status === 'open' ? 7 : 8,
|
||||
priority: 'normal',
|
||||
assignee_id: null
|
||||
}))
|
||||
@@ -146,7 +146,7 @@ it('upgrades legacy cases without changing evidence, events, requests, or restri
|
||||
.get('{"original":true}')
|
||||
).toEqual({ count: 3 });
|
||||
expect(legacy.prepare('SELECT COUNT(*) AS count FROM moderation_events').get()).toEqual({
|
||||
count: 3
|
||||
count: 5
|
||||
});
|
||||
expect(legacy.prepare('SELECT result FROM moderation_requests').get()).toEqual({
|
||||
result: '{"ok":true,"id":"open"}'
|
||||
@@ -245,12 +245,12 @@ it('audits title, ownership, priority and active state updates with optimistic r
|
||||
id,
|
||||
revision: 3,
|
||||
assigneeId: null,
|
||||
status: 'escalated',
|
||||
status: 'open',
|
||||
reason: 'Needs administrator review'
|
||||
});
|
||||
expect((await detail(id)).case).toMatchObject({
|
||||
revision: 4,
|
||||
status: 'escalated',
|
||||
status: 'open',
|
||||
assigneeId: null
|
||||
});
|
||||
await expect(
|
||||
@@ -267,9 +267,9 @@ it('supports every outcome and reopening while preserving each prior decision',
|
||||
);
|
||||
await mutate({ op: 'resolve-case', id, revision, status, reason: `Decision ${status}` });
|
||||
revision++;
|
||||
expect((await detail(id)).case.status).toBe(status);
|
||||
expect((await detail(id)).case.status).toBe('closed');
|
||||
await expect(mutate({ op: 'update-case', id, revision, status: 'open' })).rejects.toThrow(
|
||||
'Use reopen'
|
||||
'Status change reason'
|
||||
);
|
||||
await expect(
|
||||
mutate({ op: 'reopen-case', id, revision: revision - 1, reason: 'Old version' })
|
||||
|
||||
@@ -185,7 +185,7 @@ it('prioritizes overdue closed cases and recalculates due and scheduled queues u
|
||||
closed.id,
|
||||
upcoming.id
|
||||
]);
|
||||
expect((await overview()).cases[0].status).toBe('no_action');
|
||||
expect((await overview()).cases[0].status).toBe('closed');
|
||||
await mutate(schedule(closed.id, 3, null));
|
||||
expect((await overview('creator', 'review=due')).cases.map((item) => item.id)).toEqual([
|
||||
upcoming.id
|
||||
|
||||
@@ -95,14 +95,14 @@ it('presents case decisions and ownership changes as readable history while pres
|
||||
'case.updated'
|
||||
)
|
||||
).toBe(
|
||||
'Title: Report → Appeal review\nAssigned to: Unassigned → Moderator\nStatus: Open → In review\nReviewing additional evidence'
|
||||
'Title: Report → Appeal review\nAssigned to: Unassigned → Moderator\nStatus: Open → In progress\nReviewing additional evidence'
|
||||
);
|
||||
expect(
|
||||
moderationEventDetail(
|
||||
JSON.stringify({ from: 'appeal_denied', reason: 'New evidence' }),
|
||||
'case.reopened'
|
||||
)
|
||||
).toBe('Previous outcome: Appeal denied\nNew evidence');
|
||||
).toBe('Previous status: Appeal denied\nNew evidence');
|
||||
expect(moderationEventDetail('Decision with {unusual text}', 'case.resolved')).toBe(
|
||||
'Decision with {unusual text}'
|
||||
);
|
||||
@@ -110,3 +110,14 @@ it('presents case decisions and ownership changes as readable history while pres
|
||||
moderationEventDetail(JSON.stringify({ count: 2, matchId: 42 }), 'case.evidence_attached')
|
||||
).toBe('2 archived chat entries attached from match #42.');
|
||||
});
|
||||
|
||||
it('explains migrated status without hiding the historical decision', () => {
|
||||
expect(
|
||||
moderationEventDetail(
|
||||
JSON.stringify({ from: 'warning_issued', to: 'closed' }),
|
||||
'case.status_migrated'
|
||||
)
|
||||
).toBe(
|
||||
'Previous status: Warning issued → Closed. Earlier decisions and actions are preserved.'
|
||||
);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user