Files
galaxy-game/gateway/internal/backendclient/lobby_commands_test.go
T
Ilia Denisov f57a290432 phase 8: lobby UI + cross-stack lobby command catalog + TS FlatBuffers
- Extend pkg/model/lobby and pkg/schema/fbs/lobby.fbs with public-games
  list, my-applications/invites lists, game-create, application-submit,
  invite-redeem/decline. Mirror the matching transcoder pairs and Go
  fixture round-trip tests.
- Wire the seven new lobby message types through
  gateway/internal/backendclient/{routes,lobby_commands}.go with
  per-command REST helpers, JSON-tolerant decoding of backend wire
  shapes, and httptest-based unit coverage for success / 4xx / 5xx /
  503 across each command.
- Introduce TS-side FlatBuffers via the `flatbuffers` runtime dep, a
  `make fbs-ts` target driving flatc, and the generated bindings under
  ui/frontend/src/proto/galaxy/fbs. Phase 7's `user.account.get` decode
  now uses these bindings as well, closing the JSON.parse vs
  FlatBuffers gap that would have failed against a real local stack.
- Replace the placeholder lobby with five sections (my games, pending
  invitations, my applications, public games, create new game) and the
  /lobby/create form. Submit-application uses an inline race-name
  form on the public-game card; create-game keeps name / description /
  turn_schedule / enrollment_ends_at always visible and the rest under
  an Advanced toggle with TS-side defaults.
- Update lobby/+page.svelte to throw LobbyError on non-ok result codes;
  GalaxyClient.executeCommand now returns { resultCode, payloadBytes }.
- Vitest binding round-trips, lobby.ts wrapper unit tests, lobby-page
  + lobby-create component tests, Playwright lobby-flow.spec covering
  create / submit / accept across all four projects. Phase 7 e2e was
  migrated to the FlatBuffers fixtures and to click+fill against the
  Safari-autofill readonly inputs.
- Mark Phase 8 done in ui/PLAN.md, mirror the wire-format note into
  Phase 7, append the new lobby commands to gateway/README.md and
  docs/ARCHITECTURE.md, add ui/docs/lobby.md.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-07 18:05:08 +02:00

513 lines
18 KiB
Go

package backendclient_test
import (
"context"
"encoding/json"
"errors"
"io"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"galaxy/gateway/internal/backendclient"
"galaxy/gateway/internal/downstream"
lobbymodel "galaxy/model/lobby"
"galaxy/transcoder"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func newAuthCommand(t *testing.T, messageType string, payload []byte) downstream.AuthenticatedCommand {
t.Helper()
return downstream.AuthenticatedCommand{
MessageType: messageType,
PayloadBytes: payload,
UserID: "user-1",
}
}
func mustEncode[T any](t *testing.T, encode func(*T) ([]byte, error), value *T) []byte {
t.Helper()
bytes, err := encode(value)
require.NoError(t, err)
return bytes
}
func TestExecuteLobbyMyGamesListReturnsItems(t *testing.T) {
t.Parallel()
enrollment := time.Date(2026, 5, 15, 12, 0, 0, 0, time.UTC)
created := time.Date(2026, 5, 7, 10, 0, 0, 0, time.UTC)
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
require.Equal(t, http.MethodGet, r.Method)
require.Equal(t, "/api/v1/user/lobby/my/games", r.URL.Path)
require.Equal(t, "user-1", r.Header.Get(backendclient.HeaderUserID))
writeJSON(t, w, http.StatusOK, map[string]any{
"items": []map[string]any{{
"game_id": "game-1",
"game_name": "Test Game",
"game_type": "private",
"status": "draft",
"owner_user_id": "user-1",
"min_players": 2,
"max_players": 8,
"enrollment_ends_at": enrollment.Format(time.RFC3339Nano),
"created_at": created.Format(time.RFC3339Nano),
"updated_at": created.Format(time.RFC3339Nano),
}},
})
}))
t.Cleanup(server.Close)
client := newRESTClient(t, server)
payload := mustEncode(t, transcoder.MyGamesListRequestToPayload, &lobbymodel.MyGamesListRequest{})
result, err := client.ExecuteLobbyCommand(context.Background(), newAuthCommand(t, lobbymodel.MessageTypeMyGamesList, payload))
require.NoError(t, err)
assert.Equal(t, "ok", result.ResultCode)
decoded, err := transcoder.PayloadToMyGamesListResponse(result.PayloadBytes)
require.NoError(t, err)
require.Len(t, decoded.Items, 1)
assert.Equal(t, "game-1", decoded.Items[0].GameID)
assert.Equal(t, enrollment, decoded.Items[0].EnrollmentEndsAt)
}
func TestExecuteLobbyPublicGamesListPaginatesAndDecodes(t *testing.T) {
t.Parallel()
enrollment := time.Date(2026, 6, 1, 12, 0, 0, 0, time.UTC)
created := time.Date(2026, 5, 1, 12, 0, 0, 0, time.UTC)
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
require.Equal(t, http.MethodGet, r.Method)
require.Equal(t, "/api/v1/user/lobby/games", r.URL.Path)
require.Equal(t, "2", r.URL.Query().Get("page"))
require.Equal(t, "10", r.URL.Query().Get("page_size"))
writeJSON(t, w, http.StatusOK, map[string]any{
"items": []map[string]any{{
"game_id": "public-1",
"game_name": "Open",
"game_type": "public",
"status": "enrollment_open",
"owner_user_id": nil,
"min_players": 4,
"max_players": 12,
"enrollment_ends_at": enrollment.Format(time.RFC3339Nano),
"created_at": created.Format(time.RFC3339Nano),
"updated_at": created.Format(time.RFC3339Nano),
}},
"page": 2,
"page_size": 10,
"total": 31,
})
}))
t.Cleanup(server.Close)
client := newRESTClient(t, server)
payload := mustEncode(t, transcoder.PublicGamesListRequestToPayload, &lobbymodel.PublicGamesListRequest{Page: 2, PageSize: 10})
result, err := client.ExecuteLobbyCommand(context.Background(), newAuthCommand(t, lobbymodel.MessageTypePublicGamesList, payload))
require.NoError(t, err)
assert.Equal(t, "ok", result.ResultCode)
decoded, err := transcoder.PayloadToPublicGamesListResponse(result.PayloadBytes)
require.NoError(t, err)
assert.Equal(t, 2, decoded.Page)
assert.Equal(t, 10, decoded.PageSize)
assert.Equal(t, 31, decoded.Total)
require.Len(t, decoded.Items, 1)
assert.Empty(t, decoded.Items[0].OwnerUserID)
}
func TestExecuteLobbyMyApplicationsList(t *testing.T) {
t.Parallel()
created := time.Date(2026, 5, 5, 10, 0, 0, 0, time.UTC)
decided := time.Date(2026, 5, 6, 12, 0, 0, 0, time.UTC)
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
require.Equal(t, "/api/v1/user/lobby/my/applications", r.URL.Path)
writeJSON(t, w, http.StatusOK, map[string]any{
"items": []map[string]any{
{
"application_id": "app-1",
"game_id": "public-1",
"applicant_user_id": "user-1",
"race_name": "Vegan Federation",
"status": "pending",
"created_at": created.Format(time.RFC3339Nano),
},
{
"application_id": "app-2",
"game_id": "public-2",
"applicant_user_id": "user-1",
"race_name": "Lithic Compact",
"status": "approved",
"created_at": created.Format(time.RFC3339Nano),
"decided_at": decided.Format(time.RFC3339Nano),
},
},
})
}))
t.Cleanup(server.Close)
client := newRESTClient(t, server)
payload := mustEncode(t, transcoder.MyApplicationsListRequestToPayload, &lobbymodel.MyApplicationsListRequest{})
result, err := client.ExecuteLobbyCommand(context.Background(), newAuthCommand(t, lobbymodel.MessageTypeMyApplicationsList, payload))
require.NoError(t, err)
assert.Equal(t, "ok", result.ResultCode)
decoded, err := transcoder.PayloadToMyApplicationsListResponse(result.PayloadBytes)
require.NoError(t, err)
require.Len(t, decoded.Items, 2)
assert.Equal(t, "pending", decoded.Items[0].Status)
assert.Nil(t, decoded.Items[0].DecidedAt)
require.NotNil(t, decoded.Items[1].DecidedAt)
assert.Equal(t, decided, *decoded.Items[1].DecidedAt)
}
func TestExecuteLobbyMyInvitesList(t *testing.T) {
t.Parallel()
created := time.Date(2026, 5, 5, 10, 0, 0, 0, time.UTC)
expires := time.Date(2026, 5, 8, 10, 0, 0, 0, time.UTC)
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
require.Equal(t, "/api/v1/user/lobby/my/invites", r.URL.Path)
writeJSON(t, w, http.StatusOK, map[string]any{
"items": []map[string]any{{
"invite_id": "invite-1",
"game_id": "private-1",
"inviter_user_id": "user-host",
"invited_user_id": "user-1",
"race_name": "Vegan Federation",
"status": "pending",
"created_at": created.Format(time.RFC3339Nano),
"expires_at": expires.Format(time.RFC3339Nano),
}},
})
}))
t.Cleanup(server.Close)
client := newRESTClient(t, server)
payload := mustEncode(t, transcoder.MyInvitesListRequestToPayload, &lobbymodel.MyInvitesListRequest{})
result, err := client.ExecuteLobbyCommand(context.Background(), newAuthCommand(t, lobbymodel.MessageTypeMyInvitesList, payload))
require.NoError(t, err)
assert.Equal(t, "ok", result.ResultCode)
decoded, err := transcoder.PayloadToMyInvitesListResponse(result.PayloadBytes)
require.NoError(t, err)
require.Len(t, decoded.Items, 1)
assert.Equal(t, "user-1", decoded.Items[0].InvitedUserID)
assert.Empty(t, decoded.Items[0].Code)
assert.Equal(t, expires, decoded.Items[0].ExpiresAt)
}
func TestExecuteLobbyGameCreatePostsPrivateAndProjectsToSummary(t *testing.T) {
t.Parallel()
enrollment := time.Date(2026, 6, 1, 12, 0, 0, 0, time.UTC)
created := time.Date(2026, 5, 7, 10, 0, 0, 0, time.UTC)
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
require.Equal(t, http.MethodPost, r.Method)
require.Equal(t, "/api/v1/user/lobby/games", r.URL.Path)
var body map[string]any
raw, err := io.ReadAll(r.Body)
require.NoError(t, err)
require.NoError(t, json.Unmarshal(raw, &body))
assert.Equal(t, "private", body["visibility"])
assert.Equal(t, "First Contact", body["game_name"])
assert.Equal(t, "0 0 * * *", body["turn_schedule"])
// Backend always returns the full GameDetail including runtime
// snapshot fields the gateway must tolerate.
writeJSON(t, w, http.StatusCreated, map[string]any{
"game_id": "newly-created",
"game_name": "First Contact",
"game_type": "private",
"status": "draft",
"owner_user_id": "user-1",
"min_players": 2,
"max_players": 8,
"enrollment_ends_at": enrollment.Format(time.RFC3339Nano),
"created_at": created.Format(time.RFC3339Nano),
"updated_at": created.Format(time.RFC3339Nano),
"visibility": "private",
"description": "",
"turn_schedule": "0 0 * * *",
"target_engine_version": "v1",
"start_gap_hours": 24,
"start_gap_players": 2,
"current_turn": 0,
"runtime_status": "",
})
}))
t.Cleanup(server.Close)
client := newRESTClient(t, server)
payload := mustEncode(t, transcoder.GameCreateRequestToPayload, &lobbymodel.GameCreateRequest{
GameName: "First Contact",
Description: "",
MinPlayers: 2,
MaxPlayers: 8,
StartGapHours: 24,
StartGapPlayers: 2,
EnrollmentEndsAt: enrollment,
TurnSchedule: "0 0 * * *",
TargetEngineVersion: "v1",
})
result, err := client.ExecuteLobbyCommand(context.Background(), newAuthCommand(t, lobbymodel.MessageTypeGameCreate, payload))
require.NoError(t, err)
assert.Equal(t, "ok", result.ResultCode)
decoded, err := transcoder.PayloadToGameCreateResponse(result.PayloadBytes)
require.NoError(t, err)
assert.Equal(t, "newly-created", decoded.Game.GameID)
assert.Equal(t, "draft", decoded.Game.Status)
}
func TestExecuteLobbyGameCreateRejectsEmptyGameName(t *testing.T) {
t.Parallel()
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
t.Errorf("backend must not be hit on validation failure")
w.WriteHeader(http.StatusInternalServerError)
}))
t.Cleanup(server.Close)
client := newRESTClient(t, server)
payload := mustEncode(t, transcoder.GameCreateRequestToPayload, &lobbymodel.GameCreateRequest{
MinPlayers: 2,
MaxPlayers: 8,
EnrollmentEndsAt: time.Date(2026, 6, 1, 12, 0, 0, 0, time.UTC),
TurnSchedule: "0 0 * * *",
TargetEngineVersion: "v1",
})
_, err := client.ExecuteLobbyCommand(context.Background(), newAuthCommand(t, lobbymodel.MessageTypeGameCreate, payload))
require.Error(t, err)
assert.Contains(t, err.Error(), "game_name must not be empty")
}
func TestExecuteLobbyApplicationSubmitPostsRaceName(t *testing.T) {
t.Parallel()
created := time.Date(2026, 5, 5, 10, 0, 0, 0, time.UTC)
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
require.Equal(t, http.MethodPost, r.Method)
require.Equal(t, "/api/v1/user/lobby/games/public-1/applications", r.URL.Path)
var body map[string]any
raw, err := io.ReadAll(r.Body)
require.NoError(t, err)
require.NoError(t, json.Unmarshal(raw, &body))
assert.Equal(t, "Vegan Federation", body["race_name"])
writeJSON(t, w, http.StatusCreated, map[string]any{
"application_id": "app-3",
"game_id": "public-1",
"applicant_user_id": "user-1",
"race_name": "Vegan Federation",
"status": "pending",
"created_at": created.Format(time.RFC3339Nano),
})
}))
t.Cleanup(server.Close)
client := newRESTClient(t, server)
payload := mustEncode(t, transcoder.ApplicationSubmitRequestToPayload, &lobbymodel.ApplicationSubmitRequest{
GameID: "public-1",
RaceName: "Vegan Federation",
})
result, err := client.ExecuteLobbyCommand(context.Background(), newAuthCommand(t, lobbymodel.MessageTypeApplicationSubmit, payload))
require.NoError(t, err)
assert.Equal(t, "ok", result.ResultCode)
decoded, err := transcoder.PayloadToApplicationSubmitResponse(result.PayloadBytes)
require.NoError(t, err)
assert.Equal(t, "app-3", decoded.Application.ApplicationID)
assert.Equal(t, "pending", decoded.Application.Status)
}
func TestExecuteLobbyInviteRedeemPostsToBackend(t *testing.T) {
t.Parallel()
created := time.Date(2026, 5, 5, 10, 0, 0, 0, time.UTC)
expires := time.Date(2026, 5, 8, 10, 0, 0, 0, time.UTC)
decided := time.Date(2026, 5, 6, 12, 0, 0, 0, time.UTC)
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
require.Equal(t, http.MethodPost, r.Method)
require.Equal(t, "/api/v1/user/lobby/games/private-1/invites/invite-1/redeem", r.URL.Path)
writeJSON(t, w, http.StatusOK, map[string]any{
"invite_id": "invite-1",
"game_id": "private-1",
"inviter_user_id": "user-host",
"invited_user_id": "user-1",
"race_name": "Vegan Federation",
"status": "accepted",
"created_at": created.Format(time.RFC3339Nano),
"expires_at": expires.Format(time.RFC3339Nano),
"decided_at": decided.Format(time.RFC3339Nano),
})
}))
t.Cleanup(server.Close)
client := newRESTClient(t, server)
payload := mustEncode(t, transcoder.InviteRedeemRequestToPayload, &lobbymodel.InviteRedeemRequest{GameID: "private-1", InviteID: "invite-1"})
result, err := client.ExecuteLobbyCommand(context.Background(), newAuthCommand(t, lobbymodel.MessageTypeInviteRedeem, payload))
require.NoError(t, err)
assert.Equal(t, "ok", result.ResultCode)
decoded, err := transcoder.PayloadToInviteRedeemResponse(result.PayloadBytes)
require.NoError(t, err)
assert.Equal(t, "accepted", decoded.Invite.Status)
}
func TestExecuteLobbyInviteDeclinePostsToBackend(t *testing.T) {
t.Parallel()
created := time.Date(2026, 5, 5, 10, 0, 0, 0, time.UTC)
expires := time.Date(2026, 5, 8, 10, 0, 0, 0, time.UTC)
decided := time.Date(2026, 5, 6, 12, 0, 0, 0, time.UTC)
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
require.Equal(t, http.MethodPost, r.Method)
require.Equal(t, "/api/v1/user/lobby/games/private-1/invites/invite-1/decline", r.URL.Path)
writeJSON(t, w, http.StatusOK, map[string]any{
"invite_id": "invite-1",
"game_id": "private-1",
"inviter_user_id": "user-host",
"invited_user_id": "user-1",
"race_name": "Vegan Federation",
"status": "declined",
"created_at": created.Format(time.RFC3339Nano),
"expires_at": expires.Format(time.RFC3339Nano),
"decided_at": decided.Format(time.RFC3339Nano),
})
}))
t.Cleanup(server.Close)
client := newRESTClient(t, server)
payload := mustEncode(t, transcoder.InviteDeclineRequestToPayload, &lobbymodel.InviteDeclineRequest{GameID: "private-1", InviteID: "invite-1"})
result, err := client.ExecuteLobbyCommand(context.Background(), newAuthCommand(t, lobbymodel.MessageTypeInviteDecline, payload))
require.NoError(t, err)
assert.Equal(t, "ok", result.ResultCode)
decoded, err := transcoder.PayloadToInviteDeclineResponse(result.PayloadBytes)
require.NoError(t, err)
assert.Equal(t, "declined", decoded.Invite.Status)
}
func TestExecuteLobbyProjectsBackendErrorAcrossCommands(t *testing.T) {
t.Parallel()
cases := []struct {
name string
messageType string
payload []byte
statusCode int
want string
}{
{
name: "public games conflict",
messageType: lobbymodel.MessageTypePublicGamesList,
payload: mustEncode(t, transcoder.PublicGamesListRequestToPayload, &lobbymodel.PublicGamesListRequest{Page: 1, PageSize: 50}),
statusCode: http.StatusConflict,
want: "conflict",
},
{
name: "applications forbidden",
messageType: lobbymodel.MessageTypeApplicationSubmit,
payload: mustEncode(t, transcoder.ApplicationSubmitRequestToPayload, &lobbymodel.ApplicationSubmitRequest{GameID: "g", RaceName: "r"}),
statusCode: http.StatusForbidden,
want: "forbidden",
},
{
name: "invite redeem not found",
messageType: lobbymodel.MessageTypeInviteRedeem,
payload: mustEncode(t, transcoder.InviteRedeemRequestToPayload, &lobbymodel.InviteRedeemRequest{GameID: "g", InviteID: "i"}),
statusCode: http.StatusNotFound,
want: "subject_not_found",
},
{
name: "create invalid request",
messageType: lobbymodel.MessageTypeGameCreate,
payload: mustEncode(t, transcoder.GameCreateRequestToPayload, validCreateRequest()),
statusCode: http.StatusBadRequest,
want: "invalid_request",
},
}
for _, tc := range cases {
tc := tc
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
writeJSON(t, w, tc.statusCode, map[string]any{
"error": map[string]any{"code": tc.want, "message": "from backend"},
})
}))
t.Cleanup(server.Close)
client := newRESTClient(t, server)
result, err := client.ExecuteLobbyCommand(context.Background(), newAuthCommand(t, tc.messageType, tc.payload))
require.NoError(t, err)
assert.Equal(t, tc.want, result.ResultCode)
errResp, err := transcoder.PayloadToLobbyErrorResponse(result.PayloadBytes)
require.NoError(t, err)
assert.Equal(t, tc.want, errResp.Error.Code)
assert.Equal(t, "from backend", errResp.Error.Message)
})
}
}
func TestExecuteLobbyMapsServiceUnavailableToDownstreamError(t *testing.T) {
t.Parallel()
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusServiceUnavailable)
}))
t.Cleanup(server.Close)
client := newRESTClient(t, server)
payload := mustEncode(t, transcoder.MyGamesListRequestToPayload, &lobbymodel.MyGamesListRequest{})
_, err := client.ExecuteLobbyCommand(context.Background(), newAuthCommand(t, lobbymodel.MessageTypeMyGamesList, payload))
require.Error(t, err)
assert.True(t, errors.Is(err, downstream.ErrDownstreamUnavailable))
}
func TestExecuteLobbyRejectsUnknownMessageType(t *testing.T) {
t.Parallel()
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusOK)
}))
t.Cleanup(server.Close)
client := newRESTClient(t, server)
_, err := client.ExecuteLobbyCommand(context.Background(), newAuthCommand(t, "lobby.unknown", []byte{0x01}))
require.Error(t, err)
assert.True(t, strings.Contains(err.Error(), "unsupported message type"))
}
func validCreateRequest() *lobbymodel.GameCreateRequest {
return &lobbymodel.GameCreateRequest{
GameName: "Test",
Description: "",
MinPlayers: 2,
MaxPlayers: 8,
StartGapHours: 24,
StartGapPlayers: 2,
EnrollmentEndsAt: time.Date(2026, 6, 1, 12, 0, 0, 0, time.UTC),
TurnSchedule: "0 0 * * *",
TargetEngineVersion: "v1",
}
}