diff --git a/api.md b/api.md index de30479..b5ebe1a 100644 --- a/api.md +++ b/api.md @@ -1,810 +1,810 @@ -# User Service API - -
- Table of Contents - - * [Query](#query) - * [Mutation](#mutation) - * [Objects](#objects) - * [AccessToken](#accesstoken) - * [ExternalUserIdWithUser](#externaluseridwithuser) - * [Notification](#notification) - * [PaginationInfo](#paginationinfo) - * [PublicUserInfo](#publicuserinfo) - * [Settings](#settings) - * [UserInfo](#userinfo) - * [Inputs](#inputs) - * [DateTimeFilter](#datetimefilter) - * [GenerateAccessTokenInput](#generateaccesstokeninput) - * [IntFilter](#intfilter) - * [NotificationInput](#notificationinput) - * [Pagination](#pagination) - * [SettingsInput](#settingsinput) - * [StringFilter](#stringfilter) - * [Enums](#enums) - * [ExternalServiceProviderDto](#externalserviceproviderdto) - * [Gamification](#gamification) - * [GlobalUserRole](#globaluserrole) - * [SortDirection](#sortdirection) - * [Scalars](#scalars) - * [Boolean](#boolean) - * [Date](#date) - * [DateTime](#datetime) - * [Int](#int) - * [LocalTime](#localtime) - * [String](#string) - * [Time](#time) - * [UUID](#uuid) - * [Url](#url) - -
- -## Query - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
FieldArgumentTypeDescription
findPublicUserInfos[PublicUserInfo]! - -Gets the publicly available information for a list of users with the specified IDs. -If a user does not exist, null is returned for that user. - -
ids[UUID!]!
currentUserInfoUserInfo! - -Gets the user information of the currently authorized user. - -
findUserInfos[UserInfo]! - -Gets all of the users' information for a list of users with the specified IDs. -Only available to privileged users. -If a user does not exist, null is returned for that user. - -
ids[UUID!]!
isAccessTokenAvailableBoolean! - -Checks whether an access token for a given third-party provider exists and is still valid for the currently authenticated user. -Returns `true` if: -- The access token exists and is not expired, OR -- The refresh token exists and is not expired. - -
providerExternalServiceProviderDto!
_internal_noauth_getAccessTokenAccessToken! - -Retrieves the access token of the specified user for a specified third-party provider. - -- If the access token is expired but a valid refresh token is available, the system will attempt to generate a new access token. -- Throws an exception if neither a valid access token nor a valid refresh token is available. - -⚠️ This query is **only accessible internally**. ⚠️ - -
userIdUUID!
providerExternalServiceProviderDto!
_internal_noauth_getExternalUserIds[ExternalUserIdWithUser!]! - -Retrieves the external user IDs for a list of users with the specified IDs. -If a user does not exist, null is returned for that user. -If the user does not have an external user ID for the specified provider, null is returned for that user. -⚠️ This query is **only accessible internally** and the caller must be at least a tutor of a course. The permission must be validated by the caller method. ⚠️ - -
userIds[UUID!]!
providerExternalServiceProviderDto
findUserSettingsSettings!
userIdUUID
findUsersSettings[Settings]!
usersIds[UUID]!
- -## Mutation - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
FieldArgumentTypeDescription
generateAccessTokenBoolean! - -Generates an access token for the given provider using an authorization code obtained from the OAuth flow. -This should be called **only after** the user completes authorization and the frontend retrieves the auth code. -After the access token is generated, the user is redirected to the redirect URI. - -
inputGenerateAccessTokenInput!
updateSettingsSettings!
userIdUUID!
inputSettingsInput!
defaultSettingsSettings!
userIdUUID!
- -## Objects - -### AccessToken - - - - - - - - - - - - - - - - - - - - - - -
FieldArgumentTypeDescription
accessTokenString!
externalUserIdString
- -### ExternalUserIdWithUser - - - - - - - - - - - - - - - - - - - - - - -
FieldArgumentTypeDescription
userIdUUID!
externalUserIdString!
- -### Notification - - - - - - - - - - - - - - - - - - - - - - -
FieldArgumentTypeDescription
gamificationBoolean
lectureBoolean
- -### PaginationInfo - -Return type for information about paginated results. - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
FieldArgumentTypeDescription
pageInt! - -The current page number. - -
sizeInt! - -The number of elements per page. - -
totalElementsInt! - -The total number of elements across all pages. - -
totalPagesInt! - -The total number of pages. - -
hasNextBoolean! - -Whether there is a next page. - -
- -### PublicUserInfo - - - - - - - - - - - - - - - - - - - - - - -
FieldArgumentTypeDescription
idUUID!
userNameString!
- -### Settings - - - - - - - - - - - - - - - - - - - - - - -
FieldArgumentTypeDescription
gamificationGamification
notificationNotification
- -### UserInfo - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
FieldArgumentTypeDescription
idUUID!
userNameString!
firstNameString!
lastNameString!
realmRoles[GlobalUserRole!]!
- -## Inputs - -### DateTimeFilter - -Filter for date values. -If multiple filters are specified, they are combined with AND. - - - - - - - - - - - - - - - - - - - - - -
FieldTypeDescription
afterDateTime - -If specified, filters for dates after the specified value. - -
beforeDateTime - -If specified, filters for dates before the specified value. - -
- -### GenerateAccessTokenInput - - - - - - - - - - - - - - - - - - - - - -
FieldTypeDescription
providerExternalServiceProviderDto!
authorizationCodeString!
- -### IntFilter - -Filter for integer values. -If multiple filters are specified, they are combined with AND. - - - - - - - - - - - - - - - - - - - - - - - - - - -
FieldTypeDescription
equalsInt - -An integer value to match exactly. - -
greaterThanInt - -If specified, filters for values greater than to the specified value. - -
lessThanInt - -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. - - - - - - - - - - - - - - - - - - - - - -
FieldTypeDescription
pageInt! - -The page number, starting at 0. -If not specified, the default value is 0. -For values greater than 0, the page size must be specified. -If this value is larger than the number of pages, an empty page is returned. - -
sizeInt! - -The number of elements per page. - -
- -### SettingsInput - - - - - - - - - - - - - - - - - - - - - -
FieldTypeDescription
gamificationGamification
notificationNotificationInput
- -### StringFilter - -Filter for string values. -If multiple filters are specified, they are combined with AND. - - - - - - - - - - - - - - - - - - - - - - - - - - -
FieldTypeDescription
equalsString - -A string value to match exactly. - -
containsString - -A string value that must be contained in the field that is being filtered. - -
ignoreCaseBoolean! - -If true, the filter is case-insensitive. - -
- -## Enums - -### ExternalServiceProviderDto - - - - - - - - - - - - - - -
ValueDescription
GITHUB
- -### Gamification - - - - - - - - - - - - - - - - - - - - - - -
ValueDescription
GAMIFICATION_ENABLED
ADAPTIVE_GAMIFICATION_ENABLED
ALL_GAMIFICATION_DISABLED
- -### GlobalUserRole - - - - - - - - - - - - - - - - - - -
ValueDescription
SUPER_USER
COURSE_CREATOR
- -### SortDirection - -Specifies the sort direction, either ascending or descending. - - - - - - - - - - - - - - - - - - -
ValueDescription
ASC
DESC
- -## Scalars - -### Boolean - -The `Boolean` scalar type represents `true` or `false`. - -### Date - -### DateTime - -### Int - -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. - -### Time - -### UUID - -### Url - +# User Service API + +
+ Table of Contents + + * [Query](#query) + * [Mutation](#mutation) + * [Objects](#objects) + * [AccessToken](#accesstoken) + * [ExternalUserIdWithUser](#externaluseridwithuser) + * [Notification](#notification) + * [PaginationInfo](#paginationinfo) + * [PublicUserInfo](#publicuserinfo) + * [Settings](#settings) + * [UserInfo](#userinfo) + * [Inputs](#inputs) + * [DateTimeFilter](#datetimefilter) + * [GenerateAccessTokenInput](#generateaccesstokeninput) + * [IntFilter](#intfilter) + * [NotificationInput](#notificationinput) + * [Pagination](#pagination) + * [SettingsInput](#settingsinput) + * [StringFilter](#stringfilter) + * [Enums](#enums) + * [ExternalServiceProviderDto](#externalserviceproviderdto) + * [Gamification](#gamification) + * [GlobalUserRole](#globaluserrole) + * [SortDirection](#sortdirection) + * [Scalars](#scalars) + * [Boolean](#boolean) + * [Date](#date) + * [DateTime](#datetime) + * [Int](#int) + * [LocalTime](#localtime) + * [String](#string) + * [Time](#time) + * [UUID](#uuid) + * [Url](#url) + +
+ +## Query + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
FieldArgumentTypeDescription
findPublicUserInfos[PublicUserInfo]! + +Gets the publicly available information for a list of users with the specified IDs. +If a user does not exist, null is returned for that user. + +
ids[UUID!]!
currentUserInfoUserInfo! + +Gets the user information of the currently authorized user. + +
findUserInfos[UserInfo]! + +Gets all of the users' information for a list of users with the specified IDs. +Only available to privileged users. +If a user does not exist, null is returned for that user. + +
ids[UUID!]!
isAccessTokenAvailableBoolean! + +Checks whether an access token for a given third-party provider exists and is still valid for the currently authenticated user. +Returns `true` if: +- The access token exists and is not expired, OR +- The refresh token exists and is not expired. + +
providerExternalServiceProviderDto!
_internal_noauth_getAccessTokenAccessToken! + +Retrieves the access token of the specified user for a specified third-party provider. + +- If the access token is expired but a valid refresh token is available, the system will attempt to generate a new access token. +- Throws an exception if neither a valid access token nor a valid refresh token is available. + +⚠️ This query is **only accessible internally**. ⚠️ + +
userIdUUID!
providerExternalServiceProviderDto!
_internal_noauth_getExternalUserIds[ExternalUserIdWithUser!]! + +Retrieves the external user IDs for a list of users with the specified IDs. +If a user does not exist, null is returned for that user. +If the user does not have an external user ID for the specified provider, null is returned for that user. +⚠️ This query is **only accessible internally** and the caller must be at least a tutor of a course. The permission must be validated by the caller method. ⚠️ + +
userIds[UUID!]!
providerExternalServiceProviderDto
findUserSettingsSettings!
userIdUUID
findUsersSettings[Settings]!
usersIds[UUID]!
+ +## Mutation + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
FieldArgumentTypeDescription
generateAccessTokenBoolean! + +Generates an access token for the given provider using an authorization code obtained from the OAuth flow. +This should be called **only after** the user completes authorization and the frontend retrieves the auth code. +After the access token is generated, the user is redirected to the redirect URI. + +
inputGenerateAccessTokenInput!
updateSettingsSettings!
userIdUUID!
inputSettingsInput!
defaultSettingsSettings!
userIdUUID!
+ +## Objects + +### AccessToken + + + + + + + + + + + + + + + + + + + + + + +
FieldArgumentTypeDescription
accessTokenString!
externalUserIdString
+ +### ExternalUserIdWithUser + + + + + + + + + + + + + + + + + + + + + + +
FieldArgumentTypeDescription
userIdUUID!
externalUserIdString!
+ +### Notification + + + + + + + + + + + + + + + + + + + + + + +
FieldArgumentTypeDescription
gamificationBoolean
lectureBoolean
+ +### PaginationInfo + +Return type for information about paginated results. + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
FieldArgumentTypeDescription
pageInt! + +The current page number. + +
sizeInt! + +The number of elements per page. + +
totalElementsInt! + +The total number of elements across all pages. + +
totalPagesInt! + +The total number of pages. + +
hasNextBoolean! + +Whether there is a next page. + +
+ +### PublicUserInfo + + + + + + + + + + + + + + + + + + + + + + +
FieldArgumentTypeDescription
idUUID!
userNameString!
+ +### Settings + + + + + + + + + + + + + + + + + + + + + + +
FieldArgumentTypeDescription
gamificationGamification
notificationNotification
+ +### UserInfo + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
FieldArgumentTypeDescription
idUUID!
userNameString!
firstNameString!
lastNameString!
realmRoles[GlobalUserRole!]!
+ +## Inputs + +### DateTimeFilter + +Filter for date values. +If multiple filters are specified, they are combined with AND. + + + + + + + + + + + + + + + + + + + + + +
FieldTypeDescription
afterDateTime + +If specified, filters for dates after the specified value. + +
beforeDateTime + +If specified, filters for dates before the specified value. + +
+ +### GenerateAccessTokenInput + + + + + + + + + + + + + + + + + + + + + +
FieldTypeDescription
providerExternalServiceProviderDto!
authorizationCodeString!
+ +### IntFilter + +Filter for integer values. +If multiple filters are specified, they are combined with AND. + + + + + + + + + + + + + + + + + + + + + + + + + + +
FieldTypeDescription
equalsInt + +An integer value to match exactly. + +
greaterThanInt + +If specified, filters for values greater than to the specified value. + +
lessThanInt + +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. + + + + + + + + + + + + + + + + + + + + + +
FieldTypeDescription
pageInt! + +The page number, starting at 0. +If not specified, the default value is 0. +For values greater than 0, the page size must be specified. +If this value is larger than the number of pages, an empty page is returned. + +
sizeInt! + +The number of elements per page. + +
+ +### SettingsInput + + + + + + + + + + + + + + + + + + + + + +
FieldTypeDescription
gamificationGamification
notificationNotificationInput
+ +### StringFilter + +Filter for string values. +If multiple filters are specified, they are combined with AND. + + + + + + + + + + + + + + + + + + + + + + + + + + +
FieldTypeDescription
equalsString + +A string value to match exactly. + +
containsString + +A string value that must be contained in the field that is being filtered. + +
ignoreCaseBoolean! + +If true, the filter is case-insensitive. + +
+ +## Enums + +### ExternalServiceProviderDto + + + + + + + + + + + + + + +
ValueDescription
GITHUB
+ +### Gamification + + + + + + + + + + + + + + + + + + + + + + +
ValueDescription
GAMIFICATION_ENABLED
ADAPTIVE_GAMIFICATION_ENABLED
ALL_GAMIFICATION_DISABLED
+ +### GlobalUserRole + + + + + + + + + + + + + + + + + + +
ValueDescription
SUPER_USER
COURSE_CREATOR
+ +### SortDirection + +Specifies the sort direction, either ascending or descending. + + + + + + + + + + + + + + + + + + +
ValueDescription
ASC
DESC
+ +## Scalars + +### Boolean + +The `Boolean` scalar type represents `true` or `false`. + +### Date + +### DateTime + +### Int + +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. + +### Time + +### UUID + +### Url + diff --git a/build.gradle b/build.gradle index ef929da..21e2aff 100644 --- a/build.gradle +++ b/build.gradle @@ -110,7 +110,7 @@ repositories { dependencies { implementation("com.google.code.gson:gson:2.10.1") - implementation 'de.unistuttgart.iste.meitrex:meitrex-common:1.4.6' + implementation 'de.unistuttgart.iste.meitrex:meitrex-common:1.4.9' 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' @@ -127,7 +127,7 @@ dependencies { runtimeOnly 'org.postgresql:postgresql' annotationProcessor 'org.springframework.boot:spring-boot-configuration-processor' annotationProcessor 'org.projectlombok:lombok' - testImplementation 'de.unistuttgart.iste.meitrex:meitrex-common-test:1.4.6' + testImplementation 'de.unistuttgart.iste.meitrex:meitrex-common-test:1.4.9' testImplementation 'org.springframework.boot:spring-boot-starter-test' testImplementation 'org.springframework:spring-webflux' testImplementation 'org.springframework.graphql:spring-graphql-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..8b8f06f --- /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.SettingServiceConnectionException; +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 SettingServiceConnectionException if the response has GraphQL errors, the field is missing/null, or mapping fails + */ + public Settings queryUserSettings(final UUID userId) throws SettingServiceConnectionException { + try { + return graphQlClient.document(QueryDefinitions.FIND_USER_SETTINGS_QUERY) + .variable("userId", userId) + .execute() + .handle((ClientGraphQlResponse result, SynchronousSink sink) -> { + if (!result.isValid()) { + sink.error(new SettingServiceConnectionException( + "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 SettingServiceConnectionException( + "Missing field 'findUserSettings' in user-service response.")); + return; + } + sink.next(s); + sink.complete(); + } catch (Exception mappingEx) { + sink.error(new SettingServiceConnectionException( + "Failed to map 'findUserSettings' from user-service response: " + + mappingEx.getMessage())); + } + }) + .retry(RETRY_COUNT) + .block(); + } catch (final RuntimeException e) { + SettingServiceConnectionException.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 SettingServiceConnectionException if the response has GraphQL errors or mapping fails + */ + public List queryUsersSettings(final List userIds) throws SettingServiceConnectionException { + 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 SettingServiceConnectionException( + "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 SettingServiceConnectionException( + "Failed to map 'findUsersSettings' from user-service response: " + + mappingEx.getMessage())); + } + }) + .retry(RETRY_COUNT) + .block(); + } catch (final RuntimeException e) { + SettingServiceConnectionException.unwrapAndThrow(e); + return List.of(); // unreachable + } + } +} diff --git a/src/main/java/de/unistuttgart/iste/meitrex/user_service/exception/SettingServiceConnectionException.java b/src/main/java/de/unistuttgart/iste/meitrex/user_service/exception/SettingServiceConnectionException.java new file mode 100644 index 0000000..d05aeae --- /dev/null +++ b/src/main/java/de/unistuttgart/iste/meitrex/user_service/exception/SettingServiceConnectionException.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 SettingServiceConnectionException extends Exception { + + private final String message; + + public SettingServiceConnectionException(final String message) { + super(message); + this.message = message; + } + + public SettingServiceConnectionException(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 SettingServiceConnectionException { + Throwable t = e; + while (t != null) { + if (t instanceof SettingServiceConnectionException uce) { + throw uce; + } + t = t.getCause(); + } + throw e; + } +} diff --git a/src/test/java/de/unistuttgart/iste/meitrex/settings/SettingsServiceClientTest.java b/src/test/java/de/unistuttgart/iste/meitrex/settings/SettingsServiceClientTest.java new file mode 100644 index 0000000..ec84b02 --- /dev/null +++ b/src/test/java/de/unistuttgart/iste/meitrex/settings/SettingsServiceClientTest.java @@ -0,0 +1,336 @@ +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.SettingServiceConnectionException; +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; + +/** + * 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(SettingServiceConnectionException.class, + () -> client.queryUserSettings(UUID.randomUUID())); + } + + @Test + void queryUserSettings_fieldMissing_throws() { + String json = "{ \"data\": { } }"; + SettingsServiceClient client = new SettingsServiceClient(gqlWithJson(json)); + + assertThrows(SettingServiceConnectionException.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 (SettingServiceConnectionException 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(SettingServiceConnectionException.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 SettingServiceConnectionException(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(SettingServiceConnectionException.class, + () -> client.queryUserSettings(UUID.randomUUID())); + } + + @Test + void queryUsersSettings_runtimeWrapped_unwrapsToUserServiceConnectionException() { + SettingsServiceClient client = new SettingsServiceClient(gqlErrorWrapped("wrapped-error")); + assertThrows(SettingServiceConnectionException.class, + () -> client.queryUsersSettings(List.of(UUID.randomUUID()))); + } + + @Test + void queryUsersSettings_nullList_normalizedToEmpty() throws SettingServiceConnectionException { + 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 SettingServiceConnectionException("plain"); + assertEquals("plain", ex.getMessage()); + } + + @Test + void constructor_withErrors_formatsMessage() { + var base = "Invalid response"; + var ex = new SettingServiceConnectionException(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 SettingServiceConnectionException("only-base", null); + assertEquals("only-base", ex.getMessage()); + } + + @Test + void unwrapAndThrow_directWrapped_throwsInnerUserEx() { + var inner = new SettingServiceConnectionException("inner"); + var outer = new RuntimeException(inner); + + var thrown = assertThrows(SettingServiceConnectionException.class, + () -> SettingServiceConnectionException.unwrapAndThrow(outer)); + assertEquals("inner", thrown.getMessage()); + } + + @Test + void unwrapAndThrow_deeplyNested_throwsInnerUserEx() { + var inner = new SettingServiceConnectionException("deep"); + var outer = new RuntimeException(new IllegalStateException(inner)); + + var thrown = assertThrows(SettingServiceConnectionException.class, + () -> SettingServiceConnectionException.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, + () -> SettingServiceConnectionException.unwrapAndThrow(outer)); + assertSame(outer, rethrown); + } +}