Add Bank accounts, transactions, and pending transfer views
CI / checks (push) Successful in 7m50s

This commit is contained in:
2026-10-13 11:50:26 -04:00
parent d6147077d7
commit e85dc58130
19 changed files with 1888 additions and 7 deletions
+2
View File
@@ -55,6 +55,8 @@ describe('management route permissions', () => {
'/api/servers/[name]/scripts',
'/api/metrics',
'/metrics',
'/bank',
'/api/bank',
'/api/matches',
'/matches/[id]',
'/leaderboard/players/[game]/[guid]',
+303
View File
@@ -0,0 +1,303 @@
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<BankQuery> = {}) => ({
...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 });
});
});
+6
View File
@@ -2,6 +2,12 @@ import { describe, expect, it } from 'vitest';
import { contextHref, returnLabel, safeReturnHref } from '../../src/lib/navigation-context.js';
describe('navigation context', () => {
it('restores Bank filters, pagination and the selected player from detail pages', () => {
const source = '/bank?view=transactions&game=t5&offset=25&player=00042&historyOffset=25';
const target = new URL(contextHref('/matches/17', source), 'http://localhost');
expect(safeReturnHref(target.searchParams.get('returnTo'), '/')).toBe(source);
expect(returnLabel(source)).toBe('Back to Bank');
});
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'
+1
View File
@@ -20,6 +20,7 @@ describe('application navigation', () => {
['/campaigns/42', 'releases', '/campaigns'],
['/configs/42', 'releases', '/configs'],
['/leaderboard/players/42', 'players', '/leaderboard'],
['/bank', 'players', '/bank'],
['/settings/users', 'admin', '/settings/users'],
['/server-keys', 'admin', '/server-keys']
])('gives %s exactly one canonical home', (path, group, href) => {
+1
View File
@@ -33,6 +33,7 @@ describe('sidebar navigation', () => {
expect(body).toContain('href="/betas"');
expect(body).toContain('href="/activity"');
expect(body).toContain('href="/metrics"');
expect(body).toContain('href="/bank"');
expect(body).not.toContain('href="/settings"');
expect(body).not.toContain('href="/server-keys"');
});