You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 

300 lines
8.5 KiB

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"`,
`<video`,
`/stream/video/20260101/record/A260101_120500_120530.265`,
`/thumb/video/20260101/record/A260101_120000_120015.265`,
`data-nav="prev"`,
`data-nav="next"`,
`keydown`,
`ArrowLeft`,
`ArrowRight`,
`ring-2 ring-indigo-400`,
`00:15`,
`00:30`,
} {
if !strings.Contains(body, want) {
t.Fatalf("expected response to contain %q\nbody:\n%s", want, body)
}
}
}
func TestStreamKillsFFmpegOnContextCancel(t *testing.T) {
root := createVideoFixture(t)
index := footage.NewIndex(root, 0)
t.Cleanup(index.Close)
killedPath := filepath.Join(t.TempDir(), "killed.txt")
router := videoRouter(root, index, writeStreamingKillFFmpeg(t, killedPath))
ctx, cancel := context.WithCancel(context.Background())
request := httptest.NewRequest(http.MethodGet, "/stream/video/20260101/record/A260101_120000_120015.265", nil).WithContext(ctx)
response := httptest.NewRecorder()
done := make(chan struct{})
go func() {
router.ServeHTTP(response, request)
close(done)
}()
time.Sleep(150 * time.Millisecond)
cancel()
select {
case <-done:
case <-time.After(2 * time.Second):
t.Fatal("stream handler did not exit after context cancellation")
}
if _, err := os.Stat(killedPath); err != nil {
t.Fatalf("expected ffmpeg kill marker to exist: %v", err)
}
if !strings.Contains(response.Body.String(), "fake-mp4-stream") {
t.Fatalf("expected stream body to contain initial bytes, got %q", response.Body.String())
}
}
func videoRouter(root string, index *footage.Index, ffmpegPath string) chi.Router {
handler := NewHandler(&config.Config{FootageRoot: root}, index)
handler.ffmpegPath = ffmpegPath
handler.thumbs = NewCache(defaultCacheEntries, ffmpegPath)
router := chi.NewRouter()
router.Get("/stream/video/*", handler.ServeStream)
router.Get("/thumb/video/*", handler.ServeThumb)
router.Get("/day/{date}/videos", handler.ServePage)
return router
}
func createVideoFixture(t *testing.T) string {
t.Helper()
root := t.TempDir()
recordDir := filepath.Join(root, "20260101", "record")
if err := os.MkdirAll(recordDir, 0o755); err != nil {
t.Fatalf("create record dir: %v", err)
}
for _, name := range []string{
"A260101_120000_120015.265",
"A260101_120500_120530.265",
} {
if err := os.WriteFile(filepath.Join(recordDir, name), []byte("video"), 0o644); err != nil {
t.Fatalf("write video fixture %s: %v", name, err)
}
}
return root
}
func writeThumbnailFFmpeg(t *testing.T, thumbPath, countPath string) string {
t.Helper()
lines := []string{"#!/bin/sh"}
if countPath != "" {
lines = append(lines, "echo thumb >> "+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)
}
}