From ebe3c1d06c2938650018c8d28d42794de199ba21 Mon Sep 17 00:00:00 2001 From: Deluan Date: Fri, 6 Feb 2026 11:38:03 -0500 Subject: [PATCH] test(e2e): add comprehensive tests for Subsonic API endpoints Signed-off-by: Deluan --- server/e2e/e2e_suite_test.go | 304 +++++++++++ server/e2e/subsonic_album_lists_test.go | 276 ++++++++++ server/e2e/subsonic_bookmarks_test.go | 164 ++++++ server/e2e/subsonic_browsing_test.go | 504 +++++++++++++++++++ server/e2e/subsonic_media_annotation_test.go | 186 +++++++ server/e2e/subsonic_media_retrieval_test.go | 79 +++ server/e2e/subsonic_playlists_test.go | 130 +++++ server/e2e/subsonic_radio_test.go | 94 ++++ server/e2e/subsonic_scan_test.go | 60 +++ server/e2e/subsonic_searching_test.go | 158 ++++++ server/e2e/subsonic_system_test.go | 86 ++++ server/e2e/subsonic_users_test.go | 56 +++ 12 files changed, 2097 insertions(+) create mode 100644 server/e2e/e2e_suite_test.go create mode 100644 server/e2e/subsonic_album_lists_test.go create mode 100644 server/e2e/subsonic_bookmarks_test.go create mode 100644 server/e2e/subsonic_browsing_test.go create mode 100644 server/e2e/subsonic_media_annotation_test.go create mode 100644 server/e2e/subsonic_media_retrieval_test.go create mode 100644 server/e2e/subsonic_playlists_test.go create mode 100644 server/e2e/subsonic_radio_test.go create mode 100644 server/e2e/subsonic_scan_test.go create mode 100644 server/e2e/subsonic_searching_test.go create mode 100644 server/e2e/subsonic_system_test.go create mode 100644 server/e2e/subsonic_users_test.go diff --git a/server/e2e/e2e_suite_test.go b/server/e2e/e2e_suite_test.go new file mode 100644 index 000000000..10cb1cbd0 --- /dev/null +++ b/server/e2e/e2e_suite_test.go @@ -0,0 +1,304 @@ +package e2e + +import ( + "context" + "io" + "net/http" + "net/http/httptest" + "net/url" + "path/filepath" + "testing" + "testing/fstest" + "time" + + "github.com/deluan/rest" + "github.com/navidrome/navidrome/conf" + "github.com/navidrome/navidrome/conf/configtest" + "github.com/navidrome/navidrome/core" + "github.com/navidrome/navidrome/core/artwork" + "github.com/navidrome/navidrome/core/auth" + "github.com/navidrome/navidrome/core/external" + "github.com/navidrome/navidrome/core/metrics" + "github.com/navidrome/navidrome/core/playback" + "github.com/navidrome/navidrome/core/scrobbler" + "github.com/navidrome/navidrome/core/storage/storagetest" + "github.com/navidrome/navidrome/db" + "github.com/navidrome/navidrome/log" + "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/model/request" + "github.com/navidrome/navidrome/persistence" + "github.com/navidrome/navidrome/scanner" + "github.com/navidrome/navidrome/server/events" + "github.com/navidrome/navidrome/server/subsonic" + "github.com/navidrome/navidrome/tests" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +func TestSubsonicE2E(t *testing.T) { + tests.Init(t, true) + defer db.Close(context.Background()) + log.SetLevel(log.LevelFatal) + RegisterFailHandler(Fail) + RunSpecs(t, "Subsonic API E2E Suite") +} + +// Easy aliases for the storagetest package +type _t = map[string]any + +var template = storagetest.Template +var track = storagetest.Track + +// Shared test state — populated in BeforeEach +var ( + ctx context.Context + ds *tests.MockDataStore + router *subsonic.Router + lib model.Library + + // Admin user used for most tests + adminUser = model.User{ + ID: "admin-1", + UserName: "admin", + Name: "Admin User", + IsAdmin: true, + } +) + +func createFS(files fstest.MapFS) storagetest.FakeFS { + fs := storagetest.FakeFS{} + fs.SetFiles(files) + storagetest.Register("fake", &fs) + return fs +} + +// buildTestFS creates the full test filesystem matching the plan +func buildTestFS() storagetest.FakeFS { + abbeyRoad := template(_t{"albumartist": "The Beatles", "artist": "The Beatles", "album": "Abbey Road", "year": 1969, "genre": "Rock"}) + help := template(_t{"albumartist": "The Beatles", "artist": "The Beatles", "album": "Help!", "year": 1965, "genre": "Rock"}) + ledZepIV := template(_t{"albumartist": "Led Zeppelin", "artist": "Led Zeppelin", "album": "IV", "year": 1971, "genre": "Rock"}) + kindOfBlue := template(_t{"albumartist": "Miles Davis", "artist": "Miles Davis", "album": "Kind of Blue", "year": 1959, "genre": "Jazz"}) + popTrack := template(_t{"albumartist": "Various", "artist": "Various", "album": "Pop", "year": 2020, "genre": "Pop"}) + + return createFS(fstest.MapFS{ + // Rock / The Beatles / Abbey Road + "Rock/The Beatles/Abbey Road/01 - Come Together.mp3": abbeyRoad(track(1, "Come Together")), + "Rock/The Beatles/Abbey Road/02 - Something.mp3": abbeyRoad(track(2, "Something")), + // Rock / The Beatles / Help! + "Rock/The Beatles/Help!/01 - Help.mp3": help(track(1, "Help!")), + // Rock / Led Zeppelin / IV + "Rock/Led Zeppelin/IV/01 - Stairway To Heaven.mp3": ledZepIV(track(1, "Stairway To Heaven")), + // Jazz / Miles Davis / Kind of Blue + "Jazz/Miles Davis/Kind of Blue/01 - So What.mp3": kindOfBlue(track(1, "So What")), + // Pop (standalone track) + "Pop/01 - Standalone Track.mp3": popTrack(track(1, "Standalone Track")), + // _empty folder (directory with no audio) + "_empty/.keep": &fstest.MapFile{Data: []byte{}, ModTime: time.Now()}, + }) +} + +// newReq creates an authenticated GET request for the given endpoint with optional query parameters. +// Parameters are provided as key-value pairs: newReq("getAlbum", "id", "123") +func newReq(endpoint string, params ...string) *http.Request { + return newReqWithUser(adminUser, endpoint, params...) +} + +// newReqWithUser creates an authenticated GET request for the given user. +func newReqWithUser(user model.User, endpoint string, params ...string) *http.Request { + u := "/rest/" + endpoint + if len(params) > 0 { + q := url.Values{} + for i := 0; i < len(params)-1; i += 2 { + q.Add(params[i], params[i+1]) + } + u += "?" + q.Encode() + } + r := httptest.NewRequest("GET", u, nil) + userCtx := request.WithUser(r.Context(), user) + userCtx = request.WithUsername(userCtx, user.UserName) + userCtx = request.WithClient(userCtx, "test-client") + userCtx = request.WithPlayer(userCtx, model.Player{ID: "player-1", Name: "Test Player", Client: "test-client"}) + return r.WithContext(userCtx) +} + +// newRawReq creates a ResponseRecorder + authenticated request for raw handlers (stream, download, getCoverArt). +func newRawReq(endpoint string, params ...string) (*httptest.ResponseRecorder, *http.Request) { + return httptest.NewRecorder(), newReq(endpoint, params...) +} + +// --- Noop stub implementations for Router dependencies --- + +// noopArtwork implements artwork.Artwork +type noopArtwork struct{} + +func (n noopArtwork) Get(context.Context, model.ArtworkID, int, bool) (io.ReadCloser, time.Time, error) { + return nil, time.Time{}, model.ErrNotFound +} + +func (n noopArtwork) GetOrPlaceholder(_ context.Context, _ string, _ int, _ bool) (io.ReadCloser, time.Time, error) { + return io.NopCloser(io.LimitReader(nil, 0)), time.Time{}, nil +} + +// noopStreamer implements core.MediaStreamer +type noopStreamer struct{} + +func (n noopStreamer) NewStream(context.Context, string, string, int, int) (*core.Stream, error) { + return nil, model.ErrNotFound +} + +func (n noopStreamer) DoStream(context.Context, *model.MediaFile, string, int, int) (*core.Stream, error) { + return nil, model.ErrNotFound +} + +// noopArchiver implements core.Archiver +type noopArchiver struct{} + +func (n noopArchiver) ZipAlbum(context.Context, string, string, int, io.Writer) error { + return model.ErrNotFound +} + +func (n noopArchiver) ZipArtist(context.Context, string, string, int, io.Writer) error { + return model.ErrNotFound +} + +func (n noopArchiver) ZipShare(context.Context, string, io.Writer) error { + return model.ErrNotFound +} + +func (n noopArchiver) ZipPlaylist(context.Context, string, string, int, io.Writer) error { + return model.ErrNotFound +} + +// noopProvider implements external.Provider +type noopProvider struct{} + +func (n noopProvider) UpdateAlbumInfo(_ context.Context, _ string) (*model.Album, error) { + return &model.Album{}, nil +} + +func (n noopProvider) UpdateArtistInfo(_ context.Context, _ string, _ int, _ bool) (*model.Artist, error) { + return &model.Artist{}, nil +} + +func (n noopProvider) SimilarSongs(context.Context, string, int) (model.MediaFiles, error) { + return nil, nil +} + +func (n noopProvider) TopSongs(context.Context, string, int) (model.MediaFiles, error) { + return nil, nil +} + +func (n noopProvider) ArtistImage(context.Context, string) (*url.URL, error) { + return nil, model.ErrNotFound +} + +func (n noopProvider) AlbumImage(context.Context, string) (*url.URL, error) { + return nil, model.ErrNotFound +} + +// noopPlayTracker implements scrobbler.PlayTracker +type noopPlayTracker struct{} + +func (n noopPlayTracker) NowPlaying(context.Context, string, string, string, int) error { + return nil +} + +func (n noopPlayTracker) GetNowPlaying(context.Context) ([]scrobbler.NowPlayingInfo, error) { + return nil, nil +} + +func (n noopPlayTracker) Submit(context.Context, []scrobbler.Submission) error { + return nil +} + +// noopShare implements core.Share +type noopShare struct{} + +func (n noopShare) Load(context.Context, string) (*model.Share, error) { + return nil, model.ErrNotFound +} + +func (n noopShare) NewRepository(context.Context) rest.Repository { + return nil +} + +// Compile-time interface checks +var ( + _ artwork.Artwork = noopArtwork{} + _ core.MediaStreamer = noopStreamer{} + _ core.Archiver = noopArchiver{} + _ external.Provider = noopProvider{} + _ scrobbler.PlayTracker = noopPlayTracker{} + _ core.Share = noopShare{} +) + +var _ = BeforeSuite(func() { + ctx = request.WithUser(context.Background(), adminUser) + tmpDir := GinkgoT().TempDir() + conf.Server.DbPath = filepath.Join(tmpDir, "test-e2e.db?_journal_mode=WAL") + db.Db().SetMaxOpenConns(1) +}) + +// setupTestDB initializes the database, creates the admin user, library, scans the +// test filesystem, and creates the Subsonic Router. Call this from BeforeEach in each +// test container that needs the full E2E environment. +func setupTestDB() { + // Refresh context with the current spec's context to avoid using a canceled context + ctx = request.WithUser(GinkgoT().Context(), adminUser) + + DeferCleanup(configtest.SetupConfig()) + conf.Server.MusicFolder = "fake:///music" + conf.Server.DevExternalScanner = false + + db.Init(ctx) + DeferCleanup(func() { + Expect(tests.ClearDB()).To(Succeed()) + }) + + ds = &tests.MockDataStore{RealDS: persistence.New(db.Db())} + + // Initialize JWT auth (needed for public URL generation in search responses) + auth.Init(ds) + + // Create admin user in DB + adminUserWithPass := adminUser + adminUserWithPass.NewPassword = "password" + Expect(ds.User(ctx).Put(&adminUserWithPass)).To(Succeed()) + + // Create library + lib = model.Library{ID: 1, Name: "Music Library", Path: "fake:///music"} + Expect(ds.Library(ctx).Put(&lib)).To(Succeed()) + + // Set user libraries for access control + Expect(ds.User(ctx).SetUserLibraries(adminUser.ID, []int{lib.ID})).To(Succeed()) + + // Reload user with libraries for context + loadedUser, err := ds.User(ctx).FindByUsername(adminUser.UserName) + Expect(err).ToNot(HaveOccurred()) + adminUser.Libraries = loadedUser.Libraries + ctx = request.WithUser(GinkgoT().Context(), adminUser) + + // Build the fake filesystem and run the scanner + buildTestFS() + s := scanner.New(ctx, ds, artwork.NoopCacheWarmer(), events.NoopBroker(), + core.NewPlaylists(ds), metrics.NewNoopInstance()) + _, err = s.ScanAll(ctx, true) + Expect(err).ToNot(HaveOccurred()) + + // Create the Subsonic Router with real DS + noop stubs + router = subsonic.New( + ds, // DataStore (real) + noopArtwork{}, // Artwork + noopStreamer{}, // MediaStreamer + noopArchiver{}, // Archiver + core.NewPlayers(ds), // Players (real) + noopProvider{}, // Provider + s, // Scanner (real) + events.NoopBroker(), // Broker + core.NewPlaylists(ds), // Playlists (real) + noopPlayTracker{}, // PlayTracker + noopShare{}, // Share + playback.PlaybackServer(nil), // PlaybackServer (nil, jukebox disabled) + metrics.NewNoopInstance(), // Metrics + ) +} diff --git a/server/e2e/subsonic_album_lists_test.go b/server/e2e/subsonic_album_lists_test.go new file mode 100644 index 000000000..f18ffd4b9 --- /dev/null +++ b/server/e2e/subsonic_album_lists_test.go @@ -0,0 +1,276 @@ +package e2e + +import ( + "net/http/httptest" + + "github.com/navidrome/navidrome/server/subsonic/responses" + "github.com/navidrome/navidrome/utils/req" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("Album List Endpoints", func() { + BeforeEach(func() { + setupTestDB() + }) + + Describe("GetAlbumList", func() { + It("type=newest returns albums sorted by creation date", func() { + w, r := newRawReq("getAlbumList", "type", "newest") + resp, err := router.GetAlbumList(w, r) + + Expect(err).ToNot(HaveOccurred()) + Expect(resp.Status).To(Equal(responses.StatusOK)) + Expect(resp.AlbumList).ToNot(BeNil()) + Expect(resp.AlbumList.Album).To(HaveLen(5)) + }) + + It("type=alphabeticalByName sorts albums by name", func() { + w, r := newRawReq("getAlbumList", "type", "alphabeticalByName") + resp, err := router.GetAlbumList(w, r) + + Expect(err).ToNot(HaveOccurred()) + Expect(resp.AlbumList).ToNot(BeNil()) + albums := resp.AlbumList.Album + Expect(albums).To(HaveLen(5)) + // Verify alphabetical order: Abbey Road, Help!, IV, Kind of Blue, Pop + Expect(albums[0].Title).To(Equal("Abbey Road")) + Expect(albums[1].Title).To(Equal("Help!")) + Expect(albums[2].Title).To(Equal("IV")) + Expect(albums[3].Title).To(Equal("Kind of Blue")) + Expect(albums[4].Title).To(Equal("Pop")) + }) + + It("type=alphabeticalByArtist sorts albums by artist name", func() { + w, r := newRawReq("getAlbumList", "type", "alphabeticalByArtist") + resp, err := router.GetAlbumList(w, r) + + Expect(err).ToNot(HaveOccurred()) + Expect(resp.AlbumList).ToNot(BeNil()) + albums := resp.AlbumList.Album + Expect(albums).To(HaveLen(5)) + // Articles like "The" are stripped for sorting, so "The Beatles" sorts as "Beatles" + // Non-compilations first: Beatles (x2), Led Zeppelin, Miles Davis, then compilations: Various + Expect(albums[0].Artist).To(Equal("The Beatles")) + Expect(albums[2].Artist).To(Equal("Led Zeppelin")) + Expect(albums[3].Artist).To(Equal("Miles Davis")) + }) + + It("type=random returns albums", func() { + w, r := newRawReq("getAlbumList", "type", "random") + resp, err := router.GetAlbumList(w, r) + + Expect(err).ToNot(HaveOccurred()) + Expect(resp.AlbumList).ToNot(BeNil()) + Expect(resp.AlbumList.Album).To(HaveLen(5)) + }) + + It("type=byGenre filters by genre parameter", func() { + w, r := newRawReq("getAlbumList", "type", "byGenre", "genre", "Jazz") + resp, err := router.GetAlbumList(w, r) + + Expect(err).ToNot(HaveOccurred()) + Expect(resp.AlbumList).ToNot(BeNil()) + Expect(resp.AlbumList.Album).To(HaveLen(1)) + Expect(resp.AlbumList.Album[0].Title).To(Equal("Kind of Blue")) + }) + + It("type=byYear filters by fromYear/toYear range", func() { + w, r := newRawReq("getAlbumList", "type", "byYear", "fromYear", "1965", "toYear", "1970") + resp, err := router.GetAlbumList(w, r) + + Expect(err).ToNot(HaveOccurred()) + Expect(resp.AlbumList).ToNot(BeNil()) + // Should include Abbey Road (1969) and Help! (1965) + Expect(resp.AlbumList.Album).To(HaveLen(2)) + years := make([]int32, len(resp.AlbumList.Album)) + for i, a := range resp.AlbumList.Album { + years[i] = a.Year + } + Expect(years).To(ConsistOf(int32(1965), int32(1969))) + }) + + It("respects size parameter", func() { + w, r := newRawReq("getAlbumList", "type", "newest", "size", "2") + resp, err := router.GetAlbumList(w, r) + + Expect(err).ToNot(HaveOccurred()) + Expect(resp.AlbumList).ToNot(BeNil()) + Expect(resp.AlbumList.Album).To(HaveLen(2)) + }) + + It("supports offset for pagination", func() { + // First get all albums sorted by name to know the expected order + w1, r1 := newRawReq("getAlbumList", "type", "alphabeticalByName", "size", "5") + resp1, err := router.GetAlbumList(w1, r1) + Expect(err).ToNot(HaveOccurred()) + allAlbums := resp1.AlbumList.Album + + // Now get with offset=2, size=2 + w2, r2 := newRawReq("getAlbumList", "type", "alphabeticalByName", "size", "2", "offset", "2") + resp2, err := router.GetAlbumList(w2, r2) + + Expect(err).ToNot(HaveOccurred()) + Expect(resp2.AlbumList).ToNot(BeNil()) + Expect(resp2.AlbumList.Album).To(HaveLen(2)) + Expect(resp2.AlbumList.Album[0].Title).To(Equal(allAlbums[2].Title)) + Expect(resp2.AlbumList.Album[1].Title).To(Equal(allAlbums[3].Title)) + }) + + It("returns error when type parameter is missing", func() { + w := httptest.NewRecorder() + r := newReq("getAlbumList") + _, err := router.GetAlbumList(w, r) + + Expect(err).To(HaveOccurred()) + Expect(err).To(MatchError(req.ErrMissingParam)) + }) + + It("returns error for unknown type", func() { + w, r := newRawReq("getAlbumList", "type", "invalid_type") + _, err := router.GetAlbumList(w, r) + + Expect(err).To(HaveOccurred()) + }) + }) + + Describe("GetAlbumList2", func() { + It("returns albums in AlbumID3 format", func() { + w, r := newRawReq("getAlbumList2", "type", "alphabeticalByName") + resp, err := router.GetAlbumList2(w, r) + + Expect(err).ToNot(HaveOccurred()) + Expect(resp.Status).To(Equal(responses.StatusOK)) + Expect(resp.AlbumList2).ToNot(BeNil()) + albums := resp.AlbumList2.Album + Expect(albums).To(HaveLen(5)) + // Verify AlbumID3 format fields + Expect(albums[0].Name).To(Equal("Abbey Road")) + Expect(albums[0].Id).ToNot(BeEmpty()) + Expect(albums[0].Artist).ToNot(BeEmpty()) + }) + + It("type=newest works correctly", func() { + w, r := newRawReq("getAlbumList2", "type", "newest") + resp, err := router.GetAlbumList2(w, r) + + Expect(err).ToNot(HaveOccurred()) + Expect(resp.AlbumList2).ToNot(BeNil()) + Expect(resp.AlbumList2.Album).To(HaveLen(5)) + }) + }) + + Describe("GetStarred", func() { + It("returns empty lists when nothing is starred", func() { + r := newReq("getStarred") + resp, err := router.GetStarred(r) + + Expect(err).ToNot(HaveOccurred()) + Expect(resp.Status).To(Equal(responses.StatusOK)) + Expect(resp.Starred).ToNot(BeNil()) + Expect(resp.Starred.Artist).To(BeEmpty()) + Expect(resp.Starred.Album).To(BeEmpty()) + Expect(resp.Starred.Song).To(BeEmpty()) + }) + }) + + Describe("GetStarred2", func() { + It("returns empty lists when nothing is starred", func() { + r := newReq("getStarred2") + resp, err := router.GetStarred2(r) + + Expect(err).ToNot(HaveOccurred()) + Expect(resp.Status).To(Equal(responses.StatusOK)) + Expect(resp.Starred2).ToNot(BeNil()) + Expect(resp.Starred2.Artist).To(BeEmpty()) + Expect(resp.Starred2.Album).To(BeEmpty()) + Expect(resp.Starred2.Song).To(BeEmpty()) + }) + }) + + Describe("GetNowPlaying", func() { + It("returns empty list when nobody is playing", func() { + r := newReq("getNowPlaying") + resp, err := router.GetNowPlaying(r) + + Expect(err).ToNot(HaveOccurred()) + Expect(resp.Status).To(Equal(responses.StatusOK)) + Expect(resp.NowPlaying).ToNot(BeNil()) + Expect(resp.NowPlaying.Entry).To(BeEmpty()) + }) + }) + + Describe("GetRandomSongs", func() { + It("returns random songs from library", func() { + r := newReq("getRandomSongs") + resp, err := router.GetRandomSongs(r) + + Expect(err).ToNot(HaveOccurred()) + Expect(resp.Status).To(Equal(responses.StatusOK)) + Expect(resp.RandomSongs).ToNot(BeNil()) + Expect(resp.RandomSongs.Songs).ToNot(BeEmpty()) + Expect(len(resp.RandomSongs.Songs)).To(BeNumerically("<=", 6)) + }) + + It("respects size parameter", func() { + r := newReq("getRandomSongs", "size", "2") + resp, err := router.GetRandomSongs(r) + + Expect(err).ToNot(HaveOccurred()) + Expect(resp.RandomSongs).ToNot(BeNil()) + Expect(resp.RandomSongs.Songs).To(HaveLen(2)) + }) + + It("filters by genre when specified", func() { + r := newReq("getRandomSongs", "size", "500", "genre", "Jazz") + resp, err := router.GetRandomSongs(r) + + Expect(err).ToNot(HaveOccurred()) + Expect(resp.RandomSongs).ToNot(BeNil()) + Expect(resp.RandomSongs.Songs).To(HaveLen(1)) + Expect(resp.RandomSongs.Songs[0].Genre).To(Equal("Jazz")) + }) + }) + + Describe("GetSongsByGenre", func() { + It("returns songs matching the genre", func() { + r := newReq("getSongsByGenre", "genre", "Rock") + resp, err := router.GetSongsByGenre(r) + + Expect(err).ToNot(HaveOccurred()) + Expect(resp.Status).To(Equal(responses.StatusOK)) + Expect(resp.SongsByGenre).ToNot(BeNil()) + // 4 Rock songs: Come Together, Something, Help!, Stairway To Heaven + Expect(resp.SongsByGenre.Songs).To(HaveLen(4)) + for _, song := range resp.SongsByGenre.Songs { + Expect(song.Genre).To(Equal("Rock")) + } + }) + + It("supports count and offset parameters", func() { + // First get all Rock songs + r1 := newReq("getSongsByGenre", "genre", "Rock", "count", "500") + resp1, err := router.GetSongsByGenre(r1) + Expect(err).ToNot(HaveOccurred()) + allSongs := resp1.SongsByGenre.Songs + + // Now get with count=2, offset=1 + r2 := newReq("getSongsByGenre", "genre", "Rock", "count", "2", "offset", "1") + resp2, err := router.GetSongsByGenre(r2) + + Expect(err).ToNot(HaveOccurred()) + Expect(resp2.SongsByGenre).ToNot(BeNil()) + Expect(resp2.SongsByGenre.Songs).To(HaveLen(2)) + Expect(resp2.SongsByGenre.Songs[0].Id).To(Equal(allSongs[1].Id)) + }) + + It("returns empty for non-existent genre", func() { + r := newReq("getSongsByGenre", "genre", "NonExistentGenre") + resp, err := router.GetSongsByGenre(r) + + Expect(err).ToNot(HaveOccurred()) + Expect(resp.SongsByGenre).ToNot(BeNil()) + Expect(resp.SongsByGenre.Songs).To(BeEmpty()) + }) + }) +}) diff --git a/server/e2e/subsonic_bookmarks_test.go b/server/e2e/subsonic_bookmarks_test.go new file mode 100644 index 000000000..97943c843 --- /dev/null +++ b/server/e2e/subsonic_bookmarks_test.go @@ -0,0 +1,164 @@ +package e2e + +import ( + "fmt" + + "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/server/subsonic/responses" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("Bookmark and PlayQueue Endpoints", Ordered, func() { + BeforeAll(func() { + setupTestDB() + }) + + Describe("Bookmark Endpoints", Ordered, func() { + var trackID string + + BeforeAll(func() { + // Get a media file ID from the database to use for bookmarks + mfs, err := ds.MediaFile(ctx).GetAll(model.QueryOptions{Max: 1}) + Expect(err).ToNot(HaveOccurred()) + Expect(mfs).ToNot(BeEmpty()) + trackID = mfs[0].ID + }) + + It("getBookmarks returns empty initially", func() { + r := newReq("getBookmarks") + resp, err := router.GetBookmarks(r) + + Expect(err).ToNot(HaveOccurred()) + Expect(resp.Status).To(Equal(responses.StatusOK)) + Expect(resp.Bookmarks).ToNot(BeNil()) + Expect(resp.Bookmarks.Bookmark).To(BeEmpty()) + }) + + It("createBookmark creates a bookmark with position", func() { + r := newReq("createBookmark", "id", trackID, "position", "12345", "comment", "test bookmark") + resp, err := router.CreateBookmark(r) + + Expect(err).ToNot(HaveOccurred()) + Expect(resp.Status).To(Equal(responses.StatusOK)) + }) + + It("getBookmarks shows the created bookmark", func() { + r := newReq("getBookmarks") + resp, err := router.GetBookmarks(r) + + Expect(err).ToNot(HaveOccurred()) + Expect(resp.Status).To(Equal(responses.StatusOK)) + Expect(resp.Bookmarks).ToNot(BeNil()) + Expect(resp.Bookmarks.Bookmark).To(HaveLen(1)) + + bmk := resp.Bookmarks.Bookmark[0] + Expect(bmk.Entry.Id).To(Equal(trackID)) + Expect(bmk.Position).To(Equal(int64(12345))) + Expect(bmk.Comment).To(Equal("test bookmark")) + Expect(bmk.Username).To(Equal(adminUser.UserName)) + }) + + It("deleteBookmark removes the bookmark", func() { + r := newReq("deleteBookmark", "id", trackID) + resp, err := router.DeleteBookmark(r) + + Expect(err).ToNot(HaveOccurred()) + Expect(resp.Status).To(Equal(responses.StatusOK)) + + // Verify it's gone + r = newReq("getBookmarks") + resp, err = router.GetBookmarks(r) + Expect(err).ToNot(HaveOccurred()) + Expect(resp.Bookmarks.Bookmark).To(BeEmpty()) + }) + }) + + Describe("PlayQueue Endpoints", Ordered, func() { + var trackIDs []string + + BeforeAll(func() { + // Get multiple media file IDs from the database + mfs, err := ds.MediaFile(ctx).GetAll(model.QueryOptions{Max: 3, Sort: "title"}) + Expect(err).ToNot(HaveOccurred()) + Expect(len(mfs)).To(BeNumerically(">=", 2)) + for _, mf := range mfs { + trackIDs = append(trackIDs, mf.ID) + } + }) + + It("getPlayQueue returns empty when nothing saved", func() { + r := newReq("getPlayQueue") + resp, err := router.GetPlayQueue(r) + + Expect(err).ToNot(HaveOccurred()) + Expect(resp.Status).To(Equal(responses.StatusOK)) + // When no play queue exists, PlayQueue should be nil (no entry returned) + Expect(resp.PlayQueue).To(BeNil()) + }) + + It("savePlayQueue stores current play queue", func() { + r := newReq("savePlayQueue", + "id", trackIDs[0], + "id", trackIDs[1], + "current", trackIDs[1], + "position", "5000", + ) + resp, err := router.SavePlayQueue(r) + + Expect(err).ToNot(HaveOccurred()) + Expect(resp.Status).To(Equal(responses.StatusOK)) + }) + + It("getPlayQueue returns saved queue with tracks", func() { + r := newReq("getPlayQueue") + resp, err := router.GetPlayQueue(r) + + Expect(err).ToNot(HaveOccurred()) + Expect(resp.Status).To(Equal(responses.StatusOK)) + Expect(resp.PlayQueue).ToNot(BeNil()) + Expect(resp.PlayQueue.Entry).To(HaveLen(2)) + Expect(resp.PlayQueue.Current).To(Equal(trackIDs[1])) + Expect(resp.PlayQueue.Position).To(Equal(int64(5000))) + Expect(resp.PlayQueue.Username).To(Equal(adminUser.UserName)) + Expect(resp.PlayQueue.ChangedBy).To(Equal("test-client")) + }) + + It("getPlayQueueByIndex returns data with current index", func() { + r := newReq("getPlayQueueByIndex") + resp, err := router.GetPlayQueueByIndex(r) + + Expect(err).ToNot(HaveOccurred()) + Expect(resp.Status).To(Equal(responses.StatusOK)) + Expect(resp.PlayQueueByIndex).ToNot(BeNil()) + Expect(resp.PlayQueueByIndex.Entry).To(HaveLen(2)) + Expect(resp.PlayQueueByIndex.CurrentIndex).ToNot(BeNil()) + Expect(*resp.PlayQueueByIndex.CurrentIndex).To(Equal(1)) + Expect(resp.PlayQueueByIndex.Position).To(Equal(int64(5000))) + }) + + It("savePlayQueueByIndex stores queue by index", func() { + r := newReq("savePlayQueueByIndex", + "id", trackIDs[0], + "id", trackIDs[1], + "id", trackIDs[2], + "currentIndex", fmt.Sprintf("%d", 0), + "position", "9999", + ) + resp, err := router.SavePlayQueueByIndex(r) + + Expect(err).ToNot(HaveOccurred()) + Expect(resp.Status).To(Equal(responses.StatusOK)) + + // Verify with getPlayQueueByIndex + r = newReq("getPlayQueueByIndex") + resp, err = router.GetPlayQueueByIndex(r) + Expect(err).ToNot(HaveOccurred()) + Expect(resp.PlayQueueByIndex).ToNot(BeNil()) + Expect(resp.PlayQueueByIndex.Entry).To(HaveLen(3)) + Expect(resp.PlayQueueByIndex.CurrentIndex).ToNot(BeNil()) + Expect(*resp.PlayQueueByIndex.CurrentIndex).To(Equal(0)) + Expect(resp.PlayQueueByIndex.Position).To(Equal(int64(9999))) + }) + }) +}) diff --git a/server/e2e/subsonic_browsing_test.go b/server/e2e/subsonic_browsing_test.go new file mode 100644 index 000000000..b8ab297cb --- /dev/null +++ b/server/e2e/subsonic_browsing_test.go @@ -0,0 +1,504 @@ +package e2e + +import ( + "github.com/Masterminds/squirrel" + "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/server/subsonic/responses" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("Browsing Endpoints", func() { + BeforeEach(func() { + setupTestDB() + }) + + Describe("getMusicFolders", func() { + It("returns the configured music library", func() { + r := newReq("getMusicFolders") + resp, err := router.GetMusicFolders(r) + + Expect(err).ToNot(HaveOccurred()) + Expect(resp.Status).To(Equal(responses.StatusOK)) + Expect(resp.MusicFolders).ToNot(BeNil()) + Expect(resp.MusicFolders.Folders).To(HaveLen(1)) + Expect(resp.MusicFolders.Folders[0].Name).To(Equal("Music Library")) + Expect(resp.MusicFolders.Folders[0].Id).To(Equal(int32(lib.ID))) + }) + }) + + Describe("getIndexes", func() { + It("returns artist indexes", func() { + r := newReq("getIndexes") + resp, err := router.GetIndexes(r) + + Expect(err).ToNot(HaveOccurred()) + Expect(resp.Status).To(Equal(responses.StatusOK)) + Expect(resp.Indexes).ToNot(BeNil()) + Expect(resp.Indexes.Index).ToNot(BeEmpty()) + }) + + It("includes all artists across indexes", func() { + r := newReq("getIndexes") + resp, err := router.GetIndexes(r) + + Expect(err).ToNot(HaveOccurred()) + var allArtistNames []string + for _, idx := range resp.Indexes.Index { + for _, a := range idx.Artists { + allArtistNames = append(allArtistNames, a.Name) + } + } + Expect(allArtistNames).To(ContainElements("The Beatles", "Led Zeppelin", "Miles Davis", "Various")) + }) + }) + + Describe("getArtists", func() { + It("returns artist indexes in ID3 format", func() { + r := newReq("getArtists") + resp, err := router.GetArtists(r) + + Expect(err).ToNot(HaveOccurred()) + Expect(resp.Status).To(Equal(responses.StatusOK)) + Expect(resp.Artist).ToNot(BeNil()) + Expect(resp.Artist.Index).ToNot(BeEmpty()) + }) + + It("includes all artists across ID3 indexes", func() { + r := newReq("getArtists") + resp, err := router.GetArtists(r) + + Expect(err).ToNot(HaveOccurred()) + var allArtistNames []string + for _, idx := range resp.Artist.Index { + for _, a := range idx.Artists { + allArtistNames = append(allArtistNames, a.Name) + } + } + Expect(allArtistNames).To(ContainElements("The Beatles", "Led Zeppelin", "Miles Davis", "Various")) + }) + + It("reports correct album counts for artists", func() { + r := newReq("getArtists") + resp, err := router.GetArtists(r) + + Expect(err).ToNot(HaveOccurred()) + var beatlesAlbumCount int32 + for _, idx := range resp.Artist.Index { + for _, a := range idx.Artists { + if a.Name == "The Beatles" { + beatlesAlbumCount = a.AlbumCount + } + } + } + Expect(beatlesAlbumCount).To(Equal(int32(2))) + }) + }) + + Describe("getMusicDirectory", func() { + It("returns an artist directory with its albums as children", func() { + artists, err := ds.Artist(ctx).GetAll(model.QueryOptions{ + Filters: squirrel.Eq{"name": "The Beatles"}, + }) + Expect(err).ToNot(HaveOccurred()) + Expect(artists).ToNot(BeEmpty()) + beatlesID := artists[0].ID + + r := newReq("getMusicDirectory", "id", beatlesID) + resp, err := router.GetMusicDirectory(r) + + Expect(err).ToNot(HaveOccurred()) + Expect(resp.Status).To(Equal(responses.StatusOK)) + Expect(resp.Directory).ToNot(BeNil()) + Expect(resp.Directory.Name).To(Equal("The Beatles")) + Expect(resp.Directory.Child).To(HaveLen(2)) // Abbey Road, Help! + }) + + It("returns an album directory with its tracks as children", func() { + albums, err := ds.Album(ctx).GetAll(model.QueryOptions{ + Filters: squirrel.Eq{"album.name": "Abbey Road"}, + }) + Expect(err).ToNot(HaveOccurred()) + Expect(albums).ToNot(BeEmpty()) + abbeyRoadID := albums[0].ID + + r := newReq("getMusicDirectory", "id", abbeyRoadID) + resp, err := router.GetMusicDirectory(r) + + Expect(err).ToNot(HaveOccurred()) + Expect(resp.Directory).ToNot(BeNil()) + Expect(resp.Directory.Name).To(Equal("Abbey Road")) + Expect(resp.Directory.Child).To(HaveLen(2)) // Come Together, Something + }) + + It("returns an error for a non-existent ID", func() { + r := newReq("getMusicDirectory", "id", "non-existent-id") + _, err := router.GetMusicDirectory(r) + + Expect(err).To(HaveOccurred()) + }) + }) + + Describe("getArtist", func() { + It("returns artist with albums in ID3 format", func() { + artists, err := ds.Artist(ctx).GetAll(model.QueryOptions{ + Filters: squirrel.Eq{"name": "The Beatles"}, + }) + Expect(err).ToNot(HaveOccurred()) + Expect(artists).ToNot(BeEmpty()) + beatlesID := artists[0].ID + + r := newReq("getArtist", "id", beatlesID) + resp, err := router.GetArtist(r) + + Expect(err).ToNot(HaveOccurred()) + Expect(resp.Status).To(Equal(responses.StatusOK)) + Expect(resp.ArtistWithAlbumsID3).ToNot(BeNil()) + Expect(resp.ArtistWithAlbumsID3.Name).To(Equal("The Beatles")) + Expect(resp.ArtistWithAlbumsID3.Album).To(HaveLen(2)) + }) + + It("returns album names for the artist", func() { + artists, err := ds.Artist(ctx).GetAll(model.QueryOptions{ + Filters: squirrel.Eq{"name": "The Beatles"}, + }) + Expect(err).ToNot(HaveOccurred()) + Expect(artists).ToNot(BeEmpty()) + beatlesID := artists[0].ID + + r := newReq("getArtist", "id", beatlesID) + resp, err := router.GetArtist(r) + + Expect(err).ToNot(HaveOccurred()) + var albumNames []string + for _, a := range resp.ArtistWithAlbumsID3.Album { + albumNames = append(albumNames, a.Name) + } + Expect(albumNames).To(ContainElements("Abbey Road", "Help!")) + }) + + It("returns an error for a non-existent artist", func() { + r := newReq("getArtist", "id", "non-existent-id") + _, err := router.GetArtist(r) + + Expect(err).To(HaveOccurred()) + }) + + It("returns artist with a single album", func() { + artists, err := ds.Artist(ctx).GetAll(model.QueryOptions{ + Filters: squirrel.Eq{"name": "Led Zeppelin"}, + }) + Expect(err).ToNot(HaveOccurred()) + Expect(artists).ToNot(BeEmpty()) + ledZepID := artists[0].ID + + r := newReq("getArtist", "id", ledZepID) + resp, err := router.GetArtist(r) + + Expect(err).ToNot(HaveOccurred()) + Expect(resp.ArtistWithAlbumsID3).ToNot(BeNil()) + Expect(resp.ArtistWithAlbumsID3.Name).To(Equal("Led Zeppelin")) + Expect(resp.ArtistWithAlbumsID3.Album).To(HaveLen(1)) + Expect(resp.ArtistWithAlbumsID3.Album[0].Name).To(Equal("IV")) + }) + }) + + Describe("getAlbum", func() { + It("returns album with its tracks", func() { + albums, err := ds.Album(ctx).GetAll(model.QueryOptions{ + Filters: squirrel.Eq{"album.name": "Abbey Road"}, + }) + Expect(err).ToNot(HaveOccurred()) + Expect(albums).ToNot(BeEmpty()) + abbeyRoadID := albums[0].ID + + r := newReq("getAlbum", "id", abbeyRoadID) + resp, err := router.GetAlbum(r) + + Expect(err).ToNot(HaveOccurred()) + Expect(resp.Status).To(Equal(responses.StatusOK)) + Expect(resp.AlbumWithSongsID3).ToNot(BeNil()) + Expect(resp.AlbumWithSongsID3.Name).To(Equal("Abbey Road")) + Expect(resp.AlbumWithSongsID3.Song).To(HaveLen(2)) + }) + + It("includes correct track metadata", func() { + albums, err := ds.Album(ctx).GetAll(model.QueryOptions{ + Filters: squirrel.Eq{"album.name": "Abbey Road"}, + }) + Expect(err).ToNot(HaveOccurred()) + Expect(albums).ToNot(BeEmpty()) + abbeyRoadID := albums[0].ID + + r := newReq("getAlbum", "id", abbeyRoadID) + resp, err := router.GetAlbum(r) + + Expect(err).ToNot(HaveOccurred()) + var trackTitles []string + for _, s := range resp.AlbumWithSongsID3.Song { + trackTitles = append(trackTitles, s.Title) + } + Expect(trackTitles).To(ContainElements("Come Together", "Something")) + }) + + It("returns album with correct artist and year", func() { + albums, err := ds.Album(ctx).GetAll(model.QueryOptions{ + Filters: squirrel.Eq{"album.name": "Kind of Blue"}, + }) + Expect(err).ToNot(HaveOccurred()) + Expect(albums).ToNot(BeEmpty()) + kindOfBlueID := albums[0].ID + + r := newReq("getAlbum", "id", kindOfBlueID) + resp, err := router.GetAlbum(r) + + Expect(err).ToNot(HaveOccurred()) + Expect(resp.AlbumWithSongsID3).ToNot(BeNil()) + Expect(resp.AlbumWithSongsID3.Name).To(Equal("Kind of Blue")) + Expect(resp.AlbumWithSongsID3.Artist).To(Equal("Miles Davis")) + Expect(resp.AlbumWithSongsID3.Year).To(Equal(int32(1959))) + Expect(resp.AlbumWithSongsID3.Song).To(HaveLen(1)) + }) + + It("returns an error for a non-existent album", func() { + r := newReq("getAlbum", "id", "non-existent-id") + _, err := router.GetAlbum(r) + + Expect(err).To(HaveOccurred()) + }) + }) + + Describe("getSong", func() { + It("returns a song by its ID", func() { + songs, err := ds.MediaFile(ctx).GetAll(model.QueryOptions{ + Filters: squirrel.Eq{"title": "Come Together"}, + }) + Expect(err).ToNot(HaveOccurred()) + Expect(songs).ToNot(BeEmpty()) + songID := songs[0].ID + + r := newReq("getSong", "id", songID) + resp, err := router.GetSong(r) + + Expect(err).ToNot(HaveOccurred()) + Expect(resp.Status).To(Equal(responses.StatusOK)) + Expect(resp.Song).ToNot(BeNil()) + Expect(resp.Song.Title).To(Equal("Come Together")) + Expect(resp.Song.Album).To(Equal("Abbey Road")) + Expect(resp.Song.Artist).To(Equal("The Beatles")) + }) + + It("returns an error for a non-existent song", func() { + r := newReq("getSong", "id", "non-existent-id") + _, err := router.GetSong(r) + + Expect(err).To(HaveOccurred()) + }) + + It("returns correct metadata for a jazz track", func() { + songs, err := ds.MediaFile(ctx).GetAll(model.QueryOptions{ + Filters: squirrel.Eq{"title": "So What"}, + }) + Expect(err).ToNot(HaveOccurred()) + Expect(songs).ToNot(BeEmpty()) + songID := songs[0].ID + + r := newReq("getSong", "id", songID) + resp, err := router.GetSong(r) + + Expect(err).ToNot(HaveOccurred()) + Expect(resp.Song).ToNot(BeNil()) + Expect(resp.Song.Title).To(Equal("So What")) + Expect(resp.Song.Album).To(Equal("Kind of Blue")) + Expect(resp.Song.Artist).To(Equal("Miles Davis")) + }) + }) + + Describe("getGenres", func() { + It("returns all genres", func() { + r := newReq("getGenres") + resp, err := router.GetGenres(r) + + Expect(err).ToNot(HaveOccurred()) + Expect(resp.Status).To(Equal(responses.StatusOK)) + Expect(resp.Genres).ToNot(BeNil()) + Expect(resp.Genres.Genre).To(HaveLen(3)) + }) + + It("includes correct genre names", func() { + r := newReq("getGenres") + resp, err := router.GetGenres(r) + + Expect(err).ToNot(HaveOccurred()) + var genreNames []string + for _, g := range resp.Genres.Genre { + genreNames = append(genreNames, g.Name) + } + Expect(genreNames).To(ContainElements("Rock", "Jazz", "Pop")) + }) + + It("reports correct song and album counts for Rock", func() { + r := newReq("getGenres") + resp, err := router.GetGenres(r) + + Expect(err).ToNot(HaveOccurred()) + var rockGenre *responses.Genre + for i, g := range resp.Genres.Genre { + if g.Name == "Rock" { + rockGenre = &resp.Genres.Genre[i] + break + } + } + Expect(rockGenre).ToNot(BeNil()) + Expect(rockGenre.SongCount).To(Equal(int32(4))) + Expect(rockGenre.AlbumCount).To(Equal(int32(3))) + }) + + It("reports correct song and album counts for Jazz", func() { + r := newReq("getGenres") + resp, err := router.GetGenres(r) + + Expect(err).ToNot(HaveOccurred()) + var jazzGenre *responses.Genre + for i, g := range resp.Genres.Genre { + if g.Name == "Jazz" { + jazzGenre = &resp.Genres.Genre[i] + break + } + } + Expect(jazzGenre).ToNot(BeNil()) + Expect(jazzGenre.SongCount).To(Equal(int32(1))) + Expect(jazzGenre.AlbumCount).To(Equal(int32(1))) + }) + + It("reports correct song and album counts for Pop", func() { + r := newReq("getGenres") + resp, err := router.GetGenres(r) + + Expect(err).ToNot(HaveOccurred()) + var popGenre *responses.Genre + for i, g := range resp.Genres.Genre { + if g.Name == "Pop" { + popGenre = &resp.Genres.Genre[i] + break + } + } + Expect(popGenre).ToNot(BeNil()) + Expect(popGenre.SongCount).To(Equal(int32(1))) + Expect(popGenre.AlbumCount).To(Equal(int32(1))) + }) + }) + + Describe("getAlbumInfo", func() { + It("returns album info for a valid album", func() { + albums, err := ds.Album(ctx).GetAll(model.QueryOptions{ + Filters: squirrel.Eq{"album.name": "Abbey Road"}, + }) + Expect(err).ToNot(HaveOccurred()) + Expect(albums).ToNot(BeEmpty()) + abbeyRoadID := albums[0].ID + + r := newReq("getAlbumInfo", "id", abbeyRoadID) + resp, err := router.GetAlbumInfo(r) + + Expect(err).ToNot(HaveOccurred()) + Expect(resp.Status).To(Equal(responses.StatusOK)) + Expect(resp.AlbumInfo).ToNot(BeNil()) + }) + }) + + Describe("getArtistInfo", func() { + It("returns artist info for a valid artist", func() { + artists, err := ds.Artist(ctx).GetAll(model.QueryOptions{ + Filters: squirrel.Eq{"name": "The Beatles"}, + }) + Expect(err).ToNot(HaveOccurred()) + Expect(artists).ToNot(BeEmpty()) + beatlesID := artists[0].ID + + r := newReq("getArtistInfo", "id", beatlesID) + resp, err := router.GetArtistInfo(r) + + Expect(err).ToNot(HaveOccurred()) + Expect(resp.Status).To(Equal(responses.StatusOK)) + Expect(resp.ArtistInfo).ToNot(BeNil()) + }) + }) + + Describe("getArtistInfo2", func() { + It("returns artist info2 for a valid artist", func() { + artists, err := ds.Artist(ctx).GetAll(model.QueryOptions{ + Filters: squirrel.Eq{"name": "The Beatles"}, + }) + Expect(err).ToNot(HaveOccurred()) + Expect(artists).ToNot(BeEmpty()) + beatlesID := artists[0].ID + + r := newReq("getArtistInfo2", "id", beatlesID) + resp, err := router.GetArtistInfo2(r) + + Expect(err).ToNot(HaveOccurred()) + Expect(resp.Status).To(Equal(responses.StatusOK)) + Expect(resp.ArtistInfo2).ToNot(BeNil()) + }) + }) + + Describe("getTopSongs", func() { + It("returns a response for a known artist name", func() { + r := newReq("getTopSongs", "artist", "The Beatles") + resp, err := router.GetTopSongs(r) + + Expect(err).ToNot(HaveOccurred()) + Expect(resp.Status).To(Equal(responses.StatusOK)) + Expect(resp.TopSongs).ToNot(BeNil()) + // noopProvider returns empty list, so Songs may be empty + }) + + It("returns an empty list for an unknown artist", func() { + r := newReq("getTopSongs", "artist", "Unknown Artist") + resp, err := router.GetTopSongs(r) + + Expect(err).ToNot(HaveOccurred()) + Expect(resp.TopSongs).ToNot(BeNil()) + Expect(resp.TopSongs.Song).To(BeEmpty()) + }) + }) + + Describe("getSimilarSongs", func() { + It("returns a response for a valid song ID", func() { + songs, err := ds.MediaFile(ctx).GetAll(model.QueryOptions{ + Filters: squirrel.Eq{"title": "Come Together"}, + }) + Expect(err).ToNot(HaveOccurred()) + Expect(songs).ToNot(BeEmpty()) + songID := songs[0].ID + + r := newReq("getSimilarSongs", "id", songID) + resp, err := router.GetSimilarSongs(r) + + Expect(err).ToNot(HaveOccurred()) + Expect(resp.Status).To(Equal(responses.StatusOK)) + Expect(resp.SimilarSongs).ToNot(BeNil()) + // noopProvider returns empty list + }) + }) + + Describe("getSimilarSongs2", func() { + It("returns a response for a valid song ID", func() { + songs, err := ds.MediaFile(ctx).GetAll(model.QueryOptions{ + Filters: squirrel.Eq{"title": "Come Together"}, + }) + Expect(err).ToNot(HaveOccurred()) + Expect(songs).ToNot(BeEmpty()) + songID := songs[0].ID + + r := newReq("getSimilarSongs2", "id", songID) + resp, err := router.GetSimilarSongs2(r) + + Expect(err).ToNot(HaveOccurred()) + Expect(resp.Status).To(Equal(responses.StatusOK)) + Expect(resp.SimilarSongs2).ToNot(BeNil()) + // noopProvider returns empty list + }) + }) +}) diff --git a/server/e2e/subsonic_media_annotation_test.go b/server/e2e/subsonic_media_annotation_test.go new file mode 100644 index 000000000..2be189966 --- /dev/null +++ b/server/e2e/subsonic_media_annotation_test.go @@ -0,0 +1,186 @@ +package e2e + +import ( + "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/server/subsonic/responses" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("Media Annotation Endpoints", Ordered, func() { + BeforeAll(func() { + setupTestDB() + }) + + Describe("Star/Unstar", Ordered, func() { + var songID, albumID, artistID string + + BeforeAll(func() { + // Look up a song from the scanned data + songs, err := ds.MediaFile(ctx).GetAll(model.QueryOptions{Max: 1, Sort: "title"}) + Expect(err).ToNot(HaveOccurred()) + Expect(songs).ToNot(BeEmpty()) + songID = songs[0].ID + + // Look up an album + albums, err := ds.Album(ctx).GetAll(model.QueryOptions{Max: 1, Sort: "name"}) + Expect(err).ToNot(HaveOccurred()) + Expect(albums).ToNot(BeEmpty()) + albumID = albums[0].ID + + // Look up an artist + artists, err := ds.Artist(ctx).GetAll(model.QueryOptions{Max: 1, Sort: "name"}) + Expect(err).ToNot(HaveOccurred()) + Expect(artists).ToNot(BeEmpty()) + artistID = artists[0].ID + }) + + It("stars a song by id", func() { + r := newReq("star", "id", songID) + resp, err := router.Star(r) + + Expect(err).ToNot(HaveOccurred()) + Expect(resp.Status).To(Equal(responses.StatusOK)) + }) + + It("starred song appears in getStarred response", func() { + r := newReq("getStarred") + resp, err := router.GetStarred(r) + + Expect(err).ToNot(HaveOccurred()) + Expect(resp.Status).To(Equal(responses.StatusOK)) + Expect(resp.Starred).ToNot(BeNil()) + Expect(resp.Starred.Song).To(HaveLen(1)) + Expect(resp.Starred.Song[0].Id).To(Equal(songID)) + }) + + It("unstars a previously starred song", func() { + r := newReq("unstar", "id", songID) + resp, err := router.Unstar(r) + + Expect(err).ToNot(HaveOccurred()) + Expect(resp.Status).To(Equal(responses.StatusOK)) + + // Verify song no longer appears in starred + r = newReq("getStarred") + resp, err = router.GetStarred(r) + + Expect(err).ToNot(HaveOccurred()) + Expect(resp.Starred.Song).To(BeEmpty()) + }) + + It("stars an album by albumId", func() { + r := newReq("star", "albumId", albumID) + resp, err := router.Star(r) + + Expect(err).ToNot(HaveOccurred()) + Expect(resp.Status).To(Equal(responses.StatusOK)) + + // Verify album appears in starred + r = newReq("getStarred") + resp, err = router.GetStarred(r) + + Expect(err).ToNot(HaveOccurred()) + Expect(resp.Starred.Album).To(HaveLen(1)) + Expect(resp.Starred.Album[0].Id).To(Equal(albumID)) + }) + + It("stars an artist by artistId", func() { + r := newReq("star", "artistId", artistID) + resp, err := router.Star(r) + + Expect(err).ToNot(HaveOccurred()) + Expect(resp.Status).To(Equal(responses.StatusOK)) + + // Verify artist appears in starred + r = newReq("getStarred") + resp, err = router.GetStarred(r) + + Expect(err).ToNot(HaveOccurred()) + Expect(resp.Starred.Artist).To(HaveLen(1)) + Expect(resp.Starred.Artist[0].Id).To(Equal(artistID)) + }) + + It("returns error when no id provided", func() { + r := newReq("star") + _, err := router.Star(r) + + Expect(err).To(HaveOccurred()) + }) + }) + + Describe("SetRating", Ordered, func() { + var songID, albumID string + + BeforeAll(func() { + songs, err := ds.MediaFile(ctx).GetAll(model.QueryOptions{Max: 1, Sort: "title"}) + Expect(err).ToNot(HaveOccurred()) + Expect(songs).ToNot(BeEmpty()) + songID = songs[0].ID + + albums, err := ds.Album(ctx).GetAll(model.QueryOptions{Max: 1, Sort: "name"}) + Expect(err).ToNot(HaveOccurred()) + Expect(albums).ToNot(BeEmpty()) + albumID = albums[0].ID + }) + + It("sets rating on a song", func() { + r := newReq("setRating", "id", songID, "rating", "4") + resp, err := router.SetRating(r) + + Expect(err).ToNot(HaveOccurred()) + Expect(resp.Status).To(Equal(responses.StatusOK)) + }) + + It("rated song has correct userRating in getSong", func() { + r := newReq("getSong", "id", songID) + resp, err := router.GetSong(r) + + Expect(err).ToNot(HaveOccurred()) + Expect(resp.Status).To(Equal(responses.StatusOK)) + Expect(resp.Song).ToNot(BeNil()) + Expect(resp.Song.UserRating).To(Equal(int32(4))) + }) + + It("sets rating on an album", func() { + r := newReq("setRating", "id", albumID, "rating", "3") + resp, err := router.SetRating(r) + + Expect(err).ToNot(HaveOccurred()) + Expect(resp.Status).To(Equal(responses.StatusOK)) + }) + + It("returns error for missing parameters", func() { + // Missing both id and rating + r := newReq("setRating") + _, err := router.SetRating(r) + Expect(err).To(HaveOccurred()) + + // Missing rating + r = newReq("setRating", "id", songID) + _, err = router.SetRating(r) + Expect(err).To(HaveOccurred()) + }) + }) + + Describe("Scrobble", func() { + It("submits a scrobble for a song", func() { + songs, err := ds.MediaFile(ctx).GetAll(model.QueryOptions{Max: 1, Sort: "title"}) + Expect(err).ToNot(HaveOccurred()) + Expect(songs).ToNot(BeEmpty()) + + r := newReq("scrobble", "id", songs[0].ID, "submission", "true") + resp, err := router.Scrobble(r) + + Expect(err).ToNot(HaveOccurred()) + Expect(resp.Status).To(Equal(responses.StatusOK)) + }) + + It("returns error when id is missing", func() { + r := newReq("scrobble") + _, err := router.Scrobble(r) + + Expect(err).To(HaveOccurred()) + }) + }) +}) diff --git a/server/e2e/subsonic_media_retrieval_test.go b/server/e2e/subsonic_media_retrieval_test.go new file mode 100644 index 000000000..130aa9713 --- /dev/null +++ b/server/e2e/subsonic_media_retrieval_test.go @@ -0,0 +1,79 @@ +package e2e + +import ( + "github.com/navidrome/navidrome/server/subsonic/responses" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("Media Retrieval Endpoints", Ordered, func() { + BeforeAll(func() { + setupTestDB() + }) + + Describe("Stream", func() { + It("returns error when id parameter is missing", func() { + w, r := newRawReq("stream") + _, err := router.Stream(w, r) + + Expect(err).To(HaveOccurred()) + }) + }) + + Describe("Download", func() { + It("returns error when id parameter is missing", func() { + w, r := newRawReq("download") + _, err := router.Download(w, r) + + Expect(err).To(HaveOccurred()) + }) + }) + + Describe("GetCoverArt", func() { + It("handles request without error", func() { + w, r := newRawReq("getCoverArt") + _, err := router.GetCoverArt(w, r) + + Expect(err).ToNot(HaveOccurred()) + }) + }) + + Describe("GetAvatar", func() { + It("returns placeholder avatar when gravatar disabled", func() { + w, r := newRawReq("getAvatar", "username", "admin") + resp, err := router.GetAvatar(w, r) + + // When gravatar is disabled, it returns nil response (writes directly to w) + Expect(err).ToNot(HaveOccurred()) + Expect(resp).To(BeNil()) + }) + }) + + Describe("GetLyrics", func() { + It("returns empty lyrics when no match found", func() { + r := newReq("getLyrics", "artist", "NonExistentArtist", "title", "NonExistentTitle") + resp, err := router.GetLyrics(r) + + Expect(err).ToNot(HaveOccurred()) + Expect(resp.Status).To(Equal(responses.StatusOK)) + Expect(resp.Lyrics).ToNot(BeNil()) + Expect(resp.Lyrics.Value).To(BeEmpty()) + }) + }) + + Describe("GetLyricsBySongId", func() { + It("returns error when id parameter is missing", func() { + r := newReq("getLyricsBySongId") + _, err := router.GetLyricsBySongId(r) + + Expect(err).To(HaveOccurred()) + }) + + It("returns error for non-existent song id", func() { + r := newReq("getLyricsBySongId", "id", "non-existent-id") + _, err := router.GetLyricsBySongId(r) + + Expect(err).To(HaveOccurred()) + }) + }) +}) diff --git a/server/e2e/subsonic_playlists_test.go b/server/e2e/subsonic_playlists_test.go new file mode 100644 index 000000000..fb7a52138 --- /dev/null +++ b/server/e2e/subsonic_playlists_test.go @@ -0,0 +1,130 @@ +package e2e + +import ( + "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/server/subsonic/responses" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("Playlist Endpoints", Ordered, func() { + var playlistID string + var songIDs []string + + BeforeAll(func() { + setupTestDB() + + // Look up song IDs from scanned data for playlist operations + songs, err := ds.MediaFile(ctx).GetAll(model.QueryOptions{Sort: "title", Max: 3}) + Expect(err).ToNot(HaveOccurred()) + Expect(len(songs)).To(BeNumerically(">=", 3)) + for _, s := range songs { + songIDs = append(songIDs, s.ID) + } + }) + + It("getPlaylists returns empty list initially", func() { + r := newReq("getPlaylists") + resp, err := router.GetPlaylists(r) + + Expect(err).ToNot(HaveOccurred()) + Expect(resp.Status).To(Equal(responses.StatusOK)) + Expect(resp.Playlists).ToNot(BeNil()) + Expect(resp.Playlists.Playlist).To(BeEmpty()) + }) + + It("createPlaylist creates a new playlist with songs", func() { + r := newReq("createPlaylist", "name", "Test Playlist", "songId", songIDs[0], "songId", songIDs[1]) + resp, err := router.CreatePlaylist(r) + + Expect(err).ToNot(HaveOccurred()) + Expect(resp.Status).To(Equal(responses.StatusOK)) + Expect(resp.Playlist).ToNot(BeNil()) + Expect(resp.Playlist.Name).To(Equal("Test Playlist")) + Expect(resp.Playlist.SongCount).To(Equal(int32(2))) + playlistID = resp.Playlist.Id + }) + + It("getPlaylist returns playlist with tracks", func() { + r := newReq("getPlaylist", "id", playlistID) + resp, err := router.GetPlaylist(r) + + Expect(err).ToNot(HaveOccurred()) + Expect(resp.Status).To(Equal(responses.StatusOK)) + Expect(resp.Playlist).ToNot(BeNil()) + Expect(resp.Playlist.Name).To(Equal("Test Playlist")) + Expect(resp.Playlist.Entry).To(HaveLen(2)) + Expect(resp.Playlist.Entry[0].Id).To(Equal(songIDs[0])) + Expect(resp.Playlist.Entry[1].Id).To(Equal(songIDs[1])) + }) + + It("createPlaylist without name or playlistId returns error", func() { + r := newReq("createPlaylist", "songId", songIDs[0]) + _, err := router.CreatePlaylist(r) + + Expect(err).To(HaveOccurred()) + }) + + It("updatePlaylist can rename the playlist", func() { + r := newReq("updatePlaylist", "playlistId", playlistID, "name", "Renamed Playlist") + resp, err := router.UpdatePlaylist(r) + + Expect(err).ToNot(HaveOccurred()) + Expect(resp.Status).To(Equal(responses.StatusOK)) + + // Verify the rename + r = newReq("getPlaylist", "id", playlistID) + resp, err = router.GetPlaylist(r) + + Expect(err).ToNot(HaveOccurred()) + Expect(resp.Playlist.Name).To(Equal("Renamed Playlist")) + }) + + It("updatePlaylist can add songs", func() { + r := newReq("updatePlaylist", "playlistId", playlistID, "songIdToAdd", songIDs[2]) + resp, err := router.UpdatePlaylist(r) + + Expect(err).ToNot(HaveOccurred()) + Expect(resp.Status).To(Equal(responses.StatusOK)) + + // Verify the song was added + r = newReq("getPlaylist", "id", playlistID) + resp, err = router.GetPlaylist(r) + + Expect(err).ToNot(HaveOccurred()) + Expect(resp.Playlist.SongCount).To(Equal(int32(3))) + Expect(resp.Playlist.Entry).To(HaveLen(3)) + }) + + It("updatePlaylist can remove songs by index", func() { + // Remove the first song (index 0) + r := newReq("updatePlaylist", "playlistId", playlistID, "songIndexToRemove", "0") + resp, err := router.UpdatePlaylist(r) + + Expect(err).ToNot(HaveOccurred()) + Expect(resp.Status).To(Equal(responses.StatusOK)) + + // Verify the song was removed + r = newReq("getPlaylist", "id", playlistID) + resp, err = router.GetPlaylist(r) + + Expect(err).ToNot(HaveOccurred()) + Expect(resp.Playlist.SongCount).To(Equal(int32(2))) + Expect(resp.Playlist.Entry).To(HaveLen(2)) + }) + + It("deletePlaylist removes the playlist", func() { + r := newReq("deletePlaylist", "id", playlistID) + resp, err := router.DeletePlaylist(r) + + Expect(err).ToNot(HaveOccurred()) + Expect(resp.Status).To(Equal(responses.StatusOK)) + }) + + It("getPlaylist on deleted playlist returns error", func() { + r := newReq("getPlaylist", "id", playlistID) + _, err := router.GetPlaylist(r) + + Expect(err).To(HaveOccurred()) + }) +}) diff --git a/server/e2e/subsonic_radio_test.go b/server/e2e/subsonic_radio_test.go new file mode 100644 index 000000000..99267aadb --- /dev/null +++ b/server/e2e/subsonic_radio_test.go @@ -0,0 +1,94 @@ +package e2e + +import ( + "github.com/navidrome/navidrome/server/subsonic/responses" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("Internet Radio Endpoints", Ordered, func() { + var radioID string + + BeforeAll(func() { + setupTestDB() + }) + + It("getInternetRadioStations returns empty initially", func() { + r := newReq("getInternetRadioStations") + resp, err := router.GetInternetRadios(r) + + Expect(err).ToNot(HaveOccurred()) + Expect(resp.Status).To(Equal(responses.StatusOK)) + Expect(resp.InternetRadioStations).ToNot(BeNil()) + Expect(resp.InternetRadioStations.Radios).To(BeEmpty()) + }) + + It("createInternetRadioStation adds a station", func() { + r := newReq("createInternetRadioStation", + "streamUrl", "https://stream.example.com/radio", + "name", "Test Radio", + "homepageUrl", "https://example.com", + ) + resp, err := router.CreateInternetRadio(r) + + Expect(err).ToNot(HaveOccurred()) + Expect(resp.Status).To(Equal(responses.StatusOK)) + }) + + It("getInternetRadioStations returns the created station", func() { + r := newReq("getInternetRadioStations") + resp, err := router.GetInternetRadios(r) + + Expect(err).ToNot(HaveOccurred()) + Expect(resp.Status).To(Equal(responses.StatusOK)) + Expect(resp.InternetRadioStations).ToNot(BeNil()) + Expect(resp.InternetRadioStations.Radios).To(HaveLen(1)) + + radio := resp.InternetRadioStations.Radios[0] + Expect(radio.Name).To(Equal("Test Radio")) + Expect(radio.StreamUrl).To(Equal("https://stream.example.com/radio")) + Expect(radio.HomepageUrl).To(Equal("https://example.com")) + radioID = radio.ID + Expect(radioID).ToNot(BeEmpty()) + }) + + It("updateInternetRadioStation modifies the station", func() { + r := newReq("updateInternetRadioStation", + "id", radioID, + "streamUrl", "https://stream.example.com/radio-v2", + "name", "Updated Radio", + "homepageUrl", "https://updated.example.com", + ) + resp, err := router.UpdateInternetRadio(r) + + Expect(err).ToNot(HaveOccurred()) + Expect(resp.Status).To(Equal(responses.StatusOK)) + + // Verify update + r = newReq("getInternetRadioStations") + resp, err = router.GetInternetRadios(r) + Expect(err).ToNot(HaveOccurred()) + Expect(resp.InternetRadioStations.Radios).To(HaveLen(1)) + Expect(resp.InternetRadioStations.Radios[0].Name).To(Equal("Updated Radio")) + Expect(resp.InternetRadioStations.Radios[0].StreamUrl).To(Equal("https://stream.example.com/radio-v2")) + Expect(resp.InternetRadioStations.Radios[0].HomepageUrl).To(Equal("https://updated.example.com")) + }) + + It("deleteInternetRadioStation removes it", func() { + r := newReq("deleteInternetRadioStation", "id", radioID) + resp, err := router.DeleteInternetRadio(r) + + Expect(err).ToNot(HaveOccurred()) + Expect(resp.Status).To(Equal(responses.StatusOK)) + }) + + It("getInternetRadioStations returns empty after deletion", func() { + r := newReq("getInternetRadioStations") + resp, err := router.GetInternetRadios(r) + + Expect(err).ToNot(HaveOccurred()) + Expect(resp.Status).To(Equal(responses.StatusOK)) + Expect(resp.InternetRadioStations).ToNot(BeNil()) + Expect(resp.InternetRadioStations.Radios).To(BeEmpty()) + }) +}) diff --git a/server/e2e/subsonic_scan_test.go b/server/e2e/subsonic_scan_test.go new file mode 100644 index 000000000..62e0dbda5 --- /dev/null +++ b/server/e2e/subsonic_scan_test.go @@ -0,0 +1,60 @@ +package e2e + +import ( + "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/server/subsonic/responses" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("Scan Endpoints", func() { + BeforeEach(func() { + setupTestDB() + }) + + It("getScanStatus returns status", func() { + r := newReq("getScanStatus") + resp, err := router.GetScanStatus(r) + + Expect(err).ToNot(HaveOccurred()) + Expect(resp.Status).To(Equal(responses.StatusOK)) + Expect(resp.ScanStatus).ToNot(BeNil()) + Expect(resp.ScanStatus.Scanning).To(BeFalse()) + Expect(resp.ScanStatus.Count).To(BeNumerically(">", 0)) + Expect(resp.ScanStatus.LastScan).ToNot(BeNil()) + }) + + It("startScan requires admin user", func() { + regularUser := model.User{ + ID: "user-2", + UserName: "regular", + Name: "Regular User", + IsAdmin: false, + } + // Store the regular user in the database + regularUserWithPass := regularUser + regularUserWithPass.NewPassword = "password" + Expect(ds.User(ctx).Put(®ularUserWithPass)).To(Succeed()) + Expect(ds.User(ctx).SetUserLibraries(regularUser.ID, []int{lib.ID})).To(Succeed()) + + // Reload user with libraries + loadedUser, err := ds.User(ctx).FindByUsername(regularUser.UserName) + Expect(err).ToNot(HaveOccurred()) + regularUser.Libraries = loadedUser.Libraries + + r := newReqWithUser(regularUser, "startScan") + _, err = router.StartScan(r) + + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("not authorized")) + }) + + It("startScan returns scan status response", func() { + r := newReq("startScan") + resp, err := router.StartScan(r) + + Expect(err).ToNot(HaveOccurred()) + Expect(resp.Status).To(Equal(responses.StatusOK)) + Expect(resp.ScanStatus).ToNot(BeNil()) + }) +}) diff --git a/server/e2e/subsonic_searching_test.go b/server/e2e/subsonic_searching_test.go new file mode 100644 index 000000000..a40ce4582 --- /dev/null +++ b/server/e2e/subsonic_searching_test.go @@ -0,0 +1,158 @@ +package e2e + +import ( + "github.com/navidrome/navidrome/server/subsonic/responses" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("Search Endpoints", func() { + BeforeEach(func() { + setupTestDB() + }) + + Describe("Search2", func() { + It("finds artists by name", func() { + r := newReq("search2", "query", "Beatles") + resp, err := router.Search2(r) + + Expect(err).ToNot(HaveOccurred()) + Expect(resp.Status).To(Equal(responses.StatusOK)) + Expect(resp.SearchResult2).ToNot(BeNil()) + Expect(resp.SearchResult2.Artist).ToNot(BeEmpty()) + + found := false + for _, a := range resp.SearchResult2.Artist { + if a.Name == "The Beatles" { + found = true + break + } + } + Expect(found).To(BeTrue(), "expected to find artist 'The Beatles'") + }) + + It("finds albums by name", func() { + r := newReq("search2", "query", "Abbey Road") + resp, err := router.Search2(r) + + Expect(err).ToNot(HaveOccurred()) + Expect(resp.SearchResult2).ToNot(BeNil()) + Expect(resp.SearchResult2.Album).ToNot(BeEmpty()) + + found := false + for _, a := range resp.SearchResult2.Album { + if a.Title == "Abbey Road" { + found = true + break + } + } + Expect(found).To(BeTrue(), "expected to find album 'Abbey Road'") + }) + + It("finds songs by title", func() { + r := newReq("search2", "query", "Come Together") + resp, err := router.Search2(r) + + Expect(err).ToNot(HaveOccurred()) + Expect(resp.SearchResult2).ToNot(BeNil()) + Expect(resp.SearchResult2.Song).ToNot(BeEmpty()) + + found := false + for _, s := range resp.SearchResult2.Song { + if s.Title == "Come Together" { + found = true + break + } + } + Expect(found).To(BeTrue(), "expected to find song 'Come Together'") + }) + + It("respects artistCount/albumCount/songCount limits", func() { + r := newReq("search2", "query", "Beatles", + "artistCount", "1", "albumCount", "1", "songCount", "1") + resp, err := router.Search2(r) + + Expect(err).ToNot(HaveOccurred()) + Expect(resp.SearchResult2).ToNot(BeNil()) + Expect(len(resp.SearchResult2.Artist)).To(BeNumerically("<=", 1)) + Expect(len(resp.SearchResult2.Album)).To(BeNumerically("<=", 1)) + Expect(len(resp.SearchResult2.Song)).To(BeNumerically("<=", 1)) + }) + + It("supports offset parameters", func() { + // First get all results for Beatles + r1 := newReq("search2", "query", "Beatles", "songCount", "500") + resp1, err := router.Search2(r1) + Expect(err).ToNot(HaveOccurred()) + allSongs := resp1.SearchResult2.Song + + if len(allSongs) > 1 { + // Get with offset to skip the first song + r2 := newReq("search2", "query", "Beatles", "songOffset", "1", "songCount", "500") + resp2, err := router.Search2(r2) + + Expect(err).ToNot(HaveOccurred()) + Expect(resp2.SearchResult2).ToNot(BeNil()) + Expect(len(resp2.SearchResult2.Song)).To(Equal(len(allSongs) - 1)) + } + }) + + It("returns empty results for non-matching query", func() { + r := newReq("search2", "query", "ZZZZNONEXISTENT99999") + resp, err := router.Search2(r) + + Expect(err).ToNot(HaveOccurred()) + Expect(resp.SearchResult2).ToNot(BeNil()) + Expect(resp.SearchResult2.Artist).To(BeEmpty()) + Expect(resp.SearchResult2.Album).To(BeEmpty()) + Expect(resp.SearchResult2.Song).To(BeEmpty()) + }) + }) + + Describe("Search3", func() { + It("returns results in ID3 format", func() { + r := newReq("search3", "query", "Beatles") + resp, err := router.Search3(r) + + Expect(err).ToNot(HaveOccurred()) + Expect(resp.Status).To(Equal(responses.StatusOK)) + Expect(resp.SearchResult3).ToNot(BeNil()) + // Verify ID3 format: Artist should be ArtistID3 with Name and AlbumCount + Expect(resp.SearchResult3.Artist).ToNot(BeEmpty()) + Expect(resp.SearchResult3.Artist[0].Name).ToNot(BeEmpty()) + Expect(resp.SearchResult3.Artist[0].Id).ToNot(BeEmpty()) + }) + + It("finds across all entity types simultaneously", func() { + // "Beatles" should match artist, albums, and songs by The Beatles + r := newReq("search3", "query", "Beatles") + resp, err := router.Search3(r) + + Expect(err).ToNot(HaveOccurred()) + Expect(resp.SearchResult3).ToNot(BeNil()) + + // Should find at least the artist "The Beatles" + artistFound := false + for _, a := range resp.SearchResult3.Artist { + if a.Name == "The Beatles" { + artistFound = true + break + } + } + Expect(artistFound).To(BeTrue(), "expected to find artist 'The Beatles'") + + // Should find albums by The Beatles (albums contain "Beatles" in artist field) + // Albums are returned as AlbumID3 type + for _, a := range resp.SearchResult3.Album { + Expect(a.Id).ToNot(BeEmpty()) + Expect(a.Name).ToNot(BeEmpty()) + } + + // Songs are returned as Child type + for _, s := range resp.SearchResult3.Song { + Expect(s.Id).ToNot(BeEmpty()) + Expect(s.Title).ToNot(BeEmpty()) + } + }) + }) +}) diff --git a/server/e2e/subsonic_system_test.go b/server/e2e/subsonic_system_test.go new file mode 100644 index 000000000..563608b97 --- /dev/null +++ b/server/e2e/subsonic_system_test.go @@ -0,0 +1,86 @@ +package e2e + +import ( + "github.com/navidrome/navidrome/server/subsonic/responses" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("System Endpoints", func() { + BeforeEach(func() { + setupTestDB() + }) + + Describe("ping", func() { + It("returns a successful response", func() { + r := newReq("ping") + resp, err := router.Ping(r) + + Expect(err).ToNot(HaveOccurred()) + Expect(resp.Status).To(Equal(responses.StatusOK)) + }) + }) + + Describe("getLicense", func() { + It("returns a valid license", func() { + r := newReq("getLicense") + resp, err := router.GetLicense(r) + + Expect(err).ToNot(HaveOccurred()) + Expect(resp.Status).To(Equal(responses.StatusOK)) + Expect(resp.License).ToNot(BeNil()) + Expect(resp.License.Valid).To(BeTrue()) + }) + }) + + Describe("getOpenSubsonicExtensions", func() { + It("returns a list of supported extensions", func() { + r := newReq("getOpenSubsonicExtensions") + resp, err := router.GetOpenSubsonicExtensions(r) + + Expect(err).ToNot(HaveOccurred()) + Expect(resp.Status).To(Equal(responses.StatusOK)) + Expect(resp.OpenSubsonicExtensions).ToNot(BeNil()) + Expect(*resp.OpenSubsonicExtensions).ToNot(BeEmpty()) + }) + + It("includes the transcodeOffset extension", func() { + r := newReq("getOpenSubsonicExtensions") + resp, err := router.GetOpenSubsonicExtensions(r) + + Expect(err).ToNot(HaveOccurred()) + extensions := *resp.OpenSubsonicExtensions + var names []string + for _, ext := range extensions { + names = append(names, ext.Name) + } + Expect(names).To(ContainElement("transcodeOffset")) + }) + + It("includes the formPost extension", func() { + r := newReq("getOpenSubsonicExtensions") + resp, err := router.GetOpenSubsonicExtensions(r) + + Expect(err).ToNot(HaveOccurred()) + extensions := *resp.OpenSubsonicExtensions + var names []string + for _, ext := range extensions { + names = append(names, ext.Name) + } + Expect(names).To(ContainElement("formPost")) + }) + + It("includes the songLyrics extension", func() { + r := newReq("getOpenSubsonicExtensions") + resp, err := router.GetOpenSubsonicExtensions(r) + + Expect(err).ToNot(HaveOccurred()) + extensions := *resp.OpenSubsonicExtensions + var names []string + for _, ext := range extensions { + names = append(names, ext.Name) + } + Expect(names).To(ContainElement("songLyrics")) + }) + }) +}) diff --git a/server/e2e/subsonic_users_test.go b/server/e2e/subsonic_users_test.go new file mode 100644 index 000000000..232e33bfe --- /dev/null +++ b/server/e2e/subsonic_users_test.go @@ -0,0 +1,56 @@ +package e2e + +import ( + "github.com/navidrome/navidrome/server/subsonic/responses" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("User Endpoints", func() { + BeforeEach(func() { + setupTestDB() + }) + + It("getUser returns current user info", func() { + r := newReq("getUser", "username", adminUser.UserName) + resp, err := router.GetUser(r) + + Expect(err).ToNot(HaveOccurred()) + Expect(resp.Status).To(Equal(responses.StatusOK)) + Expect(resp.User).ToNot(BeNil()) + Expect(resp.User.Username).To(Equal(adminUser.UserName)) + Expect(resp.User.AdminRole).To(BeTrue()) + Expect(resp.User.StreamRole).To(BeTrue()) + Expect(resp.User.Folder).ToNot(BeEmpty()) + }) + + It("getUser with matching username case-insensitive succeeds", func() { + r := newReq("getUser", "username", "Admin") + resp, err := router.GetUser(r) + + Expect(err).ToNot(HaveOccurred()) + Expect(resp.Status).To(Equal(responses.StatusOK)) + Expect(resp.User).ToNot(BeNil()) + Expect(resp.User.Username).To(Equal(adminUser.UserName)) + }) + + It("getUser with different username returns authorization error", func() { + r := newReq("getUser", "username", "otheruser") + _, err := router.GetUser(r) + + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("not authorized")) + }) + + It("getUsers returns list with current user only", func() { + r := newReq("getUsers") + resp, err := router.GetUsers(r) + + Expect(err).ToNot(HaveOccurred()) + Expect(resp.Status).To(Equal(responses.StatusOK)) + Expect(resp.Users).ToNot(BeNil()) + Expect(resp.Users.User).To(HaveLen(1)) + Expect(resp.Users.User[0].Username).To(Equal(adminUser.UserName)) + Expect(resp.Users.User[0].AdminRole).To(BeTrue()) + }) +})