mirror of
https://github.com/gesellix/Bose-SoundTouch.git
synced 2026-08-18 08:36:13 +00:00
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:
co-authored by
Claude Opus 4.7
parent
13e82bbf85
commit
dd535cdb52
@@ -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
@@ -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>
|
||||
@@ -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>
|
||||
Reference in New Issue
Block a user