| package service |
| |
| import ( |
| "context" |
| "testing" |
| "time" |
| |
| "github.com/korap/korap-mcp/config" |
| "github.com/stretchr/testify/assert" |
| "github.com/stretchr/testify/require" |
| ) |
| |
| func TestDefaultCacheConfig(t *testing.T) { |
| cacheConfig := config.DefaultCacheConfig() |
| |
| assert.True(t, cacheConfig.Enabled) |
| assert.Equal(t, "5m", cacheConfig.DefaultTTL) |
| assert.Equal(t, "2m", cacheConfig.SearchTTL) |
| assert.Equal(t, "15m", cacheConfig.MetadataTTL) |
| assert.Equal(t, 1000, cacheConfig.MaxSize) |
| } |
| |
| func TestNewCache(t *testing.T) { |
| tests := []struct { |
| name string |
| config *config.CacheConfig |
| expectError bool |
| expectNilCache bool |
| }{ |
| { |
| name: "enabled cache", |
| config: config.DefaultCacheConfig(), |
| expectError: false, |
| expectNilCache: false, |
| }, |
| { |
| name: "disabled cache", |
| config: &config.CacheConfig{ |
| Enabled: false, |
| }, |
| expectError: false, |
| expectNilCache: true, |
| }, |
| { |
| name: "custom configuration", |
| config: &config.CacheConfig{ |
| Enabled: true, |
| DefaultTTL: "1m", |
| SearchTTL: "30s", |
| MetadataTTL: "5m", |
| 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(config.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(config.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(config.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(config.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(config.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) { |
| cacheConfig := &config.CacheConfig{Enabled: false} |
| cache, err := NewCache(cacheConfig) |
| 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) { |
| cacheConfig := &config.CacheConfig{Enabled: false} |
| cache, err := NewCache(cacheConfig) |
| 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(config.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(config.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) { |
| cacheConfig := config.DefaultCacheConfig() |
| cache, err := NewCache(cacheConfig) |
| require.NoError(t, err) |
| |
| tests := []struct { |
| name string |
| endpoint string |
| expected time.Duration |
| }{ |
| { |
| name: "search endpoint", |
| endpoint: "/api/v1/search", |
| expected: cacheConfig.GetSearchTTL(), |
| }, |
| { |
| name: "query endpoint", |
| endpoint: "/query", |
| expected: cacheConfig.GetSearchTTL(), |
| }, |
| { |
| name: "corpus endpoint", |
| endpoint: "/corpus", |
| expected: cacheConfig.GetMetadataTTL(), |
| }, |
| { |
| name: "metadata endpoint", |
| endpoint: "/metadata", |
| expected: cacheConfig.GetMetadataTTL(), |
| }, |
| { |
| name: "statistics endpoint", |
| endpoint: "/statistics", |
| expected: cacheConfig.GetMetadataTTL(), |
| }, |
| { |
| name: "info endpoint", |
| endpoint: "/info", |
| expected: cacheConfig.GetMetadataTTL(), |
| }, |
| { |
| name: "other endpoint", |
| endpoint: "/other", |
| expected: cacheConfig.GetDefaultTTL(), |
| }, |
| { |
| name: "case insensitive", |
| endpoint: "/API/V1/SEARCH", |
| expected: cacheConfig.GetSearchTTL(), |
| }, |
| } |
| |
| 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(config.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(&config.CacheConfig{Enabled: false}) |
| require.NoError(t, err) |
| |
| err = disabledCache.Close() |
| assert.NoError(t, err) |
| } |