feat: implement comprehensive /supportedURLs endpoint with feature mapping system

 New Features:
- Implement missing /supportedURLs endpoint with full XML parsing
- Add comprehensive endpoint-to-feature mapping system (15+ features, 9 categories)
- Create device capability analysis with personalized recommendations
- Add intelligent device classification (Premium, Standard, Basic, Essential, Limited)

🔧 CLI Enhancements:
- Add 'supported-urls' command with --features and --verbose flags
- Add 'analyze' command for comprehensive device capability analysis
- Add 'station list' command for saved station management
- Add 'source availability' and 'source compare' commands
- Enhanced service availability checking across all commands

📚 Models & API:
- New SupportedURLsResponse model with rich helper methods
- Enhanced ServiceAvailability model with validation utilities
- New EndpointFeature mapping system with CLI command references
- Feature completeness scoring and partial implementation detection

🧪 Testing:
- 35+ new test cases covering all functionality
- Comprehensive feature mapping validation tests
- Service availability integration tests with real device scenarios
- Mock server tests for error handling and edge cases

📖 Documentation:
- New FEATURE-MAPPING-GUIDE.md with comprehensive usage examples
- Updated API documentation with correct implementation status
- CLI command reference organized by feature category
- Device troubleshooting guide with capability checking

🎯 Key Capabilities:
- Device feature coverage scoring (0-100%)
- Essential vs optional feature classification
- Personalized CLI command recommendations
- Missing capability detection with usage impact analysis
- Smart device type classification based on supported endpoints

This resolves the documentation inconsistency where /supportedURLs was marked as
implemented but was actually missing from the client. The new implementation goes
far beyond basic endpoint listing to provide intelligent device capability analysis
and personalized usage recommendations.
This commit is contained in:
Tobias Gesellchen
2026-01-31 20:23:30 +01:00
parent 4ebc42f5d5
commit 83e289ab38
24 changed files with 4936 additions and 50 deletions
+25 -1
View File
@@ -250,6 +250,18 @@ func (c *Client) GetSources() (*models.Sources, error) {
return &sources, nil
}
// GetServiceAvailability retrieves service availability status from the /serviceAvailability endpoint
func (c *Client) GetServiceAvailability() (*models.ServiceAvailability, error) {
var serviceAvailability models.ServiceAvailability
err := c.get("/serviceAvailability", &serviceAvailability)
if err != nil {
return nil, fmt.Errorf("failed to get service availability: %w", err)
}
return &serviceAvailability, nil
}
// GetName retrieves the device name from the /name endpoint
func (c *Client) GetName() (*models.Name, error) {
var name models.Name
@@ -274,6 +286,18 @@ func (c *Client) GetCapabilities() (*models.Capabilities, error) {
return &capabilities, nil
}
// GetSupportedURLs retrieves all supported endpoints from the /supportedURLs endpoint
func (c *Client) GetSupportedURLs() (*models.SupportedURLsResponse, error) {
var supportedURLs models.SupportedURLsResponse
err := c.get("/supportedURLs", &supportedURLs)
if err != nil {
return nil, fmt.Errorf("failed to get supported URLs: %w", err)
}
return &supportedURLs, nil
}
// GetPresets retrieves configured presets from the /presets endpoint
func (c *Client) GetPresets() (*models.Presets, error) {
var presets models.Presets
@@ -974,7 +998,7 @@ func (c *Client) post(endpoint string, payload interface{}) error {
}
// postWithResponse performs a POST request with XML body and parses the response
func (c *Client) postWithResponse(endpoint string, payload interface{}, result interface{}) error {
func (c *Client) postWithResponse(endpoint string, payload, result interface{}) error {
url := c.baseURL + endpoint
var body io.Reader
+30
View File
@@ -284,3 +284,33 @@ func ExampleClient_GetCapabilities() {
// - PRESETS (/presets)
// - ZONE (/getZone)
}
func ExampleClient_GetSupportedURLs_concept() {
// Example of how to use GetSupportedURLs() method
// Note: This example shows the concept but doesn't execute to avoid requiring a real device
config := &client.Config{Host: "192.168.1.100"}
c := client.NewClient(config)
supportedURLs, err := c.GetSupportedURLs()
if err != nil {
log.Fatal(err)
}
fmt.Printf("Device %s supports %d endpoints\n", supportedURLs.DeviceID, supportedURLs.GetURLCount())
fmt.Printf("Core functionality: %v\n", supportedURLs.HasCorePlaybackSupport())
fmt.Printf("Multiroom support: %v\n", supportedURLs.HasMultiroomSupport())
fmt.Printf("Streaming support: %v\n", supportedURLs.HasStreamingSupport())
// Check specific endpoints
if supportedURLs.HasURL("/audiodspcontrols") {
fmt.Println("Device supports advanced audio controls")
}
// Expected output with a real device:
// Device 08DF1F0BA325 supports 103 endpoints
// Core functionality: true
// Multiroom support: true
// Streaming support: true
// Device supports advanced audio controls
}
+3 -3
View File
@@ -170,7 +170,7 @@ func ExampleClient_NavigateContainer() {
len(tracks), len(subdirs))
// Show first few tracks
for i, track := range tracks[:min(3, len(tracks))] {
for i, track := range tracks[:minInt(3, len(tracks))] {
fmt.Printf("%d. %s", i+1, track.GetDisplayName())
if track.ArtistName != "" {
fmt.Printf(" - %s", track.ArtistName)
@@ -201,7 +201,7 @@ func Example_searchAndPlayWorkflow() {
// 2. Show available stations
fmt.Printf("Found %d stations:\n", len(stations))
for i, station := range stations[:min(5, len(stations))] {
for i, station := range stations[:minInt(5, len(stations))] {
fmt.Printf("%d. %s", i+1, station.GetDisplayName())
if station.Description != "" {
fmt.Printf(" - %s", station.Description)
@@ -224,7 +224,7 @@ func Example_searchAndPlayWorkflow() {
}
// Helper function for min calculation
func min(a, b int) int {
func minInt(a, b int) int {
if a < b {
return a
}
+5 -5
View File
@@ -118,7 +118,7 @@ func TestClient_Navigate(t *testing.T) {
w.WriteHeader(tt.serverStatus)
}
if tt.serverResponse != "" {
w.Write([]byte(tt.serverResponse))
_, _ = w.Write([]byte(tt.serverResponse))
}
}))
defer server.Close()
@@ -190,7 +190,7 @@ func TestClient_NavigateWithMenu(t *testing.T) {
t.Errorf("Expected sort 'dateCreated', got %s", request.Sort)
}
w.Write([]byte(serverResponse))
_, _ = w.Write([]byte(serverResponse))
}))
defer server.Close()
@@ -242,7 +242,7 @@ func TestClient_NavigateContainer(t *testing.T) {
</navigateResponse>`
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Write([]byte(serverResponse))
_, _ = w.Write([]byte(serverResponse))
}))
defer server.Close()
@@ -522,7 +522,7 @@ func TestClient_GetPandoraStations(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// Verify it's calling navigate with the right parameters
var request models.NavigateRequest
xml.NewDecoder(r.Body).Decode(&request)
_ = xml.NewDecoder(r.Body).Decode(&request)
if request.Source != "PANDORA" {
t.Errorf("Expected source PANDORA, got %s", request.Source)
@@ -811,7 +811,7 @@ func TestClient_SearchPandoraStations(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// Verify it's calling searchStation with the right parameters
var request models.SearchStationRequest
xml.NewDecoder(r.Body).Decode(&request)
_ = xml.NewDecoder(r.Body).Decode(&request)
if request.Source != "PANDORA" {
t.Errorf("Expected source PANDORA, got %s", request.Source)
@@ -0,0 +1,256 @@
package client
import (
"os"
"testing"
"github.com/gesellix/bose-soundtouch/pkg/models"
)
func TestGetServiceAvailability_Integration(t *testing.T) {
if testing.Short() {
t.Skip("Skipping integration test in short mode")
}
// This test requires a real SoundTouch device
// Set the SOUNDTOUCH_HOST environment variable to run this test
// Example: SOUNDTOUCH_HOST=192.168.1.100 go test -v -run TestGetServiceAvailability_Integration
host := os.Getenv("SOUNDTOUCH_TEST_HOST")
if host == "" {
t.Skip("SOUNDTOUCH_TEST_HOST environment variable not set - skipping integration tests")
}
client := NewClientFromHost(host)
t.Run("get service availability", func(t *testing.T) {
serviceAvailability, err := client.GetServiceAvailability()
if err != nil {
t.Fatalf("Failed to get service availability: %v", err)
}
if serviceAvailability == nil {
t.Fatal("Service availability response is nil")
}
if serviceAvailability.Services == nil {
t.Fatal("Services list is nil")
}
t.Logf("Total services: %d", serviceAvailability.GetServiceCount())
t.Logf("Available services: %d", serviceAvailability.GetAvailableServiceCount())
t.Logf("Unavailable services: %d", serviceAvailability.GetUnavailableServiceCount())
// Log all services and their availability
if serviceAvailability.Services != nil {
for _, service := range serviceAvailability.Services.Service {
status := "available"
if !service.IsAvailable {
status = "unavailable"
if service.Reason != "" {
status += " (" + service.Reason + ")"
}
}
t.Logf("Service %s: %s", service.Type, status)
}
}
// Test convenience methods
t.Logf("Has Spotify: %v", serviceAvailability.HasSpotify())
t.Logf("Has Bluetooth: %v", serviceAvailability.HasBluetooth())
t.Logf("Has AirPlay: %v", serviceAvailability.HasAirPlay())
t.Logf("Has Alexa: %v", serviceAvailability.HasAlexa())
t.Logf("Has TuneIn: %v", serviceAvailability.HasTuneIn())
t.Logf("Has Pandora: %v", serviceAvailability.HasPandora())
t.Logf("Has Local Music: %v", serviceAvailability.HasLocalMusic())
// Test service categorization
streamingServices := serviceAvailability.GetStreamingServices()
t.Logf("Streaming services count: %d", len(streamingServices))
for _, service := range streamingServices {
t.Logf(" - Streaming: %s (%v)", service.Type, service.IsAvailable)
}
localServices := serviceAvailability.GetLocalServices()
t.Logf("Local services count: %d", len(localServices))
for _, service := range localServices {
t.Logf(" - Local: %s (%v)", service.Type, service.IsAvailable)
}
// Validate that we have at least some services
if serviceAvailability.GetServiceCount() == 0 {
t.Error("Expected at least one service in the response")
}
})
t.Run("compare with sources endpoint", func(t *testing.T) {
// Get service availability
serviceAvailability, err := client.GetServiceAvailability()
if err != nil {
t.Fatalf("Failed to get service availability: %v", err)
}
// Get sources for comparison
sources, err := client.GetSources()
if err != nil {
t.Fatalf("Failed to get sources: %v", err)
}
t.Logf("Comparing service availability with sources endpoint...")
// Compare Spotify availability
spotifyAvailable := serviceAvailability.HasSpotify()
spotifyInSources := sources.HasSpotify()
t.Logf("Spotify - ServiceAvailability: %v, Sources: %v", spotifyAvailable, spotifyInSources)
// Compare Bluetooth availability
bluetoothAvailable := serviceAvailability.HasBluetooth()
bluetoothInSources := sources.HasBluetooth()
t.Logf("Bluetooth - ServiceAvailability: %v, Sources: %v", bluetoothAvailable, bluetoothInSources)
// Compare AUX availability (not directly comparable but useful info)
auxInSources := sources.HasAux()
t.Logf("AUX in Sources: %v (no direct equivalent in ServiceAvailability)", auxInSources)
// Note: ServiceAvailability and Sources may not always match perfectly
// ServiceAvailability shows what services are theoretically available
// Sources shows what sources are currently configured and ready
t.Logf("Note: ServiceAvailability shows theoretical availability, Sources shows current configuration")
})
t.Run("validate specific service details", func(t *testing.T) {
serviceAvailability, err := client.GetServiceAvailability()
if err != nil {
t.Fatalf("Failed to get service availability: %v", err)
}
// Test getting specific services
spotifyService := serviceAvailability.GetServiceByType(models.ServiceTypeSpotify)
if spotifyService != nil {
t.Logf("Spotify service details: Available=%v, Reason=%s",
spotifyService.IsAvailable, spotifyService.Reason)
if !spotifyService.IsType(models.ServiceTypeSpotify) {
t.Error("Spotify service type check failed")
}
} else {
t.Log("Spotify service not found in response")
}
bluetoothService := serviceAvailability.GetServiceByType(models.ServiceTypeBluetooth)
if bluetoothService != nil {
t.Logf("Bluetooth service details: Available=%v, Reason=%s",
bluetoothService.IsAvailable, bluetoothService.Reason)
} else {
t.Log("Bluetooth service not found in response")
}
// Check for services that commonly have reasons when unavailable
unavailableServices := serviceAvailability.GetUnavailableServices()
for _, service := range unavailableServices {
if service.Reason != "" {
t.Logf("Service %s is unavailable: %s", service.Type, service.Reason)
} else {
t.Logf("Service %s is unavailable (no reason provided)", service.Type)
}
}
})
}
func TestGetServiceAvailability_UserFeedback(t *testing.T) {
if testing.Short() {
t.Skip("Skipping integration test in short mode")
}
host := os.Getenv("SOUNDTOUCH_TEST_HOST")
if host == "" {
t.Skip("SOUNDTOUCH_TEST_HOST environment variable not set - skipping integration tests")
}
client := NewClientFromHost(host)
t.Run("generate user feedback about supported services", func(t *testing.T) {
serviceAvailability, err := client.GetServiceAvailability()
if err != nil {
t.Fatalf("Failed to get service availability: %v", err)
}
// Example of how this could be used for user feedback
t.Log("\n=== SERVICE AVAILABILITY REPORT ===")
availableServices := serviceAvailability.GetAvailableServices()
if len(availableServices) > 0 {
t.Log("\nAvailable Services:")
for _, service := range availableServices {
t.Logf(" ✅ %s", formatServiceName(service.Type))
}
}
unavailableServices := serviceAvailability.GetUnavailableServices()
if len(unavailableServices) > 0 {
t.Log("\nUnavailable Services:")
for _, service := range unavailableServices {
reason := ""
if service.Reason != "" {
reason = " - " + service.Reason
}
t.Logf(" ❌ %s%s", formatServiceName(service.Type), reason)
}
}
// Streaming services summary
streamingServices := serviceAvailability.GetStreamingServices()
availableStreaming := 0
for _, service := range streamingServices {
if service.IsAvailable {
availableStreaming++
}
}
t.Logf("\nStreaming Services: %d/%d available", availableStreaming, len(streamingServices))
// Local services summary
localServices := serviceAvailability.GetLocalServices()
availableLocal := 0
for _, service := range localServices {
if service.IsAvailable {
availableLocal++
}
}
t.Logf("Local Input Services: %d/%d available", availableLocal, len(localServices))
t.Log("\n=== END REPORT ===")
})
}
// formatServiceName converts service type constants to user-friendly names
func formatServiceName(serviceType string) string {
switch serviceType {
case "SPOTIFY":
return "Spotify"
case "BLUETOOTH":
return "Bluetooth"
case "AIRPLAY":
return "AirPlay"
case "ALEXA":
return "Amazon Alexa"
case "AMAZON":
return "Amazon Music"
case "PANDORA":
return "Pandora"
case "TUNEIN":
return "TuneIn Radio"
case "DEEZER":
return "Deezer"
case "IHEART":
return "iHeartRadio"
case "LOCAL_INTERNET_RADIO":
return "Internet Radio"
case "LOCAL_MUSIC":
return "Local Music Library"
case "BMX":
return "BMX"
case "NOTIFICATION":
return "Notifications"
default:
return serviceType
}
}
+360
View File
@@ -0,0 +1,360 @@
package client
import (
"fmt"
"net/http"
"net/http/httptest"
"testing"
"github.com/gesellix/bose-soundtouch/pkg/models"
)
func TestGetServiceAvailability(t *testing.T) {
tests := []struct {
name string
responseBody string
statusCode int
expectError bool
validate func(t *testing.T, sa *models.ServiceAvailability)
}{
{
name: "successful response with mixed availability",
responseBody: `<?xml version="1.0" encoding="UTF-8" ?>
<serviceAvailability>
<services>
<service type="AIRPLAY" isAvailable="true" />
<service type="ALEXA" isAvailable="false" />
<service type="AMAZON" isAvailable="true" />
<service type="BLUETOOTH" isAvailable="false" reason="INVALID_SOURCE_TYPE" />
<service type="BMX" isAvailable="false" />
<service type="DEEZER" isAvailable="true" />
<service type="IHEART" isAvailable="true" />
<service type="LOCAL_INTERNET_RADIO" isAvailable="true" />
<service type="LOCAL_MUSIC" isAvailable="true" />
<service type="NOTIFICATION" isAvailable="false" />
<service type="PANDORA" isAvailable="true" />
<service type="SPOTIFY" isAvailable="true" />
<service type="TUNEIN" isAvailable="true" />
</services>
</serviceAvailability>`,
statusCode: 200,
expectError: false,
validate: func(t *testing.T, sa *models.ServiceAvailability) {
t.Helper()
if sa == nil {
t.Fatal("service availability should not be nil")
}
if sa.Services == nil {
t.Fatal("services should not be nil")
}
// Check total service count
if sa.GetServiceCount() != 13 {
t.Errorf("expected 13 services, got %d", sa.GetServiceCount())
}
// Check available services count
if sa.GetAvailableServiceCount() != 9 {
t.Errorf("expected 9 available services, got %d", sa.GetAvailableServiceCount())
}
// Check unavailable services count
if sa.GetUnavailableServiceCount() != 4 {
t.Errorf("expected 4 unavailable services, got %d", sa.GetUnavailableServiceCount())
}
// Check specific service availability
if !sa.HasSpotify() {
t.Error("should have Spotify")
}
if !sa.HasAirPlay() {
t.Error("should have AirPlay")
}
if !sa.HasTuneIn() {
t.Error("should have TuneIn")
}
if !sa.HasPandora() {
t.Error("should have Pandora")
}
if !sa.HasLocalMusic() {
t.Error("should have Local Music")
}
if sa.HasAlexa() {
t.Error("should not have Alexa")
}
if sa.HasBluetooth() {
t.Error("should not have Bluetooth")
}
// Check service with reason
bluetoothService := sa.GetServiceByType(models.ServiceTypeBluetooth)
if bluetoothService == nil {
t.Fatal("bluetooth service should not be nil")
}
if bluetoothService.IsAvailable {
t.Error("bluetooth service should not be available")
}
if bluetoothService.GetReason() != "INVALID_SOURCE_TYPE" {
t.Errorf("expected bluetooth reason 'INVALID_SOURCE_TYPE', got '%s'", bluetoothService.GetReason())
}
// Check streaming services
streamingServices := sa.GetStreamingServices()
if len(streamingServices) != 7 {
t.Errorf("expected 7 streaming services, got %d", len(streamingServices))
}
// Check local services
localServices := sa.GetLocalServices()
if len(localServices) != 3 {
t.Errorf("expected 3 local services, got %d", len(localServices))
}
},
},
{
name: "successful response with all services available",
responseBody: `<?xml version="1.0" encoding="UTF-8" ?>
<serviceAvailability>
<services>
<service type="SPOTIFY" isAvailable="true" />
<service type="BLUETOOTH" isAvailable="true" />
<service type="AIRPLAY" isAvailable="true" />
</services>
</serviceAvailability>`,
statusCode: 200,
expectError: false,
validate: func(t *testing.T, sa *models.ServiceAvailability) {
t.Helper()
if sa == nil {
t.Fatal("service availability should not be nil")
}
if sa.Services == nil {
t.Fatal("services should not be nil")
}
if sa.GetServiceCount() != 3 {
t.Errorf("expected 3 services, got %d", sa.GetServiceCount())
}
if sa.GetAvailableServiceCount() != 3 {
t.Errorf("expected 3 available services, got %d", sa.GetAvailableServiceCount())
}
if sa.GetUnavailableServiceCount() != 0 {
t.Errorf("expected 0 unavailable services, got %d", sa.GetUnavailableServiceCount())
}
if !sa.HasSpotify() {
t.Error("should have Spotify")
}
if !sa.HasBluetooth() {
t.Error("should have Bluetooth")
}
if !sa.HasAirPlay() {
t.Error("should have AirPlay")
}
},
},
{
name: "successful response with no services",
responseBody: `<?xml version="1.0" encoding="UTF-8" ?>
<serviceAvailability>
<services>
</services>
</serviceAvailability>`,
statusCode: 200,
expectError: false,
validate: func(t *testing.T, sa *models.ServiceAvailability) {
t.Helper()
if sa == nil {
t.Fatal("service availability should not be nil")
}
if sa.Services == nil {
t.Fatal("services should not be nil")
}
if sa.GetServiceCount() != 0 {
t.Errorf("expected 0 services, got %d", sa.GetServiceCount())
}
if sa.GetAvailableServiceCount() != 0 {
t.Errorf("expected 0 available services, got %d", sa.GetAvailableServiceCount())
}
if sa.GetUnavailableServiceCount() != 0 {
t.Errorf("expected 0 unavailable services, got %d", sa.GetUnavailableServiceCount())
}
if sa.HasSpotify() {
t.Error("should not have Spotify")
}
if sa.HasBluetooth() {
t.Error("should not have Bluetooth")
}
},
},
{
name: "server error",
responseBody: "Internal Server Error",
statusCode: 500,
expectError: true,
validate: func(_ *testing.T, _ *models.ServiceAvailability) {},
},
{
name: "invalid XML",
responseBody: "not valid xml",
statusCode: 200,
expectError: true,
validate: func(_ *testing.T, _ *models.ServiceAvailability) {},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
// Create test server
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/serviceAvailability" {
t.Errorf("expected path /serviceAvailability, got %s", r.URL.Path)
}
if r.Method != "GET" {
t.Errorf("expected GET method, got %s", r.Method)
}
w.WriteHeader(tt.statusCode)
_, _ = fmt.Fprint(w, tt.responseBody)
}))
defer server.Close()
// Create client
client := createTestClient(server.URL)
// Execute test
result, err := client.GetServiceAvailability()
// Validate error expectation
if tt.expectError {
if err == nil {
t.Error("expected an error but got none")
}
if result != nil {
t.Error("expected nil result on error")
}
} else {
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
tt.validate(t, result)
}
})
}
}
func TestGetServiceAvailability_NetworkError(t *testing.T) {
// Create client with invalid host
client := createTestClient("http://invalid-host:99999")
result, err := client.GetServiceAvailability()
if err == nil {
t.Error("expected an error but got none")
}
if result != nil {
t.Error("expected nil result on error")
}
if err != nil && !contains(err.Error(), "failed to get service availability") {
t.Errorf("error message should contain 'failed to get service availability', got: %v", err)
}
}
func TestServiceAvailabilityModel_EdgeCases(t *testing.T) {
t.Run("nil services", func(t *testing.T) {
sa := &models.ServiceAvailability{}
if sa.GetServiceCount() != 0 {
t.Errorf("expected 0 service count, got %d", sa.GetServiceCount())
}
if sa.GetAvailableServiceCount() != 0 {
t.Errorf("expected 0 available count, got %d", sa.GetAvailableServiceCount())
}
if sa.GetUnavailableServiceCount() != 0 {
t.Errorf("expected 0 unavailable count, got %d", sa.GetUnavailableServiceCount())
}
if sa.HasSpotify() {
t.Error("should not have Spotify")
}
if sa.GetServiceByType(models.ServiceTypeSpotify) != nil {
t.Error("service should be nil")
}
if len(sa.GetAvailableServices()) != 0 {
t.Error("available services should be empty")
}
if len(sa.GetUnavailableServices()) != 0 {
t.Error("unavailable services should be empty")
}
if len(sa.GetStreamingServices()) != 0 {
t.Error("streaming services should be empty")
}
if len(sa.GetLocalServices()) != 0 {
t.Error("local services should be empty")
}
})
t.Run("service type checking", func(t *testing.T) {
service := models.Service{
Type: "SPOTIFY",
IsAvailable: true,
}
if !service.IsType(models.ServiceTypeSpotify) {
t.Error("service should be of type Spotify")
}
if service.IsType(models.ServiceTypeBluetooth) {
t.Error("service should not be of type Bluetooth")
}
})
t.Run("service reason handling", func(t *testing.T) {
serviceWithReason := models.Service{
Type: "BLUETOOTH",
IsAvailable: false,
Reason: "DEVICE_NOT_CONNECTED",
}
serviceWithoutReason := models.Service{
Type: "SPOTIFY",
IsAvailable: true,
}
if serviceWithReason.GetReason() != "DEVICE_NOT_CONNECTED" {
t.Errorf("expected DEVICE_NOT_CONNECTED, got %s", serviceWithReason.GetReason())
}
if serviceWithoutReason.GetReason() != "" {
t.Errorf("expected empty reason, got %s", serviceWithoutReason.GetReason())
}
})
}
func BenchmarkGetServiceAvailability(b *testing.B) {
responseBody := `<?xml version="1.0" encoding="UTF-8" ?>
<serviceAvailability>
<services>
<service type="SPOTIFY" isAvailable="true" />
<service type="BLUETOOTH" isAvailable="false" reason="UNAVAILABLE" />
<service type="AIRPLAY" isAvailable="true" />
<service type="PANDORA" isAvailable="true" />
<service type="TUNEIN" isAvailable="true" />
</services>
</serviceAvailability>`
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
_, _ = fmt.Fprint(w, responseBody)
}))
defer server.Close()
client := createTestClient(server.URL)
b.ResetTimer()
for i := 0; i < b.N; i++ {
_, err := client.GetServiceAvailability()
if err != nil {
b.Fatal(err)
}
}
}
+651
View File
@@ -0,0 +1,651 @@
package client
import (
"net/http"
"net/http/httptest"
"net/url"
"strconv"
"testing"
"github.com/gesellix/bose-soundtouch/pkg/models"
)
func TestClient_GetSupportedURLs(t *testing.T) {
tests := []struct {
name string
responseXML string
expectedError bool
expectedDeviceID string
expectedURLCount int
expectedURLs []string
}{
{
name: "successful_supported_urls_retrieval",
responseXML: `<?xml version="1.0" encoding="UTF-8"?>
<supportedURLs deviceID="08DF1F0BA325">
<URL location="/info" />
<URL location="/capabilities" />
<URL location="/supportedURLs" />
<URL location="/volume" />
<URL location="/bass" />
<URL location="/balance" />
<URL location="/presets" />
<URL location="/nowPlaying" />
<URL location="/key" />
<URL location="/sources" />
<URL location="/serviceAvailability" />
<URL location="/navigate" />
<URL location="/search" />
<URL location="/addStation" />
<URL location="/removeStation" />
<URL location="/clock" />
<URL location="/name" />
<URL location="/networkInfo" />
<URL location="/setZone" />
<URL location="/addZoneSlave" />
<URL location="/removeZoneSlave" />
<URL location="/audiodspcontrols" />
<URL location="/audioproducttonecontrols" />
<URL location="/audioproductlevelcontrols" />
<URL location="/bassCapabilities" />
</supportedURLs>`,
expectedError: false,
expectedDeviceID: "08DF1F0BA325",
expectedURLCount: 25,
expectedURLs: []string{
"/info", "/capabilities", "/supportedURLs", "/volume", "/bass",
"/balance", "/presets", "/nowPlaying", "/key", "/sources",
"/serviceAvailability", "/navigate", "/search", "/addStation",
"/removeStation", "/clock", "/name", "/networkInfo", "/setZone",
"/addZoneSlave", "/removeZoneSlave", "/audiodspcontrols",
"/audioproducttonecontrols", "/audioproductlevelcontrols", "/bassCapabilities",
},
},
{
name: "minimal_device_supported_urls",
responseXML: `<?xml version="1.0" encoding="UTF-8"?>
<supportedURLs deviceID="12345">
<URL location="/info" />
<URL location="/capabilities" />
<URL location="/volume" />
<URL location="/nowPlaying" />
<URL location="/key" />
</supportedURLs>`,
expectedError: false,
expectedDeviceID: "12345",
expectedURLCount: 5,
expectedURLs: []string{"/info", "/capabilities", "/volume", "/nowPlaying", "/key"},
},
{
name: "empty_supported_urls",
responseXML: `<?xml version="1.0" encoding="UTF-8"?>
<supportedURLs deviceID="EMPTY123">
</supportedURLs>`,
expectedError: false,
expectedDeviceID: "EMPTY123",
expectedURLCount: 0,
expectedURLs: []string{},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
// Create mock server
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// Verify request
if r.URL.Path != "/supportedURLs" {
t.Errorf("Expected path '/supportedURLs', got '%s'", r.URL.Path)
}
if r.Method != "GET" {
t.Errorf("Expected GET method, got '%s'", r.Method)
}
w.Header().Set("Content-Type", "application/xml")
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte(tt.responseXML))
}))
defer server.Close()
// Parse server URL
serverURL, _ := url.Parse(server.URL)
host := serverURL.Hostname()
port, _ := strconv.Atoi(serverURL.Port())
// Create client
client := NewClient(&Config{
Host: host,
Port: port,
})
// Test GetSupportedURLs
supportedURLs, err := client.GetSupportedURLs()
// Check error expectation
if tt.expectedError && err == nil {
t.Errorf("Expected error, but got none")
}
if !tt.expectedError && err != nil {
t.Errorf("Unexpected error: %v", err)
}
if !tt.expectedError {
// Verify device ID
if supportedURLs.DeviceID != tt.expectedDeviceID {
t.Errorf("Expected device ID '%s', got '%s'", tt.expectedDeviceID, supportedURLs.DeviceID)
}
// Verify URL count
if supportedURLs.GetURLCount() != tt.expectedURLCount {
t.Errorf("Expected %d URLs, got %d", tt.expectedURLCount, supportedURLs.GetURLCount())
}
// Verify specific URLs
urls := supportedURLs.GetURLs()
if len(urls) != len(tt.expectedURLs) {
t.Errorf("Expected %d URLs in list, got %d", len(tt.expectedURLs), len(urls))
}
// Check each expected URL exists
for _, expectedURL := range tt.expectedURLs {
if !supportedURLs.HasURL(expectedURL) {
t.Errorf("Expected URL '%s' not found in supported URLs", expectedURL)
}
}
}
})
}
}
func TestClient_GetSupportedURLs_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,
})
// Test GetSupportedURLs with server error
supportedURLs, err := client.GetSupportedURLs()
// Should return error
if err == nil {
t.Error("Expected error for server error response, but got none")
}
if supportedURLs != nil {
t.Error("Expected nil supportedURLs on error, but got result")
}
}
func TestClient_GetSupportedURLs_NotFound(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusNotFound)
_, _ = w.Write([]byte("Not Found"))
}))
defer server.Close()
serverURL, _ := url.Parse(server.URL)
host := serverURL.Hostname()
port, _ := strconv.Atoi(serverURL.Port())
client := NewClient(&Config{
Host: host,
Port: port,
})
// Test GetSupportedURLs with 404 response
supportedURLs, err := client.GetSupportedURLs()
// Should return error
if err == nil {
t.Error("Expected error for 404 response, but got none")
}
if supportedURLs != nil {
t.Error("Expected nil supportedURLs on error, but got result")
}
}
func TestClient_GetSupportedURLs_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 content"))
}))
defer server.Close()
serverURL, _ := url.Parse(server.URL)
host := serverURL.Hostname()
port, _ := strconv.Atoi(serverURL.Port())
client := NewClient(&Config{
Host: host,
Port: port,
})
// Test GetSupportedURLs with invalid XML
supportedURLs, err := client.GetSupportedURLs()
// Should return error
if err == nil {
t.Error("Expected error for invalid XML, but got none")
}
if supportedURLs != nil {
t.Error("Expected nil supportedURLs on error, but got result")
}
}
func TestSupportedURLsResponse_Methods(t *testing.T) {
// Create test data
supportedURLs := &models.SupportedURLsResponse{
DeviceID: "TEST123",
URLs: []models.URL{
{Location: "/info"},
{Location: "/capabilities"},
{Location: "/volume"},
{Location: "/bass"},
{Location: "/balance"},
{Location: "/presets"},
{Location: "/nowPlaying"},
{Location: "/key"},
{Location: "/sources"},
{Location: "/navigate"},
{Location: "/search"},
{Location: "/audiodspcontrols"},
{Location: "/setZone"},
{Location: "/networkInfo"},
},
}
t.Run("GetURLs", func(t *testing.T) {
urls := supportedURLs.GetURLs()
if len(urls) != 14 {
t.Errorf("Expected 14 URLs, got %d", len(urls))
}
if urls[0] != "/info" {
t.Errorf("Expected first URL to be '/info', got '%s'", urls[0])
}
})
t.Run("HasURL", func(t *testing.T) {
if !supportedURLs.HasURL("/info") {
t.Error("Expected '/info' to be found")
}
if !supportedURLs.HasURL("/capabilities") {
t.Error("Expected '/capabilities' to be found")
}
if supportedURLs.HasURL("/nonexistent") {
t.Error("Expected '/nonexistent' not to be found")
}
})
t.Run("GetURLCount", func(t *testing.T) {
count := supportedURLs.GetURLCount()
if count != 14 {
t.Errorf("Expected URL count to be 14, got %d", count)
}
})
t.Run("GetCoreURLs", func(t *testing.T) {
coreURLs := supportedURLs.GetCoreURLs()
expectedCore := []string{"/info", "/capabilities", "/sources", "/volume", "/bass", "/balance", "/presets", "/nowPlaying", "/key"}
if len(coreURLs) != len(expectedCore) {
t.Errorf("Expected %d core URLs, got %d", len(expectedCore), len(coreURLs))
}
for _, url := range expectedCore {
found := false
for _, core := range coreURLs {
if core == url {
found = true
break
}
}
if !found {
t.Errorf("Expected core URL '%s' not found", url)
}
}
})
t.Run("GetStreamingURLs", func(t *testing.T) {
streamingURLs := supportedURLs.GetStreamingURLs()
expectedStreaming := []string{"/navigate", "/search", "/sources"}
if len(streamingURLs) != len(expectedStreaming) {
t.Errorf("Expected %d streaming URLs, got %d", len(expectedStreaming), len(streamingURLs))
}
})
t.Run("GetAdvancedURLs", func(t *testing.T) {
advancedURLs := supportedURLs.GetAdvancedURLs()
expectedAdvanced := []string{"/audiodspcontrols", "/setZone"}
if len(advancedURLs) != len(expectedAdvanced) {
t.Errorf("Expected %d advanced URLs, got %d", len(expectedAdvanced), len(advancedURLs))
}
})
t.Run("GetNetworkURLs", func(t *testing.T) {
networkURLs := supportedURLs.GetNetworkURLs()
expectedNetwork := []string{"/networkInfo"}
if len(networkURLs) != len(expectedNetwork) {
t.Errorf("Expected %d network URLs, got %d", len(expectedNetwork), len(networkURLs))
}
})
t.Run("HasCorePlaybackSupport", func(t *testing.T) {
if !supportedURLs.HasCorePlaybackSupport() {
t.Error("Expected device to have core playback support")
}
})
t.Run("HasPresetSupport", func(t *testing.T) {
if !supportedURLs.HasPresetSupport() {
t.Error("Expected device to have preset support")
}
})
t.Run("HasMultiroomSupport", func(t *testing.T) {
if !supportedURLs.HasMultiroomSupport() {
t.Error("Expected device to have multiroom support")
}
})
t.Run("HasAdvancedAudioSupport", func(t *testing.T) {
if !supportedURLs.HasAdvancedAudioSupport() {
t.Error("Expected device to have advanced audio support")
}
})
t.Run("HasStreamingSupport", func(t *testing.T) {
if !supportedURLs.HasStreamingSupport() {
t.Error("Expected device to have streaming support")
}
})
t.Run("GetUnsupportedURLs", func(t *testing.T) {
checkList := []string{"/info", "/nonexistent1", "/capabilities", "/nonexistent2"}
unsupported := supportedURLs.GetUnsupportedURLs(checkList)
expectedUnsupported := []string{"/nonexistent1", "/nonexistent2"}
if len(unsupported) != len(expectedUnsupported) {
t.Errorf("Expected %d unsupported URLs, got %d", len(expectedUnsupported), len(unsupported))
}
for _, url := range expectedUnsupported {
found := false
for _, unsup := range unsupported {
if unsup == url {
found = true
break
}
}
if !found {
t.Errorf("Expected unsupported URL '%s' not found", url)
}
}
})
}
func TestSupportedURLsResponse_EmptyURLs(t *testing.T) {
// Test with empty URL list
supportedURLs := &models.SupportedURLsResponse{
DeviceID: "EMPTY",
URLs: []models.URL{},
}
t.Run("empty_urls_basic_checks", func(t *testing.T) {
if supportedURLs.GetURLCount() != 0 {
t.Errorf("Expected 0 URLs, got %d", supportedURLs.GetURLCount())
}
if supportedURLs.HasURL("/info") {
t.Error("Expected '/info' not to be found in empty list")
}
if supportedURLs.HasCorePlaybackSupport() {
t.Error("Expected no core playback support with empty URLs")
}
if supportedURLs.HasPresetSupport() {
t.Error("Expected no preset support with empty URLs")
}
})
}
func TestSupportedURLsResponse_FeatureMapping(t *testing.T) {
// Create test data with comprehensive feature set
supportedURLs := &models.SupportedURLsResponse{
DeviceID: "FEATURE_TEST",
URLs: []models.URL{
// Core features
{Location: "/info"},
{Location: "/capabilities"},
{Location: "/name"},
{Location: "/supportedURLs"},
// Audio features
{Location: "/volume"},
{Location: "/bass"},
{Location: "/bassCapabilities"},
{Location: "/balance"},
{Location: "/audiodspcontrols"},
// Playback features
{Location: "/nowPlaying"},
{Location: "/key"},
{Location: "/trackInfo"},
// Source features
{Location: "/sources"},
{Location: "/select"},
{Location: "/serviceAvailability"},
// Content features
{Location: "/navigate"},
{Location: "/search"},
{Location: "/addStation"},
{Location: "/removeStation"},
// Preset features
{Location: "/presets"},
// Multiroom features
{Location: "/setZone"},
{Location: "/getZone"},
{Location: "/addZoneSlave"},
// Network features
{Location: "/networkInfo"},
{Location: "/bluetoothInfo"},
// System features
{Location: "/clock"},
{Location: "/powerManagement"},
},
}
t.Run("GetSupportedFeatures", func(t *testing.T) {
features := supportedURLs.GetSupportedFeatures()
if len(features) == 0 {
t.Error("Expected supported features, got none")
}
// Check for some expected features
featureNames := make(map[string]bool)
for _, feature := range features {
featureNames[feature.Name] = true
}
expectedFeatures := []string{
"Device Information",
"Volume Control",
"Bass Control",
"Playback Control",
"Audio Sources",
"Content Navigation",
"Station Management",
"Preset Management",
"Multiroom Zones",
}
for _, expected := range expectedFeatures {
if !featureNames[expected] {
t.Errorf("Expected feature '%s' not found in supported features", expected)
}
}
})
t.Run("GetUnsupportedFeatures", func(t *testing.T) {
unsupported := supportedURLs.GetUnsupportedFeatures()
// With our comprehensive test data, there should be few unsupported features
if len(unsupported) > 5 {
t.Errorf("Expected few unsupported features, got %d", len(unsupported))
}
})
t.Run("GetFeaturesByCategory", func(t *testing.T) {
featuresByCategory := supportedURLs.GetFeaturesByCategory()
expectedCategories := []string{"Core", "Audio", "Playback", "Sources", "Content", "Presets", "Multiroom", "Network", "System"}
for _, category := range expectedCategories {
if features, exists := featuresByCategory[category]; !exists || len(features) == 0 {
t.Errorf("Expected category '%s' to have features", category)
}
}
})
t.Run("GetFeatureCompleteness", func(t *testing.T) {
completeness, supported, total := supportedURLs.GetFeatureCompleteness()
if completeness < 0 || completeness > 100 {
t.Errorf("Completeness should be 0-100, got %d", completeness)
}
if supported <= 0 {
t.Errorf("Expected some supported features, got %d", supported)
}
if total <= 0 {
t.Errorf("Expected some total features, got %d", total)
}
if supported > total {
t.Errorf("Supported features (%d) cannot exceed total (%d)", supported, total)
}
// With our comprehensive test data, should have high completeness
if completeness < 70 {
t.Errorf("Expected high completeness with comprehensive data, got %d%%", completeness)
}
})
t.Run("GetMissingEssentialFeatures", func(t *testing.T) {
missing := supportedURLs.GetMissingEssentialFeatures()
// With our comprehensive test data, should have no missing essential features
if len(missing) > 0 {
t.Errorf("Expected no missing essential features with comprehensive data, got %d", len(missing))
for _, feature := range missing {
t.Errorf("Missing essential feature: %s", feature.Name)
}
}
})
t.Run("GetPartiallyImplementedFeatures", func(t *testing.T) {
partial := supportedURLs.GetPartiallyImplementedFeatures()
// The result depends on our test data - some features might be partial
// This mainly tests that the function doesn't crash
for _, feature := range partial {
if len(feature.Endpoints) <= 1 {
t.Errorf("Partial feature '%s' should have multiple endpoints, got %d", feature.Name, len(feature.Endpoints))
}
}
})
}
func TestSupportedURLsResponse_FeatureMappingLimitedDevice(t *testing.T) {
// Create test data for a limited device
limitedURLs := &models.SupportedURLsResponse{
DeviceID: "LIMITED_TEST",
URLs: []models.URL{
{Location: "/info"},
{Location: "/volume"},
{Location: "/nowPlaying"},
{Location: "/key"},
},
}
t.Run("LimitedDevice_GetMissingEssentialFeatures", func(t *testing.T) {
missing := limitedURLs.GetMissingEssentialFeatures()
// Should have some missing essential features
if len(missing) == 0 {
t.Error("Expected some missing essential features for limited device")
}
})
t.Run("LimitedDevice_GetFeatureCompleteness", func(t *testing.T) {
completeness, supported, total := limitedURLs.GetFeatureCompleteness()
// Should have lower completeness
if completeness > 50 {
t.Errorf("Expected low completeness for limited device, got %d%%", completeness)
}
if supported == total {
t.Error("Limited device should not support all features")
}
})
}
func TestEndpointFeatureMap(t *testing.T) {
features := models.GetEndpointFeatureMap()
t.Run("FeatureMapStructure", func(t *testing.T) {
if len(features) == 0 {
t.Error("Expected feature map to contain features")
}
for _, feature := range features {
if feature.Name == "" {
t.Error("Feature should have a name")
}
if feature.Description == "" {
t.Error("Feature should have a description")
}
if len(feature.Endpoints) == 0 {
t.Errorf("Feature '%s' should have at least one endpoint", feature.Name)
}
if feature.Category == "" {
t.Errorf("Feature '%s' should have a category", feature.Name)
}
if feature.CLICommand == "" {
t.Errorf("Feature '%s' should have CLI command info", feature.Name)
}
}
})
t.Run("FeatureCategories", func(t *testing.T) {
categories := make(map[string]bool)
for _, feature := range features {
categories[feature.Category] = true
}
expectedCategories := []string{"Core", "Audio", "Playback", "Sources", "Content", "Presets", "Multiroom", "Network", "System"}
for _, expected := range expectedCategories {
if !categories[expected] {
t.Errorf("Expected category '%s' not found in feature map", expected)
}
}
})
t.Run("EssentialFeatures", func(t *testing.T) {
essentialCount := 0
for _, feature := range features {
if feature.Essential {
essentialCount++
}
}
if essentialCount == 0 {
t.Error("Expected some features to be marked as essential")
}
// Should have a reasonable number of essential features
if essentialCount > len(features)/2 {
t.Errorf("Too many features marked as essential: %d/%d", essentialCount, len(features))
}
})
}
+18
View File
@@ -0,0 +1,18 @@
<?xml version="1.0" encoding="UTF-8" ?>
<serviceAvailability>
<services>
<service type="AIRPLAY" isAvailable="true" />
<service type="ALEXA" isAvailable="false" />
<service type="AMAZON" isAvailable="true" />
<service type="BLUETOOTH" isAvailable="false" reason="INVALID_SOURCE_TYPE" />
<service type="BMX" isAvailable="false" />
<service type="DEEZER" isAvailable="true" />
<service type="IHEART" isAvailable="true" />
<service type="LOCAL_INTERNET_RADIO" isAvailable="true" />
<service type="LOCAL_MUSIC" isAvailable="true" />
<service type="NOTIFICATION" isAvailable="false" />
<service type="PANDORA" isAvailable="true" />
<service type="SPOTIFY" isAvailable="true" />
<service type="TUNEIN" isAvailable="true" />
</services>
</serviceAvailability>