mirror of
https://github.com/gesellix/Bose-SoundTouch.git
synced 2026-08-19 00:56:16 +00:00
Add account registration/login (#129)
This commit is contained in:
@@ -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:
|
||||
|
||||
@@ -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..."
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
|
||||
@@ -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 := `
|
||||
<!doctype html>
|
||||
<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 := `
|
||||
<!doctype html>
|
||||
<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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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")
|
||||
|
||||
+1
-1
@@ -2,7 +2,7 @@
|
||||
<info deviceID="ABCD1234EFGH">
|
||||
<name>My SoundTouch Device</name>
|
||||
<type>SoundTouch 10</type>
|
||||
<margeAccountUUID>3230304</margeAccountUUID>
|
||||
<margeAccountUUID>1234567</margeAccountUUID>
|
||||
<components>
|
||||
<component>
|
||||
<componentCategory>SCM</componentCategory>
|
||||
+1
-1
@@ -2,7 +2,7 @@
|
||||
<info deviceID="ABCD1234EFGH">
|
||||
<name>My SoundTouch Device</name>
|
||||
<type>SoundTouch 20</type>
|
||||
<margeAccountUUID>3230304</margeAccountUUID>
|
||||
<margeAccountUUID>1234567</margeAccountUUID>
|
||||
<components>
|
||||
<component>
|
||||
<componentCategory>SCM</componentCategory>
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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"`
|
||||
}
|
||||
|
||||
@@ -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 <updates>
|
||||
type SpecialMessageType string
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 := `<account>
|
||||
<preferredLanguage>de</preferredLanguage>
|
||||
</account>`
|
||||
|
||||
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 := `<login>
|
||||
<username>test@example.com</username>
|
||||
<password>secret</password>
|
||||
</login>`
|
||||
|
||||
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 := `<login>
|
||||
<username>none@example.com</username>
|
||||
<password>secret</password>
|
||||
</login>`
|
||||
|
||||
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)
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
### Create Account (Official Stockholm endpoint)
|
||||
POST {{host}}/streaming/account
|
||||
Content-Type: application/vnd.bose.customer-v1.0+xml
|
||||
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<account id="{{accountId}}">
|
||||
<firstName>John</firstName>
|
||||
<lastName>Doe</lastName>
|
||||
<email>john.doe@example.com</email>
|
||||
<password>password123</password>
|
||||
<countryCode>US</countryCode>
|
||||
<preferredLanguage>en</preferredLanguage>
|
||||
</account>
|
||||
|
||||
> {%
|
||||
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
|
||||
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<login>
|
||||
<username>john.doe@example.com</username>
|
||||
<password>password123</password>
|
||||
</login>
|
||||
|
||||
> {%
|
||||
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"));
|
||||
});
|
||||
%}
|
||||
@@ -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 <account>", 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 <account>");
|
||||
client.assert(account.getAttribute("id") === expectedAccountId, "Expected account id '" + expectedAccountId + "' but received '" + account.getAttribute("id") + "'");
|
||||
});
|
||||
%}
|
||||
@@ -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 <group/>", function() {
|
||||
const doc = response.body;
|
||||
client.assert(doc.getElementsByTagName("group").length > 0, "Response body does not contain <group/>");
|
||||
});
|
||||
%}
|
||||
@@ -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 <providerSettings>", function() {
|
||||
const doc = response.body;
|
||||
client.assert(doc.getElementsByTagName("providerSettings").length > 0, "Response body does not contain <providerSettings>");
|
||||
});
|
||||
%}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
<?xml version="1.0" encoding="UTF-8" ?><device-data><device id="{{deviceId}}"><serialnumber>{{serialNumber}}</serialnumber><firmware-version>27.0.6.46330.5043500 epdbuild.trunk.hepdswbld04.2022-08-04T11:20:29</firmware-version><product product_code="{{productCode}}" type="5"><serialnumber>{{productSerialNumber}}</serialnumber></product></device><diagnostic-data><device-landscape><rssi>Excellent</rssi><gateway-ip-address>{{gatewayIp}}</gateway-ip-address><macaddresses><macaddress>{{macAddress1}}</macaddress><macaddress>{{macAddress2}}</macaddress></macaddresses><ip-address>{{deviceIp}}</ip-address><network-connection-type>Wireless</network-connection-type></device-landscape><network-landscape><network-data xmlns="http://www.Bose.com/Schemas/2012-12/NetworkMonitor/" /></network-landscape></diagnostic-data></device-data>
|
||||
|
||||
> {%
|
||||
client.test("Request executed successfully", function() {
|
||||
client.assert(response.status === 200, "Response status is not 200");
|
||||
});
|
||||
%}
|
||||
@@ -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}}
|
||||
|
||||
<?xml version="1.0" encoding="UTF-8" ?>
|
||||
<device deviceid="{{deviceId}}">
|
||||
<name>{{deviceName}}</name>
|
||||
</device>
|
||||
|
||||
> {%
|
||||
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 <device>");
|
||||
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");
|
||||
});
|
||||
%}
|
||||
@@ -0,0 +1 @@
|
||||
*.xml
|
||||
Reference in New Issue
Block a user