f57a290432
- 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>
518 lines
15 KiB
Go
518 lines
15 KiB
Go
package transcoder
|
|
|
|
import (
|
|
"reflect"
|
|
"testing"
|
|
"time"
|
|
|
|
lobbymodel "galaxy/model/lobby"
|
|
)
|
|
|
|
// fixedTimes returns deterministic UTC timestamps for round-trip
|
|
// fixtures. Millisecond precision matches the FlatBuffers `*Ms` ints,
|
|
// which is what the transcoder preserves.
|
|
func fixedTimes() (created, updated, ends time.Time) {
|
|
created = time.Date(2026, time.May, 7, 9, 30, 15, 123_000_000, time.UTC)
|
|
updated = created.Add(2 * time.Minute)
|
|
ends = created.Add(48 * time.Hour)
|
|
return
|
|
}
|
|
|
|
func TestLobbyMyGamesListRoundTrip(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
created, updated, ends := fixedTimes()
|
|
|
|
source := &lobbymodel.MyGamesListResponse{
|
|
Items: []lobbymodel.GameSummary{
|
|
{
|
|
GameID: "game-private-7c8f",
|
|
GameName: "First Contact",
|
|
GameType: "private",
|
|
Status: "draft",
|
|
OwnerUserID: "user-9912",
|
|
MinPlayers: 2,
|
|
MaxPlayers: 8,
|
|
EnrollmentEndsAt: ends,
|
|
CreatedAt: created,
|
|
UpdatedAt: updated,
|
|
},
|
|
{
|
|
GameID: "game-public-aabb",
|
|
GameName: "Open Lobby",
|
|
GameType: "public",
|
|
Status: "enrollment_open",
|
|
OwnerUserID: "",
|
|
MinPlayers: 4,
|
|
MaxPlayers: 12,
|
|
EnrollmentEndsAt: ends,
|
|
CreatedAt: created,
|
|
UpdatedAt: updated,
|
|
},
|
|
},
|
|
}
|
|
|
|
payload, err := MyGamesListResponseToPayload(source)
|
|
if err != nil {
|
|
t.Fatalf("encode: %v", err)
|
|
}
|
|
|
|
decoded, err := PayloadToMyGamesListResponse(payload)
|
|
if err != nil {
|
|
t.Fatalf("decode: %v", err)
|
|
}
|
|
if !reflect.DeepEqual(source, decoded) {
|
|
t.Fatalf("round-trip mismatch\nsource: %#v\ndecoded:%#v", source, decoded)
|
|
}
|
|
|
|
requestBytes, err := MyGamesListRequestToPayload(&lobbymodel.MyGamesListRequest{})
|
|
if err != nil {
|
|
t.Fatalf("encode request: %v", err)
|
|
}
|
|
if _, err := PayloadToMyGamesListRequest(requestBytes); err != nil {
|
|
t.Fatalf("decode request: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestLobbyMyGamesListEmpty(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
payload, err := MyGamesListResponseToPayload(&lobbymodel.MyGamesListResponse{Items: nil})
|
|
if err != nil {
|
|
t.Fatalf("encode: %v", err)
|
|
}
|
|
decoded, err := PayloadToMyGamesListResponse(payload)
|
|
if err != nil {
|
|
t.Fatalf("decode: %v", err)
|
|
}
|
|
if got := len(decoded.Items); got != 0 {
|
|
t.Fatalf("expected empty items, got %d", got)
|
|
}
|
|
}
|
|
|
|
func TestLobbyPublicGamesListRoundTrip(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
created, updated, ends := fixedTimes()
|
|
|
|
requestSource := &lobbymodel.PublicGamesListRequest{Page: 3, PageSize: 25}
|
|
requestBytes, err := PublicGamesListRequestToPayload(requestSource)
|
|
if err != nil {
|
|
t.Fatalf("encode request: %v", err)
|
|
}
|
|
requestDecoded, err := PayloadToPublicGamesListRequest(requestBytes)
|
|
if err != nil {
|
|
t.Fatalf("decode request: %v", err)
|
|
}
|
|
if !reflect.DeepEqual(requestSource, requestDecoded) {
|
|
t.Fatalf("request round-trip mismatch\nsource: %#v\ndecoded:%#v", requestSource, requestDecoded)
|
|
}
|
|
|
|
responseSource := &lobbymodel.PublicGamesListResponse{
|
|
Items: []lobbymodel.GameSummary{
|
|
{
|
|
GameID: "game-public-aabb",
|
|
GameName: "Open Lobby",
|
|
GameType: "public",
|
|
Status: "enrollment_open",
|
|
OwnerUserID: "",
|
|
MinPlayers: 4,
|
|
MaxPlayers: 12,
|
|
EnrollmentEndsAt: ends,
|
|
CreatedAt: created,
|
|
UpdatedAt: updated,
|
|
},
|
|
},
|
|
Page: 3,
|
|
PageSize: 25,
|
|
Total: 51,
|
|
}
|
|
responseBytes, err := PublicGamesListResponseToPayload(responseSource)
|
|
if err != nil {
|
|
t.Fatalf("encode response: %v", err)
|
|
}
|
|
responseDecoded, err := PayloadToPublicGamesListResponse(responseBytes)
|
|
if err != nil {
|
|
t.Fatalf("decode response: %v", err)
|
|
}
|
|
if !reflect.DeepEqual(responseSource, responseDecoded) {
|
|
t.Fatalf("response round-trip mismatch\nsource: %#v\ndecoded:%#v", responseSource, responseDecoded)
|
|
}
|
|
}
|
|
|
|
func TestLobbyMyApplicationsListRoundTrip(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
created, _, _ := fixedTimes()
|
|
decided := created.Add(2 * time.Hour)
|
|
|
|
requestBytes, err := MyApplicationsListRequestToPayload(&lobbymodel.MyApplicationsListRequest{})
|
|
if err != nil {
|
|
t.Fatalf("encode request: %v", err)
|
|
}
|
|
if _, err := PayloadToMyApplicationsListRequest(requestBytes); err != nil {
|
|
t.Fatalf("decode request: %v", err)
|
|
}
|
|
|
|
source := &lobbymodel.MyApplicationsListResponse{
|
|
Items: []lobbymodel.ApplicationSummary{
|
|
{
|
|
ApplicationID: "app-1",
|
|
GameID: "game-public-aabb",
|
|
ApplicantUserID: "user-9912",
|
|
RaceName: "Vegan Federation",
|
|
Status: "pending",
|
|
CreatedAt: created,
|
|
DecidedAt: nil,
|
|
},
|
|
{
|
|
ApplicationID: "app-2",
|
|
GameID: "game-public-ccdd",
|
|
ApplicantUserID: "user-9912",
|
|
RaceName: "Lithic Compact",
|
|
Status: "approved",
|
|
CreatedAt: created,
|
|
DecidedAt: &decided,
|
|
},
|
|
},
|
|
}
|
|
payload, err := MyApplicationsListResponseToPayload(source)
|
|
if err != nil {
|
|
t.Fatalf("encode: %v", err)
|
|
}
|
|
decoded, err := PayloadToMyApplicationsListResponse(payload)
|
|
if err != nil {
|
|
t.Fatalf("decode: %v", err)
|
|
}
|
|
if !reflect.DeepEqual(source, decoded) {
|
|
t.Fatalf("round-trip mismatch\nsource: %#v\ndecoded:%#v", source, decoded)
|
|
}
|
|
}
|
|
|
|
func TestLobbyMyInvitesListRoundTrip(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
created, _, _ := fixedTimes()
|
|
expires := created.Add(72 * time.Hour)
|
|
decided := created.Add(1 * time.Hour)
|
|
|
|
requestBytes, err := MyInvitesListRequestToPayload(&lobbymodel.MyInvitesListRequest{})
|
|
if err != nil {
|
|
t.Fatalf("encode request: %v", err)
|
|
}
|
|
if _, err := PayloadToMyInvitesListRequest(requestBytes); err != nil {
|
|
t.Fatalf("decode request: %v", err)
|
|
}
|
|
|
|
source := &lobbymodel.MyInvitesListResponse{
|
|
Items: []lobbymodel.InviteSummary{
|
|
{
|
|
InviteID: "invite-user-bound",
|
|
GameID: "game-private-7c8f",
|
|
InviterUserID: "user-1111",
|
|
InvitedUserID: "user-9912",
|
|
Code: "",
|
|
RaceName: "Vegan Federation",
|
|
Status: "pending",
|
|
CreatedAt: created,
|
|
ExpiresAt: expires,
|
|
DecidedAt: nil,
|
|
},
|
|
{
|
|
InviteID: "invite-code-based",
|
|
GameID: "game-private-3322",
|
|
InviterUserID: "user-1111",
|
|
InvitedUserID: "",
|
|
Code: "ABCDEF12",
|
|
RaceName: "Lithic Compact",
|
|
Status: "accepted",
|
|
CreatedAt: created,
|
|
ExpiresAt: expires,
|
|
DecidedAt: &decided,
|
|
},
|
|
},
|
|
}
|
|
payload, err := MyInvitesListResponseToPayload(source)
|
|
if err != nil {
|
|
t.Fatalf("encode: %v", err)
|
|
}
|
|
decoded, err := PayloadToMyInvitesListResponse(payload)
|
|
if err != nil {
|
|
t.Fatalf("decode: %v", err)
|
|
}
|
|
if !reflect.DeepEqual(source, decoded) {
|
|
t.Fatalf("round-trip mismatch\nsource: %#v\ndecoded:%#v", source, decoded)
|
|
}
|
|
}
|
|
|
|
func TestLobbyOpenEnrollmentRoundTrip(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
requestSource := &lobbymodel.OpenEnrollmentRequest{GameID: "game-private-7c8f"}
|
|
requestBytes, err := OpenEnrollmentRequestToPayload(requestSource)
|
|
if err != nil {
|
|
t.Fatalf("encode request: %v", err)
|
|
}
|
|
requestDecoded, err := PayloadToOpenEnrollmentRequest(requestBytes)
|
|
if err != nil {
|
|
t.Fatalf("decode request: %v", err)
|
|
}
|
|
if !reflect.DeepEqual(requestSource, requestDecoded) {
|
|
t.Fatalf("request round-trip mismatch\nsource: %#v\ndecoded:%#v", requestSource, requestDecoded)
|
|
}
|
|
|
|
responseSource := &lobbymodel.OpenEnrollmentResponse{GameID: "game-private-7c8f", Status: "enrollment_open"}
|
|
responseBytes, err := OpenEnrollmentResponseToPayload(responseSource)
|
|
if err != nil {
|
|
t.Fatalf("encode response: %v", err)
|
|
}
|
|
responseDecoded, err := PayloadToOpenEnrollmentResponse(responseBytes)
|
|
if err != nil {
|
|
t.Fatalf("decode response: %v", err)
|
|
}
|
|
if !reflect.DeepEqual(responseSource, responseDecoded) {
|
|
t.Fatalf("response round-trip mismatch\nsource: %#v\ndecoded:%#v", responseSource, responseDecoded)
|
|
}
|
|
}
|
|
|
|
func TestLobbyGameCreateRoundTrip(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
_, _, ends := fixedTimes()
|
|
|
|
requestSource := &lobbymodel.GameCreateRequest{
|
|
GameName: "First Contact",
|
|
Description: "First Phase 8 sandbox game",
|
|
MinPlayers: 2,
|
|
MaxPlayers: 8,
|
|
StartGapHours: 24,
|
|
StartGapPlayers: 2,
|
|
EnrollmentEndsAt: ends,
|
|
TurnSchedule: "0 0 * * *",
|
|
TargetEngineVersion: "v1",
|
|
}
|
|
requestBytes, err := GameCreateRequestToPayload(requestSource)
|
|
if err != nil {
|
|
t.Fatalf("encode request: %v", err)
|
|
}
|
|
requestDecoded, err := PayloadToGameCreateRequest(requestBytes)
|
|
if err != nil {
|
|
t.Fatalf("decode request: %v", err)
|
|
}
|
|
if !reflect.DeepEqual(requestSource, requestDecoded) {
|
|
t.Fatalf("request round-trip mismatch\nsource: %#v\ndecoded:%#v", requestSource, requestDecoded)
|
|
}
|
|
|
|
created, updated, _ := fixedTimes()
|
|
responseSource := &lobbymodel.GameCreateResponse{
|
|
Game: lobbymodel.GameSummary{
|
|
GameID: "game-private-newly-created",
|
|
GameName: "First Contact",
|
|
GameType: "private",
|
|
Status: "draft",
|
|
OwnerUserID: "user-9912",
|
|
MinPlayers: 2,
|
|
MaxPlayers: 8,
|
|
EnrollmentEndsAt: ends,
|
|
CreatedAt: created,
|
|
UpdatedAt: updated,
|
|
},
|
|
}
|
|
responseBytes, err := GameCreateResponseToPayload(responseSource)
|
|
if err != nil {
|
|
t.Fatalf("encode response: %v", err)
|
|
}
|
|
responseDecoded, err := PayloadToGameCreateResponse(responseBytes)
|
|
if err != nil {
|
|
t.Fatalf("decode response: %v", err)
|
|
}
|
|
if !reflect.DeepEqual(responseSource, responseDecoded) {
|
|
t.Fatalf("response round-trip mismatch\nsource: %#v\ndecoded:%#v", responseSource, responseDecoded)
|
|
}
|
|
}
|
|
|
|
func TestLobbyApplicationSubmitRoundTrip(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
requestSource := &lobbymodel.ApplicationSubmitRequest{GameID: "game-public-aabb", RaceName: "Vegan Federation"}
|
|
requestBytes, err := ApplicationSubmitRequestToPayload(requestSource)
|
|
if err != nil {
|
|
t.Fatalf("encode request: %v", err)
|
|
}
|
|
requestDecoded, err := PayloadToApplicationSubmitRequest(requestBytes)
|
|
if err != nil {
|
|
t.Fatalf("decode request: %v", err)
|
|
}
|
|
if !reflect.DeepEqual(requestSource, requestDecoded) {
|
|
t.Fatalf("request round-trip mismatch\nsource: %#v\ndecoded:%#v", requestSource, requestDecoded)
|
|
}
|
|
|
|
created, _, _ := fixedTimes()
|
|
responseSource := &lobbymodel.ApplicationSubmitResponse{
|
|
Application: lobbymodel.ApplicationSummary{
|
|
ApplicationID: "app-3",
|
|
GameID: "game-public-aabb",
|
|
ApplicantUserID: "user-9912",
|
|
RaceName: "Vegan Federation",
|
|
Status: "pending",
|
|
CreatedAt: created,
|
|
DecidedAt: nil,
|
|
},
|
|
}
|
|
responseBytes, err := ApplicationSubmitResponseToPayload(responseSource)
|
|
if err != nil {
|
|
t.Fatalf("encode response: %v", err)
|
|
}
|
|
responseDecoded, err := PayloadToApplicationSubmitResponse(responseBytes)
|
|
if err != nil {
|
|
t.Fatalf("decode response: %v", err)
|
|
}
|
|
if !reflect.DeepEqual(responseSource, responseDecoded) {
|
|
t.Fatalf("response round-trip mismatch\nsource: %#v\ndecoded:%#v", responseSource, responseDecoded)
|
|
}
|
|
}
|
|
|
|
func TestLobbyInviteRedeemAndDeclineRoundTrip(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
created, _, _ := fixedTimes()
|
|
expires := created.Add(72 * time.Hour)
|
|
decided := created.Add(1 * time.Hour)
|
|
|
|
redeemReq := &lobbymodel.InviteRedeemRequest{GameID: "game-private-7c8f", InviteID: "invite-user-bound"}
|
|
redeemBytes, err := InviteRedeemRequestToPayload(redeemReq)
|
|
if err != nil {
|
|
t.Fatalf("encode redeem request: %v", err)
|
|
}
|
|
redeemDecoded, err := PayloadToInviteRedeemRequest(redeemBytes)
|
|
if err != nil {
|
|
t.Fatalf("decode redeem request: %v", err)
|
|
}
|
|
if !reflect.DeepEqual(redeemReq, redeemDecoded) {
|
|
t.Fatalf("redeem request mismatch")
|
|
}
|
|
|
|
redeemResp := &lobbymodel.InviteRedeemResponse{
|
|
Invite: lobbymodel.InviteSummary{
|
|
InviteID: "invite-user-bound",
|
|
GameID: "game-private-7c8f",
|
|
InviterUserID: "user-1111",
|
|
InvitedUserID: "user-9912",
|
|
Code: "",
|
|
RaceName: "Vegan Federation",
|
|
Status: "accepted",
|
|
CreatedAt: created,
|
|
ExpiresAt: expires,
|
|
DecidedAt: &decided,
|
|
},
|
|
}
|
|
redeemRespBytes, err := InviteRedeemResponseToPayload(redeemResp)
|
|
if err != nil {
|
|
t.Fatalf("encode redeem response: %v", err)
|
|
}
|
|
redeemRespDecoded, err := PayloadToInviteRedeemResponse(redeemRespBytes)
|
|
if err != nil {
|
|
t.Fatalf("decode redeem response: %v", err)
|
|
}
|
|
if !reflect.DeepEqual(redeemResp, redeemRespDecoded) {
|
|
t.Fatalf("redeem response mismatch")
|
|
}
|
|
|
|
declineReq := &lobbymodel.InviteDeclineRequest{GameID: "game-private-7c8f", InviteID: "invite-user-bound"}
|
|
declineBytes, err := InviteDeclineRequestToPayload(declineReq)
|
|
if err != nil {
|
|
t.Fatalf("encode decline request: %v", err)
|
|
}
|
|
declineDecoded, err := PayloadToInviteDeclineRequest(declineBytes)
|
|
if err != nil {
|
|
t.Fatalf("decode decline request: %v", err)
|
|
}
|
|
if !reflect.DeepEqual(declineReq, declineDecoded) {
|
|
t.Fatalf("decline request mismatch")
|
|
}
|
|
|
|
declineResp := &lobbymodel.InviteDeclineResponse{
|
|
Invite: lobbymodel.InviteSummary{
|
|
InviteID: "invite-user-bound",
|
|
GameID: "game-private-7c8f",
|
|
InviterUserID: "user-1111",
|
|
InvitedUserID: "user-9912",
|
|
Code: "",
|
|
RaceName: "Vegan Federation",
|
|
Status: "declined",
|
|
CreatedAt: created,
|
|
ExpiresAt: expires,
|
|
DecidedAt: &decided,
|
|
},
|
|
}
|
|
declineRespBytes, err := InviteDeclineResponseToPayload(declineResp)
|
|
if err != nil {
|
|
t.Fatalf("encode decline response: %v", err)
|
|
}
|
|
declineRespDecoded, err := PayloadToInviteDeclineResponse(declineRespBytes)
|
|
if err != nil {
|
|
t.Fatalf("decode decline response: %v", err)
|
|
}
|
|
if !reflect.DeepEqual(declineResp, declineRespDecoded) {
|
|
t.Fatalf("decline response mismatch")
|
|
}
|
|
}
|
|
|
|
func TestLobbyErrorResponseRoundTrip(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
source := &lobbymodel.ErrorResponse{
|
|
Error: lobbymodel.ErrorBody{
|
|
Code: "conflict",
|
|
Message: "request conflicts with current state",
|
|
},
|
|
}
|
|
payload, err := LobbyErrorResponseToPayload(source)
|
|
if err != nil {
|
|
t.Fatalf("encode: %v", err)
|
|
}
|
|
decoded, err := PayloadToLobbyErrorResponse(payload)
|
|
if err != nil {
|
|
t.Fatalf("decode: %v", err)
|
|
}
|
|
if !reflect.DeepEqual(source, decoded) {
|
|
t.Fatalf("round-trip mismatch\nsource: %#v\ndecoded:%#v", source, decoded)
|
|
}
|
|
}
|
|
|
|
func TestLobbyDecodersRecoverFromCorruption(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
garbage := []byte{0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08}
|
|
|
|
if _, err := PayloadToMyGamesListResponse(garbage); err == nil {
|
|
t.Fatal("expected error decoding corrupt my games payload")
|
|
}
|
|
if _, err := PayloadToPublicGamesListResponse(garbage); err == nil {
|
|
t.Fatal("expected error decoding corrupt public games payload")
|
|
}
|
|
if _, err := PayloadToApplicationSubmitResponse(garbage); err == nil {
|
|
t.Fatal("expected error decoding corrupt application submit response")
|
|
}
|
|
if _, err := PayloadToInviteRedeemResponse(garbage); err == nil {
|
|
t.Fatal("expected error decoding corrupt invite redeem response")
|
|
}
|
|
if _, err := PayloadToLobbyErrorResponse(garbage); err == nil {
|
|
t.Fatal("expected error decoding corrupt error payload")
|
|
}
|
|
}
|
|
|
|
func TestLobbyDecodersRejectEmptyPayload(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
if _, err := PayloadToMyGamesListRequest(nil); err == nil {
|
|
t.Fatal("expected error decoding nil request")
|
|
}
|
|
if _, err := PayloadToPublicGamesListResponse(nil); err == nil {
|
|
t.Fatal("expected error decoding nil response")
|
|
}
|
|
if _, err := PayloadToInviteDeclineRequest(nil); err == nil {
|
|
t.Fatal("expected error decoding nil decline request")
|
|
}
|
|
}
|