Files
Tobias Gesellchen 285f85efa2 feat: implement comprehensive music service account management with full golangci-lint compliance
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.
2026-02-02 17:44:26 +01:00

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)
}
})
}
}