mirror of
https://github.com/navidrome/navidrome.git
synced 2026-08-31 07:30:32 +00:00
Backfill enumerated every album, artist, playlist and radio via GetAll and mapped out just the ID. GetAll materializes full entities (library joins, participant/stats/tags JSON, annotation, artwork hydration), so on a large library it loaded tens of thousands of heavy structs only to read one field each — spiking transient RSS to ~1GB during the one-time upgrade backfill, a memory risk on small NAS/Pi hardware. Add GetAllIDs to the album, artist, playlist and radio repositories: it reuses each repo's base row-set filter (library visibility, artist content join, playlist userFilter) but projects only id, skipping the heavy columns and post-processing. A per-repo parity test asserts GetAllIDs returns exactly the same id set as GetAll. Verified on a 727MB / 29k-artist production DB copy: peak RSS during backfill dropped from ~1012MB to ~89MB, file descriptors flat, same 36,138 items enqueued.
1246 lines
47 KiB
Go
1246 lines
47 KiB
Go
package persistence
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"os"
|
|
"path/filepath"
|
|
|
|
"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/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() {
|
|
|
|
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("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(ctx, GetDBXBuilder()).(*artistRepository)
|
|
return r.searchScope(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(GinkgoT().Context(), GetDBXBuilder()).CountAll()
|
|
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(GinkgoT().Context(), GetDBXBuilder()).(*libraryRepository).
|
|
queryAllSlice(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 := GinkgoT().Context()
|
|
ctx = request.WithUser(ctx, adminUser)
|
|
repo = NewArtistRepository(ctx, GetDBXBuilder())
|
|
})
|
|
|
|
Describe("GetCursor", func() {
|
|
It("yields the same artists as GetAll", func() {
|
|
opts := model.QueryOptions{Sort: "name"}
|
|
want, err := repo.GetAll(opts)
|
|
Expect(err).ToNot(HaveOccurred())
|
|
Expect(collectCursor(repo.GetCursor(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(opts)
|
|
Expect(err).ToNot(HaveOccurred())
|
|
Expect(collectCursor(repo.GetCursor(opts))).To(Equal([]model.Artist(want)))
|
|
})
|
|
})
|
|
|
|
Describe("GetAllIDs", func() {
|
|
It("returns the same id set as GetAll", func() {
|
|
want, err := repo.GetAll()
|
|
Expect(err).ToNot(HaveOccurred())
|
|
Expect(want).ToNot(BeEmpty())
|
|
wantIDs := make([]string, 0, len(want))
|
|
for _, a := range want {
|
|
wantIDs = append(wantIDs, a.ID)
|
|
}
|
|
ids, err := repo.GetAllIDs()
|
|
Expect(err).ToNot(HaveOccurred())
|
|
Expect(ids).To(ConsistOf(wantIDs))
|
|
})
|
|
})
|
|
|
|
Describe("Basic Operations", func() {
|
|
Describe("Count", func() {
|
|
It("returns the number of artists in the DB", func() {
|
|
Expect(repo.CountAll()).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(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(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("3")).To(BeTrue())
|
|
})
|
|
It("returns false for an artist that is NOT in the DB", func() {
|
|
Expect(repo.Exists("666")).To(BeFalse())
|
|
})
|
|
})
|
|
|
|
Describe("Get", func() {
|
|
It("retrieves existing artist data", func() {
|
|
artist, err := repo.Get("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(&artistBeatles)
|
|
Expect(er).To(BeNil())
|
|
|
|
idx, err := repo.GetIndex(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(&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(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(&artistBeatles)
|
|
Expect(er).To(BeNil())
|
|
|
|
idx, err := repo.GetIndex(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(&artistBeatles)
|
|
Expect(er).To(BeNil())
|
|
})
|
|
|
|
It("returns the index when SortArtistName is empty", func() {
|
|
idx, err := repo.GetIndex(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(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(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(squirrel.Update("library_artist").
|
|
Set("stats", "{}").
|
|
Where(squirrel.Eq{"artist_id": artistBeatles.ID, "library_id": 1}))
|
|
_, _ = raw.executeSQL(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(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(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(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(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(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(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(repo, &artistWithoutAnnotation, 1)
|
|
Expect(err).ToNot(HaveOccurred())
|
|
})
|
|
|
|
AfterEach(func() {
|
|
if raw, ok := repo.(*artistRepository); ok {
|
|
_, _ = raw.executeSQL(squirrel.Delete(raw.tableName).Where(squirrel.Eq{"id": artistWithoutAnnotation.ID}))
|
|
}
|
|
})
|
|
|
|
Describe("starred", func() {
|
|
It("false includes items without annotations", func() {
|
|
res, err := repo.(model.ResourceRepository).ReadAll(rest.QueryOptions{
|
|
Filters: map[string]any{"starred": "false"},
|
|
})
|
|
Expect(err).ToNot(HaveOccurred())
|
|
artists := res.(model.Artists)
|
|
|
|
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.(model.ResourceRepository).ReadAll(rest.QueryOptions{
|
|
Filters: map[string]any{"starred": "true"},
|
|
})
|
|
Expect(err).ToNot(HaveOccurred())
|
|
artists := res.(model.Artists)
|
|
|
|
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 restrictedUser model.User
|
|
var restrictedRepo model.ArtistRepository
|
|
var headlessRepo model.ArtistRepository
|
|
|
|
BeforeEach(func() {
|
|
// Set up headless repo (no user context)
|
|
headlessRepo = NewArtistRepository(context.Background(), GetDBXBuilder())
|
|
|
|
// Create library for testing access restrictions
|
|
lib2 = model.Library{ID: 0, Name: "Artist Test Library", Path: "/artist/test/lib"}
|
|
lr = NewLibraryRepository(request.WithUser(GinkgoT().Context(), adminUser), GetDBXBuilder())
|
|
err := lr.Put(&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
|
|
ctx := request.WithUser(GinkgoT().Context(), restrictedUser)
|
|
restrictedRepo = NewArtistRepository(ctx, GetDBXBuilder())
|
|
|
|
// Ensure both test artists are associated with library 1
|
|
err = lr.AddArtist(1, artistBeatles.ID)
|
|
Expect(err).ToNot(HaveOccurred())
|
|
err = lr.AddArtist(1, artistKraftwerk.ID)
|
|
Expect(err).ToNot(HaveOccurred())
|
|
|
|
// Create the restricted user in the database
|
|
ur := NewUserRepository(request.WithUser(GinkgoT().Context(), adminUser), GetDBXBuilder())
|
|
err = ur.Put(&restrictedUser)
|
|
Expect(err).ToNot(HaveOccurred())
|
|
err = ur.SetUserLibraries(restrictedUser.ID, []int{1})
|
|
Expect(err).ToNot(HaveOccurred())
|
|
})
|
|
|
|
AfterEach(func() {
|
|
// Clean up library 2
|
|
lr := NewLibraryRepository(request.WithUser(GinkgoT().Context(), adminUser), GetDBXBuilder())
|
|
_ = lr.(*libraryRepository).delete(squirrel.Eq{"id": lib2.ID})
|
|
})
|
|
|
|
DescribeTable("MBID search behavior across different user types",
|
|
func(testRepo *model.ArtistRepository, shouldFind bool, testDesc string) {
|
|
// Create test artist with MBID
|
|
artistWithMBID := createTestArtistWithMBID("test-mbid-artist", "Test MBID Artist", "550e8400-e29b-41d4-a716-446655440010")
|
|
|
|
err := createArtistWithLibrary(*testRepo, &artistWithMBID, 1)
|
|
Expect(err).ToNot(HaveOccurred())
|
|
|
|
// Test the search
|
|
results, err := (*testRepo).Search("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(squirrel.Delete(raw.tableName).Where(squirrel.Eq{"id": artistWithMBID.ID}))
|
|
}
|
|
},
|
|
Entry("Admin user can find artist by MBID", &repo, true, "Admin should find MBID artist"),
|
|
Entry("Restricted user can find artist by MBID in accessible library", &restrictedRepo, true, "Restricted user should find MBID artist in accessible library"),
|
|
Entry("Headless process can find artist by MBID", &headlessRepo, 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(&inaccessibleArtist)
|
|
Expect(err).ToNot(HaveOccurred())
|
|
|
|
// Add to library 2 (not accessible to restricted user)
|
|
err = lr.AddArtist(lib2.ID, inaccessibleArtist.ID)
|
|
Expect(err).ToNot(HaveOccurred())
|
|
|
|
// Restricted user should not find this artist
|
|
results, err := restrictedRepo.Search("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("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(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("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(&inaccessibleArtist)
|
|
Expect(err).ToNot(HaveOccurred())
|
|
|
|
// Add to library 2 (not accessible to restricted user)
|
|
err = lr.AddArtist(lib2.ID, inaccessibleArtist.ID)
|
|
Expect(err).ToNot(HaveOccurred())
|
|
|
|
// Restricted user should not find this artist
|
|
results, err := restrictedRepo.Search("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(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(lib2.ID, artistBeatles.ID)).To(Succeed())
|
|
|
|
all, err := repo.Search("", 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("", 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("", 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("", 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(&lib2Artist)).To(Succeed())
|
|
Expect(lr.AddArtist(lib2.ID, lib2Artist.ID)).To(Succeed())
|
|
|
|
results, err := restrictedRepo.Search("", 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(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(&lib2Artist)).To(Succeed())
|
|
Expect(lr.AddArtist(lib2.ID, lib2Artist.ID)).To(Succeed())
|
|
DeferCleanup(func() {
|
|
if raw, ok := repo.(*artistRepository); ok {
|
|
_, _ = raw.executeSQL(squirrel.Delete(raw.tableName).Where(squirrel.Eq{"id": lib2Artist.ID}))
|
|
}
|
|
})
|
|
|
|
all, err := restrictedRepo.Search("", 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("", 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(lib2.ID, artistBeatles.ID)
|
|
Expect(err).ToNot(HaveOccurred())
|
|
|
|
// Headless processes should see all artists regardless of library
|
|
artists, err := headlessRepo.GetAll()
|
|
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(lib2.ID, artistBeatles.ID)
|
|
Expect(err).ToNot(HaveOccurred())
|
|
|
|
// Filter by specific library
|
|
artists, err := headlessRepo.GetAll(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(lib2.ID, artistBeatles.ID)
|
|
Expect(err).ToNot(HaveOccurred())
|
|
|
|
// Headless process should be able to get the artist
|
|
artist, err := headlessRepo.Get(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()
|
|
Expect(err).ToNot(HaveOccurred())
|
|
Expect(count).To(Equal(int64(4)))
|
|
|
|
artists, err := repo.GetAll()
|
|
Expect(err).ToNot(HaveOccurred())
|
|
Expect(artists).To(HaveLen(4))
|
|
|
|
exists, err := repo.Exists(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(repo, &missingArtist, 1)
|
|
Expect(err).ToNot(HaveOccurred())
|
|
|
|
_, err = raw.executeSQL(squirrel.Update(raw.tableName).Set("missing", true).Where(squirrel.Eq{"id": missingArtist.ID}))
|
|
Expect(err).ToNot(HaveOccurred())
|
|
})
|
|
|
|
AfterEach(func() {
|
|
_, _ = raw.executeSQL(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()
|
|
Expect(err).ToNot(HaveOccurred())
|
|
Expect(artists).To(HaveLen(5)) // Including the missing artist
|
|
|
|
// Search never returns missing artists (hardcoded behavior)
|
|
results, err := repo.Search("Missing Artist", model.QueryOptions{Max: 10})
|
|
Expect(err).ToNot(HaveOccurred())
|
|
Expect(results).To(BeEmpty())
|
|
})
|
|
})
|
|
})
|
|
|
|
Context("Regular User Operations", func() {
|
|
var restrictedRepo model.ArtistRepository
|
|
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
|
|
ctx := GinkgoT().Context()
|
|
ctx = request.WithUser(ctx, unauthorizedUser)
|
|
restrictedRepo = NewArtistRepository(ctx, GetDBXBuilder())
|
|
})
|
|
|
|
Describe("Library Access Restrictions", func() {
|
|
It("CountAll returns 0 for users without library access", func() {
|
|
count, err := restrictedRepo.CountAll()
|
|
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()
|
|
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(artistBeatles.ID)
|
|
Expect(err).ToNot(HaveOccurred())
|
|
Expect(exists).To(BeFalse())
|
|
|
|
exists, err = restrictedRepo.Exists(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(artistBeatles.ID)
|
|
Expect(err).To(Equal(model.ErrNotFound))
|
|
|
|
_, err = restrictedRepo.Get(artistKraftwerk.ID)
|
|
Expect(err).To(Equal(model.ErrNotFound))
|
|
})
|
|
|
|
It("Search returns empty results for users without library access", func() {
|
|
results, err := restrictedRepo.Search("Beatles", model.QueryOptions{Max: 10})
|
|
Expect(err).ToNot(HaveOccurred())
|
|
Expect(results).To(BeEmpty())
|
|
|
|
results, err = restrictedRepo.Search("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(false, []int{1})
|
|
Expect(err).ToNot(HaveOccurred())
|
|
Expect(idx).To(HaveLen(0))
|
|
})
|
|
})
|
|
|
|
Context("when user gains library access", func() {
|
|
BeforeEach(func() {
|
|
ctx := GinkgoT().Context()
|
|
// Give the user access to library 1
|
|
ur := NewUserRepository(request.WithUser(ctx, adminUser), GetDBXBuilder())
|
|
|
|
// First create the user if not exists
|
|
err := ur.Put(&unauthorizedUser)
|
|
Expect(err).ToNot(HaveOccurred())
|
|
|
|
// Then add library access
|
|
err = ur.SetUserLibraries(unauthorizedUser.ID, []int{1})
|
|
Expect(err).ToNot(HaveOccurred())
|
|
|
|
// Update the user object with the libraries to simulate middleware behavior
|
|
libraries, err := ur.GetUserLibraries(unauthorizedUser.ID)
|
|
Expect(err).ToNot(HaveOccurred())
|
|
unauthorizedUser.Libraries = libraries
|
|
|
|
// Recreate repository context with updated user
|
|
ctx = request.WithUser(ctx, unauthorizedUser)
|
|
restrictedRepo = NewArtistRepository(ctx, GetDBXBuilder())
|
|
})
|
|
|
|
AfterEach(func() {
|
|
// Clean up: remove the user's library access
|
|
ur := NewUserRepository(request.WithUser(GinkgoT().Context(), adminUser), GetDBXBuilder())
|
|
_ = ur.SetUserLibraries(unauthorizedUser.ID, []int{})
|
|
})
|
|
|
|
It("CountAll returns correct count after gaining access", func() {
|
|
count, err := restrictedRepo.CountAll()
|
|
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()
|
|
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(artistBeatles.ID)
|
|
Expect(err).ToNot(HaveOccurred())
|
|
Expect(exists).To(BeTrue())
|
|
|
|
exists, err = restrictedRepo.Exists(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(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(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.
|
|
adminRepo := NewArtistRepository(request.WithUser(GinkgoT().Context(), adminUser), GetDBXBuilder())
|
|
adminAll, err := adminRepo.Search("", model.QueryOptions{Max: 1000})
|
|
Expect(err).ToNot(HaveOccurred())
|
|
|
|
userAll, err := restrictedRepo.Search("", 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(GinkgoT().Context(), GetDBXBuilder()).CountAll()
|
|
Expect(err).ToNot(HaveOccurred())
|
|
Expect(total).To(BeNumerically(">", 0))
|
|
|
|
allLibs := make([]int, total)
|
|
for i := range allLibs {
|
|
allLibs[i] = i + 1
|
|
}
|
|
Expect(raw.userSeesAllLibraries(allLibs)).To(BeTrue())
|
|
Expect(raw.userSeesAllLibraries(allLibs[:total-1])).To(BeFalse())
|
|
Expect(raw.userSeesAllLibraries([]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(GinkgoT().Context(), adminUser)
|
|
repo = NewArtistRepository(ctx, 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(&orphanArtist)).To(Succeed())
|
|
imgPath := createImageFile("orphan-with-image_Orphan_Artist.jpg")
|
|
|
|
Expect(repo.purgeEmpty()).To(Succeed())
|
|
|
|
// Artist should be gone from DB
|
|
exists, err := repo.Exists("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(&orphanArtist)).To(Succeed())
|
|
|
|
Expect(repo.purgeEmpty()).To(Succeed())
|
|
|
|
// Artist should be gone from DB
|
|
exists, err := repo.Exists("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(&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(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(squirrel.Delete("album_artists").Where(squirrel.Eq{"artist_id": "kept-artist"}))
|
|
_ = repo.delete(squirrel.Eq{"id": "kept-artist"})
|
|
})
|
|
|
|
Expect(repo.purgeEmpty()).To(Succeed())
|
|
|
|
// Artist should still exist (check directly, bypassing library filter)
|
|
var ids []string
|
|
err = repo.queryAllSlice(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(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(GinkgoT().Context(), adminUser)
|
|
repo = NewArtistRepository(ctx, 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(&emptyArtist)).To(Succeed())
|
|
_, err := repo.executeSQL(squirrel.Insert("library_artist").
|
|
SetMap(map[string]any{"library_id": 1, "artist_id": emptyArtist.ID, "stats": "{}"}))
|
|
Expect(err).ToNot(HaveOccurred())
|
|
DeferCleanup(func() {
|
|
_, _ = repo.executeSQL(squirrel.Delete("library_artist").Where(squirrel.Eq{"artist_id": emptyArtist.ID}))
|
|
_ = repo.delete(squirrel.Eq{"id": emptyArtist.ID})
|
|
})
|
|
|
|
Expect(missing(emptyArtist.ID)).To(BeFalse())
|
|
|
|
_, err = repo.RefreshStats(true)
|
|
Expect(err).ToNot(HaveOccurred())
|
|
|
|
Expect(missing(emptyArtist.ID)).To(BeTrue())
|
|
var orphanIDs []string
|
|
Expect(repo.queryAllSlice(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(&legacyOrphan)).To(Succeed())
|
|
DeferCleanup(func() {
|
|
_ = repo.delete(squirrel.Eq{"id": legacyOrphan.ID})
|
|
})
|
|
|
|
Expect(missing(legacyOrphan.ID)).To(BeFalse())
|
|
|
|
_, err := repo.RefreshStats(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(repo model.ArtistRepository, artist *model.Artist, libraryID int) error {
|
|
err := repo.Put(artist)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
// Add the artist to the specified library
|
|
lr := NewLibraryRepository(request.WithUser(GinkgoT().Context(), adminUser), GetDBXBuilder())
|
|
return lr.AddArtist(libraryID, artist.ID)
|
|
}
|