Files
galaxy-game/user/runtime_contract_test.go
T
2026-04-10 19:05:02 +02:00

684 lines
22 KiB
Go

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-"))
}