364 lines
13 KiB
Go
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())
|
|
}
|