diff --git a/docs/reference/spotify-account-addition.md b/docs/reference/spotify-account-addition.md
index fcfdb8d..7392f37 100644
--- a/docs/reference/spotify-account-addition.md
+++ b/docs/reference/spotify-account-addition.md
@@ -121,15 +121,14 @@ curl -X POST "https://streaming.bose.com/streaming/account/[ACCOUNT_ID]/source"
---
-## 3. Local Device Notification (LISA API)
+### Local Device Sync (LISA API)
The app notifies the physical SoundTouch speaker about the new source. This is usually done via the device's management API on port 8090.
-### Request Details
+#### Modern Flow (OAuth)
- **Endpoint**: `http://[DEVICE_IP]:8090/setMusicServiceOAuthAccount`
- **Method**: `POST`
-
-### Payload (XML)
+- **Payload**:
```xml
[SPOTIFY_USER_ID]
@@ -138,23 +137,27 @@ The app notifies the physical SoundTouch speaker about the new source. This is u
```
-**Note**: In some cases, the app sends a wrapped message format if communicating over WebSockets:
+#### Marge-Sync Notification (Fall-back)
+If the speaker returns `1029 UNKNOWN_ACTION_ERROR`, it signifies the LISA API version is too old for the OAuth flow. Stockholm-based firmware often expects the account to be registered in Marge first, followed by a notification to sync.
+- **Endpoint**: `http://[DEVICE_IP]:8090/notification`
+- **Method**: `POST`
+- **Payload**:
```xml
-
-
-
-
- [USER]
- [TOKEN]
- token_version_3
-
-
-
+
+
+
+```
+
+#### Legacy Flow (Fall-back)
+For older firmware that doesn't use Marge for Spotify:
+- **Endpoint**: `http://[DEVICE_IP]:8090/setMusicServiceAccount`
+- **Method**: `POST`
+- **Payload**:
+```xml
+
+ [USER]
+ [TOKEN]
+
```
---
diff --git a/pkg/client/client.go b/pkg/client/client.go
index 1fcccb0..8010c1c 100644
--- a/pkg/client/client.go
+++ b/pkg/client/client.go
@@ -1157,6 +1157,19 @@ func (c *Client) post(endpoint string, payload interface{}) error {
if resp.StatusCode != http.StatusOK {
responseBody, _ := io.ReadAll(resp.Body)
+
+ // Try to parse as ErrorsResponse (speaker error format)
+ var errs models.ErrorsResponse
+ if xmlErr := xml.Unmarshal(responseBody, &errs); xmlErr == nil && len(errs.Errors) > 0 {
+ return &errs
+ }
+
+ // Try to parse as APIError (standard format)
+ var apiError models.APIError
+ if xmlErr := xml.Unmarshal(responseBody, &apiError); xmlErr == nil && apiError.Message != "" {
+ return &apiError
+ }
+
return fmt.Errorf("API request failed with status %d: %s", resp.StatusCode, string(responseBody))
}
@@ -1201,6 +1214,19 @@ func (c *Client) postWithResponse(endpoint string, payload, result interface{})
if resp.StatusCode != http.StatusOK {
responseBody, _ := io.ReadAll(resp.Body)
+
+ // Try to parse as ErrorsResponse (speaker error format)
+ var errs models.ErrorsResponse
+ if xmlErr := xml.Unmarshal(responseBody, &errs); xmlErr == nil && len(errs.Errors) > 0 {
+ return &errs
+ }
+
+ // Try to parse as APIError (standard format)
+ var apiError models.APIError
+ if xmlErr := xml.Unmarshal(responseBody, &apiError); xmlErr == nil && apiError.Message != "" {
+ return &apiError
+ }
+
return fmt.Errorf("API request failed with status %d: %s", resp.StatusCode, string(responseBody))
}
@@ -1213,6 +1239,11 @@ func (c *Client) postWithResponse(endpoint string, payload, result interface{})
// Parse the actual response first
if err := xml.Unmarshal(responseBody, result); err != nil {
// Check if it might be an API error response instead
+ var errs models.ErrorsResponse
+ if xmlErr := xml.Unmarshal(responseBody, &errs); xmlErr == nil && len(errs.Errors) > 0 {
+ return &errs
+ }
+
var apiError models.APIError
if xmlErr := xml.Unmarshal(responseBody, &apiError); xmlErr == nil && apiError.Message != "" {
return &apiError
@@ -1953,6 +1984,24 @@ func (c *Client) SetMusicServiceOAuthAccount(credentials *models.OAuthCredential
return nil
}
+// NotifySourcesUpdated notifies the device that sources have been updated in Marge
+func (c *Client) NotifySourcesUpdated(deviceID string) error {
+ notification := models.NewSourcesUpdatedNotification(deviceID)
+
+ var response models.MusicServiceAccountResponse
+
+ err := c.postWithResponse("/notification", notification, &response)
+ if err != nil {
+ return fmt.Errorf("failed to send sources updated notification: %w", err)
+ }
+
+ if response.Status != "/notification" {
+ return fmt.Errorf("sources updated notification failed: unexpected response %s", response.Status)
+ }
+
+ return nil
+}
+
// RemoveMusicServiceAccount removes an existing music service account
func (c *Client) RemoveMusicServiceAccount(credentials *models.MusicServiceCredentials) error {
if credentials == nil {
diff --git a/pkg/client/errors_test.go b/pkg/client/errors_test.go
new file mode 100644
index 0000000..529271e
--- /dev/null
+++ b/pkg/client/errors_test.go
@@ -0,0 +1,135 @@
+package client
+
+import (
+ "encoding/xml"
+ "errors"
+ "net/http"
+ "net/http/httptest"
+ "testing"
+
+ "github.com/gesellix/bose-soundtouch/pkg/models"
+)
+
+func TestClient_Post_ErrorsResponse(t *testing.T) {
+ // Mock speaker error response
+ errorXML := `
+
+ This version of SCM does not support spotify create account functionality.
+`
+
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ w.Header().Set("Content-Type", "application/xml")
+ w.WriteHeader(http.StatusInternalServerError)
+ _, _ = w.Write([]byte(errorXML))
+ }))
+ defer server.Close()
+
+ c := createTestClient(server.URL)
+
+ // Test post method
+ err := c.post("/test", nil)
+ if err == nil {
+ t.Fatal("expected error, got nil")
+ }
+
+ errs := &models.ErrorsResponse{}
+ ok := errors.As(err, &errs)
+ if !ok {
+ t.Fatalf("expected models.ErrorsResponse, got %T: %v", err, err)
+ }
+
+ if errs.DeviceID != "08DF1F0BA325" {
+ t.Errorf("expected DeviceID 08DF1F0BA325, got %s", errs.DeviceID)
+ }
+
+ if len(errs.Errors) != 1 {
+ t.Fatalf("expected 1 error, got %d", len(errs.Errors))
+ }
+
+ if errs.Errors[0].Value != 1029 {
+ t.Errorf("expected error value 1029, got %d", errs.Errors[0].Value)
+ }
+
+ if errs.Errors[0].Name != "UNKNOWN_ACTION_ERROR" {
+ t.Errorf("expected error name UNKNOWN_ACTION_ERROR, got %s", errs.Errors[0].Name)
+ }
+
+ expectedMsg := "This version of SCM does not support spotify create account functionality."
+ if errs.Errors[0].Message != expectedMsg {
+ t.Errorf("expected message '%s', got '%s'", expectedMsg, errs.Errors[0].Message)
+ }
+
+ if err.Error() != expectedMsg {
+ t.Errorf("expected Error() to return '%s', got '%s'", expectedMsg, err.Error())
+ }
+}
+
+func TestClient_PostWithResponse_ErrorsResponse(t *testing.T) {
+ // Mock speaker error response
+ errorXML := `
+
+ This version of SCM does not support spotify create account functionality.
+`
+
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ w.Header().Set("Content-Type", "application/xml")
+ w.WriteHeader(http.StatusInternalServerError)
+ _, _ = w.Write([]byte(errorXML))
+ }))
+ defer server.Close()
+
+ c := createTestClient(server.URL)
+
+ // Test postWithResponse method
+ var result struct {
+ XMLName xml.Name `xml:"status"`
+ Data string `xml:",chardata"`
+ }
+ err := c.postWithResponse("/test", nil, &result)
+ if err == nil {
+ t.Fatal("expected error, got nil")
+ }
+
+ errs := &models.ErrorsResponse{}
+ ok := errors.As(err, &errs)
+ if !ok {
+ t.Fatalf("expected models.ErrorsResponse, got %T: %v", err, err)
+ }
+
+ if errs.Errors[0].Value != 1029 {
+ t.Errorf("expected error value 1029, got %d", errs.Errors[0].Value)
+ }
+}
+
+func TestClient_Post_StandardAPIError(t *testing.T) {
+ // Mock standard API error response
+ errorXML := `Not Found`
+
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ w.Header().Set("Content-Type", "application/xml")
+ w.WriteHeader(http.StatusNotFound)
+ _, _ = w.Write([]byte(errorXML))
+ }))
+ defer server.Close()
+
+ c := createTestClient(server.URL)
+
+ err := c.post("/test", nil)
+ if err == nil {
+ t.Fatal("expected error, got nil")
+ }
+
+ apiErr := &models.APIError{}
+ ok := errors.As(err, &apiErr)
+ if !ok {
+ t.Fatalf("expected models.APIError, got %T: %v", err, err)
+ }
+
+ if apiErr.Code != 404 {
+ t.Errorf("expected code 404, got %d", apiErr.Code)
+ }
+
+ if apiErr.Message != "Not Found" {
+ t.Errorf("expected message 'Not Found', got '%s'", apiErr.Message)
+ }
+}
diff --git a/pkg/models/account.go b/pkg/models/account.go
index 08c0f76..3df73c7 100644
--- a/pkg/models/account.go
+++ b/pkg/models/account.go
@@ -141,7 +141,13 @@ type MusicServiceAccountResponse struct {
Status string `xml:",chardata"`
}
+// SourcesUpdatedResponse represents the response from /notification
+type SourcesUpdatedResponse struct {
+ XMLName xml.Name `xml:"status"`
+ Status string `xml:",chardata"`
+}
+
// IsSuccess returns true if the account operation was successful
func (resp *MusicServiceAccountResponse) IsSuccess() bool {
- return resp.Status == "/setMusicServiceAccount" || resp.Status == "/removeMusicServiceAccount"
+ return resp.Status == "/setMusicServiceAccount" || resp.Status == "/removeMusicServiceAccount" || resp.Status == "/notification"
}
diff --git a/pkg/models/device.go b/pkg/models/device.go
index c2ce4e4..206f465 100644
--- a/pkg/models/device.go
+++ b/pkg/models/device.go
@@ -36,6 +36,22 @@ type NetworkInfo struct {
IPAddress string `xml:"ipAddress"`
}
+// SourcesUpdatedNotification represents the notification XML sent to the device
+type SourcesUpdatedNotification struct {
+ XMLName xml.Name `xml:"updates"`
+ DeviceID string `xml:"deviceID,attr"`
+ Sources struct {
+ XMLName xml.Name `xml:"sourcesUpdated"`
+ } `xml:"sourcesUpdated"`
+}
+
+// NewSourcesUpdatedNotification creates a new sources updated notification
+func NewSourcesUpdatedNotification(deviceID string) *SourcesUpdatedNotification {
+ return &SourcesUpdatedNotification{
+ DeviceID: deviceID,
+ }
+}
+
// XMLResponse is a generic wrapper for API responses
type XMLResponse struct {
XMLName xml.Name
@@ -53,6 +69,29 @@ func (e *APIError) Error() string {
return e.Message
}
+// ErrorsResponse represents a multi-error response from the API (common in some firmware versions)
+type ErrorsResponse struct {
+ XMLName xml.Name `xml:"errors"`
+ DeviceID string `xml:"deviceID,attr"`
+ Errors []DeviceError `xml:"error"`
+}
+
+// Error implements the error interface for ErrorsResponse
+func (e *ErrorsResponse) Error() string {
+ if len(e.Errors) > 0 {
+ return e.Errors[0].Message
+ }
+
+ return "unknown API error"
+}
+
+// DeviceError represents a single error in an ErrorsResponse
+type DeviceError struct {
+ Value int `xml:"value,attr"`
+ Name string `xml:"name,attr"`
+ Message string `xml:",chardata"`
+}
+
// DiscoveredDevice represents a device found through network discovery
type DiscoveredDevice struct {
Name string `json:"name"`
diff --git a/pkg/service/handlers/handlers_mgmt.go b/pkg/service/handlers/handlers_mgmt.go
index 912243a..bdc3eb7 100644
--- a/pkg/service/handlers/handlers_mgmt.go
+++ b/pkg/service/handlers/handlers_mgmt.go
@@ -2,6 +2,7 @@ package handlers
import (
"encoding/json"
+ "errors"
"fmt"
"io"
"log"
@@ -97,7 +98,7 @@ func (s *Server) HandleMgmtDeviceEvents(w http.ResponseWriter, r *http.Request)
}
// HandleMgmtSpotifyInit starts the Spotify OAuth flow by returning an authorization URL.
-func (s *Server) HandleMgmtSpotifyInit(w http.ResponseWriter, _ *http.Request) {
+func (s *Server) HandleMgmtSpotifyInit(w http.ResponseWriter, r *http.Request) {
s.mu.RLock()
svc := s.spotifyService
s.mu.RUnlock()
@@ -107,7 +108,8 @@ func (s *Server) HandleMgmtSpotifyInit(w http.ResponseWriter, _ *http.Request) {
return
}
- redirectURL := svc.BuildAuthorizeURL()
+ state := r.URL.Query().Get("account")
+ redirectURL := svc.BuildAuthorizeURL(state)
w.Header().Set("Content-Type", "application/json")
enc := json.NewEncoder(w)
@@ -163,7 +165,12 @@ func (s *Server) HandleMgmtSpotifyCallback(w http.ResponseWriter, r *http.Reques
}
// Register account in Marge and notify speakers
- s.bridgeSpotifyToMarge(r.URL.Query().Get("account"))
+ accountID := r.URL.Query().Get("account")
+ if accountID == "" {
+ accountID = r.URL.Query().Get("state")
+ }
+
+ s.bridgeSpotifyToMarge(accountID)
w.Header().Set("Content-Type", "text/html")
_, _ = w.Write([]byte(`
Spotify Connected
You can close this window.
`))
@@ -196,7 +203,12 @@ func (s *Server) HandleMgmtSpotifyConfirm(w http.ResponseWriter, r *http.Request
}
// Register account in Marge and notify speakers
- s.bridgeSpotifyToMarge(r.URL.Query().Get("account"))
+ accountID := r.URL.Query().Get("account")
+ if accountID == "" {
+ accountID = r.URL.Query().Get("state")
+ }
+
+ s.bridgeSpotifyToMarge(accountID)
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
@@ -247,8 +259,6 @@ func (s *Server) bridgeSpotifyToMarge(accountID string) {
continue
}
- creds := models.NewSpotifyOAuthCredentials(acc.UserID, credential, acc.DisplayName)
-
for i := range allDevices {
dev := &allDevices[i]
if dev.AccountID != accountID && accountID != "default" {
@@ -263,8 +273,47 @@ func (s *Server) bridgeSpotifyToMarge(accountID string) {
log.Printf("[Spotify Bridge] Notifying speaker %s (%s) about new Spotify account", d.Name, d.IPAddress)
c := client.NewClientFromHost(d.IPAddress)
+ creds := models.NewSpotifyOAuthCredentials(acc.UserID, credential, acc.DisplayName)
+
if err := c.SetMusicServiceOAuthAccount(creds); err != nil {
- log.Printf("[Spotify Bridge] Failed to notify speaker %s: %v", d.Name, err)
+ log.Printf("[Spotify Bridge] Failed to notify speaker %s via OAuth: %v", d.Name, err)
+
+ // Fallback if OAuth is not supported (Error 1029)
+ errs := &models.ErrorsResponse{}
+ if errors.As(err, &errs) {
+ isUnsupported := false
+
+ for _, e := range errs.Errors {
+ if e.Value == 1029 {
+ isUnsupported = true
+ break
+ }
+ }
+
+ if isUnsupported {
+ log.Printf("[Spotify Bridge] Speaker %s doesn't support OAuth, falling back to Marge sync notification", d.Name)
+
+ // Some speakers (especially Stockholm-based) don't support /setMusicServiceOAuthAccount
+ // via LISA but will pick up the new source from Marge if notified.
+ if err := c.NotifySourcesUpdated(d.DeviceID); err != nil {
+ log.Printf("[Spotify Bridge] Sync notification failed for speaker %s: %v", d.Name, err)
+
+ // Final fallback to legacy account creation
+ log.Printf("[Spotify Bridge] Falling back to legacy account creation for speaker %s", d.Name)
+
+ legacyCreds := models.NewSpotifyCredentials(acc.UserID, credential)
+ if err := c.SetMusicServiceAccount(legacyCreds); err != nil {
+ log.Printf("[Spotify Bridge] Legacy fallback failed for speaker %s: %v", d.Name, err)
+ } else {
+ log.Printf("[Spotify Bridge] Legacy fallback successful for speaker %s", d.Name)
+ }
+ } else {
+ log.Printf("[Spotify Bridge] Sync notification successful for speaker %s", d.Name)
+ }
+
+ return
+ }
+ }
} else {
log.Printf("[Spotify Bridge] Successfully notified speaker %s", d.Name)
}
diff --git a/pkg/service/handlers/handlers_mgmt_test.go b/pkg/service/handlers/handlers_mgmt_test.go
index 1b824f1..5158beb 100644
--- a/pkg/service/handlers/handlers_mgmt_test.go
+++ b/pkg/service/handlers/handlers_mgmt_test.go
@@ -14,34 +14,35 @@ import (
func TestHandleMgmtSpotifyInit(t *testing.T) {
s := NewServer(nil, nil, "http://localhost", false, false, false)
- // No spotify service configured
- req := httptest.NewRequest("POST", "/mgmt/spotify/init", nil)
- w := httptest.NewRecorder()
- s.HandleMgmtSpotifyInit(w, req)
- if w.Code != http.StatusServiceUnavailable {
- t.Errorf("expected 503, got %d", w.Code)
- }
+ t.Run("POST - No spotify service configured", func(t *testing.T) {
+ req := httptest.NewRequest("POST", "/mgmt/spotify/init", nil)
+ w := httptest.NewRecorder()
+ s.HandleMgmtSpotifyInit(w, req)
+ if w.Code != http.StatusServiceUnavailable {
+ t.Errorf("expected 503, got %d", w.Code)
+ }
+ })
// With spotify service
svc := spotify.NewSpotifyService("cid", "secret", "http://localhost/cb", t.TempDir())
s.SetSpotifyService(svc)
- w = httptest.NewRecorder()
- s.HandleMgmtSpotifyInit(w, req)
-
- if w.Code != http.StatusOK {
- t.Errorf("expected 200, got %d", w.Code)
- }
-
- var resp map[string]string
- if err := json.NewDecoder(w.Body).Decode(&resp); err != nil {
- t.Fatal(err)
- }
-
- if !strings.Contains(resp["redirectUrl"], "client_id=cid") {
- t.Errorf("expected redirectUrl to contain client_id=cid, got %s", resp["redirectUrl"])
- }
+ t.Run("POST - Success", func(t *testing.T) {
+ req := httptest.NewRequest("POST", "/mgmt/spotify/init", nil)
+ w := httptest.NewRecorder()
+ s.HandleMgmtSpotifyInit(w, req)
+ if w.Code != http.StatusOK {
+ t.Errorf("expected 200, got %d", w.Code)
+ }
+ var resp map[string]string
+ if err := json.NewDecoder(w.Body).Decode(&resp); err != nil {
+ t.Fatal(err)
+ }
+ if !strings.Contains(resp["redirectUrl"], "client_id=cid") {
+ t.Errorf("expected redirectUrl to contain client_id=cid, got %s", resp["redirectUrl"])
+ }
+ })
}
func TestHandleMgmtSpotifyAccounts(t *testing.T) {
diff --git a/pkg/service/handlers/web/index.html b/pkg/service/handlers/web/index.html
index cbaf711..06c070d 100644
--- a/pkg/service/handlers/web/index.html
+++ b/pkg/service/handlers/web/index.html
@@ -1463,6 +1463,21 @@
Loading...
+
+
Spotify Integration
+
+ Register a new Spotify source for this local account. This mimics the official SoundTouch app flow:
+
+
+ - Exchange OAuth code for a Bose-mediated token.
+ - Register the source in the local Marge cloud profile.
+
+
+
+
+
Connected Devices
Select an account to view devices.
diff --git a/pkg/service/handlers/web/js/script.js b/pkg/service/handlers/web/js/script.js
index 3176221..a06f56f 100644
--- a/pkg/service/handlers/web/js/script.js
+++ b/pkg/service/handlers/web/js/script.js
@@ -492,7 +492,9 @@ async function fetchAccountDetails(accountId) {
if (!accountId) return;
const metadataEl = document.getElementById("account-metadata");
const devicesEl = document.getElementById("account-devices-list");
+ const regStatus = document.getElementById("spotify-reg-status");
+ if (regStatus) regStatus.innerText = "";
if (metadataEl) metadataEl.innerHTML = "Loading...";
if (devicesEl) devicesEl.innerHTML = "Loading devices...";
@@ -761,6 +763,50 @@ async function fetchAccountDetails(accountId) {
}
}
+async function connectSpotifyToAccount() {
+ const selector = document.getElementById("account-selector");
+ const accountId = selector ? selector.value : "default";
+ const statusEl = document.getElementById("spotify-reg-status");
+
+ if (statusEl) statusEl.innerHTML = "Initializing Spotify authorization...";
+
+ try {
+ const response = await fetch(`/mgmt/spotify/init?account=${encodeURIComponent(accountId)}`, {
+ method: "POST"
+ });
+ if (!response.ok) {
+ const err = await response.text();
+ throw new Error(err || response.statusText);
+ }
+
+ const data = await response.json();
+ const redirectUrl = data.redirectUrl;
+
+ if (statusEl) {
+ statusEl.innerHTML = `Spotify authorization window opened.
If it didn't open,
click here to authorize.`;
+ }
+
+ // Open Spotify auth in a new window
+ window.open(redirectUrl, "SpotifyAuth", "width=600,height=800");
+
+ // Simple poll to see when we might be done (refresh every 5s for 5 mins)
+ let pollCount = 0;
+ const interval = setInterval(async () => {
+ pollCount++;
+ if (pollCount > 60) {
+ clearInterval(interval);
+ return;
+ }
+ // Refresh account details to see if source appeared
+ await fetchAccountDetails(accountId);
+ }, 5000);
+
+ } catch (error) {
+ if (statusEl) statusEl.innerHTML = `
Error: ${error.message}`;
+ console.error("Spotify link failed", error);
+ }
+}
+
async function fetchInteractionStats() {
console.log("Fetching interaction stats...");
try {
diff --git a/pkg/service/spotify/service.go b/pkg/service/spotify/service.go
index d33809c..5309de0 100644
--- a/pkg/service/spotify/service.go
+++ b/pkg/service/spotify/service.go
@@ -86,13 +86,16 @@ func (s *Service) SetEndpoints(tokenURL, apiBase string) {
}
// BuildAuthorizeURL constructs the Spotify OAuth authorization URL.
-func (s *Service) BuildAuthorizeURL() string {
+func (s *Service) BuildAuthorizeURL(state string) string {
params := url.Values{
"client_id": {s.clientID},
"response_type": {"code"},
"redirect_uri": {s.redirectURI},
"scope": {SpotifyScopes},
}
+ if state != "" {
+ params.Set("state", state)
+ }
return SpotifyAuthorizeURL + "?" + params.Encode()
}
diff --git a/pkg/service/spotify/service_test.go b/pkg/service/spotify/service_test.go
index 77592b8..f48d0ef 100644
--- a/pkg/service/spotify/service_test.go
+++ b/pkg/service/spotify/service_test.go
@@ -14,7 +14,8 @@ import (
func TestBuildAuthorizeURL(t *testing.T) {
svc := NewSpotifyService("test-client-id", "test-secret", "http://localhost/callback", t.TempDir())
- url := svc.BuildAuthorizeURL()
+ 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)
@@ -28,6 +29,9 @@ func TestBuildAuthorizeURL(t *testing.T) {
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)
}