package handlers import ( "bytes" "context" "database/sql" "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/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", Whitelist: []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) } if err := database.ApplyMigrations(db); 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 := 0; y < height; y++ { for x := 0; x < width; x++ { img.Set(x, y, c) } } var buf bytes.Buffer if err := jpeg.Encode(&buf, img, &jpeg.Options{Quality: 85}); 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(ctx context.Context, url string) (*imgcache.FetchResult, error) { // Remove https:// prefix path := url[8:] // Remove "https://" data, err := fs.ReadFile(f.fs, path) if err != nil { return nil, imgcache.ErrUpstreamError } return &imgcache.FetchResult{ Content: io.NopCloser(bytes.NewReader(data)), ContentLength: int64(len(data)), ContentType: "image/jpeg", }, nil } func TestHandleImage_HEAD_ReturnsHeadersOnly(t *testing.T) { fix := setupTestHandler(t) // Create a chi router to properly handle wildcards r := chi.NewRouter() r.Head("/v1/image/*", fix.handler.HandleImage()) req := httptest.NewRequest(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) { fix := setupTestHandler(t) r := chi.NewRouter() r.Get("/v1/image/*", fix.handler.HandleImage()) // First request to get the ETag req1 := httptest.NewRequest(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.NewRequest(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) { fix := setupTestHandler(t) r := chi.NewRouter() r.Get("/v1/image/*", fix.handler.HandleImage()) // Request with non-matching ETag req := httptest.NewRequest(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) { fix := setupTestHandler(t) r := chi.NewRouter() r.Get("/v1/image/*", fix.handler.HandleImage()) req := httptest.NewRequest(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) } }