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 = `
2
-
Albums
dir
Albums
-
Great Song
track
Great Song
`
// cannedSourcesResponse is a minimal /sources XML containing one STORED_MUSIC
// account for use in HandleDeviceLibraryServers tests.
const cannedSourcesResponse = `
My NAS
Bluetooth
`
// 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 "/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": `/setMusicServiceAccount`,
"/notification": `/notification`,
})
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 := `
1024: Account already exists
`
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(`/notification`))
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 body so the client wraps it as an
// ErrorsResponse whose .Error() text contains "1024".
alreadyRegistered := `
1024: Account already exists
`
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)
}
}