add unit tests for ListenBrainz scrobbler (#317)

* Access HTTP client via interface to allow for testing

* [Minor] Fix typo

* Add initial test cases for ListenBrainz scrobbler

* Fix linter error for insecure TLS in tests

* Use Testify for unit tests

* Move model into separate file

* Embed JSON responses into tests

* [Minor] Fix test function names
This commit is contained in:
Gregor Zurowski
2023-05-13 14:10:11 +02:00
committed by GitHub
parent 05b2b469dc
commit bb8507a72f
7 changed files with 212 additions and 26 deletions

View File

@@ -27,33 +27,16 @@ var (
ErrListenBrainz = errors.New("listenbrainz error")
)
// https://listenbrainz.readthedocs.io/en/latest/users/json.html#submission-json
type Payload struct {
ListenedAt int `json:"listened_at,omitempty"`
TrackMetadata *TrackMetadata `json:"track_metadata"`
type Scrobbler struct {
httpClient *http.Client
}
type AdditionalInfo struct {
TrackNumber int `json:"tracknumber,omitempty"`
TrackMBID string `json:"track_mbid,omitempty"`
RecordingMBID string `json:"recording_mbid,omitempty"`
TrackLength int `json:"track_length,omitempty"`
func NewScrobbler() *Scrobbler {
return &Scrobbler{
httpClient: http.DefaultClient,
}
}
type TrackMetadata struct {
AdditionalInfo *AdditionalInfo `json:"additional_info"`
ArtistName string `json:"artist_name,omitempty"`
TrackName string `json:"track_name,omitempty"`
ReleaseName string `json:"release_name,omitempty"`
}
type Scrobble struct {
ListenType string `json:"listen_type,omitempty"`
Payload []*Payload `json:"payload"`
}
type Scrobbler struct{}
func (s *Scrobbler) Scrobble(user *db.User, track *db.Track, stamp time.Time, submission bool) error {
if user.ListenBrainzURL == "" || user.ListenBrainzToken == "" {
return nil
@@ -96,7 +79,7 @@ func (s *Scrobbler) Scrobble(user *db.User, track *db.Track, stamp time.Time, su
req, _ := http.NewRequest(http.MethodPost, submitURL, &payloadBuf)
req.Header.Add("Content-Type", "application/json")
req.Header.Add("Authorization", authHeader)
resp, err := http.DefaultClient.Do(req)
resp, err := s.httpClient.Do(req)
if err != nil {
return fmt.Errorf("http post: %w", err)
}
@@ -104,7 +87,7 @@ func (s *Scrobbler) Scrobble(user *db.User, track *db.Track, stamp time.Time, su
switch {
case resp.StatusCode == http.StatusUnauthorized:
return fmt.Errorf("unathorized: %w", ErrListenBrainz)
return fmt.Errorf("unauthorized: %w", ErrListenBrainz)
case resp.StatusCode >= 400:
respBytes, _ := httputil.DumpResponse(resp, true)
log.Printf("received bad listenbrainz response:\n%s", string(respBytes))

View File

@@ -0,0 +1,148 @@
package listenbrainz
import (
"context"
"crypto/tls"
_ "embed"
"io"
"net"
"net/http"
"net/http/httptest"
"testing"
"time"
"github.com/stretchr/testify/assert"
"go.senan.xyz/gonic/db"
)
func httpClientMock(handler http.Handler) (http.Client, func()) {
server := httptest.NewTLSServer(handler)
client := http.Client{
Transport: &http.Transport{
DialContext: func(ctx context.Context, network, addr string) (net.Conn, error) {
return net.Dial(network, server.Listener.Addr().String())
},
TLSClientConfig: &tls.Config{
InsecureSkipVerify: true, //nolint:gosec
},
},
}
return client, server.Close
}
//go:embed testdata/submit_listens_response.json
var submitListensResponse string
func TestScrobble(t *testing.T) {
t.Parallel()
assert := assert.New(t)
// arrange
client, close := httpClientMock(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
assert.Equal(http.MethodPost, r.Method)
assert.Equal("/1/submit-listens", r.URL.Path)
assert.Equal("application/json", r.Header.Get("Content-Type"))
assert.Equal("Token token1", r.Header.Get("Authorization"))
bodyBytes, err := io.ReadAll(r.Body)
assert.NoError(err)
assert.JSONEq(submitListensResponse, string(bodyBytes))
w.WriteHeader(http.StatusOK)
w.Write([]byte(`{"accepted": 1}`))
}))
defer close()
scrobbler := Scrobbler{
httpClient: &client,
}
// act
err := scrobbler.Scrobble(&db.User{
ListenBrainzURL: "https://listenbrainz.org",
ListenBrainzToken: "token1",
}, &db.Track{
Album: &db.Album{
TagTitle: "album",
},
TagTitle: "title",
TagTrackArtist: "artist",
TagTrackNumber: 1,
}, time.Unix(1683804525, 0), true)
// assert
assert.NoError(err)
}
func TestScrobbleUnauthorized(t *testing.T) {
t.Parallel()
assert := assert.New(t)
// arrange
client, close := httpClientMock(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
assert.Equal(http.MethodPost, r.Method)
assert.Equal("/1/submit-listens", r.URL.Path)
assert.Equal("application/json", r.Header.Get("Content-Type"))
assert.Equal("Token token1", r.Header.Get("Authorization"))
w.WriteHeader(http.StatusUnauthorized)
w.Write([]byte(`{"code": 401, "error": "Invalid authorization token."}`))
}))
defer close()
scrobbler := Scrobbler{
httpClient: &client,
}
// act
err := scrobbler.Scrobble(&db.User{
ListenBrainzURL: "https://listenbrainz.org",
ListenBrainzToken: "token1",
}, &db.Track{
Album: &db.Album{
TagTitle: "album",
},
TagTitle: "title",
TagTrackArtist: "artist",
TagTrackNumber: 1,
}, time.Now(), true)
// assert
assert.ErrorIs(err, ErrListenBrainz)
}
func TestScrobbleServerError(t *testing.T) {
t.Parallel()
assert := assert.New(t)
// arrange
client, close := httpClientMock(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
assert.Equal(http.MethodPost, r.Method)
assert.Equal("/1/submit-listens", r.URL.Path)
assert.Equal("application/json", r.Header.Get("Content-Type"))
assert.Equal("Token token1", r.Header.Get("Authorization"))
w.WriteHeader(http.StatusInternalServerError)
}))
defer close()
scrobbler := Scrobbler{
httpClient: &client,
}
// act
err := scrobbler.Scrobble(&db.User{
ListenBrainzURL: "https://listenbrainz.org",
ListenBrainzToken: "token1",
}, &db.Track{
Album: &db.Album{
TagTitle: "album",
},
TagTitle: "title",
TagTrackArtist: "artist",
TagTrackNumber: 1,
}, time.Now(), true)
// assert
assert.ErrorIs(err, ErrListenBrainz)
}

View File

@@ -0,0 +1,29 @@
package listenbrainz
// https://listenbrainz.readthedocs.io/en/latest/users/json.html#submission-json
type (
Payload struct {
ListenedAt int `json:"listened_at,omitempty"`
TrackMetadata *TrackMetadata `json:"track_metadata"`
}
AdditionalInfo struct {
TrackNumber int `json:"tracknumber,omitempty"`
TrackMBID string `json:"track_mbid,omitempty"`
RecordingMBID string `json:"recording_mbid,omitempty"`
TrackLength int `json:"track_length,omitempty"`
}
TrackMetadata struct {
AdditionalInfo *AdditionalInfo `json:"additional_info"`
ArtistName string `json:"artist_name,omitempty"`
TrackName string `json:"track_name,omitempty"`
ReleaseName string `json:"release_name,omitempty"`
}
Scrobble struct {
ListenType string `json:"listen_type,omitempty"`
Payload []*Payload `json:"payload"`
}
)

View File

@@ -0,0 +1,16 @@
{
"listen_type": "single",
"payload": [
{
"listened_at": 1683804525,
"track_metadata": {
"additional_info": {
"tracknumber": 1
},
"artist_name": "artist",
"track_name": "title",
"release_name": "album"
}
}
]
}