package main // Test fixture generation commands (run from repository root): // // Valid 720p30 fixture (1.5s, H.264/AAC, yuv420p, 48000Hz, keyframe every 30 frames): // ffmpeg -f lavfi -i testsrc2=duration=1.5:size=1280x720:rate=30 \ // -f lavfi -i sine=frequency=440:duration=1.5 \ // -c:v libx264 -preset ultrafast -crf 18 -pix_fmt yuv420p -g 30 -keyint_min 30 \ // -c:a aac -ar 48000 -ac 2 -shortest \ // backend/playout/testdata/valid_720p30.mp4 // // Invalid h265 fixture (1.5s, HEVC, otherwise compliant): // ffmpeg -f lavfi -i testsrc2=duration=1.5:size=1280x720:rate=30 \ // -f lavfi -i sine=frequency=440:duration=1.5 \ // -c:v libx265 -preset ultrafast -crf 28 -pix_fmt yuv420p -g 30 -keyint_min 30 \ // -c:a aac -ar 48000 -ac 2 -shortest \ // -tag:v hvc1 backend/playout/testdata/invalid_h265.mp4 // // Invalid 24fps fixture (1.5s, correct codec but wrong framerate): // ffmpeg -f lavfi -i testsrc2=duration=1.5:size=1280x720:rate=24 \ // -f lavfi -i sine=frequency=440:duration=1.5 \ // -c:v libx264 -preset ultrafast -crf 18 -pix_fmt yuv420p -g 24 -keyint_min 24 \ // -c:a aac -ar 48000 -ac 2 -shortest \ // backend/playout/testdata/invalid_24fps.mp4 // // Test playlist fixtures are plain text files with paths relative to /app/media. // See backend/playout/testdata/*.txt. import ( "context" "fmt" "io" "net/http" "net/http/httptest" "os" "os/exec" "path/filepath" "strings" "testing" "time" "github.com/aws/aws-sdk-go-v2/aws" "github.com/aws/aws-sdk-go-v2/service/s3" "github.com/aws/aws-sdk-go-v2/service/s3/types" ) func ensureFFProbeInPath(t *testing.T) { t.Helper() if _, err := exec.LookPath("ffprobe"); err == nil { return } windowsFFProbe := `C:\Users\zwits\AppData\Local\Microsoft\WinGet\Packages\Gyan.FFmpeg_Microsoft.Winget.Source_8wekyb3d8bbwe\ffmpeg-9.0-full_build\bin\ffprobe.exe` if _, err := os.Stat(windowsFFProbe); err == nil { t.Setenv("PATH", filepath.Dir(windowsFFProbe)+string(os.PathListSeparator)+os.Getenv("PATH")) return } t.Skip("ffprobe not available on PATH and Windows WinGet install path not found") } func TestLoadPlaylist(t *testing.T) { tests := []struct { name string file string wantLen int wantErr bool first string }{ { name: "valid playlist", file: "valid_playlist.txt", wantLen: 2, first: "/app/media/demo1.mp4", }, { name: "empty playlist", file: "empty_playlist.txt", wantLen: 0, }, { name: "comments only", file: "comments_only.txt", wantLen: 0, }, { name: "mixed valid invalid", file: "mixed_valid_invalid.txt", wantLen: 3, first: "/app/media/demo1.mp4", }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { path := filepath.Join("..", "testdata", tt.file) 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) { ensureFFProbeInPath(t) tests := []struct { name string file string wantValid bool wantErr string }{ { name: "valid h264 fixture", file: "valid_720p30.mp4", wantValid: true, }, { name: "invalid h265 fixture", file: "invalid_h265.mp4", wantErr: "video codec must be h264", }, { name: "invalid framerate fixture", file: "invalid_24fps.mp4", wantErr: "framerate must be 30/1", }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { path := filepath.Join("..", "testdata", tt.file) result := ValidateVideoStrict(path) valid, _ := result["valid"].(bool) if valid != tt.wantValid { t.Fatalf("valid = %v, want %v", valid, tt.wantValid) } errs, _ := result["errors"].([]string) if tt.wantErr == "" { if len(errs) != 0 { t.Fatalf("errors = %v, want none", errs) } if got, ok := result["duration_seconds"].(float64); !ok || got <= 0 { t.Fatalf("duration_seconds = %v, want positive duration", result["duration_seconds"]) } return } if len(errs) == 0 { t.Fatalf("errors = %v, want message containing %q", errs, tt.wantErr) } if !strings.Contains(strings.Join(errs, "; "), tt.wantErr) { t.Fatalf("errors = %v, want message containing %q", errs, tt.wantErr) } }) } } func TestHealthHandler(t *testing.T) { // Save and restore globals prevMode := playoutMode prevCount := restartCount prevRunID := runID playoutMode = "live" restartCount = 3 runID = "r-20260810T120000Z" lastR2PubTime = time.Now() defer func() { playoutMode = prevMode restartCount = prevCount runID = prevRunID }() 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() for _, want := range []string{ `"status":"healthy"`, `"service":"playout"`, `"mode":"live"`, `"crash_count":3`, `"run_id":"r-20260810T120000Z"`, `"last_segment_seq"`, `"consecutive_failures"`, } { if !strings.Contains(body, want) { t.Errorf("body missing %q: %s", want, body) } } } func TestHealthHandler_Degraded(t *testing.T) { prevMode := playoutMode prevR2Pub := lastR2PubTime playoutMode = "slate" lastR2PubTime = time.Now() // fresh R2 pub = not unhealthy defer func() { playoutMode = prevMode lastR2PubTime = prevR2Pub }() 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 TestHealthHandler_Unhealthy(t *testing.T) { prevMode := playoutMode prevR2Pub := lastR2PubTime playoutMode = "live" lastR2PubTime = time.Now().Add(-30 * time.Second) // stale R2 = unhealthy defer func() { playoutMode = prevMode lastR2PubTime = prevR2Pub }() req := httptest.NewRequest("GET", "/health", nil) w := httptest.NewRecorder() HealthHandler(w, req) body := w.Body.String() if !strings.Contains(body, `"unhealthy"`) { t.Errorf("body missing unhealthy (stale R2 pub should trigger unhealthy): %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 TestValidateWorkersConfig(t *testing.T) { os.Unsetenv("PLAYOUT_VALIDATE_WORKERS") if got := validateWorkers(); got < 1 { t.Errorf("validateWorkers() = %d, want >= 1", got) } os.Setenv("PLAYOUT_VALIDATE_WORKERS", "3") if got := validateWorkers(); got != 3 { t.Errorf("validateWorkers() = %d, want 3", got) } os.Setenv("PLAYOUT_VALIDATE_WORKERS", "invalid") if got := validateWorkers(); got < 1 { t.Errorf("validateWorkers() = %d, want fallback >= 1", got) } os.Unsetenv("PLAYOUT_VALIDATE_WORKERS") } func TestValidatePlaylist_Dedup(t *testing.T) { ensureFFProbeInPath(t) valid := filepath.Join("..", "testdata", "valid_720p30.mp4") playlistDir := t.TempDir() p := filepath.Join(playlistDir, "playlist.txt") // Same file listed three times to exercise dedup + order preservation. content := fmt.Sprintf("file '%s'\nfile '%s'\nfile '%s'\n", valid, valid, valid) if err := os.WriteFile(p, []byte(content), 0o644); err != nil { t.Fatalf("write playlist: %v", err) } videos, err := ValidatePlaylist(p) if err != nil { t.Fatalf("ValidatePlaylist error: %v", err) } if len(videos) != 3 { t.Fatalf("len = %d, want 3", len(videos)) } for i, v := range videos { if !v.Valid { t.Errorf("entry %d invalid: %s", i, v.Validation) } if v.Path != valid { t.Errorf("entry %d path = %q, want %q", i, v.Path, valid) } } } func TestSyncToR2(t *testing.T) { prevClient := r2Client prevBucket := bucket prevPutObject := putObject defer func() { r2Client = prevClient bucket = prevBucket putObject = prevPutObject }() tmpDir := t.TempDir() localPath := filepath.Join(tmpDir, "segment.m4s") if err := os.WriteFile(localPath, []byte("segment-bytes"), 0o644); err != nil { t.Fatalf("write fixture: %v", err) } bucket = "linearvoid-media" r2Client = &s3.Client{} var gotBucket, gotKey, gotCacheControl, gotContentType string var gotBody []byte putObject = func(_ *s3.Client, input *s3.PutObjectInput) (*s3.PutObjectOutput, error) { gotBucket = aws.ToString(input.Bucket) gotKey = aws.ToString(input.Key) gotCacheControl = aws.ToString(input.CacheControl) gotContentType = aws.ToString(input.ContentType) body, err := io.ReadAll(input.Body) if err != nil { return nil, err } gotBody = body return &s3.PutObjectOutput{}, nil } if err := SyncToR2(localPath, "hls/segment.m4s"); err != nil { t.Fatalf("SyncToR2 error: %v", err) } if gotBucket != "linearvoid-media" { t.Fatalf("bucket = %q, want %q", gotBucket, "linearvoid-media") } if gotKey != "hls/segment.m4s" { t.Fatalf("key = %q, want %q", gotKey, "hls/segment.m4s") } if string(gotBody) != "segment-bytes" { t.Fatalf("body = %q, want %q", string(gotBody), "segment-bytes") } if gotCacheControl != segmentCacheControl { t.Fatalf("CacheControl = %q, want %q", gotCacheControl, segmentCacheControl) } if gotContentType != "video/MP2T" { t.Fatalf("ContentType = %q, want %q", gotContentType, "video/MP2T") } } func TestSyncToR2_ManifestCacheHeaders(t *testing.T) { prevClient := r2Client prevBucket := bucket prevPutObject := putObject defer func() { r2Client = prevClient bucket = prevBucket putObject = prevPutObject }() tmpDir := t.TempDir() localPath := filepath.Join(tmpDir, "playlist.m3u8") if err := os.WriteFile(localPath, []byte("#EXTM3U\n"), 0o644); err != nil { t.Fatalf("write fixture: %v", err) } bucket = "linearvoid-media" r2Client = &s3.Client{} var gotCacheControl, gotContentType string putObject = func(_ *s3.Client, input *s3.PutObjectInput) (*s3.PutObjectOutput, error) { gotCacheControl = aws.ToString(input.CacheControl) gotContentType = aws.ToString(input.ContentType) return &s3.PutObjectOutput{}, nil } if err := SyncToR2(localPath, "hls/playlist.m3u8"); err != nil { t.Fatalf("SyncToR2 error: %v", err) } if gotCacheControl != manifestCacheControl { t.Fatalf("CacheControl = %q, want %q", gotCacheControl, manifestCacheControl) } if gotContentType != "application/vnd.apple.mpegurl" { t.Fatalf("ContentType = %q, want %q", gotContentType, "application/vnd.apple.mpegurl") } } func TestSyncToR2_NoCacheHeadersForUnknownExt(t *testing.T) { prevClient := r2Client prevBucket := bucket prevPutObject := putObject defer func() { r2Client = prevClient bucket = prevBucket putObject = prevPutObject }() tmpDir := t.TempDir() localPath := filepath.Join(tmpDir, "data.bin") if err := os.WriteFile(localPath, []byte("raw"), 0o644); err != nil { t.Fatalf("write fixture: %v", err) } bucket = "linearvoid-media" r2Client = &s3.Client{} var gotCacheControl, gotContentType string putObject = func(_ *s3.Client, input *s3.PutObjectInput) (*s3.PutObjectOutput, error) { gotCacheControl = aws.ToString(input.CacheControl) gotContentType = aws.ToString(input.ContentType) return &s3.PutObjectOutput{}, nil } if err := SyncToR2(localPath, "misc/data.bin"); err != nil { t.Fatalf("SyncToR2 error: %v", err) } if gotCacheControl != "" { t.Fatalf("CacheControl = %q, want empty", gotCacheControl) } if gotContentType != "" { t.Fatalf("ContentType = %q, want empty", gotContentType) } } func TestR2KeepRuns(t *testing.T) { os.Setenv("R2_KEEP_RUNS", "") if got := r2KeepRuns(); got != 2 { t.Errorf("default = %d, want 2", got) } os.Setenv("R2_KEEP_RUNS", "5") if got := r2KeepRuns(); got != 5 { t.Errorf("override = %d, want 5", got) } os.Setenv("R2_KEEP_RUNS", "0") if got := r2KeepRuns(); got != 2 { t.Errorf("invalid = %d, want 2 fallback", got) } os.Unsetenv("R2_KEEP_RUNS") } func TestR2MaxRetentionBytes(t *testing.T) { os.Setenv("R2_MAX_RETENTION_BYTES", "") if got := r2MaxRetentionBytes(); got != 10*1024*1024*1024 { t.Errorf("default = %d, want 10GiB", got) } os.Setenv("R2_MAX_RETENTION_BYTES", "100") if got := r2MaxRetentionBytes(); got != 100 { t.Errorf("override = %d, want 100", got) } os.Setenv("R2_MAX_RETENTION_BYTES", "invalid") if got := r2MaxRetentionBytes(); got != 10*1024*1024*1024 { t.Errorf("invalid = %d, want 10GiB fallback", got) } os.Unsetenv("R2_MAX_RETENTION_BYTES") } func TestR2RunFromKey(t *testing.T) { cases := []struct { key string run string ok bool }{ {"hls/r-20260815T120000Z/seg-1.m4s", "r-20260815T120000Z", true}, {"hls/r-20260815T120000Z/init.mp4", "r-20260815T120000Z", true}, {"hls/playlist.m3u8", "", false}, {"hls/playlist.m3u8.tmp", "", false}, {"other/r-x/seg.m4s", "", false}, {"hls/r-noslash", "", false}, } for _, c := range cases { run, ok := r2RunFromKey(c.key) if run != c.run || ok != c.ok { t.Errorf("r2RunFromKey(%q) = (%q, %v), want (%q, %v)", c.key, run, ok, c.run, c.ok) } } } func TestCleanupOldRuns(t *testing.T) { prevClient := r2Client prevBucket := bucket prevList := listObjects prevDelete := deleteObjects prevTotal := r2CleanupObjectsTotal prevRuns := r2RunsRetained prevBytes := r2BytesRetained defer func() { r2Client = prevClient bucket = prevBucket listObjects = prevList deleteObjects = prevDelete r2CleanupObjectsTotal = prevTotal r2RunsRetained = prevRuns r2BytesRetained = prevBytes }() bucket = "linearvoid-media" r2Client = &s3.Client{} size := int64(1024) listObjects = func(_ *s3.Client, input *s3.ListObjectsV2Input) (*s3.ListObjectsV2Output, error) { return &s3.ListObjectsV2Output{ Contents: []types.Object{ {Key: aws.String("hls/r-20260813T000000Z/seg-1.m4s"), Size: &size}, {Key: aws.String("hls/r-20260814T000000Z/seg-2.m4s"), Size: &size}, {Key: aws.String("hls/r-20260815T000000Z/seg-3.m4s"), Size: &size}, {Key: aws.String("hls/playlist.m3u8"), Size: &size}, {Key: aws.String("hls/playlist.m3u8.tmp"), Size: &size}, }, IsTruncated: aws.Bool(false), }, nil } var deletedKeys []string deleteObjects = func(_ *s3.Client, input *s3.DeleteObjectsInput) (*s3.DeleteObjectsOutput, error) { for _, obj := range input.Delete.Objects { deletedKeys = append(deletedKeys, aws.ToString(obj.Key)) } return &s3.DeleteObjectsOutput{}, nil } n, err := cleanupOldRuns(context.Background()) if err != nil { t.Fatalf("cleanupOldRuns error: %v", err) } if n != 1 { t.Fatalf("deleted = %d, want 1 (oldest run)", n) } joined := strings.Join(deletedKeys, ",") if !strings.Contains(joined, "hls/r-20260813T000000Z/seg-1.m4s") { t.Fatalf("expected oldest run deleted, got: %v", deletedKeys) } if strings.Contains(joined, "playlist.m3u8") { t.Fatalf("manifest must not be deleted: %v", deletedKeys) } if r2RunsRetained != 2 { t.Fatalf("r2RunsRetained = %d, want 2", r2RunsRetained) } if r2BytesRetained != 2*size { t.Fatalf("r2BytesRetained = %d, want %d", r2BytesRetained, 2*size) } if r2CleanupObjectsTotal != 1 { t.Fatalf("r2CleanupObjectsTotal = %d, want 1", r2CleanupObjectsTotal) } } func TestCleanupOldRuns_SizeCap(t *testing.T) { prevClient := r2Client prevBucket := bucket prevList := listObjects prevDelete := deleteObjects prevTotal := r2CleanupObjectsTotal prevRuns := r2RunsRetained prevBytes := r2BytesRetained defer func() { r2Client = prevClient bucket = prevBucket listObjects = prevList deleteObjects = prevDelete r2CleanupObjectsTotal = prevTotal r2RunsRetained = prevRuns r2BytesRetained = prevBytes }() bucket = "linearvoid-media" r2Client = &s3.Client{} // Force a small cap so the size policy trims even the last 2 runs. os.Setenv("R2_KEEP_RUNS", "2") os.Setenv("R2_MAX_RETENTION_BYTES", "1024") defer os.Unsetenv("R2_KEEP_RUNS") defer os.Unsetenv("R2_MAX_RETENTION_BYTES") size := int64(1024) listObjects = func(_ *s3.Client, input *s3.ListObjectsV2Input) (*s3.ListObjectsV2Output, error) { return &s3.ListObjectsV2Output{ Contents: []types.Object{ {Key: aws.String("hls/r-20260813T000000Z/seg-1.m4s"), Size: &size}, {Key: aws.String("hls/r-20260814T000000Z/seg-2.m4s"), Size: &size}, {Key: aws.String("hls/r-20260815T000000Z/seg-3.m4s"), Size: &size}, }, IsTruncated: aws.Bool(false), }, nil } var deletedKeys []string deleteObjects = func(_ *s3.Client, input *s3.DeleteObjectsInput) (*s3.DeleteObjectsOutput, error) { for _, obj := range input.Delete.Objects { deletedKeys = append(deletedKeys, aws.ToString(obj.Key)) } return &s3.DeleteObjectsOutput{}, nil } n, err := cleanupOldRuns(context.Background()) if err != nil { t.Fatalf("cleanupOldRuns error: %v", err) } // 3 runs -> keep 2 -> oldest deleted by count; remaining 2 bytes (2048) exceed // the 1024 cap -> the older of the kept two is deleted, leaving only the newest. if n != 2 { t.Fatalf("deleted = %d, want 2", n) } joined := strings.Join(deletedKeys, ",") if !strings.Contains(joined, "hls/r-20260813T000000Z/seg-1.m4s") { t.Fatalf("expected oldest run deleted, got: %v", deletedKeys) } if !strings.Contains(joined, "hls/r-20260814T000000Z/seg-2.m4s") { t.Fatalf("expected second-oldest run deleted by size cap, got: %v", deletedKeys) } if strings.Contains(joined, "hls/r-20260815T000000Z/seg-3.m4s") { t.Fatalf("newest run must be kept, got: %v", deletedKeys) } if r2RunsRetained != 1 { t.Fatalf("r2RunsRetained = %d, want 1", r2RunsRetained) } } func TestPlaylistFingerprint(t *testing.T) { tmpDir := t.TempDir() p := filepath.Join(tmpDir, "playlist.txt") if err := os.WriteFile(p, []byte("file '/app/media/a.mp4'\n"), 0o644); err != nil { t.Fatal(err) } fp1, err := playlistFingerprint(p) if err != nil { t.Fatalf("playlistFingerprint error: %v", err) } fp2, err := playlistFingerprint(p) if err != nil { t.Fatalf("playlistFingerprint error: %v", err) } if fp1 != fp2 { t.Errorf("fingerprint not stable: %q vs %q", fp1, fp2) } if err := os.WriteFile(p, []byte("file '/app/media/b.mp4'\n"), 0o644); err != nil { t.Fatal(err) } fp3, err := playlistFingerprint(p) if err != nil { t.Fatalf("playlistFingerprint error: %v", err) } if fp1 == fp3 { t.Errorf("fingerprint should change when playlist content changes") } } func TestHLSNextStartNumber(t *testing.T) { tmpDir := t.TempDir() // Create segments 0..29 with a live window of 10. for i := 0; i < 30; i++ { if err := os.WriteFile(filepath.Join(tmpDir, fmt.Sprintf("seg-%06d.m4s", i)), []byte("x"), 0o644); err != nil { t.Fatal(err) } } next, err := hlsNextStartNumber(tmpDir, 10) if err != nil { t.Fatalf("hlsNextStartNumber error: %v", err) } if next != 30 { t.Errorf("next = %d, want 30", next) } // Segments < 20 should be pruned; 20..29 kept (the live window). for i := 0; i < 20; i++ { if _, err := os.Stat(filepath.Join(tmpDir, fmt.Sprintf("seg-%06d.m4s", i))); !os.IsNotExist(err) { t.Errorf("seg-%06d should have been pruned", i) } } for i := 20; i < 30; i++ { if _, err := os.Stat(filepath.Join(tmpDir, fmt.Sprintf("seg-%06d.m4s", i))); err != nil { t.Errorf("seg-%06d should be kept: %v", i, err) } } } func TestLoadSharedHLSConfig(t *testing.T) { prevDur := hlsSegmentDuration prevWin := hlsWindowSize defer func() { hlsSegmentDuration = prevDur hlsWindowSize = prevWin }() tmpDir := t.TempDir() cfgPath := filepath.Join(tmpDir, "config.json") if err := os.WriteFile(cfgPath, []byte(`{"hls_segment_duration":"4","hls_window_size":"12"}`), 0o644); err != nil { t.Fatal(err) } os.Setenv("PLAYOUT_CONFIG_PATH", cfgPath) defer os.Unsetenv("PLAYOUT_CONFIG_PATH") loadSharedHLSConfig() if hlsSegmentDuration != "4" { t.Errorf("hlsSegmentDuration = %q, want 4", hlsSegmentDuration) } if hlsWindowSize != "12" { t.Errorf("hlsWindowSize = %q, want 12", hlsWindowSize) } } func TestLoadSharedHLSConfig_MissingFile(t *testing.T) { prevDur := hlsSegmentDuration prevWin := hlsWindowSize defer func() { hlsSegmentDuration = prevDur hlsWindowSize = prevWin }() os.Setenv("PLAYOUT_CONFIG_PATH", filepath.Join(t.TempDir(), "nope.json")) defer os.Unsetenv("PLAYOUT_CONFIG_PATH") loadSharedHLSConfig() // should be a no-op without erroring if hlsSegmentDuration != prevDur { t.Errorf("hlsSegmentDuration changed to %q, want unchanged %q", hlsSegmentDuration, prevDur) } }