Skip to content

Commit 24a4f58

Browse files
committed
feat(mock-test): Introduce BatchWrite test case
1 parent 40b29f7 commit 24a4f58

8 files changed

Lines changed: 239 additions & 32 deletions

File tree

common/construct.go

Lines changed: 12 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -34,14 +34,14 @@ var ErrNumWriteResultExceedsTotalRecords = errors.New(
3434
// and
3535
// len(Results) ≤ totalNumRecords.
3636
//
37-
// - fatalErrors represent top-level (batch-level) errors that may coexist
38-
// with item-level successes. For example, partial API failures or warnings
39-
// that affected only some records.
37+
// - unmatchedErrors represent provider responses that could not be associated
38+
// with specific payload items — for example, schema validation issues or
39+
// general API errors returned alongside per-record results.
4040
//
4141
// Constructors may return an error to signal invalid or inconsistent usage
4242
// rather than to represent runtime provider failures.
4343
func NewBatchWriteResult(
44-
results []WriteResult, successCounter, totalNumRecords int, fatalErrors []any,
44+
results []WriteResult, successCounter, totalNumRecords int, unmatchedErrors []any,
4545
) (*BatchWriteResult, error) {
4646
if len(results) > totalNumRecords {
4747
return nil, errors.Join(ErrInvalidImplementation, ErrNumWriteResultExceedsTotalRecords)
@@ -55,7 +55,7 @@ func NewBatchWriteResult(
5555

5656
return &BatchWriteResult{
5757
Status: newBatchStatus(successCounter, failureCounter, totalNumRecords),
58-
Errors: fatalErrors,
58+
Errors: unmatchedErrors,
5959
Results: results,
6060
SuccessCount: successCounter,
6161
FailureCount: failureCounter,
@@ -67,18 +67,17 @@ func NewBatchWriteResult(
6767
// BatchStatus as failure. The constructor still validates that the number of
6868
// WriteResult entries does not exceed totalNumRecords.
6969
//
70-
// fatalErrors may include provider-level or transport-level issues explaining
71-
// the batch failure.
70+
// unmatchedErrors may include provider-level issues explaining the failure that cannot be tied to specific records.
7271
func NewBatchWriteResultFailed(
73-
results []WriteResult, totalNumRecords int, fatalErrors []any,
72+
results []WriteResult, totalNumRecords int, unmatchedErrors []any,
7473
) (*BatchWriteResult, error) {
7574
if len(results) > totalNumRecords {
7675
return nil, errors.Join(ErrInvalidImplementation, ErrNumWriteResultExceedsTotalRecords)
7776
}
7877

7978
return &BatchWriteResult{
8079
Status: newBatchStatus(0, totalNumRecords, totalNumRecords),
81-
Errors: fatalErrors,
80+
Errors: unmatchedErrors,
8281
Results: results,
8382
SuccessCount: 0,
8483
FailureCount: totalNumRecords,
@@ -146,14 +145,14 @@ var ErrBatchUnprocessedRecord = errors.New("record was not processed due to othe
146145
// payloadItems - list of items that are part of payload to create/update each record.
147146
// responseMatcher - list of items that are part of payload to create/update each record.
148147
// responseToResult - a transformer that converts a matched item pair (payload P, response R) into a WriteResult.
149-
// fatalErrors – top-level errors not tied to individual records,
148+
// unmatchedErrors – top-level errors not tied to individual records,
150149
//
151150
// such as validation failures detected before response matching.
152151
func ParseBatchWrite[P, R any](
153152
payloadItems []P,
154153
responseMatcher BatchWriteResponseMatcher[P, R],
155154
responseToResult BatchWriteResponseTransformer[P, R],
156-
fatalErrors []any,
155+
unmatchedErrors []any,
157156
) (*BatchWriteResult, error) {
158157
var (
159158
totalNumRecords = len(payloadItems)
@@ -170,7 +169,7 @@ func ParseBatchWrite[P, R any](
170169

171170
result, err := responseToResult(record, response)
172171
if err != nil {
173-
fatalErrors = append(fatalErrors, err)
172+
unmatchedErrors = append(unmatchedErrors, err)
174173

175174
// Record cannot be added into the list of results ([]WriteResult).
176175
continue
@@ -185,7 +184,7 @@ func ParseBatchWrite[P, R any](
185184
}
186185
}
187186

188-
return NewBatchWriteResult(results, successCounter, totalNumRecords, fatalErrors)
187+
return NewBatchWriteResult(results, successCounter, totalNumRecords, unmatchedErrors)
189188
}
190189

191190
func countSuccesses(results []WriteResult) int {

common/types.go

Lines changed: 10 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -341,20 +341,20 @@ func (p BatchWriteParam) GetRecords() ([]Record, error) {
341341
})
342342
}
343343

344-
// BatchWriteResult aggregates the outcome of a synchronous batch write operation.
345-
// It provides both a high-level summary of the batch outcome and detailed results
346-
// for records that could be matched back to specific payload items.
344+
// BatchWriteResult represents the outcome of a provider batch write operation.
347345
//
348-
// The HubSpot connector (and potentially others) may return more errors than the number
349-
// of submitted payload items, or omit per-record identifiers altogether. In such cases,
350-
// unidentifiable errors are included in the top-level Errors slice.
346+
// It contains both a high-level summary of the batch and detailed per-record results.
351347
//
352-
// Each identifiable record — that is, one that could be matched by reference ID or
353-
// record ID — contributes a WriteResult entry in Results. If a record failed for
354-
// multiple identifiable reasons, they are grouped under that record’s WriteResult.Errors.
348+
// Providers may return more errors than there are payload items, or omit identifiers
349+
// that would allow matching errors to specific records. In such cases, unmatched or
350+
// batch-level issues are collected in the top-level Errors slice.
351+
//
352+
// Each identifiable record—matched by reference ID or record ID—produces a WriteResult
353+
// entry in Results. If multiple identifiable errors occurred for the same record, they
354+
// are grouped under WriteResult.Errors.
355355
//
356356
// Top-level Errors represent issues that apply to the batch as a whole or to records
357-
// that could not be reliably matched back to specific payload items.
357+
// that could not be reliably matched to payload items.
358358
type BatchWriteResult struct {
359359
// Status summarizes the batch outcome (success, failure, or partial).
360360
Status BatchStatus
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
package mockutils
2+
3+
import (
4+
"github.com/amp-labs/connectors/common"
5+
)
6+
7+
// BatchWriteResultComparator provides utility methods for comparing BatchWriteResult structures in tests.
8+
//
9+
// Unlike reflect.DeepEqual, these comparators support flexible (subset-based)
10+
// data matching, allowing assertions on only relevant fields.
11+
var BatchWriteResultComparator = batchWriteResultComparator{}
12+
13+
type batchWriteResultComparator struct{}
14+
15+
// SubsetWriteResults compares two BatchWriteResult objects and returns true
16+
// if each WriteResult in `expected` matches its corresponding entry in `actual`.
17+
//
18+
// A match is defined as follows:
19+
// - Subset equality for the Data field of each WriteResult (only expected keys/values are checked).
20+
// - Normalized equality for Errors, supporting struct/JSON, string, or golang error comparison.
21+
// - Exact equality for the Success and RecordId fields.
22+
func (batchWriteResultComparator) SubsetWriteResults(actual, expected *common.BatchWriteResult) bool {
23+
if len(actual.Results) != len(expected.Results) {
24+
return false
25+
}
26+
27+
// Compare each result using existing comparator
28+
for i := range len(actual.Results) {
29+
actualResult := &actual.Results[i]
30+
expectedResult := &expected.Results[i]
31+
32+
a := WriteResultComparator.SubsetData(actualResult, expectedResult)
33+
b := ErrorNormalizedComparator.EachErrorEquals(actualResult.Errors, expectedResult.Errors)
34+
c := actualResult.Success == expectedResult.Success &&
35+
actualResult.RecordId == expectedResult.RecordId
36+
37+
if !(a && b && c) {
38+
return false
39+
}
40+
}
41+
42+
return true
43+
}

test/utils/mockutils/errors.go

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,5 +26,18 @@ func errorsAre(actualError error, expectedErrors ExpectedSubsetErrors) bool {
2626
}
2727

2828
func (e ExpectedSubsetErrors) Error() string {
29-
return errors.Join(e...).Error()
29+
joined := errors.Join(e...)
30+
if joined == nil {
31+
return ""
32+
}
33+
34+
return joined.Error()
3035
}
36+
37+
// JSONErrorWrapper marks a string literal as a JSON structure to be compared semantically.
38+
//
39+
// When used in test expectations, this signals the comparator to treat the wrapped
40+
// value as JSON — it will parse both sides and compare their data structures instead
41+
// of comparing raw strings. This allows tests to assert equality between Go structs
42+
// and their expected JSON representation in error results.
43+
type JSONErrorWrapper string
Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
1+
package mockutils
2+
3+
import (
4+
"encoding/json"
5+
"errors"
6+
"fmt"
7+
"reflect"
8+
"strings"
9+
)
10+
11+
// ErrorNormalizedComparator provides helper methods to compare error values
12+
// of arbitrary types (errors, structs, strings, or JSON).
13+
//
14+
// It performs flexible equality checks that normalize differences in format,
15+
// allowing robust test assertions across heterogeneous error types.
16+
var ErrorNormalizedComparator = errorNormalizedComparator{}
17+
18+
type errorNormalizedComparator struct{}
19+
20+
// ErrorEquals compares two arbitrary error representations for semantic equality.
21+
//
22+
// Comparison is performed in the following order:
23+
// 1. Direct equality via reflect.DeepEqual.
24+
// 2. If both values implement error, compare using errors.Is or substring match.
25+
// 3. If the expected value is a JSONErrorWrapper, compare by marshaling the
26+
// actual value to JSON and checking structural equality.
27+
// 4. Fallback: string comparison via fmt.Sprintf("%v").
28+
//
29+
// It returns true if the two values are considered equivalent under these rules.
30+
func (errorNormalizedComparator) ErrorEquals(actualErr, expectedErr any) bool {
31+
// 1. Direct equality first.
32+
if reflect.DeepEqual(actualErr, expectedErr) {
33+
return true
34+
}
35+
36+
// 2. If both implement error, compare semantically.
37+
aErr, aOK := actualErr.(error)
38+
eErr, eOL := expectedErr.(error)
39+
if aOK && eOL {
40+
if errors.Is(aErr, eErr) || strings.Contains(aErr.Error(), eErr.Error()) {
41+
return true
42+
}
43+
return false
44+
}
45+
46+
// 3. Handle JSON case if expected is a JSON string.
47+
if expectedJSON, ok := expectedErr.(JSONErrorWrapper); ok {
48+
aJSON, err := json.Marshal(actualErr)
49+
if err != nil {
50+
return false
51+
}
52+
53+
if jsonBodyMatch(aJSON, string(expectedJSON)) {
54+
return true
55+
}
56+
57+
return false
58+
}
59+
60+
// 4. Fallback string-based comparison.
61+
aStr := fmt.Sprintf("%v", actualErr)
62+
eStr := fmt.Sprintf("%v", expectedErr)
63+
if aStr == eStr {
64+
return true
65+
}
66+
67+
return false
68+
}
69+
70+
// EachErrorEquals compares two slices of heterogeneous error values.
71+
// It returns true if each corresponding pair of elements is considered equal
72+
// according to ErrorEquals.
73+
//
74+
// Order and slice length must match exactly.
75+
func (c errorNormalizedComparator) EachErrorEquals(actual, expected []any) bool {
76+
if len(actual) != len(expected) {
77+
return false
78+
}
79+
80+
for i := range len(actual) {
81+
if !c.ErrorEquals(actual[i], expected[i]) {
82+
return false
83+
}
84+
}
85+
86+
return true
87+
}
88+
89+
func jsonBodyMatch(actual []byte, expected string) bool {
90+
first := make(map[string]any)
91+
if err := json.Unmarshal(actual, &first); err != nil {
92+
return false
93+
}
94+
95+
second := make(map[string]any)
96+
if err := json.Unmarshal([]byte(expected), &second); err != nil {
97+
return false
98+
}
99+
100+
return reflect.DeepEqual(first, second)
101+
}

test/utils/mockutils/writeResult.go

Lines changed: 0 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -30,8 +30,3 @@ func (writeResultComparator) SubsetData(actual, expected *common.WriteResult) bo
3030

3131
return true
3232
}
33-
34-
// ExactErrors uses strict error comparison.
35-
func (writeResultComparator) ExactErrors(actual, expected *common.WriteResult) bool {
36-
return reflect.DeepEqual(actual.Errors, expected.Errors)
37-
}
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
package testroutines
2+
3+
import (
4+
"context"
5+
"testing"
6+
7+
"github.com/amp-labs/connectors"
8+
"github.com/amp-labs/connectors/common"
9+
)
10+
11+
type (
12+
BatchWriteType = TestCase[*common.BatchWriteParam, *common.BatchWriteResult]
13+
// BatchWrite is a test suite useful for testing connectors.BatchWriteConnector interface.
14+
BatchWrite BatchWriteType
15+
)
16+
17+
// Run provides a procedure to test connectors.BatchWriteConnector
18+
func (m BatchWrite) Run(t *testing.T, builder ConnectorBuilder[connectors.BatchWriteConnector]) {
19+
t.Helper()
20+
t.Cleanup(func() {
21+
BatchWriteType(m).Close()
22+
})
23+
24+
conn := builder.Build(t, m.Name)
25+
output, err := conn.BatchWrite(context.Background(), m.Input)
26+
BatchWriteType(m).Validate(t, err, output)
27+
}

test/utils/testroutines/comparator.go

Lines changed: 32 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -78,15 +78,44 @@ func compareNextPageToken(actual, expected string) bool {
7878
return actualURL.Equals(expectedURL)
7979
}
8080

81-
// ComparatorSubsetWrite ensures that only the specified metadata objects are present,
82-
// while other values are verified through an exact match..
81+
// ComparatorSubsetWrite compares two WriteResult objects, allowing partial
82+
// (subset) matching for Data fields while requiring exact matches for Success and RecordId.
83+
//
84+
// It provides flexible error comparison logic:
85+
// - Errors are normalized before comparison, allowing strings, Go error types,
86+
// and mockutils.JSONErrorWrapper values (for JSON-based or struct comparison)
87+
// to be treated uniformly.
88+
//
89+
// This comparator is typically used when only a subset of Data fields
90+
// needs verification rather than a full equality check.
8391
func ComparatorSubsetWrite(_ string, actual, expected *common.WriteResult) bool {
8492
return mockutils.WriteResultComparator.SubsetData(actual, expected) &&
85-
mockutils.WriteResultComparator.ExactErrors(actual, expected) &&
93+
mockutils.ErrorNormalizedComparator.EachErrorEquals(actual.Errors, expected.Errors) &&
8694
actual.Success == expected.Success &&
8795
actual.RecordId == expected.RecordId
8896
}
8997

98+
// ComparatorSubsetBatchWrite compares two BatchWriteResult objects,
99+
// performing subset matching for individual WriteResult entries while
100+
// ensuring batch-level metrics (Status, SuccessCount, FailureCount) match exactly.
101+
//
102+
// Error comparison is normalized, allowing flexible matches between
103+
// strings, Go errors, and mockutils.JSONErrorWrapper values—useful when
104+
// top-level or per-record errors are represented as structs or JSON.
105+
//
106+
// This enables expressive, stable tests that verify meaningful fields
107+
// without enforcing strict structural equality across the entire batch.
108+
func ComparatorSubsetBatchWrite(_ string, actual, expected *common.BatchWriteResult) bool {
109+
if actual.Status != expected.Status ||
110+
actual.SuccessCount != expected.SuccessCount ||
111+
actual.FailureCount != expected.FailureCount {
112+
return false
113+
}
114+
115+
return mockutils.BatchWriteResultComparator.SubsetWriteResults(actual, expected) &&
116+
mockutils.ErrorNormalizedComparator.EachErrorEquals(actual.Errors, expected.Errors)
117+
}
118+
90119
// ComparatorSubsetMetadata will check a subset of fields is present.
91120
// Errors could be an exact match for each object or subset can be used as well.
92121
// This must be done by specifying expected errors using mockutils.ExpectedSubsetErrors.

0 commit comments

Comments
 (0)