// Package lobbyuser_test exercises the synchronous Lobby → User Service // eligibility boundary by running both binaries in-process against a real // Redis container. The Game Master client surface is satisfied by an // inline httptest stub because the eligibility flow does not touch GM. package lobbyuser_test import ( "bytes" "encoding/json" "errors" "io" "maps" "net/http" "net/http/httptest" "testing" "time" "galaxy/integration/internal/harness" "github.com/stretchr/testify/require" ) func TestEligibilityCapturedOnApplication(t *testing.T) { h := newLobbyUserHarness(t) user := h.ensureUser(t, "happy@example.com") gameID := h.adminCreatePublicGame(t, "Happy Path Galaxy", time.Now().Add(48*time.Hour).Unix()) h.openEnrollment(t, gameID) app := h.submitApplicationExpectStatus(t, user.UserID, gameID, "PilotAurora", http.StatusCreated) require.NotEmpty(t, app["application_id"]) require.Equal(t, gameID, app["game_id"]) require.Equal(t, user.UserID, app["applicant_user_id"]) require.Equal(t, "PilotAurora", app["race_name"]) require.Equal(t, "submitted", app["status"]) } func TestEligibilityRejectedForPermanentlyBlockedUser(t *testing.T) { h := newLobbyUserHarness(t) user := h.ensureUser(t, "blocked@example.com") h.applyPermanentBlock(t, user.UserID) gameID := h.adminCreatePublicGame(t, "Block Galaxy", time.Now().Add(48*time.Hour).Unix()) h.openEnrollment(t, gameID) body := h.submitApplicationExpectStatus(t, user.UserID, gameID, "PilotEclipse", http.StatusUnprocessableEntity) requireErrorCode(t, body, "eligibility_denied") } func TestEligibilityRejectedForUnknownUser(t *testing.T) { h := newLobbyUserHarness(t) gameID := h.adminCreatePublicGame(t, "Unknown Galaxy", time.Now().Add(48*time.Hour).Unix()) h.openEnrollment(t, gameID) body := h.submitApplicationExpectStatus(t, "user-does-not-exist", gameID, "PilotPhantom", http.StatusUnprocessableEntity) requireErrorCode(t, body, "eligibility_denied") } func TestEligibilityFailsWhenUserServiceDown(t *testing.T) { h := newLobbyUserHarness(t) user := h.ensureUser(t, "transient@example.com") gameID := h.adminCreatePublicGame(t, "Transient Galaxy", time.Now().Add(48*time.Hour).Unix()) h.openEnrollment(t, gameID) h.userServiceProcess.Stop(t) body := h.submitApplicationExpectStatus(t, user.UserID, gameID, "PilotOutage", http.StatusServiceUnavailable) requireErrorCode(t, body, "service_unavailable") } type lobbyUserHarness struct { userServiceURL string lobbyPublicURL string lobbyAdminURL string gmStub *httptest.Server userServiceProcess *harness.Process lobbyProcess *harness.Process } type ensureByEmailResponse struct { Outcome string `json:"outcome"` UserID string `json:"user_id"` } func newLobbyUserHarness(t *testing.T) *lobbyUserHarness { t.Helper() redisRuntime := harness.StartRedisContainer(t) gmStub := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusOK) _, _ = w.Write([]byte(`{}`)) })) t.Cleanup(gmStub.Close) userServiceAddr := harness.FreeTCPAddress(t) lobbyPublicAddr := harness.FreeTCPAddress(t) lobbyInternalAddr := harness.FreeTCPAddress(t) userServiceBinary := harness.BuildBinary(t, "userservice", "./user/cmd/userservice") lobbyBinary := harness.BuildBinary(t, "lobby", "./lobby/cmd/lobby") userServiceProcess := harness.StartProcess(t, "userservice", userServiceBinary, map[string]string{ "USERSERVICE_LOG_LEVEL": "info", "USERSERVICE_INTERNAL_HTTP_ADDR": userServiceAddr, "USERSERVICE_REDIS_ADDR": redisRuntime.Addr, "OTEL_TRACES_EXPORTER": "none", "OTEL_METRICS_EXPORTER": "none", }) waitForUserServiceReady(t, userServiceProcess, "http://"+userServiceAddr) lobbyProcess := harness.StartProcess(t, "lobby", lobbyBinary, map[string]string{ "LOBBY_LOG_LEVEL": "info", "LOBBY_PUBLIC_HTTP_ADDR": lobbyPublicAddr, "LOBBY_INTERNAL_HTTP_ADDR": lobbyInternalAddr, "LOBBY_REDIS_ADDR": redisRuntime.Addr, "LOBBY_USER_SERVICE_BASE_URL": "http://" + userServiceAddr, "LOBBY_GM_BASE_URL": gmStub.URL, "OTEL_TRACES_EXPORTER": "none", "OTEL_METRICS_EXPORTER": "none", }) harness.WaitForHTTPStatus(t, lobbyProcess, "http://"+lobbyInternalAddr+"/readyz", http.StatusOK) return &lobbyUserHarness{ userServiceURL: "http://" + userServiceAddr, lobbyPublicURL: "http://" + lobbyPublicAddr, lobbyAdminURL: "http://" + lobbyInternalAddr, gmStub: gmStub, userServiceProcess: userServiceProcess, lobbyProcess: lobbyProcess, } } func (h *lobbyUserHarness) ensureUser(t *testing.T, email string) ensureByEmailResponse { 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 ensureByEmailResponse requireJSONStatus(t, resp, http.StatusOK, &out) require.Equal(t, "created", out.Outcome) require.NotEmpty(t, out.UserID) return out } func (h *lobbyUserHarness) applyPermanentBlock(t *testing.T, userID string) { t.Helper() resp := postJSON(t, h.userServiceURL+"/api/v1/internal/users/"+userID+"/sanctions/apply", map[string]any{ "sanction_code": "permanent_block", "scope": "platform", "reason_code": "terminal_policy_violation", "actor": map[string]string{"type": "admin", "id": "admin-1"}, "applied_at": time.Now().UTC().Format(time.RFC3339), }, nil) require.Equalf(t, http.StatusOK, resp.StatusCode, "apply permanent_block: %s", resp.Body) } func (h *lobbyUserHarness) adminCreatePublicGame(t *testing.T, name string, enrollmentEndsAt int64) string { t.Helper() resp := postJSON(t, h.lobbyAdminURL+"/api/v1/lobby/games", map[string]any{ "game_name": name, "game_type": "public", "min_players": 2, "max_players": 4, "start_gap_hours": 6, "start_gap_players": 1, "enrollment_ends_at": enrollmentEndsAt, "turn_schedule": "0 18 * * *", "target_engine_version": "1.0.0", }, nil) require.Equalf(t, http.StatusCreated, resp.StatusCode, "admin create 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.True(t, ok, "game_id missing in admin create response: %s", resp.Body) return gameID } func (h *lobbyUserHarness) openEnrollment(t *testing.T, gameID string) { t.Helper() resp := postJSON(t, h.lobbyAdminURL+"/api/v1/lobby/games/"+gameID+"/open-enrollment", nil, nil) require.Equalf(t, http.StatusOK, resp.StatusCode, "open enrollment: %s", resp.Body) } func (h *lobbyUserHarness) submitApplicationExpectStatus(t *testing.T, userID, gameID, raceName string, want int) map[string]any { t.Helper() resp := postJSON(t, h.lobbyPublicURL+"/api/v1/lobby/games/"+gameID+"/applications", map[string]any{ "race_name": raceName, }, http.Header{"X-User-Id": []string{userID}}) require.Equalf(t, want, resp.StatusCode, "submit application: %s", resp.Body) var body map[string]any if resp.Body != "" { require.NoError(t, json.Unmarshal([]byte(resp.Body), &body)) } return body } 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()) } type httpResponse struct { StatusCode int Body string Header http.Header } 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 } func requireErrorCode(t *testing.T, body map[string]any, want string) { t.Helper() require.NotNil(t, body, "error response body must not be empty") envelope, ok := body["error"].(map[string]any) require.Truef(t, ok, "expected error envelope, got %v", body) require.Equalf(t, want, envelope["code"], "expected error code %q, got %v", want, envelope["code"]) }