Files
galaxy-game/user/runtime_contract_test.go
T
2026-04-26 20:34:39 +02:00

924 lines
30 KiB
Go

package user
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"log/slog"
"net"
"net/http"
"net/url"
"strings"
"testing"
"time"
"galaxy/postgres"
"galaxy/user/internal/app"
"galaxy/user/internal/config"
"github.com/alicebob/miniredis/v2"
"github.com/stretchr/testify/require"
testcontainers "github.com/testcontainers/testcontainers-go"
tcpostgres "github.com/testcontainers/testcontainers-go/modules/postgres"
"github.com/testcontainers/testcontainers-go/wait"
)
type runtimeContractHarness struct {
baseURL string
client *http.Client
runtime *app.Runtime
cancel context.CancelFunc
runErr chan error
redisServer *miniredis.Miniredis
}
func newRuntimeContractHarness(t *testing.T) *runtimeContractHarness {
t.Helper()
redisServer := miniredis.RunT(t)
redisServer.RequireAuth("integration")
pgDSN := startPostgresForContractTest(t)
cfg := config.DefaultConfig()
cfg.Redis.Conn.MasterAddr = redisServer.Addr()
cfg.Redis.Conn.Password = "integration"
cfg.Postgres.Conn.PrimaryDSN = pgDSN
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,
redisServer: redisServer,
}
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) deleteUser(t *testing.T, userID string, reasonCode string) httpResponse {
t.Helper()
return h.postJSON(t, "/api/v1/internal/users/"+userID+"/delete", map[string]any{
"reason_code": reasonCode,
"actor": map[string]string{
"type": "admin",
"id": "admin-1",
},
})
}
func (h *runtimeContractHarness) lifecycleStreamEntries(t *testing.T) []map[string]string {
t.Helper()
stream, err := h.redisServer.Stream("user:lifecycle_events")
require.NoError(t, err)
entries := make([]map[string]string, 0, len(stream))
for _, entry := range stream {
require.Equal(t, 0, len(entry.Values)%2, "stream entry values must come in key/value pairs")
values := make(map[string]string, len(entry.Values)/2)
for index := 0; index < len(entry.Values); index += 2 {
values[entry.Values[index]] = entry.Values[index+1]
}
entries = append(entries, values)
}
return entries
}
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},
{LimitCode: "max_registered_race_names", Value: 1},
}, 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},
{LimitCode: "max_registered_race_names", Value: 2},
}, 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.UserName, after.User.UserName)
require.Equal(t, before.User.DisplayName, after.User.DisplayName)
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 TestRuntimeContractPermanentBlockCollapsesEligibilityMarkers(t *testing.T) {
t.Parallel()
h := newRuntimeContractHarness(t)
created := h.ensureUser(t, "blocked@example.com", "en", "UTC")
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(72*time.Hour))
h.applySanction(t, created.UserID, "permanent_block", "platform", now.Add(-5*time.Minute))
eligibility := h.getEligibility(t, created.UserID)
require.True(t, eligibility.Exists)
require.Equal(t, eligibilityMarkers{}, eligibility.Markers,
"every can_* marker must be false under permanent_block")
var permanentBlockSeen bool
for _, sanction := range eligibility.ActiveSanctions {
if sanction.SanctionCode == "permanent_block" {
permanentBlockSeen = true
}
}
require.True(t, permanentBlockSeen,
"permanent_block must surface in the lobby eligibility snapshot")
}
func TestRuntimeContractPermanentBlockBlocksSelfService(t *testing.T) {
t.Parallel()
h := newRuntimeContractHarness(t)
created := h.ensureUser(t, "self-blocked@example.com", "en", "UTC")
require.Equal(t, "created", created.Outcome)
now := time.Now().UTC().Truncate(time.Second)
h.applySanction(t, created.UserID, "permanent_block", "platform", now.Add(-time.Minute))
readResponse := h.get(t, "/api/v1/internal/users/"+created.UserID+"/account")
requireJSONBody(t, readResponse, http.StatusConflict,
`{"error":{"code":"conflict","message":"request conflicts with current state"}}`)
profileResponse := h.postJSON(t, "/api/v1/internal/users/"+created.UserID+"/profile", map[string]string{
"display_name": "Nova",
})
requireJSONBody(t, profileResponse, http.StatusConflict,
`{"error":{"code":"conflict","message":"request conflicts with current state"}}`)
settingsResponse := h.postJSON(t, "/api/v1/internal/users/"+created.UserID+"/settings", map[string]string{
"preferred_language": "en",
"time_zone": "UTC",
})
requireJSONBody(t, settingsResponse, http.StatusConflict,
`{"error":{"code":"conflict","message":"request conflicts with current state"}}`)
}
func TestRuntimeContractPermanentBlockEmitsLifecycleEvent(t *testing.T) {
t.Parallel()
h := newRuntimeContractHarness(t)
created := h.ensureUser(t, "lifecycle-block@example.com", "en", "UTC")
require.Equal(t, "created", created.Outcome)
now := time.Now().UTC().Truncate(time.Second)
h.applySanction(t, created.UserID, "permanent_block", "platform", now.Add(-time.Minute))
entries := h.lifecycleStreamEntries(t)
require.Len(t, entries, 1)
require.Equal(t, "user.lifecycle.permanent_blocked", entries[0]["event_type"])
require.Equal(t, created.UserID, entries[0]["user_id"])
require.Equal(t, "admin_internal_api", entries[0]["source"])
require.Equal(t, "admin", entries[0]["actor_type"])
require.Equal(t, "admin-1", entries[0]["actor_id"])
require.Equal(t, "manual_block", entries[0]["reason_code"])
}
func TestRuntimeContractDeleteUserIsIdempotentAndEmitsLifecycleEvent(t *testing.T) {
t.Parallel()
h := newRuntimeContractHarness(t)
created := h.ensureUser(t, "delete@example.com", "en", "UTC")
require.Equal(t, "created", created.Outcome)
firstResponse := h.deleteUser(t, created.UserID, "user_right_to_be_forgotten")
require.Equal(t, http.StatusOK, firstResponse.StatusCode, "response body: %s", firstResponse.Body)
var firstBody struct {
UserID string `json:"user_id"`
DeletedAt time.Time `json:"deleted_at"`
}
require.NoError(t, decodeStrictJSONPayload([]byte(firstResponse.Body), &firstBody))
require.Equal(t, created.UserID, firstBody.UserID)
require.False(t, firstBody.DeletedAt.IsZero())
entries := h.lifecycleStreamEntries(t)
require.Len(t, entries, 1)
require.Equal(t, "user.lifecycle.deleted", entries[0]["event_type"])
require.Equal(t, created.UserID, entries[0]["user_id"])
secondResponse := h.deleteUser(t, created.UserID, "user_right_to_be_forgotten")
requireJSONBody(t, secondResponse, http.StatusNotFound,
`{"error":{"code":"subject_not_found","message":"subject not found"}}`)
entriesAfterSecond := h.lifecycleStreamEntries(t)
require.Len(t, entriesAfterSecond, 1,
"second DeleteUser call must not re-emit a lifecycle event")
eligibility := h.getEligibility(t, created.UserID)
require.False(t, eligibility.Exists)
accountResponse := h.get(t, "/api/v1/internal/users/"+created.UserID+"/account")
requireJSONBody(t, accountResponse, http.StatusNotFound,
`{"error":{"code":"subject_not_found","message":"subject not found"}}`)
lookupResponse := h.lookupUserByEmailRaw(t, "delete@example.com")
requireJSONBody(t, lookupResponse, http.StatusNotFound,
`{"error":{"code":"subject_not_found","message":"subject not found"}}`)
}
func (h *runtimeContractHarness) lookupUserByEmailRaw(t *testing.T, email string) httpResponse {
t.Helper()
return h.postJSON(t, "/api/v1/internal/user-lookups/by-email", map[string]string{
"email": email,
})
}
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"`
UserName string `json:"user_name"`
DisplayName string `json:"display_name,omitempty"`
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-"))
}
// startPostgresForContractTest boots one isolated PostgreSQL container,
// provisions the user schema with the userservice role, and returns a DSN
// pinned to search_path=user. The test is skipped (not failed) when a
// container cannot be started — typically because Docker is unavailable in
// the dev environment.
func startPostgresForContractTest(t *testing.T) string {
t.Helper()
ctx := context.Background()
container, err := tcpostgres.Run(ctx,
"postgres:16-alpine",
tcpostgres.WithDatabase("galaxy_user"),
tcpostgres.WithUsername("galaxy"),
tcpostgres.WithPassword("galaxy"),
testcontainers.WithWaitStrategy(
wait.ForLog("database system is ready to accept connections").
WithOccurrence(2).
WithStartupTimeout(60*time.Second),
),
)
if err != nil {
t.Skipf("postgres container start failed (Docker likely unavailable): %v", err)
}
t.Cleanup(func() {
if err := testcontainers.TerminateContainer(container); err != nil {
t.Errorf("terminate postgres container: %v", err)
}
})
baseDSN, err := container.ConnectionString(ctx, "sslmode=disable")
require.NoError(t, err)
cfg := postgres.DefaultConfig()
cfg.PrimaryDSN = baseDSN
cfg.OperationTimeout = 5 * time.Second
db, err := postgres.OpenPrimary(ctx, cfg)
require.NoError(t, err)
defer func() { _ = db.Close() }()
for _, statement := range []string{
`CREATE ROLE userservice LOGIN PASSWORD 'userservice'`,
`CREATE SCHEMA IF NOT EXISTS "user" AUTHORIZATION userservice`,
`GRANT USAGE ON SCHEMA "user" TO userservice`,
} {
if _, err := db.ExecContext(ctx, statement); err != nil {
require.NoError(t, err, "provision postgres role/schema: %s", statement)
}
}
parsed, err := url.Parse(baseDSN)
require.NoError(t, err)
values := url.Values{}
values.Set("search_path", "user")
values.Set("sslmode", "disable")
scoped := url.URL{
Scheme: parsed.Scheme,
User: url.UserPassword("userservice", "userservice"),
Host: parsed.Host,
Path: parsed.Path,
RawQuery: values.Encode(),
}
return scoped.String()
}
// errSentinel is a small unused alias kept to silence imports above when
// non-default builds drop testcontainers references.
var errSentinel = fmt.Errorf("contract test sentinel")