package imgcache import ( "bytes" "errors" "fmt" "image/color" "io" "os" "testing" "time" "sneak.berlin/go/pixa/internal/imageprocessor" "sneak.berlin/go/pixa/internal/imagerequest" "sneak.berlin/go/pixa/internal/magic" ) // TestGetVariant_ContentTypeFromFirstBytes stores an image in each output // format as a variant, recorded with the content type text/plain, and checks // that GetVariant serves it whole with the content type of its format, read // from the file. func TestGetVariant_ContentTypeFromFirstBytes(t *testing.T) { t.Parallel() cache, _ := newEvictionTestCache(t, 1<<30) processor := imageprocessor.New(imageprocessor.Params{}) 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.StoreVariant(t.Context(), key, bytes.NewReader(image), "text/plain") if err != nil { t.Fatalf("StoreVariant(%s) error = %v", format, err) } reader, size, contentType, err := cache.GetVariant(key) if err != nil { t.Fatalf("GetVariant(%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)) } } } // TestGetVariant_RefusesFileOfNoOutputFormat checks that GetVariant 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 TestGetVariant_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(``), 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.GetVariant(key) t.Logf("%s: GetVariant() error = %v", tt.name, err) if reader != nil || contentType != "" || !errors.Is(err, tt.wantErr) { t.Errorf("%s: GetVariant() = reader %v, content type %q, error %v; "+ "want no reader and %v", tt.name, reader, contentType, err, tt.wantErr) } } reader, _, _, err := cache.GetVariant(testVariantKeyFour) if reader != nil || !errors.Is(err, ErrNotFound) { t.Errorf("GetVariant() 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.GetVariant(testVariantKeyOne) if reader != nil || !errors.Is(err, ErrNotFound) { t.Errorf("GetVariant() on a disabled cache = reader %v, error %v; "+ "want no reader and %v", reader, err, ErrNotFound) } } // TestService_Get_MakesAgainVariantOfNoOutputFormat replaces a cached // variant's file with text, and checks that the next request is a miss // answered with the variant made again, and the one after a hit. func TestService_Get_MakesAgainVariantOfNoOutputFormat(t *testing.T) { t.Parallel() svc, fixtures := SetupTestService(t) req := photoVariant(fixtures, 85, imagerequest.FitCover) want, _ := getImage(t, svc, req) path := svc.cache.variants.keyToPath(imagerequest.CacheKey(req)) err := os.WriteFile(path, []byte("this is text, not an image"), StorageFilePerm) if err != nil { t.Fatalf("replacing the variant file: %v", err) } got, status := getImage(t, svc, req) t.Logf("after the replacement: %d bytes, %s", len(got), status) if status != CacheMiss || !bytes.Equal(got, want) { t.Errorf("after the replacement: Get() = %d bytes, %s; want the %d bytes "+ "made again, %s", len(got), status, len(want), CacheMiss) } got, status = getImage(t, svc, req) 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_HitWaitsForNoDatabaseWrite holds the database's one // connection while a cached variant is requested. The hit must be answered // while the connection is still held, and its access time and hit count // must reach the database once it is free. func TestService_Get_HitWaitsForNoDatabaseWrite(t *testing.T) { t.Parallel() svc, fixtures := SetupTestService(t) req := photoVariant(fixtures, 85, imagerequest.FitCover) key := imagerequest.CacheKey(req) getImage(t, svc, req) waitForPendingCountsWritten(t, svc.cache) anHourAgo := time.Now().Add(-time.Hour) setVariantLastAccessed(t, svc.cache, key, anHourAgo) want := readCacheStatsCounters(t, svc.cache) want.hitCount++ releaseDatabase := holdDatabase(t, svc.cache) defer releaseDatabase() results := startGet(t.Context(), svc, req) select { case got := <-results: if got.err != nil { t.Fatalf("Get() of the cached variant error = %v", got.err) } case <-time.After(pendingCountsWait): t.Fatal("the cache hit waited for the database") } releaseDatabase() waitForCounters(t, svc.cache, want) deadline := time.Now().Add(pendingCountsWait) for countRows(t, svc.cache, ` SELECT COUNT(*) FROM variant_content WHERE cache_key = ? AND last_accessed_at > ? `, string(key), anHourAgo.UTC().Format(sqliteTimestampFormat)) == 0 { if time.Now().After(deadline) { t.Fatal("the hit's access time was not written") } time.Sleep(10 * time.Millisecond) } } // 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(pendingCountsWait): 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) } } // TestStopPendingCountWrites_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 TestStopPendingCountWrites_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.StopPendingCountWrites(t.Context()) if err != nil { t.Fatalf("StopPendingCountWrites() 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) } }