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
@@ -17,8 +17,8 @@ type getUserByEmailRequest struct {
Email string `json:"email"`
}
type getUserByRaceNameRequest struct {
RaceName string `json:"race_name"`
type getUserByUserNameRequest struct {
UserName string `json:"user_name"`
}
func handleGetUserByID(useCase GetUserByIDUseCase, timeout time.Duration) gin.HandlerFunc {
@@ -61,9 +61,9 @@ func handleGetUserByEmail(useCase GetUserByEmailUseCase, timeout time.Duration)
}
}
func handleGetUserByRaceName(useCase GetUserByRaceNameUseCase, timeout time.Duration) gin.HandlerFunc {
func handleGetUserByUserName(useCase GetUserByUserNameUseCase, timeout time.Duration) gin.HandlerFunc {
return func(c *gin.Context) {
var request getUserByRaceNameRequest
var request getUserByUserNameRequest
if err := decodeJSONRequest(c.Request, &request); err != nil {
abortWithProjection(c, shared.ProjectInternalError(shared.InvalidRequest(err.Error())))
return
@@ -72,8 +72,8 @@ func handleGetUserByRaceName(useCase GetUserByRaceNameUseCase, timeout time.Dura
callCtx, cancel := context.WithTimeout(c.Request.Context(), timeout)
defer cancel()
result, err := useCase.Execute(callCtx, adminusers.GetUserByRaceNameInput{
RaceName: request.RaceName,
result, err := useCase.Execute(callCtx, adminusers.GetUserByUserNameInput{
UserName: request.UserName,
})
if err != nil {
abortWithProjection(c, shared.ProjectInternalError(err))
@@ -144,6 +144,9 @@ func buildListUsersInput(c *gin.Context) (adminusers.ListUsersInput, error) {
DeclaredCountry: c.Query("declared_country"),
SanctionCode: c.Query("sanction_code"),
LimitCode: c.Query("limit_code"),
UserName: c.Query("user_name"),
DisplayName: c.Query("display_name"),
DisplayNameMatch: c.Query("display_name_match"),
CanLogin: canLogin,
CanCreatePrivateGame: canCreatePrivateGame,
CanJoinGame: canJoinGame,
@@ -27,8 +27,8 @@ func TestAdminReadHandlersSuccessCases(t *testing.T) {
require.Equal(t, "pilot@example.com", input.Email)
return adminusers.LookupResult{User: sampleAccountView()}, nil
}),
GetUserByRaceName: getUserByRaceNameFunc(func(_ context.Context, input adminusers.GetUserByRaceNameInput) (adminusers.LookupResult, error) {
require.Equal(t, "Pilot Nova", input.RaceName)
GetUserByUserName: getUserByUserNameFunc(func(_ context.Context, input adminusers.GetUserByUserNameInput) (adminusers.LookupResult, error) {
require.Equal(t, "player-abcdefgh", input.UserName)
return adminusers.LookupResult{User: sampleAccountView()}, nil
}),
ListUsers: listUsersFunc(func(_ context.Context, input adminusers.ListUsersInput) (adminusers.ListUsersResult, error) {
@@ -52,7 +52,7 @@ func TestAdminReadHandlersSuccessCases(t *testing.T) {
other := sampleAccountView()
other.UserID = "user-234"
other.Email = "second@example.com"
other.RaceName = "Second Pilot"
other.UserName = "player-second12"
return adminusers.ListUsersResult{
Items: []accountview.AccountView{sampleAccountView(), other},
@@ -74,7 +74,7 @@ func TestAdminReadHandlersSuccessCases(t *testing.T) {
method: http.MethodGet,
path: "/api/v1/internal/users/user-123",
wantStatus: http.StatusOK,
wantBody: `{"user":{"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: `{"user":{"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: "get user by email",
@@ -82,22 +82,22 @@ func TestAdminReadHandlersSuccessCases(t *testing.T) {
path: "/api/v1/internal/user-lookups/by-email",
body: `{"email":"pilot@example.com"}`,
wantStatus: http.StatusOK,
wantBody: `{"user":{"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: `{"user":{"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: "get user by race name",
name: "get user by user name",
method: http.MethodPost,
path: "/api/v1/internal/user-lookups/by-race-name",
body: `{"race_name":"Pilot Nova"}`,
path: "/api/v1/internal/user-lookups/by-user-name",
body: `{"user_name":"player-abcdefgh"}`,
wantStatus: http.StatusOK,
wantBody: `{"user":{"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: `{"user":{"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: "list users",
method: http.MethodGet,
path: "/api/v1/internal/users?page_size=2&page_token=cursor-1&paid_state=paid&paid_expires_before=2026-04-10T12:00:00Z&paid_expires_after=2026-04-01T12:00:00Z&declared_country=DE&sanction_code=login_block&limit_code=max_owned_private_games&can_login=false&can_create_private_game=true&can_join_game=true",
wantStatus: http.StatusOK,
wantBody: `{"items":[{"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"},{"user_id":"user-234","email":"second@example.com","race_name":"Second Pilot","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"}],"next_page_token":"cursor-2"}`,
wantBody: `{"items":[{"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"},{"user_id":"user-234","email":"second@example.com","user_name":"player-second12","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"}],"next_page_token":"cursor-2"}`,
},
}
@@ -137,7 +137,7 @@ func TestAdminReadHandlersErrorCases(t *testing.T) {
GetUserByEmail: getUserByEmailFunc(func(context.Context, adminusers.GetUserByEmailInput) (adminusers.LookupResult, error) {
return adminusers.LookupResult{}, shared.SubjectNotFound()
}),
GetUserByRaceName: getUserByRaceNameFunc(func(context.Context, adminusers.GetUserByRaceNameInput) (adminusers.LookupResult, error) {
GetUserByUserName: getUserByUserNameFunc(func(context.Context, adminusers.GetUserByUserNameInput) (adminusers.LookupResult, error) {
return adminusers.LookupResult{}, shared.SubjectNotFound()
}),
ListUsers: listUsersFunc(func(context.Context, adminusers.ListUsersInput) (adminusers.ListUsersResult, error) {
@@ -169,10 +169,10 @@ func TestAdminReadHandlersErrorCases(t *testing.T) {
wantBody: `{"error":{"code":"invalid_request","message":"request body contains unknown field \"extra\""}}`,
},
{
name: "get user by race name not found",
name: "get user by user name not found",
method: http.MethodPost,
path: "/api/v1/internal/user-lookups/by-race-name",
body: `{"race_name":"Missing Pilot"}`,
path: "/api/v1/internal/user-lookups/by-user-name",
body: `{"user_name":"player-missingx"}`,
wantStatus: http.StatusNotFound,
wantBody: `{"error":{"code":"subject_not_found","message":"subject not found"}}`,
},
+51 -6
View File
@@ -8,6 +8,7 @@ import (
"time"
"galaxy/user/internal/logging"
"galaxy/user/internal/service/accountdeletion"
"galaxy/user/internal/service/authdirectory"
"galaxy/user/internal/service/entitlementsvc"
"galaxy/user/internal/service/geosync"
@@ -82,7 +83,7 @@ type getMyAccountResponse struct {
}
type updateMyProfileRequest struct {
RaceName string `json:"race_name"`
DisplayName string `json:"display_name"`
}
type updateMySettingsRequest struct {
@@ -157,6 +158,16 @@ type removeLimitRequest struct {
Actor actorDTO `json:"actor"`
}
type deleteUserRequest struct {
ReasonCode string `json:"reason_code"`
Actor actorDTO `json:"actor"`
}
type deleteUserResponse struct {
UserID string `json:"user_id"`
DeletedAt time.Time `json:"deleted_at"`
}
type entitlementSnapshotResponse struct {
PlanCode string `json:"plan_code"`
IsPaid bool `json:"is_paid"`
@@ -200,7 +211,7 @@ func newHandlerWithConfig(cfg Config, deps Dependencies) (http.Handler, error) {
engine.POST("/api/v1/internal/users/:user_id/settings", handleUpdateMySettings(normalizedDeps.UpdateMySettings, cfg.RequestTimeout))
engine.GET("/api/v1/internal/users/:user_id", handleGetUserByID(normalizedDeps.GetUserByID, cfg.RequestTimeout))
engine.POST("/api/v1/internal/user-lookups/by-email", handleGetUserByEmail(normalizedDeps.GetUserByEmail, cfg.RequestTimeout))
engine.POST("/api/v1/internal/user-lookups/by-race-name", handleGetUserByRaceName(normalizedDeps.GetUserByRaceName, cfg.RequestTimeout))
engine.POST("/api/v1/internal/user-lookups/by-user-name", handleGetUserByUserName(normalizedDeps.GetUserByUserName, cfg.RequestTimeout))
engine.GET("/api/v1/internal/users", handleListUsers(normalizedDeps.ListUsers, cfg.RequestTimeout))
engine.GET("/api/v1/internal/users/:user_id/eligibility", handleGetUserEligibility(normalizedDeps.GetUserEligibility, cfg.RequestTimeout))
engine.POST("/api/v1/internal/users/:user_id/declared-country/sync", handleSyncDeclaredCountry(normalizedDeps.SyncDeclaredCountry, cfg.RequestTimeout))
@@ -211,6 +222,7 @@ func newHandlerWithConfig(cfg Config, deps Dependencies) (http.Handler, error) {
engine.POST("/api/v1/internal/users/:user_id/sanctions/remove", handleRemoveSanction(normalizedDeps.RemoveSanction, cfg.RequestTimeout))
engine.POST("/api/v1/internal/users/:user_id/limits/set", handleSetLimit(normalizedDeps.SetLimit, cfg.RequestTimeout))
engine.POST("/api/v1/internal/users/:user_id/limits/remove", handleRemoveLimit(normalizedDeps.RemoveLimit, cfg.RequestTimeout))
engine.POST("/api/v1/internal/users/:user_id/delete", handleDeleteUser(normalizedDeps.DeleteUser, cfg.RequestTimeout))
return engine, nil
}
@@ -382,8 +394,8 @@ func handleUpdateMyProfile(useCase UpdateMyProfileUseCase, timeout time.Duration
defer cancel()
result, err := useCase.Execute(callCtx, selfservice.UpdateMyProfileInput{
UserID: c.Param("user_id"),
RaceName: request.RaceName,
UserID: c.Param("user_id"),
DisplayName: request.DisplayName,
})
if err != nil {
abortWithProjection(c, shared.ProjectInternalError(err))
@@ -681,6 +693,37 @@ func handleRemoveLimit(useCase RemoveLimitUseCase, timeout time.Duration) gin.Ha
}
}
func handleDeleteUser(useCase DeleteUserUseCase, timeout time.Duration) gin.HandlerFunc {
return func(c *gin.Context) {
var request deleteUserRequest
if err := decodeJSONRequest(c.Request, &request); err != nil {
abortWithProjection(c, shared.ProjectInternalError(shared.InvalidRequest(err.Error())))
return
}
callCtx, cancel := context.WithTimeout(c.Request.Context(), timeout)
defer cancel()
result, err := useCase.Execute(callCtx, accountdeletion.Input{
UserID: c.Param("user_id"),
ReasonCode: request.ReasonCode,
Actor: accountdeletion.ActorInput{
Type: request.Actor.Type,
ID: request.Actor.ID,
},
})
if err != nil {
abortWithProjection(c, shared.ProjectInternalError(err))
return
}
c.JSON(http.StatusOK, deleteUserResponse{
UserID: result.UserID,
DeletedAt: result.DeletedAt.UTC(),
})
}
}
func normalizeDependencies(deps Dependencies) (Dependencies, error) {
switch {
case deps.ResolveByEmail == nil:
@@ -703,8 +746,8 @@ func normalizeDependencies(deps Dependencies) (Dependencies, error) {
return Dependencies{}, fmt.Errorf("get-user-by-id use case must not be nil")
case deps.GetUserByEmail == nil:
return Dependencies{}, fmt.Errorf("get-user-by-email use case must not be nil")
case deps.GetUserByRaceName == nil:
return Dependencies{}, fmt.Errorf("get-user-by-race-name use case must not be nil")
case deps.GetUserByUserName == nil:
return Dependencies{}, fmt.Errorf("get-user-by-user-name use case must not be nil")
case deps.ListUsers == nil:
return Dependencies{}, fmt.Errorf("list-users use case must not be nil")
case deps.GetUserEligibility == nil:
@@ -725,6 +768,8 @@ func normalizeDependencies(deps Dependencies) (Dependencies, error) {
return Dependencies{}, fmt.Errorf("set-limit use case must not be nil")
case deps.RemoveLimit == nil:
return Dependencies{}, fmt.Errorf("remove-limit use case must not be nil")
case deps.DeleteUser == nil:
return Dependencies{}, fmt.Errorf("delete-user use case must not be nil")
default:
if deps.Logger == nil {
deps.Logger = slog.Default()
+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{}
)
+20 -8
View File
@@ -12,6 +12,7 @@ import (
"sync"
"time"
"galaxy/user/internal/service/accountdeletion"
"galaxy/user/internal/service/adminusers"
"galaxy/user/internal/service/authdirectory"
"galaxy/user/internal/service/entitlementsvc"
@@ -98,12 +99,12 @@ type GetUserByEmailUseCase interface {
Execute(ctx context.Context, input adminusers.GetUserByEmailInput) (adminusers.LookupResult, error)
}
// GetUserByRaceNameUseCase describes the trusted admin exact-read by exact
// stored race name consumed by the HTTP transport layer.
type GetUserByRaceNameUseCase interface {
// Execute returns the full current account aggregate for one exact race
// GetUserByUserNameUseCase describes the trusted admin exact-read by stored
// user name consumed by the HTTP transport layer.
type GetUserByUserNameUseCase interface {
// Execute returns the full current account aggregate for one stored user
// name.
Execute(ctx context.Context, input adminusers.GetUserByRaceNameInput) (adminusers.LookupResult, error)
Execute(ctx context.Context, input adminusers.GetUserByUserNameInput) (adminusers.LookupResult, error)
}
// ListUsersUseCase describes the trusted admin paginated listing use case
@@ -178,6 +179,14 @@ type RemoveLimitUseCase interface {
Execute(ctx context.Context, input policysvc.RemoveLimitInput) (policysvc.LimitCommandResult, error)
}
// DeleteUserUseCase describes the trusted `DeleteUser` soft-delete use case
// consumed by the HTTP transport layer.
type DeleteUserUseCase interface {
// Execute soft-deletes one regular-user account and emits a
// `user.lifecycle.deleted` event on success.
Execute(ctx context.Context, input accountdeletion.Input) (accountdeletion.Result, error)
}
// Config describes the trusted internal HTTP listener owned by the user
// service.
type Config struct {
@@ -252,9 +261,9 @@ type Dependencies struct {
// e-mail.
GetUserByEmail GetUserByEmailUseCase
// GetUserByRaceName executes the trusted admin exact-read by exact stored
// race name.
GetUserByRaceName GetUserByRaceNameUseCase
// GetUserByUserName executes the trusted admin exact-read by stored user
// name.
GetUserByUserName GetUserByUserNameUseCase
// ListUsers executes the trusted admin paginated filtered listing use case.
ListUsers ListUsersUseCase
@@ -288,6 +297,9 @@ type Dependencies struct {
// RemoveLimit executes the trusted limit-remove use case.
RemoveLimit RemoveLimitUseCase
// DeleteUser executes the trusted `DeleteUser` soft-delete use case.
DeleteUser DeleteUserUseCase
// Logger writes structured transport logs. When nil, the default logger is
// used.
Logger *slog.Logger