Files
linearvoid/backend/playout/src/playlist_test.go
T

353 lines
8.6 KiB
Go

package main
import (
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
"time"
)
func TestLoadPlaylist(t *testing.T) {
tmpDir := t.TempDir()
tests := []struct {
name string
content string
wantLen int
wantErr bool
first string
}{
{
name: "simple paths",
content: "/videos/video1.mp4\n/videos/video2.mp4\n",
wantLen: 2,
first: "/videos/video1.mp4",
},
{
name: "with file prefix",
content: "file /videos/video1.mp4\nfile '/videos/video2.mp4'\n",
wantLen: 2,
first: "/videos/video1.mp4",
},
{
name: "with file prefix double quotes",
content: "file \"/videos/video1.mp4\"\n",
wantLen: 1,
first: "/videos/video1.mp4",
},
{
name: "with comments and blanks",
content: "# comment\n\n/videos/video1.mp4\n\n# another\n/videos/video2.mp4\n",
wantLen: 2,
first: "/videos/video1.mp4",
},
{
name: "empty file",
content: "",
wantLen: 0,
},
{
name: "only comments",
content: "# comment\n# another\n",
wantLen: 0,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
path := filepath.Join(tmpDir, tt.name+".txt")
os.WriteFile(path, []byte(tt.content), 0644)
videos, err := LoadPlaylist(path)
if tt.wantErr && err == nil {
t.Error("expected error, got nil")
}
if !tt.wantErr && err != nil {
t.Errorf("unexpected error: %v", err)
}
if len(videos) != tt.wantLen {
t.Errorf("len = %d, want %d", len(videos), tt.wantLen)
}
if tt.wantLen > 0 && videos[0].Path != tt.first {
t.Errorf("videos[0].Path = %q, want %q", videos[0].Path, tt.first)
}
})
}
}
func TestLoadPlaylist_FileNotFound(t *testing.T) {
_, err := LoadPlaylist("/nonexistent/playlist.txt")
if err == nil {
t.Error("expected error for missing file, got nil")
}
}
func TestValidateKeyframeInterval(t *testing.T) {
tests := []struct {
name string
csvData string
wantErr bool
errText string
}{
{
name: "valid keyframes (gaps <=2s)",
csvData: "1,0.00\n0,0.50\n1,1.00\n0,1.50\n1,2.00\n0,2.50\n1,3.00\n",
wantErr: false,
},
{
name: "gap too large >2s",
csvData: "1,0.00\n0,1.00\n1,3.50\n0,4.00\n1,6.00\n",
wantErr: true,
errText: "keyframe interval too large",
},
{
name: "insufficient keyframes (<2)",
csvData: "1,0.00\n",
wantErr: true,
errText: "insufficient keyframes",
},
{
name: "empty output",
csvData: "",
wantErr: true,
errText: "keyframe probe failed",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
// create temp ffprobe that outputs csvData
tmpDir := t.TempDir()
mockProbe := filepath.Join(tmpDir, "ffprobe")
var script string
if tt.csvData == "" {
script = "@echo off\necho\nexit /b 1\n"
} else {
script = "@echo off\necho " + strings.ReplaceAll(tt.csvData, "\n", "& echo ") + "\nexit /b 0\n"
}
os.WriteFile(mockProbe+".bat", []byte(script), 0755)
os.Setenv("PATH", tmpDir+string(os.PathListSeparator)+os.Getenv("PATH"))
defer os.Unsetenv("PATH")
err := validateKeyframeInterval("test.mp4")
if tt.wantErr && err == nil {
t.Error("expected error, got nil")
}
if !tt.wantErr && err != nil {
t.Errorf("unexpected error: %v", err)
}
if tt.wantErr && tt.errText != "" && err != nil {
if !strings.Contains(err.Error(), tt.errText) {
t.Errorf("error = %v, want containing %q", err, tt.errText)
}
}
})
}
}
func TestValidateVideoStrict(t *testing.T) {
// ValidateVideoStrict calls ValidateVideo which calls ffprobe
// Test only the result structure when ValidateVideo returns valid=false
// Mock not feasible without full ffprobe mock, skip for now
t.Skip("requires ffprobe mock or test media fixtures")
}
func TestHealthHandler(t *testing.T) {
// Save and restore globals
prevMode := playoutMode
prevCount := restartCount
playoutMode = "live"
restartCount = 3
defer func() {
playoutMode = prevMode
restartCount = prevCount
}()
req := httptest.NewRequest("GET", "/health", nil)
w := httptest.NewRecorder()
HealthHandler(w, req)
if w.Code != http.StatusOK {
t.Errorf("status = %d, want 200", w.Code)
}
body := w.Body.String()
if !strings.Contains(body, `"healthy"`) {
t.Errorf("body missing healthy: %s", body)
}
if !strings.Contains(body, `"playout"`) {
t.Errorf("body missing service: %s", body)
}
if !strings.Contains(body, `"restarts":3`) {
t.Errorf("body missing restarts: %s", body)
}
}
func TestHealthHandler_Degraded(t *testing.T) {
prevMode := playoutMode
playoutMode = "slate"
defer func() { playoutMode = prevMode }()
req := httptest.NewRequest("GET", "/health", nil)
w := httptest.NewRecorder()
HealthHandler(w, req)
if w.Code != http.StatusOK {
t.Errorf("status = %d, want 200", w.Code)
}
body := w.Body.String()
if !strings.Contains(body, `"degraded"`) {
t.Errorf("body missing degraded: %s", body)
}
}
func TestShouldEscalateToSlate(t *testing.T) {
// Save globals
prevCount := restartCount
prevWindow := restartWindow
defer func() {
restartCount = prevCount
restartWindow = prevWindow
}()
// Under threshold
restartWindow = time.Now()
restartCount = 3
if shouldEscalateToSlate() {
t.Error("shouldEscalateToSlate() = true, want false (under threshold)")
}
// At threshold
restartCount = maxRestartsPerMinute
if !shouldEscalateToSlate() {
t.Error("shouldEscalateToSlate() = false, want true (at threshold)")
}
// Over threshold
restartCount = 7
if !shouldEscalateToSlate() {
t.Error("shouldEscalateToSlate() = false, want true (over threshold)")
}
// Window expired
restartWindow = time.Now().Add(-61 * time.Second)
restartCount = 10
if shouldEscalateToSlate() {
t.Error("shouldEscalateToSlate() = true, want false (window expired, should reset)")
}
if restartCount != 0 {
t.Errorf("restartCount after window reset = %d, want 0", restartCount)
}
}
func TestResetHLSOutput(t *testing.T) {
tmpDir := t.TempDir()
// Create some segment files
os.MkdirAll(tmpDir, 0755)
os.WriteFile(filepath.Join(tmpDir, "segment_test.ts"), []byte("data"), 0644)
os.WriteFile(filepath.Join(tmpDir, "segment_test.m4s"), []byte("data"), 0644)
os.WriteFile(filepath.Join(tmpDir, "playlist.m3u8"), []byte("#EXTM3U"), 0644)
os.WriteFile(filepath.Join(tmpDir, "init.mp4"), []byte("init"), 0644)
err := resetHLSOutput(tmpDir)
if err != nil {
t.Fatalf("resetHLSOutput error: %v", err)
}
// Verify all cleaned
for _, name := range []string{"segment_test.ts", "segment_test.m4s", "playlist.m3u8", "init.mp4"} {
if _, err := os.Stat(filepath.Join(tmpDir, name)); !os.IsNotExist(err) {
t.Errorf("file %s still exists after reset", name)
}
}
}
func TestResetHLSOutput_EmptyDir(t *testing.T) {
tmpDir := t.TempDir()
err := resetHLSOutput(tmpDir)
if err != nil {
t.Fatalf("resetHLSOutput on empty dir error: %v", err)
}
}
func TestActivateSlate_NoSlate(t *testing.T) {
prevSlateDir := slateDir
prevHLSDir := hlsDir
prevMode := playoutMode
tmpDir := t.TempDir()
slateDir = filepath.Join(tmpDir, "slate")
hlsDir = filepath.Join(tmpDir, "hls")
os.MkdirAll(hlsDir, 0755)
defer func() {
slateDir = prevSlateDir
hlsDir = prevHLSDir
playoutMode = prevMode
}()
err := activateSlate()
if err == nil {
t.Error("expected error when slate HLS missing, got nil")
}
}
func TestActivateSlate_Success(t *testing.T) {
prevSlateDir := slateDir
prevHLSDir := hlsDir
prevMode := playoutMode
tmpDir := t.TempDir()
slateDir = filepath.Join(tmpDir, "slate")
hlsDir = filepath.Join(tmpDir, "hls")
// Create slate HLS structure
slateHLS := filepath.Join(slateDir, "hls")
os.MkdirAll(slateHLS, 0755)
os.MkdirAll(hlsDir, 0755)
os.WriteFile(filepath.Join(slateHLS, "slate.m3u8"), []byte("#EXTM3U\n#EXT-X-ENDLIST\n"), 0644)
os.WriteFile(filepath.Join(slateHLS, "slate_segment.ts"), []byte("slate-data"), 0644)
defer func() {
slateDir = prevSlateDir
hlsDir = prevHLSDir
playoutMode = prevMode
}()
err := activateSlate()
if err != nil {
t.Fatalf("activateSlate error: %v", err)
}
if playoutMode != "slate" {
t.Errorf("playoutMode = %q, want slate", playoutMode)
}
// Verify files copied
if _, err := os.Stat(filepath.Join(hlsDir, "slate.m3u8")); os.IsNotExist(err) {
t.Error("slate.m3u8 not copied to hls dir")
}
}
func TestDeactivateSlate(t *testing.T) {
prevMode := playoutMode
playoutMode = "slate"
defer func() { playoutMode = prevMode }()
deactivateSlate()
if playoutMode != "live" {
t.Errorf("playoutMode = %q, want live", playoutMode)
}
}
func TestValidatePlaylist(t *testing.T) {
// ValidatePlaylist calls ValidateVideo which needs ffprobe
t.Skip("requires ffprobe mock or test media fixtures")
}
func TestSyncToR2(t *testing.T) {
// Requires R2/S3 mock
t.Skip("requires S3 client mock")
}