A community based topic aggregation platform built on atproto
1package imageproxy
2
3import (
4 "errors"
5 "os"
6 "path/filepath"
7 "testing"
8 "time"
9)
10
11// mustNewDiskCache is a test helper that creates a DiskCache or fails the test
12// Uses 0 for TTL (disabled) by default for backward compatibility
13func mustNewDiskCache(t *testing.T, basePath string, maxSizeGB int) *DiskCache {
14 t.Helper()
15 cache, err := NewDiskCache(basePath, maxSizeGB, 0)
16 if err != nil {
17 t.Fatalf("NewDiskCache failed: %v", err)
18 }
19 return cache
20}
21
22func TestDiskCache_SetAndGet(t *testing.T) {
23 // Create a temporary directory for the cache
24 tmpDir := t.TempDir()
25
26 cache := mustNewDiskCache(t, tmpDir, 1)
27
28 testData := []byte("test image data")
29 preset := "thumb"
30 did := "did:plc:abc123"
31 cid := "bafyreiabc123"
32
33 // Set the data
34 err := cache.Set(preset, did, cid, testData)
35 if err != nil {
36 t.Fatalf("Set failed: %v", err)
37 }
38
39 // Get the data back
40 data, found, err := cache.Get(preset, did, cid)
41 if err != nil {
42 t.Fatalf("Get failed: %v", err)
43 }
44 if !found {
45 t.Fatal("Expected data to be found in cache")
46 }
47 if string(data) != string(testData) {
48 t.Errorf("Get returned %q, want %q", string(data), string(testData))
49 }
50}
51
52func TestDiskCache_GetMissingKey(t *testing.T) {
53 tmpDir := t.TempDir()
54 cache := mustNewDiskCache(t, tmpDir, 1)
55
56 data, found, err := cache.Get("thumb", "did:plc:notexist", "bafynotexist")
57 if err != nil {
58 t.Fatalf("Get should not error for missing key: %v", err)
59 }
60 if found {
61 t.Error("Expected found to be false for missing key")
62 }
63 if data != nil {
64 t.Error("Expected data to be nil for missing key")
65 }
66}
67
68func TestDiskCache_Delete(t *testing.T) {
69 tmpDir := t.TempDir()
70 cache := mustNewDiskCache(t, tmpDir, 1)
71
72 testData := []byte("data to delete")
73 preset := "medium"
74 did := "did:plc:todelete"
75 cid := "bafyreitodelete"
76
77 // Set data
78 err := cache.Set(preset, did, cid, testData)
79 if err != nil {
80 t.Fatalf("Set failed: %v", err)
81 }
82
83 // Verify it exists
84 _, found, _ := cache.Get(preset, did, cid)
85 if !found {
86 t.Fatal("Expected data to exist before delete")
87 }
88
89 // Delete
90 err = cache.Delete(preset, did, cid)
91 if err != nil {
92 t.Fatalf("Delete failed: %v", err)
93 }
94
95 // Verify it's gone
96 _, found, _ = cache.Get(preset, did, cid)
97 if found {
98 t.Error("Expected data to be gone after delete")
99 }
100}
101
102func TestDiskCache_DeleteNonExistent(t *testing.T) {
103 tmpDir := t.TempDir()
104 cache := mustNewDiskCache(t, tmpDir, 1)
105
106 // Deleting a non-existent key should not error
107 err := cache.Delete("thumb", "did:plc:notexist", "bafynotexist")
108 if err != nil {
109 t.Errorf("Delete of non-existent key should not error: %v", err)
110 }
111}
112
113func TestDiskCache_PathConstruction(t *testing.T) {
114 tmpDir := t.TempDir()
115 cache := mustNewDiskCache(t, tmpDir, 1)
116
117 testData := []byte("path test data")
118 preset := "thumb"
119 did := "did:plc:abc123"
120 cid := "bafyreiabc123"
121
122 err := cache.Set(preset, did, cid, testData)
123 if err != nil {
124 t.Fatalf("Set failed: %v", err)
125 }
126
127 // Verify the path structure: {basePath}/{preset}/{did_safe}/{cid}
128 // did_safe should have colons replaced with underscores
129 expectedPath := filepath.Join(tmpDir, preset, "did_plc_abc123", cid)
130 if _, err := os.Stat(expectedPath); os.IsNotExist(err) {
131 t.Errorf("Expected cache file at %s to exist", expectedPath)
132 }
133}
134
135func TestDiskCache_HandlesSpecialCharactersInDID(t *testing.T) {
136 tmpDir := t.TempDir()
137 cache := mustNewDiskCache(t, tmpDir, 1)
138
139 tests := []struct {
140 name string
141 did string
142 wantDir string
143 }{
144 {
145 name: "plc DID with colons",
146 did: "did:plc:abc123",
147 wantDir: "did_plc_abc123",
148 },
149 {
150 name: "web DID with multiple colons",
151 did: "did:web:example.com:user",
152 wantDir: "did_web_example.com_user",
153 },
154 {
155 name: "DID with many segments",
156 did: "did:plc:a:b:c:d",
157 wantDir: "did_plc_a_b_c_d",
158 },
159 }
160
161 for _, tt := range tests {
162 t.Run(tt.name, func(t *testing.T) {
163 testData := []byte("test data for " + tt.name)
164 preset := "thumb"
165 cid := "bafytest123"
166
167 err := cache.Set(preset, tt.did, cid, testData)
168 if err != nil {
169 t.Fatalf("Set failed: %v", err)
170 }
171
172 expectedPath := filepath.Join(tmpDir, preset, tt.wantDir, cid)
173 if _, err := os.Stat(expectedPath); os.IsNotExist(err) {
174 t.Errorf("Expected cache file at %s to exist for DID %s", expectedPath, tt.did)
175 }
176
177 // Also verify we can read it back
178 data, found, err := cache.Get(preset, tt.did, cid)
179 if err != nil {
180 t.Fatalf("Get failed: %v", err)
181 }
182 if !found {
183 t.Error("Expected to find cached data")
184 }
185 if string(data) != string(testData) {
186 t.Errorf("Get returned %q, want %q", string(data), string(testData))
187 }
188 })
189 }
190}
191
192func TestDiskCache_DifferentPresetsAreSeparate(t *testing.T) {
193 tmpDir := t.TempDir()
194 cache := mustNewDiskCache(t, tmpDir, 1)
195
196 did := "did:plc:same"
197 cid := "bafysame"
198 thumbData := []byte("thumbnail data")
199 fullData := []byte("full size data")
200
201 // Set different data for different presets
202 err := cache.Set("thumb", did, cid, thumbData)
203 if err != nil {
204 t.Fatalf("Set thumb failed: %v", err)
205 }
206
207 err = cache.Set("full", did, cid, fullData)
208 if err != nil {
209 t.Fatalf("Set full failed: %v", err)
210 }
211
212 // Verify they're separate
213 data, found, _ := cache.Get("thumb", did, cid)
214 if !found {
215 t.Fatal("Expected thumb data to be found")
216 }
217 if string(data) != string(thumbData) {
218 t.Errorf("thumb preset returned wrong data: got %q, want %q", string(data), string(thumbData))
219 }
220
221 data, found, _ = cache.Get("full", did, cid)
222 if !found {
223 t.Fatal("Expected full data to be found")
224 }
225 if string(data) != string(fullData) {
226 t.Errorf("full preset returned wrong data: got %q, want %q", string(data), string(fullData))
227 }
228}
229
230func TestDiskCache_EmptyParametersHandled(t *testing.T) {
231 tmpDir := t.TempDir()
232 cache := mustNewDiskCache(t, tmpDir, 1)
233
234 // Empty preset
235 err := cache.Set("", "did:plc:abc", "bafytest", []byte("data"))
236 if err == nil {
237 t.Error("Expected error when preset is empty")
238 }
239
240 // Empty DID
241 err = cache.Set("thumb", "", "bafytest", []byte("data"))
242 if err == nil {
243 t.Error("Expected error when DID is empty")
244 }
245
246 // Empty CID
247 err = cache.Set("thumb", "did:plc:abc", "", []byte("data"))
248 if err == nil {
249 t.Error("Expected error when CID is empty")
250 }
251}
252
253func TestNewDiskCache(t *testing.T) {
254 cache, err := NewDiskCache("/some/path", 5, 30)
255 if err != nil {
256 t.Fatalf("NewDiskCache failed: %v", err)
257 }
258
259 if cache == nil {
260 t.Fatal("NewDiskCache returned nil")
261 }
262 if cache.basePath != "/some/path" {
263 t.Errorf("basePath = %q, want %q", cache.basePath, "/some/path")
264 }
265 if cache.maxSizeGB != 5 {
266 t.Errorf("maxSizeGB = %d, want %d", cache.maxSizeGB, 5)
267 }
268 if cache.ttlDays != 30 {
269 t.Errorf("ttlDays = %d, want %d", cache.ttlDays, 30)
270 }
271}
272
273func TestNewDiskCache_Errors(t *testing.T) {
274 t.Run("empty base path", func(t *testing.T) {
275 _, err := NewDiskCache("", 5, 0)
276 if !errors.Is(err, ErrInvalidCacheBasePath) {
277 t.Errorf("expected ErrInvalidCacheBasePath, got: %v", err)
278 }
279 })
280
281 t.Run("zero max size", func(t *testing.T) {
282 _, err := NewDiskCache("/some/path", 0, 0)
283 if !errors.Is(err, ErrInvalidCacheMaxSize) {
284 t.Errorf("expected ErrInvalidCacheMaxSize, got: %v", err)
285 }
286 })
287
288 t.Run("negative max size", func(t *testing.T) {
289 _, err := NewDiskCache("/some/path", -1, 0)
290 if !errors.Is(err, ErrInvalidCacheMaxSize) {
291 t.Errorf("expected ErrInvalidCacheMaxSize, got: %v", err)
292 }
293 })
294
295 t.Run("negative TTL", func(t *testing.T) {
296 _, err := NewDiskCache("/some/path", 5, -1)
297 if err == nil {
298 t.Error("expected error for negative TTL")
299 }
300 })
301}
302
303func TestCache_InterfaceImplementation(t *testing.T) {
304 // Compile-time check that DiskCache implements Cache
305 var _ Cache = (*DiskCache)(nil)
306}
307
308func TestDiskCache_GetCacheSize(t *testing.T) {
309 tmpDir := t.TempDir()
310 cache := mustNewDiskCache(t, tmpDir, 1)
311
312 // Empty cache should be 0
313 size, err := cache.GetCacheSize()
314 if err != nil {
315 t.Fatalf("GetCacheSize failed: %v", err)
316 }
317 if size != 0 {
318 t.Errorf("Expected 0 for empty cache, got %d", size)
319 }
320
321 // Add some data
322 data := make([]byte, 1000) // 1KB
323 if err := cache.Set("avatar", "did:plc:test1", "cid1", data); err != nil {
324 t.Fatalf("Set failed: %v", err)
325 }
326 if err := cache.Set("avatar", "did:plc:test2", "cid2", data); err != nil {
327 t.Fatalf("Set failed: %v", err)
328 }
329
330 size, err = cache.GetCacheSize()
331 if err != nil {
332 t.Fatalf("GetCacheSize failed: %v", err)
333 }
334 if size != 2000 {
335 t.Errorf("Expected 2000 bytes, got %d", size)
336 }
337}
338
339func TestDiskCache_EvictLRU(t *testing.T) {
340 tmpDir := t.TempDir()
341 // Use a very small max size (1 byte) so any data triggers eviction
342 cache, err := NewDiskCache(tmpDir, 1, 0) // 1GB but we'll add more than that won't fit
343 if err != nil {
344 t.Fatalf("NewDiskCache failed: %v", err)
345 }
346
347 // Add some files with different modification times
348 data := make([]byte, 100)
349
350 // Create old file
351 if err := cache.Set("avatar", "did:plc:old", "cid_old", data); err != nil {
352 t.Fatalf("Set failed: %v", err)
353 }
354 oldPath := cache.cachePath("avatar", "did:plc:old", "cid_old")
355 oldTime := time.Now().Add(-24 * time.Hour)
356 if err := os.Chtimes(oldPath, oldTime, oldTime); err != nil {
357 t.Fatalf("Chtimes failed: %v", err)
358 }
359
360 // Create new file
361 if err := cache.Set("avatar", "did:plc:new", "cid_new", data); err != nil {
362 t.Fatalf("Set failed: %v", err)
363 }
364
365 // Cache is under 1GB so eviction shouldn't remove anything
366 removed, err := cache.EvictLRU()
367 if err != nil {
368 t.Fatalf("EvictLRU failed: %v", err)
369 }
370 if removed != 0 {
371 t.Errorf("Expected 0 entries removed (under limit), got %d", removed)
372 }
373
374 // Both files should still exist
375 if _, found, _ := cache.Get("avatar", "did:plc:old", "cid_old"); !found {
376 t.Error("Old entry should still exist")
377 }
378 if _, found, _ := cache.Get("avatar", "did:plc:new", "cid_new"); !found {
379 t.Error("New entry should still exist")
380 }
381}
382
383func TestDiskCache_CleanExpired(t *testing.T) {
384 tmpDir := t.TempDir()
385 // TTL of 1 day
386 cache, err := NewDiskCache(tmpDir, 1, 1)
387 if err != nil {
388 t.Fatalf("NewDiskCache failed: %v", err)
389 }
390
391 data := make([]byte, 100)
392
393 // Create fresh file
394 if err := cache.Set("avatar", "did:plc:fresh", "cid_fresh", data); err != nil {
395 t.Fatalf("Set failed: %v", err)
396 }
397
398 // Create expired file (manually set old mtime)
399 if err := cache.Set("avatar", "did:plc:expired", "cid_expired", data); err != nil {
400 t.Fatalf("Set failed: %v", err)
401 }
402 expiredPath := cache.cachePath("avatar", "did:plc:expired", "cid_expired")
403 oldTime := time.Now().Add(-48 * time.Hour) // 2 days old, TTL is 1 day
404 if err := os.Chtimes(expiredPath, oldTime, oldTime); err != nil {
405 t.Fatalf("Chtimes failed: %v", err)
406 }
407
408 // Clean expired entries
409 removed, err := cache.CleanExpired()
410 if err != nil {
411 t.Fatalf("CleanExpired failed: %v", err)
412 }
413 if removed != 1 {
414 t.Errorf("Expected 1 expired entry removed, got %d", removed)
415 }
416
417 // Fresh file should still exist
418 if _, found, _ := cache.Get("avatar", "did:plc:fresh", "cid_fresh"); !found {
419 t.Error("Fresh entry should still exist")
420 }
421
422 // Expired file should be gone
423 if _, found, _ := cache.Get("avatar", "did:plc:expired", "cid_expired"); found {
424 t.Error("Expired entry should be removed")
425 }
426}
427
428func TestDiskCache_CleanExpired_TTLDisabled(t *testing.T) {
429 tmpDir := t.TempDir()
430 // TTL of 0 = disabled
431 cache, err := NewDiskCache(tmpDir, 1, 0)
432 if err != nil {
433 t.Fatalf("NewDiskCache failed: %v", err)
434 }
435
436 data := make([]byte, 100)
437
438 // Create a file with old mtime
439 if err := cache.Set("avatar", "did:plc:old", "cid_old", data); err != nil {
440 t.Fatalf("Set failed: %v", err)
441 }
442 path := cache.cachePath("avatar", "did:plc:old", "cid_old")
443 oldTime := time.Now().Add(-365 * 24 * time.Hour) // 1 year old
444 if err := os.Chtimes(path, oldTime, oldTime); err != nil {
445 t.Fatalf("Chtimes failed: %v", err)
446 }
447
448 // Clean expired should do nothing when TTL is disabled
449 removed, err := cache.CleanExpired()
450 if err != nil {
451 t.Fatalf("CleanExpired failed: %v", err)
452 }
453 if removed != 0 {
454 t.Errorf("Expected 0 removed with TTL disabled, got %d", removed)
455 }
456
457 // File should still exist
458 if _, found, _ := cache.Get("avatar", "did:plc:old", "cid_old"); !found {
459 t.Error("Entry should still exist when TTL is disabled")
460 }
461}
462
463func TestDiskCache_Cleanup(t *testing.T) {
464 tmpDir := t.TempDir()
465 // TTL of 1 day
466 cache, err := NewDiskCache(tmpDir, 1, 1)
467 if err != nil {
468 t.Fatalf("NewDiskCache failed: %v", err)
469 }
470
471 data := make([]byte, 100)
472
473 // Create fresh file
474 if err := cache.Set("avatar", "did:plc:fresh", "cid_fresh", data); err != nil {
475 t.Fatalf("Set failed: %v", err)
476 }
477
478 // Create expired file
479 if err := cache.Set("avatar", "did:plc:expired", "cid_expired", data); err != nil {
480 t.Fatalf("Set failed: %v", err)
481 }
482 expiredPath := cache.cachePath("avatar", "did:plc:expired", "cid_expired")
483 oldTime := time.Now().Add(-48 * time.Hour)
484 if err := os.Chtimes(expiredPath, oldTime, oldTime); err != nil {
485 t.Fatalf("Chtimes failed: %v", err)
486 }
487
488 // Cleanup should remove expired entry
489 removed, err := cache.Cleanup()
490 if err != nil {
491 t.Fatalf("Cleanup failed: %v", err)
492 }
493 if removed != 1 {
494 t.Errorf("Expected 1 entry removed, got %d", removed)
495 }
496
497 // Fresh file should still exist
498 if _, found, _ := cache.Get("avatar", "did:plc:fresh", "cid_fresh"); !found {
499 t.Error("Fresh entry should still exist")
500 }
501}
502
503func TestDiskCache_GetUpdatesMtime(t *testing.T) {
504 tmpDir := t.TempDir()
505 cache := mustNewDiskCache(t, tmpDir, 1)
506
507 data := []byte("test data")
508 if err := cache.Set("avatar", "did:plc:test", "cid1", data); err != nil {
509 t.Fatalf("Set failed: %v", err)
510 }
511
512 path := cache.cachePath("avatar", "did:plc:test", "cid1")
513
514 // Set an old mtime
515 oldTime := time.Now().Add(-24 * time.Hour)
516 if err := os.Chtimes(path, oldTime, oldTime); err != nil {
517 t.Fatalf("Chtimes failed: %v", err)
518 }
519
520 // Get the file - this should update mtime
521 _, found, err := cache.Get("avatar", "did:plc:test", "cid1")
522 if err != nil {
523 t.Fatalf("Get failed: %v", err)
524 }
525 if !found {
526 t.Fatal("Expected to find entry")
527 }
528
529 // Check that mtime was updated
530 info, err := os.Stat(path)
531 if err != nil {
532 t.Fatalf("Stat failed: %v", err)
533 }
534
535 // Mtime should be recent (within last minute)
536 if time.Since(info.ModTime()) > time.Minute {
537 t.Errorf("Expected mtime to be updated to now, but it's %v old", time.Since(info.ModTime()))
538 }
539}
540
541func TestDiskCache_StartCleanupJob(t *testing.T) {
542 tmpDir := t.TempDir()
543 // Create cache with 1 day TTL
544 cache, err := NewDiskCache(tmpDir, 1, 1)
545 if err != nil {
546 t.Fatalf("NewDiskCache failed: %v", err)
547 }
548
549 data := make([]byte, 100)
550
551 // Create an expired file
552 if err := cache.Set("avatar", "did:plc:expired", "cid_expired", data); err != nil {
553 t.Fatalf("Set failed: %v", err)
554 }
555 expiredPath := cache.cachePath("avatar", "did:plc:expired", "cid_expired")
556 oldTime := time.Now().Add(-48 * time.Hour)
557 if err := os.Chtimes(expiredPath, oldTime, oldTime); err != nil {
558 t.Fatalf("Chtimes failed: %v", err)
559 }
560
561 // Start cleanup job with very short interval
562 cancel := cache.StartCleanupJob(50 * time.Millisecond)
563 defer cancel()
564
565 // Wait for at least one cleanup cycle
566 time.Sleep(100 * time.Millisecond)
567
568 // Expired file should be gone
569 if _, found, _ := cache.Get("avatar", "did:plc:expired", "cid_expired"); found {
570 t.Error("Expired entry should have been cleaned up by background job")
571 }
572}
573
574func TestDiskCache_StartCleanupJob_ZeroInterval(t *testing.T) {
575 tmpDir := t.TempDir()
576 cache := mustNewDiskCache(t, tmpDir, 1)
577
578 // Starting with 0 interval should return a no-op cancel
579 cancel := cache.StartCleanupJob(0)
580 defer cancel()
581
582 // Should not panic when called
583 cancel()
584 cancel() // Multiple calls should be safe
585}
586
587func TestDiskCache_StartCleanupJob_GracefulShutdown(t *testing.T) {
588 tmpDir := t.TempDir()
589 cache := mustNewDiskCache(t, tmpDir, 1)
590
591 // Start cleanup job
592 cancel := cache.StartCleanupJob(10 * time.Millisecond)
593
594 // Let it run briefly
595 time.Sleep(30 * time.Millisecond)
596
597 // Cancel should not hang or panic
598 done := make(chan struct{})
599 go func() {
600 cancel()
601 close(done)
602 }()
603
604 select {
605 case <-done:
606 // Good, cancel returned
607 case <-time.After(1 * time.Second):
608 t.Error("Cancel took too long, may be stuck")
609 }
610}