Add AFK account and session overview (Refs #151)
CI / checks (push) Successful in 7m19s

This commit is contained in:
2026-10-17 17:52:03 -04:00
parent d723f23d67
commit 59f88ac33d
22 changed files with 2035 additions and 6 deletions
+320
View File
@@ -0,0 +1,320 @@
import { afterEach, beforeEach, expect, it, vi } from 'vitest';
import { flushSync, mount, tick, unmount } from 'svelte';
import { fromStore, writable } from 'svelte/store';
import AfkPage from '../../src/routes/afk/+page.svelte';
import { readAfkQuery, type AfkOverview } from '../../src/lib/afk.js';
const navigation = vi.hoisted(() => ({ goto: vi.fn(), before: [] as (() => void)[] }));
const currentPage = writable({ url: new URL('http://localhost/afk'), data: { user: null } });
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),
afterNavigate: vi.fn()
}));
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.useRealTimers();
vi.unstubAllGlobals();
vi.restoreAllMocks();
});
const guid = '18446744073709551615';
const server = 't5_zombie_theater_1';
const checkedAt = '2026-10-17T12:00:00.000Z';
function report(search = ''): AfkOverview {
const query = readAfkQuery(new URLSearchParams(search));
return {
query,
checkedAt,
capacitySeconds: 21600,
summary: { accounts: 1, savedSeconds: 7200, reservedSeconds: 600, unresolvedSessions: 1 },
servers: [server],
accounts: {
entries: [
{
guid,
name: 'Dempsey',
freeSeconds: 1200,
savedSeconds: 7200,
availableSeconds: 8400,
reservedSeconds: 600,
reservedSavedSeconds: 300,
unresolvedSessions: 1,
lastActivity: checkedAt,
freeRenewsAt: null
}
],
total: 1,
offset: query.offset
},
sessions: {
entries: [
{
guid,
name: 'Dempsey',
sessionId: 'crash-session',
server,
game: 't5',
matchId: 17,
matchAvailable: 0,
state: 'corrected',
authorizedSeconds: 600,
consumedSeconds: null,
chargedSeconds: 45,
confirmedSeconds: 40,
endReason: 'crash',
createdAt: '2026-10-17T11:00:00.000Z',
startedAt: '2026-10-17T11:00:00.000Z',
endedAt: '2026-10-17T11:00:45.000Z'
}
],
total: 1,
offset: query.offset
},
ledger: {
entries: [
{
guid,
name: 'Dempsey',
id: 1,
server,
game: 't5',
matchId: 17,
matchAvailable: 0,
kind: 'purchase',
freeDelta: 0,
savedDelta: 600,
freeSeconds: 1200,
savedSeconds: 7200,
operationId: 'purchase-1',
sessionId: null,
eventAt: checkedAt,
createdAt: checkedAt
}
],
total: 1,
offset: query.offset
}
};
}
function show(initial = report()) {
const data = writable({ report: initial, user: null, setupRequired: false });
const state = fromStore(data);
const updatePage = (next: AfkOverview) =>
currentPage.set({
url: new URL(
`http://localhost/afk?${new URLSearchParams(Object.entries(next.query).map(([key, value]) => [key, String(value)]))}`
),
data: { user: null }
});
updatePage(initial);
component = mount(AfkPage, {
target: document.body,
props: {
get data() {
return state.current;
}
}
});
flushSync();
return (next: AfkOverview) => {
updatePage(next);
data.set({ report: next, user: null, setupRequired: false });
flushSync();
};
}
const button = (label: string) =>
[...document.querySelectorAll('button')].find((node) => node.textContent?.trim() === label)!;
const lastNavigation = () => new URL(navigation.goto.mock.calls.at(-1)![0], 'http://localhost');
const enter = (name: string, value: string) => {
const input = document.querySelector<HTMLInputElement>(`input[name="${name}"]`)!;
input.value = value;
input.dispatchEvent(new Event('input', { bubbles: true }));
flushSync();
};
const select = (name: string, value: string) => {
const node = document.querySelector<HTMLSelectElement>(`select[name="${name}"]`)!;
// happy-dom's :checked selector only matches inputs, not selected options.
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 submit = () =>
document
.querySelector('form')!
.dispatchEvent(new Event('submit', { bubbles: true, cancelable: true }));
it('links the exact account identity to its profile, sessions and ledger', () => {
show(report('q=Dempsey&sort=recent&offset=25'));
const panel = document.querySelector('#afk-results')!;
const links = [...panel.querySelectorAll('a')];
const profile = links.find((link) => link.textContent === 'Dempsey')!;
const profileUrl = new URL(profile.getAttribute('href')!, 'http://localhost');
expect(profileUrl.pathname).toBe(`/leaderboard/players/${guid}`);
expect(profileUrl.searchParams.get('returnTo')).toContain('q=Dempsey');
expect(profileUrl.searchParams.get('returnTo')).toContain('offset=25');
for (const [label, view] of [
['Sessions', 'sessions'],
['Time ledger', 'ledger']
]) {
const history = links.find((link) => link.textContent === label)!;
const historyUrl = new URL(history.getAttribute('href')!, 'http://localhost');
expect(historyUrl.pathname).toBe('/afk');
expect(historyUrl.searchParams.get('view')).toBe(view);
expect(historyUrl.searchParams.get('player')).toBe(guid);
expect(historyUrl.searchParams.has('q')).toBe(false);
expect(historyUrl.searchParams.has('offset')).toBe(false);
}
// Held saved time still occupies account capacity.
expect(panel.querySelector('meter')!.getAttribute('value')).toBe('7500');
expect(panel.textContent).toContain('Unresolved session');
});
it('applies session filters without losing an exact player and resets pagination', () => {
show(report(`view=sessions&offset=25&player=${guid}`));
enter('q', 'Nikolai');
select('game', 't6');
select('state', 'unresolved');
enter('from', '2026-10-01');
enter('to', '2026-10-17');
submit();
expect(lastNavigation().searchParams.get('q')).toBe('Nikolai');
expect(lastNavigation().searchParams.get('game')).toBe('t6');
expect(lastNavigation().searchParams.get('state')).toBe('unresolved');
expect(lastNavigation().searchParams.get('from')).toBe('2026-10-01');
expect(lastNavigation().searchParams.get('to')).toBe('2026-10-17');
expect(lastNavigation().searchParams.get('player')).toBe(guid);
expect(lastNavigation().searchParams.has('offset')).toBe(false);
button('Reset').click();
expect(lastNavigation().searchParams.get('view')).toBe('sessions');
for (const key of ['q', 'game', 'state', 'from', 'to', 'player', 'offset'])
expect(lastNavigation().searchParams.has(key)).toBe(false);
});
it('keeps the exact player across tabs and pagination', () => {
const update = show(report(`q=Dempsey&player=${guid}&offset=25`));
document.querySelector<HTMLButtonElement>('#afk-tab-sessions')!.click();
expect(lastNavigation().searchParams.get('view')).toBe('sessions');
expect(lastNavigation().searchParams.get('q')).toBe('Dempsey');
expect(lastNavigation().searchParams.get('player')).toBe(guid);
expect(lastNavigation().searchParams.has('offset')).toBe(false);
const next = report(`view=sessions&player=${guid}&state=unresolved&game=t5`);
next.sessions.total = 30;
update(next);
button('Next').click();
expect(lastNavigation().searchParams.get('offset')).toBe('25');
expect(lastNavigation().searchParams.get('player')).toBe(guid);
expect(lastNavigation().searchParams.get('state')).toBe('unresolved');
expect(lastNavigation().searchParams.get('game')).toBe('t5');
});
it('opens unresolved sessions across the fleet instead of retaining list filters', () => {
show(report(`view=ledger&player=${guid}&q=Dempsey&game=t5&kind=purchase&offset=25`));
document.querySelector<HTMLButtonElement>('.summary button')!.click();
const destination = lastNavigation();
expect(destination.searchParams.get('view')).toBe('sessions');
expect(destination.searchParams.get('state')).toBe('unresolved');
for (const key of ['player', 'q', 'game', 'kind', 'offset'])
expect(destination.searchParams.has(key)).toBe(false);
});
it('keeps corrected charges distinct from confirmed time and avoids expired match links', () => {
const update = show(report('view=sessions'));
const panel = document.querySelector('#afk-results')!;
expect(panel.textContent).toContain('Unconfirmed duration');
expect(panel.textContent).toContain('Corrected charge: 45s');
expect(panel.textContent).toContain('Confirmed at least 40s');
expect(panel.textContent).not.toContain('45s used');
expect(panel.textContent).toContain('Match #17 · History expired');
expect(panel.querySelector('a[href*="matches/17"]')).toBeNull();
update(report('view=ledger'));
expect(panel.textContent).toContain('purchase-1');
expect(panel.textContent).toContain('+10m 00s');
expect(panel.textContent).toContain('Match #17 · History expired');
expect(panel.querySelector('a[href*="matches/17"]')).toBeNull();
});
it('retains the last snapshot and draft filters after a refresh failure and retries', async () => {
const fetch = vi
.fn()
.mockRejectedValueOnce(new Error('offline'))
.mockResolvedValueOnce(new Response(JSON.stringify(report())));
vi.stubGlobal('fetch', fetch);
show();
enter('q', 'draft');
const before = document.querySelector('.summary')!.textContent;
button('Refresh').click();
await vi.waitFor(() => expect(document.body.textContent).toContain('AFK updates paused.'));
expect(document.querySelector('.summary')!.textContent).toBe(before);
button('Retry').click();
await vi.waitFor(() => expect(document.body.textContent).not.toContain('AFK updates paused.'));
expect(document.querySelector<HTMLInputElement>('input[name="q"]')!.value).toBe('draft');
expect(fetch.mock.calls[0][0]).toContain('/api/afk?');
});
it('discards an old refresh after navigation changes the AFK view', async () => {
let resolve!: (response: Response) => void;
vi.stubGlobal(
'fetch',
vi.fn(
() =>
new Promise<Response>((done) => {
resolve = done;
})
)
);
const update = show();
button('Refresh').click();
navigation.before.forEach((fn) => fn());
const next = report('view=ledger');
next.summary.accounts = 42;
update(next);
resolve(new Response(JSON.stringify(report())));
await tick();
await tick();
expect(document.querySelector('.summary')?.textContent).toContain('42');
expect(document.querySelector('#afk-tab-ledger')?.getAttribute('aria-selected')).toBe('true');
});
it('polls after 15 seconds, pauses while hidden, and refreshes when visible again', async () => {
vi.useFakeTimers();
let visibility: DocumentVisibilityState = 'visible';
vi.spyOn(document, 'visibilityState', 'get').mockImplementation(() => visibility);
const fetch = vi.fn(() => Promise.resolve(new Response(JSON.stringify(report()))));
vi.stubGlobal('fetch', fetch);
show();
await vi.advanceTimersByTimeAsync(14_999);
expect(fetch).not.toHaveBeenCalled();
await vi.advanceTimersByTimeAsync(1);
expect(fetch).toHaveBeenCalledTimes(1);
visibility = 'hidden';
document.dispatchEvent(new Event('visibilitychange'));
await vi.advanceTimersByTimeAsync(60_000);
expect(fetch).toHaveBeenCalledTimes(1);
visibility = 'visible';
document.dispatchEvent(new Event('visibilitychange'));
await tick();
expect(fetch).toHaveBeenCalledTimes(2);
});
+451
View File
@@ -0,0 +1,451 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import type Database from 'better-sqlite3';
import { readAfkQuery, type AfkQuery, type AfkSessionState } from '../../src/lib/afk.js';
import { newFixture, type Fixture } from '../helpers/fixture.js';
let fixture: Fixture;
let handle: Database.Database;
let overview: typeof import('../../src/lib/server/fleet/afk-overview.js').afkOverview;
const now = new Date('2026-10-17T12:00:00.000Z');
const day = 86_400_000;
const server = 't5_zombie_theater_1';
const other = 't6_zm_transit_1';
const guid = '18446744073709551615';
const query = (changes: Partial<AfkQuery> = {}) => ({
...readAfkQuery(new URLSearchParams()),
...changes
});
beforeEach(async () => {
vi.resetModules();
fixture = await newFixture([]);
vi.stubEnv('EC_AFK_FREE_SECONDS', '1800');
vi.stubEnv('EC_AFK_CAPACITY_SECONDS', '21600');
handle = (await import('../../src/lib/server/db/index.js')).db();
overview = (await import('../../src/lib/server/fleet/afk-overview.js')).afkOverview;
});
afterEach(async () => {
await fixture.cleanup();
vi.unstubAllEnvs();
});
function identity(id = guid, name = 'Dempsey', game = 't5', seen = '2026-10-17T10:00:00.000Z') {
handle
.prepare(
'INSERT INTO leaderboard_players(game,guid,name,first_seen_at,last_seen_at) VALUES (?,?,?,?,?)'
)
.run(game, id, name, seen, seen);
}
function account(id = guid, saved = 400, cachedFree = 0) {
handle
.prepare(
'INSERT INTO afk_accounts(guid,free_seconds,saved_seconds,free_started_at) VALUES (?,?,?,?)'
)
.run(id, cachedFree, saved, now.getTime() - day);
}
let serial = 0;
function session(
options: {
guid?: string;
sessionId?: string;
server?: string;
state?: AfkSessionState;
free?: number;
saved?: number;
consumed?: number | null;
charged?: number | null;
confirmed?: number;
chargeAt?: number | null;
createdAt?: string;
matchId?: number | null;
} = {}
) {
const id = options.sessionId ?? `session-${serial++}`;
const state = options.state ?? 'settled';
const held = ['reserved', 'started', 'unresolved'].includes(state);
const free = options.free ?? 100;
const saved = options.saved ?? 0;
const createdAt = options.createdAt ?? now.toISOString();
const consumed =
options.consumed === undefined ? (held ? null : free + saved) : options.consumed;
const charged = options.charged === undefined ? consumed : options.charged;
const chargeAt =
options.chargeAt === undefined ? (held ? null : now.getTime()) : options.chargeAt;
handle
.prepare(
`INSERT INTO afk_sessions(session_id,server,instance_id,guid,match_key,match_id,state,
authorized_seconds,free_seconds,saved_seconds,free_started_at,consumed_seconds,charged_seconds,
free_charge_at,confirmed_seconds,end_reason,created_at,started_at,ended_at)
VALUES (?,?,'private-instance',?,'private-match-key',?,?,?,?,?,?,?,?,?,?,?,?,?,?)`
)
.run(
id,
options.server ?? server,
options.guid ?? guid,
options.matchId ?? null,
state,
free + saved,
free,
saved,
now.getTime() - day,
consumed,
charged,
chargeAt,
options.confirmed ?? 0,
state === 'unresolved' || state === 'corrected' ? 'crash' : held ? null : 'manual',
createdAt,
state === 'reserved' ? null : createdAt,
state === 'reserved' || state === 'started' ? null : createdAt
);
return id;
}
function ledger(
options: {
guid?: string;
server?: string;
kind?: string;
savedDelta?: number;
createdAt?: string;
eventAt?: string;
matchId?: number | null;
sessionId?: string | null;
} = {}
) {
return handle
.prepare(
`INSERT INTO afk_ledger(guid,server,match_id,operation_id,session_id,kind,
free_delta,saved_delta,free_seconds,saved_seconds,event_at,created_at,details)
VALUES (?,?,?,?,?,?,0,?,1800,400,?,?,'{"secret":"private-details"}')`
)
.run(
options.guid ?? guid,
options.server ?? server,
options.matchId ?? null,
`operation-${serial++}`,
options.sessionId ?? null,
options.kind ?? 'reward',
options.savedDelta ?? 60,
options.eventAt ?? now.toISOString(),
options.createdAt ?? now.toISOString()
).lastInsertRowid;
}
it('shares exact string GUID accounts across games and selects the latest player name', () => {
identity();
identity(guid, 'Latest name', 't6', '2026-10-17T11:00:00.000Z');
identity('00042', 'Leading zero');
identity('42', 'Separate account');
account();
account('00042', 100);
account('42', 0);
const report = overview(query(), now);
expect(report.accounts.total).toBe(3);
expect(report.accounts.entries[0]).toMatchObject({
guid,
name: 'Latest name',
freeSeconds: 1800,
savedSeconds: 400,
availableSeconds: 2200,
reservedSeconds: 0,
reservedSavedSeconds: 0,
unresolvedSessions: 0,
freeRenewsAt: null
});
expect(
overview(query({ player: '42' }), now).accounts.entries.map((row) => row.savedSeconds)
).toEqual([0]);
expect(
overview(query({ player: '00042' }), now).accounts.entries.map((row) => row.savedSeconds)
).toEqual([100]);
expect(overview(query({ player: 'unknown' }), now).accounts.total).toBe(0);
expect(report.summary).toEqual({
accounts: 3,
savedSeconds: 500,
reservedSeconds: 0,
unresolvedSessions: 0
});
});
it('computes rolling free availability from durable sessions without refreshing balances or granting time', () => {
account();
session({ state: 'started', free: 300, saved: 200, createdAt: '2026-10-14T12:00:00.000Z' });
session({ free: 600, consumed: 400, chargeAt: now.getTime() - 4 * 3_600_000 });
session({ free: 900, consumed: 900, chargeAt: now.getTime() - day });
session({ free: 300, consumed: 300, chargeAt: now.getTime() - day - 1 });
const before = handle.prepare('SELECT * FROM afk_accounts').all();
const report = overview(query(), now);
expect(report.accounts.entries[0]).toMatchObject({
freeSeconds: 1100,
savedSeconds: 400,
availableSeconds: 1500,
reservedSeconds: 500,
reservedSavedSeconds: 200,
freeRenewsAt: '2026-10-18T08:00:00.000Z'
});
expect(overview(query(), new Date(now.getTime() + day)).accounts.entries[0]).toMatchObject({
freeSeconds: 1500,
reservedSeconds: 500,
freeRenewsAt: null
});
expect(handle.prepare('SELECT * FROM afk_accounts').all()).toEqual(before);
expect(handle.prepare('SELECT COUNT(*) AS total FROM afk_ledger').get()).toEqual({ total: 0 });
expect(handle.prepare('SELECT COUNT(*) AS total FROM afk_operations').get()).toEqual({
total: 0
});
});
it('preserves unresolved holds and distinguishes accounting corrections from confirmed consumption', () => {
account();
account('corrected', 21000);
session({
state: 'unresolved',
free: 1000,
saved: 300,
confirmed: 250,
createdAt: '2026-10-01T00:00:00.000Z'
});
session({
guid: 'corrected',
state: 'corrected',
free: 800,
consumed: null,
charged: 500,
confirmed: 200
});
expect(overview(query({ balance: 'unresolved' }), now).accounts.entries[0]).toMatchObject({
guid,
freeSeconds: 800,
savedSeconds: 400,
reservedSeconds: 1300,
reservedSavedSeconds: 300,
unresolvedSessions: 1,
freeRenewsAt: null
});
expect(overview(query({ player: 'corrected' }), now).accounts.entries[0]).toMatchObject({
freeSeconds: 1300,
savedSeconds: 21000,
reservedSeconds: 0
});
const report = overview(query({ view: 'sessions', state: 'corrected' }), now);
expect(report.sessions.entries[0]).toMatchObject({
state: 'corrected',
consumedSeconds: null,
chargedSeconds: 500,
confirmedSeconds: 200
});
expect(report.summary).toEqual({
accounts: 2,
savedSeconds: 21400,
reservedSeconds: 1300,
unresolvedSessions: 1
});
expect(report.capacitySeconds).toBe(21600);
});
it('searches literally, filters and sorts accounts while retaining fleet-wide summary totals', () => {
identity();
identity('other', '100%_Player');
account();
account('other', 800);
account('empty', 0);
session({ guid: 'other', state: 'reserved', free: 1800, saved: 200 });
ledger({ createdAt: '2026-10-17T11:00:00.000Z' });
ledger({ guid: 'empty', createdAt: '2026-10-17T12:01:00.000Z' });
expect(overview(query({ q: '%_' }), now).accounts.entries.map((row) => row.guid)).toEqual([
'other'
]);
expect(overview(query({ q: "' OR 1=1 --" }), now).accounts.total).toBe(0);
expect(overview(query({ q: 'dEmP' }), now).accounts.entries[0].guid).toBe(guid);
expect(
overview(query({ balance: 'saved' }), now).accounts.entries.map((row) => row.guid)
).toEqual(['other', guid]);
expect(
overview(query({ balance: 'reserved' }), now).accounts.entries.map((row) => row.guid)
).toEqual(['other']);
expect(
overview(query({ sort: 'available' }), now).accounts.entries.map((row) => row.guid)
).toEqual([guid, 'empty', 'other']);
expect(
overview(query({ sort: 'recent' }), now).accounts.entries.map((row) => row.guid)
).toEqual(['empty', 'other', guid]);
expect(overview(query({ q: 'not-found' }), now).summary).toEqual({
accounts: 3,
savedSeconds: 1200,
reservedSeconds: 2000,
unresolvedSessions: 0
});
});
it('retains historical sessions and ledger entries after telemetry matches and identities are removed', () => {
identity();
account();
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 (?,'t5','theater','theater','Kino',?,?,'game_start','private-fingerprint')`
)
.run(server, now.toISOString(), now.toISOString()).lastInsertRowid
);
const sessionId = session({ matchId });
ledger({ matchId, sessionId });
expect(overview(query({ view: 'sessions' }), now).sessions.entries[0].matchAvailable).toBe(1);
expect(overview(query({ view: 'ledger' }), now).ledger.entries[0].matchAvailable).toBe(1);
handle.prepare('DELETE FROM leaderboard_matches WHERE id = ?').run(matchId);
handle.prepare('DELETE FROM leaderboard_players WHERE guid = ?').run(guid);
for (const view of ['sessions', 'ledger'] as const) {
const report = overview(query({ view, game: 't5', q: guid, player: guid }), now);
expect(report[view].entries[0]).toMatchObject({
guid,
name: null,
matchId,
matchAvailable: 0,
game: 't5'
});
expect(JSON.stringify(report)).not.toMatch(
/private-instance|private-match-key|private-fingerprint|private-details|instance_id|details/
);
expect(report.servers).toEqual([server]);
}
expect(overview(query(), now).accounts.entries[0].savedSeconds).toBe(400);
});
it('filters session and ledger history by game, server, state or kind, exact GUID and inclusive UTC receipt dates', () => {
account();
const dates = [
'2026-10-16T23:59:59.999Z',
'2026-10-17T00:00:00.000Z',
'2026-10-17T23:59:59.999Z',
'2026-10-18T00:00:00.000Z'
];
for (const createdAt of dates) {
session({ server: other, createdAt });
ledger({ server: other, createdAt, eventAt: '2026-10-16T12:00:00.000Z', kind: 'purchase' });
}
session({ guid: 'someone-else', server: other });
ledger({ guid: 'someone-else', server: other });
session({ state: 'reserved', server });
ledger({ server, kind: 'reserve' });
for (const view of ['sessions', 'ledger'] as const) {
const report = overview(
query({
view,
game: 't6',
server: other,
player: guid,
from: '2026-10-17',
to: '2026-10-17'
}),
now
);
expect(report[view].entries.map((row) => row.createdAt)).toEqual([dates[2], dates[1]]);
expect(overview(query({ view, server: 'missing' }), now)[view].total).toBe(0);
expect(overview(query({ view, player: guid }), now)[view].total).toBe(5);
expect(overview(query({ view, player: 'unknown' }), now)[view].total).toBe(0);
expect(overview(query({ view, game: 't5' }), now)[view].total).toBe(1);
}
expect(overview(query({ view: 'sessions', state: 'reserved' }), now).sessions.total).toBe(1);
expect(overview(query({ view: 'ledger', kind: 'purchase' }), now).ledger.total).toBe(4);
expect(
overview(query({ view: 'ledger' }), now).ledger.entries.every(
(row) => typeof row.operationId === 'string'
)
).toBe(true);
});
it('paginates every view deterministically and clamps stale offsets', () => {
for (let i = 0; i < 31; i++) {
const id = String(i).padStart(2, '0');
account(id, 100);
session({ guid: id });
ledger({ guid: id });
}
for (const view of ['accounts', 'sessions', 'ledger'] as const) {
const first = overview(query({ view }), now)[view];
const second = overview(query({ view, offset: 999999 }), now)[view];
expect(first.entries).toHaveLength(25);
expect(second).toMatchObject({ total: 31, offset: 25 });
expect(second.entries).toHaveLength(6);
expect(new Set([...first.entries, ...second.entries].map((row) => row.guid)).size).toBe(31);
expect(overview(query({ view, q: 'absent', offset: 999999 }), now)[view]).toEqual({
entries: [],
total: 0,
offset: 0
});
}
});
it('updates free allowance calculations without changing retained saved balances or reservations', () => {
account();
session({ state: 'reserved', free: 1000, saved: 200 });
vi.stubEnv('EC_AFK_FREE_SECONDS', '300');
vi.stubEnv('EC_AFK_CAPACITY_SECONDS', '100');
expect(overview(query(), now)).toMatchObject({
capacitySeconds: 100,
accounts: {
entries: [
expect.objectContaining({
freeSeconds: 0,
savedSeconds: 400,
reservedSeconds: 1200,
reservedSavedSeconds: 200
})
]
}
});
});
it('serves an empty read-only report and uncached API, rejects invalid filters in both routes', async () => {
identity();
const { GET } = await import('../../src/routes/api/afk/+server.js');
const { load } = await import('../../src/routes/afk/+page.server.js');
const url = new URL('https://test/afk');
const response = GET({ url });
expect(response.headers.get('cache-control')).toBe('no-store');
expect((await response.json()).summary).toEqual({
accounts: 0,
savedSeconds: 0,
reservedSeconds: 0,
unresolvedSessions: 0
});
expect(load({ url }).report.accounts.total).toBe(0);
expect(handle.prepare('SELECT COUNT(*) AS total FROM afk_accounts').get()).toEqual({
total: 0
});
for (const invalid of ['view=unknown', 'state=unknown', 'offset=-1', 'from=2026-02-30']) {
expect(() => GET({ url: new URL(`https://test/api/afk?${invalid}`) })).toThrow(
expect.objectContaining({ status: 400 })
);
expect(() => load({ url: new URL(`https://test/afk?${invalid}`) })).toThrow(
expect.objectContaining({ status: 400 })
);
}
});
describe('AFK query validation', () => {
it.each([
'view=nope',
'balance=nope',
'game=t7',
'sort=nope',
'state=crash',
'kind=secret',
'offset=NaN',
'offset=1.5',
'offset=Infinity',
'from=2026-02-30',
'to=2026-13-01',
'from=2026-10-18&to=2026-10-17',
`q=${'a'.repeat(101)}`,
`server=${'a'.repeat(257)}`,
`player=${'a'.repeat(129)}`
])('rejects %s', (value) => {
expect(() => readAfkQuery(new URLSearchParams(value))).toThrow();
});
it('retains string GUIDs and normalizes page boundaries', () => {
expect(readAfkQuery(new URLSearchParams(`player=${guid}&offset=27`))).toMatchObject({
player: guid,
offset: 25
});
});
});
+17
View File
@@ -102,6 +102,8 @@ describe('management route permissions', () => {
'/metrics',
'/bank',
'/api/bank',
'/afk',
'/api/afk',
'/api/matches',
'/matches/[id]',
'/leaderboard/players/[game]/[guid]',
@@ -128,6 +130,21 @@ describe('management route permissions', () => {
expect(allows('viewer', route, 'HEAD')).toBe(true);
});
it('keeps AFK reporting separate from game credentials and administrator corrections', () => {
for (const route of ['/afk', '/api/afk']) {
for (const method of ['GET', 'HEAD']) {
expect(routePermission(route, method)).toBe('viewer');
for (const role of roles) expect(allows(role, route, method)).toBe(true);
}
}
expect(routePermission('/api/afk/[action]', 'POST')).toBe('machine');
for (const role of roles) {
expect(allows(role, '/api/afk/[action]', 'POST')).toBe(false);
expect(allows(role, '/api/afk/corrections', 'POST')).toBe(role === 'admin');
}
expect(routePermission('/api/afk/corrections', 'POST')).toBe('admin');
});
it.each([
['/api/servers', 'POST'],
['/api/servers/[name]', 'DELETE'],
+9
View File
@@ -8,6 +8,15 @@ describe('navigation context', () => {
expect(safeReturnHref(target.searchParams.get('returnTo'), '/')).toBe(source);
expect(returnLabel(source)).toBe('Back to Bank');
});
it('restores AFK history filters and pagination after opening a player or match', () => {
const source =
'/afk?view=sessions&game=t6&server=town&state=unresolved&from=2026-10-01&offset=25';
for (const destination of ['/leaderboard/players/00042', '/matches/17']) {
const target = new URL(contextHref(destination, source), 'http://localhost');
expect(safeReturnHref(target.searchParams.get('returnTo'), '/')).toBe(source);
expect(returnLabel(source)).toBe('Back to AFK');
}
});
it('retains nested origins, list scope, loaded results and anchors', () => {
const source = new URL(
'http://localhost/leaderboard/players/42?game=t5&offset=25&returnTo=%2Fleaderboard%3Fq%3DAce#history-title'
+9
View File
@@ -20,6 +20,7 @@ describe('application navigation', () => {
['/configs/42', 'releases', '/configs'],
['/leaderboard/players/42', 'players', '/leaderboard'],
['/bank', 'players', '/bank'],
['/afk', 'players', '/afk'],
['/moderation', 'players', '/moderation'],
['/settings/users', 'admin', '/settings/users'],
['/server-keys', 'admin', '/server-keys']
@@ -62,6 +63,14 @@ describe('application navigation', () => {
expect(navigationLocation('/server-keys', 'super_viewer')?.item.shortcut).toBe('k');
});
it('keeps AFK beside Bank for every signed-in role', () => {
for (const role of ['viewer', 'super_viewer', 'operator', 'moderator', 'admin'] as const) {
const items = visibleNavigation(role).find((group) => group.id === 'players')!.items;
const bankIndex = items.findIndex((item) => item.href === '/bank');
expect(items[bankIndex + 1]).toMatchObject({ label: 'AFK', href: '/afk' });
}
});
it('reserves private moderation navigation for moderators, administrators, and Super Viewers', () => {
for (const role of ['admin', 'moderator', 'super_viewer'] as const)
expect(navigationLocation('/moderation', role)?.group.id).toBe('players');
+3 -1
View File
@@ -50,7 +50,9 @@ it('separates active play, confirmed usage and corrected crash charges without l
}
]
};
const { body } = render(AfkReserveCard, { props: { account } });
const { body } = render(AfkReserveCard, { props: { account, guid: '18446744073709551615' } });
expect(body).toContain('href="/afk?view=ledger&amp;player=18446744073709551615"');
expect(body).toContain('View AFK history');
expect(body).toContain('Recorded active play');
expect(body).toContain('Confirmed AFK time');
expect(body).toContain('Unconfirmed');
+8
View File
@@ -36,10 +36,18 @@ describe('sidebar navigation', () => {
expect(body).toContain('href="/maintenance"');
expect(body).toContain('href="/metrics"');
expect(body).toContain('href="/bank"');
expect(body).toContain('href="/afk"');
expect(body).not.toContain('href="/settings"');
expect(body).not.toContain('href="/server-keys"');
});
it('marks the AFK overview active for viewers', () => {
currentPage.set({ url: new URL('http://localhost/afk?view=sessions'), data: {} });
const body = render(Navigation, { props: { role: 'viewer' } }).body;
expect(body).toMatch(/<a[^>]*href="\/afk"[^>]*aria-current="page"/);
expect(body).not.toMatch(/<a[^>]*href="\/bank"[^>]*aria-current="page"/);
});
it('does not show invented attention counts before the fleet snapshot loads', () => {
expect(render(Navigation, { props: { role: 'admin' } }).body).not.toMatch(
/needs? attention/