🏡 index : quine-global/quintodrome.git

package persistence

import (
	"context"
	"errors"
	"slices"
	"sync"

	"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/navidrome/navidrome/utils/slice"
	. "github.com/onsi/ginkgo/v2"
	. "github.com/onsi/gomega"
)

var _ = Describe("UserRepository", func() {
	var repo model.UserRepository
	var ctx context.Context

	BeforeEach(func() {
		ctx = log.NewContext(GinkgoT().Context())
		repo = NewUserRepository(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(ctx, &usr)).To(BeNil())
		})
		It("returns the newly created user", func() {
			actual, err := repo.Get(ctx, "123")
			Expect(err).ToNot(HaveOccurred())
			Expect(actual.Name).To(Equal("Admin"))
		})
		It("find the user by case-insensitive username", func() {
			actual, err := repo.FindByUsername(ctx, "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(ctx, "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(ctx, &usr)).To(BeNil())

			actual, err := repo.FindByUsernameWithPassword(ctx, "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(ctx, &usr)).To(BeNil())

			actual, err := repo.FindByUsernameWithPassword(ctx, "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(ctx, &usr)).To(Succeed())

			saved, err := repo.Get(ctx, "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(ctx, "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("Delete", func() {
		It("returns not found for a missing user", func() {
			adminCtx := request.WithUser(log.NewContext(GinkgoT().Context()), adminUser)
			adminRepo := NewUserRepository(GetDBXBuilder()).(*userRepository)
			Expect(adminRepo.Delete(adminCtx, "does-not-exist")).To(MatchError(model.ErrNotFound))
		})
	})

	Describe("ReadAll name filter", func() {
		var adminRepo model.UserRepository
		var adminCtx context.Context

		BeforeEach(func() {
			adminCtx = request.WithUser(ctx, model.User{ID: "admin-id", UserName: "admin", IsAdmin: true})
			adminRepo = NewUserRepository(GetDBXBuilder())

			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.Put(adminCtx, &u)).To(Succeed())
			}
		})

		AfterEach(func() {
			_ = adminRepo.Delete(adminCtx, "filter-alice", "filter-bob")
		})

		It("matches users whose name starts with the given prefix", func() {
			users, err := adminRepo.ReadAll(adminCtx, rest.QueryOptions{Filters: map[string]any{"name": "Alice"}})
			Expect(err).ToNot(HaveOccurred())

			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() {
			users, err := adminRepo.ReadAll(adminCtx, rest.QueryOptions{Filters: map[string]any{"name": "Filter"}})
			Expect(err).ToNot(HaveOccurred())

			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(ctx, existingUser)
			Expect(err).ToNot(HaveOccurred())
		})
		It("allows unique usernames", func() {
			var newUser = &model.User{ID: "2", UserName: "unique_username"}
			err := validateUsernameUnique(ctx, repo, newUser)
			Expect(err).ToNot(HaveOccurred())
		})
		It("returns ValidationError if username already exists", func() {
			var newUser = &model.User{ID: "2", UserName: "johndoe"}
			err := validateUsernameUnique(ctx, 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(ctx, 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(ctx, &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(GetDBXBuilder())
			Expect(libRepo.Put(ctx, &library1)).To(BeNil())
			Expect(libRepo.Put(ctx, &library2)).To(BeNil())
		})

		AfterEach(func() {
			// Clean up user-library associations to ensure test isolation
			_ = repo.SetUserLibraries(ctx, userID, []int{})

			// Clean up test libraries to ensure isolation between test groups
			libRepo := NewLibraryRepository(GetDBXBuilder())
			_ = libRepo.(*libraryRepository).delete(ctx, 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(ctx, "non-existent-user")
				Expect(err).ToNot(HaveOccurred())
				Expect(libraries).To(HaveLen(0))
			})

			It("returns user's associated libraries", func() {
				err := repo.SetUserLibraries(ctx, userID, []int{library1.ID, library2.ID})
				Expect(err).ToNot(HaveOccurred())

				libraries, err := repo.GetUserLibraries(ctx, 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(ctx, userID, libraryIDs)
				Expect(err).ToNot(HaveOccurred())

				libraries, err := repo.GetUserLibraries(ctx, userID)
				Expect(err).ToNot(HaveOccurred())
				Expect(libraries).To(HaveLen(2))
			})

			It("replaces existing associations", func() {
				// Set initial associations
				err := repo.SetUserLibraries(ctx, userID, []int{library1.ID, library2.ID})
				Expect(err).ToNot(HaveOccurred())

				// Replace with just one library
				err = repo.SetUserLibraries(ctx, userID, []int{library1.ID})
				Expect(err).ToNot(HaveOccurred())

				libraries, err := repo.GetUserLibraries(ctx, 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(ctx, userID, []int{library1.ID, library2.ID})
				Expect(err).ToNot(HaveOccurred())

				// Remove all
				err = repo.SetUserLibraries(ctx, userID, []int{})
				Expect(err).ToNot(HaveOccurred())

				libraries, err := repo.GetUserLibraries(ctx, 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(GetDBXBuilder())

			// Count initial libraries
			existingLibs, err := libRepo.GetAll(ctx)
			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(ctx, &library1)).To(BeNil())
			Expect(libRepo.Put(ctx, &library2)).To(BeNil())
		})

		AfterEach(func() {
			// Clean up test libraries and their associations
			_ = libRepo.(*libraryRepository).delete(ctx, squirrel.Eq{"id": []int{library1.ID, library2.ID}})

			// Clean up user-library associations for these test libraries
			_, _ = repo.(*userRepository).executeSQL(ctx, 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(ctx, &adminUser)
			Expect(err).ToNot(HaveOccurred())

			// Admin should automatically have access to all libraries (including existing ones)
			libraries, err := repo.GetUserLibraries(ctx, 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(ctx, &regularUser)
			Expect(err).ToNot(HaveOccurred())

			// Give them access to just one library
			err = repo.SetUserLibraries(ctx, regularUser.ID, []int{library1.ID})
			Expect(err).ToNot(HaveOccurred())

			// Promote to admin
			regularUser.IsAdmin = true
			err = repo.Put(ctx, &regularUser)
			Expect(err).ToNot(HaveOccurred())

			// Should now have access to all libraries (including existing ones)
			libraries, err := repo.GetUserLibraries(ctx, 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(ctx, &regularUser)
			Expect(err).ToNot(HaveOccurred())

			// Regular user should be assigned to default libraries (library ID 1 from migration)
			libraries, err := repo.GetUserLibraries(ctx, 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(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(ctx, &library1)).To(BeNil())
			Expect(libRepo.Put(ctx, &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(ctx, &testUser)).To(BeNil())

			// Assign libraries to user
			Expect(repo.SetUserLibraries(ctx, testUser.ID, []int{library1.ID, library2.ID})).To(BeNil())
		})

		AfterEach(func() {
			// Clean up test libraries and their associations
			_ = libRepo.(*libraryRepository).delete(ctx, squirrel.Eq{"id": []int{library1.ID, library2.ID}})
			_ = repo.(*userRepository).delete(ctx, squirrel.Eq{"id": testUser.ID})

			// Clean up user-library associations for these test libraries
			_, _ = repo.(*userRepository).executeSQL(ctx, 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(ctx, 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(ctx)
			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(ctx, 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(ctx, &userWithoutLibs)).To(BeNil())
			defer func() { _ = repo.(*userRepository).delete(ctx, squirrel.Eq{"id": userWithoutLibs.ID}) }()

			user, err := repo.Get(ctx, 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(ctx, rest.QueryOptions{Filters: map[string]any{"id": "123"}})
			sel := r.selectUserWithLibraries(ctx, qo)
			query, _, err := r.toSQL(sel)
			Expect(err).NotTo(HaveOccurred())
			Expect(query).To(ContainSubstring("user.id = {:p0}"))
		})
	})

	Describe("token epoch", func() {
		var repo model.UserRepository
		var usr model.User

		newUser := func() model.User {
			uid := id.NewRandom()
			// user_name is unique; suffix it so each It gets its own row in the shared suite DB.
			return model.User{ID: uid, UserName: "epoch-user-" + uid, Name: "Epoch", NewPassword: "hunter2"}
		}

		BeforeEach(func() {
			ctx = request.WithUser(ctx, model.User{ID: "userid", IsAdmin: true})
			repo = NewUserRepository(GetDBXBuilder())
			usr = newUser()
			Expect(repo.Put(ctx, &usr)).To(Succeed())
		})

		It("starts at zero for a new user", func() {
			got, err := repo.Get(ctx, usr.ID)
			Expect(err).ToNot(HaveOccurred())
			Expect(got.TokenEpoch).To(Equal(0))
		})

		It("increments once per password change", func() {
			usr.NewPassword = "second"
			Expect(repo.Put(ctx, &usr)).To(Succeed())
			got, err := repo.Get(ctx, usr.ID)
			Expect(err).ToNot(HaveOccurred())
			Expect(got.TokenEpoch).To(Equal(1))

			usr.NewPassword = "third"
			Expect(repo.Put(ctx, &usr)).To(Succeed())
			got, err = repo.Get(ctx, usr.ID)
			Expect(err).ToNot(HaveOccurred())
			Expect(got.TokenEpoch).To(Equal(2))
		})

		It("leaves the epoch alone when the password is untouched", func() {
			usr.NewPassword = ""
			usr.Name = "Renamed"
			Expect(repo.Put(ctx, &usr)).To(Succeed())

			got, err := repo.Get(ctx, usr.ID)
			Expect(err).ToNot(HaveOccurred())
			Expect(got.TokenEpoch).To(Equal(0))
			Expect(got.Name).To(Equal("Renamed"))
		})

		It("never signals the same epoch to two concurrent password changes", func() {
			// Each writer's epoch must be the one its own UPDATE produced.
			const callers = 4
			var mu sync.Mutex
			var signalled []int
			var wg sync.WaitGroup
			for range callers {
				wg.Go(func() {
					ctx := log.NewContext(context.TODO())
					ctx = request.WithUser(ctx, model.User{ID: usr.ID})
					ctx = request.WithTokenEpochHolder(ctx)
					own := NewUserRepository(GetDBXBuilder())

					u := usr
					u.NewPassword = "concurrent"
					if err := own.Put(ctx, &u); err != nil {
						return // the shared in-memory test DB can raise SQLITE_LOCKED
					}
					epoch, ok := request.TokenEpochFrom(ctx)
					if !ok {
						return
					}
					mu.Lock()
					defer mu.Unlock()
					signalled = append(signalled, epoch)
				})
			}
			wg.Wait()

			Expect(signalled).To(HaveLen(len(slice.Unique(signalled))),
				"an epoch was signalled to more than one writer: %v", signalled)
		})
	})

	Describe("Put and the token epoch", func() {
		newRepo := func(actingUserID string) (context.Context, model.UserRepository) {
			ctx := log.NewContext(context.TODO())
			ctx = request.WithUser(ctx, model.User{ID: actingUserID, IsAdmin: true})
			ctx = request.WithTokenEpochHolder(ctx)
			return ctx, NewUserRepository(GetDBXBuilder())
		}

		It("does not bump when creating a user", func() {
			ctx, repo := newRepo("admin")
			usr := model.User{ID: id.NewRandom(), UserName: "fresh", NewPassword: "pw1"}
			Expect(repo.Put(ctx, &usr)).To(Succeed())

			got, err := repo.Get(ctx, usr.ID)
			Expect(err).ToNot(HaveOccurred())
			Expect(got.TokenEpoch).To(Equal(0))
		})

		It("bumps when the password changes", func() {
			ctx, repo := newRepo("admin")
			usr := model.User{ID: id.NewRandom(), UserName: "changer", NewPassword: "pw1"}
			Expect(repo.Put(ctx, &usr)).To(Succeed())

			usr.NewPassword = "pw2"
			Expect(repo.Put(ctx, &usr)).To(Succeed())

			got, err := repo.Get(ctx, usr.ID)
			Expect(err).ToNot(HaveOccurred())
			Expect(got.TokenEpoch).To(Equal(1))
		})

		It("does not bump on an edit that leaves the password alone", func() {
			ctx, repo := newRepo("admin")
			usr := model.User{ID: id.NewRandom(), UserName: "renamer", NewPassword: "pw1"}
			Expect(repo.Put(ctx, &usr)).To(Succeed())

			usr.NewPassword = ""
			usr.Name = "New Display Name"
			Expect(repo.Put(ctx, &usr)).To(Succeed())

			got, err := repo.Get(ctx, usr.ID)
			Expect(err).ToNot(HaveOccurred())
			Expect(got.TokenEpoch).To(Equal(0))
		})

		It("signals the new epoch when a user changes their own password", func() {
			userID := id.NewRandom()
			ctx, repo := newRepo(userID)
			usr := model.User{ID: userID, UserName: "self", NewPassword: "pw1"}
			Expect(repo.Put(ctx, &usr)).To(Succeed())

			usr.NewPassword = "pw2"
			Expect(repo.Put(ctx, &usr)).To(Succeed())

			epoch, ok := request.TokenEpochFrom(ctx)
			Expect(ok).To(BeTrue())
			Expect(epoch).To(Equal(1))
		})

		It("does not signal when an admin changes someone else's password", func() {
			ctx, repo := newRepo("some-admin")
			usr := model.User{ID: id.NewRandom(), UserName: "other", NewPassword: "pw1"}
			Expect(repo.Put(ctx, &usr)).To(Succeed())

			usr.NewPassword = "pw2"
			Expect(repo.Put(ctx, &usr)).To(Succeed())

			_, ok := request.TokenEpochFrom(ctx)
			Expect(ok).To(BeFalse())
		})
	})
})