mirror of
https://github.com/gesellix/Bose-SoundTouch.git
synced 2026-08-18 08:36:13 +00:00
Major Features: • Implement complete /requestToken API endpoint for bearer token generation • Fix clock time parsing and display with comprehensive time information • Add comprehensive API documentation for 103 discovered endpoints /requestToken Implementation: • Add BearerToken model with full XML marshaling support • Add RequestToken() client method with proper error handling • Add 'soundtouch-cli token request' CLI command with security features • Token validation, formatting, and secure display (truncated for security) • Comprehensive unit tests and integration tests • Support for Authorization header formatting and raw token extraction Clock Time Fixes: • Fix ClockTime model to match actual device XML response structure • Add LocalTime component for nested time details • Support for utcTime, timeFormat, brightness, clockError attributes • Enhanced CLI display with comprehensive time information • Fixed month conversion (device uses 0-11, Go uses 1-12) Documentation Enhancements: • Add comprehensive /supportedURLs endpoint analysis (103 endpoints discovered) • Create detailed unimplemented endpoints documentation with examples • Update API coverage from 34% implemented to full endpoint catalog • Add SoundTouch End of Life notice with May 6, 2026 details • Enhanced endpoint descriptions with real device response examples Security: • All tests use generic token examples (no real tokens exposed) • Integration tests validate token properties without exposing values • Secure token display with truncation in CLI and string representations • Environment variable based testing for real devices Testing: • 15+ new test functions with comprehensive coverage • Real device validation on 192.168.178.28 and 192.168.178.35 • Mock server tests and XML marshaling validation • Integration tests with SOUNDTOUCH_TEST_HOST environment variable CLI Enhancements: • Enhanced clock time display with local time details and device settings • New token management commands with usage instructions • Improved error handling and user-friendly output formatting
1163 lines
30 KiB
Go
1163 lines
30 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 := &Config{
|
|
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 := &Config{
|
|
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 != "ABCD1234EFGH" {
|
|
t.Errorf("Expected DeviceID 'ABCD1234EFGH', got '%s'", deviceInfo.DeviceID)
|
|
}
|
|
|
|
if deviceInfo.Type != "SoundTouch 10" {
|
|
t.Errorf("Expected Type 'SoundTouch 10', got '%s'", deviceInfo.Type)
|
|
}
|
|
|
|
if deviceInfo.Name != "My SoundTouch Device" {
|
|
t.Errorf("Expected Name 'My SoundTouch Device', 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.10" {
|
|
t.Errorf("Expected IP address '192.168.1.10', 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, _ *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, _ *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, _ *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, _ *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, _ *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, _ *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(&Config{
|
|
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, _ *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(&Config{
|
|
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(&Config{
|
|
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, _ *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(&Config{
|
|
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(&Config{
|
|
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, _ *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(&Config{
|
|
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(&Config{
|
|
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, _ *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(&Config{
|
|
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())
|
|
}
|
|
}
|
|
|
|
func TestClient_GetName(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
responseFile string
|
|
expectedError bool
|
|
expectedName string
|
|
}{
|
|
{
|
|
name: "valid device name",
|
|
responseFile: "name_response.xml",
|
|
expectedError: false,
|
|
expectedName: "My SoundTouch Device",
|
|
},
|
|
}
|
|
|
|
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 != "/name" {
|
|
t.Errorf("Expected path /name, 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(&Config{
|
|
Host: host,
|
|
Port: port,
|
|
Timeout: 5 * time.Second,
|
|
UserAgent: "test-client",
|
|
})
|
|
|
|
name, err := client.GetName()
|
|
|
|
if tt.expectedError {
|
|
if err == nil {
|
|
t.Error("Expected error but got none")
|
|
}
|
|
|
|
return
|
|
}
|
|
|
|
if err != nil {
|
|
t.Fatalf("Unexpected error: %v", err)
|
|
}
|
|
|
|
if name == nil {
|
|
t.Fatal("Expected Name response but got nil")
|
|
}
|
|
|
|
if name.GetName() != tt.expectedName {
|
|
t.Errorf("Expected name '%s', got '%s'", tt.expectedName, name.GetName())
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestClient_GetCapabilities(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
responseFile string
|
|
expectedError bool
|
|
expectedDevice string
|
|
hasLRStereo bool
|
|
hasDualMode bool
|
|
hasWSAPIProxy bool
|
|
}{
|
|
{
|
|
name: "valid capabilities",
|
|
responseFile: "capabilities_response.xml",
|
|
expectedError: false,
|
|
expectedDevice: "ABCD1234EFGH",
|
|
hasLRStereo: true,
|
|
hasDualMode: true,
|
|
hasWSAPIProxy: 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.Method != "GET" {
|
|
t.Errorf("Expected GET request, got %s", r.Method)
|
|
}
|
|
|
|
if r.URL.Path != "/capabilities" {
|
|
t.Errorf("Expected path /capabilities, got %s", r.URL.Path)
|
|
}
|
|
|
|
// 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(&Config{
|
|
Host: host,
|
|
Port: port,
|
|
Timeout: 5 * time.Second,
|
|
UserAgent: "test-client",
|
|
})
|
|
|
|
capabilities, err := client.GetCapabilities()
|
|
|
|
if tt.expectedError {
|
|
if err == nil {
|
|
t.Error("Expected error but got none")
|
|
}
|
|
|
|
return
|
|
}
|
|
|
|
if err != nil {
|
|
t.Fatalf("Unexpected error: %v", err)
|
|
}
|
|
|
|
if capabilities == nil {
|
|
t.Fatal("Expected Capabilities response but got nil")
|
|
}
|
|
|
|
if capabilities.DeviceID != tt.expectedDevice {
|
|
t.Errorf("Expected device ID '%s', got '%s'", tt.expectedDevice, capabilities.DeviceID)
|
|
}
|
|
|
|
if capabilities.HasLRStereoCapability() != tt.hasLRStereo {
|
|
t.Errorf("Expected HasLRStereoCapability() %v, got %v", tt.hasLRStereo, capabilities.HasLRStereoCapability())
|
|
}
|
|
|
|
if capabilities.HasDualModeNetwork() != tt.hasDualMode {
|
|
t.Errorf("Expected HasDualModeNetwork() %v, got %v", tt.hasDualMode, capabilities.HasDualModeNetwork())
|
|
}
|
|
|
|
if capabilities.HasWSAPIProxy() != tt.hasWSAPIProxy {
|
|
t.Errorf("Expected HasWSAPIProxy() %v, got %v", tt.hasWSAPIProxy, capabilities.HasWSAPIProxy())
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestClient_GetPresets(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
responseFile string
|
|
expectedError bool
|
|
expectedCount int
|
|
expectedUsed int
|
|
expectedSpotify int
|
|
}{
|
|
{
|
|
name: "valid presets",
|
|
responseFile: "presets_response.xml",
|
|
expectedError: false,
|
|
expectedCount: 6,
|
|
expectedUsed: 6,
|
|
expectedSpotify: 6,
|
|
},
|
|
}
|
|
|
|
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 != "/presets" {
|
|
t.Errorf("Expected path /presets, got %s", r.URL.Path)
|
|
}
|
|
|
|
// 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(&Config{
|
|
Host: host,
|
|
Port: port,
|
|
Timeout: 5 * time.Second,
|
|
UserAgent: "test-client",
|
|
})
|
|
|
|
presets, err := client.GetPresets()
|
|
|
|
if tt.expectedError {
|
|
if err == nil {
|
|
t.Error("Expected error but got none")
|
|
}
|
|
|
|
return
|
|
}
|
|
|
|
if err != nil {
|
|
t.Fatalf("Unexpected error: %v", err)
|
|
}
|
|
|
|
if presets == nil {
|
|
t.Fatal("Expected Presets response but got nil")
|
|
}
|
|
|
|
if presets.GetPresetCount() != tt.expectedCount {
|
|
t.Errorf("Expected preset count %d, got %d", tt.expectedCount, presets.GetPresetCount())
|
|
}
|
|
|
|
if len(presets.GetUsedPresetSlots()) != tt.expectedUsed {
|
|
t.Errorf("Expected used count %d, got %d", tt.expectedUsed, len(presets.GetUsedPresetSlots()))
|
|
}
|
|
|
|
if len(presets.GetSpotifyPresets()) != tt.expectedSpotify {
|
|
t.Errorf("Expected Spotify count %d, got %d", tt.expectedSpotify, len(presets.GetSpotifyPresets()))
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestClient_GetName_ServerError(t *testing.T) {
|
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *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(&Config{
|
|
Host: host,
|
|
Port: port,
|
|
Timeout: 5 * time.Second,
|
|
})
|
|
|
|
_, err := client.GetName()
|
|
if err == nil {
|
|
t.Error("Expected error for server error response")
|
|
}
|
|
|
|
expectedErrorMsg := "failed to get device name"
|
|
if !strings.Contains(err.Error(), expectedErrorMsg) {
|
|
t.Errorf("Expected error message to contain '%s', got '%s'", expectedErrorMsg, err.Error())
|
|
}
|
|
}
|
|
|
|
func TestClient_GetCapabilities_ServerError(t *testing.T) {
|
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *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(&Config{
|
|
Host: host,
|
|
Port: port,
|
|
Timeout: 5 * time.Second,
|
|
})
|
|
|
|
_, err := client.GetCapabilities()
|
|
if err == nil {
|
|
t.Error("Expected error for server error response")
|
|
}
|
|
|
|
expectedErrorMsg := "failed to get device capabilities"
|
|
if !strings.Contains(err.Error(), expectedErrorMsg) {
|
|
t.Errorf("Expected error message to contain '%s', got '%s'", expectedErrorMsg, err.Error())
|
|
}
|
|
}
|
|
|
|
func TestClient_GetPresets_ServerError(t *testing.T) {
|
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *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(&Config{
|
|
Host: host,
|
|
Port: port,
|
|
Timeout: 5 * time.Second,
|
|
})
|
|
|
|
_, err := client.GetPresets()
|
|
if err == nil {
|
|
t.Error("Expected error for server error response")
|
|
}
|
|
|
|
expectedErrorMsg := "failed to get presets"
|
|
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)
|
|
}
|
|
|
|
func TestClient_RequestToken(t *testing.T) {
|
|
// Create mock server
|
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
if r.URL.Path != "/requestToken" {
|
|
t.Errorf("Expected path '/requestToken', got '%s'", r.URL.Path)
|
|
w.WriteHeader(http.StatusNotFound)
|
|
|
|
return
|
|
}
|
|
|
|
if r.Method != http.MethodGet {
|
|
t.Errorf("Expected GET method, got %s", r.Method)
|
|
w.WriteHeader(http.StatusMethodNotAllowed)
|
|
|
|
return
|
|
}
|
|
|
|
// Return mock bearer token response (generic example)
|
|
w.Header().Set("Content-Type", "application/xml")
|
|
w.WriteHeader(http.StatusOK)
|
|
_, _ = w.Write([]byte(`<?xml version="1.0" encoding="UTF-8" ?><bearertoken value="Bearer vUApzBVT6Lh0nw1xVu/plr1UDRNdMYMEpe0cStm4wCH5mWSjrrtORnGGirMn3pspkJ8mNR1MFh/J4OcsbEikMplcDGJVeuZOnDPAskQALvDBCF0PW74qXRms2k1AfLJ/" />`))
|
|
}))
|
|
defer server.Close()
|
|
|
|
// Create test client
|
|
client := createTestClient(server.URL)
|
|
|
|
// Test RequestToken
|
|
token, err := client.RequestToken()
|
|
if err != nil {
|
|
t.Fatalf("RequestToken() failed: %v", err)
|
|
}
|
|
|
|
if token == nil {
|
|
t.Fatal("RequestToken() returned nil token")
|
|
}
|
|
|
|
// Verify token properties instead of exact values
|
|
if !token.IsValid() {
|
|
t.Error("Token should be valid")
|
|
}
|
|
|
|
// Verify token has proper Bearer prefix
|
|
tokenValue := token.GetToken()
|
|
if !strings.HasPrefix(tokenValue, "Bearer ") {
|
|
t.Errorf("Token should start with 'Bearer ', got: %s", tokenValue)
|
|
}
|
|
|
|
// Verify auth header matches full token
|
|
if token.GetAuthHeader() != tokenValue {
|
|
t.Errorf("Auth header should match token value")
|
|
}
|
|
|
|
// Verify raw token extraction
|
|
rawToken := token.GetTokenWithoutPrefix()
|
|
if rawToken == tokenValue {
|
|
t.Error("Raw token should not include Bearer prefix")
|
|
}
|
|
|
|
// Verify token is reasonably long (bearer tokens should be substantial)
|
|
if len(rawToken) < 50 {
|
|
t.Errorf("Token seems too short: %d characters", len(rawToken))
|
|
}
|
|
}
|
|
|
|
func TestClient_RequestToken_Error(t *testing.T) {
|
|
// Create mock server that returns error
|
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
|
w.WriteHeader(http.StatusInternalServerError)
|
|
_, _ = w.Write([]byte("Internal Server Error"))
|
|
}))
|
|
defer server.Close()
|
|
|
|
// Create test client
|
|
client := createTestClient(server.URL)
|
|
|
|
// Test RequestToken with error
|
|
token, err := client.RequestToken()
|
|
if err == nil {
|
|
t.Fatal("RequestToken() should have failed")
|
|
}
|
|
|
|
if token != nil {
|
|
t.Error("RequestToken() should return nil token on error")
|
|
}
|
|
|
|
if !strings.Contains(err.Error(), "failed to request token") {
|
|
t.Errorf("Error should mention 'failed to request token', got: %v", err)
|
|
}
|
|
}
|