Files
electric-cherry/tests/unit/moderation-evidence.test.ts
T

221 lines
7.9 KiB
TypeScript

import { randomUUID } from 'node:crypto';
import type Database from 'better-sqlite3';
import { afterEach, beforeEach, expect, it, vi } from 'vitest';
import { newFixture, type Fixture } from '../helpers/fixture.js';
let fixture: Fixture;
let handle: Database.Database;
const clock = '2026-10-17T12:00:00.000Z';
const server = 't5_zombie_theater_1';
beforeEach(async () => {
vi.resetModules();
vi.useFakeTimers({ toFake: ['Date'] });
vi.setSystemTime(new Date(clock));
fixture = await newFixture([server]);
const { seedServerRegistry } = await import('../../src/lib/server/fleet/servers.js');
await seedServerRegistry();
const { db } = await import('../../src/lib/server/db/index.js');
handle = db();
handle
.prepare(
`INSERT INTO auth_users(id,username,display_name,password_hash,role,created_at,updated_at)
VALUES('moderator','moderator','moderator','unused','moderator',0,0)`
)
.run();
});
afterEach(async () => {
vi.useRealTimers();
await fixture.cleanup();
});
async function mutate(input: Record<string, unknown>) {
const { mutateModeration } = await import('../../src/lib/server/moderation/actions.js');
return mutateModeration('moderator', { idempotencyKey: randomUUID(), ...input });
}
async function detail(id: string) {
const { moderationCaseDetail } = await import('../../src/lib/server/moderation/cases.js');
return moderationCaseDetail('moderator', id);
}
async function create(overrides: Record<string, unknown> = {}) {
return mutate({
op: 'create-case',
game: 't5',
guid: '123',
title: 'Review archived conversation',
...overrides
});
}
async function archive(messages: Array<{ guid?: string; message: string }>) {
const { saveTelemetry } = await import('../../src/lib/server/fleet/telemetry.js');
const { matchChatTimeline } = await import('../../src/lib/server/fleet/chat.js');
saveTelemetry({
server,
map: 'zombie_theater',
event: 'game_start',
players: [{ guid: '123', name: 'Subject' }],
receivedAt: clock
});
const { id: matchId } = handle.prepare('SELECT id FROM leaderboard_matches').get() as {
id: number;
};
const insert = handle.prepare('INSERT INTO match_chat VALUES(?,?,?,?)');
for (const [index, message] of messages.entries())
insert.run(
matchId,
`message-${index}`,
1,
JSON.stringify({
type: 'message',
context: { realTime: null, matchElapsedMs: null, round: null },
message: {
time: '1:01',
guid: message.guid ?? '123',
slot: message.guid ? 2 : 1,
player: message.guid ? 'Witness' : 'Subject',
message: message.message
}
})
);
return { matchId, entries: matchChatTimeline(matchId) };
}
it('rejects a witness-only selection, foreign archive IDs and another game without changing a case', async () => {
const { matchId, entries } = await archive([
{ message: 'Subject statement' },
{ guid: '456', message: 'Witness reply' }
]);
const { id } = await create();
const before = await detail(id);
for (const messageIds of [
[entries[1].messageId],
[`${matchId + 1}:message-0:1`],
[entries[0].messageId, `${matchId + 1}:message-1:1`]
])
await expect(
mutate({ op: 'attach-evidence', id, revision: 1, evidence: { matchId, messageIds } })
).rejects.toMatchObject({ status: expect.any(Number) });
expect(await detail(id)).toEqual(before);
const other = await create({ game: 't6' });
await expect(
mutate({
op: 'attach-evidence',
id: other.id,
revision: 1,
evidence: { matchId, messageIds: [entries[0].messageId] }
})
).rejects.toMatchObject({ status: 404 });
expect((await detail(other.id)).case).toMatchObject({ revision: 1, evidence: [] });
});
it('allows a new witness reply alongside an attached subject and deduplicates later selections', async () => {
const { matchId, entries } = await archive([
{ message: 'Subject statement' },
{ guid: '456', message: 'Witness reply' }
]);
const [subjectId, witnessId] = entries.map((entry) => entry.messageId!);
const { id } = await create();
await mutate({
op: 'attach-evidence',
id,
revision: 1,
evidence: { matchId, messageIds: [subjectId], contextBefore: 0, contextAfter: 0 }
});
const request = {
op: 'attach-evidence',
id,
revision: 2,
evidence: {
matchId,
messageIds: [witnessId, subjectId, witnessId],
contextBefore: 0,
contextAfter: 0
},
idempotencyKey: randomUUID()
};
await mutate(request);
await mutate(request);
const attached = await detail(id);
expect(attached.case.revision).toBe(3);
expect(attached.case.evidence).toHaveLength(2);
const snapshot = JSON.parse(attached.case.evidence[1].snapshot!);
expect(snapshot.selectedMessageIds).toEqual([witnessId]);
expect(snapshot.highlightedMessageIds).toEqual([subjectId]);
expect(snapshot.entries.map((entry: { messageId: string }) => entry.messageId)).toEqual([
subjectId,
witnessId
]);
expect(snapshot.entries[1].message.guid).toBe('456');
await mutate({ ...request, revision: 3, idempotencyKey: randomUUID() });
expect(await detail(id)).toEqual(attached);
});
it('validates selection and context bounds and rolls back opening a case with invalid evidence', async () => {
const { matchId, entries } = await archive([{ message: 'Subject statement' }]);
const messageId = entries[0].messageId!;
const invalid = [
{ messageIds: [] },
{ messageIds: Array.from({ length: 101 }, () => messageId) },
{ messageIds: [messageId], contextBefore: -1 },
{ messageIds: [messageId], contextAfter: 21 },
{ messageIds: [messageId], contextBefore: 1.5 },
{ messageIds: [messageId], contextAfter: '3' }
];
for (const evidence of invalid)
await expect(create({ evidence: { matchId, ...evidence } })).rejects.toMatchObject({
status: 400
});
expect(handle.prepare('SELECT COUNT(*) AS count FROM moderation_cases').get()).toEqual({
count: 0
});
expect(handle.prepare('SELECT COUNT(*) AS count FROM moderation_events').get()).toEqual({
count: 0
});
expect(handle.prepare('SELECT COUNT(*) AS count FROM moderation_requests').get()).toEqual({
count: 0
});
});
it('rolls back a legacy bulk attachment if the case reaches its evidence limit partway through', async () => {
const { matchId } = await archive([
{ message: 'Repeated statement' },
{ message: 'Repeated statement' },
{ message: 'Repeated statement' }
]);
const { id } = await create();
const insert = handle.prepare('INSERT INTO moderation_evidence VALUES(?,?,?,?,?,?,?,?)');
for (let index = 0; index < 99; index++)
insert.run(
randomUUID(),
id,
`Note ${index}`,
'Staff note',
null,
'moderator',
'moderator',
clock
);
const before = await detail(id);
const idempotencyKey = randomUUID();
await expect(
mutate({
op: 'attach-evidence',
id,
revision: 1,
evidence: { matchId, message: 'Repeated statement' },
idempotencyKey
})
).rejects.toMatchObject({ status: 409 });
expect(await detail(id)).toEqual(before);
expect(
handle
.prepare('SELECT COUNT(*) AS count FROM moderation_requests WHERE request_key=?')
.get(idempotencyKey)
).toEqual({ count: 0 });
});