201 lines
6.1 KiB
Go
201 lines
6.1 KiB
Go
// Package applicationinmem 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 applicationinmem
|
|
|
|
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)
|