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