package spotify import ( "encoding/json" "net/http" "net/http/httptest" "os" "path/filepath" "strings" "testing" "time" ) func TestBuildAuthorizeURL(t *testing.T) { svc := NewSpotifyService("test-client-id", "test-secret", "http://localhost/callback", t.TempDir()) state := "test-state" url := svc.BuildAuthorizeURL(state) if !strings.Contains(url, "client_id=test-client-id") { t.Errorf("URL should contain client_id, got: %s", url) } if !strings.Contains(url, "redirect_uri=") { t.Errorf("URL should contain redirect_uri, got: %s", url) } if !strings.Contains(url, "scope=") { t.Errorf("URL should contain scope, got: %s", url) } if !strings.Contains(url, "response_type=code") { t.Errorf("URL should contain response_type=code, got: %s", url) } if !strings.Contains(url, "state=test-state") { t.Errorf("URL should contain state=test-state, got: %s", url) } if !strings.HasPrefix(url, SpotifyAuthorizeURL) { t.Errorf("URL should start with %s, got: %s", SpotifyAuthorizeURL, url) } } func TestGetAccountsStripsTokens(t *testing.T) { svc := NewSpotifyService("cid", "csecret", "http://localhost/cb", t.TempDir()) // Manually add an account with tokens svc.mu.Lock() svc.accounts["user1"] = &Account{ UserID: "user1", DisplayName: "Test User", Email: "test@example.com", AccessToken: "secret-access-token", RefreshToken: "secret-refresh-token", ExpiresAt: time.Now().Add(1 * time.Hour).Unix(), } svc.mu.Unlock() accounts := svc.GetAccounts() if len(accounts) != 1 { t.Fatalf("expected 1 account, got %d", len(accounts)) } if accounts[0].AccessToken != "" { t.Errorf("AccessToken should be stripped, got: %s", accounts[0].AccessToken) } if accounts[0].RefreshToken != "" { t.Errorf("RefreshToken should be stripped, got: %s", accounts[0].RefreshToken) } if accounts[0].UserID != "user1" { t.Errorf("UserID should be preserved, got: %s", accounts[0].UserID) } if accounts[0].DisplayName != "Test User" { t.Errorf("DisplayName should be preserved, got: %s", accounts[0].DisplayName) } } func TestGetFreshTokenRefreshesExpired(t *testing.T) { // Set up a mock Spotify token endpoint tokenServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { t.Errorf("expected POST, got %s", r.Method) } if err := r.ParseForm(); err != nil { t.Fatal(err) } if r.Form.Get("grant_type") != "refresh_token" { t.Errorf("expected grant_type=refresh_token, got %s", r.Form.Get("grant_type")) } if r.Form.Get("refresh_token") != "my-refresh-token" { t.Errorf("expected refresh_token=my-refresh-token, got %s", r.Form.Get("refresh_token")) } // Verify Basic Auth user, pass, ok := r.BasicAuth() if !ok || user != "cid" || pass != "csecret" { t.Errorf("expected Basic Auth cid:csecret, got %s:%s (ok=%v)", user, pass, ok) } w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(map[string]interface{}{ "access_token": "new-access-token", "token_type": "Bearer", "expires_in": 3600, "refresh_token": "new-refresh-token", }) })) defer tokenServer.Close() svc := NewSpotifyService("cid", "csecret", "http://localhost/cb", t.TempDir()) // Override the token URL for testing svc.tokenURL = tokenServer.URL // Add an account with an expired token svc.mu.Lock() svc.accounts["user1"] = &Account{ UserID: "user1", DisplayName: "Test User", AccessToken: "old-expired-token", RefreshToken: "my-refresh-token", ExpiresAt: time.Now().Add(-1 * time.Hour).Unix(), // expired } svc.mu.Unlock() accessToken, username, err := svc.GetFreshToken() if err != nil { t.Fatalf("unexpected error: %v", err) } if accessToken != "new-access-token" { t.Errorf("expected new-access-token, got %s", accessToken) } if username != "user1" { t.Errorf("expected user1, got %s", username) } // Verify the account was updated svc.mu.RLock() account := svc.accounts["user1"] svc.mu.RUnlock() if account.RefreshToken != "new-refresh-token" { t.Errorf("refresh token should be updated, got %s", account.RefreshToken) } } func TestResolveEntityParsesURI(t *testing.T) { tests := []struct { uri string expectedType string expectedID string shouldErr bool }{ {"spotify:track:abc123", "tracks", "abc123", false}, {"spotify:album:xyz789", "albums", "xyz789", false}, {"spotify:playlist:pl1", "playlists", "pl1", false}, {"spotify:artist:ar1", "artists", "ar1", false}, {"invalid-uri", "", "", true}, {"spotify:invalid_type:id", "", "", true}, {"spotify:track", "", "", true}, } for _, tc := range tests { t.Run(tc.uri, func(t *testing.T) { entityType, entityID, err := parseSpotifyURI(tc.uri) if tc.shouldErr { if err == nil { t.Errorf("expected error for URI %s", tc.uri) } return } if err != nil { t.Fatalf("unexpected error for URI %s: %v", tc.uri, err) } if entityType != tc.expectedType { t.Errorf("expected type %s, got %s", tc.expectedType, entityType) } if entityID != tc.expectedID { t.Errorf("expected id %s, got %s", tc.expectedID, entityID) } }) } } func TestResolveEntityFetchesFromAPI(t *testing.T) { // Mock Spotify API apiServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { // Check Authorization header auth := r.Header.Get("Authorization") if auth != "Bearer fresh-token" { t.Errorf("expected Bearer fresh-token, got %s", auth) } switch r.URL.Path { case "/tracks/abc123": json.NewEncoder(w).Encode(map[string]interface{}{ "name": "Test Track", "album": map[string]interface{}{ "images": []map[string]interface{}{ {"url": "http://img.example.com/track.jpg"}, }, }, }) case "/albums/xyz789": json.NewEncoder(w).Encode(map[string]interface{}{ "name": "Test Album", "images": []map[string]interface{}{ {"url": "http://img.example.com/album.jpg"}, }, }) default: http.NotFound(w, r) } })) defer apiServer.Close() svc := NewSpotifyService("cid", "csecret", "http://localhost/cb", t.TempDir()) svc.apiBase = apiServer.URL // Add a non-expired account svc.mu.Lock() svc.accounts["user1"] = &Account{ UserID: "user1", AccessToken: "fresh-token", RefreshToken: "refresh", ExpiresAt: time.Now().Add(1 * time.Hour).Unix(), } svc.mu.Unlock() // Test track (images come from album) name, imageURL, err := svc.ResolveEntity("spotify:track:abc123") if err != nil { t.Fatalf("unexpected error: %v", err) } if name != "Test Track" { t.Errorf("expected Test Track, got %s", name) } if imageURL != "http://img.example.com/track.jpg" { t.Errorf("expected track image URL, got %s", imageURL) } // Test album (images at top level) name, imageURL, err = svc.ResolveEntity("spotify:album:xyz789") if err != nil { t.Fatalf("unexpected error: %v", err) } if name != "Test Album" { t.Errorf("expected Test Album, got %s", name) } if imageURL != "http://img.example.com/album.jpg" { t.Errorf("expected album image URL, got %s", imageURL) } } func TestSaveAndLoad(t *testing.T) { dir := t.TempDir() // Create and populate svc := NewSpotifyService("cid", "csecret", "http://localhost/cb", dir) svc.mu.Lock() svc.accounts["user1"] = &Account{ UserID: "user1", DisplayName: "Test User", Email: "test@example.com", AccessToken: "at", RefreshToken: "rt", ExpiresAt: 1234567890, } svc.accounts["user2"] = &Account{ UserID: "user2", DisplayName: "User Two", Email: "two@example.com", AccessToken: "at2", RefreshToken: "rt2", ExpiresAt: 9876543210, } svc.mu.Unlock() // Save if err := svc.save(); err != nil { t.Fatalf("save failed: %v", err) } // Verify file exists accountsFile := filepath.Join(dir, "spotify", "accounts.json") if _, err := os.Stat(accountsFile); os.IsNotExist(err) { t.Fatal("accounts.json was not created") } // Load into new service svc2 := NewSpotifyService("cid", "csecret", "http://localhost/cb", dir) if err := svc2.Load(); err != nil { t.Fatalf("load failed: %v", err) } svc2.mu.RLock() defer svc2.mu.RUnlock() if len(svc2.accounts) != 2 { t.Fatalf("expected 2 accounts after load, got %d", len(svc2.accounts)) } u1, ok := svc2.accounts["user1"] if !ok { t.Fatal("user1 not found after load") } if u1.DisplayName != "Test User" { t.Errorf("expected Test User, got %s", u1.DisplayName) } if u1.AccessToken != "at" { t.Errorf("expected at, got %s", u1.AccessToken) } if u1.ExpiresAt != 1234567890 { t.Errorf("expected ExpiresAt 1234567890, got %d", u1.ExpiresAt) } } func TestExchangeCodeAndStore(t *testing.T) { // Mock token endpoint tokenServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if err := r.ParseForm(); err != nil { t.Fatal(err) } switch r.Form.Get("grant_type") { case "authorization_code": if r.Form.Get("code") != "test-auth-code" { t.Errorf("expected code=test-auth-code, got %s", r.Form.Get("code")) } user, pass, ok := r.BasicAuth() if !ok || user != "cid" || pass != "csecret" { t.Errorf("bad Basic Auth: %s:%s ok=%v", user, pass, ok) } w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(map[string]interface{}{ "access_token": "new-at", "refresh_token": "new-rt", "expires_in": 3600, }) default: t.Errorf("unexpected grant_type: %s", r.Form.Get("grant_type")) http.Error(w, "bad request", 400) } })) defer tokenServer.Close() // Mock profile endpoint profileServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { auth := r.Header.Get("Authorization") if auth != "Bearer new-at" { t.Errorf("expected Bearer new-at, got %s", auth) } json.NewEncoder(w).Encode(map[string]interface{}{ "id": "spotify-user-123", "display_name": "Spotify User", "email": "user@spotify.com", }) })) defer profileServer.Close() dir := t.TempDir() svc := NewSpotifyService("cid", "csecret", "http://localhost/cb", dir) svc.tokenURL = tokenServer.URL svc.apiBase = profileServer.URL err := svc.ExchangeCodeAndStore("test-auth-code") if err != nil { t.Fatalf("ExchangeCodeAndStore failed: %v", err) } // Verify account stored svc.mu.RLock() account, ok := svc.accounts["spotify-user-123"] svc.mu.RUnlock() if !ok { t.Fatal("account not found after exchange") } if account.DisplayName != "Spotify User" { t.Errorf("expected Spotify User, got %s", account.DisplayName) } if account.Email != "user@spotify.com" { t.Errorf("expected user@spotify.com, got %s", account.Email) } if account.AccessToken != "new-at" { t.Errorf("expected new-at, got %s", account.AccessToken) } if account.RefreshToken != "new-rt" { t.Errorf("expected new-rt, got %s", account.RefreshToken) } // Verify saved to disk accountsFile := filepath.Join(dir, "spotify", "accounts.json") data, err := os.ReadFile(accountsFile) if err != nil { t.Fatalf("failed to read accounts file: %v", err) } if !strings.Contains(string(data), "spotify-user-123") { t.Error("accounts file should contain the user ID") } } func TestGetFreshTokenNoAccounts(t *testing.T) { svc := NewSpotifyService("cid", "csecret", "http://localhost/cb", t.TempDir()) _, _, err := svc.GetFreshToken() if err == nil { t.Error("expected error when no accounts exist") } } func TestGetFreshTokenNotExpired(t *testing.T) { svc := NewSpotifyService("cid", "csecret", "http://localhost/cb", t.TempDir()) svc.mu.Lock() svc.accounts["user1"] = &Account{ UserID: "user1", AccessToken: "valid-token", RefreshToken: "rt", ExpiresAt: time.Now().Add(1 * time.Hour).Unix(), } svc.mu.Unlock() token, username, err := svc.GetFreshToken() if err != nil { t.Fatalf("unexpected error: %v", err) } if token != "valid-token" { t.Errorf("expected valid-token, got %s", token) } if username != "user1" { t.Errorf("expected user1, got %s", username) } }