diff --git a/src/main/java/de/unistuttgart/iste/meitrex/course_service/client/CourseServiceClient.java b/src/main/java/de/unistuttgart/iste/meitrex/course_service/client/CourseServiceClient.java new file mode 100644 index 0000000..6044787 --- /dev/null +++ b/src/main/java/de/unistuttgart/iste/meitrex/course_service/client/CourseServiceClient.java @@ -0,0 +1,102 @@ +package de.unistuttgart.iste.meitrex.course_service.client; + +import de.unistuttgart.iste.meitrex.course_service.exception.CourseServiceConnectionException; +import de.unistuttgart.iste.meitrex.generated.dto.CourseMembership; +import org.springframework.graphql.client.ClientGraphQlResponse; +import org.springframework.graphql.client.FieldAccessException; +import org.springframework.graphql.client.GraphQlClient; +import org.springframework.orm.jpa.JpaObjectRetrievalFailureException; +import reactor.core.publisher.SynchronousSink; + +import java.util.List; +import java.util.UUID; + +/* +Client allowing to query course memberships. + */ +public class CourseServiceClient { + + private static final long RETRY_COUNT = 3; + private final GraphQlClient graphQlClient; + + public CourseServiceClient(GraphQlClient graphQlClient) { + this.graphQlClient = graphQlClient; + } + + public List queryMembershipsInCourse(final UUID courseId) throws CourseServiceConnectionException { + if (courseId == null) { + throw new CourseServiceConnectionException("Error fetching courseMemberships from CourseService: Course ID cannot be null"); + } + + final String query = + """ + query($courseId: UUID!) { + coursesByIds(ids: [$courseId]) { + memberships { + userId + courseId + role + } + } + } + """; + String queryName = "coursesByIds[0].memberships"; + + List courseMembershipList = null; + + try { + courseMembershipList = graphQlClient.document(query) + .variable("courseId", courseId) + .execute() + .handle((ClientGraphQlResponse result, SynchronousSink> sink) + -> handleGraphQlResponse(result, sink, queryName)) + .retry(RETRY_COUNT) + .block(); + } catch (RuntimeException e) { + if (e.getCause() instanceof JpaObjectRetrievalFailureException && e.getMessage().contains("Entities(s) with id(s) %s not found".formatted(courseId))) { + throw new CourseServiceConnectionException(e.getMessage()); + } else { + unwrapCourseServiceConnectionException(e); + } + } + + if (courseMembershipList == null) { + throw new CourseServiceConnectionException("Error fetching courseMemberships from CourseService"); + } + + return courseMembershipList; + } + + private void handleGraphQlResponse(final ClientGraphQlResponse result, final SynchronousSink> sink, final String queryName) { + if (!result.isValid()) { + sink.error(new CourseServiceConnectionException(result.getErrors().toString())); + return; + } + + List retrievedCourseMemberships; + try { + retrievedCourseMemberships = result.field(queryName).toEntityList(CourseMembership.class); + } catch (FieldAccessException e) { + sink.error(new CourseServiceConnectionException(e.toString())); + return; + } + + // retrievedCourseMemberships == null is always false, therefore no check + if (retrievedCourseMemberships.isEmpty()) { + sink.error(new CourseServiceConnectionException("Error fetching courseMemberships from CourseService: CourseMembership List is empty.")); + return; + } + + sink.next(retrievedCourseMemberships); + } + + private static void unwrapCourseServiceConnectionException(final RuntimeException e) throws CourseServiceConnectionException { + // block wraps exceptions in a RuntimeException, so we need to unwrap them + if (e.getCause() instanceof final CourseServiceConnectionException courseServiceConnectionException) { + throw courseServiceConnectionException; + } + // if the exception is not a ContentServiceConnectionException, we don't know how to handle it + throw e; + } + +} diff --git a/src/main/java/de/unistuttgart/iste/meitrex/course_service/exception/CourseServiceConnectionException.java b/src/main/java/de/unistuttgart/iste/meitrex/course_service/exception/CourseServiceConnectionException.java new file mode 100644 index 0000000..18f101c --- /dev/null +++ b/src/main/java/de/unistuttgart/iste/meitrex/course_service/exception/CourseServiceConnectionException.java @@ -0,0 +1,7 @@ +package de.unistuttgart.iste.meitrex.course_service.exception; + +public class CourseServiceConnectionException extends Exception { + public CourseServiceConnectionException(String message) { + super(message); + } +} diff --git a/src/test/java/de/unistuttgart/iste/meitrex/course_service/client/CourseServiceClientTest.java b/src/test/java/de/unistuttgart/iste/meitrex/course_service/client/CourseServiceClientTest.java new file mode 100644 index 0000000..c4b9220 --- /dev/null +++ b/src/test/java/de/unistuttgart/iste/meitrex/course_service/client/CourseServiceClientTest.java @@ -0,0 +1,223 @@ +package de.unistuttgart.iste.meitrex.course_service.client; + +import de.unistuttgart.iste.meitrex.common.testutil.GraphQlApiTest; +import de.unistuttgart.iste.meitrex.common.user_handling.LoggedInUser; +import de.unistuttgart.iste.meitrex.course_service.exception.CourseServiceConnectionException; +import de.unistuttgart.iste.meitrex.course_service.persistence.entity.CourseEntity; +import de.unistuttgart.iste.meitrex.course_service.persistence.entity.CourseMembershipEntity; +import de.unistuttgart.iste.meitrex.course_service.persistence.repository.CourseMembershipRepository; +import de.unistuttgart.iste.meitrex.course_service.persistence.repository.CourseRepository; +import de.unistuttgart.iste.meitrex.generated.dto.CourseMembership; +import de.unistuttgart.iste.meitrex.generated.dto.UserRoleInCourse; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.graphql.client.GraphQlClient; +import org.springframework.test.web.reactive.server.WebTestClient; +import org.springframework.test.web.servlet.client.MockMvcWebTestClient; +import org.springframework.web.context.WebApplicationContext; + +import java.time.OffsetDateTime; +import java.util.ArrayList; +import java.util.List; +import java.util.UUID; + +import static de.unistuttgart.iste.meitrex.common.testutil.TestUsers.userWithMembershipInCourseWithId; +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.*; + +@GraphQlApiTest +public class CourseServiceClientTest { + + private CourseEntity course; + + private GraphQlClient graphQlClient; + + @Autowired + private WebApplicationContext applicationContext; + + @Autowired + private CourseRepository courseRepository; + + @Autowired + private CourseMembershipRepository courseMembershipRepository; + + @BeforeEach + void setUp() { + this.course = courseRepository.save(createTestCourse()); + LoggedInUser loggedInUser = userWithMembershipInCourseWithId(course.getId(), LoggedInUser.UserRoleInCourse.ADMINISTRATOR); + + WebTestClient webTestClient = MockMvcWebTestClient.bindToApplicationContext(applicationContext) + .configureClient().baseUrl("/graphql").defaultHeaders(httpHeaders -> httpHeaders.add("CurrentUser", getJson(loggedInUser))).build(); + + graphQlClient = GraphQlClient.builder(new WebTestClientTransport(webTestClient)).build(); + } + + @Test + void testQueryMembershipsInCourse() throws CourseServiceConnectionException { + final CourseServiceClient courseServiceClient = new CourseServiceClient(graphQlClient); + + final UUID userId1 = UUID.randomUUID(); + final UUID userId2 = UUID.randomUUID(); + final UUID userId3 = UUID.randomUUID(); + + courseMembershipRepository.save(CourseMembershipEntity.builder() + .userId(userId1) + .courseId(course.getId()) + .role(UserRoleInCourse.ADMINISTRATOR) + .build()); + courseMembershipRepository.save(CourseMembershipEntity.builder() + .userId(userId2) + .courseId(course.getId()) + .role(UserRoleInCourse.STUDENT) + .build()); + courseMembershipRepository.save(CourseMembershipEntity.builder() + .userId(userId3) + .courseId(course.getId()) + .role(UserRoleInCourse.STUDENT) + .build()); + + List queriedMemberships = courseServiceClient.queryMembershipsInCourse(course.getId()); + + assertThat(queriedMemberships, hasSize(3)); + + System.out.println(queriedMemberships); + + CourseMembership membership1 = queriedMemberships.get(0); + CourseMembership membership2 = queriedMemberships.get(1); + CourseMembership membership3 = queriedMemberships.get(2); + + assertThat(membership1.getCourseId(), is(course.getId())); + assertThat(membership1.getRole(), is(UserRoleInCourse.ADMINISTRATOR)); + assertThat(membership1.getUserId(), is(userId1)); + + assertThat(membership2.getCourseId(), is(course.getId())); + assertThat(membership2.getRole(), is(UserRoleInCourse.STUDENT)); + assertThat(membership2.getUserId(), is(userId2)); + + assertThat(membership3.getCourseId(), is(course.getId())); + assertThat(membership3.getRole(), is(UserRoleInCourse.STUDENT)); + assertThat(membership3.getUserId(), is(userId3)); + + List queriedStudents = queriedMemberships.stream() + .filter(membership -> membership.getRole() == UserRoleInCourse.STUDENT) + .toList(); + + assertThat(queriedStudents, hasSize(2)); + + List studentIds = queriedStudents.stream().map(CourseMembership::getUserId).toList(); + + assertThat(studentIds, hasSize(2)); + + System.out.println(studentIds); + + } + + + @Test + void testQueryMembershipsInCourseNoMembers() { + final CourseServiceClient courseServiceClient = new CourseServiceClient(graphQlClient); + + try { + courseServiceClient.queryMembershipsInCourse(course.getId()); + assertThat(true, is(false)); + } catch (CourseServiceConnectionException e) { + assertThat(e.getMessage(), is("Error fetching courseMemberships from CourseService: CourseMembership List is empty.")); + } + + } + + @Test + void testQueryMembershipsInCourseWrongCourseId() { + final CourseServiceClient courseServiceClient = new CourseServiceClient(graphQlClient); + final UUID wrongCourseId = UUID.randomUUID(); + try { + courseServiceClient.queryMembershipsInCourse(wrongCourseId); + assertThat(true, is(false)); + } catch (CourseServiceConnectionException e) { + assertThat(e.getMessage(), containsString("Entities(s) with id(s) %s not found".formatted(wrongCourseId))); + } + } + + + @Test + void testQueryMembershipsInCourseNullCourseId() { + final CourseServiceClient courseServiceClient = new CourseServiceClient(graphQlClient); + try { + courseServiceClient.queryMembershipsInCourse(null); + assertThat(true, is(false)); + } catch (CourseServiceConnectionException e) { + assertThat(e.getMessage(), is("Error fetching courseMemberships from CourseService: Course ID cannot be null")); + } + } + + private static CourseEntity createTestCourse() { + return CourseEntity.builder() + .startDate(OffsetDateTime.parse("2021-01-01T00:00:00+00:00")) + .endDate(OffsetDateTime.parse("2021-01-01T00:00:00+00:00")) + .title("Test Course") + .description("Test Description") + .chapters(new ArrayList<>()) + .build(); + } + + + /* + Copied from de.unistuttgart.iste.meitrex.common.testutil.HeaderUtils + */ + private static String getJson(final LoggedInUser user) { + + final StringBuilder courseMemberships = new StringBuilder().append("["); + final StringBuilder realmRoles = new StringBuilder().append("["); + + for (int i = 0; i < user.getCourseMemberships().size(); i++) { + final LoggedInUser.CourseMembership courseMembership = user.getCourseMemberships().get(i); + + courseMemberships.append("{") + .append("\"courseId\": \"").append(courseMembership.getCourseId()).append("\",") + .append("\"role\": \"").append(courseMembership.getRole()).append("\",") + .append("\"published\": ").append(courseMembership.isPublished()).append(",") + .append("\"startDate\": \"").append(courseMembership.getStartDate()).append("\",") + .append("\"endDate\": \"").append(courseMembership.getEndDate()).append("\"") + .append("}"); + + if (i < user.getCourseMemberships().size() - 1) { + courseMemberships.append(","); + } + } + + courseMemberships.append("]"); + + List roleStrings = LoggedInUser.RealmRole.getRoleStringsFromEnum(user.getRealmRoles()).stream().toList(); + + for (int j = 0; j < roleStrings.size(); j++) { + + realmRoles.append("\"") + .append(roleStrings.get(j)) + .append("\""); + + if (j < roleStrings.size() - 1) { + realmRoles.append(","); + } + } + + realmRoles.append("]"); + + return """ + { + "id": "%s", + "userName": "%s", + "firstName": "%s", + "lastName": "%s", + "courseMemberships": %s, + "realmRoles": %s + } + """ + .formatted(user.getId(), + user.getUserName(), + user.getFirstName(), + user.getLastName(), + courseMemberships.toString(), + realmRoles.toString()); + } +} diff --git a/src/test/java/de/unistuttgart/iste/meitrex/course_service/client/WebTestClientTransport.java b/src/test/java/de/unistuttgart/iste/meitrex/course_service/client/WebTestClientTransport.java new file mode 100644 index 0000000..694bc32 --- /dev/null +++ b/src/test/java/de/unistuttgart/iste/meitrex/course_service/client/WebTestClientTransport.java @@ -0,0 +1,61 @@ +package de.unistuttgart.iste.meitrex.course_service.client; + + +// COPY PASTED from org.springframework.graphql.test.tester.WebTestClientTransport +// because it is not public +// author: Rossen Stoyanchev + +import org.springframework.core.ParameterizedTypeReference; +import org.springframework.graphql.GraphQlRequest; +import org.springframework.graphql.GraphQlResponse; +import org.springframework.graphql.client.GraphQlTransport; +import org.springframework.http.MediaType; +import org.springframework.test.web.reactive.server.WebTestClient; +import org.springframework.util.Assert; +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; + +import java.util.Collections; +import java.util.Map; + +public class WebTestClientTransport implements GraphQlTransport { + + private static final ParameterizedTypeReference> MAP_TYPE = + new ParameterizedTypeReference>() { + }; + + + private final WebTestClient webTestClient; + + + WebTestClientTransport(WebTestClient webTestClient) { + Assert.notNull(webTestClient, "WebTestClient is required"); + this.webTestClient = webTestClient; + } + + + @Override + public Mono execute(GraphQlRequest request) { + + Map responseMap = this.webTestClient.post() + .contentType(MediaType.APPLICATION_JSON) + .accept(MediaType.APPLICATION_JSON) + .bodyValue(request.toMap()) + .exchange() + .expectStatus().isOk() + .expectHeader().contentTypeCompatibleWith(MediaType.APPLICATION_JSON) + .expectBody(MAP_TYPE) + .returnResult() + .getResponseBody(); + + responseMap = (responseMap != null ? responseMap : Collections.emptyMap()); + GraphQlResponse response = GraphQlTransport.createResponse(responseMap); + return Mono.just(response); + } + + @Override + public Flux executeSubscription(GraphQlRequest request) { + throw new UnsupportedOperationException("Subscriptions not supported over HTTP"); + } + +} \ No newline at end of file