221 lines
7.9 KiB
TypeScript
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 });
|
|
});
|