This commit is contained in:
@@ -0,0 +1,305 @@
|
||||
import { afterEach, beforeEach, expect, it, vi } from 'vitest';
|
||||
import { flushSync, mount, tick, unmount } from 'svelte';
|
||||
import { fromStore, writable } from 'svelte/store';
|
||||
import ModerationPage from '../../src/routes/moderation/+page.svelte';
|
||||
import type { ModerationOverview } from '../../src/lib/moderation.js';
|
||||
import type { UserRole } from '../../src/lib/auth.js';
|
||||
|
||||
const navigation = vi.hoisted(() => ({ goto: vi.fn(), before: [] as (() => void)[] }));
|
||||
const currentPage = writable({
|
||||
url: new URL('http://localhost/moderation'),
|
||||
data: { user: { id: 'staff-1', role: 'moderator' as UserRole } }
|
||||
});
|
||||
vi.mock('$app/stores', () => ({
|
||||
page: {
|
||||
subscribe: (run: Parameters<typeof currentPage.subscribe>[0]) => currentPage.subscribe(run)
|
||||
},
|
||||
navigating: writable(null)
|
||||
}));
|
||||
vi.mock('$app/navigation', () => ({
|
||||
goto: navigation.goto,
|
||||
beforeNavigate: (fn: () => void) => navigation.before.push(fn)
|
||||
}));
|
||||
vi.mock('../../src/lib/ui/poll.js', () => ({ pollVisible: () => () => {} }));
|
||||
|
||||
let component: ReturnType<typeof mount> | undefined;
|
||||
beforeEach(() => {
|
||||
navigation.goto.mockReset().mockResolvedValue(undefined);
|
||||
navigation.before.length = 0;
|
||||
});
|
||||
afterEach(async () => {
|
||||
if (component) await unmount(component);
|
||||
component = undefined;
|
||||
document.body.replaceChildren();
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
const guid = '18446744073709551615';
|
||||
function overview(view: ModerationOverview['query']['view'] = 'players'): ModerationOverview {
|
||||
return {
|
||||
query: { view, q: '', game: 't5', guid, offset: 0 },
|
||||
players: [
|
||||
{
|
||||
game: 't5',
|
||||
guid,
|
||||
name: 'Dempsey',
|
||||
aliases: ['Tank'],
|
||||
firstSeen: '2026-10-01T12:00:00Z',
|
||||
lastSeen: '2026-10-15T12:00:00Z',
|
||||
servers: ['t5_zombie_theater_1'],
|
||||
restrictions: 1
|
||||
}
|
||||
],
|
||||
sanctions: [
|
||||
{
|
||||
id: 'restriction-1',
|
||||
game: 't5',
|
||||
guid,
|
||||
kind: 'ban',
|
||||
scope: 'game',
|
||||
server: null,
|
||||
reason: 'Repeated abuse',
|
||||
note: 'Reviewed chat',
|
||||
createdAt: '2026-10-15T12:00:00Z',
|
||||
expiresAt: '2026-10-16T12:00:00Z',
|
||||
revokedAt: null,
|
||||
actor: 'Other moderator',
|
||||
actorId: 'staff-2',
|
||||
revision: 1,
|
||||
active: true
|
||||
}
|
||||
],
|
||||
cases: [],
|
||||
events: [],
|
||||
actions: [],
|
||||
coverage: [
|
||||
{
|
||||
server: 't5_zombie_theater_1',
|
||||
game: 't5',
|
||||
connectedAt: '2026-10-15T12:00:00Z',
|
||||
revision: 1,
|
||||
desiredRevision: 2,
|
||||
state: 'pending',
|
||||
capabilities: ['ban', 'mute']
|
||||
}
|
||||
],
|
||||
total: 1,
|
||||
limit: 25,
|
||||
checkedAt: '2026-10-15T12:00:00Z'
|
||||
};
|
||||
}
|
||||
function show(initial = overview(), role: UserRole = 'moderator', extra = '') {
|
||||
const data = writable({ moderation: initial });
|
||||
const state = fromStore(data);
|
||||
const updatePage = (next: ModerationOverview) =>
|
||||
currentPage.set({
|
||||
url: new URL(
|
||||
`http://localhost/moderation?view=${next.query.view}&game=t5&guid=${guid}${extra}`
|
||||
),
|
||||
data: { user: { id: 'staff-1', role } }
|
||||
});
|
||||
updatePage(initial);
|
||||
component = mount(ModerationPage, {
|
||||
target: document.body,
|
||||
props: {
|
||||
get data() {
|
||||
return state.current;
|
||||
}
|
||||
}
|
||||
});
|
||||
flushSync();
|
||||
return (next: ModerationOverview) => {
|
||||
updatePage(next);
|
||||
data.set({ moderation: next });
|
||||
flushSync();
|
||||
};
|
||||
}
|
||||
const button = (label: string) =>
|
||||
[...document.querySelectorAll('button')].find((node) => node.textContent?.trim() === label)!;
|
||||
const enter = (node: HTMLInputElement | HTMLTextAreaElement, value: string) => {
|
||||
node.value = value;
|
||||
node.dispatchEvent(new Event('input', { bubbles: true }));
|
||||
flushSync();
|
||||
};
|
||||
const submitDialog = () =>
|
||||
document
|
||||
.querySelector('dialog form')!
|
||||
.dispatchEvent(new Event('submit', { bubbles: true, cancelable: true }));
|
||||
|
||||
it('limits moderator restrictions and preserves the decimal identity from player context', async () => {
|
||||
const fetch = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce(new Response(JSON.stringify({ ok: true, id: 'sanction' })))
|
||||
.mockResolvedValue(new Response(JSON.stringify(overview())));
|
||||
vi.stubGlobal('fetch', fetch);
|
||||
show();
|
||||
button('Restrict player').click();
|
||||
await tick();
|
||||
const dialog = document.querySelector('dialog')!;
|
||||
expect(dialog.open).toBe(true);
|
||||
expect(dialog.querySelector<HTMLInputElement>('input[name="target-guid"]')!.value).toBe(guid);
|
||||
expect(dialog.textContent).not.toContain('Permanent');
|
||||
expect(dialog.textContent).not.toContain('Entire fleet');
|
||||
enter(dialog.querySelector('textarea')!, 'Repeated abuse');
|
||||
submitDialog();
|
||||
await vi.waitFor(() => expect(fetch).toHaveBeenCalled());
|
||||
expect(JSON.parse(fetch.mock.calls[0][1].body)).toMatchObject({
|
||||
op: 'create-sanction',
|
||||
guid,
|
||||
game: 't5',
|
||||
reason: 'Repeated abuse',
|
||||
scope: 'game',
|
||||
durationMinutes: 60
|
||||
});
|
||||
expect(JSON.parse(fetch.mock.calls[0][1].body).idempotencyKey).toEqual(expect.any(String));
|
||||
await vi.waitFor(() => expect(navigation.goto).toHaveBeenCalled());
|
||||
const destination = new URL(navigation.goto.mock.calls[0][0], 'http://localhost');
|
||||
expect(destination.searchParams.get('view')).toBe('restrictions');
|
||||
expect(destination.searchParams.get('guid')).toBe(guid);
|
||||
expect(navigation.goto.mock.calls[0][1].invalidateAll).toBe(true);
|
||||
});
|
||||
|
||||
it('keeps unknown submissions immutable and retries the same idempotent request', async () => {
|
||||
const fetch = vi
|
||||
.fn()
|
||||
.mockRejectedValueOnce(new Error('Connection lost'))
|
||||
.mockResolvedValueOnce(new Response(JSON.stringify({ ok: true, id: 'note-1' })))
|
||||
.mockResolvedValue(new Response(JSON.stringify(overview())));
|
||||
vi.stubGlobal('fetch', fetch);
|
||||
show();
|
||||
button('Add private note').click();
|
||||
await tick();
|
||||
enter(document.querySelector('dialog textarea')!, 'Investigate a report');
|
||||
submitDialog();
|
||||
await vi.waitFor(() =>
|
||||
expect(document.body.textContent).toContain('The outcome is not yet known')
|
||||
);
|
||||
expect(document.querySelector('dialog fieldset')?.hasAttribute('disabled')).toBe(true);
|
||||
button('Retry same request').click();
|
||||
await vi.waitFor(() => expect(fetch.mock.calls.length).toBeGreaterThanOrEqual(2));
|
||||
expect(fetch.mock.calls[1][1].body).toBe(fetch.mock.calls[0][1].body);
|
||||
});
|
||||
|
||||
it('retains records on refresh failure and ignores a stale response after navigation', async () => {
|
||||
let resolve!: (response: Response) => void;
|
||||
const fetch = vi
|
||||
.fn()
|
||||
.mockRejectedValueOnce(new Error('offline'))
|
||||
.mockImplementationOnce(
|
||||
() =>
|
||||
new Promise<Response>((done) => {
|
||||
resolve = done;
|
||||
})
|
||||
);
|
||||
vi.stubGlobal('fetch', fetch);
|
||||
const update = show();
|
||||
button('Refresh').click();
|
||||
await vi.waitFor(() =>
|
||||
expect(document.body.textContent).toContain('Moderation updates paused.')
|
||||
);
|
||||
expect(document.querySelector('#moderation-results')?.textContent).toContain('Dempsey');
|
||||
button('Retry').click();
|
||||
navigation.before.forEach((fn) => fn());
|
||||
update(overview('restrictions'));
|
||||
resolve(new Response(JSON.stringify(overview())));
|
||||
await tick();
|
||||
await tick();
|
||||
expect(
|
||||
document.querySelector('#moderation-tab-restrictions')?.getAttribute('aria-selected')
|
||||
).toBe('true');
|
||||
expect(document.querySelector('#moderation-results')?.textContent).toContain('Repeated abuse');
|
||||
});
|
||||
|
||||
it('shows enforcement pending honestly and keeps super viewer access read-only', () => {
|
||||
show(overview('restrictions'), 'super_viewer');
|
||||
expect(document.body.textContent).toContain('Policy pending');
|
||||
expect(document.body.textContent).toContain('0 of 1 servers current');
|
||||
expect(document.body.textContent).toContain('Read-only moderation access');
|
||||
expect(button('Restrict player')).toBeUndefined();
|
||||
expect(button('Revoke restriction')).toBeUndefined();
|
||||
expect(document.querySelector('dialog')).toBeNull();
|
||||
});
|
||||
|
||||
it('does not offer revoking another moderator restriction', () => {
|
||||
show(overview('restrictions'));
|
||||
expect(button('Revoke restriction')).toBeUndefined();
|
||||
});
|
||||
|
||||
it('limits revoke controls to the current role even for restrictions created by that staff member', () => {
|
||||
const initial = overview('restrictions');
|
||||
const own = { ...initial.sanctions[0], actorId: 'staff-1' };
|
||||
initial.sanctions = [
|
||||
{ ...own, id: 'permitted', expiresAt: '2026-10-22T12:00:00Z' },
|
||||
{ ...own, id: 'fleet', scope: 'fleet' },
|
||||
{ ...own, id: 'permanent', expiresAt: null },
|
||||
{ ...own, id: 'longer', expiresAt: '2026-10-23T12:00:00Z' }
|
||||
];
|
||||
show(initial);
|
||||
const controls = () =>
|
||||
[...document.querySelectorAll('button')].filter(
|
||||
(node) => node.textContent?.trim() === 'Revoke restriction'
|
||||
);
|
||||
expect(controls()).toHaveLength(1);
|
||||
currentPage.update((value) => ({
|
||||
...value,
|
||||
data: { user: { id: 'staff-1', role: 'admin' } }
|
||||
}));
|
||||
flushSync();
|
||||
expect(controls()).toHaveLength(4);
|
||||
});
|
||||
|
||||
it('disables unsupported live actions and requires an explicit game for unscoped identities', async () => {
|
||||
const data = overview();
|
||||
data.query.game = '';
|
||||
show(data);
|
||||
button('Live action').click();
|
||||
await tick();
|
||||
const game = document.querySelector<HTMLSelectElement>('dialog select')!;
|
||||
expect(game.value).toBe('');
|
||||
expect(
|
||||
document.querySelector<HTMLButtonElement>('dialog button[type="submit"]')!.disabled
|
||||
).toBe(true);
|
||||
expect(document.querySelector('dialog')!.textContent).toMatch(
|
||||
/Unavailable actions\s+cannot be sent/
|
||||
);
|
||||
});
|
||||
|
||||
it('preserves player and game filters when paging and resets pagination when applying search', () => {
|
||||
const data = overview();
|
||||
data.total = 60;
|
||||
show(data);
|
||||
button('Next').click();
|
||||
let destination = new URL(navigation.goto.mock.calls.at(-1)![0], 'http://localhost');
|
||||
expect(destination.searchParams.get('offset')).toBe('25');
|
||||
expect(destination.searchParams.get('guid')).toBe(guid);
|
||||
expect(destination.searchParams.get('game')).toBe('t5');
|
||||
enter(document.querySelector<HTMLInputElement>('input[name="q"]')!, 'Tank');
|
||||
document
|
||||
.querySelector('form.filters')!
|
||||
.dispatchEvent(new Event('submit', { bubbles: true, cancelable: true }));
|
||||
destination = new URL(navigation.goto.mock.calls.at(-1)![0], 'http://localhost');
|
||||
expect(destination.searchParams.has('offset')).toBe(false);
|
||||
expect(destination.searchParams.get('q')).toBe('Tank');
|
||||
});
|
||||
|
||||
it('copies selected archived chat evidence separately from staff notes', async () => {
|
||||
const fetch = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce(new Response(JSON.stringify({ ok: true, id: 'case' })))
|
||||
.mockResolvedValue(new Response(JSON.stringify(overview())));
|
||||
vi.stubGlobal('fetch', fetch);
|
||||
show(overview(), 'moderator', '&matchId=17&message=abusive%20message');
|
||||
button('Open case').click();
|
||||
await tick();
|
||||
const inputs = document.querySelectorAll<HTMLInputElement>('dialog input');
|
||||
enter(inputs[inputs.length - 1], 'Chat report');
|
||||
enter(document.querySelector('dialog textarea')!, 'Investigate context');
|
||||
submitDialog();
|
||||
await vi.waitFor(() => expect(fetch).toHaveBeenCalled());
|
||||
expect(JSON.parse(fetch.mock.calls[0][1].body)).toMatchObject({
|
||||
op: 'create-case',
|
||||
title: 'Chat report',
|
||||
note: 'Investigate context',
|
||||
evidence: { matchId: 17, message: 'abusive message' }
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user