Repository: amp-labs/connectors
Pull request #1468 (2025-03-11 23:50:05)

[CON-1326] feat(Capsule): Write
# Testing
## Projects
![image](https://github.com/user-attachments/assets/d1e20c6c-b16c-466c-88c9-de86bfb1dc67)

## Tasks
![image](https://github.com/user-attachments/assets/d69a1851-99b5-45ad-8f62-84c24def8fd1)


Cobalt0s: > [!WARNING]
> <b>This pull request is not mergeable via GitHub because a downstack PR is open. Once all requirements are satisfied, merge this PR as a stack <a href="https://app.graphite.dev/github/pr/amp-labs/connectors/1468?utm_source=stack-comment-downstack-mergeability-warning" >on Graphite</a>.</b>
> <a href="https://graphite.dev/docs/merge-pull-requests">Learn more</a>

* **#1468** <a href="https://app.graphite.dev/github/pr/amp-labs/connectors/1468?utm_source=stack-comment-icon" target="_blank"><img src="https://static.graphite.dev/graphite-32x32-black.png" alt="Graphite" width="10px" height="10px"/></a> 👈 <a href="https://app.graphite.dev/github/pr/amp-labs/connectors/1468?utm_source=stack-comment-view-in-graphite" target="_blank">(View in Graphite)</a>
* **#1446** <a href="https://app.graphite.dev/github/pr/amp-labs/connectors/1446?utm_source=stack-comment-icon" target="_blank"><img src="https://static.graphite.dev/graphite-32x32-black.png" alt="Graphite" width="10px" height="10px"/></a>
* **#1445** <a href="https://app.graphite.dev/github/pr/amp-labs/connectors/1445?utm_source=stack-comment-icon" target="_blank"><img src="https://static.graphite.dev/graphite-32x32-black.png" alt="Graphite" width="10px" height="10px"/></a>
* **#1433** <a href="https://app.graphite.dev/github/pr/amp-labs/connectors/1433?utm_source=stack-comment-icon" target="_blank"><img src="https://static.graphite.dev/graphite-32x32-black.png" alt="Graphite" width="10px" height="10px"/></a>
* **#1432** <a href="https://app.graphite.dev/github/pr/amp-labs/connectors/1432?utm_source=stack-comment-icon" target="_blank"><img src="https://static.graphite.dev/graphite-32x32-black.png" alt="Graphite" width="10px" height="10px"/></a>
* `main`


<h2></h2>

This stack of pull requests is managed by <a href="https://graphite.dev?utm-source=stack-comment"><b>Graphite</b></a>. Learn more about <a href="https://stacking.dev/?utm_source=stack-comment">stacking</a>.
<!-- Current dependencies on/for this PR: -->

Patch:
diff --git a/providers/capsule/connector.go b/providers/capsule/connector.go
index c60648d9..5a77e13e 100644
--- a/providers/capsule/connector.go
+++ b/providers/capsule/connector.go
@@ -6,9 +6,11 @@ import (
 	"github.com/amp-labs/connectors/common"
 	"github.com/amp-labs/connectors/common/interpreter"
 	"github.com/amp-labs/connectors/internal/components"
+	"github.com/amp-labs/connectors/internal/components/deleter"
 	"github.com/amp-labs/connectors/internal/components/operations"
 	"github.com/amp-labs/connectors/internal/components/reader"
 	"github.com/amp-labs/connectors/internal/components/schema"
+	"github.com/amp-labs/connectors/internal/components/writer"
 	"github.com/amp-labs/connectors/providers"
 	"github.com/amp-labs/connectors/providers/capsule/metadata"
 )
@@ -23,6 +25,8 @@ type Connector struct {
 	// Supported operations
 	components.SchemaProvider
 	components.Reader
+	components.Writer
+	components.Deleter
 }
 
 func NewConnector(params common.Parameters) (*Connector, error) {
@@ -41,7 +45,7 @@ func constructor(base *components.Connector) (*Connector, error) {
 	}
 
 	errorHandler := interpreter.ErrorHandler{
-		JSON: interpreter.NewFaultyResponder(errorFormats, nil),
+		JSON: interpreter.NewFaultyResponder(errorFormats, statusCodeMapping),
 	}.Handle
 
 	connector.SchemaProvider = schema.NewOpenAPISchemaProvider(connector.ProviderContext.Module(), metadata.Schemas)
@@ -57,5 +61,28 @@ func constructor(base *components.Connector) (*Connector, error) {
 		},
 	)
 
+	connector.Writer = writer.NewHTTPWriter(
+		connector.HTTPClient().Client,
+		registry,
+		connector.ProviderContext.Module(),
+		operations.WriteHandlers{
+			BuildRequest:  connector.buildWriteRequest,
+			ParseResponse: connector.parseWriteResponse,
+			ErrorHandler:  errorHandler,
+		},
+	)
+
+	// Set the deleter for the connector
+	connector.Deleter = deleter.NewHTTPDeleter(
+		connector.HTTPClient().Client,
+		registry,
+		connector.ProviderContext.Module(),
+		operations.DeleteHandlers{
+			BuildRequest:  connector.buildDeleteRequest,
+			ParseResponse: connector.parseDeleteResponse,
+			ErrorHandler:  errorHandler,
+		},
+	)
+
 	return connector, nil
 }
diff --git a/providers/capsule/errors.go b/providers/capsule/errors.go
index 2be4cde8..88b50dad 100644
--- a/providers/capsule/errors.go
+++ b/providers/capsule/errors.go
@@ -2,7 +2,10 @@ package capsule
 
 import (
 	"fmt"
+	"net/http"
+	"strings"
 
+	"github.com/amp-labs/connectors/common"
 	"github.com/amp-labs/connectors/common/interpreter"
 )
 
@@ -17,6 +20,11 @@ var errorFormats = interpreter.NewFormatSwitch( // nolint:gochecknoglobals
 
 type ResponseMessageError struct {
 	Message string `json:"message"`
+	Errors  []struct {
+		Message  string `json:"message"`
+		Resource string `json:"resource"`
+		Field    string `json:"field"`
+	} `json:"errors"`
 }
 
 func (r ResponseMessageError) CombineErr(base error) error {
@@ -24,5 +32,19 @@ func (r ResponseMessageError) CombineErr(base error) error {
 		return base
 	}
 
-	return fmt.Errorf("%w: %v", base, r.Message)
+	messages := make([]string, len(r.Errors))
+	for index, subErr := range r.Errors {
+		messages[index] = subErr.Message
+	}
+
+	var description string
+	if len(messages) != 0 {
+		description = fmt.Sprintf(" (%v)", strings.Join(messages, ","))
+	}
+
+	return fmt.Errorf("%w: %v%v", base, r.Message, description)
+}
+
+var statusCodeMapping = map[int]error{ // nolint:gochecknoglobals
+	http.StatusUnprocessableEntity: common.ErrBadRequest,
 }
diff --git a/providers/capsule/handlers.go b/providers/capsule/handlers.go
index 7d7572db..739f01ad 100644
--- a/providers/capsule/handlers.go
+++ b/providers/capsule/handlers.go
@@ -1,7 +1,10 @@
 package capsule
 
 import (
+	"bytes"
 	"context"
+	"encoding/json"
+	"fmt"
 	"net/http"
 	"strings"
 
@@ -9,6 +12,7 @@ import (
 	"github.com/amp-labs/connectors/common/urlbuilder"
 	"github.com/amp-labs/connectors/internal/datautils"
 	"github.com/amp-labs/connectors/internal/httpkit"
+	"github.com/amp-labs/connectors/internal/jsonquery"
 	"github.com/amp-labs/connectors/providers/capsule/metadata"
 	"github.com/spyzhov/ajson"
 )
@@ -75,3 +79,101 @@ func makeNextRecordsURL(resp *common.JSONHTTPResponse) common.NextPageFunc {
 		return httpkit.HeaderLink(resp, "next"), nil
 	}
 }
+
+func (c *Connector) buildWriteRequest(ctx context.Context, params common.WriteParams) (*http.Request, error) {
+	url, err := c.getWriteURL(params.ObjectName, params.RecordId)
+	if err != nil {
+		return nil, err
+	}
+
+	method := http.MethodPost
+	if len(params.RecordId) != 0 {
+		method = http.MethodPut
+	}
+
+	recordData, err := common.RecordDataToMap(params.RecordData)
+	if err != nil {
+		return nil, err
+	}
+
+	// Wrap user payload in the named object required by the API.
+	nestedKey := nestedWriteObject(params.ObjectName)
+	payload := map[string]any{
+		nestedKey: recordData,
+	}
+
+	jsonData, err := json.Marshal(payload)
+	if err != nil {
+		return nil, fmt.Errorf("failed to marshal record data: %w", err)
+	}
+
+	req, err := http.NewRequestWithContext(ctx, method, url.String(), bytes.NewReader(jsonData))
+	if err != nil {
+		return nil, fmt.Errorf("failed to create request: %w", err)
+	}
+
+	return req, nil
+}
+
+func (c *Connector) parseWriteResponse(ctx context.Context, params common.WriteParams,
+	request *http.Request, response *common.JSONHTTPResponse,
+) (*common.WriteResult, error) {
+	body, ok := response.Body()
+	if !ok {
+		// it is unlikely to have no payload
+		return &common.WriteResult{
+			Success: true,
+		}, nil
+	}
+
+	nestedKey := nestedWriteObject(params.ObjectName)
+
+	nested, err := jsonquery.New(body).ObjectRequired(nestedKey)
+	if err != nil {
+		return nil, err
+	}
+
+	recordID, err := jsonquery.New(nested).TextWithDefault("id", params.RecordId)
+	if err != nil {
+		return nil, err
+	}
+
+	data, err := jsonquery.Convertor.ObjectToMap(nested)
+	if err != nil {
+		return nil, err
+	}
+
+	return &common.WriteResult{
+		Success:  true,
+		RecordId: recordID,
+		Errors:   nil,
+		Data:     data,
+	}, nil
+}
+
+func (c *Connector) buildDeleteRequest(ctx context.Context, params common.DeleteParams) (*http.Request, error) {
+	url, err := c.getDeleteURL(params.ObjectName, params.RecordId)
+	if err != nil {
+		return nil, err
+	}
+
+	req, err := http.NewRequestWithContext(ctx, http.MethodDelete, url.String(), nil)
+	if err != nil {
+		return nil, fmt.Errorf("failed to create request: %w", err)
+	}
+
+	return req, nil
+}
+
+func (c *Connector) parseDeleteResponse(ctx context.Context, params common.DeleteParams,
+	request *http.Request, response *common.JSONHTTPResponse,
+) (*common.DeleteResult, error) {
+	if response.Code != http.StatusOK && response.Code != http.StatusNoContent {
+		return nil, fmt.Errorf("%w: failed to delete record: %d", common.ErrRequestFailed, response.Code)
+	}
+
+	// Response body is not used.
+	return &common.DeleteResult{
+		Success: true,
+	}, nil
+}
diff --git a/providers/capsule/objects.go b/providers/capsule/objects.go
new file mode 100644
index 00000000..f12607b9
--- /dev/null
+++ b/providers/capsule/objects.go
@@ -0,0 +1,51 @@
+package capsule
+
+import (
+	"github.com/amp-labs/connectors/common"
+	"github.com/amp-labs/connectors/common/naming"
+	"github.com/amp-labs/connectors/internal/datautils"
+)
+
+const objectNameProjects = "projects"
+
+var supportedObjectsByCreate = map[common.ModuleID]datautils.StringSet{ // nolint:gochecknoglobals
+	common.ModuleRoot: datautils.NewSet(
+		"activitytypes", "boards", "categories",
+		"kases", objectNameProjects, "lostreasons", "milestones", "opportunities",
+		"parties", "pipelines", "stages", "tasks",
+		"titles", "trackdefinitions",
+	),
+}
+
+var supportedObjectsByUpdate = map[common.ModuleID]datautils.StringSet{ // nolint:gochecknoglobals
+	common.ModuleRoot: datautils.NewSet(
+		"activitytypes", "boards", "categories",
+		"kases", objectNameProjects, "lostreasons", "milestones", "opportunities",
+		"parties", "pipelines", "stages", "tasks",
+		"trackdefinitions", "users",
+	),
+}
+
+var supportedObjectsByDelete = map[common.ModuleID]datautils.StringSet{ // nolint:gochecknoglobals
+	common.ModuleRoot: datautils.NewSet(
+		"activitytypes", "boards", "categories",
+		"kases", objectNameProjects, "lostreasons", "milestones", "opportunities",
+		"parties", "pipelines", "stages", "tasks",
+		"titles", "trackdefinitions",
+	),
+}
+
+func nestedWriteObject(objectName string) string {
+	switch objectName {
+	case "activitytypes":
+		return "activityType"
+	case objectNameProjects:
+		return "kase"
+	case "titles":
+		return "personTitle"
+	case "trackdefinitions":
+		return "trackDefinition"
+	default:
+		return naming.NewSingularString(objectName).String()
+	}
+}
diff --git a/providers/capsule/supports.go b/providers/capsule/supports.go
index 2484d7ca..10460c94 100644
--- a/providers/capsule/supports.go
+++ b/providers/capsule/supports.go
@@ -6,17 +6,29 @@ import (
 
 	"github.com/amp-labs/connectors/common"
 	"github.com/amp-labs/connectors/internal/components"
+	"github.com/amp-labs/connectors/internal/datautils"
 	"github.com/amp-labs/connectors/providers/capsule/metadata"
 )
 
 // How to read & build these patterns: https://github.com/gobwas/glob
 func supportedOperations() components.EndpointRegistryInput {
 	readSupport := metadata.Schemas.ObjectNames().GetList(common.ModuleRoot)
+	writeSupport := datautils.MergeUniqueLists(
+		supportedObjectsByCreate,
+		supportedObjectsByUpdate,
+	).GetList(common.ModuleRoot)
+	deleteSupport := supportedObjectsByDelete[common.ModuleRoot].List()
 
 	return components.EndpointRegistryInput{
 		common.ModuleRoot: {{
 			Endpoint: fmt.Sprintf("{%s}", strings.Join(readSupport, ",")),
 			Support:  components.ReadSupport,
+		}, {
+			Endpoint: fmt.Sprintf("{%s}", strings.Join(writeSupport, ",")),
+			Support:  components.WriteSupport,
+		}, {
+			Endpoint: fmt.Sprintf("{%s}", strings.Join(deleteSupport, ",")),
+			Support:  components.DeleteSupport,
 		}},
 	}
 }
diff --git a/providers/capsule/url.go b/providers/capsule/url.go
index ab9dd3f4..56486127 100644
--- a/providers/capsule/url.go
+++ b/providers/capsule/url.go
@@ -15,3 +15,23 @@ func (c *Connector) getReadURL(objectName string) (*urlbuilder.URL, error) {
 
 	return urlbuilder.New(c.ProviderInfo().BaseURL, apiVersion, path)
 }
+
+func (c *Connector) getWriteURL(objectName string, id string) (*urlbuilder.URL, error) {
+	if objectName == objectNameProjects {
+		objectName = "kases"
+	}
+
+	if len(id) == 0 {
+		return urlbuilder.New(c.ProviderInfo().BaseURL, apiVersion, objectName)
+	}
+
+	return urlbuilder.New(c.ProviderInfo().BaseURL, apiVersion, objectName, id)
+}
+
+func (c *Connector) getDeleteURL(objectName string, id string) (*urlbuilder.URL, error) {
+	if objectName == objectNameProjects {
+		objectName = "kases"
+	}
+
+	return urlbuilder.New(c.ProviderInfo().BaseURL, apiVersion, objectName, id)
+}


Test patch:
diff --git a/providers/capsule/delete_test.go b/providers/capsule/delete_test.go
new file mode 100644
index 00000000..46695483
--- /dev/null
+++ b/providers/capsule/delete_test.go
@@ -0,0 +1,63 @@
+package capsule
+
+import (
+	"net/http"
+	"testing"
+
+	"github.com/amp-labs/connectors"
+	"github.com/amp-labs/connectors/common"
+	"github.com/amp-labs/connectors/test/utils/mockutils/mockcond"
+	"github.com/amp-labs/connectors/test/utils/mockutils/mockserver"
+	"github.com/amp-labs/connectors/test/utils/testroutines"
+)
+
+func TestDelete(t *testing.T) { // nolint:funlen,cyclop
+	t.Parallel()
+
+	tests := []testroutines.Delete{
+		{
+			Name:         "Delete object must be included",
+			Server:       mockserver.Dummy(),
+			ExpectedErrs: []error{common.ErrMissingObjects},
+		},
+		{
+			Name:         "Write object and its ID must be included",
+			Input:        common.DeleteParams{ObjectName: "order"},
+			Server:       mockserver.Dummy(),
+			ExpectedErrs: []error{common.ErrMissingRecordID},
+		},
+		{
+			Name:  "Remove Party",
+			Input: common.DeleteParams{ObjectName: "kases", RecordId: "5271516"},
+			Server: mockserver.Conditional{
+				Setup: mockserver.ContentJSON(),
+				If:    mockcond.MethodDELETE(),
+				Then:  mockserver.Response(http.StatusNoContent),
+			}.Server(),
+			Expected:     &common.DeleteResult{Success: true},
+			ExpectedErrs: nil,
+		},
+		{
+			Name:  "Remove Task",
+			Input: common.DeleteParams{ObjectName: "tasks", RecordId: "147248893"},
+			Server: mockserver.Conditional{
+				Setup: mockserver.ContentJSON(),
+				If:    mockcond.MethodDELETE(),
+				Then:  mockserver.Response(http.StatusNoContent),
+			}.Server(),
+			Expected:     &common.DeleteResult{Success: true},
+			ExpectedErrs: nil,
+		},
+	}
+
+	for _, tt := range tests { // nolint:dupl
+		// nolint:varnamelen
+		t.Run(tt.Name, func(t *testing.T) {
+			t.Parallel()
+
+			tt.Run(t, func() (connectors.DeleteConnector, error) {
+				return constructTestConnector(tt.Server.URL)
+			})
+		})
+	}
+}
diff --git a/providers/capsule/test/write/method-not-allowed.json b/providers/capsule/test/write/method-not-allowed.json
new file mode 100644
index 00000000..d6629c89
--- /dev/null
+++ b/providers/capsule/test/write/method-not-allowed.json
@@ -0,0 +1,3 @@
+{
+  "message": "Method not allowed"
+}
diff --git a/providers/capsule/test/write/project/new.json b/providers/capsule/test/write/project/new.json
new file mode 100644
index 00000000..633b46f0
--- /dev/null
+++ b/providers/capsule/test/write/project/new.json
@@ -0,0 +1,32 @@
+{
+  "kase": {
+    "id": 5271516,
+    "owner": {
+      "id": 674197,
+      "deleted": false,
+      "username": "integration.test",
+      "pictureURL": "https://facehub.appspot.com/default/person?text=I&size=100&colour=2099955006",
+      "name": "integration.tes1907"
+    },
+    "team": null,
+    "party": {
+      "id": 254633973,
+      "type": "organisation",
+      "name": "Capsule",
+      "pictureURL": "https://capsule-pictures-prod.s3.amazonaws.com/6a991dea-8623-4cef-9f3f-b5ff25509d81?size=100&v=0"
+    },
+    "status": "OPEN",
+    "opportunity": null,
+    "stage": {
+      "name": "Project Brief",
+      "id": 104607
+    },
+    "createdAt": "2025-03-07T23:32:00Z",
+    "updatedAt": "2025-03-07T23:32:00Z",
+    "description": "Project focused on business research",
+    "name": "Research",
+    "closedOn": null,
+    "lastContactedAt": null,
+    "expectedCloseOn": "2015-12-09"
+  }
+}
diff --git a/providers/capsule/test/write/task/new.json b/providers/capsule/test/write/task/new.json
new file mode 100644
index 00000000..a3e4b747
--- /dev/null
+++ b/providers/capsule/test/write/task/new.json
@@ -0,0 +1,33 @@
+{
+  "task": {
+    "id": 147248893,
+    "category": null,
+    "description": "Brand new task from postman",
+    "detail": null,
+    "dueTime": null,
+    "status": "OPEN",
+    "party": null,
+    "opportunity": null,
+    "owner": {
+      "id": 674197,
+      "deleted": false,
+      "username": "integration.test",
+      "pictureURL": "https://facehub.appspot.com/default/person?text=I&size=100&colour=2099955006",
+      "name": "integration.tes1907"
+    },
+    "daysAfter": null,
+    "taskDelayRule": null,
+    "nextTask": null,
+    "active": true,
+    "createdAt": "2025-04-24T21:00:49Z",
+    "updatedAt": "2025-04-24T21:00:49Z",
+    "dueOn": "2025-05-20",
+    "completedBy": null,
+    "completedAt": null,
+    "kase": null,
+    "taskDayDelayRule": "TRACK_DAYS",
+    "overrideToCurrentUser": null,
+    "repeat": null,
+    "hasTrack": false
+  }
+}
diff --git a/providers/capsule/test/write/unprocessable-payload.json b/providers/capsule/test/write/unprocessable-payload.json
new file mode 100644
index 00000000..0d499b2a
--- /dev/null
+++ b/providers/capsule/test/write/unprocessable-payload.json
@@ -0,0 +1,10 @@
+{
+  "message": "Validation Failed",
+  "errors": [
+    {
+      "message": "dueOn is required",
+      "resource": "task",
+      "field": "dueOn"
+    }
+  ]
+}
diff --git a/providers/capsule/write_test.go b/providers/capsule/write_test.go
new file mode 100644
index 00000000..4581bd99
--- /dev/null
+++ b/providers/capsule/write_test.go
@@ -0,0 +1,183 @@
+package capsule
+
+import (
+	"errors"
+	"net/http"
+	"testing"
+
+	"github.com/amp-labs/connectors"
+	"github.com/amp-labs/connectors/common"
+	"github.com/amp-labs/connectors/test/utils/mockutils/mockcond"
+	"github.com/amp-labs/connectors/test/utils/mockutils/mockserver"
+	"github.com/amp-labs/connectors/test/utils/testroutines"
+	"github.com/amp-labs/connectors/test/utils/testutils"
+)
+
+func TestWrite(t *testing.T) { // nolint:funlen,gocognit,cyclop
+	t.Parallel()
+
+	responseUnprocessablePayload := testutils.DataFromFile(t, "write/unprocessable-payload.json")
+	responseMethodNotAllowedPayload := testutils.DataFromFile(t, "write/method-not-allowed.json")
+	responseTask := testutils.DataFromFile(t, "write/task/new.json")
+	responseProject := testutils.DataFromFile(t, "write/project/new.json")
+
+	tests := []testroutines.Write{
+		{
+			Name:         "Write object must be included",
+			Server:       mockserver.Dummy(),
+			ExpectedErrs: []error{common.ErrMissingObjects},
+		},
+		{
+			Name:  "Error invalid payload",
+			Input: common.WriteParams{ObjectName: "tasks", RecordData: map[string]any{}},
+			Server: mockserver.Fixed{
+				Setup:  mockserver.ContentJSON(),
+				Always: mockserver.Response(http.StatusUnprocessableEntity, responseUnprocessablePayload),
+			}.Server(),
+			ExpectedErrs: []error{
+				common.ErrBadRequest,
+				errors.New("Validation Failed (dueOn is required)"), // nolint:goerr113
+			},
+		},
+		{
+			Name:  "Error wrong http verb",
+			Input: common.WriteParams{ObjectName: "tasks", RecordData: map[string]any{}},
+			Server: mockserver.Fixed{
+				Setup:  mockserver.ContentJSON(),
+				Always: mockserver.Response(http.StatusMethodNotAllowed, responseMethodNotAllowedPayload),
+			}.Server(),
+			ExpectedErrs: []error{
+				common.ErrBadRequest,
+				errors.New("Method not allowed"), // nolint:goerr113
+			},
+		},
+		{
+			Name: "Create task via POST",
+			Input: common.WriteParams{
+				ObjectName: "tasks",
+				RecordData: map[string]any{
+					"dueOn": "2025-05-20",
+				},
+			},
+			Server: mockserver.Conditional{
+				Setup: mockserver.ContentJSON(),
+				If: mockcond.And{
+					mockcond.MethodPOST(),
+					mockcond.PathSuffix("/v2/tasks"),
+					mockcond.Body(`{"task": {"dueOn" : "2025-05-20"}}`),
+				},
+				Then: mockserver.Response(http.StatusOK, responseTask),
+			}.Server(),
+			Comparator: testroutines.ComparatorSubsetWrite,
+			Expected: &common.WriteResult{
+				Success:  true,
+				RecordId: "147248893",
+				Errors:   nil,
+				Data: map[string]any{
+					"description":      "Brand new task from postman",
+					"dueOn":            "2025-05-20",
+					"taskDayDelayRule": "TRACK_DAYS",
+				},
+			},
+			ExpectedErrs: nil,
+		},
+		{
+			Name: "Update task via PUT",
+			Input: common.WriteParams{
+				ObjectName: "tasks",
+				RecordId:   "147248893",
+				RecordData: map[string]any{
+					"dueOn": "2025-05-20",
+				},
+			},
+			Server: mockserver.Conditional{
+				Setup: mockserver.ContentJSON(),
+				If: mockcond.And{
+					mockcond.MethodPUT(),
+					mockcond.PathSuffix("/v2/tasks/147248893"),
+					mockcond.Body(`{"task": {"dueOn" : "2025-05-20"}}`),
+				},
+				Then: mockserver.Response(http.StatusOK, responseTask),
+			}.Server(),
+			Comparator: testroutines.ComparatorSubsetWrite,
+			Expected: &common.WriteResult{
+				Success:  true,
+				RecordId: "147248893",
+				Errors:   nil,
+				Data: map[string]any{
+					"description": "Brand new task from postman",
+				},
+			},
+			ExpectedErrs: nil,
+		},
+		{
+			Name: "Create project via POST",
+			Input: common.WriteParams{
+				ObjectName: "kases",
+				RecordData: map[string]any{
+					"name": "Research",
+				},
+			},
+			Server: mockserver.Conditional{
+				Setup: mockserver.ContentJSON(),
+				If: mockcond.And{
+					mockcond.MethodPOST(),
+					mockcond.PathSuffix("/v2/kases"),
+					mockcond.Body(`{"kase": {"name" : "Research"}}`),
+				},
+				Then: mockserver.Response(http.StatusOK, responseProject),
+			}.Server(),
+			Comparator: testroutines.ComparatorSubsetWrite,
+			Expected: &common.WriteResult{
+				Success:  true,
+				RecordId: "5271516",
+				Errors:   nil,
+				Data: map[string]any{
+					"name":        "Research",
+					"description": "Project focused on business research",
+				},
+			},
+			ExpectedErrs: nil,
+		},
+		{
+			Name: "Update project via PUT",
+			Input: common.WriteParams{
+				ObjectName: "kases",
+				RecordData: map[string]any{
+					"name": "Research",
+				},
+				RecordId: "5271516",
+			},
+			Server: mockserver.Conditional{
+				Setup: mockserver.ContentJSON(),
+				If: mockcond.And{
+					mockcond.MethodPUT(),
+					mockcond.PathSuffix("/v2/kases/5271516"),
+					mockcond.Body(`{"kase": {"name" : "Research"}}`),
+				},
+				Then: mockserver.Response(http.StatusOK, responseProject),
+			}.Server(),
+			Comparator: testroutines.ComparatorSubsetWrite,
+			Expected: &common.WriteResult{
+				Success:  true,
+				RecordId: "5271516",
+				Errors:   nil,
+				Data: map[string]any{
+					"name":        "Research",
+					"description": "Project focused on business research",
+				},
+			},
+			ExpectedErrs: nil,
+		},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.Name, func(t *testing.T) {
+			t.Parallel()
+
+			tt.Run(t, func() (connectors.WriteConnector, error) {
+				return constructTestConnector(tt.Server.URL)
+			})
+		})
+	}
+}
diff --git a/test/capsule/write-delete/projects/main.go b/test/capsule/write-delete/projects/main.go
new file mode 100644
index 00000000..2ae0a0d9
--- /dev/null
+++ b/test/capsule/write-delete/projects/main.go
@@ -0,0 +1,65 @@
+package main
+
+import (
+	"context"
+	"os/signal"
+	"syscall"
+
+	"github.com/amp-labs/connectors/internal/datautils"
+	connTest "github.com/amp-labs/connectors/test/capsule"
+	"github.com/amp-labs/connectors/test/utils"
+	"github.com/amp-labs/connectors/test/utils/testscenario"
+	"github.com/brianvoe/gofakeit/v6"
+)
+
+type Payload struct {
+	Party       Party  `json:"party"`
+	Name        string `json:"name"`
+	Description string `json:"description"`
+}
+
+type Party struct {
+	ID int `json:"id"`
+}
+
+func main() {
+	// Handle Ctrl-C gracefully.
+	ctx, done := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
+	defer done()
+
+	// Set up slog logging.
+	utils.SetupLogging()
+
+	conn := connTest.GetCapsuleConnector(ctx)
+
+	name := gofakeit.Name()
+	description := gofakeit.Name()
+	updatedName := gofakeit.Name()
+	updatedDescription := gofakeit.Name()
+
+	testscenario.ValidateCreateUpdateDelete(ctx, conn,
+		"projects",
+		Payload{
+			Party:       Party{ID: 254633973},
+			Name:        name,
+			Description: description,
+		},
+		Payload{
+			Party:       Party{ID: 254633973},
+			Name:        updatedName,
+			Description: updatedDescription,
+		},
+		testscenario.CRUDTestSuite{
+			ReadFields: datautils.NewSet("id", "name", "description"),
+			SearchBy: testscenario.Property{
+				Key:   "name",
+				Value: name,
+			},
+			RecordIdentifierKey: "id",
+			UpdatedFields: map[string]string{
+				"name":        updatedName,
+				"description": updatedDescription,
+			},
+		},
+	)
+}
diff --git a/test/capsule/write-delete/tasks/main.go b/test/capsule/write-delete/tasks/main.go
new file mode 100644
index 00000000..c59930dd
--- /dev/null
+++ b/test/capsule/write-delete/tasks/main.go
@@ -0,0 +1,62 @@
+package main
+
+import (
+	"context"
+	"os/signal"
+	"syscall"
+
+	"github.com/amp-labs/connectors/internal/datautils"
+	connTest "github.com/amp-labs/connectors/test/capsule"
+	"github.com/amp-labs/connectors/test/utils"
+	"github.com/amp-labs/connectors/test/utils/testscenario"
+	"github.com/brianvoe/gofakeit/v6"
+)
+
+type Payload struct {
+	Party       Party  `json:"party"`
+	Description string `json:"description"`
+	DueOn       string `json:"dueOn"`
+}
+
+type Party struct {
+	ID int `json:"id"`
+}
+
+func main() {
+	// Handle Ctrl-C gracefully.
+	ctx, done := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
+	defer done()
+
+	// Set up slog logging.
+	utils.SetupLogging()
+
+	conn := connTest.GetCapsuleConnector(ctx)
+
+	description := gofakeit.Name()
+	updatedDescription := gofakeit.Name()
+
+	testscenario.ValidateCreateUpdateDelete(ctx, conn,
+		"tasks",
+		Payload{
+			Party:       Party{ID: 254633973},
+			Description: description,
+			DueOn:       "2025-05-20",
+		},
+		Payload{
+			Party:       Party{ID: 254633973},
+			Description: updatedDescription,
+			DueOn:       "2025-05-20",
+		},
+		testscenario.CRUDTestSuite{
+			ReadFields: datautils.NewSet("id", "description"),
+			SearchBy: testscenario.Property{
+				Key:   "description",
+				Value: description,
+			},
+			RecordIdentifierKey: "id",
+			UpdatedFields: map[string]string{
+				"description": updatedDescription,
+			},
+		},
+	)
+}
diff --git a/test/utils/testroutines/delete.go b/test/utils/testroutines/delete.go
index 7a708ffb..71eda296 100644
--- a/test/utils/testroutines/delete.go
+++ b/test/utils/testroutines/delete.go
@@ -17,6 +17,10 @@ type (
 // Run provides a procedure to test connectors.DeleteConnector
 func (d Delete) Run(t *testing.T, builder ConnectorBuilder[connectors.DeleteConnector]) {
 	t.Helper()
+	t.Cleanup(func() {
+		DeleteType(d).Close()
+	})
+
 	conn := builder.Build(t, d.Name)
 	output, err := conn.Delete(context.Background(), d.Input)
 	DeleteType(d).Validate(t, err, output)
diff --git a/test/utils/testroutines/metadata.go b/test/utils/testroutines/metadata.go
index 83a57af6..16ca2a3d 100644
--- a/test/utils/testroutines/metadata.go
+++ b/test/utils/testroutines/metadata.go
@@ -17,6 +17,10 @@ type (
 // Run provides a procedure to test connectors.ObjectMetadataConnector
 func (m Metadata) Run(t *testing.T, builder ConnectorBuilder[connectors.ObjectMetadataConnector]) {
 	t.Helper()
+	t.Cleanup(func() {
+		MetadataType(m).Close()
+	})
+
 	conn := builder.Build(t, m.Name)
 	output, err := conn.ListObjectMetadata(context.Background(), m.Input)
 	MetadataType(m).Validate(t, err, output)
diff --git a/test/utils/testroutines/outline.go b/test/utils/testroutines/outline.go
index 88297854..dc1c047c 100644
--- a/test/utils/testroutines/outline.go
+++ b/test/utils/testroutines/outline.go
@@ -27,32 +27,34 @@ type TestCase[Input any, Output any] struct {
 	ExpectedErrs []error
 }
 
-// Validate checks if supplied input conforms to the test intention.
-func (o TestCase[Input, Output]) Validate(t *testing.T, err error, output Output) {
-	defer o.Server.Close()
+func (c TestCase[Input, Output]) Close() {
+	c.Server.Close()
+}
 
+// Validate checks if supplied input conforms to the test intention.
+func (c TestCase[Input, Output]) Validate(t *testing.T, err error, output Output) {
 	// performs validation of error output using described test suite outline.
-	o.checkError(t, err)
+	c.checkError(t, err)
 	// performs validation of data output using described test suite outline.
-	o.checkValue(t, output)
+	c.checkValue(t, output)
 }
 
-func (o TestCase[Input, Output]) checkError(t *testing.T, err error) {
-	testutils.CheckErrors(t, o.Name, o.ExpectedErrs, err)
+func (c TestCase[Input, Output]) checkError(t *testing.T, err error) {
+	testutils.CheckErrors(t, c.Name, c.ExpectedErrs, err)
 }
 
-func (o TestCase[Input, Output]) checkValue(t *testing.T, output Output) {
+func (c TestCase[Input, Output]) checkValue(t *testing.T, output Output) {
 	// compare desired output
 	var ok bool
-	if o.Comparator == nil {
+	if c.Comparator == nil {
 		// default comparison is concerned about all fields
-		ok = reflect.DeepEqual(output, o.Expected)
+		ok = reflect.DeepEqual(output, c.Expected)
 	} else {
-		ok = o.Comparator(o.Server.URL, output, o.Expected)
+		ok = c.Comparator(c.Server.URL, output, c.Expected)
 	}
 
 	if !ok {
-		diff := deep.Equal(output, o.Expected)
-		t.Fatalf("%s:, \nexpected: (%v), \ngot: (%v), \ndiff: (%v)", o.Name, o.Expected, output, diff)
+		diff := deep.Equal(output, c.Expected)
+		t.Fatalf("%s:, \nexpected: (%v), \ngot: (%v), \ndiff: (%v)", c.Name, c.Expected, output, diff)
 	}
 }
diff --git a/test/utils/testroutines/read.go b/test/utils/testroutines/read.go
index 0a1a334f..c7503dd5 100644
--- a/test/utils/testroutines/read.go
+++ b/test/utils/testroutines/read.go
@@ -17,6 +17,10 @@ type (
 // Run provides a procedure to test connectors.ReadConnector
 func (r Read) Run(t *testing.T, builder ConnectorBuilder[connectors.ReadConnector]) {
 	t.Helper()
+	t.Cleanup(func() {
+		ReadType(r).Close()
+	})
+
 	conn := builder.Build(t, r.Name)
 	readParams := prepareReadParams(r.Server.URL, r.Input)
 	output, err := conn.Read(context.Background(), readParams)
diff --git a/test/utils/testroutines/write.go b/test/utils/testroutines/write.go
index ece342fc..7391d401 100644
--- a/test/utils/testroutines/write.go
+++ b/test/utils/testroutines/write.go
@@ -17,6 +17,10 @@ type (
 // Run provides a procedure to test connectors.WriteConnector
 func (w Write) Run(t *testing.T, builder ConnectorBuilder[connectors.WriteConnector]) {
 	t.Helper()
+	t.Cleanup(func() {
+		WriteType(w).Close()
+	})
+
 	conn := builder.Build(t, w.Name)
 	output, err := conn.Write(context.Background(), w.Input)
 	WriteType(w).Validate(t, err, output)