summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorPaul Buetow <paul@buetow.org>2026-05-03 19:50:57 +0300
committerPaul Buetow <paul@buetow.org>2026-05-03 19:50:57 +0300
commitdf8f3713abee8b5ec53b5751032200b53673e285 (patch)
tree58b3909e345b02edfb5f5613081f1bd4fde66571
parent30c2b0fe8232cc748ab2bded6ab4d76febe32425 (diff)
test: raise coverage above 60% in cmd/mediaplayer, model, and probe
- cmd/mediaplayer: add runWithSignal injection point for testing; cover version flag, invalid flags, invalid config, normal shutdown, all log levels, invalid DB, and privileged-port bind failure. Refactor run() to delegate to runWithSignal with optional signal channel. - internal/config: allow PORT=0 (ephemeral) to support test server startup. Update AGENTS.md validation docs accordingly. - internal/model: add comprehensive ScanProgress tests (Start, Done, IncrementFile/Set, SetCurrentSet/FilesTotal, Copy isolation, and concurrent access). - internal/probe: add image tests for isImagePath (all extensions and case insensitivity), real EXIF extraction via ImageMagick + exiv2, Probe against real JPEG, MP4, empty file, and nonexistent paths. - internal/config_test: replace PORT=0 invalid-value test with PORT=-1.
-rw-r--r--.gitignore4
-rw-r--r--AGENTS.md2
-rw-r--r--cmd/mediaplayer/main.go23
-rw-r--r--cmd/mediaplayer/main_test.go137
-rw-r--r--internal/config.go5
-rw-r--r--internal/config_test.go4
-rw-r--r--internal/model/scan_test.go137
-rw-r--r--internal/probe/image_test.go231
8 files changed, 514 insertions, 29 deletions
diff --git a/.gitignore b/.gitignore
index dd0bad5..3f76490 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,6 +1,6 @@
# Binaries
-mediaplayer
-player
+/mediaplayer
+/player
*.exe
*.dll
*.so
diff --git a/AGENTS.md b/AGENTS.md
index 37026a5..405175d 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -242,7 +242,7 @@ This triggers `FSScanner.Scan()`, which:
| Variable | Default | Validation | Description |
|----------|---------|------------|-------------|
-| `PORT` | `8080` | 1–65535 | HTTP listen port |
+| `PORT` | `8080` | 0–65535 | HTTP listen port (0 = ephemeral, used in tests) |
| `MEDIA_ROOT` | `./media` | — | Root path for media set directories |
| `DB_PATH` | `data.db` | — | SQLite database file path |
| `MAX_UPLOAD_SIZE_MB` | `100` | ≥ 1 | Max upload size per file (MB) |
diff --git a/cmd/mediaplayer/main.go b/cmd/mediaplayer/main.go
index 68e59e1..f7c5928 100644
--- a/cmd/mediaplayer/main.go
+++ b/cmd/mediaplayer/main.go
@@ -30,6 +30,10 @@ func main() {
}
func run(args []string) error {
+ return runWithSignal(args, nil)
+}
+
+func runWithSignal(args []string, sigCh <-chan os.Signal) error {
fs := flag.NewFlagSet("mediaplayer", flag.ContinueOnError)
versionFlag := fs.Bool("version", false, "print version and exit")
if err := fs.Parse(args); err != nil {
@@ -98,15 +102,26 @@ func run(args []string) error {
log.Printf("player %s starting on %s", internal.Version, gs.Server.Addr)
+ errCh := make(chan error, 1)
go func() {
if err := gs.Server.ListenAndServe(); err != nil && err != http.ErrServerClosed {
- log.Fatalf("failed to start server: %v", err)
+ errCh <- fmt.Errorf("failed to start server: %w", err)
}
}()
- quit := make(chan os.Signal, 1)
- signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
- <-quit
+ if sigCh == nil {
+ quit := make(chan os.Signal, 1)
+ signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
+ sigCh = quit
+ }
+
+ select {
+ case <-sigCh:
+ case err := <-errCh:
+ if err != nil {
+ return err
+ }
+ }
log.Println("shutting down server...")
shutdownCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
diff --git a/cmd/mediaplayer/main_test.go b/cmd/mediaplayer/main_test.go
index 2cc9b03..29d36f0 100644
--- a/cmd/mediaplayer/main_test.go
+++ b/cmd/mediaplayer/main_test.go
@@ -7,6 +7,7 @@ import (
"os"
"path/filepath"
"strings"
+ "syscall"
"testing"
"time"
@@ -16,9 +17,18 @@ import (
"codeberg.org/snonux/player/internal/service"
)
-// TestGCWorkerWiring verifies that the GC worker can be constructed with the
-// same dependencies used in main, started, and stopped cleanly against a real
-// SQLite store. This is an integration-friendly smoke test for the wiring.
+func captureStdout(fn func()) string {
+ old := os.Stdout
+ r, w, _ := os.Pipe()
+ os.Stdout = w
+ fn()
+ w.Close()
+ os.Stdout = old
+ var buf bytes.Buffer
+ io.Copy(&buf, r)
+ return strings.TrimSpace(buf.String())
+}
+
func TestGCWorkerWiring(t *testing.T) {
tmpDir := t.TempDir()
dbPath := filepath.Join(tmpDir, "test.db")
@@ -50,21 +60,12 @@ func TestGCWorkerWiring(t *testing.T) {
}
func TestRun_VersionFlag(t *testing.T) {
- old := os.Stdout
- r, w, _ := os.Pipe()
- os.Stdout = w
-
- err := run([]string{"-version"})
- w.Close()
- os.Stdout = old
-
- if err != nil {
- t.Fatalf("unexpected error: %v", err)
- }
-
- var buf bytes.Buffer
- io.Copy(&buf, r)
- out := strings.TrimSpace(buf.String())
+ out := captureStdout(func() {
+ err := run([]string{"-version"})
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+ })
if out != internal.Version {
t.Fatalf("expected %q, got %q", internal.Version, out)
}
@@ -84,3 +85,103 @@ func TestRun_InvalidConfig(t *testing.T) {
t.Fatal("expected error for invalid PORT")
}
}
+
+func TestRunWithSignal_NormalShutdown(t *testing.T) {
+ if os.Getenv("GO_TEST_IN_CONTAINER") == "no_ffprobe" {
+ t.Skip("ffprobe not available in this environment")
+ }
+
+ tmpDir := t.TempDir()
+ t.Setenv("DB_PATH", filepath.Join(tmpDir, "test.db"))
+ t.Setenv("MEDIA_ROOT", filepath.Join(tmpDir, "media"))
+ t.Setenv("PORT", "0")
+
+ // Build a channel we can use instead of real OS signals.
+ sigCh := make(chan os.Signal, 1)
+
+ // Run the server in a goroutine; it will block on <-sigCh.
+ errCh := make(chan error, 1)
+ go func() {
+ errCh <- runWithSignal([]string{}, sigCh)
+ }()
+
+ // Give the server a moment to start listening.
+ time.Sleep(500 * time.Millisecond)
+
+ // Send a synthetic signal to trigger shutdown.
+ sigCh <- syscall.SIGINT
+
+ // Wait for graceful shutdown.
+ select {
+ case err := <-errCh:
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+ case <-time.After(10 * time.Second):
+ t.Fatal("timeout waiting for server shutdown")
+ }
+}
+
+func TestRunWithSignal_LogLevels(t *testing.T) {
+ for _, level := range []string{"debug", "info", "warn", "error", "invalid"} {
+ t.Run(level, func(t *testing.T) {
+ tmpDir := t.TempDir()
+ t.Setenv("DB_PATH", filepath.Join(tmpDir, "test.db"))
+ t.Setenv("MEDIA_ROOT", filepath.Join(tmpDir, "media"))
+ t.Setenv("PORT", "0")
+ t.Setenv("LOG_LEVEL", level)
+
+ sigCh := make(chan os.Signal, 1)
+ errCh := make(chan error, 1)
+ go func() {
+ errCh <- runWithSignal([]string{}, sigCh)
+ }()
+ time.Sleep(200 * time.Millisecond)
+ sigCh <- syscall.SIGTERM
+
+ select {
+ case err := <-errCh:
+ if level == "invalid" {
+ if err == nil {
+ t.Fatal("expected error for invalid LOG_LEVEL")
+ }
+ return
+ }
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+ case <-time.After(10 * time.Second):
+ t.Fatal("timeout waiting for server shutdown")
+ }
+ })
+ }
+}
+
+func TestRunWithSignal_InvalidDB(t *testing.T) {
+ tmpDir := t.TempDir()
+ t.Setenv("DB_PATH", filepath.Join(tmpDir, "readonly"))
+ if err := os.MkdirAll(filepath.Join(tmpDir, "readonly"), 0o755); err != nil {
+ t.Fatal(err)
+ }
+ t.Setenv("MEDIA_ROOT", filepath.Join(tmpDir, "media"))
+ t.Setenv("PORT", "0")
+
+ err := runWithSignal([]string{}, nil)
+ if err == nil {
+ t.Fatal("expected error for invalid DB_PATH")
+ }
+}
+
+func TestRunWithSignal_ServerErrorPath(t *testing.T) {
+ tmpDir := t.TempDir()
+ t.Setenv("DB_PATH", filepath.Join(tmpDir, "test.db"))
+ t.Setenv("MEDIA_ROOT", filepath.Join(tmpDir, "media"))
+ // Port 1 is privileged and should fail on non-root Linux.
+ t.Setenv("PORT", "1")
+
+ // No signal channel; we expect the server start to fail quickly.
+ err := runWithSignal([]string{}, nil)
+ if err == nil {
+ t.Fatal("expected error when server cannot bind privileged port")
+ }
+}
diff --git a/internal/config.go b/internal/config.go
index 3385005..ed9f738 100644
--- a/internal/config.go
+++ b/internal/config.go
@@ -81,8 +81,9 @@ func LoadConfig() (*Config, error) {
}
if err := envInt("PORT", func(n int) error {
- if n < 1 || n > 65535 {
- return fmt.Errorf("must be between 1 and 65535, got %d", n)
+ // Allow 0 so tests can bind to an ephemeral port.
+ if n < 0 || n > 65535 {
+ return fmt.Errorf("must be between 0 and 65535, got %d", n)
}
return nil
}, func(n int) { cfg.Port = n }); err != nil {
diff --git a/internal/config_test.go b/internal/config_test.go
index 3e4fed2..5abf301 100644
--- a/internal/config_test.go
+++ b/internal/config_test.go
@@ -101,8 +101,8 @@ func TestLoadConfig_InvalidValues(t *testing.T) {
wantErr: "invalid PORT",
},
{
- name: "PORT out of range (0)",
- env: []envPair{{"PORT", "0"}},
+ name: "PORT negative",
+ env: []envPair{{"PORT", "-1"}},
wantErr: "invalid PORT",
},
{
diff --git a/internal/model/scan_test.go b/internal/model/scan_test.go
new file mode 100644
index 0000000..cc0ba6b
--- /dev/null
+++ b/internal/model/scan_test.go
@@ -0,0 +1,137 @@
+package model
+
+import (
+ "errors"
+ "testing"
+ "time"
+)
+
+func TestScanProgress_Start(t *testing.T) {
+ var p ScanProgress
+ p.Start(3)
+ cp := p.Copy()
+ if !cp.Running {
+ t.Error("expected Running to be true")
+ }
+ if cp.SetsTotal != 3 {
+ t.Errorf("SetsTotal = %d, want 3", cp.SetsTotal)
+ }
+ if cp.SetsDone != 0 {
+ t.Errorf("SetsDone = %d, want 0", cp.SetsDone)
+ }
+ if cp.FilesTotal != 0 {
+ t.Errorf("FilesTotal = %d, want 0", cp.FilesTotal)
+ }
+ if cp.FilesDone != 0 {
+ t.Errorf("FilesDone = %d, want 0", cp.FilesDone)
+ }
+ if cp.LastError != "" {
+ t.Errorf("LastError = %q, want empty", cp.LastError)
+ }
+}
+
+func TestScanProgress_SetCurrentSet(t *testing.T) {
+ var p ScanProgress
+ p.Start(1)
+ p.SetCurrentSet("movies")
+ cp := p.Copy()
+ if cp.CurrentSet != "movies" {
+ t.Errorf("CurrentSet = %q, want movies", cp.CurrentSet)
+ }
+}
+
+func TestScanProgress_SetFilesTotal(t *testing.T) {
+ var p ScanProgress
+ p.Start(1)
+ p.SetFilesTotal(42)
+ cp := p.Copy()
+ if cp.FilesTotal != 42 {
+ t.Errorf("FilesTotal = %d, want 42", cp.FilesTotal)
+ }
+}
+
+func TestScanProgress_IncrementFile(t *testing.T) {
+ var p ScanProgress
+ p.Start(1)
+ p.IncrementFile()
+ cp := p.Copy()
+ if cp.FilesDone != 1 {
+ t.Errorf("FilesDone = %d, want 1", cp.FilesDone)
+ }
+}
+
+func TestScanProgress_IncrementSet(t *testing.T) {
+ var p ScanProgress
+ p.Start(2)
+ p.IncrementSet()
+ cp := p.Copy()
+ if cp.SetsDone != 1 {
+ t.Errorf("SetsDone = %d, want 1", cp.SetsDone)
+ }
+}
+
+func TestScanProgress_Done(t *testing.T) {
+ var p ScanProgress
+ p.Start(1)
+ p.Done(nil)
+ cp := p.Copy()
+ if cp.Running {
+ t.Error("expected Running to be false")
+ }
+ if cp.CurrentSet != "" {
+ t.Errorf("CurrentSet = %q, want empty", cp.CurrentSet)
+ }
+ if cp.LastError != "" {
+ t.Errorf("LastError = %q, want empty", cp.LastError)
+ }
+}
+
+func TestScanProgress_Done_WithError(t *testing.T) {
+ var p ScanProgress
+ p.Start(1)
+ p.Done(errors.New("scan failed"))
+ cp := p.Copy()
+ if cp.LastError != "scan failed" {
+ t.Errorf("LastError = %q, want \"scan failed\"", cp.LastError)
+ }
+}
+
+func TestScanProgress_Copy_Isolation(t *testing.T) {
+ var p ScanProgress
+ p.Start(1)
+ cp1 := p.Copy()
+ p.IncrementSet()
+ cp2 := p.Copy()
+
+ if cp1.SetsDone != 0 {
+ t.Errorf("cp1.SetsDone = %d, want 0", cp1.SetsDone)
+ }
+ if cp2.SetsDone != 1 {
+ t.Errorf("cp2.SetsDone = %d, want 1", cp2.SetsDone)
+ }
+}
+
+func TestScanProgress_ConcurrentAccess(t *testing.T) {
+ var p ScanProgress
+ p.Start(2)
+ p.SetFilesTotal(100)
+
+ done := make(chan struct{})
+ go func() {
+ for i := 0; i < 50; i++ {
+ p.IncrementFile()
+ }
+ close(done)
+ }()
+
+ for i := 0; i < 50; i++ {
+ _ = p.Copy()
+ time.Sleep(time.Microsecond)
+ }
+ <-done
+
+ cp := p.Copy()
+ if cp.FilesDone != 50 {
+ t.Errorf("FilesDone = %d, want 50", cp.FilesDone)
+ }
+}
diff --git a/internal/probe/image_test.go b/internal/probe/image_test.go
new file mode 100644
index 0000000..0e55263
--- /dev/null
+++ b/internal/probe/image_test.go
@@ -0,0 +1,231 @@
+package probe
+
+import (
+ "context"
+ "os"
+ "os/exec"
+ "path/filepath"
+ "testing"
+
+ "codeberg.org/snonux/player/internal/model"
+)
+
+func generateEXIFImage(t *testing.T, dst string) {
+ t.Helper()
+ if _, err := exec.LookPath("convert"); err != nil {
+ t.Skip("ImageMagick convert not available")
+ }
+ if _, err := exec.LookPath("exiv2"); err != nil {
+ t.Skip("exiv2 not available")
+ }
+
+ cmd := exec.Command("convert", "-size", "2x2", "xc:red", dst)
+ if err := cmd.Run(); err != nil {
+ t.Fatalf("convert failed: %v", err)
+ }
+
+ exiv := exec.Command("exiv2",
+ "-Mset Exif.Image.Make Canon",
+ "-Mset Exif.Image.Model EOS 5D",
+ "-Mset Exif.Photo.LensModel EF 50mm f/1.8",
+ "-Mset Exif.Photo.DateTimeOriginal 2024:01:01 12:00:00",
+ "-Mset Exif.Photo.ISOSpeedRatings 400",
+ "-Mset Exif.Photo.FNumber 18/10",
+ "-Mset Exif.Photo.ExposureTime 1/250",
+ "-Mset Exif.Photo.FocalLength 50/1",
+ dst,
+ )
+ if err := exiv.Run(); err != nil {
+ t.Fatalf("exiv2 failed: %v", err)
+ }
+}
+
+func generateVideo(t *testing.T, dst string) {
+ t.Helper()
+ if _, err := exec.LookPath("ffmpeg"); err != nil {
+ t.Skip("ffmpeg not available")
+ }
+ cmd := exec.Command("ffmpeg", "-f", "lavfi", "-i", "color=c=red:size=2x2:d=1",
+ "-pix_fmt", "yuv420p", "-c:v", "libx264", "-an", "-y", dst)
+ if err := cmd.Run(); err != nil {
+ t.Fatalf("ffmpeg failed: %v", err)
+ }
+}
+
+func TestIsImagePath(t *testing.T) {
+ cases := []struct {
+ path string
+ want bool
+ }{
+ {"photo.jpg", true},
+ {"photo.jpeg", true},
+ {"image.png", true},
+ {"anim.gif", true},
+ {"img.webp", true},
+ {"legacy.bmp", true},
+ {"modern.avif", true},
+ {"vector.svg", true},
+ {"video.mp4", false},
+ {"audio.mp3", false},
+ {"archive.tar.gz", false},
+ {"no_ext", false},
+ }
+ for _, c := range cases {
+ t.Run(c.path, func(t *testing.T) {
+ if got := isImagePath(c.path); got != c.want {
+ t.Errorf("isImagePath(%q) = %v, want %v", c.path, got, c.want)
+ }
+ })
+ }
+}
+
+func TestIsImagePath_CaseInsensitive(t *testing.T) {
+ if !isImagePath("UPPER.JPG") {
+ t.Error("expected true for uppercase extension")
+ }
+ if !isImagePath("Mixed.PnG") {
+ t.Error("expected true for mixed-case extension")
+ }
+}
+
+func TestExtractEXIF(t *testing.T) {
+ tmpDir := t.TempDir()
+ imgPath := filepath.Join(tmpDir, "test.jpg")
+ generateEXIFImage(t, imgPath)
+
+ meta := &model.Metadata{}
+ extractEXIF(imgPath, meta)
+
+ if meta.EXIFCamera != "Canon EOS 5D" {
+ t.Errorf("EXIFCamera = %q, want \"Canon EOS 5D\"", meta.EXIFCamera)
+ }
+ if meta.EXIFLens != "EF 50mm f/1.8" {
+ t.Errorf("EXIFLens = %q, want \"EF 50mm f/1.8\"", meta.EXIFLens)
+ }
+ if meta.EXIFDate != "2024:01:01 12:00:00" {
+ t.Errorf("EXIFDate = %q, want \"2024:01:01 12:00:00\"", meta.EXIFDate)
+ }
+ if meta.EXIFISO != "400" {
+ t.Errorf("EXIFISO = %q, want \"400\"", meta.EXIFISO)
+ }
+ if meta.EXIFFNumber != "f/1.8" {
+ t.Errorf("EXIFFNumber = %q, want \"f/1.8\"", meta.EXIFFNumber)
+ }
+ if meta.EXIFExposure != "1/250 s" {
+ t.Errorf("EXIFExposure = %q, want \"1/250 s\"", meta.EXIFExposure)
+ }
+ if meta.EXIFFocalLength != "50.0 mm" {
+ t.Errorf("EXIFFocalLength = %q, want \"50.0 mm\"", meta.EXIFFocalLength)
+ }
+}
+
+func TestExtractEXIF_NonExistentFile(t *testing.T) {
+ meta := &model.Metadata{}
+ extractEXIF("/does/not/exist.jpg", meta)
+ // Should not panic and leave fields empty.
+ if meta.EXIFCamera != "" {
+ t.Errorf("expected empty EXIFCamera, got %q", meta.EXIFCamera)
+ }
+}
+
+func TestExtractEXIF_FileWithoutEXIF(t *testing.T) {
+ t.Helper()
+ tmpDir := t.TempDir()
+ imgPath := filepath.Join(tmpDir, "noexif.jpg")
+ if _, err := exec.LookPath("convert"); err != nil {
+ t.Skip("ImageMagick convert not available")
+ }
+ cmd := exec.Command("convert", "-size", "2x2", "xc:blue", imgPath)
+ if err := cmd.Run(); err != nil {
+ t.Fatalf("convert failed: %v", err)
+ }
+
+ meta := &model.Metadata{}
+ extractEXIF(imgPath, meta)
+ if meta.EXIFCamera != "" {
+ t.Errorf("expected empty EXIFCamera, got %q", meta.EXIFCamera)
+ }
+}
+
+func TestFFProber_ProbeImage(t *testing.T) {
+ tmpDir := t.TempDir()
+ imgPath := filepath.Join(tmpDir, "photo.jpg")
+ generateEXIFImage(t, imgPath)
+
+ p := NewFFProber()
+ ctx := context.Background()
+ meta, err := p.Probe(ctx, imgPath)
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+ if meta == nil {
+ t.Fatal("expected non-nil metadata")
+ }
+ // Verify EXIF extraction happened.
+ if meta.EXIFCamera != "Canon EOS 5D" {
+ t.Errorf("EXIFCamera = %q, want \"Canon EOS 5D\"", meta.EXIFCamera)
+ }
+}
+
+func TestFFProber_ProbeVideo(t *testing.T) {
+ tmpDir := t.TempDir()
+ vidPath := filepath.Join(tmpDir, "video.mp4")
+ generateVideo(t, vidPath)
+
+ p := NewFFProber()
+ ctx := context.Background()
+ meta, err := p.Probe(ctx, vidPath)
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+ if meta == nil {
+ t.Fatal("expected non-nil metadata")
+ }
+ if meta.Codec == "" {
+ t.Error("expected non-empty Codec for video")
+ }
+ if meta.Resolution == "" {
+ t.Error("expected non-empty Resolution for video")
+ }
+ if meta.Duration <= 0 {
+ t.Errorf("expected positive Duration, got %v", meta.Duration)
+ }
+}
+
+func TestFFProber_ProbeNonExistent(t *testing.T) {
+ p := NewFFProber()
+ ctx := context.Background()
+ _, err := p.Probe(ctx, "/nonexistent/file.mp4")
+ if err == nil {
+ t.Fatal("expected error for nonexistent file")
+ }
+}
+
+func TestFFProber_ProbeEmptyFile(t *testing.T) {
+ if _, err := exec.LookPath("ffprobe"); err != nil {
+ t.Skip("ffprobe not available")
+ }
+ tmpDir := t.TempDir()
+ emptyPath := filepath.Join(tmpDir, "empty.mp4")
+ if err := os.WriteFile(emptyPath, []byte{}, 0o644); err != nil {
+ t.Fatal(err)
+ }
+
+ p := NewFFProber()
+ ctx := context.Background()
+ _, err := p.Probe(ctx, emptyPath)
+ if err == nil {
+ t.Fatal("expected error for empty file")
+ }
+}
+
+func TestParseFFprobeOutput_BitrateParsing(t *testing.T) {
+ input := `{"format":{"duration":"60","bit_rate":"0"},"streams":[{"codec_name":"h264","width":640,"height":480,"codec_type":"video"}]}`
+ meta, err := parseFFprobeOutput([]byte(input))
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+ if meta.Bitrate != 0 {
+ t.Errorf("Bitrate = %d, want 0", meta.Bitrate)
+ }
+} \ No newline at end of file