Add related cases and duplicate review suggestions

This commit is contained in:
2026-10-17 19:52:50 -04:00
parent 428aefdf24
commit 90b94911c5
15 changed files with 1289 additions and 1 deletions
@@ -0,0 +1,187 @@
import { afterEach, expect, it, vi } from 'vitest';
import { flushSync, mount, tick, unmount } from 'svelte';
import CaseRelationships from '../../src/lib/ui/components/moderation/CaseRelationships.svelte';
import ModerationDialog from '../../src/lib/ui/components/moderation/ModerationDialog.svelte';
import type { ModerationCaseRelationship } from '../../src/lib/moderation.js';
let component: ReturnType<typeof mount> | undefined;
afterEach(async () => {
if (component) await unmount(component);
component = undefined;
document.body.replaceChildren();
vi.unstubAllGlobals();
});
const candidate = {
id: 'case-2',
title: 'Earlier report',
status: 'open' as const,
game: 't5' as const,
guid: '123',
sharedMatch: true
};
const relationship: ModerationCaseRelationship = {
id: 'relationship-1',
relation: 'appeal_of',
direction: 'incoming',
case: candidate,
actor: 'Moderator One',
createdAt: '2026-10-17T10:00:00Z'
};
function show(permitted = true, relationships: ModerationCaseRelationship[] = []) {
const saved = vi.fn();
component = mount(CaseRelationships, {
target: document.body,
props: {
caseId: 'case-1',
revision: 6,
relationships,
candidates: [candidate],
permitted,
onsubmitted: saved
}
});
flushSync();
return saved;
}
const button = (label: string) =>
[...document.querySelectorAll('button')].find((node) => node.textContent?.trim() === label)!;
function field<T extends HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement>(
label: string
): T {
const node = [...document.querySelectorAll('dialog label')].find(
(node) => node.textContent?.replace('*', '').trim() === label
)!;
return document.querySelector(`[aria-labelledby="${node.id}"]`)! as T;
}
function enter(label: string, value: string) {
const node = field<HTMLInputElement | HTMLTextAreaElement>(label);
node.value = value;
node.dispatchEvent(new Event('input', { bubbles: true }));
flushSync();
}
function select(label: string, value: string) {
const node = field<HTMLSelectElement>(label);
const query = node.querySelector.bind(node);
node.querySelector = ((selector: string) =>
selector === ':checked'
? (node.selectedOptions[0] ?? null)
: query(selector)) as typeof node.querySelector;
node.value = value;
node.dispatchEvent(new Event('change', { bubbles: true }));
flushSync();
}
const response = (value: unknown, status = 200) => new Response(JSON.stringify(value), { status });
it.each(['candidate', 'url'])(
'links a %s with an explicit relationship and reason',
async (source) => {
const fetch = vi.fn().mockResolvedValue(response({ ok: true, id: 'case-1' }));
vi.stubGlobal('fetch', fetch);
const saved = show();
expect(document.body.textContent).toContain('Shared archived match');
button('Link case').click();
await tick();
if (source === 'candidate') {
select('Related case', 'case-2');
select('Relationship', 'duplicate_of');
} else enter('Case ID or URL', 'https://example.com/moderation/cases/case-2?view=history');
enter('Relationship reason', 'Both reports refer to the same exchange');
button('Link cases').click();
await vi.waitFor(() => expect(saved).toHaveBeenCalledOnce());
expect(JSON.parse(fetch.mock.calls[0][1].body)).toEqual({
op: 'link-case',
id: 'case-1',
revision: 6,
relatedCaseId: 'case-2',
relation: source === 'candidate' ? 'duplicate_of' : 'same_incident',
reason: 'Both reports refer to the same exchange',
idempotencyKey: expect.any(String)
});
}
);
it('displays inverse relationships and removes links only with a recorded reason', async () => {
const fetch = vi.fn().mockResolvedValue(response({ ok: true, id: 'case-1' }));
vi.stubGlobal('fetch', fetch);
const saved = show(true, [relationship]);
expect(document.body.textContent).toContain('Appealed by');
button('Remove link').click();
await tick();
expect(field<HTMLTextAreaElement>('Relationship reason').required).toBe(true);
enter('Relationship reason', 'Linked to the wrong incident');
button('Remove relationship').click();
await vi.waitFor(() => expect(saved).toHaveBeenCalledOnce());
expect(JSON.parse(fetch.mock.calls[0][1].body)).toMatchObject({
op: 'unlink-case',
id: 'case-1',
revision: 6,
relationshipId: 'relationship-1',
reason: 'Linked to the wrong incident'
});
});
it('lets read-only viewers inspect candidate hints and inverse relationships', () => {
show(false, [
relationship,
{ ...relationship, id: 'relationship-2', relation: 'duplicate_of' }
]);
expect(document.body.textContent).toContain('Appealed by');
expect(document.body.textContent).toContain('Has duplicate');
expect(document.querySelector('a[href="/moderation/cases/case-2"]')).not.toBeNull();
expect(button('Link case')).toBeUndefined();
expect(button('Remove link')).toBeUndefined();
});
it('shows existing cases in the generic case creation dialog', async () => {
const fetch = vi.fn().mockResolvedValue(response({ cases: [candidate], total: 31 }));
vi.stubGlobal('fetch', fetch);
component = mount(ModerationDialog, {
target: document.body,
props: {
open: true,
mode: 'case',
coverage: [],
admin: false,
seed: { game: 't5', guid: '123' },
onsubmitted: vi.fn()
}
});
flushSync();
await vi.waitFor(() => expect(document.body.textContent).toContain('31 existing cases'));
expect(fetch.mock.calls[0][0]).toBe('/api/moderation?view=cases&game=t5&guid=123');
expect(document.querySelector('a[href="/moderation/cases/case-2"]')).not.toBeNull();
expect(document.body.textContent).toContain('View all cases for this player');
});
it('allows case creation when duplicate lookup fails', async () => {
const fetch = vi
.fn()
.mockRejectedValueOnce(new Error('Lookup unavailable'))
.mockResolvedValue(response({ ok: true, id: 'new-case' }));
vi.stubGlobal('fetch', fetch);
const saved = vi.fn();
component = mount(ModerationDialog, {
target: document.body,
props: {
open: true,
mode: 'case',
coverage: [],
admin: false,
seed: { game: 't5', guid: '123' },
onsubmitted: saved
}
});
flushSync();
await vi.waitFor(() =>
expect(document.body.textContent).toContain('Existing cases could not be loaded')
);
enter('Case title', 'Distinct incident');
button('Save').click();
await vi.waitFor(() => expect(saved).toHaveBeenCalledOnce());
expect(JSON.parse(fetch.mock.calls[1][1].body)).toMatchObject({
op: 'create-case',
game: 't5',
guid: '123',
title: 'Distinct incident'
});
});
@@ -0,0 +1,315 @@
import { randomUUID } from 'node:crypto';
import 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';
beforeEach(async () => {
vi.resetModules();
vi.useFakeTimers({ toFake: ['Date'] });
vi.setSystemTime(new Date(clock));
fixture = await newFixture([]);
const { db } = await import('../../src/lib/server/db/index.js');
handle = db();
for (const role of ['admin', 'moderator', 'super_viewer', '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);
});
afterEach(async () => {
vi.useRealTimers();
await fixture.cleanup();
});
async function mutate(input: Record<string, unknown>, actor = 'moderator') {
const { mutateModeration } = await import('../../src/lib/server/moderation/actions.js');
return mutateModeration(actor, { idempotencyKey: randomUUID(), ...input });
}
async function detail(id: string, actor = 'moderator') {
const { moderationCaseDetail } = await import('../../src/lib/server/moderation/cases.js');
return moderationCaseDetail(actor, id);
}
const create = (overrides: Record<string, unknown> = {}) =>
mutate({
op: 'create-case',
game: 't5',
guid: '123',
title: 'Review incident',
...overrides
});
const link = (id: string, revision: number, relatedCaseId: string, relation = 'same_incident') => ({
op: 'link-case',
id,
revision,
relatedCaseId,
relation,
reason: 'Evidence connects these reviews.'
});
const unlink = (id: string, revision: number, relationshipId: string) => ({
op: 'unlink-case',
id,
revision,
relationshipId,
reason: 'The investigations concern different incidents.'
});
async function linkCurrent(id: string, related: string, relation: string) {
return mutate(link(id, (await detail(id)).case.revision, related, relation));
}
it('records symmetric incident links once, preserving both players and advancing both case revisions', async () => {
const a = await create();
const b = await create({ game: 't6', guid: '456', title: 'Connected player' });
const request = { ...link(a.id, 1, b.id), idempotencyKey: randomUUID() };
await mutate(request);
await mutate(request);
const first = await detail(a.id);
const second = await detail(b.id, 'super_viewer');
expect(first.case).toMatchObject({ revision: 2, game: 't5', guid: '123', status: 'open' });
expect(second.case).toMatchObject({ revision: 2, game: 't6', guid: '456', status: 'open' });
expect(first.relatedCases).toMatchObject([
{ relation: 'same_incident', case: { id: b.id, title: 'Connected player' } }
]);
expect(second.relatedCases).toMatchObject([
{ id: first.relatedCases![0].id, case: { id: a.id } }
]);
expect(new Set([first.relatedCases![0].direction, second.relatedCases![0].direction])).toEqual(
new Set(['incoming', 'outgoing'])
);
await mutate(link(b.id, 2, a.id));
expect(await detail(a.id)).toEqual(first);
expect(await detail(b.id)).toEqual(second);
expect(
handle.prepare('SELECT case_id,target_case_id FROM moderation_case_relationships').all()
).toEqual([{ case_id: [a.id, b.id].sort()[0], target_case_id: [a.id, b.id].sort()[1] }]);
expect(JSON.parse(first.events[0].detail)).toMatchObject({
relation: 'same_incident',
relatedCaseId: b.id,
reason: request.reason
});
await expect(
mutate({ op: 'case-note', id: b.id, revision: 1, text: 'Stale note' })
).rejects.toMatchObject({ status: 409 });
});
it('retains directed appeal and duplicate relationships while rejecting cycles within each relation', async () => {
const a = await create();
const b = await create();
const c = await create();
await linkCurrent(a.id, b.id, 'duplicate_of');
await linkCurrent(b.id, c.id, 'duplicate_of');
await expect(linkCurrent(c.id, a.id, 'duplicate_of')).rejects.toThrow('cycle');
await expect(linkCurrent(b.id, a.id, 'duplicate_of')).rejects.toThrow('cycle');
await linkCurrent(b.id, a.id, 'appeal_of');
await expect(linkCurrent(a.id, b.id, 'appeal_of')).rejects.toThrow('cycle');
expect((await detail(a.id)).relatedCases).toEqual(
expect.arrayContaining([
expect.objectContaining({
relation: 'duplicate_of',
direction: 'outgoing',
case: expect.objectContaining({ id: b.id })
}),
expect.objectContaining({
relation: 'appeal_of',
direction: 'incoming',
case: expect.objectContaining({ id: b.id })
})
])
);
const relationship = (await detail(b.id)).relatedCases!.find(
(entry) => entry.relation === 'duplicate_of' && entry.direction === 'outgoing'
)!;
await mutate(unlink(b.id, (await detail(b.id)).case.revision, relationship.id));
await linkCurrent(c.id, a.id, 'duplicate_of');
expect((await detail(c.id)).relatedCases).toMatchObject([
{ relation: 'duplicate_of', direction: 'outgoing', case: { id: a.id } }
]);
});
it('enforces case existence, staff access, revisions, self-link rejection and subject boundaries', async () => {
const a = await create();
const b = await create({ guid: '456' });
const c = await create({ game: 't6' });
for (const actor of ['viewer', 'super_viewer'])
await expect(mutate(link(a.id, 1, b.id), actor)).rejects.toMatchObject({ status: 403 });
await expect(mutate(link(a.id, 2, b.id))).rejects.toMatchObject({ status: 409 });
await expect(mutate(link(a.id, 1, 'missing'))).rejects.toMatchObject({ status: 404 });
await expect(mutate(link(a.id, 1, a.id))).rejects.toThrow('itself');
await expect(mutate(link(a.id, 1, b.id, 'unknown'))).rejects.toMatchObject({ status: 400 });
for (const relation of ['duplicate_of', 'appeal_of'])
for (const other of [b.id, c.id])
await expect(mutate(link(a.id, 1, other, relation))).rejects.toThrow(
'same game and player'
);
expect((await detail(a.id)).case.revision).toBe(1);
expect(handle.prepare('SELECT * FROM moderation_case_relationships').all()).toEqual([]);
await mutate({
op: 'resolve-case',
id: a.id,
revision: 1,
status: 'no_action',
reason: 'Review complete'
});
await mutate(link(a.id, 2, b.id));
expect((await detail(a.id)).case).toMatchObject({ status: 'no_action', revision: 3 });
await expect(detail(a.id, 'viewer')).rejects.toMatchObject({ status: 403 });
});
it('removes relationships from either endpoint without erasing history and permits a fresh link later', async () => {
const a = await create();
const b = await create();
const unrelated = await create();
await mutate(link(a.id, 1, b.id, 'appeal_of'));
const relationship = (await detail(a.id)).relatedCases![0];
await expect(mutate(unlink(unrelated.id, 1, relationship.id))).rejects.toMatchObject({
status: 404
});
await expect(mutate(unlink(b.id, 1, relationship.id))).rejects.toMatchObject({ status: 409 });
await mutate(unlink(b.id, 2, relationship.id));
for (const item of [a, b]) {
const data = await detail(item.id);
expect(data.case.revision).toBe(3);
expect(data.relatedCases).toEqual([]);
expect(data.events.map((entry) => entry.action)).toEqual([
'case.unlinked',
'case.linked',
'case.opened'
]);
}
expect(
handle
.prepare('SELECT removed_at FROM moderation_case_relationships WHERE id=?')
.get(relationship.id)
).toEqual({ removed_at: clock });
await mutate(unlink(a.id, 3, relationship.id));
expect((await detail(a.id)).case.revision).toBe(3);
await mutate(link(a.id, 3, b.id, 'appeal_of'));
expect((await detail(a.id)).relatedCases![0].id).not.toBe(relationship.id);
expect(
handle.prepare('SELECT COUNT(*) AS count FROM moderation_case_relationships').get()
).toEqual({ count: 2 });
});
it('rolls back both case revisions, relationship changes and receipts when either audit fails', async () => {
const a = await create();
const b = await create();
handle.exec(`CREATE TRIGGER fail_relationship_audit BEFORE INSERT ON moderation_events
WHEN NEW.action IN ('case.linked','case.unlinked') BEGIN SELECT RAISE(ABORT,'audit unavailable'); END`);
const request = { ...link(a.id, 1, b.id), idempotencyKey: randomUUID() };
await expect(mutate(request)).rejects.toThrow('audit unavailable');
expect((await detail(a.id)).case.revision).toBe(1);
expect((await detail(b.id)).case.revision).toBe(1);
expect(handle.prepare('SELECT * FROM moderation_case_relationships').all()).toEqual([]);
expect(
handle
.prepare('SELECT * FROM moderation_requests WHERE request_key=?')
.all(request.idempotencyKey)
).toEqual([]);
handle.exec('DROP TRIGGER fail_relationship_audit');
await mutate(request);
const relationship = (await detail(a.id)).relatedCases![0];
handle.exec(`CREATE TRIGGER fail_relationship_audit BEFORE INSERT ON moderation_events
WHEN NEW.action='case.unlinked' BEGIN SELECT RAISE(ABORT,'audit unavailable'); END`);
await expect(mutate(unlink(a.id, 2, relationship.id))).rejects.toThrow('audit unavailable');
expect((await detail(a.id)).case.revision).toBe(2);
expect((await detail(b.id)).case.revision).toBe(2);
expect(handle.prepare('SELECT removed_at FROM moderation_case_relationships').get()).toEqual({
removed_at: null
});
});
it('suggests recent same-player cases and treats valid shared archived matches only as review hints', async () => {
const a = await create();
const shared = await create();
const different = await create();
const malformed = await create();
const wrongSubject = await create();
await create({ guid: '456' });
await create({ game: 't6' });
const snapshot = (matchId: number, guid = '123') => ({
matchId,
match: { game: 't5', server: 'removed-server', started_at: clock },
entry: { type: 'message', message: { guid, message: 'Preserved evidence' } }
});
const add = (caseId: string, value: string) =>
handle
.prepare('INSERT INTO moderation_evidence VALUES(?,?,?,?,?,?,?,?)')
.run(
randomUUID(),
caseId,
'Preserved evidence',
'Archived chat',
value,
'moderator',
'moderator',
clock
);
add(a.id, JSON.stringify(snapshot(12)));
add(shared.id, JSON.stringify(snapshot(12)));
add(different.id, JSON.stringify(snapshot(13)));
add(malformed.id, '{broken JSON');
add(malformed.id, JSON.stringify({ matchId: 12 }));
add(wrongSubject.id, JSON.stringify(snapshot(12, '456')));
const candidates = (await detail(a.id)).candidateCases!;
expect(candidates).toHaveLength(4);
expect(candidates.find((item) => item.id === shared.id)?.sharedMatch).toBe(true);
expect(
candidates.filter((item) => item.id !== shared.id).every((item) => !item.sharedMatch)
).toBe(true);
expect((await detail(a.id)).relatedCases).toEqual([]);
for (let index = 0; index < 30; index++) {
vi.setSystemTime(new Date(Date.parse(clock) + (index + 1) * 60_000));
await create({ title: `Later case ${index}` });
}
const recent = (await detail(a.id)).candidateCases!;
expect(recent).toHaveLength(25);
expect(recent[0].title).toBe('Later case 29');
expect(recent[24].title).toBe('Later case 5');
});
it('upgrades relationship storage with foreign keys, symmetric identity and active-only uniqueness', async () => {
const { migrations } = await import('../../src/lib/server/db/migrations.js');
const { runMigrations } = await import('../../src/lib/server/db/migration-runner.js');
const legacy = new Database(':memory:');
legacy.pragma('foreign_keys = ON');
try {
runMigrations(legacy, migrations.slice(0, 32));
const insertCase =
legacy.prepare(`INSERT INTO moderation_cases(id,game,guid,title,status,actor_id,actor,created_at,updated_at)
VALUES(?,'t5','123','Existing case','open','former','Former staff',?,?)`);
insertCase.run('a', clock, clock);
insertCase.run('b', clock, clock);
const before = legacy.prepare('SELECT * FROM moderation_cases ORDER BY id').all();
runMigrations(legacy, migrations);
runMigrations(legacy, migrations);
expect(legacy.prepare('SELECT * FROM moderation_cases ORDER BY id').all()).toEqual(before);
const insert = legacy.prepare(
'INSERT INTO moderation_case_relationships VALUES(?,?,?,?,?,?,?,NULL)'
);
insert.run('first', 'a', 'b', 'same_incident', 'former', 'Former staff', clock);
expect(() =>
insert.run('second', 'a', 'b', 'same_incident', 'former', 'Former staff', clock)
).toThrow('UNIQUE');
expect(() =>
insert.run('reverse', 'b', 'a', 'same_incident', 'former', 'Former staff', clock)
).toThrow('CHECK');
expect(() =>
insert.run('self', 'a', 'a', 'appeal_of', 'former', 'Former staff', clock)
).toThrow('CHECK');
expect(() =>
insert.run('missing', 'a', 'missing', 'appeal_of', 'former', 'Former staff', clock)
).toThrow('FOREIGN KEY');
legacy
.prepare('UPDATE moderation_case_relationships SET removed_at=? WHERE id=?')
.run(clock, 'first');
insert.run('second', 'a', 'b', 'same_incident', 'former', 'Former staff', clock);
expect(legacy.pragma('foreign_key_check')).toEqual([]);
} finally {
legacy.close();
}
});