feat: game lobby service
This commit is contained in:
@@ -0,0 +1,243 @@
|
||||
// Package accountdeletion implements the trusted `DeleteUser` soft-delete
|
||||
// command owned by User Service.
|
||||
package accountdeletion
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"galaxy/user/internal/domain/common"
|
||||
"galaxy/user/internal/ports"
|
||||
"galaxy/user/internal/service/shared"
|
||||
"galaxy/user/internal/telemetry"
|
||||
)
|
||||
|
||||
const adminInternalAPISource = common.Source("admin_internal_api")
|
||||
|
||||
// Input stores one trusted `DeleteUser` command request.
|
||||
type Input struct {
|
||||
// UserID identifies the regular-user account to soft-delete.
|
||||
UserID string
|
||||
|
||||
// ReasonCode stores the machine-readable mutation reason.
|
||||
ReasonCode string
|
||||
|
||||
// Actor stores the audit actor metadata attached to the mutation.
|
||||
Actor ActorInput
|
||||
}
|
||||
|
||||
// ActorInput stores one transport-facing audit actor payload.
|
||||
type ActorInput struct {
|
||||
// Type stores the machine-readable actor type.
|
||||
Type string
|
||||
|
||||
// ID stores the optional stable actor identifier.
|
||||
ID string
|
||||
}
|
||||
|
||||
// Result stores one trusted `DeleteUser` command outcome.
|
||||
type Result struct {
|
||||
// UserID identifies the soft-deleted account.
|
||||
UserID string `json:"user_id"`
|
||||
|
||||
// DeletedAt stores the committed soft-delete timestamp.
|
||||
DeletedAt time.Time `json:"deleted_at"`
|
||||
}
|
||||
|
||||
// Service executes the explicit trusted `DeleteUser` soft-delete command.
|
||||
type Service struct {
|
||||
accounts ports.UserAccountStore
|
||||
clock ports.Clock
|
||||
lifecyclePublisher ports.UserLifecyclePublisher
|
||||
logger *slog.Logger
|
||||
telemetry *telemetry.Runtime
|
||||
}
|
||||
|
||||
// NewService constructs one `DeleteUser` use case without optional
|
||||
// observability hooks.
|
||||
func NewService(
|
||||
accounts ports.UserAccountStore,
|
||||
clock ports.Clock,
|
||||
lifecyclePublisher ports.UserLifecyclePublisher,
|
||||
) (*Service, error) {
|
||||
return NewServiceWithObservability(accounts, clock, lifecyclePublisher, nil, nil)
|
||||
}
|
||||
|
||||
// NewServiceWithObservability constructs one `DeleteUser` use case with
|
||||
// optional observability hooks.
|
||||
func NewServiceWithObservability(
|
||||
accounts ports.UserAccountStore,
|
||||
clock ports.Clock,
|
||||
lifecyclePublisher ports.UserLifecyclePublisher,
|
||||
logger *slog.Logger,
|
||||
telemetryRuntime *telemetry.Runtime,
|
||||
) (*Service, error) {
|
||||
switch {
|
||||
case accounts == nil:
|
||||
return nil, fmt.Errorf("account deletion service: user account store must not be nil")
|
||||
case clock == nil:
|
||||
return nil, fmt.Errorf("account deletion service: clock must not be nil")
|
||||
case lifecyclePublisher == nil:
|
||||
return nil, fmt.Errorf("account deletion service: lifecycle publisher must not be nil")
|
||||
default:
|
||||
return &Service{
|
||||
accounts: accounts,
|
||||
clock: clock,
|
||||
lifecyclePublisher: lifecyclePublisher,
|
||||
logger: logger,
|
||||
telemetry: telemetryRuntime,
|
||||
}, nil
|
||||
}
|
||||
}
|
||||
|
||||
// Execute soft-deletes the account identified by input.UserID. The command is
|
||||
// idempotent per `user_id`: calling it after the account is already
|
||||
// soft-deleted returns `subject_not_found` and does not re-publish the
|
||||
// lifecycle event.
|
||||
func (service *Service) Execute(ctx context.Context, input Input) (result Result, err error) {
|
||||
outcome := shared.ErrorCodeInternalError
|
||||
userIDString := strings.TrimSpace(input.UserID)
|
||||
reasonCodeValue := strings.TrimSpace(input.ReasonCode)
|
||||
actorTypeValue := strings.TrimSpace(input.Actor.Type)
|
||||
actorIDValue := strings.TrimSpace(input.Actor.ID)
|
||||
defer func() {
|
||||
if service.telemetry != nil {
|
||||
service.telemetry.RecordUserLifecycleMutation(ctx, "delete", outcome)
|
||||
}
|
||||
shared.LogServiceOutcome(service.logger, ctx, "delete user completed", err,
|
||||
"use_case", "delete_user",
|
||||
"command", "delete",
|
||||
"outcome", outcome,
|
||||
"user_id", userIDString,
|
||||
"source", adminInternalAPISource.String(),
|
||||
"reason_code", reasonCodeValue,
|
||||
"actor_type", actorTypeValue,
|
||||
"actor_id", actorIDValue,
|
||||
)
|
||||
}()
|
||||
|
||||
if ctx == nil {
|
||||
outcome = shared.ErrorCodeInvalidRequest
|
||||
return Result{}, shared.InvalidRequest("context must not be nil")
|
||||
}
|
||||
|
||||
userID, err := shared.ParseUserID(input.UserID)
|
||||
if err != nil {
|
||||
outcome = shared.MetricOutcome(err)
|
||||
return Result{}, err
|
||||
}
|
||||
userIDString = userID.String()
|
||||
|
||||
reasonCode, err := shared.ParseReasonCode(input.ReasonCode)
|
||||
if err != nil {
|
||||
outcome = shared.MetricOutcome(err)
|
||||
return Result{}, err
|
||||
}
|
||||
reasonCodeValue = reasonCode.String()
|
||||
|
||||
actor, err := parseActor(input.Actor)
|
||||
if err != nil {
|
||||
outcome = shared.MetricOutcome(err)
|
||||
return Result{}, err
|
||||
}
|
||||
actorTypeValue = actor.Type.String()
|
||||
actorIDValue = actor.ID.String()
|
||||
|
||||
record, err := service.accounts.GetByUserID(ctx, userID)
|
||||
switch {
|
||||
case err == nil:
|
||||
case errors.Is(err, ports.ErrNotFound):
|
||||
outcome = shared.ErrorCodeSubjectNotFound
|
||||
return Result{}, shared.SubjectNotFound()
|
||||
default:
|
||||
outcome = shared.ErrorCodeServiceUnavailable
|
||||
return Result{}, shared.ServiceUnavailable(err)
|
||||
}
|
||||
if record.IsDeleted() {
|
||||
outcome = shared.ErrorCodeSubjectNotFound
|
||||
return Result{}, shared.SubjectNotFound()
|
||||
}
|
||||
|
||||
now := service.clock.Now().UTC()
|
||||
record.UpdatedAt = now
|
||||
record.DeletedAt = &now
|
||||
|
||||
if err := service.accounts.Update(ctx, record); err != nil {
|
||||
switch {
|
||||
case errors.Is(err, ports.ErrNotFound):
|
||||
outcome = shared.ErrorCodeSubjectNotFound
|
||||
return Result{}, shared.SubjectNotFound()
|
||||
case errors.Is(err, ports.ErrConflict):
|
||||
outcome = shared.ErrorCodeConflict
|
||||
return Result{}, shared.Conflict()
|
||||
default:
|
||||
outcome = shared.ErrorCodeServiceUnavailable
|
||||
return Result{}, shared.ServiceUnavailable(err)
|
||||
}
|
||||
}
|
||||
|
||||
outcome = "success"
|
||||
result = Result{
|
||||
UserID: userID.String(),
|
||||
DeletedAt: now,
|
||||
}
|
||||
publishDeleted(ctx, service.lifecyclePublisher, service.telemetry, service.logger, userID, now, actor, reasonCode)
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func parseActor(input ActorInput) (common.ActorRef, error) {
|
||||
ref := common.ActorRef{
|
||||
Type: common.ActorType(shared.NormalizeString(input.Type)),
|
||||
ID: common.ActorID(shared.NormalizeString(input.ID)),
|
||||
}
|
||||
if err := ref.Validate(); err != nil {
|
||||
if ref.Type.IsZero() {
|
||||
return common.ActorRef{}, shared.InvalidRequest("actor.type must not be empty")
|
||||
}
|
||||
return common.ActorRef{}, shared.InvalidRequest(err.Error())
|
||||
}
|
||||
|
||||
return ref, nil
|
||||
}
|
||||
|
||||
func publishDeleted(
|
||||
ctx context.Context,
|
||||
publisher ports.UserLifecyclePublisher,
|
||||
telemetryRuntime *telemetry.Runtime,
|
||||
logger *slog.Logger,
|
||||
userID common.UserID,
|
||||
occurredAt time.Time,
|
||||
actor common.ActorRef,
|
||||
reasonCode common.ReasonCode,
|
||||
) {
|
||||
if publisher == nil {
|
||||
return
|
||||
}
|
||||
|
||||
event := ports.UserLifecycleEvent{
|
||||
EventType: ports.UserLifecycleDeletedEventType,
|
||||
UserID: userID,
|
||||
OccurredAt: occurredAt,
|
||||
Source: adminInternalAPISource,
|
||||
Actor: actor,
|
||||
ReasonCode: reasonCode,
|
||||
}
|
||||
if err := publisher.PublishUserLifecycleEvent(ctx, event); err != nil {
|
||||
if telemetryRuntime != nil {
|
||||
telemetryRuntime.RecordEventPublicationFailure(ctx, string(ports.UserLifecycleDeletedEventType))
|
||||
}
|
||||
shared.LogEventPublicationFailure(logger, ctx, string(ports.UserLifecycleDeletedEventType), err,
|
||||
"use_case", "delete_user",
|
||||
"user_id", userID.String(),
|
||||
"source", adminInternalAPISource.String(),
|
||||
"reason_code", reasonCode.String(),
|
||||
"actor_type", actor.Type.String(),
|
||||
"actor_id", actor.ID.String(),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,229 @@
|
||||
package accountdeletion
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"galaxy/user/internal/domain/account"
|
||||
"galaxy/user/internal/domain/common"
|
||||
"galaxy/user/internal/ports"
|
||||
"galaxy/user/internal/service/shared"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestServiceExecuteSoftDeletesAndEmitsLifecycleEvent(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
now := time.Unix(1_775_240_500, 0).UTC()
|
||||
userID := common.UserID("user-123")
|
||||
created := now.Add(-24 * time.Hour)
|
||||
|
||||
accounts := newFakeAccountStore()
|
||||
accounts.records[userID] = account.UserAccount{
|
||||
UserID: userID,
|
||||
Email: common.Email("pilot@example.com"),
|
||||
UserName: common.UserName("player-abcdefgh"),
|
||||
PreferredLanguage: common.LanguageTag("en"),
|
||||
TimeZone: common.TimeZoneName("Europe/Berlin"),
|
||||
CreatedAt: created,
|
||||
UpdatedAt: created,
|
||||
}
|
||||
|
||||
publisher := &fakeLifecyclePublisher{}
|
||||
service, err := NewService(accounts, fixedClock{now: now}, publisher)
|
||||
require.NoError(t, err)
|
||||
|
||||
result, err := service.Execute(context.Background(), Input{
|
||||
UserID: userID.String(),
|
||||
ReasonCode: "user_right_to_be_forgotten",
|
||||
Actor: ActorInput{Type: "admin", ID: "admin-1"},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, userID.String(), result.UserID)
|
||||
require.True(t, result.DeletedAt.Equal(now))
|
||||
|
||||
stored := accounts.records[userID]
|
||||
require.NotNil(t, stored.DeletedAt)
|
||||
require.True(t, stored.DeletedAt.Equal(now))
|
||||
|
||||
require.Len(t, publisher.events, 1)
|
||||
emitted := publisher.events[0]
|
||||
require.Equal(t, ports.UserLifecycleDeletedEventType, emitted.EventType)
|
||||
require.Equal(t, userID, emitted.UserID)
|
||||
require.True(t, emitted.OccurredAt.Equal(now))
|
||||
require.Equal(t, common.Source("admin_internal_api"), emitted.Source)
|
||||
require.Equal(t, common.ReasonCode("user_right_to_be_forgotten"), emitted.ReasonCode)
|
||||
require.Equal(t, common.ActorRef{Type: common.ActorType("admin"), ID: common.ActorID("admin-1")}, emitted.Actor)
|
||||
}
|
||||
|
||||
func TestServiceExecuteSecondCallReturnsSubjectNotFound(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
now := time.Unix(1_775_240_500, 0).UTC()
|
||||
userID := common.UserID("user-123")
|
||||
created := now.Add(-24 * time.Hour)
|
||||
alreadyDeleted := now.Add(-time.Hour)
|
||||
|
||||
accounts := newFakeAccountStore()
|
||||
accounts.records[userID] = account.UserAccount{
|
||||
UserID: userID,
|
||||
Email: common.Email("pilot@example.com"),
|
||||
UserName: common.UserName("player-abcdefgh"),
|
||||
PreferredLanguage: common.LanguageTag("en"),
|
||||
TimeZone: common.TimeZoneName("Europe/Berlin"),
|
||||
CreatedAt: created,
|
||||
UpdatedAt: alreadyDeleted,
|
||||
DeletedAt: &alreadyDeleted,
|
||||
}
|
||||
|
||||
publisher := &fakeLifecyclePublisher{}
|
||||
service, err := NewService(accounts, fixedClock{now: now}, publisher)
|
||||
require.NoError(t, err)
|
||||
|
||||
_, err = service.Execute(context.Background(), Input{
|
||||
UserID: userID.String(),
|
||||
ReasonCode: "user_right_to_be_forgotten",
|
||||
Actor: ActorInput{Type: "admin"},
|
||||
})
|
||||
require.Error(t, err)
|
||||
require.Equal(t, shared.ErrorCodeSubjectNotFound, shared.CodeOf(err))
|
||||
require.Empty(t, publisher.events)
|
||||
}
|
||||
|
||||
func TestServiceExecuteUnknownUserReturnsSubjectNotFound(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
accounts := newFakeAccountStore()
|
||||
publisher := &fakeLifecyclePublisher{}
|
||||
service, err := NewService(accounts, fixedClock{now: time.Unix(1_775_240_500, 0).UTC()}, publisher)
|
||||
require.NoError(t, err)
|
||||
|
||||
_, err = service.Execute(context.Background(), Input{
|
||||
UserID: "user-missing",
|
||||
ReasonCode: "manual",
|
||||
Actor: ActorInput{Type: "admin"},
|
||||
})
|
||||
require.Error(t, err)
|
||||
require.Equal(t, shared.ErrorCodeSubjectNotFound, shared.CodeOf(err))
|
||||
require.Empty(t, publisher.events)
|
||||
}
|
||||
|
||||
func TestServiceExecuteInvalidActorRejected(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
accounts := newFakeAccountStore()
|
||||
publisher := &fakeLifecyclePublisher{}
|
||||
service, err := NewService(accounts, fixedClock{now: time.Unix(1_775_240_500, 0).UTC()}, publisher)
|
||||
require.NoError(t, err)
|
||||
|
||||
_, err = service.Execute(context.Background(), Input{
|
||||
UserID: "user-123",
|
||||
ReasonCode: "manual",
|
||||
Actor: ActorInput{},
|
||||
})
|
||||
require.Error(t, err)
|
||||
require.Equal(t, shared.ErrorCodeInvalidRequest, shared.CodeOf(err))
|
||||
require.Empty(t, publisher.events)
|
||||
}
|
||||
|
||||
func TestServiceExecuteStoreConflictSurfacesAsConflict(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
now := time.Unix(1_775_240_500, 0).UTC()
|
||||
userID := common.UserID("user-123")
|
||||
created := now.Add(-24 * time.Hour)
|
||||
|
||||
accounts := newFakeAccountStore()
|
||||
accounts.records[userID] = account.UserAccount{
|
||||
UserID: userID,
|
||||
Email: common.Email("pilot@example.com"),
|
||||
UserName: common.UserName("player-abcdefgh"),
|
||||
PreferredLanguage: common.LanguageTag("en"),
|
||||
TimeZone: common.TimeZoneName("Europe/Berlin"),
|
||||
CreatedAt: created,
|
||||
UpdatedAt: created,
|
||||
}
|
||||
accounts.updateErr = ports.ErrConflict
|
||||
|
||||
publisher := &fakeLifecyclePublisher{}
|
||||
service, err := NewService(accounts, fixedClock{now: now}, publisher)
|
||||
require.NoError(t, err)
|
||||
|
||||
_, err = service.Execute(context.Background(), Input{
|
||||
UserID: userID.String(),
|
||||
ReasonCode: "manual",
|
||||
Actor: ActorInput{Type: "admin"},
|
||||
})
|
||||
require.Error(t, err)
|
||||
require.Equal(t, shared.ErrorCodeConflict, shared.CodeOf(err))
|
||||
require.Empty(t, publisher.events)
|
||||
}
|
||||
|
||||
type fakeAccountStore struct {
|
||||
records map[common.UserID]account.UserAccount
|
||||
updateErr error
|
||||
}
|
||||
|
||||
func newFakeAccountStore() *fakeAccountStore {
|
||||
return &fakeAccountStore{records: map[common.UserID]account.UserAccount{}}
|
||||
}
|
||||
|
||||
func (store *fakeAccountStore) Create(context.Context, ports.CreateAccountInput) error {
|
||||
return errors.New("unexpected Create in accountdeletion tests")
|
||||
}
|
||||
|
||||
func (store *fakeAccountStore) GetByUserID(_ context.Context, userID common.UserID) (account.UserAccount, error) {
|
||||
record, ok := store.records[userID]
|
||||
if !ok {
|
||||
return account.UserAccount{}, ports.ErrNotFound
|
||||
}
|
||||
return record, nil
|
||||
}
|
||||
|
||||
func (store *fakeAccountStore) GetByEmail(context.Context, common.Email) (account.UserAccount, error) {
|
||||
return account.UserAccount{}, ports.ErrNotFound
|
||||
}
|
||||
|
||||
func (store *fakeAccountStore) GetByUserName(context.Context, common.UserName) (account.UserAccount, error) {
|
||||
return account.UserAccount{}, ports.ErrNotFound
|
||||
}
|
||||
|
||||
func (store *fakeAccountStore) ExistsByUserID(_ context.Context, userID common.UserID) (bool, error) {
|
||||
record, ok := store.records[userID]
|
||||
if !ok {
|
||||
return false, nil
|
||||
}
|
||||
return !record.IsDeleted(), nil
|
||||
}
|
||||
|
||||
func (store *fakeAccountStore) Update(_ context.Context, record account.UserAccount) error {
|
||||
if store.updateErr != nil {
|
||||
return store.updateErr
|
||||
}
|
||||
store.records[record.UserID] = record
|
||||
return nil
|
||||
}
|
||||
|
||||
type fakeLifecyclePublisher struct {
|
||||
events []ports.UserLifecycleEvent
|
||||
err error
|
||||
}
|
||||
|
||||
func (publisher *fakeLifecyclePublisher) PublishUserLifecycleEvent(_ context.Context, event ports.UserLifecycleEvent) error {
|
||||
if publisher.err != nil {
|
||||
return publisher.err
|
||||
}
|
||||
publisher.events = append(publisher.events, event)
|
||||
return nil
|
||||
}
|
||||
|
||||
type fixedClock struct {
|
||||
now time.Time
|
||||
}
|
||||
|
||||
func (clock fixedClock) Now() time.Time {
|
||||
return clock.now
|
||||
}
|
||||
Reference in New Issue
Block a user