mirror of
https://github.com/navidrome/navidrome.git
synced 2026-10-18 07:30:29 +00:00
* 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.
1363 lines
52 KiB
Go
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)
|
|
}
|