navidrome/persistence/artist_repository_test.go
Deluan Quintão b293b96256
refactor(persistence): stateless repositories with per-call context (#6149)
* refactor(persistence): adopt generic deluan/rest repository API

Pin deluan/rest to the refactor branch. REST-facing repository methods
take a context and return typed values. Drop DataStore.Resource and
ResourceRepository; the native API names typed repositories directly
through a per-request adapter that later commits remove.

* refactor(persistence): base repository helpers take a context

* refactor(persistence): LibraryRepository takes a context per call

* refactor(persistence): PropertyRepository takes a context per call

* refactor(persistence): UserPropsRepository takes a context per call

* refactor(persistence): TranscodingRepository takes a context per call

* refactor(persistence): ShareRepository takes a context per call

* refactor(persistence): PlayerRepository takes a context per call

* refactor(persistence): RadioRepository takes a context per call

* refactor(persistence): PlayQueueRepository takes a context per call

* refactor(persistence): Tag and Genre repositories take a context per call

* refactor(persistence): PluginRepository takes a context per call

* refactor(persistence): Scrobble repositories take a context per call

* refactor(persistence): FolderRepository takes a context per call

* refactor(persistence): Artwork repositories take a context per call

* refactor(persistence): UserRepository takes a context per call

* refactor(persistence): ArtistRepository takes a context per call

ReadAll no longer rewrites the shared sort mappings for the role filter;
it works on a per-call copy.

* test(persistence): assert artist role sort sanitization in ReadAll

* refactor(persistence): AlbumRepository takes a context per call

* test(persistence): pass the test context to album repository helpers

* refactor(persistence): MediaFileRepository takes a context per call

* refactor(persistence): Playlist repositories take a context per call

* refactor(persistence): build all repositories once per store

* refactor(core): REST repository wrappers are built once

* refactor(persistence): repositories are stateless

Remove the context field from the base repository and the per-request
REST adapter. Enable the containedctx linter so no repository can hold a
request context again.

* chore(lint): skip containedctx in test files

* refactor: share simplifications from the stateless repositories sweep

Add deleteOwnedAll on sqlRepository and use it in player/share Delete
to remove the duplicated bulk-delete loop; have Share.Repository()
return model.ShareRepository so subsonic sharing.go drops its repeated
type assertions.

* chore(core): assert REST wrappers implement Persistable

* chore: reformat imports

* perf(persistence): build repositories on first use

Each transaction store used to construct all 21 repositories up front,
paying for filter and sort mapping setup the block never touched. Fields
are now sync.OnceValue thunks, so a store only builds what it uses.

* fix(persistence): clean plugin references per deleted user

A bulk user delete that fails on a later id had already removed the
earlier rows but skipped their plugin cleanup. Cleanup now runs right
after each successful delete.

* fix(core): unload disabled plugins even when a user delete fails

A bulk delete can fail on a later id after earlier users were removed
and their plugins auto-disabled. The wrapper returned before unloading,
leaving those plugins running until the next successful delete or a
restart.

* chore(deps): pin deluan/rest to v1.0.1

Replaces the pseudo-version of the refactor branch with the tagged
release. REST error messages now name the bare type (Artist, not
model.Artist).

* test: use the spec context instead of context.Background()

Replace the context.Background()/context.TODO() calls this branch added
to tests with the spec's ctx, GinkgoT().Context(), or t/b.Context(), so
repository calls are bound to the running spec's lifetime.

* test: declare the spec context once per Describe

Set ctx from GinkgoT().Context() first in each top-level BeforeEach and reuse it, building user contexts on top of it instead of repeating inline calls.
2026-09-25 18:06:10 -04:00

1363 lines
52 KiB
Go

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