🏡 index : quine-global/quintodrome.git

// 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{}