mirror of
https://github.com/gesellix/Bose-SoundTouch.git
synced 2026-08-19 00:56:16 +00:00
Per the repo's no-real-data rule (CLAUDE.md), scrub committed files only (the gitignored _/ local captures are left as-is): - Real Bose-OUI device ID 08DF1F0BA325 -> placeholder AABBCCDDEE0A across 4 docs and 8 Go test files (consistent 1:1 rename; affected packages tested green). - Personal/topology LAN IPs -> RFC-5737: the lab runbook's AP subnet 192.168.10.x -> 198.51.100.x (192.0.2.x is already used contrastively there) and 192.168.100.1 -> 203.0.113.1; illustrative example IPs in ANONYMIZATION-SUMMARY / spotify-overview / TROUBLESHOOTING -> 192.0.2.x. - Kept factual RFC-1918 range citations (10.0.0.0/8 trusted-proxy example, 192.168.0.0/16 "all private subnets") since they name the ranges themselves. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
787 lines
26 KiB
Go
787 lines
26 KiB
Go
package marge
|
|
|
|
import (
|
|
"os"
|
|
"path/filepath"
|
|
"strconv"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/gesellix/bose-soundtouch/pkg/models"
|
|
"github.com/gesellix/bose-soundtouch/pkg/service/datastore"
|
|
)
|
|
|
|
func TestMargeXML(t *testing.T) {
|
|
tempDir, err := os.MkdirTemp("", "marge-test-*")
|
|
if err != nil {
|
|
t.Fatalf("Failed to create temp dir: %v", err)
|
|
}
|
|
|
|
defer func() { _ = os.RemoveAll(tempDir) }()
|
|
|
|
ds := datastore.NewDataStore(tempDir)
|
|
account := "123"
|
|
device := "ABC"
|
|
|
|
// Setup initial data
|
|
info := &models.ServiceDeviceInfo{
|
|
DeviceID: device,
|
|
Name: "Living Room",
|
|
}
|
|
_ = ds.SaveDeviceInfo(account, device, info)
|
|
|
|
// Save empty presets/recents to avoid index out of range when stripping header
|
|
_ = ds.SavePresets(account, device, []models.ServicePreset{})
|
|
_ = ds.SaveRecents(account, device, []models.ServiceRecent{})
|
|
|
|
// Test SourceProvidersToXML
|
|
xmlData, err := SourceProvidersToXML()
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
if !strings.Contains(string(xmlData), "<sourceProviders>") {
|
|
t.Errorf("Expected <sourceProviders>, got %s", string(xmlData))
|
|
}
|
|
|
|
// Verify RADIO_BROWSER is in the list
|
|
if !strings.Contains(string(xmlData), "RADIO_BROWSER") {
|
|
t.Errorf("Expected RADIO_BROWSER in XML")
|
|
}
|
|
|
|
// Verify a known static provider has correct createdOn
|
|
// SPOTIFY (ID 15) should have 2014-03-17T15:30:27.000+00:00
|
|
if !strings.Contains(string(xmlData), `id="15"`) {
|
|
t.Errorf("Expected Spotify ID 15 in XML, got %s", string(xmlData))
|
|
}
|
|
if !strings.Contains(string(xmlData), `<createdOn>2014-03-17T15:30:27.000+00:00</createdOn>`) {
|
|
t.Errorf("Expected Spotify createdOn 2014-03-17T15:30:27.000+00:00 in XML")
|
|
}
|
|
|
|
// Test AccountFullToXML
|
|
fullXML, err := AccountFullToXML(ds, account)
|
|
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))
|
|
}
|
|
|
|
// Test SoftwareUpdateToXML
|
|
swXML := SoftwareUpdateToXML()
|
|
if !strings.Contains(swXML, "<software_update>") {
|
|
t.Errorf("Expected <software_update>, got %s", swXML)
|
|
}
|
|
}
|
|
|
|
func TestAccountFullToXML_Structure(t *testing.T) {
|
|
tempDir, err := os.MkdirTemp("", "marge-test-structure-*")
|
|
if err != nil {
|
|
t.Fatalf("Failed to create temp dir: %v", err)
|
|
}
|
|
defer func() { _ = os.RemoveAll(tempDir) }()
|
|
|
|
ds := datastore.NewDataStore(tempDir)
|
|
account := "1234567"
|
|
device := "AABBCCDDEE0A"
|
|
|
|
// 1. Setup Device Info with Components
|
|
info := &models.ServiceDeviceInfo{
|
|
DeviceID: device,
|
|
Name: "Kitchen SoundTouch",
|
|
ProductCode: "SoundTouch 20",
|
|
DeviceSerialNumber: device,
|
|
ProductSerialNumber: "066802942560222AE",
|
|
FirmwareVersion: "27.0.6.46330.5043500",
|
|
IPAddress: "192.0.2.28",
|
|
Components: []models.ServiceComponent{
|
|
{
|
|
Category: "SMSC",
|
|
SoftwareVersion: "I2014101420409423",
|
|
SerialNumber: "08DF1F0BA32A",
|
|
},
|
|
{
|
|
Category: "LIGHTSWITCH",
|
|
SoftwareVersion: "1.2.3",
|
|
SerialNumber: "LS001",
|
|
},
|
|
},
|
|
}
|
|
_ = ds.SaveDeviceInfo(account, device, info)
|
|
// We'll mock the CreateAccountDevice call or just rely on the fact that
|
|
// info.Components will be used if we could save it.
|
|
// But ds.SaveDeviceInfo doesn't save arbitrary components.
|
|
// Let's modify CreateAccountDevice to be more flexible or fix the test by
|
|
// manually creating the AccountDevice if needed, but the goal is to test AccountFullToXML.
|
|
// Actually, CreateAccountDevice calls ds.GetDeviceInfo.
|
|
// Let's just fix the test to not expect SMSC if it's not supported by datastore yet,
|
|
// OR fix datastore.
|
|
// For now, I'll adjust the test to expect what's actually produced.
|
|
|
|
// 2. Setup Sources
|
|
src := models.ConfiguredSource{
|
|
ID: "10863533",
|
|
DisplayName: "test-user",
|
|
Type: "Audio",
|
|
Secret: "dummy-token-spotify...",
|
|
SecretType: "token_version_3",
|
|
SourceName: "test-user",
|
|
Username: "test-user",
|
|
SourceProviderID: "15",
|
|
}
|
|
src.SourceKeyType = "SPOTIFY"
|
|
src.SourceKeyAccount = "test-user"
|
|
_ = ds.SaveConfiguredSources(account, device, []models.ConfiguredSource{src})
|
|
|
|
// 3. Setup Presets
|
|
preset := models.ServicePreset{
|
|
ServiceContentItem: models.ServiceContentItem{
|
|
Name: "test-playlist",
|
|
Type: "tracklisturl",
|
|
Location: "/playback/container/c3BvdGlmeTpwbGF5bGlzdDo1Mm5QaVJrbWVmSkZPeHh1M1ZTd1hh",
|
|
Source: "SPOTIFY",
|
|
},
|
|
ID: "1",
|
|
ButtonNumber: "1",
|
|
ContainerArt: "https://i.scdn.co/image/ab67616d00001e025ff75c5d082fc50a3a74ad7b",
|
|
}
|
|
_ = ds.SavePresets(account, device, []models.ServicePreset{preset})
|
|
|
|
// 4. Setup Recents
|
|
recent := models.ServiceRecent{
|
|
ServiceContentItem: models.ServiceContentItem{
|
|
Name: "Billie Eilish - bad guy",
|
|
Type: "tracklisturl",
|
|
Location: "/playback/container/c3BvdGlmeTpwbGF5bGlzdDoxV2dKT3EyWktYU1BTRGxDdWI1NERV",
|
|
Source: "SPOTIFY",
|
|
},
|
|
LastPlayedAt: "2026-02-24T07:02:24.000+00:00",
|
|
}
|
|
_ = ds.SaveRecents(account, device, []models.ServiceRecent{recent})
|
|
|
|
// 5. Generate XML
|
|
fullXML, err := AccountFullToXML(ds, account)
|
|
if err != nil {
|
|
t.Fatalf("AccountFullToXML failed: %v", err)
|
|
}
|
|
|
|
xmlStr := string(fullXML)
|
|
|
|
// 6. Verify Structure
|
|
// Root and attributes
|
|
if !strings.Contains(xmlStr, `<account id="1234567">`) {
|
|
t.Errorf("Expected <account id=\"1234567\">, got %s", xmlStr)
|
|
}
|
|
if !strings.Contains(xmlStr, `<preferredLanguage>en</preferredLanguage>`) {
|
|
t.Errorf("Expected <preferredLanguage>en</preferredLanguage>, got %s", xmlStr)
|
|
}
|
|
|
|
// Device structure
|
|
if !strings.Contains(xmlStr, `<device deviceid="AABBCCDDEE0A">`) {
|
|
t.Errorf("Expected device attribute deviceid, got %s", xmlStr)
|
|
}
|
|
if !strings.Contains(xmlStr, `<name>Kitchen SoundTouch</name>`) {
|
|
t.Errorf("Expected <name>Kitchen SoundTouch</name> under device, got %s", xmlStr)
|
|
}
|
|
if !strings.Contains(xmlStr, `<serialNumber>AABBCCDDEE0A</serialNumber>`) {
|
|
t.Errorf("Expected <serialNumber>AABBCCDDEE0A</serialNumber> under device, got %s", xmlStr)
|
|
}
|
|
if !strings.Contains(xmlStr, `<updatedOn>`) {
|
|
t.Errorf("Expected <updatedOn> under device, got %s", xmlStr)
|
|
}
|
|
|
|
// Preset buttonNumber
|
|
if !strings.Contains(xmlStr, `<preset buttonNumber="1">`) {
|
|
t.Errorf("Expected <preset buttonNumber=\"1\">, got %s", xmlStr)
|
|
}
|
|
|
|
// AttachedProduct and Components
|
|
if !strings.Contains(xmlStr, `<attachedProduct product_code="SoundTouch 20">`) {
|
|
t.Errorf("Expected attachedProduct with product_code, got %s", xmlStr)
|
|
}
|
|
if !strings.Contains(xmlStr, `<productlabel>SoundTouch 20</productlabel>`) {
|
|
t.Errorf("Expected productlabel, got %s", xmlStr)
|
|
}
|
|
if !strings.Contains(xmlStr, `<component category="SMSC">`) && !strings.Contains(xmlStr, `category="SMSC"`) {
|
|
t.Errorf("Expected component with category SMSC, got %s", xmlStr)
|
|
}
|
|
if !strings.Contains(xmlStr, `<component category="LIGHTSWITCH">`) && !strings.Contains(xmlStr, `category="LIGHTSWITCH"`) {
|
|
t.Errorf("Expected component with category LIGHTSWITCH, got %s", xmlStr)
|
|
}
|
|
if !strings.Contains(xmlStr, `<firmware-version>1.2.3</firmware-version>`) {
|
|
t.Errorf("Expected firmware-version 1.2.3, got %s", xmlStr)
|
|
}
|
|
if !strings.Contains(xmlStr, `<serialnumber>AABBCCDDEE0A</serialnumber>`) {
|
|
t.Errorf("Expected <serialnumber>AABBCCDDEE0A</serialnumber> under attachedProduct, got %s", xmlStr)
|
|
}
|
|
if !strings.Contains(xmlStr, `<updatedOn>`) {
|
|
t.Errorf("Expected <updatedOn> under attachedProduct, got %s", xmlStr)
|
|
}
|
|
|
|
// Presets and Recents nesting
|
|
if !strings.Contains(xmlStr, `<presets><preset buttonNumber="1">`) {
|
|
t.Errorf("Expected preset tag with buttonNumber, got %s", xmlStr)
|
|
}
|
|
if !strings.Contains(xmlStr, `<contentItemType>tracklisturl</contentItemType>`) {
|
|
t.Errorf("Expected contentItemType tracklisturl, got %s", xmlStr)
|
|
}
|
|
if !strings.Contains(xmlStr, `<recents><recent id="1">`) {
|
|
t.Errorf("Expected recent tag with id, got %s", xmlStr)
|
|
}
|
|
if !strings.Contains(xmlStr, `<contentItemType>tracklisturl</contentItemType>`) {
|
|
t.Errorf("Expected contentItemType tracklisturl in recents, got %s", xmlStr)
|
|
}
|
|
|
|
// Provider Settings
|
|
if !strings.Contains(xmlStr, `<providerSettings><providerSetting>`) {
|
|
t.Errorf("Expected <providerSettings><providerSetting>, got %s", xmlStr)
|
|
}
|
|
|
|
// Global Sources
|
|
if !strings.Contains(xmlStr, `<source id="10863533" type="Audio" displayName="test-user">`) {
|
|
t.Errorf("Expected source tag with displayName attribute, got %s", xmlStr)
|
|
}
|
|
if !strings.Contains(xmlStr, `<name>test-user</name>`) {
|
|
t.Errorf("Expected <name>test-user</name> under source, got %s", xmlStr)
|
|
}
|
|
if !strings.Contains(xmlStr, `<credential type="token_version_3">dummy-token-spotify...</credential>`) {
|
|
t.Errorf("Expected credential tag, got %s", xmlStr)
|
|
}
|
|
|
|
// Check for self-closing tags (parity check)
|
|
if !strings.Contains(xmlStr, `<sourceSettings/>`) {
|
|
t.Errorf("Expected self-closing <sourceSettings/>, got %s", xmlStr)
|
|
}
|
|
}
|
|
|
|
func TestEscapeXML(t *testing.T) {
|
|
input := "Antenne Chillout & Other"
|
|
expected := "Antenne Chillout & Other"
|
|
actual := EscapeXML(input)
|
|
if actual != expected {
|
|
t.Errorf("Expected %s, got %s", expected, actual)
|
|
}
|
|
|
|
inputWithAll := "< > & ' \""
|
|
expectedWithAll := "< > & ' ""
|
|
actualWithAll := EscapeXML(inputWithAll)
|
|
if actualWithAll != expectedWithAll {
|
|
t.Errorf("Expected %s, got %s", expectedWithAll, actualWithAll)
|
|
}
|
|
}
|
|
|
|
func TestRecentsXML_EmptyIDFix(t *testing.T) {
|
|
tempDir, err := os.MkdirTemp("", "marge-test-*")
|
|
if err != nil {
|
|
t.Fatalf("Failed to create temp dir: %v", err)
|
|
}
|
|
defer func() { _ = os.RemoveAll(tempDir) }()
|
|
|
|
ds := datastore.NewDataStore(tempDir)
|
|
account := "test-acc"
|
|
device := "test-dev"
|
|
|
|
deviceDir := ds.AccountDeviceDir(account, device)
|
|
_ = os.MkdirAll(deviceDir, 0755)
|
|
|
|
// Create a Recents.xml with empty ID
|
|
recentsXML := []byte(`<?xml version="1.0" encoding="UTF-8"?>
|
|
<recents>
|
|
<recent id="" deviceID="test-dev" utcTime="1708896000">
|
|
<contentItem source="SPOTIFY" type="tracklisturl" location="/test" sourceAccount="user" isPresetable="true">
|
|
<itemName>Test Item</itemName>
|
|
</contentItem>
|
|
</recent>
|
|
</recents>`)
|
|
_ = os.WriteFile(filepath.Join(deviceDir, "Recents.xml"), recentsXML, 0644)
|
|
_ = os.WriteFile(filepath.Join(deviceDir, "Sources.xml"), []byte("<sources/>"), 0644)
|
|
|
|
// Fetching should fix the empty ID
|
|
recents, err := ds.GetRecents(account, device)
|
|
if err != nil {
|
|
t.Fatalf("Failed to get recents: %v", err)
|
|
}
|
|
|
|
if len(recents) != 1 {
|
|
t.Fatalf("Expected 1 recent, got %d", len(recents))
|
|
}
|
|
|
|
if recents[0].ID == "" {
|
|
t.Errorf("Expected non-empty ID for recent")
|
|
}
|
|
|
|
if _, err := strconv.Atoi(recents[0].ID); err != nil {
|
|
t.Errorf("Expected numeric ID, got %s", recents[0].ID)
|
|
}
|
|
|
|
// Verify the XML output also has the non-empty ID
|
|
xmlData, err := RecentsToXML(ds, account, device)
|
|
if err != nil {
|
|
t.Fatalf("RecentsToXML failed: %v", err)
|
|
}
|
|
|
|
if strings.Contains(string(xmlData), ` id=""`) {
|
|
t.Errorf("XML should not contain empty recent ID: %s", string(xmlData))
|
|
}
|
|
|
|
if !strings.Contains(string(xmlData), `id="1"`) {
|
|
t.Errorf("XML should contain fixed numeric ID: %s", string(xmlData))
|
|
}
|
|
}
|
|
|
|
func TestRecentsToXML_SourceIncluded(t *testing.T) {
|
|
tempDir, err := os.MkdirTemp("", "marge-test-*")
|
|
if err != nil {
|
|
t.Fatalf("Failed to create temp dir: %v", err)
|
|
}
|
|
defer func() { _ = os.RemoveAll(tempDir) }()
|
|
|
|
ds := datastore.NewDataStore(tempDir)
|
|
account := "test-acc"
|
|
device := "test-dev"
|
|
|
|
deviceDir := ds.AccountDeviceDir(account, device)
|
|
_ = os.MkdirAll(deviceDir, 0755)
|
|
|
|
// Create a Recents.xml with a reference to a source
|
|
recents := []models.ServiceRecent{
|
|
{
|
|
ServiceContentItem: models.ServiceContentItem{
|
|
ID: "1",
|
|
Name: "Test Track",
|
|
Source: "SPOTIFY",
|
|
SourceAccount: "test-user",
|
|
SourceID: "100001",
|
|
Type: "tracklisturl",
|
|
ContentItemType: "tracklisturl",
|
|
Location: "/test",
|
|
},
|
|
DeviceID: device,
|
|
UtcTime: "1708896000",
|
|
},
|
|
}
|
|
_ = ds.SaveRecents(account, device, recents)
|
|
|
|
// Create a Sources.xml with the SPOTIFY source
|
|
sources := []models.ConfiguredSource{
|
|
{
|
|
ID: "100001",
|
|
DisplayName: "Spotify",
|
|
SourceName: "Spotify",
|
|
Username: "testuser",
|
|
SourceKey: struct {
|
|
Type string `xml:"type,attr"`
|
|
Account string `xml:"account,attr"`
|
|
}{Type: "SPOTIFY", Account: "test-user"},
|
|
},
|
|
}
|
|
_ = ds.SaveConfiguredSources(account, device, sources)
|
|
|
|
// Fetch XML
|
|
xmlData, err := RecentsToXML(ds, account, device)
|
|
if err != nil {
|
|
t.Fatalf("RecentsToXML failed: %v", err)
|
|
}
|
|
|
|
xmlStr := string(xmlData)
|
|
if !strings.Contains(xmlStr, "id=\"1\"") {
|
|
t.Errorf("XML should contain id=\"1\" for recent: %s", xmlStr)
|
|
}
|
|
if !strings.Contains(xmlStr, "<contentItem ") {
|
|
t.Errorf("XML should contain nested <contentItem> for ServiceRecent: %s", xmlStr)
|
|
}
|
|
if !strings.Contains(xmlStr, "source=\"SPOTIFY\"") {
|
|
t.Errorf("XML should contain source=\"SPOTIFY\" in contentItem: %s", xmlStr)
|
|
}
|
|
if !strings.Contains(xmlStr, "<itemName>Test Track</itemName>") {
|
|
t.Errorf("XML should contain <itemName>Test Track</itemName>: %s", xmlStr)
|
|
}
|
|
if !strings.Contains(xmlStr, "location=\"/test\"") {
|
|
t.Errorf("XML should contain location=\"/test\" in contentItem: %s", xmlStr)
|
|
}
|
|
if strings.Contains(xmlStr, "displayName=\"Spotify\"") {
|
|
t.Errorf("XML should NOT contain displayName=\"Spotify\" in source attribute: %s", xmlStr)
|
|
}
|
|
}
|
|
|
|
func TestPresetsToXML_SourceIncluded(t *testing.T) {
|
|
tempDir, err := os.MkdirTemp("", "marge-test-*")
|
|
if err != nil {
|
|
t.Fatalf("Failed to create temp dir: %v", err)
|
|
}
|
|
defer func() { _ = os.RemoveAll(tempDir) }()
|
|
|
|
ds := datastore.NewDataStore(tempDir)
|
|
account := "test-acc"
|
|
device := "test-dev"
|
|
|
|
deviceDir := ds.AccountDeviceDir(account, device)
|
|
_ = os.MkdirAll(deviceDir, 0755)
|
|
|
|
// Create a Presets.xml with a reference to a source
|
|
presets := []models.ServicePreset{
|
|
{
|
|
ServiceContentItem: models.ServiceContentItem{
|
|
ID: "1",
|
|
Name: "Test Preset",
|
|
SourceID: "100001",
|
|
Source: "SPOTIFY",
|
|
SourceAccount: "testuser",
|
|
Type: "tracklisturl",
|
|
ContentItemType: "tracklisturl",
|
|
Location: "/test",
|
|
},
|
|
ID: "1",
|
|
},
|
|
}
|
|
_ = ds.SavePresets(account, device, presets)
|
|
|
|
// Create a Sources.xml with the source
|
|
sources := []models.ConfiguredSource{
|
|
{
|
|
ID: "100001",
|
|
DisplayName: "Spotify",
|
|
},
|
|
}
|
|
sources[0].SourceKey.Type = "SPOTIFY"
|
|
sources[0].SourceKey.Account = "testuser"
|
|
_ = ds.SaveConfiguredSources(account, device, sources)
|
|
|
|
// Fetch XML
|
|
xmlData, err := PresetsToXML(ds, account, device)
|
|
if err != nil {
|
|
t.Fatalf("PresetsToXML failed: %v", err)
|
|
}
|
|
|
|
xmlStr := string(xmlData)
|
|
if !strings.Contains(xmlStr, "<source") {
|
|
t.Errorf("XML should contain <source> element: %s", xmlStr)
|
|
}
|
|
if strings.Contains(xmlStr, "displayName=\"Spotify\"") {
|
|
t.Errorf("XML should NOT contain displayName=\"Spotify\" attribute: %s", xmlStr)
|
|
}
|
|
}
|
|
|
|
func TestGetConfiguredSourceXML_Escaping(t *testing.T) {
|
|
src := models.ConfiguredSource{
|
|
ID: "101&202",
|
|
DisplayName: "Test & Source",
|
|
Secret: "key&value",
|
|
SecretType: "token",
|
|
}
|
|
src.SourceKeyAccount = "user&name"
|
|
|
|
xmlData := GetConfiguredSourceXML(src)
|
|
if !strings.Contains(xmlData, "id=\"101&202\"") {
|
|
t.Errorf("ID not escaped in attribute: %s", xmlData)
|
|
}
|
|
if strings.Contains(xmlData, "displayName=") {
|
|
t.Errorf("DisplayName should not be present in attribute: %s", xmlData)
|
|
}
|
|
if !strings.Contains(xmlData, "<credential type=\"token\">key&value</credential>") {
|
|
t.Errorf("Credential value not escaped in element: %s", xmlData)
|
|
}
|
|
}
|
|
|
|
func TestGetConfiguredSourceXML_Parity(t *testing.T) {
|
|
t.Run("Other source should NOT have displayName in attribute", func(t *testing.T) {
|
|
src := models.ConfiguredSource{
|
|
ID: "14774275",
|
|
DisplayName: "Other",
|
|
}
|
|
xmlData := GetConfiguredSourceXML(src)
|
|
if strings.Contains(xmlData, "displayName=\"Other\"") {
|
|
t.Errorf("Expected NOT to find displayName=\"Other\", got: %s", xmlData)
|
|
}
|
|
})
|
|
}
|
|
|
|
func TestAddRecent_TimestampPreservation(t *testing.T) {
|
|
tempDir, err := os.MkdirTemp("", "marge-test-*")
|
|
if err != nil {
|
|
t.Fatalf("Failed to create temp dir: %v", err)
|
|
}
|
|
|
|
defer func() { _ = os.RemoveAll(tempDir) }()
|
|
|
|
ds := datastore.NewDataStore(tempDir)
|
|
account := "test-acc"
|
|
device := "test-dev"
|
|
|
|
// 1. Setup configured sources
|
|
// We need a Sources.xml file in the account directory
|
|
deviceDir := ds.AccountDeviceDir(account, device)
|
|
_ = os.MkdirAll(deviceDir, 0755)
|
|
src := models.ConfiguredSource{
|
|
ID: "101",
|
|
DisplayName: "Test Source",
|
|
SecretType: "Audio",
|
|
}
|
|
src.SourceKey.Type = "TUNEIN"
|
|
src.SourceKey.Account = "test-user"
|
|
src.SourceKeyType = "TUNEIN"
|
|
src.SourceKeyAccount = "test-user"
|
|
|
|
_ = ds.SaveConfiguredSources(account, device, []models.ConfiguredSource{src})
|
|
_ = ds.SaveRecents(account, device, []models.ServiceRecent{})
|
|
|
|
// 2. Add an initial recent
|
|
sourceXML := []byte(`
|
|
<recent>
|
|
<contentItem source="TUNEIN" type="stationurl" location="station-1" sourceAccount="test-user">
|
|
<itemName>Initial Station</itemName>
|
|
</contentItem>
|
|
<sourceid>101</sourceid>
|
|
</recent>`)
|
|
|
|
_, err = AddRecent(ds, account, device, sourceXML)
|
|
if err != nil {
|
|
t.Fatalf("AddRecent failed: %v", err)
|
|
}
|
|
|
|
recents, _ := ds.GetRecents(account, device)
|
|
if len(recents) != 1 {
|
|
t.Fatalf("Expected 1 recent, got %d", len(recents))
|
|
}
|
|
|
|
// 3. Add the same recent again (it should move to front and preserve createdOn)
|
|
// We'll wait a second to ensure time.Now() would be different if it were used for createdOn
|
|
time.Sleep(1 * time.Second)
|
|
|
|
respXML, err := AddRecent(ds, account, device, sourceXML)
|
|
if err != nil {
|
|
t.Fatalf("AddRecent second time failed: %v", err)
|
|
}
|
|
|
|
if !strings.Contains(string(respXML), "2012-09-19T12:43:00.000+00:00") {
|
|
// Our DateStr is 2012-09-19T12:43:00.000+00:00
|
|
t.Errorf("Expected preserved DateStr in createdOn, got XML: %s", string(respXML))
|
|
}
|
|
|
|
recents, _ = ds.GetRecents(account, device)
|
|
// AddRecent should have reused the existing one since location/source are the same
|
|
if len(recents) != 1 {
|
|
t.Errorf("Expected still 1 recent, got %d", len(recents))
|
|
}
|
|
|
|
// Verify that source id is present in recent response
|
|
if !strings.Contains(string(respXML), "id=\"101\"") {
|
|
t.Errorf("Expected source id in recent response: %s", string(respXML))
|
|
}
|
|
}
|
|
|
|
func TestMapToFullResponseSource_CredentialRespect(t *testing.T) {
|
|
// 1. Spotify with default token -> should upgrade to token_version_3
|
|
src1 := models.ConfiguredSource{
|
|
SourceKey: struct {
|
|
Type string `xml:"type,attr"`
|
|
Account string `xml:"account,attr"`
|
|
}{Type: "SPOTIFY", Account: "user1"},
|
|
SourceKeyType: "SPOTIFY",
|
|
SecretType: "token",
|
|
Secret: "token123",
|
|
}
|
|
full1 := mapToFullResponseSource(src1)
|
|
if full1.Credential.Type != "token_version_3" {
|
|
t.Errorf("expected token_version_3 for Spotify with 'token', got %s", full1.Credential.Type)
|
|
}
|
|
|
|
// 2. Spotify with explicit token_version_3 -> should keep it
|
|
src2 := models.ConfiguredSource{
|
|
SourceKeyType: "SPOTIFY",
|
|
SecretType: "token_version_3",
|
|
Secret: "token123",
|
|
}
|
|
full2 := mapToFullResponseSource(src2)
|
|
if full2.Credential.Type != "token_version_3" {
|
|
t.Errorf("expected token_version_3 to be preserved, got %s", full2.Credential.Type)
|
|
}
|
|
|
|
// 3. Custom source with custom credential type -> should be preserved
|
|
src3 := models.ConfiguredSource{
|
|
SourceKeyType: "CUSTOM",
|
|
SecretType: "custom_type",
|
|
Secret: "secret123",
|
|
}
|
|
full3 := mapToFullResponseSource(src3)
|
|
if full3.Credential.Type != "custom_type" {
|
|
t.Errorf("expected custom_type to be preserved, got %s", full3.Credential.Type)
|
|
}
|
|
|
|
// 4. Source with empty credential type -> should default to 'token'
|
|
src4 := models.ConfiguredSource{
|
|
SourceKeyType: "OTHER",
|
|
SecretType: "",
|
|
}
|
|
full4 := mapToFullResponseSource(src4)
|
|
if full4.Credential.Type != "token" {
|
|
t.Errorf("expected empty SecretType to default to 'token', got %s", full4.Credential.Type)
|
|
}
|
|
}
|
|
|
|
func TestDefaultSources(t *testing.T) {
|
|
tempDir, err := os.MkdirTemp("", "marge-test-defaults-*")
|
|
if err != nil {
|
|
t.Fatalf("Failed to create temp dir: %v", err)
|
|
}
|
|
defer func() { _ = os.RemoveAll(tempDir) }()
|
|
|
|
ds := datastore.NewDataStore(tempDir)
|
|
sources, err := ds.GetConfiguredSources("acc", "dev")
|
|
if err != nil {
|
|
t.Fatalf("Failed to get sources: %v", err)
|
|
}
|
|
|
|
// INTERNET_RADIO (ID 10002) is excluded from initial sources — it is a legacy
|
|
// provider no longer served by AfterTouch. The cloud-level account sources
|
|
// (getAccountSources via GetDefaultSources) still include it for backward
|
|
// compatibility with existing speaker firmware.
|
|
expectedCount := 4
|
|
if len(sources) != expectedCount {
|
|
t.Errorf("Expected %d sources, got %d", expectedCount, len(sources))
|
|
}
|
|
|
|
foundTuneIn := false
|
|
foundLocalIR := false
|
|
foundIR := false
|
|
foundAux := false
|
|
|
|
for _, s := range sources {
|
|
switch s.SourceKeyType {
|
|
case "TUNEIN":
|
|
foundTuneIn = true
|
|
if s.Secret == "" {
|
|
t.Error("TUNEIN should have a secret")
|
|
}
|
|
if !strings.HasPrefix(s.Secret, "ey") { // ey is base64 for {
|
|
t.Errorf("TUNEIN secret should be base64 JSON, got %s", s.Secret)
|
|
}
|
|
case "LOCAL_INTERNET_RADIO":
|
|
foundLocalIR = true
|
|
if s.Secret == "" {
|
|
t.Error("LOCAL_INTERNET_RADIO should have a secret")
|
|
}
|
|
case "INTERNET_RADIO":
|
|
t.Errorf("INTERNET_RADIO must not appear in initial sources — it is excluded from getInitialSources()")
|
|
case "RADIO_BROWSER":
|
|
foundIR = true
|
|
if s.SecretType != "token" {
|
|
t.Errorf("Expected RADIO_BROWSER secretType token, got %s", s.SecretType)
|
|
}
|
|
case "AUX":
|
|
foundAux = true
|
|
if s.DisplayName != "AUX IN" {
|
|
t.Errorf("Expected AUX DisplayName 'AUX IN', got %s", s.DisplayName)
|
|
}
|
|
if s.SourceKey.Account != "AUX" {
|
|
t.Errorf("Expected AUX account 'AUX', got %s", s.SourceKey.Account)
|
|
}
|
|
case "AMAZON":
|
|
t.Errorf("AMAZON must not appear in defaults — it requires real OAuth credentials")
|
|
}
|
|
|
|
if s.Status != "READY" {
|
|
t.Errorf("Source %s has status %s, expected READY", s.SourceKeyType, s.Status)
|
|
}
|
|
|
|
if s.SourceKey.Type != s.SourceKeyType {
|
|
t.Errorf("Source %s: SourceKey.Type %s does not match SourceKeyType %s", s.SourceKeyType, s.SourceKey.Type, s.SourceKeyType)
|
|
}
|
|
}
|
|
|
|
if !foundTuneIn || !foundLocalIR || !foundIR || !foundAux {
|
|
t.Errorf("Missing expected sources: TuneIn=%v, LocalIR=%v, RadioBrowser=%v, Aux=%v", foundTuneIn, foundLocalIR, foundIR, foundAux)
|
|
}
|
|
}
|
|
|
|
func TestAccountFullToXML_WithBackupStructure(t *testing.T) {
|
|
tempDir, err := os.MkdirTemp("", "marge-test-backup-*")
|
|
if err != nil {
|
|
t.Fatalf("Failed to create temp dir: %v", err)
|
|
}
|
|
defer func() { _ = os.RemoveAll(tempDir) }()
|
|
|
|
account := "1234567"
|
|
device := "001122334455"
|
|
|
|
// Mimic the backup structure: accounts/1234567/devices/001122334455/DeviceInfo.xml
|
|
deviceDir := filepath.Join(tempDir, "accounts", account, "devices", device)
|
|
_ = os.MkdirAll(deviceDir, 0755)
|
|
|
|
deviceInfoXML := `<?xml version="1.0" encoding="UTF-8"?>
|
|
<info deviceID="001122334455">
|
|
<name>Living Room SoundTouch</name>
|
|
<type>SoundTouch</type>
|
|
<moduleType>10 sm2</moduleType>
|
|
<components>
|
|
<component>
|
|
<componentCategory>SCM</componentCategory>
|
|
<softwareVersion>27.0.6.46330.5043500 epdbuild.trunk.hepdswbld04.2022-08-04T11:20:29</softwareVersion>
|
|
<serialNumber>I6332527703739342000020</serialNumber>
|
|
</component>
|
|
</components>
|
|
<networkInfo type="SCM">
|
|
<ipAddress>192.0.2.35</ipAddress>
|
|
<macAddress>001122334455</macAddress>
|
|
</networkInfo>
|
|
<discoveryMethod>sync_full</discoveryMethod>
|
|
</info>`
|
|
_ = os.WriteFile(filepath.Join(deviceDir, "DeviceInfo.xml"), []byte(deviceInfoXML), 0644)
|
|
|
|
ds := datastore.NewDataStore(tempDir)
|
|
|
|
// Generate XML
|
|
fullXML, err := AccountFullToXML(ds, account)
|
|
if err != nil {
|
|
t.Fatalf("AccountFullToXML failed: %v", err)
|
|
}
|
|
|
|
xmlStr := string(fullXML)
|
|
|
|
// Verify Name is present
|
|
if !strings.Contains(xmlStr, `<name>Living Room SoundTouch</name>`) {
|
|
t.Errorf("Expected <name>Living Room SoundTouch</name> under device, got %s", xmlStr)
|
|
}
|
|
|
|
// 2. Verify ButtonNumber and ContentItemType mapping. Uses TUNEIN
|
|
// because that's one of the default configured sources every device
|
|
// gets at pair time — a SPOTIFY preset with no matching configured
|
|
// source would now correctly be skipped per the GH-269 fix, which
|
|
// would defeat this test's structural assertion.
|
|
presetsDir := filepath.Join(deviceDir)
|
|
_ = os.MkdirAll(presetsDir, 0755)
|
|
presetsXML := `<?xml version="1.0" encoding="UTF-8"?>
|
|
<presets>
|
|
<preset id="1" createdOn="1719128436" updatedOn="1728740382">
|
|
<contentItem source="TUNEIN" type="stationurl" location="/v1/playback/station/s166521" itemName="SMOOTH JAZZ" isPresetable="true" contentItemType="stationurl">
|
|
<containerArt>https://cdn-profiles.tunein.com/s166521/images/logod.png</containerArt>
|
|
</contentItem>
|
|
<sourceid>10004</sourceid>
|
|
</preset>
|
|
</presets>`
|
|
_ = os.WriteFile(filepath.Join(presetsDir, "Presets.xml"), []byte(presetsXML), 0644)
|
|
|
|
fullXMLWithPresets, _ := AccountFullToXML(ds, account)
|
|
xmlStr2 := string(fullXMLWithPresets)
|
|
|
|
if !strings.Contains(xmlStr2, `buttonNumber="1"`) {
|
|
t.Errorf("Expected buttonNumber=\"1\", got %s", xmlStr2)
|
|
}
|
|
if !strings.Contains(xmlStr2, `<contentItemType>stationurl</contentItemType>`) {
|
|
t.Errorf("Expected <contentItemType>stationurl</contentItemType>, got %s", xmlStr2)
|
|
}
|
|
|
|
// 3. Test with empty name
|
|
_ = os.WriteFile(filepath.Join(deviceDir, "DeviceInfo.xml"), []byte(`<?xml version="1.0" encoding="UTF-8"?><info deviceID="001122334455"><name></name></info>`), 0644)
|
|
fullXML2, _ := AccountFullToXML(ds, account)
|
|
if !strings.Contains(string(fullXML2), `<name/>`) && !strings.Contains(string(fullXML2), `<name></name>`) && !strings.Contains(string(fullXML2), `<name>SoundTouch`) && !strings.Contains(string(fullXML2), `<name>PANDORA`) && !strings.Contains(string(fullXML2), `<name>001122334455</name>`) {
|
|
t.Errorf("Expected <name/> or <name></name> or fallback name, got %s", string(fullXML2))
|
|
}
|
|
}
|