diff --git a/pkg/service/setup/issue218_regression_test.go b/pkg/service/setup/issue218_regression_test.go
new file mode 100644
index 0000000..c36bc20
--- /dev/null
+++ b/pkg/service/setup/issue218_regression_test.go
@@ -0,0 +1,116 @@
+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"
+)
+
+// TestIssue218_LocalInternetRadioPresetSurvivesSync drives a syncPresets
+// against a fakespeaker that emits the exact LOCAL_INTERNET_RADIO preset
+// XML pasted by the reporter in issue #218:
+//
+// https://github.com/gesellix/Bose-SoundTouch/issues/218
+//
+// The preset's contentItem location points at
+// `https://content.api.bose.io/core02/svc-bmx-adapter-orion/prod/orion/station?data=...`
+// — a Bose cloud URL that broke when the cloud shut down. After
+// migration AfterTouch must keep that URL reachable (via the DNS
+// interception hook + serving the /core02/svc-bmx-adapter-orion path
+// itself); the first step is verifying the URL is preserved verbatim
+// through the device → datastore sync round-trip rather than getting
+// rewritten or dropped.
+//
+// This test locks in the "location preserved verbatim" contract. When
+// AfterTouch starts rewriting the URL to its own base (the eventual
+// fix for #218 — see also issue #195's AUX divergence and #234's
+// factory-reset preset revert which overlap with the same DNS/HTTPS
+// interception story), this test will need to flip its assertion
+// accordingly. The fixture stays — the assertion records the
+// decision.
+//
+// Pattern reference: pkg/service/marge/recents_sourceproviderid_regression_test.go
+// is the existing "regression test = locked-in behaviour" exemplar in
+// this codebase; this is the first one to drive the fake speaker via
+// fakespeaker.Config.FixtureOverrides rather than an inline
+// httptest.NewServer.
+func TestIssue218_LocalInternetRadioPresetSurvivesSync(t *testing.T) {
+ presetsXML, err := os.ReadFile(filepath.Join("testdata", "issue218", "presets.xml"))
+ if err != nil {
+ t.Fatalf("read issue218 presets fixture: %v", err)
+ }
+
+ const boseCloudURL = "https://content.api.bose.io/core02/svc-bmx-adapter-orion/"
+
+ // Sanity-check the fixture itself before trusting any assertion
+ // downstream — a typo in the testdata would silently turn the
+ // regression test into a no-op.
+ if !strings.Contains(string(presetsXML), boseCloudURL) {
+ t.Fatalf("fixture missing expected Bose cloud URL prefix %q; got:\n%s", boseCloudURL, presetsXML)
+ }
+
+ s, err := fakespeaker.Start(fakespeaker.Config{
+ FixtureOverrides: map[string][]byte{
+ "/presets": presetsXML,
+ },
+ })
+ 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("", "issue218-*")
+ 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() // e.g. "127.0.0.1:54321" — syncPresets routes via host:port form
+
+ const accountID = "issue218"
+
+ const deviceID = "DEADBEEFCAFE"
+
+ m.syncPresets(deviceIP, accountID, deviceID)
+
+ persistedPath := filepath.Join(tempDir, "accounts", accountID, "devices", deviceID, "Presets.xml")
+
+ persisted, err := os.ReadFile(persistedPath)
+ if err != nil {
+ t.Fatalf("read persisted presets at %s: %v", persistedPath, err)
+ }
+
+ // The locked-in contract: the cloud URL survives the round-trip.
+ // When AfterTouch starts rewriting it (the actual fix for #218),
+ // flip this assertion to assert the rewritten URL.
+ if !strings.Contains(string(persisted), boseCloudURL) {
+ t.Errorf("persisted Presets.xml dropped the Bose cloud URL.\nfixture URL prefix:\n %s\npersisted body:\n%s",
+ boseCloudURL, persisted)
+ }
+
+ // Round-trip should preserve preset id and source as well — basic
+ // shape checks borrowed from sync_regression_test.go.
+ if !strings.Contains(string(persisted), `id="1"`) {
+ t.Errorf("persisted Presets.xml missing id=\"1\"; body:\n%s", persisted)
+ }
+
+ if !strings.Contains(string(persisted), `source="LOCAL_INTERNET_RADIO"`) {
+ t.Errorf("persisted Presets.xml missing source=\"LOCAL_INTERNET_RADIO\"; body:\n%s", persisted)
+ }
+}
diff --git a/pkg/service/setup/testdata/issue218/presets.xml b/pkg/service/setup/testdata/issue218/presets.xml
new file mode 100644
index 0000000..1322799
--- /dev/null
+++ b/pkg/service/setup/testdata/issue218/presets.xml
@@ -0,0 +1,9 @@
+
+
+
+
+ OPB
+
+
+
+
diff --git a/pkg/service/testing/fakespeaker/fakespeaker.go b/pkg/service/testing/fakespeaker/fakespeaker.go
index 78cf307..02b5788 100644
--- a/pkg/service/testing/fakespeaker/fakespeaker.go
+++ b/pkg/service/testing/fakespeaker/fakespeaker.go
@@ -35,6 +35,19 @@ type Config struct {
// diagnostic shell. Empty disables the telnet listener entirely.
// Use "127.0.0.1:17000" to match the real port the wizard probes.
TelnetListen string
+
+ // FixtureOverrides replaces the response body for the given fixture
+ // route (e.g. "/info", "/presets", "/sources") with the supplied
+ // bytes. Routes not present in the map fall through to the embedded
+ // defaults shipped under testdata/. A nil or empty map keeps the
+ // default behaviour the screenshot pipeline relies on.
+ //
+ // Stateful handlers (/getGroup, /addGroup, /updateGroup,
+ // /removeGroup) are not affected — overrides only apply to the
+ // GET fixture routes. Use this to wire issue-specific payloads
+ // into per-issue regression tests; see
+ // pkg/service/setup/issue218_regression_test.go for the pattern.
+ FixtureOverrides map[string][]byte
}
// Server is a running fake speaker. It bundles whichever sub-servers
@@ -61,7 +74,7 @@ func Start(cfg Config) (*Server, error) {
}
mux := http.NewServeMux()
- registerRoutes(mux)
+ registerRoutes(mux, cfg.FixtureOverrides)
s := &Server{
srv: &http.Server{
@@ -117,19 +130,40 @@ func (s *Server) Stop(ctx context.Context) error {
return nil
}
-func registerRoutes(mux *http.ServeMux) {
- mux.HandleFunc("/info", serveFixture("testdata/info.xml"))
- mux.HandleFunc("/presets", serveFixture("testdata/presets.xml"))
- mux.HandleFunc("/recents", serveFixture("testdata/recents.xml"))
- mux.HandleFunc("/networkInfo", serveFixture("testdata/networkinfo.xml"))
- mux.HandleFunc("/sources", serveFixture("testdata/sources.xml"))
- mux.HandleFunc("/supportedURLs", serveFixture("testdata/supportedurls.xml"))
+func registerRoutes(mux *http.ServeMux, overrides map[string][]byte) {
+ fixture := func(route, embedPath string) {
+ mux.HandleFunc(route, serveFixtureOr(embedPath, overrides[route]))
+ }
+
+ fixture("/info", "testdata/info.xml")
+ fixture("/presets", "testdata/presets.xml")
+ fixture("/recents", "testdata/recents.xml")
+ fixture("/networkInfo", "testdata/networkinfo.xml")
+ fixture("/sources", "testdata/sources.xml")
+ fixture("/supportedURLs", "testdata/supportedurls.xml")
+
mux.HandleFunc("/getGroup", serveEmptyGroup)
mux.HandleFunc("/addGroup", handleAddGroup)
mux.HandleFunc("/updateGroup", handleUpdateGroup)
mux.HandleFunc("/removeGroup", handleRemoveGroup)
}
+// serveFixtureOr returns a handler that writes override (when non-nil)
+// or the embedded fixture at embedPath (when override is nil). The
+// override is snapshotted at construction so later mutations of the
+// caller's slice don't change the served body.
+func serveFixtureOr(embedPath string, override []byte) http.HandlerFunc {
+ if override != nil {
+ snapshot := append([]byte(nil), override...)
+ return func(w http.ResponseWriter, _ *http.Request) {
+ w.Header().Set("Content-Type", "application/xml; charset=utf-8")
+ _, _ = w.Write(snapshot)
+ }
+ }
+
+ return serveFixture(embedPath)
+}
+
func serveFixture(path string) http.HandlerFunc {
body, err := fixtures.ReadFile(path)
if err != nil {
diff --git a/pkg/service/testing/fakespeaker/fakespeaker_test.go b/pkg/service/testing/fakespeaker/fakespeaker_test.go
index ba13aad..6fac371 100644
--- a/pkg/service/testing/fakespeaker/fakespeaker_test.go
+++ b/pkg/service/testing/fakespeaker/fakespeaker_test.go
@@ -172,6 +172,61 @@ func TestFakeSpeakerUpdateGroupEchoesWithGroupOK(t *testing.T) {
}
}
+func TestFakeSpeakerFixtureOverride_ReplacesEmbeddedBody(t *testing.T) {
+ custom := []byte(`
+
+ Custom Override
+`)
+
+ s, err := Start(Config{
+ FixtureOverrides: map[string][]byte{
+ "/presets": custom,
+ },
+ })
+ if err != nil {
+ t.Fatalf("start: %v", err)
+ }
+
+ t.Cleanup(func() {
+ ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
+ defer cancel()
+
+ _ = s.Stop(ctx)
+ })
+
+ // Overridden route returns the custom body verbatim.
+ resp, err := http.Get("http://" + s.HTTPAddr() + "/presets") //nolint:noctx
+ if err != nil {
+ t.Fatalf("get /presets: %v", err)
+ }
+ defer func() { _ = resp.Body.Close() }()
+
+ body, err := io.ReadAll(resp.Body)
+ if err != nil {
+ t.Fatalf("read body: %v", err)
+ }
+
+ if !bytes.Equal(body, custom) {
+ t.Errorf("/presets body mismatch.\ngot:\n%s\nwant:\n%s", body, custom)
+ }
+
+ // Non-overridden route still serves the embedded default.
+ resp2, err := http.Get("http://" + s.HTTPAddr() + "/info") //nolint:noctx
+ if err != nil {
+ t.Fatalf("get /info: %v", err)
+ }
+ defer func() { _ = resp2.Body.Close() }()
+
+ body2, err := io.ReadAll(resp2.Body)
+ if err != nil {
+ t.Fatalf("read /info body: %v", err)
+ }
+
+ if !bytes.Contains(body2, []byte(`deviceID="DEADBEEFCAFE"`)) {
+ t.Errorf("/info default fixture missing expected deviceID; body:\n%s", body2)
+ }
+}
+
func TestFakeSpeakerRemoveGroupRejectsNonGET(t *testing.T) {
s, err := Start(Config{})
if err != nil {