package web import ( "bytes" "encoding/json" "net/http" "net/http/httptest" "testing" "time" "pete/internal/config" "pete/internal/storage" ) const testPlayer = "@reala:parodia.dev" // newCasino is a server with the tables open and one signed-in player. The // Authenticator is built by hand rather than through OIDC discovery: sign-in is // a network call, and none of what's under test is about the handshake. func newCasino(t *testing.T) *Server { t.Helper() s, _ := newAdvServer(t, "tok") s.auth = &Authenticator{secret: []byte("test-secret-key-at-least-16")} s.cfg.Games = config.GamesConfig{Enabled: true, MatrixServer: "parodia.dev"} return s } // as returns a request carrying the signed session of the given username. An // empty username is the pre-casino session: signed in, but nobody the economy // can name. func as(t *testing.T, s *Server, username, method, path string, body any) *http.Request { t.Helper() var buf bytes.Buffer if body != nil { if err := json.NewEncoder(&buf).Encode(body); err != nil { t.Fatal(err) } } r := httptest.NewRequest(method, path, &buf) payload, _ := json.Marshal(SessionUser{ Sub: "sub-1", Username: username, Exp: time.Now().Add(time.Hour).Unix(), }) r.AddCookie(&http.Cookie{Name: sessionCookie, Value: s.auth.sign(payload)}) return r } // call runs one request against a handler and decodes the table view. func call(t *testing.T, s *Server, h http.HandlerFunc, r *http.Request) (tableView, int) { t.Helper() w := httptest.NewRecorder() h(w, r) var v tableView if w.Code == 200 { if err := json.Unmarshal(w.Body.Bytes(), &v); err != nil { t.Fatalf("decode table: %v (body %q)", err, w.Body.String()) } } return v, w.Code } // fund puts chips in front of the player the way the border really does it. func fund(t *testing.T, chips int64) { t.Helper() e, err := storage.RequestBuyIn(testPlayer, chips) if err != nil { t.Fatal(err) } if _, err := storage.ClaimEscrow(e.GUID); err != nil { t.Fatal(err) } if _, err := storage.SettleEscrow(e.GUID, true, "", 0); err != nil { t.Fatal(err) } } func chipsNow(t *testing.T) int64 { t.Helper() st, err := storage.Chips(testPlayer) if err != nil { t.Fatal(err) } return st.Chips } // TestDealTakesTheStakeAndHidesTheHoleCard is the one thing the table cannot get // wrong: the bet leaves the stack, and the dealer's second card does not leave // the server. func TestDealTakesTheStakeAndHidesTheHoleCard(t *testing.T) { s := newCasino(t) fund(t, 1000) v, code := call(t, s, s.handleDeal, as(t, s, "reala", "POST", "/api/games/blackjack/deal", map[string]int64{"bet": 100})) if code != 200 { t.Fatalf("deal = %d, want 200", code) } if v.Chips != 900 { t.Fatalf("chips after a 100 bet = %d, want 900", v.Chips) } if v.Hand == nil { t.Fatal("deal returned no hand") } if len(v.Hand.Hands[0].Cards) != 2 { t.Fatalf("player was dealt %d cards, want 2", len(v.Hand.Hands[0].Cards)) } // A natural settles on the spot and legitimately shows both dealer cards. if v.Hand.Phase == "done" { return } if !v.Hand.Hole || len(v.Hand.Dealer) != 1 { t.Fatalf("dealer shows %d cards (hole=%v), want 1 with the hole card held back", len(v.Hand.Dealer), v.Hand.Hole) } // And it isn't smuggled out in the dealing script either. for _, e := range v.Events { if e.Kind == "dealer_hole" && e.Card != nil { t.Fatal("the hole card's face went to the browser in the events") } } } // TestHandSettlesIntoTheChipStack plays a hand to the end and checks the chips // moved by exactly what the engine said they did — and that the hand left the // felt and landed in the audit log. func TestHandSettlesIntoTheChipStack(t *testing.T) { s := newCasino(t) fund(t, 1000) v, _ := call(t, s, s.handleDeal, as(t, s, "reala", "POST", "/deal", map[string]int64{"bet": 100})) for v.Hand != nil && v.Hand.Phase == "player" { v, _ = call(t, s, s.handleMove, as(t, s, "reala", "POST", "/move", map[string]string{"move": "stand"})) } if v.Hand == nil || v.Hand.Phase != "done" { t.Fatalf("hand didn't finish: %+v", v.Hand) } // 1000, minus the stake, plus whatever came back. want := int64(1000) - 100 + v.Hand.Payout if got := chipsNow(t); got != want { t.Fatalf("chips = %d, want %d (payout %d, outcome %q)", got, want, v.Hand.Payout, v.Hand.Outcome) } if _, err := storage.LoadLiveHand(testPlayer); err == nil { t.Fatal("a settled hand is still sitting on the felt") } if v.Hand.Outcome == "" { t.Fatal("a finished hand with no outcome") } // The rake only ever comes out of winnings. if v.Hand.Outcome == "push" && v.Hand.Payout != 100 { t.Fatalf("a push paid %d, want the 100 back untouched", v.Hand.Payout) } if v.Hand.Net < 0 && v.Hand.Rake != 0 { t.Fatalf("a losing hand was raked %d", v.Hand.Rake) } } // TestOneHandAtATime is the double-click: a second Deal must not overwrite the // hand the first one is paying for, and must not keep the chips it staked. func TestOneHandAtATime(t *testing.T) { s := newCasino(t) fund(t, 1000) first, _ := call(t, s, s.handleDeal, as(t, s, "reala", "POST", "/deal", map[string]int64{"bet": 100})) if first.Hand != nil && first.Hand.Phase == "done" { t.Skip("dealt a natural; there is no live hand to protect") } before := chipsNow(t) w := httptest.NewRecorder() s.handleDeal(w, as(t, s, "reala", "POST", "/deal", map[string]int64{"bet": 100})) if w.Code != http.StatusConflict { t.Fatalf("second deal = %d, want 409", w.Code) } if got := chipsNow(t); got != before { t.Fatalf("the refused deal cost the player %d chips", before-got) } // The original hand is untouched. live, err := storage.LoadLiveHand(testPlayer) if err != nil { t.Fatalf("the live hand went missing: %v", err) } var st struct { Hands []struct { Cards []struct{} `json:"cards"` } `json:"hands"` } if err := json.Unmarshal(live.State, &st); err != nil { t.Fatal(err) } if len(st.Hands) != 1 || len(st.Hands[0].Cards) != len(first.Hand.Hands[0].Cards) { t.Fatal("the refused deal replaced the hand in progress") } } // TestCannotCashOutMidHand — the stake is on the table, so the session it would // settle into cannot be closed underneath it. func TestCannotCashOutMidHand(t *testing.T) { s := newCasino(t) fund(t, 1000) v, _ := call(t, s, s.handleDeal, as(t, s, "reala", "POST", "/deal", map[string]int64{"bet": 100})) if v.Hand != nil && v.Hand.Phase == "done" { t.Skip("dealt a natural; nothing is in progress") } w := httptest.NewRecorder() s.handleCashOut(w, as(t, s, "reala", "POST", "/cashout", map[string]int64{"amount": 0})) if w.Code != http.StatusConflict { t.Fatalf("cash-out mid-hand = %d, want 409", w.Code) } if got := chipsNow(t); got != 900 { t.Fatalf("the refused cash-out moved chips: %d, want 900", got) } } // TestDoubleWithoutTheChipsChangesNothing: a double the player can't cover is // refused, and refusing it must not quietly pocket the raise. func TestDoubleWithoutTheChipsChangesNothing(t *testing.T) { s := newCasino(t) fund(t, 100) v, _ := call(t, s, s.handleDeal, as(t, s, "reala", "POST", "/deal", map[string]int64{"bet": 100})) if v.Hand == nil || v.Hand.Phase != "player" { t.Skip("no live hand to double on") } if chipsNow(t) != 0 { t.Fatal("test wants a player with nothing left to raise with") } w := httptest.NewRecorder() s.handleMove(w, as(t, s, "reala", "POST", "/move", map[string]string{"move": "double"})) if w.Code != http.StatusBadRequest { t.Fatalf("broke double = %d, want 400", w.Code) } if got := chipsNow(t); got != 0 { t.Fatalf("chips = %d after a refused double, want 0", got) } // And the hand is still there, still doubleable once they can afford it. after, code := call(t, s, s.handleTable, as(t, s, "reala", "GET", "/table", nil)) if code != 200 || after.Hand == nil || !after.Hand.Double { t.Fatalf("the hand should be intact and still doubleable: %d %+v", code, after.Hand) } } // TestTableNeedsAPlayerTheEconomyCanName. Anonymous is a 401. A session minted // before the casino existed carries no username, so it can't be mapped to a // Matrix id — that's a 403, and the fix is to sign in again. func TestTableNeedsAPlayerTheEconomyCanName(t *testing.T) { s := newCasino(t) w := httptest.NewRecorder() s.handleTable(w, httptest.NewRequest("GET", "/api/games/table", nil)) if w.Code != http.StatusUnauthorized { t.Fatalf("anonymous = %d, want 401", w.Code) } w = httptest.NewRecorder() s.handleTable(w, as(t, s, "", "GET", "/api/games/table", nil)) if w.Code != http.StatusForbidden { t.Fatalf("session with no username = %d, want 403", w.Code) } } // TestCasinoIsShutWithoutAServerName. No Matrix server name means no player can // be named to gogobee, so the tables 404 rather than dealing hands whose money // has nowhere to go. func TestCasinoIsShutWithoutAServerName(t *testing.T) { s := newCasino(t) s.cfg.Games.MatrixServer = "" w := httptest.NewRecorder() s.handleTable(w, as(t, s, "reala", "GET", "/api/games/table", nil)) if w.Code != http.StatusNotFound { t.Fatalf("table with no server name = %d, want 404", w.Code) } }