fix(game): #59 — per-command rejection on PUT /api/v1/order
Tests · Go / test (push) Successful in 2m2s
Tests · Go / test (pull_request) Successful in 3m3s
Tests · Integration / integration (pull_request) Successful in 1m40s

Validation of a player's order now applies every command against a
transient game-state snapshot and records the per-command outcome
(cmdApplied, cmdErrorCode) in each command's meta. The order is
persisted even when some commands are rejected, and the response is
202 + UserGamesOrder so clients can surface the partial failure
without the chain collapsing into "downstream service is unavailable".

Pkg/error consts are reshelved onto three explicit ranges with a
package doc and helpers (IsInternalCode/IsInputCode/IsGameStateCode):
1xxx internal/server (500/501), 2xxx structural input (400), 3xxx
game-state per-command rejection (400 when escaping HTTP, otherwise
recorded as cmdErrorCode). Two pre-existing typos fixed mechanically
(ErrBeakGroupNumberNotEnough -> ErrBreakGroupNumberNotEnough,
ErrRaceExinct -> ErrRaceExtinct) along with all callsites.

Engine errorResponse maps *GenericError by shelf rather than mapping
everything to 500. The Quit-not-last structural check in
Controller.ValidateOrder is preserved and its type assertion fixed
(was a value assertion against a pointer-typed command, so the check
silently never fired).

Backend, gateway and UI are unchanged — they were already correct on
the 202 path; only the engine collapsing per-command rejection into
500 was needed.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Ilia Denisov
2026-05-29 09:36:29 +02:00
parent ce1dc19a29
commit af30846091
22 changed files with 517 additions and 110 deletions
+16 -2
View File
@@ -142,6 +142,18 @@ func stateResponse(s game.State) rest.StateResponse {
return *result
}
// errorResponse renders err onto c and reports whether the caller
// should stop further processing. The HTTP status is selected by the
// GenericError shelf (see pkg/error for the taxonomy):
//
// - validator.ValidationErrors (request struct binding) → 400 with
// {"error": ...}.
// - GenericError, ErrGameNotInitialized → 501 with no body.
// - GenericError on the internal shelf (1xxx) → 500 with
// {"generic_error", "code"}.
// - GenericError on the input-validation shelf (2xxx) or the
// game-state shelf (3xxx) → 400 with {"generic_error", "code"}.
// - everything else (non-GenericError) → 500 with {"error": ...}.
func errorResponse(c *gin.Context, err error) bool {
if err == nil {
return false
@@ -153,9 +165,11 @@ func errorResponse(c *gin.Context, err error) bool {
}
if ge, ok := errors.AsType[*e.GenericError](err); ok {
switch ge.Code {
case e.ErrGameNotInitialized:
switch {
case ge.Code == e.ErrGameNotInitialized:
c.Status(http.StatusNotImplemented)
case e.IsInputCode(ge.Code), e.IsGameStateCode(ge.Code):
c.JSON(http.StatusBadRequest, gin.H{"generic_error": ge.Error(), "code": ge.Code})
default:
c.JSON(http.StatusInternalServerError, gin.H{"generic_error": ge.Error(), "code": ge.Code})
}
+123
View File
@@ -7,6 +7,7 @@ import (
"net/http/httptest"
"testing"
e "galaxy/error"
"galaxy/model/order"
"galaxy/model/rest"
@@ -1016,6 +1017,128 @@ func TestPutOrderEngineError(t *testing.T) {
assert.Equal(t, http.StatusInternalServerError, w.Code, w.Body)
}
// TestPutOrderPerCommandRejection — when the engine returns an order
// where some commands carry `cmdErrorCode != 0`, the handler must
// still answer 202 with the full UserGamesOrder body so the client
// can surface the per-command failure (rather than treating the
// response as a generic error).
func TestPutOrderPerCommandRejection(t *testing.T) {
applied := true
rejectedFlag := false
rejectedCode := e.ErrInputEntityNotExists
zero := 0
result := &order.UserGamesOrder{
GameID: uuid.New(),
UpdatedAt: 4242,
Commands: []order.DecodableCommand{
&order.CommandShipClassCreate{
CommandMeta: order.CommandMeta{
CmdID: id(),
CmdType: order.CommandTypeShipClassCreate,
CmdApplied: &applied,
CmdErrCode: &zero,
},
Name: "Drone",
Drive: 1,
},
&order.CommandPlanetProduce{
CommandMeta: order.CommandMeta{
CmdID: id(),
CmdType: order.CommandTypePlanetProduce,
CmdApplied: &rejectedFlag,
CmdErrCode: &rejectedCode,
},
Number: 0,
Production: "SHIP",
Subject: "Nonexistent",
},
},
}
executor := &dummyExecutor{ValidateOrderResult: result}
r := setupRouterExecutor(executor)
payload := &rest.Command{
Actor: commandDefaultActor,
Commands: []json.RawMessage{
encodeCommand(&order.CommandShipClassCreate{
CommandMeta: order.CommandMeta{CmdID: id(), CmdType: order.CommandTypeShipClassCreate},
Name: "Drone",
Drive: 1,
}),
encodeCommand(&order.CommandPlanetProduce{
CommandMeta: order.CommandMeta{CmdID: id(), CmdType: order.CommandTypePlanetProduce},
Number: 0,
Production: "SHIP",
Subject: "Nonexistent",
}),
},
}
w := httptest.NewRecorder()
req, _ := http.NewRequest(apiCommandMethod, apiOrderPath, asBody(payload))
r.ServeHTTP(w, req)
require.Equal(t, http.StatusAccepted, w.Code, w.Body)
var got struct {
GameID uuid.UUID `json:"game_id"`
UpdatedAt int64 `json:"updatedAt"`
Commands []json.RawMessage `json:"cmd"`
}
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &got))
require.Len(t, got.Commands, 2)
var first struct {
CmdApplied *bool `json:"cmdApplied"`
CmdErrCode *int `json:"cmdErrorCode"`
}
require.NoError(t, json.Unmarshal(got.Commands[0], &first))
require.NotNil(t, first.CmdApplied)
assert.True(t, *first.CmdApplied)
var second struct {
CmdApplied *bool `json:"cmdApplied"`
CmdErrCode *int `json:"cmdErrorCode"`
}
require.NoError(t, json.Unmarshal(got.Commands[1], &second))
require.NotNil(t, second.CmdApplied)
assert.False(t, *second.CmdApplied)
require.NotNil(t, second.CmdErrCode)
assert.Equal(t, e.ErrInputEntityNotExists, *second.CmdErrCode)
}
// TestPutOrderStructuralRejection — order-level structural errors
// (e.g. `quit` not the last command) come back from the executor as a
// *GenericError on the input shelf, which must map to HTTP 400 with
// the `{"generic_error","code"}` envelope rather than 500.
func TestPutOrderStructuralRejection(t *testing.T) {
executor := &dummyExecutor{ValidateOrderErr: e.NewQuitCommandFollowedByCommandError()}
r := setupRouterExecutor(executor)
payload := &rest.Command{
Actor: commandDefaultActor,
Commands: []json.RawMessage{
encodeCommand(&order.CommandRaceQuit{
CommandMeta: order.CommandMeta{CmdID: id(), CmdType: order.CommandTypeRaceQuit},
}),
},
}
w := httptest.NewRecorder()
req, _ := http.NewRequest(apiCommandMethod, apiOrderPath, asBody(payload))
r.ServeHTTP(w, req)
require.Equal(t, http.StatusBadRequest, w.Code, w.Body)
var got struct {
Code int `json:"code"`
Msg string `json:"generic_error"`
}
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &got))
assert.Equal(t, e.ErrInputQuitCommandFollowedByCommand, got.Code)
assert.NotEmpty(t, got.Msg)
}
func TestGetOrderQueryValidation(t *testing.T) {
for _, tc := range []struct {
description string