package imageproxy import ( "bytes" "context" "database/sql" "image" "image/color" "image/gif" "image/jpeg" "image/png" "io/fs" "path/filepath" "testing" "testing/fstest" "time" "github.com/prometheus/client_golang/prometheus" "sneak.berlin/go/pixa/internal/database" "sneak.berlin/go/pixa/internal/httpfetcher" "sneak.berlin/go/pixa/internal/imageprocessor" "sneak.berlin/go/pixa/internal/imagerequest" "sneak.berlin/go/pixa/internal/imgcache" ) // Shared test data literals, extracted as constants for goconst. const ( testContentTypeJPEG = "image/jpeg" ) // sqliteTimestampFormat matches the format SQLite's CURRENT_TIMESTAMP // produces, so injected timestamps compare correctly against ones the // cache writes. const sqliteTimestampFormat = "2006-01-02 15:04:05" // TestFixtures contains paths to test files in the mock filesystem. type TestFixtures struct { // Valid image files GoodHostJPEG string // valid JPEG on GoodHost GoodHostPNG string // valid PNG on GoodHost GoodHostGIF string // valid GIF on GoodHost OtherHostJPEG string // valid JPEG on OtherHost OtherHostPNG string // valid PNG on OtherHost // Invalid/edge case files InvalidFile string // file with wrong magic bytes EmptyFile string // zero-byte file TextFile string // text file masquerading as image // Hostnames GoodHost string // a host the mock fetcher serves OtherHost string // a second host the mock fetcher serves } // DefaultFixtures returns the standard test fixture paths. func DefaultFixtures() *TestFixtures { return &TestFixtures{ GoodHostJPEG: "goodhost.example.com/images/photo.jpg", GoodHostPNG: "goodhost.example.com/images/logo.png", GoodHostGIF: "goodhost.example.com/images/animation.gif", OtherHostJPEG: "otherhost.example.com/uploads/image.jpg", OtherHostPNG: "otherhost.example.com/uploads/icon.png", InvalidFile: "goodhost.example.com/images/fake.jpg", EmptyFile: "goodhost.example.com/images/empty.jpg", TextFile: "goodhost.example.com/images/text.png", GoodHost: "goodhost.example.com", OtherHost: "otherhost.example.com", } } // NewTestFS creates a mock filesystem with test images. func NewTestFS(t *testing.T) (fs.FS, *TestFixtures) { t.Helper() fixtures := DefaultFixtures() // Generate test images jpegData := generateTestJPEG(t, 100, 100, color.RGBA{255, 0, 0, 255}) pngData := generateTestPNG(t, 100, 100, color.RGBA{0, 255, 0, 255}) gifData := generateTestGIF(t, 100, 100, color.RGBA{0, 0, 255, 255}) // Create the mock filesystem mockFS := fstest.MapFS{ // Good host files fixtures.GoodHostJPEG: &fstest.MapFile{Data: jpegData}, fixtures.GoodHostPNG: &fstest.MapFile{Data: pngData}, fixtures.GoodHostGIF: &fstest.MapFile{Data: gifData}, // Other host files fixtures.OtherHostJPEG: &fstest.MapFile{Data: jpegData}, fixtures.OtherHostPNG: &fstest.MapFile{Data: pngData}, // Invalid files fixtures.InvalidFile: &fstest.MapFile{Data: []byte("not a real image file content")}, fixtures.EmptyFile: &fstest.MapFile{Data: []byte{}}, fixtures.TextFile: &fstest.MapFile{Data: []byte("This is just text, not a PNG")}, } return mockFS, fixtures } // generateTestJPEG creates a minimal valid JPEG image. func generateTestJPEG(t *testing.T, width, height int, c color.Color) []byte { t.Helper() img := image.NewRGBA(image.Rect(0, 0, width, height)) for y := range height { for x := range width { img.Set(x, y, c) } } var buf bytes.Buffer err := jpeg.Encode(&buf, img, &jpeg.Options{Quality: 85}) if err != nil { t.Fatalf("failed to encode test JPEG: %v", err) } return buf.Bytes() } // generateTestPNG creates a minimal valid PNG image. func generateTestPNG(t *testing.T, width, height int, c color.Color) []byte { t.Helper() img := image.NewRGBA(image.Rect(0, 0, width, height)) for y := range height { for x := range width { img.Set(x, y, c) } } var buf bytes.Buffer err := png.Encode(&buf, img) if err != nil { t.Fatalf("failed to encode test PNG: %v", err) } return buf.Bytes() } // generateTestGIF creates a minimal valid GIF image. func generateTestGIF(t *testing.T, width, height int, c color.Color) []byte { t.Helper() img := image.NewPaletted( image.Rect(0, 0, width, height), []color.Color{c, color.White}, ) for y := range height { for x := range width { img.SetColorIndex(x, y, 0) } } var buf bytes.Buffer err := gif.Encode(&buf, img, nil) if err != nil { t.Fatalf("failed to encode test GIF: %v", err) } return buf.Bytes() } // testProxy is a Proxy with the database and the state directory of its // cache, through which a test reads and changes the cache behind the proxy. // registry is the metrics registry the proxy and its cache share. type testProxy struct { *Proxy db *sql.DB stateDir string registry *prometheus.Registry } // setupTestProxy creates a Proxy with mock fetcher for testing. func setupTestProxy(t *testing.T) (*testProxy, *TestFixtures) { t.Helper() mockFS, fixtures := NewTestFS(t) // Create temp directory for cache tmpDir := t.TempDir() // Create in-memory database db := setupTestDB(t) registry := prometheus.NewRegistry() cache, err := imgcache.NewCache(db, imgcache.Config{ StateDir: tmpDir, NegativeTTL: 5 * time.Minute, MaxBytes: 1 << 30, }, registry) if err != nil { t.Fatalf("failed to create cache: %v", err) } proxy, err := NewProxy(&Config{ Cache: cache, Fetcher: httpfetcher.NewMock(mockFS), Processor: imageprocessor.New(imageprocessor.Params{ MaxInputBytes: 50 << 20, MaxConcurrentProcessing: 2, }), MaxResponseSize: 50 << 20, }, registry) if err != nil { t.Fatalf("failed to create proxy: %v", err) } return &testProxy{ Proxy: proxy, db: db, stateDir: tmpDir, registry: registry, }, fixtures } // counterValues holds the values of the counters of a proxy and its cache. type counterValues struct { hitCount int64 missCount int64 upstreamFetchCount int64 upstreamFetchBytes int64 transformCount int64 } // readCounters reads the counters of svc and its cache from their metrics // registry. func readCounters(t *testing.T, svc *testProxy) counterValues { t.Helper() families, err := svc.registry.Gather() if err != nil { t.Fatalf("failed to gather the counters: %v", err) } values := make(map[string]int64, len(families)) for _, family := range families { values[family.GetName()] = int64(family.GetMetric()[0].GetCounter().GetValue()) } return counterValues{ hitCount: values["pixa_cache_hits_total"], missCount: values["pixa_cache_misses_total"], upstreamFetchCount: values["pixa_upstream_fetches_total"], upstreamFetchBytes: values["pixa_upstream_fetch_bytes_total"], transformCount: values["pixa_transforms_total"], } } // sourceFilePath returns the path of the file of the source key in svc's // cache, as README.md gives it: /cache/sources///. func sourceFilePath(svc *testProxy, key imagerequest.SourceKey) string { name := string(key) return filepath.Join(svc.stateDir, "cache", "sources", name[0:2], name[2:4], name) } // variantFilePath returns the path of the file of the variant key in svc's // cache, as README.md gives it: /cache/variants///. func variantFilePath(svc *testProxy, key imagerequest.VariantKey) string { name := string(key) return filepath.Join(svc.stateDir, "cache", "variants", name[0:2], name[2:4], name) } // setVariantLastAccessed backdates the last access time of a tracked // variant. func setVariantLastAccessed( t *testing.T, db *sql.DB, key imagerequest.VariantKey, when time.Time, ) { t.Helper() res, err := db.ExecContext(t.Context(), `UPDATE variant_content SET last_accessed_at = ? WHERE cache_key = ?`, when.UTC().Format(sqliteTimestampFormat), string(key), ) if err != nil { t.Fatalf("failed to set variant last_accessed_at: %v", err) } affected, err := res.RowsAffected() if err != nil { t.Fatalf("failed to read affected rows: %v", err) } if affected != 1 { t.Fatalf("variant %s has no accounting row (affected=%d); "+ "stores must track variants in the database", key, affected) } } // setSourceLastAccessed backdates the last access time of a tracked // source content blob. func setSourceLastAccessed( t *testing.T, db *sql.DB, key imagerequest.SourceKey, when time.Time, ) { t.Helper() res, err := db.ExecContext(t.Context(), `UPDATE source_content SET last_accessed_at = ? WHERE source_key = ?`, when.UTC().Format(sqliteTimestampFormat), string(key), ) if err != nil { t.Fatalf("failed to set source last_accessed_at: %v", err) } affected, err := res.RowsAffected() if err != nil { t.Fatalf("failed to read affected rows: %v", err) } if affected != 1 { t.Fatalf("source %s has no accounting row (affected=%d)", key, affected) } } // countRows returns the number of rows the given query yields. func countRows(t *testing.T, db *sql.DB, query string, args ...any) int { t.Helper() var n int err := db.QueryRowContext(t.Context(), query, args...).Scan(&n) if err != nil { t.Fatalf("count query %q failed: %v", query, err) } return n } // setupTestDB creates an in-memory SQLite database for testing // using the production schema. func setupTestDB(t *testing.T) *sql.DB { t.Helper() db, err := sql.Open("sqlite", ":memory:") if err != nil { t.Fatalf("failed to open test db: %v", err) } // Use the real production schema via migrations err = database.ApplyMigrations(context.Background(), db, nil) if err != nil { t.Fatalf("failed to apply migrations: %v", err) } return db }