package image import ( "bytes" stdimage "image" "image/color" "image/jpeg" "io" "net/http" "net/http/httptest" "os" "path/filepath" "testing" "github.com/domagojzecevic/cammonitor/internal/config" "github.com/domagojzecevic/cammonitor/internal/footage" "github.com/go-chi/chi/v5" ) func TestThumbnailEndpointReturnsSmallJPEG(t *testing.T) { root := createImageFixture(t) index := footage.NewIndex(root, 0) t.Cleanup(index.Close) router := imageRouter(root, index) request := httptest.NewRequest(http.MethodGet, "/thumb/image/20260101/images/A26010112000001.jpg", nil) response := httptest.NewRecorder() router.ServeHTTP(response, request) if response.Code != http.StatusOK { t.Fatalf("expected status %d, got %d: %s", http.StatusOK, response.Code, response.Body.String()) } if contentType := response.Header().Get("Content-Type"); contentType != "image/jpeg" { t.Fatalf("expected image/jpeg content type, got %q", contentType) } if response.Body.Len() > 30*1024 { t.Fatalf("expected thumbnail <= 30 KB, got %d bytes", response.Body.Len()) } thumbnail, err := jpeg.Decode(bytes.NewReader(response.Body.Bytes())) if err != nil { t.Fatalf("decode thumbnail: %v", err) } bounds := thumbnail.Bounds() if bounds.Dx() > 160 || bounds.Dy() > 90 { t.Fatalf("expected thumbnail within 160x90, got %dx%d", bounds.Dx(), bounds.Dy()) } } func TestRawEndpointServesOriginalFile(t *testing.T) { root := createImageFixture(t) originalPath := filepath.Join(root, "20260101", "images", "A26010112000001.jpg") original, err := os.ReadFile(originalPath) if err != nil { t.Fatalf("read original: %v", err) } index := footage.NewIndex(root, 0) t.Cleanup(index.Close) router := imageRouter(root, index) request := httptest.NewRequest(http.MethodGet, "/raw/image/20260101/images/A26010112000001.jpg", nil) response := httptest.NewRecorder() router.ServeHTTP(response, request) if response.Code != http.StatusOK { t.Fatalf("expected status %d, got %d: %s", http.StatusOK, response.Code, response.Body.String()) } served, err := io.ReadAll(response.Body) if err != nil { t.Fatalf("read response: %v", err) } if !bytes.Equal(served, original) { t.Fatalf("expected raw endpoint to serve original bytes unchanged") } } func TestRawEndpointRejectsTraversal(t *testing.T) { root := createImageFixture(t) index := footage.NewIndex(root, 0) t.Cleanup(index.Close) router := imageRouter(root, index) request := httptest.NewRequest(http.MethodGet, "/raw/image/%2e%2e/secret.jpg", nil) response := httptest.NewRecorder() router.ServeHTTP(response, request) if response.Code != http.StatusBadRequest { t.Fatalf("expected status %d, got %d", http.StatusBadRequest, response.Code) } } func TestImagePageRendersViewerNavigation(t *testing.T) { root := createImageFixture(t) index := footage.NewIndex(root, 0) t.Cleanup(index.Close) router := imageRouter(root, index) request := httptest.NewRequest(http.MethodGet, "/day/20260101/images?idx=1", nil) response := httptest.NewRecorder() router.ServeHTTP(response, request) if response.Code != http.StatusOK { t.Fatalf("expected status %d, got %d: %s", http.StatusOK, response.Code, response.Body.String()) } body := response.Body.String() for _, want := range []string{ `data-image-browser`, `data-active-index="1"`, `/thumb/image/20260101/images/A26010112000001.jpg`, `/raw/image/20260101/images/A26010112050001.jpg`, `data-prev-index="0"`, `data-next-index="0"`, `keydown`, `ArrowLeft`, `ArrowRight`, `ring-2 ring-indigo-400`, } { if !bytes.Contains(response.Body.Bytes(), []byte(want)) { t.Fatalf("expected response to contain %q\nbody:\n%s", want, body) } } } func imageRouter(root string, index *footage.Index) chi.Router { handler := NewHandler(&config.Config{FootageRoot: root}, index) router := chi.NewRouter() router.Get("/raw/image/*", handler.ServeRaw) router.Get("/thumb/image/*", handler.ServeThumb) router.Get("/day/{date}/images", handler.ServePage) return router } func createImageFixture(t *testing.T) string { t.Helper() root := t.TempDir() imageDir := filepath.Join(root, "20260101", "images") if err := os.MkdirAll(imageDir, 0o755); err != nil { t.Fatalf("create image dir: %v", err) } writeJPEG(t, filepath.Join(imageDir, "A26010112000001.jpg"), color.RGBA{R: 220, G: 40, B: 40, A: 255}) writeJPEG(t, filepath.Join(imageDir, "A26010112050001.jpg"), color.RGBA{R: 40, G: 90, B: 220, A: 255}) return root } func writeJPEG(t *testing.T, path string, fill color.Color) { t.Helper() img := stdimage.NewRGBA(stdimage.Rect(0, 0, 320, 180)) for y := 0; y < img.Bounds().Dy(); y++ { for x := 0; x < img.Bounds().Dx(); x++ { img.Set(x, y, fill) } } file, err := os.Create(path) if err != nil { t.Fatalf("create jpeg %s: %v", path, err) } defer file.Close() if err := jpeg.Encode(file, img, &jpeg.Options{Quality: 90}); err != nil { t.Fatalf("encode jpeg %s: %v", path, err) } }