304 lines
12 KiB
TypeScript
304 lines
12 KiB
TypeScript
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 });
|
|
});
|
|
});
|