feat: add Spotify OAuth service with token management

This commit is contained in:
Tim Van Wassenhove
2026-02-21 00:21:52 +01:00
committed by Tobias Gesellchen
parent be7e44e14b
commit 395b2fec8e
2 changed files with 869 additions and 0 deletions
+443
View File
@@ -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
}
+426
View File
@@ -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)
}
}