test(chat,playout): enable test suite, coverage gate, switch to pure-Go SQLite driver

This commit is contained in:
zwitschi committed 2026-08-07 14:06:30 +02:00
1 parent b3a10ba3ef
commit e8a43b8a79
16 files changed
+3257 -8

No files matched your search

+27
View File
@@ -0,0 +1,27 @@
module playout
go 1.26.5
require (
github.com/aws/aws-sdk-go-v2 v1.43.4
github.com/aws/aws-sdk-go-v2/config v1.32.35
github.com/aws/aws-sdk-go-v2/service/s3 v1.107.0
)
require (
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.16 // indirect
github.com/aws/aws-sdk-go-v2/credentials v1.19.34 // indirect
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.35 // indirect
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.35 // indirect
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.35 // indirect
github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.36 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.15 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.28 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.35 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.36 // indirect
github.com/aws/aws-sdk-go-v2/service/signin v1.5.4 // indirect
github.com/aws/aws-sdk-go-v2/service/sso v1.33.4 // indirect
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.38.4 // indirect
github.com/aws/aws-sdk-go-v2/service/sts v1.45.4 // indirect
github.com/aws/smithy-go v1.27.6 // indirect
)
+36
View File
@@ -0,0 +1,36 @@
github.com/aws/aws-sdk-go-v2 v1.43.4 h1:b9FTvbRwy+JCsfp2Wp6wV/KbOx3Aj7nkoFb2cRX0IhE=
github.com/aws/aws-sdk-go-v2 v1.43.4/go.mod h1:70vwSy16txshwG+g55WkpgPKDIByzHI8ccBsOteo3bQ=
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.16 h1:aiuaKlDweRC5qExJondpWjOgyzMHpofpwspGXUtwn4c=
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.16/go.mod h1:nG/LOlmox9BDe9HvQnXWzgcK8uKbgBMZ/Hp5pVt/21I=
github.com/aws/aws-sdk-go-v2/config v1.32.35 h1:UEzXuET8E42lxBPijuACu/tEK7v5lFPlk0Q+GT5WD9E=
github.com/aws/aws-sdk-go-v2/config v1.32.35/go.mod h1:KaMtJpFa2JlL2BStjjHQVwQpzZEmw+ND/EgVrfFoo2g=
github.com/aws/aws-sdk-go-v2/credentials v1.19.34 h1:y6GkSmcv5myd1ngrYbGmiLlwQqB6TQhOuN/tbSSuWDY=
github.com/aws/aws-sdk-go-v2/credentials v1.19.34/go.mod h1:w3dTcnDVoQIewjo7JG45hduAToikiIFLC4FIO7fndvw=
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.35 h1:+S7kbJoLDDQ5tE+lHrUBgMkzC8NLgsaioS2F3dVoFAE=
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.35/go.mod h1:Ak7xXviIARfFdNUJ9Etb0bdVDt/KAvKjMGJVLWXDzik=
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.35 h1:kzVuGlatQtYinwBJEEyLAbggepCoavosiaHHX9+fD+c=
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.35/go.mod h1:0yLx0yEI+SfqeJMPvOtIEFoZbiQYXMGszBueiutQyaI=
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.35 h1:WK6CjihTuLisCjSKKbildJ79sGZZgbBz3iNa7VsKIhU=
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.35/go.mod h1:KYleN57luLoe97R7vTnx8PMcVrr9gAcRECtOjl91DNg=
github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.36 h1:jbGY4CXLzZElOXgGsexlC3Hi+3YM0rSmk4opFXKqg/k=
github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.36/go.mod h1:uBu/9aKsS/UQGc72RAt3y54kjgYQxmhut8ZD2dXCDNE=
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.15 h1:JJLBQxwY+AFwuPAi5ivGc1ChnTdUt4cXMv7e76m2c/Y=
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.15/go.mod h1:lQknBIe78MVL0cQOQDlag8KGflMbMEVFx9mB6O8ENvk=
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.28 h1:Q1TF1J9jVD+vFo0LzNnmNdQ9EAt52TS+MQlq9Ir+Yxo=
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.28/go.mod h1:4KqXXC/p1hrotmouDFbrRoWaLy962b9PMUReCG6+uWo=
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.35 h1:BBEElKh4a+rKshvjrfpajTe9CbpZvrbb4Jkg2PB7RzA=
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.35/go.mod h1:zaZk983w//8beSruBVec/mr4CmDwgZitW/qzGhAAX0g=
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.36 h1:EUIwBoN+q7UmhAejxgD27APiRjh1vwCFo53gSqdT0BM=
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.36/go.mod h1:6u00gmlTGR6W0b2k9NBrld7MnOEmf1Spqx0VVt6AqyE=
github.com/aws/aws-sdk-go-v2/service/s3 v1.107.0 h1:OkYV+1171za+ab9otU1tGxMXhx6uZvwVEtVddjLuYTg=
github.com/aws/aws-sdk-go-v2/service/s3 v1.107.0/go.mod h1:5FTZoQxhmLEiCAtYVk6V+t0iS/B5yGZVLZ3Wq5FDJZI=
github.com/aws/aws-sdk-go-v2/service/signin v1.5.4 h1:cOJELVNrq5Q3Udry2GLuHUM7MhwpeaQRdYaoa6GI/yI=
github.com/aws/aws-sdk-go-v2/service/signin v1.5.4/go.mod h1:f4LxzKBtaTxD7xh3PiVg3CE1tchQemfmghaJr+NbK2c=
github.com/aws/aws-sdk-go-v2/service/sso v1.33.4 h1:AMW7a7S8iQaHjBYZdU3PCq4GKRPijTPRAc7e6XtEThY=
github.com/aws/aws-sdk-go-v2/service/sso v1.33.4/go.mod h1:QQNsFV1DVXoXcZt18FS8lI8rtUrlDyAuWZLQ5shunv4=
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.38.4 h1:AsbZcJAQPRmHDJG8K1N0pof/1zPWjVT8TFlTWuGLSvo=
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.38.4/go.mod h1:6imqztH0//t0mKbl6yWl7swSEl7F/w32oAmqB3vP1ag=
github.com/aws/aws-sdk-go-v2/service/sts v1.45.4 h1:w/AryDYMjSUANSQ2uoZxJovUsMTwWJNTv3IMex30Y+4=
github.com/aws/aws-sdk-go-v2/service/sts v1.45.4/go.mod h1:WeBiAa67azG7Su9Vf+ChGDBLiAozJCXzdjXiPBUwtbc=
github.com/aws/smithy-go v1.27.6 h1:0zjT8jgK3jbrTT7JJ3EE6JsMhX8JTrZ+f1sEndYDXrA=
github.com/aws/smithy-go v1.27.6/go.mod h1:YE2RhdIuDbA5E5bTdciG9KrW3+TiEONeUWCqxX9i1Fc=
+353
View File
@@ -0,0 +1,353 @@
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")
}