This commit is contained in:
@@ -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);
|
||||
});
|
||||
@@ -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
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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'],
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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&player=18446744073709551615"');
|
||||
expect(body).toContain('View AFK history');
|
||||
expect(body).toContain('Recorded active play');
|
||||
expect(body).toContain('Confirmed AFK time');
|
||||
expect(body).toContain('Unconfirmed');
|
||||
|
||||
@@ -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/
|
||||
|
||||
Reference in New Issue
Block a user