// Package lobbyrtm_test exercises the Lobby ↔ Runtime Manager // boundary against real Lobby + real Runtime Manager + real // PostgreSQL + real Redis + real Docker daemon running the // galaxy/game test engine container. It satisfies the inter-service // requirement spelled out in `TESTING.md §7` and PLAN.md Stage 20. // // The boundary contract is: Lobby publishes `runtime:start_jobs` and // `runtime:stop_jobs` envelopes, RTM consumes them and runs/stops // engine containers, RTM publishes `runtime:job_results`, Lobby // transitions the game accordingly. The suite asserts only on those // public surfaces (Lobby/RTM REST, Redis Streams, Docker container // state); it never imports `*/internal/...` packages of either // service. package lobbyrtm_test import ( "bytes" "context" "encoding/json" "errors" "fmt" "io" "maps" "net/http" "net/http/httptest" "os" "strconv" "strings" "sync/atomic" "testing" "time" "galaxy/integration/internal/harness" "github.com/redis/go-redis/v9" "github.com/stretchr/testify/require" ) const ( defaultEngineVersion = "1.0.0" missingEngineVersion = "0.0.0-missing" startJobsStream = "runtime:start_jobs" stopJobsStream = "runtime:stop_jobs" jobResultsStream = "runtime:job_results" healthEventsStream = "runtime:health_events" notificationIntentsKey = "notification:intents" userLifecycleStream = "user:lifecycle_events" gmEventsStream = "gm:lobby_events" expectedLobbyProducer = "game_lobby" notificationImagePulled = "runtime.image_pull_failed" ) // suiteSeq scopes per-test stream prefixes so concurrent test // invocations cannot bleed events into each other. var suiteSeq atomic.Int64 // lobbyRTMHarness owns the per-test infrastructure: containers, // processes, stream keys, and helper clients. One harness per test // keeps each scenario fully isolated. type lobbyRTMHarness struct { redis *redis.Client userServiceURL string lobbyPublicURL string lobbyAdminURL string rtmInternalURL string intentsStream string lifecycleStream string jobResultsStream string startJobsStream string stopJobsStream string healthEvents string gmStub *httptest.Server dockerNetwork string engineImage string userServiceProcess *harness.Process lobbyProcess *harness.Process rtmProcess *harness.Process } type ensureUserResponse struct { Outcome string `json:"outcome"` UserID string `json:"user_id"` } type httpResponse struct { StatusCode int Body string Header http.Header } // newLobbyRTMHarness brings up one independent test environment: // Postgres containers per service (mirrors `lobbynotification`), one // Redis container, real binaries for User Service / Lobby / RTM, a // GM stub that returns 200, a per-test Docker bridge network, and // the freshly-built `galaxy/game` test image. func newLobbyRTMHarness(t *testing.T) *lobbyRTMHarness { t.Helper() // Skip the whole suite when Docker is unreachable. The ensure-only // check runs before any testcontainer is started so the skip path // kicks in before testcontainers-go tries (and fails) to probe the // daemon. harness.RequireDockerDaemon(t) redisRuntime := harness.StartRedisContainer(t) redisClient := redis.NewClient(&redis.Options{ Addr: redisRuntime.Addr, Protocol: 2, DisableIdentity: true, }) t.Cleanup(func() { require.NoError(t, redisClient.Close()) }) gmStub := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusOK) _, _ = w.Write([]byte(`{}`)) })) t.Cleanup(gmStub.Close) engineImage := harness.EnsureGalaxyGameImage(t) dockerNetwork := harness.EnsureDockerNetwork(t) userServiceAddr := harness.FreeTCPAddress(t) lobbyPublicAddr := harness.FreeTCPAddress(t) lobbyInternalAddr := harness.FreeTCPAddress(t) rtmInternalAddr := harness.FreeTCPAddress(t) userServiceBinary := harness.BuildBinary(t, "userservice", "./user/cmd/userservice") lobbyBinary := harness.BuildBinary(t, "lobby", "./lobby/cmd/lobby") rtmBinary := harness.BuildBinary(t, "rtmanager", "./rtmanager/cmd/rtmanager") userServiceEnv := harness.StartUserServicePersistence(t, redisRuntime.Addr).Env userServiceEnv["USERSERVICE_LOG_LEVEL"] = "info" userServiceEnv["USERSERVICE_INTERNAL_HTTP_ADDR"] = userServiceAddr userServiceEnv["OTEL_TRACES_EXPORTER"] = "none" userServiceEnv["OTEL_METRICS_EXPORTER"] = "none" userServiceProcess := harness.StartProcess(t, "userservice", userServiceBinary, userServiceEnv) waitForUserServiceReady(t, userServiceProcess, "http://"+userServiceAddr) suffix := strconv.FormatInt(suiteSeq.Add(1), 10) intentsStream := notificationIntentsKey + ":" + suffix lifecycleStream := userLifecycleStream + ":" + suffix jobResultsStreamKey := jobResultsStream + ":" + suffix startJobsStreamKey := startJobsStream + ":" + suffix stopJobsStreamKey := stopJobsStream + ":" + suffix healthEventsStreamKey := healthEventsStream + ":" + suffix gmEventsStreamKey := gmEventsStream + ":" + suffix lobbyEnv := harness.StartLobbyServicePersistence(t, redisRuntime.Addr).Env lobbyEnv["LOBBY_LOG_LEVEL"] = "info" lobbyEnv["LOBBY_PUBLIC_HTTP_ADDR"] = lobbyPublicAddr lobbyEnv["LOBBY_INTERNAL_HTTP_ADDR"] = lobbyInternalAddr lobbyEnv["LOBBY_USER_SERVICE_BASE_URL"] = "http://" + userServiceAddr lobbyEnv["LOBBY_GM_BASE_URL"] = gmStub.URL lobbyEnv["LOBBY_NOTIFICATION_INTENTS_STREAM"] = intentsStream lobbyEnv["LOBBY_USER_LIFECYCLE_STREAM"] = lifecycleStream lobbyEnv["LOBBY_RUNTIME_JOB_RESULTS_STREAM"] = jobResultsStreamKey lobbyEnv["LOBBY_RUNTIME_START_JOBS_STREAM"] = startJobsStreamKey lobbyEnv["LOBBY_RUNTIME_STOP_JOBS_STREAM"] = stopJobsStreamKey lobbyEnv["LOBBY_GM_EVENTS_STREAM"] = gmEventsStreamKey lobbyEnv["LOBBY_RUNTIME_JOB_RESULTS_READ_BLOCK_TIMEOUT"] = "200ms" lobbyEnv["LOBBY_USER_LIFECYCLE_READ_BLOCK_TIMEOUT"] = "200ms" lobbyEnv["LOBBY_GM_EVENTS_READ_BLOCK_TIMEOUT"] = "200ms" lobbyEnv["LOBBY_ENGINE_IMAGE_TEMPLATE"] = "galaxy/game:{engine_version}-lobbyrtm-it" lobbyEnv["OTEL_TRACES_EXPORTER"] = "none" lobbyEnv["OTEL_METRICS_EXPORTER"] = "none" lobbyProcess := harness.StartProcess(t, "lobby", lobbyBinary, lobbyEnv) harness.WaitForHTTPStatus(t, lobbyProcess, "http://"+lobbyInternalAddr+"/readyz", http.StatusOK) rtmEnv := harness.StartRTManagerServicePersistence(t, redisRuntime.Addr).Env rtmEnv["RTMANAGER_LOG_LEVEL"] = "info" rtmEnv["RTMANAGER_INTERNAL_HTTP_ADDR"] = rtmInternalAddr rtmEnv["RTMANAGER_LOBBY_INTERNAL_BASE_URL"] = "http://" + lobbyInternalAddr rtmEnv["RTMANAGER_DOCKER_HOST"] = resolveDockerHost() rtmEnv["RTMANAGER_DOCKER_NETWORK"] = dockerNetwork // On dev machines and in sandboxes the rtmanager process cannot // chown the per-game state dir to root (uid 0). Pin the owner to // the current process uid/gid so `chown` is a no-op. rtmEnv["RTMANAGER_GAME_STATE_OWNER_UID"] = strconv.Itoa(os.Getuid()) rtmEnv["RTMANAGER_GAME_STATE_OWNER_GID"] = strconv.Itoa(os.Getgid()) rtmEnv["RTMANAGER_GAME_STATE_ROOT"] = t.TempDir() rtmEnv["RTMANAGER_REDIS_START_JOBS_STREAM"] = startJobsStreamKey rtmEnv["RTMANAGER_REDIS_STOP_JOBS_STREAM"] = stopJobsStreamKey rtmEnv["RTMANAGER_REDIS_JOB_RESULTS_STREAM"] = jobResultsStreamKey rtmEnv["RTMANAGER_REDIS_HEALTH_EVENTS_STREAM"] = healthEventsStreamKey rtmEnv["RTMANAGER_NOTIFICATION_INTENTS_STREAM"] = intentsStream rtmEnv["RTMANAGER_STREAM_BLOCK_TIMEOUT"] = "200ms" rtmEnv["RTMANAGER_RECONCILE_INTERVAL"] = "1s" rtmEnv["RTMANAGER_CLEANUP_INTERVAL"] = "1s" rtmEnv["RTMANAGER_INSPECT_INTERVAL"] = "1s" rtmEnv["RTMANAGER_PROBE_INTERVAL"] = "1s" rtmEnv["RTMANAGER_PROBE_TIMEOUT"] = "1s" rtmEnv["RTMANAGER_PROBE_FAILURES_THRESHOLD"] = "3" rtmEnv["RTMANAGER_GAME_LEASE_TTL_SECONDS"] = "10" rtmEnv["OTEL_TRACES_EXPORTER"] = "none" rtmEnv["OTEL_METRICS_EXPORTER"] = "none" rtmProcess := harness.StartProcess(t, "rtmanager", rtmBinary, rtmEnv) harness.WaitForHTTPStatus(t, rtmProcess, "http://"+rtmInternalAddr+"/readyz", http.StatusOK) return &lobbyRTMHarness{ redis: redisClient, userServiceURL: "http://" + userServiceAddr, lobbyPublicURL: "http://" + lobbyPublicAddr, lobbyAdminURL: "http://" + lobbyInternalAddr, rtmInternalURL: "http://" + rtmInternalAddr, intentsStream: intentsStream, lifecycleStream: lifecycleStream, jobResultsStream: jobResultsStreamKey, startJobsStream: startJobsStreamKey, stopJobsStream: stopJobsStreamKey, healthEvents: healthEventsStreamKey, gmStub: gmStub, dockerNetwork: dockerNetwork, engineImage: engineImage, userServiceProcess: userServiceProcess, lobbyProcess: lobbyProcess, rtmProcess: rtmProcess, } } // ensureUser provisions a fresh User Service account by email and // returns the assigned user_id. The email pattern includes the test // name to avoid collisions across concurrent tests sharing the // container. func (h *lobbyRTMHarness) ensureUser(t *testing.T, email string) ensureUserResponse { t.Helper() resp := postJSON(t, h.userServiceURL+"/api/v1/internal/users/ensure-by-email", map[string]any{ "email": email, "registration_context": map[string]string{ "preferred_language": "en", "time_zone": "Europe/Kaliningrad", }, }, nil) var out ensureUserResponse requireJSONStatus(t, resp, http.StatusOK, &out) require.Equal(t, "created", out.Outcome) require.NotEmpty(t, out.UserID) return out } // userCreatePrivateGame creates a private game owned by ownerUserID // with the supplied target engine version. Returns the assigned // game_id. func (h *lobbyRTMHarness) userCreatePrivateGame( t *testing.T, ownerUserID, name, targetEngineVersion string, enrollmentEndsAt int64, ) string { t.Helper() resp := postJSON(t, h.lobbyPublicURL+"/api/v1/lobby/games", map[string]any{ "game_name": name, "game_type": "private", "min_players": 1, "max_players": 4, "start_gap_hours": 6, "start_gap_players": 1, "enrollment_ends_at": enrollmentEndsAt, "turn_schedule": "0 18 * * *", "target_engine_version": targetEngineVersion, }, http.Header{"X-User-Id": []string{ownerUserID}}) require.Equalf(t, http.StatusCreated, resp.StatusCode, "create private game: %s", resp.Body) var record map[string]any require.NoError(t, json.Unmarshal([]byte(resp.Body), &record)) gameID, ok := record["game_id"].(string) require.Truef(t, ok, "game_id missing: %s", resp.Body) return gameID } func (h *lobbyRTMHarness) userOpenEnrollment(t *testing.T, ownerUserID, gameID string) { t.Helper() resp := postJSON(t, h.lobbyPublicURL+"/api/v1/lobby/games/"+gameID+"/open-enrollment", nil, http.Header{"X-User-Id": []string{ownerUserID}}, ) require.Equalf(t, http.StatusOK, resp.StatusCode, "user open enrollment: %s", resp.Body) } func (h *lobbyRTMHarness) userCreateInvite(t *testing.T, ownerUserID, gameID, inviteeUserID string) { t.Helper() resp := postJSON(t, h.lobbyPublicURL+"/api/v1/lobby/games/"+gameID+"/invites", map[string]any{"invitee_user_id": inviteeUserID}, http.Header{"X-User-Id": []string{ownerUserID}}, ) require.Equalf(t, http.StatusCreated, resp.StatusCode, "create invite: %s", resp.Body) } func (h *lobbyRTMHarness) firstCreatedInviteID(t *testing.T, inviteeUserID, gameID string) string { t.Helper() req, err := http.NewRequest(http.MethodGet, h.lobbyPublicURL+"/api/v1/lobby/my/invites?status=created", nil) require.NoError(t, err) req.Header.Set("X-User-Id", inviteeUserID) resp := doRequest(t, req) require.Equalf(t, http.StatusOK, resp.StatusCode, "list my invites: %s", resp.Body) var body struct { Items []struct { InviteID string `json:"invite_id"` GameID string `json:"game_id"` } `json:"items"` } require.NoError(t, json.Unmarshal([]byte(resp.Body), &body)) for _, item := range body.Items { if item.GameID == gameID { return item.InviteID } } t.Fatalf("no invite found for invitee %s on game %s; body=%s", inviteeUserID, gameID, resp.Body) return "" } func (h *lobbyRTMHarness) userRedeemInvite(t *testing.T, inviteeUserID, gameID, inviteID, raceName string) { t.Helper() resp := postJSON(t, h.lobbyPublicURL+"/api/v1/lobby/games/"+gameID+"/invites/"+inviteID+"/redeem", map[string]any{"race_name": raceName}, http.Header{"X-User-Id": []string{inviteeUserID}}, ) require.Equalf(t, http.StatusOK, resp.StatusCode, "redeem invite: %s", resp.Body) } func (h *lobbyRTMHarness) userReadyToStart(t *testing.T, ownerUserID, gameID string) { t.Helper() resp := postJSON(t, h.lobbyPublicURL+"/api/v1/lobby/games/"+gameID+"/ready-to-start", nil, http.Header{"X-User-Id": []string{ownerUserID}}, ) require.Equalf(t, http.StatusOK, resp.StatusCode, "ready-to-start: %s", resp.Body) } func (h *lobbyRTMHarness) userStartGame(t *testing.T, ownerUserID, gameID string) { t.Helper() resp := postJSON(t, h.lobbyPublicURL+"/api/v1/lobby/games/"+gameID+"/start", nil, http.Header{"X-User-Id": []string{ownerUserID}}, ) require.Equalf(t, http.StatusOK, resp.StatusCode, "user start: %s", resp.Body) } // prepareInflightGame walks one private game from creation through // `start`. For the happy and cancel scenarios the game subsequently // reaches `running` once RTM publishes the success job_result; for // the failure scenario it ends in `start_failed`. // // Returns owner and invitee user records plus the game id. func (h *lobbyRTMHarness) prepareInflightGame( t *testing.T, ownerEmail, inviteeEmail, gameName, targetEngineVersion string, ) (owner, invitee ensureUserResponse, gameID string) { t.Helper() owner = h.ensureUser(t, ownerEmail) invitee = h.ensureUser(t, inviteeEmail) gameID = h.userCreatePrivateGame(t, owner.UserID, gameName, targetEngineVersion, time.Now().Add(48*time.Hour).Unix()) h.userOpenEnrollment(t, owner.UserID, gameID) h.userCreateInvite(t, owner.UserID, gameID, invitee.UserID) inviteID := h.firstCreatedInviteID(t, invitee.UserID, gameID) h.userRedeemInvite(t, invitee.UserID, gameID, inviteID, "PilotInvitee") h.userReadyToStart(t, owner.UserID, gameID) h.userStartGame(t, owner.UserID, gameID) return owner, invitee, gameID } // gameStatus reads one game record off Lobby's internal API and // returns its status field. Used by waitGameStatus and direct // assertions. func (h *lobbyRTMHarness) gameStatus(t *testing.T, gameID string) string { t.Helper() req, err := http.NewRequest(http.MethodGet, h.lobbyAdminURL+"/api/v1/internal/games/"+gameID, nil) require.NoError(t, err) resp := doRequest(t, req) if resp.StatusCode != http.StatusOK { t.Fatalf("get game internal: status=%d body=%s", resp.StatusCode, resp.Body) } var record struct { Status string `json:"status"` } require.NoError(t, json.Unmarshal([]byte(resp.Body), &record)) return record.Status } // waitGameStatus polls `GET /api/v1/internal/games/{gameID}` until // the record reports the expected status or the timeout fires. func (h *lobbyRTMHarness) waitGameStatus(t *testing.T, gameID, want string, timeout time.Duration) { t.Helper() deadline := time.Now().Add(timeout) for { got := h.gameStatus(t, gameID) if got == want { return } if time.Now().After(deadline) { t.Fatalf("game %s status: want %q got %q (after %s)", gameID, want, got, timeout) } time.Sleep(150 * time.Millisecond) } } // publishUserLifecycleEvent appends one event to the per-test // `user:lifecycle_events` stream. The Lobby userlifecycle worker // consumes the same stream. func (h *lobbyRTMHarness) publishUserLifecycleEvent(t *testing.T, eventType, userID string) { t.Helper() _, err := h.redis.XAdd(context.Background(), &redis.XAddArgs{ Stream: h.lifecycleStream, Values: map[string]any{ "event_type": eventType, "user_id": userID, "occurred_at_ms": strconv.FormatInt(time.Now().UnixMilli(), 10), "source": "user_admin", "actor_type": "admin", "actor_id": "admin-1", "reason_code": "terminal_policy_violation", }, }).Result() require.NoError(t, err) } // jobResultEntry decodes one `runtime:job_results` Redis Stream entry. type jobResultEntry struct { StreamID string GameID string Outcome string ContainerID string EngineEndpoint string ErrorCode string ErrorMessage string } // stopJobEntry decodes one `runtime:stop_jobs` Redis Stream entry as // published by Lobby. type stopJobEntry struct { StreamID string GameID string Reason string } // notificationIntentEntry decodes one `notification:intents` entry. type notificationIntentEntry struct { StreamID string NotificationType string Producer string Payload map[string]any } // allJobResults returns every entry on the per-test job_results // stream in stream order. func (h *lobbyRTMHarness) allJobResults(t *testing.T) []jobResultEntry { t.Helper() entries, err := h.redis.XRange(context.Background(), h.jobResultsStream, "-", "+").Result() require.NoError(t, err) out := make([]jobResultEntry, 0, len(entries)) for _, entry := range entries { out = append(out, jobResultEntry{ StreamID: entry.ID, GameID: streamString(entry.Values, "game_id"), Outcome: streamString(entry.Values, "outcome"), ContainerID: streamString(entry.Values, "container_id"), EngineEndpoint: streamString(entry.Values, "engine_endpoint"), ErrorCode: streamString(entry.Values, "error_code"), ErrorMessage: streamString(entry.Values, "error_message"), }) } return out } // waitJobResult polls the per-test job_results stream until predicate // matches one entry, or the timeout fires. func (h *lobbyRTMHarness) waitJobResult( t *testing.T, predicate func(jobResultEntry) bool, timeout time.Duration, ) jobResultEntry { t.Helper() deadline := time.Now().Add(timeout) for { entries := h.allJobResults(t) for _, entry := range entries { if predicate(entry) { return entry } } if time.Now().After(deadline) { t.Fatalf("no job_result matched within %s; observed=%+v", timeout, entries) } time.Sleep(150 * time.Millisecond) } } // allStopJobs returns every entry on the per-test stop_jobs stream. func (h *lobbyRTMHarness) allStopJobs(t *testing.T) []stopJobEntry { t.Helper() entries, err := h.redis.XRange(context.Background(), h.stopJobsStream, "-", "+").Result() require.NoError(t, err) out := make([]stopJobEntry, 0, len(entries)) for _, entry := range entries { out = append(out, stopJobEntry{ StreamID: entry.ID, GameID: streamString(entry.Values, "game_id"), Reason: streamString(entry.Values, "reason"), }) } return out } // waitStopJobReason polls the stop_jobs stream until an entry for // gameID with the expected reason appears. func (h *lobbyRTMHarness) waitStopJobReason(t *testing.T, gameID, reason string, timeout time.Duration) stopJobEntry { t.Helper() deadline := time.Now().Add(timeout) for { for _, entry := range h.allStopJobs(t) { if entry.GameID == gameID && entry.Reason == reason { return entry } } if time.Now().After(deadline) { t.Fatalf("no stop_job for game %s with reason %q within %s", gameID, reason, timeout) } time.Sleep(150 * time.Millisecond) } } // allNotificationIntents returns every entry on the per-test // notification:intents stream. func (h *lobbyRTMHarness) allNotificationIntents(t *testing.T) []notificationIntentEntry { t.Helper() entries, err := h.redis.XRange(context.Background(), h.intentsStream, "-", "+").Result() require.NoError(t, err) out := make([]notificationIntentEntry, 0, len(entries)) for _, entry := range entries { decoded := notificationIntentEntry{ StreamID: entry.ID, NotificationType: streamString(entry.Values, "notification_type"), Producer: streamString(entry.Values, "producer"), } // `pkg/notificationintent` publishes the payload under the // field name `payload_json`. Older versions of this harness // looked for `payload` and silently produced an empty Payload // map, which made every predicate that checks `Payload["…"]` // fall through. Read both field names for forward compat. raw := streamString(entry.Values, "payload_json") if raw == "" { raw = streamString(entry.Values, "payload") } if raw != "" { var parsed map[string]any if err := json.Unmarshal([]byte(raw), &parsed); err == nil { decoded.Payload = parsed } } out = append(out, decoded) } return out } // waitNotificationIntent polls the intents stream until the // predicate matches. func (h *lobbyRTMHarness) waitNotificationIntent( t *testing.T, predicate func(notificationIntentEntry) bool, timeout time.Duration, ) notificationIntentEntry { t.Helper() deadline := time.Now().Add(timeout) for { entries := h.allNotificationIntents(t) for _, entry := range entries { if predicate(entry) { return entry } } if time.Now().After(deadline) { summary := make([]string, 0, len(entries)) for _, entry := range entries { summary = append(summary, entry.NotificationType+":"+entry.Producer) } t.Fatalf("no notification_intent matched within %s; observed=%v", timeout, summary) } time.Sleep(150 * time.Millisecond) } } // rtmRuntimeStatus issues `GET /api/v1/internal/runtimes/{gameID}` // against RTM and returns the persisted runtime record's status, or // the empty string when RTM responds 404. func (h *lobbyRTMHarness) rtmRuntimeStatus(t *testing.T, gameID string) (string, int) { t.Helper() req, err := http.NewRequest(http.MethodGet, h.rtmInternalURL+"/api/v1/internal/runtimes/"+gameID, nil) require.NoError(t, err) resp := doRequest(t, req) if resp.StatusCode == http.StatusNotFound { return "", resp.StatusCode } if resp.StatusCode != http.StatusOK { t.Fatalf("rtm get runtime: status=%d body=%s", resp.StatusCode, resp.Body) } var record struct { Status string `json:"status"` } require.NoError(t, json.Unmarshal([]byte(resp.Body), &record)) return record.Status, resp.StatusCode } // waitRTMRuntimeStatus polls RTM until the runtime record reports // the expected status or the timeout fires. func (h *lobbyRTMHarness) waitRTMRuntimeStatus(t *testing.T, gameID, want string, timeout time.Duration) { t.Helper() deadline := time.Now().Add(timeout) for { status, code := h.rtmRuntimeStatus(t, gameID) if status == want { return } if time.Now().After(deadline) { t.Fatalf("rtm runtime status for %s: want %q got %q (http %d) within %s", gameID, want, status, code, timeout) } time.Sleep(150 * time.Millisecond) } } // streamString reads a Redis Streams field as a string regardless of // the underlying go-redis decoded type. func streamString(values map[string]any, key string) string { raw, ok := values[key] if !ok { return "" } switch typed := raw.(type) { case string: return typed case []byte: return string(typed) default: return fmt.Sprintf("%v", typed) } } func waitForUserServiceReady(t *testing.T, process *harness.Process, baseURL string) { t.Helper() client := &http.Client{Timeout: 250 * time.Millisecond} t.Cleanup(client.CloseIdleConnections) deadline := time.Now().Add(10 * time.Second) for time.Now().Before(deadline) { req, err := http.NewRequest(http.MethodGet, baseURL+"/api/v1/internal/users/user-readiness-probe/exists", nil) require.NoError(t, err) response, err := client.Do(req) if err == nil { _, _ = io.Copy(io.Discard, response.Body) response.Body.Close() if response.StatusCode == http.StatusOK { return } } time.Sleep(25 * time.Millisecond) } t.Fatalf("wait for userservice readiness: timeout\n%s", process.Logs()) } func postJSON(t *testing.T, url string, body any, header http.Header) httpResponse { t.Helper() var reader io.Reader if body != nil { payload, err := json.Marshal(body) require.NoError(t, err) reader = bytes.NewReader(payload) } req, err := http.NewRequest(http.MethodPost, url, reader) require.NoError(t, err) if body != nil { req.Header.Set("Content-Type", "application/json") } maps.Copy(req.Header, header) return doRequest(t, req) } func doRequest(t *testing.T, request *http.Request) httpResponse { t.Helper() client := &http.Client{ Timeout: 5 * time.Second, Transport: &http.Transport{DisableKeepAlives: true}, } t.Cleanup(client.CloseIdleConnections) response, err := client.Do(request) require.NoError(t, err) defer response.Body.Close() payload, err := io.ReadAll(response.Body) require.NoError(t, err) return httpResponse{ StatusCode: response.StatusCode, Body: string(payload), Header: response.Header.Clone(), } } func requireJSONStatus(t *testing.T, response httpResponse, wantStatus int, target any) { t.Helper() require.Equalf(t, wantStatus, response.StatusCode, "unexpected status, body=%s", response.Body) if target != nil { require.NoError(t, decodeStrictJSON([]byte(response.Body), target)) } } func decodeStrictJSON(payload []byte, target any) error { decoder := json.NewDecoder(bytes.NewReader(payload)) decoder.DisallowUnknownFields() if err := decoder.Decode(target); err != nil { return err } if err := decoder.Decode(&struct{}{}); err != io.EOF { if err == nil { return errors.New("unexpected trailing JSON input") } return err } return nil } // resolveDockerHost honours DOCKER_HOST when the developer machine // routes through colima or a remote daemon, falling back to the // standard unix path otherwise. func resolveDockerHost() string { if host := strings.TrimSpace(os.Getenv("DOCKER_HOST")); host != "" { return host } return "unix:///var/run/docker.sock" }