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>
This commit is contained in:
@@ -369,6 +369,15 @@ The current direct `Gateway -> User` self-service boundary uses that pattern:
|
||||
- `user.games.command`
|
||||
- `user.games.order`
|
||||
- `user.games.report`
|
||||
- `lobby.my.games.list`
|
||||
- `lobby.my.applications.list`
|
||||
- `lobby.my.invites.list`
|
||||
- `lobby.public.games.list`
|
||||
- `lobby.game.create`
|
||||
- `lobby.game.open-enrollment`
|
||||
- `lobby.application.submit`
|
||||
- `lobby.invite.redeem`
|
||||
- `lobby.invite.decline`
|
||||
- external payloads and responses:
|
||||
- FlatBuffers
|
||||
- internal downstream transport:
|
||||
|
||||
@@ -10,6 +10,7 @@ import (
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"galaxy/gateway/internal/downstream"
|
||||
lobbymodel "galaxy/model/lobby"
|
||||
@@ -55,12 +56,52 @@ func (c *RESTClient) ExecuteLobbyCommand(ctx context.Context, command downstream
|
||||
return downstream.UnaryResult{}, fmt.Errorf("backendclient: execute lobby command %q: %w", command.MessageType, err)
|
||||
}
|
||||
return c.executeLobbyMyGames(ctx, command.UserID)
|
||||
case lobbymodel.MessageTypePublicGamesList:
|
||||
req, err := transcoder.PayloadToPublicGamesListRequest(command.PayloadBytes)
|
||||
if err != nil {
|
||||
return downstream.UnaryResult{}, fmt.Errorf("backendclient: execute lobby command %q: %w", command.MessageType, err)
|
||||
}
|
||||
return c.executeLobbyPublicGames(ctx, command.UserID, req)
|
||||
case lobbymodel.MessageTypeMyApplicationsList:
|
||||
if _, err := transcoder.PayloadToMyApplicationsListRequest(command.PayloadBytes); err != nil {
|
||||
return downstream.UnaryResult{}, fmt.Errorf("backendclient: execute lobby command %q: %w", command.MessageType, err)
|
||||
}
|
||||
return c.executeLobbyMyApplications(ctx, command.UserID)
|
||||
case lobbymodel.MessageTypeMyInvitesList:
|
||||
if _, err := transcoder.PayloadToMyInvitesListRequest(command.PayloadBytes); err != nil {
|
||||
return downstream.UnaryResult{}, fmt.Errorf("backendclient: execute lobby command %q: %w", command.MessageType, err)
|
||||
}
|
||||
return c.executeLobbyMyInvites(ctx, command.UserID)
|
||||
case lobbymodel.MessageTypeOpenEnrollment:
|
||||
req, err := transcoder.PayloadToOpenEnrollmentRequest(command.PayloadBytes)
|
||||
if err != nil {
|
||||
return downstream.UnaryResult{}, fmt.Errorf("backendclient: execute lobby command %q: %w", command.MessageType, err)
|
||||
}
|
||||
return c.executeLobbyOpenEnrollment(ctx, command.UserID, req)
|
||||
case lobbymodel.MessageTypeGameCreate:
|
||||
req, err := transcoder.PayloadToGameCreateRequest(command.PayloadBytes)
|
||||
if err != nil {
|
||||
return downstream.UnaryResult{}, fmt.Errorf("backendclient: execute lobby command %q: %w", command.MessageType, err)
|
||||
}
|
||||
return c.executeLobbyGameCreate(ctx, command.UserID, req)
|
||||
case lobbymodel.MessageTypeApplicationSubmit:
|
||||
req, err := transcoder.PayloadToApplicationSubmitRequest(command.PayloadBytes)
|
||||
if err != nil {
|
||||
return downstream.UnaryResult{}, fmt.Errorf("backendclient: execute lobby command %q: %w", command.MessageType, err)
|
||||
}
|
||||
return c.executeLobbyApplicationSubmit(ctx, command.UserID, req)
|
||||
case lobbymodel.MessageTypeInviteRedeem:
|
||||
req, err := transcoder.PayloadToInviteRedeemRequest(command.PayloadBytes)
|
||||
if err != nil {
|
||||
return downstream.UnaryResult{}, fmt.Errorf("backendclient: execute lobby command %q: %w", command.MessageType, err)
|
||||
}
|
||||
return c.executeLobbyInviteRedeem(ctx, command.UserID, req)
|
||||
case lobbymodel.MessageTypeInviteDecline:
|
||||
req, err := transcoder.PayloadToInviteDeclineRequest(command.PayloadBytes)
|
||||
if err != nil {
|
||||
return downstream.UnaryResult{}, fmt.Errorf("backendclient: execute lobby command %q: %w", command.MessageType, err)
|
||||
}
|
||||
return c.executeLobbyInviteDecline(ctx, command.UserID, req)
|
||||
default:
|
||||
return downstream.UnaryResult{}, fmt.Errorf("backendclient: execute lobby command: unsupported message type %q", command.MessageType)
|
||||
}
|
||||
@@ -88,6 +129,81 @@ func (c *RESTClient) executeLobbyMyGames(ctx context.Context, userID string) (do
|
||||
return projectLobbyErrorResponse(status, body)
|
||||
}
|
||||
|
||||
func (c *RESTClient) executeLobbyPublicGames(ctx context.Context, userID string, req *lobbymodel.PublicGamesListRequest) (downstream.UnaryResult, error) {
|
||||
page := req.Page
|
||||
if page <= 0 {
|
||||
page = 1
|
||||
}
|
||||
pageSize := req.PageSize
|
||||
if pageSize <= 0 {
|
||||
pageSize = 50
|
||||
}
|
||||
target := fmt.Sprintf("%s/api/v1/user/lobby/games?page=%d&page_size=%d", c.baseURL, page, pageSize)
|
||||
body, status, err := c.do(ctx, http.MethodGet, target, userID, nil)
|
||||
if err != nil {
|
||||
return downstream.UnaryResult{}, fmt.Errorf("execute lobby.public.games.list: %w", err)
|
||||
}
|
||||
if status == http.StatusOK {
|
||||
page, err := decodePublicGamesPage(body)
|
||||
if err != nil {
|
||||
return downstream.UnaryResult{}, err
|
||||
}
|
||||
payloadBytes, err := transcoder.PublicGamesListResponseToPayload(page)
|
||||
if err != nil {
|
||||
return downstream.UnaryResult{}, fmt.Errorf("encode success response payload: %w", err)
|
||||
}
|
||||
return downstream.UnaryResult{
|
||||
ResultCode: lobbyResultCodeOK,
|
||||
PayloadBytes: payloadBytes,
|
||||
}, nil
|
||||
}
|
||||
return projectLobbyErrorResponse(status, body)
|
||||
}
|
||||
|
||||
func (c *RESTClient) executeLobbyMyApplications(ctx context.Context, userID string) (downstream.UnaryResult, error) {
|
||||
body, status, err := c.do(ctx, http.MethodGet, c.baseURL+"/api/v1/user/lobby/my/applications", userID, nil)
|
||||
if err != nil {
|
||||
return downstream.UnaryResult{}, fmt.Errorf("execute lobby.my.applications.list: %w", err)
|
||||
}
|
||||
if status == http.StatusOK {
|
||||
response, err := decodeApplicationsList(body)
|
||||
if err != nil {
|
||||
return downstream.UnaryResult{}, err
|
||||
}
|
||||
payloadBytes, err := transcoder.MyApplicationsListResponseToPayload(response)
|
||||
if err != nil {
|
||||
return downstream.UnaryResult{}, fmt.Errorf("encode success response payload: %w", err)
|
||||
}
|
||||
return downstream.UnaryResult{
|
||||
ResultCode: lobbyResultCodeOK,
|
||||
PayloadBytes: payloadBytes,
|
||||
}, nil
|
||||
}
|
||||
return projectLobbyErrorResponse(status, body)
|
||||
}
|
||||
|
||||
func (c *RESTClient) executeLobbyMyInvites(ctx context.Context, userID string) (downstream.UnaryResult, error) {
|
||||
body, status, err := c.do(ctx, http.MethodGet, c.baseURL+"/api/v1/user/lobby/my/invites", userID, nil)
|
||||
if err != nil {
|
||||
return downstream.UnaryResult{}, fmt.Errorf("execute lobby.my.invites.list: %w", err)
|
||||
}
|
||||
if status == http.StatusOK {
|
||||
response, err := decodeInvitesList(body)
|
||||
if err != nil {
|
||||
return downstream.UnaryResult{}, err
|
||||
}
|
||||
payloadBytes, err := transcoder.MyInvitesListResponseToPayload(response)
|
||||
if err != nil {
|
||||
return downstream.UnaryResult{}, fmt.Errorf("encode success response payload: %w", err)
|
||||
}
|
||||
return downstream.UnaryResult{
|
||||
ResultCode: lobbyResultCodeOK,
|
||||
PayloadBytes: payloadBytes,
|
||||
}, nil
|
||||
}
|
||||
return projectLobbyErrorResponse(status, body)
|
||||
}
|
||||
|
||||
func (c *RESTClient) executeLobbyOpenEnrollment(ctx context.Context, userID string, req *lobbymodel.OpenEnrollmentRequest) (downstream.UnaryResult, error) {
|
||||
if req == nil || strings.TrimSpace(req.GameID) == "" {
|
||||
return downstream.UnaryResult{}, errors.New("execute lobby.game.open-enrollment: game_id must not be empty")
|
||||
@@ -122,6 +238,338 @@ func (c *RESTClient) executeLobbyOpenEnrollment(ctx context.Context, userID stri
|
||||
return projectLobbyErrorResponse(status, body)
|
||||
}
|
||||
|
||||
func (c *RESTClient) executeLobbyGameCreate(ctx context.Context, userID string, req *lobbymodel.GameCreateRequest) (downstream.UnaryResult, error) {
|
||||
if req == nil || strings.TrimSpace(req.GameName) == "" {
|
||||
return downstream.UnaryResult{}, errors.New("execute lobby.game.create: game_name must not be empty")
|
||||
}
|
||||
if strings.TrimSpace(req.TurnSchedule) == "" {
|
||||
return downstream.UnaryResult{}, errors.New("execute lobby.game.create: turn_schedule must not be empty")
|
||||
}
|
||||
if strings.TrimSpace(req.TargetEngineVersion) == "" {
|
||||
return downstream.UnaryResult{}, errors.New("execute lobby.game.create: target_engine_version must not be empty")
|
||||
}
|
||||
if req.MinPlayers <= 0 || req.MaxPlayers <= 0 {
|
||||
return downstream.UnaryResult{}, errors.New("execute lobby.game.create: min_players and max_players must be positive")
|
||||
}
|
||||
if req.MinPlayers > req.MaxPlayers {
|
||||
return downstream.UnaryResult{}, errors.New("execute lobby.game.create: min_players must not exceed max_players")
|
||||
}
|
||||
if req.EnrollmentEndsAt.IsZero() {
|
||||
return downstream.UnaryResult{}, errors.New("execute lobby.game.create: enrollment_ends_at must be set")
|
||||
}
|
||||
|
||||
body := map[string]any{
|
||||
"game_name": req.GameName,
|
||||
"visibility": "private",
|
||||
"description": req.Description,
|
||||
"min_players": int32(req.MinPlayers),
|
||||
"max_players": int32(req.MaxPlayers),
|
||||
"start_gap_hours": int32(req.StartGapHours),
|
||||
"start_gap_players": int32(req.StartGapPlayers),
|
||||
"enrollment_ends_at": req.EnrollmentEndsAt.UTC().Format(time.RFC3339Nano),
|
||||
"turn_schedule": req.TurnSchedule,
|
||||
"target_engine_version": req.TargetEngineVersion,
|
||||
}
|
||||
payload, status, err := c.do(ctx, http.MethodPost, c.baseURL+"/api/v1/user/lobby/games", userID, body)
|
||||
if err != nil {
|
||||
return downstream.UnaryResult{}, fmt.Errorf("execute lobby.game.create: %w", err)
|
||||
}
|
||||
if status == http.StatusOK || status == http.StatusCreated {
|
||||
summary, err := decodeGameSummaryFromGameDetail(payload)
|
||||
if err != nil {
|
||||
return downstream.UnaryResult{}, err
|
||||
}
|
||||
payloadBytes, err := transcoder.GameCreateResponseToPayload(&lobbymodel.GameCreateResponse{Game: summary})
|
||||
if err != nil {
|
||||
return downstream.UnaryResult{}, fmt.Errorf("encode success response payload: %w", err)
|
||||
}
|
||||
return downstream.UnaryResult{
|
||||
ResultCode: lobbyResultCodeOK,
|
||||
PayloadBytes: payloadBytes,
|
||||
}, nil
|
||||
}
|
||||
return projectLobbyErrorResponse(status, payload)
|
||||
}
|
||||
|
||||
func (c *RESTClient) executeLobbyApplicationSubmit(ctx context.Context, userID string, req *lobbymodel.ApplicationSubmitRequest) (downstream.UnaryResult, error) {
|
||||
if req == nil || strings.TrimSpace(req.GameID) == "" {
|
||||
return downstream.UnaryResult{}, errors.New("execute lobby.application.submit: game_id must not be empty")
|
||||
}
|
||||
if strings.TrimSpace(req.RaceName) == "" {
|
||||
return downstream.UnaryResult{}, errors.New("execute lobby.application.submit: race_name must not be empty")
|
||||
}
|
||||
target := c.baseURL + "/api/v1/user/lobby/games/" + url.PathEscape(req.GameID) + "/applications"
|
||||
body := map[string]any{"race_name": req.RaceName}
|
||||
payload, status, err := c.do(ctx, http.MethodPost, target, userID, body)
|
||||
if err != nil {
|
||||
return downstream.UnaryResult{}, fmt.Errorf("execute lobby.application.submit: %w", err)
|
||||
}
|
||||
if status == http.StatusOK || status == http.StatusCreated {
|
||||
app, err := decodeApplicationDetail(payload)
|
||||
if err != nil {
|
||||
return downstream.UnaryResult{}, err
|
||||
}
|
||||
payloadBytes, err := transcoder.ApplicationSubmitResponseToPayload(&lobbymodel.ApplicationSubmitResponse{Application: app})
|
||||
if err != nil {
|
||||
return downstream.UnaryResult{}, fmt.Errorf("encode success response payload: %w", err)
|
||||
}
|
||||
return downstream.UnaryResult{
|
||||
ResultCode: lobbyResultCodeOK,
|
||||
PayloadBytes: payloadBytes,
|
||||
}, nil
|
||||
}
|
||||
return projectLobbyErrorResponse(status, payload)
|
||||
}
|
||||
|
||||
func (c *RESTClient) executeLobbyInviteRedeem(ctx context.Context, userID string, req *lobbymodel.InviteRedeemRequest) (downstream.UnaryResult, error) {
|
||||
if req == nil || strings.TrimSpace(req.GameID) == "" || strings.TrimSpace(req.InviteID) == "" {
|
||||
return downstream.UnaryResult{}, errors.New("execute lobby.invite.redeem: game_id and invite_id must not be empty")
|
||||
}
|
||||
target := c.baseURL + "/api/v1/user/lobby/games/" + url.PathEscape(req.GameID) + "/invites/" + url.PathEscape(req.InviteID) + "/redeem"
|
||||
payload, status, err := c.do(ctx, http.MethodPost, target, userID, nil)
|
||||
if err != nil {
|
||||
return downstream.UnaryResult{}, fmt.Errorf("execute lobby.invite.redeem: %w", err)
|
||||
}
|
||||
if status == http.StatusOK {
|
||||
invite, err := decodeInviteDetail(payload)
|
||||
if err != nil {
|
||||
return downstream.UnaryResult{}, err
|
||||
}
|
||||
payloadBytes, err := transcoder.InviteRedeemResponseToPayload(&lobbymodel.InviteRedeemResponse{Invite: invite})
|
||||
if err != nil {
|
||||
return downstream.UnaryResult{}, fmt.Errorf("encode success response payload: %w", err)
|
||||
}
|
||||
return downstream.UnaryResult{
|
||||
ResultCode: lobbyResultCodeOK,
|
||||
PayloadBytes: payloadBytes,
|
||||
}, nil
|
||||
}
|
||||
return projectLobbyErrorResponse(status, payload)
|
||||
}
|
||||
|
||||
func (c *RESTClient) executeLobbyInviteDecline(ctx context.Context, userID string, req *lobbymodel.InviteDeclineRequest) (downstream.UnaryResult, error) {
|
||||
if req == nil || strings.TrimSpace(req.GameID) == "" || strings.TrimSpace(req.InviteID) == "" {
|
||||
return downstream.UnaryResult{}, errors.New("execute lobby.invite.decline: game_id and invite_id must not be empty")
|
||||
}
|
||||
target := c.baseURL + "/api/v1/user/lobby/games/" + url.PathEscape(req.GameID) + "/invites/" + url.PathEscape(req.InviteID) + "/decline"
|
||||
payload, status, err := c.do(ctx, http.MethodPost, target, userID, nil)
|
||||
if err != nil {
|
||||
return downstream.UnaryResult{}, fmt.Errorf("execute lobby.invite.decline: %w", err)
|
||||
}
|
||||
if status == http.StatusOK {
|
||||
invite, err := decodeInviteDetail(payload)
|
||||
if err != nil {
|
||||
return downstream.UnaryResult{}, err
|
||||
}
|
||||
payloadBytes, err := transcoder.InviteDeclineResponseToPayload(&lobbymodel.InviteDeclineResponse{Invite: invite})
|
||||
if err != nil {
|
||||
return downstream.UnaryResult{}, fmt.Errorf("encode success response payload: %w", err)
|
||||
}
|
||||
return downstream.UnaryResult{
|
||||
ResultCode: lobbyResultCodeOK,
|
||||
PayloadBytes: payloadBytes,
|
||||
}, nil
|
||||
}
|
||||
return projectLobbyErrorResponse(status, payload)
|
||||
}
|
||||
|
||||
// decodeGameSummaryFromGameDetail accepts the backend's full
|
||||
// LobbyGameDetail wire shape and projects it onto the gateway's
|
||||
// GameSummary contract. It uses non-strict JSON decoding so the
|
||||
// gateway tolerates the runtime/engine fields it does not forward to
|
||||
// the UI.
|
||||
func decodeGameSummaryFromGameDetail(payload []byte) (lobbymodel.GameSummary, error) {
|
||||
var wire struct {
|
||||
GameID string `json:"game_id"`
|
||||
GameName string `json:"game_name"`
|
||||
GameType string `json:"game_type"`
|
||||
Status string `json:"status"`
|
||||
OwnerUserID *string `json:"owner_user_id"`
|
||||
MinPlayers int `json:"min_players"`
|
||||
MaxPlayers int `json:"max_players"`
|
||||
EnrollmentEndsAt time.Time `json:"enrollment_ends_at"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
if err := json.Unmarshal(payload, &wire); err != nil {
|
||||
return lobbymodel.GameSummary{}, fmt.Errorf("decode success response: %w", err)
|
||||
}
|
||||
owner := ""
|
||||
if wire.OwnerUserID != nil {
|
||||
owner = *wire.OwnerUserID
|
||||
}
|
||||
return lobbymodel.GameSummary{
|
||||
GameID: wire.GameID,
|
||||
GameName: wire.GameName,
|
||||
GameType: wire.GameType,
|
||||
Status: wire.Status,
|
||||
OwnerUserID: owner,
|
||||
MinPlayers: wire.MinPlayers,
|
||||
MaxPlayers: wire.MaxPlayers,
|
||||
EnrollmentEndsAt: wire.EnrollmentEndsAt.UTC(),
|
||||
CreatedAt: wire.CreatedAt.UTC(),
|
||||
UpdatedAt: wire.UpdatedAt.UTC(),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func decodePublicGamesPage(payload []byte) (*lobbymodel.PublicGamesListResponse, error) {
|
||||
var wire struct {
|
||||
Items []struct {
|
||||
GameID string `json:"game_id"`
|
||||
GameName string `json:"game_name"`
|
||||
GameType string `json:"game_type"`
|
||||
Status string `json:"status"`
|
||||
OwnerUserID *string `json:"owner_user_id"`
|
||||
MinPlayers int `json:"min_players"`
|
||||
MaxPlayers int `json:"max_players"`
|
||||
EnrollmentEndsAt time.Time `json:"enrollment_ends_at"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
} `json:"items"`
|
||||
Page int `json:"page"`
|
||||
PageSize int `json:"page_size"`
|
||||
Total int `json:"total"`
|
||||
}
|
||||
if err := json.Unmarshal(payload, &wire); err != nil {
|
||||
return nil, fmt.Errorf("decode success response: %w", err)
|
||||
}
|
||||
out := &lobbymodel.PublicGamesListResponse{
|
||||
Items: make([]lobbymodel.GameSummary, 0, len(wire.Items)),
|
||||
Page: wire.Page,
|
||||
PageSize: wire.PageSize,
|
||||
Total: wire.Total,
|
||||
}
|
||||
for _, w := range wire.Items {
|
||||
owner := ""
|
||||
if w.OwnerUserID != nil {
|
||||
owner = *w.OwnerUserID
|
||||
}
|
||||
out.Items = append(out.Items, lobbymodel.GameSummary{
|
||||
GameID: w.GameID,
|
||||
GameName: w.GameName,
|
||||
GameType: w.GameType,
|
||||
Status: w.Status,
|
||||
OwnerUserID: owner,
|
||||
MinPlayers: w.MinPlayers,
|
||||
MaxPlayers: w.MaxPlayers,
|
||||
EnrollmentEndsAt: w.EnrollmentEndsAt.UTC(),
|
||||
CreatedAt: w.CreatedAt.UTC(),
|
||||
UpdatedAt: w.UpdatedAt.UTC(),
|
||||
})
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func decodeApplicationsList(payload []byte) (*lobbymodel.MyApplicationsListResponse, error) {
|
||||
var wire struct {
|
||||
Items []applicationDetailWire `json:"items"`
|
||||
}
|
||||
if err := json.Unmarshal(payload, &wire); err != nil {
|
||||
return nil, fmt.Errorf("decode success response: %w", err)
|
||||
}
|
||||
out := &lobbymodel.MyApplicationsListResponse{
|
||||
Items: make([]lobbymodel.ApplicationSummary, 0, len(wire.Items)),
|
||||
}
|
||||
for _, w := range wire.Items {
|
||||
out.Items = append(out.Items, w.toModel())
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func decodeApplicationDetail(payload []byte) (lobbymodel.ApplicationSummary, error) {
|
||||
var wire applicationDetailWire
|
||||
if err := json.Unmarshal(payload, &wire); err != nil {
|
||||
return lobbymodel.ApplicationSummary{}, fmt.Errorf("decode success response: %w", err)
|
||||
}
|
||||
return wire.toModel(), nil
|
||||
}
|
||||
|
||||
func decodeInvitesList(payload []byte) (*lobbymodel.MyInvitesListResponse, error) {
|
||||
var wire struct {
|
||||
Items []inviteDetailWire `json:"items"`
|
||||
}
|
||||
if err := json.Unmarshal(payload, &wire); err != nil {
|
||||
return nil, fmt.Errorf("decode success response: %w", err)
|
||||
}
|
||||
out := &lobbymodel.MyInvitesListResponse{
|
||||
Items: make([]lobbymodel.InviteSummary, 0, len(wire.Items)),
|
||||
}
|
||||
for _, w := range wire.Items {
|
||||
out.Items = append(out.Items, w.toModel())
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func decodeInviteDetail(payload []byte) (lobbymodel.InviteSummary, error) {
|
||||
var wire inviteDetailWire
|
||||
if err := json.Unmarshal(payload, &wire); err != nil {
|
||||
return lobbymodel.InviteSummary{}, fmt.Errorf("decode success response: %w", err)
|
||||
}
|
||||
return wire.toModel(), nil
|
||||
}
|
||||
|
||||
type applicationDetailWire struct {
|
||||
ApplicationID string `json:"application_id"`
|
||||
GameID string `json:"game_id"`
|
||||
ApplicantUserID string `json:"applicant_user_id"`
|
||||
RaceName string `json:"race_name"`
|
||||
Status string `json:"status"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
DecidedAt *time.Time `json:"decided_at,omitempty"`
|
||||
}
|
||||
|
||||
func (w applicationDetailWire) toModel() lobbymodel.ApplicationSummary {
|
||||
out := lobbymodel.ApplicationSummary{
|
||||
ApplicationID: w.ApplicationID,
|
||||
GameID: w.GameID,
|
||||
ApplicantUserID: w.ApplicantUserID,
|
||||
RaceName: w.RaceName,
|
||||
Status: w.Status,
|
||||
CreatedAt: w.CreatedAt.UTC(),
|
||||
}
|
||||
if w.DecidedAt != nil {
|
||||
t := w.DecidedAt.UTC()
|
||||
out.DecidedAt = &t
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
type inviteDetailWire struct {
|
||||
InviteID string `json:"invite_id"`
|
||||
GameID string `json:"game_id"`
|
||||
InviterUserID string `json:"inviter_user_id"`
|
||||
InvitedUserID *string `json:"invited_user_id,omitempty"`
|
||||
Code *string `json:"code,omitempty"`
|
||||
RaceName string `json:"race_name"`
|
||||
Status string `json:"status"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
ExpiresAt time.Time `json:"expires_at"`
|
||||
DecidedAt *time.Time `json:"decided_at,omitempty"`
|
||||
}
|
||||
|
||||
func (w inviteDetailWire) toModel() lobbymodel.InviteSummary {
|
||||
out := lobbymodel.InviteSummary{
|
||||
InviteID: w.InviteID,
|
||||
GameID: w.GameID,
|
||||
InviterUserID: w.InviterUserID,
|
||||
RaceName: w.RaceName,
|
||||
Status: w.Status,
|
||||
CreatedAt: w.CreatedAt.UTC(),
|
||||
ExpiresAt: w.ExpiresAt.UTC(),
|
||||
}
|
||||
if w.InvitedUserID != nil {
|
||||
out.InvitedUserID = *w.InvitedUserID
|
||||
}
|
||||
if w.Code != nil {
|
||||
out.Code = *w.Code
|
||||
}
|
||||
if w.DecidedAt != nil {
|
||||
t := w.DecidedAt.UTC()
|
||||
out.DecidedAt = &t
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func projectLobbyErrorResponse(statusCode int, payload []byte) (downstream.UnaryResult, error) {
|
||||
switch {
|
||||
case statusCode == http.StatusServiceUnavailable:
|
||||
|
||||
@@ -0,0 +1,512 @@
|
||||
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",
|
||||
}
|
||||
}
|
||||
@@ -38,8 +38,15 @@ func LobbyRoutes(client *RESTClient) map[string]downstream.Client {
|
||||
target = lobbyCommandClient{rest: client}
|
||||
}
|
||||
return map[string]downstream.Client{
|
||||
lobbymodel.MessageTypeMyGamesList: target,
|
||||
lobbymodel.MessageTypeOpenEnrollment: target,
|
||||
lobbymodel.MessageTypeMyGamesList: target,
|
||||
lobbymodel.MessageTypePublicGamesList: target,
|
||||
lobbymodel.MessageTypeMyApplicationsList: target,
|
||||
lobbymodel.MessageTypeMyInvitesList: target,
|
||||
lobbymodel.MessageTypeOpenEnrollment: target,
|
||||
lobbymodel.MessageTypeGameCreate: target,
|
||||
lobbymodel.MessageTypeApplicationSubmit: target,
|
||||
lobbymodel.MessageTypeInviteRedeem: target,
|
||||
lobbymodel.MessageTypeInviteDecline: target,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user