diff options
| author | Paul Buetow <paul@buetow.org> | 2026-05-03 19:50:57 +0300 |
|---|---|---|
| committer | Paul Buetow <paul@buetow.org> | 2026-05-03 19:50:57 +0300 |
| commit | df8f3713abee8b5ec53b5751032200b53673e285 (patch) | |
| tree | 58b3909e345b02edfb5f5613081f1bd4fde66571 | |
| parent | 30c2b0fe8232cc748ab2bded6ab4d76febe32425 (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-- | .gitignore | 4 | ||||
| -rw-r--r-- | AGENTS.md | 2 | ||||
| -rw-r--r-- | cmd/mediaplayer/main.go | 23 | ||||
| -rw-r--r-- | cmd/mediaplayer/main_test.go | 137 | ||||
| -rw-r--r-- | internal/config.go | 5 | ||||
| -rw-r--r-- | internal/config_test.go | 4 | ||||
| -rw-r--r-- | internal/model/scan_test.go | 137 | ||||
| -rw-r--r-- | internal/probe/image_test.go | 231 |
8 files changed, 514 insertions, 29 deletions
@@ -1,6 +1,6 @@ # Binaries -mediaplayer -player +/mediaplayer +/player *.exe *.dll *.so @@ -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 |
