test(fakespeaker): wire issue-specific payloads via Config.FixtureOverrides

Introduces a per-route fixture-override hook on fakespeaker.Config so
open issues with concrete device-side payloads can become repeatable
regression tests, then demonstrates the pattern by wiring issue #218.

Foundation. Config grows a single optional field:

  FixtureOverrides map[string][]byte

Routes named in the map (e.g. "/presets", "/sources", "/info") return
the supplied bytes; routes not in the map fall through to the embedded
testdata defaults the screenshot pipeline relies on. Stateful handlers
(/getGroup, /addGroup, /updateGroup, /removeGroup) are unaffected
because they're code-driven, not fixture-driven. The override slice is
snapshotted at construction so later mutations of the caller's slice
don't change the served body. Zero-value Config keeps the existing
behaviour, so cmd/dummy-speaker + scripts/screenshots are untouched.

Iteration zero — issue #218.
pkg/service/setup/issue218_regression_test.go starts a fakespeaker
serving the reporter's LOCAL_INTERNET_RADIO preset XML verbatim (URL:
content.api.bose.io/core02/svc-bmx-adapter-orion/prod/orion/station?…),
runs Manager.syncPresets against it, then asserts the persisted
Presets.xml retains the Bose cloud URL prefix. This locks in the
"location preserved through sync" contract; when AfterTouch starts
rewriting the URL to its own base (the eventual fix for #218), the
assertion flips and the fixture stays unchanged — the test is the
carrier for the decision.

Pattern reference for future issue regression tests: this exemplar
mirrors pkg/service/marge/recents_sourceproviderid_regression_test.go's
style (issue link, trigger chain in the doc-comment, locked-in
assertion) but is the first one to drive the device side via fakespeaker
rather than an inline httptest.NewServer. Subsequent issues with
device-side payloads (#234 factory-reset state, #235 Spotify-as-preset,
…) can reuse the FixtureOverrides hook without further infrastructure.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Tobias Gesellchen
2026-05-15 14:13:24 +02:00
co-authored by Claude Opus 4.7
parent 6196a802e2
commit 2fabdece64
4 changed files with 222 additions and 8 deletions
@@ -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)
}
}
+9
View File
@@ -0,0 +1,9 @@
<?xml version="1.0" encoding="UTF-8" ?>
<presets>
<preset id="1" createdOn="1575607101" updatedOn="1593644620">
<ContentItem source="LOCAL_INTERNET_RADIO" type="stationurl" location="https://content.api.bose.io/core02/svc-bmx-adapter-orion/prod/orion/station?data=eyJuYW1lIjoiT1BCIiwiaW1hZ2VVcmwiOiIiLCJzdHJlYW1VcmwiOiJodHRwOi8vYWlzLXNhMy5jZG5zdHJlYW0xLmNvbS8yNDQwXzEyOC5hYWMifQ%3D%3D" sourceAccount="" isPresetable="true">
<itemName>OPB</itemName>
<containerArt></containerArt>
</ContentItem>
</preset>
</presets>
+42 -8
View File
@@ -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 {
@@ -172,6 +172,61 @@ func TestFakeSpeakerUpdateGroupEchoesWithGroupOK(t *testing.T) {
}
}
func TestFakeSpeakerFixtureOverride_ReplacesEmbeddedBody(t *testing.T) {
custom := []byte(`<?xml version="1.0" encoding="UTF-8"?>
<presets>
<preset id="42"><ContentItem source="TUNEIN" type="stationurl" location="/v1/playback/station/sCUSTOM" isPresetable="true"><itemName>Custom Override</itemName></ContentItem></preset>
</presets>`)
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 {