Files
Tobias GesellchenandClaude Opus 4.7 6e6e4838e6 fix(setup): fire <sourcesUpdated/> after data sync to recover post-factory-reset sources
Closes the AfterTouch-side half of issue #234. After a factory reset
the speaker's /sources only lists the always-on local entries (AUX,
BLUETOOTH, AIRPLAY, NOTIFICATION, QPLAY, plus a SpotifyConnectUserName
placeholder); TUNEIN, LOCAL_INTERNET_RADIO, DEEZER, and linked
Spotify accounts are absent until the device receives the
<sourcesUpdated/> notification the reporter ran by hand. SyncDeviceData
now POSTs that notification as the final step, so users get the
visible-source-list recovery for free when they click Data Sync.

The other half — re-creating Marge.xml so playback resumes — is
already handled by the wizard's pair-account flow: it detects an
empty <margeAccountUUID/> in /info and prompts the user to pick a
known account or generate a new one. The wizard's pairing UI is
deliberately user-driven (the user picks the ID); the notification
nudge is purely automatic because there's no choice to make.

Implementation routes through the existing client surface rather
than reinventing it. setup.notifySpeakerSourcesUpdated delegates to
pkg/client.Client.NotifySourcesUpdated — the same path
handlers_mgmt.go already uses after music-service account changes
(handlers_mgmt.go:304, :637). The wire shape lives in one place
(pkg/models.NewSourcesUpdatedNotification). Fire-and-forget: a
notification failure logs but doesn't fail the sync.

Adjacent UX changes:

  - docs/guides/TROUBLESHOOTING.md: new section "Presets flash then
    revert to 'Select a preset' after a factory reset". Names the
    symptom, the Marge.xml + reduced-/sources cause, and walks the
    user through re-opening the Migration tab + Data Sync.

  - pkg/service/handlers/web/js/script.js: devices list now renders
    a "⚠ Not paired — re-pair" badge in the account-ID column for
    speakers whose live /info reports an empty margeAccountUUID.
    Clicking it opens the Migration tab pre-filled with that device,
    surfacing the wizard's existing "Not paired (factory-reset or
    never paired)" flow without making users discover it cold.

  - pkg/service/testing/fakespeaker/testdata/info.xml: demo speaker
    now reports margeAccountUUID=1234567 instead of the misleading
    0000000 (which AfterTouch happens to accept as syntactically
    valid but is not a documented sentinel anywhere — the convention
    is empty for factory-reset, a real 7-digit number otherwise,
    matching pkg/client/testdata/info_response_st{10,20}.xml).
    Screenshots regenerated accordingly.

Test scaffolding:

  - fakespeaker grows a POST /notification recorder that captures
    body + Content-Type; tests assert on s.Notifications().
  - TestIssue234_FactoryResetSpeakerSyncsReducedSources now drives
    SyncDeviceData end-to-end (exercises the wiring) and asserts
    the notification fires with the right deviceID and shape.
  - TestFakeSpeakerNotificationRecorder pins the recorder contract
    and the POST-only method gate.

Refs #234.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 16:25:07 +02:00

325 lines
8.2 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 TestFakeSpeakerNotificationRecorder(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)
})
body := `<updates deviceID="DEADBEEFCAFE"><sourcesUpdated/></updates>`
req, err := http.NewRequest(http.MethodPost,
"http://"+s.HTTPAddr()+"/notification",
strings.NewReader(body))
if err != nil {
t.Fatalf("build request: %v", err)
}
req.Header.Set("Content-Type", "application/xml")
resp, err := http.DefaultClient.Do(req)
if err != nil {
t.Fatalf("post: %v", err)
}
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode != http.StatusOK {
t.Errorf("status = %d, want 200", resp.StatusCode)
}
got := s.Notifications()
if len(got) != 1 {
t.Fatalf("Notifications() returned %d entries, want 1", len(got))
}
// The test POSTs the request body verbatim; the recorder must
// return it byte-identical. (Wire-shape variation — self-closing
// vs long-form sourcesUpdated — happens upstream in
// pkg/client.NotifySourcesUpdated, not here.)
if string(got[0].Body) != body {
t.Errorf("body = %q, want %q", got[0].Body, body)
}
if got[0].ContentType != "application/xml" {
t.Errorf("ContentType = %q, want application/xml", got[0].ContentType)
}
// GET on the same path is a 405 — real speakers don't expose it.
getResp, err := http.Get("http://" + s.HTTPAddr() + "/notification") //nolint:noctx
if err != nil {
t.Fatalf("get: %v", err)
}
defer func() { _ = getResp.Body.Close() }()
if getResp.StatusCode != http.StatusMethodNotAllowed {
t.Errorf("GET status = %d, want 405", getResp.StatusCode)
}
if got := getResp.Header.Get("Allow"); got != "POST" {
t.Errorf("Allow header = %q, want POST", got)
}
}
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")
}
}