mirror of
https://github.com/navidrome/navidrome.git
synced 2026-10-31 07:27:58 +01:00
test(jellyfin): add e2e suite harness + smoke tests
This commit is contained in:
parent
03d5ff12e5
commit
8ce3f0e9ef
2 changed files with 485 additions and 0 deletions
436
server/jellyfin/e2e/e2e_suite_test.go
Normal file
436
server/jellyfin/e2e/e2e_suite_test.go
Normal file
|
|
@ -0,0 +1,436 @@
|
|||
// 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/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 Come Together (1969), 02 Something (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)
|
||||
//
|
||||
// Totals: 6 songs, 5 albums, 4 album artists, 3 genres (Rock=4, Jazz=1, Pop=1).
|
||||
package e2e
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"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/ffmpeg"
|
||||
"github.com/navidrome/navidrome/core/metrics"
|
||||
"github.com/navidrome/navidrome/core/playlists"
|
||||
"github.com/navidrome/navidrome/core/scrobbler"
|
||||
"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/scanner"
|
||||
"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/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 *spyStreamer
|
||||
artworkSpy *spyArtwork
|
||||
lib model.Library
|
||||
|
||||
// Snapshot paths for fast DB restore
|
||||
dbFilePath string
|
||||
snapshotPath string
|
||||
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,
|
||||
}
|
||||
)
|
||||
|
||||
func createFS(files fstest.MapFS) storagetest.FakeFS {
|
||||
fs := storagetest.FakeFS{}
|
||||
fs.SetFiles(files)
|
||||
storagetest.Register("fake", &fs)
|
||||
return fs
|
||||
}
|
||||
|
||||
// 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"})
|
||||
singles := template(_t{"albumartist": "Solo Artist", "artist": "Solo Artist", "album": "Singles", "year": 2020, "genre": "Pop"})
|
||||
|
||||
return createFS(fstest.MapFS{
|
||||
"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!/01 - Help.mp3": help(track(1, "Help!")),
|
||||
"Rock/Led Zeppelin/IV/01 - Stairway To Heaven.mp3": ledZepIV(track(1, "Stairway To Heaven")),
|
||||
"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")),
|
||||
})
|
||||
}
|
||||
|
||||
// createUser creates a user assigned to the test library and returns it fully loaded.
|
||||
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())
|
||||
loaded, err := ds.User(ctx).FindByUsername(user.UserName)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
user.Libraries = loaded.Libraries
|
||||
return user
|
||||
}
|
||||
|
||||
// --- 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, "") }
|
||||
|
||||
// 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
|
||||
}
|
||||
|
||||
// --- 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 ""
|
||||
}
|
||||
|
||||
// --- Suite lifecycle ---
|
||||
|
||||
var _ = BeforeSuite(func() {
|
||||
ctx = request.WithUser(GinkgoT().Context(), adminUser)
|
||||
tmpDir := GinkgoT().TempDir()
|
||||
dbFilePath = filepath.Join(tmpDir, "test-jf-e2e.db")
|
||||
snapshotPath = filepath.Join(tmpDir, "test-jf-e2e.db.snapshot")
|
||||
dataFolder = filepath.Join(tmpDir, "data")
|
||||
Expect(os.MkdirAll(dataFolder, 0o755)).To(Succeed())
|
||||
conf.Server.DbPath = dbFilePath + "?_journal_mode=WAL"
|
||||
db.Db().SetMaxOpenConns(1)
|
||||
|
||||
conf.Server.MusicFolder = "fake:///music"
|
||||
conf.Server.DataFolder = conf.NewDir(dataFolder)
|
||||
conf.Server.DevExternalScanner = false
|
||||
|
||||
db.Init(ctx)
|
||||
|
||||
initDS := &tests.MockDataStore{RealDS: persistence.New(db.Db())}
|
||||
auth.Init(initDS)
|
||||
|
||||
adminWithPass := adminUser
|
||||
adminWithPass.NewPassword = "password"
|
||||
Expect(initDS.User(ctx).Put(&adminWithPass)).To(Succeed())
|
||||
regularWithPass := regularUser
|
||||
regularWithPass.NewPassword = "password"
|
||||
Expect(initDS.User(ctx).Put(®ularWithPass)).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())
|
||||
Expect(initDS.User(ctx).SetUserLibraries(regularUser.ID, []int{lib.ID})).To(Succeed())
|
||||
|
||||
loadedAdmin, err := initDS.User(ctx).FindByUsername(adminUser.UserName)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
adminUser.Libraries = loadedAdmin.Libraries
|
||||
loadedRegular, err := initDS.User(ctx).FindByUsername(regularUser.UserName)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
regularUser.Libraries = loadedRegular.Libraries
|
||||
|
||||
ctx = request.WithUser(GinkgoT().Context(), adminUser)
|
||||
|
||||
buildTestFS()
|
||||
s := scanner.New(ctx, initDS, artwork.NoopCacheWarmer(), events.NoopBroker(),
|
||||
playlists.NewPlaylists(initDS, core.NewImageUploadService()), 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(dbFilePath)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(os.WriteFile(snapshotPath, data, 0o600)).To(Succeed())
|
||||
})
|
||||
|
||||
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
|
||||
|
||||
restoreDB()
|
||||
|
||||
ds = &tests.MockDataStore{RealDS: persistence.New(db.Db())}
|
||||
auth.Init(ds)
|
||||
|
||||
streamerSpy = &spyStreamer{}
|
||||
artworkSpy = &spyArtwork{}
|
||||
decider := stream.NewTranscodeDecider(ds, noopFFmpeg{})
|
||||
router = jellyfin.New(
|
||||
ds,
|
||||
artworkSpy,
|
||||
streamerSpy,
|
||||
decider,
|
||||
core.NewPlayers(ds),
|
||||
scrobbler.NewPlayTracker(ds, events.NoopBroker(), nil),
|
||||
playlists.NewPlaylists(ds, core.NewImageUploadService()),
|
||||
)
|
||||
}
|
||||
|
||||
// restoreDB restores all table data from the snapshot using ATTACH DATABASE (faster than rescan).
|
||||
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_%' AND name NOT LIKE '%_fts' AND name NOT LIKE '%_fts_%'")
|
||||
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())
|
||||
}
|
||||
|
||||
// --- Spy/noop dependencies ---
|
||||
|
||||
// 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) (io.ReadCloser, time.Time, error) {
|
||||
return nil, time.Time{}, model.ErrNotFound
|
||||
}
|
||||
|
||||
func (s *spyArtwork) GetOrPlaceholder(c context.Context, id string, _ int, _ bool) (io.ReadCloser, time.Time, error) {
|
||||
s.lastID = id
|
||||
s.lastCtx = c
|
||||
d := s.data
|
||||
if d == nil {
|
||||
d = []byte("IMG")
|
||||
}
|
||||
return io.NopCloser(bytes.NewReader(d)), time.Time{}, nil
|
||||
}
|
||||
|
||||
// spyStreamer captures the stream.Request and returns a minimal fake stream.
|
||||
type spyStreamer struct {
|
||||
LastRequest stream.Request
|
||||
LastMediaFile *model.MediaFile
|
||||
SimulateError error
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
r := io.NopCloser(strings.NewReader("fake audio data"))
|
||||
return stream.NewStream(mf, format, req.BitRate, r), nil
|
||||
}
|
||||
|
||||
// noopFFmpeg implements ffmpeg.FFmpeg with no-op methods (transcoding never actually runs in e2e).
|
||||
type noopFFmpeg struct{}
|
||||
|
||||
func (noopFFmpeg) Transcode(context.Context, ffmpeg.TranscodeOptions) (io.ReadCloser, error) {
|
||||
return nil, model.ErrNotFound
|
||||
}
|
||||
func (noopFFmpeg) ExtractImage(context.Context, string) (io.ReadCloser, error) {
|
||||
return nil, model.ErrNotFound
|
||||
}
|
||||
func (noopFFmpeg) Probe(context.Context, []string) (string, error) { return "", nil }
|
||||
func (noopFFmpeg) ProbeAudioStream(context.Context, string) (*ffmpeg.AudioProbeResult, error) {
|
||||
return nil, model.ErrNotFound
|
||||
}
|
||||
func (noopFFmpeg) ConvertAnimatedImage(context.Context, io.Reader, int, int) (io.ReadCloser, error) {
|
||||
return nil, model.ErrNotFound
|
||||
}
|
||||
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 (
|
||||
_ artwork.Artwork = &spyArtwork{}
|
||||
_ stream.MediaStreamer = &spyStreamer{}
|
||||
_ ffmpeg.FFmpeg = noopFFmpeg{}
|
||||
)
|
||||
49
server/jellyfin/e2e/smoke_test.go
Normal file
49
server/jellyfin/e2e/smoke_test.go
Normal file
|
|
@ -0,0 +1,49 @@
|
|||
package e2e
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/navidrome/navidrome/server/jellyfin/dto"
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
|
||||
// Smoke test: proves the harness boots (DB, scan, snapshot, router, token auth) and the seeded
|
||||
// library is queryable end-to-end. Broader per-endpoint coverage lives in the sibling files.
|
||||
var _ = Describe("Smoke", func() {
|
||||
BeforeEach(func() { setupTestDB() })
|
||||
|
||||
It("serves public system info without auth", func() {
|
||||
w := rawReq("GET", "/System/Info/Public", "")
|
||||
Expect(w.Code).To(Equal(http.StatusOK))
|
||||
var info map[string]any
|
||||
parseInto(w, &info)
|
||||
Expect(info).To(HaveKey("ServerName"))
|
||||
Expect(info).To(HaveKey("Version"))
|
||||
})
|
||||
|
||||
It("rejects an authenticated endpoint without a token", func() {
|
||||
w := rawReq("GET", "/Items?IncludeItemTypes=MusicAlbum&Recursive=true", "")
|
||||
Expect(w.Code).To(Equal(http.StatusUnauthorized))
|
||||
})
|
||||
|
||||
It("lists the seeded albums for an authenticated user", func() {
|
||||
q := queryResult(get("/Items?IncludeItemTypes=MusicAlbum&Recursive=true"))
|
||||
Expect(q.TotalRecordCount).To(Equal(5))
|
||||
names := make([]string, 0, len(q.Items))
|
||||
for _, it := range q.Items {
|
||||
Expect(it.Type).To(Equal("MusicAlbum"))
|
||||
names = append(names, it.Name)
|
||||
}
|
||||
Expect(names).To(ConsistOf("Abbey Road", "Help!", "IV", "Kind of Blue", "Singles"))
|
||||
})
|
||||
|
||||
It("resolves a seeded album id round-trip (encoded in the URL)", func() {
|
||||
id := albumID("Abbey Road")
|
||||
var item dto.BaseItemDto
|
||||
parseInto(get("/Items/"+enc(id)), &item)
|
||||
Expect(item.Id).To(Equal(enc(id)))
|
||||
Expect(item.Name).To(Equal("Abbey Road"))
|
||||
Expect(item.Type).To(Equal("MusicAlbum"))
|
||||
})
|
||||
})
|
||||
Loading…
Add table
Add a link
Reference in a new issue