//go:build !yaegi package backends import ( "context" "errors" "fmt" "sync" "sync/atomic" "testing" "time" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) // mockBackend is a simple mock implementation of CacheBackend for testing type mockBackend struct { pingError error data map[string]mockEntry stats map[string]interface{} mu sync.RWMutex getCalls atomic.Int32 setCalls atomic.Int32 deleteCalls atomic.Int32 failSet bool failGet bool failDelete bool failClear bool failPing bool } type mockEntry struct { expiresAt time.Time value []byte } // mockBatchBackend extends mockBackend with batch operations type mockBatchBackend struct { *mockBackend getManyError error setManyError error } func newMockBackend() *mockBackend { return &mockBackend{ data: make(map[string]mockEntry), stats: map[string]interface{}{ "hits": int64(0), "misses": int64(0), }, } } func newMockBatchBackend() *mockBatchBackend { return &mockBatchBackend{ mockBackend: newMockBackend(), } } func (m *mockBatchBackend) GetMany(ctx context.Context, keys []string) (map[string][]byte, error) { if m.getManyError != nil { return nil, m.getManyError } results := make(map[string][]byte) for _, key := range keys { value, _, exists, err := m.Get(ctx, key) if err != nil { return nil, err } if exists { results[key] = value } } return results, nil } func (m *mockBatchBackend) SetMany(ctx context.Context, items map[string][]byte, ttl time.Duration) error { if m.setManyError != nil { return m.setManyError } for key, value := range items { if err := m.Set(ctx, key, value, ttl); err != nil { return err } } return nil } func (m *mockBackend) Set(ctx context.Context, key string, value []byte, ttl time.Duration) error { m.setCalls.Add(1) m.mu.Lock() defer m.mu.Unlock() if m.failSet { return errors.New("mock set error") } expiresAt := time.Now().Add(ttl) if ttl == 0 { expiresAt = time.Now().Add(24 * time.Hour) } m.data[key] = mockEntry{ value: value, expiresAt: expiresAt, } return nil } func (m *mockBackend) Get(ctx context.Context, key string) ([]byte, time.Duration, bool, error) { m.getCalls.Add(1) m.mu.RLock() defer m.mu.RUnlock() if m.failGet { return nil, 0, false, errors.New("mock get error") } entry, exists := m.data[key] if !exists { return nil, 0, false, nil } // Check expiration if time.Now().After(entry.expiresAt) { return nil, 0, false, nil } ttl := time.Until(entry.expiresAt) return entry.value, ttl, true, nil } func (m *mockBackend) Delete(ctx context.Context, key string) (bool, error) { m.deleteCalls.Add(1) m.mu.Lock() defer m.mu.Unlock() if m.failDelete { return false, errors.New("mock delete error") } _, existed := m.data[key] delete(m.data, key) return existed, nil } func (m *mockBackend) Exists(ctx context.Context, key string) (bool, error) { m.mu.RLock() defer m.mu.RUnlock() entry, exists := m.data[key] if !exists { return false, nil } // Check expiration if time.Now().After(entry.expiresAt) { return false, nil } return true, nil } func (m *mockBackend) Clear(ctx context.Context) error { m.mu.Lock() defer m.mu.Unlock() if m.failClear { return errors.New("mock clear error") } m.data = make(map[string]mockEntry) return nil } func (m *mockBackend) GetStats() map[string]interface{} { m.mu.RLock() defer m.mu.RUnlock() return m.stats } func (m *mockBackend) Close() error { return nil } func (m *mockBackend) Ping(ctx context.Context) error { if m.failPing { if m.pingError != nil { return m.pingError } return errors.New("mock ping error") } return nil } // Constructor Tests func TestNewHybridBackend_Success(t *testing.T) { primary := newMockBackend() secondary := newMockBackend() config := &HybridConfig{ Primary: primary, Secondary: secondary, } hybrid, err := NewHybridBackend(config) require.NoError(t, err) require.NotNil(t, hybrid) // Verify default values assert.NotNil(t, hybrid.logger) assert.NotNil(t, hybrid.asyncWriteBuffer) assert.NotNil(t, hybrid.syncWriteCacheTypes) hybrid.Close() } func TestNewHybridBackend_NilConfig(t *testing.T) { hybrid, err := NewHybridBackend(nil) assert.Error(t, err) assert.Nil(t, hybrid) assert.Contains(t, err.Error(), "config is required") } func TestNewHybridBackend_NilPrimary(t *testing.T) { config := &HybridConfig{ Primary: nil, Secondary: newMockBackend(), } hybrid, err := NewHybridBackend(config) assert.Error(t, err) assert.Nil(t, hybrid) assert.Contains(t, err.Error(), "primary") } func TestNewHybridBackend_NilSecondary(t *testing.T) { config := &HybridConfig{ Primary: newMockBackend(), Secondary: nil, } hybrid, err := NewHybridBackend(config) assert.Error(t, err) assert.Nil(t, hybrid) assert.Contains(t, err.Error(), "secondary") } func TestNewHybridBackend_CustomLogger(t *testing.T) { logger := &TestLogger{t: t} config := &HybridConfig{ Primary: newMockBackend(), Secondary: newMockBackend(), Logger: logger, } hybrid, err := NewHybridBackend(config) require.NoError(t, err) assert.Equal(t, logger, hybrid.logger) hybrid.Close() } func TestNewHybridBackend_CustomAsyncBufferSize(t *testing.T) { config := &HybridConfig{ Primary: newMockBackend(), Secondary: newMockBackend(), AsyncBufferSize: 50, } hybrid, err := NewHybridBackend(config) require.NoError(t, err) assert.Equal(t, 50, cap(hybrid.asyncWriteBuffer)) hybrid.Close() } func TestNewHybridBackend_DefaultAsyncBufferSize(t *testing.T) { config := &HybridConfig{ Primary: newMockBackend(), Secondary: newMockBackend(), // AsyncBufferSize not set or <= 0 } hybrid, err := NewHybridBackend(config) require.NoError(t, err) assert.Equal(t, 1000, cap(hybrid.asyncWriteBuffer)) hybrid.Close() } func TestNewHybridBackend_CustomSyncWriteCacheTypes(t *testing.T) { customTypes := map[string]bool{ "custom1": true, "custom2": true, } config := &HybridConfig{ Primary: newMockBackend(), Secondary: newMockBackend(), SyncWriteCacheTypes: customTypes, } hybrid, err := NewHybridBackend(config) require.NoError(t, err) assert.True(t, hybrid.syncWriteCacheTypes["custom1"]) assert.True(t, hybrid.syncWriteCacheTypes["custom2"]) hybrid.Close() } func TestNewHybridBackend_DefaultSyncWriteCacheTypes(t *testing.T) { config := &HybridConfig{ Primary: newMockBackend(), Secondary: newMockBackend(), } hybrid, err := NewHybridBackend(config) require.NoError(t, err) // Should have default critical types assert.True(t, hybrid.syncWriteCacheTypes["blacklist"]) assert.True(t, hybrid.syncWriteCacheTypes["token"]) hybrid.Close() } // Basic Operations Tests func TestHybridBackend_Set_BothSuccess(t *testing.T) { primary := newMockBackend() secondary := newMockBackend() config := &HybridConfig{ Primary: primary, Secondary: secondary, SyncWriteCacheTypes: map[string]bool{ "test": true, // Make writes synchronous for testing }, } hybrid, err := NewHybridBackend(config) require.NoError(t, err) defer hybrid.Close() ctx := context.Background() key := "test:key1" value := []byte("test-value") ttl := 1 * time.Minute err = hybrid.Set(ctx, key, value, ttl) assert.NoError(t, err) // Verify L1 write assert.Equal(t, int32(1), primary.setCalls.Load()) assert.Equal(t, int64(1), hybrid.l1Writes.Load()) // Give time for sync write to complete time.Sleep(10 * time.Millisecond) // Verify L2 write (sync) assert.Equal(t, int32(1), secondary.setCalls.Load()) assert.Equal(t, int64(1), hybrid.l2Writes.Load()) } func TestHybridBackend_Set_L1Failure(t *testing.T) { primary := newMockBackend() primary.failSet = true secondary := newMockBackend() config := &HybridConfig{ Primary: primary, Secondary: secondary, } hybrid, err := NewHybridBackend(config) require.NoError(t, err) defer hybrid.Close() ctx := context.Background() err = hybrid.Set(ctx, "key1", []byte("value"), 1*time.Minute) // Should not return error even if L1 fails (continues to L2) assert.NoError(t, err) assert.Greater(t, hybrid.errors.Load(), int64(0)) } func TestHybridBackend_Set_AsyncWrite(t *testing.T) { primary := newMockBackend() secondary := newMockBackend() config := &HybridConfig{ Primary: primary, Secondary: secondary, SyncWriteCacheTypes: map[string]bool{ // "general" is not in sync list, so async }, } hybrid, err := NewHybridBackend(config) require.NoError(t, err) defer hybrid.Close() ctx := context.Background() key := "general:key1" // Will be async value := []byte("test-value") err = hybrid.Set(ctx, key, value, 1*time.Minute) assert.NoError(t, err) // L1 should be written immediately assert.Equal(t, int32(1), primary.setCalls.Load()) // Wait for async worker to process time.Sleep(100 * time.Millisecond) // L2 should eventually be written assert.Equal(t, int32(1), secondary.setCalls.Load()) } func TestHybridBackend_Get_L1Hit(t *testing.T) { primary := newMockBackend() secondary := newMockBackend() config := &HybridConfig{ Primary: primary, Secondary: secondary, } hybrid, err := NewHybridBackend(config) require.NoError(t, err) defer hybrid.Close() ctx := context.Background() key := "test:key1" value := []byte("test-value") // Populate L1 directly primary.Set(ctx, key, value, 1*time.Minute) // Get should hit L1 retrieved, _, exists, err := hybrid.Get(ctx, key) assert.NoError(t, err) assert.True(t, exists) assert.Equal(t, value, retrieved) // L1 hit counter should increment assert.Equal(t, int64(1), hybrid.l1Hits.Load()) // L2 should not be queried assert.Equal(t, int32(0), secondary.getCalls.Load()) } func TestHybridBackend_Get_L2Hit(t *testing.T) { primary := newMockBackend() secondary := newMockBackend() config := &HybridConfig{ Primary: primary, Secondary: secondary, } hybrid, err := NewHybridBackend(config) require.NoError(t, err) defer hybrid.Close() ctx := context.Background() key := "test:key1" value := []byte("test-value") // Populate L2 only secondary.Set(ctx, key, value, 1*time.Minute) // Get should miss L1, hit L2 retrieved, _, exists, err := hybrid.Get(ctx, key) assert.NoError(t, err) assert.True(t, exists) assert.Equal(t, value, retrieved) // L2 hit counter should increment assert.Equal(t, int64(1), hybrid.l2Hits.Load()) // L1 should be populated in background time.Sleep(150 * time.Millisecond) _, _, existsInL1, _ := primary.Get(ctx, key) assert.True(t, existsInL1, "L1 should be populated from L2") } func TestHybridBackend_Get_Miss(t *testing.T) { primary := newMockBackend() secondary := newMockBackend() config := &HybridConfig{ Primary: primary, Secondary: secondary, } hybrid, err := NewHybridBackend(config) require.NoError(t, err) defer hybrid.Close() ctx := context.Background() // Get non-existent key _, _, exists, err := hybrid.Get(ctx, "non-existent") assert.NoError(t, err) assert.False(t, exists) // Miss counter should increment assert.Equal(t, int64(1), hybrid.misses.Load()) } func TestHybridBackend_Delete_BothCaches(t *testing.T) { primary := newMockBackend() secondary := newMockBackend() config := &HybridConfig{ Primary: primary, Secondary: secondary, } hybrid, err := NewHybridBackend(config) require.NoError(t, err) defer hybrid.Close() ctx := context.Background() key := "test:key1" // Populate both caches primary.Set(ctx, key, []byte("value"), 1*time.Minute) secondary.Set(ctx, key, []byte("value"), 1*time.Minute) // Delete deleted, err := hybrid.Delete(ctx, key) assert.NoError(t, err) assert.True(t, deleted) // Both should be deleted assert.Equal(t, int32(1), primary.deleteCalls.Load()) assert.Equal(t, int32(1), secondary.deleteCalls.Load()) } func TestHybridBackend_Exists_L1(t *testing.T) { primary := newMockBackend() secondary := newMockBackend() config := &HybridConfig{ Primary: primary, Secondary: secondary, } hybrid, err := NewHybridBackend(config) require.NoError(t, err) defer hybrid.Close() ctx := context.Background() key := "test:key1" // Populate L1 primary.Set(ctx, key, []byte("value"), 1*time.Minute) exists, err := hybrid.Exists(ctx, key) assert.NoError(t, err) assert.True(t, exists) } func TestHybridBackend_Exists_L2(t *testing.T) { primary := newMockBackend() secondary := newMockBackend() config := &HybridConfig{ Primary: primary, Secondary: secondary, } hybrid, err := NewHybridBackend(config) require.NoError(t, err) defer hybrid.Close() ctx := context.Background() key := "test:key1" // Populate L2 only secondary.Set(ctx, key, []byte("value"), 1*time.Minute) exists, err := hybrid.Exists(ctx, key) assert.NoError(t, err) assert.True(t, exists) } func TestHybridBackend_Clear_BothCaches(t *testing.T) { primary := newMockBackend() secondary := newMockBackend() config := &HybridConfig{ Primary: primary, Secondary: secondary, } hybrid, err := NewHybridBackend(config) require.NoError(t, err) defer hybrid.Close() ctx := context.Background() // Populate both primary.Set(ctx, "key1", []byte("value1"), 1*time.Minute) secondary.Set(ctx, "key2", []byte("value2"), 1*time.Minute) err = hybrid.Clear(ctx) assert.NoError(t, err) // Both should be cleared exists1, _ := primary.Exists(ctx, "key1") exists2, _ := secondary.Exists(ctx, "key2") assert.False(t, exists1) assert.False(t, exists2) } // Fallback Mode Tests func TestHybridBackend_FallbackMode_OnL2Errors(t *testing.T) { primary := newMockBackend() secondary := newMockBackend() secondary.failSet = true config := &HybridConfig{ Primary: primary, Secondary: secondary, SyncWriteCacheTypes: map[string]bool{"test": true}, } hybrid, err := NewHybridBackend(config) require.NoError(t, err) defer hybrid.Close() ctx := context.Background() // Multiple failures should trigger fallback mode for i := 0; i < 3; i++ { hybrid.Set(ctx, fmt.Sprintf("test:key%d", i), []byte("value"), 1*time.Minute) time.Sleep(10 * time.Millisecond) } // Should eventually enter fallback mode time.Sleep(50 * time.Millisecond) assert.True(t, hybrid.fallbackMode.Load(), "Should enter fallback mode after L2 errors") } func TestHybridBackend_FallbackMode_SkipsL2(t *testing.T) { primary := newMockBackend() secondary := newMockBackend() config := &HybridConfig{ Primary: primary, Secondary: secondary, } hybrid, err := NewHybridBackend(config) require.NoError(t, err) defer hybrid.Close() // Manually enable fallback mode hybrid.fallbackMode.Store(true) ctx := context.Background() key := "test:key1" value := []byte("test-value") err = hybrid.Set(ctx, key, value, 1*time.Minute) assert.NoError(t, err) // L1 should be written assert.Equal(t, int32(1), primary.setCalls.Load()) // L2 should be skipped time.Sleep(50 * time.Millisecond) assert.Equal(t, int32(0), secondary.setCalls.Load()) } func TestHybridBackend_FallbackMode_Get(t *testing.T) { primary := newMockBackend() secondary := newMockBackend() config := &HybridConfig{ Primary: primary, Secondary: secondary, } hybrid, err := NewHybridBackend(config) require.NoError(t, err) defer hybrid.Close() // Enable fallback mode hybrid.fallbackMode.Store(true) ctx := context.Background() // Populate L2 secondary.Set(ctx, "key1", []byte("value"), 1*time.Minute) // Get should only check L1 in fallback mode _, _, exists, err := hybrid.Get(ctx, "key1") assert.NoError(t, err) assert.False(t, exists) // Miss should be recorded assert.Equal(t, int64(1), hybrid.misses.Load()) // L2 should not be queried assert.Equal(t, int32(0), secondary.getCalls.Load()) } // Health Monitoring Tests func TestHybridBackend_Ping_BothHealthy(t *testing.T) { primary := newMockBackend() secondary := newMockBackend() config := &HybridConfig{ Primary: primary, Secondary: secondary, } hybrid, err := NewHybridBackend(config) require.NoError(t, err) defer hybrid.Close() ctx := context.Background() err = hybrid.Ping(ctx) assert.NoError(t, err) } func TestHybridBackend_Ping_L1Failure(t *testing.T) { primary := newMockBackend() primary.failPing = true secondary := newMockBackend() config := &HybridConfig{ Primary: primary, Secondary: secondary, } hybrid, err := NewHybridBackend(config) require.NoError(t, err) defer hybrid.Close() ctx := context.Background() err = hybrid.Ping(ctx) assert.Error(t, err) assert.Contains(t, err.Error(), "L1") } func TestHybridBackend_Ping_L2Failure(t *testing.T) { primary := newMockBackend() secondary := newMockBackend() secondary.failPing = true config := &HybridConfig{ Primary: primary, Secondary: secondary, } hybrid, err := NewHybridBackend(config) require.NoError(t, err) defer hybrid.Close() ctx := context.Background() err = hybrid.Ping(ctx) // Should not return error (L2 failure is tolerated) assert.NoError(t, err) // But should record error lastErr := hybrid.lastL2Error.Load() assert.NotNil(t, lastErr) } func TestHybridBackend_Ping_RecoverFromFallback(t *testing.T) { primary := newMockBackend() secondary := newMockBackend() secondary.failPing = true config := &HybridConfig{ Primary: primary, Secondary: secondary, } hybrid, err := NewHybridBackend(config) require.NoError(t, err) defer hybrid.Close() ctx := context.Background() // Repeated L2 failures (two errors within the window) enter fallback. hybrid.Ping(ctx) hybrid.Ping(ctx) assert.True(t, hybrid.fallbackMode.Load()) // Fix L2 secondary.failPing = false // Second ping succeeds, exits fallback hybrid.Ping(ctx) time.Sleep(10 * time.Millisecond) assert.False(t, hybrid.fallbackMode.Load()) } // GetStats Tests func TestHybridBackend_GetStats(t *testing.T) { primary := newMockBackend() secondary := newMockBackend() config := &HybridConfig{ Primary: primary, Secondary: secondary, } hybrid, err := NewHybridBackend(config) require.NoError(t, err) defer hybrid.Close() ctx := context.Background() // Generate some activity hybrid.Set(ctx, "key1", []byte("value1"), 1*time.Minute) hybrid.Get(ctx, "key1") // L1 hit hybrid.Get(ctx, "key-miss") // miss stats := hybrid.GetStats() assert.NotNil(t, stats) // Check required fields assert.Equal(t, TypeHybrid, stats["type"]) assert.Contains(t, stats, "l1_hits") assert.Contains(t, stats, "l2_hits") assert.Contains(t, stats, "misses") assert.Contains(t, stats, "total") assert.Contains(t, stats, "l1_writes") assert.Contains(t, stats, "l2_writes") assert.Contains(t, stats, "errors") assert.Contains(t, stats, "fallback_mode") assert.Contains(t, stats, "l1_stats") assert.Contains(t, stats, "l2_stats") } func TestHybridBackend_GetStats_HitRates(t *testing.T) { primary := newMockBackend() secondary := newMockBackend() config := &HybridConfig{ Primary: primary, Secondary: secondary, } hybrid, err := NewHybridBackend(config) require.NoError(t, err) defer hybrid.Close() // Record some hits hybrid.l1Hits.Store(10) hybrid.l2Hits.Store(5) hybrid.misses.Store(5) stats := hybrid.GetStats() // Should calculate hit rates assert.Contains(t, stats, "l1_hit_rate") assert.Contains(t, stats, "l2_hit_rate") assert.Contains(t, stats, "overall_hit_rate") // Check values assert.InDelta(t, 0.5, stats["l1_hit_rate"], 0.01) assert.InDelta(t, 0.25, stats["l2_hit_rate"], 0.01) assert.InDelta(t, 0.75, stats["overall_hit_rate"], 0.01) } func TestHybridBackend_GetStats_LastL2Error(t *testing.T) { primary := newMockBackend() secondary := newMockBackend() config := &HybridConfig{ Primary: primary, Secondary: secondary, } hybrid, err := NewHybridBackend(config) require.NoError(t, err) defer hybrid.Close() // Record an L2 error errorTime := time.Now() hybrid.lastL2Error.Store(errorTime) stats := hybrid.GetStats() assert.Contains(t, stats, "last_l2_error") assert.Contains(t, stats, "seconds_since_l2_error") } // GetMany/SetMany Tests func TestHybridBackend_GetMany_L1Hits(t *testing.T) { primary := newMockBackend() secondary := newMockBackend() config := &HybridConfig{ Primary: primary, Secondary: secondary, } hybrid, err := NewHybridBackend(config) require.NoError(t, err) defer hybrid.Close() ctx := context.Background() // Populate L1 primary.Set(ctx, "key1", []byte("value1"), 1*time.Minute) primary.Set(ctx, "key2", []byte("value2"), 1*time.Minute) results, err := hybrid.GetMany(ctx, []string{"key1", "key2"}) assert.NoError(t, err) assert.Len(t, results, 2) assert.Equal(t, []byte("value1"), results["key1"]) assert.Equal(t, []byte("value2"), results["key2"]) // Should be L1 hits assert.Equal(t, int64(2), hybrid.l1Hits.Load()) } func TestHybridBackend_GetMany_EmptyKeys(t *testing.T) { primary := newMockBackend() secondary := newMockBackend() config := &HybridConfig{ Primary: primary, Secondary: secondary, } hybrid, err := NewHybridBackend(config) require.NoError(t, err) defer hybrid.Close() ctx := context.Background() results, err := hybrid.GetMany(ctx, []string{}) assert.NoError(t, err) assert.Empty(t, results) } func TestHybridBackend_GetMany_L2Fallback(t *testing.T) { primary := newMockBackend() secondary := newMockBackend() config := &HybridConfig{ Primary: primary, Secondary: secondary, } hybrid, err := NewHybridBackend(config) require.NoError(t, err) defer hybrid.Close() ctx := context.Background() // Populate L2 only secondary.Set(ctx, "key1", []byte("value1"), 1*time.Minute) secondary.Set(ctx, "key2", []byte("value2"), 1*time.Minute) results, err := hybrid.GetMany(ctx, []string{"key1", "key2"}) assert.NoError(t, err) assert.Len(t, results, 2) assert.Equal(t, []byte("value1"), results["key1"]) assert.Equal(t, []byte("value2"), results["key2"]) // Should be L2 misses (L1 was empty) assert.Equal(t, int64(0), hybrid.l1Hits.Load()) // Give async L1 population time to complete time.Sleep(50 * time.Millisecond) // Verify L1 was populated from L2 hits val1, _, exists1, _ := primary.Get(ctx, "key1") assert.True(t, exists1) assert.Equal(t, []byte("value1"), val1) } func TestHybridBackend_GetMany_MixedL1L2(t *testing.T) { primary := newMockBackend() secondary := newMockBackend() config := &HybridConfig{ Primary: primary, Secondary: secondary, } hybrid, err := NewHybridBackend(config) require.NoError(t, err) defer hybrid.Close() ctx := context.Background() // key1 in L1 only primary.Set(ctx, "key1", []byte("value1"), 1*time.Minute) // key2 in L2 only secondary.Set(ctx, "key2", []byte("value2"), 1*time.Minute) // key3 in both (L1 should win) primary.Set(ctx, "key3", []byte("value3-l1"), 1*time.Minute) secondary.Set(ctx, "key3", []byte("value3-l2"), 1*time.Minute) results, err := hybrid.GetMany(ctx, []string{"key1", "key2", "key3"}) assert.NoError(t, err) assert.Len(t, results, 3) assert.Equal(t, []byte("value1"), results["key1"]) assert.Equal(t, []byte("value2"), results["key2"]) assert.Equal(t, []byte("value3-l1"), results["key3"]) // L1 wins // Should have 2 L1 hits (key1, key3) assert.Equal(t, int64(2), hybrid.l1Hits.Load()) } func TestHybridBackend_GetMany_FallbackMode(t *testing.T) { primary := newMockBackend() secondary := newMockBackend() config := &HybridConfig{ Primary: primary, Secondary: secondary, } hybrid, err := NewHybridBackend(config) require.NoError(t, err) defer hybrid.Close() ctx := context.Background() // Enable fallback mode hybrid.fallbackMode.Store(true) // Populate L1 and L2 primary.Set(ctx, "key1", []byte("value1"), 1*time.Minute) secondary.Set(ctx, "key2", []byte("value2"), 1*time.Minute) // In fallback mode, should only check L1 results, err := hybrid.GetMany(ctx, []string{"key1", "key2"}) assert.NoError(t, err) // Should only find key1 (from L1) assert.Len(t, results, 1) assert.Equal(t, []byte("value1"), results["key1"]) assert.NotContains(t, results, "key2") // L2 not checked assert.Equal(t, int64(1), hybrid.l1Hits.Load()) } func TestHybridBackend_GetMany_L2Error(t *testing.T) { primary := newMockBackend() secondary := newMockBackend() secondary.failGet = true // Force L2 errors config := &HybridConfig{ Primary: primary, Secondary: secondary, } hybrid, err := NewHybridBackend(config) require.NoError(t, err) defer hybrid.Close() ctx := context.Background() // key1 in L1, key2 needs L2 (but will error) primary.Set(ctx, "key1", []byte("value1"), 1*time.Minute) results, err := hybrid.GetMany(ctx, []string{"key1", "key2"}) // Should still succeed with L1 hits even when L2 errors assert.NoError(t, err) assert.Len(t, results, 1) assert.Equal(t, []byte("value1"), results["key1"]) // Note: Individual Get errors may not immediately trigger fallback mode // The circuit breaker needs multiple consecutive errors } func TestHybridBackend_GetMany_PartialL2Results(t *testing.T) { primary := newMockBackend() secondary := newMockBackend() config := &HybridConfig{ Primary: primary, Secondary: secondary, } hybrid, err := NewHybridBackend(config) require.NoError(t, err) defer hybrid.Close() ctx := context.Background() // Only key2 exists in L2 secondary.Set(ctx, "key2", []byte("value2"), 1*time.Minute) // Request 3 keys, only one exists results, err := hybrid.GetMany(ctx, []string{"key1", "key2", "key3"}) assert.NoError(t, err) // Should only have key2 assert.Len(t, results, 1) assert.Equal(t, []byte("value2"), results["key2"]) assert.NotContains(t, results, "key1") assert.NotContains(t, results, "key3") } func TestHybridBackend_GetMany_WithBatchBackend(t *testing.T) { primary := newMockBackend() secondary := newMockBatchBackend() config := &HybridConfig{ Primary: primary, Secondary: secondary, } hybrid, err := NewHybridBackend(config) require.NoError(t, err) defer hybrid.Close() ctx := context.Background() // Populate L2 with batch backend secondary.Set(ctx, "key1", []byte("value1"), 1*time.Minute) secondary.Set(ctx, "key2", []byte("value2"), 1*time.Minute) secondary.Set(ctx, "key3", []byte("value3"), 1*time.Minute) // GetMany should use batch operation results, err := hybrid.GetMany(ctx, []string{"key1", "key2", "key3"}) assert.NoError(t, err) assert.Len(t, results, 3) assert.Equal(t, []byte("value1"), results["key1"]) assert.Equal(t, []byte("value2"), results["key2"]) assert.Equal(t, []byte("value3"), results["key3"]) // Verify L1 populated asynchronously time.Sleep(50 * time.Millisecond) val1, _, exists1, _ := primary.Get(ctx, "key1") assert.True(t, exists1) assert.Equal(t, []byte("value1"), val1) } func TestHybridBackend_GetMany_BatchBackendError(t *testing.T) { primary := newMockBackend() secondary := newMockBatchBackend() secondary.getManyError = errors.New("batch operation failed") config := &HybridConfig{ Primary: primary, Secondary: secondary, } hybrid, err := NewHybridBackend(config) require.NoError(t, err) defer hybrid.Close() ctx := context.Background() // key1 in L1 primary.Set(ctx, "key1", []byte("value1"), 1*time.Minute) // GetMany should handle batch error gracefully results, err := hybrid.GetMany(ctx, []string{"key1", "key2"}) // Should return L1 results even though L2 batch failed assert.NoError(t, err) assert.Len(t, results, 1) assert.Equal(t, []byte("value1"), results["key1"]) // A single batch error is not enough to enter fallback (avoids a cache // stampede on one transient L2 error); a second error within the window is. assert.False(t, hybrid.fallbackMode.Load()) _, err = hybrid.GetMany(ctx, []string{"key2"}) assert.NoError(t, err) // Repeated batch error should trigger fallback mode time.Sleep(50 * time.Millisecond) assert.True(t, hybrid.fallbackMode.Load()) } func TestHybridBackend_GetMany_MixedBatchResults(t *testing.T) { primary := newMockBackend() secondary := newMockBatchBackend() config := &HybridConfig{ Primary: primary, Secondary: secondary, } hybrid, err := NewHybridBackend(config) require.NoError(t, err) defer hybrid.Close() ctx := context.Background() // key1 and key2 in L1 primary.Set(ctx, "key1", []byte("value1-l1"), 1*time.Minute) primary.Set(ctx, "key2", []byte("value2-l1"), 1*time.Minute) // key3 and key4 in L2 (batch backend) secondary.Set(ctx, "key3", []byte("value3-l2"), 1*time.Minute) secondary.Set(ctx, "key4", []byte("value4-l2"), 1*time.Minute) // GetMany with mixed L1/L2 hits via batch results, err := hybrid.GetMany(ctx, []string{"key1", "key2", "key3", "key4"}) assert.NoError(t, err) assert.Len(t, results, 4) // L1 results assert.Equal(t, []byte("value1-l1"), results["key1"]) assert.Equal(t, []byte("value2-l1"), results["key2"]) // L2 batch results assert.Equal(t, []byte("value3-l2"), results["key3"]) assert.Equal(t, []byte("value4-l2"), results["key4"]) // Should have 2 L1 hits assert.Equal(t, int64(2), hybrid.l1Hits.Load()) } func TestHybridBackend_SetMany_Success(t *testing.T) { primary := newMockBackend() secondary := newMockBackend() config := &HybridConfig{ Primary: primary, Secondary: secondary, SyncWriteCacheTypes: map[string]bool{"test": true}, } hybrid, err := NewHybridBackend(config) require.NoError(t, err) defer hybrid.Close() ctx := context.Background() items := map[string][]byte{ "test:key1": []byte("value1"), "test:key2": []byte("value2"), } err = hybrid.SetMany(ctx, items, 1*time.Minute) assert.NoError(t, err) // L1 should have both assert.Equal(t, int32(2), primary.setCalls.Load()) // L2 should have both (sync writes) time.Sleep(50 * time.Millisecond) assert.Equal(t, int32(2), secondary.setCalls.Load()) } func TestHybridBackend_SetMany_EmptyItems(t *testing.T) { primary := newMockBackend() secondary := newMockBackend() config := &HybridConfig{ Primary: primary, Secondary: secondary, } hybrid, err := NewHybridBackend(config) require.NoError(t, err) defer hybrid.Close() ctx := context.Background() err = hybrid.SetMany(ctx, map[string][]byte{}, 1*time.Minute) assert.NoError(t, err) } // Close Tests func TestHybridBackend_Close(t *testing.T) { primary := newMockBackend() secondary := newMockBackend() config := &HybridConfig{ Primary: primary, Secondary: secondary, } hybrid, err := NewHybridBackend(config) require.NoError(t, err) err = hybrid.Close() assert.NoError(t, err) // Context should be canceled select { case <-hybrid.ctx.Done(): // Good default: t.Error("Context should be canceled after Close") } } // Helper Function Tests func TestExtractCacheType(t *testing.T) { primary := newMockBackend() secondary := newMockBackend() config := &HybridConfig{ Primary: primary, Secondary: secondary, } hybrid, err := NewHybridBackend(config) require.NoError(t, err) defer hybrid.Close() tests := []struct { key string expected string }{ {"blacklist:token123", "blacklist"}, {"token:access123", "token"}, {"metadata:provider", "metadata"}, {"jwk:key1234567", "jwk"}, // Needs to be > 10 chars {"session:sess1234", "session"}, {"introspect:tok123", "introspection"}, {"other:key", "general"}, {"short", "general"}, } for _, tt := range tests { t.Run(tt.key, func(t *testing.T) { result := hybrid.extractCacheType(tt.key) assert.Equal(t, tt.expected, result) }) } } func TestContains(t *testing.T) { tests := []struct { s string substr string expected bool }{ {"blacklist", "black", true}, {"blacklist", "list", true}, {"blacklist", "xyz", false}, {"TOKEN", "token", true}, // case insensitive {"short", "verylongstring", false}, {"", "any", false}, } for _, tt := range tests { t.Run(fmt.Sprintf("%s-%s", tt.s, tt.substr), func(t *testing.T) { result := contains(tt.s, tt.substr) assert.Equal(t, tt.expected, result) }) } } func TestToLower(t *testing.T) { tests := []struct { input byte expected byte }{ {'A', 'a'}, {'Z', 'z'}, {'a', 'a'}, {'z', 'z'}, {'0', '0'}, {'!', '!'}, } for _, tt := range tests { t.Run(string(tt.input), func(t *testing.T) { result := toLower(tt.input) assert.Equal(t, tt.expected, result) }) } } // Logger Tests func TestDefaultLogger_Methods(t *testing.T) { // Create a default logger using the test logger testLogger := &TestLogger{t: t} // These should not panic testLogger.Debugf("debug %s", "message") testLogger.Infof("info %s", "message") testLogger.Warnf("warn %s", "message") testLogger.Errorf("error %s", "message") } // Async Write Worker Tests func TestHybridBackend_AsyncWriteWorker_ProcessesWrites(t *testing.T) { primary := newMockBackend() secondary := newMockBackend() config := &HybridConfig{ Primary: primary, Secondary: secondary, // No sync types - all writes are async } hybrid, err := NewHybridBackend(config) require.NoError(t, err) defer hybrid.Close() ctx := context.Background() // Queue multiple async writes for i := 0; i < 5; i++ { key := fmt.Sprintf("async:key%d", i) value := []byte(fmt.Sprintf("value%d", i)) err := hybrid.Set(ctx, key, value, 1*time.Minute) require.NoError(t, err) } // Wait for async worker to process time.Sleep(200 * time.Millisecond) // All should be written to L2 assert.Equal(t, int32(5), secondary.setCalls.Load()) } func TestHybridBackend_AsyncWriteWorker_BufferFull(t *testing.T) { primary := newMockBackend() secondary := newMockBackend() config := &HybridConfig{ Primary: primary, Secondary: secondary, AsyncBufferSize: 2, // Very small buffer } hybrid, err := NewHybridBackend(config) require.NoError(t, err) defer hybrid.Close() ctx := context.Background() // Try to overflow buffer for i := 0; i < 10; i++ { key := fmt.Sprintf("async:key%d", i) value := []byte(fmt.Sprintf("value%d", i)) hybrid.Set(ctx, key, value, 1*time.Minute) } // Some writes should be dropped (errors incremented) time.Sleep(50 * time.Millisecond) errors := hybrid.errors.Load() // May have errors from buffer overflow _ = errors } // RecordL2Error Tests func TestHybridBackend_RecordL2Error_EntersFallbackMode(t *testing.T) { primary := newMockBackend() secondary := newMockBackend() config := &HybridConfig{ Primary: primary, Secondary: secondary, } hybrid, err := NewHybridBackend(config) require.NoError(t, err) defer hybrid.Close() // Record error hybrid.recordL2Error() // Should have timestamp lastErr := hybrid.lastL2Error.Load() assert.NotNil(t, lastErr) // Record another error immediately (within 1 second) hybrid.recordL2Error() // Should enter fallback mode assert.True(t, hybrid.fallbackMode.Load()) } func TestHybridBackend_RecordL2Error_RequiresRepeatedErrors(t *testing.T) { config := &HybridConfig{ Primary: newMockBackend(), Secondary: newMockBackend(), } hybrid, err := NewHybridBackend(config) require.NoError(t, err) defer hybrid.Close() // A SINGLE transient L2 error must NOT flip the whole cache into fallback // mode (which bypasses L2 for reads and writes -> cache stampede). hybrid.recordL2Error() assert.False(t, hybrid.fallbackMode.Load(), "a single L2 error must not enter fallback mode") // A second error within the 1s window (repeated failures) does. hybrid.recordL2Error() assert.True(t, hybrid.fallbackMode.Load(), "repeated L2 errors within the window must enter fallback mode") }