package server import ( "context" "errors" "io" "net/http" "net/http/httptest" "sync/atomic" "testing" "time" ) type request struct { method string path string headers http.Header body string } func testClient(t *testing.T, handler http.Handler) *Client { t.Helper() server := httptest.NewServer(handler) t.Cleanup(server.Close) return New(Options{ ServerKey: "server_key", Address: server.URL + "/", }) } func readBody(t *testing.T, r *http.Request) string { t.Helper() body, err := io.ReadAll(r.Body) if err != nil { t.Fatal(err) } return string(body) } func TestQuerySendsThePlayerAndDecodesTheResult(t *testing.T) { var sent request client := testClient(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { sent = request{method: r.Method, path: r.URL.Path, headers: r.Header, body: readBody(t, r)} _, _ = w.Write([]byte(`{"result": {"asset": "gems", "amount": 5}}`)) })) amount, err := Query[Amount](context.Background(), client, "wallet", "balance", "player-1", nil) if err != nil { t.Fatal(err) } if amount.Asset != "gems" || amount.Amount != 5 { t.Fatalf("amount %+v, want 5 gems", amount) } if sent.method != http.MethodPost || sent.path != "/server/v1/players/player-1/modules/wallet/balance" { t.Fatalf("request %s %s, want POST the module of the player", sent.method, sent.path) } if sent.headers.Get("X-Rudder-Server-Key") != "server_key" { t.Fatalf("server key header %q", sent.headers.Get("X-Rudder-Server-Key")) } if sent.headers.Get("Idempotency-Key") != "" { t.Fatalf("a query sent the idempotency key %q", sent.headers.Get("Idempotency-Key")) } if sent.body != `{"args":{}}` { t.Fatalf("query body %s", sent.body) } } func TestMutationGeneratesAndReusesTheCallerKey(t *testing.T) { var sent request client := testClient(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { sent = request{method: r.Method, path: r.URL.Path, headers: r.Header, body: readBody(t, r)} _, _ = w.Write([]byte(`{"result": {"offer": "welcome"}}`)) })) input := struct { Offer string `json:"offer"` }{Offer: "welcome"} result, err := Mutation[struct { Offer string `json:"offer"` }](context.Background(), client, "stores", "buy", "player-1", input, nil) if err != nil { t.Fatal(err) } if result.Offer != "welcome" { t.Fatalf("result %+v, want the welcome offer", result) } if key := sent.headers.Get("Idempotency-Key"); len(key) != 32 { t.Fatalf("generated key %q, want 32 hex characters", key) } if sent.body != `{"args":{"offer":"welcome"}}` { t.Fatalf("mutation body %s", sent.body) } client = testClient(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { sent = request{headers: r.Header, body: readBody(t, r)} _, _ = w.Write([]byte(`{"result": null}`)) })) if _, err := Mutation[struct{}](context.Background(), client, "stores", "reset", "player-1", nil, &MutationOptions{IdempotencyKey: "mine"}); err != nil { t.Fatal(err) } if sent.headers.Get("Idempotency-Key") != "mine" { t.Fatalf("key %q, want the caller's", sent.headers.Get("Idempotency-Key")) } } func TestEnvironmentReadsTheKeyEnvironment(t *testing.T) { var sent request client := testClient(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { sent = request{method: r.Method, path: r.URL.Path, headers: r.Header, body: readBody(t, r)} _, _ = w.Write([]byte(`{"environment": "staging"}`)) })) environment, err := client.Environment(context.Background()) if err != nil { t.Fatal(err) } if environment != Staging { t.Fatalf("environment %q, want staging", environment) } if sent.method != http.MethodGet || sent.path != "/server/v1/environment" { t.Fatalf("request %s %s", sent.method, sent.path) } if sent.body != "" { t.Fatalf("environment body %q, want none", sent.body) } } func TestProfileBanAndUnban(t *testing.T) { var sent []request client := testClient(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { sent = append(sent, request{method: r.Method, path: r.URL.Path, headers: r.Header, body: readBody(t, r)}) w.WriteHeader(http.StatusNoContent) })) until := time.Date(2026, 12, 1, 0, 0, 0, 0, time.UTC) if err := client.SetPlayerProfile(context.Background(), "player-1", Profile{Nickname: "Ada", Avatar: "https://example.com/ada.png"}); err != nil { t.Fatal(err) } if err := client.BanPlayer(context.Background(), "player-1", Ban{Until: until, Reason: "cheating"}); err != nil { t.Fatal(err) } if err := client.UnbanPlayer(context.Background(), "player-1"); err != nil { t.Fatal(err) } if len(sent) != 3 { t.Fatalf("%d requests, want three", len(sent)) } if sent[0].method != http.MethodPut || sent[0].path != "/server/v1/players/player-1/profile" || sent[0].body != `{"nickname":"Ada","avatar":"https://example.com/ada.png"}` { t.Fatalf("profile request %+v", sent[0]) } if sent[1].method != http.MethodPut || sent[1].path != "/server/v1/players/player-1/ban" || sent[1].body != `{"until":"2026-12-01T00:00:00Z","reason":"cheating"}` { t.Fatalf("ban request %+v", sent[1]) } if sent[2].method != http.MethodDelete || sent[2].path != "/server/v1/players/player-1/ban" || sent[2].body != "" { t.Fatalf("unban request %+v", sent[2]) } } func TestTryAgainKeepsTheSameKey(t *testing.T) { var attempts int32 var keys []string client := testClient(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { atomic.AddInt32(&attempts, 1) keys = append(keys, r.Header.Get("Idempotency-Key")) if len(keys) == 1 { w.Header().Set("Retry-After", "0") w.WriteHeader(http.StatusServiceUnavailable) _, _ = w.Write([]byte(`{"error": {"code": "try_again", "message": "try again"}}`)) return } _, _ = w.Write([]byte(`{"result": null}`)) })) if _, err := Mutation[struct{}](context.Background(), client, "stores", "reset", "player-1", nil, nil); err != nil { t.Fatal(err) } if attempts != 2 { t.Fatalf("%d attempts, want two", attempts) } if keys[0] != keys[1] { t.Fatalf("keys %v, want the same", keys) } } func TestRefusalIsAnErrorWithTheCodeAndStatus(t *testing.T) { client := testClient(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusNotFound) _, _ = w.Write([]byte(`{"error": {"code": "player_not_found", "message": "no such player"}}`)) })) _, err := Query[Amount](context.Background(), client, "wallet", "balance", "player-1", nil) if err == nil { t.Fatal("a refusal: no error") } var failure *Error if !errors.As(err, &failure) { t.Fatalf("error %T, want *server.Error", err) } if failure.Code != "player_not_found" || failure.Status != http.StatusNotFound { t.Fatalf("error %+v", failure) } } func TestAnswerThatIsNotJSON(t *testing.T) { client := testClient(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { _, _ = w.Write([]byte("not json")) })) _, err := Query[Amount](context.Background(), client, "wallet", "balance", "player-1", nil) var failure *Error if !errors.As(err, &failure) || failure.Code != "invalid_response" { t.Fatalf("error %v, want invalid_response", err) } }