From a06657f3f5ef31c17ca658ef62fb0e1b22cb142d Mon Sep 17 00:00:00 2001 From: Tobias Gesellchen Date: Sat, 28 Mar 2026 22:48:18 +0100 Subject: [PATCH] Add more e2e tests --- Makefile | 1 + cmd/soundtouch-service/main.go | 4 + pkg/service/datastore/datastore.go | 177 ++++++++++-------- pkg/service/handlers/handlers_marge.go | 31 ++- pkg/service/handlers/handlers_marge_test.go | 48 ++++- pkg/service/handlers/handlers_media.go | 3 + pkg/service/handlers/main_test.go | 4 + pkg/service/marge/marge.go | 10 +- .../http-client/customer_support.http | 15 ++ .../http-client/register_device.http | 37 ++++ 10 files changed, 240 insertions(+), 90 deletions(-) create mode 100644 tests/integration/http-client/customer_support.http diff --git a/Makefile b/Makefile index 919dba5..920aa5a 100644 --- a/Makefile +++ b/Makefile @@ -121,6 +121,7 @@ test-http-client: --env ci \ /workdir/create_account.http \ /workdir/register_device.http \ + /workdir/customer_support.http \ /workdir/power_on.http \ /workdir/get_provider_settings.http \ /workdir/get_full_account.http \ diff --git a/cmd/soundtouch-service/main.go b/cmd/soundtouch-service/main.go index 4aa2f32..63574c5 100644 --- a/cmd/soundtouch-service/main.go +++ b/cmd/soundtouch-service/main.go @@ -679,6 +679,10 @@ func setupRouter(server *handlers.Server) *chi.Mux { r.Get("/sourceproviders", server.HandleMargeSourceProviders) r.Post("/account", server.HandleMargeCreateAccount) r.Post("/account/login", server.HandleMargeLogin) + r.Route("/account/{account}/device", func(r chi.Router) { + r.Post("/", server.HandleMargeAddDevice) + r.Post("/{device}", server.HandleMargeAddDevice) + }) 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/service/datastore/datastore.go b/pkg/service/datastore/datastore.go index 18cec93..9dea77b 100644 --- a/pkg/service/datastore/datastore.go +++ b/pkg/service/datastore/datastore.go @@ -707,40 +707,7 @@ func (ds *DataStore) SaveDeviceInfo(account, device string, info *models.Service } // Try to load existing device info to avoid overwriting existing details with empty values. - existing, _ := ds.getDeviceInfoNoLock(account, device) - if existing != nil { - if info.Name == "" { - info.Name = existing.Name - } - - if info.ProductCode == "" { - info.ProductCode = existing.ProductCode - } - - if info.DeviceSerialNumber == "" { - info.DeviceSerialNumber = existing.DeviceSerialNumber - } - - if info.ProductSerialNumber == "" { - info.ProductSerialNumber = existing.ProductSerialNumber - } - - if info.FirmwareVersion == "" { - info.FirmwareVersion = existing.FirmwareVersion - } - - if info.IPAddress == "" { - info.IPAddress = existing.IPAddress - } - - if info.MacAddress == "" { - info.MacAddress = existing.MacAddress - } - - if info.DiscoveryMethod == "" { - info.DiscoveryMethod = existing.DiscoveryMethod - } - } + ds.mergeWithExistingDeviceInfo(account, device, info) dir := ds.AccountDeviceDir(account, device) if err := os.MkdirAll(dir, 0755); err != nil { @@ -749,12 +716,6 @@ func (ds *DataStore) SaveDeviceInfo(account, device string, info *models.Service path := filepath.Join(dir, constants.DeviceInfoFile) - type ComponentXML struct { - ComponentCategory string `xml:"componentCategory"` - SoftwareVersion string `xml:"softwareVersion,omitempty"` - SerialNumber string `xml:"serialNumber,omitempty"` - } - type NetworkInfoXML struct { Type string `xml:"type,attr"` IPAddress string `xml:"ipAddress"` @@ -767,24 +728,13 @@ func (ds *DataStore) SaveDeviceInfo(account, device string, info *models.Service Name string `xml:"name"` Type string `xml:"type"` ModuleType string `xml:"moduleType"` - Components []ComponentXML `xml:"components>component"` + Components []componentXML `xml:"components>component"` NetworkInfo []NetworkInfoXML `xml:"networkInfo"` DiscoveryMethod string `xml:"discoveryMethod,omitempty"` } // Parsing product code back to type and moduleType (best effort) - // Python: f"{type} {module_type}" - devType := info.ProductCode - moduleType := "" - - for i := 0; i < len(info.ProductCode); i++ { - if info.ProductCode[i] == ' ' { - devType = info.ProductCode[:i] - moduleType = info.ProductCode[i+1:] - - break - } - } + devType, moduleType := ds.parseProductCode(info.ProductCode) ix := InfoXML{ DeviceID: info.DeviceID, @@ -798,27 +748,7 @@ func (ds *DataStore) SaveDeviceInfo(account, device string, info *models.Service ix.DiscoveryMethod = "sync_full" } - for _, comp := range info.Components { - ix.Components = append(ix.Components, ComponentXML{ - ComponentCategory: comp.Category, - SoftwareVersion: comp.SoftwareVersion, - SerialNumber: comp.SerialNumber, - }) - } - - if len(ix.Components) == 0 { - ix.Components = []ComponentXML{ - { - ComponentCategory: "SCM", - SoftwareVersion: info.FirmwareVersion, - SerialNumber: info.DeviceSerialNumber, - }, - { - ComponentCategory: "PackagedProduct", - SerialNumber: info.ProductSerialNumber, - }, - } - } + ix.Components = ds.buildComponentsXML(info) ix.NetworkInfo = []NetworkInfoXML{ { @@ -838,7 +768,104 @@ func (ds *DataStore) SaveDeviceInfo(account, device string, info *models.Service return os.WriteFile(path, append(header, data...), 0644) } -// SaveAccountInfo saves account-level metadata to the datastore. +func (ds *DataStore) mergeWithExistingDeviceInfo(account, device string, info *models.ServiceDeviceInfo) { + existing, _ := ds.getDeviceInfoNoLock(account, device) + if existing == nil { + return + } + + if info.Name == "" { + info.Name = existing.Name + } + + if info.ProductCode == "" { + info.ProductCode = existing.ProductCode + } + + if info.DeviceSerialNumber == "" { + info.DeviceSerialNumber = existing.DeviceSerialNumber + } + + if info.ProductSerialNumber == "" { + info.ProductSerialNumber = existing.ProductSerialNumber + } + + if info.FirmwareVersion == "" { + info.FirmwareVersion = existing.FirmwareVersion + } + + if info.IPAddress == "" { + info.IPAddress = existing.IPAddress + } + + if info.MacAddress == "" { + info.MacAddress = existing.MacAddress + } + + if info.DiscoveryMethod == "" { + info.DiscoveryMethod = existing.DiscoveryMethod + } +} + +func (ds *DataStore) parseProductCode(productCode string) (string, string) { + devType := productCode + moduleType := "" + + for i := 0; i < len(productCode); i++ { + if productCode[i] == ' ' { + devType = productCode[:i] + moduleType = productCode[i+1:] + + break + } + } + + return devType, moduleType +} + +type componentXML struct { + ComponentCategory string `xml:"componentCategory"` + SoftwareVersion string `xml:"softwareVersion,omitempty"` + SerialNumber string `xml:"serialNumber,omitempty"` +} + +func (ds *DataStore) buildComponentsXML(info *models.ServiceDeviceInfo) []componentXML { + var components []componentXML + for _, comp := range info.Components { + components = append(components, componentXML{ + ComponentCategory: comp.Category, + SoftwareVersion: comp.SoftwareVersion, + SerialNumber: comp.SerialNumber, + }) + } + + if len(components) == 0 && (info.FirmwareVersion != "" || info.DeviceSerialNumber != "" || info.ProductSerialNumber != "") { + components = []componentXML{ + { + ComponentCategory: "SCM", + SoftwareVersion: info.FirmwareVersion, + SerialNumber: info.DeviceSerialNumber, + }, + { + ComponentCategory: "PackagedProduct", + SerialNumber: info.ProductSerialNumber, + }, + } + } else if len(components) > 0 { + if info.FirmwareVersion != "" { + for i := range components { + if components[i].ComponentCategory == "SCM" { + components[i].SoftwareVersion = info.FirmwareVersion + break + } + } + } + } + + return components +} + +// SaveAccountInfo stores account-level metadata in the datastore. func (ds *DataStore) SaveAccountInfo(accountID string, info *models.ServiceAccountInfo) error { if ds == nil || ds.DataDir == "" || accountID == "" { return nil diff --git a/pkg/service/handlers/handlers_marge.go b/pkg/service/handlers/handlers_marge.go index 9f9ca6a..b65cd7b 100644 --- a/pkg/service/handlers/handlers_marge.go +++ b/pkg/service/handlers/handlers_marge.go @@ -543,7 +543,7 @@ func (s *Server) HandleMargeCustomerSupport(w http.ResponseWriter, r *http.Reque } var req models.CustomerSupportRequest - if err := xml.Unmarshal(body, &req); err != nil { + if err = xml.Unmarshal(body, &req); err != nil { // Log error but might still return 200 as Bose expects log.Printf("Failed to unmarshal CustomerSupportRequest: %v", err) } @@ -561,5 +561,34 @@ func (s *Server) HandleMargeCustomerSupport(w http.ResponseWriter, r *http.Reque }, } s.ds.AddDeviceEvent(req.Device.ID, event) + + // Update DeviceInfo if possible + devices, err := s.ds.ListAllDevices() + if err == nil { + var account string + + for i := range devices { + dev := &devices[i] + if dev.DeviceID == req.Device.ID { + account = dev.AccountID + break + } + } + + if account != "" { + info, err := s.ds.GetDeviceInfo(account, req.Device.ID) + if err == nil && info != nil { + info.IPAddress = req.DiagnosticData.DeviceLandscape.IPAddress + + info.FirmwareVersion = req.Device.FirmwareVersion + if len(req.DiagnosticData.DeviceLandscape.MacAddresses) > 0 { + info.MacAddress = req.DiagnosticData.DeviceLandscape.MacAddresses[0] + } + + _ = s.ds.SaveDeviceInfo(account, req.Device.ID, info) + } + } + } + w.WriteHeader(http.StatusOK) } diff --git a/pkg/service/handlers/handlers_marge_test.go b/pkg/service/handlers/handlers_marge_test.go index 86b0d94..95f7f65 100644 --- a/pkg/service/handlers/handlers_marge_test.go +++ b/pkg/service/handlers/handlers_marge_test.go @@ -1030,11 +1030,23 @@ func TestMargeAdvancedFeatures(t *testing.T) { }) t.Run("CustomerSupport", func(t *testing.T) { - payload := ` + account := "A123" + deviceId := "587A628A4042" + macAddress := "AABBCCDDEEFF" + ipAddress := "192.168.1.100" + firmware := "27.0.6" + + // Pre-register device + _ = ds.SaveDeviceInfo(account, deviceId, &models.ServiceDeviceInfo{ + DeviceID: deviceId, + Name: "TestDevice", + }) + + payload := fmt.Sprintf(` - + P123 - 27.0.6 + %s SN123 @@ -1042,10 +1054,13 @@ func TestMargeAdvancedFeatures(t *testing.T) { Good - 192.168.1.100 + + %s + + %s - ` + `, deviceId, firmware, macAddress, ipAddress) res, err := http.Post(ts.URL+"/marge/streaming/support/customersupport", "application/vnd.bose.streaming-v1.2+xml", strings.NewReader(payload)) if err != nil { @@ -1063,17 +1078,15 @@ func TestMargeAdvancedFeatures(t *testing.T) { } // Verify event was recorded - events := ds.GetDeviceEvents("587A628A4042") + events := ds.GetDeviceEvents(deviceId) found := false for _, e := range events { if e.Type == "customer-support-upload" { found = true - - if e.Data["firmware"] != "27.0.6" { - t.Errorf("Expected firmware 27.0.6, got %v", e.Data["firmware"]) + if e.Data["firmware"] != firmware { + t.Errorf("Expected firmware %s, got %v", firmware, e.Data["firmware"]) } - break } } @@ -1081,6 +1094,21 @@ func TestMargeAdvancedFeatures(t *testing.T) { if !found { t.Error("Customer support event not found in event log") } + + // Verify DeviceInfo was updated + info, err := ds.GetDeviceInfo(account, deviceId) + if err != nil { + t.Fatalf("Failed to get device info: %v", err) + } + if info.IPAddress != ipAddress { + t.Errorf("Expected updated IP %s, got %s", ipAddress, info.IPAddress) + } + if info.MacAddress != macAddress { + t.Errorf("Expected updated MAC %s, got %s", macAddress, info.MacAddress) + } + if info.FirmwareVersion != firmware { + t.Errorf("Expected updated firmware %s, got %s", firmware, info.FirmwareVersion) + } }) t.Run("AddRecent_Reproduction", func(t *testing.T) { diff --git a/pkg/service/handlers/handlers_media.go b/pkg/service/handlers/handlers_media.go index 03e0679..2d819af 100644 --- a/pkg/service/handlers/handlers_media.go +++ b/pkg/service/handlers/handlers_media.go @@ -20,6 +20,9 @@ var mediaFS embed.FS //go:embed static/bmx_services.json var bmxServicesJSON []byte +// Upstream source available at https://worldwide.bose.com/updates/soundtouch?serialnumber=_serial_ +// which results in a redirect to https://downloads.bose.com/ced/soundtouch/mr4_22097fe2/index.xml +// //go:embed static/swupdate.xml var swUpdateXML []byte diff --git a/pkg/service/handlers/main_test.go b/pkg/service/handlers/main_test.go index b715afd..5e27d68 100644 --- a/pkg/service/handlers/main_test.go +++ b/pkg/service/handlers/main_test.go @@ -39,6 +39,10 @@ func setupRouter(targetURL string, ds *datastore.DataStore) (*chi.Mux, *Server) streamingRoutes := func(r chi.Router) { r.Get("/sourceproviders", server.HandleMargeSourceProviders) + r.Route("/account/{account}/device", func(r chi.Router) { + r.Post("/", server.HandleMargeAddDevice) + r.Post("/{device}", server.HandleMargeAddDevice) + }) 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/service/marge/marge.go b/pkg/service/marge/marge.go index 1abc49e..8c32af6 100644 --- a/pkg/service/marge/marge.go +++ b/pkg/service/marge/marge.go @@ -971,16 +971,18 @@ func formatRecentResponse(recentObj *models.ServiceRecent, matchingSrc *models.C // AddDeviceToAccount adds a new device to the specified account. func AddDeviceToAccount(ds *datastore.DataStore, account string, sourceXML []byte) ([]byte, error) { var newDeviceElem struct { - DeviceID string `xml:"deviceid,attr"` - Name string `xml:"name"` + DeviceID string `xml:"deviceid,attr"` + Name string `xml:"name"` + MACAddress string `xml:"macaddress"` } if err := xml.Unmarshal(sourceXML, &newDeviceElem); err != nil { return nil, err } info := &models.ServiceDeviceInfo{ - DeviceID: newDeviceElem.DeviceID, - Name: newDeviceElem.Name, + DeviceID: newDeviceElem.DeviceID, + Name: newDeviceElem.Name, + MacAddress: newDeviceElem.MACAddress, // Other fields will be filled by discovery later or default } diff --git a/tests/integration/http-client/customer_support.http b/tests/integration/http-client/customer_support.http new file mode 100644 index 0000000..37dfe58 --- /dev/null +++ b/tests/integration/http-client/customer_support.http @@ -0,0 +1,15 @@ +### POST /streaming/support/customersupport +POST {{host}}/streaming/support/customersupport +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 {{token}} + +{{serialNumber}}27.0.6.46330.5043500 epdbuild.trunk.hepdswbld04.2022-08-04T11:20:29{{serialNumber}}Excellent{{gatewayIp}}{{macAddress1}}{{macAddress2}}{{deviceIp}}Wireless + +> {% + client.test("Customer support data uploaded 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 index ecd7f63..6336ebb 100644 --- a/tests/integration/http-client/register_device.http +++ b/tests/integration/http-client/register_device.http @@ -6,6 +6,7 @@ Authorization: Bearer {{token}} {{deviceName}} + {{macAddress1}} > {% @@ -17,3 +18,39 @@ Authorization: Bearer {{token}} client.assert(device.getAttribute("deviceid") === client.variables.environment.get("deviceId"), "Response body should contain the deviceId"); }); %} + +### POST /streaming/account/{{accountId}}/device/ (Register Device Variant) +POST {{host}}/streaming/account/{{accountId}}/device/ +Content-Type: application/vnd.bose.streaming-v1.2+xml +Authorization: Bearer {{token}} + + + + {{deviceName}} + {{macAddress1}}mac + + +> {% + client.test("Device registered successfully (variant)", 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"); + + const createdOn = device.getElementsByTagName("createdOn")[0]; + client.assert(createdOn !== undefined, "Response body should contain "); + client.assert(createdOn.textContent.length > 0, "createdOn should not be empty"); + + const name = device.getElementsByTagName("name")[0]; + client.assert(name !== undefined, "Response body should contain "); + client.assert(name.textContent === client.variables.environment.get("deviceName"), "name should match requested name"); + + const updatedOn = device.getElementsByTagName("updatedOn")[0]; + client.assert(updatedOn !== undefined, "Response body should contain "); + client.assert(updatedOn.textContent === createdOn.textContent, "updatedOn should match createdOn for a new device"); + + const ipaddress = device.getElementsByTagName("ipaddress")[0]; + client.assert(ipaddress !== undefined, "Response body should contain "); + }); +%}