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()) }