feat: game lobby service
This commit is contained in:
@@ -35,11 +35,10 @@ type GetUserByEmailInput struct {
|
||||
Email string
|
||||
}
|
||||
|
||||
// GetUserByRaceNameInput stores one exact trusted lookup by exact stored race
|
||||
// name.
|
||||
type GetUserByRaceNameInput struct {
|
||||
// RaceName stores the exact current race name to resolve.
|
||||
RaceName string
|
||||
// GetUserByUserNameInput stores one exact trusted lookup by stored user name.
|
||||
type GetUserByUserNameInput struct {
|
||||
// UserName stores the exact `player-<suffix>` handle to resolve.
|
||||
UserName string
|
||||
}
|
||||
|
||||
// ListUsersInput stores one trusted administrative user-list request.
|
||||
@@ -71,6 +70,16 @@ type ListUsersInput struct {
|
||||
// LimitCode stores the optional active user-specific limit filter.
|
||||
LimitCode string
|
||||
|
||||
// UserName stores the optional exact `user_name` filter.
|
||||
UserName string
|
||||
|
||||
// DisplayName stores the optional `display_name` filter value.
|
||||
DisplayName string
|
||||
|
||||
// DisplayNameMatch selects between `exact` (default) and `prefix` matching
|
||||
// for DisplayName. An empty value is treated as `exact`.
|
||||
DisplayNameMatch string
|
||||
|
||||
// CanLogin stores the optional derived login-eligibility filter.
|
||||
CanLogin *bool
|
||||
|
||||
@@ -207,40 +216,39 @@ func (service *ByEmailGetter) Execute(ctx context.Context, input GetUserByEmailI
|
||||
return LookupResult{User: aggregate.View()}, nil
|
||||
}
|
||||
|
||||
// ByRaceNameGetter executes exact trusted lookups by exact stored race name.
|
||||
type ByRaceNameGetter struct {
|
||||
// ByUserNameGetter executes exact trusted lookups by stored user name.
|
||||
type ByUserNameGetter struct {
|
||||
support readSupport
|
||||
}
|
||||
|
||||
// NewByRaceNameGetter constructs one exact admin lookup by exact stored race
|
||||
// name.
|
||||
func NewByRaceNameGetter(
|
||||
// NewByUserNameGetter constructs one exact admin lookup by stored user name.
|
||||
func NewByUserNameGetter(
|
||||
accounts ports.UserAccountStore,
|
||||
entitlements entitlementReader,
|
||||
sanctions ports.SanctionStore,
|
||||
limits ports.LimitStore,
|
||||
clock ports.Clock,
|
||||
) (*ByRaceNameGetter, error) {
|
||||
) (*ByUserNameGetter, error) {
|
||||
support, err := newReadSupport(accounts, entitlements, sanctions, limits, clock)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("admin users by-race-name getter: %w", err)
|
||||
return nil, fmt.Errorf("admin users by-user-name getter: %w", err)
|
||||
}
|
||||
|
||||
return &ByRaceNameGetter{support: support}, nil
|
||||
return &ByUserNameGetter{support: support}, nil
|
||||
}
|
||||
|
||||
// Execute resolves one exact user by exact stored race name.
|
||||
func (service *ByRaceNameGetter) Execute(ctx context.Context, input GetUserByRaceNameInput) (LookupResult, error) {
|
||||
// Execute resolves one exact user by stored user name.
|
||||
func (service *ByUserNameGetter) Execute(ctx context.Context, input GetUserByUserNameInput) (LookupResult, error) {
|
||||
if ctx == nil {
|
||||
return LookupResult{}, shared.InvalidRequest("context must not be nil")
|
||||
}
|
||||
|
||||
raceName, err := shared.ParseRaceName(input.RaceName)
|
||||
userName, err := shared.ParseUserName(input.UserName)
|
||||
if err != nil {
|
||||
return LookupResult{}, err
|
||||
}
|
||||
|
||||
record, err := service.support.accounts.GetByRaceName(ctx, raceName)
|
||||
record, err := service.support.accounts.GetByUserName(ctx, userName)
|
||||
switch {
|
||||
case err == nil:
|
||||
case errors.Is(err, ports.ErrNotFound):
|
||||
@@ -333,7 +341,19 @@ func (service *Lister) Execute(ctx context.Context, input ListUsersInput) (ListU
|
||||
candidateID := candidatePage.UserIDs[0]
|
||||
|
||||
aggregate, err := service.support.loader.Load(ctx, candidateID)
|
||||
if err != nil {
|
||||
switch {
|
||||
case err == nil:
|
||||
case shared.CodeOf(err) == shared.ErrorCodeSubjectNotFound:
|
||||
// Soft-deleted accounts are silently skipped from the default admin
|
||||
// listing per Stage 22. The candidate index may still reference them
|
||||
// while their account record carries a DeletedAt timestamp.
|
||||
if nextToken == "" {
|
||||
result.NextPageToken = ""
|
||||
return result, nil
|
||||
}
|
||||
currentToken = nextToken
|
||||
continue
|
||||
default:
|
||||
return ListUsersResult{}, err
|
||||
}
|
||||
if matchesFilters(aggregate, filters) {
|
||||
@@ -382,6 +402,18 @@ func parseListFilters(input ListUsersInput) (ports.UserListFilters, error) {
|
||||
if err != nil {
|
||||
return ports.UserListFilters{}, err
|
||||
}
|
||||
userName, err := parseListUserName(input.UserName)
|
||||
if err != nil {
|
||||
return ports.UserListFilters{}, err
|
||||
}
|
||||
displayName, err := parseListDisplayName(input.DisplayName)
|
||||
if err != nil {
|
||||
return ports.UserListFilters{}, err
|
||||
}
|
||||
displayNameMatch, err := parseListDisplayNameMatch(input.DisplayNameMatch, displayName)
|
||||
if err != nil {
|
||||
return ports.UserListFilters{}, err
|
||||
}
|
||||
|
||||
filters := ports.UserListFilters{
|
||||
PaidState: paidState,
|
||||
@@ -390,6 +422,9 @@ func parseListFilters(input ListUsersInput) (ports.UserListFilters, error) {
|
||||
DeclaredCountry: declaredCountry,
|
||||
SanctionCode: sanctionCode,
|
||||
LimitCode: limitCode,
|
||||
UserName: userName,
|
||||
DisplayName: displayName,
|
||||
DisplayNameMatch: displayNameMatch,
|
||||
CanLogin: input.CanLogin,
|
||||
CanCreatePrivateGame: input.CanCreatePrivateGame,
|
||||
CanJoinGame: input.CanJoinGame,
|
||||
@@ -401,6 +436,40 @@ func parseListFilters(input ListUsersInput) (ports.UserListFilters, error) {
|
||||
return filters, nil
|
||||
}
|
||||
|
||||
func parseListUserName(value string) (common.UserName, error) {
|
||||
trimmed := shared.NormalizeString(value)
|
||||
if trimmed == "" {
|
||||
return "", nil
|
||||
}
|
||||
|
||||
return shared.ParseUserName(trimmed)
|
||||
}
|
||||
|
||||
func parseListDisplayName(value string) (common.DisplayName, error) {
|
||||
trimmed := shared.NormalizeString(value)
|
||||
if trimmed == "" {
|
||||
return "", nil
|
||||
}
|
||||
|
||||
return shared.ParseDisplayName(trimmed)
|
||||
}
|
||||
|
||||
func parseListDisplayNameMatch(value string, displayName common.DisplayName) (ports.DisplayNameMatchMode, error) {
|
||||
trimmed := shared.NormalizeString(value)
|
||||
if trimmed == "" {
|
||||
return "", nil
|
||||
}
|
||||
mode := ports.DisplayNameMatchMode(trimmed)
|
||||
if !mode.IsKnown() {
|
||||
return "", shared.InvalidRequest(fmt.Sprintf("display_name_match %q is unsupported", trimmed))
|
||||
}
|
||||
if displayName.IsZero() {
|
||||
return "", shared.InvalidRequest("display_name_match requires display_name")
|
||||
}
|
||||
|
||||
return mode, nil
|
||||
}
|
||||
|
||||
func parsePaidState(value string) (entitlement.PaidState, error) {
|
||||
state := entitlement.PaidState(shared.NormalizeString(value))
|
||||
if !state.IsKnown() {
|
||||
@@ -477,6 +546,23 @@ func matchesFilters(aggregate accountview.Aggregate, filters ports.UserListFilte
|
||||
if filters.LimitCode != "" && !aggregate.HasActiveLimit(filters.LimitCode) {
|
||||
return false
|
||||
}
|
||||
if !filters.UserName.IsZero() && aggregate.AccountRecord.UserName != filters.UserName {
|
||||
return false
|
||||
}
|
||||
if !filters.DisplayName.IsZero() {
|
||||
recordDisplayName := aggregate.AccountRecord.DisplayName.String()
|
||||
filterValue := filters.DisplayName.String()
|
||||
switch filters.DisplayNameMatch {
|
||||
case ports.DisplayNameMatchModePrefix:
|
||||
if !strings.HasPrefix(recordDisplayName, filterValue) {
|
||||
return false
|
||||
}
|
||||
default:
|
||||
if recordDisplayName != filterValue {
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
canLogin, canCreatePrivateGame, canJoinGame := deriveFilterEligibility(aggregate)
|
||||
if filters.CanLogin != nil && canLogin != *filters.CanLogin {
|
||||
|
||||
@@ -23,7 +23,7 @@ func TestByIDGetterExecuteReturnsAggregate(t *testing.T) {
|
||||
|
||||
now := time.Unix(1_775_240_500, 0).UTC()
|
||||
service, err := NewByIDGetter(
|
||||
newFakeAdminAccountStore(validAdminUserAccount("user-123", "pilot@example.com", "Pilot Nova", now)),
|
||||
newFakeAdminAccountStore(validAdminUserAccount("user-123", "pilot@example.com", "player-abcdefgh", now)),
|
||||
&fakeAdminEntitlementSnapshotStore{
|
||||
byUserID: map[common.UserID]entitlement.CurrentSnapshot{
|
||||
common.UserID("user-123"): validAdminFreeSnapshot(common.UserID("user-123"), now),
|
||||
@@ -75,12 +75,12 @@ func TestByEmailGetterExecuteUnknownUserReturnsNotFound(t *testing.T) {
|
||||
require.Equal(t, shared.ErrorCodeSubjectNotFound, shared.CodeOf(err))
|
||||
}
|
||||
|
||||
func TestByRaceNameGetterExecuteReturnsAggregate(t *testing.T) {
|
||||
func TestByUserNameGetterExecuteReturnsAggregate(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
now := time.Unix(1_775_240_500, 0).UTC()
|
||||
service, err := NewByRaceNameGetter(
|
||||
newFakeAdminAccountStore(validAdminUserAccount("user-123", "pilot@example.com", "Pilot Nova", now)),
|
||||
service, err := NewByUserNameGetter(
|
||||
newFakeAdminAccountStore(validAdminUserAccount("user-123", "pilot@example.com", "player-abcdefgh", now)),
|
||||
&fakeAdminEntitlementSnapshotStore{
|
||||
byUserID: map[common.UserID]entitlement.CurrentSnapshot{
|
||||
common.UserID("user-123"): validAdminFreeSnapshot(common.UserID("user-123"), now),
|
||||
@@ -92,10 +92,10 @@ func TestByRaceNameGetterExecuteReturnsAggregate(t *testing.T) {
|
||||
)
|
||||
require.NoError(t, err)
|
||||
|
||||
result, err := service.Execute(context.Background(), GetUserByRaceNameInput{RaceName: " Pilot Nova "})
|
||||
result, err := service.Execute(context.Background(), GetUserByUserNameInput{UserName: " player-abcdefgh "})
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "user-123", result.User.UserID)
|
||||
require.Equal(t, "Pilot Nova", result.User.RaceName)
|
||||
require.Equal(t, "player-abcdefgh", result.User.UserName)
|
||||
}
|
||||
|
||||
func TestListerExecuteAppliesCombinedFiltersWithLogicalAND(t *testing.T) {
|
||||
@@ -111,9 +111,9 @@ func TestListerExecuteAppliesCombinedFiltersWithLogicalAND(t *testing.T) {
|
||||
canJoinGame := false
|
||||
|
||||
accountStore := newFakeAdminAccountStore(
|
||||
validAdminUserAccount("user-300", "u300@example.com", "User 300", now),
|
||||
validAdminUserAccount("user-200", "u200@example.com", "User 200", now),
|
||||
validAdminUserAccount("user-100", "u100@example.com", "User 100", now),
|
||||
validAdminUserAccount("user-300", "u300@example.com", "player-user300a", now),
|
||||
validAdminUserAccount("user-200", "u200@example.com", "player-user200a", now),
|
||||
validAdminUserAccount("user-100", "u100@example.com", "player-user100a", now),
|
||||
)
|
||||
snapshotStore := &fakeAdminEntitlementSnapshotStore{
|
||||
byUserID: map[common.UserID]entitlement.CurrentSnapshot{
|
||||
@@ -197,9 +197,9 @@ func TestListerExecuteDefaultAndMaximumPageSize(t *testing.T) {
|
||||
|
||||
now := time.Unix(1_775_240_500, 0).UTC()
|
||||
accountStore := newFakeAdminAccountStore(
|
||||
validAdminUserAccount("user-300", "u300@example.com", "User 300", now),
|
||||
validAdminUserAccount("user-200", "u200@example.com", "User 200", now),
|
||||
validAdminUserAccount("user-100", "u100@example.com", "User 100", now),
|
||||
validAdminUserAccount("user-300", "u300@example.com", "player-user300a", now),
|
||||
validAdminUserAccount("user-200", "u200@example.com", "player-user200a", now),
|
||||
validAdminUserAccount("user-100", "u100@example.com", "player-user100a", now),
|
||||
)
|
||||
snapshotStore := &fakeAdminEntitlementSnapshotStore{
|
||||
byUserID: map[common.UserID]entitlement.CurrentSnapshot{
|
||||
@@ -273,7 +273,7 @@ func TestListerExecuteInvalidPageTokenReturnsInvalidRequest(t *testing.T) {
|
||||
|
||||
now := time.Unix(1_775_240_500, 0).UTC()
|
||||
service, err := NewLister(
|
||||
newFakeAdminAccountStore(validAdminUserAccount("user-123", "pilot@example.com", "Pilot Nova", now)),
|
||||
newFakeAdminAccountStore(validAdminUserAccount("user-123", "pilot@example.com", "player-abcdefgh", now)),
|
||||
&fakeAdminEntitlementSnapshotStore{
|
||||
byUserID: map[common.UserID]entitlement.CurrentSnapshot{
|
||||
common.UserID("user-123"): validAdminFreeSnapshot(common.UserID("user-123"), now),
|
||||
@@ -360,8 +360,8 @@ func (generator adminReaderIDGenerator) NewUserID() (common.UserID, error) {
|
||||
return "", errors.New("unexpected NewUserID call")
|
||||
}
|
||||
|
||||
func (generator adminReaderIDGenerator) NewInitialRaceName() (common.RaceName, error) {
|
||||
return "", errors.New("unexpected NewInitialRaceName call")
|
||||
func (generator adminReaderIDGenerator) NewUserName() (common.UserName, error) {
|
||||
return "", errors.New("unexpected NewUserName call")
|
||||
}
|
||||
|
||||
func (generator adminReaderIDGenerator) NewEntitlementRecordID() (entitlement.EntitlementRecordID, error) {
|
||||
@@ -379,9 +379,8 @@ func (generator adminReaderIDGenerator) NewLimitRecordID() (policy.LimitRecordID
|
||||
type fakeAdminAccountStore struct {
|
||||
byUserID map[common.UserID]account.UserAccount
|
||||
byEmail map[common.Email]common.UserID
|
||||
byRaceName map[common.RaceName]common.UserID
|
||||
byUserName map[common.UserName]common.UserID
|
||||
updateErr error
|
||||
renameErr error
|
||||
createErr error
|
||||
existsByID map[common.UserID]bool
|
||||
}
|
||||
@@ -390,14 +389,14 @@ func newFakeAdminAccountStore(records ...account.UserAccount) *fakeAdminAccountS
|
||||
store := &fakeAdminAccountStore{
|
||||
byUserID: make(map[common.UserID]account.UserAccount, len(records)),
|
||||
byEmail: make(map[common.Email]common.UserID, len(records)),
|
||||
byRaceName: make(map[common.RaceName]common.UserID, len(records)),
|
||||
byUserName: make(map[common.UserName]common.UserID, len(records)),
|
||||
existsByID: make(map[common.UserID]bool, len(records)),
|
||||
}
|
||||
|
||||
for _, record := range records {
|
||||
store.byUserID[record.UserID] = record
|
||||
store.byEmail[record.Email] = record.UserID
|
||||
store.byRaceName[record.RaceName] = record.UserID
|
||||
store.byUserName[record.UserName] = record.UserID
|
||||
store.existsByID[record.UserID] = true
|
||||
}
|
||||
|
||||
@@ -426,8 +425,8 @@ func (store *fakeAdminAccountStore) GetByEmail(_ context.Context, email common.E
|
||||
return store.byUserID[userID], nil
|
||||
}
|
||||
|
||||
func (store *fakeAdminAccountStore) GetByRaceName(_ context.Context, raceName common.RaceName) (account.UserAccount, error) {
|
||||
userID, ok := store.byRaceName[raceName]
|
||||
func (store *fakeAdminAccountStore) GetByUserName(_ context.Context, userName common.UserName) (account.UserAccount, error) {
|
||||
userID, ok := store.byUserName[userName]
|
||||
if !ok {
|
||||
return account.UserAccount{}, ports.ErrNotFound
|
||||
}
|
||||
@@ -439,10 +438,6 @@ func (store *fakeAdminAccountStore) ExistsByUserID(_ context.Context, userID com
|
||||
return store.existsByID[userID], nil
|
||||
}
|
||||
|
||||
func (store *fakeAdminAccountStore) RenameRaceName(context.Context, ports.RenameRaceNameInput) error {
|
||||
return store.renameErr
|
||||
}
|
||||
|
||||
func (store *fakeAdminAccountStore) Update(context.Context, account.UserAccount) error {
|
||||
return store.updateErr
|
||||
}
|
||||
@@ -547,11 +542,11 @@ func (store *fakeAdminListStore) ListUserIDs(_ context.Context, input ports.List
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func validAdminUserAccount(userID string, email string, raceName string, now time.Time) account.UserAccount {
|
||||
func validAdminUserAccount(userID string, email string, userName string, now time.Time) account.UserAccount {
|
||||
return account.UserAccount{
|
||||
UserID: common.UserID(userID),
|
||||
Email: common.Email(email),
|
||||
RaceName: common.RaceName(raceName),
|
||||
UserName: common.UserName(userName),
|
||||
PreferredLanguage: common.LanguageTag("en"),
|
||||
TimeZone: common.TimeZoneName("Europe/Kaliningrad"),
|
||||
DeclaredCountry: common.CountryCode("DE"),
|
||||
|
||||
Reference in New Issue
Block a user