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"
)
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() {
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))
}
},
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),
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)
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() {
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() {
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() {
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++ {
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() {
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() {
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() {
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() {
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() {
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() {
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))
artistBeatles.SortArtistName = ""
er = repo.Put(ctx, &artistBeatles)
Expect(er).To(BeNil())
})
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() {
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))
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)
composerStats := `{"composer": {"s": 1000, "m": 5, "a": 2}}`
producerStats := `{"producer": {"s": 500, "m": 3, "a": 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())
_, 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() {
_, _ = 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))
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() {
idx, err := repo.GetIndex(ctx, false, []int{1})
Expect(err).ToNot(HaveOccurred())
Expect(idx).To(HaveLen(4))
idx, err = repo.GetIndex(ctx, false, []int{999})
Expect(err).ToNot(HaveOccurred())
Expect(idx).To(HaveLen(0))
})
})
})
Describe("Filters", func() {
var artistWithoutAnnotation model.Artist
BeforeEach(func() {
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() {
headlessCtx = GinkgoT().Context()
headlessRepo = NewArtistRepository(GetDBXBuilder())
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())
restrictedUser = createUserWithLibraries("search_user", []int{1})
restrictedCtx = request.WithUser(ctx, restrictedUser)
restrictedRepo = NewArtistRepository(GetDBXBuilder())
err = lr.AddArtist(lrCtx, 1, artistBeatles.ID)
Expect(err).ToNot(HaveOccurred())
err = lr.AddArtist(lrCtx, 1, artistKraftwerk.ID)
Expect(err).ToNot(HaveOccurred())
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() {
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) {
artistWithMBID := createTestArtistWithMBID("test-mbid-artist", "Test MBID Artist", "550e8400-e29b-41d4-a716-446655440010")
err := createArtistWithLibrary(*testCtx, *testRepo, &artistWithMBID, 1)
Expect(err).ToNot(HaveOccurred())
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)
}
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() {
inaccessibleArtist := createTestArtistWithMBID("inaccessible-mbid-artist", "Inaccessible MBID Artist", "a74b1b7f-71a5-4011-9441-d0b5e4122711")
err := repo.Put(ctx, &inaccessibleArtist)
Expect(err).ToNot(HaveOccurred())
err = lr.AddArtist(lrCtx, lib2.ID, inaccessibleArtist.ID)
Expect(err).ToNot(HaveOccurred())
results, err := restrictedRepo.Search(restrictedCtx, "a74b1b7f-71a5-4011-9441-d0b5e4122711", model.QueryOptions{Max: 10})
Expect(err).ToNot(HaveOccurred())
Expect(results).To(BeEmpty())
results, err = repo.Search(ctx, "a74b1b7f-71a5-4011-9441-d0b5e4122711", model.QueryOptions{Max: 10})
Expect(err).ToNot(HaveOccurred())
Expect(results).To(HaveLen(1))
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() {
inaccessibleArtist := model.Artist{
ID: "inaccessible-text-artist",
Name: "Unique Search Name Artist",
}
err := repo.Put(ctx, &inaccessibleArtist)
Expect(err).ToNot(HaveOccurred())
err = lr.AddArtist(lrCtx, lib2.ID, inaccessibleArtist.ID)
Expect(err).ToNot(HaveOccurred())
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")
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() {
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() {
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")
}
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() {
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() {
err := lr.AddArtist(lrCtx, lib2.ID, artistBeatles.ID)
Expect(err).ToNot(HaveOccurred())
artists, err := headlessRepo.GetAll(headlessCtx)
Expect(err).ToNot(HaveOccurred())
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() {
err := lr.AddArtist(lrCtx, lib2.ID, artistBeatles.ID)
Expect(err).ToNot(HaveOccurred())
artists, err := headlessRepo.GetAll(headlessCtx, model.QueryOptions{
Filters: squirrel.Eq{"library_id": lib2.ID},
})
Expect(err).ToNot(HaveOccurred())
for _, artist := range artists {
if artist.ID == artistBeatles.ID {
return
}
}
Expect(false).To(BeTrue(), "Should find artist from specified library")
})
It("should get individual artists when no user is in context", func() {
err := lr.AddArtist(lrCtx, lib2.ID, artistBeatles.ID)
Expect(err).ToNot(HaveOccurred())
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"}
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() {
artists, err := repo.GetAll(ctx)
Expect(err).ToNot(HaveOccurred())
Expect(artists).To(HaveLen(5))
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() {
unauthorizedUser = model.User{ID: "restricted_user", UserName: "restricted", Name: "Restricted User", Email: "restricted@test.com", IsAdmin: false}
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() {
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() {
urCtx := request.WithUser(ctx, adminUser)
ur := NewUserRepository(GetDBXBuilder())
err := ur.Put(urCtx, &unauthorizedUser)
Expect(err).ToNot(HaveOccurred())
err = ur.SetUserLibraries(urCtx, unauthorizedUser.ID, []int{1})
Expect(err).ToNot(HaveOccurred())
libraries, err := ur.GetUserLibraries(urCtx, unauthorizedUser.ID)
Expect(err).ToNot(HaveOccurred())
unauthorizedUser.Libraries = libraries
restrictedCtx = request.WithUser(ctx, unauthorizedUser)
restrictedRepo = NewArtistRepository(GetDBXBuilder())
})
AfterEach(func() {
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)))
})
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() {
idx, err := restrictedRepo.GetIndex(restrictedCtx, false, []int{1})
Expect(err).ToNot(HaveOccurred())
Expect(idx).To(HaveLen(4))
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() {
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() {
raw := restrictedRepo.(*artistRepository)
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)
})
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() {
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())
exists, err := repo.Exists(ctx, "orphan-with-image")
Expect(err).ToNot(HaveOccurred())
Expect(exists).To(BeFalse())
_, err = os.Stat(imgPath)
Expect(os.IsNotExist(err)).To(BeTrue())
})
It("handles missing image files gracefully", func() {
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())
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() {
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")
_, 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())
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))
_, 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() {
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() {
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())
})
})
})
func createArtistWithLibrary(ctx context.Context, repo model.ArtistRepository, artist *model.Artist, libraryID int) error {
err := repo.Put(ctx, artist)
if err != nil {
return err
}
lr := NewLibraryRepository(GetDBXBuilder())
return lr.AddArtist(request.WithUser(ctx, adminUser), libraryID, artist.ID)
}