package storage import ( "errors" "testing" "time" ) // The settle used to be four autocommit statements in the web layer — save the // state, award the payout, write the audit row, clear the felt — sequenced so // that a crash between any two of them cost the player as little as possible. // // These are the tests for the thing that replaced it. They are less about the // happy path (the game tests already cover that: a hand pays what it says it // pays) and more about the two properties the old shape did not have, and which // a shared table with a pot in it cannot do without. func liveBlob(game string) LiveHand { return LiveHand{Game: game, State: []byte(`{"phase":"player"}`), Seed1: 7, Seed2: 9} } // A settled hand moves the money, writes the audit row, and leaves the felt // empty — and it does all three or none of them. // // The old code logged and carried on if the clear failed. That is the double-pay: // a settled hand still sitting in game_live_hands is a hand that settles again on // the next request, and pays again with it. func TestSettleMovesTheMoneyAndTheFeltTogether(t *testing.T) { setupTestDB(t) fund(t, player, 1000) if err := Stake(player, 200); err != nil { t.Fatal(err) } if err := CommitHand(player, Commit{ Live: liveBlob("blackjack"), Fresh: true, Stake: 200, Done: true, Payout: 390, Audit: Hand{MatrixUser: player, Game: "blackjack", Bet: 200, Payout: 390, Rake: 10, Outcome: "won"}, }); err != nil { t.Fatal(err) } // Paid: 1000 - 200 staked + 390 back. if c := chipsOf(t, player); c != 1190 { t.Fatalf("chips = %d after a 390 payout on a 200 stake, want 1190", c) } // The felt is empty, which is what stops it settling a second time. if _, err := LoadLiveHand(player); !errors.Is(err, ErrNoLiveHand) { t.Fatalf("live hand after settle: err = %v, want ErrNoLiveHand", err) } // And the house took its cut, once. take, err := HouseTake(0) if err != nil { t.Fatal(err) } if take != 10 { t.Fatalf("house take = %d, want 10", take) } } // A seat that is already taken refuses the game *and hands the stake back*, in // the same transaction that refused it. // // This was two statements: the save came back ErrHandInProgress, and then a // separate Award put the chips back. A crash in between took a player's stake for // a game that never existed anywhere — no felt, no audit row, no way to find it. func TestARefusedSeatGivesTheStakeBackInTheSameBreath(t *testing.T) { setupTestDB(t) fund(t, player, 1000) // A game is already in progress. if err := Stake(player, 200); err != nil { t.Fatal(err) } if err := CommitHand(player, Commit{Live: liveBlob("uno"), Fresh: true, Stake: 200}); err != nil { t.Fatal(err) } if c := chipsOf(t, player); c != 800 { t.Fatalf("chips = %d with 200 staked on a live game, want 800", c) } // A second game is dealt on top of it: the stake leaves, as it must, in the // same statement that checks it's there. if err := Stake(player, 300); err != nil { t.Fatal(err) } err := CommitHand(player, Commit{ Live: liveBlob("blackjack"), Fresh: true, Stake: 300, Done: true, Payout: 600, // a natural, which would settle the instant it's dealt Audit: Hand{MatrixUser: player, Game: "blackjack", Bet: 300, Payout: 600}, }) if !errors.Is(err, ErrHandInProgress) { t.Fatalf("err = %v, want ErrHandInProgress", err) } // The 300 is back, and the natural was *not* paid — a game that was never // seated must not settle. if c := chipsOf(t, player); c != 800 { t.Fatalf("chips = %d after a refused deal, want 800 (the 300 refunded, the 600 never paid)", c) } // And the game already in progress is untouched. This is the bit that matters: // a natural dealt on top of a live game used to be able to settle, clear the // felt, and take the other game's stake down with it. live, err := LoadLiveHand(player) if err != nil { t.Fatalf("the live game is gone: %v", err) } if live.Game != "uno" { t.Fatalf("live game = %q, want the uno game still sitting there", live.Game) } // Nothing was recorded, because nothing finished. if take, err := HouseTake(0); err != nil || take != 0 { t.Fatalf("house take = %d (err %v), want 0 — no hand finished", take, err) } } // The deadlock canary. // // SQLite runs at MaxOpenConns(1), so the connection is a global mutex. A bare // Get().Exec inside an open transaction waits for the one connection that the // transaction is holding, and waits forever — it is not an error, it is a hung // process, and because the news app shares the pool it hangs that too. // // So: if anybody ever reaches for Award or RecordHand (rather than award/ // recordHand) from inside CommitHand, this test stops returning. It does not // fail with a nice message; it hangs, and the timeout is the message. That is // exactly the failure mode in production, which is the point of catching it here. func TestTheSettleDoesNotDeadlockAgainstItsOwnConnection(t *testing.T) { setupTestDB(t) fund(t, player, 1000) if err := Stake(player, 100); err != nil { t.Fatal(err) } done := make(chan error, 1) go func() { done <- CommitHand(player, Commit{ Live: liveBlob("hangman"), Fresh: true, Stake: 100, Done: true, Payout: 234, Audit: Hand{MatrixUser: player, Game: "hangman", Bet: 100, Payout: 234, Rake: 12, Outcome: "won"}, }) }() select { case err := <-done: if err != nil { t.Fatal(err) } case <-time.After(5 * time.Second): t.Fatal("CommitHand did not return: something inside the transaction is waiting " + "for the connection the transaction is holding. Look for a Get().Exec that " + "should be a tx.Exec.") } if c := chipsOf(t, player); c != 1134 { t.Fatalf("chips = %d, want 1134", c) } } // Being paid moves the idle clock, so the reaper leaves a player who is // mid-session alone. Touch used to be a separate statement after the settle; it // is inside it now, and this is what would notice if it got dropped on the way. func TestSettlingKeepsTheReaperAway(t *testing.T) { setupTestDB(t) fund(t, player, 1000) // Backdate the session well past the reaper's patience. if _, err := Get().Exec( `UPDATE game_chips SET last_played = ? WHERE matrix_user = ?`, nowUnix()-int64((2 * SessionIdleAfter).Seconds()), player, ); err != nil { t.Fatal(err) } if err := Stake(player, 100); err != nil { t.Fatal(err) } if err := CommitHand(player, Commit{ Live: liveBlob("trivia"), Fresh: true, Stake: 100, Done: true, Payout: 150, Audit: Hand{MatrixUser: player, Game: "trivia", Bet: 100, Payout: 150, Outcome: "won"}, }); err != nil { t.Fatal(err) } stacks, _, err := IdleStacks(SessionIdleAfter) if err != nil { t.Fatal(err) } if len(stacks) != 0 { t.Fatalf("the reaper found %d idle stacks; a player who just settled a hand is not idle", len(stacks)) } }