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'
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user