diff --git a/Makefile b/Makefile
index 1ca6272..a221975 100644
--- a/Makefile
+++ b/Makefile
@@ -106,22 +106,17 @@ test-coverage:
check: fmt vet test test-http-client
test-http-client:
- @echo "Running HTTP client integration tests..."
- @docker network create soundtouch-test-net || true
- @docker build -t soundtouch-service-test .
- @docker run -d --name soundtouch-service --network soundtouch-test-net \
- -e PORT=8000 \
- -e SPOTIFY_CLIENT_ID=mock-id \
- -e SPOTIFY_CLIENT_SECRET=mock-secret \
- -v $(PWD)/tests/integration/testdata:/app/data \
- soundtouch-service-test
- @echo "Waiting for service to start..."
- @sleep 5
+ @echo "Starting services with docker-compose..."
+ @docker-compose -f docker-compose.yml -f docker-compose.ci.yml up -d --build
+ @echo "Waiting for services to start..."
+ @sleep 10
+ @echo "Running .http tests..."
@docker run --rm --network soundtouch-test-net \
- -v $(PWD)/tests/integration/http-client:/workdir \
+ -v "$(PWD)/tests/integration/http-client:/workdir" \
jetbrains/intellij-http-client:2026.1 \
--env-file /workdir/http-client.env.json \
--env ci \
+ /workdir/spotify_registration.http \
/workdir/create_account.http \
/workdir/register_device.http \
/workdir/customer_support.http \
@@ -150,11 +145,9 @@ test-http-client:
/workdir/unregister_device.http \
--report; \
EXIT_CODE=$$?; \
- docker logs soundtouch-service; \
- docker stop soundtouch-service; \
- docker rm soundtouch-service; \
- docker rmi soundtouch-service-test; \
- docker network rm soundtouch-test-net; \
+ docker-compose -f docker-compose.yml -f docker-compose.ci.yml logs soundtouch-service; \
+ docker-compose -f docker-compose.yml -f docker-compose.ci.yml logs spotify-mock; \
+ docker-compose -f docker-compose.yml -f docker-compose.ci.yml down; \
exit $$EXIT_CODE
fmt:
diff --git a/cmd/mock-spotify/main.go b/cmd/mock-spotify/main.go
new file mode 100644
index 0000000..81e7118
--- /dev/null
+++ b/cmd/mock-spotify/main.go
@@ -0,0 +1,23 @@
+// Package main provides a mock Spotify server for testing purposes.
+package main
+
+import (
+ "flag"
+ "fmt"
+ "log"
+ "net/http"
+
+ "github.com/gesellix/bose-soundtouch/pkg/testutils/spotify"
+)
+
+func main() {
+ port := flag.Int("port", 8080, "Port to listen on")
+
+ flag.Parse()
+
+ log.Printf("Starting mock Spotify server on port %d", *port)
+
+ if err := http.ListenAndServe(fmt.Sprintf(":%d", *port), spotify.NewSpotifyHandler()); err != nil {
+ log.Fatal(err)
+ }
+}
diff --git a/cmd/soundtouch-service/main.go b/cmd/soundtouch-service/main.go
index 7752a51..65d22b3 100644
--- a/cmd/soundtouch-service/main.go
+++ b/cmd/soundtouch-service/main.go
@@ -209,6 +209,16 @@ func main() {
Value: "ueberboese-login://spotify",
EnvVars: []string{"SPOTIFY_REDIRECT_URI"},
},
+ &cli.StringFlag{
+ Name: "spotify-token-url",
+ Usage: "Spotify OAuth token URL (for testing)",
+ EnvVars: []string{"SPOTIFY_TOKEN_URL"},
+ },
+ &cli.StringFlag{
+ Name: "spotify-api-base",
+ Usage: "Spotify API base URL (for testing)",
+ EnvVars: []string{"SPOTIFY_API_BASE"},
+ },
&cli.StringFlag{
Name: "mgmt-username",
Usage: "Management API username for HTTP Basic Auth",
@@ -305,6 +315,10 @@ func main() {
config.spotifyRedirectURI,
config.dataDir,
)
+ if config.spotifyTokenURL != "" || config.spotifyAPIBase != "" {
+ spotifyService.SetEndpoints(config.spotifyTokenURL, config.spotifyAPIBase)
+ }
+
if err := spotifyService.Load(); err != nil {
log.Printf("[Spotify] Failed to load accounts: %v", err)
}
@@ -439,6 +453,8 @@ type serviceConfig struct {
spotifyClientID string
spotifyClientSecret string
spotifyRedirectURI string
+ spotifyTokenURL string
+ spotifyAPIBase string
mgmtUsername string
mgmtPassword string
migrationEnabled bool
@@ -503,6 +519,8 @@ func loadConfig(c *cli.Context) serviceConfig {
spotifyClientID := c.String("spotify-client-id")
spotifyClientSecret := c.String("spotify-client-secret")
spotifyRedirectURI := c.String("spotify-redirect-uri")
+ spotifyTokenURL := c.String("spotify-token-url")
+ spotifyAPIBase := c.String("spotify-api-base")
mgmtUsername := c.String("mgmt-username")
mgmtPassword := c.String("mgmt-password")
mirrorEnabled := c.Bool("mirror-enabled")
@@ -536,6 +554,8 @@ func loadConfig(c *cli.Context) serviceConfig {
spotifyClientID: spotifyClientID,
spotifyClientSecret: spotifyClientSecret,
spotifyRedirectURI: spotifyRedirectURI,
+ spotifyTokenURL: spotifyTokenURL,
+ spotifyAPIBase: spotifyAPIBase,
mgmtUsername: mgmtUsername,
mgmtPassword: mgmtPassword,
migrationEnabled: migrationEnabled,
diff --git a/docker-compose.ci.yml b/docker-compose.ci.yml
new file mode 100644
index 0000000..284dc26
--- /dev/null
+++ b/docker-compose.ci.yml
@@ -0,0 +1,10 @@
+services:
+ soundtouch-service:
+ build: .
+ volumes:
+ - ./tests/integration/testdata:/app/data
+ environment:
+ - SPOTIFY_CLIENT_ID=mock-id
+ - SPOTIFY_CLIENT_SECRET=mock-secret
+ - SPOTIFY_TOKEN_URL=http://spotify-mock:8080/api/token
+ - SPOTIFY_API_BASE=http://spotify-mock:8080
diff --git a/docker-compose.yml b/docker-compose.yml
index de8dc11..0da58bd 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -8,6 +8,8 @@ services:
ports:
- "8000:8000"
- "8443:8443"
+ networks:
+ - soundtouch-test-net
environment:
- PORT=8000
- HTTPS_PORT=8443
@@ -35,6 +37,22 @@ services:
cpus: '0.25'
memory: 128M
+ spotify-mock:
+ image: golang:1.26.1-alpine
+ container_name: spotify-mock
+ working_dir: /app
+ volumes:
+ - .:/app
+ command: go run ./cmd/mock-spotify/main.go -port 8080
+ ports:
+ - "8081:8080"
+ networks:
+ - soundtouch-test-net
+
+networks:
+ soundtouch-test-net:
+ name: soundtouch-test-net
+
volumes:
soundtouch-data:
# Named volumes are preferred in Swarm. For multi-node persistence,
diff --git a/pkg/testutils/spotify/handlers.go b/pkg/testutils/spotify/handlers.go
new file mode 100644
index 0000000..3a45aca
--- /dev/null
+++ b/pkg/testutils/spotify/handlers.go
@@ -0,0 +1,96 @@
+// Package spotify provides shared handlers for mocking the Spotify API.
+package spotify
+
+import (
+ "encoding/json"
+ "log"
+ "net/http"
+)
+
+// NewSpotifyHandler returns a new http.Handler configured with Spotify mock endpoints.
+func NewSpotifyHandler() http.Handler {
+ mux := http.NewServeMux()
+
+ // OAuth Token Endpoint
+ mux.HandleFunc("/api/token", HandleToken)
+
+ // User Profile Endpoint
+ mux.HandleFunc("/v1/me", HandleMe)
+ mux.HandleFunc("/me", HandleMe)
+
+ return mux
+}
+
+// HandleToken simulates the Spotify OAuth token endpoint.
+func HandleToken(w http.ResponseWriter, r *http.Request) {
+ log.Printf("[Spotify Mock] Token request: %s", r.Method)
+
+ if r.Method != http.MethodPost {
+ http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
+ return
+ }
+
+ if err := r.ParseForm(); err != nil {
+ http.Error(w, "Bad request", http.StatusBadRequest)
+ return
+ }
+
+ grantType := r.FormValue("grant_type")
+ log.Printf("[Spotify Mock] Grant type: %s", grantType)
+
+ resp := map[string]interface{}{
+ "access_token": "mock-access-token",
+ "token_type": "Bearer",
+ "expires_in": 3600,
+ "refresh_token": "mock-refresh-token",
+ "scope": "user-read-private user-read-email",
+ }
+
+ switch grantType {
+ case "authorization_code":
+ code := r.FormValue("code")
+ if code == "" {
+ http.Error(w, `{"error":"invalid_grant"}`, http.StatusBadRequest)
+ return
+ }
+ case "refresh_token":
+ refreshToken := r.FormValue("refresh_token")
+ if refreshToken == "" {
+ http.Error(w, `{"error":"invalid_grant"}`, http.StatusBadRequest)
+ return
+ }
+ default:
+ http.Error(w, `{"error":"unsupported_grant_type"}`, http.StatusBadRequest)
+ return
+ }
+
+ w.Header().Set("Content-Type", "application/json")
+
+ if err := json.NewEncoder(w).Encode(resp); err != nil {
+ log.Printf("Error encoding token response: %v", err)
+ }
+}
+
+// HandleMe simulates the Spotify user profile endpoint.
+func HandleMe(w http.ResponseWriter, r *http.Request) {
+ log.Printf("[Spotify Mock] Profile request: %s", r.Method)
+
+ auth := r.Header.Get("Authorization")
+ if auth != "Bearer mock-access-token" {
+ http.Error(w, `{"error":"unauthorized"}`, http.StatusUnauthorized)
+ return
+ }
+
+ resp := map[string]interface{}{
+ "id": "mock-user-id",
+ "display_name": "Mock User",
+ "email": "mock@example.com",
+ "uri": "spotify:user:mock-user-id",
+ }
+
+ w.Header().Set("Content-Type", "application/json")
+
+ if err := json.NewEncoder(w).Encode(resp); err != nil {
+ log.Printf("Error encoding profile response: %v", err)
+ }
+}
diff --git a/tests/integration/http-client/http-client.env.json b/tests/integration/http-client/http-client.env.json
index e8122e0..f477678 100644
--- a/tests/integration/http-client/http-client.env.json
+++ b/tests/integration/http-client/http-client.env.json
@@ -12,6 +12,7 @@
"macAddress2": "B05ECAFF",
"accountId": "7654321",
"deviceName": "SoundTouch-11",
+ "spotifyProviderID": "15",
"spotifyUserId": "13570",
"spotifyToken": "example-spotify-token",
"spotifyRefreshToken": "example-spotify-refresh-token",
@@ -32,6 +33,7 @@
"macAddress2": "B05ECAFF",
"accountId": "7654321",
"deviceName": "SoundTouch-11",
+ "spotifyProviderID": "15",
"spotifyUserId": "13570",
"spotifyToken": "example-spotify-token",
"spotifyRefreshToken": "example-spotify-refresh-token",
diff --git a/tests/integration/http-client/set_preset_5.http b/tests/integration/http-client/set_preset_5.http
index 4f9f7fd..191a6a7 100644
--- a/tests/integration/http-client/set_preset_5.http
+++ b/tests/integration/http-client/set_preset_5.http
@@ -6,7 +6,7 @@ Accept: application/vnd.bose.streaming-v1.1+xml
Authorization: Bearer {{token}}
Content-Type: application/vnd.bose.streaming-v1.1+xml
-{{spotifyUserId}}15{{spotifyToken}}{{spotifyDisplayName}}
+{{spotifyUserId}}{{spotifyProviderID}}{{spotifyToken}}{{spotifyDisplayName}}
> {%
client.test("Response is 201 Created", function() {
@@ -15,10 +15,11 @@ Content-Type: application/vnd.bose.streaming-v1.1+xml
client.test("Response body is a source", function() {
const doc = response.body;
+ const spotifySourceProviderID = client.variables.environment.get("spotifyProviderID");
const sourceID = doc.getElementsByTagName("sourceID")[0].textContent;
client.assert(sourceID !== "", "Response body does not contain a non-empty ");
client.global.set("sourceID", sourceID);
- client.assert(doc.getElementsByTagName("sourceProviderID")[0].textContent === "15", "Response body does not contain 15");
+ client.assert(doc.getElementsByTagName("sourceProviderID")[0].textContent === spotifySourceProviderID, "Response body does not contain {{spotifySourceProviderID}}");
});
%}
@@ -47,10 +48,11 @@ Content-Type: application/vnd.bose.streaming-v1.2+xml
client.assert(doc.getElementsByTagName("contentItemType")[0].textContent === "tracklisturl", "Response body does not contain tracklisturl");
const source = doc.getElementsByTagName("source")[0];
+ const spotifySourceProviderID = client.variables.environment.get("spotifyProviderID");
client.assert(source.getAttribute("id") === client.global.get("sourceID"), "Response body does not contain source id=\"" + client.global.get("sourceID") + "\"");
client.assert(source.getAttribute("type") === "Audio", "Response body does not contain source type=\"Audio\"");
client.assert(source.getAttribute("displayName") === null, "Response body should NOT contain displayName attribute in source");
- client.assert(doc.getElementsByTagName("sourceproviderid")[0].textContent === "15", "Response body does not contain 15");
+ client.assert(doc.getElementsByTagName("sourceproviderid")[0].textContent === spotifySourceProviderID, "Response body does not contain {{spotifySourceProviderID}}");
client.assert(source.getElementsByTagName("username")[0].textContent === "", "Response body does not contain empty in source, found: " + source.getElementsByTagName("username")[0].textContent);
client.assert(doc.getElementsByTagName("username")[1].textContent === "For Lovers, Not Killers", "Response body does not contain For Lovers, Not Killers in preset, found: " + doc.getElementsByTagName("username")[1].textContent);
});
diff --git a/tests/integration/http-client/spotify_registration.http b/tests/integration/http-client/spotify_registration.http
new file mode 100644
index 0000000..54cb004
--- /dev/null
+++ b/tests/integration/http-client/spotify_registration.http
@@ -0,0 +1,36 @@
+### Spotify Initial Registration Flow
+
+# @name Init Spotify Flow
+POST {{host}}/mgmt/spotify/init
+Authorization: Basic admin change_me!
+
+> {%
+ client.global.set("redirectUrl", response.body.redirectUrl);
+ %}
+
+###
+
+# @name Spotify Callback
+# Simulates the user being redirected back from Spotify with a code
+GET {{host}}/mgmt/spotify/callback?code=mock-auth-code
+
+###
+
+# @name Verify Accounts
+GET {{host}}/mgmt/spotify/accounts
+Authorization: Basic admin change_me!
+
+> {%
+ client.test("Account exists", function() {
+ client.assert(response.body.accounts.length > 0, "No accounts found");
+ var found = false;
+ for (var i = 0; i < response.body.accounts.length; i++) {
+ if (response.body.accounts[i].user_id === "mock-user-id") {
+ found = true;
+ client.assert(response.body.accounts[i].display_name === "Mock User", "Display name mismatch");
+ break;
+ }
+ }
+ client.assert(found, "Account 'mock-user-id' not found in " + JSON.stringify(response.body.accounts));
+ });
+ %}
diff --git a/tests/integration/mocks/spotify.go b/tests/integration/mocks/spotify.go
new file mode 100644
index 0000000..c56181d
--- /dev/null
+++ b/tests/integration/mocks/spotify.go
@@ -0,0 +1,40 @@
+// Package mocks provides mock implementations for external services during testing.
+package mocks
+
+import (
+ "net/http/httptest"
+
+ "github.com/gesellix/bose-soundtouch/pkg/testutils/spotify"
+)
+
+// SpotifyMock simulates Spotify API responses for OAuth and profile interactions.
+type SpotifyMock struct {
+ server *httptest.Server
+}
+
+// NewSpotifyMock creates and starts a new Spotify mock server.
+func NewSpotifyMock() *SpotifyMock {
+ return &SpotifyMock{
+ server: httptest.NewServer(spotify.NewSpotifyHandler()),
+ }
+}
+
+// URL returns the base URL of the mock server.
+func (m *SpotifyMock) URL() string {
+ return m.server.URL
+}
+
+// TokenURL returns the OAuth token endpoint URL.
+func (m *SpotifyMock) TokenURL() string {
+ return m.server.URL + "/api/token"
+}
+
+// APIBase returns the base API URL.
+func (m *SpotifyMock) APIBase() string {
+ return m.server.URL
+}
+
+// Close stops the mock server.
+func (m *SpotifyMock) Close() {
+ m.server.Close()
+}
diff --git a/tests/integration/testdata/spotify/accounts.json b/tests/integration/testdata/spotify/accounts.json
index 08e7002..81489bd 100644
--- a/tests/integration/testdata/spotify/accounts.json
+++ b/tests/integration/testdata/spotify/accounts.json
@@ -1,4 +1,12 @@
{
+ "mock-user-id": {
+ "user_id": "mock-user-id",
+ "display_name": "Mock User",
+ "email": "mock@example.com",
+ "access_token": "mock-access-token",
+ "refresh_token": "mock-refresh-token",
+ "expires_at": 1775483553
+ },
"test-user": {
"user_id": "test-user",
"display_name": "Integration Test User",
@@ -7,4 +15,4 @@
"refresh_token": "mock-refresh-token",
"expires_at": 2147483647
}
-}
+}
\ No newline at end of file