Files
Bose-SoundTouch/pkg/client/zone_test.go
T
Tobias GesellchenandClaude Opus 4.8 720d2abc5c fix(zone): remove a member via /removeZoneSlave instead of a /setZone rebuild (refs #511)
Removing one member from a multi-member zone did nothing. The remove
paths rebuilt the zone with /setZone and the remaining members, but
/setZone is additive: it never drops a member that is simply absent from
the list. It only "removed" when the resulting set was empty (equivalent
to dissolve), which is why removing the last member worked but removing
one of several did not.

Switch all three remove paths to the dedicated /removeZoneSlave endpoint
(already implemented as client.RemoveZoneSlave):

- HandleZoneRemove  (web UI "remove member")
- HandleZoneLeave   (web UI slave "leave zone")
- RemoveFromZone    (client lib, used by CLI `zone remove`)

DissolveZone (setZone master-only) and HandleZoneAdd (additive setZone)
are correct and unchanged. Adds handler regression tests for remove/leave
and rewrites TestClient_RemoveFromZone to assert /removeZoneSlave (the old
test removed one of two members but only checked that setZone was called,
never that the member was dropped).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-27 20:25:41 +02:00

719 lines
18 KiB
Go

package client
import (
"io"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"github.com/gesellix/bose-soundtouch/pkg/models"
)
func TestClient_GetZone(t *testing.T) {
tests := []struct {
name string
responseXML string
responseStatus int
expectError bool
expectedMaster string
expectedMembers int
}{
{
name: "Standalone device",
responseXML: `<?xml version="1.0" encoding="UTF-8" ?>
<zone master="ABCD1234EFGH"></zone>`,
responseStatus: http.StatusOK,
expectError: false,
expectedMaster: "ABCD1234EFGH",
expectedMembers: 0,
},
{
name: "Zone with members",
responseXML: `<?xml version="1.0" encoding="UTF-8" ?>
<zone master="ABCD1234EFGH">
<member ipaddress="192.0.2.11">EFGH5678IJKL</member>
<member ipaddress="192.0.2.12">IJKL9012MNOP</member>
</zone>`,
responseStatus: http.StatusOK,
expectError: false,
expectedMaster: "ABCD1234EFGH",
expectedMembers: 2,
},
{
name: "Server error",
responseXML: `<error>Server Error</error>`,
responseStatus: http.StatusInternalServerError,
expectError: true,
expectedMaster: "",
expectedMembers: 0,
},
{
name: "Device not found",
responseXML: `<?xml version="1.0" encoding="UTF-8" ?>
<errors deviceID="ABCD1234EFGH">
<error value="7" name="DEVICE_NOT_FOUND_ERROR">Device not found</error>
</errors>`,
responseStatus: http.StatusNotFound,
expectError: true,
expectedMaster: "",
expectedMembers: 0,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/getZone" {
t.Errorf("Expected path /getZone, got %s", r.URL.Path)
}
if r.Method != http.MethodGet {
t.Errorf("Expected GET method, got %s", r.Method)
}
w.Header().Set("Content-Type", "application/xml")
w.WriteHeader(tt.responseStatus)
_, _ = w.Write([]byte(tt.responseXML))
}))
defer server.Close()
client := createTestClient(server.URL)
zone, err := client.GetZone()
if tt.expectError {
if err == nil {
t.Error("Expected error, but got none")
}
return
}
if err != nil {
t.Errorf("Expected no error, but got: %v", err)
return
}
if zone.Master != tt.expectedMaster {
t.Errorf("Expected master %s, got %s", tt.expectedMaster, zone.Master)
}
if len(zone.Members) != tt.expectedMembers {
t.Errorf("Expected %d members, got %d", tt.expectedMembers, len(zone.Members))
}
})
}
}
func TestClient_SetZone(t *testing.T) {
tests := []struct {
name string
zoneRequest *models.ZoneRequest
responseStatus int
expectError bool
errorMessage string
}{
{
name: "Valid zone request",
zoneRequest: func() *models.ZoneRequest {
zr := models.NewZoneRequest("ABCD1234EFGH")
zr.AddMember("EFGH5678IJKL", "192.0.2.11")
return zr
}(),
responseStatus: http.StatusOK,
expectError: false,
},
{
name: "Standalone zone request",
zoneRequest: models.NewZoneRequest("ABCD1234EFGH"),
responseStatus: http.StatusOK,
expectError: false,
},
{
name: "Invalid zone request - empty master",
zoneRequest: &models.ZoneRequest{},
expectError: true,
errorMessage: "invalid zone request: master device ID is required",
},
{
name: "Invalid zone request - duplicate device",
zoneRequest: func() *models.ZoneRequest {
zr := models.NewZoneRequest("ABCD1234EFGH")
zr.AddMember("ABCD1234EFGH", "192.0.2.10") // Same as master
return zr
}(),
expectError: true,
errorMessage: "invalid zone request: duplicate device ID found: ABCD1234EFGH",
},
{
name: "Server error response",
zoneRequest: models.NewZoneRequest("ABCD1234EFGH"),
responseStatus: http.StatusInternalServerError,
expectError: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/setZone" {
t.Errorf("Expected path /setZone, got %s", r.URL.Path)
}
if r.Method != http.MethodPost {
t.Errorf("Expected POST method, got %s", r.Method)
}
// Check Content-Type
contentType := r.Header.Get("Content-Type")
if contentType != "application/xml" {
t.Errorf("Expected Content-Type application/xml, got %s", contentType)
}
w.Header().Set("Content-Type", "application/xml")
w.WriteHeader(tt.responseStatus)
if tt.responseStatus != http.StatusOK {
_, _ = w.Write([]byte(`<error>Server Error</error>`))
}
}))
defer server.Close()
client := createTestClient(server.URL)
err := client.SetZone(tt.zoneRequest)
if tt.expectError {
if err == nil {
t.Error("Expected error, but got none")
} else if tt.errorMessage != "" && err.Error() != tt.errorMessage {
t.Errorf("Expected error message '%s', got '%s'", tt.errorMessage, err.Error())
}
return
}
if err != nil {
t.Errorf("Expected no error, but got: %v", err)
}
})
}
}
func TestClient_CreateZone(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/setZone" {
t.Errorf("Expected path /setZone, got %s", r.URL.Path)
}
w.Header().Set("Content-Type", "application/xml")
w.WriteHeader(http.StatusOK)
}))
defer server.Close()
client := createTestClient(server.URL)
masterDeviceID := "ABCD1234EFGH"
memberDeviceIDs := []string{"EFGH5678IJKL", "IJKL9012MNOP"}
err := client.CreateZone(masterDeviceID, memberDeviceIDs)
if err != nil {
t.Errorf("Expected no error, but got: %v", err)
}
}
func TestClient_CreateZoneWithIPs(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/setZone" {
t.Errorf("Expected path /setZone, got %s", r.URL.Path)
}
w.Header().Set("Content-Type", "application/xml")
w.WriteHeader(http.StatusOK)
}))
defer server.Close()
client := createTestClient(server.URL)
masterDeviceID := "ABCD1234EFGH"
members := map[string]string{
"EFGH5678IJKL": "192.0.2.11",
"IJKL9012MNOP": "192.0.2.12",
}
err := client.CreateZoneWithIPs(masterDeviceID, members)
if err != nil {
t.Errorf("Expected no error, but got: %v", err)
}
}
func TestClient_AddToZone(t *testing.T) {
getZoneCalled := false
setZoneCalled := false
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/xml")
if r.URL.Path == "/getZone" && r.Method == http.MethodGet {
getZoneCalled = true
// Return existing zone
response := `<?xml version="1.0" encoding="UTF-8" ?>
<zone master="ABCD1234EFGH">
<member ipaddress="192.0.2.11">EFGH5678IJKL</member>
</zone>`
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte(response))
} else if r.URL.Path == "/setZone" && r.Method == http.MethodPost {
setZoneCalled = true
w.WriteHeader(http.StatusOK)
} else {
w.WriteHeader(http.StatusNotFound)
}
}))
defer server.Close()
client := createTestClient(server.URL)
err := client.AddToZone("IJKL9012MNOP", "192.0.2.12")
if err != nil {
t.Errorf("Expected no error, but got: %v", err)
}
if !getZoneCalled {
t.Error("Expected GetZone to be called")
}
if !setZoneCalled {
t.Error("Expected SetZone to be called")
}
}
func TestClient_RemoveFromZone(t *testing.T) {
getZoneCalled := false
setZoneCalled := false
var removeBody string
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/xml")
switch {
case r.URL.Path == "/getZone" && r.Method == http.MethodGet:
getZoneCalled = true
// Return existing zone with two members.
response := `<?xml version="1.0" encoding="UTF-8" ?>
<zone master="ABCD1234EFGH">
<member ipaddress="192.0.2.11">EFGH5678IJKL</member>
<member ipaddress="192.0.2.12">IJKL9012MNOP</member>
</zone>`
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte(response))
case r.URL.Path == "/removeZoneSlave" && r.Method == http.MethodPost:
b, _ := io.ReadAll(r.Body)
removeBody = string(b)
w.WriteHeader(http.StatusOK)
case r.URL.Path == "/setZone" && r.Method == http.MethodPost:
setZoneCalled = true
w.WriteHeader(http.StatusOK)
default:
w.WriteHeader(http.StatusNotFound)
}
}))
defer server.Close()
client := createTestClient(server.URL)
// Removing one of two members must target that member via /removeZoneSlave,
// not rebuild the zone via /setZone (which does not drop a member from a
// multi-member zone). Regression for #511.
err := client.RemoveFromZone("EFGH5678IJKL")
if err != nil {
t.Errorf("Expected no error, but got: %v", err)
}
if !getZoneCalled {
t.Error("Expected GetZone to be called")
}
if setZoneCalled {
t.Error("RemoveFromZone must not use /setZone to drop a member from a multi-member zone")
}
if !strings.Contains(removeBody, "EFGH5678IJKL") {
t.Errorf("removeZoneSlave body should target the member, got: %s", removeBody)
}
if !strings.Contains(removeBody, `master="ABCD1234EFGH"`) {
t.Errorf("removeZoneSlave body should name the master, got: %s", removeBody)
}
if !strings.Contains(removeBody, `ipaddress="192.0.2.11"`) {
t.Errorf("removeZoneSlave body should carry the member IP from the zone, got: %s", removeBody)
}
}
func TestClient_DissolveZone(t *testing.T) {
getZoneCalled := false
setZoneCalled := false
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/xml")
if r.URL.Path == "/getZone" && r.Method == http.MethodGet {
getZoneCalled = true
// Return existing zone with members
response := `<?xml version="1.0" encoding="UTF-8" ?>
<zone master="ABCD1234EFGH">
<member ipaddress="192.0.2.11">EFGH5678IJKL</member>
<member ipaddress="192.0.2.12">IJKL9012MNOP</member>
</zone>`
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte(response))
} else if r.URL.Path == "/setZone" && r.Method == http.MethodPost {
setZoneCalled = true
w.WriteHeader(http.StatusOK)
} else {
w.WriteHeader(http.StatusNotFound)
}
}))
defer server.Close()
client := createTestClient(server.URL)
err := client.DissolveZone()
if err != nil {
t.Errorf("Expected no error, but got: %v", err)
}
if !getZoneCalled {
t.Error("Expected GetZone to be called")
}
if !setZoneCalled {
t.Error("Expected SetZone to be called")
}
}
func TestClient_IsInZone(t *testing.T) {
tests := []struct {
name string
responseXML string
expectedResult bool
expectError bool
}{
{
name: "Standalone device",
responseXML: `<?xml version="1.0" encoding="UTF-8" ?>
<zone master="ABCD1234EFGH"></zone>`,
expectedResult: false,
expectError: false,
},
{
name: "Device in zone",
responseXML: `<?xml version="1.0" encoding="UTF-8" ?>
<zone master="ABCD1234EFGH">
<member ipaddress="192.0.2.11">EFGH5678IJKL</member>
</zone>`,
expectedResult: true,
expectError: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/getZone" {
t.Errorf("Expected path /getZone, got %s", r.URL.Path)
}
w.Header().Set("Content-Type", "application/xml")
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte(tt.responseXML))
}))
defer server.Close()
client := createTestClient(server.URL)
result, err := client.IsInZone()
if tt.expectError {
if err == nil {
t.Error("Expected error, but got none")
}
return
}
if err != nil {
t.Errorf("Expected no error, but got: %v", err)
return
}
if result != tt.expectedResult {
t.Errorf("Expected result %t, got %t", tt.expectedResult, result)
}
})
}
}
func TestClient_GetZoneStatus(t *testing.T) {
tests := []struct {
name string
deviceInfoXML string
zoneXML string
expectedStatus models.ZoneStatus
expectError bool
}{
{
name: "Standalone device",
deviceInfoXML: `<?xml version="1.0" encoding="UTF-8" ?>
<info deviceID="ABCD1234EFGH">
<name>Living Room</name>
<type>SoundTouch 20</type>
</info>`,
zoneXML: `<?xml version="1.0" encoding="UTF-8" ?>
<zone master="ABCD1234EFGH"></zone>`,
expectedStatus: models.ZoneStatusStandalone,
expectError: false,
},
{
name: "Zone master",
deviceInfoXML: `<?xml version="1.0" encoding="UTF-8" ?>
<info deviceID="ABCD1234EFGH">
<name>Living Room</name>
<type>SoundTouch 20</type>
</info>`,
zoneXML: `<?xml version="1.0" encoding="UTF-8" ?>
<zone master="ABCD1234EFGH">
<member ipaddress="192.0.2.11">EFGH5678IJKL</member>
</zone>`,
expectedStatus: models.ZoneStatusMaster,
expectError: false,
},
{
name: "Zone member",
deviceInfoXML: `<?xml version="1.0" encoding="UTF-8" ?>
<info deviceID="EFGH5678IJKL">
<name>Kitchen</name>
<type>SoundTouch 10</type>
</info>`,
zoneXML: `<?xml version="1.0" encoding="UTF-8" ?>
<zone master="ABCD1234EFGH">
<member ipaddress="192.0.2.11">EFGH5678IJKL</member>
</zone>`,
expectedStatus: models.ZoneStatusSlave,
expectError: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/xml")
w.WriteHeader(http.StatusOK)
switch r.URL.Path {
case "/getZone":
_, _ = w.Write([]byte(tt.zoneXML))
case "/info":
_, _ = w.Write([]byte(tt.deviceInfoXML))
default:
w.WriteHeader(http.StatusNotFound)
}
}))
defer server.Close()
client := createTestClient(server.URL)
status, err := client.GetZoneStatus()
if tt.expectError {
if err == nil {
t.Error("Expected error, but got none")
}
return
}
if err != nil {
t.Errorf("Expected no error, but got: %v", err)
return
}
if status != tt.expectedStatus {
t.Errorf("Expected status %v, got %v", tt.expectedStatus, status)
}
})
}
}
func TestClient_GetZoneMembers(t *testing.T) {
tests := []struct {
name string
responseXML string
expectedMembers []string
expectError bool
}{
{
name: "Standalone device",
responseXML: `<?xml version="1.0" encoding="UTF-8" ?>
<zone master="ABCD1234EFGH"></zone>`,
expectedMembers: []string{"ABCD1234EFGH"},
expectError: false,
},
{
name: "Zone with members",
responseXML: `<?xml version="1.0" encoding="UTF-8" ?>
<zone master="ABCD1234EFGH">
<member ipaddress="192.0.2.11">EFGH5678IJKL</member>
<member ipaddress="192.0.2.12">IJKL9012MNOP</member>
</zone>`,
expectedMembers: []string{"ABCD1234EFGH", "EFGH5678IJKL", "IJKL9012MNOP"},
expectError: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/getZone" {
t.Errorf("Expected path /getZone, got %s", r.URL.Path)
}
w.Header().Set("Content-Type", "application/xml")
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte(tt.responseXML))
}))
defer server.Close()
client := createTestClient(server.URL)
members, err := client.GetZoneMembers()
if tt.expectError {
if err == nil {
t.Error("Expected error, but got none")
}
return
}
if err != nil {
t.Errorf("Expected no error, but got: %v", err)
return
}
if len(members) != len(tt.expectedMembers) {
t.Errorf("Expected %d members, got %d", len(tt.expectedMembers), len(members))
return
}
for i, expectedMember := range tt.expectedMembers {
if members[i] != expectedMember {
t.Errorf("Expected member %s at index %d, got %s", expectedMember, i, members[i])
}
}
})
}
}
func TestClient_Zone_ErrorHandling(t *testing.T) {
t.Run("GetZone network error", func(t *testing.T) {
client := NewClientFromHost("invalid.host:9999")
client.timeout = 100 * time.Millisecond
_, err := client.GetZone()
if err == nil {
t.Error("Expected network error, but got none")
}
})
t.Run("SetZone network error", func(t *testing.T) {
client := NewClientFromHost("invalid.host:9999")
client.timeout = 100 * time.Millisecond
zr := models.NewZoneRequest("DEVICE123")
err := client.SetZone(zr)
if err == nil {
t.Error("Expected network error, but got none")
}
})
t.Run("AddToZone - GetZone fails", func(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/getZone" {
w.WriteHeader(http.StatusInternalServerError)
_, _ = w.Write([]byte(`<error>Server Error</error>`))
}
}))
defer server.Close()
client := createTestClient(server.URL)
err := client.AddToZone("DEVICE456", "192.0.2.10")
if err == nil {
t.Error("Expected error when GetZone fails, but got none")
}
expectedErrorPrefix := "failed to get current zone:"
if !strings.Contains(err.Error(), expectedErrorPrefix) {
t.Errorf("Expected error to contain '%s', got: %v", expectedErrorPrefix, err)
}
})
}
// Benchmark tests
func BenchmarkClient_GetZone(b *testing.B) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
response := `<?xml version="1.0" encoding="UTF-8" ?>
<zone master="ABCD1234EFGH">
<member ipaddress="192.0.2.11">EFGH5678IJKL</member>
<member ipaddress="192.0.2.12">IJKL9012MNOP</member>
</zone>`
w.Header().Set("Content-Type", "application/xml")
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte(response))
}))
defer server.Close()
client := createTestClient(server.URL)
b.ResetTimer()
for i := 0; i < b.N; i++ {
_, err := client.GetZone()
if err != nil {
b.Fatal(err)
}
}
}
func BenchmarkClient_SetZone(b *testing.B) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Content-Type", "application/xml")
w.WriteHeader(http.StatusOK)
}))
defer server.Close()
client := createTestClient(server.URL)
zoneRequest := models.NewZoneRequest("ABCD1234EFGH")
zoneRequest.AddMember("EFGH5678IJKL", "192.0.2.11")
b.ResetTimer()
for i := 0; i < b.N; i++ {
err := client.SetZone(zoneRequest)
if err != nil {
b.Fatal(err)
}
}
}