check / check (push) Canceled after 0s
A cached source and its variants were served until eviction, so a changed image at the same URL was never picked up. Once a source was last checked 24 hours ago, the next request for one of its images asks upstream with If-None-Match and If-Modified-Since, from the ETag and Last-Modified it stored. A 304 renews the source and its variant without a download or a conversion. New bytes replace the source, and each variant made before them is converted again. A failed check serves the cached image and puts the URL in the negative cache. Sources and variants not checked successfully for 30 days are evicted. A variant file's modification time is its source's check time, so a hit still makes no database call. Existing tests change only in call signatures. The test that asserted two-day-old entries are served without asking upstream is replaced by the new ones. Model: opus-5-5
483 lines
11 KiB
Go
483 lines
11 KiB
Go
package imageproxy
|
|
|
|
import (
|
|
"context"
|
|
"io"
|
|
"testing"
|
|
|
|
"sneak.berlin/go/pixa/internal/imagerequest"
|
|
"sneak.berlin/go/pixa/internal/magic"
|
|
)
|
|
|
|
// Test data literals used repeatedly in this file (goconst).
|
|
const (
|
|
testPathPhoto = "/images/photo.jpg"
|
|
testPathUpload = "/uploads/image.jpg"
|
|
)
|
|
|
|
func TestProxy_Get_GoodHost(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
svc, fixtures := setupTestProxy(t)
|
|
ctx := context.Background()
|
|
|
|
req := &imagerequest.Request{
|
|
Source: imagerequest.Source{
|
|
Host: fixtures.GoodHost,
|
|
Path: testPathPhoto,
|
|
},
|
|
Transform: imagerequest.Transform{
|
|
Size: imagerequest.Size{Width: 50, Height: 50},
|
|
Format: imagerequest.FormatJPEG,
|
|
Quality: 85,
|
|
FitMode: imagerequest.FitCover,
|
|
},
|
|
}
|
|
|
|
resp, err := svc.Get(ctx, req)
|
|
if err != nil {
|
|
t.Fatalf("Get() error = %v", err)
|
|
}
|
|
|
|
defer func() { _ = resp.Content.Close() }()
|
|
|
|
// Verify we got content
|
|
data, err := io.ReadAll(resp.Content)
|
|
if err != nil {
|
|
t.Fatalf("failed to read response: %v", err)
|
|
}
|
|
|
|
if len(data) == 0 {
|
|
t.Error("expected non-empty response")
|
|
}
|
|
|
|
if resp.ContentType != testContentTypeJPEG {
|
|
t.Errorf("ContentType = %q, want %q", resp.ContentType, testContentTypeJPEG)
|
|
}
|
|
}
|
|
|
|
// TestProxy_Get_OtherHost checks that the proxy serves a second host as it
|
|
// serves GoodHost.
|
|
func TestProxy_Get_OtherHost(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
svc, fixtures := setupTestProxy(t)
|
|
ctx := context.Background()
|
|
|
|
req := &imagerequest.Request{
|
|
Source: imagerequest.Source{
|
|
Host: fixtures.OtherHost,
|
|
Path: testPathUpload,
|
|
},
|
|
Transform: imagerequest.Transform{
|
|
Size: imagerequest.Size{Width: 50, Height: 50},
|
|
Format: imagerequest.FormatJPEG,
|
|
Quality: 85,
|
|
FitMode: imagerequest.FitCover,
|
|
},
|
|
}
|
|
|
|
// Should fetch successfully
|
|
resp, err := svc.Get(ctx, req)
|
|
if err != nil {
|
|
t.Fatalf("Get() error = %v", err)
|
|
}
|
|
|
|
defer func() { _ = resp.Content.Close() }()
|
|
|
|
data, err := io.ReadAll(resp.Content)
|
|
if err != nil {
|
|
t.Fatalf("failed to read response: %v", err)
|
|
}
|
|
|
|
if len(data) == 0 {
|
|
t.Error("expected non-empty response")
|
|
}
|
|
}
|
|
|
|
func TestProxy_Get_InvalidFile(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
svc, fixtures := setupTestProxy(t)
|
|
ctx := context.Background()
|
|
|
|
req := photoVariant(fixtures, 85, imagerequest.FitCover)
|
|
req.Source.Path = "/images/fake.jpg"
|
|
req.Transform.Size = imagerequest.Size{Width: 50, Height: 50}
|
|
|
|
// Should fail because magic bytes don't match
|
|
_, err := svc.Get(ctx, req)
|
|
if err == nil {
|
|
t.Error("Get() expected error for invalid file")
|
|
}
|
|
}
|
|
|
|
func TestProxy_Get_NotFound(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
svc, fixtures := setupTestProxy(t)
|
|
ctx := context.Background()
|
|
|
|
req := photoVariant(fixtures, 85, imagerequest.FitCover)
|
|
req.Source.Path = "/images/nonexistent.jpg"
|
|
req.Transform.Size = imagerequest.Size{Width: 50, Height: 50}
|
|
|
|
_, err := svc.Get(ctx, req)
|
|
if err == nil {
|
|
t.Error("Get() expected error for nonexistent file")
|
|
}
|
|
}
|
|
|
|
func TestProxy_Get_FormatConversion(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
svc, fixtures := setupTestProxy(t)
|
|
ctx := context.Background()
|
|
|
|
tests := []struct {
|
|
name string
|
|
sourcePath string
|
|
outFormat imagerequest.Format
|
|
wantMIME string
|
|
}{
|
|
{
|
|
name: "JPEG to PNG",
|
|
sourcePath: testPathPhoto,
|
|
outFormat: imagerequest.FormatPNG,
|
|
wantMIME: "image/png",
|
|
},
|
|
{
|
|
name: "PNG to JPEG",
|
|
sourcePath: "/images/logo.png",
|
|
outFormat: imagerequest.FormatJPEG,
|
|
wantMIME: testContentTypeJPEG,
|
|
},
|
|
{
|
|
name: "GIF to PNG",
|
|
sourcePath: "/images/animation.gif",
|
|
outFormat: imagerequest.FormatPNG,
|
|
wantMIME: "image/png",
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
req := &imagerequest.Request{
|
|
Source: imagerequest.Source{Host: fixtures.GoodHost, Path: tt.sourcePath},
|
|
Transform: imagerequest.Transform{
|
|
Size: imagerequest.Size{Width: 50, Height: 50},
|
|
Format: tt.outFormat, Quality: 85, FitMode: imagerequest.FitCover,
|
|
},
|
|
}
|
|
|
|
resp, err := svc.Get(ctx, req)
|
|
if err != nil {
|
|
t.Fatalf("Get() error = %v", err)
|
|
}
|
|
|
|
defer func() { _ = resp.Content.Close() }()
|
|
|
|
if resp.ContentType != tt.wantMIME {
|
|
t.Errorf("ContentType = %q, want %q", resp.ContentType, tt.wantMIME)
|
|
}
|
|
|
|
// Verify magic bytes match the expected format
|
|
data, err := io.ReadAll(resp.Content)
|
|
if err != nil {
|
|
t.Fatalf("failed to read response: %v", err)
|
|
}
|
|
|
|
detectedFormat, err := magic.DetectFormat(data)
|
|
if err != nil {
|
|
t.Fatalf("failed to detect format: %v", err)
|
|
}
|
|
|
|
if detectedFormat.MIMEType() != tt.wantMIME {
|
|
t.Errorf("detected format = %q, want the format of %s",
|
|
detectedFormat, tt.wantMIME)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestProxy_Get_Caching(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
svc, fixtures := setupTestProxy(t)
|
|
ctx := context.Background()
|
|
|
|
req := &imagerequest.Request{
|
|
Source: imagerequest.Source{
|
|
Host: fixtures.GoodHost,
|
|
Path: testPathPhoto,
|
|
},
|
|
Transform: imagerequest.Transform{
|
|
Size: imagerequest.Size{Width: 50, Height: 50},
|
|
Format: imagerequest.FormatJPEG,
|
|
Quality: 85,
|
|
FitMode: imagerequest.FitCover,
|
|
},
|
|
}
|
|
|
|
// First request - should be a cache miss
|
|
resp1, err := svc.Get(ctx, req)
|
|
if err != nil {
|
|
t.Fatalf("Get() first request error = %v", err)
|
|
}
|
|
|
|
if resp1.CacheStatus != CacheMiss {
|
|
t.Errorf("first request CacheStatus = %q, want %q", resp1.CacheStatus, CacheMiss)
|
|
}
|
|
|
|
data1, err := io.ReadAll(resp1.Content)
|
|
if err != nil {
|
|
t.Fatalf("failed to read first response: %v", err)
|
|
}
|
|
|
|
_ = resp1.Content.Close()
|
|
|
|
// Second request - should be a cache hit
|
|
resp2, err := svc.Get(ctx, req)
|
|
if err != nil {
|
|
t.Fatalf("Get() second request error = %v", err)
|
|
}
|
|
|
|
if resp2.CacheStatus != CacheHit {
|
|
t.Errorf("second request CacheStatus = %q, want %q", resp2.CacheStatus, CacheHit)
|
|
}
|
|
|
|
data2, err := io.ReadAll(resp2.Content)
|
|
if err != nil {
|
|
t.Fatalf("failed to read second response: %v", err)
|
|
}
|
|
|
|
_ = resp2.Content.Close()
|
|
|
|
// Content should be identical
|
|
if len(data1) != len(data2) {
|
|
t.Errorf("response sizes differ: %d vs %d", len(data1), len(data2))
|
|
}
|
|
}
|
|
|
|
func TestProxy_Get_DifferentSizes(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
svc, fixtures := setupTestProxy(t)
|
|
ctx := context.Background()
|
|
|
|
// Request same image at different sizes
|
|
sizes := []imagerequest.Size{
|
|
{Width: 25, Height: 25},
|
|
{Width: 50, Height: 50},
|
|
{Width: 75, Height: 75},
|
|
}
|
|
|
|
responses := make([][]byte, 0, len(sizes))
|
|
|
|
for _, size := range sizes {
|
|
req := &imagerequest.Request{
|
|
Source: imagerequest.Source{
|
|
Host: fixtures.GoodHost,
|
|
Path: testPathPhoto,
|
|
},
|
|
Transform: imagerequest.Transform{
|
|
Size: size,
|
|
Format: imagerequest.FormatJPEG,
|
|
Quality: 85,
|
|
FitMode: imagerequest.FitCover,
|
|
},
|
|
}
|
|
|
|
resp, err := svc.Get(ctx, req)
|
|
if err != nil {
|
|
t.Fatalf("Get() error for size %v = %v", size, err)
|
|
}
|
|
|
|
data, err := io.ReadAll(resp.Content)
|
|
if err != nil {
|
|
t.Fatalf("failed to read response: %v", err)
|
|
}
|
|
|
|
_ = resp.Content.Close()
|
|
|
|
responses = append(responses, data)
|
|
}
|
|
|
|
// All responses should be different sizes (different cache entries)
|
|
for i := range len(responses) - 1 {
|
|
if len(responses[i]) == len(responses[i+1]) {
|
|
// Not necessarily an error, but worth noting
|
|
t.Logf("responses %d and %d have same size: %d bytes",
|
|
i, i+1, len(responses[i]))
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestProxy_Get_ContextCancellation(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
svc, fixtures := setupTestProxy(t)
|
|
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
cancel() // Cancel immediately
|
|
|
|
req := &imagerequest.Request{
|
|
Source: imagerequest.Source{
|
|
Host: fixtures.GoodHost,
|
|
Path: testPathPhoto,
|
|
},
|
|
Transform: imagerequest.Transform{
|
|
Size: imagerequest.Size{Width: 50, Height: 50},
|
|
Format: imagerequest.FormatJPEG,
|
|
Quality: 85,
|
|
FitMode: imagerequest.FitCover,
|
|
},
|
|
}
|
|
|
|
_, err := svc.Get(ctx, req)
|
|
if err == nil {
|
|
t.Error("Get() expected error for cancelled context")
|
|
}
|
|
}
|
|
|
|
func TestProxy_Get_ReturnsETag(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
svc, fixtures := setupTestProxy(t)
|
|
ctx := context.Background()
|
|
|
|
req := &imagerequest.Request{
|
|
Source: imagerequest.Source{
|
|
Host: fixtures.GoodHost,
|
|
Path: testPathPhoto,
|
|
},
|
|
Transform: imagerequest.Transform{
|
|
Size: imagerequest.Size{Width: 50, Height: 50},
|
|
Format: imagerequest.FormatJPEG,
|
|
Quality: 85,
|
|
FitMode: imagerequest.FitCover,
|
|
},
|
|
}
|
|
|
|
resp, err := svc.Get(ctx, req)
|
|
if err != nil {
|
|
t.Fatalf("Get() error = %v", err)
|
|
}
|
|
|
|
defer func() { _ = resp.Content.Close() }()
|
|
|
|
// ETag should be set
|
|
if resp.ETag == "" {
|
|
t.Error("ETag should be set in response")
|
|
}
|
|
|
|
// ETag should be properly quoted
|
|
if len(resp.ETag) < 2 || resp.ETag[0] != '"' || resp.ETag[len(resp.ETag)-1] != '"' {
|
|
t.Errorf("ETag should be quoted, got %q", resp.ETag)
|
|
}
|
|
}
|
|
|
|
func TestProxy_Get_ETagConsistency(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
svc, fixtures := setupTestProxy(t)
|
|
ctx := context.Background()
|
|
|
|
req := &imagerequest.Request{
|
|
Source: imagerequest.Source{
|
|
Host: fixtures.GoodHost,
|
|
Path: testPathPhoto,
|
|
},
|
|
Transform: imagerequest.Transform{
|
|
Size: imagerequest.Size{Width: 50, Height: 50},
|
|
Format: imagerequest.FormatJPEG,
|
|
Quality: 85,
|
|
FitMode: imagerequest.FitCover,
|
|
},
|
|
}
|
|
|
|
// First request
|
|
resp1, err := svc.Get(ctx, req)
|
|
if err != nil {
|
|
t.Fatalf("Get() first request error = %v", err)
|
|
}
|
|
|
|
etag1 := resp1.ETag
|
|
|
|
_ = resp1.Content.Close()
|
|
|
|
// Second request (from cache)
|
|
resp2, err := svc.Get(ctx, req)
|
|
if err != nil {
|
|
t.Fatalf("Get() second request error = %v", err)
|
|
}
|
|
|
|
etag2 := resp2.ETag
|
|
|
|
_ = resp2.Content.Close()
|
|
|
|
// ETags should be identical for the same content
|
|
if etag1 != etag2 {
|
|
t.Errorf("ETags should match: %q != %q", etag1, etag2)
|
|
}
|
|
}
|
|
|
|
func TestProxy_Get_DifferentETagsForDifferentContent(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
svc, fixtures := setupTestProxy(t)
|
|
ctx := context.Background()
|
|
|
|
// Request same image at different sizes - should get different ETags
|
|
req1 := &imagerequest.Request{
|
|
Source: imagerequest.Source{
|
|
Host: fixtures.GoodHost,
|
|
Path: testPathPhoto,
|
|
},
|
|
Transform: imagerequest.Transform{
|
|
Size: imagerequest.Size{Width: 25, Height: 25},
|
|
Format: imagerequest.FormatJPEG,
|
|
Quality: 85,
|
|
FitMode: imagerequest.FitCover,
|
|
},
|
|
}
|
|
|
|
req2 := &imagerequest.Request{
|
|
Source: imagerequest.Source{
|
|
Host: fixtures.GoodHost,
|
|
Path: testPathPhoto,
|
|
},
|
|
Transform: imagerequest.Transform{
|
|
Size: imagerequest.Size{Width: 50, Height: 50},
|
|
Format: imagerequest.FormatJPEG,
|
|
Quality: 85,
|
|
FitMode: imagerequest.FitCover,
|
|
},
|
|
}
|
|
|
|
resp1, err := svc.Get(ctx, req1)
|
|
if err != nil {
|
|
t.Fatalf("Get() first request error = %v", err)
|
|
}
|
|
|
|
etag1 := resp1.ETag
|
|
|
|
_ = resp1.Content.Close()
|
|
|
|
resp2, err := svc.Get(ctx, req2)
|
|
if err != nil {
|
|
t.Fatalf("Get() second request error = %v", err)
|
|
}
|
|
|
|
etag2 := resp2.ETag
|
|
|
|
_ = resp2.Content.Close()
|
|
|
|
// ETags should be different for different content
|
|
if etag1 == etag2 {
|
|
t.Error("ETags should differ for different sizes")
|
|
}
|
|
}
|