This commit is contained in:
@@ -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 });
|
||||
});
|
||||
Reference in New Issue
Block a user