From 5f679d40438736e48508079032cd36aba09958e2 Mon Sep 17 00:00:00 2001 From: Rain Ramm Date: Fri, 10 Apr 2026 06:09:36 +0000 Subject: [PATCH 1/5] Add immutable builder-based SDK configuration Expand MontonioSdkConfiguration with credentials, timeouts, and token expiration. The class is now immutable and constructed via Lombok @Builder with validation that accessKey and secretKey are required. Closes #10 Co-Authored-By: Claude Opus 4.6 (1M context) --- .../2026-04-10-sdk-configuration-design.md | 151 ++++++++++++++++++ .../sdk/MontonioSdkConfiguration.java | 40 ++++- .../sdk/MontonioSdkConfigurationTest.java | 97 ++++++++++- 3 files changed, 282 insertions(+), 6 deletions(-) create mode 100644 docs/plans/2026-04-10-sdk-configuration-design.md diff --git a/docs/plans/2026-04-10-sdk-configuration-design.md b/docs/plans/2026-04-10-sdk-configuration-design.md new file mode 100644 index 0000000..3816ada --- /dev/null +++ b/docs/plans/2026-04-10-sdk-configuration-design.md @@ -0,0 +1,151 @@ +# SDK Configuration Design + +**Issue:** #10 — Core configuration and multi-merchant credential support +**Date:** 2026-04-10 +**Status:** Proposed + +## Overview + +Expand `MontonioSdkConfiguration` from a minimal base-URL holder into a complete, immutable configuration object with credentials, timeouts, and token settings. Built via Lombok `@Builder` with validation at construction time. + +Multi-merchant support is intentionally deferred — consumers who need multiple merchants simply create multiple configuration instances. + +## Design Decisions + +| Decision | Choice | Rationale | +|----------------------------|----------------------------------------------|---------------------------------------------------------------------------------| +| Multi-merchant approach | Multiple instances, no built-in registry | YAGNI; consumers manage their own instances | +| Environment selection | Base URL string + constants | Simple, flexible; enum wrapper adds little value for a rarely-changed URL | +| Timeout granularity | Connect + request (two fields) | Matches `java.net.http.HttpClient` natively; no separate write timeout needed | +| Timeout type | `java.time.Duration` | Idiomatic, self-documenting units, native HttpClient compatibility | +| Construction | Lombok `@Builder` with custom `build()` | Fluent API, immutable result, validation at construction time | +| Mutability | Immutable (`@Getter` only, `final` fields) | Safer for concurrent use; no reason to mutate after construction | +| Defaults | Sensible defaults for all optional fields | Works out of the box with just credentials | +| Validation | Fail-fast in `build()` via `MontonioValidationException` | Catches missing credentials early; reuses existing exception type | + +## Configuration Fields + +| Field | Type | Default | Required | +|----------------------|------------|----------------------------------|----------| +| `accessKey` | `String` | none | yes | +| `secretKey` | `String` | none | yes | +| `baseUrl` | `String` | `SANDBOX_BASE_URL` | no | +| `connectTimeout` | `Duration` | 10 seconds | no | +| `requestTimeout` | `Duration` | 30 seconds | no | +| `tokenExpirationTime`| `Duration` | 5 minutes | no | + +## Constants + +```java +public static final String SANDBOX_BASE_URL = "https://sandbox-stargate.montonio.com/api"; +public static final String PRODUCTION_BASE_URL = "https://stargate.montonio.com/api"; +``` + +## Class Specification + +```java +package ee.bitweb.montonio.sdk; + +import ee.bitweb.montonio.sdk.exception.MontonioValidationException; +import lombok.Builder; +import lombok.Getter; + +import java.time.Duration; + +@Getter +@Builder +public class MontonioSdkConfiguration { + + public static final String SANDBOX_BASE_URL = "https://sandbox-stargate.montonio.com/api"; + public static final String PRODUCTION_BASE_URL = "https://stargate.montonio.com/api"; + + private static final Duration DEFAULT_CONNECT_TIMEOUT = Duration.ofSeconds(10); + private static final Duration DEFAULT_REQUEST_TIMEOUT = Duration.ofSeconds(30); + private static final Duration DEFAULT_TOKEN_EXPIRATION_TIME = Duration.ofMinutes(5); + + private final String accessKey; + private final String secretKey; + private final String baseUrl; + private final Duration connectTimeout; + private final Duration requestTimeout; + private final Duration tokenExpirationTime; + + public static class MontonioSdkConfigurationBuilder { + public MontonioSdkConfiguration build() { + if (accessKey == null || accessKey.isBlank()) { + throw new MontonioValidationException("accessKey", "must not be null or blank"); + } + if (secretKey == null || secretKey.isBlank()) { + throw new MontonioValidationException("secretKey", "must not be null or blank"); + } + return new MontonioSdkConfiguration( + accessKey, + secretKey, + baseUrl != null ? baseUrl : SANDBOX_BASE_URL, + connectTimeout != null ? connectTimeout : DEFAULT_CONNECT_TIMEOUT, + requestTimeout != null ? requestTimeout : DEFAULT_REQUEST_TIMEOUT, + tokenExpirationTime != null ? tokenExpirationTime : DEFAULT_TOKEN_EXPIRATION_TIME + ); + } + } +} +``` + +**Note:** Defaults are applied in the custom `build()` method rather than via `@Builder.Default`, since Lombok's `@Builder.Default` doesn't initialize `$value` fields until the generated `build()` is called — which we override. + +## Usage Examples + +### Minimal (sandbox, defaults) + +```java +MontonioSdkConfiguration config = MontonioSdkConfiguration.builder() + .accessKey("your-access-key") + .secretKey("your-secret-key") + .build(); +``` + +### Production with custom timeouts + +```java +MontonioSdkConfiguration config = MontonioSdkConfiguration.builder() + .accessKey("your-access-key") + .secretKey("your-secret-key") + .baseUrl(MontonioSdkConfiguration.PRODUCTION_BASE_URL) + .connectTimeout(Duration.ofSeconds(5)) + .requestTimeout(Duration.ofSeconds(15)) + .tokenExpirationTime(Duration.ofMinutes(10)) + .build(); +``` + +### Multi-merchant (consumer-managed) + +```java +MontonioSdkConfiguration eeConfig = MontonioSdkConfiguration.builder() + .accessKey("ee-access-key") + .secretKey("ee-secret-key") + .build(); + +MontonioSdkConfiguration lvConfig = MontonioSdkConfiguration.builder() + .accessKey("lv-access-key") + .secretKey("lv-secret-key") + .build(); +``` + +## Testing Strategy + +**Test class:** `MontonioSdkConfigurationTest` (replaces existing minimal version) + +**Test cases:** + +| Test | What it verifies | +|------|------------------| +| Builder with required fields only | All defaults applied: sandbox URL, 10s connect, 30s request, 5min token | +| Builder with all fields overridden | Each custom value returned by getter | +| Production URL constant | `PRODUCTION_BASE_URL` equals `https://stargate.montonio.com/api` | +| Sandbox URL constant | `SANDBOX_BASE_URL` equals `https://sandbox-stargate.montonio.com/api` | +| Missing accessKey (null) | Throws `MontonioValidationException` with field `"accessKey"` | +| Missing secretKey (null) | Throws `MontonioValidationException` with field `"secretKey"` | +| Blank accessKey | Throws `MontonioValidationException` with field `"accessKey"` | +| Blank secretKey | Throws `MontonioValidationException` with field `"secretKey"` | + +No integration tests needed. Target: 100% line coverage. diff --git a/src/main/java/ee/bitweb/montonio/sdk/MontonioSdkConfiguration.java b/src/main/java/ee/bitweb/montonio/sdk/MontonioSdkConfiguration.java index b6b2f93..5cd7520 100644 --- a/src/main/java/ee/bitweb/montonio/sdk/MontonioSdkConfiguration.java +++ b/src/main/java/ee/bitweb/montonio/sdk/MontonioSdkConfiguration.java @@ -1,13 +1,47 @@ package ee.bitweb.montonio.sdk; +import ee.bitweb.montonio.sdk.exception.MontonioValidationException; +import lombok.Builder; import lombok.Getter; -import lombok.Setter; + +import java.time.Duration; @Getter -@Setter +@Builder public class MontonioSdkConfiguration { public static final String SANDBOX_BASE_URL = "https://sandbox-stargate.montonio.com/api"; + public static final String PRODUCTION_BASE_URL = "https://stargate.montonio.com/api"; + + private static final Duration DEFAULT_CONNECT_TIMEOUT = Duration.ofSeconds(10); + private static final Duration DEFAULT_REQUEST_TIMEOUT = Duration.ofSeconds(30); + private static final Duration DEFAULT_TOKEN_EXPIRATION_TIME = Duration.ofMinutes(5); + + private final String accessKey; + private final String secretKey; + private final String baseUrl; + private final Duration connectTimeout; + private final Duration requestTimeout; + private final Duration tokenExpirationTime; + + public static class MontonioSdkConfigurationBuilder { + + public MontonioSdkConfiguration build() { + if (accessKey == null || accessKey.isBlank()) { + throw new MontonioValidationException("accessKey", "must not be null or blank"); + } + if (secretKey == null || secretKey.isBlank()) { + throw new MontonioValidationException("secretKey", "must not be null or blank"); + } - private String baseUrl = SANDBOX_BASE_URL; + return new MontonioSdkConfiguration( + accessKey, + secretKey, + baseUrl != null ? baseUrl : SANDBOX_BASE_URL, + connectTimeout != null ? connectTimeout : DEFAULT_CONNECT_TIMEOUT, + requestTimeout != null ? requestTimeout : DEFAULT_REQUEST_TIMEOUT, + tokenExpirationTime != null ? tokenExpirationTime : DEFAULT_TOKEN_EXPIRATION_TIME + ); + } + } } diff --git a/src/test/java/ee/bitweb/montonio/sdk/MontonioSdkConfigurationTest.java b/src/test/java/ee/bitweb/montonio/sdk/MontonioSdkConfigurationTest.java index 9f65d37..1c94252 100644 --- a/src/test/java/ee/bitweb/montonio/sdk/MontonioSdkConfigurationTest.java +++ b/src/test/java/ee/bitweb/montonio/sdk/MontonioSdkConfigurationTest.java @@ -1,15 +1,106 @@ package ee.bitweb.montonio.sdk; +import ee.bitweb.montonio.sdk.exception.MontonioValidationException; import org.junit.jupiter.api.Test; +import java.time.Duration; + import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; class MontonioSdkConfigurationTest { @Test - void defaultBaseUrlIsSandbox() { - MontonioSdkConfiguration configuration = new MontonioSdkConfiguration(); + void buildWithRequiredFieldsOnlyAppliesDefaults() { + MontonioSdkConfiguration config = MontonioSdkConfiguration.builder() + .accessKey("test-access-key") + .secretKey("test-secret-key") + .build(); + + assertEquals("test-access-key", config.getAccessKey()); + assertEquals("test-secret-key", config.getSecretKey()); + assertEquals(MontonioSdkConfiguration.SANDBOX_BASE_URL, config.getBaseUrl()); + assertEquals(Duration.ofSeconds(10), config.getConnectTimeout()); + assertEquals(Duration.ofSeconds(30), config.getRequestTimeout()); + assertEquals(Duration.ofMinutes(5), config.getTokenExpirationTime()); + } + + @Test + void buildWithAllFieldsOverridden() { + MontonioSdkConfiguration config = MontonioSdkConfiguration.builder() + .accessKey("custom-access-key") + .secretKey("custom-secret-key") + .baseUrl(MontonioSdkConfiguration.PRODUCTION_BASE_URL) + .connectTimeout(Duration.ofSeconds(5)) + .requestTimeout(Duration.ofSeconds(15)) + .tokenExpirationTime(Duration.ofMinutes(10)) + .build(); + + assertEquals("custom-access-key", config.getAccessKey()); + assertEquals("custom-secret-key", config.getSecretKey()); + assertEquals(MontonioSdkConfiguration.PRODUCTION_BASE_URL, config.getBaseUrl()); + assertEquals(Duration.ofSeconds(5), config.getConnectTimeout()); + assertEquals(Duration.ofSeconds(15), config.getRequestTimeout()); + assertEquals(Duration.ofMinutes(10), config.getTokenExpirationTime()); + } + + @Test + void sandboxBaseUrlConstant() { + assertEquals("https://sandbox-stargate.montonio.com/api", MontonioSdkConfiguration.SANDBOX_BASE_URL); + } + + @Test + void productionBaseUrlConstant() { + assertEquals("https://stargate.montonio.com/api", MontonioSdkConfiguration.PRODUCTION_BASE_URL); + } + + @Test + void buildWithNullAccessKeyThrows() { + MontonioValidationException exception = assertThrows( + MontonioValidationException.class, + () -> MontonioSdkConfiguration.builder() + .secretKey("test-secret-key") + .build() + ); + + assertEquals("accessKey", exception.getField()); + } + + @Test + void buildWithNullSecretKeyThrows() { + MontonioValidationException exception = assertThrows( + MontonioValidationException.class, + () -> MontonioSdkConfiguration.builder() + .accessKey("test-access-key") + .build() + ); + + assertEquals("secretKey", exception.getField()); + } + + @Test + void buildWithBlankAccessKeyThrows() { + MontonioValidationException exception = assertThrows( + MontonioValidationException.class, + () -> MontonioSdkConfiguration.builder() + .accessKey(" ") + .secretKey("test-secret-key") + .build() + ); + + assertEquals("accessKey", exception.getField()); + } + + @Test + void buildWithBlankSecretKeyThrows() { + MontonioValidationException exception = assertThrows( + MontonioValidationException.class, + () -> MontonioSdkConfiguration.builder() + .accessKey("test-access-key") + .secretKey(" ") + .build() + ); - assertEquals("https://sandbox-stargate.montonio.com/api", configuration.getBaseUrl()); + assertEquals("secretKey", exception.getField()); } } From 8465219896b9830d60b429767dcd796d90733267 Mon Sep 17 00:00:00 2001 From: Rain Ramm Date: Fri, 10 Apr 2026 06:13:10 +0000 Subject: [PATCH 2/5] Address CodeRabbit review feedback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fix hyphenation style nit in design doc: "rarely-changed" → "rarely changed". Co-Authored-By: Claude Opus 4.6 (1M context) --- docs/plans/2026-04-10-sdk-configuration-design.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/plans/2026-04-10-sdk-configuration-design.md b/docs/plans/2026-04-10-sdk-configuration-design.md index 3816ada..4831537 100644 --- a/docs/plans/2026-04-10-sdk-configuration-design.md +++ b/docs/plans/2026-04-10-sdk-configuration-design.md @@ -15,7 +15,7 @@ Multi-merchant support is intentionally deferred — consumers who need multiple | Decision | Choice | Rationale | |----------------------------|----------------------------------------------|---------------------------------------------------------------------------------| | Multi-merchant approach | Multiple instances, no built-in registry | YAGNI; consumers manage their own instances | -| Environment selection | Base URL string + constants | Simple, flexible; enum wrapper adds little value for a rarely-changed URL | +| Environment selection | Base URL string + constants | Simple, flexible; enum wrapper adds little value for a rarely changed URL | | Timeout granularity | Connect + request (two fields) | Matches `java.net.http.HttpClient` natively; no separate write timeout needed | | Timeout type | `java.time.Duration` | Idiomatic, self-documenting units, native HttpClient compatibility | | Construction | Lombok `@Builder` with custom `build()` | Fluent API, immutable result, validation at construction time | From 33f8aef703f74e3d22f12ebe52c5a7f03437deab Mon Sep 17 00:00:00 2001 From: Rain Ramm Date: Fri, 10 Apr 2026 06:28:09 +0000 Subject: [PATCH 3/5] Use @Builder.Default for defaults, validate in constructor MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Remove custom builder class — let Lombok's generated build() handle @Builder.Default values. Validation moves to a package-private all-args constructor that Lombok's builder calls directly. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../2026-04-10-sdk-configuration-design.md | 59 +++++++++++-------- .../sdk/MontonioSdkConfiguration.java | 59 ++++++++++--------- 2 files changed, 65 insertions(+), 53 deletions(-) diff --git a/docs/plans/2026-04-10-sdk-configuration-design.md b/docs/plans/2026-04-10-sdk-configuration-design.md index 4831537..aaa41a9 100644 --- a/docs/plans/2026-04-10-sdk-configuration-design.md +++ b/docs/plans/2026-04-10-sdk-configuration-design.md @@ -59,39 +59,46 @@ public class MontonioSdkConfiguration { public static final String SANDBOX_BASE_URL = "https://sandbox-stargate.montonio.com/api"; public static final String PRODUCTION_BASE_URL = "https://stargate.montonio.com/api"; - private static final Duration DEFAULT_CONNECT_TIMEOUT = Duration.ofSeconds(10); - private static final Duration DEFAULT_REQUEST_TIMEOUT = Duration.ofSeconds(30); - private static final Duration DEFAULT_TOKEN_EXPIRATION_TIME = Duration.ofMinutes(5); - private final String accessKey; private final String secretKey; - private final String baseUrl; - private final Duration connectTimeout; - private final Duration requestTimeout; - private final Duration tokenExpirationTime; - - public static class MontonioSdkConfigurationBuilder { - public MontonioSdkConfiguration build() { - if (accessKey == null || accessKey.isBlank()) { - throw new MontonioValidationException("accessKey", "must not be null or blank"); - } - if (secretKey == null || secretKey.isBlank()) { - throw new MontonioValidationException("secretKey", "must not be null or blank"); - } - return new MontonioSdkConfiguration( - accessKey, - secretKey, - baseUrl != null ? baseUrl : SANDBOX_BASE_URL, - connectTimeout != null ? connectTimeout : DEFAULT_CONNECT_TIMEOUT, - requestTimeout != null ? requestTimeout : DEFAULT_REQUEST_TIMEOUT, - tokenExpirationTime != null ? tokenExpirationTime : DEFAULT_TOKEN_EXPIRATION_TIME - ); + + @Builder.Default + private final String baseUrl = SANDBOX_BASE_URL; + + @Builder.Default + private final Duration connectTimeout = Duration.ofSeconds(10); + + @Builder.Default + private final Duration requestTimeout = Duration.ofSeconds(30); + + @Builder.Default + private final Duration tokenExpirationTime = Duration.ofMinutes(5); + + MontonioSdkConfiguration( + String accessKey, + String secretKey, + String baseUrl, + Duration connectTimeout, + Duration requestTimeout, + Duration tokenExpirationTime + ) { + if (accessKey == null || accessKey.isBlank()) { + throw new MontonioValidationException("accessKey", "must not be null or blank"); + } + if (secretKey == null || secretKey.isBlank()) { + throw new MontonioValidationException("secretKey", "must not be null or blank"); } + this.accessKey = accessKey; + this.secretKey = secretKey; + this.baseUrl = baseUrl; + this.connectTimeout = connectTimeout; + this.requestTimeout = requestTimeout; + this.tokenExpirationTime = tokenExpirationTime; } } ``` -**Note:** Defaults are applied in the custom `build()` method rather than via `@Builder.Default`, since Lombok's `@Builder.Default` doesn't initialize `$value` fields until the generated `build()` is called — which we override. +**Note:** `@Builder.Default` handles defaults in the generated `build()` method. Validation lives in the package-private all-args constructor, which Lombok's builder calls — no custom builder class needed. ## Usage Examples diff --git a/src/main/java/ee/bitweb/montonio/sdk/MontonioSdkConfiguration.java b/src/main/java/ee/bitweb/montonio/sdk/MontonioSdkConfiguration.java index 5cd7520..61fcf41 100644 --- a/src/main/java/ee/bitweb/montonio/sdk/MontonioSdkConfiguration.java +++ b/src/main/java/ee/bitweb/montonio/sdk/MontonioSdkConfiguration.java @@ -13,35 +13,40 @@ public class MontonioSdkConfiguration { public static final String SANDBOX_BASE_URL = "https://sandbox-stargate.montonio.com/api"; public static final String PRODUCTION_BASE_URL = "https://stargate.montonio.com/api"; - private static final Duration DEFAULT_CONNECT_TIMEOUT = Duration.ofSeconds(10); - private static final Duration DEFAULT_REQUEST_TIMEOUT = Duration.ofSeconds(30); - private static final Duration DEFAULT_TOKEN_EXPIRATION_TIME = Duration.ofMinutes(5); - private final String accessKey; private final String secretKey; - private final String baseUrl; - private final Duration connectTimeout; - private final Duration requestTimeout; - private final Duration tokenExpirationTime; - - public static class MontonioSdkConfigurationBuilder { - - public MontonioSdkConfiguration build() { - if (accessKey == null || accessKey.isBlank()) { - throw new MontonioValidationException("accessKey", "must not be null or blank"); - } - if (secretKey == null || secretKey.isBlank()) { - throw new MontonioValidationException("secretKey", "must not be null or blank"); - } - - return new MontonioSdkConfiguration( - accessKey, - secretKey, - baseUrl != null ? baseUrl : SANDBOX_BASE_URL, - connectTimeout != null ? connectTimeout : DEFAULT_CONNECT_TIMEOUT, - requestTimeout != null ? requestTimeout : DEFAULT_REQUEST_TIMEOUT, - tokenExpirationTime != null ? tokenExpirationTime : DEFAULT_TOKEN_EXPIRATION_TIME - ); + + @Builder.Default + private final String baseUrl = SANDBOX_BASE_URL; + + @Builder.Default + private final Duration connectTimeout = Duration.ofSeconds(10); + + @Builder.Default + private final Duration requestTimeout = Duration.ofSeconds(30); + + @Builder.Default + private final Duration tokenExpirationTime = Duration.ofMinutes(5); + + MontonioSdkConfiguration( + String accessKey, + String secretKey, + String baseUrl, + Duration connectTimeout, + Duration requestTimeout, + Duration tokenExpirationTime + ) { + if (accessKey == null || accessKey.isBlank()) { + throw new MontonioValidationException("accessKey", "must not be null or blank"); + } + if (secretKey == null || secretKey.isBlank()) { + throw new MontonioValidationException("secretKey", "must not be null or blank"); } + this.accessKey = accessKey; + this.secretKey = secretKey; + this.baseUrl = baseUrl; + this.connectTimeout = connectTimeout; + this.requestTimeout = requestTimeout; + this.tokenExpirationTime = tokenExpirationTime; } } From 4c8d6ba34ee451a6903bf0b2798dff09e5c323c4 Mon Sep 17 00:00:00 2001 From: Rain Ramm Date: Fri, 10 Apr 2026 06:31:22 +0000 Subject: [PATCH 4/5] Validate all fields are non-null in constructor MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reject explicit nulls for baseUrl, connectTimeout, requestTimeout, and tokenExpirationTime — prevents callers from overriding @Builder.Default values with null. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../sdk/MontonioSdkConfiguration.java | 12 ++++ .../sdk/MontonioSdkConfigurationTest.java | 56 +++++++++++++++++++ 2 files changed, 68 insertions(+) diff --git a/src/main/java/ee/bitweb/montonio/sdk/MontonioSdkConfiguration.java b/src/main/java/ee/bitweb/montonio/sdk/MontonioSdkConfiguration.java index 61fcf41..c699b70 100644 --- a/src/main/java/ee/bitweb/montonio/sdk/MontonioSdkConfiguration.java +++ b/src/main/java/ee/bitweb/montonio/sdk/MontonioSdkConfiguration.java @@ -42,6 +42,18 @@ public class MontonioSdkConfiguration { if (secretKey == null || secretKey.isBlank()) { throw new MontonioValidationException("secretKey", "must not be null or blank"); } + if (baseUrl == null || baseUrl.isBlank()) { + throw new MontonioValidationException("baseUrl", "must not be null or blank"); + } + if (connectTimeout == null) { + throw new MontonioValidationException("connectTimeout", "must not be null"); + } + if (requestTimeout == null) { + throw new MontonioValidationException("requestTimeout", "must not be null"); + } + if (tokenExpirationTime == null) { + throw new MontonioValidationException("tokenExpirationTime", "must not be null"); + } this.accessKey = accessKey; this.secretKey = secretKey; this.baseUrl = baseUrl; diff --git a/src/test/java/ee/bitweb/montonio/sdk/MontonioSdkConfigurationTest.java b/src/test/java/ee/bitweb/montonio/sdk/MontonioSdkConfigurationTest.java index 1c94252..fcd97bc 100644 --- a/src/test/java/ee/bitweb/montonio/sdk/MontonioSdkConfigurationTest.java +++ b/src/test/java/ee/bitweb/montonio/sdk/MontonioSdkConfigurationTest.java @@ -103,4 +103,60 @@ void buildWithBlankSecretKeyThrows() { assertEquals("secretKey", exception.getField()); } + + @Test + void buildWithNullBaseUrlThrows() { + MontonioValidationException exception = assertThrows( + MontonioValidationException.class, + () -> MontonioSdkConfiguration.builder() + .accessKey("test-access-key") + .secretKey("test-secret-key") + .baseUrl(null) + .build() + ); + + assertEquals("baseUrl", exception.getField()); + } + + @Test + void buildWithNullConnectTimeoutThrows() { + MontonioValidationException exception = assertThrows( + MontonioValidationException.class, + () -> MontonioSdkConfiguration.builder() + .accessKey("test-access-key") + .secretKey("test-secret-key") + .connectTimeout(null) + .build() + ); + + assertEquals("connectTimeout", exception.getField()); + } + + @Test + void buildWithNullRequestTimeoutThrows() { + MontonioValidationException exception = assertThrows( + MontonioValidationException.class, + () -> MontonioSdkConfiguration.builder() + .accessKey("test-access-key") + .secretKey("test-secret-key") + .requestTimeout(null) + .build() + ); + + assertEquals("requestTimeout", exception.getField()); + } + + @Test + void buildWithNullTokenExpirationTimeThrows() { + MontonioValidationException exception = assertThrows( + MontonioValidationException.class, + () -> MontonioSdkConfiguration.builder() + .accessKey("test-access-key") + .secretKey("test-secret-key") + .tokenExpirationTime(null) + .build() + ); + + assertEquals("tokenExpirationTime", exception.getField()); + } } From 6b71c42c71f15274605a39d090af44e4b1d6d9ec Mon Sep 17 00:00:00 2001 From: Rain Ramm Date: Fri, 10 Apr 2026 06:38:42 +0000 Subject: [PATCH 5/5] Reject negative durations in constructor validation Fail fast on negative connectTimeout, requestTimeout, and tokenExpirationTime instead of deferring the error to the HTTP/auth layer. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../sdk/MontonioSdkConfiguration.java | 12 +++--- .../sdk/MontonioSdkConfigurationTest.java | 42 +++++++++++++++++++ 2 files changed, 48 insertions(+), 6 deletions(-) diff --git a/src/main/java/ee/bitweb/montonio/sdk/MontonioSdkConfiguration.java b/src/main/java/ee/bitweb/montonio/sdk/MontonioSdkConfiguration.java index c699b70..1d28c60 100644 --- a/src/main/java/ee/bitweb/montonio/sdk/MontonioSdkConfiguration.java +++ b/src/main/java/ee/bitweb/montonio/sdk/MontonioSdkConfiguration.java @@ -45,14 +45,14 @@ public class MontonioSdkConfiguration { if (baseUrl == null || baseUrl.isBlank()) { throw new MontonioValidationException("baseUrl", "must not be null or blank"); } - if (connectTimeout == null) { - throw new MontonioValidationException("connectTimeout", "must not be null"); + if (connectTimeout == null || connectTimeout.isNegative()) { + throw new MontonioValidationException("connectTimeout", "must not be null or negative"); } - if (requestTimeout == null) { - throw new MontonioValidationException("requestTimeout", "must not be null"); + if (requestTimeout == null || requestTimeout.isNegative()) { + throw new MontonioValidationException("requestTimeout", "must not be null or negative"); } - if (tokenExpirationTime == null) { - throw new MontonioValidationException("tokenExpirationTime", "must not be null"); + if (tokenExpirationTime == null || tokenExpirationTime.isNegative()) { + throw new MontonioValidationException("tokenExpirationTime", "must not be null or negative"); } this.accessKey = accessKey; this.secretKey = secretKey; diff --git a/src/test/java/ee/bitweb/montonio/sdk/MontonioSdkConfigurationTest.java b/src/test/java/ee/bitweb/montonio/sdk/MontonioSdkConfigurationTest.java index fcd97bc..e8e5d7e 100644 --- a/src/test/java/ee/bitweb/montonio/sdk/MontonioSdkConfigurationTest.java +++ b/src/test/java/ee/bitweb/montonio/sdk/MontonioSdkConfigurationTest.java @@ -159,4 +159,46 @@ void buildWithNullTokenExpirationTimeThrows() { assertEquals("tokenExpirationTime", exception.getField()); } + + @Test + void buildWithNegativeConnectTimeoutThrows() { + MontonioValidationException exception = assertThrows( + MontonioValidationException.class, + () -> MontonioSdkConfiguration.builder() + .accessKey("test-access-key") + .secretKey("test-secret-key") + .connectTimeout(Duration.ofSeconds(-1)) + .build() + ); + + assertEquals("connectTimeout", exception.getField()); + } + + @Test + void buildWithNegativeRequestTimeoutThrows() { + MontonioValidationException exception = assertThrows( + MontonioValidationException.class, + () -> MontonioSdkConfiguration.builder() + .accessKey("test-access-key") + .secretKey("test-secret-key") + .requestTimeout(Duration.ofSeconds(-1)) + .build() + ); + + assertEquals("requestTimeout", exception.getField()); + } + + @Test + void buildWithNegativeTokenExpirationTimeThrows() { + MontonioValidationException exception = assertThrows( + MontonioValidationException.class, + () -> MontonioSdkConfiguration.builder() + .accessKey("test-access-key") + .secretKey("test-secret-key") + .tokenExpirationTime(Duration.ofSeconds(-1)) + .build() + ); + + assertEquals("tokenExpirationTime", exception.getField()); + } }