package session_test import ( "context" "errors" "sync" "sync/atomic" "testing" "time" "galaxy/gateway/internal/session" ) // stubLookup is the BackendLookup test fake. lookups counts hits; // records is the canonical source of truth keyed by device_session_id. type stubLookup struct { mu sync.Mutex records map[string]session.Record hits atomic.Int64 notFound bool } func newStubLookup() *stubLookup { return &stubLookup{records: make(map[string]session.Record)} } func (s *stubLookup) put(rec session.Record) { s.mu.Lock() s.records[rec.DeviceSessionID] = rec s.mu.Unlock() } func (s *stubLookup) LookupSession(_ context.Context, deviceSessionID string) (session.Record, error) { s.hits.Add(1) s.mu.Lock() defer s.mu.Unlock() if s.notFound { return session.Record{}, session.ErrNotFound } rec, ok := s.records[deviceSessionID] if !ok { return session.Record{}, session.ErrNotFound } return rec, nil } func TestMemoryCacheLookupHitsCacheAfterFirstFetch(t *testing.T) { stub := newStubLookup() stub.put(session.Record{DeviceSessionID: "a", UserID: "u1", Status: session.StatusActive}) cache, err := session.NewMemoryCache(stub, session.MemoryCacheOptions{ MaxEntries: 10, TTL: time.Hour, }) if err != nil { t.Fatalf("NewMemoryCache: %v", err) } if _, err := cache.Lookup(context.Background(), "a"); err != nil { t.Fatalf("first lookup: %v", err) } if _, err := cache.Lookup(context.Background(), "a"); err != nil { t.Fatalf("second lookup: %v", err) } if got := stub.hits.Load(); got != 1 { t.Fatalf("backend hits = %d, want 1 (cache should serve the second call)", got) } } func TestMemoryCacheLookupRefreshesOnTTLExpiry(t *testing.T) { stub := newStubLookup() stub.put(session.Record{DeviceSessionID: "a", UserID: "u1", Status: session.StatusActive}) clock := time.Unix(1_000_000, 0) now := func() time.Time { return clock } cache, err := session.NewMemoryCache(stub, session.MemoryCacheOptions{ MaxEntries: 10, TTL: 100 * time.Millisecond, Now: now, }) if err != nil { t.Fatalf("NewMemoryCache: %v", err) } if _, err := cache.Lookup(context.Background(), "a"); err != nil { t.Fatalf("first lookup: %v", err) } clock = clock.Add(200 * time.Millisecond) if _, err := cache.Lookup(context.Background(), "a"); err != nil { t.Fatalf("post-TTL lookup: %v", err) } if got := stub.hits.Load(); got != 2 { t.Fatalf("backend hits = %d, want 2 (TTL expiry should refetch)", got) } } func TestMemoryCacheMarkRevokedFlipsCachedRecord(t *testing.T) { stub := newStubLookup() stub.put(session.Record{DeviceSessionID: "a", UserID: "u1", Status: session.StatusActive}) cache, err := session.NewMemoryCache(stub, session.MemoryCacheOptions{MaxEntries: 10, TTL: time.Hour}) if err != nil { t.Fatalf("NewMemoryCache: %v", err) } if _, err := cache.Lookup(context.Background(), "a"); err != nil { t.Fatalf("first lookup: %v", err) } cache.MarkRevoked("a") rec, err := cache.Lookup(context.Background(), "a") if err != nil { t.Fatalf("post-revoke lookup: %v", err) } if rec.Status != session.StatusRevoked { t.Fatalf("status = %q, want %q", rec.Status, session.StatusRevoked) } if got := stub.hits.Load(); got != 1 { t.Fatalf("backend hits = %d, want 1 (MarkRevoked must not refetch)", got) } } func TestMemoryCacheMarkAllRevokedForUserFlipsAllSessions(t *testing.T) { stub := newStubLookup() stub.put(session.Record{DeviceSessionID: "a", UserID: "u1", Status: session.StatusActive}) stub.put(session.Record{DeviceSessionID: "b", UserID: "u1", Status: session.StatusActive}) stub.put(session.Record{DeviceSessionID: "c", UserID: "u2", Status: session.StatusActive}) cache, err := session.NewMemoryCache(stub, session.MemoryCacheOptions{MaxEntries: 10, TTL: time.Hour}) if err != nil { t.Fatalf("NewMemoryCache: %v", err) } for _, id := range []string{"a", "b", "c"} { if _, err := cache.Lookup(context.Background(), id); err != nil { t.Fatalf("seed %s: %v", id, err) } } cache.MarkAllRevokedForUser("u1") for _, id := range []string{"a", "b"} { rec, err := cache.Lookup(context.Background(), id) if err != nil { t.Fatalf("post-revoke lookup %s: %v", id, err) } if rec.Status != session.StatusRevoked { t.Fatalf("session %s status = %q, want revoked", id, rec.Status) } } rec, err := cache.Lookup(context.Background(), "c") if err != nil { t.Fatalf("post-revoke lookup c: %v", err) } if rec.Status != session.StatusActive { t.Fatalf("session c status = %q, want active (other user)", rec.Status) } } func TestMemoryCacheLRUEvictsLeastRecentlyUsed(t *testing.T) { stub := newStubLookup() stub.put(session.Record{DeviceSessionID: "a", UserID: "u1", Status: session.StatusActive}) stub.put(session.Record{DeviceSessionID: "b", UserID: "u2", Status: session.StatusActive}) stub.put(session.Record{DeviceSessionID: "c", UserID: "u3", Status: session.StatusActive}) cache, err := session.NewMemoryCache(stub, session.MemoryCacheOptions{MaxEntries: 2, TTL: time.Hour}) if err != nil { t.Fatalf("NewMemoryCache: %v", err) } if _, err := cache.Lookup(context.Background(), "a"); err != nil { t.Fatalf("seed a: %v", err) } if _, err := cache.Lookup(context.Background(), "b"); err != nil { t.Fatalf("seed b: %v", err) } if _, err := cache.Lookup(context.Background(), "c"); err != nil { t.Fatalf("seed c: %v", err) } if got := cache.Len(); got != 2 { t.Fatalf("Len = %d, want 2", got) } hitsBefore := stub.hits.Load() if _, err := cache.Lookup(context.Background(), "a"); err != nil { t.Fatalf("re-lookup a: %v", err) } if got := stub.hits.Load(); got != hitsBefore+1 { t.Fatalf("backend hits = %d, want +1 (a was evicted)", got-hitsBefore) } } func TestMemoryCachePropagatesBackendNotFound(t *testing.T) { stub := newStubLookup() stub.notFound = true cache, err := session.NewMemoryCache(stub, session.MemoryCacheOptions{MaxEntries: 4, TTL: time.Hour}) if err != nil { t.Fatalf("NewMemoryCache: %v", err) } _, err = cache.Lookup(context.Background(), "missing") if !errors.Is(err, session.ErrNotFound) { t.Fatalf("Lookup error = %v, want ErrNotFound", err) } }