From 0e10bfcb143f909515f6339b18ed492c11bd2f33 Mon Sep 17 00:00:00 2001 From: Tobias Gesellchen Date: Fri, 15 May 2026 13:58:24 +0200 Subject: [PATCH] =?UTF-8?q?test(setup):=20wire=20issue=20#235=20=E2=80=94?= =?UTF-8?q?=20Spotify=20Connect=20/now=5Fplaying=20reports=20IsPresetable?= =?UTF-8?q?=3Dfalse?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two-part iteration. First, the fakespeaker grows a `/now_playing` route with a default STANDBY fixture — issue #235 is the first one in this series that needs to override /now_playing, and adding the route on its own would be infrastructure noise; bundled here it has an immediate consumer. The regression test then locks in the device-side signal at the heart of #235: when a SoundTouch is targeted by Spotify Connect (Spotify app sends audio to the speaker), the speaker's /now_playing reports - source = SPOTIFY - sourceAccount = SpotifyConnectUserName (the marker) - ContentItem.location = /playback/container/ — a perfectly resolvable URI - **ContentItem.isPresetable = false** The contradiction (resolvable location + isPresetable=false) is the reason the CLI's storeCurrentPreset at cmd/soundtouch-cli/cmd_preset.go:41 refuses to act and emits "current content cannot be preset" — exactly the reporter's symptom. The test base64-decodes the location to surface the contradiction explicitly: it should yield a `spotify:` URI. When AfterTouch grows a fallback path (CLI --force, or service-side resolution to the device's own Spotify integration via the SoundTouch Spotify source provider), the assertion here stays sound — it tests what the device emits, not what the CLI decides — but a sibling test should assert the new fallback path produces a successful preset. Fixture pattern matches the rest of the issue series: testdata/issue235/ next to the test, fakespeaker driven via FixtureOverrides, doc-comment naming what would have to change for the assertion to flip. Refs #235. Co-Authored-By: Claude Opus 4.7 (1M context) --- pkg/service/setup/issue235_regression_test.go | 141 ++++++++++++++++++ .../setup/testdata/issue235/now_playing.xml | 16 ++ .../testing/fakespeaker/fakespeaker.go | 3 +- .../testing/fakespeaker/fakespeaker_test.go | 1 + .../fakespeaker/testdata/now_playing.xml | 4 + 5 files changed, 164 insertions(+), 1 deletion(-) create mode 100644 pkg/service/setup/issue235_regression_test.go create mode 100644 pkg/service/setup/testdata/issue235/now_playing.xml create mode 100644 pkg/service/testing/fakespeaker/testdata/now_playing.xml diff --git a/pkg/service/setup/issue235_regression_test.go b/pkg/service/setup/issue235_regression_test.go new file mode 100644 index 0000000..3729bae --- /dev/null +++ b/pkg/service/setup/issue235_regression_test.go @@ -0,0 +1,141 @@ +package setup + +import ( + "context" + "encoding/base64" + "os" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/gesellix/bose-soundtouch/pkg/client" + "github.com/gesellix/bose-soundtouch/pkg/service/testing/fakespeaker" +) + +// TestIssue235_SpotifyConnectNowPlayingReportsNotPresetable documents +// the device-side signal behind issue #235: +// +// https://github.com/gesellix/Bose-SoundTouch/issues/235 +// +// When music is streamed to a SoundTouch via Spotify Connect (the +// Spotify mobile/desktop app sends audio to the speaker, as opposed +// to the speaker's own Spotify integration), the speaker's +// /now_playing response carries: +// +// - source = SPOTIFY +// - sourceAccount = SpotifyConnectUserName (the magic placeholder) +// - ContentItem.location = a base64-encoded Spotify URI that *does* +// look replayable (e.g. spotify:playlist:... once decoded) +// - **ContentItem.isPresetable = false** +// +// The CLI's storeCurrentPreset (cmd/soundtouch-cli/cmd_preset.go:41) +// keys on `IsPresetable` and bails out with the documented error +// "current content cannot be preset" — exactly what the reporter +// sees. The contradiction at the heart of the bug: the location is a +// perfectly resolvable Spotify URI, but the speaker still refuses +// to expose it as presetable. +// +// What this test locks in: +// +// - The /now_playing payload AfterTouch reads from a Spotify +// Connect session has IsPresetable=false, despite a non-empty +// location. +// - The location field, base64-URL-decoded, yields a recognisable +// `spotify:` URI. The contradiction is preserved verbatim so we +// don't accidentally "fix" the test by stripping the location. +// +// When AfterTouch grows logic to override the IsPresetable signal +// for Spotify Connect (e.g. a CLI --force flag, or service-side +// resolution to the device's own Spotify integration), the assertion +// here stays sound — it tests what the device emits, not what the +// CLI decides — but a sibling test should assert the new fallback +// path produces a successful preset. +// +// Pattern mirrors pkg/service/setup/issue218_regression_test.go. +func TestIssue235_SpotifyConnectNowPlayingReportsNotPresetable(t *testing.T) { + npXML, err := os.ReadFile(filepath.Join("testdata", "issue235", "now_playing.xml")) + if err != nil { + t.Fatalf("read issue235 now_playing fixture: %v", err) + } + + // Fixture sanity: the SpotifyConnectUserName marker and + // isPresetable=false are the load-bearing parts. + if !strings.Contains(string(npXML), "SpotifyConnectUserName") { + t.Fatalf("fixture missing SpotifyConnectUserName marker; got:\n%s", npXML) + } + + if !strings.Contains(string(npXML), `isPresetable="false"`) { + t.Fatalf("fixture missing isPresetable=\"false\"; got:\n%s", npXML) + } + + s, err := fakespeaker.Start(fakespeaker.Config{ + FixtureOverrides: map[string][]byte{ + "/now_playing": npXML, + }, + }) + 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) + }) + + // fakespeaker.HTTPAddr() returns "127.0.0.1:"; client.NewClientFromHost + // accepts the host:port form directly and routes /now_playing to it. + c := client.NewClientFromHost(s.HTTPAddr()) + + now, err := c.GetNowPlaying() + if err != nil { + t.Fatalf("GetNowPlaying: %v", err) + } + + if now.ContentItem == nil { + t.Fatalf("ContentItem is nil; full now_playing:\n%+v", now) + } + + // The bug's defining signal: false despite a non-empty location. + if now.ContentItem.IsPresetable { + t.Errorf("ContentItem.IsPresetable = true, want false — the Spotify Connect contradiction was 'fixed' on the device side; review whether the CLI's storeCurrentPreset still needs the IsPresetable gate") + } + + if now.ContentItem.Location == "" { + t.Errorf("ContentItem.Location is empty, want a Spotify URI — fixture has drifted from the issue payload") + } + + if now.Source != "SPOTIFY" { + t.Errorf("Source = %q, want SPOTIFY", now.Source) + } + + if now.SourceAccount != "SpotifyConnectUserName" { + t.Errorf("SourceAccount = %q, want SpotifyConnectUserName (the Spotify Connect marker)", now.SourceAccount) + } + + // Surface the contradiction: the location decodes to a real Spotify URI, + // so the IsPresetable=false is purely a device-side policy. Decode the + // path-segment that follows `/playback/container/`. + const containerPrefix = "/playback/container/" + + segment := strings.TrimPrefix(now.ContentItem.Location, containerPrefix) + if segment == now.ContentItem.Location { + t.Logf("note: location does not match /playback/container/ shape (was %q); not decoding", now.ContentItem.Location) + return + } + + decoded, err := base64.URLEncoding.DecodeString(segment) + if err != nil { + decoded, err = base64.RawURLEncoding.DecodeString(strings.TrimRight(segment, "=")) + if err != nil { + t.Logf("note: location segment %q is not base64-URL-decodable: %v", segment, err) + return + } + } + + if !strings.HasPrefix(string(decoded), "spotify:") { + t.Errorf("decoded location %q does not look like a spotify: URI; fixture may have drifted", decoded) + } +} diff --git a/pkg/service/setup/testdata/issue235/now_playing.xml b/pkg/service/setup/testdata/issue235/now_playing.xml new file mode 100644 index 0000000..4191939 --- /dev/null +++ b/pkg/service/setup/testdata/issue235/now_playing.xml @@ -0,0 +1,16 @@ + + + + Aiyomi + https://example.invalid/cover.jpg + + Aiyomi + Naritomi + Aiyomi + + https://example.invalid/cover.jpg + + + PLAY_STATE + TRACK_ONDEMAND + diff --git a/pkg/service/testing/fakespeaker/fakespeaker.go b/pkg/service/testing/fakespeaker/fakespeaker.go index 02b5788..ae59c4f 100644 --- a/pkg/service/testing/fakespeaker/fakespeaker.go +++ b/pkg/service/testing/fakespeaker/fakespeaker.go @@ -20,7 +20,7 @@ import ( "time" ) -//go:embed testdata/info.xml testdata/presets.xml testdata/recents.xml testdata/networkinfo.xml testdata/sources.xml testdata/supportedurls.xml +//go:embed testdata/info.xml testdata/presets.xml testdata/recents.xml testdata/networkinfo.xml testdata/sources.xml testdata/supportedurls.xml testdata/now_playing.xml var fixtures embed.FS // Config configures a fake speaker. The zero value is valid and binds the @@ -141,6 +141,7 @@ func registerRoutes(mux *http.ServeMux, overrides map[string][]byte) { fixture("/networkInfo", "testdata/networkinfo.xml") fixture("/sources", "testdata/sources.xml") fixture("/supportedURLs", "testdata/supportedurls.xml") + fixture("/now_playing", "testdata/now_playing.xml") mux.HandleFunc("/getGroup", serveEmptyGroup) mux.HandleFunc("/addGroup", handleAddGroup) diff --git a/pkg/service/testing/fakespeaker/fakespeaker_test.go b/pkg/service/testing/fakespeaker/fakespeaker_test.go index 6fac371..b8e5b79 100644 --- a/pkg/service/testing/fakespeaker/fakespeaker_test.go +++ b/pkg/service/testing/fakespeaker/fakespeaker_test.go @@ -36,6 +36,7 @@ func TestFakeSpeakerServesFixtures(t *testing.T) { {"/supportedURLs", "supportedURLs"}, {"/getGroup", "group"}, {"/removeGroup", "group"}, + {"/now_playing", "nowPlaying"}, } for _, tc := range cases { diff --git a/pkg/service/testing/fakespeaker/testdata/now_playing.xml b/pkg/service/testing/fakespeaker/testdata/now_playing.xml new file mode 100644 index 0000000..236ae2e --- /dev/null +++ b/pkg/service/testing/fakespeaker/testdata/now_playing.xml @@ -0,0 +1,4 @@ + + + +