mirror of
https://github.com/gesellix/Bose-SoundTouch.git
synced 2026-08-18 00:26:29 +00:00
test(setup): wire issue #235 — Spotify Connect /now_playing reports IsPresetable=false
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/<base64 spotify:...> — 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) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.7
parent
65a9873545
commit
0e10bfcb14
@@ -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:<port>"; 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/<base64> 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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
<?xml version="1.0" encoding="UTF-8" ?>
|
||||
<nowPlaying deviceID="DEADBEEFCAFE" source="SPOTIFY" sourceAccount="SpotifyConnectUserName">
|
||||
<ContentItem source="SPOTIFY" sourceAccount="SpotifyConnectUserName" type="tracklisturl" location="/playback/container/c3BvdGlmeTpwbGF5bGlzdDozN2k5ZFFaRjFEWDBraFRZM0hGQTRN" isPresetable="false">
|
||||
<itemName>Aiyomi</itemName>
|
||||
<containerArt>https://example.invalid/cover.jpg</containerArt>
|
||||
</ContentItem>
|
||||
<track>Aiyomi</track>
|
||||
<artist>Naritomi</artist>
|
||||
<album>Aiyomi</album>
|
||||
<stationName></stationName>
|
||||
<art artImageStatus="IMAGE_PRESENT">https://example.invalid/cover.jpg</art>
|
||||
<time total="156">29</time>
|
||||
<skipEnabled/>
|
||||
<playStatus>PLAY_STATE</playStatus>
|
||||
<streamType>TRACK_ONDEMAND</streamType>
|
||||
</nowPlaying>
|
||||
@@ -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)
|
||||
|
||||
@@ -36,6 +36,7 @@ func TestFakeSpeakerServesFixtures(t *testing.T) {
|
||||
{"/supportedURLs", "supportedURLs"},
|
||||
{"/getGroup", "group"},
|
||||
{"/removeGroup", "group"},
|
||||
{"/now_playing", "nowPlaying"},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
<?xml version="1.0" encoding="UTF-8" ?>
|
||||
<nowPlaying deviceID="DEADBEEFCAFE" source="STANDBY">
|
||||
<ContentItem source="STANDBY" isPresetable="false"/>
|
||||
</nowPlaying>
|
||||
Reference in New Issue
Block a user