Files
Bose-SoundTouch/pkg/client/client_test.go
T
Tobias Gesellchen 5caad90d51 Implement /now_playing and /sources endpoints with real device integration
## New Endpoints

### GET /now_playing 
- Rich XML models with PlayStatus, ShuffleSetting, RepeatSetting enums
- Comprehensive playback information (track, artist, album, artwork, position)
- Device capabilities (skip, seek, favorite functionality)
- Smart display methods for different content types (music vs radio)
- Duration formatting with position/total time display

### GET /sources 
- Complete audio source management with SourceStatus enum
- Source categorization (Local/Remote, Streaming, Multiroom support)
- Multiple account support (multiple Spotify accounts per device)
- Availability filtering (Ready vs Unavailable sources)
- Helper methods for quick capability checks

## Real Device Integration

- Fetched actual XML responses from SoundTouch devices (192.168.178.28 & 192.168.178.35)
- Updated all test fixtures with real device data (anonymized)
- Enhanced XML models to handle all real-world fields and edge cases
- Verified compatibility across different device types and configurations

## Enhanced CLI Tool

- Added -nowplaying command with rich formatted output
- Added -sources command with categorized source listing
- Display enhancements: duration info, capabilities, source attributes
- Improved build process to use ./build/ directory consistently

## Comprehensive Testing

- 15+ unit tests for XML models with enum validation
- Client integration tests with mock HTTP responses
- Real device response validation
- Edge case handling (empty states, network errors, invalid data)

## Documentation & Guidelines

- Updated CLAUDE.md with build directory and real device testing guidelines
- Enhanced README with comprehensive usage examples
- Updated PLAN.md to reflect implementation progress
- All examples use real device data patterns

## Quality Improvements

- Type-safe XML unmarshaling with custom validation
- Consistent error handling across all endpoints
- Privacy protection (anonymized account information)
- Production-ready code structure and patterns

Features:
 GET /info - Device information
 GET /now_playing - Current playback status with full metadata
 GET /sources - Available audio sources with smart categorization
 UPnP device discovery
 Cross-platform CLI tool with rich output formatting
 Comprehensive test coverage with real device data
 Build automation with proper directory structure
2026-01-08 23:21:01 +01:00

710 lines
19 KiB
Go

package client
import (
"net/http"
"net/http/httptest"
"net/url"
"os"
"path/filepath"
"strconv"
"strings"
"testing"
"time"
)
func TestNewClient(t *testing.T) {
config := ClientConfig{
Host: "192.168.1.100",
Port: 8090,
Timeout: 15 * time.Second,
}
client := NewClient(config)
if client.baseURL != "http://192.168.1.100:8090" {
t.Errorf("Expected baseURL 'http://192.168.1.100:8090', got '%s'", client.baseURL)
}
if client.timeout != 15*time.Second {
t.Errorf("Expected timeout 15s, got %v", client.timeout)
}
if client.httpClient.Timeout != 15*time.Second {
t.Errorf("Expected HTTP client timeout 15s, got %v", client.httpClient.Timeout)
}
}
func TestNewClientWithDefaults(t *testing.T) {
config := ClientConfig{
Host: "192.168.1.100",
}
client := NewClient(config)
if client.baseURL != "http://192.168.1.100:8090" {
t.Errorf("Expected default port 8090 in baseURL, got '%s'", client.baseURL)
}
if client.timeout != 10*time.Second {
t.Errorf("Expected default timeout 10s, got %v", client.timeout)
}
if client.userAgent != "Bose-SoundTouch-Go-Client/1.0" {
t.Errorf("Expected default user agent, got '%s'", client.userAgent)
}
}
func TestNewClientFromHost(t *testing.T) {
client := NewClientFromHost("192.168.1.200")
expected := "http://192.168.1.200:8090"
if client.baseURL != expected {
t.Errorf("Expected baseURL '%s', got '%s'", expected, client.baseURL)
}
}
func TestGetDeviceInfo_Success(t *testing.T) {
// Load test data
testData := loadTestData(t, "info_response.xml")
// Create mock server
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// Verify request
if r.URL.Path != "/info" {
t.Errorf("Expected path '/info', got '%s'", r.URL.Path)
}
if r.Method != "GET" {
t.Errorf("Expected method GET, got %s", r.Method)
}
// Check headers
if r.Header.Get("Accept") != "application/xml" {
t.Errorf("Expected Accept header 'application/xml', got '%s'", r.Header.Get("Accept"))
}
if r.Header.Get("User-Agent") != "Bose-SoundTouch-Go-Client/1.0" {
t.Errorf("Expected User-Agent header, got '%s'", r.Header.Get("User-Agent"))
}
// Send response
w.Header().Set("Content-Type", "application/xml")
w.WriteHeader(http.StatusOK)
w.Write([]byte(testData))
}))
defer server.Close()
// Create client pointing to mock server
client := createTestClient(server.URL)
// Test GetDeviceInfo
deviceInfo, err := client.GetDeviceInfo()
if err != nil {
t.Fatalf("Expected no error, got: %v", err)
}
// Verify response parsing
if deviceInfo.DeviceID != "A81B6A536A98" {
t.Errorf("Expected DeviceID 'A81B6A536A98', got '%s'", deviceInfo.DeviceID)
}
if deviceInfo.Type != "SoundTouch 10" {
t.Errorf("Expected Type 'SoundTouch 10', got '%s'", deviceInfo.Type)
}
if deviceInfo.Name != "Sound Machinechen" {
t.Errorf("Expected Name 'Sound Machinechen', got '%s'", deviceInfo.Name)
}
if deviceInfo.MargeAccountUUID != "3230304" {
t.Errorf("Expected MargeAccountUUID '3230304', got '%s'", deviceInfo.MargeAccountUUID)
}
if deviceInfo.ModuleType != "sm2" {
t.Errorf("Expected ModuleType 'sm2', got '%s'", deviceInfo.ModuleType)
}
if len(deviceInfo.Components) != 2 {
t.Errorf("Expected 2 components, got %d", len(deviceInfo.Components))
}
// Check first component
if len(deviceInfo.Components) > 0 {
comp := deviceInfo.Components[0]
if comp.ComponentCategory != "SCM" {
t.Errorf("Expected first component category 'SCM', got '%s'", comp.ComponentCategory)
}
if comp.SerialNumber != "I6332527703739342000020" {
t.Errorf("Expected first component serial 'I6332527703739342000020', got '%s'", comp.SerialNumber)
}
}
// Check network info
if len(deviceInfo.NetworkInfo) != 2 {
t.Errorf("Expected 2 network info entries, got %d", len(deviceInfo.NetworkInfo))
}
if len(deviceInfo.NetworkInfo) > 0 {
net := deviceInfo.NetworkInfo[0]
if net.Type != "SCM" {
t.Errorf("Expected first network type 'SCM', got '%s'", net.Type)
}
if net.IPAddress != "192.168.1.35" {
t.Errorf("Expected IP address '192.168.1.35', got '%s'", net.IPAddress)
}
}
}
func TestGetDeviceInfo_HTTPError(t *testing.T) {
// Create mock server that returns 404
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusNotFound)
w.Write([]byte("Not Found"))
}))
defer server.Close()
client := createTestClient(server.URL)
// Test GetDeviceInfo with 404 response
_, err := client.GetDeviceInfo()
if err == nil {
t.Fatal("Expected error for 404 response, got nil")
}
expectedError := "API request failed with status 404"
if !contains(err.Error(), expectedError) {
t.Errorf("Expected error to contain '%s', got '%s'", expectedError, err.Error())
}
}
func TestGetDeviceInfo_InvalidXML(t *testing.T) {
// Create mock server that returns invalid XML
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/xml")
w.WriteHeader(http.StatusOK)
w.Write([]byte("invalid xml content"))
}))
defer server.Close()
client := createTestClient(server.URL)
// Test GetDeviceInfo with invalid XML
_, err := client.GetDeviceInfo()
if err == nil {
t.Fatal("Expected error for invalid XML, got nil")
}
expectedError := "failed to unmarshal XML response"
if !contains(err.Error(), expectedError) {
t.Errorf("Expected error to contain '%s', got '%s'", expectedError, err.Error())
}
}
func TestGetDeviceInfo_APIError(t *testing.T) {
// Create mock server that returns API error
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/xml")
w.WriteHeader(http.StatusOK)
w.Write([]byte(`<?xml version="1.0" encoding="UTF-8"?><error code="404">Device not found</error>`))
}))
defer server.Close()
client := createTestClient(server.URL)
// Test GetDeviceInfo with API error
_, err := client.GetDeviceInfo()
if err == nil {
t.Fatal("Expected API error, got nil")
}
// The error gets wrapped by GetDeviceInfo, so check the error message content
if !contains(err.Error(), "Device not found") {
t.Errorf("Expected error to contain 'Device not found', got '%s'", err.Error())
}
}
func TestPing_Success(t *testing.T) {
testData := loadTestData(t, "info_response.xml")
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/xml")
w.WriteHeader(http.StatusOK)
w.Write([]byte(testData))
}))
defer server.Close()
client := createTestClient(server.URL)
err := client.Ping()
if err != nil {
t.Errorf("Expected successful ping, got error: %v", err)
}
}
func TestPing_Failure(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusInternalServerError)
}))
defer server.Close()
client := createTestClient(server.URL)
err := client.Ping()
if err == nil {
t.Error("Expected ping to fail, but got no error")
}
}
func TestBaseURL(t *testing.T) {
client := NewClientFromHost("192.168.1.100")
expected := "http://192.168.1.100:8090"
if client.BaseURL() != expected {
t.Errorf("Expected BaseURL '%s', got '%s'", expected, client.BaseURL())
}
}
func TestClientTimeout(t *testing.T) {
// Create a server that delays response
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
time.Sleep(200 * time.Millisecond)
w.WriteHeader(http.StatusOK)
w.Write([]byte(`<info deviceID="test"></info>`))
}))
defer server.Close()
// Create client with short timeout
config := DefaultConfig()
config.Timeout = 100 * time.Millisecond
client := NewClient(config)
client.baseURL = server.URL
// Test that request times out
_, err := client.GetDeviceInfo()
if err == nil {
t.Error("Expected timeout error, got nil")
}
expectedError := "deadline exceeded"
if !contains(err.Error(), expectedError) {
t.Errorf("Expected error to contain '%s', got '%s'", expectedError, err.Error())
}
}
func TestClient_GetNowPlaying(t *testing.T) {
tests := []struct {
name string
responseFile string
expectedError bool
expectedTrack string
expectedArtist string
expectedSource string
expectedStatus string
}{
{
name: "spotify track playing",
responseFile: "nowplaying_response.xml",
expectedError: false,
expectedTrack: "In Between Breaths - Paris Unplugged",
expectedArtist: "SYML",
expectedSource: "SPOTIFY",
expectedStatus: "Playing",
},
{
name: "radio station playing",
responseFile: "nowplaying_radio.xml",
expectedError: false,
expectedTrack: "",
expectedArtist: "",
expectedSource: "TUNEIN",
expectedStatus: "Playing",
},
{
name: "standby state",
responseFile: "nowplaying_empty.xml",
expectedError: false,
expectedTrack: "",
expectedArtist: "",
expectedSource: "STANDBY",
expectedStatus: "Unknown",
},
}
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.Method != "GET" {
t.Errorf("Expected GET request, got %s", r.Method)
}
if r.URL.Path != "/now_playing" {
t.Errorf("Expected path /now_playing, got %s", r.URL.Path)
}
// Check headers
if userAgent := r.Header.Get("User-Agent"); userAgent == "" {
t.Error("Expected User-Agent header to be set")
}
if accept := r.Header.Get("Accept"); accept != "application/xml" {
t.Errorf("Expected Accept header 'application/xml', got '%s'", accept)
}
// Read test data
data, err := os.ReadFile(filepath.Join("testdata", tt.responseFile))
if err != nil {
t.Fatalf("Failed to read test data: %v", err)
}
w.Header().Set("Content-Type", "application/xml")
w.WriteHeader(http.StatusOK)
w.Write(data)
}))
defer server.Close()
// Parse server URL to get host and port
serverURL, err := url.Parse(server.URL)
if err != nil {
t.Fatalf("Failed to parse server URL: %v", err)
}
host := serverURL.Hostname()
port, _ := strconv.Atoi(serverURL.Port())
client := NewClient(ClientConfig{
Host: host,
Port: port,
Timeout: 5 * time.Second,
UserAgent: "test-client",
})
nowPlaying, err := client.GetNowPlaying()
if tt.expectedError {
if err == nil {
t.Error("Expected error but got none")
}
return
}
if err != nil {
t.Fatalf("Unexpected error: %v", err)
}
if nowPlaying == nil {
t.Fatal("Expected NowPlaying response but got nil")
}
// Verify basic fields
if nowPlaying.Source != tt.expectedSource {
t.Errorf("Expected Source '%s', got '%s'", tt.expectedSource, nowPlaying.Source)
}
if nowPlaying.Track != tt.expectedTrack {
t.Errorf("Expected Track '%s', got '%s'", tt.expectedTrack, nowPlaying.Track)
}
if nowPlaying.Artist != tt.expectedArtist {
t.Errorf("Expected Artist '%s', got '%s'", tt.expectedArtist, nowPlaying.Artist)
}
if nowPlaying.PlayStatus.String() != tt.expectedStatus {
t.Errorf("Expected PlayStatus '%s', got '%s'", tt.expectedStatus, nowPlaying.PlayStatus.String())
}
})
}
}
func TestClient_GetNowPlaying_ServerError(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusInternalServerError)
w.Write([]byte("Internal Server Error"))
}))
defer server.Close()
serverURL, _ := url.Parse(server.URL)
host := serverURL.Hostname()
port, _ := strconv.Atoi(serverURL.Port())
client := NewClient(ClientConfig{
Host: host,
Port: port,
Timeout: 5 * time.Second,
})
_, err := client.GetNowPlaying()
if err == nil {
t.Error("Expected error for server error response")
}
expectedErrorMsg := "failed to get now playing"
if !strings.Contains(err.Error(), expectedErrorMsg) {
t.Errorf("Expected error message to contain '%s', got '%s'", expectedErrorMsg, err.Error())
}
}
func TestClient_GetNowPlaying_NetworkError(t *testing.T) {
client := NewClient(ClientConfig{
Host: "non-existent-host.invalid",
Port: 8090,
Timeout: 1 * time.Second,
})
_, err := client.GetNowPlaying()
if err == nil {
t.Error("Expected error for network error")
}
expectedErrorMsg := "failed to get now playing"
if !strings.Contains(err.Error(), expectedErrorMsg) {
t.Errorf("Expected error message to contain '%s', got '%s'", expectedErrorMsg, err.Error())
}
}
func TestClient_GetNowPlaying_InvalidXML(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)
w.Write([]byte("<invalid-xml>"))
}))
defer server.Close()
serverURL, _ := url.Parse(server.URL)
host := serverURL.Hostname()
port, _ := strconv.Atoi(serverURL.Port())
client := NewClient(ClientConfig{
Host: host,
Port: port,
Timeout: 5 * time.Second,
})
_, err := client.GetNowPlaying()
if err == nil {
t.Error("Expected error for invalid XML response")
}
expectedErrorMsg := "failed to get now playing"
if !strings.Contains(err.Error(), expectedErrorMsg) {
t.Errorf("Expected error message to contain '%s', got '%s'", expectedErrorMsg, err.Error())
}
}
func TestClient_GetSources(t *testing.T) {
tests := []struct {
name string
responseFile string
expectedError bool
expectedCount int
expectedReady int
hasSpotify bool
hasAux bool
hasBluetooth bool
}{
{
name: "sources with mixed availability",
responseFile: "sources_response.xml",
expectedError: false,
expectedCount: 14,
expectedReady: 5,
hasSpotify: true,
hasAux: true,
hasBluetooth: false, // Bluetooth is unavailable in test data
},
}
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.Method != "GET" {
t.Errorf("Expected GET request, got %s", r.Method)
}
if r.URL.Path != "/sources" {
t.Errorf("Expected path /sources, got %s", r.URL.Path)
}
// Check headers
if userAgent := r.Header.Get("User-Agent"); userAgent == "" {
t.Error("Expected User-Agent header to be set")
}
if accept := r.Header.Get("Accept"); accept != "application/xml" {
t.Errorf("Expected Accept header 'application/xml', got '%s'", accept)
}
// Read test data
data, err := os.ReadFile(filepath.Join("testdata", tt.responseFile))
if err != nil {
t.Fatalf("Failed to read test data: %v", err)
}
w.Header().Set("Content-Type", "application/xml")
w.WriteHeader(http.StatusOK)
w.Write(data)
}))
defer server.Close()
// Parse server URL to get host and port
serverURL, err := url.Parse(server.URL)
if err != nil {
t.Fatalf("Failed to parse server URL: %v", err)
}
host := serverURL.Hostname()
port, _ := strconv.Atoi(serverURL.Port())
client := NewClient(ClientConfig{
Host: host,
Port: port,
Timeout: 5 * time.Second,
UserAgent: "test-client",
})
sources, err := client.GetSources()
if tt.expectedError {
if err == nil {
t.Error("Expected error but got none")
}
return
}
if err != nil {
t.Fatalf("Unexpected error: %v", err)
}
if sources == nil {
t.Fatal("Expected Sources response but got nil")
}
// Verify counts
if sources.GetSourceCount() != tt.expectedCount {
t.Errorf("Expected source count %d, got %d", tt.expectedCount, sources.GetSourceCount())
}
if sources.GetReadySourceCount() != tt.expectedReady {
t.Errorf("Expected ready source count %d, got %d", tt.expectedReady, sources.GetReadySourceCount())
}
// Verify specific sources
if sources.HasSpotify() != tt.hasSpotify {
t.Errorf("Expected HasSpotify() %v, got %v", tt.hasSpotify, sources.HasSpotify())
}
if sources.HasAux() != tt.hasAux {
t.Errorf("Expected HasAux() %v, got %v", tt.hasAux, sources.HasAux())
}
if sources.HasBluetooth() != tt.hasBluetooth {
t.Errorf("Expected HasBluetooth() %v, got %v", tt.hasBluetooth, sources.HasBluetooth())
}
})
}
}
func TestClient_GetSources_ServerError(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusInternalServerError)
w.Write([]byte("Internal Server Error"))
}))
defer server.Close()
serverURL, _ := url.Parse(server.URL)
host := serverURL.Hostname()
port, _ := strconv.Atoi(serverURL.Port())
client := NewClient(ClientConfig{
Host: host,
Port: port,
Timeout: 5 * time.Second,
})
_, err := client.GetSources()
if err == nil {
t.Error("Expected error for server error response")
}
expectedErrorMsg := "failed to get sources"
if !strings.Contains(err.Error(), expectedErrorMsg) {
t.Errorf("Expected error message to contain '%s', got '%s'", expectedErrorMsg, err.Error())
}
}
func TestClient_GetSources_NetworkError(t *testing.T) {
client := NewClient(ClientConfig{
Host: "non-existent-host.invalid",
Port: 8090,
Timeout: 1 * time.Second,
})
_, err := client.GetSources()
if err == nil {
t.Error("Expected error for network error")
}
expectedErrorMsg := "failed to get sources"
if !strings.Contains(err.Error(), expectedErrorMsg) {
t.Errorf("Expected error message to contain '%s', got '%s'", expectedErrorMsg, err.Error())
}
}
func TestClient_GetSources_InvalidXML(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)
w.Write([]byte("<invalid-xml>"))
}))
defer server.Close()
serverURL, _ := url.Parse(server.URL)
host := serverURL.Hostname()
port, _ := strconv.Atoi(serverURL.Port())
client := NewClient(ClientConfig{
Host: host,
Port: port,
Timeout: 5 * time.Second,
})
_, err := client.GetSources()
if err == nil {
t.Error("Expected error for invalid XML response")
}
expectedErrorMsg := "failed to get sources"
if !strings.Contains(err.Error(), expectedErrorMsg) {
t.Errorf("Expected error message to contain '%s', got '%s'", expectedErrorMsg, err.Error())
}
}
// Helper functions
func loadTestData(t *testing.T, filename string) string {
t.Helper()
path := filepath.Join("testdata", filename)
data, err := os.ReadFile(path)
if err != nil {
t.Fatalf("Failed to load test data %s: %v", filename, err)
}
return string(data)
}
func createTestClient(serverURL string) *Client {
config := DefaultConfig()
config.Host = "localhost" // Will be overridden by baseURL
client := NewClient(config)
client.baseURL = serverURL
return client
}
func contains(s, substr string) bool {
return strings.Contains(s, substr)
}