mirror of
https://github.com/gesellix/Bose-SoundTouch.git
synced 2026-08-24 14:47:23 +00:00
Add Spotify mock server and integration tests
This commit is contained in:
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -6,7 +6,7 @@ Accept: application/vnd.bose.streaming-v1.1+xml
|
||||
Authorization: Bearer {{token}}
|
||||
Content-Type: application/vnd.bose.streaming-v1.1+xml
|
||||
|
||||
<?xml version="1.0" encoding="UTF-8"?><source><username>{{spotifyUserId}}</username><sourceproviderid>15</sourceproviderid><credential type="token_version_3">{{spotifyToken}}</credential><sourcename>{{spotifyDisplayName}}</sourcename></source>
|
||||
<?xml version="1.0" encoding="UTF-8"?><source><username>{{spotifyUserId}}</username><sourceproviderid>{{spotifyProviderID}}</sourceproviderid><credential type="token_version_3">{{spotifyToken}}</credential><sourcename>{{spotifyDisplayName}}</sourcename></source>
|
||||
|
||||
> {%
|
||||
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 <sourceID>");
|
||||
client.global.set("sourceID", sourceID);
|
||||
client.assert(doc.getElementsByTagName("sourceProviderID")[0].textContent === "15", "Response body does not contain <sourceProviderID>15</sourceProviderID>");
|
||||
client.assert(doc.getElementsByTagName("sourceProviderID")[0].textContent === spotifySourceProviderID, "Response body does not contain <sourceProviderID>{{spotifySourceProviderID}}</sourceProviderID>");
|
||||
});
|
||||
%}
|
||||
|
||||
@@ -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 <contentItemType>tracklisturl</contentItemType>");
|
||||
|
||||
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 <sourceproviderid>15</sourceproviderid>");
|
||||
client.assert(doc.getElementsByTagName("sourceproviderid")[0].textContent === spotifySourceProviderID, "Response body does not contain <sourceproviderid>{{spotifySourceProviderID}}</sourceproviderid>");
|
||||
client.assert(source.getElementsByTagName("username")[0].textContent === "", "Response body does not contain empty <username> in source, found: " + source.getElementsByTagName("username")[0].textContent);
|
||||
client.assert(doc.getElementsByTagName("username")[1].textContent === "For Lovers, Not Killers", "Response body does not contain <username>For Lovers, Not Killers</username> in preset, found: " + doc.getElementsByTagName("username")[1].textContent);
|
||||
});
|
||||
|
||||
@@ -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));
|
||||
});
|
||||
%}
|
||||
@@ -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()
|
||||
}
|
||||
+9
-1
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user