diff --git a/server/e2e/e2e_suite_test.go b/server/e2e/e2e_suite_test.go new file mode 100644 index 00000000..e658d1fd --- /dev/null +++ b/server/e2e/e2e_suite_test.go @@ -0,0 +1,384 @@ +package e2e + +import ( + "context" + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "net/url" + "os" + "path/filepath" + "testing" + "testing/fstest" + "time" + + "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/server/subsonic/responses" + "github.com/navidrome/navidrome/tests" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +func TestSubsonicE2E(t *testing.T) { + tests.Init(t, false) + defer db.Close(t.Context()) + 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 +var ( + ctx context.Context + ds *tests.MockDataStore + router *subsonic.Router + lib model.Library + + // Snapshot paths for fast DB restore + dbFilePath string + snapshotPath string + + // 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()}, + }) +} + +// createUser creates a user in the database with the given properties, assigns them to the test +// library, and returns the fully-loaded user (with Libraries populated). +func createUser(id, username, name string, isAdmin bool) model.User { + user := model.User{ + ID: id, + UserName: username, + Name: name, + IsAdmin: isAdmin, + NewPassword: "password", + } + Expect(ds.User(ctx).Put(&user)).To(Succeed()) + Expect(ds.User(ctx).SetUserLibraries(user.ID, []int{lib.ID})).To(Succeed()) + + loadedUser, err := ds.User(ctx).FindByUsername(user.UserName) + Expect(err).ToNot(HaveOccurred()) + user.Libraries = loadedUser.Libraries + return user +} + +// doReq makes a full HTTP round-trip through the router and returns the parsed Subsonic response. +func doReq(endpoint string, params ...string) *responses.Subsonic { + return doReqWithUser(adminUser, endpoint, params...) +} + +// doReqWithUser makes a full HTTP round-trip for the given user and returns the parsed Subsonic response. +func doReqWithUser(user model.User, endpoint string, params ...string) *responses.Subsonic { + w := httptest.NewRecorder() + r := buildReq(user, endpoint, params...) + router.ServeHTTP(w, r) + return parseJSONResponse(w) +} + +// doRawReq returns the raw ResponseRecorder for endpoints that write binary data (stream, download, getCoverArt). +func doRawReq(endpoint string, params ...string) *httptest.ResponseRecorder { + return doRawReqWithUser(adminUser, endpoint, params...) +} + +// doRawReqWithUser returns the raw ResponseRecorder for the given user. +func doRawReqWithUser(user model.User, endpoint string, params ...string) *httptest.ResponseRecorder { + w := httptest.NewRecorder() + r := buildReq(user, endpoint, params...) + router.ServeHTTP(w, r) + return w +} + +// buildReq creates a GET request with Subsonic auth params (u, p, v, c, f=json). +func buildReq(user model.User, endpoint string, params ...string) *http.Request { + if len(params)%2 != 0 { + panic("buildReq: odd number of parameters") + } + q := url.Values{} + q.Add("u", user.UserName) + q.Add("p", "password") + q.Add("v", "1.16.1") + q.Add("c", "test-client") + q.Add("f", "json") + for i := 0; i < len(params); i += 2 { + q.Add(params[i], params[i+1]) + } + return httptest.NewRequest("GET", "/"+endpoint+"?"+q.Encode(), nil) +} + +// parseJSONResponse parses the JSON response body into a Subsonic response struct. +func parseJSONResponse(w *httptest.ResponseRecorder) *responses.Subsonic { + Expect(w.Code).To(Equal(http.StatusOK)) + var wrapper responses.JsonWrapper + Expect(json.Unmarshal(w.Body.Bytes(), &wrapper)).To(Succeed()) + return &wrapper.Subsonic +} + +// --- 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 +} + +// Compile-time interface checks +var ( + _ artwork.Artwork = noopArtwork{} + _ core.MediaStreamer = noopStreamer{} + _ core.Archiver = noopArchiver{} + _ external.Provider = noopProvider{} + _ scrobbler.PlayTracker = noopPlayTracker{} +) + +var _ = BeforeSuite(func() { + ctx = request.WithUser(GinkgoT().Context(), adminUser) + tmpDir := GinkgoT().TempDir() + dbFilePath = filepath.Join(tmpDir, "test-e2e.db") + snapshotPath = filepath.Join(tmpDir, "test-e2e.db.snapshot") + conf.Server.DbPath = dbFilePath + "?_journal_mode=WAL" + db.Db().SetMaxOpenConns(1) + + // Initial setup: schema, user, library, and full scan (runs once for the entire suite) + conf.Server.MusicFolder = "fake:///music" + conf.Server.DevExternalScanner = false + + db.Init(ctx) + + initDS := &tests.MockDataStore{RealDS: persistence.New(db.Db())} + auth.Init(initDS) + + adminUserWithPass := adminUser + adminUserWithPass.NewPassword = "password" + Expect(initDS.User(ctx).Put(&adminUserWithPass)).To(Succeed()) + + lib = model.Library{ID: 1, Name: "Music Library", Path: "fake:///music"} + Expect(initDS.Library(ctx).Put(&lib)).To(Succeed()) + + Expect(initDS.User(ctx).SetUserLibraries(adminUser.ID, []int{lib.ID})).To(Succeed()) + + loadedUser, err := initDS.User(ctx).FindByUsername(adminUser.UserName) + Expect(err).ToNot(HaveOccurred()) + adminUser.Libraries = loadedUser.Libraries + ctx = request.WithUser(GinkgoT().Context(), adminUser) + + buildTestFS() + s := scanner.New(ctx, initDS, artwork.NoopCacheWarmer(), events.NoopBroker(), + core.NewPlaylists(initDS), metrics.NewNoopInstance()) + _, err = s.ScanAll(ctx, true) + Expect(err).ToNot(HaveOccurred()) + + // Checkpoint WAL and snapshot the golden DB state + _, err = db.Db().Exec("PRAGMA wal_checkpoint(TRUNCATE)") + Expect(err).ToNot(HaveOccurred()) + data, err := os.ReadFile(dbFilePath) + Expect(err).ToNot(HaveOccurred()) + Expect(os.WriteFile(snapshotPath, data, 0600)).To(Succeed()) +}) + +// setupTestDB restores the database from the golden snapshot and creates the +// Subsonic Router. Call this from BeforeEach/BeforeAll in each test container. +func setupTestDB() { + ctx = request.WithUser(GinkgoT().Context(), adminUser) + + DeferCleanup(configtest.SetupConfig()) + conf.Server.MusicFolder = "fake:///music" + conf.Server.DevExternalScanner = false + + // Restore DB to golden state (no scan needed) + restoreDB() + + ds = &tests.MockDataStore{RealDS: persistence.New(db.Db())} + auth.Init(ds) + + // Create the Subsonic Router with real DS + noop stubs + s := scanner.New(ctx, ds, artwork.NoopCacheWarmer(), events.NoopBroker(), + core.NewPlaylists(ds), metrics.NewNoopInstance()) + router = subsonic.New( + ds, + noopArtwork{}, + noopStreamer{}, + noopArchiver{}, + core.NewPlayers(ds), + noopProvider{}, + s, + events.NoopBroker(), + core.NewPlaylists(ds), + noopPlayTracker{}, + core.NewShare(ds), + playback.PlaybackServer(nil), + metrics.NewNoopInstance(), + ) +} + +// restoreDB restores all table data from the snapshot using ATTACH DATABASE. +// This is much faster than re-running the scanner for each test. +func restoreDB() { + sqlDB := db.Db() + + _, err := sqlDB.Exec("PRAGMA foreign_keys = OFF") + Expect(err).ToNot(HaveOccurred()) + + _, err = sqlDB.Exec("ATTACH DATABASE ? AS snapshot", snapshotPath) + Expect(err).ToNot(HaveOccurred()) + + rows, err := sqlDB.Query("SELECT name FROM main.sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%'") + Expect(err).ToNot(HaveOccurred()) + var tables []string + for rows.Next() { + var name string + Expect(rows.Scan(&name)).To(Succeed()) + tables = append(tables, name) + } + Expect(rows.Err()).ToNot(HaveOccurred()) + rows.Close() + + for _, table := range tables { + // Table names come from sqlite_master, not user input, so concatenation is safe here + _, err = sqlDB.Exec(`DELETE FROM main."` + table + `"`) //nolint:gosec + Expect(err).ToNot(HaveOccurred()) + _, err = sqlDB.Exec(`INSERT INTO main."` + table + `" SELECT * FROM snapshot."` + table + `"`) //nolint:gosec + Expect(err).ToNot(HaveOccurred()) + } + + _, err = sqlDB.Exec("DETACH DATABASE snapshot") + Expect(err).ToNot(HaveOccurred()) + _, err = sqlDB.Exec("PRAGMA foreign_keys = ON") + Expect(err).ToNot(HaveOccurred()) +} diff --git a/server/e2e/subsonic_album_lists_test.go b/server/e2e/subsonic_album_lists_test.go new file mode 100644 index 00000000..f7a5af17 --- /dev/null +++ b/server/e2e/subsonic_album_lists_test.go @@ -0,0 +1,295 @@ +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("Album List Endpoints", func() { + BeforeEach(func() { + setupTestDB() + }) + + Describe("GetAlbumList", func() { + It("type=newest returns albums sorted by creation date", func() { + resp := doReq("getAlbumList", "type", "newest") + + 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() { + resp := doReq("getAlbumList", "type", "alphabeticalByName") + + 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() { + resp := doReq("getAlbumList", "type", "alphabeticalByArtist") + + 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[1].Artist).To(Equal("The Beatles")) + Expect(albums[2].Artist).To(Equal("Led Zeppelin")) + Expect(albums[3].Artist).To(Equal("Miles Davis")) + Expect(albums[4].Artist).To(Equal("Various")) + }) + + It("type=random returns albums", func() { + resp := doReq("getAlbumList", "type", "random") + + Expect(resp.AlbumList).ToNot(BeNil()) + Expect(resp.AlbumList.Album).To(HaveLen(5)) + }) + + It("type=byGenre filters by genre parameter", func() { + resp := doReq("getAlbumList", "type", "byGenre", "genre", "Jazz") + + 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() { + resp := doReq("getAlbumList", "type", "byYear", "fromYear", "1965", "toYear", "1970") + + 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() { + resp := doReq("getAlbumList", "type", "newest", "size", "2") + + 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 + resp1 := doReq("getAlbumList", "type", "alphabeticalByName", "size", "5") + allAlbums := resp1.AlbumList.Album + + // Now get with offset=2, size=2 + resp2 := doReq("getAlbumList", "type", "alphabeticalByName", "size", "2", "offset", "2") + + 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() { + resp := doReq("getAlbumList") + + Expect(resp.Status).To(Equal(responses.StatusFailed)) + Expect(resp.Error).ToNot(BeNil()) + }) + + It("returns error for unknown type", func() { + resp := doReq("getAlbumList", "type", "invalid_type") + + Expect(resp.Status).To(Equal(responses.StatusFailed)) + Expect(resp.Error).ToNot(BeNil()) + }) + + It("type=frequent returns empty when no albums have been played", func() { + resp := doReq("getAlbumList", "type", "frequent") + + Expect(resp.AlbumList).ToNot(BeNil()) + Expect(resp.AlbumList.Album).To(BeEmpty()) + }) + + It("type=recent returns empty when no albums have been played", func() { + resp := doReq("getAlbumList", "type", "recent") + + Expect(resp.AlbumList).ToNot(BeNil()) + Expect(resp.AlbumList.Album).To(BeEmpty()) + }) + }) + + Describe("GetAlbumList - starred type", Ordered, func() { + BeforeAll(func() { + setupTestDB() + + // Star an album so the starred filter returns results + albums, err := ds.Album(ctx).GetAll(model.QueryOptions{ + Filters: squirrel.Eq{"album.name": "Abbey Road"}, + }) + Expect(err).ToNot(HaveOccurred()) + Expect(albums).ToNot(BeEmpty()) + + resp := doReq("star", "albumId", albums[0].ID) + Expect(resp.Status).To(Equal(responses.StatusOK)) + }) + + It("type=starred returns only starred albums", func() { + resp := doReq("getAlbumList", "type", "starred") + + Expect(resp.AlbumList).ToNot(BeNil()) + Expect(resp.AlbumList.Album).To(HaveLen(1)) + Expect(resp.AlbumList.Album[0].Title).To(Equal("Abbey Road")) + }) + }) + + Describe("GetAlbumList - highest type", Ordered, func() { + BeforeAll(func() { + setupTestDB() + + // Rate an album so the highest filter returns results + albums, err := ds.Album(ctx).GetAll(model.QueryOptions{ + Filters: squirrel.Eq{"album.name": "Kind of Blue"}, + }) + Expect(err).ToNot(HaveOccurred()) + Expect(albums).ToNot(BeEmpty()) + + resp := doReq("setRating", "id", albums[0].ID, "rating", "5") + Expect(resp.Status).To(Equal(responses.StatusOK)) + }) + + It("type=highest returns only rated albums", func() { + resp := doReq("getAlbumList", "type", "highest") + + Expect(resp.AlbumList).ToNot(BeNil()) + Expect(resp.AlbumList.Album).To(HaveLen(1)) + Expect(resp.AlbumList.Album[0].Title).To(Equal("Kind of Blue")) + }) + }) + + Describe("GetAlbumList2", func() { + It("returns albums in AlbumID3 format", func() { + resp := doReq("getAlbumList2", "type", "alphabeticalByName") + + 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() { + resp := doReq("getAlbumList2", "type", "newest") + + Expect(resp.AlbumList2).ToNot(BeNil()) + Expect(resp.AlbumList2.Album).To(HaveLen(5)) + }) + }) + + Describe("GetStarred", func() { + It("returns empty lists when nothing is starred", func() { + resp := doReq("getStarred") + + 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() { + resp := doReq("getStarred2") + + 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() { + resp := doReq("getNowPlaying") + + 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() { + resp := doReq("getRandomSongs") + + 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() { + resp := doReq("getRandomSongs", "size", "2") + + Expect(resp.RandomSongs).ToNot(BeNil()) + Expect(resp.RandomSongs.Songs).To(HaveLen(2)) + }) + + It("filters by genre when specified", func() { + resp := doReq("getRandomSongs", "size", "500", "genre", "Jazz") + + 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() { + resp := doReq("getSongsByGenre", "genre", "Rock") + + 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 + resp1 := doReq("getSongsByGenre", "genre", "Rock", "count", "500") + allSongs := resp1.SongsByGenre.Songs + + // Now get with count=2, offset=1 + resp2 := doReq("getSongsByGenre", "genre", "Rock", "count", "2", "offset", "1") + + 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() { + resp := doReq("getSongsByGenre", "genre", "NonExistentGenre") + + 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 00000000..d0dc0620 --- /dev/null +++ b/server/e2e/subsonic_bookmarks_test.go @@ -0,0 +1,142 @@ +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() { + resp := doReq("getBookmarks") + + 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() { + resp := doReq("createBookmark", "id", trackID, "position", "12345", "comment", "test bookmark") + + Expect(resp.Status).To(Equal(responses.StatusOK)) + }) + + It("getBookmarks shows the created bookmark", func() { + resp := doReq("getBookmarks") + + 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() { + resp := doReq("deleteBookmark", "id", trackID) + + Expect(resp.Status).To(Equal(responses.StatusOK)) + + // Verify it's gone + resp = doReq("getBookmarks") + 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() { + resp := doReq("getPlayQueue") + + 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() { + resp := doReq("savePlayQueue", + "id", trackIDs[0], + "id", trackIDs[1], + "current", trackIDs[1], + "position", "5000", + ) + + Expect(resp.Status).To(Equal(responses.StatusOK)) + }) + + It("getPlayQueue returns saved queue with tracks", func() { + resp := doReq("getPlayQueue") + + 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() { + resp := doReq("getPlayQueueByIndex") + + 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() { + resp := doReq("savePlayQueueByIndex", + "id", trackIDs[0], + "id", trackIDs[1], + "id", trackIDs[2], + "currentIndex", fmt.Sprintf("%d", 0), + "position", "9999", + ) + + Expect(resp.Status).To(Equal(responses.StatusOK)) + + // Verify with getPlayQueueByIndex + resp = doReq("getPlayQueueByIndex") + 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 00000000..403e1ba6 --- /dev/null +++ b/server/e2e/subsonic_browsing_test.go @@ -0,0 +1,464 @@ +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() { + resp := doReq("getMusicFolders") + + 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() { + resp := doReq("getIndexes") + + 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() { + resp := doReq("getIndexes") + + 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() { + resp := doReq("getArtists") + + 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() { + resp := doReq("getArtists") + + 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() { + resp := doReq("getArtists") + + 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 + + resp := doReq("getMusicDirectory", "id", beatlesID) + + 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 + + resp := doReq("getMusicDirectory", "id", abbeyRoadID) + + 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() { + resp := doReq("getMusicDirectory", "id", "non-existent-id") + + Expect(resp.Status).To(Equal(responses.StatusFailed)) + Expect(resp.Error).ToNot(BeNil()) + }) + }) + + 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 + + resp := doReq("getArtist", "id", beatlesID) + + 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 + + resp := doReq("getArtist", "id", beatlesID) + + 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() { + resp := doReq("getArtist", "id", "non-existent-id") + + Expect(resp.Status).To(Equal(responses.StatusFailed)) + Expect(resp.Error).ToNot(BeNil()) + }) + + 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 + + resp := doReq("getArtist", "id", ledZepID) + + 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 + + resp := doReq("getAlbum", "id", abbeyRoadID) + + 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 + + resp := doReq("getAlbum", "id", abbeyRoadID) + + 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 + + resp := doReq("getAlbum", "id", kindOfBlueID) + + 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() { + resp := doReq("getAlbum", "id", "non-existent-id") + + Expect(resp.Status).To(Equal(responses.StatusFailed)) + Expect(resp.Error).ToNot(BeNil()) + }) + }) + + 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 + + resp := doReq("getSong", "id", songID) + + 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() { + resp := doReq("getSong", "id", "non-existent-id") + + Expect(resp.Status).To(Equal(responses.StatusFailed)) + Expect(resp.Error).ToNot(BeNil()) + }) + + 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 + + resp := doReq("getSong", "id", songID) + + 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() { + resp := doReq("getGenres") + + 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() { + resp := doReq("getGenres") + + 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() { + resp := doReq("getGenres") + + 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() { + resp := doReq("getGenres") + + 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() { + resp := doReq("getGenres") + + 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 + + resp := doReq("getAlbumInfo", "id", abbeyRoadID) + + Expect(resp.Status).To(Equal(responses.StatusOK)) + Expect(resp.AlbumInfo).ToNot(BeNil()) + }) + }) + + Describe("getAlbumInfo2", 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 + + resp := doReq("getAlbumInfo2", "id", abbeyRoadID) + + 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 + + resp := doReq("getArtistInfo", "id", beatlesID) + + 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 + + resp := doReq("getArtistInfo2", "id", beatlesID) + + 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() { + resp := doReq("getTopSongs", "artist", "The Beatles") + + 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() { + resp := doReq("getTopSongs", "artist", "Unknown Artist") + + 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 + + resp := doReq("getSimilarSongs", "id", songID) + + 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 + + resp := doReq("getSimilarSongs2", "id", songID) + + 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 00000000..0f7e9208 --- /dev/null +++ b/server/e2e/subsonic_media_annotation_test.go @@ -0,0 +1,160 @@ +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() { + resp := doReq("star", "id", songID) + + Expect(resp.Status).To(Equal(responses.StatusOK)) + }) + + It("starred song appears in getStarred response", func() { + resp := doReq("getStarred") + + 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() { + resp := doReq("unstar", "id", songID) + + Expect(resp.Status).To(Equal(responses.StatusOK)) + + // Verify song no longer appears in starred + resp = doReq("getStarred") + + Expect(resp.Starred.Song).To(BeEmpty()) + }) + + It("stars an album by albumId", func() { + resp := doReq("star", "albumId", albumID) + + Expect(resp.Status).To(Equal(responses.StatusOK)) + + // Verify album appears in starred + resp = doReq("getStarred") + + Expect(resp.Starred.Album).To(HaveLen(1)) + Expect(resp.Starred.Album[0].Id).To(Equal(albumID)) + }) + + It("stars an artist by artistId", func() { + resp := doReq("star", "artistId", artistID) + + Expect(resp.Status).To(Equal(responses.StatusOK)) + + // Verify artist appears in starred + resp = doReq("getStarred") + + Expect(resp.Starred.Artist).To(HaveLen(1)) + Expect(resp.Starred.Artist[0].Id).To(Equal(artistID)) + }) + + It("returns error when no id provided", func() { + resp := doReq("star") + + Expect(resp.Status).To(Equal(responses.StatusFailed)) + Expect(resp.Error).ToNot(BeNil()) + }) + }) + + 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() { + resp := doReq("setRating", "id", songID, "rating", "4") + + Expect(resp.Status).To(Equal(responses.StatusOK)) + }) + + It("rated song has correct userRating in getSong", func() { + resp := doReq("getSong", "id", songID) + + 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() { + resp := doReq("setRating", "id", albumID, "rating", "3") + + Expect(resp.Status).To(Equal(responses.StatusOK)) + }) + + It("returns error for missing parameters", func() { + // Missing both id and rating + resp := doReq("setRating") + Expect(resp.Status).To(Equal(responses.StatusFailed)) + + // Missing rating + resp = doReq("setRating", "id", songID) + Expect(resp.Status).To(Equal(responses.StatusFailed)) + }) + }) + + 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()) + + resp := doReq("scrobble", "id", songs[0].ID, "submission", "true") + + Expect(resp.Status).To(Equal(responses.StatusOK)) + }) + + It("returns error when id is missing", func() { + resp := doReq("scrobble") + + Expect(resp.Status).To(Equal(responses.StatusFailed)) + Expect(resp.Error).ToNot(BeNil()) + }) + }) +}) diff --git a/server/e2e/subsonic_media_retrieval_test.go b/server/e2e/subsonic_media_retrieval_test.go new file mode 100644 index 00000000..c36713db --- /dev/null +++ b/server/e2e/subsonic_media_retrieval_test.go @@ -0,0 +1,75 @@ +package e2e + +import ( + "net/http" + + "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() { + resp := doReq("stream") + + Expect(resp.Status).To(Equal(responses.StatusFailed)) + Expect(resp.Error).ToNot(BeNil()) + }) + }) + + Describe("Download", func() { + It("returns error when id parameter is missing", func() { + resp := doReq("download") + + Expect(resp.Status).To(Equal(responses.StatusFailed)) + Expect(resp.Error).ToNot(BeNil()) + }) + }) + + Describe("GetCoverArt", func() { + It("handles request without error", func() { + w := doRawReq("getCoverArt") + + Expect(w.Code).To(Equal(http.StatusOK)) + }) + }) + + Describe("GetAvatar", func() { + It("returns placeholder avatar when gravatar disabled", func() { + w := doRawReq("getAvatar", "username", "admin") + + Expect(w.Code).To(Equal(http.StatusOK)) + }) + }) + + Describe("GetLyrics", func() { + It("returns empty lyrics when no match found", func() { + resp := doReq("getLyrics", "artist", "NonExistentArtist", "title", "NonExistentTitle") + + 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() { + resp := doReq("getLyricsBySongId") + + Expect(resp.Status).To(Equal(responses.StatusFailed)) + Expect(resp.Error).ToNot(BeNil()) + }) + + It("returns error for non-existent song id", func() { + resp := doReq("getLyricsBySongId", "id", "non-existent-id") + + Expect(resp.Status).To(Equal(responses.StatusFailed)) + Expect(resp.Error).ToNot(BeNil()) + }) + }) +}) diff --git a/server/e2e/subsonic_multilibrary_test.go b/server/e2e/subsonic_multilibrary_test.go new file mode 100644 index 00000000..2292bfab --- /dev/null +++ b/server/e2e/subsonic_multilibrary_test.go @@ -0,0 +1,279 @@ +package e2e + +import ( + "fmt" + "testing/fstest" + + "github.com/Masterminds/squirrel" + "github.com/navidrome/navidrome/conf" + "github.com/navidrome/navidrome/core" + "github.com/navidrome/navidrome/core/artwork" + "github.com/navidrome/navidrome/core/metrics" + "github.com/navidrome/navidrome/core/storage/storagetest" + "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/scanner" + "github.com/navidrome/navidrome/server/events" + "github.com/navidrome/navidrome/server/subsonic/responses" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("Multi-Library Support", Ordered, func() { + var lib2 model.Library + var adminWithLibs model.User // admin reloaded with both libraries + var userLib1Only model.User // non-admin with lib1 access only + + BeforeAll(func() { + conf.Server.EnableSharing = true + setupTestDB() + + // Create a second FakeFS with Classical music content + classical := template(_t{ + "albumartist": "Ludwig van Beethoven", + "artist": "Ludwig van Beethoven", + "album": "Symphony No. 9", + "year": 1824, + "genre": "Classical", + }) + classicalFS := storagetest.FakeFS{} + classicalFS.SetFiles(fstest.MapFS{ + "Classical/Beethoven/Symphony No. 9/01 - Allegro ma non troppo.mp3": classical(track(1, "Allegro ma non troppo")), + "Classical/Beethoven/Symphony No. 9/02 - Ode to Joy.mp3": classical(track(2, "Ode to Joy")), + }) + storagetest.Register("fake2", &classicalFS) + + // Create the second library in the DB (Put auto-assigns admin users) + lib2 = model.Library{ID: 2, Name: "Classical Library", Path: "fake2:///classical"} + Expect(ds.Library(ctx).Put(&lib2)).To(Succeed()) + + // Reload admin user to get both libraries in the Libraries field + loadedAdmin, err := ds.User(ctx).FindByUsername(adminUser.UserName) + Expect(err).ToNot(HaveOccurred()) + adminWithLibs = *loadedAdmin + + // Run incremental scan to import lib2 content (lib1 files unchanged → skipped) + s := scanner.New(ctx, ds, artwork.NoopCacheWarmer(), events.NoopBroker(), + core.NewPlaylists(ds), metrics.NewNoopInstance()) + _, err = s.ScanAll(ctx, false) + Expect(err).ToNot(HaveOccurred()) + + // Create a non-admin user with access only to lib1 + userLib1Only = model.User{ + ID: "multilib-user-1", + UserName: "lib1user", + Name: "Lib1 User", + IsAdmin: false, + NewPassword: "password", + } + Expect(ds.User(ctx).Put(&userLib1Only)).To(Succeed()) + Expect(ds.User(ctx).SetUserLibraries(userLib1Only.ID, []int{lib.ID})).To(Succeed()) + + loadedUser, err := ds.User(ctx).FindByUsername(userLib1Only.UserName) + Expect(err).ToNot(HaveOccurred()) + userLib1Only.Libraries = loadedUser.Libraries + }) + + Describe("getMusicFolders", func() { + It("returns both libraries for admin user", func() { + resp := doReqWithUser(adminWithLibs, "getMusicFolders") + + Expect(resp.Status).To(Equal(responses.StatusOK)) + Expect(resp.MusicFolders.Folders).To(HaveLen(2)) + + names := make([]string, len(resp.MusicFolders.Folders)) + for i, f := range resp.MusicFolders.Folders { + names[i] = f.Name + } + Expect(names).To(ConsistOf("Music Library", "Classical Library")) + }) + }) + + Describe("getArtists - library filtering", func() { + It("returns only lib1 artists when musicFolderId=1", func() { + resp := doReqWithUser(adminWithLibs, "getArtists", "musicFolderId", fmt.Sprintf("%d", lib.ID)) + + Expect(resp.Status).To(Equal(responses.StatusOK)) + Expect(resp.Artist).ToNot(BeNil()) + + var artistNames []string + for _, idx := range resp.Artist.Index { + for _, a := range idx.Artists { + artistNames = append(artistNames, a.Name) + } + } + Expect(artistNames).To(ContainElements("The Beatles", "Led Zeppelin", "Miles Davis")) + Expect(artistNames).ToNot(ContainElement("Ludwig van Beethoven")) + }) + + It("returns only lib2 artists when musicFolderId=2", func() { + resp := doReqWithUser(adminWithLibs, "getArtists", "musicFolderId", fmt.Sprintf("%d", lib2.ID)) + + Expect(resp.Status).To(Equal(responses.StatusOK)) + Expect(resp.Artist).ToNot(BeNil()) + + var artistNames []string + for _, idx := range resp.Artist.Index { + for _, a := range idx.Artists { + artistNames = append(artistNames, a.Name) + } + } + Expect(artistNames).To(ContainElement("Ludwig van Beethoven")) + Expect(artistNames).ToNot(ContainElements("The Beatles", "Led Zeppelin", "Miles Davis")) + }) + + It("returns artists from all libraries when no musicFolderId is specified", func() { + resp := doReqWithUser(adminWithLibs, "getArtists") + + Expect(resp.Status).To(Equal(responses.StatusOK)) + + var artistNames []string + for _, idx := range resp.Artist.Index { + for _, a := range idx.Artists { + artistNames = append(artistNames, a.Name) + } + } + Expect(artistNames).To(ContainElements("The Beatles", "Led Zeppelin", "Miles Davis", "Ludwig van Beethoven")) + }) + }) + + Describe("getAlbumList - library filtering", func() { + It("returns only lib1 albums when musicFolderId=1", func() { + resp := doReqWithUser(adminWithLibs, "getAlbumList", "type", "alphabeticalByName", "musicFolderId", fmt.Sprintf("%d", lib.ID)) + + Expect(resp.AlbumList).ToNot(BeNil()) + Expect(resp.AlbumList.Album).To(HaveLen(5)) + for _, a := range resp.AlbumList.Album { + Expect(a.Title).ToNot(Equal("Symphony No. 9")) + } + }) + + It("returns only lib2 albums when musicFolderId=2", func() { + resp := doReqWithUser(adminWithLibs, "getAlbumList", "type", "alphabeticalByName", "musicFolderId", fmt.Sprintf("%d", lib2.ID)) + + Expect(resp.AlbumList).ToNot(BeNil()) + Expect(resp.AlbumList.Album).To(HaveLen(1)) + Expect(resp.AlbumList.Album[0].Title).To(Equal("Symphony No. 9")) + }) + }) + + Describe("search3 - library filtering", func() { + It("does not find lib1 content when searching in lib2 only", func() { + resp := doReqWithUser(adminWithLibs, "search3", "query", "Beatles", "musicFolderId", fmt.Sprintf("%d", lib2.ID)) + + Expect(resp.SearchResult3).ToNot(BeNil()) + Expect(resp.SearchResult3.Artist).To(BeEmpty()) + Expect(resp.SearchResult3.Album).To(BeEmpty()) + Expect(resp.SearchResult3.Song).To(BeEmpty()) + }) + + It("finds lib2 content when searching in lib2", func() { + resp := doReqWithUser(adminWithLibs, "search3", "query", "Beethoven", "musicFolderId", fmt.Sprintf("%d", lib2.ID)) + + Expect(resp.SearchResult3).ToNot(BeNil()) + Expect(resp.SearchResult3.Artist).ToNot(BeEmpty()) + Expect(resp.SearchResult3.Artist[0].Name).To(Equal("Ludwig van Beethoven")) + }) + }) + + Describe("Cross-library playlists", Ordered, func() { + var playlistID string + var lib1SongID, lib2SongID string + + BeforeAll(func() { + // Look up one song from each library + lib1Songs, err := ds.MediaFile(ctx).GetAll(model.QueryOptions{ + Filters: squirrel.Eq{"media_file.library_id": lib.ID}, + Max: 1, Sort: "title", + }) + Expect(err).ToNot(HaveOccurred()) + Expect(lib1Songs).ToNot(BeEmpty()) + lib1SongID = lib1Songs[0].ID + + lib2Songs, err := ds.MediaFile(ctx).GetAll(model.QueryOptions{ + Filters: squirrel.Eq{"media_file.library_id": lib2.ID}, + Max: 1, Sort: "title", + }) + Expect(err).ToNot(HaveOccurred()) + Expect(lib2Songs).ToNot(BeEmpty()) + lib2SongID = lib2Songs[0].ID + }) + + It("admin creates a playlist with songs from both libraries", func() { + resp := doReqWithUser(adminWithLibs, "createPlaylist", + "name", "Cross-Library Playlist", "songId", lib1SongID, "songId", lib2SongID) + + Expect(resp.Status).To(Equal(responses.StatusOK)) + Expect(resp.Playlist).ToNot(BeNil()) + Expect(resp.Playlist.SongCount).To(Equal(int32(2))) + Expect(resp.Playlist.Entry).To(HaveLen(2)) + playlistID = resp.Playlist.Id + }) + + It("admin makes the playlist public", func() { + resp := doReqWithUser(adminWithLibs, "updatePlaylist", + "playlistId", playlistID, "public", "true") + + Expect(resp.Status).To(Equal(responses.StatusOK)) + }) + + It("non-admin user with lib1 only sees only lib1 tracks in the playlist", func() { + resp := doReqWithUser(userLib1Only, "getPlaylist", "id", playlistID) + + Expect(resp.Playlist).ToNot(BeNil()) + // The playlist has 2 songs total, but the non-admin user only has access to lib1 + Expect(resp.Playlist.Entry).To(HaveLen(1)) + Expect(resp.Playlist.Entry[0].Id).To(Equal(lib1SongID)) + }) + }) + + Describe("Cross-library shares", Ordered, func() { + var lib2AlbumID string + + BeforeAll(func() { + lib2Albums, err := ds.Album(ctx).GetAll(model.QueryOptions{ + Filters: squirrel.Eq{"album.library_id": lib2.ID}, + }) + Expect(err).ToNot(HaveOccurred()) + Expect(lib2Albums).ToNot(BeEmpty()) + lib2AlbumID = lib2Albums[0].ID + }) + + It("admin creates a share for a lib2 album", func() { + resp := doReqWithUser(adminWithLibs, "createShare", + "id", lib2AlbumID, "description", "Classical album share") + + Expect(resp.Status).To(Equal(responses.StatusOK)) + Expect(resp.Shares).ToNot(BeNil()) + Expect(resp.Shares.Share).To(HaveLen(1)) + + share := resp.Shares.Share[0] + Expect(share.Description).To(Equal("Classical album share")) + Expect(share.Entry).ToNot(BeEmpty()) + Expect(share.Entry[0].Title).To(Equal("Symphony No. 9")) + }) + }) + + Describe("Library access control", func() { + It("returns error when non-admin user requests inaccessible library", func() { + resp := doReqWithUser(userLib1Only, "getArtists", "musicFolderId", fmt.Sprintf("%d", lib2.ID)) + + Expect(resp.Status).To(Equal(responses.StatusFailed)) + Expect(resp.Error).ToNot(BeNil()) + }) + + It("non-admin user sees only their library's content without musicFolderId", func() { + resp := doReqWithUser(userLib1Only, "getArtists") + + Expect(resp.Status).To(Equal(responses.StatusOK)) + + var artistNames []string + for _, idx := range resp.Artist.Index { + for _, a := range idx.Artists { + artistNames = append(artistNames, a.Name) + } + } + Expect(artistNames).To(ContainElements("The Beatles", "Led Zeppelin", "Miles Davis")) + Expect(artistNames).ToNot(ContainElement("Ludwig van Beethoven")) + }) + }) +}) diff --git a/server/e2e/subsonic_multiuser_test.go b/server/e2e/subsonic_multiuser_test.go new file mode 100644 index 00000000..4a5c35a7 --- /dev/null +++ b/server/e2e/subsonic_multiuser_test.go @@ -0,0 +1,74 @@ +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("Multi-User Isolation", Ordered, func() { + var regularUser model.User + + BeforeAll(func() { + setupTestDB() + + regularUser = createUser("regular-1", "regular", "Regular User", false) + }) + + Describe("Admin-only endpoint restrictions", func() { + It("startScan fails for regular user", func() { + resp := doReqWithUser(regularUser, "startScan") + + Expect(resp.Status).To(Equal(responses.StatusFailed)) + Expect(resp.Error).ToNot(BeNil()) + }) + }) + + Describe("Browsing as regular user", func() { + It("regular user can browse the library", func() { + resp := doReqWithUser(regularUser, "getArtists") + + Expect(resp.Status).To(Equal(responses.StatusOK)) + Expect(resp.Artist).ToNot(BeNil()) + Expect(resp.Artist.Index).ToNot(BeEmpty()) + }) + + It("regular user can search", func() { + resp := doReqWithUser(regularUser, "search3", "query", "Beatles") + + Expect(resp.Status).To(Equal(responses.StatusOK)) + Expect(resp.SearchResult3).ToNot(BeNil()) + Expect(resp.SearchResult3.Artist).ToNot(BeEmpty()) + }) + }) + + Describe("getUser authorization", func() { + It("regular user can get their own info", func() { + resp := doReqWithUser(regularUser, "getUser", "username", "regular") + + Expect(resp.Status).To(Equal(responses.StatusOK)) + Expect(resp.User.Username).To(Equal("regular")) + Expect(resp.User.AdminRole).To(BeFalse()) + }) + + It("regular user cannot get another user's info", func() { + resp := doReqWithUser(regularUser, "getUser", "username", "admin") + + Expect(resp.Status).To(Equal(responses.StatusFailed)) + Expect(resp.Error).ToNot(BeNil()) + }) + }) + + Describe("getUsers for regular user", func() { + It("returns only the requesting user's info", func() { + resp := doReqWithUser(regularUser, "getUsers") + + 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("regular")) + Expect(resp.Users.User[0].AdminRole).To(BeFalse()) + }) + }) +}) diff --git a/server/e2e/subsonic_playlists_test.go b/server/e2e/subsonic_playlists_test.go new file mode 100644 index 00000000..6e9c2376 --- /dev/null +++ b/server/e2e/subsonic_playlists_test.go @@ -0,0 +1,110 @@ +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() { + resp := doReq("getPlaylists") + + 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() { + resp := doReq("createPlaylist", "name", "Test Playlist", "songId", songIDs[0], "songId", songIDs[1]) + + 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() { + resp := doReq("getPlaylist", "id", playlistID) + + 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() { + resp := doReq("createPlaylist", "songId", songIDs[0]) + + Expect(resp.Status).To(Equal(responses.StatusFailed)) + Expect(resp.Error).ToNot(BeNil()) + }) + + It("updatePlaylist can rename the playlist", func() { + resp := doReq("updatePlaylist", "playlistId", playlistID, "name", "Renamed Playlist") + + Expect(resp.Status).To(Equal(responses.StatusOK)) + + // Verify the rename + resp = doReq("getPlaylist", "id", playlistID) + + Expect(resp.Playlist.Name).To(Equal("Renamed Playlist")) + }) + + It("updatePlaylist can add songs", func() { + resp := doReq("updatePlaylist", "playlistId", playlistID, "songIdToAdd", songIDs[2]) + + Expect(resp.Status).To(Equal(responses.StatusOK)) + + // Verify the song was added + resp = doReq("getPlaylist", "id", playlistID) + + 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) + resp := doReq("updatePlaylist", "playlistId", playlistID, "songIndexToRemove", "0") + + Expect(resp.Status).To(Equal(responses.StatusOK)) + + // Verify the song was removed + resp = doReq("getPlaylist", "id", playlistID) + + Expect(resp.Playlist.SongCount).To(Equal(int32(2))) + Expect(resp.Playlist.Entry).To(HaveLen(2)) + }) + + It("deletePlaylist removes the playlist", func() { + resp := doReq("deletePlaylist", "id", playlistID) + + Expect(resp.Status).To(Equal(responses.StatusOK)) + }) + + It("getPlaylist on deleted playlist returns error", func() { + resp := doReq("getPlaylist", "id", playlistID) + + Expect(resp.Status).To(Equal(responses.StatusFailed)) + Expect(resp.Error).ToNot(BeNil()) + }) +}) diff --git a/server/e2e/subsonic_radio_test.go b/server/e2e/subsonic_radio_test.go new file mode 100644 index 00000000..ce64c31a --- /dev/null +++ b/server/e2e/subsonic_radio_test.go @@ -0,0 +1,80 @@ +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() { + resp := doReq("getInternetRadioStations") + + Expect(resp.Status).To(Equal(responses.StatusOK)) + Expect(resp.InternetRadioStations).ToNot(BeNil()) + Expect(resp.InternetRadioStations.Radios).To(BeEmpty()) + }) + + It("createInternetRadioStation adds a station", func() { + resp := doReq("createInternetRadioStation", + "streamUrl", "https://stream.example.com/radio", + "name", "Test Radio", + "homepageUrl", "https://example.com", + ) + + Expect(resp.Status).To(Equal(responses.StatusOK)) + }) + + It("getInternetRadioStations returns the created station", func() { + resp := doReq("getInternetRadioStations") + + 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() { + resp := doReq("updateInternetRadioStation", + "id", radioID, + "streamUrl", "https://stream.example.com/radio-v2", + "name", "Updated Radio", + "homepageUrl", "https://updated.example.com", + ) + + Expect(resp.Status).To(Equal(responses.StatusOK)) + + // Verify update + resp = doReq("getInternetRadioStations") + 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() { + resp := doReq("deleteInternetRadioStation", "id", radioID) + + Expect(resp.Status).To(Equal(responses.StatusOK)) + }) + + It("getInternetRadioStations returns empty after deletion", func() { + resp := doReq("getInternetRadioStations") + + 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 00000000..a6fb28bc --- /dev/null +++ b/server/e2e/subsonic_scan_test.go @@ -0,0 +1,39 @@ +package e2e + +import ( + "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() { + resp := doReq("getScanStatus") + + 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 := createUser("user-2", "regular", "Regular User", false) + + resp := doReqWithUser(regularUser, "startScan") + + Expect(resp.Status).To(Equal(responses.StatusFailed)) + Expect(resp.Error).ToNot(BeNil()) + }) + + It("startScan returns scan status response", func() { + resp := doReq("startScan") + + 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 00000000..3a7512fd --- /dev/null +++ b/server/e2e/subsonic_searching_test.go @@ -0,0 +1,140 @@ +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() { + resp := doReq("search2", "query", "Beatles") + + 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() { + resp := doReq("search2", "query", "Abbey Road") + + 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() { + resp := doReq("search2", "query", "Come Together") + + 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() { + resp := doReq("search2", "query", "Beatles", + "artistCount", "1", "albumCount", "1", "songCount", "1") + + 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 + resp1 := doReq("search2", "query", "Beatles", "songCount", "500") + allSongs := resp1.SearchResult2.Song + + if len(allSongs) > 1 { + // Get with offset to skip the first song + resp2 := doReq("search2", "query", "Beatles", "songOffset", "1", "songCount", "500") + + Expect(resp2.SearchResult2).ToNot(BeNil()) + Expect(len(resp2.SearchResult2.Song)).To(Equal(len(allSongs) - 1)) + } + }) + + It("returns empty results for non-matching query", func() { + resp := doReq("search2", "query", "ZZZZNONEXISTENT99999") + + 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() { + resp := doReq("search3", "query", "Beatles") + + 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 + resp := doReq("search3", "query", "Beatles") + + 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_sharing_test.go b/server/e2e/subsonic_sharing_test.go new file mode 100644 index 00000000..1a082ba0 --- /dev/null +++ b/server/e2e/subsonic_sharing_test.go @@ -0,0 +1,127 @@ +package e2e + +import ( + "github.com/Masterminds/squirrel" + "github.com/navidrome/navidrome/conf" + "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/server/subsonic/responses" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("Sharing Endpoints", Ordered, func() { + var shareID string + var albumID string + var songID string + + BeforeAll(func() { + conf.Server.EnableSharing = true + setupTestDB() + + albums, err := ds.Album(ctx).GetAll(model.QueryOptions{ + Filters: squirrel.Eq{"album.name": "Abbey Road"}, + }) + Expect(err).ToNot(HaveOccurred()) + Expect(albums).ToNot(BeEmpty()) + albumID = albums[0].ID + + 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 + }) + + It("getShares returns empty initially", func() { + resp := doReq("getShares") + + Expect(resp.Status).To(Equal(responses.StatusOK)) + Expect(resp.Shares).ToNot(BeNil()) + Expect(resp.Shares.Share).To(BeEmpty()) + }) + + It("createShare creates a share for an album", func() { + resp := doReq("createShare", "id", albumID, "description", "Check out this album") + + Expect(resp.Status).To(Equal(responses.StatusOK)) + Expect(resp.Shares).ToNot(BeNil()) + Expect(resp.Shares.Share).To(HaveLen(1)) + + share := resp.Shares.Share[0] + Expect(share.ID).ToNot(BeEmpty()) + Expect(share.Description).To(Equal("Check out this album")) + Expect(share.Username).To(Equal(adminUser.UserName)) + shareID = share.ID + }) + + It("getShares returns the created share", func() { + resp := doReq("getShares") + + Expect(resp.Status).To(Equal(responses.StatusOK)) + Expect(resp.Shares).ToNot(BeNil()) + Expect(resp.Shares.Share).To(HaveLen(1)) + + share := resp.Shares.Share[0] + Expect(share.ID).To(Equal(shareID)) + Expect(share.Description).To(Equal("Check out this album")) + Expect(share.Username).To(Equal(adminUser.UserName)) + Expect(share.Entry).ToNot(BeEmpty()) + }) + + It("updateShare modifies the description", func() { + resp := doReq("updateShare", "id", shareID, "description", "Updated description") + + Expect(resp.Status).To(Equal(responses.StatusOK)) + + // Verify update + resp = doReq("getShares") + Expect(resp.Shares.Share).To(HaveLen(1)) + Expect(resp.Shares.Share[0].Description).To(Equal("Updated description")) + }) + + It("deleteShare removes it", func() { + resp := doReq("deleteShare", "id", shareID) + + Expect(resp.Status).To(Equal(responses.StatusOK)) + }) + + It("getShares returns empty after deletion", func() { + resp := doReq("getShares") + + Expect(resp.Status).To(Equal(responses.StatusOK)) + Expect(resp.Shares).ToNot(BeNil()) + Expect(resp.Shares.Share).To(BeEmpty()) + }) + + It("createShare works with a song ID", func() { + resp := doReq("createShare", "id", songID, "description", "Great song") + + Expect(resp.Status).To(Equal(responses.StatusOK)) + Expect(resp.Shares).ToNot(BeNil()) + Expect(resp.Shares.Share).To(HaveLen(1)) + Expect(resp.Shares.Share[0].Description).To(Equal("Great song")) + Expect(resp.Shares.Share[0].Entry).To(HaveLen(1)) + }) + + It("createShare returns error when id parameter is missing", func() { + resp := doReq("createShare") + + Expect(resp.Status).To(Equal(responses.StatusFailed)) + Expect(resp.Error).ToNot(BeNil()) + }) + + It("updateShare returns error when id parameter is missing", func() { + resp := doReq("updateShare") + + Expect(resp.Status).To(Equal(responses.StatusFailed)) + Expect(resp.Error).ToNot(BeNil()) + }) + + It("deleteShare returns error when id parameter is missing", func() { + resp := doReq("deleteShare") + + Expect(resp.Status).To(Equal(responses.StatusFailed)) + Expect(resp.Error).ToNot(BeNil()) + }) +}) diff --git a/server/e2e/subsonic_system_test.go b/server/e2e/subsonic_system_test.go new file mode 100644 index 00000000..16078f70 --- /dev/null +++ b/server/e2e/subsonic_system_test.go @@ -0,0 +1,74 @@ +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() { + resp := doReq("ping") + + Expect(resp.Status).To(Equal(responses.StatusOK)) + }) + }) + + Describe("getLicense", func() { + It("returns a valid license", func() { + resp := doReq("getLicense") + + 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() { + resp := doReq("getOpenSubsonicExtensions") + + Expect(resp.Status).To(Equal(responses.StatusOK)) + Expect(resp.OpenSubsonicExtensions).ToNot(BeNil()) + Expect(*resp.OpenSubsonicExtensions).ToNot(BeEmpty()) + }) + + It("includes the transcodeOffset extension", func() { + resp := doReq("getOpenSubsonicExtensions") + + 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() { + resp := doReq("getOpenSubsonicExtensions") + + 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() { + resp := doReq("getOpenSubsonicExtensions") + + 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 00000000..849089f1 --- /dev/null +++ b/server/e2e/subsonic_users_test.go @@ -0,0 +1,49 @@ +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() { + resp := doReq("getUser", "username", adminUser.UserName) + + 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() { + resp := doReq("getUser", "username", "Admin") + + 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() { + resp := doReq("getUser", "username", "otheruser") + + Expect(resp.Status).To(Equal(responses.StatusFailed)) + Expect(resp.Error).ToNot(BeNil()) + }) + + It("getUsers returns list with current user only", func() { + resp := doReq("getUsers") + + 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()) + }) +}) diff --git a/tests/mock_data_store.go b/tests/mock_data_store.go index 6b696ee7..c4b0113f 100644 --- a/tests/mock_data_store.go +++ b/tests/mock_data_store.go @@ -37,215 +37,213 @@ type MockDataStore struct { } func (db *MockDataStore) Library(ctx context.Context) model.LibraryRepository { - if db.MockedLibrary == nil { - if db.RealDS != nil { - db.MockedLibrary = db.RealDS.Library(ctx) - } else { - db.MockedLibrary = &MockLibraryRepo{} - } + if db.MockedLibrary != nil { + return db.MockedLibrary } + if db.RealDS != nil { + return db.RealDS.Library(ctx) + } + db.MockedLibrary = &MockLibraryRepo{} return db.MockedLibrary } func (db *MockDataStore) Folder(ctx context.Context) model.FolderRepository { - if db.MockedFolder == nil { - if db.RealDS != nil { - db.MockedFolder = db.RealDS.Folder(ctx) - } else { - db.MockedFolder = struct{ model.FolderRepository }{} - } + if db.MockedFolder != nil { + return db.MockedFolder } + if db.RealDS != nil { + return db.RealDS.Folder(ctx) + } + db.MockedFolder = struct{ model.FolderRepository }{} return db.MockedFolder } func (db *MockDataStore) Tag(ctx context.Context) model.TagRepository { - if db.MockedTag == nil { - if db.RealDS != nil { - db.MockedTag = db.RealDS.Tag(ctx) - } else { - db.MockedTag = struct{ model.TagRepository }{} - } + if db.MockedTag != nil { + return db.MockedTag } + if db.RealDS != nil { + return db.RealDS.Tag(ctx) + } + db.MockedTag = struct{ model.TagRepository }{} return db.MockedTag } func (db *MockDataStore) Album(ctx context.Context) model.AlbumRepository { - if db.MockedAlbum == nil { - if db.RealDS != nil { - db.MockedAlbum = db.RealDS.Album(ctx) - } else { - db.MockedAlbum = CreateMockAlbumRepo() - } + if db.MockedAlbum != nil { + return db.MockedAlbum } + if db.RealDS != nil { + return db.RealDS.Album(ctx) + } + db.MockedAlbum = CreateMockAlbumRepo() return db.MockedAlbum } func (db *MockDataStore) Artist(ctx context.Context) model.ArtistRepository { - if db.MockedArtist == nil { - if db.RealDS != nil { - db.MockedArtist = db.RealDS.Artist(ctx) - } else { - db.MockedArtist = CreateMockArtistRepo() - } + if db.MockedArtist != nil { + return db.MockedArtist } + if db.RealDS != nil { + return db.RealDS.Artist(ctx) + } + db.MockedArtist = CreateMockArtistRepo() return db.MockedArtist } func (db *MockDataStore) MediaFile(ctx context.Context) model.MediaFileRepository { + if db.RealDS != nil && db.MockedMediaFile == nil { + return db.RealDS.MediaFile(ctx) + } db.repoMu.Lock() defer db.repoMu.Unlock() if db.MockedMediaFile == nil { - if db.RealDS != nil { - db.MockedMediaFile = db.RealDS.MediaFile(ctx) - } else { - db.MockedMediaFile = CreateMockMediaFileRepo() - } + db.MockedMediaFile = CreateMockMediaFileRepo() } return db.MockedMediaFile } func (db *MockDataStore) Genre(ctx context.Context) model.GenreRepository { - if db.MockedGenre == nil { - if db.RealDS != nil { - db.MockedGenre = db.RealDS.Genre(ctx) - } else { - db.MockedGenre = &MockedGenreRepo{} - } + if db.MockedGenre != nil { + return db.MockedGenre } + if db.RealDS != nil { + return db.RealDS.Genre(ctx) + } + db.MockedGenre = &MockedGenreRepo{} return db.MockedGenre } func (db *MockDataStore) Playlist(ctx context.Context) model.PlaylistRepository { - if db.MockedPlaylist == nil { - if db.RealDS != nil { - db.MockedPlaylist = db.RealDS.Playlist(ctx) - } else { - db.MockedPlaylist = &MockPlaylistRepo{} - } + if db.MockedPlaylist != nil { + return db.MockedPlaylist } + if db.RealDS != nil { + return db.RealDS.Playlist(ctx) + } + db.MockedPlaylist = &MockPlaylistRepo{} return db.MockedPlaylist } func (db *MockDataStore) PlayQueue(ctx context.Context) model.PlayQueueRepository { - if db.MockedPlayQueue == nil { - if db.RealDS != nil { - db.MockedPlayQueue = db.RealDS.PlayQueue(ctx) - } else { - db.MockedPlayQueue = &MockPlayQueueRepo{} - } + if db.MockedPlayQueue != nil { + return db.MockedPlayQueue } + if db.RealDS != nil { + return db.RealDS.PlayQueue(ctx) + } + db.MockedPlayQueue = &MockPlayQueueRepo{} return db.MockedPlayQueue } func (db *MockDataStore) UserProps(ctx context.Context) model.UserPropsRepository { - if db.MockedUserProps == nil { - if db.RealDS != nil { - db.MockedUserProps = db.RealDS.UserProps(ctx) - } else { - db.MockedUserProps = &MockedUserPropsRepo{} - } + if db.MockedUserProps != nil { + return db.MockedUserProps } + if db.RealDS != nil { + return db.RealDS.UserProps(ctx) + } + db.MockedUserProps = &MockedUserPropsRepo{} return db.MockedUserProps } func (db *MockDataStore) Property(ctx context.Context) model.PropertyRepository { - if db.MockedProperty == nil { - if db.RealDS != nil { - db.MockedProperty = db.RealDS.Property(ctx) - } else { - db.MockedProperty = &MockedPropertyRepo{} - } + if db.MockedProperty != nil { + return db.MockedProperty } + if db.RealDS != nil { + return db.RealDS.Property(ctx) + } + db.MockedProperty = &MockedPropertyRepo{} return db.MockedProperty } func (db *MockDataStore) Share(ctx context.Context) model.ShareRepository { - if db.MockedShare == nil { - if db.RealDS != nil { - db.MockedShare = db.RealDS.Share(ctx) - } else { - db.MockedShare = &MockShareRepo{} - } + if db.MockedShare != nil { + return db.MockedShare } + if db.RealDS != nil { + return db.RealDS.Share(ctx) + } + db.MockedShare = &MockShareRepo{} return db.MockedShare } func (db *MockDataStore) User(ctx context.Context) model.UserRepository { - if db.MockedUser == nil { - if db.RealDS != nil { - db.MockedUser = db.RealDS.User(ctx) - } else { - db.MockedUser = CreateMockUserRepo() - } + if db.MockedUser != nil { + return db.MockedUser } + if db.RealDS != nil { + return db.RealDS.User(ctx) + } + db.MockedUser = CreateMockUserRepo() return db.MockedUser } func (db *MockDataStore) Transcoding(ctx context.Context) model.TranscodingRepository { - if db.MockedTranscoding == nil { - if db.RealDS != nil { - db.MockedTranscoding = db.RealDS.Transcoding(ctx) - } else { - db.MockedTranscoding = struct{ model.TranscodingRepository }{} - } + if db.MockedTranscoding != nil { + return db.MockedTranscoding } + if db.RealDS != nil { + return db.RealDS.Transcoding(ctx) + } + db.MockedTranscoding = struct{ model.TranscodingRepository }{} return db.MockedTranscoding } func (db *MockDataStore) Player(ctx context.Context) model.PlayerRepository { - if db.MockedPlayer == nil { - if db.RealDS != nil { - db.MockedPlayer = db.RealDS.Player(ctx) - } else { - db.MockedPlayer = struct{ model.PlayerRepository }{} - } + if db.MockedPlayer != nil { + return db.MockedPlayer } + if db.RealDS != nil { + return db.RealDS.Player(ctx) + } + db.MockedPlayer = struct{ model.PlayerRepository }{} return db.MockedPlayer } func (db *MockDataStore) ScrobbleBuffer(ctx context.Context) model.ScrobbleBufferRepository { + if db.RealDS != nil && db.MockedScrobbleBuffer == nil { + return db.RealDS.ScrobbleBuffer(ctx) + } db.scrobbleBufferMu.Lock() defer db.scrobbleBufferMu.Unlock() if db.MockedScrobbleBuffer == nil { - if db.RealDS != nil { - db.MockedScrobbleBuffer = db.RealDS.ScrobbleBuffer(ctx) - } else { - db.MockedScrobbleBuffer = &MockedScrobbleBufferRepo{} - } + db.MockedScrobbleBuffer = &MockedScrobbleBufferRepo{} } return db.MockedScrobbleBuffer } func (db *MockDataStore) Scrobble(ctx context.Context) model.ScrobbleRepository { - if db.MockedScrobble == nil { - if db.RealDS != nil { - db.MockedScrobble = db.RealDS.Scrobble(ctx) - } else { - db.MockedScrobble = &MockScrobbleRepo{ctx: ctx} - } + if db.MockedScrobble != nil { + return db.MockedScrobble } + if db.RealDS != nil { + return db.RealDS.Scrobble(ctx) + } + db.MockedScrobble = &MockScrobbleRepo{ctx: ctx} return db.MockedScrobble } func (db *MockDataStore) Radio(ctx context.Context) model.RadioRepository { - if db.MockedRadio == nil { - if db.RealDS != nil { - db.MockedRadio = db.RealDS.Radio(ctx) - } else { - db.MockedRadio = CreateMockedRadioRepo() - } + if db.MockedRadio != nil { + return db.MockedRadio } + if db.RealDS != nil { + return db.RealDS.Radio(ctx) + } + db.MockedRadio = CreateMockedRadioRepo() return db.MockedRadio } func (db *MockDataStore) Plugin(ctx context.Context) model.PluginRepository { - if db.MockedPlugin == nil { - if db.RealDS != nil { - db.MockedPlugin = db.RealDS.Plugin(ctx) - } else { - db.MockedPlugin = CreateMockPluginRepo() - } + if db.MockedPlugin != nil { + return db.MockedPlugin } + if db.RealDS != nil { + return db.RealDS.Plugin(ctx) + } + db.MockedPlugin = CreateMockPluginRepo() return db.MockedPlugin }