navidrome/server/jellyfin/e2e/e2e_suite_test.go
Deluan Quintão b293b96256
refactor(persistence): stateless repositories with per-call context (#6149)
* refactor(persistence): adopt generic deluan/rest repository API

Pin deluan/rest to the refactor branch. REST-facing repository methods
take a context and return typed values. Drop DataStore.Resource and
ResourceRepository; the native API names typed repositories directly
through a per-request adapter that later commits remove.

* refactor(persistence): base repository helpers take a context

* refactor(persistence): LibraryRepository takes a context per call

* refactor(persistence): PropertyRepository takes a context per call

* refactor(persistence): UserPropsRepository takes a context per call

* refactor(persistence): TranscodingRepository takes a context per call

* refactor(persistence): ShareRepository takes a context per call

* refactor(persistence): PlayerRepository takes a context per call

* refactor(persistence): RadioRepository takes a context per call

* refactor(persistence): PlayQueueRepository takes a context per call

* refactor(persistence): Tag and Genre repositories take a context per call

* refactor(persistence): PluginRepository takes a context per call

* refactor(persistence): Scrobble repositories take a context per call

* refactor(persistence): FolderRepository takes a context per call

* refactor(persistence): Artwork repositories take a context per call

* refactor(persistence): UserRepository takes a context per call

* refactor(persistence): ArtistRepository takes a context per call

ReadAll no longer rewrites the shared sort mappings for the role filter;
it works on a per-call copy.

* test(persistence): assert artist role sort sanitization in ReadAll

* refactor(persistence): AlbumRepository takes a context per call

* test(persistence): pass the test context to album repository helpers

* refactor(persistence): MediaFileRepository takes a context per call

* refactor(persistence): Playlist repositories take a context per call

* refactor(persistence): build all repositories once per store

* refactor(core): REST repository wrappers are built once

* refactor(persistence): repositories are stateless

Remove the context field from the base repository and the per-request
REST adapter. Enable the containedctx linter so no repository can hold a
request context again.

* chore(lint): skip containedctx in test files

* refactor: share simplifications from the stateless repositories sweep

Add deleteOwnedAll on sqlRepository and use it in player/share Delete
to remove the duplicated bulk-delete loop; have Share.Repository()
return model.ShareRepository so subsonic sharing.go drops its repeated
type assertions.

* chore(core): assert REST wrappers implement Persistable

* chore: reformat imports

* perf(persistence): build repositories on first use

Each transaction store used to construct all 21 repositories up front,
paying for filter and sort mapping setup the block never touched. Fields
are now sync.OnceValue thunks, so a store only builds what it uses.

* fix(persistence): clean plugin references per deleted user

A bulk user delete that fails on a later id had already removed the
earlier rows but skipped their plugin cleanup. Cleanup now runs right
after each successful delete.

* fix(core): unload disabled plugins even when a user delete fails

A bulk delete can fail on a later id after earlier users were removed
and their plugins auto-disabled. The wrapper returned before unloading,
leaving those plugins running until the next successful delete or a
restart.

* chore(deps): pin deluan/rest to v1.0.1

Replaces the pseudo-version of the refactor branch with the tagged
release. REST error messages now name the bare type (Artist, not
model.Artist).

* test: use the spec context instead of context.Background()

Replace the context.Background()/context.TODO() calls this branch added
to tests with the spec's ctx, GinkgoT().Context(), or t/b.Context(), so
repository calls are bound to the running spec's lifetime.

* test: declare the spec context once per Describe

Set ctx from GinkgoT().Context() first in each top-level BeforeEach and reuse it, building user contexts on top of it instead of repeating inline calls.
2026-09-25 18:06:10 -04:00

438 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/quickconnect"
"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/id"
"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")
}
// testID maps a readable label to a deterministic canonical id, so fixtures exercise the same
// id shape production uses.
func testID(label string) string { return id.NewHash("jellyfin-test", label) }
// 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: testID("admin-1"),
UserName: "admin",
Name: "Admin User",
IsAdmin: true,
}
regularUser = model.User{
ID: testID("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())
return createPlaylistBodyAs(user, string(body))
}
// createPlaylistBodyAs posts a raw create body, for tests that need fields the helpers above don't
// build, and returns the new playlist's decoded id.
func createPlaylistBodyAs(user model.User, body string) string {
var res map[string]string
parseInto(postAs(user, "/Playlists", body), &res)
Expect(res["Id"]).ToNot(BeEmpty())
id, ok := dto.DecodeID(res["Id"])
Expect(ok).To(BeTrue())
return 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().GetAll(ctx)
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().GetAll(ctx)
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().GetAll(ctx)
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().GetAll(ctx)
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, &regularUser)
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, artwork.NewUploader(ds)),
providerFake,
sonicSvc,
lyrics.NewLyrics(ds, nil),
events.NoopBroker(),
quickconnect.New(),
)
}
// 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().GetAll(ctx)
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.Artwork = &spyArtwork{}