mirror of
https://github.com/gesellix/Bose-SoundTouch.git
synced 2026-08-18 08:36:13 +00:00
Comparing against JRpersonal/streborn#587 surfaced two gaps: no test pinned that a newly added source type renders the same element shape as a known-good default (the firmware rejects the whole account document if one source entry omits an expected element), and our DLNA discovery only swept SSDP from the service host, missing servers only visible from a paired speaker's own LAN segment. Adds TestSourceXMLShapeConsistencyAcrossTypes in pkg/service/marge, and has HandleDiscoverLibraryServers merge results from each paired speaker's own /listMediaServers alongside the existing SSDP sweep, deduped by UDN, with unreachable speakers skipped silently. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
851 lines
27 KiB
Go
851 lines
27 KiB
Go
package soundtouchweb
|
|
|
|
import (
|
|
"encoding/json"
|
|
"io"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/gesellix/bose-soundtouch/pkg/client"
|
|
"github.com/gesellix/bose-soundtouch/pkg/models"
|
|
"github.com/gesellix/bose-soundtouch/pkg/service/soundtouchweb/webtypes"
|
|
)
|
|
|
|
// cannedNavigateResponse is a minimal XML navigateResponse the fake speaker
|
|
// returns for /navigate in browse tests. It contains one directory and one
|
|
// track so we can assert both are mapped correctly.
|
|
// Note: totalItems is an XML element, not an attribute, per models.NavigateResponse.
|
|
const cannedNavigateResponse = `<?xml version="1.0" encoding="UTF-8" ?>
|
|
<navigateResponse source="STORED_MUSIC" sourceAccount="uuid:test-udn/0">
|
|
<totalItems>2</totalItems>
|
|
<items>
|
|
<item Playable="0">
|
|
<name>Albums</name>
|
|
<type>dir</type>
|
|
<ContentItem source="STORED_MUSIC" type="dir" location="4:cont2:150:0:0:" sourceAccount="uuid:test-udn/0" isPresetable="false">
|
|
<itemName>Albums</itemName>
|
|
</ContentItem>
|
|
</item>
|
|
<item Playable="1">
|
|
<name>Great Song</name>
|
|
<type>track</type>
|
|
<ContentItem source="STORED_MUSIC" type="track" location="5:audio5:part13:3171:5 TRACK" sourceAccount="uuid:test-udn/0" isPresetable="true">
|
|
<itemName>Great Song</itemName>
|
|
</ContentItem>
|
|
</item>
|
|
</items>
|
|
</navigateResponse>`
|
|
|
|
// cannedSourcesResponse is a minimal /sources XML containing one STORED_MUSIC
|
|
// account for use in HandleDeviceLibraryServers tests.
|
|
const cannedSourcesResponse = `<?xml version="1.0" encoding="UTF-8" ?>
|
|
<sources deviceID="AABBCCDDEEFF">
|
|
<sourceItem source="STORED_MUSIC" sourceAccount="uuid:nas-udn/0" status="READY" isLocal="false" multiroomallowed="true">My NAS</sourceItem>
|
|
<sourceItem source="BLUETOOTH" sourceAccount="" status="READY" isLocal="true" multiroomallowed="false">Bluetooth</sourceItem>
|
|
</sources>`
|
|
|
|
// setupSpeakerMock creates an httptest.Server that captures request bodies for
|
|
// paths listed in captureMap, writes canned XML responses from responseMap,
|
|
// and returns HTTP 200 for everything else. Call speaker.Close() when done.
|
|
func setupSpeakerMock(t *testing.T, responseMap map[string]string) (*httptest.Server, map[string]string) {
|
|
t.Helper()
|
|
|
|
captured := map[string]string{}
|
|
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
if body, err := io.ReadAll(r.Body); err == nil {
|
|
captured[r.URL.Path] = string(body)
|
|
}
|
|
|
|
if resp, ok := responseMap[r.URL.Path]; ok {
|
|
w.Header().Set("Content-Type", "application/xml")
|
|
_, _ = w.Write([]byte(resp))
|
|
return
|
|
}
|
|
|
|
w.WriteHeader(http.StatusOK)
|
|
}))
|
|
|
|
return srv, captured
|
|
}
|
|
|
|
// newLibraryTestApp builds a WebApp with a single device whose Client points
|
|
// at the given speaker URL. The device is registered under "lib-device" with
|
|
// a non-empty DeviceID so HandleAddLibraryServer can resolve the Bose ID from
|
|
// the cached DeviceInfo without a /info fallback.
|
|
func newLibraryTestApp(speakerURL string) *WebApp {
|
|
app := NewWebApp()
|
|
|
|
c := client.NewClient(&client.Config{Host: speakerURL})
|
|
info := &models.DeviceInfo{Name: "Library Test Speaker", DeviceID: "AABBCCDDEEFF"}
|
|
conn := webtypes.NewDeviceConnection(c, info)
|
|
conn.SetStatus(&webtypes.DeviceStatus{IsConnected: true, LastActivity: time.Now()})
|
|
app.AddDevice("lib-device", conn)
|
|
|
|
return app
|
|
}
|
|
|
|
// ---- HandlePlayLibrary --------------------------------------------------
|
|
|
|
// TestHandlePlayLibrary_XMLShape verifies that the /select XML the handler
|
|
// posts to the speaker carries source="STORED_MUSIC", the given sourceAccount,
|
|
// location, and type="track".
|
|
func TestHandlePlayLibrary_XMLShape(t *testing.T) {
|
|
speaker, captured := setupSpeakerMock(t, nil)
|
|
defer speaker.Close()
|
|
|
|
app := newLibraryTestApp(speaker.URL)
|
|
|
|
body := strings.NewReader(`{
|
|
"account": "uuid:test-udn/0",
|
|
"location": "5:audio5:part13:3171:5 TRACK",
|
|
"type": "track",
|
|
"name": "Great Song"
|
|
}`)
|
|
|
|
req := httptest.NewRequest("POST", "/api/control/devices/lib-device/library/play", body)
|
|
req.Header.Set("Content-Type", "application/json")
|
|
req = withChiParams(req, map[string]string{"id": "lib-device"})
|
|
w := httptest.NewRecorder()
|
|
|
|
app.HandlePlayLibrary(w, req)
|
|
|
|
if w.Code != http.StatusOK {
|
|
t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String())
|
|
}
|
|
|
|
var resp webtypes.APIResponse
|
|
if err := json.NewDecoder(w.Body).Decode(&resp); err != nil {
|
|
t.Fatalf("decode response: %v", err)
|
|
}
|
|
|
|
if !resp.Success {
|
|
t.Errorf("expected success=true, got false (error=%s)", resp.Error)
|
|
}
|
|
|
|
selectXML := captured["/select"]
|
|
if selectXML == "" {
|
|
t.Fatal("speaker /select was never called")
|
|
}
|
|
|
|
for _, want := range []string{
|
|
`source="STORED_MUSIC"`,
|
|
`sourceAccount="uuid:test-udn/0"`,
|
|
`location="5:audio5:part13:3171:5 TRACK"`,
|
|
`type="track"`,
|
|
} {
|
|
if !strings.Contains(selectXML, want) {
|
|
t.Errorf("select XML should contain %q, got:\n%s", want, selectXML)
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestHandlePlayLibrary_DefaultsTypeToTrack checks that omitting "type" in
|
|
// the request body still sends type="track" in the /select XML.
|
|
func TestHandlePlayLibrary_DefaultsTypeToTrack(t *testing.T) {
|
|
speaker, captured := setupSpeakerMock(t, nil)
|
|
defer speaker.Close()
|
|
|
|
app := newLibraryTestApp(speaker.URL)
|
|
|
|
body := strings.NewReader(`{
|
|
"account": "uuid:test-udn/0",
|
|
"location": "5:audio5:part13:3171:5 TRACK"
|
|
}`)
|
|
|
|
req := httptest.NewRequest("POST", "/api/control/devices/lib-device/library/play", body)
|
|
req.Header.Set("Content-Type", "application/json")
|
|
req = withChiParams(req, map[string]string{"id": "lib-device"})
|
|
w := httptest.NewRecorder()
|
|
|
|
app.HandlePlayLibrary(w, req)
|
|
|
|
if w.Code != http.StatusOK {
|
|
t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String())
|
|
}
|
|
|
|
if want := `type="track"`; !strings.Contains(captured["/select"], want) {
|
|
t.Errorf("select XML should contain %q, got:\n%s", want, captured["/select"])
|
|
}
|
|
}
|
|
|
|
// TestHandlePlayLibrary_MissingFields checks that missing required fields
|
|
// result in 400.
|
|
func TestHandlePlayLibrary_MissingFields(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
body string
|
|
}{
|
|
{"missing account", `{"location":"5:audio5:part13:3171:5 TRACK"}`},
|
|
{"missing location", `{"account":"uuid:test-udn/0"}`},
|
|
{"both missing", `{}`},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
speaker, _ := setupSpeakerMock(t, nil)
|
|
defer speaker.Close()
|
|
|
|
app := newLibraryTestApp(speaker.URL)
|
|
|
|
req := httptest.NewRequest("POST", "/api/control/devices/lib-device/library/play",
|
|
strings.NewReader(tt.body))
|
|
req.Header.Set("Content-Type", "application/json")
|
|
req = withChiParams(req, map[string]string{"id": "lib-device"})
|
|
w := httptest.NewRecorder()
|
|
|
|
app.HandlePlayLibrary(w, req)
|
|
|
|
if w.Code != http.StatusBadRequest {
|
|
t.Errorf("expected 400, got %d", w.Code)
|
|
}
|
|
|
|
var resp webtypes.APIResponse
|
|
if err := json.NewDecoder(w.Body).Decode(&resp); err != nil {
|
|
t.Fatalf("decode response: %v", err)
|
|
}
|
|
|
|
if resp.Success {
|
|
t.Error("expected success=false")
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestHandlePlayLibrary_UnknownDevice checks that requesting an unregistered
|
|
// device returns 404.
|
|
func TestHandlePlayLibrary_UnknownDevice(t *testing.T) {
|
|
app := NewWebApp()
|
|
|
|
body := strings.NewReader(`{"account":"uuid:test-udn/0","location":"5:audio5:part13:3171:5 TRACK"}`)
|
|
req := httptest.NewRequest("POST", "/api/control/devices/ghost/library/play", body)
|
|
req.Header.Set("Content-Type", "application/json")
|
|
req = withChiParams(req, map[string]string{"id": "ghost"})
|
|
w := httptest.NewRecorder()
|
|
|
|
app.HandlePlayLibrary(w, req)
|
|
|
|
if w.Code != http.StatusNotFound {
|
|
t.Errorf("expected 404, got %d", w.Code)
|
|
}
|
|
}
|
|
|
|
// ---- HandleLibraryBrowse -----------------------------------------------
|
|
|
|
// TestHandleLibraryBrowse_RootMapsEntries verifies that a root browse
|
|
// (no location) calls /navigate and maps both a directory and a track
|
|
// entry correctly.
|
|
func TestHandleLibraryBrowse_RootMapsEntries(t *testing.T) {
|
|
speaker, _ := setupSpeakerMock(t, map[string]string{
|
|
"/navigate": cannedNavigateResponse,
|
|
})
|
|
defer speaker.Close()
|
|
|
|
app := newLibraryTestApp(speaker.URL)
|
|
|
|
req := httptest.NewRequest("GET",
|
|
"/api/control/devices/lib-device/library/browse?account=uuid:test-udn/0",
|
|
nil)
|
|
req = withChiParams(req, map[string]string{"id": "lib-device"})
|
|
w := httptest.NewRecorder()
|
|
|
|
app.HandleLibraryBrowse(w, req)
|
|
|
|
if w.Code != http.StatusOK {
|
|
t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String())
|
|
}
|
|
|
|
var resp webtypes.APIResponse
|
|
if err := json.NewDecoder(w.Body).Decode(&resp); err != nil {
|
|
t.Fatalf("decode response: %v", err)
|
|
}
|
|
|
|
if !resp.Success {
|
|
t.Fatalf("expected success=true, error=%s", resp.Error)
|
|
}
|
|
|
|
// Decode the page from the generic Data interface{}.
|
|
pageBytes, err := json.Marshal(resp.Data)
|
|
if err != nil {
|
|
t.Fatalf("re-marshal data: %v", err)
|
|
}
|
|
|
|
var page libraryPage
|
|
if err := json.Unmarshal(pageBytes, &page); err != nil {
|
|
t.Fatalf("unmarshal page: %v", err)
|
|
}
|
|
|
|
if page.TotalItems != 2 {
|
|
t.Errorf("expected totalItems=2, got %d", page.TotalItems)
|
|
}
|
|
|
|
if len(page.Entries) != 2 {
|
|
t.Fatalf("expected 2 entries, got %d", len(page.Entries))
|
|
}
|
|
|
|
dir := page.Entries[0]
|
|
|
|
if dir.Name != "Albums" {
|
|
t.Errorf("dir name: expected 'Albums', got %q", dir.Name)
|
|
}
|
|
|
|
if dir.Type != "dir" {
|
|
t.Errorf("dir type: expected 'dir', got %q", dir.Type)
|
|
}
|
|
|
|
if !dir.IsDir {
|
|
t.Error("dir.IsDir should be true")
|
|
}
|
|
|
|
if dir.Playable {
|
|
t.Error("dir.Playable should be false")
|
|
}
|
|
|
|
if dir.Location != "4:cont2:150:0:0:" {
|
|
t.Errorf("dir location: expected '4:cont2:150:0:0:', got %q", dir.Location)
|
|
}
|
|
|
|
track := page.Entries[1]
|
|
|
|
if track.Name != "Great Song" {
|
|
t.Errorf("track name: expected 'Great Song', got %q", track.Name)
|
|
}
|
|
|
|
if track.Type != "track" {
|
|
t.Errorf("track type: expected 'track', got %q", track.Type)
|
|
}
|
|
|
|
if track.IsDir {
|
|
t.Error("track.IsDir should be false")
|
|
}
|
|
|
|
if !track.Playable {
|
|
t.Error("track.Playable should be true")
|
|
}
|
|
|
|
if track.Location != "5:audio5:part13:3171:5 TRACK" {
|
|
t.Errorf("track location: expected '5:audio5:part13:3171:5 TRACK', got %q", track.Location)
|
|
}
|
|
|
|
if track.SourceAccount != "uuid:test-udn/0" {
|
|
t.Errorf("track sourceAccount: expected 'uuid:test-udn/0', got %q", track.SourceAccount)
|
|
}
|
|
}
|
|
|
|
// TestHandleLibraryBrowse_MissingAccount checks that omitting ?account=
|
|
// returns 400.
|
|
func TestHandleLibraryBrowse_MissingAccount(t *testing.T) {
|
|
speaker, _ := setupSpeakerMock(t, nil)
|
|
defer speaker.Close()
|
|
|
|
app := newLibraryTestApp(speaker.URL)
|
|
|
|
req := httptest.NewRequest("GET",
|
|
"/api/control/devices/lib-device/library/browse",
|
|
nil)
|
|
req = withChiParams(req, map[string]string{"id": "lib-device"})
|
|
w := httptest.NewRecorder()
|
|
|
|
app.HandleLibraryBrowse(w, req)
|
|
|
|
if w.Code != http.StatusBadRequest {
|
|
t.Errorf("expected 400, got %d", w.Code)
|
|
}
|
|
|
|
var resp webtypes.APIResponse
|
|
if err := json.NewDecoder(w.Body).Decode(&resp); err != nil {
|
|
t.Fatalf("decode: %v", err)
|
|
}
|
|
|
|
if resp.Success {
|
|
t.Error("expected success=false")
|
|
}
|
|
}
|
|
|
|
// TestHandleLibraryBrowse_UnknownDevice checks that a missing device returns 404.
|
|
func TestHandleLibraryBrowse_UnknownDevice(t *testing.T) {
|
|
app := NewWebApp()
|
|
|
|
req := httptest.NewRequest("GET",
|
|
"/api/control/devices/ghost/library/browse?account=uuid:x/0",
|
|
nil)
|
|
req = withChiParams(req, map[string]string{"id": "ghost"})
|
|
w := httptest.NewRecorder()
|
|
|
|
app.HandleLibraryBrowse(w, req)
|
|
|
|
if w.Code != http.StatusNotFound {
|
|
t.Errorf("expected 404, got %d", w.Code)
|
|
}
|
|
}
|
|
|
|
// ---- HandleDeviceLibraryServers ----------------------------------------
|
|
|
|
// TestHandleDeviceLibraryServers_FiltersStoredMusic checks that only
|
|
// STORED_MUSIC sources are returned and that the UDN is stripped of the "/0"
|
|
// suffix.
|
|
func TestHandleDeviceLibraryServers_FiltersStoredMusic(t *testing.T) {
|
|
speaker, _ := setupSpeakerMock(t, map[string]string{
|
|
"/sources": cannedSourcesResponse,
|
|
})
|
|
defer speaker.Close()
|
|
|
|
app := newLibraryTestApp(speaker.URL)
|
|
|
|
req := httptest.NewRequest("GET",
|
|
"/api/control/devices/lib-device/library/servers",
|
|
nil)
|
|
req = withChiParams(req, map[string]string{"id": "lib-device"})
|
|
w := httptest.NewRecorder()
|
|
|
|
app.HandleDeviceLibraryServers(w, req)
|
|
|
|
if w.Code != http.StatusOK {
|
|
t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String())
|
|
}
|
|
|
|
var resp webtypes.APIResponse
|
|
if err := json.NewDecoder(w.Body).Decode(&resp); err != nil {
|
|
t.Fatalf("decode: %v", err)
|
|
}
|
|
|
|
if !resp.Success {
|
|
t.Fatalf("expected success=true, error=%s", resp.Error)
|
|
}
|
|
|
|
raw, err := json.Marshal(resp.Data)
|
|
if err != nil {
|
|
t.Fatalf("marshal data: %v", err)
|
|
}
|
|
|
|
var servers []libraryServer
|
|
if err := json.Unmarshal(raw, &servers); err != nil {
|
|
t.Fatalf("unmarshal servers: %v", err)
|
|
}
|
|
|
|
if len(servers) != 1 {
|
|
t.Fatalf("expected 1 STORED_MUSIC server, got %d", len(servers))
|
|
}
|
|
|
|
s := servers[0]
|
|
|
|
if s.UDN != "uuid:nas-udn" {
|
|
t.Errorf("UDN: expected 'uuid:nas-udn', got %q", s.UDN)
|
|
}
|
|
|
|
if s.Name != "My NAS" {
|
|
t.Errorf("Name: expected 'My NAS', got %q", s.Name)
|
|
}
|
|
|
|
if !s.Registered {
|
|
t.Error("Registered should be true")
|
|
}
|
|
|
|
if !s.Ready {
|
|
t.Error("Ready should be true for READY status")
|
|
}
|
|
}
|
|
|
|
// TestHandleDeviceLibraryServers_UnknownDevice checks that an unknown device
|
|
// returns 404.
|
|
func TestHandleDeviceLibraryServers_UnknownDevice(t *testing.T) {
|
|
app := NewWebApp()
|
|
|
|
req := httptest.NewRequest("GET",
|
|
"/api/control/devices/ghost/library/servers",
|
|
nil)
|
|
req = withChiParams(req, map[string]string{"id": "ghost"})
|
|
w := httptest.NewRecorder()
|
|
|
|
app.HandleDeviceLibraryServers(w, req)
|
|
|
|
if w.Code != http.StatusNotFound {
|
|
t.Errorf("expected 404, got %d", w.Code)
|
|
}
|
|
}
|
|
|
|
// ---- HandleAddLibraryServer --------------------------------------------
|
|
|
|
// TestDiscoverLibraryServers_UDNNormalization verifies the mapping logic used
|
|
// inside HandleDiscoverLibraryServers: a MediaServer with a "uuid:"-prefixed
|
|
// UDN must produce a libraryServer DTO with the bare UUID (no prefix), because
|
|
// SoundTouch STORED_MUSIC sourceAccounts use the bare form.
|
|
// This exercises normalizeUDN indirectly through the same code path used in
|
|
// the handler loop; HandleDiscoverLibraryServers itself cannot be called in a
|
|
// unit test because it invokes the real SSDP stack.
|
|
func TestDiscoverLibraryServers_UDNNormalization(t *testing.T) {
|
|
prefixedUDN := "uuid:fa095ecc-e13e-40e7-8e6c-e0286d5bc000"
|
|
want := "fa095ecc-e13e-40e7-8e6c-e0286d5bc000"
|
|
|
|
got := libraryServer{
|
|
UDN: normalizeUDN(prefixedUDN),
|
|
}
|
|
|
|
if got.UDN != want {
|
|
t.Errorf("libraryServer UDN after normalizeUDN = %q, want %q", got.UDN, want)
|
|
}
|
|
}
|
|
|
|
// TestNormalizeUDN verifies that normalizeUDN strips the "uuid:" prefix and
|
|
// is a no-op when the prefix is absent.
|
|
func TestNormalizeUDN(t *testing.T) {
|
|
tests := []struct {
|
|
input string
|
|
want string
|
|
}{
|
|
{"uuid:fa095ecc-e13e-40e7-8e6c-e0286d5bc000", "fa095ecc-e13e-40e7-8e6c-e0286d5bc000"},
|
|
{"fa095ecc-e13e-40e7-8e6c-e0286d5bc000", "fa095ecc-e13e-40e7-8e6c-e0286d5bc000"},
|
|
{"uuid:nas-udn", "nas-udn"},
|
|
{"", ""},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
got := normalizeUDN(tt.input)
|
|
if got != tt.want {
|
|
t.Errorf("normalizeUDN(%q) = %q, want %q", tt.input, got, tt.want)
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestHandleAddLibraryServer_AccountFormat verifies that the speaker receives
|
|
// a setMusicServiceAccount call with the account set to "<bare-uuid>/0", i.e.
|
|
// any "uuid:" prefix is stripped before the "/0" suffix is appended.
|
|
// It also asserts that a POST /notification (sourcesUpdated nudge) is sent
|
|
// after a successful registration and that the response carries refreshed=true.
|
|
func TestHandleAddLibraryServer_AccountFormat(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
requestUDN string
|
|
wantAccount string
|
|
}{
|
|
{
|
|
name: "bare UDN",
|
|
requestUDN: "nas-udn",
|
|
wantAccount: "nas-udn/0",
|
|
},
|
|
{
|
|
name: "uuid-prefixed UDN is normalised",
|
|
requestUDN: "uuid:nas-udn",
|
|
wantAccount: "nas-udn/0",
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
// The client parses the response XML and checks for the success sentinel.
|
|
// Also handle /notification so NotifySourcesUpdated succeeds.
|
|
speaker, captured := setupSpeakerMock(t, map[string]string{
|
|
"/setMusicServiceAccount": `<status>/setMusicServiceAccount</status>`,
|
|
"/notification": `<status>/notification</status>`,
|
|
})
|
|
defer speaker.Close()
|
|
|
|
app := newLibraryTestApp(speaker.URL)
|
|
|
|
bodyStr := `{"udn":"` + tt.requestUDN + `","name":"My NAS"}`
|
|
req := httptest.NewRequest("POST",
|
|
"/api/control/devices/lib-device/library/servers",
|
|
strings.NewReader(bodyStr))
|
|
req.Header.Set("Content-Type", "application/json")
|
|
req = withChiParams(req, map[string]string{"id": "lib-device"})
|
|
w := httptest.NewRecorder()
|
|
|
|
app.HandleAddLibraryServer(w, req)
|
|
|
|
if w.Code != http.StatusOK {
|
|
t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String())
|
|
}
|
|
|
|
var resp webtypes.APIResponse
|
|
if err := json.NewDecoder(w.Body).Decode(&resp); err != nil {
|
|
t.Fatalf("decode: %v", err)
|
|
}
|
|
|
|
if !resp.Success {
|
|
t.Fatalf("expected success=true, error=%s", resp.Error)
|
|
}
|
|
|
|
// The speaker should have been called at the setMusicServiceAccount endpoint.
|
|
setXML := captured["/setMusicServiceAccount"]
|
|
if setXML == "" {
|
|
t.Fatal("speaker /setMusicServiceAccount was never called")
|
|
}
|
|
|
|
if !strings.Contains(setXML, tt.wantAccount) {
|
|
t.Errorf("setMusicServiceAccount XML should contain %q, got:\n%s", tt.wantAccount, setXML)
|
|
}
|
|
|
|
// A sourcesUpdated nudge must have been POSTed to /notification.
|
|
notifXML := captured["/notification"]
|
|
if notifXML == "" {
|
|
t.Fatal("speaker /notification was never called (sourcesUpdated nudge missing)")
|
|
}
|
|
|
|
if !strings.Contains(notifXML, "sourcesUpdated") {
|
|
t.Errorf("/notification body should contain 'sourcesUpdated', got:\n%s", notifXML)
|
|
}
|
|
|
|
// The response must carry the account and refreshed=true.
|
|
data, ok := resp.Data.(map[string]interface{})
|
|
if !ok {
|
|
t.Fatalf("resp.Data is not a map: %T", resp.Data)
|
|
}
|
|
|
|
if got, _ := data["account"].(string); got != tt.wantAccount {
|
|
t.Errorf("response account = %q, want %q", got, tt.wantAccount)
|
|
}
|
|
|
|
if refreshed, _ := data["refreshed"].(bool); !refreshed {
|
|
t.Errorf("response refreshed should be true, got %v", data["refreshed"])
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestHandleAddLibraryServer_NudgeSentAfterAlreadyRegistered verifies that
|
|
// the sourcesUpdated nudge is also fired for the 1024 (already-registered)
|
|
// idempotent path, since the source still needs to be re-registered on the
|
|
// speaker.
|
|
func TestHandleAddLibraryServer_NudgeSentAfterAlreadyRegistered(t *testing.T) {
|
|
alreadyRegistered := `<errors deviceID="AABBCCDDEEFF">
|
|
<error value="1024">1024: Account already exists</error>
|
|
</errors>`
|
|
|
|
var notifCalled int
|
|
|
|
speaker := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
switch r.URL.Path {
|
|
case "/setMusicServiceAccount":
|
|
w.WriteHeader(http.StatusBadRequest)
|
|
w.Header().Set("Content-Type", "application/xml")
|
|
_, _ = w.Write([]byte(alreadyRegistered))
|
|
case "/notification":
|
|
notifCalled++
|
|
w.Header().Set("Content-Type", "application/xml")
|
|
_, _ = w.Write([]byte(`<status>/notification</status>`))
|
|
default:
|
|
w.WriteHeader(http.StatusOK)
|
|
}
|
|
}))
|
|
defer speaker.Close()
|
|
|
|
app := newLibraryTestApp(speaker.URL)
|
|
|
|
body := strings.NewReader(`{"udn":"uuid:nas-udn","name":"My NAS"}`)
|
|
req := httptest.NewRequest("POST",
|
|
"/api/control/devices/lib-device/library/servers",
|
|
body)
|
|
req.Header.Set("Content-Type", "application/json")
|
|
req = withChiParams(req, map[string]string{"id": "lib-device"})
|
|
w := httptest.NewRecorder()
|
|
|
|
app.HandleAddLibraryServer(w, req)
|
|
|
|
if w.Code != http.StatusOK {
|
|
t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String())
|
|
}
|
|
|
|
var resp webtypes.APIResponse
|
|
if err := json.NewDecoder(w.Body).Decode(&resp); err != nil {
|
|
t.Fatalf("decode: %v", err)
|
|
}
|
|
|
|
if !resp.Success {
|
|
t.Errorf("expected success=true when error contains 1024, got error=%s", resp.Error)
|
|
}
|
|
|
|
if notifCalled == 0 {
|
|
t.Error("expected /notification to be called for already-registered path, but it was not")
|
|
}
|
|
}
|
|
|
|
// TestHandleAddLibraryServer_MissingUDN checks that omitting udn returns 400.
|
|
func TestHandleAddLibraryServer_MissingUDN(t *testing.T) {
|
|
speaker, _ := setupSpeakerMock(t, nil)
|
|
defer speaker.Close()
|
|
|
|
app := newLibraryTestApp(speaker.URL)
|
|
|
|
body := strings.NewReader(`{"name":"My NAS"}`)
|
|
req := httptest.NewRequest("POST",
|
|
"/api/control/devices/lib-device/library/servers",
|
|
body)
|
|
req.Header.Set("Content-Type", "application/json")
|
|
req = withChiParams(req, map[string]string{"id": "lib-device"})
|
|
w := httptest.NewRecorder()
|
|
|
|
app.HandleAddLibraryServer(w, req)
|
|
|
|
if w.Code != http.StatusBadRequest {
|
|
t.Errorf("expected 400, got %d", w.Code)
|
|
}
|
|
}
|
|
|
|
// TestHandleAddLibraryServer_AlreadyRegistered verifies that a speaker error
|
|
// response whose text contains "1024" is treated as success by the handler.
|
|
// The client surfaces the ErrorsResponse chardata as the error string; to
|
|
// make it contain "1024" we put that token in the message text and return
|
|
// HTTP 400 so the client takes the error-parse path.
|
|
func TestHandleAddLibraryServer_AlreadyRegistered(t *testing.T) {
|
|
// Return HTTP 400 with an <errors> body so the client wraps it as an
|
|
// ErrorsResponse whose .Error() text contains "1024".
|
|
alreadyRegistered := `<errors deviceID="AABBCCDDEEFF">
|
|
<error value="1024">1024: Account already exists</error>
|
|
</errors>`
|
|
|
|
speaker := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
if r.URL.Path == "/setMusicServiceAccount" {
|
|
w.WriteHeader(http.StatusBadRequest)
|
|
w.Header().Set("Content-Type", "application/xml")
|
|
_, _ = w.Write([]byte(alreadyRegistered))
|
|
return
|
|
}
|
|
w.WriteHeader(http.StatusOK)
|
|
}))
|
|
defer speaker.Close()
|
|
|
|
app := newLibraryTestApp(speaker.URL)
|
|
|
|
body := strings.NewReader(`{"udn":"uuid:nas-udn","name":"My NAS"}`)
|
|
req := httptest.NewRequest("POST",
|
|
"/api/control/devices/lib-device/library/servers",
|
|
body)
|
|
req.Header.Set("Content-Type", "application/json")
|
|
req = withChiParams(req, map[string]string{"id": "lib-device"})
|
|
w := httptest.NewRecorder()
|
|
|
|
app.HandleAddLibraryServer(w, req)
|
|
|
|
// The error text includes "1024" so the handler should absorb it and return 200.
|
|
if w.Code != http.StatusOK {
|
|
t.Errorf("expected 200 when error contains 1024, got %d: %s", w.Code, w.Body.String())
|
|
}
|
|
|
|
var resp webtypes.APIResponse
|
|
if err := json.NewDecoder(w.Body).Decode(&resp); err != nil {
|
|
t.Fatalf("decode: %v", err)
|
|
}
|
|
|
|
if !resp.Success {
|
|
t.Errorf("expected success=true when error contains 1024, got error=%s", resp.Error)
|
|
}
|
|
}
|
|
|
|
// ---- discoverDeviceMediaServers (speaker-side /listMediaServers) --------
|
|
|
|
// cannedListMediaServersResponse is a minimal /listMediaServers XML response
|
|
// with one server, used to exercise the speaker-side discovery merge path
|
|
// that complements our own SSDP sweep.
|
|
const cannedListMediaServersResponse = `<?xml version="1.0" encoding="UTF-8" ?>
|
|
<ListMediaServersResponse>
|
|
<media_server id="uuid:fa095ecc-uuid" ip="198.51.100.10" manufacturer="AVM" model_name="FRITZ!Mediaserver" friendly_name="FRITZ!Mediaserver"/>
|
|
</ListMediaServersResponse>`
|
|
|
|
// newMediaServerTestDevice registers a device backed by speakerURL under id,
|
|
// mirroring newLibraryTestApp's setup but for tests that need more than one
|
|
// device on the same WebApp.
|
|
func newMediaServerTestDevice(app *WebApp, id, speakerURL string) {
|
|
c := client.NewClient(&client.Config{Host: speakerURL})
|
|
info := &models.DeviceInfo{DeviceID: id}
|
|
app.AddDevice(id, webtypes.NewDeviceConnection(c, info))
|
|
}
|
|
|
|
// TestDiscoverDeviceMediaServers_QueriesEveryPairedSpeaker verifies that
|
|
// discoverDeviceMediaServers calls /listMediaServers on every paired device
|
|
// and returns the union of what they report. This is the path that lets
|
|
// discovery see a server the AfterTouch service's own SSDP sweep might miss
|
|
// because it isn't co-located with the speaker's LAN segment.
|
|
func TestDiscoverDeviceMediaServers_QueriesEveryPairedSpeaker(t *testing.T) {
|
|
speakerA, _ := setupSpeakerMock(t, map[string]string{
|
|
"/listMediaServers": cannedListMediaServersResponse,
|
|
})
|
|
defer speakerA.Close()
|
|
|
|
speakerB, _ := setupSpeakerMock(t, map[string]string{
|
|
"/listMediaServers": `<?xml version="1.0" encoding="UTF-8" ?>
|
|
<ListMediaServersResponse>
|
|
<media_server id="uuid:other-udn" ip="198.51.100.20" manufacturer="Synology" model_name="DS220" friendly_name="NAS"/>
|
|
</ListMediaServersResponse>`,
|
|
})
|
|
defer speakerB.Close()
|
|
|
|
app := NewWebApp()
|
|
newMediaServerTestDevice(app, "dev-a", speakerA.URL)
|
|
newMediaServerTestDevice(app, "dev-b", speakerB.URL)
|
|
|
|
got := app.discoverDeviceMediaServers()
|
|
|
|
if len(got) != 2 {
|
|
t.Fatalf("expected 2 servers across both speakers, got %d: %+v", len(got), got)
|
|
}
|
|
|
|
udns := map[string]bool{}
|
|
for _, s := range got {
|
|
udns[normalizeUDN(s.ID)] = true
|
|
}
|
|
|
|
if !udns["fa095ecc-uuid"] || !udns["other-udn"] {
|
|
t.Errorf("expected both UDNs present, got %+v", udns)
|
|
}
|
|
}
|
|
|
|
// TestDiscoverDeviceMediaServers_DedupesAcrossSpeakers verifies that the same
|
|
// server reported by two speakers (e.g. two boxes on the same LAN both seeing
|
|
// one NAS) is returned only once, keyed by normalized UDN, even when the two
|
|
// speakers report the UDN in different forms (with/without "uuid:" prefix).
|
|
func TestDiscoverDeviceMediaServers_DedupesAcrossSpeakers(t *testing.T) {
|
|
speakerA, _ := setupSpeakerMock(t, map[string]string{
|
|
"/listMediaServers": cannedListMediaServersResponse,
|
|
})
|
|
defer speakerA.Close()
|
|
|
|
speakerB, _ := setupSpeakerMock(t, map[string]string{
|
|
"/listMediaServers": `<?xml version="1.0" encoding="UTF-8" ?>
|
|
<ListMediaServersResponse>
|
|
<media_server id="fa095ecc-uuid" ip="198.51.100.10" manufacturer="AVM" model_name="FRITZ!Mediaserver" friendly_name="FRITZ!Mediaserver"/>
|
|
</ListMediaServersResponse>`,
|
|
})
|
|
defer speakerB.Close()
|
|
|
|
app := NewWebApp()
|
|
newMediaServerTestDevice(app, "dev-a", speakerA.URL)
|
|
newMediaServerTestDevice(app, "dev-b", speakerB.URL)
|
|
|
|
got := app.discoverDeviceMediaServers()
|
|
|
|
if len(got) != 1 {
|
|
t.Fatalf("expected 1 deduplicated server, got %d: %+v", len(got), got)
|
|
}
|
|
}
|
|
|
|
// TestDiscoverDeviceMediaServers_UnreachableSpeakerSkippedSilently verifies
|
|
// that a speaker whose /listMediaServers call fails (offline, old firmware
|
|
// without the endpoint) does not prevent results from other, reachable
|
|
// speakers, and does not error the overall call.
|
|
func TestDiscoverDeviceMediaServers_UnreachableSpeakerSkippedSilently(t *testing.T) {
|
|
speakerA, _ := setupSpeakerMock(t, map[string]string{
|
|
"/listMediaServers": cannedListMediaServersResponse,
|
|
})
|
|
defer speakerA.Close()
|
|
|
|
speakerB, _ := setupSpeakerMock(t, nil)
|
|
speakerB.Close() // closed before use: every request to it fails outright.
|
|
|
|
app := NewWebApp()
|
|
newMediaServerTestDevice(app, "dev-a", speakerA.URL)
|
|
newMediaServerTestDevice(app, "dev-b", speakerB.URL)
|
|
|
|
got := app.discoverDeviceMediaServers()
|
|
|
|
if len(got) != 1 {
|
|
t.Fatalf("expected 1 server from the reachable speaker, got %d: %+v", len(got), got)
|
|
}
|
|
|
|
if normalizeUDN(got[0].ID) != "fa095ecc-uuid" {
|
|
t.Errorf("expected the reachable speaker's server, got %+v", got[0])
|
|
}
|
|
}
|