mirror of
https://github.com/navidrome/navidrome.git
synced 2026-02-15 09:21:22 -05:00
407 lines
13 KiB
Go
407 lines
13 KiB
Go
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/core/transcode"
|
|
"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, core.StreamRequest) (*core.Stream, error) {
|
|
return nil, model.ErrNotFound
|
|
}
|
|
|
|
func (n noopStreamer) DoStream(context.Context, *model.MediaFile, core.StreamRequest) (*core.Stream, error) {
|
|
return nil, model.ErrNotFound
|
|
}
|
|
|
|
// noopDecider implements transcode.Decider
|
|
type noopDecider struct{}
|
|
|
|
func (n noopDecider) MakeDecision(context.Context, *model.MediaFile, *transcode.ClientInfo) (*transcode.Decision, error) {
|
|
return nil, nil
|
|
}
|
|
|
|
func (n noopDecider) CreateTranscodeParams(*transcode.Decision) (string, error) {
|
|
return "", nil
|
|
}
|
|
|
|
func (n noopDecider) ParseTranscodeParams(string) (*transcode.Params, error) {
|
|
return nil, nil
|
|
}
|
|
|
|
func (n noopDecider) ValidateTranscodeParams(context.Context, string, string) (*transcode.Params, *model.MediaFile, error) {
|
|
return nil, nil, nil
|
|
}
|
|
|
|
// 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{}
|
|
_ transcode.Decider = noopDecider{}
|
|
)
|
|
|
|
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(),
|
|
noopDecider{},
|
|
)
|
|
}
|
|
|
|
// 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())
|
|
}
|