Add related cases and duplicate review suggestions
This commit is contained in:
@@ -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'
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user