fix(game): #59 — per-command rejection on PUT /api/v1/order
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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user