Files
Bose-SoundTouch/pkg/service/soundtouchweb/websocket_test.go
T
Tobias GesellchenandClaude Sonnet 5 9629d3e057 fix(player): don't mark a device connected on GetGroup success alone
statusUpdated (which drives IsConnected) ORed in
"stereoCapable && groupErr == nil" alongside the five substantive
status fetches. Since GetGroup is gated to stereo-capable models and
trivially succeeds even when a device is struggling (an empty
<group/> is a near-guaranteed reply, per Client.GetGroup's doc
comment), a round where NowPlaying/Volume/Presets/Sources/Bass all
fail but GetGroup alone succeeds would still report the device
connected -- masking a real status-refresh failure specifically on
ST10 hardware.

Removed the extra OR term entirely: IsConnected now depends only on
the five substantive fetches, matching the comment's own stated
intent ("mirrors prior behaviour"). GetGroup's own success/failure
still drives whether Group gets refreshed (unchanged, see
ApplyPolledGroup below), just no longer feeds the connectivity signal.

Added TestUpdateDeviceStatusNotConnectedWhenOnlyGroupSucceeds,
verified to fail against the prior logic and pass with this fix.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-30 22:27:56 +02:00

251 lines
8.3 KiB
Go

package soundtouchweb
import (
"net/http"
"net/http/httptest"
"sync/atomic"
"testing"
"time"
"github.com/gesellix/bose-soundtouch/pkg/client"
"github.com/gesellix/bose-soundtouch/pkg/models"
"github.com/gesellix/bose-soundtouch/pkg/service/soundtouchweb/webtypes"
)
func TestUpdateDeviceStatusRefreshesGroup(t *testing.T) {
server := newStatusTestServer(t, http.StatusOK, `<group id="pair-1">
<name>Living Room</name>
<masterDeviceId>master-1</masterDeviceId>
<roles>
<groupRole><deviceId>master-1</deviceId><role>LEFT</role></groupRole>
<groupRole><deviceId>member-1</deviceId><role>RIGHT</role></groupRole>
</roles>
<status>GROUP_OK</status>
</group>`)
defer server.Close()
conn := webtypes.NewDeviceConnection(client.NewClientFromHost(server.URL), &models.DeviceInfo{Type: "SoundTouch 10"})
NewWebApp().UpdateDeviceStatus("device-1", conn)
status := conn.Status()
if status.Group == nil {
t.Fatal("Group was not populated by UpdateDeviceStatus")
}
if status.Group.ID != "pair-1" || status.Group.MasterDeviceID != "master-1" {
t.Errorf("Group = %+v, want refreshed stereo pair", status.Group)
}
if len(status.Group.Roles.Roles) != 2 {
t.Errorf("group roles = %d, want 2", len(status.Group.Roles.Roles))
}
if !status.IsConnected {
t.Error("successful status refresh should mark the device connected")
}
}
func TestUpdateDeviceStatusPreservesGroupOnError(t *testing.T) {
server := newStatusTestServer(t, http.StatusInternalServerError, "group unavailable")
defer server.Close()
conn := webtypes.NewDeviceConnection(client.NewClientFromHost(server.URL), &models.DeviceInfo{Type: "SoundTouch 10"})
existing := &models.Group{ID: "pair-old", Name: "Existing Pair"}
conn.SetStatus(&webtypes.DeviceStatus{Group: existing})
NewWebApp().UpdateDeviceStatus("device-1", conn)
status := conn.Status()
if status.Group != existing {
t.Errorf("Group = %+v, want previous group preserved on refresh error", status.Group)
}
if !status.IsConnected {
t.Error("other successful status fetches should keep the device connected")
}
}
func TestUpdateDeviceStatusSkipsGroupForNonStereoModel(t *testing.T) {
var groupRequested atomic.Bool
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/getGroup" {
groupRequested.Store(true)
http.Error(w, "unsupported endpoint", http.StatusInternalServerError)
return
}
responses := map[string]string{
"/now_playing": `<nowPlaying source="STANDBY"><playStatus>STOP_STATE</playStatus></nowPlaying>`,
"/volume": `<volume><targetvolume>10</targetvolume><actualvolume>10</actualvolume><muteenabled>false</muteenabled></volume>`,
"/presets": `<presets/>`,
"/sources": `<sources/>`,
"/bass": `<bass><targetbass>0</targetbass><actualbass>0</actualbass></bass>`,
}
body, ok := responses[r.URL.Path]
if !ok {
http.NotFound(w, r)
return
}
w.Header().Set("Content-Type", "application/xml")
_, _ = w.Write([]byte(body))
}))
defer server.Close()
for _, model := range []string{"SoundTouch 20", "SoundTouch 30"} {
t.Run(model, func(t *testing.T) {
conn := webtypes.NewDeviceConnection(client.NewClientFromHost(server.URL), &models.DeviceInfo{Type: model})
NewWebApp().UpdateDeviceStatus("device-1", conn)
if !conn.Status().IsConnected {
t.Fatal("successful ordinary status requests should mark a non-stereo model connected")
}
})
}
if groupRequested.Load() {
t.Fatal("UpdateDeviceStatus requested /getGroup for a non-stereo model")
}
}
// TestUpdateDeviceStatusNotConnectedWhenOnlyGroupSucceeds covers a stereo-
// capable device where every substantive status fetch fails but /getGroup
// alone succeeds (a near-guaranteed reply -- even an empty <group/> is a
// success, see Client.GetGroup's doc comment). IsConnected must not be set
// from GetGroup's success alone, or a device with genuinely stale
// NowPlaying/Volume/Presets/Sources/Bass data would be reported connected.
func TestUpdateDeviceStatusNotConnectedWhenOnlyGroupSucceeds(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/getGroup" {
w.Header().Set("Content-Type", "application/xml")
_, _ = w.Write([]byte(`<group/>`))
return
}
http.Error(w, "device struggling", http.StatusInternalServerError)
}))
defer server.Close()
conn := webtypes.NewDeviceConnection(client.NewClientFromHost(server.URL), &models.DeviceInfo{Type: "SoundTouch 10"})
NewWebApp().UpdateDeviceStatus("device-1", conn)
if conn.Status().IsConnected {
t.Fatal("IsConnected must stay false when every substantive status fetch failed, even though GetGroup alone succeeded")
}
}
func TestApplyGroupUpdatedEventReplacesGroup(t *testing.T) {
conn := webtypes.NewDeviceConnection(nil, nil)
previousActivity := time.Unix(1, 0)
conn.SetStatus(&webtypes.DeviceStatus{
Group: &models.Group{ID: "pair-old"},
Volume: &models.Volume{ActualVolume: 25},
IsConnected: true,
LastActivity: previousActivity,
})
event := &models.GroupUpdatedEvent{
Group: models.Group{ID: "pair-new", Name: "Renamed Pair"},
}
applyGroupUpdatedEvent(conn, event)
status := conn.Status()
if status.Group != &event.Group || status.Group.ID != "pair-new" {
t.Errorf("Group = %+v, want event group", status.Group)
}
if status.Volume == nil || status.Volume.ActualVolume != 25 || !status.IsConnected {
t.Errorf("unrelated status fields were not preserved: %+v", status)
}
if !status.LastActivity.After(previousActivity) {
t.Errorf("LastActivity = %s, want after %s", status.LastActivity, previousActivity)
}
teardown := &models.GroupUpdatedEvent{Group: models.Group{}}
applyGroupUpdatedEvent(conn, teardown)
if conn.Status().Group != nil {
t.Errorf("teardown event did not clear the group: %+v", conn.Status().Group)
}
}
func TestPeriodicPlayerMessagesPreserveStatusUpdateStream(t *testing.T) {
app := NewWebApp()
group := testStereoGroup()
for _, entry := range []DeviceEntry{
projectionDevice("192.0.2.10", "left-id", "Living Room", true, group),
projectionDevice("192.0.2.11", "right-id", "Living Room", true, group),
projectionDevice("192.0.2.12", "standalone-id", "Kitchen", false, nil),
} {
app.AddDevice(entry.ID, entry.Device)
}
messages := app.periodicPlayerMessages()
if len(messages) != 3 {
t.Fatalf("periodic messages = %d, want one devices frame and two connected status updates: %+v", len(messages), messages)
}
if messages[0].Type != "devices" {
t.Fatalf("first periodic message type = %q, want devices", messages[0].Type)
}
devices, ok := messages[0].Data.(map[string]deviceView)
if !ok || len(devices) != 2 || devices["192.0.2.10"].StereoPair == nil {
t.Fatalf("periodic devices frame is not the logical projection: %#v", messages[0].Data)
}
statusUpdates := make(map[string]bool)
for _, message := range messages[1:] {
if message.Type != "status_update" {
t.Fatalf("periodic message type = %q, want status_update", message.Type)
}
statusUpdates[message.DeviceID] = true
}
if !statusUpdates["192.0.2.10"] || !statusUpdates["192.0.2.11"] || statusUpdates["192.0.2.12"] {
t.Fatalf("unexpected status_update device IDs: %+v", statusUpdates)
}
}
func newStatusTestServer(t *testing.T, groupStatus int, groupBody string) *httptest.Server {
t.Helper()
responses := map[string]string{
"/now_playing": `<nowPlaying source="STANDBY"><playStatus>STOP_STATE</playStatus></nowPlaying>`,
"/volume": `<volume><targetvolume>10</targetvolume><actualvolume>10</actualvolume><muteenabled>false</muteenabled></volume>`,
"/presets": `<presets/>`,
"/sources": `<sources/>`,
"/bass": `<bass><targetbass>0</targetbass><actualbass>0</actualbass></bass>`,
}
return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
t.Errorf("method for %s = %s, want GET", r.URL.Path, r.Method)
w.WriteHeader(http.StatusMethodNotAllowed)
return
}
w.Header().Set("Content-Type", "application/xml")
if r.URL.Path == "/getGroup" {
w.WriteHeader(groupStatus)
_, _ = w.Write([]byte(groupBody))
return
}
body, ok := responses[r.URL.Path]
if !ok {
t.Errorf("unexpected status endpoint %q", r.URL.Path)
w.WriteHeader(http.StatusNotFound)
return
}
_, _ = w.Write([]byte(body))
}))
}