🏡 index : quine-global/quintodrome.git

package persistence

import (
	"context"
	"encoding/json"
	"fmt"
	"os"
	"path/filepath"
	"sync"

	"github.com/Masterminds/squirrel"
	"github.com/deluan/rest"
	"github.com/navidrome/navidrome/conf"
	"github.com/navidrome/navidrome/conf/configtest"
	"github.com/navidrome/navidrome/consts"
	"github.com/navidrome/navidrome/model"
	"github.com/navidrome/navidrome/model/request"
	"github.com/navidrome/navidrome/utils"
	"github.com/navidrome/navidrome/utils/slice"
	. "github.com/onsi/ginkgo/v2"
	. "github.com/onsi/gomega"
)

// Test helper functions to reduce duplication
func createTestArtistWithMBID(id, name, mbid string) model.Artist {
	return model.Artist{
		ID:          id,
		Name:        name,
		MbzArtistID: mbid,
	}
}

func createUserWithLibraries(userID string, libraryIDs []int) model.User {
	user := model.User{
		ID:       userID,
		UserName: userID,
		Name:     userID,
		Email:    userID + "@test.com",
		IsAdmin:  false,
	}

	if len(libraryIDs) > 0 {
		user.Libraries = make(model.Libraries, len(libraryIDs))
		for i, libID := range libraryIDs {
			user.Libraries[i] = model.Library{ID: libID, Name: "Test Library", Path: "/test"}
		}
	}

	return user
}

var _ = Describe("ArtistRepository", func() {
	var ctx context.Context

	BeforeEach(func() {
		ctx = GinkgoT().Context()
	})

	Context("Core Functionality", func() {
		Describe("GetIndexKey", func() {
			// Note: OrderArtistName should never be empty, so we don't need to test for that
			r := artistRepository{indexGroups: utils.ParseIndexGroups(conf.Server.IndexGroups)}

			DescribeTable("returns correct index key based on PreferSortTags setting",
				func(preferSortTags bool, sortArtistName, orderArtistName, expectedKey string) {
					DeferCleanup(configtest.SetupConfig())
					conf.Server.PreferSortTags = preferSortTags
					a := model.Artist{SortArtistName: sortArtistName, OrderArtistName: orderArtistName, Name: "Test"}
					idx := GetIndexKey(&r, a)
					Expect(idx).To(Equal(expectedKey))
				},
				Entry("PreferSortTags=false, SortArtistName empty -> uses OrderArtistName", false, "", "Bar", "B"),
				Entry("PreferSortTags=false, SortArtistName not empty -> still uses OrderArtistName", false, "Foo", "Bar", "B"),
				Entry("PreferSortTags=true, SortArtistName not empty -> uses SortArtistName", true, "Foo", "Bar", "F"),
				Entry("PreferSortTags=true, SortArtistName empty -> falls back to OrderArtistName", true, "", "Bar", "B"),
			)
		})

		Describe("roleFilter", func() {
			DescribeTable("validates roles and returns appropriate SQL expressions",
				func(role string, shouldBeValid bool) {
					result := roleFilter("", role)
					if shouldBeValid {
						expectedExpr := squirrel.Expr("JSON_EXTRACT(library_artist.stats, '$." + role + ".m') IS NOT NULL")
						Expect(result).To(Equal(expectedExpr))
					} else {
						expectedInvalid := squirrel.Eq{"1": 2}
						Expect(result).To(Equal(expectedInvalid))
					}
				},
				// Valid roles from model.AllRoles
				Entry("artist role", "artist", true),
				Entry("albumartist role", "albumartist", true),
				Entry("composer role", "composer", true),
				Entry("conductor role", "conductor", true),
				Entry("lyricist role", "lyricist", true),
				Entry("arranger role", "arranger", true),
				Entry("producer role", "producer", true),
				Entry("director role", "director", true),
				Entry("engineer role", "engineer", true),
				Entry("mixer role", "mixer", true),
				Entry("remixer role", "remixer", true),
				Entry("djmixer role", "djmixer", true),
				Entry("performer role", "performer", true),
				Entry("maincredit role", "maincredit", true),
				// Invalid roles
				Entry("invalid role - wizard", "wizard", false),
				Entry("invalid role - songanddanceman", "songanddanceman", false),
				Entry("empty string", "", false),
				Entry("SQL injection attempt", "artist') SELECT LIKE(CHAR(65,66,67,68,69,70,71),UPPER(HEX(RANDOMBLOB(500000000/2))))--", false),
			)

			It("handles non-string input types", func() {
				expectedInvalid := squirrel.Eq{"1": 2}
				Expect(roleFilter("", 123)).To(Equal(expectedInvalid))
				Expect(roleFilter("", nil)).To(Equal(expectedInvalid))
				Expect(roleFilter("", []string{"artist"})).To(Equal(expectedInvalid))
			})
		})

		Describe("sanitizeArtistStatsRole", func() {
			It("allowlists total and registered roles", func() {
				role, ok := sanitizeArtistStatsRole("total")
				Expect(ok).To(BeTrue())
				Expect(role).To(Equal("total"))
				role, ok = sanitizeArtistStatsRole("albumartist")
				Expect(ok).To(BeTrue())
				Expect(role).To(Equal("albumartist"))
			})

			It("rejects SQL injection payloads used in sort mappings", func() {
				payload := "total'||(SELECT password FROM user LIMIT 1)||'"
				role, ok := sanitizeArtistStatsRole(payload)
				Expect(ok).To(BeFalse())
				Expect(role).To(BeEmpty())
			})
		})

		Describe("ReadAll role sort", func() {
			payload := "total') OR 1=1--"

			songCountSortFor := func(role any) string {
				repo := NewArtistRepository(GetDBXBuilder()).(*artistRepository)
				return repo.sortMappingsForRole(rest.QueryOptions{Filters: map[string]any{"role": role}})["song_count"]
			}

			It("falls back to the total stats role for an attacker-controlled role", func() {
				Expect(songCountSortFor(payload)).To(Equal("sum(stats->>'total'->>'m')"))
				Expect(songCountSortFor("bogus")).To(Equal("sum(stats->>'total'->>'m')"))
				Expect(songCountSortFor(42)).To(Equal("sum(stats->>'total'->>'m')"))
			})

			It("keeps valid role sort paths", func() {
				Expect(songCountSortFor("composer")).To(Equal("sum(stats->>'composer'->>'m')"))
				Expect(songCountSortFor("albumartist")).To(Equal("sum(stats->>'albumartist'->>'m')"))
			})

			It("leaves the shared mappings untouched", func() {
				repo := NewArtistRepository(GetDBXBuilder()).(*artistRepository)
				Expect(repo.sortMappingsForRole(rest.QueryOptions{Filters: map[string]any{"role": "composer"}})).
					ToNot(Equal(repo.sortMappings))
				Expect(repo.sortMappings["song_count"]).To(Equal("stats->>'total'->>'m'"))
			})

			It("orders by the requested role's stats, not the total", func() {
				ctx := request.WithUser(GinkgoT().Context(), adminUser)
				repo := NewArtistRepository(GetDBXBuilder()).(*artistRepository)
				// Composer and total counts rank the two artists in opposite orders, so the
				// resulting order alone proves which mapping the sort used.
				seed := func(artistID, stats string) {
					_, err := repo.executeSQL(ctx, squirrel.Insert("library_artist").
						Columns("library_id", "artist_id", "stats").
						Values(1, artistID, stats).
						Suffix("ON CONFLICT(library_id, artist_id) DO UPDATE SET stats = excluded.stats"))
					Expect(err).ToNot(HaveOccurred())
					DeferCleanup(func() {
						_, _ = repo.executeSQL(ctx, squirrel.Update("library_artist").Set("stats", "{}").
							Where(squirrel.Eq{"library_id": 1, "artist_id": artistID}))
					})
				}
				seed(artistBeatles.ID, `{"composer": {"s": 1, "m": 1, "a": 1}, "total": {"s": 1, "m": 100, "a": 1}}`)
				seed(artistKraftwerk.ID, `{"composer": {"s": 1, "m": 9, "a": 1}, "total": {"s": 1, "m": 2, "a": 1}}`)

				res, err := repo.ReadAll(ctx, rest.QueryOptions{
					Sort:    "songCount",
					Order:   "DESC",
					Filters: map[string]any{"role": "composer"},
				})
				Expect(err).ToNot(HaveOccurred())
				Expect(slice.Map(res, func(a model.Artist) string { return a.ID })).
					To(Equal([]string{artistKraftwerk.ID, artistBeatles.ID}))
			})

			It("still returns results when the role is an injection payload", func() {
				ctx := request.WithUser(GinkgoT().Context(), adminUser)
				repo := NewArtistRepository(GetDBXBuilder()).(*artistRepository)
				_, err := repo.ReadAll(ctx, rest.QueryOptions{
					Sort:    "songCount",
					Order:   "ASC",
					Filters: map[string]any{"role": payload},
				})
				Expect(err).ToNot(HaveOccurred())
			})
		})

		Describe("searchScope", func() {
			// Resolves the library IDs a search must be restricted to (nil = fast-path / no filter),
			// the way Search() does, for a repo whose context carries the given user.
			scope := func(user model.User, filter squirrel.Sqlizer) []int {
				ctx := request.WithUser(GinkgoT().Context(), user)
				r := NewArtistRepository(GetDBXBuilder()).(*artistRepository)
				return r.searchScope(ctx, filter)
			}
			subsetUser := model.User{ID: "u", Libraries: model.Libraries{{ID: 1}, {ID: 2}, {ID: 3}}}

			It("scopes to a strict subset of the user's libraries", func() {
				Expect(scope(subsetUser, squirrel.Eq{"library_id": []int{1, 2}})).To(Equal([]int{1, 2}))
			})

			It("treats duplicate IDs as a set so a real subset still narrows", func() {
				// {1,1,2} has 3 entries but is a strict subset of the user's 3 libraries.
				Expect(scope(subsetUser, squirrel.Eq{"library_id": []int{1, 1, 2}})).To(Equal([]int{1, 1, 2}))
			})

			It("returns nil (fast-path) when the request covers all the user's libraries", func() {
				Expect(scope(subsetUser, squirrel.Eq{"library_id": []int{1, 2, 3}})).To(BeNil())
			})

			It("scopes to the user's libraries when no library filter is given", func() {
				// A restricted user (strictly fewer libs than exist) with no musicFolderId is still
				// confined to their granted libs. Build the user with total-1 libraries derived from
				// the real DB total, so the "sees all" fast-path can't kick in regardless of count.
				total, err := NewLibraryRepository(GetDBXBuilder()).CountAll(ctx)
				Expect(err).ToNot(HaveOccurred())
				Expect(total).To(BeNumerically(">", 0))
				libs := make(model.Libraries, 0, total-1)
				for i := int64(1); i < total; i++ { // total-1 distinct libraries → a strict subset
					libs = append(libs, model.Library{ID: int(i)})
				}
				restricted := model.User{ID: "r", Libraries: libs}
				got := scope(restricted, nil)
				Expect(got).To(HaveLen(int(total) - 1))
			})

			It("returns nil (fast-path) for an admin requesting all existing libraries", func() {
				// Admins see every library, so the visible set is the whole library table — derive
				// it from the DB rather than assuming a count.
				var allLibs []int
				Expect(NewLibraryRepository(GetDBXBuilder()).(*libraryRepository).
					queryAllSlice(ctx, squirrel.Select("id").From("library"), &allLibs)).To(Succeed())
				admin := model.User{ID: "a", IsAdmin: true}
				Expect(scope(admin, squirrel.Eq{"library_id": allLibs})).To(BeNil())
				Expect(scope(admin, nil)).To(BeNil())
			})

			It("narrows for an admin explicitly requesting a subset via musicFolderId", func() {
				// An admin scoping to a single, non-existent-as-the-whole-set library must still be
				// narrowed (regression: search3?musicFolderId=lib2 was leaking lib1 content).
				admin := model.User{ID: "a", IsAdmin: true}
				Expect(scope(admin, squirrel.Eq{"library_id": []int{-1}})).To(Equal([]int{-1}))
			})

			It("returns nil for a non-library_id filter (no library scoping requested)", func() {
				// Such a filter carries no library intent; for this fully-granted-style user the
				// search needs no extra library restriction.
				allUser := model.User{ID: "u2", IsAdmin: true}
				Expect(scope(allUser, squirrel.Eq{"name": "x"})).To(BeNil())
			})

			It("falls back to the visible scope for a malformed library_id value (no crash)", func() {
				// A library_id filter whose value isn't []int is still recognized as a library
				// filter (so Search consumes it and it never reaches the bare artist table), and
				// searchScope falls back to exactly the no-filter behavior rather than crashing.
				malformed := squirrel.Eq{"library_id": "not-a-slice"}
				Expect(isLibraryIDFilter(malformed)).To(BeTrue())
				Expect(scope(subsetUser, malformed)).To(Equal(scope(subsetUser, nil)))
			})
		})

		Describe("dbArtist mapping", func() {
			var (
				artist *model.Artist
				dba    *dbArtist
			)

			BeforeEach(func() {
				artist = &model.Artist{ID: "1", Name: "Eddie Van Halen", SortArtistName: "Van Halen, Eddie"}
				dba = &dbArtist{Artist: artist}
			})

			Describe("PostScan", func() {
				It("parses stats and similar artists correctly", func() {
					stats := map[string]map[string]map[string]int64{
						"1": {
							"total":    {"s": 1000, "m": 10, "a": 2},
							"composer": {"s": 500, "m": 5, "a": 1},
						},
					}
					statsJSON, _ := json.Marshal(stats)
					dba.LibraryStatsJSON = string(statsJSON)
					dba.SimilarArtists = `[{"id":"2","Name":"AC/DC"},{"name":"Test;With:Sep,Chars"}]`

					err := dba.PostScan()
					Expect(err).ToNot(HaveOccurred())
					Expect(dba.Artist.Size).To(Equal(int64(1000)))
					Expect(dba.Artist.SongCount).To(Equal(10))
					Expect(dba.Artist.AlbumCount).To(Equal(2))
					Expect(dba.Artist.Stats).To(HaveLen(1))
					Expect(dba.Artist.Stats[model.RoleFromString("composer")].Size).To(Equal(int64(500)))
					Expect(dba.Artist.Stats[model.RoleFromString("composer")].SongCount).To(Equal(5))
					Expect(dba.Artist.Stats[model.RoleFromString("composer")].AlbumCount).To(Equal(1))
					Expect(dba.Artist.SimilarArtists).To(HaveLen(2))
					Expect(dba.Artist.SimilarArtists[0].ID).To(Equal("2"))
					Expect(dba.Artist.SimilarArtists[0].Name).To(Equal("AC/DC"))
					Expect(dba.Artist.SimilarArtists[1].ID).To(BeEmpty())
					Expect(dba.Artist.SimilarArtists[1].Name).To(Equal("Test;With:Sep,Chars"))
				})
			})

			Describe("PostMapArgs", func() {
				It("maps empty similar artists correctly", func() {
					m := make(map[string]any)
					err := dba.PostMapArgs(m)
					Expect(err).ToNot(HaveOccurred())
					Expect(m).To(HaveKeyWithValue("similar_artists", "[]"))
				})

				It("maps similar artists and full text correctly", func() {
					artist.SimilarArtists = []model.Artist{
						{ID: "2", Name: "AC/DC"},
						{Name: "Test;With:Sep,Chars"},
					}
					m := make(map[string]any)
					err := dba.PostMapArgs(m)
					Expect(err).ToNot(HaveOccurred())
					Expect(m).To(HaveKeyWithValue("similar_artists", `[{"id":"2","name":"AC/DC"},{"name":"Test;With:Sep,Chars"}]`))
					Expect(m).To(HaveKeyWithValue("full_text", " eddie halen van"))
				})

				It("does not override empty sort_artist_name and mbz_artist_id", func() {
					m := map[string]any{
						"sort_artist_name": "",
						"mbz_artist_id":    "",
					}
					err := dba.PostMapArgs(m)
					Expect(err).ToNot(HaveOccurred())
					Expect(m).ToNot(HaveKey("sort_artist_name"))
					Expect(m).ToNot(HaveKey("mbz_artist_id"))
				})
			})
		})
	})

	Context("Admin User Operations", func() {
		var repo model.ArtistRepository

		BeforeEach(func() {
			ctx = request.WithUser(ctx, adminUser)
			repo = NewArtistRepository(GetDBXBuilder())
		})

		Describe("ReadAll with role sort", func() {
			It("does not change the shared sort mappings", func() {
				original := repo.(*artistRepository).sortMappings["song_count"]
				var wg sync.WaitGroup
				for i := 0; i < 20; i++ {
					role := "artist"
					if i%2 == 1 {
						role = "composer"
					}
					wg.Add(1)
					go func() {
						defer GinkgoRecover()
						defer wg.Done()
						_, err := repo.ReadAll(ctx, rest.QueryOptions{Sort: "song_count", Filters: map[string]any{"role": role}})
						Expect(err).ToNot(HaveOccurred())
					}()
				}
				wg.Wait()
				Expect(repo.(*artistRepository).sortMappings["song_count"]).To(Equal(original))
			})
		})

		Describe("GetCursor", func() {
			It("yields the same artists as GetAll", func() {
				opts := model.QueryOptions{Sort: "name"}
				want, err := repo.GetAll(ctx, opts)
				Expect(err).ToNot(HaveOccurred())
				Expect(collectCursor(repo.GetCursor(ctx, opts))).To(Equal([]model.Artist(want)))
			})

			It("honors Max/Offset like GetAll", func() {
				opts := model.QueryOptions{Sort: "name", Max: 2, Offset: 1}
				want, err := repo.GetAll(ctx, opts)
				Expect(err).ToNot(HaveOccurred())
				Expect(collectCursor(repo.GetCursor(ctx, opts))).To(Equal([]model.Artist(want)))
			})
		})

		Describe("getAllIDs", func() {
			It("returns the same id set as GetAll", func() {
				want, err := repo.GetAll(ctx)
				Expect(err).ToNot(HaveOccurred())
				Expect(want).ToNot(BeEmpty())
				ids, err := repo.(*artistRepository).getAllIDs(ctx)
				Expect(err).ToNot(HaveOccurred())
				Expect(ids).To(ConsistOf(slice.Map(want, func(a model.Artist) string { return a.ID })))
			})
		})

		Describe("Basic Operations", func() {
			Describe("Count", func() {
				It("returns the number of artists in the DB", func() {
					Expect(repo.CountAll(ctx)).To(Equal(int64(4)))
				})

				It("counts starred artists when an annotation filter is present", func() {
					// The Beatles (id 3) is starred for the admin user in the seed data
					count, err := repo.CountAll(ctx, model.QueryOptions{
						Filters: annotationBoolFilter("starred")("starred", "true"),
					})
					Expect(err).ToNot(HaveOccurred())
					Expect(count).To(Equal(int64(1)))
				})

				It("counts with has_rating=false without a 'no such column' error (join kept)", func() {
					count, err := repo.CountAll(ctx, model.QueryOptions{
						Filters: annotationBoolFilter("rating")("rating", "false"),
					})
					Expect(err).ToNot(HaveOccurred())
					Expect(count).To(Equal(int64(4)))
				})
			})

			Describe("Exists", func() {
				It("returns true for an artist that is in the DB", func() {
					Expect(repo.Exists(ctx, "3")).To(BeTrue())
				})
				It("returns false for an artist that is NOT in the DB", func() {
					Expect(repo.Exists(ctx, "666")).To(BeFalse())
				})
			})

			Describe("Get", func() {
				It("retrieves existing artist data", func() {
					artist, err := repo.Get(ctx, "2")
					Expect(err).ToNot(HaveOccurred())
					Expect(artist.Name).To(Equal(artistKraftwerk.Name))
				})
			})
		})

		Describe("GetIndex", func() {
			When("PreferSortTags is true", func() {
				BeforeEach(func() {
					conf.Server.PreferSortTags = true
				})
				It("returns the index when PreferSortTags is true and SortArtistName is not empty", func() {
					// Set SortArtistName to "Foo" for Beatles
					artistBeatles.SortArtistName = "Foo"
					er := repo.Put(ctx, &artistBeatles)
					Expect(er).To(BeNil())

					idx, err := repo.GetIndex(ctx, false, []int{1})
					Expect(err).ToNot(HaveOccurred())
					Expect(idx).To(HaveLen(4))
					Expect(idx[0].ID).To(Equal("F"))
					Expect(idx[0].Artists).To(HaveLen(1))
					Expect(idx[0].Artists[0].Name).To(Equal(artistBeatles.Name))
					Expect(idx[1].ID).To(Equal("K"))
					Expect(idx[1].Artists).To(HaveLen(1))
					Expect(idx[1].Artists[0].Name).To(Equal(artistKraftwerk.Name))
					Expect(idx[2].ID).To(Equal("R"))
					Expect(idx[2].Artists).To(HaveLen(1))
					Expect(idx[2].Artists[0].Name).To(Equal(artistPunctuation.Name))
					Expect(idx[3].ID).To(Equal("S"))
					Expect(idx[3].Artists).To(HaveLen(1))
					Expect(idx[3].Artists[0].Name).To(Equal(artistCJK.Name))

					// Restore the original value
					artistBeatles.SortArtistName = ""
					er = repo.Put(ctx, &artistBeatles)
					Expect(er).To(BeNil())
				})

				// BFR Empty SortArtistName is not saved in the DB anymore
				XIt("returns the index when PreferSortTags is true and SortArtistName is empty", func() {
					idx, err := repo.GetIndex(ctx, false, []int{1})
					Expect(err).ToNot(HaveOccurred())
					Expect(idx).To(HaveLen(4))
					Expect(idx[0].ID).To(Equal("B"))
					Expect(idx[0].Artists).To(HaveLen(1))
					Expect(idx[0].Artists[0].Name).To(Equal(artistBeatles.Name))
					Expect(idx[1].ID).To(Equal("K"))
					Expect(idx[1].Artists).To(HaveLen(1))
					Expect(idx[1].Artists[0].Name).To(Equal(artistKraftwerk.Name))
					Expect(idx[2].ID).To(Equal("R"))
					Expect(idx[2].Artists).To(HaveLen(1))
					Expect(idx[2].Artists[0].Name).To(Equal(artistPunctuation.Name))
					Expect(idx[3].ID).To(Equal("S"))
					Expect(idx[3].Artists).To(HaveLen(1))
					Expect(idx[3].Artists[0].Name).To(Equal(artistCJK.Name))
				})
			})

			When("PreferSortTags is false", func() {
				BeforeEach(func() {
					conf.Server.PreferSortTags = false
				})
				It("returns the index when SortArtistName is NOT empty", func() {
					// Set SortArtistName to "Foo" for Beatles
					artistBeatles.SortArtistName = "Foo"
					er := repo.Put(ctx, &artistBeatles)
					Expect(er).To(BeNil())

					idx, err := repo.GetIndex(ctx, false, []int{1})
					Expect(err).ToNot(HaveOccurred())
					Expect(idx).To(HaveLen(4))
					Expect(idx[0].ID).To(Equal("B"))
					Expect(idx[0].Artists).To(HaveLen(1))
					Expect(idx[0].Artists[0].Name).To(Equal(artistBeatles.Name))
					Expect(idx[1].ID).To(Equal("K"))
					Expect(idx[1].Artists).To(HaveLen(1))
					Expect(idx[1].Artists[0].Name).To(Equal(artistKraftwerk.Name))
					Expect(idx[2].ID).To(Equal("R"))
					Expect(idx[2].Artists).To(HaveLen(1))
					Expect(idx[2].Artists[0].Name).To(Equal(artistPunctuation.Name))
					Expect(idx[3].ID).To(Equal("S"))
					Expect(idx[3].Artists).To(HaveLen(1))
					Expect(idx[3].Artists[0].Name).To(Equal(artistCJK.Name))

					// Restore the original value
					artistBeatles.SortArtistName = ""
					er = repo.Put(ctx, &artistBeatles)
					Expect(er).To(BeNil())
				})

				It("returns the index when SortArtistName is empty", func() {
					idx, err := repo.GetIndex(ctx, false, []int{1})
					Expect(err).ToNot(HaveOccurred())
					Expect(idx).To(HaveLen(4))
					Expect(idx[0].ID).To(Equal("B"))
					Expect(idx[0].Artists).To(HaveLen(1))
					Expect(idx[0].Artists[0].Name).To(Equal(artistBeatles.Name))
					Expect(idx[1].ID).To(Equal("K"))
					Expect(idx[1].Artists).To(HaveLen(1))
					Expect(idx[1].Artists[0].Name).To(Equal(artistKraftwerk.Name))
					Expect(idx[2].ID).To(Equal("R"))
					Expect(idx[2].Artists).To(HaveLen(1))
					Expect(idx[2].Artists[0].Name).To(Equal(artistPunctuation.Name))
					Expect(idx[3].ID).To(Equal("S"))
					Expect(idx[3].Artists).To(HaveLen(1))
					Expect(idx[3].Artists[0].Name).To(Equal(artistCJK.Name))
				})
			})

			When("filtering by role", func() {
				var raw *artistRepository

				BeforeEach(func() {
					raw = repo.(*artistRepository)
					// Add stats to library_artist table since stats are now stored per-library
					composerStats := `{"composer": {"s": 1000, "m": 5, "a": 2}}`
					producerStats := `{"producer": {"s": 500, "m": 3, "a": 1}}`

					// Set Beatles as composer in library 1
					_, err := raw.executeSQL(ctx, squirrel.Insert("library_artist").
						Columns("library_id", "artist_id", "stats").
						Values(1, artistBeatles.ID, composerStats).
						Suffix("ON CONFLICT(library_id, artist_id) DO UPDATE SET stats = excluded.stats"))
					Expect(err).ToNot(HaveOccurred())

					// Set Kraftwerk as producer in library 1
					_, err = raw.executeSQL(ctx, squirrel.Insert("library_artist").
						Columns("library_id", "artist_id", "stats").
						Values(1, artistKraftwerk.ID, producerStats).
						Suffix("ON CONFLICT(library_id, artist_id) DO UPDATE SET stats = excluded.stats"))
					Expect(err).ToNot(HaveOccurred())
				})

				AfterEach(func() {
					// Clean up stats from library_artist table
					_, _ = raw.executeSQL(ctx, squirrel.Update("library_artist").
						Set("stats", "{}").
						Where(squirrel.Eq{"artist_id": artistBeatles.ID, "library_id": 1}))
					_, _ = raw.executeSQL(ctx, squirrel.Update("library_artist").
						Set("stats", "{}").
						Where(squirrel.Eq{"artist_id": artistKraftwerk.ID, "library_id": 1}))
				})

				It("returns only artists with the specified role", func() {
					idx, err := repo.GetIndex(ctx, false, []int{1}, model.RoleComposer)
					Expect(err).ToNot(HaveOccurred())
					Expect(idx).To(HaveLen(1))
					Expect(idx[0].ID).To(Equal("B"))
					Expect(idx[0].Artists).To(HaveLen(1))
					Expect(idx[0].Artists[0].Name).To(Equal(artistBeatles.Name))
				})

				It("returns artists with any of the specified roles", func() {
					idx, err := repo.GetIndex(ctx, false, []int{1}, model.RoleComposer, model.RoleProducer)
					Expect(err).ToNot(HaveOccurred())
					Expect(idx).To(HaveLen(2))

					// Find Beatles and Kraftwerk in the results
					var beatlesFound, kraftwerkFound bool
					for _, index := range idx {
						for _, artist := range index.Artists {
							if artist.Name == artistBeatles.Name {
								beatlesFound = true
							}
							if artist.Name == artistKraftwerk.Name {
								kraftwerkFound = true
							}
						}
					}
					Expect(beatlesFound).To(BeTrue())
					Expect(kraftwerkFound).To(BeTrue())
				})

				It("returns empty index when no artists have the specified role", func() {
					idx, err := repo.GetIndex(ctx, false, []int{1}, model.RoleDirector)
					Expect(err).ToNot(HaveOccurred())
					Expect(idx).To(HaveLen(0))
				})
			})

			When("validating library IDs", func() {
				It("returns nil when no library IDs are provided", func() {
					idx, err := repo.GetIndex(ctx, false, []int{})
					Expect(err).ToNot(HaveOccurred())
					Expect(idx).To(BeNil())
				})

				It("returns artists when library IDs are provided (admin user sees all content)", func() {
					// Admin users can see all content when valid library IDs are provided
					idx, err := repo.GetIndex(ctx, false, []int{1})
					Expect(err).ToNot(HaveOccurred())
					Expect(idx).To(HaveLen(4))

					// With non-existent library ID, admin users see no content because no artists are associated with that library
					idx, err = repo.GetIndex(ctx, false, []int{999})
					Expect(err).ToNot(HaveOccurred())
					Expect(idx).To(HaveLen(0)) // Even admin users need valid library associations
				})
			})
		})

		Describe("Filters", func() {
			var artistWithoutAnnotation model.Artist

			BeforeEach(func() {
				// Create artist without any annotation
				artistWithoutAnnotation = model.Artist{ID: "no-annotation-artist", Name: "No Annotation Artist"}
				err := createArtistWithLibrary(ctx, repo, &artistWithoutAnnotation, 1)
				Expect(err).ToNot(HaveOccurred())
			})

			AfterEach(func() {
				if raw, ok := repo.(*artistRepository); ok {
					_, _ = raw.executeSQL(ctx, squirrel.Delete(raw.tableName).Where(squirrel.Eq{"id": artistWithoutAnnotation.ID}))
				}
			})

			Describe("starred", func() {
				It("false includes items without annotations", func() {
					res, err := repo.ReadAll(ctx, rest.QueryOptions{
						Filters: map[string]any{"starred": "false"},
					})
					Expect(err).ToNot(HaveOccurred())
					artists := res

					var found bool
					for _, a := range artists {
						if a.ID == artistWithoutAnnotation.ID {
							found = true
							break
						}
					}
					Expect(found).To(BeTrue(), "Artist without annotation should be included in starred=false filter")
				})

				It("true excludes items without annotations", func() {
					res, err := repo.ReadAll(ctx, rest.QueryOptions{
						Filters: map[string]any{"starred": "true"},
					})
					Expect(err).ToNot(HaveOccurred())
					artists := res

					for _, a := range artists {
						Expect(a.ID).ToNot(Equal(artistWithoutAnnotation.ID))
					}
				})
			})
		})

		Describe("MBID and Text Search", func() {
			var lib2 model.Library
			var lr model.LibraryRepository
			var lrCtx context.Context
			var restrictedUser model.User
			var restrictedRepo model.ArtistRepository
			var restrictedCtx context.Context
			var headlessRepo model.ArtistRepository
			var headlessCtx context.Context

			BeforeEach(func() {
				// Set up headless repo (no user context)
				headlessCtx = GinkgoT().Context()
				headlessRepo = NewArtistRepository(GetDBXBuilder())

				// Create library for testing access restrictions
				lib2 = model.Library{ID: 0, Name: "Artist Test Library", Path: "/artist/test/lib"}
				lrCtx = request.WithUser(ctx, adminUser)
				lr = NewLibraryRepository(GetDBXBuilder())
				err := lr.Put(lrCtx, &lib2)
				Expect(err).ToNot(HaveOccurred())

				// Create a user with access to only library 1
				restrictedUser = createUserWithLibraries("search_user", []int{1})

				// Create repository context for the restricted user
				restrictedCtx = request.WithUser(ctx, restrictedUser)
				restrictedRepo = NewArtistRepository(GetDBXBuilder())

				// Ensure both test artists are associated with library 1
				err = lr.AddArtist(lrCtx, 1, artistBeatles.ID)
				Expect(err).ToNot(HaveOccurred())
				err = lr.AddArtist(lrCtx, 1, artistKraftwerk.ID)
				Expect(err).ToNot(HaveOccurred())

				// Create the restricted user in the database
				urCtx := request.WithUser(ctx, adminUser)
				ur := NewUserRepository(GetDBXBuilder())
				err = ur.Put(urCtx, &restrictedUser)
				Expect(err).ToNot(HaveOccurred())
				err = ur.SetUserLibraries(urCtx, restrictedUser.ID, []int{1})
				Expect(err).ToNot(HaveOccurred())
			})

			AfterEach(func() {
				// Clean up library 2
				lr := NewLibraryRepository(GetDBXBuilder())
				_ = lr.(*libraryRepository).delete(ctx, squirrel.Eq{"id": lib2.ID})
			})

			DescribeTable("MBID search behavior across different user types",
				func(testRepo *model.ArtistRepository, testCtx *context.Context, shouldFind bool, testDesc string) {
					// Create test artist with MBID
					artistWithMBID := createTestArtistWithMBID("test-mbid-artist", "Test MBID Artist", "550e8400-e29b-41d4-a716-446655440010")

					err := createArtistWithLibrary(*testCtx, *testRepo, &artistWithMBID, 1)
					Expect(err).ToNot(HaveOccurred())

					// Test the search
					results, err := (*testRepo).Search(*testCtx, "550e8400-e29b-41d4-a716-446655440010", model.QueryOptions{Max: 10})
					Expect(err).ToNot(HaveOccurred())

					if shouldFind {
						Expect(results).To(HaveLen(1), testDesc)
						Expect(results[0].ID).To(Equal("test-mbid-artist"))
					} else {
						Expect(results).To(BeEmpty(), testDesc)
					}

					// Clean up
					if raw, ok := (*testRepo).(*artistRepository); ok {
						_, _ = raw.executeSQL(ctx, squirrel.Delete(raw.tableName).Where(squirrel.Eq{"id": artistWithMBID.ID}))
					}
				},
				Entry("Admin user can find artist by MBID", &repo, &ctx, true, "Admin should find MBID artist"),
				Entry("Restricted user can find artist by MBID in accessible library", &restrictedRepo, &restrictedCtx, true, "Restricted user should find MBID artist in accessible library"),
				Entry("Headless process can find artist by MBID", &headlessRepo, &headlessCtx, true, "Headless process should find MBID artist"),
			)

			It("prevents restricted user from finding artist by MBID when not in accessible library", func() {
				// Create an artist in library 2 (not accessible to restricted user)
				inaccessibleArtist := createTestArtistWithMBID("inaccessible-mbid-artist", "Inaccessible MBID Artist", "a74b1b7f-71a5-4011-9441-d0b5e4122711")
				err := repo.Put(ctx, &inaccessibleArtist)
				Expect(err).ToNot(HaveOccurred())

				// Add to library 2 (not accessible to restricted user)
				err = lr.AddArtist(lrCtx, lib2.ID, inaccessibleArtist.ID)
				Expect(err).ToNot(HaveOccurred())

				// Restricted user should not find this artist
				results, err := restrictedRepo.Search(restrictedCtx, "a74b1b7f-71a5-4011-9441-d0b5e4122711", model.QueryOptions{Max: 10})
				Expect(err).ToNot(HaveOccurred())
				Expect(results).To(BeEmpty())

				// But admin should find it
				results, err = repo.Search(ctx, "a74b1b7f-71a5-4011-9441-d0b5e4122711", model.QueryOptions{Max: 10})
				Expect(err).ToNot(HaveOccurred())
				Expect(results).To(HaveLen(1))

				// Clean up
				if raw, ok := repo.(*artistRepository); ok {
					_, _ = raw.executeSQL(ctx, squirrel.Delete(raw.tableName).Where(squirrel.Eq{"id": inaccessibleArtist.ID}))
				}
			})

			Context("Text Search", func() {
				It("allows admin to find artists by name regardless of library", func() {
					results, err := repo.Search(ctx, "Beatles", model.QueryOptions{Max: 10})
					Expect(err).ToNot(HaveOccurred())
					Expect(results).To(HaveLen(1))
					Expect(results[0].Name).To(Equal("The Beatles"))
				})

				It("correctly prevents restricted user from finding artists by name when not in accessible library", func() {
					// Create an artist in library 2 (not accessible to restricted user)
					inaccessibleArtist := model.Artist{
						ID:   "inaccessible-text-artist",
						Name: "Unique Search Name Artist",
					}
					err := repo.Put(ctx, &inaccessibleArtist)
					Expect(err).ToNot(HaveOccurred())

					// Add to library 2 (not accessible to restricted user)
					err = lr.AddArtist(lrCtx, lib2.ID, inaccessibleArtist.ID)
					Expect(err).ToNot(HaveOccurred())

					// Restricted user should not find this artist
					results, err := restrictedRepo.Search(restrictedCtx, "Unique Search Name", model.QueryOptions{Max: 10})
					Expect(err).ToNot(HaveOccurred())
					Expect(results).To(BeEmpty(), "Text search should respect library filtering")

					// Clean up
					if raw, ok := repo.(*artistRepository); ok {
						_, _ = raw.executeSQL(ctx, squirrel.Delete(raw.tableName).Where(squirrel.Eq{"id": inaccessibleArtist.ID}))
					}
				})
			})

			Context("Empty Query (sync pagination)", func() {
				It("does not duplicate artists that belong to multiple libraries", func() {
					// An artist in two libraries has two library_artist rows; pagination
					// must still enumerate it exactly once, at a stable offset.
					Expect(lr.AddArtist(lrCtx, lib2.ID, artistBeatles.ID)).To(Succeed())

					all, err := repo.Search(ctx, "", model.QueryOptions{Max: 1000})
					Expect(err).ToNot(HaveOccurred())

					seen := map[string]bool{}
					var paged model.Artists
					for offset := range len(all) {
						page, err := repo.Search(ctx, "", model.QueryOptions{Max: 1, Offset: offset})
						Expect(err).ToNot(HaveOccurred())
						for _, a := range page {
							Expect(seen[a.ID]).To(BeFalse(), fmt.Sprintf("artist %s returned twice", a.ID))
							seen[a.ID] = true
						}
						paged = append(paged, page...)
					}
					Expect(paged).To(HaveLen(len(all)))
				})

				It("paginates all artists in natural order without overlaps or gaps", func() {
					all, err := repo.Search(ctx, "", model.QueryOptions{Max: 1000})
					Expect(err).ToNot(HaveOccurred())
					Expect(len(all)).To(BeNumerically(">", 1))

					var paged model.Artists
					pageSize := 2
					for offset := 0; offset < len(all); offset += pageSize {
						page, err := repo.Search(ctx, "", model.QueryOptions{Max: pageSize, Offset: offset})
						Expect(err).ToNot(HaveOccurred())
						paged = append(paged, page...)
					}
					Expect(paged).To(HaveLen(len(all)))
					for i := range all {
						Expect(paged[i].ID).To(Equal(all[i].ID))
					}
				})

				It("respects library filtering for restricted users", func() {
					// Create an artist only in library 2 (not accessible to restricted user)
					lib2Artist := model.Artist{ID: "empty-query-lib2-artist", Name: "Empty Query Lib2 Artist"}
					Expect(repo.Put(ctx, &lib2Artist)).To(Succeed())
					Expect(lr.AddArtist(lrCtx, lib2.ID, lib2Artist.ID)).To(Succeed())

					results, err := restrictedRepo.Search(restrictedCtx, "", model.QueryOptions{Max: 1000})
					Expect(err).ToNot(HaveOccurred())
					for _, a := range results {
						Expect(a.ID).ToNot(Equal(lib2Artist.ID), "Empty query search should respect library filtering")
					}

					// Clean up
					if raw, ok := repo.(*artistRepository); ok {
						_, _ = raw.executeSQL(ctx, squirrel.Delete(raw.tableName).Where(squirrel.Eq{"id": lib2Artist.ID}))
					}
				})

				It("paginates a restricted user's visible artists without gaps", func() {
					// ID "25" sorts between base fixtures "2" and "3", so this lib2-only artist lands
					// inside the restricted user's visible range — exercising the no-gap guarantee.
					lib2Artist := model.Artist{ID: "25", Name: "Restricted Lib2 Artist"}
					Expect(repo.Put(ctx, &lib2Artist)).To(Succeed())
					Expect(lr.AddArtist(lrCtx, lib2.ID, lib2Artist.ID)).To(Succeed())
					DeferCleanup(func() {
						if raw, ok := repo.(*artistRepository); ok {
							_, _ = raw.executeSQL(ctx, squirrel.Delete(raw.tableName).Where(squirrel.Eq{"id": lib2Artist.ID}))
						}
					})

					all, err := restrictedRepo.Search(restrictedCtx, "", model.QueryOptions{Max: 1000})
					Expect(err).ToNot(HaveOccurred())
					Expect(len(all)).To(BeNumerically(">", 1))
					for _, a := range all {
						Expect(a.ID).ToNot(Equal(lib2Artist.ID))
					}

					var paged model.Artists
					for offset := range len(all) {
						page, err := restrictedRepo.Search(restrictedCtx, "", model.QueryOptions{Max: 1, Offset: offset})
						Expect(err).ToNot(HaveOccurred())
						Expect(page).To(HaveLen(1), fmt.Sprintf("page at offset %d should be full", offset))
						paged = append(paged, page...)
					}
					Expect(paged).To(HaveLen(len(all)))
					for i := range all {
						Expect(paged[i].ID).To(Equal(all[i].ID))
					}
				})
			})

			Context("Headless Processes (No User Context)", func() {
				It("should see all artists from all libraries when no user is in context", func() {
					// Add artists to different libraries
					err := lr.AddArtist(lrCtx, lib2.ID, artistBeatles.ID)
					Expect(err).ToNot(HaveOccurred())

					// Headless processes should see all artists regardless of library
					artists, err := headlessRepo.GetAll(headlessCtx)
					Expect(err).ToNot(HaveOccurred())

					// Should see all artists from all libraries
					found := false
					for _, artist := range artists {
						if artist.ID == artistBeatles.ID {
							found = true
							break
						}
					}
					Expect(found).To(BeTrue(), "Headless process should see artists from all libraries")
				})

				It("should allow headless processes to apply explicit library_id filters", func() {
					// Add artists to different libraries
					err := lr.AddArtist(lrCtx, lib2.ID, artistBeatles.ID)
					Expect(err).ToNot(HaveOccurred())

					// Filter by specific library
					artists, err := headlessRepo.GetAll(headlessCtx, model.QueryOptions{
						Filters: squirrel.Eq{"library_id": lib2.ID},
					})
					Expect(err).ToNot(HaveOccurred())

					// Should see only artists from the specified library
					for _, artist := range artists {
						if artist.ID == artistBeatles.ID {
							return // Found the expected artist
						}
					}
					Expect(false).To(BeTrue(), "Should find artist from specified library")
				})

				It("should get individual artists when no user is in context", func() {
					// Add artist to a library
					err := lr.AddArtist(lrCtx, lib2.ID, artistBeatles.ID)
					Expect(err).ToNot(HaveOccurred())

					// Headless process should be able to get the artist
					artist, err := headlessRepo.Get(headlessCtx, artistBeatles.ID)
					Expect(err).ToNot(HaveOccurred())
					Expect(artist.ID).To(Equal(artistBeatles.ID))
				})
			})
		})

		Describe("Admin User Library Access", func() {
			It("sees all artists regardless of library permissions", func() {
				count, err := repo.CountAll(ctx)
				Expect(err).ToNot(HaveOccurred())
				Expect(count).To(Equal(int64(4)))

				artists, err := repo.GetAll(ctx)
				Expect(err).ToNot(HaveOccurred())
				Expect(artists).To(HaveLen(4))

				exists, err := repo.Exists(ctx, artistBeatles.ID)
				Expect(err).ToNot(HaveOccurred())
				Expect(exists).To(BeTrue())
			})
		})

		Describe("Missing Artist Handling", func() {
			var missingArtist model.Artist
			var raw *artistRepository

			BeforeEach(func() {
				raw = repo.(*artistRepository)
				missingArtist = model.Artist{ID: "missing_test", Name: "Missing Artist", OrderArtistName: "missing artist"}

				// Create and mark as missing
				err := createArtistWithLibrary(ctx, repo, &missingArtist, 1)
				Expect(err).ToNot(HaveOccurred())

				_, err = raw.executeSQL(ctx, squirrel.Update(raw.tableName).Set("missing", true).Where(squirrel.Eq{"id": missingArtist.ID}))
				Expect(err).ToNot(HaveOccurred())
			})

			AfterEach(func() {
				_, _ = raw.executeSQL(ctx, squirrel.Delete(raw.tableName).Where(squirrel.Eq{"id": missingArtist.ID}))
			})

			It("missing artists are never returned by search", func() {
				// Should see missing artist in GetAll by default for admin users
				artists, err := repo.GetAll(ctx)
				Expect(err).ToNot(HaveOccurred())
				Expect(artists).To(HaveLen(5)) // Including the missing artist

				// Search never returns missing artists (hardcoded behavior)
				results, err := repo.Search(ctx, "Missing Artist", model.QueryOptions{Max: 10})
				Expect(err).ToNot(HaveOccurred())
				Expect(results).To(BeEmpty())
			})
		})
	})

	Context("Regular User Operations", func() {
		var restrictedRepo model.ArtistRepository
		var restrictedCtx context.Context
		var unauthorizedUser model.User

		BeforeEach(func() {
			// Create a user without access to any libraries
			unauthorizedUser = model.User{ID: "restricted_user", UserName: "restricted", Name: "Restricted User", Email: "restricted@test.com", IsAdmin: false}

			// Create repository context for the unauthorized user
			restrictedCtx = request.WithUser(ctx, unauthorizedUser)
			restrictedRepo = NewArtistRepository(GetDBXBuilder())
		})

		Describe("Library Access Restrictions", func() {
			It("CountAll returns 0 for users without library access", func() {
				count, err := restrictedRepo.CountAll(restrictedCtx)
				Expect(err).ToNot(HaveOccurred())
				Expect(count).To(Equal(int64(0)))
			})

			It("GetAll returns empty list for users without library access", func() {
				artists, err := restrictedRepo.GetAll(restrictedCtx)
				Expect(err).ToNot(HaveOccurred())
				Expect(artists).To(BeEmpty())
			})

			It("Exists returns false for existing artists when user has no library access", func() {
				// These artists exist in the DB but the user has no access to them
				exists, err := restrictedRepo.Exists(restrictedCtx, artistBeatles.ID)
				Expect(err).ToNot(HaveOccurred())
				Expect(exists).To(BeFalse())

				exists, err = restrictedRepo.Exists(restrictedCtx, artistKraftwerk.ID)
				Expect(err).ToNot(HaveOccurred())
				Expect(exists).To(BeFalse())
			})

			It("Get returns ErrNotFound for existing artists when user has no library access", func() {
				_, err := restrictedRepo.Get(restrictedCtx, artistBeatles.ID)
				Expect(err).To(Equal(model.ErrNotFound))

				_, err = restrictedRepo.Get(restrictedCtx, artistKraftwerk.ID)
				Expect(err).To(Equal(model.ErrNotFound))
			})

			It("Search returns empty results for users without library access", func() {
				results, err := restrictedRepo.Search(restrictedCtx, "Beatles", model.QueryOptions{Max: 10})
				Expect(err).ToNot(HaveOccurred())
				Expect(results).To(BeEmpty())

				results, err = restrictedRepo.Search(restrictedCtx, "Kraftwerk", model.QueryOptions{Max: 10})
				Expect(err).ToNot(HaveOccurred())
				Expect(results).To(BeEmpty())
			})

			It("GetIndex returns empty index for users without library access", func() {
				idx, err := restrictedRepo.GetIndex(restrictedCtx, false, []int{1})
				Expect(err).ToNot(HaveOccurred())
				Expect(idx).To(HaveLen(0))
			})
		})

		Context("when user gains library access", func() {
			BeforeEach(func() {
				// Give the user access to library 1
				urCtx := request.WithUser(ctx, adminUser)
				ur := NewUserRepository(GetDBXBuilder())

				// First create the user if not exists
				err := ur.Put(urCtx, &unauthorizedUser)
				Expect(err).ToNot(HaveOccurred())

				// Then add library access
				err = ur.SetUserLibraries(urCtx, unauthorizedUser.ID, []int{1})
				Expect(err).ToNot(HaveOccurred())

				// Update the user object with the libraries to simulate middleware behavior
				libraries, err := ur.GetUserLibraries(urCtx, unauthorizedUser.ID)
				Expect(err).ToNot(HaveOccurred())
				unauthorizedUser.Libraries = libraries

				// Recreate repository context with updated user
				restrictedCtx = request.WithUser(ctx, unauthorizedUser)
				restrictedRepo = NewArtistRepository(GetDBXBuilder())
			})

			AfterEach(func() {
				// Clean up: remove the user's library access
				urCtx := request.WithUser(ctx, adminUser)
				ur := NewUserRepository(GetDBXBuilder())
				_ = ur.SetUserLibraries(urCtx, unauthorizedUser.ID, []int{})
			})

			It("CountAll returns correct count after gaining access", func() {
				count, err := restrictedRepo.CountAll(restrictedCtx)
				Expect(err).ToNot(HaveOccurred())
				Expect(count).To(Equal(int64(4))) // Beatles, Kraftwerk, Seatbelts, and The Roots
			})

			It("GetAll returns artists after gaining access", func() {
				artists, err := restrictedRepo.GetAll(restrictedCtx)
				Expect(err).ToNot(HaveOccurred())
				Expect(artists).To(HaveLen(4))

				var names []string
				for _, artist := range artists {
					names = append(names, artist.Name)
				}
				Expect(names).To(ContainElements("The Beatles", "Kraftwerk", "シートベルツ", "The Roots"))
			})

			It("Exists returns true for accessible artists", func() {
				exists, err := restrictedRepo.Exists(restrictedCtx, artistBeatles.ID)
				Expect(err).ToNot(HaveOccurred())
				Expect(exists).To(BeTrue())

				exists, err = restrictedRepo.Exists(restrictedCtx, artistKraftwerk.ID)
				Expect(err).ToNot(HaveOccurred())
				Expect(exists).To(BeTrue())
			})

			It("GetIndex returns artists with proper library filtering", func() {
				// With valid library access, should see artists
				idx, err := restrictedRepo.GetIndex(restrictedCtx, false, []int{1})
				Expect(err).ToNot(HaveOccurred())
				Expect(idx).To(HaveLen(4))

				// With non-existent library ID, should see nothing (non-admin user)
				idx, err = restrictedRepo.GetIndex(restrictedCtx, false, []int{999})
				Expect(err).ToNot(HaveOccurred())
				Expect(idx).To(HaveLen(0))
			})

			It("takes the unfiltered fast-path when the user can access every library", func() {
				// The fixture DB has a single library and the user was granted it, so it has access
				// to all libraries: search results must match what an admin sees.
				adminCtx := request.WithUser(ctx, adminUser)
				adminRepo := NewArtistRepository(GetDBXBuilder())
				adminAll, err := adminRepo.Search(adminCtx, "", model.QueryOptions{Max: 1000})
				Expect(err).ToNot(HaveOccurred())

				userAll, err := restrictedRepo.Search(restrictedCtx, "", model.QueryOptions{Max: 1000})
				Expect(err).ToNot(HaveOccurred())

				ids := func(artists model.Artists) []string {
					out := make([]string, len(artists))
					for i, a := range artists {
						out[i] = a.ID
					}
					return out
				}
				Expect(ids(userAll)).To(Equal(ids(adminAll)))
				Expect(userAll).ToNot(BeEmpty())
			})

			It("detects all-library access regardless of result equivalence", func() {
				// userSeesAllLibraries drives the search fast-path for a non-admin: true when the
				// visible-library count reaches the DB total. Derive the total from the DB so the
				// assertion doesn't depend on how many libraries other specs left behind.
				raw := restrictedRepo.(*artistRepository) // context carries a non-admin user
				total, err := NewLibraryRepository(GetDBXBuilder()).CountAll(ctx)
				Expect(err).ToNot(HaveOccurred())
				Expect(total).To(BeNumerically(">", 0))

				allLibs := make([]int, total)
				for i := range allLibs {
					allLibs[i] = i + 1
				}
				Expect(raw.userSeesAllLibraries(restrictedCtx, allLibs)).To(BeTrue())
				Expect(raw.userSeesAllLibraries(restrictedCtx, allLibs[:total-1])).To(BeFalse())
				Expect(raw.userSeesAllLibraries(restrictedCtx, []int{})).To(BeFalse())
			})
		})
	})

	Describe("purgeEmpty", func() {
		var repo *artistRepository
		var tmpDir string

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

			ctx = request.WithUser(ctx, adminUser)
			repo = NewArtistRepository(GetDBXBuilder()).(*artistRepository)
		})

		// Helper to create an artist image file on disk and return its path
		createImageFile := func(filename string) string {
			dir := filepath.Join(tmpDir, consts.ArtworkFolder, consts.EntityArtist)
			Expect(os.MkdirAll(dir, 0755)).To(Succeed())
			path := filepath.Join(dir, filename)
			Expect(os.WriteFile(path, []byte("fake image data"), 0600)).To(Succeed())
			return path
		}

		It("removes uploaded image files for purged artists", func() {
			// Create an orphan artist (not in album_artists) with an uploaded image
			orphanArtist := model.Artist{ID: "orphan-with-image", Name: "Orphan Artist", UploadedImage: "orphan-with-image_Orphan_Artist.jpg"}
			Expect(repo.Put(ctx, &orphanArtist)).To(Succeed())
			imgPath := createImageFile("orphan-with-image_Orphan_Artist.jpg")

			Expect(repo.purgeEmpty(ctx)).To(Succeed())

			// Artist should be gone from DB
			exists, err := repo.Exists(ctx, "orphan-with-image")
			Expect(err).ToNot(HaveOccurred())
			Expect(exists).To(BeFalse())

			// Image file should be removed from disk
			_, err = os.Stat(imgPath)
			Expect(os.IsNotExist(err)).To(BeTrue())
		})

		It("handles missing image files gracefully", func() {
			// Artist has UploadedImage set but no actual file on disk
			orphanArtist := model.Artist{ID: "orphan-no-file", Name: "Ghost Image", UploadedImage: "orphan-no-file_Ghost_Image.jpg"}
			Expect(repo.Put(ctx, &orphanArtist)).To(Succeed())

			Expect(repo.purgeEmpty(ctx)).To(Succeed())

			// Artist should be gone from DB
			exists, err := repo.Exists(ctx, "orphan-no-file")
			Expect(err).ToNot(HaveOccurred())
			Expect(exists).To(BeFalse())
		})

		It("does not delete images for artists that are kept", func() {
			// Create an artist with an uploaded image AND an album_artists entry so it won't be purged
			keptArtist := model.Artist{ID: "kept-artist", Name: "Kept Artist", UploadedImage: "kept-artist_Kept_Artist.jpg"}
			Expect(repo.Put(ctx, &keptArtist)).To(Succeed())
			imgPath := createImageFile("kept-artist_Kept_Artist.jpg")

			// Insert an album_artists record to keep this artist from being purged
			_, err := repo.executeSQL(ctx, squirrel.Insert("album_artists").
				SetMap(map[string]any{"album_id": "101", "artist_id": "kept-artist", "role": "artist", "sub_role": ""}))
			Expect(err).ToNot(HaveOccurred())

			DeferCleanup(func() {
				_, _ = repo.executeSQL(ctx, squirrel.Delete("album_artists").Where(squirrel.Eq{"artist_id": "kept-artist"}))
				_ = repo.delete(ctx, squirrel.Eq{"id": "kept-artist"})
			})

			Expect(repo.purgeEmpty(ctx)).To(Succeed())

			// Artist should still exist (check directly, bypassing library filter)
			var ids []string
			err = repo.queryAllSlice(ctx, squirrel.Select("id").From("artist").Where(squirrel.Eq{"id": "kept-artist"}), &ids)
			Expect(err).ToNot(HaveOccurred())
			Expect(ids).To(HaveLen(1))

			// Image file should still be on disk
			_, err = os.Stat(imgPath)
			Expect(err).ToNot(HaveOccurred())
		})
	})

	Describe("RefreshStats", func() {
		var repo *artistRepository

		missing := func(id string) bool {
			var vals []bool
			Expect(repo.queryAllSlice(ctx, squirrel.Select("missing").From("artist").Where(squirrel.Eq{"id": id}), &vals)).To(Succeed())
			Expect(vals).To(HaveLen(1))
			return vals[0]
		}

		BeforeEach(func() {
			ctx = request.WithUser(ctx, adminUser)
			repo = NewArtistRepository(GetDBXBuilder()).(*artistRepository)
		})

		It("marks artists missing when the empty-stats cleanup drops their last library_artist row", func() {
			// A library_artist row with stats '{}' (no content) gets deleted by the cleanup,
			// which would orphan this non-missing artist.
			emptyArtist := model.Artist{ID: "refresh-empty", Name: "No Content Artist"}
			Expect(repo.Put(ctx, &emptyArtist)).To(Succeed())
			_, err := repo.executeSQL(ctx, squirrel.Insert("library_artist").
				SetMap(map[string]any{"library_id": 1, "artist_id": emptyArtist.ID, "stats": "{}"}))
			Expect(err).ToNot(HaveOccurred())
			DeferCleanup(func() {
				_, _ = repo.executeSQL(ctx, squirrel.Delete("library_artist").Where(squirrel.Eq{"artist_id": emptyArtist.ID}))
				_ = repo.delete(ctx, squirrel.Eq{"id": emptyArtist.ID})
			})

			Expect(missing(emptyArtist.ID)).To(BeFalse())

			_, err = repo.RefreshStats(ctx, true)
			Expect(err).ToNot(HaveOccurred())

			Expect(missing(emptyArtist.ID)).To(BeTrue())
			var orphanIDs []string
			Expect(repo.queryAllSlice(ctx, squirrel.Select("id").From("artist").
				Where("missing = false").
				Where("id not in (select artist_id from library_artist)"), &orphanIDs)).To(Succeed())
			Expect(orphanIDs).ToNot(ContainElement(emptyArtist.ID))
		})

		It("heals a pre-existing orphan (no library_artist row) on a full refresh", func() {
			// A legacy orphan left by an older version: non-missing, with no library_artist row at
			// all. The cleanup deletes nothing for it, so a full refresh (allArtists) must still
			// reconcile it.
			legacyOrphan := model.Artist{ID: "refresh-legacy-orphan", Name: "Legacy Orphan"}
			Expect(repo.Put(ctx, &legacyOrphan)).To(Succeed())
			DeferCleanup(func() {
				_ = repo.delete(ctx, squirrel.Eq{"id": legacyOrphan.ID})
			})

			Expect(missing(legacyOrphan.ID)).To(BeFalse())

			_, err := repo.RefreshStats(ctx, true)
			Expect(err).ToNot(HaveOccurred())

			Expect(missing(legacyOrphan.ID)).To(BeTrue())
		})
	})
})

// Helper function to create an artist with proper library association.
// This ensures test artists always have library_artist associations to avoid orphaned artists in tests.
func createArtistWithLibrary(ctx context.Context, repo model.ArtistRepository, artist *model.Artist, libraryID int) error {
	err := repo.Put(ctx, artist)
	if err != nil {
		return err
	}

	// Add the artist to the specified library
	lr := NewLibraryRepository(GetDBXBuilder())
	return lr.AddArtist(request.WithUser(ctx, adminUser), libraryID, artist.ID)
}