Add cache mechanism to KorAP client
Change-Id: Ie3f3d48611f039904f22a19cf6299a3c43fe8bbe
diff --git a/service/cache_test.go b/service/cache_test.go
new file mode 100644
index 0000000..037f0b8
--- /dev/null
+++ b/service/cache_test.go
@@ -0,0 +1,443 @@
+package service
+
+import (
+ "context"
+ "testing"
+ "time"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func TestDefaultCacheConfig(t *testing.T) {
+ config := DefaultCacheConfig()
+
+ assert.True(t, config.Enabled)
+ assert.Equal(t, 5*time.Minute, config.DefaultTTL)
+ assert.Equal(t, 2*time.Minute, config.SearchTTL)
+ assert.Equal(t, 15*time.Minute, config.MetadataTTL)
+ assert.Equal(t, 1000, config.MaxSize)
+}
+
+func TestNewCache(t *testing.T) {
+ tests := []struct {
+ name string
+ config CacheConfig
+ expectError bool
+ expectNilCache bool
+ }{
+ {
+ name: "enabled cache",
+ config: DefaultCacheConfig(),
+ expectError: false,
+ expectNilCache: false,
+ },
+ {
+ name: "disabled cache",
+ config: CacheConfig{
+ Enabled: false,
+ },
+ expectError: false,
+ expectNilCache: true,
+ },
+ {
+ name: "custom configuration",
+ config: CacheConfig{
+ Enabled: true,
+ DefaultTTL: 1 * time.Minute,
+ SearchTTL: 30 * time.Second,
+ MetadataTTL: 5 * time.Minute,
+ MaxSize: 500,
+ },
+ expectError: false,
+ expectNilCache: false,
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ cache, err := NewCache(tt.config)
+
+ if tt.expectError {
+ assert.Error(t, err)
+ return
+ }
+
+ require.NoError(t, err)
+ assert.NotNil(t, cache)
+
+ if tt.expectNilCache {
+ assert.Nil(t, cache.cache)
+ } else {
+ assert.NotNil(t, cache.cache)
+ }
+
+ assert.Equal(t, tt.config, cache.config)
+ })
+ }
+}
+
+func TestCacheGetSet(t *testing.T) {
+ cache, err := NewCache(DefaultCacheConfig())
+ require.NoError(t, err)
+
+ ctx := context.Background()
+ key := "test-key"
+ data := []byte("test data")
+ ttl := 1 * time.Minute
+
+ // Test cache miss
+ result, found := cache.Get(ctx, key)
+ assert.False(t, found)
+ assert.Nil(t, result)
+
+ // Test cache set and hit
+ cache.Set(ctx, key, data, ttl)
+ result, found = cache.Get(ctx, key)
+ assert.True(t, found)
+ assert.Equal(t, data, result)
+}
+
+func TestCacheExpiry(t *testing.T) {
+ cache, err := NewCache(DefaultCacheConfig())
+ require.NoError(t, err)
+
+ ctx := context.Background()
+ key := "test-key"
+ data := []byte("test data")
+ ttl := 50 * time.Millisecond
+
+ // Set cache entry with short TTL
+ cache.Set(ctx, key, data, ttl)
+
+ // Should hit immediately
+ result, found := cache.Get(ctx, key)
+ assert.True(t, found)
+ assert.Equal(t, data, result)
+
+ // Wait for expiry
+ time.Sleep(100 * time.Millisecond)
+
+ // Should miss after expiry
+ result, found = cache.Get(ctx, key)
+ assert.False(t, found)
+ assert.Nil(t, result)
+}
+
+func TestCacheDelete(t *testing.T) {
+ cache, err := NewCache(DefaultCacheConfig())
+ require.NoError(t, err)
+
+ ctx := context.Background()
+ key := "test-key"
+ data := []byte("test data")
+ ttl := 1 * time.Minute
+
+ // Set and verify
+ cache.Set(ctx, key, data, ttl)
+ result, found := cache.Get(ctx, key)
+ assert.True(t, found)
+ assert.Equal(t, data, result)
+
+ // Delete and verify
+ cache.Delete(ctx, key)
+ result, found = cache.Get(ctx, key)
+ assert.False(t, found)
+ assert.Nil(t, result)
+}
+
+func TestCacheClear(t *testing.T) {
+ cache, err := NewCache(DefaultCacheConfig())
+ require.NoError(t, err)
+
+ ctx := context.Background()
+ ttl := 1 * time.Minute
+
+ // Set multiple entries
+ cache.Set(ctx, "key1", []byte("data1"), ttl)
+ cache.Set(ctx, "key2", []byte("data2"), ttl)
+ cache.Set(ctx, "key3", []byte("data3"), ttl)
+
+ // Verify all entries exist
+ _, found1 := cache.Get(ctx, "key1")
+ _, found2 := cache.Get(ctx, "key2")
+ _, found3 := cache.Get(ctx, "key3")
+ assert.True(t, found1)
+ assert.True(t, found2)
+ assert.True(t, found3)
+
+ // Clear cache
+ cache.Clear()
+
+ // Verify all entries are gone
+ _, found1 = cache.Get(ctx, "key1")
+ _, found2 = cache.Get(ctx, "key2")
+ _, found3 = cache.Get(ctx, "key3")
+ assert.False(t, found1)
+ assert.False(t, found2)
+ assert.False(t, found3)
+}
+
+func TestCacheStats(t *testing.T) {
+ cache, err := NewCache(DefaultCacheConfig())
+ require.NoError(t, err)
+
+ ctx := context.Background()
+
+ // Test stats with empty cache
+ stats := cache.Stats()
+ assert.True(t, stats["enabled"].(bool))
+ assert.Equal(t, 0, stats["size"].(int))
+
+ // Add some data and check stats
+ cache.Set(ctx, "key1", []byte("data1"), 1*time.Minute)
+ cache.Set(ctx, "key2", []byte("data2"), 1*time.Minute)
+
+ stats = cache.Stats()
+ assert.True(t, stats["enabled"].(bool))
+ assert.Equal(t, 2, stats["size"].(int))
+ assert.Equal(t, 1000, stats["max_size"].(int))
+ assert.Contains(t, stats, "hits")
+ assert.Contains(t, stats, "misses")
+ assert.Contains(t, stats, "hit_ratio")
+}
+
+func TestCacheStatsDisabled(t *testing.T) {
+ config := CacheConfig{Enabled: false}
+ cache, err := NewCache(config)
+ require.NoError(t, err)
+
+ stats := cache.Stats()
+ assert.False(t, stats["enabled"].(bool))
+ assert.Len(t, stats, 1) // Only "enabled" key should be present
+}
+
+func TestCacheDisabled(t *testing.T) {
+ config := CacheConfig{Enabled: false}
+ cache, err := NewCache(config)
+ require.NoError(t, err)
+
+ ctx := context.Background()
+ key := "test-key"
+ data := []byte("test data")
+ ttl := 1 * time.Minute
+
+ // All operations should be no-ops
+ cache.Set(ctx, key, data, ttl)
+ result, found := cache.Get(ctx, key)
+ assert.False(t, found)
+ assert.Nil(t, result)
+
+ cache.Delete(ctx, key)
+ cache.Clear()
+
+ // Should not panic
+ assert.NotPanics(t, func() {
+ cache.Close()
+ })
+}
+
+func TestGenerateCacheKey(t *testing.T) {
+ cache, err := NewCache(DefaultCacheConfig())
+ require.NoError(t, err)
+
+ tests := []struct {
+ name string
+ method string
+ endpoint string
+ params map[string]any
+ wantSame bool
+ }{
+ {
+ name: "same parameters",
+ method: "GET",
+ endpoint: "/search",
+ params: map[string]any{"q": "test", "count": 10},
+ wantSame: true,
+ },
+ {
+ name: "different method",
+ method: "POST",
+ endpoint: "/search",
+ params: map[string]any{"q": "test", "count": 10},
+ wantSame: false,
+ },
+ {
+ name: "different endpoint",
+ method: "GET",
+ endpoint: "/corpus",
+ params: map[string]any{"q": "test", "count": 10},
+ wantSame: false,
+ },
+ {
+ name: "different parameters",
+ method: "GET",
+ endpoint: "/search",
+ params: map[string]any{"q": "different", "count": 10},
+ wantSame: false,
+ },
+ {
+ name: "nil parameters",
+ method: "GET",
+ endpoint: "/search",
+ params: nil,
+ wantSame: false,
+ },
+ }
+
+ baseKey := cache.generateCacheKey("GET", "/search", map[string]any{"q": "test", "count": 10})
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ key := cache.generateCacheKey(tt.method, tt.endpoint, tt.params)
+
+ assert.NotEmpty(t, key)
+ assert.Len(t, key, 32) // MD5 hash length
+
+ if tt.wantSame {
+ assert.Equal(t, baseKey, key)
+ } else {
+ assert.NotEqual(t, baseKey, key)
+ }
+ })
+ }
+}
+
+func TestCacheKeyDeterministic(t *testing.T) {
+ cache, err := NewCache(DefaultCacheConfig())
+ require.NoError(t, err)
+
+ // Test that map parameter order doesn't affect cache key generation
+ // This verifies that json.Marshal provides deterministic ordering
+ params1 := map[string]any{
+ "query": "test search",
+ "count": 50,
+ "offset": 0,
+ "corpus": "news-corpus",
+ "language": "poliqarp",
+ }
+
+ params2 := map[string]any{
+ "language": "poliqarp",
+ "corpus": "news-corpus",
+ "offset": 0,
+ "count": 50,
+ "query": "test search",
+ }
+
+ // Same parameters in different map creation order should produce same cache key
+ key1 := cache.generateCacheKey("GET", "/search", params1)
+ key2 := cache.generateCacheKey("GET", "/search", params2)
+
+ assert.Equal(t, key1, key2, "Cache keys should be identical regardless of map parameter order")
+ assert.Len(t, key1, 32, "Cache key should be MD5 hash length")
+ assert.Len(t, key2, 32, "Cache key should be MD5 hash length")
+
+ // Generate keys multiple times to ensure consistency
+ for i := 0; i < 10; i++ {
+ keyN := cache.generateCacheKey("GET", "/search", params1)
+ assert.Equal(t, key1, keyN, "Cache key should be consistent across multiple generations")
+ }
+}
+
+func TestGetTTLForEndpoint(t *testing.T) {
+ config := DefaultCacheConfig()
+ cache, err := NewCache(config)
+ require.NoError(t, err)
+
+ tests := []struct {
+ name string
+ endpoint string
+ expected time.Duration
+ }{
+ {
+ name: "search endpoint",
+ endpoint: "/api/v1/search",
+ expected: config.SearchTTL,
+ },
+ {
+ name: "query endpoint",
+ endpoint: "/query",
+ expected: config.SearchTTL,
+ },
+ {
+ name: "corpus endpoint",
+ endpoint: "/corpus",
+ expected: config.MetadataTTL,
+ },
+ {
+ name: "metadata endpoint",
+ endpoint: "/metadata",
+ expected: config.MetadataTTL,
+ },
+ {
+ name: "statistics endpoint",
+ endpoint: "/statistics",
+ expected: config.MetadataTTL,
+ },
+ {
+ name: "info endpoint",
+ endpoint: "/info",
+ expected: config.MetadataTTL,
+ },
+ {
+ name: "other endpoint",
+ endpoint: "/other",
+ expected: config.DefaultTTL,
+ },
+ {
+ name: "case insensitive",
+ endpoint: "/API/V1/SEARCH",
+ expected: config.SearchTTL,
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ ttl := cache.GetTTLForEndpoint(tt.endpoint)
+ assert.Equal(t, tt.expected, ttl)
+ })
+ }
+}
+
+func TestCacheEntry(t *testing.T) {
+ t.Run("not expired", func(t *testing.T) {
+ entry := &CacheEntry{
+ Data: []byte("test"),
+ Timestamp: time.Now(),
+ TTL: 1 * time.Minute,
+ }
+
+ assert.False(t, entry.IsExpired())
+ })
+
+ t.Run("expired", func(t *testing.T) {
+ entry := &CacheEntry{
+ Data: []byte("test"),
+ Timestamp: time.Now().Add(-2 * time.Minute),
+ TTL: 1 * time.Minute,
+ }
+
+ assert.True(t, entry.IsExpired())
+ })
+}
+
+func TestCacheClose(t *testing.T) {
+ cache, err := NewCache(DefaultCacheConfig())
+ require.NoError(t, err)
+
+ // Add some data
+ ctx := context.Background()
+ cache.Set(ctx, "key1", []byte("data1"), 1*time.Minute)
+
+ // Close should not error
+ err = cache.Close()
+ assert.NoError(t, err)
+
+ // Disabled cache close should also not error
+ disabledCache, err := NewCache(CacheConfig{Enabled: false})
+ require.NoError(t, err)
+
+ err = disabledCache.Close()
+ assert.NoError(t, err)
+}