From 4ffcac00d08ca6104dc4b0c9b0e3a3ab7b53a056 Mon Sep 17 00:00:00 2001 From: Ilia Denisov Date: Wed, 13 May 2026 11:28:28 +0200 Subject: [PATCH] tests, docs: game engine fetch battle api --- game/internal/router/battle_test.go | 152 +++++++++++++++++++ game/internal/router/router_helper_test.go | 11 +- game/openapi.yaml | 161 +++++++++++++++++++++ game/openapi_contract_test.go | 56 +++++++ 4 files changed, 379 insertions(+), 1 deletion(-) create mode 100644 game/internal/router/battle_test.go diff --git a/game/internal/router/battle_test.go b/game/internal/router/battle_test.go new file mode 100644 index 0000000..5c4ab87 --- /dev/null +++ b/game/internal/router/battle_test.go @@ -0,0 +1,152 @@ +package router_test + +import ( + "encoding/json" + "errors" + "fmt" + "net/http" + "net/http/httptest" + "testing" + + "galaxy/model/report" + + "github.com/google/uuid" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestGetBattleValidation(t *testing.T) { + validUUID := uuid.New().String() + + for _, tc := range []struct { + description string + turn string + battleID string + expectStatus int + }{ + {"Negative turn", "-1", validUUID, http.StatusBadRequest}, + {"Non-numeric turn", "abc", validUUID, http.StatusBadRequest}, + {"Invalid uuid", "0", invalidId, http.StatusBadRequest}, + } { + t.Run(tc.description, func(t *testing.T) { + e := &dummyExecutor{} + r := setupRouterExecutor(e) + + w := httptest.NewRecorder() + path := fmt.Sprintf("/api/v1/battle/%s/%s", tc.turn, tc.battleID) + req, _ := http.NewRequest(http.MethodGet, path, nil) + r.ServeHTTP(w, req) + + assert.Equal(t, tc.expectStatus, w.Code, w.Body) + assert.Equal(t, uuid.Nil, e.FetchBattleID, "FetchBattle must not be called on validation error") + }) + } +} + +func TestGetBattleFound(t *testing.T) { + id := uuid.New() + raceA := uuid.New() + raceB := uuid.New() + stored := &report.BattleReport{ + ID: id, + Planet: 42, + PlanetName: "X-Prime", + Races: map[int]uuid.UUID{ + 0: raceA, + 1: raceB, + }, + Ships: map[int]report.BattleReportGroup{ + 10: { + Race: "Alpha", + ClassName: "Drone", + Tech: map[string]report.Float{"WEAPONS": report.F(1)}, + Number: 5, + NumberLeft: 3, + LoadType: "EMP", + LoadQuantity: report.F(0), + InBattle: true, + }, + 20: { + Race: "Beta", + ClassName: "Spy", + Tech: map[string]report.Float{"SHIELDS": report.F(2)}, + Number: 4, + NumberLeft: 0, + LoadType: "EMP", + LoadQuantity: report.F(0), + InBattle: true, + }, + }, + Protocol: []report.BattleActionReport{ + {Attacker: 0, AttackerShipClass: 10, Defender: 1, DefenderShipClass: 20, Destroyed: true}, + }, + } + e := &dummyExecutor{ + FetchBattleResult: stored, + FetchBattleOK: true, + } + r := setupRouterExecutor(e) + + w := httptest.NewRecorder() + path := fmt.Sprintf("/api/v1/battle/%d/%s", 7, id.String()) + req, _ := http.NewRequest(http.MethodGet, path, nil) + r.ServeHTTP(w, req) + + require.Equal(t, http.StatusOK, w.Code, w.Body) + assert.Equal(t, uint(7), e.FetchBattleTurn) + assert.Equal(t, id, e.FetchBattleID) + + var got report.BattleReport + require.NoError(t, json.Unmarshal(w.Body.Bytes(), &got)) + assert.Equal(t, stored.ID, got.ID) + assert.Equal(t, stored.Planet, got.Planet) + assert.Equal(t, stored.PlanetName, got.PlanetName) + assert.Equal(t, stored.Races, got.Races) + require.Len(t, got.Ships, len(stored.Ships)) + assert.Equal(t, stored.Ships[10].ClassName, got.Ships[10].ClassName) + assert.Equal(t, stored.Ships[20].NumberLeft, got.Ships[20].NumberLeft) + require.Len(t, got.Protocol, 1) + assert.Equal(t, stored.Protocol[0], got.Protocol[0]) +} + +func TestGetBattleTurnZero(t *testing.T) { + id := uuid.New() + e := &dummyExecutor{ + FetchBattleResult: &report.BattleReport{ID: id}, + FetchBattleOK: true, + } + r := setupRouterExecutor(e) + + w := httptest.NewRecorder() + req, _ := http.NewRequest(http.MethodGet, fmt.Sprintf("/api/v1/battle/0/%s", id.String()), nil) + r.ServeHTTP(w, req) + + require.Equal(t, http.StatusOK, w.Code, w.Body) + assert.Equal(t, uint(0), e.FetchBattleTurn) + assert.Equal(t, id, e.FetchBattleID) +} + +func TestGetBattleNotFound(t *testing.T) { + id := uuid.New() + e := &dummyExecutor{FetchBattleOK: false} + r := setupRouterExecutor(e) + + w := httptest.NewRecorder() + req, _ := http.NewRequest(http.MethodGet, fmt.Sprintf("/api/v1/battle/3/%s", id.String()), nil) + r.ServeHTTP(w, req) + + assert.Equal(t, http.StatusNotFound, w.Code, w.Body) + assert.Equal(t, uint(3), e.FetchBattleTurn) + assert.Equal(t, id, e.FetchBattleID) +} + +func TestGetBattleEngineError(t *testing.T) { + e := &dummyExecutor{FetchBattleErr: errors.New("engine boom")} + r := setupRouterExecutor(e) + + w := httptest.NewRecorder() + req, _ := http.NewRequest(http.MethodGet, fmt.Sprintf("/api/v1/battle/3/%s", uuid.NewString()), nil) + r.ServeHTTP(w, req) + + assert.Equal(t, http.StatusInternalServerError, w.Code, w.Body) +} diff --git a/game/internal/router/router_helper_test.go b/game/internal/router/router_helper_test.go index e42b90d..8b87936 100644 --- a/game/internal/router/router_helper_test.go +++ b/game/internal/router/router_helper_test.go @@ -45,6 +45,13 @@ type dummyExecutor struct { FetchOrderResult *order.UserGamesOrder FetchOrderOK bool FetchOrderErr error + + // FetchBattle controls and observes calls to FetchBattle. + FetchBattleTurn uint + FetchBattleID uuid.UUID + FetchBattleResult *report.BattleReport + FetchBattleOK bool + FetchBattleErr error } func (e *dummyExecutor) ValidateOrder(actor string, cmd ...order.DecodableCommand) (*order.UserGamesOrder, error) { @@ -69,7 +76,9 @@ func (e *dummyExecutor) FetchOrder(actor string, turn uint) (*order.UserGamesOrd } func (e *dummyExecutor) FetchBattle(turn uint, ID uuid.UUID) (*report.BattleReport, bool, error) { - return nil, false, nil + e.FetchBattleTurn = turn + e.FetchBattleID = ID + return e.FetchBattleResult, e.FetchBattleOK, e.FetchBattleErr } func (e *dummyExecutor) Execute(command ...handler.Command) error { diff --git a/game/openapi.yaml b/game/openapi.yaml index 37f8b36..1c126ce 100644 --- a/game/openapi.yaml +++ b/game/openapi.yaml @@ -207,6 +207,33 @@ paths: $ref: "#/components/responses/ValidationError" "500": $ref: "#/components/responses/InternalError" + /api/v1/battle/{turn}/{uuid}: + get: + tags: + - PlayerActions + operationId: getBattle + summary: Fetch a single battle report + description: | + Returns the full `BattleReport` for the supplied `turn` and battle + identifier. The `turn` segment must be a non-negative integer; the + `uuid` segment must be a valid RFC 4122 UUID. Responds with + `404 Not Found` when no battle is stored for the supplied pair. + parameters: + - $ref: "#/components/parameters/BattleTurnParam" + - $ref: "#/components/parameters/BattleIDParam" + responses: + "200": + description: Battle report for the supplied turn and identifier. + content: + application/json: + schema: + $ref: "#/components/schemas/BattleReport" + "400": + $ref: "#/components/responses/ValidationError" + "404": + description: No battle exists for the supplied turn and identifier. + "500": + $ref: "#/components/responses/InternalError" /api/v1/admin/turn: put: tags: @@ -265,6 +292,22 @@ components: type: integer minimum: 0 default: 0 + BattleTurnParam: + name: turn + in: path + required: true + description: Turn number the battle was generated on. + schema: + type: integer + minimum: 0 + BattleIDParam: + name: uuid + in: path + required: true + description: Battle identifier (RFC 4122 UUID). + schema: + type: string + format: uuid schemas: HealthzResponse: type: object @@ -788,6 +831,124 @@ components: wiped: type: boolean description: True when all population was eliminated by the bombing. + BattleReport: + type: object + description: | + Full battle report. `races` and `ships` are JSON objects whose + keys are stringified integers used to cross-reference entries + from `protocol`: a `BattleActionReport` carries integer indices + into both maps. The serialised key is a string because JSON + object keys are always strings. + required: + - id + - planet + - planetName + - races + - ships + - protocol + properties: + id: + type: string + format: uuid + description: Battle identifier. + planet: + type: integer + minimum: 0 + description: Planet number the battle took place on. + planetName: + type: string + description: Planet name at battle start. + races: + type: object + description: | + Participating races keyed by the integer index used in + `protocol.a` / `protocol.d`. Values are race identifiers. + additionalProperties: + type: string + format: uuid + ships: + type: object + description: | + Participating ship groups keyed by the integer index used + in `protocol.sa` / `protocol.sd`. + additionalProperties: + $ref: "#/components/schemas/BattleReportGroup" + protocol: + type: array + description: Ordered list of shots exchanged during the battle. + items: + $ref: "#/components/schemas/BattleActionReport" + BattleReportGroup: + type: object + description: One ship group participating in the battle. + required: + - race + - className + - tech + - num + - numLeft + - loadType + - loadQuantity + - inBattle + properties: + race: + type: string + description: Race name of the group owner. + className: + type: string + description: Ship class name; resolvable through `LocalShipClass` or `OtherShipClass`. + tech: + type: object + description: Technology levels keyed by tech type name. + additionalProperties: + type: number + num: + type: integer + minimum: 0 + description: Initial number of ships in this group. + numLeft: + type: integer + minimum: 0 + description: Number of ships remaining at the end of the battle. + loadType: + type: string + description: Type of cargo loaded. + loadQuantity: + type: number + description: Quantity of cargo loaded. + inBattle: + type: boolean + description: | + True when the group actually fights. False groups observe + the battle in peace state and never fire or take damage. + BattleActionReport: + type: object + description: | + One shot in the battle. Attacker and defender indices reference + `BattleReport.races`; ship-class indices reference + `BattleReport.ships`. + required: + - a + - sa + - d + - sd + - x + properties: + a: + type: integer + description: Index into `BattleReport.races` for the attacker. + sa: + type: integer + description: Index into `BattleReport.ships` for the attacker's group. + d: + type: integer + description: Index into `BattleReport.races` for the defender. + sd: + type: integer + description: Index into `BattleReport.ships` for the defender's group. + x: + type: boolean + description: True when the defender ship was destroyed by this shot. IncomingGroup: type: object description: An identified ship group inbound toward a planet of this race. diff --git a/game/openapi_contract_test.go b/game/openapi_contract_test.go index 12446da..1c0b210 100644 --- a/game/openapi_contract_test.go +++ b/game/openapi_contract_test.go @@ -79,6 +79,13 @@ func TestGameOpenAPISpecFreezesResponseSchemas(t *testing.T) { status: http.StatusOK, wantRef: "#/components/schemas/HealthzResponse", }, + { + name: "get battle", + path: "/api/v1/battle/{turn}/{uuid}", + method: http.MethodGet, + status: http.StatusOK, + wantRef: "#/components/schemas/BattleReport", + }, } for _, tt := range tests { @@ -271,6 +278,55 @@ func TestGameOpenAPISpecFreezesCommandRequest(t *testing.T) { require.Equal(t, uint64(1), cmdSchema.Value.MinItems, "CommandRequest.cmd minItems must be 1") } +func TestGameOpenAPISpecFreezesGetBattleOperation(t *testing.T) { + t.Parallel() + + doc := loadOpenAPISpec(t) + operation := getOpenAPIOperation(t, doc, "/api/v1/battle/{turn}/{uuid}", http.MethodGet) + + require.Equal(t, "getBattle", operation.OperationID, "GET /api/v1/battle/{turn}/{uuid} operation id") + + paramRefs := make(map[string]bool) + for _, p := range operation.Parameters { + require.NotNil(t, p.Value, "parameter must have value") + paramRefs[p.Ref] = true + } + require.True(t, paramRefs["#/components/parameters/BattleTurnParam"], "GET /api/v1/battle/{turn}/{uuid} must reference BattleTurnParam") + require.True(t, paramRefs["#/components/parameters/BattleIDParam"], "GET /api/v1/battle/{turn}/{uuid} must reference BattleIDParam") + + require.NotNil(t, operation.Responses, "operation must declare responses") + notFound := operation.Responses.Status(http.StatusNotFound) + require.NotNil(t, notFound, "operation must declare 404 response") + require.NotNil(t, notFound.Value, "404 response must have a value") +} + +func TestGameOpenAPISpecFreezesBattleReport(t *testing.T) { + t.Parallel() + + doc := loadOpenAPISpec(t) + + reportSchema := componentSchemaRef(t, doc, "BattleReport") + assertRequiredFields(t, reportSchema, "id", "planet", "planetName", "races", "ships", "protocol") + + groupSchema := componentSchemaRef(t, doc, "BattleReportGroup") + assertRequiredFields(t, groupSchema, "race", "className", "tech", "num", "numLeft", "loadType", "loadQuantity", "inBattle") + + actionSchema := componentSchemaRef(t, doc, "BattleActionReport") + assertRequiredFields(t, actionSchema, "a", "sa", "d", "sd", "x") + + protocolSchema := reportSchema.Value.Properties["protocol"] + require.NotNil(t, protocolSchema, "BattleReport.protocol schema must exist") + require.True(t, protocolSchema.Value.Type.Is("array"), "BattleReport.protocol must be array") + require.NotNil(t, protocolSchema.Value.Items, "BattleReport.protocol items must be defined") + assertSchemaRef(t, protocolSchema.Value.Items, "#/components/schemas/BattleActionReport", "BattleReport.protocol items schema") + + shipsSchema := reportSchema.Value.Properties["ships"] + require.NotNil(t, shipsSchema, "BattleReport.ships schema must exist") + require.True(t, shipsSchema.Value.Type.Is("object"), "BattleReport.ships must be object") + require.NotNil(t, shipsSchema.Value.AdditionalProperties.Schema, "BattleReport.ships additionalProperties must be a schema") + assertSchemaRef(t, shipsSchema.Value.AdditionalProperties.Schema, "#/components/schemas/BattleReportGroup", "BattleReport.ships additionalProperties schema") +} + func TestGameOpenAPISpecHealthzStatusEnum(t *testing.T) { t.Parallel()