Add case follow-up queues and personal update notifications

This commit is contained in:
2026-10-17 20:00:13 -04:00
parent 90b94911c5
commit b678de2f6f
19 changed files with 1408 additions and 16 deletions
+226
View File
@@ -0,0 +1,226 @@
import { afterEach, expect, it, vi } from 'vitest';
import { flushSync, mount, tick, unmount } from 'svelte';
import { fromStore, writable } from 'svelte/store';
import CaseFollowUp from '../../src/lib/ui/components/moderation/CaseFollowUp.svelte';
import CaseUpdates from '../../src/lib/ui/components/moderation/CaseUpdates.svelte';
import type {
ModerationCase,
ModerationCaseAction,
ModerationCaseNotification
} 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 item: ModerationCase = {
id: 'case-1',
game: 't5',
guid: '123',
title: 'Chat review',
status: 'resolved',
priority: 'normal',
assignee: null,
assigneeId: null,
actor: 'Moderator One',
createdAt: '2026-10-16T10:00:00Z',
updatedAt: '2026-10-16T10:00:00Z',
revision: 6,
evidence: []
};
const mute: ModerationCaseAction = {
id: 'mute-1',
type: 'restriction',
kind: 'mute',
reason: 'Earlier chat abuse',
actor: 'Moderator One',
createdAt: '2026-10-16T10:00:00Z',
state: 'active',
expiresAt: '2026-10-17T10:00:00Z'
};
function show(initial = item, permitted = true) {
const saved = vi.fn();
component = mount(CaseFollowUp, {
target: document.body,
props: {
item: initial,
actions: [mute, { ...mute, id: 'permanent', expiresAt: null }],
checkedAt: '2026-10-17T12:00:00Z',
permitted,
onsubmitted: saved
}
});
flushSync();
return saved;
}
const button = (label: string, dialog = false) =>
[...document.querySelectorAll(dialog ? 'dialog button' : 'button')].find(
(node) => node.textContent?.trim() === label
)! as HTMLButtonElement;
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('schedules a closed case using a browser-local date converted to canonical ISO', async () => {
const fetch = vi.fn().mockResolvedValue(response({ ok: true, id: 'case-1' }));
vi.stubGlobal('fetch', fetch);
const saved = show();
button('Schedule review').click();
await tick();
enter('Review date (browser local time)', '2026-10-19T14:30');
enter('Follow-up reason', 'Check for additional reports');
button('Save follow-up').click();
await vi.waitFor(() => expect(saved).toHaveBeenCalledOnce());
expect(JSON.parse(fetch.mock.calls[0][1].body)).toEqual({
op: 'schedule-case-review',
id: 'case-1',
revision: 6,
reviewAt: new Date('2026-10-19T14:30').toISOString(),
reason: 'Check for additional reports',
idempotencyKey: expect.any(String)
});
});
it('uses the linked finite mute ID to schedule an exact expiration review', async () => {
const fetch = vi.fn().mockResolvedValue(response({ ok: true, id: 'case-1' }));
vi.stubGlobal('fetch', fetch);
const saved = show();
button('Schedule review').click();
await tick();
expect(
[...field<HTMLSelectElement>('Review timing').options].map((option) => option.value)
).toEqual(['custom', 'mute-1']);
select('Review timing', 'mute-1');
enter('Follow-up reason', 'Review after mute expires');
button('Save follow-up').click();
await vi.waitFor(() => expect(saved).toHaveBeenCalledOnce());
const payload = JSON.parse(fetch.mock.calls[0][1].body);
expect(payload).toMatchObject({
op: 'schedule-case-review',
restrictionId: 'mute-1',
revision: 6
});
expect(payload).not.toHaveProperty('reviewAt');
});
it('shows overdue reviews and clears the date with a recorded result', async () => {
const fetch = vi.fn().mockResolvedValue(response({ ok: true, id: 'case-1' }));
vi.stubGlobal('fetch', fetch);
const saved = show({
...item,
reviewAt: '2026-10-17T10:00:00Z',
reviewReason: 'Check once the mute ends',
reviewSanctionId: 'mute-1'
});
expect(document.body.textContent).toContain('Overdue review');
expect(document.body.textContent).toContain('Review after linked mute expires.');
button('Complete or clear review').click();
await tick();
enter('Review result or clearing reason', 'Review complete; no further reports');
button('Complete or clear review', true).click();
await vi.waitFor(() => expect(saved).toHaveBeenCalledOnce());
expect(JSON.parse(fetch.mock.calls[0][1].body)).toMatchObject({
op: 'schedule-case-review',
id: 'case-1',
revision: 6,
reviewAt: null,
reason: 'Review complete; no further reports'
});
});
it('shows overdue follow-up details without edit controls for read-only viewers', () => {
show({ ...item, reviewAt: '2026-10-17T10:00:00Z', reviewReason: 'Review reports' }, false);
expect(document.body.textContent).toContain('Overdue review');
expect(document.body.textContent).toContain('Review reports');
expect(button('Reschedule review')).toBeUndefined();
expect(button('Complete or clear review')).toBeUndefined();
});
const notification = (id: number): ModerationCaseNotification => ({
id,
caseId: 'case-1',
caseTitle: item.title,
kind: id === 3 ? 'assignment' : 'evidence',
createdAt: '2026-10-17T10:00:00Z',
readAt: null
});
it('acknowledges only loaded updates and preserves the read-through ID on an uncertain retry', async () => {
const fetch = vi
.fn()
.mockRejectedValueOnce(new Error('Connection lost'))
.mockResolvedValue(response({ ok: true, id: 'case-1' }));
vi.stubGlobal('fetch', fetch);
const values = writable([notification(3), notification(9)]);
const state = fromStore(values);
const saved = vi.fn();
component = mount(CaseUpdates, {
target: document.body,
props: {
caseId: 'case-1',
get notifications() {
return state.current;
},
unread: 2,
permitted: true,
onsubmitted: saved
}
});
flushSync();
expect(document.body.textContent).toContain('Assignment changed');
expect(document.body.textContent).toContain('Evidence or note added');
button('Mark updates as seen').click();
await vi.waitFor(() => expect(button('Retry same request')).toBeDefined());
values.set([notification(12), notification(9), notification(3)]);
flushSync();
button('Retry same request').click();
await vi.waitFor(() => expect(saved).toHaveBeenCalledOnce());
expect(fetch.mock.calls[1][1].body).toBe(fetch.mock.calls[0][1].body);
expect(JSON.parse(fetch.mock.calls[0][1].body)).toEqual({
op: 'read-case-updates',
id: 'case-1',
throughId: 9,
idempotencyKey: expect.any(String)
});
});
it('does not offer notification acknowledgement to read-only viewers', () => {
component = mount(CaseUpdates, {
target: document.body,
props: {
caseId: 'case-1',
notifications: [notification(3)],
unread: 1,
permitted: false,
onsubmitted: vi.fn()
}
});
flushSync();
expect(document.body.textContent).toContain('1 unread');
expect(button('Mark updates as seen')).toBeUndefined();
});
+48 -3
View File
@@ -324,13 +324,21 @@ it('opens a newly created case page after preserving selected evidence', async (
it('links case records to workspaces and preserves case filters while paging', () => {
const data = overview('cases');
data.query = { ...data.query, caseStatus: 'active', priority: 'high', assignedTo: 'me' };
data.query = {
...data.query,
caseStatus: 'active',
priority: 'high',
assignedTo: 'me',
review: 'due'
};
data.cases = [
{
id: 'case-1',
game: 't5',
guid,
title: 'Chat investigation',
reviewAt: '2026-10-14T12:00:00Z',
unreadUpdates: 2,
assessment: {
revision: 3,
summary: 'The threat remains ambiguous.',
@@ -357,7 +365,7 @@ it('links case records to workspaces and preserves case filters while paging', (
}
];
data.total = 40;
show(data, 'moderator', '&caseStatus=active&priority=high&assignedTo=me');
show(data, 'moderator', '&caseStatus=active&priority=high&assignedTo=me&review=due');
expect(document.querySelector('a[href="/moderation/cases/case-1"]')?.textContent).toBe(
'Chat investigation'
);
@@ -374,9 +382,46 @@ it('links case records to workspaces and preserves case filters while paging', (
expect(destination.searchParams.get('caseStatus')).toBe('active');
expect(destination.searchParams.get('priority')).toBe('high');
expect(destination.searchParams.get('assignedTo')).toBe('me');
expect(destination.searchParams.get('review')).toBe('due');
expect(document.querySelector('#moderation-results')?.textContent).toContain('Overdue review');
expect(document.querySelector('#moderation-results')?.textContent).toContain(
'2 unread updates'
);
expect(destination.searchParams.get('offset')).toBe('25');
button('Reset').click();
const reset = new URL(navigation.goto.mock.calls.at(-1)![0], 'http://localhost');
for (const filter of ['caseStatus', 'priority', 'assignedTo', 'offset'])
for (const filter of ['caseStatus', 'priority', 'assignedTo', 'review', 'offset'])
expect(reset.searchParams.has(filter)).toBe(false);
});
it('shows personal evidence and assignment notifications with links into the actionable queue', () => {
const data = overview('cases');
data.unreadCount = 2;
data.notifications = [
{
id: 1,
caseId: 'follow-up',
caseTitle: 'Review context',
kind: 'evidence',
createdAt: data.checkedAt,
readAt: null
},
{
id: 2,
caseId: 'appeal',
caseTitle: 'Review appeal',
kind: 'assignment',
createdAt: data.checkedAt,
readAt: null
}
];
show(data);
const notifications = document.querySelector('[aria-label="Your case notifications"]')!;
expect(notifications.textContent).toContain('Evidence or note added');
expect(notifications.textContent).toContain('Assignment changed');
expect(notifications.querySelector('a[href="/moderation/cases/follow-up"]')).not.toBeNull();
expect(
document.querySelector('a[href="/moderation?view=cases&review=updates"]')
).not.toBeNull();
expect(document.querySelector('a[href="/moderation?view=cases&review=due"]')).not.toBeNull();
});
+1 -1
View File
@@ -255,7 +255,7 @@ it('adds assessment storage to an existing database without altering case record
const before = legacy.prepare('SELECT * FROM moderation_cases').all();
runMigrations(legacy, migrations);
runMigrations(legacy, migrations);
expect(legacy.prepare('SELECT * FROM moderation_cases').all()).toEqual(before);
expect(legacy.prepare('SELECT * FROM moderation_cases').all()).toMatchObject(before);
const insert = legacy.prepare(
'INSERT INTO moderation_case_assessments VALUES(?,?,?,?,?,?)'
);
@@ -287,7 +287,9 @@ it('upgrades relationship storage with foreign keys, symmetric identity and acti
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);
expect(legacy.prepare('SELECT * FROM moderation_cases ORDER BY id').all()).toMatchObject(
before
);
const insert = legacy.prepare(
'INSERT INTO moderation_case_relationships VALUES(?,?,?,?,?,?,?,NULL)'
);
+395
View File
@@ -0,0 +1,395 @@
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';
const future = '2026-10-17T13: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 [id, role] of [
['creator', 'moderator'],
['owner', 'moderator'],
['next', 'moderator'],
['admin', 'admin'],
['viewer', 'viewer'],
['reader', 'super_viewer']
])
handle
.prepare(
`INSERT INTO auth_users(id,username,display_name,password_hash,role,created_at,updated_at)
VALUES(?,?,?,'unused',?,0,0)`
)
.run(id, id, id, role);
});
afterEach(async () => {
vi.useRealTimers();
await fixture.cleanup();
});
async function mutate(input: Record<string, unknown>, actor = 'creator') {
const { mutateModeration } = await import('../../src/lib/server/moderation/actions.js');
return mutateModeration(actor, { idempotencyKey: randomUUID(), ...input });
}
async function detail(id: string, actor = 'creator') {
const { moderationCaseDetail } = await import('../../src/lib/server/moderation/cases.js');
return moderationCaseDetail(actor, id);
}
async function overview(actor = 'creator', params = '') {
const { moderationOverview, moderationQuery } =
await import('../../src/lib/server/moderation/overview.js');
return moderationOverview(actor, moderationQuery(new URLSearchParams(`view=cases&${params}`)));
}
const create = (overrides: Record<string, unknown> = {}) =>
mutate({
op: 'create-case',
game: 't5',
guid: '123',
title: 'Review incident',
...overrides
});
const schedule = (id: string, revision: number, reviewAt: unknown = future) => ({
op: 'schedule-case-review',
id,
revision,
reviewAt,
reason: 'Review the case after the restriction ends.'
});
const restriction = (overrides: Record<string, unknown> = {}) =>
mutate(
{
op: 'create-sanction',
game: 't5',
guid: '123',
kind: 'mute',
scope: 'game',
durationMinutes: 60,
reason: 'Earlier moderation action',
...overrides
},
'admin'
);
it('schedules canonical dates, preserves them through closure and reopening, and clears them with an audit', async () => {
const { id } = await create();
for (const value of [
'',
'2026-10-17',
'2026-02-30T12:00:00.000Z',
'2026-10-17T12:00:00Z',
'2026-10-17T08:00:00.000-04:00',
12,
'+010000-01-01T00:00:00.000Z'
])
await expect(mutate(schedule(id, 1, value))).rejects.toMatchObject({ status: 400 });
await expect(mutate({ ...schedule(id, 1), restrictionId: 'another' })).rejects.toMatchObject({
status: 400
});
for (const actor of ['viewer', 'reader'])
await expect(mutate(schedule(id, 1), actor)).rejects.toMatchObject({ status: 403 });
await mutate(schedule(id, 1));
await expect(mutate(schedule(id, 1))).rejects.toMatchObject({ status: 409 });
const first = await detail(id);
expect(first.case).toMatchObject({
revision: 2,
reviewAt: future,
reviewSanctionId: null,
reviewReason: schedule(id, 1).reason
});
await mutate(schedule(id, 2));
expect(await detail(id)).toEqual(first);
await mutate({
op: 'resolve-case',
id,
revision: 2,
status: 'no_action',
reason: 'Review complete for now'
});
expect((await detail(id)).case.reviewAt).toBe(future);
await mutate({ op: 'reopen-case', id, revision: 3, reason: 'New context arrived' });
expect((await detail(id)).case.reviewAt).toBe(future);
await mutate(schedule(id, 4, null));
const cleared = await detail(id);
expect(cleared.case).toMatchObject({ revision: 5, reviewAt: null, reviewSanctionId: null });
expect(JSON.parse(cleared.events[0].detail)).toEqual({
reviewAt: null,
reason: schedule(id, 1).reason,
restrictionId: null
});
});
it('derives dates only from linked finite mutes and supports already expired restrictions without reissuing them', async () => {
const { id } = await create();
const mute = await restriction({ caseId: id, caseRevision: 1 });
const unlinked = await restriction();
const permanent = await restriction({ caseId: id, caseRevision: 2, durationMinutes: null });
const ban = await restriction({ caseId: id, caseRevision: 3, kind: 'ban' });
const other = await restriction({ guid: '456' });
const input = (restrictionId: string) => ({
op: 'schedule-case-review',
id,
revision: 4,
restrictionId,
reason: 'Check after mute expires'
});
await expect(mutate(input(unlinked.id))).rejects.toThrow('Link the mute');
for (const target of [permanent.id, ban.id])
await expect(mutate(input(target))).rejects.toThrow('recorded expiry');
await expect(mutate(input(other.id))).rejects.toMatchObject({ status: 404 });
const before = handle.prepare('SELECT * FROM moderation_sanctions ORDER BY id').all();
const policy = handle.prepare('SELECT * FROM moderation_policy').all();
vi.setSystemTime(new Date('2026-10-17T14:00:00.000Z'));
await mutate(input(mute.id));
expect((await detail(id)).case).toMatchObject({
reviewAt: future,
reviewSanctionId: mute.id,
revision: 5
});
expect(handle.prepare('SELECT * FROM moderation_sanctions ORDER BY id').all()).toEqual(before);
expect(handle.prepare('SELECT * FROM moderation_policy').all()).toEqual(policy);
});
it('prioritizes overdue closed cases and recalculates due and scheduled queues using the current clock', async () => {
const closed = await create({ title: 'Closed follow-up' });
const active = await create({ title: 'Urgent active case', priority: 'urgent' });
const upcoming = await create({ title: 'Upcoming review' });
await mutate({
op: 'resolve-case',
id: closed.id,
revision: 1,
status: 'no_action',
reason: 'Initial review complete'
});
await mutate(schedule(closed.id, 2, clock));
await mutate(schedule(upcoming.id, 1, future));
expect((await overview()).cases.map((item) => item.id)).toEqual([
closed.id,
active.id,
upcoming.id
]);
expect((await overview('creator', 'review=due')).cases.map((item) => item.id)).toEqual([
closed.id
]);
expect((await overview('creator', 'review=scheduled')).total).toBe(2);
expect((await overview('creator', 'review=invalid')).query.review).toBe('');
vi.setSystemTime(new Date(future));
expect((await overview('creator', 'review=due')).cases.map((item) => item.id)).toEqual([
closed.id,
upcoming.id
]);
expect((await overview()).cases[0].status).toBe('no_action');
await mutate(schedule(closed.id, 3, null));
expect((await overview('creator', 'review=due')).cases.map((item) => item.id)).toEqual([
upcoming.id
]);
});
it('notifies distinct active assignees and the creator of assignment changes, excluding the acting staff member', async () => {
const request = {
op: 'create-case',
game: 't5',
guid: '123',
title: 'Assigned review',
assigneeId: 'owner',
idempotencyKey: randomUUID()
};
const { id } = await mutate(request);
await mutate(request);
expect((await detail(id, 'owner')).notifications).toMatchObject([{ kind: 'assignment' }]);
expect((await detail(id, 'creator')).notifications).toEqual([]);
await mutate({ op: 'update-case', id, revision: 1, assigneeId: 'next' }, 'admin');
const recipients = handle
.prepare(
'SELECT user_id,COUNT(*) AS count FROM moderation_case_notifications GROUP BY user_id ORDER BY user_id'
)
.all();
expect(recipients).toEqual([
{ user_id: 'creator', count: 1 },
{ user_id: 'next', count: 1 },
{ user_id: 'owner', count: 2 }
]);
expect((await overview('admin')).unreadCount).toBe(0);
handle.prepare("UPDATE auth_users SET disabled=1 WHERE id='creator'").run();
handle.prepare("UPDATE auth_users SET role='viewer' WHERE id='next'").run();
await mutate({ op: 'update-case', id, revision: 2, assigneeId: 'owner' }, 'admin');
expect(
handle.prepare('SELECT COUNT(*) AS count FROM moderation_case_notifications').get()
).toEqual({ count: 5 });
await expect(
mutate({ op: 'update-case', id, revision: 3, assigneeId: 'owner' }, 'admin')
).rejects.toThrow('Change the case');
});
it('notifies once per fresh evidence batch, with no notifications for duplicate attachments or retries', async () => {
const { id } = await create({ assigneeId: 'owner' });
const matchId = Number(
handle
.prepare(
`INSERT INTO leaderboard_matches(server,game,map,map_key,map_name,started_at,last_received_at,last_event,last_fingerprint)
VALUES('removed-server','t5','map','map','Map',?,?,'game_start','start')`
)
.run(clock, clock).lastInsertRowid
);
for (let occurrence = 1; occurrence <= 19; occurrence++)
handle.prepare('INSERT INTO match_chat VALUES(?,?,?,?)').run(
matchId,
'repeat',
occurrence,
JSON.stringify({
type: 'message',
message: {
time: '1:00',
guid: '123',
player: 'Player',
message: 'Repeated message'
},
context: { realTime: null, round: null, matchElapsedMs: null }
})
);
const request = {
op: 'attach-evidence',
id,
revision: 1,
evidence: { matchId, message: 'Repeated message' },
idempotencyKey: randomUUID()
};
await mutate(request, 'admin');
await mutate(request, 'admin');
await mutate({ ...request, revision: 2, idempotencyKey: randomUUID() }, 'admin');
expect((await detail(id)).case.evidence).toHaveLength(19);
expect(
handle
.prepare(
"SELECT user_id,kind FROM moderation_case_notifications WHERE kind='evidence' ORDER BY user_id"
)
.all()
).toEqual([
{ user_id: 'creator', kind: 'evidence' },
{ user_id: 'owner', kind: 'evidence' }
]);
await mutate({ op: 'case-note', id, revision: 2, text: 'More context' }, 'owner');
expect((await detail(id, 'owner')).case.unreadUpdates).toBe(2);
expect((await detail(id, 'creator')).case.unreadUpdates).toBe(2);
expect((await overview('owner', 'review=updates')).cases.map((item) => item.id)).toEqual([id]);
expect((await overview('admin', 'review=updates')).total).toBe(0);
});
it('marks only the reader’s seen case notifications and preserves newer arrivals and other cases', async () => {
const { id } = await create({ assigneeId: 'owner' });
const other = await create({ assigneeId: 'owner' });
await mutate({ op: 'case-note', id, text: 'First new evidence' }, 'admin');
const seen = await detail(id, 'owner');
const throughId = Math.max(...seen.notifications!.map((entry) => entry.id));
await mutate({ op: 'case-note', id, text: 'Later evidence' }, 'admin');
const revision = (await detail(id)).case.revision;
const read = { op: 'read-case-updates', id, throughId, idempotencyKey: randomUUID() };
await mutate(read, 'owner');
await mutate(read, 'owner');
const data = await detail(id, 'owner');
expect(data.case).toMatchObject({ revision, unreadUpdates: 1 });
expect(data.notifications!.filter((entry) => !entry.readAt)).toHaveLength(1);
expect(data.notifications![0].id).toBeGreaterThan(throughId);
expect((await detail(id, 'creator')).case.unreadUpdates).toBe(2);
expect((await detail(other.id, 'owner')).case.unreadUpdates).toBe(1);
for (const invalid of [-1, 1.5, Number.MAX_SAFE_INTEGER + 1, '2'])
await expect(
mutate({ ...read, throughId: invalid, idempotencyKey: randomUUID() }, 'owner')
).rejects.toMatchObject({ status: 400 });
await expect(mutate({ ...read, idempotencyKey: randomUUID() }, 'reader')).rejects.toMatchObject(
{ status: 403 }
);
});
it('returns bounded notification lists alongside complete personal unread counts', async () => {
const { id } = await create();
for (let index = 0; index < 30; index++)
handle
.prepare(
"INSERT INTO moderation_case_notifications(user_id,case_id,kind,created_at) VALUES('owner',?,'evidence',?)"
)
.run(id, clock);
const data = await detail(id, 'owner');
const queue = await overview('owner');
expect(data.case.unreadUpdates).toBe(30);
expect(data.notifications).toHaveLength(25);
expect(queue.unreadCount).toBe(30);
expect(queue.notifications).toHaveLength(25);
expect(queue.notifications![0].id).toBeGreaterThan(queue.notifications![24].id);
expect((await detail(id)).notifications).toEqual([]);
expect((await overview('creator')).notifications).toEqual([]);
});
it('rolls back schedules and evidence if their audit or notification write fails', async () => {
const { id } = await create({ assigneeId: 'owner' });
const before = await detail(id);
handle.exec(`CREATE TRIGGER fail_review_audit BEFORE INSERT ON moderation_events
WHEN NEW.action='case.review_scheduled' BEGIN SELECT RAISE(ABORT,'audit unavailable'); END`);
await expect(mutate(schedule(id, 1))).rejects.toThrow('audit unavailable');
expect(await detail(id)).toEqual(before);
handle.exec('DROP TRIGGER fail_review_audit');
handle.exec(`CREATE TRIGGER fail_notification BEFORE INSERT ON moderation_case_notifications
BEGIN SELECT RAISE(ABORT,'notification unavailable'); END`);
const request = { op: 'case-note', id, text: 'New evidence', idempotencyKey: randomUUID() };
await expect(mutate(request, 'admin')).rejects.toThrow('notification unavailable');
expect(await detail(id)).toEqual(before);
expect(
handle
.prepare('SELECT * FROM moderation_requests WHERE request_key=?')
.all(request.idempotencyKey)
).toEqual([]);
});
it('upgrades existing cases with unscheduled defaults and durable personal notification constraints', 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, 33));
legacy
.prepare(
`INSERT INTO moderation_cases(id,game,guid,title,status,actor_id,actor,created_at,updated_at)
VALUES('case','t5','123','Existing case','open','former','Former staff',?,?)`
)
.run(clock, clock);
legacy
.prepare(
`INSERT INTO auth_users(id,username,display_name,password_hash,role,created_at,updated_at)
VALUES('owner','owner','Owner','unused','moderator',0,0)`
)
.run();
runMigrations(legacy, migrations);
runMigrations(legacy, migrations);
expect(
legacy
.prepare(
'SELECT id,revision,review_at,review_reason,review_sanction_id FROM moderation_cases'
)
.get()
).toEqual({
id: 'case',
revision: 1,
review_at: null,
review_reason: '',
review_sanction_id: null
});
const insert = legacy.prepare(
'INSERT INTO moderation_case_notifications(user_id,case_id,kind,created_at) VALUES(?,?,?,?)'
);
insert.run('owner', 'case', 'evidence', clock);
expect(() => insert.run('missing', 'case', 'evidence', clock)).toThrow('FOREIGN KEY');
expect(() => insert.run('owner', 'missing', 'evidence', clock)).toThrow('FOREIGN KEY');
expect(() => insert.run('owner', 'case', 'unknown', clock)).toThrow('CHECK');
expect(legacy.pragma('foreign_key_check')).toEqual([]);
} finally {
legacy.close();
}
});