🏡 index : ironmagma/quintodrome.git

package external_test

import (
	"bytes"
	"context"
	"errors"
	"net/url"
	"time"

	"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/core/matcher"
	"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 - ArtistImage", func() {
	var ds *tests.MockDataStore
	var provider Provider
	var mockArtistRepo *mockArtistRepo
	var mockAlbumRepo *mockAlbumRepo
	var mockMediaFileRepo *mockMediaFileRepo
	var mockImageAgent *mockArtistImageAgent
	var agentsCombined *mockAgents
	var ctx context.Context

	BeforeEach(func() {
		DeferCleanup(configtest.SetupConfig())
		conf.Server.Agents = "mockImage" // Configure only the mock agent
		ctx = GinkgoT().Context()

		mockArtistRepo = newMockArtistRepo()
		mockAlbumRepo = newMockAlbumRepo()
		mockMediaFileRepo = newMockMediaFileRepo()

		ds = &tests.MockDataStore{
			MockedArtist:    mockArtistRepo,
			MockedAlbum:     mockAlbumRepo,
			MockedMediaFile: mockMediaFileRepo,
		}

		mockImageAgent = newMockArtistImageAgent()

		// Use the mockAgents from helper, setting the specific agent
		agentsCombined = &mockAgents{
			imageAgent: mockImageAgent,
		}

		provider = NewProvider(ds, agentsCombined, matcher.New(ds))

		// Default mocks for successful Get calls
		mockArtistRepo.On("Get", "artist-1").Return(&model.Artist{ID: "artist-1", Name: "Artist One"}, nil).Maybe()
		mockAlbumRepo.On("Get", "album-1").Return(&model.Album{ID: "album-1", Name: "Album One", AlbumArtistID: "artist-1"}, nil).Maybe()
		mockMediaFileRepo.On("Get", "mf-1").Return(&model.MediaFile{ID: "mf-1", Title: "Track One", ArtistID: "artist-1"}, nil).Maybe()
		// Default mock for non-existent entities
		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()

		// Default successful image agent response
		mockImageAgent.On("GetArtistImages", mock.Anything, "artist-1", "Artist 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).Maybe()
	})

	AfterEach(func() {
		mockArtistRepo.AssertExpectations(GinkgoT())
		mockAlbumRepo.AssertExpectations(GinkgoT())
		mockMediaFileRepo.AssertExpectations(GinkgoT())
		mockImageAgent.AssertExpectations(GinkgoT())
	})

	It("returns the largest image URL when successful", func() {
		// Arrange
		expectedURL, _ := url.Parse("http://example.com/large.jpg")

		// Act
		imgURL, err := provider.ArtistImage(ctx, "artist-1")

		// Assert
		Expect(err).ToNot(HaveOccurred())
		Expect(imgURL).To(Equal(expectedURL))
		mockArtistRepo.AssertCalled(GinkgoT(), "Get", "artist-1")
		mockImageAgent.AssertCalled(GinkgoT(), "GetArtistImages", ctx, "artist-1", "Artist One", "")
	})

	It("returns ErrNotFound if the artist is not found in the DB", func() {
		// Arrange

		// Act
		imgURL, err := provider.ArtistImage(ctx, "not-found")

		// Assert
		Expect(err).To(MatchError(model.ErrNotFound))
		Expect(imgURL).To(BeNil())
		mockArtistRepo.AssertCalled(GinkgoT(), "Get", "not-found")
		mockImageAgent.AssertNotCalled(GinkgoT(), "GetArtistImages", mock.Anything, mock.Anything, mock.Anything, mock.Anything)
	})

	It("returns the agent error if the agent fails", func() {
		// Arrange
		agentErr := errors.New("agent failure")
		mockImageAgent.Mock = mock.Mock{} // Reset default expectation
		mockImageAgent.On("GetArtistImages", ctx, "artist-1", "Artist One", "").Return(nil, agentErr).Once()

		// Act
		imgURL, err := provider.ArtistImage(ctx, "artist-1")

		// Assert
		Expect(err).To(MatchError(model.ErrNotFound)) // Corrected Expectation: The provider maps agent errors (other than canceled) to ErrNotFound if no image was found/populated
		Expect(imgURL).To(BeNil())
		mockArtistRepo.AssertCalled(GinkgoT(), "Get", "artist-1")
		mockImageAgent.AssertCalled(GinkgoT(), "GetArtistImages", ctx, "artist-1", "Artist One", "")
	})

	It("returns ErrNotFound if the agent returns ErrNotFound", func() {
		// Arrange
		mockImageAgent.Mock = mock.Mock{} // Reset default expectation
		mockImageAgent.On("GetArtistImages", ctx, "artist-1", "Artist One", "").Return(nil, agents.ErrNotFound).Once()

		// Act
		imgURL, err := provider.ArtistImage(ctx, "artist-1")

		// Assert
		Expect(err).To(MatchError(model.ErrNotFound))
		Expect(imgURL).To(BeNil())
		mockArtistRepo.AssertCalled(GinkgoT(), "Get", "artist-1")
		mockImageAgent.AssertCalled(GinkgoT(), "GetArtistImages", ctx, "artist-1", "Artist One", "")
	})

	It("returns ErrNotFound if the agent returns no images", func() {
		// Arrange
		mockImageAgent.Mock = mock.Mock{} // Reset default expectation
		mockImageAgent.On("GetArtistImages", ctx, "artist-1", "Artist One", "").Return([]agents.ExternalImage{}, nil).Once()

		// Act
		imgURL, err := provider.ArtistImage(ctx, "artist-1")

		// Assert
		Expect(err).To(MatchError(model.ErrNotFound)) // Implementation maps empty result to ErrNotFound
		Expect(imgURL).To(BeNil())
		mockArtistRepo.AssertCalled(GinkgoT(), "Get", "artist-1")
		mockImageAgent.AssertCalled(GinkgoT(), "GetArtistImages", ctx, "artist-1", "Artist One", "")
	})

	It("returns context error if context is canceled before agent call", func() {
		// Arrange
		cctx, cancelCtx := context.WithCancel(context.Background())
		mockArtistRepo.Mock = mock.Mock{} // Reset default expectation for artist repo as well
		mockArtistRepo.On("Get", "artist-1").Return(&model.Artist{ID: "artist-1", Name: "Artist One"}, nil).Run(func(args mock.Arguments) {
			cancelCtx() // Cancel context *during* the DB call simulation
		}).Once()

		// Act
		imgURL, err := provider.ArtistImage(cctx, "artist-1")

		// Assert
		Expect(err).To(MatchError(context.Canceled))
		Expect(imgURL).To(BeNil())
		mockArtistRepo.AssertCalled(GinkgoT(), "Get", "artist-1")
	})

	It("derives artist ID from MediaFile ID", func() {
		// Arrange: Add mocks for the initial GetEntityByID lookups
		mockArtistRepo.On("Get", "mf-1").Return(nil, model.ErrNotFound).Once()
		mockAlbumRepo.On("Get", "mf-1").Return(nil, model.ErrNotFound).Once()
		// Default mocks for MediaFileRepo.Get("mf-1") and ArtistRepo.Get("artist-1") handle the rest
		expectedURL, _ := url.Parse("http://example.com/large.jpg")

		// Act
		imgURL, err := provider.ArtistImage(ctx, "mf-1")

		// Assert
		Expect(err).ToNot(HaveOccurred())
		Expect(imgURL).To(Equal(expectedURL))
		mockArtistRepo.AssertCalled(GinkgoT(), "Get", "mf-1") // GetEntityByID sequence
		mockAlbumRepo.AssertCalled(GinkgoT(), "Get", "mf-1")  // GetEntityByID sequence
		mockMediaFileRepo.AssertCalled(GinkgoT(), "Get", "mf-1")
		mockArtistRepo.AssertCalled(GinkgoT(), "Get", "artist-1") // Should be called after getting MF
		mockImageAgent.AssertCalled(GinkgoT(), "GetArtistImages", ctx, "artist-1", "Artist One", "")
	})

	It("derives artist ID from Album ID", func() {
		// Arrange: Add mock for the initial GetEntityByID lookup
		mockArtistRepo.On("Get", "album-1").Return(nil, model.ErrNotFound).Once()
		// Default mocks for AlbumRepo.Get("album-1") and ArtistRepo.Get("artist-1") handle the rest
		expectedURL, _ := url.Parse("http://example.com/large.jpg")

		// Act
		imgURL, err := provider.ArtistImage(ctx, "album-1")

		// Assert
		Expect(err).ToNot(HaveOccurred())
		Expect(imgURL).To(Equal(expectedURL))
		mockArtistRepo.AssertCalled(GinkgoT(), "Get", "album-1") // GetEntityByID sequence
		mockAlbumRepo.AssertCalled(GinkgoT(), "Get", "album-1")
		mockArtistRepo.AssertCalled(GinkgoT(), "Get", "artist-1") // Should be called after getting Album
		mockImageAgent.AssertCalled(GinkgoT(), "GetArtistImages", ctx, "artist-1", "Artist One", "")
	})

	It("returns ErrNotFound if derived artist is not found", func() {
		// Arrange
		// Add mocks for the initial GetEntityByID lookups
		mockArtistRepo.On("Get", "mf-bad-artist").Return(nil, model.ErrNotFound).Once()
		mockAlbumRepo.On("Get", "mf-bad-artist").Return(nil, model.ErrNotFound).Once()
		mockMediaFileRepo.On("Get", "mf-bad-artist").Return(&model.MediaFile{ID: "mf-bad-artist", ArtistID: "not-found"}, nil).Once()
		// Add expectation for the recursive GetEntityByID call for the MediaFileRepo
		mockMediaFileRepo.On("Get", "not-found").Return(nil, model.ErrNotFound).Maybe()
		// The default mocks for ArtistRepo/AlbumRepo handle the final "not-found" lookups

		// Act
		imgURL, err := provider.ArtistImage(ctx, "mf-bad-artist")

		// Assert
		Expect(err).To(MatchError(model.ErrNotFound))
		Expect(imgURL).To(BeNil())
		mockArtistRepo.AssertCalled(GinkgoT(), "Get", "mf-bad-artist") // GetEntityByID sequence
		mockAlbumRepo.AssertCalled(GinkgoT(), "Get", "mf-bad-artist")  // GetEntityByID sequence
		mockMediaFileRepo.AssertCalled(GinkgoT(), "Get", "mf-bad-artist")
		mockArtistRepo.AssertCalled(GinkgoT(), "Get", "not-found")
		mockImageAgent.AssertNotCalled(GinkgoT(), "GetArtistImages", mock.Anything, mock.Anything, mock.Anything, mock.Anything)
	})

	It("handles different image orders from agent", func() {
		// Arrange
		mockImageAgent.Mock = mock.Mock{} // Reset default expectation
		mockImageAgent.On("GetArtistImages", ctx, "artist-1", "Artist 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")

		// Act
		imgURL, err := provider.ArtistImage(ctx, "artist-1")

		// Assert
		Expect(err).ToNot(HaveOccurred())
		Expect(imgURL).To(Equal(expectedURL)) // Still picks the largest
		mockArtistRepo.AssertCalled(GinkgoT(), "Get", "artist-1")
		mockImageAgent.AssertCalled(GinkgoT(), "GetArtistImages", ctx, "artist-1", "Artist One", "")
	})

	It("handles agent returning only one image", func() {
		// Arrange
		mockImageAgent.Mock = mock.Mock{} // Reset default expectation
		mockImageAgent.On("GetArtistImages", ctx, "artist-1", "Artist One", "").
			Return([]agents.ExternalImage{
				{URL: "http://example.com/medium.jpg", Size: 500},
			}, nil).Once()
		expectedURL, _ := url.Parse("http://example.com/medium.jpg")

		// Act
		imgURL, err := provider.ArtistImage(ctx, "artist-1")

		// Assert
		Expect(err).ToNot(HaveOccurred())
		Expect(imgURL).To(Equal(expectedURL))
		mockArtistRepo.AssertCalled(GinkgoT(), "Get", "artist-1")
		mockImageAgent.AssertCalled(GinkgoT(), "GetArtistImages", ctx, "artist-1", "Artist One", "")
	})

	It("returns cached URL and does not call agent when info is not expired", func() {
		// Arrange: artist has a cached image URL with recent ExternalInfoUpdatedAt
		cachedArtist := &model.Artist{
			ID:                    "artist-cached",
			Name:                  "Cached Artist",
			LargeImageUrl:         "http://example.com/cached-large.jpg",
			ExternalInfoUpdatedAt: new(time.Now().Add(-1 * time.Minute)),
		}
		mockArtistRepo.On("Get", "artist-cached").Return(cachedArtist, nil).Maybe()
		expectedURL, _ := url.Parse("http://example.com/cached-large.jpg")

		// Capture log output
		var logBuf bytes.Buffer
		log.SetOutput(&logBuf)
		defer log.SetOutput(GinkgoWriter)
		log.SetLevel(log.LevelDebug)

		// Act
		imgURL, err := provider.ArtistImage(ctx, "artist-cached")

		// Assert
		Expect(err).ToNot(HaveOccurred())
		Expect(imgURL).To(Equal(expectedURL))
		mockImageAgent.AssertNotCalled(GinkgoT(), "GetArtistImages", mock.Anything, "artist-cached", mock.Anything, mock.Anything)

		// Assert: background refresh was NOT enqueued
		Expect(logBuf.String()).ToNot(ContainSubstring("Artist image info expired, enqueuing background refresh"))

	})

	It("returns stale URL and enqueues refresh when info is expired", func() {
		// Arrange
		conf.Server.DevArtistInfoTimeToLive = 1 * time.Nanosecond
		staleArtist := &model.Artist{
			ID:                    "artist-expired",
			Name:                  "Expired Artist",
			LargeImageUrl:         "http://example.com/expired-large.jpg",
			ExternalInfoUpdatedAt: new(time.Now().Add(-1 * time.Hour)),
		}
		mockArtistRepo.On("Get", "artist-expired").Return(staleArtist, nil).Maybe()
		expectedURL, _ := url.Parse("http://example.com/expired-large.jpg")

		// Capture log output
		var logBuf bytes.Buffer
		log.SetOutput(&logBuf)
		defer log.SetOutput(GinkgoWriter)
		log.SetLevel(log.LevelDebug)

		// Act
		imgURL, err := provider.ArtistImage(ctx, "artist-expired")

		// Assert: returns stale URL immediately, no agent call
		Expect(err).ToNot(HaveOccurred())
		Expect(imgURL).To(Equal(expectedURL))
		mockImageAgent.AssertNotCalled(GinkgoT(), "GetArtistImages", mock.Anything, "artist-expired", mock.Anything, mock.Anything)

		// Assert: background refresh was enqueued
		Expect(logBuf.String()).To(ContainSubstring("Artist image info expired, enqueuing background refresh"))
	})

	Context("Unicode handling in artist names", func() {
		var artistWithEnDash *model.Artist
		var expectedURL *url.URL

		const (
			originalArtistName   = "Run–D.M.C." // Artist name with en dash
			normalizedArtistName = "Run-D.M.C." // Normalized version with hyphen
		)

		BeforeEach(func() {
			// Test with en dash (–) in artist name like "Run–D.M.C."
			artistWithEnDash = &model.Artist{ID: "artist-endash", Name: originalArtistName}
			mockArtistRepo.Mock = mock.Mock{} // Reset default expectations
			mockArtistRepo.On("Get", "artist-endash").Return(artistWithEnDash, nil).Once()

			expectedURL, _ = url.Parse("http://example.com/rundmc.jpg")

			// Mock the image agent to return an image for the artist
			mockImageAgent.On("GetArtistImages", ctx, "artist-endash", mock.AnythingOfType("string"), "").
				Return([]agents.ExternalImage{
					{URL: "http://example.com/rundmc.jpg", Size: 1000},
				}, nil).Once()

		})

		When("DevPreserveUnicodeInExternalCalls is true", func() {
			BeforeEach(func() {
				conf.Server.DevPreserveUnicodeInExternalCalls = true
			})
			It("preserves Unicode characters in artist names", func() {
				// Act
				imgURL, err := provider.ArtistImage(ctx, "artist-endash")

				// Assert
				Expect(err).ToNot(HaveOccurred())
				Expect(imgURL).To(Equal(expectedURL))
				mockArtistRepo.AssertCalled(GinkgoT(), "Get", "artist-endash")
				// This is the key assertion: ensure the original Unicode name is used
				mockImageAgent.AssertCalled(GinkgoT(), "GetArtistImages", ctx, "artist-endash", originalArtistName, "")
			})
		})

		When("DevPreserveUnicodeInExternalCalls is false", func() {
			BeforeEach(func() {
				conf.Server.DevPreserveUnicodeInExternalCalls = false
			})

			It("normalizes Unicode characters", func() {
				// Act
				imgURL, err := provider.ArtistImage(ctx, "artist-endash")

				// Assert
				Expect(err).ToNot(HaveOccurred())
				Expect(imgURL).To(Equal(expectedURL))
				mockArtistRepo.AssertCalled(GinkgoT(), "Get", "artist-endash")
				// This assertion ensures the normalized name is used (en dash → hyphen)
				mockImageAgent.AssertCalled(GinkgoT(), "GetArtistImages", ctx, "artist-endash", normalizedArtistName, "")
			})
		})
	})
})

// mockArtistImageAgent implementation using testify/mock
// This remains local as it's specific to testing the ArtistImage functionality
type mockArtistImageAgent struct {
	mock.Mock
	agents.ArtistImageRetriever // Embed interface
}

// Constructor for the mock agent
func newMockArtistImageAgent() *mockArtistImageAgent {
	mock := new(mockArtistImageAgent)
	// Set default AgentName if needed, although usually called via mockAgents
	mock.On("AgentName").Return("mockImage").Maybe()
	return mock
}

func (m *mockArtistImageAgent) AgentName() string {
	args := m.Called()
	return args.String(0)
}

func (m *mockArtistImageAgent) GetArtistImages(ctx context.Context, id, artistName, mbid string) ([]agents.ExternalImage, error) {
	args := m.Called(ctx, id, artistName, mbid)
	// Need careful type assertion for potentially nil slice
	var res []agents.ExternalImage
	if args.Get(0) != nil {
		res = args.Get(0).([]agents.ExternalImage)
	}
	return res, args.Error(1)
}

// Ensure mockAgent implements the interface
var _ agents.ArtistImageRetriever = (*mockArtistImageAgent)(nil)