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:
4
go.mod
4
go.mod
@@ -30,6 +30,7 @@ require (
|
|||||||
github.com/philippta/go-template v0.0.0-20220911145045-4556aca435e4
|
github.com/philippta/go-template v0.0.0-20220911145045-4556aca435e4
|
||||||
github.com/rainycape/unidecode v0.0.0-20150907023854-cb7f23ec59be
|
github.com/rainycape/unidecode v0.0.0-20150907023854-cb7f23ec59be
|
||||||
github.com/sentriz/gormstore v0.0.0-20220105134332-64e31f7f6981
|
github.com/sentriz/gormstore v0.0.0-20220105134332-64e31f7f6981
|
||||||
|
github.com/stretchr/testify v1.8.1
|
||||||
golang.org/x/exp v0.0.0-20230224173230-c95f2b4c22f2
|
golang.org/x/exp v0.0.0-20230224173230-c95f2b4c22f2
|
||||||
golang.org/x/net v0.7.0
|
golang.org/x/net v0.7.0
|
||||||
gopkg.in/gormigrate.v1 v1.6.0
|
gopkg.in/gormigrate.v1 v1.6.0
|
||||||
@@ -39,6 +40,7 @@ require (
|
|||||||
github.com/Masterminds/goutils v1.1.1 // indirect
|
github.com/Masterminds/goutils v1.1.1 // indirect
|
||||||
github.com/Masterminds/semver v1.5.0 // indirect
|
github.com/Masterminds/semver v1.5.0 // indirect
|
||||||
github.com/PuerkitoBio/goquery v1.8.1 // indirect
|
github.com/PuerkitoBio/goquery v1.8.1 // indirect
|
||||||
|
github.com/davecgh/go-spew v1.1.1 // indirect
|
||||||
github.com/felixge/httpsnoop v1.0.3 // indirect
|
github.com/felixge/httpsnoop v1.0.3 // indirect
|
||||||
github.com/go-openapi/jsonpointer v0.19.5 // indirect
|
github.com/go-openapi/jsonpointer v0.19.5 // indirect
|
||||||
github.com/go-openapi/swag v0.21.1 // indirect
|
github.com/go-openapi/swag v0.21.1 // indirect
|
||||||
@@ -56,10 +58,12 @@ require (
|
|||||||
github.com/mmcdole/goxpp v1.1.0 // indirect
|
github.com/mmcdole/goxpp v1.1.0 // indirect
|
||||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
|
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
|
||||||
github.com/modern-go/reflect2 v1.0.2 // indirect
|
github.com/modern-go/reflect2 v1.0.2 // indirect
|
||||||
|
github.com/pmezard/go-difflib v1.0.0 // indirect
|
||||||
golang.org/x/crypto v0.6.0 // indirect
|
golang.org/x/crypto v0.6.0 // indirect
|
||||||
golang.org/x/image v0.5.0 // indirect
|
golang.org/x/image v0.5.0 // indirect
|
||||||
golang.org/x/sys v0.5.0 // indirect
|
golang.org/x/sys v0.5.0 // indirect
|
||||||
golang.org/x/text v0.7.0 // indirect
|
golang.org/x/text v0.7.0 // indirect
|
||||||
gopkg.in/natefinch/npipe.v2 v2.0.0-20160621034901-c1b8fa8bdcce // indirect
|
gopkg.in/natefinch/npipe.v2 v2.0.0-20160621034901-c1b8fa8bdcce // indirect
|
||||||
gopkg.in/yaml.v2 v2.4.0 // indirect
|
gopkg.in/yaml.v2 v2.4.0 // indirect
|
||||||
|
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||||
)
|
)
|
||||||
|
|||||||
6
go.sum
6
go.sum
@@ -142,9 +142,14 @@ github.com/rainycape/unidecode v0.0.0-20150907023854-cb7f23ec59be/go.mod h1:MIDF
|
|||||||
github.com/sentriz/gormstore v0.0.0-20220105134332-64e31f7f6981 h1:sLILANWN76ja66/K4k/mBqJuCjDZaM67w+Ru6rEB0s0=
|
github.com/sentriz/gormstore v0.0.0-20220105134332-64e31f7f6981 h1:sLILANWN76ja66/K4k/mBqJuCjDZaM67w+Ru6rEB0s0=
|
||||||
github.com/sentriz/gormstore v0.0.0-20220105134332-64e31f7f6981/go.mod h1:Rx8XB1ck+so+41uu9VY1gMKs1CPQ2NTq0pzf+OCCQHo=
|
github.com/sentriz/gormstore v0.0.0-20220105134332-64e31f7f6981/go.mod h1:Rx8XB1ck+so+41uu9VY1gMKs1CPQ2NTq0pzf+OCCQHo=
|
||||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||||
|
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
|
||||||
|
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
|
||||||
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
||||||
github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||||
|
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||||
|
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
|
||||||
github.com/stretchr/testify v1.8.1 h1:w7B6lhMri9wdJUVmEZPGGhZzrYTPvgJArz7wNPgYKsk=
|
github.com/stretchr/testify v1.8.1 h1:w7B6lhMri9wdJUVmEZPGGhZzrYTPvgJArz7wNPgYKsk=
|
||||||
|
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
|
||||||
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
|
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
|
||||||
golang.org/x/crypto v0.0.0-20181112202954-3d3f9f413869/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4=
|
golang.org/x/crypto v0.0.0-20181112202954-3d3f9f413869/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4=
|
||||||
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||||
@@ -214,3 +219,4 @@ gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C
|
|||||||
gopkg.in/yaml.v3 v3.0.0-20200615113413-eeeca48fe776/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
gopkg.in/yaml.v3 v3.0.0-20200615113413-eeeca48fe776/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
gopkg.in/yaml.v3 v3.0.0/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
gopkg.in/yaml.v3 v3.0.0/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||||
|
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
|
|||||||
@@ -27,33 +27,16 @@ var (
|
|||||||
ErrListenBrainz = errors.New("listenbrainz error")
|
ErrListenBrainz = errors.New("listenbrainz error")
|
||||||
)
|
)
|
||||||
|
|
||||||
// https://listenbrainz.readthedocs.io/en/latest/users/json.html#submission-json
|
type Scrobbler struct {
|
||||||
type Payload struct {
|
httpClient *http.Client
|
||||||
ListenedAt int `json:"listened_at,omitempty"`
|
|
||||||
TrackMetadata *TrackMetadata `json:"track_metadata"`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type AdditionalInfo struct {
|
func NewScrobbler() *Scrobbler {
|
||||||
TrackNumber int `json:"tracknumber,omitempty"`
|
return &Scrobbler{
|
||||||
TrackMBID string `json:"track_mbid,omitempty"`
|
httpClient: http.DefaultClient,
|
||||||
RecordingMBID string `json:"recording_mbid,omitempty"`
|
}
|
||||||
TrackLength int `json:"track_length,omitempty"`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
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 {
|
func (s *Scrobbler) Scrobble(user *db.User, track *db.Track, stamp time.Time, submission bool) error {
|
||||||
if user.ListenBrainzURL == "" || user.ListenBrainzToken == "" {
|
if user.ListenBrainzURL == "" || user.ListenBrainzToken == "" {
|
||||||
return nil
|
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, _ := http.NewRequest(http.MethodPost, submitURL, &payloadBuf)
|
||||||
req.Header.Add("Content-Type", "application/json")
|
req.Header.Add("Content-Type", "application/json")
|
||||||
req.Header.Add("Authorization", authHeader)
|
req.Header.Add("Authorization", authHeader)
|
||||||
resp, err := http.DefaultClient.Do(req)
|
resp, err := s.httpClient.Do(req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("http post: %w", err)
|
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 {
|
switch {
|
||||||
case resp.StatusCode == http.StatusUnauthorized:
|
case resp.StatusCode == http.StatusUnauthorized:
|
||||||
return fmt.Errorf("unathorized: %w", ErrListenBrainz)
|
return fmt.Errorf("unauthorized: %w", ErrListenBrainz)
|
||||||
case resp.StatusCode >= 400:
|
case resp.StatusCode >= 400:
|
||||||
respBytes, _ := httputil.DumpResponse(resp, true)
|
respBytes, _ := httputil.DumpResponse(resp, true)
|
||||||
log.Printf("received bad listenbrainz response:\n%s", string(respBytes))
|
log.Printf("received bad listenbrainz response:\n%s", string(respBytes))
|
||||||
|
|||||||
148
scrobble/listenbrainz/listenbrainz_test.go
Normal file
148
scrobble/listenbrainz/listenbrainz_test.go
Normal 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)
|
||||||
|
}
|
||||||
29
scrobble/listenbrainz/model.go
Normal file
29
scrobble/listenbrainz/model.go
Normal 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"`
|
||||||
|
}
|
||||||
|
)
|
||||||
16
scrobble/listenbrainz/testdata/submit_listens_response.json
vendored
Normal file
16
scrobble/listenbrainz/testdata/submit_listens_response.json
vendored
Normal 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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -107,7 +107,7 @@ func New(opts Options) (*Server, error) {
|
|||||||
PodcastsPath: opts.PodcastPath,
|
PodcastsPath: opts.PodcastPath,
|
||||||
CacheAudioPath: opts.CacheAudioPath,
|
CacheAudioPath: opts.CacheAudioPath,
|
||||||
CoverCachePath: opts.CoverCachePath,
|
CoverCachePath: opts.CoverCachePath,
|
||||||
Scrobblers: []scrobble.Scrobbler{&lastfm.Scrobbler{DB: opts.DB}, &listenbrainz.Scrobbler{}},
|
Scrobblers: []scrobble.Scrobbler{&lastfm.Scrobbler{DB: opts.DB}, listenbrainz.NewScrobbler()},
|
||||||
Podcasts: podcast,
|
Podcasts: podcast,
|
||||||
Transcoder: cacheTranscoder,
|
Transcoder: cacheTranscoder,
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user