mirror of
https://github.com/navidrome/navidrome.git
synced 2026-08-01 07:21:17 +00:00
* refactor(jellyfin): inject core/sonic into the Jellyfin Router
* feat(jellyfin): add AudioMuse /info endpoint
* feat(jellyfin): add AudioMuse /similar_tracks endpoint
* feat(jellyfin): gate AudioMuse endpoints on sonic provider
* feat(jellyfin): add AudioMuse /find_path endpoint
* fix(jellyfin): fix case-insensitive route collision across positions
canonicalRouteSegments keyed canonical case by lower-cased segment name
alone, globally. Two unrelated routes sharing a segment name with
different casing at different tree depths (e.g. "Info" in
/System/Info/Public vs "info" in /AudioMuseAI/info) silently overwrote
each other, 404-ing the loser even for exact-case requests. Replace the
flat map with a position-aware trie mirroring the routing tree.
* test(jellyfin): e2e tests for AudioMuse endpoints
* docs(jellyfin): document AudioMuse compatibility endpoints
* test(jellyfin): harden AudioMuse tests and doc note (final-review follow-ups)
- Comment-lock the []string{} (not nil) contract for /AudioMuseAI/info's
AvailableEndpoints so it keeps serializing as [] rather than null, and
add a raw-body assertion to the existing empty-list test to catch a
regression a struct-only unmarshal can't detect.
- Cover the previously-untested engine-error branch in similar_tracks and
find_path, both of which degrade to an empty result.
- Document that find_path's path/total_distance only reflect hops through
libraries the caller can access in multi-library setups.
* refactor(jellyfin): dedup AudioMuse test request helper, presize dedup map
* refactor(sonic): expose sonic.Engine interface; drop typed-nil guard in jellyfin.New
The Jellyfin Router's sonic field was an interface but New() took the concrete
*sonic.Sonic, so a nil arg became a non-nil typed-nil and needed a guard — the
only injected dependency that did. Move the interface (sonic.Engine) beside its
implementation, take it in New() like every other service, and bind it in wire.
* refactor(jellyfin): case-insensitive routing via lowercased paths
Replace the position-aware route trie with a trivial middleware that lowercases
the request path, and register every route in lowercase. Simpler, and no segment
name can collide across positions. caseInsensitivePaths moves into middlewares.go
alongside normalizeQueryKeys. Relies on the invariant that no Jellyfin path segment
carries case-sensitive data (all ids are lowercase hex via dto.EncodeID).
* feat(jellyfin): add AudioMuse /health endpoint
A liveness probe matching the reference plugin: 200 with an empty body when a
SonicSimilarity provider is loaded, 404 otherwise. /AudioMuseAI/info now
advertises it (list alphabetized like the plugin's OrderBy).
Also trims the AudioMuse and case-insensitive-routing comments to their essential
rationale.
* fix(jellyfin): hex-encode user IDs so lowercased paths stay valid
Address PR review: user IDs were the one id the Jellyfin API emitted raw (base62,
uppercase-capable), so lowercasing request paths could alter a userId segment. Encode
them via dto.EncodeID like every other id, making the 'all boundary ids are lowercase
hex' invariant true — no routing special-casing needed. Also caps user-controlled n /
max_steps, fixes the songAgent test comment, and adds leading slashes to the README
endpoint list.
465 lines
17 KiB
Go
465 lines
17 KiB
Go
package jellyfin
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"io"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"strings"
|
|
|
|
"github.com/go-chi/chi/v5"
|
|
"github.com/navidrome/navidrome/core/playlists"
|
|
"github.com/navidrome/navidrome/model"
|
|
"github.com/navidrome/navidrome/model/request"
|
|
"github.com/navidrome/navidrome/server/filter"
|
|
"github.com/navidrome/navidrome/server/jellyfin/dto"
|
|
"github.com/navidrome/navidrome/tests"
|
|
. "github.com/onsi/ginkgo/v2"
|
|
. "github.com/onsi/gomega"
|
|
)
|
|
|
|
// fakePlaylists is a local fake for core/playlists.Playlists. It embeds the interface so
|
|
// unimplemented methods aren't needed here; only the ones this test exercises are overridden.
|
|
type fakePlaylists struct {
|
|
playlists.Playlists
|
|
|
|
createdName string
|
|
createdIds []string
|
|
createErr error
|
|
|
|
getPls *model.Playlist
|
|
getErr error
|
|
tracksRepo *tests.MockPlaylistTrackRepo
|
|
|
|
getByIDPls *model.Playlist
|
|
getByIDErr error
|
|
|
|
addPlaylistID string
|
|
addIds []string
|
|
addErr error
|
|
|
|
removePlaylistID string
|
|
removeIds []string
|
|
removeErr error
|
|
|
|
setImagePlaylistID string
|
|
setImageBytes []byte
|
|
setImageExt string
|
|
setImageErr error
|
|
|
|
removeImagePlaylistID string
|
|
removeImageErr error
|
|
|
|
deletePlaylistID string
|
|
deleteErr error
|
|
}
|
|
|
|
func (f *fakePlaylists) Delete(_ context.Context, id string) error {
|
|
f.deletePlaylistID = id
|
|
return f.deleteErr
|
|
}
|
|
|
|
func (f *fakePlaylists) Create(_ context.Context, _ string, name string, ids []string) (string, error) {
|
|
f.createdName = name
|
|
f.createdIds = ids
|
|
if f.createErr != nil {
|
|
return "", f.createErr
|
|
}
|
|
return "pl-new", nil
|
|
}
|
|
|
|
// Get defaults to model.ErrNotFound when getByIDPls/getByIDErr aren't set, matching the real
|
|
// service's behavior for a missing or inaccessible playlist and letting getItem tests that don't
|
|
// care about playlists leave it unconfigured.
|
|
func (f *fakePlaylists) Get(_ context.Context, _ string) (*model.Playlist, error) {
|
|
if f.getByIDErr != nil {
|
|
return nil, f.getByIDErr
|
|
}
|
|
if f.getByIDPls == nil {
|
|
return nil, model.ErrNotFound
|
|
}
|
|
return f.getByIDPls, nil
|
|
}
|
|
|
|
func (f *fakePlaylists) GetWithTracks(_ context.Context, _ string) (*model.Playlist, error) {
|
|
if f.getErr != nil {
|
|
return nil, f.getErr
|
|
}
|
|
if f.getPls == nil {
|
|
return nil, model.ErrNotFound // mirror the real repo: never (nil, nil)
|
|
}
|
|
return f.getPls, nil
|
|
}
|
|
|
|
// Tracks serves the same getPls fixture as GetWithTracks. tracksRepo is kept so tests can assert
|
|
// what was pushed down to the query.
|
|
func (f *fakePlaylists) Tracks(_ context.Context, _ string) (model.PlaylistTrackRepository, error) {
|
|
if f.getErr != nil {
|
|
return nil, f.getErr
|
|
}
|
|
if f.getPls == nil {
|
|
return nil, model.ErrNotFound
|
|
}
|
|
f.tracksRepo = &tests.MockPlaylistTrackRepo{}
|
|
f.tracksRepo.SetData(f.getPls.Tracks)
|
|
return f.tracksRepo, nil
|
|
}
|
|
|
|
func (f *fakePlaylists) AddTracks(_ context.Context, playlistID string, ids []string) (int, error) {
|
|
f.addPlaylistID = playlistID
|
|
f.addIds = ids
|
|
return len(ids), f.addErr
|
|
}
|
|
|
|
func (f *fakePlaylists) RemoveTracks(_ context.Context, playlistID string, trackIds []string) error {
|
|
f.removePlaylistID = playlistID
|
|
f.removeIds = trackIds
|
|
return f.removeErr
|
|
}
|
|
|
|
func (f *fakePlaylists) SetImage(_ context.Context, playlistID string, reader io.Reader, ext string) error {
|
|
f.setImagePlaylistID = playlistID
|
|
f.setImageExt = ext
|
|
if reader != nil {
|
|
f.setImageBytes, _ = io.ReadAll(reader)
|
|
}
|
|
return f.setImageErr
|
|
}
|
|
|
|
func (f *fakePlaylists) RemoveImage(_ context.Context, playlistID string) error {
|
|
f.removeImagePlaylistID = playlistID
|
|
return f.removeImageErr
|
|
}
|
|
|
|
var _ = Describe("Playlists", func() {
|
|
var api *Router
|
|
var fp *fakePlaylists
|
|
|
|
BeforeEach(func() {
|
|
fp = &fakePlaylists{}
|
|
api = &Router{ds: &tests.MockDataStore{}, playlists: fp}
|
|
})
|
|
|
|
Describe("createPlaylist", func() {
|
|
It("creates a playlist and returns its id", func() {
|
|
w := httptest.NewRecorder()
|
|
r := httptest.NewRequest("POST", "/Playlists", strings.NewReader(`{"Name":"Mix","Ids":["s1","s2"]}`)).
|
|
WithContext(context.Background())
|
|
invoke(api.createPlaylist, w, r)
|
|
Expect(w.Code).To(Equal(http.StatusOK))
|
|
var res map[string]string
|
|
Expect(json.Unmarshal(w.Body.Bytes(), &res)).To(Succeed())
|
|
Expect(res["Id"]).To(Equal(dto.EncodeID("pl-new")))
|
|
Expect(fp.createdName).To(Equal("Mix"))
|
|
Expect(fp.createdIds).To(Equal([]string{"s1", "s2"}))
|
|
})
|
|
|
|
It("returns 400 on an invalid JSON body", func() {
|
|
w := httptest.NewRecorder()
|
|
r := httptest.NewRequest("POST", "/Playlists", strings.NewReader(`not json`)).
|
|
WithContext(context.Background())
|
|
invoke(api.createPlaylist, w, r)
|
|
Expect(w.Code).To(Equal(http.StatusBadRequest))
|
|
})
|
|
|
|
It("returns 500 when the service fails", func() {
|
|
fp.createErr = errors.New("boom")
|
|
w := httptest.NewRecorder()
|
|
r := httptest.NewRequest("POST", "/Playlists", strings.NewReader(`{"Name":"Mix"}`)).
|
|
WithContext(context.Background())
|
|
invoke(api.createPlaylist, w, r)
|
|
Expect(w.Code).To(Equal(http.StatusInternalServerError))
|
|
})
|
|
})
|
|
|
|
Describe("getPlaylistItems", func() {
|
|
It("maps playlist tracks to Audio BaseItemDtos, tagging each with its PlaylistItemId", func() {
|
|
fp.getPls = &model.Playlist{
|
|
ID: "pl1",
|
|
Tracks: model.PlaylistTracks{
|
|
{ID: "1", MediaFileID: "s1", PlaylistID: "pl1", MediaFile: model.MediaFile{ID: "s1", Title: "Song One"}},
|
|
{ID: "2", MediaFileID: "s2", PlaylistID: "pl1", MediaFile: model.MediaFile{ID: "s2", Title: "Song Two"}},
|
|
},
|
|
}
|
|
w := httptest.NewRecorder()
|
|
r := httptest.NewRequest("GET", "/Playlists/pl1/Items", nil).WithContext(context.Background())
|
|
r = withChiURLParam(r, "playlistId", "pl1")
|
|
api.getPlaylistItems(w, r)
|
|
Expect(w.Code).To(Equal(http.StatusOK))
|
|
var res dto.QueryResult
|
|
Expect(json.Unmarshal(w.Body.Bytes(), &res)).To(Succeed())
|
|
Expect(res.TotalRecordCount).To(Equal(2))
|
|
Expect(res.Items).To(HaveLen(2))
|
|
Expect(res.Items[0].Id).To(Equal(dto.EncodeID("s1")))
|
|
Expect(res.Items[0].Type).To(Equal("Audio"))
|
|
Expect(res.Items[0].PlaylistItemId).To(Equal(dto.EncodeID("1")))
|
|
Expect(res.Items[1].Id).To(Equal(dto.EncodeID("s2")))
|
|
Expect(res.Items[1].PlaylistItemId).To(Equal(dto.EncodeID("2")))
|
|
})
|
|
|
|
It("pages with StartIndex/Limit, pushing them down to the query", func() {
|
|
fp.getPls = &model.Playlist{
|
|
ID: "pl1",
|
|
Tracks: model.PlaylistTracks{
|
|
{ID: "1", MediaFileID: "s1", PlaylistID: "pl1", MediaFile: model.MediaFile{ID: "s1"}},
|
|
{ID: "2", MediaFileID: "s2", PlaylistID: "pl1", MediaFile: model.MediaFile{ID: "s2"}},
|
|
{ID: "3", MediaFileID: "s3", PlaylistID: "pl1", MediaFile: model.MediaFile{ID: "s3"}},
|
|
},
|
|
}
|
|
w := httptest.NewRecorder()
|
|
r := httptest.NewRequest("GET", "/Playlists/pl1/Items?StartIndex=1&Limit=1", nil).
|
|
WithContext(context.Background())
|
|
r = withChiURLParam(r, "playlistId", "pl1")
|
|
invoke(api.getPlaylistItems, w, r)
|
|
Expect(w.Code).To(Equal(http.StatusOK))
|
|
var res dto.QueryResult
|
|
Expect(json.Unmarshal(w.Body.Bytes(), &res)).To(Succeed())
|
|
Expect(res.TotalRecordCount).To(Equal(3))
|
|
Expect(res.Items).To(HaveLen(1))
|
|
Expect(res.Items[0].Id).To(Equal(dto.EncodeID("s2")))
|
|
Expect(fp.tracksRepo.Options.Offset).To(Equal(1))
|
|
Expect(fp.tracksRepo.Options.Max).To(Equal(1))
|
|
})
|
|
|
|
It("returns 404 for a non-owned or absent playlist", func() {
|
|
fp.getErr = model.ErrNotFound
|
|
w := httptest.NewRecorder()
|
|
r := httptest.NewRequest("GET", "/Playlists/missing/Items", nil).WithContext(context.Background())
|
|
r = withChiURLParam(r, "playlistId", "missing")
|
|
api.getPlaylistItems(w, r)
|
|
Expect(w.Code).To(Equal(http.StatusNotFound))
|
|
})
|
|
})
|
|
|
|
Describe("container id expansion", func() {
|
|
var ds *tests.MockDataStore
|
|
var ctx context.Context
|
|
|
|
BeforeEach(func() {
|
|
ctx = context.Background()
|
|
ds = &tests.MockDataStore{}
|
|
api = &Router{ds: ds, playlists: fp}
|
|
})
|
|
|
|
createWith := func(id string) {
|
|
w := httptest.NewRecorder()
|
|
r := httptest.NewRequest("POST", "/Playlists", strings.NewReader(`{"Name":"Mix","Ids":["`+id+`"]}`)).
|
|
WithContext(ctx)
|
|
invoke(api.createPlaylist, w, r)
|
|
Expect(w.Code).To(Equal(http.StatusOK))
|
|
}
|
|
|
|
It("passes a bare song id through unchanged", func() {
|
|
ds.MediaFile(ctx).(*tests.MockMediaFileRepo).SetData(model.MediaFiles{{ID: "s1"}})
|
|
createWith("s1")
|
|
Expect(fp.createdIds).To(Equal([]string{"s1"}))
|
|
})
|
|
|
|
It("expands an album id into its songs, filtered by album", func() {
|
|
ds.Album(ctx).(*tests.MockAlbumRepo).SetData(model.Albums{{ID: "al1"}})
|
|
ds.MediaFile(ctx).(*tests.MockMediaFileRepo).SetData(model.MediaFiles{
|
|
{ID: "s1", AlbumID: "al1"}, {ID: "s2", AlbumID: "al1"},
|
|
})
|
|
createWith("al1")
|
|
Expect(fp.createdIds).To(Equal([]string{"s1", "s2"}))
|
|
Expect(ds.MediaFile(ctx).(*tests.MockMediaFileRepo).Options.Filters).To(Equal(filter.SongsByAlbum("al1").Filters))
|
|
})
|
|
|
|
It("expands an artist id into its songs", func() {
|
|
ds.Artist(ctx).(*tests.MockArtistRepo).SetData(model.Artists{{ID: "ar1"}})
|
|
ds.MediaFile(ctx).(*tests.MockMediaFileRepo).SetData(model.MediaFiles{{ID: "s1"}, {ID: "s2"}})
|
|
createWith("ar1")
|
|
Expect(fp.createdIds).To(Equal([]string{"s1", "s2"}))
|
|
Expect(ds.MediaFile(ctx).(*tests.MockMediaFileRepo).Options.Filters).To(Equal(filter.SongsByArtistID("ar1").Filters))
|
|
})
|
|
|
|
It("expands a playlist id into its tracks' media file ids", func() {
|
|
fp.getPls = &model.Playlist{ID: "pl9", Tracks: model.PlaylistTracks{
|
|
{ID: "1", MediaFileID: "s3"}, {ID: "2", MediaFileID: "s4"},
|
|
}}
|
|
createWith("pl9")
|
|
Expect(fp.createdIds).To(Equal([]string{"s3", "s4"}))
|
|
})
|
|
})
|
|
|
|
Describe("getPlaylist", func() {
|
|
It("returns OpenAccess from Public and item ids (encoded media file ids, not entry ids)", func() {
|
|
pls := &model.Playlist{
|
|
ID: "pl1",
|
|
Public: true,
|
|
Tracks: model.PlaylistTracks{
|
|
{ID: "1", MediaFileID: "s1", PlaylistID: "pl1", MediaFile: model.MediaFile{ID: "s1"}},
|
|
{ID: "2", MediaFileID: "s2", PlaylistID: "pl1", MediaFile: model.MediaFile{ID: "s2"}},
|
|
},
|
|
}
|
|
fp.getPls, fp.getByIDPls = pls, pls
|
|
w := httptest.NewRecorder()
|
|
r := httptest.NewRequest("GET", "/Playlists/pl1", nil).WithContext(context.Background())
|
|
r = withChiURLParam(r, "playlistId", "pl1")
|
|
invoke(api.getPlaylist, w, r)
|
|
Expect(w.Code).To(Equal(http.StatusOK))
|
|
var res dto.PlaylistInfo
|
|
Expect(json.Unmarshal(w.Body.Bytes(), &res)).To(Succeed())
|
|
Expect(res.OpenAccess).To(BeTrue())
|
|
Expect(res.Shares).To(BeEmpty())
|
|
Expect(res.ItemIds).To(Equal([]string{dto.EncodeID("s1"), dto.EncodeID("s2")}))
|
|
})
|
|
|
|
It("returns 404 for a non-owned or absent playlist", func() {
|
|
fp.getErr = model.ErrNotFound
|
|
w := httptest.NewRecorder()
|
|
r := httptest.NewRequest("GET", "/Playlists/missing", nil).WithContext(context.Background())
|
|
r = withChiURLParam(r, "playlistId", "missing")
|
|
invoke(api.getPlaylist, w, r)
|
|
Expect(w.Code).To(Equal(http.StatusNotFound))
|
|
})
|
|
})
|
|
|
|
Describe("deleteItem", func() {
|
|
deleteReq := func(id string) *http.Request {
|
|
r := httptest.NewRequest("DELETE", "/Items/"+dto.EncodeID(id), nil).WithContext(context.Background())
|
|
return withChiURLParam(r, "itemId", dto.EncodeID(id))
|
|
}
|
|
|
|
It("deletes the playlist and returns 204", func() {
|
|
w := httptest.NewRecorder()
|
|
api.deleteItem(w, deleteReq("pl1"))
|
|
Expect(w.Code).To(Equal(http.StatusNoContent))
|
|
Expect(fp.deletePlaylistID).To(Equal("pl1"))
|
|
})
|
|
|
|
It("returns 403 when the user doesn't own the playlist", func() {
|
|
fp.deleteErr = model.ErrNotAuthorized
|
|
w := httptest.NewRecorder()
|
|
api.deleteItem(w, deleteReq("pl1"))
|
|
Expect(w.Code).To(Equal(http.StatusForbidden))
|
|
})
|
|
|
|
It("returns 404 for a missing playlist or non-playlist id", func() {
|
|
fp.deleteErr = model.ErrNotFound
|
|
w := httptest.NewRecorder()
|
|
api.deleteItem(w, deleteReq("al1"))
|
|
Expect(w.Code).To(Equal(http.StatusNotFound))
|
|
})
|
|
|
|
It("returns 500 on an unexpected error", func() {
|
|
fp.deleteErr = errors.New("boom")
|
|
w := httptest.NewRecorder()
|
|
api.deleteItem(w, deleteReq("pl1"))
|
|
Expect(w.Code).To(Equal(http.StatusInternalServerError))
|
|
})
|
|
})
|
|
|
|
Describe("addToPlaylist", func() {
|
|
It("adds tracks by song id from the lowercase ids param real Jellyfin clients send", func() {
|
|
w := httptest.NewRecorder()
|
|
r := httptest.NewRequest("POST", "/Playlists/pl1/Items?ids=s1,s2", nil).WithContext(context.Background())
|
|
r = withChiURLParam(r, "playlistId", "pl1")
|
|
invoke(api.addToPlaylist, w, r)
|
|
Expect(w.Code).To(Equal(http.StatusNoContent))
|
|
Expect(fp.addPlaylistID).To(Equal("pl1"))
|
|
Expect(fp.addIds).To(Equal([]string{"s1", "s2"}))
|
|
})
|
|
|
|
It("accepts a PascalCase Ids param (case-folded by the middleware)", func() {
|
|
w := httptest.NewRecorder()
|
|
r := httptest.NewRequest("POST", "/Playlists/pl1/Items?Ids=s1,s2", nil).WithContext(context.Background())
|
|
r = withChiURLParam(r, "playlistId", "pl1")
|
|
invoke(api.addToPlaylist, w, r)
|
|
Expect(w.Code).To(Equal(http.StatusNoContent))
|
|
Expect(fp.addIds).To(Equal([]string{"s1", "s2"}))
|
|
})
|
|
|
|
It("returns 404 when the service rejects the request (not found/not owned)", func() {
|
|
fp.addErr = model.ErrNotAuthorized
|
|
w := httptest.NewRecorder()
|
|
r := httptest.NewRequest("POST", "/Playlists/pl1/Items?ids=s1", nil).WithContext(context.Background())
|
|
r = withChiURLParam(r, "playlistId", "pl1")
|
|
invoke(api.addToPlaylist, w, r)
|
|
Expect(w.Code).To(Equal(http.StatusNotFound))
|
|
})
|
|
|
|
It("passes no ids (not a spurious empty string) when the ids param is absent", func() {
|
|
w := httptest.NewRecorder()
|
|
r := httptest.NewRequest("POST", "/Playlists/pl1/Items", nil).WithContext(context.Background())
|
|
r = withChiURLParam(r, "playlistId", "pl1")
|
|
invoke(api.addToPlaylist, w, r)
|
|
Expect(w.Code).To(Equal(http.StatusNoContent))
|
|
Expect(fp.addPlaylistID).To(Equal("pl1"))
|
|
Expect(fp.addIds).To(BeEmpty())
|
|
})
|
|
})
|
|
|
|
Describe("removeFromPlaylist", func() {
|
|
It("removes entries by the lowercase entryIds param real Jellyfin clients send (playlist-track position ids, not song ids)", func() {
|
|
w := httptest.NewRecorder()
|
|
r := httptest.NewRequest("DELETE", "/Playlists/pl1/Items?entryIds=1,2", nil).WithContext(context.Background())
|
|
r = withChiURLParam(r, "playlistId", "pl1")
|
|
invoke(api.removeFromPlaylist, w, r)
|
|
Expect(w.Code).To(Equal(http.StatusNoContent))
|
|
Expect(fp.removePlaylistID).To(Equal("pl1"))
|
|
Expect(fp.removeIds).To(Equal([]string{"1", "2"}))
|
|
})
|
|
|
|
It("accepts a PascalCase EntryIds param (case-folded by the middleware)", func() {
|
|
w := httptest.NewRecorder()
|
|
r := httptest.NewRequest("DELETE", "/Playlists/pl1/Items?EntryIds=1,2", nil).WithContext(context.Background())
|
|
r = withChiURLParam(r, "playlistId", "pl1")
|
|
invoke(api.removeFromPlaylist, w, r)
|
|
Expect(w.Code).To(Equal(http.StatusNoContent))
|
|
Expect(fp.removeIds).To(Equal([]string{"1", "2"}))
|
|
})
|
|
|
|
It("returns 404 when the service rejects the request (not found/not owned)", func() {
|
|
fp.removeErr = model.ErrNotFound
|
|
w := httptest.NewRecorder()
|
|
r := httptest.NewRequest("DELETE", "/Playlists/pl1/Items?entryIds=1", nil).WithContext(context.Background())
|
|
r = withChiURLParam(r, "playlistId", "pl1")
|
|
invoke(api.removeFromPlaylist, w, r)
|
|
Expect(w.Code).To(Equal(http.StatusNotFound))
|
|
})
|
|
|
|
It("passes no ids (not a spurious empty string) when the entryIds param is absent", func() {
|
|
w := httptest.NewRecorder()
|
|
r := httptest.NewRequest("DELETE", "/Playlists/pl1/Items", nil).WithContext(context.Background())
|
|
r = withChiURLParam(r, "playlistId", "pl1")
|
|
invoke(api.removeFromPlaylist, w, r)
|
|
Expect(w.Code).To(Equal(http.StatusNoContent))
|
|
Expect(fp.removePlaylistID).To(Equal("pl1"))
|
|
Expect(fp.removeIds).To(BeEmpty())
|
|
})
|
|
})
|
|
|
|
Describe("getPlaylistUsers", func() {
|
|
It("returns the current user with CanEdit true", func() {
|
|
w := httptest.NewRecorder()
|
|
ctx := request.WithUser(context.Background(), model.User{ID: "u1", UserName: "alice"})
|
|
r := httptest.NewRequest("GET", "/Playlists/pl1/Users", nil).WithContext(ctx)
|
|
r = withChiURLParam(r, "playlistId", "pl1")
|
|
api.getPlaylistUsers(w, r)
|
|
Expect(w.Code).To(Equal(http.StatusOK))
|
|
var res []dto.PlaylistUserPermissions
|
|
Expect(json.Unmarshal(w.Body.Bytes(), &res)).To(Succeed())
|
|
Expect(res).To(Equal([]dto.PlaylistUserPermissions{{UserId: dto.EncodeID("u1"), CanEdit: true}}))
|
|
})
|
|
})
|
|
|
|
Describe("getPlaylistUser", func() {
|
|
It("returns CanEdit true for the requested user", func() {
|
|
w := httptest.NewRecorder()
|
|
r := httptest.NewRequest("GET", "/Playlists/pl1/Users/u1", nil).WithContext(context.Background())
|
|
rctx := chi.NewRouteContext()
|
|
rctx.URLParams.Add("playlistId", "pl1")
|
|
rctx.URLParams.Add("userId", "u1")
|
|
r = r.WithContext(context.WithValue(r.Context(), chi.RouteCtxKey, rctx))
|
|
api.getPlaylistUser(w, r)
|
|
Expect(w.Code).To(Equal(http.StatusOK))
|
|
var res dto.PlaylistUserPermissions
|
|
Expect(json.Unmarshal(w.Body.Bytes(), &res)).To(Succeed())
|
|
Expect(res).To(Equal(dto.PlaylistUserPermissions{UserId: "u1", CanEdit: true}))
|
|
})
|
|
})
|
|
})
|