package imageprocessor import ( "bytes" "context" "errors" "image" "image/color" "image/jpeg" "image/png" "io" "os" "testing" "github.com/davidbyttow/govips/v2/vips" ) func TestMain(m *testing.M) { initVips() code := m.Run() vips.Shutdown() os.Exit(code) } // createTestJPEG creates a simple test JPEG image. func createTestJPEG(t *testing.T, width, height int) []byte { t.Helper() img := image.NewRGBA(image.Rect(0, 0, width, height)) // Fill with a gradient for y := range height { for x := range width { img.Set(x, y, color.RGBA{ R: uint8((x * 255 / width) & 0xff), G: uint8((y * 255 / height) & 0xff), B: 128, A: 255, }) } } var buf bytes.Buffer err := jpeg.Encode(&buf, img, &jpeg.Options{Quality: 90}) if err != nil { t.Fatalf("failed to encode test JPEG: %v", err) } return buf.Bytes() } // createTestPNG creates a simple test PNG image. func createTestPNG(t *testing.T, width, height int) []byte { t.Helper() img := image.NewRGBA(image.Rect(0, 0, width, height)) for y := range height { for x := range width { img.Set(x, y, color.RGBA{ R: uint8((x * 255 / width) & 0xff), G: uint8((y * 255 / height) & 0xff), B: 128, A: 255, }) } } var buf bytes.Buffer err := png.Encode(&buf, img) if err != nil { t.Fatalf("failed to encode test PNG: %v", err) } return buf.Bytes() } // isAVIF reports whether data starts with an AVIF ftyp box. func isAVIF(data []byte) bool { if len(data) < 12 || string(data[4:8]) != "ftyp" { return false } brand := string(data[8:12]) return brand == string(FormatAVIF) || brand == "avis" } // detectMIME is a minimal magic-byte detector for test assertions. func detectMIME(data []byte) string { if len(data) >= 3 && data[0] == 0xFF && data[1] == 0xD8 && data[2] == 0xFF { return mimeJPEG } if len(data) >= 8 && string(data[:8]) == "\x89PNG\r\n\x1a\n" { return mimePNG } if len(data) >= 4 && string(data[:4]) == "GIF8" { return mimeGIF } if len(data) >= 12 && string(data[:4]) == "RIFF" && string(data[8:12]) == "WEBP" { return mimeWebP } if isAVIF(data) { return mimeAVIF } return "" } func TestImageProcessor_ResizeJPEG(t *testing.T) { t.Parallel() proc := New(Params{}) ctx := context.Background() input := createTestJPEG(t, 800, 600) req := &Request{ Size: Size{Width: 400, Height: 300}, Format: FormatJPEG, Quality: 85, FitMode: FitCover, } result, err := proc.Process(ctx, bytes.NewReader(input), req) if err != nil { t.Fatalf("Process() error = %v", err) } defer func() { _ = result.Content.Close() }() if result.Width != 400 { t.Errorf("Process() width = %d, want 400", result.Width) } if result.Height != 300 { t.Errorf("Process() height = %d, want 300", result.Height) } if result.ContentLength == 0 { t.Error("Process() returned zero content length") } // Verify it's valid JPEG by reading the content data, err := io.ReadAll(result.Content) if err != nil { t.Fatalf("failed to read result: %v", err) } mime := detectMIME(data) if mime != mimeJPEG { t.Errorf("Output format = %v, want image/jpeg", mime) } } func TestImageProcessor_ConvertToPNG(t *testing.T) { t.Parallel() proc := New(Params{}) ctx := context.Background() input := createTestJPEG(t, 200, 150) req := &Request{ Size: Size{Width: 200, Height: 150}, Format: FormatPNG, FitMode: FitCover, } result, err := proc.Process(ctx, bytes.NewReader(input), req) if err != nil { t.Fatalf("Process() error = %v", err) } defer func() { _ = result.Content.Close() }() data, err := io.ReadAll(result.Content) if err != nil { t.Fatalf("failed to read result: %v", err) } mime := detectMIME(data) if mime != mimePNG { t.Errorf("Output format = %v, want image/png", mime) } } // processAndCheckSize processes a test JPEG of the given input dimensions // with the requested size and asserts the resulting dimensions. func processAndCheckSize( t *testing.T, inputW, inputH int, size Size, wantW, wantH int, ) { t.Helper() proc := New(Params{}) ctx := context.Background() input := createTestJPEG(t, inputW, inputH) req := &Request{ Size: size, Format: FormatJPEG, Quality: 85, FitMode: FitCover, } result, err := proc.Process(ctx, bytes.NewReader(input), req) if err != nil { t.Fatalf("Process() error = %v", err) } defer func() { _ = result.Content.Close() }() if result.Width != wantW { t.Errorf("Process() width = %d, want %d", result.Width, wantW) } if result.Height != wantH { t.Errorf("Process() height = %d, want %d", result.Height, wantH) } } func TestImageProcessor_OriginalSize(t *testing.T) { t.Parallel() // Width and height 0: keep original size processAndCheckSize(t, 640, 480, Size{Width: 0, Height: 0}, 640, 480) } func TestImageProcessor_FitContain(t *testing.T) { t.Parallel() proc := New(Params{}) ctx := context.Background() // 800x400 image (2:1 aspect) into 400x400 box with contain // Should result in 400x200 (maintaining aspect ratio) input := createTestJPEG(t, 800, 400) req := &Request{ Size: Size{Width: 400, Height: 400}, Format: FormatJPEG, Quality: 85, FitMode: FitContain, } result, err := proc.Process(ctx, bytes.NewReader(input), req) if err != nil { t.Fatalf("Process() error = %v", err) } defer func() { _ = result.Content.Close() }() // With contain, the image should fit within the box if result.Width > 400 || result.Height > 400 { t.Errorf("Process() size %dx%d exceeds 400x400 box", result.Width, result.Height) } } func TestImageProcessor_ProportionalScale_WidthOnly(t *testing.T) { t.Parallel() // 800x600 image, request width=400 height=0 // Should scale proportionally to 400x300 processAndCheckSize(t, 800, 600, Size{Width: 400, Height: 0}, 400, 300) } func TestImageProcessor_ProportionalScale_HeightOnly(t *testing.T) { t.Parallel() // 800x600 image, request width=0 height=300 // Should scale proportionally to 400x300 processAndCheckSize(t, 800, 600, Size{Width: 0, Height: 300}, 400, 300) } func TestImageProcessor_ProcessPNG(t *testing.T) { t.Parallel() proc := New(Params{}) ctx := context.Background() input := createTestPNG(t, 400, 300) req := &Request{ Size: Size{Width: 200, Height: 150}, Format: FormatPNG, FitMode: FitCover, } result, err := proc.Process(ctx, bytes.NewReader(input), req) if err != nil { t.Fatalf("Process() error = %v", err) } defer func() { _ = result.Content.Close() }() if result.Width != 200 { t.Errorf("Process() width = %d, want 200", result.Width) } if result.Height != 150 { t.Errorf("Process() height = %d, want 150", result.Height) } } func TestImageProcessor_SupportedFormats(t *testing.T) { t.Parallel() proc := New(Params{}) inputFormats := proc.SupportedInputFormats() if len(inputFormats) == 0 { t.Error("SupportedInputFormats() returned empty slice") } outputFormats := proc.SupportedOutputFormats() if len(outputFormats) == 0 { t.Error("SupportedOutputFormats() returned empty slice") } } func TestImageProcessor_RejectsOversizedInput(t *testing.T) { t.Parallel() // Images exceeding MaxInputDimension in either dimension must be // rejected before processing to prevent DoS. tests := []struct { name string width int height int }{ {name: "oversized width", width: 10000, height: 100}, {name: "oversized height", width: 100, height: 10000}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { t.Parallel() proc := New(Params{}) ctx := context.Background() input := createTestJPEG(t, tt.width, tt.height) req := &Request{ Size: Size{Width: 100, Height: 100}, Format: FormatJPEG, Quality: 85, FitMode: FitCover, } _, err := proc.Process(ctx, bytes.NewReader(input), req) if err == nil { t.Error("Process() should reject oversized input images") } if !errors.Is(err, ErrInputTooLarge) { t.Errorf("Process() error = %v, want ErrInputTooLarge", err) } }) } } func TestImageProcessor_AcceptsMaxDimensionInput(t *testing.T) { t.Parallel() proc := New(Params{}) ctx := context.Background() // Create an image at exactly MaxInputDimension - should be accepted input := createTestJPEG(t, MaxInputDimension, 100) req := &Request{ Size: Size{Width: 100, Height: 100}, Format: FormatJPEG, Quality: 85, FitMode: FitCover, } result, err := proc.Process(ctx, bytes.NewReader(input), req) if err != nil { t.Fatalf( "Process() should accept images at MaxInputDimension, got error: %v", err, ) } defer func() { _ = result.Content.Close() }() } // encodeAndCheck processes a 200x150 test JPEG into a 100x75 output of the // given format and asserts the output MIME type and dimensions. func encodeAndCheck(t *testing.T, format Format, quality int, wantMIME string) { t.Helper() proc := New(Params{}) ctx := context.Background() input := createTestJPEG(t, 200, 150) req := &Request{ Size: Size{Width: 100, Height: 75}, Format: format, Quality: quality, FitMode: FitCover, } result, err := proc.Process(ctx, bytes.NewReader(input), req) if err != nil { t.Fatalf("Process() error = %v, want nil", err) } defer func() { _ = result.Content.Close() }() // Verify output format data, err := io.ReadAll(result.Content) if err != nil { t.Fatalf("failed to read result: %v", err) } mime := detectMIME(data) if mime != wantMIME { t.Errorf("Output format = %v, want %v", mime, wantMIME) } // Verify dimensions if result.Width != 100 { t.Errorf("Width = %d, want 100", result.Width) } if result.Height != 75 { t.Errorf("Height = %d, want 75", result.Height) } } func TestImageProcessor_EncodeWebP(t *testing.T) { t.Parallel() encodeAndCheck(t, FormatWebP, 80, mimeWebP) } func TestImageProcessor_DecodeAVIF(t *testing.T) { t.Parallel() proc := New(Params{}) ctx := context.Background() // Load test AVIF file input, err := os.ReadFile("testdata/red.avif") if err != nil { t.Fatalf("failed to read test AVIF: %v", err) } // Request resize and convert to JPEG req := &Request{ Size: Size{Width: 2, Height: 2}, Format: FormatJPEG, Quality: 85, FitMode: FitCover, } result, err := proc.Process(ctx, bytes.NewReader(input), req) if err != nil { t.Fatalf("Process() error = %v, want nil (AVIF decoding should work)", err) } defer func() { _ = result.Content.Close() }() // Verify output is valid JPEG data, err := io.ReadAll(result.Content) if err != nil { t.Fatalf("failed to read result: %v", err) } mime := detectMIME(data) if mime != mimeJPEG { t.Errorf("Output format = %v, want image/jpeg", mime) } } func TestImageProcessor_RejectsOversizedInputData(t *testing.T) { t.Parallel() // Create a processor with a very small byte limit const limit = 1024 proc := New(Params{MaxInputBytes: limit}) ctx := context.Background() // Create a valid JPEG that exceeds the byte limit input := createTestJPEG(t, 800, 600) // will be well over 1 KiB if int64(len(input)) <= limit { t.Fatalf("test JPEG must exceed %d bytes, got %d", limit, len(input)) } req := &Request{ Size: Size{Width: 100, Height: 75}, Format: FormatJPEG, Quality: 85, FitMode: FitCover, } _, err := proc.Process(ctx, bytes.NewReader(input), req) if err == nil { t.Fatal("Process() should reject input exceeding maxInputBytes") } if !errors.Is(err, ErrInputDataTooLarge) { t.Errorf("Process() error = %v, want ErrInputDataTooLarge", err) } } func TestImageProcessor_AcceptsInputWithinLimit(t *testing.T) { t.Parallel() // Create a small image and set limit well above its size input := createTestJPEG(t, 10, 10) limit := int64(len(input)) * 10 // 10× headroom proc := New(Params{MaxInputBytes: limit}) ctx := context.Background() req := &Request{ Size: Size{Width: 10, Height: 10}, Format: FormatJPEG, Quality: 85, FitMode: FitCover, } result, err := proc.Process(ctx, bytes.NewReader(input), req) if err != nil { t.Fatalf("Process() error = %v, want nil", err) } defer func() { _ = result.Content.Close() }() } func TestImageProcessor_DefaultMaxInputBytes(t *testing.T) { t.Parallel() // Passing 0 should use the default proc := New(Params{}) if proc.maxInputBytes != DefaultMaxInputBytes { t.Errorf("maxInputBytes = %d, want %d", proc.maxInputBytes, DefaultMaxInputBytes) } // Passing negative should also use the default proc = New(Params{MaxInputBytes: -1}) if proc.maxInputBytes != DefaultMaxInputBytes { t.Errorf("maxInputBytes = %d, want %d", proc.maxInputBytes, DefaultMaxInputBytes) } } func TestImageProcessor_EncodeAVIF(t *testing.T) { t.Parallel() encodeAndCheck(t, FormatAVIF, 85, mimeAVIF) }