// Package inviteinmem provides an in-memory ports.InviteStore implementation // for service-level tests. The stub mirrors the behavioural contract of the // Redis adapter in redisstate: Save is create-only, UpdateStatus enforces // invite.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 inviteinmem import ( "context" "errors" "fmt" "sort" "strings" "sync" "galaxy/lobby/internal/domain/common" "galaxy/lobby/internal/domain/invite" "galaxy/lobby/internal/ports" ) // Store is a concurrency-safe in-memory implementation of ports.InviteStore. // The zero value is not usable; call NewStore to construct. type Store struct { mu sync.Mutex records map[common.InviteID]invite.Invite } // NewStore constructs one empty Store ready for use. func NewStore() *Store { return &Store{records: make(map[common.InviteID]invite.Invite)} } // Save persists a new created invite record. Create-only. func (store *Store) Save(ctx context.Context, record invite.Invite) error { if store == nil { return errors.New("save invite: nil store") } if ctx == nil { return errors.New("save invite: nil context") } if err := record.Validate(); err != nil { return fmt.Errorf("save invite: %w", err) } if record.Status != invite.StatusCreated { return fmt.Errorf( "save invite: status must be %q, got %q", invite.StatusCreated, record.Status, ) } store.mu.Lock() defer store.mu.Unlock() if _, exists := store.records[record.InviteID]; exists { return fmt.Errorf("save invite: %w", invite.ErrConflict) } store.records[record.InviteID] = record return nil } // Get returns the record identified by inviteID. func (store *Store) Get(ctx context.Context, inviteID common.InviteID) (invite.Invite, error) { if store == nil { return invite.Invite{}, errors.New("get invite: nil store") } if ctx == nil { return invite.Invite{}, errors.New("get invite: nil context") } if err := inviteID.Validate(); err != nil { return invite.Invite{}, fmt.Errorf("get invite: %w", err) } store.mu.Lock() defer store.mu.Unlock() record, ok := store.records[inviteID] if !ok { return invite.Invite{}, invite.ErrNotFound } return record, nil } // GetByGame returns every invite attached to gameID, sorted by CreatedAt // ascending. func (store *Store) GetByGame(ctx context.Context, gameID common.GameID) ([]invite.Invite, error) { if store == nil { return nil, errors.New("get invites by game: nil store") } if ctx == nil { return nil, errors.New("get invites by game: nil context") } if err := gameID.Validate(); err != nil { return nil, fmt.Errorf("get invites by game: %w", err) } store.mu.Lock() defer store.mu.Unlock() matching := make([]invite.Invite, 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 invite addressed to inviteeUserID, sorted by // CreatedAt ascending. func (store *Store) GetByUser(ctx context.Context, inviteeUserID string) ([]invite.Invite, error) { if store == nil { return nil, errors.New("get invites by user: nil store") } if ctx == nil { return nil, errors.New("get invites by user: nil context") } trimmed := strings.TrimSpace(inviteeUserID) if trimmed == "" { return nil, fmt.Errorf("get invites by user: invitee user id must not be empty") } store.mu.Lock() defer store.mu.Unlock() matching := make([]invite.Invite, 0, len(store.records)) for _, record := range store.records { if record.InviteeUserID == trimmed { matching = append(matching, record) } } sort.Slice(matching, func(i, j int) bool { return matching[i].CreatedAt.Before(matching[j].CreatedAt) }) return matching, nil } // GetByInviter returns every invite created by inviterUserID, sorted by // CreatedAt ascending. func (store *Store) GetByInviter(ctx context.Context, inviterUserID string) ([]invite.Invite, error) { if store == nil { return nil, errors.New("get invites by inviter: nil store") } if ctx == nil { return nil, errors.New("get invites by inviter: nil context") } trimmed := strings.TrimSpace(inviterUserID) if trimmed == "" { return nil, fmt.Errorf("get invites by inviter: inviter user id must not be empty") } store.mu.Lock() defer store.mu.Unlock() matching := make([]invite.Invite, 0, len(store.records)) for _, record := range store.records { if record.InviterUserID == 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.UpdateInviteStatusInput) error { if store == nil { return errors.New("update invite status: nil store") } if ctx == nil { return errors.New("update invite status: nil context") } if err := input.Validate(); err != nil { return fmt.Errorf("update invite status: %w", err) } if err := invite.Transition(input.ExpectedFrom, input.To); err != nil { return err } store.mu.Lock() defer store.mu.Unlock() record, ok := store.records[input.InviteID] if !ok { return invite.ErrNotFound } if record.Status != input.ExpectedFrom { return fmt.Errorf("update invite status: %w", invite.ErrConflict) } at := input.At.UTC() record.Status = input.To record.DecidedAt = &at if input.To == invite.StatusRedeemed { record.RaceName = input.RaceName } store.records[input.InviteID] = record return nil } // Compile-time interface assertion. var _ ports.InviteStore = (*Store)(nil)