package cache import ( "context" "testing" "time" "github.com/router-for-me/CLIProxyAPI/v6/internal/config" ) func defaultCacheConfig() config.ResponseCacheConfig { return config.ResponseCacheConfig{ Enabled: true, TTLSeconds: 300, MaxMemoryEntries: 100, MaxResponseSizeKB: 512, ExcludeStreaming: true, RedisKeyPrefix: "test", } } func TestMemoryResponseCache_SetGet(t *testing.T) { cfg := defaultCacheConfig() c := newMemoryResponseCache(cfg) defer c.Close() ctx := context.Background() resp := &CachedResponse{ StatusCode: 200, Body: []byte(`{"result":"ok"}`), Model: "test-model", } if err := c.Set(ctx, "key1", resp, 5*time.Minute); err != nil { t.Fatalf("Set failed: %v", err) } got, ok := c.Get(ctx, "key1") if !ok { t.Fatal("expected cache hit") } if got.StatusCode != 200 { t.Fatalf("expected status 200, got %d", got.StatusCode) } if string(got.Body) != `{"result":"ok"}` { t.Fatalf("unexpected body: %s", got.Body) } } func TestMemoryResponseCache_Miss(t *testing.T) { cfg := defaultCacheConfig() c := newMemoryResponseCache(cfg) defer c.Close() _, ok := c.Get(context.Background(), "nonexistent") if ok { t.Fatal("expected cache miss") } } func TestMemoryResponseCache_Expiry(t *testing.T) { cfg := defaultCacheConfig() c := newMemoryResponseCache(cfg) defer c.Close() ctx := context.Background() resp := &CachedResponse{StatusCode: 200, Body: []byte("test")} if err := c.Set(ctx, "expiring", resp, 1*time.Millisecond); err != nil { t.Fatalf("Set failed: %v", err) } time.Sleep(5 * time.Millisecond) _, ok := c.Get(ctx, "expiring") if ok { t.Fatal("expected cache miss after TTL expiry") } } func TestMemoryResponseCache_Eviction(t *testing.T) { cfg := defaultCacheConfig() cfg.MaxMemoryEntries = 2 c := newMemoryResponseCache(cfg) defer c.Close() ctx := context.Background() resp := &CachedResponse{StatusCode: 200, Body: []byte("data")} c.Set(ctx, "a", resp, time.Hour) c.Set(ctx, "b", resp, time.Hour) c.Set(ctx, "c", resp, time.Hour) // should evict "a" _, ok := c.Get(ctx, "a") if ok { t.Fatal("expected 'a' to be evicted") } _, ok = c.Get(ctx, "c") if !ok { t.Fatal("expected 'c' to be present") } } func TestMemoryResponseCache_Stats(t *testing.T) { cfg := defaultCacheConfig() c := newMemoryResponseCache(cfg) defer c.Close() ctx := context.Background() c.Get(ctx, "miss1") // miss c.Get(ctx, "miss2") // miss resp := &CachedResponse{StatusCode: 200, Body: []byte("x")} c.Set(ctx, "hit", resp, time.Hour) c.Get(ctx, "hit") // hit stats := c.Stats() if stats.Hits != 1 { t.Fatalf("expected 1 hit, got %d", stats.Hits) } if stats.Misses != 2 { t.Fatalf("expected 2 misses, got %d", stats.Misses) } if stats.Size != 1 { t.Fatalf("expected size 1, got %d", stats.Size) } } func TestMemoryResponseCache_Delete(t *testing.T) { cfg := defaultCacheConfig() c := newMemoryResponseCache(cfg) defer c.Close() ctx := context.Background() resp := &CachedResponse{StatusCode: 200, Body: []byte("x")} c.Set(ctx, "k1", resp, time.Hour) c.Set(ctx, "k2", resp, time.Hour) c.Delete(ctx, "*") _, ok1 := c.Get(ctx, "k1") _, ok2 := c.Get(ctx, "k2") if ok1 || ok2 { t.Fatal("expected all entries deleted") } } func TestResponseCacheKey(t *testing.T) { k1 := ResponseCacheKey("model-a", []byte("body1")) k2 := ResponseCacheKey("model-a", []byte("body1")) k3 := ResponseCacheKey("model-b", []byte("body1")) if k1 != k2 { t.Fatal("same inputs should produce same key") } if k1 == k3 { t.Fatal("different models should produce different keys") } } func TestShouldCache(t *testing.T) { cfg := defaultCacheConfig() if !ShouldCache(cfg, "gemini-pro", false, 1024) { t.Fatal("should cache normal response") } if ShouldCache(cfg, "gemini-pro", true, 1024) { t.Fatal("should not cache streaming when exclude-streaming=true") } // Exceeded max response size if ShouldCache(cfg, "gemini-pro", false, 1024*1024) { t.Fatal("should not cache oversized response") } // Disabled config disabledCfg := cfg disabledCfg.Enabled = false if ShouldCache(disabledCfg, "gemini-pro", false, 1024) { t.Fatal("should not cache when disabled") } // Excluded model cfg.ExcludeModels = []string{"*-preview"} if ShouldCache(cfg, "gemini-pro-preview", false, 1024) { t.Fatal("should not cache excluded model") } } func TestNewResponseCache_MemoryFallback(t *testing.T) { cfg := defaultCacheConfig() c := NewResponseCache(cfg, nil) defer c.Close() ctx := context.Background() resp := &CachedResponse{StatusCode: 200, Body: []byte("test")} if err := c.Set(ctx, "k", resp, time.Minute); err != nil { t.Fatalf("Set failed: %v", err) } got, ok := c.Get(ctx, "k") if !ok || got.StatusCode != 200 { t.Fatal("expected cache hit with status 200") } } func BenchmarkMemoryResponseCache_SetGet(b *testing.B) { cfg := defaultCacheConfig() cfg.MaxMemoryEntries = 100000 c := newMemoryResponseCache(cfg) defer c.Close() ctx := context.Background() resp := &CachedResponse{StatusCode: 200, Body: []byte(`{"choices":[{"message":{"content":"hello"}}]}`)} b.ResetTimer() for i := 0; i < b.N; i++ { key := ResponseCacheKey("bench-model", []byte(`{"model":"bench-model","messages":[{"role":"user","content":"hi"}]}`)) c.Set(ctx, key, resp, time.Hour) c.Get(ctx, key) } } func BenchmarkResponseCacheKey(b *testing.B) { body := []byte(`{"model":"gemini-2.5-pro","messages":[{"role":"user","content":"explain quantum computing"}]}`) b.ResetTimer() for i := 0; i < b.N; i++ { ResponseCacheKey("gemini-2.5-pro", body) } }