navidrome/core/extdata/provider_topsongs_test.go
Deluan ae65490d8b receive Agents interface in Provider constructor
Signed-off-by: Deluan <deluan@navidrome.org>
2025-03-30 13:43:55 -04:00

465 lines
14 KiB
Go

package extdata
import (
"context"
"errors"
"reflect"
"unsafe"
"github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/core/agents"
_ "github.com/navidrome/navidrome/core/agents/lastfm"
_ "github.com/navidrome/navidrome/core/agents/listenbrainz"
_ "github.com/navidrome/navidrome/core/agents/spotify"
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/tests"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
"github.com/stretchr/testify/mock"
)
var _ = Describe("Provider", func() {
var ds model.DataStore
var provider Provider
var mockAgent *mockArtistTopSongsAgent
var artistRepo *mockArtistRepo
var mediaFileRepo *mockMediaFileRepo
var ctx context.Context
var originalAgentsConfig string
var agentsImpl *agents.Agents
BeforeEach(func() {
ctx = context.Background()
// Store the original agents config to restore it later
originalAgentsConfig = conf.Server.Agents
// Setup mocks
artistRepo = newMockArtistRepo()
mediaFileRepo = newMockMediaFileRepo()
ds = &tests.MockDataStore{
MockedArtist: artistRepo,
MockedMediaFile: mediaFileRepo,
}
// Clear the agents map to prevent interference from previous tests
agents.Map = nil
// Create a mock agent
mockAgent = &mockArtistTopSongsAgent{}
log.Debug(ctx, "Creating mock agent", "agent", mockAgent)
// Create a custom agents instance directly with our mock agent
agentsImpl = &agents.Agents{}
setAgentField(agentsImpl, "ds", ds)
setAgentField(agentsImpl, "agents", []agents.Interface{mockAgent})
// Create the provider instance with our custom Agents implementation
provider = NewProvider(ds, agentsImpl)
})
AfterEach(func() {
// Restore original config
conf.Server.Agents = originalAgentsConfig
})
Describe("TopSongs with direct agent injection", func() {
BeforeEach(func() {
// Set up test data
artist1 := model.Artist{ID: "artist-1", Name: "Artist One"}
artist2 := model.Artist{ID: "artist-2", Name: "Artist Two"}
song1 := model.MediaFile{
ID: "song-1",
Title: "Song One",
Artist: "Artist One",
ArtistID: "artist-1",
AlbumArtistID: "artist-1",
MbzReleaseTrackID: "mbid-1",
Missing: false,
}
song2 := model.MediaFile{
ID: "song-2",
Title: "Song Two",
Artist: "Artist One",
ArtistID: "artist-1",
AlbumArtistID: "artist-1",
MbzReleaseTrackID: "mbid-2",
Missing: false,
}
song3 := model.MediaFile{
ID: "song-3",
Title: "Song Three",
Artist: "Artist Two",
ArtistID: "artist-2",
AlbumArtistID: "artist-2",
MbzReleaseTrackID: "mbid-3",
Missing: false,
}
// Set up basic data for the repos
artistRepo.SetData(model.Artists{artist1, artist2})
mediaFileRepo.SetData(model.MediaFiles{song1, song2, song3})
})
It("returns matching songs from the agent results", func() {
// Setup data needed for this specific test
artist1 := model.Artist{ID: "artist-1", Name: "Artist One"}
artistRepo.FindByName("Artist One", artist1)
song1 := model.MediaFile{
ID: "song-1",
Title: "Song One",
ArtistID: "artist-1",
MbzReleaseTrackID: "mbid-1",
Missing: false,
}
song2 := model.MediaFile{
ID: "song-2",
Title: "Song Two",
ArtistID: "artist-1",
MbzReleaseTrackID: "mbid-2",
Missing: false,
}
mediaFileRepo.FindByMBID("mbid-1", song1)
mediaFileRepo.FindByMBID("mbid-2", song2)
// Configure the mockAgent to return some top songs
mockAgent.topSongs = []agents.Song{
{Name: "Song One", MBID: "mbid-1"},
{Name: "Song Two", MBID: "mbid-2"},
}
songs, err := provider.TopSongs(ctx, "Artist One", 5)
Expect(err).ToNot(HaveOccurred())
Expect(songs).To(HaveLen(2))
Expect(songs[0].ID).To(Equal("song-1"))
Expect(songs[1].ID).To(Equal("song-2"))
// Verify the agent was called with the right parameters
Expect(mockAgent.lastArtistID).To(Equal("artist-1"))
Expect(mockAgent.lastArtistName).To(Equal("Artist One"))
Expect(mockAgent.lastCount).To(Equal(5))
})
It("returns nil when artist is not found", func() {
// Clear any previous mock setup to avoid conflicts
artistRepo = newMockArtistRepo()
// Setup for artist not found scenario - return empty list
artistRepo.On("GetAll", mock.Anything).Return(model.Artists{}, nil).Once()
// We need to recreate the datastore with the new mocks
ds = &tests.MockDataStore{
MockedArtist: artistRepo,
MockedMediaFile: mediaFileRepo,
}
// Create a new provider with the updated datastore
provider = NewProvider(ds, agentsImpl)
songs, err := provider.TopSongs(ctx, "Unknown Artist", 5)
Expect(err).To(BeNil())
Expect(songs).To(BeNil())
})
It("returns empty list when no matching songs are found", func() {
// Set up artist data
artist1 := model.Artist{ID: "artist-1", Name: "Artist One"}
artistRepo.FindByName("Artist One", artist1)
// Configure the agent to return songs that don't match our repo
mockAgent.topSongs = []agents.Song{
{Name: "Nonexistent Song", MBID: "unknown-mbid"},
}
// Default to empty response for any queries
mediaFileRepo.On("GetAll", mock.Anything).Return(model.MediaFiles{}, nil).Maybe()
songs, err := provider.TopSongs(ctx, "Artist One", 5)
Expect(err).ToNot(HaveOccurred())
Expect(songs).To(HaveLen(0))
})
It("returns nil when agent returns errors", func() {
// Set up artist data
artist1 := model.Artist{ID: "artist-1", Name: "Artist One"}
artistRepo.SetData(model.Artists{artist1})
artistRepo.FindByName("Artist One", artist1)
// Set the error
testError := errors.New("some agent error")
mockAgent.err = testError
songs, err := provider.TopSongs(ctx, "Artist One", 5)
// Current behavior returns nil for both error and songs
Expect(err).To(BeNil())
Expect(songs).To(BeNil())
})
It("respects count parameter", func() {
// Set up test data
artist1 := model.Artist{ID: "artist-1", Name: "Artist One"}
song1 := model.MediaFile{
ID: "song-1",
Title: "Song One",
ArtistID: "artist-1",
MbzReleaseTrackID: "mbid-1",
Missing: false,
}
// Set up mocks
artistRepo.SetData(model.Artists{artist1})
artistRepo.FindByName("Artist One", artist1)
mediaFileRepo.FindByMBID("mbid-1", song1)
// Configure the mockAgent
mockAgent.topSongs = []agents.Song{
{Name: "Song One", MBID: "mbid-1"},
{Name: "Song Two", MBID: "mbid-2"},
{Name: "Song Three", MBID: "mbid-3"},
}
// Default to empty response for any queries
mediaFileRepo.On("GetAll", mock.Anything).Return(model.MediaFiles{}, nil).Maybe()
songs, err := provider.TopSongs(ctx, "Artist One", 1)
Expect(err).ToNot(HaveOccurred())
Expect(songs).To(HaveLen(1))
Expect(songs[0].ID).To(Equal("song-1"))
})
})
Describe("TopSongs with agent registration", func() {
BeforeEach(func() {
// Set our mock agent as the only agent
conf.Server.Agents = "mock"
// Set up test data
artist1 := model.Artist{ID: "artist-1", Name: "Artist One"}
song1 := model.MediaFile{
ID: "song-1",
Title: "Song One",
Artist: "Artist One",
ArtistID: "artist-1",
MbzReleaseTrackID: "mbid-1",
Missing: false,
}
song2 := model.MediaFile{
ID: "song-2",
Title: "Song Two",
Artist: "Artist One",
ArtistID: "artist-1",
MbzReleaseTrackID: "mbid-2",
Missing: false,
}
// Set up basic data for the repos
artistRepo.SetData(model.Artists{artist1})
mediaFileRepo.SetData(model.MediaFiles{song1, song2})
// Set up the specific mock responses needed for the TopSongs method
artistRepo.FindByName("Artist One", artist1)
mediaFileRepo.FindByMBID("mbid-1", song1)
mediaFileRepo.FindByMBID("mbid-2", song2)
// Setup default behavior for empty searches
mediaFileRepo.On("GetAll", mock.Anything).Return(model.MediaFiles{}, nil).Maybe()
// Configure and register the agent
mockAgent.topSongs = []agents.Song{
{Name: "Song One", MBID: "mbid-1"},
{Name: "Song Two", MBID: "mbid-2"},
}
// Register our mock agent
agents.Register("mock", func(model.DataStore) agents.Interface { return mockAgent })
// Create the provider instance with registered agents
provider = NewProvider(ds, agents.GetAgents(ds))
})
It("returns matching songs from the registered agent", func() {
songs, err := provider.TopSongs(ctx, "Artist One", 5)
Expect(err).ToNot(HaveOccurred())
Expect(songs).To(HaveLen(2))
Expect(songs[0].ID).To(Equal("song-1"))
Expect(songs[1].ID).To(Equal("song-2"))
})
})
Describe("Error propagation from agents", func() {
BeforeEach(func() {
// Set up test data
artist1 := model.Artist{ID: "artist-1", Name: "Artist One"}
// Set up basic data for the repos
artistRepo.SetData(model.Artists{artist1})
artistRepo.FindByName("Artist One", artist1)
// Setup default behavior for empty searches
mediaFileRepo.On("GetAll", mock.Anything).Return(model.MediaFiles{}, nil).Maybe()
// Create a direct agent that returns an error
testError := errors.New("direct agent error")
directAgent := &mockArtistTopSongsAgent{
getArtistTopSongsFn: func(ctx context.Context, id, artistName, mbid string, count int) ([]agents.Song, error) {
return nil, testError
},
}
// Override the default agents implementation with our error-returning one
setAgentField(agentsImpl, "agents", []agents.Interface{directAgent})
})
It("handles errors from the agent according to current behavior", func() {
songs, err := provider.TopSongs(ctx, "Artist One", 5)
// Current behavior returns nil for both error and songs
Expect(err).To(BeNil())
Expect(songs).To(BeNil())
})
})
})
// Mock agent implementation for testing
type mockArtistTopSongsAgent struct {
agents.Interface
err error
topSongs []agents.Song
lastArtistID string
lastArtistName string
lastMBID string
lastCount int
getArtistTopSongsFn func(ctx context.Context, id, artistName, mbid string, count int) ([]agents.Song, error)
}
func (m *mockArtistTopSongsAgent) AgentName() string {
return "mock"
}
func (m *mockArtistTopSongsAgent) GetArtistTopSongs(ctx context.Context, id, artistName, mbid string, count int) ([]agents.Song, error) {
m.lastCount = count
m.lastArtistID = id
m.lastArtistName = artistName
m.lastMBID = mbid
log.Debug(ctx, "MockAgent.GetArtistTopSongs called", "id", id, "name", artistName, "mbid", mbid, "count", count)
// Use the custom function if available
if m.getArtistTopSongsFn != nil {
return m.getArtistTopSongsFn(ctx, id, artistName, mbid, count)
}
if m.err != nil {
log.Debug(ctx, "MockAgent.GetArtistTopSongs returning error", "err", m.err)
return nil, m.err
}
log.Debug(ctx, "MockAgent.GetArtistTopSongs returning songs", "count", len(m.topSongs))
return m.topSongs, nil
}
// Make sure the mock agent implements the necessary interface
var _ agents.ArtistTopSongsRetriever = (*mockArtistTopSongsAgent)(nil)
// Sets unexported fields in a struct using reflection and unsafe package
func setAgentField(obj interface{}, fieldName string, value interface{}) {
v := reflect.ValueOf(obj).Elem()
f := v.FieldByName(fieldName)
rf := reflect.NewAt(f.Type(), unsafe.Pointer(f.UnsafeAddr())).Elem()
rf.Set(reflect.ValueOf(value))
}
// Mocked ArtistRepo that uses testify's mock
type mockArtistRepo struct {
mock.Mock
model.ArtistRepository
}
func newMockArtistRepo() *mockArtistRepo {
return &mockArtistRepo{}
}
func (m *mockArtistRepo) SetData(artists model.Artists) {
// Store the data for Get queries
for _, a := range artists {
m.On("Get", a.ID).Return(&a, nil)
}
}
func (m *mockArtistRepo) SetError(hasError bool) {
if hasError {
m.On("GetAll", mock.Anything).Return(nil, errors.New("error"))
}
}
func (m *mockArtistRepo) FindByName(name string, artist model.Artist) {
// Set up a mock for finding an artist by name with LIKE filter, using Anything matcher for flexibility
m.On("GetAll", mock.Anything).Return(model.Artists{artist}, nil).Once()
}
func (m *mockArtistRepo) GetAll(options ...model.QueryOptions) (model.Artists, error) {
args := m.Called(mock.Anything)
if args.Get(0) == nil {
return nil, args.Error(1)
}
return args.Get(0).(model.Artists), args.Error(1)
}
// Mocked MediaFileRepo that uses testify's mock
type mockMediaFileRepo struct {
mock.Mock
model.MediaFileRepository
}
func newMockMediaFileRepo() *mockMediaFileRepo {
return &mockMediaFileRepo{}
}
func (m *mockMediaFileRepo) SetData(mediaFiles model.MediaFiles) {
// Store the data for Get queries
for _, mf := range mediaFiles {
m.On("Get", mf.ID).Return(&mf, nil)
}
}
func (m *mockMediaFileRepo) SetError(hasError bool) {
if hasError {
m.On("GetAll", mock.Anything).Return(nil, errors.New("error"))
}
}
func (m *mockMediaFileRepo) FindByMBID(mbid string, mediaFile model.MediaFile) {
// Set up a mock for finding a media file by MBID using Anything matcher for flexibility
m.On("GetAll", mock.Anything).Return(model.MediaFiles{mediaFile}, nil).Once()
}
func (m *mockMediaFileRepo) FindByArtistAndTitle(artistID string, title string, mediaFile model.MediaFile) {
// Set up a mock for finding a media file by artist ID and title
m.On("GetAll", mock.Anything).Return(model.MediaFiles{mediaFile}, nil).Once()
}
func (m *mockMediaFileRepo) GetAll(options ...model.QueryOptions) (model.MediaFiles, error) {
args := m.Called(mock.Anything)
if args.Get(0) == nil {
return nil, args.Error(1)
}
return args.Get(0).(model.MediaFiles), args.Error(1)
}