diff --git a/server/build.gradle b/server/build.gradle index f867f0234..b4bb1340c 100644 --- a/server/build.gradle +++ b/server/build.gradle @@ -5,7 +5,7 @@ plugins { id "jacoco" id "com.github.andygoossens.modernizer" version "1.12.0" id "com.gorylenko.gradle-git-properties" version "2.5.7" - id "org.springframework.boot" version "3.5.10" + id "org.springframework.boot" version "4.0.2" id "io.spring.dependency-management" version "1.1.7" id "com.github.ben-manes.versions" version "0.53.0" id "com.diffplug.spotless" version "8.2.1" @@ -35,13 +35,13 @@ repositories { dependencies { implementation "org.springframework.boot:spring-boot-starter-data-jpa" - implementation "org.springframework.boot:spring-boot-starter-web" + implementation "org.springframework.boot:spring-boot-starter-webmvc" implementation "org.springframework.boot:spring-boot-starter-validation" implementation "org.springframework.boot:spring-boot-starter-mail" implementation "org.springframework.boot:spring-boot-starter-webflux" implementation "org.springframework.boot:spring-boot-starter-security" - implementation "org.springframework.boot:spring-boot-starter-oauth2-client" - implementation "org.springframework.boot:spring-boot-starter-oauth2-resource-server" + implementation "org.springframework.boot:spring-boot-starter-security-oauth2-client" + implementation "org.springframework.boot:spring-boot-starter-security-oauth2-resource-server" implementation "org.springframework.boot:spring-boot-starter-thymeleaf" implementation "org.springframework.boot:spring-boot-starter-actuator" @@ -53,7 +53,7 @@ dependencies { // Avoid outdated version to prevent security issues implementation("net.minidev:json-smart") { version { strictly "2.6.0" } } - implementation "org.liquibase:liquibase-core:4.33.0" + implementation "org.springframework.boot:spring-boot-starter-liquibase" implementation "org.postgresql:postgresql:42.7.10" implementation "commons-io:commons-io:2.21.0" @@ -80,6 +80,7 @@ dependencies { exclude group: "com.vaadin.external.google", module: "android-json" exclude group: "org.xmlunit", module: "xmlunit-core" } + testImplementation "org.springframework.boot:spring-boot-starter-webmvc-test" testImplementation "org.mockito:mockito-core:5.21.0" testImplementation "org.mockito:mockito-junit-jupiter:5.21.0" @@ -92,6 +93,9 @@ dependencies { testImplementation "com.github.dasniko:testcontainers-keycloak:4.1.1" + testImplementation "com.icegreen:greenmail-junit5:2.1.8" + testImplementation "org.wiremock:wiremock-standalone:3.13.0" + testImplementation "org.junit.jupiter:junit-jupiter:${junit_version}" testImplementation "org.junit.jupiter:junit-jupiter-api:${junit_version}" testImplementation "org.junit.jupiter:junit-jupiter-engine:${junit_version}" diff --git a/server/gradle/wrapper/gradle-wrapper.jar b/server/gradle/wrapper/gradle-wrapper.jar index 8bdaf60c7..f8e1ee312 100644 Binary files a/server/gradle/wrapper/gradle-wrapper.jar and b/server/gradle/wrapper/gradle-wrapper.jar differ diff --git a/server/gradle/wrapper/gradle-wrapper.properties b/server/gradle/wrapper/gradle-wrapper.properties index 23449a2b5..37f78a6af 100644 --- a/server/gradle/wrapper/gradle-wrapper.properties +++ b/server/gradle/wrapper/gradle-wrapper.properties @@ -1,6 +1,6 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-9.2.1-bin.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-9.3.1-bin.zip networkTimeout=10000 validateDistributionUrl=true zipStoreBase=GRADLE_USER_HOME diff --git a/server/gradlew b/server/gradlew index ef07e0162..adff685a0 100755 --- a/server/gradlew +++ b/server/gradlew @@ -114,7 +114,6 @@ case "$( uname )" in #( NONSTOP* ) nonstop=true ;; esac -CLASSPATH="\\\"\\\"" # Determine the Java command to use to start the JVM. @@ -172,7 +171,6 @@ fi # For Cygwin or MSYS, switch paths to Windows format before running java if "$cygwin" || "$msys" ; then APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) - CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" ) JAVACMD=$( cygpath --unix "$JAVACMD" ) @@ -212,7 +210,6 @@ DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' set -- \ "-Dorg.gradle.appname=$APP_BASE_NAME" \ - -classpath "$CLASSPATH" \ -jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \ "$@" diff --git a/server/gradlew.bat b/server/gradlew.bat index 5eed7ee84..e509b2dd8 100644 --- a/server/gradlew.bat +++ b/server/gradlew.bat @@ -70,11 +70,10 @@ goto fail :execute @rem Setup the command line -set CLASSPATH= @rem Execute Gradle -"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %* +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %* :end @rem End local scope for the variables with windows NT shell diff --git a/server/src/main/java/de/tum/cit/aet/thesis/controller/ThesisController.java b/server/src/main/java/de/tum/cit/aet/thesis/controller/ThesisController.java index 2523fa246..1ec98b869 100644 --- a/server/src/main/java/de/tum/cit/aet/thesis/controller/ThesisController.java +++ b/server/src/main/java/de/tum/cit/aet/thesis/controller/ThesisController.java @@ -258,14 +258,14 @@ public ResponseEntity updateThesisInfo( thesis = thesisService.updateThesisInfo( thesis, - RequestValidator.validateStringMaxLength(payload.abstractText(), StringLimits.UNLIMITED_TEXT.getLimit()), - RequestValidator.validateStringMaxLength(payload.infoText(), StringLimits.UNLIMITED_TEXT.getLimit()) + RequestValidator.validateStringMaxLengthAllowNull(payload.abstractText(), StringLimits.UNLIMITED_TEXT.getLimit()), + RequestValidator.validateStringMaxLengthAllowNull(payload.infoText(), StringLimits.UNLIMITED_TEXT.getLimit()) ); thesis = thesisService.updateThesisTitles( thesis, - RequestValidator.validateStringMaxLength(payload.primaryTitle(), StringLimits.THESIS_TITLE.getLimit()), - RequestValidator.validateNotNull(payload.secondaryTitles()) + RequestValidator.validateStringMaxLengthAllowNull(payload.primaryTitle(), StringLimits.THESIS_TITLE.getLimit()), + payload.secondaryTitles() ); return ResponseEntity.ok(ThesisDto.fromThesisEntity(thesis, thesis.hasAdvisorAccess(currentUser), thesis.hasStudentAccess(currentUser))); diff --git a/server/src/main/java/de/tum/cit/aet/thesis/dto/ErrorDto.java b/server/src/main/java/de/tum/cit/aet/thesis/dto/ErrorDto.java index 50bbd42e0..1458f1398 100644 --- a/server/src/main/java/de/tum/cit/aet/thesis/dto/ErrorDto.java +++ b/server/src/main/java/de/tum/cit/aet/thesis/dto/ErrorDto.java @@ -7,10 +7,10 @@ public record ErrorDto( String message, String exception ) { - public static ErrorDto fromRuntimeException(RuntimeException error) { + public static ErrorDto fromException(Exception error) { return new ErrorDto( Instant.now(), - error.getMessage(), + error.getMessage() != null ? error.getMessage() : error.toString(), error.getClass().getName() ); } diff --git a/server/src/main/java/de/tum/cit/aet/thesis/entity/Thesis.java b/server/src/main/java/de/tum/cit/aet/thesis/entity/Thesis.java index bc50c7a0c..ff08978a0 100644 --- a/server/src/main/java/de/tum/cit/aet/thesis/entity/Thesis.java +++ b/server/src/main/java/de/tum/cit/aet/thesis/entity/Thesis.java @@ -81,7 +81,7 @@ public class Thesis { private ThesisVisibility visibility; @JdbcTypeCode(SqlTypes.ARRAY) - @Column(name = "keywords", columnDefinition = "text[]") + @Column(name = "keywords", columnDefinition = "text[]", nullable = false) private Set keywords = new HashSet<>(); @ManyToOne(fetch = FetchType.LAZY) diff --git a/server/src/main/java/de/tum/cit/aet/thesis/exception/ResponseExceptionHandler.java b/server/src/main/java/de/tum/cit/aet/thesis/exception/ResponseExceptionHandler.java index 2b39ab8e1..eb8b1509e 100644 --- a/server/src/main/java/de/tum/cit/aet/thesis/exception/ResponseExceptionHandler.java +++ b/server/src/main/java/de/tum/cit/aet/thesis/exception/ResponseExceptionHandler.java @@ -1,7 +1,5 @@ package de.tum.cit.aet.thesis.exception; -import com.fasterxml.jackson.core.JsonParseException; -import com.fasterxml.jackson.core.JsonProcessingException; import de.tum.cit.aet.thesis.dto.ErrorDto; import de.tum.cit.aet.thesis.exception.request.AccessDeniedException; import de.tum.cit.aet.thesis.exception.request.ResourceAlreadyExistsException; @@ -14,6 +12,7 @@ import org.springframework.web.bind.annotation.ExceptionHandler; import org.springframework.web.context.request.WebRequest; import org.springframework.web.servlet.mvc.method.annotation.ResponseEntityExceptionHandler; +import tools.jackson.core.JacksonException; import java.text.ParseException; @@ -21,31 +20,30 @@ public class ResponseExceptionHandler extends ResponseEntityExceptionHandler { @ExceptionHandler({ ResourceNotFoundException.class }) protected ResponseEntity handleNotFound(RuntimeException ex, WebRequest request) { - return handleExceptionInternal(ex, ErrorDto.fromRuntimeException(ex), new HttpHeaders(), HttpStatus.NOT_FOUND, request); + return handleExceptionInternal(ex, ErrorDto.fromException(ex), new HttpHeaders(), HttpStatus.NOT_FOUND, request); } @ExceptionHandler({ ResourceAlreadyExistsException.class }) protected ResponseEntity handleAlreadyExists(RuntimeException ex, WebRequest request) { - return handleExceptionInternal(ex, ErrorDto.fromRuntimeException(ex), new HttpHeaders(), HttpStatus.CONFLICT, request); + return handleExceptionInternal(ex, ErrorDto.fromException(ex), new HttpHeaders(), HttpStatus.CONFLICT, request); } @ExceptionHandler({ ParseException.class, ResourceInvalidParametersException.class, - JsonParseException.class, - JsonProcessingException.class, + JacksonException.class, }) - protected ResponseEntity handleBadRequest(RuntimeException ex, WebRequest request) { - return handleExceptionInternal(ex, ErrorDto.fromRuntimeException(ex), new HttpHeaders(), HttpStatus.BAD_REQUEST, request); + protected ResponseEntity handleBadRequest(Exception ex, WebRequest request) { + return handleExceptionInternal(ex, ErrorDto.fromException(ex), new HttpHeaders(), HttpStatus.BAD_REQUEST, request); } @ExceptionHandler({ AccessDeniedException.class }) protected ResponseEntity handleAccessDenied(RuntimeException ex, WebRequest request) { - return handleExceptionInternal(ex, ErrorDto.fromRuntimeException(ex), new HttpHeaders(), HttpStatus.FORBIDDEN, request); + return handleExceptionInternal(ex, ErrorDto.fromException(ex), new HttpHeaders(), HttpStatus.FORBIDDEN, request); } @ExceptionHandler({ MailingException.class, UploadException.class }) protected ResponseEntity handleServerError(RuntimeException ex, WebRequest request) { - return handleExceptionInternal(ex, ErrorDto.fromRuntimeException(ex), new HttpHeaders(), HttpStatus.INTERNAL_SERVER_ERROR, request); + return handleExceptionInternal(ex, ErrorDto.fromException(ex), new HttpHeaders(), HttpStatus.INTERNAL_SERVER_ERROR, request); } } diff --git a/server/src/main/java/de/tum/cit/aet/thesis/repository/ApplicationRepository.java b/server/src/main/java/de/tum/cit/aet/thesis/repository/ApplicationRepository.java index 0b314afcd..c3cc52acc 100644 --- a/server/src/main/java/de/tum/cit/aet/thesis/repository/ApplicationRepository.java +++ b/server/src/main/java/de/tum/cit/aet/thesis/repository/ApplicationRepository.java @@ -44,8 +44,8 @@ Page searchApplications( @Param("reviewerId") UUID reviewerId, @Param("searchQuery") String searchQuery, @Param("states") Set states, - @Param("previousIds") Set previousIds, - @Param("topics") Set topics, + @Param("previousIds") Set previousIds, + @Param("topics") Set topics, @Param("types") Set types, @Param("includeSuggestedTopics") boolean includeSuggestedTopics, Pageable page diff --git a/server/src/main/java/de/tum/cit/aet/thesis/security/JwtAuthConverter.java b/server/src/main/java/de/tum/cit/aet/thesis/security/JwtAuthConverter.java index 5855c95f9..6e49bd49c 100644 --- a/server/src/main/java/de/tum/cit/aet/thesis/security/JwtAuthConverter.java +++ b/server/src/main/java/de/tum/cit/aet/thesis/security/JwtAuthConverter.java @@ -1,8 +1,8 @@ package de.tum.cit.aet.thesis.security; +import org.jspecify.annotations.NonNull; +import org.jspecify.annotations.Nullable; import org.springframework.core.convert.converter.Converter; -import org.springframework.lang.NonNull; -import org.springframework.lang.Nullable; import org.springframework.security.authentication.AbstractAuthenticationToken; import org.springframework.security.core.GrantedAuthority; import org.springframework.security.core.authority.SimpleGrantedAuthority; diff --git a/server/src/main/java/de/tum/cit/aet/thesis/service/ApplicationService.java b/server/src/main/java/de/tum/cit/aet/thesis/service/ApplicationService.java index 77f0657c4..e64a76dfa 100644 --- a/server/src/main/java/de/tum/cit/aet/thesis/service/ApplicationService.java +++ b/server/src/main/java/de/tum/cit/aet/thesis/service/ApplicationService.java @@ -38,6 +38,7 @@ import java.util.Optional; import java.util.Set; import java.util.UUID; +import java.util.stream.Collectors; /** * Handles the lifecycle of thesis applications, including creation, review, acceptance, and rejection. @@ -130,9 +131,21 @@ public Page getAll( ResearchGroup researchGroup = currentUserProvider().getResearchGroupOrThrow(); String searchQueryFilter = searchQuery == null || searchQuery.isEmpty() ? null : searchQuery.toLowerCase(); Set statesFilter = states == null || states.length == 0 ? null : new HashSet<>(Arrays.asList(states)); - Set topicsFilter = topics == null || topics.length == 0 ? null : new HashSet<>(Arrays.asList(topics)); + Set topicsFilter = topics == null || topics.length == 0 ? null : Arrays.stream(topics).map(t -> { + try { + return UUID.fromString(t); + } catch (IllegalArgumentException e) { + throw new ResourceInvalidParametersException("Invalid topic ID: " + t); + } + }).collect(Collectors.toSet()); Set typesFilter = types == null || types.length == 0 ? null : new HashSet<>(Arrays.asList(types)); - Set previousFilter = previous == null || previous.length == 0 ? null : new HashSet<>(Arrays.asList(previous)); + Set previousFilter = previous == null || previous.length == 0 ? null : Arrays.stream(previous).map(t -> { + try { + return UUID.fromString(t); + } catch (IllegalArgumentException e) { + throw new ResourceInvalidParametersException("Invalid application ID: " + t); + } + }).collect(Collectors.toSet()); return applicationRepository.searchApplications( researchGroup == null ? null : researchGroup.getId(), diff --git a/server/src/main/java/de/tum/cit/aet/thesis/service/ThesisService.java b/server/src/main/java/de/tum/cit/aet/thesis/service/ThesisService.java index 2418d0889..588790f98 100644 --- a/server/src/main/java/de/tum/cit/aet/thesis/service/ThesisService.java +++ b/server/src/main/java/de/tum/cit/aet/thesis/service/ThesisService.java @@ -322,8 +322,8 @@ public Thesis updateThesisInfo( String infoText ) { currentUserProvider().assertCanAccessResearchGroup(thesis.getResearchGroup()); - thesis.setAbstractField(abstractText); - thesis.setInfo(infoText); + thesis.setAbstractField(abstractText != null ? abstractText : ""); + thesis.setInfo(infoText != null ? infoText : ""); thesis = thesisRepository.save(thesis); @@ -339,11 +339,15 @@ public Thesis updateThesisTitles( Map titles ) { currentUserProvider().assertCanAccessResearchGroup(thesis.getResearchGroup()); - thesis.setMetadata(new ThesisMetadata( - titles, - thesis.getMetadata().credits() - )); - thesis.setTitle(primaryTitle); + if (titles != null) { + thesis.setMetadata(new ThesisMetadata( + titles, + thesis.getMetadata().credits() + )); + } + if (primaryTitle != null) { + thesis.setTitle(primaryTitle); + } thesis = thesisRepository.save(thesis); diff --git a/server/src/test/java/de/tum/cit/aet/thesis/controller/ApplicationControllerTest.java b/server/src/test/java/de/tum/cit/aet/thesis/controller/ApplicationControllerTest.java index 9546370da..e442498c1 100644 --- a/server/src/test/java/de/tum/cit/aet/thesis/controller/ApplicationControllerTest.java +++ b/server/src/test/java/de/tum/cit/aet/thesis/controller/ApplicationControllerTest.java @@ -3,18 +3,21 @@ import static org.assertj.core.api.Assertions.assertThat; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; -import com.fasterxml.jackson.databind.JsonNode; import de.tum.cit.aet.thesis.constants.ApplicationRejectReason; import de.tum.cit.aet.thesis.constants.ApplicationReviewReason; import de.tum.cit.aet.thesis.constants.ApplicationState; import de.tum.cit.aet.thesis.controller.payload.AcceptApplicationPayload; +import de.tum.cit.aet.thesis.controller.payload.CloseTopicPayload; import de.tum.cit.aet.thesis.controller.payload.CreateApplicationPayload; +import de.tum.cit.aet.thesis.controller.payload.CreateInterviewProcessPayload; import de.tum.cit.aet.thesis.controller.payload.RejectApplicationPayload; +import de.tum.cit.aet.thesis.controller.payload.ReplaceTopicPayload; import de.tum.cit.aet.thesis.controller.payload.ReviewApplicationPayload; import de.tum.cit.aet.thesis.controller.payload.UpdateApplicationCommentPayload; import de.tum.cit.aet.thesis.mock.BaseIntegrationTest; import de.tum.cit.aet.thesis.repository.ApplicationRepository; import de.tum.cit.aet.thesis.repository.ApplicationReviewerRepository; +import de.tum.cit.aet.thesis.repository.InterviewProcessRepository; import de.tum.cit.aet.thesis.repository.ThesisRepository; import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; @@ -24,9 +27,13 @@ import org.springframework.test.context.DynamicPropertySource; import org.springframework.test.web.servlet.request.MockMvcRequestBuilders; import org.testcontainers.junit.jupiter.Testcontainers; +import tools.jackson.databind.JsonNode; + +import jakarta.mail.internet.MimeMessage; import java.time.Instant; import java.util.List; +import java.util.Set; import java.util.UUID; @Testcontainers @@ -46,6 +53,9 @@ static void configureDynamicProperties(DynamicPropertyRegistry registry) { @Autowired private ThesisRepository thesisRepository; + @Autowired + private InterviewProcessRepository interviewProcessRepository; + @Nested class ApplicationCreation { @Test @@ -74,11 +84,11 @@ void createApplication_Success() throws Exception { .andReturn().getResponse().getContentAsString(); JsonNode json = objectMapper.readTree(response); - assertThat(json.get("thesisTitle").asText()).isEqualTo("Test Thesis"); - assertThat(json.get("thesisType").asText()).isEqualTo("MASTER"); - assertThat(json.get("motivation").asText()).isEqualTo("Test motivation"); - assertThat(json.get("state").asText()).isEqualTo(ApplicationState.NOT_ASSESSED.getValue()); - assertThat(json.get("applicationId").asText()).isNotBlank(); + assertThat(json.get("thesisTitle").asString()).isEqualTo("Test Thesis"); + assertThat(json.get("thesisType").asString()).isEqualTo("MASTER"); + assertThat(json.get("motivation").asString()).isEqualTo("Test motivation"); + assertThat(json.get("state").asString()).isEqualTo(ApplicationState.NOT_ASSESSED.getValue()); + assertThat(json.get("applicationId").asString()).isNotBlank(); assertThat(applicationRepository.count()).isEqualTo(1); } @@ -103,8 +113,8 @@ void createApplication_WithTopic_Success() throws Exception { JsonNode json = objectMapper.readTree(response); assertThat(json.get("topic")).isNotNull(); - assertThat(json.get("motivation").asText()).isEqualTo("Motivation for topic"); - assertThat(json.get("state").asText()).isEqualTo(ApplicationState.NOT_ASSESSED.getValue()); + assertThat(json.get("motivation").asString()).isEqualTo("Motivation for topic"); + assertThat(json.get("state").asString()).isEqualTo(ApplicationState.NOT_ASSESSED.getValue()); } @Test @@ -167,7 +177,7 @@ void createApplication_VerifyDatabaseState() throws Exception { .andExpect(status().isOk()) .andReturn().getResponse().getContentAsString(); - UUID applicationId = UUID.fromString(objectMapper.readTree(response).get("applicationId").asText()); + UUID applicationId = UUID.fromString(objectMapper.readTree(response).get("applicationId").asString()); var application = applicationRepository.findById(applicationId).orElseThrow(); assertThat(application.getThesisTitle()).isEqualTo("Database Check Thesis"); @@ -228,6 +238,47 @@ void getApplications_FilterByState() throws Exception { assertThat(jsonEmpty.path("content").size()).isZero(); } + @Test + void getApplications_FilterByPreviousIds() throws Exception { + String studentAuth = createRandomAuthentication("student"); + UUID applicationId = createTestApplication(studentAuth, "Previous App"); + + // Reject the application so it no longer matches NOT_ASSESSED state + createTestEmailTemplate("APPLICATION_REJECTED"); + RejectApplicationPayload rejectPayload = new RejectApplicationPayload( + ApplicationRejectReason.GENERAL, false + ); + mockMvc.perform(MockMvcRequestBuilders.put("/v2/applications/{applicationId}/reject", applicationId) + .header("Authorization", createRandomAdminAuthentication()) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(rejectPayload))) + .andExpect(status().isOk()); + + // Without previous param, filtering by NOT_ASSESSED should return nothing + String responseWithout = mockMvc.perform(MockMvcRequestBuilders.get("/v2/applications") + .header("Authorization", createRandomAdminAuthentication()) + .param("fetchAll", "true") + .param("state", "NOT_ASSESSED")) + .andExpect(status().isOk()) + .andReturn().getResponse().getContentAsString(); + + JsonNode contentWithout = objectMapper.readTree(responseWithout).get("content"); + assertThat(contentWithout == null || contentWithout.size() == 0).isTrue(); + + // With previous param, the rejected application should still be included + String responseWith = mockMvc.perform(MockMvcRequestBuilders.get("/v2/applications") + .header("Authorization", createRandomAdminAuthentication()) + .param("fetchAll", "true") + .param("state", "NOT_ASSESSED") + .param("previous", applicationId.toString())) + .andExpect(status().isOk()) + .andReturn().getResponse().getContentAsString(); + + JsonNode json = objectMapper.readTree(responseWith); + assertThat(json.get("content").size()).isEqualTo(1); + assertThat(json.get("content").get(0).get("applicationId").asString()).isEqualTo(applicationId.toString()); + } + @Test void getApplications_AsStudent_ReturnsOwnOnly() throws Exception { String studentAuth = createRandomAuthentication("student"); @@ -243,7 +294,7 @@ void getApplications_AsStudent_ReturnsOwnOnly() throws Exception { JsonNode json = objectMapper.readTree(response); assertThat(json.get("content").size()).isEqualTo(1); - assertThat(json.get("content").get(0).get("thesisTitle").asText()).isEqualTo("Student Application"); + assertThat(json.get("content").get(0).get("thesisTitle").asString()).isEqualTo("Student Application"); } @Test @@ -279,9 +330,9 @@ void getApplication_Success() throws Exception { .andReturn().getResponse().getContentAsString(); JsonNode json = objectMapper.readTree(response); - assertThat(json.get("applicationId").asText()).isEqualTo(applicationId.toString()); - assertThat(json.get("thesisTitle").asText()).isEqualTo("Test Application"); - assertThat(json.get("state").asText()).isEqualTo(ApplicationState.NOT_ASSESSED.getValue()); + assertThat(json.get("applicationId").asString()).isEqualTo(applicationId.toString()); + assertThat(json.get("thesisTitle").asString()).isEqualTo("Test Application"); + assertThat(json.get("state").asString()).isEqualTo(ApplicationState.NOT_ASSESSED.getValue()); } @Test @@ -361,10 +412,10 @@ void updateApplication_Success() throws Exception { .andReturn().getResponse().getContentAsString(); JsonNode json = objectMapper.readTree(response); - assertThat(json.get("thesisTitle").asText()).isEqualTo("Updated Thesis"); - assertThat(json.get("thesisType").asText()).isEqualTo("BACHELOR"); - assertThat(json.get("motivation").asText()).isEqualTo("Updated motivation"); - assertThat(json.get("state").asText()).isEqualTo(ApplicationState.NOT_ASSESSED.getValue()); + assertThat(json.get("thesisTitle").asString()).isEqualTo("Updated Thesis"); + assertThat(json.get("thesisType").asString()).isEqualTo("BACHELOR"); + assertThat(json.get("motivation").asString()).isEqualTo("Updated motivation"); + assertThat(json.get("state").asString()).isEqualTo(ApplicationState.NOT_ASSESSED.getValue()); var application = applicationRepository.findById(applicationId).orElseThrow(); assertThat(application.getThesisTitle()).isEqualTo("Updated Thesis"); @@ -435,7 +486,7 @@ void updateComment_Success() throws Exception { .andReturn().getResponse().getContentAsString(); JsonNode json = objectMapper.readTree(response); - assertThat(json.get("comment").asText()).isEqualTo("Management comment"); + assertThat(json.get("comment").asString()).isEqualTo("Management comment"); var application = applicationRepository.findById(applicationId).orElseThrow(); assertThat(application.getComment()).isEqualTo("Management comment"); @@ -496,7 +547,7 @@ void reviewApplication_Success() throws Exception { .andReturn().getResponse().getContentAsString(); JsonNode json = objectMapper.readTree(response); - assertThat(json.get("applicationId").asText()).isEqualTo(applicationId.toString()); + assertThat(json.get("applicationId").asString()).isEqualTo(applicationId.toString()); assertThat(applicationReviewerRepository.count()).isEqualTo(1); } @@ -584,7 +635,7 @@ void acceptApplication_Success() throws Exception { boolean hasAccepted = false; for (JsonNode app : json) { - if (app.get("state").asText().equals(ApplicationState.ACCEPTED.getValue())) { + if (app.get("state").asString().equals(ApplicationState.ACCEPTED.getValue())) { hasAccepted = true; } } @@ -637,6 +688,113 @@ void acceptApplication_AccessDenied_AsStudent() throws Exception { .andExpect(status().isForbidden()); } + @Test + void acceptApplication_WithCloseTopic_RejectsOtherApplications() throws Exception { + createTestEmailTemplate("APPLICATION_CREATED_CHAIR"); + createTestEmailTemplate("APPLICATION_CREATED_STUDENT"); + createTestEmailTemplate("APPLICATION_ACCEPTED"); + createTestEmailTemplate("APPLICATION_REJECTED_TOPIC_FILLED"); + + UUID topicId = createTestTopic("Close Topic Test"); + + // Two different students apply for the same topic + String student1Auth = createRandomAuthentication("student"); + CreateApplicationPayload payload1 = new CreateApplicationPayload( + topicId, null, "MASTER", Instant.now(), "Motivation 1", null + ); + String response1 = mockMvc.perform(MockMvcRequestBuilders.post("/v2/applications") + .header("Authorization", student1Auth) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(payload1))) + .andExpect(status().isOk()) + .andReturn().getResponse().getContentAsString(); + UUID appId1 = UUID.fromString(objectMapper.readTree(response1).get("applicationId").asString()); + + String student2Auth = createRandomAuthentication("student"); + CreateApplicationPayload payload2 = new CreateApplicationPayload( + topicId, null, "MASTER", Instant.now(), "Motivation 2", null + ); + String response2 = mockMvc.perform(MockMvcRequestBuilders.post("/v2/applications") + .header("Authorization", student2Auth) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(payload2))) + .andExpect(status().isOk()) + .andReturn().getResponse().getContentAsString(); + UUID appId2 = UUID.fromString(objectMapper.readTree(response2).get("applicationId").asString()); + + TestUser advisor = createTestUser("advisor-close", List.of("advisor")); + TestUser supervisor = createTestUser("supervisor-close", List.of("supervisor")); + + AcceptApplicationPayload acceptPayload = new AcceptApplicationPayload( + "Accepted Thesis", "MASTER", "ENGLISH", + List.of(advisor.userId()), List.of(supervisor.userId()), + true, true // notifyUser=true, closeTopic=true + ); + + String response = mockMvc.perform(MockMvcRequestBuilders.put("/v2/applications/{applicationId}/accept", appId1) + .header("Authorization", createRandomAdminAuthentication()) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(acceptPayload))) + .andExpect(status().isOk()) + .andReturn().getResponse().getContentAsString(); + + JsonNode json = objectMapper.readTree(response); + assertThat(json.isArray()).isTrue(); + // Should contain both the accepted and the rejected application + assertThat(json.size()).isGreaterThanOrEqualTo(2); + + // Verify the second application was rejected + var app2 = applicationRepository.findById(appId2).orElseThrow(); + assertThat(app2.getState()).isEqualTo(ApplicationState.REJECTED); + assertThat(app2.getRejectReason()).isEqualTo(ApplicationRejectReason.TOPIC_FILLED); + } + + @Test + void acceptApplication_WithNotify_SameAdvisorSupervisor_UsesNoAdvisorTemplate() throws Exception { + createTestEmailTemplate("APPLICATION_CREATED_CHAIR"); + createTestEmailTemplate("APPLICATION_CREATED_STUDENT"); + createTestEmailTemplate("APPLICATION_ACCEPTED_NO_ADVISOR"); + createTestEmailTemplate("THESIS_CREATED"); + + TestUser student = createRandomTestUser(List.of("student")); + String studentAuth = generateTestAuthenticationHeader(student.universityId(), List.of("student")); + + TestUser advisor = createRandomTestUser(List.of("supervisor", "advisor")); + UUID researchGroupId = createTestResearchGroup("Accept No Advisor", advisor.universityId()); + + CreateApplicationPayload appPayload = new CreateApplicationPayload( + null, "No Advisor Thesis", "MASTER", Instant.now(), "Motivation", researchGroupId + ); + + String appResponse = mockMvc.perform(MockMvcRequestBuilders.post("/v2/applications") + .header("Authorization", studentAuth) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(appPayload))) + .andExpect(status().isOk()) + .andReturn().getResponse().getContentAsString(); + + UUID applicationId = UUID.fromString(objectMapper.readTree(appResponse).get("applicationId").asString()); + + clearEmails(); + + // Same user as both advisor and supervisor triggers APPLICATION_ACCEPTED_NO_ADVISOR template + AcceptApplicationPayload acceptPayload = new AcceptApplicationPayload( + "No Advisor Thesis", "MASTER", "ENGLISH", + List.of(advisor.userId()), + List.of(advisor.userId()), + true, false + ); + + mockMvc.perform(MockMvcRequestBuilders.put("/v2/applications/{applicationId}/accept", applicationId) + .header("Authorization", createRandomAdminAuthentication()) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(acceptPayload))) + .andExpect(status().isOk()); + + MimeMessage[] emails = getReceivedEmails(); + assertThat(emails.length).isGreaterThanOrEqualTo(1); + } + @Test void acceptApplication_VerifyThesisCreated() throws Exception { UUID applicationId = createTestApplication(createRandomAdminAuthentication(), "Thesis Source Application"); @@ -689,7 +847,7 @@ void rejectApplication_Success() throws Exception { boolean hasRejected = false; for (JsonNode app : json) { - if (app.get("state").asText().equals(ApplicationState.REJECTED.getValue())) { + if (app.get("state").asString().equals(ApplicationState.REJECTED.getValue())) { hasRejected = true; } } @@ -755,7 +913,7 @@ void rejectApplication_FailedStudentRequirements_RejectsAllPending() throws Exce .content(objectMapper.writeValueAsString(payload1))) .andExpect(status().isOk()) .andReturn().getResponse().getContentAsString(); - UUID appId1 = UUID.fromString(objectMapper.readTree(response1).get("applicationId").asText()); + UUID appId1 = UUID.fromString(objectMapper.readTree(response1).get("applicationId").asString()); CreateApplicationPayload payload2 = new CreateApplicationPayload( topicId2, null, "MASTER", Instant.now(), "Motivation 2", null @@ -766,7 +924,7 @@ void rejectApplication_FailedStudentRequirements_RejectsAllPending() throws Exce .content(objectMapper.writeValueAsString(payload2))) .andExpect(status().isOk()) .andReturn().getResponse().getContentAsString(); - UUID appId2 = UUID.fromString(objectMapper.readTree(response2).get("applicationId").asText()); + UUID appId2 = UUID.fromString(objectMapper.readTree(response2).get("applicationId").asString()); createTestEmailTemplate("APPLICATION_REJECTED_STUDENT_REQUIREMENTS"); @@ -807,4 +965,218 @@ void rejectApplication_DifferentReasons_VerifyDatabaseState() throws Exception { assertThat(application.getRejectReason()).isEqualTo(ApplicationRejectReason.NO_CAPACITY); } } + + @Nested + class ApplicationSorting { + @Test + void getApplications_SortByCreatedAtDesc_Success() throws Exception { + String student1Auth = createRandomAuthentication("student"); + createTestApplication(student1Auth, "First Application"); + String student2Auth = createRandomAuthentication("student"); + createTestApplication(student2Auth, "Second Application"); + + String response = mockMvc.perform(MockMvcRequestBuilders.get("/v2/applications") + .header("Authorization", createRandomAdminAuthentication()) + .param("fetchAll", "true") + .param("sortBy", "createdAt") + .param("sortOrder", "desc")) + .andExpect(status().isOk()) + .andReturn().getResponse().getContentAsString(); + + JsonNode json = objectMapper.readTree(response); + assertThat(json.get("content").size()).isEqualTo(2); + // Last created should be first with desc order + assertThat(json.get("content").get(0).get("thesisTitle").asString()).isEqualTo("Second Application"); + } + + @Test + void getApplications_SortByCreatedAtAsc_Success() throws Exception { + String student1Auth = createRandomAuthentication("student"); + createTestApplication(student1Auth, "First Application"); + String student2Auth = createRandomAuthentication("student"); + createTestApplication(student2Auth, "Second Application"); + + String response = mockMvc.perform(MockMvcRequestBuilders.get("/v2/applications") + .header("Authorization", createRandomAdminAuthentication()) + .param("fetchAll", "true") + .param("sortBy", "createdAt") + .param("sortOrder", "asc")) + .andExpect(status().isOk()) + .andReturn().getResponse().getContentAsString(); + + JsonNode json = objectMapper.readTree(response); + assertThat(json.get("content").size()).isEqualTo(2); + // First created should be first with asc order + assertThat(json.get("content").get(0).get("thesisTitle").asString()).isEqualTo("First Application"); + } + } + + @Nested + class CloseTopicWithInterviewProcess { + @Test + void closeTopic_WithInterviewProcess_MarksProcessCompleted() throws Exception { + createTestEmailTemplate("APPLICATION_CREATED_CHAIR"); + createTestEmailTemplate("APPLICATION_CREATED_STUDENT"); + createTestEmailTemplate("APPLICATION_REJECTED_TOPIC_FILLED"); + createTestEmailTemplate("INTERVIEW_INVITATION"); + createTestEmailTemplate("INTERVIEW_INVITATION_REMINDER"); + + TestUser advisor = createRandomTestUser(List.of("supervisor", "advisor")); + UUID researchGroupId = createTestResearchGroup("Close IP Group", advisor.universityId()); + + ReplaceTopicPayload topicPayload = new ReplaceTopicPayload( + "Close IP Topic", Set.of("MASTER"), + "PS", "Req", "Goals", "Refs", + List.of(advisor.userId()), List.of(advisor.userId()), + researchGroupId, null, null, false + ); + String topicResponse = mockMvc.perform(MockMvcRequestBuilders.post("/v2/topics") + .header("Authorization", createRandomAdminAuthentication()) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(topicPayload))) + .andExpect(status().isOk()) + .andReturn().getResponse().getContentAsString(); + UUID topicId = UUID.fromString(objectMapper.readTree(topicResponse).get("topicId").asString()); + + // Create application for the topic + String studentAuth = createRandomAuthentication("student"); + CreateApplicationPayload appPayload = new CreateApplicationPayload( + topicId, null, "MASTER", Instant.now(), "Interview motivation", null + ); + String appResponse = mockMvc.perform(MockMvcRequestBuilders.post("/v2/applications") + .header("Authorization", studentAuth) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(appPayload))) + .andExpect(status().isOk()) + .andReturn().getResponse().getContentAsString(); + UUID applicationId = UUID.fromString(objectMapper.readTree(appResponse).get("applicationId").asString()); + + // Create interview process for the topic + String advisorAuth = generateTestAuthenticationHeader(advisor.universityId(), List.of("supervisor", "advisor")); + CreateInterviewProcessPayload processPayload = new CreateInterviewProcessPayload(topicId, List.of(applicationId)); + String processResponse = mockMvc.perform(MockMvcRequestBuilders.post("/v2/interview-process") + .header("Authorization", advisorAuth) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(processPayload))) + .andExpect(status().isOk()) + .andReturn().getResponse().getContentAsString(); + UUID processId = UUID.fromString(objectMapper.readTree(processResponse).get("interviewProcessId").asString()); + + // Close the topic + CloseTopicPayload closePayload = new CloseTopicPayload( + ApplicationRejectReason.TOPIC_FILLED, true + ); + mockMvc.perform(MockMvcRequestBuilders.delete("/v2/topics/{topicId}", topicId) + .header("Authorization", createRandomAdminAuthentication()) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(closePayload))) + .andExpect(status().isOk()); + + // Verify interview process is marked completed + var process = interviewProcessRepository.findById(processId).orElseThrow(); + assertThat(process.isCompleted()).isTrue(); + + // Verify application was rejected + var app = applicationRepository.findById(applicationId).orElseThrow(); + assertThat(app.getState()).isEqualTo(ApplicationState.REJECTED); + } + } + + @Nested + class ApplicationFilterCombinations { + @Test + void getApplications_FilterByType_Success() throws Exception { + String studentAuth = createRandomAuthentication("student"); + createTestApplication(studentAuth, "Bachelor App"); + + String response = mockMvc.perform(MockMvcRequestBuilders.get("/v2/applications") + .header("Authorization", createRandomAdminAuthentication()) + .param("fetchAll", "true") + .param("types", "BACHELOR")) + .andExpect(status().isOk()) + .andReturn().getResponse().getContentAsString(); + + JsonNode json = objectMapper.readTree(response); + assertThat(json.get("content").size()).isGreaterThanOrEqualTo(1); + } + + @Test + void getApplications_WithSearch_FiltersResults() throws Exception { + String student1Auth = createRandomAuthentication("student"); + createTestApplication(student1Auth, "Unique Search Term XYZ"); + String student2Auth = createRandomAuthentication("student"); + createTestApplication(student2Auth, "Other Application"); + + // Search with a term that doesn't match any user name - should return fewer results + String response = mockMvc.perform(MockMvcRequestBuilders.get("/v2/applications") + .header("Authorization", createRandomAdminAuthentication()) + .param("fetchAll", "true") + .param("search", "nonexistent-search-term-99999")) + .andExpect(status().isOk()) + .andReturn().getResponse().getContentAsString(); + + JsonNode json = objectMapper.readTree(response); + assertThat(json.get("totalElements").asInt()).isEqualTo(0); + } + + @Test + void getApplications_WithTopicFilter_Success() throws Exception { + createTestEmailTemplate("APPLICATION_CREATED_CHAIR"); + createTestEmailTemplate("APPLICATION_CREATED_STUDENT"); + + UUID topicId = createTestTopic("Filter Topic"); + + String studentAuth = createRandomAuthentication("student"); + CreateApplicationPayload payload = new CreateApplicationPayload( + topicId, null, "MASTER", Instant.now(), "Topic filter test", null + ); + mockMvc.perform(MockMvcRequestBuilders.post("/v2/applications") + .header("Authorization", studentAuth) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(payload))) + .andExpect(status().isOk()); + + String response = mockMvc.perform(MockMvcRequestBuilders.get("/v2/applications") + .header("Authorization", createRandomAdminAuthentication()) + .param("fetchAll", "true") + .param("topic", topicId.toString())) + .andExpect(status().isOk()) + .andReturn().getResponse().getContentAsString(); + + JsonNode json = objectMapper.readTree(response); + assertThat(json.get("content").size()).isEqualTo(1); + } + + @Test + void getApplications_MultipleStates_Success() throws Exception { + String studentAuth = createRandomAuthentication("student"); + UUID applicationId = createTestApplication(studentAuth, "Multi State App"); + + // Reject one application + createTestEmailTemplate("APPLICATION_REJECTED"); + RejectApplicationPayload rejectPayload = new RejectApplicationPayload( + ApplicationRejectReason.GENERAL, false + ); + mockMvc.perform(MockMvcRequestBuilders.put("/v2/applications/{applicationId}/reject", applicationId) + .header("Authorization", createRandomAdminAuthentication()) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(rejectPayload))) + .andExpect(status().isOk()); + + // Create another application + String student2Auth = createRandomAuthentication("student"); + createTestApplication(student2Auth, "Not Assessed App"); + + // Filter by both states + String response = mockMvc.perform(MockMvcRequestBuilders.get("/v2/applications") + .header("Authorization", createRandomAdminAuthentication()) + .param("fetchAll", "true") + .param("state", "NOT_ASSESSED", "REJECTED")) + .andExpect(status().isOk()) + .andReturn().getResponse().getContentAsString(); + + JsonNode json = objectMapper.readTree(response); + assertThat(json.get("content").size()).isEqualTo(2); + } + } } diff --git a/server/src/test/java/de/tum/cit/aet/thesis/controller/AvatarControllerTest.java b/server/src/test/java/de/tum/cit/aet/thesis/controller/AvatarControllerTest.java new file mode 100644 index 000000000..01c47a656 --- /dev/null +++ b/server/src/test/java/de/tum/cit/aet/thesis/controller/AvatarControllerTest.java @@ -0,0 +1,42 @@ +package de.tum.cit.aet.thesis.controller; + +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import de.tum.cit.aet.thesis.mock.BaseIntegrationTest; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.test.context.DynamicPropertyRegistry; +import org.springframework.test.context.DynamicPropertySource; +import org.springframework.test.web.servlet.request.MockMvcRequestBuilders; +import org.testcontainers.junit.jupiter.Testcontainers; + +import java.util.List; +import java.util.UUID; + +@Testcontainers +class AvatarControllerTest extends BaseIntegrationTest { + + @DynamicPropertySource + static void configureDynamicProperties(DynamicPropertyRegistry registry) { + configureProperties(registry); + } + + @Nested + class GetAvatar { + @Test + void getAvatar_UserWithNoAvatar_Returns404() throws Exception { + TestUser user = createRandomTestUser(List.of("student")); + + mockMvc.perform(MockMvcRequestBuilders.get("/v2/avatars/{userId}", user.userId()) + .header("Authorization", createRandomAdminAuthentication())) + .andExpect(status().isNotFound()); + } + + @Test + void getAvatar_UserNotFound_Returns404() throws Exception { + mockMvc.perform(MockMvcRequestBuilders.get("/v2/avatars/{userId}", UUID.randomUUID()) + .header("Authorization", createRandomAdminAuthentication())) + .andExpect(status().isNotFound()); + } + } +} diff --git a/server/src/test/java/de/tum/cit/aet/thesis/controller/CalendarControllerTest.java b/server/src/test/java/de/tum/cit/aet/thesis/controller/CalendarControllerTest.java new file mode 100644 index 000000000..ee15471e3 --- /dev/null +++ b/server/src/test/java/de/tum/cit/aet/thesis/controller/CalendarControllerTest.java @@ -0,0 +1,197 @@ +package de.tum.cit.aet.thesis.controller; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import de.tum.cit.aet.thesis.controller.payload.BookInterviewSlotPayload; +import de.tum.cit.aet.thesis.controller.payload.CreateApplicationPayload; +import de.tum.cit.aet.thesis.controller.payload.CreateInterviewProcessPayload; +import de.tum.cit.aet.thesis.controller.payload.CreateInterviewSlotsPayload; +import de.tum.cit.aet.thesis.controller.payload.InviteIntervieweesPayload; +import de.tum.cit.aet.thesis.controller.payload.ReplaceTopicPayload; +import de.tum.cit.aet.thesis.dto.InterviewSlotDto; +import de.tum.cit.aet.thesis.entity.ResearchGroup; +import de.tum.cit.aet.thesis.mock.BaseIntegrationTest; +import de.tum.cit.aet.thesis.repository.ResearchGroupRepository; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.http.MediaType; +import org.springframework.test.context.DynamicPropertyRegistry; +import org.springframework.test.context.DynamicPropertySource; +import org.springframework.test.web.servlet.request.MockMvcRequestBuilders; +import org.testcontainers.junit.jupiter.Testcontainers; + +import java.time.Instant; +import java.time.temporal.ChronoUnit; +import java.util.List; +import java.util.Set; +import java.util.UUID; + +@Testcontainers +class CalendarControllerTest extends BaseIntegrationTest { + + @DynamicPropertySource + static void configureDynamicProperties(DynamicPropertyRegistry registry) { + configureProperties(registry); + } + + @Autowired + private ResearchGroupRepository researchGroupRepository; + + @Nested + class PresentationCalendar { + @Test + void getCalendar_Success() throws Exception { + TestUser head = createRandomTestUser(List.of("supervisor")); + UUID groupId = createTestResearchGroup("Calendar Group", head.universityId()); + ResearchGroup group = researchGroupRepository.findById(groupId).orElseThrow(); + + String response = mockMvc.perform(MockMvcRequestBuilders.get("/v2/calendar/presentations/{abbreviation}", group.getAbbreviation()) + .header("Authorization", createRandomAdminAuthentication())) + .andExpect(status().isOk()) + .andExpect(content().contentType("text/calendar")) + .andReturn().getResponse().getContentAsString(); + + assertThat(response).contains("VCALENDAR"); + } + + @Test + void getCalendar_UnknownAbbreviation_Returns404() throws Exception { + mockMvc.perform(MockMvcRequestBuilders.get("/v2/calendar/presentations/{abbreviation}", "nonexistent-abbr") + .header("Authorization", createRandomAdminAuthentication())) + .andExpect(status().isNotFound()); + } + } + + @Nested + class InterviewCalendar { + @Test + void getInterviewCalendar_Success() throws Exception { + TestUser user = createRandomTestUser(List.of("student")); + + String response = mockMvc.perform(MockMvcRequestBuilders.get("/v2/calendar/interviews/user/{userId}", user.userId()) + .header("Authorization", createRandomAdminAuthentication())) + .andExpect(status().isOk()) + .andExpect(content().contentType("text/calendar")) + .andReturn().getResponse().getContentAsString(); + + assertThat(response).contains("VCALENDAR"); + } + + @Test + void getInterviewCalendar_UnknownUser_ReturnsNotFound() throws Exception { + mockMvc.perform(MockMvcRequestBuilders.get("/v2/calendar/interviews/user/{userId}", UUID.randomUUID()) + .header("Authorization", createRandomAdminAuthentication())) + .andExpect(status().isNotFound()); + } + + @Test + void getInterviewCalendar_WithBookedSlots_ContainsEvents() throws Exception { + createTestEmailTemplate("APPLICATION_CREATED_CHAIR"); + createTestEmailTemplate("APPLICATION_CREATED_STUDENT"); + createTestEmailTemplate("INTERVIEW_INVITATION"); + createTestEmailTemplate("INTERVIEW_INVITATION_REMINDER"); + createTestEmailTemplate("INTERVIEW_SLOT_BOOKED_CONFORMATION"); + createTestEmailTemplate("INTERVIEW_SLOT_BOOKED_CANCELLATION"); + + TestUser advisor = createRandomTestUser(List.of("supervisor", "advisor")); + UUID researchGroupId = createTestResearchGroup("Calendar Interview RG", advisor.universityId()); + + // Create topic + ReplaceTopicPayload topicPayload = new ReplaceTopicPayload( + "Calendar Interview Topic", Set.of("MASTER"), + "PS", "Req", "Goals", "Refs", + List.of(advisor.userId()), List.of(advisor.userId()), + researchGroupId, null, null, false + ); + String topicResponse = mockMvc.perform(MockMvcRequestBuilders.post("/v2/topics") + .header("Authorization", createRandomAdminAuthentication()) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(topicPayload))) + .andExpect(status().isOk()) + .andReturn().getResponse().getContentAsString(); + UUID topicId = UUID.fromString(objectMapper.readTree(topicResponse).get("topicId").asString()); + + // Create student application + TestUser student = createRandomTestUser(List.of("student")); + String studentAuth = generateTestAuthenticationHeader(student.universityId(), List.of("student")); + CreateApplicationPayload appPayload = new CreateApplicationPayload( + topicId, null, "MASTER", Instant.now(), "Calendar test", researchGroupId + ); + String appResponse = mockMvc.perform(MockMvcRequestBuilders.post("/v2/applications") + .header("Authorization", studentAuth) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(appPayload))) + .andExpect(status().isOk()) + .andReturn().getResponse().getContentAsString(); + UUID applicationId = UUID.fromString(objectMapper.readTree(appResponse).get("applicationId").asString()); + + // Create interview process + String advisorAuth = generateTestAuthenticationHeader(advisor.universityId(), List.of("supervisor", "advisor")); + CreateInterviewProcessPayload processPayload = new CreateInterviewProcessPayload(topicId, List.of(applicationId)); + String processResponse = mockMvc.perform(MockMvcRequestBuilders.post("/v2/interview-process") + .header("Authorization", advisorAuth) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(processPayload))) + .andExpect(status().isOk()) + .andReturn().getResponse().getContentAsString(); + UUID processId = UUID.fromString(objectMapper.readTree(processResponse).get("interviewProcessId").asString()); + + // Get interviewee ID + String intervieweesResponse = mockMvc.perform(MockMvcRequestBuilders.get("/v2/interview-process/{id}/interviewees", processId) + .header("Authorization", advisorAuth)) + .andExpect(status().isOk()) + .andReturn().getResponse().getContentAsString(); + UUID intervieweeId = UUID.fromString(objectMapper.readTree(intervieweesResponse) + .get("content").get(0).get("intervieweeId").asString()); + + // Add slots (2 non-overlapping to avoid overlap bug) + Instant now = Instant.now(); + InterviewSlotDto slot1 = new InterviewSlotDto( + null, now.plus(1, ChronoUnit.DAYS), now.plus(1, ChronoUnit.DAYS).plus(30, ChronoUnit.MINUTES), + null, "Room 101", "https://meet.example.com" + ); + InterviewSlotDto slot2 = new InterviewSlotDto( + null, now.plus(2, ChronoUnit.DAYS), now.plus(2, ChronoUnit.DAYS).plus(30, ChronoUnit.MINUTES), + null, "Room 102", null + ); + CreateInterviewSlotsPayload slotsPayload = new CreateInterviewSlotsPayload(processId, List.of(slot1, slot2)); + String slotsResponse = mockMvc.perform(MockMvcRequestBuilders.post("/v2/interview-process/interview-slots") + .header("Authorization", advisorAuth) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(slotsPayload))) + .andExpect(status().isOk()) + .andReturn().getResponse().getContentAsString(); + UUID slotId = UUID.fromString(objectMapper.readTree(slotsResponse).get(0).get("slotId").asString()); + + // Invite interviewee + InviteIntervieweesPayload invitePayload = new InviteIntervieweesPayload(List.of(intervieweeId)); + mockMvc.perform(MockMvcRequestBuilders.post("/v2/interview-process/{id}/invite", processId) + .header("Authorization", advisorAuth) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(invitePayload))) + .andExpect(status().isOk()); + + // Book slot + BookInterviewSlotPayload bookPayload = new BookInterviewSlotPayload(student.userId()); + mockMvc.perform(MockMvcRequestBuilders.put("/v2/interview-process/{id}/slot/{slotId}/book", processId, slotId) + .header("Authorization", studentAuth) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(bookPayload))) + .andExpect(status().isOk()); + + // Get the calendar for the advisor (who has interview slots) + String calendarResponse = mockMvc.perform(MockMvcRequestBuilders.get("/v2/calendar/interviews/user/{userId}", advisor.userId()) + .header("Authorization", createRandomAdminAuthentication())) + .andExpect(status().isOk()) + .andExpect(content().contentType("text/calendar")) + .andReturn().getResponse().getContentAsString(); + + assertThat(calendarResponse).contains("VCALENDAR"); + assertThat(calendarResponse).contains("VEVENT"); + assertThat(calendarResponse).contains("Interview Slot"); + } + } +} diff --git a/server/src/test/java/de/tum/cit/aet/thesis/controller/DashboardControllerTest.java b/server/src/test/java/de/tum/cit/aet/thesis/controller/DashboardControllerTest.java new file mode 100644 index 000000000..d02ee8dd8 --- /dev/null +++ b/server/src/test/java/de/tum/cit/aet/thesis/controller/DashboardControllerTest.java @@ -0,0 +1,363 @@ +package de.tum.cit.aet.thesis.controller; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import de.tum.cit.aet.thesis.constants.ThesisPresentationType; +import de.tum.cit.aet.thesis.constants.ThesisPresentationVisibility; +import de.tum.cit.aet.thesis.constants.ThesisState; +import de.tum.cit.aet.thesis.controller.payload.CreateApplicationPayload; +import de.tum.cit.aet.thesis.controller.payload.CreateThesisPayload; +import de.tum.cit.aet.thesis.controller.payload.ReplacePresentationPayload; +import de.tum.cit.aet.thesis.entity.Thesis; +import de.tum.cit.aet.thesis.entity.ThesisStateChange; +import de.tum.cit.aet.thesis.entity.key.ThesisStateChangeId; +import de.tum.cit.aet.thesis.mock.BaseIntegrationTest; +import de.tum.cit.aet.thesis.repository.ThesisRepository; +import de.tum.cit.aet.thesis.repository.ThesisStateChangeRepository; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.http.MediaType; +import org.springframework.mock.web.MockMultipartFile; +import org.springframework.test.context.DynamicPropertyRegistry; +import org.springframework.test.context.DynamicPropertySource; +import org.springframework.test.web.servlet.request.MockMvcRequestBuilders; +import org.testcontainers.junit.jupiter.Testcontainers; +import tools.jackson.databind.JsonNode; + +import java.time.Instant; +import java.util.List; +import java.util.UUID; + +@Testcontainers +class DashboardControllerTest extends BaseIntegrationTest { + + @DynamicPropertySource + static void configureDynamicProperties(DynamicPropertyRegistry registry) { + configureProperties(registry); + } + + @Autowired + private ThesisRepository thesisRepository; + + @Autowired + private ThesisStateChangeRepository thesisStateChangeRepository; + + private UUID createThesisWithState(String title, ThesisState targetState, + List students, List advisors, List supervisors, UUID researchGroupId) throws Exception { + CreateThesisPayload payload = new CreateThesisPayload( + title, "MASTER", "ENGLISH", students, advisors, supervisors, researchGroupId + ); + + String thesisResponse = mockMvc.perform(MockMvcRequestBuilders.post("/v2/theses") + .header("Authorization", createRandomAdminAuthentication()) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(payload))) + .andExpect(status().isOk()) + .andReturn().getResponse().getContentAsString(); + + UUID thesisId = UUID.fromString(objectMapper.readTree(thesisResponse).get("thesisId").asString()); + + if (targetState != null && targetState != ThesisState.PROPOSAL) { + Thesis thesis = thesisRepository.findById(thesisId).orElseThrow(); + ThesisStateChangeId stateChangeId = new ThesisStateChangeId(); + stateChangeId.setThesisId(thesis.getId()); + stateChangeId.setState(targetState); + ThesisStateChange stateChange = new ThesisStateChange(); + stateChange.setId(stateChangeId); + stateChange.setThesis(thesis); + stateChange.setChangedAt(Instant.now()); + thesisStateChangeRepository.save(stateChange); + thesis.setState(targetState); + thesis.getStates().add(stateChange); + thesisRepository.save(thesis); + } + + return thesisId; + } + + private boolean hasTaskContaining(JsonNode tasks, String text) { + for (JsonNode task : tasks) { + if (task.get("message").asString().contains(text)) { + return true; + } + } + return false; + } + + @Nested + class StudentTasks { + @Test + void getTasks_AsStudent_ReturnsScientificWritingGuideTask() throws Exception { + String auth = createRandomAuthentication("student"); + + String response = mockMvc.perform(MockMvcRequestBuilders.get("/v2/dashboard/tasks") + .header("Authorization", auth)) + .andExpect(status().isOk()) + .andReturn().getResponse().getContentAsString(); + + JsonNode json = objectMapper.readTree(response); + assertThat(json.size()).isGreaterThanOrEqualTo(1); + + boolean hasWritingGuide = false; + for (JsonNode task : json) { + if (task.get("message").asString().contains("scientific writing")) { + hasWritingGuide = true; + break; + } + } + assertThat(hasWritingGuide).isTrue(); + } + + @Test + void getTasks_StudentWithThesis_ReturnsAbstractTask() throws Exception { + createTestEmailTemplate("THESIS_CREATED"); + TestUser student = createRandomTestUser(List.of("student")); + TestUser advisor = createRandomTestUser(List.of("supervisor", "advisor")); + UUID researchGroupId = createTestResearchGroup("Dashboard Test Group", advisor.universityId()); + + createThesisWithState("Dashboard Test Thesis", null, + List.of(student.userId()), List.of(advisor.userId()), List.of(advisor.userId()), researchGroupId); + + String response = mockMvc.perform(MockMvcRequestBuilders.get("/v2/dashboard/tasks") + .header("Authorization", generateTestAuthenticationHeader(student.universityId(), List.of("student")))) + .andExpect(status().isOk()) + .andReturn().getResponse().getContentAsString(); + + JsonNode json = objectMapper.readTree(response); + assertThat(hasTaskContaining(json, "abstract")).isTrue(); + assertThat(hasTaskContaining(json, "proposal")).isTrue(); + } + + @Test + void getTasks_StudentWithWritingThesis_ReturnsSubmissionTask() throws Exception { + createTestEmailTemplate("THESIS_CREATED"); + TestUser student = createRandomTestUser(List.of("student")); + TestUser advisor = createRandomTestUser(List.of("supervisor", "advisor")); + UUID researchGroupId = createTestResearchGroup("Writing Thesis Group", advisor.universityId()); + + createThesisWithState("Writing Thesis", ThesisState.WRITING, + List.of(student.userId()), List.of(advisor.userId()), List.of(advisor.userId()), researchGroupId); + + String response = mockMvc.perform(MockMvcRequestBuilders.get("/v2/dashboard/tasks") + .header("Authorization", generateTestAuthenticationHeader(student.universityId(), List.of("student")))) + .andExpect(status().isOk()) + .andReturn().getResponse().getContentAsString(); + + JsonNode json = objectMapper.readTree(response); + assertThat(hasTaskContaining(json, "Submit your final thesis")).isTrue(); + } + } + + @Nested + class AdvisorTasks { + @Test + void getTasks_AdvisorWithSubmittedThesis_ReturnsAssessmentTask() throws Exception { + createTestEmailTemplate("THESIS_CREATED"); + TestUser student = createRandomTestUser(List.of("student")); + TestUser advisor = createRandomTestUser(List.of("supervisor", "advisor")); + UUID researchGroupId = createTestResearchGroup("Advisor Task Group", advisor.universityId()); + + createThesisWithState("Submitted Thesis", ThesisState.SUBMITTED, + List.of(student.userId()), List.of(advisor.userId()), List.of(advisor.userId()), researchGroupId); + + String response = mockMvc.perform(MockMvcRequestBuilders.get("/v2/dashboard/tasks") + .header("Authorization", generateTestAuthenticationHeader(advisor.universityId(), List.of("supervisor", "advisor")))) + .andExpect(status().isOk()) + .andReturn().getResponse().getContentAsString(); + + JsonNode json = objectMapper.readTree(response); + assertThat(hasTaskContaining(json, "assessment")).isTrue(); + } + + @Test + void getTasks_AdvisorWithMissingDates_ReturnsDateTask() throws Exception { + createTestEmailTemplate("THESIS_CREATED"); + TestUser student = createRandomTestUser(List.of("student")); + TestUser advisor = createRandomTestUser(List.of("supervisor", "advisor")); + UUID researchGroupId = createTestResearchGroup("Dates Task Group", advisor.universityId()); + + createThesisWithState("No Dates Thesis", ThesisState.WRITING, + List.of(student.userId()), List.of(advisor.userId()), List.of(advisor.userId()), researchGroupId); + + String response = mockMvc.perform(MockMvcRequestBuilders.get("/v2/dashboard/tasks") + .header("Authorization", generateTestAuthenticationHeader(advisor.universityId(), List.of("supervisor", "advisor")))) + .andExpect(status().isOk()) + .andReturn().getResponse().getContentAsString(); + + JsonNode json = objectMapper.readTree(response); + assertThat(hasTaskContaining(json, "start and end date")).isTrue(); + } + } + + @Nested + class SupervisorTasks { + @Test + void getTasks_SupervisorWithAssessedThesis_ReturnsGradeTask() throws Exception { + createTestEmailTemplate("THESIS_CREATED"); + TestUser student = createRandomTestUser(List.of("student")); + TestUser supervisor = createRandomTestUser(List.of("supervisor")); + TestUser advisor = createRandomTestUser(List.of("advisor")); + UUID researchGroupId = createTestResearchGroup("Supervisor Task Group", supervisor.universityId()); + + mockMvc.perform(MockMvcRequestBuilders.put("/v2/research-groups/{id}/assign/{username}", researchGroupId, advisor.universityId()) + .header("Authorization", createRandomAdminAuthentication())) + .andExpect(status().isOk()); + + createThesisWithState("Assessed Thesis", ThesisState.ASSESSED, + List.of(student.userId()), List.of(advisor.userId()), List.of(supervisor.userId()), researchGroupId); + + String response = mockMvc.perform(MockMvcRequestBuilders.get("/v2/dashboard/tasks") + .header("Authorization", generateTestAuthenticationHeader(supervisor.universityId(), List.of("supervisor")))) + .andExpect(status().isOk()) + .andReturn().getResponse().getContentAsString(); + + JsonNode json = objectMapper.readTree(response); + assertThat(hasTaskContaining(json, "final grade")).isTrue(); + } + } + + @Nested + class SupervisorCloseTasks { + @Test + void getTasks_SupervisorWithGradedThesis_ReturnsCloseTask() throws Exception { + createTestEmailTemplate("THESIS_CREATED"); + TestUser student = createRandomTestUser(List.of("student")); + TestUser supervisor = createRandomTestUser(List.of("supervisor")); + TestUser advisor = createRandomTestUser(List.of("advisor")); + UUID researchGroupId = createTestResearchGroup("Close Task Group", supervisor.universityId()); + + mockMvc.perform(MockMvcRequestBuilders.put("/v2/research-groups/{id}/assign/{username}", researchGroupId, advisor.universityId()) + .header("Authorization", createRandomAdminAuthentication())) + .andExpect(status().isOk()); + + createThesisWithState("Graded Thesis", ThesisState.GRADED, + List.of(student.userId()), List.of(advisor.userId()), List.of(supervisor.userId()), researchGroupId); + + String response = mockMvc.perform(MockMvcRequestBuilders.get("/v2/dashboard/tasks") + .header("Authorization", generateTestAuthenticationHeader(supervisor.universityId(), List.of("supervisor")))) + .andExpect(status().isOk()) + .andReturn().getResponse().getContentAsString(); + + JsonNode json = objectMapper.readTree(response); + assertThat(hasTaskContaining(json, "graded but not completed")).isTrue(); + } + } + + @Nested + class PresentationDraftTasks { + @Test + void getTasks_AdvisorWithDraftPresentation_ReturnsReviewTask() throws Exception { + createTestEmailTemplate("THESIS_CREATED"); + TestUser student = createRandomTestUser(List.of("student")); + TestUser advisor = createRandomTestUser(List.of("supervisor", "advisor")); + UUID researchGroupId = createTestResearchGroup("Presentation Draft Group", advisor.universityId()); + + UUID thesisId = createThesisWithState("Draft Presentation Thesis", ThesisState.WRITING, + List.of(student.userId()), List.of(advisor.userId()), List.of(advisor.userId()), researchGroupId); + + String advisorAuth = generateTestAuthenticationHeader(advisor.universityId(), List.of("supervisor", "advisor")); + ReplacePresentationPayload presPayload = new ReplacePresentationPayload( + ThesisPresentationType.INTERMEDIATE, + ThesisPresentationVisibility.PUBLIC, + "Room 101", "http://stream.url", "English", + Instant.now().plusSeconds(86400) + ); + mockMvc.perform(MockMvcRequestBuilders.post("/v2/theses/{thesisId}/presentations", thesisId) + .header("Authorization", advisorAuth) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(presPayload))) + .andExpect(status().isOk()); + + String response = mockMvc.perform(MockMvcRequestBuilders.get("/v2/dashboard/tasks") + .header("Authorization", advisorAuth)) + .andExpect(status().isOk()) + .andReturn().getResponse().getContentAsString(); + + JsonNode json = objectMapper.readTree(response); + assertThat(hasTaskContaining(json, "presentation draft")).isTrue(); + } + } + + @Nested + class ProposalReviewTasks { + @Test + void getTasks_AdvisorWithSubmittedProposal_ReturnsReviewTask() throws Exception { + createTestEmailTemplate("THESIS_CREATED"); + createTestEmailTemplate("THESIS_PROPOSAL_UPLOADED"); + TestUser student = createRandomTestUser(List.of("student")); + TestUser advisor = createRandomTestUser(List.of("supervisor", "advisor")); + UUID researchGroupId = createTestResearchGroup("Proposal Review Group", advisor.universityId()); + + UUID thesisId = createThesisWithState("Proposal Review Thesis", null, + List.of(student.userId()), List.of(advisor.userId()), List.of(advisor.userId()), researchGroupId); + + String advisorAuth = generateTestAuthenticationHeader(advisor.universityId(), List.of("supervisor", "advisor")); + MockMultipartFile proposalFile = new MockMultipartFile( + "proposal", "proposal.pdf", "application/pdf", "proposal content".getBytes() + ); + mockMvc.perform(MockMvcRequestBuilders.multipart("/v2/theses/{thesisId}/proposal", thesisId) + .file(proposalFile) + .header("Authorization", advisorAuth)) + .andExpect(status().isOk()); + + String response = mockMvc.perform(MockMvcRequestBuilders.get("/v2/dashboard/tasks") + .header("Authorization", advisorAuth)) + .andExpect(status().isOk()) + .andReturn().getResponse().getContentAsString(); + + JsonNode json = objectMapper.readTree(response); + assertThat(hasTaskContaining(json, "proposal was submitted")).isTrue(); + } + } + + @Nested + class AdminTasks { + @Test + void getTasks_AdminWithUnreviewedApplications_ReturnsApplicationTask() throws Exception { + createTestEmailTemplate("APPLICATION_CREATED_CHAIR"); + createTestEmailTemplate("APPLICATION_CREATED_STUDENT"); + + TestUser head = createRandomTestUser(List.of("supervisor")); + UUID researchGroupId = createTestResearchGroup("Admin Task Group", head.universityId()); + + String studentAuth = createRandomAuthentication("student"); + CreateApplicationPayload appPayload = new CreateApplicationPayload( + null, "Test App Title", "BACHELOR", Instant.now(), "Motivation", researchGroupId + ); + + mockMvc.perform(MockMvcRequestBuilders.post("/v2/applications") + .header("Authorization", studentAuth) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(appPayload))) + .andExpect(status().isOk()); + + String headAuth = generateTestAuthenticationHeader(head.universityId(), List.of("supervisor")); + String response = mockMvc.perform(MockMvcRequestBuilders.get("/v2/dashboard/tasks") + .header("Authorization", headAuth)) + .andExpect(status().isOk()) + .andReturn().getResponse().getContentAsString(); + + JsonNode json = objectMapper.readTree(response); + assertThat(hasTaskContaining(json, "unreviewed applications")).isTrue(); + } + + @Test + void getTasks_SortedByPriorityDescending() throws Exception { + String response = mockMvc.perform(MockMvcRequestBuilders.get("/v2/dashboard/tasks") + .header("Authorization", createRandomAdminAuthentication())) + .andExpect(status().isOk()) + .andReturn().getResponse().getContentAsString(); + + JsonNode json = objectMapper.readTree(response); + assertThat(json.size()).as("Admin should have at least one dashboard task").isGreaterThan(0); + int prevPriority = Integer.MAX_VALUE; + for (JsonNode task : json) { + int priority = task.get("priority").asInt(); + assertThat(priority).isLessThanOrEqualTo(prevPriority); + prevPriority = priority; + } + } + } +} diff --git a/server/src/test/java/de/tum/cit/aet/thesis/controller/EmailTemplateIntegrationTest.java b/server/src/test/java/de/tum/cit/aet/thesis/controller/EmailTemplateIntegrationTest.java new file mode 100644 index 000000000..42ea0a485 --- /dev/null +++ b/server/src/test/java/de/tum/cit/aet/thesis/controller/EmailTemplateIntegrationTest.java @@ -0,0 +1,195 @@ +package de.tum.cit.aet.thesis.controller; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.hamcrest.Matchers.*; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import de.tum.cit.aet.thesis.mock.BaseIntegrationTest; +import de.tum.cit.aet.thesis.repository.EmailTemplateRepository; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.http.MediaType; +import org.springframework.test.context.DynamicPropertyRegistry; +import org.springframework.test.context.DynamicPropertySource; +import org.springframework.test.web.servlet.request.MockMvcRequestBuilders; +import org.testcontainers.junit.jupiter.Testcontainers; +import tools.jackson.databind.JsonNode; + +import java.util.List; +import java.util.Map; +import java.util.UUID; + +@Testcontainers +class EmailTemplateIntegrationTest extends BaseIntegrationTest { + + @DynamicPropertySource + static void configureDynamicProperties(DynamicPropertyRegistry registry) { + configureProperties(registry); + } + + @Autowired + private EmailTemplateRepository emailTemplateRepository; + + private UUID createTemplate(String templateCase) throws Exception { + String payload = objectMapper.writeValueAsString(Map.of( + "templateCase", templateCase, + "description", "Test desc for " + templateCase, + "subject", "Subject for " + templateCase, + "bodyHtml", "

Body for " + templateCase + "

", + "language", "en" + )); + + String response = mockMvc.perform(MockMvcRequestBuilders.post("/v2/email-templates") + .header("Authorization", createRandomAdminAuthentication()) + .contentType(MediaType.APPLICATION_JSON) + .content(payload)) + .andExpect(status().isOk()) + .andReturn().getResponse().getContentAsString(); + + return UUID.fromString(objectMapper.readTree(response).get("id").asString()); + } + + @Nested + class GetEmailTemplates { + @Test + void getEmailTemplates_ReturnsCreatedTemplates() throws Exception { + createTemplate("APPLICATION_CREATED_CHAIR"); + createTemplate("THESIS_CREATED"); + + mockMvc.perform(MockMvcRequestBuilders.get("/v2/email-templates") + .header("Authorization", createRandomAdminAuthentication())) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.content", hasSize(greaterThanOrEqualTo(2)))); + } + + @Test + void getEmailTemplates_WithTemplateCaseFilter() throws Exception { + createTemplate("APPLICATION_CREATED_CHAIR"); + createTemplate("THESIS_CREATED"); + + mockMvc.perform(MockMvcRequestBuilders.get("/v2/email-templates") + .header("Authorization", createRandomAdminAuthentication()) + .param("templateCases", "APPLICATION_CREATED_CHAIR")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.content", hasSize(1))); + } + + @Test + void getEmailTemplates_WithLanguageFilter() throws Exception { + createTemplate("APPLICATION_REJECTED"); + + mockMvc.perform(MockMvcRequestBuilders.get("/v2/email-templates") + .header("Authorization", createRandomAdminAuthentication()) + .param("languages", "en")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.content", hasSize(greaterThanOrEqualTo(1)))); + + String deResponse = mockMvc.perform(MockMvcRequestBuilders.get("/v2/email-templates") + .header("Authorization", createRandomAdminAuthentication()) + .param("languages", "de")) + .andExpect(status().isOk()) + .andReturn().getResponse().getContentAsString(); + assertThat(objectMapper.readTree(deResponse).get("totalElements").asInt()).isZero(); + } + + @Test + void getEmailTemplates_WithSearch() throws Exception { + createTemplate("THESIS_FINAL_SUBMISSION"); + + mockMvc.perform(MockMvcRequestBuilders.get("/v2/email-templates") + .header("Authorization", createRandomAdminAuthentication()) + .param("search", "THESIS_FINAL_SUBMISSION")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.content", hasSize(greaterThanOrEqualTo(1)))); + } + + @Test + void getEmailTemplates_AsStudent_Forbidden() throws Exception { + mockMvc.perform(MockMvcRequestBuilders.get("/v2/email-templates") + .header("Authorization", createRandomAuthentication("student"))) + .andExpect(status().isForbidden()); + } + } + + @Nested + class GetSingleTemplate { + @Test + void getEmailTemplate_Success() throws Exception { + UUID templateId = createTemplate("APPLICATION_CREATED_STUDENT"); + + String response = mockMvc.perform(MockMvcRequestBuilders.get("/v2/email-templates/{id}", templateId) + .header("Authorization", createRandomAdminAuthentication())) + .andExpect(status().isOk()) + .andReturn().getResponse().getContentAsString(); + + JsonNode json = objectMapper.readTree(response); + assertThat(json.get("id").asString()).isEqualTo(templateId.toString()); + assertThat(json.get("templateCase").asString()).isEqualTo("APPLICATION_CREATED_STUDENT"); + } + + @Test + void getEmailTemplate_NotFound() throws Exception { + mockMvc.perform(MockMvcRequestBuilders.get("/v2/email-templates/{id}", UUID.randomUUID()) + .header("Authorization", createRandomAdminAuthentication())) + .andExpect(status().isNotFound()); + } + } + + @Nested + class UpdateTemplate { + @Test + void updateEmailTemplate_Success() throws Exception { + UUID templateId = createTemplate("THESIS_CLOSED"); + + String updatePayload = objectMapper.writeValueAsString(Map.of( + "templateCase", "THESIS_CLOSED", + "description", "Updated description", + "subject", "Updated Subject", + "bodyHtml", "

Updated body

", + "language", "en" + )); + + String response = mockMvc.perform(MockMvcRequestBuilders.put("/v2/email-templates/{id}", templateId) + .header("Authorization", createRandomAdminAuthentication()) + .contentType(MediaType.APPLICATION_JSON) + .content(updatePayload)) + .andExpect(status().isOk()) + .andReturn().getResponse().getContentAsString(); + + JsonNode json = objectMapper.readTree(response); + assertThat(json.get("subject").asString()).isEqualTo("Updated Subject"); + assertThat(json.get("description").asString()).isEqualTo("Updated description"); + } + } + + @Nested + class DeleteTemplate { + @Test + void deleteEmailTemplate_Success() throws Exception { + UUID templateId = createTemplate("THESIS_COMMENT_POSTED"); + + mockMvc.perform(MockMvcRequestBuilders.delete("/v2/email-templates/{id}", templateId) + .header("Authorization", createRandomAdminAuthentication())) + .andExpect(status().isOk()); + + mockMvc.perform(MockMvcRequestBuilders.get("/v2/email-templates/{id}", templateId) + .header("Authorization", createRandomAdminAuthentication())) + .andExpect(status().isNotFound()); + } + } + + @Nested + class GetTemplateVariables { + @Test + void getVariables_Success() throws Exception { + UUID templateId = createTemplate("APPLICATION_CREATED_CHAIR"); + + mockMvc.perform(MockMvcRequestBuilders.get("/v2/email-templates/{id}/variables", templateId) + .header("Authorization", createRandomAdminAuthentication())) + .andExpect(status().isOk()) + .andExpect(jsonPath("$", isA(List.class))); + } + } +} diff --git a/server/src/test/java/de/tum/cit/aet/thesis/controller/InterviewProcessControllerTest.java b/server/src/test/java/de/tum/cit/aet/thesis/controller/InterviewProcessControllerTest.java index 57856d900..7b2f017ae 100644 --- a/server/src/test/java/de/tum/cit/aet/thesis/controller/InterviewProcessControllerTest.java +++ b/server/src/test/java/de/tum/cit/aet/thesis/controller/InterviewProcessControllerTest.java @@ -3,6 +3,8 @@ import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertNotNull; import static org.junit.jupiter.api.Assertions.assertNull; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyInt; import static org.mockito.Mockito.never; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; @@ -312,7 +314,7 @@ void updateInterviewee_WhenDifferentProcess_ReturnsNotFoundAndDoesNotUpdate() { ); assertEquals(HttpStatus.NOT_FOUND, response.getStatusCode()); - verify(interviewProcessService, never()).updateIntervieweeAssessment(org.mockito.ArgumentMatchers.any(), org.mockito.ArgumentMatchers.any(), org.mockito.ArgumentMatchers.anyInt()); + verify(interviewProcessService, never()).updateIntervieweeAssessment(any(), any(), anyInt()); } @Test diff --git a/server/src/test/java/de/tum/cit/aet/thesis/controller/InterviewProcessIntegrationTest.java b/server/src/test/java/de/tum/cit/aet/thesis/controller/InterviewProcessIntegrationTest.java new file mode 100644 index 000000000..60544ce8c --- /dev/null +++ b/server/src/test/java/de/tum/cit/aet/thesis/controller/InterviewProcessIntegrationTest.java @@ -0,0 +1,409 @@ +package de.tum.cit.aet.thesis.controller; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.hamcrest.Matchers.*; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import de.tum.cit.aet.thesis.controller.payload.AddIntervieweesPayload; +import de.tum.cit.aet.thesis.controller.payload.BookInterviewSlotPayload; +import de.tum.cit.aet.thesis.controller.payload.CreateApplicationPayload; +import de.tum.cit.aet.thesis.controller.payload.CreateInterviewProcessPayload; +import de.tum.cit.aet.thesis.controller.payload.CreateInterviewSlotsPayload; +import de.tum.cit.aet.thesis.controller.payload.InviteIntervieweesPayload; +import de.tum.cit.aet.thesis.controller.payload.ReplaceTopicPayload; +import de.tum.cit.aet.thesis.controller.payload.UpdateIntervieweeAssessmentPayload; +import de.tum.cit.aet.thesis.dto.InterviewSlotDto; +import de.tum.cit.aet.thesis.mock.BaseIntegrationTest; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.http.MediaType; +import org.springframework.test.context.DynamicPropertyRegistry; +import org.springframework.test.context.DynamicPropertySource; +import org.springframework.test.web.servlet.request.MockMvcRequestBuilders; +import org.testcontainers.junit.jupiter.Testcontainers; +import tools.jackson.databind.JsonNode; + +import java.time.Instant; +import java.time.temporal.ChronoUnit; +import java.util.List; +import java.util.Set; +import java.util.UUID; + +@Testcontainers +class InterviewProcessIntegrationTest extends BaseIntegrationTest { + + @DynamicPropertySource + static void configureDynamicProperties(DynamicPropertyRegistry registry) { + configureProperties(registry); + } + + private record InterviewSetup(UUID processId, UUID topicId, UUID intervieweeId, TestUser advisor, String advisorAuth, TestUser student) {} + + private InterviewSetup createInterviewProcess() throws Exception { + createTestEmailTemplate("APPLICATION_CREATED_CHAIR"); + createTestEmailTemplate("APPLICATION_CREATED_STUDENT"); + createTestEmailTemplate("INTERVIEW_INVITATION"); + createTestEmailTemplate("INTERVIEW_INVITATION_REMINDER"); + createTestEmailTemplate("INTERVIEW_SLOT_BOOKED_CONFORMATION"); + createTestEmailTemplate("INTERVIEW_SLOT_BOOKED_CANCELLATION"); + + TestUser advisor = createRandomTestUser(List.of("supervisor", "advisor")); + UUID researchGroupId = createTestResearchGroup("Interview RG", advisor.universityId()); + + UUID topicId = createTestTopicForGroup("Interview Topic", advisor, researchGroupId); + + // Create a student application for the topic + TestUser student = createRandomTestUser(List.of("student")); + String studentAuth = generateTestAuthenticationHeader(student.universityId(), List.of("student")); + CreateApplicationPayload appPayload = new CreateApplicationPayload( + topicId, null, "MASTER", Instant.now(), "Interview motivation", researchGroupId + ); + String appResponse = mockMvc.perform(MockMvcRequestBuilders.post("/v2/applications") + .header("Authorization", studentAuth) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(appPayload))) + .andExpect(status().isOk()) + .andReturn().getResponse().getContentAsString(); + UUID applicationId = UUID.fromString(objectMapper.readTree(appResponse).get("applicationId").asString()); + + // Create the interview process + String advisorAuth = generateTestAuthenticationHeader(advisor.universityId(), List.of("supervisor", "advisor")); + CreateInterviewProcessPayload processPayload = new CreateInterviewProcessPayload( + topicId, List.of(applicationId) + ); + String processResponse = mockMvc.perform(MockMvcRequestBuilders.post("/v2/interview-process") + .header("Authorization", advisorAuth) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(processPayload))) + .andExpect(status().isOk()) + .andReturn().getResponse().getContentAsString(); + + JsonNode processJson = objectMapper.readTree(processResponse); + UUID processId = UUID.fromString(processJson.get("interviewProcessId").asString()); + + // Get interviewee ID from interviewees endpoint (InterviewProcessDto doesn't include interviewees list) + String intervieweesResponse = mockMvc.perform(MockMvcRequestBuilders.get("/v2/interview-process/{id}/interviewees", processId) + .header("Authorization", advisorAuth)) + .andExpect(status().isOk()) + .andReturn().getResponse().getContentAsString(); + UUID intervieweeId = UUID.fromString(objectMapper.readTree(intervieweesResponse) + .get("content").get(0).get("intervieweeId").asString()); + + return new InterviewSetup(processId, topicId, intervieweeId, advisor, advisorAuth, student); + } + + private UUID createTestTopicForGroup(String title, TestUser advisor, UUID researchGroupId) throws Exception { + ReplaceTopicPayload payload = new ReplaceTopicPayload( + title, + Set.of("MASTER", "BACHELOR"), + "Problem Statement", "Requirements", "Goals", "References", + List.of(advisor.userId()), List.of(advisor.userId()), + researchGroupId, null, null, false + ); + + String response = mockMvc.perform(MockMvcRequestBuilders.post("/v2/topics") + .header("Authorization", createRandomAdminAuthentication()) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(payload))) + .andExpect(status().isOk()) + .andReturn().getResponse().getContentAsString(); + + return UUID.fromString(objectMapper.readTree(response).get("topicId").asString()); + } + + @Nested + class ProcessCreation { + @Test + void createInterviewProcess_Success() throws Exception { + InterviewSetup setup = createInterviewProcess(); + assertThat(setup.processId).isNotNull(); + assertThat(setup.intervieweeId).isNotNull(); + } + + @Test + void getInterviewProcess_Success() throws Exception { + InterviewSetup setup = createInterviewProcess(); + + mockMvc.perform(MockMvcRequestBuilders.get("/v2/interview-process/{id}", setup.processId) + .header("Authorization", setup.advisorAuth)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.interviewProcessId").value(setup.processId.toString())); + } + + @Test + void getMyInterviewProcesses_Success() throws Exception { + InterviewSetup setup = createInterviewProcess(); + + mockMvc.perform(MockMvcRequestBuilders.get("/v2/interview-process") + .header("Authorization", setup.advisorAuth)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.content", hasSize(greaterThanOrEqualTo(1)))); + } + + @Test + void getInterviewProcessTopic_Success() throws Exception { + InterviewSetup setup = createInterviewProcess(); + + mockMvc.perform(MockMvcRequestBuilders.get("/v2/interview-process/{id}/topic", setup.processId) + .header("Authorization", setup.advisorAuth)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.topicId").value(setup.topicId.toString())); + } + + @Test + void isInterviewProcessCompleted_ReturnsFalse() throws Exception { + InterviewSetup setup = createInterviewProcess(); + + mockMvc.perform(MockMvcRequestBuilders.get("/v2/interview-process/{id}/completed", setup.processId) + .header("Authorization", setup.advisorAuth)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$").value(false)); + } + + @Test + void getInterviewApplications_Success() throws Exception { + InterviewSetup setup = createInterviewProcess(); + + // All applications moved to INTERVIEWING, so no NOT_ASSESSED applications available + mockMvc.perform(MockMvcRequestBuilders.get("/v2/interview-process/{id}/interview-applications", setup.processId) + .header("Authorization", setup.advisorAuth)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.totalElements").value(0)); + } + } + + @Nested + class SlotManagement { + @Test + void addSlots_Success() throws Exception { + InterviewSetup setup = createInterviewProcess(); + + Instant now = Instant.now(); + InterviewSlotDto slot1 = new InterviewSlotDto( + null, now.plus(1, ChronoUnit.DAYS), now.plus(1, ChronoUnit.DAYS).plus(30, ChronoUnit.MINUTES), + null, "Room 101", "https://meet.example.com" + ); + InterviewSlotDto slot2 = new InterviewSlotDto( + null, now.plus(2, ChronoUnit.DAYS), now.plus(2, ChronoUnit.DAYS).plus(30, ChronoUnit.MINUTES), + null, "Room 102", null + ); + + CreateInterviewSlotsPayload slotsPayload = new CreateInterviewSlotsPayload( + setup.processId, List.of(slot1, slot2) + ); + + mockMvc.perform(MockMvcRequestBuilders.post("/v2/interview-process/interview-slots") + .header("Authorization", setup.advisorAuth) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(slotsPayload))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$", hasSize(2))); + } + + @Test + void getSlots_Success() throws Exception { + InterviewSetup setup = createInterviewProcess(); + addSlotsToProcess(setup); + + mockMvc.perform(MockMvcRequestBuilders.get("/v2/interview-process/{id}/interview-slots", setup.processId) + .header("Authorization", setup.advisorAuth)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$", hasSize(greaterThanOrEqualTo(1)))); + } + + @Test + void getSlots_ExcludeBooked() throws Exception { + InterviewSetup setup = createInterviewProcess(); + addSlotsToProcess(setup); + + mockMvc.perform(MockMvcRequestBuilders.get("/v2/interview-process/{id}/interview-slots", setup.processId) + .header("Authorization", setup.advisorAuth) + .param("excludeBooked", "true")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$", hasSize(greaterThanOrEqualTo(1)))); + } + } + + @Nested + class IntervieweeManagement { + @Test + void getInterviewees_Success() throws Exception { + InterviewSetup setup = createInterviewProcess(); + + mockMvc.perform(MockMvcRequestBuilders.get("/v2/interview-process/{id}/interviewees", setup.processId) + .header("Authorization", setup.advisorAuth)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.content", hasSize(1))); + } + + @Test + void getInterviewee_Success() throws Exception { + InterviewSetup setup = createInterviewProcess(); + + mockMvc.perform(MockMvcRequestBuilders.get("/v2/interview-process/{id}/interviewee/{iId}", + setup.processId, setup.intervieweeId) + .header("Authorization", setup.advisorAuth)) + .andExpect(status().isOk()); + } + + @Test + void updateIntervieweeAssessment_Success() throws Exception { + InterviewSetup setup = createInterviewProcess(); + + UpdateIntervieweeAssessmentPayload payload = new UpdateIntervieweeAssessmentPayload( + "Good technical skills", 8 + ); + + mockMvc.perform(MockMvcRequestBuilders.post("/v2/interview-process/{id}/interviewee/{iId}", + setup.processId, setup.intervieweeId) + .header("Authorization", setup.advisorAuth) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(payload))) + .andExpect(status().isOk()); + } + + @Test + void updateIntervieweeAssessment_NegativeScore_ClearsScore() throws Exception { + InterviewSetup setup = createInterviewProcess(); + + UpdateIntervieweeAssessmentPayload payload = new UpdateIntervieweeAssessmentPayload( + "Reset score", -1 + ); + + mockMvc.perform(MockMvcRequestBuilders.post("/v2/interview-process/{id}/interviewee/{iId}", + setup.processId, setup.intervieweeId) + .header("Authorization", setup.advisorAuth) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(payload))) + .andExpect(status().isOk()); + } + + @Test + void inviteInterviewees_Success() throws Exception { + InterviewSetup setup = createInterviewProcess(); + addSlotsToProcess(setup); + + InviteIntervieweesPayload payload = new InviteIntervieweesPayload( + List.of(setup.intervieweeId) + ); + + mockMvc.perform(MockMvcRequestBuilders.post("/v2/interview-process/{id}/invite", setup.processId) + .header("Authorization", setup.advisorAuth) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(payload))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$", hasSize(1))); + } + + @Test + void addMoreInterviewees_Success() throws Exception { + createTestEmailTemplate("APPLICATION_CREATED_CHAIR"); + createTestEmailTemplate("APPLICATION_CREATED_STUDENT"); + + InterviewSetup setup = createInterviewProcess(); + + // Create another student and application + TestUser student2 = createRandomTestUser(List.of("student")); + String student2Auth = generateTestAuthenticationHeader(student2.universityId(), List.of("student")); + CreateApplicationPayload appPayload = new CreateApplicationPayload( + setup.topicId, null, "BACHELOR", Instant.now(), "Another motivation", null + ); + String appResponse = mockMvc.perform(MockMvcRequestBuilders.post("/v2/applications") + .header("Authorization", student2Auth) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(appPayload))) + .andExpect(status().isOk()) + .andReturn().getResponse().getContentAsString(); + UUID app2Id = UUID.fromString(objectMapper.readTree(appResponse).get("applicationId").asString()); + + // Add the new interviewee to the existing process + AddIntervieweesPayload addPayload = new AddIntervieweesPayload(List.of(app2Id)); + + mockMvc.perform(MockMvcRequestBuilders.post("/v2/interview-process/{id}/interviewees", setup.processId) + .header("Authorization", setup.advisorAuth) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(addPayload))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.totalInterviewees").value(2)); + } + } + + @Nested + class SlotBookingAndCancellation { + @Test + void bookAndCancelSlot_FullFlow() throws Exception { + InterviewSetup setup = createInterviewProcess(); + UUID slotId = addSlotsToProcess(setup); + + // Invite the interviewee first + InviteIntervieweesPayload invitePayload = new InviteIntervieweesPayload( + List.of(setup.intervieweeId) + ); + mockMvc.perform(MockMvcRequestBuilders.post("/v2/interview-process/{id}/invite", setup.processId) + .header("Authorization", setup.advisorAuth) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(invitePayload))) + .andExpect(status().isOk()); + + // Book the slot (as the student interviewee) + String studentAuth = generateTestAuthenticationHeader(setup.student.universityId(), List.of("student")); + BookInterviewSlotPayload bookPayload = new BookInterviewSlotPayload(setup.student.userId()); + + mockMvc.perform(MockMvcRequestBuilders.put("/v2/interview-process/{id}/slot/{slotId}/book", + setup.processId, slotId) + .header("Authorization", studentAuth) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(bookPayload))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.bookedBy").isNotEmpty()); + + // Cancel the slot (as advisor) + mockMvc.perform(MockMvcRequestBuilders.put("/v2/interview-process/{id}/slot/{slotId}/cancel", + setup.processId, slotId) + .header("Authorization", setup.advisorAuth)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.bookedBy").isEmpty()); + } + } + + @Nested + class UpcomingInterviews { + @Test + void getUpcomingInterviews_ReturnsEmpty() throws Exception { + InterviewSetup setup = createInterviewProcess(); + + mockMvc.perform(MockMvcRequestBuilders.get("/v2/interview-process/upcoming-interviews") + .header("Authorization", setup.advisorAuth)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$", hasSize(0))); + } + } + + private UUID addSlotsToProcess(InterviewSetup setup) throws Exception { + Instant now = Instant.now(); + // Must include at least 2 non-overlapping slots (service overlap check returns true for single slot) + InterviewSlotDto slot = new InterviewSlotDto( + null, now.plus(1, ChronoUnit.DAYS), now.plus(1, ChronoUnit.DAYS).plus(30, ChronoUnit.MINUTES), + null, "Room 101", "https://meet.example.com" + ); + InterviewSlotDto slot2 = new InterviewSlotDto( + null, now.plus(2, ChronoUnit.DAYS), now.plus(2, ChronoUnit.DAYS).plus(30, ChronoUnit.MINUTES), + null, "Room 102", null + ); + + CreateInterviewSlotsPayload slotsPayload = new CreateInterviewSlotsPayload( + setup.processId, List.of(slot, slot2) + ); + + String response = mockMvc.perform(MockMvcRequestBuilders.post("/v2/interview-process/interview-slots") + .header("Authorization", setup.advisorAuth) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(slotsPayload))) + .andExpect(status().isOk()) + .andReturn().getResponse().getContentAsString(); + + JsonNode slotsJson = objectMapper.readTree(response); + return UUID.fromString(slotsJson.get(0).get("slotId").asString()); + } +} diff --git a/server/src/test/java/de/tum/cit/aet/thesis/controller/PublishedPresentationControllerTest.java b/server/src/test/java/de/tum/cit/aet/thesis/controller/PublishedPresentationControllerTest.java new file mode 100644 index 000000000..9205836c1 --- /dev/null +++ b/server/src/test/java/de/tum/cit/aet/thesis/controller/PublishedPresentationControllerTest.java @@ -0,0 +1,168 @@ +package de.tum.cit.aet.thesis.controller; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.hamcrest.Matchers.*; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import de.tum.cit.aet.thesis.constants.ThesisPresentationState; +import de.tum.cit.aet.thesis.constants.ThesisPresentationType; +import de.tum.cit.aet.thesis.constants.ThesisPresentationVisibility; +import de.tum.cit.aet.thesis.entity.Thesis; +import de.tum.cit.aet.thesis.entity.ThesisPresentation; +import de.tum.cit.aet.thesis.entity.User; +import de.tum.cit.aet.thesis.mock.BaseIntegrationTest; +import de.tum.cit.aet.thesis.repository.ThesisPresentationRepository; +import de.tum.cit.aet.thesis.repository.ThesisRepository; +import de.tum.cit.aet.thesis.repository.UserRepository; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.test.context.DynamicPropertyRegistry; +import org.springframework.test.context.DynamicPropertySource; +import org.springframework.test.web.servlet.request.MockMvcRequestBuilders; +import org.testcontainers.junit.jupiter.Testcontainers; +import tools.jackson.databind.JsonNode; + +import java.time.Instant; +import java.time.temporal.ChronoUnit; +import java.util.UUID; + +@Testcontainers +class PublishedPresentationControllerTest extends BaseIntegrationTest { + + @DynamicPropertySource + static void configureDynamicProperties(DynamicPropertyRegistry registry) { + configureProperties(registry); + } + + @Autowired + private ThesisRepository thesisRepository; + + @Autowired + private ThesisPresentationRepository thesisPresentationRepository; + + @Autowired + private UserRepository userRepository; + + private UUID createPublicScheduledPresentation() throws Exception { + UUID thesisId = createTestThesis("Presentation Thesis"); + Thesis thesis = thesisRepository.findById(thesisId).orElseThrow(); + User creator = userRepository.findAll().getFirst(); + + ThesisPresentation presentation = new ThesisPresentation(); + presentation.setThesis(thesis); + presentation.setState(ThesisPresentationState.SCHEDULED); + presentation.setType(ThesisPresentationType.FINAL); + presentation.setVisibility(ThesisPresentationVisibility.PUBLIC); + presentation.setLocation("Room 101"); + presentation.setLanguage("ENGLISH"); + presentation.setScheduledAt(Instant.now().plus(7, ChronoUnit.DAYS)); + presentation.setCreatedAt(Instant.now()); + presentation.setCreatedBy(creator); + + presentation = thesisPresentationRepository.save(presentation); + return presentation.getId(); + } + + @Nested + class GetPublishedPresentations { + @Test + void getPresentations_EmptyList() throws Exception { + String response = mockMvc.perform(MockMvcRequestBuilders.get("/v2/published-presentations") + .header("Authorization", createRandomAdminAuthentication())) + .andExpect(status().isOk()) + .andReturn().getResponse().getContentAsString(); + + JsonNode json = objectMapper.readTree(response); + assertThat(json.get("totalElements").asInt()).isZero(); + } + + @Test + void getPresentations_WithPublicPresentation() throws Exception { + createPublicScheduledPresentation(); + + mockMvc.perform(MockMvcRequestBuilders.get("/v2/published-presentations") + .header("Authorization", createRandomAdminAuthentication())) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.content", hasSize(1))); + } + + @Test + void getPresentations_IncludeDrafts() throws Exception { + UUID thesisId = createTestThesis("Draft Thesis"); + Thesis thesis = thesisRepository.findById(thesisId).orElseThrow(); + User creator = userRepository.findAll().getFirst(); + + ThesisPresentation draft = new ThesisPresentation(); + draft.setThesis(thesis); + draft.setState(ThesisPresentationState.DRAFTED); + draft.setType(ThesisPresentationType.FINAL); + draft.setVisibility(ThesisPresentationVisibility.PUBLIC); + draft.setLocation("Room 202"); + draft.setLanguage("ENGLISH"); + draft.setScheduledAt(Instant.now().plus(14, ChronoUnit.DAYS)); + draft.setCreatedAt(Instant.now()); + draft.setCreatedBy(creator); + thesisPresentationRepository.save(draft); + + String responseNoDrafts = mockMvc.perform(MockMvcRequestBuilders.get("/v2/published-presentations") + .header("Authorization", createRandomAdminAuthentication()) + .param("includeDrafts", "false")) + .andExpect(status().isOk()) + .andReturn().getResponse().getContentAsString(); + + JsonNode noDraftsJson = objectMapper.readTree(responseNoDrafts); + assertThat(noDraftsJson.get("totalElements").asInt()).isZero(); + + mockMvc.perform(MockMvcRequestBuilders.get("/v2/published-presentations") + .header("Authorization", createRandomAdminAuthentication()) + .param("includeDrafts", "true")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.content", hasSize(1))); + } + } + + @Nested + class GetSinglePresentation { + @Test + void getPresentation_Success() throws Exception { + UUID presentationId = createPublicScheduledPresentation(); + + mockMvc.perform(MockMvcRequestBuilders.get("/v2/published-presentations/{id}", presentationId) + .header("Authorization", createRandomAdminAuthentication())) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.presentationId").value(presentationId.toString())); + } + + @Test + void getPresentation_NotFound() throws Exception { + mockMvc.perform(MockMvcRequestBuilders.get("/v2/published-presentations/{id}", UUID.randomUUID()) + .header("Authorization", createRandomAdminAuthentication())) + .andExpect(status().isNotFound()); + } + + @Test + void getPresentation_PrivatePresentation_ReturnsForbidden() throws Exception { + UUID thesisId = createTestThesis("Private Presentation Thesis"); + Thesis thesis = thesisRepository.findById(thesisId).orElseThrow(); + User creator = userRepository.findAll().getFirst(); + + ThesisPresentation privatePresentation = new ThesisPresentation(); + privatePresentation.setThesis(thesis); + privatePresentation.setState(ThesisPresentationState.SCHEDULED); + privatePresentation.setType(ThesisPresentationType.FINAL); + privatePresentation.setVisibility(ThesisPresentationVisibility.PRIVATE); + privatePresentation.setLocation("Room 303"); + privatePresentation.setLanguage("ENGLISH"); + privatePresentation.setScheduledAt(Instant.now().plus(7, ChronoUnit.DAYS)); + privatePresentation.setCreatedAt(Instant.now()); + privatePresentation.setCreatedBy(creator); + privatePresentation = thesisPresentationRepository.save(privatePresentation); + + mockMvc.perform(MockMvcRequestBuilders.get("/v2/published-presentations/{id}", privatePresentation.getId()) + .header("Authorization", createRandomAdminAuthentication())) + .andExpect(status().isForbidden()); + } + } +} diff --git a/server/src/test/java/de/tum/cit/aet/thesis/controller/PublishedThesisControllerTest.java b/server/src/test/java/de/tum/cit/aet/thesis/controller/PublishedThesisControllerTest.java new file mode 100644 index 000000000..9f4c9ba73 --- /dev/null +++ b/server/src/test/java/de/tum/cit/aet/thesis/controller/PublishedThesisControllerTest.java @@ -0,0 +1,185 @@ +package de.tum.cit.aet.thesis.controller; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.hamcrest.Matchers.*; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import de.tum.cit.aet.thesis.constants.ThesisState; +import de.tum.cit.aet.thesis.constants.ThesisVisibility; +import de.tum.cit.aet.thesis.entity.Thesis; +import de.tum.cit.aet.thesis.entity.ThesisStateChange; +import de.tum.cit.aet.thesis.entity.key.ThesisStateChangeId; +import de.tum.cit.aet.thesis.mock.BaseIntegrationTest; +import de.tum.cit.aet.thesis.repository.ThesisRepository; +import de.tum.cit.aet.thesis.repository.ThesisStateChangeRepository; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.test.context.DynamicPropertyRegistry; +import org.springframework.test.context.DynamicPropertySource; +import org.springframework.test.web.servlet.request.MockMvcRequestBuilders; +import org.testcontainers.junit.jupiter.Testcontainers; +import tools.jackson.databind.JsonNode; + +import java.time.Instant; +import java.util.UUID; + +@Testcontainers +class PublishedThesisControllerTest extends BaseIntegrationTest { + + @DynamicPropertySource + static void configureDynamicProperties(DynamicPropertyRegistry registry) { + configureProperties(registry); + } + + @Autowired + private ThesisRepository thesisRepository; + + @Autowired + private ThesisStateChangeRepository thesisStateChangeRepository; + + private UUID createFinishedThesis(String title) throws Exception { + UUID thesisId = createTestThesis(title); + + Thesis thesis = thesisRepository.findById(thesisId).orElseThrow(); + + ThesisStateChangeId stateChangeId = new ThesisStateChangeId(); + stateChangeId.setThesisId(thesis.getId()); + stateChangeId.setState(ThesisState.FINISHED); + + ThesisStateChange stateChange = new ThesisStateChange(); + stateChange.setId(stateChangeId); + stateChange.setThesis(thesis); + stateChange.setChangedAt(Instant.now()); + thesisStateChangeRepository.save(stateChange); + + thesis.setState(ThesisState.FINISHED); + thesis.setVisibility(ThesisVisibility.PUBLIC); + thesis.getStates().add(stateChange); + thesisRepository.save(thesis); + + return thesisId; + } + + @Nested + class GetPublishedTheses { + @Test + void getPublishedTheses_EmptyList() throws Exception { + String response = mockMvc.perform(MockMvcRequestBuilders.get("/v2/published-theses") + .header("Authorization", createRandomAdminAuthentication())) + .andExpect(status().isOk()) + .andReturn().getResponse().getContentAsString(); + + JsonNode json = objectMapper.readTree(response); + assertThat(json.get("totalElements").asInt()).isZero(); + } + + @Test + void getPublishedTheses_WithFinishedThesis() throws Exception { + createFinishedThesis("Finished Thesis"); + + mockMvc.perform(MockMvcRequestBuilders.get("/v2/published-theses") + .header("Authorization", createRandomAdminAuthentication())) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.content", hasSize(1))) + .andExpect(jsonPath("$.content[0].title").value("Finished Thesis")); + } + + @Test + void getPublishedTheses_DoesNotIncludeNonFinished() throws Exception { + createTestThesis("Active Thesis"); + + String response = mockMvc.perform(MockMvcRequestBuilders.get("/v2/published-theses") + .header("Authorization", createRandomAdminAuthentication())) + .andExpect(status().isOk()) + .andReturn().getResponse().getContentAsString(); + + JsonNode json = objectMapper.readTree(response); + assertThat(json.get("totalElements").asInt()).isZero(); + } + + @Test + void getPublishedTheses_WithSearch() throws Exception { + createFinishedThesis("Unique Title XYZ"); + createFinishedThesis("Another Thesis"); + + String response = mockMvc.perform(MockMvcRequestBuilders.get("/v2/published-theses") + .header("Authorization", createRandomAdminAuthentication()) + .param("search", "Unique Title XYZ")) + .andExpect(status().isOk()) + .andReturn().getResponse().getContentAsString(); + + JsonNode json = objectMapper.readTree(response); + assertThat(json.get("content").size()).isEqualTo(1); + } + + @Test + void getPublishedTheses_WithTypeFilter() throws Exception { + createFinishedThesis("Master Thesis"); + + mockMvc.perform(MockMvcRequestBuilders.get("/v2/published-theses") + .header("Authorization", createRandomAdminAuthentication()) + .param("types", "MASTER")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.content", hasSize(1))); + + String emptyResponse = mockMvc.perform(MockMvcRequestBuilders.get("/v2/published-theses") + .header("Authorization", createRandomAdminAuthentication()) + .param("types", "BACHELOR")) + .andExpect(status().isOk()) + .andReturn().getResponse().getContentAsString(); + assertThat(objectMapper.readTree(emptyResponse).get("totalElements").asInt()).isZero(); + } + + @Test + void getPublishedTheses_WithPagination() throws Exception { + createFinishedThesis("Thesis A"); + createFinishedThesis("Thesis B"); + createFinishedThesis("Thesis C"); + + mockMvc.perform(MockMvcRequestBuilders.get("/v2/published-theses") + .header("Authorization", createRandomAdminAuthentication()) + .param("page", "0") + .param("limit", "2")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.content", hasSize(2))) + .andExpect(jsonPath("$.totalElements").value(3)); + } + } + + @Nested + class GetThesisFile { + @Test + void getThesisFile_NotFound() throws Exception { + mockMvc.perform(MockMvcRequestBuilders.get("/v2/published-theses/{id}/thesis", UUID.randomUUID()) + .header("Authorization", createRandomAdminAuthentication())) + .andExpect(status().isNotFound()); + } + + @Test + void getThesisFile_NonFinishedThesis_AccessDenied() throws Exception { + UUID thesisId = createTestThesis("Private Thesis"); + + mockMvc.perform(MockMvcRequestBuilders.get("/v2/published-theses/{id}/thesis", thesisId) + .header("Authorization", createRandomAdminAuthentication())) + .andExpect(status().isForbidden()); + } + } + + @Nested + class GetPublishedThesesSorting { + @Test + void getPublishedTheses_SortByAsc() throws Exception { + createFinishedThesis("Sort Thesis A"); + createFinishedThesis("Sort Thesis B"); + + mockMvc.perform(MockMvcRequestBuilders.get("/v2/published-theses") + .header("Authorization", createRandomAdminAuthentication()) + .param("sortOrder", "asc")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.content", hasSize(2))) + .andExpect(jsonPath("$.content[0].thesisId").exists()); + } + } +} diff --git a/server/src/test/java/de/tum/cit/aet/thesis/controller/ResearchGroupControllerTest.java b/server/src/test/java/de/tum/cit/aet/thesis/controller/ResearchGroupControllerTest.java new file mode 100644 index 000000000..84dedec1c --- /dev/null +++ b/server/src/test/java/de/tum/cit/aet/thesis/controller/ResearchGroupControllerTest.java @@ -0,0 +1,757 @@ +package de.tum.cit.aet.thesis.controller; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.hamcrest.Matchers.*; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import de.tum.cit.aet.thesis.controller.payload.CreateResearchGroupPayload; +import de.tum.cit.aet.thesis.mock.BaseIntegrationTest; +import de.tum.cit.aet.thesis.repository.ResearchGroupRepository; +import de.tum.cit.aet.thesis.repository.UserRepository; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.http.MediaType; +import org.springframework.test.context.DynamicPropertyRegistry; +import org.springframework.test.context.DynamicPropertySource; +import org.springframework.test.web.servlet.request.MockMvcRequestBuilders; +import org.testcontainers.junit.jupiter.Testcontainers; +import tools.jackson.databind.JsonNode; + +import java.util.List; +import java.util.UUID; + +@Testcontainers +class ResearchGroupControllerTest extends BaseIntegrationTest { + + @DynamicPropertySource + static void configureDynamicProperties(DynamicPropertyRegistry registry) { + configureProperties(registry); + } + + @Autowired + private ResearchGroupRepository researchGroupRepository; + + @Autowired + private UserRepository userRepository; + + @Nested + class GetResearchGroups { + @Test + void getResearchGroups_AsAdmin_Success() throws Exception { + TestUser head = createRandomTestUser(List.of("supervisor")); + createTestResearchGroup("Test Group", head.universityId()); + + mockMvc.perform(MockMvcRequestBuilders.get("/v2/research-groups") + .header("Authorization", createRandomAdminAuthentication())) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.content", hasSize(greaterThanOrEqualTo(1)))) + .andExpect(jsonPath("$.totalElements", isA(Number.class))); + } + + @Test + void getResearchGroups_WithSearch_FiltersResults() throws Exception { + TestUser head1 = createRandomTestUser(List.of("supervisor")); + createTestResearchGroup("Alpha Group", head1.universityId()); + TestUser head2 = createRandomTestUser(List.of("supervisor")); + createTestResearchGroup("Beta Group", head2.universityId()); + + String response = mockMvc.perform(MockMvcRequestBuilders.get("/v2/research-groups") + .header("Authorization", createRandomAdminAuthentication()) + .param("search", "Alpha")) + .andExpect(status().isOk()) + .andReturn().getResponse().getContentAsString(); + + JsonNode json = objectMapper.readTree(response); + assertThat(json.get("content").size()).isEqualTo(1); + } + + @Test + void getResearchGroups_WithPagination_ReturnsPagedResults() throws Exception { + for (int i = 0; i < 3; i++) { + TestUser head = createRandomTestUser(List.of("supervisor")); + createTestResearchGroup("Group " + i, head.universityId()); + } + + mockMvc.perform(MockMvcRequestBuilders.get("/v2/research-groups") + .header("Authorization", createRandomAdminAuthentication()) + .param("page", "0") + .param("limit", "2")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.content", hasSize(2))) + .andExpect(jsonPath("$.totalPages").value(2)); + } + + @Test + void getResearchGroups_IncludeArchived_ShowsArchivedGroups() throws Exception { + TestUser head = createRandomTestUser(List.of("supervisor")); + UUID groupId = createTestResearchGroup("Archived Group", head.universityId()); + + mockMvc.perform(MockMvcRequestBuilders.patch("/v2/research-groups/{id}/archive", groupId) + .header("Authorization", createRandomAdminAuthentication())) + .andExpect(status().isNoContent()); + + String withoutArchived = mockMvc.perform(MockMvcRequestBuilders.get("/v2/research-groups") + .header("Authorization", createRandomAdminAuthentication()) + .param("includeArchived", "false")) + .andExpect(status().isOk()) + .andReturn().getResponse().getContentAsString(); + + String withArchived = mockMvc.perform(MockMvcRequestBuilders.get("/v2/research-groups") + .header("Authorization", createRandomAdminAuthentication()) + .param("includeArchived", "true")) + .andExpect(status().isOk()) + .andReturn().getResponse().getContentAsString(); + + JsonNode withoutJson = objectMapper.readTree(withoutArchived); + JsonNode withJson = objectMapper.readTree(withArchived); + assertThat(withJson.get("totalElements").asInt()).isGreaterThan(withoutJson.get("totalElements").asInt()); + } + + @Test + void getResearchGroups_AsNonAdmin_ReturnsOwnGroup() throws Exception { + TestUser advisor = createRandomTestUser(List.of("advisor")); + UUID groupId = createTestResearchGroup("Advisor Group", advisor.universityId()); + + mockMvc.perform(MockMvcRequestBuilders.get("/v2/research-groups") + .header("Authorization", generateTestAuthenticationHeader(advisor.universityId(), List.of("advisor")))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.content", hasSize(1))); + } + } + + @Nested + class GetLightResearchGroups { + @Test + void getLightResearchGroups_Success() throws Exception { + TestUser head = createRandomTestUser(List.of("supervisor")); + createTestResearchGroup("Light Group", head.universityId()); + + mockMvc.perform(MockMvcRequestBuilders.get("/v2/research-groups/light") + .header("Authorization", createRandomAdminAuthentication())) + .andExpect(status().isOk()) + .andExpect(jsonPath("$", hasSize(greaterThanOrEqualTo(1)))); + } + + @Test + void getLightResearchGroups_WithSearch_FiltersResults() throws Exception { + TestUser head = createRandomTestUser(List.of("supervisor")); + createTestResearchGroup("Unique Name XYZ", head.universityId()); + + String response = mockMvc.perform(MockMvcRequestBuilders.get("/v2/research-groups/light") + .header("Authorization", createRandomAdminAuthentication()) + .param("search", "Unique Name XYZ")) + .andExpect(status().isOk()) + .andReturn().getResponse().getContentAsString(); + + JsonNode json = objectMapper.readTree(response); + assertThat(json.size()).isEqualTo(1); + } + } + + @Nested + class GetActiveLightResearchGroups { + @Test + void getActiveLightResearchGroups_AsAdmin_ReturnsAll() throws Exception { + TestUser head = createRandomTestUser(List.of("supervisor")); + createTestResearchGroup("Active Group", head.universityId()); + + mockMvc.perform(MockMvcRequestBuilders.get("/v2/research-groups/light/active") + .header("Authorization", createRandomAdminAuthentication())) + .andExpect(status().isOk()) + .andExpect(jsonPath("$", hasSize(greaterThanOrEqualTo(1)))); + } + + @Test + void getActiveLightResearchGroups_AsMember_ReturnsOwnGroup() throws Exception { + TestUser advisor = createRandomTestUser(List.of("advisor")); + createTestResearchGroup("Member Group", advisor.universityId()); + + mockMvc.perform(MockMvcRequestBuilders.get("/v2/research-groups/light/active") + .header("Authorization", generateTestAuthenticationHeader(advisor.universityId(), List.of("advisor")))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$", hasSize(1))); + } + } + + @Nested + class GetResearchGroupById { + @Test + void getResearchGroup_Success() throws Exception { + TestUser head = createRandomTestUser(List.of("supervisor")); + UUID groupId = createTestResearchGroup("Specific Group", head.universityId()); + + mockMvc.perform(MockMvcRequestBuilders.get("/v2/research-groups/{id}", groupId) + .header("Authorization", createRandomAdminAuthentication())) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.id").value(groupId.toString())); + } + + @Test + void getResearchGroup_NotFound() throws Exception { + mockMvc.perform(MockMvcRequestBuilders.get("/v2/research-groups/{id}", UUID.randomUUID()) + .header("Authorization", createRandomAdminAuthentication())) + .andExpect(status().isNotFound()); + } + } + + @Nested + class GetResearchGroupMembers { + @Test + void getMembers_AsAdmin_Success() throws Exception { + TestUser head = createRandomTestUser(List.of("supervisor")); + UUID groupId = createTestResearchGroup("Members Group", head.universityId()); + + mockMvc.perform(MockMvcRequestBuilders.get("/v2/research-groups/{id}/members", groupId) + .header("Authorization", createRandomAdminAuthentication())) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.content", isA(List.class))); + } + + @Test + void getMembers_AsStudent_Forbidden() throws Exception { + TestUser head = createRandomTestUser(List.of("supervisor")); + UUID groupId = createTestResearchGroup("Forbidden Group", head.universityId()); + + mockMvc.perform(MockMvcRequestBuilders.get("/v2/research-groups/{id}/members", groupId) + .header("Authorization", createRandomAuthentication("student"))) + .andExpect(status().isForbidden()); + } + } + + @Nested + class CreateResearchGroup { + @Test + void createResearchGroup_AsAdmin_Success() throws Exception { + TestUser head = createRandomTestUser(List.of("student")); + + CreateResearchGroupPayload payload = new CreateResearchGroupPayload( + head.universityId(), "New Research Group " + UUID.randomUUID(), + "NRG-" + UUID.randomUUID().toString().substring(0, 6), + "Garching", "A test description", "https://example.com" + ); + + String response = mockMvc.perform(MockMvcRequestBuilders.post("/v2/research-groups") + .header("Authorization", createRandomAdminAuthentication()) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(payload))) + .andExpect(status().isOk()) + .andReturn().getResponse().getContentAsString(); + + JsonNode json = objectMapper.readTree(response); + assertThat(json.get("name").asString()).contains("New Research Group"); + assertThat(json.get("id").asString()).isNotBlank(); + } + + @Test + void createResearchGroup_AsStudent_Forbidden() throws Exception { + TestUser head = createRandomTestUser(List.of("student")); + + CreateResearchGroupPayload payload = new CreateResearchGroupPayload( + head.universityId(), "Forbidden Group", "FG", + null, null, null + ); + + mockMvc.perform(MockMvcRequestBuilders.post("/v2/research-groups") + .header("Authorization", createRandomAuthentication("student")) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(payload))) + .andExpect(status().isForbidden()); + } + + @Test + void createResearchGroup_DuplicateHead_ReturnsError() throws Exception { + TestUser head = createRandomTestUser(List.of("supervisor")); + createTestResearchGroup("First Group", head.universityId()); + + CreateResearchGroupPayload payload = new CreateResearchGroupPayload( + head.universityId(), "Second Group " + UUID.randomUUID(), + "SG-" + UUID.randomUUID().toString().substring(0, 6), + null, null, null + ); + + mockMvc.perform(MockMvcRequestBuilders.post("/v2/research-groups") + .header("Authorization", createRandomAdminAuthentication()) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(payload))) + .andExpect(status().isForbidden()); + } + } + + @Nested + class UpdateResearchGroup { + @Test + void updateResearchGroup_Success() throws Exception { + TestUser head = createRandomTestUser(List.of("supervisor")); + UUID groupId = createTestResearchGroup("Original Group", head.universityId()); + + CreateResearchGroupPayload payload = new CreateResearchGroupPayload( + head.universityId(), "Updated Group " + UUID.randomUUID(), + UUID.randomUUID().toString(), null, "Updated description", null + ); + + String response = mockMvc.perform(MockMvcRequestBuilders.put("/v2/research-groups/{id}", groupId) + .header("Authorization", createRandomAdminAuthentication()) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(payload))) + .andExpect(status().isOk()) + .andReturn().getResponse().getContentAsString(); + + JsonNode json = objectMapper.readTree(response); + assertThat(json.get("name").asString()).contains("Updated Group"); + assertThat(json.get("description").asString()).isEqualTo("Updated description"); + } + + @Test + void updateResearchGroup_ChangeHead_Success() throws Exception { + TestUser oldHead = createRandomTestUser(List.of("supervisor")); + UUID groupId = createTestResearchGroup("Head Change Group", oldHead.universityId()); + + // Create new head user + TestUser newHead = createRandomTestUser(List.of("supervisor")); + + CreateResearchGroupPayload payload = new CreateResearchGroupPayload( + newHead.universityId(), "Head Change Group " + UUID.randomUUID(), + UUID.randomUUID().toString(), null, "Changed head", null + ); + + String response = mockMvc.perform(MockMvcRequestBuilders.put("/v2/research-groups/{id}", groupId) + .header("Authorization", createRandomAdminAuthentication()) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(payload))) + .andExpect(status().isOk()) + .andReturn().getResponse().getContentAsString(); + + JsonNode json = objectMapper.readTree(response); + assertThat(json.get("head").get("universityId").asString()).isEqualTo(newHead.universityId()); + } + + @Test + void updateResearchGroup_Archived_ReturnsForbidden() throws Exception { + TestUser head = createRandomTestUser(List.of("supervisor")); + UUID groupId = createTestResearchGroup("To Archive", head.universityId()); + + mockMvc.perform(MockMvcRequestBuilders.patch("/v2/research-groups/{id}/archive", groupId) + .header("Authorization", createRandomAdminAuthentication())) + .andExpect(status().isNoContent()); + + CreateResearchGroupPayload payload = new CreateResearchGroupPayload( + head.universityId(), "Trying Update", UUID.randomUUID().toString(), + null, null, null + ); + + mockMvc.perform(MockMvcRequestBuilders.put("/v2/research-groups/{id}", groupId) + .header("Authorization", createRandomAdminAuthentication()) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(payload))) + .andExpect(status().isForbidden()); + } + } + + @Nested + class ArchiveResearchGroup { + @Test + void archiveResearchGroup_AsAdmin_Success() throws Exception { + TestUser head = createRandomTestUser(List.of("supervisor")); + UUID groupId = createTestResearchGroup("To Archive Group", head.universityId()); + + mockMvc.perform(MockMvcRequestBuilders.patch("/v2/research-groups/{id}/archive", groupId) + .header("Authorization", createRandomAdminAuthentication())) + .andExpect(status().isNoContent()); + + assertThat(researchGroupRepository.findById(groupId).get().isArchived()).isTrue(); + } + + @Test + void archiveResearchGroup_AsStudent_Forbidden() throws Exception { + TestUser head = createRandomTestUser(List.of("supervisor")); + UUID groupId = createTestResearchGroup("Student Archive", head.universityId()); + + mockMvc.perform(MockMvcRequestBuilders.patch("/v2/research-groups/{id}/archive", groupId) + .header("Authorization", createRandomAuthentication("student"))) + .andExpect(status().isForbidden()); + } + } + + @Nested + class AssignUserToResearchGroup { + @Test + void assignUser_Success() throws Exception { + TestUser head = createRandomTestUser(List.of("supervisor")); + UUID groupId = createTestResearchGroup("Assign Group", head.universityId()); + TestUser newUser = createRandomTestUser(List.of("student")); + + mockMvc.perform(MockMvcRequestBuilders.put("/v2/research-groups/{id}/assign/{username}", groupId, newUser.universityId()) + .header("Authorization", createRandomAdminAuthentication())) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.universityId").value(newUser.universityId())); + } + + @Test + void assignUser_AlreadyAssigned_ReturnsForbidden() throws Exception { + TestUser head = createRandomTestUser(List.of("supervisor")); + UUID groupId = createTestResearchGroup("Duplicate Assign", head.universityId()); + TestUser newUser = createRandomTestUser(List.of("student")); + + mockMvc.perform(MockMvcRequestBuilders.put("/v2/research-groups/{id}/assign/{username}", groupId, newUser.universityId()) + .header("Authorization", createRandomAdminAuthentication())) + .andExpect(status().isOk()); + + TestUser head2 = createRandomTestUser(List.of("supervisor")); + UUID groupId2 = createTestResearchGroup("Another Group", head2.universityId()); + + mockMvc.perform(MockMvcRequestBuilders.put("/v2/research-groups/{id}/assign/{username}", groupId2, newUser.universityId()) + .header("Authorization", createRandomAdminAuthentication())) + .andExpect(status().isForbidden()); + } + + @Test + void assignUser_ArchivedGroup_ReturnsForbidden() throws Exception { + TestUser head = createRandomTestUser(List.of("supervisor")); + UUID groupId = createTestResearchGroup("Archived Assign", head.universityId()); + + mockMvc.perform(MockMvcRequestBuilders.patch("/v2/research-groups/{id}/archive", groupId) + .header("Authorization", createRandomAdminAuthentication())) + .andExpect(status().isNoContent()); + + TestUser newUser = createRandomTestUser(List.of("student")); + + mockMvc.perform(MockMvcRequestBuilders.put("/v2/research-groups/{id}/assign/{username}", groupId, newUser.universityId()) + .header("Authorization", createRandomAdminAuthentication())) + .andExpect(status().isForbidden()); + } + } + + @Nested + class RemoveUserFromResearchGroup { + @Test + void removeUser_Success() throws Exception { + TestUser head = createRandomTestUser(List.of("supervisor")); + UUID groupId = createTestResearchGroup("Remove Group", head.universityId()); + TestUser member = createRandomTestUser(List.of("student")); + + mockMvc.perform(MockMvcRequestBuilders.put("/v2/research-groups/{id}/assign/{username}", groupId, member.universityId()) + .header("Authorization", createRandomAdminAuthentication())) + .andExpect(status().isOk()); + + mockMvc.perform(MockMvcRequestBuilders.put("/v2/research-groups/{id}/remove/{userId}", groupId, member.userId()) + .header("Authorization", createRandomAdminAuthentication())) + .andExpect(status().isOk()); + } + + @Test + void removeHead_ReturnsForbidden() throws Exception { + TestUser head = createRandomTestUser(List.of("supervisor")); + UUID groupId = createTestResearchGroup("Head Remove", head.universityId()); + + mockMvc.perform(MockMvcRequestBuilders.put("/v2/research-groups/{id}/remove/{userId}", groupId, head.userId()) + .header("Authorization", createRandomAdminAuthentication())) + .andExpect(status().isForbidden()); + } + } + + @Nested + class UpdateMemberRole { + @Test + void updateRole_ToAdvisor_Success() throws Exception { + TestUser head = createRandomTestUser(List.of("supervisor")); + UUID groupId = createTestResearchGroup("Role Group", head.universityId()); + TestUser member = createRandomTestUser(List.of("student")); + + mockMvc.perform(MockMvcRequestBuilders.put("/v2/research-groups/{id}/assign/{username}", groupId, member.universityId()) + .header("Authorization", createRandomAdminAuthentication())) + .andExpect(status().isOk()); + + mockMvc.perform(MockMvcRequestBuilders.put("/v2/research-groups/{id}/member/{userId}/role", groupId, member.userId()) + .header("Authorization", createRandomAdminAuthentication()) + .param("role", "advisor")) + .andExpect(status().isOk()); + } + + @Test + void updateRole_ToSupervisor_Success() throws Exception { + TestUser head = createRandomTestUser(List.of("supervisor")); + UUID groupId = createTestResearchGroup("Supervisor Role Group", head.universityId()); + TestUser member = createRandomTestUser(List.of("student")); + + mockMvc.perform(MockMvcRequestBuilders.put("/v2/research-groups/{id}/assign/{username}", groupId, member.universityId()) + .header("Authorization", createRandomAdminAuthentication())) + .andExpect(status().isOk()); + + mockMvc.perform(MockMvcRequestBuilders.put("/v2/research-groups/{id}/member/{userId}/role", groupId, member.userId()) + .header("Authorization", createRandomAdminAuthentication()) + .param("role", "supervisor")) + .andExpect(status().isOk()); + } + + @Test + void updateRole_InvalidRole_ThrowsException() throws Exception { + TestUser head = createRandomTestUser(List.of("supervisor")); + UUID groupId = createTestResearchGroup("Invalid Role Group", head.universityId()); + TestUser member = createRandomTestUser(List.of("student")); + + mockMvc.perform(MockMvcRequestBuilders.put("/v2/research-groups/{id}/assign/{username}", groupId, member.universityId()) + .header("Authorization", createRandomAdminAuthentication())) + .andExpect(status().isOk()); + + String adminAuth = createRandomAdminAuthentication(); + assertThatThrownBy(() -> + mockMvc.perform(MockMvcRequestBuilders.put("/v2/research-groups/{id}/member/{userId}/role", groupId, member.userId()) + .header("Authorization", adminAuth) + .param("role", "invalid")) + ).hasCauseInstanceOf(IllegalArgumentException.class); + } + } + + @Nested + class ToggleGroupAdmin { + @Test + void toggleGroupAdmin_AddRole_Success() throws Exception { + TestUser head = createRandomTestUser(List.of("supervisor")); + UUID groupId = createTestResearchGroup("Admin Toggle Group", head.universityId()); + TestUser member = createRandomTestUser(List.of("student")); + + mockMvc.perform(MockMvcRequestBuilders.put("/v2/research-groups/{id}/assign/{username}", groupId, member.universityId()) + .header("Authorization", createRandomAdminAuthentication())) + .andExpect(status().isOk()); + + mockMvc.perform(MockMvcRequestBuilders.put("/v2/research-groups/{id}/member/{userId}/group-admin", groupId, member.userId()) + .header("Authorization", createRandomAdminAuthentication())) + .andExpect(status().isOk()); + } + + @Test + void toggleGroupAdmin_RemoveRole_Success() throws Exception { + TestUser head = createRandomTestUser(List.of("supervisor")); + UUID groupId = createTestResearchGroup("Admin Remove Toggle Group", head.universityId()); + TestUser member = createRandomTestUser(List.of("student")); + + mockMvc.perform(MockMvcRequestBuilders.put("/v2/research-groups/{id}/assign/{username}", groupId, member.universityId()) + .header("Authorization", createRandomAdminAuthentication())) + .andExpect(status().isOk()); + + // Add group-admin role + mockMvc.perform(MockMvcRequestBuilders.put("/v2/research-groups/{id}/member/{userId}/group-admin", groupId, member.userId()) + .header("Authorization", createRandomAdminAuthentication())) + .andExpect(status().isOk()); + + // Toggle again to remove it + mockMvc.perform(MockMvcRequestBuilders.put("/v2/research-groups/{id}/member/{userId}/group-admin", groupId, member.userId()) + .header("Authorization", createRandomAdminAuthentication())) + .andExpect(status().isOk()); + } + } + + @Nested + class RemoveUserEdgeCases { + @Test + void removeUser_FromArchivedGroup_ReturnsForbidden() throws Exception { + TestUser head = createRandomTestUser(List.of("supervisor")); + UUID groupId = createTestResearchGroup("Archived Remove Group", head.universityId()); + TestUser member = createRandomTestUser(List.of("student")); + + mockMvc.perform(MockMvcRequestBuilders.put("/v2/research-groups/{id}/assign/{username}", groupId, member.universityId()) + .header("Authorization", createRandomAdminAuthentication())) + .andExpect(status().isOk()); + + mockMvc.perform(MockMvcRequestBuilders.patch("/v2/research-groups/{id}/archive", groupId) + .header("Authorization", createRandomAdminAuthentication())) + .andExpect(status().isNoContent()); + + mockMvc.perform(MockMvcRequestBuilders.put("/v2/research-groups/{id}/remove/{userId}", groupId, member.userId()) + .header("Authorization", createRandomAdminAuthentication())) + .andExpect(status().isForbidden()); + } + } + + @Nested + class UpdateRoleEdgeCases { + @Test + void updateRole_ArchivedGroup_ReturnsForbidden() throws Exception { + TestUser head = createRandomTestUser(List.of("supervisor")); + UUID groupId = createTestResearchGroup("Archived Role Group", head.universityId()); + TestUser member = createRandomTestUser(List.of("student")); + + mockMvc.perform(MockMvcRequestBuilders.put("/v2/research-groups/{id}/assign/{username}", groupId, member.universityId()) + .header("Authorization", createRandomAdminAuthentication())) + .andExpect(status().isOk()); + + mockMvc.perform(MockMvcRequestBuilders.patch("/v2/research-groups/{id}/archive", groupId) + .header("Authorization", createRandomAdminAuthentication())) + .andExpect(status().isNoContent()); + + mockMvc.perform(MockMvcRequestBuilders.put("/v2/research-groups/{id}/member/{userId}/role", groupId, member.userId()) + .header("Authorization", createRandomAdminAuthentication()) + .param("role", "supervisor")) + .andExpect(status().isForbidden()); + } + } + + @Nested + class UpdateResearchGroupEdgeCases { + @Test + void updateResearchGroup_ArchivedGroup_ReturnsForbidden() throws Exception { + TestUser head = createRandomTestUser(List.of("supervisor")); + UUID groupId = createTestResearchGroup("Archived Update Group", head.universityId()); + + mockMvc.perform(MockMvcRequestBuilders.patch("/v2/research-groups/{id}/archive", groupId) + .header("Authorization", createRandomAdminAuthentication())) + .andExpect(status().isNoContent()); + + String adminAuth = createRandomAdminAuthentication(); + mockMvc.perform(MockMvcRequestBuilders.put("/v2/research-groups/{id}", groupId) + .header("Authorization", adminAuth) + .contentType(MediaType.APPLICATION_JSON) + .content("{\"name\": \"New Name\", \"headUsername\": \"" + head.universityId() + "\"}")) + .andExpect(status().isForbidden()); + } + + @Test + void assignUser_ToArchivedGroup_ReturnsForbidden() throws Exception { + TestUser head = createRandomTestUser(List.of("supervisor")); + UUID groupId = createTestResearchGroup("Archived Assign Group", head.universityId()); + TestUser member = createRandomTestUser(List.of("student")); + + mockMvc.perform(MockMvcRequestBuilders.patch("/v2/research-groups/{id}/archive", groupId) + .header("Authorization", createRandomAdminAuthentication())) + .andExpect(status().isNoContent()); + + mockMvc.perform(MockMvcRequestBuilders.put("/v2/research-groups/{id}/assign/{username}", groupId, member.universityId()) + .header("Authorization", createRandomAdminAuthentication())) + .andExpect(status().isForbidden()); + } + } + + @Nested + class ResearchGroupSearchFilters { + @Test + void getResearchGroups_WithSearchQuery_FiltersResults() throws Exception { + TestUser head = createRandomTestUser(List.of("supervisor")); + createTestResearchGroup("UniqueSearchXyz Group", head.universityId()); + TestUser head2 = createRandomTestUser(List.of("supervisor")); + createTestResearchGroup("Other Group", head2.universityId()); + + String response = mockMvc.perform(MockMvcRequestBuilders.get("/v2/research-groups") + .header("Authorization", createRandomAdminAuthentication()) + .param("search", "UniqueSearchXyz")) + .andExpect(status().isOk()) + .andReturn().getResponse().getContentAsString(); + + JsonNode json = objectMapper.readTree(response); + assertThat(json.get("content").size()).as("Only the matching group should be returned").isEqualTo(1); + assertThat(json.get("content").get(0).get("name").asString()).contains("UniqueSearchXyz"); + } + + @Test + void getLightResearchGroups_WithSearchQuery_FiltersResults() throws Exception { + TestUser head = createRandomTestUser(List.of("supervisor")); + createTestResearchGroup("UniqueLightXyz Group", head.universityId()); + TestUser head2 = createRandomTestUser(List.of("supervisor")); + createTestResearchGroup("Different Group", head2.universityId()); + + String response = mockMvc.perform(MockMvcRequestBuilders.get("/v2/research-groups/light") + .header("Authorization", createRandomAdminAuthentication()) + .param("search", "UniqueLightXyz")) + .andExpect(status().isOk()) + .andReturn().getResponse().getContentAsString(); + + JsonNode json = objectMapper.readTree(response); + assertThat(json.size()).as("Only the matching group should be returned").isEqualTo(1); + assertThat(json.get(0).get("name").asString()).contains("UniqueLightXyz"); + } + } + + @Nested + class MembersSorting { + @Test + void getMembers_SortByFirstName_VerifiesOrder() throws Exception { + TestUser head = createRandomTestUser(List.of("supervisor")); + UUID groupId = createTestResearchGroup("Member Sort Group", head.universityId()); + + // Add a second member so we can verify sort order + TestUser member = createRandomTestUser(List.of("advisor")); + mockMvc.perform(MockMvcRequestBuilders.put("/v2/research-groups/{id}/assign/{username}", groupId, member.universityId()) + .header("Authorization", createRandomAdminAuthentication())) + .andExpect(status().isOk()); + + String ascResponse = mockMvc.perform(MockMvcRequestBuilders.get("/v2/research-groups/{id}/members", groupId) + .header("Authorization", createRandomAdminAuthentication()) + .param("sortBy", "firstName") + .param("sortOrder", "asc")) + .andExpect(status().isOk()) + .andReturn().getResponse().getContentAsString(); + + JsonNode ascContent = objectMapper.readTree(ascResponse).get("content"); + assertThat(ascContent.size()).isGreaterThanOrEqualTo(2); + for (int i = 1; i < ascContent.size(); i++) { + String prev = ascContent.get(i - 1).get("firstName").asString(); + String curr = ascContent.get(i).get("firstName").asString(); + assertThat(prev.compareToIgnoreCase(curr)).as("Members should be sorted ascending by firstName").isLessThanOrEqualTo(0); + } + + String descResponse = mockMvc.perform(MockMvcRequestBuilders.get("/v2/research-groups/{id}/members", groupId) + .header("Authorization", createRandomAdminAuthentication()) + .param("sortBy", "firstName") + .param("sortOrder", "desc")) + .andExpect(status().isOk()) + .andReturn().getResponse().getContentAsString(); + + JsonNode descContent = objectMapper.readTree(descResponse).get("content"); + assertThat(descContent.size()).isGreaterThanOrEqualTo(2); + for (int i = 1; i < descContent.size(); i++) { + String prev = descContent.get(i - 1).get("firstName").asString(); + String curr = descContent.get(i).get("firstName").asString(); + assertThat(prev.compareToIgnoreCase(curr)).as("Members should be sorted descending by firstName").isGreaterThanOrEqualTo(0); + } + } + + @Test + void getResearchGroups_SortOrder_VerifiesOrder() throws Exception { + TestUser head1 = createRandomTestUser(List.of("supervisor")); + TestUser head2 = createRandomTestUser(List.of("supervisor")); + createTestResearchGroup("AAA Sort Group", head1.universityId()); + createTestResearchGroup("ZZZ Sort Group", head2.universityId()); + + String ascResponse = mockMvc.perform(MockMvcRequestBuilders.get("/v2/research-groups") + .header("Authorization", createRandomAdminAuthentication()) + .param("sortBy", "name") + .param("sortOrder", "asc")) + .andExpect(status().isOk()) + .andReturn().getResponse().getContentAsString(); + + JsonNode ascContent = objectMapper.readTree(ascResponse).get("content"); + assertThat(ascContent.size()).isGreaterThanOrEqualTo(2); + String firstName = ascContent.get(0).get("name").asString(); + String lastName = ascContent.get(ascContent.size() - 1).get("name").asString(); + assertThat(firstName.compareToIgnoreCase(lastName)).as("First item should come before last in asc order").isLessThanOrEqualTo(0); + + String descResponse = mockMvc.perform(MockMvcRequestBuilders.get("/v2/research-groups") + .header("Authorization", createRandomAdminAuthentication()) + .param("sortBy", "name") + .param("sortOrder", "desc")) + .andExpect(status().isOk()) + .andReturn().getResponse().getContentAsString(); + + JsonNode descContent = objectMapper.readTree(descResponse).get("content"); + assertThat(descContent.size()).isGreaterThanOrEqualTo(2); + String firstDesc = descContent.get(0).get("name").asString(); + String lastDesc = descContent.get(descContent.size() - 1).get("name").asString(); + assertThat(firstDesc.compareToIgnoreCase(lastDesc)).as("First item should come after last in desc order").isGreaterThanOrEqualTo(0); + } + + @Test + void getResearchGroups_WithLimitMinusOne_ReturnsAllResults() throws Exception { + TestUser head = createRandomTestUser(List.of("supervisor")); + createTestResearchGroup("Unlimited RG", head.universityId()); + + mockMvc.perform(MockMvcRequestBuilders.get("/v2/research-groups") + .header("Authorization", createRandomAdminAuthentication()) + .param("limit", "-1")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.content").isArray()) + .andExpect(jsonPath("$.content", hasSize(greaterThanOrEqualTo(1)))); + } + } +} diff --git a/server/src/test/java/de/tum/cit/aet/thesis/controller/ResearchGroupSettingsControllerTest.java b/server/src/test/java/de/tum/cit/aet/thesis/controller/ResearchGroupSettingsControllerTest.java new file mode 100644 index 000000000..6707b0306 --- /dev/null +++ b/server/src/test/java/de/tum/cit/aet/thesis/controller/ResearchGroupSettingsControllerTest.java @@ -0,0 +1,199 @@ +package de.tum.cit.aet.thesis.controller; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import de.tum.cit.aet.thesis.mock.BaseIntegrationTest; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.http.MediaType; +import org.springframework.test.context.DynamicPropertyRegistry; +import org.springframework.test.context.DynamicPropertySource; +import org.springframework.test.web.servlet.request.MockMvcRequestBuilders; +import org.testcontainers.junit.jupiter.Testcontainers; +import tools.jackson.databind.JsonNode; + +import java.util.List; +import java.util.Map; +import java.util.UUID; + +@Testcontainers +class ResearchGroupSettingsControllerTest extends BaseIntegrationTest { + + @DynamicPropertySource + static void configureDynamicProperties(DynamicPropertyRegistry registry) { + configureProperties(registry); + } + + @Nested + class GetSettings { + @Test + void getSettings_ReturnsDefaults_WhenNoSettingsExist() throws Exception { + TestUser head = createRandomTestUser(List.of("supervisor")); + UUID groupId = createTestResearchGroup("Settings Default Group", head.universityId()); + + String response = mockMvc.perform(MockMvcRequestBuilders.get("/v2/research-group-settings/{id}", groupId) + .header("Authorization", createRandomAdminAuthentication())) + .andExpect(status().isOk()) + .andReturn().getResponse().getContentAsString(); + + JsonNode json = objectMapper.readTree(response); + assertThat(json.has("rejectSettings")).isTrue(); + assertThat(json.has("phaseSettings")).isTrue(); + } + + @Test + void getSettings_ReturnsSavedSettings() throws Exception { + TestUser head = createRandomTestUser(List.of("supervisor")); + UUID groupId = createTestResearchGroup("Settings Saved Group", head.universityId()); + + String createPayload = objectMapper.writeValueAsString(Map.of( + "rejectSettings", Map.of("automaticRejectEnabled", true, "rejectDuration", 30), + "presentationSettings", Map.of("presentationSlotDuration", 45), + "phaseSettings", Map.of("proposalPhaseActive", false) + )); + + mockMvc.perform(MockMvcRequestBuilders.post("/v2/research-group-settings/{id}", groupId) + .header("Authorization", createRandomAdminAuthentication()) + .contentType(MediaType.APPLICATION_JSON) + .content(createPayload)) + .andExpect(status().isOk()); + + String response = mockMvc.perform(MockMvcRequestBuilders.get("/v2/research-group-settings/{id}", groupId) + .header("Authorization", createRandomAdminAuthentication())) + .andExpect(status().isOk()) + .andReturn().getResponse().getContentAsString(); + + JsonNode json = objectMapper.readTree(response); + assertThat(json.get("rejectSettings").get("automaticRejectEnabled").asBoolean()).isTrue(); + assertThat(json.get("rejectSettings").get("rejectDuration").asInt()).isEqualTo(30); + assertThat(json.get("presentationSettings").get("presentationSlotDuration").asInt()).isEqualTo(45); + assertThat(json.get("phaseSettings").get("proposalPhaseActive").asBoolean()).isFalse(); + } + + @Test + void getSettings_AsStudent_Forbidden() throws Exception { + TestUser head = createRandomTestUser(List.of("supervisor")); + UUID groupId = createTestResearchGroup("Forbidden Settings", head.universityId()); + + mockMvc.perform(MockMvcRequestBuilders.get("/v2/research-group-settings/{id}", groupId) + .header("Authorization", createRandomAuthentication("student"))) + .andExpect(status().isForbidden()); + } + } + + @Nested + class CreateOrUpdateSettings { + @Test + void createSettings_WithAllOptions_Success() throws Exception { + TestUser head = createRandomTestUser(List.of("supervisor")); + UUID groupId = createTestResearchGroup("Full Settings Group", head.universityId()); + + String payload = objectMapper.writeValueAsString(Map.of( + "rejectSettings", Map.of("automaticRejectEnabled", true, "rejectDuration", 14), + "presentationSettings", Map.of("presentationSlotDuration", 60), + "phaseSettings", Map.of("proposalPhaseActive", true), + "emailSettings", Map.of("applicationNotificationEmail", "notify@test.com") + )); + + String response = mockMvc.perform(MockMvcRequestBuilders.post("/v2/research-group-settings/{id}", groupId) + .header("Authorization", createRandomAdminAuthentication()) + .contentType(MediaType.APPLICATION_JSON) + .content(payload)) + .andExpect(status().isOk()) + .andReturn().getResponse().getContentAsString(); + + JsonNode json = objectMapper.readTree(response); + assertThat(json.get("rejectSettings").get("automaticRejectEnabled").asBoolean()).isTrue(); + assertThat(json.get("rejectSettings").get("rejectDuration").asInt()).isEqualTo(14); + assertThat(json.get("emailSettings").get("applicationNotificationEmail").asString()).isEqualTo("notify@test.com"); + } + + @Test + void updateSettings_PartialUpdate_Success() throws Exception { + TestUser head = createRandomTestUser(List.of("supervisor")); + UUID groupId = createTestResearchGroup("Partial Update Group", head.universityId()); + + String createPayload = objectMapper.writeValueAsString(Map.of( + "rejectSettings", Map.of("automaticRejectEnabled", false, "rejectDuration", 7) + )); + + mockMvc.perform(MockMvcRequestBuilders.post("/v2/research-group-settings/{id}", groupId) + .header("Authorization", createRandomAdminAuthentication()) + .contentType(MediaType.APPLICATION_JSON) + .content(createPayload)) + .andExpect(status().isOk()); + + String updatePayload = objectMapper.writeValueAsString(Map.of( + "presentationSettings", Map.of("presentationSlotDuration", 90) + )); + + String response = mockMvc.perform(MockMvcRequestBuilders.post("/v2/research-group-settings/{id}", groupId) + .header("Authorization", createRandomAdminAuthentication()) + .contentType(MediaType.APPLICATION_JSON) + .content(updatePayload)) + .andExpect(status().isOk()) + .andReturn().getResponse().getContentAsString(); + + JsonNode json = objectMapper.readTree(response); + assertThat(json.get("presentationSettings").get("presentationSlotDuration").asInt()).isEqualTo(90); + } + + @Test + void createSettings_InvalidEmail_ReturnsBadRequest() throws Exception { + TestUser head = createRandomTestUser(List.of("supervisor")); + UUID groupId = createTestResearchGroup("Invalid Email Group", head.universityId()); + + String payload = objectMapper.writeValueAsString(Map.of( + "emailSettings", Map.of("applicationNotificationEmail", "not-an-email") + )); + + mockMvc.perform(MockMvcRequestBuilders.post("/v2/research-group-settings/{id}", groupId) + .header("Authorization", createRandomAdminAuthentication()) + .contentType(MediaType.APPLICATION_JSON) + .content(payload)) + .andExpect(status().isBadRequest()); + } + } + + @Nested + class GetPhaseSettings { + @Test + void getPhaseSettings_ReturnsDefaults() throws Exception { + TestUser head = createRandomTestUser(List.of("supervisor")); + UUID groupId = createTestResearchGroup("Phase Settings Group", head.universityId()); + + String response = mockMvc.perform(MockMvcRequestBuilders.get("/v2/research-group-settings/{id}/phase-settings", groupId) + .header("Authorization", createRandomAdminAuthentication())) + .andExpect(status().isOk()) + .andReturn().getResponse().getContentAsString(); + + JsonNode json = objectMapper.readTree(response); + assertThat(json.has("proposalPhaseActive")).isTrue(); + } + + @Test + void getPhaseSettings_ReturnsSavedPhaseSettings() throws Exception { + TestUser head = createRandomTestUser(List.of("supervisor")); + UUID groupId = createTestResearchGroup("Saved Phase Group", head.universityId()); + + String payload = objectMapper.writeValueAsString(Map.of( + "phaseSettings", Map.of("proposalPhaseActive", false) + )); + + mockMvc.perform(MockMvcRequestBuilders.post("/v2/research-group-settings/{id}", groupId) + .header("Authorization", createRandomAdminAuthentication()) + .contentType(MediaType.APPLICATION_JSON) + .content(payload)) + .andExpect(status().isOk()); + + String response = mockMvc.perform(MockMvcRequestBuilders.get("/v2/research-group-settings/{id}/phase-settings", groupId) + .header("Authorization", createRandomAdminAuthentication())) + .andExpect(status().isOk()) + .andReturn().getResponse().getContentAsString(); + + JsonNode json = objectMapper.readTree(response); + assertThat(json.get("proposalPhaseActive").asBoolean()).isFalse(); + } + } +} diff --git a/server/src/test/java/de/tum/cit/aet/thesis/controller/ThesisControllerAdditionalTest.java b/server/src/test/java/de/tum/cit/aet/thesis/controller/ThesisControllerAdditionalTest.java index 0743ec9c6..792cf4bd0 100644 --- a/server/src/test/java/de/tum/cit/aet/thesis/controller/ThesisControllerAdditionalTest.java +++ b/server/src/test/java/de/tum/cit/aet/thesis/controller/ThesisControllerAdditionalTest.java @@ -3,7 +3,6 @@ import static org.assertj.core.api.Assertions.assertThat; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; -import com.fasterxml.jackson.databind.JsonNode; import de.tum.cit.aet.thesis.constants.ThesisCommentType; import de.tum.cit.aet.thesis.constants.ThesisFeedbackType; import de.tum.cit.aet.thesis.constants.ThesisPresentationType; @@ -17,6 +16,8 @@ import de.tum.cit.aet.thesis.controller.payload.RequestChangesPayload; import de.tum.cit.aet.thesis.controller.payload.SchedulePresentationPayload; import de.tum.cit.aet.thesis.controller.payload.UpdateNotePayload; +import de.tum.cit.aet.thesis.controller.payload.UpdateThesisCreditsPayload; +import de.tum.cit.aet.thesis.controller.payload.UpdateThesisInfoPayload; import de.tum.cit.aet.thesis.entity.Thesis; import de.tum.cit.aet.thesis.mock.BaseIntegrationTest; import de.tum.cit.aet.thesis.repository.ThesisAssessmentRepository; @@ -32,9 +33,11 @@ import org.springframework.test.context.DynamicPropertySource; import org.springframework.test.web.servlet.request.MockMvcRequestBuilders; import org.testcontainers.junit.jupiter.Testcontainers; +import tools.jackson.databind.JsonNode; import java.time.Instant; import java.util.List; +import java.util.Map; import java.util.UUID; @Testcontainers @@ -74,7 +77,7 @@ private UUID createDraftedPresentation(UUID thesisId, String adminAuth) throws E .andReturn().getResponse().getContentAsString(); return UUID.fromString(objectMapper.readTree(response) - .get("presentations").get(0).get("presentationId").asText()); + .get("presentations").get(0).get("presentationId").asString()); } private UUID createScheduledPresentation(UUID thesisId, String adminAuth) throws Exception { @@ -224,7 +227,7 @@ private UUID createUniqueThesis(String title) throws Exception { .andExpect(status().isOk()) .andReturn().getResponse().getContentAsString(); - return UUID.fromString(objectMapper.readTree(response).get("thesisId").asText()); + return UUID.fromString(objectMapper.readTree(response).get("thesisId").asString()); } @Test @@ -261,7 +264,7 @@ void getTheses_SearchByTitle() throws Exception { JsonNode json = objectMapper.readTree(response); assertThat(json.get("content").size()).isEqualTo(1); - assertThat(json.get("content").get(0).get("title").asText()).isEqualTo("UniqueSearchableTitle"); + assertThat(json.get("content").get(0).get("title").asString()).isEqualTo("UniqueSearchableTitle"); } @Test @@ -277,7 +280,7 @@ void getTheses_FilterByType() throws Exception { JsonNode json = objectMapper.readTree(response); assertThat(json.get("content").size()).isEqualTo(1); - assertThat(json.get("content").get(0).get("type").asText()).isEqualTo("MASTER"); + assertThat(json.get("content").get(0).get("type").asString()).isEqualTo("MASTER"); String emptyResponse = mockMvc.perform(MockMvcRequestBuilders.get("/v2/theses") .header("Authorization", createRandomAdminAuthentication()) @@ -305,8 +308,8 @@ void getTheses_MultipleFilters() throws Exception { JsonNode json = objectMapper.readTree(response); assertThat(json.get("content").size()).isGreaterThanOrEqualTo(1); for (JsonNode thesis : json.get("content")) { - assertThat(thesis.get("state").asText()).isEqualTo("PROPOSAL"); - assertThat(thesis.get("type").asText()).isEqualTo("MASTER"); + assertThat(thesis.get("state").asString()).isEqualTo("PROPOSAL"); + assertThat(thesis.get("type").asString()).isEqualTo("MASTER"); } } @@ -388,7 +391,7 @@ void getTheses_WithPresentation_IncludesPresentationOverview() throws Exception assertThat(presentations.get(0).has("presentationId")).isTrue(); assertThat(presentations.get(0).has("type")).isTrue(); assertThat(presentations.get(0).has("scheduledAt")).isTrue(); - assertThat(presentations.get(0).get("type").asText()).isEqualTo("INTERMEDIATE"); + assertThat(presentations.get(0).get("type").asString()).isEqualTo("INTERMEDIATE"); } } @@ -417,7 +420,7 @@ void createThesis_VerifyDatabaseState() throws Exception { .andExpect(status().isOk()) .andReturn().getResponse().getContentAsString(); - UUID thesisId = UUID.fromString(objectMapper.readTree(response).get("thesisId").asText()); + UUID thesisId = UUID.fromString(objectMapper.readTree(response).get("thesisId").asString()); Thesis thesis = thesisRepository.findById(thesisId).orElseThrow(); assertThat(thesis.getTitle()).isEqualTo("Database State Thesis"); @@ -650,7 +653,7 @@ void gradeThesis_Success_AsSupervisor() throws Exception { .content(objectMapper.writeValueAsString(thesisPayload))) .andExpect(status().isOk()) .andReturn().getResponse().getContentAsString(); - UUID thesisId = UUID.fromString(objectMapper.readTree(response).get("thesisId").asText()); + UUID thesisId = UUID.fromString(objectMapper.readTree(response).get("thesisId").asString()); String supervisorAuth = generateTestAuthenticationHeader( supervisor.universityId(), List.of("supervisor", "advisor")); @@ -690,7 +693,7 @@ void completeThesis_Success_AsSupervisor() throws Exception { .content(objectMapper.writeValueAsString(thesisPayload))) .andExpect(status().isOk()) .andReturn().getResponse().getContentAsString(); - UUID thesisId = UUID.fromString(objectMapper.readTree(response).get("thesisId").asText()); + UUID thesisId = UUID.fromString(objectMapper.readTree(response).get("thesisId").asString()); String supervisorAuth = generateTestAuthenticationHeader( supervisor.universityId(), List.of("supervisor", "advisor")); @@ -732,7 +735,7 @@ void gradeThesis_AccessDenied_AsAdvisorOnly() throws Exception { .content(objectMapper.writeValueAsString(thesisPayload))) .andExpect(status().isOk()) .andReturn().getResponse().getContentAsString(); - UUID thesisId = UUID.fromString(objectMapper.readTree(response).get("thesisId").asText()); + UUID thesisId = UUID.fromString(objectMapper.readTree(response).get("thesisId").asString()); // Advisor (non-supervisor) should not be able to grade String advisorAuth = generateTestAuthenticationHeader( @@ -749,4 +752,450 @@ void gradeThesis_AccessDenied_AsAdvisorOnly() throws Exception { .andExpect(status().isForbidden()); } } + + @Nested + class ThesisInfoUpdate { + @Test + void updateThesisInfo_Success_AsAdvisor() throws Exception { + TestUser student = createRandomTestUser(List.of("student")); + TestUser advisor = createRandomTestUser(List.of("supervisor", "advisor")); + UUID researchGroupId = createTestResearchGroup("Info Group", advisor.universityId()); + createTestEmailTemplate("THESIS_CREATED"); + + CreateThesisPayload thesisPayload = new CreateThesisPayload( + "Info Update Thesis", "MASTER", "ENGLISH", + List.of(student.userId()), List.of(advisor.userId()), + List.of(advisor.userId()), researchGroupId + ); + + String response = mockMvc.perform(MockMvcRequestBuilders.post("/v2/theses") + .header("Authorization", createRandomAdminAuthentication()) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(thesisPayload))) + .andExpect(status().isOk()) + .andReturn().getResponse().getContentAsString(); + UUID thesisId = UUID.fromString(objectMapper.readTree(response).get("thesisId").asString()); + + String advisorAuth = generateTestAuthenticationHeader( + advisor.universityId(), List.of("supervisor", "advisor")); + + UpdateThesisInfoPayload infoPayload = new UpdateThesisInfoPayload( + "This is the abstract", "Additional info text", + "Primary Title", Map.of("de", "German Title") + ); + + String updateResponse = mockMvc.perform(MockMvcRequestBuilders.put( + "/v2/theses/{thesisId}/info", thesisId) + .header("Authorization", advisorAuth) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(infoPayload))) + .andExpect(status().isOk()) + .andReturn().getResponse().getContentAsString(); + + JsonNode json = objectMapper.readTree(updateResponse); + assertThat(json.get("abstractText").asString()).isEqualTo("This is the abstract"); + } + + @Test + void updateThesisInfo_AccessDenied_AsNonMember() throws Exception { + UUID thesisId = createTestThesis("Non-member Info Thesis"); + String studentAuth = createRandomAuthentication("student"); + + UpdateThesisInfoPayload infoPayload = new UpdateThesisInfoPayload( + "Unauthorized abstract", null, null, null + ); + + mockMvc.perform(MockMvcRequestBuilders.put( + "/v2/theses/{thesisId}/info", thesisId) + .header("Authorization", studentAuth) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(infoPayload))) + .andExpect(status().isForbidden()); + } + } + + @Nested + class ThesisCreditsUpdate { + @Test + void updateThesisCredits_Success_AsAdvisor() throws Exception { + TestUser advisor = createRandomTestUser(List.of("supervisor", "advisor")); + UUID researchGroupId = createTestResearchGroup("Credits Group", advisor.universityId()); + createTestEmailTemplate("THESIS_CREATED"); + + CreateThesisPayload thesisPayload = new CreateThesisPayload( + "Credits Update Thesis", "MASTER", "ENGLISH", + List.of(advisor.userId()), List.of(advisor.userId()), + List.of(advisor.userId()), researchGroupId + ); + + String response = mockMvc.perform(MockMvcRequestBuilders.post("/v2/theses") + .header("Authorization", createRandomAdminAuthentication()) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(thesisPayload))) + .andExpect(status().isOk()) + .andReturn().getResponse().getContentAsString(); + UUID thesisId = UUID.fromString(objectMapper.readTree(response).get("thesisId").asString()); + + String advisorAuth = generateTestAuthenticationHeader( + advisor.universityId(), List.of("supervisor", "advisor")); + + UpdateThesisCreditsPayload creditsPayload = new UpdateThesisCreditsPayload( + Map.of(advisor.userId(), 30) + ); + + mockMvc.perform(MockMvcRequestBuilders.put( + "/v2/theses/{thesisId}/credits", thesisId) + .header("Authorization", advisorAuth) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(creditsPayload))) + .andExpect(status().isOk()); + } + + @Test + void updateThesisCredits_AccessDenied_AsStudent() throws Exception { + UUID thesisId = createTestThesis("Student Credits Thesis"); + String studentAuth = createRandomAuthentication("student"); + + UpdateThesisCreditsPayload creditsPayload = new UpdateThesisCreditsPayload( + Map.of(UUID.randomUUID(), 30) + ); + + mockMvc.perform(MockMvcRequestBuilders.put( + "/v2/theses/{thesisId}/credits", thesisId) + .header("Authorization", studentAuth) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(creditsPayload))) + .andExpect(status().isForbidden()); + } + } + + @Nested + class ThesisLifecycle { + private record ThesisSetup(UUID thesisId, String advisorAuth, TestUser advisor) {} + + private ThesisSetup createThesisWithAdvisor() throws Exception { + TestUser student = createRandomTestUser(List.of("student")); + TestUser advisor = createRandomTestUser(List.of("supervisor", "advisor")); + UUID researchGroupId = createTestResearchGroup("Lifecycle Group", advisor.universityId()); + createTestEmailTemplate("THESIS_CREATED"); + + CreateThesisPayload thesisPayload = new CreateThesisPayload( + "Lifecycle Thesis", "MASTER", "ENGLISH", + List.of(student.userId()), List.of(advisor.userId()), + List.of(advisor.userId()), researchGroupId + ); + + String response = mockMvc.perform(MockMvcRequestBuilders.post("/v2/theses") + .header("Authorization", createRandomAdminAuthentication()) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(thesisPayload))) + .andExpect(status().isOk()) + .andReturn().getResponse().getContentAsString(); + UUID thesisId = UUID.fromString(objectMapper.readTree(response).get("thesisId").asString()); + String advisorAuth = generateTestAuthenticationHeader( + advisor.universityId(), List.of("supervisor", "advisor")); + return new ThesisSetup(thesisId, advisorAuth, advisor); + } + + @Test + void proposalUploadAndAccept_FullFlow() throws Exception { + createTestEmailTemplate("THESIS_PROPOSAL_UPLOADED"); + createTestEmailTemplate("THESIS_PROPOSAL_ACCEPTED"); + + ThesisSetup setup = createThesisWithAdvisor(); + + // Upload proposal (as advisor since they're also in the thesis) + MockMultipartFile proposalFile = new MockMultipartFile( + "proposal", "proposal.pdf", MediaType.APPLICATION_PDF_VALUE, "proposal content".getBytes() + ); + mockMvc.perform(MockMvcRequestBuilders.multipart("/v2/theses/{thesisId}/proposal", setup.thesisId) + .file(proposalFile) + .header("Authorization", setup.advisorAuth)) + .andExpect(status().isOk()); + + Thesis thesis = thesisRepository.findById(setup.thesisId).orElseThrow(); + assertThat(thesis.getState()).isEqualTo(ThesisState.PROPOSAL); + + // Accept proposal + mockMvc.perform(MockMvcRequestBuilders.put("/v2/theses/{thesisId}/proposal/accept", setup.thesisId) + .header("Authorization", setup.advisorAuth)) + .andExpect(status().isOk()); + + thesis = thesisRepository.findById(setup.thesisId).orElseThrow(); + assertThat(thesis.getState()).isEqualTo(ThesisState.WRITING); + } + + @Test + void requestChanges_ProposalFeedback_Success() throws Exception { + createTestEmailTemplate("THESIS_PROPOSAL_UPLOADED"); + createTestEmailTemplate("THESIS_PROPOSAL_REJECTED"); + + ThesisSetup setup = createThesisWithAdvisor(); + + // Upload proposal first + MockMultipartFile proposalFile = new MockMultipartFile( + "proposal", "proposal.pdf", MediaType.APPLICATION_PDF_VALUE, "proposal content".getBytes() + ); + mockMvc.perform(MockMvcRequestBuilders.multipart("/v2/theses/{thesisId}/proposal", setup.thesisId) + .file(proposalFile) + .header("Authorization", setup.advisorAuth)) + .andExpect(status().isOk()); + + // Request changes + RequestChangesPayload changesPayload = new RequestChangesPayload( + ThesisFeedbackType.PROPOSAL, + List.of(new RequestChangesPayload.RequestedChange("Improve the introduction", false)) + ); + + mockMvc.perform(MockMvcRequestBuilders.post("/v2/theses/{thesisId}/feedback", setup.thesisId) + .header("Authorization", setup.advisorAuth) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(changesPayload))) + .andExpect(status().isOk()); + + assertThat(thesisFeedbackRepository.count()).isEqualTo(1); + } + + @Test + void assessmentAndGrade_FullFlow() throws Exception { + createTestEmailTemplate("THESIS_PROPOSAL_UPLOADED"); + createTestEmailTemplate("THESIS_PROPOSAL_ACCEPTED"); + createTestEmailTemplate("THESIS_FINAL_SUBMISSION"); + createTestEmailTemplate("THESIS_ASSESSMENT_ADDED"); + createTestEmailTemplate("THESIS_FINAL_GRADE"); + + ThesisSetup setup = createThesisWithAdvisor(); + + // Upload and accept proposal to get to WRITING state + MockMultipartFile proposalFile = new MockMultipartFile( + "proposal", "proposal.pdf", MediaType.APPLICATION_PDF_VALUE, "proposal content".getBytes() + ); + mockMvc.perform(MockMvcRequestBuilders.multipart("/v2/theses/{thesisId}/proposal", setup.thesisId) + .file(proposalFile) + .header("Authorization", setup.advisorAuth)) + .andExpect(status().isOk()); + mockMvc.perform(MockMvcRequestBuilders.put("/v2/theses/{thesisId}/proposal/accept", setup.thesisId) + .header("Authorization", setup.advisorAuth)) + .andExpect(status().isOk()); + + // Upload thesis file to enable final submission + MockMultipartFile thesisFile = new MockMultipartFile( + "file", "thesis.pdf", MediaType.APPLICATION_PDF_VALUE, "thesis content".getBytes() + ); + MockMultipartFile thesisType = new MockMultipartFile( + "type", "", MediaType.TEXT_PLAIN_VALUE, "THESIS".getBytes() + ); + mockMvc.perform(MockMvcRequestBuilders.multipart("/v2/theses/{thesisId}/files", setup.thesisId) + .file(thesisFile) + .file(thesisType) + .header("Authorization", setup.advisorAuth)) + .andExpect(status().isOk()); + + // Submit thesis + mockMvc.perform(MockMvcRequestBuilders.put("/v2/theses/{thesisId}/thesis/final-submission", setup.thesisId) + .header("Authorization", setup.advisorAuth)) + .andExpect(status().isOk()); + + Thesis thesis = thesisRepository.findById(setup.thesisId).orElseThrow(); + assertThat(thesis.getState()).isEqualTo(ThesisState.SUBMITTED); + + // Create assessment + CreateAssessmentPayload assessmentPayload = new CreateAssessmentPayload( + "Good thesis", "Clear structure", "Minor issues", "1.3" + ); + mockMvc.perform(MockMvcRequestBuilders.post("/v2/theses/{thesisId}/assessment", setup.thesisId) + .header("Authorization", setup.advisorAuth) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(assessmentPayload))) + .andExpect(status().isOk()); + + thesis = thesisRepository.findById(setup.thesisId).orElseThrow(); + assertThat(thesis.getState()).isEqualTo(ThesisState.ASSESSED); + + // Grade thesis + AddThesisGradePayload gradePayload = new AddThesisGradePayload( + "1.3", "Well done", ThesisVisibility.PUBLIC + ); + mockMvc.perform(MockMvcRequestBuilders.post("/v2/theses/{thesisId}/grade", setup.thesisId) + .header("Authorization", setup.advisorAuth) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(gradePayload))) + .andExpect(status().isOk()); + + thesis = thesisRepository.findById(setup.thesisId).orElseThrow(); + assertThat(thesis.getState()).isEqualTo(ThesisState.GRADED); + assertThat(thesis.getFinalGrade()).isEqualTo("1.3"); + } + + @Test + void deletePresentation_Success() throws Exception { + createTestEmailTemplate("THESIS_PRESENTATION_DELETED"); + createTestEmailTemplate("THESIS_PRESENTATION_INVITATION_CANCELLED"); + + ThesisSetup setup = createThesisWithAdvisor(); + UUID presentationId = createScheduledPresentation(setup.thesisId, setup.advisorAuth); + + mockMvc.perform(MockMvcRequestBuilders.delete( + "/v2/theses/{thesisId}/presentations/{presentationId}", + setup.thesisId, presentationId) + .header("Authorization", setup.advisorAuth)) + .andExpect(status().isOk()); + } + } + + @Nested + class ThesisClose { + @Test + void closeThesis_Success_AsAdvisor() throws Exception { + TestUser advisor = createRandomTestUser(List.of("supervisor", "advisor")); + UUID researchGroupId = createTestResearchGroup("Close Group", advisor.universityId()); + createTestEmailTemplate("THESIS_CREATED"); + createTestEmailTemplate("THESIS_CLOSED"); + + CreateThesisPayload thesisPayload = new CreateThesisPayload( + "Close Thesis Test", "MASTER", "ENGLISH", + List.of(advisor.userId()), List.of(advisor.userId()), + List.of(advisor.userId()), researchGroupId + ); + + String response = mockMvc.perform(MockMvcRequestBuilders.post("/v2/theses") + .header("Authorization", createRandomAdminAuthentication()) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(thesisPayload))) + .andExpect(status().isOk()) + .andReturn().getResponse().getContentAsString(); + UUID thesisId = UUID.fromString(objectMapper.readTree(response).get("thesisId").asString()); + + String advisorAuth = generateTestAuthenticationHeader( + advisor.universityId(), List.of("supervisor", "advisor")); + + String closeResponse = mockMvc.perform(MockMvcRequestBuilders.delete( + "/v2/theses/{thesisId}", thesisId) + .header("Authorization", advisorAuth)) + .andExpect(status().isOk()) + .andReturn().getResponse().getContentAsString(); + + JsonNode json = objectMapper.readTree(closeResponse); + assertThat(json.get("state").asString()).isEqualTo("DROPPED_OUT"); + + Thesis thesis = thesisRepository.findById(thesisId).orElseThrow(); + assertThat(thesis.getState()).isEqualTo(ThesisState.DROPPED_OUT); + } + + @Test + void closeThesis_AccessDenied_AsStudent() throws Exception { + UUID thesisId = createTestThesis("Student Close Thesis"); + String studentAuth = createRandomAuthentication("student"); + + mockMvc.perform(MockMvcRequestBuilders.delete( + "/v2/theses/{thesisId}", thesisId) + .header("Authorization", studentAuth)) + .andExpect(status().isForbidden()); + } + } + + @Nested + class UpdateThesisInfo { + @Test + void updateThesisInfo_Success() throws Exception { + createTestEmailTemplate("THESIS_CREATED"); + TestUser advisor = createRandomTestUser(List.of("supervisor", "advisor")); + UUID researchGroupId = createTestResearchGroup("Info RG", advisor.universityId()); + + CreateThesisPayload createPayload = new CreateThesisPayload( + "Info Test Thesis", "MASTER", "ENGLISH", + List.of(advisor.userId()), List.of(advisor.userId()), + List.of(advisor.userId()), researchGroupId + ); + String adminAuth = createRandomAdminAuthentication(); + String createResponse = mockMvc.perform(MockMvcRequestBuilders.post("/v2/theses") + .header("Authorization", adminAuth) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(createPayload))) + .andExpect(status().isOk()) + .andReturn().getResponse().getContentAsString(); + UUID thesisId = UUID.fromString(objectMapper.readTree(createResponse).get("thesisId").asString()); + + String advisorAuth = generateTestAuthenticationHeader(advisor.universityId(), List.of("supervisor", "advisor")); + + UpdateThesisInfoPayload infoPayload = new UpdateThesisInfoPayload( + "Test abstract text", "Test info text", "Updated Title", null + ); + String response = mockMvc.perform(MockMvcRequestBuilders.put("/v2/theses/{thesisId}/info", thesisId) + .header("Authorization", advisorAuth) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(infoPayload))) + .andExpect(status().isOk()) + .andReturn().getResponse().getContentAsString(); + + JsonNode json = objectMapper.readTree(response); + assertThat(json.get("abstractText").asString()).isEqualTo("Test abstract text"); + assertThat(json.get("infoText").asString()).isEqualTo("Test info text"); + } + + @Test + void updateThesisInfo_WithNullValues_Success() throws Exception { + createTestEmailTemplate("THESIS_CREATED"); + TestUser advisor = createRandomTestUser(List.of("supervisor", "advisor")); + UUID researchGroupId = createTestResearchGroup("Null Info RG", advisor.universityId()); + + CreateThesisPayload createPayload = new CreateThesisPayload( + "Null Info Thesis", "MASTER", "ENGLISH", + List.of(advisor.userId()), List.of(advisor.userId()), + List.of(advisor.userId()), researchGroupId + ); + String adminAuth = createRandomAdminAuthentication(); + String createResponse = mockMvc.perform(MockMvcRequestBuilders.post("/v2/theses") + .header("Authorization", adminAuth) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(createPayload))) + .andExpect(status().isOk()) + .andReturn().getResponse().getContentAsString(); + UUID thesisId = UUID.fromString(objectMapper.readTree(createResponse).get("thesisId").asString()); + + String advisorAuth = generateTestAuthenticationHeader(advisor.universityId(), List.of("supervisor", "advisor")); + + UpdateThesisInfoPayload infoPayload = new UpdateThesisInfoPayload( + null, null, null, null + ); + mockMvc.perform(MockMvcRequestBuilders.put("/v2/theses/{thesisId}/info", thesisId) + .header("Authorization", advisorAuth) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(infoPayload))) + .andExpect(status().isOk()); + } + } + + @Nested + class UpdateThesisCredits { + @Test + void updateThesisCredits_Success() throws Exception { + createTestEmailTemplate("THESIS_CREATED"); + TestUser advisor = createRandomTestUser(List.of("supervisor", "advisor")); + UUID researchGroupId = createTestResearchGroup("Credits RG", advisor.universityId()); + + CreateThesisPayload createPayload = new CreateThesisPayload( + "Credits Test Thesis", "MASTER", "ENGLISH", + List.of(advisor.userId()), List.of(advisor.userId()), + List.of(advisor.userId()), researchGroupId + ); + String adminAuth = createRandomAdminAuthentication(); + String createResponse = mockMvc.perform(MockMvcRequestBuilders.post("/v2/theses") + .header("Authorization", adminAuth) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(createPayload))) + .andExpect(status().isOk()) + .andReturn().getResponse().getContentAsString(); + UUID thesisId = UUID.fromString(objectMapper.readTree(createResponse).get("thesisId").asString()); + + String advisorAuth = generateTestAuthenticationHeader(advisor.universityId(), List.of("supervisor", "advisor")); + + UpdateThesisCreditsPayload creditsPayload = new UpdateThesisCreditsPayload(Map.of(advisor.userId(), 30)); + mockMvc.perform(MockMvcRequestBuilders.put("/v2/theses/{thesisId}/credits", thesisId) + .header("Authorization", advisorAuth) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(creditsPayload))) + .andExpect(status().isOk()); + } + } } diff --git a/server/src/test/java/de/tum/cit/aet/thesis/controller/ThesisControllerTest.java b/server/src/test/java/de/tum/cit/aet/thesis/controller/ThesisControllerTest.java index 4af0e30e8..a7c7dcb9b 100644 --- a/server/src/test/java/de/tum/cit/aet/thesis/controller/ThesisControllerTest.java +++ b/server/src/test/java/de/tum/cit/aet/thesis/controller/ThesisControllerTest.java @@ -3,7 +3,6 @@ import static org.assertj.core.api.Assertions.assertThat; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; -import com.fasterxml.jackson.databind.JsonNode; import de.tum.cit.aet.thesis.constants.ThesisCommentType; import de.tum.cit.aet.thesis.constants.ThesisFeedbackType; import de.tum.cit.aet.thesis.constants.ThesisPresentationType; @@ -37,6 +36,7 @@ import org.springframework.test.context.DynamicPropertySource; import org.springframework.test.web.servlet.request.MockMvcRequestBuilders; import org.testcontainers.junit.jupiter.Testcontainers; +import tools.jackson.databind.JsonNode; import java.time.Instant; import java.util.List; @@ -101,12 +101,12 @@ void getThesis_Success() throws Exception { .andReturn().getResponse().getContentAsString(); JsonNode json = objectMapper.readTree(response); - assertThat(json.get("thesisId").asText()).isEqualTo(thesisId.toString()); - assertThat(json.get("title").asText()).isEqualTo("Test Thesis"); - assertThat(json.get("type").asText()).isEqualTo("MASTER"); - assertThat(json.get("language").asText()).isEqualTo("ENGLISH"); - assertThat(json.get("state").asText()).isEqualTo("PROPOSAL"); - assertThat(json.get("visibility").asText()).isEqualTo("INTERNAL"); + assertThat(json.get("thesisId").asString()).isEqualTo(thesisId.toString()); + assertThat(json.get("title").asString()).isEqualTo("Test Thesis"); + assertThat(json.get("type").asString()).isEqualTo("MASTER"); + assertThat(json.get("language").asString()).isEqualTo("ENGLISH"); + assertThat(json.get("state").asString()).isEqualTo("PROPOSAL"); + assertThat(json.get("visibility").asString()).isEqualTo("INTERNAL"); } @Test @@ -175,8 +175,8 @@ void createThesis_Success() throws Exception { .andReturn().getResponse().getContentAsString(); JsonNode json = objectMapper.readTree(response); - assertThat(json.get("title").asText()).isEqualTo("Test Thesis"); - assertThat(json.get("type").asText()).isEqualTo("MASTER"); + assertThat(json.get("title").asString()).isEqualTo("Test Thesis"); + assertThat(json.get("type").asString()).isEqualTo("MASTER"); } @Test @@ -225,8 +225,8 @@ void updateThesis_Success() throws Exception { .andReturn().getResponse().getContentAsString(); JsonNode json = objectMapper.readTree(response); - assertThat(json.get("title").asText()).isEqualTo("Updated Thesis"); - assertThat(json.get("visibility").asText()).isEqualTo("PUBLIC"); + assertThat(json.get("title").asString()).isEqualTo("Updated Thesis"); + assertThat(json.get("visibility").asString()).isEqualTo("PUBLIC"); } @Test @@ -298,9 +298,9 @@ void updateThesisInfo_Success() throws Exception { .andReturn().getResponse().getContentAsString(); JsonNode json = objectMapper.readTree(response); - assertThat(json.get("abstractText").asText()).isEqualTo("Test abstract text"); - assertThat(json.get("infoText").asText()).isEqualTo("Test info text"); - assertThat(json.get("title").asText()).isEqualTo("Updated Primary Title"); + assertThat(json.get("abstractText").asString()).isEqualTo("Test abstract text"); + assertThat(json.get("infoText").asString()).isEqualTo("Test info text"); + assertThat(json.get("title").asString()).isEqualTo("Updated Primary Title"); Thesis thesis = thesisRepository.findById(thesisId).orElseThrow(); assertThat(thesis.getAbstractField()).isEqualTo("Test abstract text"); @@ -411,7 +411,7 @@ void completeFeedback_Success() throws Exception { .andReturn().getResponse().getContentAsString(); UUID feedbackId = UUID.fromString(objectMapper.readTree(feedbackResponse) - .get("feedback").get(0).get("feedbackId").asText()); + .get("feedback").get(0).get("feedbackId").asString()); mockMvc.perform(MockMvcRequestBuilders.put("/v2/theses/{thesisId}/feedback/{feedbackId}/complete", thesisId, feedbackId) .header("Authorization", adminAuth)) @@ -439,7 +439,7 @@ void incompleteFeedback_Success() throws Exception { .andReturn().getResponse().getContentAsString(); UUID feedbackId = UUID.fromString(objectMapper.readTree(feedbackResponse) - .get("feedback").get(0).get("feedbackId").asText()); + .get("feedback").get(0).get("feedbackId").asString()); mockMvc.perform(MockMvcRequestBuilders.put("/v2/theses/{thesisId}/feedback/{feedbackId}/incomplete", thesisId, feedbackId) .header("Authorization", adminAuth)) @@ -467,7 +467,7 @@ void deleteFeedback_Success() throws Exception { .andReturn().getResponse().getContentAsString(); UUID feedbackId = UUID.fromString(objectMapper.readTree(feedbackResponse) - .get("feedback").get(0).get("feedbackId").asText()); + .get("feedback").get(0).get("feedbackId").asString()); mockMvc.perform(MockMvcRequestBuilders.delete("/v2/theses/{thesisId}/feedback/{feedbackId}", thesisId, feedbackId) .header("Authorization", adminAuth)) @@ -493,7 +493,7 @@ void deleteFeedback_AccessDenied_AsStudent() throws Exception { .andReturn().getResponse().getContentAsString(); UUID feedbackId = UUID.fromString(objectMapper.readTree(feedbackResponse) - .get("feedback").get(0).get("feedbackId").asText()); + .get("feedback").get(0).get("feedbackId").asString()); String studentAuth = createRandomAuthentication("student"); mockMvc.perform(MockMvcRequestBuilders.delete("/v2/theses/{thesisId}/feedback/{feedbackId}", thesisId, feedbackId) @@ -563,7 +563,7 @@ void getProposalFile_Success() throws Exception { .andReturn().getResponse().getContentAsString(); UUID proposalId = UUID.fromString(objectMapper.readTree(response) - .get("proposals").get(0).get("proposalId").asText()); + .get("proposals").get(0).get("proposalId").asString()); mockMvc.perform(MockMvcRequestBuilders.get("/v2/theses/{thesisId}/proposal/{proposalId}", thesisId, proposalId) .header("Authorization", createRandomAdminAuthentication())) @@ -586,7 +586,7 @@ void deleteProposal_Success() throws Exception { .andReturn().getResponse().getContentAsString(); UUID proposalId = UUID.fromString(objectMapper.readTree(response) - .get("proposals").get(0).get("proposalId").asText()); + .get("proposals").get(0).get("proposalId").asString()); mockMvc.perform(MockMvcRequestBuilders.delete("/v2/theses/{thesisId}/proposal/{proposalId}", thesisId, proposalId) .header("Authorization", createRandomAdminAuthentication())) @@ -611,7 +611,7 @@ void deleteProposal_AccessDenied_AsStudent() throws Exception { .andReturn().getResponse().getContentAsString(); UUID proposalId = UUID.fromString(objectMapper.readTree(response) - .get("proposals").get(0).get("proposalId").asText()); + .get("proposals").get(0).get("proposalId").asString()); String studentAuth = createRandomAuthentication("student"); mockMvc.perform(MockMvcRequestBuilders.delete("/v2/theses/{thesisId}/proposal/{proposalId}", thesisId, proposalId) @@ -663,7 +663,7 @@ void getThesisFile_Success() throws Exception { .andReturn().getResponse().getContentAsString(); UUID fileId = UUID.fromString(objectMapper.readTree(response) - .get("files").get(0).get("fileId").asText()); + .get("files").get(0).get("fileId").asString()); mockMvc.perform(MockMvcRequestBuilders.get("/v2/theses/{thesisId}/files/{fileId}", thesisId, fileId) .header("Authorization", createRandomAdminAuthentication())) @@ -689,7 +689,7 @@ void deleteThesisFile_Success() throws Exception { .andReturn().getResponse().getContentAsString(); UUID fileId = UUID.fromString(objectMapper.readTree(response) - .get("files").get(0).get("fileId").asText()); + .get("files").get(0).get("fileId").asString()); mockMvc.perform(MockMvcRequestBuilders.delete("/v2/theses/{thesisId}/files/{fileId}", thesisId, fileId) .header("Authorization", createRandomAdminAuthentication())) @@ -778,7 +778,7 @@ void updatePresentation_Success() throws Exception { .andReturn().getResponse().getContentAsString(); UUID presentationId = UUID.fromString(objectMapper.readTree(response) - .get("presentations").get(0).get("presentationId").asText()); + .get("presentations").get(0).get("presentationId").asString()); ReplacePresentationPayload updatePayload = new ReplacePresentationPayload( ThesisPresentationType.FINAL, @@ -818,7 +818,7 @@ void deletePresentation_Success() throws Exception { .andReturn().getResponse().getContentAsString(); UUID presentationId = UUID.fromString(objectMapper.readTree(response) - .get("presentations").get(0).get("presentationId").asText()); + .get("presentations").get(0).get("presentationId").asString()); mockMvc.perform(MockMvcRequestBuilders.delete("/v2/theses/{thesisId}/presentations/{presentationId}", thesisId, presentationId) .header("Authorization", adminAuth)) @@ -846,7 +846,7 @@ void deletePresentation_AccessDenied_AsStudent() throws Exception { .andReturn().getResponse().getContentAsString(); UUID presentationId = UUID.fromString(objectMapper.readTree(response) - .get("presentations").get(0).get("presentationId").asText()); + .get("presentations").get(0).get("presentationId").asString()); String studentAuth = createRandomAuthentication("student"); mockMvc.perform(MockMvcRequestBuilders.delete("/v2/theses/{thesisId}/presentations/{presentationId}", thesisId, presentationId) @@ -920,7 +920,7 @@ void deleteComment_Success() throws Exception { .andExpect(status().isOk()) .andReturn().getResponse().getContentAsString(); - UUID commentId = UUID.fromString(objectMapper.readTree(response).get("commentId").asText()); + UUID commentId = UUID.fromString(objectMapper.readTree(response).get("commentId").asString()); mockMvc.perform(MockMvcRequestBuilders.delete("/v2/theses/{thesisId}/comments/{commentId}", thesisId, commentId) .header("Authorization", adminAuth)) @@ -948,7 +948,7 @@ void deleteComment_AccessDenied_AsStudent() throws Exception { .andExpect(status().isOk()) .andReturn().getResponse().getContentAsString(); - UUID commentId = UUID.fromString(objectMapper.readTree(response).get("commentId").asText()); + UUID commentId = UUID.fromString(objectMapper.readTree(response).get("commentId").asString()); String studentAuth = createRandomAuthentication("student"); mockMvc.perform(MockMvcRequestBuilders.delete("/v2/theses/{thesisId}/comments/{commentId}", thesisId, commentId) @@ -982,7 +982,7 @@ void getCommentFile_Success() throws Exception { .andExpect(status().isOk()) .andReturn().getResponse().getContentAsString(); - UUID commentId = UUID.fromString(objectMapper.readTree(response).get("commentId").asText()); + UUID commentId = UUID.fromString(objectMapper.readTree(response).get("commentId").asString()); mockMvc.perform(MockMvcRequestBuilders.get("/v2/theses/{thesisId}/comments/{commentId}/file", thesisId, commentId) .header("Authorization", createRandomAdminAuthentication())) diff --git a/server/src/test/java/de/tum/cit/aet/thesis/controller/TopicControllerTest.java b/server/src/test/java/de/tum/cit/aet/thesis/controller/TopicControllerTest.java index be31a31ae..67fc8388c 100644 --- a/server/src/test/java/de/tum/cit/aet/thesis/controller/TopicControllerTest.java +++ b/server/src/test/java/de/tum/cit/aet/thesis/controller/TopicControllerTest.java @@ -5,18 +5,21 @@ import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; -import com.fasterxml.jackson.databind.JsonNode; import de.tum.cit.aet.thesis.constants.ApplicationRejectReason; import de.tum.cit.aet.thesis.controller.payload.CloseTopicPayload; +import de.tum.cit.aet.thesis.controller.payload.CreateApplicationPayload; import de.tum.cit.aet.thesis.controller.payload.ReplaceTopicPayload; import de.tum.cit.aet.thesis.mock.BaseIntegrationTest; +import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; import org.springframework.http.MediaType; import org.springframework.test.context.DynamicPropertyRegistry; import org.springframework.test.context.DynamicPropertySource; import org.springframework.test.web.servlet.request.MockMvcRequestBuilders; import org.testcontainers.junit.jupiter.Testcontainers; +import tools.jackson.databind.JsonNode; +import java.time.Instant; import java.util.List; import java.util.Set; import java.util.UUID; @@ -156,6 +159,62 @@ void closeTopic_Success() throws Exception { .andExpect(jsonPath("$.closedAt").value(notNullValue(String.class))); } + @Test + void closeTopic_WithPendingApplications_RejectsAll() throws Exception { + createTestEmailTemplate("APPLICATION_CREATED_CHAIR"); + createTestEmailTemplate("APPLICATION_CREATED_STUDENT"); + createTestEmailTemplate("APPLICATION_REJECTED_TOPIC_FILLED"); + + UUID topicId = createTestTopic("Close With Apps Topic"); + + // Create two applications for this topic + String student1Auth = createRandomAuthentication("student"); + CreateApplicationPayload appPayload1 = new CreateApplicationPayload( + topicId, null, "MASTER", Instant.now(), "Motivation 1", null + ); + mockMvc.perform(MockMvcRequestBuilders.post("/v2/applications") + .header("Authorization", student1Auth) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(appPayload1))) + .andExpect(status().isOk()); + + String student2Auth = createRandomAuthentication("student"); + CreateApplicationPayload appPayload2 = new CreateApplicationPayload( + topicId, null, "MASTER", Instant.now(), "Motivation 2", null + ); + mockMvc.perform(MockMvcRequestBuilders.post("/v2/applications") + .header("Authorization", student2Auth) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(appPayload2))) + .andExpect(status().isOk()); + + CloseTopicPayload closePayload = new CloseTopicPayload( + ApplicationRejectReason.TOPIC_FILLED, true + ); + + mockMvc.perform(MockMvcRequestBuilders.delete("/v2/topics/{topicId}", topicId) + .header("Authorization", createRandomAdminAuthentication()) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(closePayload))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.closedAt").value(notNullValue(String.class))); + } + + @Test + void closeTopic_AsStudent_Forbidden() throws Exception { + UUID topicId = createTestTopic("Close Forbidden Topic"); + + CloseTopicPayload closePayload = new CloseTopicPayload( + ApplicationRejectReason.TOPIC_FILLED, false + ); + + mockMvc.perform(MockMvcRequestBuilders.delete("/v2/topics/{topicId}", topicId) + .header("Authorization", createRandomAuthentication("student")) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(closePayload))) + .andExpect(status().isForbidden()); + } + @Test void getTopics_WithPagination_Success() throws Exception { // Create multiple test topics @@ -220,4 +279,260 @@ void getTopics_VerifyResponseStructure() throws Exception { assertThat(firstTopic.has("publishedAt")).isFalse(); assertThat(firstTopic.has("updatedAt")).isFalse(); } + + @Nested + class TopicStateFiltering { + @Test + void getTopics_FilterByOpenState_ReturnsOpenTopics() throws Exception { + createTestTopic("Open Topic"); + + mockMvc.perform(MockMvcRequestBuilders.get("/v2/topics") + .header("Authorization", createRandomAdminAuthentication()) + .param("states", "OPEN")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.content", hasSize(greaterThanOrEqualTo(1)))); + } + + @Test + void getTopics_FilterByClosedState_ReturnsClosedTopics() throws Exception { + UUID topicId = createTestTopic("Close Me Topic"); + + CloseTopicPayload closePayload = new CloseTopicPayload( + ApplicationRejectReason.TOPIC_FILLED, false + ); + + mockMvc.perform(MockMvcRequestBuilders.delete("/v2/topics/{topicId}", topicId) + .header("Authorization", createRandomAdminAuthentication()) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(closePayload))) + .andExpect(status().isOk()); + + String response = mockMvc.perform(MockMvcRequestBuilders.get("/v2/topics") + .header("Authorization", createRandomAdminAuthentication()) + .param("states", "CLOSED")) + .andExpect(status().isOk()) + .andReturn().getResponse().getContentAsString(); + + JsonNode json = objectMapper.readTree(response); + assertThat(json.get("content").size()).isGreaterThanOrEqualTo(1); + } + + @Test + void getTopics_FilterByDraftState_AsAdmin_ReturnsDrafts() throws Exception { + TestUser advisor = createRandomTestUser(List.of("supervisor", "advisor")); + UUID researchGroupId = createTestResearchGroup("Draft Group", advisor.universityId()); + + ReplaceTopicPayload draftPayload = new ReplaceTopicPayload( + "Draft Topic", + Set.of("MASTER"), + "Problem Statement", "Requirements", "Goals", "References", + List.of(advisor.userId()), List.of(advisor.userId()), + researchGroupId, null, null, true + ); + + mockMvc.perform(MockMvcRequestBuilders.post("/v2/topics") + .header("Authorization", createRandomAdminAuthentication()) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(draftPayload))) + .andExpect(status().isOk()); + + String response = mockMvc.perform(MockMvcRequestBuilders.get("/v2/topics") + .header("Authorization", createRandomAdminAuthentication()) + .param("states", "DRAFT")) + .andExpect(status().isOk()) + .andReturn().getResponse().getContentAsString(); + + JsonNode json = objectMapper.readTree(response); + assertThat(json.get("content").size()).isGreaterThanOrEqualTo(1); + } + } + + @Nested + class InterviewTopics { + @Test + void getInterviewTopics_AsAdmin_Success() throws Exception { + // Admin needs a research group to avoid NPE in getPossibleInterviewTopics + TestUser adminUser = createRandomTestUser(List.of("supervisor", "admin")); + UUID rg = createTestResearchGroup("Admin Interview RG", adminUser.universityId()); + + ReplaceTopicPayload payload = new ReplaceTopicPayload( + "Interview Eligible Topic", Set.of("MASTER"), + "PS", "Req", "Goals", "Refs", + List.of(adminUser.userId()), List.of(adminUser.userId()), + rg, null, null, false + ); + mockMvc.perform(MockMvcRequestBuilders.post("/v2/topics") + .header("Authorization", generateTestAuthenticationHeader(adminUser.universityId(), List.of("supervisor", "admin"))) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(payload))) + .andExpect(status().isOk()); + + mockMvc.perform(MockMvcRequestBuilders.get("/v2/topics/interview-topics") + .header("Authorization", generateTestAuthenticationHeader(adminUser.universityId(), List.of("supervisor", "admin")))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.content", isA(List.class))) + .andExpect(jsonPath("$.content", hasSize(greaterThanOrEqualTo(1)))); + } + + @Test + void getInterviewTopics_AsStudent_Forbidden() throws Exception { + mockMvc.perform(MockMvcRequestBuilders.get("/v2/topics/interview-topics") + .header("Authorization", createRandomAuthentication("student"))) + .andExpect(status().isForbidden()); + } + + @Test + void getInterviewTopics_AsAdvisor_Success() throws Exception { + TestUser advisor = createRandomTestUser(List.of("supervisor", "advisor")); + UUID rg = createTestResearchGroup("Interview RG", advisor.universityId()); + + ReplaceTopicPayload payload = new ReplaceTopicPayload( + "Advisor Interview Topic", Set.of("MASTER"), + "PS", "Req", "Goals", "Refs", + List.of(advisor.userId()), List.of(advisor.userId()), + rg, null, null, false + ); + mockMvc.perform(MockMvcRequestBuilders.post("/v2/topics") + .header("Authorization", createRandomAdminAuthentication()) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(payload))) + .andExpect(status().isOk()); + + String advisorAuth = generateTestAuthenticationHeader(advisor.universityId(), List.of("supervisor", "advisor")); + + String response = mockMvc.perform(MockMvcRequestBuilders.get("/v2/topics/interview-topics") + .header("Authorization", advisorAuth)) + .andExpect(status().isOk()) + .andReturn().getResponse().getContentAsString(); + + JsonNode json = objectMapper.readTree(response); + assertThat(json.get("content").size()).isGreaterThanOrEqualTo(1); + } + } + + @Nested + class TopicDraftAndPublish { + @Test + void createTopic_AsDraft_Success() throws Exception { + TestUser advisor = createRandomTestUser(List.of("supervisor", "advisor")); + UUID rg = createTestResearchGroup("Draft RG", advisor.universityId()); + + ReplaceTopicPayload draftPayload = new ReplaceTopicPayload( + "Draft Topic Test", Set.of("MASTER"), + "PS", "Req", "Goals", "Refs", + List.of(advisor.userId()), List.of(advisor.userId()), + rg, null, null, true + ); + + String response = mockMvc.perform(MockMvcRequestBuilders.post("/v2/topics") + .header("Authorization", createRandomAdminAuthentication()) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(draftPayload))) + .andExpect(status().isOk()) + .andReturn().getResponse().getContentAsString(); + + JsonNode json = objectMapper.readTree(response); + assertThat(json.get("state").asString()).isEqualTo("DRAFT"); + assertThat(json.has("publishedAt")).isFalse(); + } + + @Test + void updateTopic_PublishDraft_SetsPublishedAt() throws Exception { + TestUser advisor = createRandomTestUser(List.of("supervisor", "advisor")); + UUID rg = createTestResearchGroup("Publish RG", advisor.universityId()); + + // Create as draft + ReplaceTopicPayload draftPayload = new ReplaceTopicPayload( + "To Publish Topic", Set.of("MASTER"), + "PS", "Req", "Goals", "Refs", + List.of(advisor.userId()), List.of(advisor.userId()), + rg, null, null, true + ); + String createResponse = mockMvc.perform(MockMvcRequestBuilders.post("/v2/topics") + .header("Authorization", createRandomAdminAuthentication()) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(draftPayload))) + .andExpect(status().isOk()) + .andReturn().getResponse().getContentAsString(); + UUID topicId = UUID.fromString(objectMapper.readTree(createResponse).get("topicId").asString()); + + // Publish (set isDraft to false) + ReplaceTopicPayload publishPayload = new ReplaceTopicPayload( + "To Publish Topic", Set.of("MASTER"), + "PS", "Req", "Goals", "Refs", + List.of(advisor.userId()), List.of(advisor.userId()), + rg, null, null, false + ); + String updateResponse = mockMvc.perform(MockMvcRequestBuilders.put("/v2/topics/{topicId}", topicId) + .header("Authorization", createRandomAdminAuthentication()) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(publishPayload))) + .andExpect(status().isOk()) + .andReturn().getResponse().getContentAsString(); + + JsonNode json = objectMapper.readTree(updateResponse); + assertThat(json.get("state").asString()).isEqualTo("OPEN"); + assertThat(json.has("publishedAt")).isTrue(); + } + } + + @Nested + class TopicResearchGroupFiltering { + @Test + void getTopics_FilterByResearchGroupId_ReturnsOnlyMatchingTopics() throws Exception { + TestUser advisor1 = createRandomTestUser(List.of("supervisor", "advisor")); + UUID rg1 = createTestResearchGroup("RG Filter 1", advisor1.universityId()); + + ReplaceTopicPayload payload1 = new ReplaceTopicPayload( + "RG1 Topic", Set.of("MASTER"), + "PS", "Req", "Goals", "Refs", + List.of(advisor1.userId()), List.of(advisor1.userId()), + rg1, null, null, false + ); + mockMvc.perform(MockMvcRequestBuilders.post("/v2/topics") + .header("Authorization", createRandomAdminAuthentication()) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(payload1))) + .andExpect(status().isOk()); + + createTestTopic("Other RG Topic"); + + String response = mockMvc.perform(MockMvcRequestBuilders.get("/v2/topics") + .header("Authorization", createRandomAdminAuthentication()) + .param("researchGroupIds", rg1.toString())) + .andExpect(status().isOk()) + .andReturn().getResponse().getContentAsString(); + + JsonNode json = objectMapper.readTree(response); + assertThat(json.get("content").size()).isEqualTo(1); + assertThat(json.get("content").get(0).get("title").asString()).isEqualTo("RG1 Topic"); + } + + @Test + void getTopics_FilterByType_ReturnsMatchingTopics() throws Exception { + TestUser advisor = createRandomTestUser(List.of("supervisor", "advisor")); + UUID rg = createTestResearchGroup("Type Filter Group", advisor.universityId()); + + ReplaceTopicPayload payload = new ReplaceTopicPayload( + "Bachelor Only Topic", Set.of("BACHELOR"), + "PS", "Req", "Goals", "Refs", + List.of(advisor.userId()), List.of(advisor.userId()), + rg, null, null, false + ); + mockMvc.perform(MockMvcRequestBuilders.post("/v2/topics") + .header("Authorization", createRandomAdminAuthentication()) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(payload))) + .andExpect(status().isOk()); + + String response = mockMvc.perform(MockMvcRequestBuilders.get("/v2/topics") + .header("Authorization", createRandomAdminAuthentication()) + .param("type", "BACHELOR")) + .andExpect(status().isOk()) + .andReturn().getResponse().getContentAsString(); + + JsonNode json = objectMapper.readTree(response); + assertThat(json.get("content").size()).isGreaterThanOrEqualTo(1); + } + } } diff --git a/server/src/test/java/de/tum/cit/aet/thesis/controller/UserControllerTest.java b/server/src/test/java/de/tum/cit/aet/thesis/controller/UserControllerTest.java index d9dcbefb4..691f4e7dd 100644 --- a/server/src/test/java/de/tum/cit/aet/thesis/controller/UserControllerTest.java +++ b/server/src/test/java/de/tum/cit/aet/thesis/controller/UserControllerTest.java @@ -7,7 +7,6 @@ import static org.mockito.Mockito.when; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; -import com.fasterxml.jackson.databind.JsonNode; import de.tum.cit.aet.thesis.mock.BaseIntegrationTest; import de.tum.cit.aet.thesis.repository.UserRepository; import de.tum.cit.aet.thesis.service.AccessManagementService; @@ -20,6 +19,7 @@ import org.springframework.test.context.DynamicPropertySource; import org.springframework.test.web.servlet.request.MockMvcRequestBuilders; import org.testcontainers.junit.jupiter.Testcontainers; +import tools.jackson.databind.JsonNode; import jakarta.servlet.ServletException; @@ -139,7 +139,7 @@ void getUsers_SearchByName() throws Exception { List userIds = new ArrayList<>(); for (JsonNode u : content) { - userIds.add(u.get("userId").asText()); + userIds.add(u.get("userId").asString()); } assertThat(userIds).contains(user.userId().toString()); } @@ -183,8 +183,8 @@ void getUsers_SortByFirstName_Ascending() throws Exception { assertThat(content.size()).isGreaterThanOrEqualTo(2); for (int i = 1; i < content.size(); i++) { - String prev = content.get(i - 1).get("firstName").asText(); - String curr = content.get(i).get("firstName").asText(); + String prev = content.get(i - 1).get("firstName").asString(); + String curr = content.get(i).get("firstName").asString(); assertThat(prev.compareToIgnoreCase(curr)).isLessThanOrEqualTo(0); } } @@ -246,7 +246,7 @@ void getUsers_VerifyDatabaseState() throws Exception { JsonNode json = objectMapper.readTree(response); boolean foundStudent = false; for (JsonNode u : json.get("content")) { - if (u.get("universityId").asText().equals("dbcheck1")) { + if (u.get("universityId").asString().equals("dbcheck1")) { foundStudent = true; break; } @@ -282,10 +282,10 @@ void getKeycloakUsers_Success_AsAdmin() throws Exception { JsonNode json = objectMapper.readTree(response); assertThat(json.isArray()).isTrue(); assertThat(json.size()).isEqualTo(1); - assertThat(json.get(0).get("username").asText()).isEqualTo("kc-user-1"); - assertThat(json.get(0).get("firstName").asText()).isEqualTo("John"); - assertThat(json.get(0).get("lastName").asText()).isEqualTo("Doe"); - assertThat(json.get(0).get("email").asText()).isEqualTo("john@example.com"); + assertThat(json.get(0).get("username").asString()).isEqualTo("kc-user-1"); + assertThat(json.get(0).get("firstName").asString()).isEqualTo("John"); + assertThat(json.get(0).get("lastName").asString()).isEqualTo("Doe"); + assertThat(json.get(0).get("email").asString()).isEqualTo("john@example.com"); } @Test @@ -305,7 +305,7 @@ void getKeycloakUsers_WithExistingLocalUser() throws Exception { JsonNode json = objectMapper.readTree(response); assertThat(json.isArray()).isTrue(); assertThat(json.size()).isEqualTo(1); - assertThat(json.get(0).get("username").asText()).isEqualTo("kc-existing-user"); + assertThat(json.get(0).get("username").asString()).isEqualTo("kc-existing-user"); } @Test diff --git a/server/src/test/java/de/tum/cit/aet/thesis/controller/UserInfoControllerTest.java b/server/src/test/java/de/tum/cit/aet/thesis/controller/UserInfoControllerTest.java new file mode 100644 index 000000000..860241fac --- /dev/null +++ b/server/src/test/java/de/tum/cit/aet/thesis/controller/UserInfoControllerTest.java @@ -0,0 +1,208 @@ +package de.tum.cit.aet.thesis.controller; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import de.tum.cit.aet.thesis.mock.BaseIntegrationTest; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.http.MediaType; +import org.springframework.mock.web.MockMultipartFile; +import org.springframework.test.context.DynamicPropertyRegistry; +import org.springframework.test.context.DynamicPropertySource; +import org.springframework.test.web.servlet.request.MockMvcRequestBuilders; +import org.testcontainers.junit.jupiter.Testcontainers; +import tools.jackson.databind.JsonNode; + +import java.util.List; +import java.util.Map; + +@Testcontainers +class UserInfoControllerTest extends BaseIntegrationTest { + + @DynamicPropertySource + static void configureDynamicProperties(DynamicPropertyRegistry registry) { + configureProperties(registry); + } + + @Nested + class GetUserInfo { + @Test + void getUserInfo_Success_ReturnsUserProfile() throws Exception { + TestUser user = createRandomTestUser(List.of("student")); + + String response = mockMvc.perform(MockMvcRequestBuilders.get("/v2/user-info") + .header("Authorization", generateTestAuthenticationHeader(user.universityId(), List.of("student")))) + .andExpect(status().isOk()) + .andReturn().getResponse().getContentAsString(); + + JsonNode json = objectMapper.readTree(response); + assertThat(json.get("userId").asString()).isEqualTo(user.userId().toString()); + assertThat(json.get("universityId").asString()).isEqualTo(user.universityId()); + assertThat(json.has("groups")).isTrue(); + } + + @Test + void getUserInfo_CreatesUserOnFirstAccess() throws Exception { + String universityId = "newuser" + System.currentTimeMillis(); + String auth = generateTestAuthenticationHeader(universityId, List.of("student")); + + String response = mockMvc.perform(MockMvcRequestBuilders.get("/v2/user-info") + .header("Authorization", auth)) + .andExpect(status().isOk()) + .andReturn().getResponse().getContentAsString(); + + JsonNode json = objectMapper.readTree(response); + assertThat(json.get("universityId").asString()).isEqualTo(universityId); + assertThat(json.get("userId").asString()).isNotBlank(); + } + + @Test + void getUserInfo_Unauthenticated_ReturnsUnauthorized() throws Exception { + mockMvc.perform(MockMvcRequestBuilders.get("/v2/user-info")) + .andExpect(status().isUnauthorized()); + } + } + + @Nested + class UpdateUserInfo { + @Test + void updateUserInfo_WithData_Success() throws Exception { + TestUser user = createRandomTestUser(List.of("student")); + String auth = generateTestAuthenticationHeader(user.universityId(), List.of("student")); + + java.util.HashMap data = new java.util.HashMap<>(); + data.put("firstName", "John"); + data.put("lastName", "Doe"); + data.put("gender", "male"); + data.put("nationality", "German"); + data.put("email", "john@example.com"); + data.put("studyDegree", "MASTER"); + data.put("studyProgram", "Informatics"); + data.put("specialSkills", "Java, Spring"); + data.put("interests", "AI, ML"); + data.put("projects", "Thesis Management"); + data.put("customData", Map.of("key1", "value1")); + String dataJson = objectMapper.writeValueAsString(data); + + MockMultipartFile dataPart = new MockMultipartFile("data", "", "application/json", dataJson.getBytes()); + + String response = mockMvc.perform(MockMvcRequestBuilders.multipart("/v2/user-info") + .file(dataPart) + .with(request -> { + request.setMethod("PUT"); + return request; + }) + .header("Authorization", auth) + .contentType(MediaType.MULTIPART_FORM_DATA)) + .andExpect(status().isOk()) + .andReturn().getResponse().getContentAsString(); + + JsonNode json = objectMapper.readTree(response); + assertThat(json.get("firstName").asString()).isEqualTo("John"); + assertThat(json.get("lastName").asString()).isEqualTo("Doe"); + assertThat(json.get("gender").asString()).isEqualTo("male"); + assertThat(json.get("nationality").asString()).isEqualTo("German"); + } + + @Test + void updateUserInfo_WithAvatar_Success() throws Exception { + TestUser user = createRandomTestUser(List.of("student")); + String auth = generateTestAuthenticationHeader(user.universityId(), List.of("student")); + + String dataJson = objectMapper.writeValueAsString(Map.of( + "firstName", "Jane", + "lastName", "Doe", + "email", "jane@example.com", + "customData", Map.of() + )); + + MockMultipartFile dataPart = new MockMultipartFile("data", "", "application/json", dataJson.getBytes()); + MockMultipartFile avatarPart = new MockMultipartFile("avatar", "avatar.png", "image/png", new byte[]{1, 2, 3, 4}); + + mockMvc.perform(MockMvcRequestBuilders.multipart("/v2/user-info") + .file(dataPart) + .file(avatarPart) + .with(request -> { + request.setMethod("PUT"); + return request; + }) + .header("Authorization", auth) + .contentType(MediaType.MULTIPART_FORM_DATA)) + .andExpect(status().isOk()); + } + } + + @Nested + class NotificationSettings { + @Test + void getNotifications_ReturnsEmptyList() throws Exception { + TestUser user = createRandomTestUser(List.of("student")); + String auth = generateTestAuthenticationHeader(user.universityId(), List.of("student")); + + String response = mockMvc.perform(MockMvcRequestBuilders.get("/v2/user-info/notifications") + .header("Authorization", auth)) + .andExpect(status().isOk()) + .andReturn().getResponse().getContentAsString(); + + JsonNode json = objectMapper.readTree(response); + assertThat(json.size()).isZero(); + } + + @Test + void updateNotifications_CreatesNewSetting() throws Exception { + TestUser user = createRandomTestUser(List.of("student")); + String auth = generateTestAuthenticationHeader(user.universityId(), List.of("student")); + + String payload = objectMapper.writeValueAsString(Map.of( + "name", "new-applications", + "email", "notify@example.com" + )); + + String response = mockMvc.perform(MockMvcRequestBuilders.put("/v2/user-info/notifications") + .header("Authorization", auth) + .contentType(MediaType.APPLICATION_JSON) + .content(payload)) + .andExpect(status().isOk()) + .andReturn().getResponse().getContentAsString(); + + JsonNode json = objectMapper.readTree(response); + assertThat(json.size()).isEqualTo(1); + assertThat(json.get(0).get("name").asString()).isEqualTo("new-applications"); + assertThat(json.get(0).get("email").asString()).isEqualTo("notify@example.com"); + } + + @Test + void updateNotifications_UpdatesExistingSetting() throws Exception { + TestUser user = createRandomTestUser(List.of("student")); + String auth = generateTestAuthenticationHeader(user.universityId(), List.of("student")); + + String createPayload = objectMapper.writeValueAsString(Map.of( + "name", "new-applications", + "email", "old@example.com" + )); + + mockMvc.perform(MockMvcRequestBuilders.put("/v2/user-info/notifications") + .header("Authorization", auth) + .contentType(MediaType.APPLICATION_JSON) + .content(createPayload)) + .andExpect(status().isOk()); + + String updatePayload = objectMapper.writeValueAsString(Map.of( + "name", "new-applications", + "email", "new@example.com" + )); + + String response = mockMvc.perform(MockMvcRequestBuilders.put("/v2/user-info/notifications") + .header("Authorization", auth) + .contentType(MediaType.APPLICATION_JSON) + .content(updatePayload)) + .andExpect(status().isOk()) + .andReturn().getResponse().getContentAsString(); + + JsonNode json = objectMapper.readTree(response); + assertThat(json.size()).isEqualTo(1); + assertThat(json.get(0).get("email").asString()).isEqualTo("new@example.com"); + } + } +} diff --git a/server/src/test/java/de/tum/cit/aet/thesis/cron/CronJobIntegrationTest.java b/server/src/test/java/de/tum/cit/aet/thesis/cron/CronJobIntegrationTest.java new file mode 100644 index 000000000..e0dfac880 --- /dev/null +++ b/server/src/test/java/de/tum/cit/aet/thesis/cron/CronJobIntegrationTest.java @@ -0,0 +1,370 @@ +package de.tum.cit.aet.thesis.cron; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import de.tum.cit.aet.thesis.constants.ApplicationState; +import de.tum.cit.aet.thesis.controller.payload.CreateApplicationPayload; +import de.tum.cit.aet.thesis.controller.payload.ReplaceTopicPayload; +import de.tum.cit.aet.thesis.entity.Application; +import de.tum.cit.aet.thesis.entity.ResearchGroupSettings; +import de.tum.cit.aet.thesis.mock.BaseIntegrationTest; +import de.tum.cit.aet.thesis.repository.ApplicationRepository; +import de.tum.cit.aet.thesis.repository.ResearchGroupSettingsRepository; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.http.MediaType; +import org.springframework.test.context.DynamicPropertyRegistry; +import org.springframework.test.context.DynamicPropertySource; +import org.springframework.test.web.servlet.request.MockMvcRequestBuilders; +import org.springframework.transaction.support.TransactionTemplate; +import org.testcontainers.junit.jupiter.Testcontainers; + +import jakarta.mail.Address; +import jakarta.mail.internet.MimeMessage; +import jakarta.persistence.EntityManager; + +import java.time.Instant; +import java.time.temporal.ChronoUnit; +import java.util.Arrays; +import java.util.List; +import java.util.Set; +import java.util.UUID; +import java.util.stream.Stream; + +@Testcontainers +class CronJobIntegrationTest extends BaseIntegrationTest { + + @DynamicPropertySource + static void configureDynamicProperties(DynamicPropertyRegistry registry) { + configureProperties(registry); + } + + @Autowired + private AutomaticRejects automaticRejects; + + @Autowired + private ApplicationRepository applicationRepository; + + @Autowired + private ResearchGroupSettingsRepository researchGroupSettingsRepository; + + @Autowired + private EntityManager entityManager; + + @Autowired + private TransactionTemplate transactionTemplate; + + private record CronSetup(UUID topicId, UUID applicationId, UUID researchGroupId, TestUser advisor) {} + + private CronSetup createTopicWithOldApplicationAndAutoReject() throws Exception { + createTestEmailTemplate("APPLICATION_CREATED_CHAIR"); + createTestEmailTemplate("APPLICATION_CREATED_STUDENT"); + createTestEmailTemplate("APPLICATION_REJECTED"); + createTestEmailTemplate("APPLICATION_AUTOMATIC_REJECT_REMINDER"); + + TestUser advisor = createRandomTestUser(List.of("supervisor", "advisor")); + UUID researchGroupId = createTestResearchGroup("Cron RG", advisor.universityId()); + + // Enable automatic reject for this research group + ResearchGroupSettings settings = new ResearchGroupSettings(); + settings.setResearchGroupId(researchGroupId); + settings.setAutomaticRejectEnabled(true); + settings.setRejectDuration(2); // 2 weeks + researchGroupSettingsRepository.save(settings); + + // Create topic + ReplaceTopicPayload topicPayload = new ReplaceTopicPayload( + "Cron Topic", Set.of("MASTER"), + "PS", "Req", "Goals", "Refs", + List.of(advisor.userId()), List.of(advisor.userId()), + researchGroupId, null, null, false + ); + String topicResponse = mockMvc.perform(MockMvcRequestBuilders.post("/v2/topics") + .header("Authorization", createRandomAdminAuthentication()) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(topicPayload))) + .andExpect(status().isOk()) + .andReturn().getResponse().getContentAsString(); + UUID topicId = UUID.fromString(objectMapper.readTree(topicResponse).get("topicId").asString()); + + // Create application + String studentAuth = createRandomAuthentication("student"); + CreateApplicationPayload appPayload = new CreateApplicationPayload( + topicId, null, "MASTER", Instant.now(), "Cron test", null + ); + String appResponse = mockMvc.perform(MockMvcRequestBuilders.post("/v2/applications") + .header("Authorization", studentAuth) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(appPayload))) + .andExpect(status().isOk()) + .andReturn().getResponse().getContentAsString(); + UUID applicationId = UUID.fromString(objectMapper.readTree(appResponse).get("applicationId").asString()); + + // Backdate application to 30 days ago + transactionTemplate.executeWithoutResult(status -> { + entityManager.createNativeQuery("UPDATE applications SET created_at = :date WHERE application_id = :id") + .setParameter("date", Instant.now().minus(30, ChronoUnit.DAYS)) + .setParameter("id", applicationId) + .executeUpdate(); + entityManager.clear(); + }); + + return new CronSetup(topicId, applicationId, researchGroupId, advisor); + } + + @Nested + class RejectOldApplications { + @Test + void rejectOldApplications_WithAutoRejectEnabled_RejectsOldApplications() throws Exception { + CronSetup setup = createTopicWithOldApplicationAndAutoReject(); + + automaticRejects.rejectOldApplications(); + + Application app = applicationRepository.findById(setup.applicationId).orElseThrow(); + assertThat(app.getState()).isEqualTo(ApplicationState.REJECTED); + } + + @Test + void rejectOldApplications_WithRecentApplication_DoesNotReject() throws Exception { + createTestEmailTemplate("APPLICATION_CREATED_CHAIR"); + createTestEmailTemplate("APPLICATION_CREATED_STUDENT"); + createTestEmailTemplate("APPLICATION_REJECTED"); + createTestEmailTemplate("APPLICATION_AUTOMATIC_REJECT_REMINDER"); + + TestUser advisor = createRandomTestUser(List.of("supervisor", "advisor")); + UUID researchGroupId = createTestResearchGroup("Recent Cron RG", advisor.universityId()); + + ResearchGroupSettings settings = new ResearchGroupSettings(); + settings.setResearchGroupId(researchGroupId); + settings.setAutomaticRejectEnabled(true); + settings.setRejectDuration(2); + researchGroupSettingsRepository.save(settings); + + ReplaceTopicPayload topicPayload = new ReplaceTopicPayload( + "Recent Cron Topic", Set.of("MASTER"), + "PS", "Req", "Goals", "Refs", + List.of(advisor.userId()), List.of(advisor.userId()), + researchGroupId, null, null, false + ); + String topicResponse = mockMvc.perform(MockMvcRequestBuilders.post("/v2/topics") + .header("Authorization", createRandomAdminAuthentication()) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(topicPayload))) + .andExpect(status().isOk()) + .andReturn().getResponse().getContentAsString(); + + String studentAuth = createRandomAuthentication("student"); + CreateApplicationPayload appPayload = new CreateApplicationPayload( + UUID.fromString(objectMapper.readTree(topicResponse).get("topicId").asString()), + null, "MASTER", Instant.now(), "Recent cron test", null + ); + String appResponse = mockMvc.perform(MockMvcRequestBuilders.post("/v2/applications") + .header("Authorization", studentAuth) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(appPayload))) + .andExpect(status().isOk()) + .andReturn().getResponse().getContentAsString(); + UUID applicationId = UUID.fromString(objectMapper.readTree(appResponse).get("applicationId").asString()); + + automaticRejects.rejectOldApplications(); + + Application app = applicationRepository.findById(applicationId).orElseThrow(); + assertThat(app.getState()).isEqualTo(ApplicationState.NOT_ASSESSED); + } + + @Test + void rejectOldApplications_SendsReminderEmailForUpcomingRejects() throws Exception { + createTestEmailTemplate("APPLICATION_CREATED_CHAIR"); + createTestEmailTemplate("APPLICATION_CREATED_STUDENT"); + createTestEmailTemplate("APPLICATION_REJECTED"); + createTestEmailTemplate("APPLICATION_AUTOMATIC_REJECT_REMINDER"); + + TestUser advisor = createRandomTestUser(List.of("supervisor", "advisor")); + UUID researchGroupId = createTestResearchGroup("Reminder Cron RG", advisor.universityId()); + + ResearchGroupSettings settings = new ResearchGroupSettings(); + settings.setResearchGroupId(researchGroupId); + settings.setAutomaticRejectEnabled(true); + settings.setRejectDuration(4); // 4 weeks - application created 8 days ago won't be rejected but will be in "upcoming" list + researchGroupSettingsRepository.save(settings); + + ReplaceTopicPayload topicPayload = new ReplaceTopicPayload( + "Reminder Cron Topic", Set.of("MASTER"), + "PS", "Req", "Goals", "Refs", + List.of(advisor.userId()), List.of(advisor.userId()), + researchGroupId, null, null, false + ); + String topicResponse = mockMvc.perform(MockMvcRequestBuilders.post("/v2/topics") + .header("Authorization", createRandomAdminAuthentication()) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(topicPayload))) + .andExpect(status().isOk()) + .andReturn().getResponse().getContentAsString(); + UUID topicId = UUID.fromString(objectMapper.readTree(topicResponse).get("topicId").asString()); + + String studentAuth = createRandomAuthentication("student"); + CreateApplicationPayload appPayload = new CreateApplicationPayload( + topicId, null, "MASTER", Instant.now(), "Reminder cron test", null + ); + mockMvc.perform(MockMvcRequestBuilders.post("/v2/applications") + .header("Authorization", studentAuth) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(appPayload))) + .andExpect(status().isOk()); + + // Backdate application to 21 days ago (will be rejected in 7 days with 4-week duration) + transactionTemplate.executeWithoutResult(status -> { + entityManager.createNativeQuery( + "UPDATE applications SET created_at = :date WHERE application_id IN " + + "(SELECT application_id FROM applications WHERE topic_id = :topicId)") + .setParameter("date", Instant.now().minus(21, ChronoUnit.DAYS)) + .setParameter("topicId", topicId) + .executeUpdate(); + entityManager.clear(); + }); + + clearEmails(); + + automaticRejects.rejectOldApplications(); + + // Reminder email should have been sent to the advisor + MimeMessage[] emails = getReceivedEmails(); + assertThat(emails.length).as("At least one reminder email should be sent").isGreaterThanOrEqualTo(1); + + List recipients = Stream.of(emails) + .flatMap(email -> { + try { + return Arrays.stream(email.getAllRecipients()); + } catch (Exception e) { + return Stream.empty(); + } + }) + .map(Address::toString) + .toList(); + assertThat(recipients).as("Advisor should receive a reminder email") + .anyMatch(addr -> addr.contains(advisor.universityId())); + } + + @Test + void rejectOldApplications_WithTopicDeadline_UsesDeadlineAsReference() throws Exception { + createTestEmailTemplate("APPLICATION_CREATED_CHAIR"); + createTestEmailTemplate("APPLICATION_CREATED_STUDENT"); + createTestEmailTemplate("APPLICATION_REJECTED"); + createTestEmailTemplate("APPLICATION_AUTOMATIC_REJECT_REMINDER"); + + TestUser advisor = createRandomTestUser(List.of("supervisor", "advisor")); + UUID researchGroupId = createTestResearchGroup("Deadline Cron RG", advisor.universityId()); + + ResearchGroupSettings settings = new ResearchGroupSettings(); + settings.setResearchGroupId(researchGroupId); + settings.setAutomaticRejectEnabled(true); + settings.setRejectDuration(2); + researchGroupSettingsRepository.save(settings); + + // Create topic with an application deadline in the past + ReplaceTopicPayload topicPayload = new ReplaceTopicPayload( + "Deadline Cron Topic", Set.of("MASTER"), + "PS", "Req", "Goals", "Refs", + List.of(advisor.userId()), List.of(advisor.userId()), + researchGroupId, null, Instant.now().minus(20, ChronoUnit.DAYS), false + ); + String topicResponse = mockMvc.perform(MockMvcRequestBuilders.post("/v2/topics") + .header("Authorization", createRandomAdminAuthentication()) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(topicPayload))) + .andExpect(status().isOk()) + .andReturn().getResponse().getContentAsString(); + UUID topicId = UUID.fromString(objectMapper.readTree(topicResponse).get("topicId").asString()); + + String studentAuth = createRandomAuthentication("student"); + CreateApplicationPayload appPayload = new CreateApplicationPayload( + topicId, null, "MASTER", Instant.now(), "Deadline test", null + ); + String appResponse = mockMvc.perform(MockMvcRequestBuilders.post("/v2/applications") + .header("Authorization", studentAuth) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(appPayload))) + .andExpect(status().isOk()) + .andReturn().getResponse().getContentAsString(); + UUID applicationId = UUID.fromString(objectMapper.readTree(appResponse).get("applicationId").asString()); + + // Backdate the application to satisfy the 14-day minimum age requirement + transactionTemplate.executeWithoutResult(status -> { + entityManager.createNativeQuery("UPDATE applications SET created_at = :date WHERE application_id = :id") + .setParameter("date", Instant.now().minus(30, ChronoUnit.DAYS)) + .setParameter("id", applicationId) + .executeUpdate(); + entityManager.clear(); + }); + + automaticRejects.rejectOldApplications(); + + // Application should be rejected because the topic deadline is 20 days in the past + // and rejectDuration is 2 weeks (14 days), and application is older than 14 days + Application app = applicationRepository.findById(applicationId).orElseThrow(); + assertThat(app.getState()).isEqualTo(ApplicationState.REJECTED); + } + + @Test + void rejectOldApplications_WithNoAutoRejectEnabled_DoesNothing() throws Exception { + createTestEmailTemplate("APPLICATION_CREATED_CHAIR"); + createTestEmailTemplate("APPLICATION_CREATED_STUDENT"); + + TestUser advisor = createRandomTestUser(List.of("supervisor", "advisor")); + UUID researchGroupId = createTestResearchGroup("No Auto RG", advisor.universityId()); + + // Settings with auto-reject DISABLED + ResearchGroupSettings settings = new ResearchGroupSettings(); + settings.setResearchGroupId(researchGroupId); + settings.setAutomaticRejectEnabled(false); + settings.setRejectDuration(2); + researchGroupSettingsRepository.save(settings); + + ReplaceTopicPayload topicPayload = new ReplaceTopicPayload( + "No Auto Topic", Set.of("MASTER"), + "PS", "Req", "Goals", "Refs", + List.of(advisor.userId()), List.of(advisor.userId()), + researchGroupId, null, null, false + ); + String topicResponse = mockMvc.perform(MockMvcRequestBuilders.post("/v2/topics") + .header("Authorization", createRandomAdminAuthentication()) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(topicPayload))) + .andExpect(status().isOk()) + .andReturn().getResponse().getContentAsString(); + UUID topicId = UUID.fromString(objectMapper.readTree(topicResponse).get("topicId").asString()); + + String studentAuth = createRandomAuthentication("student"); + CreateApplicationPayload appPayload = new CreateApplicationPayload( + topicId, null, "MASTER", Instant.now(), "No auto test", null + ); + String appResponse = mockMvc.perform(MockMvcRequestBuilders.post("/v2/applications") + .header("Authorization", studentAuth) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(appPayload))) + .andExpect(status().isOk()) + .andReturn().getResponse().getContentAsString(); + UUID applicationId = UUID.fromString(objectMapper.readTree(appResponse).get("applicationId").asString()); + + // Backdate + transactionTemplate.executeWithoutResult(status -> { + entityManager.createNativeQuery("UPDATE applications SET created_at = :date WHERE application_id = :id") + .setParameter("date", Instant.now().minus(30, ChronoUnit.DAYS)) + .setParameter("id", applicationId) + .executeUpdate(); + entityManager.clear(); + }); + + automaticRejects.rejectOldApplications(); + + Application app = applicationRepository.findById(applicationId).orElseThrow(); + assertThat(app.getState()).isEqualTo(ApplicationState.NOT_ASSESSED); + } + } + + // Note: ApplicationReminder.emailReminder() cannot be tested directly because it calls + // ResearchGroupService.getAll() which requires the request-scoped CurrentUserProvider bean. + // Calling the cron method outside of a request context throws ScopeNotActiveException. +} diff --git a/server/src/test/java/de/tum/cit/aet/thesis/keycloak/AccessManagementServiceIntegrationTest.java b/server/src/test/java/de/tum/cit/aet/thesis/keycloak/AccessManagementServiceIntegrationTest.java index 25a5ff5f4..e7dc176e2 100644 --- a/server/src/test/java/de/tum/cit/aet/thesis/keycloak/AccessManagementServiceIntegrationTest.java +++ b/server/src/test/java/de/tum/cit/aet/thesis/keycloak/AccessManagementServiceIntegrationTest.java @@ -42,18 +42,10 @@ void resetKeycloakState() { .clients().findByClientId("thesis-management-app").get(0).getId(); // Reset "student" user's client roles back to only "student" - String studentKeycloakId = adminClient.realm("thesis-management") - .users().search("student", true).get(0).getId(); - List currentRoles = adminClient.realm("thesis-management") - .users().get(studentKeycloakId).roles().clientLevel(appClientUuid).listAll(); - if (!currentRoles.isEmpty()) { - adminClient.realm("thesis-management") - .users().get(studentKeycloakId).roles().clientLevel(appClientUuid).remove(currentRoles); - } - RoleRepresentation studentRole = adminClient.realm("thesis-management") - .clients().get(appClientUuid).roles().get("student").toRepresentation(); - adminClient.realm("thesis-management") - .users().get(studentKeycloakId).roles().clientLevel(appClientUuid).add(List.of(studentRole)); + resetUserRoles(adminClient, appClientUuid, "student", List.of("student")); + + // Reset "advisor" user's client roles back to only "advisor" + resetUserRoles(adminClient, appClientUuid, "advisor", List.of("advisor")); // Remove "advisor" user from thesis-students group if present String advisorKeycloakId = adminClient.realm("thesis-management") @@ -67,6 +59,23 @@ void resetKeycloakState() { .users().get(advisorKeycloakId).leaveGroup(g.getId())); } + private void resetUserRoles(Keycloak adminClient, String appClientUuid, String username, List expectedRoles) { + String keycloakUserId = adminClient.realm("thesis-management") + .users().search(username, true).get(0).getId(); + List currentRoles = adminClient.realm("thesis-management") + .users().get(keycloakUserId).roles().clientLevel(appClientUuid).listAll(); + if (!currentRoles.isEmpty()) { + adminClient.realm("thesis-management") + .users().get(keycloakUserId).roles().clientLevel(appClientUuid).remove(currentRoles); + } + List rolesToAssign = expectedRoles.stream() + .map(roleName -> adminClient.realm("thesis-management") + .clients().get(appClientUuid).roles().get(roleName).toRepresentation()) + .toList(); + adminClient.realm("thesis-management") + .users().get(keycloakUserId).roles().clientLevel(appClientUuid).add(rolesToAssign); + } + private User createLocalUser(String username) throws Exception { mockMvc.perform(MockMvcRequestBuilders.get("/v2/user-info") .header("Authorization", authHeaderFor(username))) @@ -157,6 +166,83 @@ void testAssignAdvisorRole() throws Exception { assertFalse(roleNames.contains("student")); } + @Test + void testAssignGroupAdminRole() throws Exception { + User advisor = createLocalUser("advisor"); + + accessManagementService.assignGroupAdminRole(advisor); + + Keycloak adminClient = KEYCLOAK_CONTAINER.getKeycloakAdminClient(); + String keycloakUserId = adminClient.realm("thesis-management") + .users().search("advisor", true).get(0).getId(); + String appClientUuid = adminClient.realm("thesis-management") + .clients().findByClientId("thesis-management-app").get(0).getId(); + List roles = adminClient.realm("thesis-management") + .users().get(keycloakUserId).roles().clientLevel(appClientUuid).listAll(); + + Set roleNames = roles.stream().map(RoleRepresentation::getName).collect(Collectors.toSet()); + assertTrue(roleNames.contains("group-admin")); + assertTrue(roleNames.contains("advisor"), "Original advisor role should be preserved"); + } + + @Test + void testRemoveGroupAdminRole() throws Exception { + User advisor = createLocalUser("advisor"); + + // First assign group-admin, then remove it + accessManagementService.assignGroupAdminRole(advisor); + accessManagementService.removeGroupAdminRole(advisor); + + Keycloak adminClient = KEYCLOAK_CONTAINER.getKeycloakAdminClient(); + String keycloakUserId = adminClient.realm("thesis-management") + .users().search("advisor", true).get(0).getId(); + String appClientUuid = adminClient.realm("thesis-management") + .clients().findByClientId("thesis-management-app").get(0).getId(); + List roles = adminClient.realm("thesis-management") + .users().get(keycloakUserId).roles().clientLevel(appClientUuid).listAll(); + + Set roleNames = roles.stream().map(RoleRepresentation::getName).collect(Collectors.toSet()); + assertFalse(roleNames.contains("group-admin")); + } + + @Test + void testRemoveResearchGroupRoles() throws Exception { + User student = createLocalUser("student"); + + // First assign supervisor role so the user has research group roles + accessManagementService.assignSupervisorRole(student); + + // Now remove all research group roles + accessManagementService.removeResearchGroupRoles(student); + + Keycloak adminClient = KEYCLOAK_CONTAINER.getKeycloakAdminClient(); + String keycloakUserId = adminClient.realm("thesis-management") + .users().search("student", true).get(0).getId(); + String appClientUuid = adminClient.realm("thesis-management") + .clients().findByClientId("thesis-management-app").get(0).getId(); + List roles = adminClient.realm("thesis-management") + .users().get(keycloakUserId).roles().clientLevel(appClientUuid).listAll(); + + Set roleNames = roles.stream().map(RoleRepresentation::getName).collect(Collectors.toSet()); + assertTrue(roleNames.contains("student"), "Student role should be reassigned"); + assertFalse(roleNames.contains("advisor"), "Advisor role should be removed"); + assertFalse(roleNames.contains("supervisor"), "Supervisor role should be removed"); + assertFalse(roleNames.contains("group-admin"), "Group-admin role should be removed"); + } + + @Test + void testGetAllUsers() { + List users = accessManagementService.getAllUsers("student"); + + assertNotNull(users); + assertFalse(users.isEmpty(), "Should find at least one user matching 'student'"); + + Set usernames = users.stream() + .map(KeycloakUserInformation::username) + .collect(Collectors.toSet()); + assertTrue(usernames.contains("student"), "Results should include the 'student' user"); + } + @Test void testAddAndRemoveStudentGroup() throws Exception { User advisor = createLocalUser("advisor"); diff --git a/server/src/test/java/de/tum/cit/aet/thesis/keycloak/BaseKeycloakIntegrationTest.java b/server/src/test/java/de/tum/cit/aet/thesis/keycloak/BaseKeycloakIntegrationTest.java index 96c2a613e..beee11fc1 100644 --- a/server/src/test/java/de/tum/cit/aet/thesis/keycloak/BaseKeycloakIntegrationTest.java +++ b/server/src/test/java/de/tum/cit/aet/thesis/keycloak/BaseKeycloakIntegrationTest.java @@ -1,7 +1,5 @@ package de.tum.cit.aet.thesis.keycloak; -import com.fasterxml.jackson.databind.JsonNode; -import com.fasterxml.jackson.databind.ObjectMapper; import dasniko.testcontainers.keycloak.KeycloakContainer; import de.tum.cit.aet.thesis.repository.ApplicationRepository; import de.tum.cit.aet.thesis.repository.ApplicationReviewerRepository; @@ -25,13 +23,15 @@ import org.keycloak.admin.client.Keycloak; import org.keycloak.representations.idm.CredentialRepresentation; import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc; import org.springframework.test.context.ActiveProfiles; import org.springframework.test.context.DynamicPropertyRegistry; import org.springframework.test.context.DynamicPropertySource; import org.springframework.test.web.servlet.MockMvc; import org.testcontainers.postgresql.PostgreSQLContainer; +import tools.jackson.databind.JsonNode; +import tools.jackson.databind.ObjectMapper; import java.net.URI; import java.net.URLEncoder; @@ -195,7 +195,7 @@ protected String obtainAccessToken(String username, String password) { if (tokenNode == null || tokenNode.isNull()) { throw new RuntimeException("No access_token in response: " + response.body()); } - return tokenNode.asText(); + return tokenNode.asString(); } catch (RuntimeException e) { throw e; } catch (Exception e) { diff --git a/server/src/test/java/de/tum/cit/aet/thesis/mailvariables/MailVariablesBuilderTest.java b/server/src/test/java/de/tum/cit/aet/thesis/mailvariables/MailVariablesBuilderTest.java new file mode 100644 index 000000000..d41b5cbeb --- /dev/null +++ b/server/src/test/java/de/tum/cit/aet/thesis/mailvariables/MailVariablesBuilderTest.java @@ -0,0 +1,252 @@ +package de.tum.cit.aet.thesis.mailvariables; + +import static org.assertj.core.api.Assertions.assertThat; + +import de.tum.cit.aet.thesis.dto.MailVariableDto; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; + +import java.util.List; + +class MailVariablesBuilderTest { + + private final MailVariablesBuilder builder = new MailVariablesBuilder(); + + @Nested + class ApplicationAccepted { + @ParameterizedTest + @ValueSource(strings = {"APPLICATION_ACCEPTED", "APPLICATION_ACCEPTED_NO_ADVISOR"}) + void returnsApplicationAndThesisAndUserVariables(String templateCase) { + List variables = builder.getMailVariables(templateCase); + + assertThat(variables).isNotEmpty(); + assertThat(variables).anyMatch(v -> v.group().equals("Application")); + assertThat(variables).anyMatch(v -> v.group().equals("Thesis")); + assertThat(variables).anyMatch(v -> v.group().equals("User")); + assertThat(variables).anyMatch(v -> v.group().equals("General")); + } + } + + @Nested + class ApplicationRejected { + @ParameterizedTest + @ValueSource(strings = { + "APPLICATION_REJECTED_TOPIC_REQUIREMENTS", + "APPLICATION_REJECTED_TOPIC_OUTDATED", + "APPLICATION_REJECTED_TITLE_NOT_INTERESTING", + "APPLICATION_REJECTED", + "APPLICATION_REJECTED_TOPIC_FILLED", + "APPLICATION_REJECTED_STUDENT_REQUIREMENTS", + "APPLICATION_CREATED_STUDENT", + "APPLICATION_CREATED_CHAIR" + }) + void returnsApplicationVariables(String templateCase) { + List variables = builder.getMailVariables(templateCase); + + assertThat(variables).isNotEmpty(); + assertThat(variables).anyMatch(v -> v.group().equals("Application")); + assertThat(variables).anyMatch(v -> v.group().equals("General")); + } + } + + @Nested + class ThesisCreatedAndClosed { + @ParameterizedTest + @ValueSource(strings = {"THESIS_CREATED", "THESIS_CLOSED"}) + void returnsThesisAndUserVariables(String templateCase) { + List variables = builder.getMailVariables(templateCase); + + assertThat(variables).isNotEmpty(); + assertThat(variables).anyMatch(v -> v.group().equals("Thesis")); + assertThat(variables).anyMatch(v -> v.group().equals("User")); + } + + @Test + void thesisCreated_HasCreatingUserVariables() { + List variables = builder.getMailVariables("THESIS_CREATED"); + + assertThat(variables).anyMatch(v -> v.templateVariable().contains("creatingUser")); + } + + @Test + void thesisClosed_HasDeletingUserVariables() { + List variables = builder.getMailVariables("THESIS_CLOSED"); + + assertThat(variables).anyMatch(v -> v.templateVariable().contains("deletingUser")); + } + } + + @Nested + class ThesisProposals { + @ParameterizedTest + @ValueSource(strings = {"THESIS_PROPOSAL_UPLOADED", "THESIS_PROPOSAL_REJECTED", "THESIS_PROPOSAL_ACCEPTED"}) + void returnsProposalAndThesisAndUserVariables(String templateCase) { + List variables = builder.getMailVariables(templateCase); + + assertThat(variables).isNotEmpty(); + assertThat(variables).anyMatch(v -> v.group().equals("Proposal")); + assertThat(variables).anyMatch(v -> v.group().equals("Thesis")); + } + + @Test + void proposalRejected_HasRequestedChangesVariable() { + List variables = builder.getMailVariables("THESIS_PROPOSAL_REJECTED"); + + assertThat(variables).anyMatch(v -> v.templateVariable().contains("requestedChanges")); + assertThat(variables).anyMatch(v -> v.templateVariable().contains("reviewingUser")); + } + } + + @Nested + class ThesisPresentation { + @ParameterizedTest + @ValueSource(strings = { + "THESIS_PRESENTATION_UPDATED", + "THESIS_PRESENTATION_SCHEDULED", + "THESIS_PRESENTATION_INVITATION_CANCELLED", + "THESIS_PRESENTATION_INVITATION_UPDATED", + "THESIS_PRESENTATION_INVITATION" + }) + void returnsPresentationAndThesisVariables(String templateCase) { + List variables = builder.getMailVariables(templateCase); + + assertThat(variables).isNotEmpty(); + assertThat(variables).anyMatch(v -> v.group().equals("Presentation")); + assertThat(variables).anyMatch(v -> v.group().equals("Thesis")); + } + + @Test + void presentationDeleted_HasDeletingUserVariables() { + List variables = builder.getMailVariables("THESIS_PRESENTATION_DELETED"); + + assertThat(variables).anyMatch(v -> v.group().equals("Presentation")); + assertThat(variables).anyMatch(v -> v.group().equals("Thesis")); + assertThat(variables).anyMatch(v -> v.templateVariable().contains("deletingUser")); + } + } + + @Nested + class ThesisFinalSubmission { + @Test + void returnsThesisVariables() { + List variables = builder.getMailVariables("THESIS_FINAL_SUBMISSION"); + + assertThat(variables).isNotEmpty(); + assertThat(variables).anyMatch(v -> v.group().equals("Thesis")); + } + } + + @Nested + class ThesisFinalGrade { + @Test + void returnsThesisAndGradeVariables() { + List variables = builder.getMailVariables("THESIS_FINAL_GRADE"); + + assertThat(variables).isNotEmpty(); + assertThat(variables).anyMatch(v -> v.group().equals("Thesis")); + assertThat(variables).anyMatch(v -> v.templateVariable().contains("finalGrade")); + } + } + + @Nested + class ThesisAssessment { + @Test + void returnsAssessmentAndThesisVariables() { + List variables = builder.getMailVariables("THESIS_ASSESSMENT_ADDED"); + + assertThat(variables).isNotEmpty(); + assertThat(variables).anyMatch(v -> v.group().equals("Assessment")); + assertThat(variables).anyMatch(v -> v.group().equals("Thesis")); + } + } + + @Nested + class ThesisComment { + @Test + void returnsCommentAndThesisVariables() { + List variables = builder.getMailVariables("THESIS_COMMENT_POSTED"); + + assertThat(variables).isNotEmpty(); + assertThat(variables).anyMatch(v -> v.group().equals("Thesis Comment")); + assertThat(variables).anyMatch(v -> v.group().equals("Thesis")); + } + } + + @Nested + class ApplicationReminder { + @Test + void returnsReminderVariables() { + List variables = builder.getMailVariables("APPLICATION_REMINDER"); + + assertThat(variables).isNotEmpty(); + assertThat(variables).anyMatch(v -> v.group().equals("Application Reminder")); + } + } + + @Nested + class AutomaticRejectReminder { + @Test + void returnsAutomaticRejectReminderVariables() { + List variables = builder.getMailVariables("APPLICATION_AUTOMATIC_REJECT_REMINDER"); + + assertThat(variables).isNotEmpty(); + assertThat(variables).anyMatch(v -> v.group().equals("Application Reminder")); + assertThat(variables).anyMatch(v -> v.templateVariable().contains("applications")); + assertThat(variables).anyMatch(v -> v.templateVariable().contains("clientHost")); + } + } + + @Nested + class InterviewCases { + @ParameterizedTest + @ValueSource(strings = {"INTERVIEW_INVITATION", "INTERVIEW_INVITATION_REMINDER"}) + void returnsApplicationAndInterviewVariables(String templateCase) { + List variables = builder.getMailVariables(templateCase); + + assertThat(variables).isNotEmpty(); + assertThat(variables).anyMatch(v -> v.group().equals("Application")); + assertThat(variables).anyMatch(v -> v.group().equals("Interview")); + } + + @ParameterizedTest + @ValueSource(strings = {"INTERVIEW_SLOT_BOOKED_CONFORMATION", "INTERVIEW_SLOT_BOOKED_CANCELLATION"}) + void returnsApplicationAndInterviewAndSlotVariables(String templateCase) { + List variables = builder.getMailVariables(templateCase); + + assertThat(variables).isNotEmpty(); + assertThat(variables).anyMatch(v -> v.group().equals("Application")); + assertThat(variables).anyMatch(v -> v.group().equals("Interview")); + assertThat(variables).anyMatch(v -> v.group().equals("Interview Slot")); + } + } + + @Nested + class DefaultCase { + @Test + void unknownTemplateCase_ReturnsOnlyGeneralVariables() { + List variables = builder.getMailVariables("UNKNOWN_TEMPLATE_CASE"); + + assertThat(variables).isNotEmpty(); + assertThat(variables).allMatch(v -> v.group().equals("General")); + } + } + + @Nested + class GeneralVariables { + @Test + void allCases_IncludeRecipientVariables() { + List variables = builder.getMailVariables("APPLICATION_ACCEPTED"); + + assertThat(variables).anyMatch(v -> v.templateVariable().contains("recipient")); + } + + @Test + void allCases_IncludeNotificationUrlFooter() { + List variables = builder.getMailVariables("APPLICATION_ACCEPTED"); + + assertThat(variables).anyMatch(v -> v.label().equals("Notification URL Footer")); + } + } +} diff --git a/server/src/test/java/de/tum/cit/aet/thesis/mock/BaseIntegrationTest.java b/server/src/test/java/de/tum/cit/aet/thesis/mock/BaseIntegrationTest.java index d34b6bf24..3dc823788 100644 --- a/server/src/test/java/de/tum/cit/aet/thesis/mock/BaseIntegrationTest.java +++ b/server/src/test/java/de/tum/cit/aet/thesis/mock/BaseIntegrationTest.java @@ -2,7 +2,11 @@ import com.auth0.jwt.JWT; import com.auth0.jwt.algorithms.Algorithm; -import com.fasterxml.jackson.databind.ObjectMapper; +import com.github.tomakehurst.wiremock.WireMockServer; +import com.github.tomakehurst.wiremock.client.WireMock; +import com.github.tomakehurst.wiremock.core.WireMockConfiguration; +import com.icegreen.greenmail.util.GreenMail; +import com.icegreen.greenmail.util.ServerSetup; import com.jayway.jsonpath.JsonPath; import de.tum.cit.aet.thesis.controller.payload.CreateApplicationPayload; import de.tum.cit.aet.thesis.controller.payload.CreateThesisPayload; @@ -10,7 +14,11 @@ import de.tum.cit.aet.thesis.repository.ApplicationRepository; import de.tum.cit.aet.thesis.repository.ApplicationReviewerRepository; import de.tum.cit.aet.thesis.repository.EmailTemplateRepository; +import de.tum.cit.aet.thesis.repository.InterviewProcessRepository; +import de.tum.cit.aet.thesis.repository.IntervieweeRepository; import de.tum.cit.aet.thesis.repository.NotificationSettingRepository; +import de.tum.cit.aet.thesis.repository.ResearchGroupRepository; +import de.tum.cit.aet.thesis.repository.ResearchGroupSettingsRepository; import de.tum.cit.aet.thesis.repository.ThesisAssessmentRepository; import de.tum.cit.aet.thesis.repository.ThesisCommentRepository; import de.tum.cit.aet.thesis.repository.ThesisFeedbackRepository; @@ -29,15 +37,19 @@ import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.TestInstance; import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc; import org.springframework.context.annotation.Import; import org.springframework.http.MediaType; +import org.springframework.jdbc.core.JdbcTemplate; import org.springframework.test.context.ActiveProfiles; import org.springframework.test.context.DynamicPropertyRegistry; import org.springframework.test.web.servlet.MockMvc; import org.springframework.test.web.servlet.request.MockMvcRequestBuilders; import org.testcontainers.postgresql.PostgreSQLContainer; +import tools.jackson.databind.ObjectMapper; + +import jakarta.mail.internet.MimeMessage; import java.time.Instant; import java.util.HashMap; @@ -59,6 +71,12 @@ public abstract class BaseIntegrationTest { @Autowired private ApplicationReviewerRepository applicationReviewerRepository; + @Autowired + private IntervieweeRepository intervieweeRepository; + + @Autowired + private InterviewProcessRepository interviewProcessRepository; + @Autowired private EmailTemplateRepository emailTemplateRepository; @@ -101,6 +119,12 @@ public abstract class BaseIntegrationTest { @Autowired private TopicRoleRepository topicRoleRepository; + @Autowired + private ResearchGroupRepository researchGroupRepository; + + @Autowired + private ResearchGroupSettingsRepository researchGroupSettingsRepository; + @Autowired private UserGroupRepository userGroupRepository; @@ -116,7 +140,17 @@ public abstract class BaseIntegrationTest { @Autowired private AccessManagementService accessManagementService; - protected static PostgreSQLContainer dbContainer = new PostgreSQLContainer("postgres:17.8-alpine"); + @Autowired + private JdbcTemplate jdbcTemplate; + + protected static PostgreSQLContainer dbContainer = new PostgreSQLContainer("postgres:17.8-alpine") + .withCommand("postgres", "-c", "max_connections=200"); + + protected static GreenMail greenMail; + + protected static WireMockServer wireMockServer; + + private static final String EMPTY_ICAL = "BEGIN:VCALENDAR\r\nVERSION:2.0\r\nPRODID:-//Test//Test//EN\r\nCALSCALE:GREGORIAN\r\nEND:VCALENDAR\r\n"; protected static void configureProperties(DynamicPropertyRegistry registry) { dbContainer.start(); @@ -124,6 +158,31 @@ protected static void configureProperties(DynamicPropertyRegistry registry) { registry.add("spring.datasource.url", dbContainer::getJdbcUrl); registry.add("spring.datasource.username", dbContainer::getUsername); registry.add("spring.datasource.password", dbContainer::getPassword); + + if (greenMail == null) { + greenMail = new GreenMail(new ServerSetup(0, "127.0.0.1", ServerSetup.PROTOCOL_SMTP)); + greenMail.start(); + } + + registry.add("spring.mail.host", () -> "127.0.0.1"); + registry.add("spring.mail.port", () -> greenMail.getSmtp().getPort()); + + if (wireMockServer == null) { + wireMockServer = new WireMockServer(WireMockConfiguration.wireMockConfig().dynamicPort()); + wireMockServer.start(); + + wireMockServer.stubFor(WireMock.get(WireMock.urlEqualTo("/")) + .willReturn(WireMock.aResponse() + .withStatus(200) + .withHeader("Content-Type", "text/calendar") + .withBody(EMPTY_ICAL))); + + wireMockServer.stubFor(WireMock.put(WireMock.urlEqualTo("/")) + .willReturn(WireMock.aResponse() + .withStatus(204))); + } + + registry.add("thesis-management.calendar.url", () -> wireMockServer.baseUrl()); } // Deletion order matters: child tables with foreign keys must be deleted before parent tables. @@ -144,12 +203,38 @@ void deleteDatabase() { thesisRoleRepository.deleteAll(); topicRoleRepository.deleteAll(); + // Interview tables must be cleaned before topics/applications due to FK constraints + intervieweeRepository.deleteAll(); + interviewProcessRepository.deleteAll(); + thesisRepository.deleteAll(); applicationRepository.deleteAll(); topicRepository.deleteAll(); - userGroupRepository.deleteAll(); + // Break circular FK between User and ResearchGroup before deletion + jdbcTemplate.execute("UPDATE users SET research_group_id = NULL"); + jdbcTemplate.execute("UPDATE research_groups SET head_user_id = NULL, created_by = NULL, updated_by = NULL"); + + researchGroupSettingsRepository.deleteAll(); + userGroupRepository.deleteAll(); + researchGroupRepository.deleteAll(); userRepository.deleteAll(); + + clearEmails(); + } + + protected MimeMessage[] getReceivedEmails() { + return greenMail.getReceivedMessages(); + } + + protected void clearEmails() { + if (greenMail != null) { + try { + greenMail.purgeEmailFromAllMailboxes(); + } catch (com.icegreen.greenmail.store.FolderException e) { + throw new RuntimeException(e); + } + } } protected String createRandomAuthentication(String role) throws Exception { @@ -268,7 +353,7 @@ protected UUID createTestTopic(String title) throws Exception { .getResponse() .getContentAsString(); - return objectMapper.readTree(response).get("topicId").asText().transform(UUID::fromString); + return objectMapper.readTree(response).get("topicId").asString().transform(UUID::fromString); } protected UUID createTestThesis(String title) throws Exception { @@ -294,7 +379,7 @@ protected UUID createTestThesis(String title) throws Exception { .getResponse() .getContentAsString(); - return objectMapper.readTree(response).get("thesisId").asText().transform(UUID::fromString); + return objectMapper.readTree(response).get("thesisId").asString().transform(UUID::fromString); } protected void createTestEmailTemplate(String templateCase) throws Exception { diff --git a/server/src/test/java/de/tum/cit/aet/thesis/service/ApplicationServiceIntegrationTest.java b/server/src/test/java/de/tum/cit/aet/thesis/service/ApplicationServiceIntegrationTest.java new file mode 100644 index 000000000..0b6841bd8 --- /dev/null +++ b/server/src/test/java/de/tum/cit/aet/thesis/service/ApplicationServiceIntegrationTest.java @@ -0,0 +1,338 @@ +package de.tum.cit.aet.thesis.service; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import de.tum.cit.aet.thesis.constants.ApplicationState; +import de.tum.cit.aet.thesis.controller.payload.CreateApplicationPayload; +import de.tum.cit.aet.thesis.controller.payload.ReplaceTopicPayload; +import de.tum.cit.aet.thesis.cron.model.ApplicationRejectObject; +import de.tum.cit.aet.thesis.entity.Application; +import de.tum.cit.aet.thesis.entity.Topic; +import de.tum.cit.aet.thesis.mock.BaseIntegrationTest; +import de.tum.cit.aet.thesis.repository.ApplicationRepository; +import de.tum.cit.aet.thesis.repository.TopicRepository; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.http.MediaType; +import org.springframework.test.context.DynamicPropertyRegistry; +import org.springframework.test.context.DynamicPropertySource; +import org.springframework.test.web.servlet.request.MockMvcRequestBuilders; +import org.springframework.transaction.support.TransactionTemplate; +import org.testcontainers.junit.jupiter.Testcontainers; + +import jakarta.persistence.EntityManager; + +import java.time.Instant; +import java.time.temporal.ChronoUnit; +import java.util.List; +import java.util.Set; +import java.util.UUID; + +@Testcontainers +class ApplicationServiceIntegrationTest extends BaseIntegrationTest { + + @DynamicPropertySource + static void configureDynamicProperties(DynamicPropertyRegistry registry) { + configureProperties(registry); + } + + @Autowired + private ApplicationService applicationService; + + @Autowired + private ApplicationRepository applicationRepository; + + @Autowired + private TopicRepository topicRepository; + + @Autowired + private EntityManager entityManager; + + @Autowired + private TransactionTemplate transactionTemplate; + + private record TopicAppSetup(UUID topicId, UUID applicationId, UUID researchGroupId, TestUser advisor) {} + + private TopicAppSetup createTopicWithOldApplication() throws Exception { + createTestEmailTemplate("APPLICATION_CREATED_CHAIR"); + createTestEmailTemplate("APPLICATION_CREATED_STUDENT"); + createTestEmailTemplate("APPLICATION_REJECTED"); + + TestUser advisor = createRandomTestUser(List.of("supervisor", "advisor")); + UUID researchGroupId = createTestResearchGroup("Auto Reject RG", advisor.universityId()); + + ReplaceTopicPayload topicPayload = new ReplaceTopicPayload( + "Auto Reject Topic", Set.of("MASTER"), + "PS", "Req", "Goals", "Refs", + List.of(advisor.userId()), List.of(advisor.userId()), + researchGroupId, null, null, false + ); + String topicResponse = mockMvc.perform(MockMvcRequestBuilders.post("/v2/topics") + .header("Authorization", createRandomAdminAuthentication()) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(topicPayload))) + .andExpect(status().isOk()) + .andReturn().getResponse().getContentAsString(); + UUID topicId = UUID.fromString(objectMapper.readTree(topicResponse).get("topicId").asString()); + + // Create application + String studentAuth = createRandomAuthentication("student"); + CreateApplicationPayload appPayload = new CreateApplicationPayload( + topicId, null, "MASTER", Instant.now(), "Auto reject test", null + ); + String appResponse = mockMvc.perform(MockMvcRequestBuilders.post("/v2/applications") + .header("Authorization", studentAuth) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(appPayload))) + .andExpect(status().isOk()) + .andReturn().getResponse().getContentAsString(); + UUID applicationId = UUID.fromString(objectMapper.readTree(appResponse).get("applicationId").asString()); + + // Backdate the application to make it old enough for auto-reject + // Must use native query because @CreationTimestamp prevents JPA from updating createdAt + transactionTemplate.executeWithoutResult(status -> { + entityManager.createNativeQuery("UPDATE applications SET created_at = :date WHERE application_id = :id") + .setParameter("date", Instant.now().minus(30, ChronoUnit.DAYS)) + .setParameter("id", applicationId) + .executeUpdate(); + entityManager.clear(); + }); + + return new TopicAppSetup(topicId, applicationId, researchGroupId, advisor); + } + + @Nested + class RejectAllApplicationsAutomatically { + @Test + void rejectOldApplications_Success() throws Exception { + TopicAppSetup setup = createTopicWithOldApplication(); + Topic topic = topicRepository.findById(setup.topicId).orElseThrow(); + + // afterDuration=2 weeks, referenceDate=30 days ago + applicationService.rejectAllApplicationsAutomatically( + topic, 2, + Instant.now().minus(30, ChronoUnit.DAYS), + setup.researchGroupId + ); + + Application app = applicationRepository.findById(setup.applicationId).orElseThrow(); + assertThat(app.getState()).isEqualTo(ApplicationState.REJECTED); + } + + @Test + void rejectOldApplications_WithNullReferenceDate_UsesCreatedAt() throws Exception { + TopicAppSetup setup = createTopicWithOldApplication(); + Topic topic = topicRepository.findById(setup.topicId).orElseThrow(); + + applicationService.rejectAllApplicationsAutomatically( + topic, 2, null, setup.researchGroupId + ); + + Application app = applicationRepository.findById(setup.applicationId).orElseThrow(); + assertThat(app.getState()).isEqualTo(ApplicationState.REJECTED); + } + + @Test + void rejectOldApplications_RecentApplication_NotRejected() throws Exception { + createTestEmailTemplate("APPLICATION_CREATED_CHAIR"); + createTestEmailTemplate("APPLICATION_CREATED_STUDENT"); + + TestUser advisor = createRandomTestUser(List.of("supervisor", "advisor")); + UUID researchGroupId = createTestResearchGroup("Recent RG", advisor.universityId()); + + ReplaceTopicPayload topicPayload = new ReplaceTopicPayload( + "Recent Topic", Set.of("MASTER"), + "PS", "Req", "Goals", "Refs", + List.of(advisor.userId()), List.of(advisor.userId()), + researchGroupId, null, null, false + ); + String topicResponse = mockMvc.perform(MockMvcRequestBuilders.post("/v2/topics") + .header("Authorization", createRandomAdminAuthentication()) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(topicPayload))) + .andExpect(status().isOk()) + .andReturn().getResponse().getContentAsString(); + UUID topicId = UUID.fromString(objectMapper.readTree(topicResponse).get("topicId").asString()); + + // Create recent application (not old enough) + String studentAuth = createRandomAuthentication("student"); + CreateApplicationPayload appPayload = new CreateApplicationPayload( + topicId, null, "MASTER", Instant.now(), "Recent app", null + ); + String appResponse = mockMvc.perform(MockMvcRequestBuilders.post("/v2/applications") + .header("Authorization", studentAuth) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(appPayload))) + .andExpect(status().isOk()) + .andReturn().getResponse().getContentAsString(); + UUID applicationId = UUID.fromString(objectMapper.readTree(appResponse).get("applicationId").asString()); + + Topic topic = topicRepository.findById(topicId).orElseThrow(); + applicationService.rejectAllApplicationsAutomatically( + topic, 2, null, researchGroupId + ); + + // Application should still be NOT_ASSESSED + Application app = applicationRepository.findById(applicationId).orElseThrow(); + assertThat(app.getState()).isEqualTo(ApplicationState.NOT_ASSESSED); + } + } + + @Nested + class GetListOfApplicationsThatWillBeRejected { + @Test + void getListToReject_WithOldApplication_ReturnsApplication() throws Exception { + TopicAppSetup setup = createTopicWithOldApplication(); + Topic topic = topicRepository.findById(setup.topicId).orElseThrow(); + + List result = applicationService.getListOfApplicationsThatWillBeRejected( + topic, 2, Instant.now().minus(30, ChronoUnit.DAYS) + ); + + assertThat(result).hasSize(1); + assertThat(result.getFirst().applicationId()).isEqualTo(setup.applicationId); + } + + @Test + void getListToReject_WithNullReferenceDate_ReturnsApplication() throws Exception { + TopicAppSetup setup = createTopicWithOldApplication(); + Topic topic = topicRepository.findById(setup.topicId).orElseThrow(); + + List result = applicationService.getListOfApplicationsThatWillBeRejected( + topic, 2, null + ); + + assertThat(result).hasSize(1); + } + + @Test + void getListToReject_WithRecentApplication_ReturnsEmpty() throws Exception { + createTestEmailTemplate("APPLICATION_CREATED_CHAIR"); + createTestEmailTemplate("APPLICATION_CREATED_STUDENT"); + + TestUser advisor = createRandomTestUser(List.of("supervisor", "advisor")); + UUID researchGroupId = createTestResearchGroup("Future RG", advisor.universityId()); + + ReplaceTopicPayload topicPayload = new ReplaceTopicPayload( + "Future Topic", Set.of("MASTER"), + "PS", "Req", "Goals", "Refs", + List.of(advisor.userId()), List.of(advisor.userId()), + researchGroupId, null, null, false + ); + String topicResponse = mockMvc.perform(MockMvcRequestBuilders.post("/v2/topics") + .header("Authorization", createRandomAdminAuthentication()) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(topicPayload))) + .andExpect(status().isOk()) + .andReturn().getResponse().getContentAsString(); + UUID topicId = UUID.fromString(objectMapper.readTree(topicResponse).get("topicId").asString()); + + String studentAuth = createRandomAuthentication("student"); + CreateApplicationPayload appPayload = new CreateApplicationPayload( + topicId, null, "MASTER", Instant.now(), "Recent app", null + ); + mockMvc.perform(MockMvcRequestBuilders.post("/v2/applications") + .header("Authorization", studentAuth) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(appPayload))) + .andExpect(status().isOk()); + + Topic topic = topicRepository.findById(topicId).orElseThrow(); + // Use a future reference date so rejection date is far away + List result = applicationService.getListOfApplicationsThatWillBeRejected( + topic, 2, Instant.now().plus(60, ChronoUnit.DAYS) + ); + + assertThat(result).isEmpty(); + } + } + + @Nested + class RejectListOfApplicationsIfOlderThan { + @Test + void rejectList_OldApplications_Rejected() throws Exception { + TopicAppSetup setup = createTopicWithOldApplication(); + Application app = applicationRepository.findById(setup.applicationId).orElseThrow(); + + applicationService.rejectListOfApplicationsIfOlderThan( + List.of(app), 14, setup.researchGroupId + ); + + Application updated = applicationRepository.findById(setup.applicationId).orElseThrow(); + assertThat(updated.getState()).isEqualTo(ApplicationState.REJECTED); + } + + @Test + void rejectList_RecentApplications_NotRejected() throws Exception { + createTestEmailTemplate("APPLICATION_CREATED_CHAIR"); + createTestEmailTemplate("APPLICATION_CREATED_STUDENT"); + + TestUser advisor = createRandomTestUser(List.of("supervisor", "advisor")); + UUID researchGroupId = createTestResearchGroup("Recent List RG", advisor.universityId()); + + ReplaceTopicPayload topicPayload = new ReplaceTopicPayload( + "Recent List Topic", Set.of("MASTER"), + "PS", "Req", "Goals", "Refs", + List.of(advisor.userId()), List.of(advisor.userId()), + researchGroupId, null, null, false + ); + String topicResponse = mockMvc.perform(MockMvcRequestBuilders.post("/v2/topics") + .header("Authorization", createRandomAdminAuthentication()) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(topicPayload))) + .andExpect(status().isOk()) + .andReturn().getResponse().getContentAsString(); + UUID topicId = UUID.fromString(objectMapper.readTree(topicResponse).get("topicId").asString()); + + String studentAuth = createRandomAuthentication("student"); + CreateApplicationPayload appPayload = new CreateApplicationPayload( + topicId, null, "MASTER", Instant.now(), "Recent list app", null + ); + String appResponse = mockMvc.perform(MockMvcRequestBuilders.post("/v2/applications") + .header("Authorization", studentAuth) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(appPayload))) + .andExpect(status().isOk()) + .andReturn().getResponse().getContentAsString(); + UUID applicationId = UUID.fromString(objectMapper.readTree(appResponse).get("applicationId").asString()); + + Application app = applicationRepository.findById(applicationId).orElseThrow(); + + applicationService.rejectListOfApplicationsIfOlderThan( + List.of(app), 14, researchGroupId + ); + + Application updated = applicationRepository.findById(applicationId).orElseThrow(); + assertThat(updated.getState()).isEqualTo(ApplicationState.NOT_ASSESSED); + } + } + + @Nested + class GetNotAssessedSuggestedOfResearchGroup { + @Test + void getNotAssessed_ReturnsSuggestedApplications() throws Exception { + createTestEmailTemplate("APPLICATION_CREATED_CHAIR"); + createTestEmailTemplate("APPLICATION_CREATED_STUDENT"); + + TestUser advisor = createRandomTestUser(List.of("supervisor", "advisor")); + UUID researchGroupId = createTestResearchGroup("Suggested RG", advisor.universityId()); + + // Create a suggested application (no topic, just a thesis title and research group) + String studentAuth = createRandomAuthentication("student"); + CreateApplicationPayload appPayload = new CreateApplicationPayload( + null, "My Suggested Thesis", "MASTER", Instant.now(), "Suggested test", researchGroupId + ); + mockMvc.perform(MockMvcRequestBuilders.post("/v2/applications") + .header("Authorization", studentAuth) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(appPayload))) + .andExpect(status().isOk()); + + List result = applicationService.getNotAssesedSuggestedOfResearchGroup(researchGroupId); + assertThat(result).hasSize(1); + assertThat(result.getFirst().getThesisTitle()).isEqualTo("My Suggested Thesis"); + } + } +} diff --git a/server/src/test/java/de/tum/cit/aet/thesis/service/CalendarServiceDisabledTest.java b/server/src/test/java/de/tum/cit/aet/thesis/service/CalendarServiceDisabledTest.java new file mode 100644 index 000000000..7ff7d77f5 --- /dev/null +++ b/server/src/test/java/de/tum/cit/aet/thesis/service/CalendarServiceDisabledTest.java @@ -0,0 +1,55 @@ +package de.tum.cit.aet.thesis.service; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import jakarta.mail.internet.InternetAddress; + +import java.time.Instant; +import java.time.temporal.ChronoUnit; +import java.util.List; + +class CalendarServiceDisabledTest { + + private CalendarService calendarService; + + @BeforeEach + void setUp() { + calendarService = new CalendarService(false, "http://localhost:9999", "user", "pass"); + } + + @Test + void createEvent_WhenDisabled_ReturnsNull() throws Exception { + CalendarService.CalendarEvent data = new CalendarService.CalendarEvent( + "Test", "Room", "Desc", + Instant.now().plus(1, ChronoUnit.DAYS), + Instant.now().plus(1, ChronoUnit.DAYS).plus(30, ChronoUnit.MINUTES), + new InternetAddress("org@test.com"), + List.of(new InternetAddress("req@test.com")), + List.of(new InternetAddress("opt@test.com")) + ); + + String result = calendarService.createEvent(data); + assertThat(result).isNull(); + } + + @Test + void updateEvent_WhenDisabled_ReturnsEarlyWithoutError() { + CalendarService.CalendarEvent data = new CalendarService.CalendarEvent( + "Test", null, null, + Instant.now().plus(1, ChronoUnit.DAYS), + Instant.now().plus(1, ChronoUnit.DAYS).plus(30, ChronoUnit.MINUTES), + null, null, null + ); + + assertDoesNotThrow(() -> calendarService.updateEvent("some-id", data)); + } + + @Test + void deleteEvent_WhenDisabled_ReturnsEarlyWithoutError() { + assertDoesNotThrow(() -> calendarService.deleteEvent("some-id")); + } +} diff --git a/server/src/test/java/de/tum/cit/aet/thesis/service/CalendarServiceIntegrationTest.java b/server/src/test/java/de/tum/cit/aet/thesis/service/CalendarServiceIntegrationTest.java new file mode 100644 index 000000000..9578e37e3 --- /dev/null +++ b/server/src/test/java/de/tum/cit/aet/thesis/service/CalendarServiceIntegrationTest.java @@ -0,0 +1,300 @@ +package de.tum.cit.aet.thesis.service; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; + +import de.tum.cit.aet.thesis.mock.BaseIntegrationTest; +import net.fortuna.ical4j.model.Calendar; +import net.fortuna.ical4j.model.component.VEvent; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.test.context.DynamicPropertyRegistry; +import org.springframework.test.context.DynamicPropertySource; +import org.testcontainers.junit.jupiter.Testcontainers; + +import jakarta.mail.internet.InternetAddress; + +import java.time.Instant; +import java.time.temporal.ChronoUnit; +import java.util.List; +import java.util.Optional; + +@Testcontainers +class CalendarServiceIntegrationTest extends BaseIntegrationTest { + + @DynamicPropertySource + static void configureDynamicProperties(DynamicPropertyRegistry registry) { + configureProperties(registry); + } + + @Autowired + private CalendarService calendarService; + + @Nested + class CreateEvent { + @Test + void createEvent_WithFullData_ReturnsEventId() throws Exception { + CalendarService.CalendarEvent data = new CalendarService.CalendarEvent( + "Test Presentation", + "Room 101", + "A test description", + Instant.now().plus(1, ChronoUnit.DAYS), + Instant.now().plus(1, ChronoUnit.DAYS).plus(30, ChronoUnit.MINUTES), + new InternetAddress("organizer@test.com"), + List.of(new InternetAddress("required@test.com")), + List.of(new InternetAddress("optional@test.com")) + ); + + String eventId = calendarService.createEvent(data); + assertThat(eventId).isNotNull().isNotBlank(); + } + + @Test + void createEvent_WithMinimalData_ReturnsEventId() throws Exception { + CalendarService.CalendarEvent data = new CalendarService.CalendarEvent( + "Minimal Event", + null, + null, + Instant.now().plus(2, ChronoUnit.DAYS), + Instant.now().plus(2, ChronoUnit.DAYS).plus(60, ChronoUnit.MINUTES), + null, + null, + null + ); + + String eventId = calendarService.createEvent(data); + assertThat(eventId).isNotNull().isNotBlank(); + } + } + + @Nested + class UpdateEvent { + @Test + void updateEvent_ExistingEvent_Succeeds() throws Exception { + CalendarService.CalendarEvent createData = new CalendarService.CalendarEvent( + "Original Event", + "Room A", + "Original description", + Instant.now().plus(3, ChronoUnit.DAYS), + Instant.now().plus(3, ChronoUnit.DAYS).plus(30, ChronoUnit.MINUTES), + null, null, null + ); + + String eventId = calendarService.createEvent(createData); + assertThat(eventId).isNotNull(); + + CalendarService.CalendarEvent updateData = new CalendarService.CalendarEvent( + "Updated Event", + "Room B", + "Updated description", + Instant.now().plus(4, ChronoUnit.DAYS), + Instant.now().plus(4, ChronoUnit.DAYS).plus(30, ChronoUnit.MINUTES), + null, null, null + ); + + assertDoesNotThrow(() -> calendarService.updateEvent(eventId, updateData)); + } + + @Test + void updateEvent_NullEventId_ReturnsEarly() { + CalendarService.CalendarEvent data = new CalendarService.CalendarEvent( + "Event", null, null, + Instant.now().plus(1, ChronoUnit.DAYS), + Instant.now().plus(1, ChronoUnit.DAYS).plus(30, ChronoUnit.MINUTES), + null, null, null + ); + + assertDoesNotThrow(() -> calendarService.updateEvent(null, data)); + } + } + + @Nested + class DeleteEvent { + @Test + void deleteEvent_ExistingEvent_Succeeds() throws Exception { + CalendarService.CalendarEvent data = new CalendarService.CalendarEvent( + "To Delete", + null, null, + Instant.now().plus(5, ChronoUnit.DAYS), + Instant.now().plus(5, ChronoUnit.DAYS).plus(30, ChronoUnit.MINUTES), + null, null, null + ); + + String eventId = calendarService.createEvent(data); + assertThat(eventId).isNotNull(); + + assertDoesNotThrow(() -> calendarService.deleteEvent(eventId)); + } + + @Test + void deleteEvent_NullEventId_ReturnsEarly() { + assertDoesNotThrow(() -> calendarService.deleteEvent(null)); + } + + @Test + void deleteEvent_BlankEventId_ReturnsEarly() { + assertDoesNotThrow(() -> calendarService.deleteEvent("")); + } + + @Test + void deleteEvent_NonExistentEvent_GracefulFailure() { + assertDoesNotThrow(() -> calendarService.deleteEvent("non-existent-event-id")); + } + } + + @Nested + class CreateVEvent { + @Test + void createVEvent_SetsUidAndTitle() throws Exception { + CalendarService.CalendarEvent data = new CalendarService.CalendarEvent( + "Test Title", + "Test Location", + "Test Description", + Instant.now(), + Instant.now().plus(1, ChronoUnit.HOURS), + null, null, null + ); + + VEvent event = calendarService.createVEvent("test-uid-123", data); + + assertThat(event.getUid().get().getValue()).isEqualTo("test-uid-123"); + assertThat(event.getSummary().getValue()).isEqualTo("Test Title"); + assertThat(event.getLocation().getValue()).isEqualTo("Test Location"); + assertThat(event.getDescription().getValue()).isEqualTo("Test Description"); + } + + @Test + void createVEvent_WithOrganizer_SetsOrganizerProperty() throws Exception { + CalendarService.CalendarEvent data = new CalendarService.CalendarEvent( + "Organizer Event", + null, null, + Instant.now(), + Instant.now().plus(1, ChronoUnit.HOURS), + new InternetAddress("organizer@test.com"), + null, null + ); + + VEvent event = calendarService.createVEvent("org-uid", data); + + assertThat(event.getOrganizer().getValue()).contains("organizer@test.com"); + } + + @Test + void createVEvent_WithRequiredAttendees_SetsAttendeeProperties() throws Exception { + CalendarService.CalendarEvent data = new CalendarService.CalendarEvent( + "Attendee Event", + null, null, + Instant.now(), + Instant.now().plus(1, ChronoUnit.HOURS), + null, + List.of(new InternetAddress("req@test.com")), + null + ); + + VEvent event = calendarService.createVEvent("att-uid", data); + + assertThat(event.getProperties("ATTENDEE")).isNotEmpty(); + assertThat(event.getProperties("ATTENDEE").getFirst().getValue()).contains("req@test.com"); + } + + @Test + void createVEvent_WithOptionalAttendees_SetsOptionalAttendeeProperties() throws Exception { + CalendarService.CalendarEvent data = new CalendarService.CalendarEvent( + "Optional Attendee Event", + null, null, + Instant.now(), + Instant.now().plus(1, ChronoUnit.HOURS), + null, + null, + List.of(new InternetAddress("opt@test.com")) + ); + + VEvent event = calendarService.createVEvent("opt-uid", data); + + assertThat(event.getProperties("ATTENDEE")).isNotEmpty(); + } + + @Test + void createVEvent_WithOverlappingAttendees_DeduplicatesOptional() throws Exception { + InternetAddress shared = new InternetAddress("shared@test.com"); + + CalendarService.CalendarEvent data = new CalendarService.CalendarEvent( + "Dedup Event", + null, null, + Instant.now(), + Instant.now().plus(1, ChronoUnit.HOURS), + null, + List.of(shared), + List.of(shared, new InternetAddress("unique@test.com")) + ); + + VEvent event = calendarService.createVEvent("dedup-uid", data); + + long sharedCount = event.getProperties("ATTENDEE").stream() + .filter(p -> p.getValue().contains("shared@test.com")) + .count(); + assertThat(sharedCount).isEqualTo(1); + } + + @Test + void createVEvent_WithNullLocationAndDescription_OmitsThoseFields() throws Exception { + CalendarService.CalendarEvent data = new CalendarService.CalendarEvent( + "Sparse Event", + null, + null, + Instant.now(), + Instant.now().plus(1, ChronoUnit.HOURS), + null, null, null + ); + + VEvent event = calendarService.createVEvent("sparse-uid", data); + + assertThat(event.getLocation()).isNull(); + assertThat(event.getDescription()).isNull(); + } + } + + @Nested + class FindVEvent { + @Test + void findVEvent_MatchingUid_ReturnsEvent() { + Calendar calendar = calendarService.createEmptyCalendar("-//Test//Test//EN"); + + CalendarService.CalendarEvent data = new CalendarService.CalendarEvent( + "Find Me", + null, null, + Instant.now(), + Instant.now().plus(1, ChronoUnit.HOURS), + null, null, null + ); + + VEvent event = calendarService.createVEvent("find-uid", data); + calendar.add(event); + + Optional found = calendarService.findVEvent(calendar, "find-uid"); + assertThat(found).isPresent(); + assertThat(found.get().getUid().get().getValue()).isEqualTo("find-uid"); + } + + @Test + void findVEvent_NonMatchingUid_ReturnsEmpty() { + Calendar calendar = calendarService.createEmptyCalendar("-//Test//Test//EN"); + + Optional found = calendarService.findVEvent(calendar, "nonexistent-uid"); + assertThat(found).isEmpty(); + } + } + + @Nested + class CreateEmptyCalendar { + @Test + void createEmptyCalendar_SetsPropertiesCorrectly() { + Calendar calendar = calendarService.createEmptyCalendar("-//Test//Calendar//EN"); + + assertThat(calendar.toString()).contains("-//Test//Calendar//EN"); + assertThat(calendar.toString()).contains("VERSION:2.0"); + assertThat(calendar.toString()).contains("CALSCALE:GREGORIAN"); + } + } +} diff --git a/server/src/test/java/de/tum/cit/aet/thesis/service/InterviewProcessServiceTest.java b/server/src/test/java/de/tum/cit/aet/thesis/service/InterviewProcessServiceTest.java index fd6eb58cf..02663857b 100644 --- a/server/src/test/java/de/tum/cit/aet/thesis/service/InterviewProcessServiceTest.java +++ b/server/src/test/java/de/tum/cit/aet/thesis/service/InterviewProcessServiceTest.java @@ -112,7 +112,7 @@ void findAllMyProcesses_WithValidArguments_CallsRepositoryWithExpectedFilterAndP eq(user.getId()), eq("%thesis%"), eq(true), - org.mockito.ArgumentMatchers.any() + any(Pageable.class) )).thenReturn(expectedPage); Page result = interviewProcessService.findAllMyProcesses("thesis", 1, 5, "completed", "asc", true); @@ -351,7 +351,7 @@ void getUpcomingInterviewsForCurrentUser_FiltersOnlyFutureBookedSlots() { eq(currentUser.getId()), eq(null), eq(false), - org.mockito.ArgumentMatchers.any() + any(Pageable.class) )).thenReturn(new PageImpl<>(List.of(process))); List result = interviewProcessService.getUpcomingInterviewsForCurrentUser(); diff --git a/server/src/test/java/de/tum/cit/aet/thesis/service/MailingServiceIntegrationTest.java b/server/src/test/java/de/tum/cit/aet/thesis/service/MailingServiceIntegrationTest.java new file mode 100644 index 000000000..18cb9dac2 --- /dev/null +++ b/server/src/test/java/de/tum/cit/aet/thesis/service/MailingServiceIntegrationTest.java @@ -0,0 +1,205 @@ +package de.tum.cit.aet.thesis.service; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import de.tum.cit.aet.thesis.constants.ApplicationRejectReason; +import de.tum.cit.aet.thesis.controller.payload.AcceptApplicationPayload; +import de.tum.cit.aet.thesis.controller.payload.CreateApplicationPayload; +import de.tum.cit.aet.thesis.controller.payload.RejectApplicationPayload; +import de.tum.cit.aet.thesis.mock.BaseIntegrationTest; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.http.MediaType; +import org.springframework.test.context.DynamicPropertyRegistry; +import org.springframework.test.context.DynamicPropertySource; +import org.springframework.test.web.servlet.request.MockMvcRequestBuilders; +import org.testcontainers.junit.jupiter.Testcontainers; + +import jakarta.mail.Address; +import jakarta.mail.internet.MimeMessage; + +import java.time.Instant; +import java.util.Arrays; +import java.util.List; +import java.util.UUID; +import java.util.stream.Stream; + +@Testcontainers +class MailingServiceIntegrationTest extends BaseIntegrationTest { + + @DynamicPropertySource + static void configureDynamicProperties(DynamicPropertyRegistry registry) { + configureProperties(registry); + } + + private List getAllRecipientAddresses(MimeMessage[] emails) { + return Stream.of(emails) + .flatMap(email -> { + try { + return Arrays.stream(email.getAllRecipients()); + } catch (Exception e) { + return Stream.empty(); + } + }) + .map(Address::toString) + .toList(); + } + + @Nested + class ApplicationEmails { + @Test + void createApplication_SendsEmailToChairMembersAndStudent() throws Exception { + createTestEmailTemplate("APPLICATION_CREATED_CHAIR"); + createTestEmailTemplate("APPLICATION_CREATED_STUDENT"); + + TestUser head = createRandomTestUser(List.of("supervisor")); + UUID researchGroupId = createTestResearchGroup("Email App Group", head.universityId()); + + clearEmails(); + + TestUser student = createRandomTestUser(List.of("student")); + String studentAuth = generateTestAuthenticationHeader(student.universityId(), List.of("student")); + + CreateApplicationPayload payload = new CreateApplicationPayload( + null, "Email Test Thesis", "BACHELOR", Instant.now(), "Test motivation", researchGroupId + ); + + mockMvc.perform(MockMvcRequestBuilders.post("/v2/applications") + .header("Authorization", studentAuth) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(payload))) + .andExpect(status().isOk()); + + MimeMessage[] emails = getReceivedEmails(); + assertThat(emails.length).as("At least one email should be sent on application creation") + .isGreaterThanOrEqualTo(1); + + List recipients = getAllRecipientAddresses(emails); + assertThat(recipients).as("Student should receive an email") + .anyMatch(addr -> addr.contains(student.universityId())); + } + + @Test + void acceptApplication_SendsAcceptanceEmail() throws Exception { + createTestEmailTemplate("APPLICATION_CREATED_CHAIR"); + createTestEmailTemplate("APPLICATION_CREATED_STUDENT"); + createTestEmailTemplate("APPLICATION_ACCEPTED"); + createTestEmailTemplate("APPLICATION_ACCEPTED_NO_ADVISOR"); + createTestEmailTemplate("THESIS_CREATED"); + + TestUser student = createRandomTestUser(List.of("student")); + String studentAuth = generateTestAuthenticationHeader(student.universityId(), List.of("student")); + + TestUser advisor = createRandomTestUser(List.of("supervisor", "advisor")); + UUID researchGroupId = createTestResearchGroup("Accept Email Group", advisor.universityId()); + + CreateApplicationPayload appPayload = new CreateApplicationPayload( + null, "Accept Email Thesis", "MASTER", Instant.now(), "Motivation", researchGroupId + ); + + String appResponse = mockMvc.perform(MockMvcRequestBuilders.post("/v2/applications") + .header("Authorization", studentAuth) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(appPayload))) + .andExpect(status().isOk()) + .andReturn().getResponse().getContentAsString(); + + UUID applicationId = UUID.fromString(objectMapper.readTree(appResponse).get("applicationId").asString()); + + clearEmails(); + + AcceptApplicationPayload acceptPayload = new AcceptApplicationPayload( + "Accept Email Thesis", "MASTER", "ENGLISH", + List.of(advisor.userId()), + List.of(advisor.userId()), + true, false + ); + + mockMvc.perform(MockMvcRequestBuilders.put("/v2/applications/{id}/accept", applicationId) + .header("Authorization", createRandomAdminAuthentication()) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(acceptPayload))) + .andExpect(status().isOk()); + + MimeMessage[] emails = getReceivedEmails(); + assertThat(emails.length).as("At least one email should be sent on acceptance") + .isGreaterThanOrEqualTo(1); + + List recipients = getAllRecipientAddresses(emails); + assertThat(recipients).as("Student should receive an acceptance email") + .anyMatch(addr -> addr.contains(student.universityId())); + } + + @Test + void rejectApplication_SendsRejectionEmail() throws Exception { + createTestEmailTemplate("APPLICATION_CREATED_CHAIR"); + createTestEmailTemplate("APPLICATION_CREATED_STUDENT"); + createTestEmailTemplate("APPLICATION_REJECTED"); + + TestUser student = createRandomTestUser(List.of("student")); + String studentAuth = generateTestAuthenticationHeader(student.universityId(), List.of("student")); + + TestUser head = createRandomTestUser(List.of("supervisor")); + UUID researchGroupId = createTestResearchGroup("Reject Email Group", head.universityId()); + + CreateApplicationPayload appPayload = new CreateApplicationPayload( + null, "Reject Email Thesis", "MASTER", Instant.now(), "Motivation", researchGroupId + ); + + String appResponse = mockMvc.perform(MockMvcRequestBuilders.post("/v2/applications") + .header("Authorization", studentAuth) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(appPayload))) + .andExpect(status().isOk()) + .andReturn().getResponse().getContentAsString(); + + UUID applicationId = UUID.fromString(objectMapper.readTree(appResponse).get("applicationId").asString()); + + clearEmails(); + + RejectApplicationPayload rejectPayload = new RejectApplicationPayload( + ApplicationRejectReason.GENERAL, true + ); + + mockMvc.perform(MockMvcRequestBuilders.put("/v2/applications/{id}/reject", applicationId) + .header("Authorization", createRandomAdminAuthentication()) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(rejectPayload))) + .andExpect(status().isOk()); + + MimeMessage[] emails = getReceivedEmails(); + assertThat(emails.length).as("At least one email should be sent on rejection") + .isGreaterThanOrEqualTo(1); + + List recipients = getAllRecipientAddresses(emails); + assertThat(recipients).as("Student should receive a rejection email") + .anyMatch(addr -> addr.contains(student.universityId())); + } + } + + @Nested + class ThesisEmails { + @Test + void createThesis_SendsCreatedEmail() throws Exception { + createTestEmailTemplate("THESIS_CREATED"); + + clearEmails(); + + createTestThesis("Thesis Email Test"); + + MimeMessage[] emails = getReceivedEmails(); + assertThat(emails.length).as("At least one email should be sent on thesis creation") + .isGreaterThanOrEqualTo(1); + + List recipients = getAllRecipientAddresses(emails); + assertThat(recipients).as("At least one recipient should receive the thesis creation email") + .isNotEmpty(); + + // Verify all emails have a subject + for (MimeMessage email : emails) { + assertThat(email.getSubject()).as("Each email should have a non-null subject").isNotNull(); + } + } + } +} diff --git a/server/src/test/java/de/tum/cit/aet/thesis/service/ThesisPresentationServiceTest.java b/server/src/test/java/de/tum/cit/aet/thesis/service/ThesisPresentationServiceTest.java index c8429593d..ed74113a2 100644 --- a/server/src/test/java/de/tum/cit/aet/thesis/service/ThesisPresentationServiceTest.java +++ b/server/src/test/java/de/tum/cit/aet/thesis/service/ThesisPresentationServiceTest.java @@ -22,6 +22,7 @@ import de.tum.cit.aet.thesis.repository.UserRepository; import de.tum.cit.aet.thesis.security.CurrentUserProvider; import net.fortuna.ical4j.model.Calendar; +import net.fortuna.ical4j.model.component.VEvent; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; @@ -249,8 +250,10 @@ void findById_WithInvalidThesisId_ThrowsException() { void getPresentationCalendar_ReturnsCalendarWithEvents() { when(thesisPresentationRepository.findAllPresentations(isNull(), anySet())) .thenReturn(List.of(testPresentation)); + + VEvent vEvent = new VEvent(); when(calendarService.createVEvent(anyString(), any())) - .thenReturn(null); + .thenReturn(vEvent); when(calendarService.createEmptyCalendar(anyString())).thenReturn( new Calendar() ); @@ -258,6 +261,7 @@ void getPresentationCalendar_ReturnsCalendarWithEvents() { Calendar result = presentationService.getPresentationCalendar(null); assertNotNull(result); + assertEquals(1, result.getComponents().size()); verify(calendarService).createVEvent(anyString(), any()); verify(thesisPresentationRepository).findAllPresentations(isNull(), anySet()); } diff --git a/server/src/test/java/de/tum/cit/aet/thesis/service/UploadServiceTest.java b/server/src/test/java/de/tum/cit/aet/thesis/service/UploadServiceTest.java new file mode 100644 index 000000000..9532604bd --- /dev/null +++ b/server/src/test/java/de/tum/cit/aet/thesis/service/UploadServiceTest.java @@ -0,0 +1,149 @@ +package de.tum.cit.aet.thesis.service; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import de.tum.cit.aet.thesis.constants.UploadFileType; +import de.tum.cit.aet.thesis.exception.UploadException; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import org.springframework.core.io.FileSystemResource; +import org.springframework.mock.web.MockMultipartFile; + +import java.nio.file.Path; + +class UploadServiceTest { + + @TempDir + Path tempDir; + + private UploadService uploadService; + + @BeforeEach + void setUp() { + uploadService = new UploadService(tempDir.toString()); + } + + @Nested + class StoreFile { + @Test + void store_ValidPdf_ReturnsFilename() { + MockMultipartFile file = new MockMultipartFile( + "file", "test.pdf", "application/pdf", new byte[]{1, 2, 3, 4} + ); + + String filename = uploadService.store(file, 1024 * 1024, UploadFileType.PDF); + assertThat(filename).endsWith(".pdf"); + assertThat(filename).isNotBlank(); + } + + @Test + void store_ValidImage_ReturnsFilename() { + MockMultipartFile file = new MockMultipartFile( + "file", "test.png", "image/png", new byte[]{1, 2, 3, 4} + ); + + String filename = uploadService.store(file, 1024 * 1024, UploadFileType.IMAGE); + assertThat(filename).endsWith(".png"); + } + + @Test + void store_EmptyFile_ThrowsException() { + MockMultipartFile file = new MockMultipartFile( + "file", "empty.pdf", "application/pdf", new byte[0] + ); + + assertThatThrownBy(() -> uploadService.store(file, 1024 * 1024, UploadFileType.PDF)) + .isInstanceOf(UploadException.class) + .hasMessageContaining("empty"); + } + + @Test + void store_OversizedFile_ThrowsException() { + byte[] largeContent = new byte[2 * 1024 * 1024]; + MockMultipartFile file = new MockMultipartFile( + "file", "large.pdf", "application/pdf", largeContent + ); + + assertThatThrownBy(() -> uploadService.store(file, 1024 * 1024, UploadFileType.PDF)) + .isInstanceOf(UploadException.class) + .hasMessageContaining("size"); + } + + @Test + void store_InvalidExtensionForPdf_ThrowsException() { + MockMultipartFile file = new MockMultipartFile( + "file", "test.exe", "application/octet-stream", new byte[]{1, 2, 3} + ); + + assertThatThrownBy(() -> uploadService.store(file, 1024 * 1024, UploadFileType.PDF)) + .isInstanceOf(UploadException.class) + .hasMessageContaining("type"); + } + + @Test + void store_InvalidExtensionForImage_ThrowsException() { + MockMultipartFile file = new MockMultipartFile( + "file", "test.pdf", "application/pdf", new byte[]{1, 2, 3} + ); + + assertThatThrownBy(() -> uploadService.store(file, 1024 * 1024, UploadFileType.IMAGE)) + .isInstanceOf(UploadException.class) + .hasMessageContaining("type"); + } + + @Test + void store_MaliciousFilename_StoredWithSafeHashedName() { + MockMultipartFile file = new MockMultipartFile( + "file", "../../../etc/passwd.pdf", "application/pdf", new byte[]{1, 2, 3} + ); + + // Original filename is ignored — stored filename is a SHA hash of the content, + // so path traversal via filename is not possible. + String filename = uploadService.store(file, 1024 * 1024, UploadFileType.PDF); + assertThat(filename).doesNotContain(".."); + assertThat(filename).doesNotContain("/"); + } + + @Test + void store_AnyTypeAllowed_Success() { + MockMultipartFile file = new MockMultipartFile( + "file", "test.docx", "application/msword", new byte[]{1, 2, 3} + ); + + String filename = uploadService.store(file, 1024 * 1024, UploadFileType.ANY); + assertThat(filename).endsWith(".docx"); + } + } + + @Nested + class LoadFile { + @Test + void load_ValidFile_ReturnsResource() { + MockMultipartFile file = new MockMultipartFile( + "file", "load.pdf", "application/pdf", new byte[]{1, 2, 3, 4, 5} + ); + + String filename = uploadService.store(file, 1024 * 1024, UploadFileType.PDF); + + FileSystemResource resource = uploadService.load(filename); + assertThat(resource).isNotNull(); + assertThat(resource.exists()).isTrue(); + } + + @Test + void load_PathTraversal_ThrowsException() { + assertThatThrownBy(() -> uploadService.load("../../../etc/passwd")) + .isInstanceOf(UploadException.class) + .hasMessageContaining("relative path"); + } + + @Test + void load_NonExistentFile_ThrowsException() { + assertThatThrownBy(() -> uploadService.load("nonexistent-file.pdf")) + .isInstanceOf(UploadException.class); + } + } +} diff --git a/server/src/test/java/de/tum/cit/aet/thesis/utility/DataFormatterTest.java b/server/src/test/java/de/tum/cit/aet/thesis/utility/DataFormatterTest.java index 7b365ab06..bcf95c664 100644 --- a/server/src/test/java/de/tum/cit/aet/thesis/utility/DataFormatterTest.java +++ b/server/src/test/java/de/tum/cit/aet/thesis/utility/DataFormatterTest.java @@ -182,13 +182,9 @@ private static Stream provideOptionalStringTestCases() { @Test void formatSemester_WithValidInstant_ReturnsExpectedSemester() { - Instant now = Instant.now(); - Instant sixMonthsAgo = now.minus(182, ChronoUnit.DAYS); - - String result = DataFormatter.formatSemester(sixMonthsAgo); - - int semester = Integer.parseInt(result); - assertTrue(semester >= 1 && semester <= 2, "Semester should be 1 or 2"); + Instant oneYearAgo = Instant.now().minus(365, ChronoUnit.DAYS); + String result = DataFormatter.formatSemester(oneYearAgo); + assertEquals("2", result, "365 days / 182 days per semester = 2"); } @Test diff --git a/server/src/test/java/de/tum/cit/aet/thesis/utility/MailLoggerTest.java b/server/src/test/java/de/tum/cit/aet/thesis/utility/MailLoggerTest.java new file mode 100644 index 000000000..2f347feb5 --- /dev/null +++ b/server/src/test/java/de/tum/cit/aet/thesis/utility/MailLoggerTest.java @@ -0,0 +1,111 @@ +package de.tum.cit.aet.thesis.utility; + +import static org.assertj.core.api.Assertions.assertThat; + +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +import jakarta.mail.Message; +import jakarta.mail.Session; +import jakarta.mail.internet.InternetAddress; +import jakarta.mail.internet.MimeBodyPart; +import jakarta.mail.internet.MimeMessage; +import jakarta.mail.internet.MimeMultipart; + +import java.util.Properties; + +class MailLoggerTest { + + private MimeMessage createMimeMessage() { + Session session = Session.getDefaultInstance(new Properties()); + return new MimeMessage(session); + } + + @Nested + class GetTextFromMimeMessage { + @Test + void returnsFormattedString_WithAllFields() throws Exception { + MimeMessage message = createMimeMessage(); + message.setSubject("Test Subject"); + message.setFrom(new InternetAddress("from@test.com")); + message.setRecipient(Message.RecipientType.TO, new InternetAddress("to@test.com")); + message.addRecipient(Message.RecipientType.CC, new InternetAddress("cc@test.com")); + message.addRecipient(Message.RecipientType.BCC, new InternetAddress("bcc@test.com")); + message.setText("Plain text body"); + + String result = MailLogger.getTextFromMimeMessage(message); + + assertThat(result).contains("Subject: Test Subject"); + assertThat(result).contains("From: from@test.com"); + assertThat(result).contains("To: to@test.com"); + assertThat(result).contains("CC: cc@test.com"); + assertThat(result).contains("BCC: bcc@test.com"); + assertThat(result).contains("Content: Plain text body"); + } + + @Test + void handlesNullRecipients() throws Exception { + MimeMessage message = createMimeMessage(); + message.setSubject("No Recipients"); + message.setFrom(new InternetAddress("from@test.com")); + message.setText("Body"); + + String result = MailLogger.getTextFromMimeMessage(message); + + assertThat(result).contains("Subject: No Recipients"); + assertThat(result).contains("To: "); + assertThat(result).contains("CC: "); + assertThat(result).contains("BCC: "); + } + + @Test + void handlesMultipartContent() throws Exception { + MimeMessage message = createMimeMessage(); + message.setSubject("Multipart"); + message.setFrom(new InternetAddress("from@test.com")); + + MimeMultipart multipart = new MimeMultipart(); + MimeBodyPart textPart = new MimeBodyPart(); + textPart.setText("Plain text content", "utf-8", "plain"); + multipart.addBodyPart(textPart); + + MimeBodyPart htmlPart = new MimeBodyPart(); + htmlPart.setText("

HTML content

", "utf-8", "html"); + multipart.addBodyPart(htmlPart); + + message.setContent(multipart); + + String result = MailLogger.getTextFromMimeMessage(message); + + assertThat(result).contains("Subject: Multipart"); + assertThat(result).contains("Content: "); + assertThat(result).contains("Plain text content"); + assertThat(result).contains("

HTML content

"); + } + + @Test + void handlesNestedMultipart() throws Exception { + MimeMessage message = createMimeMessage(); + message.setSubject("Nested"); + message.setFrom(new InternetAddress("from@test.com")); + + MimeMultipart innerMultipart = new MimeMultipart(); + MimeBodyPart innerText = new MimeBodyPart(); + innerText.setText("Nested text", "utf-8", "plain"); + innerMultipart.addBodyPart(innerText); + + MimeBodyPart wrapperPart = new MimeBodyPart(); + wrapperPart.setContent(innerMultipart, innerMultipart.getContentType()); + + MimeMultipart outerMultipart = new MimeMultipart(); + outerMultipart.addBodyPart(wrapperPart); + + message.setContent(outerMultipart); + message.saveChanges(); + + String result = MailLogger.getTextFromMimeMessage(message); + + assertThat(result).contains("Nested text"); + } + } +} diff --git a/server/src/test/resources/application.yml b/server/src/test/resources/application.yml index de7909c65..5e4927d25 100644 --- a/server/src/test/resources/application.yml +++ b/server/src/test/resources/application.yml @@ -11,6 +11,8 @@ spring: username: "" password: "" driver-class-name: org.postgresql.Driver + hikari: + maximum-pool-size: 5 liquibase: enabled: true change-log: db/changelog/db.changelog-master.xml @@ -51,18 +53,18 @@ thesis-management: secret: "" student-group-name: thesis-students calendar: - enabled: false - url: "" - username: "" - password: "" + enabled: true + url: http://localhost:18080 + username: test + password: test client: host: http://localhost:3000 mail: - enabled: false + enabled: true sender: test@ios.ase.cit.tum.de signature: "" workspace-url: https://slack.com bcc-recipients: "" storage: upload-location: uploads - scientific-writing-guide: "" + scientific-writing-guide: http://localhost:3000/writing-guide