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