diff --git a/docs/plans/2026-04-10-get-order-status-design.md b/docs/plans/2026-04-10-get-order-status-design.md new file mode 100644 index 0000000..84524d5 --- /dev/null +++ b/docs/plans/2026-04-10-get-order-status-design.md @@ -0,0 +1,77 @@ +# Get Order Status — Design + +**Issue:** #16 +**Date:** 2026-04-10 + +## Summary + +Add a `GET /orders/{uuid}` operation exposed through a domain-scoped facade: +`MontonioClient(config).orders().get(uuid)` returning the existing `OrderResponse`. + +## Public API + +```java +MontonioClient client = new MontonioClient(configuration); +OrderResponse order = client.orders().get("order-uuid"); +``` + +### Classes Introduced + +| Class | Package | Role | +|-------|---------|------| +| `MontonioClient` | `ee.bitweb.montonio.sdk` | Top-level facade, owns `MontonioHttpClient`, lazy-creates domain services | +| `OrderService` | `ee.bitweb.montonio.sdk.order` | Validates input, delegates `GET /orders/{uuid}` to HTTP client | + +No new models — reuses `OrderResponse`, `PaymentIntent`, `PaymentStatus`, and all supporting types from #14. + +### Dependency Flow + +```text +MontonioClient → MontonioHttpClient → HttpClient (java.net) + ↓ + OrderService (receives MontonioHttpClient) +``` + +## MontonioClient + +- Constructor takes `MontonioSdkConfiguration`, creates `MontonioHttpClient` internally. +- Package-private constructor overload accepts `MontonioHttpClient` for testing. +- `orders()` uses lazy-cached initialization: creates `OrderService` on first call, reuses it after. +- Not thread-safe on `orders()` — benign race at worst. Matches typical single-threaded SDK usage. + +## OrderService + +- Package-private constructor receives `MontonioHttpClient` — users go through `MontonioClient`. +- `get(String uuid)` validates null/blank (throws `MontonioValidationException` with field `"uuid"`), then delegates to `httpClient.get("/orders/" + uuid, OrderResponse.class)`. +- No additional error handling — `MontonioHttpClient` already maps 4xx/5xx to `MontonioApiException` and network failures to `MontonioNetworkException`. + +## Testing + +### OrderServiceTest + +Unit tests with stubbed HTTP client (same pattern as `MontonioHttpClientTest`): + +| Test case | Setup | Assertion | +|-----------|-------|-----------| +| Successful retrieval | Stub 200, full JSON | All fields deserialized, including nested paymentIntents | +| Null UUID | `get(null)` | `MontonioValidationException` with field `"uuid"` | +| Blank UUID | `get(" ")` | `MontonioValidationException` with field `"uuid"` | +| Order not found | Stub 404 with error JSON | `MontonioApiException` with status 404 | +| Multiple payment intents | Stub 200, 2+ intents | List size and individual fields correct | +| Various payment statuses | Stub 200, PAID/PENDING/VOIDED | `PaymentStatus` enum deserialized correctly | + +### MontonioClientTest + +| Test case | Assertion | +|-----------|-----------| +| `orders()` returns non-null | Basic wiring | +| `orders()` returns same instance | Lazy-caching works | +| Constructor rejects null config | Validation error | + +## Decisions + +- **Domain-scoped facade** (`client.orders().get()`) over flat methods or standalone services — scales as more domains are added. +- **Lazy-cached domain services** — avoids allocating services never used, avoids re-allocating on every call. +- **Full `OrderResponse` return** — no slimmer DTO; callers pick fields they need. +- **Null/blank validation only** — no UUID format regex; let the API decide validity beyond that. +- **Package-private constructors** — testability without exposing internals to SDK consumers. diff --git a/src/main/java/ee/bitweb/montonio/sdk/MontonioClient.java b/src/main/java/ee/bitweb/montonio/sdk/MontonioClient.java new file mode 100644 index 0000000..e579a94 --- /dev/null +++ b/src/main/java/ee/bitweb/montonio/sdk/MontonioClient.java @@ -0,0 +1,38 @@ +package ee.bitweb.montonio.sdk; + +import ee.bitweb.montonio.sdk.http.MontonioHttpClient; +import ee.bitweb.montonio.sdk.order.OrderService; + +public class MontonioClient { + + private final MontonioHttpClient httpClient; + private volatile OrderService orderService; + + public MontonioClient(MontonioSdkConfiguration configuration) { + if (configuration == null) { + throw new NullPointerException("configuration must not be null"); + } + this.httpClient = new MontonioHttpClient(configuration); + } + + MontonioClient(MontonioHttpClient httpClient) { + if (httpClient == null) { + throw new NullPointerException("httpClient must not be null"); + } + this.httpClient = httpClient; + } + + public OrderService orders() { + OrderService local = orderService; + if (local == null) { + synchronized (this) { + local = orderService; + if (local == null) { + local = new OrderService(httpClient); + orderService = local; + } + } + } + return local; + } +} diff --git a/src/main/java/ee/bitweb/montonio/sdk/http/MontonioHttpClient.java b/src/main/java/ee/bitweb/montonio/sdk/http/MontonioHttpClient.java index b066d39..ec69704 100644 --- a/src/main/java/ee/bitweb/montonio/sdk/http/MontonioHttpClient.java +++ b/src/main/java/ee/bitweb/montonio/sdk/http/MontonioHttpClient.java @@ -40,8 +40,8 @@ public MontonioHttpClient(MontonioSdkConfiguration configuration) { this.tokenProvider = new MontonioTokenProvider(configuration, objectMapper); } - MontonioHttpClient(MontonioSdkConfiguration configuration, HttpClient httpClient, - MontonioTokenProvider tokenProvider) { + public MontonioHttpClient(MontonioSdkConfiguration configuration, HttpClient httpClient, + MontonioTokenProvider tokenProvider) { this.configuration = configuration; this.httpClient = httpClient; this.objectMapper = createObjectMapper(); diff --git a/src/main/java/ee/bitweb/montonio/sdk/order/OrderService.java b/src/main/java/ee/bitweb/montonio/sdk/order/OrderService.java new file mode 100644 index 0000000..aa19373 --- /dev/null +++ b/src/main/java/ee/bitweb/montonio/sdk/order/OrderService.java @@ -0,0 +1,28 @@ +package ee.bitweb.montonio.sdk.order; + +import ee.bitweb.montonio.sdk.exception.MontonioValidationException; +import ee.bitweb.montonio.sdk.http.MontonioHttpClient; +import ee.bitweb.montonio.sdk.order.response.OrderResponse; + +public class OrderService { + + private final MontonioHttpClient httpClient; + + public OrderService(MontonioHttpClient httpClient) { + if (httpClient == null) { + throw new NullPointerException("httpClient must not be null"); + } + this.httpClient = httpClient; + } + + public OrderResponse get(String uuid) { + if (uuid == null) { + throw new MontonioValidationException("uuid", "must not be null or blank"); + } + String trimmedUuid = uuid.trim(); + if (trimmedUuid.isEmpty()) { + throw new MontonioValidationException("uuid", "must not be null or blank"); + } + return httpClient.get("/orders/" + trimmedUuid, OrderResponse.class); + } +} diff --git a/src/test/java/ee/bitweb/montonio/sdk/MontonioClientTest.java b/src/test/java/ee/bitweb/montonio/sdk/MontonioClientTest.java new file mode 100644 index 0000000..0e8e9b2 --- /dev/null +++ b/src/test/java/ee/bitweb/montonio/sdk/MontonioClientTest.java @@ -0,0 +1,40 @@ +package ee.bitweb.montonio.sdk; + +import ee.bitweb.montonio.sdk.order.OrderService; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.*; + +class MontonioClientTest { + + @Test + void constructorRejectsNullConfiguration() { + MontonioSdkConfiguration nullConfig = null; + assertThrows(NullPointerException.class, () -> new MontonioClient(nullConfig)); + } + + @Test + void ordersReturnsNonNull() { + MontonioClient client = createClient(); + + assertNotNull(client.orders()); + } + + @Test + void ordersReturnsSameInstanceOnRepeatedCalls() { + MontonioClient client = createClient(); + + OrderService first = client.orders(); + OrderService second = client.orders(); + + assertSame(first, second); + } + + private MontonioClient createClient() { + MontonioSdkConfiguration configuration = MontonioSdkConfiguration.builder() + .accessKey("test-access-key") + .secretKey("test-secret-key-that-is-long-enough") + .build(); + return new MontonioClient(configuration); + } +} diff --git a/src/test/java/ee/bitweb/montonio/sdk/order/OrderServiceTest.java b/src/test/java/ee/bitweb/montonio/sdk/order/OrderServiceTest.java new file mode 100644 index 0000000..cb6cd33 --- /dev/null +++ b/src/test/java/ee/bitweb/montonio/sdk/order/OrderServiceTest.java @@ -0,0 +1,344 @@ +package ee.bitweb.montonio.sdk.order; + +import ee.bitweb.montonio.sdk.MontonioSdkConfiguration; +import ee.bitweb.montonio.sdk.auth.MontonioTokenProvider; +import ee.bitweb.montonio.sdk.exception.MontonioApiException; +import ee.bitweb.montonio.sdk.exception.MontonioValidationException; +import ee.bitweb.montonio.sdk.http.MontonioHttpClient; +import ee.bitweb.montonio.sdk.model.Currency; +import ee.bitweb.montonio.sdk.model.PaymentMethodType; +import ee.bitweb.montonio.sdk.order.model.PaymentStatus; +import ee.bitweb.montonio.sdk.order.response.OrderResponse; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.EnumSource; +import tools.jackson.databind.DeserializationFeature; +import tools.jackson.databind.ObjectMapper; +import tools.jackson.databind.json.JsonMapper; + +import javax.net.ssl.SSLSession; +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpHeaders; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.time.Clock; +import java.time.Duration; +import java.time.Instant; +import java.time.ZoneOffset; +import java.util.Map; +import java.util.Optional; + +import static org.junit.jupiter.api.Assertions.*; + +class OrderServiceTest { + + private static final String BASE_URL = "https://api.example.com"; + private static final String ACCESS_KEY = "test-access-key"; + private static final String SECRET_KEY = "test-secret-key-that-is-long-enough"; + + private MontonioSdkConfiguration configuration; + + @BeforeEach + void setUp() { + configuration = MontonioSdkConfiguration.builder() + .accessKey(ACCESS_KEY) + .secretKey(SECRET_KEY) + .baseUrl(BASE_URL) + .connectTimeout(Duration.ofSeconds(5)) + .requestTimeout(Duration.ofSeconds(10)) + .build(); + } + + @Test + void getReturnsDeserializedOrderResponse() { + String json = fullOrderJson("PAID", "paymentInitiation"); + OrderService service = createServiceWithStub(200, json); + + OrderResponse response = service.get("order-uuid-123"); + + assertEquals("order-uuid-123", response.getUuid()); + assertEquals(PaymentStatus.PAID, response.getPaymentStatus()); + assertEquals("Order1234567", response.getMerchantReference()); + assertEquals("100.00", response.getGrandTotal()); + assertEquals(Currency.EUR, response.getCurrency()); + assertEquals(PaymentMethodType.PAYMENT_INITIATION, response.getPaymentMethodType()); + } + + @Test + void getBuildsCorrectPath() { + StubHttpClient stubClient = new StubHttpClient(200, fullOrderJson("PENDING", "paymentInitiation")); + OrderService service = createServiceWithStub(stubClient); + + service.get("my-order-uuid"); + + assertEquals( + URI.create(BASE_URL + "/orders/my-order-uuid"), + stubClient.capturedRequest.uri() + ); + } + + @Test + void getTrimsUuidBeforeBuildingPath() { + StubHttpClient stubClient = new StubHttpClient(200, fullOrderJson("PAID", "paymentInitiation")); + OrderService service = createServiceWithStub(stubClient); + + service.get(" order-uuid "); + + assertEquals( + URI.create(BASE_URL + "/orders/order-uuid"), + stubClient.capturedRequest.uri() + ); + } + + @Test + void getWithNullUuidThrowsValidationException() { + OrderService service = createServiceWithStub(200, "{}"); + + MontonioValidationException exception = assertThrows( + MontonioValidationException.class, + () -> service.get(null) + ); + + assertEquals("uuid", exception.getField()); + assertTrue(exception.getMessage().contains("must not be null or blank")); + } + + @Test + void getWithBlankUuidThrowsValidationException() { + OrderService service = createServiceWithStub(200, "{}"); + + MontonioValidationException exception = assertThrows( + MontonioValidationException.class, + () -> service.get(" ") + ); + + assertEquals("uuid", exception.getField()); + } + + @Test + void getWithEmptyUuidThrowsValidationException() { + OrderService service = createServiceWithStub(200, "{}"); + + MontonioValidationException exception = assertThrows( + MontonioValidationException.class, + () -> service.get("") + ); + + assertEquals("uuid", exception.getField()); + assertTrue(exception.getMessage().contains("must not be null or blank")); + } + + @Test + void getWithNotFoundOrderThrowsApiException() { + String errorJson = "{\"errorCode\":\"ORDER_NOT_FOUND\",\"message\":\"Order does not exist\"}"; + OrderService service = createServiceWithStub(404, errorJson); + + MontonioApiException exception = assertThrows( + MontonioApiException.class, + () -> service.get("nonexistent-uuid") + ); + + assertEquals(404, exception.getStatusCode()); + assertEquals("ORDER_NOT_FOUND", exception.getErrorCode()); + assertEquals("Order does not exist", exception.getErrorMessage()); + } + + @Test + void getDeserializesMultiplePaymentIntents() { + String json = """ + { + "uuid": "order-uuid", + "paymentStatus": "PAID", + "locale": "en", + "merchantReference": "ref", + "merchantReferenceDisplay": "ref", + "merchantReturnUrl": "http://example.com", + "merchantNotificationUrl": "http://example.com", + "grandTotal": "200.00", + "currency": "EUR", + "paymentMethodType": "cardPayments", + "storeUuid": "store-uuid", + "paymentIntents": [ + { + "uuid": "intent-1", + "paymentMethodType": "paymentInitiation", + "amount": "100.00", + "currency": "EUR", + "status": "PAID", + "serviceFee": "0.50", + "serviceFeeCurrency": "EUR", + "createdAt": "2026-04-10T12:00:00Z" + }, + { + "uuid": "intent-2", + "paymentMethodType": "cardPayments", + "amount": "100.00", + "currency": "EUR", + "status": "AUTHORIZED", + "serviceFee": "1.00", + "serviceFeeCurrency": "EUR", + "createdAt": "2026-04-10T12:01:00Z", + "paymentMethodMetadata": { + "preferredCountry": "EE" + } + } + ], + "lineItems": [], + "billingAddress": {}, + "shippingAddress": {}, + "expiresAt": "2026-04-10T13:00:00Z", + "createdAt": "2026-04-10T12:00:00Z", + "storeName": "Store", + "businessName": "Business", + "paymentUrl": "https://example.com/pay" + } + """; + OrderService service = createServiceWithStub(200, json); + + OrderResponse response = service.get("order-uuid"); + + assertEquals(2, response.getPaymentIntents().size()); + assertEquals("intent-1", response.getPaymentIntents().get(0).getUuid()); + assertEquals(PaymentMethodType.PAYMENT_INITIATION, response.getPaymentIntents().get(0).getPaymentMethodType()); + assertEquals(PaymentStatus.PAID, response.getPaymentIntents().get(0).getStatus()); + assertEquals("intent-2", response.getPaymentIntents().get(1).getUuid()); + assertEquals(PaymentMethodType.CARD_PAYMENTS, response.getPaymentIntents().get(1).getPaymentMethodType()); + assertEquals(PaymentStatus.AUTHORIZED, response.getPaymentIntents().get(1).getStatus()); + assertEquals("EE", response.getPaymentIntents().get(1).getPaymentMethodMetadata().get("preferredCountry")); + } + + @ParameterizedTest + @EnumSource(PaymentStatus.class) + void getDeserializesAllPaymentStatuses(PaymentStatus status) { + String json = fullOrderJson(status.getValue(), "paymentInitiation"); + OrderService service = createServiceWithStub(200, json); + + OrderResponse response = service.get("order-uuid"); + + assertEquals(status, response.getPaymentStatus()); + } + + // --- Helpers --- + + private OrderService createServiceWithStub(int statusCode, String responseBody) { + return createServiceWithStub(new StubHttpClient(statusCode, responseBody)); + } + + private OrderService createServiceWithStub(StubHttpClient stubClient) { + ObjectMapper objectMapper = JsonMapper.builder() + .disable(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES) + .build(); + MontonioTokenProvider tokenProvider = new MontonioTokenProvider( + configuration, objectMapper, + Clock.fixed(Instant.parse("2026-01-15T10:00:00Z"), ZoneOffset.UTC) + ); + MontonioHttpClient httpClient = new MontonioHttpClient(configuration, stubClient, tokenProvider); + return new OrderService(httpClient); + } + + private static String fullOrderJson(String paymentStatus, String paymentMethodType) { + return """ + { + "uuid": "order-uuid-123", + "paymentStatus": "%s", + "locale": "en", + "merchantReference": "Order1234567", + "merchantReferenceDisplay": "Order 1234567", + "merchantReturnUrl": "http://localhost:3000/return", + "merchantNotificationUrl": "http://example.com/notify", + "grandTotal": "100.00", + "currency": "EUR", + "paymentMethodType": "%s", + "storeUuid": "store-uuid", + "paymentIntents": [ + { + "uuid": "intent-uuid", + "paymentMethodType": "paymentInitiation", + "amount": "100.00", + "currency": "EUR", + "status": "PAID", + "serviceFee": "0.00", + "serviceFeeCurrency": "EUR", + "createdAt": "2026-04-10T12:00:00Z" + } + ], + "lineItems": [], + "billingAddress": {}, + "shippingAddress": {}, + "expiresAt": "2026-04-10T12:10:00Z", + "createdAt": "2026-04-10T12:00:00Z", + "storeName": "Test Store", + "businessName": "Test Business", + "paymentUrl": "https://example.com/pay" + } + """.formatted(paymentStatus, paymentMethodType); + } + + // --- Stub HttpClient implementations --- + + private static class StubHttpClient extends HttpClient { + + private final int statusCode; + private final String responseBody; + HttpRequest capturedRequest; + + StubHttpClient(int statusCode, String responseBody) { + this.statusCode = statusCode; + this.responseBody = responseBody; + } + + @Override + @SuppressWarnings("unchecked") + public HttpResponse send(HttpRequest request, HttpResponse.BodyHandler responseBodyHandler) { + this.capturedRequest = request; + return (HttpResponse) new StubHttpResponse(statusCode, responseBody, request); + } + + @Override public Optional cookieHandler() { return Optional.empty(); } + @Override public Optional connectTimeout() { return Optional.empty(); } + @Override public Redirect followRedirects() { return Redirect.NEVER; } + @Override public Optional proxy() { return Optional.empty(); } + @Override public javax.net.ssl.SSLContext sslContext() { return null; } + @Override public javax.net.ssl.SSLParameters sslParameters() { return null; } + @Override public Optional authenticator() { return Optional.empty(); } + @Override public Version version() { return Version.HTTP_2; } + @Override public Optional executor() { return Optional.empty(); } + + @Override + public java.util.concurrent.CompletableFuture> sendAsync( + HttpRequest request, HttpResponse.BodyHandler responseBodyHandler) { + throw new UnsupportedOperationException(); + } + + @Override + public java.util.concurrent.CompletableFuture> sendAsync( + HttpRequest request, HttpResponse.BodyHandler responseBodyHandler, + HttpResponse.PushPromiseHandler pushPromiseHandler) { + throw new UnsupportedOperationException(); + } + } + + private static class StubHttpResponse implements HttpResponse { + + private final int statusCode; + private final String body; + private final HttpRequest request; + + StubHttpResponse(int statusCode, String body, HttpRequest request) { + this.statusCode = statusCode; + this.body = body; + this.request = request; + } + + @Override public int statusCode() { return statusCode; } + @Override public String body() { return body; } + @Override public HttpRequest request() { return request; } + @Override public Optional> previousResponse() { return Optional.empty(); } + @Override public HttpHeaders headers() { return HttpHeaders.of(Map.of(), (a, b) -> true); } + @Override public URI uri() { return request.uri(); } + @Override public HttpClient.Version version() { return HttpClient.Version.HTTP_2; } + @Override public Optional sslSession() { return Optional.empty(); } + } +}