From 121dd71ea164a39d809936a04fa7da571abdfd78 Mon Sep 17 00:00:00 2001 From: Deluan Date: Mon, 6 Jul 2026 00:33:24 -0400 Subject: [PATCH] test(jellyfin): e2e for system, auth, routing, browsing, annotations --- server/jellyfin/e2e/annotations_test.go | 81 ++++++++++++ server/jellyfin/e2e/auth_test.go | 102 ++++++++++++++ server/jellyfin/e2e/browsing_test.go | 168 ++++++++++++++++++++++++ server/jellyfin/e2e/routing_test.go | 31 +++++ server/jellyfin/e2e/system_test.go | 55 ++++++++ 5 files changed, 437 insertions(+) create mode 100644 server/jellyfin/e2e/annotations_test.go create mode 100644 server/jellyfin/e2e/auth_test.go create mode 100644 server/jellyfin/e2e/browsing_test.go create mode 100644 server/jellyfin/e2e/routing_test.go create mode 100644 server/jellyfin/e2e/system_test.go diff --git a/server/jellyfin/e2e/annotations_test.go b/server/jellyfin/e2e/annotations_test.go new file mode 100644 index 000000000..a5b1edeb7 --- /dev/null +++ b/server/jellyfin/e2e/annotations_test.go @@ -0,0 +1,81 @@ +package e2e + +import ( + "net/http" + + "github.com/navidrome/navidrome/server/jellyfin/dto" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("Annotations", func() { + BeforeEach(func() { setupTestDB() }) + + itemUserData := func(id string) *dto.UserItemDataDto { + var item dto.BaseItemDto + parseInto(get("/Items/"+enc(id)), &item) + return item.UserData + } + + Describe("favorites", func() { + It("marks and unmarks an album as favorite", func() { + id := albumID("Abbey Road") + + var marked dto.UserItemDataDto + parseInto(post("/Users/admin-1/FavoriteItems/"+enc(id), ""), &marked) + Expect(marked.IsFavorite).To(BeTrue()) + Expect(itemUserData(id).IsFavorite).To(BeTrue()) + + var unmarked dto.UserItemDataDto + parseInto(del("/Users/admin-1/FavoriteItems/"+enc(id)), &unmarked) + Expect(unmarked.IsFavorite).To(BeFalse()) + Expect(itemUserData(id).IsFavorite).To(BeFalse()) + }) + + It("marks a song as favorite", func() { + id := songID("So What") + var data dto.UserItemDataDto + parseInto(post("/Users/admin-1/FavoriteItems/"+enc(id), ""), &data) + Expect(itemUserData(id).IsFavorite).To(BeTrue()) + }) + + It("filters items to favorites only", func() { + post("/Users/admin-1/FavoriteItems/"+enc(albumID("Abbey Road")), "") + q := queryResult(get("/Items?IncludeItemTypes=MusicAlbum&Recursive=true&Filters=IsFavorite")) + Expect(q.TotalRecordCount).To(Equal(1)) + Expect(q.Items[0].Name).To(Equal("Abbey Road")) + }) + + It("returns 404 when favoriting an unknown item", func() { + Expect(post("/Users/admin-1/FavoriteItems/"+enc("nope"), "").Code).To(Equal(http.StatusNotFound)) + }) + }) + + Describe("ratings", func() { + It("sets and clears an album rating (Jellyfin 0-10 scale)", func() { + id := albumID("IV") + + var set dto.UserItemDataDto + parseInto(post("/Users/admin-1/Items/"+enc(id)+"/Rating?Rating=10", ""), &set) + Expect(set.Rating).ToNot(BeNil()) + Expect(*set.Rating).To(Equal(float64(10))) + Expect(*itemUserData(id).Rating).To(Equal(float64(10))) + + // Fresh struct: the DELETE response omits the (now-nil) Rating field, so reusing `set` + // would leave the stale value. + var cleared dto.UserItemDataDto + parseInto(del("/Users/admin-1/Items/"+enc(id)+"/Rating"), &cleared) + Expect(cleared.Rating).To(BeNil()) + Expect(itemUserData(id).Rating).To(BeNil()) + }) + + It("clamps an out-of-range rating to the valid domain", func() { + id := albumID("Help!") + var data dto.UserItemDataDto + parseInto(post("/Users/admin-1/Items/"+enc(id)+"/Rating?Rating=100", ""), &data) + // 100 clamps to 10 (Jellyfin) -> 5 (Navidrome) -> 10 back out. + Expect(data.Rating).ToNot(BeNil()) + Expect(*data.Rating).To(Equal(float64(10))) + }) + }) +}) diff --git a/server/jellyfin/e2e/auth_test.go b/server/jellyfin/e2e/auth_test.go new file mode 100644 index 000000000..7d6a4b793 --- /dev/null +++ b/server/jellyfin/e2e/auth_test.go @@ -0,0 +1,102 @@ +package e2e + +import ( + "net/http" + "net/http/httptest" + + "github.com/navidrome/navidrome/server/jellyfin/dto" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("Authentication", func() { + BeforeEach(func() { setupTestDB() }) + + authenticate := func(username, pw string) *httptest.ResponseRecorder { + body := `{"Username":"` + username + `","Pw":"` + pw + `"}` + return rawReq("POST", "/Users/AuthenticateByName", body) + } + + Describe("POST /Users/AuthenticateByName", func() { + It("authenticates a valid user and returns a usable token", func() { + w := authenticate("admin", "password") + var res dto.AuthenticationResult + parseInto(w, &res) + Expect(res.AccessToken).ToNot(BeEmpty()) + Expect(res.User).ToNot(BeNil()) + Expect(res.User.Name).To(Equal("admin")) + Expect(res.User.Id).To(Equal("admin-1")) + Expect(res.User.Policy.IsAdministrator).To(BeTrue()) + Expect(res.ServerId).ToNot(BeEmpty()) + + // The returned token must actually authenticate a protected request. + r := httptest.NewRequest("GET", "/Users/Me", nil) + r.Header.Set("X-Emby-Token", res.AccessToken) + pw := httptest.NewRecorder() + router.ServeHTTP(pw, r) + Expect(pw.Code).To(Equal(http.StatusOK)) + }) + + It("marks a non-admin user's policy as non-administrator", func() { + w := authenticate("regular", "password") + var res dto.AuthenticationResult + parseInto(w, &res) + Expect(res.User.Policy.IsAdministrator).To(BeFalse()) + }) + + It("rejects a wrong password", func() { + Expect(authenticate("admin", "wrong").Code).To(Equal(http.StatusUnauthorized)) + }) + + It("rejects an empty password", func() { + Expect(authenticate("admin", "").Code).To(Equal(http.StatusUnauthorized)) + }) + + It("rejects an unknown user", func() { + Expect(authenticate("nobody", "password").Code).To(Equal(http.StatusUnauthorized)) + }) + + It("rejects a malformed body", func() { + Expect(rawReq("POST", "/Users/AuthenticateByName", "not json").Code).To(Equal(http.StatusBadRequest)) + }) + }) + + Describe("GET /Users/Public", func() { + It("returns an empty list (manual login only)", func() { + w := rawReq("GET", "/Users/Public", "") + Expect(w.Code).To(Equal(http.StatusOK)) + var users []dto.UserDto + parseInto(w, &users) + Expect(users).To(BeEmpty()) + }) + }) + + Describe("current user", func() { + It("returns the caller from GET /Users/Me", func() { + var u dto.UserDto + parseInto(getAs(regularUser, "/Users/Me"), &u) + Expect(u.Name).To(Equal("regular")) + Expect(u.Id).To(Equal("regular-1")) + }) + + It("returns the caller from GET /Users/{userId}", func() { + var u dto.UserDto + parseInto(get("/Users/admin-1"), &u) + Expect(u.Name).To(Equal("admin")) + }) + }) + + Describe("auth enforcement", func() { + It("rejects a protected request with no token", func() { + Expect(rawReq("GET", "/Users/Me", "").Code).To(Equal(http.StatusUnauthorized)) + }) + + It("rejects a protected request with a bogus token", func() { + r := httptest.NewRequest("GET", "/Users/Me", nil) + r.Header.Set("X-Emby-Token", "not-a-valid-jwt") + w := httptest.NewRecorder() + router.ServeHTTP(w, r) + Expect(w.Code).To(Equal(http.StatusUnauthorized)) + }) + }) +}) diff --git a/server/jellyfin/e2e/browsing_test.go b/server/jellyfin/e2e/browsing_test.go new file mode 100644 index 000000000..3f45d7d8f --- /dev/null +++ b/server/jellyfin/e2e/browsing_test.go @@ -0,0 +1,168 @@ +package e2e + +import ( + "net/http" + + "github.com/navidrome/navidrome/server/jellyfin/dto" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +func names(items []dto.BaseItemDto) []string { + out := make([]string, len(items)) + for i, it := range items { + out[i] = it.Name + } + return out +} + +var _ = Describe("Browsing", func() { + BeforeEach(func() { setupTestDB() }) + + Describe("GET /UserViews", func() { + It("returns the user's libraries as CollectionFolders", func() { + q := queryResult(get("/UserViews")) + Expect(q.TotalRecordCount).To(Equal(1)) + Expect(q.Items[0].Name).To(Equal("Music Library")) + Expect(q.Items[0].Type).To(Equal("CollectionFolder")) + Expect(q.Items[0].CollectionType).To(Equal("music")) + }) + }) + + Describe("GET /Items by type", func() { + It("lists all albums", func() { + q := queryResult(get("/Items?IncludeItemTypes=MusicAlbum&Recursive=true")) + Expect(q.TotalRecordCount).To(Equal(5)) + Expect(names(q.Items)).To(ConsistOf("Abbey Road", "Help!", "IV", "Kind of Blue", "Singles")) + }) + + It("lists all songs with Audio type and an AlbumId", func() { + q := queryResult(get("/Items?IncludeItemTypes=Audio&Recursive=true")) + Expect(q.TotalRecordCount).To(Equal(6)) + for _, it := range q.Items { + Expect(it.Type).To(Equal("Audio")) + Expect(it.MediaType).To(Equal("Audio")) + Expect(it.AlbumId).ToNot(BeEmpty()) + } + }) + + It("lists all album artists", func() { + q := queryResult(get("/Items?IncludeItemTypes=MusicArtist&Recursive=true")) + Expect(q.TotalRecordCount).To(Equal(4)) + Expect(names(q.Items)).To(ConsistOf("The Beatles", "Led Zeppelin", "Miles Davis", "Solo Artist")) + }) + + It("lists all genres", func() { + q := queryResult(get("/Items?IncludeItemTypes=MusicGenre&Recursive=true")) + Expect(q.TotalRecordCount).To(Equal(3)) + Expect(names(q.Items)).To(ConsistOf("Rock", "Jazz", "Pop")) + }) + + It("returns no playlists when none exist", func() { + q := queryResult(get("/Items?IncludeItemTypes=Playlist&Recursive=true")) + Expect(q.TotalRecordCount).To(Equal(0)) + Expect(q.Items).To(BeEmpty()) + }) + + It("defaults to albums when IncludeItemTypes is unrecognized", func() { + q := queryResult(get("/Items?IncludeItemTypes=Nonsense&Recursive=true")) + Expect(q.TotalRecordCount).To(Equal(5)) + }) + }) + + Describe("ParentId browsing", func() { + It("browses an artist's albums", func() { + q := queryResult(get("/Items?IncludeItemTypes=MusicAlbum&ParentId=" + enc(artistID("The Beatles")))) + Expect(names(q.Items)).To(ConsistOf("Abbey Road", "Help!")) + }) + + It("browses an album's tracks in track order", func() { + q := queryResult(get("/Items?IncludeItemTypes=Audio&ParentId=" + enc(albumID("Abbey Road")))) + Expect(q.TotalRecordCount).To(Equal(2)) + Expect(names(q.Items)).To(Equal([]string{"Come Together", "Something"})) + Expect(*q.Items[0].IndexNumber).To(Equal(1)) + Expect(*q.Items[1].IndexNumber).To(Equal(2)) + }) + }) + + Describe("search, batch and pagination", func() { + It("searches albums by term", func() { + q := queryResult(get("/Items?IncludeItemTypes=MusicAlbum&Recursive=true&SearchTerm=Abbey")) + Expect(names(q.Items)).To(ContainElement("Abbey Road")) + }) + + It("batch-fetches specific items by Ids", func() { + ids := enc(albumID("Abbey Road")) + "," + enc(albumID("IV")) + q := queryResult(get("/Items?ids=" + ids)) + Expect(q.TotalRecordCount).To(Equal(2)) + Expect(names(q.Items)).To(ConsistOf("Abbey Road", "IV")) + }) + + It("applies Limit while reporting the full TotalRecordCount", func() { + q := queryResult(get("/Items?IncludeItemTypes=MusicAlbum&Recursive=true&Limit=2")) + Expect(q.Items).To(HaveLen(2)) + Expect(q.TotalRecordCount).To(Equal(5)) + }) + + It("pages distinct items via StartIndex", func() { + p1 := queryResult(get("/Items?IncludeItemTypes=MusicAlbum&Recursive=true&SortBy=SortName&Limit=2&StartIndex=0")) + p2 := queryResult(get("/Items?IncludeItemTypes=MusicAlbum&Recursive=true&SortBy=SortName&Limit=2&StartIndex=2")) + Expect(p1.Items).To(HaveLen(2)) + Expect(p2.Items).To(HaveLen(2)) + Expect(names(p1.Items)).ToNot(ContainElement(BeElementOf(names(p2.Items)))) + }) + + It("merges multiple types into one paginated result", func() { + q := queryResult(get("/Items?IncludeItemTypes=MusicAlbum,Audio&Recursive=true")) + Expect(q.TotalRecordCount).To(Equal(11)) // 5 albums + 6 songs + }) + }) + + Describe("GET /Items/{id}", func() { + It("resolves an album", func() { + var item dto.BaseItemDto + parseInto(get("/Items/"+enc(albumID("Kind of Blue"))), &item) + Expect(item.Name).To(Equal("Kind of Blue")) + Expect(item.Type).To(Equal("MusicAlbum")) + }) + + It("resolves a song", func() { + var item dto.BaseItemDto + parseInto(get("/Items/"+enc(songID("So What"))), &item) + Expect(item.Type).To(Equal("Audio")) + }) + + It("resolves an artist", func() { + var item dto.BaseItemDto + parseInto(get("/Items/"+enc(artistID("Miles Davis"))), &item) + Expect(item.Type).To(Equal("MusicArtist")) + }) + + It("returns 404 for an unknown id", func() { + Expect(get("/Items/" + enc("does-not-exist")).Code).To(Equal(http.StatusNotFound)) + }) + }) + + Describe("GET /Users/{userId}/Items/Latest", func() { + It("returns recent albums as a bare array, respecting Limit", func() { + var items []dto.BaseItemDto + parseInto(get("/Users/admin-1/Items/Latest?Limit=3"), &items) + Expect(items).To(HaveLen(3)) + for _, it := range items { + Expect(it.Type).To(Equal("MusicAlbum")) + } + }) + }) + + Describe("GET /Artists and /Genres", func() { + It("lists artists", func() { + q := queryResult(get("/Artists")) + Expect(q.TotalRecordCount).To(Equal(4)) + }) + + It("lists genres", func() { + q := queryResult(get("/Genres")) + Expect(names(q.Items)).To(ConsistOf("Rock", "Jazz", "Pop")) + }) + }) +}) diff --git a/server/jellyfin/e2e/routing_test.go b/server/jellyfin/e2e/routing_test.go new file mode 100644 index 000000000..49faad342 --- /dev/null +++ b/server/jellyfin/e2e/routing_test.go @@ -0,0 +1,31 @@ +package e2e + +import ( + "net/http" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("Routing", func() { + BeforeEach(func() { setupTestDB() }) + + It("routes authenticated endpoints case-insensitively", func() { + // Lowercase path variant of GET /Items — real clients (jellyfin-apiclient-python) send these. + lower := queryResult(get("/items?IncludeItemTypes=MusicAlbum&Recursive=true")) + Expect(lower.TotalRecordCount).To(Equal(5)) + }) + + It("returns a JSON 404 for an unknown route", func() { + w := get("/Nonexistent/Route") + Expect(w.Code).To(Equal(http.StatusNotFound)) + Expect(w.Header().Get("Content-Type")).To(HavePrefix("application/json")) + Expect(w.Body.String()).To(ContainSubstring("{}")) + }) + + It("returns 404 for an unsupported method on a known path", func() { + // PUT isn't registered for /Items; the MethodNotAllowed handler maps to the same JSON 404. + w := jReq(adminUser, "PUT", "/Items", "") + Expect(w.Code).To(Equal(http.StatusNotFound)) + }) +}) diff --git a/server/jellyfin/e2e/system_test.go b/server/jellyfin/e2e/system_test.go new file mode 100644 index 000000000..d4d2f2777 --- /dev/null +++ b/server/jellyfin/e2e/system_test.go @@ -0,0 +1,55 @@ +package e2e + +import ( + "net/http" + "strings" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("System", func() { + BeforeEach(func() { setupTestDB() }) + + Describe("GET /System/Info/Public", func() { + It("returns public server info without authentication", func() { + w := rawReq("GET", "/System/Info/Public", "") + Expect(w.Code).To(Equal(http.StatusOK)) + var info map[string]any + parseInto(w, &info) + Expect(info["ServerName"]).To(HavePrefix("Navidrome")) + Expect(info["ProductName"]).To(Equal("Jellyfin Server")) + Expect(info["StartupWizardCompleted"]).To(BeTrue()) + Expect(info["Id"]).ToNot(BeEmpty()) + Expect(info["Version"]).ToNot(BeEmpty()) + }) + + It("routes case-insensitively (lowercase path)", func() { + w := rawReq("GET", "/system/info/public", "") + Expect(w.Code).To(Equal(http.StatusOK)) + }) + }) + + Describe("GET/POST /System/Ping", func() { + It("answers GET with a plain-text server name", func() { + w := rawReq("GET", "/System/Ping", "") + Expect(w.Code).To(Equal(http.StatusOK)) + Expect(w.Header().Get("Content-Type")).To(HavePrefix("text/plain")) + Expect(w.Body.String()).To(HavePrefix("Navidrome")) + }) + + It("answers POST identically", func() { + w := rawReq("POST", "/System/Ping", "") + Expect(w.Code).To(Equal(http.StatusOK)) + Expect(strings.TrimSpace(w.Body.String())).To(HavePrefix("Navidrome")) + }) + }) + + Describe("GET /QuickConnect/Enabled", func() { + It("reports QuickConnect disabled", func() { + w := rawReq("GET", "/QuickConnect/Enabled", "") + Expect(w.Code).To(Equal(http.StatusOK)) + Expect(strings.TrimSpace(w.Body.String())).To(Equal("false")) + }) + }) +})