Files
SpotifyRecAlg/apps/backend/internal/provider/service_test.go
T
Tomas Dvorak 6e8fedf534 first commit
2026-04-13 17:46:58 +02:00

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)
}
}))
}