From a1fc42b80e1dd55762b4c7d7ee7302a547272c5f Mon Sep 17 00:00:00 2001 From: sneak Date: Thu, 15 Oct 2026 02:22:22 +0000 Subject: [PATCH] Test cached files left empty or partial (refs #252) Failing tests: an empty or half-size cached source must be fetched again once and then cached whole, an empty or cut cached variant must be converted again instead of served, and storing a source over a file of another size must replace it. https://git.eeqj.de/sneak/pixa/issues/252 Model: opus-5-5 --- .../partial_cache_files_internal_test.go | 230 ++++++++++++++++++ 1 file changed, 230 insertions(+) create mode 100644 internal/imgcache/partial_cache_files_internal_test.go diff --git a/internal/imgcache/partial_cache_files_internal_test.go b/internal/imgcache/partial_cache_files_internal_test.go new file mode 100644 index 0000000..c3fa5e1 --- /dev/null +++ b/internal/imgcache/partial_cache_files_internal_test.go @@ -0,0 +1,230 @@ +package imgcache + +import ( + "bytes" + "crypto/sha256" + "encoding/hex" + "io" + "os" + "path/filepath" + "testing" +) + +// getImage calls Get for req and returns the image it answered with and +// its cache status. +func getImage(t *testing.T, svc *Service, req *ImageRequest) ([]byte, CacheStatus) { + t.Helper() + + resp, err := svc.Get(t.Context(), req) + if err != nil { + t.Fatalf("Get() of quality %d error = %v", req.Quality, err) + } + + defer func() { _ = resp.Content.Close() }() + + body, err := io.ReadAll(resp.Content) + if err != nil { + t.Fatalf("reading the image of quality %d: %v", req.Quality, err) + } + + return body, resp.CacheStatus +} + +// fileSize returns the size of the file at path. +func fileSize(t *testing.T, path string) int64 { + t.Helper() + + info, err := os.Stat(path) + if err != nil { + t.Fatalf("stat %s: %v", path, err) + } + + return info.Size() +} + +// truncateFile cuts the file at path down to size bytes. +func truncateFile(t *testing.T, path string, size int64) { + t.Helper() + + t.Logf("cutting %s from %d to %d bytes", + filepath.Base(path), fileSize(t, path), size) + + err := os.Truncate(path, size) + if err != nil { + t.Fatalf("truncating %s: %v", path, err) + } +} + +// cachedSourcePath returns the path of the cached source file req uses. +func cachedSourcePath(t *testing.T, svc *Service, req *ImageRequest) string { + t.Helper() + + contentHash, _, err := svc.cache.LookupSource(t.Context(), req) + if err != nil || contentHash == "" { + t.Fatalf("LookupSource() = %q, %v; want the cached source", contentHash, err) + } + + return svc.cache.srcContent.hashToPath(contentHash) +} + +// TestService_Get_FetchesAgainForEmptyCachedSource empties the cached source +// file, as a crash during its write could, then asks for two new sizes of +// it: the first must fetch the source again and cache it whole, so that the +// second is made from the cache without a third fetch. +func TestService_Get_FetchesAgainForEmptyCachedSource(t *testing.T) { + t.Parallel() + + svc, fixtures := SetupTestService(t) + + getImage(t, svc, photoVariant(fixtures, 85, FitCover)) + + path := cachedSourcePath(t, svc, photoVariant(fixtures, 85, FitCover)) + wholeSize := fileSize(t, path) + + truncateFile(t, path, 0) + + getImage(t, svc, photoVariant(fixtures, 40, FitCover)) + getImage(t, svc, photoVariant(fixtures, 30, FitCover)) + + fetches := readCacheStatsCounters(t, svc.cache).upstreamFetchCount + t.Logf("upstream fetches = %d", fetches) + + if fetches != 2 { + t.Errorf("upstream fetches = %d, want 2: the empty cached source "+ + "was kept and fetched again on every new size", fetches) + } + + size := fileSize(t, path) + if size != wholeSize { + t.Errorf("cached source is %d bytes, want %d", size, wholeSize) + } +} + +// TestService_Get_FetchesAgainForPartialCachedSource cuts the cached source +// file to half its size, as a crash during its write could, then asks for +// two new sizes of it: the first must fetch the source again rather than +// fail to decode the partial file, and the second is made from the cache. +func TestService_Get_FetchesAgainForPartialCachedSource(t *testing.T) { + t.Parallel() + + svc, fixtures := SetupTestService(t) + + getImage(t, svc, photoVariant(fixtures, 85, FitCover)) + + path := cachedSourcePath(t, svc, photoVariant(fixtures, 85, FitCover)) + wholeSize := fileSize(t, path) + + truncateFile(t, path, wholeSize/2) + + getImage(t, svc, photoVariant(fixtures, 40, FitCover)) + getImage(t, svc, photoVariant(fixtures, 30, FitCover)) + + fetches := readCacheStatsCounters(t, svc.cache).upstreamFetchCount + t.Logf("upstream fetches = %d", fetches) + + if fetches != 2 { + t.Errorf("upstream fetches = %d, want 2", fetches) + } + + size := fileSize(t, path) + if size != wholeSize { + t.Errorf("cached source is %d bytes, want %d", size, wholeSize) + } +} + +// assertConvertedAgain caches a variant of the photo and cuts its file to +// size bytes, then checks that Get converts the image again instead of +// serving the file, and that the next Get serves the whole variant from +// the cache. +func assertConvertedAgain(t *testing.T, size int64) { + t.Helper() + + svc, fixtures := SetupTestService(t) + req := photoVariant(fixtures, 85, FitCover) + + want, _ := getImage(t, svc, req) + + truncateFile(t, svc.cache.variants.keyToPath(CacheKey(req)), size) + + got, status := getImage(t, svc, req) + t.Logf("after the cut: %d bytes, %s", len(got), status) + + if status != CacheMiss || !bytes.Equal(got, want) { + t.Errorf("after the cut: Get() = %d bytes, %s; want the %d bytes "+ + "converted again, %s", len(got), status, len(want), CacheMiss) + } + + got, status = getImage(t, svc, req) + t.Logf("next request: %d bytes, %s", len(got), status) + + if status != CacheHit || !bytes.Equal(got, want) { + t.Errorf("next request: Get() = %d bytes, %s; want the %d bytes, %s", + len(got), status, len(want), CacheHit) + } +} + +// TestService_Get_ConvertsAgainForEmptyCachedVariant empties a cached +// variant file, as a crash during its write could, and checks that it is +// converted again rather than served empty. +func TestService_Get_ConvertsAgainForEmptyCachedVariant(t *testing.T) { + t.Parallel() + + assertConvertedAgain(t, 0) +} + +// TestService_Get_ConvertsAgainForPartialCachedVariant cuts a cached +// variant file to ten bytes, as a crash during its write could, and checks +// that it is converted again rather than served cut short. +func TestService_Get_ConvertsAgainForPartialCachedVariant(t *testing.T) { + t.Parallel() + + assertConvertedAgain(t, 10) +} + +// TestContentStorage_StoreHashedReplacesFileOfAnotherSize checks that a +// store over a file of another size at the content's path, such as a +// partial file a crash left, replaces it with the content. +func TestContentStorage_StoreHashedReplacesFileOfAnotherSize(t *testing.T) { + t.Parallel() + + storage, err := NewContentStorage(t.TempDir()) + if err != nil { + t.Fatalf("NewContentStorage() error = %v", err) + } + + content := []byte("the whole source image") + sum := sha256.Sum256(content) + hash := ContentHash(hex.EncodeToString(sum[:])) + path := storage.hashToPath(hash) + + err = os.MkdirAll(filepath.Dir(path), StorageDirPerm) + if err != nil { + t.Fatalf("creating the content's directory: %v", err) + } + + err = os.WriteFile(path, content[:5], StorageFilePerm) + if err != nil { + t.Fatalf("writing a partial file: %v", err) + } + + _, err = storage.StoreHashed(hash, content) + if err != nil { + t.Fatalf("StoreHashed() error = %v", err) + } + + stored, err := storage.Load(hash) + if err != nil { + t.Fatalf("Load() error = %v", err) + } + + defer func() { _ = stored.Close() }() + + got, err := io.ReadAll(stored) + if err != nil { + t.Fatalf("reading the stored content: %v", err) + } + + if !bytes.Equal(got, content) { + t.Errorf("stored file = %q, want %q", got, content) + } +}