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
+50 -26
View File
@@ -8,11 +8,11 @@ import (
"testing"
"time"
"galaxy/user/internal/domain/account"
"galaxy/user/internal/domain/common"
"galaxy/user/internal/domain/entitlement"
"galaxy/user/internal/domain/policy"
"galaxy/user/internal/ports"
"galaxy/user/internal/service/accountdeletion"
"galaxy/user/internal/service/adminusers"
"galaxy/user/internal/service/authdirectory"
"galaxy/user/internal/service/entitlementsvc"
@@ -56,9 +56,9 @@ func TestAuthFacingHandlersSuccessCases(t *testing.T) {
}),
UpdateMyProfile: updateMyProfileFunc(func(_ context.Context, input selfservice.UpdateMyProfileInput) (selfservice.UpdateMyProfileResult, error) {
require.Equal(t, "user-123", input.UserID)
require.Equal(t, "Nova Prime", input.RaceName)
require.Equal(t, "NovaPrime", input.DisplayName)
accountView := sampleAccountView()
accountView.RaceName = input.RaceName
accountView.DisplayName = input.DisplayName
return selfservice.UpdateMyProfileResult{Account: accountView}, nil
}),
UpdateMySettings: updateMySettingsFunc(func(_ context.Context, input selfservice.UpdateMySettingsInput) (selfservice.UpdateMySettingsResult, error) {
@@ -211,6 +211,16 @@ func TestAuthFacingHandlersSuccessCases(t *testing.T) {
require.Equal(t, "manual_remove", input.ReasonCode)
return policysvc.LimitCommandResult{UserID: "user-123", ActiveLimits: []policysvc.ActiveLimitView{}}, nil
}),
DeleteUser: deleteUserFunc(func(_ context.Context, input accountdeletion.Input) (accountdeletion.Result, error) {
require.Equal(t, "user-123", input.UserID)
require.Equal(t, "user_right_to_be_forgotten", input.ReasonCode)
require.Equal(t, "admin", input.Actor.Type)
require.Equal(t, "admin-1", input.Actor.ID)
return accountdeletion.Result{
UserID: "user-123",
DeletedAt: time.Date(2026, time.April, 24, 12, 0, 0, 0, time.UTC),
}, nil
}),
})
tests := []struct {
@@ -265,15 +275,15 @@ func TestAuthFacingHandlersSuccessCases(t *testing.T) {
method: http.MethodGet,
path: "/api/v1/internal/users/user-123/account",
wantStatus: http.StatusOK,
wantBody: `{"account":{"user_id":"user-123","email":"pilot@example.com","race_name":"Pilot Nova","preferred_language":"en","time_zone":"Europe/Kaliningrad","declared_country":"DE","entitlement":{"plan_code":"free","is_paid":false,"source":"auth_registration","actor":{"type":"service","id":"user-service"},"reason_code":"initial_free_entitlement","starts_at":"2026-04-09T10:00:00Z","updated_at":"2026-04-09T10:00:00Z"},"active_sanctions":[],"active_limits":[],"created_at":"2026-04-09T10:00:00Z","updated_at":"2026-04-09T10:00:00Z"}}`,
wantBody: `{"account":{"user_id":"user-123","email":"pilot@example.com","user_name":"player-abcdefgh","preferred_language":"en","time_zone":"Europe/Kaliningrad","declared_country":"DE","entitlement":{"plan_code":"free","is_paid":false,"source":"auth_registration","actor":{"type":"service","id":"user-service"},"reason_code":"initial_free_entitlement","starts_at":"2026-04-09T10:00:00Z","updated_at":"2026-04-09T10:00:00Z"},"active_sanctions":[],"active_limits":[],"created_at":"2026-04-09T10:00:00Z","updated_at":"2026-04-09T10:00:00Z"}}`,
},
{
name: "update my profile",
method: http.MethodPost,
path: "/api/v1/internal/users/user-123/profile",
body: `{"race_name":"Nova Prime"}`,
body: `{"display_name":"NovaPrime"}`,
wantStatus: http.StatusOK,
wantBody: `{"account":{"user_id":"user-123","email":"pilot@example.com","race_name":"Nova Prime","preferred_language":"en","time_zone":"Europe/Kaliningrad","declared_country":"DE","entitlement":{"plan_code":"free","is_paid":false,"source":"auth_registration","actor":{"type":"service","id":"user-service"},"reason_code":"initial_free_entitlement","starts_at":"2026-04-09T10:00:00Z","updated_at":"2026-04-09T10:00:00Z"},"active_sanctions":[],"active_limits":[],"created_at":"2026-04-09T10:00:00Z","updated_at":"2026-04-09T10:00:00Z"}}`,
wantBody: `{"account":{"user_id":"user-123","email":"pilot@example.com","user_name":"player-abcdefgh","display_name":"NovaPrime","preferred_language":"en","time_zone":"Europe/Kaliningrad","declared_country":"DE","entitlement":{"plan_code":"free","is_paid":false,"source":"auth_registration","actor":{"type":"service","id":"user-service"},"reason_code":"initial_free_entitlement","starts_at":"2026-04-09T10:00:00Z","updated_at":"2026-04-09T10:00:00Z"},"active_sanctions":[],"active_limits":[],"created_at":"2026-04-09T10:00:00Z","updated_at":"2026-04-09T10:00:00Z"}}`,
},
{
name: "update my settings",
@@ -281,14 +291,14 @@ func TestAuthFacingHandlersSuccessCases(t *testing.T) {
path: "/api/v1/internal/users/user-123/settings",
body: `{"preferred_language":"en-US","time_zone":"UTC"}`,
wantStatus: http.StatusOK,
wantBody: `{"account":{"user_id":"user-123","email":"pilot@example.com","race_name":"Pilot Nova","preferred_language":"en-US","time_zone":"UTC","declared_country":"DE","entitlement":{"plan_code":"free","is_paid":false,"source":"auth_registration","actor":{"type":"service","id":"user-service"},"reason_code":"initial_free_entitlement","starts_at":"2026-04-09T10:00:00Z","updated_at":"2026-04-09T10:00:00Z"},"active_sanctions":[],"active_limits":[],"created_at":"2026-04-09T10:00:00Z","updated_at":"2026-04-09T10:00:00Z"}}`,
wantBody: `{"account":{"user_id":"user-123","email":"pilot@example.com","user_name":"player-abcdefgh","preferred_language":"en-US","time_zone":"UTC","declared_country":"DE","entitlement":{"plan_code":"free","is_paid":false,"source":"auth_registration","actor":{"type":"service","id":"user-service"},"reason_code":"initial_free_entitlement","starts_at":"2026-04-09T10:00:00Z","updated_at":"2026-04-09T10:00:00Z"},"active_sanctions":[],"active_limits":[],"created_at":"2026-04-09T10:00:00Z","updated_at":"2026-04-09T10:00:00Z"}}`,
},
{
name: "get user eligibility",
method: http.MethodGet,
path: "/api/v1/internal/users/user-123/eligibility",
wantStatus: http.StatusOK,
wantBody: `{"exists":true,"user_id":"user-123","entitlement":{"plan_code":"paid_monthly","is_paid":true,"source":"billing","actor":{"type":"billing","id":"invoice-1"},"reason_code":"renewal","starts_at":"2026-04-09T10:00:00Z","ends_at":"2026-05-09T10:00:00Z","updated_at":"2026-04-09T10:00:00Z"},"active_sanctions":[{"sanction_code":"private_game_create_block","scope":"lobby","reason_code":"manual_block","actor":{"type":"admin","id":"admin-1"},"applied_at":"2026-04-09T10:00:00Z","expires_at":"2026-05-09T10:00:00Z"}],"effective_limits":[{"limit_code":"max_owned_private_games","value":3},{"limit_code":"max_pending_public_applications","value":10},{"limit_code":"max_active_game_memberships","value":10}],"markers":{"can_login":true,"can_create_private_game":false,"can_manage_private_game":true,"can_join_game":true,"can_update_profile":true}}`,
wantBody: `{"exists":true,"user_id":"user-123","entitlement":{"plan_code":"paid_monthly","is_paid":true,"source":"billing","actor":{"type":"billing","id":"invoice-1"},"reason_code":"renewal","starts_at":"2026-04-09T10:00:00Z","ends_at":"2026-05-09T10:00:00Z","updated_at":"2026-04-09T10:00:00Z"},"active_sanctions":[{"sanction_code":"private_game_create_block","scope":"lobby","reason_code":"manual_block","actor":{"type":"admin","id":"admin-1"},"applied_at":"2026-04-09T10:00:00Z","expires_at":"2026-05-09T10:00:00Z"}],"effective_limits":[{"limit_code":"max_owned_private_games","value":3},{"limit_code":"max_pending_public_applications","value":10},{"limit_code":"max_active_game_memberships","value":10},{"limit_code":"max_registered_race_names","value":2}],"markers":{"can_login":true,"can_create_private_game":false,"can_manage_private_game":true,"can_join_game":true,"can_update_profile":true}}`,
},
{
name: "get user eligibility not found snapshot",
@@ -369,6 +379,14 @@ func TestAuthFacingHandlersSuccessCases(t *testing.T) {
wantStatus: http.StatusOK,
wantBody: `{"user_id":"user-123","active_limits":[]}`,
},
{
name: "delete user",
method: http.MethodPost,
path: "/api/v1/internal/users/user-123/delete",
body: `{"reason_code":"user_right_to_be_forgotten","actor":{"type":"admin","id":"admin-1"}}`,
wantStatus: http.StatusOK,
wantBody: `{"user_id":"user-123","deleted_at":"2026-04-24T12:00:00Z"}`,
},
}
for _, tt := range tests {
@@ -659,7 +677,7 @@ func TestSelfServiceHandlersRejectUnknownFieldsAndProjectErrors(t *testing.T) {
name: "update my profile conflict",
method: http.MethodPost,
path: "/api/v1/internal/users/user-123/profile",
body: `{"race_name":"Taken Name"}`,
body: `{"display_name":"TakenName"}`,
wantStatus: http.StatusConflict,
wantBody: `{"error":{"code":"conflict","message":"request conflicts with current state"}}`,
},
@@ -667,7 +685,7 @@ func TestSelfServiceHandlersRejectUnknownFieldsAndProjectErrors(t *testing.T) {
name: "update my profile rejects email field",
method: http.MethodPost,
path: "/api/v1/internal/users/user-123/profile",
body: `{"race_name":"Nova Prime","email":"pilot@example.com"}`,
body: `{"display_name":"NovaPrime","email":"pilot@example.com"}`,
wantStatus: http.StatusBadRequest,
wantBody: `{"error":{"code":"invalid_request","message":"request body contains unknown field \"email\""}}`,
},
@@ -776,9 +794,9 @@ func TestEnsureByEmailHandlerRejectsSemanticRegistrationContext(t *testing.T) {
ensurer, err := authdirectory.NewEnsurer(handlerTestStore{}, handlerTestClock{now: time.Unix(1_775_240_000, 0).UTC()}, handlerTestIDGenerator{
userID: common.UserID("user-created"),
raceName: common.RaceName("player-test123"),
userName: common.UserName("player-test123"),
entitlementRecordID: entitlement.EntitlementRecordID("entitlement-created"),
}, handlerTestRaceNamePolicy{})
})
require.NoError(t, err)
handler := mustNewHandler(t, Dependencies{
@@ -937,8 +955,8 @@ func mustNewHandler(t *testing.T, deps Dependencies) http.Handler {
return adminusers.LookupResult{}, nil
})
}
if deps.GetUserByRaceName == nil {
deps.GetUserByRaceName = getUserByRaceNameFunc(func(context.Context, adminusers.GetUserByRaceNameInput) (adminusers.LookupResult, error) {
if deps.GetUserByUserName == nil {
deps.GetUserByUserName = getUserByUserNameFunc(func(context.Context, adminusers.GetUserByUserNameInput) (adminusers.LookupResult, error) {
return adminusers.LookupResult{}, nil
})
}
@@ -967,6 +985,11 @@ func mustNewHandler(t *testing.T, deps Dependencies) http.Handler {
return policysvc.LimitCommandResult{}, nil
})
}
if deps.DeleteUser == nil {
deps.DeleteUser = deleteUserFunc(func(context.Context, accountdeletion.Input) (accountdeletion.Result, error) {
return accountdeletion.Result{}, nil
})
}
handler, err := newHandlerWithConfig(Config{
Addr: "127.0.0.1:0",
@@ -1046,9 +1069,9 @@ func (fn getUserByEmailFunc) Execute(ctx context.Context, input adminusers.GetUs
return fn(ctx, input)
}
type getUserByRaceNameFunc func(context.Context, adminusers.GetUserByRaceNameInput) (adminusers.LookupResult, error)
type getUserByUserNameFunc func(context.Context, adminusers.GetUserByUserNameInput) (adminusers.LookupResult, error)
func (fn getUserByRaceNameFunc) Execute(ctx context.Context, input adminusers.GetUserByRaceNameInput) (adminusers.LookupResult, error) {
func (fn getUserByUserNameFunc) Execute(ctx context.Context, input adminusers.GetUserByUserNameInput) (adminusers.LookupResult, error) {
return fn(ctx, input)
}
@@ -1112,6 +1135,12 @@ func (fn removeLimitFunc) Execute(ctx context.Context, input policysvc.RemoveLim
return fn(ctx, input)
}
type deleteUserFunc func(context.Context, accountdeletion.Input) (accountdeletion.Result, error)
func (fn deleteUserFunc) Execute(ctx context.Context, input accountdeletion.Input) (accountdeletion.Result, error) {
return fn(ctx, input)
}
type handlerTestStore struct{}
func (handlerTestStore) ResolveByEmail(context.Context, common.Email) (ports.ResolveByEmailResult, error) {
@@ -1144,7 +1173,7 @@ func (clock handlerTestClock) Now() time.Time {
type handlerTestIDGenerator struct {
userID common.UserID
raceName common.RaceName
userName common.UserName
entitlementRecordID entitlement.EntitlementRecordID
sanctionRecordID policy.SanctionRecordID
limitRecordID policy.LimitRecordID
@@ -1154,8 +1183,8 @@ func (generator handlerTestIDGenerator) NewUserID() (common.UserID, error) {
return generator.userID, nil
}
func (generator handlerTestIDGenerator) NewInitialRaceName() (common.RaceName, error) {
return generator.raceName, nil
func (generator handlerTestIDGenerator) NewUserName() (common.UserName, error) {
return generator.userName, nil
}
func (generator handlerTestIDGenerator) NewEntitlementRecordID() (entitlement.EntitlementRecordID, error) {
@@ -1170,18 +1199,13 @@ func (generator handlerTestIDGenerator) NewLimitRecordID() (policy.LimitRecordID
return generator.limitRecordID, nil
}
type handlerTestRaceNamePolicy struct{}
func (handlerTestRaceNamePolicy) CanonicalKey(raceName common.RaceName) (account.RaceNameCanonicalKey, error) {
return account.RaceNameCanonicalKey("key:" + raceName.String()), nil
}
func sampleAccountView() selfservice.AccountView {
timestamp := time.Date(2026, time.April, 9, 10, 0, 0, 0, time.UTC)
return selfservice.AccountView{
UserID: "user-123",
Email: "pilot@example.com",
RaceName: "Pilot Nova",
UserName: "player-abcdefgh",
PreferredLanguage: "en",
TimeZone: "Europe/Kaliningrad",
DeclaredCountry: "DE",
@@ -1240,6 +1264,7 @@ func sampleEligibilityView(exists bool) lobbyeligibility.GetUserEligibilityResul
{LimitCode: "max_owned_private_games", Value: 3},
{LimitCode: "max_pending_public_applications", Value: 10},
{LimitCode: "max_active_game_memberships", Value: 10},
{LimitCode: "max_registered_race_names", Value: 2},
},
Markers: lobbyeligibility.EligibilityMarkersView{
CanLogin: true,
@@ -1260,5 +1285,4 @@ var (
_ ports.AuthDirectoryStore = handlerTestStore{}
_ ports.Clock = handlerTestClock{}
_ ports.IDGenerator = handlerTestIDGenerator{}
_ ports.RaceNamePolicy = handlerTestRaceNamePolicy{}
)