package navidrome import ( "net/http" "net/http/httptest" "net/url" "strings" "testing" "github.com/bjarneo/cliamp/config" "github.com/bjarneo/cliamp/playlist" "github.com/bjarneo/cliamp/provider" ) // subsonicHandler serves fake Subsonic API responses for testing. func subsonicHandler(t *testing.T) http.HandlerFunc { t.Helper() return func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") path := r.URL.Path switch { case strings.HasSuffix(path, "/rest/getPlaylists"): w.Write([]byte(`{ "subsonic-response": { "status": "ok", "playlists": { "playlist": [ {"id":"pl-1","name":"Jazz Classics","songCount":12}, {"id":"pl-2","name":"Late Night","songCount":8} ] } } }`)) case strings.HasSuffix(path, "/rest/getPlaylist"): w.Write([]byte(`{ "subsonic-response": { "status": "ok", "playlist": { "entry": [ { "id":"song-1","title":"So What","artist":"Miles Davis", "album":"Kind of Blue","year":1959,"track":1,"genre":"Jazz","duration":565 }, { "id":"song-2","title":"Blue in Green","artist":"Miles Davis", "album":"Kind of Blue","year":1959,"track":3,"genre":"Jazz","duration":327 } ] } } }`)) case strings.HasSuffix(path, "/rest/getArtists"): w.Write([]byte(`{ "subsonic-response": { "status": "ok", "artists": { "index": [ { "artist": [ {"id":"ar-1","name":"Miles Davis","albumCount":5}, {"id":"ar-2","name":"Mingus","albumCount":3} ] }, { "artist": [ {"id":"ar-3","name":"Thelonious Monk","albumCount":4} ] } ] } } }`)) case strings.HasSuffix(path, "/rest/getArtist"): w.Write([]byte(`{ "subsonic-response": { "status": "ok", "artist": { "album": [ {"id":"al-1","name":"Kind of Blue","artist":"Miles Davis","artistId":"ar-1","year":1959,"songCount":5,"genre":"Jazz"}, {"id":"al-2","name":"Bitches Brew","artist":"Miles Davis","artistId":"ar-1","year":1970,"songCount":4,"genre":"Jazz"} ] } } }`)) case strings.HasSuffix(path, "/rest/getAlbumList2"): w.Write([]byte(`{ "subsonic-response": { "status": "ok", "albumList2": { "album": [ {"id":"al-1","name":"Kind of Blue","artist":"Miles Davis","artistId":"ar-1","year":1959,"songCount":5,"genre":"Jazz"} ] } } }`)) case strings.HasSuffix(path, "/rest/getAlbum"): w.Write([]byte(`{ "subsonic-response": { "status": "ok", "album": { "song": [ { "id":"song-1","title":"So What","artist":"Miles Davis", "album":"Kind of Blue","year":1959,"track":1,"genre":"Jazz","duration":565 } ] } } }`)) case strings.HasSuffix(path, "/rest/search3"): w.Write([]byte(`{ "subsonic-response": { "status": "ok", "searchResult3": { "song": [ { "id":"song-1","title":"So What","artist":"Miles Davis", "album":"Kind of Blue","year":1959,"track":1,"genre":"Jazz","duration":565 }, { "id":"song-7","title":"What'd I Say","artist":"Ray Charles", "album":"What'd I Say","year":1959,"track":1,"genre":"Soul","duration":380 } ] } } }`)) case strings.HasSuffix(path, "/rest/scrobble"): w.Write([]byte(`{"subsonic-response":{"status":"ok"}}`)) default: t.Errorf("unexpected path: %s", r.URL.Path) w.WriteHeader(http.StatusNotFound) } } } func newTestClient(t *testing.T) (*NavidromeClient, *httptest.Server) { t.Helper() srv := httptest.NewServer(subsonicHandler(t)) c := New(srv.URL, "testuser", "testpass") return c, srv } func TestName(t *testing.T) { c := New("http://localhost", "u", "p") if c.Name() != "Navidrome" { t.Errorf("Name() = %q, want %q", c.Name(), "Navidrome") } } func TestPlaylists(t *testing.T) { c, srv := newTestClient(t) defer srv.Close() lists, err := c.Playlists() if err != nil { t.Fatalf("Playlists() error: %v", err) } if len(lists) != 2 { t.Fatalf("expected 2 playlists, got %d", len(lists)) } if lists[0].ID != "pl-1" || lists[0].Name != "Jazz Classics" || lists[0].TrackCount != 12 { t.Errorf("lists[0] = %+v", lists[0]) } if lists[1].ID != "pl-2" || lists[1].Name != "Late Night" || lists[1].TrackCount != 8 { t.Errorf("lists[1] = %+v", lists[1]) } } func TestPlaylists_Cached(t *testing.T) { callCount := 0 srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if strings.HasSuffix(r.URL.Path, "/rest/getPlaylists") { callCount++ } w.Header().Set("Content-Type", "application/json") w.Write([]byte(`{ "subsonic-response": { "status": "ok", "playlists": {"playlist": [{"id":"1","name":"P","songCount":1}]} } }`)) })) defer srv.Close() c := New(srv.URL, "u", "p") if _, err := c.Playlists(); err != nil { t.Fatalf("first Playlists() error: %v", err) } if _, err := c.Playlists(); err != nil { t.Fatalf("second Playlists() error: %v", err) } if callCount != 1 { t.Errorf("expected getPlaylists called once, called %d times", callCount) } } func TestTracks(t *testing.T) { c, srv := newTestClient(t) defer srv.Close() tracks, err := c.Tracks("pl-1") if err != nil { t.Fatalf("Tracks() error: %v", err) } if len(tracks) != 2 { t.Fatalf("expected 2 tracks, got %d", len(tracks)) } tr := tracks[0] if tr.Title != "So What" { t.Errorf("Title = %q, want %q", tr.Title, "So What") } if tr.Artist != "Miles Davis" { t.Errorf("Artist = %q, want %q", tr.Artist, "Miles Davis") } if tr.Album != "Kind of Blue" { t.Errorf("Album = %q, want %q", tr.Album, "Kind of Blue") } if tr.Year != 1959 { t.Errorf("Year = %d, want 1959", tr.Year) } if tr.TrackNumber != 1 { t.Errorf("TrackNumber = %d, want 1", tr.TrackNumber) } if tr.Genre != "Jazz" { t.Errorf("Genre = %q, want %q", tr.Genre, "Jazz") } if tr.DurationSecs != 565 { t.Errorf("DurationSecs = %d, want 565", tr.DurationSecs) } if !tr.Stream { t.Error("Stream = false, want true") } if got := tr.Meta(provider.MetaNavidromeID); got != "song-1" { t.Errorf("Meta(NavidromeID) = %q, want %q", got, "song-1") } if !strings.Contains(tr.Path, "/rest/stream") { t.Errorf("Path %q missing /rest/stream", tr.Path) } } func TestTracks_Cached(t *testing.T) { callCount := 0 srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if strings.HasSuffix(r.URL.Path, "/rest/getPlaylist") { callCount++ } w.Header().Set("Content-Type", "application/json") w.Write([]byte(`{ "subsonic-response": { "status": "ok", "playlist": {"entry": [{"id":"1","title":"T","artist":"A","album":"Al","duration":60}]} } }`)) })) defer srv.Close() c := New(srv.URL, "u", "p") if _, err := c.Tracks("pl-1"); err != nil { t.Fatalf("first Tracks() error: %v", err) } if _, err := c.Tracks("pl-1"); err != nil { t.Fatalf("second Tracks() error: %v", err) } if callCount != 1 { t.Errorf("expected getPlaylist called once, called %d times", callCount) } } func TestArtists(t *testing.T) { c, srv := newTestClient(t) defer srv.Close() artists, err := c.Artists() if err != nil { t.Fatalf("Artists() error: %v", err) } if len(artists) != 3 { t.Fatalf("expected 3 artists (across 2 indexes), got %d", len(artists)) } if artists[0].ID != "ar-1" || artists[0].Name != "Miles Davis" || artists[0].AlbumCount != 5 { t.Errorf("artists[0] = %+v", artists[0]) } if artists[2].ID != "ar-3" || artists[2].Name != "Thelonious Monk" { t.Errorf("artists[2] = %+v", artists[2]) } } func TestArtistAlbums(t *testing.T) { c, srv := newTestClient(t) defer srv.Close() albums, err := c.ArtistAlbums("ar-1") if err != nil { t.Fatalf("ArtistAlbums() error: %v", err) } if len(albums) != 2 { t.Fatalf("expected 2 albums, got %d", len(albums)) } if albums[0].ID != "al-1" || albums[0].Name != "Kind of Blue" || albums[0].Year != 1959 { t.Errorf("albums[0] = %+v", albums[0]) } if albums[1].ID != "al-2" || albums[1].Name != "Bitches Brew" || albums[1].Year != 1970 { t.Errorf("albums[1] = %+v", albums[1]) } } func TestAlbumList(t *testing.T) { c, srv := newTestClient(t) defer srv.Close() albums, err := c.AlbumList(SortAlphabeticalByName, 0, 50) if err != nil { t.Fatalf("AlbumList() error: %v", err) } if len(albums) != 1 { t.Fatalf("expected 1 album, got %d", len(albums)) } if albums[0].ID != "al-1" || albums[0].Name != "Kind of Blue" { t.Errorf("albums[0] = %+v", albums[0]) } } func TestAlbumTracks(t *testing.T) { c, srv := newTestClient(t) defer srv.Close() tracks, err := c.AlbumTracks("al-1") if err != nil { t.Fatalf("AlbumTracks() error: %v", err) } if len(tracks) != 1 { t.Fatalf("expected 1 track, got %d", len(tracks)) } if tracks[0].Title != "So What" || tracks[0].DurationSecs != 565 { t.Errorf("tracks[0] = %+v", tracks[0]) } } func TestSearchTracks(t *testing.T) { c, srv := newTestClient(t) defer srv.Close() tracks, err := c.SearchTracks(t.Context(), "what", 20) if err != nil { t.Fatalf("SearchTracks() error: %v", err) } if len(tracks) != 2 { t.Fatalf("expected 2 tracks, got %d", len(tracks)) } if tracks[0].Title != "So What" { t.Errorf("tracks[0].Title = %q, want %q", tracks[0].Title, "So What") } if !tracks[0].Stream { t.Error("tracks[0].Stream should be true") } if tracks[0].Meta(provider.MetaNavidromeID) != "song-1" { t.Errorf("tracks[0] meta navidrome id = %q, want song-1", tracks[0].Meta(provider.MetaNavidromeID)) } } func TestSubsonicError(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") w.Write([]byte(`{ "subsonic-response": { "status": "failed", "error": {"code": 40, "message": "Wrong username or password"} } }`)) })) defer srv.Close() c := New(srv.URL, "bad", "creds") _, err := c.Playlists() if err == nil { t.Fatal("expected error for bad credentials, got nil") } if !strings.Contains(err.Error(), "Wrong username or password") { t.Errorf("error = %q, expected credential error", err.Error()) } } func TestHTTPError(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusInternalServerError) })) defer srv.Close() c := New(srv.URL, "u", "p") _, err := c.Playlists() if err == nil { t.Fatal("expected error for HTTP 500, got nil") } } func TestBuildURL_AuthParams(t *testing.T) { c := New("https://music.example.com", "alice", "secret123") u := c.buildURL("ping", nil) parsed, err := url.Parse(u) if err != nil { t.Fatalf("buildURL() returned invalid URL: %v", err) } q := parsed.Query() if q.Get("u") != "alice" { t.Errorf("user = %q, want alice", q.Get("u")) } if q.Get("t") == "" { t.Error("token (t) is empty") } if q.Get("s") == "" { t.Error("salt (s) is empty") } if q.Get("v") != "1.0.0" { t.Errorf("version = %q, want 1.0.0", q.Get("v")) } if q.Get("c") != "cliamp" { t.Errorf("client = %q, want cliamp", q.Get("c")) } if q.Get("f") != "json" { t.Errorf("format = %q, want json", q.Get("f")) } if !strings.HasPrefix(u, "https://music.example.com/rest/ping?") { t.Errorf("URL = %q, missing expected prefix", u) } } func TestIsSubsonicStreamURL(t *testing.T) { tests := []struct { url string want bool }{ {"https://music.example.com/rest/stream?id=1", true}, {"https://music.example.com/rest/stream.view?id=1", true}, {"https://music.example.com/rest/download?id=1", true}, {"https://music.example.com/rest/download.view?id=1", true}, {"https://music.example.com/rest/getPlaylists", false}, {"https://example.com/song.mp3", false}, {"", false}, } for _, tt := range tests { if got := IsSubsonicStreamURL(tt.url); got != tt.want { t.Errorf("IsSubsonicStreamURL(%q) = %v, want %v", tt.url, got, tt.want) } } } func TestNewFromEnv(t *testing.T) { t.Setenv("NAVIDROME_URL", "") t.Setenv("NAVIDROME_USER", "") t.Setenv("NAVIDROME_PASS", "") if c := NewFromEnv(); c != nil { t.Error("NewFromEnv() should return nil when env vars are empty") } t.Setenv("NAVIDROME_URL", "https://music.test") t.Setenv("NAVIDROME_USER", "alice") t.Setenv("NAVIDROME_PASS", "secret") c := NewFromEnv() if c == nil { t.Fatal("NewFromEnv() returned nil with all env vars set") } if c.url != "https://music.test" || c.user != "alice" || c.password != "secret" { t.Errorf("NewFromEnv() = %+v", c) } } func TestNewFromConfig(t *testing.T) { tests := []struct { name string cfg config.NavidromeConfig wantNil bool }{ {"empty", config.NavidromeConfig{}, true}, {"no password", config.NavidromeConfig{URL: "http://localhost", User: "u"}, true}, {"no url", config.NavidromeConfig{User: "u", Password: "p"}, true}, {"valid", config.NavidromeConfig{URL: "http://localhost", User: "u", Password: "p"}, false}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { c := NewFromConfig(tt.cfg) if (c == nil) != tt.wantNil { t.Errorf("NewFromConfig(%+v) nil=%v, want nil=%v", tt.cfg, c == nil, tt.wantNil) } }) } } func TestNewFromConfig_BrowseSort(t *testing.T) { cfg := config.NavidromeConfig{ URL: "http://localhost", User: "u", Password: "p", BrowseSort: SortNewest, } c := NewFromConfig(cfg) if c.browseSort != SortNewest { t.Errorf("browseSort = %q, want %q", c.browseSort, SortNewest) } } func TestNewFromConfig_ScrobbleDisabled(t *testing.T) { cfg := config.NavidromeConfig{ URL: "http://localhost", User: "u", Password: "p", ScrobbleDisabled: true, } c := NewFromConfig(cfg) if !c.scrobbleDisabled { t.Error("scrobbleDisabled = false, want true") } } func TestAlbumSortTypes(t *testing.T) { c := New("http://localhost", "u", "p") sorts := c.AlbumSortTypes() if len(sorts) != 8 { t.Fatalf("expected 8 sort types, got %d", len(sorts)) } } func TestDefaultAlbumSort(t *testing.T) { c := New("http://localhost", "u", "p") if c.DefaultAlbumSort() != SortAlphabeticalByName { t.Errorf("DefaultAlbumSort() = %q, want %q", c.DefaultAlbumSort(), SortAlphabeticalByName) } c.browseSort = SortNewest if c.DefaultAlbumSort() != SortNewest { t.Errorf("DefaultAlbumSort() = %q, want %q", c.DefaultAlbumSort(), SortNewest) } } func TestCanReportPlayback(t *testing.T) { c := New("http://localhost", "u", "p") track := trackWithNavidromeMeta("song-1") if !c.CanReportPlayback(track) { t.Error("CanReportPlayback() = false for track with navidrome ID") } c.scrobbleDisabled = true if c.CanReportPlayback(track) { t.Error("CanReportPlayback() = true when scrobbling is disabled") } } func TestCanReportPlayback_NoMeta(t *testing.T) { c := New("http://localhost", "u", "p") track := trackWithNavidromeMeta("") if c.CanReportPlayback(track) { t.Error("CanReportPlayback() = true for track without navidrome ID") } } func TestScrobble(t *testing.T) { var gotSubmission string srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if strings.HasSuffix(r.URL.Path, "/rest/scrobble") { gotSubmission = r.URL.Query().Get("submission") } w.Write([]byte(`{"subsonic-response":{"status":"ok"}}`)) })) defer srv.Close() c := New(srv.URL, "u", "p") c.ReportNowPlaying(trackWithNavidromeMeta("song-1"), 0, false) if gotSubmission != "false" { t.Errorf("ReportNowPlaying submission = %q, want false", gotSubmission) } c.ReportScrobble(trackWithNavidromeMeta("song-1"), 0, 0, false) if gotSubmission != "true" { t.Errorf("ReportScrobble submission = %q, want true", gotSubmission) } } func TestCheckSubsonicError(t *testing.T) { if err := checkSubsonicError("ok", nil); err != nil { t.Errorf("expected nil for status ok, got %v", err) } if err := checkSubsonicError("", nil); err != nil { t.Errorf("expected nil for empty status, got %v", err) } err := checkSubsonicError("failed", &subsonicError{Code: 40, Message: "Wrong password"}) if err == nil { t.Fatal("expected error for failed status") } if !strings.Contains(err.Error(), "Wrong password") { t.Errorf("error = %q, missing message", err.Error()) } err = checkSubsonicError("failed", nil) if err == nil { t.Fatal("expected error for failed status with nil error body") } } func trackWithNavidromeMeta(id string) playlist.Track { meta := map[string]string{} if id != "" { meta[provider.MetaNavidromeID] = id } return playlist.Track{ProviderMeta: meta} }