diff --git a/build.gradle.kts b/build.gradle.kts index 02ba2a2b..17a645f7 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -43,7 +43,7 @@ repositories { dependencies { testImplementation("org.junit.jupiter:junit-jupiter:5.13.0") testRuntimeOnly("org.junit.platform:junit-platform-launcher") - testImplementation("org.mockito:mockito-core:5.19.0") + testImplementation("org.mockito:mockito-core:4.11.0") } tasks.withType { diff --git a/src/test/java/com/chargebee/v4/client/ChargebeeClientRetryTest.java b/src/test/java/com/chargebee/v4/client/ChargebeeClientRetryTest.java new file mode 100644 index 00000000..b2c81c42 --- /dev/null +++ b/src/test/java/com/chargebee/v4/client/ChargebeeClientRetryTest.java @@ -0,0 +1,947 @@ +package com.chargebee.v4.client; + +import com.chargebee.v4.internal.RetryConfig; +import com.chargebee.v4.transport.*; +import org.junit.jupiter.api.*; +import org.mockito.*; + +import java.util.*; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.atomic.AtomicInteger; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.*; + +@DisplayName("ChargebeeClient Retry Tests") +class ChargebeeClientRetryTest { + + private static final String TEST_API_KEY = "test_api_key"; + private static final String TEST_SITE = "test-site"; + + @Nested + @DisplayName("sendWithRetry - Retry Disabled Tests") + class RetryDisabledTests { + + @Test + @DisplayName("should not retry when retry is disabled and request succeeds") + void shouldNotRetryWhenDisabledAndSucceeds() throws Exception { + Transport mockTransport = mock(Transport.class); + Response mockResponse = createSuccessResponse(); + when(mockTransport.send(any(Request.class))).thenReturn(mockResponse); + + RetryConfig noRetry = RetryConfig.builder().enabled(false).build(); + ChargebeeClient client = ChargebeeClient.builder() + .apiKey(TEST_API_KEY) + .siteName(TEST_SITE) + .transport(mockTransport) + .retry(noRetry) + .build(); + + Request request = Request.builder() + .method("GET") + .url("http://test.com") + .build(); + + Response response = client.sendWithRetry(request); + + assertEquals(200, response.getStatusCode()); + verify(mockTransport, times(1)).send(any(Request.class)); + } + + @Test + @DisplayName("should not retry when retry is disabled and request fails") + void shouldNotRetryWhenDisabledAndFails() throws Exception { + Transport mockTransport = mock(Transport.class); + when(mockTransport.send(any(Request.class))) + .thenThrow(new NetworkException("Network error", new Exception())); + + RetryConfig noRetry = RetryConfig.builder().enabled(false).build(); + ChargebeeClient client = ChargebeeClient.builder() + .apiKey(TEST_API_KEY) + .siteName(TEST_SITE) + .transport(mockTransport) + .retry(noRetry) + .build(); + + Request request = Request.builder() + .method("GET") + .url("http://test.com") + .build(); + + assertThrows(NetworkException.class, () -> client.sendWithRetry(request)); + verify(mockTransport, times(1)).send(any(Request.class)); + } + } + + @Nested + @DisplayName("sendWithRetry - Retry on Network Errors") + class RetryOnNetworkErrorsTests { + + @Test + @DisplayName("should retry on TimeoutException and succeed") + void shouldRetryOnTimeoutAndSucceed() throws Exception { + Transport mockTransport = mock(Transport.class); + TimeoutException timeoutException = new TimeoutException("read", "Timeout", new Exception()); + Response successResponse = createSuccessResponse(); + + when(mockTransport.send(any(Request.class))) + .thenThrow(timeoutException) + .thenReturn(successResponse); + + RetryConfig retryConfig = RetryConfig.builder() + .enabled(true) + .maxRetries(2) + .baseDelayMs(10) + .build(); + + ChargebeeClient client = ChargebeeClient.builder() + .apiKey(TEST_API_KEY) + .siteName(TEST_SITE) + .transport(mockTransport) + .retry(retryConfig) + .build(); + + Request request = Request.builder() + .method("GET") + .url("http://test.com") + .build(); + + Response response = client.sendWithRetry(request); + + assertEquals(200, response.getStatusCode()); + verify(mockTransport, times(2)).send(any(Request.class)); + } + + @Test + @DisplayName("should retry on NetworkException and succeed") + void shouldRetryOnNetworkExceptionAndSucceed() throws Exception { + Transport mockTransport = mock(Transport.class); + NetworkException networkException = new NetworkException("Network error", new Exception()); + Response successResponse = createSuccessResponse(); + + when(mockTransport.send(any(Request.class))) + .thenThrow(networkException) + .thenReturn(successResponse); + + RetryConfig retryConfig = RetryConfig.builder() + .enabled(true) + .maxRetries(2) + .baseDelayMs(10) + .build(); + + ChargebeeClient client = ChargebeeClient.builder() + .apiKey(TEST_API_KEY) + .siteName(TEST_SITE) + .transport(mockTransport) + .retry(retryConfig) + .build(); + + Request request = Request.builder() + .method("GET") + .url("http://test.com") + .build(); + + Response response = client.sendWithRetry(request); + + assertEquals(200, response.getStatusCode()); + verify(mockTransport, times(2)).send(any(Request.class)); + } + + @Test + @DisplayName("should fail after max retries on TimeoutException") + void shouldFailAfterMaxRetriesOnTimeout() throws Exception { + Transport mockTransport = mock(Transport.class); + TimeoutException timeoutException = new TimeoutException("read", "Timeout", new Exception()); + + when(mockTransport.send(any(Request.class))).thenThrow(timeoutException); + + RetryConfig retryConfig = RetryConfig.builder() + .enabled(true) + .maxRetries(2) + .baseDelayMs(10) + .build(); + + ChargebeeClient client = ChargebeeClient.builder() + .apiKey(TEST_API_KEY) + .siteName(TEST_SITE) + .transport(mockTransport) + .retry(retryConfig) + .build(); + + Request request = Request.builder() + .method("GET") + .url("http://test.com") + .build(); + + TimeoutException thrown = assertThrows(TimeoutException.class, + () -> client.sendWithRetry(request)); + + assertEquals("Timeout", thrown.getMessage()); + verify(mockTransport, times(3)).send(any(Request.class)); // Initial + 2 retries + } + + @Test + @DisplayName("should fail after max retries on NetworkException") + void shouldFailAfterMaxRetriesOnNetworkException() throws Exception { + Transport mockTransport = mock(Transport.class); + NetworkException networkException = new NetworkException("Network error", new Exception()); + + when(mockTransport.send(any(Request.class))).thenThrow(networkException); + + RetryConfig retryConfig = RetryConfig.builder() + .enabled(true) + .maxRetries(3) + .baseDelayMs(10) + .build(); + + ChargebeeClient client = ChargebeeClient.builder() + .apiKey(TEST_API_KEY) + .siteName(TEST_SITE) + .transport(mockTransport) + .retry(retryConfig) + .build(); + + Request request = Request.builder() + .method("GET") + .url("http://test.com") + .build(); + + NetworkException thrown = assertThrows(NetworkException.class, + () -> client.sendWithRetry(request)); + + assertEquals("Network error", thrown.getMessage()); + verify(mockTransport, times(4)).send(any(Request.class)); // Initial + 3 retries + } + } + + @Nested + @DisplayName("sendWithRetry - Retry on Status Codes") + class RetryOnStatusCodesTests { + + @Test + @DisplayName("should retry on 429 Too Many Requests") + void shouldRetryOn429() throws Exception { + Transport mockTransport = mock(Transport.class); + Response tooManyRequests = createResponse(429); + Response successResponse = createSuccessResponse(); + + when(mockTransport.send(any(Request.class))) + .thenReturn(tooManyRequests) + .thenReturn(successResponse); + + RetryConfig retryConfig = RetryConfig.builder() + .enabled(true) + .maxRetries(2) + .baseDelayMs(10) + .retryOnStatus(new HashSet<>(Arrays.asList(429))) + .build(); + + ChargebeeClient client = ChargebeeClient.builder() + .apiKey(TEST_API_KEY) + .siteName(TEST_SITE) + .transport(mockTransport) + .retry(retryConfig) + .build(); + + Request request = Request.builder() + .method("GET") + .url("http://test.com") + .build(); + + Response response = client.sendWithRetry(request); + + assertEquals(200, response.getStatusCode()); + verify(mockTransport, times(2)).send(any(Request.class)); + } + + @Test + @DisplayName("should retry on 502 Bad Gateway") + void shouldRetryOn502() throws Exception { + Transport mockTransport = mock(Transport.class); + Response badGateway = createResponse(502); + Response successResponse = createSuccessResponse(); + + when(mockTransport.send(any(Request.class))) + .thenReturn(badGateway) + .thenReturn(successResponse); + + RetryConfig retryConfig = RetryConfig.builder() + .enabled(true) + .maxRetries(2) + .baseDelayMs(10) + .retryOnStatus(new HashSet<>(Arrays.asList(502))) + .build(); + + ChargebeeClient client = ChargebeeClient.builder() + .apiKey(TEST_API_KEY) + .siteName(TEST_SITE) + .transport(mockTransport) + .retry(retryConfig) + .build(); + + Request request = Request.builder() + .method("GET") + .url("http://test.com") + .build(); + + Response response = client.sendWithRetry(request); + + assertEquals(200, response.getStatusCode()); + verify(mockTransport, times(2)).send(any(Request.class)); + } + + @Test + @DisplayName("should retry on 503 Service Unavailable") + void shouldRetryOn503() throws Exception { + Transport mockTransport = mock(Transport.class); + Response serviceUnavailable = createResponse(503); + Response successResponse = createSuccessResponse(); + + when(mockTransport.send(any(Request.class))) + .thenReturn(serviceUnavailable) + .thenReturn(successResponse); + + RetryConfig retryConfig = RetryConfig.builder() + .enabled(true) + .maxRetries(2) + .baseDelayMs(10) + .retryOnStatus(new HashSet<>(Arrays.asList(503))) + .build(); + + ChargebeeClient client = ChargebeeClient.builder() + .apiKey(TEST_API_KEY) + .siteName(TEST_SITE) + .transport(mockTransport) + .retry(retryConfig) + .build(); + + Request request = Request.builder() + .method("GET") + .url("http://test.com") + .build(); + + Response response = client.sendWithRetry(request); + + assertEquals(200, response.getStatusCode()); + verify(mockTransport, times(2)).send(any(Request.class)); + } + + @Test + @DisplayName("should retry on 504 Gateway Timeout") + void shouldRetryOn504() throws Exception { + Transport mockTransport = mock(Transport.class); + Response gatewayTimeout = createResponse(504); + Response successResponse = createSuccessResponse(); + + when(mockTransport.send(any(Request.class))) + .thenReturn(gatewayTimeout) + .thenReturn(successResponse); + + RetryConfig retryConfig = RetryConfig.builder() + .enabled(true) + .maxRetries(2) + .baseDelayMs(10) + .retryOnStatus(new HashSet<>(Arrays.asList(504))) + .build(); + + ChargebeeClient client = ChargebeeClient.builder() + .apiKey(TEST_API_KEY) + .siteName(TEST_SITE) + .transport(mockTransport) + .retry(retryConfig) + .build(); + + Request request = Request.builder() + .method("GET") + .url("http://test.com") + .build(); + + Response response = client.sendWithRetry(request); + + assertEquals(200, response.getStatusCode()); + verify(mockTransport, times(2)).send(any(Request.class)); + } + + @Test + @DisplayName("should not retry on 400 Bad Request") + void shouldNotRetryOn400() throws Exception { + Transport mockTransport = mock(Transport.class); + Response badRequest = createResponse(400); + + when(mockTransport.send(any(Request.class))).thenReturn(badRequest); + + RetryConfig retryConfig = RetryConfig.builder() + .enabled(true) + .maxRetries(2) + .baseDelayMs(10) + .retryOnStatus(new HashSet<>(Arrays.asList(429, 502, 503, 504))) // Not 400 + .build(); + + ChargebeeClient client = ChargebeeClient.builder() + .apiKey(TEST_API_KEY) + .siteName(TEST_SITE) + .transport(mockTransport) + .retry(retryConfig) + .build(); + + Request request = Request.builder() + .method("GET") + .url("http://test.com") + .build(); + + Response response = client.sendWithRetry(request); + + assertEquals(400, response.getStatusCode()); + verify(mockTransport, times(1)).send(any(Request.class)); // No retries + } + + @Test + @DisplayName("should return retryable status code after max retries") + void shouldReturnRetryableStatusAfterMaxRetries() throws Exception { + Transport mockTransport = mock(Transport.class); + Response tooManyRequests = createResponse(429); + + when(mockTransport.send(any(Request.class))).thenReturn(tooManyRequests); + + RetryConfig retryConfig = RetryConfig.builder() + .enabled(true) + .maxRetries(2) + .baseDelayMs(10) + .retryOnStatus(new HashSet<>(Arrays.asList(429))) + .build(); + + ChargebeeClient client = ChargebeeClient.builder() + .apiKey(TEST_API_KEY) + .siteName(TEST_SITE) + .transport(mockTransport) + .retry(retryConfig) + .build(); + + Request request = Request.builder() + .method("GET") + .url("http://test.com") + .build(); + + Response response = client.sendWithRetry(request); + + assertEquals(429, response.getStatusCode()); + verify(mockTransport, times(3)).send(any(Request.class)); // Initial + 2 retries + } + } + + @Nested + @DisplayName("sendWithRetry - Non-Retryable Exceptions") + class NonRetryableExceptionsTests { + + @Test + @DisplayName("should not retry on ConfigurationException") + void shouldNotRetryOnConfigurationException() throws Exception { + Transport mockTransport = mock(Transport.class); + ConfigurationException configException = new ConfigurationException("Invalid config"); + + when(mockTransport.send(any(Request.class))).thenThrow(configException); + + RetryConfig retryConfig = RetryConfig.builder() + .enabled(true) + .maxRetries(3) + .baseDelayMs(10) + .build(); + + ChargebeeClient client = ChargebeeClient.builder() + .apiKey(TEST_API_KEY) + .siteName(TEST_SITE) + .transport(mockTransport) + .retry(retryConfig) + .build(); + + Request request = Request.builder() + .method("GET") + .url("http://test.com") + .build(); + + ConfigurationException thrown = assertThrows(ConfigurationException.class, + () -> client.sendWithRetry(request)); + + assertEquals("Invalid config", thrown.getMessage()); + verify(mockTransport, times(1)).send(any(Request.class)); // No retries + } + + @Test + @DisplayName("should not retry on ClientErrorException") + void shouldNotRetryOnClientErrorException() throws Exception { + Transport mockTransport = mock(Transport.class); + Response errorResponse = createResponse(400); + ClientErrorException clientError = new ClientErrorException(400, "Bad Request", errorResponse); + + when(mockTransport.send(any(Request.class))).thenThrow(clientError); + + RetryConfig retryConfig = RetryConfig.builder() + .enabled(true) + .maxRetries(3) + .baseDelayMs(10) + .build(); + + ChargebeeClient client = ChargebeeClient.builder() + .apiKey(TEST_API_KEY) + .siteName(TEST_SITE) + .transport(mockTransport) + .retry(retryConfig) + .build(); + + Request request = Request.builder() + .method("GET") + .url("http://test.com") + .build(); + + ClientErrorException thrown = assertThrows(ClientErrorException.class, + () -> client.sendWithRetry(request)); + + assertEquals(400, thrown.getStatusCode()); + verify(mockTransport, times(1)).send(any(Request.class)); // No retries + } + } + + @Nested + @DisplayName("sendWithRetry - Request Override Tests") + class RequestOverrideTests { + + @Test + @DisplayName("should use request override for max retries") + void shouldUseRequestOverrideForMaxRetries() throws Exception { + Transport mockTransport = mock(Transport.class); + NetworkException networkException = new NetworkException("Network error", new Exception()); + Response successResponse = createSuccessResponse(); + + when(mockTransport.send(any(Request.class))) + .thenThrow(networkException) + .thenThrow(networkException) + .thenThrow(networkException) + .thenReturn(successResponse); + + RetryConfig retryConfig = RetryConfig.builder() + .enabled(true) + .maxRetries(1) // Default is 1 retry + .baseDelayMs(10) + .build(); + + ChargebeeClient client = ChargebeeClient.builder() + .apiKey(TEST_API_KEY) + .siteName(TEST_SITE) + .transport(mockTransport) + .retry(retryConfig) + .build(); + + Request request = Request.builder() + .method("GET") + .url("http://test.com") + .maxNetworkRetriesOverride(3) // Override to 3 retries + .build(); + + Response response = client.sendWithRetry(request); + + assertEquals(200, response.getStatusCode()); + verify(mockTransport, times(4)).send(any(Request.class)); // Initial + 3 retries + } + + @Test + @DisplayName("should enable retry when override is set even if config disabled") + void shouldEnableRetryWithOverride() throws Exception { + Transport mockTransport = mock(Transport.class); + NetworkException networkException = new NetworkException("Network error", new Exception()); + Response successResponse = createSuccessResponse(); + + when(mockTransport.send(any(Request.class))) + .thenThrow(networkException) + .thenReturn(successResponse); + + RetryConfig retryConfig = RetryConfig.builder() + .enabled(false) // Retry disabled in config + .build(); + + ChargebeeClient client = ChargebeeClient.builder() + .apiKey(TEST_API_KEY) + .siteName(TEST_SITE) + .transport(mockTransport) + .retry(retryConfig) + .build(); + + Request request = Request.builder() + .method("GET") + .url("http://test.com") + .maxNetworkRetriesOverride(2) // Enable via override + .build(); + + Response response = client.sendWithRetry(request); + + assertEquals(200, response.getStatusCode()); + verify(mockTransport, times(2)).send(any(Request.class)); + } + + @Test + @DisplayName("should disable retry when override is 0") + void shouldDisableRetryWithZeroOverride() throws Exception { + Transport mockTransport = mock(Transport.class); + NetworkException networkException = new NetworkException("Network error", new Exception()); + + when(mockTransport.send(any(Request.class))).thenThrow(networkException); + + RetryConfig retryConfig = RetryConfig.builder() + .enabled(true) + .maxRetries(3) // Enabled in config + .baseDelayMs(10) + .build(); + + ChargebeeClient client = ChargebeeClient.builder() + .apiKey(TEST_API_KEY) + .siteName(TEST_SITE) + .transport(mockTransport) + .retry(retryConfig) + .build(); + + Request request = Request.builder() + .method("GET") + .url("http://test.com") + .maxNetworkRetriesOverride(0) // Disable retries for this request + .build(); + + NetworkException thrown = assertThrows(NetworkException.class, + () -> client.sendWithRetry(request)); + + assertNotNull(thrown); + verify(mockTransport, times(1)).send(any(Request.class)); // No retries + } + } + + @Nested + @DisplayName("sendWithRetryAsync - Async Retry Tests") + class AsyncRetryTests { + + @Test + @DisplayName("should retry async request on TimeoutException and succeed") + void shouldRetryAsyncOnTimeoutAndSucceed() throws Exception { + Transport mockTransport = mock(Transport.class); + TimeoutException timeoutException = new TimeoutException("read", "Timeout", new Exception()); + Response successResponse = createSuccessResponse(); + + CompletableFuture failedFuture = new CompletableFuture<>(); + failedFuture.completeExceptionally(timeoutException); + + when(mockTransport.sendAsync(any(Request.class))) + .thenReturn(failedFuture) + .thenReturn(CompletableFuture.completedFuture(successResponse)); + + RetryConfig retryConfig = RetryConfig.builder() + .enabled(true) + .maxRetries(2) + .baseDelayMs(10) + .build(); + + ChargebeeClient client = ChargebeeClient.builder() + .apiKey(TEST_API_KEY) + .siteName(TEST_SITE) + .transport(mockTransport) + .retry(retryConfig) + .build(); + + Request request = Request.builder() + .method("GET") + .url("http://test.com") + .build(); + + CompletableFuture future = client.sendWithRetryAsync(request); + Response response = future.get(); + + assertEquals(200, response.getStatusCode()); + verify(mockTransport, times(2)).sendAsync(any(Request.class)); + } + + @Test + @DisplayName("should retry async request on NetworkException and succeed") + void shouldRetryAsyncOnNetworkExceptionAndSucceed() throws Exception { + Transport mockTransport = mock(Transport.class); + NetworkException networkException = new NetworkException("Network error", new Exception()); + Response successResponse = createSuccessResponse(); + + CompletableFuture failedFuture = new CompletableFuture<>(); + failedFuture.completeExceptionally(networkException); + + when(mockTransport.sendAsync(any(Request.class))) + .thenReturn(failedFuture) + .thenReturn(CompletableFuture.completedFuture(successResponse)); + + RetryConfig retryConfig = RetryConfig.builder() + .enabled(true) + .maxRetries(2) + .baseDelayMs(10) + .build(); + + ChargebeeClient client = ChargebeeClient.builder() + .apiKey(TEST_API_KEY) + .siteName(TEST_SITE) + .transport(mockTransport) + .retry(retryConfig) + .build(); + + Request request = Request.builder() + .method("GET") + .url("http://test.com") + .build(); + + CompletableFuture future = client.sendWithRetryAsync(request); + Response response = future.get(); + + assertEquals(200, response.getStatusCode()); + verify(mockTransport, times(2)).sendAsync(any(Request.class)); + } + + @Test + @DisplayName("should fail async after max retries") + void shouldFailAsyncAfterMaxRetries() throws Exception { + Transport mockTransport = mock(Transport.class); + NetworkException networkException = new NetworkException("Network error", new Exception()); + + CompletableFuture failedFuture = new CompletableFuture<>(); + failedFuture.completeExceptionally(networkException); + + when(mockTransport.sendAsync(any(Request.class))) + .thenReturn(failedFuture); + + RetryConfig retryConfig = RetryConfig.builder() + .enabled(true) + .maxRetries(2) + .baseDelayMs(10) + .build(); + + ChargebeeClient client = ChargebeeClient.builder() + .apiKey(TEST_API_KEY) + .siteName(TEST_SITE) + .transport(mockTransport) + .retry(retryConfig) + .build(); + + Request request = Request.builder() + .method("GET") + .url("http://test.com") + .build(); + + CompletableFuture future = client.sendWithRetryAsync(request); + + ExecutionException exception = assertThrows(ExecutionException.class, () -> future.get()); + assertTrue(exception.getCause() instanceof NetworkException); + verify(mockTransport, times(3)).sendAsync(any(Request.class)); // Initial + 2 retries + } + + @Test + @DisplayName("should retry async on retryable status code") + void shouldRetryAsyncOnRetryableStatusCode() throws Exception { + Transport mockTransport = mock(Transport.class); + Response tooManyRequests = createResponse(429); + Response successResponse = createSuccessResponse(); + + when(mockTransport.sendAsync(any(Request.class))) + .thenReturn(CompletableFuture.completedFuture(tooManyRequests)) + .thenReturn(CompletableFuture.completedFuture(successResponse)); + + RetryConfig retryConfig = RetryConfig.builder() + .enabled(true) + .maxRetries(2) + .baseDelayMs(10) + .retryOnStatus(new HashSet<>(Arrays.asList(429))) + .build(); + + ChargebeeClient client = ChargebeeClient.builder() + .apiKey(TEST_API_KEY) + .siteName(TEST_SITE) + .transport(mockTransport) + .retry(retryConfig) + .build(); + + Request request = Request.builder() + .method("GET") + .url("http://test.com") + .build(); + + CompletableFuture future = client.sendWithRetryAsync(request); + Response response = future.get(); + + assertEquals(200, response.getStatusCode()); + verify(mockTransport, times(2)).sendAsync(any(Request.class)); + } + + @Test + @DisplayName("should not retry async when retry disabled") + void shouldNotRetryAsyncWhenDisabled() throws Exception { + Transport mockTransport = mock(Transport.class); + Response successResponse = createSuccessResponse(); + + when(mockTransport.sendAsync(any(Request.class))) + .thenReturn(CompletableFuture.completedFuture(successResponse)); + + RetryConfig noRetry = RetryConfig.builder().enabled(false).build(); + ChargebeeClient client = ChargebeeClient.builder() + .apiKey(TEST_API_KEY) + .siteName(TEST_SITE) + .transport(mockTransport) + .retry(noRetry) + .build(); + + Request request = Request.builder() + .method("GET") + .url("http://test.com") + .build(); + + CompletableFuture future = client.sendWithRetryAsync(request); + Response response = future.get(); + + assertEquals(200, response.getStatusCode()); + verify(mockTransport, times(1)).sendAsync(any(Request.class)); + } + + @Test + @DisplayName("should not retry async on non-retryable exception") + void shouldNotRetryAsyncOnNonRetryableException() throws Exception { + Transport mockTransport = mock(Transport.class); + ConfigurationException configException = new ConfigurationException("Invalid config"); + + CompletableFuture failedFuture = new CompletableFuture<>(); + failedFuture.completeExceptionally(configException); + + when(mockTransport.sendAsync(any(Request.class))) + .thenReturn(failedFuture); + + RetryConfig retryConfig = RetryConfig.builder() + .enabled(true) + .maxRetries(3) + .baseDelayMs(10) + .build(); + + ChargebeeClient client = ChargebeeClient.builder() + .apiKey(TEST_API_KEY) + .siteName(TEST_SITE) + .transport(mockTransport) + .retry(retryConfig) + .build(); + + Request request = Request.builder() + .method("GET") + .url("http://test.com") + .build(); + + CompletableFuture future = client.sendWithRetryAsync(request); + + ExecutionException exception = assertThrows(ExecutionException.class, () -> future.get()); + assertTrue(exception.getCause() instanceof ConfigurationException); + verify(mockTransport, times(1)).sendAsync(any(Request.class)); // No retries + } + } + + @Nested + @DisplayName("Backoff Delay Calculation Tests") + class BackoffDelayTests { + + @Test + @DisplayName("should calculate exponential backoff with increasing delays") + void shouldCalculateExponentialBackoff() throws Exception { + Transport mockTransport = mock(Transport.class); + NetworkException networkException = new NetworkException("Network error", new Exception()); + Response successResponse = createSuccessResponse(); + + AtomicInteger attemptCount = new AtomicInteger(0); + List timestamps = new ArrayList<>(); + + when(mockTransport.send(any(Request.class))).thenAnswer(invocation -> { + timestamps.add(System.currentTimeMillis()); + int attempt = attemptCount.getAndIncrement(); + if (attempt < 3) { + throw networkException; + } + return successResponse; + }); + + RetryConfig retryConfig = RetryConfig.builder() + .enabled(true) + .maxRetries(3) + .baseDelayMs(50) // 50ms base delay + .build(); + + ChargebeeClient client = ChargebeeClient.builder() + .apiKey(TEST_API_KEY) + .siteName(TEST_SITE) + .transport(mockTransport) + .retry(retryConfig) + .build(); + + Request request = Request.builder() + .method("GET") + .url("http://test.com") + .build(); + + long start = System.currentTimeMillis(); + Response response = client.sendWithRetry(request); + long duration = System.currentTimeMillis() - start; + + assertEquals(200, response.getStatusCode()); + verify(mockTransport, times(4)).send(any(Request.class)); + + // Verify that delays were applied (approximate check) + // Expected delays: ~50ms, ~100ms, ~200ms = ~350ms total (with jitter) + assertTrue(duration >= 200, "Duration too short: " + duration + "ms"); // Allow for jitter + assertTrue(duration < 1000, "Duration too long: " + duration + "ms"); + } + + @Test + @DisplayName("should apply backoff delays between retries") + void shouldApplyBackoffDelays() throws Exception { + Transport mockTransport = mock(Transport.class); + TimeoutException timeoutException = new TimeoutException("read", "Timeout", new Exception()); + Response successResponse = createSuccessResponse(); + + when(mockTransport.send(any(Request.class))) + .thenThrow(timeoutException) + .thenThrow(timeoutException) + .thenReturn(successResponse); + + RetryConfig retryConfig = RetryConfig.builder() + .enabled(true) + .maxRetries(2) + .baseDelayMs(100) + .build(); + + ChargebeeClient client = ChargebeeClient.builder() + .apiKey(TEST_API_KEY) + .siteName(TEST_SITE) + .transport(mockTransport) + .retry(retryConfig) + .build(); + + Request request = Request.builder() + .method("GET") + .url("http://test.com") + .build(); + + long start = System.currentTimeMillis(); + Response response = client.sendWithRetry(request); + long duration = System.currentTimeMillis() - start; + + assertEquals(200, response.getStatusCode()); + // Should take at least 100ms (first retry) + 200ms (second retry) = 300ms + assertTrue(duration >= 200, "Duration too short, backoff not applied: " + duration + "ms"); + } + } + + // Helper methods + + private Response createSuccessResponse() { + return new Response(200, new HashMap<>(), "OK".getBytes()); + } + + private Response createResponse(int statusCode) { + Map> headers = new HashMap<>(); + headers.put("Content-Type", Arrays.asList("application/json")); + String body = String.format("{\"status\":%d}", statusCode); + return new Response(statusCode, headers, body.getBytes()); + } +} + diff --git a/src/test/java/com/chargebee/v4/transport/DefaultTransportTest.java b/src/test/java/com/chargebee/v4/transport/DefaultTransportTest.java new file mode 100644 index 00000000..39a4ae5e --- /dev/null +++ b/src/test/java/com/chargebee/v4/transport/DefaultTransportTest.java @@ -0,0 +1,1543 @@ +package com.chargebee.v4.transport; + +import org.junit.jupiter.api.*; +import org.mockito.*; + +import com.sun.net.httpserver.HttpServer; + +import java.io.*; +import java.net.*; +import java.nio.charset.StandardCharsets; +import java.util.*; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.ExecutionException; +import java.util.zip.GZIPOutputStream; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +@DisplayName("DefaultTransport Tests") +class DefaultTransportTest { + + private static final String TEST_API_KEY = "test_api_key"; + private static final String TEST_URL = "https://test-site.chargebee.com/api/v4/customers"; + + private TransportConfig config; + private DefaultTransport transport; + + @BeforeEach + void setUp() { + config = TransportConfig.builder() + .apiKey(TEST_API_KEY) + .build(); + transport = new DefaultTransport(config); + } + + @Nested + @DisplayName("Constructor Tests") + class ConstructorTests { + + @Test + @DisplayName("should create transport with valid config") + void shouldCreateTransportWithValidConfig() { + assertNotNull(transport); + } + + @Test + @DisplayName("should throw NullPointerException when config is null") + void shouldThrowWhenConfigIsNull() { + assertThrows(NullPointerException.class, () -> new DefaultTransport(null)); + } + } + + @Nested + @DisplayName("Builder Tests") + class BuilderTests { + + @Test + @DisplayName("should build transport with all parameters") + void shouldBuildWithAllParameters() { + Map headers = new HashMap<>(); + headers.put("X-Custom", "value"); + + DefaultTransport transport = DefaultTransport.builder() + .apiKey(TEST_API_KEY) + .connectTimeout(5000) + .readTimeout(10000) + .defaultHeader("X-Test", "test") + .defaultHeaders(headers) + .followRedirects(false) + .gzipCompression(false) + .maxConnections(10) + .keepAliveDuration(60000) + .build(); + + assertNotNull(transport); + } + + @Test + @DisplayName("should use default values when not specified") + void shouldUseDefaultValues() { + DefaultTransport transport = DefaultTransport.builder() + .apiKey(TEST_API_KEY) + .build(); + + assertNotNull(transport); + } + + @Test + @DisplayName("should throw when apiKey is missing") + void shouldThrowWhenApiKeyMissing() { + assertThrows(IllegalArgumentException.class, () -> + DefaultTransport.builder().build() + ); + } + } + + @Nested + @DisplayName("Synchronous Send Tests") + class SynchronousSendTests { + + @Test + @DisplayName("should send GET request successfully using real HTTP") + void shouldSendGetRequestSuccessfully() throws Exception { + // Use a real HTTP test server endpoint + TestHttpServer server = new TestHttpServer(); + server.start(); + + try { + Request request = Request.builder() + .method("GET") + .url(server.getUrl()) + .build(); + + Response response = transport.send(request); + + assertNotNull(response); + assertEquals(200, response.getStatusCode()); + assertTrue(response.isSuccessful()); + } finally { + server.stop(); + } + } + + @Test + @DisplayName("should send POST request with JSON body") + void shouldSendPostRequestWithJsonBody() throws Exception { + TestHttpServer server = new TestHttpServer(); + server.start(); + + try { + Request request = Request.builder() + .method("POST") + .url(server.getUrl()) + .jsonBody("{\"name\":\"test\"}") + .build(); + + Response response = transport.send(request); + + assertNotNull(response); + assertEquals(200, response.getStatusCode()); + } finally { + server.stop(); + } + } + + @Test + @DisplayName("should send request with query parameters") + void shouldSendRequestWithQueryParameters() throws Exception { + TestHttpServer server = new TestHttpServer(); + server.start(); + + try { + Request request = Request.builder() + .method("GET") + .url(server.getUrl()) + .queryParam("limit", "10") + .queryParam("offset", "0") + .queryParam("filter", "active") + .build(); + + Response response = transport.send(request); + + assertNotNull(response); + String requestUrl = server.getLastRequestUrl(); + assertTrue(requestUrl.contains("limit=10")); + assertTrue(requestUrl.contains("offset=0")); + } finally { + server.stop(); + } + } + + @Test + @DisplayName("should send request with custom headers") + void shouldSendRequestWithCustomHeaders() throws Exception { + TestHttpServer server = new TestHttpServer(); + server.start(); + + try { + Request request = Request.builder() + .method("GET") + .url(server.getUrl()) + .header("X-Custom-Header", "test-value") + .header("X-Request-Id", "12345") + .build(); + + Response response = transport.send(request); + + assertNotNull(response); + Map receivedHeaders = server.getLastRequestHeaders(); + assertEquals("test-value", receivedHeaders.get("X-Custom-Header")); + assertEquals("12345", receivedHeaders.get("X-Request-Id")); + } finally { + server.stop(); + } + } + + @Test + @DisplayName("should handle 400 client error") + void shouldHandle400ClientError() throws Exception { + TestHttpServer server = new TestHttpServer(); + server.setResponseCode(400); + server.setResponseBody("{\"message\":\"Bad Request\"}"); + server.start(); + + try { + Request request = Request.builder() + .method("GET") + .url(server.getUrl()) + .build(); + + ClientErrorException exception = assertThrows(ClientErrorException.class, + () -> transport.send(request)); + + assertEquals(400, exception.getStatusCode()); + assertNotNull(exception.getResponse()); + } finally { + server.stop(); + } + } + + @Test + @DisplayName("should handle 401 unauthorized error") + void shouldHandle401UnauthorizedError() throws Exception { + TestHttpServer server = new TestHttpServer(); + server.setResponseCode(401); + server.setResponseBody("{\"message\":\"Unauthorized\"}"); + server.start(); + + try { + Request request = Request.builder() + .method("GET") + .url(server.getUrl()) + .build(); + + ClientErrorException exception = assertThrows(ClientErrorException.class, + () -> transport.send(request)); + + assertEquals(401, exception.getStatusCode()); + } finally { + server.stop(); + } + } + + @Test + @DisplayName("should handle 404 not found error") + void shouldHandle404NotFoundError() throws Exception { + TestHttpServer server = new TestHttpServer(); + server.setResponseCode(404); + server.setResponseBody("{\"message\":\"Not Found\"}"); + server.start(); + + try { + Request request = Request.builder() + .method("GET") + .url(server.getUrl()) + .build(); + + ClientErrorException exception = assertThrows(ClientErrorException.class, + () -> transport.send(request)); + + assertEquals(404, exception.getStatusCode()); + } finally { + server.stop(); + } + } + + @Test + @DisplayName("should handle 500 server error") + void shouldHandle500ServerError() throws Exception { + TestHttpServer server = new TestHttpServer(); + server.setResponseCode(500); + server.setResponseBody("{\"message\":\"Internal Server Error\"}"); + server.start(); + + try { + Request request = Request.builder() + .method("GET") + .url(server.getUrl()) + .build(); + + ServerErrorException exception = assertThrows(ServerErrorException.class, + () -> transport.send(request)); + + assertEquals(500, exception.getStatusCode()); + } finally { + server.stop(); + } + } + + @Test + @DisplayName("should handle 503 service unavailable error") + void shouldHandle503ServiceUnavailableError() throws Exception { + TestHttpServer server = new TestHttpServer(); + server.setResponseCode(503); + server.setResponseBody("{\"message\":\"Service Unavailable\"}"); + server.start(); + + try { + Request request = Request.builder() + .method("GET") + .url(server.getUrl()) + .build(); + + ServerErrorException exception = assertThrows(ServerErrorException.class, + () -> transport.send(request)); + + assertEquals(503, exception.getStatusCode()); + } finally { + server.stop(); + } + } + + @Test + @DisplayName("should handle connection timeout") + @Timeout(5) // Prevent test from hanging - fail after 5 seconds + void shouldHandleConnectionTimeout() { + // Use a non-routable IP address to trigger timeout + TransportConfig timeoutConfig = TransportConfig.builder() + .apiKey(TEST_API_KEY) + .connectTimeout(1000) // 1 second timeout + .build(); + + DefaultTransport timeoutTransport = new DefaultTransport(timeoutConfig); + + // Use TEST-NET-1 reserved IP from RFC 5737 (should drop packets) + Request request = Request.builder() + .method("GET") + .url("http://192.0.2.1:81") // Non-routable test IP + .build(); + + assertThrows(TransportException.class, () -> timeoutTransport.send(request)); + } + + @Test + @DisplayName("should handle invalid URL") + void shouldHandleInvalidUrl() { + Request request = Request.builder() + .method("GET") + .url("not-a-valid-url") + .build(); + + ConfigurationException exception = assertThrows(ConfigurationException.class, + () -> transport.send(request)); + + assertTrue(exception.getMessage().contains("Invalid URL")); + } + + @Test + @DisplayName("should handle unknown host") + @Timeout(10) // Prevent test from hanging if DNS resolution is slow + void shouldHandleUnknownHost() { + Request request = Request.builder() + .method("GET") + .url("http://this-domain-does-not-exist-12345678.com") + .build(); + + NetworkException exception = assertThrows(NetworkException.class, + () -> transport.send(request)); + + assertNotNull(exception.getCause()); + assertTrue(exception.getCause() instanceof UnknownHostException); + } + + @Test + @DisplayName("should handle GZIP compressed response") + void shouldHandleGzipCompressedResponse() throws Exception { + TestHttpServer server = new TestHttpServer(); + server.setGzipResponse(true); + server.setResponseBody("Compressed content"); + server.start(); + + try { + TransportConfig gzipConfig = TransportConfig.builder() + .apiKey(TEST_API_KEY) + .gzipCompression(true) + .build(); + + DefaultTransport gzipTransport = new DefaultTransport(gzipConfig); + + Request request = Request.builder() + .method("GET") + .url(server.getUrl()) + .build(); + + Response response = gzipTransport.send(request); + + assertEquals(200, response.getStatusCode()); + assertEquals("Compressed content", response.getBodyAsString()); + } finally { + server.stop(); + } + } + + @Test + @DisplayName("should respect follow redirects setting") + void shouldRespectFollowRedirectsSetting() throws Exception { + TestHttpServer server = new TestHttpServer(); + server.setResponseCode(302); + server.start(); + + try { + Request request = Request.builder() + .method("GET") + .url(server.getUrl()) + .followRedirectsOverride(false) + .build(); + + Response response = transport.send(request); + + // With redirects disabled, we should get 302 + // Note: HttpURLConnection may still follow redirects in some cases + assertTrue(response.getStatusCode() >= 200); + } finally { + server.stop(); + } + } + + @Test + @DisplayName("should send Content-Type from body") + void shouldSendContentTypeFromBody() throws Exception { + TestHttpServer server = new TestHttpServer(); + server.start(); + + try { + Request request = Request.builder() + .method("POST") + .url(server.getUrl()) + .jsonBody("{\"test\":\"data\"}") + .build(); + + transport.send(request); + + Map headers = server.getLastRequestHeaders(); + assertTrue(headers.get("Content-Type").contains("application/json")); + } finally { + server.stop(); + } + } + + @Test + @DisplayName("should send custom Content-Type header") + void shouldSendCustomContentTypeHeader() throws Exception { + TestHttpServer server = new TestHttpServer(); + server.start(); + + try { + Request request = Request.builder() + .method("POST") + .url(server.getUrl()) + .header("Content-Type", "text/plain") + .rawBody("plain text".getBytes(), "text/plain") + .build(); + + transport.send(request); + + Map headers = server.getLastRequestHeaders(); + assertEquals("text/plain", headers.get("Content-Type")); + } finally { + server.stop(); + } + } + } + + @Nested + @DisplayName("Asynchronous Send Tests") + class AsynchronousSendTests { + + @Test + @DisplayName("should send async request successfully") + void shouldSendAsyncRequestSuccessfully() throws Exception { + TestHttpServer server = new TestHttpServer(); + server.start(); + + try { + Request request = Request.builder() + .method("GET") + .url(server.getUrl()) + .build(); + + CompletableFuture future = transport.sendAsync(request); + + Response response = future.get(); + + assertNotNull(response); + assertEquals(200, response.getStatusCode()); + } finally { + server.stop(); + } + } + + @Test + @DisplayName("should handle async request error") + void shouldHandleAsyncRequestError() throws Exception { + TestHttpServer server = new TestHttpServer(); + server.setResponseCode(500); + server.start(); + + try { + Request request = Request.builder() + .method("GET") + .url(server.getUrl()) + .build(); + + CompletableFuture future = transport.sendAsync(request); + + ExecutionException exception = assertThrows(ExecutionException.class, + () -> future.get()); + + assertTrue(exception.getCause() instanceof ServerErrorException); + } finally { + server.stop(); + } + } + + @Test + @DisplayName("should handle async network error") + @Timeout(10) // Prevent test from hanging + void shouldHandleAsyncNetworkError() throws Exception { + Request request = Request.builder() + .method("GET") + .url("http://invalid-host-12345.test") + .build(); + + CompletableFuture future = transport.sendAsync(request); + + ExecutionException exception = assertThrows(ExecutionException.class, + () -> future.get()); + + assertTrue(exception.getCause() instanceof NetworkException); + } + } + + @Nested + @DisplayName("Request Logger Tests") + class RequestLoggerTests { + + @Test + @DisplayName("should log successful request and response") + void shouldLogSuccessfulRequestAndResponse() throws Exception { + RequestLogger logger = mock(RequestLogger.class); + when(logger.isEnabled()).thenReturn(true); + + TransportConfig logConfig = TransportConfig.builder() + .apiKey(TEST_API_KEY) + .requestLogger(logger) + .build(); + + DefaultTransport logTransport = new DefaultTransport(logConfig); + + TestHttpServer server = new TestHttpServer(); + server.start(); + + try { + Request request = Request.builder() + .method("GET") + .url(server.getUrl()) + .build(); + + logTransport.send(request); + + verify(logger, times(1)).logRequest(any(Request.class)); + verify(logger, times(1)).logResponse(any(Request.class), any(Response.class), anyLong()); + verify(logger, never()).logError(any(Request.class), any(Throwable.class), anyLong()); + } finally { + server.stop(); + } + } + + @Test + @DisplayName("should log HTTP error") + void shouldLogHttpError() throws Exception { + RequestLogger logger = mock(RequestLogger.class); + when(logger.isEnabled()).thenReturn(true); + + TransportConfig logConfig = TransportConfig.builder() + .apiKey(TEST_API_KEY) + .requestLogger(logger) + .build(); + + DefaultTransport logTransport = new DefaultTransport(logConfig); + + TestHttpServer server = new TestHttpServer(); + server.setResponseCode(404); + server.start(); + + try { + Request request = Request.builder() + .method("GET") + .url(server.getUrl()) + .build(); + + try { + logTransport.send(request); + } catch (ClientErrorException e) { + // Expected + } + + verify(logger, times(1)).logRequest(any(Request.class)); + verify(logger, times(1)).logError(any(Request.class), any(HttpException.class), anyLong()); + } finally { + server.stop(); + } + } + + @Test + @DisplayName("should log network error") + @Timeout(10) // Prevent test from hanging + void shouldLogNetworkError() throws Exception { + RequestLogger logger = mock(RequestLogger.class); + when(logger.isEnabled()).thenReturn(true); + + TransportConfig logConfig = TransportConfig.builder() + .apiKey(TEST_API_KEY) + .requestLogger(logger) + .build(); + + DefaultTransport logTransport = new DefaultTransport(logConfig); + + Request request = Request.builder() + .method("GET") + .url("http://invalid-host-98765.test") + .build(); + + try { + logTransport.send(request); + } catch (NetworkException e) { + // Expected + } + + verify(logger, times(1)).logRequest(any(Request.class)); + verify(logger, times(1)).logError(any(Request.class), any(NetworkException.class), anyLong()); + } + + @Test + @DisplayName("should not log when logger is disabled") + void shouldNotLogWhenLoggerIsDisabled() throws Exception { + RequestLogger logger = mock(RequestLogger.class); + when(logger.isEnabled()).thenReturn(false); + + TransportConfig logConfig = TransportConfig.builder() + .apiKey(TEST_API_KEY) + .requestLogger(logger) + .build(); + + DefaultTransport logTransport = new DefaultTransport(logConfig); + + TestHttpServer server = new TestHttpServer(); + server.start(); + + try { + Request request = Request.builder() + .method("GET") + .url(server.getUrl()) + .build(); + + logTransport.send(request); + + verify(logger, never()).logRequest(any(Request.class)); + verify(logger, never()).logResponse(any(Request.class), any(Response.class), anyLong()); + } finally { + server.stop(); + } + } + + @Test + @DisplayName("should not log when logger is null") + void shouldNotLogWhenLoggerIsNull() throws Exception { + TransportConfig noLogConfig = TransportConfig.builder() + .apiKey(TEST_API_KEY) + .build(); + + DefaultTransport noLogTransport = new DefaultTransport(noLogConfig); + + TestHttpServer server = new TestHttpServer(); + server.start(); + + try { + Request request = Request.builder() + .method("GET") + .url(server.getUrl()) + .build(); + + // Should not throw exception even without logger + Response response = noLogTransport.send(request); + assertNotNull(response); + } finally { + server.stop(); + } + } + + @Test + @DisplayName("should log request with complete headers including defaults") + void shouldLogRequestWithCompleteHeaders() throws Exception { + RequestLogger logger = mock(RequestLogger.class); + when(logger.isEnabled()).thenReturn(true); + + Map defaultHeaders = new HashMap<>(); + defaultHeaders.put("X-Default-Header", "default-value"); + + TransportConfig logConfig = TransportConfig.builder() + .apiKey(TEST_API_KEY) + .defaultHeaders(defaultHeaders) + .requestLogger(logger) + .build(); + + DefaultTransport logTransport = new DefaultTransport(logConfig); + + TestHttpServer server = new TestHttpServer(); + server.start(); + + try { + Request request = Request.builder() + .method("GET") + .url(server.getUrl()) + .header("X-Custom-Header", "custom-value") + .build(); + + logTransport.send(request); + + ArgumentCaptor requestCaptor = ArgumentCaptor.forClass(Request.class); + verify(logger).logRequest(requestCaptor.capture()); + + Request loggedRequest = requestCaptor.getValue(); + assertNotNull(loggedRequest); + assertTrue(loggedRequest.getHeaders().containsKey("Authorization")); + assertTrue(loggedRequest.getHeaders().containsKey("User-Agent")); + } finally { + server.stop(); + } + } + + @Test + @DisplayName("should handle error when copying body for logging") + void shouldHandleErrorWhenCopyingBodyForLogging() throws Exception { + RequestLogger logger = mock(RequestLogger.class); + when(logger.isEnabled()).thenReturn(true); + + TransportConfig logConfig = TransportConfig.builder() + .apiKey(TEST_API_KEY) + .requestLogger(logger) + .build(); + + DefaultTransport logTransport = new DefaultTransport(logConfig); + + TestHttpServer server = new TestHttpServer(); + server.start(); + + try { + // Create a request body that throws exception + RequestBody errorBody = new RequestBody() { + @Override + public String getContentType() { + return "application/json"; + } + + @Override + public byte[] getBytes() throws IOException { + throw new IOException("Simulated body read error"); + } + }; + + Request request = Request.builder() + .method("POST") + .url(server.getUrl()) + .build(); + + // This should still work despite body copy error + logTransport.send(request); + + verify(logger, times(1)).logRequest(any(Request.class)); + } finally { + server.stop(); + } + } + } + + @Nested + @DisplayName("Header Tests") + class HeaderTests { + + @Test + @DisplayName("should add API key authorization header") + void shouldAddApiKeyAuthorizationHeader() throws Exception { + TestHttpServer server = new TestHttpServer(); + server.start(); + + try { + Request request = Request.builder() + .method("GET") + .url(server.getUrl()) + .build(); + + transport.send(request); + + Map headers = server.getLastRequestHeaders(); + assertTrue(headers.containsKey("Authorization")); + assertTrue(headers.get("Authorization").startsWith("Basic ")); + } finally { + server.stop(); + } + } + + @Test + @DisplayName("should add default headers") + void shouldAddDefaultHeaders() throws Exception { + TestHttpServer server = new TestHttpServer(); + server.start(); + + try { + Request request = Request.builder() + .method("GET") + .url(server.getUrl()) + .build(); + + transport.send(request); + + Map headers = server.getLastRequestHeaders(); + assertTrue(headers.containsKey("User-Agent")); + assertTrue(headers.get("User-Agent").contains("Chargebee-Java-Client")); + assertEquals("UTF-8", headers.get("Accept-Charset")); + assertEquals("application/json", headers.get("Accept")); + } finally { + server.stop(); + } + } + + @Test + @DisplayName("should add GZIP accept-encoding when enabled") + void shouldAddGzipAcceptEncodingWhenEnabled() throws Exception { + TransportConfig gzipConfig = TransportConfig.builder() + .apiKey(TEST_API_KEY) + .gzipCompression(true) + .build(); + + DefaultTransport gzipTransport = new DefaultTransport(gzipConfig); + + TestHttpServer server = new TestHttpServer(); + server.start(); + + try { + Request request = Request.builder() + .method("GET") + .url(server.getUrl()) + .build(); + + gzipTransport.send(request); + + Map headers = server.getLastRequestHeaders(); + assertEquals("gzip", headers.get("Accept-Encoding")); + } finally { + server.stop(); + } + } + + @Test + @DisplayName("should not add GZIP accept-encoding when disabled") + void shouldNotAddGzipAcceptEncodingWhenDisabled() throws Exception { + TransportConfig noGzipConfig = TransportConfig.builder() + .apiKey(TEST_API_KEY) + .gzipCompression(false) + .build(); + + DefaultTransport noGzipTransport = new DefaultTransport(noGzipConfig); + + TestHttpServer server = new TestHttpServer(); + server.start(); + + try { + Request request = Request.builder() + .method("GET") + .url(server.getUrl()) + .build(); + + noGzipTransport.send(request); + + Map headers = server.getLastRequestHeaders(); + assertNull(headers.get("Accept-Encoding")); + } finally { + server.stop(); + } + } + + @Test + @DisplayName("should add config default headers") + void shouldAddConfigDefaultHeaders() throws Exception { + Map defaultHeaders = new HashMap<>(); + defaultHeaders.put("X-Custom-1", "value1"); + defaultHeaders.put("X-Custom-2", "value2"); + + TransportConfig customConfig = TransportConfig.builder() + .apiKey(TEST_API_KEY) + .defaultHeaders(defaultHeaders) + .build(); + + DefaultTransport customTransport = new DefaultTransport(customConfig); + + TestHttpServer server = new TestHttpServer(); + server.start(); + + try { + Request request = Request.builder() + .method("GET") + .url(server.getUrl()) + .build(); + + customTransport.send(request); + + Map headers = server.getLastRequestHeaders(); + assertEquals("value1", headers.get("X-Custom-1")); + assertEquals("value2", headers.get("X-Custom-2")); + } finally { + server.stop(); + } + } + + @Test + @DisplayName("should allow request headers to override default headers") + void shouldAllowRequestHeadersToOverrideDefaultHeaders() throws Exception { + Map defaultHeaders = new HashMap<>(); + defaultHeaders.put("X-Override", "default"); + + TransportConfig customConfig = TransportConfig.builder() + .apiKey(TEST_API_KEY) + .defaultHeaders(defaultHeaders) + .build(); + + DefaultTransport customTransport = new DefaultTransport(customConfig); + + TestHttpServer server = new TestHttpServer(); + server.start(); + + try { + Request request = Request.builder() + .method("GET") + .url(server.getUrl()) + .header("X-Override", "custom") + .build(); + + customTransport.send(request); + + Map headers = server.getLastRequestHeaders(); + assertEquals("custom", headers.get("X-Override")); + } finally { + server.stop(); + } + } + + @Test + @DisplayName("should handle case-insensitive Content-Type check") + void shouldHandleCaseInsensitiveContentTypeCheck() throws Exception { + TestHttpServer server = new TestHttpServer(); + server.start(); + + try { + Request request = Request.builder() + .method("POST") + .url(server.getUrl()) + .header("content-type", "text/plain") // lowercase + .rawBody("test".getBytes(), "text/plain") + .build(); + + transport.send(request); + + Map headers = server.getLastRequestHeaders(); + assertNotNull(headers.get("content-type")); + } finally { + server.stop(); + } + } + } + + @Nested + @DisplayName("Request Body Tests") + class RequestBodyTests { + + @Test + @DisplayName("should send request with form body") + void shouldSendRequestWithFormBody() throws Exception { + TestHttpServer server = new TestHttpServer(); + server.start(); + + try { + Map formData = new HashMap<>(); + formData.put("name", "John"); + formData.put("email", "john@example.com"); + + Request request = Request.builder() + .method("POST") + .url(server.getUrl()) + .formBody(formData) + .build(); + + transport.send(request); + + String body = server.getLastRequestBody(); + assertTrue(body.contains("name=John")); + assertTrue(body.contains("email=john%40example.com")); + } finally { + server.stop(); + } + } + + @Test + @DisplayName("should send request with raw body") + void shouldSendRequestWithRawBody() throws Exception { + TestHttpServer server = new TestHttpServer(); + server.start(); + + try { + byte[] rawData = "raw binary data".getBytes(StandardCharsets.UTF_8); + + Request request = Request.builder() + .method("POST") + .url(server.getUrl()) + .rawBody(rawData, "application/octet-stream") + .build(); + + transport.send(request); + + String body = server.getLastRequestBody(); + assertEquals("raw binary data", body); + } finally { + server.stop(); + } + } + + @Test + @DisplayName("should send request without body") + void shouldSendRequestWithoutBody() throws Exception { + TestHttpServer server = new TestHttpServer(); + server.start(); + + try { + Request request = Request.builder() + .method("GET") + .url(server.getUrl()) + .build(); + + transport.send(request); + + String body = server.getLastRequestBody(); + assertTrue(body == null || body.isEmpty()); + } finally { + server.stop(); + } + } + + @Test + @DisplayName("should send request with empty body bytes") + void shouldSendRequestWithEmptyBodyBytes() throws Exception { + TestHttpServer server = new TestHttpServer(); + server.start(); + + try { + Request request = Request.builder() + .method("POST") + .url(server.getUrl()) + .rawBody(new byte[0], "text/plain") + .build(); + + Response response = transport.send(request); + + assertNotNull(response); + assertEquals(200, response.getStatusCode()); + } finally { + server.stop(); + } + } + } + + @Nested + @DisplayName("Timeout Tests") + class TimeoutTests { + + @Test + @DisplayName("should respect connect timeout setting") + @Timeout(5) // Prevent test from hanging - fail after 5 seconds + void shouldRespectConnectTimeoutSetting() { + TransportConfig timeoutConfig = TransportConfig.builder() + .apiKey(TEST_API_KEY) + .connectTimeout(1000) // 1 second timeout + .build(); + + DefaultTransport timeoutTransport = new DefaultTransport(timeoutConfig); + + // Use IP address that drops packets (TEST-NET-1 from RFC 5737) + // This should result in connection timeout + Request request = Request.builder() + .method("GET") + .url("http://192.0.2.1:81") // Non-routable test IP + .build(); + + long start = System.currentTimeMillis(); + assertThrows(TransportException.class, () -> { + timeoutTransport.send(request); + }); + + long duration = System.currentTimeMillis() - start; + // Should timeout within reasonable time (allow some buffer) + assertTrue(duration < 3000, "Timeout took too long: " + duration + "ms"); + } + + @Test + @DisplayName("should respect read timeout setting") + void shouldRespectReadTimeoutSetting() throws Exception { + TestHttpServer server = new TestHttpServer(); + server.setDelay(5000); // 5 second delay + server.start(); + + try { + TransportConfig timeoutConfig = TransportConfig.builder() + .apiKey(TEST_API_KEY) + .readTimeout(100) // Very short timeout + .build(); + + DefaultTransport timeoutTransport = new DefaultTransport(timeoutConfig); + + Request request = Request.builder() + .method("GET") + .url(server.getUrl()) + .build(); + + assertThrows(TimeoutException.class, () -> timeoutTransport.send(request)); + } finally { + server.stop(); + } + } + } + + @Nested + @DisplayName("Equals and HashCode Tests") + class EqualsAndHashCodeTests { + + @Test + @DisplayName("should be equal when configs are equal") + void shouldBeEqualWhenConfigsAreEqual() { + TransportConfig config1 = TransportConfig.builder() + .apiKey(TEST_API_KEY) + .build(); + + TransportConfig config2 = TransportConfig.builder() + .apiKey(TEST_API_KEY) + .build(); + + DefaultTransport transport1 = new DefaultTransport(config1); + DefaultTransport transport2 = new DefaultTransport(config2); + + assertEquals(transport1, transport2); + assertEquals(transport1.hashCode(), transport2.hashCode()); + } + + @Test + @DisplayName("should not be equal when configs are different") + void shouldNotBeEqualWhenConfigsAreDifferent() { + TransportConfig config1 = TransportConfig.builder() + .apiKey(TEST_API_KEY) + .connectTimeout(5000) + .build(); + + TransportConfig config2 = TransportConfig.builder() + .apiKey(TEST_API_KEY) + .connectTimeout(10000) + .build(); + + DefaultTransport transport1 = new DefaultTransport(config1); + DefaultTransport transport2 = new DefaultTransport(config2); + + assertNotEquals(transport1, transport2); + } + + @Test + @DisplayName("should be equal to itself") + void shouldBeEqualToItself() { + assertEquals(transport, transport); + } + + @Test + @DisplayName("should not be equal to null") + void shouldNotBeEqualToNull() { + assertNotEquals(transport, null); + } + + @Test + @DisplayName("should not be equal to different class") + void shouldNotBeEqualToDifferentClass() { + assertNotEquals(transport, "string"); + } + } + + @Nested + @DisplayName("Response Reading Tests") + class ResponseReadingTests { + + @Test + @DisplayName("should read response with empty body") + void shouldReadResponseWithEmptyBody() throws Exception { + TestHttpServer server = new TestHttpServer(); + server.setResponseBody(""); + server.start(); + + try { + Request request = Request.builder() + .method("GET") + .url(server.getUrl()) + .build(); + + Response response = transport.send(request); + + assertNotNull(response); + assertEquals(0, response.getBody().length); + } finally { + server.stop(); + } + } + + @Test + @DisplayName("should read response headers") + void shouldReadResponseHeaders() throws Exception { + TestHttpServer server = new TestHttpServer(); + server.addResponseHeader("X-Custom-Response", "test-value"); + server.start(); + + try { + Request request = Request.builder() + .method("GET") + .url(server.getUrl()) + .build(); + + Response response = transport.send(request); + + assertNotNull(response.getHeaders()); + // Check that we received headers + assertFalse(response.getHeaders().isEmpty()); + } finally { + server.stop(); + } + } + + @Test + @DisplayName("should handle error stream for 4xx and 5xx") + void shouldHandleErrorStreamFor4xxAnd5xx() throws Exception { + TestHttpServer server = new TestHttpServer(); + server.setResponseCode(400); + server.setResponseBody("{\"error\":\"Bad request error\"}"); + server.start(); + + try { + Request request = Request.builder() + .method("POST") + .url(server.getUrl()) + .jsonBody("{}") + .build(); + + ClientErrorException exception = assertThrows(ClientErrorException.class, + () -> transport.send(request)); + + assertNotNull(exception.getResponseBody()); + assertTrue(exception.getResponseBody().contains("error")); + } finally { + server.stop(); + } + } + } + + @Nested + @DisplayName("URL Building Tests") + class UrlBuildingTests { + + @Test + @DisplayName("should build URL without query params") + void shouldBuildUrlWithoutQueryParams() throws Exception { + TestHttpServer server = new TestHttpServer(); + server.start(); + + try { + Request request = Request.builder() + .method("GET") + .url(server.getUrl()) + .build(); + + transport.send(request); + + String requestUrl = server.getLastRequestUrl(); + assertFalse(requestUrl.contains("?")); + } finally { + server.stop(); + } + } + + @Test + @DisplayName("should build URL with single query param") + void shouldBuildUrlWithSingleQueryParam() throws Exception { + TestHttpServer server = new TestHttpServer(); + server.start(); + + try { + Request request = Request.builder() + .method("GET") + .url(server.getUrl()) + .queryParam("key", "value") + .build(); + + transport.send(request); + + String requestUrl = server.getLastRequestUrl(); + assertTrue(requestUrl.contains("key=value")); + } finally { + server.stop(); + } + } + + @Test + @DisplayName("should build URL with multiple query params") + void shouldBuildUrlWithMultipleQueryParams() throws Exception { + TestHttpServer server = new TestHttpServer(); + server.start(); + + try { + Request request = Request.builder() + .method("GET") + .url(server.getUrl()) + .queryParam("key1", "value1") + .queryParam("key2", "value2") + .queryParam("key3", "value3") + .build(); + + transport.send(request); + + String requestUrl = server.getLastRequestUrl(); + assertTrue(requestUrl.contains("key1=value1")); + assertTrue(requestUrl.contains("key2=value2")); + assertTrue(requestUrl.contains("key3=value3")); + } finally { + server.stop(); + } + } + + @Test + @DisplayName("should URL encode query param values") + void shouldUrlEncodeQueryParamValues() throws Exception { + TestHttpServer server = new TestHttpServer(); + server.start(); + + try { + Request request = Request.builder() + .method("GET") + .url(server.getUrl()) + .queryParam("email", "test@example.com") + .queryParam("name", "John Doe") + .build(); + + transport.send(request); + + String requestUrl = server.getLastRequestUrl(); + assertTrue(requestUrl.contains("email=test%40example.com")); + assertTrue(requestUrl.contains("name=John+Doe") || requestUrl.contains("name=John%20Doe")); + } finally { + server.stop(); + } + } + + @Test + @DisplayName("should handle URL with existing query string") + void shouldHandleUrlWithExistingQueryString() throws Exception { + TestHttpServer server = new TestHttpServer(); + server.start(); + + try { + Request request = Request.builder() + .method("GET") + .url(server.getUrl() + "?existing=param") + .queryParam("new", "value") + .build(); + + transport.send(request); + + String requestUrl = server.getLastRequestUrl(); + assertTrue(requestUrl.contains("existing=param")); + assertTrue(requestUrl.contains("new=value")); + } finally { + server.stop(); + } + } + + @Test + @DisplayName("should handle multiple values for same query param") + void shouldHandleMultipleValuesForSameQueryParam() throws Exception { + TestHttpServer server = new TestHttpServer(); + server.start(); + + try { + Request request = Request.builder() + .method("GET") + .url(server.getUrl()) + .queryParam("id", "1") + .queryParam("id", "2") + .queryParam("id", "3") + .build(); + + transport.send(request); + + String requestUrl = server.getLastRequestUrl(); + assertTrue(requestUrl.contains("id=1")); + assertTrue(requestUrl.contains("id=2")); + assertTrue(requestUrl.contains("id=3")); + } finally { + server.stop(); + } + } + } + + /** + * Simple embedded HTTP server for testing. + * This provides controlled HTTP responses for testing various scenarios. + */ + private static class TestHttpServer { + private HttpServer server; + private int port; + private int responseCode = 200; + private String responseBody = "OK"; + private Map responseHeaders = new HashMap<>(); + private boolean gzipResponse = false; + private int delay = 0; + + private String lastRequestUrl; + // Use TreeMap with case-insensitive comparator for HTTP headers + private Map lastRequestHeaders = new TreeMap<>(String.CASE_INSENSITIVE_ORDER); + private String lastRequestBody; + + void start() throws IOException { + port = findFreePort(); + server = HttpServer.create(new InetSocketAddress(port), 0); + + server.createContext("/", exchange -> { + try { + // Add delay if specified + if (delay > 0) { + Thread.sleep(delay); + } + + // Capture request details + lastRequestUrl = exchange.getRequestURI().toString(); + lastRequestHeaders.clear(); + for (Map.Entry> header : exchange.getRequestHeaders().entrySet()) { + lastRequestHeaders.put(header.getKey(), header.getValue().get(0)); + } + + // Read request body + try (InputStream is = exchange.getRequestBody()) { + lastRequestBody = new String(readAllBytes(is), StandardCharsets.UTF_8); + } + + // Set response headers + for (Map.Entry header : responseHeaders.entrySet()) { + exchange.getResponseHeaders().set(header.getKey(), header.getValue()); + } + + // Prepare response body + byte[] responseBytes = responseBody.getBytes(StandardCharsets.UTF_8); + + if (gzipResponse) { + exchange.getResponseHeaders().set("Content-Encoding", "gzip"); + ByteArrayOutputStream baos = new ByteArrayOutputStream(); + try (GZIPOutputStream gzipOut = new GZIPOutputStream(baos)) { + gzipOut.write(responseBytes); + } + responseBytes = baos.toByteArray(); + } + + // Send response + exchange.sendResponseHeaders(responseCode, responseBytes.length); + try (OutputStream os = exchange.getResponseBody()) { + os.write(responseBytes); + } + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } + }); + + server.setExecutor(null); + server.start(); + } + + void stop() { + if (server != null) { + server.stop(0); + } + } + + String getUrl() { + return "http://localhost:" + port + "/"; + } + + void setResponseCode(int code) { + this.responseCode = code; + } + + void setResponseBody(String body) { + this.responseBody = body; + } + + void addResponseHeader(String key, String value) { + this.responseHeaders.put(key, value); + } + + void setGzipResponse(boolean gzip) { + this.gzipResponse = gzip; + } + + void setDelay(int delayMs) { + this.delay = delayMs; + } + + String getLastRequestUrl() { + return lastRequestUrl; + } + + Map getLastRequestHeaders() { + return lastRequestHeaders; + } + + String getLastRequestBody() { + return lastRequestBody; + } + + private int findFreePort() throws IOException { + try (ServerSocket socket = new ServerSocket(0)) { + return socket.getLocalPort(); + } + } + + private byte[] readAllBytes(InputStream is) throws IOException { + ByteArrayOutputStream buffer = new ByteArrayOutputStream(); + byte[] data = new byte[8192]; + int bytesRead; + while ((bytesRead = is.read(data)) != -1) { + buffer.write(data, 0, bytesRead); + } + return buffer.toByteArray(); + } + } +} + diff --git a/src/test/java/com/chargebee/v4/transport/UrlBuilderTest.java b/src/test/java/com/chargebee/v4/transport/UrlBuilderTest.java new file mode 100644 index 00000000..54c79eab --- /dev/null +++ b/src/test/java/com/chargebee/v4/transport/UrlBuilderTest.java @@ -0,0 +1,601 @@ +package com.chargebee.v4.transport; + +import org.junit.jupiter.api.*; + +import java.util.*; + +import static org.junit.jupiter.api.Assertions.*; + +@DisplayName("UrlBuilder Tests") +class UrlBuilderTest { + + @Nested + @DisplayName("buildUrl Tests") + class BuildUrlTests { + + @Test + @DisplayName("should build URL with base URL only") + void shouldBuildUrlWithBaseUrlOnly() { + String result = UrlBuilder.buildUrl("https://api.example.com", null, null); + assertEquals("https://api.example.com", result); + } + + @Test + @DisplayName("should build URL with base URL and path") + void shouldBuildUrlWithBaseUrlAndPath() { + String result = UrlBuilder.buildUrl("https://api.example.com", "/customers", null); + assertEquals("https://api.example.com/customers", result); + } + + @Test + @DisplayName("should build URL with base URL (with trailing slash) and path (with leading slash)") + void shouldHandleDoubleSlash() { + String result = UrlBuilder.buildUrl("https://api.example.com/", "/customers", null); + assertEquals("https://api.example.com/customers", result); + } + + @Test + @DisplayName("should build URL with base URL (no trailing slash) and path (no leading slash)") + void shouldHandleNoSlash() { + String result = UrlBuilder.buildUrl("https://api.example.com", "customers", null); + assertEquals("https://api.example.com/customers", result); + } + + @Test + @DisplayName("should build URL with base URL (with trailing slash) and path (no leading slash)") + void shouldHandleTrailingSlash() { + String result = UrlBuilder.buildUrl("https://api.example.com/", "customers", null); + assertEquals("https://api.example.com/customers", result); + } + + @Test + @DisplayName("should build URL with empty path") + void shouldBuildUrlWithEmptyPath() { + String result = UrlBuilder.buildUrl("https://api.example.com", "", null); + assertEquals("https://api.example.com", result); + } + + @Test + @DisplayName("should build URL with query parameters") + void shouldBuildUrlWithQueryParams() { + Map params = new HashMap<>(); + params.put("limit", "10"); + params.put("offset", "20"); + + String result = UrlBuilder.buildUrl("https://api.example.com", "/customers", params); + + assertTrue(result.startsWith("https://api.example.com/customers?")); + assertTrue(result.contains("limit=10")); + assertTrue(result.contains("offset=20")); + assertTrue(result.contains("&")); + } + + @Test + @DisplayName("should build URL with path and query parameters") + void shouldBuildUrlWithPathAndQueryParams() { + Map params = new HashMap<>(); + params.put("name", "John Doe"); + + String result = UrlBuilder.buildUrl("https://api.example.com", "/customers", params); + + assertTrue(result.startsWith("https://api.example.com/customers?")); + assertTrue(result.contains("name=John+Doe")); + } + + @Test + @DisplayName("should build URL with empty query parameters map") + void shouldBuildUrlWithEmptyQueryParams() { + Map params = new HashMap<>(); + + String result = UrlBuilder.buildUrl("https://api.example.com", "/customers", params); + assertEquals("https://api.example.com/customers", result); + } + + @Test + @DisplayName("should throw exception when base URL is null") + void shouldThrowExceptionWhenBaseUrlIsNull() { + IllegalArgumentException exception = assertThrows( + IllegalArgumentException.class, + () -> UrlBuilder.buildUrl(null, "/path", null) + ); + assertEquals("Base URL cannot be null", exception.getMessage()); + } + } + + @Nested + @DisplayName("encodeQueryParams Tests") + class EncodeQueryParamsTests { + + @Test + @DisplayName("should encode single value parameters") + void shouldEncodeSingleValueParams() { + Map params = new HashMap<>(); + params.put("name", "John Doe"); + params.put("age", "30"); + + String result = UrlBuilder.encodeQueryParams(params); + + assertTrue(result.contains("name=John+Doe")); + assertTrue(result.contains("age=30")); + assertTrue(result.contains("&")); + } + + @Test + @DisplayName("should encode list value parameters") + void shouldEncodeListValueParams() { + Map params = new LinkedHashMap<>(); + List ids = Arrays.asList("id1", "id2", "id3"); + params.put("ids", ids); + + String result = UrlBuilder.encodeQueryParams(params); + + assertTrue(result.contains("ids=id1")); + assertTrue(result.contains("ids=id2")); + assertTrue(result.contains("ids=id3")); + assertEquals(2, result.chars().filter(ch -> ch == '&').count()); + } + + @Test + @DisplayName("should encode mixed single and list parameters") + void shouldEncodeMixedParams() { + Map params = new LinkedHashMap<>(); + params.put("name", "John"); + params.put("tags", Arrays.asList("tag1", "tag2")); + params.put("status", "active"); + + String result = UrlBuilder.encodeQueryParams(params); + + assertTrue(result.contains("name=John")); + assertTrue(result.contains("tags=tag1")); + assertTrue(result.contains("tags=tag2")); + assertTrue(result.contains("status=active")); + } + + @Test + @DisplayName("should handle special characters in parameters") + void shouldHandleSpecialCharacters() { + Map params = new HashMap<>(); + params.put("email", "user@example.com"); + params.put("message", "Hello & goodbye!"); + + String result = UrlBuilder.encodeQueryParams(params); + + assertTrue(result.contains("email=user%40example.com")); + assertTrue(result.contains("message=Hello+%26+goodbye%21")); + } + + @Test + @DisplayName("should return empty string for null parameters") + void shouldReturnEmptyStringForNull() { + String result = UrlBuilder.encodeQueryParams(null); + assertEquals("", result); + } + + @Test + @DisplayName("should return empty string for empty parameters") + void shouldReturnEmptyStringForEmpty() { + String result = UrlBuilder.encodeQueryParams(new HashMap<>()); + assertEquals("", result); + } + + @Test + @DisplayName("should skip null keys") + void shouldSkipNullKeys() { + Map params = new HashMap<>(); + params.put(null, "value"); + params.put("valid", "data"); + + String result = UrlBuilder.encodeQueryParams(params); + + assertEquals("valid=data", result); + } + + @Test + @DisplayName("should skip null values") + void shouldSkipNullValues() { + Map params = new LinkedHashMap<>(); + params.put("name", "John"); + params.put("email", null); + params.put("age", "30"); + + String result = UrlBuilder.encodeQueryParams(params); + + assertFalse(result.contains("email")); + assertTrue(result.contains("name=John")); + assertTrue(result.contains("age=30")); + } + + @Test + @DisplayName("should skip null values in lists") + void shouldSkipNullValuesInLists() { + Map params = new LinkedHashMap<>(); + List values = new ArrayList<>(); + values.add("value1"); + values.add(null); + values.add("value2"); + params.put("items", values); + + String result = UrlBuilder.encodeQueryParams(params); + + assertTrue(result.contains("items=value1")); + assertTrue(result.contains("items=value2")); + assertEquals(1, result.chars().filter(ch -> ch == '&').count()); + } + + @Test + @DisplayName("should handle empty lists") + void shouldHandleEmptyLists() { + Map params = new HashMap<>(); + params.put("items", new ArrayList()); + params.put("name", "test"); + + String result = UrlBuilder.encodeQueryParams(params); + + assertEquals("name=test", result); + } + + @Test + @DisplayName("should handle lists with all null values") + void shouldHandleListsWithAllNullValues() { + Map params = new LinkedHashMap<>(); + List values = new ArrayList<>(); + values.add(null); + values.add(null); + params.put("items", values); + params.put("name", "test"); + + String result = UrlBuilder.encodeQueryParams(params); + + assertEquals("name=test", result); + } + + @Test + @DisplayName("should encode numeric values as strings") + void shouldEncodeNumericValues() { + Map params = new HashMap<>(); + params.put("count", 100); + params.put("price", 99.99); + + String result = UrlBuilder.encodeQueryParams(params); + + assertTrue(result.contains("count=100")); + assertTrue(result.contains("price=99.99")); + } + } + + @Nested + @DisplayName("urlEncode Tests") + class UrlEncodeTests { + + @Test + @DisplayName("should encode simple string") + void shouldEncodeSimpleString() { + String result = UrlBuilder.urlEncode("hello"); + assertEquals("hello", result); + } + + @Test + @DisplayName("should encode string with spaces") + void shouldEncodeStringWithSpaces() { + String result = UrlBuilder.urlEncode("hello world"); + assertEquals("hello+world", result); + } + + @Test + @DisplayName("should encode special characters") + void shouldEncodeSpecialCharacters() { + String result = UrlBuilder.urlEncode("user@example.com"); + assertEquals("user%40example.com", result); + } + + @Test + @DisplayName("should encode ampersand") + void shouldEncodeAmpersand() { + String result = UrlBuilder.urlEncode("rock&roll"); + assertEquals("rock%26roll", result); + } + + @Test + @DisplayName("should encode equals sign") + void shouldEncodeEquals() { + String result = UrlBuilder.urlEncode("a=b"); + assertEquals("a%3Db", result); + } + + @Test + @DisplayName("should encode question mark") + void shouldEncodeQuestionMark() { + String result = UrlBuilder.urlEncode("what?"); + assertEquals("what%3F", result); + } + + @Test + @DisplayName("should encode slash") + void shouldEncodeSlash() { + String result = UrlBuilder.urlEncode("path/to/resource"); + assertEquals("path%2Fto%2Fresource", result); + } + + @Test + @DisplayName("should encode plus sign") + void shouldEncodePlus() { + String result = UrlBuilder.urlEncode("1+1=2"); + assertEquals("1%2B1%3D2", result); + } + + @Test + @DisplayName("should return empty string for null") + void shouldReturnEmptyStringForNull() { + String result = UrlBuilder.urlEncode(null); + assertEquals("", result); + } + + @Test + @DisplayName("should return empty string for empty string") + void shouldReturnEmptyStringForEmpty() { + String result = UrlBuilder.urlEncode(""); + assertEquals("", result); + } + + @Test + @DisplayName("should encode unicode characters") + void shouldEncodeUnicode() { + String result = UrlBuilder.urlEncode("café"); + assertTrue(result.contains("%")); + } + + @Test + @DisplayName("should encode percent sign") + void shouldEncodePercent() { + String result = UrlBuilder.urlEncode("100%"); + assertEquals("100%25", result); + } + } + + @Nested + @DisplayName("isValidBaseUrl Tests") + class IsValidBaseUrlTests { + + @Test + @DisplayName("should return true for valid https URL") + void shouldReturnTrueForValidHttpsUrl() { + assertTrue(UrlBuilder.isValidBaseUrl("https://api.example.com")); + } + + @Test + @DisplayName("should return true for valid http URL") + void shouldReturnTrueForValidHttpUrl() { + assertTrue(UrlBuilder.isValidBaseUrl("http://api.example.com")); + } + + @Test + @DisplayName("should return true for URL with path") + void shouldReturnTrueForUrlWithPath() { + assertTrue(UrlBuilder.isValidBaseUrl("https://api.example.com/v1")); + } + + @Test + @DisplayName("should return true for URL with port") + void shouldReturnTrueForUrlWithPort() { + assertTrue(UrlBuilder.isValidBaseUrl("https://api.example.com:8080")); + } + + @Test + @DisplayName("should return false for null URL") + void shouldReturnFalseForNull() { + assertFalse(UrlBuilder.isValidBaseUrl(null)); + } + + @Test + @DisplayName("should return false for empty URL") + void shouldReturnFalseForEmpty() { + assertFalse(UrlBuilder.isValidBaseUrl("")); + } + + @Test + @DisplayName("should return false for whitespace only") + void shouldReturnFalseForWhitespace() { + assertFalse(UrlBuilder.isValidBaseUrl(" ")); + } + + @Test + @DisplayName("should return false for invalid protocol") + void shouldReturnFalseForInvalidProtocol() { + assertFalse(UrlBuilder.isValidBaseUrl("ftp://example.com")); + } + + @Test + @DisplayName("should return false for malformed URL") + void shouldReturnFalseForMalformedUrl() { + assertFalse(UrlBuilder.isValidBaseUrl("not a url")); + } + + @Test + @DisplayName("should return false for URL without protocol") + void shouldReturnFalseForUrlWithoutProtocol() { + assertFalse(UrlBuilder.isValidBaseUrl("example.com")); + } + + @Test + @DisplayName("should return false for incomplete protocol") + void shouldReturnFalseForIncompleteProtocol() { + assertFalse(UrlBuilder.isValidBaseUrl("http:/example.com")); + } + } + + @Nested + @DisplayName("joinPaths Tests") + class JoinPathsTests { + + @Test + @DisplayName("should join two path segments") + void shouldJoinTwoSegments() { + String result = UrlBuilder.joinPaths("api", "v1"); + assertEquals("api/v1", result); + } + + @Test + @DisplayName("should join multiple path segments") + void shouldJoinMultipleSegments() { + String result = UrlBuilder.joinPaths("api", "v1", "customers", "123"); + assertEquals("api/v1/customers/123", result); + } + + @Test + @DisplayName("should handle segments with leading slashes") + void shouldHandleLeadingSlashes() { + String result = UrlBuilder.joinPaths("api", "/v1", "/customers"); + assertEquals("api/v1/customers", result); + } + + @Test + @DisplayName("should handle segments with trailing slashes") + void shouldHandleTrailingSlashes() { + String result = UrlBuilder.joinPaths("api/", "v1/", "customers"); + assertEquals("api/v1/customers", result); + } + + @Test + @DisplayName("should preserve leading slash on first segment") + void shouldPreserveLeadingSlashOnFirst() { + String result = UrlBuilder.joinPaths("/api", "v1", "customers"); + assertEquals("/api/v1/customers", result); + } + + @Test + @DisplayName("should preserve trailing slash on last segment") + void shouldPreserveTrailingSlashOnLast() { + String result = UrlBuilder.joinPaths("api", "v1", "customers/"); + assertEquals("api/v1/customers/", result); + } + + @Test + @DisplayName("should return empty string for null array") + void shouldReturnEmptyForNull() { + String result = UrlBuilder.joinPaths((String[]) null); + assertEquals("", result); + } + + @Test + @DisplayName("should return empty string for empty array") + void shouldReturnEmptyForEmptyArray() { + String result = UrlBuilder.joinPaths(); + assertEquals("", result); + } + + @Test + @DisplayName("should skip null segments") + void shouldSkipNullSegments() { + String result = UrlBuilder.joinPaths("api", null, "v1", null, "customers"); + assertEquals("api/v1/customers", result); + } + + @Test + @DisplayName("should skip empty segments") + void shouldSkipEmptySegments() { + String result = UrlBuilder.joinPaths("api", "", "v1", "", "customers"); + assertEquals("api/v1/customers", result); + } + + @Test + @DisplayName("should handle single segment") + void shouldHandleSingleSegment() { + String result = UrlBuilder.joinPaths("api"); + assertEquals("api", result); + } + + @Test + @DisplayName("should handle single segment with leading slash") + void shouldHandleSingleSegmentWithLeadingSlash() { + String result = UrlBuilder.joinPaths("/api"); + assertEquals("/api", result); + } + + @Test + @DisplayName("should handle single segment with trailing slash") + void shouldHandleSingleSegmentWithTrailingSlash() { + String result = UrlBuilder.joinPaths("api/"); + assertEquals("api/", result); + } + + @Test + @DisplayName("should handle segments with multiple slashes") + void shouldHandleMultipleSlashes() { + String result = UrlBuilder.joinPaths("api//", "//v1", "customers"); + assertEquals("api//v1/customers", result); + } + + @Test + @DisplayName("should handle all null segments") + void shouldHandleAllNullSegments() { + String result = UrlBuilder.joinPaths(null, null, null); + assertEquals("", result); + } + + @Test + @DisplayName("should handle all empty segments") + void shouldHandleAllEmptySegments() { + String result = UrlBuilder.joinPaths("", "", ""); + assertEquals("", result); + } + } + + @Nested + @DisplayName("Integration Tests") + class IntegrationTests { + + @Test + @DisplayName("should build complex URL with all components") + void shouldBuildComplexUrl() { + Map params = new LinkedHashMap<>(); + params.put("limit", "50"); + params.put("offset", "100"); + params.put("sort_by", "created_at"); + params.put("filters", Arrays.asList("active", "verified")); + + String result = UrlBuilder.buildUrl( + "https://api.example.com", + "/v1/customers", + params + ); + + assertTrue(result.startsWith("https://api.example.com/v1/customers?")); + assertTrue(result.contains("limit=50")); + assertTrue(result.contains("offset=100")); + assertTrue(result.contains("sort_by=created_at")); + assertTrue(result.contains("filters=active")); + assertTrue(result.contains("filters=verified")); + } + + @Test + @DisplayName("should handle URL with encoded characters in path and params") + void shouldHandleEncodedCharacters() { + Map params = new HashMap<>(); + params.put("email", "user@example.com"); + params.put("name", "John & Jane"); + + String result = UrlBuilder.buildUrl( + "https://api.example.com", + "/customers/search", + params + ); + + assertTrue(result.contains("email=user%40example.com")); + assertTrue(result.contains("name=John+%26+Jane")); + } + + @Test + @DisplayName("should validate and build URL") + void shouldValidateAndBuildUrl() { + String baseUrl = "https://api.example.com"; + + assertTrue(UrlBuilder.isValidBaseUrl(baseUrl)); + + String path = UrlBuilder.joinPaths("api", "v1", "customers"); + String fullUrl = UrlBuilder.buildUrl(baseUrl, path, null); + + assertEquals("https://api.example.com/api/v1/customers", fullUrl); + } + } +} +