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
+217
View File
@@ -0,0 +1,217 @@
import { afterEach, beforeEach, expect, it, vi } from 'vitest';
import { flushSync, mount, tick, unmount } from 'svelte';
import { fromStore, writable } from 'svelte/store';
import BankPage from '../../src/routes/bank/+page.svelte';
import { readBankQuery, type BankOverview } from '../../src/lib/bank.js';
const navigation = vi.hoisted(() => ({ goto: vi.fn(), before: [] as (() => void)[] }));
const currentPage = writable({ url: new URL('http://localhost/bank'), data: { user: null } });
vi.mock('$app/stores', () => ({
page: {
subscribe: (run: Parameters<typeof currentPage.subscribe>[0]) => currentPage.subscribe(run)
},
navigating: writable(null)
}));
vi.mock('$app/navigation', () => ({
goto: navigation.goto,
beforeNavigate: (fn: () => void) => navigation.before.push(fn),
afterNavigate: vi.fn()
}));
vi.mock('../../src/lib/ui/poll.js', () => ({ pollVisible: () => () => {} }));
let component: ReturnType<typeof mount> | undefined;
beforeEach(() => {
navigation.goto.mockReset().mockResolvedValue(undefined);
navigation.before.length = 0;
});
afterEach(async () => {
if (component) await unmount(component);
component = undefined;
document.body.replaceChildren();
vi.unstubAllGlobals();
});
const account = {
guid: '18446744073709551615',
name: 'Dempsey',
balance: 2000,
available: 1500,
pendingOperation: 'withdraw' as const,
pendingAmount: 500,
lastActivity: '2026-10-13T11:00:00.000Z',
lastAmount: 2000
};
const transfer = {
id: 1,
guid: account.guid,
name: account.name,
amount: 2000,
balance: 2000,
server: 't5_zombie_theater_1',
game: 't5',
matchId: 17,
matchAvailable: 1,
createdAt: account.lastActivity
};
function report(search = ''): BankOverview {
const query = readBankQuery(new URLSearchParams(search));
return {
query,
maximum: 250000,
checkedAt: '2026-10-13T12:00:00.000Z',
summary: { totalBanked: 2000, fundedAccounts: 1, transfers24h: 1, pending: 1 },
servers: [transfer.server],
accounts: { entries: [account], total: 1, offset: 0 },
transactions: { entries: [transfer], total: 1, offset: 0 },
pending: {
entries: [{ ...transfer, requestId: 'pending-id', amount: 500, operation: 'withdraw' }],
total: 1,
offset: 0
},
detail: query.player
? {
account,
pending: null,
history: { entries: [transfer], total: 30, offset: query.historyOffset }
}
: null
};
}
function show(initial = report()) {
const data = writable({ report: initial, user: null, setupRequired: false });
const state = fromStore(data);
currentPage.set({
url: new URL(
`http://localhost/bank?${new URLSearchParams(Object.entries(initial.query).map(([key, value]) => [key, String(value)]))}`
),
data: { user: null }
});
component = mount(BankPage, {
target: document.body,
props: {
get data() {
return state.current;
}
}
});
flushSync();
return (next: BankOverview) => {
currentPage.set({
url: new URL(
`http://localhost/bank?${new URLSearchParams(Object.entries(next.query).map(([key, value]) => [key, String(value)]))}`
),
data: { user: null }
});
data.set({ report: next, user: null, setupRequired: false });
flushSync();
};
}
const button = (label: string) =>
[...document.querySelectorAll('button')].find((node) => node.textContent?.trim() === label)!;
const lastNavigation = () => new URL(navigation.goto.mock.calls.at(-1)![0], 'http://localhost');
it('opens player details without losing list filters and restores the list when closed', async () => {
const update = show(report('q=Dempsey&sort=recent&offset=25'));
button('Dempsey').click();
expect(lastNavigation().searchParams.get('player')).toBe(account.guid);
expect(lastNavigation().searchParams.get('q')).toBe('Dempsey');
expect(lastNavigation().searchParams.get('offset')).toBe('25');
update(report(`q=Dempsey&sort=recent&offset=25&player=${account.guid}`));
await tick();
expect(document.querySelector('dialog')?.open).toBe(true);
expect(document.querySelector('dialog')?.textContent).toContain('1,500 pts');
const profile = [...document.querySelectorAll('dialog a')].find((link) =>
link.textContent?.includes('View player profile')
)!;
const target = new URL(profile.getAttribute('href')!, 'http://localhost');
expect(target.searchParams.get('returnTo')).toContain(`player=${account.guid}`);
button('Next').click();
expect(lastNavigation().searchParams.get('historyOffset')).toBe('25');
expect(lastNavigation().searchParams.get('offset')).toBe('25');
button('Close').click();
expect(lastNavigation().searchParams.has('player')).toBe(false);
expect(lastNavigation().searchParams.get('sort')).toBe('recent');
});
it('applies accessible transfer filters and resets pagination while preserving player detail', () => {
show(report(`view=transactions&offset=25&player=${account.guid}`));
const search = document.querySelector<HTMLInputElement>('input[name="q"]')!;
search.value = 'Nikolai';
search.dispatchEvent(new Event('input', { bubbles: true }));
const game = document.querySelector<HTMLSelectElement>('select[name="game"]')!;
// happy-dom's :checked selector only matches inputs, not selected options.
const selectQuery = game.querySelector.bind(game);
game.querySelector = ((selector: string) =>
selector === ':checked'
? (game.selectedOptions[0] ?? null)
: selectQuery(selector)) as typeof game.querySelector;
game.value = 't6';
game.dispatchEvent(new Event('change', { bubbles: true }));
flushSync();
document
.querySelector('form')!
.dispatchEvent(new Event('submit', { bubbles: true, cancelable: true }));
expect(lastNavigation().searchParams.get('q')).toBe('Nikolai');
expect(lastNavigation().searchParams.get('game')).toBe('t6');
expect(lastNavigation().searchParams.has('offset')).toBe(false);
expect(lastNavigation().searchParams.get('player')).toBe(account.guid);
button('Reset').click();
expect(lastNavigation().searchParams.has('game')).toBe(false);
});
it('shows pending reservation guidance, age and historical matches without dead links', () => {
const initial = report('view=pending');
initial.pending.entries[0].matchAvailable = 0;
show(initial);
const panel = document.querySelector('#bank-results')!;
expect(panel.textContent).toContain('Waiting over 5 minutes');
expect(panel.textContent).toContain('These points are reserved');
expect(panel.textContent).toContain('Match #17 · History expired');
expect(panel.querySelector('a[href*="matches/17"]')).toBeNull();
expect(panel.querySelector('details summary')?.textContent).toBe('Why pending?');
});
it('retains data on refresh errors and successfully retries without erasing draft filters', async () => {
const fetch = vi
.fn()
.mockRejectedValueOnce(new Error('offline'))
.mockResolvedValueOnce(new Response(JSON.stringify(report())));
vi.stubGlobal('fetch', fetch);
show();
const search = document.querySelector<HTMLInputElement>('input[name="q"]')!;
search.value = 'draft';
search.dispatchEvent(new Event('input', { bubbles: true }));
button('Refresh').click();
await vi.waitFor(() => expect(document.body.textContent).toContain('Bank updates paused.'));
expect(document.querySelector('#bank-results')?.textContent).toContain('Dempsey');
button('Retry').click();
await vi.waitFor(() => expect(document.body.textContent).not.toContain('Bank updates paused.'));
expect(search.value).toBe('draft');
});
it('discards an old refresh when navigation changes the bank view', async () => {
let resolve!: (response: Response) => void;
vi.stubGlobal(
'fetch',
vi.fn(
() =>
new Promise<Response>((done) => {
resolve = done;
})
)
);
const update = show();
button('Refresh').click();
navigation.before.forEach((fn) => fn());
const next = report('view=transactions');
next.summary.totalBanked = 9000;
update(next);
resolve(new Response(JSON.stringify(report())));
await tick();
await tick();
expect(document.querySelector('.summary')?.textContent).toContain('9,000');
expect(document.querySelector('#bank-tab-transactions')?.getAttribute('aria-selected')).toBe(
'true'
);
expect(document.querySelector('#bank-results')?.textContent).toContain('Balance after');
});
+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"');
});