feat: implement complete /storePreset and /removePreset functionality

- Add StorePreset, StoreCurrentAsPreset, and RemovePreset methods to client
- Create comprehensive preset management CLI with subcommands:
  * preset store-current --slot N (store currently playing content)
  * preset store --slot N --source X --location Y (store specific content)
  * preset remove --slot N (remove preset)
  * preset select --slot N (select/play preset)
  * preset list (list all presets)
- Fix WebSocket event handling for preset updates:
  * Correct event type from 'presetUpdated' to 'presetsUpdated'
  * Update event structure to handle complete preset list
  * Improve WebSocket demo display for preset events
- Add comprehensive test coverage for all new client methods
- Fix mock server URL mismatch in tests (/now_playing vs /nowPlaying)
- Add proper input validation and error handling
- Support all content sources: SPOTIFY, TUNEIN, LOCAL_INTERNET_RADIO, etc.

Successfully tested with real SoundTouch device:
- Storing Spotify content as presets 
- Removing presets 
- Real-time WebSocket events 
- CLI usability and error handling 

Resolves #14 - Complete /storePreset implementation
This commit is contained in:
Tobias Gesellchen
2026-01-30 23:12:45 +01:00
parent 63de381411
commit 90f5a53ef4
6 changed files with 976 additions and 20 deletions
+218
View File
@@ -0,0 +1,218 @@
package main
import (
"fmt"
"github.com/gesellix/bose-soundtouch/pkg/models"
"github.com/urfave/cli/v2"
)
// storeCurrentPreset handles storing currently playing content as preset
func storeCurrentPreset(c *cli.Context) error {
slot := c.Int("slot")
clientConfig := GetClientConfig(c)
PrintDeviceHeader(fmt.Sprintf("Storing current content as preset %d", slot), clientConfig.Host, clientConfig.Port)
client, err := CreateSoundTouchClient(clientConfig)
if err != nil {
PrintError(fmt.Sprintf("Failed to create client: %v", err))
return err
}
// Check what's currently playing
nowPlaying, err := client.GetNowPlaying()
if err != nil {
PrintError(fmt.Sprintf("Failed to get current content: %v", err))
return err
}
if nowPlaying.IsEmpty() {
PrintError("No content currently playing")
return fmt.Errorf("no content currently playing")
}
if nowPlaying.ContentItem == nil {
PrintError("Current content has no preset information")
return fmt.Errorf("current content cannot be saved as preset")
}
if !nowPlaying.ContentItem.IsPresetable {
PrintError("Current content cannot be saved as preset")
fmt.Printf(" Content: %s\n", nowPlaying.Track)
fmt.Printf(" Source: %s\n", nowPlaying.Source)
return fmt.Errorf("current content cannot be preset")
}
// Show what we're about to store
fmt.Printf("Current Content:\n")
fmt.Printf(" Track: %s\n", nowPlaying.Track)
if nowPlaying.Artist != "" {
fmt.Printf(" Artist: %s\n", nowPlaying.Artist)
}
if nowPlaying.Album != "" {
fmt.Printf(" Album: %s\n", nowPlaying.Album)
}
fmt.Printf(" Source: %s\n", nowPlaying.Source)
if nowPlaying.ContentItem.Location != "" {
fmt.Printf(" Location: %s\n", nowPlaying.ContentItem.Location)
}
// Store as preset
err = client.StoreCurrentAsPreset(slot)
if err != nil {
PrintError(fmt.Sprintf("Failed to store preset: %v", err))
return err
}
PrintSuccess(fmt.Sprintf("Stored current content as preset %d", slot))
return nil
}
// storePreset handles storing specific content as preset
func storePreset(c *cli.Context) error {
slot := c.Int("slot")
source := c.String("source")
location := c.String("location")
sourceAccount := c.String("source-account")
name := c.String("name")
itemType := c.String("type")
artwork := c.String("artwork")
clientConfig := GetClientConfig(c)
if source == "" {
return fmt.Errorf("source is required (use --source)")
}
if location == "" {
return fmt.Errorf("location is required (use --location)")
}
PrintDeviceHeader(fmt.Sprintf("Storing %s content as preset %d", source, slot), clientConfig.Host, clientConfig.Port)
client, err := CreateSoundTouchClient(clientConfig)
if err != nil {
PrintError(fmt.Sprintf("Failed to create client: %v", err))
return err
}
// Create content item
contentItem := &models.ContentItem{
Source: source,
Type: itemType,
Location: location,
SourceAccount: sourceAccount,
IsPresetable: true,
ItemName: name,
ContainerArt: artwork,
}
// Set default type if not specified
if itemType == "" {
switch source {
case "SPOTIFY":
contentItem.Type = "uri"
case "TUNEIN", "LOCAL_INTERNET_RADIO":
contentItem.Type = "stationurl"
default:
contentItem.Type = ""
}
}
// Show what we're storing
fmt.Printf("Content to store:\n")
fmt.Printf(" Name: %s\n", name)
fmt.Printf(" Source: %s\n", source)
fmt.Printf(" Location: %s\n", location)
if sourceAccount != "" {
fmt.Printf(" Source Account: %s\n", sourceAccount)
}
if itemType != "" {
fmt.Printf(" Type: %s\n", itemType)
}
// Store preset
err = client.StorePreset(slot, contentItem)
if err != nil {
PrintError(fmt.Sprintf("Failed to store preset: %v", err))
return err
}
PrintSuccess(fmt.Sprintf("Stored content as preset %d", slot))
return nil
}
// removePreset handles removing a preset
func removePreset(c *cli.Context) error {
slot := c.Int("slot")
clientConfig := GetClientConfig(c)
PrintDeviceHeader(fmt.Sprintf("Removing preset %d", slot), clientConfig.Host, clientConfig.Port)
client, err := CreateSoundTouchClient(clientConfig)
if err != nil {
PrintError(fmt.Sprintf("Failed to create client: %v", err))
return err
}
// Check if preset exists first
presets, err := client.GetPresets()
if err != nil {
PrintError(fmt.Sprintf("Failed to get presets: %v", err))
return err
}
preset := presets.GetPresetByID(slot)
if preset == nil || preset.IsEmpty() {
PrintError(fmt.Sprintf("Preset %d is already empty", slot))
return fmt.Errorf("preset %d does not exist", slot)
}
// Show what we're removing
fmt.Printf("Removing preset %d:\n", slot)
fmt.Printf(" Name: %s\n", preset.GetDisplayName())
fmt.Printf(" Source: %s\n", preset.GetSource())
// Remove preset
err = client.RemovePreset(slot)
if err != nil {
PrintError(fmt.Sprintf("Failed to remove preset: %v", err))
return err
}
PrintSuccess(fmt.Sprintf("Removed preset %d", slot))
return nil
}
// selectPresetNew handles selecting a preset (new version that works with subcommands)
func selectPresetNew(c *cli.Context) error {
slot := c.Int("slot")
clientConfig := GetClientConfig(c)
PrintDeviceHeader(fmt.Sprintf("Selecting preset %d", slot), clientConfig.Host, clientConfig.Port)
client, err := CreateSoundTouchClient(clientConfig)
if err != nil {
PrintError(fmt.Sprintf("Failed to create client: %v", err))
return err
}
err = client.SelectPreset(slot)
if err != nil {
PrintError(fmt.Sprintf("Failed to select preset: %v", err))
return err
}
PrintSuccess(fmt.Sprintf("Preset %d selected", slot))
return nil
}
// listPresets handles listing all presets (alias for existing getPresets command)
func listPresets(c *cli.Context) error {
return getPresets(c)
}
+86 -9
View File
@@ -237,17 +237,94 @@ func main() {
},
// Preset commands
{
Name: "preset",
Usage: "Select preset by number",
Action: selectPreset,
Flags: []cli.Flag{
&cli.IntFlag{
Name: "preset",
Usage: "Preset number (1-6)",
Required: true,
Name: "preset",
Usage: "Preset management commands",
Subcommands: []*cli.Command{
{
Name: "store-current",
Usage: "Store currently playing content as preset",
Action: storeCurrentPreset,
Flags: []cli.Flag{
&cli.IntFlag{
Name: "slot",
Usage: "Preset slot number (1-6)",
Required: true,
},
},
Before: RequireHost,
},
{
Name: "store",
Usage: "Store specific content as preset",
Action: storePreset,
Flags: []cli.Flag{
&cli.IntFlag{
Name: "slot",
Usage: "Preset slot number (1-6)",
Required: true,
},
&cli.StringFlag{
Name: "source",
Usage: "Content source (SPOTIFY, TUNEIN, LOCAL_INTERNET_RADIO, etc.)",
Required: true,
},
&cli.StringFlag{
Name: "location",
Usage: "Content location (URI, URL, or ID)",
Required: true,
},
&cli.StringFlag{
Name: "source-account",
Usage: "Source account (username, device ID, etc.)",
},
&cli.StringFlag{
Name: "name",
Usage: "Display name for the preset",
},
&cli.StringFlag{
Name: "type",
Usage: "Content type (uri, stationurl, etc.)",
},
&cli.StringFlag{
Name: "artwork",
Usage: "Artwork URL",
},
},
Before: RequireHost,
},
{
Name: "remove",
Usage: "Remove a preset",
Action: removePreset,
Flags: []cli.Flag{
&cli.IntFlag{
Name: "slot",
Usage: "Preset slot number (1-6)",
Required: true,
},
},
Before: RequireHost,
},
{
Name: "select",
Usage: "Select and play a preset",
Action: selectPresetNew,
Flags: []cli.Flag{
&cli.IntFlag{
Name: "slot",
Usage: "Preset slot number (1-6)",
Required: true,
},
},
Before: RequireHost,
},
{
Name: "list",
Usage: "List all presets",
Action: listPresets,
Before: RequireHost,
},
},
Before: RequireHost,
},
// Key commands
{
+15 -7
View File
@@ -342,17 +342,25 @@ func handleConnection(event *models.ConnectionStateUpdatedEvent) {
}
func handlePreset(event *models.PresetUpdatedEvent, verbose bool) {
preset := &event.Preset
fmt.Printf("\n📻 Preset Update [%s]:\n", event.DeviceID)
fmt.Printf(" 📻 Preset: %d\n", preset.ID)
presets := &event.Presets
deviceHeader := "\n📻 Presets Update"
if event.DeviceID != "" {
deviceHeader += fmt.Sprintf(" [%s]", event.DeviceID)
}
fmt.Printf("%s:\n", deviceHeader)
fmt.Printf(" 📻 Total presets: %d\n", len(presets.Preset))
if preset.ContentItem != nil {
fmt.Printf(" 🎵 %s\n", preset.ContentItem.ItemName)
fmt.Printf(" 📻 Source: %s\n", preset.ContentItem.Source)
for _, preset := range presets.Preset {
fmt.Printf(" 📻 Preset %d:", preset.ID)
if preset.ContentItem != nil {
fmt.Printf(" %s", preset.ContentItem.ItemName)
fmt.Printf(" (%s)", preset.ContentItem.Source)
}
fmt.Println()
}
if verbose {
fmt.Printf(" 📱 Raw preset data: ID=%d\n", preset.ID)
fmt.Printf(" 📱 Raw presets data: %d total presets\n", len(presets.Preset))
}
}
+63
View File
@@ -316,6 +316,69 @@ func (c *Client) IsCurrentContentPresetable() (bool, error) {
return nowPlaying.ContentItem.IsPresetable, nil
}
// StorePreset saves content as a preset on the SoundTouch device
func (c *Client) StorePreset(id int, contentItem *models.ContentItem) error {
if id < 1 || id > 6 {
return fmt.Errorf("preset ID must be between 1 and 6, got %d", id)
}
if contentItem == nil {
return fmt.Errorf("content item cannot be nil")
}
now := time.Now().Unix()
preset := &models.Preset{
ID: id,
CreatedOn: &now,
UpdatedOn: &now,
ContentItem: contentItem,
}
err := c.post("/storePreset", preset)
if err != nil {
return fmt.Errorf("failed to store preset %d: %w", id, err)
}
return nil
}
// StoreCurrentAsPreset saves currently playing content as preset
func (c *Client) StoreCurrentAsPreset(id int) error {
if id < 1 || id > 6 {
return fmt.Errorf("preset ID must be between 1 and 6, got %d", id)
}
nowPlaying, err := c.GetNowPlaying()
if err != nil {
return fmt.Errorf("failed to get current content: %w", err)
}
if nowPlaying.IsEmpty() || nowPlaying.ContentItem == nil {
return fmt.Errorf("no content currently playing")
}
if !nowPlaying.ContentItem.IsPresetable {
return fmt.Errorf("current content cannot be saved as preset")
}
return c.StorePreset(id, nowPlaying.ContentItem)
}
// RemovePreset deletes a preset from the SoundTouch device
func (c *Client) RemovePreset(id int) error {
if id < 1 || id > 6 {
return fmt.Errorf("preset ID must be between 1 and 6, got %d", id)
}
preset := &models.Preset{ID: id}
err := c.post("/removePreset", preset)
if err != nil {
return fmt.Errorf("failed to remove preset %d: %w", id, err)
}
return nil
}
// SendKey sends a key press command to the device (press followed by release)
func (c *Client) SendKey(keyValue string) error {
if !models.IsValidKey(keyValue) {
+590
View File
@@ -0,0 +1,590 @@
package client
import (
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"github.com/gesellix/bose-soundtouch/pkg/models"
)
func TestClient_StorePreset(t *testing.T) {
tests := []struct {
name string
presetID int
contentItem *models.ContentItem
serverResponse string
serverStatus int
expectError bool
errorContains string
}{
{
name: "store_spotify_playlist_success",
presetID: 1,
contentItem: &models.ContentItem{
Source: "SPOTIFY",
Type: "uri",
Location: "spotify:playlist:37i9dQZF1DX0XUsuxWHRQd",
SourceAccount: "testuser",
IsPresetable: true,
ItemName: "My Playlist",
ContainerArt: "https://example.com/art.jpg",
},
serverResponse: `<?xml version="1.0" encoding="UTF-8"?><presets><preset id="1"><ContentItem source="SPOTIFY" type="uri" location="spotify:playlist:37i9dQZF1DX0XUsuxWHRQd" sourceAccount="testuser" isPresetable="true"><itemName>My Playlist</itemName></ContentItem></preset></presets>`,
serverStatus: http.StatusOK,
expectError: false,
},
{
name: "store_tunein_radio_success",
presetID: 2,
contentItem: &models.ContentItem{
Source: "TUNEIN",
Type: "stationurl",
Location: "/v1/playback/station/s33828",
IsPresetable: true,
ItemName: "K-LOVE Radio",
},
serverResponse: `<?xml version="1.0" encoding="UTF-8"?><presets><preset id="2"><ContentItem source="TUNEIN" type="stationurl" location="/v1/playback/station/s33828" isPresetable="true"><itemName>K-LOVE Radio</itemName></ContentItem></preset></presets>`,
serverStatus: http.StatusOK,
expectError: false,
},
{
name: "store_local_internet_radio_success",
presetID: 3,
contentItem: &models.ContentItem{
Source: "LOCAL_INTERNET_RADIO",
Type: "stationurl",
Location: "https://stream.example.com/radio",
IsPresetable: true,
ItemName: "Custom Radio",
},
serverResponse: `<?xml version="1.0" encoding="UTF-8"?><presets><preset id="3"><ContentItem source="LOCAL_INTERNET_RADIO" type="stationurl" location="https://stream.example.com/radio" isPresetable="true"><itemName>Custom Radio</itemName></ContentItem></preset></presets>`,
serverStatus: http.StatusOK,
expectError: false,
},
{
name: "invalid_preset_id_too_low",
presetID: 0,
contentItem: &models.ContentItem{Source: "SPOTIFY", Location: "test"},
expectError: true,
errorContains: "preset ID must be between 1 and 6",
},
{
name: "invalid_preset_id_too_high",
presetID: 7,
contentItem: &models.ContentItem{Source: "SPOTIFY", Location: "test"},
expectError: true,
errorContains: "preset ID must be between 1 and 6",
},
{
name: "nil_content_item",
presetID: 1,
contentItem: nil,
expectError: true,
errorContains: "content item cannot be nil",
},
{
name: "server_error_response",
presetID: 1,
contentItem: &models.ContentItem{Source: "SPOTIFY", Location: "test"},
serverResponse: `<?xml version="1.0" encoding="UTF-8"?><error>Invalid preset</error>`,
serverStatus: http.StatusBadRequest,
expectError: true,
errorContains: "failed to store preset 1",
},
}
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 endpoint
if r.Method != http.MethodPost {
t.Errorf("Expected POST request, got %s", r.Method)
}
if r.URL.Path != "/storePreset" {
t.Errorf("Expected /storePreset endpoint, got %s", r.URL.Path)
}
// Verify content type
if r.Header.Get("Content-Type") != "application/xml" {
t.Errorf("Expected Content-Type application/xml, got %s", r.Header.Get("Content-Type"))
}
// Return mock response
if tt.serverStatus != 0 {
w.WriteHeader(tt.serverStatus)
}
if tt.serverResponse != "" {
w.Write([]byte(tt.serverResponse))
}
}))
defer server.Close()
client := &Client{
baseURL: server.URL,
httpClient: &http.Client{Timeout: 10 * time.Second},
}
err := client.StorePreset(tt.presetID, tt.contentItem)
if tt.expectError {
if err == nil {
t.Errorf("Expected error, but got nil")
return
}
if tt.errorContains != "" && !strings.Contains(err.Error(), tt.errorContains) {
t.Errorf("Expected error to contain '%s', but got: %v", tt.errorContains, err)
}
} else {
if err != nil {
t.Errorf("Expected no error, but got: %v", err)
}
}
})
}
}
func TestClient_RemovePreset(t *testing.T) {
tests := []struct {
name string
presetID int
serverResponse string
serverStatus int
expectError bool
errorContains string
}{
{
name: "remove_preset_success",
presetID: 3,
serverResponse: `<?xml version="1.0" encoding="UTF-8"?><presets></presets>`,
serverStatus: http.StatusOK,
expectError: false,
},
{
name: "invalid_preset_id_too_low",
presetID: 0,
expectError: true,
errorContains: "preset ID must be between 1 and 6",
},
{
name: "invalid_preset_id_too_high",
presetID: 7,
expectError: true,
errorContains: "preset ID must be between 1 and 6",
},
{
name: "server_error_response",
presetID: 1,
serverResponse: `<?xml version="1.0" encoding="UTF-8"?><error>Preset not found</error>`,
serverStatus: http.StatusNotFound,
expectError: true,
errorContains: "failed to remove preset 1",
},
}
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 endpoint
if r.Method != http.MethodPost {
t.Errorf("Expected POST request, got %s", r.Method)
}
if r.URL.Path != "/removePreset" {
t.Errorf("Expected /removePreset endpoint, got %s", r.URL.Path)
}
// Return mock response
if tt.serverStatus != 0 {
w.WriteHeader(tt.serverStatus)
}
if tt.serverResponse != "" {
w.Write([]byte(tt.serverResponse))
}
}))
defer server.Close()
client := &Client{
baseURL: server.URL,
httpClient: &http.Client{Timeout: 10 * time.Second},
}
err := client.RemovePreset(tt.presetID)
if tt.expectError {
if err == nil {
t.Errorf("Expected error, but got nil")
return
}
if tt.errorContains != "" && !strings.Contains(err.Error(), tt.errorContains) {
t.Errorf("Expected error to contain '%s', but got: %v", tt.errorContains, err)
}
} else {
if err != nil {
t.Errorf("Expected no error, but got: %v", err)
}
}
})
}
}
func TestClient_StoreCurrentAsPreset(t *testing.T) {
tests := []struct {
name string
presetID int
nowPlayingResponse string
nowPlayingStatus int
storePresetStatus int
expectError bool
errorContains string
}{
{
name: "store_current_spotify_success",
presetID: 2,
nowPlayingResponse: `<?xml version="1.0" encoding="UTF-8"?>
<nowPlaying deviceID="TEST123" source="SPOTIFY" sourceAccount="testuser">
<ContentItem source="SPOTIFY" type="uri" location="spotify:track:123456789" sourceAccount="testuser" isPresetable="true">
<itemName>Test Track</itemName>
</ContentItem>
<track>Test Track</track>
<artist>Test Artist</artist>
<playStatus>PLAY_STATE</playStatus>
</nowPlaying>`,
nowPlayingStatus: http.StatusOK,
storePresetStatus: http.StatusOK,
expectError: false,
},
{
name: "store_current_tunein_success",
presetID: 1,
nowPlayingResponse: `<?xml version="1.0" encoding="UTF-8"?>
<nowPlaying deviceID="TEST123" source="TUNEIN">
<ContentItem source="TUNEIN" type="stationurl" location="/v1/playback/station/s33828" isPresetable="true">
<itemName>K-LOVE Radio</itemName>
</ContentItem>
<track>K-LOVE Radio</track>
<playStatus>PLAY_STATE</playStatus>
</nowPlaying>`,
nowPlayingStatus: http.StatusOK,
storePresetStatus: http.StatusOK,
expectError: false,
},
{
name: "empty_now_playing",
presetID: 1,
nowPlayingResponse: `<?xml version="1.0" encoding="UTF-8"?>
<nowPlaying deviceID="TEST123" source="STANDBY">
<ContentItem source="STANDBY" isPresetable="false" />
</nowPlaying>`,
nowPlayingStatus: http.StatusOK,
expectError: true,
errorContains: "no content currently playing",
},
{
name: "content_not_presetable",
presetID: 1,
nowPlayingResponse: `<?xml version="1.0" encoding="UTF-8"?>
<nowPlaying deviceID="TEST123" source="BLUETOOTH">
<ContentItem source="BLUETOOTH" isPresetable="false">
<itemName>Phone Audio</itemName>
</ContentItem>
<track>Phone Audio</track>
<playStatus>PLAY_STATE</playStatus>
</nowPlaying>`,
nowPlayingStatus: http.StatusOK,
expectError: true,
errorContains: "current content cannot be saved as preset",
},
{
name: "no_content_item",
presetID: 1,
nowPlayingResponse: `<?xml version="1.0" encoding="UTF-8"?>
<nowPlaying deviceID="TEST123" source="UNKNOWN">
<track>Unknown Track</track>
<playStatus>PLAY_STATE</playStatus>
</nowPlaying>`,
nowPlayingStatus: http.StatusOK,
expectError: true,
errorContains: "no content currently playing",
},
{
name: "now_playing_request_fails",
presetID: 1,
nowPlayingStatus: http.StatusInternalServerError,
expectError: true,
errorContains: "failed to get current content",
},
{
name: "invalid_preset_id",
presetID: 0,
nowPlayingResponse: `<?xml version="1.0" encoding="UTF-8"?>
<nowPlaying deviceID="TEST123" source="SPOTIFY">
<ContentItem source="SPOTIFY" type="uri" location="spotify:track:123" isPresetable="true">
<itemName>Test Track</itemName>
</ContentItem>
<track>Test Track</track>
<playStatus>PLAY_STATE</playStatus>
</nowPlaying>`,
nowPlayingStatus: http.StatusOK,
expectError: true,
errorContains: "preset ID must be between 1 and 6",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/now_playing":
if tt.nowPlayingStatus != 0 {
w.WriteHeader(tt.nowPlayingStatus)
} else {
w.WriteHeader(http.StatusOK)
}
if tt.nowPlayingResponse != "" {
w.Write([]byte(tt.nowPlayingResponse))
}
case "/storePreset":
if tt.storePresetStatus != 0 {
w.WriteHeader(tt.storePresetStatus)
} else {
w.WriteHeader(http.StatusOK)
}
w.Write([]byte(`<?xml version="1.0" encoding="UTF-8"?><presets></presets>`))
default:
w.WriteHeader(http.StatusNotFound)
}
}))
defer server.Close()
client := &Client{
baseURL: server.URL,
httpClient: &http.Client{Timeout: 10 * time.Second},
}
err := client.StoreCurrentAsPreset(tt.presetID)
if tt.expectError {
if err == nil {
t.Errorf("Expected error, but got nil")
return
}
if tt.errorContains != "" && !strings.Contains(err.Error(), tt.errorContains) {
t.Errorf("Expected error to contain '%s', but got: %v", tt.errorContains, err)
}
} else {
if err != nil {
t.Errorf("Expected no error, but got: %v", err)
}
}
})
}
}
func TestClient_StorePreset_XMLGeneration(t *testing.T) {
var capturedXML string
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
body := make([]byte, r.ContentLength)
r.Body.Read(body)
capturedXML = string(body)
w.Write([]byte(`<?xml version="1.0" encoding="UTF-8"?><presets></presets>`))
}))
defer server.Close()
client := &Client{
baseURL: server.URL,
httpClient: &http.Client{Timeout: 10 * time.Second},
}
contentItem := &models.ContentItem{
Source: "SPOTIFY",
Type: "uri",
Location: "spotify:playlist:37i9dQZF1DX0XUsuxWHRQd",
SourceAccount: "testuser",
IsPresetable: true,
ItemName: "Test Playlist",
ContainerArt: "https://example.com/art.jpg",
}
err := client.StorePreset(3, contentItem)
if err != nil {
t.Fatalf("StorePreset failed: %v", err)
}
// Verify XML structure
expectedElements := []string{
`<preset id="3"`,
`createdOn="`,
`updatedOn="`,
`<ContentItem source="SPOTIFY"`,
`type="uri"`,
`location="spotify:playlist:37i9dQZF1DX0XUsuxWHRQd"`,
`sourceAccount="testuser"`,
`isPresetable="true"`,
`<itemName>Test Playlist</itemName>`,
`<containerArt>https://example.com/art.jpg</containerArt>`,
}
for _, element := range expectedElements {
if !strings.Contains(capturedXML, element) {
t.Errorf("Expected XML to contain '%s', but got:\n%s", element, capturedXML)
}
}
}
func TestClient_RemovePreset_XMLGeneration(t *testing.T) {
var capturedXML string
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
body := make([]byte, r.ContentLength)
r.Body.Read(body)
capturedXML = string(body)
w.Write([]byte(`<?xml version="1.0" encoding="UTF-8"?><presets></presets>`))
}))
defer server.Close()
client := &Client{
baseURL: server.URL,
httpClient: &http.Client{Timeout: 10 * time.Second},
}
err := client.RemovePreset(4)
if err != nil {
t.Fatalf("RemovePreset failed: %v", err)
}
// Verify XML structure - should only contain preset ID
expectedElements := []string{
`<preset id="4"`,
}
for _, element := range expectedElements {
if !strings.Contains(capturedXML, element) {
t.Errorf("Expected XML to contain '%s', but got:\n%s", element, capturedXML)
}
}
// Should NOT contain content item for remove requests
unexpectedElements := []string{
`<ContentItem`,
`createdOn=`,
`updatedOn=`,
}
for _, element := range unexpectedElements {
if strings.Contains(capturedXML, element) {
t.Errorf("Did not expect XML to contain '%s', but got:\n%s", element, capturedXML)
}
}
}
func TestClient_StorePreset_RealWorldScenarios(t *testing.T) {
scenarios := []struct {
name string
contentItem *models.ContentItem
description string
}{
{
name: "spotify_daily_mix",
contentItem: &models.ContentItem{
Source: "SPOTIFY",
Type: "uri",
Location: "spotify:playlist:37i9dQZF1E35Ky0Qr5WjPT",
SourceAccount: "testuser",
IsPresetable: true,
ItemName: "Daily Mix 1",
ContainerArt: "https://dailymix-images.scdn.co/v2/img/ab6761610000e5eb1/1/en/default",
},
description: "User wants to save Spotify Daily Mix as preset",
},
{
name: "internet_radio_station",
contentItem: &models.ContentItem{
Source: "TUNEIN",
Type: "stationurl",
Location: "/v1/playback/station/s33828",
IsPresetable: true,
ItemName: "K-LOVE Radio",
ContainerArt: "http://cdn-profiles.tunein.com/s33828/images/logog.png",
},
description: "User wants to save favorite radio station",
},
{
name: "nas_music_album",
contentItem: &models.ContentItem{
Source: "STORED_MUSIC",
Location: "6_a2874b5d_4f83d999",
SourceAccount: "d09708a1-5953-44bc-a413-123456789012/0",
IsPresetable: true,
ItemName: "MercyMe, It's Christmas!",
},
description: "User wants to save NAS album as preset",
},
{
name: "pandora_station",
contentItem: &models.ContentItem{
Source: "PANDORA",
Location: "126740707481236361",
SourceAccount: "pandorauser",
IsPresetable: true,
ItemName: "Zach Williams Radio",
ContainerArt: "https://content-images.p-cdn.com/images/68/88/0d/fb/aed34095a11118d2aa7b02a2/_500W_500H.jpg",
},
description: "User wants to save Pandora station as preset",
},
}
for _, scenario := range scenarios {
t.Run(scenario.name, func(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// Just return success for these scenario tests
w.Write([]byte(`<?xml version="1.0" encoding="UTF-8"?><presets></presets>`))
}))
defer server.Close()
client := &Client{
baseURL: server.URL,
httpClient: &http.Client{Timeout: 10 * time.Second},
}
err := client.StorePreset(1, scenario.contentItem)
if err != nil {
t.Errorf("Scenario '%s' failed: %s. Error: %v", scenario.name, scenario.description, err)
}
})
}
}
func TestClient_PresetTimestamps(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Write([]byte(`<?xml version="1.0" encoding="UTF-8"?><presets></presets>`))
}))
defer server.Close()
client := &Client{
baseURL: server.URL,
httpClient: &http.Client{Timeout: 10 * time.Second},
}
contentItem := &models.ContentItem{
Source: "SPOTIFY",
Location: "spotify:track:test",
IsPresetable: true,
ItemName: "Test",
}
startTime := time.Now().Unix()
err := client.StorePreset(1, contentItem)
if err != nil {
t.Fatalf("StorePreset failed: %v", err)
}
endTime := time.Now().Unix()
// Timestamps should be set within the test timeframe
// This is a basic check - in a real scenario, we'd inspect the XML or server response
if endTime < startTime {
t.Error("Timestamps appear to be incorrect")
}
}
+4 -4
View File
@@ -18,7 +18,7 @@ const (
// EventTypeConnectionState indicates a connection state change
EventTypeConnectionState WebSocketEventType = "connectionStateUpdated"
// EventTypePresetUpdated indicates a preset configuration change
EventTypePresetUpdated WebSocketEventType = "presetUpdated"
EventTypePresetUpdated WebSocketEventType = "presetsUpdated"
// EventTypeZoneUpdated indicates a zone configuration change
EventTypeZoneUpdated WebSocketEventType = "zoneUpdated"
// EventTypeBassUpdated indicates a bass level change
@@ -78,7 +78,7 @@ type WebSocketEvent struct {
NowPlayingUpdated *NowPlayingUpdatedEvent `xml:"nowPlayingUpdated,omitempty"`
VolumeUpdated *VolumeUpdatedEvent `xml:"volumeUpdated,omitempty"`
ConnectionStateUpdated *ConnectionStateUpdatedEvent `xml:"connectionStateUpdated,omitempty"`
PresetUpdated *PresetUpdatedEvent `xml:"presetUpdated,omitempty"`
PresetUpdated *PresetUpdatedEvent `xml:"presetsUpdated,omitempty"`
ZoneUpdated *ZoneUpdatedEvent `xml:"zoneUpdated,omitempty"`
BassUpdated *BassUpdatedEvent `xml:"bassUpdated,omitempty"`
ClockTimeUpdated *ClockTimeUpdatedEvent `xml:"clockTimeUpdated,omitempty"`
@@ -195,9 +195,9 @@ func (cs *ConnectionState) GetSignalStrength() string {
// PresetUpdatedEvent represents a preset update event
type PresetUpdatedEvent struct {
XMLName xml.Name `xml:"presetUpdated"`
XMLName xml.Name `xml:"presetsUpdated"`
DeviceID string `xml:"deviceID,attr"`
Preset Preset `xml:"preset"`
Presets Presets `xml:"presets"`
}
// ZoneUpdatedEvent represents a multiroom zone update event