feat: game lobby service

This commit is contained in:
Ilia Denisov
2026-04-25 23:20:55 +02:00
committed by GitHub
parent 32dc29359a
commit 48b0056b49
336 changed files with 57074 additions and 1418 deletions
+170 -10
View File
@@ -24,9 +24,10 @@ type runtimeContractHarness struct {
baseURL string
client *http.Client
runtime *app.Runtime
cancel context.CancelFunc
runErr chan error
runtime *app.Runtime
cancel context.CancelFunc
runErr chan error
redisServer *miniredis.Miniredis
}
func newRuntimeContractHarness(t *testing.T) *runtimeContractHarness {
@@ -60,11 +61,12 @@ func newRuntimeContractHarness(t *testing.T) *runtimeContractHarness {
}
harness := &runtimeContractHarness{
baseURL: "http://" + cfg.InternalHTTP.Addr,
client: client,
runtime: runtime,
cancel: cancel,
runErr: runErr,
baseURL: "http://" + cfg.InternalHTTP.Addr,
client: client,
runtime: runtime,
cancel: cancel,
runErr: runErr,
redisServer: redisServer,
}
harness.waitUntilReady(t)
@@ -222,6 +224,35 @@ func (h *runtimeContractHarness) setLimit(t *testing.T, userID string, limitCode
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()
@@ -344,6 +375,7 @@ func TestRuntimeContractEligibilitySnapshotCoversUnknownFreeAndPaidUsers(t *test
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")
@@ -371,6 +403,7 @@ func TestRuntimeContractEligibilitySnapshotCoversUnknownFreeAndPaidUsers(t *test
{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)
}
@@ -388,7 +421,8 @@ func TestRuntimeContractGeoSyncOnlyMutatesCurrentDeclaredCountry(t *testing.T) {
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.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)
@@ -404,6 +438,131 @@ func TestRuntimeContractGeoSyncOnlyMutatesCurrentDeclaredCountry(t *testing.T) {
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()
@@ -487,7 +646,8 @@ type userListResponse struct {
type accountView struct {
UserID string `json:"user_id"`
Email string `json:"email"`
RaceName string `json:"race_name"`
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"`