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, 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 = {}) => 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 = {}) => 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('closed'); 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(); } });