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
+3 -3
View File
@@ -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(?,?,?,?,?,?)'
+3 -3
View File
@@ -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);
});
+3 -3
View File
@@ -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 });
});
+157
View File
@@ -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 });
});
+7 -7
View File
@@ -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' })
+1 -1
View File
@@ -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
+13 -2
View File
@@ -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.'
);
});