mirror of
https://github.com/gesellix/Bose-SoundTouch.git
synced 2026-08-18 08:36:13 +00:00
feat: implement /introspect and /recents endpoints with full CLI support
🔥 NEW ENDPOINTS IMPLEMENTED: 📊 /introspect endpoint: - Get detailed music service state and capabilities data - Support for SPOTIFY, PANDORA, TUNEIN, AMAZON, DEEZER services - Service state tracking (Active, Inactive, InactiveUnselected) - Playback capabilities (skip, seek, resume, data collection) - Authentication token status and user account information - Subscription type and content history metadata 📚 /recents endpoint: - Retrieve recently played content history - Support for all music sources (Spotify, Local, TuneIn, Pandora, etc.) - Rich filtering by source type and content type - Content classification (tracks, stations, playlists, albums) - Presetable item identification and artwork metadata - Timestamp tracking with UTC time support ⚡ CLIENT API: - client.Introspect(source, sourceAccount) method - client.IntrospectSpotify(sourceAccount) convenience method - client.GetRecents() method with comprehensive filtering - Complete error handling and validation - Rich helper methods for content analysis 🖥️ CLI COMMANDS: - soundtouch-cli source introspect --source <SERVICE> - soundtouch-cli source introspect-spotify - soundtouch-cli source introspect-all (bulk introspect) - soundtouch-cli recents list [--detailed] [--limit N] - soundtouch-cli recents filter --source <SRC> --type <TYPE> - soundtouch-cli recents latest (most recent item) - soundtouch-cli recents stats (detailed analytics) 📦 MODELS & FEATURES: - IntrospectRequest/Response with service-specific handling - RecentsResponse with RecentsResponseItem for individual items - Rich filtering: GetSpotifyItems(), GetTracks(), GetPresetableItems() - Content type detection: IsTrack(), IsStation(), IsPlaylist() - Source classification: IsStreamingContent(), IsLocalContent() - Full XML marshalling/unmarshalling with proper attribute handling 🧪 COMPREHENSIVE TESTING: - Unit tests for models with XML parsing validation - Integration tests for real device communication - CLI command tests with mock server responses - Error condition testing and edge case handling - Performance tests and timeout validation 📖 DOCUMENTATION & EXAMPLES: - Updated API endpoints overview marking endpoints as implemented - Comprehensive CLI reference with usage examples - Removed endpoints from unimplemented list - Updated wiki implementation plan status - Complete example applications with README guides - Real-world usage patterns and best practices ✨ KEY FEATURES: - Service health monitoring and diagnostics - Recently played content discovery and analysis - Preset candidate identification - Content statistics and usage analytics - Time-based filtering and relative timestamps - Rich emoji-based CLI output formatting - Cross-service compatibility and error handling This implements two critical missing endpoints from the SoundTouch API, providing essential functionality for music service management and recently played content analysis with full programmatic and CLI access.
This commit is contained in:
@@ -1681,6 +1681,41 @@ func (c *Client) PlayNotificationBeep() error {
|
||||
return c.get("/playNotification", &status)
|
||||
}
|
||||
|
||||
// Introspect retrieves introspect data for a specified music service
|
||||
func (c *Client) Introspect(source, sourceAccount string) (*models.IntrospectResponse, error) {
|
||||
if source == "" {
|
||||
return nil, fmt.Errorf("source cannot be empty")
|
||||
}
|
||||
|
||||
request := models.NewIntrospectRequest(source, sourceAccount)
|
||||
|
||||
var response models.IntrospectResponse
|
||||
|
||||
err := c.postWithResponse("/introspect", request, &response)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get introspect data for %s: %w", source, err)
|
||||
}
|
||||
|
||||
return &response, nil
|
||||
}
|
||||
|
||||
// IntrospectSpotify is a convenience method to get introspect data for Spotify
|
||||
func (c *Client) IntrospectSpotify(sourceAccount string) (*models.IntrospectResponse, error) {
|
||||
return c.Introspect("SPOTIFY", sourceAccount)
|
||||
}
|
||||
|
||||
// GetRecents retrieves recently played content from the device
|
||||
func (c *Client) GetRecents() (*models.RecentsResponse, error) {
|
||||
var response models.RecentsResponse
|
||||
|
||||
err := c.get("/recents", &response)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get recent items: %w", err)
|
||||
}
|
||||
|
||||
return &response, nil
|
||||
}
|
||||
|
||||
// postPlayInfo sends a PlayInfo request to the /speaker endpoint
|
||||
func (c *Client) postPlayInfo(playInfo *models.PlayInfo) error {
|
||||
return c.post("/speaker", playInfo)
|
||||
|
||||
@@ -0,0 +1,235 @@
|
||||
package client
|
||||
|
||||
import (
|
||||
"os"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestClient_Introspect_Integration(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.Skip("skipping integration test")
|
||||
}
|
||||
|
||||
host := os.Getenv("SOUNDTOUCH_HOST")
|
||||
if host == "" {
|
||||
t.Skip("SOUNDTOUCH_HOST not set, skipping integration test")
|
||||
}
|
||||
|
||||
config := &Config{
|
||||
Host: host,
|
||||
Timeout: 10 * time.Second,
|
||||
}
|
||||
client := NewClient(config)
|
||||
|
||||
// Test getting Spotify introspect data
|
||||
t.Run("spotify introspect", func(t *testing.T) {
|
||||
// First check if Spotify is available
|
||||
serviceAvailability, err := client.GetServiceAvailability()
|
||||
if err != nil {
|
||||
t.Fatalf("failed to get service availability: %v", err)
|
||||
}
|
||||
|
||||
if !serviceAvailability.HasSpotify() {
|
||||
t.Skip("Spotify not available on this device")
|
||||
}
|
||||
|
||||
// Test introspect with empty source account (should still work)
|
||||
response, err := client.Introspect("SPOTIFY", "")
|
||||
if err != nil {
|
||||
t.Fatalf("failed to get Spotify introspect data: %v", err)
|
||||
}
|
||||
|
||||
if response == nil {
|
||||
t.Fatal("expected response, got nil")
|
||||
}
|
||||
|
||||
t.Logf("Spotify introspect state: %s", response.State)
|
||||
t.Logf("Spotify user: %s", response.User)
|
||||
t.Logf("Spotify is playing: %t", response.IsPlaying)
|
||||
t.Logf("Spotify shuffle mode: %s", response.ShuffleMode)
|
||||
t.Logf("Spotify current URI: %s", response.CurrentURI)
|
||||
t.Logf("Spotify subscription type: %s", response.SubscriptionType)
|
||||
|
||||
// Test state methods
|
||||
if response.IsActive() {
|
||||
t.Log("Spotify service is active")
|
||||
} else if response.IsInactive() {
|
||||
t.Log("Spotify service is inactive")
|
||||
}
|
||||
|
||||
// Test capabilities
|
||||
if response.SupportsSkipPrevious() {
|
||||
t.Log("Spotify supports skip previous")
|
||||
}
|
||||
if response.SupportsSeek() {
|
||||
t.Log("Spotify supports seek")
|
||||
}
|
||||
if response.SupportsResume() {
|
||||
t.Log("Spotify supports resume")
|
||||
}
|
||||
|
||||
// Test history
|
||||
historySize := response.GetMaxHistorySize()
|
||||
if historySize > 0 {
|
||||
t.Logf("Spotify content history max size: %d", historySize)
|
||||
}
|
||||
})
|
||||
|
||||
// Test the convenience method
|
||||
t.Run("spotify introspect convenience method", func(t *testing.T) {
|
||||
// First check if Spotify is available
|
||||
serviceAvailability, err := client.GetServiceAvailability()
|
||||
if err != nil {
|
||||
t.Fatalf("failed to get service availability: %v", err)
|
||||
}
|
||||
|
||||
if !serviceAvailability.HasSpotify() {
|
||||
t.Skip("Spotify not available on this device")
|
||||
}
|
||||
|
||||
response, err := client.IntrospectSpotify("")
|
||||
if err != nil {
|
||||
t.Fatalf("failed to get Spotify introspect data using convenience method: %v", err)
|
||||
}
|
||||
|
||||
if response == nil {
|
||||
t.Fatal("expected response from convenience method, got nil")
|
||||
}
|
||||
|
||||
t.Logf("Convenience method - Spotify state: %s", response.State)
|
||||
})
|
||||
|
||||
// Test introspect with other services if available
|
||||
t.Run("other services introspect", func(t *testing.T) {
|
||||
serviceAvailability, err := client.GetServiceAvailability()
|
||||
if err != nil {
|
||||
t.Fatalf("failed to get service availability: %v", err)
|
||||
}
|
||||
|
||||
// Test Pandora if available
|
||||
if serviceAvailability.HasPandora() {
|
||||
t.Log("Testing Pandora introspect...")
|
||||
response, err := client.Introspect("PANDORA", "")
|
||||
if err != nil {
|
||||
t.Logf("Pandora introspect failed (expected for some configurations): %v", err)
|
||||
} else {
|
||||
t.Logf("Pandora introspect state: %s", response.State)
|
||||
}
|
||||
}
|
||||
|
||||
// Test TuneIn if available
|
||||
if serviceAvailability.HasTuneIn() {
|
||||
t.Log("Testing TuneIn introspect...")
|
||||
response, err := client.Introspect("TUNEIN", "")
|
||||
if err != nil {
|
||||
t.Logf("TuneIn introspect failed (expected for some configurations): %v", err)
|
||||
} else {
|
||||
t.Logf("TuneIn introspect state: %s", response.State)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestClient_Introspect_ErrorCases_Integration(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.Skip("skipping integration test")
|
||||
}
|
||||
|
||||
host := os.Getenv("SOUNDTOUCH_HOST")
|
||||
if host == "" {
|
||||
t.Skip("SOUNDTOUCH_HOST not set, skipping integration test")
|
||||
}
|
||||
|
||||
config := &Config{
|
||||
Host: host,
|
||||
Timeout: 5 * time.Second,
|
||||
}
|
||||
client := NewClient(config)
|
||||
|
||||
// Test with invalid source
|
||||
t.Run("invalid source", func(t *testing.T) {
|
||||
response, err := client.Introspect("INVALID_SOURCE", "")
|
||||
if err == nil {
|
||||
t.Error("expected error for invalid source, got nil")
|
||||
}
|
||||
if response != nil {
|
||||
t.Error("expected nil response for invalid source, got non-nil")
|
||||
}
|
||||
t.Logf("Expected error for invalid source: %v", err)
|
||||
})
|
||||
|
||||
// Test with empty source
|
||||
t.Run("empty source", func(t *testing.T) {
|
||||
response, err := client.Introspect("", "")
|
||||
if err == nil {
|
||||
t.Error("expected error for empty source, got nil")
|
||||
}
|
||||
if response != nil {
|
||||
t.Error("expected nil response for empty source, got non-nil")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// ExampleClient_Introspect demonstrates how to use the Introspect method
|
||||
func ExampleClient_Introspect() {
|
||||
config := &Config{
|
||||
Host: "192.168.1.100",
|
||||
Port: 8090,
|
||||
}
|
||||
client := NewClient(config)
|
||||
|
||||
// Get introspect data for Spotify
|
||||
response, err := client.Introspect("SPOTIFY", "")
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
// Check service state
|
||||
if response.IsActive() {
|
||||
println("Spotify service is active")
|
||||
if response.IsPlaying {
|
||||
println("Currently playing:", response.CurrentURI)
|
||||
}
|
||||
} else {
|
||||
println("Spotify service is inactive")
|
||||
}
|
||||
|
||||
// Check capabilities
|
||||
if response.SupportsSeek() {
|
||||
println("Seek is supported")
|
||||
}
|
||||
if response.SupportsSkipPrevious() {
|
||||
println("Skip previous is supported")
|
||||
}
|
||||
}
|
||||
|
||||
// ExampleClient_IntrospectSpotify demonstrates the Spotify convenience method
|
||||
func ExampleClient_IntrospectSpotify() {
|
||||
config := &Config{
|
||||
Host: "192.168.1.100",
|
||||
Port: 8090,
|
||||
}
|
||||
client := NewClient(config)
|
||||
|
||||
// Get Spotify introspect data using convenience method
|
||||
response, err := client.IntrospectSpotify("")
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
// Display user and subscription info
|
||||
if response.HasUser() {
|
||||
println("Spotify user:", response.User)
|
||||
}
|
||||
if response.HasSubscription() {
|
||||
println("Subscription type:", response.SubscriptionType)
|
||||
}
|
||||
|
||||
// Check shuffle state
|
||||
if response.IsShuffleEnabled() {
|
||||
println("Shuffle is enabled")
|
||||
} else {
|
||||
println("Shuffle is disabled")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,361 @@
|
||||
package client
|
||||
|
||||
import (
|
||||
"encoding/xml"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/gesellix/bose-soundtouch/pkg/models"
|
||||
)
|
||||
|
||||
func TestClient_Introspect(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
source string
|
||||
sourceAccount string
|
||||
responseXML string
|
||||
expectedError string
|
||||
wantResponse *models.IntrospectResponse
|
||||
}{
|
||||
{
|
||||
name: "successful spotify introspect",
|
||||
source: "SPOTIFY",
|
||||
sourceAccount: "SpotifyConnectUserName",
|
||||
responseXML: `<?xml version="1.0" encoding="UTF-8" ?>
|
||||
<spotifyAccountIntrospectResponse state="InactiveUnselected" user="SpotifyConnectUserName" isPlaying="false" tokenLastChangedTimeSeconds="1702566495" tokenLastChangedTimeMicroseconds="427884" shuffleMode="OFF" playStatusState="2" currentUri="" receivedPlaybackRequest="false" subscriptionType="">
|
||||
<cachedPlaybackRequest />
|
||||
<nowPlaying skipPreviousSupported="false" seekSupported="false" resumeSupported="true" collectData="true" />
|
||||
<contentItemHistory maxSize="10" />
|
||||
</spotifyAccountIntrospectResponse>`,
|
||||
wantResponse: &models.IntrospectResponse{
|
||||
State: "InactiveUnselected",
|
||||
User: "SpotifyConnectUserName",
|
||||
IsPlaying: false,
|
||||
TokenLastChangedTimeSeconds: 1702566495,
|
||||
TokenLastChangedTimeMicroseconds: 427884,
|
||||
ShuffleMode: "OFF",
|
||||
PlayStatusState: "2",
|
||||
CurrentURI: "",
|
||||
ReceivedPlaybackRequest: false,
|
||||
SubscriptionType: "",
|
||||
CachedPlaybackRequest: &models.CachedPlaybackRequest{},
|
||||
NowPlaying: &models.IntrospectNowPlaying{
|
||||
SkipPreviousSupported: false,
|
||||
SeekSupported: false,
|
||||
ResumeSupported: true,
|
||||
CollectData: true,
|
||||
},
|
||||
ContentItemHistory: &models.ContentItemHistory{
|
||||
MaxSize: 10,
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "successful pandora introspect",
|
||||
source: "PANDORA",
|
||||
sourceAccount: "pandora_user",
|
||||
responseXML: `<?xml version="1.0" encoding="UTF-8" ?>
|
||||
<pandoraAccountIntrospectResponse state="Active" user="pandora_user" isPlaying="true" shuffleMode="ON" currentUri="pandora://track/123" subscriptionType="Premium">
|
||||
<nowPlaying skipPreviousSupported="true" seekSupported="false" resumeSupported="true" collectData="false" />
|
||||
<contentItemHistory maxSize="20" />
|
||||
</pandoraAccountIntrospectResponse>`,
|
||||
wantResponse: &models.IntrospectResponse{
|
||||
State: "Active",
|
||||
User: "pandora_user",
|
||||
IsPlaying: true,
|
||||
ShuffleMode: "ON",
|
||||
CurrentURI: "pandora://track/123",
|
||||
SubscriptionType: "Premium",
|
||||
NowPlaying: &models.IntrospectNowPlaying{
|
||||
SkipPreviousSupported: true,
|
||||
SeekSupported: false,
|
||||
ResumeSupported: true,
|
||||
CollectData: false,
|
||||
},
|
||||
ContentItemHistory: &models.ContentItemHistory{
|
||||
MaxSize: 20,
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "empty source error",
|
||||
source: "",
|
||||
sourceAccount: "test_user",
|
||||
expectedError: "source cannot be empty",
|
||||
},
|
||||
{
|
||||
name: "http error",
|
||||
source: "SPOTIFY",
|
||||
sourceAccount: "test_user",
|
||||
responseXML: "",
|
||||
expectedError: "failed to get introspect data for SPOTIFY:",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
// Verify request method and path
|
||||
if r.Method != "POST" {
|
||||
t.Errorf("expected POST request, got %s", r.Method)
|
||||
}
|
||||
if r.URL.Path != "/introspect" {
|
||||
t.Errorf("expected /introspect path, got %s", r.URL.Path)
|
||||
}
|
||||
|
||||
// Verify request body
|
||||
var requestBody models.IntrospectRequest
|
||||
if err := xml.NewDecoder(r.Body).Decode(&requestBody); err != nil {
|
||||
t.Errorf("failed to decode request body: %v", err)
|
||||
}
|
||||
|
||||
if requestBody.Source != tt.source {
|
||||
t.Errorf("expected source %s, got %s", tt.source, requestBody.Source)
|
||||
}
|
||||
if requestBody.SourceAccount != tt.sourceAccount {
|
||||
t.Errorf("expected sourceAccount %s, got %s", tt.sourceAccount, requestBody.SourceAccount)
|
||||
}
|
||||
|
||||
if tt.responseXML == "" {
|
||||
// Simulate server error
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/xml")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
w.Write([]byte(tt.responseXML))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
config := &Config{
|
||||
Host: server.URL[7:], // Remove "http://" prefix
|
||||
Port: 80,
|
||||
}
|
||||
client := NewClient(config)
|
||||
// Override the base URL to use test server
|
||||
client.baseURL = server.URL
|
||||
|
||||
response, err := client.Introspect(tt.source, tt.sourceAccount)
|
||||
|
||||
if tt.expectedError != "" {
|
||||
if err == nil {
|
||||
t.Errorf("expected error containing %q, got nil", tt.expectedError)
|
||||
return
|
||||
}
|
||||
if !containsString(err.Error(), tt.expectedError) {
|
||||
t.Errorf("expected error containing %q, got %q", tt.expectedError, err.Error())
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
t.Errorf("unexpected error: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
if response == nil {
|
||||
t.Error("expected response, got nil")
|
||||
return
|
||||
}
|
||||
|
||||
// Verify response fields
|
||||
if response.State != tt.wantResponse.State {
|
||||
t.Errorf("expected state %s, got %s", tt.wantResponse.State, response.State)
|
||||
}
|
||||
if response.User != tt.wantResponse.User {
|
||||
t.Errorf("expected user %s, got %s", tt.wantResponse.User, response.User)
|
||||
}
|
||||
if response.IsPlaying != tt.wantResponse.IsPlaying {
|
||||
t.Errorf("expected isPlaying %t, got %t", tt.wantResponse.IsPlaying, response.IsPlaying)
|
||||
}
|
||||
if response.ShuffleMode != tt.wantResponse.ShuffleMode {
|
||||
t.Errorf("expected shuffleMode %s, got %s", tt.wantResponse.ShuffleMode, response.ShuffleMode)
|
||||
}
|
||||
if response.CurrentURI != tt.wantResponse.CurrentURI {
|
||||
t.Errorf("expected currentUri %s, got %s", tt.wantResponse.CurrentURI, response.CurrentURI)
|
||||
}
|
||||
if response.SubscriptionType != tt.wantResponse.SubscriptionType {
|
||||
t.Errorf("expected subscriptionType %s, got %s", tt.wantResponse.SubscriptionType, response.SubscriptionType)
|
||||
}
|
||||
|
||||
// Verify nested structures
|
||||
if tt.wantResponse.NowPlaying != nil {
|
||||
if response.NowPlaying == nil {
|
||||
t.Error("expected nowPlaying, got nil")
|
||||
} else {
|
||||
if response.NowPlaying.SkipPreviousSupported != tt.wantResponse.NowPlaying.SkipPreviousSupported {
|
||||
t.Errorf("expected skipPreviousSupported %t, got %t",
|
||||
tt.wantResponse.NowPlaying.SkipPreviousSupported,
|
||||
response.NowPlaying.SkipPreviousSupported)
|
||||
}
|
||||
if response.NowPlaying.SeekSupported != tt.wantResponse.NowPlaying.SeekSupported {
|
||||
t.Errorf("expected seekSupported %t, got %t",
|
||||
tt.wantResponse.NowPlaying.SeekSupported,
|
||||
response.NowPlaying.SeekSupported)
|
||||
}
|
||||
if response.NowPlaying.ResumeSupported != tt.wantResponse.NowPlaying.ResumeSupported {
|
||||
t.Errorf("expected resumeSupported %t, got %t",
|
||||
tt.wantResponse.NowPlaying.ResumeSupported,
|
||||
response.NowPlaying.ResumeSupported)
|
||||
}
|
||||
if response.NowPlaying.CollectData != tt.wantResponse.NowPlaying.CollectData {
|
||||
t.Errorf("expected collectData %t, got %t",
|
||||
tt.wantResponse.NowPlaying.CollectData,
|
||||
response.NowPlaying.CollectData)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if tt.wantResponse.ContentItemHistory != nil {
|
||||
if response.ContentItemHistory == nil {
|
||||
t.Error("expected contentItemHistory, got nil")
|
||||
} else {
|
||||
if response.ContentItemHistory.MaxSize != tt.wantResponse.ContentItemHistory.MaxSize {
|
||||
t.Errorf("expected maxSize %d, got %d",
|
||||
tt.wantResponse.ContentItemHistory.MaxSize,
|
||||
response.ContentItemHistory.MaxSize)
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestIntrospectResponse_Methods(t *testing.T) {
|
||||
response := &models.IntrospectResponse{
|
||||
State: "Active",
|
||||
User: "test_user",
|
||||
IsPlaying: true,
|
||||
ShuffleMode: "ON",
|
||||
CurrentURI: "spotify://track/123",
|
||||
SubscriptionType: "Premium",
|
||||
NowPlaying: &models.IntrospectNowPlaying{
|
||||
SkipPreviousSupported: true,
|
||||
SeekSupported: true,
|
||||
ResumeSupported: true,
|
||||
CollectData: false,
|
||||
},
|
||||
ContentItemHistory: &models.ContentItemHistory{
|
||||
MaxSize: 15,
|
||||
},
|
||||
}
|
||||
|
||||
// Test state methods
|
||||
if !response.IsActive() {
|
||||
t.Error("expected IsActive() to return true")
|
||||
}
|
||||
if response.IsInactive() {
|
||||
t.Error("expected IsInactive() to return false")
|
||||
}
|
||||
|
||||
// Test user methods
|
||||
if !response.HasUser() {
|
||||
t.Error("expected HasUser() to return true")
|
||||
}
|
||||
|
||||
// Test shuffle methods
|
||||
if !response.IsShuffleEnabled() {
|
||||
t.Error("expected IsShuffleEnabled() to return true")
|
||||
}
|
||||
|
||||
// Test content methods
|
||||
if !response.HasCurrentContent() {
|
||||
t.Error("expected HasCurrentContent() to return true")
|
||||
}
|
||||
|
||||
// Test capability methods
|
||||
if !response.SupportsSkipPrevious() {
|
||||
t.Error("expected SupportsSkipPrevious() to return true")
|
||||
}
|
||||
if !response.SupportsSeek() {
|
||||
t.Error("expected SupportsSeek() to return true")
|
||||
}
|
||||
if !response.SupportsResume() {
|
||||
t.Error("expected SupportsResume() to return true")
|
||||
}
|
||||
if response.CollectsData() {
|
||||
t.Error("expected CollectsData() to return false")
|
||||
}
|
||||
|
||||
// Test history methods
|
||||
if response.GetMaxHistorySize() != 15 {
|
||||
t.Errorf("expected GetMaxHistorySize() to return 15, got %d", response.GetMaxHistorySize())
|
||||
}
|
||||
|
||||
// Test subscription methods
|
||||
if !response.HasSubscription() {
|
||||
t.Error("expected HasSubscription() to return true")
|
||||
}
|
||||
}
|
||||
|
||||
func TestIntrospectResponse_InactiveState(t *testing.T) {
|
||||
response := &models.IntrospectResponse{
|
||||
State: "InactiveUnselected",
|
||||
User: "",
|
||||
IsPlaying: false,
|
||||
ShuffleMode: "OFF",
|
||||
CurrentURI: "",
|
||||
SubscriptionType: "",
|
||||
}
|
||||
|
||||
// Test inactive state
|
||||
if response.IsActive() {
|
||||
t.Error("expected IsActive() to return false")
|
||||
}
|
||||
if !response.IsInactive() {
|
||||
t.Error("expected IsInactive() to return true")
|
||||
}
|
||||
|
||||
// Test empty values
|
||||
if response.HasUser() {
|
||||
t.Error("expected HasUser() to return false")
|
||||
}
|
||||
if response.IsShuffleEnabled() {
|
||||
t.Error("expected IsShuffleEnabled() to return false")
|
||||
}
|
||||
if response.HasCurrentContent() {
|
||||
t.Error("expected HasCurrentContent() to return false")
|
||||
}
|
||||
if response.HasSubscription() {
|
||||
t.Error("expected HasSubscription() to return false")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewIntrospectRequest(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
source string
|
||||
sourceAccount string
|
||||
}{
|
||||
{
|
||||
name: "with source account",
|
||||
source: "SPOTIFY",
|
||||
sourceAccount: "test_user",
|
||||
},
|
||||
{
|
||||
name: "without source account",
|
||||
source: "BLUETOOTH",
|
||||
sourceAccount: "",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
request := models.NewIntrospectRequest(tt.source, tt.sourceAccount)
|
||||
|
||||
if request == nil {
|
||||
t.Error("expected request, got nil")
|
||||
return
|
||||
}
|
||||
|
||||
if request.Source != tt.source {
|
||||
t.Errorf("expected source %s, got %s", tt.source, request.Source)
|
||||
}
|
||||
if request.SourceAccount != tt.sourceAccount {
|
||||
t.Errorf("expected sourceAccount %s, got %s", tt.sourceAccount, request.SourceAccount)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,325 @@
|
||||
package client
|
||||
|
||||
import (
|
||||
"os"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestClient_GetRecents_Integration(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.Skip("skipping integration test")
|
||||
}
|
||||
|
||||
host := os.Getenv("SOUNDTOUCH_HOST")
|
||||
if host == "" {
|
||||
t.Skip("SOUNDTOUCH_HOST not set, skipping integration test")
|
||||
}
|
||||
|
||||
config := &Config{
|
||||
Host: host,
|
||||
Timeout: 10 * time.Second,
|
||||
}
|
||||
client := NewClient(config)
|
||||
|
||||
t.Run("get recents", func(t *testing.T) {
|
||||
response, err := client.GetRecents()
|
||||
if err != nil {
|
||||
t.Fatalf("failed to get recents: %v", err)
|
||||
}
|
||||
|
||||
if response == nil {
|
||||
t.Fatal("expected response, got nil")
|
||||
}
|
||||
|
||||
t.Logf("Recent items count: %d", response.GetItemCount())
|
||||
|
||||
if response.IsEmpty() {
|
||||
t.Log("No recent items found - this is normal if device hasn't played anything recently")
|
||||
return
|
||||
}
|
||||
|
||||
// Test basic functionality
|
||||
t.Logf("Recent items found: %d", response.GetItemCount())
|
||||
|
||||
// Get most recent item
|
||||
mostRecent := response.GetMostRecent()
|
||||
if mostRecent != nil {
|
||||
t.Logf("Most recent item: %s (Source: %s, Time: %d)",
|
||||
mostRecent.GetDisplayName(),
|
||||
mostRecent.GetSource(),
|
||||
mostRecent.GetUTCTime())
|
||||
|
||||
if mostRecent.HasArtwork() {
|
||||
t.Logf(" Has artwork: %s", mostRecent.GetArtwork())
|
||||
}
|
||||
|
||||
if mostRecent.IsPresetable() {
|
||||
t.Log(" Can be saved as preset")
|
||||
}
|
||||
|
||||
// Test content type detection
|
||||
if mostRecent.IsTrack() {
|
||||
t.Log(" Content type: Track")
|
||||
} else if mostRecent.IsStation() {
|
||||
t.Log(" Content type: Radio Station")
|
||||
} else if mostRecent.IsPlaylist() {
|
||||
t.Log(" Content type: Playlist")
|
||||
} else if mostRecent.IsAlbum() {
|
||||
t.Log(" Content type: Album")
|
||||
} else if mostRecent.IsContainer() {
|
||||
t.Log(" Content type: Container")
|
||||
}
|
||||
|
||||
// Test source type detection
|
||||
if mostRecent.IsSpotifyContent() {
|
||||
t.Log(" Source type: Spotify")
|
||||
} else if mostRecent.IsLocalContent() {
|
||||
t.Log(" Source type: Local")
|
||||
} else if mostRecent.IsStreamingContent() {
|
||||
t.Log(" Source type: Streaming service")
|
||||
}
|
||||
}
|
||||
|
||||
// Test filtering methods
|
||||
spotifyItems := response.GetSpotifyItems()
|
||||
if len(spotifyItems) > 0 {
|
||||
t.Logf("Spotify items: %d", len(spotifyItems))
|
||||
for i, item := range spotifyItems {
|
||||
if i < 3 { // Show first 3
|
||||
t.Logf(" - %s", item.GetDisplayName())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
localItems := response.GetLocalMusicItems()
|
||||
if len(localItems) > 0 {
|
||||
t.Logf("Local music items: %d", len(localItems))
|
||||
}
|
||||
|
||||
storedItems := response.GetStoredMusicItems()
|
||||
if len(storedItems) > 0 {
|
||||
t.Logf("Stored music items: %d", len(storedItems))
|
||||
}
|
||||
|
||||
tuneInItems := response.GetTuneInItems()
|
||||
if len(tuneInItems) > 0 {
|
||||
t.Logf("TuneIn items: %d", len(tuneInItems))
|
||||
}
|
||||
|
||||
pandoraItems := response.GetPandoraItems()
|
||||
if len(pandoraItems) > 0 {
|
||||
t.Logf("Pandora items: %d", len(pandoraItems))
|
||||
}
|
||||
|
||||
// Test content type filters
|
||||
tracks := response.GetTracks()
|
||||
if len(tracks) > 0 {
|
||||
t.Logf("Track items: %d", len(tracks))
|
||||
}
|
||||
|
||||
stations := response.GetStations()
|
||||
if len(stations) > 0 {
|
||||
t.Logf("Station items: %d", len(stations))
|
||||
}
|
||||
|
||||
playlistsAndAlbums := response.GetPlaylistsAndAlbums()
|
||||
if len(playlistsAndAlbums) > 0 {
|
||||
t.Logf("Playlist/Album items: %d", len(playlistsAndAlbums))
|
||||
}
|
||||
|
||||
presetableItems := response.GetPresetableItems()
|
||||
if len(presetableItems) > 0 {
|
||||
t.Logf("Presetable items: %d", len(presetableItems))
|
||||
}
|
||||
|
||||
// Show all items with details
|
||||
t.Log("\nAll recent items:")
|
||||
for i, item := range response.Items {
|
||||
if i >= 10 { // Limit to first 10 items to avoid spam
|
||||
t.Logf(" ... and %d more items", len(response.Items)-i)
|
||||
break
|
||||
}
|
||||
|
||||
displayName := item.GetDisplayName()
|
||||
source := item.GetSource()
|
||||
contentType := item.GetContentType()
|
||||
utcTime := item.GetUTCTime()
|
||||
|
||||
timeStr := ""
|
||||
if utcTime > 0 {
|
||||
playTime := time.Unix(utcTime, 0)
|
||||
timeStr = playTime.Format("2006-01-02 15:04:05")
|
||||
}
|
||||
|
||||
t.Logf(" %d. %s (%s/%s) - %s", i+1, displayName, source, contentType, timeStr)
|
||||
|
||||
if item.HasID() {
|
||||
t.Logf(" ID: %s", item.GetID())
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestClient_GetRecents_Performance(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.Skip("skipping performance test")
|
||||
}
|
||||
|
||||
host := os.Getenv("SOUNDTOUCH_HOST")
|
||||
if host == "" {
|
||||
t.Skip("SOUNDTOUCH_HOST not set, skipping integration test")
|
||||
}
|
||||
|
||||
config := &Config{
|
||||
Host: host,
|
||||
Timeout: 5 * time.Second,
|
||||
}
|
||||
client := NewClient(config)
|
||||
|
||||
// Measure response time
|
||||
start := time.Now()
|
||||
response, err := client.GetRecents()
|
||||
duration := time.Since(start)
|
||||
|
||||
if err != nil {
|
||||
t.Fatalf("failed to get recents: %v", err)
|
||||
}
|
||||
|
||||
t.Logf("GetRecents() took %v", duration)
|
||||
|
||||
if duration > 2*time.Second {
|
||||
t.Logf("Warning: GetRecents() took longer than expected: %v", duration)
|
||||
}
|
||||
|
||||
if response != nil {
|
||||
t.Logf("Retrieved %d recent items", response.GetItemCount())
|
||||
}
|
||||
}
|
||||
|
||||
func TestClient_GetRecents_ErrorConditions(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.Skip("skipping integration test")
|
||||
}
|
||||
|
||||
// Test with invalid host
|
||||
t.Run("invalid host", func(t *testing.T) {
|
||||
config := &Config{
|
||||
Host: "192.168.255.255", // Non-existent IP
|
||||
Timeout: 2 * time.Second, // Short timeout
|
||||
}
|
||||
client := NewClient(config)
|
||||
|
||||
response, err := client.GetRecents()
|
||||
if err == nil {
|
||||
t.Error("expected error for invalid host, got nil")
|
||||
}
|
||||
if response != nil {
|
||||
t.Error("expected nil response for invalid host, got non-nil")
|
||||
}
|
||||
t.Logf("Expected error for invalid host: %v", err)
|
||||
})
|
||||
|
||||
// Test with very short timeout
|
||||
t.Run("timeout", func(t *testing.T) {
|
||||
host := os.Getenv("SOUNDTOUCH_HOST")
|
||||
if host == "" {
|
||||
t.Skip("SOUNDTOUCH_HOST not set")
|
||||
}
|
||||
|
||||
config := &Config{
|
||||
Host: host,
|
||||
Timeout: 1 * time.Nanosecond, // Impossibly short timeout
|
||||
}
|
||||
client := NewClient(config)
|
||||
|
||||
response, err := client.GetRecents()
|
||||
if err == nil {
|
||||
t.Log("Warning: expected timeout error, but request succeeded")
|
||||
}
|
||||
if response != nil && err != nil {
|
||||
t.Error("got both response and error")
|
||||
}
|
||||
t.Logf("Timeout test result - error: %v, response nil: %t", err, response == nil)
|
||||
})
|
||||
}
|
||||
|
||||
// ExampleClient_GetRecents demonstrates how to use the GetRecents method
|
||||
func ExampleClient_GetRecents() {
|
||||
config := &Config{
|
||||
Host: "192.168.1.100",
|
||||
Port: 8090,
|
||||
}
|
||||
client := NewClient(config)
|
||||
|
||||
// Get recent items
|
||||
response, err := client.GetRecents()
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
if response.IsEmpty() {
|
||||
println("No recent items found")
|
||||
return
|
||||
}
|
||||
|
||||
// Show most recent item
|
||||
mostRecent := response.GetMostRecent()
|
||||
if mostRecent != nil {
|
||||
println("Most recent:", mostRecent.GetDisplayName())
|
||||
println("Source:", mostRecent.GetSource())
|
||||
|
||||
if mostRecent.IsPresetable() {
|
||||
println("Can be saved as preset")
|
||||
}
|
||||
}
|
||||
|
||||
// Show Spotify items
|
||||
spotifyItems := response.GetSpotifyItems()
|
||||
if len(spotifyItems) > 0 {
|
||||
println("Recent Spotify tracks:")
|
||||
for _, item := range spotifyItems {
|
||||
println("-", item.GetDisplayName())
|
||||
}
|
||||
}
|
||||
|
||||
// Show only tracks (no stations or playlists)
|
||||
tracks := response.GetTracks()
|
||||
println("Total tracks in recent items:", len(tracks))
|
||||
}
|
||||
|
||||
// ExampleRecentsResponse_filtering demonstrates filtering recent items
|
||||
func ExampleRecentsResponse_filtering() {
|
||||
config := &Config{
|
||||
Host: "192.168.1.100",
|
||||
Port: 8090,
|
||||
}
|
||||
client := NewClient(config)
|
||||
|
||||
response, err := client.GetRecents()
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
// Filter by source
|
||||
println("Spotify items:", len(response.GetSpotifyItems()))
|
||||
println("Local music items:", len(response.GetLocalMusicItems()))
|
||||
println("TuneIn items:", len(response.GetTuneInItems()))
|
||||
|
||||
// Filter by type
|
||||
println("Tracks:", len(response.GetTracks()))
|
||||
println("Stations:", len(response.GetStations()))
|
||||
println("Playlists/Albums:", len(response.GetPlaylistsAndAlbums()))
|
||||
|
||||
// Filter by capability
|
||||
println("Presetable items:", len(response.GetPresetableItems()))
|
||||
|
||||
// Get items from streaming services only
|
||||
streamingItems := 0
|
||||
for _, item := range response.Items {
|
||||
if item.IsStreamingContent() {
|
||||
streamingItems++
|
||||
}
|
||||
}
|
||||
println("Streaming service items:", streamingItems)
|
||||
}
|
||||
@@ -0,0 +1,396 @@
|
||||
package client
|
||||
|
||||
import (
|
||||
"encoding/xml"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/gesellix/bose-soundtouch/pkg/models"
|
||||
)
|
||||
|
||||
func TestClient_GetRecents(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
responseXML string
|
||||
statusCode int
|
||||
expectedError string
|
||||
wantResponse *models.RecentsResponse
|
||||
}{
|
||||
{
|
||||
name: "successful recents response",
|
||||
statusCode: http.StatusOK,
|
||||
responseXML: `<?xml version="1.0" encoding="UTF-8" ?>
|
||||
<recents>
|
||||
<recent deviceID="1004567890AA" utcTime="1701202831">
|
||||
<contentItem source="STORED_MUSIC" location="6_a2874b5d_4f83d999" sourceAccount="d09708a1-5953-44bc-a413-123456789012/0" isPresetable="true">
|
||||
<itemName>MercyMe, It's Christmas!</itemName>
|
||||
</contentItem>
|
||||
</recent>
|
||||
<recent deviceID="1004567890AA" utcTime="1700232917" id="2487503626">
|
||||
<contentItem source="LOCAL_MUSIC" type="track" location="track:2590" sourceAccount="3f205110-4a57-4e91-810a-123456789012" isPresetable="true">
|
||||
<itemName>Baby It's Cold Outside - ANNE MURRAY</itemName>
|
||||
</contentItem>
|
||||
</recent>
|
||||
</recents>`,
|
||||
wantResponse: &models.RecentsResponse{
|
||||
Items: []models.RecentsResponseItem{
|
||||
{
|
||||
DeviceID: "1004567890AA",
|
||||
UTCTime: 1701202831,
|
||||
ContentItem: &models.ContentItem{
|
||||
Source: "STORED_MUSIC",
|
||||
Location: "6_a2874b5d_4f83d999",
|
||||
SourceAccount: "d09708a1-5953-44bc-a413-123456789012/0",
|
||||
IsPresetable: true,
|
||||
ItemName: "MercyMe, It's Christmas!",
|
||||
},
|
||||
},
|
||||
{
|
||||
DeviceID: "1004567890AA",
|
||||
UTCTime: 1700232917,
|
||||
ID: "2487503626",
|
||||
ContentItem: &models.ContentItem{
|
||||
Source: "LOCAL_MUSIC",
|
||||
Type: "track",
|
||||
Location: "track:2590",
|
||||
SourceAccount: "3f205110-4a57-4e91-810a-123456789012",
|
||||
IsPresetable: true,
|
||||
ItemName: "Baby It's Cold Outside - ANNE MURRAY",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "empty recents response",
|
||||
statusCode: http.StatusOK,
|
||||
responseXML: `<?xml version="1.0" encoding="UTF-8" ?>
|
||||
<recents>
|
||||
</recents>`,
|
||||
wantResponse: &models.RecentsResponse{
|
||||
Items: []models.RecentsResponseItem{},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "spotify recents with artwork",
|
||||
statusCode: http.StatusOK,
|
||||
responseXML: `<?xml version="1.0" encoding="UTF-8" ?>
|
||||
<recents>
|
||||
<recent deviceID="1004567890AA" utcTime="1701300000" id="spotify123">
|
||||
<contentItem source="SPOTIFY" type="track" location="spotify:track:4iV5W9uYEdYUVa79Axb7Rh" sourceAccount="spotify_user" isPresetable="true">
|
||||
<itemName>Shape of You - Ed Sheeran</itemName>
|
||||
<containerArt>https://i.scdn.co/image/ab67616d0000b273ba5db46f4b838ef6027e6f96</containerArt>
|
||||
</contentItem>
|
||||
</recent>
|
||||
<recent deviceID="1004567890AA" utcTime="1701250000" id="spotify124">
|
||||
<contentItem source="SPOTIFY" type="playlist" location="spotify:playlist:37i9dQZF1DXcBWIGoYBM5M" sourceAccount="spotify_user" isPresetable="true">
|
||||
<itemName>Today's Top Hits</itemName>
|
||||
<containerArt>https://i.scdn.co/image/ab67706f00000002ca5a7517156021292e5663a6</containerArt>
|
||||
</contentItem>
|
||||
</recent>
|
||||
</recents>`,
|
||||
wantResponse: &models.RecentsResponse{
|
||||
Items: []models.RecentsResponseItem{
|
||||
{
|
||||
DeviceID: "1004567890AA",
|
||||
UTCTime: 1701300000,
|
||||
ID: "spotify123",
|
||||
ContentItem: &models.ContentItem{
|
||||
Source: "SPOTIFY",
|
||||
Type: "track",
|
||||
Location: "spotify:track:4iV5W9uYEdYUVa79Axb7Rh",
|
||||
SourceAccount: "spotify_user",
|
||||
IsPresetable: true,
|
||||
ItemName: "Shape of You - Ed Sheeran",
|
||||
ContainerArt: "https://i.scdn.co/image/ab67616d0000b273ba5db46f4b838ef6027e6f96",
|
||||
},
|
||||
},
|
||||
{
|
||||
DeviceID: "1004567890AA",
|
||||
UTCTime: 1701250000,
|
||||
ID: "spotify124",
|
||||
ContentItem: &models.ContentItem{
|
||||
Source: "SPOTIFY",
|
||||
Type: "playlist",
|
||||
Location: "spotify:playlist:37i9dQZF1DXcBWIGoYBM5M",
|
||||
SourceAccount: "spotify_user",
|
||||
IsPresetable: true,
|
||||
ItemName: "Today's Top Hits",
|
||||
ContainerArt: "https://i.scdn.co/image/ab67706f00000002ca5a7517156021292e5663a6",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "tunein radio station",
|
||||
statusCode: http.StatusOK,
|
||||
responseXML: `<?xml version="1.0" encoding="UTF-8" ?>
|
||||
<recents>
|
||||
<recent deviceID="1004567890AA" utcTime="1701400000">
|
||||
<contentItem source="TUNEIN" type="stationurl" location="tunein:station:s24939" sourceAccount="tunein" isPresetable="true">
|
||||
<itemName>BBC Radio 1</itemName>
|
||||
</contentItem>
|
||||
</recent>
|
||||
</recents>`,
|
||||
wantResponse: &models.RecentsResponse{
|
||||
Items: []models.RecentsResponseItem{
|
||||
{
|
||||
DeviceID: "1004567890AA",
|
||||
UTCTime: 1701400000,
|
||||
ContentItem: &models.ContentItem{
|
||||
Source: "TUNEIN",
|
||||
Type: "stationurl",
|
||||
Location: "tunein:station:s24939",
|
||||
SourceAccount: "tunein",
|
||||
IsPresetable: true,
|
||||
ItemName: "BBC Radio 1",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "http error",
|
||||
statusCode: http.StatusInternalServerError,
|
||||
responseXML: "",
|
||||
expectedError: "failed to get recent items:",
|
||||
},
|
||||
{
|
||||
name: "malformed xml",
|
||||
statusCode: http.StatusOK,
|
||||
responseXML: `<invalid>xml</malformed>`,
|
||||
expectedError: "failed to get recent items:",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
// Verify request method and path
|
||||
if r.Method != "GET" {
|
||||
t.Errorf("expected GET request, got %s", r.Method)
|
||||
}
|
||||
if r.URL.Path != "/recents" {
|
||||
t.Errorf("expected /recents path, got %s", r.URL.Path)
|
||||
}
|
||||
|
||||
if tt.statusCode != http.StatusOK {
|
||||
w.WriteHeader(tt.statusCode)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/xml")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
w.Write([]byte(tt.responseXML))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
config := &Config{
|
||||
Host: server.URL[7:], // Remove "http://" prefix
|
||||
Port: 80,
|
||||
}
|
||||
client := NewClient(config)
|
||||
// Override the base URL to use test server
|
||||
client.baseURL = server.URL
|
||||
|
||||
response, err := client.GetRecents()
|
||||
|
||||
if tt.expectedError != "" {
|
||||
if err == nil {
|
||||
t.Errorf("expected error containing %q, got nil", tt.expectedError)
|
||||
return
|
||||
}
|
||||
if !containsString(err.Error(), tt.expectedError) {
|
||||
t.Errorf("expected error containing %q, got %q", tt.expectedError, err.Error())
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
t.Errorf("unexpected error: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
if response == nil {
|
||||
t.Error("expected response, got nil")
|
||||
return
|
||||
}
|
||||
|
||||
// Verify response structure
|
||||
if len(response.Items) != len(tt.wantResponse.Items) {
|
||||
t.Errorf("expected %d items, got %d", len(tt.wantResponse.Items), len(response.Items))
|
||||
}
|
||||
|
||||
// Verify each item
|
||||
for i, expectedItem := range tt.wantResponse.Items {
|
||||
if i >= len(response.Items) {
|
||||
break
|
||||
}
|
||||
|
||||
actualItem := response.Items[i]
|
||||
|
||||
if actualItem.DeviceID != expectedItem.DeviceID {
|
||||
t.Errorf("item %d: expected deviceID %s, got %s", i, expectedItem.DeviceID, actualItem.DeviceID)
|
||||
}
|
||||
if actualItem.UTCTime != expectedItem.UTCTime {
|
||||
t.Errorf("item %d: expected utcTime %d, got %d", i, expectedItem.UTCTime, actualItem.UTCTime)
|
||||
}
|
||||
if actualItem.ID != expectedItem.ID {
|
||||
t.Errorf("item %d: expected id %s, got %s", i, expectedItem.ID, actualItem.ID)
|
||||
}
|
||||
|
||||
// Verify ContentItem
|
||||
if expectedItem.ContentItem != nil {
|
||||
if actualItem.ContentItem == nil {
|
||||
t.Errorf("item %d: expected contentItem, got nil", i)
|
||||
continue
|
||||
}
|
||||
|
||||
if actualItem.ContentItem.Source != expectedItem.ContentItem.Source {
|
||||
t.Errorf("item %d: expected source %s, got %s", i, expectedItem.ContentItem.Source, actualItem.ContentItem.Source)
|
||||
}
|
||||
if actualItem.ContentItem.Type != expectedItem.ContentItem.Type {
|
||||
t.Errorf("item %d: expected type %s, got %s", i, expectedItem.ContentItem.Type, actualItem.ContentItem.Type)
|
||||
}
|
||||
if actualItem.ContentItem.Location != expectedItem.ContentItem.Location {
|
||||
t.Errorf("item %d: expected location %s, got %s", i, expectedItem.ContentItem.Location, actualItem.ContentItem.Location)
|
||||
}
|
||||
if actualItem.ContentItem.ItemName != expectedItem.ContentItem.ItemName {
|
||||
t.Errorf("item %d: expected itemName %s, got %s", i, expectedItem.ContentItem.ItemName, actualItem.ContentItem.ItemName)
|
||||
}
|
||||
if actualItem.ContentItem.IsPresetable != expectedItem.ContentItem.IsPresetable {
|
||||
t.Errorf("item %d: expected isPresetable %t, got %t", i, expectedItem.ContentItem.IsPresetable, actualItem.ContentItem.IsPresetable)
|
||||
}
|
||||
if actualItem.ContentItem.ContainerArt != expectedItem.ContentItem.ContainerArt {
|
||||
t.Errorf("item %d: expected containerArt %s, got %s", i, expectedItem.ContentItem.ContainerArt, actualItem.ContentItem.ContainerArt)
|
||||
}
|
||||
} else if actualItem.ContentItem != nil {
|
||||
t.Errorf("item %d: expected nil contentItem, got non-nil", i)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestRecentsResponse_MethodsIntegration(t *testing.T) {
|
||||
// Test the response methods with a realistic response
|
||||
xmlData := `<recents>
|
||||
<recent deviceID="1004567890AA" utcTime="1701300000" id="1">
|
||||
<contentItem source="SPOTIFY" type="track" location="spotify:track:123" isPresetable="true">
|
||||
<itemName>Spotify Track</itemName>
|
||||
</contentItem>
|
||||
</recent>
|
||||
<recent deviceID="1004567890AA" utcTime="1701200000" id="2">
|
||||
<contentItem source="LOCAL_MUSIC" type="track" location="/music/local.mp3" isPresetable="false">
|
||||
<itemName>Local Track</itemName>
|
||||
</contentItem>
|
||||
</recent>
|
||||
<recent deviceID="1004567890AA" utcTime="1701100000" id="3">
|
||||
<contentItem source="TUNEIN" type="stationurl" location="tunein:station:123" isPresetable="true">
|
||||
<itemName>Radio Station</itemName>
|
||||
</contentItem>
|
||||
</recent>
|
||||
<recent deviceID="1004567890AA" utcTime="1701000000" id="4">
|
||||
<contentItem source="PANDORA" type="track" location="pandora:track:456" isPresetable="true">
|
||||
<itemName>Pandora Track</itemName>
|
||||
</contentItem>
|
||||
</recent>
|
||||
</recents>`
|
||||
|
||||
var response models.RecentsResponse
|
||||
err := xml.Unmarshal([]byte(xmlData), &response)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to unmarshal test data: %v", err)
|
||||
}
|
||||
|
||||
// Test various filtering methods
|
||||
tests := []struct {
|
||||
name string
|
||||
method func() interface{}
|
||||
expected interface{}
|
||||
}{
|
||||
{"GetItemCount", func() interface{} { return response.GetItemCount() }, 4},
|
||||
{"IsEmpty", func() interface{} { return response.IsEmpty() }, false},
|
||||
{"GetSpotifyItems count", func() interface{} { return len(response.GetSpotifyItems()) }, 1},
|
||||
{"GetLocalMusicItems count", func() interface{} { return len(response.GetLocalMusicItems()) }, 1},
|
||||
{"GetTuneInItems count", func() interface{} { return len(response.GetTuneInItems()) }, 1},
|
||||
{"GetPandoraItems count", func() interface{} { return len(response.GetPandoraItems()) }, 1},
|
||||
{"GetTracks count", func() interface{} { return len(response.GetTracks()) }, 3},
|
||||
{"GetStations count", func() interface{} { return len(response.GetStations()) }, 1},
|
||||
{"GetPresetableItems count", func() interface{} { return len(response.GetPresetableItems()) }, 3},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
result := tt.method()
|
||||
if result != tt.expected {
|
||||
t.Errorf("expected %v, got %v", tt.expected, result)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// Test most recent item
|
||||
mostRecent := response.GetMostRecent()
|
||||
if mostRecent == nil {
|
||||
t.Error("expected most recent item, got nil")
|
||||
} else {
|
||||
if mostRecent.GetDisplayName() != "Spotify Track" {
|
||||
t.Errorf("expected most recent to be 'Spotify Track', got %s", mostRecent.GetDisplayName())
|
||||
}
|
||||
if mostRecent.GetUTCTime() != 1701300000 {
|
||||
t.Errorf("expected most recent UTC time 1701300000, got %d", mostRecent.GetUTCTime())
|
||||
}
|
||||
}
|
||||
|
||||
// Test individual item methods
|
||||
for i, item := range response.Items {
|
||||
t.Run(t.Name()+"/item_"+item.GetID(), func(t *testing.T) {
|
||||
if !item.HasContent() {
|
||||
t.Error("expected item to have content")
|
||||
}
|
||||
if item.GetDisplayName() == "" {
|
||||
t.Error("expected item to have display name")
|
||||
}
|
||||
if item.GetSource() == "" {
|
||||
t.Error("expected item to have source")
|
||||
}
|
||||
if item.GetUTCTime() == 0 {
|
||||
t.Error("expected item to have UTC time")
|
||||
}
|
||||
|
||||
// Test specific item properties
|
||||
switch i {
|
||||
case 0: // Spotify track
|
||||
if !item.IsSpotifyContent() {
|
||||
t.Error("expected first item to be Spotify content")
|
||||
}
|
||||
if !item.IsTrack() {
|
||||
t.Error("expected first item to be a track")
|
||||
}
|
||||
if !item.IsStreamingContent() {
|
||||
t.Error("expected first item to be streaming content")
|
||||
}
|
||||
case 1: // Local music
|
||||
if !item.IsLocalContent() {
|
||||
t.Error("expected second item to be local content")
|
||||
}
|
||||
if item.IsStreamingContent() {
|
||||
t.Error("expected second item to not be streaming content")
|
||||
}
|
||||
case 2: // TuneIn station
|
||||
if !item.IsStation() {
|
||||
t.Error("expected third item to be a station")
|
||||
}
|
||||
if item.IsTrack() {
|
||||
t.Error("expected third item to not be a track")
|
||||
}
|
||||
case 3: // Pandora track
|
||||
if !item.IsStreamingContent() {
|
||||
t.Error("expected fourth item to be streaming content")
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user