Files
wehub-resource-sync ead81af521
Deploy to GitHub Pages / deploy (push) Failing after 0s
CI / go (push) Has been cancelled
CI / build (darwin, macos-14) (push) Has been cancelled
CI / build (windows, windows-2025) (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 12:31:13 +08:00

605 lines
16 KiB
Go

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}
}