navidrome/core/library_test.go
Deluan 9aa06efede feat: add event broadcasting for library creation, update, and deletion
Signed-off-by: Deluan <deluan@navidrome.org>
2025-07-15 12:59:33 -04:00

981 lines
30 KiB
Go

package core_test
import (
"context"
"errors"
"net/http"
"os"
"path/filepath"
"sync"
"github.com/deluan/rest"
_ "github.com/navidrome/navidrome/adapters/taglib" // 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 *mockScanner
var watcherManager *mockWatcherManager
var broker *mockEventBroker
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 = &mockScanner{}
// Create a mock watcher manager
watcherManager = &mockWatcherManager{
libraryStates: make(map[int]model.Library),
}
// Create a mock event broker
broker = &mockEventBroker{}
service = core.NewLibrary(ds, scanner, watcherManager, broker)
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
BeforeEach(func() {
r := service.NewRepository(ctx)
repo = r.(rest.Persistable)
})
Describe("Create", func() {
It("creates a new library successfully", func() {
library := &model.Library{ID: 1, Name: "New Library", Path: tempDir}
_, err := repo.Save(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(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(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(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(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(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("1", library)
Expect(err).NotTo(HaveOccurred())
Expect(libraryRepo.Data[1].Name).To(Equal("Updated Library"))
Expect(libraryRepo.Data[1].Path).To(Equal(newTempDir))
})
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("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("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("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("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("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("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("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("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(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(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(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(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("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("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("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("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("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("1")
Expect(err).NotTo(HaveOccurred())
Expect(libraryRepo.Data).To(HaveLen(0))
})
It("fails when library doesn't exist", func() {
err := repo.Delete("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
BeforeEach(func() {
r := service.NewRepository(ctx)
repo = r.(rest.Persistable)
})
It("triggers scan when creating a new library", func() {
library := &model.Library{ID: 1, Name: "New Library", Path: tempDir}
_, err := repo.Save(library)
Expect(err).NotTo(HaveOccurred())
// Wait briefly for the goroutine to complete
Eventually(func() int {
return scanner.len()
}, "1s", "10ms").Should(Equal(1))
// Verify scan was called with correct parameters
Expect(scanner.ScanCalls[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("1", library)
Expect(err).NotTo(HaveOccurred())
// Wait briefly for the goroutine to complete
Eventually(func() int {
return scanner.len()
}, "1s", "10ms").Should(Equal(1))
// Verify scan was called with correct parameters
Expect(scanner.ScanCalls[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("1", library)
Expect(err).NotTo(HaveOccurred())
// Wait a bit to ensure no scan was triggered
Consistently(func() int {
return scanner.len()
}, "100ms", "10ms").Should(Equal(0))
})
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(library)
Expect(err).To(HaveOccurred())
// Ensure no scan was triggered since creation failed
Consistently(func() int {
return scanner.len()
}, "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("1", library)
Expect(err).To(HaveOccurred())
// Ensure no scan was triggered since update failed
Consistently(func() int {
return scanner.len()
}, "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("1")
Expect(err).NotTo(HaveOccurred())
// Wait briefly for the goroutine to complete
Eventually(func() int {
return scanner.len()
}, "1s", "10ms").Should(Equal(1))
// Verify scan was called with correct parameters
Expect(scanner.ScanCalls[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("999")
Expect(err).To(HaveOccurred())
// Ensure no scan was triggered since deletion failed
Consistently(func() int {
return scanner.len()
}, "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(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("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("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("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("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
BeforeEach(func() {
r := service.NewRepository(ctx)
repo = r.(rest.Persistable)
// 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(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("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("2")
Expect(err).NotTo(HaveOccurred())
Expect(broker.Events).To(HaveLen(1))
})
})
})
// mockScanner provides a simple mock implementation of core.Scanner for testing
type mockScanner struct {
ScanCalls []ScanCall
mu sync.RWMutex
}
type ScanCall struct {
FullScan bool
}
func (m *mockScanner) ScanAll(ctx context.Context, fullScan bool) (warnings []string, err error) {
m.mu.Lock()
defer m.mu.Unlock()
m.ScanCalls = append(m.ScanCalls, ScanCall{
FullScan: fullScan,
})
return []string{}, nil
}
func (m *mockScanner) len() int {
m.mu.RLock()
defer m.mu.RUnlock()
return len(m.ScanCalls)
}
// 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)
}