test(setup): pin factory-reset behaviour from issue #234

Wires the device-side state the reporter described in
https://github.com/gesellix/Bose-SoundTouch/issues/234 into the
fakespeaker via FixtureOverrides, and exercises GetLiveDeviceInfo +
syncSources against it.

The factory-reset state has two observable signals:

  - `/info` returns an empty `<margeAccountUUID/>` because Marge.xml
    is missing from the persistence partition. AfterTouch's
    "is the device paired?" check at setup.go:632 keys on AccountID,
    so this is the canonical "needs re-pairing" signal.
  - `/sources` lists only AUX, BLUETOOTH, AIRPLAY, the
    SpotifyConnectUserName placeholder, NOTIFICATION, and QPLAY —
    TUNEIN, LOCAL_INTERNET_RADIO, and any post-pairing Spotify
    accounts are gone until the speaker is nudged with a
    `<sourcesUpdated/>` notification or re-pairs.

Today AfterTouch has no auto-recovery for either signal — it just
passes the state through. The test locks in that contract by
asserting:

  - GetLiveDeviceInfo reports an empty MargeAccountUUID,
  - persisted Sources.xml contains AUX/BLUETOOTH/AIRPLAY sourceKeys,
  - persisted Sources.xml does NOT contain TUNEIN/LOCAL_INTERNET_RADIO.

When auto-recovery lands (e.g. an automatic POST of the
sourcesUpdated notification during sync, or marge-side source
replenishment), the absence assertions will flip — at which point
update them to assert the survivors are *present*, and adjust the
doc-comment so the contract stays in sync with the code.

Pattern mirrors pkg/service/setup/issue218_regression_test.go: a
testdata fixture next to the test, fakespeaker driven via
Config.FixtureOverrides, doc-comment naming what would have to
change for the assertion to flip.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Tobias Gesellchen
2026-05-15 14:13:24 +02:00
co-authored by Claude Opus 4.7
parent 13e82bbf85
commit dd535cdb52
3 changed files with 189 additions and 0 deletions
@@ -0,0 +1,157 @@
package setup
import (
"context"
"os"
"path/filepath"
"strings"
"testing"
"time"
"github.com/gesellix/bose-soundtouch/pkg/service/datastore"
"github.com/gesellix/bose-soundtouch/pkg/service/testing/fakespeaker"
)
// TestIssue234_FactoryResetSpeakerSyncsReducedSources captures the
// device-side state reported in
//
// https://github.com/gesellix/Bose-SoundTouch/issues/234
//
// After a factory reset the SoundTouch's `/sources` only lists the
// always-on local sources (AUX, BLUETOOTH, AIRPLAY, NOTIFICATION,
// QPLAY) plus a placeholder SPOTIFY entry for the Spotify Connect
// fallback. TUNEIN, LOCAL_INTERNET_RADIO, DEEZER, and any
// post-pairing Spotify accounts are absent. The reporter's
// workaround is a POST to `:8090/notification` with a
// `<sourcesUpdated/>` payload — that nudges the device to re-render
// its source list. Separately, `/info` reports an empty
// `<margeAccountUUID/>` because `Marge.xml` is missing in the
// persistence partition.
//
// What this test locks in (current behaviour):
//
// - GetLiveDeviceInfo against a factory-reset speaker correctly
// reports an empty MargeAccountUUID, so downstream code that
// keys on "is the device paired?" (e.g. setup.go:632 sets
// IsPaired from AccountID) gets the right answer.
// - syncSources persists exactly the reduced list verbatim — AUX
// and BLUETOOTH survive as `<sourceKey type="…">` entries, but
// TUNEIN / LOCAL_INTERNET_RADIO are NOT in the persisted
// Sources.xml.
//
// What this test would catch if it flipped:
//
// - If AfterTouch grows auto-recovery (POST sourcesUpdated on the
// speaker's behalf during sync, or marge-side source
// replenishment from the catalog), the "TUNEIN absent" assertion
// below would start failing — at which point flip it to assert
// TUNEIN *is* present, and adjust the comment to reflect the new
// contract.
//
// Pattern mirrors pkg/service/setup/issue218_regression_test.go.
func TestIssue234_FactoryResetSpeakerSyncsReducedSources(t *testing.T) {
infoXML, err := os.ReadFile(filepath.Join("testdata", "issue234", "info.xml"))
if err != nil {
t.Fatalf("read issue234 info fixture: %v", err)
}
sourcesXML, err := os.ReadFile(filepath.Join("testdata", "issue234", "sources.xml"))
if err != nil {
t.Fatalf("read issue234 sources fixture: %v", err)
}
// Sanity-check the fixtures before relying on the round-trip:
// a typo in testdata would silently invalidate the assertions.
if !strings.Contains(string(infoXML), "<margeAccountUUID></margeAccountUUID>") {
t.Fatalf("issue234 info fixture must carry an empty <margeAccountUUID> to model a factory-reset device; got:\n%s", infoXML)
}
if strings.Contains(string(sourcesXML), `source="TUNEIN"`) ||
strings.Contains(string(sourcesXML), `source="LOCAL_INTERNET_RADIO"`) {
t.Fatalf("issue234 sources fixture must NOT contain TUNEIN or LOCAL_INTERNET_RADIO — they're the symptom we're modelling; got:\n%s", sourcesXML)
}
s, err := fakespeaker.Start(fakespeaker.Config{
FixtureOverrides: map[string][]byte{
"/info": infoXML,
"/sources": sourcesXML,
},
})
if err != nil {
t.Fatalf("start fakespeaker: %v", err)
}
t.Cleanup(func() {
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel()
_ = s.Stop(ctx)
})
tempDir, err := os.MkdirTemp("", "issue234-*")
if err != nil {
t.Fatalf("mkdir temp: %v", err)
}
t.Cleanup(func() { _ = os.RemoveAll(tempDir) })
ds := datastore.NewDataStore(tempDir)
m := NewManager("http://localhost:8080", ds, nil)
deviceIP := s.HTTPAddr() // "127.0.0.1:<random>" — host:port form routes via the bare-URL branch in syncSources
// 1. Factory-reset detection: /info reports no margeAccountUUID,
// so downstream code can refuse to claim "paired" status.
info, err := m.GetLiveDeviceInfo(deviceIP)
if err != nil {
t.Fatalf("GetLiveDeviceInfo: %v", err)
}
if info.MargeAccountUUID != "" {
t.Errorf("MargeAccountUUID = %q, want empty (factory-reset speaker has no account yet)", info.MargeAccountUUID)
}
if info.DeviceID != "DEADBEEFCAFE" {
t.Errorf("DeviceID = %q, want %q", info.DeviceID, "DEADBEEFCAFE")
}
// 2. Source round-trip: reduced list survives sync verbatim.
const accountID = "issue234"
const deviceID = "DEADBEEFCAFE"
m.syncSources(deviceIP, accountID, deviceID)
sourcesPath := filepath.Join(tempDir, "accounts", accountID, "devices", deviceID, "Sources.xml")
persisted, err := os.ReadFile(sourcesPath)
if err != nil {
t.Fatalf("read persisted sources at %s: %v", sourcesPath, err)
}
content := string(persisted)
// Survivors: the local-only sources reported by the factory-reset
// device should land in the persisted file.
for _, sourceKey := range []string{
`<sourceKey type="AUX"`,
`<sourceKey type="BLUETOOTH"`,
`<sourceKey type="AIRPLAY"`,
} {
if !strings.Contains(content, sourceKey) {
t.Errorf("persisted Sources.xml missing %s; body:\n%s", sourceKey, content)
}
}
// Casualties: TUNEIN / LOCAL_INTERNET_RADIO are the symptom of
// #234 — they should remain absent until auto-recovery lands.
for _, missingKey := range []string{
`<sourceKey type="TUNEIN"`,
`<sourceKey type="LOCAL_INTERNET_RADIO"`,
} {
if strings.Contains(content, missingKey) {
t.Errorf("persisted Sources.xml unexpectedly contains %s — auto-recovery for issue #234 may have landed; if so, flip this assertion and the doc-comment;\nbody:\n%s",
missingKey, content)
}
}
}
+23
View File
@@ -0,0 +1,23 @@
<?xml version="1.0" encoding="UTF-8" ?>
<info deviceID="DEADBEEFCAFE">
<name>Factory-Reset SoundTouch 20</name>
<type>SoundTouch 20</type>
<margeAccountUUID></margeAccountUUID>
<components>
<component>
<componentCategory>SCM</componentCategory>
<softwareVersion>27.0.6.46330.5043500</softwareVersion>
<serialNumber>SN0000000000000000DEMO</serialNumber>
</component>
</components>
<margeURL>https://streaming.bose.com</margeURL>
<networkInfo type="SCM">
<macAddress>02:00:00:00:00:01</macAddress>
<ipAddress>127.0.0.1</ipAddress>
</networkInfo>
<moduleType>sm2</moduleType>
<variant>rhino</variant>
<variantMode>normal</variantMode>
<countryCode>GB</countryCode>
<regionCode>GB</regionCode>
</info>
+9
View File
@@ -0,0 +1,9 @@
<?xml version="1.0" encoding="UTF-8" ?>
<sources deviceID="DEADBEEFCAFE">
<sourceItem source="AUX" sourceAccount="AUX" status="READY" isLocal="true" multiroomallowed="true">AUX IN</sourceItem>
<sourceItem source="BLUETOOTH" status="UNAVAILABLE" isLocal="true" multiroomallowed="true"/>
<sourceItem source="AIRPLAY" status="UNAVAILABLE" isLocal="false" multiroomallowed="false"/>
<sourceItem source="SPOTIFY" sourceAccount="SpotifyConnectUserName" status="UNAVAILABLE" isLocal="false" multiroomallowed="true">SpotifyConnectUserName</sourceItem>
<sourceItem source="NOTIFICATION" status="UNAVAILABLE" isLocal="false" multiroomallowed="true"/>
<sourceItem source="QPLAY" sourceAccount="QPlay1UserName" status="UNAVAILABLE" isLocal="true" multiroomallowed="true">QPlay1UserName</sourceItem>
</sources>