Preserve conversation excerpts and deduplicate case evidence

This commit is contained in:
2026-10-17 19:35:52 -04:00
parent 83f1c91877
commit ef96a2c308
13 changed files with 1222 additions and 50 deletions
+152
View File
@@ -0,0 +1,152 @@
import { afterEach, expect, it, vi } from 'vitest';
import { flushSync, mount, tick, unmount } from 'svelte';
import { writable } from 'svelte/store';
import ChatPanel from '../../src/lib/ui/components/ChatPanel.svelte';
import type { ContextualChatEntry } from '../../src/lib/game-chat.js';
import type { UserRole } from '../../src/lib/auth.js';
const currentPage = writable({ data: { user: { role: 'moderator' as UserRole } } });
vi.mock('$app/stores', () => ({
page: {
subscribe: (run: Parameters<typeof currentPage.subscribe>[0]) => currentPage.subscribe(run)
}
}));
let component: ReturnType<typeof mount> | undefined;
afterEach(async () => {
if (component) await unmount(component);
component = undefined;
document.body.replaceChildren();
vi.unstubAllGlobals();
});
const history: ContextualChatEntry[] = [
['11', 'Subject', 'A concerning reply'],
['22', 'Other player', 'The question that explains it'],
['11', 'Subject', 'A later reply']
].map(([guid, player, message], index) => ({
type: 'message',
matchId: 17,
messageId: `17:message-${index}:1`,
message: { guid, player, message, time: `1:0${index}`, slot: index },
context: { realTime: null, matchElapsedMs: index * 1000, round: 2 }
}));
const button = (label: string) =>
[...document.querySelectorAll('button')].find((node) => node.textContent?.trim() === label)!;
const response = (value: unknown) => new Response(JSON.stringify(value));
function show(role: UserRole = 'moderator') {
currentPage.set({ data: { user: { role } } });
component = mount(ChatPanel, { target: document.body, props: { game: 't5', history } });
flushSync();
}
function select(label: string, value: string) {
const labelNode = [...document.querySelectorAll('dialog label')].find(
(node) => node.textContent?.replace('*', '').trim() === label
)!;
const node = document.querySelector<HTMLSelectElement>(`[aria-labelledby="${labelNode.id}"]`)!;
const originalQuery = node.querySelector.bind(node);
node.querySelector = ((selector: string) =>
selector === ':checked'
? (node.selectedOptions[0] ?? null)
: originalQuery(selector)) as typeof node.querySelector;
node.value = value;
node.dispatchEvent(new Event('change', { bubbles: true }));
flushSync();
}
const submit = () =>
document
.querySelector('dialog form')!
.dispatchEvent(new Event('submit', { bubbles: true, cancelable: true }));
it('selects messages across participants, previews their context, and attaches to an existing case', async () => {
const fetch = vi
.fn()
.mockResolvedValueOnce(
response({
cases: [{ id: 'case-1', title: 'Existing review', status: 'open', revision: 4 }],
total: 1
})
)
.mockResolvedValueOnce(response({ ok: true, id: 'case-1' }));
vi.stubGlobal('fetch', fetch);
show();
expect(button('Attach to case').disabled).toBe(true);
const checkboxes = document.querySelectorAll<HTMLInputElement>('input[type="checkbox"]');
checkboxes[0].click();
checkboxes[1].click();
flushSync();
expect(document.body.textContent).toContain('2 selected');
button('Attach to case').click();
await vi.waitFor(() =>
expect(document.querySelector('dialog')?.textContent).toContain('Existing review')
);
expect(fetch.mock.calls[0][0]).toContain('game=t5&guid=11');
expect(document.querySelector('dialog')?.textContent).toContain('GUID 22');
expect(document.querySelector('dialog')?.textContent).toContain('Message time unknown');
const excerpt = document.querySelector('dialog [aria-label="Preserved conversation excerpt"]')!;
expect(excerpt.querySelectorAll('li')).toHaveLength(3);
expect(excerpt.querySelector('.highlighted')?.textContent).toContain('A concerning reply');
expect(excerpt.textContent).toContain('Selected context');
select('Destination', 'case-1');
submit();
await vi.waitFor(() => expect(document.body.textContent).toContain('Selected evidence saved.'));
expect(JSON.parse(fetch.mock.calls[1][1].body)).toEqual({
op: 'attach-evidence',
id: 'case-1',
revision: 4,
evidence: {
matchId: 17,
messageIds: ['17:message-0:1', '17:message-1:1'],
contextBefore: 3,
contextAfter: 3
},
idempotencyKey: expect.any(String)
});
expect(document.querySelector('a[href="/moderation/cases/case-1"]')).not.toBeNull();
expect(
[...document.querySelectorAll<HTMLInputElement>('input[type="checkbox"]')].every(
(node) => !node.checked
)
).toBe(true);
});
it('opens a new case with all selected messages and retries an uncertain request unchanged', async () => {
const fetch = vi
.fn()
.mockResolvedValueOnce(response({ cases: [], total: 0 }))
.mockRejectedValueOnce(new Error('Connection lost'))
.mockResolvedValueOnce(response({ ok: true, id: 'new-case' }));
vi.stubGlobal('fetch', fetch);
show();
button('Select all').click();
flushSync();
button('Attach to case').click();
await vi.waitFor(() =>
expect(document.querySelector('dialog')?.textContent).toContain('No existing cases')
);
const title = document.querySelector<HTMLInputElement>('dialog input:not([type="number"])')!;
title.value = 'Conversation review';
title.dispatchEvent(new Event('input', { bubbles: true }));
flushSync();
submit();
await vi.waitFor(() => expect(button('Retry same request')).toBeDefined());
expect(document.querySelector('dialog fieldset')?.hasAttribute('disabled')).toBe(true);
button('Retry same request').click();
await vi.waitFor(() => expect(fetch).toHaveBeenCalledTimes(3));
expect(fetch.mock.calls[2][1].body).toBe(fetch.mock.calls[1][1].body);
expect(JSON.parse(fetch.mock.calls[1][1].body)).toMatchObject({
op: 'create-case',
game: 't5',
guid: '11',
title: 'Conversation review',
evidence: { messageIds: history.map((entry) => entry.messageId) }
});
await tick();
});
it('keeps evidence selection unavailable to read-only viewers', () => {
show('super_viewer');
expect(document.querySelector('input[type="checkbox"]')).toBeNull();
expect(button('Attach to case')).toBeUndefined();
});
+78
View File
@@ -453,3 +453,81 @@ it('keeps decision history writable when a case has reached its evidence limit',
expect((await detail(id)).case).toMatchObject({ status: 'open', revision: 3 });
expect((await detail(id)).case.evidence).toHaveLength(102);
});
it('preserves all participants and selected authors, distinguishes repeated messages, and deduplicates attachments', async () => {
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: 'Player' }],
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 (let i = 0; i < 22; i++) {
const subject = i > 0 && i < 20;
insert.run(
matchId,
subject ? 'repeat' : `context-${i}`,
subject ? i : 1,
JSON.stringify({
type: 'message',
context: { realTime: null, round: null, matchElapsedMs: null },
message: {
time: '1:01',
guid: subject ? '123' : '456',
player: subject ? 'Subject' : 'Witness',
slot: subject ? 1 : 2,
message: subject ? 'Repeated message' : `Context ${i}`
}
})
);
}
const entries = matchChatTimeline(matchId);
expect(new Set(entries.map((entry) => entry.messageId)).size).toBe(22);
const { id } = await create();
await mutate({
op: 'attach-evidence',
id,
revision: 1,
evidence: { matchId, messageIds: entries.slice(1, 20).map((entry) => entry.messageId) }
});
let data = await detail(id);
expect(data.case.evidence).toHaveLength(1);
const snapshot = JSON.parse(data.case.evidence[0].snapshot!);
expect(snapshot.entries).toHaveLength(22);
expect(snapshot.selectedMessageIds).toHaveLength(19);
expect(snapshot.highlightedMessageIds).toHaveLength(19);
expect(snapshot.entries[0].message.player).toBe('Witness');
await mutate({
op: 'attach-evidence',
id,
revision: 2,
evidence: { matchId, message: 'Repeated message' }
});
data = await detail(id);
expect(data.case.revision).toBe(2);
expect(data.case.evidence).toHaveLength(1);
await expect(
mutate({
op: 'attach-evidence',
id,
revision: 2,
evidence: { matchId, messageIds: ['forged'] }
})
).rejects.toThrow('identifiers');
const legacy = await create();
await mutate({
op: 'attach-evidence',
id: legacy.id,
revision: 1,
evidence: { matchId, message: 'Repeated message' }
});
expect((await detail(legacy.id)).case.evidence).toHaveLength(19);
handle.prepare('DELETE FROM leaderboard_matches').run();
expect(JSON.parse((await detail(id)).case.evidence[0].snapshot!).entries).toHaveLength(22);
});
+220
View File
@@ -0,0 +1,220 @@
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 });
});