All checks were successful
check / check (push) Successful in 2m3s
Replace .golangci.yml with the canonical v2-schema config (default: all minus six disabled linters, lll 88, tests included) and bump every golangci-lint pin to v2.12.2: - Dockerfile: golangci/golangci-lint:v2.12.2-alpine (hash-pinned) - script/bootstrap: GOLANGCI_LINT_VERSION 2.12.2 with new linux-amd64/arm64 release-archive sha256 pins Fix all 747 findings the stricter config surfaces, with no behavior changes: t.Parallel() throughout the test suite, static sentinel errors and errors.Is comparisons, checked error returns, context propagation (contextcheck/noctx), 88-column wrapping, extracted constants and helpers for goconst/dupl/funlen/cyclop, exhaustive switch cases replicating existing defaults, and white-box test files renamed to *_internal_test.go for testpackage. Three nolint:tagliatelle directives preserve the existing snake_case JSON wire and on-disk metadata formats.
564 lines
12 KiB
Go
564 lines
12 KiB
Go
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)
|
||
}
|