diff --git a/README.md b/README.md index 43f55df..54884da 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@ A modern Go library and CLI tool for interacting with Bose SoundTouch devices vi ## Features -### ✅ Implemented (84% Complete - 16/19 official endpoints) +### ✅ Implemented (89% Complete - 17/19 official endpoints) - **HTTP Client with XML Support**: Complete client for SoundTouch Web API - **Device Information**: Get detailed device info via `/info` endpoint - **Device Name**: Get device name via `/name` endpoint @@ -685,8 +685,8 @@ Bose-SoundTouch/ | `/name` | POST | ✅ Complete | Set device name | | `/bassCapabilities` | GET | ✅ Complete | Bass capability detection | | `/trackInfo` | GET | ✅ Complete | Track information (duplicate of /now_playing) | -| `/addZoneSlave` | POST | ❌ Missing | **Individual slave addition (use AddToZone instead)** | -| `/removeZoneSlave` | POST | ❌ Missing | **Individual slave removal (use RemoveFromZone instead)** | +| `/addZoneSlave` | POST | ✅ Complete | **Individual slave addition to existing zone** | +| `/removeZoneSlave` | POST | ✅ Complete | **Individual slave removal from existing zone** | | `/audiodspcontrols` | GET/POST | ❌ Missing | **DSP audio modes and video sync delay** | | `/audioproducttonecontrols` | GET/POST | ❌ Missing | **Advanced bass/treble controls** | | `/audioproductlevelcontrols` | GET/POST | ❌ Missing | **Speaker level controls (front-center/rear-surround)** | diff --git a/cmd/soundtouch-cli/cmd_zone_slave.go b/cmd/soundtouch-cli/cmd_zone_slave.go new file mode 100644 index 0000000..b7a04a6 --- /dev/null +++ b/cmd/soundtouch-cli/cmd_zone_slave.go @@ -0,0 +1,93 @@ +package main + +import ( + "fmt" + + "github.com/urfave/cli/v2" +) + +// addZoneSlave adds a device to an existing zone using the official /addZoneSlave endpoint +func addZoneSlave(c *cli.Context) error { + clientConfig := GetClientConfig(c) + masterID := c.String("master") + slaveID := c.String("slave") + slaveIP := c.String("slave-ip") + + if masterID == "" { + return fmt.Errorf("master device ID is required (use --master)") + } + + if slaveID == "" { + return fmt.Errorf("slave device ID is required (use --slave)") + } + + PrintDeviceHeader(fmt.Sprintf("Adding slave '%s' to zone master '%s'", slaveID, masterID), clientConfig.Host, clientConfig.Port) + + client, err := CreateSoundTouchClient(clientConfig) + if err != nil { + PrintError(fmt.Sprintf("Failed to create client: %v", err)) + return err + } + + if slaveIP != "" { + err = client.AddZoneSlave(masterID, slaveID, slaveIP) + } else { + err = client.AddZoneSlaveByDeviceID(masterID, slaveID) + } + + if err != nil { + PrintError(fmt.Sprintf("Failed to add zone slave: %v", err)) + return err + } + + fmt.Printf("✅ Successfully added device '%s' to zone master '%s'\n", slaveID, masterID) + + if slaveIP != "" { + fmt.Printf(" Slave IP: %s\n", slaveIP) + } + + return nil +} + +// removeZoneSlave removes a device from an existing zone using the official /removeZoneSlave endpoint +func removeZoneSlave(c *cli.Context) error { + clientConfig := GetClientConfig(c) + masterID := c.String("master") + slaveID := c.String("slave") + slaveIP := c.String("slave-ip") + + if masterID == "" { + return fmt.Errorf("master device ID is required (use --master)") + } + + if slaveID == "" { + return fmt.Errorf("slave device ID is required (use --slave)") + } + + PrintDeviceHeader(fmt.Sprintf("Removing slave '%s' from zone master '%s'", slaveID, masterID), clientConfig.Host, clientConfig.Port) + + client, err := CreateSoundTouchClient(clientConfig) + if err != nil { + PrintError(fmt.Sprintf("Failed to create client: %v", err)) + return err + } + + if slaveIP != "" { + err = client.RemoveZoneSlave(masterID, slaveID, slaveIP) + } else { + err = client.RemoveZoneSlaveByDeviceID(masterID, slaveID) + } + + if err != nil { + PrintError(fmt.Sprintf("Failed to remove zone slave: %v", err)) + return err + } + + fmt.Printf("✅ Successfully removed device '%s' from zone master '%s'\n", slaveID, masterID) + + if slaveIP != "" { + fmt.Printf(" Slave IP: %s\n", slaveIP) + } + + return nil +} diff --git a/cmd/soundtouch-cli/main.go b/cmd/soundtouch-cli/main.go index 9a88993..d6b1bc1 100644 --- a/cmd/soundtouch-cli/main.go +++ b/cmd/soundtouch-cli/main.go @@ -660,6 +660,50 @@ func main() { }, Before: RequireHost, }, + { + Name: "add-slave", + Usage: "Add slave to zone (official API)", + Action: addZoneSlave, + Flags: []cli.Flag{ + &cli.StringFlag{ + Name: "master", + Usage: "Master device ID", + Required: true, + }, + &cli.StringFlag{ + Name: "slave", + Usage: "Slave device ID", + Required: true, + }, + &cli.StringFlag{ + Name: "slave-ip", + Usage: "Slave device IP address (optional)", + }, + }, + Before: RequireHost, + }, + { + Name: "remove-slave", + Usage: "Remove slave from zone (official API)", + Action: removeZoneSlave, + Flags: []cli.Flag{ + &cli.StringFlag{ + Name: "master", + Usage: "Master device ID", + Required: true, + }, + &cli.StringFlag{ + Name: "slave", + Usage: "Slave device ID", + Required: true, + }, + &cli.StringFlag{ + Name: "slave-ip", + Usage: "Slave device IP address (optional)", + }, + }, + Before: RequireHost, + }, }, }, }, diff --git a/docs/API-COVERAGE-ANALYSIS.md b/docs/API-COVERAGE-ANALYSIS.md index 081c235..64c7476 100644 --- a/docs/API-COVERAGE-ANALYSIS.md +++ b/docs/API-COVERAGE-ANALYSIS.md @@ -2,24 +2,24 @@ **Last Updated:** January 2025 **API Version:** Official Bose SoundTouch Web API v1.0 -**Implementation Status:** 84% Official Coverage + Extended Features +**Implementation Status:** 89% Official Coverage + Extended Features ## Executive Summary -This Go implementation provides **comprehensive coverage** of the Bose SoundTouch Web API with **84% of official endpoints implemented** (16/19) plus **5 additional extended features** not documented in the official API v1.0 but working with real hardware. +This Go implementation provides **comprehensive coverage** of the Bose SoundTouch Web API with **89% of official endpoints implemented** (17/19) plus **5 additional extended features** not documented in the official API v1.0 but working with real hardware. ### Key Findings - ✅ **All essential user functionality implemented** -- ✅ **Superior zone management implementation** +- ✅ **Complete zone management implementation** - ✅ **Real-time WebSocket event system** - ✅ **Extended features beyond official specification** -- ❌ **3 missing advanced audio endpoints** (professional/audiophile features) +- ❌ **2 missing advanced audio endpoints** (professional/audiophile features) --- ## Official API v1.0 Endpoint Coverage -### Implemented Endpoints: 16/19 (84%) +### Implemented Endpoints: 17/19 (89%) | Endpoint | Method | Status | Implementation | Notes | |----------|--------|--------|----------------|--------| @@ -37,13 +37,13 @@ This Go implementation provides **comprehensive coverage** of the Bose SoundTouc | `/info` | GET | ✅ **Complete** | `GetDeviceInfo()` | Device information and capabilities | | `/name` | POST | ✅ **Complete** | `SetName()` | Device name modification | | `/capabilities` | GET | ✅ **Complete** | `GetCapabilities()` | Device feature capabilities | +| `/addZoneSlave` | POST | ✅ **Complete** | `AddZoneSlave()`, `AddZoneSlaveByDeviceID()` | Individual device addition to zone | +| `/removeZoneSlave` | POST | ✅ **Complete** | `RemoveZoneSlave()`, `RemoveZoneSlaveByDeviceID()` | Individual device removal from zone | -### Missing Official Endpoints: 3/19 (16%) +### Missing Official Endpoints: 2/19 (11%) | Endpoint | Method | Status | Reason | Impact | |----------|--------|--------|--------|---------| -| `/addZoneSlave` | POST | ❌ **Missing** | Replaced by superior high-level zone API | **Low** - Better implementation exists | -| `/removeZoneSlave` | POST | ❌ **Missing** | Replaced by superior high-level zone API | **Low** - Better implementation exists | | `/audiodspcontrols` | GET/POST | ❌ **Missing** | Advanced professional feature | **Low** - Niche audiophile feature | | `/audioproducttonecontrols` | GET/POST | ❌ **Missing** | Advanced bass/treble beyond `/bass` | **Low** - Basic bass control available | | `/audioproductlevelcontrols` | GET/POST | ❌ **Missing** | Front-center/rear-surround speaker levels | **Low** - Professional audio feature | @@ -81,18 +81,18 @@ This Go implementation provides **comprehensive coverage** of the Bose SoundTouc ## Implementation Analysis -### Zone Management: Superior Implementation ⚠️ +### Zone Management: Complete Implementation ✅ -**Official API Approach:** -```xml - -POST /addZoneSlave -POST /removeZoneSlave +**Official Low-Level API:** +```go +// Individual slave operations (exact official API implementation) +client.AddZoneSlave("MASTER123", "SLAVE456", "192.168.1.101") +client.RemoveZoneSlave("MASTER123", "SLAVE456", "192.168.1.101") ``` -**Our Implementation:** +**Enhanced High-Level API:** ```go -// High-level fluent API +// High-level fluent API (enhanced implementation) zone := client.CreateZoneWithIPs("192.168.1.100", []string{"192.168.1.101", "192.168.1.102"}) client.AddToZone("192.168.1.100", "192.168.1.103") client.RemoveFromZone("192.168.1.100", "192.168.1.101") @@ -100,9 +100,10 @@ client.DissolveZone("192.168.1.100") ``` **Advantages:** -- ✅ **Atomic operations** - entire zone created/modified in single request +- ✅ **Complete official API compliance** - exact implementation of official endpoints +- ✅ **Enhanced high-level operations** - atomic zone creation/modification - ✅ **Validation and error handling** - comprehensive zone state validation -- ✅ **Simpler API** - no need to manage individual slave additions/removals +- ✅ **Flexible usage patterns** - choose low-level or high-level as needed - ✅ **Better user experience** - intuitive zone construction and modification ### Safety and Validation Enhancements @@ -130,19 +131,14 @@ All essential user functionality is fully implemented. ### Medium Impact: None ✅ All common use cases are covered. -### Low Impact: 3 Missing Features ❌ +### Low Impact: 2 Missing Features ❌ -#### 1. Individual Zone Slave Management -- **Official**: `/addZoneSlave`, `/removeZoneSlave` -- **Impact**: Low - Our high-level zone API is superior -- **Workaround**: Use `AddToZone()`, `RemoveFromZone()` methods - -#### 2. Advanced Audio DSP Controls +#### 1. Advanced Audio DSP Controls - **Official**: `/audiodspcontrols` - **Impact**: Low - Professional feature for high-end devices only - **Alternative**: Basic controls available via other endpoints -#### 3. Advanced Tone and Level Controls +#### 2. Advanced Tone and Level Controls - **Official**: `/audioproducttonecontrols`, `/audioproductlevelcontrols` - **Impact**: Low - Audiophile features for professional installations - **Alternative**: Basic bass control via `/bass` endpoint @@ -195,8 +191,7 @@ Missing only niche professional features: ### Potential Additions (Low Priority): 1. **Advanced Audio Controls** - For professional installations requiring fine audio control -2. **Individual Zone Slave Operations** - For applications requiring micro-management of zone membership -3. **Extended WebSocket Events** - Additional real-time notifications if discovered +2. **Extended WebSocket Events** - Additional real-time notifications if discovered ### API Evolution: - Monitor for new official API versions beyond v1.0 @@ -208,12 +203,12 @@ Missing only niche professional features: ## Conclusion This implementation achieves **excellent API coverage** with: -- ✅ **84% official endpoint implementation** (16/19) +- ✅ **89% official endpoint implementation** (17/19) - ✅ **100% essential functionality coverage** - ✅ **Superior implementations** for complex operations - ✅ **Extended features** beyond official specification - ✅ **Comprehensive testing and validation** -The missing 3 endpoints represent **professional/niche features** that don't impact the vast majority of users. The implementation actually **exceeds the official API** in many areas through enhanced safety features, better zone management, and real-time event capabilities. +The missing 2 endpoints represent **professional/niche features** that don't impact the vast majority of users. The implementation actually **exceeds the official API** in many areas through enhanced safety features, complete zone management, and real-time event capabilities. **Overall Assessment: Excellent** ⭐⭐⭐⭐⭐ \ No newline at end of file diff --git a/docs/API-Endpoints-Overview.md b/docs/API-Endpoints-Overview.md index f5a9efd..ab692cd 100644 --- a/docs/API-Endpoints-Overview.md +++ b/docs/API-Endpoints-Overview.md @@ -288,13 +288,22 @@ Gets track information (duplicate of `/now_playing` per official API). **Implementation**: Available via `GetTrackInfo()` method with identical response format to `/now_playing`. -### Zone Slave Management ⚠️ **Different Implementation** -Our implementation uses high-level methods instead of official endpoints: -- **Official**: `/addZoneSlave` (POST) - Add slave to zone -- **Official**: `/removeZoneSlave` (POST) - Remove slave from zone -- **Our Implementation**: `AddToZone()` and `RemoveFromZone()` methods via `/setZone` +### Zone Slave Management ✅ **Implemented** +Both official low-level endpoints and high-level zone management are available: -**Status**: Functionally equivalent and arguably cleaner approach. +#### POST /addZoneSlave ✅ **Implemented** +Add individual device to existing zone using official API format. + +**Implementation**: Available via `AddZoneSlave()` and `AddZoneSlaveByDeviceID()` methods + +#### POST /removeZoneSlave ✅ **Implemented** +Remove individual device from existing zone using official API format. + +**Implementation**: Available via `RemoveZoneSlave()` and `RemoveZoneSlaveByDeviceID()` methods + +#### High-Level Zone API ✅ **Enhanced** +- **Enhanced**: `CreateZone()`, `AddToZone()`, `RemoveFromZone()` methods via `/setZone` +- **Status**: Provides both official low-level API and enhanced high-level operations ### Advanced Audio Controls ❌ **Missing** Professional/high-end device features (only available via `/capabilities` check): @@ -321,10 +330,10 @@ These endpoints work with real hardware but are NOT in official API v1.0: ## Coverage Summary -### Official API Coverage: 84% +### Official API Coverage: 89% - **Total Official Endpoints**: 19 -- **Implemented**: 16 (84%) -- **Missing Low-Impact**: 3 (16%) +- **Implemented**: 17 (89%) +- **Missing Low-Impact**: 2 (11%) ### Feature Coverage: 100% - ✅ All essential user functionality implemented diff --git a/examples/zone-slave-operations.go b/examples/zone-slave-operations.go new file mode 100644 index 0000000..8f39c8a --- /dev/null +++ b/examples/zone-slave-operations.go @@ -0,0 +1,147 @@ +package main + +import ( + "fmt" + "log" + "time" + + "github.com/gesellix/bose-soundtouch/pkg/client" +) + +func main() { + // Configure your device + deviceIP := "192.168.1.100" // Replace with your SoundTouch device IP + + // Create client + soundtouchClient := client.NewClientFromHost(deviceIP) + + fmt.Println("🎵 Bose SoundTouch Zone Slave Operations Example") + fmt.Println("==============================================") + + // Example 1: Add a slave to an existing zone using official /addZoneSlave endpoint + fmt.Println("\n1. Adding slave to zone using official API...") + + masterDeviceID := "ABCD1234EFGH" // Replace with actual master device ID + slaveDeviceID := "WXYZ5678IJKL" // Replace with actual slave device ID + slaveIP := "192.168.1.101" // Replace with actual slave IP + + err := soundtouchClient.AddZoneSlave(masterDeviceID, slaveDeviceID, slaveIP) + if err != nil { + log.Printf("❌ Failed to add zone slave: %v", err) + } else { + fmt.Printf("✅ Successfully added slave '%s' to master '%s'\n", slaveDeviceID, masterDeviceID) + } + + // Wait a moment for the zone change to take effect + time.Sleep(2 * time.Second) + + // Example 2: Check zone status after adding slave + fmt.Println("\n2. Checking zone status...") + + zone, err := soundtouchClient.GetZone() + if err != nil { + log.Printf("❌ Failed to get zone info: %v", err) + } else { + fmt.Printf("📡 Zone Status: %s\n", zone.String()) + fmt.Printf(" Total devices: %d\n", zone.GetTotalDeviceCount()) + + for _, member := range zone.Members { + fmt.Printf(" Member: %s (%s)\n", member.DeviceID, member.IP) + } + } + + // Example 3: Add slave by device ID only (without IP) + fmt.Println("\n3. Adding another slave by device ID only...") + + anotherSlaveID := "PQRS9012MNOP" // Replace with actual device ID + + err = soundtouchClient.AddZoneSlaveByDeviceID(masterDeviceID, anotherSlaveID) + if err != nil { + log.Printf("❌ Failed to add zone slave by ID: %v", err) + } else { + fmt.Printf("✅ Successfully added slave '%s' to master '%s' (by ID only)\n", anotherSlaveID, masterDeviceID) + } + + time.Sleep(2 * time.Second) + + // Example 4: Remove a slave from the zone using official /removeZoneSlave endpoint + fmt.Println("\n4. Removing slave from zone using official API...") + + err = soundtouchClient.RemoveZoneSlave(masterDeviceID, slaveDeviceID, slaveIP) + if err != nil { + log.Printf("❌ Failed to remove zone slave: %v", err) + } else { + fmt.Printf("✅ Successfully removed slave '%s' from master '%s'\n", slaveDeviceID, masterDeviceID) + } + + time.Sleep(2 * time.Second) + + // Example 5: Remove slave by device ID only + fmt.Println("\n5. Removing another slave by device ID only...") + + err = soundtouchClient.RemoveZoneSlaveByDeviceID(masterDeviceID, anotherSlaveID) + if err != nil { + log.Printf("❌ Failed to remove zone slave by ID: %v", err) + } else { + fmt.Printf("✅ Successfully removed slave '%s' from master '%s' (by ID only)\n", anotherSlaveID, masterDeviceID) + } + + // Example 6: Final zone status check + fmt.Println("\n6. Final zone status...") + + finalZone, err := soundtouchClient.GetZone() + if err != nil { + log.Printf("❌ Failed to get final zone info: %v", err) + } else { + fmt.Printf("📡 Final Zone Status: %s\n", finalZone.String()) + + if finalZone.IsStandalone() { + fmt.Println(" Device is now standalone (no zone)") + } else { + fmt.Printf(" Zone has %d total devices\n", finalZone.GetTotalDeviceCount()) + } + } + + // Example 7: Comparison with high-level zone API + fmt.Println("\n7. Comparison: High-level zone API (enhanced functionality)...") + fmt.Println(" For more complex zone operations, you can also use:") + fmt.Printf(" - soundtouchClient.CreateZoneWithIPs(master, []string{slave1, slave2})\n") + fmt.Printf(" - soundtouchClient.AddToZone(master, slave)\n") + fmt.Printf(" - soundtouchClient.RemoveFromZone(master, slave)\n") + fmt.Printf(" - soundtouchClient.DissolveZone(master)\n") + + fmt.Println("\n🎉 Zone slave operations example completed!") + + // Example 8: Error handling demonstration + fmt.Println("\n8. Error handling example...") + + // Try to add a non-existent device to demonstrate error handling + err = soundtouchClient.AddZoneSlave("INVALID123", "NOTFOUND456", "192.168.1.999") + if err != nil { + fmt.Printf("⚠️ Expected error for invalid operation: %v\n", err) + fmt.Println(" This demonstrates proper error handling for invalid device IDs or IPs") + } +} + +// Notes for usage: +// +// 1. Replace the device IPs and IDs with your actual SoundTouch devices +// 2. Ensure devices are on the same network and powered on +// 3. The master device should be capable of creating zones +// 4. Zone slave operations require exact device IDs (MAC addresses) +// 5. IP addresses are optional but recommended for faster operations +// +// To get device IDs: +// info, _ := soundtouchClient.GetDeviceInfo() +// deviceID := info.DeviceID +// +// To discover devices on your network: +// Use the discovery package or the soundtouch-cli discover command +// +// Official API endpoints implemented: +// POST /addZoneSlave - Add individual slave to existing zone +// POST /removeZoneSlave - Remove individual slave from existing zone +// +// These complement the high-level zone management API: +// GET /getZone - Get zone information +// POST /setZone - Create/modify zones with multiple members diff --git a/pkg/client/client.go b/pkg/client/client.go index 4a58b10..181fbab 100644 --- a/pkg/client/client.go +++ b/pkg/client/client.go @@ -1056,3 +1056,37 @@ func (c *Client) GetTrackInfo() (*models.NowPlaying, error) { return &nowPlaying, err } + +// AddZoneSlave adds a single device to an existing zone using the official /addZoneSlave endpoint +func (c *Client) AddZoneSlave(masterDeviceID, slaveDeviceID, slaveIP string) error { + request := models.NewZoneSlaveRequest(masterDeviceID) + request.AddSlave(slaveDeviceID, slaveIP) + + if err := request.Validate(); err != nil { + return fmt.Errorf("invalid zone slave request: %w", err) + } + + return c.post("/addZoneSlave", request) +} + +// AddZoneSlaveByDeviceID adds a single device to an existing zone by device ID only +func (c *Client) AddZoneSlaveByDeviceID(masterDeviceID, slaveDeviceID string) error { + return c.AddZoneSlave(masterDeviceID, slaveDeviceID, "") +} + +// RemoveZoneSlave removes a single device from an existing zone using the official /removeZoneSlave endpoint +func (c *Client) RemoveZoneSlave(masterDeviceID, slaveDeviceID, slaveIP string) error { + request := models.NewZoneSlaveRequest(masterDeviceID) + request.AddSlave(slaveDeviceID, slaveIP) + + if err := request.Validate(); err != nil { + return fmt.Errorf("invalid zone slave request: %w", err) + } + + return c.post("/removeZoneSlave", request) +} + +// RemoveZoneSlaveByDeviceID removes a single device from an existing zone by device ID only +func (c *Client) RemoveZoneSlaveByDeviceID(masterDeviceID, slaveDeviceID string) error { + return c.RemoveZoneSlave(masterDeviceID, slaveDeviceID, "") +} diff --git a/pkg/client/zone_slave_test.go b/pkg/client/zone_slave_test.go new file mode 100644 index 0000000..00138ae --- /dev/null +++ b/pkg/client/zone_slave_test.go @@ -0,0 +1,558 @@ +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: `OK`, + expectError: false, + expectedPath: "/addZoneSlave", + }, + { + name: "successful add zone slave without IP", + masterID: "MASTER123", + slaveID: "SLAVE456", + slaveIP: "", + responseStatus: http.StatusOK, + responseBody: `OK`, + expectError: false, + expectedPath: "/addZoneSlave", + }, + { + name: "server error response", + masterID: "MASTER123", + slaveID: "SLAVE456", + slaveIP: "192.168.1.101", + responseStatus: http.StatusInternalServerError, + responseBody: `Internal Server Error`, + expectError: true, + expectedPath: "/addZoneSlave", + }, + { + name: "empty master device ID", + masterID: "", + slaveID: "SLAVE456", + slaveIP: "192.168.1.101", + responseStatus: http.StatusOK, + responseBody: `OK`, + expectError: true, + expectedPath: "/addZoneSlave", + }, + { + name: "empty slave device ID", + masterID: "MASTER123", + slaveID: "", + slaveIP: "192.168.1.101", + responseStatus: http.StatusOK, + responseBody: `OK`, + expectError: true, + expectedPath: "/addZoneSlave", + }, + { + name: "invalid slave IP address", + masterID: "MASTER123", + slaveID: "SLAVE456", + slaveIP: "invalid-ip", + responseStatus: http.StatusOK, + responseBody: `OK`, + expectError: true, + expectedPath: "/addZoneSlave", + }, + { + name: "same master and slave device ID", + masterID: "MASTER123", + slaveID: "MASTER123", + slaveIP: "192.168.1.101", + responseStatus: http.StatusOK, + responseBody: `OK`, + 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, `OK`)) + })) + 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: `OK`, + expectError: false, + expectedPath: "/removeZoneSlave", + }, + { + name: "successful remove zone slave without IP", + masterID: "MASTER123", + slaveID: "SLAVE456", + slaveIP: "", + responseStatus: http.StatusOK, + responseBody: `OK`, + expectError: false, + expectedPath: "/removeZoneSlave", + }, + { + name: "server error response", + masterID: "MASTER123", + slaveID: "SLAVE456", + slaveIP: "192.168.1.101", + responseStatus: http.StatusBadRequest, + responseBody: `Bad Request`, + expectError: true, + expectedPath: "/removeZoneSlave", + }, + { + name: "device not found", + masterID: "MASTER123", + slaveID: "NONEXISTENT", + slaveIP: "192.168.1.101", + responseStatus: http.StatusNotFound, + responseBody: `Device not found`, + 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, `OK`)) + })) + 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") + } +} diff --git a/pkg/models/zone.go b/pkg/models/zone.go index 45849f9..2e075cc 100644 --- a/pkg/models/zone.go +++ b/pkg/models/zone.go @@ -387,3 +387,94 @@ func (zc *ZoneCapabilities) CanCreateZone() bool { func (zc *ZoneCapabilities) CanJoinZone() bool { return zc.SupportsMultiroom && zc.CanBeMember } + +// ZoneSlaveRequest represents the request for /addZoneSlave and /removeZoneSlave endpoints +type ZoneSlaveRequest struct { + XMLName xml.Name `xml:"zone"` + Master string `xml:"master,attr"` + Members []ZoneSlaveEntry `xml:"member"` +} + +// ZoneSlaveEntry represents a single member entry in zone slave operations +type ZoneSlaveEntry struct { + XMLName xml.Name `xml:"member"` + DeviceID string `xml:",chardata"` + IP string `xml:"ipaddress,attr,omitempty"` +} + +// NewZoneSlaveRequest creates a new zone slave operation request +func NewZoneSlaveRequest(masterDeviceID string) *ZoneSlaveRequest { + return &ZoneSlaveRequest{ + Master: masterDeviceID, + Members: []ZoneSlaveEntry{}, + } +} + +// AddSlave adds a single slave to the request +func (zsr *ZoneSlaveRequest) AddSlave(deviceID, ipAddress string) { + slave := ZoneSlaveEntry{ + DeviceID: deviceID, + IP: ipAddress, + } + zsr.Members = append(zsr.Members, slave) +} + +// Validate validates the zone slave request +func (zsr *ZoneSlaveRequest) Validate() error { + if zsr.Master == "" { + return fmt.Errorf("master device ID is required") + } + + if len(zsr.Members) != 1 { + return fmt.Errorf("zone slave operations require exactly one member, got %d", len(zsr.Members)) + } + + member := zsr.Members[0] + if member.DeviceID == "" { + return fmt.Errorf("slave device ID cannot be empty") + } + + if member.DeviceID == zsr.Master { + return fmt.Errorf("slave device ID cannot be the same as master: %s", member.DeviceID) + } + + if member.IP != "" { + if net.ParseIP(member.IP) == nil { + return fmt.Errorf("invalid IP address for device %s: %s", member.DeviceID, member.IP) + } + } + + return nil +} + +// GetSlaveDeviceID returns the device ID of the slave being added/removed +func (zsr *ZoneSlaveRequest) GetSlaveDeviceID() string { + if len(zsr.Members) > 0 { + return zsr.Members[0].DeviceID + } + return "" +} + +// GetSlaveIP returns the IP address of the slave being added/removed +func (zsr *ZoneSlaveRequest) GetSlaveIP() string { + if len(zsr.Members) > 0 { + return zsr.Members[0].IP + } + return "" +} + +// String returns a human-readable string representation +func (zsr *ZoneSlaveRequest) String() string { + if len(zsr.Members) == 0 { + return fmt.Sprintf("Zone slave operation on master %s (no slave specified)", zsr.Master) + } + + slave := zsr.Members[0] + if slave.IP != "" { + return fmt.Sprintf("Zone slave operation: master=%s, slave=%s (%s)", + zsr.Master, slave.DeviceID, slave.IP) + } + + return fmt.Sprintf("Zone slave operation: master=%s, slave=%s", + zsr.Master, slave.DeviceID) +} diff --git a/pkg/models/zone_slave_test.go b/pkg/models/zone_slave_test.go new file mode 100644 index 0000000..d4ee583 --- /dev/null +++ b/pkg/models/zone_slave_test.go @@ -0,0 +1,445 @@ +package models + +import ( + "encoding/xml" + "strings" + "testing" +) + +func TestZoneSlaveRequest_Creation(t *testing.T) { + t.Run("NewZoneSlaveRequest", func(t *testing.T) { + masterID := "MASTER123" + request := NewZoneSlaveRequest(masterID) + + if request.Master != masterID { + t.Errorf("Expected master ID '%s', got '%s'", masterID, request.Master) + } + + if len(request.Members) != 0 { + t.Errorf("Expected empty members slice, got %d members", len(request.Members)) + } + }) + + t.Run("AddSlave", func(t *testing.T) { + request := NewZoneSlaveRequest("MASTER123") + request.AddSlave("SLAVE456", "192.168.1.101") + + if len(request.Members) != 1 { + t.Errorf("Expected 1 member, got %d", len(request.Members)) + return + } + + member := request.Members[0] + if member.DeviceID != "SLAVE456" { + t.Errorf("Expected device ID 'SLAVE456', got '%s'", member.DeviceID) + } + + if member.IP != "192.168.1.101" { + t.Errorf("Expected IP '192.168.1.101', got '%s'", member.IP) + } + }) +} + +func TestZoneSlaveRequest_Validation(t *testing.T) { + tests := []struct { + name string + masterID string + members []ZoneSlaveEntry + expectError bool + errorMsg string + }{ + { + name: "valid request with IP", + masterID: "MASTER123", + members: []ZoneSlaveEntry{ + {DeviceID: "SLAVE456", IP: "192.168.1.101"}, + }, + expectError: false, + }, + { + name: "valid request without IP", + masterID: "MASTER123", + members: []ZoneSlaveEntry{ + {DeviceID: "SLAVE456", IP: ""}, + }, + expectError: false, + }, + { + name: "empty master ID", + masterID: "", + members: []ZoneSlaveEntry{{DeviceID: "SLAVE456", IP: "192.168.1.101"}}, + expectError: true, + errorMsg: "master device ID is required", + }, + { + name: "no members", + masterID: "MASTER123", + members: []ZoneSlaveEntry{}, + expectError: true, + errorMsg: "zone slave operations require exactly one member", + }, + { + name: "multiple members", + masterID: "MASTER123", + members: []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", + masterID: "MASTER123", + members: []ZoneSlaveEntry{{DeviceID: "", IP: "192.168.1.101"}}, + expectError: true, + errorMsg: "slave device ID cannot be empty", + }, + { + name: "same master and slave ID", + masterID: "MASTER123", + members: []ZoneSlaveEntry{{DeviceID: "MASTER123", IP: "192.168.1.101"}}, + expectError: true, + errorMsg: "slave device ID cannot be the same as master", + }, + { + name: "invalid IP address", + masterID: "MASTER123", + members: []ZoneSlaveEntry{{DeviceID: "SLAVE456", IP: "invalid-ip"}}, + expectError: true, + errorMsg: "invalid IP address", + }, + { + name: "malformed IP address", + masterID: "MASTER123", + members: []ZoneSlaveEntry{{DeviceID: "SLAVE456", IP: "300.300.300.300"}}, + expectError: true, + errorMsg: "invalid IP address", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + request := &ZoneSlaveRequest{ + Master: tt.masterID, + Members: tt.members, + } + + err := 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 with member", func(t *testing.T) { + request := NewZoneSlaveRequest("MASTER123") + request.AddSlave("SLAVE456", "192.168.1.101") + + deviceID := request.GetSlaveDeviceID() + expected := "SLAVE456" + if deviceID != expected { + t.Errorf("Expected device ID '%s', got '%s'", expected, deviceID) + } + }) + + t.Run("GetSlaveDeviceID with no members", func(t *testing.T) { + request := NewZoneSlaveRequest("MASTER123") + + deviceID := request.GetSlaveDeviceID() + if deviceID != "" { + t.Errorf("Expected empty device ID, got '%s'", deviceID) + } + }) + + t.Run("GetSlaveIP with member", func(t *testing.T) { + request := NewZoneSlaveRequest("MASTER123") + request.AddSlave("SLAVE456", "192.168.1.101") + + ip := request.GetSlaveIP() + expected := "192.168.1.101" + if ip != expected { + t.Errorf("Expected IP '%s', got '%s'", expected, ip) + } + }) + + t.Run("GetSlaveIP with no members", func(t *testing.T) { + request := NewZoneSlaveRequest("MASTER123") + + ip := request.GetSlaveIP() + if ip != "" { + t.Errorf("Expected empty IP, got '%s'", ip) + } + }) + + t.Run("GetSlaveIP with empty IP", func(t *testing.T) { + request := NewZoneSlaveRequest("MASTER123") + request.AddSlave("SLAVE456", "") + + ip := request.GetSlaveIP() + if ip != "" { + t.Errorf("Expected empty IP, got '%s'", ip) + } + }) +} + +func TestZoneSlaveRequest_String(t *testing.T) { + tests := []struct { + name string + setup func() *ZoneSlaveRequest + expected string + }{ + { + name: "with IP address", + setup: func() *ZoneSlaveRequest { + req := NewZoneSlaveRequest("MASTER123") + req.AddSlave("SLAVE456", "192.168.1.101") + return req + }, + expected: "Zone slave operation: master=MASTER123, slave=SLAVE456 (192.168.1.101)", + }, + { + name: "without IP address", + setup: func() *ZoneSlaveRequest { + req := NewZoneSlaveRequest("MASTER123") + req.AddSlave("SLAVE456", "") + return req + }, + expected: "Zone slave operation: master=MASTER123, slave=SLAVE456", + }, + { + name: "no members", + setup: func() *ZoneSlaveRequest { + return NewZoneSlaveRequest("MASTER123") + }, + expected: "Zone slave operation on master MASTER123 (no slave specified)", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + request := tt.setup() + result := request.String() + + if result != tt.expected { + t.Errorf("Expected string '%s', got '%s'", tt.expected, result) + } + }) + } +} + +func TestZoneSlaveRequest_XMLMarshaling(t *testing.T) { + t.Run("marshal with IP", func(t *testing.T) { + request := NewZoneSlaveRequest("MASTER123") + request.AddSlave("SLAVE456", "192.168.1.101") + + xmlData, err := xml.Marshal(request) + if err != nil { + t.Fatalf("Failed to marshal XML: %v", err) + } + + xmlStr := string(xmlData) + + // Check for expected XML elements + if !strings.Contains(xmlStr, ``) { + t.Error("Expected XML to contain zone element with master attribute") + } + + if !strings.Contains(xmlStr, `SLAVE456`) { + t.Error("Expected XML to contain member with IP address") + } + }) + + t.Run("marshal without IP", func(t *testing.T) { + request := NewZoneSlaveRequest("MASTER123") + request.AddSlave("SLAVE456", "") + + xmlData, err := xml.Marshal(request) + if err != nil { + t.Fatalf("Failed to marshal XML: %v", err) + } + + xmlStr := string(xmlData) + + // Check for expected XML elements + if !strings.Contains(xmlStr, ``) { + t.Error("Expected XML to contain zone element with master attribute") + } + + if !strings.Contains(xmlStr, `SLAVE456`) { + t.Error("Expected XML to contain member without IP address") + } + + // Should not contain empty ipaddress attribute + if strings.Contains(xmlStr, `ipaddress=""`) { + t.Error("Expected XML to not contain empty ipaddress attribute") + } + }) +} + +func TestZoneSlaveRequest_XMLUnmarshaling(t *testing.T) { + tests := []struct { + name string + xmlData string + expectedReq *ZoneSlaveRequest + expectError bool + }{ + { + name: "valid XML with IP", + xmlData: `SLAVE456`, + expectedReq: &ZoneSlaveRequest{ + Master: "MASTER123", + Members: []ZoneSlaveEntry{ + {DeviceID: "SLAVE456", IP: "192.168.1.101"}, + }, + }, + expectError: false, + }, + { + name: "valid XML without IP", + xmlData: `SLAVE456`, + expectedReq: &ZoneSlaveRequest{ + Master: "MASTER123", + Members: []ZoneSlaveEntry{ + {DeviceID: "SLAVE456", IP: ""}, + }, + }, + expectError: false, + }, + { + name: "invalid XML", + xmlData: `SLAVE456`, + expectError: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var request ZoneSlaveRequest + err := xml.Unmarshal([]byte(tt.xmlData), &request) + + 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 + } + + // Compare the unmarshaled request with expected + if request.Master != tt.expectedReq.Master { + t.Errorf("Expected master '%s', got '%s'", tt.expectedReq.Master, request.Master) + } + + if len(request.Members) != len(tt.expectedReq.Members) { + t.Errorf("Expected %d members, got %d", len(tt.expectedReq.Members), len(request.Members)) + return + } + + for i, expectedMember := range tt.expectedReq.Members { + member := request.Members[i] + if member.DeviceID != expectedMember.DeviceID { + t.Errorf("Expected member %d device ID '%s', got '%s'", i, expectedMember.DeviceID, member.DeviceID) + } + + if member.IP != expectedMember.IP { + t.Errorf("Expected member %d IP '%s', got '%s'", i, expectedMember.IP, member.IP) + } + } + }) + } +} + +func TestZoneSlaveEntry_XMLMarshaling(t *testing.T) { + t.Run("entry with IP", func(t *testing.T) { + entry := ZoneSlaveEntry{ + DeviceID: "SLAVE456", + IP: "192.168.1.101", + } + + xmlData, err := xml.Marshal(entry) + if err != nil { + t.Fatalf("Failed to marshal XML: %v", err) + } + + xmlStr := string(xmlData) + expected := `SLAVE456` + if xmlStr != expected { + t.Errorf("Expected XML '%s', got '%s'", expected, xmlStr) + } + }) + + t.Run("entry without IP", func(t *testing.T) { + entry := ZoneSlaveEntry{ + DeviceID: "SLAVE456", + IP: "", + } + + xmlData, err := xml.Marshal(entry) + if err != nil { + t.Fatalf("Failed to marshal XML: %v", err) + } + + xmlStr := string(xmlData) + expected := `SLAVE456` + if xmlStr != expected { + t.Errorf("Expected XML '%s', got '%s'", expected, xmlStr) + } + }) +} + +func TestZoneSlaveRequest_EdgeCases(t *testing.T) { + t.Run("multiple AddSlave calls", func(t *testing.T) { + request := NewZoneSlaveRequest("MASTER123") + request.AddSlave("SLAVE456", "192.168.1.101") + request.AddSlave("SLAVE789", "192.168.1.102") + + if len(request.Members) != 2 { + t.Errorf("Expected 2 members, got %d", len(request.Members)) + } + + // Should fail validation due to multiple members + err := request.Validate() + if err == nil { + t.Error("Expected validation error for multiple members but got none") + } + }) + + t.Run("IPv6 address", func(t *testing.T) { + request := NewZoneSlaveRequest("MASTER123") + request.AddSlave("SLAVE456", "2001:db8::1") + + err := request.Validate() + if err != nil { + t.Errorf("Expected no error for IPv6 address but got: %v", err) + } + }) + + t.Run("localhost IP", func(t *testing.T) { + request := NewZoneSlaveRequest("MASTER123") + request.AddSlave("SLAVE456", "127.0.0.1") + + err := request.Validate() + if err != nil { + t.Errorf("Expected no error for localhost IP but got: %v", err) + } + }) +}