// Package harness holds the pieces shared by the API e2e suites (server/subsonic/e2e and // server/jellyfin/e2e): golden-database lifecycle, snapshot restore, fixture-FS registration, // and service doubles. Like core/storage/storagetest, it must only be imported from test code. package harness import ( "context" "errors" "io" "os" "path/filepath" "strings" "testing/fstest" "github.com/navidrome/navidrome/conf" "github.com/navidrome/navidrome/core/artwork" "github.com/navidrome/navidrome/core/auth" "github.com/navidrome/navidrome/core/ffmpeg" "github.com/navidrome/navidrome/core/metrics" "github.com/navidrome/navidrome/core/playlists" "github.com/navidrome/navidrome/core/storage/storagetest" "github.com/navidrome/navidrome/core/stream" "github.com/navidrome/navidrome/db" "github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/persistence" "github.com/navidrome/navidrome/scanner" "github.com/navidrome/navidrome/server/events" "github.com/navidrome/navidrome/tests" "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" //nolint:staticcheck ) // DB is a golden e2e database: scanned once in BeforeSuite, restored per test via Restore. type DB struct { FilePath string SnapshotPath string Library model.Library } // CreateFS registers files under the "fake:" storage scheme the suites use as MusicFolder. func CreateFS(files fstest.MapFS) storagetest.FakeFS { fs := storagetest.FakeFS{} fs.SetFiles(files) storagetest.Register("fake", &fs) return fs } // SetupDB boots the golden database: a temp SQLite file, the given users (password "password", // all with access to the seeded "Music Library"), a full scan of the registered fake FS, and a // snapshot for per-test restore. Callers must set conf.Server.MusicFolder and register the FS // first; each user's Libraries field is populated in place. func SetupDB(ctx context.Context, users ...*model.User) *DB { tmpDir := ginkgo.GinkgoT().TempDir() h := &DB{FilePath: filepath.Join(tmpDir, "test-e2e.db")} h.SnapshotPath = h.FilePath + ".snapshot" conf.Server.DbPath = h.FilePath + "?_journal_mode=WAL" db.Db().SetMaxOpenConns(1) db.Init(ctx) ds := &tests.MockDataStore{RealDS: persistence.New(db.Db())} auth.Init(ds) h.Library = model.Library{ID: 1, Name: "Music Library", Path: "fake:///music"} Expect(ds.Library(ctx).Put(&h.Library)).To(Succeed()) for _, u := range users { seeded := *u seeded.NewPassword = "password" Expect(ds.User(ctx).Put(&seeded)).To(Succeed()) Expect(ds.User(ctx).SetUserLibraries(u.ID, []int{h.Library.ID})).To(Succeed()) loaded, err := ds.User(ctx).FindByUsername(u.UserName) Expect(err).ToNot(HaveOccurred()) u.Libraries = loaded.Libraries } s := scanner.New(ctx, ds, events.NoopBroker(), playlists.NewPlaylists(ds, artwork.NewUploader(ds)), metrics.NewNoopInstance()) _, err := s.ScanAll(ctx, true) Expect(err).ToNot(HaveOccurred()) _, err = db.Db().Exec("PRAGMA wal_checkpoint(TRUNCATE)") Expect(err).ToNot(HaveOccurred()) data, err := os.ReadFile(h.FilePath) Expect(err).ToNot(HaveOccurred()) Expect(os.WriteFile(h.SnapshotPath, data, 0o600)).To(Succeed()) //nolint:gosec // path derives from TempDir return h } // ResettableTables lists the tables a per-spec reset may write. FTS shadow tables are excluded: // their content tables' triggers keep them in sync, and writing them directly corrupts the index. func ResettableTables() []string { rows, err := db.Db().Query("SELECT name FROM main.sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%' AND name NOT LIKE '%_fts' AND name NOT LIKE '%_fts_%'") Expect(err).ToNot(HaveOccurred()) defer rows.Close() var tables []string for rows.Next() { var name string Expect(rows.Scan(&name)).To(Succeed()) tables = append(tables, name) } Expect(rows.Err()).ToNot(HaveOccurred()) return tables } // TruncateDB empties every resettable table, leaving the migrated schema in place — for suites // whose specs each build their own library, so the schema is not re-migrated per spec. func TruncateDB(tables []string) { sqlDB := db.Db() _, err := sqlDB.Exec("PRAGMA foreign_keys = OFF") Expect(err).ToNot(HaveOccurred()) defer func() { _, _ = sqlDB.Exec("PRAGMA foreign_keys = ON") }() for _, table := range tables { // Table names come from sqlite_master, not user input. _, err = sqlDB.Exec(`DELETE FROM main."` + table + `"`) //nolint:gosec Expect(err).ToNot(HaveOccurred()) } } // Restore reloads every table from the golden snapshot via ATTACH DATABASE — much faster than a // rescan. func (h *DB) Restore() { sqlDB := db.Db() _, err := sqlDB.Exec("PRAGMA foreign_keys = OFF") Expect(err).ToNot(HaveOccurred()) _, err = sqlDB.Exec("ATTACH DATABASE ? AS snapshot", h.SnapshotPath) Expect(err).ToNot(HaveOccurred()) for _, table := range ResettableTables() { // Table names come from sqlite_master, not user input. _, 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()) } // SpyStreamer captures the Request passed to NewStream and returns a minimal fake stream. type SpyStreamer struct { LastRequest stream.Request LastMediaFile *model.MediaFile SimulateError error // when set, NewStream returns this error SimulateEmptyStream bool // when true, returns a 0-byte stream (ffmpeg produced no output) } func (s *SpyStreamer) NewStream(_ context.Context, mf *model.MediaFile, req stream.Request) (*stream.Stream, error) { s.LastRequest = req s.LastMediaFile = mf if s.SimulateError != nil { return nil, s.SimulateError } format := req.Format if format == "" || format == "raw" { format = mf.Suffix } content := "fake audio data" if s.SimulateEmptyStream { content = "" } return stream.NewStream(mf, format, req.BitRate, io.NopCloser(strings.NewReader(content))), nil } // NoopFFmpeg implements ffmpeg.FFmpeg; transcoding never actually runs in e2e. type NoopFFmpeg struct{} func (NoopFFmpeg) Transcode(context.Context, ffmpeg.TranscodeOptions) (io.ReadCloser, error) { return nil, errors.New("noop ffmpeg: transcode not supported") } func (NoopFFmpeg) ExtractImage(context.Context, string) (io.ReadCloser, error) { return nil, errors.New("noop ffmpeg: extract image not supported") } func (NoopFFmpeg) Probe(context.Context, []string) (string, error) { return "", nil } func (NoopFFmpeg) ProbeAudioStream(context.Context, string) (*ffmpeg.AudioProbeResult, error) { return nil, errors.New("noop ffmpeg: probe not supported") } func (NoopFFmpeg) ConvertAnimatedImage(context.Context, io.Reader, int, int) (io.ReadCloser, error) { return nil, errors.New("noop ffmpeg: convert animated image not supported") } func (NoopFFmpeg) CmdPath() (string, error) { return "", nil } func (NoopFFmpeg) IsAvailable() bool { return false } func (NoopFFmpeg) IsProbeAvailable() bool { return true } func (NoopFFmpeg) Version() string { return "noop" } var ( _ stream.MediaStreamer = &SpyStreamer{} _ ffmpeg.FFmpeg = NoopFFmpeg{} )