Route tests for q=banana, q=0 and q=101: each must be a 400 whose error names q and the value. They fail on next, where each is served at the default quality 85. Model: opus-5-5
346 lines
8.2 KiB
Go
346 lines
8.2 KiB
Go
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_InvalidQuality_Returns400 verifies that the plain image
|
|
// route answers a q that is not a whole number from 1 to 100 with 400 naming
|
|
// q and the value, instead of serving the image at the default quality 85.
|
|
func TestHandleImage_InvalidQuality_Returns400(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
tests := []struct {
|
|
q, wantError string
|
|
}{
|
|
{"banana", `invalid q: not a number, got "banana"`},
|
|
{"0", `invalid q: must be from 1 to 100, got "0"`},
|
|
{"101", `invalid q: must be from 1 to 100, got "101"`},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run("q="+tt.q, 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?q="+tt.q, 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)
|
|
}
|
|
})
|
|
}
|
|
}
|