Repository: amp-labs/connectors
Pull request #1577 (2025-04-19 00:13:35)

[CON-1275] feat(insightly) Read
# Description

* Page size 500.
* Several objects (64% from total) support `<objectName>/Search` which allows incremental reading:
![image](https://github.com/user-attachments/assets/1eb1ebde-fb9a-4b8c-bc11-e09ba6f04777)
* Some objects cannot be validated that they work. All of them follow the same format for reading so I think they must work, but with the current API plan some endpoints are simply restricted.' The readme illustrates this:
![image](https://github.com/user-attachments/assets/8d97e4b6-e2e4-47c5-a5c8-04a07c682fab)

# Test
## Contacts
![image](https://github.com/user-attachments/assets/79c1de8c-065e-4a00-8959-d8197748a67d)
![image](https://github.com/user-attachments/assets/1c3f1798-e3b5-4523-a4c0-e1bdfd402f44)
## Leads
![image](https://github.com/user-attachments/assets/d03ca051-63b8-43a2-bfe2-46079cd1114b)
![image](https://github.com/user-attachments/assets/75753693-7f90-4391-b0ec-7d8c6873b07b)


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

* **#1579** <a href="https://app.graphite.dev/github/pr/amp-labs/connectors/1579?utm_source=stack-comment-icon" target="_blank"><img src="https://static.graphite.dev/graphite-32x32-black.png" alt="Graphite" width="10px" height="10px"/></a>
* **#1578** <a href="https://app.graphite.dev/github/pr/amp-labs/connectors/1578?utm_source=stack-comment-icon" target="_blank"><img src="https://static.graphite.dev/graphite-32x32-black.png" alt="Graphite" width="10px" height="10px"/></a>
* **#1577** <a href="https://app.graphite.dev/github/pr/amp-labs/connectors/1577?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/1577?utm_source=stack-comment-view-in-graphite" target="_blank">(View in Graphite)</a>
* **#1576** <a href="https://app.graphite.dev/github/pr/amp-labs/connectors/1576?utm_source=stack-comment-icon" target="_blank"><img src="https://static.graphite.dev/graphite-32x32-black.png" alt="Graphite" width="10px" height="10px"/></a>
* **#1575** <a href="https://app.graphite.dev/github/pr/amp-labs/connectors/1575?utm_source=stack-comment-icon" target="_blank"><img src="https://static.graphite.dev/graphite-32x32-black.png" alt="Graphite" width="10px" height="10px"/></a>
* **#1560** <a href="https://app.graphite.dev/github/pr/amp-labs/connectors/1560?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/insightly/README.md b/providers/insightly/README.md
new file mode 100644
index 00000000..ebe4b177
--- /dev/null
+++ b/providers/insightly/README.md
@@ -0,0 +1,29 @@
+# Objects API Coverage
+
+The Professional plan does not provide access to all API endpoints available in Enterprise Insightly plan.
+
+Below is a list of objects that are either:
+
+* ❌ 404 Not Found: The object is not available in the Professional plan.
+* 🔒 403 Forbidden. Unknown reason, could be due to the plan but why the message differs.
+
+Although these objects are supported in the connector implementation, their functionality cannot be validated at this
+time due to access restrictions.
+
+| Object                   | Status | Message                  |
+|--------------------------|--------|--------------------------|
+| CommunityComments        | 🔒403  | user doesn't have access |
+| CommunityForums          | 🔒403  | user doesn't have access |
+| CommunityPosts           | 🔒403  | user doesn't have access |
+| ForumCategories          | 🔒403  | user doesn't have access |
+| KnowledgeArticle         | 🔒403  | user doesn't have access |
+| KnowledgeArticleCategory | 🔒403  | user doesn't have access |
+| KnowledgeArticleFolder   | 🔒403  | user doesn't have access |
+| OpportunityLineItem      | ❌404   | N/A                      |
+| Pricebook                | ❌404   | N/A                      |
+| PricebookEntry           | ❌404   | N/A                      |
+| Product                  | ❌404   | N/A                      |
+| Prospect                 | 🔒403  | user doesn't have access |
+| Quotation                | ❌404   | N/A                      |
+| QuotationLineItem        | ❌404   | N/A                      |
+| Ticket                   | 🔒403  | user doesn't have access |
diff --git a/providers/insightly/connector.go b/providers/insightly/connector.go
index e996fb2e..8e224e0c 100644
--- a/providers/insightly/connector.go
+++ b/providers/insightly/connector.go
@@ -4,7 +4,10 @@ import (
 	_ "embed"
 
 	"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/operations"
+	"github.com/amp-labs/connectors/internal/components/reader"
 	"github.com/amp-labs/connectors/internal/components/schema"
 	"github.com/amp-labs/connectors/providers"
 	"github.com/amp-labs/connectors/providers/insightly/metadata"
@@ -19,6 +22,7 @@ type Connector struct {
 
 	// Supported operations
 	components.SchemaProvider
+	components.Reader
 }
 
 func NewConnector(params common.Parameters) (*Connector, error) {
@@ -31,5 +35,16 @@ func constructor(base *components.Connector) (*Connector, error) {
 	// Set the metadata provider for the connector
 	connector.SchemaProvider = schema.NewOpenAPISchemaProvider(connector.ProviderContext.Module(), metadata.Schemas)
 
+	connector.Reader = reader.NewHTTPReader(
+		connector.HTTPClient().Client,
+		components.NewEmptyEndpointRegistry(),
+		connector.ProviderContext.Module(),
+		operations.ReadHandlers{
+			BuildRequest:  connector.buildReadRequest,
+			ParseResponse: connector.parseReadResponse,
+			ErrorHandler:  interpreter.ErrorHandler{}.Handle,
+		},
+	)
+
 	return connector, nil
 }
diff --git a/providers/insightly/handlers.go b/providers/insightly/handlers.go
new file mode 100644
index 00000000..147b235c
--- /dev/null
+++ b/providers/insightly/handlers.go
@@ -0,0 +1,84 @@
+package insightly
+
+import (
+	"context"
+	"net/http"
+	"strconv"
+
+	"github.com/amp-labs/connectors/common"
+	"github.com/amp-labs/connectors/common/urlbuilder"
+	"github.com/amp-labs/connectors/providers/insightly/metadata"
+	"github.com/spyzhov/ajson"
+)
+
+const (
+	DefaultPageSize    = 500
+	DefaultPageSizeStr = "500"
+)
+
+func (c *Connector) buildReadRequest(ctx context.Context, params common.ReadParams) (*http.Request, error) {
+	url, err := c.constructReadURL(params)
+	if err != nil {
+		return nil, err
+	}
+
+	return http.NewRequestWithContext(ctx, http.MethodGet, url.String(), nil)
+}
+
+/*
+	Response format:
+
+[
+
+	{
+	  "LEAD_ID": 78563840,
+	  .....
+	  "FIRST_NAME": "Katherine",
+	  "LAST_NAME": "Nguyen",
+	}
+
+]
+
+	Records array is situated usually at the root level of a response.
+	The identifier key includes object name.
+*/
+func (c *Connector) parseReadResponse(
+	ctx context.Context,
+	params common.ReadParams,
+	request *http.Request,
+	response *common.JSONHTTPResponse,
+) (*common.ReadResult, error) {
+	fieldName := metadata.Schemas.LookupArrayFieldName(c.Module(), params.ObjectName)
+
+	url, err := urlbuilder.FromRawURL(request.URL)
+	if err != nil {
+		return nil, err
+	}
+
+	return common.ParseResult(
+		response,
+		common.ExtractRecordsFromPath(fieldName),
+		nextRecordsURL(url),
+		common.GetMarshaledData,
+		params.Fields,
+	)
+}
+
+func nextRecordsURL(url *urlbuilder.URL) func(*ajson.Node) (string, error) {
+	return func(node *ajson.Node) (string, error) {
+		skipStr, ok := url.GetFirstQueryParam("skip")
+		if !ok {
+			skipStr = "0"
+		}
+
+		skip, err := strconv.Atoi(skipStr)
+		if err != nil {
+			return "", err
+		}
+
+		newSkip := skip + DefaultPageSize
+		url.WithQueryParam("skip", strconv.Itoa(newSkip))
+
+		return url.String(), nil
+	}
+}
diff --git a/providers/insightly/url.go b/providers/insightly/url.go
new file mode 100644
index 00000000..dddb2acd
--- /dev/null
+++ b/providers/insightly/url.go
@@ -0,0 +1,39 @@
+package insightly
+
+import (
+	"github.com/amp-labs/connectors/common"
+	"github.com/amp-labs/connectors/common/urlbuilder"
+	"github.com/amp-labs/connectors/internal/datautils"
+	"github.com/amp-labs/connectors/providers/insightly/metadata"
+)
+
+const apiVersion = "v3.1"
+
+func (c *Connector) getReadURL(objectName string) (*urlbuilder.URL, error) {
+	path, err := metadata.Schemas.LookupURLPath(common.ModuleRoot, objectName)
+	if err != nil {
+		return nil, err
+	}
+
+	return urlbuilder.New(c.ProviderInfo().BaseURL, apiVersion, path)
+}
+
+func (c *Connector) constructReadURL(params common.ReadParams) (*urlbuilder.URL, error) {
+	if len(params.NextPage) != 0 {
+		return urlbuilder.New(params.NextPage.String())
+	}
+
+	url, err := c.getReadURL(params.ObjectName)
+	if err != nil {
+		return nil, err
+	}
+
+	url.WithQueryParam("top", DefaultPageSizeStr)
+
+	if !params.Since.IsZero() {
+		sinceValue := datautils.Time.FormatRFC3339inUTC(params.Since)
+		url.WithQueryParam("updated_after_utc", sinceValue)
+	}
+
+	return url, nil
+}


Test patch:
diff --git a/providers/insightly/read_test.go b/providers/insightly/read_test.go
new file mode 100644
index 00000000..b9c2e3ad
--- /dev/null
+++ b/providers/insightly/read_test.go
@@ -0,0 +1,195 @@
+package insightly
+
+import (
+	"errors"
+	"net/http"
+	"testing"
+	"time"
+
+	"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 TestRead(t *testing.T) { //nolint:funlen,gocognit,cyclop,maintidx
+	t.Parallel()
+
+	responseLeadsFirstPage := testutils.DataFromFile(t, "read/leads/1-first-page.json")
+	responseLeadsSecondPage := testutils.DataFromFile(t, "read/leads/2-second-page.json")
+	responseLeadsLastPage := testutils.DataFromFile(t, "read/leads/3-last-page.json")
+
+	tests := []testroutines.Read{
+		{
+			Name:         "Read object must be included",
+			Input:        common.ReadParams{},
+			Server:       mockserver.Dummy(),
+			ExpectedErrs: []error{common.ErrMissingObjects},
+		},
+		{
+			Name:         "At least one field is requested",
+			Input:        common.ReadParams{ObjectName: "leads"},
+			Server:       mockserver.Dummy(),
+			ExpectedErrs: []error{common.ErrMissingFields},
+		},
+		{
+			Name:  "Error response is parsed",
+			Input: common.ReadParams{ObjectName: "CommunityComments", Fields: connectors.Fields("BODY")},
+			Server: mockserver.Fixed{
+				Setup: mockserver.ContentText(),
+				Always: mockserver.ResponseString(http.StatusNotFound,
+					"API User does not have access to CommunityComments."),
+			}.Server(),
+			ExpectedErrs: []error{
+				errors.New("API User does not have access to CommunityComments."), // nolint:goerr113
+				common.ErrRetryable,
+			},
+		},
+		{
+			Name: "Read leads first page",
+			Input: common.ReadParams{
+				ObjectName: "Leads",
+				Fields:     connectors.Fields("FIRST_NAME", "LAST_NAME"),
+			},
+			Server: mockserver.Conditional{
+				Setup: mockserver.ContentJSON(),
+				If: mockcond.And{
+					mockcond.PathSuffix("/v3.1/Leads/Search"),
+					mockcond.QueryParam("top", "500"),
+					mockcond.QueryParamsMissing("skip"),
+				},
+				Then: mockserver.Response(http.StatusOK, responseLeadsFirstPage),
+			}.Server(),
+			Comparator: testroutines.ComparatorSubsetRead,
+			Expected: &common.ReadResult{
+				Rows: 2,
+				Data: []common.ReadResultRow{{
+					Fields: map[string]any{
+						"first_name": "Katherine",
+						"last_name":  "Nguyen",
+					},
+					Raw: map[string]any{
+						"ADDRESS_COUNTRY": "United States",
+						"TAGS": []any{map[string]any{
+							"TAG_NAME": "Paris",
+						}},
+					},
+				}, {
+					Fields: map[string]any{
+						"first_name": "Miquel",
+						"last_name":  "Anthony",
+					},
+					Raw: map[string]any{
+						"ADDRESS_COUNTRY": "United States",
+						"TAGS": []any{map[string]any{
+							"TAG_NAME": "Washington",
+						}},
+					},
+				}},
+				NextPage: testroutines.URLTestServer + "/v3.1/Leads/Search?skip=500&top=500",
+				Done:     false,
+			},
+			ExpectedErrs: nil,
+		},
+		{
+			Name: "Read leads second page uses NextPage token",
+			Input: common.ReadParams{
+				ObjectName: "Leads",
+				Fields:     connectors.Fields("FIRST_NAME", "LAST_NAME"),
+				NextPage:   testroutines.URLTestServer + "/v3.1/Leads/Search?skip=500&top=500",
+			},
+			Server: mockserver.Conditional{
+				Setup: mockserver.ContentJSON(),
+				If: mockcond.And{
+					mockcond.PathSuffix("/v3.1/Leads/Search"),
+					mockcond.QueryParam("top", "500"),
+					mockcond.QueryParam("skip", "500"),
+				},
+				Then: mockserver.Response(http.StatusOK, responseLeadsSecondPage),
+			}.Server(),
+			Comparator: testroutines.ComparatorSubsetRead,
+			Expected: &common.ReadResult{
+				Rows: 1,
+				Data: []common.ReadResultRow{{
+					Fields: map[string]any{
+						"first_name": "Fred",
+						"last_name":  "Everett",
+					},
+					Raw: map[string]any{
+						"ADDRESS_COUNTRY": "United States",
+						"TAGS": []any{map[string]any{
+							"TAG_NAME": "Warsaw",
+						}},
+					},
+				}},
+				NextPage: testroutines.URLTestServer + "/v3.1/Leads/Search?top=500&skip=1000",
+				Done:     false,
+			},
+			ExpectedErrs: nil,
+		},
+		{
+			Name: "Read leads last page",
+			Input: common.ReadParams{
+				ObjectName: "Leads",
+				Fields:     connectors.Fields("FIRST_NAME", "LAST_NAME"),
+				NextPage:   testroutines.URLTestServer + "/v3.1/Leads/Search?top=500&skip=1000",
+			},
+			Server: mockserver.Conditional{
+				Setup: mockserver.ContentJSON(),
+				If: mockcond.And{
+					mockcond.PathSuffix("/v3.1/Leads/Search"),
+					mockcond.QueryParam("top", "500"),
+					mockcond.QueryParam("skip", "1000"),
+				},
+				Then: mockserver.Response(http.StatusOK, responseLeadsLastPage),
+			}.Server(),
+			Comparator: testroutines.ComparatorPagination,
+			Expected: &common.ReadResult{
+				Rows:     0,
+				Data:     []common.ReadResultRow{},
+				NextPage: "",
+				Done:     true,
+			},
+			ExpectedErrs: nil,
+		},
+		{
+			Name: "Leads incremental read",
+			Input: common.ReadParams{
+				ObjectName: "Leads",
+				Fields:     connectors.Fields("FIRST_NAME", "LAST_NAME"),
+				Since:      time.Date(2024, 3, 4, 8, 22, 56, 0, time.UTC),
+			},
+			Server: mockserver.Conditional{
+				Setup: mockserver.ContentJSON(),
+				If: mockcond.And{
+					mockcond.PathSuffix("/v3.1/Leads/Search"),
+					mockcond.QueryParam("top", "500"),
+					mockcond.QueryParamsMissing("skip"),
+					mockcond.QueryParam("updated_after_utc", "2024-03-04T08:22:56Z"),
+				},
+				Then: mockserver.Response(http.StatusOK, responseLeadsFirstPage),
+			}.Server(),
+			Comparator: testroutines.ComparatorPagination,
+			Expected: &common.ReadResult{
+				Rows: 2,
+				NextPage: testroutines.URLTestServer +
+					"/v3.1/Leads/Search?skip=500&top=500&updated_after_utc=2024-03-04T08:22:56Z",
+				Done: false,
+			},
+			ExpectedErrs: nil,
+		},
+	}
+
+	for _, tt := range tests {
+		// nolint:varnamelen
+		t.Run(tt.Name, func(t *testing.T) {
+			t.Parallel()
+
+			tt.Run(t, func() (connectors.ReadConnector, error) {
+				return constructTestConnector(tt.Server.URL)
+			})
+		})
+	}
+}
diff --git a/providers/insightly/test/read/leads/1-first-page.json b/providers/insightly/test/read/leads/1-first-page.json
new file mode 100644
index 00000000..68799284
--- /dev/null
+++ b/providers/insightly/test/read/leads/1-first-page.json
@@ -0,0 +1,98 @@
+[
+  {
+    "LEAD_ID": 78563840,
+    "SALUTATION": "Ms",
+    "FIRST_NAME": "Katherine",
+    "LAST_NAME": "Nguyen",
+    "LEAD_SOURCE_ID": 3794750,
+    "LEAD_STATUS_ID": 3724795,
+    "TITLE": null,
+    "CONVERTED": false,
+    "CONVERTED_CONTACT_ID": null,
+    "CONVERTED_DATE_UTC": null,
+    "CONVERTED_OPPORTUNITY_ID": null,
+    "CONVERTED_ORGANISATION_ID": null,
+    "DATE_CREATED_UTC": "2025-04-17 20:37:27",
+    "DATE_UPDATED_UTC": "2025-04-17 20:37:27",
+    "EMAIL": "",
+    "EMPLOYEE_COUNT": 0,
+    "FAX": null,
+    "INDUSTRY": null,
+    "LEAD_DESCRIPTION": "",
+    "LEAD_RATING": null,
+    "MOBILE": null,
+    "OWNER_USER_ID": 2240727,
+    "PHONE": null,
+    "RESPONSIBLE_USER_ID": 2240727,
+    "WEBSITE": null,
+    "ADDRESS_STREET": null,
+    "ADDRESS_CITY": null,
+    "ADDRESS_STATE": null,
+    "ADDRESS_POSTCODE": null,
+    "ADDRESS_COUNTRY": "United States",
+    "LAST_ACTIVITY_DATE_UTC": null,
+    "NEXT_ACTIVITY_DATE_UTC": null,
+    "VISIBLE_TO": "EVERYONE",
+    "VISIBLE_TEAM_ID": null,
+    "ORGANISATION_NAME": null,
+    "CREATED_USER_ID": 2240727,
+    "IMAGE_URL": null,
+    "EMAIL_OPTED_OUT": false,
+    "CUSTOMFIELDS": [],
+    "TAGS": [
+      {
+        "TAG_NAME": "Paris"
+      }
+    ],
+    "LINKS": [],
+    "LINKED_EMAIL_ADDRESS": "integration.test-L78563840-H4PRTS1@mailbox.insight.ly"
+  },
+  {
+    "LEAD_ID": 78563874,
+    "SALUTATION": "Mr",
+    "FIRST_NAME": "Miquel",
+    "LAST_NAME": "Anthony",
+    "LEAD_SOURCE_ID": 3794750,
+    "LEAD_STATUS_ID": 3724795,
+    "TITLE": null,
+    "CONVERTED": false,
+    "CONVERTED_CONTACT_ID": null,
+    "CONVERTED_DATE_UTC": null,
+    "CONVERTED_OPPORTUNITY_ID": null,
+    "CONVERTED_ORGANISATION_ID": null,
+    "DATE_CREATED_UTC": "2025-04-17 20:42:10",
+    "DATE_UPDATED_UTC": "2025-04-17 20:42:10",
+    "EMAIL": "",
+    "EMPLOYEE_COUNT": 0,
+    "FAX": null,
+    "INDUSTRY": null,
+    "LEAD_DESCRIPTION": "",
+    "LEAD_RATING": null,
+    "MOBILE": null,
+    "OWNER_USER_ID": 2240727,
+    "PHONE": null,
+    "RESPONSIBLE_USER_ID": 2240727,
+    "WEBSITE": null,
+    "ADDRESS_STREET": null,
+    "ADDRESS_CITY": null,
+    "ADDRESS_STATE": null,
+    "ADDRESS_POSTCODE": null,
+    "ADDRESS_COUNTRY": "United States",
+    "LAST_ACTIVITY_DATE_UTC": null,
+    "NEXT_ACTIVITY_DATE_UTC": null,
+    "VISIBLE_TO": "EVERYONE",
+    "VISIBLE_TEAM_ID": null,
+    "ORGANISATION_NAME": null,
+    "CREATED_USER_ID": 2240727,
+    "IMAGE_URL": null,
+    "EMAIL_OPTED_OUT": false,
+    "CUSTOMFIELDS": [],
+    "TAGS": [
+      {
+        "TAG_NAME": "Washington"
+      }
+    ],
+    "LINKS": [],
+    "LINKED_EMAIL_ADDRESS": "integration.test-L78563874-H4PRTS1@mailbox.insight.ly"
+  }
+]
diff --git a/providers/insightly/test/read/leads/2-second-page.json b/providers/insightly/test/read/leads/2-second-page.json
new file mode 100644
index 00000000..0b212de0
--- /dev/null
+++ b/providers/insightly/test/read/leads/2-second-page.json
@@ -0,0 +1,50 @@
+[
+  {
+    "LEAD_ID": 78563828,
+    "SALUTATION": "Mr",
+    "FIRST_NAME": "Fred",
+    "LAST_NAME": "Everett",
+    "LEAD_SOURCE_ID": 3794750,
+    "LEAD_STATUS_ID": 3724795,
+    "TITLE": null,
+    "CONVERTED": false,
+    "CONVERTED_CONTACT_ID": null,
+    "CONVERTED_DATE_UTC": null,
+    "CONVERTED_OPPORTUNITY_ID": null,
+    "CONVERTED_ORGANISATION_ID": null,
+    "DATE_CREATED_UTC": "2025-04-17 20:36:13",
+    "DATE_UPDATED_UTC": "2025-04-17 20:37:44",
+    "EMAIL": "",
+    "EMPLOYEE_COUNT": 0,
+    "FAX": null,
+    "INDUSTRY": null,
+    "LEAD_DESCRIPTION": null,
+    "LEAD_RATING": null,
+    "MOBILE": null,
+    "OWNER_USER_ID": 2240727,
+    "PHONE": null,
+    "RESPONSIBLE_USER_ID": 2240727,
+    "WEBSITE": null,
+    "ADDRESS_STREET": null,
+    "ADDRESS_CITY": null,
+    "ADDRESS_STATE": null,
+    "ADDRESS_POSTCODE": null,
+    "ADDRESS_COUNTRY": "United States",
+    "LAST_ACTIVITY_DATE_UTC": null,
+    "NEXT_ACTIVITY_DATE_UTC": null,
+    "VISIBLE_TO": "EVERYONE",
+    "VISIBLE_TEAM_ID": null,
+    "ORGANISATION_NAME": null,
+    "CREATED_USER_ID": 2240727,
+    "IMAGE_URL": null,
+    "EMAIL_OPTED_OUT": false,
+    "CUSTOMFIELDS": [],
+    "TAGS": [
+      {
+        "TAG_NAME": "Warsaw"
+      }
+    ],
+    "LINKS": [],
+    "LINKED_EMAIL_ADDRESS": "integration.test-L78563828-H4PRTS1@mailbox.insight.ly"
+  }
+]
diff --git a/providers/insightly/test/read/leads/3-last-page.json b/providers/insightly/test/read/leads/3-last-page.json
new file mode 100644
index 00000000..fe51488c
--- /dev/null
+++ b/providers/insightly/test/read/leads/3-last-page.json
@@ -0,0 +1,1 @@
+[]
diff --git a/test/insightly/read/contacts/main.go b/test/insightly/read/contacts/main.go
new file mode 100644
index 00000000..1ca5bc0d
--- /dev/null
+++ b/test/insightly/read/contacts/main.go
@@ -0,0 +1,38 @@
+package main
+
+import (
+	"context"
+	"log/slog"
+	"os"
+	"os/signal"
+	"syscall"
+
+	"github.com/amp-labs/connectors"
+	"github.com/amp-labs/connectors/common"
+	connTest "github.com/amp-labs/connectors/test/insightly"
+	"github.com/amp-labs/connectors/test/utils"
+)
+
+const objectName = "Contacts"
+
+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.GetInsightlyConnector(ctx)
+
+	res, err := conn.Read(ctx, common.ReadParams{
+		ObjectName: objectName,
+		Fields:     connectors.Fields("EMAIL_ADDRESS", "CONTACT_ID"),
+	})
+	if err != nil {
+		utils.Fail("error reading from provider", "error", err)
+	}
+
+	slog.Info("Reading objects..")
+	utils.DumpJSON(res, os.Stdout)
+}
diff --git a/test/insightly/read/leads/main.go b/test/insightly/read/leads/main.go
new file mode 100644
index 00000000..0be1e3ee
--- /dev/null
+++ b/test/insightly/read/leads/main.go
@@ -0,0 +1,38 @@
+package main
+
+import (
+	"context"
+	"log/slog"
+	"os"
+	"os/signal"
+	"syscall"
+
+	"github.com/amp-labs/connectors"
+	"github.com/amp-labs/connectors/common"
+	connTest "github.com/amp-labs/connectors/test/insightly"
+	"github.com/amp-labs/connectors/test/utils"
+)
+
+const objectName = "Leads"
+
+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.GetInsightlyConnector(ctx)
+
+	res, err := conn.Read(ctx, common.ReadParams{
+		ObjectName: objectName,
+		Fields:     connectors.Fields("FIRST_NAME", "LEAD_ID"),
+	})
+	if err != nil {
+		utils.Fail("error reading from provider", "error", err)
+	}
+
+	slog.Info("Reading objects..")
+	utils.DumpJSON(res, os.Stdout)
+}