check / check (push) Canceled after 0s
The new package internal/imageproxy holds Proxy, which was imgcache.Service, with its Config, ImageResponse, CacheStatus and errors. internal/imgcache holds the cache only, and imgcache.CacheConfig is imgcache.Config. The cache reads and writes each kind it keeps with one pair of methods: Variant and PutVariant, Source and PutSource, Failure and PutFailure. Source replaces LookupSource followed by GetSourceContent. imgcache.go and module.go are gone. The proxy's tests moved with it. They reach the cache through its database, its state directory and its metrics registry, and run its reconciliation pass with a second cache, as pixad does at startup. Model: opus-5-5
215 lines
6.6 KiB
Go
215 lines
6.6 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.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 := imageprocessor.FormatToMIME(format); 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},
|
|
{"SVG", []byte(`<svg xmlns="http://www.w3.org/2000/svg"></svg>`),
|
|
errNotAnOutputFormat},
|
|
{"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.Store(key, bytes.NewReader(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)
|
|
}
|
|
}
|