452 lines
17 KiB
TypeScript
452 lines
17 KiB
TypeScript
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
|
|
});
|
|
});
|
|
});
|