Files
galaxy-game/backend/internal/engineclient/client_test.go
T
Ilia Denisov 15d35f6f1f
Tests · Go / test (push) Successful in 1m57s
Tests · Integration / integration (pull_request) Successful in 1m48s
Tests · Go / test (pull_request) Successful in 2m0s
feat(game): canonical gameId in POST /api/v1/admin/init
Engine no longer mints its own game UUID. The orchestrator (backend)
generates the game UUID at game-create time and passes it in the
admin/init request body as the required `gameId` field, so the value
that names the engine container and host bind-mount directory also
ends up inside the engine's state.json.

The engine rejects the zero UUID with 400 and any init that conflicts
with an existing state.json with 409 (a second init on the same gameId
is also a conflict; full idempotency is not part of the contract).

Updates rest.InitRequest, openapi.yaml (schema + 409 response),
controller.GenerateGame/NewGame/buildGameOnMap signatures, the engine
HTTP handler/executor, the backend runtime worker, and the relevant
unit and contract tests. Documentation in game/README.md,
docs/ARCHITECTURE.md, backend/README.md, and backend/docs/{runtime,flows}.md
is updated in the same patch.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-29 13:13:31 +02:00

363 lines
11 KiB
Go

package engineclient
import (
"context"
"encoding/json"
"errors"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"galaxy/model/rest"
"github.com/google/uuid"
)
func newTestClient(t *testing.T, srv *httptest.Server) *Client {
t.Helper()
cli, err := NewClientWithHTTP(Config{CallTimeout: 2 * time.Second, ProbeTimeout: 1 * time.Second}, srv.Client())
if err != nil {
t.Fatalf("NewClientWithHTTP: %v", err)
}
return cli
}
func TestClientInitSuccess(t *testing.T) {
wantID := uuid.New()
var gotReq rest.InitRequest
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != pathAdminInit {
t.Fatalf("unexpected path: %s", r.URL.Path)
}
if r.Method != http.MethodPost {
t.Fatalf("unexpected method: %s", r.Method)
}
if err := json.NewDecoder(r.Body).Decode(&gotReq); err != nil {
t.Fatalf("decode request: %v", err)
}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(rest.StateResponse{ID: wantID, Turn: 1, Players: []rest.PlayerState{{ID: uuid.New(), RaceName: "alpha"}}})
}))
t.Cleanup(srv.Close)
cli := newTestClient(t, srv)
got, err := cli.Init(context.Background(), srv.URL, rest.InitRequest{GameID: wantID, Races: []rest.InitRace{{RaceName: "alpha"}}})
if err != nil {
t.Fatalf("Init returned error: %v", err)
}
if got.ID != wantID {
t.Fatalf("ID = %s, want %s", got.ID, wantID)
}
if got.Turn != 1 {
t.Fatalf("Turn = %d, want 1", got.Turn)
}
if gotReq.GameID != wantID {
t.Fatalf("request gameId = %s, want %s", gotReq.GameID, wantID)
}
}
func TestClientInitValidationError(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
http.Error(w, `{"reason":"races empty"}`, http.StatusBadRequest)
}))
t.Cleanup(srv.Close)
cli := newTestClient(t, srv)
_, err := cli.Init(context.Background(), srv.URL, rest.InitRequest{Races: []rest.InitRace{{RaceName: "x"}}})
if !errors.Is(err, ErrEngineValidation) {
t.Fatalf("expected ErrEngineValidation, got %v", err)
}
}
func TestClientInitUnreachableOn5xx(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
http.Error(w, "boom", http.StatusInternalServerError)
}))
t.Cleanup(srv.Close)
cli := newTestClient(t, srv)
_, err := cli.Init(context.Background(), srv.URL, rest.InitRequest{Races: []rest.InitRace{{RaceName: "x"}}})
if !errors.Is(err, ErrEngineUnreachable) {
t.Fatalf("expected ErrEngineUnreachable, got %v", err)
}
}
func TestClientInitProtocolViolation(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte("not-json"))
}))
t.Cleanup(srv.Close)
cli := newTestClient(t, srv)
_, err := cli.Init(context.Background(), srv.URL, rest.InitRequest{Races: []rest.InitRace{{RaceName: "x"}}})
if !errors.Is(err, ErrEngineProtocolViolation) {
t.Fatalf("expected ErrEngineProtocolViolation, got %v", err)
}
}
func TestClientStatusOK(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != pathAdminStatus || r.Method != http.MethodGet {
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
}
_ = json.NewEncoder(w).Encode(rest.StateResponse{Turn: 5})
}))
t.Cleanup(srv.Close)
cli := newTestClient(t, srv)
got, err := cli.Status(context.Background(), srv.URL)
if err != nil {
t.Fatalf("Status: %v", err)
}
if got.Turn != 5 {
t.Fatalf("Turn = %d, want 5", got.Turn)
}
}
func TestClientTurnOK(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != pathAdminTurn || r.Method != http.MethodPut {
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
}
_ = json.NewEncoder(w).Encode(rest.StateResponse{Turn: 6, Finished: true})
}))
t.Cleanup(srv.Close)
cli := newTestClient(t, srv)
got, err := cli.Turn(context.Background(), srv.URL)
if err != nil {
t.Fatalf("Turn: %v", err)
}
if !got.Finished {
t.Fatalf("expected finished=true")
}
}
func TestClientBanishRace(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != pathAdminRaceBanish || r.Method != http.MethodPost {
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
}
var got rest.BanishRequest
_ = json.NewDecoder(r.Body).Decode(&got)
if got.RaceName != "loser" {
t.Fatalf("got race name %q", got.RaceName)
}
w.WriteHeader(http.StatusNoContent)
}))
t.Cleanup(srv.Close)
cli := newTestClient(t, srv)
if err := cli.BanishRace(context.Background(), srv.URL, "loser"); err != nil {
t.Fatalf("BanishRace: %v", err)
}
}
func TestClientCommandsForwardsBody(t *testing.T) {
want := json.RawMessage(`{"actor":"alpha","cmd":[{"@type":"raceQuit"}]}`)
gotResp := json.RawMessage(`{"applied":true}`)
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != pathPlayerCommand || r.Method != http.MethodPut {
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
}
_, _ = w.Write(gotResp)
}))
t.Cleanup(srv.Close)
cli := newTestClient(t, srv)
resp, err := cli.ExecuteCommands(context.Background(), srv.URL, want)
if err != nil {
t.Fatalf("ExecuteCommands: %v", err)
}
if string(resp) != string(gotResp) {
t.Fatalf("response = %s, want %s", string(resp), string(gotResp))
}
}
func TestClientReportsForwardsQuery(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != pathPlayerReport {
t.Fatalf("unexpected path: %s", r.URL.Path)
}
if r.URL.Query().Get("player") != "alpha" {
t.Fatalf("player = %q", r.URL.Query().Get("player"))
}
if r.URL.Query().Get("turn") != "3" {
t.Fatalf("turn = %q", r.URL.Query().Get("turn"))
}
_, _ = w.Write([]byte(`{"turn":3}`))
}))
t.Cleanup(srv.Close)
cli := newTestClient(t, srv)
body, err := cli.GetReport(context.Background(), srv.URL, "alpha", 3)
if err != nil {
t.Fatalf("GetReport: %v", err)
}
if !strings.Contains(string(body), `"turn":3`) {
t.Fatalf("body = %s", body)
}
}
func TestClientGetOrderForwardsQuery(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != pathPlayerOrder {
t.Fatalf("unexpected path: %s", r.URL.Path)
}
if r.Method != http.MethodGet {
t.Fatalf("unexpected method: %s", r.Method)
}
if r.URL.Query().Get("player") != "alpha" {
t.Fatalf("player = %q", r.URL.Query().Get("player"))
}
if r.URL.Query().Get("turn") != "3" {
t.Fatalf("turn = %q", r.URL.Query().Get("turn"))
}
_, _ = w.Write([]byte(`{"game_id":"abc","updatedAt":99,"cmd":[]}`))
}))
t.Cleanup(srv.Close)
cli := newTestClient(t, srv)
body, status, err := cli.GetOrder(context.Background(), srv.URL, "alpha", 3)
if err != nil {
t.Fatalf("GetOrder: %v", err)
}
if status != http.StatusOK {
t.Fatalf("status = %d", status)
}
if !strings.Contains(string(body), `"updatedAt":99`) {
t.Fatalf("body = %s", body)
}
}
func TestClientGetOrderNoContent(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusNoContent)
}))
t.Cleanup(srv.Close)
cli := newTestClient(t, srv)
body, status, err := cli.GetOrder(context.Background(), srv.URL, "alpha", 3)
if err != nil {
t.Fatalf("GetOrder: %v", err)
}
if status != http.StatusNoContent {
t.Fatalf("status = %d", status)
}
if body != nil {
t.Fatalf("expected nil body on 204, got %s", body)
}
}
func TestClientGetOrderRejectsBadInput(t *testing.T) {
cli := newTestClient(t, httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
t.Fatal("server must not be hit on bad input")
})))
if _, _, err := cli.GetOrder(context.Background(), "http://example.com", "", 0); err == nil {
t.Fatal("expected error on empty race name")
}
if _, _, err := cli.GetOrder(context.Background(), "http://example.com", "alpha", -1); err == nil {
t.Fatal("expected error on negative turn")
}
}
func TestClientFetchBattleForwardsPath(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
t.Fatalf("unexpected method: %s", r.Method)
}
want := pathPlayerBattle + "/3/" + "11111111-1111-1111-1111-111111111111"
if r.URL.Path != want {
t.Fatalf("path = %q, want %q", r.URL.Path, want)
}
_, _ = w.Write([]byte(`{"id":"11111111-1111-1111-1111-111111111111","planet":4}`))
}))
t.Cleanup(srv.Close)
cli := newTestClient(t, srv)
body, status, err := cli.FetchBattle(context.Background(), srv.URL, 3, "11111111-1111-1111-1111-111111111111")
if err != nil {
t.Fatalf("FetchBattle: %v", err)
}
if status != http.StatusOK {
t.Fatalf("status = %d", status)
}
if !strings.Contains(string(body), `"planet":4`) {
t.Fatalf("body = %s", body)
}
}
func TestClientFetchBattleNotFound(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusNotFound)
}))
t.Cleanup(srv.Close)
cli := newTestClient(t, srv)
body, status, err := cli.FetchBattle(context.Background(), srv.URL, 0, "11111111-1111-1111-1111-111111111111")
if err != nil {
t.Fatalf("FetchBattle: %v", err)
}
if status != http.StatusNotFound {
t.Fatalf("status = %d", status)
}
if body != nil {
t.Fatalf("expected nil body on 404, got %s", body)
}
}
func TestClientFetchBattleRejectsBadInput(t *testing.T) {
cli := newTestClient(t, httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
t.Fatal("server must not be hit on bad input")
})))
if _, _, err := cli.FetchBattle(context.Background(), "http://example.com", -1, "11111111-1111-1111-1111-111111111111"); err == nil {
t.Fatal("expected error on negative turn")
}
if _, _, err := cli.FetchBattle(context.Background(), "http://example.com", 0, ""); err == nil {
t.Fatal("expected error on empty battle id")
}
}
func TestClientHealthzSuccess(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != pathHealthz {
t.Fatalf("unexpected path: %s", r.URL.Path)
}
_, _ = w.Write([]byte(`{"status":"ok"}`))
}))
t.Cleanup(srv.Close)
cli := newTestClient(t, srv)
if err := cli.Healthz(context.Background(), srv.URL); err != nil {
t.Fatalf("Healthz: %v", err)
}
}
func TestClientHealthzFailure(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
http.Error(w, "down", http.StatusServiceUnavailable)
}))
t.Cleanup(srv.Close)
cli := newTestClient(t, srv)
if err := cli.Healthz(context.Background(), srv.URL); !errors.Is(err, ErrEngineUnreachable) {
t.Fatalf("expected ErrEngineUnreachable, got %v", err)
}
}
func TestClientRejectsInvalidBaseURL(t *testing.T) {
cli, err := NewClientWithHTTP(Config{CallTimeout: time.Second, ProbeTimeout: time.Second}, http.DefaultClient)
if err != nil {
t.Fatalf("NewClientWithHTTP: %v", err)
}
if _, err := cli.Status(context.Background(), ""); err == nil {
t.Fatalf("expected error on empty base URL")
}
if _, err := cli.Status(context.Background(), "ftp://example.test"); err == nil {
t.Fatalf("expected error on non-http base URL")
}
}