Preserve conversation excerpts and deduplicate case evidence
This commit is contained in:
@@ -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();
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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 });
|
||||
});
|
||||
Reference in New Issue
Block a user