TestDealTakesTheStakeAndHidesTheHoleCard checked for a 900 stack straight after a 100 bet, and only afterwards looked at whether the hand had already settled. A dealt natural settles inside the deal, so the stake is back (with the win, or as a push) before the test looks, and it failed whenever the shoe handed out a blackjack. It now checks for a settled natural first and, if it finds one, only asks that the stack didn't lose more than the bet.
283 lines
9.2 KiB
Go
283 lines
9.2 KiB
Go
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.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: the stake has already come back (with the
|
|
// win, or as a push), and both dealer cards are legitimately shown. Checking
|
|
// the stack for 900 before this made the test fail on every dealt natural.
|
|
if v.Hand.Phase == "done" {
|
|
if v.Chips < 900 {
|
|
t.Fatalf("chips after a settled natural = %d, want at least the 900 left after the bet", v.Chips)
|
|
}
|
|
return
|
|
}
|
|
if v.Chips != 900 {
|
|
t.Fatalf("chips after a 100 bet = %d, want 900", v.Chips)
|
|
}
|
|
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)
|
|
}
|
|
}
|