package user import ( "bytes" "context" "encoding/json" "errors" "io" "log/slog" "net" "net/http" "strings" "testing" "time" "galaxy/user/internal/app" "galaxy/user/internal/config" "github.com/alicebob/miniredis/v2" "github.com/stretchr/testify/require" ) type runtimeContractHarness struct { baseURL string client *http.Client runtime *app.Runtime cancel context.CancelFunc runErr chan error } func newRuntimeContractHarness(t *testing.T) *runtimeContractHarness { t.Helper() redisServer := miniredis.RunT(t) cfg := config.DefaultConfig() cfg.Redis.Addr = redisServer.Addr() cfg.InternalHTTP.Addr = freeLoopbackAddress(t) cfg.AdminHTTP.Addr = "" cfg.ShutdownTimeout = 10 * time.Second cfg.Telemetry.TracesExporter = "none" cfg.Telemetry.MetricsExporter = "none" logger := slog.New(slog.NewTextHandler(io.Discard, nil)) runtime, err := app.NewRuntime(context.Background(), cfg, logger) require.NoError(t, err) runCtx, cancel := context.WithCancel(context.Background()) runErr := make(chan error, 1) go func() { runErr <- runtime.Run(runCtx) }() client := &http.Client{ Timeout: 500 * time.Millisecond, Transport: &http.Transport{ DisableKeepAlives: true, }, } harness := &runtimeContractHarness{ baseURL: "http://" + cfg.InternalHTTP.Addr, client: client, runtime: runtime, cancel: cancel, runErr: runErr, } harness.waitUntilReady(t) t.Cleanup(func() { cancel() select { case err := <-runErr: require.NoError(t, err) case <-time.After(cfg.ShutdownTimeout + 2*time.Second): t.Fatalf("runtime did not stop in time") } require.NoError(t, runtime.Close()) client.CloseIdleConnections() }) return harness } func (h *runtimeContractHarness) waitUntilReady(t *testing.T) { t.Helper() require.Eventually(t, func() bool { request, err := http.NewRequest(http.MethodGet, h.baseURL+"/api/v1/internal/users/user-missing/exists", nil) if err != nil { return false } response, err := h.client.Do(request) if err != nil { return false } defer response.Body.Close() _, _ = io.Copy(io.Discard, response.Body) return response.StatusCode == http.StatusOK }, 5*time.Second, 25*time.Millisecond, "user runtime did not become reachable") } func (h *runtimeContractHarness) ensureUser(t *testing.T, email string, preferredLanguage string, timeZone string) ensureByEmailResponse { t.Helper() response := h.postJSON(t, "/api/v1/internal/users/ensure-by-email", map[string]any{ "email": email, "registration_context": map[string]string{ "preferred_language": preferredLanguage, "time_zone": timeZone, }, }) var body ensureByEmailResponse requireResponseJSON(t, response, http.StatusOK, &body) return body } func (h *runtimeContractHarness) getMyAccount(t *testing.T, userID string) accountResponse { t.Helper() response := h.get(t, "/api/v1/internal/users/"+userID+"/account") var body accountResponse requireResponseJSON(t, response, http.StatusOK, &body) return body } func (h *runtimeContractHarness) currentEntitlementStartsAt(t *testing.T, userID string) time.Time { t.Helper() return h.getMyAccount(t, userID).Account.Entitlement.StartsAt } func (h *runtimeContractHarness) updateSettingsRaw(t *testing.T, userID string, body string) httpResponse { t.Helper() return h.postRawJSON(t, "/api/v1/internal/users/"+userID+"/settings", body) } func (h *runtimeContractHarness) getEligibility(t *testing.T, userID string) eligibilityResponse { t.Helper() response := h.get(t, "/api/v1/internal/users/"+userID+"/eligibility") var body eligibilityResponse requireResponseJSON(t, response, http.StatusOK, &body) return body } func (h *runtimeContractHarness) syncDeclaredCountry(t *testing.T, userID string, country string) declaredCountrySyncResponse { t.Helper() response := h.postJSON(t, "/api/v1/internal/users/"+userID+"/declared-country/sync", map[string]string{ "declared_country": country, }) var body declaredCountrySyncResponse requireResponseJSON(t, response, http.StatusOK, &body) return body } func (h *runtimeContractHarness) lookupUserByEmail(t *testing.T, email string) userLookupResponse { t.Helper() response := h.postJSON(t, "/api/v1/internal/user-lookups/by-email", map[string]string{ "email": email, }) var body userLookupResponse requireResponseJSON(t, response, http.StatusOK, &body) return body } func (h *runtimeContractHarness) grantPaidEntitlement(t *testing.T, userID string, startsAt time.Time, endsAt time.Time) { t.Helper() response := h.postJSON(t, "/api/v1/internal/users/"+userID+"/entitlements/grant", map[string]any{ "plan_code": "paid_monthly", "source": "admin", "reason_code": "manual_grant", "actor": map[string]string{ "type": "admin", "id": "admin-1", }, "starts_at": startsAt.UTC().Format(time.RFC3339Nano), "ends_at": endsAt.UTC().Format(time.RFC3339Nano), }) var body entitlementCommandResponse requireResponseJSON(t, response, http.StatusOK, &body) } func (h *runtimeContractHarness) applySanction(t *testing.T, userID string, sanctionCode string, scope string, appliedAt time.Time) { t.Helper() response := h.postJSON(t, "/api/v1/internal/users/"+userID+"/sanctions/apply", map[string]any{ "sanction_code": sanctionCode, "scope": scope, "reason_code": "manual_block", "actor": map[string]string{ "type": "admin", "id": "admin-1", }, "applied_at": appliedAt.UTC().Format(time.RFC3339Nano), }) var body sanctionCommandResponse requireResponseJSON(t, response, http.StatusOK, &body) } func (h *runtimeContractHarness) setLimit(t *testing.T, userID string, limitCode string, value int, appliedAt time.Time) { t.Helper() response := h.postJSON(t, "/api/v1/internal/users/"+userID+"/limits/set", map[string]any{ "limit_code": limitCode, "value": value, "reason_code": "manual_override", "actor": map[string]string{ "type": "admin", "id": "admin-1", }, "applied_at": appliedAt.UTC().Format(time.RFC3339Nano), }) var body limitCommandResponse requireResponseJSON(t, response, http.StatusOK, &body) } func (h *runtimeContractHarness) listUsers(t *testing.T, rawQuery string) httpResponse { t.Helper() path := "/api/v1/internal/users" if rawQuery != "" { path += "?" + rawQuery } return h.get(t, path) } func (h *runtimeContractHarness) get(t *testing.T, path string) httpResponse { t.Helper() request, err := http.NewRequest(http.MethodGet, h.baseURL+path, nil) require.NoError(t, err) response, err := h.client.Do(request) require.NoError(t, err) defer response.Body.Close() body, err := io.ReadAll(response.Body) require.NoError(t, err) return httpResponse{ StatusCode: response.StatusCode, Body: string(body), Header: response.Header.Clone(), } } func (h *runtimeContractHarness) postJSON(t *testing.T, path string, body any) httpResponse { t.Helper() payload, err := json.Marshal(body) require.NoError(t, err) return h.postRawJSON(t, path, string(payload)) } func (h *runtimeContractHarness) postRawJSON(t *testing.T, path string, body string) httpResponse { t.Helper() request, err := http.NewRequest(http.MethodPost, h.baseURL+path, bytes.NewBufferString(body)) require.NoError(t, err) request.Header.Set("Content-Type", "application/json") response, err := h.client.Do(request) require.NoError(t, err) defer response.Body.Close() responseBody, err := io.ReadAll(response.Body) require.NoError(t, err) return httpResponse{ StatusCode: response.StatusCode, Body: string(responseBody), Header: response.Header.Clone(), } } func TestRuntimeContractGetMyAccountReturnsAggregateAndDeclaredCountryStaysReadOnly(t *testing.T) { t.Parallel() h := newRuntimeContractHarness(t) created := h.ensureUser(t, "pilot@example.com", "en", "Europe/Kaliningrad") require.Equal(t, "created", created.Outcome) now := time.Now().UTC().Truncate(time.Second) h.grantPaidEntitlement(t, created.UserID, h.currentEntitlementStartsAt(t, created.UserID), now.Add(48*time.Hour)) h.applySanction(t, created.UserID, "login_block", "auth", now.Add(-30*time.Minute)) h.setLimit(t, created.UserID, "max_owned_private_games", 7, now.Add(-20*time.Minute)) syncResult := h.syncDeclaredCountry(t, created.UserID, "DE") account := h.getMyAccount(t, created.UserID) require.Equal(t, created.UserID, account.Account.UserID) require.Equal(t, "pilot@example.com", account.Account.Email) require.Equal(t, "en", account.Account.PreferredLanguage) require.Equal(t, "Europe/Kaliningrad", account.Account.TimeZone) require.Equal(t, "DE", account.Account.DeclaredCountry) require.Equal(t, syncResult.UpdatedAt, account.Account.UpdatedAt) require.Equal(t, "paid_monthly", account.Account.Entitlement.PlanCode) require.True(t, account.Account.Entitlement.IsPaid) require.Len(t, account.Account.ActiveSanctions, 1) require.Equal(t, "login_block", account.Account.ActiveSanctions[0].SanctionCode) require.Len(t, account.Account.ActiveLimits, 1) require.Equal(t, "max_owned_private_games", account.Account.ActiveLimits[0].LimitCode) require.Equal(t, 7, account.Account.ActiveLimits[0].Value) response := h.updateSettingsRaw(t, created.UserID, `{"preferred_language":"en","time_zone":"UTC","declared_country":"FR"}`) requireJSONBody(t, response, http.StatusBadRequest, `{"error":{"code":"invalid_request","message":"request body contains unknown field \"declared_country\""}}`) } func TestRuntimeContractEligibilitySnapshotCoversUnknownFreeAndPaidUsers(t *testing.T) { t.Parallel() h := newRuntimeContractHarness(t) unknown := h.getEligibility(t, "user-missing") require.False(t, unknown.Exists) require.Equal(t, "user-missing", unknown.UserID) require.Nil(t, unknown.Entitlement) require.Empty(t, unknown.ActiveSanctions) require.Empty(t, unknown.EffectiveLimits) require.Equal(t, eligibilityMarkers{}, unknown.Markers) freeUser := h.ensureUser(t, "free@example.com", "en", "UTC") require.Equal(t, "created", freeUser.Outcome) free := h.getEligibility(t, freeUser.UserID) require.True(t, free.Exists) require.NotNil(t, free.Entitlement) require.Equal(t, "free", free.Entitlement.PlanCode) require.False(t, free.Entitlement.IsPaid) require.Equal(t, eligibilityMarkers{ CanLogin: true, CanCreatePrivateGame: false, CanManagePrivateGame: false, CanJoinGame: true, CanUpdateProfile: true, }, free.Markers) require.Equal(t, []effectiveLimitView{ {LimitCode: "max_pending_public_applications", Value: 3}, {LimitCode: "max_active_game_memberships", Value: 3}, }, free.EffectiveLimits) paidUser := h.ensureUser(t, "paid@example.com", "en", "Europe/Paris") require.Equal(t, "created", paidUser.Outcome) now := time.Now().UTC().Truncate(time.Second) h.grantPaidEntitlement(t, paidUser.UserID, h.currentEntitlementStartsAt(t, paidUser.UserID), now.Add(72*time.Hour)) h.applySanction(t, paidUser.UserID, "private_game_manage_block", "lobby", now.Add(-30*time.Minute)) h.setLimit(t, paidUser.UserID, "max_pending_public_applications", 17, now.Add(-20*time.Minute)) paid := h.getEligibility(t, paidUser.UserID) require.True(t, paid.Exists) require.NotNil(t, paid.Entitlement) require.Equal(t, "paid_monthly", paid.Entitlement.PlanCode) require.True(t, paid.Entitlement.IsPaid) require.Len(t, paid.ActiveSanctions, 1) require.Equal(t, "private_game_manage_block", paid.ActiveSanctions[0].SanctionCode) require.Equal(t, eligibilityMarkers{ CanLogin: true, CanCreatePrivateGame: true, CanManagePrivateGame: false, CanJoinGame: true, CanUpdateProfile: true, }, paid.Markers) require.Equal(t, []effectiveLimitView{ {LimitCode: "max_owned_private_games", Value: 3}, {LimitCode: "max_pending_public_applications", Value: 17}, {LimitCode: "max_active_game_memberships", Value: 10}, }, paid.EffectiveLimits) } func TestRuntimeContractGeoSyncOnlyMutatesCurrentDeclaredCountry(t *testing.T) { t.Parallel() h := newRuntimeContractHarness(t) created := h.ensureUser(t, "geo@example.com", "en", "Europe/Berlin") require.Equal(t, "created", created.Outcome) before := h.lookupUserByEmail(t, "geo@example.com") require.Empty(t, before.User.DeclaredCountry) first := h.syncDeclaredCountry(t, created.UserID, "DE") after := h.lookupUserByEmail(t, "geo@example.com") require.Equal(t, before.User.UserID, after.User.UserID) require.Equal(t, before.User.Email, after.User.Email) require.Equal(t, before.User.RaceName, after.User.RaceName) require.Equal(t, before.User.PreferredLanguage, after.User.PreferredLanguage) require.Equal(t, before.User.TimeZone, after.User.TimeZone) require.Equal(t, before.User.Entitlement, after.User.Entitlement) require.Equal(t, before.User.ActiveSanctions, after.User.ActiveSanctions) require.Equal(t, before.User.ActiveLimits, after.User.ActiveLimits) require.Equal(t, "DE", after.User.DeclaredCountry) require.Equal(t, first.UpdatedAt, after.User.UpdatedAt) second := h.syncDeclaredCountry(t, created.UserID, "DE") require.Equal(t, first.UpdatedAt, second.UpdatedAt) repeated := h.lookupUserByEmail(t, "geo@example.com") require.Equal(t, after.User, repeated.User) } func TestRuntimeContractAdminListingPreservesOrderingFiltersAndPageTokenBinding(t *testing.T) { t.Parallel() h := newRuntimeContractHarness(t) filtered := h.ensureUser(t, "filter@example.com", "en", "UTC") require.Equal(t, "created", filtered.Outcome) time.Sleep(10 * time.Millisecond) latest := h.ensureUser(t, "latest@example.com", "en", "UTC") require.Equal(t, "created", latest.Outcome) now := time.Now().UTC().Truncate(time.Second) h.grantPaidEntitlement(t, filtered.UserID, h.currentEntitlementStartsAt(t, filtered.UserID), now.Add(48*time.Hour)) h.syncDeclaredCountry(t, filtered.UserID, "DE") h.applySanction(t, filtered.UserID, "login_block", "auth", now.Add(-30*time.Minute)) h.setLimit(t, filtered.UserID, "max_owned_private_games", 5, now.Add(-20*time.Minute)) firstPageResponse := h.listUsers(t, "page_size=1") var firstPage userListResponse requireResponseJSON(t, firstPageResponse, http.StatusOK, &firstPage) require.Len(t, firstPage.Items, 1) require.Equal(t, latest.UserID, firstPage.Items[0].UserID) require.NotEmpty(t, firstPage.NextPageToken) mismatchResponse := h.listUsers(t, "page_size=1&page_token="+firstPage.NextPageToken+"&paid_state=paid") requireJSONBody(t, mismatchResponse, http.StatusBadRequest, `{"error":{"code":"invalid_request","message":"page_token is invalid or does not match current filters"}}`) filteredResponse := h.listUsers( t, "paid_state=paid"+ "&paid_expires_after="+now.Add(time.Hour).Format(time.RFC3339)+ "&paid_expires_before="+now.Add(72*time.Hour).Format(time.RFC3339)+ "&declared_country=DE"+ "&sanction_code=login_block"+ "&limit_code=max_owned_private_games"+ "&can_login=false"+ "&can_create_private_game=false"+ "&can_join_game=false", ) var filteredBody userListResponse requireResponseJSON(t, filteredResponse, http.StatusOK, &filteredBody) require.Len(t, filteredBody.Items, 1) require.Equal(t, filtered.UserID, filteredBody.Items[0].UserID) require.Equal(t, "DE", filteredBody.Items[0].DeclaredCountry) require.Equal(t, "paid_monthly", filteredBody.Items[0].Entitlement.PlanCode) } type httpResponse struct { StatusCode int Body string Header http.Header } type errorEnvelope struct { Error struct { Code string `json:"code"` Message string `json:"message"` } `json:"error"` } type ensureByEmailResponse struct { Outcome string `json:"outcome"` UserID string `json:"user_id,omitempty"` } type accountResponse struct { Account accountView `json:"account"` } type userLookupResponse struct { User accountView `json:"user"` } type userListResponse struct { Items []accountView `json:"items"` NextPageToken string `json:"next_page_token,omitempty"` } type accountView struct { UserID string `json:"user_id"` Email string `json:"email"` RaceName string `json:"race_name"` PreferredLanguage string `json:"preferred_language"` TimeZone string `json:"time_zone"` DeclaredCountry string `json:"declared_country,omitempty"` Entitlement entitlementSnapshotView `json:"entitlement"` ActiveSanctions []activeSanctionView `json:"active_sanctions"` ActiveLimits []activeLimitView `json:"active_limits"` CreatedAt time.Time `json:"created_at"` UpdatedAt time.Time `json:"updated_at"` } type entitlementSnapshotView struct { PlanCode string `json:"plan_code"` IsPaid bool `json:"is_paid"` Source string `json:"source"` Actor actorRefView `json:"actor"` ReasonCode string `json:"reason_code"` StartsAt time.Time `json:"starts_at"` EndsAt *time.Time `json:"ends_at,omitempty"` UpdatedAt time.Time `json:"updated_at"` } type activeSanctionView struct { SanctionCode string `json:"sanction_code"` Scope string `json:"scope"` ReasonCode string `json:"reason_code"` Actor actorRefView `json:"actor"` AppliedAt time.Time `json:"applied_at"` ExpiresAt *time.Time `json:"expires_at,omitempty"` } type activeLimitView struct { LimitCode string `json:"limit_code"` Value int `json:"value"` ReasonCode string `json:"reason_code"` Actor actorRefView `json:"actor"` AppliedAt time.Time `json:"applied_at"` ExpiresAt *time.Time `json:"expires_at,omitempty"` } type actorRefView struct { Type string `json:"type"` ID string `json:"id,omitempty"` } type eligibilityResponse struct { Exists bool `json:"exists"` UserID string `json:"user_id"` Entitlement *entitlementSnapshotView `json:"entitlement,omitempty"` ActiveSanctions []activeSanctionView `json:"active_sanctions"` EffectiveLimits []effectiveLimitView `json:"effective_limits"` Markers eligibilityMarkers `json:"markers"` } type effectiveLimitView struct { LimitCode string `json:"limit_code"` Value int `json:"value"` } type eligibilityMarkers struct { CanLogin bool `json:"can_login"` CanCreatePrivateGame bool `json:"can_create_private_game"` CanManagePrivateGame bool `json:"can_manage_private_game"` CanJoinGame bool `json:"can_join_game"` CanUpdateProfile bool `json:"can_update_profile"` } type declaredCountrySyncResponse struct { UserID string `json:"user_id"` DeclaredCountry string `json:"declared_country"` UpdatedAt time.Time `json:"updated_at"` } type entitlementCommandResponse struct { UserID string `json:"user_id"` Entitlement entitlementSnapshotView `json:"entitlement"` } type sanctionCommandResponse struct { UserID string `json:"user_id"` ActiveSanctions []activeSanctionView `json:"active_sanctions"` } type limitCommandResponse struct { UserID string `json:"user_id"` ActiveLimits []activeLimitView `json:"active_limits"` } func requireResponseJSON(t *testing.T, response httpResponse, wantStatus int, target any) { t.Helper() require.Equal(t, wantStatus, response.StatusCode, "response body: %s", response.Body) require.NoError(t, decodeStrictJSONPayload([]byte(response.Body), target)) } func requireJSONBody(t *testing.T, response httpResponse, wantStatus int, wantBody string) { t.Helper() require.Equal(t, wantStatus, response.StatusCode, "response body: %s", response.Body) require.JSONEq(t, wantBody, response.Body) } func decodeStrictJSONPayload(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 freeLoopbackAddress(t *testing.T) string { t.Helper() listener, err := net.Listen("tcp", "127.0.0.1:0") require.NoError(t, err) defer listener.Close() return listener.Addr().String() } func (view entitlementSnapshotView) Equal(other entitlementSnapshotView) bool { return view.PlanCode == other.PlanCode && view.IsPaid == other.IsPaid && view.Source == other.Source && view.ReasonCode == other.ReasonCode && view.StartsAt.Equal(other.StartsAt) && optionalTimeEqual(view.EndsAt, other.EndsAt) && view.UpdatedAt.Equal(other.UpdatedAt) } func optionalTimeEqual(left *time.Time, right *time.Time) bool { switch { case left == nil && right == nil: return true case left == nil || right == nil: return false default: return left.Equal(*right) } } func TestEntitlementSnapshotViewEqual(t *testing.T) { t.Parallel() now := time.Now().UTC() next := now.Add(time.Hour) require.True(t, entitlementSnapshotView{ PlanCode: "free", IsPaid: false, Source: "auth_registration", ReasonCode: "initial_free_entitlement", StartsAt: now, UpdatedAt: now, }.Equal(entitlementSnapshotView{ PlanCode: "free", IsPaid: false, Source: "auth_registration", ReasonCode: "initial_free_entitlement", StartsAt: now, UpdatedAt: now, })) require.False(t, entitlementSnapshotView{ PlanCode: "paid_monthly", IsPaid: true, Source: "admin", ReasonCode: "manual_grant", StartsAt: now, EndsAt: &next, UpdatedAt: now, }.Equal(entitlementSnapshotView{ PlanCode: "paid_monthly", IsPaid: true, Source: "admin", ReasonCode: "manual_grant", StartsAt: now, UpdatedAt: now, })) } func TestEligibilityUnknownMarkersZeroValueMatchesContract(t *testing.T) { t.Parallel() require.Equal(t, eligibilityMarkers{}, eligibilityMarkers{}) require.False(t, strings.HasPrefix("", "user-")) }