Files
navidrome/core/external/provider_albumimage_test.go
Deluan Quintão 13f6eb9a11 feat: make Unicode handling in external API calls configurable (#4277)
* feat: make Unicode handling in external API calls configurable

- Add DevPreserveUnicodeInExternalCalls config option (default: false)
- Refactor external provider to use NameForExternal() method on auxArtist
- Remove redundant Name field from auxArtist struct
- Update all external API calls (image, URL, biography, similar, top songs, MBID) to use configurable Unicode handling
- Add comprehensive tests for both Unicode-preserving and normalized behaviors
- Refactor tests to use constants and improved structure with BeforeEach blocks

Fixes issue where Spotify integration failed to find artist images for artists with Unicode characters (e.g., en dash) in their names.

Signed-off-by: Deluan <deluan@navidrome.org>

* address comments

Signed-off-by: Deluan <deluan@navidrome.org>

* avoid calling str.Clean multiple times

Signed-off-by: Deluan <deluan@navidrome.org>

* refactor: apply Unicode handling pattern to auxAlbum

Extended the configurable Unicode handling to album names, matching the
pattern already implemented for artist names. This ensures consistent behavior
when DevPreserveUnicodeInExternalCalls is enabled for both artist and album
external API calls.

Changes:
- Removed Name field from auxAlbum struct, added Name() method with Unicode logic
- Updated getAlbum, UpdateAlbumInfo, populateAlbumInfo, and AlbumImage functions
- Added comprehensive tests for album Unicode handling (preserve and normalize)
- Fixed typo in artist image test description

---------

Signed-off-by: Deluan <deluan@navidrome.org>
2025-12-02 13:08:30 -05:00

365 lines
15 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package external_test
import (
"context"
"errors"
"net/url"
"github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/conf/configtest"
"github.com/navidrome/navidrome/core/agents"
. "github.com/navidrome/navidrome/core/external"
"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 - AlbumImage", func() {
var ds *tests.MockDataStore
var provider Provider
var mockArtistRepo *mockArtistRepo
var mockAlbumRepo *mockAlbumRepo
var mockMediaFileRepo *mockMediaFileRepo
var mockAlbumAgent *mockAlbumInfoAgent
var ctx context.Context
BeforeEach(func() {
ctx = GinkgoT().Context()
DeferCleanup(configtest.SetupConfig())
conf.Server.Agents = "mockAlbum" // Configure mock agent
mockArtistRepo = newMockArtistRepo()
mockAlbumRepo = newMockAlbumRepo()
mockMediaFileRepo = newMockMediaFileRepo()
ds = &tests.MockDataStore{
MockedArtist: mockArtistRepo,
MockedAlbum: mockAlbumRepo,
MockedMediaFile: mockMediaFileRepo,
}
mockAlbumAgent = newMockAlbumInfoAgent()
agentsCombined := &mockAgents{albumInfoAgent: mockAlbumAgent}
provider = NewProvider(ds, agentsCombined)
// Default mocks
// Mocks for GetEntityByID sequence (initial failed lookups)
mockArtistRepo.On("Get", "album-1").Return(nil, model.ErrNotFound).Once()
mockArtistRepo.On("Get", "mf-1").Return(nil, model.ErrNotFound).Once()
mockAlbumRepo.On("Get", "mf-1").Return(nil, model.ErrNotFound).Once()
// Default mock for non-existent entities - Use Maybe() for flexibility
mockArtistRepo.On("Get", "not-found").Return(nil, model.ErrNotFound).Maybe()
mockAlbumRepo.On("Get", "not-found").Return(nil, model.ErrNotFound).Maybe()
mockMediaFileRepo.On("Get", "not-found").Return(nil, model.ErrNotFound).Maybe()
})
It("returns the largest image URL when successful", func() {
// Arrange
mockArtistRepo.On("Get", "album-1").Return(nil, model.ErrNotFound).Once() // Expect GetEntityByID sequence
mockAlbumRepo.On("Get", "album-1").Return(&model.Album{ID: "album-1", Name: "Album One", AlbumArtistID: "artist-1"}, nil).Once()
// Explicitly mock agent call for this test
mockAlbumAgent.On("GetAlbumImages", ctx, "Album One", "", "").
Return([]agents.ExternalImage{
{URL: "http://example.com/large.jpg", Size: 1000},
{URL: "http://example.com/medium.jpg", Size: 500},
{URL: "http://example.com/small.jpg", Size: 200},
}, nil).Once()
expectedURL, _ := url.Parse("http://example.com/large.jpg")
imgURL, err := provider.AlbumImage(ctx, "album-1")
Expect(err).ToNot(HaveOccurred())
Expect(imgURL).To(Equal(expectedURL))
mockArtistRepo.AssertCalled(GinkgoT(), "Get", "album-1") // From GetEntityByID
mockAlbumRepo.AssertCalled(GinkgoT(), "Get", "album-1")
mockArtistRepo.AssertNotCalled(GinkgoT(), "Get", "artist-1") // Artist lookup no longer happens in getAlbum
mockAlbumAgent.AssertCalled(GinkgoT(), "GetAlbumImages", ctx, "Album One", "", "") // Expect empty artist name
})
It("returns ErrNotFound if the album is not found in the DB", func() {
// Arrange: Explicitly expect the full GetEntityByID sequence for "not-found"
mockArtistRepo.On("Get", "not-found").Return(nil, model.ErrNotFound).Once()
mockAlbumRepo.On("Get", "not-found").Return(nil, model.ErrNotFound).Once()
mockMediaFileRepo.On("Get", "not-found").Return(nil, model.ErrNotFound).Once()
imgURL, err := provider.AlbumImage(ctx, "not-found")
Expect(err).To(MatchError("data not found"))
Expect(imgURL).To(BeNil())
mockArtistRepo.AssertCalled(GinkgoT(), "Get", "not-found")
mockAlbumRepo.AssertCalled(GinkgoT(), "Get", "not-found")
mockMediaFileRepo.AssertCalled(GinkgoT(), "Get", "not-found")
mockAlbumAgent.AssertNotCalled(GinkgoT(), "GetAlbumImages", mock.Anything, mock.Anything, mock.Anything)
})
It("returns the agent error if the agent fails", func() {
// Arrange
mockArtistRepo.On("Get", "album-1").Return(nil, model.ErrNotFound).Once() // Expect GetEntityByID sequence
mockAlbumRepo.On("Get", "album-1").Return(&model.Album{ID: "album-1", Name: "Album One", AlbumArtistID: "artist-1"}, nil).Once()
agentErr := errors.New("agent failure")
// Explicitly mock agent call for this test
mockAlbumAgent.On("GetAlbumImages", ctx, "Album One", "", "").Return(nil, agentErr).Once() // Expect empty artist
imgURL, err := provider.AlbumImage(ctx, "album-1")
Expect(err).To(MatchError("agent failure"))
Expect(imgURL).To(BeNil())
mockArtistRepo.AssertCalled(GinkgoT(), "Get", "album-1")
mockAlbumRepo.AssertCalled(GinkgoT(), "Get", "album-1")
mockArtistRepo.AssertNotCalled(GinkgoT(), "Get", "artist-1")
mockAlbumAgent.AssertCalled(GinkgoT(), "GetAlbumImages", ctx, "Album One", "", "") // Expect empty artist
})
It("returns ErrNotFound if the agent returns ErrNotFound", func() {
// Arrange
mockArtistRepo.On("Get", "album-1").Return(nil, model.ErrNotFound).Once() // Expect GetEntityByID sequence
mockAlbumRepo.On("Get", "album-1").Return(&model.Album{ID: "album-1", Name: "Album One", AlbumArtistID: "artist-1"}, nil).Once()
// Explicitly mock agent call for this test
mockAlbumAgent.On("GetAlbumImages", ctx, "Album One", "", "").Return(nil, agents.ErrNotFound).Once() // Expect empty artist
imgURL, err := provider.AlbumImage(ctx, "album-1")
Expect(err).To(MatchError("data not found"))
Expect(imgURL).To(BeNil())
mockArtistRepo.AssertCalled(GinkgoT(), "Get", "album-1")
mockAlbumRepo.AssertCalled(GinkgoT(), "Get", "album-1")
mockAlbumAgent.AssertCalled(GinkgoT(), "GetAlbumImages", ctx, "Album One", "", "") // Expect empty artist
})
It("returns ErrNotFound if the agent returns no images", func() {
// Arrange
mockArtistRepo.On("Get", "album-1").Return(nil, model.ErrNotFound).Once() // Expect GetEntityByID sequence
mockAlbumRepo.On("Get", "album-1").Return(&model.Album{ID: "album-1", Name: "Album One", AlbumArtistID: "artist-1"}, nil).Once()
// Explicitly mock agent call for this test
mockAlbumAgent.On("GetAlbumImages", ctx, "Album One", "", "").
Return([]agents.ExternalImage{}, nil).Once() // Expect empty artist
imgURL, err := provider.AlbumImage(ctx, "album-1")
Expect(err).To(MatchError("data not found"))
Expect(imgURL).To(BeNil())
mockArtistRepo.AssertCalled(GinkgoT(), "Get", "album-1")
mockAlbumRepo.AssertCalled(GinkgoT(), "Get", "album-1")
mockAlbumAgent.AssertCalled(GinkgoT(), "GetAlbumImages", ctx, "Album One", "", "") // Expect empty artist
})
It("returns context error if context is canceled", func() {
// Arrange
cctx, cancelCtx := context.WithCancel(ctx)
// Mock the necessary DB calls *before* canceling the context
mockArtistRepo.On("Get", "album-1").Return(nil, model.ErrNotFound).Once()
mockAlbumRepo.On("Get", "album-1").Return(&model.Album{ID: "album-1", Name: "Album One", AlbumArtistID: "artist-1"}, nil).Once()
// Expect the agent call even if context is cancelled, returning the context error
mockAlbumAgent.On("GetAlbumImages", cctx, "Album One", "", "").Return(nil, context.Canceled).Once()
// Cancel the context *before* calling the function under test
cancelCtx()
imgURL, err := provider.AlbumImage(cctx, "album-1")
Expect(err).To(MatchError("context canceled"))
Expect(imgURL).To(BeNil())
mockArtistRepo.AssertCalled(GinkgoT(), "Get", "album-1")
mockAlbumRepo.AssertCalled(GinkgoT(), "Get", "album-1")
// Agent should now be called, verify this expectation
mockAlbumAgent.AssertCalled(GinkgoT(), "GetAlbumImages", cctx, "Album One", "", "")
})
It("derives album ID from MediaFile ID", func() {
// Arrange: Mock full GetEntityByID for "mf-1" and recursive "album-1"
mockArtistRepo.On("Get", "mf-1").Return(nil, model.ErrNotFound).Once()
mockAlbumRepo.On("Get", "mf-1").Return(nil, model.ErrNotFound).Once()
mockMediaFileRepo.On("Get", "mf-1").Return(&model.MediaFile{ID: "mf-1", Title: "Track One", ArtistID: "artist-1", AlbumID: "album-1"}, nil).Once()
mockArtistRepo.On("Get", "album-1").Return(nil, model.ErrNotFound).Once()
mockAlbumRepo.On("Get", "album-1").Return(&model.Album{ID: "album-1", Name: "Album One", AlbumArtistID: "artist-1"}, nil).Once()
// Explicitly mock agent call for this test
mockAlbumAgent.On("GetAlbumImages", ctx, "Album One", "", "").
Return([]agents.ExternalImage{
{URL: "http://example.com/large.jpg", Size: 1000},
{URL: "http://example.com/medium.jpg", Size: 500},
{URL: "http://example.com/small.jpg", Size: 200},
}, nil).Once()
expectedURL, _ := url.Parse("http://example.com/large.jpg")
imgURL, err := provider.AlbumImage(ctx, "mf-1")
Expect(err).ToNot(HaveOccurred())
Expect(imgURL).To(Equal(expectedURL))
mockArtistRepo.AssertCalled(GinkgoT(), "Get", "mf-1")
mockAlbumRepo.AssertCalled(GinkgoT(), "Get", "mf-1")
mockMediaFileRepo.AssertCalled(GinkgoT(), "Get", "mf-1")
mockArtistRepo.AssertCalled(GinkgoT(), "Get", "album-1")
mockAlbumRepo.AssertCalled(GinkgoT(), "Get", "album-1")
mockArtistRepo.AssertNotCalled(GinkgoT(), "Get", "artist-1")
mockAlbumAgent.AssertCalled(GinkgoT(), "GetAlbumImages", ctx, "Album One", "", "")
})
It("handles different image orders from agent", func() {
// Arrange
mockArtistRepo.On("Get", "album-1").Return(nil, model.ErrNotFound).Once() // Expect GetEntityByID sequence
mockAlbumRepo.On("Get", "album-1").Return(&model.Album{ID: "album-1", Name: "Album One", AlbumArtistID: "artist-1"}, nil).Once()
// Explicitly mock agent call for this test
mockAlbumAgent.On("GetAlbumImages", ctx, "Album One", "", "").
Return([]agents.ExternalImage{
{URL: "http://example.com/small.jpg", Size: 200},
{URL: "http://example.com/large.jpg", Size: 1000},
{URL: "http://example.com/medium.jpg", Size: 500},
}, nil).Once()
expectedURL, _ := url.Parse("http://example.com/large.jpg")
imgURL, err := provider.AlbumImage(ctx, "album-1")
Expect(err).ToNot(HaveOccurred())
Expect(imgURL).To(Equal(expectedURL)) // Should still pick the largest
mockAlbumAgent.AssertCalled(GinkgoT(), "GetAlbumImages", ctx, "Album One", "", "")
})
It("handles agent returning only one image", func() {
// Arrange
mockArtistRepo.On("Get", "album-1").Return(nil, model.ErrNotFound).Once() // Expect GetEntityByID sequence
mockAlbumRepo.On("Get", "album-1").Return(&model.Album{ID: "album-1", Name: "Album One", AlbumArtistID: "artist-1"}, nil).Once()
// Explicitly mock agent call for this test
mockAlbumAgent.On("GetAlbumImages", ctx, "Album One", "", "").
Return([]agents.ExternalImage{
{URL: "http://example.com/single.jpg", Size: 700},
}, nil).Once()
expectedURL, _ := url.Parse("http://example.com/single.jpg")
imgURL, err := provider.AlbumImage(ctx, "album-1")
Expect(err).ToNot(HaveOccurred())
Expect(imgURL).To(Equal(expectedURL))
mockAlbumAgent.AssertCalled(GinkgoT(), "GetAlbumImages", ctx, "Album One", "", "")
})
It("returns ErrNotFound if deriving album ID fails", func() {
// Arrange: Mock full GetEntityByID for "mf-no-album" and recursive "not-found"
mockArtistRepo.On("Get", "mf-no-album").Return(nil, model.ErrNotFound).Once()
mockAlbumRepo.On("Get", "mf-no-album").Return(nil, model.ErrNotFound).Once()
mockMediaFileRepo.On("Get", "mf-no-album").Return(&model.MediaFile{ID: "mf-no-album", Title: "Track No Album", ArtistID: "artist-1", AlbumID: "not-found"}, nil).Once()
mockArtistRepo.On("Get", "not-found").Return(nil, model.ErrNotFound).Once()
mockAlbumRepo.On("Get", "not-found").Return(nil, model.ErrNotFound).Once()
mockMediaFileRepo.On("Get", "not-found").Return(nil, model.ErrNotFound).Once()
imgURL, err := provider.AlbumImage(ctx, "mf-no-album")
Expect(err).To(MatchError("data not found"))
Expect(imgURL).To(BeNil())
mockArtistRepo.AssertCalled(GinkgoT(), "Get", "mf-no-album")
mockAlbumRepo.AssertCalled(GinkgoT(), "Get", "mf-no-album")
mockMediaFileRepo.AssertCalled(GinkgoT(), "Get", "mf-no-album")
mockArtistRepo.AssertCalled(GinkgoT(), "Get", "not-found")
mockAlbumRepo.AssertCalled(GinkgoT(), "Get", "not-found")
mockMediaFileRepo.AssertCalled(GinkgoT(), "Get", "not-found")
mockAlbumAgent.AssertNotCalled(GinkgoT(), "GetAlbumImages", mock.Anything, mock.Anything, mock.Anything)
})
Context("Unicode handling in album names", func() {
var albumWithEnDash *model.Album
var expectedURL *url.URL
const (
originalAlbumName = "Raising HellDeluxe" // Album name with en dash
normalizedAlbumName = "Raising Hell-Deluxe" // Normalized version with hyphen
)
BeforeEach(func() {
// Test with en dash () in album name
albumWithEnDash = &model.Album{ID: "album-endash", Name: originalAlbumName, AlbumArtistID: "artist-1"}
mockArtistRepo.Mock = mock.Mock{} // Reset default expectations
mockAlbumRepo.Mock = mock.Mock{} // Reset default expectations
mockArtistRepo.On("Get", "album-endash").Return(nil, model.ErrNotFound).Once()
mockAlbumRepo.On("Get", "album-endash").Return(albumWithEnDash, nil).Once()
expectedURL, _ = url.Parse("http://example.com/album.jpg")
// Mock the album agent to return an image for the album
mockAlbumAgent.On("GetAlbumImages", ctx, mock.AnythingOfType("string"), "", "").
Return([]agents.ExternalImage{
{URL: "http://example.com/album.jpg", Size: 1000},
}, nil).Once()
})
When("DevPreserveUnicodeInExternalCalls is true", func() {
BeforeEach(func() {
conf.Server.DevPreserveUnicodeInExternalCalls = true
})
It("preserves Unicode characters in album names", func() {
// Act
imgURL, err := provider.AlbumImage(ctx, "album-endash")
// Assert
Expect(err).ToNot(HaveOccurred())
Expect(imgURL).To(Equal(expectedURL))
mockAlbumRepo.AssertCalled(GinkgoT(), "Get", "album-endash")
// This is the key assertion: ensure the original Unicode name is used
mockAlbumAgent.AssertCalled(GinkgoT(), "GetAlbumImages", ctx, originalAlbumName, "", "")
})
})
When("DevPreserveUnicodeInExternalCalls is false", func() {
BeforeEach(func() {
conf.Server.DevPreserveUnicodeInExternalCalls = false
})
It("normalizes Unicode characters", func() {
// Act
imgURL, err := provider.AlbumImage(ctx, "album-endash")
// Assert
Expect(err).ToNot(HaveOccurred())
Expect(imgURL).To(Equal(expectedURL))
mockAlbumRepo.AssertCalled(GinkgoT(), "Get", "album-endash")
// This assertion ensures the normalized name is used (en dash → hyphen)
mockAlbumAgent.AssertCalled(GinkgoT(), "GetAlbumImages", ctx, normalizedAlbumName, "", "")
})
})
})
})
// mockAlbumInfoAgent implementation
type mockAlbumInfoAgent struct {
mock.Mock
agents.AlbumInfoRetriever
agents.AlbumImageRetriever
}
func newMockAlbumInfoAgent() *mockAlbumInfoAgent {
m := new(mockAlbumInfoAgent)
m.On("AgentName").Return("mockAlbum").Maybe()
return m
}
func (m *mockAlbumInfoAgent) AgentName() string {
args := m.Called()
return args.String(0)
}
func (m *mockAlbumInfoAgent) GetAlbumInfo(ctx context.Context, name, artist, mbid string) (*agents.AlbumInfo, error) {
args := m.Called(ctx, name, artist, mbid)
if args.Get(0) == nil {
return nil, args.Error(1)
}
return args.Get(0).(*agents.AlbumInfo), args.Error(1)
}
func (m *mockAlbumInfoAgent) GetAlbumImages(ctx context.Context, name, artist, mbid string) ([]agents.ExternalImage, error) {
args := m.Called(ctx, name, artist, mbid)
if args.Get(0) == nil {
return nil, args.Error(1)
}
return args.Get(0).([]agents.ExternalImage), args.Error(1)
}
// Ensure mockAgent implements the interfaces
var _ agents.AlbumInfoRetriever = (*mockAlbumInfoAgent)(nil)
var _ agents.AlbumImageRetriever = (*mockAlbumInfoAgent)(nil)