check / check (push) Canceled after 0s
The fetcher accepts image/svg+xml again and internal/magic detects SVG. A raster format renders the SVG at the least size that covers the output, held to 8192 on a side, through libvips' thumbnail, which renders at any scale, so an enlarged SVG stays sharp. An SVG's own size is not limited; one with a side more than 8192 times the other is ErrSVGTooThin, answered 415. orig of an SVG at the orig size is the SVG unchanged, cached as a variant; at any other size it is ErrSVGNotOriginalSize, answered 400. The cache reads up to 1024 first bytes of a file to find its format. Render time and memory grow with what the SVG draws; their bound is the owner's decision on #68. Tests that pinned SVG as refused now expect it to be read. Model: opus-5-5
213 lines
6.5 KiB
Go
213 lines
6.5 KiB
Go
package imgcache
|
|
|
|
import (
|
|
"bytes"
|
|
"errors"
|
|
"fmt"
|
|
"image/color"
|
|
"io"
|
|
"testing"
|
|
"time"
|
|
|
|
"sneak.berlin/go/pixa/internal/imageprocessor"
|
|
"sneak.berlin/go/pixa/internal/imagerequest"
|
|
"sneak.berlin/go/pixa/internal/magic"
|
|
)
|
|
|
|
// TestVariant_ContentTypeFromFirstBytes stores an image in each output
|
|
// format as a variant and checks that Variant serves it whole with the
|
|
// content type of its format, read from the file.
|
|
func TestVariant_ContentTypeFromFirstBytes(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
cache, _ := newEvictionTestCache(t, 1<<30)
|
|
processor := imageprocessor.New(imageprocessor.Params{
|
|
MaxInputBytes: 50 << 20,
|
|
MaxConcurrentProcessing: 1,
|
|
})
|
|
source := generateTestJPEG(t, 40, 30, color.RGBA{200, 40, 40, 255})
|
|
|
|
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(source),
|
|
imagerequest.FormatJPEG, &imagerequest.Transform{
|
|
Size: imagerequest.Size{Width: 20, Height: 15},
|
|
Format: format,
|
|
Quality: 85,
|
|
FitMode: imagerequest.FitCover,
|
|
})
|
|
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)
|
|
}
|
|
|
|
key := imagerequest.VariantKey("aabbccdd10" + string(format))
|
|
|
|
err = cache.PutVariant(t.Context(), key, bytes.NewReader(image))
|
|
if err != nil {
|
|
t.Fatalf("PutVariant(%s) error = %v", format, err)
|
|
}
|
|
|
|
reader, size, contentType, err := cache.Variant(key)
|
|
if err != nil {
|
|
t.Fatalf("Variant(%s) error = %v", format, err)
|
|
}
|
|
|
|
served, err := io.ReadAll(reader)
|
|
_ = reader.Close()
|
|
|
|
if err != nil {
|
|
t.Fatalf("reading the %s variant: %v", format, err)
|
|
}
|
|
|
|
t.Logf("%s: %d bytes served as %s", format, size, contentType)
|
|
|
|
if want := format.MIMEType(); contentType != want {
|
|
t.Errorf("%s variant content type = %q, want %q", format, contentType, want)
|
|
}
|
|
|
|
if size != int64(len(image)) || !bytes.Equal(served, image) {
|
|
t.Errorf("%s variant served %d bytes, size %d, want the %d bytes stored",
|
|
format, len(served), size, len(image))
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestVariant_RefusesFileOfNoOutputFormat checks that Variant refuses
|
|
// a variant file whose first bytes are not those of an output format, with
|
|
// an error other than ErrNotFound, and that a variant with no file, or any
|
|
// variant of a disabled cache, is ErrNotFound.
|
|
func TestVariant_RefusesFileOfNoOutputFormat(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},
|
|
{"JPEG cut to 10 bytes", jpegImage[:10], io.EOF},
|
|
{"empty", nil, io.EOF},
|
|
}
|
|
|
|
for i, tt := range tests {
|
|
key := imagerequest.VariantKey(fmt.Sprintf("aabbccdd20%02d", i))
|
|
|
|
err := cache.variants.Write(string(key), tt.content)
|
|
if err != nil {
|
|
t.Fatalf("%s: storing the variant file: %v", tt.name, err)
|
|
}
|
|
|
|
reader, _, contentType, err := cache.Variant(key)
|
|
t.Logf("%s: Variant() error = %v", tt.name, err)
|
|
|
|
if reader != nil || contentType != "" || !errors.Is(err, tt.wantErr) {
|
|
t.Errorf("%s: Variant() = reader %v, content type %q, error %v; "+
|
|
"want no reader and %v", tt.name, reader, contentType, err, tt.wantErr)
|
|
}
|
|
}
|
|
|
|
reader, _, _, err := cache.Variant(testVariantKeyFour)
|
|
if reader != nil || !errors.Is(err, ErrNotFound) {
|
|
t.Errorf("Variant() of a variant with no file = reader %v, error %v; "+
|
|
"want no reader and %v", reader, err, ErrNotFound)
|
|
}
|
|
|
|
disabled, _ := newEvictionTestCache(t, 0)
|
|
|
|
reader, _, _, err = disabled.Variant(testVariantKeyOne)
|
|
if reader != nil || !errors.Is(err, ErrNotFound) {
|
|
t.Errorf("Variant() on a disabled cache = reader %v, error %v; "+
|
|
"want no reader and %v", reader, err, ErrNotFound)
|
|
}
|
|
}
|
|
|
|
// TestTouchVariantAndTouchSourceContent_DropAccessTimesPastTheLimit hands one
|
|
// more access time of a variant, and one more of a source, than may wait to
|
|
// be written. touchVariant and touchSourceContent must return each time
|
|
// without waiting, and the last of each must be dropped.
|
|
func TestTouchVariantAndTouchSourceContent_DropAccessTimesPastTheLimit(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
cache, _ := newEvictionTestCache(t, 1<<30)
|
|
|
|
done := make(chan struct{})
|
|
|
|
go func() {
|
|
defer close(done)
|
|
|
|
for range maxPendingAccessTimes + 1 {
|
|
cache.touchVariant(testVariantKeyOne)
|
|
cache.touchSourceContent("aabbccdd3001")
|
|
}
|
|
}()
|
|
|
|
select {
|
|
case <-done:
|
|
case <-time.After(pendingAccessTimesWait):
|
|
t.Fatal("touchVariant or touchSourceContent waited once the access " +
|
|
"times reached the limit")
|
|
}
|
|
|
|
if waiting := len(cache.pendingVariantAccessTimes); waiting != maxPendingAccessTimes {
|
|
t.Errorf("%d access times of variants wait to be written, want %d",
|
|
waiting, maxPendingAccessTimes)
|
|
}
|
|
|
|
if waiting := len(cache.pendingSourceAccessTimes); waiting != maxPendingAccessTimes {
|
|
t.Errorf("%d access times of sources wait to be written, want %d",
|
|
waiting, maxPendingAccessTimes)
|
|
}
|
|
}
|
|
|
|
// TestStopPendingAccessTimeWrites_WritesPendingAccessTimes hands the access
|
|
// time of one of two variants, three times, then stops the goroutine that
|
|
// writes them, which never started. The stop must write that access time and
|
|
// leave the other variant's.
|
|
func TestStopPendingAccessTimeWrites_WritesPendingAccessTimes(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
cache, _ := newEvictionTestCache(t, 1<<30)
|
|
anHourAgo := time.Now().Add(-time.Hour)
|
|
|
|
for _, key := range []imagerequest.VariantKey{testVariantKeyOne, testVariantKeyTwo} {
|
|
storeEvictionTestVariant(t, cache, key, []byte("variant bytes"))
|
|
setVariantLastAccessed(t, cache, key, anHourAgo)
|
|
}
|
|
|
|
for range 3 {
|
|
cache.touchVariant(testVariantKeyOne)
|
|
}
|
|
|
|
err := cache.StopPendingAccessTimeWrites(t.Context())
|
|
if err != nil {
|
|
t.Fatalf("StopPendingAccessTimeWrites() error = %v", err)
|
|
}
|
|
|
|
for key, wantTouched := range map[imagerequest.VariantKey]int{
|
|
testVariantKeyOne: 1, testVariantKeyTwo: 0,
|
|
} {
|
|
touched := countRows(t, cache, `
|
|
SELECT COUNT(*) FROM variant_content
|
|
WHERE cache_key = ? AND last_accessed_at > ?
|
|
`, string(key), anHourAgo.UTC().Format(sqliteTimestampFormat))
|
|
if touched != wantTouched {
|
|
t.Errorf("variant %s: %d rows with a new access time, want %d",
|
|
key, touched, wantTouched)
|
|
}
|
|
}
|
|
|
|
if waiting := len(cache.pendingVariantAccessTimes); waiting != 0 {
|
|
t.Errorf("%d access times still wait to be written, want 0", waiting)
|
|
}
|
|
}
|