diff --git a/internal/imgcache/negative_cache_internal_test.go b/internal/imgcache/negative_cache_internal_test.go index ded267c..3f7c9c2 100644 --- a/internal/imgcache/negative_cache_internal_test.go +++ b/internal/imgcache/negative_cache_internal_test.go @@ -3,6 +3,7 @@ package imgcache import ( "context" "errors" + "strconv" "testing" "time" ) @@ -185,6 +186,120 @@ func negativeCacheRowCount(tb testing.TB, cache *Cache, sourcePath string) int { return count } +// TestCleanExpiredDeletesInBatches stores one expired negative cache row +// more than one batch holds, and one row that has not expired. One +// statement must delete one batch and no more, so a request waits behind +// one batch at most; CleanExpired must then remove the expired row left +// over and keep the row that has not expired. +func TestCleanExpiredDeletesInBatches(t *testing.T) { + t.Parallel() + + cache, err := NewCache(evictionTestDB(t), CacheConfig{StateDir: t.TempDir()}) + if err != nil { + t.Fatalf("failed to create cache: %v", err) + } + + ctx := t.Context() + + insertExpiredNegativeCacheRows(t, cache, cleanExpiredBatchSize+1) + + const notExpiredPath = "/later.jpg" + + _, err = cache.db.ExecContext(ctx, ` + 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) + } + + deleted, err := cache.cleanExpiredBatch(ctx) + if err != nil { + t.Fatalf("cleanExpiredBatch failed: %v", err) + } + + t.Logf("one statement deleted %d of %d expired rows", + deleted, cleanExpiredBatchSize+1) + + if deleted != cleanExpiredBatchSize { + t.Errorf("one statement deleted %d rows, want %d", + deleted, cleanExpiredBatchSize) + } + + err = cache.CleanExpired(ctx) + if err != nil { + t.Fatalf("CleanExpired failed: %v", err) + } + + if count := negativeCacheTableRowCount(t, cache); count != 1 { + t.Errorf("negative_cache holds %d rows after CleanExpired, want 1", count) + } + + if negativeCacheRowCount(t, cache, notExpiredPath) != 1 { + t.Error("the row that has not expired was removed") + } +} + +// TestCleanExpiredStopsOnceContextEnds runs CleanExpired with a context +// that has already ended: it must return the context's error and delete +// no row. +func TestCleanExpiredStopsOnceContextEnds(t *testing.T) { + t.Parallel() + + cache, err := NewCache(evictionTestDB(t), CacheConfig{StateDir: t.TempDir()}) + if err != nil { + t.Fatalf("failed to create cache: %v", err) + } + + insertExpiredNegativeCacheRows(t, cache, 1) + + ctx, cancel := context.WithCancel(t.Context()) + cancel() + + err = cache.CleanExpired(ctx) + if !errors.Is(err, context.Canceled) { + t.Errorf("CleanExpired() error = %v, want context.Canceled", err) + } + + if count := negativeCacheTableRowCount(t, cache); count != 1 { + t.Errorf("negative_cache holds %d rows, want the expired row kept", count) + } +} + +// insertExpiredNegativeCacheRows inserts count negative_cache rows that +// expired an hour ago, each for a different source path. +func insertExpiredNegativeCacheRows(t *testing.T, cache *Cache, count int) { + t.Helper() + + for i := range count { + _, 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, "/expired/"+strconv.Itoa(i)+".jpg") + if err != nil { + t.Fatalf("failed to insert an expired row: %v", err) + } + } +} + +// negativeCacheTableRowCount returns how many rows negative_cache holds. +func negativeCacheTableRowCount(tb testing.TB, cache *Cache) int { + tb.Helper() + + var count int + + err := cache.db.QueryRowContext(tb.Context(), + `SELECT COUNT(*) FROM negative_cache`, + ).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()