Compare commits

..

5 Commits

Author SHA1 Message Date
Deluan
a6a3ef6ea5 test(e2e): tests are fast, no need to skip on -short
Signed-off-by: Deluan <deluan@navidrome.org>
2026-02-07 22:34:08 -05:00
Deluan
f67324278e test(e2e): add tests for multi-library support and user access control
Signed-off-by: Deluan <deluan@navidrome.org>
2026-02-07 22:34:08 -05:00
Deluan
fc8b7283f0 test(e2e): add tests for album sharing and user isolation scenarios
Signed-off-by: Deluan <deluan@navidrome.org>
2026-02-07 22:34:08 -05:00
Deluan
777638e84d fix(e2e): improve database handling and snapshot restoration in tests
Signed-off-by: Deluan <deluan@navidrome.org>
2026-02-07 22:34:08 -05:00
Deluan
ebe3c1d06c test(e2e): add comprehensive tests for Subsonic API endpoints
Signed-off-by: Deluan <deluan@navidrome.org>
2026-02-07 22:34:08 -05:00
15 changed files with 2791 additions and 0 deletions

View File

@@ -0,0 +1,354 @@
package e2e
import (
"context"
"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/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()},
})
}
// 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...)
}
// newRawReqWithUser creates a ResponseRecorder + authenticated request for the given user.
func newRawReqWithUser(user model.User, endpoint string, params ...string) (*httptest.ResponseRecorder, *http.Request) {
return httptest.NewRecorder(), newReqWithUser(user, 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
}
// 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)
// Pre-populate repository cache with a valid context. The MockDataStore caches
// repositories on first access; without this, the first access may happen inside
// an errgroup (e.g., searchAll) whose context is canceled after Wait(), causing
// subsequent calls to silently fail.
ds.MediaFile(ctx)
ds.Album(ctx)
ds.Artist(ctx)
// 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())
}

View File

@@ -0,0 +1,350 @@
package e2e
import (
"net/http/httptest"
"github.com/Masterminds/squirrel"
"github.com/navidrome/navidrome/model"
"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())
})
It("type=frequent returns empty when no albums have been played", func() {
w, r := newRawReq("getAlbumList", "type", "frequent")
resp, err := router.GetAlbumList(w, r)
Expect(err).ToNot(HaveOccurred())
Expect(resp.AlbumList).ToNot(BeNil())
Expect(resp.AlbumList.Album).To(BeEmpty())
})
It("type=recent returns empty when no albums have been played", func() {
w, r := newRawReq("getAlbumList", "type", "recent")
resp, err := router.GetAlbumList(w, r)
Expect(err).ToNot(HaveOccurred())
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())
r := newReq("star", "albumId", albums[0].ID)
_, err = router.Star(r)
Expect(err).ToNot(HaveOccurred())
})
It("type=starred returns only starred albums", func() {
w, r := newRawReq("getAlbumList", "type", "starred")
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("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())
r := newReq("setRating", "id", albums[0].ID, "rating", "5")
_, err = router.SetRating(r)
Expect(err).ToNot(HaveOccurred())
})
It("type=highest returns only rated albums", func() {
w, r := newRawReq("getAlbumList", "type", "highest")
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"))
})
})
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())
})
})
})

View File

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

View File

@@ -0,0 +1,522 @@
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("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
r := newReq("getAlbumInfo2", "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
})
})
})

View File

@@ -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())
})
})
})

View File

@@ -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())
})
})
})

View File

@@ -0,0 +1,312 @@
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() {
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() {
r := newReqWithUser(adminWithLibs, "getMusicFolders")
resp, err := router.GetMusicFolders(r)
Expect(err).ToNot(HaveOccurred())
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() {
r := newReqWithUser(adminWithLibs, "getArtists", "musicFolderId", fmt.Sprintf("%d", lib.ID))
resp, err := router.GetArtists(r)
Expect(err).ToNot(HaveOccurred())
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() {
r := newReqWithUser(adminWithLibs, "getArtists", "musicFolderId", fmt.Sprintf("%d", lib2.ID))
resp, err := router.GetArtists(r)
Expect(err).ToNot(HaveOccurred())
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() {
r := newReqWithUser(adminWithLibs, "getArtists")
resp, err := router.GetArtists(r)
Expect(err).ToNot(HaveOccurred())
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() {
w, r := newRawReqWithUser(adminWithLibs, "getAlbumList", "type", "alphabeticalByName", "musicFolderId", fmt.Sprintf("%d", lib.ID))
resp, err := router.GetAlbumList(w, r)
Expect(err).ToNot(HaveOccurred())
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() {
w, r := newRawReqWithUser(adminWithLibs, "getAlbumList", "type", "alphabeticalByName", "musicFolderId", fmt.Sprintf("%d", lib2.ID))
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("Symphony No. 9"))
})
})
Describe("search3 - library filtering", func() {
It("does not find lib1 content when searching in lib2 only", func() {
r := newReqWithUser(adminWithLibs, "search3", "query", "Beatles", "musicFolderId", fmt.Sprintf("%d", lib2.ID))
resp, err := router.Search3(r)
Expect(err).ToNot(HaveOccurred())
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() {
r := newReqWithUser(adminWithLibs, "search3", "query", "Beethoven", "musicFolderId", fmt.Sprintf("%d", lib2.ID))
resp, err := router.Search3(r)
Expect(err).ToNot(HaveOccurred())
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() {
r := newReqWithUser(adminWithLibs, "createPlaylist",
"name", "Cross-Library Playlist", "songId", lib1SongID, "songId", lib2SongID)
resp, err := router.CreatePlaylist(r)
Expect(err).ToNot(HaveOccurred())
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() {
r := newReqWithUser(adminWithLibs, "updatePlaylist",
"playlistId", playlistID, "public", "true")
resp, err := router.UpdatePlaylist(r)
Expect(err).ToNot(HaveOccurred())
Expect(resp.Status).To(Equal(responses.StatusOK))
})
It("non-admin user with lib1 only sees only lib1 tracks in the playlist", func() {
// Reset the cached playlist repo so it's recreated with the non-admin user's context.
// The MockDataStore caches repos on first access; resetting forces a new repo
// whose applyLibraryFilter uses the non-admin user's library access.
ds.MockedPlaylist = nil
r := newReqWithUser(userLib1Only, "getPlaylist", "id", playlistID)
resp, err := router.GetPlaylist(r)
Expect(err).ToNot(HaveOccurred())
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() {
conf.Server.EnableSharing = true
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() {
r := newReqWithUser(adminWithLibs, "createShare",
"id", lib2AlbumID, "description", "Classical album share")
resp, err := router.CreateShare(r)
Expect(err).ToNot(HaveOccurred())
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() {
r := newReqWithUser(userLib1Only, "getArtists", "musicFolderId", fmt.Sprintf("%d", lib2.ID))
_, err := router.GetArtists(r)
Expect(err).To(HaveOccurred())
Expect(err.Error()).To(ContainSubstring("not found"))
})
It("non-admin user sees only their library's content without musicFolderId", func() {
r := newReqWithUser(userLib1Only, "getArtists")
resp, err := router.GetArtists(r)
Expect(err).ToNot(HaveOccurred())
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"))
})
})
})

View File

@@ -0,0 +1,97 @@
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()
// Create a regular (non-admin) user
regularUser = model.User{
ID: "regular-1",
UserName: "regular",
Name: "Regular User",
IsAdmin: false,
NewPassword: "password",
}
Expect(ds.User(ctx).Put(&regularUser)).To(Succeed())
Expect(ds.User(ctx).SetUserLibraries(regularUser.ID, []int{lib.ID})).To(Succeed())
loadedUser, err := ds.User(ctx).FindByUsername(regularUser.UserName)
Expect(err).ToNot(HaveOccurred())
regularUser.Libraries = loadedUser.Libraries
})
Describe("Admin-only endpoint restrictions", func() {
It("startScan fails for regular user", func() {
r := newReqWithUser(regularUser, "startScan")
_, err := router.StartScan(r)
Expect(err).To(HaveOccurred())
Expect(err.Error()).To(ContainSubstring("not authorized"))
})
})
Describe("Browsing as regular user", func() {
It("regular user can browse the library", func() {
r := newReqWithUser(regularUser, "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("regular user can search", func() {
r := newReqWithUser(regularUser, "search3", "query", "Beatles")
resp, err := router.Search3(r)
Expect(err).ToNot(HaveOccurred())
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() {
r := newReqWithUser(regularUser, "getUser", "username", "regular")
resp, err := router.GetUser(r)
Expect(err).ToNot(HaveOccurred())
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() {
r := newReqWithUser(regularUser, "getUser", "username", "admin")
_, err := router.GetUser(r)
Expect(err).To(HaveOccurred())
Expect(err.Error()).To(ContainSubstring("not authorized"))
})
})
Describe("getUsers for regular user", func() {
It("returns only the requesting user's info", func() {
r := newReqWithUser(regularUser, "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("regular"))
Expect(resp.Users.User[0].AdminRole).To(BeFalse())
})
})
})

View File

@@ -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())
})
})

View File

@@ -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())
})
})

View File

@@ -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(&regularUserWithPass)).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())
})
})

View File

@@ -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())
}
})
})
})

View File

@@ -0,0 +1,143 @@
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() {
setupTestDB()
conf.Server.EnableSharing = true
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() {
r := newReq("getShares")
resp, err := router.GetShares(r)
Expect(err).ToNot(HaveOccurred())
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() {
r := newReq("createShare", "id", albumID, "description", "Check out this album")
resp, err := router.CreateShare(r)
Expect(err).ToNot(HaveOccurred())
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() {
r := newReq("getShares")
resp, err := router.GetShares(r)
Expect(err).ToNot(HaveOccurred())
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() {
r := newReq("updateShare", "id", shareID, "description", "Updated description")
resp, err := router.UpdateShare(r)
Expect(err).ToNot(HaveOccurred())
Expect(resp.Status).To(Equal(responses.StatusOK))
// Verify update
r = newReq("getShares")
resp, err = router.GetShares(r)
Expect(err).ToNot(HaveOccurred())
Expect(resp.Shares.Share).To(HaveLen(1))
Expect(resp.Shares.Share[0].Description).To(Equal("Updated description"))
})
It("deleteShare removes it", func() {
r := newReq("deleteShare", "id", shareID)
resp, err := router.DeleteShare(r)
Expect(err).ToNot(HaveOccurred())
Expect(resp.Status).To(Equal(responses.StatusOK))
})
It("getShares returns empty after deletion", func() {
r := newReq("getShares")
resp, err := router.GetShares(r)
Expect(err).ToNot(HaveOccurred())
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() {
r := newReq("createShare", "id", songID, "description", "Great song")
resp, err := router.CreateShare(r)
Expect(err).ToNot(HaveOccurred())
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() {
r := newReq("createShare")
_, err := router.CreateShare(r)
Expect(err).To(HaveOccurred())
})
It("updateShare returns error when id parameter is missing", func() {
r := newReq("updateShare")
_, err := router.UpdateShare(r)
Expect(err).To(HaveOccurred())
})
It("deleteShare returns error when id parameter is missing", func() {
r := newReq("deleteShare")
_, err := router.DeleteShare(r)
Expect(err).To(HaveOccurred())
})
})

View File

@@ -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"))
})
})
})

View File

@@ -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())
})
})