package main import ( "bytes" "context" "log" "os" "path/filepath" "strings" "testing" "time" "naviwatcher/internal/config" "naviwatcher/internal/database" ) func TestAppRun_ScanLogsMissingReleases(t *testing.T) { // Verify the compute-only run() hook scans monitored artists and returns // nil without starting notifier/web. Uses an in-memory DB with one // monitored artist that has one missing release (Animals) vs a local album // (The Wall). The context is left live so the scan actually executes; we // cancel shortly after to let run() return cleanly. db, err := database.New(":memory:") if err != nil { t.Fatalf("database.New() error: %v", err) } defer db.Close() if err := database.SaveArtistSettings(db, &database.ArtistSettings{ ID: "artist-1", Name: "Pink Floyd", Monitored: true, }); err != nil { t.Fatalf("seed artist: %v", err) } if err := database.SaveLocalAlbum(db, &database.LocalAlbum{ ID: "l1", ArtistID: "artist-1", Title: "The Wall", }); err != nil { t.Fatalf("seed local album: %v", err) } if err := database.SaveExternalRelease(db, &database.ExternalRelease{ RGID: "rg2", ArtistID: "artist-1", Title: "Animals", }); err != nil { t.Fatalf("seed external release: %v", err) } app := &App{ cfg: &config.Config{Scanner: config.ScannerConfig{FuzzyThreshold: 0.85}}, db: db, } ctx, cancel := context.WithCancel(context.Background()) defer cancel() // Capture run()'s log output so we assert that run() ITSELF performed // the scan (not a separately re-run ScanAll). This guards against the // hook silently becoming a no-op while still passing. var buf bytes.Buffer log.SetOutput(&buf) defer log.SetOutput(os.Stderr) // Run the (blocking) hook in a goroutine; cancel after it has had time to // perform the scan so run() returns nil via the ctx.Done() path. done := make(chan error, 1) go func() { done <- app.run(ctx) }() time.Sleep(50 * time.Millisecond) cancel() if err := <-done; err != nil { t.Fatalf("app.run() returned error: %v", err) } // run() must have logged the missing release (Animals) for artist-1. out := buf.String() if !strings.Contains(out, "missing: artist=artist-1") || !strings.Contains(out, "Animals") { t.Fatalf("app.run() did not log the expected missing release; log output:\n%s", out) } } func TestConfigIntegration(t *testing.T) { // Integration test: write a minimal valid config and load it via config.LoadConfig, // verifying the full path that main() uses. dir := t.TempDir() path := filepath.Join(dir, "config.yaml") yaml := `server: host: "127.0.0.1" port: 9090 navidrome: url: "http://localhost:4533" user: "test" password: "test" musicbrainz: user_agent: "NaviWatcher/1.0 ( test@example.com )" ` if err := os.WriteFile(path, []byte(yaml), 0644); err != nil { t.Fatalf("failed to write config: %v", err) } cfg, err := config.LoadConfig(path) if err != nil { t.Fatalf("LoadConfig returned error: %v", err) } if cfg.Server.Host != "127.0.0.1" { t.Errorf("expected host 127.0.0.1, got %q", cfg.Server.Host) } if cfg.Server.Port != 9090 { t.Errorf("expected port 9090, got %d", cfg.Server.Port) } if cfg.Navidrome.URL != "http://localhost:4533" { t.Errorf("expected navidrome url http://localhost:4533, got %q", cfg.Navidrome.URL) } } func TestNewApp_CreatesMusicBrainzClient(t *testing.T) { // Verify that NewApp initializes the MusicBrainz client from config. cfg := &config.Config{ Server: config.ServerConfig{ Host: "127.0.0.1", Port: 9090, }, Navidrome: config.NavidromeConfig{ URL: "http://localhost:4533", User: "test", Password: "test", }, MusicBrainz: config.MusicBrainzConfig{ UserAgent: "NaviWatcher/1.0 ( test@example.com )", }, } ctx := context.Background() app, err := NewApp(ctx, cfg, ":memory:") if err != nil { t.Fatalf("NewApp returned error: %v", err) } defer app.Close() if app.mbClient == nil { t.Fatal("expected MusicBrainz client to be initialized, got nil") } if app.db == nil { t.Fatal("expected database to be initialized, got nil") } if app.cfg != cfg { t.Fatal("expected app.cfg to be the config passed to NewApp") } } func TestNewApp_GracefulShutdown(t *testing.T) { // Verify that App.Close() cleans up resources without error. cfg := &config.Config{ Server: config.ServerConfig{ Host: "127.0.0.1", Port: 9090, }, Navidrome: config.NavidromeConfig{ URL: "http://localhost:4533", User: "test", Password: "test", }, MusicBrainz: config.MusicBrainzConfig{ UserAgent: "NaviWatcher/1.0 ( test@example.com )", }, } ctx := context.Background() app, err := NewApp(ctx, cfg, ":memory:") if err != nil { t.Fatalf("NewApp returned error: %v", err) } // Close should not panic or return error. app.Close() } func TestAppRun_GracefulShutdown(t *testing.T) { // Verify that app.run() returns nil when context is cancelled. cfg := &config.Config{ Server: config.ServerConfig{ Host: "127.0.0.1", Port: 9090, }, Navidrome: config.NavidromeConfig{ URL: "http://localhost:4533", User: "test", Password: "test", }, MusicBrainz: config.MusicBrainzConfig{ UserAgent: "NaviWatcher/1.0 ( test@example.com )", }, } ctx, cancel := context.WithCancel(context.Background()) app, err := NewApp(ctx, cfg, ":memory:") if err != nil { t.Fatalf("NewApp returned error: %v", err) } defer app.Close() // Cancel the context to trigger shutdown. cancel() if err := app.run(ctx); err != nil { t.Fatalf("app.run returned error: %v", err) } } func TestMusicBrainzUserAgentValidation(t *testing.T) { // Verify that config validation requires MusicBrainz.UserAgent to be set. dir := t.TempDir() path := filepath.Join(dir, "config.yaml") yaml := `server: host: "127.0.0.1" port: 9090 navidrome: url: "http://localhost:4533" user: "test" password: "test" musicbrainz: user_agent: "" ` if err := os.WriteFile(path, []byte(yaml), 0644); err != nil { t.Fatalf("failed to write config: %v", err) } _, err := config.LoadConfig(path) if err == nil { t.Fatal("expected config validation error for empty musicbrainz.user_agent, got nil") } }