package imgcache import ( "context" "errors" "testing" "time" ) func TestNegativeCache_StoreAndCheck(t *testing.T) { t.Parallel() db := setupTestDB(t) dir := t.TempDir() cache, err := NewCache(db, CacheConfig{ StateDir: dir, NegativeTTL: 5 * time.Minute, }) if err != nil { t.Fatal(err) } ctx := context.Background() req := &ImageRequest{ SourceHost: testHostExample, SourcePath: "/missing.jpg", } // Initially should not be in negative cache hit, err := cache.checkNegativeCache(ctx, req) if err != nil { t.Fatal(err) } if hit { t.Error("expected no negative cache hit initially") } // Store a negative entry err = cache.StoreNegative(ctx, req, 404, "not found") if err != nil { t.Fatal(err) } // Now should be in negative cache hit, err = cache.checkNegativeCache(ctx, req) if err != nil { t.Fatal(err) } if !hit { t.Error("expected negative cache hit after storing") } } func TestNegativeCache_Expired(t *testing.T) { t.Parallel() db := setupTestDB(t) dir := t.TempDir() cache, err := NewCache(db, CacheConfig{ StateDir: dir, NegativeTTL: 1 * time.Millisecond, // very short TTL }) if err != nil { t.Fatal(err) } ctx := context.Background() req := &ImageRequest{ SourceHost: testHostExample, SourcePath: "/expired.jpg", } // Store a negative entry with very short TTL err = cache.StoreNegative(ctx, req, 500, "server error") if err != nil { t.Fatal(err) } // Wait for expiry time.Sleep(10 * time.Millisecond) // Should no longer be in negative cache hit, err := cache.checkNegativeCache(ctx, req) if err != nil { t.Fatal(err) } if hit { t.Error("expected expired negative cache entry to be a miss") } } // TestEvictionLoopRemovesExpiredNegativeCacheRows stores a failed fetch // that expires a second later and starts the eviction loop with a short // interval: a later tick must remove its row although its URL is never // requested again, and must keep a row that has not expired. It runs // with the disk cache enabled and disabled, as the negative cache is // used either way. func TestEvictionLoopRemovesExpiredNegativeCacheRows(t *testing.T) { t.Parallel() tests := []struct { name string disableDiskCache bool }{ {name: "disk cache enabled", disableDiskCache: false}, {name: "disk cache disabled", disableDiskCache: true}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { t.Parallel() cache, err := NewCache(evictionTestDB(t), CacheConfig{ StateDir: t.TempDir(), NegativeTTL: time.Second, DisableDiskCache: tt.disableDiskCache, }) if err != nil { t.Fatalf("failed to create cache: %v", err) } expired := &ImageRequest{ SourceHost: testHostExample, SourcePath: "/gone.jpg", } err = cache.StoreNegative(t.Context(), expired, 404, "not found") if err != nil { t.Fatalf("StoreNegative failed: %v", err) } const notExpiredPath = "/later.jpg" _, err = cache.db.ExecContext(t.Context(), ` INSERT INTO negative_cache (source_host, source_path, status_code, expires_at) VALUES (?, ?, 404, datetime('now', '+1 hour')) `, testHostExample, notExpiredPath) if err != nil { t.Fatalf("failed to insert a row that has not expired: %v", err) } started := time.Now() cache.StartEviction(100 * time.Millisecond) defer func() { _ = cache.StopEviction(t.Context()) }() for negativeCacheRowCount(t, cache, expired.SourcePath) > 0 { if time.Since(started) > 10*time.Second { t.Fatal("the expired row is still in negative_cache") } time.Sleep(50 * time.Millisecond) } t.Logf("the expired row was removed %s after the loop started", time.Since(started)) if negativeCacheRowCount(t, cache, notExpiredPath) != 1 { t.Error("the row that has not expired was removed") } }) } } // negativeCacheRowCount returns how many negative_cache rows hold // sourcePath. func negativeCacheRowCount(tb testing.TB, cache *Cache, sourcePath string) int { tb.Helper() var count int err := cache.db.QueryRowContext(tb.Context(), `SELECT COUNT(*) FROM negative_cache WHERE source_path = ?`, sourcePath, ).Scan(&count) if err != nil { tb.Fatalf("failed to count negative_cache rows: %v", err) } return count } func TestService_Get_ReturnsErrorForNegativeCachedURL(t *testing.T) { t.Parallel() // This test verifies that Service.Get() checks the negative cache // We can't easily test the full pipeline without vips, but we can // verify the error type err := ErrNegativeCached if !errors.Is(err, ErrNegativeCached) { t.Error("ErrNegativeCached should be identifiable with errors.Is") } }