🏡 index : quine-global/quintodrome.git

package core_test

import (
	"context"
	"errors"
	"net/http"
	"os"
	"path/filepath"
	"sync"

	"github.com/deluan/rest"
	_ "github.com/navidrome/navidrome/adapters/gotaglib" // Register taglib extractor
	"github.com/navidrome/navidrome/conf/configtest"
	"github.com/navidrome/navidrome/core"
	_ "github.com/navidrome/navidrome/core/storage/local" // Register local storage
	"github.com/navidrome/navidrome/model"
	"github.com/navidrome/navidrome/model/request"
	"github.com/navidrome/navidrome/server/events"
	"github.com/navidrome/navidrome/tests"
	. "github.com/onsi/ginkgo/v2"
	. "github.com/onsi/gomega"
)

// These tests require the local storage adapter and the taglib extractor to be registered.
var _ = Describe("Library Service", func() {
	var service core.Library
	var ds *tests.MockDataStore
	var libraryRepo *tests.MockLibraryRepo
	var userRepo *tests.MockedUserRepo
	var ctx context.Context
	var tempDir string
	var scanner *tests.MockScanner
	var watcherManager *mockWatcherManager
	var broker *mockEventBroker
	var pluginManager *mockPluginUnloader

	BeforeEach(func() {
		DeferCleanup(configtest.SetupConfig())

		ds = &tests.MockDataStore{}
		libraryRepo = &tests.MockLibraryRepo{}
		userRepo = tests.CreateMockUserRepo()
		ds.MockedLibrary = libraryRepo
		ds.MockedUser = userRepo

		// Create a mock scanner that tracks calls
		scanner = tests.NewMockScanner()
		// Create a mock watcher manager
		watcherManager = &mockWatcherManager{
			libraryStates: make(map[int]model.Library),
		}
		// Create a mock event broker
		broker = &mockEventBroker{}
		// Create a mock plugin unloader
		pluginManager = &mockPluginUnloader{}
		service = core.NewLibrary(ds, scanner, watcherManager, broker, pluginManager)
		ctx = context.Background()

		// Create a temporary directory for testing valid paths
		var err error
		tempDir, err = os.MkdirTemp("", "navidrome-library-test-")
		Expect(err).NotTo(HaveOccurred())
		DeferCleanup(func() {
			os.RemoveAll(tempDir)
		})
	})

	Describe("Library CRUD Operations", func() {
		var repo rest.Persistable[model.Library]

		BeforeEach(func() {
			r := service.Repository()
			repo = r.(rest.Persistable[model.Library])
		})

		Describe("Create", func() {
			It("creates a new library successfully", func() {
				library := &model.Library{ID: 1, Name: "New Library", Path: tempDir}

				_, err := repo.Save(ctx, library)

				Expect(err).NotTo(HaveOccurred())
				Expect(libraryRepo.Data[1].Name).To(Equal("New Library"))
				Expect(libraryRepo.Data[1].Path).To(Equal(tempDir))
			})

			It("fails when library name is empty", func() {
				library := &model.Library{Path: tempDir}

				_, err := repo.Save(ctx, library)

				Expect(err).To(HaveOccurred())
				Expect(err.Error()).To(ContainSubstring("ra.validation.required"))
			})

			It("fails when library path is empty", func() {
				library := &model.Library{Name: "Test"}

				_, err := repo.Save(ctx, library)

				Expect(err).To(HaveOccurred())
				Expect(err.Error()).To(ContainSubstring("ra.validation.required"))
			})

			It("fails when library path is not absolute", func() {
				library := &model.Library{Name: "Test", Path: "relative/path"}

				_, err := repo.Save(ctx, library)

				Expect(err).To(HaveOccurred())
				var validationErr *rest.ValidationError
				Expect(errors.As(err, &validationErr)).To(BeTrue())
				Expect(validationErr.Errors["path"]).To(Equal("library path must be absolute"))
			})

			Context("Database constraint violations", func() {
				BeforeEach(func() {
					// Set up an existing library that will cause constraint violations
					libraryRepo.SetData(model.Libraries{
						{ID: 1, Name: "Existing Library", Path: tempDir},
					})
				})

				AfterEach(func() {
					// Reset custom PutFn after each test
					libraryRepo.PutFn = nil
				})

				It("handles name uniqueness constraint violation from database", func() {
					// Create the directory that will be used for the test
					otherTempDir, err := os.MkdirTemp("", "navidrome-other-")
					Expect(err).NotTo(HaveOccurred())
					DeferCleanup(func() { os.RemoveAll(otherTempDir) })

					// Try to create another library with the same name
					library := &model.Library{ID: 2, Name: "Existing Library", Path: otherTempDir}

					// Mock the repository to return a UNIQUE constraint error
					libraryRepo.PutFn = func(library *model.Library) error {
						return errors.New("UNIQUE constraint failed: library.name")
					}

					_, err = repo.Save(ctx, library)

					Expect(err).To(HaveOccurred())
					var validationErr *rest.ValidationError
					Expect(errors.As(err, &validationErr)).To(BeTrue())
					Expect(validationErr.Errors["name"]).To(Equal("ra.validation.unique"))
				})

				It("handles path uniqueness constraint violation from database", func() {
					// Try to create another library with the same path
					library := &model.Library{ID: 2, Name: "Different Library", Path: tempDir}

					// Mock the repository to return a UNIQUE constraint error
					libraryRepo.PutFn = func(library *model.Library) error {
						return errors.New("UNIQUE constraint failed: library.path")
					}

					_, err := repo.Save(ctx, library)

					Expect(err).To(HaveOccurred())
					var validationErr *rest.ValidationError
					Expect(errors.As(err, &validationErr)).To(BeTrue())
					Expect(validationErr.Errors["path"]).To(Equal("ra.validation.unique"))
				})
			})
		})

		Describe("Update", func() {
			BeforeEach(func() {
				libraryRepo.SetData(model.Libraries{
					{ID: 1, Name: "Original Library", Path: tempDir},
				})
			})

			It("updates an existing library successfully", func() {
				newTempDir, err := os.MkdirTemp("", "navidrome-library-update-")
				Expect(err).NotTo(HaveOccurred())
				DeferCleanup(func() { os.RemoveAll(newTempDir) })

				library := &model.Library{ID: 1, Name: "Updated Library", Path: newTempDir}

				err = repo.Update(ctx, "1", *library)

				Expect(err).NotTo(HaveOccurred())
				Expect(libraryRepo.Data[1].Name).To(Equal("Updated Library"))
				Expect(libraryRepo.Data[1].Path).To(Equal(newTempDir))
			})

			It("forwards the columns sent by the client to the repository", func() {
				library := &model.Library{ID: 1, Name: "Updated Library", Path: tempDir}

				err := repo.Update(ctx, "1", *library, "name", "path")

				Expect(err).NotTo(HaveOccurred())
				Expect(libraryRepo.PutCols).To(Equal([]string{"name", "path"}))
			})

			It("fails when library doesn't exist", func() {
				// Create a unique temporary directory to avoid path conflicts
				uniqueTempDir, err := os.MkdirTemp("", "navidrome-nonexistent-")
				Expect(err).NotTo(HaveOccurred())
				DeferCleanup(func() { os.RemoveAll(uniqueTempDir) })

				library := &model.Library{ID: 999, Name: "Non-existent", Path: uniqueTempDir}

				err = repo.Update(ctx, "999", *library)

				Expect(err).To(HaveOccurred())
				Expect(err).To(Equal(model.ErrNotFound))
			})

			It("fails when library name is empty", func() {
				library := &model.Library{ID: 1, Path: tempDir}

				err := repo.Update(ctx, "1", *library)

				Expect(err).To(HaveOccurred())
				Expect(err.Error()).To(ContainSubstring("ra.validation.required"))
			})

			It("cleans and normalizes the path on update", func() {
				unnormalizedPath := tempDir + "//../" + filepath.Base(tempDir)
				library := &model.Library{ID: 1, Name: "Updated Library", Path: unnormalizedPath}

				err := repo.Update(ctx, "1", *library)

				Expect(err).NotTo(HaveOccurred())
				Expect(libraryRepo.Data[1].Path).To(Equal(filepath.Clean(unnormalizedPath)))
			})

			It("allows updating library with same name (no change)", func() {
				// Set up a library
				libraryRepo.SetData(model.Libraries{
					{ID: 1, Name: "Test Library", Path: tempDir},
				})

				// Update the library keeping the same name (should be allowed)
				library := &model.Library{ID: 1, Name: "Test Library", Path: tempDir}

				err := repo.Update(ctx, "1", *library)

				Expect(err).NotTo(HaveOccurred())
			})

			It("allows updating library with same path (no change)", func() {
				// Set up a library
				libraryRepo.SetData(model.Libraries{
					{ID: 1, Name: "Test Library", Path: tempDir},
				})

				// Update the library keeping the same path (should be allowed)
				library := &model.Library{ID: 1, Name: "Test Library", Path: tempDir}

				err := repo.Update(ctx, "1", *library)

				Expect(err).NotTo(HaveOccurred())
			})

			Context("Database constraint violations during update", func() {
				BeforeEach(func() {
					// Reset any custom PutFn from previous tests
					libraryRepo.PutFn = nil
				})

				It("handles name uniqueness constraint violation during update", func() {
					// Create additional temp directory for the test
					otherTempDir, err := os.MkdirTemp("", "navidrome-other-")
					Expect(err).NotTo(HaveOccurred())
					DeferCleanup(func() { os.RemoveAll(otherTempDir) })

					// Set up two libraries
					libraryRepo.SetData(model.Libraries{
						{ID: 1, Name: "Library One", Path: tempDir},
						{ID: 2, Name: "Library Two", Path: otherTempDir},
					})

					// Mock database constraint violation
					libraryRepo.PutFn = func(library *model.Library) error {
						return errors.New("UNIQUE constraint failed: library.name")
					}

					// Try to update library 2 to have the same name as library 1
					library := &model.Library{ID: 2, Name: "Library One", Path: otherTempDir}

					err = repo.Update(ctx, "2", *library)

					Expect(err).To(HaveOccurred())
					var validationErr *rest.ValidationError
					Expect(errors.As(err, &validationErr)).To(BeTrue())
					Expect(validationErr.Errors["name"]).To(Equal("ra.validation.unique"))
				})

				It("handles path uniqueness constraint violation during update", func() {
					// Create additional temp directory for the test
					otherTempDir, err := os.MkdirTemp("", "navidrome-other-")
					Expect(err).NotTo(HaveOccurred())
					DeferCleanup(func() { os.RemoveAll(otherTempDir) })

					// Set up two libraries
					libraryRepo.SetData(model.Libraries{
						{ID: 1, Name: "Library One", Path: tempDir},
						{ID: 2, Name: "Library Two", Path: otherTempDir},
					})

					// Mock database constraint violation
					libraryRepo.PutFn = func(library *model.Library) error {
						return errors.New("UNIQUE constraint failed: library.path")
					}

					// Try to update library 2 to have the same path as library 1
					library := &model.Library{ID: 2, Name: "Library Two", Path: tempDir}

					err = repo.Update(ctx, "2", *library)

					Expect(err).To(HaveOccurred())
					var validationErr *rest.ValidationError
					Expect(errors.As(err, &validationErr)).To(BeTrue())
					Expect(validationErr.Errors["path"]).To(Equal("ra.validation.unique"))
				})
			})
		})

		Describe("PID validation", func() {
			pidError := func(err error, field string) string {
				var validationErr *rest.ValidationError
				Expect(errors.As(err, &validationErr)).To(BeTrue())
				return validationErr.Errors[field]
			}

			It("rejects an unknown attribute in the album PID", func() {
				_, err := repo.Save(ctx, &model.Library{Name: "Lib", Path: tempDir, PIDAlbum: "albmversion"})
				Expect(pidError(err, "pidAlbum")).To(ContainSubstring(`unknown attribute "albmversion"`))
			})

			It("rejects albumid in the album PID", func() {
				_, err := repo.Save(ctx, &model.Library{Name: "Lib", Path: tempDir, PIDAlbum: "albumid"})
				Expect(pidError(err, "pidAlbum")).To(ContainSubstring("albumid"))
			})

			It("rejects an unknown attribute in the track PID", func() {
				_, err := repo.Save(ctx, &model.Library{Name: "Lib", Path: tempDir, PIDTrack: "nosuchtag"})
				Expect(pidError(err, "pidTrack")).To(ContainSubstring(`unknown attribute "nosuchtag"`))
			})

			It("trims spaces", func() {
				library := &model.Library{Name: "Lib", Path: tempDir, PIDAlbum: " folder ", PIDTrack: "  "}
				_, err := repo.Save(ctx, library)
				Expect(err).ToNot(HaveOccurred())
				Expect(library.PIDAlbum).To(Equal("folder"))
				Expect(library.PIDTrack).To(BeEmpty())
			})
		})

		Describe("Path Validation", func() {
			Context("Create operation", func() {
				It("fails when path is not absolute", func() {
					library := &model.Library{Name: "Test", Path: "relative/path"}

					_, err := repo.Save(ctx, library)

					Expect(err).To(HaveOccurred())
					var validationErr *rest.ValidationError
					Expect(errors.As(err, &validationErr)).To(BeTrue())
					Expect(validationErr.Errors["path"]).To(Equal("library path must be absolute"))
				})

				It("fails when path does not exist", func() {
					nonExistentPath := filepath.Join(tempDir, "nonexistent")
					library := &model.Library{Name: "Test", Path: nonExistentPath}

					_, err := repo.Save(ctx, library)

					Expect(err).To(HaveOccurred())
					var validationErr *rest.ValidationError
					Expect(errors.As(err, &validationErr)).To(BeTrue())
					Expect(validationErr.Errors["path"]).To(Equal("resources.library.validation.pathInvalid"))
				})

				It("fails when path is a file instead of directory", func() {
					testFile := filepath.Join(tempDir, "testfile.txt")
					err := os.WriteFile(testFile, []byte("test"), 0600)
					Expect(err).NotTo(HaveOccurred())

					library := &model.Library{Name: "Test", Path: testFile}

					_, err = repo.Save(ctx, library)

					Expect(err).To(HaveOccurred())
					var validationErr *rest.ValidationError
					Expect(errors.As(err, &validationErr)).To(BeTrue())
					Expect(validationErr.Errors["path"]).To(Equal("resources.library.validation.pathNotDirectory"))
				})

				It("fails when path is not accessible due to permissions", func() {
					Skip("Permission tests are environment-dependent and may fail in CI")
					// This test is skipped because creating a directory with no read permissions
					// is complex and may not work consistently across different environments
				})

				It("handles multiple validation errors", func() {
					library := &model.Library{Name: "", Path: "relative/path"}

					_, err := repo.Save(ctx, library)

					Expect(err).To(HaveOccurred())
					var validationErr *rest.ValidationError
					Expect(errors.As(err, &validationErr)).To(BeTrue())
					Expect(validationErr.Errors).To(HaveKey("name"))
					Expect(validationErr.Errors).To(HaveKey("path"))
					Expect(validationErr.Errors["name"]).To(Equal("ra.validation.required"))
					Expect(validationErr.Errors["path"]).To(Equal("library path must be absolute"))
				})
			})

			Context("Update operation", func() {
				BeforeEach(func() {
					libraryRepo.SetData(model.Libraries{
						{ID: 1, Name: "Test Library", Path: tempDir},
					})
				})

				It("fails when updated path is not absolute", func() {
					library := &model.Library{ID: 1, Name: "Test", Path: "relative/path"}

					err := repo.Update(ctx, "1", *library)

					Expect(err).To(HaveOccurred())
					var validationErr *rest.ValidationError
					Expect(errors.As(err, &validationErr)).To(BeTrue())
					Expect(validationErr.Errors["path"]).To(Equal("library path must be absolute"))
				})

				It("allows updating library with same name (no change)", func() {
					// Set up a library
					libraryRepo.SetData(model.Libraries{
						{ID: 1, Name: "Test Library", Path: tempDir},
					})

					// Update the library keeping the same name (should be allowed)
					library := &model.Library{ID: 1, Name: "Test Library", Path: tempDir}

					err := repo.Update(ctx, "1", *library)

					Expect(err).NotTo(HaveOccurred())
				})

				It("fails when updated path does not exist", func() {
					nonExistentPath := filepath.Join(tempDir, "nonexistent")
					library := &model.Library{ID: 1, Name: "Test", Path: nonExistentPath}

					err := repo.Update(ctx, "1", *library)

					Expect(err).To(HaveOccurred())
					var validationErr *rest.ValidationError
					Expect(errors.As(err, &validationErr)).To(BeTrue())
					Expect(validationErr.Errors["path"]).To(Equal("resources.library.validation.pathInvalid"))
				})

				It("fails when updated path is a file instead of directory", func() {
					testFile := filepath.Join(tempDir, "updatefile.txt")
					err := os.WriteFile(testFile, []byte("test"), 0600)
					Expect(err).NotTo(HaveOccurred())

					library := &model.Library{ID: 1, Name: "Test", Path: testFile}

					err = repo.Update(ctx, "1", *library)

					Expect(err).To(HaveOccurred())
					var validationErr *rest.ValidationError
					Expect(errors.As(err, &validationErr)).To(BeTrue())
					Expect(validationErr.Errors["path"]).To(Equal("resources.library.validation.pathNotDirectory"))
				})

				It("handles multiple validation errors on update", func() {
					// Try to update with empty name and invalid path
					library := &model.Library{ID: 1, Name: "", Path: "relative/path"}

					err := repo.Update(ctx, "1", *library)

					Expect(err).To(HaveOccurred())
					var validationErr *rest.ValidationError
					Expect(errors.As(err, &validationErr)).To(BeTrue())
					Expect(validationErr.Errors).To(HaveKey("name"))
					Expect(validationErr.Errors).To(HaveKey("path"))
					Expect(validationErr.Errors["name"]).To(Equal("ra.validation.required"))
					Expect(validationErr.Errors["path"]).To(Equal("library path must be absolute"))
				})
			})
		})

		Describe("Delete", func() {
			BeforeEach(func() {
				libraryRepo.SetData(model.Libraries{
					{ID: 1, Name: "Library to Delete", Path: tempDir},
				})
			})

			It("deletes an existing library successfully", func() {
				err := repo.Delete(ctx, "1")

				Expect(err).NotTo(HaveOccurred())
				Expect(libraryRepo.Data).To(HaveLen(0))
			})

			It("fails when library doesn't exist", func() {
				err := repo.Delete(ctx, "999")

				Expect(err).To(HaveOccurred())
				Expect(err).To(Equal(model.ErrNotFound))
			})
		})
	})

	Describe("User-Library Association Operations", func() {
		var regularUser, adminUser *model.User

		BeforeEach(func() {
			regularUser = &model.User{ID: "user1", UserName: "regular", IsAdmin: false}
			adminUser = &model.User{ID: "admin1", UserName: "admin", IsAdmin: true}

			userRepo.Data = map[string]*model.User{
				"regular": regularUser,
				"admin":   adminUser,
			}
			libraryRepo.SetData(model.Libraries{
				{ID: 1, Name: "Library 1", Path: "/music1"},
				{ID: 2, Name: "Library 2", Path: "/music2"},
				{ID: 3, Name: "Library 3", Path: "/music3"},
			})
		})

		Describe("GetUserLibraries", func() {
			It("returns user's libraries", func() {
				userRepo.UserLibraries = map[string][]int{
					"user1": {1},
				}

				result, err := service.GetUserLibraries(ctx, "user1")

				Expect(err).NotTo(HaveOccurred())
				Expect(result).To(HaveLen(1))
				Expect(result[0].ID).To(Equal(1))
			})

			It("fails when user doesn't exist", func() {
				_, err := service.GetUserLibraries(ctx, "nonexistent")

				Expect(err).To(HaveOccurred())
				Expect(err).To(Equal(model.ErrNotFound))
			})
		})

		Describe("SetUserLibraries", func() {
			It("sets libraries for regular user successfully", func() {
				err := service.SetUserLibraries(ctx, "user1", []int{1, 2})

				Expect(err).NotTo(HaveOccurred())
				libraries := userRepo.UserLibraries["user1"]
				Expect(libraries).To(HaveLen(2))
			})

			It("fails when user doesn't exist", func() {
				err := service.SetUserLibraries(ctx, "nonexistent", []int{1})

				Expect(err).To(HaveOccurred())
				Expect(err).To(Equal(model.ErrNotFound))
			})

			It("fails when trying to set libraries for admin user", func() {
				err := service.SetUserLibraries(ctx, "admin1", []int{1})

				Expect(err).To(HaveOccurred())
				Expect(err.Error()).To(ContainSubstring("cannot manually assign libraries to admin users"))
			})

			It("fails when no libraries provided for regular user", func() {
				err := service.SetUserLibraries(ctx, "user1", []int{})

				Expect(err).To(HaveOccurred())
				Expect(err.Error()).To(ContainSubstring("at least one library must be assigned to non-admin users"))
			})

			It("fails when library doesn't exist", func() {
				err := service.SetUserLibraries(ctx, "user1", []int{999})

				Expect(err).To(HaveOccurred())
				Expect(err.Error()).To(ContainSubstring("one or more library IDs are invalid"))
			})

			It("fails when some libraries don't exist", func() {
				err := service.SetUserLibraries(ctx, "user1", []int{1, 999, 2})

				Expect(err).To(HaveOccurred())
				Expect(err.Error()).To(ContainSubstring("one or more library IDs are invalid"))
			})
		})

		Describe("ValidateLibraryAccess", func() {
			Context("admin user", func() {
				BeforeEach(func() {
					ctx = request.WithUser(ctx, *adminUser)
				})

				It("allows access to any library", func() {
					err := service.ValidateLibraryAccess(ctx, "admin1", 1)

					Expect(err).NotTo(HaveOccurred())
				})
			})

			Context("regular user", func() {
				BeforeEach(func() {
					ctx = request.WithUser(ctx, *regularUser)
					userRepo.UserLibraries = map[string][]int{
						"user1": {1},
					}
				})

				It("allows access to user's libraries", func() {
					err := service.ValidateLibraryAccess(ctx, "user1", 1)

					Expect(err).NotTo(HaveOccurred())
				})

				It("denies access to libraries user doesn't have", func() {
					err := service.ValidateLibraryAccess(ctx, "user1", 2)

					Expect(err).To(HaveOccurred())
					Expect(err.Error()).To(ContainSubstring("user does not have access to library 2"))
				})
			})

			Context("no user in context", func() {
				It("fails with user not found error", func() {
					err := service.ValidateLibraryAccess(ctx, "user1", 1)

					Expect(err).To(HaveOccurred())
					Expect(err.Error()).To(ContainSubstring("user not found in context"))
				})
			})
		})
	})

	Describe("Scan Triggering", func() {
		var repo rest.Persistable[model.Library]

		BeforeEach(func() {
			r := service.Repository()
			repo = r.(rest.Persistable[model.Library])
		})

		It("triggers scan when creating a new library", func() {
			library := &model.Library{ID: 1, Name: "New Library", Path: tempDir}

			_, err := repo.Save(ctx, library)
			Expect(err).NotTo(HaveOccurred())

			// Wait briefly for the goroutine to complete
			Eventually(func() int {
				return scanner.GetScanAllCallCount()
			}, "1s", "10ms").Should(Equal(1))

			// Verify scan was called with correct parameters
			calls := scanner.GetScanAllCalls()
			Expect(calls[0].FullScan).To(BeFalse()) // Should be quick scan
		})

		It("triggers scan when updating library path", func() {
			// First create a library
			libraryRepo.SetData(model.Libraries{
				{ID: 1, Name: "Original Library", Path: tempDir},
			})

			// Create a new temporary directory for the update
			newTempDir, err := os.MkdirTemp("", "navidrome-library-update-")
			Expect(err).NotTo(HaveOccurred())
			DeferCleanup(func() { os.RemoveAll(newTempDir) })

			// Update the library with a new path
			library := &model.Library{ID: 1, Name: "Updated Library", Path: newTempDir}
			err = repo.Update(ctx, "1", *library)
			Expect(err).NotTo(HaveOccurred())

			// Wait briefly for the goroutine to complete
			Eventually(func() int {
				return scanner.GetScanAllCallCount()
			}, "1s", "10ms").Should(Equal(1))

			// Verify scan was called with correct parameters
			calls := scanner.GetScanAllCalls()
			Expect(calls[0].FullScan).To(BeFalse()) // Should be quick scan
		})

		It("does not trigger scan when updating library without path change", func() {
			// First create a library
			libraryRepo.SetData(model.Libraries{
				{ID: 1, Name: "Original Library", Path: tempDir},
			})

			// Update the library name only (same path)
			library := &model.Library{ID: 1, Name: "Updated Name", Path: tempDir}
			err := repo.Update(ctx, "1", *library)
			Expect(err).NotTo(HaveOccurred())

			// Wait a bit to ensure no scan was triggered
			Consistently(func() int {
				return scanner.GetScanAllCallCount()
			}, "100ms", "10ms").Should(Equal(0))
		})

		It("triggers scan when updating the library PID config", func() {
			libraryRepo.SetData(model.Libraries{{ID: 1, Name: "Library", Path: tempDir}})

			library := model.Library{ID: 1, Name: "Library", Path: tempDir, PIDAlbum: "folder"}
			Expect(repo.Update(ctx, "1", library)).To(Succeed())

			Eventually(func() int {
				return scanner.GetScanAllCallCount()
			}, "1s", "10ms").Should(Equal(1))
			// A quick scan: the scanner itself rescans this library in full
			Expect(scanner.GetScanAllCalls()[0].FullScan).To(BeFalse())
		})

		It("does not trigger scan when the PID fields were not sent", func() {
			libraryRepo.SetData(model.Libraries{{ID: 1, Name: "Library", Path: tempDir, PIDAlbum: "folder"}})

			// The REST layer decodes a missing pidAlbum as "". Only the sent fields count.
			library := model.Library{ID: 1, Name: "Renamed", Path: tempDir}
			Expect(repo.Update(ctx, "1", library, "name", "path")).To(Succeed())

			Consistently(func() int {
				return scanner.GetScanAllCallCount()
			}, "100ms", "10ms").Should(Equal(0))
		})

		It("waits for a running scan before triggering a new one", func() {
			libraryRepo.SetData(model.Libraries{{ID: 1, Name: "Library", Path: tempDir}})
			scanner.SetScanning(true)

			library := model.Library{ID: 1, Name: "Library", Path: tempDir, PIDAlbum: "folder"}
			Expect(repo.Update(ctx, "1", library)).To(Succeed())

			Consistently(func() int {
				return scanner.GetScanAllCallCount()
			}, "200ms", "20ms").Should(Equal(0))

			scanner.SetScanning(false)
			Eventually(func() int {
				return scanner.GetScanAllCallCount()
			}, "3s", "20ms").Should(Equal(1))
		})

		It("does not trigger scan when library creation fails", func() {
			// Try to create library with invalid data (empty name)
			library := &model.Library{Path: tempDir}

			_, err := repo.Save(ctx, library)
			Expect(err).To(HaveOccurred())

			// Ensure no scan was triggered since creation failed
			Consistently(func() int {
				return scanner.GetScanAllCallCount()
			}, "100ms", "10ms").Should(Equal(0))
		})

		It("does not trigger scan when library update fails", func() {
			// First create a library
			libraryRepo.SetData(model.Libraries{
				{ID: 1, Name: "Original Library", Path: tempDir},
			})

			// Try to update with invalid data (empty name)
			library := &model.Library{ID: 1, Name: "", Path: tempDir}
			err := repo.Update(ctx, "1", *library)
			Expect(err).To(HaveOccurred())

			// Ensure no scan was triggered since update failed
			Consistently(func() int {
				return scanner.GetScanAllCallCount()
			}, "100ms", "10ms").Should(Equal(0))
		})

		It("triggers scan when deleting a library", func() {
			// First create a library
			libraryRepo.SetData(model.Libraries{
				{ID: 1, Name: "Library to Delete", Path: tempDir},
			})

			// Delete the library
			err := repo.Delete(ctx, "1")
			Expect(err).NotTo(HaveOccurred())

			// Wait briefly for the goroutine to complete
			Eventually(func() int {
				return scanner.GetScanAllCallCount()
			}, "1s", "10ms").Should(Equal(1))

			// Verify scan was called with correct parameters
			calls := scanner.GetScanAllCalls()
			Expect(calls[0].FullScan).To(BeFalse()) // Should be quick scan
		})

		It("does not trigger scan when library deletion fails", func() {
			// Try to delete a non-existent library
			err := repo.Delete(ctx, "999")
			Expect(err).To(HaveOccurred())

			// Ensure no scan was triggered since deletion failed
			Consistently(func() int {
				return scanner.GetScanAllCallCount()
			}, "100ms", "10ms").Should(Equal(0))
		})

		Context("Watcher Integration", func() {
			It("starts watcher when creating a new library", func() {
				library := &model.Library{ID: 1, Name: "New Library", Path: tempDir}

				_, err := repo.Save(ctx, library)
				Expect(err).NotTo(HaveOccurred())

				// Verify watcher was started
				Eventually(func() int {
					return watcherManager.lenStarted()
				}, "1s", "10ms").Should(Equal(1))

				Expect(watcherManager.StartedWatchers[0].ID).To(Equal(1))
				Expect(watcherManager.StartedWatchers[0].Name).To(Equal("New Library"))
				Expect(watcherManager.StartedWatchers[0].Path).To(Equal(tempDir))
			})

			It("restarts watcher when library path is updated", func() {
				// First create a library
				libraryRepo.SetData(model.Libraries{
					{ID: 1, Name: "Original Library", Path: tempDir},
				})

				// Simulate that this library already has a watcher
				watcherManager.simulateExistingLibrary(model.Library{ID: 1, Name: "Original Library", Path: tempDir})

				// Create a new temp directory for the update
				newTempDir, err := os.MkdirTemp("", "navidrome-library-update-")
				Expect(err).NotTo(HaveOccurred())
				DeferCleanup(func() { os.RemoveAll(newTempDir) })

				// Update library with new path
				library := &model.Library{ID: 1, Name: "Updated Library", Path: newTempDir}
				err = repo.Update(ctx, "1", *library)
				Expect(err).NotTo(HaveOccurred())

				// Verify watcher was restarted
				Eventually(func() int {
					return watcherManager.lenRestarted()
				}, "1s", "10ms").Should(Equal(1))

				Expect(watcherManager.RestartedWatchers[0].ID).To(Equal(1))
				Expect(watcherManager.RestartedWatchers[0].Path).To(Equal(newTempDir))
			})

			It("does not restart watcher when only library name is updated", func() {
				// First create a library
				libraryRepo.SetData(model.Libraries{
					{ID: 1, Name: "Original Library", Path: tempDir},
				})

				// Update library with same path but different name
				library := &model.Library{ID: 1, Name: "Updated Name", Path: tempDir}
				err := repo.Update(ctx, "1", *library)
				Expect(err).NotTo(HaveOccurred())

				// Verify watcher was NOT restarted (since path didn't change)
				Consistently(func() int {
					return watcherManager.lenRestarted()
				}, "100ms", "10ms").Should(Equal(0))
			})

			It("stops watcher when library is deleted", func() {
				// Set up a library
				libraryRepo.SetData(model.Libraries{
					{ID: 1, Name: "Test Library", Path: tempDir},
				})

				err := repo.Delete(ctx, "1")
				Expect(err).NotTo(HaveOccurred())

				// Verify watcher was stopped
				Eventually(func() int {
					return watcherManager.lenStopped()
				}, "1s", "10ms").Should(Equal(1))

				Expect(watcherManager.StoppedWatchers[0]).To(Equal(1))
			})

			It("does not stop watcher when library deletion fails", func() {
				// Set up a library
				libraryRepo.SetData(model.Libraries{
					{ID: 1, Name: "Test Library", Path: tempDir},
				})

				// Mock deletion to fail by trying to delete non-existent library
				err := repo.Delete(ctx, "999")
				Expect(err).To(HaveOccurred())

				// Verify watcher was NOT stopped since deletion failed
				Consistently(func() int {
					return watcherManager.lenStopped()
				}, "100ms", "10ms").Should(Equal(0))
			})
		})
	})

	Describe("Event Broadcasting", func() {
		var repo rest.Persistable[model.Library]

		BeforeEach(func() {
			r := service.Repository()
			repo = r.(rest.Persistable[model.Library])
			// Clear any events from broker
			broker.Events = []events.Event{}
		})

		It("sends refresh event when creating a library", func() {
			library := &model.Library{ID: 1, Name: "New Library", Path: tempDir}

			_, err := repo.Save(ctx, library)

			Expect(err).NotTo(HaveOccurred())
			Expect(broker.Events).To(HaveLen(1))
		})

		It("sends refresh event when updating a library", func() {
			// First create a library
			libraryRepo.SetData(model.Libraries{
				{ID: 1, Name: "Original Library", Path: tempDir},
			})

			library := &model.Library{ID: 1, Name: "Updated Library", Path: tempDir}
			err := repo.Update(ctx, "1", *library)

			Expect(err).NotTo(HaveOccurred())
			Expect(broker.Events).To(HaveLen(1))
		})

		It("sends refresh event when deleting a library", func() {
			// First create a library
			libraryRepo.SetData(model.Libraries{
				{ID: 2, Name: "Library to Delete", Path: tempDir},
			})

			err := repo.Delete(ctx, "2")

			Expect(err).NotTo(HaveOccurred())
			Expect(broker.Events).To(HaveLen(1))
		})
	})

	Describe("Plugin Manager Integration", func() {
		var repo rest.Persistable[model.Library]

		BeforeEach(func() {
			// Reset the call count for each test
			pluginManager.unloadCalls = 0
			r := service.Repository()
			repo = r.(rest.Persistable[model.Library])
		})

		It("calls UnloadDisabledPlugins after successful library deletion", func() {
			libraryRepo.SetData(model.Libraries{
				{ID: 2, Name: "Library to Delete", Path: tempDir},
			})

			err := repo.Delete(ctx, "2")
			Expect(err).NotTo(HaveOccurred())
			Expect(pluginManager.unloadCalls).To(Equal(1))
		})

		It("does not call UnloadDisabledPlugins when library deletion fails", func() {
			// Try to delete non-existent library
			err := repo.Delete(ctx, "999")
			Expect(err).To(HaveOccurred())
			Expect(pluginManager.unloadCalls).To(Equal(0))
		})
	})
})

// mockPluginUnloader is a simple mock for testing UnloadDisabledPlugins calls
type mockPluginUnloader struct {
	unloadCalls int
}

func (m *mockPluginUnloader) UnloadDisabledPlugins(ctx context.Context) {
	m.unloadCalls++
}

// mockWatcherManager provides a simple mock implementation of core.Watcher for testing
type mockWatcherManager struct {
	StartedWatchers   []model.Library
	StoppedWatchers   []int
	RestartedWatchers []model.Library
	libraryStates     map[int]model.Library // Track which libraries we know about
	mu                sync.RWMutex
}

func (m *mockWatcherManager) Watch(ctx context.Context, lib *model.Library) error {
	m.mu.Lock()
	defer m.mu.Unlock()

	// Check if we already know about this library ID
	if _, exists := m.libraryStates[lib.ID]; exists {
		// This is a restart - the library already existed
		// Update our tracking and record the restart
		for i, startedLib := range m.StartedWatchers {
			if startedLib.ID == lib.ID {
				m.StartedWatchers[i] = *lib
				break
			}
		}
		m.RestartedWatchers = append(m.RestartedWatchers, *lib)
		m.libraryStates[lib.ID] = *lib
		return nil
	}

	// This is a new library - first time we're seeing it
	m.StartedWatchers = append(m.StartedWatchers, *lib)
	m.libraryStates[lib.ID] = *lib
	return nil
}

func (m *mockWatcherManager) StopWatching(ctx context.Context, libraryID int) error {
	m.mu.Lock()
	defer m.mu.Unlock()
	m.StoppedWatchers = append(m.StoppedWatchers, libraryID)
	return nil
}

func (m *mockWatcherManager) lenStarted() int {
	m.mu.RLock()
	defer m.mu.RUnlock()
	return len(m.StartedWatchers)
}

func (m *mockWatcherManager) lenStopped() int {
	m.mu.RLock()
	defer m.mu.RUnlock()
	return len(m.StoppedWatchers)
}

func (m *mockWatcherManager) lenRestarted() int {
	m.mu.RLock()
	defer m.mu.RUnlock()
	return len(m.RestartedWatchers)
}

// simulateExistingLibrary simulates the scenario where a library already exists
// and has a watcher running (used by tests to set up the initial state)
func (m *mockWatcherManager) simulateExistingLibrary(lib model.Library) {
	m.mu.Lock()
	defer m.mu.Unlock()
	m.libraryStates[lib.ID] = lib
}

// mockEventBroker provides a mock implementation of events.Broker for testing
type mockEventBroker struct {
	http.Handler
	Events []events.Event
	mu     sync.RWMutex
}

func (m *mockEventBroker) SendMessage(ctx context.Context, event events.Event) {
	m.mu.Lock()
	defer m.mu.Unlock()
	m.Events = append(m.Events, event)
}

func (m *mockEventBroker) SendBroadcastMessage(ctx context.Context, event events.Event) {
	m.mu.Lock()
	defer m.mu.Unlock()
	m.Events = append(m.Events, event)
}