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
+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
});
});
});