diff --git a/internal/imgcache/negative_cache_internal_test.go b/internal/imgcache/negative_cache_internal_test.go index f2c2115..ded267c 100644 --- a/internal/imgcache/negative_cache_internal_test.go +++ b/internal/imgcache/negative_cache_internal_test.go @@ -94,6 +94,97 @@ func TestNegativeCache_Expired(t *testing.T) { } } +// 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()