package main import ( "bytes" "context" "log" "os" "path/filepath" "strings" "sync" "sync/atomic" "testing" "time" "naviwatcher/internal/config" "naviwatcher/internal/database" "naviwatcher/internal/navidrome" "naviwatcher/internal/scanner" ) func TestAppRun_ScanLogsMissingReleases(t *testing.T) { // Verify run() performs the sync+scan pipeline once (via the injected // syncFn) and then blocks until ctx cancellation, returning nil. The // injected syncFn performs the scan and logs the missing release, mirroring // what syncAndScan does against live clients. 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) } var buf bytes.Buffer log.SetOutput(&buf) defer log.SetOutput(os.Stderr) app := &App{ cfg: &config.Config{ Scanner: config.ScannerConfig{FuzzyThreshold: 0.85}, Sync: config.SyncConfig{Interval: time.Hour}, }, db: db, syncFn: func(ctx context.Context) error { missing, err := scanner.ScanAll(ctx, db, 0.85) if err != nil { return err } for _, m := range missing { log.Printf(" missing: artist=%s rgid=%s title=%q", m.ArtistID, m.RGID, m.Title) } return nil }, } ctx, cancel := context.WithCancel(context.Background()) defer cancel() // 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 TestStartPeriodicSync_FiresOnTick(t *testing.T) { var calls int64 var wg sync.WaitGroup wg.Add(2) app := &App{ cfg: &config.Config{Sync: config.SyncConfig{Interval: 20 * time.Millisecond}}, syncFn: func(ctx context.Context) error { atomic.AddInt64(&calls, 1) wg.Done() return nil }, } ctx, cancel := context.WithCancel(context.Background()) defer cancel() go app.startPeriodicSync(ctx) if !waitWG(&wg, 2*time.Second) { t.Fatal("expected syncFn to be called at least twice within timeout") } if got := atomic.LoadInt64(&calls); got < 2 { t.Errorf("expected at least 2 sync calls, got %d", got) } cancel() } func TestStartPeriodicSync_CancelsCleanly(t *testing.T) { var calls int64 app := &App{ cfg: &config.Config{Sync: config.SyncConfig{Interval: time.Hour}}, syncFn: func(ctx context.Context) error { atomic.AddInt64(&calls, 1) return nil }, } ctx, cancel := context.WithCancel(context.Background()) done := make(chan struct{}) go func() { app.startPeriodicSync(ctx) close(done) }() // With a 1h interval the ticker never fires on its own; cancel should // return promptly. The loop does run one immediate (guarded) sync at // startup, so depending on scheduling calls may be 0 (cancel won the race) // or 1 (immediate sync ran) — but never more, since no tick can fire in 1h. cancel() select { case <-done: // clean exit case <-time.After(2 * time.Second): t.Fatal("startPeriodicSync did not exit after ctx cancellation") } if got := atomic.LoadInt64(&calls); got > 1 { t.Errorf("expected at most 1 (immediate) sync call with 1h interval, got %d", got) } } func TestDoSync_UsesInjectedSyncFn(t *testing.T) { // Verify doSync prefers an injected syncFn when present (so the periodic // loop and immediate run can be driven by tests without live clients), // and falls back to the real syncAndScan otherwise. db, err := database.New(":memory:") if err != nil { t.Fatalf("database.New() error: %v", err) } defer db.Close() var called int32 app := &App{ cfg: &config.Config{Sync: config.SyncConfig{Interval: time.Hour}}, db: db, syncFn: func(ctx context.Context) error { atomic.StoreInt32(&called, 1) return nil }, } if err := app.doSync(context.Background()); err != nil { t.Fatalf("doSync returned error: %v", err) } if atomic.LoadInt32(&called) != 1 { t.Fatal("expected injected syncFn to be called") } } 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 )", }, } // Inject an unauthenticated Navidrome client so the test needs no live server. prevFactory := navidromeClientFactory navidromeClientFactory = func(c config.NavidromeConfig) (*navidrome.NavidromeClient, error) { return navidrome.NewClientUnauthenticated(c), nil } defer func() { navidromeClientFactory = prevFactory }() 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.ndClient == nil { t.Fatal("expected Navidrome 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 )", }, } prevFactory := navidromeClientFactory navidromeClientFactory = func(c config.NavidromeConfig) (*navidrome.NavidromeClient, error) { return navidrome.NewClientUnauthenticated(c), nil } defer func() { navidromeClientFactory = prevFactory }() 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 )", }, Sync: config.SyncConfig{Interval: time.Hour}, } prevFactory := navidromeClientFactory navidromeClientFactory = func(c config.NavidromeConfig) (*navidrome.NavidromeClient, error) { return navidrome.NewClientUnauthenticated(c), nil } defer func() { navidromeClientFactory = prevFactory }() 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") } } // waitWG waits for wg with a timeout; returns true if it completed in time. func waitWG(wg *sync.WaitGroup, timeout time.Duration) bool { done := make(chan struct{}) go func() { wg.Wait() close(done) }() select { case <-done: return true case <-time.After(timeout): return false } }