package imgcache import ( "bytes" "errors" "fmt" "image/color" "io" "os" "path/filepath" "testing" "sneak.berlin/go/pixa/internal/imageprocessor" "sneak.berlin/go/pixa/internal/imagerequest" "sneak.berlin/go/pixa/internal/magic" ) // TestPutSource_StoresIdenticalBytesOfTwoURLsTwice stores the same bytes // for two source URLs and checks that each URL has a source_content row of // its own and a file of its own, named by its source key under // cache/sources///. func TestPutSource_StoresIdenticalBytesOfTwoURLsTwice(t *testing.T) { t.Parallel() cache, stateDir := newEvictionTestCache(t, 1<<30) content := bytes.Repeat([]byte{0x5A}, 400) keys := map[string]imagerequest.SourceKey{ "/a.jpg": storeEvictionTestSource(t, cache, testHostCDN, "/a.jpg", content), "/b.jpg": storeEvictionTestSource(t, cache, testHostCDN, "/b.jpg", content), } t.Logf("source keys: %v", keys) if keys["/a.jpg"] == keys["/b.jpg"] { t.Fatalf("both source URLs got the key %s", keys["/a.jpg"]) } for sourcePath, key := range keys { rows := countRows(t, cache, `SELECT COUNT(*) FROM source_content WHERE source_key = ?`, string(key)) if rows != 1 { t.Errorf("%s has %d source_content rows, want 1", sourcePath, rows) } name := string(key) file := filepath.Join(stateDir, "cache", "sources", name[0:2], name[2:4], name) stored, err := os.ReadFile(file) //nolint:gosec // path in the test's state dir if err != nil || !bytes.Equal(stored, content) { t.Errorf("file of %s at %s holds %d bytes (error %v), want the %d stored", sourcePath, file, len(stored), err, len(content)) } } usage, err := cache.UsageBytes(t.Context()) if err != nil { t.Fatalf("UsageBytes() error = %v", err) } if usage != 800 { t.Errorf("UsageBytes() = %d, want 800, the 400 bytes once for each URL", usage) } } // TestEvictSourceBlob_LeavesSourceWithIdenticalBytesServed stores the same // bytes for two source URLs and evicts the first. Its row and file must be // gone, and the second must still be found and read from the cache. func TestEvictSourceBlob_LeavesSourceWithIdenticalBytesServed(t *testing.T) { t.Parallel() cache, _ := newEvictionTestCache(t, 1<<30) content := generateTestJPEG(t, 20, 20, color.RGBA{0x5B, 0x5B, 0x5B, 255}) evicted := storeEvictionTestSource(t, cache, testHostCDN, "/evicted.jpg", content) storeEvictionTestSource(t, cache, testHostCDN, "/kept.jpg", content) err := cache.evictSourceBlob(t.Context(), evicted) if err != nil { t.Fatalf("evictSourceBlob() error = %v", err) } _, err = os.Stat(cache.srcContent.keyToPath(string(evicted))) if err == nil { t.Error("the evicted source's file is still on disk") } if n := countRows(t, cache, `SELECT COUNT(*) FROM source_content WHERE source_key = ?`, string(evicted), ); n != 0 { t.Errorf("the evicted source still has %d source_content rows", n) } reader, _, _, err := cache.Source(t.Context(), imagerequest.Source{Host: testHostCDN, Path: "/kept.jpg"}) t.Logf("Source() of the kept source: error %v", err) if err != nil { t.Fatalf("Source() of the kept source error = %v; want it cached", err) } defer func() { _ = reader.Close() }() served, err := io.ReadAll(reader) if err != nil { t.Fatalf("reading the kept source: %v", err) } if !bytes.Equal(served, content) { t.Errorf("the kept source reads as %d bytes, want the %d stored", len(served), len(content)) } } // TestPutSource_FailedRowWriteLeavesNoFile drops source_content, so that // PutSource cannot write the source's row, and checks that the store fails // and leaves no file under the source's key. func TestPutSource_FailedRowWriteLeavesNoFile(t *testing.T) { t.Parallel() cache, _ := newEvictionTestCache(t, 1<<30) _, err := cache.db.ExecContext(t.Context(), `DROP TABLE source_content`) if err != nil { t.Fatalf("failed to drop source_content: %v", err) } req := &imagerequest.Request{ Source: imagerequest.Source{Host: testHostCDN, Path: testPathCat}, } key, err := cache.PutSource(t.Context(), req.Source, bytes.NewReader([]byte("source bytes"))) t.Logf("PutSource() = %q, error = %v", key, err) if err == nil { t.Error("PutSource() error = nil, want the failed row write") } _, err = os.Stat(cache.srcContent.keyToPath(string(req.Source.Key()))) if err == nil { t.Error("the source's file is on disk although its row was not written") } } // TestSource_FormatFromFirstBytes stores an image in each source format // under one source URL, in turn. After each store, Source must return that // image whole, with its format read from the file. func TestSource_FormatFromFirstBytes(t *testing.T) { t.Parallel() cache, _ := newEvictionTestCache(t, 1<<30) processor := imageprocessor.New(imageprocessor.Params{ MaxInputBytes: 50 << 20, MaxConcurrentProcessing: 1, }) jpegImage := generateTestJPEG(t, 40, 30, color.RGBA{200, 40, 40, 255}) src := imagerequest.Source{Host: testHostCDN, Path: testPathCat} for _, format := range []imagerequest.Format{ imagerequest.FormatJPEG, imagerequest.FormatPNG, imagerequest.FormatGIF, imagerequest.FormatWebP, imagerequest.FormatAVIF, imagerequest.FormatJXL, } { result, err := processor.Process(t.Context(), bytes.NewReader(jpegImage), imagerequest.FormatJPEG, &imagerequest.Transform{Format: format}) if err != nil { t.Fatalf("making a %s image: %v", format, err) } image, err := io.ReadAll(result.Content) if err != nil { t.Fatalf("reading the %s image: %v", format, err) } _, err = cache.PutSource(t.Context(), src, bytes.NewReader(image)) if err != nil { t.Fatalf("PutSource(%s) error = %v", format, err) } reader, size, got, err := cache.Source(t.Context(), src) if err != nil { t.Fatalf("Source() of the %s image error = %v", format, err) } served, err := io.ReadAll(reader) _ = reader.Close() if err != nil { t.Fatalf("reading the %s source: %v", format, err) } t.Logf("%s: %d bytes read as %s", format, size, got) if got != format { t.Errorf("Source() of the %s image format = %q, want %q", format, got, format) } if size != int64(len(image)) || !bytes.Equal(served, image) { t.Errorf("%s source read as %d bytes, size %d, want the %d bytes stored", format, len(served), size, len(image)) } } } // TestSource_RefusesFileOfNoSourceFormat checks that Source refuses a // cached source whose file's first bytes are not those of a source format, // with an error other than ErrNotFound. func TestSource_RefusesFileOfNoSourceFormat(t *testing.T) { t.Parallel() cache, _ := newEvictionTestCache(t, 1<<30) jpegImage := generateTestJPEG(t, 10, 10, color.RGBA{0, 0, 255, 255}) tests := []struct { name string content []byte wantErr error }{ {"text", []byte("this is text, not an image"), magic.ErrUnknownFormat}, {"SVG", []byte(``), magic.ErrUnknownFormat}, {"JPEG cut to 10 bytes", jpegImage[:10], io.EOF}, {"empty file", nil, io.EOF}, } for i, tt := range tests { path := fmt.Sprintf("/refused%d.jpg", i) storeEvictionTestSource(t, cache, testHostCDN, path, tt.content) reader, _, format, err := cache.Source(t.Context(), imagerequest.Source{Host: testHostCDN, Path: path}) t.Logf("%s: Source() error = %v", tt.name, err) if reader != nil || format != "" || !errors.Is(err, tt.wantErr) { t.Errorf("%s: Source() = reader %v, format %q, error %v; "+ "want no reader and %v", tt.name, reader, format, err, tt.wantErr) } } }