210 lines
5.9 KiB
Go
210 lines
5.9 KiB
Go
// 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)
|