feat: implement source selection (POST /select)

- Add complete source selection functionality via POST /select endpoint
- Implement SelectSource() with all source types (SPOTIFY, BLUETOOTH, AUX, etc.)
- Add convenience methods: SelectSpotify(), SelectBluetooth(), SelectAux(), SelectTuneIn(), SelectPandora()
- Add SelectSourceFromItem() for working with SourceItem objects
- Add CLI flags: -select-source, -source-account, -spotify, -bluetooth, -aux
- Create comprehensive test suite (30+ test cases) with mock servers
- Add integration tests with real device validation (SoundTouch 10/20)
- Update documentation with complete SOURCE-SELECTION.md guide
- Update API endpoints status (POST /select:  Implemented)
- Update project status (50% overall completion, 60% control endpoints)
- Real device testing with Spotify and TuneIn source selection
- Error handling for invalid sources and API responses
- XML request format validation and compliance
This commit is contained in:
Tobias Gesellchen
2026-01-09 09:09:42 +01:00
parent e46f050e45
commit 65a46fd958
7 changed files with 1555 additions and 48 deletions
+71
View File
@@ -296,6 +296,77 @@ func (c *Client) DecreaseVolume(amount int) (*models.Volume, error) {
return c.GetVolume()
}
// SelectSource selects an audio source using the /select endpoint
func (c *Client) SelectSource(source string, sourceAccount string) error {
// Validate source parameter
if source == "" {
return fmt.Errorf("source cannot be empty")
}
// Create ContentItem for source selection
contentItem := &models.ContentItem{
Source: source,
SourceAccount: sourceAccount,
ItemName: source, // Use source as default item name
}
// For certain sources, we might want to customize the item name
switch source {
case "SPOTIFY":
contentItem.ItemName = "Spotify"
case "BLUETOOTH":
contentItem.ItemName = "Bluetooth"
case "AUX":
contentItem.ItemName = "AUX Input"
case "TUNEIN":
contentItem.ItemName = "TuneIn"
case "PANDORA":
contentItem.ItemName = "Pandora"
case "AMAZON":
contentItem.ItemName = "Amazon Music"
case "IHEARTRADIO":
contentItem.ItemName = "iHeartRadio"
case "STORED_MUSIC":
contentItem.ItemName = "Stored Music"
}
return c.post("/select", contentItem, nil)
}
// SelectSourceFromItem selects an audio source using a SourceItem
func (c *Client) SelectSourceFromItem(sourceItem *models.SourceItem) error {
if sourceItem == nil {
return fmt.Errorf("sourceItem cannot be nil")
}
return c.SelectSource(sourceItem.Source, sourceItem.SourceAccount)
}
// SelectSpotify is a convenience method to select Spotify source
func (c *Client) SelectSpotify(sourceAccount string) error {
return c.SelectSource("SPOTIFY", sourceAccount)
}
// SelectBluetooth is a convenience method to select Bluetooth source
func (c *Client) SelectBluetooth() error {
return c.SelectSource("BLUETOOTH", "")
}
// SelectAux is a convenience method to select AUX input
func (c *Client) SelectAux() error {
return c.SelectSource("AUX", "")
}
// SelectTuneIn is a convenience method to select TuneIn source
func (c *Client) SelectTuneIn(sourceAccount string) error {
return c.SelectSource("TUNEIN", sourceAccount)
}
// SelectPandora is a convenience method to select Pandora source
func (c *Client) SelectPandora(sourceAccount string) error {
return c.SelectSource("PANDORA", sourceAccount)
}
// Ping checks if the device is reachable by calling /info
func (c *Client) Ping() error {
_, err := c.GetDeviceInfo()
@@ -0,0 +1,422 @@
package client
import (
"os"
"testing"
"time"
)
// Integration tests for source selection functionality
// These tests require a real SoundTouch device for validation
// Set SOUNDTOUCH_TEST_HOST environment variable to run these tests
func TestClient_SelectSource_Integration(t *testing.T) {
if testing.Short() {
t.Skip("Skipping integration tests in short mode")
}
host := os.Getenv("SOUNDTOUCH_TEST_HOST")
if host == "" {
t.Skip("SOUNDTOUCH_TEST_HOST environment variable not set - skipping integration tests")
}
// Parse host:port if provided
finalHost, finalPort := parseHostPort(host, 8090)
config := ClientConfig{
Host: finalHost,
Port: finalPort,
Timeout: 15 * time.Second,
UserAgent: "Bose-SoundTouch-Go-Integration-Test/1.0",
}
client := NewClient(config)
// First, get available sources to know what we can test
sources, err := client.GetSources()
if err != nil {
t.Fatalf("Failed to get sources: %v", err)
}
t.Logf("Found %d total sources, %d ready", sources.GetSourceCount(), sources.GetReadySourceCount())
// Test source selection based on what's available
tests := []struct {
name string
method func() error
checkSource func() bool
description string
skipIfMissing bool
}{
{
name: "Select Spotify",
method: func() error {
spotifySources := sources.GetReadySpotifySources()
if len(spotifySources) == 0 {
return nil // Skip if no Spotify available
}
// Use first available Spotify account
return client.SelectSpotify(spotifySources[0].SourceAccount)
},
checkSource: func() bool {
return sources.HasSpotify()
},
description: "Spotify source selection",
skipIfMissing: true,
},
{
name: "Select TuneIn",
method: func() error {
tuneInSources := sources.GetSourcesByType("TUNEIN")
for _, src := range tuneInSources {
if src.Status.IsReady() {
return client.SelectTuneIn(src.SourceAccount)
}
}
return nil // Skip if no TuneIn available
},
checkSource: func() bool {
return sources.HasSource("TUNEIN")
},
description: "TuneIn source selection",
skipIfMissing: true,
},
{
name: "Select via generic method",
method: func() error {
// Find any ready streaming source
for _, source := range sources.GetAvailableSources() {
if source.IsStreamingService() {
return client.SelectSource(source.Source, source.SourceAccount)
}
}
return nil // Skip if no streaming sources available
},
checkSource: func() bool {
streaming := sources.GetStreamingSources()
for _, src := range streaming {
if src.Status.IsReady() {
return true
}
}
return false
},
description: "Generic source selection",
skipIfMissing: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if tt.skipIfMissing && !tt.checkSource() {
t.Skipf("Skipping %s - source not available on test device", tt.description)
}
t.Logf("Testing %s on %s:%d", tt.description, finalHost, finalPort)
err := tt.method()
if err != nil {
t.Errorf("Failed to execute %s: %v", tt.description, err)
return
}
t.Logf("✓ %s completed successfully", tt.description)
// Give the device a moment to process the change
time.Sleep(500 * time.Millisecond)
})
}
}
func TestClient_SelectSourceFromItem_Integration(t *testing.T) {
if testing.Short() {
t.Skip("Skipping integration tests in short mode")
}
host := os.Getenv("SOUNDTOUCH_TEST_HOST")
if host == "" {
t.Skip("SOUNDTOUCH_TEST_HOST environment variable not set - skipping integration tests")
}
// Parse host:port if provided
finalHost, finalPort := parseHostPort(host, 8090)
config := ClientConfig{
Host: finalHost,
Port: finalPort,
Timeout: 15 * time.Second,
UserAgent: "Bose-SoundTouch-Go-Integration-Test/1.0",
}
client := NewClient(config)
// Get available sources
sources, err := client.GetSources()
if err != nil {
t.Fatalf("Failed to get sources: %v", err)
}
// Test selecting from available source items
availableSources := sources.GetAvailableSources()
if len(availableSources) == 0 {
t.Skip("No available sources to test with")
}
// Test with the first available source
testSource := availableSources[0]
t.Logf("Testing SelectSourceFromItem with source: %s (account: %s)",
testSource.Source, testSource.SourceAccount)
err = client.SelectSourceFromItem(&testSource)
if err != nil {
t.Errorf("Failed to select source from item: %v", err)
return
}
t.Logf("✓ SelectSourceFromItem completed successfully")
}
func TestClient_SelectSource_ErrorHandling_Integration(t *testing.T) {
if testing.Short() {
t.Skip("Skipping integration tests in short mode")
}
host := os.Getenv("SOUNDTOUCH_TEST_HOST")
if host == "" {
t.Skip("SOUNDTOUCH_TEST_HOST environment variable not set - skipping integration tests")
}
// Parse host:port if provided
finalHost, finalPort := parseHostPort(host, 8090)
config := ClientConfig{
Host: finalHost,
Port: finalPort,
Timeout: 15 * time.Second,
UserAgent: "Bose-SoundTouch-Go-Integration-Test/1.0",
}
client := NewClient(config)
// Test with invalid source
t.Run("Invalid source", func(t *testing.T) {
err := client.SelectSource("INVALID_SOURCE", "")
if err == nil {
t.Error("Expected error for invalid source, got nil")
} else {
t.Logf("✓ Got expected error for invalid source: %v", err)
}
})
// Test with empty source (should fail validation)
t.Run("Empty source", func(t *testing.T) {
err := client.SelectSource("", "")
if err == nil {
t.Error("Expected error for empty source, got nil")
} else if err.Error() != "source cannot be empty" {
t.Errorf("Expected 'source cannot be empty' error, got: %v", err)
} else {
t.Logf("✓ Got expected validation error: %v", err)
}
})
// Test with nil source item
t.Run("Nil source item", func(t *testing.T) {
err := client.SelectSourceFromItem(nil)
if err == nil {
t.Error("Expected error for nil source item, got nil")
} else if err.Error() != "sourceItem cannot be nil" {
t.Errorf("Expected 'sourceItem cannot be nil' error, got: %v", err)
} else {
t.Logf("✓ Got expected validation error: %v", err)
}
})
}
func TestClient_ConvenienceSourceMethods_Integration(t *testing.T) {
if testing.Short() {
t.Skip("Skipping integration tests in short mode")
}
host := os.Getenv("SOUNDTOUCH_TEST_HOST")
if host == "" {
t.Skip("SOUNDTOUCH_TEST_HOST environment variable not set - skipping integration tests")
}
// Parse host:port if provided
finalHost, finalPort := parseHostPort(host, 8090)
config := ClientConfig{
Host: finalHost,
Port: finalPort,
Timeout: 15 * time.Second,
UserAgent: "Bose-SoundTouch-Go-Integration-Test/1.0",
}
client := NewClient(config)
// Get available sources to determine what we can test
sources, err := client.GetSources()
if err != nil {
t.Fatalf("Failed to get sources: %v", err)
}
// Test convenience methods based on availability
if sources.HasSpotify() {
t.Run("SelectSpotify", func(t *testing.T) {
spotifySources := sources.GetReadySpotifySources()
if len(spotifySources) > 0 {
err := client.SelectSpotify(spotifySources[0].SourceAccount)
if err != nil {
t.Errorf("SelectSpotify failed: %v", err)
} else {
t.Log("✓ SelectSpotify succeeded")
}
}
})
} else {
t.Log("Spotify not available - skipping SelectSpotify test")
}
if sources.HasBluetooth() {
t.Run("SelectBluetooth", func(t *testing.T) {
err := client.SelectBluetooth()
if err != nil {
t.Errorf("SelectBluetooth failed: %v", err)
} else {
t.Log("✓ SelectBluetooth succeeded")
}
})
} else {
t.Log("Bluetooth not available - skipping SelectBluetooth test")
}
if sources.HasSource("TUNEIN") {
t.Run("SelectTuneIn", func(t *testing.T) {
tuneInSources := sources.GetSourcesByType("TUNEIN")
for _, src := range tuneInSources {
if src.Status.IsReady() {
err := client.SelectTuneIn(src.SourceAccount)
if err != nil {
t.Errorf("SelectTuneIn failed: %v", err)
} else {
t.Log("✓ SelectTuneIn succeeded")
}
break
}
}
})
} else {
t.Log("TuneIn not available - skipping SelectTuneIn test")
}
if sources.HasSource("PANDORA") {
t.Run("SelectPandora", func(t *testing.T) {
pandoraSources := sources.GetSourcesByType("PANDORA")
for _, src := range pandoraSources {
if src.Status.IsReady() {
err := client.SelectPandora(src.SourceAccount)
if err != nil {
t.Errorf("SelectPandora failed: %v", err)
} else {
t.Log("✓ SelectPandora succeeded")
}
break
}
}
})
} else {
t.Log("Pandora not available - skipping SelectPandora test")
}
}
// Benchmark source selection performance
func BenchmarkClient_SelectSource_Integration(b *testing.B) {
if testing.Short() {
b.Skip("Skipping integration benchmarks in short mode")
}
host := os.Getenv("SOUNDTOUCH_TEST_HOST")
if host == "" {
b.Skip("SOUNDTOUCH_TEST_HOST environment variable not set - skipping integration benchmarks")
}
// Parse host:port if provided
finalHost, finalPort := parseHostPort(host, 8090)
config := ClientConfig{
Host: finalHost,
Port: finalPort,
Timeout: 15 * time.Second,
UserAgent: "Bose-SoundTouch-Go-Benchmark-Test/1.0",
}
client := NewClient(config)
// Get available sources
sources, err := client.GetSources()
if err != nil {
b.Fatalf("Failed to get sources: %v", err)
}
availableSources := sources.GetAvailableSources()
if len(availableSources) == 0 {
b.Skip("No available sources to benchmark with")
}
// Use first available source for benchmarking
testSource := availableSources[0]
b.ResetTimer()
for i := 0; i < b.N; i++ {
err := client.SelectSource(testSource.Source, testSource.SourceAccount)
if err != nil {
b.Fatalf("SelectSource failed: %v", err)
}
}
}
// parseHostPort is a helper function for integration tests
// This is a simple version for test use
func parseHostPort(hostPort string, defaultPort int) (string, int) {
if !containsSubstring(hostPort, ":") {
return hostPort, defaultPort
}
// Simple parsing - in real use, we'd use net.SplitHostPort
parts := make([]string, 0, 2)
current := ""
for _, char := range hostPort {
if char == ':' {
parts = append(parts, current)
current = ""
} else {
current += string(char)
}
}
if current != "" {
parts = append(parts, current)
}
if len(parts) == 2 {
// Try to parse port
port := defaultPort
portStr := parts[1]
portInt := 0
for _, char := range portStr {
if char >= '0' && char <= '9' {
portInt = portInt*10 + int(char-'0')
} else {
portInt = -1
break
}
}
if portInt > 0 && portInt <= 65535 {
port = portInt
}
return parts[0], port
}
return hostPort, defaultPort
}
+557
View File
@@ -0,0 +1,557 @@
package client
import (
"encoding/xml"
"net/http"
"net/http/httptest"
"testing"
"time"
"github.com/user_account/bose-soundtouch/pkg/models"
)
const (
testTimeout = 10 * time.Second
testUserAgent = "Bose-SoundTouch-Go-Client-Test/1.0"
)
func TestClient_SelectSource(t *testing.T) {
tests := []struct {
name string
source string
sourceAccount string
wantError bool
errorMessage string
}{
{
name: "Valid Spotify source",
source: "SPOTIFY",
sourceAccount: "user@example.com",
wantError: false,
},
{
name: "Valid Bluetooth source",
source: "BLUETOOTH",
sourceAccount: "",
wantError: false,
},
{
name: "Valid AUX source",
source: "AUX",
sourceAccount: "",
wantError: false,
},
{
name: "Valid TuneIn source",
source: "TUNEIN",
sourceAccount: "tunein_account",
wantError: false,
},
{
name: "Valid Pandora source",
source: "PANDORA",
sourceAccount: "pandora_user",
wantError: false,
},
{
name: "Valid Amazon Music source",
source: "AMAZON",
sourceAccount: "amazon_account",
wantError: false,
},
{
name: "Valid iHeartRadio source",
source: "IHEARTRADIO",
sourceAccount: "",
wantError: false,
},
{
name: "Valid Stored Music source",
source: "STORED_MUSIC",
sourceAccount: "",
wantError: false,
},
{
name: "Empty source",
source: "",
sourceAccount: "",
wantError: true,
errorMessage: "source cannot be empty",
},
}
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) {
if r.Method != "POST" {
t.Errorf("Expected POST request, got %s", r.Method)
return
}
if r.URL.Path != "/select" {
t.Errorf("Expected path /select, got %s", r.URL.Path)
return
}
// Verify Content-Type
if contentType := r.Header.Get("Content-Type"); contentType != "application/xml" {
t.Errorf("Expected Content-Type application/xml, got %s", contentType)
return
}
// Parse and validate request body
var contentItem models.ContentItem
err := xml.NewDecoder(r.Body).Decode(&contentItem)
if err != nil {
t.Errorf("Failed to decode request XML: %v", err)
return
}
// Validate source
if contentItem.Source != tt.source {
t.Errorf("Expected source %s, got %s", tt.source, contentItem.Source)
return
}
// Validate source account
if contentItem.SourceAccount != tt.sourceAccount {
t.Errorf("Expected sourceAccount %s, got %s", tt.sourceAccount, contentItem.SourceAccount)
return
}
// Validate item name is set correctly
expectedItemName := getExpectedItemName(tt.source)
if contentItem.ItemName != expectedItemName {
t.Errorf("Expected itemName %s, got %s", expectedItemName, contentItem.ItemName)
return
}
// Return success response
w.WriteHeader(http.StatusOK)
}))
defer server.Close()
// Create client
config := ClientConfig{
Host: server.URL[7:], // Remove "http://"
Port: 80,
Timeout: testTimeout,
UserAgent: testUserAgent,
}
// Override the base URL to use the test server
client := NewClient(config)
client.baseURL = server.URL
// Call SelectSource
err := client.SelectSource(tt.source, tt.sourceAccount)
// Validate result
if tt.wantError {
if err == nil {
t.Errorf("Expected error, got nil")
} else if err.Error() != tt.errorMessage {
t.Errorf("Expected error message '%s', got '%s'", tt.errorMessage, err.Error())
}
} else {
if err != nil {
t.Errorf("Unexpected error: %v", err)
}
}
})
}
}
func TestClient_SelectSourceFromItem(t *testing.T) {
tests := []struct {
name string
sourceItem *models.SourceItem
wantError bool
wantSource string
wantAccount string
}{
{
name: "Valid Spotify source item",
sourceItem: &models.SourceItem{
Source: "SPOTIFY",
SourceAccount: "spotify_user",
Status: models.SourceStatusReady,
DisplayName: "Spotify",
},
wantError: false,
wantSource: "SPOTIFY",
wantAccount: "spotify_user",
},
{
name: "Valid Bluetooth source item",
sourceItem: &models.SourceItem{
Source: "BLUETOOTH",
Status: models.SourceStatusReady,
DisplayName: "Bluetooth",
},
wantError: false,
wantSource: "BLUETOOTH",
wantAccount: "",
},
{
name: "Nil source item",
sourceItem: nil,
wantError: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if tt.sourceItem == nil {
// Test nil source item without server
config := DefaultConfig()
client := NewClient(config)
err := client.SelectSourceFromItem(tt.sourceItem)
if !tt.wantError {
t.Errorf("Expected no error, got: %v", err)
} else if err == nil {
t.Errorf("Expected error for nil source item")
}
return
}
// Create mock server for valid source items
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// Parse request body
var contentItem models.ContentItem
err := xml.NewDecoder(r.Body).Decode(&contentItem)
if err != nil {
t.Errorf("Failed to decode request XML: %v", err)
return
}
// Validate source and account
if contentItem.Source != tt.wantSource {
t.Errorf("Expected source %s, got %s", tt.wantSource, contentItem.Source)
return
}
if contentItem.SourceAccount != tt.wantAccount {
t.Errorf("Expected sourceAccount %s, got %s", tt.wantAccount, contentItem.SourceAccount)
return
}
w.WriteHeader(http.StatusOK)
}))
defer server.Close()
// Create client
config := ClientConfig{
Host: server.URL[7:],
Port: 80,
Timeout: testTimeout,
UserAgent: testUserAgent,
}
client := NewClient(config)
client.baseURL = server.URL
// Call SelectSourceFromItem
err := client.SelectSourceFromItem(tt.sourceItem)
// Validate result
if tt.wantError {
if err == nil {
t.Errorf("Expected error, got nil")
}
} else {
if err != nil {
t.Errorf("Unexpected error: %v", err)
}
}
})
}
}
func TestClient_ConvenienceSourceMethods(t *testing.T) {
tests := []struct {
name string
method string
sourceAccount string
expectedSource string
expectedAccount string
}{
{
name: "SelectSpotify with account",
method: "spotify",
sourceAccount: "spotify_user",
expectedSource: "SPOTIFY",
expectedAccount: "spotify_user",
},
{
name: "SelectSpotify without account",
method: "spotify",
sourceAccount: "",
expectedSource: "SPOTIFY",
expectedAccount: "",
},
{
name: "SelectBluetooth",
method: "bluetooth",
sourceAccount: "",
expectedSource: "BLUETOOTH",
expectedAccount: "",
},
{
name: "SelectAux",
method: "aux",
sourceAccount: "",
expectedSource: "AUX",
expectedAccount: "",
},
{
name: "SelectTuneIn",
method: "tunein",
sourceAccount: "tunein_account",
expectedSource: "TUNEIN",
expectedAccount: "tunein_account",
},
{
name: "SelectPandora",
method: "pandora",
sourceAccount: "pandora_user",
expectedSource: "PANDORA",
expectedAccount: "pandora_user",
},
}
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) {
// Parse request body
var contentItem models.ContentItem
err := xml.NewDecoder(r.Body).Decode(&contentItem)
if err != nil {
t.Errorf("Failed to decode request XML: %v", err)
return
}
// Validate source and account
if contentItem.Source != tt.expectedSource {
t.Errorf("Expected source %s, got %s", tt.expectedSource, contentItem.Source)
return
}
if contentItem.SourceAccount != tt.expectedAccount {
t.Errorf("Expected sourceAccount %s, got %s", tt.expectedAccount, contentItem.SourceAccount)
return
}
w.WriteHeader(http.StatusOK)
}))
defer server.Close()
// Create client
config := ClientConfig{
Host: server.URL[7:],
Port: 80,
Timeout: testTimeout,
UserAgent: testUserAgent,
}
client := NewClient(config)
client.baseURL = server.URL
// Call the appropriate convenience method
var err error
switch tt.method {
case "spotify":
err = client.SelectSpotify(tt.sourceAccount)
case "bluetooth":
err = client.SelectBluetooth()
case "aux":
err = client.SelectAux()
case "tunein":
err = client.SelectTuneIn(tt.sourceAccount)
case "pandora":
err = client.SelectPandora(tt.sourceAccount)
default:
t.Fatalf("Unknown method: %s", tt.method)
}
// Validate result
if err != nil {
t.Errorf("Unexpected error: %v", err)
}
})
}
}
func TestClient_SelectSource_ErrorHandling(t *testing.T) {
tests := []struct {
name string
serverResponse func(w http.ResponseWriter, r *http.Request)
wantError bool
errorContains string
}{
{
name: "Server returns 404",
serverResponse: func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusNotFound)
w.Write([]byte("Not Found"))
},
wantError: true,
errorContains: "API request failed with status 404",
},
{
name: "Server returns 500",
serverResponse: func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusInternalServerError)
w.Write([]byte("Internal Server Error"))
},
wantError: true,
errorContains: "API request failed with status 500",
},
{
name: "Server returns API error",
serverResponse: func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusBadRequest)
apiError := models.APIError{
Message: "Invalid source selection",
Code: 400,
}
xml.NewEncoder(w).Encode(apiError)
},
wantError: true,
errorContains: "Invalid source selection",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
// Create mock server
server := httptest.NewServer(http.HandlerFunc(tt.serverResponse))
defer server.Close()
// Create client
config := ClientConfig{
Host: server.URL[7:],
Port: 80,
Timeout: testTimeout,
UserAgent: testUserAgent,
}
client := NewClient(config)
client.baseURL = server.URL
// Call SelectSource
err := client.SelectSource("SPOTIFY", "test_account")
// Validate result
if tt.wantError {
if err == nil {
t.Errorf("Expected error, got nil")
} else if tt.errorContains != "" && !containsSubstring(err.Error(), tt.errorContains) {
t.Errorf("Expected error containing '%s', got '%s'", tt.errorContains, err.Error())
}
} else {
if err != nil {
t.Errorf("Unexpected error: %v", err)
}
}
})
}
}
func TestClient_SelectSource_RequestFormat(t *testing.T) {
// Test that the request XML format is correct
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// Read and parse the raw request body
var contentItem models.ContentItem
err := xml.NewDecoder(r.Body).Decode(&contentItem)
if err != nil {
t.Errorf("Failed to decode request XML: %v", err)
return
}
// Validate XML structure
expectedXML := `<ContentItem source="SPOTIFY" sourceAccount="test_user"><itemName>Spotify</itemName></ContentItem>`
// Re-encode to compare
actualXML, err := xml.Marshal(contentItem)
if err != nil {
t.Errorf("Failed to marshal ContentItem: %v", err)
return
}
// Basic validation of XML content (not exact string match due to formatting)
if contentItem.Source != "SPOTIFY" {
t.Errorf("Expected source SPOTIFY, got %s", contentItem.Source)
}
if contentItem.SourceAccount != "test_user" {
t.Errorf("Expected sourceAccount test_user, got %s", contentItem.SourceAccount)
}
if contentItem.ItemName != "Spotify" {
t.Errorf("Expected itemName Spotify, got %s", contentItem.ItemName)
}
t.Logf("Expected XML format: %s", expectedXML)
t.Logf("Actual XML: %s", string(actualXML))
w.WriteHeader(http.StatusOK)
}))
defer server.Close()
// Create client
config := ClientConfig{
Host: server.URL[7:],
Port: 80,
Timeout: testTimeout,
UserAgent: testUserAgent,
}
client := NewClient(config)
client.baseURL = server.URL
// Call SelectSource
err := client.SelectSource("SPOTIFY", "test_user")
if err != nil {
t.Errorf("Unexpected error: %v", err)
}
}
// Helper function to get expected item name for each source
func getExpectedItemName(source string) string {
switch source {
case "SPOTIFY":
return "Spotify"
case "BLUETOOTH":
return "Bluetooth"
case "AUX":
return "AUX Input"
case "TUNEIN":
return "TuneIn"
case "PANDORA":
return "Pandora"
case "AMAZON":
return "Amazon Music"
case "IHEARTRADIO":
return "iHeartRadio"
case "STORED_MUSIC":
return "Stored Music"
default:
return source // Default to source name
}
}
// Helper function to check if a string contains a substring
func containsSubstring(s, substr string) bool {
return len(s) >= len(substr) &&
(s == substr ||
(len(s) > len(substr) &&
(s[:len(substr)] == substr ||
s[len(s)-len(substr):] == substr ||
containsMiddleSubstring(s, substr))))
}
func containsMiddleSubstring(s, substr string) bool {
for i := 0; i <= len(s)-len(substr); i++ {
if s[i:i+len(substr)] == substr {
return true
}
}
return false
}