diff --git a/tests/client/moderation-assessment.test.ts b/tests/client/moderation-assessment.test.ts
new file mode 100644
index 0000000..7e2c86f
--- /dev/null
+++ b/tests/client/moderation-assessment.test.ts
@@ -0,0 +1,214 @@
+import { afterEach, expect, it, vi } from 'vitest';
+import { flushSync, mount, tick, unmount } from 'svelte';
+import { fromStore, writable } from 'svelte/store';
+import CaseAssessment from '../../src/lib/ui/components/moderation/CaseAssessment.svelte';
+import type { ModerationCaseAssessment } from '../../src/lib/moderation.js';
+
+let component: ReturnType | undefined;
+afterEach(async () => {
+ if (component) await unmount(component);
+ component = undefined;
+ document.body.replaceChildren();
+ vi.unstubAllGlobals();
+});
+const current: ModerationCaseAssessment = {
+ revision: 4,
+ summary: 'Slur documented; threatening intent unclear.',
+ rules: ['Rule 2: Respect other players'],
+ findings: [
+ {
+ category: 'hate_speech',
+ finding: 'substantiated',
+ detail: 'The preserved message contains a slur.'
+ },
+ {
+ category: 'threats',
+ finding: 'inconclusive',
+ detail: 'The reply could refer to gameplay.'
+ }
+ ],
+ actor: 'Moderator One',
+ createdAt: '2026-10-17T10:00:00Z'
+};
+function show(
+ assessment: ModerationCaseAssessment | null = null,
+ permitted = true,
+ history: ModerationCaseAssessment[] = []
+) {
+ const props = writable({ revision: 4, assessment });
+ const state = fromStore(props);
+ const saved = vi.fn();
+ component = mount(CaseAssessment, {
+ target: document.body,
+ props: {
+ caseId: 'case-1',
+ get revision() {
+ return state.current.revision;
+ },
+ get assessment() {
+ return state.current.assessment;
+ },
+ history,
+ permitted,
+ onsubmitted: saved
+ }
+ });
+ flushSync();
+ return {
+ saved,
+ update(revision: number, next = assessment) {
+ props.set({ revision, assessment: next });
+ flushSync();
+ }
+ };
+}
+const button = (label: string) =>
+ [...document.querySelectorAll('button')].find((node) => node.textContent?.trim() === label)!;
+function field(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(label);
+ node.value = value;
+ node.dispatchEvent(new Event('input', { bubbles: true }));
+ flushSync();
+}
+function select(label: string, value: string) {
+ const node = field(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 });
+function complete() {
+ enter('Current summary', 'Preserved evidence supports a safeguarding review.');
+ enter('Applicable rules', ' Rule 4: Safeguarding\n\nRule 2: Respect ');
+ select('Concern 1', 'safeguarding');
+ select('Finding 1', 'inconclusive');
+ enter('Explanation 1', 'The surrounding messages establish age but intent remains unclear.');
+}
+
+it('creates structured findings and separate rule references with one concern per category', async () => {
+ const fetch = vi.fn().mockResolvedValue(response({ ok: true, id: 'case-1' }));
+ vi.stubGlobal('fetch', fetch);
+ const { saved } = show();
+ expect(document.body.textContent).toContain('Not assessed');
+ button('Assess case').click();
+ await tick();
+ complete();
+ button('Add finding').click();
+ flushSync();
+ expect(
+ [...field('Concern 2').options].find(
+ (option) => option.value === 'safeguarding'
+ )?.disabled
+ ).toBe(true);
+ select('Concern 2', 'threats');
+ select('Finding 2', 'unsubstantiated');
+ enter('Explanation 2', 'No threat is present in the preserved exchange.');
+ button('Save assessment').click();
+ await vi.waitFor(() => expect(saved).toHaveBeenCalledOnce());
+ expect(JSON.parse(fetch.mock.calls[0][1].body)).toEqual({
+ op: 'assess-case',
+ id: 'case-1',
+ revision: 4,
+ summary: 'Preserved evidence supports a safeguarding review.',
+ rules: ['Rule 4: Safeguarding', 'Rule 2: Respect'],
+ findings: [
+ {
+ category: 'safeguarding',
+ finding: 'inconclusive',
+ detail: 'The surrounding messages establish age but intent remains unclear.'
+ },
+ {
+ category: 'threats',
+ finding: 'unsubstantiated',
+ detail: 'No threat is present in the preserved exchange.'
+ }
+ ],
+ idempotencyKey: expect.any(String)
+ });
+});
+
+it('shows current findings and preserves prior assessments for read-only viewers', () => {
+ const previous = {
+ ...current,
+ revision: 2,
+ summary: 'Initial concern awaiting full context.',
+ actor: 'Moderator Two'
+ };
+ show(current, false, [current, previous]);
+ expect(document.body.textContent).toContain(current.summary);
+ expect(document.body.textContent).toContain('Substantiated');
+ expect(document.body.textContent).toContain('Inconclusive');
+ expect(document.querySelector('details summary')?.textContent).toBe('Previous assessments (1)');
+ expect(document.querySelector('details')?.textContent).toContain(previous.summary);
+ expect(document.querySelector('details')?.textContent).toContain('Moderator Two');
+ expect(button('Edit assessment')).toBeUndefined();
+ expect(document.querySelector('dialog')).toBeNull();
+});
+
+it('retains a conflicted assessment and uses the refreshed revision after reopening', async () => {
+ const fetch = vi
+ .fn()
+ .mockResolvedValueOnce(
+ response({ error: 'This case changed. Reload it before making another change.' }, 409)
+ )
+ .mockResolvedValue(response({ ok: true, id: 'case-1' }));
+ vi.stubGlobal('fetch', fetch);
+ const { update, saved } = show(current);
+ button('Edit assessment').click();
+ await tick();
+ expect(field('Current summary').value).toBe(current.summary);
+ enter('Current summary', 'Revised conclusion after review.');
+ button('Save assessment').click();
+ await vi.waitFor(() =>
+ expect(document.querySelector('[role="alert"]')?.textContent).toContain(
+ 'This case changed.'
+ )
+ );
+ expect(field('Current summary').value).toBe(
+ 'Revised conclusion after review.'
+ );
+ expect(saved).not.toHaveBeenCalled();
+ button('Cancel').click();
+ await tick();
+ update(9);
+ button('Edit assessment').click();
+ await tick();
+ enter('Current summary', 'Reviewed against the refreshed case.');
+ document.querySelector('[aria-label="Remove finding 2"]')!.click();
+ flushSync();
+ button('Save assessment').click();
+ await vi.waitFor(() => expect(saved).toHaveBeenCalledOnce());
+ expect(JSON.parse(fetch.mock.calls[1][1].body)).toMatchObject({
+ revision: 9,
+ findings: [current.findings[0]]
+ });
+});
+
+it('retries an uncertain assessment as the exact same request', async () => {
+ const fetch = vi
+ .fn()
+ .mockRejectedValueOnce(new Error('Connection lost'))
+ .mockResolvedValue(response({ ok: true, id: 'case-1' }));
+ vi.stubGlobal('fetch', fetch);
+ const { saved } = show();
+ button('Assess case').click();
+ await tick();
+ complete();
+ button('Save assessment').click();
+ 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(saved).toHaveBeenCalledOnce());
+ expect(fetch.mock.calls[1][1].body).toBe(fetch.mock.calls[0][1].body);
+});
diff --git a/tests/client/moderation-page.test.ts b/tests/client/moderation-page.test.ts
index 8a86c8b..4d6373e 100644
--- a/tests/client/moderation-page.test.ts
+++ b/tests/client/moderation-page.test.ts
@@ -331,6 +331,20 @@ it('links case records to workspaces and preserves case filters while paging', (
game: 't5',
guid,
title: 'Chat investigation',
+ assessment: {
+ revision: 3,
+ summary: 'The threat remains ambiguous.',
+ rules: [],
+ findings: [
+ {
+ category: 'threats',
+ finding: 'inconclusive',
+ detail: 'Context is insufficient.'
+ }
+ ],
+ actor: 'Moderator',
+ createdAt: '2026-10-16T12:00:00Z'
+ },
status: 'escalated',
priority: 'high',
assigneeId: 'staff-1',
@@ -349,6 +363,12 @@ it('links case records to workspaces and preserves case filters while paging', (
);
expect(document.querySelector('#moderation-results')?.textContent).toContain('Escalated');
expect(document.querySelector('#moderation-results')?.textContent).toContain('Moderator');
+ expect(document.querySelector('#moderation-results')?.textContent).toContain(
+ 'The threat remains ambiguous.'
+ );
+ expect(document.querySelector('[aria-label="Assessment findings"]')?.textContent).toMatch(
+ /Threats\s*·\s*Inconclusive/
+ );
button('Next').click();
const destination = new URL(navigation.goto.mock.calls.at(-1)![0], 'http://localhost');
expect(destination.searchParams.get('caseStatus')).toBe('active');
diff --git a/tests/unit/moderation-assessments.test.ts b/tests/unit/moderation-assessments.test.ts
new file mode 100644
index 0000000..59e6db3
--- /dev/null
+++ b/tests/unit/moderation-assessments.test.ts
@@ -0,0 +1,273 @@
+import { randomUUID } from 'node:crypto';
+import Database from 'better-sqlite3';
+import { afterEach, beforeEach, expect, it, vi } from 'vitest';
+import { moderationConcernCategories } from '../../src/lib/moderation.js';
+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, 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 = () =>
+ mutate({ op: 'create-case', game: 't5', guid: '123', title: 'Review conduct' });
+const draft = {
+ summary: 'The documented slur is substantiated; the threatening intent remains unclear.',
+ rules: ['Rule 1: No discriminatory abuse', 'Rule 2: No threats'],
+ findings: [
+ {
+ category: 'hate_speech',
+ finding: 'substantiated',
+ detail: 'The preserved message contains a slur.'
+ },
+ {
+ category: 'threats',
+ finding: 'inconclusive',
+ detail: 'The context does not establish a threat.'
+ }
+ ]
+};
+const assess = (id: string, revision: number, overrides: Record = {}) => ({
+ op: 'assess-case',
+ id,
+ revision,
+ ...draft,
+ ...overrides
+});
+
+it('preserves current and historical assessments with case revisions, including closed cases', async () => {
+ const { id } = await create();
+ expect(await detail(id)).toMatchObject({ assessment: null, assessmentHistory: [] });
+ const request = { ...assess(id, 1), idempotencyKey: randomUUID() };
+ await mutate(request);
+ await mutate(request);
+ const first = (await detail(id)).assessment!;
+ expect(first).toEqual({ ...draft, revision: 2, actor: 'moderator', createdAt: clock });
+ await mutate({
+ op: 'resolve-case',
+ id,
+ revision: 2,
+ status: 'no_action',
+ reason: 'Initial review complete'
+ });
+ vi.setSystemTime(new Date(Date.parse(clock) + 60_000));
+ const updated = {
+ summary: 'Further context does not support a threat.',
+ rules: [],
+ findings: [
+ {
+ category: 'threats',
+ finding: 'unsubstantiated',
+ detail: 'The reply was about the game.'
+ }
+ ]
+ };
+ await mutate(assess(id, 3, updated), 'admin');
+ const data = await detail(id, 'super_viewer');
+ expect(data.case).toMatchObject({ revision: 4, status: 'no_action' });
+ expect(data.assessment).toEqual({
+ ...updated,
+ revision: 4,
+ actor: 'admin',
+ createdAt: '2026-10-17T12:01:00.000Z'
+ });
+ expect(data.assessmentHistory).toEqual([data.assessment, first]);
+ expect(JSON.parse(data.events[0].detail)).toEqual({
+ assessmentRevision: 4,
+ summary: updated.summary
+ });
+ const { currentCaseAssessment } =
+ await import('../../src/lib/server/moderation/assessments.js');
+ expect(currentCaseAssessment(id)).toEqual(data.assessment);
+ expect(currentCaseAssessment('missing')).toBeNull();
+ expect(handle.prepare('SELECT revision FROM moderation_policy').get()).toEqual({ revision: 1 });
+});
+
+it('checks staff access, case revision and normalized no-change submissions before writing history', async () => {
+ const { id } = await create();
+ for (const actor of ['viewer', 'super_viewer'])
+ await expect(mutate(assess(id, 1), actor)).rejects.toMatchObject({ status: 403 });
+ await mutate(assess(id, 1));
+ const before = await detail(id);
+ await expect(mutate(assess(id, 1, { summary: 'Stale edit' }))).rejects.toMatchObject({
+ status: 409
+ });
+ await expect(mutate(assess(id, 2, { summary: ` ${draft.summary} ` }))).rejects.toThrow(
+ 'Change the assessment'
+ );
+ expect(await detail(id)).toEqual(before);
+ await expect(detail(id, 'viewer')).rejects.toMatchObject({ status: 403 });
+ handle.prepare("UPDATE auth_users SET disabled=1 WHERE id='moderator'").run();
+ await expect(mutate(assess(id, 2, { summary: 'Disabled account edit' }))).rejects.toMatchObject(
+ { status: 403 }
+ );
+});
+
+it('rejects invalid summaries, rules and findings without adding assessment or audit rows', async () => {
+ const { id } = await create();
+ const invalid = [
+ { summary: '' },
+ { summary: 'a'.repeat(2001) },
+ { summary: 'Invalid\u0000summary' },
+ { rules: null },
+ { rules: [''] },
+ { rules: ['a'.repeat(161)] },
+ { rules: Array(21).fill('Rule') },
+ { findings: [] },
+ { findings: null },
+ { findings: [null] },
+ { findings: [{ category: 'unknown', finding: 'substantiated', detail: 'Evidence' }] },
+ { findings: [{ category: 'spam', finding: 'unknown', detail: 'Evidence' }] },
+ { findings: [{ category: 'spam', finding: 'substantiated', detail: '' }] },
+ { findings: [{ category: 'spam', finding: 'substantiated', detail: 'a'.repeat(1001) }] },
+ { findings: [draft.findings[0], draft.findings[0]] },
+ { findings: Array(8).fill(draft.findings[0]) }
+ ];
+ const before = await detail(id);
+ for (const input of invalid)
+ await expect(mutate(assess(id, 1, input))).rejects.toMatchObject({ status: 400 });
+ expect(await detail(id)).toEqual(before);
+ expect(handle.prepare('SELECT * FROM moderation_case_assessments').all()).toEqual([]);
+});
+
+it('rolls back history, revisions and request receipts when assessment auditing fails', async () => {
+ const { id } = await create();
+ const before = await detail(id);
+ handle.exec(`CREATE TRIGGER fail_assessment_audit BEFORE INSERT ON moderation_events
+ WHEN NEW.action='case.assessed' BEGIN SELECT RAISE(ABORT,'audit unavailable'); END`);
+ const request = { ...assess(id, 1), idempotencyKey: randomUUID() };
+ await expect(mutate(request)).rejects.toThrow('audit unavailable');
+ expect(await detail(id)).toEqual(before);
+ expect(handle.prepare('SELECT * FROM moderation_case_assessments').all()).toEqual([]);
+ expect(
+ handle
+ .prepare('SELECT * FROM moderation_requests WHERE request_key=?')
+ .all(request.idempotencyKey)
+ ).toEqual([]);
+ handle.exec('DROP TRIGGER fail_assessment_audit');
+ await mutate(request);
+ expect((await detail(id)).assessmentHistory).toHaveLength(1);
+});
+
+it('keeps assessment revisions after source match, server and author cleanup', async () => {
+ handle
+ .prepare(
+ "INSERT INTO servers(name,game,map) VALUES('t5_zombie_theater_1','t5','zombie_theater')"
+ )
+ .run();
+ const { saveTelemetry } = await import('../../src/lib/server/fleet/telemetry.js');
+ saveTelemetry({
+ server: 't5_zombie_theater_1',
+ map: 'zombie_theater',
+ event: 'game_start',
+ players: [{ guid: '123', name: 'Player' }],
+ receivedAt: clock
+ });
+ const { id } = await create();
+ await mutate(assess(id, 1));
+ const before = (await detail(id)).assessmentHistory;
+ handle.prepare('DELETE FROM leaderboard_matches').run();
+ handle.prepare('DELETE FROM servers').run();
+ handle.prepare("DELETE FROM auth_users WHERE id='moderator'").run();
+ expect((await detail(id, 'admin')).assessmentHistory).toEqual(before);
+});
+
+it('accepts a valid detailed multibyte assessment over 16 KiB while retaining the 64 KiB request boundary', async () => {
+ const { id } = await create();
+ const { POST } = await import('../../src/routes/api/moderation/+server.js');
+ const { getUser } = await import('../../src/lib/server/auth/index.js');
+ const input = {
+ ...assess(id, 1, {
+ summary: '界'.repeat(2000),
+ rules: Array.from({ length: 20 }, (_, index) => `${index} ${'界'.repeat(155)}`),
+ findings: moderationConcernCategories.map((category) => ({
+ category,
+ finding: 'inconclusive',
+ detail: '界'.repeat(1000)
+ }))
+ }),
+ idempotencyKey: randomUUID()
+ };
+ const body = JSON.stringify(input);
+ expect(Buffer.byteLength(body)).toBeGreaterThan(16 * 1024);
+ const post = (body: string) =>
+ POST({
+ locals: { user: getUser('moderator') },
+ request: new Request('http://localhost/api/moderation', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body
+ })
+ } as Parameters[0]);
+ const response = await post(body);
+ expect(response.status).toBe(200);
+ expect((await detail(id)).assessment?.findings).toHaveLength(7);
+ const oversized = await post(
+ JSON.stringify({ ...input, revision: 2, padding: 'a'.repeat(64 * 1024) })
+ );
+ expect(oversized.status).toBe(413);
+ expect((await detail(id)).assessmentHistory).toHaveLength(1);
+});
+
+it('adds assessment storage to an existing database without altering case records', 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, 31));
+ legacy
+ .prepare(
+ `INSERT INTO moderation_cases(id,game,guid,title,status,actor_id,actor,created_at,updated_at,revision)
+ VALUES('case','t5','123','Existing case','no_action','former','Former staff',?,?,41)`
+ )
+ .run(clock, clock);
+ 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);
+ const insert = legacy.prepare(
+ 'INSERT INTO moderation_case_assessments VALUES(?,?,?,?,?,?)'
+ );
+ insert.run('case', 42, JSON.stringify(draft), 'former', 'Former staff', clock);
+ expect(() => insert.run('case', 42, '{}', 'former', 'Former staff', clock)).toThrow(
+ 'UNIQUE'
+ );
+ expect(() => insert.run('missing', 2, '{}', 'former', 'Former staff', clock)).toThrow(
+ 'FOREIGN KEY'
+ );
+ expect(legacy.pragma('foreign_key_check')).toEqual([]);
+ } finally {
+ legacy.close();
+ }
+});
diff --git a/tests/unit/moderation-case-overview.test.ts b/tests/unit/moderation-case-overview.test.ts
index 46f066d..875d5fc 100644
--- a/tests/unit/moderation-case-overview.test.ts
+++ b/tests/unit/moderation-case-overview.test.ts
@@ -67,3 +67,36 @@ it('filters case queues by workflow, priority and ownership while prioritizing a
).total
).toBe(1);
});
+
+it('shows the latest assessment in case queues while preserving earlier findings', async () => {
+ const { mutateModeration } = await import('../../src/lib/server/moderation/actions.js');
+ const { moderationOverview, moderationQuery } =
+ await import('../../src/lib/server/moderation/overview.js');
+ const { moderationCaseDetail } = await import('../../src/lib/server/moderation/cases.js');
+ const mutate = (input: Record) =>
+ mutateModeration('first', { ...input, idempotencyKey: randomUUID() });
+ const { id } = mutate({
+ op: 'create-case',
+ game: 't5',
+ guid: '123',
+ title: 'Conversation review'
+ });
+ for (const [revision, finding, summary] of [
+ [1, 'inconclusive', 'Need more context'],
+ [2, 'substantiated', 'Full excerpt confirms targeted harassment']
+ ] as const)
+ mutate({
+ op: 'assess-case',
+ id,
+ revision,
+ summary,
+ rules: ['Respect other players'],
+ findings: [{ category: 'harassment', finding, detail: summary }]
+ });
+ const queue = moderationOverview('first', moderationQuery(new URLSearchParams('view=cases')));
+ expect(queue.cases[0].assessment).toMatchObject({
+ summary: 'Full excerpt confirms targeted harassment',
+ findings: [{ category: 'harassment', finding: 'substantiated' }]
+ });
+ expect(moderationCaseDetail('first', id).assessmentHistory).toHaveLength(2);
+});