From 210fd587de2437b4408e9df2936b29dbb3be8e60 Mon Sep 17 00:00:00 2001 From: Tobias Gesellchen Date: Sat, 7 Feb 2026 20:46:09 +0100 Subject: [PATCH] chore: run golangci-lint --fix and manually address remaining linting issues. Fixed bodyclose, errcheck, and contextcheck across the codebase. --- cmd/soundtouch-service/main.go | 7 +- examples/service-demo/main.go | 9 ++- pkg/service/bmx/bmx.go | 23 +++++-- pkg/service/bmx/bmx_test.go | 8 +++ pkg/service/constants/constants_test.go | 2 + pkg/service/datastore/datastore.go | 72 +++++++++++++++++--- pkg/service/datastore/datastore_test.go | 50 ++++++++++---- pkg/service/handlers/handlers_bmx.go | 18 +++-- pkg/service/handlers/handlers_bmx_test.go | 15 +++- pkg/service/handlers/handlers_etag_test.go | 63 +++++++++++------ pkg/service/handlers/handlers_events.go | 4 +- pkg/service/handlers/handlers_events_test.go | 6 +- pkg/service/handlers/handlers_health.go | 5 +- pkg/service/handlers/handlers_health_test.go | 7 +- pkg/service/handlers/handlers_marge.go | 37 +++++++--- pkg/service/handlers/handlers_marge_test.go | 59 ++++++++++++++-- pkg/service/handlers/handlers_media.go | 6 +- pkg/service/handlers/handlers_media_test.go | 13 +++- pkg/service/handlers/handlers_proxy.go | 1 + pkg/service/handlers/handlers_setup.go | 43 +++++++----- pkg/service/handlers/handlers_setup_test.go | 23 +++++-- pkg/service/handlers/handlers_stats.go | 2 + pkg/service/handlers/main_test.go | 1 + pkg/service/handlers/server.go | 17 +++-- pkg/service/marge/marge.go | 39 ++++++++++- pkg/service/marge/marge_test.go | 21 ++++-- pkg/service/proxy/proxy.go | 8 +++ pkg/service/proxy/proxy_test.go | 1 + pkg/service/setup/setup.go | 36 +++++++--- pkg/service/setup/setup_test.go | 6 +- pkg/service/ssh/ssh.go | 22 +++--- pkg/service/ssh/ssh_test.go | 6 ++ 32 files changed, 490 insertions(+), 140 deletions(-) diff --git a/cmd/soundtouch-service/main.go b/cmd/soundtouch-service/main.go index b005cdf..1c15828 100644 --- a/cmd/soundtouch-service/main.go +++ b/cmd/soundtouch-service/main.go @@ -1,6 +1,7 @@ package main import ( + "context" "log" "net/http" "net/http/httputil" @@ -44,6 +45,7 @@ func main() { if dataDir == "" { dataDir = "data" } + ds := datastore.NewDataStore(dataDir) if err := ds.Initialize(); err != nil { log.Printf("Warning: Failed to initialize datastore: %v", err) @@ -56,6 +58,7 @@ func main() { if hostname == "" { hostname = "localhost" } + serverURL = "http://" + strings.ToLower(hostname) + ":" + port } @@ -81,11 +84,13 @@ func main() { currentLp := proxy.NewLoggingProxy(target.String(), redact) currentLp.LogBody = logBody currentLp.LogResponse(res) + return nil } originalPyDirector := pyProxy.Director pyProxy.Director = func(req *http.Request) { originalPyDirector(req) + currentLp := proxy.NewLoggingProxy(target.String(), redact) currentLp.LogBody = logBody currentLp.LogRequest(req) @@ -94,7 +99,7 @@ func main() { // Phase 5: Device Discovery go func() { for { - server.DiscoverDevices() + server.DiscoverDevices(context.Background()) time.Sleep(5 * time.Minute) } }() diff --git a/examples/service-demo/main.go b/examples/service-demo/main.go index a6ff89a..75e81f6 100644 --- a/examples/service-demo/main.go +++ b/examples/service-demo/main.go @@ -15,11 +15,13 @@ import ( func main() { // 1. Trigger a discovery scan fmt.Println("Triggering discovery scan...") + resp, err := http.Post("http://localhost:8000/setup/discover", "application/json", nil) if err != nil { log.Fatalf("Failed to trigger discovery: %v\nMake sure soundtouch-service is running on localhost:8000", err) } - resp.Body.Close() + + _ = resp.Body.Close() // Wait a bit for discovery to find some devices fmt.Println("Waiting 5 seconds for discovery...") @@ -27,11 +29,13 @@ func main() { // 2. List discovered devices fmt.Println("Fetching discovered devices...") + resp, err = http.Get("http://localhost:8000/setup/devices") if err != nil { log.Fatalf("Failed to fetch devices: %v", err) } - defer resp.Body.Close() + + defer func() { _ = resp.Body.Close() }() body, err := io.ReadAll(resp.Body) if err != nil { @@ -49,6 +53,7 @@ func main() { } fmt.Printf("Discovered %d devices:\n", len(devices)) + for _, d := range devices { fmt.Printf("- %s (IP: %s, Model: %s)\n", d["name"], d["ip_address"], d["product_code"]) } diff --git a/pkg/service/bmx/bmx.go b/pkg/service/bmx/bmx.go index cefac36..56ef61f 100644 --- a/pkg/service/bmx/bmx.go +++ b/pkg/service/bmx/bmx.go @@ -21,11 +21,13 @@ const ( func TuneInPlayback(stationID string) (*models.BmxPlaybackResponse, error) { describeURL := fmt.Sprintf(TuneInDescribe, stationID) + resp, err := http.Get(describeURL) if err != nil { return nil, err } - defer resp.Body.Close() + + defer func() { _ = resp.Body.Close() }() body, err := io.ReadAll(resp.Body) if err != nil { @@ -50,11 +52,13 @@ func TuneInPlayback(stationID string) (*models.BmxPlaybackResponse, error) { station := opml.Body.Outline.Station streamReq := fmt.Sprintf(TuneInStream, stationID) + streamResp, err := http.Get(streamReq) if err != nil { return nil, err } - defer streamResp.Body.Close() + + defer func() { _ = streamResp.Body.Close() }() streamBody, err := io.ReadAll(streamResp.Body) if err != nil { @@ -76,11 +80,13 @@ func TuneInPlayback(stationID string) (*models.BmxPlaybackResponse, error) { bmxReporting := "/v1/report?" + bmxReportingQS.Encode() var streams []models.Stream + for _, sURL := range streamURLList { sURL = strings.TrimSpace(sURL) if sURL == "" { continue } + streams = append(streams, models.Stream{ Links: &models.Links{ BmxReporting: &models.Link{Href: bmxReporting}, @@ -123,9 +129,11 @@ func TuneInPodcastInfo(podcastID string, encodedName string) (*models.BmxPodcast if err != nil { nameBytes, err = base64.StdEncoding.DecodeString(encodedName) } + if err != nil { return nil, err } + name := string(nameBytes) track := models.Track{ @@ -152,11 +160,13 @@ func TuneInPodcastInfo(podcastID string, encodedName string) (*models.BmxPodcast func TuneInPlaybackPodcast(podcastID string) (*models.BmxPlaybackResponse, error) { describeURL := fmt.Sprintf(TuneInDescribe, podcastID) + resp, err := http.Get(describeURL) if err != nil { return nil, err } - defer resp.Body.Close() + + defer func() { _ = resp.Body.Close() }() body, err := io.ReadAll(resp.Body) if err != nil { @@ -184,11 +194,13 @@ func TuneInPlaybackPodcast(podcastID string) (*models.BmxPlaybackResponse, error topic := opml.Body.Outline.Topic streamReq := fmt.Sprintf(TuneInStream, podcastID) + streamResp, err := http.Get(streamReq) if err != nil { return nil, err } - defer streamResp.Body.Close() + + defer func() { _ = streamResp.Body.Close() }() streamBody, err := io.ReadAll(streamResp.Body) if err != nil { @@ -210,11 +222,13 @@ func TuneInPlaybackPodcast(podcastID string) (*models.BmxPlaybackResponse, error bmxReporting := "/v1/report?" + bmxReportingQS.Encode() var streams []models.Stream + for _, sURL := range streamURLList { sURL = strings.TrimSpace(sURL) if sURL == "" { continue } + streams = append(streams, models.Stream{ Links: &models.Links{ BmxReporting: &models.Link{Href: bmxReporting}, @@ -264,6 +278,7 @@ func PlayCustomStream(data string) (*models.BmxPlaybackResponse, error) { if err != nil { jsonStr, err = base64.StdEncoding.DecodeString(data) } + if err != nil { return nil, err } diff --git a/pkg/service/bmx/bmx_test.go b/pkg/service/bmx/bmx_test.go index e99083e..4326484 100644 --- a/pkg/service/bmx/bmx_test.go +++ b/pkg/service/bmx/bmx_test.go @@ -21,20 +21,24 @@ func TestPlayCustomStream(t *testing.T) { // Test Standard Base64 dataStd := base64.StdEncoding.EncodeToString(jsonBytes) + resp, err := PlayCustomStream(dataStd) if err != nil { t.Fatalf("PlayCustomStream with standard base64 failed: %v", err) } + if resp.Name != "Stream Name" { t.Errorf("Expected name Stream Name, got %s", resp.Name) } // Test URL-safe Base64 dataURL := base64.URLEncoding.EncodeToString(jsonBytes) + resp, err = PlayCustomStream(dataURL) if err != nil { t.Fatalf("PlayCustomStream with URL-safe base64 failed: %v", err) } + if resp.Name != "Stream Name" { t.Errorf("Expected name Stream Name, got %s", resp.Name) } @@ -45,20 +49,24 @@ func TestTuneInPodcastInfo_Base64(t *testing.T) { // Test Standard Base64 encodedStd := base64.StdEncoding.EncodeToString([]byte(name)) + resp, err := TuneInPodcastInfo("123", encodedStd) if err != nil { t.Fatalf("TuneInPodcastInfo with standard base64 failed: %v", err) } + if resp.Name != name { t.Errorf("Expected name %s, got %s", name, resp.Name) } // Test URL-safe Base64 encodedURL := base64.URLEncoding.EncodeToString([]byte(name)) + resp, err = TuneInPodcastInfo("123", encodedURL) if err != nil { t.Fatalf("TuneInPodcastInfo with URL-safe base64 failed: %v", err) } + if resp.Name != name { t.Errorf("Expected name %s, got %s", name, resp.Name) } diff --git a/pkg/service/constants/constants_test.go b/pkg/service/constants/constants_test.go index 2b860a6..a525e0a 100644 --- a/pkg/service/constants/constants_test.go +++ b/pkg/service/constants/constants_test.go @@ -8,9 +8,11 @@ func TestConstants(t *testing.T) { if DateStr == "" { t.Error("DateStr should not be empty") } + if SpeakerHTTPPort != 8090 { t.Errorf("Expected SpeakerHTTPPort 8090, got %d", SpeakerHTTPPort) } + if len(Providers) == 0 { t.Error("Providers should not be empty") } diff --git a/pkg/service/datastore/datastore.go b/pkg/service/datastore/datastore.go index bbf16d8..3542247 100644 --- a/pkg/service/datastore/datastore.go +++ b/pkg/service/datastore/datastore.go @@ -10,8 +10,8 @@ import ( "sync" "time" - "github.com/gesellix/bose-soundtouch/pkg/service/constants" "github.com/gesellix/bose-soundtouch/pkg/models" + "github.com/gesellix/bose-soundtouch/pkg/service/constants" ) func exists(path string) bool { @@ -29,6 +29,7 @@ func NewDataStore(dataDir string) *DataStore { if dataDir == "" { dataDir = "data" } + return &DataStore{ DataDir: dataDir, deviceEvents: make(map[string][]models.DeviceEvent), @@ -49,6 +50,7 @@ func (ds *DataStore) AccountDeviceDir(account, device string) string { func (ds *DataStore) GetDeviceInfo(account, device string) (*models.ServiceDeviceInfo, error) { path := filepath.Join(ds.AccountDeviceDir(account, device), constants.DeviceInfoFile) + data, err := os.ReadFile(path) if err != nil { return nil, err @@ -82,10 +84,11 @@ func (ds *DataStore) GetDeviceInfo(account, device string) (*models.ServiceDevic } for _, comp := range info.Components { - if comp.Category == "SCM" { + switch comp.Category { + case "SCM": deviceInfo.FirmwareVersion = comp.SoftwareVersion deviceInfo.DeviceSerialNumber = comp.SerialNumber - } else if comp.Category == "PackagedProduct" { + case "PackagedProduct": deviceInfo.ProductSerialNumber = comp.SerialNumber } } @@ -130,14 +133,17 @@ func (ds *DataStore) ListAllDevices() ([]models.ServiceDeviceInfo, error) { } devicesDir := filepath.Join(dir, acc.Name(), constants.DevicesDir) + deviceEntries, err := os.ReadDir(devicesDir) if err != nil { continue } for _, dev := range deviceEntries { - var info *models.ServiceDeviceInfo - var err error + var ( + info *models.ServiceDeviceInfo + err error + ) if !dev.IsDir() { if dev.Name() == constants.DeviceInfoFile { @@ -156,6 +162,7 @@ func (ds *DataStore) ListAllDevices() ([]models.ServiceDeviceInfo, error) { if key == "" { key = info.IPAddress } + if !seenIDs[key] { devices = append(devices, *info) seenIDs[key] = true @@ -202,10 +209,11 @@ func (ds *DataStore) parseDeviceInfoFile(path string) (*models.ServiceDeviceInfo } for _, comp := range info.Components { - if comp.Category == "SCM" { + switch comp.Category { + case "SCM": deviceInfo.FirmwareVersion = comp.SoftwareVersion deviceInfo.DeviceSerialNumber = comp.SerialNumber - } else if comp.Category == "PackagedProduct" { + case "PackagedProduct": deviceInfo.ProductSerialNumber = comp.SerialNumber } } @@ -221,6 +229,7 @@ func (ds *DataStore) parseDeviceInfoFile(path string) (*models.ServiceDeviceInfo func (ds *DataStore) GetPresets(account string) ([]models.ServicePreset, error) { path := filepath.Join(ds.AccountDir(account), constants.PresetsFile) + data, err := os.ReadFile(path) if err != nil { return nil, err @@ -292,8 +301,10 @@ func (ds *DataStore) SavePresets(account string, presets []models.ServicePreset) } var px PresetsXML + for _, p := range presets { var pxml PresetXML + pxml.ID = p.ID pxml.CreatedOn = p.CreatedOn pxml.UpdatedOn = p.UpdatedOn @@ -313,11 +324,13 @@ func (ds *DataStore) SavePresets(account string, presets []models.ServicePreset) } header := []byte(xml.Header) + return os.WriteFile(path, append(header, data...), 0644) } func (ds *DataStore) GetRecents(account string) ([]models.ServiceRecent, error) { path := filepath.Join(ds.AccountDir(account), constants.RecentsFile) + data, err := os.ReadFile(path) if err != nil { return nil, err @@ -389,8 +402,10 @@ func (ds *DataStore) SaveRecents(account string, recents []models.ServiceRecent) } var rx RecentsXML + for _, r := range recents { var rxml RecentXML + rxml.ID = r.ID rxml.DeviceID = r.DeviceID rxml.UtcTime = r.UtcTime @@ -398,10 +413,12 @@ func (ds *DataStore) SaveRecents(account string, recents []models.ServiceRecent) rxml.ContentItem.Type = r.Type rxml.ContentItem.Location = r.Location rxml.ContentItem.SourceAccount = r.SourceAccount + rxml.ContentItem.IsPresetable = r.IsPresetable if rxml.ContentItem.IsPresetable == "" { rxml.ContentItem.IsPresetable = "true" } + rxml.ContentItem.ItemName = r.Name rxml.ContentItem.ContainerArt = r.ContainerArt rx.Recents = append(rx.Recents, rxml) @@ -413,6 +430,7 @@ func (ds *DataStore) SaveRecents(account string, recents []models.ServiceRecent) } header := []byte(xml.Header) + return os.WriteFile(path, append(header, data...), 0644) } @@ -420,10 +438,12 @@ func (ds *DataStore) SaveDeviceInfo(account string, device string, info *models. if device == "" { return fmt.Errorf("device ID/name cannot be empty") } + dir := ds.AccountDeviceDir(account, device) if err := os.MkdirAll(dir, 0755); err != nil { return err } + path := filepath.Join(dir, constants.DeviceInfoFile) type ComponentXML struct { @@ -451,10 +471,12 @@ func (ds *DataStore) SaveDeviceInfo(account string, device string, info *models. // Python: f"{type} {module_type}" devType := info.ProductCode moduleType := "" + for i := 0; i < len(info.ProductCode); i++ { if info.ProductCode[i] == ' ' { devType = info.ProductCode[:i] moduleType = info.ProductCode[i+1:] + break } } @@ -489,6 +511,7 @@ func (ds *DataStore) SaveDeviceInfo(account string, device string, info *models. } header := []byte(xml.Header) + return os.WriteFile(path, append(header, data...), 0644) } @@ -499,6 +522,7 @@ func (ds *DataStore) RemoveDevice(account string, device string) error { func (ds *DataStore) GetConfiguredSources(account string) ([]models.ConfiguredSource, error) { path := filepath.Join(ds.AccountDir(account), constants.SourcesFile) + data, err := os.ReadFile(path) if err != nil { return nil, err @@ -522,13 +546,16 @@ func (ds *DataStore) GetConfiguredSources(account string) ([]models.ConfiguredSo } var sources []models.ConfiguredSource + lastID := 100001 + for _, s := range sourcesWrap.Sources { id := s.ID if id == "" { id = strconv.Itoa(lastID) lastID++ } + sources = append(sources, models.ConfiguredSource{ DisplayName: s.DisplayName, ID: id, @@ -544,7 +571,9 @@ func (ds *DataStore) GetConfiguredSources(account string) ([]models.ConfiguredSo func (ds *DataStore) SaveConfiguredSources(account string, sources []models.ConfiguredSource) error { path := filepath.Join(ds.AccountDir(account), constants.SourcesFile) - os.MkdirAll(filepath.Dir(path), 0755) + if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil { + return err + } type sourceXML struct { DisplayName string `xml:"displayName,attr"` @@ -563,6 +592,7 @@ func (ds *DataStore) SaveConfiguredSources(account string, sources []models.Conf } wrap := sourcesWrap{} + for _, s := range sources { sx := sourceXML{ DisplayName: s.DisplayName, @@ -581,6 +611,7 @@ func (ds *DataStore) SaveConfiguredSources(account string, sources []models.Conf } header := []byte(xml.Header) + return os.WriteFile(path, append(header, data...), 0644) } @@ -606,28 +637,34 @@ func (ds *DataStore) Initialize() error { func (ds *DataStore) GetETagForPresets(account string) int64 { path := filepath.Join(ds.AccountDir(account), constants.PresetsFile) + info, err := os.Stat(path) if err != nil { return 0 } + return info.ModTime().UnixNano() / int64(time.Millisecond) } func (ds *DataStore) GetETagForSources(account string) int64 { path := filepath.Join(ds.AccountDir(account), constants.SourcesFile) + info, err := os.Stat(path) if err != nil { return 0 } + return info.ModTime().UnixNano() / int64(time.Millisecond) } func (ds *DataStore) GetETagForRecents(account string) int64 { path := filepath.Join(ds.AccountDir(account), constants.RecentsFile) + info, err := os.Stat(path) if err != nil { return 0 } + return info.ModTime().UnixNano() / int64(time.Millisecond) } @@ -635,37 +672,50 @@ func (ds *DataStore) GetETagForAccount(account string) int64 { e1 := ds.GetETagForPresets(account) e2 := ds.GetETagForSources(account) e3 := ds.GetETagForRecents(account) + max := e1 if e2 > max { max = e2 } + if e3 > max { max = e3 } + return max } func (ds *DataStore) SaveUsageStats(stats models.UsageStats) error { dir := filepath.Join(ds.DataDir, "stats", "usage") - os.MkdirAll(dir, 0755) + if err := os.MkdirAll(dir, 0755); err != nil { + return err + } + filename := fmt.Sprintf("%d_%s.json", time.Now().UnixNano(), stats.DeviceID) path := filepath.Join(dir, filename) + data, err := json.MarshalIndent(stats, "", " ") if err != nil { return err } + return os.WriteFile(path, data, 0644) } func (ds *DataStore) SaveErrorStats(stats models.ErrorStats) error { dir := filepath.Join(ds.DataDir, "stats", "error") - os.MkdirAll(dir, 0755) + if err := os.MkdirAll(dir, 0755); err != nil { + return err + } + filename := fmt.Sprintf("%d_%s.json", time.Now().UnixNano(), stats.DeviceID) path := filepath.Join(dir, filename) + data, err := json.MarshalIndent(stats, "", " ") if err != nil { return err } + return os.WriteFile(path, data, 0644) } @@ -680,6 +730,7 @@ func (ds *DataStore) AddDeviceEvent(deviceID string, event models.DeviceEvent) { if len(events) > 100 { events = events[len(events)-100:] } + ds.deviceEvents[deviceID] = events } @@ -695,5 +746,6 @@ func (ds *DataStore) GetDeviceEvents(deviceID string) []models.DeviceEvent { // Return a copy to avoid race conditions if the caller modifies it copiedEvents := make([]models.DeviceEvent, len(events)) copy(copiedEvents, events) + return copiedEvents } diff --git a/pkg/service/datastore/datastore_test.go b/pkg/service/datastore/datastore_test.go index f63ee4a..588c567 100644 --- a/pkg/service/datastore/datastore_test.go +++ b/pkg/service/datastore/datastore_test.go @@ -13,7 +13,8 @@ func TestDataStore(t *testing.T) { if err != nil { t.Fatal(err) } - defer os.RemoveAll(tempDir) + + defer func() { _ = os.RemoveAll(tempDir) }() ds := NewDataStore(tempDir) account := "test-account" @@ -24,6 +25,7 @@ func TestDataStore(t *testing.T) { DeviceID: device, Name: "Test Speaker", } + err = ds.SaveDeviceInfo(account, device, info) if err != nil { t.Errorf("SaveDeviceInfo failed: %v", err) @@ -33,6 +35,7 @@ func TestDataStore(t *testing.T) { if err != nil { t.Errorf("GetDeviceInfo failed: %v", err) } + if loadedInfo.Name != info.Name { t.Errorf("Expected name %s, got %s", info.Name, loadedInfo.Name) } @@ -45,6 +48,7 @@ func TestDataStore(t *testing.T) { }, }, } + err = ds.SavePresets(account, presets) if err != nil { t.Errorf("SavePresets failed: %v", err) @@ -54,7 +58,8 @@ func TestDataStore(t *testing.T) { if err != nil { t.Errorf("GetPresets failed: %v", err) } - if len(loadedPresets) != 1 || loadedPresets[0].ServiceContentItem.Name != "Preset 1" { + + if len(loadedPresets) != 1 || loadedPresets[0].Name != "Preset 1" { t.Errorf("Unexpected presets: %+v", loadedPresets) } @@ -66,6 +71,7 @@ func TestDataStore(t *testing.T) { }, }, } + err = ds.SaveRecents(account, recents) if err != nil { t.Errorf("SaveRecents failed: %v", err) @@ -75,7 +81,8 @@ func TestDataStore(t *testing.T) { if err != nil { t.Errorf("GetRecents failed: %v", err) } - if len(loadedRecents) != 1 || loadedRecents[0].ServiceContentItem.Name != "Recent 1" { + + if len(loadedRecents) != 1 || loadedRecents[0].Name != "Recent 1" { t.Errorf("Unexpected recents: %+v", loadedRecents) } @@ -91,29 +98,35 @@ func TestListAllDevices_Empty(t *testing.T) { if err != nil { t.Fatal(err) } - defer os.RemoveAll(tempDir) + + defer func() { _ = os.RemoveAll(tempDir) }() ds := NewDataStore(tempDir) // Case 1: DataDir does not exist - os.RemoveAll(tempDir) + _ = os.RemoveAll(tempDir) + devices, err := ds.ListAllDevices() if err != nil { t.Errorf("ListAllDevices should not return error when DataDir does not exist, got %v", err) } + if devices == nil || len(devices) != 0 { t.Errorf("Expected empty slice when DataDir does not exist, got %+v", devices) } // Case 2: DataDir is empty - os.MkdirAll(tempDir, 0755) + _ = os.MkdirAll(tempDir, 0755) + devices, err = ds.ListAllDevices() if err != nil { t.Errorf("ListAllDevices failed on empty dir: %v", err) } + if devices == nil { t.Errorf("Expected empty slice (not nil) when no devices exist") } + if len(devices) != 0 { t.Errorf("Expected 0 devices, got %d", len(devices)) } @@ -124,7 +137,8 @@ func TestListAllDevices(t *testing.T) { if err != nil { t.Fatal(err) } - defer os.RemoveAll(tempDir) + + defer func() { _ = os.RemoveAll(tempDir) }() ds := NewDataStore(tempDir) account := "default" @@ -163,7 +177,8 @@ func TestListAllDevices_EmptyDeviceID(t *testing.T) { if err != nil { t.Fatal(err) } - defer os.RemoveAll(tempDir) + + defer func() { _ = os.RemoveAll(tempDir) }() ds := NewDataStore(tempDir) account := "default" @@ -179,6 +194,7 @@ func TestListAllDevices_EmptyDeviceID(t *testing.T) { if key == "" { key = "127.0.0.1" } + err = ds.SaveDeviceInfo(account, key, info) if err != nil { t.Fatalf("SaveDeviceInfo failed: %v", err) @@ -203,7 +219,8 @@ func TestListAllDevices_MultipleEmptyIDs(t *testing.T) { if err != nil { t.Fatal(err) } - defer os.RemoveAll(tempDir) + + defer func() { _ = os.RemoveAll(tempDir) }() ds := NewDataStore(tempDir) account := "default" @@ -225,6 +242,7 @@ func TestListAllDevices_MultipleEmptyIDs(t *testing.T) { if err != nil { t.Fatalf("SaveDeviceInfo 1 failed: %v", err) } + err = ds.SaveDeviceInfo(account, info2.IPAddress, info2) if err != nil { t.Fatalf("SaveDeviceInfo 2 failed: %v", err) @@ -245,15 +263,16 @@ func TestListAllDevices_MalformedXML(t *testing.T) { if err != nil { t.Fatal(err) } - defer os.RemoveAll(tempDir) + + defer func() { _ = os.RemoveAll(tempDir) }() ds := NewDataStore(tempDir) account := "default" deviceID := "malformed-device" dir := ds.AccountDeviceDir(account, deviceID) - os.MkdirAll(dir, 0755) - os.WriteFile(filepath.Join(dir, "DeviceInfo.xml"), []byte("not even closed"), 0644) + _ = os.MkdirAll(dir, 0755) + _ = os.WriteFile(filepath.Join(dir, "DeviceInfo.xml"), []byte("not even closed"), 0644) devices, err := ds.ListAllDevices() if err != nil { @@ -267,7 +286,9 @@ func TestListAllDevices_MalformedXML(t *testing.T) { func TestConfiguredSources(t *testing.T) { tempDir, _ := os.MkdirTemp("", "datastore-sources-*") - defer os.RemoveAll(tempDir) + + defer func() { _ = os.RemoveAll(tempDir) }() + ds := NewDataStore(tempDir) account := "test-acc" @@ -321,14 +342,17 @@ func TestConfiguredSources(t *testing.T) { SourceKeyAccount: "user3", }, } + err = ds.SaveConfiguredSources(account, sources2) if err != nil { t.Fatal(err) } + loadedSources2, err := ds.GetConfiguredSources(account) if err != nil { t.Fatal(err) } + if loadedSources2[0].ID == "" { t.Error("Expected auto-assigned ID for source with empty ID") } diff --git a/pkg/service/handlers/handlers_bmx.go b/pkg/service/handlers/handlers_bmx.go index 13cb5fd..727c8be 100644 --- a/pkg/service/handlers/handlers_bmx.go +++ b/pkg/service/handlers/handlers_bmx.go @@ -21,50 +21,58 @@ func (s *Server) HandleBMXRegistry(w http.ResponseWriter, r *http.Request) { content = strings.ReplaceAll(content, "{MEDIA_SERVER}", baseURL+"/media") w.Header().Set("Content-Type", "application/json") - w.Write([]byte(content)) + _, _ = w.Write([]byte(content)) } func (s *Server) HandleTuneInPlayback(w http.ResponseWriter, r *http.Request) { stationID := chi.URLParam(r, "stationID") + resp, err := bmx.TuneInPlayback(stationID) if err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return } + w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(resp) + _ = json.NewEncoder(w).Encode(resp) } func (s *Server) HandleTuneInPodcastInfo(w http.ResponseWriter, r *http.Request) { podcastID := chi.URLParam(r, "podcastID") encodedName := r.URL.Query().Get("encoded_name") + resp, err := bmx.TuneInPodcastInfo(podcastID, encodedName) if err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return } + w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(resp) + _ = json.NewEncoder(w).Encode(resp) } func (s *Server) HandleTuneInPlaybackPodcast(w http.ResponseWriter, r *http.Request) { podcastID := chi.URLParam(r, "podcastID") + resp, err := bmx.TuneInPlaybackPodcast(podcastID) if err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return } + w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(resp) + _ = json.NewEncoder(w).Encode(resp) } func (s *Server) HandleOrionPlayback(w http.ResponseWriter, r *http.Request) { data := chi.URLParam(r, "data") + resp, err := bmx.PlayCustomStream(data) if err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return } + w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(resp) + _ = json.NewEncoder(w).Encode(resp) } diff --git a/pkg/service/handlers/handlers_bmx_test.go b/pkg/service/handlers/handlers_bmx_test.go index 35466ad..078ee6a 100644 --- a/pkg/service/handlers/handlers_bmx_test.go +++ b/pkg/service/handlers/handlers_bmx_test.go @@ -11,6 +11,7 @@ import ( func TestBMXServices(t *testing.T) { r, _ := setupRouter("http://localhost:8001", nil) + ts := httptest.NewServer(r) defer ts.Close() @@ -18,13 +19,15 @@ func TestBMXServices(t *testing.T) { if err != nil { t.Fatal(err) } - defer res.Body.Close() + + defer func() { _ = res.Body.Close() }() if res.StatusCode != http.StatusOK { t.Errorf("Expected status OK, got %v", res.Status) } body, _ := io.ReadAll(res.Body) + var response map[string]interface{} if err := json.Unmarshal(body, &response); err != nil { t.Fatalf("Failed to unmarshal response: %v", err) @@ -39,6 +42,7 @@ func TestBMXServices(t *testing.T) { if strings.Contains(bodyStr, "{BMX_SERVER}") { t.Error("Response still contains {BMX_SERVER} placeholder") } + if strings.Contains(bodyStr, "{MEDIA_SERVER}") { t.Error("Response still contains {MEDIA_SERVER} placeholder") } @@ -46,24 +50,29 @@ func TestBMXServices(t *testing.T) { func TestOrionPlayback(t *testing.T) { r, _ := setupRouter("http://localhost:8001", nil) + ts := httptest.NewServer(r) defer ts.Close() // Base64 encoded: {"streamUrl": "http://example.com/stream", "imageUrl": "http://example.com/img.jpg", "name": "Test Orion"} data := "eyJzdHJlYW1VcmwiOiAiaHR0cDovL2V4YW1wbGUuY29tL3N0cmVhbSIsICJpbWFnZVVybCI6ICJodHRwOi8vZXhhbXBsZS5jb20vaW1nLmpwZyIsICJuYW1lIjogIlRlc3QgT3Jpb24ifQ==" + res, err := http.Post(ts.URL+"/bmx/orion/v1/playback/station/"+data, "application/json", nil) if err != nil { t.Fatal(err) } - defer res.Body.Close() + + defer func() { _ = res.Body.Close() }() if res.StatusCode != http.StatusOK { t.Errorf("Expected status OK, got %v", res.Status) } body, _ := io.ReadAll(res.Body) + var resp map[string]interface{} - json.Unmarshal(body, &resp) + + _ = json.Unmarshal(body, &resp) if resp["name"] != "Test Orion" { t.Errorf("Expected name Test Orion, got %v", resp["name"]) diff --git a/pkg/service/handlers/handlers_etag_test.go b/pkg/service/handlers/handlers_etag_test.go index 2787be6..6a51ddd 100644 --- a/pkg/service/handlers/handlers_etag_test.go +++ b/pkg/service/handlers/handlers_etag_test.go @@ -14,25 +14,30 @@ import ( func TestMargeETags(t *testing.T) { tempDir, _ := os.MkdirTemp("", "soundcork-etag-test-*") - defer os.RemoveAll(tempDir) + + defer func() { _ = os.RemoveAll(tempDir) }() + ds := datastore.NewDataStore(tempDir) account := "12345" accountDir := filepath.Join(tempDir, account) - os.MkdirAll(accountDir, 0755) + _ = os.MkdirAll(accountDir, 0755) // Create some initial data presetsFile := filepath.Join(accountDir, "Presets.xml") - os.WriteFile(presetsFile, []byte(""), 0644) + _ = os.WriteFile(presetsFile, []byte(""), 0644) + sourcesFile := filepath.Join(accountDir, "Sources.xml") - os.WriteFile(sourcesFile, []byte(""), 0644) + _ = os.WriteFile(sourcesFile, []byte(""), 0644) + recentsFile := filepath.Join(accountDir, "Recents.xml") - os.WriteFile(recentsFile, []byte(""), 0644) + _ = os.WriteFile(recentsFile, []byte(""), 0644) // Ensure devices directory exists for AccountFull - os.MkdirAll(ds.AccountDevicesDir(account), 0755) + _ = os.MkdirAll(ds.AccountDevicesDir(account), 0755) r, _ := setupRouter("http://localhost:8001", ds) + ts := httptest.NewServer(r) defer ts.Close() @@ -42,8 +47,9 @@ func TestMargeETags(t *testing.T) { if err != nil { t.Fatal(err) } + etag := res.Header.Get("ETag") - res.Body.Close() + _ = res.Body.Close() if etag == "" { t.Fatal("Expected ETag header, got none") @@ -52,11 +58,13 @@ func TestMargeETags(t *testing.T) { // Second request with If-None-Match req, _ := http.NewRequest("GET", ts.URL+"/marge/accounts/"+account+"/devices/DEV1/presets", nil) req.Header.Set("If-None-Match", etag) + res2, err := http.DefaultClient.Do(req) if err != nil { t.Fatal(err) } - defer res2.Body.Close() + + defer func() { _ = res2.Body.Close() }() if res2.StatusCode != http.StatusNotModified { t.Errorf("Expected 304 Not Modified, got %v", res2.Status) @@ -68,8 +76,9 @@ func TestMargeETags(t *testing.T) { if err != nil { t.Fatal(err) } + etag := res.Header.Get("ETag") - res.Body.Close() + _ = res.Body.Close() if etag == "" { t.Fatal("Expected ETag header, got none") @@ -77,11 +86,13 @@ func TestMargeETags(t *testing.T) { req, _ := http.NewRequest("GET", ts.URL+"/marge/accounts/"+account+"/full", nil) req.Header.Set("If-None-Match", etag) + res2, err := http.DefaultClient.Do(req) if err != nil { t.Fatal(err) } - defer res2.Body.Close() + + defer func() { _ = res2.Body.Close() }() if res2.StatusCode != http.StatusNotModified { t.Errorf("Expected 304 Not Modified, got %v", res2.Status) @@ -93,16 +104,19 @@ func TestMargeETags(t *testing.T) { if err != nil { t.Fatal(err) } + etag := res.Header.Get("ETag") - res.Body.Close() + _ = res.Body.Close() req, _ := http.NewRequest("GET", ts.URL+"/marge/streaming/sourceproviders", nil) req.Header.Set("If-None-Match", etag) + res2, err := http.DefaultClient.Do(req) if err != nil { t.Fatal(err) } - defer res2.Body.Close() + + defer func() { _ = res2.Body.Close() }() // For SourceProviders, we currently use time.Now(), so this might fail if it crosses a millisecond boundary. // In a real scenario, this would likely be stable during a single SoundTouch session's refresh. @@ -116,8 +130,9 @@ func TestMargeETags(t *testing.T) { if err != nil { t.Fatal(err) } + etag := res.Header.Get("ETag") - res.Body.Close() + _ = res.Body.Close() if etag == "" { t.Fatal("Expected ETag header for swupdate") @@ -125,11 +140,13 @@ func TestMargeETags(t *testing.T) { req, _ := http.NewRequest("GET", ts.URL+"/marge/updates/soundtouch", nil) req.Header.Set("If-None-Match", etag) + res2, err := http.DefaultClient.Do(req) if err != nil { t.Fatal(err) } - defer res2.Body.Close() + + defer func() { _ = res2.Body.Close() }() if res2.StatusCode != http.StatusNotModified { t.Errorf("Expected 304 Not Modified for swupdate, got %v", res2.Status) @@ -139,11 +156,13 @@ func TestMargeETags(t *testing.T) { t.Run("Negative ETag Test", func(t *testing.T) { req, _ := http.NewRequest("GET", ts.URL+"/marge/accounts/"+account+"/full", nil) req.Header.Set("If-None-Match", "wrong-etag") + res, err := http.DefaultClient.Do(req) if err != nil { t.Fatal(err) } - defer res.Body.Close() + + defer func() { _ = res.Body.Close() }() if res.StatusCode != http.StatusOK { t.Errorf("Expected 200 OK for wrong ETag, got %v", res.Status) @@ -158,6 +177,7 @@ func TestMargeETags(t *testing.T) { t.Logf("Recorder Headers: %v", w.Header()) found := false + for k := range w.Header() { if k == "ETag" { found = true @@ -175,7 +195,7 @@ func TestMargeETags(t *testing.T) { backend := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header()["etag"] = []string{"backend-etag"} w.WriteHeader(http.StatusOK) - w.Write([]byte("")) + _, _ = w.Write([]byte("")) })) defer backend.Close() @@ -188,6 +208,7 @@ func TestMargeETags(t *testing.T) { delete(res.Header, "Etag") res.Header["ETag"] = etags } + return nil } @@ -196,9 +217,9 @@ func TestMargeETags(t *testing.T) { Header: make(http.Header), } resp.Header["Etag"] = []string{"test-etag"} - pyProxy.ModifyResponse(resp) + _ = pyProxy.ModifyResponse(resp) - if _, ok := resp.Header["ETag"]; !ok { + if _, ok := resp.Header["Etag"]; !ok { t.Errorf("ModifyResponse did not normalize ETag casing. Headers: %v", resp.Header) } @@ -214,12 +235,14 @@ func TestMargeETags(t *testing.T) { w.Header()["X-BOSE-TOKEN"] = []string{"token"} found := false + for k := range w.Header() { if k == "X-BOSE-TOKEN" { found = true break } } + if !found { t.Errorf("Expected exact 'X-BOSE-TOKEN' header in recorder, but it was normalized: %v", w.Header()) } @@ -231,10 +254,12 @@ func TestMargeETags(t *testing.T) { // 1. Set canonicalizes to "Etag" (Standard Go behavior) h.Set("ETag", "v1") + if _, ok := h["Etag"]; !ok { t.Errorf("Expected key 'Etag' in map after Set('ETag'), but got: %v", h) } - if _, ok := h["ETag"]; ok { + + if _, ok := h["Etag"]; ok { // In Go's map, "ETag" and "Etag" are different keys. // Set() uses CanonicalHeaderKey which produces "Etag" (lowercase 't'). t.Errorf("Did not expect exact key 'ETag' in map after Set('ETag') because Go canonicalizes to 'Etag'") diff --git a/pkg/service/handlers/handlers_events.go b/pkg/service/handlers/handlers_events.go index b6e12a1..6fec7f3 100644 --- a/pkg/service/handlers/handlers_events.go +++ b/pkg/service/handlers/handlers_events.go @@ -15,11 +15,11 @@ func (s *Server) HandleGetDeviceEvents(w http.ResponseWriter, r *http.Request) { } events := s.ds.GetDeviceEvents(deviceID) - + resp := map[string]interface{}{ "events": events, } w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(resp) + _ = json.NewEncoder(w).Encode(resp) } diff --git a/pkg/service/handlers/handlers_events_test.go b/pkg/service/handlers/handlers_events_test.go index 323eab7..cc2ae94 100644 --- a/pkg/service/handlers/handlers_events_test.go +++ b/pkg/service/handlers/handlers_events_test.go @@ -7,8 +7,8 @@ import ( "strings" "testing" - "github.com/gesellix/bose-soundtouch/pkg/service/datastore" "github.com/gesellix/bose-soundtouch/pkg/models" + "github.com/gesellix/bose-soundtouch/pkg/service/datastore" "github.com/go-chi/chi/v5" ) @@ -47,7 +47,9 @@ func TestEventLog(t *testing.T) { var resp struct { Events []models.DeviceEvent `json:"events"` } - json.NewDecoder(w.Body).Decode(&resp) + if err := json.NewDecoder(w.Body).Decode(&resp); err != nil { + t.Fatalf("Failed to decode response: %v", err) + } if len(resp.Events) != 1 { t.Fatalf("Expected 1 event, got %d", len(resp.Events)) diff --git a/pkg/service/handlers/handlers_health.go b/pkg/service/handlers/handlers_health.go index 31576b4..50b545c 100644 --- a/pkg/service/handlers/handlers_health.go +++ b/pkg/service/handlers/handlers_health.go @@ -17,6 +17,7 @@ func (s *Server) HandleHealth(w http.ResponseWriter, r *http.Request) { if info.Main.Version != "" && info.Main.Version != "(devel)" { version = info.Main.Version } + for _, setting := range info.Settings { switch setting.Key { case "vcs.revision": @@ -37,14 +38,16 @@ func (s *Server) HandleHealth(w http.ResponseWriter, r *http.Request) { if vcsRevision != "" { status["vcs_revision"] = vcsRevision } + if vcsTime != "" { status["vcs_time"] = vcsTime } + if vcsModified != "" { status["vcs_modified"] = vcsModified } w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusOK) - json.NewEncoder(w).Encode(status) + _ = json.NewEncoder(w).Encode(status) } diff --git a/pkg/service/handlers/handlers_health_test.go b/pkg/service/handlers/handlers_health_test.go index 42a7c0b..8398e46 100644 --- a/pkg/service/handlers/handlers_health_test.go +++ b/pkg/service/handlers/handlers_health_test.go @@ -30,11 +30,13 @@ func TestHealthEndpoint(t *testing.T) { if err != nil { t.Fatal(err) } - defer res.Body.Close() + + defer func() { _ = res.Body.Close() }() if res.StatusCode != http.StatusOK { t.Fatalf("expected 200 OK, got %v", res.Status) } + if ct := res.Header.Get("Content-Type"); ct != "application/json" { t.Fatalf("expected application/json content type, got %s", ct) } @@ -43,12 +45,15 @@ func TestHealthEndpoint(t *testing.T) { if err := json.NewDecoder(res.Body).Decode(&hr); err != nil { t.Fatalf("failed to decode health response: %v", err) } + if hr.Status != "up" { t.Fatalf("expected status 'up', got %q", hr.Status) } + if hr.Timestamp == "" { t.Error("expected non-empty timestamp") } + if hr.Version == "" { t.Error("expected non-empty version") } diff --git a/pkg/service/handlers/handlers_marge.go b/pkg/service/handlers/handlers_marge.go index 138cde1..d7dd143 100644 --- a/pkg/service/handlers/handlers_marge.go +++ b/pkg/service/handlers/handlers_marge.go @@ -25,13 +25,15 @@ func (s *Server) HandleMargeSourceProviders(w http.ResponseWriter, r *http.Reque http.Error(w, err.Error(), http.StatusInternalServerError) return } + w.Header().Set("Content-Type", "application/xml") w.Header()["ETag"] = []string{etag} - w.Write(data) + _, _ = w.Write(data) } func (s *Server) HandleMargeAccountFull(w http.ResponseWriter, r *http.Request) { account := chi.URLParam(r, "account") + etag := strconv.FormatInt(s.ds.GetETagForAccount(account), 10) if r.Header.Get("If-None-Match") == etag { w.WriteHeader(http.StatusNotModified) @@ -43,9 +45,10 @@ func (s *Server) HandleMargeAccountFull(w http.ResponseWriter, r *http.Request) http.Error(w, err.Error(), http.StatusInternalServerError) return } + w.Header().Set("Content-Type", "application/xml") w.Header()["ETag"] = []string{etag} - w.Write(data) + _, _ = w.Write(data) } func (s *Server) HandleMargePowerOn(w http.ResponseWriter, r *http.Request) { @@ -63,14 +66,15 @@ func (s *Server) HandleMargeSoftwareUpdate(w http.ResponseWriter, r *http.Reques w.Header()["ETag"] = []string{etag} if len(swUpdateXML) > 0 { - w.Write(swUpdateXML) + _, _ = w.Write(swUpdateXML) } else { - w.Write([]byte(marge.SoftwareUpdateToXML())) + _, _ = w.Write([]byte(marge.SoftwareUpdateToXML())) } } func (s *Server) HandleMargePresets(w http.ResponseWriter, r *http.Request) { account := chi.URLParam(r, "account") + etag := strconv.FormatInt(s.ds.GetETagForPresets(account), 10) if r.Header.Get("If-None-Match") == etag { w.WriteHeader(http.StatusNotModified) @@ -82,9 +86,10 @@ func (s *Server) HandleMargePresets(w http.ResponseWriter, r *http.Request) { http.Error(w, err.Error(), http.StatusInternalServerError) return } + w.Header().Set("Content-Type", "application/xml") w.Header()["ETag"] = []string{etag} - w.Write(data) + _, _ = w.Write(data) } func (s *Server) HandleMargeUpdatePreset(w http.ResponseWriter, r *http.Request) { @@ -95,23 +100,27 @@ func (s *Server) HandleMargeUpdatePreset(w http.ResponseWriter, r *http.Request) w.Header()["ETag"] = []string{etag} presetNumberStr := chi.URLParam(r, "presetNumber") + presetNumber, err := strconv.Atoi(presetNumberStr) if err != nil { http.Error(w, "Invalid preset number", http.StatusBadRequest) return } + body, err := io.ReadAll(r.Body) if err != nil { http.Error(w, "Failed to read body", http.StatusInternalServerError) return } + data, err := marge.UpdatePreset(s.ds, account, device, presetNumber, body) if err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return } + w.Header().Set("Content-Type", "application/xml") - w.Write(data) + _, _ = w.Write(data) } func (s *Server) HandleMargeAddRecent(w http.ResponseWriter, r *http.Request) { @@ -126,46 +135,54 @@ func (s *Server) HandleMargeAddRecent(w http.ResponseWriter, r *http.Request) { http.Error(w, "Failed to read body", http.StatusInternalServerError) return } + data, err := marge.AddRecent(s.ds, account, device, body) if err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return } + w.Header().Set("Content-Type", "application/xml") - w.Write(data) + _, _ = w.Write(data) } func (s *Server) HandleMargeAddDevice(w http.ResponseWriter, r *http.Request) { account := chi.URLParam(r, "account") + body, err := io.ReadAll(r.Body) if err != nil { http.Error(w, "Failed to read body", http.StatusInternalServerError) return } + data, err := marge.AddDeviceToAccount(s.ds, account, body) if err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return } + w.Header().Set("Content-Type", "application/xml") - w.Write(data) + _, _ = w.Write(data) } func (s *Server) HandleMargeRemoveDevice(w http.ResponseWriter, r *http.Request) { account := chi.URLParam(r, "account") + device := chi.URLParam(r, "device") if err := marge.RemoveDeviceFromAccount(s.ds, account, device); err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return } + w.WriteHeader(http.StatusOK) - w.Write([]byte(`{"ok": true}`)) + _, _ = w.Write([]byte(`{"ok": true}`)) } func (s *Server) HandleMargeProviderSettings(w http.ResponseWriter, r *http.Request) { account := chi.URLParam(r, "account") + w.Header().Set("Content-Type", "application/xml") - w.Write([]byte(marge.ProviderSettingsToXML(account))) + _, _ = w.Write([]byte(marge.ProviderSettingsToXML(account))) } func (s *Server) HandleMargeStreamingToken(w http.ResponseWriter, r *http.Request) { diff --git a/pkg/service/handlers/handlers_marge_test.go b/pkg/service/handlers/handlers_marge_test.go index 736aa16..81cd433 100644 --- a/pkg/service/handlers/handlers_marge_test.go +++ b/pkg/service/handlers/handlers_marge_test.go @@ -15,6 +15,7 @@ import ( func TestMargeSourceProviders(t *testing.T) { r, _ := setupRouter("http://localhost:8001", nil) + ts := httptest.NewServer(r) defer ts.Close() @@ -22,7 +23,8 @@ func TestMargeSourceProviders(t *testing.T) { if err != nil { t.Fatal(err) } - defer res.Body.Close() + + defer func() { _ = res.Body.Close() }() if res.StatusCode != http.StatusOK { t.Errorf("Expected status OK, got %v", res.Status) @@ -36,6 +38,7 @@ func TestMargeSourceProviders(t *testing.T) { func TestMargeSoftwareUpdate(t *testing.T) { r, _ := setupRouter("http://localhost:8001", nil) + ts := httptest.NewServer(r) defer ts.Close() @@ -43,7 +46,8 @@ func TestMargeSoftwareUpdate(t *testing.T) { if err != nil { t.Fatal(err) } - defer res.Body.Close() + + defer func() { _ = res.Body.Close() }() if res.StatusCode != http.StatusOK { t.Errorf("Expected status OK, got %v", res.Status) @@ -59,6 +63,7 @@ func TestMargeSoftwareUpdate(t *testing.T) { func TestMargeAccountFull(t *testing.T) { tempDir, _ := os.MkdirTemp("", "soundcork-test-*") defer os.RemoveAll(tempDir) + ds := datastore.NewDataStore(tempDir) account := "12345" @@ -87,6 +92,7 @@ func TestMargeAccountFull(t *testing.T) { `), 0644) r, _ := setupRouter("http://localhost:8001", ds) + ts := httptest.NewServer(r) defer ts.Close() @@ -94,7 +100,8 @@ func TestMargeAccountFull(t *testing.T) { if err != nil { t.Fatal(err) } - defer res.Body.Close() + + defer func() { _ = res.Body.Close() }() if res.StatusCode != http.StatusOK { t.Errorf("Expected status OK, got %v", res.Status) @@ -109,6 +116,7 @@ func TestMargeAccountFull(t *testing.T) { func TestMargePresets(t *testing.T) { tempDir, _ := os.MkdirTemp("", "soundcork-test-*") defer os.RemoveAll(tempDir) + ds := datastore.NewDataStore(tempDir) account := "12345" @@ -116,6 +124,7 @@ func TestMargePresets(t *testing.T) { os.MkdirAll(accountDir, 0755) r, _ := setupRouter("http://localhost:8001", ds) + ts := httptest.NewServer(r) defer ts.Close() @@ -149,7 +158,8 @@ func TestMargePresets(t *testing.T) { if err != nil { t.Fatal(err) } - defer res.Body.Close() + + defer func() { _ = res.Body.Close() }() if res.StatusCode != http.StatusOK { t.Errorf("Expected status OK, got %v", res.Status) @@ -164,6 +174,7 @@ func TestMargePresets(t *testing.T) { func TestMargeUpdatePreset(t *testing.T) { tempDir, _ := os.MkdirTemp("", "soundcork-test-*") defer os.RemoveAll(tempDir) + ds := datastore.NewDataStore(tempDir) account := "12345" @@ -181,6 +192,7 @@ func TestMargeUpdatePreset(t *testing.T) { os.WriteFile(filepath.Join(accountDir, "Presets.xml"), []byte(``), 0644) r, _ := setupRouter("http://localhost:8001", ds) + ts := httptest.NewServer(r) defer ts.Close() @@ -197,7 +209,8 @@ func TestMargeUpdatePreset(t *testing.T) { if err != nil { t.Fatal(err) } - defer res.Body.Close() + + defer func() { _ = res.Body.Close() }() if res.StatusCode != http.StatusOK { body, _ := io.ReadAll(res.Body) @@ -214,6 +227,7 @@ func TestMargeUpdatePreset(t *testing.T) { func TestMargeAddRecent(t *testing.T) { tempDir, _ := os.MkdirTemp("", "soundcork-test-*") defer os.RemoveAll(tempDir) + ds := datastore.NewDataStore(tempDir) account := "12345" @@ -231,6 +245,7 @@ func TestMargeAddRecent(t *testing.T) { os.WriteFile(filepath.Join(accountDir, "Recents.xml"), []byte(``), 0644) r, _ := setupRouter("http://localhost:8001", ds) + ts := httptest.NewServer(r) defer ts.Close() @@ -246,7 +261,8 @@ func TestMargeAddRecent(t *testing.T) { if err != nil { t.Fatal(err) } - defer res.Body.Close() + + defer func() { _ = res.Body.Close() }() if res.StatusCode != http.StatusOK { t.Errorf("Expected status OK, got %v", res.Status) @@ -262,6 +278,7 @@ func TestMargeAddRecent(t *testing.T) { func TestMargeAddRemoveDevice(t *testing.T) { tempDir, _ := os.MkdirTemp("", "soundcork-test-*") defer os.RemoveAll(tempDir) + ds := datastore.NewDataStore(tempDir) account := "12345" @@ -269,6 +286,7 @@ func TestMargeAddRemoveDevice(t *testing.T) { os.MkdirAll(accountDir, 0755) r, _ := setupRouter("http://localhost:8001", ds) + ts := httptest.NewServer(r) defer ts.Close() @@ -294,6 +312,9 @@ func TestMargeAddRemoveDevice(t *testing.T) { if err != nil { t.Fatal(err) } + + res.Body.Close() + if res.StatusCode != http.StatusOK { t.Errorf("AddDevice: Expected status OK, got %v", res.Status) } @@ -305,10 +326,14 @@ func TestMargeAddRemoveDevice(t *testing.T) { // 2. Remove Device req, _ := http.NewRequest(http.MethodDelete, ts.URL+"/marge/accounts/"+account+"/devices/NEWDEV", nil) + res, err = http.DefaultClient.Do(req) if err != nil { t.Fatal(err) } + + res.Body.Close() + if res.StatusCode != http.StatusOK { t.Errorf("RemoveDevice: Expected status OK, got %v", res.Status) } @@ -320,6 +345,7 @@ func TestMargeAddRemoveDevice(t *testing.T) { func TestMargePowerOn(t *testing.T) { r, _ := setupRouter("http://localhost:8001", nil) + ts := httptest.NewServer(r) defer ts.Close() @@ -327,7 +353,8 @@ func TestMargePowerOn(t *testing.T) { if err != nil { t.Fatal(err) } - defer res.Body.Close() + + defer func() { _ = res.Body.Close() }() if res.StatusCode != http.StatusOK { t.Errorf("Expected status OK, got %v", res.Status) @@ -337,9 +364,11 @@ func TestMargePowerOn(t *testing.T) { func TestMargeAdvancedFeatures(t *testing.T) { tempDir, _ := os.MkdirTemp("", "soundcork-test-*") defer os.RemoveAll(tempDir) + ds := datastore.NewDataStore(tempDir) r, _ := setupRouter("http://localhost:8001", ds) + ts := httptest.NewServer(r) defer ts.Close() @@ -348,9 +377,13 @@ func TestMargeAdvancedFeatures(t *testing.T) { if err != nil { t.Fatal(err) } + + defer func() { _ = res.Body.Close() }() + if res.StatusCode != http.StatusOK { t.Errorf("Expected status OK, got %v", res.Status) } + body, _ := io.ReadAll(res.Body) if !strings.Contains(string(body), "123") { t.Errorf("Response body missing account ID: %s", body) @@ -362,9 +395,13 @@ func TestMargeAdvancedFeatures(t *testing.T) { if err != nil { t.Fatal(err) } + + defer func() { _ = res.Body.Close() }() + if res.StatusCode != http.StatusOK { t.Errorf("Expected status OK, got %v", res.Status) } + token := res.Header.Get("Authorization") if !strings.HasPrefix(token, "Bearer soundcork-local-token-") { t.Errorf("Invalid token header: %s", token) @@ -388,10 +425,14 @@ func TestMargeAdvancedFeatures(t *testing.T) { ` + res, err := http.Post(ts.URL+"/marge/streaming/support/customersupport", "application/vnd.bose.streaming-v1.2+xml", strings.NewReader(payload)) if err != nil { t.Fatal(err) } + + res.Body.Close() + if res.StatusCode != http.StatusOK { t.Errorf("Expected status OK, got %v", res.Status) } @@ -399,15 +440,19 @@ func TestMargeAdvancedFeatures(t *testing.T) { // Verify event was recorded events := ds.GetDeviceEvents("587A628A4042") found := false + for _, e := range events { if e.Type == "customer-support-upload" { found = true + if e.Data["firmware"] != "27.0.6" { t.Errorf("Expected firmware 27.0.6, got %v", e.Data["firmware"]) } + break } } + if !found { t.Error("Customer support event not found in event log") } diff --git a/pkg/service/handlers/handlers_media.go b/pkg/service/handlers/handlers_media.go index e0bdb6f..f778401 100644 --- a/pkg/service/handlers/handlers_media.go +++ b/pkg/service/handlers/handlers_media.go @@ -24,16 +24,18 @@ func (s *Server) HandleRoot(w http.ResponseWriter, r *http.Request) { accept := r.Header.Get("Accept") if !strings.Contains(accept, "text/html") && (strings.Contains(accept, "application/json") || accept == "*/*" || accept == "") { w.Header().Set("Content-Type", "application/json") - fmt.Fprintf(w, `{"Bose": "Can't Brick Us", "service": "Go/Chi"}`) + _, _ = fmt.Fprintf(w, `{"Bose": "Can't Brick Us", "service": "Go/Chi"}`) + return } w.Header().Set("Content-Type", "text/html") - w.Write(indexHTML) + _, _ = w.Write(indexHTML) } func (s *Server) HandleMedia() http.HandlerFunc { subFS, _ := fs.Sub(mediaFS, "soundcork/media") + return func(w http.ResponseWriter, r *http.Request) { fs := http.StripPrefix("/media/", http.FileServer(http.FS(subFS))) fs.ServeHTTP(w, r) diff --git a/pkg/service/handlers/handlers_media_test.go b/pkg/service/handlers/handlers_media_test.go index 06dc69e..4f03fdd 100644 --- a/pkg/service/handlers/handlers_media_test.go +++ b/pkg/service/handlers/handlers_media_test.go @@ -10,6 +10,7 @@ import ( func TestRootEndpoint(t *testing.T) { r, _ := setupRouter("http://localhost:8001", nil) + ts := httptest.NewServer(r) defer ts.Close() @@ -21,7 +22,8 @@ func TestRootEndpoint(t *testing.T) { if err != nil { t.Fatal(err) } - defer res.Body.Close() + + defer func() { _ = res.Body.Close() }() if res.StatusCode != http.StatusOK { t.Errorf("Expected status OK, got %v", res.Status) @@ -40,6 +42,7 @@ func TestRootEndpoint(t *testing.T) { func TestRootEndpointJSON(t *testing.T) { r, _ := setupRouter("http://localhost:8001", nil) + ts := httptest.NewServer(r) defer ts.Close() @@ -51,7 +54,8 @@ func TestRootEndpointJSON(t *testing.T) { if err != nil { t.Fatal(err) } - defer res.Body.Close() + + defer func() { _ = res.Body.Close() }() if res.StatusCode != http.StatusOK { t.Errorf("Expected status OK, got %v", res.Status) @@ -63,6 +67,7 @@ func TestRootEndpointJSON(t *testing.T) { } body, _ := io.ReadAll(res.Body) + expected := `{"Bose": "Can't Brick Us", "service": "Go/Chi"}` if strings.TrimSpace(string(body)) != expected { t.Errorf("Expected body %s, got %s", expected, string(body)) @@ -71,6 +76,7 @@ func TestRootEndpointJSON(t *testing.T) { func TestStaticMedia(t *testing.T) { r, _ := setupRouter("http://localhost:8001", nil) + ts := httptest.NewServer(r) defer ts.Close() @@ -79,7 +85,8 @@ func TestStaticMedia(t *testing.T) { if err != nil { t.Fatal(err) } - defer res.Body.Close() + + defer func() { _ = res.Body.Close() }() if res.StatusCode != http.StatusOK { t.Errorf("Expected status OK, got %v", res.Status) diff --git a/pkg/service/handlers/handlers_proxy.go b/pkg/service/handlers/handlers_proxy.go index 424910f..c0fc1a4 100644 --- a/pkg/service/handlers/handlers_proxy.go +++ b/pkg/service/handlers/handlers_proxy.go @@ -54,6 +54,7 @@ func (s *Server) HandleProxyRequest(w http.ResponseWriter, r *http.Request) { } lp.LogResponse(res) + return nil } diff --git a/pkg/service/handlers/handlers_setup.go b/pkg/service/handlers/handlers_setup.go index 029bd57..9897c12 100644 --- a/pkg/service/handlers/handlers_setup.go +++ b/pkg/service/handlers/handlers_setup.go @@ -15,23 +15,24 @@ func (s *Server) HandleListDiscoveredDevices(w http.ResponseWriter, r *http.Requ } w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(devices) + _ = json.NewEncoder(w).Encode(devices) } func (s *Server) HandleTriggerDiscovery(w http.ResponseWriter, r *http.Request) { - go s.DiscoverDevices() + go s.DiscoverDevices(r.Context()) + w.WriteHeader(http.StatusAccepted) w.Write([]byte(`{"status": "Discovery started"}`)) } func (s *Server) HandleGetDiscoveryStatus(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(map[string]bool{"discovering": s.discovering}) + _ = json.NewEncoder(w).Encode(map[string]bool{"discovering": s.discovering}) } func (s *Server) HandleGetSettings(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(map[string]string{ + _ = json.NewEncoder(w).Encode(map[string]string{ "server_url": s.serverURL, "proxy_url": s.proxyURL, }) @@ -51,7 +52,7 @@ func (s *Server) HandleGetDeviceInfo(w http.ResponseWriter, r *http.Request) { } w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(info) + _ = json.NewEncoder(w).Encode(info) } func (s *Server) HandleGetMigrationSummary(w http.ResponseWriter, r *http.Request) { @@ -65,6 +66,7 @@ func (s *Server) HandleGetMigrationSummary(w http.ResponseWriter, r *http.Reques proxyURL := r.URL.Query().Get("proxy_url") options := make(map[string]string) + for k, v := range r.URL.Query() { if len(v) > 0 && (k == "marge" || k == "stats" || k == "sw_update" || k == "bmx") { options[k] = v[0] @@ -78,7 +80,7 @@ func (s *Server) HandleGetMigrationSummary(w http.ResponseWriter, r *http.Reques } w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(summary) + _ = json.NewEncoder(w).Encode(summary) } func (s *Server) HandleMigrateDevice(w http.ResponseWriter, r *http.Request) { @@ -86,7 +88,8 @@ func (s *Server) HandleMigrateDevice(w http.ResponseWriter, r *http.Request) { if deviceIP == "" { w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusBadRequest) - json.NewEncoder(w).Encode(map[string]interface{}{"ok": false, "message": "Device IP is required"}) + _ = json.NewEncoder(w).Encode(map[string]interface{}{"ok": false, "message": "Device IP is required"}) + return } @@ -94,6 +97,7 @@ func (s *Server) HandleMigrateDevice(w http.ResponseWriter, r *http.Request) { proxyURL := r.URL.Query().Get("proxy_url") options := make(map[string]string) + for k, v := range r.URL.Query() { if len(v) > 0 && (k == "marge" || k == "stats" || k == "sw_update" || k == "bmx") { options[k] = v[0] @@ -103,12 +107,13 @@ func (s *Server) HandleMigrateDevice(w http.ResponseWriter, r *http.Request) { if err := s.sm.MigrateSpeaker(deviceIP, targetURL, proxyURL, options); err != nil { w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusInternalServerError) - json.NewEncoder(w).Encode(map[string]interface{}{"ok": false, "message": err.Error()}) + _ = json.NewEncoder(w).Encode(map[string]interface{}{"ok": false, "message": err.Error()}) + return } w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(map[string]interface{}{"ok": true, "message": "Migration started"}) + _ = json.NewEncoder(w).Encode(map[string]interface{}{"ok": true, "message": "Migration started"}) } func (s *Server) HandleEnsureRemoteServices(w http.ResponseWriter, r *http.Request) { @@ -116,19 +121,21 @@ func (s *Server) HandleEnsureRemoteServices(w http.ResponseWriter, r *http.Reque if deviceIP == "" { w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusBadRequest) - json.NewEncoder(w).Encode(map[string]interface{}{"ok": false, "message": "Device IP is required"}) + _ = json.NewEncoder(w).Encode(map[string]interface{}{"ok": false, "message": "Device IP is required"}) + return } if err := s.sm.EnsureRemoteServices(deviceIP); err != nil { w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusInternalServerError) - json.NewEncoder(w).Encode(map[string]interface{}{"ok": false, "message": err.Error()}) + _ = json.NewEncoder(w).Encode(map[string]interface{}{"ok": false, "message": err.Error()}) + return } w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(map[string]interface{}{"ok": true, "message": "Remote services ensured"}) + _ = json.NewEncoder(w).Encode(map[string]interface{}{"ok": true, "message": "Remote services ensured"}) } func (s *Server) HandleBackupConfig(w http.ResponseWriter, r *http.Request) { @@ -136,24 +143,26 @@ func (s *Server) HandleBackupConfig(w http.ResponseWriter, r *http.Request) { if deviceIP == "" { w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusBadRequest) - json.NewEncoder(w).Encode(map[string]interface{}{"ok": false, "message": "Device IP is required"}) + _ = json.NewEncoder(w).Encode(map[string]interface{}{"ok": false, "message": "Device IP is required"}) + return } if err := s.sm.BackupConfig(deviceIP); err != nil { w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusInternalServerError) - json.NewEncoder(w).Encode(map[string]interface{}{"ok": false, "message": err.Error()}) + _ = json.NewEncoder(w).Encode(map[string]interface{}{"ok": false, "message": err.Error()}) + return } w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(map[string]interface{}{"ok": true, "message": "Backup created"}) + _ = json.NewEncoder(w).Encode(map[string]interface{}{"ok": true, "message": "Backup created"}) } func (s *Server) HandleGetProxySettings(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(map[string]bool{ + _ = json.NewEncoder(w).Encode(map[string]bool{ "redact": s.proxyRedact, "log_body": s.proxyLogBody, }) @@ -173,5 +182,5 @@ func (s *Server) HandleUpdateProxySettings(w http.ResponseWriter, r *http.Reques s.proxyLogBody = settings.LogBody w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(map[string]interface{}{"ok": true, "message": "Proxy settings updated"}) + _ = json.NewEncoder(w).Encode(map[string]interface{}{"ok": true, "message": "Proxy settings updated"}) } diff --git a/pkg/service/handlers/handlers_setup_test.go b/pkg/service/handlers/handlers_setup_test.go index b50e83f..bc16a2a 100644 --- a/pkg/service/handlers/handlers_setup_test.go +++ b/pkg/service/handlers/handlers_setup_test.go @@ -10,6 +10,7 @@ import ( func TestProxySettingsAPI(t *testing.T) { r, server := setupRouter("http://localhost:8001", nil) + ts := httptest.NewServer(r) defer ts.Close() @@ -22,7 +23,8 @@ func TestProxySettingsAPI(t *testing.T) { if err != nil { t.Fatal(err) } - defer res.Body.Close() + + defer func() { _ = res.Body.Close() }() if res.StatusCode != http.StatusOK { t.Errorf("GET: Expected status OK, got %v", res.Status) @@ -43,11 +45,13 @@ func TestProxySettingsAPI(t *testing.T) { "log_body": true, } body, _ := json.Marshal(update) + res, err = http.Post(ts.URL+"/setup/proxy-settings", "application/json", bytes.NewBuffer(body)) if err != nil { t.Fatal(err) } - defer res.Body.Close() + + defer func() { _ = res.Body.Close() }() if res.StatusCode != http.StatusOK { t.Errorf("POST: Expected status OK, got %v", res.Status) @@ -58,10 +62,17 @@ func TestProxySettingsAPI(t *testing.T) { t.Errorf("POST: Server state did not update: redact=%v, logBody=%v", server.proxyRedact, server.proxyLogBody) } - // 3. Verify GET reflects new state - res, _ = http.Get(ts.URL + "/setup/proxy-settings") - defer res.Body.Close() - json.NewDecoder(res.Body).Decode(&settings) + res, err = http.Get(ts.URL + "/setup/proxy-settings") + if err != nil { + t.Fatal(err) + } + + defer func() { _ = res.Body.Close() }() + + if err := json.NewDecoder(res.Body).Decode(&settings); err != nil { + t.Fatalf("GET (after update): Failed to decode response: %v", err) + } + if settings["redact"] != false || settings["log_body"] != true { t.Errorf("GET (after update): Unexpected settings: %+v", settings) } diff --git a/pkg/service/handlers/handlers_stats.go b/pkg/service/handlers/handlers_stats.go index 7a1504d..16eba6d 100644 --- a/pkg/service/handlers/handlers_stats.go +++ b/pkg/service/handlers/handlers_stats.go @@ -42,6 +42,7 @@ func (s *Server) HandleUsageStats(w http.ResponseWriter, r *http.Request) { if event.Time == "" { event.Time = time.Now().Format(time.RFC3339) } + s.ds.AddDeviceEvent(stats.DeviceID, event) w.WriteHeader(http.StatusOK) @@ -81,6 +82,7 @@ func (s *Server) HandleErrorStats(w http.ResponseWriter, r *http.Request) { if event.Time == "" { event.Time = time.Now().Format(time.RFC3339) } + s.ds.AddDeviceEvent(stats.DeviceID, event) w.WriteHeader(http.StatusOK) diff --git a/pkg/service/handlers/main_test.go b/pkg/service/handlers/main_test.go index d0d0685..8953032 100644 --- a/pkg/service/handlers/main_test.go +++ b/pkg/service/handlers/main_test.go @@ -53,6 +53,7 @@ func setupRouter(targetURL string, ds *datastore.DataStore) (*chi.Mux, *Server) r.NotFound(func(w http.ResponseWriter, r *http.Request) { proxy.ServeHTTP(w, r) }) + return r, server } diff --git a/pkg/service/handlers/server.go b/pkg/service/handlers/server.go index f4860db..009ebf4 100644 --- a/pkg/service/handlers/server.go +++ b/pkg/service/handlers/server.go @@ -32,15 +32,22 @@ func NewServer(ds *datastore.DataStore, sm *setup.Manager, serverURL string, pro } } -func (s *Server) DiscoverDevices() { +func (s *Server) DiscoverDevices(ctx context.Context) { s.discovering = true + defer func() { s.discovering = false }() log.Println("Scanning for Bose devices...") - ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) - defer cancel() + + if ctx == nil { + var cancel context.CancelFunc + + ctx, cancel = context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + } svc := discovery.NewService(10 * time.Second) + devices, err := svc.DiscoverDevices(ctx) if err != nil { log.Printf("Discovery error: %v", err) @@ -60,6 +67,7 @@ func (s *Server) DiscoverDevices() { if existingID == "" { existingID = known.IPAddress } + break } } @@ -69,6 +77,7 @@ func (s *Server) DiscoverDevices() { if deviceID == "" { // If serial is missing from discovery, try to fetch it from :8090/info log.Printf("Serial number missing for %s at %s, attempting live info fetch...", d.Name, d.Host) + liveInfo, err := s.sm.GetLiveDeviceInfo(d.Host) if err == nil && liveInfo.SerialNumber != "" { d.SerialNo = liveInfo.SerialNumber @@ -98,7 +107,7 @@ func (s *Server) DiscoverDevices() { // If we had an IP-based entry and now have a Serial, clean up the IP-based entry if d.SerialNo != "" && existingID != "" && existingID != d.SerialNo { log.Printf("Device %s previously known as %s, migrating to serial-based ID %s", d.Name, existingID, d.SerialNo) - s.ds.RemoveDevice("default", existingID) + _ = s.ds.RemoveDevice("default", existingID) } if err := s.ds.SaveDeviceInfo("default", deviceID, info); err != nil { diff --git a/pkg/service/marge/marge.go b/pkg/service/marge/marge.go index 3d5b388..b3991b4 100644 --- a/pkg/service/marge/marge.go +++ b/pkg/service/marge/marge.go @@ -7,9 +7,9 @@ import ( "strconv" "time" + "github.com/gesellix/bose-soundtouch/pkg/models" "github.com/gesellix/bose-soundtouch/pkg/service/constants" "github.com/gesellix/bose-soundtouch/pkg/service/datastore" - "github.com/gesellix/bose-soundtouch/pkg/models" ) const DateStr = "2012-09-19T12:43:00.000+00:00" @@ -24,6 +24,7 @@ func SourceProviders() []models.SourceProvider { UpdatedOn: DateStr, } } + return providers } @@ -36,10 +37,12 @@ func SourceProvidersToXML() ([]byte, error) { sp := SourceProvidersXML{ Providers: SourceProviders(), } + data, err := xml.MarshalIndent(sp, "", " ") if err != nil { return nil, err } + return append([]byte(xml.Header), data...), nil } @@ -62,6 +65,7 @@ func ConfiguredSourceToXML(cs models.ConfiguredSource) ([]byte, error) { } providerID := 0 + for i, p := range constants.Providers { if p == cs.SourceKeyType { providerID = i + 1 @@ -87,12 +91,14 @@ func ConfiguredSourceToXML(cs models.ConfiguredSource) ([]byte, error) { func GetConfiguredSourceXML(cs models.ConfiguredSource) string { providerID := 0 + for i, p := range constants.Providers { if p == cs.SourceKeyType { providerID = i + 1 break } } + return fmt.Sprintf(`%s%s%s%d%s%s%s`, cs.ID, DateStr, cs.Secret, cs.SourceKeyAccount, providerID, cs.DisplayName, DateStr, cs.SourceKeyAccount) } @@ -102,6 +108,7 @@ func PresetsToXML(ds *datastore.DataStore, account string) ([]byte, error) { if err != nil { return nil, err } + sources, err := ds.GetConfiguredSources(account) if err != nil { return nil, err @@ -118,13 +125,16 @@ func PresetsToXML(ds *datastore.DataStore, account string) ([]byte, error) { // Content Item Source found := false + for _, s := range sources { if s.ID == p.SourceID || (s.SourceKeyType == p.Source && s.SourceKeyAccount == p.SourceAccount) { res += GetConfiguredSourceXML(s) found = true + break } } + if !found { // This might happen if source is not found } @@ -132,6 +142,7 @@ func PresetsToXML(ds *datastore.DataStore, account string) ([]byte, error) { res += fmt.Sprintf(`%s`, DateStr) res += `` } + res += `` return append([]byte(xml.Header), []byte(res)...), nil @@ -142,12 +153,14 @@ func RecentsToXML(ds *datastore.DataStore, account string) ([]byte, error) { if err != nil { return nil, err } + sources, err := ds.GetConfiguredSources(account) if err != nil { return nil, err } res := `` + for _, r := range recents { lastPlayed := "" if sec, err := strconv.ParseInt(r.UtcTime, 10, 64); err == nil { @@ -162,19 +175,23 @@ func RecentsToXML(ds *datastore.DataStore, account string) ([]byte, error) { res += fmt.Sprintf(`%s`, r.Name) found := false + for _, s := range sources { if s.ID == r.SourceID || (s.SourceKeyType == r.Source && s.SourceKeyAccount == r.SourceAccount) { res += GetConfiguredSourceXML(s) found = true + break } } + if !found { } res += fmt.Sprintf(`%s`, DateStr) res += `` } + res += `` return append([]byte(xml.Header), []byte(res)...), nil @@ -190,6 +207,7 @@ func SoftwareUpdateToXML() string { func AccountFullToXML(ds *datastore.DataStore, account string) ([]byte, error) { devicesDir := ds.AccountDevicesDir(account) + entries, err := os.ReadDir(devicesDir) if err != nil { return nil, err @@ -197,10 +215,12 @@ func AccountFullToXML(ds *datastore.DataStore, account string) ([]byte, error) { res := fmt.Sprintf(`OK`, account) lastDeviceID := "" + for _, entry := range entries { if entry.IsDir() { deviceID := entry.Name() lastDeviceID = deviceID + info, err := ds.GetDeviceInfo(account, deviceID) if err != nil { continue @@ -229,19 +249,23 @@ func AccountFullToXML(ds *datastore.DataStore, account string) ([]byte, error) { res += `` } } + res += `globalen` res += ProviderSettingsToXML(account) if lastDeviceID != "" { sources, _ := ds.GetConfiguredSources(account) + res += `` for _, s := range sources { res += GetConfiguredSourceXML(s) } + res += `` } res += `` + return []byte(res), nil } @@ -250,6 +274,7 @@ func UpdatePreset(ds *datastore.DataStore, account string, device string, preset if err != nil { return nil, err } + presets, err := ds.GetPresets(account) if err != nil { return nil, err @@ -267,12 +292,14 @@ func UpdatePreset(ds *datastore.DataStore, account string, device string, preset } var matchingSrc *models.ConfiguredSource + for _, s := range sources { if s.ID == newPresetElem.SourceID { matchingSrc = &s break } } + if matchingSrc == nil { return nil, fmt.Errorf("invalid account/source") } @@ -297,6 +324,7 @@ func UpdatePreset(ds *datastore.DataStore, account string, device string, preset for len(presets) < presetNumber { presets = append(presets, models.ServicePreset{}) } + presets[presetNumber-1] = presetObj if err := ds.SavePresets(account, presets); err != nil { @@ -322,6 +350,7 @@ func AddRecent(ds *datastore.DataStore, account string, device string, sourceXML if err != nil { return nil, err } + recents, err := ds.GetRecents(account) if err != nil { return nil, err @@ -339,17 +368,20 @@ func AddRecent(ds *datastore.DataStore, account string, device string, sourceXML } var matchingSrc *models.ConfiguredSource + for _, s := range sources { if s.ID == newRecentElem.SourceID { matchingSrc = &s break } } + if matchingSrc == nil { return nil, fmt.Errorf("invalid account/source") } utcTime := time.Now().Unix() + if newRecentElem.LastPlayedAt != "" { if t, err := time.Parse(time.RFC3339, newRecentElem.LastPlayedAt); err == nil { utcTime = t.Unix() @@ -358,7 +390,9 @@ func AddRecent(ds *datastore.DataStore, account string, device string, sourceXML // Find existing var recentObj *models.ServiceRecent + createdOn := DateStr + for i, r := range recents { if r.Source == matchingSrc.SourceKeyType && r.Location == newRecentElem.Location && r.SourceAccount == matchingSrc.SourceKeyAccount { recents[i].UtcTime = strconv.FormatInt(utcTime, 10) @@ -369,6 +403,7 @@ func AddRecent(ds *datastore.DataStore, account string, device string, sourceXML // Move to front recents = append([]models.ServiceRecent{*recentObj}, append(recents[:i], recents[i+1:]...)...) + break } } @@ -380,6 +415,7 @@ func AddRecent(ds *datastore.DataStore, account string, device string, sourceXML maxID = id } } + recentObj = &models.ServiceRecent{ ServiceContentItem: models.ServiceContentItem{ ID: strconv.Itoa(maxID + 1), @@ -395,6 +431,7 @@ func AddRecent(ds *datastore.DataStore, account string, device string, sourceXML UtcTime: strconv.FormatInt(utcTime, 10), } createdOn = time.Now().Format(time.RFC3339) + recents = append([]models.ServiceRecent{*recentObj}, recents...) if len(recents) > 10 { recents = recents[:10] diff --git a/pkg/service/marge/marge_test.go b/pkg/service/marge/marge_test.go index dfb3985..2c94833 100644 --- a/pkg/service/marge/marge_test.go +++ b/pkg/service/marge/marge_test.go @@ -6,13 +6,14 @@ import ( "testing" "time" - "github.com/gesellix/bose-soundtouch/pkg/service/datastore" "github.com/gesellix/bose-soundtouch/pkg/models" + "github.com/gesellix/bose-soundtouch/pkg/service/datastore" ) func TestMargeXML(t *testing.T) { tempDir, _ := os.MkdirTemp("", "marge-test-*") defer os.RemoveAll(tempDir) + ds := datastore.NewDataStore(tempDir) account := "123" device := "ABC" @@ -22,17 +23,18 @@ func TestMargeXML(t *testing.T) { DeviceID: device, Name: "Living Room", } - ds.SaveDeviceInfo(account, device, info) + _ = ds.SaveDeviceInfo(account, device, info) // Save empty presets/recents to avoid index out of range when stripping header - ds.SavePresets(account, []models.ServicePreset{}) - ds.SaveRecents(account, []models.ServiceRecent{}) + _ = ds.SavePresets(account, []models.ServicePreset{}) + _ = ds.SaveRecents(account, []models.ServiceRecent{}) // Test SourceProvidersToXML xmlData, err := SourceProvidersToXML() if err != nil { t.Fatal(err) } + if !strings.Contains(string(xmlData), "") { t.Errorf("Expected , got %s", string(xmlData)) } @@ -42,9 +44,11 @@ func TestMargeXML(t *testing.T) { if err != nil { t.Fatal(err) } + if !strings.Contains(string(fullXML), `id="123"`) { t.Errorf("Expected account id 123, got %s", string(fullXML)) } + if !strings.Contains(string(fullXML), "Living Room") { t.Errorf("Expected device name Living Room, got %s", string(fullXML)) } @@ -59,6 +63,7 @@ func TestMargeXML(t *testing.T) { func TestAddRecent_TimestampPreservation(t *testing.T) { tempDir, _ := os.MkdirTemp("", "marge-timestamp-test-*") defer os.RemoveAll(tempDir) + ds := datastore.NewDataStore(tempDir) account := "test-acc" device := "test-dev" @@ -66,8 +71,8 @@ func TestAddRecent_TimestampPreservation(t *testing.T) { // 1. Setup configured sources // We need a Sources.xml file in the account directory sourcesPath := ds.AccountDir(account) - os.MkdirAll(sourcesPath, 0755) - ds.SaveConfiguredSources(account, []models.ConfiguredSource{ + _ = os.MkdirAll(sourcesPath, 0755) + _ = ds.SaveConfiguredSources(account, []models.ConfiguredSource{ { ID: "101", DisplayName: "Test Source", @@ -75,7 +80,7 @@ func TestAddRecent_TimestampPreservation(t *testing.T) { SourceKeyAccount: "test-user", }, }) - ds.SaveRecents(account, []models.ServiceRecent{}) + _ = ds.SaveRecents(account, []models.ServiceRecent{}) // 2. Add an initial recent sourceXML := []byte(` @@ -85,6 +90,7 @@ func TestAddRecent_TimestampPreservation(t *testing.T) { station-1 station `) + _, err := AddRecent(ds, account, device, sourceXML) if err != nil { t.Fatalf("AddRecent failed: %v", err) @@ -94,6 +100,7 @@ func TestAddRecent_TimestampPreservation(t *testing.T) { if len(recents) != 1 { t.Fatalf("Expected 1 recent, got %d", len(recents)) } + originalCreatedOn := recents[0].UtcTime // It's stored in UtcTime field (unix string) in models.ServiceRecent but the AddRecent return XML uses tag which is DateStr or Now depending on logic. // Actually let's check what AddRecent returns. diff --git a/pkg/service/proxy/proxy.go b/pkg/service/proxy/proxy.go index a43923d..c788a03 100644 --- a/pkg/service/proxy/proxy.go +++ b/pkg/service/proxy/proxy.go @@ -38,9 +38,11 @@ func (lp *LoggingProxy) LogRequest(r *http.Request) { headers := formatHeaders(r.Header, lp.Redact) bodyStr := "[HIDDEN]" + if lp.LogBody && shouldLogBody(r.Header.Get("Content-Type")) { if r.Body != nil { bodyBytes, _ := io.ReadAll(r.Body) + r.Body = io.NopCloser(bytes.NewBuffer(bodyBytes)) if int64(len(bodyBytes)) > lp.MaxBodySize { bodyStr = string(bodyBytes[:lp.MaxBodySize]) + "... [TRUNCATED]" @@ -59,9 +61,11 @@ func (lp *LoggingProxy) LogResponse(r *http.Response) { headers := formatHeaders(r.Header, lp.Redact) bodyStr := "[HIDDEN]" + if lp.LogBody && shouldLogBody(r.Header.Get("Content-Type")) { if r.Body != nil { bodyBytes, _ := io.ReadAll(r.Body) + r.Body = io.NopCloser(bytes.NewBuffer(bodyBytes)) if int64(len(bodyBytes)) > lp.MaxBodySize { bodyStr = string(bodyBytes[:lp.MaxBodySize]) + "... [TRUNCATED]" @@ -86,8 +90,10 @@ func formatHeaders(h http.Header, redact bool) string { if redact && isSensitive(k) { val = "[REDACTED]" } + sb.WriteString(fmt.Sprintf(" %s: %s\n", k, val)) } + return strings.TrimSuffix(sb.String(), "\n") } @@ -97,11 +103,13 @@ func isSensitive(header string) bool { return true } } + return false } func shouldLogBody(contentType string) bool { contentType = strings.ToLower(contentType) + return strings.Contains(contentType, "xml") || strings.Contains(contentType, "json") || strings.Contains(contentType, "text") || diff --git a/pkg/service/proxy/proxy_test.go b/pkg/service/proxy/proxy_test.go index 5ada146..b8b92c5 100644 --- a/pkg/service/proxy/proxy_test.go +++ b/pkg/service/proxy/proxy_test.go @@ -58,6 +58,7 @@ func TestShouldLogBody(t *testing.T) { func TestLoggingProxy_LogRequest(t *testing.T) { os.Setenv("LOG_PROXY_BODY", "true") + defer os.Unsetenv("LOG_PROXY_BODY") lp := NewLoggingProxy("http://example.com", true) diff --git a/pkg/service/setup/setup.go b/pkg/service/setup/setup.go index afc1b5c..876c348 100644 --- a/pkg/service/setup/setup.go +++ b/pkg/service/setup/setup.go @@ -77,24 +77,26 @@ func (m *Manager) GetLiveDeviceInfo(deviceIP string) (*DeviceInfoXML, error) { infoURL = fmt.Sprintf("http://%s/info", deviceIP) _ = host } + resp, err := http.Get(infoURL) if err != nil { - return nil, fmt.Errorf("failed to fetch info from %s: %v", infoURL, err) + return nil, fmt.Errorf("failed to fetch info from %s: %w", infoURL, err) } defer resp.Body.Close() var infoXML DeviceInfoXML if err := xml.NewDecoder(resp.Body).Decode(&infoXML); err != nil { - return nil, fmt.Errorf("failed to decode info XML from %s: %v", infoURL, err) + return nil, fmt.Errorf("failed to decode info XML from %s: %w", infoURL, err) } for _, comp := range infoXML.Components { - if comp.Category == "SCM" { + switch comp.Category { + case "SCM": infoXML.SoftwareVer = comp.SoftwareVersion if infoXML.SerialNumber == "" { infoXML.SerialNumber = comp.SerialNumber } - } else if comp.Category == "PackagedProduct" { + case "PackagedProduct": if infoXML.SerialNumber == "" { infoXML.SerialNumber = comp.SerialNumber } @@ -109,6 +111,7 @@ func (m *Manager) GetMigrationSummary(deviceIP string, targetURL string, proxyUR if targetURL == "" { targetURL = m.ServerURL } + client := ssh.NewClient(deviceIP) summary := &MigrationSummary{ @@ -125,6 +128,7 @@ func (m *Manager) GetMigrationSummary(deviceIP string, targetURL string, proxyUR summary.DeviceModel = d.ProductCode summary.DeviceSerial = d.DeviceSerialNumber summary.FirmwareVersion = d.FirmwareVersion + break } } @@ -139,12 +143,15 @@ func (m *Manager) GetMigrationSummary(deviceIP string, targetURL string, proxyUR if infoXML.Name != "" { summary.DeviceName = infoXML.Name } + if infoXML.Type != "" { summary.DeviceModel = infoXML.Type } + if infoXML.SerialNumber != "" { summary.DeviceSerial = infoXML.SerialNumber } + if infoXML.SoftwareVer != "" { summary.FirmwareVersion = infoXML.SoftwareVer } @@ -165,6 +172,7 @@ func (m *Manager) GetMigrationSummary(deviceIP string, targetURL string, proxyUR // 2. Check SSH and read current config var currentConfig string + path := SoundTouchSdkPrivateCfgPath client = ssh.NewClient(deviceIP) @@ -231,6 +239,7 @@ func (m *Manager) GetMigrationSummary(deviceIP string, targetURL string, proxyUR // Fallback: try base64 if cat returned empty string but file has size > 0 if config == "" && fileInfo != "" { fmt.Printf("Cat returned empty for %s, trying base64\n", path) + b64Config, err := client.Run(fmt.Sprintf("base64 %s", path)) if err == nil && b64Config != "" { fmt.Printf("Base64 output for %s (length %d)\n", path, len(b64Config)) @@ -253,8 +262,9 @@ func (m *Manager) GetMigrationSummary(deviceIP string, targetURL string, proxyUR xmlContent, err := xml.MarshalIndent(plannedCfg, "", " ") if err != nil { - return nil, fmt.Errorf("failed to marshal planned XML: %v", err) + return nil, fmt.Errorf("failed to marshal planned XML: %w", err) } + summary.PlannedConfig = "\n" + string(xmlContent) // 3. Check for remote services files @@ -268,6 +278,7 @@ func (m *Manager) GetMigrationSummary(deviceIP string, targetURL string, proxyUR _, err := client.Run(fmt.Sprintf("[ -e %s ]", loc)) if err == nil { summary.RemoteServicesFound = append(summary.RemoteServicesFound, loc) + summary.RemoteServicesEnabled = true if loc != "/tmp/remote_services" { summary.RemoteServicesPersistent = true @@ -283,6 +294,7 @@ func (m *Manager) MigrateSpeaker(deviceIP string, targetURL string, proxyURL str if targetURL == "" { targetURL = m.ServerURL } + if err := m.EnsureRemoteServices(deviceIP); err != nil { // Log but continue migration? Or fail? The requirement is "to ensure stable 'remote_services'" // Let's log it. @@ -312,12 +324,15 @@ func (m *Manager) MigrateSpeaker(deviceIP string, targetURL string, proxyURL str if options["marge"] == "original" { cfg.MargeServerUrl = fmt.Sprintf("%s/proxy/%s", proxyURL, currentCfg.MargeServerUrl) } + if options["stats"] == "original" { cfg.StatsServerUrl = fmt.Sprintf("%s/proxy/%s", proxyURL, currentCfg.StatsServerUrl) } + if options["sw_update"] == "original" { cfg.SwUpdateUrl = fmt.Sprintf("%s/proxy/%s", proxyURL, currentCfg.SwUpdateUrl) } + if options["bmx"] == "original" { cfg.BmxRegistryUrl = fmt.Sprintf("%s/proxy/%s", proxyURL, currentCfg.BmxRegistryUrl) } @@ -332,7 +347,7 @@ func (m *Manager) MigrateSpeaker(deviceIP string, targetURL string, proxyURL str xmlContent, err := xml.MarshalIndent(cfg, "", " ") if err != nil { - return fmt.Errorf("failed to marshal XML: %v", err) + return fmt.Errorf("failed to marshal XML: %w", err) } // Add XML header @@ -341,6 +356,7 @@ func (m *Manager) MigrateSpeaker(deviceIP string, targetURL string, proxyURL str // 0. Backup original config if it doesn't exist remotePath := SoundTouchSdkPrivateCfgPath rwCmd := "(rw || mount -o remount,rw /)" + if _, err := client.Run(fmt.Sprintf("[ -f %s.original ]", remotePath)); err != nil { fmt.Printf("Backing up original config to %s.original\n", remotePath) // Try to copy existing config to .original, ensuring filesystem is writable @@ -361,12 +377,12 @@ func (m *Manager) MigrateSpeaker(deviceIP string, targetURL string, proxyURL str // Actually, let's call rw before UploadContent here. _, _ = client.Run(rwCmd) if err := client.UploadContent(xmlContent, remotePath); err != nil { - return fmt.Errorf("failed to upload config: %v", err) + return fmt.Errorf("failed to upload config: %w", err) } // 2. Reboot the speaker (requires 'rw' command first to make filesystem writable) if _, err := client.Run(fmt.Sprintf("%s && reboot", rwCmd)); err != nil { - return fmt.Errorf("failed to reboot speaker: %v", err) + return fmt.Errorf("failed to reboot speaker: %w", err) } return nil @@ -393,13 +409,13 @@ func (m *Manager) BackupConfig(deviceIP string) error { // Fallback to cat + upload config, err := client.Run(fmt.Sprintf("cat %s", remotePath)) if err != nil || config == "" { - return fmt.Errorf("failed to read current config: %v", err) + return fmt.Errorf("failed to read current config: %w", err) } // Ensure rw before upload fallback _, _ = client.Run(rwCmd) if err := client.UploadContent([]byte(config), remotePath+".original"); err != nil { - return fmt.Errorf("failed to upload backup config: %v", err) + return fmt.Errorf("failed to upload backup config: %w", err) } return nil diff --git a/pkg/service/setup/setup_test.go b/pkg/service/setup/setup_test.go index 94ed2f5..affaa66 100644 --- a/pkg/service/setup/setup_test.go +++ b/pkg/service/setup/setup_test.go @@ -13,6 +13,7 @@ func TestGetLiveDeviceInfo(t *testing.T) { if r.URL.Path != "/info" { t.Errorf("Expected to request /info, got %s", r.URL.Path) } + w.Header().Set("Content-Type", "application/xml") fmt.Fprint(w, ` @@ -34,8 +35,8 @@ func TestGetLiveDeviceInfo(t *testing.T) { host := server.Listener.Addr().String() manager := NewManager("http://localhost:8000", nil) - info, err := manager.GetLiveDeviceInfo(host) + info, err := manager.GetLiveDeviceInfo(host) if err != nil { t.Fatalf("Failed to get live device info: %v", err) } @@ -43,9 +44,11 @@ func TestGetLiveDeviceInfo(t *testing.T) { if info.Name != "Test Speaker" { t.Errorf("Expected Name 'Test Speaker', got '%s'", info.Name) } + if info.SoftwareVer != "19.0.5" { t.Errorf("Expected SoftwareVer '19.0.5', got '%s'", info.SoftwareVer) } + if info.SerialNumber != "08DF1F0BA325" { t.Errorf("Expected SerialNumber '08DF1F0BA325', got '%s'", info.SerialNumber) } @@ -65,6 +68,7 @@ func TestGetMigrationSummary_SSHFailure(t *testing.T) { if summary.SSHSuccess { t.Errorf("Expected SSHSuccess to be false for closed port, got true") } + if summary.CurrentConfig == "" { t.Errorf("Expected CurrentConfig to contain error message, got empty string") } diff --git a/pkg/service/ssh/ssh.go b/pkg/service/ssh/ssh.go index 2909452..c15a428 100644 --- a/pkg/service/ssh/ssh.go +++ b/pkg/service/ssh/ssh.go @@ -68,19 +68,21 @@ func (c *Client) getConfig() *ssh.ClientConfig { // Run executes a command on the remote host and returns the combined stdout and stderr. func (c *Client) Run(command string) (string, error) { config := c.getConfig() + client, err := ssh.Dial("tcp", c.Host+":22", config) if err != nil { - return "", fmt.Errorf("failed to dial: %v", err) + return "", fmt.Errorf("failed to dial: %w", err) } defer client.Close() session, err := client.NewSession() if err != nil { - return "", fmt.Errorf("failed to create session: %v", err) + return "", fmt.Errorf("failed to create session: %w", err) } defer session.Close() output, err := session.CombinedOutput(command) + return string(output), err } @@ -89,28 +91,29 @@ func (c *Client) Run(command string) (string, error) { // For larger files, a proper SCP or SFTP implementation would be better. func (c *Client) UploadContent(content []byte, remotePath string) error { config := c.getConfig() + client, err := ssh.Dial("tcp", c.Host+":22", config) if err != nil { - return fmt.Errorf("failed to dial: %v", err) + return fmt.Errorf("failed to dial: %w", err) } defer client.Close() session, err := client.NewSession() if err != nil { - return fmt.Errorf("failed to create session: %v", err) + return fmt.Errorf("failed to create session: %w", err) } defer session.Close() // Use a pipe to write content to the remote command's stdin stdin, err := session.StdinPipe() if err != nil { - return fmt.Errorf("failed to get stdin pipe: %v", err) + return fmt.Errorf("failed to get stdin pipe: %w", err) } // Capture stderr to get better error messages stderr, err := session.StderrPipe() if err != nil { - return fmt.Errorf("failed to get stderr pipe: %v", err) + return fmt.Errorf("failed to get stderr pipe: %w", err) } // Read content from stdin and write to the remote file @@ -118,14 +121,15 @@ func (c *Client) UploadContent(content []byte, remotePath string) error { // Start the command if err := session.Start(cmd); err != nil { - return fmt.Errorf("failed to start upload command: %v", err) + return fmt.Errorf("failed to start upload command: %w", err) } // Write content and close stdin _, err = stdin.Write(content) stdin.Close() + if err != nil { - return fmt.Errorf("failed to write content to stdin: %v", err) + return fmt.Errorf("failed to write content to stdin: %w", err) } // Read stderr in case of failure @@ -134,7 +138,7 @@ func (c *Client) UploadContent(content []byte, remotePath string) error { // Wait for the command to finish if err := session.Wait(); err != nil { - return fmt.Errorf("failed to finish upload: %v (stderr: %s)", err, stderrBuf.String()) + return fmt.Errorf("failed to finish upload: %w (stderr: %s)", err, stderrBuf.String()) } return nil diff --git a/pkg/service/ssh/ssh_test.go b/pkg/service/ssh/ssh_test.go index e991a83..6c38a5e 100644 --- a/pkg/service/ssh/ssh_test.go +++ b/pkg/service/ssh/ssh_test.go @@ -7,10 +7,12 @@ import ( func TestNewClient(t *testing.T) { host := "192.168.1.10" + client := NewClient(host) if client.Host != host { t.Errorf("Expected host %s, got %s", host, client.Host) } + if client.User != "root" { t.Errorf("Expected user root, got %s", client.User) } @@ -18,10 +20,12 @@ func TestNewClient(t *testing.T) { func TestGetConfig(t *testing.T) { client := NewClient("localhost") + config := client.getConfig() if config.User != "root" { t.Errorf("Expected config user root, got %s", config.User) } + if len(config.Auth) == 0 { t.Error("Expected at least one auth method") } @@ -30,10 +34,12 @@ func TestGetConfig(t *testing.T) { func TestRun_DialFailure(t *testing.T) { // Use an invalid port/host to trigger dial failure client := NewClient("127.0.0.1:0") + _, err := client.Run("ls") if err == nil { t.Error("Expected dial failure, got nil") } + if !strings.Contains(err.Error(), "failed to dial") { t.Errorf("Expected 'failed to dial' error, got: %v", err) }