feat: game lobby service
This commit is contained in:
+170
-10
@@ -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"`
|
||||
|
||||
Reference in New Issue
Block a user