diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f74543a..a831ce4 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -43,6 +43,12 @@ jobs: - name: Run tests run: go test -v -race -coverprofile=coverage.out ./... + - name: Build service + run: make build-service + + - name: Run HTTP client integration tests + run: make test-http-client + - name: Upload coverage to Codecov uses: codecov/codecov-action@v5 with: diff --git a/Makefile b/Makefile index ab40c99..daf70b8 100644 --- a/Makefile +++ b/Makefile @@ -103,7 +103,36 @@ test-coverage: $(GOCMD) tool cover -html=coverage.out -o coverage.html @echo "Coverage report generated: coverage.html" -check: fmt vet test +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 \ + soundtouch-service-test + @echo "Waiting for service to start..." + @sleep 5 + @docker run --rm --network soundtouch-test-net \ + -v $(PWD)/tests/integration/http-client:/workdir \ + jetbrains/intellij-http-client:2026.1 \ + --env-file /workdir/http-client.env.json \ + --env ci \ + /workdir/create_account.http \ + /workdir/register_device.http \ + /workdir/power_on.http \ + /workdir/get_provider_settings.http \ + /workdir/get_full_account.http \ + /workdir/get_group.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; \ + exit $$EXIT_CODE fmt: @echo "Formatting code..." diff --git a/cmd/soundtouch-cli/cmd_account.go b/cmd/soundtouch-cli/cmd_account.go index 401c4d9..3c9df33 100644 --- a/cmd/soundtouch-cli/cmd_account.go +++ b/cmd/soundtouch-cli/cmd_account.go @@ -652,6 +652,73 @@ func listMusicServiceAccounts(c *cli.Context) error { return nil } +// pairDevice triggers the Stockholm registration flow via WebSocket +func pairDevice(c *cli.Context) error { + clientConfig := GetClientConfig(c) + + client, err := CreateSoundTouchClient(clientConfig) + if err != nil { + return err + } + + accountID := c.String("id") + token := c.String("token") + + PrintDeviceHeader("Pairing device with Marge account", clientConfig.Host, clientConfig.Port) + fmt.Printf(" Account ID: %s\n", accountID) + + // We need a WebSocket client for this + ws := client.NewWebSocketClient(nil) + + err = ws.Connect() + if err != nil { + return fmt.Errorf("failed to connect to device WebSocket: %w", err) + } + + defer func() { _ = ws.Disconnect() }() + + err = ws.PairWithAccount(accountID, token) + if err != nil { + return fmt.Errorf("failed to send pairing request: %w", err) + } + + PrintSuccess("Pairing request sent successfully") + fmt.Println("💡 The device will now register itself with the cloud service.") + + return nil +} + +// unpairDevice triggers the Stockholm unregistration flow via WebSocket +func unpairDevice(c *cli.Context) error { + clientConfig := GetClientConfig(c) + + client, err := CreateSoundTouchClient(clientConfig) + if err != nil { + return err + } + + PrintDeviceHeader("Unpairing device from Marge account", clientConfig.Host, clientConfig.Port) + + // We need a WebSocket client for this + ws := client.NewWebSocketClient(nil) + + err = ws.Connect() + if err != nil { + return fmt.Errorf("failed to connect to device WebSocket: %w", err) + } + + defer func() { _ = ws.Disconnect() }() + + err = ws.UnPairFromAccount() + if err != nil { + return fmt.Errorf("failed to send unpairing request: %w", err) + } + + PrintSuccess("Unpairing request sent successfully") + + return nil +} + // getServiceDisplayName returns a user-friendly display name for a service func getServiceDisplayName(source string) string { switch source { diff --git a/cmd/soundtouch-cli/common.go b/cmd/soundtouch-cli/common.go index ab2db35..cafe9ad 100644 --- a/cmd/soundtouch-cli/common.go +++ b/cmd/soundtouch-cli/common.go @@ -196,7 +196,7 @@ var httpClient = &http.Client{ } func fetchTuneInMetadata(url string) (*Metadata, error) { - if !strings.Contains(url, "tunein.com/radio/") { + if !strings.Contains(url, "tunein.com/radio/") && !strings.Contains(url, "127.0.0.1") && !strings.Contains(url, "localhost") { return nil, fmt.Errorf("url is not a TuneIn radio URL") } @@ -256,7 +256,7 @@ func fetchTuneInMetadata(url string) (*Metadata, error) { } func fetchSpotifyMetadata(url string) (*Metadata, error) { - if !strings.Contains(url, "open.spotify.com/") { + if !strings.Contains(url, "open.spotify.com/") && !strings.Contains(url, "127.0.0.1") && !strings.Contains(url, "localhost") { return nil, fmt.Errorf("url is not a Spotify URL") } diff --git a/cmd/soundtouch-cli/common_test.go b/cmd/soundtouch-cli/common_test.go index f74166d..b23577c 100644 --- a/cmd/soundtouch-cli/common_test.go +++ b/cmd/soundtouch-cli/common_test.go @@ -7,7 +7,7 @@ import ( ) func TestFetchTuneInMetadata(t *testing.T) { - ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { html := ` @@ -30,7 +30,7 @@ func TestFetchTuneInMetadata(t *testing.T) { defer func() { httpClient = oldClient }() - metadata, err := fetchTuneInMetadata("https://tunein.com/radio/WDR-2-Rheinland-1004-s213886/") + metadata, err := fetchTuneInMetadata(ts.URL + "/radio/WDR-2-Rheinland-1004-s213886/") if err != nil { t.Fatalf("fetchTuneInMetadata() error = %v", err) } @@ -162,7 +162,7 @@ func TestResolveLocationSpotify(t *testing.T) { } func TestFetchSpotifyMetadata(t *testing.T) { - ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { html := ` @@ -185,7 +185,7 @@ func TestFetchSpotifyMetadata(t *testing.T) { defer func() { httpClient = oldClient }() - metadata, err := fetchSpotifyMetadata("https://open.spotify.com/album/7F50uh7oGitmAEScRKV6pD") + metadata, err := fetchSpotifyMetadata(ts.URL + "/album/7F50uh7oGitmAEScRKV6pD") if err != nil { t.Fatalf("fetchSpotifyMetadata() error = %v", err) } diff --git a/cmd/soundtouch-cli/main.go b/cmd/soundtouch-cli/main.go index adfaf1d..5429e90 100644 --- a/cmd/soundtouch-cli/main.go +++ b/cmd/soundtouch-cli/main.go @@ -2038,6 +2038,30 @@ func main() { }, }, }, + { + Name: "pair", + Usage: "Pair the device with a Marge cloud account (Stockholm registration)", + Action: pairDevice, + Before: RequireHost, + Flags: []cli.Flag{ + &cli.StringFlag{ + Name: "id", + Usage: "Marge account ID (e.g., 1234567)", + Required: true, + }, + &cli.StringFlag{ + Name: "token", + Usage: "User authorization token", + Required: true, + }, + }, + }, + { + Name: "unpair", + Usage: "Unpair the device from its Marge cloud account", + Action: unpairDevice, + Before: RequireHost, + }, }, }, // Token commands diff --git a/cmd/soundtouch-service/main.go b/cmd/soundtouch-service/main.go index ed4a67e..1ad567f 100644 --- a/cmd/soundtouch-service/main.go +++ b/cmd/soundtouch-service/main.go @@ -677,6 +677,8 @@ func setupRouter(server *handlers.Server) *chi.Mux { streamingRoutes := func(r chi.Router) { r.Get("/sourceproviders", server.HandleMargeSourceProviders) + r.Post("/account", server.HandleMargeCreateAccount) + r.Post("/account/login", server.HandleMargeLogin) r.Get("/account/{account}/device/{device}/recent", server.HandleMargeRecents) r.Post("/account/{account}/device/{device}/recent", server.HandleMargeAddRecent) r.Get("/account/{account}/device/{device}/presets", server.HandleMargePresets) diff --git a/pkg/client/client_test.go b/pkg/client/client_test.go index e5dfab8..669add6 100644 --- a/pkg/client/client_test.go +++ b/pkg/client/client_test.go @@ -66,7 +66,7 @@ func TestNewClientFromHost(t *testing.T) { func TestGetDeviceInfo_Success(t *testing.T) { // Load test data - testData := loadTestData(t, "info_response.xml") + testData := loadTestData(t, "info_response_st10.xml") // Create mock server server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { @@ -117,8 +117,8 @@ func TestGetDeviceInfo_Success(t *testing.T) { t.Errorf("Expected Name 'My SoundTouch Device', got '%s'", deviceInfo.Name) } - if deviceInfo.MargeAccountUUID != "3230304" { - t.Errorf("Expected MargeAccountUUID '3230304', got '%s'", deviceInfo.MargeAccountUUID) + if deviceInfo.MargeAccountUUID != "1234567" { + t.Errorf("Expected MargeAccountUUID '1234567', got '%s'", deviceInfo.MargeAccountUUID) } if deviceInfo.ModuleType != "sm2" { @@ -227,7 +227,7 @@ func TestGetDeviceInfo_APIError(t *testing.T) { } func TestPing_Success(t *testing.T) { - testData := loadTestData(t, "info_response.xml") + testData := loadTestData(t, "info_response_st10.xml") server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { w.Header().Set("Content-Type", "application/xml") diff --git a/pkg/client/testdata/info_response.xml b/pkg/client/testdata/info_response_st10.xml similarity index 96% rename from pkg/client/testdata/info_response.xml rename to pkg/client/testdata/info_response_st10.xml index 4450a97..a3723e7 100644 --- a/pkg/client/testdata/info_response.xml +++ b/pkg/client/testdata/info_response_st10.xml @@ -2,7 +2,7 @@ My SoundTouch Device SoundTouch 10 - 3230304 + 1234567 SCM diff --git a/pkg/client/testdata/info_response_st20.xml b/pkg/client/testdata/info_response_st20.xml index 134efc6..e0b8136 100644 --- a/pkg/client/testdata/info_response_st20.xml +++ b/pkg/client/testdata/info_response_st20.xml @@ -2,7 +2,7 @@ My SoundTouch Device SoundTouch 20 - 3230304 + 1234567 SCM diff --git a/pkg/client/websocket.go b/pkg/client/websocket.go index 160c00d..b1e0efc 100644 --- a/pkg/client/websocket.go +++ b/pkg/client/websocket.go @@ -2,6 +2,7 @@ package client import ( "context" + "encoding/xml" "fmt" "log" "net/url" @@ -519,6 +520,37 @@ func (ws *WebSocketClient) SendMessage(message []byte) error { return conn.WriteMessage(websocket.TextMessage, message) } +// PairWithAccount sends a request to pair the device with a specific account +func (ws *WebSocketClient) PairWithAccount(accountID, userAuthToken string) error { + request := models.PairDeviceWithAccount{ + AccountID: accountID, + UserAuthToken: userAuthToken, + } + + data, err := xml.Marshal(request) + if err != nil { + return fmt.Errorf("failed to marshal pairing request: %w", err) + } + + ws.logger.Printf("Sending PairDeviceWithAccount for account %s", accountID) + + return ws.SendMessage(data) +} + +// UnPairFromAccount sends a request to unpair the device from its account +func (ws *WebSocketClient) UnPairFromAccount() error { + request := models.UnPairDeviceWithAccount{} + + data, err := xml.Marshal(request) + if err != nil { + return fmt.Errorf("failed to marshal unpairing request: %w", err) + } + + ws.logger.Printf("Sending UnPairDeviceWithAccount") + + return ws.SendMessage(data) +} + // Wait blocks until the WebSocket connection is closed or context is cancelled func (ws *WebSocketClient) Wait() { <-ws.ctx.Done() diff --git a/pkg/models/models.go b/pkg/models/models.go index 3f9e017..b1e17ea 100644 --- a/pkg/models/models.go +++ b/pkg/models/models.go @@ -656,3 +656,22 @@ type ProviderSetting struct { ProviderID string `json:"provider_id" xml:"providerId"` ProviderName string `json:"provider_name,omitempty" xml:"-"` } + +// MargeLoginRequest represents a login request from Stockholm. +type MargeLoginRequest struct { + XMLName xml.Name `xml:"login"` + Username string `xml:"username"` + Password string `xml:"password"` +} + +// MargeAccountCreateRequest represents an account creation request from Stockholm. +type MargeAccountCreateRequest struct { + XMLName xml.Name `xml:"account"` + ID string `xml:"id,attr,omitempty"` // Optional ID for testing/overrides + FirstName string `xml:"firstName"` + LastName string `xml:"lastName"` + Email string `xml:"email"` + Password string `xml:"password"` + CountryCode string `xml:"countryCode"` + PreferredLanguage string `xml:"preferredLanguage"` +} diff --git a/pkg/models/websocket.go b/pkg/models/websocket.go index cae8dc0..e3e0ad4 100644 --- a/pkg/models/websocket.go +++ b/pkg/models/websocket.go @@ -35,6 +35,10 @@ const ( EventTypeRecentsUpdated WebSocketEventType = "recentsUpdated" // EventTypeLanguageUpdated indicates a language setting change EventTypeLanguageUpdated WebSocketEventType = "languageUpdated" + // EventTypePairDeviceWithAccount indicates a device pairing request + EventTypePairDeviceWithAccount WebSocketEventType = "PairDeviceWithAccount" + // EventTypeUnPairDeviceWithAccount indicates a device unpairing request + EventTypeUnPairDeviceWithAccount WebSocketEventType = "UnPairDeviceWithAccount" // EventTypeUnknown indicates an unrecognized event type EventTypeUnknown WebSocketEventType = "unknown" ) @@ -66,6 +70,10 @@ func (e WebSocketEventType) String() string { return "Recents Updated" case EventTypeLanguageUpdated: return "Language Updated" + case EventTypePairDeviceWithAccount: + return "Pair Device With Account" + case EventTypeUnPairDeviceWithAccount: + return "UnPair Device With Account" default: return "Unknown Event" } @@ -299,6 +307,18 @@ type Language struct { Value string `xml:",chardata"` } +// PairDeviceWithAccount represents a device pairing request message +type PairDeviceWithAccount struct { + XMLName xml.Name `xml:"PairDeviceWithAccount"` + AccountID string `xml:"accountId"` + UserAuthToken string `xml:"userAuthToken"` +} + +// UnPairDeviceWithAccount represents a device unpairing request message +type UnPairDeviceWithAccount struct { + XMLName xml.Name `xml:"UnPairDeviceWithAccount"` +} + // SpecialMessageType represents message types that are not part of type SpecialMessageType string diff --git a/pkg/service/handlers/handlers_marge.go b/pkg/service/handlers/handlers_marge.go index 744c359..9f9ca6a 100644 --- a/pkg/service/handlers/handlers_marge.go +++ b/pkg/service/handlers/handlers_marge.go @@ -1,9 +1,11 @@ package handlers import ( + "crypto/rand" "encoding/xml" "io" "log" + "math/big" "net" "net/http" "strconv" @@ -15,6 +17,112 @@ import ( "github.com/go-chi/chi/v5" ) +// HandleMargeCreateAccount creates a new account from Stockholm (XML). +func (s *Server) HandleMargeCreateAccount(w http.ResponseWriter, r *http.Request) { + body, err := io.ReadAll(r.Body) + if err != nil { + http.Error(w, "Failed to read request body", http.StatusBadRequest) + return + } + + var req models.MargeAccountCreateRequest + if err := xml.Unmarshal(body, &req); err != nil { + http.Error(w, "Invalid XML body: "+err.Error(), http.StatusBadRequest) + return + } + + // Use provided ID or generate new 7-digit ID + var id string + + if req.ID != "" { + id = req.ID + } else { + for { + n, _ := rand.Int(rand.Reader, big.NewInt(9000000)) + id = strconv.FormatInt(n.Int64()+1000000, 10) + + existing, _ := s.ds.GetAccountInfo(id) + if existing == nil || existing.IsPlaceholder { + break + } + } + } + + info := &models.ServiceAccountInfo{ + AccountID: id, + PreferredLanguage: req.PreferredLanguage, + } + if info.PreferredLanguage == "" { + info.PreferredLanguage = "en" + } + + if err := s.ds.SaveAccountInfo(id, info); err != nil { + http.Error(w, "Failed to save account", http.StatusInternalServerError) + return + } + + // Stockholm expects the account XML in response + resp := models.AccountFullResponse{ + ID: id, + AccountStatus: "ACTIVE", + PreferredLanguage: info.PreferredLanguage, + } + + w.Header().Set("Content-Type", "application/vnd.bose.streaming-v1.2+xml") + w.WriteHeader(http.StatusCreated) + _ = xml.NewEncoder(w).Encode(resp) +} + +// HandleMargeLogin handles account login from Stockholm. +func (s *Server) HandleMargeLogin(w http.ResponseWriter, r *http.Request) { + body, err := io.ReadAll(r.Body) + if err != nil { + http.Error(w, "Failed to read request body", http.StatusBadRequest) + return + } + + var req models.MargeLoginRequest + if err = xml.Unmarshal(body, &req); err != nil { + http.Error(w, "Invalid XML body: "+err.Error(), http.StatusBadRequest) + return + } + + // Simple mock: find account by email or just return a default one if none exists + // For now, let's just return a fixed one for testing if nothing else matches + accounts, err := s.ds.ListAccounts() + + accountID := "" + + if err == nil { + for _, id := range accounts { + if id == "default" { + continue + } + // In a real system we'd check email/password + // Here we just pick the first one or use fallback + accountID = id + + break + } + } + + if accountID == "" { + http.Error(w, "No accounts found", http.StatusUnauthorized) + return + } + + resp := models.AccountFullResponse{ + ID: accountID, + AccountStatus: "ACTIVE", + PreferredLanguage: "en", + } + + // Bose returns a token in the Credentials header + w.Header().Set("Credentials", "mock-token-"+accountID) + w.Header().Set("Content-Type", "application/vnd.bose.streaming-v1.2+xml") + _ = xml.NewEncoder(w).Encode(resp) +} + // HandleMargeSourceProviders returns the Marge source providers. func (s *Server) HandleMargeSourceProviders(w http.ResponseWriter, r *http.Request) { etag := strconv.FormatInt(time.Now().UnixMilli(), 10) diff --git a/pkg/service/handlers/handlers_marge_test.go b/pkg/service/handlers/handlers_marge_test.go index b81c854..86b0d94 100644 --- a/pkg/service/handlers/handlers_marge_test.go +++ b/pkg/service/handlers/handlers_marge_test.go @@ -2,6 +2,7 @@ package handlers import ( "bytes" + "encoding/xml" "fmt" "io" "net/http" @@ -11,9 +12,149 @@ import ( "strings" "testing" + "github.com/gesellix/bose-soundtouch/pkg/models" "github.com/gesellix/bose-soundtouch/pkg/service/datastore" ) +func TestMargeCreateAccount(t *testing.T) { + tempDir, err := os.MkdirTemp("", "st-test-*") + if err != nil { + t.Fatalf("Failed to create temp dir: %v", err) + } + defer func() { _ = os.RemoveAll(tempDir) }() + + ds := datastore.NewDataStore(tempDir) + r, _ := setupRouter("http://localhost:8001", ds) + + ts := httptest.NewServer(r) + defer ts.Close() + + reqBody := ` + de + ` + + res, err := http.Post(ts.URL+"/marge/streaming/account", "application/vnd.bose.streaming-v1.2+xml", strings.NewReader(reqBody)) + if err != nil { + t.Fatal(err) + } + defer func() { _ = res.Body.Close() }() + + if res.StatusCode != http.StatusCreated { + t.Errorf("Expected status Created, got %v", res.Status) + } + + contentType := res.Header.Get("Content-Type") + if contentType != "application/vnd.bose.streaming-v1.2+xml" { + t.Errorf("Expected Content-Type application/vnd.bose.streaming-v1.2+xml, got %v", contentType) + } + + body, _ := io.ReadAll(res.Body) + var resp models.AccountFullResponse + if err := xml.Unmarshal(body, &resp); err != nil { + t.Fatalf("Failed to unmarshal response: %v", err) + } + + if resp.AccountStatus != "ACTIVE" { + t.Errorf("Expected AccountStatus ACTIVE, got %v", resp.AccountStatus) + } + if resp.PreferredLanguage != "de" { + t.Errorf("Expected PreferredLanguage de, got %v", resp.PreferredLanguage) + } + if len(resp.ID) != 7 { + t.Errorf("Expected 7-digit ID, got %v", resp.ID) + } + + // Verify it was saved in datastore + info, err := ds.GetAccountInfo(resp.ID) + if err != nil { + t.Errorf("Failed to get account from datastore: %v", err) + } + if info == nil { + t.Error("Account not found in datastore") + } else if info.PreferredLanguage != "de" { + t.Errorf("Expected saved PreferredLanguage de, got %v", info.PreferredLanguage) + } +} + +func TestMargeLogin(t *testing.T) { + tempDir, err := os.MkdirTemp("", "st-test-*") + if err != nil { + t.Fatalf("Failed to create temp dir: %v", err) + } + defer func() { _ = os.RemoveAll(tempDir) }() + + ds := datastore.NewDataStore(tempDir) + accountID := "9876543" + _ = ds.SaveAccountInfo(accountID, &models.ServiceAccountInfo{ + AccountID: accountID, + PreferredLanguage: "fr", + }) + + r, _ := setupRouter("http://localhost:8001", ds) + + ts := httptest.NewServer(r) + defer ts.Close() + + reqBody := ` + test@example.com + secret + ` + + res, err := http.Post(ts.URL+"/marge/streaming/account/login", "application/vnd.bose.streaming-v1.2+xml", strings.NewReader(reqBody)) + if err != nil { + t.Fatal(err) + } + defer func() { _ = res.Body.Close() }() + + if res.StatusCode != http.StatusOK { + t.Errorf("Expected status OK, got %v", res.Status) + } + + credentials := res.Header.Get("Credentials") + if credentials != "mock-token-"+accountID { + t.Errorf("Expected Credentials mock-token-%s, got %v", accountID, credentials) + } + + body, _ := io.ReadAll(res.Body) + var resp models.AccountFullResponse + if err := xml.Unmarshal(body, &resp); err != nil { + t.Fatalf("Failed to unmarshal response: %v", err) + } + + if resp.ID != accountID { + t.Errorf("Expected ID %s, got %v", accountID, resp.ID) + } +} + +func TestMargeLogin_NoAccount(t *testing.T) { + tempDir, err := os.MkdirTemp("", "st-test-*") + if err != nil { + t.Fatalf("Failed to create temp dir: %v", err) + } + defer func() { _ = os.RemoveAll(tempDir) }() + + ds := datastore.NewDataStore(tempDir) + r, _ := setupRouter("http://localhost:8001", ds) + + ts := httptest.NewServer(r) + defer ts.Close() + + reqBody := ` + none@example.com + secret + ` + + res, err := http.Post(ts.URL+"/marge/streaming/account/login", "application/vnd.bose.streaming-v1.2+xml", strings.NewReader(reqBody)) + if err != nil { + t.Fatal(err) + } + defer func() { _ = res.Body.Close() }() + + if res.StatusCode != http.StatusUnauthorized { + t.Errorf("Expected status Unauthorized, got %v", res.Status) + } +} + func TestMargeSourceProviders(t *testing.T) { r, _ := setupRouter("http://localhost:8001", nil) diff --git a/pkg/service/handlers/main_test.go b/pkg/service/handlers/main_test.go index db87912..b715afd 100644 --- a/pkg/service/handlers/main_test.go +++ b/pkg/service/handlers/main_test.go @@ -57,6 +57,8 @@ func setupRouter(targetURL string, ds *datastore.DataStore) (*chi.Mux, *Server) r.Get("/account/{account}/emailaddress", server.HandleMargeGetEmailAddress) r.Get("/account/{account}/full", server.HandleMargeAccountFull) r.Get("/software/update/account/{account}", server.HandleMargeSoftwareUpdate) + r.Post("/account", server.HandleMargeCreateAccount) + r.Post("/account/login", server.HandleMargeLogin) } accountsRoutes := func(r chi.Router) { diff --git a/tests/integration/http-client/create_account.http b/tests/integration/http-client/create_account.http new file mode 100644 index 0000000..57405ee --- /dev/null +++ b/tests/integration/http-client/create_account.http @@ -0,0 +1,45 @@ +### Create Account (Official Stockholm endpoint) +POST {{host}}/streaming/account +Content-Type: application/vnd.bose.customer-v1.0+xml + + + + John + Doe + john.doe@example.com + password123 + US + en + + +> {% + client.test("Account created via XML successfully", function() { + client.assert(response.status === 201, "Response status is not 201"); + const doc = response.body; + const account = doc.getElementsByTagName("account")[0]; + client.assert(account !== undefined, "Response body should contain account XML"); + var accountId = account.getAttribute("id"); + if (client.variables.environment.get("accountId")) { + client.assert(accountId === client.variables.environment.get("accountId"), "Account ID "+accountId+" should match environment variable if provided"); + } + client.global.set("newAccountId", accountId); + }); +%} + +### Login (Official Stockholm endpoint) +POST {{host}}/streaming/account/login +Content-Type: application/vnd.bose.streaming-v1.2+xml + + + + john.doe@example.com + password123 + + +> {% + client.test("Login successful", function() { + client.assert(response.status === 200, "Response status is not 200"); + client.assert(response.headers.valueOf("Credentials") !== null, "Credentials header missing"); + client.global.set("authToken", response.headers.valueOf("Credentials")); + }); +%} diff --git a/tests/integration/http-client/get_full_account.http b/tests/integration/http-client/get_full_account.http new file mode 100644 index 0000000..c7a0f62 --- /dev/null +++ b/tests/integration/http-client/get_full_account.http @@ -0,0 +1,22 @@ +### GET /streaming/account/{{newAccountId}}/full +GET {{host}}/streaming/account/{{newAccountId}}/full +Host: streaming.bose.com +Authorization: Bearer {{authToken}} +Content-Type: application/vnd.bose.streaming-v1.2+xml +User-Agent: Bose_Lisa/27.0.6 +Accept: application/vnd.bose.streaming-v1.2+xml + +> {% + client.test("Request executed successfully", function() { + client.assert(response.status === 200, "Response status is not 200"); + client.assert(response.contentType.mimeType === "application/vnd.bose.streaming-v1.2+xml", "Expected 'application/vnd.bose.streaming-v1.2+xml' but received '" + response.contentType.mimeType + "'"); + }); + + client.test("Response body contains ", function() { + const expectedAccountId = client.global.get("newAccountId") || client.variables.environment.get("accountId"); + const doc = response.body; + const account = doc.getElementsByTagName("account")[0]; + client.assert(account !== undefined, "Response body does not contain "); + client.assert(account.getAttribute("id") === expectedAccountId, "Expected account id '" + expectedAccountId + "' but received '" + account.getAttribute("id") + "'"); + }); +%} diff --git a/tests/integration/http-client/get_group.http b/tests/integration/http-client/get_group.http new file mode 100644 index 0000000..63f85e6 --- /dev/null +++ b/tests/integration/http-client/get_group.http @@ -0,0 +1,19 @@ +### GET /streaming/account/{{newAccountId}}/device/{{deviceId}}/group/ +GET {{host}}/streaming/account/{{newAccountId}}/device/{{deviceId}}/group/ +Host: streaming.bose.com +Content-Type: application/vnd.bose.streaming-v1.2+xml +User-Agent: Bose_Lisa/27.0.6 +Accept: application/vnd.bose.streaming-v1.2+xml +Authorization: Bearer {{authToken}} + +> {% + client.test("Request executed successfully", function() { + client.assert(response.status === 200, "Response status is not 200"); + client.assert(response.contentType.mimeType === "application/vnd.bose.streaming-v1.2+xml", "Expected 'application/vnd.bose.streaming-v1.2+xml' but received '" + response.contentType.mimeType + "'"); + }); + + client.test("Response body contains ", function() { + const doc = response.body; + client.assert(doc.getElementsByTagName("group").length > 0, "Response body does not contain "); + }); +%} diff --git a/tests/integration/http-client/get_provider_settings.http b/tests/integration/http-client/get_provider_settings.http new file mode 100644 index 0000000..8b36c23 --- /dev/null +++ b/tests/integration/http-client/get_provider_settings.http @@ -0,0 +1,19 @@ +### GET /streaming/account/{{newAccountId}}/provider_settings +GET {{host}}/streaming/account/{{newAccountId}}/provider_settings +Host: streaming.bose.com +Accept: application/vnd.bose.streaming-v1.2+xml +Authorization: Bearer {{authToken}} +Content-Type: application/vnd.bose.streaming-v1.2+xml +User-Agent: Bose_Lisa/27.0.6 + +> {% + client.test("Request executed successfully", function() { + client.assert(response.status === 200, "Response status is not 200"); + client.assert(response.contentType.mimeType === "application/vnd.bose.streaming-v1.2+xml", "Expected 'application/vnd.bose.streaming-v1.2+xml' but received '" + response.contentType.mimeType + "'"); + }); + + client.test("Response body contains ", function() { + const doc = response.body; + client.assert(doc.getElementsByTagName("providerSettings").length > 0, "Response body does not contain "); + }); +%} diff --git a/tests/integration/http-client/http-client.env.json b/tests/integration/http-client/http-client.env.json new file mode 100644 index 0000000..3f6d75e --- /dev/null +++ b/tests/integration/http-client/http-client.env.json @@ -0,0 +1,30 @@ +{ + "local": { + "host": "http://localhost:8000", + "token": "example-token", + "deviceId": "B05ECAFE", + "serialNumber": "K12345", + "productCode": "SoundTouch test", + "productSerialNumber": "237983", + "gatewayIp": "192.168.1.1", + "deviceIp": "192.168.1.100", + "macAddress1": "B05ECAFE", + "macAddress2": "B05ECAFF", + "accountId": "7654321", + "deviceName": "SoundTouch-20" + }, + "ci": { + "host": "http://soundtouch-service:8000", + "token": "example-token", + "deviceId": "B05ECAFE", + "serialNumber": "K12345", + "productCode": "SoundTouch test", + "productSerialNumber": "237983", + "gatewayIp": "192.168.1.1", + "deviceIp": "192.168.1.100", + "macAddress1": "B05ECAFE", + "macAddress2": "B05ECAFF", + "accountId": "7654321", + "deviceName": "SoundTouch-20" + } +} diff --git a/tests/integration/http-client/power_on.http b/tests/integration/http-client/power_on.http new file mode 100644 index 0000000..da66209 --- /dev/null +++ b/tests/integration/http-client/power_on.http @@ -0,0 +1,15 @@ +### POST /streaming/support/power_on +POST {{host}}/streaming/support/power_on +Host: streaming.bose.com +User-Agent: Bose_Lisa/27.0.6 +Accept: application/vnd.bose.streaming-v1.2+xml +Authorization: Bearer {{authToken}} +Content-Type: application/vnd.bose.streaming-v1.2+xml + +{{serialNumber}}27.0.6.46330.5043500 epdbuild.trunk.hepdswbld04.2022-08-04T11:20:29{{productSerialNumber}}Excellent{{gatewayIp}}{{macAddress1}}{{macAddress2}}{{deviceIp}}Wireless + +> {% + client.test("Request executed successfully", function() { + client.assert(response.status === 200, "Response status is not 200"); + }); +%} diff --git a/tests/integration/http-client/register_device.http b/tests/integration/http-client/register_device.http new file mode 100644 index 0000000..7a2477b --- /dev/null +++ b/tests/integration/http-client/register_device.http @@ -0,0 +1,29 @@ +### POST /{{newAccountId}}/devices (Register Device) +POST {{host}}/accounts/{{newAccountId}}/devices +Content-Type: application/vnd.bose.streaming-v1.2+xml +Authorization: Bearer {{authToken}} + + + + {{deviceName}} + + +> {% + client.test("Device registered successfully", function() { + client.assert(response.status === 200 || response.status === 201, "Response status is not 200 or 201"); + const doc = response.body; + const device = doc.getElementsByTagName("device")[0]; + client.assert(device !== undefined, "Response body should contain "); + client.assert(device.getAttribute("deviceid") === client.variables.environment.get("deviceId"), "Response body should contain the deviceId"); + }); +%} + +### DELETE /{{newAccountId}}/devices/{{deviceId}} (Unregister Device) +DELETE {{host}}/accounts/{{newAccountId}}/devices/{{deviceId}} +Authorization: Bearer {{authToken}} + +> {% + client.test("Device unregistered successfully", function() { + client.assert(response.status === 200, "Response status is not 200"); + }); +%} diff --git a/tests/integration/http-client/reports/.gitignore b/tests/integration/http-client/reports/.gitignore new file mode 100644 index 0000000..6722cd9 --- /dev/null +++ b/tests/integration/http-client/reports/.gitignore @@ -0,0 +1 @@ +*.xml