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") }