From c4cf078d2a209c6d5ec86736b7235b10107942b5 Mon Sep 17 00:00:00 2001 From: Tobias Gesellchen Date: Mon, 6 Apr 2026 14:55:27 +0200 Subject: [PATCH] Add Spotify mock server and integration tests --- Makefile | 27 ++---- cmd/mock-spotify/main.go | 23 +++++ cmd/soundtouch-service/main.go | 20 ++++ docker-compose.ci.yml | 10 ++ docker-compose.yml | 18 ++++ pkg/testutils/spotify/handlers.go | 96 +++++++++++++++++++ .../http-client/http-client.env.json | 2 + .../integration/http-client/set_preset_5.http | 8 +- .../http-client/spotify_registration.http | 36 +++++++ tests/integration/mocks/spotify.go | 40 ++++++++ .../testdata/spotify/accounts.json | 10 +- 11 files changed, 269 insertions(+), 21 deletions(-) create mode 100644 cmd/mock-spotify/main.go create mode 100644 docker-compose.ci.yml create mode 100644 pkg/testutils/spotify/handlers.go create mode 100644 tests/integration/http-client/spotify_registration.http create mode 100644 tests/integration/mocks/spotify.go 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