mirror of
https://github.com/Dvorinka/SpotifyRecAlg.git
synced 2026-06-03 20:13:03 +00:00
217 lines
7.5 KiB
Go
217 lines
7.5 KiB
Go
package provider_test
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/tdvorak/spotifyrecalg/apps/backend/internal/provider"
|
|
"github.com/tdvorak/spotifyrecalg/apps/backend/internal/provider/musicbrainz"
|
|
"github.com/tdvorak/spotifyrecalg/apps/backend/internal/provider/songlink"
|
|
"github.com/tdvorak/spotifyrecalg/apps/backend/internal/provider/spotify"
|
|
"github.com/tdvorak/spotifyrecalg/apps/backend/internal/provider/webplayer"
|
|
"github.com/tdvorak/spotifyrecalg/apps/backend/internal/recommendation"
|
|
"github.com/tdvorak/spotifyrecalg/apps/backend/internal/storage/memory"
|
|
)
|
|
|
|
func TestImportSpotifyTrackPersistsRecommendableTrack(t *testing.T) {
|
|
store := memory.New()
|
|
spotifyServer := fakeSpotifyServer(t)
|
|
defer spotifyServer.Close()
|
|
mbServer := fakeMusicBrainzServer(t)
|
|
defer mbServer.Close()
|
|
|
|
service := provider.NewService(store,
|
|
spotify.New(spotify.Config{BearerToken: "token", APIBaseURL: spotifyServer.URL + "/v1"}),
|
|
webplayer.NewClient(),
|
|
songlink.NewClient(),
|
|
musicbrainz.New(musicbrainz.Config{AppName: "SpotifyRecAlg", Contact: "test@example.com", BaseURL: mbServer.URL + "/ws/2", MinDelay: time.Nanosecond}),
|
|
provider.ServiceConfig{DefaultMarket: "US", CacheTTL: time.Hour},
|
|
)
|
|
|
|
resp, err := service.ImportSpotify(context.Background(), provider.ImportRequest{
|
|
Source: provider.Source{Type: "url", Value: "https://open.spotify.com/track/good"},
|
|
Market: "US",
|
|
EnrichMusicBrainz: boolPtr(true),
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("import spotify: %v", err)
|
|
}
|
|
if resp.ImportedTracks != 1 || resp.Skipped != 0 {
|
|
t.Fatalf("unexpected import response: %+v", resp)
|
|
}
|
|
|
|
engine := recommendation.NewEngine(recommendation.EngineConfig{
|
|
ContentWeight: 0.5,
|
|
PopularityWeight: 0.2,
|
|
ExplorationWeight: 0.3,
|
|
DiversityLambda: 0.7,
|
|
})
|
|
recs, _, err := engine.Recommend(context.Background(), store, recommendation.RecommendRequest{UserID: "user", Limit: 1})
|
|
if err != nil {
|
|
t.Fatalf("recommend after import: %v", err)
|
|
}
|
|
if len(recs) != 1 || recs[0].Track.ID != "spotify:track:good" {
|
|
t.Fatalf("unexpected recommendations: %+v", recs)
|
|
}
|
|
if got := recs[0].Track.External["musicbrainz_recording_id"]; got != "mb-recording" {
|
|
t.Fatalf("musicbrainz recording id = %q", got)
|
|
}
|
|
}
|
|
|
|
func boolPtr(value bool) *bool {
|
|
return &value
|
|
}
|
|
|
|
func TestSearchSpotifyCapsLimitAndPersistFalse(t *testing.T) {
|
|
store := memory.New()
|
|
spotifyServer := fakeSpotifyServer(t)
|
|
defer spotifyServer.Close()
|
|
|
|
service := provider.NewService(store,
|
|
spotify.New(spotify.Config{BearerToken: "token", APIBaseURL: spotifyServer.URL + "/v1"}),
|
|
webplayer.NewClient(),
|
|
songlink.NewClient(),
|
|
nil,
|
|
provider.ServiceConfig{DefaultMarket: "US", CacheTTL: time.Hour},
|
|
)
|
|
|
|
resp, err := service.SearchSpotify(context.Background(), provider.SearchRequest{Query: "hello", Type: "track", Limit: 50, Persist: false})
|
|
if err != nil {
|
|
t.Fatalf("search spotify: %v", err)
|
|
}
|
|
if len(resp.Tracks) != 1 || resp.Persisted != 0 {
|
|
t.Fatalf("unexpected search response: %+v", resp)
|
|
}
|
|
if _, _, err := recommendation.NewEngine(recommendation.EngineConfig{}).Recommend(context.Background(), store, recommendation.RecommendRequest{UserID: "user", Limit: 1}); err == nil {
|
|
t.Fatal("expected empty catalog because persist=false")
|
|
}
|
|
}
|
|
|
|
func TestProviderCacheUsesStaleOnError(t *testing.T) {
|
|
store := memory.New()
|
|
spotifyServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
http.Error(w, "upstream down", http.StatusInternalServerError)
|
|
}))
|
|
defer spotifyServer.Close()
|
|
service := provider.NewService(store,
|
|
spotify.New(spotify.Config{BearerToken: "token", APIBaseURL: spotifyServer.URL + "/v1", MaxRetries: 1}),
|
|
webplayer.NewClient(),
|
|
songlink.NewClient(),
|
|
nil,
|
|
provider.ServiceConfig{DefaultMarket: "US", CacheTTL: time.Hour},
|
|
)
|
|
|
|
now := time.Now().UTC()
|
|
trackPayload := []byte(`{"id":"cached","name":"Cached","artists":[{"name":"Artist"}],"album":{"name":"Album"},"popularity":50}`)
|
|
if err := store.UpsertProviderCache(context.Background(), provider.CacheEntry{
|
|
Provider: provider.ProviderSpotify,
|
|
ItemType: "track",
|
|
ItemID: "cached",
|
|
Market: "US",
|
|
Payload: trackPayload,
|
|
FetchedAt: now.Add(-2 * time.Hour),
|
|
ExpiresAt: now.Add(-time.Hour),
|
|
}); err != nil {
|
|
t.Fatalf("upsert track cache: %v", err)
|
|
}
|
|
featuresPayload := []byte(`{"danceability":0.5,"energy":0.6,"loudness":-7,"speechiness":0.03,"acousticness":0.2,"instrumentalness":0,"liveness":0.1,"valence":0.4,"tempo":100,"time_signature":4,"key":1,"mode":1}`)
|
|
if err := store.UpsertProviderCache(context.Background(), provider.CacheEntry{
|
|
Provider: provider.ProviderSpotify,
|
|
ItemType: "audio_features",
|
|
ItemID: "cached",
|
|
Payload: featuresPayload,
|
|
FetchedAt: now.Add(-2 * time.Hour),
|
|
ExpiresAt: now.Add(-time.Hour),
|
|
}); err != nil {
|
|
t.Fatalf("upsert features cache: %v", err)
|
|
}
|
|
|
|
resp, err := service.ImportSpotify(context.Background(), provider.ImportRequest{
|
|
Source: provider.Source{Type: "url", Value: "https://open.spotify.com/track/cached"},
|
|
Market: "US",
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("import with stale cache: %v", err)
|
|
}
|
|
if resp.ImportedTracks != 1 || len(resp.Warnings) == 0 {
|
|
t.Fatalf("expected stale fallback import with warning, got %+v", resp)
|
|
}
|
|
}
|
|
|
|
func fakeSpotifyServer(t *testing.T) *httptest.Server {
|
|
t.Helper()
|
|
return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
w.Header().Set("Content-Type", "application/json")
|
|
switch r.URL.Path {
|
|
case "/v1/search":
|
|
if got := r.URL.Query().Get("limit"); got != "10" {
|
|
t.Fatalf("search limit = %q, want 10", got)
|
|
}
|
|
_ = json.NewEncoder(w).Encode(map[string]any{
|
|
"tracks": map[string]any{"items": []map[string]any{{"id": "good"}}},
|
|
})
|
|
case "/v1/tracks/good":
|
|
_ = json.NewEncoder(w).Encode(map[string]any{
|
|
"id": "good",
|
|
"name": "Good Song",
|
|
"artists": []map[string]any{{"id": "spotify-artist", "name": "Good Artist"}},
|
|
"album": map[string]any{"id": "album", "name": "Good Album", "release_date": "2024-01-01", "images": []map[string]any{{"url": "https://img.example/good.jpg"}}},
|
|
"duration_ms": 210000,
|
|
"popularity": 80,
|
|
"explicit": false,
|
|
"external_ids": map[string]string{"isrc": "USRC17607839"},
|
|
"external_urls": map[string]string{
|
|
"spotify": "https://open.spotify.com/track/good",
|
|
},
|
|
})
|
|
case "/v1/audio-features/good":
|
|
_ = json.NewEncoder(w).Encode(map[string]any{
|
|
"danceability": 0.7,
|
|
"energy": 0.8,
|
|
"loudness": -5.0,
|
|
"speechiness": 0.04,
|
|
"acousticness": 0.1,
|
|
"instrumentalness": 0.0,
|
|
"liveness": 0.12,
|
|
"valence": 0.6,
|
|
"tempo": 120,
|
|
"time_signature": 4,
|
|
"key": 2,
|
|
"mode": 1,
|
|
})
|
|
default:
|
|
http.NotFound(w, r)
|
|
}
|
|
}))
|
|
}
|
|
|
|
func fakeMusicBrainzServer(t *testing.T) *httptest.Server {
|
|
t.Helper()
|
|
return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
if got := r.Header.Get("User-Agent"); got == "" {
|
|
t.Fatal("missing User-Agent")
|
|
}
|
|
w.Header().Set("Content-Type", "application/json")
|
|
switch r.URL.Path {
|
|
case "/ws/2/isrc/USRC17607839":
|
|
_ = json.NewEncoder(w).Encode(map[string]any{
|
|
"recordings": []map[string]any{{
|
|
"id": "mb-recording",
|
|
"title": "Good Song",
|
|
"artist-credit": []map[string]any{{
|
|
"artist": map[string]string{"id": "mb-artist", "name": "Good Artist"},
|
|
}},
|
|
"isrcs": []string{"USRC17607839"},
|
|
"tags": []map[string]string{{"name": "indie"}},
|
|
}},
|
|
})
|
|
default:
|
|
http.NotFound(w, r)
|
|
}
|
|
}))
|
|
}
|