package navidrome import ( "context" "fmt" "net/http" "net/http/httptest" "testing" "naviwatcher/internal/config" "naviwatcher/internal/database" ) // newTestServerAndClient creates a mock Subsonic server and a NavidromeClient // pointing at it. The handler receives the raw HTTP requests so tests can // inspect them if needed. func newTestServerAndClient(handler http.HandlerFunc) (*httptest.Server, *NavidromeClient) { server := httptest.NewServer(handler) cfg := config.NavidromeConfig{ URL: server.URL, User: "testuser", Password: "testpass", } nc, err := NewClient(cfg) if err != nil { server.Close() panic(fmt.Sprintf("NewClient() in test setup failed: %v", err)) } return server, nc } // subsonicOKResponse returns a minimal valid Subsonic XML response. func subsonicOKResponse(body string) string { return ` ` + body + `` } func TestSyncArtists_EmptyLibrary(t *testing.T) { server, nc := newTestServerAndClient(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/xml") if r.URL.Path == "/rest/getArtists" { w.Write([]byte(subsonicOKResponse(``))) return } w.Write([]byte(subsonicOKResponse(""))) }) defer server.Close() db, err := database.New(":memory:") if err != nil { t.Fatalf("database.New() error: %v", err) } defer db.Close() ctx := context.Background() if err := SyncArtists(ctx, nc, db); err != nil { t.Fatalf("SyncArtists() error: %v", err) } // Verify no artists in DB. artists, err := database.GetAllArtistSettings(db) if err != nil { t.Fatalf("GetAllArtistSettings() error: %v", err) } if len(artists) != 0 { t.Errorf("expected 0 artists in DB, got %d", len(artists)) } } func TestSyncArtists_MultipleArtists(t *testing.T) { server, nc := newTestServerAndClient(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/xml") if r.URL.Path == "/rest/getArtists" { w.Write([]byte(subsonicOKResponse(` `))) return } w.Write([]byte(subsonicOKResponse(""))) }) defer server.Close() db, err := database.New(":memory:") if err != nil { t.Fatalf("database.New() error: %v", err) } defer db.Close() ctx := context.Background() if err := SyncArtists(ctx, nc, db); err != nil { t.Fatalf("SyncArtists() error: %v", err) } artists, err := database.GetAllArtistSettings(db) if err != nil { t.Fatalf("GetAllArtistSettings() error: %v", err) } if len(artists) != 3 { t.Fatalf("expected 3 artists in DB, got %d", len(artists)) } // Build a map for order-independent comparison. byID := make(map[string]database.ArtistSettings) for _, a := range artists { byID[a.ID] = a } expected := map[string]database.ArtistSettings{ "1": {ID: "1", Name: "Artist One", Monitored: true}, "2": {ID: "2", Name: "Artist Two", Monitored: true}, "3": {ID: "3", Name: "Band Three", Monitored: true}, } for id, exp := range expected { got, ok := byID[id] if !ok { t.Errorf("expected artist %s in DB", id) continue } if got.Name != exp.Name { t.Errorf("artist %s: expected Name %q, got %q", id, exp.Name, got.Name) } if !got.Monitored { t.Errorf("artist %s: expected Monitored=true, got false", id) } } } func TestSyncArtists_Idempotency(t *testing.T) { server, nc := newTestServerAndClient(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/xml") if r.URL.Path == "/rest/getArtists" { w.Write([]byte(subsonicOKResponse(` `))) return } w.Write([]byte(subsonicOKResponse(""))) }) defer server.Close() db, err := database.New(":memory:") if err != nil { t.Fatalf("database.New() error: %v", err) } defer db.Close() ctx := context.Background() // First sync. if err := SyncArtists(ctx, nc, db); err != nil { t.Fatalf("first SyncArtists() error: %v", err) } artists1, err := database.GetAllArtistSettings(db) if err != nil { t.Fatalf("GetAllArtistSettings() after first sync error: %v", err) } if len(artists1) != 1 { t.Fatalf("expected 1 artist after first sync, got %d", len(artists1)) } // Manually change monitored to false to verify it is preserved across syncs. if err := database.UpdateArtistSettings(db, "1", map[string]interface{}{"monitored": false}); err != nil { t.Fatalf("UpdateArtistSettings() error: %v", err) } // Second sync — should not duplicate, and should preserve monitored=false. if err := SyncArtists(ctx, nc, db); err != nil { t.Fatalf("second SyncArtists() error: %v", err) } artists2, err := database.GetAllArtistSettings(db) if err != nil { t.Fatalf("GetAllArtistSettings() after second sync error: %v", err) } if len(artists2) != 1 { t.Fatalf("expected 1 artist after second sync (no duplicates), got %d", len(artists2)) } // Verify the artist was updated (monitored setting was preserved (not reset to true)). got, err := database.GetArtistSettings(db, "1") if err != nil { t.Fatalf("GetArtistSettings() error: %v", err) } if got.Monitored { t.Error("expected Monitored=false to be preserved after re-sync, got true") } if got.Name != "Artist One" { t.Errorf("expected Name 'Artist One', got %q", got.Name) } } func TestSyncAlbums_SingleArtist(t *testing.T) { server, nc := newTestServerAndClient(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/xml") switch r.URL.Path { case "/rest/getArtists": // No artists returned — we pre-seed the DB below. w.Write([]byte(subsonicOKResponse(``))) case "/rest/getArtist": w.Write([]byte(subsonicOKResponse(` `))) default: w.Write([]byte(subsonicOKResponse(""))) } }) defer server.Close() db, err := database.New(":memory:") if err != nil { t.Fatalf("database.New() error: %v", err) } defer db.Close() // Pre-seed a monitored artist. if err := database.SaveArtistSettings(db, &database.ArtistSettings{ ID: "1", Name: "Artist One", Monitored: true, }); err != nil { t.Fatalf("SaveArtistSettings() error: %v", err) } ctx := context.Background() if err := SyncAlbums(ctx, nc, db); err != nil { t.Fatalf("SyncAlbums() error: %v", err) } albums, err := database.GetLocalAlbumsByArtist(db, "1") if err != nil { t.Fatalf("GetLocalAlbumsByArtist() error: %v", err) } if len(albums) != 2 { t.Fatalf("expected 2 albums, got %d", len(albums)) } byID := make(map[string]database.LocalAlbum) for _, a := range albums { byID[a.ID] = a } exp1 := database.LocalAlbum{ID: "101", ArtistID: "1", Title: "First Album"} if got, ok := byID["101"]; !ok { t.Error("expected album 101 in DB") } else if got != exp1 { t.Errorf("album 101 = %+v, want %+v", got, exp1) } exp2 := database.LocalAlbum{ID: "102", ArtistID: "1", Title: "Second Album"} if got, ok := byID["102"]; !ok { t.Error("expected album 102 in DB") } else if got != exp2 { t.Errorf("album 102 = %+v, want %+v", got, exp2) } } func TestSyncAlbums_SkipsUnmonitored(t *testing.T) { server, nc := newTestServerAndClient(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/xml") switch r.URL.Path { case "/rest/getArtists": w.Write([]byte(subsonicOKResponse(``))) case "/rest/getArtist": // This should NOT be called for unmonitored artist. t.Error("GetArtist should not be called for unmonitored artist") w.Write([]byte(subsonicOKResponse(``))) default: w.Write([]byte(subsonicOKResponse(""))) } }) defer server.Close() db, err := database.New(":memory:") if err != nil { t.Fatalf("database.New() error: %v", err) } defer db.Close() // Pre-seed an unmonitored artist. if err := database.SaveArtistSettings(db, &database.ArtistSettings{ ID: "2", Name: "Unmonitored", Monitored: false, }); err != nil { t.Fatalf("SaveArtistSettings() error: %v", err) } ctx := context.Background() if err := SyncAlbums(ctx, nc, db); err != nil { t.Fatalf("SyncAlbums() error: %v", err) } // Verify no albums were stored. allAlbums, err := database.GetAllLocalAlbums(db) if err != nil { t.Fatalf("GetAllLocalAlbums() error: %v", err) } if len(allAlbums) != 0 { t.Errorf("expected 0 albums for unmonitored artist, got %d", len(allAlbums)) } } func TestSyncAlbums_APIErrorMidSync(t *testing.T) { callCount := 0 server, nc := newTestServerAndClient(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/xml") switch r.URL.Path { case "/rest/getArtists": w.Write([]byte(subsonicOKResponse(``))) case "/rest/getArtist": callCount++ if callCount == 2 { // Fail on the second artist. w.Write([]byte(` `)) return } w.Write([]byte(subsonicOKResponse(` `))) default: w.Write([]byte(subsonicOKResponse(""))) } }) defer server.Close() db, err := database.New(":memory:") if err != nil { t.Fatalf("database.New() error: %v", err) } defer db.Close() // Pre-seed two monitored artists. for _, id := range []string{"1", "2"} { if err := database.SaveArtistSettings(db, &database.ArtistSettings{ ID: id, Name: "Artist " + id, Monitored: true, }); err != nil { t.Fatalf("SaveArtistSettings() error: %v", err) } } ctx := context.Background() // A transient failure for one artist must not abort the whole pull: the // failing artist is skipped (and logged) while the others succeed. err = SyncAlbums(ctx, nc, db) if err != nil { t.Fatalf("SyncAlbums() expected no fatal error on per-artist API failure, got %v", err) } // The first artist's albums should have been stored despite the second one // failing. albums1, err := database.GetLocalAlbumsByArtist(db, "1") if err != nil { t.Fatalf("GetLocalAlbumsByArtist(1) error: %v", err) } if len(albums1) != 1 { t.Errorf("expected 1 album for artist 1 (synced before skip), got %d", len(albums1)) } // The failing artist should have no local albums stored. albums2, err := database.GetLocalAlbumsByArtist(db, "2") if err != nil { t.Fatalf("GetLocalAlbumsByArtist(2) error: %v", err) } if len(albums2) != 0 { t.Errorf("expected 0 albums for artist 2 (skipped on error), got %d", len(albums2)) } } func TestSyncArtists_ContextCancellation(t *testing.T) { server, nc := newTestServerAndClient(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/xml") if r.URL.Path == "/rest/getArtists" { w.Write([]byte(subsonicOKResponse(` `))) return } w.Write([]byte(subsonicOKResponse(""))) }) defer server.Close() db, err := database.New(":memory:") if err != nil { t.Fatalf("database.New() error: %v", err) } defer db.Close() // Create a context that is already cancelled. ctx, cancel := context.WithCancel(context.Background()) cancel() err = SyncArtists(ctx, nc, db) if err == nil { t.Fatal("SyncArtists() expected error for cancelled context, got nil") } } func TestSyncAlbums_ContextCancellation(t *testing.T) { server, nc := newTestServerAndClient(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/xml") switch r.URL.Path { case "/rest/getArtists": w.Write([]byte(subsonicOKResponse(``))) default: w.Write([]byte(subsonicOKResponse(""))) } }) defer server.Close() db, err := database.New(":memory:") if err != nil { t.Fatalf("database.New() error: %v", err) } defer db.Close() // Pre-seed a monitored artist so SyncAlbums has work to do. if err := database.SaveArtistSettings(db, &database.ArtistSettings{ ID: "1", Name: "Artist One", Monitored: true, }); err != nil { t.Fatalf("SaveArtistSettings() error: %v", err) } // Create a context that is already cancelled. ctx, cancel := context.WithCancel(context.Background()) cancel() err = SyncAlbums(ctx, nc, db) if err == nil { t.Fatal("SyncAlbums() expected error for cancelled context, got nil") } }