diff --git a/api.md b/api.md index b521e65..e58e2e5 100644 --- a/api.md +++ b/api.md @@ -4,17 +4,23 @@ Table of Contents * [Query](#query) + * [Mutation](#mutation) * [Objects](#objects) + * [Notification](#notification) * [PaginationInfo](#paginationinfo) * [PublicUserInfo](#publicuserinfo) + * [Settings](#settings) * [UserInfo](#userinfo) * [Inputs](#inputs) * [DateTimeFilter](#datetimefilter) * [IntFilter](#intfilter) + * [NotificationInput](#notificationinput) * [Pagination](#pagination) + * [SettingsInput](#settingsinput) * [StringFilter](#stringfilter) * [Enums](#enums) - * [GlobalUserRole](#globaluserrole) + * [Gamification](#gamification) + * [GlobalUserRole](#globaluserrole) * [SortDirection](#sortdirection) * [Scalars](#scalars) * [Boolean](#boolean) @@ -41,7 +47,7 @@ -findPublicUserInfos +findPublicUserInfos [PublicUserInfo]! @@ -56,7 +62,7 @@ If a user does not exist, null is returned for that user. -currentUserInfo +currentUserInfo UserInfo! @@ -65,7 +71,7 @@ Gets the user information of the currently authorized user. -findUserInfos +findUserInfos [UserInfo]! @@ -80,11 +86,95 @@ If a user does not exist, null is returned for that user. [UUID!]! + +findUserSettings +Settings! + + + +userId +UUID + + + +findUsersSettings +[Settings]! + + + +usersIds +[UUID]! + + + + + +## Mutation + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
FieldArgumentTypeDescription
updateSettingsSettings!
userIdUUID!
inputSettingsInput!
defaultSettingsSettings!
userIdUUID!
## Objects +### Notification + + + + + + + + + + + + + + + + + + + + + + +
FieldArgumentTypeDescription
gamificationBoolean
lectureBoolean
+ ### PaginationInfo Return type for information about paginated results. @@ -100,7 +190,7 @@ Return type for information about paginated results. -page +page Int! @@ -109,7 +199,7 @@ The current page number. -size +size Int! @@ -118,7 +208,7 @@ The number of elements per page. -totalElements +totalElements Int! @@ -127,7 +217,7 @@ The total number of elements across all pages. -totalPages +totalPages Int! @@ -136,7 +226,7 @@ The total number of pages. -hasNext +hasNext Boolean! @@ -160,18 +250,43 @@ Whether there is a next page. -id +id UUID! -userName +userName String! +### Settings + + + + + + + + + + + + + + + + + + + + + + +
FieldArgumentTypeDescription
gamificationGamification
notificationNotification
+ ### UserInfo @@ -185,27 +300,27 @@ Whether there is a next page. - + - + - + - + - + @@ -229,7 +344,7 @@ If multiple filters are specified, they are combined with AND. - + - + - + - + - +
idid UUID!
userNameuserName String!
firstNamefirstName String!
lastNamelastName String!
realmRolesrealmRoles [GlobalUserRole!]!
afterafter DateTime @@ -238,7 +353,7 @@ If specified, filters for dates after the specified value.
beforebefore DateTime @@ -264,7 +379,7 @@ If multiple filters are specified, they are combined with AND.
equalsequals Int @@ -273,7 +388,7 @@ An integer value to match exactly.
greaterThangreaterThan Int @@ -282,7 +397,7 @@ If specified, filters for values greater than to the specified value.
lessThanlessThan Int @@ -293,6 +408,30 @@ If specified, filters for values less than to the specified value.
+### NotificationInput + + + + + + + + + + + + + + + + + + + + + +
FieldTypeDescription
gamificationBoolean
lectureBoolean
+ ### Pagination Specifies the page size and page number for paginated results. @@ -307,7 +446,7 @@ Specifies the page size and page number for paginated results. -page +page Int! @@ -319,7 +458,7 @@ If this value is larger than the number of pages, an empty page is returned. -size +size Int! @@ -330,6 +469,30 @@ The number of elements per page. +### SettingsInput + + + + + + + + + + + + + + + + + + + + + +
FieldTypeDescription
gamificationGamification
notificationNotificationInput
+ ### StringFilter Filter for string values. @@ -345,7 +508,7 @@ If multiple filters are specified, they are combined with AND. -equals +equals String @@ -354,7 +517,7 @@ A string value to match exactly. -contains +contains String @@ -363,7 +526,7 @@ A string value that must be contained in the field that is being filtered. -ignoreCase +ignoreCase Boolean! @@ -376,12 +539,39 @@ If true, the filter is case-insensitive. ## Enums +### Gamification + + + + + + + + + + + + + + + + + + + + + + +
ValueDescription
GAMIFICATION_ENABLED
ADAPTIVE_GAMIFICATION_ENABLED
ALL_GAMIFICATION_DISABLED
+ ### GlobalUserRole + + @@ -401,8 +591,10 @@ Specifies the sort direction, either ascending or descending.
Value Description
+ + @@ -428,15 +620,13 @@ The `Boolean` scalar type represents `true` or `false`. ### Int -The `Int` scalar type represents non-fractional signed whole numeric values. Int can represent values between -(2^31) -and 2^31 - 1. +The `Int` scalar type represents non-fractional signed whole numeric values. Int can represent values between -(2^31) and 2^31 - 1. ### LocalTime ### String -The `String` scalar type represents textual data, represented as UTF-8 character sequences. The String type is most -often used by GraphQL to represent free-form human-readable text. +The `String` scalar type represents textual data, represented as UTF-8 character sequences. The String type is most often used by GraphQL to represent free-form human-readable text. ### Time diff --git a/build.gradle b/build.gradle index d4be657..8cd8a05 100644 --- a/build.gradle +++ b/build.gradle @@ -109,6 +109,8 @@ repositories { dependencies { implementation 'de.unistuttgart.iste.meitrex:meitrex-common:1.2.6' + implementation 'org.springframework.boot:spring-boot-starter-graphql' + implementation 'org.springframework.boot:spring-boot-starter-webflux' implementation 'org.springframework.boot:spring-boot-starter-data-jpa' implementation 'org.springframework.boot:spring-boot-starter-graphql' implementation 'org.springframework.boot:spring-boot-starter-validation' @@ -132,7 +134,7 @@ dependencies { testImplementation "org.mockito:mockito-core:5.+" testImplementation 'org.hamcrest:hamcrest:2.+' testImplementation "org.testcontainers:postgresql:1.+" - testImplementation "org.testcontainers:junit-jupiter:1.+" + testImplementation "org.testcontainers:junit-jupiter:1.18.3" } tasks.named('test') { diff --git a/src/main/java/de/unistuttgart/iste/meitrex/user_service/client/QueryDefinitions.java b/src/main/java/de/unistuttgart/iste/meitrex/user_service/client/QueryDefinitions.java new file mode 100644 index 0000000..e8542eb --- /dev/null +++ b/src/main/java/de/unistuttgart/iste/meitrex/user_service/client/QueryDefinitions.java @@ -0,0 +1,31 @@ +package de.unistuttgart.iste.meitrex.user_service.client; + +import lombok.NoArgsConstructor; + +/** + * GraphQL documents used by SettingsServiceClient. + */ +@NoArgsConstructor(access = lombok.AccessLevel.PRIVATE) +public final class QueryDefinitions { + + public static final String FIND_USER_SETTINGS_QUERY_NAME = "findUserSettings"; + public static final String FIND_USERS_SETTINGS_QUERY_NAME = "findUsersSettings"; + + public static final String FIND_USER_SETTINGS_QUERY = """ + query($userId: UUID!) { + findUserSettings(userId: $userId) { + gamification + notification { gamification lecture } + } + } + """; + + public static final String FIND_USERS_SETTINGS_QUERY = """ + query($usersIds: [UUID!]!) { + findUsersSettings(usersIds: $usersIds) { + gamification + notification { gamification lecture } + } + } + """; +} diff --git a/src/main/java/de/unistuttgart/iste/meitrex/user_service/client/SettingsServiceClient.java b/src/main/java/de/unistuttgart/iste/meitrex/user_service/client/SettingsServiceClient.java new file mode 100644 index 0000000..7072f7c --- /dev/null +++ b/src/main/java/de/unistuttgart/iste/meitrex/user_service/client/SettingsServiceClient.java @@ -0,0 +1,111 @@ +package de.unistuttgart.iste.meitrex.user_service.client; + +import de.unistuttgart.iste.meitrex.generated.dto.Settings; +import de.unistuttgart.iste.meitrex.user_service.exception.UserServiceConnectionException; +import lombok.extern.slf4j.Slf4j; +import org.springframework.graphql.client.ClientGraphQlResponse; +import org.springframework.graphql.client.GraphQlClient; +import reactor.core.publisher.SynchronousSink; + +import java.util.*; +import java.util.UUID; + +/** + * Client for the user-service to query user settings over GraphQL. + */ +@Slf4j +public class SettingsServiceClient { + + private static final long RETRY_COUNT = 3; + private final GraphQlClient graphQlClient; + + public SettingsServiceClient(final GraphQlClient graphQlClient) { + this.graphQlClient = graphQlClient; + } + + /** + * Query settings for a single user. + * + * @param userId the user id + * @return settings of the user (never null on success) + * @throws UserServiceConnectionException if the response has GraphQL errors, the field is missing/null, or mapping fails + */ + public Settings queryUserSettings(final UUID userId) throws UserServiceConnectionException { + try { + return graphQlClient.document(QueryDefinitions.FIND_USER_SETTINGS_QUERY) + .variable("userId", userId) + .execute() + .handle((ClientGraphQlResponse result, SynchronousSink sink) -> { + if (!result.isValid()) { + sink.error(new UserServiceConnectionException( + "Invalid response from user-service (findUserSettings).", + result.getErrors())); + return; + } + try { + final Settings s = result + .field(QueryDefinitions.FIND_USER_SETTINGS_QUERY_NAME) + .toEntity(Settings.class); + if (s == null) { + sink.error(new UserServiceConnectionException( + "Missing field 'findUserSettings' in user-service response.")); + return; + } + sink.next(s); + sink.complete(); + } catch (Exception mappingEx) { + sink.error(new UserServiceConnectionException( + "Failed to map 'findUserSettings' from user-service response: " + + mappingEx.getMessage())); + } + }) + .retry(RETRY_COUNT) + .block(); + } catch (final RuntimeException e) { + UserServiceConnectionException.unwrapAndThrow(e); + return null; + } + } + + /** + * Query settings for multiple users. + * + * @param userIds list of user ids + * @return list of settings (never null; may be empty) + * @throws UserServiceConnectionException if the response has GraphQL errors or mapping fails + */ + public List queryUsersSettings(final List userIds) throws UserServiceConnectionException { + if (userIds == null || userIds.isEmpty()) { + return List.of(); + } + try { + return graphQlClient.document(QueryDefinitions.FIND_USERS_SETTINGS_QUERY) + .variable("usersIds", userIds) + .execute() + .handle((ClientGraphQlResponse result, SynchronousSink> sink) -> { + if (!result.isValid()) { + sink.error(new UserServiceConnectionException( + "Invalid response from user-service (findUsersSettings).", + result.getErrors())); + return; + } + try { + final List list = result + .field(QueryDefinitions.FIND_USERS_SETTINGS_QUERY_NAME) + .toEntityList(Settings.class); + sink.next(list != null ? list : List.of()); + sink.complete(); + } catch (Exception mappingEx) { + sink.error(new UserServiceConnectionException( + "Failed to map 'findUsersSettings' from user-service response: " + + mappingEx.getMessage())); + } + }) + .retry(RETRY_COUNT) + .block(); + } catch (final RuntimeException e) { + UserServiceConnectionException.unwrapAndThrow(e); + return List.of(); // unreachable + } + } +} diff --git a/src/main/java/de/unistuttgart/iste/meitrex/user_service/exception/UserServiceConnectionException.java b/src/main/java/de/unistuttgart/iste/meitrex/user_service/exception/UserServiceConnectionException.java new file mode 100644 index 0000000..cef8a87 --- /dev/null +++ b/src/main/java/de/unistuttgart/iste/meitrex/user_service/exception/UserServiceConnectionException.java @@ -0,0 +1,52 @@ +package de.unistuttgart.iste.meitrex.user_service.exception; + +import org.springframework.graphql.ResponseError; + +import java.util.List; + +/** + * Exception thrown when the connection to the user-service fails or returns an invalid response. + */ +public class UserServiceConnectionException extends Exception { + + private final String message; + + public UserServiceConnectionException(final String message) { + super(message); + this.message = message; + } + + public UserServiceConnectionException(final String message, final List errors) { + super(withErrors(message, errors)); + this.message = withErrors(message, errors); + } + + private static String withErrors(final String base, final List errors) { + if (errors == null || errors.isEmpty()) return base; + final StringBuilder sb = new StringBuilder(base).append('\n') + .append("GraphQL Response Errors:\n"); + for (final ResponseError err : errors) { + sb.append(err.getMessage()).append(" at path ").append(err.getPath()).append('\n'); + } + return sb.toString(); + } + + @Override + public String getMessage() { + return this.message; + } + + /** + * Unwraps a RuntimeException thrown by reactive pipelines and rethrows as UserServiceConnectionException when possible. + */ + public static void unwrapAndThrow(final RuntimeException e) throws UserServiceConnectionException { + Throwable t = e; + while (t != null) { + if (t instanceof UserServiceConnectionException uce) { + throw uce; + } + t = t.getCause(); + } + throw e; + } +} diff --git a/src/test/java/de/unistuttgart/iste/meitrex/client/SettingsServiceClientTest.java b/src/test/java/de/unistuttgart/iste/meitrex/client/SettingsServiceClientTest.java new file mode 100644 index 0000000..a01ed83 --- /dev/null +++ b/src/test/java/de/unistuttgart/iste/meitrex/client/SettingsServiceClientTest.java @@ -0,0 +1,339 @@ +package de.unistuttgart.iste.meitrex.client; + +import de.unistuttgart.iste.meitrex.generated.dto.Gamification; +import de.unistuttgart.iste.meitrex.generated.dto.Settings; +import de.unistuttgart.iste.meitrex.user_service.client.SettingsServiceClient; +import de.unistuttgart.iste.meitrex.user_service.exception.UserServiceConnectionException; +import graphql.ErrorType; +import org.junit.jupiter.api.Test; +import org.springframework.graphql.ResponseError; + +import java.lang.reflect.Proxy; +import java.util.List; +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.*; +import org.springframework.graphql.client.GraphQlClient; +import org.springframework.graphql.client.HttpGraphQlClient; +import org.springframework.http.HttpStatus; +import org.springframework.web.reactive.function.client.ClientResponse; +import org.springframework.web.reactive.function.client.ExchangeFunction; +import org.springframework.web.reactive.function.client.WebClient; +import reactor.core.publisher.Mono; + +import java.util.UUID; +import java.util.concurrent.atomic.AtomicInteger; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * Tests for SettingsServiceClient + */ +class SettingsServiceClientTest { + + @Test + void queryUserSettings_success() throws Exception { + String json = """ + { + "data": { + "findUserSettings": { + "gamification": "GAMIFICATION_ENABLED", + "notification": { "gamification": true, "lecture": false } + } + } + } + """; + + SettingsServiceClient client = new SettingsServiceClient(mockGraphQl(json)); + + UUID userId = UUID.randomUUID(); + Settings s = client.queryUserSettings(userId); + + assertNotNull(s); + assertEquals(Gamification.GAMIFICATION_ENABLED, s.getGamification()); + assertNotNull(s.getNotification()); + assertEquals(Boolean.TRUE, s.getNotification().getGamification()); + assertEquals(Boolean.FALSE, s.getNotification().getLecture()); + } + + @Test + void queryUsersSettings_success() throws Exception { + String json = """ + { + "data": { + "findUsersSettings": [ + { + "gamification": "GAMIFICATION_ENABLED", + "notification": { "gamification": true, "lecture": false } + }, + { + "gamification": "ALL_GAMIFICATION_DISABLED", + "notification": { "gamification": false, "lecture": true } + } + ] + } + } + """; + + SettingsServiceClient client = new SettingsServiceClient(mockGraphQl(json)); + + var u1 = UUID.randomUUID(); + var u2 = UUID.randomUUID(); + List list = client.queryUsersSettings(List.of(u1, u2)); + + assertNotNull(list); + assertEquals(2, list.size()); + + assertEquals(Gamification.GAMIFICATION_ENABLED, list.get(0).getGamification()); + assertEquals(Boolean.TRUE, list.get(0).getNotification().getGamification()); + assertEquals(Boolean.FALSE, list.get(0).getNotification().getLecture()); + + assertEquals(Gamification.ALL_GAMIFICATION_DISABLED, list.get(1).getGamification()); + assertEquals(Boolean.FALSE, list.get(1).getNotification().getGamification()); + assertEquals(Boolean.TRUE, list.get(1).getNotification().getLecture()); + } + + @Test + void queryUserSettings_graphQlErrors_throws() { + String json = """ + { + "data": { "findUserSettings": null }, + "errors": [ { "message": "boom", "path": ["findUserSettings"] } ] + } + """; + + ExchangeFunction fx = req -> { + ClientResponse resp = ClientResponse.create(HttpStatus.OK) + .header("Content-Type", "application/json") + .body(json) + .build(); + return Mono.just(resp); + }; + WebClient webClient = WebClient.builder().exchangeFunction(fx).build(); + GraphQlClient gql = HttpGraphQlClient.builder(webClient).build(); + + SettingsServiceClient client = new SettingsServiceClient(gql); + + assertThrows(UserServiceConnectionException.class, + () -> client.queryUserSettings(UUID.randomUUID())); + } + + @Test + void queryUserSettings_fieldMissing_throws() { + String json = "{ \"data\": { } }"; + SettingsServiceClient client = new SettingsServiceClient(gqlWithJson(json)); + + assertThrows(UserServiceConnectionException.class, + () -> client.queryUserSettings(UUID.randomUUID())); + } + + + @Test + void queryUserSettings_retriesThenSuccess() throws Exception { + String err = "{ \"data\": { \"findUserSettings\": null }, \"errors\": [ { \"message\": \"e\" } ] }"; + String ok = """ + { "data": { "findUserSettings": { + "gamification": "ALL_GAMIFICATION_DISABLED", + "notification": { "gamification": false, "lecture": true } + } } } + """; + SettingsServiceClient client = new SettingsServiceClient(gqlWithSequence(err, err, ok)); + + Settings s = client.queryUserSettings(UUID.randomUUID()); + + assertNotNull(s); + assertEquals(Gamification.ALL_GAMIFICATION_DISABLED, s.getGamification()); + assertEquals(Boolean.FALSE, s.getNotification().getGamification()); + assertEquals(Boolean.TRUE, s.getNotification().getLecture()); + } + + @Test + void queryUsersSettings_emptyInput_noHttpCall() { + AtomicInteger calls = new AtomicInteger(0); + SettingsServiceClient client = new SettingsServiceClient(gqlCounting(calls)); + + try { + List list = client.queryUsersSettings(List.of()); + assertNotNull(list); + assertTrue(list.isEmpty()); + assertEquals(0, calls.get(), "No HTTP call should be made for empty input"); + } catch (UserServiceConnectionException e) { + fail("Unexpected exception: " + e.getMessage()); + } + } + + + + @Test + void queryUsersSettings_mappingError_throws() { + String json = "{ \"data\": { \"findUsersSettings\": { \"not\": \"a list\" } } }"; + SettingsServiceClient client = new SettingsServiceClient(gqlWithJson(json)); + + assertThrows(UserServiceConnectionException.class, + () -> client.queryUsersSettings(List.of(UUID.randomUUID()))); + } + + + /** + * Creates a GraphQlClient backed by a WebClient that always returns the given JSON. + * No server, no extra deps. + */ + private static GraphQlClient mockGraphQl(String jsonResponse) { + ExchangeFunction fx = request -> { + ClientResponse resp = ClientResponse + .create(HttpStatus.OK) + .header("Content-Type", "application/json") + .body(jsonResponse) + .build(); + return Mono.just(resp); + }; + WebClient webClient = WebClient.builder() + .exchangeFunction(fx) + .build(); + return HttpGraphQlClient.builder(webClient).build(); + } + + private static GraphQlClient gqlWithJson(String json) { + ExchangeFunction fx = req -> Mono.just( + ClientResponse.create(HttpStatus.OK) + .header("Content-Type", "application/json") + .body(json) + .build() + ); + WebClient webClient = WebClient.builder().exchangeFunction(fx).build(); + return HttpGraphQlClient.builder(webClient).build(); + } + + private static GraphQlClient gqlWithSequence(String... jsonResponses) { + AtomicInteger idx = new AtomicInteger(0); + ExchangeFunction fx = req -> { + String body = jsonResponses[Math.min(idx.getAndIncrement(), jsonResponses.length - 1)]; + return Mono.just(ClientResponse.create(HttpStatus.OK) + .header("Content-Type", "application/json") + .body(body) + .build()); + }; + WebClient webClient = WebClient.builder().exchangeFunction(fx).build(); + return HttpGraphQlClient.builder(webClient).build(); + } + + private static GraphQlClient gqlCounting(AtomicInteger counter) { + ExchangeFunction fx = req -> { + counter.incrementAndGet(); + return Mono.just(ClientResponse.create(HttpStatus.OK) + .header("Content-Type", "application/json") + .body("{ \"data\": { \"findUsersSettings\": [] } }") + .build()); + }; + WebClient webClient = WebClient.builder().exchangeFunction(fx).build(); + return HttpGraphQlClient.builder(webClient).build(); + } + + private static GraphQlClient gqlErrorWrapped(String message) { + ExchangeFunction fx = req -> { + return Mono.error(new RuntimeException( + new UserServiceConnectionException(message) + )); + }; + WebClient webClient = WebClient.builder().exchangeFunction(fx).build(); + return HttpGraphQlClient.builder(webClient).build(); + } + + @Test + void queryUserSettings_runtimeWrapped_unwrapsToUserServiceConnectionException() { + SettingsServiceClient client = new SettingsServiceClient(gqlErrorWrapped("wrapped-error")); + assertThrows(UserServiceConnectionException.class, + () -> client.queryUserSettings(UUID.randomUUID())); + } + + @Test + void queryUsersSettings_runtimeWrapped_unwrapsToUserServiceConnectionException() { + SettingsServiceClient client = new SettingsServiceClient(gqlErrorWrapped("wrapped-error")); + assertThrows(UserServiceConnectionException.class, + () -> client.queryUsersSettings(List.of(UUID.randomUUID()))); + } + + @Test + void queryUsersSettings_nullList_normalizedToEmpty() throws UserServiceConnectionException { + String json = """ + { "data": { "findUsersSettings": null } } + """; + SettingsServiceClient client = new SettingsServiceClient(gqlWithJson(json)); + + List list = client.queryUsersSettings(List.of(UUID.randomUUID())); + org.junit.jupiter.api.Assertions.assertNotNull(list); + org.junit.jupiter.api.Assertions.assertTrue(list.isEmpty()); + } + + private static ResponseError responseError(String message) { + return (ResponseError) Proxy.newProxyInstance( + ResponseError.class.getClassLoader(), + new Class[]{ResponseError.class}, + (proxy, method, args) -> { + String name = method.getName(); + if ("getMessage".equals(name)) { + return message; + } + if ("toSpecification".equals(name)) { + return Map.of("message", message); + } + return null; + } + ); + } + + + @Test + void constructor_withMessage_keepsMessage() { + var ex = new UserServiceConnectionException("plain"); + assertEquals("plain", ex.getMessage()); + } + + @Test + void constructor_withErrors_formatsMessage() { + var base = "Invalid response"; + var ex = new UserServiceConnectionException(base, java.util.List.of( + responseError("boom1"), + responseError("boom2") + )); + + String msg = ex.getMessage(); + assertTrue(msg.contains(base)); + assertTrue(msg.contains("boom1")); + assertTrue(msg.contains("boom2")); + } + + @Test + void constructor_withNullErrors_fallsBackToBaseMessage() { + var ex = new UserServiceConnectionException("only-base", null); + assertEquals("only-base", ex.getMessage()); + } + + @Test + void unwrapAndThrow_directWrapped_throwsInnerUserEx() { + var inner = new UserServiceConnectionException("inner"); + var outer = new RuntimeException(inner); + + var thrown = assertThrows(UserServiceConnectionException.class, + () -> UserServiceConnectionException.unwrapAndThrow(outer)); + assertEquals("inner", thrown.getMessage()); + } + + @Test + void unwrapAndThrow_deeplyNested_throwsInnerUserEx() { + var inner = new UserServiceConnectionException("deep"); + var outer = new RuntimeException(new IllegalStateException(inner)); + + var thrown = assertThrows(UserServiceConnectionException.class, + () -> UserServiceConnectionException.unwrapAndThrow(outer)); + assertEquals("deep", thrown.getMessage()); + } + + @Test + void unwrapAndThrow_noUserEx_rethrowsOriginalRuntime() { + var outer = new RuntimeException(new IllegalArgumentException("no-user-ex")); + var rethrown = assertThrows(RuntimeException.class, + () -> UserServiceConnectionException.unwrapAndThrow(outer)); + assertSame(outer, rethrown); + } +}
Value Description