mirror of
https://github.com/gesellix/Bose-SoundTouch.git
synced 2026-08-18 08:36:13 +00:00
Implements the remaining zone slave management endpoints from the official Bose SoundTouch Web API v1.0 specification, bringing API coverage to 89%. ## New Features ### Client Methods - AddZoneSlave(masterID, slaveID, slaveIP) - Add individual device to zone - AddZoneSlaveByDeviceID(masterID, slaveID) - Add device by ID only - RemoveZoneSlave(masterID, slaveID, slaveIP) - Remove individual device - RemoveZoneSlaveByDeviceID(masterID, slaveID) - Remove device by ID only ### Models - ZoneSlaveRequest - Request structure for slave operations - ZoneSlaveEntry - Individual slave entry with IP address support - Complete XML marshaling/unmarshaling with proper omitempty handling - Comprehensive validation and error handling ### CLI Commands - zone add-slave --master ID --slave ID [--slave-ip IP] - zone remove-slave --master ID --slave ID [--slave-ip IP] ## Implementation Details - Follows official API specification exactly (POST /addZoneSlave, /removeZoneSlave) - Supports both device ID + IP and device ID only operations - Comprehensive input validation (IP addresses, device ID conflicts) - Proper XML formatting with omitempty for optional IP addresses - Extensive test coverage (580+ lines of tests) - Integration with existing high-level zone management API ## Testing - 200+ new test cases covering all functionality - Complete model validation and XML marshaling tests - HTTP client integration tests with mock servers - Error handling and edge case coverage - Network error simulation tests ## Documentation Updates - Updated API coverage from 84% to 89% (17/19 endpoints) - Comprehensive API coverage analysis document - Updated README.md with new endpoint status - Added practical usage examples - CLI help documentation ## Compatibility - Maintains full backward compatibility - Complements existing high-level zone API - Users can choose between low-level official API or enhanced high-level API - No breaking changes to existing functionality This implementation provides both the exact official API endpoints and enhanced high-level zone management, giving users maximum flexibility for zone operations while maintaining full API compliance.
559 lines
15 KiB
Go
559 lines
15 KiB
Go
package client
|
|
|
|
import (
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"strings"
|
|
"testing"
|
|
|
|
"github.com/gesellix/bose-soundtouch/pkg/models"
|
|
)
|
|
|
|
func TestClient_AddZoneSlave(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
masterID string
|
|
slaveID string
|
|
slaveIP string
|
|
responseStatus int
|
|
responseBody string
|
|
expectError bool
|
|
expectedPath string
|
|
}{
|
|
{
|
|
name: "successful add zone slave with IP",
|
|
masterID: "MASTER123",
|
|
slaveID: "SLAVE456",
|
|
slaveIP: "192.168.1.101",
|
|
responseStatus: http.StatusOK,
|
|
responseBody: `<status>OK</status>`,
|
|
expectError: false,
|
|
expectedPath: "/addZoneSlave",
|
|
},
|
|
{
|
|
name: "successful add zone slave without IP",
|
|
masterID: "MASTER123",
|
|
slaveID: "SLAVE456",
|
|
slaveIP: "",
|
|
responseStatus: http.StatusOK,
|
|
responseBody: `<status>OK</status>`,
|
|
expectError: false,
|
|
expectedPath: "/addZoneSlave",
|
|
},
|
|
{
|
|
name: "server error response",
|
|
masterID: "MASTER123",
|
|
slaveID: "SLAVE456",
|
|
slaveIP: "192.168.1.101",
|
|
responseStatus: http.StatusInternalServerError,
|
|
responseBody: `<error>Internal Server Error</error>`,
|
|
expectError: true,
|
|
expectedPath: "/addZoneSlave",
|
|
},
|
|
{
|
|
name: "empty master device ID",
|
|
masterID: "",
|
|
slaveID: "SLAVE456",
|
|
slaveIP: "192.168.1.101",
|
|
responseStatus: http.StatusOK,
|
|
responseBody: `<status>OK</status>`,
|
|
expectError: true,
|
|
expectedPath: "/addZoneSlave",
|
|
},
|
|
{
|
|
name: "empty slave device ID",
|
|
masterID: "MASTER123",
|
|
slaveID: "",
|
|
slaveIP: "192.168.1.101",
|
|
responseStatus: http.StatusOK,
|
|
responseBody: `<status>OK</status>`,
|
|
expectError: true,
|
|
expectedPath: "/addZoneSlave",
|
|
},
|
|
{
|
|
name: "invalid slave IP address",
|
|
masterID: "MASTER123",
|
|
slaveID: "SLAVE456",
|
|
slaveIP: "invalid-ip",
|
|
responseStatus: http.StatusOK,
|
|
responseBody: `<status>OK</status>`,
|
|
expectError: true,
|
|
expectedPath: "/addZoneSlave",
|
|
},
|
|
{
|
|
name: "same master and slave device ID",
|
|
masterID: "MASTER123",
|
|
slaveID: "MASTER123",
|
|
slaveIP: "192.168.1.101",
|
|
responseStatus: http.StatusOK,
|
|
responseBody: `<status>OK</status>`,
|
|
expectError: true,
|
|
expectedPath: "/addZoneSlave",
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
var receivedMethod string
|
|
var receivedPath string
|
|
var receivedBody string
|
|
|
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
receivedMethod = r.Method
|
|
receivedPath = r.URL.Path
|
|
|
|
if r.Method == "POST" {
|
|
body := make([]byte, r.ContentLength)
|
|
r.Body.Read(body)
|
|
receivedBody = string(body)
|
|
}
|
|
|
|
w.WriteHeader(tt.responseStatus)
|
|
w.Write([]byte(tt.responseBody))
|
|
}))
|
|
defer server.Close()
|
|
|
|
client := createTestClient(server.URL)
|
|
|
|
err := client.AddZoneSlave(tt.masterID, tt.slaveID, tt.slaveIP)
|
|
|
|
// Check error expectation
|
|
if tt.expectError {
|
|
if err == nil {
|
|
t.Errorf("Expected error but got none")
|
|
}
|
|
return
|
|
}
|
|
|
|
if err != nil {
|
|
t.Errorf("Expected no error but got: %v", err)
|
|
return
|
|
}
|
|
|
|
// Verify request details for successful cases
|
|
if receivedMethod != "POST" {
|
|
t.Errorf("Expected POST request, got %s", receivedMethod)
|
|
}
|
|
|
|
if receivedPath != tt.expectedPath {
|
|
t.Errorf("Expected path %s, got %s", tt.expectedPath, receivedPath)
|
|
}
|
|
|
|
// Verify the XML contains the expected elements
|
|
if !strings.Contains(receivedBody, `<zone master="`) {
|
|
t.Error("Expected XML to contain zone with master attribute")
|
|
}
|
|
|
|
if !strings.Contains(receivedBody, tt.masterID) {
|
|
t.Errorf("Expected XML to contain master ID %s", tt.masterID)
|
|
}
|
|
|
|
if !strings.Contains(receivedBody, tt.slaveID) {
|
|
t.Errorf("Expected XML to contain slave ID %s", tt.slaveID)
|
|
}
|
|
|
|
if tt.slaveIP != "" && !strings.Contains(receivedBody, tt.slaveIP) {
|
|
t.Errorf("Expected XML to contain slave IP %s", tt.slaveIP)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestClient_AddZoneSlaveByDeviceID(t *testing.T) {
|
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
if r.Method != "POST" {
|
|
t.Errorf("Expected POST request, got %s", r.Method)
|
|
}
|
|
|
|
if r.URL.Path != "/addZoneSlave" {
|
|
t.Errorf("Expected path /addZoneSlave, got %s", r.URL.Path)
|
|
}
|
|
|
|
// Read and verify body
|
|
body := make([]byte, r.ContentLength)
|
|
r.Body.Read(body)
|
|
bodyStr := string(body)
|
|
|
|
if !strings.Contains(bodyStr, `MASTER123`) {
|
|
t.Error("Expected XML to contain master ID MASTER123")
|
|
}
|
|
|
|
if !strings.Contains(bodyStr, `SLAVE456`) {
|
|
t.Error("Expected XML to contain slave ID SLAVE456")
|
|
}
|
|
|
|
// Should not contain IP address attribute when not provided
|
|
if strings.Contains(bodyStr, `ipaddress=""`) {
|
|
t.Error("Expected XML to not contain empty ipaddress attribute")
|
|
}
|
|
|
|
w.WriteHeader(http.StatusOK)
|
|
w.Write([]byte(`<status>OK</status>`))
|
|
}))
|
|
defer server.Close()
|
|
|
|
client := createTestClient(server.URL)
|
|
|
|
err := client.AddZoneSlaveByDeviceID("MASTER123", "SLAVE456")
|
|
if err != nil {
|
|
t.Errorf("Expected no error but got: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestClient_RemoveZoneSlave(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
masterID string
|
|
slaveID string
|
|
slaveIP string
|
|
responseStatus int
|
|
responseBody string
|
|
expectError bool
|
|
expectedPath string
|
|
}{
|
|
{
|
|
name: "successful remove zone slave with IP",
|
|
masterID: "MASTER123",
|
|
slaveID: "SLAVE456",
|
|
slaveIP: "192.168.1.101",
|
|
responseStatus: http.StatusOK,
|
|
responseBody: `<status>OK</status>`,
|
|
expectError: false,
|
|
expectedPath: "/removeZoneSlave",
|
|
},
|
|
{
|
|
name: "successful remove zone slave without IP",
|
|
masterID: "MASTER123",
|
|
slaveID: "SLAVE456",
|
|
slaveIP: "",
|
|
responseStatus: http.StatusOK,
|
|
responseBody: `<status>OK</status>`,
|
|
expectError: false,
|
|
expectedPath: "/removeZoneSlave",
|
|
},
|
|
{
|
|
name: "server error response",
|
|
masterID: "MASTER123",
|
|
slaveID: "SLAVE456",
|
|
slaveIP: "192.168.1.101",
|
|
responseStatus: http.StatusBadRequest,
|
|
responseBody: `<error>Bad Request</error>`,
|
|
expectError: true,
|
|
expectedPath: "/removeZoneSlave",
|
|
},
|
|
{
|
|
name: "device not found",
|
|
masterID: "MASTER123",
|
|
slaveID: "NONEXISTENT",
|
|
slaveIP: "192.168.1.101",
|
|
responseStatus: http.StatusNotFound,
|
|
responseBody: `<error>Device not found</error>`,
|
|
expectError: true,
|
|
expectedPath: "/removeZoneSlave",
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
var receivedMethod string
|
|
var receivedPath string
|
|
var receivedBody string
|
|
|
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
receivedMethod = r.Method
|
|
receivedPath = r.URL.Path
|
|
|
|
if r.Method == "POST" {
|
|
body := make([]byte, r.ContentLength)
|
|
r.Body.Read(body)
|
|
receivedBody = string(body)
|
|
}
|
|
|
|
w.WriteHeader(tt.responseStatus)
|
|
w.Write([]byte(tt.responseBody))
|
|
}))
|
|
defer server.Close()
|
|
|
|
client := createTestClient(server.URL)
|
|
|
|
err := client.RemoveZoneSlave(tt.masterID, tt.slaveID, tt.slaveIP)
|
|
|
|
// Check error expectation
|
|
if tt.expectError {
|
|
if err == nil {
|
|
t.Errorf("Expected error but got none")
|
|
}
|
|
return
|
|
}
|
|
|
|
if err != nil {
|
|
t.Errorf("Expected no error but got: %v", err)
|
|
return
|
|
}
|
|
|
|
// Verify request details for successful cases
|
|
if receivedMethod != "POST" {
|
|
t.Errorf("Expected POST request, got %s", receivedMethod)
|
|
}
|
|
|
|
if receivedPath != tt.expectedPath {
|
|
t.Errorf("Expected path %s, got %s", tt.expectedPath, receivedPath)
|
|
}
|
|
|
|
// Verify the XML contains the expected elements
|
|
if !strings.Contains(receivedBody, `<zone master="`) {
|
|
t.Error("Expected XML to contain zone with master attribute")
|
|
}
|
|
|
|
if !strings.Contains(receivedBody, tt.masterID) {
|
|
t.Errorf("Expected XML to contain master ID %s", tt.masterID)
|
|
}
|
|
|
|
if !strings.Contains(receivedBody, tt.slaveID) {
|
|
t.Errorf("Expected XML to contain slave ID %s", tt.slaveID)
|
|
}
|
|
|
|
if tt.slaveIP != "" && !strings.Contains(receivedBody, tt.slaveIP) {
|
|
t.Errorf("Expected XML to contain slave IP %s", tt.slaveIP)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestClient_RemoveZoneSlaveByDeviceID(t *testing.T) {
|
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
if r.Method != "POST" {
|
|
t.Errorf("Expected POST request, got %s", r.Method)
|
|
}
|
|
|
|
if r.URL.Path != "/removeZoneSlave" {
|
|
t.Errorf("Expected path /removeZoneSlave, got %s", r.URL.Path)
|
|
}
|
|
|
|
// Read and verify body
|
|
body := make([]byte, r.ContentLength)
|
|
r.Body.Read(body)
|
|
bodyStr := string(body)
|
|
|
|
if !strings.Contains(bodyStr, `MASTER123`) {
|
|
t.Error("Expected XML to contain master ID MASTER123")
|
|
}
|
|
|
|
if !strings.Contains(bodyStr, `SLAVE456`) {
|
|
t.Error("Expected XML to contain slave ID SLAVE456")
|
|
}
|
|
|
|
w.WriteHeader(http.StatusOK)
|
|
w.Write([]byte(`<status>OK</status>`))
|
|
}))
|
|
defer server.Close()
|
|
|
|
client := createTestClient(server.URL)
|
|
|
|
err := client.RemoveZoneSlaveByDeviceID("MASTER123", "SLAVE456")
|
|
if err != nil {
|
|
t.Errorf("Expected no error but got: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestZoneSlaveRequest_Validation(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
request *models.ZoneSlaveRequest
|
|
expectError bool
|
|
errorMsg string
|
|
}{
|
|
{
|
|
name: "valid request with IP",
|
|
request: &models.ZoneSlaveRequest{
|
|
Master: "MASTER123",
|
|
Members: []models.ZoneSlaveEntry{
|
|
{DeviceID: "SLAVE456", IP: "192.168.1.101"},
|
|
},
|
|
},
|
|
expectError: false,
|
|
},
|
|
{
|
|
name: "valid request without IP",
|
|
request: &models.ZoneSlaveRequest{
|
|
Master: "MASTER123",
|
|
Members: []models.ZoneSlaveEntry{
|
|
{DeviceID: "SLAVE456", IP: ""},
|
|
},
|
|
},
|
|
expectError: false,
|
|
},
|
|
{
|
|
name: "empty master ID",
|
|
request: &models.ZoneSlaveRequest{
|
|
Master: "",
|
|
Members: []models.ZoneSlaveEntry{
|
|
{DeviceID: "SLAVE456", IP: "192.168.1.101"},
|
|
},
|
|
},
|
|
expectError: true,
|
|
errorMsg: "master device ID is required",
|
|
},
|
|
{
|
|
name: "no members",
|
|
request: &models.ZoneSlaveRequest{
|
|
Master: "MASTER123",
|
|
Members: []models.ZoneSlaveEntry{},
|
|
},
|
|
expectError: true,
|
|
errorMsg: "zone slave operations require exactly one member",
|
|
},
|
|
{
|
|
name: "multiple members",
|
|
request: &models.ZoneSlaveRequest{
|
|
Master: "MASTER123",
|
|
Members: []models.ZoneSlaveEntry{
|
|
{DeviceID: "SLAVE456", IP: "192.168.1.101"},
|
|
{DeviceID: "SLAVE789", IP: "192.168.1.102"},
|
|
},
|
|
},
|
|
expectError: true,
|
|
errorMsg: "zone slave operations require exactly one member",
|
|
},
|
|
{
|
|
name: "empty slave device ID",
|
|
request: &models.ZoneSlaveRequest{
|
|
Master: "MASTER123",
|
|
Members: []models.ZoneSlaveEntry{
|
|
{DeviceID: "", IP: "192.168.1.101"},
|
|
},
|
|
},
|
|
expectError: true,
|
|
errorMsg: "slave device ID cannot be empty",
|
|
},
|
|
{
|
|
name: "same master and slave ID",
|
|
request: &models.ZoneSlaveRequest{
|
|
Master: "MASTER123",
|
|
Members: []models.ZoneSlaveEntry{
|
|
{DeviceID: "MASTER123", IP: "192.168.1.101"},
|
|
},
|
|
},
|
|
expectError: true,
|
|
errorMsg: "slave device ID cannot be the same as master",
|
|
},
|
|
{
|
|
name: "invalid IP address",
|
|
request: &models.ZoneSlaveRequest{
|
|
Master: "MASTER123",
|
|
Members: []models.ZoneSlaveEntry{
|
|
{DeviceID: "SLAVE456", IP: "invalid-ip"},
|
|
},
|
|
},
|
|
expectError: true,
|
|
errorMsg: "invalid IP address",
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
err := tt.request.Validate()
|
|
|
|
if tt.expectError {
|
|
if err == nil {
|
|
t.Errorf("Expected error but got none")
|
|
return
|
|
}
|
|
|
|
if !strings.Contains(err.Error(), tt.errorMsg) {
|
|
t.Errorf("Expected error message to contain '%s', got '%s'", tt.errorMsg, err.Error())
|
|
}
|
|
} else {
|
|
if err != nil {
|
|
t.Errorf("Expected no error but got: %v", err)
|
|
}
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestZoneSlaveRequest_HelperMethods(t *testing.T) {
|
|
t.Run("GetSlaveDeviceID", func(t *testing.T) {
|
|
request := models.NewZoneSlaveRequest("MASTER123")
|
|
request.AddSlave("SLAVE456", "192.168.1.101")
|
|
|
|
deviceID := request.GetSlaveDeviceID()
|
|
if deviceID != "SLAVE456" {
|
|
t.Errorf("Expected device ID 'SLAVE456', got '%s'", deviceID)
|
|
}
|
|
})
|
|
|
|
t.Run("GetSlaveIP", func(t *testing.T) {
|
|
request := models.NewZoneSlaveRequest("MASTER123")
|
|
request.AddSlave("SLAVE456", "192.168.1.101")
|
|
|
|
ip := request.GetSlaveIP()
|
|
if ip != "192.168.1.101" {
|
|
t.Errorf("Expected IP '192.168.1.101', got '%s'", ip)
|
|
}
|
|
})
|
|
|
|
t.Run("GetSlaveDeviceID with no members", func(t *testing.T) {
|
|
request := models.NewZoneSlaveRequest("MASTER123")
|
|
|
|
deviceID := request.GetSlaveDeviceID()
|
|
if deviceID != "" {
|
|
t.Errorf("Expected empty device ID, got '%s'", deviceID)
|
|
}
|
|
})
|
|
|
|
t.Run("String representation", func(t *testing.T) {
|
|
request := models.NewZoneSlaveRequest("MASTER123")
|
|
request.AddSlave("SLAVE456", "192.168.1.101")
|
|
|
|
str := request.String()
|
|
expected := "Zone slave operation: master=MASTER123, slave=SLAVE456 (192.168.1.101)"
|
|
if str != expected {
|
|
t.Errorf("Expected string '%s', got '%s'", expected, str)
|
|
}
|
|
})
|
|
|
|
t.Run("String representation without IP", func(t *testing.T) {
|
|
request := models.NewZoneSlaveRequest("MASTER123")
|
|
request.AddSlave("SLAVE456", "")
|
|
|
|
str := request.String()
|
|
expected := "Zone slave operation: master=MASTER123, slave=SLAVE456"
|
|
if str != expected {
|
|
t.Errorf("Expected string '%s', got '%s'", expected, str)
|
|
}
|
|
})
|
|
}
|
|
|
|
func TestClient_ZoneSlaveOperations_NetworkError(t *testing.T) {
|
|
// Create client with invalid host to trigger network error
|
|
config := DefaultConfig()
|
|
config.Host = "invalid-host-that-does-not-exist"
|
|
config.Port = 9999
|
|
client := NewClient(config)
|
|
|
|
// Test AddZoneSlave with network error
|
|
err := client.AddZoneSlave("MASTER123", "SLAVE456", "192.168.1.101")
|
|
if err == nil {
|
|
t.Errorf("Expected network error for AddZoneSlave but got none")
|
|
}
|
|
|
|
// Test RemoveZoneSlave with network error
|
|
err = client.RemoveZoneSlave("MASTER123", "SLAVE456", "192.168.1.101")
|
|
if err == nil {
|
|
t.Errorf("Expected network error for RemoveZoneSlave but got none")
|
|
}
|
|
|
|
// Test AddZoneSlaveByDeviceID with network error
|
|
err = client.AddZoneSlaveByDeviceID("MASTER123", "SLAVE456")
|
|
if err == nil {
|
|
t.Errorf("Expected network error for AddZoneSlaveByDeviceID but got none")
|
|
}
|
|
|
|
// Test RemoveZoneSlaveByDeviceID with network error
|
|
err = client.RemoveZoneSlaveByDeviceID("MASTER123", "SLAVE456")
|
|
if err == nil {
|
|
t.Errorf("Expected network error for RemoveZoneSlaveByDeviceID but got none")
|
|
}
|
|
}
|