377 lines
12 KiB
Go
377 lines
12 KiB
Go
package membership_test
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"sync"
|
|
"sync/atomic"
|
|
"testing"
|
|
"time"
|
|
|
|
"galaxy/gamemaster/internal/ports"
|
|
"galaxy/gamemaster/internal/service/membership"
|
|
"galaxy/gamemaster/internal/telemetry"
|
|
|
|
"github.com/stretchr/testify/assert"
|
|
"github.com/stretchr/testify/require"
|
|
)
|
|
|
|
// fakeLobby is a hand-rolled LobbyClient stub used by membership tests.
|
|
// It mirrors the test-double style used elsewhere in the gamemaster
|
|
// service tree.
|
|
type fakeLobby struct {
|
|
mu sync.Mutex
|
|
calls atomic.Int32
|
|
answers map[string][]ports.Membership
|
|
errs map[string]error
|
|
delay time.Duration
|
|
released chan struct{}
|
|
}
|
|
|
|
func newFakeLobby() *fakeLobby {
|
|
return &fakeLobby{
|
|
answers: map[string][]ports.Membership{},
|
|
errs: map[string]error{},
|
|
}
|
|
}
|
|
|
|
func (f *fakeLobby) seed(gameID string, members []ports.Membership) {
|
|
f.mu.Lock()
|
|
defer f.mu.Unlock()
|
|
f.answers[gameID] = members
|
|
}
|
|
|
|
func (f *fakeLobby) seedErr(gameID string, err error) {
|
|
f.mu.Lock()
|
|
defer f.mu.Unlock()
|
|
f.errs[gameID] = err
|
|
}
|
|
|
|
func (f *fakeLobby) GetMemberships(ctx context.Context, gameID string) ([]ports.Membership, error) {
|
|
f.calls.Add(1)
|
|
if f.delay > 0 {
|
|
select {
|
|
case <-time.After(f.delay):
|
|
case <-ctx.Done():
|
|
return nil, ctx.Err()
|
|
}
|
|
}
|
|
if f.released != nil {
|
|
select {
|
|
case <-f.released:
|
|
case <-ctx.Done():
|
|
return nil, ctx.Err()
|
|
}
|
|
}
|
|
f.mu.Lock()
|
|
defer f.mu.Unlock()
|
|
if err, ok := f.errs[gameID]; ok {
|
|
return nil, err
|
|
}
|
|
if members, ok := f.answers[gameID]; ok {
|
|
out := make([]ports.Membership, len(members))
|
|
copy(out, members)
|
|
return out, nil
|
|
}
|
|
return []ports.Membership{}, nil
|
|
}
|
|
|
|
func (f *fakeLobby) GetGameSummary(_ context.Context, _ string) (ports.GameSummary, error) {
|
|
return ports.GameSummary{}, errors.New("not used in cache tests")
|
|
}
|
|
|
|
func newTelemetry(t *testing.T) *telemetry.Runtime {
|
|
t.Helper()
|
|
tel, err := telemetry.NewWithProviders(nil, nil)
|
|
require.NoError(t, err)
|
|
return tel
|
|
}
|
|
|
|
func active(userID, raceName string) ports.Membership {
|
|
return ports.Membership{UserID: userID, RaceName: raceName, Status: "active", JoinedAt: time.Unix(0, 0).UTC()}
|
|
}
|
|
|
|
func newCacheForTest(t *testing.T, lobby ports.LobbyClient, ttl time.Duration, maxGames int, clock func() time.Time) *membership.Cache {
|
|
t.Helper()
|
|
cache, err := membership.NewCache(membership.Dependencies{
|
|
Lobby: lobby,
|
|
Telemetry: newTelemetry(t),
|
|
TTL: ttl,
|
|
MaxGames: maxGames,
|
|
Clock: clock,
|
|
})
|
|
require.NoError(t, err)
|
|
return cache
|
|
}
|
|
|
|
func TestNewCacheRejectsBadDependencies(t *testing.T) {
|
|
tel := newTelemetry(t)
|
|
cases := []struct {
|
|
name string
|
|
deps membership.Dependencies
|
|
}{
|
|
{"nil lobby", membership.Dependencies{Telemetry: tel, TTL: time.Second, MaxGames: 1}},
|
|
{"nil telemetry", membership.Dependencies{Lobby: newFakeLobby(), TTL: time.Second, MaxGames: 1}},
|
|
{"zero ttl", membership.Dependencies{Lobby: newFakeLobby(), Telemetry: tel, TTL: 0, MaxGames: 1}},
|
|
{"negative ttl", membership.Dependencies{Lobby: newFakeLobby(), Telemetry: tel, TTL: -time.Second, MaxGames: 1}},
|
|
{"zero max games", membership.Dependencies{Lobby: newFakeLobby(), Telemetry: tel, TTL: time.Second, MaxGames: 0}},
|
|
{"negative max games", membership.Dependencies{Lobby: newFakeLobby(), Telemetry: tel, TTL: time.Second, MaxGames: -1}},
|
|
}
|
|
for _, tc := range cases {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
cache, err := membership.NewCache(tc.deps)
|
|
require.Error(t, err)
|
|
assert.Nil(t, cache)
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestResolveHitServesCachedEntry(t *testing.T) {
|
|
lobby := newFakeLobby()
|
|
lobby.seed("game-1", []ports.Membership{active("user-1", "Aelinari"), active("user-2", "Drazi")})
|
|
now := time.Date(2026, 5, 1, 12, 0, 0, 0, time.UTC)
|
|
clock := func() time.Time { return now }
|
|
cache := newCacheForTest(t, lobby, time.Minute, 8, clock)
|
|
|
|
first, err := cache.Resolve(context.Background(), "game-1", "user-1")
|
|
require.NoError(t, err)
|
|
assert.Equal(t, "active", first)
|
|
|
|
second, err := cache.Resolve(context.Background(), "game-1", "user-2")
|
|
require.NoError(t, err)
|
|
assert.Equal(t, "active", second)
|
|
|
|
assert.Equal(t, int32(1), lobby.calls.Load())
|
|
}
|
|
|
|
func TestResolveUnknownUserReturnsEmptyString(t *testing.T) {
|
|
lobby := newFakeLobby()
|
|
lobby.seed("game-1", []ports.Membership{active("user-1", "Aelinari")})
|
|
clock := func() time.Time { return time.Date(2026, 5, 1, 12, 0, 0, 0, time.UTC) }
|
|
cache := newCacheForTest(t, lobby, time.Minute, 8, clock)
|
|
|
|
status, err := cache.Resolve(context.Background(), "game-1", "ghost")
|
|
require.NoError(t, err)
|
|
assert.Empty(t, status)
|
|
}
|
|
|
|
func TestResolveTTLExpiryRefetches(t *testing.T) {
|
|
lobby := newFakeLobby()
|
|
lobby.seed("game-1", []ports.Membership{active("user-1", "Aelinari")})
|
|
now := time.Date(2026, 5, 1, 12, 0, 0, 0, time.UTC)
|
|
clockTime := now
|
|
clock := func() time.Time { return clockTime }
|
|
cache := newCacheForTest(t, lobby, 30*time.Second, 8, clock)
|
|
|
|
_, err := cache.Resolve(context.Background(), "game-1", "user-1")
|
|
require.NoError(t, err)
|
|
assert.Equal(t, int32(1), lobby.calls.Load())
|
|
|
|
clockTime = now.Add(20 * time.Second)
|
|
_, err = cache.Resolve(context.Background(), "game-1", "user-1")
|
|
require.NoError(t, err)
|
|
assert.Equal(t, int32(1), lobby.calls.Load(), "fresh entry must not refetch")
|
|
|
|
clockTime = now.Add(31 * time.Second)
|
|
_, err = cache.Resolve(context.Background(), "game-1", "user-1")
|
|
require.NoError(t, err)
|
|
assert.Equal(t, int32(2), lobby.calls.Load(), "expired entry must refetch")
|
|
}
|
|
|
|
func TestInvalidatePurgesEntry(t *testing.T) {
|
|
lobby := newFakeLobby()
|
|
lobby.seed("game-1", []ports.Membership{active("user-1", "Aelinari")})
|
|
clock := func() time.Time { return time.Date(2026, 5, 1, 12, 0, 0, 0, time.UTC) }
|
|
cache := newCacheForTest(t, lobby, time.Minute, 8, clock)
|
|
|
|
_, err := cache.Resolve(context.Background(), "game-1", "user-1")
|
|
require.NoError(t, err)
|
|
assert.Equal(t, int32(1), lobby.calls.Load())
|
|
|
|
cache.Invalidate("game-1")
|
|
|
|
_, err = cache.Resolve(context.Background(), "game-1", "user-1")
|
|
require.NoError(t, err)
|
|
assert.Equal(t, int32(2), lobby.calls.Load())
|
|
}
|
|
|
|
func TestInvalidateOnAbsentGameIsNoop(t *testing.T) {
|
|
lobby := newFakeLobby()
|
|
clock := func() time.Time { return time.Date(2026, 5, 1, 12, 0, 0, 0, time.UTC) }
|
|
cache := newCacheForTest(t, lobby, time.Minute, 8, clock)
|
|
|
|
cache.Invalidate("missing")
|
|
}
|
|
|
|
func TestLRUEvictsOldestEntry(t *testing.T) {
|
|
lobby := newFakeLobby()
|
|
for index := range 4 {
|
|
gameID := fmt.Sprintf("game-%d", index)
|
|
lobby.seed(gameID, []ports.Membership{active("user-1", "Aelinari")})
|
|
}
|
|
now := time.Date(2026, 5, 1, 12, 0, 0, 0, time.UTC)
|
|
clockTime := now
|
|
clock := func() time.Time { return clockTime }
|
|
cache := newCacheForTest(t, lobby, time.Minute, 2, clock)
|
|
|
|
// Load games 0, 1, 2 sequentially. The cache holds at most 2; game-0
|
|
// must have been evicted by the time game-2 lands.
|
|
for index := range 3 {
|
|
clockTime = now.Add(time.Duration(index) * time.Second)
|
|
_, err := cache.Resolve(context.Background(), fmt.Sprintf("game-%d", index), "user-1")
|
|
require.NoError(t, err)
|
|
}
|
|
require.Equal(t, int32(3), lobby.calls.Load())
|
|
|
|
// Re-resolving game-1 hits the cache.
|
|
clockTime = now.Add(3 * time.Second)
|
|
_, err := cache.Resolve(context.Background(), "game-1", "user-1")
|
|
require.NoError(t, err)
|
|
assert.Equal(t, int32(3), lobby.calls.Load(), "game-1 must still be cached")
|
|
|
|
// Re-resolving game-0 misses (it was the LRU victim).
|
|
clockTime = now.Add(4 * time.Second)
|
|
_, err = cache.Resolve(context.Background(), "game-0", "user-1")
|
|
require.NoError(t, err)
|
|
assert.Equal(t, int32(4), lobby.calls.Load(), "game-0 must have been evicted")
|
|
}
|
|
|
|
func TestResolveLobbyUnavailableSurfacesAndDoesNotCache(t *testing.T) {
|
|
lobby := newFakeLobby()
|
|
lobby.seedErr("game-1", fmt.Errorf("dial: %w", ports.ErrLobbyUnavailable))
|
|
clock := func() time.Time { return time.Date(2026, 5, 1, 12, 0, 0, 0, time.UTC) }
|
|
cache := newCacheForTest(t, lobby, time.Minute, 8, clock)
|
|
|
|
_, err := cache.Resolve(context.Background(), "game-1", "user-1")
|
|
require.Error(t, err)
|
|
assert.True(t, errors.Is(err, membership.ErrLobbyUnavailable))
|
|
assert.True(t, errors.Is(err, ports.ErrLobbyUnavailable))
|
|
|
|
_, err = cache.Resolve(context.Background(), "game-1", "user-1")
|
|
require.Error(t, err)
|
|
assert.Equal(t, int32(2), lobby.calls.Load(), "failed fetch must not be cached")
|
|
}
|
|
|
|
func TestResolveUnwrappedLobbyErrorIsStillSurfacedAsLobbyUnavailable(t *testing.T) {
|
|
lobby := newFakeLobby()
|
|
lobby.seedErr("game-1", errors.New("transport"))
|
|
clock := func() time.Time { return time.Date(2026, 5, 1, 12, 0, 0, 0, time.UTC) }
|
|
cache := newCacheForTest(t, lobby, time.Minute, 8, clock)
|
|
|
|
_, err := cache.Resolve(context.Background(), "game-1", "user-1")
|
|
require.Error(t, err)
|
|
assert.True(t, errors.Is(err, membership.ErrLobbyUnavailable))
|
|
}
|
|
|
|
func TestResolveDeduplicatesConcurrentMisses(t *testing.T) {
|
|
lobby := newFakeLobby()
|
|
lobby.seed("game-1", []ports.Membership{active("user-1", "Aelinari")})
|
|
gate := make(chan struct{})
|
|
lobby.released = gate
|
|
clock := func() time.Time { return time.Date(2026, 5, 1, 12, 0, 0, 0, time.UTC) }
|
|
cache := newCacheForTest(t, lobby, time.Minute, 8, clock)
|
|
|
|
const callers = 16
|
|
var wg sync.WaitGroup
|
|
results := make([]string, callers)
|
|
errs := make([]error, callers)
|
|
wg.Add(callers)
|
|
for index := range callers {
|
|
go func(slot int) {
|
|
defer wg.Done()
|
|
results[slot], errs[slot] = cache.Resolve(context.Background(), "game-1", "user-1")
|
|
}(index)
|
|
}
|
|
|
|
// Give all goroutines a moment to register on the inflight map
|
|
// before releasing the Lobby fetch.
|
|
time.Sleep(10 * time.Millisecond)
|
|
close(gate)
|
|
wg.Wait()
|
|
|
|
for index := range callers {
|
|
require.NoError(t, errs[index])
|
|
assert.Equal(t, "active", results[index])
|
|
}
|
|
assert.Equal(t, int32(1), lobby.calls.Load(), "concurrent misses must collapse to one Lobby call")
|
|
}
|
|
|
|
func TestResolveRespectsContextCancellation(t *testing.T) {
|
|
lobby := newFakeLobby()
|
|
lobby.seed("game-1", []ports.Membership{active("user-1", "Aelinari")})
|
|
gate := make(chan struct{})
|
|
lobby.released = gate
|
|
clock := func() time.Time { return time.Date(2026, 5, 1, 12, 0, 0, 0, time.UTC) }
|
|
cache := newCacheForTest(t, lobby, time.Minute, 8, clock)
|
|
|
|
leaderDone := make(chan struct{})
|
|
go func() {
|
|
defer close(leaderDone)
|
|
_, _ = cache.Resolve(context.Background(), "game-1", "user-1")
|
|
}()
|
|
|
|
// Wait for leader to register the inflight slot.
|
|
time.Sleep(10 * time.Millisecond)
|
|
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
cancel()
|
|
|
|
_, err := cache.Resolve(ctx, "game-1", "user-1")
|
|
require.Error(t, err)
|
|
assert.True(t, errors.Is(err, context.Canceled))
|
|
|
|
close(gate)
|
|
<-leaderDone
|
|
}
|
|
|
|
func TestResolveRefreshAfterErrorReturnsSuccess(t *testing.T) {
|
|
lobby := newFakeLobby()
|
|
lobby.seedErr("game-1", errors.New("transport"))
|
|
clock := func() time.Time { return time.Date(2026, 5, 1, 12, 0, 0, 0, time.UTC) }
|
|
cache := newCacheForTest(t, lobby, time.Minute, 8, clock)
|
|
|
|
_, err := cache.Resolve(context.Background(), "game-1", "user-1")
|
|
require.Error(t, err)
|
|
|
|
lobby.mu.Lock()
|
|
delete(lobby.errs, "game-1")
|
|
lobby.answers["game-1"] = []ports.Membership{active("user-1", "Aelinari")}
|
|
lobby.mu.Unlock()
|
|
|
|
status, err := cache.Resolve(context.Background(), "game-1", "user-1")
|
|
require.NoError(t, err)
|
|
assert.Equal(t, "active", status)
|
|
}
|
|
|
|
func TestResolveRejectsNilContextAndReceiver(t *testing.T) {
|
|
lobby := newFakeLobby()
|
|
clock := func() time.Time { return time.Date(2026, 5, 1, 12, 0, 0, 0, time.UTC) }
|
|
cache := newCacheForTest(t, lobby, time.Minute, 8, clock)
|
|
|
|
var nilCtx context.Context
|
|
_, err := cache.Resolve(nilCtx, "game-1", "user-1")
|
|
require.Error(t, err)
|
|
|
|
var nilCache *membership.Cache
|
|
_, err = nilCache.Resolve(context.Background(), "game-1", "user-1")
|
|
require.Error(t, err)
|
|
}
|
|
|
|
func TestStatusFromLobbyIsPreserved(t *testing.T) {
|
|
lobby := newFakeLobby()
|
|
lobby.seed("game-1", []ports.Membership{
|
|
{UserID: "user-1", RaceName: "Aelinari", Status: "active", JoinedAt: time.Unix(0, 0).UTC()},
|
|
{UserID: "user-2", RaceName: "Drazi", Status: "removed", JoinedAt: time.Unix(0, 0).UTC()},
|
|
{UserID: "user-3", RaceName: "Vorlons", Status: "blocked", JoinedAt: time.Unix(0, 0).UTC()},
|
|
})
|
|
clock := func() time.Time { return time.Date(2026, 5, 1, 12, 0, 0, 0, time.UTC) }
|
|
cache := newCacheForTest(t, lobby, time.Minute, 8, clock)
|
|
|
|
for userID, expected := range map[string]string{"user-1": "active", "user-2": "removed", "user-3": "blocked"} {
|
|
status, err := cache.Resolve(context.Background(), "game-1", userID)
|
|
require.NoError(t, err)
|
|
assert.Equal(t, expected, status, "user %s", userID)
|
|
}
|
|
}
|