diff --git a/test/utils/mockutils/jsonComparator.go b/test/utils/mockutils/jsonComparator.go new file mode 100644 index 0000000000..d5c227410f --- /dev/null +++ b/test/utils/mockutils/jsonComparator.go @@ -0,0 +1,82 @@ +package mockutils + +import ( + "encoding/json" + + "github.com/amp-labs/connectors/test/utils/testutils" +) + +// JSONComparator offers helpers for structural equality checks between +// arbitrary Go values and JSON documents. +// +// It provides a robust comparison mechanism for verifying JSON +// equivalence in tests, ignoring superficial differences such as +// whitespace, field order, or formatting. +// +// Example: +// +// type response struct { +// ID string `json:"id"` +// Name string `json:"name"` +// } +// +// actual := response{ID: "123", Name: "Alice"} +// expected := mockutils.JSONErrorWrapper(`{"name":"Alice","id":"123"}`) +// +// ok := mockutils.JSONComparator.Equals(actual, expected) +// // ok == true (field order does not matter) +// +// This utility is primarily used by ErrorNormalizedComparator +// to compare JSON-encoded error objects but can be reused +// directly in tests where normalized JSON equality is needed. +var JSONComparator = jsonComparator{} + +type jsonComparator struct{} + +// Equals reports whether the given Go value and a JSON-encoded +// expected representation are structurally equivalent. +// +// It returns true if both JSON documents represent identical key-value +// structures, ignoring field order and insignificant formatting. +// +// Any marshal or unmarshal failure results in false. +func (jsonComparator) Equals(expected string, actual any) *testutils.CompareResult { + result := testutils.NewCompareResult() + + actualBytes, err := json.Marshal(actual) + if err != nil { + result.AddDiff("jsonComparator.Equals: couldn't marshall: %v", actual) + return result + } + + actualJSON := make(map[string]any) + if err = json.Unmarshal(actualBytes, &actualJSON); err != nil { + result.AddDiff("jsonComparator.Equals: couldn't unmarshall 'actual' into map[string]any") + return result + } + + expectedJSON := make(map[string]any) + if err = json.Unmarshal([]byte(expected), &expectedJSON); err != nil { + result.AddDiff("jsonComparator.Equals: couldn't unmarshall 'expected' into map[string]any") + return result + } + + result.Assert("jsonComparator.Equals", actualJSON, expectedJSON) + + return result +} + +func (jsonComparator) ListsEqual(expected []string, actual []any) (result *testutils.CompareResult) { + result = testutils.NewCompareResult() + + if !result.Assert("jsonComparator.ListsEqual lists are of different sizes", len(expected), len(actual)) { + return result + } + + for index, e := range expected { + a := actual[index] + result.Merge(JSONComparator.Equals(e, a)) + } + + return result +} diff --git a/test/utils/mockutils/mockserver/switch.go b/test/utils/mockutils/mockserver/switch.go index 6f357dd8c7..10152b78e8 100644 --- a/test/utils/mockutils/mockserver/switch.go +++ b/test/utils/mockutils/mockserver/switch.go @@ -43,7 +43,7 @@ func (c Switch) Server() *httptest.Server { return } - // Default fail behaviour. + // Default fail behavior. w.WriteHeader(http.StatusInternalServerError) _, _ = w.Write([]byte(`{"error": {"message": "condition failed"}}`)) }) diff --git a/test/utils/mockutils/normalizedErrors.go b/test/utils/mockutils/normalizedErrors.go index 0aac97961d..d8b3459885 100644 --- a/test/utils/mockutils/normalizedErrors.go +++ b/test/utils/mockutils/normalizedErrors.go @@ -1,7 +1,6 @@ package mockutils import ( - "encoding/json" "errors" "fmt" "reflect" @@ -51,17 +50,10 @@ func (errorNormalizedComparator) ErrorEquals(actualErr, expectedErr any) *testut // 3. Handle JSON case if expected is a JSON string. if expectedJSON, ok := expectedErr.(JSONErrorWrapper); ok { - aJSON, err := json.Marshal(actualErr) - if err != nil { - result.AddDiff("failed to marshal actual error to JSON: %v", err) - return result + if equals := JSONComparator.Equals(string(expectedJSON), actualErr); !equals.OK { + result.Merge(equals) } - if jsonBodyMatch(aJSON, string(expectedJSON)) { - return result // good - } - - result.Assert("JSON error", string(expectedJSON), string(aJSON)) return result } @@ -96,17 +88,3 @@ func (c errorNormalizedComparator) EachErrorEquals(actual, expected []any) *test return result } - -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/subscriptionResult.go b/test/utils/mockutils/subscriptionResult.go new file mode 100644 index 0000000000..179a8b8bb4 --- /dev/null +++ b/test/utils/mockutils/subscriptionResult.go @@ -0,0 +1,25 @@ +package mockutils + +import ( + "github.com/amp-labs/connectors/common" + "github.com/amp-labs/connectors/test/utils/testutils" +) + +var SubscriptionResultComparator = subscriptionResultComparator{} + +type subscriptionResultComparator struct{} + +func (subscriptionResultComparator) CompareWithoutResultArg( + actual, expected *common.SubscriptionResult, +) *testutils.CompareResult { + result := testutils.NewCompareResult() + + result.Assert("ObjectEvents", expected.ObjectEvents, actual.ObjectEvents) + result.Assert("Status", expected.Status, actual.Status) + result.Assert("Objects", expected.Objects, actual.Objects) + result.Assert("Events", expected.Events, actual.Events) + result.Assert("UpdateFields", expected.UpdateFields, actual.UpdateFields) + result.Assert("PassThroughEvents", expected.PassThroughEvents, actual.PassThroughEvents) + + return result +} diff --git a/test/utils/testroutines/comparator.go b/test/utils/testroutines/comparator.go index 1d17434e22..c5b6c1fb7f 100644 --- a/test/utils/testroutines/comparator.go +++ b/test/utils/testroutines/comparator.go @@ -36,6 +36,21 @@ func ComparatorSubsetRead(serverURL string, actual, expected *common.ReadResult) return result } +// ComparatorSubsetReadByIds compares two slices of ReadResultRow as a subset, +// ignoring order and focusing only on relevant fields, raw data, associations, and identifiers. +func ComparatorSubsetReadByIds(serverURL string, actual, expected []common.ReadResultRow) *testutils.CompareResult { + return ComparatorSubsetRead(serverURL, + &common.ReadResult{ + Rows: int64(len(actual)), + Data: actual, + }, + &common.ReadResult{ + Rows: int64(len(expected)), + Data: expected, + }, + ) +} + // ComparatorPagination will check pagination related fields. // Note: you may use an alias for Mock-Server-URL which will be dynamically resolved at runtime. // Example: @@ -215,6 +230,24 @@ func ComparatorSubsetUpsertMetadata(_ string, actual, expected *common.UpsertMet return result } +func ComparatorSubscriptionWithResult( + resultComparator func(expectedResult, actualResult any) *testutils.CompareResult, +) Comparator[*common.SubscriptionResult] { + return func(_ string, actual, expected *common.SubscriptionResult) *testutils.CompareResult { + result := testutils.NewCompareResult() + result.Merge(mockutils.SubscriptionResultComparator.CompareWithoutResultArg(actual, expected)) + result.Merge(resultComparator(expected.Result, actual.Result)) + + return result + } +} + +func ComparatorSubscriptionWithoutResult( + _ string, actual, expected *common.SubscriptionResult, +) *testutils.CompareResult { + return mockutils.SubscriptionResultComparator.CompareWithoutResultArg(actual, expected) +} + func mapIsSubsetMap(subset, superset map[string]any) bool { for key, expected := range subset { actual, ok := superset[key] diff --git a/test/utils/testroutines/outline.go b/test/utils/testroutines/outline.go index d7bcff84b4..f38bc73ff5 100644 --- a/test/utils/testroutines/outline.go +++ b/test/utils/testroutines/outline.go @@ -27,6 +27,9 @@ type TestCase[Input any, Output any] struct { ExpectedErrs []error } +// None can be used to indicate no Input or no Output type. +type None struct{} + func (c TestCase[Input, Output]) Close() { c.Server.Close() } diff --git a/test/utils/testroutines/read-by-ids.go b/test/utils/testroutines/read-by-ids.go new file mode 100644 index 0000000000..62d5047492 --- /dev/null +++ b/test/utils/testroutines/read-by-ids.go @@ -0,0 +1,36 @@ +package testroutines + +import ( + "testing" + + "github.com/amp-labs/connectors" + "github.com/amp-labs/connectors/common" +) + +type ( + ReadByIdsType = TestCase[ReadByIdsParams, []common.ReadResultRow] + // ReadByIds is a test suite useful for testing connectors.BatchRecordReaderConnector interface. + ReadByIds ReadByIdsType +) + +type ReadByIdsParams struct { + ObjectName string + RecordIds []string + Fields []string + Associations []string +} + +// Run provides a procedure to test connectors.BatchRecordReaderConnector +func (r ReadByIds) Run(t *testing.T, builder ConnectorBuilder[connectors.BatchRecordReaderConnector]) { + t.Helper() + t.Cleanup(func() { + ReadByIdsType(r).Close() + }) + + conn := builder.Build(t, r.Name) + output, err := conn.GetRecordsByIds(t.Context(), + r.Input.ObjectName, r.Input.RecordIds, + r.Input.Fields, r.Input.Associations, + ) + ReadByIdsType(r).Validate(t, err, output) +} diff --git a/test/utils/testroutines/subscription-delete.go b/test/utils/testroutines/subscription-delete.go new file mode 100644 index 0000000000..1fabe6bd84 --- /dev/null +++ b/test/utils/testroutines/subscription-delete.go @@ -0,0 +1,33 @@ +package testroutines + +import ( + "testing" + + "github.com/amp-labs/connectors/common" + "github.com/amp-labs/connectors/internal/components" +) + +type ( + DeleteSubscriptionType = TestCase[common.SubscriptionResult, None] + // DeleteSubscription is a test suite useful for testing part of connectors.SubscribeConnector interface. + DeleteSubscription DeleteSubscriptionType +) + +type DeleteSubscriptionParams struct { + Params common.SubscribeParams + PreviousResult *common.SubscriptionResult +} + +// Run provides a procedure to test connectors.SubscribeConnector +func (s DeleteSubscription) Run(t *testing.T, builder ConnectorBuilder[components.SubscriptionRemover]) { + t.Helper() + t.Cleanup(func() { + DeleteSubscriptionType(s).Close() + }) + + s.Expected = None{} + + conn := builder.Build(t, s.Name) + err := conn.DeleteSubscription(t.Context(), s.Input) + DeleteSubscriptionType(s).Validate(t, err, None{}) +} diff --git a/test/utils/testroutines/subscription-event.go b/test/utils/testroutines/subscription-event.go new file mode 100644 index 0000000000..45559fe637 --- /dev/null +++ b/test/utils/testroutines/subscription-event.go @@ -0,0 +1,132 @@ +package testroutines + +import ( + "fmt" + "testing" + + "github.com/amp-labs/connectors/common" + "github.com/amp-labs/connectors/test/utils/testutils" +) + +type SubscriptionEventExpected struct { + Data SubscriptionEventExpectedData + Err SubscriptionEventExpectedErr +} + +type SubscriptionEventExpectedData struct { + EventType common.SubscriptionEventType + RawEventName string + ObjectName string + Workspace string + RecordId string + EventTimeStampNano int64 + UpdatedFields []string +} + +type SubscriptionEventExpectedErr struct { + EventType error + RawEventName error + ObjectName error + Workspace error + RecordId error + EventTimeStampNano error + UpdatedFields error +} + +type SubscriptionEventTestCase struct { + Name string + Input common.Event + Expected []SubscriptionEventExpected + SubscriptionEventListErr error +} + +func (c SubscriptionEventTestCase) Run(t *testing.T) { + t.Helper() + + result := testutils.NewCompareResult() + + switch event := c.Input.(type) { + case common.CollapsedSubscriptionEvent: + eventsList, err := event.SubscriptionEventList() + if !result.AssertErr("SubscriptionEventList (err)", c.SubscriptionEventListErr, err) { + break + } + + if !result.Assert("Mismatching number of events", len(c.Expected), len(eventsList)) { + break + } + + for index, subscriptionEvent := range eventsList { + result.Merge(validateSubscriptionEvent(t, subscriptionEvent, index, c.Expected[index])) + } + case common.SubscriptionEvent: + if !result.Assert("expected multiple events, but got a single SubscriptionEvent", 1, len(c.Expected)) { + break + } + result.Merge(validateSubscriptionEvent(t, event, 0, c.Expected[0])) + case common.SubscriptionUpdateEvent: + if !result.Assert("expected multiple events, but got a single SubscriptionUpdateEvent", 1, len(c.Expected)) { + break + } + result.Merge(validateSubscriptionUpdateEvent(event, c.Expected[0])) + default: + result.AddDiff("Input is of unknown type %T", event) + } + + result.Validate(t, c.Name) +} + +func validateSubscriptionUpdateEvent( + event common.SubscriptionUpdateEvent, + expected SubscriptionEventExpected, +) *testutils.CompareResult { + result := testutils.NewCompareResult() + + fields, err := event.UpdatedFields() + result.AssertErr("UpdatedFields (err)", expected.Err.UpdatedFields, err) + result.Assert("UpdatedFields", expected.Data.UpdatedFields, fields) + + return result +} + +func validateSubscriptionEvent( + t *testing.T, event common.SubscriptionEvent, arrPosition int, expected SubscriptionEventExpected, +) *testutils.CompareResult { + t.Helper() + + result := testutils.NewCompareResult() + + // Test EventType + eventType, err := event.EventType() + result.AssertErr(fmt.Sprintf("[%v].EventType (err)", arrPosition), expected.Err.EventType, err) + result.Assert(fmt.Sprintf("[%v].EventType", arrPosition), expected.Data.EventType, eventType) + + // Test RawEventName + rawEventName, err := event.RawEventName() + result.AssertErr(fmt.Sprintf("[%v].RawEventName (err)", arrPosition), expected.Err.RawEventName, err) + result.Assert(fmt.Sprintf("[%v].RawEventName", arrPosition), expected.Data.RawEventName, rawEventName) + + // Test ObjectName + objectName, err := event.ObjectName() + result.AssertErr(fmt.Sprintf("[%v].ObjectName (err)", arrPosition), expected.Err.ObjectName, err) + result.Assert(fmt.Sprintf("[%v].ObjectName", arrPosition), expected.Data.ObjectName, objectName) + + // Test Workspace + workspace, err := event.Workspace() + result.AssertErr(fmt.Sprintf("[%v].Workspace (err)", arrPosition), expected.Err.Workspace, err) + result.Assert(fmt.Sprintf("[%v].Workspace", arrPosition), expected.Data.Workspace, workspace) + + // Test RecordId + recordID, err := event.RecordId() + result.AssertErr(fmt.Sprintf("[%v].RecordId (err)", arrPosition), expected.Err.RecordId, err) + result.Assert(fmt.Sprintf("[%v].RecordId", arrPosition), expected.Data.RecordId, recordID) + + // Test EventTimeStampNano + timestamp, err := event.EventTimeStampNano() + result.AssertErr( + fmt.Sprintf("[%v].EventTimeStampNano (err)", arrPosition), expected.Err.EventTimeStampNano, err) + result.Assert( + fmt.Sprintf("[%v].EventTimeStampNano", arrPosition), expected.Data.EventTimeStampNano, timestamp) + + return result +} diff --git a/test/utils/testroutines/subscription-update.go b/test/utils/testroutines/subscription-update.go new file mode 100644 index 0000000000..51356c2d74 --- /dev/null +++ b/test/utils/testroutines/subscription-update.go @@ -0,0 +1,31 @@ +package testroutines + +import ( + "testing" + + "github.com/amp-labs/connectors/common" + "github.com/amp-labs/connectors/internal/components" +) + +type ( + UpdateSubscriptionType = TestCase[UpdateSubscriptionParams, *common.SubscriptionResult] + // UpdateSubscription is a test suite useful for testing part of connectors.SubscribeConnector interface. + UpdateSubscription UpdateSubscriptionType +) + +type UpdateSubscriptionParams struct { + Params common.SubscribeParams + PreviousResult *common.SubscriptionResult +} + +// Run provides a procedure to test connectors.SubscribeConnector +func (s UpdateSubscription) Run(t *testing.T, builder ConnectorBuilder[components.SubscriptionUpdater]) { + t.Helper() + t.Cleanup(func() { + UpdateSubscriptionType(s).Close() + }) + + conn := builder.Build(t, s.Name) + output, err := conn.UpdateSubscription(t.Context(), s.Input.Params, s.Input.PreviousResult) + UpdateSubscriptionType(s).Validate(t, err, output) +} diff --git a/test/utils/testroutines/subscriptions-create.go b/test/utils/testroutines/subscriptions-create.go new file mode 100644 index 0000000000..fa99d98c8a --- /dev/null +++ b/test/utils/testroutines/subscriptions-create.go @@ -0,0 +1,26 @@ +package testroutines + +import ( + "testing" + + "github.com/amp-labs/connectors/common" + "github.com/amp-labs/connectors/internal/components" +) + +type ( + CreateSubscriptionType = TestCase[common.SubscribeParams, *common.SubscriptionResult] + // CreateSubscription is a test suite useful for testing part of connectors.SubscribeConnector interface. + CreateSubscription CreateSubscriptionType +) + +// Run provides a procedure to test connectors.SubscribeConnector +func (s CreateSubscription) Run(t *testing.T, builder ConnectorBuilder[components.SubscriptionCreator]) { + t.Helper() + t.Cleanup(func() { + CreateSubscriptionType(s).Close() + }) + + conn := builder.Build(t, s.Name) + output, err := conn.Subscribe(t.Context(), s.Input) + CreateSubscriptionType(s).Validate(t, err, output) +} diff --git a/test/utils/testroutines/webook-message-verifier.go b/test/utils/testroutines/webook-message-verifier.go new file mode 100644 index 0000000000..cd81936355 --- /dev/null +++ b/test/utils/testroutines/webook-message-verifier.go @@ -0,0 +1,31 @@ +package testroutines + +import ( + "testing" + + "github.com/amp-labs/connectors/common" + "github.com/amp-labs/connectors/internal/components" +) + +type ( + WebhookMessageVerificationType = TestCase[WebhookMessageVerificationParams, bool] + // WebhookMessageVerification is a test suite useful for testing connectors.WebhookVerifierConnector interface. + WebhookMessageVerification WebhookMessageVerificationType +) + +type WebhookMessageVerificationParams struct { + Request *common.WebhookRequest + Params *common.VerificationParams +} + +// Run provides a procedure to test connectors.WebhookVerifierConnector +func (r WebhookMessageVerification) Run(t *testing.T, builder ConnectorBuilder[components.WebhookMessageVerifier]) { + t.Helper() + t.Cleanup(func() { + WebhookMessageVerificationType(r).Close() + }) + + conn := builder.Build(t, r.Name) + output, err := conn.VerifyWebhookMessage(t.Context(), r.Input.Request, r.Input.Params) + WebhookMessageVerificationType(r).Validate(t, err, output) +} diff --git a/test/utils/testutils/fileReader.go b/test/utils/testutils/fileReader.go index d4c7fed621..f1a2f8b176 100644 --- a/test/utils/testutils/fileReader.go +++ b/test/utils/testutils/fileReader.go @@ -1,6 +1,7 @@ package testutils import ( + "encoding/json" "os" "path" "runtime" @@ -21,6 +22,21 @@ func DataFromFile(t *testing.T, testFileName string) FileData { return data } +// DataFromFileAs is similar to DataFromFile but additionally marshalls data into specified type T. +func DataFromFileAs[T any](t *testing.T, testFileName string) T { + data, err := internalDataFromFile(testFileName) + if err != nil { + t.Fatalf("failed to start test, input file missing, %v", err) + } + + var output T + if err := json.Unmarshal(data, &output); err != nil { + t.Fatalf("failed to start test, input file cannot be unmarshalled into type %T, %v", output, err) + } + + return output +} + func internalDataFromFile(testFileName string) (FileData, error) { // NOTE: the deeper the call stack the higher the number should be _, parentCallerLocation, _, _ := runtime.Caller(2) // nolint:dogsled