Files
galaxy-game/lobby/internal/adapters/membershipinmem/store.go
T
2026-04-28 20:39:18 +02:00

202 lines
5.7 KiB
Go

// Package membershipinmem 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 membershipinmem
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)