Expand moderation case workflows
CI / checks (push) Successful in 6m49s

This commit is contained in:
2026-10-17 16:19:33 -04:00
parent 4501eba873
commit 7f2205e9c4
22 changed files with 2815 additions and 167 deletions
+2
View File
@@ -21,7 +21,9 @@ describe('management route permissions', () => {
it.each([
'/moderation',
'/moderation/players/[guid]',
'/moderation/cases/[id]',
'/api/moderation',
'/api/moderation/cases/[id]',
'/api/moderation/players/[guid]',
'/api/moderation/sanctions/[id]/revoke'
])('limits private moderation access at %s without changing fleet privileges', (route) => {
+29 -12
View File
@@ -290,21 +290,38 @@ describe('browser authentication guard', () => {
}
}
);
it('protects moderation pages and Svelte page-data requests with the same role checks', async () => {
for (const role of ['viewer', 'super_viewer', 'operator', 'moderator', 'admin'] as const) {
signIn(role);
for (const path of ['/moderation', '/moderation/__data.json']) {
const call = requestFor('/moderation', { path });
if (role === 'viewer' || role === 'operator') {
await expect(call.invoke()).rejects.toMatchObject({ status: 403 });
expect(call.resolve).not.toHaveBeenCalled();
} else {
expect((await call.invoke()).status).toBe(200);
expect(call.resolve).toHaveBeenCalledOnce();
it.each([
['/moderation', '/moderation'],
['/moderation/cases/[id]', '/moderation/cases/case-123']
])(
'protects %s pages and Svelte page-data requests with the same role checks',
async (route, path) => {
for (const role of [
'viewer',
'super_viewer',
'operator',
'moderator',
'admin'
] as const) {
const session = signIn(role);
for (const requestPath of [path, `${path}/__data.json`]) {
for (const method of ['GET', 'HEAD']) {
const call = requestFor(route, { path: requestPath, method });
if (role === 'viewer' || role === 'operator') {
await expect(call.invoke()).rejects.toMatchObject({ status: 403 });
expect(call.resolve).not.toHaveBeenCalled();
} else {
const response = await call.invoke();
expect(response.status).toBe(200);
expect(response.headers.get('Cache-Control')).toContain('no-store');
expect(call.resolve).toHaveBeenCalledOnce();
expect(call.event.locals.user).toEqual(session.user);
}
}
}
}
}
});
);
it.each(['GET', 'HEAD', 'POST', 'PATCH', 'DELETE'])(
'permits only administrators to write settings and Super Viewers to read them',
async (method) => {
@@ -0,0 +1,69 @@
import { randomUUID } from 'node:crypto';
import { afterEach, beforeEach, expect, it, vi } from 'vitest';
import { newFixture, type Fixture } from '../helpers/fixture.js';
let fixture: Fixture;
beforeEach(async () => {
vi.resetModules();
fixture = await newFixture();
const { db } = await import('../../src/lib/server/db/index.js');
for (const id of ['first', 'second'])
db()
.prepare(
`INSERT INTO auth_users(id,username,display_name,password_hash,role,created_at,updated_at)
VALUES(?,?,?,'unused','moderator',0,0)`
)
.run(id, id, id);
});
afterEach(async () => fixture.cleanup());
it('filters case queues by workflow, priority and ownership while prioritizing active work', async () => {
const { mutateModeration } = await import('../../src/lib/server/moderation/actions.js');
const { moderationOverview, moderationQuery } =
await import('../../src/lib/server/moderation/overview.js');
const { db } = await import('../../src/lib/server/db/index.js');
const ids: string[] = [];
for (const [title, priority, status, owner] of [
['Normal report', 'normal', 'open', null],
['Urgent review', 'urgent', 'in_review', 'first'],
['High escalation', 'high', 'escalated', 'second'],
['Closed urgent appeal', 'urgent', 'appeal_upheld', 'first']
]) {
const { id } = mutateModeration('first', {
op: 'create-case',
game: 't5',
guid: '123',
title,
idempotencyKey: randomUUID()
});
ids.push(id);
db()
.prepare('UPDATE moderation_cases SET priority=?,status=?,assignee_id=? WHERE id=?')
.run(priority, status, owner, id);
}
const read = (extra = '') =>
moderationOverview('first', moderationQuery(new URLSearchParams(`view=cases&${extra}`)));
expect(read().cases.map((item) => item.id)).toEqual([ids[1], ids[2], ids[0], ids[3]]);
expect(read('caseStatus=active').total).toBe(3);
expect(read('caseStatus=closed').cases.map((item) => item.id)).toEqual([ids[3]]);
expect(read('caseStatus=escalated&priority=high').cases.map((item) => item.id)).toEqual([
ids[2]
]);
expect(read('assignedTo=me').cases.map((item) => item.id)).toEqual([ids[1], ids[3]]);
expect(read('assignedTo=unassigned').cases.map((item) => item.id)).toEqual([ids[0]]);
expect(read('assignedTo=second').cases[0].assignee).toBe('second');
expect(
read('caseStatus=active&assignedTo=me&priority=urgent&game=t5&guid=123&q=review&offset=100')
.query.offset
).toBe(0);
expect(read('caseStatus=active&assignedTo=me&priority=urgent&game=t6').total).toBe(0);
expect(read('caseStatus=invalid&priority=invalid').total).toBe(4);
expect(
moderationOverview(
'first',
moderationQuery(
new URLSearchParams('caseStatus=closed&assignedTo=unassigned&priority=urgent')
)
).total
).toBe(1);
});
+120
View File
@@ -0,0 +1,120 @@
import { afterEach, beforeEach, expect, it, vi } from 'vitest';
import type { AuthUser } from '../../src/lib/auth.js';
import { newFixture, type Fixture } from '../helpers/fixture.js';
let fixture: Fixture;
beforeEach(async () => {
vi.resetModules();
fixture = await newFixture([]);
const { db } = await import('../../src/lib/server/db/index.js');
for (const role of ['admin', 'moderator', 'super_viewer', 'operator', 'viewer']) {
db()
.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);
}
db()
.prepare(
`INSERT INTO moderation_cases
(id,game,guid,title,status,actor_id,actor,created_at,updated_at)
VALUES('case-123','t5','123','Private investigation','open','moderator','moderator',?,?)`
)
.run('2026-10-17T12:00:00.000Z', '2026-10-17T12:00:00.000Z');
});
afterEach(async () => fixture.cleanup());
async function api(user: AuthUser | null, id = 'case-123') {
const { GET } = await import('../../src/routes/api/moderation/cases/[id]/+server.js');
return GET({ locals: { user }, params: { id } } as Parameters<typeof GET>[0]);
}
async function page(user: AuthUser | null, id = 'case-123') {
const { load } = await import('../../src/routes/moderation/cases/[id]/+page.server.js');
const setHeaders = vi.fn();
const result = await load({
locals: { user },
params: { id },
setHeaders
} as unknown as Parameters<typeof load>[0]);
return { result, setHeaders };
}
it.each(['admin', 'moderator', 'super_viewer'])(
'serves private case detail without caching to %s',
async (role) => {
const { getUser } = await import('../../src/lib/server/auth/index.js');
const user = getUser(role)!;
const response = await api(user);
expect(response.status).toBe(200);
expect(response.headers.get('Cache-Control')).toBe('no-store');
expect(await response.json()).toMatchObject({
case: { id: 'case-123', title: 'Private investigation' }
});
const { result, setHeaders } = await page(user);
expect(result).toMatchObject({
moderationCase: { case: { id: 'case-123', title: 'Private investigation' } }
});
expect(setHeaders).toHaveBeenCalledWith({ 'Cache-Control': 'no-store' });
}
);
it.each(['operator', 'viewer'])(
'rejects %s reads before revealing case existence or content',
async (role) => {
const { getUser } = await import('../../src/lib/server/auth/index.js');
const user = getUser(role)!;
for (const id of ['case-123', 'missing']) {
const response = await api(user, id);
expect(response.status).toBe(403);
expect(response.headers.get('Cache-Control')).toBe('no-store');
expect(await response.json()).toEqual({ error: 'Moderation access is required.' });
await expect(page(user, id)).rejects.toMatchObject({
status: 403,
body: { message: 'Moderation access is required.' }
});
}
}
);
it.each(["role='viewer'", 'disabled=1', 'must_change_password=1'])(
'rechecks current staff access when stale session locals have %s',
async (change) => {
const { getUser } = await import('../../src/lib/server/auth/index.js');
const { db } = await import('../../src/lib/server/db/index.js');
const user = getUser('moderator')!;
db().prepare(`UPDATE auth_users SET ${change} WHERE id='moderator'`).run();
const response = await api(user);
expect(response.status).toBe(403);
expect(response.headers.get('Cache-Control')).toBe('no-store');
await expect(page(user)).rejects.toMatchObject({ status: 403 });
}
);
it('preserves private not-found responses in the API and page loader', async () => {
const { getUser } = await import('../../src/lib/server/auth/index.js');
const user = getUser('moderator')!;
const response = await api(user, 'missing');
expect(response.status).toBe(404);
expect(response.headers.get('Cache-Control')).toBe('no-store');
expect(await response.json()).toEqual({ error: 'Case not found.' });
await expect(page(user, 'missing')).rejects.toMatchObject({
status: 404,
body: { message: 'Case not found.' }
});
});
it('requires a signed-in user before looking up a case', async () => {
const response = await api(null);
expect(response.status).toBe(401);
expect(response.headers.get('Cache-Control')).toBe('no-store');
expect(await response.json()).toEqual({ error: 'Sign in to continue.' });
await expect(page(null)).rejects.toMatchObject({
status: 401,
body: { message: 'Sign in to continue.' }
});
});
+455
View File
@@ -0,0 +1,455 @@
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';
import { moderationCaseOutcomes } from '../../src/lib/moderation.js';
let fixture: Fixture;
let handle: Database.Database;
const clock = '2026-10-17T12:00:00.000Z';
const server = 't5_zombie_theater_1';
beforeEach(async () => {
vi.resetModules();
vi.useFakeTimers({ toFake: ['Date'] });
vi.setSystemTime(new Date(clock));
fixture = await newFixture([server]);
const { seedServerRegistry } = await import('../../src/lib/server/fleet/servers.js');
await seedServerRegistry();
const { db } = await import('../../src/lib/server/db/index.js');
handle = db();
for (const role of ['admin', 'moderator', 'viewer', 'operator', 'super_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 chat conduct',
...overrides
});
const restriction = (
caseId: string,
caseRevision: number,
overrides: Record<string, unknown> = {}
) => ({
op: 'create-sanction',
game: 't5',
guid: '123',
kind: 'mute',
scope: 'game',
reason: 'Repeated chat abuse',
durationMinutes: 60,
caseId,
caseRevision,
...overrides
});
it('upgrades legacy cases without changing evidence, events, requests, or restriction records', async () => {
const { migrations } = await import('../../src/lib/server/db/migrations.js');
const { runMigrations } = await import('../../src/lib/server/db/migration-runner.js');
const { migrateModerationCases } = await import('../../src/lib/server/moderation/schema.js');
const legacy = new Database(':memory:');
try {
legacy.pragma('foreign_keys = ON');
runMigrations(legacy, migrations.slice(0, 28));
for (const status of ['open', 'resolved', 'dismissed']) {
legacy
.prepare('INSERT INTO moderation_cases VALUES(?,?,?,?,?,?,?,?,?,?)')
.run(
status,
't5',
'123',
`Legacy ${status}`,
status,
'retired-user',
'Former staff',
clock,
clock,
7
);
legacy
.prepare('INSERT INTO moderation_evidence VALUES(?,?,?,?,?,?,?,?)')
.run(
`evidence-${status}`,
status,
'Original evidence',
'Archived chat',
'{"original":true}',
'retired-user',
'Former staff',
clock
);
legacy
.prepare(
`INSERT INTO moderation_events(game,guid,actor,action,subject_id,detail,created_at)
VALUES('t5','123','Former staff','case.opened',?,'Original history',?)`
)
.run(status, clock);
}
legacy
.prepare('INSERT INTO moderation_requests VALUES(?,?,?,?,?)')
.run(
'retired-user',
'original-request',
'fingerprint',
'{"ok":true,"id":"open"}',
clock
);
legacy
.prepare(
`INSERT INTO moderation_sanctions(id,game,guid,kind,scope,reason,note,actor_id,actor,created_at)
VALUES('old-sanction','t5','123','ban','game','Original reason','','retired-user','Former staff',?)`
)
.run(clock);
runMigrations(legacy, migrations);
runMigrations(legacy, migrations);
migrateModerationCases(legacy);
expect(legacy.pragma('foreign_keys', { simple: true })).toBe(1);
expect(legacy.pragma('foreign_key_check')).toEqual([]);
expect(
legacy
.prepare(
'SELECT status,revision,priority,assignee_id FROM moderation_cases ORDER BY id'
)
.all()
).toEqual(
['dismissed', 'open', 'resolved'].map((status) => ({
status,
revision: 7,
priority: 'normal',
assignee_id: null
}))
);
expect(
legacy
.prepare('SELECT COUNT(*) AS count FROM moderation_evidence WHERE snapshot=?')
.get('{"original":true}')
).toEqual({ count: 3 });
expect(legacy.prepare('SELECT COUNT(*) AS count FROM moderation_events').get()).toEqual({
count: 3
});
expect(legacy.prepare('SELECT result FROM moderation_requests').get()).toEqual({
result: '{"ok":true,"id":"open"}'
});
expect(legacy.prepare('SELECT id,case_id FROM moderation_sanctions').get()).toEqual({
id: 'old-sanction',
case_id: null
});
legacy.prepare("UPDATE moderation_cases SET status='in_review' WHERE id='open'").run();
expect(() =>
legacy.prepare("UPDATE moderation_sanctions SET case_id='missing'").run()
).toThrow('FOREIGN KEY');
expect(() => legacy.prepare("DELETE FROM moderation_cases WHERE id='open'").run()).toThrow(
'FOREIGN KEY'
);
} finally {
legacy.close();
}
});
it('requires private read access and returns a complete case outside list pagination', async () => {
const { id } = await create({ note: 'Initial context' });
for (const actor of ['viewer', 'operator'])
await expect(detail(id, actor)).rejects.toThrow('Moderation access');
await expect(detail('missing')).rejects.toMatchObject({ status: 404 });
for (let i = 0; i < 30; i++) await mutate({ op: 'case-note', id, text: `Follow-up ${i}` });
const data = await detail(id, 'super_viewer');
expect(data.case).toMatchObject({
id,
priority: 'normal',
assigneeId: null,
assignee: null,
revision: 31
});
expect(data.case.evidence).toHaveLength(31);
expect(data.events).toHaveLength(31);
expect(data.player).toMatchObject({ game: 't5', guid: '123' });
expect(data.assignees).toEqual([
{ id: 'admin', username: 'admin' },
{ id: 'moderator', username: 'moderator' }
]);
await expect(
mutate({ op: 'case-note', id, text: 'Forbidden write' }, 'super_viewer')
).rejects.toThrow('Moderation access');
handle.prepare("UPDATE auth_users SET disabled=1 WHERE id='moderator'").run();
await expect(detail(id)).rejects.toThrow('Moderation access');
});
it('audits title, ownership, priority and active state updates with optimistic revisions', async () => {
const { id } = await create();
await expect(
mutate({ op: 'update-case', id, revision: 1, status: 'in_review' })
).rejects.toThrow('Status change reason');
await mutate({
op: 'update-case',
id,
revision: 1,
title: 'Escalated chat report',
priority: 'urgent',
assigneeId: 'admin',
status: 'in_review',
reason: 'Reviewing archive'
});
const data = await detail(id);
expect(data.case).toMatchObject({
title: 'Escalated chat report',
priority: 'urgent',
assigneeId: 'admin',
assignee: 'admin',
status: 'in_review',
revision: 2
});
expect(JSON.parse(data.events[0].detail)).toMatchObject({
title: { from: 'Review chat conduct', to: 'Escalated chat report' },
assignee: { from: null, to: 'admin' },
status: { from: 'open', to: 'in_review' },
reason: 'Reviewing archive'
});
await expect(mutate({ op: 'update-case', id, revision: 1, priority: 'low' })).rejects.toThrow(
'changed'
);
for (const assigneeId of ['viewer', 'super_viewer', 'operator', 'missing'])
await expect(mutate({ op: 'update-case', id, revision: 2, assigneeId })).rejects.toThrow(
'active administrator or moderator'
);
handle.prepare("UPDATE auth_users SET must_change_password=1 WHERE id='admin'").run();
await mutate({
op: 'update-case',
id,
revision: 2,
assigneeId: 'admin',
title: 'Existing ownership retained'
});
await mutate({
op: 'update-case',
id,
revision: 3,
assigneeId: null,
status: 'escalated',
reason: 'Needs administrator review'
});
expect((await detail(id)).case).toMatchObject({
revision: 4,
status: 'escalated',
assigneeId: null
});
await expect(
mutate({ op: 'update-case', id, revision: 4, assigneeId: 'admin' })
).rejects.toThrow('active administrator or moderator');
});
it('supports every outcome and reopening while preserving each prior decision', async () => {
const { id } = await create();
let revision = 1;
for (const status of moderationCaseOutcomes) {
await expect(mutate({ op: 'resolve-case', id, revision, status })).rejects.toThrow(
'Decision reason'
);
await mutate({ op: 'resolve-case', id, revision, status, reason: `Decision ${status}` });
revision++;
expect((await detail(id)).case.status).toBe(status);
await expect(mutate({ op: 'update-case', id, revision, status: 'open' })).rejects.toThrow(
'Use reopen'
);
await expect(
mutate({ op: 'reopen-case', id, revision: revision - 1, reason: 'Old version' })
).rejects.toThrow('changed');
await expect(mutate({ op: 'reopen-case', id, revision })).rejects.toThrow(
'Reopening reason'
);
await mutate({ op: 'reopen-case', id, revision, reason: `New evidence after ${status}` });
revision++;
}
const data = await detail(id);
expect(data.case).toMatchObject({ revision, status: 'open' });
for (const status of moderationCaseOutcomes) {
expect(data.case.evidence.some((item) => item.text === `Decision ${status}`)).toBe(true);
expect(data.events.some((event) => event.action === `case.${status}`)).toBe(true);
}
await expect(
mutate({ op: 'reopen-case', id, revision, reason: 'Already open' })
).rejects.toThrow('already active');
await expect(
mutate({ op: 'resolve-case', id, revision, status: 'escalated', reason: 'Wrong operation' })
).rejects.toThrow('case outcome');
});
it('links restrictions atomically with case revisions and idempotent retries', async () => {
const { id } = await create();
const input = { ...restriction(id, 1), idempotencyKey: randomUUID() };
for (const override of [
{ game: 't6' },
{ guid: '456' },
{ durationMinutes: null },
{ caseRevision: 0 }
])
await expect(mutate({ ...input, ...override })).rejects.toThrow();
expect((await detail(id)).case.revision).toBe(1);
expect(handle.prepare('SELECT COUNT(*) AS count FROM moderation_sanctions').get()).toEqual({
count: 0
});
const applied = await mutate(input);
expect(await mutate(input)).toEqual(applied);
let data = await detail(id);
expect(data.case.revision).toBe(2);
expect(data.sanctions).toMatchObject([{ id: applied.id, caseId: id, active: true }]);
expect(data.events.filter((event) => event.action === 'case.restriction')).toHaveLength(1);
await expect(mutate(restriction(id, 1))).rejects.toThrow('changed');
await mutate({ op: 'revoke-sanction', id: applied.id, revision: 1, reason: 'Review revised' });
data = await detail(id);
expect(data.case.revision).toBe(3);
expect(data.sanctions[0].active).toBe(false);
expect(data.events[0]).toMatchObject({
action: 'restriction.revoked',
subjectId: applied.id,
detail: 'Review revised'
});
await mutate({
op: 'resolve-case',
id,
revision: 3,
status: 'restriction_applied',
reason: 'Finished review'
});
await expect(mutate(restriction(id, 4))).rejects.toThrow('Reopen this case');
});
it('rolls back restriction, policy and case writes when a linked audit write fails', async () => {
const { id } = await create();
const policy = handle.prepare('SELECT revision FROM moderation_policy').get();
handle.exec(`CREATE TRIGGER fail_case_audit BEFORE INSERT ON moderation_events
WHEN NEW.action='case.restriction' BEGIN SELECT RAISE(ABORT,'audit unavailable'); END`);
const input = { ...restriction(id, 1), idempotencyKey: randomUUID() };
await expect(mutate(input)).rejects.toThrow('audit unavailable');
expect((await detail(id)).case.revision).toBe(1);
expect(handle.prepare('SELECT revision FROM moderation_policy').get()).toEqual(policy);
expect(handle.prepare('SELECT COUNT(*) AS count FROM moderation_sanctions').get()).toEqual({
count: 0
});
expect(
handle
.prepare('SELECT COUNT(*) AS count FROM moderation_requests WHERE request_key=?')
.get(input.idempotencyKey)
).toEqual({ count: 0 });
handle.exec('DROP TRIGGER fail_case_audit');
await mutate(input);
expect((await detail(id)).sanctions).toHaveLength(1);
});
it('attaches only archived evidence for the exact player and retains snapshots after cleanup', async () => {
const { saveTelemetry } = await import('../../src/lib/server/fleet/telemetry.js');
saveTelemetry({
server,
map: 'zombie_theater',
event: 'game_start',
players: [{ guid: '123', name: 'Player' }],
receivedAt: clock
});
const { id: matchId } = handle.prepare('SELECT id FROM leaderboard_matches').get() as {
id: number;
};
for (const [guid, message] of [
['123', 'Verified message'],
['123', ' Evidence with padding '],
['456', 'Another player']
])
handle
.prepare('INSERT INTO match_chat VALUES(?,?,?,?)')
.run(
matchId,
message,
0,
JSON.stringify({ type: 'message', message: { guid, message, player: 'Player' } })
);
const { id } = await create();
for (const message of ['Forged text', 'Another player', 'Evidence with padding'])
await expect(
mutate({ op: 'attach-evidence', id, revision: 1, evidence: { matchId, message } })
).rejects.toThrow('could not be verified');
const { id: otherGame } = await create({ game: 't6' });
await expect(
mutate({
op: 'attach-evidence',
id: otherGame,
revision: 1,
evidence: { matchId, message: 'Verified message' }
})
).rejects.toThrow('source match is unavailable');
const input = {
op: 'attach-evidence',
id,
revision: 1,
evidence: { matchId, message: 'Verified message' },
idempotencyKey: randomUUID()
};
await mutate(input);
await mutate(input);
await expect(mutate({ ...input, idempotencyKey: randomUUID() })).rejects.toThrow('changed');
await mutate({
op: 'attach-evidence',
id,
revision: 2,
evidence: { matchId, message: ' Evidence with padding ' }
});
handle.prepare('DELETE FROM leaderboard_matches').run();
const data = await detail(id);
expect(data.case.revision).toBe(3);
expect(data.case.evidence).toMatchObject([
{ text: 'Verified message', snapshot: expect.stringContaining('Verified message') },
{
text: ' Evidence with padding ',
snapshot: expect.stringContaining(' Evidence with padding ')
}
]);
expect(data.events[0].action).toBe('case.evidence_attached');
});
it('keeps decision history writable when a case has reached its evidence limit', async () => {
const { id } = await create();
for (let index = 0; index < 100; index++)
handle
.prepare('INSERT INTO moderation_evidence VALUES(?,?,?,?,?,?,?,?)')
.run(
randomUUID(),
id,
`Evidence ${index}`,
'Staff note',
null,
'moderator',
'moderator',
clock
);
await expect(
mutate({ op: 'case-note', id, revision: 1, text: 'Over the limit' })
).rejects.toThrow('100 evidence entries');
await mutate({
op: 'resolve-case',
id,
revision: 1,
status: 'no_action',
reason: 'Review complete'
});
await mutate({ op: 'reopen-case', id, revision: 2, reason: 'Reconsider the decision' });
expect((await detail(id)).case).toMatchObject({ status: 'open', revision: 3 });
expect((await detail(id)).case.evidence).toHaveLength(102);
});
+39 -1
View File
@@ -3,7 +3,7 @@ import { render } from 'svelte/server';
import { writable } from 'svelte/store';
import ChatPanel from '../../src/lib/ui/components/ChatPanel.svelte';
import PlayerScoreboard from '../../src/lib/ui/components/PlayerScoreboard.svelte';
import { moderationHref } from '../../src/lib/ui/moderation.js';
import { moderationEventDetail, moderationHref } from '../../src/lib/ui/moderation.js';
import type { UserRole } from '../../src/lib/auth.js';
const currentPage = writable({
@@ -72,3 +72,41 @@ it('does not invent a game namespace for an unscoped profile', () => {
expect(target.searchParams.get('guid')).toBe('42');
expect(target.searchParams.has('game')).toBe(false);
});
it('presents case decisions and ownership changes as readable history while preserving staff text', () => {
expect(
moderationEventDetail(
JSON.stringify({
title: { from: 'open', to: 'constructor' },
assignee: { from: 'open', to: 'constructor' }
}),
'case.updated'
)
).toBe('Title: open → constructor\nAssigned to: open → constructor');
expect(
moderationEventDetail(
JSON.stringify({
title: { from: 'Report', to: 'Appeal review' },
assignee: { from: null, to: 'Moderator' },
assigneeId: { from: null, to: 'private-internal-user-id' },
status: { from: 'open', to: 'in_review' },
reason: 'Reviewing additional evidence'
}),
'case.updated'
)
).toBe(
'Title: Report → Appeal review\nAssigned to: Unassigned → Moderator\nStatus: Open → In review\nReviewing additional evidence'
);
expect(
moderationEventDetail(
JSON.stringify({ from: 'appeal_denied', reason: 'New evidence' }),
'case.reopened'
)
).toBe('Previous outcome: Appeal denied\nNew evidence');
expect(moderationEventDetail('Decision with {unusual text}', 'case.resolved')).toBe(
'Decision with {unusual text}'
);
expect(
moderationEventDetail(JSON.stringify({ count: 2, matchId: 42 }), 'case.evidence_attached')
).toBe('2 archived chat entries attached from match #42.');
});