Repository: amp-labs/connectors
Pull request #1337 (2025-02-07 00:00:00)

[CON-617] feat(google): Calendar - Write
- [x] Connector uses `internal/components`
- [x] Raw response is returned as is, no formatting or flattening is performed.
- [x] Write payloads should accept what `ReadResults.Fields` is returning. Any unnecessary nesting around the input is removed.
- [ ] Provider errors are mapped if non-standard (errors with 200 response code are converted to 4XX)
- [ ] Custom fields, if not human readable names, are resolved to readable names.
- [x] Unit tests cover write logic (placed in /tests/<provider>)
- [x] Appropriate object names are used. Objects need to be resources, not actions (`jobs` and not `jobs.list`).

# Live Tests
## calendarList
<img width="608" height="247" alt="image" src="https://github.com/user-attachments/assets/1d65062a-d3c0-4bc1-ac88-b65ded866568" />

## events
<img width="443" height="253" alt="image" src="https://github.com/user-attachments/assets/d0e1d156-b289-4f09-86ee-cf61c7a4ce59" />



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/1337?utm_source=stack-comment-downstack-mergeability-warning" >on Graphite</a>.</b>
> <a href="https://graphite.dev/docs/merge-pull-requests">Learn more</a>

* **#1337** <a href="https://app.graphite.dev/github/pr/amp-labs/connectors/1337?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/1337?utm_source=stack-comment-view-in-graphite" target="_blank">(View in Graphite)</a>
* **#1336** <a href="https://app.graphite.dev/github/pr/amp-labs/connectors/1336?utm_source=stack-comment-icon" target="_blank"><img src="https://static.graphite.dev/graphite-32x32-black.png" alt="Graphite" width="10px" height="10px"/></a>
* **#1316** <a href="https://app.graphite.dev/github/pr/amp-labs/connectors/1316?utm_source=stack-comment-icon" target="_blank"><img src="https://static.graphite.dev/graphite-32x32-black.png" alt="Graphite" width="10px" height="10px"/></a>: 1 other dependent PR ([#1318](https://github.com/amp-labs/connectors/pull/1318) <a href="https://app.graphite.dev/github/pr/amp-labs/connectors/1318?utm_source=stack-comment-icon" target="_blank"><img src="https://static.graphite.dev/graphite-32x32-black.png" alt="Graphite" width="10px" height="10px"/></a>)
* **#1315** <a href="https://app.graphite.dev/github/pr/amp-labs/connectors/1315?utm_source=stack-comment-icon" target="_blank"><img src="https://static.graphite.dev/graphite-32x32-black.png" alt="Graphite" width="10px" height="10px"/></a>
* **#1314** <a href="https://app.graphite.dev/github/pr/amp-labs/connectors/1314?utm_source=stack-comment-icon" target="_blank"><img src="https://static.graphite.dev/graphite-32x32-black.png" alt="Graphite" width="10px" height="10px"/></a>
* **#1313** <a href="https://app.graphite.dev/github/pr/amp-labs/connectors/1313?utm_source=stack-comment-icon" target="_blank"><img src="https://static.graphite.dev/graphite-32x32-black.png" alt="Graphite" width="10px" height="10px"/></a>: 1 other dependent PR ([#1331](https://github.com/amp-labs/connectors/pull/1331) <a href="https://app.graphite.dev/github/pr/amp-labs/connectors/1331?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/google/connector.go b/providers/google/connector.go
index 65d6e662..7ab1a4f8 100644
--- a/providers/google/connector.go
+++ b/providers/google/connector.go
@@ -65,3 +65,19 @@ func (c Connector) Read(ctx context.Context, params connectors.ReadParams) (*con
 
 	return nil, common.ErrNotImplemented
 }
+
+func (c Connector) Write(ctx context.Context, params connectors.WriteParams) (*connectors.WriteResult, error) {
+	if c.Calendar != nil {
+		return c.Calendar.Write(ctx, params)
+	}
+
+	return nil, common.ErrNotImplemented
+}
+
+func (c Connector) Delete(ctx context.Context, params connectors.DeleteParams) (*connectors.DeleteResult, error) {
+	if c.Calendar != nil {
+		return c.Calendar.Delete(ctx, params)
+	}
+
+	return nil, common.ErrNotImplemented
+}
diff --git a/providers/google/internal/calendar/adapter.go b/providers/google/internal/calendar/adapter.go
index a5f24154..000a4ac7 100644
--- a/providers/google/internal/calendar/adapter.go
+++ b/providers/google/internal/calendar/adapter.go
@@ -7,9 +7,11 @@ import (
 	"github.com/amp-labs/connectors/common/interpreter"
 	"github.com/amp-labs/connectors/common/urlbuilder"
 	"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"
 )
 
@@ -19,6 +21,8 @@ type Adapter struct {
 	*components.Connector
 	components.SchemaProvider
 	components.Reader
+	components.Writer
+	components.Deleter
 }
 
 func NewAdapter(params common.ConnectorParams) (*Adapter, error) {
@@ -48,6 +52,28 @@ func constructor(base *components.Connector) (*Adapter, error) {
 		},
 	)
 
+	adapter.Writer = writer.NewHTTPWriter(
+		adapter.HTTPClient().Client,
+		components.NewEmptyEndpointRegistry(),
+		adapter.ProviderContext.Module(),
+		operations.WriteHandlers{
+			BuildRequest:  adapter.buildWriteRequest,
+			ParseResponse: adapter.parseWriteResponse,
+			ErrorHandler:  errorHandler,
+		},
+	)
+
+	adapter.Deleter = deleter.NewHTTPDeleter(
+		adapter.HTTPClient().Client,
+		components.NewEmptyEndpointRegistry(),
+		adapter.ProviderContext.Module(),
+		operations.DeleteHandlers{
+			BuildRequest:  adapter.buildDeleteRequest,
+			ParseResponse: adapter.parseDeleteResponse,
+			ErrorHandler:  errorHandler,
+		},
+	)
+
 	return adapter, nil
 }
 
diff --git a/providers/google/internal/calendar/operations.go b/providers/google/internal/calendar/operations.go
index 6ae81877..edee3285 100644
--- a/providers/google/internal/calendar/operations.go
+++ b/providers/google/internal/calendar/operations.go
@@ -1,7 +1,10 @@
 package calendar
 
 import (
+	"bytes"
 	"context"
+	"encoding/json"
+	"fmt"
 	"net/http"
 	"strconv"
 
@@ -108,3 +111,116 @@ func makeNextRecordsURL(url *urlbuilder.URL) common.NextPageFunc {
 		return url.String(), nil
 	}
 }
+
+func (a *Adapter) buildWriteRequest(ctx context.Context, params common.WriteParams) (*http.Request, error) {
+	url, err := a.getURL(params.ObjectName)
+	if err != nil {
+		return nil, err
+	}
+
+	method := http.MethodPost
+	if len(params.RecordId) != 0 {
+		method = http.MethodPatch
+
+		url.AddPath(params.RecordId)
+	}
+
+	if params.ObjectName == objectNameCalendarList {
+		if needsCalendarColorQueryParam(params) {
+			url.WithQueryParam("colorRgbFormat", "true")
+		}
+	}
+
+	jsonData, err := json.Marshal(params.RecordData)
+	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
+}
+
+// When either background or foreground is specified this means
+// we must attach a query parameter for the Write request to succeed.
+//
+// https://developers.google.com/calendar/api/v3/reference/calendarList/insert
+func needsCalendarColorQueryParam(params common.WriteParams) bool {
+	properties, err := common.RecordDataToMap(params.RecordData)
+	if err != nil {
+		return false
+	}
+
+	triggerFields := []string{
+		"foregroundColor", "backgroundColor",
+	}
+
+	for _, field := range triggerFields {
+		if _, ok := properties[field]; ok {
+			return true
+		}
+	}
+
+	return false
+}
+
+func (a *Adapter) 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
+	}
+
+	recordID, err := jsonquery.New(body).StrWithDefault("id", "")
+	if err != nil {
+		return nil, err
+	}
+
+	data, err := jsonquery.Convertor.ObjectToMap(body)
+	if err != nil {
+		return nil, err
+	}
+
+	return &common.WriteResult{
+		Success:  true,
+		RecordId: recordID,
+		Errors:   nil,
+		Data:     data,
+	}, nil
+}
+
+func (a *Adapter) buildDeleteRequest(ctx context.Context, params common.DeleteParams) (*http.Request, error) {
+	url, err := a.getURL(params.ObjectName)
+	if err != nil {
+		return nil, err
+	}
+
+	url.AddPath(params.RecordId)
+
+	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 (a *Adapter) 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
+}


Test patch:
diff --git a/providers/google/delete_test.go b/providers/google/delete_test.go
new file mode 100644
index 00000000..3ca283b0
--- /dev/null
+++ b/providers/google/delete_test.go
@@ -0,0 +1,78 @@
+package google
+
+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 TestDelete(t *testing.T) { // nolint:funlen,cyclop
+	t.Parallel()
+
+	errorNotFound := testutils.DataFromFile(t, "delete/calendarList/not-found.json")
+
+	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: "calendarList"},
+			Server:       mockserver.Dummy(),
+			ExpectedErrs: []error{common.ErrMissingRecordID},
+		},
+		{
+			Name: "Successful delete",
+			Input: common.DeleteParams{
+				ObjectName: "calendarList",
+				RecordId:   "en.italian#holiday@group.v.calendar.google.com",
+			},
+			Server: mockserver.Conditional{
+				Setup: mockserver.ContentJSON(),
+				If: mockcond.And{
+					mockcond.MethodDELETE(),
+					mockcond.Path("/calendar/v3/users/me/calendarList/en.italian#holiday@group.v.calendar.google.com"), // nolint:lll
+				},
+				Then: mockserver.Response(http.StatusNoContent),
+			}.Server(),
+			Expected: &common.DeleteResult{Success: true},
+		},
+		{
+			Name: "Error on deleting missing record",
+			Input: common.DeleteParams{
+				ObjectName: "calendarList",
+				RecordId:   "en.italian#holiday@group.v.calendar.google.com",
+			},
+			Server: mockserver.Fixed{
+				Setup:  mockserver.ContentJSON(),
+				Always: mockserver.Response(http.StatusNotFound, errorNotFound),
+			}.Server(),
+			ExpectedErrs: []error{
+				common.ErrBadRequest,
+				errors.New( // nolint:goerr113
+					"Not Found",
+				),
+			},
+		},
+	}
+
+	for _, tt := range tests {
+		// nolint:varnamelen
+		t.Run(tt.Name, func(t *testing.T) {
+			t.Parallel()
+
+			tt.Run(t, func() (connectors.DeleteConnector, error) {
+				return constructTestCalendarConnector(tt.Server.URL)
+			})
+		})
+	}
+}
diff --git a/providers/google/test/delete/calendarList/not-found.json b/providers/google/test/delete/calendarList/not-found.json
new file mode 100644
index 00000000..e2f0bba6
--- /dev/null
+++ b/providers/google/test/delete/calendarList/not-found.json
@@ -0,0 +1,13 @@
+{
+  "error": {
+    "errors": [
+      {
+        "domain": "global",
+        "reason": "notFound",
+        "message": "Not Found"
+      }
+    ],
+    "code": 404,
+    "message": "Not Found"
+  }
+}
diff --git a/providers/google/test/write/calendarList/error-missing-color.json b/providers/google/test/write/calendarList/error-missing-color.json
new file mode 100644
index 00000000..fff226e7
--- /dev/null
+++ b/providers/google/test/write/calendarList/error-missing-color.json
@@ -0,0 +1,13 @@
+{
+  "error": {
+    "errors": [
+      {
+        "domain": "global",
+        "reason": "required",
+        "message": "Missing foreground color."
+      }
+    ],
+    "code": 400,
+    "message": "Missing foreground color."
+  }
+}
diff --git a/providers/google/test/write/calendarList/new.json b/providers/google/test/write/calendarList/new.json
new file mode 100644
index 00000000..f32eb6c8
--- /dev/null
+++ b/providers/google/test/write/calendarList/new.json
@@ -0,0 +1,18 @@
+{
+  "kind": "calendar#calendarListEntry",
+  "etag": "\"1738882575255000\"",
+  "id": "en.italian#holiday@group.v.calendar.google.com",
+  "summary": "Holidays in Italy",
+  "description": "Holidays and Observances in Italy",
+  "timeZone": "America/Los_Angeles",
+  "colorId": "7",
+  "backgroundColor": "#19f7f0",
+  "foregroundColor": "#ffffff",
+  "accessRole": "reader",
+  "defaultReminders": [],
+  "conferenceProperties": {
+    "allowedConferenceSolutionTypes": [
+      "hangoutsMeet"
+    ]
+  }
+}
diff --git a/providers/google/test/write/events/new.json b/providers/google/test/write/events/new.json
new file mode 100644
index 00000000..0d6c4904
--- /dev/null
+++ b/providers/google/test/write/events/new.json
@@ -0,0 +1,32 @@
+{
+  "kind": "calendar#event",
+  "etag": "\"3504368700651198\"",
+  "id": "std4ien2l4fovmbup80ov20tp8",
+  "status": "confirmed",
+  "htmlLink": "https://www.google.com/calendar/event?eid=c3RkNGllbjJsNGZvdm1idXA4MG92MjB0cDggaW50ZWdyYXRpb24udGVzdEB3aXRoYW1wZXJzYW5kLmNvbQ",
+  "created": "2025-07-10T21:52:30.000Z",
+  "updated": "2025-07-10T21:52:30.325Z",
+  "summary": "Monthly team meeting",
+  "creator": {
+    "email": "test@withampersand.com",
+    "self": true
+  },
+  "organizer": {
+    "email": "test@withampersand.com",
+    "self": true
+  },
+  "start": {
+    "dateTime": "2025-08-07T15:30:00-07:00",
+    "timeZone": "America/Los_Angeles"
+  },
+  "end": {
+    "dateTime": "2025-08-07T17:22:11-07:00",
+    "timeZone": "America/Los_Angeles"
+  },
+  "iCalUID": "std4ien2l4fovmbup80ov20tp8@google.com",
+  "sequence": 0,
+  "reminders": {
+    "useDefault": true
+  },
+  "eventType": "default"
+}
diff --git a/providers/google/write_test.go b/providers/google/write_test.go
new file mode 100644
index 00000000..3785555e
--- /dev/null
+++ b/providers/google/write_test.go
@@ -0,0 +1,136 @@
+package google
+
+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,cyclop
+	t.Parallel()
+
+	errorCalendarNoColor := testutils.DataFromFile(t, "write/calendarList/error-missing-color.json")
+	responseInsertCalendar := testutils.DataFromFile(t, "write/calendarList/new.json")
+	responseEvent := testutils.DataFromFile(t, "write/events/new.json")
+
+	tests := []testroutines.Write{
+		{
+			Name:         "Write object must be included",
+			Server:       mockserver.Dummy(),
+			ExpectedErrs: []error{common.ErrMissingObjects},
+		},
+		{
+			Name:         "Write needs data payload",
+			Input:        common.WriteParams{ObjectName: "calendarList"},
+			Server:       mockserver.Dummy(),
+			ExpectedErrs: []error{common.ErrMissingRecordData},
+		},
+		{
+			Name:  "Bad request from provider",
+			Input: common.WriteParams{ObjectName: "calendarList", RecordData: make(map[string]any)},
+			Server: mockserver.Fixed{
+				Setup:  mockserver.ContentJSON(),
+				Always: mockserver.Response(http.StatusBadRequest, errorCalendarNoColor),
+			}.Server(),
+			ExpectedErrs: []error{
+				common.ErrBadRequest,
+				errors.New( // nolint:goerr113
+					"Missing foreground color.",
+				),
+			},
+		},
+		{
+			Name: "Valid insert of a calendar item to my calendar",
+			Input: common.WriteParams{
+				ObjectName: "calendarList",
+				RecordData: map[string]any{
+					"foregroundColor": "#ffffff",
+					"backgroundColor": "#19f7f0",
+				},
+			},
+			Server: mockserver.Conditional{
+				Setup: mockserver.ContentJSON(),
+				If: mockcond.And{
+					mockcond.MethodPOST(),
+					mockcond.Path("/calendar/v3/users/me/calendarList"),
+					mockcond.QueryParam("colorRgbFormat", "true"),
+				},
+				Then: mockserver.Response(http.StatusOK, responseInsertCalendar),
+			}.Server(),
+			Comparator: testroutines.ComparatorSubsetWrite,
+			Expected: &common.WriteResult{
+				Success:  true,
+				RecordId: "en.italian#holiday@group.v.calendar.google.com",
+				Errors:   nil,
+				Data: map[string]any{
+					"summary":     "Holidays in Italy",
+					"description": "Holidays and Observances in Italy",
+					"timeZone":    "America/Los_Angeles",
+				},
+			},
+			ExpectedErrs: nil,
+		},
+		{
+			Name: "Write must act as an Update",
+			Input: common.WriteParams{
+				ObjectName: "calendarList",
+				RecordId:   "en.usa#holiday@group.v.calendar.google.com",
+				RecordData: make(map[string]any),
+			},
+			Server: mockserver.Conditional{
+				Setup: mockserver.ContentJSON(),
+				If: mockcond.And{
+					mockcond.MethodPATCH(),
+					mockcond.Path("/calendar/v3/users/me/calendarList/en.usa#holiday@group.v.calendar.google.com"), // nolint:lll
+				},
+				Then: mockserver.Response(http.StatusOK),
+			}.Server(),
+			Expected:     &common.WriteResult{Success: true},
+			ExpectedErrs: nil,
+		},
+		{
+			Name: "Create event",
+			Input: common.WriteParams{
+				ObjectName: "events",
+				RecordData: map[string]any{},
+			},
+			Server: mockserver.Conditional{
+				Setup: mockserver.ContentJSON(),
+				If: mockcond.And{
+					mockcond.MethodPOST(),
+					mockcond.Path("/calendar/v3/calendars/primary/events"),
+				},
+				Then: mockserver.Response(http.StatusOK, responseEvent),
+			}.Server(),
+			Comparator: testroutines.ComparatorSubsetWrite,
+			Expected: &common.WriteResult{
+				Success:  true,
+				RecordId: "std4ien2l4fovmbup80ov20tp8",
+				Errors:   nil,
+				Data: map[string]any{
+					"status":  "confirmed",
+					"summary": "Monthly team meeting",
+				},
+			},
+			ExpectedErrs: nil,
+		},
+	}
+
+	for _, tt := range tests {
+		// nolint:varnamelen
+		t.Run(tt.Name, func(t *testing.T) {
+			t.Parallel()
+
+			tt.Run(t, func() (connectors.WriteConnector, error) {
+				return constructTestCalendarConnector(tt.Server.URL)
+			})
+		})
+	}
+}
diff --git a/test/google/calendar/write-delete/calendarList/main.go b/test/google/calendar/write-delete/calendarList/main.go
new file mode 100644
index 00000000..3a9e8337
--- /dev/null
+++ b/test/google/calendar/write-delete/calendarList/main.go
@@ -0,0 +1,64 @@
+package main
+
+import (
+	"context"
+	"os/signal"
+	"syscall"
+
+	"github.com/amp-labs/connectors/internal/datautils"
+	connTest "github.com/amp-labs/connectors/test/google"
+	"github.com/amp-labs/connectors/test/utils"
+	"github.com/amp-labs/connectors/test/utils/testscenario"
+)
+
+const (
+	italianCalendar   = "en.italian#holiday@group.v.calendar.google.com"
+	colorForeground   = "#ffffff"
+	colorBeforeUpdate = "#19f7f0"
+	colorAfterUpdate  = "#ff69b4"
+)
+
+type payload struct {
+	ID              string `json:"id"`
+	ForegroundColor string `json:"foregroundColor"`
+	BackgroundColor string `json:"backgroundColor"`
+	Selected        bool   `json:"selected"`
+}
+
+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.GetGoogleCalendarConnector(ctx)
+
+	testscenario.ValidateCreateUpdateDelete(ctx, conn,
+		"calendarList",
+		payload{
+			ID:              italianCalendar,
+			ForegroundColor: colorForeground,
+			BackgroundColor: colorBeforeUpdate,
+			Selected:        true,
+		},
+		payload{
+			ID:              italianCalendar,
+			ForegroundColor: colorForeground,
+			BackgroundColor: colorAfterUpdate,
+			Selected:        true,
+		},
+		testscenario.CRUDTestSuite{
+			ReadFields: datautils.NewSet("id", "backgroundColor"),
+			SearchBy: testscenario.Property{
+				Key:   "backgroundcolor",
+				Value: colorBeforeUpdate,
+			},
+			RecordIdentifierKey: "id",
+			UpdatedFields: map[string]string{
+				"backgroundcolor": colorAfterUpdate,
+			},
+		},
+	)
+}
diff --git a/test/google/calendar/write-delete/events/main.go b/test/google/calendar/write-delete/events/main.go
new file mode 100644
index 00000000..42180ab3
--- /dev/null
+++ b/test/google/calendar/write-delete/events/main.go
@@ -0,0 +1,77 @@
+package main
+
+import (
+	"context"
+	"os/signal"
+	"syscall"
+	"time"
+
+	"github.com/amp-labs/connectors/internal/datautils"
+	connTest "github.com/amp-labs/connectors/test/google"
+	"github.com/amp-labs/connectors/test/utils"
+	"github.com/amp-labs/connectors/test/utils/testscenario"
+	"github.com/brianvoe/gofakeit/v6"
+)
+
+type createPayload struct {
+	Start   dateTime `json:"start"`
+	End     dateTime `json:"end"`
+	Summary string   `json:"summary"`
+}
+
+type updatePayload struct {
+	Summary string `json:"summary"`
+}
+
+type dateTime struct {
+	DateTime string `json:"dateTime"`
+	TimeZone string `json:"timeZone"`
+}
+
+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.GetGoogleCalendarConnector(ctx)
+
+	summaryBefore := gofakeit.Name()
+	summaryAfter := gofakeit.Name()
+
+	startTime := time.Now().Add(time.Hour)
+	endTime := startTime.Add(time.Hour)
+	startTimeStr := datautils.Time.FormatRFC3339inUTCWithMilliseconds(startTime)
+	endTimeStr := datautils.Time.FormatRFC3339inUTCWithMilliseconds(endTime)
+
+	testscenario.ValidateCreateUpdateDelete(ctx, conn,
+		"events",
+		createPayload{
+			Start: dateTime{
+				DateTime: startTimeStr,
+				TimeZone: "America/Los_Angeles",
+			},
+			End: dateTime{
+				DateTime: endTimeStr,
+				TimeZone: "America/Los_Angeles",
+			},
+			Summary: summaryBefore,
+		},
+		updatePayload{
+			Summary: summaryAfter,
+		},
+		testscenario.CRUDTestSuite{
+			ReadFields: datautils.NewSet("id", "summary"),
+			SearchBy: testscenario.Property{
+				Key:   "summary",
+				Value: summaryBefore,
+			},
+			RecordIdentifierKey: "id",
+			UpdatedFields: map[string]string{
+				"summary": summaryAfter,
+			},
+		},
+	)
+}