mirror of
https://github.com/navidrome/navidrome.git
synced 2026-10-28 14:17:10 +01:00
test(jellyfin): e2e for system, auth, routing, browsing, annotations
This commit is contained in:
parent
8ce3f0e9ef
commit
121dd71ea1
5 changed files with 437 additions and 0 deletions
81
server/jellyfin/e2e/annotations_test.go
Normal file
81
server/jellyfin/e2e/annotations_test.go
Normal 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)))
|
||||
})
|
||||
})
|
||||
})
|
||||
102
server/jellyfin/e2e/auth_test.go
Normal file
102
server/jellyfin/e2e/auth_test.go
Normal 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))
|
||||
})
|
||||
})
|
||||
})
|
||||
168
server/jellyfin/e2e/browsing_test.go
Normal file
168
server/jellyfin/e2e/browsing_test.go
Normal 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"))
|
||||
})
|
||||
})
|
||||
})
|
||||
31
server/jellyfin/e2e/routing_test.go
Normal file
31
server/jellyfin/e2e/routing_test.go
Normal 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))
|
||||
})
|
||||
})
|
||||
55
server/jellyfin/e2e/system_test.go
Normal file
55
server/jellyfin/e2e/system_test.go
Normal 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"))
|
||||
})
|
||||
})
|
||||
})
|
||||
Loading…
Add table
Add a link
Reference in a new issue