Files
electric-cherry/tests/unit/moderation-followups.test.ts
T

396 lines
16 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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();
}
});