Repository: amp-labs/connectors
Pull request #202 (2024-04-10 08:52:47)

feat: Deep connector MS Dynamics365 Sales (Write)
Closes https://github.com/amp-labs/connectors/issues/201


Patch:
diff --git a/common/types.go b/common/types.go
index ed88ac47..731f3b4e 100644
--- a/common/types.go
+++ b/common/types.go
@@ -58,6 +58,9 @@ var (
 	// ErrMissingObjects is returned when no objects are provided in the request.
 	ErrMissingObjects = errors.New("no objects provided")
 
+	// ErrMissingRecordID is returned when resource id is missing in the request.
+	ErrMissingRecordID = errors.New("no object ID provided")
+
 	// ErrInvalidPathJoin is returned when the path join is invalid.
 	ErrInvalidPathJoin = errors.New("invalid path join")
 
@@ -101,6 +104,15 @@ type WriteParams struct {
 	RecordData any // required
 }
 
+// DeleteParams defines how we are deleting data in SaaS API.
+type DeleteParams struct {
+	// The name of the object we are deleting, e.g. "Account"
+	ObjectName string // required
+
+	// The external ID of the object instance we are removing.
+	RecordId string // required
+}
+
 // NextPageToken is an opaque token that can be used to get the next page of results.
 // Callers are encouraged to treat this as an opaque string, and not attempt to parse it.
 // And although each provider will be different, callers should expect that this token
@@ -145,6 +157,12 @@ type WriteResult struct {
 	Data map[string]interface{} `json:"data,omitempty"` // optional
 }
 
+// DeleteResult is what's returned from deleting data via the Delete call.
+type DeleteResult struct {
+	// Success is true if deletion succeeded.
+	Success bool `json:"success"`
+}
+
 // NewHTTPStatusError creates a new error with the given HTTP status.
 func NewHTTPStatusError(status int, err error) error {
 	if status < 1 || status > 599 {
diff --git a/connectors.go b/connectors.go
index 38b54615..48e8550d 100644
--- a/connectors.go
+++ b/connectors.go
@@ -54,6 +54,13 @@ type WriteConnector interface {
 	Write(ctx context.Context, params WriteParams) (*WriteResult, error)
 }
 
+// DeleteConnector is an interface that extends the Connector interface with delete capabilities.
+type DeleteConnector interface {
+	Connector
+
+	Delete(ctx context.Context, params DeleteParams) (*DeleteResult, error)
+}
+
 // ObjectMetadataConnector is an interface that extends the Connector interface with
 // the ability to list object metadata.
 type ObjectMetadataConnector interface {
@@ -80,8 +87,8 @@ var Salesforce API[*salesforce.Connector, salesforce.Option] = salesforce.NewCon
 // Hubspot is an API that returns a new Hubspot Connector.
 var Hubspot API[*hubspot.Connector, hubspot.Option] = hubspot.NewConnector //nolint:gochecknoglobals
 
-// MSDynamicsSales is an API that returns a new MS Dynamics 365 Sales Connector.
-var MSDynamicsSales API[*microsoftdynamicscrm.Connector, microsoftdynamicscrm.Option] = microsoftdynamicscrm.NewConnector //nolint:gochecknoglobals,lll
+// MSDynamicsCRM is an API that returns a new MS Dynamics 365 Sales Connector.
+var MSDynamicsCRM API[*microsoftdynamicscrm.Connector, microsoftdynamicscrm.Option] = microsoftdynamicscrm.NewConnector //nolint:gochecknoglobals,lll
 
 // Mock is an API that returns a new Mock Connector.
 var Mock API[*mock.Connector, mock.Option] = mock.NewConnector //nolint:gochecknoglobals
@@ -93,8 +100,10 @@ var Outreach API[*outreach.Connector, outreach.Option] = outreach.NewConnector /
 type (
 	ReadParams               = common.ReadParams
 	WriteParams              = common.WriteParams
+	DeleteParams             = common.DeleteParams
 	ReadResult               = common.ReadResult
 	WriteResult              = common.WriteResult
+	DeleteResult             = common.DeleteResult
 	ListObjectMetadataResult = common.ListObjectMetadataResult
 
 	ErrorWithStatus = common.HTTPStatusError
diff --git a/microsoftdynamicscrm/delete.go b/microsoftdynamicscrm/delete.go
new file mode 100644
index 00000000..ec5f7ae3
--- /dev/null
+++ b/microsoftdynamicscrm/delete.go
@@ -0,0 +1,31 @@
+package microsoftdynamicscrm
+
+import (
+	"context"
+	"fmt"
+
+	"github.com/amp-labs/connectors/common"
+)
+
+func (c *Connector) Delete(ctx context.Context, config common.DeleteParams) (*common.DeleteResult, error) {
+	if len(config.ObjectName) == 0 {
+		return nil, common.ErrMissingObjects
+	}
+
+	if len(config.RecordId) == 0 {
+		return nil, common.ErrMissingRecordID
+	}
+
+	// resource id must be enclosed in brackets
+	url := c.getURL(fmt.Sprintf("%v(%v)", config.ObjectName, config.RecordId))
+
+	// 204 NoContent is expected
+	_, err := c.delete(ctx, url)
+	if err != nil {
+		return nil, err
+	}
+
+	return &common.DeleteResult{
+		Success: true,
+	}, nil
+}
diff --git a/microsoftdynamicscrm/errors.go b/microsoftdynamicscrm/errors.go
index bdfaca42..31e5be23 100644
--- a/microsoftdynamicscrm/errors.go
+++ b/microsoftdynamicscrm/errors.go
@@ -10,7 +10,7 @@ import (
 )
 
 func (*Connector) interpretJSONError(res *http.Response, body []byte) error { //nolint:cyclop
-	apiError := &SalesResponseError{}
+	apiError := &CRMResponseError{}
 	if err := json.Unmarshal(body, &apiError); err != nil {
 		return fmt.Errorf("interpretJSONError: %w %w", interpreter.ErrUnmarshal, err)
 	}
@@ -41,23 +41,23 @@ func (*Connector) interpretJSONError(res *http.Response, body []byte) error { //
 	}
 }
 
-type SalesResponseError struct {
-	Err SalesError `json:"error"`
+type CRMResponseError struct {
+	Err CRMError `json:"error"`
 }
 
-type SalesError struct {
+type CRMError struct {
 	Code    string `json:"code"`
 	Message string `json:"message"`
-	*EnhancedSalesError
+	*EnhancedCRMError
 }
 
-type EnhancedSalesError struct {
+type EnhancedCRMError struct {
 	HelpLink     string `json:"@Microsoft.PowerApps.CDS.HelpLink"`           // nolint:tagliatelle
 	TraceText    string `json:"@Microsoft.PowerApps.CDS.TraceText"`          // nolint:tagliatelle
 	InnerMessage string `json:"@Microsoft.PowerApps.CDS.InnerError.Message"` // nolint:tagliatelle
 }
 
-func createError(base error, response *SalesResponseError) error {
+func createError(base error, response *CRMResponseError) error {
 	if len(response.Err.Message) > 0 {
 		return fmt.Errorf("%w: %s", base, response.Err.Message)
 	}
diff --git a/microsoftdynamicscrm/methods.go b/microsoftdynamicscrm/methods.go
index 72d1889d..1ee40c16 100644
--- a/microsoftdynamicscrm/methods.go
+++ b/microsoftdynamicscrm/methods.go
@@ -20,6 +20,33 @@ func (c *Connector) get(ctx context.Context,
 	return rsp, nil
 }
 
+func (c *Connector) post(ctx context.Context, url string, body any) (*common.JSONHTTPResponse, error) {
+	rsp, err := c.Client.Post(ctx, url, body)
+	if err = handleError(err); err != nil {
+		return nil, err
+	}
+
+	return rsp, nil
+}
+
+func (c *Connector) patch(ctx context.Context, url string, body any) (*common.JSONHTTPResponse, error) {
+	rsp, err := c.Client.Patch(ctx, url, body)
+	if err = handleError(err); err != nil {
+		return nil, err
+	}
+
+	return rsp, nil
+}
+
+func (c *Connector) delete(ctx context.Context, url string) (*common.JSONHTTPResponse, error) {
+	rsp, err := c.Client.Delete(ctx, url)
+	if err = handleError(err); err != nil {
+		return nil, err
+	}
+
+	return rsp, nil
+}
+
 func handleError(err error) error {
 	if err == nil {
 		return nil
diff --git a/microsoftdynamicscrm/params.go b/microsoftdynamicscrm/params.go
index 7d0ab030..a0bf7ea7 100644
--- a/microsoftdynamicscrm/params.go
+++ b/microsoftdynamicscrm/params.go
@@ -18,7 +18,7 @@ const (
 // Option is a function which mutates the connector configuration.
 type Option func(params *parameters)
 
-// parameters MS Sales supports auth client, workspace, etc. by delegation.
+// parameters MS CRM supports auth client, workspace, etc. by delegation.
 type parameters struct {
 	paramsbuilder.Client
 	paramsbuilder.Workspace
diff --git a/microsoftdynamicscrm/write.go b/microsoftdynamicscrm/write.go
index dc012986..9d4723c4 100644
--- a/microsoftdynamicscrm/write.go
+++ b/microsoftdynamicscrm/write.go
@@ -2,10 +2,42 @@ package microsoftdynamicscrm
 
 import (
 	"context"
+	"fmt"
 
 	"github.com/amp-labs/connectors/common"
 )
 
+type writeMethod func(context.Context, string, any) (*common.JSONHTTPResponse, error)
+
+// Write data will be used to Create or Update entity.
+// Return: common.WriteResult, where only the Success flag will be set.
 func (c *Connector) Write(ctx context.Context, config common.WriteParams) (*common.WriteResult, error) {
-	panic("not implemented")
+	if len(config.ObjectName) == 0 {
+		return nil, common.ErrMissingObjects
+	}
+
+	url := c.getURL(config.ObjectName)
+
+	var write writeMethod
+	if len(config.RecordId) == 0 {
+		// writing to the entity without id means
+		// that we are extending 'List' resource and creating a new record
+		write = c.post
+	} else {
+		// only patch is supported for updating 'Single' resource
+		write = c.patch
+		// resource id is passed via brackets in OData spec
+		url = fmt.Sprintf("%s(%s)", url, config.RecordId)
+	}
+
+	// Neither Post nor Patch return any response data on successful completion
+	// Both complete with 204 NoContent
+	_, err := write(ctx, url, config.RecordData)
+	if err != nil {
+		return nil, err
+	}
+
+	return &common.WriteResult{
+		Success: true,
+	}, nil
 }
diff --git a/providers/catalog.go b/providers/catalog.go
index cbb6bd74..dd8f2ad5 100644
--- a/providers/catalog.go
+++ b/providers/catalog.go
@@ -598,28 +598,30 @@ var catalog = CatalogType{ // nolint:gochecknoglobals
 	},
 
 	// DocuSign configuration
-	DocuSign: {
-		AuthType: Oauth2,
-		BaseURL:  "https://{{.workspace}}.docusign.net",
-		OauthOpts: OauthOpts{
-			AuthURL:                   "https://account.docusign.com/oauth/auth",
-			TokenURL:                  "https://account.docusign.com/oauth/token",
-			ExplicitScopesRequired:    true,
-			ExplicitWorkspaceRequired: true,
-		},
-		Support: Support{
-			BulkWrite: BulkWriteSupport{
-				Insert: false,
-				Update: false,
-				Upsert: false,
-				Delete: false,
-			},
-			Proxy:     false,
-			Read:      false,
-			Subscribe: false,
-			Write:     false,
-		},
-	},
+	// TODO: we don't have a good way to get the server string yet. Need to make API call to /oauth/userinfo.
+	// Leaving this connector commented out until that is unblocked.
+	//DocuSign: {
+	//	AuthType: Oauth2,
+	//	BaseURL:  "https://{{.server}}.docusign.net",
+	//	OauthOpts: OauthOpts{
+	//		AuthURL:                   "https://account.docusign.com/oauth/auth",
+	//		TokenURL:                  "https://account.docusign.com/oauth/token",
+	//		ExplicitScopesRequired:    true,
+	//		ExplicitWorkspaceRequired: false,
+	//	},
+	//	Support: Support{
+	//		BulkWrite: BulkWriteSupport{
+	//			Insert: false,
+	//			Update: false,
+	//			Upsert: false,
+	//			Delete: false,
+	//		},
+	//		Proxy:     false,
+	//		Read:      false,
+	//		Subscribe: false,
+	//		Write:     false,
+	//	},
+	// },
 
 	// DocuSign Developer configuration
 	DocuSignDeveloper: {
diff --git a/utils/credentials.go b/utils/credentials.go
index 3f90fb46..8f4f5eb4 100644
--- a/utils/credentials.go
+++ b/utils/credentials.go
@@ -90,9 +90,9 @@ func HubspotOAuthConfigFromRegistry(registry CredentialsRegistry) *oauth2.Config
 	return cfg
 }
 
-var MSDynamics365SalesWorkspace = "org5bd08fdd" //nolint:gochecknoglobals
+var MSDynamics365CRMWorkspace = "org5bd08fdd" //nolint:gochecknoglobals
 
-func MSDynamics365SalesConfigFromRegistry(registry CredentialsRegistry) *oauth2.Config {
+func MSDynamics365CRMConfigFromRegistry(registry CredentialsRegistry) *oauth2.Config {
 	clientId := registry.MustString(ClientId)
 	clientSecret := registry.MustString(ClientSecret)
 
@@ -106,7 +106,7 @@ func MSDynamics365SalesConfigFromRegistry(registry CredentialsRegistry) *oauth2.
 			AuthStyle: oauth2.AuthStyleInParams,
 		},
 		Scopes: []string{
-			fmt.Sprintf("https://%v.crm.dynamics.com/user_impersonation", MSDynamics365SalesWorkspace),
+			fmt.Sprintf("https://%v.crm.dynamics.com/user_impersonation", MSDynamics365CRMWorkspace),
 			"offline_access",
 		},
 	}
@@ -114,7 +114,7 @@ func MSDynamics365SalesConfigFromRegistry(registry CredentialsRegistry) *oauth2.
 	return cfg
 }
 
-func MSDynamics365SalesTokenFromRegistry(registry CredentialsRegistry) *oauth2.Token {
+func MSDynamics365CRMTokenFromRegistry(registry CredentialsRegistry) *oauth2.Token {
 	accessToken := registry.MustString(AccessToken)
 	refreshToken := registry.MustString(RefreshToken)
 


Test patch:
diff --git a/microsoftdynamicscrm/delete_test.go b/microsoftdynamicscrm/delete_test.go
new file mode 100644
index 00000000..8edb9b38
--- /dev/null
+++ b/microsoftdynamicscrm/delete_test.go
@@ -0,0 +1,125 @@
+package microsoftdynamicscrm
+
+import (
+	"context"
+	"errors"
+	"net/http"
+	"net/http/httptest"
+	"reflect"
+	"strings"
+	"testing"
+
+	"github.com/amp-labs/connectors/common"
+	"github.com/amp-labs/connectors/common/interpreter"
+	"github.com/amp-labs/connectors/test/utils/mockutils"
+	"github.com/go-test/deep"
+)
+
+func TestDelete(t *testing.T) { // nolint:funlen,cyclop
+	t.Parallel()
+
+	tests := []struct {
+		name         string
+		input        common.DeleteParams
+		server       *httptest.Server
+		connector    Connector
+		expected     *common.DeleteResult
+		expectedErrs []error
+	}{
+		{
+			name: "Write object must be included",
+			server: httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+				w.WriteHeader(http.StatusTeapot)
+			})),
+			expectedErrs: []error{common.ErrMissingObjects},
+		},
+		{
+			name:  "Write object and its ID must be included",
+			input: common.DeleteParams{ObjectName: "fax"},
+			server: httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+				w.WriteHeader(http.StatusTeapot)
+			})),
+			expectedErrs: []error{common.ErrMissingRecordID},
+		},
+		{
+			name:  "Mime response header expected",
+			input: common.DeleteParams{ObjectName: "fax", RecordId: "dd2f7870-3fe8-ee11-a204-0022481f9e3c"},
+			server: httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+				w.WriteHeader(http.StatusTeapot)
+			})),
+			expectedErrs: []error{interpreter.ErrMissingContentType},
+		},
+		{
+			name:  "Correct error message is understood from JSON response",
+			input: common.DeleteParams{ObjectName: "fax", RecordId: "dd2f7870-3fe8-ee11-a204-0022481f9e3c"},
+			server: httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+				w.Header().Set("Content-Type", "application/json")
+				w.WriteHeader(http.StatusBadRequest)
+				writeBody(w, `{
+					"error": {
+						"code": "0x80060888",
+						"message":"Resource not found for the segment 'conacs'."
+					}
+				}`)
+			})),
+			expectedErrs: []error{
+				common.ErrBadRequest,
+				errors.New("Resource not found for the segment 'conacs'"), // nolint:goerr113
+			},
+		},
+		{
+			name:  "Successful delete",
+			input: common.DeleteParams{ObjectName: "fax", RecordId: "dd2f7870-3fe8-ee11-a204-0022481f9e3c"},
+			server: httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+				w.Header().Set("Content-Type", "application/json")
+				mockutils.RespondWithMethodExpectation(w, r, "DELETE")
+			})),
+			expected:     &common.DeleteResult{Success: true},
+			expectedErrs: nil,
+		},
+	}
+
+	for _, tt := range tests { // nolint:dupl
+		// nolint:varnamelen
+		tt := tt // rebind, omit loop side effects for parallel goroutine
+		t.Run(tt.name, func(t *testing.T) {
+			t.Parallel()
+
+			defer tt.server.Close()
+
+			ctx := context.Background()
+
+			connector, err := NewConnector(
+				WithAuthenticatedClient(http.DefaultClient),
+				WithWorkspace("test-workspace"),
+			)
+			if err != nil {
+				t.Fatalf("%s: error in test while constructin connector %v", tt.name, err)
+			}
+
+			// for testing we want to redirect calls to our server
+			connector.setBaseURL(tt.server.URL)
+
+			// start of tests
+			output, err := connector.Delete(ctx, tt.input)
+			if len(tt.expectedErrs) == 0 && err != nil {
+				t.Fatalf("%s: expected no errors, got: (%v)", tt.name, err)
+			}
+
+			if len(tt.expectedErrs) != 0 && err == nil {
+				t.Fatalf("%s: expected errors (%v), but got nothing", tt.name, tt.expectedErrs)
+			}
+
+			for _, expectedErr := range tt.expectedErrs {
+				if !errors.Is(err, expectedErr) && !strings.Contains(err.Error(), expectedErr.Error()) {
+					t.Fatalf("%s: expected Error: (%v), got: (%v)", tt.name, expectedErr, err)
+				}
+			}
+
+			if !reflect.DeepEqual(output, tt.expected) {
+				diff := deep.Equal(output, tt.expected)
+				t.Fatalf("%s:, \nexpected: (%v), \ngot: (%v), \ndiff: (%v)", tt.name, tt.expected, output, diff)
+			}
+		})
+	}
+}
diff --git a/microsoftdynamicscrm/write_test.go b/microsoftdynamicscrm/write_test.go
new file mode 100644
index 00000000..07cb5d4d
--- /dev/null
+++ b/microsoftdynamicscrm/write_test.go
@@ -0,0 +1,127 @@
+package microsoftdynamicscrm
+
+import (
+	"context"
+	"errors"
+	"net/http"
+	"net/http/httptest"
+	"reflect"
+	"strings"
+	"testing"
+
+	"github.com/amp-labs/connectors/common"
+	"github.com/amp-labs/connectors/common/interpreter"
+	"github.com/amp-labs/connectors/test/utils/mockutils"
+	"github.com/go-test/deep"
+)
+
+func TestWrite(t *testing.T) { // nolint:funlen,cyclop
+	t.Parallel()
+
+	tests := []struct {
+		name         string
+		input        common.WriteParams
+		server       *httptest.Server
+		connector    Connector
+		expected     *common.WriteResult
+		expectedErrs []error
+	}{
+		{
+			name: "Write object must be included",
+			server: httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+				w.WriteHeader(http.StatusTeapot)
+			})),
+			expectedErrs: []error{common.ErrMissingObjects},
+		},
+		{
+			name:  "Mime response header expected",
+			input: common.WriteParams{ObjectName: "fax"},
+			server: httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+				w.WriteHeader(http.StatusTeapot)
+			})),
+			expectedErrs: []error{interpreter.ErrMissingContentType},
+		},
+		{
+			name:  "Correct error message is understood from JSON response",
+			input: common.WriteParams{ObjectName: "fax"},
+			server: httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+				w.Header().Set("Content-Type", "application/json")
+				w.WriteHeader(http.StatusBadRequest)
+				writeBody(w, `{
+					"error": {
+						"code": "0x80060888",
+						"message":"Resource not found for the segment 'conacs'."
+					}
+				}`)
+			})),
+			expectedErrs: []error{
+				common.ErrBadRequest,
+				errors.New("Resource not found for the segment 'conacs'"), // nolint:goerr113
+			},
+		},
+		{
+			name:  "Write must act as a Create",
+			input: common.WriteParams{ObjectName: "fax"},
+			server: httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+				w.Header().Set("Content-Type", "application/json")
+				mockutils.RespondWithMethodExpectation(w, r, "POST")
+			})),
+			expected:     &common.WriteResult{Success: true},
+			expectedErrs: nil,
+		},
+		{
+			name:  "Write must act as an Update",
+			input: common.WriteParams{ObjectName: "fax", RecordId: "dd2f7870-3fe8-ee11-a204-0022481f9e3c"},
+			server: httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+				w.Header().Set("Content-Type", "application/json")
+				mockutils.RespondWithMethodExpectation(w, r, "PATCH")
+			})),
+			expected:     &common.WriteResult{Success: true},
+			expectedErrs: nil,
+		},
+	}
+
+	for _, tt := range tests { // nolint:dupl
+		// nolint:varnamelen
+		tt := tt // rebind, omit loop side effects for parallel goroutine
+		t.Run(tt.name, func(t *testing.T) {
+			t.Parallel()
+
+			defer tt.server.Close()
+
+			ctx := context.Background()
+
+			connector, err := NewConnector(
+				WithAuthenticatedClient(http.DefaultClient),
+				WithWorkspace("test-workspace"),
+			)
+			if err != nil {
+				t.Fatalf("%s: error in test while constructin connector %v", tt.name, err)
+			}
+
+			// for testing we want to redirect calls to our server
+			connector.setBaseURL(tt.server.URL)
+
+			// start of tests
+			output, err := connector.Write(ctx, tt.input)
+			if len(tt.expectedErrs) == 0 && err != nil {
+				t.Fatalf("%s: expected no errors, got: (%v)", tt.name, err)
+			}
+
+			if len(tt.expectedErrs) != 0 && err == nil {
+				t.Fatalf("%s: expected errors (%v), but got nothing", tt.name, tt.expectedErrs)
+			}
+
+			for _, expectedErr := range tt.expectedErrs {
+				if !errors.Is(err, expectedErr) && !strings.Contains(err.Error(), expectedErr.Error()) {
+					t.Fatalf("%s: expected Error: (%v), got: (%v)", tt.name, expectedErr, err)
+				}
+			}
+
+			if !reflect.DeepEqual(output, tt.expected) {
+				diff := deep.Equal(output, tt.expected)
+				t.Fatalf("%s:, \nexpected: (%v), \ngot: (%v), \ndiff: (%v)", tt.name, tt.expected, output, diff)
+			}
+		})
+	}
+}
diff --git a/providers/catalog_test.go b/providers/catalog_test.go
index 5b149865..8af37ea3 100644
--- a/providers/catalog_test.go
+++ b/providers/catalog_test.go
@@ -628,35 +628,36 @@ var testCases = []struct { // nolint
 		},
 		expectedErr: nil,
 	},
-	{
-		provider: DocuSign,
-		substitutions: map[string]string{
-			"workspace": "example",
-		},
-		expected: &ProviderInfo{
-			Support: Support{
-				Read:  false,
-				Write: false,
-				BulkWrite: BulkWriteSupport{
-					Insert: false,
-					Update: false,
-					Upsert: false,
-					Delete: false,
-				},
-				Subscribe: false,
-				Proxy:     false,
-			},
-			AuthType: Oauth2,
-			OauthOpts: OauthOpts{
-				AuthURL:                   "https://account.docusign.com/oauth/auth",
-				TokenURL:                  "https://account.docusign.com/oauth/token",
-				ExplicitScopesRequired:    true,
-				ExplicitWorkspaceRequired: true,
-			},
-			BaseURL: "https://example.docusign.net",
-		},
-		expectedErr: nil,
-	},
+	// TODO: uncomment this when the docusign connector is uncommented
+	//{
+	//	provider: DocuSign,
+	//	substitutions: map[string]string{
+	//		"server": "example",
+	//	},
+	//	expected: &ProviderInfo{
+	//		Support: Support{
+	//			Read:  false,
+	//			Write: false,
+	//			BulkWrite: BulkWriteSupport{
+	//				Insert: false,
+	//				Update: false,
+	//				Upsert: false,
+	//				Delete: false,
+	//			},
+	//			Subscribe: false,
+	//			Proxy:     false,
+	//		},
+	//		AuthType: Oauth2,
+	//		OauthOpts: OauthOpts{
+	//			AuthURL:                   "https://account.docusign.com/oauth/auth",
+	//			TokenURL:                  "https://account.docusign.com/oauth/token",
+	//			ExplicitScopesRequired:    true,
+	//			ExplicitWorkspaceRequired: false,
+	//		},
+	//		BaseURL: "https://example.docusign.net",
+	//	},
+	//	expectedErr: nil,
+	// },
 	{
 		provider: DocuSignDeveloper,
 		expected: &ProviderInfo{
diff --git a/test/microsoftdynamicscrm/README.md b/test/microsoftdynamicscrm/README.md
index 0f8e552a..9362eb29 100644
--- a/test/microsoftdynamicscrm/README.md
+++ b/test/microsoftdynamicscrm/README.md
@@ -2,7 +2,7 @@
 
 ## Step 1: prepare "ms-dales-creds.json" file
 
-Create a file called "ms-sales-creds.json" in the root of the project with the following contents
+Create a file called "ms-crm-creds.json" in the root of the project with the following contents
 
     e.g. {
         "CLIENT_ID": "<client id goes here>",
@@ -11,15 +11,15 @@ Create a file called "ms-sales-creds.json" in the root of the project with the f
         "REFRESH_TOKEN": "<refresh token goes here>"
     }
 
-or export to an environment variable MS_SALES_CRED_FILE by following command
+or export to an environment variable MS_CRM_CRED_FILE by following command
 
-$> export MS_SALES_CRED_FILE=./ms-sales-creds.json # or the path to your ms-sales-creds.json file
+$> export MS_CRM_CRED_FILE=./ms-crm-creds.json # or the path to your ms-crm-creds.json file
 
 
-In 1password, you can find a MS Sales creds.json file in the "Shared" vault. TODO this must be in 1password
-Look for the title "MS Sales Sample OAuth Credentials".
+In 1password, you can find a MS CRM creds.json file in the "Shared" vault. TODO this must be in 1password
+Look for the title "MS CRM Sample OAuth Credentials".
 The 1password item has an attached file called "creds.json" that contains the JSON.
 
 ## Step 2: run the following command
 
-    $> go run test/msdsales/read/main.go
\ No newline at end of file
+    $> go run test/microsoftdynamicscrm/read/main.go
\ No newline at end of file
diff --git a/test/microsoftdynamicscrm/connector.go b/test/microsoftdynamicscrm/connector.go
index 221fdea7..fd29e0c6 100644
--- a/test/microsoftdynamicscrm/connector.go
+++ b/test/microsoftdynamicscrm/connector.go
@@ -10,7 +10,7 @@ import (
 	"github.com/amp-labs/connectors/utils"
 )
 
-func GetMSDynamics365SalesConnector(ctx context.Context, filePath string) *microsoftdynamicscrm.Connector {
+func GetMSDynamics365CRMConnector(ctx context.Context, filePath string) *microsoftdynamicscrm.Connector {
 	registry := utils.NewCredentialsRegistry()
 
 	readers := []utils.Reader{
@@ -42,16 +42,16 @@ func GetMSDynamics365SalesConnector(ctx context.Context, filePath string) *micro
 	}
 	_ = registry.AddReaders(readers...)
 
-	cfg := utils.MSDynamics365SalesConfigFromRegistry(registry)
-	tok := utils.MSDynamics365SalesTokenFromRegistry(registry)
+	cfg := utils.MSDynamics365CRMConfigFromRegistry(registry)
+	tok := utils.MSDynamics365CRMTokenFromRegistry(registry)
 
-	conn, err := connectors.MSDynamicsSales(
+	conn, err := connectors.MSDynamicsCRM(
 		microsoftdynamicscrm.WithClient(ctx, http.DefaultClient, cfg, tok),
-		microsoftdynamicscrm.WithWorkspace(utils.MSDynamics365SalesWorkspace),
+		microsoftdynamicscrm.WithWorkspace(utils.MSDynamics365CRMWorkspace),
 		microsoftdynamicscrm.WithModule(microsoftdynamicscrm.DefaultModuleCRM),
 	)
 	if err != nil {
-		testUtils.Fail("error creating microsoft sales connector", "error", err)
+		testUtils.Fail("error creating microsoft CRM connector", "error", err)
 	}
 
 	return conn
diff --git a/test/microsoftdynamicscrm/read/main.go b/test/microsoftdynamicscrm/read/main.go
index b0478629..7bc022ff 100644
--- a/test/microsoftdynamicscrm/read/main.go
+++ b/test/microsoftdynamicscrm/read/main.go
@@ -21,12 +21,12 @@ func main() {
 	// Set up slog logging.
 	utils.SetupLogging()
 
-	filePath := os.Getenv("MS_SALES_CRED_FILE")
+	filePath := os.Getenv("MS_CRM_CRED_FILE")
 	if filePath == "" {
-		filePath = "./ms-sales-creds.json"
+		filePath = "./ms-crm-creds.json"
 	}
 
-	conn := msTest.GetMSDynamics365SalesConnector(ctx, filePath)
+	conn := msTest.GetMSDynamics365CRMConnector(ctx, filePath)
 	defer utils.Close(conn)
 
 	res, err := conn.Read(ctx, common.ReadParams{
@@ -36,7 +36,7 @@ func main() {
 		},
 	})
 	if err != nil {
-		utils.Fail("error reading from microsoft sales", "error", err)
+		utils.Fail("error reading from microsoft CRM", "error", err)
 	}
 
 	fmt.Println("Reading contacts..")
diff --git a/test/microsoftdynamicscrm/write-delete/main.go b/test/microsoftdynamicscrm/write-delete/main.go
new file mode 100644
index 00000000..c635a649
--- /dev/null
+++ b/test/microsoftdynamicscrm/write-delete/main.go
@@ -0,0 +1,169 @@
+package main
+
+import (
+	"context"
+	"fmt"
+	"os"
+	"os/signal"
+	"syscall"
+
+	"github.com/amp-labs/connectors/common"
+	"github.com/amp-labs/connectors/microsoftdynamicscrm"
+	msTest "github.com/amp-labs/connectors/test/microsoftdynamicscrm"
+	"github.com/amp-labs/connectors/test/utils"
+)
+
+type LeadCreatePayload struct {
+	LastName    string `json:"lastname,omitempty"`
+	FirstName   string `json:"firstname,omitempty"`
+	CompanyName string `json:"companyname,omitempty"`
+	Subject     string `json:"subject,omitempty"`
+}
+
+type LeadUploadPayload struct {
+	LastName    *string `json:"lastname,omitempty"`
+	FirstName   *string `json:"firstname,omitempty"`
+	CompanyName *string `json:"companyname,omitempty"`
+	Subject     *string `json:"subject,omitempty"`
+}
+
+func main() {
+	// Handle Ctrl-C gracefully.
+	ctx, done := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
+	defer done()
+
+	// Set up slog logging.
+	utils.SetupLogging()
+
+	filePath := os.Getenv("MS_CRM_CRED_FILE")
+	if filePath == "" {
+		filePath = "./ms-crn-creds.json"
+	}
+
+	conn := msTest.GetMSDynamics365CRMConnector(ctx, filePath)
+	defer utils.Close(conn)
+
+	fmt.Println("> TEST Create/Update/Delete lead")
+	fmt.Println("Creating lead")
+	createLead(ctx, conn, &LeadCreatePayload{
+		LastName:    "Sponge",
+		FirstName:   "Bob",
+		CompanyName: "Bikini Bottom",
+		Subject:     "Burgers",
+	})
+
+	fmt.Println("Reading leads")
+
+	res := readLeads(ctx, conn)
+
+	fmt.Println("Finding recently created lead")
+
+	lead := searchLead(res, "subject", "Burgers")
+	leadID := fmt.Sprintf("%v", lead["leadid"])
+	fmt.Println("Updating some lead properties")
+	updateLead(ctx, conn, leadID, &LeadUploadPayload{
+		LastName:  strPtr(""),
+		FirstName: strPtr("Squidward"),
+	})
+	fmt.Println("View that lead has changed accordingly")
+
+	res = readLeads(ctx, conn)
+
+	lead = searchLead(res, "leadid", leadID)
+	for k, v := range map[string]string{
+		"lastname":    "",
+		"firstname":   "Squidward",
+		"companyname": "Bikini Bottom",
+		"subject":     "Burgers",
+	} {
+		if !compare(lead[k], v) {
+			utils.Fail("error updated properties do not match", k, v, lead[k])
+		}
+	}
+
+	fmt.Println("Removing this lead")
+	removeLead(ctx, conn, leadID)
+	fmt.Println("> Successful test completion")
+}
+
+func searchLead(res *common.ReadResult, key, value string) map[string]any {
+	for _, data := range res.Data {
+		if data.Fields[key] == value {
+			return data.Fields
+		}
+	}
+
+	utils.Fail("error finding lead")
+
+	return nil
+}
+
+func readLeads(ctx context.Context, conn *microsoftdynamicscrm.Connector) *common.ReadResult {
+	res, err := conn.Read(ctx, common.ReadParams{
+		ObjectName: "leads",
+		Fields: []string{
+			"leadid", "lastname", "firstname", "companyname", "subject",
+		},
+	})
+	if err != nil {
+		utils.Fail("error reading from microsoft CRM", "error", err)
+	}
+
+	return res
+}
+
+func createLead(ctx context.Context, conn *microsoftdynamicscrm.Connector, payload *LeadCreatePayload) {
+	res, err := conn.Write(ctx, common.WriteParams{
+		ObjectName: "leads",
+		RecordId:   "",
+		RecordData: payload,
+	})
+	if err != nil {
+		utils.Fail("error writing to microsoft CRM", "error", err)
+	}
+
+	if !res.Success {
+		utils.Fail("failed to create a lead")
+	}
+}
+
+func updateLead(ctx context.Context, conn *microsoftdynamicscrm.Connector, leadID string, payload *LeadUploadPayload) {
+	res, err := conn.Write(ctx, common.WriteParams{
+		ObjectName: "leads",
+		RecordId:   leadID,
+		RecordData: payload,
+	})
+	if err != nil {
+		utils.Fail("error writing to microsoft CRM", "error", err)
+	}
+
+	if !res.Success {
+		utils.Fail("failed to update a lead")
+	}
+}
+
+func removeLead(ctx context.Context, conn *microsoftdynamicscrm.Connector, leadID string) {
+	res, err := conn.Delete(ctx, common.DeleteParams{
+		ObjectName: "leads",
+		RecordId:   leadID,
+	})
+	if err != nil {
+		utils.Fail("error deleting for microsoft CRM", "error", err)
+	}
+
+	if !res.Success {
+		utils.Fail("failed to remove a lead")
+	}
+}
+
+func strPtr(s string) *string {
+	return &s
+}
+
+func compare(field any, value string) bool {
+	if len(value) == 0 && field == nil {
+		return true
+	}
+
+	return fmt.Sprintf("%v", field) == value
+}
diff --git a/test/outreach/read/read.go b/test/outreach/read/read.go
index cd2f6e06..0dc8021d 100644
--- a/test/outreach/read/read.go
+++ b/test/outreach/read/read.go
@@ -69,6 +69,7 @@ func main() {
 		ObjectName: "users",
 		// NextPage:   "https://api.outreach.io/api/v2/users?page%5Blimit%5D=1\u0026page%5Boffset%5D=2",
 	}
+
 	result, err := outreach.Read(context.Background(), config)
 	if err != nil {
 		log.Fatal(err)
diff --git a/test/utils/mockutils/server.go b/test/utils/mockutils/server.go
new file mode 100644
index 00000000..9398e258
--- /dev/null
+++ b/test/utils/mockutils/server.go
@@ -0,0 +1,25 @@
+package mockutils
+
+import (
+	"fmt"
+	"net/http"
+)
+
+func RespondWithMethodExpectation(w http.ResponseWriter, r *http.Request, methodName string) {
+	// if method is not as expected we return error code so the test will fail
+	// and with response payload which will be a helpful message for debugging
+	if r.Method == methodName {
+		w.WriteHeader(http.StatusNoContent)
+	} else {
+		w.WriteHeader(http.StatusBadRequest)
+		WriteBody(w, fmt.Sprintf(`{
+			"error": {
+				"code": "from test",
+				"message":"test server expected %v request"
+			}}`, methodName))
+	}
+}
+
+func WriteBody(w http.ResponseWriter, body string) {
+	_, _ = w.Write([]byte(body))
+}