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 {