mirror of
https://github.com/gesellix/Bose-SoundTouch.git
synced 2026-08-18 08:36:13 +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.
483 lines
12 KiB
Go
483 lines
12 KiB
Go
package main
|
|
|
|
import (
|
|
"bytes"
|
|
"os"
|
|
"testing"
|
|
|
|
"github.com/gesellix/bose-soundtouch/pkg/models"
|
|
)
|
|
|
|
func TestIntrospectCommands(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
args []string
|
|
expectedOutput []string
|
|
expectError bool
|
|
}{
|
|
{
|
|
name: "introspect service with source flag",
|
|
args: []string{"soundtouch-cli", "--host", "192.168.1.100", "source", "introspect", "--source", "SPOTIFY"},
|
|
expectedOutput: []string{
|
|
"Getting introspect data for SPOTIFY",
|
|
"=== SPOTIFY Service Introspect Data ===",
|
|
"State: Active",
|
|
"User: test_user",
|
|
"Currently Playing: ✅ Yes",
|
|
"Current Content: spotify://track/123",
|
|
"Shuffle Mode: ON",
|
|
"Subscription Type: Premium",
|
|
"=== Service State ===",
|
|
"✅ Service is ACTIVE",
|
|
"🎵 Currently playing content",
|
|
"🔀 Shuffle mode is ON",
|
|
"=== Service Capabilities ===",
|
|
"✅ ⏮️ Skip Previous",
|
|
"✅ 🎯 Seek within tracks",
|
|
"✅ ▶️ Resume playback",
|
|
"🚫 Data collection: DISABLED",
|
|
"=== Spotify Content History ===",
|
|
"Max History Size: 15 items",
|
|
"=== Technical Details ===",
|
|
"Token Last Changed:",
|
|
"Token Timestamp: 1702566495",
|
|
"Play Status State: 2",
|
|
"Received Playback Request: ❌ No",
|
|
},
|
|
},
|
|
{
|
|
name: "introspect spotify convenience command",
|
|
args: []string{"soundtouch-cli", "--host", "192.168.1.100", "source", "introspect-spotify"},
|
|
expectedOutput: []string{
|
|
"Getting Spotify introspect data",
|
|
"=== Spotify Service Introspect Data ===",
|
|
"State: Active",
|
|
"User: test_user",
|
|
"=== Spotify Service State ===",
|
|
"✅ Service is ACTIVE",
|
|
"=== Spotify Service Capabilities ===",
|
|
},
|
|
},
|
|
{
|
|
name: "introspect with account parameter",
|
|
args: []string{"soundtouch-cli", "--host", "192.168.1.100", "source", "introspect", "--source", "SPOTIFY", "--account", "my_spotify_account"},
|
|
expectedOutput: []string{
|
|
"Getting introspect data for SPOTIFY",
|
|
"Source Account: my_spotify_account",
|
|
},
|
|
},
|
|
{
|
|
name: "introspect missing source flag",
|
|
args: []string{"soundtouch-cli", "--host", "192.168.1.100", "source", "introspect"},
|
|
expectError: true,
|
|
},
|
|
{
|
|
name: "introspect missing host",
|
|
args: []string{"soundtouch-cli", "source", "introspect", "--source", "SPOTIFY"},
|
|
expectError: true,
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
// Skip actual execution for now - these would need mock HTTP servers
|
|
// This test structure shows how the CLI commands would be tested
|
|
t.Skip("Integration test - requires mock HTTP server setup")
|
|
|
|
// Example of how you would set up the test:
|
|
// app := createTestApp()
|
|
//
|
|
// var buf bytes.Buffer
|
|
// app.Writer = &buf
|
|
// app.ErrWriter = &buf
|
|
//
|
|
// err := app.Run(tt.args)
|
|
//
|
|
// if tt.expectError {
|
|
// if err == nil {
|
|
// t.Error("expected error, got nil")
|
|
// }
|
|
// return
|
|
// }
|
|
//
|
|
// if err != nil {
|
|
// t.Fatalf("unexpected error: %v", err)
|
|
// }
|
|
//
|
|
// output := buf.String()
|
|
// for _, expected := range tt.expectedOutput {
|
|
// if !strings.Contains(output, expected) {
|
|
// t.Errorf("expected output to contain %q, got:\n%s", expected, output)
|
|
// }
|
|
// }
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestPrintIntrospectBasicInfo(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
response *models.IntrospectResponse
|
|
expected []string
|
|
}{
|
|
{
|
|
name: "active spotify response",
|
|
response: &models.IntrospectResponse{
|
|
State: "Active",
|
|
User: "test_user",
|
|
IsPlaying: true,
|
|
ShuffleMode: "ON",
|
|
CurrentURI: "spotify://track/123",
|
|
SubscriptionType: "Premium",
|
|
},
|
|
expected: []string{
|
|
"State: Active",
|
|
"User: test_user",
|
|
"Currently Playing: ✅ Yes",
|
|
"Current Content: spotify://track/123",
|
|
"Shuffle Mode: ON",
|
|
"Subscription Type: Premium",
|
|
},
|
|
},
|
|
{
|
|
name: "inactive response",
|
|
response: &models.IntrospectResponse{
|
|
State: "InactiveUnselected",
|
|
User: "",
|
|
IsPlaying: false,
|
|
ShuffleMode: "OFF",
|
|
CurrentURI: "",
|
|
},
|
|
expected: []string{
|
|
"State: InactiveUnselected",
|
|
"Currently Playing: ❌ No",
|
|
"Shuffle Mode: OFF",
|
|
},
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
// Capture stdout
|
|
oldStdout := os.Stdout
|
|
r, w, _ := os.Pipe()
|
|
os.Stdout = w
|
|
|
|
// Call the function
|
|
printIntrospectBasicInfo(tt.response)
|
|
|
|
// Restore stdout and read output
|
|
w.Close()
|
|
|
|
os.Stdout = oldStdout
|
|
|
|
var buf bytes.Buffer
|
|
|
|
_, err := buf.ReadFrom(r)
|
|
if err != nil {
|
|
t.Fatalf("failed to read output: %v", err)
|
|
}
|
|
|
|
output := buf.String()
|
|
|
|
// Check expected strings are present
|
|
for _, expected := range tt.expected {
|
|
if !containsSubstring(output, expected) {
|
|
t.Errorf("expected output to contain %q, got:\n%s", expected, output)
|
|
}
|
|
}
|
|
|
|
// Check unwanted strings are not present
|
|
if tt.response.User == "" && containsSubstring(output, "User:") {
|
|
t.Error("expected no user information when user is empty")
|
|
}
|
|
|
|
if tt.response.CurrentURI == "" && containsSubstring(output, "Current Content:") {
|
|
t.Error("expected no current content when URI is empty")
|
|
}
|
|
|
|
if tt.response.SubscriptionType == "" && containsSubstring(output, "Subscription Type:") {
|
|
t.Error("expected no subscription information when type is empty")
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestPrintIntrospectServiceState(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
response *models.IntrospectResponse
|
|
expected []string
|
|
}{
|
|
{
|
|
name: "active playing with shuffle",
|
|
response: &models.IntrospectResponse{
|
|
State: "Active",
|
|
IsPlaying: true,
|
|
ShuffleMode: "ON",
|
|
},
|
|
expected: []string{
|
|
"✅ Service is ACTIVE",
|
|
"🎵 Currently playing content",
|
|
"🔀 Shuffle mode is ON",
|
|
},
|
|
},
|
|
{
|
|
name: "inactive unselected",
|
|
response: &models.IntrospectResponse{
|
|
State: "InactiveUnselected",
|
|
IsPlaying: false,
|
|
ShuffleMode: "OFF",
|
|
},
|
|
expected: []string{
|
|
"❌ Service is INACTIVE (Never been used)",
|
|
"⏸️ Not currently playing",
|
|
"➡️ Shuffle mode is OFF",
|
|
},
|
|
},
|
|
{
|
|
name: "inactive but configured",
|
|
response: &models.IntrospectResponse{
|
|
State: "Inactive",
|
|
IsPlaying: false,
|
|
ShuffleMode: "OFF",
|
|
},
|
|
expected: []string{
|
|
"❌ Service is INACTIVE",
|
|
"⏸️ Not currently playing",
|
|
"➡️ Shuffle mode is OFF",
|
|
},
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
// Capture stdout
|
|
oldStdout := os.Stdout
|
|
r, w, _ := os.Pipe()
|
|
os.Stdout = w
|
|
|
|
// Call the function
|
|
printIntrospectServiceState(tt.response)
|
|
|
|
// Restore stdout and read output
|
|
w.Close()
|
|
|
|
os.Stdout = oldStdout
|
|
|
|
var buf bytes.Buffer
|
|
|
|
_, err := buf.ReadFrom(r)
|
|
if err != nil {
|
|
t.Fatalf("failed to read output: %v", err)
|
|
}
|
|
|
|
output := buf.String()
|
|
|
|
// Check expected strings are present
|
|
for _, expected := range tt.expected {
|
|
if !containsSubstring(output, expected) {
|
|
t.Errorf("expected output to contain %q, got:\n%s", expected, output)
|
|
}
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestPrintIntrospectCapabilities(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
response *models.IntrospectResponse
|
|
expected []string
|
|
}{
|
|
{
|
|
name: "full capabilities enabled",
|
|
response: &models.IntrospectResponse{
|
|
NowPlaying: &models.IntrospectNowPlaying{
|
|
SkipPreviousSupported: true,
|
|
SeekSupported: true,
|
|
ResumeSupported: true,
|
|
CollectData: true,
|
|
},
|
|
},
|
|
expected: []string{
|
|
"✅ ⏮️ Skip Previous",
|
|
"✅ 🎯 Seek within tracks",
|
|
"✅ ▶️ Resume playback",
|
|
"📊 Data collection: ENABLED",
|
|
},
|
|
},
|
|
{
|
|
name: "limited capabilities",
|
|
response: &models.IntrospectResponse{
|
|
NowPlaying: &models.IntrospectNowPlaying{
|
|
SkipPreviousSupported: false,
|
|
SeekSupported: false,
|
|
ResumeSupported: true,
|
|
CollectData: false,
|
|
},
|
|
},
|
|
expected: []string{
|
|
"❌ ⏮️ Skip Previous",
|
|
"❌ 🎯 Seek within tracks",
|
|
"✅ ▶️ Resume playback",
|
|
"🚫 Data collection: DISABLED",
|
|
},
|
|
},
|
|
{
|
|
name: "no capabilities info",
|
|
response: &models.IntrospectResponse{
|
|
NowPlaying: nil,
|
|
},
|
|
expected: []string{
|
|
"❌ ⏮️ Skip Previous",
|
|
"❌ 🎯 Seek within tracks",
|
|
"❌ ▶️ Resume playback",
|
|
"🚫 Data collection: DISABLED",
|
|
},
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
// Capture stdout
|
|
oldStdout := os.Stdout
|
|
r, w, _ := os.Pipe()
|
|
os.Stdout = w
|
|
|
|
// Call the function
|
|
printIntrospectCapabilities(tt.response)
|
|
|
|
// Restore stdout and read output
|
|
w.Close()
|
|
|
|
os.Stdout = oldStdout
|
|
|
|
var buf bytes.Buffer
|
|
|
|
_, err := buf.ReadFrom(r)
|
|
if err != nil {
|
|
t.Fatalf("failed to read output: %v", err)
|
|
}
|
|
|
|
output := buf.String()
|
|
|
|
// Check expected strings are present
|
|
for _, expected := range tt.expected {
|
|
if !containsSubstring(output, expected) {
|
|
t.Errorf("expected output to contain %q, got:\n%s", expected, output)
|
|
}
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestPrintIntrospectSummary(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
source string
|
|
response *models.IntrospectResponse
|
|
expected []string
|
|
}{
|
|
{
|
|
name: "full spotify summary",
|
|
source: "SPOTIFY",
|
|
response: &models.IntrospectResponse{
|
|
State: "Active",
|
|
User: "spotify_user",
|
|
IsPlaying: true,
|
|
CurrentURI: "spotify://track/very_long_track_uri_that_should_be_truncated_because_its_too_long_for_display",
|
|
NowPlaying: &models.IntrospectNowPlaying{
|
|
SkipPreviousSupported: true,
|
|
SeekSupported: true,
|
|
ResumeSupported: true,
|
|
},
|
|
},
|
|
expected: []string{
|
|
"State: Active (User: spotify_user)",
|
|
"Playing: ✅ Yes | Content: spotify://track/very_long_track_uri_that_should_be...",
|
|
"Capabilities: Skip, Seek, Resume",
|
|
},
|
|
},
|
|
{
|
|
name: "minimal summary",
|
|
source: "PANDORA",
|
|
response: &models.IntrospectResponse{
|
|
State: "Inactive",
|
|
IsPlaying: false,
|
|
},
|
|
expected: []string{
|
|
"State: Inactive",
|
|
"Playing: ❌ No",
|
|
"Capabilities: None",
|
|
},
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
// Capture stdout
|
|
oldStdout := os.Stdout
|
|
r, w, _ := os.Pipe()
|
|
os.Stdout = w
|
|
|
|
// Call the function
|
|
printIntrospectSummary(tt.source, tt.response)
|
|
|
|
// Restore stdout and read output
|
|
w.Close()
|
|
|
|
os.Stdout = oldStdout
|
|
|
|
var buf bytes.Buffer
|
|
|
|
_, err := buf.ReadFrom(r)
|
|
if err != nil {
|
|
t.Fatalf("failed to read output: %v", err)
|
|
}
|
|
|
|
output := buf.String()
|
|
|
|
// Check expected strings are present
|
|
for _, expected := range tt.expected {
|
|
if !containsSubstring(output, expected) {
|
|
t.Errorf("expected output to contain %q, got:\n%s", expected, output)
|
|
}
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestFormatBooleanStatus(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
value bool
|
|
expected string
|
|
}{
|
|
{
|
|
name: "true value",
|
|
value: true,
|
|
expected: "✅ Yes",
|
|
},
|
|
{
|
|
name: "false value",
|
|
value: false,
|
|
expected: "❌ No",
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
result := formatBooleanStatus(tt.value)
|
|
if result != tt.expected {
|
|
t.Errorf("expected %q, got %q", tt.expected, result)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// Helper function to check if output contains a substring
|
|
func containsSubstring(output, substring string) bool {
|
|
return bytes.Contains([]byte(output), []byte(substring))
|
|
}
|