diff --git a/internal/handlers/avif_too_large_internal_test.go b/internal/handlers/avif_too_large_internal_test.go new file mode 100644 index 0000000..c634a03 --- /dev/null +++ b/internal/handlers/avif_too_large_internal_test.go @@ -0,0 +1,49 @@ +package handlers + +import ( + "fmt" + "log/slog" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "sneak.berlin/go/pixa/internal/imageprocessor" + "sneak.berlin/go/pixa/internal/imgcache" +) + +// TestAVIFOutputTooLargeAnswers400 checks that respondImageError and +// handleImageError, which answer the errors of the two image routes, answer +// 400 naming the AVIF pixel limit when the image processor refuses an AVIF +// output above it, wrapped as the service wraps it. The request's source only +// goes into a log line, so it is left empty. +func TestAVIFOutputTooLargeAnswers400(t *testing.T) { + t.Parallel() + + h := &Handlers{log: slog.New(slog.DiscardHandler)} + err := fmt.Errorf("image processing failed: %w: 4097x4096", + imageprocessor.ErrAVIFOutputTooLarge) + wantBody := fmt.Sprintf("above %d pixels", imageprocessor.MaxAVIFOutputPixels) + + plain := httptest.NewRecorder() + h.respondImageError(plain, &imgcache.ImageRequest{}, err) + + encrypted := httptest.NewRecorder() + h.handleImageError(encrypted, err) + + for name, rec := range map[string]*httptest.ResponseRecorder{ + "respondImageError": plain, "handleImageError": encrypted, + } { + t.Logf("%s: status = %d, body = %q", name, rec.Code, rec.Body.String()) + + if rec.Code != http.StatusBadRequest { + t.Errorf("%s: status = %d, want %d", + name, rec.Code, http.StatusBadRequest) + } + + if !strings.Contains(rec.Body.String(), wantBody) { + t.Errorf("%s: body = %q, want it to say %q", + name, rec.Body.String(), wantBody) + } + } +} diff --git a/internal/imageprocessor/avif_too_large_internal_test.go b/internal/imageprocessor/avif_too_large_internal_test.go new file mode 100644 index 0000000..029ad2a --- /dev/null +++ b/internal/imageprocessor/avif_too_large_internal_test.go @@ -0,0 +1,64 @@ +package imageprocessor + +import ( + "bytes" + "errors" + "testing" +) + +// TestImageProcessor_RefusesAVIFAboveMaxPixels verifies that an AVIF output +// of more than MaxAVIFOutputPixels is refused with ErrAVIFOutputTooLarge +// before anything is decoded, whatever size and fit give it, and that an AVIF +// output of at most that many pixels, or one in another format, is not. The +// input is a 64x32 PNG cut off in its pixel data: libvips reads its size from +// its header but fails when it decodes it, so a request that gets that failure +// was not refused, and a refusal came before any decoding. +func TestImageProcessor_RefusesAVIFAboveMaxPixels(t *testing.T) { + t.Parallel() + + whole := createTestPNG(t, 64, 32) + cutOff := whole[:len(whole)/2] + + tests := []struct { + name string + format Format + width, height int + fit FitMode + refused bool + }{ + {"AVIF at the limit", FormatAVIF, 4096, 4096, FitCover, false}, + {"AVIF above the limit", FormatAVIF, 4096, 4097, FitCover, true}, + {"WebP above the AVIF limit", FormatWebP, 4096, 4097, FitCover, false}, + // FitInside does not enlarge, so the output stays 64x32 + {"AVIF inside a larger size", FormatAVIF, 8192, 8192, FitInside, false}, + // FitOutside scales 64x32 by 128 to cover 8192x1: 8192x4096 + {"AVIF outside a smaller size", FormatAVIF, 8192, 1, FitOutside, true}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + _, err := New(Params{}).Process(t.Context(), bytes.NewReader(cutOff), + &Request{ + Size: Size{Width: tt.width, Height: tt.height}, + Format: tt.format, + FitMode: tt.fit, + }) + t.Logf("Process() error = %v", err) + + if tt.refused { + if !errors.Is(err, ErrAVIFOutputTooLarge) { + t.Errorf("Process() error = %v, want %v", err, ErrAVIFOutputTooLarge) + } + + return + } + + if err == nil || errors.Is(err, ErrAVIFOutputTooLarge) { + t.Errorf("Process() error = %v, want the failure to decode "+ + "the cut-off input", err) + } + }) + } +} diff --git a/internal/imageprocessor/encoder_settings_internal_test.go b/internal/imageprocessor/encoder_settings_internal_test.go index 90634ce..af1b31d 100644 --- a/internal/imageprocessor/encoder_settings_internal_test.go +++ b/internal/imageprocessor/encoder_settings_internal_test.go @@ -77,10 +77,10 @@ func TestImageProcessor_PNGIsCompressed(t *testing.T) { } } -// TestImageProcessor_WebPAtDefaultEffort verifies that WebP is saved at -// libvips' default effort, 4: the output is the size of the same image saved -// at that effort. -func TestImageProcessor_WebPAtDefaultEffort(t *testing.T) { +// TestImageProcessor_WebPAtEffort2 verifies that WebP is saved at effort 2, +// not libvips' default of 4: the output is the size of the same image saved +// at effort 2. +func TestImageProcessor_WebPAtEffort2(t *testing.T) { t.Parallel() input := createTestJPEG(t, 200, 150) @@ -90,14 +90,14 @@ func TestImageProcessor_WebPAtDefaultEffort(t *testing.T) { want, _, err := decode(t, input).ExportWebp(&vips.WebpExportParams{ StripMetadata: true, Quality: 85, - ReductionEffort: 4, + ReductionEffort: 2, }) if err != nil { t.Fatalf("ExportWebp() error = %v", err) } if size != int64(len(want)) { - t.Errorf("WebP output is %d bytes, want %d, the size at effort 4", + t.Errorf("WebP output is %d bytes, want %d, the size at effort 2", size, len(want)) } } @@ -143,3 +143,29 @@ func TestImageProcessor_AVIFAtEffort1(t *testing.T) { "and 8 bits", size, len(want)) } } + +// TestImageProcessor_JPEGXLAtEffort4 verifies that JPEG XL is saved at +// effort 4, not libvips' default of 7: the output is the size of the same +// image saved at effort 4, with the distance exportJXL gives quality 85. +func TestImageProcessor_JPEGXLAtEffort4(t *testing.T) { + t.Parallel() + + input := createTestJPEG(t, 200, 150) + + size := processedSize(t, input, &Request{Format: FormatJXL, Quality: 85}) + + params := vips.NewJxlExportParams() + params.Quality = 85 + params.Distance = jxlDistance(85) + params.Effort = 4 + + want, _, err := decode(t, input).ExportJxl(params) + if err != nil { + t.Fatalf("ExportJxl() error = %v", err) + } + + if size != int64(len(want)) { + t.Errorf("JPEG XL output is %d bytes, want %d, the size at effort 4", + size, len(want)) + } +}