Files
Bose-SoundTouch/pkg/service/testing/fakespeaker/fakespeaker_test.go
T
Tobias GesellchenandClaude Opus 4.7 0e10bfcb14 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>
2026-05-15 14:13:24 +02:00

258 lines
6.4 KiB
Go

package fakespeaker
import (
"bytes"
"context"
"encoding/xml"
"io"
"net/http"
"strings"
"testing"
"time"
)
func TestFakeSpeakerServesFixtures(t *testing.T) {
s, err := Start(Config{})
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)
})
cases := []struct {
path string
root string
}{
{"/info", "info"},
{"/presets", "presets"},
{"/recents", "recents"},
{"/networkInfo", "networkInfo"},
{"/sources", "sources"},
{"/supportedURLs", "supportedURLs"},
{"/getGroup", "group"},
{"/removeGroup", "group"},
{"/now_playing", "nowPlaying"},
}
for _, tc := range cases {
t.Run(tc.path, func(t *testing.T) {
resp, err := http.Get("http://" + s.HTTPAddr() + tc.path) //nolint:noctx
if err != nil {
t.Fatalf("get %s: %v", tc.path, err)
}
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode != http.StatusOK {
t.Fatalf("status = %d, want 200", resp.StatusCode)
}
body, err := io.ReadAll(resp.Body)
if err != nil {
t.Fatalf("read body: %v", err)
}
var root struct {
XMLName xml.Name
}
if err := xml.Unmarshal(body, &root); err != nil {
t.Fatalf("parse XML: %v\n%s", err, body)
}
if root.XMLName.Local != tc.root {
t.Fatalf("root element = %q, want %q", root.XMLName.Local, tc.root)
}
})
}
}
func TestFakeSpeakerAddGroupEchoesWithGroupOK(t *testing.T) {
s, err := Start(Config{})
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)
})
posted := `<?xml version="1.0" encoding="UTF-8"?>
<group>
<name>TEST</name>
<masterDeviceId>DEADBEEFCAFE</masterDeviceId>
<roles>
<groupRole><deviceId>DEADBEEFCAFE</deviceId><role>LEFT</role><ipAddress>127.0.0.1</ipAddress></groupRole>
<groupRole><deviceId>0000DEADBEEF</deviceId><role>RIGHT</role><ipAddress>127.0.0.2</ipAddress></groupRole>
</roles>
</group>`
resp, err := http.Post("http://"+s.HTTPAddr()+"/addGroup", "application/xml", strings.NewReader(posted)) //nolint:noctx
if err != nil {
t.Fatalf("post: %v", err)
}
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode != http.StatusOK {
t.Fatalf("status = %d, want 200", resp.StatusCode)
}
body, err := io.ReadAll(resp.Body)
if err != nil {
t.Fatalf("read body: %v", err)
}
// Echo: the posted name + roles survive in the response.
if !bytes.Contains(body, []byte("<name>TEST</name>")) {
t.Errorf("response missing posted <name>; body:\n%s", body)
}
if !bytes.Contains(body, []byte("<masterDeviceId>DEADBEEFCAFE</masterDeviceId>")) {
t.Errorf("response missing posted <masterDeviceId>; body:\n%s", body)
}
// Success marker: <status>GROUP_OK</status> appears before </group>.
statusIdx := bytes.Index(body, []byte("<status>GROUP_OK</status>"))
if statusIdx < 0 {
t.Fatalf("response missing <status>GROUP_OK</status>; body:\n%s", body)
}
closeIdx := bytes.LastIndex(body, []byte("</group>"))
if closeIdx < 0 || statusIdx >= closeIdx {
t.Errorf("<status> not nested inside <group>...</group>; body:\n%s", body)
}
}
func TestFakeSpeakerUpdateGroupEchoesWithGroupOK(t *testing.T) {
s, err := Start(Config{})
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)
})
posted := `<?xml version="1.0" encoding="UTF-8"?>
<group>
<name>RENAMED</name>
<masterDeviceId>DEADBEEFCAFE</masterDeviceId>
</group>`
resp, err := http.Post("http://"+s.HTTPAddr()+"/updateGroup", "application/xml", strings.NewReader(posted)) //nolint:noctx
if err != nil {
t.Fatalf("post: %v", err)
}
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode != http.StatusOK {
t.Fatalf("status = %d, want 200", resp.StatusCode)
}
body, err := io.ReadAll(resp.Body)
if err != nil {
t.Fatalf("read body: %v", err)
}
if !bytes.Contains(body, []byte("<name>RENAMED</name>")) {
t.Errorf("response missing posted <name>; body:\n%s", body)
}
if !bytes.Contains(body, []byte("<status>GROUP_OK</status>")) {
t.Errorf("response missing <status>GROUP_OK</status>; body:\n%s", body)
}
}
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 {
t.Fatalf("start: %v", err)
}
t.Cleanup(func() {
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel()
_ = s.Stop(ctx)
})
resp, err := http.Post("http://"+s.HTTPAddr()+"/removeGroup", "application/xml", strings.NewReader("")) //nolint:noctx
if err != nil {
t.Fatalf("post: %v", err)
}
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode != http.StatusMethodNotAllowed {
t.Fatalf("status = %d, want 405", resp.StatusCode)
}
if got := resp.Header.Get("Allow"); got != "GET" {
t.Errorf("Allow header = %q, want %q", got, "GET")
}
}