package storage import ( "errors" "testing" "time" ) const player = "@reala:parodia.dev" // fund runs a buy-in all the way through the happy path, so a test that needs // chips on the table can just say so. func fund(t *testing.T, user string, amount int64) { t.Helper() e, err := RequestBuyIn(user, amount) if err != nil { t.Fatal(err) } if _, err := ClaimEscrow(e.GUID); err != nil { t.Fatal(err) } if _, err := SettleEscrow(e.GUID, true, "", 5000); err != nil { t.Fatal(err) } } func chipsOf(t *testing.T, user string) int64 { t.Helper() st, err := Chips(user) if err != nil { t.Fatal(err) } return st.Chips } func TestBuyIn_ChipsOnlyExistOnceGogobeeConfirms(t *testing.T) { setupTestDB(t) e, err := RequestBuyIn(player, 500) if err != nil { t.Fatal(err) } if e.State != EscrowRequested { t.Fatalf("state = %q, want %q", e.State, EscrowRequested) } // Requested is not funded. Nothing is spendable yet — gogobee hasn't taken // the euros, so creating chips here would mint money out of nothing. st, err := Chips(player) if err != nil { t.Fatal(err) } if st.Chips != 0 { t.Fatalf("chips = %d before settlement, want 0", st.Chips) } if st.Pending != 500 { t.Fatalf("pending = %d, want 500", st.Pending) } if _, err := ClaimEscrow(e.GUID); err != nil { t.Fatal(err) } if _, err := SettleEscrow(e.GUID, true, "", 4500); err != nil { t.Fatal(err) } st, err = Chips(player) if err != nil { t.Fatal(err) } if st.Chips != 500 { t.Fatalf("chips = %d after funding, want 500", st.Chips) } if st.Pending != 0 { t.Fatalf("pending = %d after funding, want 0", st.Pending) } if st.EuroBalance != 4500 { t.Fatalf("advisory euro balance = %v, want 4500", st.EuroBalance) } } func TestBuyIn_RejectedCreatesNoChips(t *testing.T) { setupTestDB(t) e, err := RequestBuyIn(player, 500) if err != nil { t.Fatal(err) } if _, err := ClaimEscrow(e.GUID); err != nil { t.Fatal(err) } got, err := SettleEscrow(e.GUID, false, "insufficient_funds", 12) if err != nil { t.Fatal(err) } if got.State != EscrowRejected { t.Fatalf("state = %q, want %q", got.State, EscrowRejected) } if c := chipsOf(t, player); c != 0 { t.Fatalf("chips = %d after a rejected buy-in, want 0", c) } } // The push queue retries. A verdict that lands twice must only move chips once. func TestSettle_IsIdempotent(t *testing.T) { setupTestDB(t) e, err := RequestBuyIn(player, 500) if err != nil { t.Fatal(err) } if _, err := ClaimEscrow(e.GUID); err != nil { t.Fatal(err) } for i := 0; i < 3; i++ { if _, err := SettleEscrow(e.GUID, true, "", 4500); err != nil { t.Fatalf("settle %d: %v", i, err) } } if c := chipsOf(t, player); c != 500 { t.Fatalf("chips = %d after three identical pushes, want 500", c) } } // A late, contradictory push must not overturn a settled row — otherwise a // delayed "rejected" could confiscate chips the player already won hands with. func TestSettle_TerminalStateWins(t *testing.T) { setupTestDB(t) e, err := RequestBuyIn(player, 500) if err != nil { t.Fatal(err) } if _, err := ClaimEscrow(e.GUID); err != nil { t.Fatal(err) } if _, err := SettleEscrow(e.GUID, true, "", 4500); err != nil { t.Fatal(err) } got, err := SettleEscrow(e.GUID, false, "insufficient_funds", 0) if err != nil { t.Fatal(err) } if got.State != EscrowFunded { t.Fatalf("state = %q, want the original %q", got.State, EscrowFunded) } if c := chipsOf(t, player); c != 500 { t.Fatalf("chips = %d, want 500 — a late rejection took funded chips", c) } } func TestCashOut_ChipsLeaveImmediately(t *testing.T) { setupTestDB(t) fund(t, player, 1000) e, err := RequestCashOut(player, 400) if err != nil { t.Fatal(err) } // The chips are gone the moment the row opens. If they lingered until gogobee // confirmed, the player could bet them while the euros were also in flight — // the same euro on both sides of the border. if c := chipsOf(t, player); c != 600 { t.Fatalf("chips = %d immediately after cash-out, want 600", c) } if _, err := ClaimEscrow(e.GUID); err != nil { t.Fatal(err) } got, err := SettleEscrow(e.GUID, true, "", 4400) if err != nil { t.Fatal(err) } if got.State != EscrowSettled { t.Fatalf("state = %q, want %q", got.State, EscrowSettled) } if c := chipsOf(t, player); c != 600 { t.Fatalf("chips = %d after settlement, want 600", c) } } func TestCashOut_FailedCreditGivesTheChipsBack(t *testing.T) { setupTestDB(t) fund(t, player, 1000) e, err := RequestCashOut(player, 400) if err != nil { t.Fatal(err) } if _, err := ClaimEscrow(e.GUID); err != nil { t.Fatal(err) } // gogobee couldn't pay. The chips were already destroyed here, so they have to // come back — the player's money cannot simply evaporate at the border. if _, err := SettleEscrow(e.GUID, false, "ledger_error", 0); err != nil { t.Fatal(err) } if c := chipsOf(t, player); c != 1000 { t.Fatalf("chips = %d after a failed cash-out, want the full 1000 back", c) } } func TestCashOut_CannotExceedTheStack(t *testing.T) { setupTestDB(t) fund(t, player, 100) if _, err := RequestCashOut(player, 500); !errors.Is(err, ErrInsufficientChips) { t.Fatalf("err = %v, want ErrInsufficientChips", err) } if c := chipsOf(t, player); c != 100 { t.Fatalf("chips = %d after a refused cash-out, want 100", c) } } func TestBuyIn_TableCapCountsChipsAndInFlightBuyIns(t *testing.T) { setupTestDB(t) if _, err := RequestBuyIn(player, MaxChipsOnTable+1); !errors.Is(err, ErrOverTableCap) { t.Fatalf("err = %v, want ErrOverTableCap", err) } fund(t, player, MaxChipsOnTable-1000) // A second buy-in that fits is fine. if _, err := RequestBuyIn(player, 1000); err != nil { t.Fatal(err) } // A third, while the second is still in flight, must not clear the cap by // racing — pending buy-ins count against it. if _, err := RequestBuyIn(player, 1); !errors.Is(err, ErrOverTableCap) { t.Fatalf("err = %v, want ErrOverTableCap — in-flight buy-ins must count", err) } } func TestRequest_RejectsNonPositiveAmounts(t *testing.T) { setupTestDB(t) fund(t, player, 100) for _, amount := range []int64{0, -50} { if _, err := RequestBuyIn(player, amount); !errors.Is(err, ErrBadAmount) { t.Errorf("buy-in %d: err = %v, want ErrBadAmount", amount, err) } if _, err := RequestCashOut(player, amount); !errors.Is(err, ErrBadAmount) { t.Errorf("cash-out %d: err = %v, want ErrBadAmount", amount, err) } } if c := chipsOf(t, player); c != 100 { t.Fatalf("chips = %d, want 100", c) } } func TestStakeAndAward(t *testing.T) { setupTestDB(t) fund(t, player, 500) if err := Stake(player, 100); err != nil { t.Fatal(err) } if c := chipsOf(t, player); c != 400 { t.Fatalf("chips = %d after staking 100, want 400", c) } // A win pays the stake back plus the profit, net of rake. if err := Award(player, 195); err != nil { t.Fatal(err) } if c := chipsOf(t, player); c != 595 { t.Fatalf("chips = %d after a 195 payout, want 595", c) } // You cannot bet chips you don't have. if err := Stake(player, 10_000); !errors.Is(err, ErrInsufficientChips) { t.Fatalf("err = %v, want ErrInsufficientChips", err) } if c := chipsOf(t, player); c != 595 { t.Fatalf("chips = %d after a refused stake, want 595", c) } } func TestStake_UnknownPlayerHasNothingToBet(t *testing.T) { setupTestDB(t) if err := Stake("@stranger:parodia.dev", 10); !errors.Is(err, ErrInsufficientChips) { t.Fatalf("err = %v, want ErrInsufficientChips", err) } } func TestPendingEscrow_OffersUnclaimedAndAbandonedRows(t *testing.T) { setupTestDB(t) fresh, err := RequestBuyIn(player, 100) if err != nil { t.Fatal(err) } abandoned, err := RequestBuyIn("@other:parodia.dev", 200) if err != nil { t.Fatal(err) } pending, err := PendingEscrow(10) if err != nil { t.Fatal(err) } if len(pending) != 2 { t.Fatalf("%d pending rows, want 2", len(pending)) } // Claim both: neither should be offered again while gogobee is working on them. for _, e := range []Escrow{fresh, abandoned} { if _, err := ClaimEscrow(e.GUID); err != nil { t.Fatal(err) } } pending, err = PendingEscrow(10) if err != nil { t.Fatal(err) } if len(pending) != 0 { t.Fatalf("%d rows offered while claimed, want 0", len(pending)) } // Now pretend gogobee died holding one of them. A claim that never came back // must be re-offered, or the player's money sits in limbo forever. stale := nowUnix() - int64(EscrowStaleAfter.Seconds()) - 1 if _, err := Get().Exec(`UPDATE game_escrow SET claimed_at = ? WHERE guid = ?`, stale, abandoned.GUID); err != nil { t.Fatal(err) } pending, err = PendingEscrow(10) if err != nil { t.Fatal(err) } if len(pending) != 1 || pending[0].GUID != abandoned.GUID { t.Fatalf("stale claim was not re-offered: got %d rows", len(pending)) } } func TestClaim_CannotReopenAFinishedRow(t *testing.T) { setupTestDB(t) fund(t, player, 300) e, err := RequestCashOut(player, 300) if err != nil { t.Fatal(err) } if _, err := ClaimEscrow(e.GUID); err != nil { t.Fatal(err) } if _, err := SettleEscrow(e.GUID, true, "", 5000); err != nil { t.Fatal(err) } // Re-claiming a settled cash-out must not walk it back to claimed, or gogobee // would pay the same euros out twice. got, err := ClaimEscrow(e.GUID) if err != nil { t.Fatal(err) } if got.State != EscrowSettled { t.Fatalf("state = %q after re-claiming a settled row, want %q", got.State, EscrowSettled) } } func TestEscrow_UnknownGUID(t *testing.T) { setupTestDB(t) if _, err := EscrowByGUID("nope"); !errors.Is(err, ErrNoSuchEscrow) { t.Fatalf("err = %v, want ErrNoSuchEscrow", err) } if _, err := SettleEscrow("nope", true, "", 0); !errors.Is(err, ErrNoSuchEscrow) { t.Fatalf("err = %v, want ErrNoSuchEscrow", err) } } func TestReaper_CashesOutTheWalkedAway(t *testing.T) { setupTestDB(t) fund(t, player, 700) // Still playing: the reaper must leave them alone. Touch(player) n, err := ReapIdleSessions(SessionIdleAfter) if err != nil { t.Fatal(err) } if n != 0 || chipsOf(t, player) != 700 { t.Fatalf("reaped %d active players (chips now %d)", n, chipsOf(t, player)) } // Now they've been gone an hour. old := nowUnix() - int64((2 * time.Hour).Seconds()) if _, err := Get().Exec(`UPDATE game_chips SET last_played = ? WHERE matrix_user = ?`, old, player); err != nil { t.Fatal(err) } n, err = ReapIdleSessions(SessionIdleAfter) if err != nil { t.Fatal(err) } if n != 1 { t.Fatalf("reaped %d, want 1", n) } if c := chipsOf(t, player); c != 0 { t.Fatalf("chips = %d after the reaper ran, want 0 — they should be euros now", c) } // And it must be a real cash-out, waiting for gogobee to pay it out. pending, err := PendingEscrow(10) if err != nil { t.Fatal(err) } if len(pending) != 1 || pending[0].Kind != KindCashOut || pending[0].Amount != 700 { t.Fatalf("reaper did not queue a 700 cash-out: %+v", pending) } // Running again finds nothing left to reap. if n, err := ReapIdleSessions(SessionIdleAfter); err != nil || n != 0 { t.Fatalf("second sweep reaped %d (err=%v), want 0", n, err) } } func TestRecordHand_AndHouseTake(t *testing.T) { setupTestDB(t) hands := []Hand{ {MatrixUser: player, Game: "blackjack", Bet: 100, Payout: 195, Rake: 5, Outcome: "win", Seed1: 42, Seed2: 7}, {MatrixUser: player, Game: "blackjack", Bet: 100, Payout: 0, Rake: 0, Outcome: "bust", Seed1: 43, Seed2: 8}, {MatrixUser: player, Game: "blackjack", Bet: 200, Payout: 486, Rake: 14, Outcome: "blackjack", Seed1: 44, Seed2: 9}, } for _, h := range hands { if err := RecordHand(h); err != nil { t.Fatal(err) } } take, err := HouseTake(0) if err != nil { t.Fatal(err) } if take != 19 { t.Fatalf("house take = %d, want 19", take) } // The seeds have to survive the round trip, or a disputed hand can't be re-dealt. var s1, s2 int64 if err := Get().QueryRow( `SELECT seed1, seed2 FROM game_hands WHERE outcome = 'blackjack'`, ).Scan(&s1, &s2); err != nil { t.Fatal(err) } if s1 != 44 || s2 != 9 { t.Fatalf("seeds came back as (%d, %d), want (44, 9)", s1, s2) } } // The invariant, end to end: every euro that entered the casino is either a chip // on the table or a euro on its way home. None are minted, none evaporate. func TestBorder_ChipsAreConserved(t *testing.T) { setupTestDB(t) fund(t, player, 1000) // Play a losing hand and a winning one. if err := Stake(player, 100); err != nil { t.Fatal(err) } // 900 if err := Stake(player, 100); err != nil { t.Fatal(err) } // 800 if err := Award(player, 195); err != nil { t.Fatal(err) } // 995 — one loss, one win less rake if c := chipsOf(t, player); c != 995 { t.Fatalf("chips = %d, want 995", c) } e, err := RequestCashOut(player, 995) if err != nil { t.Fatal(err) } if _, err := ClaimEscrow(e.GUID); err != nil { t.Fatal(err) } if _, err := SettleEscrow(e.GUID, true, "", 4995); err != nil { t.Fatal(err) } if c := chipsOf(t, player); c != 0 { t.Fatalf("chips = %d after cashing out everything, want 0", c) } // 1000 in, 995 out, 5 lost to the table and the rake. Nothing left stranded. st, err := Chips(player) if err != nil { t.Fatal(err) } if st.Pending != 0 { t.Fatalf("pending = %d, want 0", st.Pending) } }