🏡 index : ironmagma/quintodrome.git

package playlists_test

import (
	"context"
	"os"
	"path/filepath"
	"strings"

	"github.com/navidrome/navidrome/conf"
	"github.com/navidrome/navidrome/conf/configtest"
	"github.com/navidrome/navidrome/core"
	"github.com/navidrome/navidrome/core/playlists"
	"github.com/navidrome/navidrome/model"
	"github.com/navidrome/navidrome/model/criteria"
	"github.com/navidrome/navidrome/model/request"
	"github.com/navidrome/navidrome/tests"
	. "github.com/onsi/ginkgo/v2"
	. "github.com/onsi/gomega"
)

var _ = Describe("Playlists", func() {
	var ds *tests.MockDataStore
	var ps playlists.Playlists
	var mockPlsRepo *tests.MockPlaylistRepo
	ctx := context.Background()

	BeforeEach(func() {
		mockPlsRepo = tests.CreateMockPlaylistRepo()
		ds = &tests.MockDataStore{
			MockedPlaylist: mockPlsRepo,
			MockedLibrary:  &tests.MockLibraryRepo{},
		}
		ctx = request.WithUser(ctx, model.User{ID: "123"})
	})

	Describe("Delete", func() {
		var mockTracks *tests.MockPlaylistTrackRepo

		BeforeEach(func() {
			mockTracks = &tests.MockPlaylistTrackRepo{AddCount: 3}
			mockPlsRepo.Data = map[string]*model.Playlist{
				"pls-1": {ID: "pls-1", Name: "My Playlist", OwnerID: "user-1"},
			}
			mockPlsRepo.TracksRepo = mockTracks
			ps = playlists.NewPlaylists(ds, core.NewImageUploadService())
		})

		It("allows owner to delete their playlist", func() {
			ctx = request.WithUser(ctx, model.User{ID: "user-1", IsAdmin: false})
			err := ps.Delete(ctx, "pls-1")
			Expect(err).ToNot(HaveOccurred())
			Expect(mockPlsRepo.Deleted).To(ContainElement("pls-1"))
		})

		It("allows admin to delete any playlist", func() {
			ctx = request.WithUser(ctx, model.User{ID: "admin-1", IsAdmin: true})
			err := ps.Delete(ctx, "pls-1")
			Expect(err).ToNot(HaveOccurred())
			Expect(mockPlsRepo.Deleted).To(ContainElement("pls-1"))
		})

		It("denies non-owner, non-admin from deleting", func() {
			ctx = request.WithUser(ctx, model.User{ID: "other-user", IsAdmin: false})
			err := ps.Delete(ctx, "pls-1")
			Expect(err).To(MatchError(model.ErrNotAuthorized))
			Expect(mockPlsRepo.Deleted).To(BeEmpty())
		})

		It("returns error when playlist not found", func() {
			ctx = request.WithUser(ctx, model.User{ID: "user-1", IsAdmin: false})
			err := ps.Delete(ctx, "nonexistent")
			Expect(err).To(Equal(model.ErrNotFound))
		})
	})

	Describe("Create", func() {
		BeforeEach(func() {
			mockPlsRepo.Data = map[string]*model.Playlist{
				"pls-1": {ID: "pls-1", Name: "Existing", OwnerID: "user-1"},
				"pls-2": {ID: "pls-2", Name: "Other's", OwnerID: "other-user"},
				"pls-smart": {ID: "pls-smart", Name: "Smart", OwnerID: "user-1",
					Rules: &criteria.Criteria{Expression: criteria.Contains{"title": "test"}}},
			}
			ps = playlists.NewPlaylists(ds, core.NewImageUploadService())
		})

		It("creates a new playlist with owner set from context", func() {
			ctx = request.WithUser(ctx, model.User{ID: "user-1", IsAdmin: false})
			id, err := ps.Create(ctx, "", "New Playlist", []string{"song-1", "song-2"})
			Expect(err).ToNot(HaveOccurred())
			Expect(id).ToNot(BeEmpty())
			Expect(mockPlsRepo.Last.Name).To(Equal("New Playlist"))
			Expect(mockPlsRepo.Last.OwnerID).To(Equal("user-1"))
		})

		It("replaces tracks on existing playlist when owner matches", func() {
			ctx = request.WithUser(ctx, model.User{ID: "user-1", IsAdmin: false})
			id, err := ps.Create(ctx, "pls-1", "", []string{"song-3"})
			Expect(err).ToNot(HaveOccurred())
			Expect(id).To(Equal("pls-1"))
			Expect(mockPlsRepo.Last.Tracks).To(HaveLen(1))
		})

		It("allows admin to replace tracks on any playlist", func() {
			ctx = request.WithUser(ctx, model.User{ID: "admin-1", IsAdmin: true})
			id, err := ps.Create(ctx, "pls-2", "", []string{"song-3"})
			Expect(err).ToNot(HaveOccurred())
			Expect(id).To(Equal("pls-2"))
		})

		It("denies non-owner, non-admin from replacing tracks on existing playlist", func() {
			ctx = request.WithUser(ctx, model.User{ID: "user-1", IsAdmin: false})
			_, err := ps.Create(ctx, "pls-2", "", []string{"song-3"})
			Expect(err).To(MatchError(model.ErrNotAuthorized))
		})

		It("returns error when existing playlistId not found", func() {
			ctx = request.WithUser(ctx, model.User{ID: "user-1", IsAdmin: false})
			_, err := ps.Create(ctx, "nonexistent", "", []string{"song-1"})
			Expect(err).To(Equal(model.ErrNotFound))
		})

		It("denies replacing tracks on a smart playlist", func() {
			ctx = request.WithUser(ctx, model.User{ID: "user-1", IsAdmin: false})
			_, err := ps.Create(ctx, "pls-smart", "", []string{"song-1"})
			Expect(err).To(MatchError(model.ErrNotAuthorized))
		})
	})

	Describe("Update", func() {
		var mockTracks *tests.MockPlaylistTrackRepo

		BeforeEach(func() {
			mockTracks = &tests.MockPlaylistTrackRepo{AddCount: 2}
			mockPlsRepo.Data = map[string]*model.Playlist{
				"pls-1":     {ID: "pls-1", Name: "My Playlist", OwnerID: "user-1"},
				"pls-other": {ID: "pls-other", Name: "Other's", OwnerID: "other-user"},
				"pls-smart": {ID: "pls-smart", Name: "Smart", OwnerID: "user-1",
					Rules: &criteria.Criteria{Expression: criteria.Contains{"title": "test"}}},
			}
			mockPlsRepo.TracksRepo = mockTracks
			ps = playlists.NewPlaylists(ds, core.NewImageUploadService())
		})

		It("allows owner to update their playlist", func() {
			ctx = request.WithUser(ctx, model.User{ID: "user-1", IsAdmin: false})
			err := ps.Update(ctx, "pls-1", new("Updated Name"), nil, nil, nil, nil)
			Expect(err).ToNot(HaveOccurred())
		})

		It("allows admin to update any playlist", func() {
			ctx = request.WithUser(ctx, model.User{ID: "admin-1", IsAdmin: true})
			err := ps.Update(ctx, "pls-other", new("Updated Name"), nil, nil, nil, nil)
			Expect(err).ToNot(HaveOccurred())
		})

		It("denies non-owner, non-admin from updating", func() {
			ctx = request.WithUser(ctx, model.User{ID: "other-user", IsAdmin: false})
			err := ps.Update(ctx, "pls-1", new("Updated Name"), nil, nil, nil, nil)
			Expect(err).To(MatchError(model.ErrNotAuthorized))
		})

		It("returns error when playlist not found", func() {
			ctx = request.WithUser(ctx, model.User{ID: "user-1", IsAdmin: false})
			err := ps.Update(ctx, "nonexistent", new("Updated Name"), nil, nil, nil, nil)
			Expect(err).To(Equal(model.ErrNotFound))
		})

		It("denies adding tracks to a smart playlist", func() {
			ctx = request.WithUser(ctx, model.User{ID: "user-1", IsAdmin: false})
			err := ps.Update(ctx, "pls-smart", nil, nil, nil, []string{"song-1"}, nil)
			Expect(err).To(MatchError(model.ErrNotAuthorized))
		})

		It("denies removing tracks from a smart playlist", func() {
			ctx = request.WithUser(ctx, model.User{ID: "user-1", IsAdmin: false})
			err := ps.Update(ctx, "pls-smart", nil, nil, nil, nil, []int{0})
			Expect(err).To(MatchError(model.ErrNotAuthorized))
		})

		It("allows metadata updates on a smart playlist", func() {
			ctx = request.WithUser(ctx, model.User{ID: "user-1", IsAdmin: false})
			err := ps.Update(ctx, "pls-smart", new("Updated Smart"), nil, nil, nil, nil)
			Expect(err).ToNot(HaveOccurred())
		})
	})

	Describe("AddTracks", func() {
		var mockTracks *tests.MockPlaylistTrackRepo

		BeforeEach(func() {
			mockTracks = &tests.MockPlaylistTrackRepo{AddCount: 2}
			mockPlsRepo.Data = map[string]*model.Playlist{
				"pls-1": {ID: "pls-1", Name: "My Playlist", OwnerID: "user-1"},
				"pls-smart": {ID: "pls-smart", Name: "Smart", OwnerID: "user-1",
					Rules: &criteria.Criteria{Expression: criteria.Contains{"title": "test"}}},
				"pls-other": {ID: "pls-other", Name: "Other's", OwnerID: "other-user"},
			}
			mockPlsRepo.TracksRepo = mockTracks
			ps = playlists.NewPlaylists(ds, core.NewImageUploadService())
		})

		It("allows owner to add tracks", func() {
			ctx = request.WithUser(ctx, model.User{ID: "user-1", IsAdmin: false})
			count, err := ps.AddTracks(ctx, "pls-1", []string{"song-1", "song-2"})
			Expect(err).ToNot(HaveOccurred())
			Expect(count).To(Equal(2))
			Expect(mockTracks.AddedIds).To(ConsistOf("song-1", "song-2"))
		})

		It("allows admin to add tracks to any playlist", func() {
			ctx = request.WithUser(ctx, model.User{ID: "admin-1", IsAdmin: true})
			count, err := ps.AddTracks(ctx, "pls-other", []string{"song-1"})
			Expect(err).ToNot(HaveOccurred())
			Expect(count).To(Equal(2))
		})

		It("denies non-owner, non-admin", func() {
			ctx = request.WithUser(ctx, model.User{ID: "other-user", IsAdmin: false})
			_, err := ps.AddTracks(ctx, "pls-1", []string{"song-1"})
			Expect(err).To(MatchError(model.ErrNotAuthorized))
		})

		It("denies editing smart playlists", func() {
			ctx = request.WithUser(ctx, model.User{ID: "user-1", IsAdmin: false})
			_, err := ps.AddTracks(ctx, "pls-smart", []string{"song-1"})
			Expect(err).To(MatchError(model.ErrNotAuthorized))
		})

		It("returns error when playlist not found", func() {
			ctx = request.WithUser(ctx, model.User{ID: "user-1", IsAdmin: false})
			_, err := ps.AddTracks(ctx, "nonexistent", []string{"song-1"})
			Expect(err).To(Equal(model.ErrNotFound))
		})
	})

	Describe("RemoveTracks", func() {
		var mockTracks *tests.MockPlaylistTrackRepo

		BeforeEach(func() {
			mockTracks = &tests.MockPlaylistTrackRepo{}
			mockPlsRepo.Data = map[string]*model.Playlist{
				"pls-1": {ID: "pls-1", Name: "My Playlist", OwnerID: "user-1"},
				"pls-smart": {ID: "pls-smart", Name: "Smart", OwnerID: "user-1",
					Rules: &criteria.Criteria{Expression: criteria.Contains{"title": "test"}}},
			}
			mockPlsRepo.TracksRepo = mockTracks
			ps = playlists.NewPlaylists(ds, core.NewImageUploadService())
		})

		It("allows owner to remove tracks", func() {
			ctx = request.WithUser(ctx, model.User{ID: "user-1", IsAdmin: false})
			err := ps.RemoveTracks(ctx, "pls-1", []string{"track-1", "track-2"})
			Expect(err).ToNot(HaveOccurred())
			Expect(mockTracks.DeletedIds).To(ConsistOf("track-1", "track-2"))
		})

		It("denies on smart playlist", func() {
			ctx = request.WithUser(ctx, model.User{ID: "user-1", IsAdmin: false})
			err := ps.RemoveTracks(ctx, "pls-smart", []string{"track-1"})
			Expect(err).To(MatchError(model.ErrNotAuthorized))
		})

		It("denies non-owner", func() {
			ctx = request.WithUser(ctx, model.User{ID: "other-user", IsAdmin: false})
			err := ps.RemoveTracks(ctx, "pls-1", []string{"track-1"})
			Expect(err).To(MatchError(model.ErrNotAuthorized))
		})
	})

	Describe("ReorderTrack", func() {
		var mockTracks *tests.MockPlaylistTrackRepo

		BeforeEach(func() {
			mockTracks = &tests.MockPlaylistTrackRepo{}
			mockPlsRepo.Data = map[string]*model.Playlist{
				"pls-1": {ID: "pls-1", Name: "My Playlist", OwnerID: "user-1"},
				"pls-smart": {ID: "pls-smart", Name: "Smart", OwnerID: "user-1",
					Rules: &criteria.Criteria{Expression: criteria.Contains{"title": "test"}}},
			}
			mockPlsRepo.TracksRepo = mockTracks
			ps = playlists.NewPlaylists(ds, core.NewImageUploadService())
		})

		It("allows owner to reorder", func() {
			ctx = request.WithUser(ctx, model.User{ID: "user-1", IsAdmin: false})
			err := ps.ReorderTrack(ctx, "pls-1", 1, 3)
			Expect(err).ToNot(HaveOccurred())
			Expect(mockTracks.Reordered).To(BeTrue())
		})

		It("denies on smart playlist", func() {
			ctx = request.WithUser(ctx, model.User{ID: "user-1", IsAdmin: false})
			err := ps.ReorderTrack(ctx, "pls-smart", 1, 3)
			Expect(err).To(MatchError(model.ErrNotAuthorized))
		})
	})

	Describe("SetImage", func() {
		var tmpDir string

		BeforeEach(func() {
			DeferCleanup(configtest.SetupConfig())
			tmpDir = GinkgoT().TempDir()
			conf.Server.DataFolder = conf.NewDir(tmpDir)

			mockPlsRepo.Data = map[string]*model.Playlist{
				"pls-1":     {ID: "pls-1", Name: "My Playlist", OwnerID: "user-1"},
				"pls-other": {ID: "pls-other", Name: "Other's", OwnerID: "other-user"},
			}
			ps = playlists.NewPlaylists(ds, core.NewImageUploadService())
		})

		It("saves image file and updates UploadedImage", func() {
			ctx = request.WithUser(ctx, model.User{ID: "user-1", IsAdmin: false})
			reader := strings.NewReader("fake image data")
			err := ps.SetImage(ctx, "pls-1", reader, ".jpg")
			Expect(err).ToNot(HaveOccurred())

			Expect(mockPlsRepo.Last.UploadedImage).To(Equal("pls-1_my_playlist.jpg"))
			absPath := filepath.Join(tmpDir, "artwork", "playlist", "pls-1_my_playlist.jpg")
			data, err := os.ReadFile(absPath)
			Expect(err).ToNot(HaveOccurred())
			Expect(string(data)).To(Equal("fake image data"))
		})

		It("removes old image when replacing", func() {
			ctx = request.WithUser(ctx, model.User{ID: "user-1", IsAdmin: false})

			// Upload first image
			err := ps.SetImage(ctx, "pls-1", strings.NewReader("first"), ".png")
			Expect(err).ToNot(HaveOccurred())
			oldPath := filepath.Join(tmpDir, "artwork", "playlist", "pls-1_my_playlist.png")
			Expect(oldPath).To(BeAnExistingFile())

			// Upload replacement image
			err = ps.SetImage(ctx, "pls-1", strings.NewReader("second"), ".jpg")
			Expect(err).ToNot(HaveOccurred())
			Expect(oldPath).ToNot(BeAnExistingFile())
			newPath := filepath.Join(tmpDir, "artwork", "playlist", "pls-1_my_playlist.jpg")
			Expect(newPath).To(BeAnExistingFile())
		})

		It("allows admin to set image on any playlist", func() {
			ctx = request.WithUser(ctx, model.User{ID: "admin-1", IsAdmin: true})
			err := ps.SetImage(ctx, "pls-other", strings.NewReader("data"), ".jpg")
			Expect(err).ToNot(HaveOccurred())
		})

		It("denies non-owner", func() {
			ctx = request.WithUser(ctx, model.User{ID: "other-user", IsAdmin: false})
			err := ps.SetImage(ctx, "pls-1", strings.NewReader("data"), ".jpg")
			Expect(err).To(MatchError(model.ErrNotAuthorized))
		})

		It("returns error when playlist not found", func() {
			ctx = request.WithUser(ctx, model.User{ID: "user-1", IsAdmin: false})
			err := ps.SetImage(ctx, "nonexistent", strings.NewReader("data"), ".jpg")
			Expect(err).To(Equal(model.ErrNotFound))
		})
	})

	Describe("RemoveImage", func() {
		var tmpDir string

		BeforeEach(func() {
			DeferCleanup(configtest.SetupConfig())
			tmpDir = GinkgoT().TempDir()
			conf.Server.DataFolder = conf.NewDir(tmpDir)

			// Create a real image file on disk
			imgDir := filepath.Join(tmpDir, "artwork", "playlist")
			Expect(os.MkdirAll(imgDir, 0755)).To(Succeed())
			Expect(os.WriteFile(filepath.Join(imgDir, "pls-1.jpg"), []byte("img data"), 0600)).To(Succeed())

			mockPlsRepo.Data = map[string]*model.Playlist{
				"pls-1":     {ID: "pls-1", Name: "My Playlist", OwnerID: "user-1", UploadedImage: "pls-1.jpg"},
				"pls-empty": {ID: "pls-empty", Name: "No Cover", OwnerID: "user-1"},
				"pls-other": {ID: "pls-other", Name: "Other's", OwnerID: "other-user"},
			}
			ps = playlists.NewPlaylists(ds, core.NewImageUploadService())
		})

		It("removes file and clears UploadedImage", func() {
			ctx = request.WithUser(ctx, model.User{ID: "user-1", IsAdmin: false})
			err := ps.RemoveImage(ctx, "pls-1")
			Expect(err).ToNot(HaveOccurred())

			Expect(mockPlsRepo.Last.UploadedImage).To(BeEmpty())
			absPath := filepath.Join(tmpDir, "artwork", "playlist", "pls-1.jpg")
			Expect(absPath).ToNot(BeAnExistingFile())
		})

		It("succeeds even if playlist has no image", func() {
			ctx = request.WithUser(ctx, model.User{ID: "user-1", IsAdmin: false})
			err := ps.RemoveImage(ctx, "pls-empty")
			Expect(err).ToNot(HaveOccurred())
			Expect(mockPlsRepo.Last.UploadedImage).To(BeEmpty())
		})

		It("denies non-owner", func() {
			ctx = request.WithUser(ctx, model.User{ID: "other-user", IsAdmin: false})
			err := ps.RemoveImage(ctx, "pls-1")
			Expect(err).To(MatchError(model.ErrNotAuthorized))
		})

		It("returns error when playlist not found", func() {
			ctx = request.WithUser(ctx, model.User{ID: "user-1", IsAdmin: false})
			err := ps.RemoveImage(ctx, "nonexistent")
			Expect(err).To(Equal(model.ErrNotFound))
		})
	})
})