diff --git a/common/construct.go b/common/construct.go index e83e127d58..a8cc2bc122 100644 --- a/common/construct.go +++ b/common/construct.go @@ -34,14 +34,14 @@ var ErrNumWriteResultExceedsTotalRecords = errors.New( // and // len(Results) ≤ totalNumRecords. // -// - fatalErrors represent top-level (batch-level) errors that may coexist -// with item-level successes. For example, partial API failures or warnings -// that affected only some records. +// - unmatchedErrors represent provider responses that could not be associated +// with specific payload items — for example, schema validation issues or +// general API errors returned alongside per-record results. // // Constructors may return an error to signal invalid or inconsistent usage // rather than to represent runtime provider failures. func NewBatchWriteResult( - results []WriteResult, successCounter, totalNumRecords int, fatalErrors []any, + results []WriteResult, successCounter, totalNumRecords int, unmatchedErrors []any, ) (*BatchWriteResult, error) { if len(results) > totalNumRecords { return nil, errors.Join(ErrInvalidImplementation, ErrNumWriteResultExceedsTotalRecords) @@ -55,7 +55,7 @@ func NewBatchWriteResult( return &BatchWriteResult{ Status: newBatchStatus(successCounter, failureCounter, totalNumRecords), - Errors: fatalErrors, + Errors: unmatchedErrors, Results: results, SuccessCount: successCounter, FailureCount: failureCounter, @@ -67,10 +67,9 @@ func NewBatchWriteResult( // BatchStatus as failure. The constructor still validates that the number of // WriteResult entries does not exceed totalNumRecords. // -// fatalErrors may include provider-level or transport-level issues explaining -// the batch failure. +// unmatchedErrors may include provider-level issues explaining the failure that cannot be tied to specific records. func NewBatchWriteResultFailed( - results []WriteResult, totalNumRecords int, fatalErrors []any, + results []WriteResult, totalNumRecords int, unmatchedErrors []any, ) (*BatchWriteResult, error) { if len(results) > totalNumRecords { return nil, errors.Join(ErrInvalidImplementation, ErrNumWriteResultExceedsTotalRecords) @@ -78,7 +77,7 @@ func NewBatchWriteResultFailed( return &BatchWriteResult{ Status: newBatchStatus(0, totalNumRecords, totalNumRecords), - Errors: fatalErrors, + Errors: unmatchedErrors, Results: results, SuccessCount: 0, FailureCount: totalNumRecords, @@ -146,14 +145,14 @@ var ErrBatchUnprocessedRecord = errors.New("record was not processed due to othe // payloadItems - list of items that are part of payload to create/update each record. // responseMatcher - list of items that are part of payload to create/update each record. // responseToResult - a transformer that converts a matched item pair (payload P, response R) into a WriteResult. -// fatalErrors – top-level errors not tied to individual records, +// unmatchedErrors – top-level errors not tied to individual records, // // such as validation failures detected before response matching. func ParseBatchWrite[P, R any]( payloadItems []P, responseMatcher BatchWriteResponseMatcher[P, R], responseToResult BatchWriteResponseTransformer[P, R], - fatalErrors []any, + unmatchedErrors []any, ) (*BatchWriteResult, error) { var ( totalNumRecords = len(payloadItems) @@ -170,7 +169,7 @@ func ParseBatchWrite[P, R any]( result, err := responseToResult(record, response) if err != nil { - fatalErrors = append(fatalErrors, err) + unmatchedErrors = append(unmatchedErrors, err) // Record cannot be added into the list of results ([]WriteResult). continue @@ -185,7 +184,7 @@ func ParseBatchWrite[P, R any]( } } - return NewBatchWriteResult(results, successCounter, totalNumRecords, fatalErrors) + return NewBatchWriteResult(results, successCounter, totalNumRecords, unmatchedErrors) } func countSuccesses(results []WriteResult) int { diff --git a/common/types.go b/common/types.go index 14c8cc5dc2..8677ed35b1 100644 --- a/common/types.go +++ b/common/types.go @@ -341,20 +341,20 @@ func (p BatchWriteParam) GetRecords() ([]Record, error) { }) } -// BatchWriteResult aggregates the outcome of a synchronous batch write operation. -// It provides both a high-level summary of the batch outcome and detailed results -// for records that could be matched back to specific payload items. +// BatchWriteResult represents the outcome of a provider batch write operation. // -// The HubSpot connector (and potentially others) may return more errors than the number -// of submitted payload items, or omit per-record identifiers altogether. In such cases, -// unidentifiable errors are included in the top-level Errors slice. +// It contains both a high-level summary of the batch and detailed per-record results. // -// Each identifiable record — that is, one that could be matched by reference ID or -// record ID — contributes a WriteResult entry in Results. If a record failed for -// multiple identifiable reasons, they are grouped under that record’s WriteResult.Errors. +// Providers may return more errors than there are payload items, or omit identifiers +// that would allow matching errors to specific records. In such cases, unmatched or +// batch-level issues are collected in the top-level Errors slice. +// +// Each identifiable record—matched by reference ID or record ID—produces a WriteResult +// entry in Results. If multiple identifiable errors occurred for the same record, they +// are grouped under WriteResult.Errors. // // Top-level Errors represent issues that apply to the batch as a whole or to records -// that could not be reliably matched back to specific payload items. +// that could not be reliably matched to payload items. type BatchWriteResult struct { // Status summarizes the batch outcome (success, failure, or partial). Status BatchStatus diff --git a/test/utils/mockutils/batchWriteResult.go b/test/utils/mockutils/batchWriteResult.go new file mode 100644 index 0000000000..3d6491345a --- /dev/null +++ b/test/utils/mockutils/batchWriteResult.go @@ -0,0 +1,43 @@ +package mockutils + +import ( + "github.com/amp-labs/connectors/common" +) + +// BatchWriteResultComparator provides utility methods for comparing BatchWriteResult structures in tests. +// +// Unlike reflect.DeepEqual, these comparators support flexible (subset-based) +// data matching, allowing assertions on only relevant fields. +var BatchWriteResultComparator = batchWriteResultComparator{} + +type batchWriteResultComparator struct{} + +// SubsetWriteResults compares two BatchWriteResult objects and returns true +// if each WriteResult in `expected` matches its corresponding entry in `actual`. +// +// A match is defined as follows: +// - Subset equality for the Data field of each WriteResult (only expected keys/values are checked). +// - Normalized equality for Errors, supporting struct/JSON, string, or golang error comparison. +// - Exact equality for the Success and RecordId fields. +func (batchWriteResultComparator) SubsetWriteResults(actual, expected *common.BatchWriteResult) bool { + if len(actual.Results) != len(expected.Results) { + return false + } + + // Compare each result using existing comparator + for i := range len(actual.Results) { + actualResult := &actual.Results[i] + expectedResult := &expected.Results[i] + + a := WriteResultComparator.SubsetData(actualResult, expectedResult) + b := ErrorNormalizedComparator.EachErrorEquals(actualResult.Errors, expectedResult.Errors) + c := actualResult.Success == expectedResult.Success && + actualResult.RecordId == expectedResult.RecordId + + if !(a && b && c) { + return false + } + } + + return true +} diff --git a/test/utils/mockutils/errors.go b/test/utils/mockutils/errors.go index 5adb72a037..a621e1e5ff 100644 --- a/test/utils/mockutils/errors.go +++ b/test/utils/mockutils/errors.go @@ -26,5 +26,18 @@ func errorsAre(actualError error, expectedErrors ExpectedSubsetErrors) bool { } func (e ExpectedSubsetErrors) Error() string { - return errors.Join(e...).Error() + joined := errors.Join(e...) + if joined == nil { + return "" + } + + return joined.Error() } + +// JSONErrorWrapper marks a string literal as a JSON structure to be compared semantically. +// +// When used in test expectations, this signals the comparator to treat the wrapped +// value as JSON — it will parse both sides and compare their data structures instead +// of comparing raw strings. This allows tests to assert equality between Go structs +// and their expected JSON representation in error results. +type JSONErrorWrapper string diff --git a/test/utils/mockutils/normalizedErrors.go b/test/utils/mockutils/normalizedErrors.go new file mode 100644 index 0000000000..701ce0cd98 --- /dev/null +++ b/test/utils/mockutils/normalizedErrors.go @@ -0,0 +1,101 @@ +package mockutils + +import ( + "encoding/json" + "errors" + "fmt" + "reflect" + "strings" +) + +// ErrorNormalizedComparator provides helper methods to compare error values +// of arbitrary types (errors, structs, strings, or JSON). +// +// It performs flexible equality checks that normalize differences in format, +// allowing robust test assertions across heterogeneous error types. +var ErrorNormalizedComparator = errorNormalizedComparator{} + +type errorNormalizedComparator struct{} + +// ErrorEquals compares two arbitrary error representations for semantic equality. +// +// Comparison is performed in the following order: +// 1. Direct equality via reflect.DeepEqual. +// 2. If both values implement error, compare using errors.Is or substring match. +// 3. If the expected value is a JSONErrorWrapper, compare by marshaling the +// actual value to JSON and checking structural equality. +// 4. Fallback: string comparison via fmt.Sprintf("%v"). +// +// It returns true if the two values are considered equivalent under these rules. +func (errorNormalizedComparator) ErrorEquals(actualErr, expectedErr any) bool { + // 1. Direct equality first. + if reflect.DeepEqual(actualErr, expectedErr) { + return true + } + + // 2. If both implement error, compare semantically. + aErr, aOK := actualErr.(error) + eErr, eOL := expectedErr.(error) + if aOK && eOL { + if errors.Is(aErr, eErr) || strings.Contains(aErr.Error(), eErr.Error()) { + return true + } + return false + } + + // 3. Handle JSON case if expected is a JSON string. + if expectedJSON, ok := expectedErr.(JSONErrorWrapper); ok { + aJSON, err := json.Marshal(actualErr) + if err != nil { + return false + } + + if jsonBodyMatch(aJSON, string(expectedJSON)) { + return true + } + + return false + } + + // 4. Fallback string-based comparison. + aStr := fmt.Sprintf("%v", actualErr) + eStr := fmt.Sprintf("%v", expectedErr) + if aStr == eStr { + return true + } + + return false +} + +// EachErrorEquals compares two slices of heterogeneous error values. +// It returns true if each corresponding pair of elements is considered equal +// according to ErrorEquals. +// +// Order and slice length must match exactly. +func (c errorNormalizedComparator) EachErrorEquals(actual, expected []any) bool { + if len(actual) != len(expected) { + return false + } + + for i := range len(actual) { + if !c.ErrorEquals(actual[i], expected[i]) { + return false + } + } + + return true +} + +func jsonBodyMatch(actual []byte, expected string) bool { + first := make(map[string]any) + if err := json.Unmarshal(actual, &first); err != nil { + return false + } + + second := make(map[string]any) + if err := json.Unmarshal([]byte(expected), &second); err != nil { + return false + } + + return reflect.DeepEqual(first, second) +} diff --git a/test/utils/mockutils/writeResult.go b/test/utils/mockutils/writeResult.go index c3fbbf584d..43ffed858d 100644 --- a/test/utils/mockutils/writeResult.go +++ b/test/utils/mockutils/writeResult.go @@ -30,8 +30,3 @@ func (writeResultComparator) SubsetData(actual, expected *common.WriteResult) bo return true } - -// ExactErrors uses strict error comparison. -func (writeResultComparator) ExactErrors(actual, expected *common.WriteResult) bool { - return reflect.DeepEqual(actual.Errors, expected.Errors) -} diff --git a/test/utils/testroutines/batch-write.go b/test/utils/testroutines/batch-write.go new file mode 100644 index 0000000000..2599faeb95 --- /dev/null +++ b/test/utils/testroutines/batch-write.go @@ -0,0 +1,27 @@ +package testroutines + +import ( + "context" + "testing" + + "github.com/amp-labs/connectors" + "github.com/amp-labs/connectors/common" +) + +type ( + BatchWriteType = TestCase[*common.BatchWriteParam, *common.BatchWriteResult] + // BatchWrite is a test suite useful for testing connectors.BatchWriteConnector interface. + BatchWrite BatchWriteType +) + +// Run provides a procedure to test connectors.BatchWriteConnector +func (m BatchWrite) Run(t *testing.T, builder ConnectorBuilder[connectors.BatchWriteConnector]) { + t.Helper() + t.Cleanup(func() { + BatchWriteType(m).Close() + }) + + conn := builder.Build(t, m.Name) + output, err := conn.BatchWrite(context.Background(), m.Input) + BatchWriteType(m).Validate(t, err, output) +} diff --git a/test/utils/testroutines/comparator.go b/test/utils/testroutines/comparator.go index 7498247d82..696f1d5ee8 100644 --- a/test/utils/testroutines/comparator.go +++ b/test/utils/testroutines/comparator.go @@ -78,15 +78,44 @@ func compareNextPageToken(actual, expected string) bool { return actualURL.Equals(expectedURL) } -// ComparatorSubsetWrite ensures that only the specified metadata objects are present, -// while other values are verified through an exact match.. +// ComparatorSubsetWrite compares two WriteResult objects, allowing partial +// (subset) matching for Data fields while requiring exact matches for Success and RecordId. +// +// It provides flexible error comparison logic: +// - Errors are normalized before comparison, allowing strings, Go error types, +// and mockutils.JSONErrorWrapper values (for JSON-based or struct comparison) +// to be treated uniformly. +// +// This comparator is typically used when only a subset of Data fields +// needs verification rather than a full equality check. func ComparatorSubsetWrite(_ string, actual, expected *common.WriteResult) bool { return mockutils.WriteResultComparator.SubsetData(actual, expected) && - mockutils.WriteResultComparator.ExactErrors(actual, expected) && + mockutils.ErrorNormalizedComparator.EachErrorEquals(actual.Errors, expected.Errors) && actual.Success == expected.Success && actual.RecordId == expected.RecordId } +// ComparatorSubsetBatchWrite compares two BatchWriteResult objects, +// performing subset matching for individual WriteResult entries while +// ensuring batch-level metrics (Status, SuccessCount, FailureCount) match exactly. +// +// Error comparison is normalized, allowing flexible matches between +// strings, Go errors, and mockutils.JSONErrorWrapper values—useful when +// top-level or per-record errors are represented as structs or JSON. +// +// This enables expressive, stable tests that verify meaningful fields +// without enforcing strict structural equality across the entire batch. +func ComparatorSubsetBatchWrite(_ string, actual, expected *common.BatchWriteResult) bool { + if actual.Status != expected.Status || + actual.SuccessCount != expected.SuccessCount || + actual.FailureCount != expected.FailureCount { + return false + } + + return mockutils.BatchWriteResultComparator.SubsetWriteResults(actual, expected) && + mockutils.ErrorNormalizedComparator.EachErrorEquals(actual.Errors, expected.Errors) +} + // ComparatorSubsetMetadata will check a subset of fields is present. // Errors could be an exact match for each object or subset can be used as well. // This must be done by specifying expected errors using mockutils.ExpectedSubsetErrors.