From df8f3713abee8b5ec53b5751032200b53673e285 Mon Sep 17 00:00:00 2001 From: Paul Buetow Date: Sun, 3 May 2026 19:50:57 +0300 Subject: 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. --- cmd/mediaplayer/main.go | 23 ++++++-- cmd/mediaplayer/main_test.go | 137 +++++++++++++++++++++++++++++++++++++------ 2 files changed, 138 insertions(+), 22 deletions(-) (limited to 'cmd') 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") + } +} -- cgit v1.2.3