package integration_test import ( "context" "encoding/json" "net/http" "testing" "time" "galaxy/integration/testenv" ) // TestLobbyFlow_PrivateGameInviteRedeem exercises the lobby state // machine that does NOT require a live engine container: // // 1. owner registers and creates a private game (draft); // 2. owner moves it to `enrollment_open` via `/open-enrollment`; // 3. owner issues a user-bound invite to a second user; // 4. invitee redeems the invite; // 5. owner lists `/lobby/games/{game_id}/memberships` and sees both // pilots. // // The engine-running phases (start → command → force-next-turn → // finish → race name promotion) live in `runtime_lifecycle_test.go` // and `engine_command_proxy_test.go`, which spin up the // `galaxy/game:integration` container. func TestLobbyFlow_PrivateGameInviteRedeem(t *testing.T) { plat := testenv.Bootstrap(t, testenv.BootstrapOptions{}) ctx, cancel := context.WithTimeout(context.Background(), 90*time.Second) defer cancel() // Seed engine version so create-game validation passes. admin := testenv.NewBackendAdminClient(plat.Backend.HTTPURL, plat.Backend.AdminUser, plat.Backend.AdminPassword) if _, resp, err := admin.Do(ctx, http.MethodPost, "/api/v1/admin/engine-versions", map[string]any{ "version": "v1.0.0", "image_ref": "galaxy/game:integration", "enabled": true, }); err != nil || resp.StatusCode/100 != 2 { t.Fatalf("seed engine_version: err=%v resp=%v", err, resp) } owner := testenv.RegisterSession(t, plat, "owner+lobby@example.com") invitee := testenv.RegisterSession(t, plat, "invitee+lobby@example.com") ownerID, err := owner.LookupUserID(ctx, plat) if err != nil { t.Fatalf("resolve owner: %v", err) } inviteeID, err := invitee.LookupUserID(ctx, plat) if err != nil { t.Fatalf("resolve invitee: %v", err) } ownerClient := testenv.NewBackendUserClient(plat.Backend.HTTPURL, ownerID) inviteeClient := testenv.NewBackendUserClient(plat.Backend.HTTPURL, inviteeID) // 1+2. Create + open enrollment. gameBody := map[string]any{ "game_name": "Private Lobby Run", "visibility": "private", "min_players": 2, "max_players": 4, "start_gap_hours": 1, "start_gap_players": 2, "enrollment_ends_at": time.Now().Add(24 * time.Hour).UTC().Format(time.RFC3339), "turn_schedule": "0 * * * *", "target_engine_version": "v1.0.0", } raw, resp, err := ownerClient.Do(ctx, http.MethodPost, "/api/v1/user/lobby/games", gameBody) if err != nil || resp.StatusCode != http.StatusCreated { t.Fatalf("create private game: err=%v status=%d body=%s", err, resp.StatusCode, string(raw)) } var game struct { GameID string `json:"game_id"` } if err := json.Unmarshal(raw, &game); err != nil { t.Fatalf("decode game: %v", err) } if _, resp, err = ownerClient.Do(ctx, http.MethodPost, "/api/v1/user/lobby/games/"+game.GameID+"/open-enrollment", nil); err != nil { t.Fatalf("open enrollment: %v", err) } if resp.StatusCode != http.StatusOK { t.Fatalf("open enrollment: status %d", resp.StatusCode) } // 3. Owner issues an invite for invitee. raw, resp, err = ownerClient.Do(ctx, http.MethodPost, "/api/v1/user/lobby/games/"+game.GameID+"/invites", map[string]any{ "invited_user_id": inviteeID, "race_name": "Invitee-Crew", }) if err != nil || resp.StatusCode != http.StatusCreated { t.Fatalf("issue invite: err=%v status=%d body=%s", err, resp.StatusCode, string(raw)) } var invite struct { InviteID string `json:"invite_id"` } if err := json.Unmarshal(raw, &invite); err != nil { t.Fatalf("decode invite: %v", err) } // 4. Invitee redeems. raw, resp, err = inviteeClient.Do(ctx, http.MethodPost, "/api/v1/user/lobby/games/"+game.GameID+"/invites/"+invite.InviteID+"/redeem", nil) if err != nil { t.Fatalf("redeem: %v", err) } if resp.StatusCode/100 != 2 { t.Fatalf("redeem: status %d body=%s", resp.StatusCode, string(raw)) } // 5. Memberships listing should now include the invitee. raw, resp, err = ownerClient.Do(ctx, http.MethodGet, "/api/v1/user/lobby/games/"+game.GameID+"/memberships?page=1&page_size=10", nil) if err != nil || resp.StatusCode != http.StatusOK { t.Fatalf("memberships list: err=%v status=%d body=%s", err, resp.StatusCode, string(raw)) } var mems struct { Items []struct { UserID string `json:"user_id"` } `json:"items"` } if err := json.Unmarshal(raw, &mems); err != nil { t.Fatalf("decode memberships: %v", err) } found := false for _, m := range mems.Items { if m.UserID == inviteeID { found = true break } } if !found { t.Fatalf("invitee membership not present in listing: %+v", mems.Items) } }