summaryrefslogtreecommitdiff
path: root/internal
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 /internal
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.
Diffstat (limited to 'internal')
-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
4 files changed, 373 insertions, 4 deletions
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