package video import ( "bytes" "context" stdimage "image" "image/color" "image/jpeg" "net/http" "net/http/httptest" "os" "path/filepath" "strings" "testing" "time" "github.com/domagojzecevic/cammonitor/internal/config" "github.com/domagojzecevic/cammonitor/internal/footage" "github.com/go-chi/chi/v5" ) func TestThumbnailEndpointReturnsJPEG(t *testing.T) { root := createVideoFixture(t) index := footage.NewIndex(root, 0) t.Cleanup(index.Close) thumbPath := filepath.Join(t.TempDir(), "thumb.jpg") writeJPEG(t, thumbPath, color.RGBA{R: 90, G: 200, B: 120, A: 255}) router := videoRouter(root, index, writeThumbnailFFmpeg(t, thumbPath, "")) request := httptest.NewRequest(http.MethodGet, "/thumb/video/20260101/record/A260101_120000_120015.265", 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 _, err := jpeg.Decode(bytes.NewReader(response.Body.Bytes())); err != nil { t.Fatalf("decode thumbnail: %v", err) } } func TestThumbnailCacheReturnsStoredBytes(t *testing.T) { root := createVideoFixture(t) videoPath := filepath.Join(root, "20260101", "record", "A260101_120000_120015.265") tempDir := t.TempDir() thumbPath := filepath.Join(tempDir, "thumb.jpg") countPath := filepath.Join(tempDir, "count.txt") writeJPEG(t, thumbPath, color.RGBA{R: 200, G: 120, B: 90, A: 255}) cache := NewCache(2, writeThumbnailFFmpeg(t, thumbPath, countPath)) first, err := cache.Thumbnail(videoPath) if err != nil { t.Fatalf("first thumbnail: %v", err) } second, err := cache.Thumbnail(videoPath) if err != nil { t.Fatalf("second thumbnail: %v", err) } if !bytes.Equal(first, second) { t.Fatalf("expected cached thumbnail bytes to match") } counts, err := os.ReadFile(countPath) if err != nil { t.Fatalf("read count file: %v", err) } if got := strings.Count(strings.TrimSpace(string(counts)), "thumb"); got != 1 { t.Fatalf("expected ffmpeg to run once, got %d entries in %q", got, string(counts)) } } func TestStreamEndpointReturnsMP4HeadersAndBody(t *testing.T) { root := createVideoFixture(t) index := footage.NewIndex(root, 0) t.Cleanup(index.Close) router := videoRouter(root, index, writeStreamFFmpeg(t, "fake-mp4-stream", "")) request := httptest.NewRequest(http.MethodGet, "/stream/video/20260101/record/A260101_120000_120015.265", 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 != "video/mp4" { t.Fatalf("expected video/mp4 content type, got %q", contentType) } if cacheControl := response.Header().Get("Cache-Control"); cacheControl != "no-store" { t.Fatalf("expected Cache-Control no-store, got %q", cacheControl) } if got := response.Body.String(); got != "fake-mp4-stream" { t.Fatalf("expected stream body %q, got %q", "fake-mp4-stream", got) } } func TestStreamEndpointRejectsTraversal(t *testing.T) { root := createVideoFixture(t) index := footage.NewIndex(root, 0) t.Cleanup(index.Close) router := videoRouter(root, index, writeStreamFFmpeg(t, "fake-mp4-stream", "")) request := httptest.NewRequest(http.MethodGet, "/stream/video/%2e%2e/secret.265", 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 TestVideoPageRendersPlayerNavigation(t *testing.T) { root := createVideoFixture(t) index := footage.NewIndex(root, 0) t.Cleanup(index.Close) router := videoRouter(root, index, writeStreamFFmpeg(t, "fake-mp4-stream", "")) request := httptest.NewRequest(http.MethodGet, "/day/20260101/videos?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-video-browser`, `data-active-index="1"`, `> "+shellQuote(countPath)) } lines = append(lines, "cat "+shellQuote(thumbPath)) lines = append(lines, "") return writeScript(t, strings.Join(lines, "\n")) } func writeStreamFFmpeg(t *testing.T, body, countPath string) string { t.Helper() lines := []string{"#!/bin/sh"} if countPath != "" { lines = append(lines, "echo stream >> "+shellQuote(countPath)) } lines = append(lines, "printf %s "+shellQuote(body)) lines = append(lines, "") return writeScript(t, strings.Join(lines, "\n")) } func writeStreamingKillFFmpeg(t *testing.T, killedPath string) string { t.Helper() script := strings.Join([]string{ "#!/bin/sh", "trap \"echo killed > " + shellQuote(killedPath) + "; exit 0\" TERM INT", "printf %s " + shellQuote("fake-mp4-stream"), "while :; do sleep 0.05; done", "", }, "\n") return writeScript(t, script) } func writeScript(t *testing.T, content string) string { t.Helper() path := filepath.Join(t.TempDir(), "ffmpeg.sh") if err := os.WriteFile(path, []byte(content), 0o755); err != nil { t.Fatalf("write script: %v", err) } return path } func shellQuote(value string) string { return "'" + strings.ReplaceAll(value, "'", "'\"'\"'") + "'" } func writeJPEG(t *testing.T, path string, fill color.Color) { t.Helper() img := stdimage.NewRGBA(stdimage.Rect(0, 0, 160, 90)) 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: 85}); err != nil { t.Fatalf("encode jpeg %s: %v", path, err) } }