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. End-to-end sync. Driving SyncDeviceData rather than
// syncSources directly exercises the wiring between
// syncSources and notifySpeakerSourcesUpdated — the source
// list still lands on disk (assertions below) AND the
// sourcesUpdated notification fires against the device.
// SyncDeviceData derives accountID/deviceID from /info; with
// an empty margeAccountUUID the account falls through to
// "default".
if err := m.SyncDeviceData(deviceIP); err != nil {
t.Fatalf("SyncDeviceData: %v", err)
}
const (
accountID = "default"
deviceID = "DEADBEEFCAFE"
)
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{
`) is up to encoding/xml and
// not protocol-meaningful — assert on the load-bearing pieces
// instead of the byte-identical body.
body := string(notifs[0].Body)
if !strings.Contains(body, `deviceID="DEADBEEFCAFE"`) {
t.Errorf("notification body missing deviceID; got: %q", body)
}
if !strings.Contains(body, "sourcesUpdated") {
t.Errorf("notification body missing sourcesUpdated; got: %q", body)
}
if !strings.Contains(notifs[0].ContentType, "xml") {
t.Errorf("notification Content-Type = %q, want something xml-shaped", notifs[0].ContentType)
}
}