package marge import ( "encoding/xml" "os" "path/filepath" "testing" "github.com/gesellix/bose-soundtouch/pkg/models" "github.com/gesellix/bose-soundtouch/pkg/service/constants" "github.com/gesellix/bose-soundtouch/pkg/service/datastore" ) func TestReadSourcesWithEmptyDisplayName(t *testing.T) { tempBaseDir := "repro_sources_data" err := os.MkdirAll(tempBaseDir, 0755) if err != nil { t.Fatal(err) } defer os.RemoveAll(tempBaseDir) accountID := "1234567" deviceID := "001122334455" // Create device directory devDir := filepath.Join(tempBaseDir, "accounts", accountID, "devices", deviceID) err = os.MkdirAll(devDir, 0755) if err != nil { t.Fatal(err) } // Create DeviceInfo.xml so ListAllDevices finds it devInfo := `Test Device` os.WriteFile(filepath.Join(devDir, "DeviceInfo.xml"), []byte(devInfo), 0644) // Create Sources.xml with some empty displayNames (as provided in the issue) sourcesXML := ` ` os.WriteFile(filepath.Join(devDir, "Sources.xml"), []byte(sourcesXML), 0644) ds := datastore.NewDataStore(tempBaseDir) sources, err := ds.GetConfiguredSources(accountID, deviceID) if err != nil { t.Fatalf("Failed to get configured sources: %v", err) } if len(sources) != 5 { t.Errorf("Expected 5 sources, got %d", len(sources)) } for i, s := range sources { t.Logf("Source %d: ID=%s, DisplayName=%s, Type=%s, SourceKeyType=%s, Account=%s", i, s.ID, s.DisplayName, s.Type, s.SourceKeyType, s.SourceKey.Account) if s.SourceKeyType == "" { t.Errorf("Source %d (%s) has empty SourceKeyType", i, s.DisplayName) } if s.Type == "SPOTIFY" && s.SourceKey.Account != "test-user" { t.Errorf("Source %d (%s) expected account 'test-user', got '%s'", i, s.DisplayName, s.SourceKey.Account) } label := constants.GetSourceLabel(s.Type) t.Logf(" Label: %s", label) if label == "" && s.Type != "" { t.Errorf("Source %d (%s) has empty label for type %s", i, s.DisplayName, s.Type) } } } func TestReproduceMissingName(t *testing.T) { tempBaseDir := "repro_data" err := os.MkdirAll(tempBaseDir, 0755) if err != nil { t.Fatal(err) } defer os.RemoveAll(tempBaseDir) accountID := "1234567" // Create device folders // 08DF1F0BA325 (has name) // 001122334455 (missing name in full_local.xml) // Device 1: 08DF1F0BA325 dev1Dir := filepath.Join(tempBaseDir, "accounts", accountID, "devices", "08DF1F0BA325") err = os.MkdirAll(dev1Dir, 0755) if err != nil { t.Fatal(err) } dev1Info := ` Kitchen SoundTouch SoundTouch 20 SCM 27.0.6.46330.5043500 epdbuild.trunk.hepdswbld04.2022-08-04T11:20:29 K4245112804625125000710 PackagedProduct 066802942560222AE ` os.WriteFile(filepath.Join(dev1Dir, "DeviceInfo.xml"), []byte(dev1Info), 0644) // Device 2: 001122334455 - MAC address ID in XML, name with special char or space? dev2Dir := filepath.Join(tempBaseDir, "accounts", accountID, "devices", "001122334455") err = os.MkdirAll(dev2Dir, 0755) if err != nil { t.Fatal(err) } dev2Info := ` Living Room SoundTouch SoundTouch 10 sm2 SCM 27.0.6.46330.5043500 epdbuild.trunk.hepdswbld04.2022-08-04T11:20:29 I6332527703739342000020 PackagedProduct 069231P63364828AE 192.0.2.35 001122334455 sync_full ` os.WriteFile(filepath.Join(dev2Dir, "DeviceInfo.xml"), []byte(dev2Info), 0644) // In the backup, there is NO default entry with empty name for this device's serial. // But let's see what happens if we use the EXACT content from the backup. // I'll also add a test case that unmarshals the exact backup file content. ds := datastore.NewDataStore(tempBaseDir) err = ds.Initialize() if err != nil { t.Fatal(err) } // Generate /full response XML data, err := AccountFullToXML(ds, accountID) if err != nil { t.Fatal(err) } t.Logf("Resulting XML:\n%s\n", string(data)) var resp models.AccountFullResponse err = xml.Unmarshal(data, &resp) if err != nil { t.Fatal(err) } // Now test name preservation during sync // Mock a response with empty name for 001122334455 for i := range resp.Devices { if resp.Devices[i].DeviceID == "001122334455" { resp.Devices[i].Name = "" } } // Remove the account-specific device directory to force resolution to 'default' os.RemoveAll(filepath.Join(tempBaseDir, "accounts", accountID, "devices", "001122334455")) // Create a duplicate directory in another place (e.g. 'st-go/data/accounts/default') with the CORRECT name // This simulates a global entry that ds.ListAllDevices() should find globalDevDir := filepath.Join("st-go", "data", "accounts", "default", "devices", "001122334455") os.MkdirAll(globalDevDir, 0755) defer os.RemoveAll("st-go") globalDevInfo := `Living Room SoundTouchSoundTouch10 sm2` os.WriteFile(filepath.Join(globalDevDir, "DeviceInfo.xml"), []byte(globalDevInfo), 0644) // Create a directory in 'default' with EMPTY name (the one that GetDeviceInfo will pick up) defaultDevDir := filepath.Join(tempBaseDir, "default", "devices", "001122334455") os.MkdirAll(defaultDevDir, 0755) defaultDevInfo := `SoundTouch10 sm2` os.WriteFile(filepath.Join(defaultDevDir, "DeviceInfo.xml"), []byte(defaultDevInfo), 0644) err = SyncFromAccountFull(ds, &resp) if err != nil { t.Fatal(err) } // Verify name was preserved info, err := ds.GetDeviceInfo(accountID, "001122334455") if err != nil { t.Fatal(err) } if info.Name != "Living Room SoundTouch" { t.Errorf("Expected name 'Living Room SoundTouch' to be preserved, got '%s'", info.Name) } // Re-generate XML to see if it now uses the preserved name data, err = AccountFullToXML(ds, accountID) if err != nil { t.Fatal(err) } err = xml.Unmarshal(data, &resp) if err != nil { t.Fatal(err) } found08 := false foundA8 := false for _, d := range resp.Devices { t.Logf("Checking device in response: ID=%s, Name='%s'\n", d.DeviceID, d.Name) if d.DeviceID == "08DF1F0BA325" { found08 = true if d.Name == "" { t.Error("Device 08DF1F0BA325 name should not be empty") } } if d.DeviceID == "001122334455" || d.DeviceID == "I6332527703739342000020" { if d.Name != "" { foundA8 = true } } } if !found08 { t.Error("Device 08DF1F0BA325 not found in response") } if !foundA8 { t.Error("Device 001122334455 not found in response") } } func TestRecentItemsMissingSources(t *testing.T) { tempBaseDir := "repro_recents_data" err := os.MkdirAll(tempBaseDir, 0755) if err != nil { t.Fatal(err) } defer os.RemoveAll(tempBaseDir) accountID := "1234567" deviceID := "001122334455" // Create device directory devDir := filepath.Join(tempBaseDir, "accounts", accountID, "devices", deviceID) err = os.MkdirAll(devDir, 0755) if err != nil { t.Fatal(err) } // Create Recents.xml with some entries that have missing sources or sparse data recentsXML := ` For Your Darkest Days Spotify Item ` os.WriteFile(filepath.Join(devDir, "Recents.xml"), []byte(recentsXML), 0644) // Create Sources.xml with matching sources sourcesXML := ` ` os.WriteFile(filepath.Join(devDir, "Sources.xml"), []byte(sourcesXML), 0644) ds := datastore.NewDataStore(tempBaseDir) recents, err := ds.GetRecents(accountID, deviceID) if err != nil { t.Fatalf("Failed to get recents: %v", err) } if len(recents) != 2 { t.Errorf("Expected 2 recents, got %d", len(recents)) } for _, r := range recents { t.Logf("Recent: ID=%s, Name=%s, Source=%s, SourceAccount=%s", r.ID, r.Name, r.Source, r.SourceAccount) if r.Name == "" { t.Errorf("Recent %s has empty Name", r.ID) } if r.Source == "" { t.Errorf("Recent %s has empty Source attribute", r.ID) } } } func TestSyncSourcesAttributes(t *testing.T) { tempBaseDir := "sync_test_data" err := os.MkdirAll(tempBaseDir, 0755) if err != nil { t.Fatal(err) } defer os.RemoveAll(tempBaseDir) ds := datastore.NewDataStore(tempBaseDir) err = ds.Initialize() if err != nil { t.Fatal(err) } xmlData := ` test-playlist test-user 15 test-user+spotify@gmail.com test-user test-playlist test-user 15 test-user+spotify@gmail.com test-user test-user 15 test-user+spotify@gmail.com test-user ` var resp models.AccountFullResponse err = xml.Unmarshal([]byte(xmlData), &resp) if err != nil { t.Fatal(err) } // Verify unmarshaling of attributes if resp.ID != "1234567" { t.Errorf("Expected account ID 1234567, got %s", resp.ID) } if len(resp.Devices) == 0 { t.Fatal("No devices found in unmarshaled response") } dev := resp.Devices[0] if len(dev.Presets) == 0 { t.Fatal("No presets found in unmarshaled response") } p := dev.Presets[0] if p.Source.ID != "10863533" { t.Errorf("Preset source ID not unmarshaled: expected 10863533, got '%s'", p.Source.ID) } if p.Source.Type != "Audio" { t.Errorf("Preset source Type not unmarshaled: expected Audio, got '%s'", p.Source.Type) } err = SyncFromAccountFull(ds, &resp) if err != nil { t.Fatal(err) } // Check datastore presets, err := ds.GetPresets("1234567", "08DF1F0BA325") if err != nil { t.Fatal(err) } if len(presets) == 0 { t.Fatal("No presets found in datastore after sync") } lp := presets[0] if lp.SourceID != "10863533" { t.Errorf("Synced preset source ID mismatch: expected 10863533, got '%s'", lp.SourceID) } recents, err := ds.GetRecents("1234567", "08DF1F0BA325") if err != nil { t.Fatal(err) } if len(recents) == 0 { t.Fatal("No recents found in datastore after sync") } lr := recents[0] if lr.SourceID != "10863533" { t.Errorf("Synced recent source ID mismatch: expected 10863533, got '%s'", lr.SourceID) } } func TestSyncSourcesAggregation(t *testing.T) { tempBaseDir := "sync_agg_test_data" err := os.MkdirAll(tempBaseDir, 0755) if err != nil { t.Fatal(err) } defer os.RemoveAll(tempBaseDir) ds := datastore.NewDataStore(tempBaseDir) err = ds.Initialize() if err != nil { t.Fatal(err) } xmlData := ` Preset Source Preset Source Name Recent Source Recent Source Name Account Source Name ` var resp models.AccountFullResponse err = xml.Unmarshal([]byte(xmlData), &resp) if err != nil { t.Fatal(err) } err = SyncFromAccountFull(ds, &resp) if err != nil { t.Fatal(err) } // Check aggregated sources for dev1 sources, err := ds.GetConfiguredSources("agg_account", "dev1") if err != nil { t.Fatal(err) } // We expect 3 sources: src_account, src_preset, src_recent if len(sources) != 3 { t.Errorf("Expected 3 aggregated sources, got %d", len(sources)) for _, s := range sources { t.Logf("Found source: ID=%s, Name=%s", s.ID, s.Name) } } foundAccount := false foundPreset := false foundRecent := false for _, s := range sources { switch s.ID { case "src_account": foundAccount = true case "src_preset": foundPreset = true case "src_recent": foundRecent = true } } if !foundAccount { t.Error("Source 'src_account' not found in aggregated sources") } if !foundPreset { t.Error("Source 'src_preset' not found in aggregated sources") } if !foundRecent { t.Error("Source 'src_recent' not found in aggregated sources") } }