158 lines
7.6 KiB
TypeScript
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 });
|
|
});
|