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", } }