test(jellyfin): e2e for playlists (CRUD, expansion, cover) and item images

This commit is contained in:
Deluan 2026-07-06 00:36:04 -04:00
commit 6aa0d6a2aa
3 changed files with 213 additions and 0 deletions

View file

@ -185,6 +185,19 @@ func postAs(u model.User, path, body string) *httptest.ResponseRecorder {
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())
@ -198,6 +211,20 @@ func queryResult(w *httptest.ResponseRecorder) dto.QueryResult {
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 {
if encodedIds == nil {
encodedIds = []string{}
}
body, err := json.Marshal(map[string]any{"Name": name, "Ids": encodedIds})
Expect(err).ToNot(HaveOccurred())
var res map[string]string
parseInto(post("/Playlists", string(body)), &res)
Expect(res["Id"]).ToNot(BeEmpty())
return dto.DecodeID(res["Id"])
}
// --- Seeded-id lookup helpers (return Navidrome ids; wrap with enc() for URLs) ---
func enc(id string) string { return dto.EncodeID(id) }

View file

@ -0,0 +1,50 @@
package e2e
import (
"net/http"
"github.com/navidrome/navidrome/model/request"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
// The image endpoint is public and resolves artwork under an elevated (admin) context. The suite
// wires a spyArtwork that captures the resolved ArtworkID and the context, so these tests assert
// resolution and elevation without needing real image processing.
var _ = Describe("Item images", func() {
BeforeEach(func() { setupTestDB() })
It("resolves an album's Primary image", func() {
id := albumID("Abbey Road")
w := get("/Items/" + enc(id) + "/Images/Primary")
Expect(w.Code).To(Equal(http.StatusOK))
Expect(w.Body.String()).To(Equal("IMG"))
Expect(artworkSpy.lastID).To(ContainSubstring(id))
})
It("resolves an artist's Primary image", func() {
id := artistID("Miles Davis")
Expect(get("/Items/" + enc(id) + "/Images/Primary").Code).To(Equal(http.StatusOK))
Expect(artworkSpy.lastID).To(ContainSubstring(id))
})
It("resolves a private playlist's cover under an elevated admin context", func() {
// Regression: the public image route carries no user, so a private playlist would fail its
// visibility filter without the elevated-context resolution.
plID := createPlaylist("Private Mix", nil)
w := get("/Items/" + enc(plID) + "/Images/Primary")
Expect(w.Code).To(Equal(http.StatusOK))
Expect(artworkSpy.lastID).To(ContainSubstring(plID))
u, ok := request.UserFrom(artworkSpy.lastCtx)
Expect(ok).To(BeTrue())
Expect(u.IsAdmin).To(BeTrue())
})
It("serves images without authentication (public route)", func() {
id := albumID("IV")
w := rawReq("GET", "/Items/"+enc(id)+"/Images/Primary", "")
Expect(w.Code).To(Equal(http.StatusOK))
Expect(w.Body.String()).To(Equal("IMG"))
})
})

View file

@ -0,0 +1,136 @@
package e2e
import (
"net/http"
"os"
"github.com/navidrome/navidrome/server/jellyfin/dto"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
var _ = Describe("Playlists", func() {
BeforeEach(func() { setupTestDB() })
playlistItems := func(plID string) dto.QueryResult {
return queryResult(get("/Playlists/" + enc(plID) + "/Items"))
}
Describe("create", func() {
It("creates an empty playlist", func() {
plID := createPlaylist("Empty", nil)
var info dto.PlaylistInfo
parseInto(get("/Playlists/"+enc(plID)), &info)
Expect(info.OpenAccess).To(BeFalse())
Expect(info.Shares).To(BeEmpty())
Expect(info.ItemIds).To(BeEmpty())
})
It("creates a playlist from song ids", func() {
plID := createPlaylist("Songs", []string{enc(songID("Come Together")), enc(songID("So What"))})
Expect(playlistItems(plID).TotalRecordCount).To(Equal(2))
})
It("expands an album id into its tracks", func() {
plID := createPlaylist("From Album", []string{enc(albumID("Abbey Road"))})
q := playlistItems(plID)
Expect(q.TotalRecordCount).To(Equal(2))
Expect(names(q.Items)).To(ConsistOf("Come Together", "Something"))
})
It("expands an artist id into its tracks", func() {
plID := createPlaylist("From Artist", []string{enc(artistID("The Beatles"))})
Expect(playlistItems(plID).TotalRecordCount).To(Equal(3)) // Abbey Road (2) + Help! (1)
})
})
Describe("items", func() {
It("tags each entry with a PlaylistItemId", func() {
plID := createPlaylist("Tagged", []string{enc(songID("Help!"))})
q := playlistItems(plID)
Expect(q.Items).To(HaveLen(1))
Expect(q.Items[0].Type).To(Equal("Audio"))
Expect(q.Items[0].PlaylistItemId).ToNot(BeEmpty())
})
})
Describe("add and remove", func() {
It("adds a song by id", func() {
plID := createPlaylist("Add", nil)
Expect(post("/Playlists/"+enc(plID)+"/Items?ids="+enc(songID("So What")), "").Code).To(Equal(http.StatusNoContent))
Expect(playlistItems(plID).TotalRecordCount).To(Equal(1))
})
It("adds an album (expanding to its tracks)", func() {
plID := createPlaylist("AddAlbum", []string{enc(songID("So What"))})
post("/Playlists/"+enc(plID)+"/Items?ids="+enc(albumID("Abbey Road")), "")
Expect(playlistItems(plID).TotalRecordCount).To(Equal(3)) // 1 + Abbey Road (2)
})
It("removes an entry by its PlaylistItemId", func() {
plID := createPlaylist("Remove", []string{enc(songID("Come Together")), enc(songID("Something"))})
entryID := playlistItems(plID).Items[0].PlaylistItemId
Expect(del("/Playlists/" + enc(plID) + "/Items?entryIds=" + entryID).Code).To(Equal(http.StatusNoContent))
Expect(playlistItems(plID).TotalRecordCount).To(Equal(1))
})
})
Describe("users", func() {
It("reports the current user as an editor", func() {
plID := createPlaylist("Perms", nil)
var perms []dto.PlaylistUserPermissions
parseInto(get("/Playlists/"+enc(plID)+"/Users"), &perms)
Expect(perms).To(HaveLen(1))
Expect(perms[0].UserId).To(Equal("admin-1"))
Expect(perms[0].CanEdit).To(BeTrue())
})
})
Describe("listing", func() {
It("lists a created playlist advertising a Primary image tag", func() {
createPlaylist("Listed", nil)
q := queryResult(get("/Items?IncludeItemTypes=Playlist&Recursive=true"))
Expect(q.TotalRecordCount).To(Equal(1))
Expect(q.Items[0].Name).To(Equal("Listed"))
Expect(q.Items[0].ImageTags).To(HaveKey("Primary"))
})
})
Describe("cover art", func() {
jpeg := []byte{0xFF, 0xD8, 0xFF, 0xE0, 0x00, 0x10, 'J', 'F', 'I', 'F', 0x00}
It("uploads and removes a playlist cover", func() {
plID := createPlaylist("Cover", nil)
Expect(upload(adminUser, "/Items/"+enc(plID)+"/Images/Primary", "image/jpeg", jpeg).Code).
To(Equal(http.StatusNoContent))
pls, err := ds.Playlist(ctx).Get(plID)
Expect(err).ToNot(HaveOccurred())
Expect(pls.UploadedImage).ToNot(BeEmpty())
_, statErr := os.Stat(pls.UploadedImagePath())
Expect(statErr).ToNot(HaveOccurred(), "cover file should exist on disk")
Expect(del("/Items/" + enc(plID) + "/Images/Primary").Code).To(Equal(http.StatusNoContent))
pls, _ = ds.Playlist(ctx).Get(plID)
Expect(pls.UploadedImage).To(BeEmpty())
})
It("rejects cover upload for a non-playlist item", func() {
Expect(upload(adminUser, "/Items/"+enc(albumID("IV"))+"/Images/Primary", "image/jpeg", jpeg).Code).
To(Equal(http.StatusNotImplemented))
})
})
Describe("delete", func() {
It("deletes a playlist", func() {
plID := createPlaylist("ToDelete", nil)
Expect(del("/Items/" + enc(plID)).Code).To(Equal(http.StatusNoContent))
Expect(queryResult(get("/Items?IncludeItemTypes=Playlist&Recursive=true")).TotalRecordCount).To(Equal(0))
})
It("returns 404 when deleting a non-playlist item", func() {
Expect(del("/Items/" + enc(albumID("IV"))).Code).To(Equal(http.StatusNotFound))
})
})
})