mirror of
https://github.com/gesellix/Bose-SoundTouch.git
synced 2026-08-19 00:56:16 +00:00
This commit completes the music service account management implementation and resolves all golangci-lint issues across the codebase. Music Service Account Management: • Add/remove accounts for all major streaming services (Spotify, Pandora, Amazon Music, Deezer, iHeartRadio) • Support for network music libraries (NAS/UPnP/DLNA servers) • Generic account management with service-specific convenience methods • Full CLI integration with 14 account management commands • Comprehensive test coverage with mock HTTP servers • Complete API documentation and usage examples New CLI Commands: • account list - List configured accounts • account add/remove - Generic account management • account add-spotify/remove-spotify - Spotify Premium • account add-pandora/remove-pandora - Pandora Music Service • account add-amazon/remove-amazon - Amazon Music • account add-deezer/remove-deezer - Deezer Premium • account add-iheart/remove-iheart - iHeartRadio • account add-nas/remove-nas - Network music libraries New API Methods: • SetMusicServiceAccount() / RemoveMusicServiceAccount() - Generic methods • AddSpotifyAccount() / RemoveSpotifyAccount() - Convenience methods • AddPandoraAccount() / RemovePandoraAccount() - Convenience methods • AddAmazonMusicAccount() / RemoveAmazonMusicAccount() - Convenience methods • AddDeezerAccount() / RemoveDeezerAccount() - Convenience methods • AddIHeartRadioAccount() / RemoveIHeartRadioAccount() - Convenience methods • AddStoredMusicAccount() / RemoveStoredMusicAccount() - Network libraries golangci-lint Fixes (36 issues resolved): • errcheck (3): Fixed unchecked w.Write() returns in tests • gocritic (3): Rewrote if-else chains to switch statements • gocyclo (6): Reduced cyclomatic complexity via helper function extraction • govet (12): Removed unused test data and field assignments • revive (6): Added package comments and fixed unused parameters • staticcheck (2): Replaced deprecated strings.Title usage • thelper (6): Added t.Helper() calls to test helper functions • unused (1): Removed unused createTestApp() function • whitespace/wsl_v5 (7): Fixed whitespace and formatting issues Code Quality Improvements: • All functions now have complexity < 15 (down from max 28) • Consistent error handling and validation patterns • Better separation of concerns with extracted helper functions • Zero external dependencies added for simple fixes • Comprehensive documentation with usage examples • Full backward compatibility maintained Files Added: • pkg/models/account.go - Account management models • pkg/models/account_test.go - Account model tests • pkg/client/account_test.go - Account client tests • cmd/soundtouch-cli/cmd_account.go - Account CLI commands • examples/account-management/ - Complete usage example • Updated docs/CLI-REFERENCE.md with account management section The implementation provides a complete, production-ready music service account management system with full CLI and programmatic API support.
383 lines
11 KiB
Go
383 lines
11 KiB
Go
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)
|
|
}
|
|
})
|
|
}
|
|
}
|