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 = {}) => ({ ...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 }); }); });