This commit is contained in:
@@ -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');
|
||||
});
|
||||
@@ -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]',
|
||||
|
||||
@@ -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 });
|
||||
});
|
||||
});
|
||||
@@ -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'
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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"');
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user