🏡 index : quine-global/quintodrome.git

package deezer

import (
	"bytes"
	"errors"
	"fmt"
	"io"
	"net/http"
	"os"
	"time"

	"github.com/navidrome/navidrome/core/agents"
	. "github.com/onsi/ginkgo/v2"
	. "github.com/onsi/gomega"
)

var _ = Describe("client", func() {
	var httpClient *fakeHttpClient
	var client *client

	BeforeEach(func() {
		httpClient = &fakeHttpClient{}
		client = newClient(httpClient)
	})

	Describe("ArtistImages", func() {
		It("returns artist images from a successful request", func() {
			f, err := os.Open("tests/fixtures/deezer.search.artist.json")
			Expect(err).To(BeNil())
			httpClient.mock("https://api.deezer.com/search/artist", http.Response{Body: f, StatusCode: 200})

			artists, err := client.searchArtists(GinkgoT().Context(), "Michael Jackson", 20)
			Expect(err).To(BeNil())
			Expect(artists).To(HaveLen(17))
			Expect(artists[0].Name).To(Equal("Michael Jackson"))
			Expect(artists[0].PictureXl).To(Equal("https://cdn-images.dzcdn.net/images/artist/97fae13b2b30e4aec2e8c9e0c7839d92/1000x1000-000000-80-0-0.jpg"))
		})

		It("fails if artist was not found", func() {
			httpClient.mock("https://api.deezer.com/search/artist", http.Response{
				StatusCode: 200,
				Body:       io.NopCloser(bytes.NewBufferString(`{"data":[],"total":0}`)),
			})

			_, err := client.searchArtists(GinkgoT().Context(), "Michael Jackson", 20)
			Expect(err).To(MatchError(agents.ErrNotFound))
		})

		// Deezer answers 200 with no rate-limit headers when throttling, so this body is the only signal.
		It("reports an exhausted quota as a retryable error, not as a missing artist", func() {
			httpClient.mock("https://api.deezer.com/search/artist", http.Response{
				StatusCode: 200,
				Body: io.NopCloser(bytes.NewBufferString(
					`{"error":{"type":"Exception","message":"Quota limit exceeded","code":4}}`)),
			})

			_, err := client.searchArtists(GinkgoT().Context(), "Michael Jackson", 20)
			Expect(err).To(HaveOccurred())
			Expect(err).ToNot(MatchError(agents.ErrNotFound),
				"a throttled lookup would otherwise settle the artist as having no image")
			Expect(errors.Is(err, agents.ErrRetryLater)).To(BeTrue())
			Expect(err.Error()).To(ContainSubstring("Quota limit exceeded"))
		})

		It("reports a non-quota body error as a plain error", func() {
			httpClient.mock("https://api.deezer.com/search/artist", http.Response{
				StatusCode: 200,
				Body: io.NopCloser(bytes.NewBufferString(
					`{"error":{"type":"Exception","message":"Invalid query","code":100}}`)),
			})

			_, err := client.searchArtists(GinkgoT().Context(), "Michael Jackson", 20)
			Expect(err).To(HaveOccurred())
			Expect(err).ToNot(MatchError(agents.ErrNotFound))
			Expect(errors.Is(err, agents.ErrRetryLater)).To(BeFalse(),
				"only a throttle asks the caller to come back later")
		})
	})

	Describe("TopTracks", func() {
		It("returns top tracks with artist and album info from a successful request", func() {
			f, err := os.Open("tests/fixtures/deezer.artist.top.json")
			Expect(err).To(BeNil())
			httpClient.mock("https://api.deezer.com/artist/27/top", http.Response{Body: f, StatusCode: 200})

			tracks, err := client.getTopTracks(GinkgoT().Context(), 27, 5)
			Expect(err).To(BeNil())
			Expect(tracks).To(HaveLen(5))

			// Verify first track has all expected fields
			Expect(tracks[0].Title).To(Equal("Instant Crush (feat. Julian Casablancas)"))
			Expect(tracks[0].Artist.Name).To(Equal("Daft Punk"))
			Expect(tracks[0].Album.Title).To(Equal("Random Access Memories"))

			// Verify second track
			Expect(tracks[1].Title).To(Equal("One More Time"))
			Expect(tracks[1].Artist.Name).To(Equal("Daft Punk"))
			Expect(tracks[1].Album.Title).To(Equal("Discovery"))
		})
	})

	Describe("ArtistBio", func() {
		BeforeEach(func() {
			// Mock the JWT token endpoint with a valid JWT that expires in 5 minutes
			testJWT := createTestJWT(5 * time.Minute)
			httpClient.mock("https://auth.deezer.com/login/anonymous", http.Response{
				StatusCode: 200,
				Body:       io.NopCloser(bytes.NewBufferString(fmt.Sprintf(`{"jwt":"%s","refresh_token":""}`, testJWT))),
			})
		})

		It("returns artist bio from a successful request", func() {
			f, err := os.Open("tests/fixtures/deezer.artist.bio.en.json")
			Expect(err).To(BeNil())
			httpClient.mock("https://pipe.deezer.com/api", http.Response{Body: f, StatusCode: 200})

			bio, err := client.getArtistBio(GinkgoT().Context(), 27, "en")
			Expect(err).To(BeNil())
			Expect(bio).To(ContainSubstring("Schoolmates Thomas and Guy-Manuel"))
			Expect(bio).ToNot(ContainSubstring("<p>"))
			Expect(bio).ToNot(ContainSubstring("</p>"))
		})

		It("uses the provided language", func() {
			f, err := os.Open("tests/fixtures/deezer.artist.bio.fr.json")
			Expect(err).To(BeNil())
			httpClient.mock("https://pipe.deezer.com/api", http.Response{Body: f, StatusCode: 200})

			_, err = client.getArtistBio(GinkgoT().Context(), 27, "fr")
			Expect(err).To(BeNil())
			Expect(httpClient.lastRequest.Header.Get("Accept-Language")).To(Equal("fr"))
		})

		It("includes the JWT token in the request", func() {
			f, err := os.Open("tests/fixtures/deezer.artist.bio.en.json")
			Expect(err).To(BeNil())
			httpClient.mock("https://pipe.deezer.com/api", http.Response{Body: f, StatusCode: 200})

			_, err = client.getArtistBio(GinkgoT().Context(), 27, "en")
			Expect(err).To(BeNil())
			// Verify that the Authorization header has the Bearer token format
			authHeader := httpClient.lastRequest.Header.Get("Authorization")
			Expect(authHeader).To(HavePrefix("Bearer "))
			Expect(len(authHeader)).To(BeNumerically(">", 20)) // JWT tokens are longer than 20 chars
		})

		It("handles GraphQL errors", func() {
			errorResponse := `{
				"data": {
					"artist": {
						"bio": {
							"full": ""
						}
					}
				},
				"errors": [
					{
						"message": "Artist not found"
					},
					{
						"message": "Invalid artist ID"
					}
				]
			}`
			httpClient.mock("https://pipe.deezer.com/api", http.Response{
				StatusCode: 200,
				Body:       io.NopCloser(bytes.NewBufferString(errorResponse)),
			})

			_, err := client.getArtistBio(GinkgoT().Context(), 999, "en")
			Expect(err).To(HaveOccurred())
			Expect(err.Error()).To(ContainSubstring("GraphQL error"))
			Expect(err.Error()).To(ContainSubstring("Artist not found"))
			Expect(err.Error()).To(ContainSubstring("Invalid artist ID"))
		})

		It("handles empty biography", func() {
			emptyBioResponse := `{
				"data": {
					"artist": {
						"bio": {
							"full": ""
						}
					}
				}
			}`
			httpClient.mock("https://pipe.deezer.com/api", http.Response{
				StatusCode: 200,
				Body:       io.NopCloser(bytes.NewBufferString(emptyBioResponse)),
			})

			_, err := client.getArtistBio(GinkgoT().Context(), 27, "en")
			Expect(err).To(MatchError("deezer: biography not found"))
		})

		It("handles JWT token fetch failure", func() {
			httpClient.mock("https://auth.deezer.com/login/anonymous", http.Response{
				StatusCode: 500,
				Body:       io.NopCloser(bytes.NewBufferString(`{"error":"Internal server error"}`)),
			})

			_, err := client.getArtistBio(GinkgoT().Context(), 27, "en")
			Expect(err).To(HaveOccurred())
			Expect(err.Error()).To(ContainSubstring("failed to get JWT"))
		})

		It("handles JWT token that expires too soon", func() {
			// Create a JWT that expires in 30 seconds (less than the 1-minute buffer)
			expiredJWT := createTestJWT(30 * time.Second)
			httpClient.mock("https://auth.deezer.com/login/anonymous", http.Response{
				StatusCode: 200,
				Body:       io.NopCloser(bytes.NewBufferString(fmt.Sprintf(`{"jwt":"%s","refresh_token":""}`, expiredJWT))),
			})

			_, err := client.getArtistBio(GinkgoT().Context(), 27, "en")
			Expect(err).To(HaveOccurred())
			Expect(err.Error()).To(ContainSubstring("JWT token already expired or expires too soon"))
		})
	})
})

type fakeHttpClient struct {
	responses   map[string]*http.Response
	lastRequest *http.Request
}

func (c *fakeHttpClient) mock(url string, response http.Response) {
	if c.responses == nil {
		c.responses = make(map[string]*http.Response)
	}
	c.responses[url] = &response
}

func (c *fakeHttpClient) Do(req *http.Request) (*http.Response, error) {
	c.lastRequest = req
	u := req.URL
	u.RawQuery = ""
	if resp, ok := c.responses[u.String()]; ok {
		return resp, nil
	}
	panic("URL not mocked: " + u.String())
}