mirror of
https://github.com/navidrome/navidrome.git
synced 2026-08-31 07:30:32 +00:00
423 lines
15 KiB
Go
423 lines
15 KiB
Go
// Package e2e provides end-to-end integration tests for the Navidrome Jellyfin API.
|
|
//
|
|
// These tests exercise the full HTTP request/response cycle through the Jellyfin API router,
|
|
// using a real SQLite database and real repository implementations while stubbing out external
|
|
// services (artwork, streaming, transcoding) with spy/noop implementations.
|
|
//
|
|
// The harness mirrors server/subsonic/e2e (the Subsonic suite): BeforeSuite creates a temporary SQLite
|
|
// database, seeds two users (admin + regular) and one library backed by a fake in-memory
|
|
// filesystem, runs the scanner, and snapshots the golden DB. Each top-level Describe restores
|
|
// that snapshot and builds a fresh jellyfin.Router.
|
|
//
|
|
// # Seeded library (see buildTestFS)
|
|
//
|
|
// Rock/The Beatles/Abbey Road/ 01 Something (1969), 02 Come Together (1969)
|
|
// Rock/The Beatles/Help!/ 01 Help! (1965)
|
|
// Rock/Led Zeppelin/IV/ 01 Stairway To Heaven (1971)
|
|
// Jazz/Miles Davis/Kind of Blue/01 So What (1959)
|
|
// Pop/Solo Artist/Singles/ 01 Standalone Track (2020), 02 Duet (artist "Featured Guest")
|
|
//
|
|
// Totals: 7 songs, 5 albums, 4 album artists (+ 1 performer-only "Featured Guest" = 5 artists),
|
|
// 3 genres (Rock=4, Jazz=1, Pop=2).
|
|
package e2e
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"io"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"testing"
|
|
"testing/fstest"
|
|
|
|
"github.com/navidrome/navidrome/conf"
|
|
"github.com/navidrome/navidrome/conf/configtest"
|
|
"github.com/navidrome/navidrome/core"
|
|
"github.com/navidrome/navidrome/core/agents"
|
|
"github.com/navidrome/navidrome/core/artwork"
|
|
"github.com/navidrome/navidrome/core/auth"
|
|
"github.com/navidrome/navidrome/core/external"
|
|
"github.com/navidrome/navidrome/core/lyrics"
|
|
"github.com/navidrome/navidrome/core/matcher"
|
|
"github.com/navidrome/navidrome/core/playlists"
|
|
"github.com/navidrome/navidrome/core/scrobbler"
|
|
"github.com/navidrome/navidrome/core/sonic"
|
|
"github.com/navidrome/navidrome/core/storage/storagetest"
|
|
"github.com/navidrome/navidrome/core/stream"
|
|
"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/server/events"
|
|
"github.com/navidrome/navidrome/server/jellyfin"
|
|
"github.com/navidrome/navidrome/server/jellyfin/dto"
|
|
"github.com/navidrome/navidrome/tests"
|
|
"github.com/navidrome/navidrome/tests/harness"
|
|
. "github.com/onsi/ginkgo/v2"
|
|
. "github.com/onsi/gomega"
|
|
)
|
|
|
|
func TestJellyfinE2E(t *testing.T) {
|
|
tests.Init(t, false)
|
|
defer db.Close(t.Context())
|
|
log.SetLevel(log.LevelFatal)
|
|
RegisterFailHandler(Fail)
|
|
RunSpecs(t, "Jellyfin API E2E Suite")
|
|
}
|
|
|
|
// Easy aliases for the storagetest package
|
|
type _t = map[string]any
|
|
|
|
var (
|
|
template = storagetest.Template
|
|
track = storagetest.Track
|
|
)
|
|
|
|
// Shared test state
|
|
var (
|
|
ctx context.Context
|
|
ds *tests.MockDataStore
|
|
router http.Handler
|
|
streamerSpy *harness.SpyStreamer
|
|
artworkSpy *spyArtwork
|
|
providerFake *fakeExternalProvider
|
|
sonicProviderFake *fakeSonicProvider
|
|
goldenDB *harness.DB
|
|
dataFolder string
|
|
|
|
adminUser = model.User{
|
|
ID: "admin-1",
|
|
UserName: "admin",
|
|
Name: "Admin User",
|
|
IsAdmin: true,
|
|
}
|
|
|
|
regularUser = model.User{
|
|
ID: "regular-1",
|
|
UserName: "regular",
|
|
Name: "Regular User",
|
|
IsAdmin: false,
|
|
}
|
|
)
|
|
|
|
// buildTestFS creates the seeded test filesystem (see package doc for totals).
|
|
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", "label": "Columbia"})
|
|
singles := template(_t{"albumartist": "Solo Artist", "artist": "Solo Artist", "album": "Singles", "year": 2020, "genre": "Pop"})
|
|
|
|
return harness.CreateFS(fstest.MapFS{
|
|
// Track numbers are deliberately reversed vs. alphabetical title order (Something=1,
|
|
// Come Together=2) so tests can tell track-order sorting apart from title sorting.
|
|
"Rock/The Beatles/Abbey Road/01 - Something.mp3": abbeyRoad(track(1, "Something")),
|
|
"Rock/The Beatles/Abbey Road/02 - Come Together.mp3": abbeyRoad(track(2, "Come Together")),
|
|
"Rock/The Beatles/Help!/01 - Help.mp3": help(track(1, "Help!")),
|
|
"Rock/Led Zeppelin/IV/01 - Stairway To Heaven.mp3": ledZepIV(track(1, "Stairway To Heaven", _t{
|
|
"lyrics:eng": "[00:01.00]There's a lady who's sure\n[00:05.50]All that glitters is gold",
|
|
"replaygain_track_gain": "-3.50 dB",
|
|
"replaygain_album_gain": "-4.25 dB",
|
|
})),
|
|
"Jazz/Miles Davis/Kind of Blue/01 - So What.mp3": kindOfBlue(track(1, "So What")),
|
|
"Pop/Solo Artist/Singles/01 - Standalone Track.mp3": singles(track(1, "Standalone Track")),
|
|
// "Featured Guest" is the track artist here (album artist stays "Solo Artist"), so it's a
|
|
// performer but not an album artist — lets tests tell /Artists from /Artists/AlbumArtists.
|
|
"Pop/Solo Artist/Singles/02 - Duet.mp3": singles(track(2, "Duet", _t{"artist": "Featured Guest"})),
|
|
})
|
|
}
|
|
|
|
// --- Request helpers ---
|
|
|
|
// jReq performs a full HTTP round-trip as the given user (token auth) and returns the recorder.
|
|
func jReq(user model.User, method, path, body string) *httptest.ResponseRecorder {
|
|
w := httptest.NewRecorder()
|
|
var reader io.Reader
|
|
if body != "" {
|
|
reader = strings.NewReader(body)
|
|
}
|
|
r := httptest.NewRequest(method, path, reader)
|
|
token, err := auth.CreateToken(&user)
|
|
Expect(err).ToNot(HaveOccurred())
|
|
r.Header.Set("X-Emby-Token", token)
|
|
r.Header.Set("X-Emby-Authorization", `MediaBrowser Client="e2e", Device="test", DeviceId="e2e-device", Version="1.0"`)
|
|
if body != "" {
|
|
r.Header.Set("Content-Type", "application/json")
|
|
}
|
|
router.ServeHTTP(w, r)
|
|
return w
|
|
}
|
|
|
|
// rawReq performs a request with no authentication (for public routes).
|
|
func rawReq(method, path, body string) *httptest.ResponseRecorder {
|
|
w := httptest.NewRecorder()
|
|
var reader io.Reader
|
|
if body != "" {
|
|
reader = strings.NewReader(body)
|
|
}
|
|
r := httptest.NewRequest(method, path, reader)
|
|
if body != "" {
|
|
r.Header.Set("Content-Type", "application/json")
|
|
}
|
|
router.ServeHTTP(w, r)
|
|
return w
|
|
}
|
|
|
|
func get(path string) *httptest.ResponseRecorder { return jReq(adminUser, "GET", path, "") }
|
|
func getAs(u model.User, path string) *httptest.ResponseRecorder { return jReq(u, "GET", path, "") }
|
|
func post(path, body string) *httptest.ResponseRecorder { return jReq(adminUser, "POST", path, body) }
|
|
func postAs(u model.User, path, body string) *httptest.ResponseRecorder {
|
|
return jReq(u, "POST", path, body)
|
|
}
|
|
func del(path string) *httptest.ResponseRecorder { return jReq(adminUser, "DELETE", path, "") }
|
|
func delAs(u model.User, path string) *httptest.ResponseRecorder { return jReq(u, "DELETE", path, "") }
|
|
|
|
// upload performs an authenticated POST with a custom Content-Type and raw body (image upload).
|
|
func upload(user model.User, path, contentType string, body []byte) *httptest.ResponseRecorder {
|
|
w := httptest.NewRecorder()
|
|
r := httptest.NewRequest("POST", path, bytes.NewReader(body))
|
|
token, err := auth.CreateToken(&user)
|
|
Expect(err).ToNot(HaveOccurred())
|
|
r.Header.Set("X-Emby-Token", token)
|
|
r.Header.Set("X-Emby-Authorization", `MediaBrowser Client="e2e", Device="test", DeviceId="e2e-device", Version="1.0"`)
|
|
r.Header.Set("Content-Type", contentType)
|
|
router.ServeHTTP(w, r)
|
|
return w
|
|
}
|
|
|
|
// parseInto asserts a 200 and unmarshals the JSON body into target.
|
|
func parseInto(w *httptest.ResponseRecorder, target any) {
|
|
Expect(w.Code).To(Equal(http.StatusOK), "body: %s", w.Body.String())
|
|
Expect(json.Unmarshal(w.Body.Bytes(), target)).To(Succeed())
|
|
}
|
|
|
|
// queryResult asserts a 200 and returns the parsed QueryResult.
|
|
func queryResult(w *httptest.ResponseRecorder) dto.QueryResult {
|
|
var q dto.QueryResult
|
|
parseInto(w, &q)
|
|
return q
|
|
}
|
|
|
|
// createPlaylist creates a playlist as admin (encodedIds are the Jellyfin-encoded item ids a
|
|
// client would send) and returns its decoded Navidrome id.
|
|
func createPlaylist(name string, encodedIds []string) string {
|
|
return createPlaylistAs(adminUser, name, encodedIds...)
|
|
}
|
|
|
|
// createPlaylistAs creates a playlist owned by the given user and returns its decoded id.
|
|
func createPlaylistAs(user model.User, name string, encodedIds ...string) string {
|
|
if encodedIds == nil {
|
|
encodedIds = []string{}
|
|
}
|
|
body, err := json.Marshal(map[string]any{"Name": name, "Ids": encodedIds})
|
|
Expect(err).ToNot(HaveOccurred())
|
|
var res map[string]string
|
|
parseInto(postAs(user, "/Playlists", string(body)), &res)
|
|
Expect(res["Id"]).ToNot(BeEmpty())
|
|
return dto.DecodeID(res["Id"])
|
|
}
|
|
|
|
// --- Seeded-id lookup helpers (return Navidrome ids; wrap with enc() for URLs) ---
|
|
|
|
func enc(id string) string { return dto.EncodeID(id) }
|
|
|
|
// The seeded library is tiny, so the id lookups fetch-all and match by name in Go rather than
|
|
// guessing repository filter column names.
|
|
|
|
func albumID(name string) string {
|
|
albums, err := ds.Album(ctx).GetAll()
|
|
Expect(err).ToNot(HaveOccurred())
|
|
for _, a := range albums {
|
|
if a.Name == name {
|
|
return a.ID
|
|
}
|
|
}
|
|
Fail("album not found: " + name)
|
|
return ""
|
|
}
|
|
|
|
func songID(title string) string {
|
|
mfs, err := ds.MediaFile(ctx).GetAll()
|
|
Expect(err).ToNot(HaveOccurred())
|
|
for _, mf := range mfs {
|
|
if mf.Title == title {
|
|
return mf.ID
|
|
}
|
|
}
|
|
Fail("song not found: " + title)
|
|
return ""
|
|
}
|
|
|
|
func artistID(name string) string {
|
|
artists, err := ds.Artist(ctx).GetAll()
|
|
Expect(err).ToNot(HaveOccurred())
|
|
for _, a := range artists {
|
|
if a.Name == name {
|
|
return a.ID
|
|
}
|
|
}
|
|
Fail("artist not found: " + name)
|
|
return ""
|
|
}
|
|
|
|
func genreID(name string) string {
|
|
genres, err := ds.Genre(ctx).GetAll()
|
|
Expect(err).ToNot(HaveOccurred())
|
|
for _, g := range genres {
|
|
if g.Name == name {
|
|
return g.ID
|
|
}
|
|
}
|
|
Fail("genre not found: " + name)
|
|
return ""
|
|
}
|
|
|
|
// --- Suite lifecycle ---
|
|
|
|
var _ = BeforeSuite(func() {
|
|
ctx = request.WithUser(GinkgoT().Context(), adminUser)
|
|
dataFolder = filepath.Join(GinkgoT().TempDir(), "data")
|
|
Expect(os.MkdirAll(dataFolder, 0o755)).To(Succeed())
|
|
|
|
conf.Server.MusicFolder = "fake:///music"
|
|
conf.Server.DataFolder = conf.NewDir(dataFolder)
|
|
conf.Server.DevExternalScanner = false
|
|
|
|
buildTestFS()
|
|
goldenDB = harness.SetupDB(ctx, &adminUser, ®ularUser)
|
|
ctx = request.WithUser(GinkgoT().Context(), adminUser)
|
|
})
|
|
|
|
var _ = AfterSuite(func() {
|
|
db.Close(ctx)
|
|
})
|
|
|
|
// setupTestDB restores the golden snapshot and builds a fresh jellyfin.Router. Call from
|
|
// BeforeEach in each test container.
|
|
func setupTestDB() {
|
|
ctx = request.WithUser(GinkgoT().Context(), adminUser)
|
|
|
|
DeferCleanup(configtest.SetupConfig())
|
|
conf.Server.MusicFolder = "fake:///music"
|
|
conf.Server.DataFolder = conf.NewDir(dataFolder)
|
|
conf.Server.DevExternalScanner = false
|
|
conf.Server.DevEnableMediaFileProbe = false
|
|
|
|
goldenDB.Restore()
|
|
|
|
ds = &tests.MockDataStore{RealDS: persistence.New(db.Db())}
|
|
auth.Init(ds)
|
|
|
|
streamerSpy = &harness.SpyStreamer{}
|
|
artworkSpy = &spyArtwork{}
|
|
providerFake = &fakeExternalProvider{}
|
|
sonicProviderFake = &fakeSonicProvider{}
|
|
sonicSvc := sonic.New(ds, &fakeSonicLoader{provider: sonicProviderFake}, matcher.New(ds))
|
|
decider := stream.NewTranscodeDecider(ds, harness.NoopFFmpeg{})
|
|
router = jellyfin.New(
|
|
ds,
|
|
artworkSpy,
|
|
streamerSpy,
|
|
decider,
|
|
core.NewPlayers(ds),
|
|
scrobbler.NewPlayTracker(ds, events.NoopBroker(), nil),
|
|
playlists.NewPlaylists(ds, core.NewImageUploadService(ds)),
|
|
providerFake,
|
|
sonicSvc,
|
|
lyrics.NewLyrics(ds, nil),
|
|
events.NoopBroker(),
|
|
)
|
|
}
|
|
|
|
// fakeExternalProvider is a configurable stand-in for external.Provider. Tests set the return
|
|
// values they need; unset fields yield empty similar lists. Only the methods the Jellyfin API uses
|
|
// are overridden — the embedded interface panics for anything else, flagging unexpected calls.
|
|
type fakeExternalProvider struct {
|
|
external.Provider
|
|
similarArtists model.Artists
|
|
similarSongs model.MediaFiles
|
|
}
|
|
|
|
func (f *fakeExternalProvider) UpdateArtistInfo(_ context.Context, id string, _ int, _ bool) (*model.Artist, error) {
|
|
return &model.Artist{ID: id, SimilarArtists: f.similarArtists}, nil
|
|
}
|
|
|
|
func (f *fakeExternalProvider) SimilarSongs(context.Context, string, int) (model.MediaFiles, error) {
|
|
return f.similarSongs, nil
|
|
}
|
|
|
|
// fakeSonicLoader always advertises a SonicSimilarity provider so the AudioMuse endpoints are
|
|
// active in e2e; the provider it hands back returns test-configured results.
|
|
type fakeSonicLoader struct{ provider sonic.Provider }
|
|
|
|
func (f *fakeSonicLoader) PluginNames(capability string) []string {
|
|
if capability == "SonicSimilarity" {
|
|
return []string{"fake"}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (f *fakeSonicLoader) LoadSonicSimilarity(string) (sonic.Provider, bool) {
|
|
return f.provider, true
|
|
}
|
|
|
|
// fakeSonicProvider is a configurable stand-in for a sonic-similarity plugin. Tests set the
|
|
// agents.Song results; the real matcher resolves them back to seeded library tracks.
|
|
type fakeSonicProvider struct {
|
|
similar []sonic.SimilarResult
|
|
path []sonic.SimilarResult
|
|
}
|
|
|
|
func (f *fakeSonicProvider) GetSonicSimilarTracks(context.Context, *model.MediaFile, int) ([]sonic.SimilarResult, error) {
|
|
return f.similar, nil
|
|
}
|
|
|
|
func (f *fakeSonicProvider) FindSonicPath(context.Context, *model.MediaFile, *model.MediaFile, int) ([]sonic.SimilarResult, error) {
|
|
return f.path, nil
|
|
}
|
|
|
|
// songAgent looks a seeded track up by title (titles are unique in the seed) and builds an
|
|
// agents.Song carrying its title+artist, so the matcher resolves it back to that MediaFile.
|
|
func songAgent(title string) agents.Song {
|
|
mfs, err := ds.MediaFile(ctx).GetAll()
|
|
Expect(err).ToNot(HaveOccurred())
|
|
for _, mf := range mfs {
|
|
if mf.Title == title {
|
|
return agents.Song{Name: mf.Title, Artists: []agents.Artist{{Name: mf.Artist}}}
|
|
}
|
|
}
|
|
Fail("song not found: " + title)
|
|
return agents.Song{}
|
|
}
|
|
|
|
// --- Spy/noop dependencies (shared ones live in tests/harness) ---
|
|
|
|
// spyArtwork captures the id and context passed to GetOrPlaceholder so image tests can assert the
|
|
// resolved ArtworkID and that resolution runs under an elevated (admin) context.
|
|
type spyArtwork struct {
|
|
lastID string
|
|
lastCtx context.Context
|
|
data []byte
|
|
}
|
|
|
|
func (s *spyArtwork) Get(context.Context, model.ArtworkID, int, bool) (*artwork.Image, error) {
|
|
return nil, model.ErrNotFound
|
|
}
|
|
|
|
func (s *spyArtwork) GetOrPlaceholder(c context.Context, id string, _ int, _ bool) (*artwork.Image, error) {
|
|
s.lastID = id
|
|
s.lastCtx = c
|
|
d := s.data
|
|
if d == nil {
|
|
d = []byte("IMG")
|
|
}
|
|
return &artwork.Image{ReadCloser: io.NopCloser(bytes.NewReader(d))}, nil
|
|
}
|
|
|
|
var _ artwork.Service = &spyArtwork{}
|