Skip to content

Commit

Permalink
feat: support matching on arrays nested in the body
Browse files Browse the repository at this point in the history
If there is an array nested within a JSON body on the pact, the interaction was previously skipped. Now it can be matched on.
  • Loading branch information
joshkeegan-form3 committed Apr 3, 2024
1 parent 9a9fbeb commit dd1c14e
Show file tree
Hide file tree
Showing 3 changed files with 213 additions and 29 deletions.
51 changes: 27 additions & 24 deletions internal/app/pactproxy/interaction.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@ import (
"encoding/json"
"fmt"
"mime"
"reflect"
"regexp"
"strings"
"sync"
Expand Down Expand Up @@ -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},
})
}
}
}
Expand Down Expand Up @@ -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
Expand Down
3 changes: 1 addition & 2 deletions internal/app/proxy_stage_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}

Expand Down
188 changes: 185 additions & 3 deletions internal/app/proxy_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@ package app
import (
"net/http"
"testing"

"github.com/pact-foundation/pact-go/dsl"
)

func TestLargePactResponse(t *testing.T) {
Expand Down Expand Up @@ -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)
})
}
}
Expand Down Expand Up @@ -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)
})
}

0 comments on commit dd1c14e

Please sign in to comment.