mirror of
https://github.com/gesellix/Bose-SoundTouch.git
synced 2026-08-24 14:47:23 +00:00
feat: add group CRUD endpoints (POST add, POST modify, DELETE delete) (#191)
Groups (stereo pairs of ST10 speakers) were read-only — the GET endpoint
always returned an empty <group/>. Add POST /account/{account}/group,
POST /account/{account}/group/{groupId}, and DELETE
/account/{account}/group/{groupId} with datastore persistence, matching
the API shape observed in soundcork. The GET endpoint now reads live
group state from the datastore.
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Sonnet 4.6
parent
ff6edc5383
commit
9412b5ffa0
@@ -0,0 +1,5 @@
|
||||
# Files intentionally not linked in docs/SUMMARY.md.
|
||||
# Paths are relative to the docs/ directory.
|
||||
# Lines starting with # and blank lines are ignored.
|
||||
|
||||
analysis/bose-soundtouch-community-tools.md
|
||||
@@ -804,6 +804,10 @@ func setupRouter(server *handlers.Server) *chi.Mux {
|
||||
r.Get("/group/member", server.HandleMargeDeviceGroupMember)
|
||||
})
|
||||
|
||||
r.Post("/group", server.HandleMargeAddGroup)
|
||||
r.Post("/group/{groupId}", server.HandleMargeModifyGroup)
|
||||
r.Delete("/group/{groupId}", server.HandleMargeDeleteGroup)
|
||||
|
||||
r.Delete("/device/{device}", server.HandleMargeRemoveDevice)
|
||||
})
|
||||
|
||||
@@ -846,6 +850,10 @@ func setupRouter(server *handlers.Server) *chi.Mux {
|
||||
r.Get("/devices/{device}/group/", server.HandleMargeDeviceGroup)
|
||||
r.Get("/devices/{device}/group/server", server.HandleMargeDeviceGroupServer)
|
||||
r.Get("/devices/{device}/group/member", server.HandleMargeDeviceGroupMember)
|
||||
|
||||
r.Post("/group", server.HandleMargeAddGroup)
|
||||
r.Post("/group/{groupId}", server.HandleMargeModifyGroup)
|
||||
r.Delete("/group/{groupId}", server.HandleMargeDeleteGroup)
|
||||
r.Get("/devices/{device}/presets", server.HandleMargePresets)
|
||||
r.Get("/devices/{device}/recents", server.HandleMargeRecents)
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
CONNECT /oauth/* handlers.(*Server).HandleBoseProxy-fm
|
||||
DELETE /accounts/{account}/devices/{device} handlers.(*Server).HandleMargeRemoveDevice-fm
|
||||
DELETE /accounts/{account}/group/{groupId} handlers.(*Server).HandleMargeDeleteGroup-fm
|
||||
DELETE /oauth/* handlers.(*Server).HandleBoseProxy-fm
|
||||
DELETE /setup/devices/{deviceId} handlers.(*Server).HandleRemoveDevice-fm
|
||||
DELETE /setup/dns-discoveries handlers.(*Server).HandleClearDNSDiscoveries-fm
|
||||
@@ -7,6 +8,7 @@ DELETE /setup/interactions/sessions handlers.(
|
||||
DELETE /setup/interactions/sessions/{session} handlers.(*Server).HandleDeleteSession-fm
|
||||
DELETE /setup/parity-mismatches handlers.(*Server).HandleClearParityMismatches-fm
|
||||
DELETE /streaming/account/{account}/device/{device}/preset/{presetNumber} handlers.(*Server).HandleMargeRemovePreset-fm
|
||||
DELETE /streaming/account/{account}/group/{groupId} handlers.(*Server).HandleMargeDeleteGroup-fm
|
||||
GET / handlers.(*Server).HandleRoot-fm
|
||||
GET /accounts/{account}/devices handlers.(*Server).HandleMargeAccountDevices-fm
|
||||
GET /accounts/{account}/devices/{device}/group handlers.(*Server).HandleMargeDeviceGroup-fm
|
||||
@@ -84,6 +86,8 @@ PATCH /oauth/* handlers.(
|
||||
POST /accounts/{account}/devices handlers.(*Server).HandleMargeAddDevice-fm
|
||||
POST /accounts/{account}/devices/{device}/presets/{presetNumber} handlers.(*Server).HandleMargeUpdatePreset-fm
|
||||
POST /accounts/{account}/devices/{device}/recents handlers.(*Server).HandleMargeAddRecent-fm
|
||||
POST /accounts/{account}/group handlers.(*Server).HandleMargeAddGroup-fm
|
||||
POST /accounts/{account}/group/{groupId} handlers.(*Server).HandleMargeModifyGroup-fm
|
||||
POST /bmx/orion/v1/playback/station/{data} handlers.(*Server).HandleOrionPlayback-fm
|
||||
POST /bmx/tunein/v1/report handlers.(*Server).HandleTuneInReport-fm
|
||||
POST /bmx/tunein/v1/token handlers.(*Server).HandleTuneInToken-fm
|
||||
@@ -120,6 +124,8 @@ POST /streaming/account/{account}/device/ handlers.(
|
||||
POST /streaming/account/{account}/device/{device} handlers.(*Server).HandleMargeAddDevice-fm
|
||||
POST /streaming/account/{account}/device/{device}/presets/{presetNumber} handlers.(*Server).HandleMargeUpdatePreset-fm
|
||||
POST /streaming/account/{account}/device/{device}/recent handlers.(*Server).HandleMargeAddRecent-fm
|
||||
POST /streaming/account/{account}/group handlers.(*Server).HandleMargeAddGroup-fm
|
||||
POST /streaming/account/{account}/group/{groupId} handlers.(*Server).HandleMargeModifyGroup-fm
|
||||
POST /streaming/account/{account}/source handlers.(*Server).HandleMargeAddSource-fm
|
||||
POST /streaming/device_setting/account/{account}/device/{device}/device_settings handlers.(*Server).HandleMargeUpdateDeviceSettings-fm
|
||||
POST /streaming/music/musicprovider/{providerID}/is_eligible handlers.(*Server).HandleMusicProviderIsEligible-fm
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
package models
|
||||
|
||||
import "encoding/xml"
|
||||
|
||||
// Group represents a stereo pair of two ST10 SoundTouch speakers.
|
||||
type Group struct {
|
||||
XMLName xml.Name `xml:"group"`
|
||||
ID string `xml:"id,attr,omitempty"`
|
||||
Name string `xml:"name"`
|
||||
MasterDeviceID string `xml:"masterDeviceId"`
|
||||
Roles GroupRoles `xml:"roles"`
|
||||
SenderIPAddress string `xml:"senderIPAddress,omitempty"`
|
||||
}
|
||||
|
||||
// GroupRoles contains the role assignments for devices in a group.
|
||||
type GroupRoles struct {
|
||||
Roles []GroupRole `xml:"groupRole"`
|
||||
}
|
||||
|
||||
// GroupRole describes the role (LEFT or RIGHT) of a single device in a group.
|
||||
type GroupRole struct {
|
||||
DeviceID string `xml:"deviceId"`
|
||||
Role string `xml:"role"`
|
||||
IPAddress string `xml:"ipAddress,omitempty"`
|
||||
}
|
||||
@@ -10,6 +10,7 @@ import (
|
||||
"encoding/xml"
|
||||
"fmt"
|
||||
"io"
|
||||
"math/rand"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
@@ -1906,3 +1907,128 @@ func (ds *DataStore) ClearDNSDiscoveries() error {
|
||||
|
||||
return os.Remove(path)
|
||||
}
|
||||
|
||||
// groupFilePath returns the on-disk path for a group file.
|
||||
func (ds *DataStore) groupFilePath(account, groupID string) string {
|
||||
return filepath.Join(ds.AccountDevicesDir(account), "Group_"+groupID+".xml")
|
||||
}
|
||||
|
||||
// generateGroupID returns a unique 7-digit group ID that has no existing file.
|
||||
func (ds *DataStore) generateGroupID(account string) string {
|
||||
for {
|
||||
id := fmt.Sprintf("%07d", rand.Int63n(10_000_000)) //nolint:gosec
|
||||
if !exists(ds.groupFilePath(account, id)) {
|
||||
return id
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// GetGroupForDevice returns the group containing the given device, or nil if ungrouped.
|
||||
func (ds *DataStore) GetGroupForDevice(account, deviceID string) (*models.Group, error) {
|
||||
ds.fileMutex.RLock()
|
||||
defer ds.fileMutex.RUnlock()
|
||||
|
||||
dir := ds.AccountDevicesDir(account)
|
||||
|
||||
entries, err := os.ReadDir(dir)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
return nil, err
|
||||
}
|
||||
|
||||
for _, e := range entries {
|
||||
if e.IsDir() || !strings.HasPrefix(e.Name(), "Group_") || !strings.HasSuffix(e.Name(), ".xml") {
|
||||
continue
|
||||
}
|
||||
|
||||
data, readErr := os.ReadFile(filepath.Join(dir, e.Name()))
|
||||
if readErr != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
var g models.Group
|
||||
if unmarshalErr := xml.Unmarshal(data, &g); unmarshalErr != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
for _, role := range g.Roles.Roles {
|
||||
if role.DeviceID == deviceID {
|
||||
return &g, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
// AddGroup saves a new group to disk and returns its generated ID.
|
||||
func (ds *DataStore) AddGroup(account string, group *models.Group) (string, error) {
|
||||
ds.fileMutex.Lock()
|
||||
defer ds.fileMutex.Unlock()
|
||||
|
||||
dir := ds.AccountDevicesDir(account)
|
||||
if err := os.MkdirAll(dir, 0755); err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
id := ds.generateGroupID(account)
|
||||
group.ID = id
|
||||
|
||||
data, err := xml.MarshalIndent(group, "", " ")
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
return id, ds.atomicWriteFile(ds.groupFilePath(account, id), append([]byte(xml.Header), data...))
|
||||
}
|
||||
|
||||
// ModifyGroup updates the name of an existing group and returns the updated group.
|
||||
func (ds *DataStore) ModifyGroup(account, groupID, newName string) (*models.Group, error) {
|
||||
ds.fileMutex.Lock()
|
||||
defer ds.fileMutex.Unlock()
|
||||
|
||||
path := ds.groupFilePath(account, groupID)
|
||||
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return nil, fmt.Errorf("group %s not found", groupID)
|
||||
}
|
||||
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var g models.Group
|
||||
if err := xml.Unmarshal(data, &g); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
g.Name = newName
|
||||
|
||||
updated, err := xml.MarshalIndent(&g, "", " ")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if err := ds.atomicWriteFile(path, append([]byte(xml.Header), updated...)); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &g, nil
|
||||
}
|
||||
|
||||
// DeleteGroup removes a group from disk.
|
||||
func (ds *DataStore) DeleteGroup(account, groupID string) error {
|
||||
ds.fileMutex.Lock()
|
||||
defer ds.fileMutex.Unlock()
|
||||
|
||||
err := os.Remove(ds.groupFilePath(account, groupID))
|
||||
if os.IsNotExist(err) {
|
||||
return fmt.Errorf("group %s not found", groupID)
|
||||
}
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"io/fs"
|
||||
"os"
|
||||
"path/filepath"
|
||||
@@ -22,6 +23,8 @@ func TestDocsConsistency(t *testing.T) {
|
||||
|
||||
summaryText := string(summaryContent)
|
||||
|
||||
docsIgnore := readDocsIgnore(t, filepath.Join(projectRoot, ".docsignore"))
|
||||
|
||||
// List of directories to check
|
||||
dirsToCheck := []string{".", "guides", "reference", "analysis"}
|
||||
|
||||
@@ -48,6 +51,13 @@ func TestDocsConsistency(t *testing.T) {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Skip files listed in .docsignore at the project root
|
||||
for _, skip := range docsIgnore {
|
||||
if strings.HasSuffix(path, filepath.FromSlash(skip)) {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// Get relative path from docs/
|
||||
relPath, err := filepath.Rel(docsDir, path)
|
||||
if err != nil {
|
||||
@@ -69,3 +79,31 @@ func TestDocsConsistency(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// readDocsIgnore reads a .docsignore file and returns the non-empty, non-comment lines.
|
||||
// If the file does not exist it returns nil without failing the test.
|
||||
func readDocsIgnore(t *testing.T, path string) []string {
|
||||
t.Helper()
|
||||
f, err := os.Open(path)
|
||||
if os.IsNotExist(err) {
|
||||
return nil
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to read %s: %v", path, err)
|
||||
}
|
||||
defer func() { _ = f.Close() }()
|
||||
|
||||
var patterns []string
|
||||
scanner := bufio.NewScanner(f)
|
||||
for scanner.Scan() {
|
||||
line := strings.TrimSpace(scanner.Text())
|
||||
if line == "" || strings.HasPrefix(line, "#") {
|
||||
continue
|
||||
}
|
||||
patterns = append(patterns, line)
|
||||
}
|
||||
if err := scanner.Err(); err != nil {
|
||||
t.Fatalf("Error reading %s: %v", path, err)
|
||||
}
|
||||
return patterns
|
||||
}
|
||||
|
||||
@@ -679,26 +679,142 @@ func (s *Server) HandleMargeStreamingToken(w http.ResponseWriter, _ *http.Reques
|
||||
_, _ = w.Write(data)
|
||||
}
|
||||
|
||||
// HandleMargeDeviceGroup returns grouping information for a device (empty group by default).
|
||||
func (s *Server) HandleMargeDeviceGroup(w http.ResponseWriter, _ *http.Request) {
|
||||
// Native firmware expects vnd.bose.streaming content type
|
||||
// HandleMargeDeviceGroup returns grouping information for a device.
|
||||
func (s *Server) HandleMargeDeviceGroup(w http.ResponseWriter, r *http.Request) {
|
||||
account := chi.URLParam(r, "account")
|
||||
device := chi.URLParam(r, "device")
|
||||
|
||||
w.Header().Set("Content-Type", "application/vnd.bose.streaming-v1.2+xml")
|
||||
|
||||
group, err := s.ds.GetGroupForDevice(account, device)
|
||||
if err != nil || group == nil {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_, _ = w.Write([]byte(constants.XMLHeader + `<group/>`))
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
data, err := xml.Marshal(group)
|
||||
if err != nil {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_, _ = w.Write([]byte(constants.XMLHeader + `<group/>`))
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_, _ = w.Write([]byte(constants.XMLHeader + `<group/>`))
|
||||
_, _ = w.Write([]byte(constants.XMLHeader))
|
||||
_, _ = w.Write(data)
|
||||
}
|
||||
|
||||
// HandleMargeDeviceGroupServer returns grouping server information (404 by default if not a server).
|
||||
func (s *Server) HandleMargeDeviceGroupServer(w http.ResponseWriter, r *http.Request) {
|
||||
// Not in a group as server
|
||||
http.NotFound(w, r)
|
||||
}
|
||||
|
||||
// HandleMargeDeviceGroupMember returns grouping member information (404 by default if not a member).
|
||||
func (s *Server) HandleMargeDeviceGroupMember(w http.ResponseWriter, r *http.Request) {
|
||||
// Not in a group as member
|
||||
http.NotFound(w, r)
|
||||
}
|
||||
|
||||
// HandleMargeAddGroup creates a new stereo group for an account.
|
||||
func (s *Server) HandleMargeAddGroup(w http.ResponseWriter, r *http.Request) {
|
||||
account := chi.URLParam(r, "account")
|
||||
if !validatePathID(account) {
|
||||
http.Error(w, "Invalid account ID", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
body, err := io.ReadAll(r.Body)
|
||||
if err != nil {
|
||||
http.Error(w, "Failed to read body", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
var group models.Group
|
||||
if err := xml.Unmarshal(body, &group); err != nil {
|
||||
http.Error(w, "Invalid XML", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
id, err := s.ds.AddGroup(account, &group)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
data, err := xml.Marshal(&group)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/vnd.bose.streaming-v1.2+xml")
|
||||
w.Header().Set("Location", s.serverURL+"/account/"+account+"/group/"+id)
|
||||
w.WriteHeader(http.StatusCreated)
|
||||
_, _ = w.Write([]byte(constants.XMLHeader))
|
||||
_, _ = w.Write(data)
|
||||
}
|
||||
|
||||
// HandleMargeModifyGroup updates the name of an existing stereo group.
|
||||
func (s *Server) HandleMargeModifyGroup(w http.ResponseWriter, r *http.Request) {
|
||||
account := chi.URLParam(r, "account")
|
||||
groupID := chi.URLParam(r, "groupId")
|
||||
|
||||
if !validatePathID(account) || !validatePathID(groupID) {
|
||||
http.Error(w, "Invalid ID", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
body, err := io.ReadAll(r.Body)
|
||||
if err != nil {
|
||||
http.Error(w, "Failed to read body", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
var req models.Group
|
||||
if err := xml.Unmarshal(body, &req); err != nil {
|
||||
http.Error(w, "Invalid XML", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
updated, err := s.ds.ModifyGroup(account, groupID, req.Name)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
data, err := xml.Marshal(updated)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/vnd.bose.streaming-v1.2+xml")
|
||||
_, _ = w.Write([]byte(constants.XMLHeader))
|
||||
_, _ = w.Write(data)
|
||||
}
|
||||
|
||||
// HandleMargeDeleteGroup removes a stereo group.
|
||||
func (s *Server) HandleMargeDeleteGroup(w http.ResponseWriter, r *http.Request) {
|
||||
account := chi.URLParam(r, "account")
|
||||
groupID := chi.URLParam(r, "groupId")
|
||||
|
||||
if !validatePathID(account) || !validatePathID(groupID) {
|
||||
http.Error(w, "Invalid ID", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
if err := s.ds.DeleteGroup(account, groupID); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/vnd.bose.streaming-v1.2+xml")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_, _ = w.Write([]byte(constants.XMLHeader + `<status>Group deleted successfully</status>`))
|
||||
}
|
||||
|
||||
// HandleMusicProviderIsEligible returns the music provider eligibility.
|
||||
func (s *Server) HandleMusicProviderIsEligible(w http.ResponseWriter, _ *http.Request) {
|
||||
// For now, we return false as seen in the interaction sample.
|
||||
|
||||
@@ -1563,3 +1563,177 @@ func TestMargeAdvancedFeatures(t *testing.T) {
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestMargeGroupCRUD(t *testing.T) {
|
||||
tempDir, err := os.MkdirTemp("", "st-group-test-*")
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create temp dir: %v", err)
|
||||
}
|
||||
|
||||
defer func() { _ = os.RemoveAll(tempDir) }()
|
||||
|
||||
ds := datastore.NewDataStore(tempDir)
|
||||
r, _ := setupRouter("http://localhost:8001", ds)
|
||||
ts := httptest.NewServer(r)
|
||||
defer ts.Close()
|
||||
|
||||
account := "ACC001"
|
||||
device1 := "AABBCCDDEEFF"
|
||||
device2 := "112233445566"
|
||||
|
||||
groupXML := `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<group>
|
||||
<name>Living Room Stereo</name>
|
||||
<masterDeviceId>` + device1 + `</masterDeviceId>
|
||||
<roles>
|
||||
<groupRole><deviceId>` + device1 + `</deviceId><role>LEFT</role><ipAddress>192.168.1.10</ipAddress></groupRole>
|
||||
<groupRole><deviceId>` + device2 + `</deviceId><role>RIGHT</role><ipAddress>192.168.1.11</ipAddress></groupRole>
|
||||
</roles>
|
||||
<senderIPAddress>192.168.1.10</senderIPAddress>
|
||||
</group>`
|
||||
|
||||
var groupID string
|
||||
|
||||
t.Run("GET device group returns empty group before creation", func(t *testing.T) {
|
||||
res, err := http.Get(ts.URL + "/marge/streaming/account/" + account + "/device/" + device1 + "/group")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer func() { _ = res.Body.Close() }()
|
||||
|
||||
if res.StatusCode != http.StatusOK {
|
||||
t.Errorf("Expected 200, got %d", res.StatusCode)
|
||||
}
|
||||
body, _ := io.ReadAll(res.Body)
|
||||
if !strings.Contains(string(body), "<group") {
|
||||
t.Errorf("Expected <group> element, got: %s", body)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("POST group creates a new group and returns 201 with ID", func(t *testing.T) {
|
||||
res, err := http.Post(
|
||||
ts.URL+"/marge/streaming/account/"+account+"/group",
|
||||
"application/xml",
|
||||
bytes.NewBufferString(groupXML),
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer func() { _ = res.Body.Close() }()
|
||||
|
||||
if res.StatusCode != http.StatusCreated {
|
||||
body, _ := io.ReadAll(res.Body)
|
||||
t.Fatalf("Expected 201 Created, got %d: %s", res.StatusCode, body)
|
||||
}
|
||||
|
||||
body, _ := io.ReadAll(res.Body)
|
||||
if !strings.Contains(string(body), `<group `) {
|
||||
t.Errorf("Response missing <group> with id attr: %s", body)
|
||||
}
|
||||
|
||||
// Parse out the group ID from the response XML
|
||||
type groupResp struct {
|
||||
ID string `xml:"id,attr"`
|
||||
}
|
||||
var gr groupResp
|
||||
if err := xml.Unmarshal(body, &gr); err != nil {
|
||||
t.Fatalf("Failed to unmarshal group response: %v", err)
|
||||
}
|
||||
if gr.ID == "" {
|
||||
t.Fatalf("Response group has no ID: %s", body)
|
||||
}
|
||||
groupID = gr.ID
|
||||
})
|
||||
|
||||
t.Run("GET device group returns the group after creation", func(t *testing.T) {
|
||||
res, err := http.Get(ts.URL + "/marge/streaming/account/" + account + "/device/" + device1 + "/group")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer func() { _ = res.Body.Close() }()
|
||||
|
||||
if res.StatusCode != http.StatusOK {
|
||||
t.Errorf("Expected 200, got %d", res.StatusCode)
|
||||
}
|
||||
body, _ := io.ReadAll(res.Body)
|
||||
if !strings.Contains(string(body), "Living Room Stereo") {
|
||||
t.Errorf("Expected group name in response: %s", body)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("POST group/{groupId} renames the group", func(t *testing.T) {
|
||||
if groupID == "" {
|
||||
t.Skip("No group ID from prior subtest")
|
||||
}
|
||||
modXML := `<group><name>Bedroom Stereo</name><masterDeviceId>` + device1 + `</masterDeviceId></group>`
|
||||
req, _ := http.NewRequest(http.MethodPost,
|
||||
ts.URL+"/marge/streaming/account/"+account+"/group/"+groupID,
|
||||
bytes.NewBufferString(modXML),
|
||||
)
|
||||
req.Header.Set("Content-Type", "application/xml")
|
||||
res, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer func() { _ = res.Body.Close() }()
|
||||
|
||||
if res.StatusCode != http.StatusOK {
|
||||
body, _ := io.ReadAll(res.Body)
|
||||
t.Fatalf("Expected 200, got %d: %s", res.StatusCode, body)
|
||||
}
|
||||
body, _ := io.ReadAll(res.Body)
|
||||
if !strings.Contains(string(body), "Bedroom Stereo") {
|
||||
t.Errorf("Expected updated name in response: %s", body)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("DELETE group/{groupId} removes the group", func(t *testing.T) {
|
||||
if groupID == "" {
|
||||
t.Skip("No group ID from prior subtest")
|
||||
}
|
||||
req, _ := http.NewRequest(http.MethodDelete,
|
||||
ts.URL+"/marge/streaming/account/"+account+"/group/"+groupID,
|
||||
nil,
|
||||
)
|
||||
res, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer func() { _ = res.Body.Close() }()
|
||||
|
||||
if res.StatusCode != http.StatusOK {
|
||||
body, _ := io.ReadAll(res.Body)
|
||||
t.Fatalf("Expected 200, got %d: %s", res.StatusCode, body)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("DELETE group/{groupId} returns 404 for missing group", func(t *testing.T) {
|
||||
req, _ := http.NewRequest(http.MethodDelete,
|
||||
ts.URL+"/marge/streaming/account/"+account+"/group/9999999",
|
||||
nil,
|
||||
)
|
||||
res, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer func() { _ = res.Body.Close() }()
|
||||
|
||||
if res.StatusCode != http.StatusNotFound {
|
||||
t.Errorf("Expected 404, got %d", res.StatusCode)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("GET device group is empty after deletion", func(t *testing.T) {
|
||||
res, err := http.Get(ts.URL + "/marge/streaming/account/" + account + "/device/" + device1 + "/group")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer func() { _ = res.Body.Close() }()
|
||||
|
||||
body, _ := io.ReadAll(res.Body)
|
||||
// Should be back to empty <group/>
|
||||
if strings.Contains(string(body), "Bedroom Stereo") {
|
||||
t.Errorf("Group should be gone after deletion: %s", body)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -58,6 +58,9 @@ func setupRouter(targetURL string, ds *datastore.DataStore) (*chi.Mux, *Server)
|
||||
r.Get("/account/{account}/device/{device}/group/", server.HandleMargeDeviceGroup)
|
||||
r.Get("/account/{account}/device/{device}/group/server", server.HandleMargeDeviceGroupServer)
|
||||
r.Get("/account/{account}/device/{device}/group/member", server.HandleMargeDeviceGroupMember)
|
||||
r.Post("/account/{account}/group", server.HandleMargeAddGroup)
|
||||
r.Post("/account/{account}/group/{groupId}", server.HandleMargeModifyGroup)
|
||||
r.Delete("/account/{account}/group/{groupId}", server.HandleMargeDeleteGroup)
|
||||
r.Post("/device_setting/account/{account}/device/{device}/device_settings", server.HandleMargeUpdateDeviceSettings)
|
||||
r.Get("/account/{account}/emailaddress", server.HandleMargeGetEmailAddress)
|
||||
r.Get("/account/{account}/full", server.HandleMargeAccountFull)
|
||||
@@ -87,6 +90,9 @@ func setupRouter(targetURL string, ds *datastore.DataStore) (*chi.Mux, *Server)
|
||||
r.Get("/{account}/devices/{device}/group/", server.HandleMargeDeviceGroup)
|
||||
r.Get("/{account}/devices/{device}/group/server", server.HandleMargeDeviceGroupServer)
|
||||
r.Get("/{account}/devices/{device}/group/member", server.HandleMargeDeviceGroupMember)
|
||||
r.Post("/{account}/group", server.HandleMargeAddGroup)
|
||||
r.Post("/{account}/group/{groupId}", server.HandleMargeModifyGroup)
|
||||
r.Delete("/{account}/group/{groupId}", server.HandleMargeDeleteGroup)
|
||||
}
|
||||
|
||||
// Setup Marge for tests
|
||||
|
||||
Reference in New Issue
Block a user