A community based topic aggregation platform built on atproto
at main 610 lines 16 kB view raw
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}