// Package applicationstub provides an in-memory ports.ApplicationStore // implementation for service-level tests. The stub mirrors the // behavioural contract of the Redis adapter in redisstate: it enforces // application.Transition for status updates, the single-active // per-(applicant,game) constraint on Save, and the ExpectedFrom CAS // guard on UpdateStatus. // // Production code never wires this stub; it is test-only but exposed as // a regular (non _test.go) package so other service test packages can // import it. package applicationstub import ( "context" "errors" "fmt" "sort" "sync" "galaxy/lobby/internal/domain/application" "galaxy/lobby/internal/domain/common" "galaxy/lobby/internal/ports" ) // Store is a concurrency-safe in-memory implementation of // ports.ApplicationStore. The zero value is not usable; call NewStore // to construct. type Store struct { mu sync.Mutex records map[common.ApplicationID]application.Application // activeByUserGame indexes application id by the // `applicant_user_id|game_id` pair to enforce the single-active // constraint. Rejected applications are removed from this index // (mirrors the Redis adapter's `user_game_application` key // lifecycle). activeByUserGame map[string]common.ApplicationID } // NewStore constructs one empty Store ready for use. func NewStore() *Store { return &Store{ records: make(map[common.ApplicationID]application.Application), activeByUserGame: make(map[string]common.ApplicationID), } } // Save persists a new submitted application record. func (store *Store) Save(ctx context.Context, record application.Application) error { if store == nil { return errors.New("save application: nil store") } if ctx == nil { return errors.New("save application: nil context") } if err := record.Validate(); err != nil { return fmt.Errorf("save application: %w", err) } if record.Status != application.StatusSubmitted { return fmt.Errorf( "save application: status must be %q, got %q", application.StatusSubmitted, record.Status, ) } store.mu.Lock() defer store.mu.Unlock() if _, exists := store.records[record.ApplicationID]; exists { return fmt.Errorf("save application: %w", application.ErrConflict) } indexKey := activeIndexKey(record.ApplicantUserID, record.GameID) if _, exists := store.activeByUserGame[indexKey]; exists { return fmt.Errorf("save application: %w", application.ErrConflict) } store.records[record.ApplicationID] = record store.activeByUserGame[indexKey] = record.ApplicationID return nil } // Get returns the record identified by applicationID. func (store *Store) Get(ctx context.Context, applicationID common.ApplicationID) (application.Application, error) { if store == nil { return application.Application{}, errors.New("get application: nil store") } if ctx == nil { return application.Application{}, errors.New("get application: nil context") } if err := applicationID.Validate(); err != nil { return application.Application{}, fmt.Errorf("get application: %w", err) } store.mu.Lock() defer store.mu.Unlock() record, ok := store.records[applicationID] if !ok { return application.Application{}, application.ErrNotFound } return record, nil } // GetByGame returns every application attached to gameID. func (store *Store) GetByGame(ctx context.Context, gameID common.GameID) ([]application.Application, error) { if store == nil { return nil, errors.New("get applications by game: nil store") } if ctx == nil { return nil, errors.New("get applications by game: nil context") } if err := gameID.Validate(); err != nil { return nil, fmt.Errorf("get applications by game: %w", err) } store.mu.Lock() defer store.mu.Unlock() matching := make([]application.Application, 0, len(store.records)) for _, record := range store.records { if record.GameID == gameID { matching = append(matching, record) } } sort.Slice(matching, func(i, j int) bool { return matching[i].CreatedAt.Before(matching[j].CreatedAt) }) return matching, nil } // GetByUser returns every application submitted by applicantUserID. func (store *Store) GetByUser(ctx context.Context, applicantUserID string) ([]application.Application, error) { if store == nil { return nil, errors.New("get applications by user: nil store") } if ctx == nil { return nil, errors.New("get applications by user: nil context") } trimmed := ports.NormalizedApplicantUserID(applicantUserID) if trimmed == "" { return nil, fmt.Errorf("get applications by user: applicant user id must not be empty") } store.mu.Lock() defer store.mu.Unlock() matching := make([]application.Application, 0, len(store.records)) for _, record := range store.records { if record.ApplicantUserID == trimmed { matching = append(matching, record) } } sort.Slice(matching, func(i, j int) bool { return matching[i].CreatedAt.Before(matching[j].CreatedAt) }) return matching, nil } // UpdateStatus applies one status transition in a compare-and-swap fashion. func (store *Store) UpdateStatus(ctx context.Context, input ports.UpdateApplicationStatusInput) error { if store == nil { return errors.New("update application status: nil store") } if ctx == nil { return errors.New("update application status: nil context") } if err := input.Validate(); err != nil { return fmt.Errorf("update application status: %w", err) } if err := application.Transition(input.ExpectedFrom, input.To); err != nil { return err } store.mu.Lock() defer store.mu.Unlock() record, ok := store.records[input.ApplicationID] if !ok { return application.ErrNotFound } if record.Status != input.ExpectedFrom { return fmt.Errorf("update application status: %w", application.ErrConflict) } at := input.At.UTC() record.Status = input.To record.DecidedAt = &at store.records[input.ApplicationID] = record if input.To == application.StatusRejected { delete(store.activeByUserGame, activeIndexKey(record.ApplicantUserID, record.GameID)) } return nil } func activeIndexKey(applicantUserID string, gameID common.GameID) string { return applicantUserID + "|" + gameID.String() } // Compile-time interface assertion. var _ ports.ApplicationStore = (*Store)(nil)