diff --git a/Makefile b/Makefile
index eac07f1..708a7d1 100644
--- a/Makefile
+++ b/Makefile
@@ -140,6 +140,7 @@ test-http-client:
/workdir/set_preset_5.http \
/workdir/post_recent.http \
/workdir/get_recents.http \
+ /workdir/get_account_sources.http \
/workdir/get_full_account.http \
/workdir/get_group.http \
/workdir/unregister_device.http \
diff --git a/cmd/soundtouch-service/main.go b/cmd/soundtouch-service/main.go
index 176c2d1..ed263ac 100644
--- a/cmd/soundtouch-service/main.go
+++ b/cmd/soundtouch-service/main.go
@@ -741,6 +741,7 @@ func setupRouter(server *handlers.Server) *chi.Mux {
r.Route("/account/{account}", func(r chi.Router) {
r.Get("/emailaddress", server.HandleMargeGetEmailAddress)
r.Get("/full", server.HandleMargeAccountFull)
+ r.Get("/sources", server.HandleMargeAccountSources)
r.Get("/provider_settings", server.HandleMargeProviderSettings)
r.Route("/device", func(r chi.Router) {
diff --git a/cmd/soundtouch-service/testdata/router_routes.txt b/cmd/soundtouch-service/testdata/router_routes.txt
index 0dae0a6..1aa5d1c 100644
--- a/cmd/soundtouch-service/testdata/router_routes.txt
+++ b/cmd/soundtouch-service/testdata/router_routes.txt
@@ -64,6 +64,7 @@ GET /streaming/account/{account}/device/{device}/recents handlers.(
GET /streaming/account/{account}/emailaddress handlers.(*Server).HandleMargeGetEmailAddress-fm
GET /streaming/account/{account}/full handlers.(*Server).HandleMargeAccountFull-fm
GET /streaming/account/{account}/provider_settings handlers.(*Server).HandleMargeProviderSettings-fm
+GET /streaming/account/{account}/sources handlers.(*Server).HandleMargeAccountSources-fm
GET /streaming/device/{device}/streaming_token handlers.(*Server).HandleMargeStreamingToken-fm
GET /streaming/device_setting/account/{account}/device/{device}/device_settings handlers.(*Server).HandleMargeGetDeviceSettings-fm
GET /streaming/software/update/account/{account} handlers.(*Server).HandleMargeSoftwareUpdate-fm
diff --git a/pkg/models/models.go b/pkg/models/models.go
index 24e0ecc..9c8f5bb 100644
--- a/pkg/models/models.go
+++ b/pkg/models/models.go
@@ -6,6 +6,7 @@ package models
import (
"encoding/xml"
"strconv"
+ "strings"
"time"
)
@@ -494,9 +495,25 @@ func (s ConfiguredSource) MarshalXML(e *xml.Encoder, start xml.StartElement) err
a.DisplayName = ""
}
- a.Name = s.getFirstNonEmpty(s.Name, s.SourceName, s.Username, s.DisplayName)
- a.SourceName = s.getFirstNonEmpty(s.SourceName, s.Name, s.Username, s.DisplayName)
- a.Username = s.getFirstNonEmpty(s.Username, s.Name, s.SourceName, s.DisplayName)
+ a.Name = s.Name
+ a.SourceName = s.SourceName
+ a.Username = s.Username
+
+ // Parity: for TuneIn and some others, sourcename, name and username should NOT automatically fall back to displayName
+ // if they are intended to be empty. However, if they are ALL empty, we need some value.
+ isTuneIn := strings.EqualFold(s.DisplayName, "TUNEIN") || strings.EqualFold(s.SourceKeyType, "TUNEIN") || strings.EqualFold(s.ID, "TUNEIN")
+
+ if a.Name == "" {
+ a.Name = s.getFirstNonEmpty(s.Name, s.SourceName, s.Username, s.DisplayName)
+ }
+
+ if a.SourceName == "" && !isTuneIn {
+ a.SourceName = s.getFirstNonEmpty(s.SourceName, s.Name, s.Username, s.DisplayName)
+ }
+
+ if a.Username == "" && !isTuneIn {
+ a.Username = s.getFirstNonEmpty(s.Username, s.Name, s.SourceName, s.DisplayName)
+ }
if s.Secret != "" || s.SecretType != "" {
a.Credential = &sourceCredential{
@@ -678,7 +695,7 @@ type EmailAddressResponse struct {
type FullResponseSource struct {
ID string `json:"id" xml:"id,attr"`
Type string `json:"type" xml:"type,attr"`
- DisplayName string `json:"display_name,omitempty" xml:"displayName,attr,omitempty"`
+ DisplayName string `json:"display_name" xml:"displayName,attr"`
CreatedOn string `json:"created_on" xml:"createdOn"`
Credential struct {
Type string `json:"type" xml:"type,attr"`
@@ -733,6 +750,12 @@ type AccountFullResponse struct {
Sources []FullResponseSource `xml:"sources>source"`
}
+// AccountSourcesResponse represents the response from /streaming/account/{accountId}/sources.
+type AccountSourcesResponse struct {
+ XMLName xml.Name `xml:"sources"`
+ Sources []FullResponseSource `xml:"source"`
+}
+
// AccountDevice represents a device in the account response.
type AccountDevice struct {
DeviceID string `json:"device_id" xml:"deviceid,attr"`
diff --git a/pkg/service/datastore/datastore.go b/pkg/service/datastore/datastore.go
index 51cc479..4915625 100644
--- a/pkg/service/datastore/datastore.go
+++ b/pkg/service/datastore/datastore.go
@@ -1219,27 +1219,48 @@ func (ds *DataStore) getDefaultSources() []models.ConfiguredSource {
DisplayName: "AUX IN",
SourceKeyType: "AUX",
SourceKeyAccount: "AUX",
+ Type: "Audio",
Status: "READY",
+ CreatedOn: "2015-03-11T19:12:38.000+00:00",
+ UpdatedOn: "2015-03-11T19:12:38.000+00:00",
},
{
- ID: "10002",
- SourceKeyType: "INTERNET_RADIO",
- SecretType: "token",
- Status: "READY",
+ ID: "10002",
+ DisplayName: "",
+ SourceKeyType: "INTERNET_RADIO",
+ SourceKeyAccount: "",
+ SourceProviderID: "2",
+ Type: "Audio",
+ SecretType: "token",
+ Status: "READY",
+ CreatedOn: "2015-03-11T19:12:38.000+00:00",
+ UpdatedOn: "2015-03-11T19:12:38.000+00:00",
},
{
- ID: "10003",
- SourceKeyType: "LOCAL_INTERNET_RADIO",
- Secret: GenerateSerialSecret("local-internet-radio"),
- SecretType: "token",
- Status: "READY",
+ ID: "10003",
+ DisplayName: "",
+ SourceKeyType: "LOCAL_INTERNET_RADIO",
+ SourceKeyAccount: "",
+ SourceProviderID: "11",
+ Type: "Audio",
+ Secret: GenerateSerialSecret("local-internet-radio"),
+ SecretType: "token",
+ Status: "READY",
+ CreatedOn: "2019-01-24T08:18:37.000+00:00",
+ UpdatedOn: "2019-02-03T18:35:45.000+00:00",
},
{
- ID: "10004",
- SourceKeyType: "TUNEIN",
- Secret: GenerateSerialSecret("tunein"),
- SecretType: "token",
- Status: "READY",
+ ID: "10004",
+ DisplayName: "",
+ SourceKeyType: "TUNEIN",
+ SourceKeyAccount: "",
+ SourceProviderID: "25",
+ Type: "Audio",
+ Secret: GenerateSerialSecret("tunein"),
+ SecretType: "token",
+ Status: "READY",
+ CreatedOn: "2017-07-20T16:43:48.000+00:00",
+ UpdatedOn: "2017-07-20T16:43:48.000+00:00",
},
}
diff --git a/pkg/service/handlers/handlers_marge.go b/pkg/service/handlers/handlers_marge.go
index 7304304..0bfed37 100644
--- a/pkg/service/handlers/handlers_marge.go
+++ b/pkg/service/handlers/handlers_marge.go
@@ -26,7 +26,9 @@ func (s *Server) HandleMargeCreateAccount(w http.ResponseWriter, r *http.Request
}
var req models.MargeAccountCreateRequest
- if err := xml.Unmarshal(body, &req); err != nil {
+
+ err = xml.Unmarshal(body, &req)
+ if err != nil {
http.Error(w, "Invalid XML body: "+err.Error(), http.StatusBadRequest)
return
}
@@ -56,21 +58,22 @@ func (s *Server) HandleMargeCreateAccount(w http.ResponseWriter, r *http.Request
info.PreferredLanguage = "en"
}
- if err := s.ds.SaveAccountInfo(id, info); err != nil {
+ err = s.ds.SaveAccountInfo(id, info)
+ if 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,
+ data, err := marge.AccountFullToXML(s.ds, id)
+ if err != nil {
+ http.Error(w, "Failed to generate account XML", http.StatusInternalServerError)
+ return
}
w.Header().Set("Content-Type", "application/vnd.bose.streaming-v1.2+xml")
w.WriteHeader(http.StatusCreated)
- _ = xml.NewEncoder(w).Encode(resp)
+ _, _ = w.Write(data)
}
// HandleMargeLogin handles account login from Stockholm.
@@ -111,16 +114,16 @@ func (s *Server) HandleMargeLogin(w http.ResponseWriter, r *http.Request) {
return
}
- resp := models.AccountFullResponse{
- ID: accountID,
- AccountStatus: "ACTIVE",
- PreferredLanguage: "en",
+ data, err := marge.AccountFullToXML(s.ds, accountID)
+ if err != nil {
+ http.Error(w, "Failed to generate account XML", http.StatusInternalServerError)
+ return
}
// 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)
+ _, _ = w.Write(data)
}
// HandleMargeSourceProviders returns the Marge source providers.
@@ -165,6 +168,29 @@ func (s *Server) HandleMargeAccountFull(w http.ResponseWriter, r *http.Request)
_, _ = w.Write(data)
}
+// HandleMargeAccountSources returns the Marge account sources.
+func (s *Server) HandleMargeAccountSources(w http.ResponseWriter, r *http.Request) {
+ account := chi.URLParam(r, "account")
+
+ device := r.URL.Query().Get("device")
+
+ etag := strconv.FormatInt(s.ds.GetETagForAccount(account, device), 10)
+ if r.Header.Get("If-None-Match") == etag {
+ w.WriteHeader(http.StatusNotModified)
+ return
+ }
+
+ data, err := marge.AccountSourcesToXML(s.ds, account)
+ if err != nil {
+ http.Error(w, err.Error(), http.StatusInternalServerError)
+ return
+ }
+
+ w.Header().Set("Content-Type", "application/vnd.bose.streaming-v1.1+xml")
+ w.Header()["ETag"] = []string{etag}
+ _, _ = w.Write(data)
+}
+
// HandleMargePowerOn handles the Marge power on request.
func (s *Server) HandleMargePowerOn(w http.ResponseWriter, r *http.Request) {
body, err := io.ReadAll(r.Body)
diff --git a/pkg/service/handlers/handlers_marge_test.go b/pkg/service/handlers/handlers_marge_test.go
index 8efd6e0..3a29d96 100644
--- a/pkg/service/handlers/handlers_marge_test.go
+++ b/pkg/service/handlers/handlers_marge_test.go
@@ -64,6 +64,15 @@ func TestMargeCreateAccount(t *testing.T) {
t.Errorf("Expected 7-digit ID, got %v", resp.ID)
}
+ // Verify it has default sources
+ if len(resp.Sources) != 4 {
+ t.Errorf("Expected 4 default sources, got %d", len(resp.Sources))
+ } else {
+ if resp.Sources[0].ID != "10001" {
+ t.Errorf("Expected first source ID 10001, got %s", resp.Sources[0].ID)
+ }
+ }
+
// Verify it was saved in datastore
info, err := ds.GetAccountInfo(resp.ID)
if err != nil {
@@ -269,6 +278,123 @@ func TestMargeAccountFull(t *testing.T) {
}
}
+func TestMargeAccountSources(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)
+ account := "12345"
+ deviceID := "DEV1"
+
+ deviceDir := filepath.Join(tempDir, "accounts", account, "devices", deviceID)
+ _ = os.MkdirAll(deviceDir, 0755)
+
+ // Mock Sources.xml
+ sourcesXML := `
+
+
+
+
+ `
+ _ = os.WriteFile(filepath.Join(deviceDir, "Sources.xml"), []byte(sourcesXML), 0644)
+
+ r, _ := setupRouter("http://localhost:8001", ds)
+ ts := httptest.NewServer(r)
+ defer ts.Close()
+
+ res, err := http.Get(ts.URL + "/streaming/account/" + account + "/sources")
+ 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)
+ }
+
+ contentType := res.Header.Get("Content-Type")
+ if contentType != "application/vnd.bose.streaming-v1.1+xml" {
+ t.Errorf("Expected Content-Type application/vnd.bose.streaming-v1.1+xml, got %v", contentType)
+ }
+
+ body, _ := io.ReadAll(res.Body)
+ bodyStr := string(body)
+ if !strings.Contains(bodyStr, "SRC1") {
+ t.Errorf("Response missing expected source ID: %s", bodyStr)
+ }
+
+ // Verify current XML structure produced by marge.go
+ expectedSnippets := []string{
+ "",
+ "2024-01-01T00:00:00Z",
+ "2024-01-01T00:00:00Z",
+ "TOKEN1",
+ "User1",
+ "",
+ "",
+ "User1",
+ }
+
+ for _, snippet := range expectedSnippets {
+ if !strings.Contains(bodyStr, snippet) {
+ t.Errorf("Response missing expected snippet [%s]: %s", snippet, bodyStr)
+ }
+ }
+}
+
+func TestMargeAccountSourcesNoDevices(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)
+ account := "12345"
+
+ r, _ := setupRouter("http://localhost:8001", ds)
+ ts := httptest.NewServer(r)
+ defer ts.Close()
+
+ res, err := http.Get(ts.URL + "/streaming/account/" + account + "/sources")
+ 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)
+ }
+
+ body, _ := io.ReadAll(res.Body)
+ bodyStr := string(body)
+
+ // Verify that we get the default sources with correct IDs and empty display names
+ expectedSnippets := []string{
+ "",
+ "",
+ "displayName=\"\"", // for the other sources
+ }
+
+ for _, snippet := range expectedSnippets {
+ if !strings.Contains(bodyStr, snippet) {
+ t.Errorf("Response missing expected snippet [%s]: %s", snippet, bodyStr)
+ }
+ }
+
+ // Verify that 3 sources have empty display names
+ if strings.Count(bodyStr, "displayName=\"\"") != 3 {
+ t.Errorf("Expected 3 sources with empty displayName, got %d: %s", strings.Count(bodyStr, "displayName=\"\""), bodyStr)
+ }
+}
+
func TestMargePresets(t *testing.T) {
tempDir, err := os.MkdirTemp("", "st-test-*")
if err != nil {
@@ -707,27 +833,11 @@ func TestMargeNativeStreamingRoutes(t *testing.T) {
}
})
- t.Run("PUT /streaming/account/{account}/device/{device}/preset/{presetNumber} - missing Sources.xml", func(t *testing.T) {
- // Delete Sources.xml to trigger the error
- sourcesPath := filepath.Join(deviceDir, "Sources.xml")
- if err := os.Remove(sourcesPath); err != nil {
- t.Fatalf("Failed to remove Sources.xml: %v", err)
- }
- defer func() {
- // Restore Sources.xml for other tests
- _ = os.WriteFile(sourcesPath, []byte(`
-
-
-
-
-
- `), 0644)
- }()
-
+ t.Run("PUT /streaming/account/{account}/device/{device}/preset/{presetNumber} - valid Sources.xml", func(t *testing.T) {
payload := `
PUT Native Preset Singular
- TUNEIN
+ SRC1
/station/s888
station
`
diff --git a/pkg/service/handlers/main_test.go b/pkg/service/handlers/main_test.go
index a9a218a..59ce991 100644
--- a/pkg/service/handlers/main_test.go
+++ b/pkg/service/handlers/main_test.go
@@ -61,6 +61,7 @@ func setupRouter(targetURL string, ds *datastore.DataStore) (*chi.Mux, *Server)
r.Post("/device_setting/account/{account}/device/{device}/device_settings", server.HandleMargeUpdateDeviceSettings)
r.Get("/account/{account}/emailaddress", server.HandleMargeGetEmailAddress)
r.Get("/account/{account}/full", server.HandleMargeAccountFull)
+ r.Get("/account/{account}/sources", server.HandleMargeAccountSources)
r.Get("/software/update/account/{account}", server.HandleMargeSoftwareUpdate)
r.Post("/account", server.HandleMargeCreateAccount)
r.Post("/account/login", server.HandleMargeLogin)
diff --git a/pkg/service/marge/marge.go b/pkg/service/marge/marge.go
index dcd297a..270cd4a 100644
--- a/pkg/service/marge/marge.go
+++ b/pkg/service/marge/marge.go
@@ -826,11 +826,17 @@ func getAccountDevices(ds *datastore.DataStore, account string, entries []os.Dir
}
func getAccountSources(ds *datastore.DataStore, account, lastDeviceID string) []models.FullResponseSource {
- if lastDeviceID == "" {
- return nil
+ var (
+ sources []models.ConfiguredSource
+ err error
+ )
+
+ if lastDeviceID != "" {
+ sources, err = ds.GetConfiguredSources(account, lastDeviceID)
+ } else {
+ sources = ds.GetDefaultSources()
}
- sources, err := ds.GetConfiguredSources(account, lastDeviceID)
if err != nil {
return nil
}
@@ -846,30 +852,39 @@ func getAccountSources(ds *datastore.DataStore, account, lastDeviceID string) []
return fullSources
}
+// AccountSourcesToXML generates the account sources XML.
+func AccountSourcesToXML(ds *datastore.DataStore, account string) ([]byte, error) {
+ devicesDir := ds.AccountDevicesDir(account)
+
+ entries, err := os.ReadDir(devicesDir)
+ if err != nil && !os.IsNotExist(err) {
+ return nil, err
+ }
+
+ _, lastDeviceID := getAccountDevices(ds, account, entries)
+ resp := models.AccountSourcesResponse{
+ Sources: getAccountSources(ds, account, lastDeviceID),
+ }
+
+ data, err := xml.Marshal(resp)
+ if err != nil {
+ return nil, err
+ }
+
+ // Parity: use self-closing tags and handle empty sourceproviderid
+ data = bytes.ReplaceAll(data, []byte(""), []byte(""))
+ data = bytes.ReplaceAll(data, []byte(""), []byte(""))
+
+ return append([]byte(constants.XMLHeader), data...), nil
+}
+
// AccountFullToXML generates a complete account XML with devices, presets, and recents.
func AccountFullToXML(ds *datastore.DataStore, account string) ([]byte, error) {
devicesDir := ds.AccountDevicesDir(account)
- entries, err := os.ReadDir(devicesDir)
- if err != nil {
- if os.IsNotExist(err) {
- resp := models.AccountFullResponse{
- ID: account,
- AccountStatus: "OK",
- Mode: "global",
- PreferredLanguage: "en",
- }
- data, _ := xml.Marshal(resp)
-
- return append([]byte(constants.XMLHeader), data...), nil
- }
-
- return nil, err
- }
-
resp := models.AccountFullResponse{
ID: account,
- AccountStatus: "OK",
+ AccountStatus: "ACTIVE",
Mode: "global",
PreferredLanguage: "en",
}
@@ -877,6 +892,11 @@ func AccountFullToXML(ds *datastore.DataStore, account string) ([]byte, error) {
fillDefaultProviderSettings(account, &resp)
fillAccountInfo(ds, account, &resp)
+ entries, err := os.ReadDir(devicesDir)
+ if err != nil && !os.IsNotExist(err) {
+ return nil, err
+ }
+
devices, lastDeviceID := getAccountDevices(ds, account, entries)
resp.Devices = devices
resp.Sources = getAccountSources(ds, account, lastDeviceID)
@@ -991,6 +1011,22 @@ func UpdatePreset(ds *datastore.DataStore, account, device string, presetNumber
// Return XML for the single preset
PrepareConfiguredSource(matchingSrc)
+ syncMatchingSource(matchingSrc, recentInput{
+ Source: struct {
+ ID string `xml:"id,attr"`
+ Type string `xml:"type,attr"`
+ SourceName string `xml:"sourcename"`
+ SourceProviderID string `xml:"sourceproviderid"`
+ CreatedOn string `xml:"createdOn"`
+ UpdatedOn string `xml:"updatedOn"`
+ Credential struct {
+ Type string `xml:"type,attr"`
+ Value string `xml:",chardata"`
+ } `xml:"credential"`
+ }{
+ SourceName: newPresetElem.Name,
+ },
+ })
presetObj.SourceConfig = matchingSrc
presetObj.Username = newPresetElem.Name
@@ -1064,10 +1100,9 @@ func syncMatchingSource(matchingSrc *models.ConfiguredSource, input recentInput)
matchingSrc.ID = input.SourceID
}
- // Ensure DisplayName and SourceName are consistent
if matchingSrc.SourceName == "" && matchingSrc.DisplayName != "" {
// Parity: for some services like TuneIn, sourcename should be empty
- if matchingSrc.DisplayName != "TuneIn" && matchingSrc.DisplayName != "Other" {
+ if !strings.EqualFold(matchingSrc.DisplayName, "TUNEIN") && matchingSrc.DisplayName != "Other" {
matchingSrc.SourceName = matchingSrc.DisplayName
}
}
@@ -1076,12 +1111,11 @@ func syncMatchingSource(matchingSrc *models.ConfiguredSource, input recentInput)
matchingSrc.DisplayName = matchingSrc.SourceName
}
- if matchingSrc.SourceName == "" && matchingSrc.DisplayName != "" {
- matchingSrc.SourceName = matchingSrc.DisplayName
- }
-
if matchingSrc.Username == "" && matchingSrc.DisplayName != "" {
- matchingSrc.Username = matchingSrc.DisplayName
+ // Parity: for some services like TuneIn, username should be empty
+ if !strings.EqualFold(matchingSrc.DisplayName, "TUNEIN") && matchingSrc.DisplayName != "Other" {
+ matchingSrc.Username = matchingSrc.DisplayName
+ }
}
}
diff --git a/tests/integration/http-client/get_account_sources.http b/tests/integration/http-client/get_account_sources.http
new file mode 100644
index 0000000..eea6b5c
--- /dev/null
+++ b/tests/integration/http-client/get_account_sources.http
@@ -0,0 +1,41 @@
+### GET /streaming/account/{accountId}/sources
+GET {{host}}/streaming/account/3230304/sources
+Accept: application/vnd.bose.streaming-v1.1+xml
+Authorization: Bearer dummy-token
+
+> {%
+ client.test("Response is 200 OK", function() {
+ client.assert(response.status === 200, "Response status is not 200");
+ });
+
+ client.test("Content-Type is correct", function() {
+ client.assert(response.contentType.mimeType === "application/vnd.bose.streaming-v1.1+xml", "Wrong content type");
+ });
+
+ client.test("Response is XML and contains sources", function() {
+ const doc = response.body;
+ const sources = doc.documentElement;
+ client.assert(sources.nodeName === "sources", "Root element is not 'sources'");
+
+ const sourceList = sources.getElementsByTagName("source");
+ client.assert(sourceList.length === 4, "Expected 4 source elements, found " + sourceList.length);
+
+ const expectedIds = ["10001", "10002", "10003", "10004"];
+ for (let i = 0; i < sourceList.length; i++) {
+ const source = sourceList.item(i);
+ client.assert(source.getAttribute("id") === expectedIds[i], "Wrong source ID at index " + i);
+ client.assert(source.getAttribute("type") === "Audio", "Wrong source type at index " + i);
+
+ const credentials = source.getElementsByTagName("credential");
+ client.assert(credentials.length > 0, "Missing credential for source " + expectedIds[i]);
+ const credential = credentials.item(0);
+ client.assert(credential.getAttribute("type") === "token", "Wrong credential type for source " + expectedIds[i]);
+
+ const expectedChildren = ["createdOn", "updatedOn", "name", "sourceproviderid", "sourcename", "sourceSettings", "username"];
+ expectedChildren.forEach(childName => {
+ const children = source.getElementsByTagName(childName);
+ client.assert(children.length > 0, "Missing " + childName + " for source " + expectedIds[i]);
+ });
+ }
+ });
+%}