From add3f413d88d4762455d950f9bc6ffb4cd04b18b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Armando=20Rodr=C3=ADguez?= <127134616+armando-rodriguez-cko@users.noreply.github.com> Date: Tue, 5 May 2026 16:43:02 +0200 Subject: [PATCH 1/5] fix: use correct base URLs for forward and identity services --- .../java/com/checkout/CheckoutApiImpl.java | 50 ++++++++++++++++--- .../java/com/checkout/CustomEnvironment.java | 4 ++ src/main/java/com/checkout/Environment.java | 10 ++++ .../com/checkout/EnvironmentSubdomain.java | 2 +- src/main/java/com/checkout/IEnvironment.java | 4 ++ .../DefaultCheckoutConfigurationTest.java | 30 ++++++++++- 6 files changed, 91 insertions(+), 9 deletions(-) diff --git a/src/main/java/com/checkout/CheckoutApiImpl.java b/src/main/java/com/checkout/CheckoutApiImpl.java index 7678ee1fa..4de3eb905 100644 --- a/src/main/java/com/checkout/CheckoutApiImpl.java +++ b/src/main/java/com/checkout/CheckoutApiImpl.java @@ -140,12 +140,12 @@ public CheckoutApiImpl(final CheckoutConfiguration configuration) { this.applePayClient = new ApplePayClientImpl(this.apiClient, configuration); this.googlePayClient = new GooglePayClientImpl(this.apiClient, configuration); this.complianceClient = new ComplianceClientImpl(this.apiClient, configuration); - this.forwardClient = new ForwardClientImpl(this.apiClient, configuration); - this.faceAuthenticationClient = new FaceAuthenticationClientImpl(this.apiClient, configuration); - this.applicantClient = new ApplicantClientImpl(this.apiClient, configuration); - this.identityVerificationClient = new IdentityVerificationClientImpl(this.apiClient, configuration); - this.idDocumentVerificationClient = new IdDocumentVerificationClientImpl(this.apiClient, configuration); - this.amlScreeningClient = new AmlScreeningClientImpl(this.apiClient, configuration); + this.forwardClient = new ForwardClientImpl(getForwardClient(configuration), configuration); + this.faceAuthenticationClient = new FaceAuthenticationClientImpl(getIdentityClient(configuration), configuration); + this.applicantClient = new ApplicantClientImpl(getIdentityClient(configuration), configuration); + this.identityVerificationClient = new IdentityVerificationClientImpl(getIdentityClient(configuration), configuration); + this.idDocumentVerificationClient = new IdDocumentVerificationClientImpl(getIdentityClient(configuration), configuration); + this.amlScreeningClient = new AmlScreeningClientImpl(getIdentityClient(configuration), configuration); this.networkTokensClient = new NetworkTokensClientImpl(this.apiClient, configuration); this.standaloneAccountUpdaterClient = new StandaloneAccountUpdaterClientImpl(this.apiClient, configuration); this.agenticCommerceClient = new AgenticCommerceClientImpl(this.apiClient, configuration); @@ -303,6 +303,14 @@ private ApiClient getBalancesClient(final CheckoutConfiguration configuration) { return new ApiClientImpl(configuration, new BalancesApiUriStrategy(configuration)); } + private ApiClient getForwardClient(final CheckoutConfiguration configuration) { + return new ApiClientImpl(configuration, new ForwardApiUriStrategy(configuration)); + } + + private ApiClient getIdentityClient(final CheckoutConfiguration configuration) { + return new ApiClientImpl(configuration, new IdentityApiUriStrategy(configuration)); + } + private static class FilesApiUriStrategy implements UriStrategy { private final CheckoutConfiguration configuration; @@ -348,4 +356,34 @@ public URI getUri() { } + private static class ForwardApiUriStrategy implements UriStrategy { + + private final CheckoutConfiguration configuration; + + private ForwardApiUriStrategy(final CheckoutConfiguration configuration) { + this.configuration = configuration; + } + + @Override + public URI getUri() { + return configuration.getEnvironment().getForwardApi(); + } + + } + + private static class IdentityApiUriStrategy implements UriStrategy { + + private final CheckoutConfiguration configuration; + + private IdentityApiUriStrategy(final CheckoutConfiguration configuration) { + this.configuration = configuration; + } + + @Override + public URI getUri() { + return configuration.getEnvironment().getIdentityApi(); + } + + } + } diff --git a/src/main/java/com/checkout/CustomEnvironment.java b/src/main/java/com/checkout/CustomEnvironment.java index 856f0a9d3..1a0766afc 100644 --- a/src/main/java/com/checkout/CustomEnvironment.java +++ b/src/main/java/com/checkout/CustomEnvironment.java @@ -17,6 +17,10 @@ public final class CustomEnvironment implements IEnvironment { private URI balancesApi; + private URI forwardApi; + + private URI identityApi; + private URI oAuthAuthorizationApi; private boolean sandbox; diff --git a/src/main/java/com/checkout/Environment.java b/src/main/java/com/checkout/Environment.java index 48dd74a16..41ac60e47 100644 --- a/src/main/java/com/checkout/Environment.java +++ b/src/main/java/com/checkout/Environment.java @@ -13,12 +13,16 @@ public enum Environment implements IEnvironment { create("https://files.sandbox.checkout.com/"), create("https://transfers.sandbox.checkout.com/"), create("https://balances.sandbox.checkout.com/"), + create("https://forward.sandbox.checkout.com/"), + create("https://identity-verification.sandbox.checkout.com/"), create("https://access.sandbox.checkout.com/connect/token"), true), PRODUCTION(create("https://api.checkout.com/"), create("https://files.checkout.com/"), create("https://transfers.checkout.com/"), create("https://balances.checkout.com/"), + create("https://forward.checkout.com/"), + create("https://identity-verification.checkout.com/"), create("https://access.checkout.com/connect/token"), false); @@ -26,6 +30,8 @@ public enum Environment implements IEnvironment { private final URI filesApi; private final URI transfersApi; private final URI balancesApi; + private final URI forwardApi; + private final URI identityApi; private final URI oAuthAuthorizationApi; private final boolean sandbox; @@ -33,12 +39,16 @@ public enum Environment implements IEnvironment { final URI filesApi, final URI transfersApi, final URI balancesApi, + final URI forwardApi, + final URI identityApi, final URI oAuthAuthorizationApi, final boolean sandbox) { this.checkoutApi = checkoutApi; this.filesApi = filesApi; this.transfersApi = transfersApi; this.balancesApi = balancesApi; + this.forwardApi = forwardApi; + this.identityApi = identityApi; this.oAuthAuthorizationApi = oAuthAuthorizationApi; this.sandbox = sandbox; } diff --git a/src/main/java/com/checkout/EnvironmentSubdomain.java b/src/main/java/com/checkout/EnvironmentSubdomain.java index 918847350..73befb7cd 100644 --- a/src/main/java/com/checkout/EnvironmentSubdomain.java +++ b/src/main/java/com/checkout/EnvironmentSubdomain.java @@ -40,7 +40,7 @@ private static URI createUrlWithSubdomain(URI originalUrl, String subdomain) { throw new CheckoutException(e); } - Pattern pattern = Pattern.compile("^[0-9a-z]+$"); + Pattern pattern = Pattern.compile("^[a-z0-9]+(-[a-z0-9]+)*$"); Matcher matcher = pattern.matcher(subdomain); if (matcher.matches()) { String host = originalUrl.getHost(); diff --git a/src/main/java/com/checkout/IEnvironment.java b/src/main/java/com/checkout/IEnvironment.java index 3241bfe54..7a6b5a229 100644 --- a/src/main/java/com/checkout/IEnvironment.java +++ b/src/main/java/com/checkout/IEnvironment.java @@ -12,6 +12,10 @@ public interface IEnvironment { URI getBalancesApi(); + URI getForwardApi(); + + URI getIdentityApi(); + URI getOAuthAuthorizationApi(); boolean isSandbox(); diff --git a/src/test/java/com/checkout/DefaultCheckoutConfigurationTest.java b/src/test/java/com/checkout/DefaultCheckoutConfigurationTest.java index e66e170a4..b5be1f7d3 100644 --- a/src/test/java/com/checkout/DefaultCheckoutConfigurationTest.java +++ b/src/test/java/com/checkout/DefaultCheckoutConfigurationTest.java @@ -53,7 +53,7 @@ void shouldCreateConfiguration() { } @ParameterizedTest - @ValueSource(strings = {"a", "ab", "abc", "abc1", "12345domain", "a1b2c3d4", "12345678", "abcdefgh", "1234doma"}) + @ValueSource(strings = {"a", "ab", "abc", "abc1", "12345domain", "a1b2c3d4", "12345678", "abcdefgh", "1234doma", "test-123", "pl-abc123", "pl-loquesea", "vkuhvk4v"}) void shouldCreateConfigurationWithSubdomain(String subdomain) { final StaticKeysSdkCredentials credentials = Mockito.mock(StaticKeysSdkCredentials.class); @@ -65,7 +65,7 @@ void shouldCreateConfigurationWithSubdomain(String subdomain) { } @ParameterizedTest - @ValueSource(strings = {"", " ", " ", " - ", "a b", "ab c1"}) + @ValueSource(strings = {"", " ", " ", " - ", "a b", "ab c1", "foo-", "-foo", "ABC123", "FOO"}) void shouldCreateConfigurationWithBadSubdomain(String subdomain) { final StaticKeysSdkCredentials credentials = Mockito.mock(StaticKeysSdkCredentials.class); @@ -88,6 +88,28 @@ void shouldCreateConfigurationWithSubdomainForProduction() { assertEquals("https://" + subdomain + ".access.checkout.com/connect/token", configuration.getEnvironmentSubdomain().getOAuthAuthorizationApi().toString()); } + @Test + void shouldHaveCorrectSandboxUrls() { + + assertEquals(create("https://api.sandbox.checkout.com/"), Environment.SANDBOX.getCheckoutApi()); + assertEquals(create("https://files.sandbox.checkout.com/"), Environment.SANDBOX.getFilesApi()); + assertEquals(create("https://transfers.sandbox.checkout.com/"), Environment.SANDBOX.getTransfersApi()); + assertEquals(create("https://balances.sandbox.checkout.com/"), Environment.SANDBOX.getBalancesApi()); + assertEquals(create("https://forward.sandbox.checkout.com/"), Environment.SANDBOX.getForwardApi()); + assertEquals(create("https://identity-verification.sandbox.checkout.com/"), Environment.SANDBOX.getIdentityApi()); + } + + @Test + void shouldHaveCorrectProductionUrls() { + + assertEquals(create("https://api.checkout.com/"), Environment.PRODUCTION.getCheckoutApi()); + assertEquals(create("https://files.checkout.com/"), Environment.PRODUCTION.getFilesApi()); + assertEquals(create("https://transfers.checkout.com/"), Environment.PRODUCTION.getTransfersApi()); + assertEquals(create("https://balances.checkout.com/"), Environment.PRODUCTION.getBalancesApi()); + assertEquals(create("https://forward.checkout.com/"), Environment.PRODUCTION.getForwardApi()); + assertEquals(create("https://identity-verification.checkout.com/"), Environment.PRODUCTION.getIdentityApi()); + } + @Test void shouldCreateConfiguration_defaultHttpClientBuilderAndExecutor() { @@ -136,6 +158,8 @@ void shouldCreateConfigurationWithCustomEnvironment() { .filesApi(create("https://the.files.uri/")) .transfersApi(create("https://the.transfers.uri/")) .balancesApi(create("https://the.balances.uri/")) + .forwardApi(create("https://the.forward.uri/")) + .identityApi(create("https://the.identity.uri/")) .build(); final StaticKeysSdkCredentials credentials = Mockito.mock(StaticKeysSdkCredentials.class); @@ -147,6 +171,8 @@ void shouldCreateConfigurationWithCustomEnvironment() { assertEquals(environment.getFilesApi(), configuration.getEnvironment().getFilesApi()); assertEquals(environment.getTransfersApi(), configuration.getEnvironment().getTransfersApi()); assertEquals(environment.getBalancesApi(), configuration.getEnvironment().getBalancesApi()); + assertEquals(environment.getForwardApi(), configuration.getEnvironment().getForwardApi()); + assertEquals(environment.getIdentityApi(), configuration.getEnvironment().getIdentityApi()); } /** From 42a2e9f703889584c858cc5b06f64a73dcd04812 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Armando=20Rodr=C3=ADguez?= <127134616+armando-rodriguez-cko@users.noreply.github.com> Date: Mon, 11 May 2026 15:48:37 +0200 Subject: [PATCH 2/5] refactor: reuse identity ApiClient across identity clients Cache the identity ApiClient instance once in the CheckoutApiImpl constructor instead of constructing a new ApiClientImpl (with its own IdentityApiUriStrategy and HTTP client) inside each of the five identity clients. Behaviour unchanged; avoids redundant allocations for the five clients that all target the same identity host. --- src/main/java/com/checkout/CheckoutApiImpl.java | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/src/main/java/com/checkout/CheckoutApiImpl.java b/src/main/java/com/checkout/CheckoutApiImpl.java index 4de3eb905..c4c18c4f3 100644 --- a/src/main/java/com/checkout/CheckoutApiImpl.java +++ b/src/main/java/com/checkout/CheckoutApiImpl.java @@ -141,11 +141,12 @@ public CheckoutApiImpl(final CheckoutConfiguration configuration) { this.googlePayClient = new GooglePayClientImpl(this.apiClient, configuration); this.complianceClient = new ComplianceClientImpl(this.apiClient, configuration); this.forwardClient = new ForwardClientImpl(getForwardClient(configuration), configuration); - this.faceAuthenticationClient = new FaceAuthenticationClientImpl(getIdentityClient(configuration), configuration); - this.applicantClient = new ApplicantClientImpl(getIdentityClient(configuration), configuration); - this.identityVerificationClient = new IdentityVerificationClientImpl(getIdentityClient(configuration), configuration); - this.idDocumentVerificationClient = new IdDocumentVerificationClientImpl(getIdentityClient(configuration), configuration); - this.amlScreeningClient = new AmlScreeningClientImpl(getIdentityClient(configuration), configuration); + final ApiClient identityApiClient = getIdentityClient(configuration); + this.faceAuthenticationClient = new FaceAuthenticationClientImpl(identityApiClient, configuration); + this.applicantClient = new ApplicantClientImpl(identityApiClient, configuration); + this.identityVerificationClient = new IdentityVerificationClientImpl(identityApiClient, configuration); + this.idDocumentVerificationClient = new IdDocumentVerificationClientImpl(identityApiClient, configuration); + this.amlScreeningClient = new AmlScreeningClientImpl(identityApiClient, configuration); this.networkTokensClient = new NetworkTokensClientImpl(this.apiClient, configuration); this.standaloneAccountUpdaterClient = new StandaloneAccountUpdaterClientImpl(this.apiClient, configuration); this.agenticCommerceClient = new AgenticCommerceClientImpl(this.apiClient, configuration); From 1d3d1032698b4d2aa0fe686d500694a254122867 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Armando=20Rodr=C3=ADguez?= <127134616+armando-rodriguez-cko@users.noreply.github.com> Date: Mon, 11 May 2026 16:44:58 +0200 Subject: [PATCH 3/5] fix: tighten subdomain regex to PrivateLink prefix format Per the AWS PrivateLink docs (https://www.checkout.com/docs/developer-resources/api/private-connections/aws-privatelink), the valid subdomain is the first eight characters of the client_id (alphanumeric only), optionally with the literal pl- prefix when calling through PrivateLink. Tighten the regex from RFC-1123-style hyphenated to ^(?:pl-)?[a-z0-9]+$ and update the test corpus: test-123 moves to the rejected list, pl-vkuhvk4v (the docs example) joins the accepted list, and pl-, foo-bar are added as rejected. --- src/main/java/com/checkout/EnvironmentSubdomain.java | 2 +- .../java/com/checkout/DefaultCheckoutConfigurationTest.java | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/main/java/com/checkout/EnvironmentSubdomain.java b/src/main/java/com/checkout/EnvironmentSubdomain.java index 73befb7cd..54841a6ed 100644 --- a/src/main/java/com/checkout/EnvironmentSubdomain.java +++ b/src/main/java/com/checkout/EnvironmentSubdomain.java @@ -40,7 +40,7 @@ private static URI createUrlWithSubdomain(URI originalUrl, String subdomain) { throw new CheckoutException(e); } - Pattern pattern = Pattern.compile("^[a-z0-9]+(-[a-z0-9]+)*$"); + Pattern pattern = Pattern.compile("^(?:pl-)?[a-z0-9]+$"); Matcher matcher = pattern.matcher(subdomain); if (matcher.matches()) { String host = originalUrl.getHost(); diff --git a/src/test/java/com/checkout/DefaultCheckoutConfigurationTest.java b/src/test/java/com/checkout/DefaultCheckoutConfigurationTest.java index b5be1f7d3..e8f9da6e4 100644 --- a/src/test/java/com/checkout/DefaultCheckoutConfigurationTest.java +++ b/src/test/java/com/checkout/DefaultCheckoutConfigurationTest.java @@ -53,7 +53,7 @@ void shouldCreateConfiguration() { } @ParameterizedTest - @ValueSource(strings = {"a", "ab", "abc", "abc1", "12345domain", "a1b2c3d4", "12345678", "abcdefgh", "1234doma", "test-123", "pl-abc123", "pl-loquesea", "vkuhvk4v"}) + @ValueSource(strings = {"a", "ab", "abc", "abc1", "12345domain", "a1b2c3d4", "12345678", "abcdefgh", "1234doma", "pl-abc123", "pl-loquesea", "vkuhvk4v", "pl-vkuhvk4v"}) void shouldCreateConfigurationWithSubdomain(String subdomain) { final StaticKeysSdkCredentials credentials = Mockito.mock(StaticKeysSdkCredentials.class); @@ -65,7 +65,7 @@ void shouldCreateConfigurationWithSubdomain(String subdomain) { } @ParameterizedTest - @ValueSource(strings = {"", " ", " ", " - ", "a b", "ab c1", "foo-", "-foo", "ABC123", "FOO"}) + @ValueSource(strings = {"", " ", " ", " - ", "a b", "ab c1", "foo-", "-foo", "ABC123", "FOO", "test-123", "foo-bar", "pl-"}) void shouldCreateConfigurationWithBadSubdomain(String subdomain) { final StaticKeysSdkCredentials credentials = Mockito.mock(StaticKeysSdkCredentials.class); From 5f419290a4617fa4fbacba4e0b6db84fa65ddb59 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Armando=20Rodr=C3=ADguez?= <127134616+armando-rodriguez-cko@users.noreply.github.com> Date: Tue, 12 May 2026 11:08:58 +0200 Subject: [PATCH 4/5] feat(oauth): add IDENTITY_VERIFICATION scope constant Per the swagger spec, all identity endpoints (applicants, identity-verifications, aml-verifications, face-authentications, id-document-verifications) require the OAuth scope identity-verification. Expose it as a typed enum constant so OAuth clients can request it without hardcoding the string. --- src/main/java/com/checkout/OAuthScope.java | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/main/java/com/checkout/OAuthScope.java b/src/main/java/com/checkout/OAuthScope.java index 3c53ca693..259668811 100644 --- a/src/main/java/com/checkout/OAuthScope.java +++ b/src/main/java/com/checkout/OAuthScope.java @@ -58,7 +58,8 @@ public enum OAuthScope { VAULT_TOKENIZATION("vault:tokenization"), VAULT_NETWORK_TOKENS("vault:network-tokens"), FORWARD("forward"), - FORWARD_SECRETS("forward:secrets"); + FORWARD_SECRETS("forward:secrets"), + IDENTITY_VERIFICATION("identity-verification"); private final String scope; From 56256ee28a2d1b07b2bc8035866963a2187bb390 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Armando=20Rodr=C3=ADguez?= <127134616+armando-rodriguez-cko@users.noreply.github.com> Date: Tue, 30 Jun 2026 15:54:10 +0200 Subject: [PATCH 5/5] test: tolerate PayPal apm_service_unavailable in PaymentContextsTestIT PayPal as an APM is intermittently unavailable in sandbox, returning a 422 'apm_service_unavailable'. Skip the affected payment-context tests in that case instead of failing the build, mirroring the existing Tabby handling. Any other error still fails the test, so SDK regressions are not masked. --- .../contexts/PaymentContextsTestIT.java | 66 ++++++++++++++++--- 1 file changed, 58 insertions(+), 8 deletions(-) diff --git a/src/test/java/com/checkout/payments/contexts/PaymentContextsTestIT.java b/src/test/java/com/checkout/payments/contexts/PaymentContextsTestIT.java index 37e81fd1d..77e2db298 100644 --- a/src/test/java/com/checkout/payments/contexts/PaymentContextsTestIT.java +++ b/src/test/java/com/checkout/payments/contexts/PaymentContextsTestIT.java @@ -1,5 +1,6 @@ package com.checkout.payments.contexts; +import com.checkout.CheckoutApiException; import com.checkout.PlatformType; import com.checkout.SandboxTestFixture; import com.checkout.TestHelper; @@ -7,10 +8,17 @@ import com.checkout.payments.PaymentType; import org.junit.jupiter.api.Disabled; import org.junit.jupiter.api.Test; +import org.opentest4j.AssertionFailedError; + +import java.util.List; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.ExecutionException; +import java.util.function.Supplier; import static com.checkout.payments.AbstractPaymentsTestIT.APM_SERVICE_UNAVAILABLE; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assumptions.abort; class PaymentContextsTestIT extends SandboxTestFixture { @@ -21,8 +29,9 @@ class PaymentContextsTestIT extends SandboxTestFixture { @Test void shouldMakeAPaymentContextPayPalRequest() { final PaymentContextsRequest request = TestHelper.createPaymentContextsPayPalRequest(); - final PaymentContextsRequestResponse response = blocking(() -> checkoutApi.paymentContextsClient().requestPaymentContexts(request, null)); - + final PaymentContextsRequestResponse response = tolerateApmUnavailable( + () -> checkoutApi.paymentContextsClient().requestPaymentContexts(request, null)); + validatePayPalResponse(response); } @@ -52,9 +61,10 @@ void shouldMakeAPaymentContextTabbyRequest() { @Test void shouldGetAPaymentContext() { final PaymentContextsRequest request = TestHelper.createPaymentContextsPayPalRequest(); - final PaymentContextsRequestResponse paymentContextsResponse = blocking(() -> checkoutApi.paymentContextsClient().requestPaymentContexts(request, null)); + final PaymentContextsRequestResponse paymentContextsResponse = tolerateApmUnavailable( + () -> checkoutApi.paymentContextsClient().requestPaymentContexts(request, null)); final PaymentContextDetailsResponse response = blocking(() -> checkoutApi.paymentContextsClient().getPaymentContextDetails(paymentContextsResponse.getId())); - + validatePaymentContextDetails(response); } @@ -62,8 +72,9 @@ void shouldGetAPaymentContext() { @Test void shouldMakeAPaymentContextPayPalRequestSync() { final PaymentContextsRequest request = TestHelper.createPaymentContextsPayPalRequest(); - final PaymentContextsRequestResponse response = checkoutApi.paymentContextsClient().requestPaymentContextsSync(request, null); - + final PaymentContextsRequestResponse response = tolerateApmUnavailableSync( + () -> checkoutApi.paymentContextsClient().requestPaymentContextsSync(request, null)); + validatePayPalResponse(response); } @@ -93,9 +104,10 @@ void shouldMakeAPaymentContextTabbyRequestSync() { @Test void shouldGetAPaymentContextSync() { final PaymentContextsRequest request = TestHelper.createPaymentContextsPayPalRequest(); - final PaymentContextsRequestResponse paymentContextsResponse = checkoutApi.paymentContextsClient().requestPaymentContextsSync(request, null); + final PaymentContextsRequestResponse paymentContextsResponse = tolerateApmUnavailableSync( + () -> checkoutApi.paymentContextsClient().requestPaymentContextsSync(request, null)); final PaymentContextDetailsResponse response = checkoutApi.paymentContextsClient().getPaymentContextDetailsSync(paymentContextsResponse.getId()); - + validatePaymentContextDetails(response); } @@ -134,4 +146,42 @@ private void validatePaymentContextDetails(PaymentContextDetailsResponse respons assertNotNull(response.getPartnerMetadata()); assertNotNull(response.getPartnerMetadata().getOrderId()); } + + // PayPal is an APM that can be temporarily unavailable in sandbox, returning + // a 422 'apm_service_unavailable'. When that happens we skip the test instead of + // failing, since it reflects an environment outage rather than an SDK defect. + private T tolerateApmUnavailable(final Supplier> supplier) { + try { + return supplier.get().get(); + } catch (final InterruptedException | ExecutionException exception) { + if (isApmServiceUnavailable(exception.getCause())) { + abort("PayPal APM unavailable in sandbox (apm_service_unavailable) - skipping"); + } + throw new AssertionFailedError("Unexpected error", exception); + } + } + + private T tolerateApmUnavailableSync(final Supplier supplier) { + try { + return supplier.get(); + } catch (final CheckoutApiException exception) { + if (isApmServiceUnavailable(exception)) { + abort("PayPal APM unavailable in sandbox (apm_service_unavailable) - skipping"); + } + throw exception; + } + } + + @SuppressWarnings("unchecked") + private static boolean isApmServiceUnavailable(final Throwable throwable) { + if (!(throwable instanceof CheckoutApiException)) { + return false; + } + final CheckoutApiException apiException = (CheckoutApiException) throwable; + if (apiException.getErrorDetails() == null) { + return false; + } + final Object errorCodes = apiException.getErrorDetails().get("error_codes"); + return errorCodes instanceof List && ((List) errorCodes).contains(APM_SERVICE_UNAVAILABLE); + } } \ No newline at end of file