From dd9c85d5ec39f842225dcff1944d81419a3d4b9c Mon Sep 17 00:00:00 2001 From: clawbot <35+clawbot@noreply.example.org> Date: Tue, 29 Sep 2026 05:54:42 +0000 Subject: [PATCH] Test that a cache hit takes the content type from memory (closes #70) Tests for keeping each variant's content type in memory. A second hit must still get the stored content type after the variant's .meta file is deleted, whether the first came from storing the variant or from reading it after a restart; this fails now. A variant removed by EvictToLimit, or whose file was deleted from disk, must be a miss and must not be served, and concurrent stores, reads and evictions run under the race detector; these pass now and guard the change. Model: opus-5-5 --- internal/imgcache/metacache_internal_test.go | 222 +++++++++++++++++++ internal/imgcache/testutil_internal_test.go | 1 + 2 files changed, 223 insertions(+) create mode 100644 internal/imgcache/metacache_internal_test.go diff --git a/internal/imgcache/metacache_internal_test.go b/internal/imgcache/metacache_internal_test.go new file mode 100644 index 0000000..0a2d368 --- /dev/null +++ b/internal/imgcache/metacache_internal_test.go @@ -0,0 +1,222 @@ +package imgcache + +import ( + "bytes" + "errors" + "fmt" + "io" + "os" + "sync" + "testing" + "time" +) + +// webpRequest returns a request for a 100x100 WebP variant of path. +func webpRequest(path string) *ImageRequest { + return &ImageRequest{ + SourceHost: testHostCDN, + SourcePath: path, + Size: Size{Width: 100, Height: 100}, + Format: FormatWebP, + Quality: 85, + FitMode: FitCover, + } +} + +// assertVariantServed checks that GetVariant serves key with the given +// content and the image/webp content type storeEvictionTestVariant stores. +func assertVariantServed(t *testing.T, cache *Cache, key VariantKey, content []byte) { + t.Helper() + + reader, size, contentType, err := cache.GetVariant(key) + if err != nil { + t.Fatalf("GetVariant(%s) error = %v", key, err) + } + + defer func() { _ = reader.Close() }() + + got, err := io.ReadAll(reader) + if err != nil { + t.Fatalf("reading variant %s: %v", key, err) + } + + if !bytes.Equal(got, content) { + t.Errorf("GetVariant(%s) content = %q, want %q", key, got, content) + } + + if size != int64(len(content)) { + t.Errorf("GetVariant(%s) size = %d, want %d", key, size, len(content)) + } + + if contentType != testContentTypeWebP { + t.Errorf("GetVariant(%s) content type = %q, want %q", + key, contentType, testContentTypeWebP) + } +} + +// assertVariantNotFound checks that GetVariant refuses key with +// ErrNotFound. +func assertVariantNotFound(t *testing.T, cache *Cache, key VariantKey) { + t.Helper() + + reader, _, _, err := cache.GetVariant(key) + if err == nil { + _ = reader.Close() + } + + if !errors.Is(err, ErrNotFound) { + t.Errorf("GetVariant(%s) error = %v, want ErrNotFound", key, err) + } +} + +// assertLookupMisses checks that Lookup reports request as a miss. +func assertLookupMisses(t *testing.T, cache *Cache, request *ImageRequest) { + t.Helper() + + lookup, err := cache.Lookup(t.Context(), request) + if err != nil { + t.Fatalf("Lookup(%s) error = %v", request.SourcePath, err) + } + + if lookup.Hit { + t.Errorf("Lookup(%s) is a hit, want a miss", request.SourcePath) + } +} + +// TestSecondHitDoesNotReadMetaFile checks that once a variant has been +// stored or read, a hit takes its content type from memory: with the +// .meta file deleted, GetVariant must still return the stored content +// type rather than the application/octet-stream it uses without one. +func TestSecondHitDoesNotReadMetaFile(t *testing.T) { + t.Parallel() + + cache, _ := newEvictionTestCache(t, 1<<20) + content := []byte("webp variant bytes") + + storeEvictionTestVariant(t, cache, testVariantKeyOne, content) + + // A second Cache on the same state directory starts with nothing in + // memory, as pixad does after a restart, so its first read uses the + // .meta file. + restarted, err := NewCache(cache.db, cache.config) + if err != nil { + t.Fatalf("NewCache() error = %v", err) + } + + assertVariantServed(t, restarted, testVariantKeyOne, content) + + err = os.Remove(cache.variants.keyToPath(testVariantKeyOne) + ".meta") + if err != nil { + t.Fatalf("removing .meta file: %v", err) + } + + assertVariantServed(t, cache, testVariantKeyOne, content) + assertVariantServed(t, restarted, testVariantKeyOne, content) +} + +// TestEvictedVariantIsNotServed checks that a variant the evictor +// removed is a miss and cannot be read, although it had been stored +// and served before. +func TestEvictedVariantIsNotServed(t *testing.T) { + t.Parallel() + + cache, _ := newEvictionTestCache(t, 1500) + + oldRequest := webpRequest("/old.jpg") + newRequest := webpRequest("/new.jpg") + oldKey := CacheKey(oldRequest) + newKey := CacheKey(newRequest) + oldContent := bytes.Repeat([]byte{0x01}, 1000) + newContent := bytes.Repeat([]byte{0x02}, 1000) + + storeEvictionTestVariant(t, cache, oldKey, oldContent) + storeEvictionTestVariant(t, cache, newKey, newContent) + assertVariantServed(t, cache, oldKey, oldContent) + setVariantLastAccessed(t, cache, oldKey, time.Now().Add(-time.Hour)) + + err := cache.EvictToLimit(t.Context()) + if err != nil { + t.Fatalf("EvictToLimit() error = %v", err) + } + + assertLookupMisses(t, cache, oldRequest) + assertVariantNotFound(t, cache, oldKey) + assertVariantServed(t, cache, newKey, newContent) +} + +// TestVariantDeletedFromDiskIsNotServed checks that a variant whose +// file was deleted by something other than the evictor cannot be read, +// and is a miss afterwards. +func TestVariantDeletedFromDiskIsNotServed(t *testing.T) { + t.Parallel() + + cache, _ := newEvictionTestCache(t, 1<<20) + + request := webpRequest("/deleted.jpg") + key := CacheKey(request) + content := []byte("webp variant bytes") + + storeEvictionTestVariant(t, cache, key, content) + assertVariantServed(t, cache, key, content) + + err := os.Remove(cache.variants.keyToPath(key)) + if err != nil { + t.Fatalf("removing variant file: %v", err) + } + + assertVariantNotFound(t, cache, key) + assertLookupMisses(t, cache, request) +} + +// TestConcurrentVariantStoreReadAndEvict stores, reads and evicts +// variants from several goroutines at once, for the race detector. +func TestConcurrentVariantStoreReadAndEvict(t *testing.T) { + t.Parallel() + + cache, _ := newEvictionTestCache(t, 1<<20) + ctx := t.Context() + + var wg sync.WaitGroup + + for goroutine := range 8 { + wg.Go(func() { + key := VariantKey(fmt.Sprintf("aabbccdd01%02d", goroutine)) + content := []byte(key) + + for range 20 { + err := cache.StoreVariant( + ctx, key, bytes.NewReader(content), testContentTypeWebP) + if err != nil { + t.Errorf("StoreVariant(%s) error = %v", key, err) + + return + } + + reader, _, contentType, err := cache.GetVariant(key) + if err != nil { + t.Errorf("GetVariant(%s) error = %v", key, err) + + return + } + + _ = reader.Close() + + if contentType != testContentTypeWebP { + t.Errorf("GetVariant(%s) content type = %q, want %q", + key, contentType, testContentTypeWebP) + } + + err = cache.evictVariant(ctx, key) + if err != nil { + t.Errorf("evictVariant(%s) error = %v", key, err) + + return + } + + assertVariantNotFound(t, cache, key) + } + }) + } + + wg.Wait() +} diff --git a/internal/imgcache/testutil_internal_test.go b/internal/imgcache/testutil_internal_test.go index 1ca7c11..b29f750 100644 --- a/internal/imgcache/testutil_internal_test.go +++ b/internal/imgcache/testutil_internal_test.go @@ -24,6 +24,7 @@ const ( testHostExample = "example.com" testPathCat = "/photos/cat.jpg" testContentTypeJPEG = "image/jpeg" + testContentTypeWebP = "image/webp" testHeaderContentType = "Content-Type" )