mirror of
https://github.com/navidrome/navidrome.git
synced 2026-10-08 02:17:25 +02:00
* feat(scrobbler): add scrobble_filter column to user * feat(scrobbler): validate scrobble filter criteria on user save * refactor(persistence): make smart playlist join helpers package-level * feat(scrobbler): add MediaFileRepository.MatchesCriteria * feat(scrobbler): filter external scrobbles with per-user criteria * feat(ui): add scrobble filter field to user form * fix(scrobbler): default scrobble_filter to empty string for existing users * refactor(scrobbler): also gate playback reports on the scrobble filter Playback reports carry the same track metadata to plugin scrobblers, so a filtered track leaked through that third dispatch path. Skip the filter evaluation entirely when no scrobbler is active. * refactor(persistence): move criteria join building into criteria_sql.go The join set a criteria needs was decided in criteria_sql.go but built in smart_playlist_repository.go, so both callers had to pair the two by hand. * refactor(persistence): unexport smartPlaylistCriteria methods The type never leaves the package, so the exported names advertised an API that callers outside persistence could never reach. Also disambiguates where/orderBy from squirrel's SelectBuilder methods of the same name. * fix(ui): cap the scrobble filter field width fullWidth stretched it across the whole page next to 256px inputs. Bounded at 40em, with two rows and a resize handle so JSON rules stay readable. * refactor(ui): move scrobble filter input in UserEdit component * feat(ui): add pt-BR translations for the scrobble filter * fix(scrobbler): take the filter verdict before incPlay incPlay mutates play counts and dates a filter can test on, so evaluating at dispatch time let one play decide differently on either side of the increment: a track could be scrobbled despite matching, or lose only its stopped report and strand presence plugins. Reject limit/offset too, rather than silently ignoring part of a rule copied from a smart playlist. * fix(scrobbler): filter the report from an expired session The expiry callback runs with a stub user carrying no filter, so evaluating there always returned false and leaked the track to plugin scrobblers. That is the normal path for clients that never send stopped, such as legacy Subsonic now-playing. Carry the last verdict on the session instead. * refactor(scrobbler): skip the now-playing enqueue instead of threading the verdict Queuing an entry only to drop it at dispatch also cancelled a pending announcement for the previous, unfiltered track, since the queue is keyed by player and a new entry replaces the old one. * fix(scrobbler): evaluate the filter regardless of active scrobblers The verdict is stored on the session and dispatched at expiry, so skipping evaluation when no scrobbler was active let a plugin enabled mid-session receive a filtered track. The empty-filter guard above already gives servers without scrobbling the same free path, so the shortcut only ever applied to users who had a filter set.
686 lines
23 KiB
Go
686 lines
23 KiB
Go
package persistence
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"slices"
|
|
|
|
"github.com/Masterminds/squirrel"
|
|
"github.com/deluan/rest"
|
|
"github.com/navidrome/navidrome/consts"
|
|
"github.com/navidrome/navidrome/log"
|
|
"github.com/navidrome/navidrome/model"
|
|
"github.com/navidrome/navidrome/model/id"
|
|
"github.com/navidrome/navidrome/model/request"
|
|
"github.com/navidrome/navidrome/tests"
|
|
. "github.com/onsi/ginkgo/v2"
|
|
. "github.com/onsi/gomega"
|
|
)
|
|
|
|
var _ = Describe("UserRepository", func() {
|
|
var repo model.UserRepository
|
|
|
|
BeforeEach(func() {
|
|
repo = NewUserRepository(log.NewContext(GinkgoT().Context()), GetDBXBuilder())
|
|
})
|
|
|
|
Describe("Put/Get/FindByUsername", func() {
|
|
usr := model.User{
|
|
ID: "123",
|
|
UserName: "AdMiN",
|
|
Name: "Admin",
|
|
Email: "admin@admin.com",
|
|
NewPassword: "wordpass",
|
|
IsAdmin: true,
|
|
}
|
|
It("saves the user to the DB", func() {
|
|
Expect(repo.Put(&usr)).To(BeNil())
|
|
})
|
|
It("returns the newly created user", func() {
|
|
actual, err := repo.Get("123")
|
|
Expect(err).ToNot(HaveOccurred())
|
|
Expect(actual.Name).To(Equal("Admin"))
|
|
})
|
|
It("find the user by case-insensitive username", func() {
|
|
actual, err := repo.FindByUsername("aDmIn")
|
|
Expect(err).ToNot(HaveOccurred())
|
|
Expect(actual.Name).To(Equal("Admin"))
|
|
})
|
|
It("find the user by username and decrypts the password", func() {
|
|
actual, err := repo.FindByUsernameWithPassword("aDmIn")
|
|
Expect(err).ToNot(HaveOccurred())
|
|
Expect(actual.Name).To(Equal("Admin"))
|
|
Expect(actual.Password).To(Equal("wordpass"))
|
|
})
|
|
It("updates the name and keep the same password", func() {
|
|
usr.Name = "Jane Doe"
|
|
usr.NewPassword = ""
|
|
Expect(repo.Put(&usr)).To(BeNil())
|
|
|
|
actual, err := repo.FindByUsernameWithPassword("admin")
|
|
Expect(err).ToNot(HaveOccurred())
|
|
Expect(actual.Name).To(Equal("Jane Doe"))
|
|
Expect(actual.Password).To(Equal("wordpass"))
|
|
})
|
|
It("updates password if specified", func() {
|
|
usr.NewPassword = "newpass"
|
|
Expect(repo.Put(&usr)).To(BeNil())
|
|
|
|
actual, err := repo.FindByUsernameWithPassword("admin")
|
|
Expect(err).ToNot(HaveOccurred())
|
|
Expect(actual.Password).To(Equal("newpass"))
|
|
})
|
|
It("persists and reads back the scrobble filter", func() {
|
|
usr := model.User{ID: "u-filter", UserName: "u-filter", Name: "Filter User",
|
|
ScrobbleFilter: `{"all":[{"contains":{"title":"????"}}]}`}
|
|
Expect(repo.Put(&usr)).To(Succeed())
|
|
|
|
saved, err := repo.Get("u-filter")
|
|
Expect(err).ToNot(HaveOccurred())
|
|
Expect(saved.ScrobbleFilter).To(Equal(`{"all":[{"contains":{"title":"????"}}]}`))
|
|
})
|
|
It("reads back a user row inserted without scrobble_filter", func() {
|
|
// Guards the column's NOT NULL DEFAULT '': rows predating the migration must stay scannable
|
|
_, err := GetDBXBuilder().NewQuery(
|
|
"insert into user (id, user_name, name, email, password, created_at, updated_at) " +
|
|
"values ('u-rawsql', 'u-rawsql', 'Raw', '', '', datetime('now'), datetime('now'))").Execute()
|
|
Expect(err).ToNot(HaveOccurred())
|
|
|
|
saved, err := repo.Get("u-rawsql")
|
|
Expect(err).ToNot(HaveOccurred())
|
|
Expect(saved.ScrobbleFilter).To(Equal(""))
|
|
})
|
|
})
|
|
|
|
Describe("validatePasswordChange", func() {
|
|
var loggedUser *model.User
|
|
|
|
BeforeEach(func() {
|
|
loggedUser = &model.User{ID: "1", UserName: "logan"}
|
|
})
|
|
|
|
It("does nothing if passwords are not specified", func() {
|
|
user := &model.User{ID: "2", UserName: "johndoe"}
|
|
err := validatePasswordChange(user, loggedUser)
|
|
Expect(err).ToNot(HaveOccurred())
|
|
})
|
|
|
|
Context("Autogenerated password (used with Reverse Proxy Authentication)", func() {
|
|
var user model.User
|
|
BeforeEach(func() {
|
|
loggedUser.IsAdmin = false
|
|
loggedUser.Password = consts.PasswordAutogenPrefix + id.NewRandom()
|
|
})
|
|
It("does nothing if passwords are not specified", func() {
|
|
user = *loggedUser
|
|
err := validatePasswordChange(&user, loggedUser)
|
|
Expect(err).ToNot(HaveOccurred())
|
|
})
|
|
It("does not requires currentPassword for regular user", func() {
|
|
user = *loggedUser
|
|
user.CurrentPassword = ""
|
|
user.NewPassword = "new"
|
|
err := validatePasswordChange(&user, loggedUser)
|
|
Expect(err).ToNot(HaveOccurred())
|
|
})
|
|
It("does not requires currentPassword for admin", func() {
|
|
loggedUser.IsAdmin = true
|
|
user = *loggedUser
|
|
user.CurrentPassword = ""
|
|
user.NewPassword = "new"
|
|
err := validatePasswordChange(&user, loggedUser)
|
|
Expect(err).ToNot(HaveOccurred())
|
|
})
|
|
})
|
|
|
|
Context("Logged User is admin", func() {
|
|
BeforeEach(func() {
|
|
loggedUser.IsAdmin = true
|
|
})
|
|
It("can change other user's passwords without currentPassword", func() {
|
|
user := &model.User{ID: "2", UserName: "johndoe"}
|
|
user.NewPassword = "new"
|
|
err := validatePasswordChange(user, loggedUser)
|
|
Expect(err).ToNot(HaveOccurred())
|
|
})
|
|
It("requires currentPassword to change its own", func() {
|
|
user := *loggedUser
|
|
user.NewPassword = "new"
|
|
err := validatePasswordChange(&user, loggedUser)
|
|
var verr *rest.ValidationError
|
|
errors.As(err, &verr)
|
|
Expect(verr.Errors).To(HaveLen(1))
|
|
Expect(verr.Errors).To(HaveKeyWithValue("currentPassword", "ra.validation.required"))
|
|
})
|
|
It("does not allow to change password to empty string", func() {
|
|
loggedUser.Password = "abc123"
|
|
user := *loggedUser
|
|
user.CurrentPassword = "abc123"
|
|
err := validatePasswordChange(&user, loggedUser)
|
|
var verr *rest.ValidationError
|
|
errors.As(err, &verr)
|
|
Expect(verr.Errors).To(HaveLen(1))
|
|
Expect(verr.Errors).To(HaveKeyWithValue("password", "ra.validation.required"))
|
|
})
|
|
It("fails if currentPassword does not match", func() {
|
|
loggedUser.Password = "abc123"
|
|
user := *loggedUser
|
|
user.CurrentPassword = "current"
|
|
user.NewPassword = "new"
|
|
err := validatePasswordChange(&user, loggedUser)
|
|
var verr *rest.ValidationError
|
|
errors.As(err, &verr)
|
|
Expect(verr.Errors).To(HaveLen(1))
|
|
Expect(verr.Errors).To(HaveKeyWithValue("currentPassword", "ra.validation.passwordDoesNotMatch"))
|
|
})
|
|
It("can change own password if requirements are met", func() {
|
|
loggedUser.Password = "abc123"
|
|
user := *loggedUser
|
|
user.CurrentPassword = "abc123"
|
|
user.NewPassword = "new"
|
|
err := validatePasswordChange(&user, loggedUser)
|
|
Expect(err).ToNot(HaveOccurred())
|
|
})
|
|
})
|
|
|
|
Context("Logged User is a regular user", func() {
|
|
BeforeEach(func() {
|
|
loggedUser.IsAdmin = false
|
|
})
|
|
It("requires currentPassword", func() {
|
|
user := *loggedUser
|
|
user.NewPassword = "new"
|
|
err := validatePasswordChange(&user, loggedUser)
|
|
var verr *rest.ValidationError
|
|
errors.As(err, &verr)
|
|
Expect(verr.Errors).To(HaveLen(1))
|
|
Expect(verr.Errors).To(HaveKeyWithValue("currentPassword", "ra.validation.required"))
|
|
})
|
|
It("does not allow to change password to empty string", func() {
|
|
loggedUser.Password = "abc123"
|
|
user := *loggedUser
|
|
user.CurrentPassword = "abc123"
|
|
err := validatePasswordChange(&user, loggedUser)
|
|
var verr *rest.ValidationError
|
|
errors.As(err, &verr)
|
|
Expect(verr.Errors).To(HaveLen(1))
|
|
Expect(verr.Errors).To(HaveKeyWithValue("password", "ra.validation.required"))
|
|
})
|
|
It("fails if currentPassword does not match", func() {
|
|
loggedUser.Password = "abc123"
|
|
user := *loggedUser
|
|
user.CurrentPassword = "current"
|
|
user.NewPassword = "new"
|
|
err := validatePasswordChange(&user, loggedUser)
|
|
var verr *rest.ValidationError
|
|
errors.As(err, &verr)
|
|
Expect(verr.Errors).To(HaveLen(1))
|
|
Expect(verr.Errors).To(HaveKeyWithValue("currentPassword", "ra.validation.passwordDoesNotMatch"))
|
|
})
|
|
It("can change own password if requirements are met", func() {
|
|
loggedUser.Password = "abc123"
|
|
user := *loggedUser
|
|
user.CurrentPassword = "abc123"
|
|
user.NewPassword = "new"
|
|
err := validatePasswordChange(&user, loggedUser)
|
|
Expect(err).ToNot(HaveOccurred())
|
|
})
|
|
})
|
|
})
|
|
|
|
Describe("ReadAll name filter", func() {
|
|
var adminRepo model.ResourceRepository
|
|
|
|
BeforeEach(func() {
|
|
adminCtx := request.WithUser(GinkgoT().Context(), model.User{ID: "admin-id", UserName: "admin", IsAdmin: true})
|
|
adminRepo = NewUserRepository(adminCtx, GetDBXBuilder()).(model.ResourceRepository)
|
|
|
|
for _, u := range []model.User{
|
|
{ID: "filter-alice", UserName: "alice_filter", Name: "Alice Filter", NewPassword: "x"},
|
|
{ID: "filter-bob", UserName: "bob_filter", Name: "Bob Filter", NewPassword: "x"},
|
|
} {
|
|
Expect(adminRepo.(model.UserRepository).Put(&u)).To(Succeed())
|
|
}
|
|
})
|
|
|
|
AfterEach(func() {
|
|
ur := adminRepo.(model.UserRepository)
|
|
_ = ur.Delete("filter-alice")
|
|
_ = ur.Delete("filter-bob")
|
|
})
|
|
|
|
It("matches users whose name starts with the given prefix", func() {
|
|
res, err := adminRepo.ReadAll(rest.QueryOptions{Filters: map[string]any{"name": "Alice"}})
|
|
Expect(err).ToNot(HaveOccurred())
|
|
users := res.(model.Users)
|
|
|
|
var names []string
|
|
for _, u := range users {
|
|
names = append(names, u.Name)
|
|
}
|
|
Expect(names).To(ContainElement("Alice Filter"))
|
|
Expect(names).ToNot(ContainElement("Bob Filter"))
|
|
})
|
|
|
|
It("does not match names by mid-string substring (startsWith, not contains)", func() {
|
|
res, err := adminRepo.ReadAll(rest.QueryOptions{Filters: map[string]any{"name": "Filter"}})
|
|
Expect(err).ToNot(HaveOccurred())
|
|
users := res.(model.Users)
|
|
|
|
for _, u := range users {
|
|
Expect(u.ID).ToNot(Or(Equal("filter-alice"), Equal("filter-bob")),
|
|
"a mid-string substring should not match a startsWith filter")
|
|
}
|
|
})
|
|
})
|
|
|
|
Describe("validateUsernameUnique", func() {
|
|
var repo *tests.MockedUserRepo
|
|
var existingUser *model.User
|
|
BeforeEach(func() {
|
|
existingUser = &model.User{ID: "1", UserName: "johndoe"}
|
|
repo = tests.CreateMockUserRepo()
|
|
err := repo.Put(existingUser)
|
|
Expect(err).ToNot(HaveOccurred())
|
|
})
|
|
It("allows unique usernames", func() {
|
|
var newUser = &model.User{ID: "2", UserName: "unique_username"}
|
|
err := validateUsernameUnique(repo, newUser)
|
|
Expect(err).ToNot(HaveOccurred())
|
|
})
|
|
It("returns ValidationError if username already exists", func() {
|
|
var newUser = &model.User{ID: "2", UserName: "johndoe"}
|
|
err := validateUsernameUnique(repo, newUser)
|
|
var verr *rest.ValidationError
|
|
isValidationError := errors.As(err, &verr)
|
|
|
|
Expect(isValidationError).To(BeTrue())
|
|
Expect(verr.Errors).To(HaveKeyWithValue("userName", "ra.validation.unique"))
|
|
})
|
|
It("returns generic error if repository call fails", func() {
|
|
repo.Error = errors.New("fake error")
|
|
|
|
var newUser = &model.User{ID: "2", UserName: "newuser"}
|
|
err := validateUsernameUnique(repo, newUser)
|
|
Expect(err).To(MatchError("fake error"))
|
|
})
|
|
})
|
|
|
|
Describe("Library Association Methods", func() {
|
|
var userID string
|
|
var library1, library2 model.Library
|
|
|
|
BeforeEach(func() {
|
|
// Create a test user first to satisfy foreign key constraints
|
|
testUser := model.User{
|
|
ID: "test-user-id",
|
|
UserName: "testuser",
|
|
Name: "Test User",
|
|
Email: "test@example.com",
|
|
NewPassword: "password",
|
|
IsAdmin: false,
|
|
}
|
|
Expect(repo.Put(&testUser)).To(BeNil())
|
|
userID = testUser.ID
|
|
|
|
library1 = model.Library{ID: 0, Name: "Library 500", Path: "/path/500"}
|
|
library2 = model.Library{ID: 0, Name: "Library 501", Path: "/path/501"}
|
|
|
|
// Create test libraries
|
|
libRepo := NewLibraryRepository(log.NewContext(context.TODO()), GetDBXBuilder())
|
|
Expect(libRepo.Put(&library1)).To(BeNil())
|
|
Expect(libRepo.Put(&library2)).To(BeNil())
|
|
})
|
|
|
|
AfterEach(func() {
|
|
// Clean up user-library associations to ensure test isolation
|
|
_ = repo.SetUserLibraries(userID, []int{})
|
|
|
|
// Clean up test libraries to ensure isolation between test groups
|
|
libRepo := NewLibraryRepository(log.NewContext(context.TODO()), GetDBXBuilder())
|
|
_ = libRepo.(*libraryRepository).delete(squirrel.Eq{"id": []int{library1.ID, library2.ID}})
|
|
})
|
|
|
|
Describe("GetUserLibraries", func() {
|
|
It("returns empty list when user has no library associations", func() {
|
|
libraries, err := repo.GetUserLibraries("non-existent-user")
|
|
Expect(err).ToNot(HaveOccurred())
|
|
Expect(libraries).To(HaveLen(0))
|
|
})
|
|
|
|
It("returns user's associated libraries", func() {
|
|
err := repo.SetUserLibraries(userID, []int{library1.ID, library2.ID})
|
|
Expect(err).ToNot(HaveOccurred())
|
|
|
|
libraries, err := repo.GetUserLibraries(userID)
|
|
Expect(err).ToNot(HaveOccurred())
|
|
Expect(libraries).To(HaveLen(2))
|
|
|
|
libIDs := []int{libraries[0].ID, libraries[1].ID}
|
|
Expect(libIDs).To(ContainElements(library1.ID, library2.ID))
|
|
})
|
|
})
|
|
|
|
Describe("SetUserLibraries", func() {
|
|
It("sets user's library associations", func() {
|
|
libraryIDs := []int{library1.ID, library2.ID}
|
|
err := repo.SetUserLibraries(userID, libraryIDs)
|
|
Expect(err).ToNot(HaveOccurred())
|
|
|
|
libraries, err := repo.GetUserLibraries(userID)
|
|
Expect(err).ToNot(HaveOccurred())
|
|
Expect(libraries).To(HaveLen(2))
|
|
})
|
|
|
|
It("replaces existing associations", func() {
|
|
// Set initial associations
|
|
err := repo.SetUserLibraries(userID, []int{library1.ID, library2.ID})
|
|
Expect(err).ToNot(HaveOccurred())
|
|
|
|
// Replace with just one library
|
|
err = repo.SetUserLibraries(userID, []int{library1.ID})
|
|
Expect(err).ToNot(HaveOccurred())
|
|
|
|
libraries, err := repo.GetUserLibraries(userID)
|
|
Expect(err).ToNot(HaveOccurred())
|
|
Expect(libraries).To(HaveLen(1))
|
|
Expect(libraries[0].ID).To(Equal(library1.ID))
|
|
})
|
|
|
|
It("removes all associations when passed empty slice", func() {
|
|
// Set initial associations
|
|
err := repo.SetUserLibraries(userID, []int{library1.ID, library2.ID})
|
|
Expect(err).ToNot(HaveOccurred())
|
|
|
|
// Remove all
|
|
err = repo.SetUserLibraries(userID, []int{})
|
|
Expect(err).ToNot(HaveOccurred())
|
|
|
|
libraries, err := repo.GetUserLibraries(userID)
|
|
Expect(err).ToNot(HaveOccurred())
|
|
Expect(libraries).To(HaveLen(0))
|
|
})
|
|
})
|
|
})
|
|
|
|
Describe("Admin User Auto-Assignment", func() {
|
|
var (
|
|
libRepo model.LibraryRepository
|
|
library1 model.Library
|
|
library2 model.Library
|
|
initialLibCount int
|
|
)
|
|
|
|
BeforeEach(func() {
|
|
libRepo = NewLibraryRepository(log.NewContext(context.TODO()), GetDBXBuilder())
|
|
|
|
// Count initial libraries
|
|
existingLibs, err := libRepo.GetAll()
|
|
Expect(err).ToNot(HaveOccurred())
|
|
initialLibCount = len(existingLibs)
|
|
|
|
library1 = model.Library{ID: 0, Name: "Admin Test Library 1", Path: "/admin/test/path1"}
|
|
library2 = model.Library{ID: 0, Name: "Admin Test Library 2", Path: "/admin/test/path2"}
|
|
|
|
// Create test libraries
|
|
Expect(libRepo.Put(&library1)).To(BeNil())
|
|
Expect(libRepo.Put(&library2)).To(BeNil())
|
|
})
|
|
|
|
AfterEach(func() {
|
|
// Clean up test libraries and their associations
|
|
_ = libRepo.(*libraryRepository).delete(squirrel.Eq{"id": []int{library1.ID, library2.ID}})
|
|
|
|
// Clean up user-library associations for these test libraries
|
|
_, _ = repo.(*userRepository).executeSQL(squirrel.Delete("user_library").Where(squirrel.Eq{"library_id": []int{library1.ID, library2.ID}}))
|
|
})
|
|
|
|
It("automatically assigns all libraries to admin users when created", func() {
|
|
adminUser := model.User{
|
|
ID: "admin-user-id-1",
|
|
UserName: "adminuser1",
|
|
Name: "Admin User",
|
|
Email: "admin1@example.com",
|
|
NewPassword: "password",
|
|
IsAdmin: true,
|
|
}
|
|
|
|
err := repo.Put(&adminUser)
|
|
Expect(err).ToNot(HaveOccurred())
|
|
|
|
// Admin should automatically have access to all libraries (including existing ones)
|
|
libraries, err := repo.GetUserLibraries(adminUser.ID)
|
|
Expect(err).ToNot(HaveOccurred())
|
|
Expect(libraries).To(HaveLen(initialLibCount + 2)) // Initial libraries + our 2 test libraries
|
|
|
|
libIDs := make([]int, len(libraries))
|
|
for i, lib := range libraries {
|
|
libIDs[i] = lib.ID
|
|
}
|
|
Expect(libIDs).To(ContainElements(library1.ID, library2.ID))
|
|
})
|
|
|
|
It("automatically assigns all libraries to admin users when updated", func() {
|
|
// Create regular user first
|
|
regularUser := model.User{
|
|
ID: "regular-user-id-1",
|
|
UserName: "regularuser1",
|
|
Name: "Regular User",
|
|
Email: "regular1@example.com",
|
|
NewPassword: "password",
|
|
IsAdmin: false,
|
|
}
|
|
|
|
err := repo.Put(®ularUser)
|
|
Expect(err).ToNot(HaveOccurred())
|
|
|
|
// Give them access to just one library
|
|
err = repo.SetUserLibraries(regularUser.ID, []int{library1.ID})
|
|
Expect(err).ToNot(HaveOccurred())
|
|
|
|
// Promote to admin
|
|
regularUser.IsAdmin = true
|
|
err = repo.Put(®ularUser)
|
|
Expect(err).ToNot(HaveOccurred())
|
|
|
|
// Should now have access to all libraries (including existing ones)
|
|
libraries, err := repo.GetUserLibraries(regularUser.ID)
|
|
Expect(err).ToNot(HaveOccurred())
|
|
Expect(libraries).To(HaveLen(initialLibCount + 2)) // Initial libraries + our 2 test libraries
|
|
|
|
libIDs := make([]int, len(libraries))
|
|
for i, lib := range libraries {
|
|
libIDs[i] = lib.ID
|
|
}
|
|
// Should include our test libraries plus all existing ones
|
|
Expect(libIDs).To(ContainElements(library1.ID, library2.ID))
|
|
})
|
|
|
|
It("assigns default libraries to regular users", func() {
|
|
regularUser := model.User{
|
|
ID: "regular-user-id-2",
|
|
UserName: "regularuser2",
|
|
Name: "Regular User",
|
|
Email: "regular2@example.com",
|
|
NewPassword: "password",
|
|
IsAdmin: false,
|
|
}
|
|
|
|
err := repo.Put(®ularUser)
|
|
Expect(err).ToNot(HaveOccurred())
|
|
|
|
// Regular user should be assigned to default libraries (library ID 1 from migration)
|
|
libraries, err := repo.GetUserLibraries(regularUser.ID)
|
|
Expect(err).ToNot(HaveOccurred())
|
|
Expect(libraries).To(HaveLen(1))
|
|
Expect(libraries[0].ID).To(Equal(1))
|
|
Expect(libraries[0].DefaultNewUsers).To(BeTrue())
|
|
})
|
|
})
|
|
|
|
Describe("Libraries Field Population", func() {
|
|
var (
|
|
libRepo model.LibraryRepository
|
|
library1 model.Library
|
|
library2 model.Library
|
|
testUser model.User
|
|
)
|
|
|
|
BeforeEach(func() {
|
|
libRepo = NewLibraryRepository(log.NewContext(context.TODO()), GetDBXBuilder())
|
|
library1 = model.Library{ID: 0, Name: "Field Test Library 1", Path: "/field/test/path1"}
|
|
library2 = model.Library{ID: 0, Name: "Field Test Library 2", Path: "/field/test/path2"}
|
|
|
|
// Create test libraries
|
|
Expect(libRepo.Put(&library1)).To(BeNil())
|
|
Expect(libRepo.Put(&library2)).To(BeNil())
|
|
|
|
// Create test user
|
|
testUser = model.User{
|
|
ID: "field-test-user",
|
|
UserName: "fieldtestuser",
|
|
Name: "Field Test User",
|
|
Email: "fieldtest@example.com",
|
|
NewPassword: "password",
|
|
IsAdmin: false,
|
|
}
|
|
Expect(repo.Put(&testUser)).To(BeNil())
|
|
|
|
// Assign libraries to user
|
|
Expect(repo.SetUserLibraries(testUser.ID, []int{library1.ID, library2.ID})).To(BeNil())
|
|
})
|
|
|
|
AfterEach(func() {
|
|
// Clean up test libraries and their associations
|
|
_ = libRepo.(*libraryRepository).delete(squirrel.Eq{"id": []int{library1.ID, library2.ID}})
|
|
_ = repo.(*userRepository).delete(squirrel.Eq{"id": testUser.ID})
|
|
|
|
// Clean up user-library associations for these test libraries
|
|
_, _ = repo.(*userRepository).executeSQL(squirrel.Delete("user_library").Where(squirrel.Eq{"library_id": []int{library1.ID, library2.ID}}))
|
|
})
|
|
|
|
It("populates Libraries field when getting a single user", func() {
|
|
user, err := repo.Get(testUser.ID)
|
|
Expect(err).ToNot(HaveOccurred())
|
|
Expect(user.Libraries).To(HaveLen(2))
|
|
|
|
libIDs := []int{user.Libraries[0].ID, user.Libraries[1].ID}
|
|
Expect(libIDs).To(ContainElements(library1.ID, library2.ID))
|
|
|
|
// Check that library details are properly populated
|
|
for _, lib := range user.Libraries {
|
|
switch lib.ID {
|
|
case library1.ID:
|
|
Expect(lib.Name).To(Equal("Field Test Library 1"))
|
|
Expect(lib.Path).To(Equal("/field/test/path1"))
|
|
case library2.ID:
|
|
Expect(lib.Name).To(Equal("Field Test Library 2"))
|
|
Expect(lib.Path).To(Equal("/field/test/path2"))
|
|
}
|
|
}
|
|
})
|
|
|
|
It("populates Libraries field when getting all users", func() {
|
|
users, err := repo.(*userRepository).GetAll()
|
|
Expect(err).ToNot(HaveOccurred())
|
|
|
|
// Find our test user in the results
|
|
found := slices.IndexFunc(users, func(u model.User) bool { return u.ID == testUser.ID })
|
|
Expect(found).ToNot(Equal(-1))
|
|
|
|
foundUser := users[found]
|
|
Expect(foundUser).ToNot(BeNil())
|
|
Expect(foundUser.Libraries).To(HaveLen(2))
|
|
|
|
libIDs := []int{foundUser.Libraries[0].ID, foundUser.Libraries[1].ID}
|
|
Expect(libIDs).To(ContainElements(library1.ID, library2.ID))
|
|
})
|
|
|
|
It("populates Libraries field when finding user by username", func() {
|
|
user, err := repo.FindByUsername(testUser.UserName)
|
|
Expect(err).ToNot(HaveOccurred())
|
|
Expect(user.Libraries).To(HaveLen(2))
|
|
|
|
libIDs := []int{user.Libraries[0].ID, user.Libraries[1].ID}
|
|
Expect(libIDs).To(ContainElements(library1.ID, library2.ID))
|
|
})
|
|
|
|
It("returns default Libraries array for new regular users", func() {
|
|
// Create a user with no explicit library associations - should get default libraries
|
|
userWithoutLibs := model.User{
|
|
ID: "no-libs-user",
|
|
UserName: "nolibsuser",
|
|
Name: "No Libs User",
|
|
Email: "nolibs@example.com",
|
|
NewPassword: "password",
|
|
IsAdmin: false,
|
|
}
|
|
Expect(repo.Put(&userWithoutLibs)).To(BeNil())
|
|
defer func() { _ = repo.(*userRepository).delete(squirrel.Eq{"id": userWithoutLibs.ID}) }()
|
|
|
|
user, err := repo.Get(userWithoutLibs.ID)
|
|
Expect(err).ToNot(HaveOccurred())
|
|
Expect(user.Libraries).ToNot(BeNil())
|
|
// Regular users should be assigned to default libraries (library ID 1 from migration)
|
|
Expect(user.Libraries).To(HaveLen(1))
|
|
Expect(user.Libraries[0].ID).To(Equal(1))
|
|
})
|
|
})
|
|
|
|
Describe("validateScrobbleFilter", func() {
|
|
It("accepts an empty filter", func() {
|
|
u := &model.User{}
|
|
Expect(validateScrobbleFilter(u)).To(Succeed())
|
|
})
|
|
It("trims a whitespace-only filter to empty", func() {
|
|
u := &model.User{ScrobbleFilter: " "}
|
|
Expect(validateScrobbleFilter(u)).To(Succeed())
|
|
Expect(u.ScrobbleFilter).To(Equal(""))
|
|
})
|
|
It("accepts valid criteria JSON", func() {
|
|
u := &model.User{ScrobbleFilter: `{"all":[{"lt":{"rating":4}}]}`}
|
|
Expect(validateScrobbleFilter(u)).To(Succeed())
|
|
})
|
|
It("rejects malformed JSON", func() {
|
|
u := &model.User{ScrobbleFilter: `{not json`}
|
|
var vErr *rest.ValidationError
|
|
err := validateScrobbleFilter(u)
|
|
Expect(errors.As(err, &vErr)).To(BeTrue())
|
|
Expect(vErr.Errors).To(HaveKey("scrobbleFilter"))
|
|
})
|
|
It("rejects criteria without rules", func() {
|
|
u := &model.User{ScrobbleFilter: `{"sort":"title"}`}
|
|
Expect(validateScrobbleFilter(u)).ToNot(Succeed())
|
|
})
|
|
It("rejects selection options that mean nothing for a single track", func() {
|
|
for _, f := range []string{
|
|
`{"all":[{"lt":{"rating":4}}],"limit":100}`,
|
|
`{"all":[{"lt":{"rating":4}}],"limitPercent":10}`,
|
|
`{"all":[{"lt":{"rating":4}}],"offset":5}`,
|
|
`{"all":[{"lt":{"rating":4}}],"refreshDelay":"1h"}`,
|
|
} {
|
|
u := &model.User{ScrobbleFilter: f}
|
|
Expect(validateScrobbleFilter(u)).ToNot(Succeed(), f)
|
|
}
|
|
})
|
|
It("accepts a sort, which cannot change a single-track match", func() {
|
|
u := &model.User{ScrobbleFilter: `{"all":[{"lt":{"rating":4}}],"sort":"title"}`}
|
|
Expect(validateScrobbleFilter(u)).To(Succeed())
|
|
})
|
|
It("rejects unknown fields", func() {
|
|
u := &model.User{ScrobbleFilter: `{"all":[{"is":{"bogusfield":1}}]}`}
|
|
Expect(validateScrobbleFilter(u)).ToNot(Succeed())
|
|
})
|
|
})
|
|
|
|
Describe("filters", func() {
|
|
It("qualifies id filter with table name", func() {
|
|
r := repo.(*userRepository)
|
|
qo := r.parseRestOptions(r.ctx, rest.QueryOptions{Filters: map[string]any{"id": "123"}})
|
|
sel := r.selectUserWithLibraries(qo)
|
|
query, _, err := r.toSQL(sel)
|
|
Expect(err).NotTo(HaveOccurred())
|
|
Expect(query).To(ContainSubstring("user.id = {:p0}"))
|
|
})
|
|
})
|
|
})
|