Three bearer-authed endpoints and gogobee can work the border: poll what's waiting, claim a row, report what happened to the money. The storage layer underneath was already done; this is the transport, and deliberately nothing more. All three are idempotent, because the thing on the other end of them is a retrying queue and the thing they move is money. A verdict delivered three times creates chips once. A rejected buy-in moves nothing and clears the pending amount so it stops eating the table cap. A cash-out gogobee couldn't pay gives the chips back rather than vanishing them from both sides. A verdict for a row Pete has never heard of is a 400, not a shrug: gogobee has by then moved real euros against it, and no amount of retrying invents the missing row. Under the contract the adventure seam set, a 400 parks it in gogobee's queue where a human can find it.
249 lines
8.4 KiB
Go
249 lines
8.4 KiB
Go
package web
|
|
|
|
import (
|
|
"bytes"
|
|
"encoding/json"
|
|
"net/http/httptest"
|
|
"testing"
|
|
|
|
"pete/internal/storage"
|
|
)
|
|
|
|
// pollEscrow runs one gogobee poll.
|
|
func pollEscrow(t *testing.T, s *Server, token string) ([]storage.Escrow, int) {
|
|
t.Helper()
|
|
req := httptest.NewRequest("GET", "/api/games/escrow/pending", nil)
|
|
req.Header.Set("Authorization", "Bearer "+token)
|
|
w := httptest.NewRecorder()
|
|
s.handleEscrowPending(w, req)
|
|
if w.Code != 200 {
|
|
return nil, w.Code
|
|
}
|
|
var out []storage.Escrow
|
|
if err := json.Unmarshal(w.Body.Bytes(), &out); err != nil {
|
|
t.Fatalf("decode pending: %v (body %q)", err, w.Body.String())
|
|
}
|
|
return out, w.Code
|
|
}
|
|
|
|
func claimEscrow(t *testing.T, s *Server, token, guid string) (storage.Escrow, int) {
|
|
t.Helper()
|
|
body, _ := json.Marshal(escrowGUID{GUID: guid})
|
|
req := httptest.NewRequest("POST", "/api/games/escrow/claim", bytes.NewReader(body))
|
|
req.Header.Set("Authorization", "Bearer "+token)
|
|
w := httptest.NewRecorder()
|
|
s.handleEscrowClaim(w, req)
|
|
var e storage.Escrow
|
|
if w.Code == 200 {
|
|
_ = json.Unmarshal(w.Body.Bytes(), &e)
|
|
}
|
|
return e, w.Code
|
|
}
|
|
|
|
func settleEscrow(t *testing.T, s *Server, token string, v escrowVerdict) (storage.Escrow, int) {
|
|
t.Helper()
|
|
body, _ := json.Marshal(v)
|
|
req := httptest.NewRequest("POST", "/api/games/escrow/settled", bytes.NewReader(body))
|
|
req.Header.Set("Authorization", "Bearer "+token)
|
|
w := httptest.NewRecorder()
|
|
s.handleEscrowSettled(w, req)
|
|
var e storage.Escrow
|
|
if w.Code == 200 {
|
|
_ = json.Unmarshal(w.Body.Bytes(), &e)
|
|
}
|
|
return e, w.Code
|
|
}
|
|
|
|
// TestEscrowBuyInRoundTrip walks the happy path a player actually takes: ask for
|
|
// chips, watch gogobee pick the row up, and have the chips appear only once the
|
|
// euros are confirmed gone.
|
|
func TestEscrowBuyInRoundTrip(t *testing.T) {
|
|
s, _ := newAdvServer(t, "tok")
|
|
const user = "@reala:parodia.dev"
|
|
|
|
req, err := storage.RequestBuyIn(user, 500)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
// No chips yet. The euros are still gogobee's.
|
|
if st, _ := storage.Chips(user); st.Chips != 0 || st.Pending != 500 {
|
|
t.Fatalf("before settle: chips=%d pending=%d, want 0/500", st.Chips, st.Pending)
|
|
}
|
|
|
|
pending, code := pollEscrow(t, s, "tok")
|
|
if code != 200 || len(pending) != 1 || pending[0].GUID != req.GUID {
|
|
t.Fatalf("poll = %d %+v, want the one buy-in", code, pending)
|
|
}
|
|
if pending[0].MatrixUser != user || pending[0].Kind != storage.KindBuyIn || pending[0].Amount != 500 {
|
|
t.Fatalf("poll row = %+v, want the row we opened", pending[0])
|
|
}
|
|
|
|
claimed, code := claimEscrow(t, s, "tok", req.GUID)
|
|
if code != 200 || claimed.State != storage.EscrowClaimed {
|
|
t.Fatalf("claim = %d state=%q, want 200/claimed", code, claimed.State)
|
|
}
|
|
|
|
e, code := settleEscrow(t, s, "tok", escrowVerdict{GUID: req.GUID, OK: true, BalanceAfter: 1500})
|
|
if code != 200 || e.State != storage.EscrowFunded {
|
|
t.Fatalf("settle = %d state=%q, want 200/funded", code, e.State)
|
|
}
|
|
|
|
st, _ := storage.Chips(user)
|
|
if st.Chips != 500 || st.Pending != 0 {
|
|
t.Fatalf("after settle: chips=%d pending=%d, want 500/0", st.Chips, st.Pending)
|
|
}
|
|
if st.EuroBalance != 1500 {
|
|
t.Fatalf("euro balance = %v, want the 1500 gogobee reported", st.EuroBalance)
|
|
}
|
|
|
|
// Settled rows are done. A later poll must not offer it again, or gogobee
|
|
// would debit the player a second time.
|
|
if pending, _ := pollEscrow(t, s, "tok"); len(pending) != 0 {
|
|
t.Fatalf("settled row still pending: %+v", pending)
|
|
}
|
|
}
|
|
|
|
// TestEscrowVerdictReplayedCreatesChipsOnce is the property the whole guid
|
|
// scheme exists for. gogobee's push queue retries: the same verdict lands twice,
|
|
// and only the first one may create chips.
|
|
func TestEscrowVerdictReplayedCreatesChipsOnce(t *testing.T) {
|
|
s, _ := newAdvServer(t, "tok")
|
|
const user = "@replay:parodia.dev"
|
|
|
|
req, err := storage.RequestBuyIn(user, 200)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if _, code := claimEscrow(t, s, "tok", req.GUID); code != 200 {
|
|
t.Fatalf("claim = %d", code)
|
|
}
|
|
|
|
for i := 0; i < 3; i++ {
|
|
e, code := settleEscrow(t, s, "tok", escrowVerdict{GUID: req.GUID, OK: true, BalanceAfter: 800})
|
|
if code != 200 || e.State != storage.EscrowFunded {
|
|
t.Fatalf("settle %d = %d state=%q", i, code, e.State)
|
|
}
|
|
}
|
|
|
|
if st, _ := storage.Chips(user); st.Chips != 200 {
|
|
t.Fatalf("chips = %d after three deliveries of one verdict, want 200", st.Chips)
|
|
}
|
|
}
|
|
|
|
// TestEscrowRejectedBuyInMovesNothing — the player couldn't cover it. gogobee
|
|
// took no euros, so Pete must create no chips, and the pending amount has to
|
|
// clear or it would eat into the table cap forever.
|
|
func TestEscrowRejectedBuyInMovesNothing(t *testing.T) {
|
|
s, _ := newAdvServer(t, "tok")
|
|
const user = "@broke:parodia.dev"
|
|
|
|
req, err := storage.RequestBuyIn(user, 9000)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if _, code := claimEscrow(t, s, "tok", req.GUID); code != 200 {
|
|
t.Fatalf("claim = %d", code)
|
|
}
|
|
|
|
e, code := settleEscrow(t, s, "tok",
|
|
escrowVerdict{GUID: req.GUID, OK: false, Reason: "insufficient_funds", BalanceAfter: 12})
|
|
if code != 200 || e.State != storage.EscrowRejected {
|
|
t.Fatalf("settle = %d state=%q, want 200/rejected", code, e.State)
|
|
}
|
|
if e.Reason != "insufficient_funds" {
|
|
t.Fatalf("reason = %q, want it carried back for the player to read", e.Reason)
|
|
}
|
|
|
|
st, _ := storage.Chips(user)
|
|
if st.Chips != 0 || st.Pending != 0 {
|
|
t.Fatalf("after rejection: chips=%d pending=%d, want 0/0", st.Chips, st.Pending)
|
|
}
|
|
}
|
|
|
|
// TestEscrowCashOutFailureReturnsChips — gogobee couldn't pay. The chips were
|
|
// destroyed the moment the cash-out opened, so if we simply mark it done the
|
|
// player's money is gone from both sides. It has to come back.
|
|
func TestEscrowCashOutFailureReturnsChips(t *testing.T) {
|
|
s, _ := newAdvServer(t, "tok")
|
|
const user = "@unlucky:parodia.dev"
|
|
|
|
buy, err := storage.RequestBuyIn(user, 300)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if _, code := settleEscrow(t, s, "tok", escrowVerdict{GUID: buy.GUID, OK: true}); code != 200 {
|
|
t.Fatalf("fund = %d", code)
|
|
}
|
|
|
|
out, err := storage.RequestCashOut(user, 300)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if st, _ := storage.Chips(user); st.Chips != 0 {
|
|
t.Fatalf("chips during cash-out = %d, want 0 — they must not be bettable in flight", st.Chips)
|
|
}
|
|
|
|
e, code := settleEscrow(t, s, "tok", escrowVerdict{GUID: out.GUID, OK: false, Reason: "ledger_error"})
|
|
if code != 200 {
|
|
t.Fatalf("settle = %d", code)
|
|
}
|
|
if e.State != storage.EscrowFunded {
|
|
t.Fatalf("state = %q, want funded — a failed cash-out gives the chips back", e.State)
|
|
}
|
|
if st, _ := storage.Chips(user); st.Chips != 300 {
|
|
t.Fatalf("chips = %d after a failed cash-out, want the 300 back", st.Chips)
|
|
}
|
|
}
|
|
|
|
// TestEscrowUnknownGUIDIsA400 — a verdict for a row Pete has never heard of.
|
|
// gogobee has already moved real euros for it, and no amount of retrying will
|
|
// conjure the row, so the answer is the one that parks the row in gogobee's
|
|
// queue for a human rather than the one that retries forever.
|
|
func TestEscrowUnknownGUIDIsA400(t *testing.T) {
|
|
s, _ := newAdvServer(t, "tok")
|
|
if _, code := settleEscrow(t, s, "tok", escrowVerdict{GUID: "ghost", OK: true}); code != 400 {
|
|
t.Fatalf("settle unknown guid = %d, want 400", code)
|
|
}
|
|
if _, code := claimEscrow(t, s, "tok", "ghost"); code != 404 {
|
|
t.Fatalf("claim unknown guid = %d, want 404", code)
|
|
}
|
|
}
|
|
|
|
// TestEscrowNeedsTheBearerToken. These endpoints move money and are reachable on
|
|
// the same mux as the public news site.
|
|
func TestEscrowNeedsTheBearerToken(t *testing.T) {
|
|
s, _ := newAdvServer(t, "tok")
|
|
req, err := storage.RequestBuyIn("@x:parodia.dev", 10)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
if _, code := pollEscrow(t, s, "wrong"); code != 401 {
|
|
t.Fatalf("poll with a bad token = %d, want 401", code)
|
|
}
|
|
if _, code := claimEscrow(t, s, "", req.GUID); code != 401 {
|
|
t.Fatalf("claim with no token = %d, want 401", code)
|
|
}
|
|
if _, code := settleEscrow(t, s, "wrong", escrowVerdict{GUID: req.GUID, OK: true}); code != 401 {
|
|
t.Fatalf("settle with a bad token = %d, want 401", code)
|
|
}
|
|
if st, _ := storage.Chips("@x:parodia.dev"); st.Chips != 0 {
|
|
t.Fatal("an unauthenticated settle created chips")
|
|
}
|
|
}
|
|
|
|
// TestEscrowEmptyPollIsAnArrayNotNull. gogobee decodes into []Escrow; a bare
|
|
// `null` body decodes fine in Go but is a trap for anything else that ever reads
|
|
// this, and an empty poll is the overwhelmingly common case.
|
|
func TestEscrowEmptyPollIsAnArrayNotNull(t *testing.T) {
|
|
s, _ := newAdvServer(t, "tok")
|
|
req := httptest.NewRequest("GET", "/api/games/escrow/pending", nil)
|
|
req.Header.Set("Authorization", "Bearer tok")
|
|
w := httptest.NewRecorder()
|
|
s.handleEscrowPending(w, req)
|
|
if got := w.Body.String(); got != "[]\n" {
|
|
t.Fatalf("empty poll body = %q, want %q", got, "[]\n")
|
|
}
|
|
}
|