Files
electric-cherry/tests/unit/moderation-case-state.test.ts
T
2026-10-17 21:30:43 -04:00

158 lines
7.6 KiB
TypeScript

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 });
});