All checks were successful
check / check (push) Successful in 58s
Move ImageProcessor, Params, New(), DefaultMaxInputBytes, ErrInputDataTooLarge, and related types from internal/imgcache/ into a new standalone package internal/imageprocessor/. The imageprocessor package defines its own Format, FitMode, Size, Request, and Result types, making it fully independent with no imports from imgcache. The imgcache service converts between its own types and imageprocessor types at the boundary. Changes: - New package: internal/imageprocessor/ with imageprocessor.go and tests - Removed: processor.go and processor_test.go from internal/imgcache/ - Removed: Processor interface and ProcessResult from imgcache.go (now unused) - Updated: service.go uses *imageprocessor.ImageProcessor directly - Copied: testdata/red.avif for AVIF decode test Addresses review feedback on PR #37: image processing is a distinct concern from the HTTP service layer and belongs in its own package.
572 lines
13 KiB
Go
572 lines
13 KiB
Go
package imageprocessor
|
||
|
||
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()
|
||
}
|
||
|
||
// 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 "image/jpeg"
|
||
}
|
||
if len(data) >= 8 && string(data[:8]) == "\x89PNG\r\n\x1a\n" {
|
||
return "image/png"
|
||
}
|
||
if len(data) >= 4 && string(data[:4]) == "GIF8" {
|
||
return "image/gif"
|
||
}
|
||
if len(data) >= 12 && string(data[:4]) == "RIFF" && string(data[8:12]) == "WEBP" {
|
||
return "image/webp"
|
||
}
|
||
if len(data) >= 12 && string(data[4:8]) == "ftyp" {
|
||
brand := string(data[8:12])
|
||
if brand == "avif" || brand == "avis" {
|
||
return "image/avif"
|
||
}
|
||
}
|
||
return ""
|
||
}
|
||
|
||
func TestImageProcessor_ResizeJPEG(t *testing.T) {
|
||
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 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 != "image/jpeg" {
|
||
t.Errorf("Output format = %v, want image/jpeg", mime)
|
||
}
|
||
}
|
||
|
||
func TestImageProcessor_ConvertToPNG(t *testing.T) {
|
||
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 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 != "image/png" {
|
||
t.Errorf("Output format = %v, want image/png", mime)
|
||
}
|
||
}
|
||
|
||
func TestImageProcessor_OriginalSize(t *testing.T) {
|
||
proc := New(Params{})
|
||
ctx := context.Background()
|
||
|
||
input := createTestJPEG(t, 640, 480)
|
||
|
||
req := &Request{
|
||
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 := &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 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 := &Request{
|
||
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 := &Request{
|
||
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 := &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 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) {
|
||
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 := &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 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 := &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 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
|
||
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 result.Content.Close()
|
||
}
|
||
|
||
func TestImageProcessor_EncodeWebP(t *testing.T) {
|
||
proc := New(Params{})
|
||
ctx := context.Background()
|
||
|
||
input := createTestJPEG(t, 200, 150)
|
||
|
||
req := &Request{
|
||
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 := detectMIME(data)
|
||
if mime != "image/webp" {
|
||
t.Errorf("Output format = %v, want image/webp", mime)
|
||
}
|
||
|
||
// 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 := &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 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 != "image/jpeg" {
|
||
t.Errorf("Output format = %v, want image/jpeg", mime)
|
||
}
|
||
}
|
||
|
||
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 := &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 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 := &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 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 := &Request{
|
||
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 := detectMIME(data)
|
||
if mime != "image/avif" {
|
||
t.Errorf("Output format = %v, want image/avif", mime)
|
||
}
|
||
|
||
// 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)
|
||
}
|
||
}
|