diff --git a/internal/app/pactproxy/interaction.go b/internal/app/pactproxy/interaction.go index 8c5bd29..7b6b286 100644 --- a/internal/app/pactproxy/interaction.go +++ b/internal/app/pactproxy/interaction.go @@ -4,7 +4,6 @@ import ( "encoding/json" "fmt" "mime" - "reflect" "regexp" "strings" "sync" @@ -272,21 +271,29 @@ func parseMediaType(request map[string]interface{}) (string, error) { // have a corresponding matching rule func (i *Interaction) addJSONConstraintsFromPact(path string, matchingRules map[string]bool, values map[string]interface{}) { for k, v := range values { - switch val := v.(type) { - case map[string]interface{}: - if _, exists := val["json_class"]; exists { - continue - } - i.addJSONConstraintsFromPact(path+"."+k, matchingRules, val) - default: - p := path + "." + k - if _, hasRule := matchingRules[p]; !hasRule { - i.AddConstraint(interactionConstraint{ - Path: p, - Format: "%v", - Values: []interface{}{val}, - }) - } + i.addJSONConstraintsFromPactAny(path+"."+k, matchingRules, v) + } +} + +func (i *Interaction) addJSONConstraintsFromPactAny(path string, matchingRules map[string]bool, value interface{}) { + switch val := value.(type) { + case map[string]interface{}: + if _, exists := val["json_class"]; exists { + return + } + i.addJSONConstraintsFromPact(path, matchingRules, val) + case []interface{}: + // Otherwise, create constraints for each element in the array. This allows matching rules to override them. + for j := range val { + i.addJSONConstraintsFromPactAny(fmt.Sprintf("%v[%v]", path, j), matchingRules, val[j]) + } + default: + if _, hasRule := matchingRules[path]; !hasRule { + i.AddConstraint(interactionConstraint{ + Path: path, + Format: "%v", + Values: []interface{}{val}, + }) } } } @@ -352,20 +359,16 @@ func (i *Interaction) EvaluateConstraints(request requestDocument, interactions } } - actual := "" val, err := jsonpath.Get(request.encodeValues(constraint.Path), map[string]interface{}(request)) if err != nil { - log.Warn(err) - } - if reflect.TypeOf(val) == reflect.TypeOf([]interface{}{}) { - log.Infof("skipping matching on []interface{} type for path '%s'", constraint.Path) + violations = append(violations, + fmt.Sprintf("constraint path %q cannot be resolved within request: %q", constraint.Path, err)) + result = false continue } - if err == nil { - actual = fmt.Sprintf("%v", val) - } expected := fmt.Sprintf(constraint.Format, values...) + actual := fmt.Sprintf("%v", val) if actual != expected { violations = append(violations, fmt.Sprintf("value '%s' at path '%s' does not match constraint '%s'", actual, constraint.Path, expected)) result = false diff --git a/internal/app/proxy_stage_test.go b/internal/app/proxy_stage_test.go index c68660a..c3d1494 100644 --- a/internal/app/proxy_stage_test.go +++ b/internal/app/proxy_stage_test.go @@ -451,8 +451,7 @@ func (s *ProxyStage) the_nth_response_body_is(n int, data []byte) *ProxyStage { s.assert.GreaterOrEqual(len(s.responseBodies), n, "number of request bodies is les than expected") body := s.responseBodies[n-1] - c := bytes.Compare(body, data) - s.assert.Equal(0, c, "Expected body did not match") + s.assert.Equal(data, body, "Expected body did not match") return s } diff --git a/internal/app/proxy_test.go b/internal/app/proxy_test.go index e0c8539..db71ac3 100644 --- a/internal/app/proxy_test.go +++ b/internal/app/proxy_test.go @@ -3,6 +3,8 @@ package app import ( "net/http" "testing" + + "github.com/pact-foundation/pact-go/dsl" ) func TestLargePactResponse(t *testing.T) { @@ -592,9 +594,9 @@ func TestArrayBodyRequestUnmatchedRequestBody(t *testing.T) { a_request_is_sent_with("application/json", tc.unmatchedReqBody) then. - // Pact Mock Server returns 500 if request body does not match, - // so the response status code is not checked - pact_verification_is_not_successful() + // Pact Mock Server returns 500 if request body does not match + pact_verification_is_not_successful().and(). + the_response_is_(http.StatusInternalServerError) }) } } @@ -637,3 +639,183 @@ func TestArrayBodyRequestConstraintDoesNotMatch(t *testing.T) { }) } } + +func TestArrayNestedWithinBody(t *testing.T) { + pactReqBody := map[string]interface{}{ + "entries": []interface{}{ + map[string]string{"key": "val"}, + map[string]string{"key": "val"}, + }, + } + const respContentType = "application/json" + const respBody = `[{"status":"ok"}]` + + const matchedReqBody = `{"entries": [ {"key": "val"}, {"key": "val"} ]}` + const unmatchedReqBody = `{"entries": [ {"key": "val"}, {"key": "unexpected value"} ]}` + + const matchedConstraintPath = "$.body.entries[0].key" + const matchedConstraintValue = "val" + + const unmatchedConstraintPath = "$.body.entries[1].key" + const unmatchedConstraintValue = "wrong value" + + const outOfBoundsConstraintPath = "$.body.entries[2].key" + + t.Run("Matches", func(t *testing.T) { + given, when, then := NewProxyStage(t) + + given. + a_pact_that_expects("application/json", pactReqBody, respContentType, respBody) + + when. + a_request_is_sent_with("application/json", matchedReqBody) + + then. + pact_verification_is_successful().and(). + the_response_is_(http.StatusOK).and(). + the_response_body_is(respBody) + }) + + t.Run("Does not match", func(t *testing.T) { + given, when, then := NewProxyStage(t) + + given. + a_pact_that_expects("application/json", pactReqBody, respContentType, respBody) + + when. + a_request_is_sent_with("application/json", unmatchedReqBody) + + then. + pact_verification_is_not_successful().and(). + the_response_is_(http.StatusBadRequest) + }) + + t.Run("Matches with additional constraint", func(t *testing.T) { + given, when, then := NewProxyStage(t) + + given. + a_pact_that_expects("application/json", pactReqBody, respContentType, respBody).and(). + an_additional_constraint_is_added(matchedConstraintPath, matchedConstraintValue) + + when. + a_request_is_sent_with("application/json", matchedReqBody) + + then. + pact_verification_is_successful().and(). + the_response_is_(http.StatusOK).and(). + the_response_body_is(respBody) + }) + + t.Run("Does not match due to additional constraint with different value", func(t *testing.T) { + given, when, then := NewProxyStage(t) + + given. + a_pact_that_expects("application/json", pactReqBody, respContentType, respBody).and(). + an_additional_constraint_is_added(unmatchedConstraintPath, unmatchedConstraintValue) + + when. + a_request_is_sent_with("application/json", matchedReqBody) + + then. + pact_verification_is_not_successful().and(). + the_response_is_(http.StatusBadRequest) + }) + + t.Run("Does not match due to additional constraint out of array bounds", func(t *testing.T) { + given, when, then := NewProxyStage(t) + + given. + a_pact_that_expects("application/json", pactReqBody, respContentType, respBody).and(). + an_additional_constraint_is_added(outOfBoundsConstraintPath, matchedConstraintValue) + + when. + a_request_is_sent_with("application/json", matchedReqBody) + + then. + pact_verification_is_not_successful().and(). + the_response_is_(http.StatusBadRequest) + }) +} + +func TestArrayNestedWithinBodyContainingMatchers(t *testing.T) { + pactReqBody := map[string]interface{}{ + "entries": []interface{}{ + map[string]any{"key": "val"}, + map[string]any{"key": dsl.Term("a", "(a|b)")}, + }, + } + const respContentType = "application/json" + const respBody = `[{"status":"ok"}]` + + const matchedReqBody = `{"entries": [ {"key": "val"}, {"key": "a"} ]}` + const unmatchedReqBody = `{"entries": [ {"key": "val"}, {"key": "c"} ]}` + + t.Run("Matches", func(t *testing.T) { + given, when, then := NewProxyStage(t) + + given. + a_pact_that_expects("application/json", pactReqBody, respContentType, respBody) + + when. + a_request_is_sent_with("application/json", matchedReqBody) + + then. + pact_verification_is_successful().and(). + the_response_is_(http.StatusOK).and(). + the_response_body_is(respBody) + }) + + t.Run("Does not match", func(t *testing.T) { + given, when, then := NewProxyStage(t) + + given. + a_pact_that_expects("application/json", pactReqBody, respContentType, respBody) + + when. + a_request_is_sent_with("application/json", unmatchedReqBody) + + then. + pact_verification_is_not_successful().and(). + the_response_is_(http.StatusInternalServerError) + }) +} + +func TestEmptyArrayNestedWithinBody(t *testing.T) { + pactReqBody := map[string]interface{}{ + "entries": []interface{}{}, + } + const respContentType = "application/json" + const respBody = `[{"status":"ok"}]` + + const matchedReqBody = `{"entries": []}` + const unmatchedReqBody = `{"entries": [ {"key": "val"} ]}` + + t.Run("Matches", func(t *testing.T) { + given, when, then := NewProxyStage(t) + + given. + a_pact_that_expects("application/json", pactReqBody, respContentType, respBody) + + when. + a_request_is_sent_with("application/json", matchedReqBody) + + then. + pact_verification_is_successful().and(). + the_response_is_(http.StatusOK).and(). + the_response_body_is(respBody) + }) + + t.Run("Does not match", func(t *testing.T) { + given, when, then := NewProxyStage(t) + + given. + a_pact_that_expects("application/json", pactReqBody, respContentType, respBody) + + when. + a_request_is_sent_with("application/json", unmatchedReqBody) + + then. + pact_verification_is_not_successful().and(). + the_response_is_(http.StatusInternalServerError) + }) +}