From 56531798c3ac53d274f973d68fcdb646c67bfc92 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Armando=20Rodr=C3=ADguez?= <127134616+armando-rodriguez-cko@users.noreply.github.com> Date: Thu, 12 Dec 2024 14:12:22 +0100 Subject: [PATCH] Add telemetry (#454) --- README.md | 19 +- build.gradle | 2 +- .../checkout/AbstractCheckoutSdkBuilder.java | 8 +- .../checkout/ApacheHttpClientTransport.java | 98 ++++-- src/main/java/com/checkout/ApiClientImpl.java | 2 +- .../com/checkout/CheckoutConfiguration.java | 2 + .../DefaultCheckoutConfiguration.java | 14 +- .../java/com/checkout/RequestMetrics.java | 17 + .../CheckoutSdkTelemetryIntegrationTest.java | 307 ++++++++++++++++++ .../DefaultCheckoutConfigurationTest.java | 78 ++++- .../PreviousStaticKeysSdkCredentialsTest.java | 4 +- .../StaticKeysSdkCredentialsTest.java | 4 +- .../customers/previous/CustomersTestIT.java | 2 + .../previous/InstrumentsTestIT.java | 5 + .../previous/RequestPaymentsTestIT.java | 2 + .../tokens/previous/TokensTestIT.java | 2 + .../checkout/workflows/WorkflowsTestIT.java | 8 + 17 files changed, 527 insertions(+), 47 deletions(-) create mode 100644 src/main/java/com/checkout/RequestMetrics.java create mode 100644 src/test/java/com/checkout/CheckoutSdkTelemetryIntegrationTest.java diff --git a/README.md b/README.md index b97262fb..e9234282 100644 --- a/README.md +++ b/README.md @@ -52,13 +52,13 @@ dependencies { ## How to use the SDK -This SDK can be used with two different pair of API keys provided by Checkout. However, using different API keys imply using specific API features.
+This SDK can be used with two different pair of API keys provided by Checkout. However, using different API keys imply using specific API features.
Please find in the table below the types of keys that can be used within this SDK. | Account System | Public Key (example) | Secret Key (example) | |----------------|-----------------------------------------|-----------------------------------------| -| Default | pk_pkhpdtvabcf7hdgpwnbhw7r2uic | sk_m73dzypy7cf3gf5d2xr4k7sxo4e | -| Previous | pk_g650ff27-7c42-4ce1-ae90-5691a188ee7b | sk_gk3517a8-3z01-45fq-b4bd-4282384b0a64 | +| Default | pk_abcdef123456ghijkl789mnopqr | sk_123456ghijklm7890abcdefxyz | +| Previous | pk_12345678-abcd-efgh-ijkl-mnopqrstuvwx | sk_abcdef12-3456-ghij-klmn-opqrstuvwxyz | Note: sandbox keys have a `sbox_` or `test_` identifier, for Default and Previous accounts respectively. @@ -207,6 +207,19 @@ The execution of integration tests require the following environment variables s * For default account systems (OAuth): `CHECKOUT_DEFAULT_OAUTH_CLIENT_ID` & `CHECKOUT_DEFAULT_OAUTH_CLIENT_SECRET` * For Previous account systems (ABC): `CHECKOUT_PREVIOUS_PUBLIC_KEY` & `CHECKOUT_PREVIOUS_SECRET_KEY` +## Telemetry +Request telemetry is enabled by default in the Java SDK. Request latency is included in the telemetry data. Recording the request latency allows Checkout.com to continuously monitor and improve the merchant experience. + +Request telemetry can be disabled by opting out during CheckoutSdk builder step: +```java +final CheckoutApi checkoutApi = CheckoutSdk.builder() + .staticKeys() + .secretKey("secret_key") + .environment(Environment.PRODUCTION) + .recordTelemetry(false) + .build(); +``` + ## Code of Conduct Please refer to [Code of Conduct](CODE_OF_CONDUCT.md) diff --git a/build.gradle b/build.gradle index d286c87d..5f92723f 100644 --- a/build.gradle +++ b/build.gradle @@ -38,7 +38,7 @@ dependencies { testImplementation 'org.apache.logging.log4j:log4j-api:2.17.1' testImplementation 'org.apache.logging.log4j:log4j-core:2.17.1' testImplementation 'org.apache.logging.log4j:log4j-slf4j-impl:2.17.1' - + testImplementation 'org.apache.httpcomponents:httpclient:4.5.13' } buildScan { diff --git a/src/main/java/com/checkout/AbstractCheckoutSdkBuilder.java b/src/main/java/com/checkout/AbstractCheckoutSdkBuilder.java index 71399f40..2e11f781 100644 --- a/src/main/java/com/checkout/AbstractCheckoutSdkBuilder.java +++ b/src/main/java/com/checkout/AbstractCheckoutSdkBuilder.java @@ -11,6 +11,7 @@ public abstract class AbstractCheckoutSdkBuilder { private EnvironmentSubdomain environmentSubdomain; private Executor executor = ForkJoinPool.commonPool(); private TransportConfiguration transportConfiguration; + private Boolean recordTelemetry = true; public AbstractCheckoutSdkBuilder environment(final IEnvironment environment) { this.environment = environment; @@ -48,6 +49,11 @@ protected EnvironmentSubdomain getEnvironmentSubdomain() { return environmentSubdomain; } + public AbstractCheckoutSdkBuilder recordTelemetry(final Boolean recordTelemetry) { + this.recordTelemetry = recordTelemetry; + return this; + } + protected abstract SdkCredentials getSdkCredentials(); protected CheckoutConfiguration getCheckoutConfiguration() { @@ -62,7 +68,7 @@ protected CheckoutConfiguration getCheckoutConfiguration() { } private CheckoutConfiguration buildCheckoutConfiguration(final SdkCredentials sdkCredentials) { - return new DefaultCheckoutConfiguration(sdkCredentials, getEnvironment(), getEnvironmentSubdomain(), httpClientBuilder, executor, transportConfiguration); + return new DefaultCheckoutConfiguration(sdkCredentials, getEnvironment(), getEnvironmentSubdomain(), httpClientBuilder, executor, transportConfiguration, recordTelemetry); } public abstract T build(); diff --git a/src/main/java/com/checkout/ApacheHttpClientTransport.java b/src/main/java/com/checkout/ApacheHttpClientTransport.java index 0a8ead50..7e0db0d6 100644 --- a/src/main/java/com/checkout/ApacheHttpClientTransport.java +++ b/src/main/java/com/checkout/ApacheHttpClientTransport.java @@ -1,23 +1,10 @@ package com.checkout; -import static com.checkout.ClientOperation.POST; -import static com.checkout.common.CheckoutUtils.ACCEPT_JSON; -import static com.checkout.common.CheckoutUtils.PROJECT_NAME; -import static com.checkout.common.CheckoutUtils.getVersionFromManifest; -import static org.apache.http.HttpHeaders.ACCEPT; -import static org.apache.http.HttpHeaders.AUTHORIZATION; -import static org.apache.http.HttpHeaders.USER_AGENT; - -import java.net.MalformedURLException; -import java.net.URI; -import java.net.URISyntaxException; -import java.util.Arrays; -import java.util.List; -import java.util.Map; -import java.util.concurrent.CompletableFuture; -import java.util.concurrent.Executor; -import java.util.stream.Collectors; - +import com.checkout.accounts.AccountsFileRequest; +import com.checkout.common.AbstractFileRequest; +import com.checkout.common.CheckoutUtils; +import com.checkout.common.FileRequest; +import lombok.extern.slf4j.Slf4j; import org.apache.http.Header; import org.apache.http.HttpEntity; import org.apache.http.HttpEntityEnclosingRequest; @@ -42,12 +29,25 @@ import org.apache.http.message.BasicNameValuePair; import org.apache.http.util.EntityUtils; -import com.checkout.accounts.AccountsFileRequest; -import com.checkout.common.AbstractFileRequest; -import com.checkout.common.CheckoutUtils; -import com.checkout.common.FileRequest; +import java.net.MalformedURLException; +import java.net.URI; +import java.net.URISyntaxException; +import java.util.Arrays; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.UUID; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.Executor; +import java.util.stream.Collectors; -import lombok.extern.slf4j.Slf4j; +import static com.checkout.ClientOperation.POST; +import static com.checkout.common.CheckoutUtils.ACCEPT_JSON; +import static com.checkout.common.CheckoutUtils.PROJECT_NAME; +import static com.checkout.common.CheckoutUtils.getVersionFromManifest; +import static org.apache.http.HttpHeaders.ACCEPT; +import static org.apache.http.HttpHeaders.AUTHORIZATION; +import static org.apache.http.HttpHeaders.USER_AGENT; @Slf4j class ApacheHttpClientTransport implements Transport { @@ -57,12 +57,22 @@ class ApacheHttpClientTransport implements Transport { private static final String FILE = "file"; private static final String PURPOSE = "purpose"; private static final String PATH = "path"; + private final URI baseUri; private final CloseableHttpClient httpClient; private final Executor executor; private final TransportConfiguration transportConfiguration; + private final CheckoutConfiguration configuration; - ApacheHttpClientTransport(final URI baseUri, final HttpClientBuilder httpClientBuilder, final Executor executor, final TransportConfiguration transportConfiguration) { + private static final ThreadLocal> telemetryData = ThreadLocal.withInitial(HashMap::new); + + ApacheHttpClientTransport( + final URI baseUri, + final HttpClientBuilder httpClientBuilder, + final Executor executor, + final TransportConfiguration transportConfiguration, + final CheckoutConfiguration configuration + ) { CheckoutUtils.validateParams("baseUri", baseUri, "httpClientBuilder", httpClientBuilder, "executor", executor); this.baseUri = baseUri; this.httpClient = httpClientBuilder @@ -70,6 +80,7 @@ class ApacheHttpClientTransport implements Transport { .build(); this.executor = executor; this.transportConfiguration = transportConfiguration; + this.configuration = configuration; } @Override @@ -154,15 +165,29 @@ private Response performCall(final SdkAuthorization authorization, request.setHeader(ACCEPT, getAcceptHeader(clientOperation)); request.setHeader(AUTHORIZATION, authorization.getAuthorizationHeader()); + String currentRequestId = UUID.randomUUID().toString(); + + if (configuration.isTelemetryEnabled()) { + String telemetryHeader = generateTelemetryHeader(currentRequestId); + request.setHeader("cko-sdk-telemetry", telemetryHeader); + } + + long startTime = System.currentTimeMillis(); + log.info("Request: " + Arrays.toString(sanitiseHeaders(request.getAllHeaders()))); if (requestBody != null && request instanceof HttpEntityEnclosingRequest) { ((HttpEntityEnclosingRequestBase) request).setEntity(new StringEntity(requestBody, ContentType.APPLICATION_JSON)); } try (final CloseableHttpResponse response = httpClient.execute(request)) { + long elapsed = System.currentTimeMillis() - startTime; log.info("Response: " + response.getStatusLine().getStatusCode() + " " + Arrays.toString(response.getAllHeaders())); + + updateTelemetryData(currentRequestId, elapsed); + final int statusCode = response.getStatusLine().getStatusCode(); final Map headers = Arrays.stream(response.getAllHeaders()) .collect(Collectors.toMap(Header::getName, Header::getValue)); + if (statusCode != HttpStatus.SC_NOT_FOUND && response.getEntity() != null && response.getEntity().getContent() != null) { return Response.builder() .statusCode(statusCode) @@ -174,13 +199,33 @@ private Response performCall(final SdkAuthorization authorization, } catch (final NoHttpResponseException e) { log.error("Target server failed to respond with a valid HTTP response."); return Response.builder().statusCode(HttpStatus.SC_GATEWAY_TIMEOUT).build(); - } catch (final Exception e) { log.error("Exception occurred during the execution of the client...", e); } return Response.builder().statusCode(transportConfiguration.getDefaultHttpStatusCode()).build(); } + private String generateTelemetryHeader(String currentRequestId) { + Map data = getTelemetryData(); + String prevRequestId = (String) data.get("prevRequestId"); + Long prevRequestDuration = (Long) data.get("prevRequestDuration"); + + return String.format("{\"requestId\":\"%s\",\"prevRequestId\":\"%s\",\"prevRequestDuration\":%d}", + currentRequestId, + prevRequestId != null ? prevRequestId : "N/A", + prevRequestDuration != null ? prevRequestDuration : 0); + } + + private static void updateTelemetryData(String requestId, long duration) { + Map data = telemetryData.get(); + data.put("prevRequestId", requestId); + data.put("prevRequestDuration", duration); + } + + private static Map getTelemetryData() { + return telemetryData.get(); + } + private Header[] sanitiseHeaders(final Header[] headers) { return Arrays.stream(headers) .filter(it -> !it.getName().equals(AUTHORIZATION)) @@ -210,5 +255,4 @@ private String getRequestUrl(final String path) { throw new CheckoutException(e); } } - -} +} \ No newline at end of file diff --git a/src/main/java/com/checkout/ApiClientImpl.java b/src/main/java/com/checkout/ApiClientImpl.java index 10304dc7..36fb15ed 100644 --- a/src/main/java/com/checkout/ApiClientImpl.java +++ b/src/main/java/com/checkout/ApiClientImpl.java @@ -34,7 +34,7 @@ public class ApiClientImpl implements ApiClient { public ApiClientImpl(final CheckoutConfiguration configuration, final UriStrategy uriStrategy) { this.serializer = new GsonSerializer(); - this.transport = new ApacheHttpClientTransport(uriStrategy.getUri(), configuration.getHttpClientBuilder(), configuration.getExecutor(), configuration.getTransportConfiguration()); + this.transport = new ApacheHttpClientTransport(uriStrategy.getUri(), configuration.getHttpClientBuilder(), configuration.getExecutor(), configuration.getTransportConfiguration(), configuration); } @Override diff --git a/src/main/java/com/checkout/CheckoutConfiguration.java b/src/main/java/com/checkout/CheckoutConfiguration.java index 8df00873..eb02773b 100644 --- a/src/main/java/com/checkout/CheckoutConfiguration.java +++ b/src/main/java/com/checkout/CheckoutConfiguration.java @@ -18,4 +18,6 @@ public interface CheckoutConfiguration { TransportConfiguration getTransportConfiguration(); + Boolean isTelemetryEnabled(); + } diff --git a/src/main/java/com/checkout/DefaultCheckoutConfiguration.java b/src/main/java/com/checkout/DefaultCheckoutConfiguration.java index ffe2b2cf..07836cc4 100644 --- a/src/main/java/com/checkout/DefaultCheckoutConfiguration.java +++ b/src/main/java/com/checkout/DefaultCheckoutConfiguration.java @@ -14,12 +14,14 @@ class DefaultCheckoutConfiguration implements CheckoutConfiguration { private final IEnvironment environment; private final EnvironmentSubdomain environmentSubdomain; private final TransportConfiguration transportConfiguration; + private final boolean recordTelemetry; DefaultCheckoutConfiguration(final SdkCredentials sdkCredentials, final IEnvironment environment, final HttpClientBuilder httpClientBuilder, final Executor executor, - final TransportConfiguration transportConfiguration) { + final TransportConfiguration transportConfiguration, + final boolean recordTelemetry) { validateParams("sdkCredentials", sdkCredentials, "environment", environment, "httpClientBuilder", httpClientBuilder, "executor", executor, "transportConfiguration", transportConfiguration); this.sdkCredentials = sdkCredentials; this.httpClientBuilder = httpClientBuilder; @@ -27,6 +29,7 @@ class DefaultCheckoutConfiguration implements CheckoutConfiguration { this.environment = environment; this.environmentSubdomain = null; this.transportConfiguration = transportConfiguration; + this.recordTelemetry = recordTelemetry; } DefaultCheckoutConfiguration(final SdkCredentials sdkCredentials, @@ -34,7 +37,8 @@ class DefaultCheckoutConfiguration implements CheckoutConfiguration { final EnvironmentSubdomain environmentSubdomain, final HttpClientBuilder httpClientBuilder, final Executor executor, - final TransportConfiguration transportConfiguration) { + final TransportConfiguration transportConfiguration, + final Boolean recordTelemetry) { validateParams("sdkCredentials", sdkCredentials, "environment", environment, "httpClientBuilder", httpClientBuilder, "executor", executor, "transportConfiguration", transportConfiguration); this.sdkCredentials = sdkCredentials; this.httpClientBuilder = httpClientBuilder; @@ -42,6 +46,7 @@ class DefaultCheckoutConfiguration implements CheckoutConfiguration { this.environment = environment; this.environmentSubdomain = environmentSubdomain; this.transportConfiguration = transportConfiguration; + this.recordTelemetry = recordTelemetry; } @Override @@ -73,4 +78,9 @@ public EnvironmentSubdomain getEnvironmentSubdomain() { public TransportConfiguration getTransportConfiguration() { return transportConfiguration; } + + @Override + public Boolean isTelemetryEnabled() { + return this.recordTelemetry; + } } diff --git a/src/main/java/com/checkout/RequestMetrics.java b/src/main/java/com/checkout/RequestMetrics.java new file mode 100644 index 00000000..16a6cabe --- /dev/null +++ b/src/main/java/com/checkout/RequestMetrics.java @@ -0,0 +1,17 @@ +package com.checkout; + +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@NoArgsConstructor +public class RequestMetrics { + private String requestId; + private Long prevRequestDuration; + private String prevRequestId; + + public RequestMetrics(Long prevRequestDuration, String prevRequestId) { + this.prevRequestDuration = prevRequestDuration; + this.prevRequestId = prevRequestId; + } +} \ No newline at end of file diff --git a/src/test/java/com/checkout/CheckoutSdkTelemetryIntegrationTest.java b/src/test/java/com/checkout/CheckoutSdkTelemetryIntegrationTest.java new file mode 100644 index 00000000..54d669f5 --- /dev/null +++ b/src/test/java/com/checkout/CheckoutSdkTelemetryIntegrationTest.java @@ -0,0 +1,307 @@ +package com.checkout; + +import org.apache.http.Header; +import org.apache.http.ProtocolVersion; +import org.apache.http.client.methods.CloseableHttpResponse; +import org.apache.http.client.methods.HttpUriRequest; +import org.apache.http.entity.StringEntity; +import org.apache.http.impl.client.CloseableHttpClient; +import org.apache.http.impl.client.HttpClientBuilder; +import org.apache.http.message.BasicHeader; +import org.apache.http.message.BasicStatusLine; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.util.List; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.stream.Collectors; + +import static com.checkout.Environment.SANDBOX; +import static java.util.Objects.requireNonNull; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.atLeastOnce; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; +import static org.mockito.internal.verification.VerificationModeFactory.times; + +class CheckoutSdkTelemetryIntegrationTest { + + private static final Logger log = LoggerFactory.getLogger(CheckoutSdkTelemetryIntegrationTest.class); + + @Test + void shouldSendTelemetryByDefault() throws Exception { + // Mock CloseableHttpClient and response + CloseableHttpClient httpClientMock = mock(CloseableHttpClient.class); + CloseableHttpResponse responseMock = mock(CloseableHttpResponse.class); + + // Mock the response status line as 200 OK + when(responseMock.getStatusLine()).thenReturn( + new BasicStatusLine(new ProtocolVersion("HTTP", 1, 1), 200, "OK") + ); + + // Mock response headers + when(responseMock.getAllHeaders()).thenReturn(new Header[]{ + new BasicHeader("Content-Type", "application/json"), + new BasicHeader("Cko-Request-Id", "test-request-id") + }); + + // Mock response entity (simulated JSON data) + StringEntity entity = new StringEntity("{\"workflows\": [{\"id\": \"wf_123\", \"name\": \"Test Workflow\"}]}"); + when(responseMock.getEntity()).thenReturn(entity); + + // Configure the HTTP client mock to return the mock response + when(httpClientMock.execute(any(HttpUriRequest.class))).thenReturn(responseMock); + + // Mock HttpClientBuilder to return the mocked HttpClient + HttpClientBuilder httpClientBuilderMock = mock(HttpClientBuilder.class); + when(httpClientBuilderMock.setRedirectStrategy(any())).thenReturn(httpClientBuilderMock); + when(httpClientBuilderMock.build()).thenReturn(httpClientMock); + + // Build CheckoutApi with mocked components + CheckoutApi checkoutApi = CheckoutSdk.builder() + .staticKeys() + .publicKey(requireNonNull(System.getenv("CHECKOUT_DEFAULT_PUBLIC_KEY"))) + .secretKey(requireNonNull(System.getenv("CHECKOUT_DEFAULT_SECRET_KEY"))) + .environment(SANDBOX) + .httpClientBuilder(httpClientBuilderMock) + .build(); + + // Execute some requests to test telemetry + checkoutApi.workflowsClient().getWorkflows().get(); + checkoutApi.workflowsClient().getWorkflows().get(); + checkoutApi.workflowsClient().getWorkflows().get(); + + // Capture and verify requests + ArgumentCaptor requestCaptor = ArgumentCaptor.forClass(HttpUriRequest.class); + verify(httpClientMock, atLeastOnce()).execute(requestCaptor.capture()); + + // Assert that the telemetry header is present by default + boolean telemetryHeaderFound = requestCaptor.getAllValues().stream() + .anyMatch(req -> req.containsHeader("cko-sdk-telemetry")); + + assertTrue(telemetryHeaderFound, "The telemetry header should be present by default"); + } + + @Test + void shouldNotSendTelemetryWhenOptedOut() throws Exception { + // Mock CloseableHttpClient and response + CloseableHttpClient httpClientMock = mock(CloseableHttpClient.class); + CloseableHttpResponse responseMock = mock(CloseableHttpResponse.class); + + // Mock the response status line as 200 OK + when(responseMock.getStatusLine()).thenReturn( + new BasicStatusLine(new ProtocolVersion("HTTP", 1, 1), 200, "OK") + ); + + // Mock response headers + when(responseMock.getAllHeaders()).thenReturn(new Header[]{ + new BasicHeader("Content-Type", "application/json"), + new BasicHeader("Cko-Request-Id", "test-request-id") + }); + + // Mock response entity (simulated JSON data) + StringEntity entity = new StringEntity("{\"workflows\": [{\"id\": \"wf_123\", \"name\": \"Test Workflow\"}]}"); + when(responseMock.getEntity()).thenReturn(entity); + + // Configure the HTTP client mock to return the mock response + when(httpClientMock.execute(any(HttpUriRequest.class))).thenReturn(responseMock); + + // Mock HttpClientBuilder to return the mocked HttpClient + HttpClientBuilder httpClientBuilderMock = mock(HttpClientBuilder.class); + when(httpClientBuilderMock.setRedirectStrategy(any())).thenReturn(httpClientBuilderMock); + when(httpClientBuilderMock.build()).thenReturn(httpClientMock); + + // Build CheckoutApi with mocked components and telemetry disabled + CheckoutApi checkoutApi = CheckoutSdk.builder() + .staticKeys() + .publicKey(requireNonNull(System.getenv("CHECKOUT_DEFAULT_PUBLIC_KEY"))) + .secretKey(requireNonNull(System.getenv("CHECKOUT_DEFAULT_SECRET_KEY"))) + .recordTelemetry(false) // Disable telemetry + .environment(SANDBOX) + .httpClientBuilder(httpClientBuilderMock) + .build(); + + // Execute some requests to test telemetry + checkoutApi.workflowsClient().getWorkflows().get(); + checkoutApi.workflowsClient().getWorkflows().get(); + + // Capture and verify requests + ArgumentCaptor requestCaptor = ArgumentCaptor.forClass(HttpUriRequest.class); + verify(httpClientMock, atLeastOnce()).execute(requestCaptor.capture()); + + // Assert that the telemetry header is not present + boolean telemetryHeaderFound = requestCaptor.getAllValues().stream() + .anyMatch(req -> req.containsHeader("cko-sdk-telemetry")); + + assertFalse(telemetryHeaderFound, "The telemetry header should not be present when telemetry is disabled"); + } + + @Test + void shouldHandleConcurrentRequests() throws Exception { + // Mock CloseableHttpClient and response + CloseableHttpClient httpClientMock = mock(CloseableHttpClient.class); + CloseableHttpResponse responseMock = mock(CloseableHttpResponse.class); + + // Mock the response status line as 200 OK + when(responseMock.getStatusLine()).thenReturn( + new BasicStatusLine(new ProtocolVersion("HTTP", 1, 1), 200, "OK") + ); + + // Mock response headers + when(responseMock.getAllHeaders()).thenReturn(new Header[]{ + new BasicHeader("Content-Type", "application/json"), + new BasicHeader("Cko-Request-Id", "test-request-id") + }); + + // Mock response entity + StringEntity entity = new StringEntity("{\"workflows\": [{\"id\": \"wf_123\", \"name\": \"Test Workflow\"}]}"); + when(responseMock.getEntity()).thenReturn(entity); + + // Configure the HTTP client mock to return the mock response + when(httpClientMock.execute(any(HttpUriRequest.class))).thenReturn(responseMock); + + // Mock HttpClientBuilder to return the mocked HttpClient + HttpClientBuilder httpClientBuilderMock = mock(HttpClientBuilder.class); + when(httpClientBuilderMock.setRedirectStrategy(any())).thenReturn(httpClientBuilderMock); + when(httpClientBuilderMock.build()).thenReturn(httpClientMock); + + // Build CheckoutApi with mocked components + CheckoutApi checkoutApi = CheckoutSdk.builder() + .staticKeys() + .publicKey(requireNonNull(System.getenv("CHECKOUT_DEFAULT_PUBLIC_KEY"))) + .secretKey(requireNonNull(System.getenv("CHECKOUT_DEFAULT_SECRET_KEY"))) + .environment(SANDBOX) + .httpClientBuilder(httpClientBuilderMock) + .build(); + + // Prepare a concurrent test environment + int threadCount = 10; + CountDownLatch startLatch = new CountDownLatch(1); + CountDownLatch doneLatch = new CountDownLatch(threadCount); + ExecutorService executorService = Executors.newFixedThreadPool(threadCount); + + // Submit concurrent tasks + for (int i = 0; i < threadCount; i++) { + executorService.submit(() -> { + try { + startLatch.await(); // Wait for all threads to be ready + checkoutApi.workflowsClient().getWorkflows().get(); + } catch (Exception e) { + log.error("Error occurred during concurrent request: {}", e.getMessage(), e); + } finally { + doneLatch.countDown(); // Signal that the thread has completed + } + }); + } + + // Start all threads simultaneously + startLatch.countDown(); + doneLatch.await(); // Wait for all threads to finish + executorService.shutdown(); + + // Capture and verify requests + ArgumentCaptor requestCaptor = ArgumentCaptor.forClass(HttpUriRequest.class); + verify(httpClientMock, times(threadCount)).execute(requestCaptor.capture()); + + List requests = requestCaptor.getAllValues(); + assertFalse(requests.isEmpty(), "requests mustn't be empty."); + + // Ensure telemetry header exists and is unique for all concurrent requests + List telemetryHeaders = requests.stream() + .map(req -> req.getFirstHeader("cko-sdk-telemetry").getValue()) + .collect(Collectors.toList()); + + assertEquals( + telemetryHeaders.stream().distinct().count(), + threadCount, + "All concurrent requests should have unique telemetry headers" + ); + } + + @Test + void shouldHandleHighLoadRequestsConcurrently() throws Exception { + // Mock CloseableHttpClient and response + CloseableHttpClient httpClientMock = mock(CloseableHttpClient.class); + CloseableHttpResponse responseMock = mock(CloseableHttpResponse.class); + + // Mock the response status line as 200 OK + when(responseMock.getStatusLine()).thenReturn( + new BasicStatusLine(new ProtocolVersion("HTTP", 1, 1), 200, "OK") + ); + + // Mock response headers + when(responseMock.getAllHeaders()).thenReturn(new Header[]{ + new BasicHeader("Content-Type", "application/json"), + new BasicHeader("Cko-Request-Id", "test-request-id") + }); + + // Mock response entity + StringEntity entity = new StringEntity("{\"workflows\": [{\"id\": \"wf_123\", \"name\": \"Test Workflow\"}]}"); + when(responseMock.getEntity()).thenReturn(entity); + + // Configure the HTTP client mock to return the mock response + when(httpClientMock.execute(any(HttpUriRequest.class))).thenReturn(responseMock); + + // Mock HttpClientBuilder to return the mocked HttpClient + HttpClientBuilder httpClientBuilderMock = mock(HttpClientBuilder.class); + when(httpClientBuilderMock.setRedirectStrategy(any())).thenReturn(httpClientBuilderMock); + when(httpClientBuilderMock.build()).thenReturn(httpClientMock); + + // Build CheckoutApi with mocked components + CheckoutApi checkoutApi = CheckoutSdk.builder() + .staticKeys() + .publicKey(requireNonNull(System.getenv("CHECKOUT_DEFAULT_PUBLIC_KEY"))) + .secretKey(requireNonNull(System.getenv("CHECKOUT_DEFAULT_SECRET_KEY"))) + .environment(SANDBOX) + .httpClientBuilder(httpClientBuilderMock) + .build(); + + // Simulate high load + int requestCount = 1000; // Total number of requests + CountDownLatch startLatch = new CountDownLatch(1); + CountDownLatch doneLatch = new CountDownLatch(requestCount); + ExecutorService executorService = Executors.newFixedThreadPool(50); // 50 threads + + for (int i = 0; i < requestCount; i++) { + executorService.submit(() -> { + try { + startLatch.await(); // Wait for all threads to be ready + checkoutApi.workflowsClient().getWorkflows().get(); + } catch (Exception e) { + log.error("Error occurred during concurrent request: {}", e.getMessage(), e); + } finally { + doneLatch.countDown(); // Signal completion + } + }); + } + + // Start all threads and wait for them to finish + startLatch.countDown(); + doneLatch.await(); // Wait for all threads to complete + executorService.shutdown(); + + // Capture and verify requests + ArgumentCaptor requestCaptor = ArgumentCaptor.forClass(HttpUriRequest.class); + verify(httpClientMock, times(requestCount)).execute(requestCaptor.capture()); + + List requests = requestCaptor.getAllValues(); + assertFalse(requests.isEmpty(), "Requests mustn't be empty."); + + // Ensure all telemetry headers are present and correctly formed + List telemetryHeaders = requests.stream() + .map(req -> req.getFirstHeader("cko-sdk-telemetry").getValue()) + .collect(Collectors.toList()); + + assertEquals(requestCount, telemetryHeaders.size(), "All requests must include telemetry headers."); + assertTrue(telemetryHeaders.stream().allMatch(header -> header.contains("\"requestId\"")), + "Each telemetry header must include a 'requestId'."); + } +} \ No newline at end of file diff --git a/src/test/java/com/checkout/DefaultCheckoutConfigurationTest.java b/src/test/java/com/checkout/DefaultCheckoutConfigurationTest.java index c0c0666a..daff450b 100644 --- a/src/test/java/com/checkout/DefaultCheckoutConfigurationTest.java +++ b/src/test/java/com/checkout/DefaultCheckoutConfigurationTest.java @@ -2,14 +2,19 @@ import static java.net.URI.create; import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertNotNull; import static org.junit.jupiter.api.Assertions.assertTrue; import static org.junit.jupiter.api.Assertions.fail; +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.ExecutionException; import java.util.concurrent.Executor; import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; import java.util.concurrent.ForkJoinPool; +import java.util.concurrent.Future; import org.apache.http.HttpStatus; import org.apache.http.impl.client.HttpClientBuilder; @@ -28,7 +33,7 @@ class DefaultCheckoutConfigurationTest { void shouldFailCreatingConfiguration() { try { final StaticKeysSdkCredentials credentials = Mockito.mock(StaticKeysSdkCredentials.class); - new DefaultCheckoutConfiguration(credentials, null, null, null, null); + new DefaultCheckoutConfiguration(credentials, null, null, null, null, false); fail(); } catch (final Exception e) { assertTrue(e instanceof CheckoutArgumentException); @@ -41,7 +46,7 @@ void shouldCreateConfiguration() { final StaticKeysSdkCredentials credentials = Mockito.mock(StaticKeysSdkCredentials.class); - final CheckoutConfiguration configuration = new DefaultCheckoutConfiguration(credentials, Environment.PRODUCTION, DEFAULT_CLIENT_BUILDER, DEFAULT_EXECUTOR, DEFAULT_TRANSPORT_CONFIGURATION); + final CheckoutConfiguration configuration = new DefaultCheckoutConfiguration(credentials, Environment.PRODUCTION, DEFAULT_CLIENT_BUILDER, DEFAULT_EXECUTOR, DEFAULT_TRANSPORT_CONFIGURATION, false); assertEquals(Environment.PRODUCTION, configuration.getEnvironment()); } @@ -53,7 +58,7 @@ void shouldCreateConfigurationWithSubdomain(String subdomain) { final StaticKeysSdkCredentials credentials = Mockito.mock(StaticKeysSdkCredentials.class); final EnvironmentSubdomain environmentSubdomain = new EnvironmentSubdomain(Environment.SANDBOX, subdomain); - final CheckoutConfiguration configuration = new DefaultCheckoutConfiguration(credentials, Environment.SANDBOX, environmentSubdomain, DEFAULT_CLIENT_BUILDER, DEFAULT_EXECUTOR, DEFAULT_TRANSPORT_CONFIGURATION); + final CheckoutConfiguration configuration = new DefaultCheckoutConfiguration(credentials, Environment.SANDBOX, environmentSubdomain, DEFAULT_CLIENT_BUILDER, DEFAULT_EXECUTOR, DEFAULT_TRANSPORT_CONFIGURATION, false); assertEquals("https://" + subdomain + ".api.sandbox.checkout.com/", configuration.getEnvironmentSubdomain().getCheckoutApi().toString()); } @@ -64,7 +69,7 @@ void shouldCreateConfigurationWithBadSubdomain(String subdomain) { final StaticKeysSdkCredentials credentials = Mockito.mock(StaticKeysSdkCredentials.class); final EnvironmentSubdomain environmentSubdomain = new EnvironmentSubdomain(Environment.SANDBOX, subdomain); - final CheckoutConfiguration configuration = new DefaultCheckoutConfiguration(credentials, Environment.SANDBOX, environmentSubdomain, DEFAULT_CLIENT_BUILDER, DEFAULT_EXECUTOR, DEFAULT_TRANSPORT_CONFIGURATION); + final CheckoutConfiguration configuration = new DefaultCheckoutConfiguration(credentials, Environment.SANDBOX, environmentSubdomain, DEFAULT_CLIENT_BUILDER, DEFAULT_EXECUTOR, DEFAULT_TRANSPORT_CONFIGURATION, false); assertEquals("https://api.sandbox.checkout.com/", configuration.getEnvironmentSubdomain().getCheckoutApi().toString()); } @@ -73,7 +78,7 @@ void shouldCreateConfiguration_defaultHttpClientBuilderAndExecutor() { final StaticKeysSdkCredentials credentials = Mockito.mock(StaticKeysSdkCredentials.class); - final CheckoutConfiguration configuration = new DefaultCheckoutConfiguration(credentials, Environment.PRODUCTION, DEFAULT_CLIENT_BUILDER, DEFAULT_EXECUTOR, DEFAULT_TRANSPORT_CONFIGURATION); + final CheckoutConfiguration configuration = new DefaultCheckoutConfiguration(credentials, Environment.PRODUCTION, DEFAULT_CLIENT_BUILDER, DEFAULT_EXECUTOR, DEFAULT_TRANSPORT_CONFIGURATION, false); assertEquals(Environment.PRODUCTION, configuration.getEnvironment()); assertNotNull(configuration.getHttpClientBuilder()); @@ -91,7 +96,7 @@ void shouldCreateConfiguration_customHttpClientBuilderAndExecutor() { .defaultHttpStatusCode(HttpStatus.SC_INTERNAL_SERVER_ERROR) .build(); - final CheckoutConfiguration configuration = new DefaultCheckoutConfiguration(credentials, Environment.PRODUCTION, httpClientBuilder, executorService, transportConfiguration); + final CheckoutConfiguration configuration = new DefaultCheckoutConfiguration(credentials, Environment.PRODUCTION, httpClientBuilder, executorService, transportConfiguration, false); assertEquals(Environment.PRODUCTION, configuration.getEnvironment()); assertEquals(httpClientBuilder, configuration.getHttpClientBuilder()); @@ -103,7 +108,7 @@ void shouldCreateConfigurationForProd() { final StaticKeysSdkCredentials credentials = Mockito.mock(StaticKeysSdkCredentials.class); - final CheckoutConfiguration configuration = new DefaultCheckoutConfiguration(credentials, Environment.PRODUCTION, DEFAULT_CLIENT_BUILDER, DEFAULT_EXECUTOR, DEFAULT_TRANSPORT_CONFIGURATION); + final CheckoutConfiguration configuration = new DefaultCheckoutConfiguration(credentials, Environment.PRODUCTION, DEFAULT_CLIENT_BUILDER, DEFAULT_EXECUTOR, DEFAULT_TRANSPORT_CONFIGURATION, false); assertEquals(Environment.PRODUCTION, configuration.getEnvironment()); } @@ -120,7 +125,7 @@ void shouldCreateConfigurationWithCustomEnvironment() { final StaticKeysSdkCredentials credentials = Mockito.mock(StaticKeysSdkCredentials.class); - final CheckoutConfiguration configuration = new DefaultCheckoutConfiguration(credentials, environment, DEFAULT_CLIENT_BUILDER, DEFAULT_EXECUTOR, DEFAULT_TRANSPORT_CONFIGURATION); + final CheckoutConfiguration configuration = new DefaultCheckoutConfiguration(credentials, environment, DEFAULT_CLIENT_BUILDER, DEFAULT_EXECUTOR, DEFAULT_TRANSPORT_CONFIGURATION, false); assertEquals(environment, configuration.getEnvironment()); assertEquals(environment.getCheckoutApi(), configuration.getEnvironment().getCheckoutApi()); assertEquals(environment.getOAuthAuthorizationApi(), configuration.getEnvironment().getOAuthAuthorizationApi()); @@ -129,4 +134,61 @@ void shouldCreateConfigurationWithCustomEnvironment() { assertEquals(environment.getBalancesApi(), configuration.getEnvironment().getBalancesApi()); } + /** + * Test concurrent creation of configurations to ensure thread-safety or at least no exceptions. + * This is a simple concurrency test that tries to create multiple configurations in parallel. + */ + @Test + void shouldCreateConfigurationsConcurrently() throws InterruptedException, ExecutionException { + final StaticKeysSdkCredentials credentials = Mockito.mock(StaticKeysSdkCredentials.class); + int numberOfThreads = 10; + ExecutorService executorService = Executors.newFixedThreadPool(numberOfThreads); + + List> futures = new ArrayList<>(); + for (int i = 0; i < numberOfThreads; i++) { + futures.add(executorService.submit(() -> new DefaultCheckoutConfiguration( + credentials, Environment.PRODUCTION, DEFAULT_CLIENT_BUILDER, DEFAULT_EXECUTOR, DEFAULT_TRANSPORT_CONFIGURATION, true))); + } + + for (Future future : futures) { + CheckoutConfiguration config = future.get(); + assertNotNull(config); + assertEquals(Environment.PRODUCTION, config.getEnvironment()); + } + + executorService.shutdown(); + } + + /** + * Test to ensure telemetry flag works in concurrency as well. + * This test tries to create configurations with different telemetry flags and checks them. + */ + @Test + void shouldHandleTelemetryFlagConcurrently() throws InterruptedException, ExecutionException { + final StaticKeysSdkCredentials credentials = Mockito.mock(StaticKeysSdkCredentials.class); + int numberOfThreads = 10; + ExecutorService executorService = Executors.newFixedThreadPool(numberOfThreads); + + List> futures = new ArrayList<>(); + for (int i = 0; i < numberOfThreads; i++) { + final boolean flag = (i % 2 == 0); + futures.add(executorService.submit(() -> { + CheckoutConfiguration config = new DefaultCheckoutConfiguration( + credentials, Environment.PRODUCTION, DEFAULT_CLIENT_BUILDER, DEFAULT_EXECUTOR, DEFAULT_TRANSPORT_CONFIGURATION, flag); + return config.isTelemetryEnabled(); + })); + } + + for (int i = 0; i < numberOfThreads; i++) { + Boolean result = futures.get(i).get(); + if (i % 2 == 0) { + assertTrue(result, "Expected telemetry to be enabled for even i=" + i); + } else { + assertFalse(result, "Expected telemetry to be disabled for odd i=" + i); + } + } + + executorService.shutdown(); + } + } diff --git a/src/test/java/com/checkout/PreviousStaticKeysSdkCredentialsTest.java b/src/test/java/com/checkout/PreviousStaticKeysSdkCredentialsTest.java index e46d972e..d3f68ad9 100644 --- a/src/test/java/com/checkout/PreviousStaticKeysSdkCredentialsTest.java +++ b/src/test/java/com/checkout/PreviousStaticKeysSdkCredentialsTest.java @@ -64,7 +64,7 @@ void shouldFailToCreatePreviousStaticKeysSdkCredentialsForProd() { try { final PreviousStaticKeysSdkCredentials credentials = new PreviousStaticKeysSdkCredentials(VALID_PREVIOUS_SK, similarDefaultPk); - new DefaultCheckoutConfiguration(credentials, Environment.SANDBOX, null, null, null); + new DefaultCheckoutConfiguration(credentials, Environment.SANDBOX, null, null, null, false); fail(); } catch (final Exception e) { assertTrue(e instanceof CheckoutArgumentException); @@ -72,7 +72,7 @@ void shouldFailToCreatePreviousStaticKeysSdkCredentialsForProd() { } try { final PreviousStaticKeysSdkCredentials credentials = new PreviousStaticKeysSdkCredentials(similarDefaultSk, VALID_PREVIOUS_PK); - new DefaultCheckoutConfiguration(credentials, Environment.SANDBOX, null, null, null); + new DefaultCheckoutConfiguration(credentials, Environment.SANDBOX, null, null, null, false); fail(); } catch (final Exception e) { assertTrue(e instanceof CheckoutArgumentException); diff --git a/src/test/java/com/checkout/StaticKeysSdkCredentialsTest.java b/src/test/java/com/checkout/StaticKeysSdkCredentialsTest.java index 7e0da790..b2ead0bd 100644 --- a/src/test/java/com/checkout/StaticKeysSdkCredentialsTest.java +++ b/src/test/java/com/checkout/StaticKeysSdkCredentialsTest.java @@ -66,7 +66,7 @@ void shouldFailToCreateStaticKeysSdkCredentialsForProd() { try { final StaticKeysSdkCredentials credentials = new StaticKeysSdkCredentials(VALID_DEFAULT_SK, similarDefaultPk); - new DefaultCheckoutConfiguration(credentials, Environment.SANDBOX, null, null, null); + new DefaultCheckoutConfiguration(credentials, Environment.SANDBOX, null, null, null, false); fail(); } catch (final Exception e) { assertTrue(e instanceof CheckoutArgumentException); @@ -74,7 +74,7 @@ void shouldFailToCreateStaticKeysSdkCredentialsForProd() { } try { final StaticKeysSdkCredentials credentials = new StaticKeysSdkCredentials(similarDefaultSk, VALID_DEFAULT_PK); - new DefaultCheckoutConfiguration(credentials, Environment.SANDBOX, null, null, null); + new DefaultCheckoutConfiguration(credentials, Environment.SANDBOX, null, null, null, false); fail(); } catch (final Exception e) { assertTrue(e instanceof CheckoutArgumentException); diff --git a/src/test/java/com/checkout/customers/previous/CustomersTestIT.java b/src/test/java/com/checkout/customers/previous/CustomersTestIT.java index 51dfc0dd..e279895b 100644 --- a/src/test/java/com/checkout/customers/previous/CustomersTestIT.java +++ b/src/test/java/com/checkout/customers/previous/CustomersTestIT.java @@ -14,6 +14,7 @@ import com.checkout.instruments.previous.InstrumentDetails; import com.checkout.tokens.CardTokenRequest; import com.checkout.tokens.CardTokenResponse; +import org.junit.jupiter.api.Disabled; import org.junit.jupiter.api.Test; import static org.junit.jupiter.api.Assertions.assertEquals; @@ -82,6 +83,7 @@ void shouldCreateAndEditCustomer() { } @Test + @Disabled("unavailable") void shouldGetCustomerDetailsWithInstrument() { final CardTokenResponse cardToken = blocking(() -> previousApi.tokensClient().requestCardToken(createValidTokenRequest())); diff --git a/src/test/java/com/checkout/instruments/previous/InstrumentsTestIT.java b/src/test/java/com/checkout/instruments/previous/InstrumentsTestIT.java index cb701eb6..43aad2f5 100644 --- a/src/test/java/com/checkout/instruments/previous/InstrumentsTestIT.java +++ b/src/test/java/com/checkout/instruments/previous/InstrumentsTestIT.java @@ -10,6 +10,7 @@ import com.checkout.tokens.CardTokenRequest; import com.checkout.tokens.CardTokenResponse; import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Disabled; import org.junit.jupiter.api.Test; import static org.junit.jupiter.api.Assertions.assertEquals; @@ -43,6 +44,7 @@ void setUp() { } @Test + @Disabled("unavailable") void shouldCreateInstrument() { final CardTokenResponse cardToken = blocking(() -> previousApi.tokensClient().requestCardToken(createValidTokenRequest())); @@ -81,6 +83,7 @@ void shouldCreateInstrument() { } @Test + @Disabled("unavailable") void shouldGetInstrument() { final CardTokenResponse cardToken = blocking(() -> previousApi.tokensClient().requestCardToken(createValidTokenRequest())); @@ -134,6 +137,7 @@ void shouldGetInstrument() { } @Test + @Disabled("unavailable") void shouldUpdateInstrument() { final CardTokenResponse cardToken = blocking(() -> previousApi.tokensClient().requestCardToken(createValidTokenRequest())); @@ -155,6 +159,7 @@ void shouldUpdateInstrument() { } @Test + @Disabled("unavailable") void shouldDeleteInstrument() { final CardTokenResponse cardToken = blocking(() -> previousApi.tokensClient().requestCardToken(createValidTokenRequest())); diff --git a/src/test/java/com/checkout/payments/previous/RequestPaymentsTestIT.java b/src/test/java/com/checkout/payments/previous/RequestPaymentsTestIT.java index 601af7aa..ba4a92d9 100644 --- a/src/test/java/com/checkout/payments/previous/RequestPaymentsTestIT.java +++ b/src/test/java/com/checkout/payments/previous/RequestPaymentsTestIT.java @@ -12,6 +12,7 @@ import com.checkout.payments.previous.request.source.RequestCardSource; import com.checkout.payments.previous.response.PaymentResponse; import com.checkout.payments.previous.response.source.CardResponseSource; +import org.junit.jupiter.api.Disabled; import org.junit.jupiter.api.Test; import java.util.UUID; @@ -262,6 +263,7 @@ void shouldMakeCardPayment_n3d() { } @Test + @Disabled("unavailable") void shouldMakeTokenPayment() { final PaymentResponse paymentResponse = makeTokenPayment(); diff --git a/src/test/java/com/checkout/tokens/previous/TokensTestIT.java b/src/test/java/com/checkout/tokens/previous/TokensTestIT.java index 11b274b4..9cc0381e 100644 --- a/src/test/java/com/checkout/tokens/previous/TokensTestIT.java +++ b/src/test/java/com/checkout/tokens/previous/TokensTestIT.java @@ -9,6 +9,7 @@ import com.checkout.tokens.CardTokenResponse; import com.checkout.tokens.TokenType; import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Disabled; import org.junit.jupiter.api.Test; import java.time.Instant; @@ -24,6 +25,7 @@ class TokensTestIT extends SandboxTestFixture { } @Test + @Disabled("unavailable") void shouldRequestCardToken() { final CardTokenRequest request = CardTokenRequest.builder() diff --git a/src/test/java/com/checkout/workflows/WorkflowsTestIT.java b/src/test/java/com/checkout/workflows/WorkflowsTestIT.java index 85688c72..55e825cd 100644 --- a/src/test/java/com/checkout/workflows/WorkflowsTestIT.java +++ b/src/test/java/com/checkout/workflows/WorkflowsTestIT.java @@ -76,6 +76,14 @@ void shouldCreateAndGetWorkflows() { } + @Test + void shouldGetWorkflows() { + final GetWorkflowsResponse getWorkflowsResponse = blocking(() -> checkoutApi.workflowsClient().getWorkflows()); + assertNotNull(getWorkflowsResponse); + assertNotNull(getWorkflowsResponse.getWorkflows()); + assertFalse(getWorkflowsResponse.getWorkflows().isEmpty()); + } + @Test void shouldCreateAndUpdateWorkflow() {