Repository: amp-labs/connectors
Pull request #1432 (2025-03-04 04:03:18)

feat(transport): SetBaseURL
# Background
Transport should override base URL. This is useful for mock tests, where the base URL is replaced with the test server URL.

# Usage
![image](https://github.com/user-attachments/assets/2c9d6aec-1c92-4fe2-afd4-b0eab83dfc9d)

# Goal
Unblocks mock tests.


Cobalt0s: * **#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> 👈 <a href="https://app.graphite.dev/github/pr/amp-labs/connectors/1432?utm_source=stack-comment-view-in-graphite" target="_blank">(View in Graphite)</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/common/types.go b/common/types.go
index b71ee540..48f52254 100644
--- a/common/types.go
+++ b/common/types.go
@@ -510,6 +510,12 @@ type SubscribeConnector interface {
 		ctx context.Context,
 		previousResult RegistrationResult,
 	) error
+	// EmptyRegistrationParams returns a empty instance of SubscriptionRegistrationParams.
+	// if there is any provider specific initialization required, it should be done here.
+	EmptyRegistrationParams() *SubscriptionRegistrationParams
+	// EmptyRegistrationResult returns a empty instance of RegistrationResult.
+	// if there is any provider specific initialization required, it should be done here.
+	EmptyRegistrationResult() *RegistrationResult
 
 	Subscribe(
 		ctx context.Context,
@@ -525,4 +531,10 @@ type SubscribeConnector interface {
 		ctx context.Context,
 		previousResult SubscriptionResult,
 	) error
+	// EmptySubscritpionParams returns a empty instance of SubscribeParams.
+	// if there is any provider specific initialization required, it should be done here.
+	EmptySubscritpionParams() *SubscribeParams
+	// EmptySubscriptionResult returns a empty instance of SubscriptionResult.
+	// if there is any provider specific initialization required, it should be done here.
+	EmptySubscriptionResult() *SubscriptionResult
 }
diff --git a/internal/components/operations/operations.go b/internal/components/operations/operations.go
index 640499b9..e3101e61 100644
--- a/internal/components/operations/operations.go
+++ b/internal/components/operations/operations.go
@@ -23,7 +23,7 @@ type HTTPOperation[RequestType any, ResponseType any] struct {
 // HTTPHandlers contains operation-specific HTTP handlers for building and parsing HTTP requests and responses.
 type HTTPHandlers[RequestType any, ResponseType any] struct {
 	BuildRequest  func(context.Context, RequestType) (*http.Request, error)
-	ParseResponse func(context.Context, RequestType, *common.JSONHTTPResponse) (ResponseType, error)
+	ParseResponse func(context.Context, RequestType, *http.Request, *common.JSONHTTPResponse) (ResponseType, error)
 	ErrorHandler  func(*http.Response, []byte) error
 }
 
@@ -85,5 +85,5 @@ func (op *HTTPOperation[RequestType, ResponseType]) ExecuteRequest(
 		return response, err
 	}
 
-	return op.handlers.ParseResponse(ctx, params, jsonResp)
+	return op.handlers.ParseResponse(ctx, params, req, jsonResp)
 }
diff --git a/internal/components/transport.go b/internal/components/transport.go
index bc89070c..e39fc61b 100644
--- a/internal/components/transport.go
+++ b/internal/components/transport.go
@@ -40,5 +40,10 @@ func NewTransport(
 	}, nil
 }
 
-func (c *Transport) JSONHTTPClient() *common.JSONHTTPClient { return c.json }
-func (c *Transport) HTTPClient() *common.HTTPClient         { return c.json.HTTPClient }
+func (t *Transport) SetBaseURL(newURL string) {
+	t.ProviderContext.providerInfo.BaseURL = newURL
+	t.json.HTTPClient.Base = newURL
+}
+
+func (t *Transport) JSONHTTPClient() *common.JSONHTTPClient { return t.json }
+func (t *Transport) HTTPClient() *common.HTTPClient         { return t.json.HTTPClient }
diff --git a/providers/blueshift/handlers.go b/providers/blueshift/handlers.go
index 0f551ac2..e3fcad29 100644
--- a/providers/blueshift/handlers.go
+++ b/providers/blueshift/handlers.go
@@ -50,6 +50,7 @@ func (c *Connector) buildReadRequest(ctx context.Context, params common.ReadPara
 func (c *Connector) parseReadResponse(
 	ctx context.Context,
 	params common.ReadParams,
+	request *http.Request,
 	response *common.JSONHTTPResponse,
 ) (*common.ReadResult, error) {
 	path, err := metadata.Schemas.LookupURLPath(c.Module(), params.ObjectName)
@@ -102,6 +103,7 @@ func (c *Connector) buildWriteRequest(ctx context.Context, params common.WritePa
 func (c *Connector) parseWriteResponse(
 	ctx context.Context,
 	params common.WriteParams,
+	request *http.Request,
 	response *common.JSONHTTPResponse,
 ) (*common.WriteResult, error) {
 	node, ok := response.Body()
diff --git a/providers/front/handlers.go b/providers/front/handlers.go
index 006fcf18..ae2cfc3e 100644
--- a/providers/front/handlers.go
+++ b/providers/front/handlers.go
@@ -37,6 +37,7 @@ func (c *Connector) buildReadRequest(ctx context.Context, params common.ReadPara
 func (c *Connector) parseReadResponse(
 	ctx context.Context,
 	params common.ReadParams,
+	request *http.Request,
 	response *common.JSONHTTPResponse,
 ) (*common.ReadResult, error) {
 	return common.ParseResult(
@@ -73,6 +74,7 @@ func (c *Connector) buildWriteRequest(ctx context.Context, params common.WritePa
 func (c *Connector) parseWriteResponse(
 	ctx context.Context,
 	params common.WriteParams,
+	request *http.Request,
 	response *common.JSONHTTPResponse,
 ) (*common.WriteResult, error) {
 	body, ok := response.Body()
diff --git a/providers/gorgias/handlers.go b/providers/gorgias/handlers.go
index 63242757..10d7b412 100644
--- a/providers/gorgias/handlers.go
+++ b/providers/gorgias/handlers.go
@@ -35,6 +35,7 @@ func (c *Connector) buildSingleObjectMetadataRequest(ctx context.Context, object
 func (c *Connector) parseSingleObjectMetadataResponse(
 	ctx context.Context,
 	objectName string,
+	request *http.Request,
 	response *common.JSONHTTPResponse,
 ) (*common.ObjectMetadata, error) {
 	objectMetadata := common.ObjectMetadata{
diff --git a/providers/groove/connector.go b/providers/groove/connector.go
new file mode 100644
index 00000000..20ccece0
--- /dev/null
+++ b/providers/groove/connector.go
@@ -0,0 +1,76 @@
+package groove
+
+import (
+	"github.com/amp-labs/connectors/common"
+	"github.com/amp-labs/connectors/internal/components"
+	"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/internal/staticschema"
+	"github.com/amp-labs/connectors/providers"
+)
+
+const restAPIVersion = "v1"
+
+type Connector struct {
+	// Basic connector
+	*components.Connector
+
+	// Require authenticated client
+	common.RequireAuthenticatedClient
+
+	// Supported operations
+	components.SchemaProvider
+	components.Reader
+	components.Writer
+}
+
+func NewConnector(params common.Parameters) (*Connector, error) {
+	return components.Initialize(providers.Groove, params, constructor)
+}
+
+func constructor(base *components.Connector) (*Connector, error) {
+	connector := &Connector{Connector: base}
+
+	// Set the metadata provider for the connector
+	connector.SchemaProvider = schema.NewObjectSchemaProvider(
+		connector.HTTPClient().Client,
+		schema.FetchModeParallel,
+		operations.SingleObjectMetadataHandlers{
+			BuildRequest:  connector.buildSingleObjectMetadataRequest,
+			ParseResponse: connector.parseSingleObjectMetadataResponse,
+		},
+	)
+
+	registry, err := components.NewEndpointRegistry(supportedOperations())
+	if err != nil {
+		return nil, err
+	}
+
+	// Set the read provider for the connector
+	connector.Reader = reader.NewHTTPReader(
+		connector.HTTPClient().Client,
+		registry,
+		staticschema.RootModuleID,
+		operations.ReadHandlers{
+			BuildRequest:  connector.buildReadRequest,
+			ParseResponse: connector.parseReadResponse,
+			ErrorHandler:  common.InterpretError,
+		},
+	)
+
+	// Set the write provider for the connector
+	connector.Writer = writer.NewHTTPWriter(
+		connector.HTTPClient().Client,
+		registry,
+		staticschema.RootModuleID,
+		operations.WriteHandlers{
+			BuildRequest:  connector.buildWriteRequest,
+			ParseResponse: connector.parseWriteResponse,
+			ErrorHandler:  common.InterpretError,
+		},
+	)
+
+	return connector, nil
+}
diff --git a/providers/groove/handlers.go b/providers/groove/handlers.go
new file mode 100644
index 00000000..2684a895
--- /dev/null
+++ b/providers/groove/handlers.go
@@ -0,0 +1,173 @@
+package groove
+
+import (
+	"bytes"
+	"context"
+	"encoding/json"
+	"fmt"
+	"net/http"
+
+	"github.com/amp-labs/connectors/common"
+	"github.com/amp-labs/connectors/common/naming"
+	"github.com/amp-labs/connectors/common/urlbuilder"
+	"github.com/amp-labs/connectors/internal/jsonquery"
+)
+
+const (
+	limitQuery       = "per_page"
+	metadataPageSize = "1"
+	pageSize         = "50"
+)
+
+func (c *Connector) buildSingleObjectMetadataRequest(ctx context.Context, objectName string) (*http.Request, error) {
+	url, err := urlbuilder.New(c.ProviderInfo().BaseURL, restAPIVersion, objectName)
+	if err != nil {
+		return nil, err
+	}
+
+	// Limit response to 1 record data.
+	url.WithQueryParam(limitQuery, metadataPageSize)
+
+	return http.NewRequestWithContext(ctx, http.MethodGet, url.String(), nil)
+}
+
+func (c *Connector) parseSingleObjectMetadataResponse(
+	ctx context.Context,
+	objectName string,
+	request *http.Request,
+	response *common.JSONHTTPResponse,
+) (*common.ObjectMetadata, error) {
+	objectMetadata := common.ObjectMetadata{
+		FieldsMap:   make(map[string]string),
+		DisplayName: naming.CapitalizeFirstLetterEveryWord(objectName),
+	}
+
+	// We're unmarshaling the data to map[string]any,
+	// all supported objects returns this data type.
+	data, err := common.UnmarshalJSON[map[string]any](response)
+	if err != nil {
+		return nil, common.ErrFailedToUnmarshalBody
+	}
+
+	if len(*data) == 0 {
+		return nil, common.ErrMissingExpectedValues
+	}
+
+	field := responseField(objectName)
+	firstRecord := *data
+
+	// checks if the response data field is available, else assumes we are in the data object.
+	if field != "" {
+		// If this is the case, we're expecting the data in a certain field
+		// in this current map.
+		records, ok := (*data)[field].([]any)
+		if !ok {
+			return nil, fmt.Errorf("couldn't convert the data response field data to an array: %w", common.ErrMissingExpectedValues) // nolint:lll
+		}
+
+		// Iterate over the first record.
+		firstRecord, ok = records[0].(map[string]any)
+		if !ok {
+			return nil, fmt.Errorf("couldn't convert the first record data to a map: %w", common.ErrMissingExpectedValues)
+		}
+	}
+
+	for fld := range firstRecord {
+		objectMetadata.FieldsMap[fld] = fld
+	}
+
+	return &objectMetadata, nil
+}
+
+func (c *Connector) buildReadRequest(ctx context.Context, params common.ReadParams) (*http.Request, error) {
+	var (
+		url *urlbuilder.URL
+		err error
+	)
+
+	url, err = urlbuilder.New(c.ProviderInfo().BaseURL, restAPIVersion, params.ObjectName)
+	if err != nil {
+		return nil, err
+	}
+
+	if params.NextPage != "" {
+		url, err = urlbuilder.New(params.NextPage.String())
+		if err != nil {
+			return nil, err
+		}
+	}
+
+	// For some reasons the next-page url generated by the API, keeps this out.
+	// We add it in both case scenarios.
+	url.WithQueryParam(limitQuery, pageSize)
+
+	return http.NewRequestWithContext(ctx, http.MethodGet, url.String(), nil)
+}
+
+func (c *Connector) parseReadResponse(
+	ctx context.Context,
+	params common.ReadParams,
+	request *http.Request,
+	response *common.JSONHTTPResponse,
+) (*common.ReadResult, error) {
+	return common.ParseResult(
+		response,
+		common.GetRecordsUnderJSONPath(responseField(params.ObjectName)),
+		nextRecordsURL(),
+		common.GetMarshaledData,
+		params.Fields,
+	)
+}
+
+func (c *Connector) buildWriteRequest(ctx context.Context, params common.WriteParams) (*http.Request, error) {
+	method := http.MethodPost
+
+	url, err := urlbuilder.New(c.ProviderInfo().BaseURL, restAPIVersion, params.ObjectName)
+	if err != nil {
+		return nil, err
+	}
+
+	if params.RecordId != "" {
+		url.AddPath(params.RecordId)
+
+		method = http.MethodPut
+	}
+
+	jsonData, err := json.Marshal(params.RecordData)
+	if err != nil {
+		return nil, err
+	}
+
+	return http.NewRequestWithContext(ctx, method, url.String(), bytes.NewReader(jsonData))
+}
+
+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 {
+		return &common.WriteResult{
+			Success: true,
+		}, nil
+	}
+
+	writeResponseField := naming.NewSingularString(params.ObjectName).String()
+
+	resp, err := jsonquery.New(body).ObjectRequired(writeResponseField)
+	if err != nil {
+		return nil, err
+	}
+
+	data, err := jsonquery.Convertor.ObjectToMap(resp)
+	if err != nil {
+		return nil, err
+	}
+
+	return &common.WriteResult{
+		Success: true,
+		Data:    data,
+	}, nil
+}
diff --git a/providers/groove/parse.go b/providers/groove/parse.go
new file mode 100644
index 00000000..cf6c86b8
--- /dev/null
+++ b/providers/groove/parse.go
@@ -0,0 +1,28 @@
+package groove
+
+import (
+	"github.com/amp-labs/connectors/common"
+	"github.com/amp-labs/connectors/internal/jsonquery"
+	"github.com/spyzhov/ajson"
+)
+
+func nextRecordsURL() common.NextPageFunc {
+	return func(node *ajson.Node) (string, error) {
+		pagination, err := jsonquery.New(node, "meta").ObjectOptional("pagination")
+		if err != nil {
+			return "", err
+		}
+
+		nextPage, err := jsonquery.New(pagination).StringOptional("next_page")
+		if err != nil {
+			return "", err
+		}
+
+		// Check this to avoid panics
+		if nextPage == nil {
+			return "", nil
+		}
+
+		return *nextPage, nil
+	}
+}
diff --git a/providers/groove/supports.go b/providers/groove/supports.go
new file mode 100644
index 00000000..575cf12d
--- /dev/null
+++ b/providers/groove/supports.go
@@ -0,0 +1,46 @@
+package groove
+
+import (
+	"fmt"
+	"strings"
+
+	"github.com/amp-labs/connectors/internal/components"
+	"github.com/amp-labs/connectors/internal/staticschema"
+)
+
+func responseField(objectName string) string {
+	switch objectName {
+	case "kb":
+		return "knowledge_bases"
+	case "kb/themes":
+		return "themes"
+	case "tickets/count":
+		return ""
+	default:
+		return objectName
+	}
+}
+
+func supportedOperations() components.EndpointRegistryInput {
+	readSupport := []string{
+		"tickets", "customers", "mailboxes", "folders",
+		"agents", "groups", "kb/themes", "widgets",
+		"kb", "tickets/count",
+	}
+
+	writeSupport := []string{
+		"tickets", "webhooks", "groups", "widgets",
+	}
+
+	return components.EndpointRegistryInput{
+		staticschema.RootModuleID: {
+			{
+				Endpoint: fmt.Sprintf("{%s}", strings.Join(readSupport, ",")),
+				Support:  components.ReadSupport,
+			}, {
+				Endpoint: fmt.Sprintf("{%s}", strings.Join(writeSupport, ",")),
+				Support:  components.WriteSupport,
+			},
+		},
+	}
+}
diff --git a/providers/helpscout/handlers.go b/providers/helpscout/handlers.go
index 37ac8334..a05c1257 100644
--- a/providers/helpscout/handlers.go
+++ b/providers/helpscout/handlers.go
@@ -43,6 +43,7 @@ func (c *Connector) buildSingleObjectMetadataRequest(ctx context.Context, object
 func (c *Connector) parseSingleObjectMetadataResponse(
 	ctx context.Context,
 	objectName string,
+	request *http.Request,
 	response *common.JSONHTTPResponse,
 ) (*common.ObjectMetadata, error) {
 	objectMetadata := common.ObjectMetadata{
@@ -89,6 +90,7 @@ func (c *Connector) buildReadRequest(ctx context.Context, params common.ReadPara
 func (c *Connector) parseReadResponse(
 	ctx context.Context,
 	params common.ReadParams,
+	request *http.Request,
 	response *common.JSONHTTPResponse,
 ) (*common.ReadResult, error) {
 	return common.ParseResult(
@@ -125,6 +127,7 @@ func (c *Connector) buildWriteRequest(ctx context.Context, params common.WritePa
 func (c *Connector) parseWriteResponse(
 	ctx context.Context,
 	params common.WriteParams,
+	request *http.Request,
 	response *common.JSONHTTPResponse,
 ) (*common.WriteResult, error) {
 	// The response is always an empty response body.
diff --git a/providers/netsuite/handlers.go b/providers/netsuite/handlers.go
index b457bd4c..390ff097 100644
--- a/providers/netsuite/handlers.go
+++ b/providers/netsuite/handlers.go
@@ -32,6 +32,7 @@ func (c *Connector) buildObjectMetadataRequest(ctx context.Context, object strin
 func (c *Connector) parseObjectMetadataResponse(
 	ctx context.Context,
 	object string,
+	request *http.Request,
 	resp *common.JSONHTTPResponse,
 ) (*common.ObjectMetadata, error) {
 	metadata, err := common.UnmarshalJSON[metadataResponse](resp)
diff --git a/providers/salesforce/register.go b/providers/salesforce/register.go
index a4d6de1b..b23b9d8e 100644
--- a/providers/salesforce/register.go
+++ b/providers/salesforce/register.go
@@ -30,6 +30,18 @@ type ResultData struct {
 	EventRelayConfig *EventRelayConfig
 }
 
+func (c *Connector) EmptyRegistrationParams() *common.SubscriptionRegistrationParams {
+	return &common.SubscriptionRegistrationParams{
+		Request: &RegistrationParams{},
+	}
+}
+
+func (c *Connector) EmptyRegistrationResult() *common.RegistrationResult {
+	return &common.RegistrationResult{
+		Result: &ResultData{},
+	}
+}
+
 func (c *Connector) rollbackRegister(ctx context.Context, res *ResultData) error {
 	if res.EventRelayConfig != nil {
 		_, err := c.DeleteEventRelayConfig(ctx, res.EventRelayConfig.Id)
diff --git a/providers/salesforce/subscribe.go b/providers/salesforce/subscribe.go
index acc41b41..867d2a35 100644
--- a/providers/salesforce/subscribe.go
+++ b/providers/salesforce/subscribe.go
@@ -13,6 +13,16 @@ type SubscribeResult struct {
 	EventChannelMembers map[common.ObjectName]*EventChannelMember
 }
 
+func (conn *Connector) EmptySubscritpionParams() *common.SubscribeParams {
+	return &common.SubscribeParams{}
+}
+
+func (conn *Connector) EmptySubscriptionResult() *common.SubscriptionResult {
+	return &common.SubscriptionResult{
+		Result: &SubscribeResult{},
+	}
+}
+
 // Subscribe subscribes to the events for the given objects.
 // It creates event channel members for each object in the subscription.
 // If any of the event channel members fail to be created, it will rollback the operation.
diff --git a/providers/smartleadv2/handlers.go b/providers/smartleadv2/handlers.go
index 98cacf0f..bc9fd388 100644
--- a/providers/smartleadv2/handlers.go
+++ b/providers/smartleadv2/handlers.go
@@ -25,6 +25,7 @@ func (c *Connector) buildReadRequest(ctx context.Context, params common.ReadPara
 func (c *Connector) parseReadResponse(
 	ctx context.Context,
 	params common.ReadParams,
+	request *http.Request,
 	resp *common.JSONHTTPResponse,
 ) (*common.ReadResult, error) {
 	return common.ParseResult(
@@ -74,6 +75,7 @@ func (c *Connector) buildWriteRequest(ctx context.Context, params common.WritePa
 func (c *Connector) parseWriteResponse(
 	ctx context.Context,
 	params common.WriteParams,
+	request *http.Request,
 	resp *common.JSONHTTPResponse,
 ) (*common.WriteResult, error) {
 	// Get the JSON node from response
@@ -133,6 +135,7 @@ func (c *Connector) buildDeleteRequest(ctx context.Context, params common.Delete
 func (c *Connector) parseDeleteResponse(
 	ctx context.Context,
 	params common.DeleteParams,
+	request *http.Request,
 	resp *common.JSONHTTPResponse,
 ) (*common.DeleteResult, error) {
 	if resp.Code != http.StatusOK && resp.Code != http.StatusNoContent {


Test patch:
diff --git a/providers/salesforce/connector_test.go b/providers/salesforce/connector_test.go
new file mode 100644
index 00000000..8d73ad66
--- /dev/null
+++ b/providers/salesforce/connector_test.go
@@ -0,0 +1,21 @@
+package salesforce
+
+import (
+	"testing"
+
+	"github.com/amp-labs/connectors/common"
+)
+
+func TestSubscribeConnector(t *testing.T) { //nolint:funlen,gocognit,cyclop,maintidx
+	t.Parallel()
+
+	conn, err := constructTestConnector("example.com")
+	if err != nil {
+		t.Fatalf("failed to construct test connector: %v", err)
+	}
+
+	_, ok := any(conn).(common.SubscribeConnector)
+	if !ok {
+		t.Fatalf("expected SubscribeConnector, got %T", conn)
+	}
+}
diff --git a/test/blueshift/metadata/metadata.go b/test/blueshift/metadata/metadata.go
index d26cdf0c..c151d174 100644
--- a/test/blueshift/metadata/metadata.go
+++ b/test/blueshift/metadata/metadata.go
@@ -19,5 +19,6 @@ func main() {
 	if err != nil {
 		log.Fatal(err)
 	}
+
 	utils.DumpJSON(m, os.Stdout)
 }
diff --git a/test/blueshift/read/main.go b/test/blueshift/read/main.go
index 89d1ad31..e970a0ba 100644
--- a/test/blueshift/read/main.go
+++ b/test/blueshift/read/main.go
@@ -22,6 +22,7 @@ func run() error {
 	connector := blueshift.GetBlueshiftConnector(ctx)
 
 	slog.Info("Reading campaigns")
+
 	res, err := connector.Read(ctx, common.ReadParams{
 		ObjectName: "campaigns",
 		Fields:     datautils.NewStringSet("uuid", "name"),
@@ -33,6 +34,7 @@ func run() error {
 	utils.DumpJSON(res, os.Stdout)
 
 	slog.Info("Reading email templates")
+
 	res, err = connector.Read(ctx, common.ReadParams{
 		ObjectName: "email_templates",
 		Fields:     datautils.NewStringSet("uuid", "name"),
@@ -44,6 +46,7 @@ func run() error {
 	utils.DumpJSON(res, os.Stdout)
 
 	slog.Info("Reading sms templates")
+
 	res, err = connector.Read(ctx, common.ReadParams{
 		ObjectName: "sms_templates",
 		Fields:     datautils.NewStringSet("uuid", "author"),
diff --git a/test/front/metadata/main.go b/test/front/metadata/main.go
index ee47ec38..f9214b69 100644
--- a/test/front/metadata/main.go
+++ b/test/front/metadata/main.go
@@ -10,7 +10,6 @@ import (
 )
 
 func main() {
-
 	ctx := context.Background()
 
 	conn := front.GetFrontConnector(ctx)
diff --git a/test/front/read/main.go b/test/front/read/main.go
index 72f96e52..ec9a7074 100644
--- a/test/front/read/main.go
+++ b/test/front/read/main.go
@@ -58,6 +58,7 @@ func testRead(ctx context.Context, conn *fr.Connector, objectName string, fields
 	if _, err := os.Stdout.Write(jsonStr); err != nil {
 		return fmt.Errorf("error writing to stdout: %w", err)
 	}
+
 	if _, err := os.Stdout.WriteString("\n"); err != nil {
 		return fmt.Errorf("error writing to stdout: %w", err)
 	}
diff --git a/test/groove/connector.go b/test/groove/connector.go
new file mode 100644
index 00000000..52cd81b6
--- /dev/null
+++ b/test/groove/connector.go
@@ -0,0 +1,50 @@
+package groove
+
+import (
+	"context"
+	"net/http"
+
+	"github.com/amp-labs/connectors/common"
+	"github.com/amp-labs/connectors/common/scanning/credscanning"
+	"github.com/amp-labs/connectors/providers"
+	"github.com/amp-labs/connectors/providers/groove"
+	"github.com/amp-labs/connectors/test/utils"
+	"golang.org/x/oauth2"
+)
+
+func GetConnector(ctx context.Context) *groove.Connector {
+	filePath := credscanning.LoadPath(providers.Groove)
+	reader := utils.MustCreateProvCredJSON(filePath, true, false)
+
+	client, err := common.NewOAuthHTTPClient(ctx,
+		common.WithOAuthClient(http.DefaultClient),
+		common.WithOAuthConfig(getConfig(reader)),
+		common.WithOAuthToken(reader.GetOauthToken()),
+	)
+	if err != nil {
+		utils.Fail(err.Error())
+	}
+
+	conn, err := groove.NewConnector(common.Parameters{
+		AuthenticatedClient: client,
+	})
+	if err != nil {
+		utils.Fail("error creating connector", "error", err)
+	}
+
+	return conn
+}
+
+func getConfig(reader *credscanning.ProviderCredentials) *oauth2.Config {
+	cfg := oauth2.Config{
+		ClientID:     reader.Get(credscanning.Fields.ClientId),
+		ClientSecret: reader.Get(credscanning.Fields.ClientSecret),
+		Endpoint: oauth2.Endpoint{
+			AuthURL:   "https://api.groovehq.com/oauth/authorize",
+			TokenURL:  "https://api.groovehq.com/oauth/token",
+			AuthStyle: oauth2.AuthStyleAutoDetect,
+		},
+	}
+
+	return &cfg
+}
diff --git a/test/groove/metadata/main.go b/test/groove/metadata/main.go
new file mode 100644
index 00000000..fa634ccf
--- /dev/null
+++ b/test/groove/metadata/main.go
@@ -0,0 +1,31 @@
+package main
+
+import (
+	"context"
+	"fmt"
+
+	"github.com/amp-labs/connectors/test/groove"
+	"github.com/amp-labs/connectors/test/utils"
+)
+
+func main() {
+	if err := run(); err != nil {
+		utils.Fail(err.Error())
+	}
+}
+
+func run() error {
+	ctx := context.Background()
+	connector := groove.GetConnector(ctx)
+
+	m, err := connector.ListObjectMetadata(ctx, []string{"tickets", "customers", "mailboxes", "tickets/count"})
+	if err != nil {
+		return err
+	}
+
+	// Print the results
+	fmt.Println("Results: ", m.Result)
+	fmt.Println("Errors: ", m.Errors)
+
+	return nil
+}
diff --git a/test/groove/read/main.go b/test/groove/read/main.go
new file mode 100644
index 00000000..059caa04
--- /dev/null
+++ b/test/groove/read/main.go
@@ -0,0 +1,68 @@
+package main
+
+import (
+	"context"
+	"encoding/json"
+	"fmt"
+	"log/slog"
+	"os"
+	"os/signal"
+	"syscall"
+
+	"github.com/amp-labs/connectors"
+	"github.com/amp-labs/connectors/common"
+	gr "github.com/amp-labs/connectors/providers/groove"
+	"github.com/amp-labs/connectors/test/groove"
+	"github.com/amp-labs/connectors/test/utils"
+)
+
+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 := groove.GetConnector(ctx)
+
+	if err := testRead(ctx, conn, "tickets", []string{"id", "created_at", "number"}); err != nil {
+		slog.Error(err.Error())
+	}
+
+	if err := testRead(ctx, conn, "widgets", []string{"id", "name"}); err != nil {
+		slog.Error(err.Error())
+	}
+
+	if err := testRead(ctx, conn, "agents", []string{"email", "first_name", "id"}); err != nil {
+		slog.Error(err.Error())
+	}
+
+}
+
+func testRead(ctx context.Context, conn *gr.Connector, objectName string, fields []string) error {
+	params := common.ReadParams{
+		ObjectName: objectName,
+		Fields:     connectors.Fields(fields...),
+		// NextPage:   "https://api.groovehq.com/v1/tickets?page=2",
+	}
+
+	res, err := conn.Read(ctx, params)
+	if err != nil {
+		return fmt.Errorf("failed to read %s: %w", objectName, err)
+	}
+
+	jsonStr, err := json.MarshalIndent(res, "", "  ")
+	if err != nil {
+		return fmt.Errorf("error marshalling JSON: %w", err)
+	}
+
+	if _, err := os.Stdout.Write(jsonStr); err != nil {
+		return fmt.Errorf("error writing to stdout: %w", err)
+	}
+	if _, err := os.Stdout.WriteString("\n"); err != nil {
+		return fmt.Errorf("error writing to stdout: %w", err)
+	}
+
+	return nil
+}
diff --git a/test/groove/write/main.go b/test/groove/write/main.go
new file mode 100644
index 00000000..c1e991c6
--- /dev/null
+++ b/test/groove/write/main.go
@@ -0,0 +1,89 @@
+package main
+
+import (
+	"context"
+	"encoding/json"
+	"fmt"
+	"log/slog"
+	"os"
+
+	"github.com/amp-labs/connectors/common"
+	gr "github.com/amp-labs/connectors/providers/groove"
+	"github.com/amp-labs/connectors/test/groove"
+)
+
+func main() {
+	if err := run(); err != nil {
+		slog.Error(err.Error())
+	}
+}
+
+func run() error {
+	ctx := context.Background()
+	conn := groove.GetConnector(ctx)
+
+	err := testCreatingTicket(ctx, conn)
+	if err != nil {
+		return err
+	}
+
+	err = testCreatingWebhooks(ctx, conn)
+	if err != nil {
+		return err
+	}
+
+	return nil
+}
+
+func testCreatingTicket(ctx context.Context, conn *gr.Connector) error {
+	params := common.WriteParams{
+		ObjectName: "tickets",
+		RecordData: map[string]any{
+			"body": "Test Ticket Body",
+			"from": "integration.user+groove1@withampersand.com",
+			"to":   "josephkarage@gmail.com",
+		},
+	}
+
+	res, err := conn.Write(ctx, params)
+	if err != nil {
+		return err
+	}
+
+	// Print the results
+	jsonStr, err := json.MarshalIndent(res, "", "  ")
+	if err != nil {
+		return fmt.Errorf("error marshalling JSON: %w", err)
+	}
+
+	_, _ = os.Stdout.Write(jsonStr)
+	_, _ = os.Stdout.WriteString("\n")
+
+	return nil
+}
+
+func testCreatingWebhooks(ctx context.Context, conn *gr.Connector) error {
+	params := common.WriteParams{
+		ObjectName: "webhooks",
+		RecordData: map[string]any{
+			"event": "ticket_started",
+			"url":   "https://play.svix.com/in/e_GrcBy3b17pfn3n9U3cPxvmC3ENN/",
+		},
+	}
+
+	res, err := conn.Write(ctx, params)
+	if err != nil {
+		return err
+	}
+
+	// Print the results
+	jsonStr, err := json.MarshalIndent(res, "", "  ")
+	if err != nil {
+		return fmt.Errorf("error marshalling JSON: %w", err)
+	}
+
+	_, _ = os.Stdout.Write(jsonStr)
+	_, _ = os.Stdout.WriteString("\n")
+
+	return nil
+}