Repository: amp-labs/connectors
Pull request #646 (2024-07-02 14:05:08)

test: mock Salesforce Read method
# Mock Tests

Added mock tests for salesforce Read operation.
Used common packages to simplify code.
By making server requests collected its output and stored under `salesforce/test/*.json`.

There was no way to make server return `nextRecordsUrl`. This requires over 2000 items. Such scenario was forged to match [documentation.](https://developer.salesforce.com/docs/atlas.en-us.api_rest.meta/api_rest/dome_query.htm)


Patch:
diff --git a/common/types.go b/common/types.go
index db632e52..45ec89e7 100644
--- a/common/types.go
+++ b/common/types.go
@@ -65,12 +65,12 @@ var (
 	// ErrMissingRecordID is returned when resource id is missing in the request.
 	ErrMissingRecordID = errors.New("no object ID provided")
 
+	// ErrMissingFields is returned when no fields are provided for reading.
+	ErrMissingFields = errors.New("no fields provided in ReadParams")
+
 	// ErrInvalidPathJoin is returned when the path join is invalid.
 	ErrInvalidPathJoin = errors.New("invalid path join")
 
-	// ErrReadFile is returned when the path is invalid.
-	ErrReadFile = errors.New("failed to read file")
-
 	// ErrRequestFailed is returned when the request failed.
 	ErrRequestFailed = errors.New("request failed")
 
diff --git a/salesforce/bulk.go b/salesforce/bulk.go
index 85d20456..5daf12b0 100644
--- a/salesforce/bulk.go
+++ b/salesforce/bulk.go
@@ -30,7 +30,6 @@ const (
 
 var (
 	ErrKeyNotFound          = errors.New("key not found")
-	ErrUnknownNodeType      = errors.New("unknown node type when parsing JSON")
 	ErrInvalidType          = errors.New("invalid type")
 	ErrInvalidJobState      = errors.New("invalid job state")
 	ErrUnsupportedMode      = errors.New("unsupported mode")
diff --git a/salesforce/connector.go b/salesforce/connector.go
index 8a1b4d24..9b6bdcbe 100644
--- a/salesforce/connector.go
+++ b/salesforce/connector.go
@@ -4,6 +4,7 @@ import (
 	"fmt"
 
 	"github.com/amp-labs/connectors/common"
+	"github.com/amp-labs/connectors/common/interpreter"
 	"github.com/amp-labs/connectors/common/urlbuilder"
 	"github.com/amp-labs/connectors/providers"
 )
@@ -54,7 +55,10 @@ func NewConnector(opts ...Option) (conn *Connector, outErr error) {
 	}
 
 	conn.setBaseURL(providerInfo.BaseURL)
-	conn.Client.HTTPClient.ErrorHandler = conn.interpretError
+	conn.Client.HTTPClient.ErrorHandler = interpreter.ErrorHandler{
+		JSON: conn.interpretJSONError,
+	}.Handle
+	// attach error post-processing
 	conn.Client.ErrorPostProcessor.Process = handleError
 
 	return conn, nil
diff --git a/salesforce/errors.go b/salesforce/errors.go
index 983cfadd..21ff8839 100644
--- a/salesforce/errors.go
+++ b/salesforce/errors.go
@@ -14,12 +14,9 @@ import (
 var (
 	ErrNotArray           = errors.New("records is not an array")
 	ErrNotObject          = errors.New("record isn't an object")
-	ErrNoFields           = errors.New("no fields specified")
 	ErrNotString          = errors.New("nextRecordsUrl isn't a string")
 	ErrNotBool            = errors.New("done isn't a boolean")
 	ErrNotNumeric         = errors.New("totalSize isn't numeric")
-	ErrMissingWorkspace   = errors.New("missing Salesforce workspace name")
-	ErrMissingClient      = errors.New("JSON http client not set")
 	ErrCannotReadMetadata = errors.New("cannot read object metadata, it is possible you don't have the correct permissions set") // nolint:lll
 )
 
@@ -28,14 +25,6 @@ type jsonError struct {
 	ErrorCode string `json:"errorCode"`
 }
 
-func (c *Connector) interpretError(res *http.Response, body []byte) error {
-	if res.Header.Get("Content-Type") == "application/json" {
-		return c.interpretJSONError(res, body)
-	}
-
-	return common.InterpretError(res, body)
-}
-
 func createError(baseErr error, sfErr jsonError) error {
 	if len(sfErr.Message) > 0 {
 		return fmt.Errorf("%w: %s", baseErr, sfErr.Message)
@@ -44,8 +33,8 @@ func createError(baseErr error, sfErr jsonError) error {
 	return baseErr
 }
 
-func (c *Connector) interpretJSONError(res *http.Response, body []byte) error {
-	errs := []jsonError{}
+func (c *Connector) interpretJSONError(res *http.Response, body []byte) error { // nolint:cyclop
+	var errs []jsonError
 	if err := json.Unmarshal(body, &errs); err != nil {
 		return fmt.Errorf("json.Unmarshal failed: %w", err)
 	}
@@ -64,6 +53,8 @@ func (c *Connector) interpretJSONError(res *http.Response, body []byte) error {
 			return createError(common.ErrInvalidGrant, sfErr)
 		case "REQUEST_LIMIT_EXCEEDED":
 			return createError(common.ErrLimitExceeded, sfErr)
+		case "INVALID_TYPE":
+			return createError(common.ErrBadRequest, sfErr)
 		default:
 			continue
 		}
diff --git a/salesforce/parse.go b/salesforce/parse.go
index 3974699e..72f3908e 100644
--- a/salesforce/parse.go
+++ b/salesforce/parse.go
@@ -1,78 +1,41 @@
 package salesforce
 
 import (
+	"errors"
+
 	"github.com/amp-labs/connectors/common"
+	"github.com/amp-labs/connectors/common/jsonquery"
 	"github.com/spyzhov/ajson"
 )
 
-// getRecords returns the records from the response.
-func getRecords(node *ajson.Node) ([]map[string]interface{}, error) {
-	records, err := node.GetKey("records")
+// getTotalSize returns the total number of records that match the query.
+func getTotalSize(node *ajson.Node) (int64, error) {
+	size, err := jsonquery.New(node).Integer("totalSize", false)
 	if err != nil {
-		return nil, err
-	}
-
-	if !records.IsArray() {
-		return nil, ErrNotArray
-	}
-
-	arr := records.MustArray()
-
-	out := make([]map[string]interface{}, 0, len(arr))
-
-	for _, v := range arr {
-		if !v.IsObject() {
-			return nil, ErrNotObject
-		}
-
-		data, err := v.Unpack()
-		if err != nil {
-			return nil, err
-		}
-
-		m, ok := data.(map[string]interface{})
-		if !ok {
-			return nil, ErrNotObject
-		}
-
-		out = append(out, m)
-	}
-
-	return out, nil
-}
-
-// getNextRecordsURL returns the URL for the next page of results.
-func getNextRecordsURL(node *ajson.Node) (string, error) {
-	var nextPage string
-
-	if node.HasKey("nextRecordsUrl") {
-		next, err := node.GetKey("nextRecordsUrl")
-		if err != nil {
-			return "", err
-		}
-
-		if !next.IsString() {
-			return "", ErrNotString
+		if !errors.Is(err, jsonquery.ErrKeyNotFound) {
+			return 0, err
 		}
 
-		nextPage = next.MustString()
+		// The totalSize key was missing. Try to manually count the number of records
+		return jsonquery.New(node).ArraySize("records")
 	}
 
-	return nextPage, nil
+	return *size, nil
 }
 
-// getTotalSize returns the total number of records that match the query.
-func getTotalSize(node *ajson.Node) (int64, error) {
-	node, err := node.GetKey("totalSize")
+// getRecords returns the records from the response.
+func getRecords(node *ajson.Node) ([]map[string]any, error) {
+	records, err := jsonquery.New(node).Array("records", false)
 	if err != nil {
-		return 0, err
+		return nil, err
 	}
 
-	if !node.IsNumeric() {
-		return 0, ErrNotNumeric
-	}
+	return jsonquery.Convertor.ArrayToMap(records)
+}
 
-	return int64(node.MustNumeric()), nil
+// getNextRecordsURL returns the URL for the next page of results.
+func getNextRecordsURL(node *ajson.Node) (string, error) {
+	return jsonquery.New(node).StrWithDefault("nextRecordsUrl", "")
 }
 
 func getMarshaledData(records []map[string]interface{}, fields []string) ([]common.ReadResultRow, error) {
diff --git a/salesforce/read.go b/salesforce/read.go
index 95b70e80..94b3da8f 100644
--- a/salesforce/read.go
+++ b/salesforce/read.go
@@ -60,7 +60,7 @@ func (c *Connector) buildReadURL(config common.ReadParams) (*urlbuilder.URL, err
 func makeSOQL(config common.ReadParams) (string, error) {
 	// Make sure we have at least one field
 	if len(config.Fields) == 0 {
-		return "", ErrNoFields
+		return "", common.ErrMissingFields
 	}
 
 	// Get the field set in SOQL format


Test patch:
diff --git a/salesforce/read_test.go b/salesforce/read_test.go
new file mode 100644
index 00000000..c97a1c54
--- /dev/null
+++ b/salesforce/read_test.go
@@ -0,0 +1,214 @@
+package salesforce
+
+import (
+	"context"
+	"errors"
+	"net/http"
+	"net/http/httptest"
+	"reflect"
+	"strings"
+	"testing"
+
+	"github.com/amp-labs/connectors/common"
+	"github.com/amp-labs/connectors/common/interpreter"
+	"github.com/amp-labs/connectors/common/jsonquery"
+	"github.com/amp-labs/connectors/test/utils/mockutils"
+	"github.com/go-test/deep"
+)
+
+func TestRead(t *testing.T) { //nolint:funlen,gocognit,cyclop,maintidx
+	t.Parallel()
+
+	responseUnknownObject := mockutils.DataFromFile(t, "unknown-object.json")
+	responseLeadsFirstPage := mockutils.DataFromFile(t, "read-list-leads.json")
+	responseListContacts := mockutils.DataFromFile(t, "read-list-contacts.json")
+
+	tests := []struct {
+		name         string
+		input        common.ReadParams
+		server       *httptest.Server
+		connector    Connector
+		comparator   func(serverURL string, actual, expected *common.ReadResult) bool // custom comparison
+		expected     *common.ReadResult
+		expectedErrs []error
+	}{
+		{
+			name: "At least one field must be provided",
+			server: httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+				w.WriteHeader(http.StatusTeapot)
+			})),
+			expectedErrs: []error{common.ErrMissingFields},
+		},
+		{
+			name:  "Mime response header expected",
+			input: common.ReadParams{Fields: []string{"Name"}},
+			server: httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+				w.WriteHeader(http.StatusTeapot)
+			})),
+			expectedErrs: []error{interpreter.ErrMissingContentType},
+		},
+		{
+			name:  "Correct error message is understood from JSON response",
+			input: common.ReadParams{Fields: []string{"Name"}},
+			server: httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+				w.Header().Set("Content-Type", "application/json")
+				w.WriteHeader(http.StatusBadRequest)
+				_, _ = w.Write(responseUnknownObject)
+			})),
+			expectedErrs: []error{
+				common.ErrBadRequest, errors.New("sObject type 'Accout' is not supported"), // nolint:goerr113
+			},
+		},
+		{
+			name:  "Incorrect key in payload",
+			input: common.ReadParams{Fields: []string{"Name"}},
+			server: httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+				w.Header().Set("Content-Type", "application/json")
+				w.WriteHeader(http.StatusOK)
+				mockutils.WriteBody(w, `{
+					"garbage": {}
+				}`)
+			})),
+			expectedErrs: []error{jsonquery.ErrKeyNotFound},
+		},
+		{
+			name:  "Incorrect data type in payload",
+			input: common.ReadParams{Fields: []string{"Name"}},
+			server: httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+				w.Header().Set("Content-Type", "application/json")
+				w.WriteHeader(http.StatusOK)
+				mockutils.WriteBody(w, `{
+					"records": {}
+				}`)
+			})),
+			expectedErrs: []error{jsonquery.ErrNotArray},
+		},
+		{
+			name:  "Next page cursor may be missing in payload",
+			input: common.ReadParams{Fields: []string{"Name"}},
+			server: httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+				w.Header().Set("Content-Type", "application/json")
+				w.WriteHeader(http.StatusOK)
+				mockutils.WriteBody(w, `
+				{
+				  "records": []
+				}`)
+			})),
+			expected: &common.ReadResult{
+				Data: []common.ReadResultRow{},
+				Done: true,
+			},
+			expectedErrs: nil,
+		},
+		{
+			name:  "Next page URL is resolved, when provided with a string",
+			input: common.ReadParams{Fields: []string{"City"}},
+			server: httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+				w.Header().Set("Content-Type", "application/json")
+				w.WriteHeader(http.StatusOK)
+				_, _ = w.Write(responseLeadsFirstPage)
+			})),
+			comparator: func(baseURL string, actual, expected *common.ReadResult) bool {
+				return actual.NextPage.String() == expected.NextPage.String()
+			},
+			expected: &common.ReadResult{
+				NextPage: "/services/data/v59.0/query/01g3A00007lZwLKQA0-2000",
+			},
+			expectedErrs: nil,
+		},
+		{
+			name: "Successful read with chosen fields",
+			input: common.ReadParams{
+				Fields: []string{"Department", "AssistantName"},
+			},
+			server: httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+				w.Header().Set("Content-Type", "application/json")
+				w.WriteHeader(http.StatusOK)
+				_, _ = w.Write(responseListContacts)
+			})),
+			comparator: func(baseURL string, actual, expected *common.ReadResult) bool {
+				// custom comparison focuses on subset of fields to keep the test short
+				return mockutils.ReadResultComparator.SubsetFields(actual, expected) &&
+					mockutils.ReadResultComparator.SubsetRaw(actual, expected) &&
+					actual.NextPage.String() == expected.NextPage.String() &&
+					actual.Done == expected.Done &&
+					actual.Rows == expected.Rows
+			},
+			expected: &common.ReadResult{
+				Rows: 20,
+				Data: []common.ReadResultRow{{
+					Fields: map[string]any{
+						"department":    "Finance",
+						"assistantname": "Jean Marie",
+					},
+					Raw: map[string]any{
+						"AccountId":     "001ak00000OKNPHAA5",
+						"Department":    "Finance",
+						"AssistantName": "Jean Marie",
+						"Description":   nil,
+					},
+				}},
+				NextPage: "",
+				Done:     true,
+			},
+			expectedErrs: nil,
+		},
+	}
+
+	for _, tt := range tests {
+		// nolint:varnamelen
+		tt := tt // rebind, omit loop side effects for parallel goroutine
+		t.Run(tt.name, func(t *testing.T) {
+			t.Parallel()
+
+			defer tt.server.Close()
+
+			ctx := context.Background()
+
+			connector, err := NewConnector(
+				WithAuthenticatedClient(http.DefaultClient),
+				WithWorkspace("test-workspace"),
+			)
+			if err != nil {
+				t.Fatalf("%s: error in test while constructing connector %v", tt.name, err)
+			}
+
+			// for testing we want to redirect calls to our mock server
+			connector.setBaseURL(tt.server.URL)
+
+			// start of tests
+			output, err := connector.Read(ctx, tt.input)
+			if err != nil {
+				if len(tt.expectedErrs) == 0 {
+					t.Fatalf("%s: expected no errors, got: (%v)", tt.name, err)
+				}
+			} else {
+				// check that missing error is what is expected
+				if len(tt.expectedErrs) != 0 {
+					t.Fatalf("%s: expected errors (%v), but got nothing", tt.name, tt.expectedErrs)
+				}
+			}
+
+			// check every error
+			for _, expectedErr := range tt.expectedErrs {
+				if !errors.Is(err, expectedErr) && !strings.Contains(err.Error(), expectedErr.Error()) {
+					t.Fatalf("%s: expected Error: (%v), got: (%v)", tt.name, expectedErr, err)
+				}
+			}
+
+			// compare desired output
+			var ok bool
+			if tt.comparator == nil {
+				// default comparison is concerned about all fields
+				ok = reflect.DeepEqual(output, tt.expected)
+			} else {
+				ok = tt.comparator(tt.server.URL, output, tt.expected)
+			}
+
+			if !ok {
+				diff := deep.Equal(output, tt.expected)
+				t.Fatalf("%s:, \nexpected: (%v), \ngot: (%v), \ndiff: (%v)", tt.name, tt.expected, output, diff)
+			}
+		})
+	}
+}
diff --git a/salesforce/test/read-list-contacts.json b/salesforce/test/read-list-contacts.json
new file mode 100644
index 00000000..026bfd70
--- /dev/null
+++ b/salesforce/test/read-list-contacts.json
@@ -0,0 +1,206 @@
+{
+  "totalSize": 20,
+  "done": true,
+  "records": [
+    {
+      "attributes": {
+        "type": "Contact",
+        "url": "/services/data/v59.0/sobjects/Contact/003ak000003dQCGAA2"
+      },
+      "AccountId": "001ak00000OKNPHAA5",
+      "Department": "Finance",
+      "AssistantName": "Jean Marie",
+      "Description": null
+    },
+    {
+      "attributes": {
+        "type": "Contact",
+        "url": "/services/data/v59.0/sobjects/Contact/003ak000003dQCDAA2"
+      },
+      "AccountId": "001ak00000OKNPFAA5",
+      "Department": "Procurement",
+      "AssistantName": null,
+      "Description": null
+    },
+    {
+      "attributes": {
+        "type": "Contact",
+        "url": "/services/data/v59.0/sobjects/Contact/003ak000003dQCEAA2"
+      },
+      "AccountId": "001ak00000OKNPFAA5",
+      "Department": "Finance",
+      "AssistantName": null,
+      "Description": null
+    },
+    {
+      "attributes": {
+        "type": "Contact",
+        "url": "/services/data/v59.0/sobjects/Contact/003ak000003dQCFAA2"
+      },
+      "AccountId": "001ak00000OKNPGAA5",
+      "Department": null,
+      "AssistantName": null,
+      "Description": null
+    },
+    {
+      "attributes": {
+        "type": "Contact",
+        "url": "/services/data/v59.0/sobjects/Contact/003ak000003dQCHAA2"
+      },
+      "AccountId": "001ak00000OKNPIAA5",
+      "Department": "Internal Operations",
+      "AssistantName": null,
+      "Description": null
+    },
+    {
+      "attributes": {
+        "type": "Contact",
+        "url": "/services/data/v59.0/sobjects/Contact/003ak000003dQCIAA2"
+      },
+      "AccountId": "001ak00000OKNPJAA5",
+      "Department": "Finance",
+      "AssistantName": null,
+      "Description": null
+    },
+    {
+      "attributes": {
+        "type": "Contact",
+        "url": "/services/data/v59.0/sobjects/Contact/003ak000003dQCJAA2"
+      },
+      "AccountId": "001ak00000OKNPJAA5",
+      "Department": "Facilities",
+      "AssistantName": null,
+      "Description": null
+    },
+    {
+      "attributes": {
+        "type": "Contact",
+        "url": "/services/data/v59.0/sobjects/Contact/003ak000003dQCKAA2"
+      },
+      "AccountId": "001ak00000OKNPKAA5",
+      "Department": "Production",
+      "AssistantName": "Audrey Haynes",
+      "Description": null
+    },
+    {
+      "attributes": {
+        "type": "Contact",
+        "url": "/services/data/v59.0/sobjects/Contact/003ak000003dQCLAA2"
+      },
+      "AccountId": "001ak00000OKNPKAA5",
+      "Department": "Technology",
+      "AssistantName": null,
+      "Description": null
+    },
+    {
+      "attributes": {
+        "type": "Contact",
+        "url": "/services/data/v59.0/sobjects/Contact/003ak000003dQCMAA2"
+      },
+      "AccountId": "001ak00000OKNPLAA5",
+      "Department": "Operations",
+      "AssistantName": "Ron Sage",
+      "Description": null
+    },
+    {
+      "attributes": {
+        "type": "Contact",
+        "url": "/services/data/v59.0/sobjects/Contact/003ak000003dQCNAA2"
+      },
+      "AccountId": "001ak00000OKNPLAA5",
+      "Department": "Warehouse Mgmt",
+      "AssistantName": null,
+      "Description": null
+    },
+    {
+      "attributes": {
+        "type": "Contact",
+        "url": "/services/data/v59.0/sobjects/Contact/003ak000003dQCOAA2"
+      },
+      "AccountId": "001ak00000OKNPMAA5",
+      "Department": "Administration",
+      "AssistantName": null,
+      "Description": null
+    },
+    {
+      "attributes": {
+        "type": "Contact",
+        "url": "/services/data/v59.0/sobjects/Contact/003ak000003dQCPAA2"
+      },
+      "AccountId": "001ak00000OKNPKAA5",
+      "Department": "Executive Team",
+      "AssistantName": "Pat Feinstein",
+      "Description": null
+    },
+    {
+      "attributes": {
+        "type": "Contact",
+        "url": "/services/data/v59.0/sobjects/Contact/003ak000003dQCQAA2"
+      },
+      "AccountId": "001ak00000OKNPNAA5",
+      "Department": "Finance",
+      "AssistantName": null,
+      "Description": null
+    },
+    {
+      "attributes": {
+        "type": "Contact",
+        "url": "/services/data/v59.0/sobjects/Contact/003ak000003dQCRAA2"
+      },
+      "AccountId": "001ak00000OKNPOAA5",
+      "Department": "Executive Team",
+      "AssistantName": null,
+      "Description": null
+    },
+    {
+      "attributes": {
+        "type": "Contact",
+        "url": "/services/data/v59.0/sobjects/Contact/003ak000003dQCSAA2"
+      },
+      "AccountId": "001ak00000OKNPOAA5",
+      "Department": "Production",
+      "AssistantName": null,
+      "Description": null
+    },
+    {
+      "attributes": {
+        "type": "Contact",
+        "url": "/services/data/v59.0/sobjects/Contact/003ak000003dQCTAA2"
+      },
+      "AccountId": "001ak00000OKNPPAA5",
+      "Department": "Technology",
+      "AssistantName": null,
+      "Description": null
+    },
+    {
+      "attributes": {
+        "type": "Contact",
+        "url": "/services/data/v59.0/sobjects/Contact/003ak000003dQCUAA2"
+      },
+      "AccountId": "001ak00000OKNPKAA5",
+      "Department": "Finance",
+      "AssistantName": "Chris Nobel",
+      "Description": null
+    },
+    {
+      "attributes": {
+        "type": "Contact",
+        "url": "/services/data/v59.0/sobjects/Contact/003ak000003dQCVAA2"
+      },
+      "AccountId": "001ak00000OKNPQAA5",
+      "Department": null,
+      "AssistantName": null,
+      "Description": null
+    },
+    {
+      "attributes": {
+        "type": "Contact",
+        "url": "/services/data/v59.0/sobjects/Contact/003ak000003dQCWAA2"
+      },
+      "AccountId": "001ak00000OKNPQAA5",
+      "Department": null,
+      "AssistantName": null,
+      "Description": null
+    }
+  ]
+}
diff --git a/salesforce/test/read-list-leads.json b/salesforce/test/read-list-leads.json
new file mode 100644
index 00000000..903d0e25
--- /dev/null
+++ b/salesforce/test/read-list-leads.json
@@ -0,0 +1,159 @@
+{
+  "totalSize": 8,
+  "done": true,
+  "nextRecordsUrl": "/services/data/v59.0/query/01g3A00007lZwLKQA0-2000",
+  "records": [
+    {
+      "attributes": {
+        "type": "Lead",
+        "url": "/services/data/v59.0/sobjects/Lead/00Qak00000696HFEAY"
+      },
+      "Address": {
+        "city": "Tallahassee",
+        "country": "USA",
+        "geocodeAccuracy": null,
+        "latitude": null,
+        "longitude": null,
+        "postalCode": "32306",
+        "state": "FL",
+        "street": "321 Westcott Building"
+      },
+      "City": "Tallahassee",
+      "CleanStatus": "Pending",
+      "FirstName": "Bertha"
+    },
+    {
+      "attributes": {
+        "type": "Lead",
+        "url": "/services/data/v59.0/sobjects/Lead/00Qak00000696HGEAY"
+      },
+      "Address": {
+        "city": null,
+        "country": "USA",
+        "geocodeAccuracy": null,
+        "latitude": null,
+        "longitude": null,
+        "postalCode": null,
+        "state": "VA",
+        "street": null
+      },
+      "City": null,
+      "CleanStatus": "Pending",
+      "FirstName": "Phyllis"
+    },
+    {
+      "attributes": {
+        "type": "Lead",
+        "url": "/services/data/v59.0/sobjects/Lead/00Qak00000696HHEAY"
+      },
+      "Address": {
+        "city": null,
+        "country": "Taiwan",
+        "geocodeAccuracy": null,
+        "latitude": null,
+        "longitude": null,
+        "postalCode": null,
+        "state": null,
+        "street": null
+      },
+      "City": null,
+      "CleanStatus": "Pending",
+      "FirstName": "Jeff"
+    },
+    {
+      "attributes": {
+        "type": "Lead",
+        "url": "/services/data/v59.0/sobjects/Lead/00Qak00000696HIEAY"
+      },
+      "Address": {
+        "city": null,
+        "country": "USA",
+        "geocodeAccuracy": null,
+        "latitude": null,
+        "longitude": null,
+        "postalCode": null,
+        "state": "MD",
+        "street": null
+      },
+      "City": null,
+      "CleanStatus": "Pending",
+      "FirstName": "Mike"
+    },
+    {
+      "attributes": {
+        "type": "Lead",
+        "url": "/services/data/v59.0/sobjects/Lead/00Qak00000696HJEAY"
+      },
+      "Address": {
+        "city": null,
+        "country": "USA",
+        "geocodeAccuracy": null,
+        "latitude": null,
+        "longitude": null,
+        "postalCode": null,
+        "state": "NC",
+        "street": null
+      },
+      "City": null,
+      "CleanStatus": "Pending",
+      "FirstName": "Patricia"
+    },
+    {
+      "attributes": {
+        "type": "Lead",
+        "url": "/services/data/v59.0/sobjects/Lead/00Qak00000696HKEAY"
+      },
+      "Address": {
+        "city": null,
+        "country": "USA",
+        "geocodeAccuracy": null,
+        "latitude": null,
+        "longitude": null,
+        "postalCode": null,
+        "state": "IL",
+        "street": null
+      },
+      "City": null,
+      "CleanStatus": "Pending",
+      "FirstName": "Brenda"
+    },
+    {
+      "attributes": {
+        "type": "Lead",
+        "url": "/services/data/v59.0/sobjects/Lead/00Qak00000696HLEAY"
+      },
+      "Address": {
+        "city": null,
+        "country": "USA",
+        "geocodeAccuracy": null,
+        "latitude": null,
+        "longitude": null,
+        "postalCode": null,
+        "state": "GA",
+        "street": null
+      },
+      "City": null,
+      "CleanStatus": "Pending",
+      "FirstName": "Violet"
+    },
+    {
+      "attributes": {
+        "type": "Lead",
+        "url": "/services/data/v59.0/sobjects/Lead/00Qak00000696HMEAY"
+      },
+      "Address": {
+        "city": null,
+        "country": "USA",
+        "geocodeAccuracy": null,
+        "latitude": null,
+        "longitude": null,
+        "postalCode": null,
+        "state": "CT",
+        "street": null
+      },
+      "City": null,
+      "CleanStatus": "Pending",
+      "FirstName": "Kathy"
+    }
+  ]
+}
\ No newline at end of file
diff --git a/salesforce/test/unknown-object.json b/salesforce/test/unknown-object.json
new file mode 100644
index 00000000..981ecb6b
--- /dev/null
+++ b/salesforce/test/unknown-object.json
@@ -0,0 +1,6 @@
+[
+  {
+    "message": "\nName,BillingCity,IsDeleted FROM Accout\n                                ^\nERROR at Row:1:Column:43\nsObject type 'Accout' is not supported. If you are attempting to use a custom object, be sure to append the '__c' after the entity name. Please reference your WSDL or the describe call for the appropriate names.",
+    "errorCode": "INVALID_TYPE"
+  }
+]