All checks were successful
check / check (push) Successful in 1m36s
Rename NewImageProcessor(maxInputBytes) to New(Params{}) with a Params
struct containing MaxInputBytes. Zero-value Params{} uses sensible
defaults (DefaultMaxInputBytes). All callers updated.
Addresses review feedback on PR #37.
575 lines
13 KiB
Go
575 lines
13 KiB
Go
package imgcache
|
||
|
||
import (
|
||
"bytes"
|
||
"context"
|
||
"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 := 0; y < height; y++ {
|
||
for x := 0; x < width; x++ {
|
||
img.Set(x, y, color.RGBA{
|
||
R: uint8(x * 255 / width),
|
||
G: uint8(y * 255 / height),
|
||
B: 128,
|
||
A: 255,
|
||
})
|
||
}
|
||
}
|
||
|
||
var buf bytes.Buffer
|
||
if err := jpeg.Encode(&buf, img, &jpeg.Options{Quality: 90}); 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 := 0; y < height; y++ {
|
||
for x := 0; x < width; x++ {
|
||
img.Set(x, y, color.RGBA{
|
||
R: uint8(x * 255 / width),
|
||
G: uint8(y * 255 / height),
|
||
B: 128,
|
||
A: 255,
|
||
})
|
||
}
|
||
}
|
||
|
||
var buf bytes.Buffer
|
||
if err := png.Encode(&buf, img); err != nil {
|
||
t.Fatalf("failed to encode test PNG: %v", err)
|
||
}
|
||
|
||
return buf.Bytes()
|
||
}
|
||
|
||
func TestImageProcessor_ResizeJPEG(t *testing.T) {
|
||
proc := New(Params{})
|
||
ctx := context.Background()
|
||
|
||
input := createTestJPEG(t, 800, 600)
|
||
|
||
req := &ImageRequest{
|
||
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 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, err := DetectFormat(data)
|
||
if err != nil {
|
||
t.Fatalf("DetectFormat() error = %v", err)
|
||
}
|
||
|
||
if mime != MIMETypeJPEG {
|
||
t.Errorf("Output format = %v, want %v", mime, MIMETypeJPEG)
|
||
}
|
||
}
|
||
|
||
func TestImageProcessor_ConvertToPNG(t *testing.T) {
|
||
proc := New(Params{})
|
||
ctx := context.Background()
|
||
|
||
input := createTestJPEG(t, 200, 150)
|
||
|
||
req := &ImageRequest{
|
||
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 result.Content.Close()
|
||
|
||
data, err := io.ReadAll(result.Content)
|
||
if err != nil {
|
||
t.Fatalf("failed to read result: %v", err)
|
||
}
|
||
|
||
mime, err := DetectFormat(data)
|
||
if err != nil {
|
||
t.Fatalf("DetectFormat() error = %v", err)
|
||
}
|
||
|
||
if mime != MIMETypePNG {
|
||
t.Errorf("Output format = %v, want %v", mime, MIMETypePNG)
|
||
}
|
||
}
|
||
|
||
func TestImageProcessor_OriginalSize(t *testing.T) {
|
||
proc := New(Params{})
|
||
ctx := context.Background()
|
||
|
||
input := createTestJPEG(t, 640, 480)
|
||
|
||
req := &ImageRequest{
|
||
Size: Size{Width: 0, Height: 0}, // Original 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 result.Content.Close()
|
||
|
||
if result.Width != 640 {
|
||
t.Errorf("Process() width = %d, want 640", result.Width)
|
||
}
|
||
|
||
if result.Height != 480 {
|
||
t.Errorf("Process() height = %d, want 480", result.Height)
|
||
}
|
||
}
|
||
|
||
func TestImageProcessor_FitContain(t *testing.T) {
|
||
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 := &ImageRequest{
|
||
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 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) {
|
||
proc := New(Params{})
|
||
ctx := context.Background()
|
||
|
||
// 800x600 image, request width=400 height=0
|
||
// Should scale proportionally to 400x300
|
||
input := createTestJPEG(t, 800, 600)
|
||
|
||
req := &ImageRequest{
|
||
Size: Size{Width: 400, Height: 0},
|
||
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 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)
|
||
}
|
||
}
|
||
|
||
func TestImageProcessor_ProportionalScale_HeightOnly(t *testing.T) {
|
||
proc := New(Params{})
|
||
ctx := context.Background()
|
||
|
||
// 800x600 image, request width=0 height=300
|
||
// Should scale proportionally to 400x300
|
||
input := createTestJPEG(t, 800, 600)
|
||
|
||
req := &ImageRequest{
|
||
Size: Size{Width: 0, 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 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)
|
||
}
|
||
}
|
||
|
||
func TestImageProcessor_ProcessPNG(t *testing.T) {
|
||
proc := New(Params{})
|
||
ctx := context.Background()
|
||
|
||
input := createTestPNG(t, 400, 300)
|
||
|
||
req := &ImageRequest{
|
||
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 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_ImplementsInterface(t *testing.T) {
|
||
// Verify ImageProcessor implements Processor interface
|
||
var _ Processor = (*ImageProcessor)(nil)
|
||
}
|
||
|
||
func TestImageProcessor_SupportedFormats(t *testing.T) {
|
||
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) {
|
||
proc := New(Params{})
|
||
ctx := context.Background()
|
||
|
||
// Create an image that exceeds MaxInputDimension (e.g., 10000x100)
|
||
// This should be rejected before processing to prevent DoS
|
||
input := createTestJPEG(t, 10000, 100)
|
||
|
||
req := &ImageRequest{
|
||
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 err != ErrInputTooLarge {
|
||
t.Errorf("Process() error = %v, want ErrInputTooLarge", err)
|
||
}
|
||
}
|
||
|
||
func TestImageProcessor_RejectsOversizedInputHeight(t *testing.T) {
|
||
proc := New(Params{})
|
||
ctx := context.Background()
|
||
|
||
// Create an image with oversized height
|
||
input := createTestJPEG(t, 100, 10000)
|
||
|
||
req := &ImageRequest{
|
||
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 err != ErrInputTooLarge {
|
||
t.Errorf("Process() error = %v, want ErrInputTooLarge", err)
|
||
}
|
||
}
|
||
|
||
func TestImageProcessor_AcceptsMaxDimensionInput(t *testing.T) {
|
||
proc := New(Params{})
|
||
ctx := context.Background()
|
||
|
||
// Create an image at exactly MaxInputDimension - should be accepted
|
||
// Using smaller dimensions to keep test fast
|
||
input := createTestJPEG(t, MaxInputDimension, 100)
|
||
|
||
req := &ImageRequest{
|
||
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 result.Content.Close()
|
||
}
|
||
|
||
func TestImageProcessor_EncodeWebP(t *testing.T) {
|
||
proc := New(Params{})
|
||
ctx := context.Background()
|
||
|
||
input := createTestJPEG(t, 200, 150)
|
||
|
||
req := &ImageRequest{
|
||
Size: Size{Width: 100, Height: 75},
|
||
Format: FormatWebP,
|
||
Quality: 80,
|
||
FitMode: FitCover,
|
||
}
|
||
|
||
result, err := proc.Process(ctx, bytes.NewReader(input), req)
|
||
if err != nil {
|
||
t.Fatalf("Process() error = %v, want nil", err)
|
||
}
|
||
defer result.Content.Close()
|
||
|
||
// Verify output is valid WebP
|
||
data, err := io.ReadAll(result.Content)
|
||
if err != nil {
|
||
t.Fatalf("failed to read result: %v", err)
|
||
}
|
||
|
||
mime, err := DetectFormat(data)
|
||
if err != nil {
|
||
t.Fatalf("DetectFormat() error = %v", err)
|
||
}
|
||
|
||
if mime != MIMETypeWebP {
|
||
t.Errorf("Output format = %v, want %v", mime, MIMETypeWebP)
|
||
}
|
||
|
||
// 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_DecodeAVIF(t *testing.T) {
|
||
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 := &ImageRequest{
|
||
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 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, err := DetectFormat(data)
|
||
if err != nil {
|
||
t.Fatalf("DetectFormat() error = %v", err)
|
||
}
|
||
|
||
if mime != MIMETypeJPEG {
|
||
t.Errorf("Output format = %v, want %v", mime, MIMETypeJPEG)
|
||
}
|
||
}
|
||
|
||
func TestImageProcessor_RejectsOversizedInputData(t *testing.T) {
|
||
// 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 := &ImageRequest{
|
||
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 err != ErrInputDataTooLarge {
|
||
t.Errorf("Process() error = %v, want ErrInputDataTooLarge", err)
|
||
}
|
||
}
|
||
|
||
func TestImageProcessor_AcceptsInputWithinLimit(t *testing.T) {
|
||
// 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 := &ImageRequest{
|
||
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 result.Content.Close()
|
||
}
|
||
|
||
func TestImageProcessor_DefaultMaxInputBytes(t *testing.T) {
|
||
// 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) {
|
||
proc := New(Params{})
|
||
ctx := context.Background()
|
||
|
||
input := createTestJPEG(t, 200, 150)
|
||
|
||
req := &ImageRequest{
|
||
Size: Size{Width: 100, Height: 75},
|
||
Format: FormatAVIF,
|
||
Quality: 85,
|
||
FitMode: FitCover,
|
||
}
|
||
|
||
result, err := proc.Process(ctx, bytes.NewReader(input), req)
|
||
if err != nil {
|
||
t.Fatalf("Process() error = %v, want nil (AVIF encoding should work)", err)
|
||
}
|
||
defer result.Content.Close()
|
||
|
||
// Verify output is valid AVIF
|
||
data, err := io.ReadAll(result.Content)
|
||
if err != nil {
|
||
t.Fatalf("failed to read result: %v", err)
|
||
}
|
||
|
||
mime, err := DetectFormat(data)
|
||
if err != nil {
|
||
t.Fatalf("DetectFormat() error = %v", err)
|
||
}
|
||
|
||
if mime != MIMETypeAVIF {
|
||
t.Errorf("Output format = %v, want %v", mime, MIMETypeAVIF)
|
||
}
|
||
|
||
// 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)
|
||
}
|
||
}
|