mirror of
https://github.com/navidrome/navidrome.git
synced 2026-10-24 18:27:08 +02:00
* refactor(persistence): adopt generic deluan/rest repository API Pin deluan/rest to the refactor branch. REST-facing repository methods take a context and return typed values. Drop DataStore.Resource and ResourceRepository; the native API names typed repositories directly through a per-request adapter that later commits remove. * refactor(persistence): base repository helpers take a context * refactor(persistence): LibraryRepository takes a context per call * refactor(persistence): PropertyRepository takes a context per call * refactor(persistence): UserPropsRepository takes a context per call * refactor(persistence): TranscodingRepository takes a context per call * refactor(persistence): ShareRepository takes a context per call * refactor(persistence): PlayerRepository takes a context per call * refactor(persistence): RadioRepository takes a context per call * refactor(persistence): PlayQueueRepository takes a context per call * refactor(persistence): Tag and Genre repositories take a context per call * refactor(persistence): PluginRepository takes a context per call * refactor(persistence): Scrobble repositories take a context per call * refactor(persistence): FolderRepository takes a context per call * refactor(persistence): Artwork repositories take a context per call * refactor(persistence): UserRepository takes a context per call * refactor(persistence): ArtistRepository takes a context per call ReadAll no longer rewrites the shared sort mappings for the role filter; it works on a per-call copy. * test(persistence): assert artist role sort sanitization in ReadAll * refactor(persistence): AlbumRepository takes a context per call * test(persistence): pass the test context to album repository helpers * refactor(persistence): MediaFileRepository takes a context per call * refactor(persistence): Playlist repositories take a context per call * refactor(persistence): build all repositories once per store * refactor(core): REST repository wrappers are built once * refactor(persistence): repositories are stateless Remove the context field from the base repository and the per-request REST adapter. Enable the containedctx linter so no repository can hold a request context again. * chore(lint): skip containedctx in test files * refactor: share simplifications from the stateless repositories sweep Add deleteOwnedAll on sqlRepository and use it in player/share Delete to remove the duplicated bulk-delete loop; have Share.Repository() return model.ShareRepository so subsonic sharing.go drops its repeated type assertions. * chore(core): assert REST wrappers implement Persistable * chore: reformat imports * perf(persistence): build repositories on first use Each transaction store used to construct all 21 repositories up front, paying for filter and sort mapping setup the block never touched. Fields are now sync.OnceValue thunks, so a store only builds what it uses. * fix(persistence): clean plugin references per deleted user A bulk user delete that fails on a later id had already removed the earlier rows but skipped their plugin cleanup. Cleanup now runs right after each successful delete. * fix(core): unload disabled plugins even when a user delete fails A bulk delete can fail on a later id after earlier users were removed and their plugins auto-disabled. The wrapper returned before unloading, leaving those plugins running until the next successful delete or a restart. * chore(deps): pin deluan/rest to v1.0.1 Replaces the pseudo-version of the refactor branch with the tagged release. REST error messages now name the bare type (Artist, not model.Artist). * test: use the spec context instead of context.Background() Replace the context.Background()/context.TODO() calls this branch added to tests with the spec's ctx, GinkgoT().Context(), or t/b.Context(), so repository calls are bound to the running spec's lifetime. * test: declare the spec context once per Describe Set ctx from GinkgoT().Context() first in each top-level BeforeEach and reuse it, building user contexts on top of it instead of repeating inline calls.
688 lines
25 KiB
Go
688 lines
25 KiB
Go
package e2e
|
|
|
|
import (
|
|
"time"
|
|
|
|
"github.com/navidrome/navidrome/conf"
|
|
"github.com/navidrome/navidrome/model"
|
|
"github.com/navidrome/navidrome/model/criteria"
|
|
"github.com/navidrome/navidrome/server/subsonic/responses"
|
|
. "github.com/onsi/ginkgo/v2"
|
|
. "github.com/onsi/gomega"
|
|
)
|
|
|
|
var _ = Describe("Playlist Endpoints", Ordered, func() {
|
|
var playlistID string
|
|
var songIDs []string
|
|
|
|
BeforeAll(func() {
|
|
setupTestDB()
|
|
|
|
// Look up song IDs from scanned data for playlist operations
|
|
songs, err := ds.MediaFile().GetAll(ctx, model.QueryOptions{Sort: "title", Max: 6})
|
|
Expect(err).ToNot(HaveOccurred())
|
|
Expect(len(songs)).To(BeNumerically(">=", 5))
|
|
for _, s := range songs {
|
|
songIDs = append(songIDs, s.ID)
|
|
}
|
|
})
|
|
|
|
It("getPlaylists returns empty list initially", func() {
|
|
resp := doReq("getPlaylists")
|
|
|
|
Expect(resp.Status).To(Equal(responses.StatusOK))
|
|
Expect(resp.Playlists).ToNot(BeNil())
|
|
Expect(resp.Playlists.Playlist).To(BeEmpty())
|
|
})
|
|
|
|
It("createPlaylist creates a new playlist with songs", func() {
|
|
resp := doReq("createPlaylist", "name", "Test Playlist",
|
|
"songId", songIDs[0], "songId", songIDs[1], "songId", songIDs[2])
|
|
|
|
Expect(resp.Status).To(Equal(responses.StatusOK))
|
|
Expect(resp.Playlist).ToNot(BeNil())
|
|
Expect(resp.Playlist.Name).To(Equal("Test Playlist"))
|
|
Expect(resp.Playlist.SongCount).To(Equal(int32(3)))
|
|
Expect(resp.Playlist.Entry).To(HaveLen(3))
|
|
Expect(resp.Playlist.Entry[0].Id).To(Equal(songIDs[0]))
|
|
Expect(resp.Playlist.Entry[1].Id).To(Equal(songIDs[1]))
|
|
Expect(resp.Playlist.Entry[2].Id).To(Equal(songIDs[2]))
|
|
playlistID = resp.Playlist.Id
|
|
})
|
|
|
|
It("getPlaylist returns playlist with tracks in order", func() {
|
|
resp := doReq("getPlaylist", "id", playlistID)
|
|
|
|
Expect(resp.Status).To(Equal(responses.StatusOK))
|
|
Expect(resp.Playlist).ToNot(BeNil())
|
|
Expect(resp.Playlist.Name).To(Equal("Test Playlist"))
|
|
Expect(resp.Playlist.Entry).To(HaveLen(3))
|
|
Expect(resp.Playlist.Entry[0].Id).To(Equal(songIDs[0]))
|
|
Expect(resp.Playlist.Entry[1].Id).To(Equal(songIDs[1]))
|
|
Expect(resp.Playlist.Entry[2].Id).To(Equal(songIDs[2]))
|
|
})
|
|
|
|
It("createPlaylist without name or playlistId returns error", func() {
|
|
resp := doReq("createPlaylist", "songId", songIDs[0])
|
|
|
|
Expect(resp.Status).To(Equal(responses.StatusFailed))
|
|
Expect(resp.Error).ToNot(BeNil())
|
|
})
|
|
|
|
It("createPlaylist with playlistId replaces tracks on existing playlist", func() {
|
|
// Replace tracks: the playlist had [song0, song1, song2], replace with [song3, song4]
|
|
resp := doReq("createPlaylist", "playlistId", playlistID,
|
|
"songId", songIDs[3], "songId", songIDs[4])
|
|
|
|
Expect(resp.Status).To(Equal(responses.StatusOK))
|
|
Expect(resp.Playlist).ToNot(BeNil())
|
|
Expect(resp.Playlist.Id).To(Equal(playlistID))
|
|
Expect(resp.Playlist.SongCount).To(Equal(int32(2)))
|
|
Expect(resp.Playlist.Entry).To(HaveLen(2))
|
|
Expect(resp.Playlist.Entry[0].Id).To(Equal(songIDs[3]))
|
|
Expect(resp.Playlist.Entry[1].Id).To(Equal(songIDs[4]))
|
|
})
|
|
|
|
It("updatePlaylist can rename the playlist", func() {
|
|
resp := doReq("updatePlaylist", "playlistId", playlistID, "name", "Renamed Playlist")
|
|
Expect(resp.Status).To(Equal(responses.StatusOK))
|
|
|
|
// Verify the rename
|
|
resp = doReq("getPlaylist", "id", playlistID)
|
|
Expect(resp.Playlist.Name).To(Equal("Renamed Playlist"))
|
|
// Tracks should be unchanged
|
|
Expect(resp.Playlist.SongCount).To(Equal(int32(2)))
|
|
})
|
|
|
|
It("updatePlaylist can set comment", func() {
|
|
resp := doReq("updatePlaylist", "playlistId", playlistID, "comment", "My favorite songs")
|
|
Expect(resp.Status).To(Equal(responses.StatusOK))
|
|
|
|
resp = doReq("getPlaylist", "id", playlistID)
|
|
Expect(resp.Playlist.Comment).To(Equal("My favorite songs"))
|
|
})
|
|
|
|
It("updatePlaylist can set public visibility", func() {
|
|
resp := doReq("updatePlaylist", "playlistId", playlistID, "public", "true")
|
|
Expect(resp.Status).To(Equal(responses.StatusOK))
|
|
|
|
resp = doReq("getPlaylist", "id", playlistID)
|
|
Expect(resp.Playlist.Public).To(BeTrue())
|
|
})
|
|
|
|
It("updatePlaylist can add songs", func() {
|
|
// Playlist currently has [song3, song4], add song0
|
|
resp := doReq("updatePlaylist", "playlistId", playlistID, "songIdToAdd", songIDs[0])
|
|
Expect(resp.Status).To(Equal(responses.StatusOK))
|
|
|
|
resp = doReq("getPlaylist", "id", playlistID)
|
|
Expect(resp.Playlist.SongCount).To(Equal(int32(3)))
|
|
Expect(resp.Playlist.Entry).To(HaveLen(3))
|
|
Expect(resp.Playlist.Entry[0].Id).To(Equal(songIDs[3]))
|
|
Expect(resp.Playlist.Entry[1].Id).To(Equal(songIDs[4]))
|
|
Expect(resp.Playlist.Entry[2].Id).To(Equal(songIDs[0]))
|
|
})
|
|
|
|
It("updatePlaylist can add multiple songs at once", func() {
|
|
// Playlist currently has [song3, song4, song0], add song1 and song2
|
|
resp := doReq("updatePlaylist", "playlistId", playlistID,
|
|
"songIdToAdd", songIDs[1], "songIdToAdd", songIDs[2])
|
|
Expect(resp.Status).To(Equal(responses.StatusOK))
|
|
|
|
resp = doReq("getPlaylist", "id", playlistID)
|
|
Expect(resp.Playlist.SongCount).To(Equal(int32(5)))
|
|
Expect(resp.Playlist.Entry).To(HaveLen(5))
|
|
})
|
|
|
|
It("updatePlaylist can remove songs by index and verifies correct songs remain", func() {
|
|
// Playlist has [song3, song4, song0, song1, song2]
|
|
// Remove index 0 (song3) and index 2 (song0)
|
|
resp := doReq("updatePlaylist", "playlistId", playlistID,
|
|
"songIndexToRemove", "0", "songIndexToRemove", "2")
|
|
Expect(resp.Status).To(Equal(responses.StatusOK))
|
|
|
|
resp = doReq("getPlaylist", "id", playlistID)
|
|
Expect(resp.Playlist.SongCount).To(Equal(int32(3)))
|
|
Expect(resp.Playlist.Entry).To(HaveLen(3))
|
|
Expect(resp.Playlist.Entry[0].Id).To(Equal(songIDs[4]))
|
|
Expect(resp.Playlist.Entry[1].Id).To(Equal(songIDs[1]))
|
|
Expect(resp.Playlist.Entry[2].Id).To(Equal(songIDs[2]))
|
|
})
|
|
|
|
It("updatePlaylist can remove and add songs in a single call", func() {
|
|
// Playlist has [song4, song1, song2]
|
|
// Remove index 1 (song1) and add song3
|
|
resp := doReq("updatePlaylist", "playlistId", playlistID,
|
|
"songIndexToRemove", "1", "songIdToAdd", songIDs[3])
|
|
Expect(resp.Status).To(Equal(responses.StatusOK))
|
|
|
|
resp = doReq("getPlaylist", "id", playlistID)
|
|
Expect(resp.Playlist.SongCount).To(Equal(int32(3)))
|
|
Expect(resp.Playlist.Entry).To(HaveLen(3))
|
|
Expect(resp.Playlist.Entry[0].Id).To(Equal(songIDs[4]))
|
|
Expect(resp.Playlist.Entry[1].Id).To(Equal(songIDs[2]))
|
|
Expect(resp.Playlist.Entry[2].Id).To(Equal(songIDs[3]))
|
|
})
|
|
|
|
It("updatePlaylist can combine metadata change with track removal", func() {
|
|
// Playlist has [song4, song2, song3]
|
|
// Rename + remove index 0 (song4)
|
|
resp := doReq("updatePlaylist", "playlistId", playlistID,
|
|
"name", "Final Playlist", "songIndexToRemove", "0")
|
|
Expect(resp.Status).To(Equal(responses.StatusOK))
|
|
|
|
resp = doReq("getPlaylist", "id", playlistID)
|
|
Expect(resp.Playlist.Name).To(Equal("Final Playlist"))
|
|
Expect(resp.Playlist.SongCount).To(Equal(int32(2)))
|
|
Expect(resp.Playlist.Entry[0].Id).To(Equal(songIDs[2]))
|
|
Expect(resp.Playlist.Entry[1].Id).To(Equal(songIDs[3]))
|
|
})
|
|
|
|
It("updatePlaylist can remove all songs from playlist", func() {
|
|
// Playlist has [song2, song3] — remove both
|
|
resp := doReq("updatePlaylist", "playlistId", playlistID,
|
|
"songIndexToRemove", "0", "songIndexToRemove", "1")
|
|
Expect(resp.Status).To(Equal(responses.StatusOK))
|
|
|
|
resp = doReq("getPlaylist", "id", playlistID)
|
|
Expect(resp.Playlist.SongCount).To(Equal(int32(0)))
|
|
Expect(resp.Playlist.Entry).To(BeEmpty())
|
|
})
|
|
|
|
It("updatePlaylist can add songs to an empty playlist", func() {
|
|
resp := doReq("updatePlaylist", "playlistId", playlistID,
|
|
"songIdToAdd", songIDs[0])
|
|
Expect(resp.Status).To(Equal(responses.StatusOK))
|
|
|
|
resp = doReq("getPlaylist", "id", playlistID)
|
|
Expect(resp.Playlist.SongCount).To(Equal(int32(1)))
|
|
Expect(resp.Playlist.Entry).To(HaveLen(1))
|
|
Expect(resp.Playlist.Entry[0].Id).To(Equal(songIDs[0]))
|
|
})
|
|
|
|
It("updatePlaylist without playlistId returns error", func() {
|
|
resp := doReq("updatePlaylist", "name", "No ID")
|
|
|
|
Expect(resp.Status).To(Equal(responses.StatusFailed))
|
|
Expect(resp.Error).ToNot(BeNil())
|
|
})
|
|
|
|
It("getPlaylists shows the playlist", func() {
|
|
resp := doReq("getPlaylists")
|
|
|
|
Expect(resp.Status).To(Equal(responses.StatusOK))
|
|
Expect(resp.Playlists.Playlist).To(HaveLen(1))
|
|
Expect(resp.Playlists.Playlist[0].Id).To(Equal(playlistID))
|
|
})
|
|
|
|
It("deletePlaylist removes the playlist", func() {
|
|
resp := doReq("deletePlaylist", "id", playlistID)
|
|
|
|
Expect(resp.Status).To(Equal(responses.StatusOK))
|
|
})
|
|
|
|
It("getPlaylist on deleted playlist returns error", func() {
|
|
resp := doReq("getPlaylist", "id", playlistID)
|
|
|
|
Expect(resp.Status).To(Equal(responses.StatusFailed))
|
|
Expect(resp.Error).ToNot(BeNil())
|
|
})
|
|
|
|
It("getPlaylists returns empty after deletion", func() {
|
|
resp := doReq("getPlaylists")
|
|
|
|
Expect(resp.Status).To(Equal(responses.StatusOK))
|
|
Expect(resp.Playlists.Playlist).To(BeEmpty())
|
|
})
|
|
|
|
Describe("Playlist Permissions", Ordered, func() {
|
|
var songIDs []string
|
|
var adminPrivateID string
|
|
var adminPublicID string
|
|
var regularPlaylistID string
|
|
|
|
BeforeAll(func() {
|
|
setupTestDB()
|
|
|
|
songs, err := ds.MediaFile().GetAll(ctx, model.QueryOptions{Sort: "title", Max: 6})
|
|
Expect(err).ToNot(HaveOccurred())
|
|
Expect(len(songs)).To(BeNumerically(">=", 3))
|
|
for _, s := range songs {
|
|
songIDs = append(songIDs, s.ID)
|
|
}
|
|
})
|
|
|
|
It("admin creates a private playlist", func() {
|
|
resp := doReqWithUser(adminUser, "createPlaylist", "name", "Admin Private",
|
|
"songId", songIDs[0], "songId", songIDs[1])
|
|
|
|
Expect(resp.Status).To(Equal(responses.StatusOK))
|
|
adminPrivateID = resp.Playlist.Id
|
|
})
|
|
|
|
It("admin creates a public playlist", func() {
|
|
resp := doReqWithUser(adminUser, "createPlaylist", "name", "Admin Public",
|
|
"songId", songIDs[0], "songId", songIDs[1])
|
|
|
|
Expect(resp.Status).To(Equal(responses.StatusOK))
|
|
adminPublicID = resp.Playlist.Id
|
|
|
|
// Make it public
|
|
resp = doReqWithUser(adminUser, "updatePlaylist",
|
|
"playlistId", adminPublicID, "public", "true")
|
|
Expect(resp.Status).To(Equal(responses.StatusOK))
|
|
})
|
|
|
|
It("regular user creates a playlist", func() {
|
|
resp := doReqWithUser(regularUser, "createPlaylist", "name", "Regular Playlist",
|
|
"songId", songIDs[0])
|
|
|
|
Expect(resp.Status).To(Equal(responses.StatusOK))
|
|
regularPlaylistID = resp.Playlist.Id
|
|
})
|
|
|
|
// --- Private playlist: regular user gets "not found" (repo hides it entirely) ---
|
|
|
|
It("regular user cannot see admin's private playlist", func() {
|
|
resp := doReqWithUser(regularUser, "getPlaylist", "id", adminPrivateID)
|
|
|
|
Expect(resp.Status).To(Equal(responses.StatusFailed))
|
|
Expect(resp.Error).ToNot(BeNil())
|
|
})
|
|
|
|
It("regular user cannot update admin's private playlist (not found)", func() {
|
|
resp := doReqWithUser(regularUser, "updatePlaylist",
|
|
"playlistId", adminPrivateID, "name", "Hacked")
|
|
|
|
Expect(resp.Status).To(Equal(responses.StatusFailed))
|
|
Expect(resp.Error).ToNot(BeNil())
|
|
})
|
|
|
|
It("regular user cannot delete admin's private playlist (not found)", func() {
|
|
resp := doReqWithUser(regularUser, "deletePlaylist", "id", adminPrivateID)
|
|
|
|
Expect(resp.Status).To(Equal(responses.StatusFailed))
|
|
Expect(resp.Error).ToNot(BeNil())
|
|
})
|
|
|
|
// --- Public playlist: regular user can see but cannot modify (authorization fail, code 50) ---
|
|
|
|
It("regular user can see admin's public playlist", func() {
|
|
resp := doReqWithUser(regularUser, "getPlaylist", "id", adminPublicID)
|
|
|
|
Expect(resp.Status).To(Equal(responses.StatusOK))
|
|
Expect(resp.Playlist.Name).To(Equal("Admin Public"))
|
|
})
|
|
|
|
It("regular user cannot update admin's public playlist", func() {
|
|
resp := doReqWithUser(regularUser, "updatePlaylist",
|
|
"playlistId", adminPublicID, "name", "Hacked")
|
|
|
|
Expect(resp.Status).To(Equal(responses.StatusFailed))
|
|
Expect(resp.Error).ToNot(BeNil())
|
|
Expect(resp.Error.Code).To(Equal(int32(50)))
|
|
})
|
|
|
|
It("regular user cannot add songs to admin's public playlist", func() {
|
|
resp := doReqWithUser(regularUser, "updatePlaylist",
|
|
"playlistId", adminPublicID, "songIdToAdd", songIDs[2])
|
|
|
|
Expect(resp.Status).To(Equal(responses.StatusFailed))
|
|
Expect(resp.Error.Code).To(Equal(int32(50)))
|
|
})
|
|
|
|
It("regular user cannot remove songs from admin's public playlist", func() {
|
|
resp := doReqWithUser(regularUser, "updatePlaylist",
|
|
"playlistId", adminPublicID, "songIndexToRemove", "0")
|
|
|
|
Expect(resp.Status).To(Equal(responses.StatusFailed))
|
|
Expect(resp.Error.Code).To(Equal(int32(50)))
|
|
})
|
|
|
|
It("regular user cannot delete admin's public playlist", func() {
|
|
resp := doReqWithUser(regularUser, "deletePlaylist", "id", adminPublicID)
|
|
|
|
Expect(resp.Status).To(Equal(responses.StatusFailed))
|
|
Expect(resp.Error.Code).To(Equal(int32(50)))
|
|
})
|
|
|
|
It("regular user cannot replace tracks on admin's public playlist via createPlaylist", func() {
|
|
resp := doReqWithUser(regularUser, "createPlaylist",
|
|
"playlistId", adminPublicID, "songId", songIDs[2])
|
|
|
|
Expect(resp.Status).To(Equal(responses.StatusFailed))
|
|
Expect(resp.Error).ToNot(BeNil())
|
|
})
|
|
|
|
// --- Regular user can manage their own playlists ---
|
|
|
|
It("regular user can update their own playlist", func() {
|
|
resp := doReqWithUser(regularUser, "updatePlaylist",
|
|
"playlistId", regularPlaylistID, "name", "My Updated Playlist")
|
|
|
|
Expect(resp.Status).To(Equal(responses.StatusOK))
|
|
|
|
resp = doReqWithUser(regularUser, "getPlaylist", "id", regularPlaylistID)
|
|
Expect(resp.Playlist.Name).To(Equal("My Updated Playlist"))
|
|
})
|
|
|
|
It("regular user can add songs to their own playlist", func() {
|
|
resp := doReqWithUser(regularUser, "updatePlaylist",
|
|
"playlistId", regularPlaylistID, "songIdToAdd", songIDs[1])
|
|
|
|
Expect(resp.Status).To(Equal(responses.StatusOK))
|
|
|
|
resp = doReqWithUser(regularUser, "getPlaylist", "id", regularPlaylistID)
|
|
Expect(resp.Playlist.SongCount).To(Equal(int32(2)))
|
|
})
|
|
|
|
It("regular user can delete their own playlist", func() {
|
|
resp := doReqWithUser(regularUser, "deletePlaylist", "id", regularPlaylistID)
|
|
|
|
Expect(resp.Status).To(Equal(responses.StatusOK))
|
|
})
|
|
|
|
// --- Admin can manage any user's playlists ---
|
|
|
|
It("admin can update any user's playlist", func() {
|
|
resp := doReqWithUser(regularUser, "createPlaylist", "name", "To Be Admin-Edited",
|
|
"songId", songIDs[0])
|
|
Expect(resp.Status).To(Equal(responses.StatusOK))
|
|
plsID := resp.Playlist.Id
|
|
|
|
resp = doReqWithUser(adminUser, "updatePlaylist",
|
|
"playlistId", plsID, "name", "Admin Edited")
|
|
Expect(resp.Status).To(Equal(responses.StatusOK))
|
|
|
|
resp = doReqWithUser(adminUser, "getPlaylist", "id", plsID)
|
|
Expect(resp.Playlist.Name).To(Equal("Admin Edited"))
|
|
})
|
|
|
|
It("admin can delete any user's playlist", func() {
|
|
resp := doReqWithUser(regularUser, "createPlaylist", "name", "To Be Admin-Deleted",
|
|
"songId", songIDs[0])
|
|
Expect(resp.Status).To(Equal(responses.StatusOK))
|
|
plsID := resp.Playlist.Id
|
|
|
|
resp = doReqWithUser(adminUser, "deletePlaylist", "id", plsID)
|
|
Expect(resp.Status).To(Equal(responses.StatusOK))
|
|
|
|
resp = doReqWithUser(adminUser, "getPlaylist", "id", plsID)
|
|
Expect(resp.Status).To(Equal(responses.StatusFailed))
|
|
})
|
|
|
|
// --- Verify admin's playlists are unchanged ---
|
|
|
|
It("admin's private playlist is unchanged after failed regular user operations", func() {
|
|
resp := doReqWithUser(adminUser, "getPlaylist", "id", adminPrivateID)
|
|
|
|
Expect(resp.Status).To(Equal(responses.StatusOK))
|
|
Expect(resp.Playlist.Name).To(Equal("Admin Private"))
|
|
Expect(resp.Playlist.SongCount).To(Equal(int32(2)))
|
|
})
|
|
|
|
It("admin's public playlist is unchanged after failed regular user operations", func() {
|
|
resp := doReqWithUser(adminUser, "getPlaylist", "id", adminPublicID)
|
|
|
|
Expect(resp.Status).To(Equal(responses.StatusOK))
|
|
Expect(resp.Playlist.Name).To(Equal("Admin Public"))
|
|
Expect(resp.Playlist.SongCount).To(Equal(int32(2)))
|
|
})
|
|
})
|
|
|
|
Describe("Smart Playlist Protection", Ordered, func() {
|
|
var smartPlaylistID string
|
|
var songID string
|
|
|
|
BeforeAll(func() {
|
|
setupTestDB()
|
|
|
|
// Look up a song ID for mutation tests
|
|
songs, err := ds.MediaFile().GetAll(ctx, model.QueryOptions{Sort: "title", Max: 1})
|
|
Expect(err).ToNot(HaveOccurred())
|
|
Expect(songs).ToNot(BeEmpty())
|
|
songID = songs[0].ID
|
|
|
|
// Insert a smart playlist directly into the DB
|
|
smartPls := &model.Playlist{
|
|
Name: "Smart Playlist",
|
|
OwnerID: adminUser.ID,
|
|
Public: false,
|
|
Rules: &criteria.Criteria{Expression: criteria.Contains{"title": ""}},
|
|
}
|
|
Expect(ds.Playlist().Put(ctx, smartPls)).To(Succeed())
|
|
smartPlaylistID = smartPls.ID
|
|
})
|
|
|
|
It("getPlaylist returns smart playlist with readonly flag and validUntil", func() {
|
|
resp := doReq("getPlaylist", "id", smartPlaylistID)
|
|
|
|
Expect(resp.Status).To(Equal(responses.StatusOK))
|
|
Expect(resp.Playlist.Name).To(Equal("Smart Playlist"))
|
|
Expect(resp.Playlist.OpenSubsonicPlaylist).ToNot(BeNil())
|
|
Expect(resp.Playlist.OpenSubsonicPlaylist.Readonly).To(BeTrue())
|
|
expectedValidUntil := time.Now().Add(conf.Server.SmartPlaylistRefreshDelay)
|
|
Expect(*resp.Playlist.OpenSubsonicPlaylist.ValidUntil).To(BeTemporally("~", expectedValidUntil, time.Second))
|
|
})
|
|
|
|
It("createPlaylist rejects replacing tracks on smart playlist", func() {
|
|
resp := doReq("createPlaylist", "playlistId", smartPlaylistID, "songId", songID)
|
|
|
|
Expect(resp.Status).To(Equal(responses.StatusFailed))
|
|
Expect(resp.Error).ToNot(BeNil())
|
|
Expect(resp.Error.Code).To(Equal(int32(50)))
|
|
})
|
|
|
|
It("updatePlaylist rejects adding songs to smart playlist", func() {
|
|
resp := doReq("updatePlaylist", "playlistId", smartPlaylistID,
|
|
"songIdToAdd", songID)
|
|
|
|
Expect(resp.Status).To(Equal(responses.StatusFailed))
|
|
Expect(resp.Error).ToNot(BeNil())
|
|
Expect(resp.Error.Code).To(Equal(int32(50)))
|
|
})
|
|
|
|
It("updatePlaylist rejects removing songs from smart playlist", func() {
|
|
resp := doReq("updatePlaylist", "playlistId", smartPlaylistID,
|
|
"songIndexToRemove", "0")
|
|
|
|
Expect(resp.Status).To(Equal(responses.StatusFailed))
|
|
Expect(resp.Error).ToNot(BeNil())
|
|
Expect(resp.Error.Code).To(Equal(int32(50)))
|
|
})
|
|
|
|
It("updatePlaylist allows renaming smart playlist", func() {
|
|
resp := doReq("updatePlaylist", "playlistId", smartPlaylistID,
|
|
"name", "Renamed Smart")
|
|
Expect(resp.Status).To(Equal(responses.StatusOK))
|
|
|
|
resp = doReq("getPlaylist", "id", smartPlaylistID)
|
|
Expect(resp.Playlist.Name).To(Equal("Renamed Smart"))
|
|
})
|
|
|
|
It("updatePlaylist allows setting comment on smart playlist", func() {
|
|
resp := doReq("updatePlaylist", "playlistId", smartPlaylistID,
|
|
"comment", "Auto-generated playlist")
|
|
Expect(resp.Status).To(Equal(responses.StatusOK))
|
|
|
|
resp = doReq("getPlaylist", "id", smartPlaylistID)
|
|
Expect(resp.Playlist.Comment).To(Equal("Auto-generated playlist"))
|
|
})
|
|
|
|
It("deletePlaylist can delete smart playlist", func() {
|
|
resp := doReq("deletePlaylist", "id", smartPlaylistID)
|
|
Expect(resp.Status).To(Equal(responses.StatusOK))
|
|
|
|
resp = doReq("getPlaylist", "id", smartPlaylistID)
|
|
Expect(resp.Status).To(Equal(responses.StatusFailed))
|
|
})
|
|
})
|
|
|
|
Describe("Smart Playlist Boolean String Normalization (issue #4826)", Ordered, func() {
|
|
var songID string
|
|
var boolPlaylistID, stringPlaylistID, nestedPlaylistID string
|
|
|
|
BeforeAll(func() {
|
|
setupTestDB()
|
|
|
|
songs, err := ds.MediaFile().GetAll(ctx, model.QueryOptions{Sort: "title", Max: 1})
|
|
Expect(err).ToNot(HaveOccurred())
|
|
Expect(songs).ToNot(BeEmpty())
|
|
songID = songs[0].ID
|
|
|
|
// Star the song via the Subsonic API
|
|
resp := doReq("star", "id", songID)
|
|
Expect(resp.Status).To(Equal(responses.StatusOK))
|
|
|
|
// Force immediate refresh for all smart playlists
|
|
conf.Server.SmartPlaylistRefreshDelay = -1 * time.Second
|
|
|
|
// Create smart playlist with boolean true
|
|
boolPls := &model.Playlist{
|
|
Name: "Bool Loved",
|
|
OwnerID: adminUser.ID,
|
|
Rules: &criteria.Criteria{Expression: criteria.All{criteria.Is{"loved": true}}},
|
|
}
|
|
Expect(ds.Playlist().Put(ctx, boolPls)).To(Succeed())
|
|
boolPlaylistID = boolPls.ID
|
|
|
|
// Create smart playlist with string "true"
|
|
stringPls := &model.Playlist{
|
|
Name: "String Loved",
|
|
OwnerID: adminUser.ID,
|
|
Rules: &criteria.Criteria{Expression: criteria.All{criteria.Is{"loved": "true"}}},
|
|
}
|
|
Expect(ds.Playlist().Put(ctx, stringPls)).To(Succeed())
|
|
stringPlaylistID = stringPls.ID
|
|
|
|
// Create smart playlist with string "true" in nested any group (exact issue #4826 scenario)
|
|
nestedPls := &model.Playlist{
|
|
Name: "Nested String Loved",
|
|
OwnerID: adminUser.ID,
|
|
Rules: &criteria.Criteria{Expression: criteria.All{
|
|
criteria.Any{
|
|
criteria.Is{"loved": "true"},
|
|
},
|
|
}},
|
|
}
|
|
Expect(ds.Playlist().Put(ctx, nestedPls)).To(Succeed())
|
|
nestedPlaylistID = nestedPls.ID
|
|
})
|
|
|
|
It("smart playlist with bool loved=true returns starred song", func() {
|
|
resp := doReq("getPlaylist", "id", boolPlaylistID)
|
|
|
|
Expect(resp.Status).To(Equal(responses.StatusOK))
|
|
Expect(resp.Playlist.SongCount).To(BeNumerically(">=", int32(1)))
|
|
entryIDs := make([]string, len(resp.Playlist.Entry))
|
|
for i, e := range resp.Playlist.Entry {
|
|
entryIDs[i] = e.Id
|
|
}
|
|
Expect(entryIDs).To(ContainElement(songID))
|
|
})
|
|
|
|
It("smart playlist with string loved='true' returns same results as bool (issue #4826)", func() {
|
|
boolResp := doReq("getPlaylist", "id", boolPlaylistID)
|
|
stringResp := doReq("getPlaylist", "id", stringPlaylistID)
|
|
|
|
Expect(stringResp.Status).To(Equal(responses.StatusOK))
|
|
Expect(stringResp.Playlist.SongCount).To(Equal(boolResp.Playlist.SongCount))
|
|
})
|
|
|
|
It("nested any group with string loved='true' returns starred song (issue #4826)", func() {
|
|
resp := doReq("getPlaylist", "id", nestedPlaylistID)
|
|
|
|
Expect(resp.Status).To(Equal(responses.StatusOK))
|
|
Expect(resp.Playlist.SongCount).To(BeNumerically(">=", int32(1)))
|
|
entryIDs := make([]string, len(resp.Playlist.Entry))
|
|
for i, e := range resp.Playlist.Entry {
|
|
entryIDs[i] = e.Id
|
|
}
|
|
Expect(entryIDs).To(ContainElement(songID))
|
|
})
|
|
|
|
It("isPresent with string 'true' matches songs that have the tag", func() {
|
|
pls := &model.Playlist{
|
|
Name: "Genre Present String",
|
|
OwnerID: adminUser.ID,
|
|
Rules: &criteria.Criteria{Expression: criteria.All{criteria.IsPresent{"genre": "true"}}},
|
|
}
|
|
Expect(ds.Playlist().Put(ctx, pls)).To(Succeed())
|
|
|
|
resp := doReq("getPlaylist", "id", pls.ID)
|
|
Expect(resp.Status).To(Equal(responses.StatusOK))
|
|
Expect(resp.Playlist.SongCount).To(BeNumerically(">=", int32(1)))
|
|
})
|
|
|
|
It("isMissing with string 'true' excludes songs that have the tag", func() {
|
|
pls := &model.Playlist{
|
|
Name: "Genre Missing String",
|
|
OwnerID: adminUser.ID,
|
|
Rules: &criteria.Criteria{Expression: criteria.All{criteria.IsMissing{"genre": "true"}}},
|
|
}
|
|
Expect(ds.Playlist().Put(ctx, pls)).To(Succeed())
|
|
|
|
resp := doReq("getPlaylist", "id", pls.ID)
|
|
Expect(resp.Status).To(Equal(responses.StatusOK))
|
|
Expect(resp.Playlist.SongCount).To(Equal(int32(0)))
|
|
})
|
|
|
|
It("isMissing with string 'true' returns same results as bool true", func() {
|
|
boolPls := &model.Playlist{
|
|
Name: "Genre Missing Bool",
|
|
OwnerID: adminUser.ID,
|
|
Rules: &criteria.Criteria{Expression: criteria.All{criteria.IsMissing{"genre": true}}},
|
|
}
|
|
Expect(ds.Playlist().Put(ctx, boolPls)).To(Succeed())
|
|
|
|
stringPls := &model.Playlist{
|
|
Name: "Genre Missing String2",
|
|
OwnerID: adminUser.ID,
|
|
Rules: &criteria.Criteria{Expression: criteria.All{criteria.IsMissing{"genre": "true"}}},
|
|
}
|
|
Expect(ds.Playlist().Put(ctx, stringPls)).To(Succeed())
|
|
|
|
boolResp := doReq("getPlaylist", "id", boolPls.ID)
|
|
stringResp := doReq("getPlaylist", "id", stringPls.ID)
|
|
Expect(stringResp.Playlist.SongCount).To(Equal(boolResp.Playlist.SongCount))
|
|
})
|
|
|
|
DescribeTable("isMissing/isPresent partition all songs for nullable column fields",
|
|
func(fieldName string) {
|
|
allPls := &model.Playlist{
|
|
Name: "All Songs " + fieldName,
|
|
OwnerID: adminUser.ID,
|
|
Rules: &criteria.Criteria{Expression: criteria.Contains{"title": ""}},
|
|
}
|
|
Expect(ds.Playlist().Put(ctx, allPls)).To(Succeed())
|
|
missingPls := &model.Playlist{
|
|
Name: "Missing " + fieldName,
|
|
OwnerID: adminUser.ID,
|
|
Rules: &criteria.Criteria{Expression: criteria.All{criteria.IsMissing{fieldName: true}}},
|
|
}
|
|
Expect(ds.Playlist().Put(ctx, missingPls)).To(Succeed())
|
|
presentPls := &model.Playlist{
|
|
Name: "Present " + fieldName,
|
|
OwnerID: adminUser.ID,
|
|
Rules: &criteria.Criteria{Expression: criteria.All{criteria.IsPresent{fieldName: true}}},
|
|
}
|
|
Expect(ds.Playlist().Put(ctx, presentPls)).To(Succeed())
|
|
|
|
allResp := doReq("getPlaylist", "id", allPls.ID)
|
|
missingResp := doReq("getPlaylist", "id", missingPls.ID)
|
|
presentResp := doReq("getPlaylist", "id", presentPls.ID)
|
|
|
|
Expect(allResp.Status).To(Equal(responses.StatusOK))
|
|
Expect(allResp.Playlist.SongCount).To(BeNumerically(">", int32(0)))
|
|
Expect(missingResp.Playlist.SongCount + presentResp.Playlist.SongCount).
|
|
To(Equal(allResp.Playlist.SongCount))
|
|
},
|
|
Entry("bpm", "bpm"),
|
|
Entry("bitdepth", "bitdepth"),
|
|
Entry("lyrics", "lyrics"),
|
|
Entry("mbz_recording_id", "mbz_recording_id"),
|
|
Entry("album", "album"),
|
|
Entry("comment", "comment"),
|
|
)
|
|
})
|
|
})
|