From dd535cdb528caf08873ed855108a1854fc266497 Mon Sep 17 00:00:00 2001 From: Tobias Gesellchen Date: Fri, 15 May 2026 13:45:27 +0200 Subject: [PATCH] test(setup): pin factory-reset behaviour from issue #234 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 `` 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 `` 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) --- pkg/service/setup/issue234_regression_test.go | 157 ++++++++++++++++++ pkg/service/setup/testdata/issue234/info.xml | 23 +++ .../setup/testdata/issue234/sources.xml | 9 + 3 files changed, 189 insertions(+) create mode 100644 pkg/service/setup/issue234_regression_test.go create mode 100644 pkg/service/setup/testdata/issue234/info.xml create mode 100644 pkg/service/setup/testdata/issue234/sources.xml diff --git a/pkg/service/setup/issue234_regression_test.go b/pkg/service/setup/issue234_regression_test.go new file mode 100644 index 0000000..2fce2a6 --- /dev/null +++ b/pkg/service/setup/issue234_regression_test.go @@ -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 +// `` payload — that nudges the device to re-render +// its source list. Separately, `/info` reports an empty +// `` 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 `` 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), "") { + t.Fatalf("issue234 info fixture must carry an empty 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:" — 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{ + ` + + Factory-Reset SoundTouch 20 + SoundTouch 20 + + + + SCM + 27.0.6.46330.5043500 + SN0000000000000000DEMO + + + https://streaming.bose.com + + 02:00:00:00:00:01 + 127.0.0.1 + + sm2 + rhino + normal + GB + GB + diff --git a/pkg/service/setup/testdata/issue234/sources.xml b/pkg/service/setup/testdata/issue234/sources.xml new file mode 100644 index 0000000..48ef65b --- /dev/null +++ b/pkg/service/setup/testdata/issue234/sources.xml @@ -0,0 +1,9 @@ + + + AUX IN + + + SpotifyConnectUserName + + QPlay1UserName +