Repository: amp-labs/connectors
Pull request #678 (2024-07-10 17:54:11)

feat: Deep connector Zendesk Support (Write)
Closes https://github.com/amp-labs/connectors/issues/616

# Manual Test

![image](https://github.com/user-attachments/assets/0a220e16-f52b-4bda-a480-f5a1c063bbb3)


Cobalt0s: * **#678** <a href="https://app.graphite.dev/github/pr/amp-labs/connectors/678?utm_source=stack-comment-icon" target="_blank"><img src="https://static.graphite.dev/graphite-32x32-black.png" alt="Graphite" width="10px" height="10px"/></a> 👈
* `cobalt0s/zendesk-support`

This stack of pull requests is managed by Graphite. <a href="https://stacking.dev/?utm_source=stack-comment">Learn more about stacking.</a>
<h2></h2>

 Join @Cobalt0s and the rest of your teammates on <a href="https://graphite.dev?utm-source=stack-comment"><img src="https://static.graphite.dev/graphite-32x32-black.png" alt="Graphite" width="11px" height="11px"/> <b>Graphite</b></a>
  <!-- Current dependencies on/for this PR: -->

Patch:
diff --git a/zendesksupport/delete.go b/zendesksupport/delete.go
new file mode 100644
index 00000000..52606f76
--- /dev/null
+++ b/zendesksupport/delete.go
@@ -0,0 +1,34 @@
+package zendesksupport
+
+import (
+	"context"
+
+	"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
+	}
+
+	url, err := c.getURL(config.ObjectName)
+	if err != nil {
+		return nil, err
+	}
+
+	url.AddPath(config.RecordId)
+
+	// 204 NoContent is expected
+	_, err = c.Client.Delete(ctx, url.String())
+	if err != nil {
+		return nil, err
+	}
+
+	return &common.DeleteResult{
+		Success: true,
+	}, nil
+}
diff --git a/zendesksupport/errors.go b/zendesksupport/errors.go
index 89f15989..5eb09086 100644
--- a/zendesksupport/errors.go
+++ b/zendesksupport/errors.go
@@ -2,6 +2,7 @@ package zendesksupport
 
 import (
 	"encoding/json"
+	"errors"
 	"fmt"
 	"net/http"
 
@@ -17,30 +18,65 @@ func (*Connector) interpretJSONError(res *http.Response, body []byte) error { //
 
 	var schema common.ErrorDescriptor
 
-	if _, ok := payload["description"]; ok {
+	if _, ok := payload["description"]; schema == nil && ok {
 		apiError := &DescriptiveResponseError{}
 		if err := json.Unmarshal(body, &apiError); err != nil {
-			return fmt.Errorf("interpretJSONError ListError: %w %w", interpreter.ErrUnmarshal, err)
+			return fmt.Errorf(
+				"interpretJSONError DescriptiveResponseError: %w %w", interpreter.ErrUnmarshal, err)
 		}
 
 		schema = apiError
-	} else {
+	}
+
+	if _, ok := payload["status"]; schema == nil && ok {
+		apiError := &StatusResponseError{}
+		if err := json.Unmarshal(body, &apiError); err != nil {
+			return fmt.Errorf("interpretJSONError StatusResponseError: %w %w", interpreter.ErrUnmarshal, err)
+		}
+
+		schema = apiError
+	}
+
+	// default format
+	if schema == nil {
 		apiError := &MessageResponseError{}
 		if err := json.Unmarshal(body, &apiError); err != nil {
-			return fmt.Errorf("interpretJSONError SingleError: %w %w", interpreter.ErrUnmarshal, err)
+			return fmt.Errorf("interpretJSONError MessageResponseError: %w %w", interpreter.ErrUnmarshal, err)
 		}
 
 		schema = apiError
 	}
 
-	return schema.CombineErr(interpreter.DefaultStatusCodeMappingToErr(res, body))
+	return schema.CombineErr(statusCodeMapping(res, body))
+}
+
+func statusCodeMapping(res *http.Response, body []byte) error {
+	if res.StatusCode == http.StatusInternalServerError {
+		return common.ErrServer
+	}
+
+	return interpreter.DefaultStatusCodeMappingToErr(res, body)
 }
 
 type DescriptiveResponseError struct {
-	Error       string `json:"error"`
+	descrDetailsError
+	Details map[string][]descrDetailsError `json:"details"`
+}
+
+type descrDetailsError struct {
+	ErrorStr    string `json:"error"`
 	Description string `json:"description"`
 }
 
+func (d descrDetailsError) Error() string {
+	return fmt.Sprintf("[%v]%v", d.ErrorStr, d.Description)
+}
+
+type StatusResponseError struct {
+	Status int    `json:"status"`
+	Error  string `json:"error"`
+}
+
 type MessageResponseError struct {
 	Error struct {
 		Title   string `json:"title"`
@@ -49,11 +85,25 @@ type MessageResponseError struct {
 }
 
 func (r DescriptiveResponseError) CombineErr(base error) error {
-	if len(r.Error)+len(r.Description) == 0 {
+	if len(r.ErrorStr)+len(r.Description) == 0 {
 		return base
 	}
 
-	return fmt.Errorf("%w: [%v]%v", base, r.Error, r.Description)
+	details := []error{
+		r.descrDetailsError,
+	}
+
+	for _, list := range r.Details {
+		for _, err := range list {
+			details = append(details, err)
+		}
+	}
+
+	return fmt.Errorf("%w: %w", base, errors.Join(details...))
+}
+
+func (r StatusResponseError) CombineErr(base error) error {
+	return fmt.Errorf("%w: %v", base, r.Error)
 }
 
 func (r MessageResponseError) CombineErr(base error) error {
diff --git a/zendesksupport/write.go b/zendesksupport/write.go
new file mode 100644
index 00000000..6d3c35c1
--- /dev/null
+++ b/zendesksupport/write.go
@@ -0,0 +1,92 @@
+package zendesksupport
+
+import (
+	"context"
+	"strconv"
+
+	"github.com/amp-labs/connectors/common"
+	"github.com/amp-labs/connectors/common/jsonquery"
+	"github.com/amp-labs/connectors/common/naming"
+	"github.com/spyzhov/ajson"
+)
+
+func (c *Connector) Write(ctx context.Context, config common.WriteParams) (*common.WriteResult, error) {
+	if len(config.ObjectName) == 0 {
+		return nil, common.ErrMissingObjects
+	}
+
+	url, err := c.getURL(config.ObjectName)
+	if err != nil {
+		return nil, err
+	}
+
+	var write common.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.Client.Post
+	} else {
+		// only put is supported for updating 'Single' resource
+		write = c.Client.Put
+
+		url.AddPath(config.RecordId)
+	}
+
+	res, err := write(ctx, url.String(), config.RecordData)
+	if err != nil {
+		return nil, err
+	}
+
+	if res == nil || res.Body == nil {
+		// it is unlikely to have no payload
+		return &common.WriteResult{
+			Success: true,
+		}, nil
+	}
+
+	// write response was with payload
+	return constructWriteResult(config, res.Body)
+}
+
+func constructWriteResult(config common.WriteParams, body *ajson.Node) (*common.WriteResult, error) {
+	nested, err := jsonquery.New(body).Object(config.ObjectName, true)
+	if err != nil {
+		return nil, err
+	}
+
+	if nested == nil {
+		// Field should be in singular form. Either one will be matched.
+		// This one is NOT optional.
+		nested, err = jsonquery.New(body).Object(
+			naming.NewSingularString(config.ObjectName).String(),
+			false,
+		)
+		if err != nil {
+			return nil, err
+		}
+	}
+	// nested node now must be not null, carry on
+
+	rawID, err := jsonquery.New(nested).Integer("id", true)
+	if err != nil {
+		return nil, err
+	}
+
+	recordID := ""
+	if rawID != nil {
+		// optional
+		recordID = strconv.FormatInt(*rawID, 10)
+	}
+
+	data, err := jsonquery.Convertor.ObjectToMap(nested)
+	if err != nil {
+		return nil, err
+	}
+
+	return &common.WriteResult{
+		Success:  true,
+		RecordId: recordID,
+		Errors:   nil,
+		Data:     data,
+	}, nil
+}


Test patch:
diff --git a/test/utils/mockutils/checks.go b/test/utils/mockutils/checks.go
index e1b8d5a1..f3499dee 100644
--- a/test/utils/mockutils/checks.go
+++ b/test/utils/mockutils/checks.go
@@ -2,6 +2,7 @@ package mockutils
 
 import (
 	"fmt"
+	"strconv"
 	"testing"
 )
 
@@ -10,6 +11,16 @@ func DoesObjectCorrespondToString(object any, correspondent string) bool {
 		return true
 	}
 
+	switch object.(type) {
+	case float64:
+		f, err := strconv.ParseFloat(correspondent, 10)
+		if err != nil {
+			return false
+		}
+
+		return f == object
+	}
+
 	return fmt.Sprintf("%v", object) == correspondent
 }
 
diff --git a/test/zendesksupport/write-delete/main.go b/test/zendesksupport/write-delete/main.go
new file mode 100644
index 00000000..0c9396fc
--- /dev/null
+++ b/test/zendesksupport/write-delete/main.go
@@ -0,0 +1,167 @@
+package main
+
+import (
+	"context"
+	"fmt"
+	"github.com/brianvoe/gofakeit/v6"
+	"os"
+	"os/signal"
+	"strings"
+	"syscall"
+
+	"github.com/amp-labs/connectors/common"
+	"github.com/amp-labs/connectors/test/utils"
+	"github.com/amp-labs/connectors/test/utils/mockutils"
+	connTest "github.com/amp-labs/connectors/test/zendesksupport"
+	"github.com/amp-labs/connectors/zendesksupport"
+)
+
+type brandsPayload struct {
+	Brand brand `json:"brand"`
+}
+type brand struct {
+	Subdomain string `json:"subdomain"`
+	Email     string `json:"email"`
+	Name      string `json:"name"`
+}
+
+var (
+	objectName = "brands" // nolint: gochecknoglobals
+)
+
+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("ZENDESK_SUPPORT_CRED_FILE")
+	if filePath == "" {
+		filePath = "./zendesk-support-creds.json"
+	}
+
+	conn := connTest.GetZendeskSupportConnector(ctx, filePath)
+	defer utils.Close(conn)
+
+	fmt.Println("> TEST Create/Update/Delete brands")
+
+	email := gofakeit.Email()
+	name := gofakeit.Noun()
+	subdomain := strings.ToLower(gofakeit.AppName())
+	subdomainChanged := strings.ToLower(gofakeit.AppName())
+	brandURL := fmt.Sprintf("https://%v.zendesk.com", subdomainChanged)
+
+	fmt.Println("Creating brands")
+
+	view := createBrands(ctx, conn, &brandsPayload{
+		Brand: brand{
+			Subdomain: subdomain,
+			Email:     email,
+			Name:      name,
+		},
+	})
+
+	fmt.Println("Updating some brands properties")
+	updateBrands(ctx, conn, view.RecordId, &brandsPayload{
+		Brand: brand{
+			Subdomain: subdomainChanged,
+			Name:      name,
+		},
+	})
+
+	fmt.Println("View that brands has changed accordingly")
+
+	res := readBrands(ctx, conn)
+
+	updatedView := searchBrands(res, "id", view.RecordId)
+	for k, v := range map[string]string{
+		"id":        view.RecordId,
+		"brand_url": brandURL,
+		"name":      name,
+		"subdomain": subdomainChanged,
+	} {
+		if !mockutils.DoesObjectCorrespondToString(updatedView[k], v) {
+			utils.Fail("error updated properties do not match", k, v, updatedView[k])
+		}
+	}
+
+	fmt.Println("Removing this brands")
+	removeBrands(ctx, conn, view.RecordId)
+	fmt.Println("> Successful test completion")
+}
+
+func searchBrands(res *common.ReadResult, key, value string) map[string]any {
+	for _, data := range res.Data {
+		if mockutils.DoesObjectCorrespondToString(data.Fields[key], value) {
+			return data.Raw
+		}
+	}
+
+	utils.Fail("error finding brands")
+
+	return nil
+}
+
+func readBrands(ctx context.Context, conn *zendesksupport.Connector) *common.ReadResult {
+	res, err := conn.Read(ctx, common.ReadParams{
+		ObjectName: objectName,
+		Fields: []string{
+			"id", "view", "name",
+		},
+	})
+	if err != nil {
+		utils.Fail("error reading from ZendeskSupport", "error", err)
+	}
+
+	return res
+}
+
+func createBrands(ctx context.Context, conn *zendesksupport.Connector, payload *brandsPayload) *common.WriteResult {
+	res, err := conn.Write(ctx, common.WriteParams{
+		ObjectName: objectName,
+		RecordId:   "",
+		RecordData: payload,
+	})
+	if err != nil {
+		utils.Fail("error writing to ZendeskSupport", "error", err)
+	}
+
+	if !res.Success {
+		utils.Fail("failed to create a brands")
+	}
+
+	return res
+}
+
+func updateBrands(ctx context.Context, conn *zendesksupport.Connector, viewID string, payload *brandsPayload) *common.WriteResult {
+	res, err := conn.Write(ctx, common.WriteParams{
+		ObjectName: objectName,
+		RecordId:   viewID,
+		RecordData: payload,
+	})
+	if err != nil {
+		utils.Fail("error writing to ZendeskSupport", "error", err)
+	}
+
+	if !res.Success {
+		utils.Fail("failed to update a brands")
+	}
+
+	return res
+}
+
+func removeBrands(ctx context.Context, conn *zendesksupport.Connector, viewID string) {
+	res, err := conn.Delete(ctx, common.DeleteParams{
+		ObjectName: objectName,
+		RecordId:   viewID,
+	})
+	if err != nil {
+		utils.Fail("error deleting for ZendeskSupport", "error", err)
+	}
+
+	if !res.Success {
+		utils.Fail("failed to remove a brands")
+	}
+}
diff --git a/zendesksupport/delete_test.go b/zendesksupport/delete_test.go
new file mode 100644
index 00000000..0b27b604
--- /dev/null
+++ b/zendesksupport/delete_test.go
@@ -0,0 +1,122 @@
+package zendesksupport
+
+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()
+
+	responseServerError := mockutils.DataFromFile(t, "server-error.json")
+
+	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: "brands"},
+			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: "brands", RecordId: "31207417638931"},
+			server: httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+				w.WriteHeader(http.StatusTeapot)
+			})),
+			expectedErrs: []error{interpreter.ErrMissingContentType},
+		},
+		{
+			name:  "Internal server error in response",
+			input: common.DeleteParams{ObjectName: "brands", RecordId: "31207417638931"},
+			server: httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+				w.Header().Set("Content-Type", "application/json")
+				w.WriteHeader(http.StatusInternalServerError)
+				_, _ = w.Write(responseServerError)
+			})),
+			expectedErrs: []error{
+				common.ErrServer,
+				errors.New("Internal Server Error"), // nolint:goerr113
+			},
+		},
+		{
+			name:  "Successful delete",
+			input: common.DeleteParams{ObjectName: "brands", RecordId: "31207417638931"},
+			server: httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+				w.Header().Set("Content-Type", "application/json")
+				mockutils.RespondNoContentForMethod(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 constructing 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/zendesksupport/read_test.go b/zendesksupport/read_test.go
index 420b47fc..90482116 100644
--- a/zendesksupport/read_test.go
+++ b/zendesksupport/read_test.go
@@ -19,8 +19,6 @@ import (
 func TestRead(t *testing.T) { //nolint:funlen,gocognit,cyclop,maintidx
 	t.Parallel()
 
-	const workspace = "testWorkspace"
-
 	responseErrorFormat := mockutils.DataFromFile(t, "resource-not-found.json")
 	responseForbiddenError := mockutils.DataFromFile(t, "forbidden.json")
 	responseUsersFirstPage := mockutils.DataFromFile(t, "read-users-1-first-page.json")
@@ -37,7 +35,7 @@ func TestRead(t *testing.T) { //nolint:funlen,gocognit,cyclop,maintidx
 		expectedErrs []error
 	}{
 		{
-			name: "Write object must be included",
+			name: "Read object must be included",
 			server: httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
 				w.WriteHeader(http.StatusTeapot)
 			})),
@@ -182,7 +180,7 @@ func TestRead(t *testing.T) { //nolint:funlen,gocognit,cyclop,maintidx
 
 			connector, err := NewConnector(
 				WithAuthenticatedClient(http.DefaultClient),
-				WithWorkspace(workspace),
+				WithWorkspace("test-workspace"),
 			)
 			if err != nil {
 				t.Fatalf("%s: error in test while constructing connector %v", tt.name, err)
diff --git a/zendesksupport/test/create-brand.json b/zendesksupport/test/create-brand.json
new file mode 100644
index 00000000..b96e8617
--- /dev/null
+++ b/zendesksupport/test/create-brand.json
@@ -0,0 +1,22 @@
+{
+  "brand": {
+    "url": "https://d3v-ampersand.zendesk.com/api/v2/brands/31207417638931.json",
+    "id": 31207417638931,
+    "name": "Nike",
+    "brand_url": "https://nkn2.zendesk.com",
+    "subdomain": "nkn2",
+    "host_mapping": null,
+    "has_help_center": false,
+    "help_center_state": "disabled",
+    "active": true,
+    "default": false,
+    "is_deleted": false,
+    "logo": null,
+    "ticket_form_ids": [
+      26363596576915
+    ],
+    "signature_template": null,
+    "created_at": "2024-07-10T17:14:09Z",
+    "updated_at": "2024-07-10T17:14:09Z"
+  }
+}
\ No newline at end of file
diff --git a/zendesksupport/test/duplicate-error.json b/zendesksupport/test/duplicate-error.json
new file mode 100644
index 00000000..a1fa0f6b
--- /dev/null
+++ b/zendesksupport/test/duplicate-error.json
@@ -0,0 +1,12 @@
+{
+  "error": "RecordInvalid",
+  "description": "Record validation errors",
+  "details": {
+    "route.subdomain": [
+      {
+        "description": "Subdomain: nk2 has already been taken",
+        "error": "DuplicateValue"
+      }
+    ]
+  }
+}
diff --git a/zendesksupport/test/missing-parameter.json b/zendesksupport/test/missing-parameter.json
new file mode 100644
index 00000000..983a1b0b
--- /dev/null
+++ b/zendesksupport/test/missing-parameter.json
@@ -0,0 +1,4 @@
+{
+  "error": "ParameterMissing",
+  "description": "Parameter brands is required"
+}
diff --git a/zendesksupport/test/record-validation.json b/zendesksupport/test/record-validation.json
new file mode 100644
index 00000000..8735fd51
--- /dev/null
+++ b/zendesksupport/test/record-validation.json
@@ -0,0 +1,24 @@
+{
+  "error": "RecordInvalid",
+  "description": "Record validation errors",
+  "details": {
+    "route.subdomain": [
+      {
+        "description": "Subdomain: is invalid (three or more letters and numbers only, without spaces)",
+        "error": "InvalidValue"
+      }
+    ],
+    "recipient_addresses.email": [
+      {
+        "description": "Email is not properly formatted",
+        "error": "InvalidFormat"
+      }
+    ],
+    "name": [
+      {
+        "description": "Name: cannot be blank",
+        "error": "BlankValue"
+      }
+    ]
+  }
+}
diff --git a/zendesksupport/test/server-error.json b/zendesksupport/test/server-error.json
new file mode 100644
index 00000000..85b4908b
--- /dev/null
+++ b/zendesksupport/test/server-error.json
@@ -0,0 +1,4 @@
+{
+  "status": 500,
+  "error": "Internal Server Error"
+}
diff --git a/zendesksupport/write_test.go b/zendesksupport/write_test.go
new file mode 100644
index 00000000..ec988487
--- /dev/null
+++ b/zendesksupport/write_test.go
@@ -0,0 +1,202 @@
+package zendesksupport
+
+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()
+
+	// server-error.json occurs when trying to Create object without payload name.
+	// ex: for tickets payload must have { "ticket": {...} }
+
+	responseMissingParameterError := mockutils.DataFromFile(t, "missing-parameter.json")
+	responseDuplicateError := mockutils.DataFromFile(t, "duplicate-error.json")
+	responseRecordValidationError := mockutils.DataFromFile(t, "record-validation.json")
+	createBrand := mockutils.DataFromFile(t, "create-brand.json")
+
+	tests := []struct {
+		name         string
+		input        common.WriteParams
+		server       *httptest.Server
+		connector    Connector
+		comparator   func(actual, expected *common.WriteResult) bool // custom comparison
+		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: "signals"},
+			server: httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+				w.WriteHeader(http.StatusTeapot)
+			})),
+			expectedErrs: []error{interpreter.ErrMissingContentType},
+		},
+		{
+			name:  "Missing write parameter",
+			input: common.WriteParams{ObjectName: "brands"},
+			server: httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+				w.Header().Set("Content-Type", "application/json")
+				w.WriteHeader(http.StatusBadRequest)
+				_, _ = w.Write(responseMissingParameterError)
+			})),
+			expectedErrs: []error{
+				common.ErrBadRequest,
+				errors.New("Parameter brands is required"), // nolint:goerr113
+			},
+		},
+		{
+			name:  "Record validation with single detail",
+			input: common.WriteParams{ObjectName: "brands"},
+			server: httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+				w.Header().Set("Content-Type", "application/json")
+				w.WriteHeader(http.StatusBadRequest)
+				_, _ = w.Write(responseDuplicateError)
+			})),
+			expectedErrs: []error{
+				common.ErrBadRequest,
+				errors.New("[RecordInvalid]Record validation errors"),               // nolint:goerr113
+				errors.New("[DuplicateValue]Subdomain: nk2 has already been taken"), // nolint:goerr113
+			},
+		},
+		{
+			name:  "Record validation with multiple details is split into dedicated errors",
+			input: common.WriteParams{ObjectName: "brands"},
+			server: httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+				w.Header().Set("Content-Type", "application/json")
+				w.WriteHeader(http.StatusBadRequest)
+				_, _ = w.Write(responseRecordValidationError)
+			})),
+			expectedErrs: []error{
+				common.ErrBadRequest,
+				errors.New("[RecordInvalid]Record validation errors"),        // nolint:goerr113
+				errors.New("[InvalidValue]Subdomain: is invalid"),            // nolint:goerr113
+				errors.New("[InvalidFormat]Email is not properly formatted"), // nolint:goerr113
+				errors.New("[BlankValue]Name: cannot be blank"),              // nolint:goerr113
+			},
+		},
+		{
+			name:  "Write must act as a Create",
+			input: common.WriteParams{ObjectName: "brands"},
+			server: httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+				w.Header().Set("Content-Type", "application/json")
+				mockutils.RespondToMethod(w, r, "POST", func() {
+					w.WriteHeader(http.StatusOK)
+				})
+			})),
+			expected:     &common.WriteResult{Success: true},
+			expectedErrs: nil,
+		},
+		{
+			name:  "Write must act as an Update",
+			input: common.WriteParams{ObjectName: "brands", RecordId: "31207417638931"},
+			server: httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+				w.Header().Set("Content-Type", "application/json")
+				mockutils.RespondToMethod(w, r, "PUT", func() {
+					w.WriteHeader(http.StatusOK)
+				})
+			})),
+			expected:     &common.WriteResult{Success: true},
+			expectedErrs: nil,
+		},
+		{
+			name:  "Valid creation of a brand",
+			input: common.WriteParams{ObjectName: "brands"},
+			server: httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+				w.Header().Set("Content-Type", "application/json")
+				mockutils.RespondToMethod(w, r, "POST", func() {
+					w.WriteHeader(http.StatusOK)
+					_, _ = w.Write(createBrand)
+				})
+			})),
+			comparator: func(actual, expected *common.WriteResult) bool {
+				return mockutils.WriteResultComparator.SubsetData(actual, expected)
+			},
+			expected: &common.WriteResult{
+				Success:  true,
+				RecordId: "31207417638931",
+				Errors:   nil,
+				Data: map[string]any{
+					"id":        float64(31207417638931),
+					"name":      "Nike",
+					"brand_url": "https://nkn2.zendesk.com",
+					"subdomain": "nkn2",
+					"active":    true,
+					"default":   false,
+				},
+			},
+			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 constructing 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)
+				}
+			}
+
+			// compare desired output
+			var ok bool
+			if tt.comparator == nil {
+				// default comparison is concerned about all fields
+				ok = reflect.DeepEqual(output, tt.expected)
+			} else {
+				ok = tt.comparator(output, tt.expected)
+			}
+
+			if !ok {
+				diff := deep.Equal(output, tt.expected)
+				t.Fatalf("%s:, \nexpected: (%v), \ngot: (%v), \ndiff: (%v)", tt.name, tt.expected, output, diff)
+			}
+		})
+	}
+}