diff --git a/libs/gru/src/main/java/com/agorapulse/gru/DefaultResponseDefinitionBuilder.java b/libs/gru/src/main/java/com/agorapulse/gru/DefaultResponseDefinitionBuilder.java index 19c85d0a..ee482660 100644 --- a/libs/gru/src/main/java/com/agorapulse/gru/DefaultResponseDefinitionBuilder.java +++ b/libs/gru/src/main/java/com/agorapulse/gru/DefaultResponseDefinitionBuilder.java @@ -28,9 +28,8 @@ import com.agorapulse.gru.minions.TextMinion; import net.javacrumbs.jsonunit.core.Option; import net.javacrumbs.jsonunit.fluent.JsonFluentAssert; +import org.hamcrest.Matcher; -import java.util.ArrayList; -import java.util.Map; import java.util.function.Consumer; import java.util.function.Function; @@ -112,18 +111,11 @@ public DefaultResponseDefinitionBuilder json(Function minion.setJsonUnitConfiguration(additionalConfiguration)); } - /** - * Adds HTTP headers which are expected to be returned after action execution. - * - * @param additionalHeaders additional HTTP headers which are expected to be returned after action execution - * @return self - */ - public DefaultResponseDefinitionBuilder headers(final Map additionalHeaders) { - return command(HttpMinion.class, minion -> - additionalHeaders.forEach((key, value) -> - minion.getResponseHeaders().computeIfAbsent(key, k -> new ArrayList<>()).add(value) - ) - ); + @Override + public ResponseDefinitionBuilder header(String name, Matcher matcher) { + return command(HttpMinion.class, minion -> { + minion.putHeaderMatcher(name, matcher); + }); } /** diff --git a/libs/gru/src/main/java/com/agorapulse/gru/ResponseDefinitionBuilder.java b/libs/gru/src/main/java/com/agorapulse/gru/ResponseDefinitionBuilder.java index b3264a92..61c0cbd7 100644 --- a/libs/gru/src/main/java/com/agorapulse/gru/ResponseDefinitionBuilder.java +++ b/libs/gru/src/main/java/com/agorapulse/gru/ResponseDefinitionBuilder.java @@ -22,9 +22,10 @@ import net.javacrumbs.jsonunit.core.Option; import net.javacrumbs.jsonunit.core.internal.JsonUtils; import net.javacrumbs.jsonunit.fluent.JsonFluentAssert; +import org.hamcrest.Matcher; +import org.hamcrest.Matchers; import org.intellij.lang.annotations.Language; -import java.util.Collections; import java.util.List; import java.util.Map; import java.util.function.Consumer; @@ -155,12 +156,19 @@ default ResponseDefinitionBuilder json(List list) { * @param additionalHeaders additional HTTP headers which are expected to be returned after action execution * @return self */ - ResponseDefinitionBuilder headers(Map additionalHeaders); + default ResponseDefinitionBuilder headers(Map additionalHeaders) { + additionalHeaders + .forEach((k, v) -> header(k, Matchers.equalTo(v))); + + return this; + } default ResponseDefinitionBuilder header(@Language("http-header-reference") String name, String value) { - return headers(Collections.singletonMap(name, value)); + return header(name, Matchers.equalTo(value)); } + ResponseDefinitionBuilder header(@Language("http-header-reference") String name, Matcher matcher); + /** * Sets the expected redirection URI. * diff --git a/libs/gru/src/main/java/com/agorapulse/gru/minions/HttpMinion.java b/libs/gru/src/main/java/com/agorapulse/gru/minions/HttpMinion.java index 3c60e105..b94a2dd4 100644 --- a/libs/gru/src/main/java/com/agorapulse/gru/minions/HttpMinion.java +++ b/libs/gru/src/main/java/com/agorapulse/gru/minions/HttpMinion.java @@ -20,8 +20,20 @@ import com.agorapulse.gru.Client; import com.agorapulse.gru.GruContext; import com.agorapulse.gru.Squad; - -import java.util.*; +import org.hamcrest.Matcher; +import org.hamcrest.StringDescription; + +import java.text.MessageFormat; +import java.util.Collection; +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.LinkedList; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Optional; +import java.util.Set; +import java.util.TreeSet; import java.util.stream.Collectors; /** @@ -34,6 +46,7 @@ public class HttpMinion extends AbstractMinion { private Set statuses = Collections.singleton(DEFAULT_STATUS); private final Map> requestHeaders = new LinkedHashMap<>(); private final Map> responseHeaders = new LinkedHashMap<>(); + private final Map>> responseHeaderMatchers = new LinkedHashMap<>(); private String redirectUri; public HttpMinion() { @@ -66,14 +79,25 @@ public void doVerify(Client client, Squad squad, GruContext context) { } } - for (Map.Entry> header : responseHeaders.entrySet()) { - Optional.ofNullable(header.getValue()).ifPresent(headers -> { - for (String value : headers) { - if (!(client.getResponse().getHeaders(header.getKey()).contains(value))) { - throw new AssertionError("Missing header " + header.getKey() + " with value " + value); + for (Map.Entry>> headerMatcher : responseHeaderMatchers.entrySet()) { + List headerValues = client.getResponse().getHeaders(headerMatcher.getKey()); + if (Objects.isNull(headerValues) || headerValues.isEmpty()) { + throw new AssertionError("Missing header " + headerMatcher.getKey()); + } + + headerMatcher.getValue() + .stream() + .allMatch(matcher -> { + boolean matches = headerValues.stream().anyMatch(matcher::matches); + if (!matches) { + StringDescription description = new StringDescription(); + matcher.describeMismatch(headerValues, description); + throw new AssertionError(MessageFormat + .format("Header value mismatch {0}: {1}", headerMatcher.getKey(), description)); } - } - }); + + return true; + }); } if (redirectUri != null) { @@ -106,6 +130,15 @@ public Map> getResponseHeaders() { return responseHeaders; } + public Map>> putHeaderMatcher(String name, Matcher matcher) { + Collection> matchers = responseHeaderMatchers.getOrDefault(name, new LinkedList<>()); + responseHeaderMatchers.put(name, matchers); + + matchers.add(matcher); + + return responseHeaderMatchers; + } + public void setRedirectUri(String redirectUri) { this.redirectUri = redirectUri; } diff --git a/libs/gru/src/test/groovy/com/agorapulse/gru/DefaultTestDefinitionBuilderSpec.groovy b/libs/gru/src/test/groovy/com/agorapulse/gru/DefaultTestDefinitionBuilderSpec.groovy index a5fe8c09..4bbbecaf 100644 --- a/libs/gru/src/test/groovy/com/agorapulse/gru/DefaultTestDefinitionBuilderSpec.groovy +++ b/libs/gru/src/test/groovy/com/agorapulse/gru/DefaultTestDefinitionBuilderSpec.groovy @@ -17,6 +17,8 @@ */ package com.agorapulse.gru +import com.agorapulse.gru.minions.HttpMinion +import org.hamcrest.Matchers import spock.lang.Specification import spock.lang.Unroll @@ -65,4 +67,32 @@ class DefaultTestDefinitionBuilderSpec extends Specification { } *.name } + void "multi header response is matched"() { + given: + String expectedHeader = "X-Foo" + + Client.Request request = Mock(Client.Request) + Client.Response response = Mock(Client.Response) + + Client client = Mock(Client) { + getInitialSquad() >> [] + getRequest() >> request + getResponse() >> response + } + + response.getHeaders(expectedHeader) >> ["Bar", "Baz"] + Gru gru = Gru.create(client) + + when: + gru.verify { + get '/foo/bar' + expect { + header(expectedHeader, Matchers.equalToIgnoringCase("Bar")) + header(expectedHeader, Matchers.equalToIgnoringCase("Baz")) + } + } + then: + noExceptionThrown() + } + } diff --git a/libs/gru/src/test/groovy/com/agorapulse/gru/minions/HttpMinionSpec.groovy b/libs/gru/src/test/groovy/com/agorapulse/gru/minions/HttpMinionSpec.groovy new file mode 100644 index 00000000..03e184a0 --- /dev/null +++ b/libs/gru/src/test/groovy/com/agorapulse/gru/minions/HttpMinionSpec.groovy @@ -0,0 +1,92 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * + * Copyright 2018-2022 Agorapulse. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.agorapulse.gru.minions + +import com.agorapulse.gru.Client +import com.agorapulse.gru.Gru +import com.agorapulse.gru.Squad +import com.agorapulse.gru.TestClient +import org.hamcrest.Matchers +import spock.lang.Specification + +import static com.agorapulse.gru.HttpStatusShortcuts.OK + +/** + * Tests for http minion. + */ +class HttpMinionSpec extends Specification { + + void 'multi header response is matched'() { + given: + Client.Request request = Mock(Client.Request) + Client.Response response = Mock(Client.Response) + + Client client = new TestClient(this, request, response) + Squad squad = new Squad() + HttpMinion minion = new HttpMinion() + squad.add(minion) + + String expectedHeader = "X-Foo" + response.getHeaders(expectedHeader) >> ["Bar", "Baz"] + response.getStatus() >> OK + + when: + Gru gru = Gru.create(client) + + gru.verify { + get '/foo/bar' + expect { + status(OK) + header(expectedHeader, Matchers.equalToIgnoringCase("Bar")) + header(expectedHeader, Matchers.equalToIgnoringCase("Baz")) + } + } + then: + noExceptionThrown() + } + + void 'legacy header matching still works'() { + given: + Client.Request request = Mock(Client.Request) + Client.Response response = Mock(Client.Response) + + Client client = new TestClient(this, request, response) + Squad squad = new Squad() + HttpMinion minion = new HttpMinion() + squad.add(minion) + + String expectedHeader = "X-Foo" + response.getHeaders(expectedHeader) >> ["Bar", "Baz"] + response.getStatus() >> OK + + when: + Gru gru = Gru.create(client) + + gru.verify { + get '/foo/bar' + expect { + status(OK) + header(expectedHeader, "Bar") + header(expectedHeader, "Baz") + } + } + then: + noExceptionThrown() + } + +}