test(subsonic): add comprehensive e2e test suite for Subsonic API (#5003)

* test(e2e): add comprehensive tests for Subsonic API endpoints

Signed-off-by: Deluan <deluan@navidrome.org>

* fix(e2e): improve database handling and snapshot restoration in tests

Signed-off-by: Deluan <deluan@navidrome.org>

* test(e2e): add tests for album sharing and user isolation scenarios

Signed-off-by: Deluan <deluan@navidrome.org>

* test(e2e): add tests for multi-library support and user access control

Signed-off-by: Deluan <deluan@navidrome.org>

* test(e2e): tests are fast, no need to skip on -short

Signed-off-by: Deluan <deluan@navidrome.org>

* address gemini comments

Signed-off-by: Deluan <deluan@navidrome.org>

* fix(tests): prevent MockDataStore from caching repos with stale context

When RealDS is set, MockDataStore previously cached repository instances
on first access, binding them to the initial caller's context. This meant
repos created with an admin context would skip library filtering for all
subsequent non-admin calls, silently masking access control bugs. Changed
MockDataStore to delegate to RealDS on every call without caching, so each
caller gets a fresh repo with the correct context. Removed the pre-warm
calls in e2e setupTestDB that were working around the old caching behavior.

* test(e2e): route subsonic tests through full HTTP middleware stack

Replace direct router method calls with full HTTP round-trips via
router.ServeHTTP(w, r) across all 15 e2e test files. Tests now exercise
the complete chi middleware chain including postFormToQueryParams,
checkRequiredParameters, authenticate, UpdateLastAccessMiddleware,
getPlayer, and sendResponse/sendError serialization.

New helpers (doReq, doReqWithUser, doRawReq, buildReq, parseJSONResponse)
use plaintext password auth and JSON response format. Old helpers that
injected context directly (newReq, newReqWithUser, newRawReq) are removed.
Sharing tests now set conf.Server.EnableSharing before router creation to
ensure sharing routes are registered.

---------

Signed-off-by: Deluan <deluan@navidrome.org>
This commit is contained in:
Deluan Quintão
2026-02-09 08:24:37 -05:00
committed by GitHub
parent c80ef8ae41
commit 8319905d2c
16 changed files with 2602 additions and 112 deletions
+384
View File
@@ -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())
}
+295
View File
@@ -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())
})
})
})
+142
View File
@@ -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)))
})
})
})
+464
View File
@@ -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
})
})
})
@@ -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())
})
})
})
@@ -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())
})
})
})
+279
View File
@@ -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"))
})
})
})
+74
View File
@@ -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())
})
})
})
+110
View File
@@ -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())
})
})
+80
View File
@@ -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())
})
})
+39
View File
@@ -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())
})
})
+140
View File
@@ -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())
}
})
})
})
+127
View File
@@ -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())
})
})
+74
View File
@@ -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"))
})
})
})
+49
View File
@@ -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())
})
})