| import test from 'node:test'; |
| import assert from 'node:assert/strict'; |
| import { newDb } from 'pg-mem'; |
| import { BetStore } from '../src/db.js'; |
|
|
| async function createStore() { |
| const db = newDb(); |
| const { Pool } = db.adapters.createPg(); |
| const pool = new Pool(); |
| const store = new BetStore('postgresql://test', { pool }); |
| await store.initialize(); |
| return store; |
| } |
|
|
| test('tracks bet numbering independently per user', async () => { |
| const store = await createStore(); |
|
|
| await store.createBet({ id: 'user-a', username: 'a', displayName: 'A' }, { |
| book: 'FanDuel', |
| sport: 'NBA', |
| oddsInput: '-110', |
| normalizedDecimalOdds: 1.9091, |
| prop: 'Knicks ML', |
| stake: 25, |
| rawInput: 'raw', |
| }); |
| await store.createBet({ id: 'user-b', username: 'b', displayName: 'B' }, { |
| book: 'DraftKings', |
| sport: 'NBA', |
| oddsInput: '+120', |
| normalizedDecimalOdds: 2.2, |
| prop: 'Lakers ML', |
| stake: 10, |
| rawInput: 'raw', |
| }); |
|
|
| assert.equal((await store.findBet('user-a', 1)).betNumber, 1); |
| assert.equal((await store.findBet('user-b', 1)).betNumber, 1); |
|
|
| await store.close(); |
| }); |
|
|
| test('calculates roi excluding void bets from settled stake', async () => { |
| const store = await createStore(); |
| const user = { id: 'user-a', username: 'a', displayName: 'A' }; |
|
|
| await store.createBet(user, { |
| book: 'FanDuel', |
| sport: 'NBA', |
| oddsInput: '-110', |
| normalizedDecimalOdds: 1.9091, |
| prop: 'Knicks ML', |
| stake: 25, |
| rawInput: 'raw', |
| }); |
| await store.createBet(user, { |
| book: 'DraftKings', |
| sport: 'NBA', |
| oddsInput: '+150', |
| normalizedDecimalOdds: 2.5, |
| prop: 'Lakers ML', |
| stake: 10, |
| rawInput: 'raw', |
| }); |
| await store.createBet(user, { |
| book: 'Caesars', |
| sport: 'NBA', |
| oddsInput: '-105', |
| normalizedDecimalOdds: 1.9524, |
| prop: 'Celtics ML', |
| stake: 5, |
| rawInput: 'raw', |
| }); |
|
|
| await store.resolveBet(user.id, 1, 'win'); |
| await store.resolveBet(user.id, 2, 'loss'); |
| await store.resolveBet(user.id, 3, 'void'); |
|
|
| const summary = await store.getUserSummary(user.id); |
| assert.equal(summary.wins, 1); |
| assert.equal(summary.losses, 1); |
| assert.equal(summary.voids, 1); |
| assert.equal(summary.settledStake, 35); |
| assert.equal(summary.netProfit.toFixed(2), '12.73'); |
|
|
| await store.close(); |
| }); |
|
|
| test('returns sportsbook breakdown with roi and win rate', async () => { |
| const store = await createStore(); |
| const user = { id: 'user-books', username: 'books', displayName: 'Books' }; |
|
|
| await store.createBet(user, { |
| book: 'FanDuel', |
| sport: 'MLB', |
| oddsInput: '+100', |
| normalizedDecimalOdds: 2, |
| prop: 'Bet A', |
| stake: 10, |
| rawInput: 'raw', |
| }); |
| await store.createBet(user, { |
| book: 'FanDuel', |
| sport: 'MLB', |
| oddsInput: '-110', |
| normalizedDecimalOdds: 1.9091, |
| prop: 'Bet B', |
| stake: 20, |
| rawInput: 'raw', |
| }); |
| await store.createBet(user, { |
| book: 'DraftKings', |
| sport: 'NBA', |
| oddsInput: '+150', |
| normalizedDecimalOdds: 2.5, |
| prop: 'Bet C', |
| stake: 10, |
| rawInput: 'raw', |
| }); |
|
|
| await store.resolveBet(user.id, 1, 'win'); |
| await store.resolveBet(user.id, 2, 'loss'); |
| await store.resolveBet(user.id, 3, 'win'); |
|
|
| const rows = await store.getBookBreakdown(user.id); |
| const draftKings = rows.find((row) => row.book === 'DraftKings'); |
| const fanDuel = rows.find((row) => row.book === 'FanDuel'); |
|
|
| assert.equal(rows.length, 2); |
| assert.equal(draftKings.winRatePercent, 100); |
| assert.equal(draftKings.roiPercent, 150); |
| assert.equal(fanDuel.wins, 1); |
| assert.equal(fanDuel.losses, 1); |
| assert.equal(fanDuel.totalBets, 2); |
|
|
| await store.close(); |
| }); |
|
|
| test('soft delete removes a bet from active summaries', async () => { |
| const store = await createStore(); |
| const user = { id: 'user-delete', username: 'delete', displayName: 'Delete' }; |
|
|
| await store.createBet(user, { |
| book: 'FanDuel', |
| sport: 'MLB', |
| oddsInput: '+100', |
| normalizedDecimalOdds: 2, |
| prop: 'Bet A', |
| stake: 10, |
| rawInput: 'raw', |
| }); |
|
|
| await store.softDeleteBet(user.id, 1, 'mistake'); |
| const summary = await store.getUserSummary(user.id); |
| const deletedBet = await store.findBet(user.id, 1); |
|
|
| assert.equal(summary.totalBets, 0); |
| assert.equal(deletedBet.deletedReason, 'mistake'); |
|
|
| await store.close(); |
| }); |
|
|
| test('bankroll settings update user profile and units', async () => { |
| const store = await createStore(); |
| const user = { id: 'user-bankroll', username: 'bankroll', displayName: 'Bankroll' }; |
|
|
| await store.updateUserPerformanceConfig(user, { |
| startingBankroll: 500, |
| unitSize: 25, |
| }); |
| await store.createBet(user, { |
| book: 'BetMGM', |
| sport: 'NFL', |
| oddsInput: '+150', |
| normalizedDecimalOdds: 2.5, |
| prop: 'Bet A', |
| stake: 50, |
| rawInput: 'raw', |
| }); |
|
|
| const profile = await store.getUserProfile(user.id); |
| const bet = await store.findBet(user.id, 1); |
|
|
| assert.equal(profile.startingBankroll, 500); |
| assert.equal(profile.unitSize, 25); |
| assert.equal(bet.unitsValue, 2); |
|
|
| await store.close(); |
| }); |
|
|
| test('returns sport breakdown with roi and win rate', async () => { |
| const store = await createStore(); |
| const user = { id: 'user-sports', username: 'sports', displayName: 'Sports' }; |
|
|
| await store.createBet(user, { |
| book: 'FanDuel', |
| sport: 'MLB', |
| oddsInput: '+100', |
| normalizedDecimalOdds: 2, |
| prop: 'Bet A', |
| stake: 10, |
| rawInput: 'raw', |
| }); |
| await store.createBet(user, { |
| book: 'DraftKings', |
| sport: 'NFL', |
| oddsInput: '-110', |
| normalizedDecimalOdds: 1.9091, |
| prop: 'Bet B', |
| stake: 20, |
| rawInput: 'raw', |
| }); |
| await store.resolveBet(user.id, 1, 'win'); |
| await store.resolveBet(user.id, 2, 'loss'); |
|
|
| const rows = await store.getSportBreakdown(user.id); |
| const mlb = rows.find((row) => row.label === 'MLB'); |
| const nfl = rows.find((row) => row.label === 'NFL'); |
|
|
| assert.equal(rows.length, 2); |
| assert.equal(mlb.roiPercent, 100); |
| assert.equal(nfl.losses, 1); |
|
|
| await store.close(); |
| }); |
|
|
| test('allows settled bets to update metadata without changing financial fields', async () => { |
| const store = await createStore(); |
| const user = { id: 'user-edit-settled', username: 'edit', displayName: 'Edit' }; |
|
|
| await store.createBet(user, { |
| book: 'FanDuel', |
| sport: 'Other', |
| oddsInput: '+100', |
| normalizedDecimalOdds: 2, |
| prop: 'Bet A', |
| stake: 10, |
| rawInput: 'raw', |
| }); |
| await store.resolveBet(user.id, 1, 'win'); |
|
|
| const updated = await store.updateBet(user.id, 1, { |
| sport: 'MLB', |
| rawInput: 'book: FanDuel\nsport: MLB\nprop: Bet A\nodds: +100\nstake: 10', |
| }); |
|
|
| assert.equal(updated.type, 'updated'); |
| assert.equal(updated.bet.sport, 'MLB'); |
| assert.equal(updated.bet.profitLoss, 10); |
|
|
| const locked = await store.updateBet(user.id, 1, { |
| stake: 25, |
| }); |
| assert.equal(locked.type, 'financial_locked'); |
|
|
| await store.close(); |
| }); |
|
|