blob: 928866087d167939e3f2829736022e72f2a3153b [file] [log] [blame]
Akron16343052025-06-17 16:16:13 +02001package service
2
3import (
4 "context"
5 "testing"
6 "time"
7
Akrone73bc912025-06-17 16:36:45 +02008 "github.com/korap/korap-mcp/config"
Akron16343052025-06-17 16:16:13 +02009 "github.com/stretchr/testify/assert"
10 "github.com/stretchr/testify/require"
11)
12
13func TestDefaultCacheConfig(t *testing.T) {
Akrone73bc912025-06-17 16:36:45 +020014 cacheConfig := config.DefaultCacheConfig()
Akron16343052025-06-17 16:16:13 +020015
Akrone73bc912025-06-17 16:36:45 +020016 assert.True(t, cacheConfig.Enabled)
17 assert.Equal(t, "5m", cacheConfig.DefaultTTL)
18 assert.Equal(t, "2m", cacheConfig.SearchTTL)
19 assert.Equal(t, "15m", cacheConfig.MetadataTTL)
20 assert.Equal(t, 1000, cacheConfig.MaxSize)
Akron16343052025-06-17 16:16:13 +020021}
22
23func TestNewCache(t *testing.T) {
24 tests := []struct {
25 name string
Akrone73bc912025-06-17 16:36:45 +020026 config *config.CacheConfig
Akron16343052025-06-17 16:16:13 +020027 expectError bool
28 expectNilCache bool
29 }{
30 {
31 name: "enabled cache",
Akrone73bc912025-06-17 16:36:45 +020032 config: config.DefaultCacheConfig(),
Akron16343052025-06-17 16:16:13 +020033 expectError: false,
34 expectNilCache: false,
35 },
36 {
37 name: "disabled cache",
Akrone73bc912025-06-17 16:36:45 +020038 config: &config.CacheConfig{
Akron16343052025-06-17 16:16:13 +020039 Enabled: false,
40 },
41 expectError: false,
42 expectNilCache: true,
43 },
44 {
45 name: "custom configuration",
Akrone73bc912025-06-17 16:36:45 +020046 config: &config.CacheConfig{
Akron16343052025-06-17 16:16:13 +020047 Enabled: true,
Akrone73bc912025-06-17 16:36:45 +020048 DefaultTTL: "1m",
49 SearchTTL: "30s",
50 MetadataTTL: "5m",
Akron16343052025-06-17 16:16:13 +020051 MaxSize: 500,
52 },
53 expectError: false,
54 expectNilCache: false,
55 },
56 }
57
58 for _, tt := range tests {
59 t.Run(tt.name, func(t *testing.T) {
60 cache, err := NewCache(tt.config)
61
62 if tt.expectError {
63 assert.Error(t, err)
64 return
65 }
66
67 require.NoError(t, err)
68 assert.NotNil(t, cache)
69
70 if tt.expectNilCache {
71 assert.Nil(t, cache.cache)
72 } else {
73 assert.NotNil(t, cache.cache)
74 }
75
76 assert.Equal(t, tt.config, cache.config)
77 })
78 }
79}
80
81func TestCacheGetSet(t *testing.T) {
Akrone73bc912025-06-17 16:36:45 +020082 cache, err := NewCache(config.DefaultCacheConfig())
Akron16343052025-06-17 16:16:13 +020083 require.NoError(t, err)
84
85 ctx := context.Background()
86 key := "test-key"
87 data := []byte("test data")
88 ttl := 1 * time.Minute
89
90 // Test cache miss
91 result, found := cache.Get(ctx, key)
92 assert.False(t, found)
93 assert.Nil(t, result)
94
95 // Test cache set and hit
96 cache.Set(ctx, key, data, ttl)
97 result, found = cache.Get(ctx, key)
98 assert.True(t, found)
99 assert.Equal(t, data, result)
100}
101
102func TestCacheExpiry(t *testing.T) {
Akrone73bc912025-06-17 16:36:45 +0200103 cache, err := NewCache(config.DefaultCacheConfig())
Akron16343052025-06-17 16:16:13 +0200104 require.NoError(t, err)
105
106 ctx := context.Background()
107 key := "test-key"
108 data := []byte("test data")
109 ttl := 50 * time.Millisecond
110
111 // Set cache entry with short TTL
112 cache.Set(ctx, key, data, ttl)
113
114 // Should hit immediately
115 result, found := cache.Get(ctx, key)
116 assert.True(t, found)
117 assert.Equal(t, data, result)
118
119 // Wait for expiry
120 time.Sleep(100 * time.Millisecond)
121
122 // Should miss after expiry
123 result, found = cache.Get(ctx, key)
124 assert.False(t, found)
125 assert.Nil(t, result)
126}
127
128func TestCacheDelete(t *testing.T) {
Akrone73bc912025-06-17 16:36:45 +0200129 cache, err := NewCache(config.DefaultCacheConfig())
Akron16343052025-06-17 16:16:13 +0200130 require.NoError(t, err)
131
132 ctx := context.Background()
133 key := "test-key"
134 data := []byte("test data")
135 ttl := 1 * time.Minute
136
137 // Set and verify
138 cache.Set(ctx, key, data, ttl)
139 result, found := cache.Get(ctx, key)
140 assert.True(t, found)
141 assert.Equal(t, data, result)
142
143 // Delete and verify
144 cache.Delete(ctx, key)
145 result, found = cache.Get(ctx, key)
146 assert.False(t, found)
147 assert.Nil(t, result)
148}
149
150func TestCacheClear(t *testing.T) {
Akrone73bc912025-06-17 16:36:45 +0200151 cache, err := NewCache(config.DefaultCacheConfig())
Akron16343052025-06-17 16:16:13 +0200152 require.NoError(t, err)
153
154 ctx := context.Background()
155 ttl := 1 * time.Minute
156
157 // Set multiple entries
158 cache.Set(ctx, "key1", []byte("data1"), ttl)
159 cache.Set(ctx, "key2", []byte("data2"), ttl)
160 cache.Set(ctx, "key3", []byte("data3"), ttl)
161
162 // Verify all entries exist
163 _, found1 := cache.Get(ctx, "key1")
164 _, found2 := cache.Get(ctx, "key2")
165 _, found3 := cache.Get(ctx, "key3")
166 assert.True(t, found1)
167 assert.True(t, found2)
168 assert.True(t, found3)
169
170 // Clear cache
171 cache.Clear()
172
173 // Verify all entries are gone
174 _, found1 = cache.Get(ctx, "key1")
175 _, found2 = cache.Get(ctx, "key2")
176 _, found3 = cache.Get(ctx, "key3")
177 assert.False(t, found1)
178 assert.False(t, found2)
179 assert.False(t, found3)
180}
181
182func TestCacheStats(t *testing.T) {
Akrone73bc912025-06-17 16:36:45 +0200183 cache, err := NewCache(config.DefaultCacheConfig())
Akron16343052025-06-17 16:16:13 +0200184 require.NoError(t, err)
185
186 ctx := context.Background()
187
188 // Test stats with empty cache
189 stats := cache.Stats()
190 assert.True(t, stats["enabled"].(bool))
191 assert.Equal(t, 0, stats["size"].(int))
192
193 // Add some data and check stats
194 cache.Set(ctx, "key1", []byte("data1"), 1*time.Minute)
195 cache.Set(ctx, "key2", []byte("data2"), 1*time.Minute)
196
197 stats = cache.Stats()
198 assert.True(t, stats["enabled"].(bool))
199 assert.Equal(t, 2, stats["size"].(int))
200 assert.Equal(t, 1000, stats["max_size"].(int))
201 assert.Contains(t, stats, "hits")
202 assert.Contains(t, stats, "misses")
203 assert.Contains(t, stats, "hit_ratio")
204}
205
206func TestCacheStatsDisabled(t *testing.T) {
Akrone73bc912025-06-17 16:36:45 +0200207 cacheConfig := &config.CacheConfig{Enabled: false}
208 cache, err := NewCache(cacheConfig)
Akron16343052025-06-17 16:16:13 +0200209 require.NoError(t, err)
210
211 stats := cache.Stats()
212 assert.False(t, stats["enabled"].(bool))
213 assert.Len(t, stats, 1) // Only "enabled" key should be present
214}
215
216func TestCacheDisabled(t *testing.T) {
Akrone73bc912025-06-17 16:36:45 +0200217 cacheConfig := &config.CacheConfig{Enabled: false}
218 cache, err := NewCache(cacheConfig)
Akron16343052025-06-17 16:16:13 +0200219 require.NoError(t, err)
220
221 ctx := context.Background()
222 key := "test-key"
223 data := []byte("test data")
224 ttl := 1 * time.Minute
225
226 // All operations should be no-ops
227 cache.Set(ctx, key, data, ttl)
228 result, found := cache.Get(ctx, key)
229 assert.False(t, found)
230 assert.Nil(t, result)
231
232 cache.Delete(ctx, key)
233 cache.Clear()
234
235 // Should not panic
236 assert.NotPanics(t, func() {
237 cache.Close()
238 })
239}
240
241func TestGenerateCacheKey(t *testing.T) {
Akrone73bc912025-06-17 16:36:45 +0200242 cache, err := NewCache(config.DefaultCacheConfig())
Akron16343052025-06-17 16:16:13 +0200243 require.NoError(t, err)
244
245 tests := []struct {
246 name string
247 method string
248 endpoint string
249 params map[string]any
250 wantSame bool
251 }{
252 {
253 name: "same parameters",
254 method: "GET",
255 endpoint: "/search",
256 params: map[string]any{"q": "test", "count": 10},
257 wantSame: true,
258 },
259 {
260 name: "different method",
261 method: "POST",
262 endpoint: "/search",
263 params: map[string]any{"q": "test", "count": 10},
264 wantSame: false,
265 },
266 {
267 name: "different endpoint",
268 method: "GET",
269 endpoint: "/corpus",
270 params: map[string]any{"q": "test", "count": 10},
271 wantSame: false,
272 },
273 {
274 name: "different parameters",
275 method: "GET",
276 endpoint: "/search",
277 params: map[string]any{"q": "different", "count": 10},
278 wantSame: false,
279 },
280 {
281 name: "nil parameters",
282 method: "GET",
283 endpoint: "/search",
284 params: nil,
285 wantSame: false,
286 },
287 }
288
289 baseKey := cache.generateCacheKey("GET", "/search", map[string]any{"q": "test", "count": 10})
290
291 for _, tt := range tests {
292 t.Run(tt.name, func(t *testing.T) {
293 key := cache.generateCacheKey(tt.method, tt.endpoint, tt.params)
294
295 assert.NotEmpty(t, key)
296 assert.Len(t, key, 32) // MD5 hash length
297
298 if tt.wantSame {
299 assert.Equal(t, baseKey, key)
300 } else {
301 assert.NotEqual(t, baseKey, key)
302 }
303 })
304 }
305}
306
307func TestCacheKeyDeterministic(t *testing.T) {
Akrone73bc912025-06-17 16:36:45 +0200308 cache, err := NewCache(config.DefaultCacheConfig())
Akron16343052025-06-17 16:16:13 +0200309 require.NoError(t, err)
310
311 // Test that map parameter order doesn't affect cache key generation
312 // This verifies that json.Marshal provides deterministic ordering
313 params1 := map[string]any{
314 "query": "test search",
315 "count": 50,
316 "offset": 0,
317 "corpus": "news-corpus",
318 "language": "poliqarp",
319 }
320
321 params2 := map[string]any{
322 "language": "poliqarp",
323 "corpus": "news-corpus",
324 "offset": 0,
325 "count": 50,
326 "query": "test search",
327 }
328
329 // Same parameters in different map creation order should produce same cache key
330 key1 := cache.generateCacheKey("GET", "/search", params1)
331 key2 := cache.generateCacheKey("GET", "/search", params2)
332
333 assert.Equal(t, key1, key2, "Cache keys should be identical regardless of map parameter order")
334 assert.Len(t, key1, 32, "Cache key should be MD5 hash length")
335 assert.Len(t, key2, 32, "Cache key should be MD5 hash length")
336
337 // Generate keys multiple times to ensure consistency
338 for i := 0; i < 10; i++ {
339 keyN := cache.generateCacheKey("GET", "/search", params1)
340 assert.Equal(t, key1, keyN, "Cache key should be consistent across multiple generations")
341 }
342}
343
344func TestGetTTLForEndpoint(t *testing.T) {
Akrone73bc912025-06-17 16:36:45 +0200345 cacheConfig := config.DefaultCacheConfig()
346 cache, err := NewCache(cacheConfig)
Akron16343052025-06-17 16:16:13 +0200347 require.NoError(t, err)
348
349 tests := []struct {
350 name string
351 endpoint string
352 expected time.Duration
353 }{
354 {
355 name: "search endpoint",
356 endpoint: "/api/v1/search",
Akrone73bc912025-06-17 16:36:45 +0200357 expected: cacheConfig.GetSearchTTL(),
Akron16343052025-06-17 16:16:13 +0200358 },
359 {
360 name: "query endpoint",
361 endpoint: "/query",
Akrone73bc912025-06-17 16:36:45 +0200362 expected: cacheConfig.GetSearchTTL(),
Akron16343052025-06-17 16:16:13 +0200363 },
364 {
365 name: "corpus endpoint",
366 endpoint: "/corpus",
Akrone73bc912025-06-17 16:36:45 +0200367 expected: cacheConfig.GetMetadataTTL(),
Akron16343052025-06-17 16:16:13 +0200368 },
369 {
370 name: "metadata endpoint",
371 endpoint: "/metadata",
Akrone73bc912025-06-17 16:36:45 +0200372 expected: cacheConfig.GetMetadataTTL(),
Akron16343052025-06-17 16:16:13 +0200373 },
374 {
375 name: "statistics endpoint",
376 endpoint: "/statistics",
Akrone73bc912025-06-17 16:36:45 +0200377 expected: cacheConfig.GetMetadataTTL(),
Akron16343052025-06-17 16:16:13 +0200378 },
379 {
380 name: "info endpoint",
381 endpoint: "/info",
Akrone73bc912025-06-17 16:36:45 +0200382 expected: cacheConfig.GetMetadataTTL(),
Akron16343052025-06-17 16:16:13 +0200383 },
384 {
385 name: "other endpoint",
386 endpoint: "/other",
Akrone73bc912025-06-17 16:36:45 +0200387 expected: cacheConfig.GetDefaultTTL(),
Akron16343052025-06-17 16:16:13 +0200388 },
389 {
390 name: "case insensitive",
391 endpoint: "/API/V1/SEARCH",
Akrone73bc912025-06-17 16:36:45 +0200392 expected: cacheConfig.GetSearchTTL(),
Akron16343052025-06-17 16:16:13 +0200393 },
394 }
395
396 for _, tt := range tests {
397 t.Run(tt.name, func(t *testing.T) {
398 ttl := cache.GetTTLForEndpoint(tt.endpoint)
399 assert.Equal(t, tt.expected, ttl)
400 })
401 }
402}
403
404func TestCacheEntry(t *testing.T) {
405 t.Run("not expired", func(t *testing.T) {
406 entry := &CacheEntry{
407 Data: []byte("test"),
408 Timestamp: time.Now(),
409 TTL: 1 * time.Minute,
410 }
411
412 assert.False(t, entry.IsExpired())
413 })
414
415 t.Run("expired", func(t *testing.T) {
416 entry := &CacheEntry{
417 Data: []byte("test"),
418 Timestamp: time.Now().Add(-2 * time.Minute),
419 TTL: 1 * time.Minute,
420 }
421
422 assert.True(t, entry.IsExpired())
423 })
424}
425
426func TestCacheClose(t *testing.T) {
Akrone73bc912025-06-17 16:36:45 +0200427 cache, err := NewCache(config.DefaultCacheConfig())
Akron16343052025-06-17 16:16:13 +0200428 require.NoError(t, err)
429
430 // Add some data
431 ctx := context.Background()
432 cache.Set(ctx, "key1", []byte("data1"), 1*time.Minute)
433
434 // Close should not error
435 err = cache.Close()
436 assert.NoError(t, err)
437
438 // Disabled cache close should also not error
Akrone73bc912025-06-17 16:36:45 +0200439 disabledCache, err := NewCache(&config.CacheConfig{Enabled: false})
Akron16343052025-06-17 16:16:13 +0200440 require.NoError(t, err)
441
442 err = disabledCache.Close()
443 assert.NoError(t, err)
444}