import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import type Database from 'better-sqlite3'; import { readBankQuery, type BankQuery } from '../../src/lib/bank.js'; import { newFixture, type Fixture } from '../helpers/fixture.js'; let fixture: Fixture; let handle: Database.Database; let overview: typeof import('../../src/lib/server/fleet/bank-overview.js').bankOverview; const now = new Date('2026-10-13T12:00:00.000Z'); const server = 't5_zombie_theater_1'; const other = 't6_zm_transit_1'; const guid = '18446744073709551615'; const query = (changes: Partial = {}) => ({ ...readBankQuery(new URLSearchParams()), ...changes }); beforeEach(async () => { vi.resetModules(); fixture = await newFixture([]); vi.stubEnv('EC_BANK_MAX_BALANCE', '250000'); handle = (await import('../../src/lib/server/db/index.js')).db(); overview = (await import('../../src/lib/server/fleet/bank-overview.js')).bankOverview; }); afterEach(async () => { await fixture.cleanup(); vi.unstubAllEnvs(); }); function identity(id = guid, name = 'Dempsey', game = 't5', seen = '2026-10-13T10: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 fund(id = guid, balance = 2000) { handle.prepare('INSERT INTO bank_accounts(guid,balance) VALUES (?,?)').run(id, balance); } function transfer( id = guid, amount = 2000, source = server, createdAt = now.toISOString(), matchId: number | null = null ) { return handle .prepare( 'INSERT INTO bank_ledger(guid,server,request_id,match_id,amount,balance,created_at) VALUES (?,?,?,?,?,?,?)' ) .run(id, source, `ledger-${serial++}`, matchId, amount, 2000, createdAt).lastInsertRowid; } let serial = 0; function prepare( id = guid, operation = 'withdraw', amount = 500, createdAt = '2026-10-13T11:50:00.000Z' ) { handle .prepare( `INSERT INTO bank_requests(server,request_id,guid,fingerprint,operation,amount,state,result,created_at) VALUES (?,?,?,'private-fingerprint',?,?,'prepared','private-result',?)` ) .run(server, `pending-${serial++}`, id, operation, amount, createdAt); } it('deduplicates game identities, preserves exact GUIDs and exposes reserved withdrawal funds', () => { identity(); identity(guid, 'Latest name', 't6', '2026-10-13T11:00:00.000Z'); identity('00042', 'Leading zero'); identity('42', 'Separate account'); fund(); fund('00042', 100); fund('42', 0); transfer(); prepare(); const report = overview(query({ player: guid }), now); expect(report.accounts.total).toBe(3); expect(report.accounts.entries[0]).toMatchObject({ guid, name: 'Latest name', balance: 2000, available: 1500, pendingAmount: 500 }); expect(report.detail?.account).toEqual(report.accounts.entries[0]); expect(report.summary).toEqual({ totalBanked: 2100, fundedAccounts: 2, transfers24h: 1, pending: 1 }); expect(overview(query({ player: '42' }), now).detail?.account.balance).toBe(0); expect(overview(query({ player: '00042' }), now).detail?.account.balance).toBe(100); }); it('includes first deposits awaiting settlement without crediting them to balances', () => { identity(); prepare(guid, 'deposit', 5000); const report = overview(query({ balance: 'pending', player: guid }), now); expect(report.accounts.entries).toHaveLength(1); expect(report.accounts.entries[0]).toMatchObject({ balance: 0, available: 0, pendingOperation: 'deposit', lastActivity: null }); expect(report.summary).toMatchObject({ totalBanked: 0, fundedAccounts: 0, pending: 1, transfers24h: 0 }); expect(report.detail?.pending).toMatchObject({ amount: 5000, operation: 'deposit' }); expect(report.detail?.history.total).toBe(0); }); it('filters and sorts accounts with literal searches and keeps summaries fleet-wide', () => { identity(); identity('other', '100%_Player'); fund(); fund('other', 250000); transfer(guid, 2000, server, '2026-10-13T11:00:00.000Z'); transfer('other', 1000, other, '2026-10-12T10:00: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: 'limit' }), now).accounts.entries.map((row) => row.guid) ).toEqual(['other']); expect( overview(query({ sort: 'recent' }), now).accounts.entries.map((row) => row.guid) ).toEqual([guid, 'other']); expect(overview(query({ q: 'not-found' }), now).summary).toMatchObject({ totalBanked: 252000, transfers24h: 1 }); }); it('filters transfer activity by game, server, direction and inclusive UTC dates without narrowing player balances', () => { identity(); fund(); transfer(guid, 3000, server, '2026-10-12T23:59:59.999Z'); transfer(guid, -1000, other, '2026-10-13T00:00:00.000Z'); transfer(guid, 500, other, '2026-10-13T23:59:59.999Z'); transfer(guid, 100, other, '2026-10-14T00:00:00.000Z'); const report = overview( query({ view: 'transactions', game: 't6', server: other, from: '2026-10-13', to: '2026-10-13', player: guid }), now ); expect(report.transactions.entries.map((row) => row.amount)).toEqual([500, -1000]); expect(report.detail?.account.balance).toBe(2000); expect(report.detail?.history.total).toBe(4); expect( overview( query({ view: 'transactions', operation: 'withdraw' }), now ).transactions.entries.map((row) => row.amount) ).toEqual([-1000]); expect(overview(query({ view: 'transactions', game: 't5' }), now).transactions.total).toBe(1); expect( overview(query({ view: 'transactions', server: 'absent' }), now).transactions.total ).toBe(0); }); it('keeps historical transfers searchable after match and player records are removed', () => { identity(); fund(); 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','fingerprint')` ) .run(server, now.toISOString(), now.toISOString()).lastInsertRowid ); transfer(guid, 2000, server, now.toISOString(), matchId); expect( overview(query({ view: 'transactions' }), now).transactions.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); const report = overview( query({ view: 'transactions', game: 't5', q: guid, player: guid }), now ); expect(report.transactions.entries[0]).toMatchObject({ guid, name: null, matchId, matchAvailable: 0, game: 't5' }); expect(report.detail?.account.balance).toBe(2000); expect(report.servers).toEqual([server]); }); it('pages accounts and player history independently with stable ties and clamps stale offsets', () => { for (let i = 0; i < 31; i++) { fund(String(i).padStart(2, '0'), 100); transfer('00', 100); } const first = overview(query({ player: '00' }), now); const second = overview(query({ offset: 999999, player: '00', historyOffset: 999999 }), now); expect(first.accounts.entries).toHaveLength(25); expect(second.accounts).toMatchObject({ total: 31, offset: 25 }); expect(second.accounts.entries).toHaveLength(6); expect( new Set([...first.accounts.entries, ...second.accounts.entries].map((row) => row.guid)).size ).toBe(31); expect(second.detail?.history).toMatchObject({ total: 31, offset: 25 }); expect( new Set( [...first.detail!.history.entries, ...second.detail!.history.entries].map( (row) => row.id ) ).size ).toBe(31); }); it('orders pending transfers oldest first and excludes cancelled requests and private request data', () => { prepare('new', 'deposit', 100, '2026-10-13T11:59:00.000Z'); prepare('old', 'withdraw', 200, '2026-10-12T11:00:00.000Z'); prepare('cancelled'); handle.prepare("UPDATE bank_requests SET state = 'cancelled' WHERE guid = 'cancelled'").run(); const report = overview(query({ view: 'pending' }), now); expect(report.pending.entries.map((row) => row.guid)).toEqual(['old', 'new']); expect( overview(query({ view: 'pending', operation: 'deposit' }), now).pending.entries.map( (row) => row.guid ) ).toEqual(['new']); expect(JSON.stringify(report)).not.toMatch( /private-fingerprint|private-result|instance_id|token/ ); }); it('keeps existing accounts readable when the deposit limit changes or becomes invalid', () => { fund(guid, 250000); prepare(); vi.stubEnv('EC_BANK_MAX_BALANCE', '100000'); expect(overview(query({ balance: 'limit' }), now).accounts.total).toBe(1); vi.stubEnv('EC_BANK_MAX_BALANCE', 'invalid'); const report = overview(query({ player: guid }), now); expect(report.maximum).toBeNull(); expect(report.detail?.account).toMatchObject({ balance: 250000, available: 249500 }); expect(report.summary.pending).toBe(1); }); it('shows known players with no bank history, distinguishes unknown GUIDs, and serves uncached reads', async () => { identity(); expect(overview(query({ player: guid }), now).detail?.account.balance).toBe(0); expect(overview(query({ player: 'unknown' }), now).detail).toBeNull(); const { GET } = await import('../../src/routes/api/bank/+server.js'); const { load } = await import('../../src/routes/bank/+page.server.js'); const url = new URL(`https://test/bank?player=${guid}`); const response = GET({ url }); expect(response.headers.get('cache-control')).toBe('no-store'); expect((await response.json()).detail.account.guid).toBe(guid); expect(load({ url }).report.detail?.account.guid).toBe(guid); for (const invalid of ['view=unknown', 'offset=-1', 'from=2026-02-30']) { expect(() => GET({ url: new URL(`https://test/api/bank?${invalid}`) })).toThrow( expect.objectContaining({ status: 400 }) ); expect(() => load({ url: new URL(`https://test/bank?${invalid}`) })).toThrow( expect.objectContaining({ status: 400 }) ); } }); describe('bank query validation', () => { it.each([ 'view=nope', 'balance=nope', 'game=t7', 'sort=nope', 'operation=commit', 'offset=NaN', 'offset=1.5', 'historyOffset=Infinity', 'from=2026-02-30', 'to=2026-13-01', 'from=2026-10-14&to=2026-10-13', `q=${'a'.repeat(101)}` ])('rejects %s', (value) => { expect(() => readBankQuery(new URLSearchParams(value))).toThrow(); }); it('retains string identities and normalizes page boundaries', () => { expect( readBankQuery(new URLSearchParams(`player=${guid}&offset=27&historyOffset=51`)) ).toMatchObject({ player: guid, offset: 25, historyOffset: 50 }); }); });