mirror of
https://github.com/gesellix/Bose-SoundTouch.git
synced 2026-08-18 00:26:29 +00:00
feat: add Spotify OAuth service with token management
This commit is contained in:
committed by
Tobias Gesellchen
parent
be7e44e14b
commit
395b2fec8e
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user