diff --git a/README.md b/README.md index dc5e8d6..9cc6d82 100644 --- a/README.md +++ b/README.md @@ -121,6 +121,21 @@ The container provides methods for different supported API styles/protocols (Soa The container also provides `getHttpEndpoint()` for raw access to those API endpoints. +### Verifying mock endpoint has been invoked + +Once the mock endpoint has been invoked, you'd probably need to ensure that the mock have been really invoked. + +You can do it like this : + +```java +Boolean serviceMockInvoked = microcks.verify("API Pastries", "0.0.1"); +``` + +Or like this : +```java +Long serviceInvocationsCount = microcks.getServiceInvocationsCount("API Pastries", "0.0.1"); +``` + ### Launching new contract-tests If you want to ensure that your application under test is conformant to an OpenAPI contract (or other type of contract), diff --git a/src/main/java/io/github/microcks/testcontainers/MicrocksContainer.java b/src/main/java/io/github/microcks/testcontainers/MicrocksContainer.java index fb70324..ca76aeb 100644 --- a/src/main/java/io/github/microcks/testcontainers/MicrocksContainer.java +++ b/src/main/java/io/github/microcks/testcontainers/MicrocksContainer.java @@ -15,6 +15,7 @@ */ package io.github.microcks.testcontainers; +import io.github.microcks.testcontainers.model.DailyInvocationStatistic; import io.github.microcks.testcontainers.model.RequestResponsePair; import io.github.microcks.testcontainers.model.Secret; import io.github.microcks.testcontainers.model.TestResult; @@ -30,6 +31,7 @@ import org.testcontainers.shaded.com.fasterxml.jackson.databind.ObjectMapper; import org.testcontainers.shaded.com.github.dockerjava.core.MediaType; import org.testcontainers.shaded.com.google.common.net.HttpHeaders; +import org.testcontainers.shaded.com.google.common.net.UrlEscapers; import org.testcontainers.shaded.org.awaitility.Awaitility; import org.testcontainers.shaded.org.awaitility.core.ConditionTimeoutException; import org.testcontainers.utility.DockerImageName; @@ -43,12 +45,15 @@ import java.io.OutputStream; import java.io.OutputStreamWriter; import java.io.PrintWriter; +import java.math.BigDecimal; import java.net.HttpURLConnection; import java.net.URL; import java.net.URLDecoder; import java.net.URLEncoder; import java.nio.charset.StandardCharsets; +import java.text.SimpleDateFormat; import java.util.Arrays; +import java.util.Date; import java.util.HashSet; import java.util.List; import java.util.Set; @@ -75,6 +80,8 @@ public class MicrocksContainer extends GenericContainer { private static ObjectMapper mapper; + private static SimpleDateFormat metricsApiDayFormatter = new SimpleDateFormat("yyyyMMdd"); + private Set snapshotsToImport; private Set mainArtifactsToImport; private Set secondaryArtifactsToImport; @@ -561,6 +568,167 @@ public void downloadAsSecondaryRemoteArtifact(String remoteArtifactUrl) throws A downloadArtifact(remoteArtifactUrl, false); } + /** + * Verifies that given Service has been invoked at least one time, for the current invocations' date. + * + * @param serviceName mandatory + * @param serviceVersion mandatory + * @return false if the given service was not found, or if the daily invocation's count is zero. Else, returns true if the daily invocations' count for the given service is at least one. + */ + public boolean verify(String serviceName, String serviceVersion) { + return doVerify(getHttpEndpoint(), serviceName, serviceVersion, null); + } + + /** + * Verifies that given Service has been invoked at least one time, for the current invocations' date. + * + * @param microcksContainerHttpEndpoint mandatory + * @param serviceName mandatory + * @param serviceVersion mandatory + * @return false if the given service was not found, or if the daily invocation's count is zero. Else, returns true if the daily invocations' count for the given service is at least one. + */ + public static boolean verify(String microcksContainerHttpEndpoint, String serviceName, String serviceVersion) { + return doVerify(microcksContainerHttpEndpoint, serviceName, serviceVersion, null); + } + + /** + * Verifies that given Service has been invoked at least one time, for the given invocations' date. + * In unit testing context, it should be useless to pass the invocations date, prefer calling {@link #verify(String, String)}`. + * + * @param serviceName mandatory + * @param serviceVersion mandatory + * @param invocationsDate nullable + * @return false if the given service was not found, or if the daily invocation's count is zero. Else, returns true if the daily invocations' count for the given service is at least one. + */ + public boolean verify(String serviceName, String serviceVersion, Date invocationsDate) { + return doVerify(getHttpEndpoint(), serviceName, serviceVersion, invocationsDate); + } + + /** + * Verifies that given Service has been invoked at least one time, for the given invocations' date. + * In unit testing context, it should be useless to pass the invocations date, prefer calling {@link #verify(String, String)}`. + * + * @param microcksContainerHttpEndpoint mandatory + * @param serviceName mandatory + * @param serviceVersion mandatory + * @param invocationsDate nullable + * @return false if the given service was not found, or if the daily invocation's count is zero. Else, returns true if the daily invocations' count for the given service is at least one. + */ + public static boolean verify(String microcksContainerHttpEndpoint, String serviceName, String serviceVersion, Date invocationsDate) { + return doVerify(microcksContainerHttpEndpoint, serviceName, serviceVersion, invocationsDate); + } + + private static boolean doVerify(String microcksContainerHttpEndpoint, String serviceName, String serviceVersion, Date invocationsDate) { + DailyInvocationStatistic dailyInvocationStatistic = getServiceInvocations(microcksContainerHttpEndpoint, serviceName, serviceVersion, invocationsDate); + + if (dailyInvocationStatistic == null) { + return false; + } + + BigDecimal count = dailyInvocationStatistic.getDailyCount(); + + return count != null && count.intValue() != 0; + } + + /** + * Get the invocations' count for a given service, identified by its name and version, for the current invocations' date. + * + * @param serviceName mandatory + * @param serviceVersion mandatory + * @return zero if the given service was not found, or has never been invoked. Else, returns the daily count of invocations for the given service. + */ + public Long getServiceInvocationsCount(String serviceName, String serviceVersion) { + return doGetServiceInvocationsCount(getHttpEndpoint(), serviceName, serviceVersion, null); + } + + /** + * Get the invocations' count for a given service, identified by its name and version, for the current invocations' date. + * + * @param microcksContainerHttpEndpoint mandatory + * @param serviceName mandatory + * @param serviceVersion mandatory + * @return zero if the given service was not found, or has never been invoked. Else, returns the daily count of invocations for the given service. + */ + public static Long getServiceInvocationsCount(String microcksContainerHttpEndpoint, String serviceName, String serviceVersion) { + return doGetServiceInvocationsCount(microcksContainerHttpEndpoint, serviceName, serviceVersion, null); + } + + /** + * Get the invocations' count for a given service, identified by its name and version, for the given invocations' date. + * In unit testing context, it should be useless to pass the invocations date, prefer calling {@link #getServiceInvocationsCount(String, String)}`. + * + * @param serviceName mandatory + * @param serviceVersion mandatory + * @param invocationsDate nullable + * @return zero if the given service was not found, or has never been invoked. Else, returns the daily count of invocations for the given service. + */ + public Long getServiceInvocationsCount(String serviceName, String serviceVersion, Date invocationsDate) { + return doGetServiceInvocationsCount(getHttpEndpoint(), serviceName, serviceVersion, invocationsDate); + } + + /** + * Get the invocations' count for a given service, identified by its name and version, for the given invocations' date. + * In unit testing context, it should be useless to pass the invocations date, prefer calling {@link #getServiceInvocationsCount(String, String)}`. + * + * @param microcksContainerHttpEndpoint mandatory + * @param serviceName mandatory + * @param serviceVersion mandatory + * @param invocationsDate nullable + * @return zero if the given service was not found, or has never been invoked. Else, returns the daily count of invocations for the given service. + */ + public static Long getServiceInvocationsCount(String microcksContainerHttpEndpoint, String serviceName, String serviceVersion, Date invocationsDate) { + return doGetServiceInvocationsCount(microcksContainerHttpEndpoint, serviceName, serviceVersion, invocationsDate); + } + + private static Long doGetServiceInvocationsCount(String microcksContainerHttpEndpoint, String serviceName, String serviceVersion, Date invocationsDate) { + DailyInvocationStatistic dailyInvocationStatistic = getServiceInvocations(microcksContainerHttpEndpoint, serviceName, serviceVersion, invocationsDate); + + if (dailyInvocationStatistic == null) { + return 0L; + } + + BigDecimal count = dailyInvocationStatistic.getDailyCount(); + + return count.longValue(); + } + + + /** + * Returns all data from Microcks Metrics REST API about the invocations of a given service. + * + * @param microcksContainerHttpEndpoint mandatory + * @param serviceName mandatory + * @param serviceVersion mandatory + * @param invocationsDate nullable + * @return + */ + private static DailyInvocationStatistic getServiceInvocations(String microcksContainerHttpEndpoint, String serviceName, String serviceVersion, Date invocationsDate) { + String encodedServiceName = UrlEscapers.urlFragmentEscaper().escape(serviceName); + String encodedServiceVersion = UrlEscapers.urlFragmentEscaper().escape(serviceVersion); + + String restApiURL = String.format("%s/api/metrics/invocations/%s/%s", microcksContainerHttpEndpoint, encodedServiceName, encodedServiceVersion); + + if (invocationsDate != null) { + restApiURL += "?day=" + metricsApiDayFormatter.format(invocationsDate); + } + + try { + Thread.sleep(100); // to avoid race condition issue when requesting Microcks Metrics REST API + } catch (InterruptedException e) { + log.warn("Failed to sleep before calling Microcks API", e); + Thread.currentThread().interrupt(); + } + + try { + StringBuilder content = getFromRestApi(restApiURL); + return content.length() == 0 ? null : getMapper().readValue(content.toString(), DailyInvocationStatistic.class); + } catch (IOException e) { + log.warn("Failed to get service's invocations at address " + restApiURL, e); + } + + return null; + } + private void importArtifact(String artifactPath, boolean mainArtifact) { URL resource = Thread.currentThread().getContextClassLoader().getResource(artifactPath); if (resource == null) { @@ -727,8 +895,21 @@ private static HttpURLConnection uploadFileToMicrocks(URL microcksApiURL, File f } private static TestResult refreshTestResult(String microcksContainerHttpEndpoint, String testResultId) throws IOException { + StringBuilder content = getFromRestApi(microcksContainerHttpEndpoint + "/api/tests/" + testResultId); + + return getMapper().readValue(content.toString(), TestResult.class); + } + + /** + * Does a GET HTTP call to Microcks REST API and expecting to obtain a 200 response with a `application/json` body: returns it as a StringBuilder. + * + * @param restApiURL mandatory + * @return + * @throws IOException + */ + private static StringBuilder getFromRestApi(String restApiURL) throws IOException { // Build a new client on correct API endpoint. - URL url = new URL(microcksContainerHttpEndpoint + "/api/tests/" + testResultId); + URL url = new URL(restApiURL); HttpURLConnection httpConn = (HttpURLConnection) url.openConnection(); httpConn.setRequestMethod("GET"); httpConn.setRequestProperty("Accept", "application/json"); @@ -742,10 +923,10 @@ private static TestResult refreshTestResult(String microcksContainerHttpEndpoint content.append(inputLine); } } + // Disconnect Http connection. httpConn.disconnect(); - - return getMapper().readValue(content.toString(), TestResult.class); + return content; } public static class ArtifactLoadException extends RuntimeException { diff --git a/src/main/java/io/github/microcks/testcontainers/model/DailyInvocationStatistic.java b/src/main/java/io/github/microcks/testcontainers/model/DailyInvocationStatistic.java new file mode 100644 index 0000000..598a1c2 --- /dev/null +++ b/src/main/java/io/github/microcks/testcontainers/model/DailyInvocationStatistic.java @@ -0,0 +1,269 @@ +/* + * Copyright The Microcks Authors. + * + * 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 + * + * http://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 io.github.microcks.testcontainers.model; + +import com.fasterxml.jackson.annotation.JsonProperty; + +import java.math.BigDecimal; +import java.util.HashMap; +import java.util.Map; +import java.util.Objects; + +/** + * The daily statistic of a service mock invocations + */ +public class DailyInvocationStatistic { + public static final String JSON_PROPERTY_ID = "id"; + private String id; + + public static final String JSON_PROPERTY_DAY = "day"; + private String day; + + public static final String JSON_PROPERTY_SERVICE_NAME = "serviceName"; + private String serviceName; + + public static final String JSON_PROPERTY_SERVICE_VERSION = "serviceVersion"; + private String serviceVersion; + + public static final String JSON_PROPERTY_DAILY_COUNT = "dailyCount"; + private BigDecimal dailyCount; + + public static final String JSON_PROPERTY_HOURLY_COUNT = "hourlyCount"; + private Map hourlyCount = new HashMap<>(); + + public static final String JSON_PROPERTY_MINUTE_COUNT = "minuteCount"; + private Map minuteCount = new HashMap<>(); + + public DailyInvocationStatistic() { + } + + public DailyInvocationStatistic id(String id) { + this.id = id; + return this; + } + + /** + * Unique identifier of this statistic object + * @return id + */ + @JsonProperty(JSON_PROPERTY_ID) + public String getId() { + return id; + } + + + @JsonProperty(JSON_PROPERTY_ID) + public void setId(String id) { + this.id = id; + } + + + public DailyInvocationStatistic day(String day) { + this.day = day; + return this; + } + + /** + * The day (formatted as yyyyMMdd string) represented by this statistic + * @return day + */ + @JsonProperty(JSON_PROPERTY_DAY) + public String getDay() { + return day; + } + + + @JsonProperty(JSON_PROPERTY_DAY) + public void setDay(String day) { + this.day = day; + } + + + public DailyInvocationStatistic serviceName(String serviceName) { + this.serviceName = serviceName; + return this; + } + + /** + * The name of the service this statistic is related to + * @return serviceName + */ + @JsonProperty(JSON_PROPERTY_SERVICE_NAME) + public String getServiceName() { + return serviceName; + } + + + @JsonProperty(JSON_PROPERTY_SERVICE_NAME) + public void setServiceName(String serviceName) { + this.serviceName = serviceName; + } + + + public DailyInvocationStatistic serviceVersion(String serviceVersion) { + this.serviceVersion = serviceVersion; + return this; + } + + /** + * The version of the service this statistic is related to + * @return serviceVersion + */ + @JsonProperty(JSON_PROPERTY_SERVICE_VERSION) + public String getServiceVersion() { + return serviceVersion; + } + + + @JsonProperty(JSON_PROPERTY_SERVICE_VERSION) + public void setServiceVersion(String serviceVersion) { + this.serviceVersion = serviceVersion; + } + + + public DailyInvocationStatistic dailyCount(BigDecimal dailyCount) { + this.dailyCount = dailyCount; + return this; + } + + /** + * The number of service mock invocations on this day + * @return dailyCount + */ + @JsonProperty(JSON_PROPERTY_DAILY_COUNT) + public BigDecimal getDailyCount() { + return dailyCount; + } + + + @JsonProperty(JSON_PROPERTY_DAILY_COUNT) + public void setDailyCount(BigDecimal dailyCount) { + this.dailyCount = dailyCount; + } + + + public DailyInvocationStatistic hourlyCount(Map hourlyCount) { + this.hourlyCount = hourlyCount; + return this; + } + + public DailyInvocationStatistic putHourlyCountItem(String key, Object hourlyCountItem) { + if (this.hourlyCount == null) { + this.hourlyCount = new HashMap<>(); + } + this.hourlyCount.put(key, hourlyCountItem); + return this; + } + + /** + * The number of service mock invocations per hour of the day (keys range from 0 to 23) + * @return hourlyCount + */ + @JsonProperty(JSON_PROPERTY_HOURLY_COUNT) + public Map getHourlyCount() { + return hourlyCount; + } + + + @JsonProperty(JSON_PROPERTY_HOURLY_COUNT) + public void setHourlyCount(Map hourlyCount) { + this.hourlyCount = hourlyCount; + } + + + public DailyInvocationStatistic minuteCount(Map minuteCount) { + this.minuteCount = minuteCount; + return this; + } + + public DailyInvocationStatistic putMinuteCountItem(String key, Object minuteCountItem) { + if (this.minuteCount == null) { + this.minuteCount = new HashMap<>(); + } + this.minuteCount.put(key, minuteCountItem); + return this; + } + + /** + * The number of service mock invocations per minute of the day (keys range from 0 to 1439) + * @return minuteCount + */ + @JsonProperty(JSON_PROPERTY_MINUTE_COUNT) + public Map getMinuteCount() { + return minuteCount; + } + + + @JsonProperty(JSON_PROPERTY_MINUTE_COUNT) + public void setMinuteCount(Map minuteCount) { + this.minuteCount = minuteCount; + } + + + /** + * Return true if this DailyInvocationStatistic object is equal to o. + */ + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } + if (o == null || getClass() != o.getClass()) { + return false; + } + DailyInvocationStatistic dailyInvocationStatistic = (DailyInvocationStatistic) o; + return Objects.equals(this.id, dailyInvocationStatistic.id) && + Objects.equals(this.day, dailyInvocationStatistic.day) && + Objects.equals(this.serviceName, dailyInvocationStatistic.serviceName) && + Objects.equals(this.serviceVersion, dailyInvocationStatistic.serviceVersion) && + Objects.equals(this.dailyCount, dailyInvocationStatistic.dailyCount) && + Objects.equals(this.hourlyCount, dailyInvocationStatistic.hourlyCount) && + Objects.equals(this.minuteCount, dailyInvocationStatistic.minuteCount); + } + + @Override + public int hashCode() { + return Objects.hash(id, day, serviceName, serviceVersion, dailyCount, hourlyCount, minuteCount); + } + + @Override + public String toString() { + StringBuilder sb = new StringBuilder(); + sb.append("class DailyInvocationStatistic {\n"); + sb.append(" id: ").append(toIndentedString(id)).append("\n"); + sb.append(" day: ").append(toIndentedString(day)).append("\n"); + sb.append(" serviceName: ").append(toIndentedString(serviceName)).append("\n"); + sb.append(" serviceVersion: ").append(toIndentedString(serviceVersion)).append("\n"); + sb.append(" dailyCount: ").append(toIndentedString(dailyCount)).append("\n"); + sb.append(" hourlyCount: ").append(toIndentedString(hourlyCount)).append("\n"); + sb.append(" minuteCount: ").append(toIndentedString(minuteCount)).append("\n"); + sb.append("}"); + return sb.toString(); + } + + /** + * Convert the given object to string with each line indented by 4 spaces + * (except the first line). + */ + private String toIndentedString(Object o) { + if (o == null) { + return "null"; + } + return o.toString().replace("\n", "\n "); + } +} + + diff --git a/src/test/java/io/github/microcks/testcontainers/MicrocksContainerTest.java b/src/test/java/io/github/microcks/testcontainers/MicrocksContainerTest.java index c9d0cea..409a240 100644 --- a/src/test/java/io/github/microcks/testcontainers/MicrocksContainerTest.java +++ b/src/test/java/io/github/microcks/testcontainers/MicrocksContainerTest.java @@ -28,6 +28,7 @@ import dasniko.testcontainers.keycloak.KeycloakContainer; import io.restassured.RestAssured; import io.restassured.response.Response; +import org.apache.commons.lang3.time.DateUtils; import org.junit.Test; import org.testcontainers.containers.GenericContainer; import org.testcontainers.containers.Network; @@ -39,6 +40,7 @@ import java.time.Duration; import java.util.ArrayList; import java.util.Arrays; +import java.util.Date; import java.util.HashMap; import java.util.List; import java.util.Map; @@ -222,6 +224,9 @@ private void testMockEndpoints(MicrocksContainer microcks) { private void testMicrocksMockingFunctionality(MicrocksContainer microcks) { String baseApiUrl = microcks.getRestMockEndpoint("API Pastries", "0.0.1"); + assertFalse(microcks.verify("API Pastries", "0.0.1")); + assertEquals(0, microcks.getServiceInvocationsCount("API Pastries", "0.0.1")); + // Check that mock from main/primary artifact has been loaded. Response millefeuille = RestAssured.given().when() .get(baseApiUrl + "/pastries/Millefeuille") @@ -231,6 +236,8 @@ private void testMicrocksMockingFunctionality(MicrocksContainer microcks) { assertEquals("Millefeuille", millefeuille.jsonPath().get("name")); //millefeuille.getBody().prettyPrint(); + testMicrocksInvocationsCheckingFunctionality(microcks, "API Pastries", "0.0.1", 1L); + // Check that mock from secondary artifact has been loaded. Response eclairChocolat = RestAssured.given().when() .get(baseApiUrl + "/pastries/Eclair Chocolat") @@ -240,6 +247,8 @@ private void testMicrocksMockingFunctionality(MicrocksContainer microcks) { assertEquals("Eclair Chocolat", eclairChocolat.jsonPath().get("name")); //eclairChocolat.getBody().prettyPrint(); + testMicrocksInvocationsCheckingFunctionality(microcks, "API Pastries", "0.0.1", 2L); + // Check that mock from main/primary remote artifact has been loaded. baseApiUrl = microcks.getRestMockEndpoint("API Pastry - 2.0", "2.0.0"); @@ -250,6 +259,27 @@ private void testMicrocksMockingFunctionality(MicrocksContainer microcks) { assertEquals(200, millefeuille.getStatusCode()); assertEquals("Millefeuille", millefeuille.jsonPath().get("name")); //millefeuille.getBody().prettyPrint(); + + testMicrocksInvocationsCheckingFunctionality(microcks, "API Pastry - 2.0", "2.0.0", 1L); + } + + private static void testMicrocksInvocationsCheckingFunctionality(MicrocksContainer microcks, String serviceName, String serviceVersion, Long expectedOccurences) { + Date invocationDay = new Date(); + Date incorrectInvocationDay = DateUtils.addDays(invocationDay, 1); + + assertTrue(microcks.verify(serviceName, serviceVersion)); + assertTrue(microcks.verify(serviceName, serviceVersion, (Date) null)); + assertTrue(microcks.verify(serviceName, serviceVersion, invocationDay)); + assertTrue(MicrocksContainer.verify(microcks.getHttpEndpoint(), serviceName, serviceVersion, invocationDay)); + assertFalse(microcks.verify(serviceName, serviceVersion, incorrectInvocationDay)); + assertFalse(MicrocksContainer.verify(microcks.getHttpEndpoint(), serviceName, serviceVersion, incorrectInvocationDay)); + + assertEquals(expectedOccurences, microcks.getServiceInvocationsCount(serviceName, serviceVersion)); + assertEquals(expectedOccurences, microcks.getServiceInvocationsCount(serviceName, serviceVersion, (Date) null)); + assertEquals(expectedOccurences, microcks.getServiceInvocationsCount(serviceName, serviceVersion, invocationDay)); + assertEquals(expectedOccurences, MicrocksContainer.getServiceInvocationsCount(microcks.getHttpEndpoint(), serviceName, serviceVersion, invocationDay)); + assertEquals(0, microcks.getServiceInvocationsCount(serviceName, serviceVersion, incorrectInvocationDay)); + assertEquals(0, MicrocksContainer.getServiceInvocationsCount(microcks.getHttpEndpoint(), serviceName, serviceVersion, incorrectInvocationDay)); } private void testMicrocksContractTestingFunctionality(MicrocksContainer microcks, GenericContainer badImpl, GenericContainer goodImpl) throws Exception {