Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 12 additions & 13 deletions common/construct.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -55,7 +55,7 @@ func NewBatchWriteResult(

return &BatchWriteResult{
Status: newBatchStatus(successCounter, failureCounter, totalNumRecords),
Errors: fatalErrors,
Errors: unmatchedErrors,
Results: results,
SuccessCount: successCounter,
FailureCount: failureCounter,
Expand All @@ -67,18 +67,17 @@ 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)
}

return &BatchWriteResult{
Status: newBatchStatus(0, totalNumRecords, totalNumRecords),
Errors: fatalErrors,
Errors: unmatchedErrors,
Results: results,
SuccessCount: 0,
FailureCount: totalNumRecords,
Expand Down Expand Up @@ -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)
Expand All @@ -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
Expand All @@ -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 {
Expand Down
20 changes: 10 additions & 10 deletions common/types.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
43 changes: 43 additions & 0 deletions test/utils/mockutils/batchWriteResult.go
Original file line number Diff line number Diff line change
@@ -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
}
15 changes: 14 additions & 1 deletion test/utils/mockutils/errors.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
101 changes: 101 additions & 0 deletions test/utils/mockutils/normalizedErrors.go
Original file line number Diff line number Diff line change
@@ -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)
}
5 changes: 0 additions & 5 deletions test/utils/mockutils/writeResult.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
27 changes: 27 additions & 0 deletions test/utils/testroutines/batch-write.go
Original file line number Diff line number Diff line change
@@ -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)
}
35 changes: 32 additions & 3 deletions test/utils/testroutines/comparator.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down