diff --git a/.docsignore b/.docsignore new file mode 100644 index 0000000..d049825 --- /dev/null +++ b/.docsignore @@ -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 diff --git a/cmd/soundtouch-service/main.go b/cmd/soundtouch-service/main.go index cb5ec60..b596e71 100644 --- a/cmd/soundtouch-service/main.go +++ b/cmd/soundtouch-service/main.go @@ -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) diff --git a/cmd/soundtouch-service/testdata/router_routes.txt b/cmd/soundtouch-service/testdata/router_routes.txt index 9396f8b..ea2622e 100644 --- a/cmd/soundtouch-service/testdata/router_routes.txt +++ b/cmd/soundtouch-service/testdata/router_routes.txt @@ -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 diff --git a/pkg/models/group.go b/pkg/models/group.go new file mode 100644 index 0000000..a4aa763 --- /dev/null +++ b/pkg/models/group.go @@ -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"` +} diff --git a/pkg/service/datastore/datastore.go b/pkg/service/datastore/datastore.go index 0bd9774..0f9c4e9 100644 --- a/pkg/service/datastore/datastore.go +++ b/pkg/service/datastore/datastore.go @@ -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 +} diff --git a/pkg/service/handlers/docs_consistency_test.go b/pkg/service/handlers/docs_consistency_test.go index e45428d..0afd7d7 100644 --- a/pkg/service/handlers/docs_consistency_test.go +++ b/pkg/service/handlers/docs_consistency_test.go @@ -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 +} diff --git a/pkg/service/handlers/handlers_marge.go b/pkg/service/handlers/handlers_marge.go index 8be7d5f..12e3587 100644 --- a/pkg/service/handlers/handlers_marge.go +++ b/pkg/service/handlers/handlers_marge.go @@ -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 + ``)) + + return + } + + data, err := xml.Marshal(group) + if err != nil { + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(constants.XMLHeader + ``)) + + return + } + w.WriteHeader(http.StatusOK) - _, _ = w.Write([]byte(constants.XMLHeader + ``)) + _, _ = 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 + `Group deleted successfully`)) +} + // 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. diff --git a/pkg/service/handlers/handlers_marge_test.go b/pkg/service/handlers/handlers_marge_test.go index 826f130..3ed6b20 100644 --- a/pkg/service/handlers/handlers_marge_test.go +++ b/pkg/service/handlers/handlers_marge_test.go @@ -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 := ` + + Living Room Stereo + ` + device1 + ` + + ` + device1 + `LEFT192.168.1.10 + ` + device2 + `RIGHT192.168.1.11 + + 192.168.1.10 +` + + 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), " 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), ` 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 := `Bedroom Stereo` + device1 + `` + 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 + if strings.Contains(string(body), "Bedroom Stereo") { + t.Errorf("Group should be gone after deletion: %s", body) + } + }) +} diff --git a/pkg/service/handlers/main_test.go b/pkg/service/handlers/main_test.go index 0740d8e..4d870b4 100644 --- a/pkg/service/handlers/main_test.go +++ b/pkg/service/handlers/main_test.go @@ -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