test(jellyfin): e2e for system, auth, routing, browsing, annotations

This commit is contained in:
Deluan 2026-07-06 00:33:24 -04:00
commit 121dd71ea1
5 changed files with 437 additions and 0 deletions

View file

@ -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)))
})
})
})

View file

@ -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))
})
})
})

View file

@ -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"))
})
})
})

View file

@ -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))
})
})

View file

@ -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"))
})
})
})