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) } }