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) }