Files
galaxy-game/gamemaster/internal/adapters/engineclient/client_test.go
T
2026-05-03 07:59:03 +02:00

364 lines
13 KiB
Go

package engineclient
import (
"context"
"encoding/json"
"errors"
"io"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"galaxy/gamemaster/internal/ports"
)
func newTestClient(t *testing.T, callTimeout, probeTimeout time.Duration) *Client {
t.Helper()
client, err := NewClient(Config{CallTimeout: callTimeout, ProbeTimeout: probeTimeout})
require.NoError(t, err)
t.Cleanup(func() { _ = client.Close() })
return client
}
func TestNewClientValidatesConfig(t *testing.T) {
cases := map[string]Config{
"non-positive call timeout": {CallTimeout: 0, ProbeTimeout: time.Second},
"non-positive probe timeout": {CallTimeout: time.Second, ProbeTimeout: 0},
}
for name, cfg := range cases {
t.Run(name, func(t *testing.T) {
_, err := NewClient(cfg)
require.Error(t, err)
})
}
}
func TestInitHappyPath(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
require.Equal(t, http.MethodPost, r.Method)
require.Equal(t, "/api/v1/admin/init", r.URL.Path)
require.Equal(t, "application/json", r.Header.Get("Content-Type"))
body, err := io.ReadAll(r.Body)
require.NoError(t, err)
var got initRequestEnvelope
require.NoError(t, json.Unmarshal(body, &got))
require.Equal(t, []initRaceEnvelope{{RaceName: "Human"}, {RaceName: "Klingon"}}, got.Races)
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusCreated)
_, _ = w.Write([]byte(`{
"id": "00000000-0000-0000-0000-000000000001",
"turn": 0,
"stage": 0,
"finished": false,
"player": [
{"id":"00000000-0000-0000-0000-000000000010","raceName":"Human","planets":3,"population":1500,"extinct":false},
{"id":"00000000-0000-0000-0000-000000000011","raceName":"Klingon","planets":3,"population":1500,"extinct":false}
]
}`))
}))
defer server.Close()
client := newTestClient(t, time.Second, time.Second)
state, err := client.Init(context.Background(), server.URL, ports.InitRequest{
Races: []ports.InitRace{{RaceName: "Human"}, {RaceName: "Klingon"}},
})
require.NoError(t, err)
assert.Equal(t, 0, state.Turn)
assert.False(t, state.Finished)
require.Len(t, state.Players, 2)
assert.Equal(t, "Human", state.Players[0].RaceName)
assert.Equal(t, "00000000-0000-0000-0000-000000000010", state.Players[0].EnginePlayerUUID)
assert.Equal(t, 3, state.Players[0].Planets)
assert.Equal(t, 1500, state.Players[0].Population)
}
func TestInitRejectsEmptyRaces(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
t.Fatal("must not contact engine on empty races")
}))
defer server.Close()
client := newTestClient(t, time.Second, time.Second)
_, err := client.Init(context.Background(), server.URL, ports.InitRequest{})
require.Error(t, err)
}
func TestInitValidationErrorMapsToEngineValidation(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusBadRequest)
_, _ = w.Write([]byte(`{"error":"races must contain at least 10 entries"}`))
}))
defer server.Close()
client := newTestClient(t, time.Second, time.Second)
_, err := client.Init(context.Background(), server.URL, ports.InitRequest{
Races: []ports.InitRace{{RaceName: "X"}},
})
require.Error(t, err)
assert.True(t, errors.Is(err, ports.ErrEngineValidation))
assert.Contains(t, err.Error(), "must contain at least 10")
}
func TestInitInternalErrorMapsToUnreachable(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusInternalServerError)
_, _ = w.Write([]byte(`{"generic_error":"boom","code":42}`))
}))
defer server.Close()
client := newTestClient(t, time.Second, time.Second)
_, err := client.Init(context.Background(), server.URL, ports.InitRequest{Races: []ports.InitRace{{RaceName: "X"}}})
require.Error(t, err)
assert.True(t, errors.Is(err, ports.ErrEngineUnreachable))
assert.Contains(t, err.Error(), "code=42")
}
func TestStatusHappyPath(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
require.Equal(t, http.MethodGet, r.Method)
require.Equal(t, "/api/v1/admin/status", r.URL.Path)
_, _ = w.Write([]byte(`{
"id": "g-1",
"turn": 5,
"stage": 0,
"finished": false,
"player": [
{"id":"p-1","raceName":"Human","planets":4,"population":1700.0,"extinct":false}
]
}`))
}))
defer server.Close()
client := newTestClient(t, time.Second, time.Second)
state, err := client.Status(context.Background(), server.URL)
require.NoError(t, err)
assert.Equal(t, 5, state.Turn)
require.Len(t, state.Players, 1)
assert.Equal(t, "Human", state.Players[0].RaceName)
assert.Equal(t, 1700, state.Players[0].Population)
}
func TestStatusUsesProbeTimeout(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
time.Sleep(120 * time.Millisecond)
_, _ = w.Write([]byte(`{}`))
}))
defer server.Close()
client := newTestClient(t, time.Second, 30*time.Millisecond)
_, err := client.Status(context.Background(), server.URL)
require.Error(t, err)
assert.True(t, errors.Is(err, ports.ErrEngineUnreachable))
}
func TestTurnFinishedFlagPropagates(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
require.Equal(t, http.MethodPut, r.Method)
require.Equal(t, "/api/v1/admin/turn", r.URL.Path)
_, _ = w.Write([]byte(`{
"id":"g","turn":42,"stage":0,"finished":true,
"player":[{"id":"p1","raceName":"Human","planets":0,"population":0,"extinct":true}]
}`))
}))
defer server.Close()
client := newTestClient(t, time.Second, time.Second)
state, err := client.Turn(context.Background(), server.URL)
require.NoError(t, err)
assert.Equal(t, 42, state.Turn)
assert.True(t, state.Finished)
}
func TestDecodeProtocolViolations(t *testing.T) {
cases := map[string]string{
"missing id": `{"turn":0,"stage":0,"finished":false,"player":[]}`,
"missing player": `{"id":"g","turn":0,"stage":0,"finished":false}`,
"missing race name": `{"id":"g","turn":0,"stage":0,"finished":false,"player":[{"id":"p","planets":0,"population":0,"extinct":false}]}`,
"missing player id": `{"id":"g","turn":0,"stage":0,"finished":false,"player":[{"raceName":"X","planets":0,"population":0,"extinct":false}]}`,
"negative planets": `{"id":"g","turn":0,"stage":0,"finished":false,"player":[{"id":"p","raceName":"X","planets":-1,"population":0,"extinct":false}]}`,
"infinite population": `{"id":"g","turn":0,"stage":0,"finished":false,"player":[{"id":"p","raceName":"X","planets":1,"population":1e400,"extinct":false}]}`,
}
for name, body := range cases {
t.Run(name, func(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
_, _ = w.Write([]byte(body))
}))
defer server.Close()
client := newTestClient(t, time.Second, time.Second)
_, err := client.Status(context.Background(), server.URL)
require.Error(t, err)
assert.True(t, errors.Is(err, ports.ErrEngineProtocolViolation), "case %q: %v", name, err)
})
}
}
func TestBanishRaceHappyPath(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
require.Equal(t, http.MethodPost, r.Method)
require.Equal(t, "/api/v1/admin/race/banish", r.URL.Path)
var got banishRequestEnvelope
require.NoError(t, json.NewDecoder(r.Body).Decode(&got))
assert.Equal(t, "Klingon", got.RaceName)
w.WriteHeader(http.StatusNoContent)
}))
defer server.Close()
client := newTestClient(t, time.Second, time.Second)
require.NoError(t, client.BanishRace(context.Background(), server.URL, "Klingon"))
}
func TestBanishRaceRejectsBlankName(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
t.Fatal("must not contact engine on blank race name")
}))
defer server.Close()
client := newTestClient(t, time.Second, time.Second)
require.Error(t, client.BanishRace(context.Background(), server.URL, " "))
}
func TestBanishRaceValidationError(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusBadRequest)
_, _ = w.Write([]byte(`{"error":"unknown race"}`))
}))
defer server.Close()
client := newTestClient(t, time.Second, time.Second)
err := client.BanishRace(context.Background(), server.URL, "Vulcan")
require.Error(t, err)
assert.True(t, errors.Is(err, ports.ErrEngineValidation))
}
func TestExecuteCommandsHappyPath(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
require.Equal(t, http.MethodPut, r.Method)
require.Equal(t, "/api/v1/command", r.URL.Path)
body, _ := io.ReadAll(r.Body)
assert.JSONEq(t, `{"actor":"Human","cmd":[]}`, string(body))
w.WriteHeader(http.StatusNoContent)
}))
defer server.Close()
client := newTestClient(t, time.Second, time.Second)
body, err := client.ExecuteCommands(context.Background(), server.URL, json.RawMessage(`{"actor":"Human","cmd":[]}`))
require.NoError(t, err)
assert.Nil(t, body)
}
func TestExecuteCommandsValidationReturnsBody(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusBadRequest)
_, _ = w.Write([]byte(`{"error":"bad command"}`))
}))
defer server.Close()
client := newTestClient(t, time.Second, time.Second)
body, err := client.ExecuteCommands(context.Background(), server.URL, json.RawMessage(`{"actor":"Human","cmd":[{}]}`))
require.Error(t, err)
assert.True(t, errors.Is(err, ports.ErrEngineValidation))
assert.JSONEq(t, `{"error":"bad command"}`, string(body))
}
func TestExecuteCommandsRejectsEmptyPayload(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
t.Fatal("must not contact engine with empty payload")
}))
defer server.Close()
client := newTestClient(t, time.Second, time.Second)
_, err := client.ExecuteCommands(context.Background(), server.URL, json.RawMessage(` `))
require.Error(t, err)
}
func TestPutOrdersHappyPath(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
require.Equal(t, http.MethodPut, r.Method)
require.Equal(t, "/api/v1/order", r.URL.Path)
w.WriteHeader(http.StatusNoContent)
}))
defer server.Close()
client := newTestClient(t, time.Second, time.Second)
body, err := client.PutOrders(context.Background(), server.URL, json.RawMessage(`{"actor":"Human","cmd":[]}`))
require.NoError(t, err)
assert.Nil(t, body)
}
func TestGetReportHappyPath(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
require.Equal(t, http.MethodGet, r.Method)
require.Equal(t, "/api/v1/report", r.URL.Path)
assert.Equal(t, "Human", r.URL.Query().Get("player"))
assert.Equal(t, "7", r.URL.Query().Get("turn"))
_, _ = w.Write([]byte(`{"version":"1","turn":7,"race":"Human"}`))
}))
defer server.Close()
client := newTestClient(t, time.Second, time.Second)
body, err := client.GetReport(context.Background(), server.URL, "Human", 7)
require.NoError(t, err)
assert.JSONEq(t, `{"version":"1","turn":7,"race":"Human"}`, string(body))
}
func TestGetReportEmptyBodyIsProtocolViolation(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusOK)
}))
defer server.Close()
client := newTestClient(t, time.Second, time.Second)
_, err := client.GetReport(context.Background(), server.URL, "Human", 0)
require.Error(t, err)
assert.True(t, errors.Is(err, ports.ErrEngineProtocolViolation))
}
func TestGetReportRejectsBadInput(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
t.Fatal("must not contact engine on bad input")
}))
defer server.Close()
client := newTestClient(t, time.Second, time.Second)
_, err := client.GetReport(context.Background(), server.URL, " ", 0)
require.Error(t, err)
_, err = client.GetReport(context.Background(), server.URL, "Human", -1)
require.Error(t, err)
}
func TestValidateBaseRejectsBadURLs(t *testing.T) {
client := newTestClient(t, time.Second, time.Second)
_, err := client.Status(context.Background(), "")
require.Error(t, err)
_, err = client.Status(context.Background(), "engine:8080")
require.Error(t, err)
require.Contains(t, err.Error(), "absolute")
}
func TestCancelledContextSurfaces(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
t.Fatal("must not contact engine with cancelled context")
}))
defer server.Close()
client := newTestClient(t, time.Second, time.Second)
ctx, cancel := context.WithCancel(context.Background())
cancel()
_, err := client.Status(ctx, server.URL)
require.Error(t, err)
assert.True(t, errors.Is(err, context.Canceled))
}
func TestSummariseEngineErrorFallback(t *testing.T) {
got := summariseEngineError([]byte("not json"), 502)
assert.True(t, strings.Contains(got, "status=502"))
}
func TestCloseIsIdempotent(t *testing.T) {
client := newTestClient(t, time.Second, time.Second)
require.NoError(t, client.Close())
require.NoError(t, client.Close())
}