See {@code src/integration-test/README.md} for configuration and execution instructions. + */ +@Tag(EXTERNAL) +@Timeout(60) +public class CheckoutCardPaymentIT extends BaseIntegrationTest { + + // Payment request + private static final String PAYMENT_CURRENCY = "EUR"; + private static final long PAYMENT_AMOUNT = 1000L; + private static final String PAYMENT_REFERENCE_PREFIX = "checkout-card-it-"; + private static final String PAYMENT_RETURN_URL = "https://example.com/checkout/return"; + + // Adyen Mastercard test card + private static final String TEST_CARD_NUMBER = "5555444433331111"; + private static final String TEST_CARD_EXPIRY_MONTH = "03"; + private static final String TEST_CARD_EXPIRY_YEAR = "2030"; + private static final String TEST_CARD_CVC = "737"; + private static final String TEST_CARD_HOLDER_NAME = "Checkout Integration Test"; + + // Expected API responses + private static final int HTTP_STATUS_UNPROCESSABLE_ENTITY = 422; + private static final String VALIDATION_ERROR_TYPE = "validation"; + private static final String MISSING_REFERENCE_ERROR_CODE = "130"; + private static final String MISSING_REFERENCE_ERROR_MESSAGE = + "Required field 'reference' is not provided."; + + @BeforeEach + public void verifyTestEnvironment() { + requireTestEnvironment(); + } + + @Test + public void shouldCreateAuthorisedCardPayment() throws ApiException, IOException { + // Given + String idempotencyKey = UUID.randomUUID().toString(); + String merchantReference = PAYMENT_REFERENCE_PREFIX + idempotencyKey; + PaymentRequest request = createBasePaymentRequest().reference(merchantReference); + RequestOptions requestOptions = createRequestOptions(idempotencyKey); + PaymentsApi paymentsApi = new PaymentsApi(getClient()); + + // When + PaymentResponse response = paymentsApi.payments(request, requestOptions); + + // Then + assertAuthorisedPayment(response, merchantReference); + } + + @Test + public void shouldReturnUnprocessableEntityWhenReferenceIsMissing() { + // Given + PaymentRequest requestWithoutReference = createBasePaymentRequest(); + RequestOptions requestOptions = createRequestOptions(UUID.randomUUID().toString()); + PaymentsApi paymentsApi = new PaymentsApi(getClient()); + + // When + ApiException exception = + assertThrows( + ApiException.class, + () -> paymentsApi.payments(requestWithoutReference, requestOptions)); + + // Then + assertMissingReferenceError(exception); + } + + private PaymentRequest createBasePaymentRequest() { + return new PaymentRequest() + .amount(new Amount().currency(PAYMENT_CURRENCY).value(PAYMENT_AMOUNT)) + .merchantAccount(getMerchantAccount()) + .returnUrl(PAYMENT_RETURN_URL) + .paymentMethod(new CheckoutPaymentMethod(createTestCardDetails())); + } + + private static RequestOptions createRequestOptions(String idempotencyKey) { + return new RequestOptions().idempotencyKey(idempotencyKey); + } + + private static CardDetails createTestCardDetails() { + return new CardDetails() + .type(CardDetails.TypeEnum.SCHEME) + .number(TEST_CARD_NUMBER) + .expiryMonth(TEST_CARD_EXPIRY_MONTH) + .expiryYear(TEST_CARD_EXPIRY_YEAR) + .cvc(TEST_CARD_CVC) + .holderName(TEST_CARD_HOLDER_NAME); + } + + private static void assertAuthorisedPayment( + PaymentResponse response, String expectedMerchantReference) { + assertNotNull(response, "The Checkout API response must not be null"); + assertNotNull(response.getPspReference(), "A created payment must have a PSP reference"); + assertFalse(response.getPspReference().isBlank(), "The PSP reference must not be blank"); + assertEquals( + expectedMerchantReference, + response.getMerchantReference(), + "The response must contain the request's merchant reference"); + assertEquals( + PaymentResponse.ResultCodeEnum.AUTHORISED, + response.getResultCode(), + "The test card payment must be authorised"); + } + + private static void assertMissingReferenceError(ApiException exception) { + assertEquals( + HTTP_STATUS_UNPROCESSABLE_ENTITY, + exception.getStatusCode(), + "The API must return HTTP 422"); + + ApiError error = exception.getError(); + assertNotNull(error, "The response body must deserialize to ApiError"); + assertNotNull(error.getStatus(), "ApiError must contain an HTTP status"); + assertEquals( + HTTP_STATUS_UNPROCESSABLE_ENTITY, + error.getStatus(), + "ApiError must contain HTTP status 422"); + assertEquals( + VALIDATION_ERROR_TYPE, + error.getErrorType(), + "The error type must identify a validation error"); + assertEquals( + MISSING_REFERENCE_ERROR_CODE, + error.getErrorCode(), + "The API must return the missing-reference error code"); + assertEquals( + MISSING_REFERENCE_ERROR_MESSAGE, + error.getMessage(), + "The API must explain that reference is required"); + } +} diff --git a/src/integration-test/java/com/adyen/service/clouddevice/CloudDeviceApiTerminalIT.java b/src/integration-test/java/com/adyen/service/clouddevice/CloudDeviceApiTerminalIT.java new file mode 100644 index 000000000..5178e5b8b --- /dev/null +++ b/src/integration-test/java/com/adyen/service/clouddevice/CloudDeviceApiTerminalIT.java @@ -0,0 +1,211 @@ +/* + * Adyen Java API Library + * + * Copyright (c) 2026 Adyen B.V. + * This file is open source and available under the MIT license. + * See the LICENSE file for more info. + */ +package com.adyen.service.clouddevice; + +import static com.adyen.IntegrationTestTags.EXTERNAL; +import static com.adyen.IntegrationTestTags.MANUAL; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import com.adyen.BaseIntegrationTest; +import com.adyen.constants.ApiConstants; +import com.adyen.model.clouddevice.CloudDeviceApiAsyncResponse; +import com.adyen.model.clouddevice.CloudDeviceApiRequest; +import com.adyen.model.clouddevice.CloudDeviceApiResponse; +import com.adyen.model.clouddevice.ConnectedDevicesResponse; +import com.adyen.model.tapi.AmountsReq; +import com.adyen.model.tapi.MessageCategory; +import com.adyen.model.tapi.MessageClass; +import com.adyen.model.tapi.MessageHeader; +import com.adyen.model.tapi.MessageType; +import com.adyen.model.tapi.PaymentRequest; +import com.adyen.model.tapi.PaymentTransaction; +import com.adyen.model.tapi.SaleData; +import com.adyen.model.tapi.SaleToPOIRequest; +import com.adyen.model.tapi.TransactionIDType; +import com.adyen.security.clouddevice.EncryptionCredentialDetails; +import com.adyen.security.clouddevice.NexoSecurityException; +import com.adyen.service.exception.ApiException; +import java.io.IOException; +import java.math.BigDecimal; +import java.time.OffsetDateTime; +import java.time.ZoneOffset; +import java.util.UUID; +import java.util.concurrent.TimeUnit; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.Timeout; + +/** + * Exercises Cloud Device API operations that require an enabled physical terminal. + * + *
See {@code src/integration-test/README.md} for shared conventions and {@code e2e-testing.md} + * for terminal-specific prerequisites. + */ +@Tag(EXTERNAL) +@Tag(MANUAL) +@Timeout(value = 5, unit = TimeUnit.MINUTES) +public class CloudDeviceApiTerminalIT extends BaseIntegrationTest { + + private static final String PAYMENT_CURRENCY = "EUR"; + private static final BigDecimal PAYMENT_AMOUNT = BigDecimal.TEN; + private static final String ASYNC_SUCCESS_RESULT = "ok"; + + @BeforeEach + public void verifyTestEnvironment() { + requireTestEnvironment(); + } + + @Test + public void shouldSendSynchronousPaymentRequest() throws ApiException, IOException { + // Arrange + String terminalDeviceId = getTerminalDeviceId(); + CloudDeviceApiRequest request = createPaymentRequest(terminalDeviceId); + CloudDeviceApi cloudDeviceApi = new CloudDeviceApi(getClient()); + + // Act + CloudDeviceApiResponse response = + cloudDeviceApi.sync(getMerchantAccount(), terminalDeviceId, request); + + // Assert + assertSynchronousResponse(response, terminalDeviceId); + } + + @Test + public void shouldSendAsynchronousPaymentRequest() throws ApiException, IOException { + // Arrange + String terminalDeviceId = getTerminalDeviceId(); + CloudDeviceApiRequest request = createPaymentRequest(terminalDeviceId); + CloudDeviceApi cloudDeviceApi = new CloudDeviceApi(getClient()); + + // Act + CloudDeviceApiAsyncResponse response = + cloudDeviceApi.async(getMerchantAccount(), terminalDeviceId, request); + + // Assert + assertAsynchronousResponseAccepted(response); + } + + @Test + public void shouldSendEncryptedSynchronousPaymentRequest() + throws ApiException, IOException, NexoSecurityException { + // Arrange + String terminalDeviceId = getTerminalDeviceId(); + CloudDeviceApiRequest request = createPaymentRequest(terminalDeviceId); + EncryptedCloudDeviceApi encryptedCloudDeviceApi = createEncryptedCloudDeviceApi(); + + // Act + CloudDeviceApiResponse response = + encryptedCloudDeviceApi.sync(getMerchantAccount(), terminalDeviceId, request); + + // Assert + assertSynchronousResponse(response, terminalDeviceId); + } + + @Test + public void shouldListConnectedDevices() throws ApiException, IOException { + // Arrange + String terminalDeviceId = getTerminalDeviceId(); + CloudDeviceApi cloudDeviceApi = new CloudDeviceApi(getClient()); + + // Act + ConnectedDevicesResponse response = cloudDeviceApi.getConnectedDevices(getMerchantAccount()); + + // Assert + assertNotNull(response, "The Cloud Device API response must not be null"); + assertNotNull(response.getUniqueDeviceIds(), "The response must contain connected device IDs"); + assertTrue( + response.getUniqueDeviceIds().contains(terminalDeviceId), + "The configured terminal must be connected"); + } + + @Test + public void shouldSendEncryptedAsynchronousPaymentRequest() + throws ApiException, IOException, NexoSecurityException { + // Arrange + String terminalDeviceId = getTerminalDeviceId(); + CloudDeviceApiRequest request = createPaymentRequest(terminalDeviceId); + EncryptedCloudDeviceApi encryptedCloudDeviceApi = createEncryptedCloudDeviceApi(); + + // Act + CloudDeviceApiAsyncResponse response = + encryptedCloudDeviceApi.async(getMerchantAccount(), terminalDeviceId, request); + + // Assert + assertAsynchronousResponseAccepted(response); + } + + private EncryptedCloudDeviceApi createEncryptedCloudDeviceApi() throws NexoSecurityException { + EncryptionCredentialDetails encryptionCredentials = + new EncryptionCredentialDetails() + .adyenCryptoVersion(1) + .keyIdentifier(getTerminalDeviceKeyIdentifier()) + .keyVersion(1) + .passphrase(getTerminalDevicePassphrase()); + + return new EncryptedCloudDeviceApi(getClient(), encryptionCredentials); + } + + private static CloudDeviceApiRequest createPaymentRequest(String terminalDeviceId) { + String transactionId = createTransactionId(); + + MessageHeader messageHeader = + new MessageHeader() + .protocolVersion(ApiConstants.TerminalAPI.PROTOCOL_VERSION) + .messageClass(MessageClass.SERVICE) + .messageCategory(MessageCategory.PAYMENT) + .messageType(MessageType.REQUEST) + .saleID(transactionId) + .serviceID(transactionId) + .POIID(terminalDeviceId); + + TransactionIDType transactionIdentification = + new TransactionIDType() + .transactionID(transactionId) + .timeStamp(OffsetDateTime.now(ZoneOffset.UTC)); + SaleData saleData = new SaleData().saleTransactionID(transactionIdentification); + AmountsReq amounts = + new AmountsReq().currency(PAYMENT_CURRENCY).requestedAmount(PAYMENT_AMOUNT); + PaymentTransaction paymentTransaction = new PaymentTransaction().amountsReq(amounts); + PaymentRequest paymentRequest = + new PaymentRequest().saleData(saleData).paymentTransaction(paymentTransaction); + SaleToPOIRequest saleToPOIRequest = + new SaleToPOIRequest().messageHeader(messageHeader).paymentRequest(paymentRequest); + + CloudDeviceApiRequest request = new CloudDeviceApiRequest(); + request.setSaleToPOIRequest(saleToPOIRequest); + return request; + } + + private static String createTransactionId() { + return UUID.randomUUID().toString().replace("-", "").substring(0, 10); + } + + private static void assertSynchronousResponse( + CloudDeviceApiResponse response, String expectedTerminalDeviceId) { + assertNotNull(response, "The Cloud Device API response must not be null"); + assertNotNull(response.getSaleToPOIResponse(), "The response must contain SaleToPOIResponse"); + assertNotNull( + response.getSaleToPOIResponse().getMessageHeader(), + "SaleToPOIResponse must contain a message header"); + assertEquals( + expectedTerminalDeviceId, + response.getSaleToPOIResponse().getMessageHeader().getPOIID(), + "The response must identify the configured terminal"); + } + + private static void assertAsynchronousResponseAccepted(CloudDeviceApiAsyncResponse response) { + assertNotNull(response, "The Cloud Device API response must not be null"); + assertEquals( + ASYNC_SUCCESS_RESULT, + response.getResult(), + "The Cloud Device API must accept the asynchronous request"); + } +} diff --git a/src/integration-test/java/com/adyen/service/clouddevice/e2e-testing.md b/src/integration-test/java/com/adyen/service/clouddevice/e2e-testing.md new file mode 100644 index 000000000..e9772bfb0 --- /dev/null +++ b/src/integration-test/java/com/adyen/service/clouddevice/e2e-testing.md @@ -0,0 +1,33 @@ +# Cloud Device API terminal tests + +These manual integration tests send requests to a physical payment terminal through the Cloud +Device API. + +## Prerequisites + +- The terminal is enabled, online, and associated with the configured merchant account. +- The terminal is available for the duration of the selected test. +- Encrypted tests have valid local encryption configuration. + +## Running a test + +Run exactly one test with the manual integration-test profile: + +```bash +mvn verify -Pmanual-integration-tests -Dgpg.skip=true \ + -Dit.test=CloudDeviceApiTerminalIT#shouldSendSynchronousPaymentRequest +``` + +No source-code changes or `@Disabled` toggles are required. + +> Run one terminal test at a time. A terminal can handle only one active request at a time. + +## Available tests + +| Test | Description | +|---|---| +| `shouldSendSynchronousPaymentRequest` | Sends a payment request and waits for the terminal response | +| `shouldSendAsynchronousPaymentRequest` | Sends a payment request for background terminal processing | +| `shouldSendEncryptedSynchronousPaymentRequest` | Sends a synchronous request with NexoSEC encryption | +| `shouldSendEncryptedAsynchronousPaymentRequest` | Sends an asynchronous request with NexoSEC encryption | +| `shouldListConnectedDevices` | Checks that the configured terminal is connected | diff --git a/src/integration-test/resources/config.properties.example b/src/integration-test/resources/config.properties.example new file mode 100644 index 000000000..f7b9936ea --- /dev/null +++ b/src/integration-test/resources/config.properties.example @@ -0,0 +1,12 @@ +# Select TEST or LIVE. Use TEST for tests that rely on Adyen test credentials or test cards. +Adyen_Environment=TEST + +ADYEN_API_KEY= +ADYEN_MERCHANT_ACCOUNT= + +# Required by services that use an account-specific LIVE endpoint, including Checkout. +ADYEN_LIVE_ENDPOINT_URL_PREFIX= + +ADYEN_TERMINAL_DEVICE_ID= +ADYEN_TERMINAL_DEVICE_KEY_IDENTIFIER= +ADYEN_TERMINAL_DEVICE_PASSPHRASE= diff --git a/src/test/java/com/adyen/BaseIntegrationTest.java b/src/test/java/com/adyen/BaseIntegrationTest.java deleted file mode 100644 index 1ef1d706f..000000000 --- a/src/test/java/com/adyen/BaseIntegrationTest.java +++ /dev/null @@ -1,81 +0,0 @@ -/* - * Adyen Java API Library - * - * Copyright (c) 2025 Adyen B.V. - * This file is open source and available under the MIT license. - * See the LICENSE file for more info. - */ -package com.adyen; - -import com.adyen.enums.Environment; -import java.io.IOException; -import java.io.InputStream; -import java.util.Properties; - -/** - * Base class for Integration tests - * - *
Define in src/test/resources the configuration for the tests - * - *
``` ADYEN_API_KEY= ADYEN_MERCHANT_ACCOUNT= ADYEN_TERMINAL_DEVICE_ID= - * ADYEN_TERMINAL_DEVICE_KEY_IDENTIFIER= ADYEN_TERMINAL_DEVICE_PASSPHRASE= ``` - */ -public class BaseIntegrationTest { - - private static Properties properties = null; - - protected Client getClient() { - return new Client(new Config().apiKey(getApiKey()).environment(Environment.TEST)); - } - - protected String getApiKey() { - return getProperty("ADYEN_API_KEY"); - } - - protected String getMerchantAccount() { - return getProperty("ADYEN_MERCHANT_ACCOUNT"); - } - - protected String getTerminalDeviceId() { - return getProperty("ADYEN_TERMINAL_DEVICE_ID"); - } - - protected String getTerminalDeviceKeyIdentifier() { - return getProperty("ADYEN_TERMINAL_DEVICE_KEY_IDENTIFIER"); - } - - protected String getTerminalDevicePassphrase() { - return getProperty("ADYEN_TERMINAL_DEVICE_PASSPHRASE"); - } - - private Properties getProperties() { - if (properties == null) { - properties = new Properties(); - try (InputStream inputStream = - BaseIntegrationTest.class.getClassLoader().getResourceAsStream("config.properties")) { - if (inputStream != null) { - properties.load(inputStream); - } - } catch (IOException e) { - // Do nothing, properties will be empty - } - } - - return properties; - } - - private String getProperty(String name) { - String property = System.getenv(name); - - if (property != null && !property.isEmpty()) { - return property; - } - property = getProperties().getProperty(name); - - if (property == null || property.isEmpty()) { - throw new RuntimeException("Property " + name + " not defined"); - } - - return property; - } -} diff --git a/src/test/java/com/adyen/service/clouddevice/CloudDeviceApiTerminalIT.java b/src/test/java/com/adyen/service/clouddevice/CloudDeviceApiTerminalIT.java deleted file mode 100644 index 979e5cdb7..000000000 --- a/src/test/java/com/adyen/service/clouddevice/CloudDeviceApiTerminalIT.java +++ /dev/null @@ -1,183 +0,0 @@ -package com.adyen.service.clouddevice; - -import com.adyen.BaseIntegrationTest; -import com.adyen.model.clouddevice.*; -import com.adyen.model.tapi.*; -import com.adyen.security.clouddevice.EncryptionCredentialDetails; -import java.math.BigDecimal; -import java.time.OffsetDateTime; -import java.time.ZoneOffset; -import java.util.UUID; -import org.junit.jupiter.api.Assertions; -import org.junit.jupiter.api.Disabled; -import org.junit.jupiter.api.Test; - -/** - * Verify Terminal integration: tests to send API requests to the Cloud Device API and test the - * Terminal responds as expected. - * - *
Don't forget to: - * - *
{@code
- * # Example of config.properties
- * ADYEN_API_KEY=
- * ADYEN_MERCHANT_ACCOUNT=MyMerchantAccount
- * ADYEN_TERMINAL_DEVICE_ID=V400m-1234567890
- * ADYEN_TERMINAL_DEVICE_KEY_IDENTIFIER=
- * ADYEN_TERMINAL_DEVICE_PASSPHRASE=
- * }
- *
- *