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
@@ -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
}