package handlers import ( "bytes" "context" "database/sql" "encoding/json" "image" "image/color" "image/jpeg" "io" "io/fs" "log/slog" "net/http" "net/http/httptest" "testing" "testing/fstest" "time" "github.com/go-chi/chi/v5" "sneak.berlin/go/pixa/internal/database" "sneak.berlin/go/pixa/internal/httpfetcher" "sneak.berlin/go/pixa/internal/imgcache" ) // testFixtures contains test data. type testFixtures struct { handler *Handlers service *imgcache.Service goodHost string } func setupTestHandler(t *testing.T) *testFixtures { t.Helper() goodHost := "goodhost.example.com" // Generate test JPEG jpegData := generateTestJPEG(t, 100, 100, color.RGBA{255, 0, 0, 255}) mockFS := fstest.MapFS{ goodHost + "/images/photo.jpg": &fstest.MapFile{Data: jpegData}, } tmpDir := t.TempDir() db := setupTestDB(t) cache, err := imgcache.NewCache(db, imgcache.CacheConfig{ StateDir: tmpDir, CacheTTL: time.Hour, NegativeTTL: 5 * time.Minute, }) if err != nil { t.Fatalf("failed to create cache: %v", err) } svc, err := imgcache.NewService(&imgcache.ServiceConfig{ Cache: cache, Fetcher: newMockFetcher(mockFS), SigningKey: "test-signing-key-must-be-32-chars", Allowlist: []string{goodHost}, }) if err != nil { t.Fatalf("failed to create service: %v", err) } h := &Handlers{ imgSvc: svc, log: slog.Default(), } return &testFixtures{ handler: h, service: svc, goodHost: goodHost, } } 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) } err = database.ApplyMigrations(context.Background(), db, nil) if err != nil { t.Fatalf("failed to apply migrations: %v", err) } return db } 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() } // mockFetcher fetches from a mock filesystem. type mockFetcher struct { fs fs.FS } func newMockFetcher(fs fs.FS) *mockFetcher { return &mockFetcher{fs: fs} } func (f *mockFetcher) Fetch( _ context.Context, url string, ) (*httpfetcher.FetchResult, error) { // Remove https:// prefix path := url[8:] // Remove "https://" data, err := fs.ReadFile(f.fs, path) if err != nil { return nil, httpfetcher.ErrUpstreamError } return &httpfetcher.FetchResult{ Content: io.NopCloser(bytes.NewReader(data)), ContentLength: int64(len(data)), ContentType: "image/jpeg", }, nil } func TestHandleImage_HEAD_ReturnsHeadersOnly(t *testing.T) { t.Parallel() fix := setupTestHandler(t) // Create a chi router to properly handle wildcards r := chi.NewRouter() r.Head("/v1/image/*", fix.handler.HandleImage()) req := httptest.NewRequestWithContext(t.Context(), http.MethodHead, "/v1/image/"+fix.goodHost+"/images/photo.jpg/50x50.jpeg", nil) rec := httptest.NewRecorder() r.ServeHTTP(rec, req) // Should return 200 OK if rec.Code != http.StatusOK { t.Errorf("HEAD request status = %d, want %d", rec.Code, http.StatusOK) } // Should have Content-Type header if ct := rec.Header().Get("Content-Type"); ct == "" { t.Error("HEAD response should have Content-Type header") } // Should have Content-Length header if cl := rec.Header().Get("Content-Length"); cl == "" { t.Error("HEAD response should have Content-Length header") } // Body should be empty for HEAD request if rec.Body.Len() != 0 { t.Errorf("HEAD response body should be empty, got %d bytes", rec.Body.Len()) } } func TestHandleImage_ConditionalRequest_IfNoneMatch_Returns304(t *testing.T) { t.Parallel() fix := setupTestHandler(t) r := chi.NewRouter() r.Get("/v1/image/*", fix.handler.HandleImage()) // First request to get the ETag req1 := httptest.NewRequestWithContext(t.Context(), http.MethodGet, "/v1/image/"+fix.goodHost+"/images/photo.jpg/50x50.jpeg", nil) rec1 := httptest.NewRecorder() r.ServeHTTP(rec1, req1) if rec1.Code != http.StatusOK { t.Fatalf("First request status = %d, want %d", rec1.Code, http.StatusOK) } etag := rec1.Header().Get("ETag") if etag == "" { t.Fatal("Response should have ETag header") } // Second request with If-None-Match header req2 := httptest.NewRequestWithContext(t.Context(), http.MethodGet, "/v1/image/"+fix.goodHost+"/images/photo.jpg/50x50.jpeg", nil) req2.Header.Set("If-None-Match", etag) rec2 := httptest.NewRecorder() r.ServeHTTP(rec2, req2) // Should return 304 Not Modified if rec2.Code != http.StatusNotModified { t.Errorf("Conditional request status = %d, want %d", rec2.Code, http.StatusNotModified) } // Body should be empty for 304 response if rec2.Body.Len() != 0 { t.Errorf("304 response body should be empty, got %d bytes", rec2.Body.Len()) } } func TestHandleImage_ConditionalRequest_IfNoneMatch_DifferentETag(t *testing.T) { t.Parallel() fix := setupTestHandler(t) r := chi.NewRouter() r.Get("/v1/image/*", fix.handler.HandleImage()) // Request with non-matching ETag req := httptest.NewRequestWithContext(t.Context(), http.MethodGet, "/v1/image/"+fix.goodHost+"/images/photo.jpg/50x50.jpeg", nil) req.Header.Set("If-None-Match", `"different-etag"`) rec := httptest.NewRecorder() r.ServeHTTP(rec, req) // Should return 200 OK with full response if rec.Code != http.StatusOK { t.Errorf("Request with non-matching ETag status = %d, want %d", rec.Code, http.StatusOK) } // Body should not be empty if rec.Body.Len() == 0 { t.Error("Response body should not be empty for non-matching ETag") } } func TestHandleImage_ETagHeader(t *testing.T) { t.Parallel() fix := setupTestHandler(t) r := chi.NewRouter() r.Get("/v1/image/*", fix.handler.HandleImage()) req := httptest.NewRequestWithContext(t.Context(), http.MethodGet, "/v1/image/"+fix.goodHost+"/images/photo.jpg/50x50.jpeg", nil) rec := httptest.NewRecorder() r.ServeHTTP(rec, req) if rec.Code != http.StatusOK { t.Fatalf("Request status = %d, want %d", rec.Code, http.StatusOK) } etag := rec.Header().Get("ETag") if etag == "" { t.Error("Response should have ETag header") } // ETag should be quoted if len(etag) < 2 || etag[0] != '"' || etag[len(etag)-1] != '"' { t.Errorf("ETag should be quoted, got %q", etag) } } // TestHandleImage_InvalidFitMode_Returns400 verifies that the plain image // route rejects an unrecognized fit mode with 400. func TestHandleImage_InvalidFitMode_Returns400(t *testing.T) { t.Parallel() fix := setupTestHandler(t) status := getImage(t, fix, "/v1/image/"+fix.goodHost+"/images/photo.jpg/50x50.jpeg?fit=bogus") if status != http.StatusBadRequest { t.Fatalf("status = %d, want %d", status, http.StatusBadRequest) } } // TestHandleImage_InvalidQuery_Returns400 verifies that the plain image route // answers a q that is not a whole number from 1 to 100, an empty one // included, with 400 naming q and the value, a parameter given more than once // with 400 naming it, and a query string that cannot be decoded with 400 // showing it, instead of serving the image at the default quality 85 or at // the first value given. func TestHandleImage_InvalidQuery_Returns400(t *testing.T) { t.Parallel() tests := []struct { query, wantError string }{ {"q=banana", `invalid q: not a number, got "banana"`}, {"q=0", `invalid q: must be from 1 to 100, got "0"`}, {"q=101", `invalid q: must be from 1 to 100, got "101"`}, {"q=", `invalid q: not a number, got ""`}, {"q=80&q=500", `invalid q: given more than once`}, {"q=80&q=", `invalid q: given more than once`}, {"fit=cover&fit=contain", `invalid fit: given more than once`}, {"q=80%", `invalid query string "q=80%": invalid URL escape "%"`}, { "q=50;fit=contain", `invalid query string "q=50;fit=contain": ` + `invalid semicolon separator in query`, }, } for _, tt := range tests { t.Run(tt.query, func(t *testing.T) { t.Parallel() fix := setupTestHandler(t) r := chi.NewRouter() r.Get("/v1/image/*", fix.handler.HandleImage()) req := httptest.NewRequestWithContext(t.Context(), http.MethodGet, "/v1/image/"+fix.goodHost+"/images/photo.jpg/50x50.jpeg?"+tt.query, nil) rec := httptest.NewRecorder() r.ServeHTTP(rec, req) if rec.Code != http.StatusBadRequest { t.Fatalf("status = %d, want %d", rec.Code, http.StatusBadRequest) } t.Logf("GET %s: %d %s", req.URL, rec.Code, rec.Body) var body struct { Error string `json:"error"` } err := json.NewDecoder(rec.Body).Decode(&body) if err != nil { t.Fatalf("decoding response body: %v", err) } if body.Error != tt.wantError { t.Errorf("error = %q, want %q", body.Error, tt.wantError) } }) } } // TestHandleImage_EmptyFit_Returns400 verifies that the plain image route // answers a fit that is in the URL but empty with 400 naming fit, instead of // serving the image as cover. Only a fit missing from the URL means cover. func TestHandleImage_EmptyFit_Returns400(t *testing.T) { t.Parallel() fix := setupTestHandler(t) r := chi.NewRouter() r.Get("/v1/image/*", fix.handler.HandleImage()) req := httptest.NewRequestWithContext(t.Context(), http.MethodGet, "/v1/image/"+fix.goodHost+"/images/photo.jpg/50x50.jpeg?fit=", nil) rec := httptest.NewRecorder() r.ServeHTTP(rec, req) if rec.Code != http.StatusBadRequest { t.Fatalf("status = %d, want %d", rec.Code, http.StatusBadRequest) } t.Logf("GET %s: %d %s", req.URL, rec.Code, rec.Body) var body struct { Error string `json:"error"` } err := json.NewDecoder(rec.Body).Decode(&body) if err != nil { t.Fatalf("decoding response body: %v", err) } wantError := `invalid fit: not a fit mode, got ""` if body.Error != wantError { t.Errorf("error = %q, want %q", body.Error, wantError) } }