Skip to content

Commit

Permalink
Generalise request matching in PublishingApi assertions
Browse files Browse the repository at this point in the history
The assertions provided by the PublishingApi test helpers are useful.
The existing implementation allows an assertion to require that a
request was made to a publishing api endpoint with a hash containing a
certain list of required attributes.

However the underlying matching process would only take into account the
outermost level of the hash when performing the comparison. This means
that you could make an flexible assertion about the required elements at
the top level of a document sent to the publishing api, but if you
wanted to make assertions about the details section or elements nested
within the details section, you could only perform an exact match on the
details hash.

This would lead to brittle, difficult to maintain tests.

This commit allows passing custom matcher predicates to the assertions
and defines a new matcher which can match nested structures more
loosely.

The original strict matching behaviour is retained as a default to avoid
possible unintended side-effects on the test suites of other apps.

We considered making the looser behaviour the default, but after
reviewing the usage of some other apps I had enough doubt about that I
thought it would be better to be conservative and avoid changing the
default matching behaviour.

To use the assertions with the looser matcher you can do the following:

```
assert_publishing_api_put_item(
  base_path,
  request_json_matching(details: {title: "My title"})
)
```

Note that both matchers will convert symbols to strings so the
hash can use either symbol or string keys.
  • Loading branch information
heathd committed Jul 30, 2015
1 parent 1ac5ef0 commit b65fae4
Show file tree
Hide file tree
Showing 2 changed files with 208 additions and 15 deletions.
65 changes: 50 additions & 15 deletions lib/gds_api/test_helpers/publishing_api.rb
Expand Up @@ -43,37 +43,72 @@ def stub_default_publishing_api_put_intent()
stub_request(:put, %r{\A#{PUBLISHING_API_ENDPOINT}/publish-intent})
end

def assert_publishing_api_put_item(base_path, attributes = {}, times = 1)
def assert_publishing_api_put_item(base_path, attributes_or_matcher = {}, times = 1)
url = PUBLISHING_API_ENDPOINT + "/content" + base_path
assert_publishing_api_put(url, attributes, times)
assert_publishing_api_put(url, attributes_or_matcher, times)
end

def assert_publishing_api_put_draft_item(base_path, attributes = {}, times = 1)
def assert_publishing_api_put_draft_item(base_path, attributes_or_matcher = {}, times = 1)
url = PUBLISHING_API_ENDPOINT + "/draft-content" + base_path
assert_publishing_api_put(url, attributes, times)
assert_publishing_api_put(url, attributes_or_matcher, times)
end

def assert_publishing_api_put_intent(base_path, attributes = {}, times = 1)
def assert_publishing_api_put_intent(base_path, attributes_or_matcher = {}, times = 1)
url = PUBLISHING_API_ENDPOINT + "/publish-intent" + base_path
assert_publishing_api_put(url, attributes, times)
assert_publishing_api_put(url, attributes_or_matcher, times)
end

def assert_publishing_api_put(url, attributes = {}, times = 1)
if attributes.empty?
assert_requested(:put, url, times: times)
def assert_publishing_api_put(url, attributes_or_matcher = {}, times = 1)
if attributes_or_matcher.is_a?(Hash)
matcher = attributes_or_matcher.empty? ? nil : request_json_matching(attributes_or_matcher)
else
assert_requested(:put, url, times: times) do |req|
data = JSON.parse(req.body)
attributes.to_a.all? do |key, value|
data[key.to_s] == value
end
end
matcher = attributes_or_matcher
end

if matcher
assert_requested(:put, url, times: times, &matcher)
else
assert_requested(:put, url, times: times)
end
end

def request_json_matching(required_attributes)
->(request) do
data = JSON.parse(request.body)
required_attributes.to_a.all? { |key, value| data[key.to_s] == value }
end
end

def request_json_including(required_attributes)
->(request) do
data = JSON.parse(request.body)
values_match_recursively(required_attributes, data)
end
end

def publishing_api_isnt_available
stub_request(:any, /#{PUBLISHING_API_ENDPOINT}\/.*/).to_return(:status => 503)
end

private
def values_match_recursively(expected_value, actual_value)
case expected_value
when Hash
return false unless actual_value.is_a?(Hash)
expected_value.all? do |expected_sub_key, expected_sub_value|
actual_value.has_key?(expected_sub_key.to_s) &&
values_match_recursively(expected_sub_value, actual_value[expected_sub_key.to_s])
end
when Array
return false unless actual_value.is_a?(Array)
return false unless actual_value.size == expected_value.size
expected_value.each.with_index.all? do |expected_sub_value, i|
values_match_recursively(expected_sub_value, actual_value[i])
end
else
expected_value == actual_value
end
end
end
end
end
158 changes: 158 additions & 0 deletions test/test_helpers/publishing_api_test.rb
@@ -0,0 +1,158 @@
require 'test_helper'
require 'gds_api/publishing_api'
require 'gds_api/test_helpers/publishing_api'

describe GdsApi::TestHelpers::PublishingApi do
include GdsApi::TestHelpers::PublishingApi
let(:base_api_url) { Plek.current.find("publishing-api") }
let(:publishing_api) { GdsApi::PublishingApi.new(base_api_url) }

describe "#assert_publishing_api_put_item" do
before { stub_default_publishing_api_put }
let(:base_path) { "/example" }

it "matches a put request with any empty attributes by default" do
publishing_api.put_content_item(base_path, {})
assert_publishing_api_put_item(base_path)
end

it "matches a put request with any arbitrary attributes by default" do
random_attributes = Hash[10.times.map {|n| [Random.rand * 1_000_000_000_000, Random.rand * 1_000_000_000_000]}]
publishing_api.put_content_item(base_path, random_attributes)
assert_publishing_api_put_item(base_path)
end

it "if attributes are specified, matches a request with at least those attributes" do
publishing_api.put_content_item(base_path, {"required_attribute" => 1, "extra_attrbibute" => 1})
assert_publishing_api_put_item(base_path, {"required_attribute" => 1})
end

it "matches using a custom request matcher" do
publishing_api.put_content_item(base_path, {})
matcher_was_called = false
matcher = ->(request) { matcher_was_called = true; true }
assert_publishing_api_put_item(base_path, matcher)
assert matcher_was_called, "matcher should have been called"
end
end

describe '#request_json_matching predicate' do
describe "nested required attribute" do
let(:matcher) { request_json_matching({"a" => {"b" => 1}}) }

it "matches a body with exact same nested hash strucure" do
assert matcher.call(stub("request", body: '{"a": {"b": 1}}'))
end

it "matches a body with exact same nested hash strucure and an extra attribute at the top level" do
assert matcher.call(stub("request", body: '{"a": {"b": 1}, "c": 3}'))
end

it "does not match a body where the inner hash has the required attribute and an extra one" do
refute matcher.call(stub("request", body: '{"a": {"b": 1, "c": 2}}'))
end

it "does not match a body where the inner hash has the required attribute with the wrong value" do
refute matcher.call(stub("request", body: '{"a": {"b": 0}}'))
end

it "does not match a body where the inner hash lacks the required attribute" do
refute matcher.call(stub("request", body: '{"a": {"c": 1}}'))
end
end

describe "hash to match uses symbol keys" do
let(:matcher) { request_json_matching({a: 1}) }

it "matches a json body" do
assert matcher.call(stub("request", body: '{"a": 1}'))
end
end
end

describe '#request_json_including predicate' do
describe "no required attributes" do
let(:matcher) { request_json_including({}) }

it "matches an empty body" do
assert matcher.call(stub("request", body: "{}"))
end

it "matches a body with some attributes" do
assert matcher.call(stub("request", body: '{"a": 1}'))
end
end

describe "one required attribute" do
let(:matcher) { request_json_including({"a" => 1}) }

it "does not match an empty body" do
refute matcher.call(stub("request", body: "{}"))
end

it "does not match a body with the required attribute if the value is different" do
refute matcher.call(stub("request", body: '{"a": 2}'))
end

it "matches a body with the required attribute and value" do
assert matcher.call(stub("request", body: '{"a": 1}'))
end

it "matches a body with the required attribute and value and extra attributes" do
assert matcher.call(stub("request", body: '{"a": 1, "b": 2}'))
end
end

describe "nested required attribute" do
let(:matcher) { request_json_including({"a" => {"b" => 1}}) }

it "matches a body with exact same nested hash strucure" do
assert matcher.call(stub("request", body: '{"a": {"b": 1}}'))
end

it "matches a body where the inner hash has the required attribute and an extra one" do
assert matcher.call(stub("request", body: '{"a": {"b": 1, "c": 2}}'))
end

it "does not match a body where the inner hash has the required attribute with the wrong value" do
refute matcher.call(stub("request", body: '{"a": {"b": 0}}'))
end

it "does not match a body where the inner hash lacks the required attribute" do
refute matcher.call(stub("request", body: '{"a": {"c": 1}}'))
end
end

describe "hash to match uses symbol keys" do
let(:matcher) { request_json_including({a: {b: 1}}) }

it "matches a json body" do
assert matcher.call(stub("request", body: '{"a": {"b": 1}}'))
end
end

describe "nested arrays" do
let(:matcher) { request_json_including({"a" => [1]}) }

it "matches a body with exact same inner array" do
assert matcher.call(stub("request", body: '{"a": [1]}'))
end

it "does not match a body with an array with extra elements" do
refute matcher.call(stub("request", body: '{"a": [1, 2]}'))
end
end

describe "hashes in nested arrays" do
let(:matcher) { request_json_including({"a" => [{"b" => 1}, 2]}) }

it "matches a body with exact same inner array" do
assert matcher.call(stub("request", body: '{"a": [{"b": 1}, 2]}'))
end

it "matches a body with an inner hash with extra elements" do
assert matcher.call(stub("request", body: '{"a": [{"b": 1, "c": 3}, 2]}'))
end
end
end
end

0 comments on commit b65fae4

Please sign in to comment.