feat: the Go server SDK
This commit is contained in:
+254
@@ -0,0 +1,254 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user