From d6dcd16fec0bcacafaed9751e963c7de6fc18634 Mon Sep 17 00:00:00 2001 From: A Anand Date: Fri, 7 Nov 2025 21:30:53 +0530 Subject: [PATCH] feature: #98 | Ability to recover from specific exceptions --- .../zeplinko/commons/lang/ext/core/Try.java | 267 +++++++- .../commons/lang/ext/core/TryTest.java | 622 +++++++++++++++++- 2 files changed, 885 insertions(+), 4 deletions(-) diff --git a/src/main/java/org/zeplinko/commons/lang/ext/core/Try.java b/src/main/java/org/zeplinko/commons/lang/ext/core/Try.java index 4786fdc..f3a2b40 100644 --- a/src/main/java/org/zeplinko/commons/lang/ext/core/Try.java +++ b/src/main/java/org/zeplinko/commons/lang/ext/core/Try.java @@ -2,6 +2,7 @@ import jakarta.annotation.Nonnull; +import java.util.Arrays; import java.util.Objects; import java.util.Optional; import java.util.concurrent.Callable; @@ -66,9 +67,8 @@ * instance inherently thread-safe as long as the wrapped value is itself * thread-safe. * - * @author Shivam Nagpal - * * @param the type of the successful value + * @author Shivam Nagpal */ public class Try extends AbstractOutcome { @@ -189,7 +189,7 @@ public Try compose( ) { Objects.requireNonNull(successMapper); Objects.requireNonNull(failureMapper); - return this.isFailure() ? failureMapper.apply(this.getError()) : successMapper.apply(this.getData()); + return isSuccess() ? successMapper.apply(getData()) : failureMapper.apply(getError()); } /** @@ -219,6 +219,130 @@ public Try recover(@Nonnull Function> map return compose(Try::success, mapper); } + /** + * Attempts to recover from a failure if the error matches the specified + * exception type. + *

+ * If this Try is successful, returns it unchanged. If this Try contains an + * error that is NOT an instance of the specified exception type, returns it + * unchanged. If this Try contains an error that IS an instance of the specified + * exception type, applies the failure mapper to attempt recovery. + *

+ * + *

+ * Example usage: + *

+ * + *
+     * {
+     *     @code
+     *     Try<String> result = Try.of(() -> riskyOperation())
+     *             .recover(IOException.class, ex -> Try.success("default value"))
+     *             .recover(TimeoutException.class, ex -> Try.failure(new CustomException(ex)));
+     * }
+     * 
+ * + * @param the type of exception to recover from + * @param exceptionType the class of the exception type to match against + * @param failureMapper function to apply to the exception if it matches the + * specified type; should return a new Try representing the + * recovery attempt + * @return this Try if successful or if the error doesn't match the exception + * type; otherwise the result of applying the failureMapper to the error + * @throws NullPointerException if exceptionType or failureMapper is null + * @see #recover(Function) + * @see #recover(Function, Class[]) + */ + public Try recover( + Class exceptionType, + Function> failureMapper + ) { + Objects.requireNonNull(exceptionType); + Objects.requireNonNull(failureMapper); + if (isSuccess() || !exceptionType.isInstance(getError())) { + return this; + } + return failureMapper.apply(getError()); + } + + /** + * Attempts to recover from a failure if the error matches any of the specified + * exception types. + *

+ * This method provides a convenient way to recover from multiple exception + * types using a single failure mapper. If this Try is successful, it returns + * unchanged. If this Try contains an error that does NOT match any of the + * specified exception types, it returns unchanged. If this Try contains an + * error that matches at least one of the specified exception types, the failure + * mapper is applied to attempt recovery. + *

+ * + *

+ * Null elements in the varargs array are automatically filtered out and ignored + * during exception type matching. + *

+ * + *

+ * Example usage: + *

+ * + *
+     * {
+     *     @code
+     *     // Recover from multiple I/O-related exceptions with a single handler
+     *     Try<String> result = Try.to(() -> readFile())
+     *             .recover(
+     *                     ex -> Try.success("default content"),
+     *                     IOException.class,
+     *                     FileNotFoundException.class,
+     *                     SocketTimeoutException.class
+     *             );
+     *
+     *     // Chain multiple recovery strategies for different exception groups
+     *     Try<Data> processed = Try.to(() -> processData())
+     *             .recover(
+     *                     ex -> Try.success(Data.empty()),
+     *                     IOException.class,
+     *                     TimeoutException.class
+     *             )
+     *             .recover(
+     *                     ex -> Try.failure(new ProcessingException(ex)),
+     *                     ValidationException.class,
+     *                     ParseException.class
+     *             );
+     * }
+     * 
+ * + * @param the type bound for exception types being recovered from + * @param failureMapper function to apply to the exception if it matches any of + * the specified types; should return a new Try + * representing the recovery attempt + * @param exceptionTypes varargs array of exception types to match against; null + * elements are ignored + * @return this Try if successful or if the error doesn't match any exception + * type; otherwise the result of applying the failureMapper to the error + * @throws NullPointerException if failureMapper or exceptionTypes array itself + * is null + * @see #recover(Class, Function) + * @see #recover(Function) + */ + @SafeVarargs + public final Try recover( + Function> failureMapper, + Class... exceptionTypes + ) { + Objects.requireNonNull(failureMapper); + Objects.requireNonNull(exceptionTypes); + if ( + isSuccess() || Arrays.stream(exceptionTypes) + .filter(Objects::nonNull) + .noneMatch(exceptionType -> exceptionType.isInstance(getError())) + ) { + return this; + } + return failureMapper.apply(getError()); + } + /** * Transforms this {@code Try} into another {@code Try} using the provided * transformation function. @@ -281,6 +405,143 @@ public Try onFailure(Consumer errorConsumer) { return onHandle(null, errorConsumer); } + /** + * Executes the provided consumer if this {@code Try} is a failure and the error + * matches the specified exception type. + *

+ * If this Try is successful, nothing happens, and this Try is returned + * unchanged. If this Try contains an error that is NOT an instance of the + * specified exception type, nothing happens, and this Try is returned + * unchanged. If this Try contains an error that IS an instance of the specified + * exception type, the error consumer is executed. + *

+ * + *

+ * This method is useful for handling specific exception types differently, such + * as logging at different levels, recording metrics, or performing + * type-specific cleanup operations. + *

+ * + *

+ * Example usage: + *

+ * + *
+     * {
+     *     @code
+     *     // Log different exception types at different levels
+     *     Try<Data> result = Try.to(() -> fetchData())
+     *             .onFailure(IOException.class, ex -> logger.error("I/O error: {}", ex.getMessage()))
+     *             .onFailure(TimeoutException.class, ex -> logger.warn("Timeout: {}", ex.getMessage()))
+     *             .onFailure(ex -> logger.error("Unexpected error", ex));
+     *
+     *     // Record type-specific metrics
+     *     Try<Response> apiResult = Try.to(() -> callApi())
+     *             .onFailure(TimeoutException.class, ex -> metrics.increment("api.timeout"))
+     *             .onFailure(IOException.class, ex -> metrics.increment("api.network_error"));
+     * }
+     * 
+ * + * @param the type of exception to handle + * @param exceptionType the class of the exception type to match against + * @param errorConsumer consumer to execute if the error matches the specified + * type + * @return this Try instance for method chaining + * @throws NullPointerException if exceptionType or errorConsumer is null + * @see #onFailure(Consumer) + * @see #onFailure(Consumer, Class[]) + * @see #recover(Class, Function) + */ + public Try onFailure( + Class exceptionType, + Consumer errorConsumer + ) { + Objects.requireNonNull(exceptionType); + Objects.requireNonNull(errorConsumer); + if (isFailure() && exceptionType.isInstance(getError())) { + errorConsumer.accept(getError()); + } + return this; + } + + /** + * Executes the provided consumer if this {@code Try} is a failure and the error + * matches any of the specified exception types. + *

+ * This method provides a convenient way to handle multiple exception types + * using a single error consumer. If this Try is successful, nothing happens, + * and this Try is returned unchanged. If this Try contains an error that does + * NOT match any of the specified exception types, nothing happens, and this Try + * is returned unchanged. If this Try contains an error that matches at least + * one of the specified exception types, the error consumer is executed. + *

+ * + *

+ * Null elements in the varargs array are automatically filtered out and ignored + * during exception type matching. + *

+ * + *

+ * Example usage: + *

+ * + *
+     * {
+     *     @code
+     *     // Handle multiple I/O-related exceptions with a single handler
+     *     Try<String> result = Try.to(() -> readFile())
+     *             .onFailure(
+     *                     ex -> logger.error("I/O operation failed: {}", ex.getMessage()),
+     *                     IOException.class,
+     *                     FileNotFoundException.class,
+     *                     SocketTimeoutException.class
+     *             );
+     *
+     *     // Combine different error handling strategies
+     *     Try<Data> processed = Try.to(() -> processData())
+     *             .onFailure(
+     *                     ex -> metrics.increment("errors.io"),
+     *                     IOException.class,
+     *                     TimeoutException.class
+     *             )
+     *             .onFailure(
+     *                     ex -> metrics.increment("errors.validation"),
+     *                     ValidationException.class,
+     *                     ParseException.class
+     *             )
+     *             .onFailure(ex -> metrics.increment("errors.other"));
+     * }
+     * 
+ * + * @param the type bound for exception types being handled + * @param errorConsumer consumer to execute if the error matches any of the + * specified types + * @param exceptionTypes varargs array of exception types to match against; null + * elements are ignored + * @return this Try instance for method chaining + * @throws NullPointerException if errorConsumer or exceptionTypes array itself + * is null + * @see #onFailure(Consumer) + * @see #onFailure(Class, Consumer) + * @see #recover(Function, Class[]) + */ + @SafeVarargs + public final Try onFailure( + Consumer errorConsumer, + Class... exceptionTypes + ) { + Objects.requireNonNull(errorConsumer); + Objects.requireNonNull(exceptionTypes); + if ( + isFailure() && Arrays.stream(exceptionTypes) + .filter(Objects::nonNull) + .anyMatch(exceptionType -> exceptionType.isInstance(getError())) + ) { + errorConsumer.accept(getError()); + } + return this; + } + /** * Converts this {@code Try} into a {@code Result} object. * diff --git a/src/test/java/org/zeplinko/commons/lang/ext/core/TryTest.java b/src/test/java/org/zeplinko/commons/lang/ext/core/TryTest.java index e10d61b..24ad65c 100644 --- a/src/test/java/org/zeplinko/commons/lang/ext/core/TryTest.java +++ b/src/test/java/org/zeplinko/commons/lang/ext/core/TryTest.java @@ -320,7 +320,6 @@ void test_givenFailure_whenFlatMapIsCalled_thenSameFailureIsReturned() { @Test void test_givenNullMapper_whenRecoverIsCalled_thenExceptionIsThrown() { Try failure = Try.failure(new RuntimeException("error")); - @SuppressWarnings("DataFlowIssue") NullPointerException nullPointerException = assertThrows( NullPointerException.class, @@ -348,6 +347,67 @@ void test_givenSuccess_whenRecoverIsCalled_thenSameSuccessIsReturned() { assertEquals("data", result.getData()); } + @Test + void test_givenNullExceptionType_whenRecoverWithExceptionTypeIsCalled_thenExceptionIsThrown() { + Try failure = Try.failure(new RuntimeException("error")); + @SuppressWarnings("DataFlowIssue") + NullPointerException nullPointerException = assertThrows( + NullPointerException.class, + () -> failure.recover(null, e -> Try.success(0)) + ); + assertNotNull(nullPointerException); + } + + @Test + void test_givenSuccess_whenRecoverWithExceptionTypeIsCalled_thenSameSuccessIsReturned() { + Try success = Try.success("data"); + Try result = success.recover(RuntimeException.class, e -> Try.success("recovered")); + + assertTrue(result.isSuccess()); + assertEquals("data", result.getData()); + } + + @Test + void test_givenFailureWithNonMatchingException_whenRecoverWithExceptionTypeIsCalled_thenSameFailureIsReturned() { + Exception error = new IllegalArgumentException("error"); + Try failure = Try.failure(error); + Try result = failure.recover(IllegalStateException.class, e -> Try.success("recovered")); + + assertTrue(result.isFailure()); + assertSame(error, result.getError()); + } + + @Test + void test_givenFailureWithMatchingException_whenRecoverWithExceptionTypeIsCalled_thenRecoveredTryIsReturned() { + Exception error = new IllegalArgumentException("error"); + Try failure = Try.failure(error); + Try result = failure.recover(IllegalArgumentException.class, e -> Try.success("recovered")); + + assertTrue(result.isSuccess()); + assertEquals("recovered", result.getData()); + } + + @Test + void test_givenFailureWithMatchingExceptionSubclass_whenRecoverWithExceptionTypeIsCalled_thenRecoveredTryIsReturned() { + Exception error = new IllegalArgumentException("error"); + Try failure = Try.failure(error); + Try result = failure.recover(RuntimeException.class, e -> Try.success("recovered")); + + assertTrue(result.isSuccess()); + assertEquals("recovered", result.getData()); + } + + @Test + void test_givenFailureWithMatchingException_whenRecoverWithExceptionTypeReturnsFailure_thenNewFailureIsReturned() { + Exception originalError = new IllegalArgumentException("original"); + Exception newError = new IllegalStateException("new error"); + Try failure = Try.failure(originalError); + Try result = failure.recover(IllegalArgumentException.class, e -> Try.failure(newError)); + + assertTrue(result.isFailure()); + assertSame(newError, result.getError()); + } + @Test void test_givenNullMapper_whenTransformIsCalled_thenExceptionIsThrown() { Try success = Try.success(10); @@ -449,6 +509,344 @@ void test_givenSuccess_whenOnFailureIsCalled_thenConsumerIsExecuted() { assertEquals(0, consumed.length()); } + // ========== Tests for single exception type onFailure method ========== + + @Test + void test_givenNullExceptionType_whenOnFailureWithExceptionTypeIsCalled_thenExceptionIsThrown() { + Try failure = Try.failure(new RuntimeException("error")); + @SuppressWarnings("DataFlowIssue") + NullPointerException nullPointerException = assertThrows( + NullPointerException.class, + () -> failure.onFailure(null, e -> { + }) + ); + assertNotNull(nullPointerException); + } + + @Test + void test_givenNullConsumer_whenOnFailureWithExceptionTypeIsCalled_thenExceptionIsThrown() { + Try failure = Try.failure(new RuntimeException("error")); + @SuppressWarnings("DataFlowIssue") + NullPointerException nullPointerException = assertThrows( + NullPointerException.class, + () -> failure.onFailure(RuntimeException.class, null) + ); + assertNotNull(nullPointerException); + } + + @Test + void test_givenSuccess_whenOnFailureWithExceptionTypeIsCalled_thenConsumerIsNotExecuted() { + Try success = Try.success("data"); + StringBuilder consumed = new StringBuilder(); + Try returnedTry = success.onFailure(RuntimeException.class, e -> consumed.append(e.getMessage())); + + assertSame(success, returnedTry); + assertEquals(0, consumed.length()); + } + + @Test + void test_givenFailureWithNonMatchingException_whenOnFailureWithExceptionTypeIsCalled_thenConsumerIsNotExecuted() { + Exception error = new IllegalArgumentException("error"); + Try failure = Try.failure(error); + StringBuilder consumed = new StringBuilder(); + Try returnedTry = failure.onFailure(IllegalStateException.class, e -> consumed.append(e.getMessage())); + + assertSame(failure, returnedTry); + assertEquals(0, consumed.length()); + } + + @Test + void test_givenFailureWithMatchingException_whenOnFailureWithExceptionTypeIsCalled_thenConsumerIsExecuted() { + Exception error = new IllegalArgumentException("error"); + Try failure = Try.failure(error); + StringBuilder consumed = new StringBuilder(); + Try returnedTry = failure + .onFailure(IllegalArgumentException.class, e -> consumed.append(e.getMessage())); + + assertSame(failure, returnedTry); + assertEquals("error", consumed.toString()); + } + + @Test + void test_givenFailureWithMatchingExceptionSubclass_whenOnFailureWithExceptionTypeIsCalled_thenConsumerIsExecuted() { + Exception error = new IllegalArgumentException("error"); + Try failure = Try.failure(error); + StringBuilder consumed = new StringBuilder(); + Try returnedTry = failure.onFailure(RuntimeException.class, e -> consumed.append(e.getMessage())); + + assertSame(failure, returnedTry); + assertEquals("error", consumed.toString()); + } + + @Test + void test_givenChainedOnFailureCallsWithDifferentTypes_whenFailureMatches_thenOnlyMatchingConsumerIsExecuted() { + Exception error = new IllegalArgumentException("validation error"); + Try failure = Try.failure(error); + List log = new ArrayList<>(); + + Try result = failure + .onFailure(IllegalStateException.class, e -> log.add("state error")) + .onFailure(IllegalArgumentException.class, e -> log.add("argument error")) + .onFailure(NullPointerException.class, e -> log.add("null error")); + + assertSame(failure, result); + assertEquals(1, log.size()); + assertEquals("argument error", log.get(0)); + } + + @Test + void test_givenChainedOnFailureWithCatchAll_whenFailureOccurs_thenAllMatchingConsumersAreExecuted() { + Exception error = new IllegalArgumentException("error"); + Try failure = Try.failure(error); + List log = new ArrayList<>(); + + Try result = failure + .onFailure(IllegalArgumentException.class, e -> log.add("specific")) + .onFailure(RuntimeException.class, e -> log.add("general")) + .onFailure(e -> log.add("catch-all")); + + assertSame(failure, result); + assertEquals(3, log.size()); + assertEquals("specific", log.get(0)); + assertEquals("general", log.get(1)); + assertEquals("catch-all", log.get(2)); + } + + // ========== Tests for varargs exception types onFailure method ========== + + @Test + void test_givenNullConsumer_whenOnFailureWithVarargsIsCalled_thenExceptionIsThrown() { + Try failure = Try.failure(new RuntimeException("error")); + @SuppressWarnings("DataFlowIssue") + NullPointerException nullPointerException = assertThrows( + NullPointerException.class, + () -> failure.onFailure(null, RuntimeException.class, IllegalArgumentException.class) + ); + assertNotNull(nullPointerException); + } + + @Test + void test_givenNullExceptionTypesArray_whenOnFailureWithVarargsIsCalled_thenExceptionIsThrown() { + Try failure = Try.failure(new RuntimeException("error")); + @SuppressWarnings({ "DataFlowIssue", "unchecked" }) + NullPointerException nullPointerException = assertThrows( + NullPointerException.class, + () -> failure.onFailure(e -> { + }, (Class[]) null) + ); + assertNotNull(nullPointerException); + } + + @Test + void test_givenSuccess_whenOnFailureWithVarargsIsCalled_thenConsumerIsNotExecuted() { + Try success = Try.success("data"); + StringBuilder consumed = new StringBuilder(); + Try returnedTry = success.onFailure( + e -> consumed.append(e.getMessage()), + RuntimeException.class, + IllegalArgumentException.class + ); + + assertSame(success, returnedTry); + assertEquals(0, consumed.length()); + } + + @Test + void test_givenFailureWithNoMatchingException_whenOnFailureWithVarargsIsCalled_thenConsumerIsNotExecuted() { + Exception error = new IllegalArgumentException("error"); + Try failure = Try.failure(error); + StringBuilder consumed = new StringBuilder(); + Try returnedTry = failure.onFailure( + e -> consumed.append(e.getMessage()), + IllegalStateException.class, + NullPointerException.class + ); + + assertSame(failure, returnedTry); + assertEquals(0, consumed.length()); + } + + @Test + void test_givenFailureWithEmptyVarargsArray_whenOnFailureWithVarargsIsCalled_thenConsumerIsNotExecuted() { + Exception error = new RuntimeException("error"); + Try failure = Try.failure(error); + StringBuilder consumed = new StringBuilder(); + Try returnedTry = failure.onFailure( + e -> consumed.append(e.getMessage()), + new Class[0] + ); + + assertSame(failure, returnedTry); + assertEquals(0, consumed.length()); + } + + @Test + void test_givenFailureWithSingleMatchingException_whenOnFailureWithVarargsIsCalled_thenConsumerIsExecuted() { + Exception error = new IllegalArgumentException("error"); + Try failure = Try.failure(error); + StringBuilder consumed = new StringBuilder(); + Try returnedTry = failure.onFailure( + e -> consumed.append(e.getMessage()), + IllegalArgumentException.class + ); + + assertSame(failure, returnedTry); + assertEquals("error", consumed.toString()); + } + + @Test + void test_givenFailureWithFirstMatchingException_whenOnFailureWithVarargsIsCalled_thenConsumerIsExecuted() { + Exception error = new IllegalArgumentException("error"); + Try failure = Try.failure(error); + StringBuilder consumed = new StringBuilder(); + Try returnedTry = failure.onFailure( + e -> consumed.append(e.getMessage()), + IllegalArgumentException.class, + IllegalStateException.class, + NullPointerException.class + ); + + assertSame(failure, returnedTry); + assertEquals("error", consumed.toString()); + } + + @Test + void test_givenFailureWithSecondMatchingException_whenOnFailureWithVarargsIsCalled_thenConsumerIsExecuted() { + Exception error = new IllegalStateException("error"); + Try failure = Try.failure(error); + StringBuilder consumed = new StringBuilder(); + Try returnedTry = failure.onFailure( + e -> consumed.append(e.getMessage()), + IllegalArgumentException.class, + IllegalStateException.class, + NullPointerException.class + ); + + assertSame(failure, returnedTry); + assertEquals("error", consumed.toString()); + } + + @Test + void test_givenFailureWithLastMatchingException_whenOnFailureWithVarargsIsCalled_thenConsumerIsExecuted() { + Exception error = new NullPointerException("error"); + Try failure = Try.failure(error); + StringBuilder consumed = new StringBuilder(); + Try returnedTry = failure.onFailure( + e -> consumed.append(e.getMessage()), + IllegalArgumentException.class, + IllegalStateException.class, + NullPointerException.class + ); + + assertSame(failure, returnedTry); + assertEquals("error", consumed.toString()); + } + + @Test + void test_givenFailureWithMatchingExceptionSubclass_whenOnFailureWithVarargsIsCalled_thenConsumerIsExecuted() { + Exception error = new IllegalArgumentException("error"); + Try failure = Try.failure(error); + StringBuilder consumed = new StringBuilder(); + Try returnedTry = failure.onFailure( + e -> consumed.append(e.getMessage()), + RuntimeException.class, + Exception.class + ); + + assertSame(failure, returnedTry); + assertEquals("error", consumed.toString()); + } + + @Test + void test_givenFailureAndNullExceptionTypesInVarargs_whenOnFailureWithVarargsIsCalled_thenNullsAreIgnored() { + Exception error = new IllegalArgumentException("error"); + Try failure = Try.failure(error); + StringBuilder consumed = new StringBuilder(); + @SuppressWarnings("unchecked") + Try returnedTry = failure.onFailure( + e -> consumed.append(e.getMessage()), + null, + IllegalArgumentException.class, + null + ); + + assertSame(failure, returnedTry); + assertEquals("error", consumed.toString()); + } + + @Test + void test_givenFailureAndAllNullExceptionTypesInVarargs_whenOnFailureWithVarargsIsCalled_thenConsumerIsNotExecuted() { + Exception error = new IllegalArgumentException("error"); + Try failure = Try.failure(error); + StringBuilder consumed = new StringBuilder(); + @SuppressWarnings("unchecked") + Try returnedTry = failure.onFailure( + e -> consumed.append(e.getMessage()), + null, + null + ); + + assertSame(failure, returnedTry); + assertEquals(0, consumed.length()); + } + + @Test + void test_givenMultipleIOExceptions_whenOnFailureWithVarargsIsCalled_thenSingleHandlerIsExecuted() { + Exception error = new java.io.FileNotFoundException("file not found"); + Try failure = Try.failure(error); + List log = new ArrayList<>(); + Try returnedTry = failure.onFailure( + e -> log.add("I/O error: " + e.getMessage()), + java.io.IOException.class, + java.io.FileNotFoundException.class, + java.net.SocketTimeoutException.class + ); + + assertSame(failure, returnedTry); + assertEquals(1, log.size()); + assertEquals("I/O error: file not found", log.get(0)); + } + + @Test + void test_givenChainedOnFailureCallsWithVarargs_whenDifferentExceptionGroups_thenCorrectHandlerIsExecuted() { + Exception error = new IllegalArgumentException("validation error"); + Try failure = Try.failure(error); + List log = new ArrayList<>(); + + Try result = failure + .onFailure( + e -> log.add("io"), + java.io.IOException.class, + java.util.concurrent.TimeoutException.class + ) + .onFailure( + e -> log.add("validation"), + IllegalArgumentException.class, + IllegalStateException.class + ); + + assertSame(failure, result); + assertEquals(1, log.size()); + assertEquals("validation", log.get(0)); + } + + @Test + void test_givenRealWorldLoggingScenario_whenOnFailureWithMultipleTypes_thenCorrectLoggingOccurs() { + Exception error = new java.io.IOException("network error"); + Try failure = Try.failure(error); + List logMessages = new ArrayList<>(); + + Try result = failure + .onFailure(java.io.IOException.class, e -> logMessages.add("ERROR: " + e.getMessage())) + .onFailure(java.util.concurrent.TimeoutException.class, e -> logMessages.add("WARN: " + e.getMessage())) + .onFailure(e -> logMessages.add("UNKNOWN: " + e.getMessage())); + + assertSame(failure, result); + assertEquals(2, logMessages.size()); + assertEquals("ERROR: network error", logMessages.get(0)); + assertEquals("UNKNOWN: network error", logMessages.get(1)); + } + @Test void test_givenSuccess_whenToResultIsCalled_thenResultIsReturned() { Try success = Try.success("data"); @@ -468,6 +866,228 @@ void test_givenFailure_whenToResultIsCalled_thenResultIsReturned() { assertSame(error, result.getError()); } + // ========== Tests for varargs recover method ========== + + @Test + void test_givenNullFailureMapper_whenRecoverWithVarargsIsCalled_thenExceptionIsThrown() { + Try failure = Try.failure(new RuntimeException("error")); + @SuppressWarnings("DataFlowIssue") + NullPointerException nullPointerException = assertThrows( + NullPointerException.class, + () -> failure.recover(null, RuntimeException.class, IllegalArgumentException.class) + ); + assertNotNull(nullPointerException); + } + + @Test + void test_givenNullExceptionTypesArray_whenRecoverWithVarargsIsCalled_thenExceptionIsThrown() { + Try failure = Try.failure(new RuntimeException("error")); + @SuppressWarnings({ "DataFlowIssue", "unchecked" }) + NullPointerException nullPointerException = assertThrows( + NullPointerException.class, + () -> failure.recover(e -> Try.success(0), (Class[]) null) + ); + assertNotNull(nullPointerException); + } + + @Test + void test_givenSuccess_whenRecoverWithVarargsIsCalled_thenSameSuccessIsReturned() { + Try success = Try.success("data"); + Try result = success.recover( + e -> Try.success("recovered"), + RuntimeException.class, + IllegalArgumentException.class + ); + + assertTrue(result.isSuccess()); + assertSame(success, result); + assertEquals("data", result.getData()); + } + + @Test + void test_givenFailureWithNoMatchingException_whenRecoverWithVarargsIsCalled_thenSameFailureIsReturned() { + Exception error = new IllegalArgumentException("error"); + Try failure = Try.failure(error); + Try result = failure.recover( + e -> Try.success("recovered"), + IllegalStateException.class, + NullPointerException.class + ); + + assertTrue(result.isFailure()); + assertSame(failure, result); + assertSame(error, result.getError()); + } + + @Test + void test_givenFailureWithEmptyVarargsArray_whenRecoverWithVarargsIsCalled_thenSameFailureIsReturned() { + Exception error = new RuntimeException("error"); + Try failure = Try.failure(error); + @SuppressWarnings("unchecked") + Try result = failure.recover( + e -> Try.success("recovered"), + new Class[0] // Explicitly pass empty array to call varargs version + ); + + assertTrue(result.isFailure()); + assertSame(failure, result); + assertSame(error, result.getError()); + } + + @Test + void test_givenFailureWithSingleMatchingException_whenRecoverWithVarargsIsCalled_thenRecoveredTryIsReturned() { + Exception error = new IllegalArgumentException("error"); + Try failure = Try.failure(error); + Try result = failure.recover( + e -> Try.success("recovered"), + IllegalArgumentException.class + ); + + assertTrue(result.isSuccess()); + assertEquals("recovered", result.getData()); + } + + @Test + void test_givenFailureWithFirstMatchingException_whenRecoverWithVarargsIsCalled_thenRecoveredTryIsReturned() { + Exception error = new IllegalArgumentException("error"); + Try failure = Try.failure(error); + Try result = failure.recover( + e -> Try.success("recovered"), + IllegalArgumentException.class, + IllegalStateException.class, + NullPointerException.class + ); + + assertTrue(result.isSuccess()); + assertEquals("recovered", result.getData()); + } + + @Test + void test_givenFailureWithSecondMatchingException_whenRecoverWithVarargsIsCalled_thenRecoveredTryIsReturned() { + Exception error = new IllegalStateException("error"); + Try failure = Try.failure(error); + Try result = failure.recover( + e -> Try.success("recovered"), + IllegalArgumentException.class, + IllegalStateException.class, + NullPointerException.class + ); + + assertTrue(result.isSuccess()); + assertEquals("recovered", result.getData()); + } + + @Test + void test_givenFailureWithLastMatchingException_whenRecoverWithVarargsIsCalled_thenRecoveredTryIsReturned() { + Exception error = new NullPointerException("error"); + Try failure = Try.failure(error); + Try result = failure.recover( + e -> Try.success("recovered"), + IllegalArgumentException.class, + IllegalStateException.class, + NullPointerException.class + ); + + assertTrue(result.isSuccess()); + assertEquals("recovered", result.getData()); + } + + @Test + void test_givenFailureWithMatchingExceptionSubclass_whenRecoverWithVarargsIsCalled_thenRecoveredTryIsReturned() { + Exception error = new IllegalArgumentException("error"); + Try failure = Try.failure(error); + Try result = failure.recover( + e -> Try.success("recovered"), + RuntimeException.class, + Exception.class + ); + + assertTrue(result.isSuccess()); + assertEquals("recovered", result.getData()); + } + + @Test + void test_givenFailureWithMatchingException_whenRecoverWithVarargsReturnsFailure_thenNewFailureIsReturned() { + Exception originalError = new IllegalArgumentException("original"); + Exception newError = new IllegalStateException("new error"); + Try failure = Try.failure(originalError); + Try result = failure.recover( + e -> Try.failure(newError), + IllegalArgumentException.class, + NullPointerException.class + ); + + assertTrue(result.isFailure()); + assertSame(newError, result.getError()); + } + + @Test + void test_givenFailureAndNullExceptionTypesInVarargs_whenRecoverWithVarargsIsCalled_thenNullsAreIgnored() { + Exception error = new IllegalArgumentException("error"); + Try failure = Try.failure(error); + @SuppressWarnings("unchecked") + Try result = failure.recover( + e -> Try.success("recovered"), + null, + IllegalArgumentException.class, + null + ); + + assertTrue(result.isSuccess()); + assertEquals("recovered", result.getData()); + } + + @Test + void test_givenFailureAndAllNullExceptionTypesInVarargs_whenRecoverWithVarargsIsCalled_thenSameFailureIsReturned() { + Exception error = new IllegalArgumentException("error"); + Try failure = Try.failure(error); + @SuppressWarnings("unchecked") + Try result = failure.recover( + e -> Try.success("recovered"), + null, + null + ); + + assertTrue(result.isFailure()); + assertSame(failure, result); + assertSame(error, result.getError()); + } + + @Test + void test_givenMultipleIOExceptions_whenRecoverWithVarargsIsCalled_thenSingleHandlerIsApplied() { + Exception error = new java.io.FileNotFoundException("file not found"); + Try failure = Try.failure(error); + Try result = failure.recover( + e -> Try.success("default content"), + java.io.IOException.class, + java.io.FileNotFoundException.class, + java.net.SocketTimeoutException.class + ); + + assertTrue(result.isSuccess()); + assertEquals("default content", result.getData()); + } + + @Test + void test_givenChainedRecoverCallsWithVarargs_whenDifferentExceptionGroups_thenCorrectHandlerIsApplied() { + Exception error = new IllegalArgumentException("validation error"); + Try failure = Try.failure(error); + Try result = failure + .recover( + e -> Try.success("io recovered"), + java.io.IOException.class, + java.util.concurrent.TimeoutException.class + ) + .recover( + e -> Try.success("validation recovered"), + IllegalArgumentException.class, + IllegalStateException.class + ); + + assertTrue(result.isSuccess()); + assertEquals("validation recovered", result.getData()); + } + @MethodSource("provideHashcodeCoverageCases") @ParameterizedTest void test_givenTry_whenHashcodeIsInvoked_thenRespectiveOutcomeIsReturned(Try tryObject, int expectedHashcode) {