feat: game lobby service
This commit is contained in:
@@ -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"}}`,
|
||||
},
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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{}
|
||||
)
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user