diff --git a/pkg/service/spotify/service.go b/pkg/service/spotify/service.go new file mode 100644 index 0000000..2aad5e0 --- /dev/null +++ b/pkg/service/spotify/service.go @@ -0,0 +1,443 @@ +// Package spotify provides Spotify OAuth integration and token management +// for the SoundTouch service, ported from soundcork's Python implementation. +package spotify + +import ( + "encoding/json" + "fmt" + "io" + "log" + "net/http" + "net/url" + "os" + "path/filepath" + "strings" + "sync" + "time" +) + +const ( + // SpotifyAuthorizeURL is the Spotify OAuth authorization endpoint. + SpotifyAuthorizeURL = "https://accounts.spotify.com/authorize" + // SpotifyTokenURL is the Spotify OAuth token endpoint. + SpotifyTokenURL = "https://accounts.spotify.com/api/token" + // SpotifyAPIBase is the base URL for the Spotify Web API. + SpotifyAPIBase = "https://api.spotify.com/v1" + // SpotifyScopes are the OAuth scopes required for speaker playback and user info. + SpotifyScopes = "streaming user-read-private user-read-email user-read-playback-state user-modify-playback-state" +) + +// SpotifyAccount represents a stored Spotify account with tokens. +type SpotifyAccount struct { + UserID string `json:"user_id"` + DisplayName string `json:"display_name"` + Email string `json:"email"` + AccessToken string `json:"access_token"` + RefreshToken string `json:"refresh_token"` + ExpiresAt int64 `json:"expires_at"` +} + +// SpotifyService manages Spotify OAuth flow and token lifecycle. +type SpotifyService struct { + clientID string + clientSecret string + redirectURI string + dataDir string + mu sync.RWMutex + accounts map[string]*SpotifyAccount + + // Overridable URLs for testing + tokenURL string + apiBase string +} + +// NewSpotifyService creates a new SpotifyService and loads any persisted accounts. +func NewSpotifyService(clientID, clientSecret, redirectURI, dataDir string) *SpotifyService { + s := &SpotifyService{ + clientID: clientID, + clientSecret: clientSecret, + redirectURI: redirectURI, + dataDir: dataDir, + accounts: make(map[string]*SpotifyAccount), + tokenURL: SpotifyTokenURL, + apiBase: SpotifyAPIBase, + } + if err := s.load(); err != nil { + log.Printf("[Spotify] Failed to load accounts: %v", err) + } + return s +} + +// BuildAuthorizeURL constructs the Spotify OAuth authorization URL. +func (s *SpotifyService) BuildAuthorizeURL() string { + params := url.Values{ + "client_id": {s.clientID}, + "response_type": {"code"}, + "redirect_uri": {s.redirectURI}, + "scope": {SpotifyScopes}, + } + return SpotifyAuthorizeURL + "?" + params.Encode() +} + +// ExchangeCodeAndStore exchanges an authorization code for tokens, +// fetches the user profile, and stores the account. +func (s *SpotifyService) ExchangeCodeAndStore(code string) error { + // Exchange code for tokens + tokenResp, err := s.exchangeCode(code) + if err != nil { + return fmt.Errorf("token exchange: %w", err) + } + + accessToken, _ := tokenResp["access_token"].(string) + refreshToken, _ := tokenResp["refresh_token"].(string) + expiresIn, _ := tokenResp["expires_in"].(float64) + if expiresIn == 0 { + expiresIn = 3600 + } + + // Fetch user profile + profile, err := s.getUserProfile(accessToken) + if err != nil { + return fmt.Errorf("fetch profile: %w", err) + } + + userID, _ := profile["id"].(string) + displayName, _ := profile["display_name"].(string) + email, _ := profile["email"].(string) + + account := &SpotifyAccount{ + UserID: userID, + DisplayName: displayName, + Email: email, + AccessToken: accessToken, + RefreshToken: refreshToken, + ExpiresAt: time.Now().Unix() + int64(expiresIn), + } + + s.mu.Lock() + s.accounts[userID] = account + s.mu.Unlock() + + if err := s.save(); err != nil { + return fmt.Errorf("save accounts: %w", err) + } + + log.Printf("[Spotify] Account linked: %s (%s)", displayName, userID) + return nil +} + +func (s *SpotifyService) exchangeCode(code string) (map[string]interface{}, error) { + data := url.Values{ + "grant_type": {"authorization_code"}, + "code": {code}, + "redirect_uri": {s.redirectURI}, + } + + req, err := http.NewRequest(http.MethodPost, s.tokenURL, strings.NewReader(data.Encode())) + if err != nil { + return nil, err + } + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + req.SetBasicAuth(s.clientID, s.clientSecret) + + resp, err := http.DefaultClient.Do(req) + if err != nil { + return nil, fmt.Errorf("token request: %w", err) + } + defer resp.Body.Close() + + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("read response: %w", err) + } + + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("token exchange failed (%d): %s", resp.StatusCode, string(body)) + } + + var result map[string]interface{} + if err := json.Unmarshal(body, &result); err != nil { + return nil, fmt.Errorf("parse response: %w", err) + } + return result, nil +} + +func (s *SpotifyService) getUserProfile(accessToken string) (map[string]interface{}, error) { + req, err := http.NewRequest(http.MethodGet, s.apiBase+"/me", nil) + if err != nil { + return nil, err + } + req.Header.Set("Authorization", "Bearer "+accessToken) + + resp, err := http.DefaultClient.Do(req) + if err != nil { + return nil, fmt.Errorf("profile request: %w", err) + } + defer resp.Body.Close() + + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("read response: %w", err) + } + + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("profile fetch failed (%d): %s", resp.StatusCode, string(body)) + } + + var result map[string]interface{} + if err := json.Unmarshal(body, &result); err != nil { + return nil, fmt.Errorf("parse profile: %w", err) + } + return result, nil +} + +// RefreshAccessToken refreshes the access token for the given account. +func (s *SpotifyService) RefreshAccessToken(account *SpotifyAccount) error { + data := url.Values{ + "grant_type": {"refresh_token"}, + "refresh_token": {account.RefreshToken}, + } + + req, err := http.NewRequest(http.MethodPost, s.tokenURL, strings.NewReader(data.Encode())) + if err != nil { + return err + } + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + req.SetBasicAuth(s.clientID, s.clientSecret) + + resp, err := http.DefaultClient.Do(req) + if err != nil { + return fmt.Errorf("refresh request: %w", err) + } + defer resp.Body.Close() + + body, err := io.ReadAll(resp.Body) + if err != nil { + return fmt.Errorf("read response: %w", err) + } + + if resp.StatusCode != http.StatusOK { + return fmt.Errorf("token refresh failed (%d): %s", resp.StatusCode, string(body)) + } + + var result map[string]interface{} + if err := json.Unmarshal(body, &result); err != nil { + return fmt.Errorf("parse response: %w", err) + } + + s.mu.Lock() + account.AccessToken, _ = result["access_token"].(string) + expiresIn, _ := result["expires_in"].(float64) + if expiresIn == 0 { + expiresIn = 3600 + } + account.ExpiresAt = time.Now().Unix() + int64(expiresIn) + if newRefresh, ok := result["refresh_token"].(string); ok && newRefresh != "" { + account.RefreshToken = newRefresh + } + s.mu.Unlock() + + if err := s.save(); err != nil { + return fmt.Errorf("save accounts: %w", err) + } + + return nil +} + +// GetFreshToken returns a valid access token and username, refreshing if needed. +func (s *SpotifyService) GetFreshToken() (accessToken, username string, err error) { + s.mu.RLock() + if len(s.accounts) == 0 { + s.mu.RUnlock() + return "", "", fmt.Errorf("no Spotify accounts linked") + } + + // Get the first account + var account *SpotifyAccount + for _, a := range s.accounts { + account = a + break + } + s.mu.RUnlock() + + // Check if token needs refresh (expired or within 60s of expiry) + if account.ExpiresAt < time.Now().Unix()+60 { + if err := s.RefreshAccessToken(account); err != nil { + return "", "", fmt.Errorf("refresh token: %w", err) + } + } + + s.mu.RLock() + defer s.mu.RUnlock() + return account.AccessToken, account.UserID, nil +} + +// GetAccounts returns a copy of all accounts with tokens stripped for API responses. +func (s *SpotifyService) GetAccounts() []SpotifyAccount { + s.mu.RLock() + defer s.mu.RUnlock() + + result := make([]SpotifyAccount, 0, len(s.accounts)) + for _, a := range s.accounts { + result = append(result, SpotifyAccount{ + UserID: a.UserID, + DisplayName: a.DisplayName, + Email: a.Email, + ExpiresAt: a.ExpiresAt, + // AccessToken and RefreshToken deliberately omitted + }) + } + return result +} + +// ResolveEntity resolves a Spotify URI to a name and image URL. +func (s *SpotifyService) ResolveEntity(uri string) (name, imageURL string, err error) { + entityType, entityID, err := parseSpotifyURI(uri) + if err != nil { + return "", "", err + } + + accessToken, _, err := s.GetFreshToken() + if err != nil { + return "", "", fmt.Errorf("get token: %w", err) + } + + apiURL := fmt.Sprintf("%s/%s/%s", s.apiBase, entityType, entityID) + req, err := http.NewRequest(http.MethodGet, apiURL, nil) + if err != nil { + return "", "", err + } + req.Header.Set("Authorization", "Bearer "+accessToken) + + resp, err := http.DefaultClient.Do(req) + if err != nil { + return "", "", fmt.Errorf("API request: %w", err) + } + defer resp.Body.Close() + + body, err := io.ReadAll(resp.Body) + if err != nil { + return "", "", fmt.Errorf("read response: %w", err) + } + + if resp.StatusCode == http.StatusNotFound { + return "", "", fmt.Errorf("Spotify entity not found") + } + if resp.StatusCode != http.StatusOK { + return "", "", fmt.Errorf("Spotify API error (%d): %s", resp.StatusCode, string(body)) + } + + var data map[string]interface{} + if err := json.Unmarshal(body, &data); err != nil { + return "", "", fmt.Errorf("parse response: %w", err) + } + + name, _ = data["name"].(string) + if name == "" { + name = "Unknown" + } + + // Extract image URL — location varies by entity type + imageURL = extractImageURL(data, entityType) + + return name, imageURL, nil +} + +// extractImageURL extracts the first image URL from a Spotify API response. +// For tracks, images are stored on the album object. +func extractImageURL(data map[string]interface{}, entityType string) string { + images, _ := data["images"].([]interface{}) + if len(images) == 0 && entityType == "tracks" { + // Tracks store images on the album + album, _ := data["album"].(map[string]interface{}) + if album != nil { + images, _ = album["images"].([]interface{}) + } + } + + if len(images) > 0 { + if img, ok := images[0].(map[string]interface{}); ok { + url, _ := img["url"].(string) + return url + } + } + return "" +} + +// parseSpotifyURI parses a Spotify URI like "spotify:track:abc" into +// the pluralized API type ("tracks") and ID ("abc"). +func parseSpotifyURI(uri string) (entityType, entityID string, err error) { + parts := strings.Split(uri, ":") + if len(parts) != 3 || parts[0] != "spotify" { + return "", "", fmt.Errorf("invalid Spotify URI format: %s", uri) + } + + typ := parts[1] + id := parts[2] + + validTypes := map[string]string{ + "track": "tracks", + "album": "albums", + "playlist": "playlists", + "artist": "artists", + } + + plural, ok := validTypes[typ] + if !ok { + return "", "", fmt.Errorf("unsupported Spotify entity type: %s", typ) + } + + return plural, id, nil +} + +// save persists accounts to disk as JSON. +func (s *SpotifyService) save() error { + s.mu.RLock() + data := make(map[string]*SpotifyAccount, len(s.accounts)) + for k, v := range s.accounts { + data[k] = v + } + s.mu.RUnlock() + + dir := filepath.Join(s.dataDir, "spotify") + if err := os.MkdirAll(dir, 0755); err != nil { + return fmt.Errorf("create directory: %w", err) + } + + jsonData, err := json.MarshalIndent(data, "", " ") + if err != nil { + return fmt.Errorf("marshal accounts: %w", err) + } + + path := filepath.Join(dir, "accounts.json") + if err := os.WriteFile(path, jsonData, 0600); err != nil { + return fmt.Errorf("write file: %w", err) + } + + return nil +} + +// load reads persisted accounts from disk. +func (s *SpotifyService) load() error { + path := filepath.Join(s.dataDir, "spotify", "accounts.json") + + jsonData, err := os.ReadFile(path) + if err != nil { + if os.IsNotExist(err) { + return nil // No accounts file yet, not an error + } + return fmt.Errorf("read file: %w", err) + } + + var accounts map[string]*SpotifyAccount + if err := json.Unmarshal(jsonData, &accounts); err != nil { + return fmt.Errorf("unmarshal accounts: %w", err) + } + + s.mu.Lock() + s.accounts = accounts + s.mu.Unlock() + + log.Printf("[Spotify] Loaded %d account(s)", len(accounts)) + return nil +} diff --git a/pkg/service/spotify/service_test.go b/pkg/service/spotify/service_test.go new file mode 100644 index 0000000..788d9db --- /dev/null +++ b/pkg/service/spotify/service_test.go @@ -0,0 +1,426 @@ +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()) + + url := svc.BuildAuthorizeURL() + + 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.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"] = &SpotifyAccount{ + 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"] = &SpotifyAccount{ + 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"] = &SpotifyAccount{ + 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"] = &SpotifyAccount{ + UserID: "user1", + DisplayName: "Test User", + Email: "test@example.com", + AccessToken: "at", + RefreshToken: "rt", + ExpiresAt: 1234567890, + } + svc.accounts["user2"] = &SpotifyAccount{ + 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) + + 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"] = &SpotifyAccount{ + 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) + } +}