diff --git a/README.md b/README.md index 66b1db1..6d407c5 100644 --- a/README.md +++ b/README.md @@ -13,6 +13,8 @@ Currently, the following functions are implemented and more features could be ad - Get records via record (sobject) type and ID - Create records - Update records +- Update multiple records in one API call +- Upsert multiple records in one API call - Delete records - Upsert (create or update) records based on an external ID - Download a file @@ -162,6 +164,23 @@ func WorkWithRecords() { Update() // Update the record on Salesforce server. fmt.Println(updateObj) + // Update up to 200 records in one API call. Results correspond to records by index. + results, err := client.Update([]*simpleforce.SObject{ + client.SObject("Contact"). + Set("Id", "__CONTACT_ID__"). + Set("FirstName", "New Name"), + client.SObject("Case"). + Set("Id", "__CASE_ID__"). + Set("Subject", "New Subject"), + }, false) + if err != nil { + // The request failed before Salesforce could return per-record results. + return + } + for _, result := range results { + fmt.Println(result.ID, result.Success, result.Errors) + } + // For Upsert(), start with a blank SObject. // Upsert will create the object if it does not already exist and will update the object if it already exists. // Set "ExternalIDField" to the name of your external ID field @@ -175,6 +194,25 @@ func WorkWithRecords() { Upsert() // Update the record on Salesforce server. fmt.Println(upsertObj) + // Upsert up to 200 records of the same type in one API call. All records must use the same external ID field. + upsertResults, err := client.Upsert([]*simpleforce.SObject{ + client.SObject("Contact"). + Set("ExternalIDField", "customExtIdField__c"). + Set("customExtIdField__c", "__EXT_ID_1__"). + Set("FirstName", "First Contact"), + client.SObject("Contact"). + Set("ExternalIDField", "customExtIdField__c"). + Set("customExtIdField__c", 1002). // Numeric external IDs retain their JSON number type. + Set("FirstName", "Second Contact"), + }, false) + if err != nil { + // The request failed before Salesforce could return per-record results. + return + } + for _, result := range upsertResults { + fmt.Println(result.ID, result.Success, result.Created, result.Errors) + } + // Many SObject methods return the instance of the SObject, allowing chained access and operations to the // object. In the following example, all methods, except "Delete", returns *SObject so that the next method // can be invoked on the returned value directly. @@ -238,6 +276,23 @@ Salesforce account. The unit test requires a custom field `customExtIdField__c` to be present on the Type `Case` in your Salesforce setup. +### Sandbox Bulk Update Example + +`examples/bulk-update` creates two temporary Accounts, then sends one valid update and one intentionally invalid update +in a single request. It prints the per-record results and deletes both test Accounts before exiting. + +```sh +export SF_URL=https://test.salesforce.com +export SF_USER='sandbox-user@example.com.sandbox' +export SF_PASS='password' +export SF_TOKEN='security-token' + +go run ./examples/bulk-update +``` + +`SF_VALUE` optionally sets the successful Account `Description` update. `SF_CLIENT_ID` and `SF_API_VERSION` are also +optional. + ## License and Acknowledgement This package is released under BSD license. Part of the code referenced the simple-salesforce diff --git a/examples/bulk-update/main.go b/examples/bulk-update/main.go new file mode 100644 index 0000000..ca723b7 --- /dev/null +++ b/examples/bulk-update/main.go @@ -0,0 +1,106 @@ +package main + +import ( + "fmt" + "log" + "os" + "time" + + "github.com/simpleforce/simpleforce" +) + +func main() { + username := requiredEnv("SF_USER") + password := requiredEnv("SF_PASS") + + client := simpleforce.NewClient( + envOrDefault("SF_URL", "https://test.salesforce.com"), + envOrDefault("SF_CLIENT_ID", simpleforce.DefaultClientID), + envOrDefault("SF_API_VERSION", simpleforce.DefaultAPIVersion), + ) + if err := client.LoginPassword(username, password, os.Getenv("SF_TOKEN")); err != nil { + log.Fatalf("sandbox login failed: %v", err) + } + if err := run(client); err != nil { + log.Fatal(err) + } +} + +func run(client *simpleforce.Client) error { + timestamp := time.Now().Format(time.RFC3339Nano) + value := envOrDefault("SF_VALUE", "Bulk update test "+time.Now().Format(time.RFC3339)) + first := client.SObject("Account"). + Set("Name", "simpleforce bulk update test 1 "+timestamp). + Create() + if first == nil { + return fmt.Errorf("failed to create first test Account") + } + defer deleteTestRecord(first) + + second := client.SObject("Account"). + Set("Name", "simpleforce bulk update test 2 "+timestamp). + Create() + if second == nil { + return fmt.Errorf("failed to create second test Account") + } + defer deleteTestRecord(second) + + fmt.Printf("created test Accounts %s and %s\n", first.ID(), second.ID()) + + records := []*simpleforce.SObject{ + client.SObject("Account"). + Set("Id", first.ID()). + Set("Description", value), + client.SObject("Account"). + Set("Id", second.ID()). + Set("Name", nil), + } + + results, err := client.Update(records, false) + if err != nil { + return fmt.Errorf("bulk update request failed: %w", err) + } + + successes := 0 + failures := 0 + for index, result := range results { + fmt.Printf("record %d: id=%s success=%t\n", index, result.ID, result.Success) + if result.Success { + successes++ + continue + } + + failures++ + for _, resultError := range result.Errors { + fmt.Printf(" error: statusCode=%s message=%q fields=%v\n", + resultError.StatusCode, resultError.Message, resultError.Fields) + } + } + + if successes == 0 || failures == 0 { + return fmt.Errorf("expected at least one success and one failure; got %d successes and %d failures", + successes, failures) + } + return nil +} + +func deleteTestRecord(record *simpleforce.SObject) { + if err := record.Delete(); err != nil { + log.Printf("failed to delete test Account %s: %v", record.ID(), err) + } +} + +func requiredEnv(name string) string { + value := os.Getenv(name) + if value == "" { + log.Fatalf("%s is required", name) + } + return value +} + +func envOrDefault(name, defaultValue string) string { + if value := os.Getenv(name); value != "" { + return value + } + return defaultValue +} diff --git a/sobject.go b/sobject.go index db02a51..27931bf 100644 --- a/sobject.go +++ b/sobject.go @@ -5,6 +5,8 @@ import ( "encoding/json" "log" "net/http" + neturl "net/url" + "strconv" "strings" "github.com/pkg/errors" @@ -15,6 +17,7 @@ const ( sobjectAttributesKey = "attributes" // points to the attributes structure which should be common to all SObjects. sobjectIDKey = "Id" sobjectExternalIDFieldNameKey = "ExternalIDField" + maxSObjectCollectionSize = 200 ) var ( @@ -53,6 +56,28 @@ type SObjectAttributes struct { URL string `json:"url"` } +// SObjectUpdateError describes an error returned for one record in an SObject collection update. +type SObjectUpdateError struct { + StatusCode string `json:"statusCode"` + Message string `json:"message"` + Fields []string `json:"fields"` +} + +// SObjectUpdateResult describes the result for one record in an SObject collection update. +type SObjectUpdateResult struct { + ID string `json:"id"` + Success bool `json:"success"` + Errors []SObjectUpdateError `json:"errors"` +} + +// SObjectUpsertResult describes the result for one record in an SObject collection upsert. +type SObjectUpsertResult struct { + ID string `json:"id"` + Success bool `json:"success"` + Created bool `json:"created"` + Errors []SObjectUpdateError `json:"errors"` +} + // Describe queries the metadata of an SObject using the "describe" API. // Ref: https://developer.salesforce.com/docs/atlas.en-us.214.0.api_rest.meta/api_rest/resources_sobject_describe.htm func (obj *SObject) Describe() *SObjectMeta { @@ -175,13 +200,75 @@ func (obj *SObject) Update() *SObject { return obj } +// Update updates up to 200 SObjects in a single API call. Results are returned in the same order as objects. +// If allOrNone is true, Salesforce rolls back all changes when any record fails. +// Ref: https://developer.salesforce.com/docs/atlas.en-us.api_rest.meta/api_rest/resources_composite_sobjects_collections_update.htm +func (client *Client) Update(objects []*SObject, allOrNone bool) ([]SObjectUpdateResult, error) { + if client == nil { + return nil, errors.New("client is required") + } + if !client.isLoggedIn() { + return nil, ErrAuthentication + } + if len(objects) == 0 || len(objects) > maxSObjectCollectionSize { + return nil, errors.Errorf("sobject collection must contain between 1 and %d records", maxSObjectCollectionSize) + } + + records := make([]map[string]interface{}, len(objects)) + for index, obj := range objects { + if obj == nil { + return nil, errors.Errorf("sobject at index %d is nil", index) + } + if obj.Type() == "" { + return nil, errors.Errorf("sobject at index %d is missing a type", index) + } + if obj.ID() == "" { + return nil, errors.Errorf("sobject at index %d is missing an ID", index) + } + + record := obj.makeCopy() + record[sobjectAttributesKey] = map[string]string{"type": obj.Type()} + record["id"] = obj.ID() + records[index] = record + } + + request := struct { + AllOrNone bool `json:"allOrNone"` + Records []map[string]interface{} `json:"records"` + }{ + AllOrNone: allOrNone, + Records: records, + } + reqData, err := json.Marshal(request) + if err != nil { + return nil, errors.Wrap(err, "failed to convert sobject collection to json") + } + + url := client.makeURL("composite/sobjects/") + respData, err := client.httpRequest(http.MethodPatch, url, bytes.NewReader(reqData)) + if err != nil { + return nil, errors.Wrap(err, "failed to update sobject collection") + } + + var results []SObjectUpdateResult + if err := json.Unmarshal(respData, &results); err != nil { + return nil, errors.Wrap(err, "failed to parse sobject collection response") + } + return results, nil +} + +// UpdateSObjects is an explicit alias for Update. +func (client *Client) UpdateSObjects(objects []*SObject, allOrNone bool) ([]SObjectUpdateResult, error) { + return client.Update(objects, allOrNone) +} + // Upsert creates SObject or updates existing SObject in place. Upon successful upsert, same SObject is returned for chained access. // ID, ExternalIDField and Type are required. ID is the value of the external ID in this case. func (obj *SObject) Upsert() *SObject { log.Println(logPrefix, "ExternalID:", obj.ExternalID()) log.Println(logPrefix, "ExternalIDField:", obj.ExternalIDFieldName()) if obj.Type() == "" || obj.client() == nil || obj.ExternalIDFieldName() == "" || - obj.ExternalID() == "" { + !obj.hasExternalID() { // Sanity check. log.Println(logPrefix, "required fields are missing") return nil @@ -200,7 +287,7 @@ func (obj *SObject) Upsert() *SObject { queryBase = "tooling/sobjects/" } url := obj.client(). - makeURL(queryBase + obj.Type() + "/" + obj.ExternalIDFieldName() + "/" + obj.ExternalID()) + makeURL(queryBase + obj.Type() + "/" + obj.ExternalIDFieldName() + "/" + neturl.PathEscape(obj.ExternalID())) respData, err := obj.client().httpRequest(http.MethodPatch, url, bytes.NewReader(reqData)) if err != nil { log.Println(logPrefix, "failed to process http request,", err) @@ -220,6 +307,88 @@ func (obj *SObject) Upsert() *SObject { return obj } +// Upsert creates or updates up to 200 SObjects by external ID in a single API call. +// All objects must have the same type and ExternalIDField. Results are returned in the same order as objects. +// If allOrNone is true, Salesforce rolls back all changes when any record fails. +// Ref: https://developer.salesforce.com/docs/atlas.en-us.api_rest.meta/api_rest/resources_composite_sobjects_collections_upsert.htm +func (client *Client) Upsert(objects []*SObject, allOrNone bool) ([]SObjectUpsertResult, error) { + if client == nil { + return nil, errors.New("client is required") + } + if !client.isLoggedIn() { + return nil, ErrAuthentication + } + if len(objects) == 0 || len(objects) > maxSObjectCollectionSize { + return nil, errors.Errorf("sobject collection must contain between 1 and %d records", maxSObjectCollectionSize) + } + + var objectType string + var externalIDField string + records := make([]map[string]interface{}, len(objects)) + for index, obj := range objects { + if obj == nil { + return nil, errors.Errorf("sobject at index %d is nil", index) + } + if obj.Type() == "" { + return nil, errors.Errorf("sobject at index %d is missing a type", index) + } + if obj.ExternalIDFieldName() == "" { + return nil, errors.Errorf("sobject at index %d is missing ExternalIDField", index) + } + if !obj.hasExternalID() { + return nil, errors.Errorf("sobject at index %d is missing external ID value", index) + } + + if index == 0 { + objectType = obj.Type() + externalIDField = obj.ExternalIDFieldName() + } else if obj.Type() != objectType { + return nil, errors.Errorf("sobject at index %d has type %q; expected %q", index, obj.Type(), objectType) + } else if obj.ExternalIDFieldName() != externalIDField { + return nil, errors.Errorf( + "sobject at index %d uses external ID field %q; expected %q", + index, + obj.ExternalIDFieldName(), + externalIDField, + ) + } + + record := obj.makeCopy() + record[sobjectAttributesKey] = map[string]string{"type": objectType} + record[externalIDField] = obj.ExternalIDValue() + records[index] = record + } + + request := struct { + AllOrNone bool `json:"allOrNone"` + Records []map[string]interface{} `json:"records"` + }{ + AllOrNone: allOrNone, + Records: records, + } + reqData, err := json.Marshal(request) + if err != nil { + return nil, errors.Wrap(err, "failed to convert sobject collection to json") + } + + url := client.makeURL("composite/sobjects/" + objectType + "/" + externalIDField) + respData, err := client.httpRequest(http.MethodPatch, url, bytes.NewReader(reqData)) + if err != nil { + return nil, errors.Wrap(err, "failed to upsert sobject collection") + } + + var results []SObjectUpsertResult + if err := json.Unmarshal(respData, &results); err != nil { + return nil, errors.Wrap(err, "failed to parse sobject collection response") + } + return results, nil +} + +// UpsertSObjects is an explicit alias for Upsert. +func (client *Client) UpsertSObjects(objects []*SObject, allOrNone bool) ([]SObjectUpsertResult, error) { + return client.Upsert(objects, allOrNone) +} + // Delete deletes an SObject record identified by external ID. nil is returned if the operation completes successfully; // otherwise an error is returned func (obj *SObject) Delete(id ...string) error { @@ -265,9 +434,60 @@ func (obj *SObject) ExternalIDFieldName() string { return obj.StringField(sobjectExternalIDFieldNameKey) } -// ExternalID returns the external ID of the SObject. +// ExternalID returns the external ID as a string for use in Salesforce resource URLs. func (obj *SObject) ExternalID() string { - return obj.StringField(obj.ExternalIDFieldName()) + value := obj.ExternalIDValue() + switch value := value.(type) { + case string: + return value + case json.Number: + return value.String() + case int: + return strconv.FormatInt(int64(value), 10) + case int8: + return strconv.FormatInt(int64(value), 10) + case int16: + return strconv.FormatInt(int64(value), 10) + case int32: + return strconv.FormatInt(int64(value), 10) + case int64: + return strconv.FormatInt(value, 10) + case uint: + return strconv.FormatUint(uint64(value), 10) + case uint8: + return strconv.FormatUint(uint64(value), 10) + case uint16: + return strconv.FormatUint(uint64(value), 10) + case uint32: + return strconv.FormatUint(uint64(value), 10) + case uint64: + return strconv.FormatUint(value, 10) + case float32: + return strconv.FormatFloat(float64(value), 'f', -1, 32) + case float64: + return strconv.FormatFloat(value, 'f', -1, 64) + default: + return "" + } +} + +// ExternalIDValue returns the external ID with its original type. +func (obj *SObject) ExternalIDValue() interface{} { + if obj.ExternalIDFieldName() == "" { + return nil + } + return obj.InterfaceField(obj.ExternalIDFieldName()) +} + +func (obj *SObject) hasExternalID() bool { + value := obj.ExternalIDValue() + if value == nil { + return false + } + if stringValue, ok := value.(string); ok { + return stringValue != "" + } + return obj.ExternalID() != "" } // StringField accesses a field in the SObject as string. Empty string is returned if the field doesn't exist. diff --git a/sobject_test.go b/sobject_test.go index a6556d0..8df5525 100644 --- a/sobject_test.go +++ b/sobject_test.go @@ -1,7 +1,10 @@ package simpleforce import ( + "encoding/json" "log" + "net/http" + "net/http/httptest" "testing" "time" @@ -199,6 +202,127 @@ func TestSObject_Update(t *testing.T) { } } +func TestClient_Update(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) { + if request.Method != http.MethodPatch { + t.Errorf("method = %s, want PATCH", request.Method) + } + if request.URL.Path != "/services/data/v54.0/composite/sobjects/" { + t.Errorf("path = %s, want /services/data/v54.0/composite/sobjects/", request.URL.Path) + } + + var body struct { + AllOrNone bool `json:"allOrNone"` + Records []map[string]interface{} `json:"records"` + } + if err := json.NewDecoder(request.Body).Decode(&body); err != nil { + t.Fatal(err) + } + if !body.AllOrNone { + t.Error("allOrNone = false, want true") + } + if len(body.Records) != 2 { + t.Fatalf("record count = %d, want 2", len(body.Records)) + } + + first := body.Records[0] + attributes, ok := first["attributes"].(map[string]interface{}) + if !ok || attributes["type"] != "Contact" { + t.Errorf("attributes = %#v, want Contact type", first["attributes"]) + } + if len(attributes) != 1 { + t.Errorf("attributes = %#v, want only type", attributes) + } + if first["id"] != "003000000000001" || first["FirstName"] != "Ada" { + t.Errorf("first record = %#v", first) + } + if _, ok := first[sobjectClientKey]; ok { + t.Error("request contains private client metadata") + } + if _, ok := first[sobjectIDKey]; ok { + t.Error("request contains duplicate Id field") + } + if _, ok := first["LastModifiedDate"]; ok { + t.Error("request contains read-only field") + } + + writer.Header().Set("Content-Type", "application/json") + _, _ = writer.Write([]byte(`[ + {"id":"003000000000001","success":true,"errors":[]}, + {"id":"500000000000001","success":false,"errors":[ + {"statusCode":"INVALID_FIELD","message":"Invalid field","fields":["Subject"]} + ]} + ]`)) + })) + defer server.Close() + + client := NewClient(server.URL, DefaultClientID, DefaultAPIVersion) + client.instanceURL = server.URL + client.sessionID = "test-session" + objects := []*SObject{ + client.SObject("Contact"). + Set("Id", "003000000000001"). + Set("FirstName", "Ada"). + Set("LastModifiedDate", "ignored"), + client.SObject("Case"). + Set("Id", "500000000000001"). + Set("Subject", "Updated"), + } + + results, err := client.Update(objects, true) + if err != nil { + t.Fatal(err) + } + if len(results) != 2 { + t.Fatalf("result count = %d, want 2", len(results)) + } + if !results[0].Success || results[0].ID != "003000000000001" { + t.Errorf("first result = %#v", results[0]) + } + if results[1].Success || len(results[1].Errors) != 1 { + t.Fatalf("second result = %#v", results[1]) + } + resultError := results[1].Errors[0] + if resultError.StatusCode != "INVALID_FIELD" || + resultError.Message != "Invalid field" || + len(resultError.Fields) != 1 || + resultError.Fields[0] != "Subject" { + t.Errorf("second result error = %#v", resultError) + } +} + +func TestClient_UpdateValidation(t *testing.T) { + client := NewClient(DefaultURL, DefaultClientID, DefaultAPIVersion) + client.sessionID = "test-session" + + tests := []struct { + name string + objects []*SObject + }{ + {name: "empty"}, + {name: "nil object", objects: []*SObject{nil}}, + {name: "missing type", objects: []*SObject{client.SObject().Set("Id", "001")}}, + {name: "missing ID", objects: []*SObject{client.SObject("Account")}}, + {name: "too many", objects: make([]*SObject, maxSObjectCollectionSize+1)}, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + if _, err := client.Update(test.objects, false); err == nil { + t.Fatal("expected validation error") + } + }) + } +} + +func TestClient_UpdateSObjects(t *testing.T) { + client := NewClient(DefaultURL, DefaultClientID, DefaultAPIVersion) + + if _, err := client.UpdateSObjects(nil, false); err != ErrAuthentication { + t.Fatalf("error = %v, want %v", err, ErrAuthentication) + } +} + func TestSObject_Upsert(t *testing.T) { client := requireClient(t, true) @@ -267,6 +391,185 @@ func TestSObject_Upsert(t *testing.T) { } } +func TestSObject_UpsertNumericExternalID(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) { + if request.Method != http.MethodPatch { + t.Errorf("method = %s, want PATCH", request.Method) + } + if request.URL.Path != "/services/data/v54.0/sobjects/Account/External_Key__c/0" { + t.Errorf( + "path = %s, want /services/data/v54.0/sobjects/Account/External_Key__c/0", + request.URL.Path, + ) + } + writer.WriteHeader(http.StatusNoContent) + })) + defer server.Close() + + client := NewClient(server.URL, DefaultClientID, DefaultAPIVersion) + client.instanceURL = server.URL + client.sessionID = "test-session" + obj := client.SObject("Account"). + Set("ExternalIDField", "External_Key__c"). + Set("External_Key__c", 0). + Set("Name", "Numeric external ID") + + if obj.ExternalID() != "0" { + t.Fatalf("ExternalID() = %q, want 0", obj.ExternalID()) + } + if obj.ExternalIDValue() != 0 { + t.Fatalf("ExternalIDValue() = %#v, want 0", obj.ExternalIDValue()) + } + if obj.Upsert() != obj { + t.Fatal("numeric external ID upsert failed") + } +} + +func TestClient_Upsert(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) { + if request.Method != http.MethodPatch { + t.Errorf("method = %s, want PATCH", request.Method) + } + if request.URL.Path != "/services/data/v54.0/composite/sobjects/Account/External_Key__c" { + t.Errorf( + "path = %s, want /services/data/v54.0/composite/sobjects/Account/External_Key__c", + request.URL.Path, + ) + } + + var body struct { + AllOrNone bool `json:"allOrNone"` + Records []map[string]interface{} `json:"records"` + } + if err := json.NewDecoder(request.Body).Decode(&body); err != nil { + t.Fatal(err) + } + if body.AllOrNone { + t.Error("allOrNone = true, want false") + } + if len(body.Records) != 2 { + t.Fatalf("record count = %d, want 2", len(body.Records)) + } + + first := body.Records[0] + attributes, ok := first["attributes"].(map[string]interface{}) + if !ok || attributes["type"] != "Account" { + t.Errorf("attributes = %#v, want Account type", first["attributes"]) + } + if first["External_Key__c"] != float64(1001) || first["Name"] != "First account" { + t.Errorf("first record = %#v", first) + } + if _, ok := first[sobjectExternalIDFieldNameKey]; ok { + t.Error("request contains private ExternalIDField metadata") + } + if _, ok := first[sobjectIDKey]; ok { + t.Error("request contains Salesforce record ID") + } + + writer.Header().Set("Content-Type", "application/json") + _, _ = writer.Write([]byte(`[ + {"id":"001000000000001","success":true,"created":true,"errors":[]}, + {"id":null,"success":false,"created":false,"errors":[ + {"statusCode":"INVALID_FIELD","message":"Invalid field","fields":["Invalid_Field__c"]} + ]} + ]`)) + })) + defer server.Close() + + client := NewClient(server.URL, DefaultClientID, DefaultAPIVersion) + client.instanceURL = server.URL + client.sessionID = "test-session" + objects := []*SObject{ + client.SObject("Account"). + Set("ExternalIDField", "External_Key__c"). + Set("External_Key__c", 1001). + Set("Name", "First account"), + client.SObject("Account"). + Set("ExternalIDField", "External_Key__c"). + Set("External_Key__c", "account-2"). + Set("Invalid_Field__c", "intentional failure"), + } + + results, err := client.Upsert(objects, false) + if err != nil { + t.Fatal(err) + } + if len(results) != 2 { + t.Fatalf("result count = %d, want 2", len(results)) + } + if !results[0].Success || !results[0].Created || results[0].ID != "001000000000001" { + t.Errorf("first result = %#v", results[0]) + } + if results[1].Success || results[1].Created || len(results[1].Errors) != 1 { + t.Fatalf("second result = %#v", results[1]) + } + resultError := results[1].Errors[0] + if resultError.StatusCode != "INVALID_FIELD" || + resultError.Message != "Invalid field" || + len(resultError.Fields) != 1 || + resultError.Fields[0] != "Invalid_Field__c" { + t.Errorf("second result error = %#v", resultError) + } +} + +func TestClient_UpsertValidation(t *testing.T) { + client := NewClient(DefaultURL, DefaultClientID, DefaultAPIVersion) + client.sessionID = "test-session" + + valid := func() *SObject { + return client.SObject("Account"). + Set("ExternalIDField", "External_Key__c"). + Set("External_Key__c", "account-1") + } + tests := []struct { + name string + objects []*SObject + }{ + {name: "empty"}, + {name: "nil object", objects: []*SObject{nil}}, + {name: "missing type", objects: []*SObject{ + client.SObject(). + Set("ExternalIDField", "External_Key__c"). + Set("External_Key__c", "account-1"), + }}, + {name: "missing external ID field", objects: []*SObject{ + client.SObject("Account"), + }}, + {name: "missing external ID value", objects: []*SObject{ + client.SObject("Account").Set("ExternalIDField", "External_Key__c"), + }}, + {name: "mismatched type", objects: []*SObject{ + valid(), + client.SObject("Contact"). + Set("ExternalIDField", "External_Key__c"). + Set("External_Key__c", "contact-1"), + }}, + {name: "mismatched external ID field", objects: []*SObject{ + valid(), + client.SObject("Account"). + Set("ExternalIDField", "Other_Key__c"). + Set("Other_Key__c", "account-2"), + }}, + {name: "too many", objects: make([]*SObject, maxSObjectCollectionSize+1)}, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + if _, err := client.Upsert(test.objects, false); err == nil { + t.Fatal("expected validation error") + } + }) + } +} + +func TestClient_UpsertSObjects(t *testing.T) { + client := NewClient(DefaultURL, DefaultClientID, DefaultAPIVersion) + + if _, err := client.UpsertSObjects(nil, false); err != ErrAuthentication { + t.Fatalf("error = %v, want %v", err, ErrAuthentication) + } +} + func TestSObject_Delete(t *testing.T) { client := requireClient(t, true)