// Package membershipstub provides an in-memory ports.MembershipStore // implementation for service-level tests. The stub mirrors the // behavioural contract of the Redis adapter in redisstate: Save is // create-only, UpdateStatus enforces membership.Transition and the // ExpectedFrom CAS guard, and the index reads honour the same // adapter-defined ordering rules. // // 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 membershipstub import ( "context" "errors" "fmt" "sort" "strings" "sync" "galaxy/lobby/internal/domain/common" "galaxy/lobby/internal/domain/membership" "galaxy/lobby/internal/ports" ) // Store is a concurrency-safe in-memory implementation of // ports.MembershipStore. The zero value is not usable; call NewStore // to construct. type Store struct { mu sync.Mutex records map[common.MembershipID]membership.Membership } // NewStore constructs one empty Store ready for use. func NewStore() *Store { return &Store{records: make(map[common.MembershipID]membership.Membership)} } // Save persists a new active membership record. Create-only. func (store *Store) Save(ctx context.Context, record membership.Membership) error { if store == nil { return errors.New("save membership: nil store") } if ctx == nil { return errors.New("save membership: nil context") } if err := record.Validate(); err != nil { return fmt.Errorf("save membership: %w", err) } if record.Status != membership.StatusActive { return fmt.Errorf( "save membership: status must be %q, got %q", membership.StatusActive, record.Status, ) } store.mu.Lock() defer store.mu.Unlock() if _, exists := store.records[record.MembershipID]; exists { return fmt.Errorf("save membership: %w", membership.ErrConflict) } store.records[record.MembershipID] = record return nil } // Get returns the record identified by membershipID. func (store *Store) Get(ctx context.Context, membershipID common.MembershipID) (membership.Membership, error) { if store == nil { return membership.Membership{}, errors.New("get membership: nil store") } if ctx == nil { return membership.Membership{}, errors.New("get membership: nil context") } if err := membershipID.Validate(); err != nil { return membership.Membership{}, fmt.Errorf("get membership: %w", err) } store.mu.Lock() defer store.mu.Unlock() record, ok := store.records[membershipID] if !ok { return membership.Membership{}, membership.ErrNotFound } return record, nil } // GetByGame returns every membership attached to gameID. func (store *Store) GetByGame(ctx context.Context, gameID common.GameID) ([]membership.Membership, error) { if store == nil { return nil, errors.New("get memberships by game: nil store") } if ctx == nil { return nil, errors.New("get memberships by game: nil context") } if err := gameID.Validate(); err != nil { return nil, fmt.Errorf("get memberships by game: %w", err) } store.mu.Lock() defer store.mu.Unlock() matching := make([]membership.Membership, 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].JoinedAt.Before(matching[j].JoinedAt) }) return matching, nil } // GetByUser returns every membership held by userID. func (store *Store) GetByUser(ctx context.Context, userID string) ([]membership.Membership, error) { if store == nil { return nil, errors.New("get memberships by user: nil store") } if ctx == nil { return nil, errors.New("get memberships by user: nil context") } trimmed := strings.TrimSpace(userID) if trimmed == "" { return nil, fmt.Errorf("get memberships by user: user id must not be empty") } store.mu.Lock() defer store.mu.Unlock() matching := make([]membership.Membership, 0, len(store.records)) for _, record := range store.records { if record.UserID == trimmed { matching = append(matching, record) } } sort.Slice(matching, func(i, j int) bool { return matching[i].JoinedAt.Before(matching[j].JoinedAt) }) return matching, nil } // UpdateStatus applies one status transition in a compare-and-swap fashion. func (store *Store) UpdateStatus(ctx context.Context, input ports.UpdateMembershipStatusInput) error { if store == nil { return errors.New("update membership status: nil store") } if ctx == nil { return errors.New("update membership status: nil context") } if err := input.Validate(); err != nil { return fmt.Errorf("update membership status: %w", err) } if err := membership.Transition(input.ExpectedFrom, input.To); err != nil { return err } store.mu.Lock() defer store.mu.Unlock() record, ok := store.records[input.MembershipID] if !ok { return membership.ErrNotFound } if record.Status != input.ExpectedFrom { return fmt.Errorf("update membership status: %w", membership.ErrConflict) } at := input.At.UTC() record.Status = input.To record.RemovedAt = &at store.records[input.MembershipID] = record return nil } // Delete removes the membership record identified by membershipID. It // returns membership.ErrNotFound when no record exists for the id. func (store *Store) Delete(ctx context.Context, membershipID common.MembershipID) error { if store == nil { return errors.New("delete membership: nil store") } if ctx == nil { return errors.New("delete membership: nil context") } if err := membershipID.Validate(); err != nil { return fmt.Errorf("delete membership: %w", err) } store.mu.Lock() defer store.mu.Unlock() if _, ok := store.records[membershipID]; !ok { return membership.ErrNotFound } delete(store.records, membershipID) return nil } // Compile-time interface assertion. var _ ports.MembershipStore = (*Store)(nil)