diff --git a/README.md b/README.md index 12a43ffee..ad37ac32e 100644 --- a/README.md +++ b/README.md @@ -436,6 +436,19 @@ These include commented code, highlighting key features and concepts, and exampl ## Feedback We value your input! Help us enhance our API Libraries and improve the integration experience by providing your feedback. Please take a moment to fill out [our feedback form](https://forms.gle/A4EERrR6CWgKWe5r9) to share your thoughts, suggestions or ideas. +## Integration testing + +External integration tests are opt-in and use Maven Failsafe profiles. Run the automated suite +with: + +```bash +mvn verify -Pintegration-tests -Dgpg.skip=true +``` + +See the [integration-test guide](src/integration-test/README.md) for local configuration, +class-and-method selection, manual terminal tests, TEST/LIVE safety, conventions, and +troubleshooting. + ## Contributing We encourage you to contribute to this repository, so everyone can benefit from new features, bug fixes, and any other improvements. diff --git a/pom.xml b/pom.xml index c908fa1f0..ddad27fa1 100644 --- a/pom.xml +++ b/pom.xml @@ -62,7 +62,8 @@ 3.6.0 5.1.9 3.8.0 - 3.5.6 + 3.5.6 + 3.6.1 1.34.1 3.6.3 @@ -72,6 +73,48 @@ git@github.com:Adyen/adyen-java-api-library.git + + + + org.codehaus.mojo + build-helper-maven-plugin + ${build-helper-maven-plugin-version} + + + add-integration-test-sources + generate-test-sources + + add-test-source + add-test-resource + + + + src/integration-test/java + + + + src/integration-test/resources + + + + + + + + org.apache.maven.plugins + maven-failsafe-plugin + ${maven-test-plugins-version} + + + + integration-test + verify + + + + + + org.apache.maven.plugins @@ -222,6 +265,7 @@ src/main/java/**/*.java src/test/java/**/*.java + src/integration-test/java/**/*.java ${google-java-format-version} @@ -238,7 +282,7 @@ org.apache.maven.plugins maven-surefire-plugin - ${maven-surefire-plugin-version} + ${maven-test-plugins-version} org.apache.maven.plugins @@ -290,6 +334,45 @@ + + + integration-tests + + + + org.codehaus.mojo + build-helper-maven-plugin + + + org.apache.maven.plugins + maven-failsafe-plugin + + external + manual + + + + + + + manual-integration-tests + + + + org.codehaus.mojo + build-helper-maven-plugin + + + org.apache.maven.plugins + maven-failsafe-plugin + + manual + + + + + + diff --git a/src/integration-test/AGENTS.md b/src/integration-test/AGENTS.md new file mode 100644 index 000000000..53c5d9fd7 --- /dev/null +++ b/src/integration-test/AGENTS.md @@ -0,0 +1,59 @@ + +## Scope + +These instructions apply to files under `src/integration-test`. + +## Before Writing a Test + +- Read `src/integration-test/README.md`. +- Verify the public service method and model types in `src/main/java`. +- Do not edit generated production models or services to make an integration test pass. +- Determine whether the test is automated external coverage or requires manual infrastructure. + +## Structure + +- Mirror the production package under `src/integration-test/java`. +- Name integration-test classes `*IT` so Maven Failsafe discovers them. +- Extend `BaseIntegrationTest` and use its typed configuration accessors. +- Annotate external tests with `@Tag(IntegrationTestTags.EXTERNAL)`. +- Also annotate tests requiring a person, terminal, or other dedicated infrastructure with + `@Tag(IntegrationTestTags.MANUAL)`. +- Use behavior-focused names such as `shouldReturnValidationErrorWhenReferenceIsMissing`. +- Keep one observable behavior per test and use Arrange, Act, Assert sections. +- Prefer explicit imports, response types, and checked exceptions. Do not use wildcard imports, + `var`, or `throws Exception`. + +## Reliability and Safety + +- Generate unique references and idempotency keys for requests that create remote state. +- Do not share mutable state or depend on test execution order. +- Clean up remotely created resources when the API supports cleanup. +- For eventually consistent APIs, use bounded polling rather than fixed sleeps. +- Add a suitable timeout when an operation can otherwise wait indefinitely. +- Tests containing Adyen test cards or other TEST-only data must call `requireTestEnvironment()` in + `@BeforeEach`. +- Do not use `@Disabled` as the normal opt-in mechanism. Use the Maven profiles and JUnit tags. +- Do not run an external integration test unless the user explicitly asks for that API call. + +## Assertions and Comments + +- Assert stable contract fields, identifiers, statuses, and documented error codes. +- Assert exact error messages only when the wording is part of the documented contract. +- Include assertion messages that explain the violated contract. +- Extract repeated request construction and response assertions into focused helpers. +- Keep comments limited to prerequisites, non-obvious API constraints, and Arrange, Act, Assert + markers. Do not narrate straightforward Java. + +## Validation + +Validate generated tests without contacting Adyen: + +```bash +mvn spotless:apply +mvn -Pintegration-tests -DskipTests test-compile +mvn spotless:check checkstyle:check -DskipTests +``` + +Use the opt-in execution commands from `src/integration-test/README.md` only when external execution +is explicitly requested. + diff --git a/src/integration-test/README.md b/src/integration-test/README.md new file mode 100644 index 000000000..49b540c79 --- /dev/null +++ b/src/integration-test/README.md @@ -0,0 +1,242 @@ +# Integration tests + +Integration tests exercise the public Java API library against an Adyen endpoint. Maven Failsafe +discovers classes ending in `IT`, and opt-in profiles keep external calls out of the default build. + +## Quick start + +From the repository root: + +```bash +cp src/integration-test/resources/config.properties.example \ + src/integration-test/resources/config.properties + +mvn verify -Pintegration-tests -Dgpg.skip=true +``` + +The first command creates the ignored local configuration. Complete the values needed by the tests +you plan to run. + +## Test profiles + +| Profile | Included tests | Purpose | +|---|---|---| +| none | No integration tests | Normal build and unit tests | +| `integration-tests` | Tagged `external`, excluding `manual` | Automated tests that call Adyen | +| `manual-integration-tests` | Tagged `manual` | Tests requiring a terminal, person, or dedicated infrastructure | + +The default lifecycle is safe from integration-test execution: + +```bash +mvn verify +``` + +## Running automated integration tests + +Run all automated integration tests: + +```bash +mvn verify -Pintegration-tests -Dgpg.skip=true +``` + +Run one class: + +```bash +mvn verify -Pintegration-tests -Dgpg.skip=true \ + -Dit.test=CheckoutCardPaymentIT +``` + +Run one method: + +```bash +mvn verify -Pintegration-tests -Dgpg.skip=true \ + -Dit.test=CheckoutCardPaymentIT#shouldCreateAuthorisedCardPayment +``` + +Run multiple classes: + +```bash +mvn verify -Pintegration-tests -Dgpg.skip=true \ + -Dit.test=FirstIntegrationIT,SecondIntegrationIT +``` + +`-Dit.test` is the Failsafe selector. Do not use Surefire's `-Dtest` selector for these tests. + +## Running manual integration tests + +Always select one manual test at a time unless its service-specific documentation explicitly +permits concurrency: + +```bash +mvn verify -Pmanual-integration-tests -Dgpg.skip=true \ + -Dit.test=CloudDeviceApiTerminalIT#shouldListConnectedDevices +``` + +Cloud Device terminal prerequisites and available operations are documented in +[`java/com/adyen/service/clouddevice/e2e-testing.md`](java/com/adyen/service/clouddevice/e2e-testing.md). +No `@Disabled` annotation needs to be removed. + +## Local configuration + +The ignored local file is: + +```text +src/integration-test/resources/config.properties +``` + +Start from +[`config.properties.example`](resources/config.properties.example). Environment variables take +precedence over values in the properties file. + +Select the client environment with: + +```properties +Adyen_Environment=TEST +``` + +For a test designed to support LIVE: + +```properties +Adyen_Environment=LIVE +ADYEN_LIVE_ENDPOINT_URL_PREFIX=your-live-prefix +``` + +The existing Checkout card and Cloud Device payment tests contain TEST-only data and call +`requireTestEnvironment()`. They fail before making a request when configured for LIVE. + +## Current coverage + +### Automated Checkout tests + +| Test | Behavior | +|---|---| +| `CheckoutCardPaymentIT#shouldCreateAuthorisedCardPayment` | Creates an authorised Checkout v72 test-card payment | +| `CheckoutCardPaymentIT#shouldReturnUnprocessableEntityWhenReferenceIsMissing` | Verifies the parsed HTTP 422 validation error | + +### Manual Cloud Device tests + +| Test | Behavior | +|---|---| +| `CloudDeviceApiTerminalIT#shouldSendSynchronousPaymentRequest` | Sends a payment and waits for the terminal response | +| `CloudDeviceApiTerminalIT#shouldSendAsynchronousPaymentRequest` | Submits a payment for background terminal processing | +| `CloudDeviceApiTerminalIT#shouldSendEncryptedSynchronousPaymentRequest` | Sends a synchronous request with NexoSEC encryption | +| `CloudDeviceApiTerminalIT#shouldSendEncryptedAsynchronousPaymentRequest` | Sends an asynchronous request with NexoSEC encryption | +| `CloudDeviceApiTerminalIT#shouldListConnectedDevices` | Checks that the configured terminal is connected | + +## Validation without external calls + +Format and compile integration-test sources without executing them: + +```bash +mvn spotless:apply +mvn -Pintegration-tests -DskipTests test-compile +mvn spotless:check checkstyle:check -DskipTests +``` + +`-DskipTests` is required for offline validation. Do not run an integration-test profile without it +unless the external API calls are intentional. + +Failsafe writes execution reports to: + +```text +target/failsafe-reports/ +``` + +## Project layout + +```text +src/integration-test/ +├── AGENTS.md +├── README.md +├── java/com/adyen/ +│ ├── BaseIntegrationTest.java +│ ├── IntegrationTestTags.java +│ └── service//*IT.java +└── resources/ + ├── config.properties.example + └── config.properties +``` + +Packages mirror production packages under `src/main/java`. + +## Conventions for new tests + +1. Name classes `*IT` and methods with behavior-focused `should...When...` names. +2. Extend `BaseIntegrationTest`. +3. Tag external classes with `external`; add `manual` when dedicated infrastructure is required. +4. Keep one observable behavior per test and use Arrange, Act, Assert sections. +5. Generate unique references and idempotency keys for requests that create remote state. +6. Use explicit imports, response types, and checked exceptions. +7. Assert stable response fields and documented error codes. +8. Extract repeated request construction and contract assertions into focused private helpers. +9. Keep tests independent and clean up remote resources where supported. +10. Use bounded polling and suitable timeouts instead of fixed sleeps or unbounded waits. +11. Call `requireTestEnvironment()` in `@BeforeEach` when a test contains TEST-only data. +12. Never execute external tests during routine agent validation. + +More specific agent instructions are in [`AGENTS.md`](AGENTS.md). + +## Minimal template + +```java +package com.adyen.service.example; + +import static com.adyen.IntegrationTestTags.EXTERNAL; +import static org.junit.jupiter.api.Assertions.assertNotNull; + +import com.adyen.BaseIntegrationTest; +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.Test; + +@Tag(EXTERNAL) +public class ExampleOperationIT extends BaseIntegrationTest { + + @Test + public void shouldReturnExpectedResultWhenRequestIsValid() throws ApiException, IOException { + // Arrange + ExampleRequest request = createRequest(); + ExampleApi exampleApi = new ExampleApi(getClient()); + + // Act + ExampleResponse response = exampleApi.exampleOperation(request); + + // Assert + assertNotNull(response, "The API response must not be null"); + } +} +``` + +Add the concrete model, service, and exception imports required by the API under test. + +## Troubleshooting + +### No integration tests were discovered + +- Activate the correct profile. +- Confirm the class name ends in `IT`. +- Confirm automated tests use the `external` tag and manual tests use the `manual` tag. +- Use `-Dit.test`, not `-Dtest`. + +### A required property is not defined + +Confirm `src/integration-test/resources/config.properties` exists and contains the local values +required by the selected test. Environment-variable values override the file. + +### Checkout returns HTTP 403 with error code `010` + +The API credential or merchant account is not allowed to perform the operation. This is an account +permission or merchant-access issue rather than a test compilation problem. + +### A TEST-only test rejects LIVE + +Set `Adyen_Environment=TEST`. Tests guarded by `requireTestEnvironment()` intentionally cannot run +against LIVE. + +### A LIVE endpoint prefix is missing + +Set `ADYEN_LIVE_ENDPOINT_URL_PREFIX` for APIs that use an account-specific LIVE hostname. + +### A terminal test times out + +Confirm the terminal is online, connected to the configured account, and not processing another +request. Terminal tests have a five-minute timeout and must run sequentially. diff --git a/src/integration-test/java/com/adyen/BaseIntegrationTest.java b/src/integration-test/java/com/adyen/BaseIntegrationTest.java new file mode 100644 index 000000000..1b9fbd19b --- /dev/null +++ b/src/integration-test/java/com/adyen/BaseIntegrationTest.java @@ -0,0 +1,109 @@ +/* + * 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.Locale; +import java.util.Properties; + +/** Shared client and local configuration access for integration tests. */ +public abstract class BaseIntegrationTest { + + private static final String CONFIGURATION_RESOURCE = "config.properties"; + private static final String ENVIRONMENT_PROPERTY = "Adyen_Environment"; + private static final String LIVE_ENDPOINT_URL_PREFIX_PROPERTY = "ADYEN_LIVE_ENDPOINT_URL_PREFIX"; + private static final Properties PROPERTIES = loadProperties(); + + protected final Client getClient() { + Environment environment = getEnvironment(); + Config config = new Config().apiKey(getApiKey()).environment(environment); + + String liveEndpointUrlPrefix = getOptionalProperty(LIVE_ENDPOINT_URL_PREFIX_PROPERTY); + if (environment == Environment.LIVE && liveEndpointUrlPrefix != null) { + config.liveEndpointUrlPrefix(liveEndpointUrlPrefix); + } + + return new Client(config); + } + + protected final Environment getEnvironment() { + Environment adyenEnvironment = Environment.valueOf("TEST"); // set TEST as default + + try { + // override if needed + String configuredEnvironment = getProperty(ENVIRONMENT_PROPERTY); + adyenEnvironment = Environment.valueOf(configuredEnvironment.trim().toUpperCase(Locale.ROOT)); + } catch (IllegalStateException exception) { + // use default + } + + return adyenEnvironment; + } + + protected final void requireTestEnvironment() { + if (getEnvironment() != Environment.TEST) { + throw new IllegalStateException( + getClass().getSimpleName() + " uses TEST-only data and cannot run against LIVE"); + } + } + + protected final String getApiKey() { + return getProperty("ADYEN_API_KEY"); + } + + protected final String getMerchantAccount() { + return getProperty("ADYEN_MERCHANT_ACCOUNT"); + } + + protected final String getTerminalDeviceId() { + return getProperty("ADYEN_TERMINAL_DEVICE_ID"); + } + + protected final String getTerminalDeviceKeyIdentifier() { + return getProperty("ADYEN_TERMINAL_DEVICE_KEY_IDENTIFIER"); + } + + protected final String getTerminalDevicePassphrase() { + return getProperty("ADYEN_TERMINAL_DEVICE_PASSPHRASE"); + } + + private static Properties loadProperties() { + Properties properties = new Properties(); + try (InputStream inputStream = + BaseIntegrationTest.class.getClassLoader().getResourceAsStream(CONFIGURATION_RESOURCE)) { + if (inputStream != null) { + properties.load(inputStream); + } + } catch (IOException exception) { + throw new IllegalStateException( + "Unable to load integration-test configuration from " + CONFIGURATION_RESOURCE, + exception); + } + return properties; + } + + private static String getProperty(String name) { + String property = getOptionalProperty(name); + + if (property == null) { + throw new IllegalStateException("Integration-test property " + name + " is not defined"); + } + + return property; + } + + private static String getOptionalProperty(String name) { + String property = System.getenv(name); + if (property == null || property.isBlank()) { + property = PROPERTIES.getProperty(name); + } + return property == null || property.isBlank() ? null : property; + } +} diff --git a/src/integration-test/java/com/adyen/IntegrationTestTags.java b/src/integration-test/java/com/adyen/IntegrationTestTags.java new file mode 100644 index 000000000..73fd6c419 --- /dev/null +++ b/src/integration-test/java/com/adyen/IntegrationTestTags.java @@ -0,0 +1,17 @@ +/* + * 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; + +/** JUnit tags shared by integration tests and Maven profile filters. */ +public final class IntegrationTestTags { + + public static final String EXTERNAL = "external"; + public static final String MANUAL = "manual"; + + private IntegrationTestTags() {} +} diff --git a/src/integration-test/java/com/adyen/service/checkout/CheckoutCardPaymentIT.java b/src/integration-test/java/com/adyen/service/checkout/CheckoutCardPaymentIT.java new file mode 100644 index 000000000..9da908048 --- /dev/null +++ b/src/integration-test/java/com/adyen/service/checkout/CheckoutCardPaymentIT.java @@ -0,0 +1,163 @@ +/* + * 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.checkout; + +import static com.adyen.IntegrationTestTags.EXTERNAL; +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.assertThrows; + +import com.adyen.BaseIntegrationTest; +import com.adyen.model.ApiError; +import com.adyen.model.RequestOptions; +import com.adyen.model.checkout.Amount; +import com.adyen.model.checkout.CardDetails; +import com.adyen.model.checkout.CheckoutPaymentMethod; +import com.adyen.model.checkout.PaymentRequest; +import com.adyen.model.checkout.PaymentResponse; +import com.adyen.service.exception.ApiException; +import java.io.IOException; +import java.util.UUID; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.Timeout; + +/** + * Example integration test that creates a card payment through Checkout API v72 in the Adyen TEST + * environment. + * + *

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: - * - *

    - *
  • Enable the terminal - *
  • Enable the test to run (by removing/commenting the {@code @Disabled} annotation) - *
  • Set required variables by creating {@code src/test/resources/config.properties}: - *
- * - *
{@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=
- * }
- * - *
    - *
  • Run one test at a time with {@code mvn test -Dtest=CloudDeviceApiTerminalIT#sendSync} - *
  • Disable the test again - *
- */ -public class CloudDeviceApiTerminalIT extends BaseIntegrationTest { - - @Disabled("Enable when you want to test with the Terminal") - @Test - public void sendSync() throws Exception { - - CloudDeviceApi cloudDeviceApi = new CloudDeviceApi(getClient()); - - CloudDeviceApiRequest cloudDeviceApiRequest = - createCloudDeviceAPIPaymentRequest(getTerminalDeviceId()); - - var response = - cloudDeviceApi.sync(getMerchantAccount(), getTerminalDeviceId(), cloudDeviceApiRequest); - - Assertions.assertNotNull(response); - Assertions.assertNotNull(response.getSaleToPOIResponse()); - Assertions.assertEquals( - getTerminalDeviceId(), response.getSaleToPOIResponse().getMessageHeader().getPOIID()); - } - - @Disabled("Enable when you want to test with the Terminal") - @Test - public void sendAsync() throws Exception { - - CloudDeviceApi cloudDeviceApi = new CloudDeviceApi(getClient()); - - CloudDeviceApiRequest cloudDeviceApiRequest = - createCloudDeviceAPIPaymentRequest(getTerminalDeviceId()); - - var response = - cloudDeviceApi.async(getMerchantAccount(), getTerminalDeviceId(), cloudDeviceApiRequest); - - Assertions.assertNotNull(response); - Assertions.assertEquals("ok", response.getResult()); - } - - @Disabled("Enable when you want to test with the Terminal") - @Test - public void sendEncryptedSync() throws Exception { - - CloudDeviceApiRequest cloudDeviceApiRequest = - createCloudDeviceAPIPaymentRequest(getTerminalDeviceId()); - - EncryptionCredentialDetails encryptionCredentialDetails = - new EncryptionCredentialDetails() - .adyenCryptoVersion(1) - .keyIdentifier(getTerminalDeviceKeyIdentifier()) - .keyVersion(1) - .passphrase(getTerminalDevicePassphrase()); - - EncryptedCloudDeviceApi encryptedCloudDeviceApi = - new EncryptedCloudDeviceApi(getClient(), encryptionCredentialDetails); - - var response = - encryptedCloudDeviceApi.sync( - getMerchantAccount(), getTerminalDeviceId(), cloudDeviceApiRequest); - - Assertions.assertNotNull(response); - Assertions.assertNotNull(response.getSaleToPOIResponse()); - Assertions.assertEquals( - getTerminalDeviceId(), response.getSaleToPOIResponse().getMessageHeader().getPOIID()); - } - - @Disabled("Enable when you want to test with the Terminal") - @Test - public void getConnectedDevices() throws Exception { - - CloudDeviceApi cloudDeviceApi = new CloudDeviceApi(getClient()); - - var response = cloudDeviceApi.getConnectedDevices(getMerchantAccount()); - - Assertions.assertNotNull(response); - Assertions.assertNotNull(response.getUniqueDeviceIds()); - Assertions.assertTrue(response.getUniqueDeviceIds().contains(getTerminalDeviceId())); - } - - @Disabled("Enable when you want to test with the Terminal") - @Test - public void sendEncryptedAsync() throws Exception { - - CloudDeviceApiRequest cloudDeviceApiRequest = - createCloudDeviceAPIPaymentRequest(getTerminalDeviceId()); - - EncryptionCredentialDetails encryptionCredentialDetails = - new EncryptionCredentialDetails() - .adyenCryptoVersion(1) - .keyIdentifier(getTerminalDeviceKeyIdentifier()) - .keyVersion(1) - .passphrase(getTerminalDevicePassphrase()); - - EncryptedCloudDeviceApi encryptedCloudDeviceApi = - new EncryptedCloudDeviceApi(getClient(), encryptionCredentialDetails); - - var response = - encryptedCloudDeviceApi.async( - getMerchantAccount(), getTerminalDeviceId(), cloudDeviceApiRequest); - - Assertions.assertNotNull(response); - Assertions.assertEquals("ok", response.getResult()); - } - - protected CloudDeviceApiRequest createCloudDeviceAPIPaymentRequest(String deviceId) { - SaleToPOIRequest saleToPOIRequest = new SaleToPOIRequest(); - - var randomId = UUID.randomUUID().toString().replaceAll("-", "").substring(0, 10); - - MessageHeader messageHeader = new MessageHeader(); - messageHeader.setProtocolVersion("3.0"); - messageHeader.setMessageClass(MessageClass.SERVICE); - messageHeader.setMessageCategory(MessageCategory.PAYMENT); - messageHeader.setMessageType(MessageType.REQUEST); - messageHeader.setSaleID(randomId); - messageHeader.setServiceID(randomId); - messageHeader.setPOIID(deviceId); - - saleToPOIRequest.setMessageHeader(messageHeader); - - PaymentRequest paymentRequest = new PaymentRequest(); - - SaleData saleData = new SaleData(); - TransactionIDType transactionIdentification = new TransactionIDType(); - transactionIdentification.setTransactionID(randomId); - OffsetDateTime timestamp = OffsetDateTime.now(ZoneOffset.UTC); - transactionIdentification.setTimeStamp(timestamp); - saleData.setSaleTransactionID(transactionIdentification); - - PaymentTransaction paymentTransaction = new PaymentTransaction(); - AmountsReq amountsReq = new AmountsReq(); - amountsReq.setCurrency("EUR"); - amountsReq.setRequestedAmount(BigDecimal.TEN); - paymentTransaction.setAmountsReq(amountsReq); - - paymentRequest.setSaleData(saleData); - paymentRequest.setPaymentTransaction(paymentTransaction); - - saleToPOIRequest.setPaymentRequest(paymentRequest); - - CloudDeviceApiRequest cloudDeviceApiRequest = new CloudDeviceApiRequest(); - cloudDeviceApiRequest.setSaleToPOIRequest(saleToPOIRequest); - - return cloudDeviceApiRequest; - } -} diff --git a/src/test/java/com/adyen/service/clouddevice/e2e-testing.md b/src/test/java/com/adyen/service/clouddevice/e2e-testing.md deleted file mode 100644 index 8ce3a8637..000000000 --- a/src/test/java/com/adyen/service/clouddevice/e2e-testing.md +++ /dev/null @@ -1,44 +0,0 @@ -# E2E Testing with a POS Terminal - -`CloudDeviceApiTerminalIT` contains end-to-end tests that send real requests to a Cloud Device API terminal. All tests are disabled by default and must be enabled manually before running. - -## Prerequisites - -- A physical POS terminal connected and switched on -- An Adyen test account with the Cloud Device API enabled -- Terminal encryption credentials (key identifier and passphrase) for testing the encryption of payloads - -## Configuration - -Create `src/test/resources/config.properties` (excluded from version control): - -```properties -ADYEN_API_KEY= -ADYEN_MERCHANT_ACCOUNT=MyMerchantAccount -ADYEN_TERMINAL_DEVICE_ID=V400m-1234567890 -ADYEN_TERMINAL_DEVICE_KEY_IDENTIFIER= -ADYEN_TERMINAL_DEVICE_PASSPHRASE= -``` - -Alternatively, export the same keys as environment variables. - -## Running a test - -1. Remove the `@Disabled` annotation from the test you want to run. -2. Execute it individually: - ```bash - mvn test -Dtest=CloudDeviceApiTerminalIT#sendSync - ``` -3. Re-add the `@Disabled` annotation when done. - -> Run one test at a time. The terminal can only handle one active request at a time. - -## Available tests - -| Test | Description | -|---|---| -| `sendSync` | Sends a payment request and waits for the terminal response | -| `sendAsync` | Sends a payment request asynchronously (terminal processes in background) | -| `sendEncryptedSync` | Same as `sendSync` with end-to-end NexoSEC encryption | -| `sendEncryptedAsync` | Same as `sendAsync` with end-to-end NexoSEC encryption | -| `getConnectedDevices` | Lists devices connected to the merchant account |