diff --git a/.gitignore b/.gitignore index 0a6bb2f9..9d406eb9 100644 --- a/.gitignore +++ b/.gitignore @@ -36,6 +36,8 @@ out/ .vscode/ backend/app/data/* backend/data/* +backend/tmp/* +backend/app/tmp/* data/* ### Secrets ### diff --git a/backend/app/artifactPath b/backend/app/artifactPath new file mode 100644 index 00000000..e69de29b diff --git a/backend/app/build.gradle b/backend/app/build.gradle index 8ab8a618..8c4995ce 100644 --- a/backend/app/build.gradle +++ b/backend/app/build.gradle @@ -21,7 +21,6 @@ dependencies { implementation 'org.springframework.boot:spring-boot-starter-web' runtimeOnly 'org.postgresql:postgresql' implementation 'org.springframework.boot:spring-boot-starter-oauth2-client' - implementation 'org.springframework.boot:spring-boot-starter-web' implementation 'org.springframework.boot:spring-boot-starter-security' implementation 'org.springframework.security:spring-security-config' implementation 'org.springframework.boot:spring-boot-starter-oauth2-resource-server' @@ -44,6 +43,9 @@ dependencies { implementation "org.springframework.boot:spring-boot-devtools" testImplementation 'org.springframework.boot:spring-boot-starter-test' testImplementation 'org.springframework.security:spring-security-test' + testImplementation 'org.mockito:mockito-junit-jupiter:4.0.0' + implementation 'com.fasterxml.jackson.datatype:jackson-datatype-jsr310' + testImplementation 'com.fasterxml.jackson.datatype:jackson-datatype-jsr310' } // tasks.named('test',Test) { @@ -52,17 +54,32 @@ dependencies { // testLogging { // events "passed" + // } // } task unitTests (type: Test){ - exclude '**/DockerSubmissionTestTest.java' + exclude '**/docker' + useJUnitPlatform() maxHeapSize = '1G' testLogging { events "passed" } + +} + +task allTest (type: Test) { + + include '**' + useJUnitPlatform() + maxHeapSize = '1G' + + + testLogging { + events "passed" + } } diff --git a/backend/app/src/main/java/com/ugent/pidgeon/GlobalErrorHandler.java b/backend/app/src/main/java/com/ugent/pidgeon/GlobalErrorHandler.java index 715598cc..47371b60 100644 --- a/backend/app/src/main/java/com/ugent/pidgeon/GlobalErrorHandler.java +++ b/backend/app/src/main/java/com/ugent/pidgeon/GlobalErrorHandler.java @@ -31,6 +31,7 @@ private void logError(Exception ex) { logger.log(Level.SEVERE, ex.getMessage(), ex); } + /* Gets thrown when a invalid json is sent */ @ExceptionHandler(HttpMessageNotReadableException.class) public ResponseEntity handleHttpMessageNotReadableException(HttpServletRequest request, Exception ex) { logError(ex); @@ -40,15 +41,17 @@ public ResponseEntity handleHttpMessageNotReadableException(Htt "Unable to process the request due to invalid or missing data. Please ensure the request body is properly formatted and all required fields are provided.", path)); } - @ExceptionHandler(NoResourceFoundException.class) - public ResponseEntity handleHttpMessageNotFoundException(HttpServletRequest request, Exception ex) { + /* Gets thrown when endpoint doesn't exist */ + @ExceptionHandler(NoHandlerFoundException.class) + public ResponseEntity handleNoHandlerFoundException(HttpServletRequest request, Exception ex) { logError(ex); String path = request.getRequestURI(); HttpStatus status = HttpStatus.NOT_FOUND; return ResponseEntity.status(status).body(new ApiErrorReponse(OffsetDateTime.now(), status.value(), status.getReasonPhrase(), - "Endpoint doesn't exist", path)); + "Resource/endpoint doesn't exist", path)); } + /* Gets thrown when the method is not allowed */ @ExceptionHandler(HttpRequestMethodNotSupportedException.class) public ResponseEntity handleMethodNotSupportedException(HttpServletRequest request, Exception ex) { logError(ex); @@ -58,6 +61,7 @@ public ResponseEntity handleMethodNotSupportedException(HttpSer "Method not supported", path)); } + /* Gets thrown when u path variable is of the wrong type */ @ExceptionHandler(MethodArgumentTypeMismatchException.class) public ResponseEntity handleMethodArgumentTypeMismatchException(HttpServletRequest request, Exception ex) { logError(ex); @@ -67,6 +71,7 @@ public ResponseEntity handleMethodArgumentTypeMismatchException "Invalid url argument type", path)); } + /* Gets thrown when an unexpected error occurs */ @ExceptionHandler(Exception.class) public ResponseEntity handleException(HttpServletRequest request, Exception ex) { logError(ex); diff --git a/backend/app/src/main/java/com/ugent/pidgeon/auth/RolesInterceptor.java b/backend/app/src/main/java/com/ugent/pidgeon/auth/RolesInterceptor.java index f87246fe..17952d89 100644 --- a/backend/app/src/main/java/com/ugent/pidgeon/auth/RolesInterceptor.java +++ b/backend/app/src/main/java/com/ugent/pidgeon/auth/RolesInterceptor.java @@ -64,7 +64,7 @@ public boolean preHandle(HttpServletRequest request, HttpServletResponse respons userEntity = new UserEntity(auth.getUser().firstName,auth.getUser().lastName, auth.getEmail(), UserRole.student, auth.getOid()); OffsetDateTime now = OffsetDateTime.now(); userEntity.setCreatedAt(now); - userRepository.save(userEntity); + userEntity = userRepository.save(userEntity); System.out.println("User created with id: " + userEntity.getId()); } diff --git a/backend/app/src/main/java/com/ugent/pidgeon/controllers/ApiRoutes.java b/backend/app/src/main/java/com/ugent/pidgeon/controllers/ApiRoutes.java index 763f9cd2..ab5886cc 100644 --- a/backend/app/src/main/java/com/ugent/pidgeon/controllers/ApiRoutes.java +++ b/backend/app/src/main/java/com/ugent/pidgeon/controllers/ApiRoutes.java @@ -3,7 +3,6 @@ public final class ApiRoutes { public static final String USERS_BASE_PATH = "/api/users"; public static final String COURSE_BASE_PATH = "/api/courses"; - public static final String DEADLINE_BASE_PATH = "/api/deadlines"; public static final String PROJECT_BASE_PATH = "/api/projects"; public static final String LOGGEDIN_USER_PATH = "/api/user"; @@ -14,10 +13,4 @@ public final class ApiRoutes { public static final String GROUP_MEMBER_BASE_PATH = GROUP_BASE_PATH + "/{groupid}/members"; public static final String GROUP_FEEDBACK_PATH = PROJECT_BASE_PATH + "/{projectid}/groups/{groupid}/score"; public static final String CLUSTER_BASE_PATH = "/api/clusters"; - - public static final String USER_AUTH_PATH = "/api/auth"; - - public static final String GROUP_SCORE_PATH = GROUP_BASE_PATH + "/{groupid}/score"; - - } diff --git a/backend/app/src/main/java/com/ugent/pidgeon/controllers/ClusterController.java b/backend/app/src/main/java/com/ugent/pidgeon/controllers/ClusterController.java index 66146efa..6508be3c 100644 --- a/backend/app/src/main/java/com/ugent/pidgeon/controllers/ClusterController.java +++ b/backend/app/src/main/java/com/ugent/pidgeon/controllers/ClusterController.java @@ -5,12 +5,17 @@ import com.ugent.pidgeon.model.Auth; import com.ugent.pidgeon.model.json.*; import com.ugent.pidgeon.postgre.models.CourseEntity; +import com.ugent.pidgeon.postgre.models.CourseUserEntity; +import com.ugent.pidgeon.postgre.models.CourseUserId; import com.ugent.pidgeon.postgre.models.GroupClusterEntity; import com.ugent.pidgeon.postgre.models.GroupEntity; +import com.ugent.pidgeon.postgre.models.UserEntity; import com.ugent.pidgeon.postgre.models.types.CourseRelation; import com.ugent.pidgeon.postgre.models.types.UserRole; import com.ugent.pidgeon.postgre.repository.*; import com.ugent.pidgeon.util.*; +import java.util.Map; +import java.util.logging.Logger; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; @@ -27,6 +32,11 @@ public class ClusterController { GroupClusterRepository groupClusterRepository; @Autowired GroupRepository groupRepository; + @Autowired + GroupMemberRepository groupMemberRepository; + @Autowired + CourseUserRepository courseUserRepository; + @Autowired private ClusterUtil clusterUtil; @@ -164,10 +174,66 @@ public ResponseEntity doGroupClusterUpdate(GroupClusterEntity clusterEntity, } clusterEntity.setMaxSize(clusterJson.getCapacity()); clusterEntity.setName(clusterJson.getName()); - groupClusterRepository.save(clusterEntity); + clusterEntity = groupClusterRepository.save(clusterEntity); return ResponseEntity.ok(entityToJsonConverter.clusterEntityToClusterJson(clusterEntity)); } + /** + * Fills up the groups in a cluster by providing a map of groupids with lists of userids + * + * @param clusterid identifier of a cluster + * @param auth authentication object of the requesting user + * @param clusterFillMap Map object containing a map of all groups and their + * members of that cluster + * @return ResponseEntity + * @HttpMethod PUT + * @ApiPath /api/clusters/{clusterid}/fill + * @AllowedRoles student, teacher + */ + @PutMapping(ApiRoutes.CLUSTER_BASE_PATH + "/{clusterid}/fill") + @Transactional + @Roles({UserRole.teacher, UserRole.student}) + public ResponseEntity fillCluster(@PathVariable("clusterid") Long clusterid, Auth auth, @RequestBody Map clusterFillMap) { + ClusterFillJson clusterFillJson = new ClusterFillJson(clusterFillMap); + try{ + CheckResult checkResult = clusterUtil.getGroupClusterEntityIfAdminAndNotIndividual(clusterid, auth.getUserEntity()); + + if (checkResult.getStatus() != HttpStatus.OK) { + return ResponseEntity.status(checkResult.getStatus()).body(checkResult.getMessage()); + } + + GroupClusterEntity groupCluster = checkResult.getData(); + + List groups = groupRepository.findAllByClusterId(clusterid); + + CheckResult jsonCheckRes = clusterUtil.checkFillClusterJson(clusterFillJson, groupCluster); + if (jsonCheckRes.getStatus() != HttpStatus.OK) { + return ResponseEntity.status(jsonCheckRes.getStatus()).body(jsonCheckRes.getMessage()); + } + + for(GroupEntity group: groups){ + commonDatabaseActions.removeGroup(group.getId()); + } + + for(String groupName: clusterFillJson.getClusterGroupMembers().keySet()){ + Long[] users = clusterFillJson.getClusterGroupMembers().get(groupName); + GroupEntity groupEntity = new GroupEntity(groupName, clusterid); + groupEntity = groupRepository.save(groupEntity); + for(Long userid: users){ + groupMemberRepository.addMemberToGroup(groupEntity.getId(), userid); + } + } + + groupCluster.setGroupAmount(clusterFillJson.getClusterGroupMembers().size()); + groupClusterRepository.save(groupCluster); + return ResponseEntity.status(HttpStatus.OK).body(entityToJsonConverter.clusterEntityToClusterJson(groupCluster)); + } catch (Exception e) { + Logger.getGlobal().severe(e.getMessage()); + return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body("Something went wrong"); + } + } + + @PatchMapping(ApiRoutes.CLUSTER_BASE_PATH + "/{clusterid}") @Roles({UserRole.teacher, UserRole.student}) public ResponseEntity patchCluster(@PathVariable("clusterid") Long clusterid, Auth auth, @RequestBody GroupClusterUpdateJson clusterJson) { diff --git a/backend/app/src/main/java/com/ugent/pidgeon/controllers/CourseController.java b/backend/app/src/main/java/com/ugent/pidgeon/controllers/CourseController.java index 747ce82f..fa912b5f 100644 --- a/backend/app/src/main/java/com/ugent/pidgeon/controllers/CourseController.java +++ b/backend/app/src/main/java/com/ugent/pidgeon/controllers/CourseController.java @@ -9,6 +9,7 @@ import com.ugent.pidgeon.postgre.models.types.UserRole; import com.ugent.pidgeon.postgre.repository.*; import com.ugent.pidgeon.util.*; +import jakarta.validation.constraints.NotNull; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.http.HttpMethod; import org.springframework.http.HttpStatus; @@ -59,7 +60,6 @@ public class CourseController { public ResponseEntity getUserCourses(Auth auth, @RequestParam(value = "archived", required = false) Boolean archived) { long userID = auth.getUserEntity().getId(); try { - Logger.getGlobal().info("Archived: " + archived); List userCourses = new ArrayList<>(); if (archived == null || !archived) { userCourses.addAll(userRepository.findCourseIdsByUserId(userID)); @@ -67,6 +67,7 @@ public ResponseEntity getUserCourses(Auth auth, @RequestParam(value = "archiv if (archived == null || archived) { userCourses.addAll(userRepository.findArchivedCoursesByUserId(userID)); } + // Retrieve course entities based on user courses List courseJSONObjects = userCourses.stream() .map(courseWithRelation -> { @@ -79,7 +80,10 @@ public ResponseEntity getUserCourses(Auth auth, @RequestParam(value = "archiv ) .filter(Objects::nonNull) .toList(); + for (CourseWithRelationJson courseJson: courseJSONObjects) { + Logger.getGlobal().info("UserCourses: " + courseJson); + } // Return the JSON string in ResponseEntity return ResponseEntity.ok(courseJSONObjects); } catch (Exception e) { @@ -117,7 +121,7 @@ public ResponseEntity createCourse(@RequestBody CourseJson courseJson, Auth a courseEntity.setCreatedAt(currentTimestamp); courseEntity.setJoinKey(UUID.randomUUID().toString()); // Save course - courseRepository.save(courseEntity); + courseEntity = courseRepository.save(courseEntity); // Add user as course creator CourseUserEntity courseUserEntity = new CourseUserEntity(courseEntity.getId(), userId, CourseRelation.creator); @@ -147,7 +151,7 @@ private ResponseEntity doCourseUpdate(CourseEntity courseEntity, CourseJson c if (courseJson.getArchived() != null) { courseEntity.setArchivedAt(courseJson.getArchived() ? OffsetDateTime.now() : null); } - courseRepository.save(courseEntity); + courseEntity = courseRepository.save(courseEntity); return ResponseEntity.ok(entityToJsonConverter.courseEntityToCourseWithInfo(courseEntity, courseUtil.getJoinLink(courseEntity.getJoinKey(), "" + courseEntity.getId()), false)); } @@ -192,10 +196,6 @@ public ResponseEntity patchCourse(@RequestBody CourseJson courseJson, @PathVa return ResponseEntity.status(checkResult.getStatus()).body(checkResult.getMessage()); } - if (courseJson.getName() == null && courseJson.getDescription() == null && courseJson.getYear() == null) { - return ResponseEntity.status(HttpStatus.BAD_REQUEST).body("Name, description or year is required"); - } - CourseEntity courseEntity = checkResult.getData(); if (courseJson.getName() == null) { courseJson.setName(courseEntity.getName()); @@ -281,9 +281,9 @@ public ResponseEntity deleteCourse(@PathVariable long courseId, Auth auth) { } } - + Iterable courseUsers = courseUserRepository.findAllUsersByCourseId(courseId); // Delete all courseusers linked to the course - courseUserRepository.deleteAll(courseUserRepository.findAllUsersByCourseId(courseId)); + courseUserRepository.deleteAll(courseUsers); // Delete the course courseRepository.deleteById(courseId); @@ -390,7 +390,7 @@ public ResponseEntity joinCourse(Auth auth, @PathVariable Long courseId, @Pat */ @GetMapping(ApiRoutes.COURSE_BASE_PATH + "/{courseId}/join/{courseKey}") @Roles({UserRole.student, UserRole.teacher}) - public ResponseEntity getCourseJoinKey(Auth auth, @PathVariable Long courseId, @PathVariable String courseKey) { + public ResponseEntity getCourseJoinInformation(Auth auth, @PathVariable Long courseId, @PathVariable String courseKey) { return getJoinLinkGetResponseEntity(courseId, courseKey, auth.getUserEntity()); } @@ -424,7 +424,7 @@ public ResponseEntity joinCourse(Auth auth, @PathVariable Long courseId) { */ @GetMapping(ApiRoutes.COURSE_BASE_PATH + "/{courseId}/join") @Roles({UserRole.student, UserRole.teacher}) - public ResponseEntity getCourseJoinKey(Auth auth, @PathVariable Long courseId) { + public ResponseEntity getCourseJoinInformation(Auth auth, @PathVariable Long courseId) { return getJoinLinkGetResponseEntity(courseId, null, auth.getUserEntity()); } @@ -445,20 +445,7 @@ public ResponseEntity leaveCourse(@PathVariable long courseId, Auth auth) { try { long userId = auth.getUserEntity().getId(); CheckResult checkResult = courseUtil.canLeaveCourse(courseId, auth.getUserEntity()); - if (!checkResult.getStatus().equals(HttpStatus.OK)) { - return ResponseEntity.status(checkResult.getStatus()).body(checkResult.getMessage()); - } - CourseRelation userRelation = checkResult.getData(); - - // Delete the user from the course - courseUserRepository.deleteById(new CourseUserId(courseId, userId)); - if (userRelation.equals(CourseRelation.enrolled)) { - if (!commonDatabaseActions.removeIndividualClusterGroup(courseId, userId)) { - return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body("Failed to remove user from individual group, contact admin."); - } - } - - return ResponseEntity.ok().build(); + return doRemoveFromCourse(courseId, userId, checkResult); } catch (Exception e) { return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).build(); } @@ -480,6 +467,14 @@ public ResponseEntity leaveCourse(@PathVariable long courseId, Auth auth) { @Roles({UserRole.teacher, UserRole.admin, UserRole.student}) public ResponseEntity removeCourseMember(Auth auth, @PathVariable Long courseId, @PathVariable Long userId) { CheckResult checkResult = courseUtil.canDeleteUser(courseId, userId, auth.getUserEntity()); + return doRemoveFromCourse(courseId, userId, checkResult); + } + + @NotNull + private ResponseEntity doRemoveFromCourse( + Long courseId, + Long userId, + CheckResult checkResult) { if (!checkResult.getStatus().equals(HttpStatus.OK)) { return ResponseEntity.status(checkResult.getStatus()).body(checkResult.getMessage()); } @@ -562,7 +557,9 @@ public ResponseEntity updateCourseMember(Auth auth, @PathVariable Long course if (user == null) { return ResponseEntity.status(HttpStatus.NOT_FOUND).body("User not found"); } - commonDatabaseActions.createNewIndividualClusterGroup(courseId, user); + if (!commonDatabaseActions.createNewIndividualClusterGroup(courseId, user)) { + return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body("Failed to add user to individual group, contact admin."); + } } else if (courseUserEntity.getRelation().equals(CourseRelation.enrolled)){ if (!commonDatabaseActions.removeIndividualClusterGroup(courseId, requestwithid.getUserId())) { return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body("Failed to remove user from individual group, contact admin."); @@ -697,9 +694,12 @@ public ResponseEntity copyCourse(@PathVariable long courseId, Auth auth) { CourseEntity course = checkResult.getData().getFirst(); CheckResult copyCheckRes = commonDatabaseActions.copyCourse(course, auth.getUserEntity().getId()); + if (copyCheckRes.getStatus() != HttpStatus.OK) { + return ResponseEntity.status(copyCheckRes.getStatus()).body(copyCheckRes.getMessage()); + } CourseEntity newCourse = copyCheckRes.getData(); - return ResponseEntity.ok(entityToJsonConverter.courseEntityToCourseWithInfo(newCourse, courseUtil.getJoinLink(newCourse.getJoinKey(), "" + newCourse.getId()), false)); + return ResponseEntity.status(HttpStatus.CREATED).body(entityToJsonConverter.courseEntityToCourseWithInfo(newCourse, courseUtil.getJoinLink(newCourse.getJoinKey(), "" + newCourse.getId()), false)); } catch (Exception e) { return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).build(); } diff --git a/backend/app/src/main/java/com/ugent/pidgeon/controllers/GroupController.java b/backend/app/src/main/java/com/ugent/pidgeon/controllers/GroupController.java index 14bd4b7e..bdae9bec 100644 --- a/backend/app/src/main/java/com/ugent/pidgeon/controllers/GroupController.java +++ b/backend/app/src/main/java/com/ugent/pidgeon/controllers/GroupController.java @@ -136,7 +136,9 @@ public ResponseEntity deleteGroup(@PathVariable("groupid") Long groupid, Auth return ResponseEntity.status(checkResult.getStatus()).body(checkResult.getMessage()); } - commonDatabaseActions.removeGroup(groupid); + if (!commonDatabaseActions.removeGroup(groupid)) { + return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body("Error deleting group"); + } // Return 204 return ResponseEntity.status(HttpStatus.NO_CONTENT).body("Group deleted"); } diff --git a/backend/app/src/main/java/com/ugent/pidgeon/controllers/GroupFeedbackController.java b/backend/app/src/main/java/com/ugent/pidgeon/controllers/GroupFeedbackController.java index aa6b9e5d..49318700 100644 --- a/backend/app/src/main/java/com/ugent/pidgeon/controllers/GroupFeedbackController.java +++ b/backend/app/src/main/java/com/ugent/pidgeon/controllers/GroupFeedbackController.java @@ -36,12 +36,12 @@ public class GroupFeedbackController { private GroupUtil groupUtil; @Autowired private EntityToJsonConverter entityToJsonConverter; - @Autowired - private ProjectRepository projectRepository; - @Autowired - private GroupRepository groupRepository; - @Autowired - private CourseUtil courseUtil; + @Autowired + private ProjectRepository projectRepository; + @Autowired + private GroupRepository groupRepository; + @Autowired + private CourseUtil courseUtil; /** * Function to update the score of a group @@ -222,19 +222,19 @@ public ResponseEntity getCourseGrades(@PathVariable("courseId") long courseId List grades = new ArrayList<>(); for (ProjectEntity project : projects) { - Long GroupId = groupRepository.groupIdByProjectAndUser(project.getId(), user.getId()); - if (GroupId == null) { // Student not yet in a group for this project + Long groupId = groupRepository.groupIdByProjectAndUser(project.getId(), user.getId()); + if (groupId == null) { // Student not yet in a group for this project grades.add(entityToJsonConverter.groupFeedbackEntityToJsonWithProject(null, project)); - } - CheckResult checkResult = groupFeedbackUtil.getGroupFeedbackIfExists(GroupId, project.getId()); - if (checkResult.getStatus() != HttpStatus.OK) { - grades.add(entityToJsonConverter.groupFeedbackEntityToJsonWithProject(null, project)); } else { + CheckResult checkResult = groupFeedbackUtil.getGroupFeedbackIfExists(groupId, project.getId()); + if (checkResult.getStatus() != HttpStatus.OK) { + grades.add(entityToJsonConverter.groupFeedbackEntityToJsonWithProject(null, project)); + } else { GroupFeedbackEntity groupFeedbackEntity = checkResult.getData(); grades.add(entityToJsonConverter.groupFeedbackEntityToJsonWithProject(groupFeedbackEntity, project)); + } } } - return ResponseEntity.ok(grades); } diff --git a/backend/app/src/main/java/com/ugent/pidgeon/controllers/ProjectController.java b/backend/app/src/main/java/com/ugent/pidgeon/controllers/ProjectController.java index 53058ab6..24bf639d 100644 --- a/backend/app/src/main/java/com/ugent/pidgeon/controllers/ProjectController.java +++ b/backend/app/src/main/java/com/ugent/pidgeon/controllers/ProjectController.java @@ -82,7 +82,7 @@ public ResponseEntity getProjects(Auth auth) { } } - return ResponseEntity.ok().body(new userProjectsJson(enrolledProjects, adminProjects)); + return ResponseEntity.ok().body(new UserProjectsJson(enrolledProjects, adminProjects)); } @@ -311,7 +311,7 @@ public ResponseEntity getGroupsOfProject(@PathVariable Long projectId, Auth a * @return ResponseEntity with the status, no content */ @DeleteMapping(ApiRoutes.PROJECT_BASE_PATH + "/{projectId}") - @Roles({UserRole.teacher}) + @Roles({UserRole.teacher, UserRole.student}) public ResponseEntity deleteProjectById(@PathVariable long projectId, Auth auth) { CheckResult projectCheck = projectUtil.getProjectIfAdmin(projectId, auth.getUserEntity()); if (projectCheck.getStatus() != HttpStatus.OK) { diff --git a/backend/app/src/main/java/com/ugent/pidgeon/controllers/SubmissionController.java b/backend/app/src/main/java/com/ugent/pidgeon/controllers/SubmissionController.java index 4f9d6d15..31dcbf86 100644 --- a/backend/app/src/main/java/com/ugent/pidgeon/controllers/SubmissionController.java +++ b/backend/app/src/main/java/com/ugent/pidgeon/controllers/SubmissionController.java @@ -6,32 +6,35 @@ import com.ugent.pidgeon.model.json.GroupJson; import com.ugent.pidgeon.model.json.LastGroupSubmissionJson; import com.ugent.pidgeon.model.json.SubmissionJson; +import com.ugent.pidgeon.model.submissionTesting.DockerOutput; +import com.ugent.pidgeon.model.submissionTesting.DockerSubmissionTestModel; import com.ugent.pidgeon.model.submissionTesting.SubmissionTemplateModel; import com.ugent.pidgeon.postgre.models.*; +import com.ugent.pidgeon.postgre.models.types.DockerTestState; +import com.ugent.pidgeon.postgre.models.types.DockerTestType; import com.ugent.pidgeon.postgre.models.types.UserRole; import com.ugent.pidgeon.postgre.repository.*; import com.ugent.pidgeon.util.*; +import java.util.concurrent.CompletableFuture; +import java.util.logging.Level; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.core.io.Resource; import org.springframework.http.HttpHeaders; import org.springframework.http.HttpStatus; -import org.springframework.http.MediaType; import org.springframework.http.ResponseEntity; import org.springframework.transaction.annotation.Transactional; import org.springframework.web.bind.annotation.*; import org.springframework.web.multipart.MultipartFile; import java.io.File; -import java.io.IOException; import java.nio.file.Path; import java.time.OffsetDateTime; import java.util.List; -import java.util.function.Function; import java.util.logging.Logger; import java.util.zip.ZipFile; @RestController -public class SubmissionController { +public class SubmissionController { @Autowired private GroupRepository groupRepository; @@ -56,22 +59,12 @@ public class SubmissionController { private EntityToJsonConverter entityToJsonConverter; @Autowired private CommonDatabaseActions commonDatabaseActions; + @Autowired + private TestUtil testUtil; + @Autowired + private TestRunner testRunner; - private SubmissionTemplateModel.SubmissionResult runStructureTest(ZipFile file, TestEntity testEntity) throws IOException { - // Get the test file from the server - FileEntity testfileEntity = fileRepository.findById(testEntity.getStructureTestId()).orElse(null); - if (testfileEntity == null) { - return null; - } - String testfile = Filehandler.getStructureTestString(Path.of(testfileEntity.getPath())); - - // Parse the file - SubmissionTemplateModel model = new SubmissionTemplateModel(); - model.parseSubmissionTemplate(testfile); - - return model.checkSubmission(file); - } /** * Function to get a submission by its ID @@ -94,8 +87,8 @@ public ResponseEntity getSubmission(@PathVariable("submissionid") long submis SubmissionEntity submission = checkResult.getData(); SubmissionJson submissionJson = entityToJsonConverter.getSubmissionJson(submission); - return ResponseEntity.ok(submissionJson); - } + return ResponseEntity.ok(submissionJson); + } /** * Function to get all submissions @@ -129,7 +122,7 @@ public ResponseEntity getSubmissions(@PathVariable("projectid") long projecti if (groupFeedbackEntity == null) { groupFeedbackJson = null; } else { - groupFeedbackJson = new GroupFeedbackJson(groupFeedbackEntity.getScore(), groupFeedbackEntity.getFeedback(), groupFeedbackEntity.getGroupId(), groupFeedbackEntity.getProjectId()); + groupFeedbackJson = entityToJsonConverter.groupFeedbackEntityToJson(groupFeedbackEntity); } SubmissionEntity submission = submissionRepository.findLatestsSubmissionIdsByProjectAndGroupId(projectid, groupId).orElse(null); if (submission == null) { @@ -172,11 +165,11 @@ public ResponseEntity submitFile(@RequestParam("file") MultipartFile file, @P long groupId = checkResult.getData(); - //TODO: execute the docker tests onces these are implemented try { //Save the file entry in the database to get the id FileEntity fileEntity = new FileEntity("", "", userId); - long fileid = fileRepository.save(fileEntity).getId(); + fileEntity = fileRepository.save(fileEntity); + long fileid = fileEntity.getId(); OffsetDateTime now = OffsetDateTime.now(); SubmissionEntity submissionEntity = new SubmissionEntity( @@ -187,6 +180,7 @@ public ResponseEntity submitFile(@RequestParam("file") MultipartFile file, @P false, false ); + submissionEntity.setDockerTestState(DockerTestState.finished); //Save the submission in the database SubmissionEntity submission = submissionRepository.save(submissionEntity); @@ -202,35 +196,81 @@ public ResponseEntity submitFile(@RequestParam("file") MultipartFile file, @P fileEntity.setPath(pathname); fileRepository.save(fileEntity); + // Run structure tests + TestEntity testEntity = testRepository.findByProjectId(projectid).orElse(null); + SubmissionTemplateModel.SubmissionResult structureTestResult; + if (testEntity == null) { + Logger.getLogger("SubmissionController").info("no tests"); + submission.setStructureFeedback("No specific structure requested for this project."); + submission.setStructureAccepted(true); + } else { - // Run structure tests - TestEntity testEntity = testRepository.findByProjectId(projectid).orElse(null); - SubmissionTemplateModel.SubmissionResult testresult; - if (testEntity == null) { - Logger.getLogger("SubmissionController").info("no test"); - testresult = new SubmissionTemplateModel.SubmissionResult(true, "No structure requirements for this project."); - } else { - testresult = runStructureTest(new ZipFile(savedFile), testEntity); - } - if (testresult == null) { - return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body("Error while running tests: test files not found"); - } - submissionRepository.save(submissionEntity); - // Update the submission with the test resultsetAccepted - submission.setStructureAccepted(testresult.passed); - submission = submissionRepository.save(submission); + // Check file structure + SubmissionTemplateModel model = new SubmissionTemplateModel(); + structureTestResult = testRunner.runStructureTest(new ZipFile(savedFile), testEntity, model); + if (structureTestResult == null) { + submission.setStructureFeedback( + "No specific structure requested for this project."); + submission.setStructureAccepted(true); + } else { + submission.setStructureAccepted(structureTestResult.passed); + submission.setStructureFeedback(structureTestResult.feedback); + } - // Update the submission with the test feedbackfiles - submission.setDockerFeedback("TEMP DOCKER FEEDBACK"); - submission.setStructureFeedback(testresult.feedback); - submissionRepository.save(submission); + if (testEntity.getDockerTestTemplate() != null) { + submission.setDockerType(DockerTestType.TEMPLATE); + } else if (testEntity.getDockerTestScript() != null) { + submission.setDockerType(DockerTestType.SIMPLE); + } else { + submission.setDockerType(DockerTestType.NONE); + } - return ResponseEntity.ok(entityToJsonConverter.getSubmissionJson(submissionEntity)); - } catch (Exception e) { - return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body("Error while saving file: " + e.getMessage()); + // save the first feedback, without docker feedback + submissionRepository.save(submission); + + if (testEntity.getDockerTestScript() != null) { + // Define docker test as running + submission.setDockerTestState(DockerTestState.running); + // run docker tests in background + File finalSavedFile = savedFile; + Path artifactPath = Filehandler.getSubmissionArtifactPath(projectid, groupId, submission.getId()); + + CompletableFuture.runAsync(() -> { + try { + // Check if docker tests succeed + DockerSubmissionTestModel dockerModel = new DockerSubmissionTestModel(testEntity.getDockerImage()); + DockerOutput dockerOutput = testRunner.runDockerTest(new ZipFile(finalSavedFile), testEntity, artifactPath, dockerModel); + if (dockerOutput == null) { + throw new RuntimeException("Error while running docker tests."); + } + // Representation of dockerOutput, this will be a json(easily displayable in frontend) if it is a template test + // or a string if it is a simple test + submission.setDockerFeedback(dockerOutput.getFeedbackAsString()); + submission.setDockerAccepted(dockerOutput.isAllowed()); + + submission.setDockerTestState(DockerTestState.finished); + submissionRepository.save(submission); + } catch (Exception e) { + /* Log error */ + Logger.getLogger("SubmissionController").log(Level.SEVERE, e.getMessage(), e); + + submission.setDockerFeedback(""); + submission.setDockerAccepted(false); + + submission.setDockerTestState(DockerTestState.aborted); + submissionRepository.save(submission); + + } + }); } + } + return ResponseEntity.ok(entityToJsonConverter.getSubmissionJson(submission)); + } catch (Exception e) { + return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR) + .body("Failed to save submissions on file server."); } + } /** * Function to get a submission file @@ -260,7 +300,10 @@ public ResponseEntity getSubmissionFile(@PathVariable("submissionid") long su // Get the file from the server try { - Resource zipFile = Filehandler.getSubmissionAsResource(Path.of(file.getPath())); + Resource zipFile = Filehandler.getFileAsResource(Path.of(file.getPath())); + if (zipFile == null) { + return ResponseEntity.status(HttpStatus.NOT_FOUND).body("File not found."); + } // Set headers for the response HttpHeaders headers = new HttpHeaders(); @@ -275,54 +318,35 @@ public ResponseEntity getSubmissionFile(@PathVariable("submissionid") long su } } - - public ResponseEntity getFeedbackReponseEntity(long submissionid, Auth auth, Function feedbackGetter) { - + @GetMapping(ApiRoutes.SUBMISSION_BASE_PATH + "/{submissionid}/artifacts") //Route to get a submission + @Roles({UserRole.teacher, UserRole.student}) + public ResponseEntity getSubmissionArtifacts(@PathVariable("submissionid") long submissionid, Auth auth) { CheckResult checkResult = submissionUtil.canGetSubmission(submissionid, auth.getUserEntity()); if (!checkResult.getStatus().equals(HttpStatus.OK)) { return ResponseEntity.status(checkResult.getStatus()).body(checkResult.getMessage()); } SubmissionEntity submission = checkResult.getData(); - HttpHeaders headers = new HttpHeaders(); - headers.add(HttpHeaders.CONTENT_TYPE, String.valueOf(MediaType.TEXT_PLAIN)); - return ResponseEntity.ok().headers(headers).body(feedbackGetter.apply(submission)); - } + // Get the file from the server + try { + Resource zipFile = Filehandler.getFileAsResource(Filehandler.getSubmissionArtifactPath(submission.getProjectId(), submission.getGroupId(), submission.getId())); + if (zipFile == null) { + return ResponseEntity.status(HttpStatus.NOT_FOUND).body("No artifacts found for this submission."); + } + // Set headers for the response + HttpHeaders headers = new HttpHeaders(); + headers.add(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=artifacts.zip" ); + headers.add(HttpHeaders.CONTENT_TYPE, "application/zip"); - /** - * Function to get the structure feedback of a submission - * - * @param submissionid ID of the submission to get the feedback from - * @param auth authentication object of the requesting user - * @return ResponseEntity with the feedback - * @ApiDog apiDog documentation - * @HttpMethod GET - * @AllowedRoles teacher, student - * @ApiPath /api/submissions/{submissionid}/structurefeedback - */ - @GetMapping(ApiRoutes.SUBMISSION_BASE_PATH + "/{submissionid}/structurefeedback") - //Route to get the structure feedback - @Roles({UserRole.teacher, UserRole.student}) - public ResponseEntity getStructureFeedback(@PathVariable("submissionid") long submissionid, Auth auth) { - return getFeedbackReponseEntity(submissionid, auth, SubmissionEntity::getStructureFeedback); + return ResponseEntity.ok() + .headers(headers) + .body(zipFile); + } catch (Exception e) { + return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(e.getMessage()); + } } - /** - * Function to get the docker feedback of a submission - * - * @param submissionid ID of the submission to get the feedback from - * @param auth authentication object of the requesting user - * @return ResponseEntity with the feedback - * @ApiDog apiDog documentation - * @HttpMethod GET - * @AllowedRoles teacher, student - * @ApiPath /api/submissions/{submissionid}/dockerfeedback - */ - @GetMapping(ApiRoutes.SUBMISSION_BASE_PATH + "/{submissionid}/dockerfeedback") //Route to get the docker feedback - @Roles({UserRole.teacher, UserRole.student}) - public ResponseEntity getDockerFeedback(@PathVariable("submissionid") long submissionid, Auth auth) { - return getFeedbackReponseEntity(submissionid, auth, SubmissionEntity::getDockerFeedback); - } + /** diff --git a/backend/app/src/main/java/com/ugent/pidgeon/controllers/TestController.java b/backend/app/src/main/java/com/ugent/pidgeon/controllers/TestController.java index 39bc514c..3fc8ea2d 100644 --- a/backend/app/src/main/java/com/ugent/pidgeon/controllers/TestController.java +++ b/backend/app/src/main/java/com/ugent/pidgeon/controllers/TestController.java @@ -3,16 +3,20 @@ import com.ugent.pidgeon.auth.Roles; import com.ugent.pidgeon.model.Auth; import com.ugent.pidgeon.model.json.TestJson; +import com.ugent.pidgeon.model.json.TestUpdateJson; +import com.ugent.pidgeon.model.submissionTesting.DockerSubmissionTestModel; import com.ugent.pidgeon.postgre.models.*; import com.ugent.pidgeon.postgre.models.types.UserRole; import com.ugent.pidgeon.postgre.repository.*; import com.ugent.pidgeon.util.*; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CompletableFuture; +import java.util.logging.Logger; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.core.io.Resource; import org.springframework.http.*; import org.springframework.web.bind.annotation.*; import org.springframework.web.multipart.MultipartFile; -import java.io.*; import java.nio.file.Path; import java.util.Optional; @@ -36,12 +40,11 @@ public class TestController { private CommonDatabaseActions commonDatabaseActions; @Autowired private EntityToJsonConverter entityToJsonConverter; + @Autowired + private ProjectUtil projectUtil; /** * Function to update the tests of a project - * @param dockerImage the docker image to use for the tests - * @param dockerTest the docker test file - * @param structureTest the structure test file * @param projectId the id of the project to update the tests for * @param auth the authentication object of the requesting user * @HttpMethod POST @@ -53,34 +56,34 @@ public class TestController { @PostMapping(ApiRoutes.PROJECT_BASE_PATH + "/{projectid}/tests") @Roles({UserRole.teacher, UserRole.student}) public ResponseEntity updateTests( - @RequestParam(name = "dockerimage", required = false) String dockerImage, - @RequestParam(name = "dockertest", required = false) MultipartFile dockerTest, - @RequestParam(name = "structuretest", required = false) MultipartFile structureTest, - @PathVariable("projectid") long projectId, - Auth auth) { - return alterTests(projectId, auth.getUserEntity(), dockerImage, dockerTest, structureTest, HttpMethod.POST); + @RequestBody TestUpdateJson testJson, + + @PathVariable("projectid") long projectId, + Auth auth) { + return alterTests(projectId, auth.getUserEntity(), testJson.getDockerImage(), testJson.getDockerScript(), + testJson.getDockerTemplate(), testJson.getStructureTest(), HttpMethod.POST); } @PatchMapping(ApiRoutes.PROJECT_BASE_PATH + "/{projectid}/tests") @Roles({UserRole.teacher, UserRole.student}) public ResponseEntity patchTests( - @RequestParam(name = "dockerimage", required = false) String dockerImage, - @RequestParam(name = "dockertest", required = false) MultipartFile dockerTest, - @RequestParam(name = "structuretest", required = false) MultipartFile structureTest, - @PathVariable("projectid") long projectId, - Auth auth) { - return alterTests(projectId, auth.getUserEntity(), dockerImage, dockerTest, structureTest, HttpMethod.PATCH); + @RequestBody TestUpdateJson testJson, + + @PathVariable("projectid") long projectId, + Auth auth) { + return alterTests(projectId, auth.getUserEntity(), testJson.getDockerImage(), testJson.getDockerScript(), + testJson.getDockerTemplate(), testJson.getStructureTest(), HttpMethod.PATCH); } @PutMapping(ApiRoutes.PROJECT_BASE_PATH + "/{projectid}/tests") @Roles({UserRole.teacher, UserRole.student}) public ResponseEntity putTests( - @RequestParam(name = "dockerimage", required = false) String dockerImage, - @RequestParam(name = "dockertest", required = false) MultipartFile dockerTest, - @RequestParam(name = "structuretest", required = false) MultipartFile structureTest, + @RequestBody TestUpdateJson testJson, + @PathVariable("projectid") long projectId, Auth auth) { - return alterTests(projectId, auth.getUserEntity(), dockerImage, dockerTest, structureTest, HttpMethod.PUT); + return alterTests(projectId, auth.getUserEntity(), testJson.getDockerImage(), testJson.getDockerScript(), + testJson.getDockerTemplate(), testJson.getStructureTest(), HttpMethod.PUT); } @@ -88,48 +91,96 @@ private ResponseEntity alterTests( long projectId, UserEntity user, String dockerImage, - MultipartFile dockerTest, - MultipartFile structureTest, + String dockerScript, + String dockerTemplate, + String structureTemplate, HttpMethod httpMethod ) { - CheckResult> checkResult = testUtil.checkForTestUpdate(projectId, user, dockerImage, dockerTest, structureTest, httpMethod); - if (!checkResult.getStatus().equals(HttpStatus.OK)) { - return ResponseEntity.status(checkResult.getStatus()).body(checkResult.getMessage()); + + + if (dockerImage != null && dockerImage.isBlank()) { + dockerImage = null; + } + if (dockerScript != null && dockerScript.isBlank()) { + dockerScript = null; + } + if (dockerTemplate != null && dockerTemplate.isBlank()) { + dockerTemplate = null; + } + if (structureTemplate != null && structureTemplate.isBlank()) { + structureTemplate = null; + } + + CheckResult> updateCheckResult = testUtil.checkForTestUpdate(projectId, user, dockerImage, dockerScript, dockerTemplate, httpMethod); + + + if (!updateCheckResult.getStatus().equals(HttpStatus.OK)) { + return ResponseEntity.status(updateCheckResult.getStatus()).body(updateCheckResult.getMessage()); + } + + TestEntity testEntity = updateCheckResult.getData().getFirst(); + ProjectEntity projectEntity = updateCheckResult.getData().getSecond(); + + // Creating a test entry + if(httpMethod.equals(HttpMethod.POST)){ + testEntity = new TestEntity(); + } + + // Docker test + if(dockerImage != null) { + + // update/install image if possible, do so in a seperate thread to reduce wait time. + String finalDockerImage = dockerImage; + CompletableFuture.runAsync(() -> { + DockerSubmissionTestModel.installImage(finalDockerImage); + }); + } + + String oldDockerImage = testEntity.getDockerImage(); + + //Update fields + if (dockerImage != null || !httpMethod.equals(HttpMethod.PATCH)) { + testEntity.setDockerImage(dockerImage); + if (!testRepository.imageIsUsed(dockerImage)) { + // Do it on a different thread + String finalDockerImage1 = dockerImage; + CompletableFuture.runAsync(() -> { + DockerSubmissionTestModel.removeDockerImage( + finalDockerImage1); + }); + } + } + + if (dockerScript != null || !httpMethod.equals(HttpMethod.PATCH)) { + testEntity.setDockerTestScript(dockerScript); } - TestEntity testEntity = checkResult.getData().getFirst(); - ProjectEntity projectEntity = checkResult.getData().getSecond(); - - try { - - // Save the files on server - long dockertestFileEntityId; - long structuretestFileEntityId; - if (dockerTest != null) { - Path dockerTestPath = Filehandler.saveTest(dockerTest, projectId); - FileEntity dockertestFileEntity = fileUtil.saveFileEntity(dockerTestPath, projectId, user.getId()); - dockertestFileEntityId = dockertestFileEntity.getId(); - } else { - dockertestFileEntityId = testEntity.getDockerTestId(); - } - - if (structureTest != null) { - Path structureTestPath = Filehandler.saveTest(structureTest, projectId); - FileEntity structuretestFileEntity = fileUtil.saveFileEntity(structureTestPath, projectId, user.getId()); - structuretestFileEntityId = structuretestFileEntity.getId(); - } else { - structuretestFileEntityId = testEntity.getStructureTestId(); - } - - // Create/update test entity - TestEntity test = new TestEntity(dockerImage, dockertestFileEntityId, structuretestFileEntityId); - test = testRepository.save(test); - projectEntity.setTestId(test.getId()); - projectRepository.save(projectEntity); - return ResponseEntity.ok(entityToJsonConverter.testEntityToTestJson(test, projectId)); - } catch (IOException e) { - return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body("Error while saving files: " + e.getMessage()); + if (dockerTemplate != null || !httpMethod.equals(HttpMethod.PATCH)) { + testEntity.setDockerTestTemplate(dockerTemplate); } + + if (structureTemplate != null || !httpMethod.equals(HttpMethod.PATCH)) { + testEntity.setStructureTemplate(structureTemplate); + } + // save test entity + testEntity = testRepository.save(testEntity); + projectEntity.setTestId(testEntity.getId()); + projectRepository.save(projectEntity); // make sure to update test id in project + + // Uninstall dockerimage if necessary + if (oldDockerImage != null) { + if (!testRepository.imageIsUsed(oldDockerImage)) { + // Do it on a different thread + String finalDockerImage1 = oldDockerImage; + CompletableFuture.runAsync(() -> { + DockerSubmissionTestModel.removeDockerImage( + finalDockerImage1); + }); + } + } + + return ResponseEntity.ok(entityToJsonConverter.testEntityToTestJson(testEntity, projectId)); + } @@ -147,66 +198,19 @@ private ResponseEntity alterTests( @GetMapping(ApiRoutes.PROJECT_BASE_PATH + "/{projectid}/tests") @Roles({UserRole.teacher, UserRole.student}) public ResponseEntity getTests(@PathVariable("projectid") long projectId, Auth auth) { - CheckResult projectCheck = testUtil.getTestIfAdmin(projectId, auth.getUserEntity()); + CheckResult> projectCheck = testUtil.getTestWithAdminStatus(projectId, auth.getUserEntity()); if (!projectCheck.getStatus().equals(HttpStatus.OK)) { return ResponseEntity.status(projectCheck.getStatus()).body(projectCheck.getMessage()); } - TestEntity test = projectCheck.getData(); + TestEntity test = projectCheck.getData().getFirst(); + if (!projectCheck.getData().getSecond()) { // user is not an admin, hide script and image + test.setDockerTestScript(null); + test.setDockerImage(null); + } TestJson res = entityToJsonConverter.testEntityToTestJson(test, projectId); return ResponseEntity.ok(res); } - /** - * Function to get the structure test file of a project - * @param projectId the id of the project to get the structure test file for - * @param auth the authentication object of the requesting user - * @HttpMethod GET - * @ApiDog apiDog documentation - * @AllowedRoles teacher, student - * @ApiPath /api/projects/{projectid}/tests/structuretest - * @return ResponseEntity with the structure test file - */ - @GetMapping(ApiRoutes.PROJECT_BASE_PATH + "/{projectid}/tests/structuretest") - @Roles({UserRole.teacher, UserRole.student}) - public ResponseEntity getStructureTestFile(@PathVariable("projectid") long projectId, Auth auth) { - return getTestFileResponseEnity(projectId, auth, TestEntity::getStructureTestId); - } - - /** - * Function to get the docker test file of a project - * @param projectId the id of the project to get the docker test file for - * @param auth the authentication object of the requesting user - * @HttpMethod GET - * @ApiDog apiDog documentation - * @AllowedRoles teacher, student - * @ApiPath /api/projects/{projectid}/tests/dockertest - * @return ResponseEntity with the docker test file - */ - @GetMapping(ApiRoutes.PROJECT_BASE_PATH + "/{projectid}/tests/dockertest") - @Roles({UserRole.teacher, UserRole.student}) - public ResponseEntity getDockerTestFile(@PathVariable("projectid") long projectId, Auth auth) { - return getTestFileResponseEnity(projectId, auth, TestEntity::getDockerTestId); - } - - public ResponseEntity getTestFileResponseEnity(long projectId, Auth auth, Function testFileIdGetter) { - CheckResult projectCheck = testUtil.getTestIfAdmin(projectId, auth.getUserEntity()); - if (!projectCheck.getStatus().equals(HttpStatus.OK)) { - return ResponseEntity.status(projectCheck.getStatus()).body(projectCheck.getMessage()); - } - TestEntity testEntity = projectCheck.getData(); - - long testFileId = testFileIdGetter.apply(testEntity); - Optional fileEntity = fileRepository.findById(testFileId); - if (fileEntity.isEmpty()) { - return ResponseEntity.status(HttpStatus.NOT_FOUND).body("No file found for test with id: " + testFileId); - } - Resource file = Filehandler.getFileAsResource(Path.of(fileEntity.get().getPath())); - HttpHeaders headers = new HttpHeaders(); - headers.add(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=" + fileEntity.get().getName()); - headers.add(HttpHeaders.CONTENT_TYPE, String.valueOf(MediaType.TEXT_PLAIN)); - return ResponseEntity.ok().headers(headers).body(file); - } - /** * Function to delete the tests of a project * @param projectId the id of the test to delete @@ -220,10 +224,11 @@ public ResponseEntity getTestFileResponseEnity(long projectId, Auth auth, Fun @DeleteMapping(ApiRoutes.PROJECT_BASE_PATH + "/{projectid}/tests") @Roles({UserRole.teacher, UserRole.student}) public ResponseEntity deleteTestById(@PathVariable("projectid") long projectId, Auth auth) { - CheckResult> updateCheckResult = testUtil.checkForTestUpdate(projectId, auth.getUserEntity(), null, null, null, HttpMethod.DELETE); + CheckResult> updateCheckResult = testUtil.checkForTestUpdate(projectId, auth.getUserEntity(), null, null, null, HttpMethod.DELETE); if (!updateCheckResult.getStatus().equals(HttpStatus.OK)) { return ResponseEntity.status(updateCheckResult.getStatus()).body(updateCheckResult.getMessage()); } + ProjectEntity projectEntity = updateCheckResult.getData().getSecond(); TestEntity testEntity = updateCheckResult.getData().getFirst(); @@ -231,7 +236,6 @@ public ResponseEntity deleteTestById(@PathVariable("projectid") long projectI if (!deleteResult.getStatus().equals(HttpStatus.OK)) { return ResponseEntity.status(deleteResult.getStatus()).body(deleteResult.getMessage()); } - return ResponseEntity.ok().build(); } } diff --git a/backend/app/src/main/java/com/ugent/pidgeon/controllers/UserController.java b/backend/app/src/main/java/com/ugent/pidgeon/controllers/UserController.java index 7ec5e23f..7351275b 100644 --- a/backend/app/src/main/java/com/ugent/pidgeon/controllers/UserController.java +++ b/backend/app/src/main/java/com/ugent/pidgeon/controllers/UserController.java @@ -39,10 +39,10 @@ public class UserController { * @return user object */ @GetMapping(ApiRoutes.USERS_BASE_PATH + "/{userid}") - @Roles({UserRole.student}) + @Roles({UserRole.student, UserRole.teacher}) public ResponseEntity getUserById(@PathVariable("userid") Long userid,Auth auth) { UserEntity requester = auth.getUserEntity(); - if (requester.getId() != userid) { + if (requester.getId() != userid && requester.getRole() != UserRole.admin) { return ResponseEntity.status(HttpStatus.FORBIDDEN).body("You do not have access to this user"); } @@ -82,7 +82,6 @@ public ResponseEntity getUsersByNameOrSurname( return ResponseEntity.status(HttpStatus.OK).body(new ArrayList<>()); } - UserEntity user = null; if (name == null) name = ""; if (surname == null) surname = ""; @@ -164,7 +163,6 @@ public ResponseEntity patchUserById(@PathVariable("userid") Long userid, @Req userUpdateJson.setEmail(user.getEmail()); } - Logger.getGlobal().info(userUpdateJson.getRole()); if (userUpdateJson.getRole() == null) { userUpdateJson.setRole(user.getRole().toString()); } diff --git a/backend/app/src/main/java/com/ugent/pidgeon/model/json/ClusterFillJson.java b/backend/app/src/main/java/com/ugent/pidgeon/model/json/ClusterFillJson.java new file mode 100644 index 00000000..2990f5f9 --- /dev/null +++ b/backend/app/src/main/java/com/ugent/pidgeon/model/json/ClusterFillJson.java @@ -0,0 +1,25 @@ +package com.ugent.pidgeon.model.json; + +import java.util.HashMap; +import java.util.Map; + +public class ClusterFillJson { + private final Map clusterGroupMembers; + + public ClusterFillJson() { + this.clusterGroupMembers = new HashMap<>(); + } + + public ClusterFillJson(Map clusterGroupMembers) { + this.clusterGroupMembers = clusterGroupMembers; + } + + public Map getClusterGroupMembers() { + return clusterGroupMembers; + } + + public void addClusterGroupMembers(String clusterId, Long[] groupIds) { + clusterGroupMembers.put(clusterId, groupIds); + } + +} diff --git a/backend/app/src/main/java/com/ugent/pidgeon/model/json/DockerTestFeedbackJson.java b/backend/app/src/main/java/com/ugent/pidgeon/model/json/DockerTestFeedbackJson.java new file mode 100644 index 00000000..1fae8f07 --- /dev/null +++ b/backend/app/src/main/java/com/ugent/pidgeon/model/json/DockerTestFeedbackJson.java @@ -0,0 +1,14 @@ +package com.ugent.pidgeon.model.json; + + +import com.fasterxml.jackson.databind.annotation.JsonSerialize; +import com.ugent.pidgeon.postgre.models.types.DockerTestType; + +@JsonSerialize(using = DockerTestFeedbackJsonSerializer.class) +public record DockerTestFeedbackJson( + DockerTestType type, + String feedback, + boolean allowed +) { + +} diff --git a/backend/app/src/main/java/com/ugent/pidgeon/model/json/DockerTestFeedbackJsonSerializer.java b/backend/app/src/main/java/com/ugent/pidgeon/model/json/DockerTestFeedbackJsonSerializer.java new file mode 100644 index 00000000..29e541ad --- /dev/null +++ b/backend/app/src/main/java/com/ugent/pidgeon/model/json/DockerTestFeedbackJsonSerializer.java @@ -0,0 +1,24 @@ +package com.ugent.pidgeon.model.json; +import com.fasterxml.jackson.core.JsonGenerator; +import com.fasterxml.jackson.databind.JsonSerializer; +import com.fasterxml.jackson.databind.SerializerProvider; +import com.ugent.pidgeon.postgre.models.types.DockerTestType; +import java.io.IOException; + +public class DockerTestFeedbackJsonSerializer extends JsonSerializer { + + @Override + public void serialize(DockerTestFeedbackJson value, JsonGenerator gen, SerializerProvider serializers) throws IOException { + gen.writeStartObject(); + gen.writeStringField("type", value.type().toString()); + if (value.type() == DockerTestType.TEMPLATE) { + gen.writeFieldName("feedback"); + gen.writeRawValue(value.feedback().replace("\n", "\\n")); + } else { + gen.writeStringField("feedback", value.feedback()); + } + gen.writeBooleanField("allowed", value.allowed()); + gen.writeEndObject(); + } +} + diff --git a/backend/app/src/main/java/com/ugent/pidgeon/model/json/GroupClusterJson.java b/backend/app/src/main/java/com/ugent/pidgeon/model/json/GroupClusterJson.java index b78b0d66..30714044 100644 --- a/backend/app/src/main/java/com/ugent/pidgeon/model/json/GroupClusterJson.java +++ b/backend/app/src/main/java/com/ugent/pidgeon/model/json/GroupClusterJson.java @@ -13,7 +13,6 @@ public record GroupClusterJson( String courseUrl ) { - public GroupClusterJson { - } + } diff --git a/backend/app/src/main/java/com/ugent/pidgeon/model/json/ProjectJson.java b/backend/app/src/main/java/com/ugent/pidgeon/model/json/ProjectJson.java index ffff3f2e..abbf1b22 100644 --- a/backend/app/src/main/java/com/ugent/pidgeon/model/json/ProjectJson.java +++ b/backend/app/src/main/java/com/ugent/pidgeon/model/json/ProjectJson.java @@ -19,7 +19,7 @@ public class ProjectJson { @JsonSerialize(using = OffsetDateTimeSerializer.class) private OffsetDateTime deadline; - public ProjectJson(String name, String description, Long groupClusterId, Long testId, Boolean visible, Integer maxScore, OffsetDateTime deadline) { + public ProjectJson(String name, String description, Long groupClusterId, Boolean visible, Integer maxScore, OffsetDateTime deadline) { this.name = name; this.description = description; this.groupClusterId = groupClusterId; diff --git a/backend/app/src/main/java/com/ugent/pidgeon/model/json/ProjectStatus.java b/backend/app/src/main/java/com/ugent/pidgeon/model/json/ProjectStatus.java new file mode 100644 index 00000000..44a2c70b --- /dev/null +++ b/backend/app/src/main/java/com/ugent/pidgeon/model/json/ProjectStatus.java @@ -0,0 +1,18 @@ +package com.ugent.pidgeon.model.json; + +public enum ProjectStatus { + not_started, + correct, + incorrect, + no_group; + + @Override + public String toString() { + if (this == ProjectStatus.not_started) { + return "not started"; + } else if (this == ProjectStatus.no_group) { + return "no group"; + } + return super.toString(); + } +} diff --git a/backend/app/src/main/java/com/ugent/pidgeon/model/json/SubmissionJson.java b/backend/app/src/main/java/com/ugent/pidgeon/model/json/SubmissionJson.java index 49096d42..1128783a 100644 --- a/backend/app/src/main/java/com/ugent/pidgeon/model/json/SubmissionJson.java +++ b/backend/app/src/main/java/com/ugent/pidgeon/model/json/SubmissionJson.java @@ -14,29 +14,26 @@ public class SubmissionJson { private String fileUrl; private Boolean structureAccepted; - private Boolean dockerAccepted; + private String dockerStatus; @JsonSerialize(using = OffsetDateTimeSerializer.class) private OffsetDateTime submissionTime; - private String structureFeedbackUrl; + private String structureFeedback; - public String getDockerFeedbackUrl() { - return dockerFeedbackUrl; - } - public void setDockerFeedbackUrl(String dockerFeedbackUrl) { - this.dockerFeedbackUrl = dockerFeedbackUrl; - } + private DockerTestFeedbackJson dockerFeedback; + private String artifactUrl; + - private String dockerFeedbackUrl; public SubmissionJson() { } public SubmissionJson( long id, String projectUrl, String groupUrl, Long projectId, Long groupId, String fileUrl, - Boolean structureAccepted, OffsetDateTime submissionTime, Boolean dockerAccepted, String structureFeedbackUrl, String dockerFeedbackUrl) { + Boolean structureAccepted, OffsetDateTime submissionTime, String structureFeedback, DockerTestFeedbackJson dockerFeedback, String dockerStatus, + String artifactUrl) { this.submissionId = id; this.projectUrl = projectUrl; this.groupUrl = groupUrl; @@ -45,9 +42,10 @@ public SubmissionJson( this.fileUrl = fileUrl; this.structureAccepted = structureAccepted; this.submissionTime = submissionTime; - this.dockerAccepted = dockerAccepted; - this.structureFeedbackUrl = structureFeedbackUrl; - this.dockerFeedbackUrl = dockerFeedbackUrl; + this.dockerFeedback = dockerFeedback; + this.structureFeedback = structureFeedback; + this.dockerStatus = dockerStatus; + this.artifactUrl = artifactUrl; } public long getSubmissionId() { @@ -98,20 +96,14 @@ public void setSubmissionTime(OffsetDateTime submissionTime) { this.submissionTime = submissionTime; } - public Boolean getDockerAccepted() { - return dockerAccepted; - } - public void setDockerAccepted(Boolean dockerAccepted) { - this.dockerAccepted = dockerAccepted; - } - public String getStructureFeedbackUrl() { - return structureFeedbackUrl; + public String getStructureFeedback() { + return structureFeedback; } - public void setStructureFeedbackUrl(String structureFeedbackUrl) { - this.structureFeedbackUrl = structureFeedbackUrl; + public void setStructureFeedback(String structureFeedback) { + this.structureFeedback = structureFeedback; } public Long getProjectId() { @@ -129,4 +121,28 @@ public Long getGroupId() { public void setGroupId(Long groupId) { this.groupId = groupId; } + + public String getDockerStatus() { + return dockerStatus; + } + + public void setDockerStatus(String dockerStatus) { + this.dockerStatus = dockerStatus; + } + + public DockerTestFeedbackJson getDockerFeedback() { + return dockerFeedback; + } + + public void setDockerFeedback(DockerTestFeedbackJson dockerFeedback) { + this.dockerFeedback = dockerFeedback; + } + + public String getArtifactUrl() { + return artifactUrl; + } + + public void setArtifactUrl(String artifactUrl) { + this.artifactUrl = artifactUrl; + } } diff --git a/backend/app/src/main/java/com/ugent/pidgeon/model/json/TestJson.java b/backend/app/src/main/java/com/ugent/pidgeon/model/json/TestJson.java index 35c5e039..e2c0d034 100644 --- a/backend/app/src/main/java/com/ugent/pidgeon/model/json/TestJson.java +++ b/backend/app/src/main/java/com/ugent/pidgeon/model/json/TestJson.java @@ -3,17 +3,20 @@ public class TestJson { private String projectUrl; private String dockerImage; - private String dockerTestUrl; - private String structureTestUrl; + private String dockerScript; + private String dockerTemplate; + private String structureTest; public TestJson() { } - public TestJson(String projectUrl, String dockerImage, String dockerTestUrl, String structureTestUrl) { + public TestJson(String projectUrl, String dockerImage, String dockerScript, + String dockerTemplate, String structureTest) { this.projectUrl = projectUrl; this.dockerImage = dockerImage; - this.dockerTestUrl = dockerTestUrl; - this.structureTestUrl = structureTestUrl; + this.dockerScript = dockerScript; + this.dockerTemplate = dockerTemplate; + this.structureTest = structureTest; } public String getProjectUrl() { @@ -32,19 +35,27 @@ public void setDockerImage(String dockerImage) { this.dockerImage = dockerImage; } - public String getDockerTestUrl() { - return dockerTestUrl; + public String getDockerScript() { + return dockerScript; } - public void setDockerTestUrl(String dockerTestUrl) { - this.dockerTestUrl = dockerTestUrl; + public void setDockerScript(String dockerScript) { + this.dockerScript = dockerScript; } - public String getStructureTestUrl() { - return structureTestUrl; + public String getStructureTest() { + return structureTest; } - public void setStructureTestUrl(String structureTestUrl) { - this.structureTestUrl = structureTestUrl; + public void setStructureTest(String structureTest) { + this.structureTest = structureTest; + } + + public String getDockerTemplate() { + return dockerTemplate; + } + + public void setDockerTemplate(String dockerTemplate) { + this.dockerTemplate = dockerTemplate; } } diff --git a/backend/app/src/main/java/com/ugent/pidgeon/model/json/TestUpdateJson.java b/backend/app/src/main/java/com/ugent/pidgeon/model/json/TestUpdateJson.java new file mode 100644 index 00000000..7ab6cbbb --- /dev/null +++ b/backend/app/src/main/java/com/ugent/pidgeon/model/json/TestUpdateJson.java @@ -0,0 +1,49 @@ +package com.ugent.pidgeon.model.json; + +public class TestUpdateJson { + private String dockerImage; + private String dockerScript; + private String dockerTemplate; + private String structureTest; + + public TestUpdateJson(String dockerImage, String dockerScript, String dockerTemplate, String structureTest) { + this.dockerImage = dockerImage; + this.dockerScript = dockerScript; + this.dockerTemplate = dockerTemplate; + this.structureTest = structureTest; + } + + public String getDockerImage() { + return dockerImage; + } + + public void setDockerImage(String dockerImage) { + this.dockerImage = dockerImage; + } + + public String getDockerScript() { + return dockerScript; + } + + public void setDockerScript(String dockerScript) { + this.dockerScript = dockerScript; + } + + public String getDockerTemplate() { + return dockerTemplate; + } + + public void setDockerTemplate(String dockerTemplate) { + this.dockerTemplate = dockerTemplate; + } + + public String getStructureTest() { + return structureTest; + } + + public void setStructureTest(String structureTest) { + this.structureTest = structureTest; + } + +} + diff --git a/backend/app/src/main/java/com/ugent/pidgeon/model/json/userProjectsJson.java b/backend/app/src/main/java/com/ugent/pidgeon/model/json/UserProjectsJson.java similarity index 73% rename from backend/app/src/main/java/com/ugent/pidgeon/model/json/userProjectsJson.java rename to backend/app/src/main/java/com/ugent/pidgeon/model/json/UserProjectsJson.java index 85b1c909..6560acb6 100644 --- a/backend/app/src/main/java/com/ugent/pidgeon/model/json/userProjectsJson.java +++ b/backend/app/src/main/java/com/ugent/pidgeon/model/json/UserProjectsJson.java @@ -3,6 +3,6 @@ import com.ugent.pidgeon.model.ProjectResponseJson; import java.util.List; -public record userProjectsJson(List enrolledProjects, List adminProjects) { +public record UserProjectsJson(List enrolledProjects, List adminProjects) { } diff --git a/backend/app/src/main/java/com/ugent/pidgeon/model/submissionTesting/DockerOutput.java b/backend/app/src/main/java/com/ugent/pidgeon/model/submissionTesting/DockerOutput.java new file mode 100644 index 00000000..4fc07377 --- /dev/null +++ b/backend/app/src/main/java/com/ugent/pidgeon/model/submissionTesting/DockerOutput.java @@ -0,0 +1,8 @@ +package com.ugent.pidgeon.model.submissionTesting; + +import java.util.List; + +public interface DockerOutput { + public boolean isAllowed(); + public String getFeedbackAsString(); +} diff --git a/backend/app/src/main/java/com/ugent/pidgeon/model/submissionTesting/DockerSubmissionTestModel.java b/backend/app/src/main/java/com/ugent/pidgeon/model/submissionTesting/DockerSubmissionTestModel.java index 872e9afa..7e67c969 100644 --- a/backend/app/src/main/java/com/ugent/pidgeon/model/submissionTesting/DockerSubmissionTestModel.java +++ b/backend/app/src/main/java/com/ugent/pidgeon/model/submissionTesting/DockerSubmissionTestModel.java @@ -16,7 +16,10 @@ import java.io.IOException; import java.util.ArrayList; import java.util.Arrays; +import java.util.Enumeration; import java.util.List; +import java.util.zip.ZipEntry; +import java.util.zip.ZipFile; import org.apache.commons.io.FileUtils; public class DockerSubmissionTestModel { @@ -48,6 +51,11 @@ public DockerSubmissionTestModel(String dockerImage) { createFolder(); // Create the folder after we// generate tmp folder of project // Configure container with volume bindings container.withHostConfig(new HostConfig().withBinds(new Bind(localMountFolder, sharedVolume))); + + // Init directories in the shared folder + new File(localMountFolder + "input/").mkdirs(); + new File(localMountFolder + "output/").mkdirs(); + new File(localMountFolder + "artifacts/").mkdirs(); } @@ -63,31 +71,50 @@ private void removeFolder() { // clear shared folder } } - public DockerTestOutput runSubmission(String script) throws InterruptedException { - return runSubmission(script, new File[0]); + // function for deleting shared docker files, only use after catching the artifacts + public void cleanUp() { + removeFolder(); } - private void runContainer(String script, File[] inputFiles, ResultCallback.Adapter callback) { - - // Init directories in the shared folder - new File(localMountFolder + "input/").mkdirs(); - new File(localMountFolder + "output/").mkdirs(); - - // Copy input files to the shared folder - for (File file : inputFiles) { + public void addInputFiles(File[] files) { + for (File file : files) { try { FileUtils.copyFileToDirectory(file, new File(localMountFolder + "input/")); } catch (IOException e) { e.printStackTrace(); } } + } + + public void addZipInputFiles(ZipFile zipFile) { + Enumeration entries = zipFile.entries(); + while (entries.hasMoreElements()) { + ZipEntry entry = entries.nextElement(); + File entryDestination = new File(localMountFolder + "input/", entry.getName()); + if (entry.isDirectory()) { + entryDestination.mkdirs(); + } else { + File parent = entryDestination.getParentFile(); + if (parent != null) { + parent.mkdirs(); + } + try { + FileUtils.copyInputStreamToFile(zipFile.getInputStream(entry), entryDestination); + } catch (IOException e) { + e.printStackTrace(); + } + } + } + } + + private void runContainer(String script, ResultCallback.Adapter callback) { // Configure and start the container container.withCmd("/bin/sh", "-c", script); CreateContainerResponse responseContainer = container.exec(); String executionContainerID = responseContainer.getId(); // Use correct ID for operations dockerClient.startContainerCmd(executionContainerID).exec(); - try{ + try { dockerClient.logContainerCmd(executionContainerID) .withStdOut(true) .withStdErr(true) @@ -95,7 +122,7 @@ private void runContainer(String script, File[] inputFiles, ResultCallback.Adapt .withTailAll() .exec(callback) .awaitCompletion(); - }catch (InterruptedException e){ + } catch (InterruptedException e) { System.err.println("Failed to read output file. Push is denied."); } @@ -107,8 +134,7 @@ private void runContainer(String script, File[] inputFiles, ResultCallback.Adapt } - public DockerTestOutput runSubmission(String script, File[] inputFiles) - { + public DockerTestOutput runSubmission(String script) { List consoleLogs = new ArrayList<>(); ResultCallback.Adapter callback = new ResultCallback.Adapter<>() { @@ -117,7 +143,7 @@ public void onNext(Frame item) { consoleLogs.add(new String(item.getPayload())); } }; - runContainer(script, inputFiles, callback); + runContainer(script, callback); boolean allowPush; @@ -133,15 +159,12 @@ public void onNext(Frame item) { allowPush = false; } - // Cleanup - removeFolder(); return new DockerTestOutput(consoleLogs, allowPush); } - public DockerTemplateTestResult runSubmissionWithTemplate(String script, String template, - File[] inputFiles) throws InterruptedException { + public DockerTemplateTestOutput runSubmissionWithTemplate(String script, String template) { - runContainer(script, inputFiles, new Adapter<>()); + runContainer(script, new Adapter<>()); // execute dockerClient and await @@ -171,9 +194,6 @@ public DockerTemplateTestResult runSubmissionWithTemplate(String script, String } } - // Cleanup - removeFolder(); - // Check if allowed boolean allowed = true; for (DockerSubtestResult result : results) { @@ -183,7 +203,7 @@ public DockerTemplateTestResult runSubmissionWithTemplate(String script, String } } - return new DockerTemplateTestResult(results, allowed); + return new DockerTemplateTestOutput(results, allowed); } private static DockerSubtestResult getDockerSubtestResult(String entry) { @@ -217,15 +237,29 @@ private static DockerSubtestResult getDockerSubtestResult(String entry) { templateEntry.setRequired(true); } else if (currentOption.equalsIgnoreCase(">Optional")) { templateEntry.setRequired(false); - } else if (currentOption.substring(0, 12).equalsIgnoreCase(">Description")) { + } else if (currentOption.length() >=13 && currentOption.substring(0, 13).equalsIgnoreCase(">Description=")) { templateEntry.setTestDescription(currentOption.split("=\"")[1].split("\"")[0]); } } - templateEntry.setCorrect(entry.substring(lineIterator)); + String substring = entry.substring(lineIterator); + if (substring.endsWith("\n")) { + substring = substring.substring(0, substring.length() - 1); + } + templateEntry.setCorrect(substring); return templateEntry; } - public static void addDocker(String imageName) { + public List getArtifacts() { + List files = new ArrayList<>(); + File[] filesInFolder = new File(localMountFolder + "artifacts/").listFiles(); + if (filesInFolder != null) { + files.addAll(Arrays.asList(filesInFolder)); + } + return files; + } + + + public static void installImage(String imageName) { DockerClient dockerClient = DockerClientInstance.getInstance(); // Pull the Docker image (if not already present) @@ -247,4 +281,50 @@ public static void removeDockerImage(String imageName) { System.out.println("Failed removing docker image: " + e.getMessage()); } } + + public static boolean imageExists(String image) { + DockerClient dockerClient = DockerClientInstance.getInstance(); + try { + dockerClient.inspectImageCmd(image).exec(); + } catch (Exception e) { + return false; + } + return true; + } + + public static boolean isValidTemplate(String template) { + // lines with @ should be the first of a string + // @ is always the first character + // ">" options under the template should be "required, optional or description="..." + boolean atLeastOne = false; // Template should not be empty + String[] lines = template.split("\n"); + if (lines[0].charAt(0) != '@') { + return false; + } + boolean isConfigurationLine = false; + for (String line : lines) { + if(line.length() == 0){ // skip line if empty + continue; + } + if (line.charAt(0) == '@') { + atLeastOne = true; + isConfigurationLine = true; + continue; + } + if (isConfigurationLine) { + if (line.charAt(0) == '>') { + boolean isDescription = line.length() >= 13 && line.substring(0, 13).equalsIgnoreCase(">Description="); + // option lines + if (!line.equalsIgnoreCase(">Required") && !line.equalsIgnoreCase(">Optional") + && !isDescription) { + return false; + } + } else { + isConfigurationLine = false; + } + } + } + return atLeastOne; + } + } diff --git a/backend/app/src/main/java/com/ugent/pidgeon/model/submissionTesting/DockerSubtestResult.java b/backend/app/src/main/java/com/ugent/pidgeon/model/submissionTesting/DockerSubtestResult.java index 43869cc9..c0c70fcd 100644 --- a/backend/app/src/main/java/com/ugent/pidgeon/model/submissionTesting/DockerSubtestResult.java +++ b/backend/app/src/main/java/com/ugent/pidgeon/model/submissionTesting/DockerSubtestResult.java @@ -1,6 +1,6 @@ package com.ugent.pidgeon.model.submissionTesting; -public class DockerSubtestResult { +public class DockerSubtestResult implements DockerOutput { private String correct; private String output; private String testName; @@ -59,4 +59,17 @@ public boolean isRequired() { public void setRequired(boolean required) { this.required = required; } + + @Override + public boolean isAllowed() { + return correct.equals(output); + } + + @Override + public String getFeedbackAsString() { + // Display feedback as a json, only display testName and testDescription if they are not empty + String testDescription = this.testDescription.isEmpty() ? "" : "\",\"testDescription\":\"" + this.testDescription; + //TODO add allowed to json + return "{\"testName\":\"" + testName + testDescription + "\",\"correct\":\"" + correct + "\",\"output\":\"" + output + "\", \"required\":" + required + ", \"succes\": " + isAllowed() + "}"; + } } diff --git a/backend/app/src/main/java/com/ugent/pidgeon/model/submissionTesting/DockerTemplateTestOutput.java b/backend/app/src/main/java/com/ugent/pidgeon/model/submissionTesting/DockerTemplateTestOutput.java new file mode 100644 index 00000000..78d19c4d --- /dev/null +++ b/backend/app/src/main/java/com/ugent/pidgeon/model/submissionTesting/DockerTemplateTestOutput.java @@ -0,0 +1,32 @@ +package com.ugent.pidgeon.model.submissionTesting; + +import java.util.List; + +public class DockerTemplateTestOutput implements DockerOutput{ + private List subtestResults; + private boolean allowed; + + public List getSubtestResults() { + return subtestResults; + } + + @Override + public boolean isAllowed() { + return allowed; + } + + public DockerTemplateTestOutput(List subtestResults, boolean allowed) { + this.subtestResults = subtestResults; + this.allowed = allowed; + } + @Override + public String getFeedbackAsString(){ + //json representation of the tests + StringBuilder feedback = new StringBuilder("{\"subtests\": ["); + for (DockerSubtestResult subtestResult : subtestResults) { + feedback.append(subtestResult.getFeedbackAsString()).append(","); + } + feedback.append("]"); + return feedback.toString(); + } +} diff --git a/backend/app/src/main/java/com/ugent/pidgeon/model/submissionTesting/DockerTemplateTestResult.java b/backend/app/src/main/java/com/ugent/pidgeon/model/submissionTesting/DockerTemplateTestResult.java deleted file mode 100644 index 1ece985d..00000000 --- a/backend/app/src/main/java/com/ugent/pidgeon/model/submissionTesting/DockerTemplateTestResult.java +++ /dev/null @@ -1,21 +0,0 @@ -package com.ugent.pidgeon.model.submissionTesting; - -import java.util.List; - -public class DockerTemplateTestResult { - private List subtestResults; - private boolean allowed; - - public List getSubtestResults() { - return subtestResults; - } - - public boolean isAllowed() { - return allowed; - } - - public DockerTemplateTestResult(List subtestResults, boolean allowed) { - this.subtestResults = subtestResults; - this.allowed = allowed; - } -} diff --git a/backend/app/src/main/java/com/ugent/pidgeon/model/submissionTesting/DockerTestOutput.java b/backend/app/src/main/java/com/ugent/pidgeon/model/submissionTesting/DockerTestOutput.java index 25e27dc5..6b9e520a 100644 --- a/backend/app/src/main/java/com/ugent/pidgeon/model/submissionTesting/DockerTestOutput.java +++ b/backend/app/src/main/java/com/ugent/pidgeon/model/submissionTesting/DockerTestOutput.java @@ -2,7 +2,7 @@ import java.util.List; -public class DockerTestOutput { +public class DockerTestOutput implements DockerOutput { public List logs; public Boolean allowed; @@ -11,4 +11,13 @@ public DockerTestOutput(List logs, Boolean allowed) { this.allowed = allowed; } + @Override + public boolean isAllowed() { + return allowed; + } + + @Override + public String getFeedbackAsString() { + return String.join("", logs); + } } diff --git a/backend/app/src/main/java/com/ugent/pidgeon/postgre/models/SubmissionEntity.java b/backend/app/src/main/java/com/ugent/pidgeon/postgre/models/SubmissionEntity.java index 65803ff0..2ecdcd8e 100644 --- a/backend/app/src/main/java/com/ugent/pidgeon/postgre/models/SubmissionEntity.java +++ b/backend/app/src/main/java/com/ugent/pidgeon/postgre/models/SubmissionEntity.java @@ -1,5 +1,7 @@ package com.ugent.pidgeon.postgre.models; +import com.ugent.pidgeon.postgre.models.types.DockerTestType; +import com.ugent.pidgeon.postgre.models.types.DockerTestState; import jakarta.persistence.*; import java.time.OffsetDateTime; @@ -36,6 +38,12 @@ public class SubmissionEntity { @Column(name="docker_feedback") private String dockerFeedback; + @Column(name="docker_test_state") + private String dockerTestState; + + @Column(name="docker_type") + private String dockerType; + public SubmissionEntity() { } @@ -116,4 +124,35 @@ public String getDockerFeedback() { public void setDockerFeedback(String dockerFeedbackFileId) { this.dockerFeedback = dockerFeedbackFileId; } + public DockerTestState getDockerTestState() { + if(dockerTestState == null) { + return DockerTestState.no_test; + } + return switch (dockerTestState) { + case "running" -> DockerTestState.running; + case "finished" -> DockerTestState.finished; + case "aborted" -> DockerTestState.aborted; + default -> null; + }; + } + + public void setDockerTestState(DockerTestState dockerTestState) { + this.dockerTestState = dockerTestState.toString(); + } + + public DockerTestType getDockerTestType() { + if (dockerType == null) { + return DockerTestType.NONE; + } + return switch (dockerType) { + case "SIMPLE" -> DockerTestType.SIMPLE; + case "TEMPLATE" -> DockerTestType.TEMPLATE; + case "NONE" -> DockerTestType.NONE; + default -> null; + }; + } + + public void setDockerType(DockerTestType dockerType) { + this.dockerType = dockerType.toString(); + } } diff --git a/backend/app/src/main/java/com/ugent/pidgeon/postgre/models/TestEntity.java b/backend/app/src/main/java/com/ugent/pidgeon/postgre/models/TestEntity.java index f0aac3e0..885c6a49 100644 --- a/backend/app/src/main/java/com/ugent/pidgeon/postgre/models/TestEntity.java +++ b/backend/app/src/main/java/com/ugent/pidgeon/postgre/models/TestEntity.java @@ -15,30 +15,44 @@ public class TestEntity { @Column(name = "docker_image") private String dockerImage; - @Column(name = "docker_test") - private long dockerTestId; + @Column(name = "docker_test_script") + private String dockerTestScript; - @Column(name = "structure_test_id") - private long structureTestId; + @Column(name = "docker_test_template") + private String dockerTestTemplate; - public TestEntity() { - } + @Column(name = "structure_template") + private String structureTemplate; - public TestEntity(String dockerImage, long dockerTestId, long structureTestId) { + public TestEntity(String dockerImage, String docker_test_script, + String dockerTestTemplate, + String structureTemplate) { this.dockerImage = dockerImage; - this.dockerTestId = dockerTestId; - this.structureTestId = structureTestId; + this.dockerTestScript = docker_test_script; + this.dockerTestTemplate = dockerTestTemplate; + this.structureTemplate = structureTemplate; } + public TestEntity() { + + } - public void setId(Long id) { - this.id = id; + public String getDockerTestScript() { + return dockerTestScript; } - public Long getId() { + public void setDockerTestScript(String docker_test_script) { + this.dockerTestScript = docker_test_script; + } + + public long getId() { return id; } + public void setId(long id) { + this.id = id; + } + public String getDockerImage() { return dockerImage; } @@ -47,19 +61,19 @@ public void setDockerImage(String dockerImage) { this.dockerImage = dockerImage; } - public long getDockerTestId() { - return dockerTestId; + public String getDockerTestTemplate() { + return dockerTestTemplate; } - public void setDockerTestId(long dockerTest) { - this.dockerTestId = dockerTest; + public void setDockerTestTemplate(String dockerTestTemplate) { + this.dockerTestTemplate = dockerTestTemplate; } - public long getStructureTestId() { - return structureTestId; + public String getStructureTemplate() { + return structureTemplate; } - public void setStructureTestId(long structureTestId) { - this.structureTestId = structureTestId; + public void setStructureTemplate(String structureTemplate) { + this.structureTemplate = structureTemplate; } } diff --git a/backend/app/src/main/java/com/ugent/pidgeon/postgre/models/types/DockerTestState.java b/backend/app/src/main/java/com/ugent/pidgeon/postgre/models/types/DockerTestState.java new file mode 100644 index 00000000..e1a84616 --- /dev/null +++ b/backend/app/src/main/java/com/ugent/pidgeon/postgre/models/types/DockerTestState.java @@ -0,0 +1,9 @@ +package com.ugent.pidgeon.postgre.models.types; + +public enum DockerTestState { + running, + finished, + aborted, + + no_test +} diff --git a/backend/app/src/main/java/com/ugent/pidgeon/postgre/models/types/DockerTestType.java b/backend/app/src/main/java/com/ugent/pidgeon/postgre/models/types/DockerTestType.java new file mode 100644 index 00000000..eefd122c --- /dev/null +++ b/backend/app/src/main/java/com/ugent/pidgeon/postgre/models/types/DockerTestType.java @@ -0,0 +1,7 @@ +package com.ugent.pidgeon.postgre.models.types; + +public enum DockerTestType { + SIMPLE, + TEMPLATE, + NONE +} diff --git a/backend/app/src/main/java/com/ugent/pidgeon/postgre/repository/SubmissionRepository.java b/backend/app/src/main/java/com/ugent/pidgeon/postgre/repository/SubmissionRepository.java index 0f93ae96..a2df7c9a 100644 --- a/backend/app/src/main/java/com/ugent/pidgeon/postgre/repository/SubmissionRepository.java +++ b/backend/app/src/main/java/com/ugent/pidgeon/postgre/repository/SubmissionRepository.java @@ -27,6 +27,7 @@ SELECT MAX(s2.submissionTime) FROM SubmissionEntity s2 WHERE s2.groupId = :groupId AND s2.projectId = :projectId + AND s2.dockerTestState != :#{T(com.ugent.pidgeon.postgre.models.types.DockerTestState).running.toString()} ) ORDER BY s.id DESC LIMIT 1 """) Optional findLatestsSubmissionIdsByProjectAndGroupId(long projectId, long groupId); diff --git a/backend/app/src/main/java/com/ugent/pidgeon/postgre/repository/TestRepository.java b/backend/app/src/main/java/com/ugent/pidgeon/postgre/repository/TestRepository.java index 7a0c5fb6..744d016c 100644 --- a/backend/app/src/main/java/com/ugent/pidgeon/postgre/repository/TestRepository.java +++ b/backend/app/src/main/java/com/ugent/pidgeon/postgre/repository/TestRepository.java @@ -7,6 +7,14 @@ import java.util.Optional; public interface TestRepository extends JpaRepository { + @Query(value = """ + SELECT CASE WHEN EXISTS (SELECT t FROM TestEntity t WHERE t.dockerImage = ?1) + THEN true + ELSE false + END + """) + boolean imageIsUsed(String image); + @Query(value ="SELECT t FROM ProjectEntity p JOIN TestEntity t ON p.testId = t.id WHERE p.id = ?1") Optional findByProjectId(long projectId); diff --git a/backend/app/src/main/java/com/ugent/pidgeon/util/ClusterUtil.java b/backend/app/src/main/java/com/ugent/pidgeon/util/ClusterUtil.java index dd64c5c5..885859f1 100644 --- a/backend/app/src/main/java/com/ugent/pidgeon/util/ClusterUtil.java +++ b/backend/app/src/main/java/com/ugent/pidgeon/util/ClusterUtil.java @@ -1,12 +1,20 @@ package com.ugent.pidgeon.util; +import com.ugent.pidgeon.model.json.ClusterFillJson; import com.ugent.pidgeon.model.json.GroupClusterCreateJson; import com.ugent.pidgeon.model.json.GroupClusterUpdateJson; import com.ugent.pidgeon.postgre.models.CourseEntity; +import com.ugent.pidgeon.postgre.models.CourseUserEntity; +import com.ugent.pidgeon.postgre.models.CourseUserId; import com.ugent.pidgeon.postgre.models.GroupClusterEntity; import com.ugent.pidgeon.postgre.models.UserEntity; import com.ugent.pidgeon.postgre.models.types.CourseRelation; +import com.ugent.pidgeon.postgre.repository.CourseUserRepository; import com.ugent.pidgeon.postgre.repository.GroupClusterRepository; +import java.util.Collection; +import java.util.HashSet; +import java.util.List; +import java.util.Set; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.http.HttpStatus; import org.springframework.stereotype.Component; @@ -17,6 +25,8 @@ public class ClusterUtil { private GroupClusterRepository groupClusterRepository; @Autowired private CourseUtil courseUtil; + @Autowired + private CourseUserRepository courseUserRepository; /** * Check if a cluster is an individual cluster. This means that it only contains one group @@ -172,4 +182,24 @@ public CheckResult checkGroupClusterCreateJson(GroupClusterCreateJson clus return new CheckResult<>(HttpStatus.OK, "", null); } + + public CheckResult checkFillClusterJson(ClusterFillJson fillJson, GroupClusterEntity cluster) { + Collection members = fillJson.getClusterGroupMembers().values(); + + Set seen = new HashSet<>(); + for (Long[] member : members) { + for (Long userId : member) { + CourseUserEntity courseUser = courseUserRepository.findById(new CourseUserId(cluster.getCourseId(), userId)).orElse(null); + if (courseUser == null || !courseUser.getRelation().equals(CourseRelation.enrolled)) { + return new CheckResult<>(HttpStatus.BAD_REQUEST, "User with id " + userId + " is not enrolled in the course", null); + } + if (seen.contains(userId)) { + return new CheckResult<>(HttpStatus.BAD_REQUEST, "Can't add a user to 2 different groups", null); + } + seen.add(userId); + } + } + + return new CheckResult<>(HttpStatus.OK, "", null); + } } diff --git a/backend/app/src/main/java/com/ugent/pidgeon/util/CommonDatabaseActions.java b/backend/app/src/main/java/com/ugent/pidgeon/util/CommonDatabaseActions.java index d05bf63e..8a91fe92 100644 --- a/backend/app/src/main/java/com/ugent/pidgeon/util/CommonDatabaseActions.java +++ b/backend/app/src/main/java/com/ugent/pidgeon/util/CommonDatabaseActions.java @@ -1,6 +1,8 @@ package com.ugent.pidgeon.util; +import com.ugent.pidgeon.model.submissionTesting.DockerSubmissionTestModel; +import com.ugent.pidgeon.model.submissionTesting.DockerSubmissionTestModel; import com.ugent.pidgeon.postgre.models.*; import com.ugent.pidgeon.postgre.models.types.CourseRelation; import com.ugent.pidgeon.postgre.repository.*; @@ -38,8 +40,7 @@ public class CommonDatabaseActions { private TestRepository testRepository; @Autowired private FileUtil fileUtil; - @Autowired - private FileRepository fileRepository; + @Autowired private CourseRepository courseRepository; @Autowired @@ -53,17 +54,20 @@ public class CommonDatabaseActions { */ public boolean removeGroup(long groupId) { try { - // Delete the group - groupRepository.deleteGroupUsersByGroupId(groupId); - groupRepository.deleteSubmissionsByGroupId(groupId); - groupRepository.deleteGroupFeedbacksByGroupId(groupId); - groupRepository.deleteById(groupId); - - // update groupcount in cluster - groupClusterRepository.findById(groupId).ifPresent(cluster -> { - cluster.setGroupAmount(cluster.getGroupAmount() - 1); - groupClusterRepository.save(cluster); - }); + GroupEntity group = groupRepository.findById(groupId).orElse(null); + if (group != null) { + // Delete the group + groupRepository.deleteGroupUsersByGroupId(groupId); + groupRepository.deleteSubmissionsByGroupId(groupId); + groupRepository.deleteGroupFeedbacksByGroupId(groupId); + groupRepository.deleteById(groupId); + + // update groupcount in cluster + groupClusterRepository.findById(group.getClusterId()).ifPresent(cluster -> { + cluster.setGroupAmount(cluster.getGroupAmount() - 1); + groupClusterRepository.save(cluster); + }); + } return true; } catch (Exception e) { return false; @@ -107,7 +111,16 @@ public boolean removeIndividualClusterGroup(long courseId, long userId) { } // Find the group of the user Optional groupEntityOptional = groupRepository.groupByClusterAndUser(groupClusterEntity.getId(), userId); - return groupEntityOptional.filter(groupEntity -> removeGroup(groupEntity.getId())).isPresent(); + if (!groupEntityOptional.isPresent()) { + return false; + } + GroupEntity groupEntity = groupEntityOptional.get(); + // Delete the group + removeGroup(groupEntity.getId()); + + groupClusterEntity.setGroupAmount(groupClusterEntity.getGroupAmount() - 1); + groupClusterRepository.save(groupClusterEntity); + return true; } /** @@ -131,18 +144,18 @@ public CheckResult deleteProject(long projectId) { } } - projectRepository.delete(projectEntity); - if (projectEntity.getTestId() != null) { TestEntity testEntity = testRepository.findById(projectEntity.getTestId()).orElse(null); if (testEntity == null) { return new CheckResult<>(HttpStatus.NOT_FOUND, "Test not found", null); } CheckResult delRes = deleteTestById(projectEntity, testEntity); - return delRes; + if (!delRes.getStatus().equals(HttpStatus.OK)) { + return delRes; + } } - + projectRepository.delete(projectEntity); return new CheckResult<>(HttpStatus.OK, "", null); } catch (Exception e) { @@ -178,15 +191,17 @@ public CheckResult deleteSubmissionById(long submissionId) { */ public CheckResult deleteTestById(ProjectEntity projectEntity, TestEntity testEntity) { try { - testRepository.deleteById(testEntity.getId()) ; - CheckResult checkAndDeleteRes = fileUtil.deleteFileById(testEntity.getStructureTestId()); - if (!checkAndDeleteRes.getStatus().equals(HttpStatus.OK)) { - return checkAndDeleteRes; + projectEntity.setTestId(null); + projectRepository.save(projectEntity); + testRepository.deleteById(testEntity.getId()); + if(!testRepository.imageIsUsed(testEntity.getDockerImage())){ + DockerSubmissionTestModel.removeDockerImage(testEntity.getDockerImage()); } - return fileUtil.deleteFileById(testEntity.getDockerTestId()); + return new CheckResult<>(HttpStatus.OK, "", null); } catch (Exception e) { return new CheckResult<>(HttpStatus.INTERNAL_SERVER_ERROR, "Error while deleting test", null); } + } /** @@ -197,9 +212,10 @@ public CheckResult deleteTestById(ProjectEntity projectEntity, TestEntity public CheckResult deleteClusterById(long clusterId) { try { for (GroupEntity group : groupRepository.findAllByClusterId(clusterId)) { - // Delete all groupUsers - groupUserRepository.deleteAllByGroupId(group.getId()); - groupRepository.deleteById(group.getId()); + boolean res = removeGroup(group.getId()); + if (!res) { + return new CheckResult<>(HttpStatus.INTERNAL_SERVER_ERROR, "Error while deleting cluster", null); + } } groupClusterRepository.deleteById(clusterId); return new CheckResult<>(HttpStatus.OK, "", null); @@ -274,7 +290,7 @@ public CheckResult copyGroupCluster(GroupClusterEntity group courseId, groupCluster.getMaxSize(), groupCluster.getName(), - groupCluster.getGroupAmount() + copyGroups ? groupCluster.getGroupAmount() : 0 ); newGroupCluster.setCreatedAt(OffsetDateTime.now()); @@ -318,7 +334,7 @@ public CheckResult copyProject(ProjectEntity project, long course if (project.getTestId() != null) { TestEntity test = testRepository.findById(project.getTestId()).orElse(null); if (test != null) { - CheckResult checkResult = copyTest(test, newProject.getId()); + CheckResult checkResult = copyTest(test); if (!checkResult.getStatus().equals(HttpStatus.OK)) { return new CheckResult<>(checkResult.getStatus(), checkResult.getMessage(), null); } @@ -334,62 +350,20 @@ public CheckResult copyProject(ProjectEntity project, long course } /** - * Copy a test and all its related data. Assumes that permissions are already checked + * Copy a test and all its related data. Assumes that permissions are already checked and that the parameters are valid. * @param test test that needs to be copied - * @param projectId id of the project the test is linked to * @return CheckResult with the status of the copy and the new test */ - public CheckResult copyTest(TestEntity test, long projectId) { + public CheckResult copyTest(TestEntity test) { // Copy the test TestEntity newTest = new TestEntity( test.getDockerImage(), - test.getDockerTestId(), - test.getStructureTestId() + test.getDockerTestScript(), + test.getDockerTestTemplate(), + test.getStructureTemplate() ); - // Copy the files linked to the test - try { - FileEntity dockerFile = fileRepository.findById(test.getDockerTestId()).orElse(null); - FileEntity structureFile = fileRepository.findById(test.getStructureTestId()).orElse(null); - if (dockerFile == null || structureFile == null) { - return new CheckResult<>(HttpStatus.INTERNAL_SERVER_ERROR, "Error while copying test", null); - } - - CheckResult copyDockRes = copyTestFile(dockerFile, projectId); - if (!copyDockRes.getStatus().equals(HttpStatus.OK)) { - return new CheckResult<>(copyDockRes.getStatus(), copyDockRes.getMessage(), null); - } - newTest.setDockerTestId(copyDockRes.getData().getId()); - - CheckResult copyStructRes = copyTestFile(structureFile, projectId); - if (!copyStructRes.getStatus().equals(HttpStatus.OK)) { - return new CheckResult<>(copyStructRes.getStatus(), copyStructRes.getMessage(), null); - } - newTest.setStructureTestId(copyStructRes.getData().getId()); - } catch (Exception e) { - return new CheckResult<>(HttpStatus.INTERNAL_SERVER_ERROR, "Error while copying test", null); - } - newTest = testRepository.save(newTest); return new CheckResult<>(HttpStatus.OK, "", newTest); } - - - /** - * Copy a file and all its related data. Assumes that permissions are already checked - * @param file file to copy - * @param projectId id of the project the file is linked to - * @return CheckResult with the status of the copy and the new file - */ - public CheckResult copyTestFile(FileEntity file, long projectId) { - // Copy the file - try { - Path newPath = Filehandler.copyTest(Path.of(file.getPath()), projectId); - FileEntity newFile = new FileEntity(newPath.getFileName().toString(), newPath.toString(), file.getUploadedBy()); - newFile = fileRepository.save(newFile); - return new CheckResult<>(HttpStatus.OK, "", newFile); - } catch (Exception e) { - return new CheckResult<>(HttpStatus.INTERNAL_SERVER_ERROR, "Error while copying file", null); - } - } } diff --git a/backend/app/src/main/java/com/ugent/pidgeon/util/CourseUtil.java b/backend/app/src/main/java/com/ugent/pidgeon/util/CourseUtil.java index c73c16a1..8e1f2823 100644 --- a/backend/app/src/main/java/com/ugent/pidgeon/util/CourseUtil.java +++ b/backend/app/src/main/java/com/ugent/pidgeon/util/CourseUtil.java @@ -61,7 +61,8 @@ public CheckResult> getCourseIfUserInCourse(l if (courseUserEntity == null && !user.getRole().equals(UserRole.admin)) { return new CheckResult<>(HttpStatus.FORBIDDEN, "User is not part of the course", null); } - return new CheckResult<>(HttpStatus.OK, "", new Pair<>(courseEntity, courseUserEntity.getRelation())); + CourseRelation relation = courseUserEntity != null ? courseUserEntity.getRelation() : CourseRelation.creator; + return new CheckResult<>(HttpStatus.OK, "", new Pair<>(courseEntity, relation)); } @@ -113,7 +114,7 @@ public CheckResult canUpdateUserInCourse(long courseId, Course return new CheckResult<>(HttpStatus.BAD_REQUEST, "User is already part of the course", null); } if (!userUtil.userExists(request.getUserId())) { - return new CheckResult<>(HttpStatus.BAD_REQUEST, "User does not exist", null); + return new CheckResult<>(HttpStatus.NOT_FOUND, "User does not exist", null); } } else { if (!courseMember) { @@ -121,17 +122,20 @@ public CheckResult canUpdateUserInCourse(long courseId, Course } } - if (user.getId() == request.getUserId()) { - return new CheckResult<>(HttpStatus.BAD_REQUEST, "Cannot change your own relation with this course", null); + boolean isAdmin = user.getRole().equals(UserRole.admin); + + if (user.getId() == request.getUserId() && !isAdmin) { + return new CheckResult<>(HttpStatus.FORBIDDEN, "Cannot change your own relation with this course", null); } - if (request.getRelationAsEnum().equals(CourseRelation.creator)) { - return new CheckResult<>(HttpStatus.BAD_REQUEST, "Cannot change the creator of the course", null); + + if (request.getRelationAsEnum().equals(CourseRelation.creator) && !isAdmin) { + return new CheckResult<>(HttpStatus.FORBIDDEN, "Cannot change the creator of the course", null); } - boolean isAdmin = user.getRole().equals(UserRole.admin); boolean isCreator = userRelation.equals(CourseRelation.creator); boolean creatingAdmin = request.getRelationAsEnum().equals(CourseRelation.course_admin); - if (creatingAdmin && !isAdmin && !isCreator) { + boolean downgradingAdmin = courseMember && courseUserEntity.getRelation().equals(CourseRelation.course_admin) && !creatingAdmin; + if ((creatingAdmin || downgradingAdmin) && !isAdmin && !isCreator) { return new CheckResult<>(HttpStatus.FORBIDDEN, "Only the course creator can create course admins", null); } @@ -152,10 +156,10 @@ public CheckResult canLeaveCourse(long courseId, UserEntity user CourseEntity course = courseCheck.getData().getFirst(); CourseRelation relation = courseCheck.getData().getSecond(); if (relation.equals(CourseRelation.creator)) { - return new CheckResult<>(HttpStatus.BAD_REQUEST, "Cannot leave a course you created", null); + return new CheckResult<>(HttpStatus.FORBIDDEN, "Cannot leave a course you created", null); } if (course.getArchivedAt() != null) { - return new CheckResult<>(HttpStatus.BAD_REQUEST, "Cannot leave an archived course", null); + return new CheckResult<>(HttpStatus.FORBIDDEN, "Cannot leave an archived course", null); } return new CheckResult<>(HttpStatus.OK, "", relation); } @@ -181,15 +185,20 @@ public CheckResult canDeleteUser(long courseId, long userId, Use CourseUserEntity courseUserEntity = courseUserRepository.findById(new CourseUserId(courseId, userId)).orElse(null); if (courseUserEntity == null) { - return new CheckResult<>(HttpStatus.BAD_REQUEST, "User is not part of the course", null); + return new CheckResult<>(HttpStatus.NOT_FOUND, "User is not part of the course", null); } if (user.getId() == userId) { - return new CheckResult<>(HttpStatus.BAD_REQUEST, "Cannot delete yourself from the course", null); + return new CheckResult<>(HttpStatus.FORBIDDEN, "Cannot delete yourself from the course", null); } if (courseUserEntity.getRelation().equals(CourseRelation.creator)) { - return new CheckResult<>(HttpStatus.BAD_REQUEST, "Cannot delete the creator of the course", null); + return new CheckResult<>(HttpStatus.FORBIDDEN, "Cannot delete the creator of the course", null); + } + + boolean isAdmin = user.getRole().equals(UserRole.admin); + if (courseUserEntity.getRelation().equals(CourseRelation.course_admin) && !userRelation.equals(CourseRelation.creator) && !isAdmin){ + return new CheckResult<>(HttpStatus.FORBIDDEN, "Only the creator can delete course admins", null); } return new CheckResult<>(HttpStatus.OK, "", courseUserEntity.getRelation()); @@ -260,7 +269,6 @@ public CheckResult checkCourseJson(CourseJson courseJson, UserEntity user, } } - if (courseJson.getName() == null || courseJson.getDescription() == null || courseJson.getYear() == null) { Logger.getGlobal().info(""+ courseJson.getYear()); return new CheckResult<>(HttpStatus.BAD_REQUEST, "name, description and year are required", null); diff --git a/backend/app/src/main/java/com/ugent/pidgeon/util/DockerClientInstance.java b/backend/app/src/main/java/com/ugent/pidgeon/util/DockerClientInstance.java index 4ee77c8b..a4813f59 100644 --- a/backend/app/src/main/java/com/ugent/pidgeon/util/DockerClientInstance.java +++ b/backend/app/src/main/java/com/ugent/pidgeon/util/DockerClientInstance.java @@ -21,8 +21,8 @@ private DockerClientInstance() { public static synchronized DockerClient getInstance() { if (dockerClient == null) { - DockerClientConfig config = DefaultDockerClientConfig.createDefaultConfigBuilder() - .withDockerHost("tcp://10.5.0.4:2375").build(); + + DockerClientConfig config = DefaultDockerClientConfig.createDefaultConfigBuilder().build(); DockerHttpClient httpClient = new ApacheDockerHttpClient.Builder() .dockerHost(config.getDockerHost()) .sslConfig(config.getSSLConfig()) diff --git a/backend/app/src/main/java/com/ugent/pidgeon/util/EntityToJsonConverter.java b/backend/app/src/main/java/com/ugent/pidgeon/util/EntityToJsonConverter.java index 9056821a..716ae1bf 100644 --- a/backend/app/src/main/java/com/ugent/pidgeon/util/EntityToJsonConverter.java +++ b/backend/app/src/main/java/com/ugent/pidgeon/util/EntityToJsonConverter.java @@ -6,10 +6,10 @@ import com.ugent.pidgeon.model.json.*; import com.ugent.pidgeon.postgre.models.*; import com.ugent.pidgeon.postgre.models.types.CourseRelation; +import com.ugent.pidgeon.postgre.models.types.DockerTestState; +import com.ugent.pidgeon.postgre.models.types.DockerTestType; import com.ugent.pidgeon.postgre.repository.*; import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.http.HttpStatus; -import org.springframework.http.ResponseEntity; import org.springframework.stereotype.Component; import java.util.List; @@ -35,12 +35,19 @@ public class EntityToJsonConverter { private SubmissionRepository submissionRepository; @Autowired private ClusterUtil clusterUtil; + @Autowired + private TestUtil testUtil; + @Autowired + private TestRepository testRepository; - public GroupJson groupEntityToJson(GroupEntity groupEntity) { + public GroupJson groupEntityToJson(GroupEntity groupEntity) { GroupClusterEntity cluster = groupClusterRepository.findById(groupEntity.getClusterId()).orElse(null); + if (cluster == null) { + throw new RuntimeException("Cluster not found"); + } GroupJson group = new GroupJson(cluster.getMaxSize(), groupEntity.getId(), groupEntity.getName(), ApiRoutes.CLUSTER_BASE_PATH + "/" + groupEntity.getClusterId()); - if (cluster != null && cluster.getGroupAmount() > 1){ + if (cluster.getMaxSize() > 1){ group.setGroupClusterUrl(ApiRoutes.CLUSTER_BASE_PATH + "/" + cluster.getId()); } else { group.setGroupClusterUrl(null); @@ -140,23 +147,23 @@ public ProjectResponseJsonWithStatus projectEntityToProjectResponseJsonWithStatu if (groupId == null) { return new ProjectResponseJsonWithStatus( projectEntityToProjectResponseJson(project, course, user), - "no group" + ProjectStatus.no_group.toString() ); } SubmissionEntity sub = submissionRepository.findLatestsSubmissionIdsByProjectAndGroupId(project.getId(), groupId).orElse(null); - String status; + ProjectStatus status; if (sub == null) { - status = "not started"; - } else if (sub.getStructureAccepted() && sub.getStructureAccepted()) { - status = "correct"; + status = ProjectStatus.not_started; + } else if (sub.getStructureAccepted() && sub.getDockerAccepted()) { + status = ProjectStatus.correct; } else { - status = "incorrect"; + status = ProjectStatus.incorrect; } return new ProjectResponseJsonWithStatus( projectEntityToProjectResponseJson(project, course, user), - status + status.toString() ); } @@ -178,7 +185,7 @@ public ProjectResponseJson projectEntityToProjectResponseJson(ProjectEntity proj String submissionUrl = ApiRoutes.PROJECT_BASE_PATH + "/" + project.getId() + "/submissions"; CourseUserEntity courseUserEntity = courseUserRepository.findById(new CourseUserId(course.getId(), user.getId())).orElse(null); if (courseUserEntity == null) { - return null; + throw new RuntimeException("User not found in course"); } // GroupId is null if the user is a course_admin/creator @@ -224,6 +231,17 @@ public CourseReferenceJson courseEntityToCourseReference(CourseEntity course) { public SubmissionJson getSubmissionJson(SubmissionEntity submission) { + DockerTestFeedbackJson feedback; + if (submission.getDockerTestState().equals(DockerTestState.running)) { + feedback = null; + } else if (submission.getDockerTestType().equals(DockerTestType.NONE)) { + feedback = new DockerTestFeedbackJson(DockerTestType.NONE, "", true); + } + else if (submission.getDockerTestType().equals(DockerTestType.SIMPLE)) { + feedback = new DockerTestFeedbackJson(DockerTestType.SIMPLE, submission.getDockerFeedback(), submission.getDockerAccepted()); + } else { + feedback = new DockerTestFeedbackJson(DockerTestType.TEMPLATE, submission.getDockerFeedback(), submission.getDockerAccepted()); + } return new SubmissionJson( submission.getId(), ApiRoutes.PROJECT_BASE_PATH + "/" + submission.getProjectId(), @@ -233,18 +251,20 @@ public SubmissionJson getSubmissionJson(SubmissionEntity submission) { ApiRoutes.SUBMISSION_BASE_PATH + "/" + submission.getId() + "/file", submission.getStructureAccepted(), submission.getSubmissionTime(), - submission.getDockerAccepted(), - ApiRoutes.SUBMISSION_BASE_PATH + "/" + submission.getId() + "/structurefeedback", - ApiRoutes.SUBMISSION_BASE_PATH + "/" + submission.getId() + "/dockerfeedback" + submission.getStructureFeedback(), + feedback, + submission.getDockerTestState().toString(), + ApiRoutes.SUBMISSION_BASE_PATH + "/" + submission.getId() + "/artifacts" ); } public TestJson testEntityToTestJson(TestEntity testEntity, long projectId) { return new TestJson( ApiRoutes.PROJECT_BASE_PATH + "/" + projectId, - testEntity.getDockerImage(), - ApiRoutes.PROJECT_BASE_PATH + "/" + projectId + "/tests/dockertest", - ApiRoutes.PROJECT_BASE_PATH + "/" + projectId + "/tests/structuretest" + testEntity.getDockerImage(), + testEntity.getDockerTestScript(), + testEntity.getDockerTestTemplate(), + testEntity.getStructureTemplate() ); } } \ No newline at end of file diff --git a/backend/app/src/main/java/com/ugent/pidgeon/util/FileUtil.java b/backend/app/src/main/java/com/ugent/pidgeon/util/FileUtil.java index 9ae43466..87f482fc 100644 --- a/backend/app/src/main/java/com/ugent/pidgeon/util/FileUtil.java +++ b/backend/app/src/main/java/com/ugent/pidgeon/util/FileUtil.java @@ -2,6 +2,7 @@ import com.ugent.pidgeon.postgre.models.FileEntity; import com.ugent.pidgeon.postgre.repository.FileRepository; +import java.io.File; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.http.HttpStatus; import org.springframework.stereotype.Component; @@ -16,22 +17,6 @@ public class FileUtil { @Autowired private FileRepository fileRepository; - /** - * Save the file entity to the database - * @param filePath path of the file - * @param projectId id of the project - * @param userId id of the user - * @return the saved file entity - * @throws IOException if an error occurs while saving the file - */ - public FileEntity saveFileEntity(Path filePath, long projectId, long userId) throws IOException { - // Save the file entity to the database - Logger.getGlobal().info("file path: " + filePath.toString()); - Logger.getGlobal().info("file name: " + filePath.getFileName().toString()); - FileEntity fileEntity = new FileEntity(filePath.getFileName().toString(), filePath.toString(), userId); - return fileRepository.save(fileEntity); - } - /** * Delete a file by id from the database and server * @param fileId id of the file @@ -43,8 +28,9 @@ public CheckResult deleteFileById(long fileId) { return new CheckResult<>(HttpStatus.NOT_FOUND, "File not found", null); } try { - Filehandler.deleteLocation(Path.of(fileEntity.getPath())); - } catch (IOException e) { + Path path = Path.of(fileEntity.getPath()); + Filehandler.deleteLocation(new File(path.toString())); + } catch (Exception e) { return new CheckResult<>(HttpStatus.INTERNAL_SERVER_ERROR, "Error deleting file", null); } fileRepository.delete(fileEntity); diff --git a/backend/app/src/main/java/com/ugent/pidgeon/util/Filehandler.java b/backend/app/src/main/java/com/ugent/pidgeon/util/Filehandler.java index 3051422a..071d1d4f 100644 --- a/backend/app/src/main/java/com/ugent/pidgeon/util/Filehandler.java +++ b/backend/app/src/main/java/com/ugent/pidgeon/util/Filehandler.java @@ -1,21 +1,18 @@ package com.ugent.pidgeon.util; -import com.ugent.pidgeon.postgre.models.FileEntity; -import com.ugent.pidgeon.postgre.repository.FileRepository; +import java.util.List; +import java.util.zip.ZipEntry; +import java.util.zip.ZipOutputStream; import org.apache.tika.Tika; import org.springframework.core.io.FileSystemResource; -import org.springframework.core.io.InputStreamResource; import org.springframework.web.multipart.MultipartFile; import org.springframework.core.io.Resource; import java.io.*; import java.nio.file.Files; import java.nio.file.Path; -import java.nio.file.Paths; import java.nio.file.StandardCopyOption; -import java.util.Objects; import java.util.logging.Logger; -import java.util.zip.ZipFile; public class Filehandler { @@ -31,7 +28,7 @@ public class Filehandler { */ public static File saveSubmission(Path directory, MultipartFile file) throws IOException { // Check if the file is empty - if (file.isEmpty()) { + if (file == null || file.isEmpty()) { throw new IOException("File is empty"); } @@ -59,30 +56,21 @@ public static File saveSubmission(Path directory, MultipartFile file) throws IOE Files.copy(stream, filePath, StandardCopyOption.REPLACE_EXISTING); } - return tempFile; + return filePath.toFile(); } catch (IOException e) { throw new IOException(e.getMessage()); } } - /** - * Delete a submission from the server - * @param directory directory of the submission to delete - * @throws IOException if an error occurs while deleting the submission - */ - public static void deleteSubmission(Path directory) throws IOException { - deleteLocation(directory); - } /** - * Delete a directory and all its contents - * @param directory directory to delete + * Delete a directory and all its contents, eg: deleteLocation(new File(path.toString()) + * @param uploadDirectory File representing directory to delete * @throws IOException if an error occurs while deleting the directory */ - public static void deleteLocation(Path directory) throws IOException { + public static void deleteLocation(File uploadDirectory) throws IOException { try { - File uploadDirectory = new File(directory.toString()); if (uploadDirectory.exists()) { if(!uploadDirectory.delete()) { throw new IOException("Error while deleting directory"); @@ -94,16 +82,18 @@ public static void deleteLocation(Path directory) throws IOException { } } + + /** * Delete empty parent directories of a directory * @param directory directory to delete */ - private static void deleteEmptyParentDirectories(File directory) { + private static void deleteEmptyParentDirectories(File directory) throws IOException { if (directory != null && directory.isDirectory()) { File[] files = directory.listFiles(); if (files != null && files.length == 0) { if (!directory.delete()) { - System.err.println("Error while deleting empty directory: " + directory.getAbsolutePath()); + throw new IOException("Error while deleting empty directory: " + directory.getAbsolutePath()); } else { deleteEmptyParentDirectories(directory.getParentFile()); } @@ -123,13 +113,8 @@ static public Path getSubmissionPath(long projectid, long groupid, long submissi return Path.of(BASEPATH,"projects", String.valueOf(projectid), String.valueOf(groupid), String.valueOf(submissionid)); } - /** - * Get the path were a test is stored - * @param projectid id of the project - * @return the path of the test - */ - static public Path getTestPath(long projectid) { - return Path.of(BASEPATH,"projects", String.valueOf(projectid), "tests"); + static public Path getSubmissionArtifactPath(long projectid, long groupid, long submissionid) { + return getSubmissionPath(projectid, groupid, submissionid).resolve("artifacts.zip"); } /** @@ -138,6 +123,9 @@ static public Path getTestPath(long projectid) { * @return the file as a resource */ public static Resource getFileAsResource(Path path) { + if (!Files.exists(path)) { + return null; + } File file = path.toFile(); return new FileSystemResource(file); } @@ -161,81 +149,39 @@ public static boolean isZipFile(File file) throws IOException { } - /** - * Get a submission as a resource - * @param path path of the submission - * @return the submission as a resource - * @throws IOException if an error occurs while getting the submission - */ - public static Resource getSubmissionAsResource(Path path) throws IOException { - return new InputStreamResource(new FileInputStream(path.toFile())); - } - - /** - * Save a file to the server - * @param file file to save - * @param projectId id of the project - * @return the path of the saved file - * @throws IOException if an error occurs while saving the file - */ - public static Path saveTest(MultipartFile file, long projectId) throws IOException { - // Check if the file is empty - if (file.isEmpty()) { - throw new IOException("File is empty"); - } - - // Create directory if it doesn't exist - Path projectDirectory = getTestPath(projectId); - if (!Files.exists(projectDirectory)) { - Files.createDirectories(projectDirectory); - } - - // Save the file to the server - Path filePath = projectDirectory.resolve(Objects.requireNonNull(file.getOriginalFilename())); - Files.write(filePath, file.getBytes()); - - return filePath; - } /** - * Copy a file to the server project directory. - * @param sourceFilePath the path of the file to copy - * @param projectId the ID of the project - * @return the path of the copied file - * @throws IOException if an error occurs while copying the file + * A function for copying internally made lists of files, to a required path. + * @param files list of files to copy + * @param path path to copy the files to + * @throws IOException if an error occurs while copying the files */ - public static Path copyTest(Path sourceFilePath, long projectId) throws IOException { - // Check if the source file exists - if (!Files.exists(sourceFilePath)) { - throw new IOException("Source file does not exist"); - } - - // Create project directory if it doesn't exist - Path projectDirectory = getTestPath(projectId); - if (!Files.exists(projectDirectory)) { - Files.createDirectories(projectDirectory); + public static void copyFilesAsZip(List files, Path path) throws IOException { + // Write directly to a zip file in the path variable + File zipFile = new File(path.toString()); + System.out.println(zipFile.getAbsolutePath()); + Logger.getGlobal().info("Filexists: " + zipFile.exists()); + if (zipFile.exists() && !zipFile.canWrite()) { + Logger.getGlobal().info("Setting writable"); + boolean res = zipFile.setWritable(true); + if (!res) { + throw new IOException("Cannot write to zip file"); + } } - // Resolve destination file path - Path destinationFilePath = projectDirectory.resolve(sourceFilePath.getFileName()); - - // Copy the file to the project directory - Files.copy(sourceFilePath, destinationFilePath); - - return destinationFilePath; - } - - /** - * Get the structure test file contents as string - * @param path path of the structure test file - * @return the structure test file contents as string - * @throws IOException if an error occurs while reading the file - */ - public static String getStructureTestString(Path path) throws IOException { - try { - return Files.readString(path); - } catch (IOException e) { - throw new IOException("Error while reading testfile: " + e.getMessage()); + try (ZipOutputStream zipOutputStream = new ZipOutputStream(new FileOutputStream(zipFile))) { + for (File file : files) { + // add file to zip + zipOutputStream.putNextEntry(new ZipEntry(file.getName())); + FileInputStream fileInputStream = new FileInputStream(file); + byte[] buffer = new byte[1024]; + int len; + while ((len = fileInputStream.read(buffer)) > 0) { + zipOutputStream.write(buffer, 0, len); + } + fileInputStream.close(); + zipOutputStream.closeEntry(); + } } } } diff --git a/backend/app/src/main/java/com/ugent/pidgeon/util/GroupFeedbackUtil.java b/backend/app/src/main/java/com/ugent/pidgeon/util/GroupFeedbackUtil.java index 346bc21c..03e21232 100644 --- a/backend/app/src/main/java/com/ugent/pidgeon/util/GroupFeedbackUtil.java +++ b/backend/app/src/main/java/com/ugent/pidgeon/util/GroupFeedbackUtil.java @@ -100,11 +100,11 @@ public CheckResult checkGroupFeedbackUpdateJson(UpdateGroupScoreRequest re return new CheckResult<>(projectCheck.getStatus(), projectCheck.getMessage(), null); } Integer maxScore = projectCheck.getData().getMaxScore(); - if (request.getScore() == null || request.getFeedback() == null) { + if ((request.getScore() == null && maxScore != null) || request.getFeedback() == null) { return new CheckResult<>(HttpStatus.BAD_REQUEST, "Score and feedback need to be provided", null); } - if (maxScore != null && request.getScore() < 0) { + if (request.getScore() != null && request.getScore() < 0) { return new CheckResult<>(HttpStatus.BAD_REQUEST, "Score can't be lower than 0", null); } diff --git a/backend/app/src/main/java/com/ugent/pidgeon/util/GroupUtil.java b/backend/app/src/main/java/com/ugent/pidgeon/util/GroupUtil.java index bf76ef38..abb96d06 100644 --- a/backend/app/src/main/java/com/ugent/pidgeon/util/GroupUtil.java +++ b/backend/app/src/main/java/com/ugent/pidgeon/util/GroupUtil.java @@ -101,11 +101,15 @@ public CheckResult canAddUserToGroup(long groupId, long userId, UserEntity if (group == null) { return new CheckResult<>(HttpStatus.NOT_FOUND, "Group not found", null); } + + boolean isAdmin = false; + if (user.getId() != userId) { CheckResult admin = isAdminOfGroup(groupId, user); if (admin.getStatus() != HttpStatus.OK) { return admin; } + isAdmin = true; } else { if (!groupRepository.userAccessToGroup(userId, groupId)) { return new CheckResult<>(HttpStatus.FORBIDDEN, "User is not part of the course", null); @@ -120,9 +124,6 @@ public CheckResult canAddUserToGroup(long groupId, long userId, UserEntity return new CheckResult<>(HttpStatus.NOT_FOUND, "User not found", null); } - - - if (groupClusterRepository.userInGroupForCluster(group.getClusterId(), userId)) { return new CheckResult<>(HttpStatus.FORBIDDEN, "User is already in a group for this cluster", null); } @@ -134,7 +135,7 @@ public CheckResult canAddUserToGroup(long groupId, long userId, UserEntity return new CheckResult<>(HttpStatus.INTERNAL_SERVER_ERROR, "Error while checking cluster", null); } - if (cluster.getData().getMaxSize() <= groupRepository.countUsersInGroup(groupId)) { + if (cluster.getData().getMaxSize() <= groupRepository.countUsersInGroup(groupId) && !isAdmin) { return new CheckResult<>(HttpStatus.FORBIDDEN, "Group is full", null); } if (clusterUtil.isIndividualCluster(group.getClusterId())) { diff --git a/backend/app/src/main/java/com/ugent/pidgeon/util/ProjectUtil.java b/backend/app/src/main/java/com/ugent/pidgeon/util/ProjectUtil.java index 32d4fd0a..b689a36a 100644 --- a/backend/app/src/main/java/com/ugent/pidgeon/util/ProjectUtil.java +++ b/backend/app/src/main/java/com/ugent/pidgeon/util/ProjectUtil.java @@ -88,11 +88,10 @@ public CheckResult getProjectIfAdmin(long projectId, UserEntity u public CheckResult checkProjectJson(ProjectJson projectJson, long courseId) { if (projectJson.getName() == null || projectJson.getDescription() == null || - projectJson.getMaxScore() == null || projectJson.getGroupClusterId() == null || projectJson.getDeadline() == null) { return new CheckResult<>(HttpStatus.BAD_REQUEST, - "name, description, maxScore and deadline are required fields", null); + "name, description and deadline are required fields", null); } if (projectJson.getName().isBlank()) { @@ -109,8 +108,8 @@ public CheckResult checkProjectJson(ProjectJson projectJson, long courseId return new CheckResult<>(HttpStatus.BAD_REQUEST, "Deadline is in the past", null); } - if (projectJson.getMaxScore() < 0) { - return new CheckResult<>(HttpStatus.BAD_REQUEST, "Max score cannot be negative", null); + if (projectJson.getMaxScore() != null && projectJson.getMaxScore() <= 0) { + return new CheckResult<>(HttpStatus.BAD_REQUEST, "Max score cannot be negative or zero", null); } return new CheckResult<>(HttpStatus.OK, "", null); @@ -132,8 +131,7 @@ public CheckResult canGetProject(long projectId, UserEntity user) boolean studentof = projectRepository.userPartOfProject(projectId, user.getId()); boolean isAdmin = - (user.getRole() == UserRole.admin) || (projectRepository.adminOfProject(projectId, - user.getId())); + (user.getRole() == UserRole.admin); if (studentof || isAdmin) { return new CheckResult<>(HttpStatus.OK, "", project); diff --git a/backend/app/src/main/java/com/ugent/pidgeon/util/StringMatcher.java b/backend/app/src/main/java/com/ugent/pidgeon/util/StringMatcher.java index ff37bb79..0b1a6dd1 100644 --- a/backend/app/src/main/java/com/ugent/pidgeon/util/StringMatcher.java +++ b/backend/app/src/main/java/com/ugent/pidgeon/util/StringMatcher.java @@ -8,6 +8,7 @@ public class StringMatcher { "^[_A-Za-z0-9-\\+]+(\\.[_A-Za-z0-9-]+)*@" + "[A-Za-z0-9-]+(\\.[A-Za-z0-9]+)*(\\.[A-Za-z]{2,})$"; + private static final Pattern EMAIL_PATTERN = Pattern.compile(EMAIL_REGEX); /** diff --git a/backend/app/src/main/java/com/ugent/pidgeon/util/SubmissionUtil.java b/backend/app/src/main/java/com/ugent/pidgeon/util/SubmissionUtil.java index 943b73e0..d3135dac 100644 --- a/backend/app/src/main/java/com/ugent/pidgeon/util/SubmissionUtil.java +++ b/backend/app/src/main/java/com/ugent/pidgeon/util/SubmissionUtil.java @@ -41,10 +41,11 @@ public CheckResult canGetSubmission(long submissionId, UserEnt if (submission == null) { return new CheckResult<>(HttpStatus.NOT_FOUND, "Submission not found", null); } - if (groupUtil.canGetProjectGroupData(submission.getGroupId(), submission.getProjectId(), user).getStatus().equals(HttpStatus.OK)) { + CheckResult groupCheck = groupUtil.canGetProjectGroupData(submission.getGroupId(), submission.getProjectId(), user); + if (groupCheck.getStatus().equals(HttpStatus.OK)) { return new CheckResult<>(HttpStatus.OK, "", submission); } else { - return new CheckResult<>(HttpStatus.FORBIDDEN, "User does not have access to this submission", null); + return new CheckResult<>(groupCheck.getStatus(), groupCheck.getMessage(), null); } } @@ -59,10 +60,11 @@ public CheckResult canDeleteSubmission(long submissionId, User if (submission == null) { return new CheckResult<>(HttpStatus.NOT_FOUND, "Submission not found", null); } - if (projectUtil.isProjectAdmin(submission.getProjectId(), user).getStatus().equals(HttpStatus.OK)) { + CheckResult projectCheck = projectUtil.isProjectAdmin(submission.getProjectId(), user); + if (projectCheck.getStatus().equals(HttpStatus.OK)) { return new CheckResult<>(HttpStatus.OK, "", submission); } else { - return new CheckResult<>(HttpStatus.FORBIDDEN, "User does not have access to delete this submission", null); + return new CheckResult<>(projectCheck.getStatus(), projectCheck.getMessage(), null); } } @@ -81,10 +83,12 @@ public CheckResult checkOnSubmit(long projectId, UserEntity user) { if (groupId == null) { return new CheckResult<>(HttpStatus.BAD_REQUEST, "User is not part of a group for this project", null); } - GroupEntity group = groupUtil.getGroupIfExists(groupId).getData(); - if (group == null) { - return new CheckResult<>(HttpStatus.NOT_FOUND, "Group not found", null); + + CheckResult groupCheck = groupUtil.getGroupIfExists(groupId); + if (groupCheck.getStatus() != HttpStatus.OK) { + return new CheckResult<>(groupCheck.getStatus(), groupCheck.getMessage(), null); } + GroupEntity group = groupCheck.getData(); if (groupClusterRepository.inArchivedCourse(group.getClusterId())) { return new CheckResult<>(HttpStatus.FORBIDDEN, "Cannot submit for a project in an archived course", null); diff --git a/backend/app/src/main/java/com/ugent/pidgeon/util/TestRunner.java b/backend/app/src/main/java/com/ugent/pidgeon/util/TestRunner.java new file mode 100644 index 00000000..0b3cdc8d --- /dev/null +++ b/backend/app/src/main/java/com/ugent/pidgeon/util/TestRunner.java @@ -0,0 +1,71 @@ +package com.ugent.pidgeon.util; + +import com.ugent.pidgeon.model.submissionTesting.DockerOutput; +import com.ugent.pidgeon.model.submissionTesting.DockerSubmissionTestModel; +import com.ugent.pidgeon.model.submissionTesting.SubmissionTemplateModel; +import com.ugent.pidgeon.postgre.models.TestEntity; +import java.io.File; +import java.io.IOException; +import java.nio.file.Path; +import java.util.List; +import java.util.zip.ZipFile; +import org.springframework.stereotype.Component; + +@Component +public class TestRunner { + + public SubmissionTemplateModel.SubmissionResult runStructureTest( + ZipFile file, TestEntity testEntity, SubmissionTemplateModel model) throws IOException { + // There is no structure test for this project + if(testEntity == null || testEntity.getStructureTemplate() == null){ + return null; + } + String structureTemplateString = testEntity.getStructureTemplate(); + + // Parse the file + model.parseSubmissionTemplate(structureTemplateString); + return model.checkSubmission(file); + } + + public DockerOutput runDockerTest(ZipFile file, TestEntity testEntity, Path outputPath, DockerSubmissionTestModel model) throws IOException { + // Get the test file from the server + String testScript = testEntity.getDockerTestScript(); + String testTemplate = testEntity.getDockerTestTemplate(); + + // The first script must always be null, otherwise there is nothing to run on the container + if (testScript == null) { + return null; + } + + // Init container and add input files + try { + + model.addZipInputFiles(file); + DockerOutput output; + + if (testTemplate == null) { + // This docker test is configured in the simple mode (store test console logs) + output = model.runSubmission(testScript); + } else { + // This docker test is configured in the template mode (store json with feedback) + output = model.runSubmissionWithTemplate(testScript, testTemplate); + } + // Get list of artifact files generated on submission + List artifacts = model.getArtifacts(); + + // Copy all files as zip into the output directory + if (artifacts != null && !artifacts.isEmpty()) { + Filehandler.copyFilesAsZip(artifacts, outputPath); + } + + // Cleanup garbage files and container + model.cleanUp(); + + return output; + } catch (Exception e) { + model.cleanUp(); + throw new IOException("Error while running docker tests: " + e.getMessage()); + } + } + +} diff --git a/backend/app/src/main/java/com/ugent/pidgeon/util/TestUtil.java b/backend/app/src/main/java/com/ugent/pidgeon/util/TestUtil.java index bf359735..b3b57abb 100644 --- a/backend/app/src/main/java/com/ugent/pidgeon/util/TestUtil.java +++ b/backend/app/src/main/java/com/ugent/pidgeon/util/TestUtil.java @@ -2,10 +2,14 @@ import com.ugent.pidgeon.controllers.ApiRoutes; import com.ugent.pidgeon.model.json.TestJson; +import com.ugent.pidgeon.model.submissionTesting.DockerSubmissionTestModel; import com.ugent.pidgeon.postgre.models.ProjectEntity; import com.ugent.pidgeon.postgre.models.TestEntity; import com.ugent.pidgeon.postgre.models.UserEntity; +import com.ugent.pidgeon.postgre.models.types.UserRole; import com.ugent.pidgeon.postgre.repository.TestRepository; +import java.util.logging.Level; +import java.util.logging.Logger; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.http.HttpMethod; import org.springframework.http.HttpStatus; @@ -31,12 +35,12 @@ public TestEntity getTestIfExists(long projectId) { } /** - * Check if a user can get update a test + * Check if a user can update a test * @param projectId id of the project * @param user user that wants to update the test * @param dockerImage docker image for the test - * @param dockerTest docker test file - * @param structureTest structure test file + * @param dockerScript docker script for the test + * @param dockerTemplate docker template for the test * @param httpMethod http method used to update the test * @return CheckResult with the status of the check and the test and project */ @@ -44,10 +48,11 @@ public CheckResult> checkForTestUpdate( long projectId, UserEntity user, String dockerImage, - MultipartFile dockerTest, - MultipartFile structureTest, + String dockerScript, + String dockerTemplate, HttpMethod httpMethod ) { + CheckResult projectCheck = projectUtil.getProjectIfAdmin(projectId, user); if (!projectCheck.getStatus().equals(HttpStatus.OK)) { return new CheckResult<>(projectCheck.getStatus(), projectCheck.getMessage(), null); @@ -67,10 +72,31 @@ public CheckResult> checkForTestUpdate( return new CheckResult<>(HttpStatus.CONFLICT, "Tests already exist for this project", null); } - if (!httpMethod.equals(HttpMethod.PATCH)) { - if (dockerImage == null || dockerTest == null || structureTest == null) { - return new CheckResult<>(HttpStatus.BAD_REQUEST, "Missing parameters: dockerimage (string), dockertest (file), structuretest (file) are required", null); - } + if(!httpMethod.equals(HttpMethod.PATCH) && dockerImage != null && dockerScript == null) { + return new CheckResult<>(HttpStatus.BAD_REQUEST, "A test script is required if u add a dockerimage.", null); + } + if (!httpMethod.equals(HttpMethod.PATCH) && dockerScript != null && dockerImage == null) { + return new CheckResult<>(HttpStatus.BAD_REQUEST, "A docker image is required if u add a script", null); + } + + if(dockerImage != null && !DockerSubmissionTestModel.imageExists(dockerImage)) { + return new CheckResult<>(HttpStatus.BAD_REQUEST, "A valid docker image is required in a docker test.", null); + } + + if (!httpMethod.equals(HttpMethod.PATCH) && dockerTemplate != null && dockerImage == null) { + return new CheckResult<>(HttpStatus.BAD_REQUEST, "A test script and image are required in a docker template test.", null); + } + + if(httpMethod.equals(HttpMethod.PATCH) && dockerScript != null && testEntity.getDockerImage() == null && dockerImage == null) { + return new CheckResult<>(HttpStatus.BAD_REQUEST, "No docker image is configured for this test", null); + } + + if(httpMethod.equals(HttpMethod.PATCH) && dockerImage != null && testEntity.getDockerTestScript() == null && dockerScript == null) { + return new CheckResult<>(HttpStatus.BAD_REQUEST, "No docker test script is configured for this test", null); + } + + if(dockerTemplate != null && !DockerSubmissionTestModel.isValidTemplate(dockerTemplate)) { + return new CheckResult<>(HttpStatus.BAD_REQUEST, "Invalid docker template", null); } return new CheckResult<>(HttpStatus.OK, "", new Pair<>(testEntity, projectEntity)); @@ -96,5 +122,28 @@ public CheckResult getTestIfAdmin(long projectId, UserEntity user) { return new CheckResult<>(HttpStatus.OK, "", testEntity); } + public CheckResult> getTestWithAdminStatus(long projectId, UserEntity user) { + TestEntity testEntity = getTestIfExists(projectId); + if (testEntity == null) { + return new CheckResult<>(HttpStatus.NOT_FOUND, "No tests found for project with id: " + projectId, null); + } + + boolean userPartOfProject = projectUtil.userPartOfProject(projectId, user.getId()); + if (!userPartOfProject) { + return new CheckResult<>(HttpStatus.FORBIDDEN, "You are not part of this project", null); + } + boolean admin = false; + + CheckResult isProjectAdmin = projectUtil.isProjectAdmin(projectId, user); + if (isProjectAdmin.getStatus().equals(HttpStatus.OK)) { + admin = true; + } else if (!isProjectAdmin.getStatus().equals(HttpStatus.FORBIDDEN)){ + return new CheckResult<>(isProjectAdmin.getStatus(), isProjectAdmin.getMessage(), null); + } else if (user.getRole().equals(UserRole.admin)) { + admin = true; + } + + return new CheckResult<>(HttpStatus.OK, "", new Pair<>(testEntity, admin)); + } } diff --git a/backend/app/src/main/resources/application.properties b/backend/app/src/main/resources/application.properties index ff099426..7b44679e 100644 --- a/backend/app/src/main/resources/application.properties +++ b/backend/app/src/main/resources/application.properties @@ -25,6 +25,6 @@ server.port=8080 # TODO: this is just temporary, we will need to think of an actual limit at some point -spring.servlet.multipart.max-file-size=10MB -spring.servlet.multipart.max-request-size=10MB +spring.servlet.multipart.max-file-size=50MB +spring.servlet.multipart.max-request-size=50MB diff --git a/backend/app/src/test/java/com/ugent/pidgeon/CustomObjectMapper.java b/backend/app/src/test/java/com/ugent/pidgeon/CustomObjectMapper.java index ee3c199f..d4465ef1 100644 --- a/backend/app/src/test/java/com/ugent/pidgeon/CustomObjectMapper.java +++ b/backend/app/src/test/java/com/ugent/pidgeon/CustomObjectMapper.java @@ -1,15 +1,20 @@ package com.ugent.pidgeon; import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.json.JsonMapper; import com.fasterxml.jackson.databind.module.SimpleModule; +import com.fasterxml.jackson.datatype.jdk8.Jdk8Module; +import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule; +import com.fasterxml.jackson.module.paramnames.ParameterNamesModule; import java.time.OffsetDateTime; public class CustomObjectMapper { public static ObjectMapper createObjectMapper() { - ObjectMapper objectMapper = new ObjectMapper(); - SimpleModule module = new SimpleModule(); - module.addDeserializer(OffsetDateTime.class, new OffsetDateTimeDeserializer()); - objectMapper.registerModule(module); - return objectMapper; + return JsonMapper.builder() // or different mapper for other format + .addModule(new ParameterNamesModule()) + .addModule(new Jdk8Module()) + .addModule(new JavaTimeModule()) + // and possibly other configuration, modules, then: + .build(); } } diff --git a/backend/app/src/test/java/com/ugent/pidgeon/controllers/ClusterControllerTest.java b/backend/app/src/test/java/com/ugent/pidgeon/controllers/ClusterControllerTest.java index 24446585..2bd77265 100644 --- a/backend/app/src/test/java/com/ugent/pidgeon/controllers/ClusterControllerTest.java +++ b/backend/app/src/test/java/com/ugent/pidgeon/controllers/ClusterControllerTest.java @@ -1,13 +1,20 @@ package com.ugent.pidgeon.controllers; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.ugent.pidgeon.CustomObjectMapper; +import com.ugent.pidgeon.model.json.GroupClusterJson; +import com.ugent.pidgeon.model.json.GroupJson; import com.ugent.pidgeon.postgre.models.CourseEntity; import com.ugent.pidgeon.postgre.models.GroupClusterEntity; import com.ugent.pidgeon.postgre.models.GroupEntity; import com.ugent.pidgeon.postgre.models.types.CourseRelation; import com.ugent.pidgeon.postgre.repository.GroupClusterRepository; +import com.ugent.pidgeon.postgre.repository.GroupMemberRepository; import com.ugent.pidgeon.postgre.repository.GroupRepository; -import com.ugent.pidgeon.postgre.repository.GroupUserRepository; import com.ugent.pidgeon.util.*; +import java.time.OffsetDateTime; +import java.util.Collections; +import java.util.logging.Logger; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; @@ -22,9 +29,18 @@ import java.util.List; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotEquals; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.ArgumentMatchers.argThat; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.reset; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; @ExtendWith(MockitoExtension.class) @@ -35,72 +51,103 @@ public class ClusterControllerTest extends ControllerTest{ @Mock GroupRepository groupRepository; @Mock - GroupUserRepository groupUserRepository; + GroupMemberRepository groupMemberRepository; @Mock private ClusterUtil clusterUtil; @Mock private EntityToJsonConverter entityToJsonConverter; + @Mock private CourseUtil courseUtil; @Mock private CommonDatabaseActions commonDatabaseActions; + @InjectMocks private ClusterController clusterController; private CourseEntity courseEntity; private GroupClusterEntity groupClusterEntity; + private GroupClusterJson groupClusterJson; private GroupEntity groupEntity; + private GroupJson groupJson; + private final Long courseId = 1L; + + private ObjectMapper objectMapper = CustomObjectMapper.createObjectMapper(); @BeforeEach public void setup() { - mockMvc = MockMvcBuilders.standaloneSetup(clusterController) - .defaultRequest(MockMvcRequestBuilders.get("/**") - .with(request -> { - request.setUserPrincipal(SecurityContextHolder.getContext().getAuthentication()); - return request; - })) - .build(); + setUpController(clusterController); courseEntity = new CourseEntity("name", "description",2024); - groupClusterEntity = new GroupClusterEntity(1L, 20, "clustername", 5); + courseEntity.setId(32L); + groupClusterEntity = new GroupClusterEntity(courseEntity.getId(), 20, "clustername", 5); + groupClusterEntity.setId(29L); + groupClusterJson = new GroupClusterJson( + groupClusterEntity.getId(), + groupClusterEntity.getName(), + groupClusterEntity.getMaxSize(), + groupClusterEntity.getGroupAmount(), + OffsetDateTime.now(), + Collections.emptyList(), + ""); groupEntity = new GroupEntity("groupName", 1L); + groupEntity.setId(78L); + groupJson = new GroupJson(groupClusterEntity.getMaxSize(), groupEntity.getId(), groupEntity.getName(), ""); } @Test public void testGetClustersForCourse() throws Exception { - when(courseUtil.getCourseIfUserInCourse(anyLong(), any())) + String url = ApiRoutes.COURSE_BASE_PATH + "/" + courseId + "/clusters"; + + /* If the user is enrolled in the course, the clusters are returned */ + when(courseUtil.getCourseIfUserInCourse(courseId, getMockUser())) .thenReturn(new CheckResult<>(HttpStatus.OK, "", new Pair<>(courseEntity, CourseRelation.enrolled))); - when(groupClusterRepository.findClustersWithoutInvidualByCourseId(anyLong())).thenReturn(List.of(groupClusterEntity)); - mockMvc.perform(MockMvcRequestBuilders.get(ApiRoutes.COURSE_BASE_PATH + "/1/clusters")) - .andExpect(status().isOk()); + when(groupClusterRepository.findClustersWithoutInvidualByCourseId(courseId)).thenReturn(List.of(groupClusterEntity)); + when(entityToJsonConverter.clusterEntityToClusterJson(groupClusterEntity)).thenReturn(groupClusterJson); + mockMvc.perform(MockMvcRequestBuilders.get(url)) + .andExpect(status().isOk()) + .andExpect(content().contentType(MediaType.APPLICATION_JSON)) + .andExpect(content().json(objectMapper.writeValueAsString(List.of(groupClusterJson)))); + /* If a certain check fails, the corresponding status code is returned */ when(courseUtil.getCourseIfUserInCourse(anyLong(), any())) .thenReturn(new CheckResult<>(HttpStatus.BAD_REQUEST, "", null)); - mockMvc.perform(MockMvcRequestBuilders.get(ApiRoutes.COURSE_BASE_PATH + "/1/clusters")) + mockMvc.perform(MockMvcRequestBuilders.get(url)) .andExpect(status().isBadRequest()); } @Test public void testCreateClusterForCourse() throws Exception { + String url = ApiRoutes.COURSE_BASE_PATH + "/" + courseId +"/clusters"; + + /* If the user is an admin of the course and the json is valid, the cluster is created */ String request = "{\"name\": \"test\", \"capacity\": 20, \"groupCount\": 5}"; - when(courseUtil.getCourseIfAdmin(anyLong(), any())).thenReturn(new CheckResult<>(HttpStatus.OK, "", courseEntity)); - when(clusterUtil.checkGroupClusterCreateJson(any())).thenReturn(new CheckResult<>(HttpStatus.OK, "", null)); + when(courseUtil.getCourseIfAdmin(courseId, getMockUser())).thenReturn(new CheckResult<>(HttpStatus.OK, "", courseEntity)); + when(clusterUtil.checkGroupClusterCreateJson(argThat( + json -> json.name().equals("test") && json.capacity().equals(20) && json.groupCount().equals(5) + ))).thenReturn(new CheckResult<>(HttpStatus.OK, "", null)); when(groupClusterRepository.save(any())).thenReturn(groupClusterEntity); - mockMvc.perform(MockMvcRequestBuilders.post(ApiRoutes.COURSE_BASE_PATH + "/1/clusters") + when(entityToJsonConverter.clusterEntityToClusterJson(groupClusterEntity)).thenReturn(groupClusterJson); + mockMvc.perform(MockMvcRequestBuilders.post(url) .contentType(MediaType.APPLICATION_JSON) .content(request)) - .andExpect(status().isCreated()); + .andExpect(status().isCreated()) + .andExpect(content().contentType(MediaType.APPLICATION_JSON)) + .andExpect(content().json(objectMapper.writeValueAsString(groupClusterJson))); + /* If the json is invalid, the corresponding status code is returned */ + reset(clusterUtil); when(clusterUtil.checkGroupClusterCreateJson(any())).thenReturn(new CheckResult<>(HttpStatus.I_AM_A_TEAPOT, "", null)); - mockMvc.perform(MockMvcRequestBuilders.post(ApiRoutes.COURSE_BASE_PATH + "/1/clusters") + mockMvc.perform(MockMvcRequestBuilders.post(url) .contentType(MediaType.APPLICATION_JSON) .content(request)) .andExpect(status().isIAmATeapot()); + /* If the user is not an admin of the course, the corresponding status code is returned */ when(courseUtil.getCourseIfAdmin(anyLong(), any())).thenReturn(new CheckResult<>(HttpStatus.BAD_REQUEST, "", null)); - mockMvc.perform(MockMvcRequestBuilders.post(ApiRoutes.COURSE_BASE_PATH + "/1/clusters") + mockMvc.perform(MockMvcRequestBuilders.post(url) .contentType(MediaType.APPLICATION_JSON) .content(request)) .andExpect(status().isBadRequest()); @@ -108,53 +155,213 @@ public void testCreateClusterForCourse() throws Exception { @Test public void testGetCluster() throws Exception { - when(clusterUtil.getGroupClusterEntityIfNotIndividual(anyLong(), any())).thenReturn(new CheckResult<>(HttpStatus.OK, "", groupClusterEntity)); - mockMvc.perform(MockMvcRequestBuilders.get(ApiRoutes.CLUSTER_BASE_PATH + "/1")) - .andExpect(status().isOk()); + String url = ApiRoutes.CLUSTER_BASE_PATH + "/" + groupClusterEntity.getId(); + + /* If the user has acces to the cluster and it isn't an individual cluster, the cluster is returned */ + when(entityToJsonConverter.clusterEntityToClusterJson(groupClusterEntity)).thenReturn(groupClusterJson); + when(clusterUtil.getGroupClusterEntityIfNotIndividual(groupClusterEntity.getId(), getMockUser())).thenReturn(new CheckResult<>(HttpStatus.OK, "", groupClusterEntity)); + mockMvc.perform(MockMvcRequestBuilders.get(url)) + .andExpect(status().isOk()) + .andExpect(content().contentType(MediaType.APPLICATION_JSON)) + .andExpect(content().json(objectMapper.writeValueAsString(groupClusterJson))); + /* If any check fails, the corresponding status code is returned */ when(clusterUtil.getGroupClusterEntityIfNotIndividual(anyLong(), any())).thenReturn(new CheckResult<>(HttpStatus.I_AM_A_TEAPOT, "", null)); - mockMvc.perform(MockMvcRequestBuilders.get(ApiRoutes.CLUSTER_BASE_PATH + "/1")) + mockMvc.perform(MockMvcRequestBuilders.get(url)) .andExpect(status().isIAmATeapot()); } //This function also tests doGroupClusterUpdate @Test public void testUpdateCluster() throws Exception { - String request = "{\"name\": \"clustername\", \"capacity\": 20}"; - when(clusterUtil.getGroupClusterEntityIfAdminAndNotIndividual(anyLong(), any())) + String url = ApiRoutes.CLUSTER_BASE_PATH + "/" + groupClusterEntity.getId(); + String request = "{\"name\": \"newclustername\", \"capacity\": 22}"; + String originalname = groupClusterEntity.getName(); + Integer originalcapacity = groupClusterEntity.getMaxSize(); + /* If the user is an admin of the cluster, the cluster isn't individual and the json is valid, the cluster is updated */ + GroupClusterEntity copy = new GroupClusterEntity(1L, 20, "newclustername", 5); + when(clusterUtil.getGroupClusterEntityIfAdminAndNotIndividual(groupClusterEntity.getId(), getMockUser())) .thenReturn(new CheckResult<>(HttpStatus.OK, "", groupClusterEntity)); - when(clusterUtil.checkGroupClusterUpdateJson(any())).thenReturn(new CheckResult<>(HttpStatus.OK, "", null)); - mockMvc.perform(MockMvcRequestBuilders.put(ApiRoutes.CLUSTER_BASE_PATH + "/1") + when(clusterUtil.checkGroupClusterUpdateJson( + argThat(json -> json.getName().equals("newclustername") && json.getCapacity().equals(22)) + )).thenReturn(new CheckResult<>(HttpStatus.OK, "", null)); + copy.setName("newclustername"); + GroupClusterJson updated = new GroupClusterJson(1L, "newclustername", 20, 5, OffsetDateTime.now(), Collections.emptyList(), ""); + when(groupClusterRepository.save(groupClusterEntity)).thenReturn(copy); + when(entityToJsonConverter.clusterEntityToClusterJson(copy)).thenReturn(updated); + mockMvc.perform(MockMvcRequestBuilders.put(url) .contentType(MediaType.APPLICATION_JSON) .content(request)) - .andExpect(status().isOk()); + .andExpect(status().isOk()) + .andExpect(content().contentType(MediaType.APPLICATION_JSON)) + .andExpect(content().json(objectMapper.writeValueAsString(updated))); + assertNotEquals(originalname, groupClusterEntity.getName()); + assertNotEquals(originalcapacity, groupClusterEntity.getMaxSize()); + /* If the json is invalid, the corresponding status code is returned */ + reset(clusterUtil); + when(clusterUtil.getGroupClusterEntityIfAdminAndNotIndividual(groupClusterEntity.getId(), getMockUser())) + .thenReturn(new CheckResult<>(HttpStatus.OK, "", groupClusterEntity)); when(clusterUtil.checkGroupClusterUpdateJson(any())).thenReturn(new CheckResult<>(HttpStatus.FORBIDDEN, "", null)); - mockMvc.perform(MockMvcRequestBuilders.put(ApiRoutes.CLUSTER_BASE_PATH + "/1") + mockMvc.perform(MockMvcRequestBuilders.put(url) .contentType(MediaType.APPLICATION_JSON) .content(request)) .andExpect(status().isForbidden()); + /* If the user is not an admin of the cluster or the cluster is individual, the corresponding status code is returned */ when(clusterUtil.getGroupClusterEntityIfAdminAndNotIndividual(anyLong(), any())).thenReturn(new CheckResult<>(HttpStatus.BAD_REQUEST, "", null)); - mockMvc.perform(MockMvcRequestBuilders.put(ApiRoutes.CLUSTER_BASE_PATH + "/1") + mockMvc.perform(MockMvcRequestBuilders.put(url) .contentType(MediaType.APPLICATION_JSON) .content(request)) .andExpect(status().isBadRequest()); } + @Test + public void testFillCluster() throws Exception { + String url = ApiRoutes.CLUSTER_BASE_PATH + "/" + groupClusterEntity.getId() + "/fill"; + String request = """ + { + "group1": [3, 2], + "group2": [4, 5] + } + """; + + long newGroupEntityId = 89L; + GroupEntity newGroupEntity = new GroupEntity("group1", groupClusterEntity.getId()); + newGroupEntity.setId(newGroupEntityId); + long newGroupEntityId2 = 221L; + GroupEntity newGroupEntity2 = new GroupEntity("group2", groupClusterEntity.getId()); + newGroupEntity2.setId(newGroupEntityId2); + /* All checks succeed */ + when(clusterUtil.getGroupClusterEntityIfAdminAndNotIndividual(groupClusterEntity.getId(), getMockUser())) + .thenReturn(new CheckResult<>(HttpStatus.OK, "", groupClusterEntity)); + + when(groupRepository.findAllByClusterId(groupClusterEntity.getId())).thenReturn(List.of(groupEntity)); + when(clusterUtil.checkFillClusterJson(argThat( + json -> { + boolean check = json.getClusterGroupMembers().size() == 2; + check = check && json.getClusterGroupMembers().get("group1").length == 2; + check = check && json.getClusterGroupMembers().get("group1")[0] == 3; + check = check && json.getClusterGroupMembers().get("group1")[1] == 2; + check = check && json.getClusterGroupMembers().get("group2").length == 2; + check = check && json.getClusterGroupMembers().get("group2")[0] == 4; + check = check && json.getClusterGroupMembers().get("group2")[1] == 5; + return check; + } + ), eq(groupClusterEntity))) + .thenReturn(new CheckResult<>(HttpStatus.OK, "", null)); + + when(groupRepository.save(argThat( + g1 -> g1 != null && g1.getName().equals("group1") && g1.getClusterId() == groupClusterEntity.getId() + ))).thenReturn(newGroupEntity); + + when(groupRepository.save(argThat( + g2 -> g2 != null && g2.getName().equals("group2") && g2.getClusterId() == groupClusterEntity.getId() + ))).thenReturn(newGroupEntity2); + + mockMvc.perform(MockMvcRequestBuilders.put(url) + .contentType(MediaType.APPLICATION_JSON) + .content(request)) + .andExpect(status().isOk()); + + verify(commonDatabaseActions, times(1)).removeGroup(groupEntity.getId()); + verify(groupMemberRepository, times(1)).addMemberToGroup(newGroupEntityId, 2); + verify(groupMemberRepository, times(1)).addMemberToGroup(newGroupEntityId, 3); + verify(groupMemberRepository, times(1)).addMemberToGroup(newGroupEntityId2, 4); + verify(groupMemberRepository, times(1)).addMemberToGroup(newGroupEntityId2, 5); + assertEquals(2, groupClusterEntity.getGroupAmount()); + verify(groupClusterRepository, times(1)).save(groupClusterEntity); + + /* Error when checking json */ + reset(clusterUtil); + when(clusterUtil.getGroupClusterEntityIfAdminAndNotIndividual(groupClusterEntity.getId(), getMockUser())) + .thenReturn(new CheckResult<>(HttpStatus.OK, "", groupClusterEntity)); + when(clusterUtil.checkFillClusterJson(any(), any())).thenReturn(new CheckResult<>(HttpStatus.I_AM_A_TEAPOT, "", null)); + mockMvc.perform(MockMvcRequestBuilders.put(url) + .contentType(MediaType.APPLICATION_JSON) + .content(request)) + .andExpect(status().isIAmATeapot()); + + /* Error when getting group cluster entity */ + reset(clusterUtil); + when(clusterUtil.getGroupClusterEntityIfAdminAndNotIndividual(groupClusterEntity.getId(), getMockUser())) + .thenReturn(new CheckResult<>(HttpStatus.I_AM_A_TEAPOT, "", null)); + mockMvc.perform(MockMvcRequestBuilders.put(url) + .contentType(MediaType.APPLICATION_JSON) + .content(request)) + .andExpect(status().isIAmATeapot()); + + /* Unexepcted error */ + reset(clusterUtil); + when(clusterUtil.getGroupClusterEntityIfAdminAndNotIndividual(groupClusterEntity.getId(), getMockUser())) + .thenReturn(new CheckResult<>(HttpStatus.OK, "", groupClusterEntity)); + when(groupRepository.findAllByClusterId(groupClusterEntity.getId())).thenThrow(new RuntimeException()); + mockMvc.perform(MockMvcRequestBuilders.put(url) + .contentType(MediaType.APPLICATION_JSON) + .content(request)) + .andExpect(status().isInternalServerError()); + + } + @Test public void testPatchCluster() throws Exception { + String url = ApiRoutes.CLUSTER_BASE_PATH + "/" + groupClusterEntity.getId(); + + /* If the user is an admin of the cluster and the json is valid, the cluster is updated */ + String originalname = groupClusterEntity.getName(); + Integer originalcapacity = groupClusterEntity.getMaxSize(); + /* If fields are null they are not updated */ String request = "{\"name\": null, \"capacity\": null}"; - when(clusterUtil.getGroupClusterEntityIfAdminAndNotIndividual(anyLong(), any())) + when(clusterUtil.getGroupClusterEntityIfAdminAndNotIndividual(groupClusterEntity.getId(), getMockUser())) .thenReturn(new CheckResult<>(HttpStatus.OK, "", groupClusterEntity)); - when(clusterUtil.checkGroupClusterUpdateJson(any())).thenReturn(new CheckResult<>(HttpStatus.OK, "", null)); - mockMvc.perform(MockMvcRequestBuilders.patch(ApiRoutes.CLUSTER_BASE_PATH + "/1") + when(clusterUtil.checkGroupClusterUpdateJson( + argThat(json -> json.getName() == groupClusterEntity.getName() && json.getCapacity() == groupClusterEntity.getMaxSize()) + )).thenReturn(new CheckResult<>(HttpStatus.OK, "", null)); + when(groupClusterRepository.save(groupClusterEntity)).thenReturn(groupClusterEntity); + when(entityToJsonConverter.clusterEntityToClusterJson(groupClusterEntity)).thenReturn(groupClusterJson); + mockMvc.perform(MockMvcRequestBuilders.patch(url) .contentType(MediaType.APPLICATION_JSON) .content(request)) - .andExpect(status().isOk()); + .andExpect(status().isOk()) + .andExpect(content().contentType(MediaType.APPLICATION_JSON)) + .andExpect(content().json(objectMapper.writeValueAsString(groupClusterJson))); + assertEquals(originalname, groupClusterEntity.getName()); + assertEquals(originalcapacity, groupClusterEntity.getMaxSize()); + + /* If fields are not null they are updated */ + request = "{\"name\": \"newclustername\", \"capacity\": 22}"; + reset(clusterUtil); + when(clusterUtil.getGroupClusterEntityIfAdminAndNotIndividual(groupClusterEntity.getId(), getMockUser())) + .thenReturn(new CheckResult<>(HttpStatus.OK, "", groupClusterEntity)); + GroupClusterEntity copy = new GroupClusterEntity(1L, 20, "newclustername", 5); + when(clusterUtil.checkGroupClusterUpdateJson( + argThat(json -> json.getName().equals("newclustername") && json.getCapacity().equals(22)) + )).thenReturn(new CheckResult<>(HttpStatus.OK, "", null)); + GroupClusterJson updated = new GroupClusterJson(1L, "newclustername", 22, 5, OffsetDateTime.now(), Collections.emptyList(), ""); + when(groupClusterRepository.save(groupClusterEntity)).thenReturn(copy); + when(entityToJsonConverter.clusterEntityToClusterJson(copy)).thenReturn(updated); + mockMvc.perform(MockMvcRequestBuilders.patch(url) + .contentType(MediaType.APPLICATION_JSON) + .content(request)) + .andExpect(status().isOk()) + .andExpect(content().contentType(MediaType.APPLICATION_JSON)) + .andExpect(content().json(objectMapper.writeValueAsString(updated))); + assertNotEquals(originalname, groupClusterEntity.getName()); + assertNotEquals(originalcapacity, groupClusterEntity.getMaxSize()); + /* If the json is invalid, the corresponding status code is returned */ + reset(clusterUtil); + when(clusterUtil.getGroupClusterEntityIfAdminAndNotIndividual(groupClusterEntity.getId(), getMockUser())) + .thenReturn(new CheckResult<>(HttpStatus.OK, "", groupClusterEntity)); + when(clusterUtil.checkGroupClusterUpdateJson(any())).thenReturn(new CheckResult<>(HttpStatus.FORBIDDEN, "", null)); + mockMvc.perform(MockMvcRequestBuilders.patch(url) + .contentType(MediaType.APPLICATION_JSON) + .content(request)) + .andExpect(status().isForbidden()); + + /* If the user is not an admin of the cluster or the cluster is individual, the corresponding status code is returned */ when(clusterUtil.getGroupClusterEntityIfAdminAndNotIndividual(anyLong(), any())).thenReturn(new CheckResult<>(HttpStatus.I_AM_A_TEAPOT, "", null)); - mockMvc.perform(MockMvcRequestBuilders.patch(ApiRoutes.CLUSTER_BASE_PATH + "/1") + mockMvc.perform(MockMvcRequestBuilders.patch(url) .contentType(MediaType.APPLICATION_JSON) .content(request)) .andExpect(status().isIAmATeapot()); @@ -162,43 +369,57 @@ public void testPatchCluster() throws Exception { @Test public void testDeleteCluster() throws Exception { - when(clusterUtil.canDeleteCluster(anyLong(), any())).thenReturn(new CheckResult<>(HttpStatus.OK, "", null)); - when(commonDatabaseActions.deleteClusterById(anyLong())).thenReturn(new CheckResult<>(HttpStatus.OK,"", null)); - mockMvc.perform(MockMvcRequestBuilders.delete(ApiRoutes.CLUSTER_BASE_PATH + "/1")) + String url = ApiRoutes.CLUSTER_BASE_PATH + "/" + groupClusterEntity.getId(); + + /* If the user can delete the cluster, the cluster is deleted */ + when(clusterUtil.canDeleteCluster(groupClusterEntity.getId(), getMockUser())).thenReturn(new CheckResult<>(HttpStatus.OK, "", null)); + when(commonDatabaseActions.deleteClusterById(groupClusterEntity.getId())).thenReturn(new CheckResult<>(HttpStatus.OK,"", null)); + mockMvc.perform(MockMvcRequestBuilders.delete(url)) .andExpect(status().isNoContent()); + /* If the delete fails, the corresponding status code is returned */ when(commonDatabaseActions.deleteClusterById(anyLong())).thenReturn(new CheckResult<>(HttpStatus.I_AM_A_TEAPOT,"", null)); - mockMvc.perform(MockMvcRequestBuilders.delete(ApiRoutes.CLUSTER_BASE_PATH + "/1")) + mockMvc.perform(MockMvcRequestBuilders.delete(url)) .andExpect(status().isIAmATeapot()); + /* If the user can't delete the cluster, the corresponding status code is returned */ when(clusterUtil.canDeleteCluster(anyLong(), any())).thenReturn(new CheckResult<>(HttpStatus.FORBIDDEN,"", null)); - mockMvc.perform(MockMvcRequestBuilders.delete(ApiRoutes.CLUSTER_BASE_PATH + "/1")) + mockMvc.perform(MockMvcRequestBuilders.delete(url)) .andExpect(status().isForbidden()); } @Test public void testCreateGroupForCluster() throws Exception { + String url = ApiRoutes.CLUSTER_BASE_PATH + "/" + groupClusterEntity.getId() + "/groups"; String request = "{\"name\": \"test\"}"; - when(clusterUtil.getGroupClusterEntityIfAdminAndNotIndividual(anyLong(), any())).thenReturn(new CheckResult<>(HttpStatus.OK, "", groupClusterEntity)); - when(groupRepository.save(any())).thenReturn(groupEntity); - mockMvc.perform(MockMvcRequestBuilders.post(ApiRoutes.CLUSTER_BASE_PATH + "/1/groups") + /* If the user is an admin of the cluster and the json is valid, the group is created */ + when(clusterUtil.getGroupClusterEntityIfAdminAndNotIndividual(groupClusterEntity.getId(), getMockUser())).thenReturn(new CheckResult<>(HttpStatus.OK, "", groupClusterEntity)); + when(groupRepository.save(argThat( + group -> group.getName().equals("test") && group.getClusterId() == groupClusterEntity.getId() + ))).thenReturn(groupEntity); + when(entityToJsonConverter.groupEntityToJson(groupEntity)).thenReturn(groupJson); + mockMvc.perform(MockMvcRequestBuilders.post(url) .contentType(MediaType.APPLICATION_JSON) .content(request)) - .andExpect(status().isCreated()); + .andExpect(status().isCreated()) + .andExpect(content().contentType(MediaType.APPLICATION_JSON)) + .andExpect(content().json(objectMapper.writeValueAsString(groupJson))); + /* if the user is not an admin or the cluster is individual, the corresponding status code is returned */ when(clusterUtil.getGroupClusterEntityIfAdminAndNotIndividual(anyLong(), any())).thenReturn(new CheckResult<>(HttpStatus.FORBIDDEN, "", null)); - mockMvc.perform(MockMvcRequestBuilders.post(ApiRoutes.CLUSTER_BASE_PATH + "/1/groups") + mockMvc.perform(MockMvcRequestBuilders.post(url) .contentType(MediaType.APPLICATION_JSON) .content(request)) .andExpect(status().isForbidden()); + /* If the json is invalid, the corresponding status code is returned */ request = "{\"name\": \"\"}"; - mockMvc.perform(MockMvcRequestBuilders.post(ApiRoutes.CLUSTER_BASE_PATH + "/1/groups") + mockMvc.perform(MockMvcRequestBuilders.post(url) .contentType(MediaType.APPLICATION_JSON) .content(request)) .andExpect(status().isBadRequest()); request = "{\"name\": null}"; - mockMvc.perform(MockMvcRequestBuilders.post(ApiRoutes.CLUSTER_BASE_PATH + "/1/groups") + mockMvc.perform(MockMvcRequestBuilders.post(url) .contentType(MediaType.APPLICATION_JSON) .content(request)) .andExpect(status().isBadRequest()); diff --git a/backend/app/src/test/java/com/ugent/pidgeon/controllers/ControllerTest.java b/backend/app/src/test/java/com/ugent/pidgeon/controllers/ControllerTest.java index 79af404e..b506e8b6 100644 --- a/backend/app/src/test/java/com/ugent/pidgeon/controllers/ControllerTest.java +++ b/backend/app/src/test/java/com/ugent/pidgeon/controllers/ControllerTest.java @@ -1,30 +1,48 @@ package com.ugent.pidgeon.controllers; +import com.ugent.pidgeon.GlobalErrorHandler; +import com.ugent.pidgeon.auth.RolesInterceptor; import com.ugent.pidgeon.model.Auth; import com.ugent.pidgeon.model.User; import com.ugent.pidgeon.postgre.models.UserEntity; import com.ugent.pidgeon.postgre.models.types.UserRole; import com.ugent.pidgeon.postgre.repository.UserRepository; +import java.util.logging.Logger; +import org.apache.juli.logging.Log; +import org.hibernate.annotations.Check; import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.Mock; import org.mockito.MockitoAnnotations; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.stereotype.Component; import org.springframework.test.web.servlet.MockMvc; import java.util.ArrayList; import java.util.Optional; +import org.springframework.test.web.servlet.request.MockMvcRequestBuilders; +import org.springframework.test.web.servlet.setup.MockMvcBuilders; import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.ArgumentMatchers.anyString; import static org.mockito.Mockito.lenient; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; public class ControllerTest { protected MockMvc mockMvc; + private UserEntity mockUser; @Mock protected UserRepository userRepository; + RolesInterceptor rolesInterceptor; + @BeforeEach public void testSetUp() { MockitoAnnotations.openMocks(this); @@ -33,23 +51,43 @@ public void testSetUp() { Auth authUser = new Auth(user, new ArrayList<>()); SecurityContextHolder.getContext().setAuthentication(authUser); - // Only stubbing necessary methods for the test - UserEntity userEntity = mockUser(); - authUser.setUserEntity(userEntity); - lenient().when(userRepository.findById(anyLong())).thenReturn(Optional.of(userEntity)); + mockUser = new UserEntity( + user.firstName, + user.lastName, + user.email, + UserRole.teacher, + user.oid + ); + mockUser.setId(1L); + authUser.setUserEntity(mockUser); + lenient().when(userRepository.findById(anyLong())).thenReturn(Optional.of(mockUser)); lenient().when(userRepository.findCourseIdsByUserId(anyLong())).thenReturn(new ArrayList<>()); - // when(userRepository.findUserByAzureId(anyString())).thenReturn(userEntity); + lenient().when(userRepository.findUserByAzureId("test")).thenReturn(Optional.of(mockUser)); + Logger.getGlobal().info("User: " + mockUser); + rolesInterceptor = new RolesInterceptor(userRepository); + // when(userRepository.findUserByAzureId(anyString())).thenReturn(userEntity); + } + protected void setUpController(Object controller) { + mockMvc = MockMvcBuilders.standaloneSetup(controller) + .addInterceptors(rolesInterceptor) + .setControllerAdvice(new GlobalErrorHandler()) + .defaultRequest(MockMvcRequestBuilders.get("/**") + .with(request -> { + request.setUserPrincipal(SecurityContextHolder.getContext().getAuthentication()); + return request; + })) + .build(); } - protected UserEntity mockUser() { - UserEntity userEntity = new UserEntity(); - userEntity.setId(1L); - userEntity.setRole(UserRole.student); - return userEntity; + protected UserEntity getMockUser() { + return mockUser; } + protected void setMockUserRoles(UserRole role) { + mockUser.setRole(role); + } } diff --git a/backend/app/src/test/java/com/ugent/pidgeon/controllers/CourseControllerTest.java b/backend/app/src/test/java/com/ugent/pidgeon/controllers/CourseControllerTest.java index 9e1e05d5..f80af245 100644 --- a/backend/app/src/test/java/com/ugent/pidgeon/controllers/CourseControllerTest.java +++ b/backend/app/src/test/java/com/ugent/pidgeon/controllers/CourseControllerTest.java @@ -1,21 +1,31 @@ package com.ugent.pidgeon.controllers; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.ugent.pidgeon.CustomObjectMapper; import com.ugent.pidgeon.model.ProjectResponseJson; +import com.ugent.pidgeon.model.json.CourseJoinInformationJson; import com.ugent.pidgeon.model.json.CourseReferenceJson; import com.ugent.pidgeon.model.json.CourseWithInfoJson; +import com.ugent.pidgeon.model.json.CourseWithRelationJson; import com.ugent.pidgeon.model.json.ProjectProgressJson; import com.ugent.pidgeon.model.json.UserReferenceJson; +import com.ugent.pidgeon.model.json.UserReferenceWithRelation; import com.ugent.pidgeon.postgre.models.*; import com.ugent.pidgeon.postgre.models.types.CourseRelation; import com.ugent.pidgeon.postgre.models.types.UserRole; import com.ugent.pidgeon.postgre.repository.*; +import com.ugent.pidgeon.postgre.repository.UserRepository.CourseIdWithRelation; import com.ugent.pidgeon.util.*; +import java.util.Collections; +import java.util.Objects; +import java.util.logging.Logger; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.InjectMocks; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.http.HttpMethod; import org.springframework.http.HttpStatus; import org.springframework.http.MediaType; import org.springframework.security.core.context.SecurityContextHolder; @@ -28,11 +38,26 @@ import java.util.List; import java.util.Optional; +import static org.hamcrest.Matchers.equalTo; +import static org.hamcrest.Matchers.not; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotEquals; +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.anyBoolean; +import static org.mockito.ArgumentMatchers.anyIterable; import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.ArgumentMatchers.argThat; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.doNothing; import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.reset; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; +import static org.springframework.test.web.servlet.result.MockMvcResultHandlers.print; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; @ExtendWith(MockitoExtension.class) @@ -66,25 +91,79 @@ public class CourseControllerTest extends ControllerTest { @InjectMocks private CourseController courseController; + private CourseEntity archivedCourse; + private CourseEntity activeCourse; + private CourseWithInfoJson activeCourseJson; + + private ObjectMapper objectMapper = CustomObjectMapper.createObjectMapper(); + + @BeforeEach public void setup() { - mockMvc = MockMvcBuilders.standaloneSetup(courseController) - .defaultRequest(MockMvcRequestBuilders.get("/**") - .with(request -> { - request.setUserPrincipal(SecurityContextHolder.getContext().getAuthentication()); - return request; - })) - .build(); + setUpController(courseController); + + archivedCourse = new CourseEntity("archivedname", "description",2024); + archivedCourse.setArchivedAt(OffsetDateTime.now()); + archivedCourse.setId(1); + activeCourse = new CourseEntity("name", "description",2024); + archivedCourse.setId(2); + + activeCourseJson = new CourseWithInfoJson( + activeCourse.getId(), + activeCourse.getName(), + activeCourse.getDescription(), + new UserReferenceJson("", "", 0L), + new ArrayList<>(), + "", + "", + "", + OffsetDateTime.now(), + OffsetDateTime.now(), + activeCourse.getCourseYear()); + } @Test public void testGetUserCourses() throws Exception { - CourseEntity course = mock(CourseEntity.class); + + /* Mock active course return */ when(userRepository.findCourseIdsByUserId(anyLong())). thenReturn(List.of(new UserRepository.CourseIdWithRelation[]{new UserRepository.CourseIdWithRelation() { @Override public Long getCourseId() { - return 1L; + return activeCourse.getId(); + } + + @Override + public CourseRelation getRelation() { + return CourseRelation.course_admin; + } + + @Override + public String getName() { + return activeCourse.getName(); + } + }})); + CourseWithRelationJson courseJson = new CourseWithRelationJson( + "", + CourseRelation.course_admin, + activeCourse.getName(), + activeCourse.getId(), + activeCourse.getArchivedAt(), + 2, + activeCourse.getCreatedAt(), + activeCourse.getCourseYear() + ); + when(entityToJsonConverter.courseEntityToCourseWithRelation(activeCourse, CourseRelation.course_admin)). + thenReturn(courseJson); + when(courseRepository.findById(activeCourse.getId())).thenReturn(Optional.of(activeCourse)); + + /* Mock archived course return */ + when(userRepository.findArchivedCoursesByUserId(anyLong())). + thenReturn(List.of(new UserRepository.CourseIdWithRelation[]{new UserRepository.CourseIdWithRelation() { + @Override + public Long getCourseId() { + return archivedCourse.getId(); } @Override @@ -94,16 +173,57 @@ public CourseRelation getRelation() { @Override public String getName() { - return ""; + return archivedCourse.getName(); } }})); - when(courseRepository.findById(anyLong())).thenReturn(Optional.empty()); + CourseWithRelationJson archivedCourseJson = new CourseWithRelationJson( + "", + CourseRelation.course_admin, + archivedCourse.getName(), + archivedCourse.getId(), + archivedCourse.getArchivedAt(), + 2, + archivedCourse.getCreatedAt(), + archivedCourse.getCourseYear() + ); + when(entityToJsonConverter.courseEntityToCourseWithRelation(archivedCourse, CourseRelation.course_admin)). + thenReturn(archivedCourseJson); + when(courseRepository.findById(archivedCourse.getId())).thenReturn(Optional.of(archivedCourse)); + /* If no archived param, return archived and active courses */ mockMvc.perform(MockMvcRequestBuilders.get(ApiRoutes.COURSE_BASE_PATH)) - .andExpect(status().isOk()); - when(courseRepository.findById(anyLong())).thenReturn(Optional.of(course)); + .andExpect(status().isOk()) + .andExpect(content().contentType(MediaType.APPLICATION_JSON)) + .andExpect(content().json(objectMapper.writeValueAsString(List.of(courseJson, archivedCourseJson)))); + + /* If archived param is false, return only active courses */ + mockMvc.perform(MockMvcRequestBuilders.get(ApiRoutes.COURSE_BASE_PATH + "?archived=false")) + .andExpect(status().isOk()) + .andExpect(content().contentType(MediaType.APPLICATION_JSON)) + .andExpect(content().json(objectMapper.writeValueAsString(List.of(courseJson)))); + + /* If archived param is true, return only archived courses */ + mockMvc.perform(MockMvcRequestBuilders.get(ApiRoutes.COURSE_BASE_PATH + "?archived=true")) + .andExpect(status().isOk()) + .andExpect(content().contentType(MediaType.APPLICATION_JSON)) + .andExpect(content().json(objectMapper.writeValueAsString(List.of(archivedCourseJson)))); + + /* If course doesn't get found, it just gets filtered */ + when(courseRepository.findById(activeCourse.getId())).thenReturn(Optional.empty()); mockMvc.perform(MockMvcRequestBuilders.get(ApiRoutes.COURSE_BASE_PATH)) - .andExpect(status().isOk()); + .andExpect(status().isOk()) + .andExpect(content().contentType(MediaType.APPLICATION_JSON)) + .andExpect(content().json(objectMapper.writeValueAsString(List.of(archivedCourseJson)))); + + /* If no courses are found, return empty list */ + when(userRepository.findCourseIdsByUserId(anyLong())).thenReturn(Collections.emptyList()); + when(userRepository.findArchivedCoursesByUserId(anyLong())).thenReturn(Collections.emptyList()); + mockMvc.perform(MockMvcRequestBuilders.get(ApiRoutes.COURSE_BASE_PATH)) + .andExpect(status().isOk()) + .andExpect(content().contentType(MediaType.APPLICATION_JSON)) + .andExpect(content().json("[]")); + + /* If error occurs, return 500 */ when(userRepository.findCourseIdsByUserId(anyLong())).thenThrow(new RuntimeException()); mockMvc.perform(MockMvcRequestBuilders.get(ApiRoutes.COURSE_BASE_PATH)) .andExpect(status().isInternalServerError()); @@ -113,26 +233,63 @@ public String getName() { @Test public void testCreateCourse() throws Exception { String courseJson = "{\"name\": \"test\", \"description\": \"description\",\"courseYear\" : 2024}"; - when(courseUtil.checkCourseJson(any(), any(), any())).thenReturn(new CheckResult<>(HttpStatus.OK, "", null)); - when(courseRepository.save(any())).thenReturn(null); - when(courseUserRepository.save(any())).thenReturn(null); - when(groupClusterRepository.save(any())).thenReturn(null); - when(courseUtil.getJoinLink(any(), any())).thenReturn(""); - when(entityToJsonConverter.courseEntityToCourseWithInfo(any(), any(), anyBoolean())). - thenReturn(new CourseWithInfoJson(0L, "", "", new UserReferenceJson("", "", 0L), - new ArrayList<>(), "", "", "", OffsetDateTime.now(), OffsetDateTime.now(), 2013)); + /* If everything is correct, return 200 */ + when(courseUtil.checkCourseJson(argThat( + json -> json.getName().equals("test") && + json.getDescription().equals("description") && + json.getYear() == 2024 + ), eq(getMockUser()), eq(null))).thenReturn(new CheckResult<>(HttpStatus.OK, "", null)); + when(courseRepository.save(any())).thenReturn(activeCourse); + when(courseUserRepository.save(argThat( + courseUser -> courseUser.getCourseId() == activeCourse.getId() && + courseUser.getUserId() == getMockUser().getId() && + courseUser.getRelation().equals(CourseRelation.creator) + ))).thenReturn(null); + when(groupClusterRepository.save(argThat( + groupCluster -> groupCluster.getCourseId() == activeCourse.getId() && + groupCluster.getMaxSize() == 1 && + groupCluster.getGroupAmount() == 0 + ))).thenReturn(null); + when(courseUtil.getJoinLink(activeCourse.getJoinKey(), ""+activeCourse.getId())).thenReturn(""); + when(entityToJsonConverter.courseEntityToCourseWithInfo(activeCourse, "", false)). + thenReturn(activeCourseJson); mockMvc.perform(MockMvcRequestBuilders.post(ApiRoutes.COURSE_BASE_PATH) .contentType(MediaType.APPLICATION_JSON) .content(courseJson)) - .andExpect(status().isOk()); + .andExpect(status().isOk()) + .andExpect(content().contentType(MediaType.APPLICATION_JSON)) + .andExpect(content().json(objectMapper.writeValueAsString(activeCourseJson))); + + verify(courseUserRepository, times(1)).save(argThat(courseUser -> + courseUser.getCourseId() == activeCourse.getId() && + courseUser.getUserId() == getMockUser().getId() && + courseUser.getRelation().equals(CourseRelation.creator) + )); + verify(groupClusterRepository, times(1)).save(argThat(groupCluster -> + groupCluster.getCourseId() == activeCourse.getId() && + groupCluster.getMaxSize() == 1 && + groupCluster.getGroupAmount() == 0 + )); + + /* If user is not a teacher, return 403 */ + setMockUserRoles(UserRole.student); + mockMvc.perform(MockMvcRequestBuilders.post(ApiRoutes.COURSE_BASE_PATH) + .contentType(MediaType.APPLICATION_JSON) + .content(courseJson)) + .andExpect(status().isForbidden()); + setMockUserRoles(UserRole.teacher); + /* If course json is invalid, return 400 */ + reset(courseUtil); + when(courseUtil.checkCourseJson(any(), any(), any())).thenReturn(new CheckResult<>(HttpStatus.BAD_REQUEST, "", null)); when(courseUtil.checkCourseJson(any(), any(),any())).thenReturn(new CheckResult<>(HttpStatus.I_AM_A_TEAPOT, "", null)); mockMvc.perform(MockMvcRequestBuilders.post(ApiRoutes.COURSE_BASE_PATH) .contentType(MediaType.APPLICATION_JSON) .content(courseJson)) .andExpect(status().isIAmATeapot()); + /* If error occurs, return 500 */ when(courseUtil.checkCourseJson(any(), any(), any())).thenThrow(new RuntimeException()); mockMvc.perform(MockMvcRequestBuilders.post(ApiRoutes.COURSE_BASE_PATH) .contentType(MediaType.APPLICATION_JSON) @@ -144,343 +301,756 @@ public void testCreateCourse() throws Exception { // This function also tests all lines of doCourseUpdate @Test public void testUpdateCourse() throws Exception { - String courseJson = "{\"name\": \"test\", \"description\": \"description\",\"courseYear\" : 2024}"; - when(courseUtil.getCourseIfAdmin(anyLong(), any())). - thenReturn(new CheckResult<>(HttpStatus.OK, "", new CourseEntity())); - when(courseUtil.checkCourseJson(any(), any(), any())).thenReturn(new CheckResult<>(HttpStatus.OK, "", null)); - when(courseRepository.save(any())).thenReturn(null); - mockMvc.perform(MockMvcRequestBuilders.put(ApiRoutes.COURSE_BASE_PATH + "/1") - .contentType(MediaType.APPLICATION_JSON) - .content(courseJson)) - .andExpect(status().isOk()); - - when(courseUtil.checkCourseJson(any(), any(), any())).thenReturn(new CheckResult<>(HttpStatus.I_AM_A_TEAPOT, "", null)); - mockMvc.perform(MockMvcRequestBuilders.put(ApiRoutes.COURSE_BASE_PATH + "/1") - .contentType(MediaType.APPLICATION_JSON) - .content(courseJson)) - .andExpect(status().isIAmATeapot()); - - when(courseUtil.getCourseIfAdmin(anyLong(), any())).thenReturn(new CheckResult<>(HttpStatus.FORBIDDEN, "", null)); - mockMvc.perform(MockMvcRequestBuilders.put(ApiRoutes.COURSE_BASE_PATH + "/1") - .contentType(MediaType.APPLICATION_JSON) - .content(courseJson)) - .andExpect(status().isForbidden()); - - when(courseUtil.getCourseIfAdmin(anyLong(), any())).thenThrow(new RuntimeException()); - mockMvc.perform(MockMvcRequestBuilders.put(ApiRoutes.COURSE_BASE_PATH + "/1") - .contentType(MediaType.APPLICATION_JSON) - .content(courseJson)) - .andExpect(status().isInternalServerError()); + String url = ApiRoutes.COURSE_BASE_PATH + "/" + activeCourse.getId(); + String courseJson = "{\"name\": \"test\", \"description\": \"description\",\"courseYear\" : 2024}"; + CourseEntity updatedEntity = new CourseEntity("test", "description",2024); + CourseWithInfoJson updatedJson = new CourseWithInfoJson( + activeCourse.getId(), + "test", + "description", + new UserReferenceJson("", "", 0L), + new ArrayList<>(), + "", + "", + "", + OffsetDateTime.now(), + OffsetDateTime.now(), + 2023 + ); + /* If admin and valid json, update course and return 200 */ + when(courseUtil.getCourseIfAdmin(activeCourse.getId(), getMockUser())). + thenReturn(new CheckResult<>(HttpStatus.OK, "", activeCourse)); + when(courseUtil.checkCourseJson( + argThat( + json -> json.getName().equals("test") && + json.getDescription().equals("description") && + json.getYear() == 2024 + ), + eq(getMockUser()), + eq(activeCourse.getId()))).thenReturn(new CheckResult<>(HttpStatus.OK, "", null)); + when(courseUtil.getJoinLink(activeCourse.getJoinKey(), ""+activeCourse.getId())).thenReturn(""); + when(courseRepository.save(activeCourse)).thenReturn(updatedEntity); + when(entityToJsonConverter.courseEntityToCourseWithInfo(updatedEntity, "", false)).thenReturn(updatedJson); + activeCourse.setArchivedAt(OffsetDateTime.now()); + OffsetDateTime originalArchivedAt = activeCourse.getArchivedAt(); + mockMvc.perform(MockMvcRequestBuilders.put(url) + .contentType(MediaType.APPLICATION_JSON) + .content(courseJson)) + .andExpect(status().isOk()) + .andExpect(content().contentType(MediaType.APPLICATION_JSON)) + .andExpect(content().json(objectMapper.writeValueAsString(updatedJson))); + assertEquals(originalArchivedAt, activeCourse.getArchivedAt()); + activeCourse.setArchivedAt(null); + + + /* If courseJson has archived field, update archived accordingly */ + String courseJsonWithArchivedTrue = "{\"name\": \"test\", \"description\": \"description\",\"courseYear\" : 2024, \"archived\": \"true\"}"; + mockMvc.perform(MockMvcRequestBuilders.put(url) + .contentType(MediaType.APPLICATION_JSON) + .content(courseJsonWithArchivedTrue)) + .andExpect(status().isOk()); + assertNotNull(activeCourse.getArchivedAt()); + + + String courseJsonWithArchivedFalse = "{\"name\": \"test\", \"description\": \"description\",\"courseYear\" : 2024, \"archived\": \"false\"}"; + mockMvc.perform(MockMvcRequestBuilders.put(url) + .contentType(MediaType.APPLICATION_JSON) + .content(courseJsonWithArchivedFalse)) + .andExpect(status().isOk()); + assertNull(activeCourse.getArchivedAt()); + + + /* If invalid json, return corresponding statuscode */ + reset(courseUtil); + when(courseUtil.getCourseIfAdmin(activeCourse.getId(), getMockUser())). + thenReturn(new CheckResult<>(HttpStatus.OK, "", activeCourse)); + when(courseUtil.checkCourseJson( + argThat( + json -> json.getName().equals("test") && + json.getDescription().equals("description") && + json.getYear() == 2024 + ), + eq(getMockUser()), + eq(activeCourse.getId()))).thenReturn(new CheckResult<>(HttpStatus.I_AM_A_TEAPOT, "", null)); + mockMvc.perform(MockMvcRequestBuilders.put(url) + .contentType(MediaType.APPLICATION_JSON) + .content(courseJson)) + .andExpect(status().isIAmATeapot()); + + /* If not admin, return 403 */ + when(courseUtil.getCourseIfAdmin(anyLong(), any())).thenReturn(new CheckResult<>(HttpStatus.FORBIDDEN, "", null)); + mockMvc.perform(MockMvcRequestBuilders.put(url) + .contentType(MediaType.APPLICATION_JSON) + .content(courseJson)) + .andExpect(status().isForbidden()); + + /* If error occurs, return 500 */ + when(courseUtil.getCourseIfAdmin(anyLong(), any())).thenThrow(new RuntimeException()); + mockMvc.perform(MockMvcRequestBuilders.put(url) + .contentType(MediaType.APPLICATION_JSON) + .content(courseJson)) + .andExpect(status().isInternalServerError()); } @Test public void testPatchCourse() throws Exception { - String courseJson = "{\"name\": null, \"description\": \"description\"}"; - CourseEntity courseEntity = new CourseEntity("name", "description",2024); - when(courseUtil.getCourseIfAdmin(anyLong(), any())).thenReturn(new CheckResult<>(HttpStatus.OK, "", courseEntity)); + String url = ApiRoutes.COURSE_BASE_PATH + "/" + activeCourse.getId(); + String originalName = activeCourse.getName(); + String originalDescription = activeCourse.getDescription(); + Integer originalYear = activeCourse.getCourseYear(); + CourseEntity updatedEntity = new CourseEntity("test", "description2",2024); + CourseWithInfoJson updatedJson = new CourseWithInfoJson( + activeCourse.getId(), + "test", + "description2", + new UserReferenceJson("", "", 0L), + new ArrayList<>(), + "", + "", + "", + OffsetDateTime.now(), + OffsetDateTime.now(), + 2023 + ); + /* If admin and valid json, update course and return 200 */ + when(courseUtil.getCourseIfAdmin(activeCourse.getId(), getMockUser())). + thenReturn(new CheckResult<>(HttpStatus.OK, "", activeCourse)); when(courseUtil.checkCourseJson(any(), any(), any())).thenReturn(new CheckResult<>(HttpStatus.OK, "", null)); - when(courseRepository.save(any())).thenReturn(null); + when(courseUtil.getJoinLink(activeCourse.getJoinKey(), ""+activeCourse.getId())).thenReturn(""); + when(courseRepository.save(activeCourse)).thenReturn(updatedEntity); + when(entityToJsonConverter.courseEntityToCourseWithInfo(updatedEntity, "", false)).thenReturn(updatedJson); + /* If field is not present, do not update it */ + String patchCourseJson = "{\"name\": \"test\"}"; + mockMvc.perform(MockMvcRequestBuilders.patch(url) + .contentType(MediaType.APPLICATION_JSON) + .content(patchCourseJson)) + .andExpect(status().isOk()); + String finalOriginalDescription = originalDescription; + verify(courseUtil, times(1)).checkCourseJson(argThat( + json -> json.getName().equals("test") && + json.getDescription().equals(finalOriginalDescription) && + Objects.equals(json.getYear(), originalYear) + ), eq(getMockUser()), eq(activeCourse.getId())); + assertNotEquals(originalName, activeCourse.getName()); + assertEquals(originalDescription, activeCourse.getDescription()); + assertEquals(originalYear, activeCourse.getCourseYear()); + assertNull(activeCourse.getArchivedAt()); + originalName = activeCourse.getName(); + + String patchCourseJsonNoName = "{\"description\": \"description88\"}"; + mockMvc.perform(MockMvcRequestBuilders.patch(url) + .contentType(MediaType.APPLICATION_JSON) + .content(patchCourseJsonNoName)) + .andExpect(status().isOk()); + String finalOriginalName = originalName; + verify(courseUtil, times(1)).checkCourseJson(argThat( + json -> json.getName().equals(finalOriginalName) && + json.getDescription().equals("description88") && + Objects.equals(json.getYear(), originalYear) + ), eq(getMockUser()), eq(activeCourse.getId())); + assertEquals(originalName, activeCourse.getName()); + assertNotEquals(originalDescription, activeCourse.getDescription()); + assertEquals(originalYear, activeCourse.getCourseYear()); + assertNull(activeCourse.getArchivedAt()); + + /* If fields are present, update them */ + String requestJson = "{\"name\": \"test2\", \"description\": \"description2\",\"courseYear\" : 2034}"; + originalDescription = activeCourse.getDescription(); + activeCourse.setArchivedAt(OffsetDateTime.now()); + OffsetDateTime originalArchivedAt = activeCourse.getArchivedAt(); + mockMvc.perform(MockMvcRequestBuilders.patch(url) + .contentType(MediaType.APPLICATION_JSON) + .content(requestJson)) + .andExpect(status().isOk()) + .andExpect(content().contentType(MediaType.APPLICATION_JSON)) + .andExpect(content().json(objectMapper.writeValueAsString(updatedJson))); + verify(courseUtil, times(1)).checkCourseJson(argThat( + json -> json.getName().equals("test2") && + json.getDescription().equals("description2") && + json.getYear() == 2034 + ), eq(getMockUser()), eq(activeCourse.getId())); + assertNotEquals(originalName, activeCourse.getName()); + assertNotEquals(originalDescription, activeCourse.getDescription()); + assertNotEquals(originalYear, activeCourse.getCourseYear()); + assertEquals(originalArchivedAt, activeCourse.getArchivedAt()); + activeCourse.setArchivedAt(null); + + /* If courseJson has archived field, update archived accordingly */ + String courseJsonWithArchivedTrue = "{\"name\": \"test\", \"description\": \"description\",\"courseYear\" : 2024, \"archived\": \"true\"}"; + mockMvc.perform(MockMvcRequestBuilders.patch(url) + .contentType(MediaType.APPLICATION_JSON) + .content(courseJsonWithArchivedTrue)) + .andExpect(status().isOk()); + assertNotNull(activeCourse.getArchivedAt()); - mockMvc.perform(MockMvcRequestBuilders.patch(ApiRoutes.COURSE_BASE_PATH + "/1") - .contentType(MediaType.APPLICATION_JSON) - .content(courseJson)) - .andExpect(status().isOk()); - courseJson = "{\"name\": \"name\", \"description\": null}"; - mockMvc.perform(MockMvcRequestBuilders.patch(ApiRoutes.COURSE_BASE_PATH + "/1") - .contentType(MediaType.APPLICATION_JSON) - .content(courseJson)) - .andExpect(status().isOk()); + String courseJsonWithArchivedFalse = "{\"name\": \"test\", \"description\": \"description\",\"courseYear\" : 2024, \"archived\": \"false\"}"; + mockMvc.perform(MockMvcRequestBuilders.patch(url) + .contentType(MediaType.APPLICATION_JSON) + .content(courseJsonWithArchivedFalse)) + .andExpect(status().isOk()); + assertNull(activeCourse.getArchivedAt()); + + /* If no fields are present, change nothing */ + String emptyJson = "{\"ietswatnietboeit\": \"test\"}"; + mockMvc.perform(MockMvcRequestBuilders.patch(url) + .contentType(MediaType.APPLICATION_JSON) + .content(emptyJson)) + .andExpect(status().isOk()); + assertEquals("test", activeCourse.getName()); + assertEquals("description", activeCourse.getDescription()); + assertEquals(2024, activeCourse.getCourseYear()); + - courseJson = "{\"name\": null, \"description\": null}"; - mockMvc.perform(MockMvcRequestBuilders.patch(ApiRoutes.COURSE_BASE_PATH + "/1") - .contentType(MediaType.APPLICATION_JSON) - .content(courseJson)) - .andExpect(status().isBadRequest()); + /* If invalid json, return corresponding statuscode */ + when(courseUtil.checkCourseJson(any(), any(), any())).thenReturn(new CheckResult<>(HttpStatus.I_AM_A_TEAPOT, "", null)); + mockMvc.perform(MockMvcRequestBuilders.patch(url) + .contentType(MediaType.APPLICATION_JSON) + .content(requestJson)) + .andExpect(status().isIAmATeapot()); + + /* If not admin, return 403 */ when(courseUtil.getCourseIfAdmin(anyLong(), any())).thenReturn(new CheckResult<>(HttpStatus.FORBIDDEN, "", null)); - mockMvc.perform(MockMvcRequestBuilders.patch(ApiRoutes.COURSE_BASE_PATH + "/1") - .contentType(MediaType.APPLICATION_JSON) - .content(courseJson)) - .andExpect(status().isForbidden()); + mockMvc.perform(MockMvcRequestBuilders.patch(url) + .contentType(MediaType.APPLICATION_JSON) + .content(requestJson)) + .andExpect(status().isForbidden()); + /* If error occurs, return 500 */ when(courseUtil.getCourseIfAdmin(anyLong(), any())).thenThrow(new RuntimeException()); - mockMvc.perform(MockMvcRequestBuilders.patch(ApiRoutes.COURSE_BASE_PATH + "/1") - .contentType(MediaType.APPLICATION_JSON) - .content(courseJson)) - .andExpect(status().isInternalServerError()); + mockMvc.perform(MockMvcRequestBuilders.patch(url) + .contentType(MediaType.APPLICATION_JSON) + .content(requestJson)) + .andExpect(status().isInternalServerError()); } @Test public void testGetCourseByCourseId() throws Exception { - when(courseUtil.getJoinLink(any(), any())).thenReturn(""); + String url = ApiRoutes.COURSE_BASE_PATH + "/" + activeCourse.getId(); + /* If user is admin, return course with joinKey information */ + when(courseUtil.getJoinLink(activeCourse.getJoinKey(), ""+activeCourse.getId())).thenReturn(""); when(entityToJsonConverter.courseEntityToCourseWithInfo(any(), any(), anyBoolean())). - thenReturn(new CourseWithInfoJson(0L, "", "", new UserReferenceJson("", "", 0L), - new ArrayList<>(), "", "", "", OffsetDateTime.now(), OffsetDateTime.now(), 2023)); - when(courseUtil.getCourseIfUserInCourse(anyLong(), any(UserEntity.class))). - thenReturn(new CheckResult<>(HttpStatus.OK, "", new Pair<>(new CourseEntity(), CourseRelation.course_admin))); - mockMvc.perform(MockMvcRequestBuilders.get(ApiRoutes.COURSE_BASE_PATH + "/1")) - .andExpect(status().isOk()); - + thenReturn(activeCourseJson); + when(courseUtil.getCourseIfUserInCourse(activeCourse.getId(), getMockUser())). + thenReturn(new CheckResult<>(HttpStatus.OK, "", new Pair<>(activeCourse, CourseRelation.course_admin))); + mockMvc.perform(MockMvcRequestBuilders.get(url)) + .andExpect(status().isOk()) + .andExpect(content().contentType(MediaType.APPLICATION_JSON)) + .andExpect(content().json(objectMapper.writeValueAsString(activeCourseJson))); + verify(entityToJsonConverter, times(1)).courseEntityToCourseWithInfo(activeCourse, "", false); + + /* If user is not admin, return course without joinKey information */ + when(courseUtil.getCourseIfUserInCourse(activeCourse.getId(), getMockUser())). + thenReturn(new CheckResult<>(HttpStatus.OK, "", new Pair<>(activeCourse, CourseRelation.enrolled))); + mockMvc.perform(MockMvcRequestBuilders.get(url)).andExpect(status().isOk()); + verify(entityToJsonConverter, times(1)).courseEntityToCourseWithInfo(activeCourse, "", true); + + /* If course is not found, or user no acces return corresponding status */ when(courseUtil.getCourseIfUserInCourse(anyLong(), any(UserEntity.class))). thenReturn(new CheckResult<>(HttpStatus.NOT_FOUND, "", new Pair<>(null, null))); - - mockMvc.perform(MockMvcRequestBuilders.get(ApiRoutes.COURSE_BASE_PATH + "/1")) + mockMvc.perform(MockMvcRequestBuilders.get(url)) .andExpect(status().isNotFound()); + when(courseUtil.getCourseIfUserInCourse(anyLong(), any(UserEntity.class))). + thenReturn(new CheckResult<>(HttpStatus.FORBIDDEN, "", new Pair<>(null, null))); + mockMvc.perform(MockMvcRequestBuilders.get(url)).andExpect(status().isForbidden()); } @Test public void testDeleteCourse() throws Exception { + String url = ApiRoutes.COURSE_BASE_PATH + "/" + activeCourse.getId(); + + /* If user is the creator of the course, delete succeeds and also deletes linked projects & coursses */ ProjectEntity project = new ProjectEntity(1, "name", "description", 1L, 1L, true, 20, OffsetDateTime.now()); GroupClusterEntity groupCluster = new GroupClusterEntity(1L, 20, "cluster", 5); - when(courseUtil.getCourseIfUserInCourse(anyLong(), any())). - thenReturn(new CheckResult<>(HttpStatus.OK, "", new Pair<>(new CourseEntity(), CourseRelation.creator))); - when(courseRepository.findAllProjectsByCourseId(anyLong())).thenReturn(List.of(project)); - when(commonDatabaseActions.deleteProject(anyLong())).thenReturn(new CheckResult<>(HttpStatus.OK, "", null)); - when(groupClusterRepository.findByCourseId(anyLong())).thenReturn(List.of(groupCluster)); - when(commonDatabaseActions.deleteClusterById(anyLong())).thenReturn(new CheckResult<>(HttpStatus.OK, "", null)); - - mockMvc.perform(MockMvcRequestBuilders.delete(ApiRoutes.COURSE_BASE_PATH + "/1")) + when(courseUtil.getCourseIfUserInCourse(activeCourse.getId(), getMockUser())). + thenReturn(new CheckResult<>(HttpStatus.OK, "", new Pair<>(activeCourse, CourseRelation.creator))); + when(courseRepository.findAllProjectsByCourseId(activeCourse.getId())).thenReturn(List.of(project)); + when(commonDatabaseActions.deleteProject(project.getId())).thenReturn(new CheckResult<>(HttpStatus.OK, "", null)); + when(groupClusterRepository.findByCourseId(activeCourse.getId())).thenReturn(List.of(groupCluster)); + when(commonDatabaseActions.deleteClusterById(groupCluster.getId())).thenReturn(new CheckResult<>(HttpStatus.OK, "", null)); + CourseUserEntity courseUser = new CourseUserEntity(1L, 1L, CourseRelation.creator); + List courseUsers = List.of(courseUser); + when(courseUserRepository.findAllUsersByCourseId(activeCourse.getId())).thenReturn(courseUsers); + doNothing().when(courseUserRepository).deleteAll(anyIterable()); + mockMvc.perform(MockMvcRequestBuilders.delete(url)) .andExpect(status().isOk()); + verify(courseUserRepository, times(1)).deleteAll(courseUsers); + /* If something goes wrong while deleting a cluster or project, return corresponding status */ when(commonDatabaseActions.deleteClusterById(anyLong())).thenReturn(new CheckResult<>(HttpStatus.NO_CONTENT, "", null)); - mockMvc.perform(MockMvcRequestBuilders.delete(ApiRoutes.COURSE_BASE_PATH + "/1")) + mockMvc.perform(MockMvcRequestBuilders.delete(url)) .andExpect(status().isNoContent()); when(commonDatabaseActions.deleteProject(anyLong())).thenReturn(new CheckResult<>(HttpStatus.I_AM_A_TEAPOT, "", null)); - mockMvc.perform(MockMvcRequestBuilders.delete(ApiRoutes.COURSE_BASE_PATH + "/1")) + mockMvc.perform(MockMvcRequestBuilders.delete(url)) .andExpect(status().isIAmATeapot()); + /* If user isn't in course or course doesn't exist return corresponding status */ when(courseUtil.getCourseIfUserInCourse(anyLong(), any())).thenReturn(new CheckResult<>(HttpStatus.BAD_REQUEST, "", new Pair<>(null, null))); - mockMvc.perform(MockMvcRequestBuilders.delete(ApiRoutes.COURSE_BASE_PATH + "/1")) + mockMvc.perform(MockMvcRequestBuilders.delete(url)) .andExpect(status().isBadRequest()); + /* If user isn't the creator of the course, return 403 */ when(courseUtil.getCourseIfUserInCourse(anyLong(), any())). thenReturn(new CheckResult<>(HttpStatus.OK, "", new Pair<>(new CourseEntity(), CourseRelation.enrolled))); - mockMvc.perform(MockMvcRequestBuilders.delete(ApiRoutes.COURSE_BASE_PATH + "/1")) + mockMvc.perform(MockMvcRequestBuilders.delete(url)) .andExpect(status().isForbidden()); + /* If a unexpected error occurs, return 500 */ when(courseUtil.getCourseIfUserInCourse(anyLong(), any())).thenThrow(new RuntimeException()); - mockMvc.perform(MockMvcRequestBuilders.delete(ApiRoutes.COURSE_BASE_PATH + "/1")) + mockMvc.perform(MockMvcRequestBuilders.delete(url)) .andExpect(status().isInternalServerError()); } @Test public void testGetProjectsByCourseId() throws Exception { - CourseEntity course = new CourseEntity("name", "descripton",2024); - course.setId(1); - List projects = Arrays.asList(new ProjectEntity(), new ProjectEntity()); - when(courseUtil.getCourseIfUserInCourse(anyLong(), any())) - .thenReturn(new CheckResult<>(HttpStatus.OK, "", new Pair<>(course, CourseRelation.creator))); - when(projectRepository.findByCourseId(anyLong())).thenReturn(projects); - when(entityToJsonConverter.projectEntityToProjectResponseJson(any(ProjectEntity.class), any(CourseEntity.class), any(UserEntity.class))).thenReturn(new ProjectResponseJson( - new CourseReferenceJson("", "Test Course", 1L, OffsetDateTime.now()), - OffsetDateTime.MIN, - "", - 1L, - "Test Description", - "", - "", - 1, - true, - new ProjectProgressJson(1, 1), - 1L, - 1L - )); - mockMvc.perform(MockMvcRequestBuilders.get(ApiRoutes.COURSE_BASE_PATH + "/1/projects")) - .andExpect(status().isOk()); + String url = ApiRoutes.COURSE_BASE_PATH + "/" + activeCourse.getId() + "/projects"; + Pair creatorPair = new Pair<>(activeCourse, CourseRelation.creator); + Pair enrolledPair = new Pair<>(activeCourse, CourseRelation.enrolled); + ProjectEntity project = new ProjectEntity(1, "name", "description", 1L, 1L, true, 20, OffsetDateTime.now()); + ProjectResponseJson projectJson = new ProjectResponseJson( + new CourseReferenceJson("", "Test Course", 1L, OffsetDateTime.now()), + OffsetDateTime.MIN, + "", + 1L, + "Test Description", + "", + "", + 1, + true, + new ProjectProgressJson(1, 1), + 1L, + 1L + ); + /* If user is in course, return projects */ + when(courseUtil.getCourseIfUserInCourse(activeCourse.getId(),getMockUser())) + .thenReturn(new CheckResult<>(HttpStatus.OK, "", creatorPair)); + + List projects = List.of(project); + when(projectRepository.findByCourseId(activeCourse.getId())).thenReturn(projects); + when(entityToJsonConverter.projectEntityToProjectResponseJson(project, activeCourse, getMockUser())).thenReturn(projectJson); + mockMvc.perform(MockMvcRequestBuilders.get(url)) + .andExpect(status().isOk()) + .andExpect(content().contentType(MediaType.APPLICATION_JSON)) + .andExpect(content().json(objectMapper.writeValueAsString(List.of(projectJson)))); + + /* If a project isn't visible, and user role is student, it should not be returned */ + project.setVisible(false); + when(courseUtil.getCourseIfUserInCourse(activeCourse.getId(), getMockUser())) + .thenReturn(new CheckResult<>(HttpStatus.OK, "", enrolledPair)); + mockMvc.perform(MockMvcRequestBuilders.get(url)) + .andExpect(status().isOk()) + .andExpect(content().contentType(MediaType.APPLICATION_JSON)) + .andExpect(content().json("[]")); + when(courseUtil.getCourseIfUserInCourse(activeCourse.getId(), getMockUser())) + .thenReturn(new CheckResult<>(HttpStatus.OK, "", creatorPair)); + mockMvc.perform(MockMvcRequestBuilders.get(url)) + .andExpect(status().isOk()) + .andExpect(content().contentType(MediaType.APPLICATION_JSON)) + .andExpect(content().json(objectMapper.writeValueAsString(List.of(projectJson)))); + + /* If user not in course, or course doesn't exit or any other check fails, return corresponding status */ when(courseUtil.getCourseIfUserInCourse(anyLong(), any())).thenReturn(new CheckResult<>(HttpStatus.I_AM_A_TEAPOT, "", null)); - mockMvc.perform(MockMvcRequestBuilders.get(ApiRoutes.COURSE_BASE_PATH + "/1/projects")) + mockMvc.perform(MockMvcRequestBuilders.get(url)) .andExpect(status().isIAmATeapot()); } @Test public void testJoinCourse() throws Exception { - CourseEntity course = new CourseEntity("name", "descripton",2024); - course.setId(1); - when(courseUtil.checkJoinLink(anyLong(), any(), any())).thenReturn(new CheckResult<>(HttpStatus.OK, "", course)); - when(commonDatabaseActions.createNewIndividualClusterGroup(anyLong(), any())).thenReturn(true); - mockMvc.perform(MockMvcRequestBuilders.post(ApiRoutes.COURSE_BASE_PATH + "/1/join/1908")) - .andExpect(status().isOk()); + String urlWithKey = ApiRoutes.COURSE_BASE_PATH + "/" + activeCourse.getId() + "/join/1908"; + String urlWithoutKey = ApiRoutes.COURSE_BASE_PATH + "/" + activeCourse.getId() + "/join"; + CourseEntity course = activeCourse; + /* If join key is correct, course is not archived and no error occurs, return 200 */ + when(courseUtil.checkJoinLink(activeCourse.getId(), "1908", getMockUser())).thenReturn(new CheckResult<>(HttpStatus.OK, "", course)); + when(courseUtil.checkJoinLink(activeCourse.getId(), null, getMockUser())).thenReturn(new CheckResult<>(HttpStatus.OK, "", course)); + when(commonDatabaseActions.createNewIndividualClusterGroup(activeCourse.getId(), getMockUser())).thenReturn(true); + when(courseUtil.getJoinLink(course.getJoinKey(), ""+course.getId())).thenReturn(""); + when(entityToJsonConverter.courseEntityToCourseWithInfo(activeCourse, "", false)).thenReturn(activeCourseJson); + mockMvc.perform(MockMvcRequestBuilders.post(urlWithKey)) + .andExpect(status().isOk()) + .andExpect(content().contentType(MediaType.APPLICATION_JSON)) + .andExpect(content().json(objectMapper.writeValueAsString(activeCourseJson))); + mockMvc.perform(MockMvcRequestBuilders.post(urlWithoutKey)) + .andExpect(status().isOk()) + .andExpect(content().contentType(MediaType.APPLICATION_JSON)) + .andExpect(content().json(objectMapper.writeValueAsString(activeCourseJson))); + verify(courseUserRepository, times(2)).save(argThat(courseUser -> + courseUser.getCourseId() == activeCourse.getId() && + courseUser.getUserId() == getMockUser().getId() && + courseUser.getRelation().equals(CourseRelation.enrolled) + )); + + /* If course is archived, return 403 */ + activeCourse.setArchivedAt(OffsetDateTime.now()); + mockMvc.perform(MockMvcRequestBuilders.post(urlWithKey)) + .andExpect(status().isForbidden()); + mockMvc.perform(MockMvcRequestBuilders.post(urlWithoutKey)) + .andExpect(status().isForbidden()); + activeCourse.setArchivedAt(null); + /* If an error occures when creating individual cluster group, return 500 */ when(commonDatabaseActions.createNewIndividualClusterGroup(anyLong(), any())).thenReturn(false); - mockMvc.perform(MockMvcRequestBuilders.post(ApiRoutes.COURSE_BASE_PATH + "/1/join/1908")) + mockMvc.perform(MockMvcRequestBuilders.post(urlWithKey)) + .andExpect(status().isInternalServerError()); + mockMvc.perform(MockMvcRequestBuilders.post(urlWithoutKey)) .andExpect(status().isInternalServerError()); + /* If join key check fails return corresponding status */ when(courseUtil.checkJoinLink(anyLong(), any(), any())).thenReturn(new CheckResult<>(HttpStatus.I_AM_A_TEAPOT, "", null)); - mockMvc.perform(MockMvcRequestBuilders.post(ApiRoutes.COURSE_BASE_PATH + "/1/join/1908")) - .andExpect(status().isIAmATeapot()); + mockMvc.perform(MockMvcRequestBuilders.post(urlWithKey)) + .andExpect(status().isIAmATeapot()); + mockMvc.perform(MockMvcRequestBuilders.post(urlWithoutKey)) + .andExpect(status().isIAmATeapot()); } @Test - public void testGetJoinKey() throws Exception { - CourseEntity course = new CourseEntity("name", "descripton",2024); - course.setId(1); - when(courseUtil.checkJoinLink(anyLong(), any(), any())).thenReturn(new CheckResult<>(HttpStatus.OK, "", course)); - mockMvc.perform(MockMvcRequestBuilders.get(ApiRoutes.COURSE_BASE_PATH + "/1/join/1908")) - .andExpect(status().isOk()); - - when(courseUtil.checkJoinLink(anyLong(), any(), any())).thenReturn(new CheckResult<>(HttpStatus.I_AM_A_TEAPOT, "", course)); - mockMvc.perform(MockMvcRequestBuilders.get(ApiRoutes.COURSE_BASE_PATH + "/1/join/1908")) - .andExpect(status().isIAmATeapot()); - } - - @Test - public void testJoinCourseNoKey() throws Exception { - CourseEntity course = new CourseEntity("name", "descripton",2024); - course.setId(1); - when(courseUtil.checkJoinLink(anyLong(), any(), any())).thenReturn(new CheckResult<>(HttpStatus.OK, "", course)); - when(commonDatabaseActions.createNewIndividualClusterGroup(anyLong(), any())).thenReturn(true); - mockMvc.perform(MockMvcRequestBuilders.post(ApiRoutes.COURSE_BASE_PATH + "/1/join")) - .andExpect(status().isOk()); - - when(commonDatabaseActions.createNewIndividualClusterGroup(anyLong(), any())).thenReturn(false); - mockMvc.perform(MockMvcRequestBuilders.post(ApiRoutes.COURSE_BASE_PATH + "/1/join")) - .andExpect(status().isInternalServerError()); + public void testGetJoinInformation() throws Exception { + String urlWithKey = ApiRoutes.COURSE_BASE_PATH + "/" + activeCourse.getId() + "/join/1908"; + String urlWithoutKey = ApiRoutes.COURSE_BASE_PATH + "/" + activeCourse.getId() + "/join"; + + CourseEntity course = activeCourse; + CourseJoinInformationJson courseJoinInformationJson = new CourseJoinInformationJson( + activeCourse.getName(), + activeCourse.getDescription() + ); + /* If join key is correct, course is not archived and no error occurs, return 200 */ + when(courseUtil.checkJoinLink(activeCourse.getId(), "1908", getMockUser())).thenReturn(new CheckResult<>(HttpStatus.OK, "", course)); + when(courseUtil.checkJoinLink(activeCourse.getId(), null, getMockUser())).thenReturn(new CheckResult<>(HttpStatus.OK, "", course)); + mockMvc.perform(MockMvcRequestBuilders.get(urlWithKey)) + .andExpect(status().isOk()) + .andExpect(content().contentType(MediaType.APPLICATION_JSON)) + .andExpect(content().json(objectMapper.writeValueAsString(courseJoinInformationJson))); + mockMvc.perform(MockMvcRequestBuilders.get(urlWithoutKey)) + .andExpect(status().isOk()) + .andExpect(content().contentType(MediaType.APPLICATION_JSON)) + .andExpect(content().json(objectMapper.writeValueAsString(courseJoinInformationJson))); + + /* If course is archived, reutrn 403 */ + activeCourse.setArchivedAt(OffsetDateTime.now()); + mockMvc.perform(MockMvcRequestBuilders.get(urlWithKey)) + .andExpect(status().isForbidden()); + mockMvc.perform(MockMvcRequestBuilders.get(urlWithoutKey)) + .andExpect(status().isForbidden()); + activeCourse.setArchivedAt(null); + + /* If join key check fails return corresponding status */ + when(courseUtil.checkJoinLink(anyLong(), any(), any())).thenReturn(new CheckResult<>(HttpStatus.I_AM_A_TEAPOT, "", null)); + mockMvc.perform(MockMvcRequestBuilders.get(urlWithKey)) + .andExpect(status().isIAmATeapot()); + mockMvc.perform(MockMvcRequestBuilders.get(urlWithoutKey)) + .andExpect(status().isIAmATeapot()); } - @Test - public void testGetCourseJoinKeyNoKey() throws Exception { - CourseEntity course = new CourseEntity("name", "descripton",2024); - course.setId(1); - when(courseUtil.checkJoinLink(anyLong(), any(), any())).thenReturn(new CheckResult<>(HttpStatus.OK, "", course)); - mockMvc.perform(MockMvcRequestBuilders.get(ApiRoutes.COURSE_BASE_PATH + "/1/join")) - .andExpect(status().isOk()); - - } @Test public void testLeaveCourse() throws Exception { - when(courseUtil.canLeaveCourse(anyLong(), any())).thenReturn(new CheckResult<>(HttpStatus.OK, "", CourseRelation.enrolled)); - when(commonDatabaseActions.removeIndividualClusterGroup(anyLong(), anyLong())).thenReturn(true); - mockMvc.perform(MockMvcRequestBuilders.delete(ApiRoutes.COURSE_BASE_PATH + "/1/leave")) + String url = ApiRoutes.COURSE_BASE_PATH + "/" + activeCourse.getId() + "/leave"; + /* If user can leave course, return 200 */ + /* If role is enrolled, an individualclustergroup should be deleted */ + when(courseUtil.canLeaveCourse(activeCourse.getId(), getMockUser())).thenReturn(new CheckResult<>(HttpStatus.OK, "", CourseRelation.enrolled)); + when(commonDatabaseActions.removeIndividualClusterGroup(activeCourse.getId(), getMockUser().getId())).thenReturn(true); + mockMvc.perform(MockMvcRequestBuilders.delete(url)) .andExpect(status().isOk()); + verify(commonDatabaseActions, times(1)).removeIndividualClusterGroup(activeCourse.getId(), getMockUser().getId()); + + /* If the role isn't enrolled, no individualclustergroup should be deleted */ + when(courseUtil.canLeaveCourse(activeCourse.getId(), getMockUser())).thenReturn(new CheckResult<>(HttpStatus.OK, "", CourseRelation.course_admin)); + reset(commonDatabaseActions); + mockMvc.perform(MockMvcRequestBuilders.delete(url)) + .andExpect(status().isOk()); + verify(commonDatabaseActions, times(0)).removeIndividualClusterGroup(activeCourse.getId(), getMockUser().getId()); + /* If something goes wrong while deleting individual cluster group, return 500 */ + when(courseUtil.canLeaveCourse(activeCourse.getId(), getMockUser())).thenReturn(new CheckResult<>(HttpStatus.OK, "", CourseRelation.enrolled)); when(commonDatabaseActions.removeIndividualClusterGroup(anyLong(), anyLong())).thenReturn(false); - mockMvc.perform(MockMvcRequestBuilders.delete(ApiRoutes.COURSE_BASE_PATH + "/1/leave")) + mockMvc.perform(MockMvcRequestBuilders.delete(url)) .andExpect(status().isInternalServerError()); + verify(commonDatabaseActions, times(1)).removeIndividualClusterGroup(activeCourse.getId(), getMockUser().getId()); + /* If user can't leave course for some reason, return corresponding error code */ when(courseUtil.canLeaveCourse(anyLong(), any())).thenReturn(new CheckResult<>(HttpStatus.BAD_REQUEST, "", null)); - mockMvc.perform(MockMvcRequestBuilders.delete(ApiRoutes.COURSE_BASE_PATH + "/1/leave")) - .andExpect(status().isBadRequest()); + mockMvc.perform(MockMvcRequestBuilders.delete(url)) + .andExpect(status().isBadRequest()); + /* If an unexpected error occurs, return 500 */ when(courseUtil.canLeaveCourse(anyLong(), any())).thenThrow(new RuntimeException()); - mockMvc.perform(MockMvcRequestBuilders.delete(ApiRoutes.COURSE_BASE_PATH + "/1/leave")) + mockMvc.perform(MockMvcRequestBuilders.delete(url)) .andExpect(status().isInternalServerError()); } @Test public void testRemoveCourseMember() throws Exception { - String userIdJson = "{\"userId\": 1}"; - when(courseUtil.canDeleteUser(anyLong(), anyLong(), any())).thenReturn(new CheckResult<>(HttpStatus.OK, "", CourseRelation.enrolled)); - when(commonDatabaseActions.removeIndividualClusterGroup(anyLong(), anyLong())).thenReturn(true); - mockMvc.perform(MockMvcRequestBuilders.delete(ApiRoutes.COURSE_BASE_PATH + "/1/members/2") - .contentType(MediaType.APPLICATION_JSON) - .content(userIdJson)) + long userId = 2L; + String url = ApiRoutes.COURSE_BASE_PATH + "/" + activeCourse.getId() + "/members/2"; + + /* If user can remove other people, and course exists, return 200 */ + /* If user is admin, removeIndividualClusterGroup gets called */ + when(courseUtil.canDeleteUser(activeCourse.getId(), userId, getMockUser())).thenReturn(new CheckResult<>(HttpStatus.OK, "", CourseRelation.course_admin)); + when(commonDatabaseActions.removeIndividualClusterGroup(activeCourse.getId(), userId)).thenReturn(true); + mockMvc.perform(MockMvcRequestBuilders.delete(url)) + .andExpect(status().isOk()); + verify(courseUserRepository, times(1)).deleteById(argThat( + id -> id.getCourseId() == activeCourse.getId() && id.getUserId() == userId + )); + verify(commonDatabaseActions, times(0)).removeIndividualClusterGroup(activeCourse.getId(), userId); + + /* If user enrolled, removeIndividualClusterGroup gets called */ + when(courseUtil.canDeleteUser(activeCourse.getId(), userId, getMockUser())).thenReturn(new CheckResult<>(HttpStatus.OK, "", CourseRelation.enrolled)); + when(commonDatabaseActions.removeIndividualClusterGroup(activeCourse.getId(), userId)).thenReturn(true); + reset(courseUserRepository); + mockMvc.perform(MockMvcRequestBuilders.delete(url)) .andExpect(status().isOk()); + verify(courseUserRepository, times(1)).deleteById(argThat( + id -> id.getCourseId() == activeCourse.getId() && id.getUserId() == userId + )); + verify(commonDatabaseActions, times(1)).removeIndividualClusterGroup(activeCourse.getId(), userId); - when(commonDatabaseActions.removeIndividualClusterGroup(anyLong(), anyLong())).thenReturn(false); - mockMvc.perform(MockMvcRequestBuilders.delete(ApiRoutes.COURSE_BASE_PATH + "/1/members/2") - .contentType(MediaType.APPLICATION_JSON) - .content(userIdJson)) + /* If something goes wrong when removing individual group, return 500 */ + when(commonDatabaseActions.removeIndividualClusterGroup(activeCourse.getId(), userId)).thenReturn(false); + mockMvc.perform(MockMvcRequestBuilders.delete(url)) .andExpect(status().isInternalServerError()); + /* If user can't delete the other use, return corresponding status*/ when(courseUtil.canDeleteUser(anyLong(), anyLong(), any())).thenReturn(new CheckResult<>(HttpStatus.I_AM_A_TEAPOT, "", null)); - mockMvc.perform(MockMvcRequestBuilders.delete(ApiRoutes.COURSE_BASE_PATH + "/1/members/2") - .contentType(MediaType.APPLICATION_JSON) - .content(userIdJson)) + mockMvc.perform(MockMvcRequestBuilders.delete(url)) .andExpect(status().isIAmATeapot()); } @Test public void testAddCourseMember() throws Exception { - String request = "{\"userId\": 1, \"relation\": \"enrolled\"}"; - when(courseUtil.canUpdateUserInCourse(anyLong(), any(), any(), any())). - thenReturn(new CheckResult<>(HttpStatus.OK, "", new CourseUserEntity(1, 1, CourseRelation.enrolled))); - when(commonDatabaseActions.createNewIndividualClusterGroup(anyLong(), any())) - .thenReturn(true); - when(userUtil.getUserIfExists(anyLong())).thenReturn(new UserEntity("name", "surname", "email", UserRole.teacher, "id")); - mockMvc.perform(MockMvcRequestBuilders.post(ApiRoutes.COURSE_BASE_PATH + "/1/members") + String requestString = "{\"userId\": 1, \"relation\": \"enrolled\"}"; + String requestStringAdmin = "{\"userId\": 1, \"relation\": \"course_admin\"}"; + String url = ApiRoutes.COURSE_BASE_PATH + "/" + activeCourse.getId() + "/members"; + CourseUserEntity courseUser = new CourseUserEntity(activeCourse.getId(), 1, CourseRelation.enrolled); + UserEntity user = new UserEntity("name", "surname", "email", UserRole.teacher, "id"); + /* If all checks succeed, return 201 */ + + when(courseUtil.canUpdateUserInCourse( + eq(activeCourse.getId()), + argThat( + request -> request.getUserId() == 1 && request.getRelationAsEnum().equals(CourseRelation.course_admin) + ), + eq(getMockUser()), + eq(HttpMethod.POST))).thenReturn(new CheckResult<>(HttpStatus.OK, "", courseUser)); + mockMvc.perform(MockMvcRequestBuilders.post(url) + .contentType(MediaType.APPLICATION_JSON) + .content(requestStringAdmin)) + .andExpect(status().isCreated()); + /* If user is not enrolled, there is no attempt to create individual cluster group */ + verify(userUtil, times(0)).getUserIfExists(anyLong()); + verify(courseUserRepository, times(1)).save(argThat( + courseUserEntity -> courseUserEntity.getCourseId() == activeCourse.getId() && + courseUserEntity.getUserId() == 1 && + courseUserEntity.getRelation().equals(CourseRelation.course_admin) + )); + verify(commonDatabaseActions, times(0)).createNewIndividualClusterGroup(anyLong(), any()); + + reset(courseUtil); + when(courseUtil.canUpdateUserInCourse( + eq(activeCourse.getId()), + argThat( + request -> request.getUserId() == 1 && request.getRelationAsEnum().equals(CourseRelation.enrolled) + ), + eq(getMockUser()), + eq(HttpMethod.POST))) + .thenReturn(new CheckResult<>(HttpStatus.OK, "", courseUser)); + when(userUtil.getUserIfExists(anyLong())).thenReturn(user); + when(commonDatabaseActions.createNewIndividualClusterGroup(activeCourse.getId(), user)).thenReturn(true); + mockMvc.perform(MockMvcRequestBuilders.post(url) .contentType(MediaType.APPLICATION_JSON) - .content(request)) + .content(requestString)) .andExpect(status().isCreated()); + verify(courseUserRepository, times(1)).save(argThat( + courseUserEntity -> courseUserEntity.getCourseId() == activeCourse.getId() && + courseUserEntity.getUserId() == 1 && + courseUserEntity.getRelation().equals(CourseRelation.enrolled) + )); + /* If something goes wrong when creating individual cluster, return 500 */ when(commonDatabaseActions.createNewIndividualClusterGroup(anyLong(), any())) .thenReturn(false); - mockMvc.perform(MockMvcRequestBuilders.post(ApiRoutes.COURSE_BASE_PATH + "/1/members") + mockMvc.perform(MockMvcRequestBuilders.post(url) .contentType(MediaType.APPLICATION_JSON) - .content(request)) + .content(requestString)) .andExpect(status().isInternalServerError()); - when(courseUtil.canUpdateUserInCourse(anyLong(), any(), any(), any())).thenReturn(new CheckResult<>(HttpStatus.I_AM_A_TEAPOT, "", null)); - mockMvc.perform(MockMvcRequestBuilders.post(ApiRoutes.COURSE_BASE_PATH + "/1/members") + /* If user isn't found, return 404 */ + when(userUtil.getUserIfExists(anyLong())).thenReturn(null); + mockMvc.perform(MockMvcRequestBuilders.post(url) + .contentType(MediaType.APPLICATION_JSON) + .content(requestString)) + .andExpect(status().isNotFound()); + + /* If user can't be added to the course, return corresponding status */ + reset(courseUtil); + when(courseUtil.canUpdateUserInCourse( + anyLong(), any(), any(), any())) + .thenReturn(new CheckResult<>(HttpStatus.I_AM_A_TEAPOT, "", null)); + mockMvc.perform(MockMvcRequestBuilders.post(url) .contentType(MediaType.APPLICATION_JSON) - .content(request)) + .content(requestString)) .andExpect(status().isIAmATeapot()); } @Test public void testUpdateCourseMember() throws Exception { - String request = "{\"userId\": 1, \"relation\": \"enrolled\"}"; - when(courseUtil.canUpdateUserInCourse(anyLong(), any(), any(), any())). - thenReturn(new CheckResult<>(HttpStatus.OK, "", new CourseUserEntity(1, 1, CourseRelation.course_admin))); - when(userUtil.getUserIfExists(anyLong())).thenReturn(new UserEntity("name", "surname", "email", UserRole.teacher, "id")); - when(commonDatabaseActions.removeIndividualClusterGroup(anyLong(), anyLong())).thenReturn(true); - mockMvc.perform(MockMvcRequestBuilders.patch(ApiRoutes.COURSE_BASE_PATH + "/1/members/2") - .contentType(MediaType.APPLICATION_JSON) - .content(request)) - .andExpect(status().isOk()); + long userId = 2L; + String url = ApiRoutes.COURSE_BASE_PATH + "/" + activeCourse.getId() + "/members/" + userId; + String request = "{\"relation\": \"enrolled\"}"; + String adminRequest = "{\"relation\": \"course_admin\"}"; + UserEntity user = new UserEntity("name", "surname", "email", UserRole.teacher, "id"); + CourseUserEntity enrolledUser = new CourseUserEntity(activeCourse.getId(), userId, CourseRelation.enrolled); + CourseUserEntity adminUser = new CourseUserEntity(activeCourse.getId(), userId, CourseRelation.course_admin); + /* If all checks succeed, 200 gets returned */ + /* If the new role is the same as the old, no changes to individualgroupClusters are done */ + when(courseUtil.canUpdateUserInCourse( + eq(activeCourse.getId()), + argThat( + requestJson -> requestJson.getRelationAsEnum().equals(CourseRelation.enrolled) + ), + eq(getMockUser()), + eq(HttpMethod.PATCH))).thenReturn(new CheckResult<>(HttpStatus.OK, "", enrolledUser)); + mockMvc.perform(MockMvcRequestBuilders.patch(url) + .contentType(MediaType.APPLICATION_JSON) + .content(request)) + .andExpect(status().isOk()); + verify(commonDatabaseActions, times(0)).removeIndividualClusterGroup(anyLong(), anyLong()); + verify(commonDatabaseActions, times(0)).createNewIndividualClusterGroup(anyLong(), any()); + verify(courseUserRepository, times(0)).save(any()); + + /* If the new role is enrolled, individual clustergroup should be created */ + reset(courseUtil); + when(courseUtil.canUpdateUserInCourse( + eq(activeCourse.getId()), + argThat( + requestJson -> requestJson.getRelationAsEnum().equals(CourseRelation.enrolled) + ), + eq(getMockUser()), + eq(HttpMethod.PATCH))).thenReturn(new CheckResult<>(HttpStatus.OK, "", adminUser)); + when(userUtil.getUserIfExists(userId)).thenReturn(user); + when(commonDatabaseActions.createNewIndividualClusterGroup(activeCourse.getId(), user)).thenReturn(true); + mockMvc.perform(MockMvcRequestBuilders.patch(url) + .contentType(MediaType.APPLICATION_JSON) + .content(request)) + .andExpect(status().isOk()); + verify(commonDatabaseActions, times(1)).createNewIndividualClusterGroup(activeCourse.getId(), user); + assertEquals(CourseRelation.enrolled, adminUser.getRelation()); + verify(courseUserRepository, times(1)).save(adminUser); + adminUser.setRelation(CourseRelation.course_admin); + /* If something goes wrong when creating individual cluster, return 500 */ + reset(commonDatabaseActions); + when(commonDatabaseActions.createNewIndividualClusterGroup(anyLong(), any())) + .thenReturn(false); + mockMvc.perform(MockMvcRequestBuilders.patch(url) + .contentType(MediaType.APPLICATION_JSON) + .content(request)) + .andExpect(status().isInternalServerError()); - when(courseUtil.canUpdateUserInCourse(anyLong(), any(), any(), any())). - thenReturn(new CheckResult<>(HttpStatus.OK, "", new CourseUserEntity(1, 3, CourseRelation.enrolled))); - when(commonDatabaseActions.removeIndividualClusterGroup(anyLong(), anyLong())).thenReturn(false); - request = "{\"relation\": \"course_admin\"}"; - mockMvc.perform(MockMvcRequestBuilders.patch(ApiRoutes.COURSE_BASE_PATH + "/1/members/2") - .contentType(MediaType.APPLICATION_JSON) - .content(request)) + /* If the user doesn't get found when trying to create individualadmin group should return 404 */ + when(userUtil.getUserIfExists(anyLong())).thenReturn(null); + mockMvc.perform(MockMvcRequestBuilders.patch(url) + .contentType(MediaType.APPLICATION_JSON) + .content(request)) + .andExpect(status().isNotFound()); + + /* If the new role is course_admin, individual clustergroup should be deleted */ + reset(commonDatabaseActions); + reset(courseUtil); + when(courseUtil.canUpdateUserInCourse( + eq(activeCourse.getId()), + argThat( + requestJson -> requestJson.getRelationAsEnum().equals(CourseRelation.course_admin) + ), + eq(getMockUser()), + eq(HttpMethod.PATCH))).thenReturn(new CheckResult<>(HttpStatus.OK, "", enrolledUser)); + when(commonDatabaseActions.removeIndividualClusterGroup(activeCourse.getId(), userId)).thenReturn(true); + mockMvc.perform(MockMvcRequestBuilders.patch(url) + .contentType(MediaType.APPLICATION_JSON) + .content(adminRequest)) + .andExpect(status().isOk()); + verify(commonDatabaseActions, times(1)).removeIndividualClusterGroup(activeCourse.getId(), userId); + assertEquals(CourseRelation.course_admin, enrolledUser.getRelation()); + verify(courseUserRepository, times(1)).save(enrolledUser); + enrolledUser.setRelation(CourseRelation.enrolled); + + /* If something goes wrong when deleting individual cluster, return 500 */ + reset(commonDatabaseActions); + when(commonDatabaseActions.removeIndividualClusterGroup(anyLong(), anyLong())) + .thenReturn(false); + mockMvc.perform(MockMvcRequestBuilders.patch(url) + .contentType(MediaType.APPLICATION_JSON) + .content(adminRequest)) .andExpect(status().isInternalServerError()); - when(courseUtil.canUpdateUserInCourse(anyLong(), any(), any(), any())).thenReturn(new CheckResult<>(HttpStatus.BAD_REQUEST, "", null)); - mockMvc.perform(MockMvcRequestBuilders.patch(ApiRoutes.COURSE_BASE_PATH + "/1/members/2") - .contentType(MediaType.APPLICATION_JSON) - .content(request)) - .andExpect(status().isBadRequest()); + /* If user can't be updated, return corresponding status */ + reset(courseUtil); + when(courseUtil.canUpdateUserInCourse(anyLong(), any(), any(), any())).thenReturn(new CheckResult<>(HttpStatus.I_AM_A_TEAPOT, "", null)); + mockMvc.perform(MockMvcRequestBuilders.patch(url) + .contentType(MediaType.APPLICATION_JSON) + .content(request)) + .andExpect(status().isIAmATeapot()); + } @Test public void testGetCourseMembers() throws Exception { - CourseEntity course = new CourseEntity("name", "descripton",2024); - course.setId(1); - when(courseUtil.getCourseIfUserInCourse(anyLong(), any())). - thenReturn(new CheckResult<>(HttpStatus.OK, "", new Pair<>(course, CourseRelation.course_admin))); - when(courseUserRepository.findAllMembers(anyLong())).thenReturn( - List.of(new CourseUserEntity(1, 2, CourseRelation.creator)) + CourseUserEntity courseUserEntity = new CourseUserEntity(1L, 1L, CourseRelation.enrolled); + UserEntity user = new UserEntity("name", "surname", "email", UserRole.teacher, "id"); + UserReferenceWithRelation userJson = new UserReferenceWithRelation( + new UserReferenceJson("name", "surname", 1L), + ""+CourseRelation.enrolled ); - when(userUtil.getUserIfExists(anyLong())).thenReturn(new UserEntity("name", "surname", "email", UserRole.teacher, "id")); - mockMvc.perform(MockMvcRequestBuilders.get(ApiRoutes.COURSE_BASE_PATH + "/1/members")) - .andExpect(status().isOk()); + List userList = List.of(courseUserEntity); + String url = ApiRoutes.COURSE_BASE_PATH + "/" + activeCourse.getId() + "/members"; + /* If user is in course, return members */ + when(courseUtil.getCourseIfUserInCourse(activeCourseJson.courseId(), getMockUser())) + .thenReturn(new CheckResult<>(HttpStatus.OK, "", new Pair<>(activeCourse, CourseRelation.course_admin))); + when(courseUserRepository.findAllMembers(activeCourseJson.courseId())).thenReturn(userList); + when(userUtil.getUserIfExists(courseUserEntity.getUserId())).thenReturn(user); + when(entityToJsonConverter.userEntityToUserReferenceWithRelation(user, CourseRelation.enrolled)).thenReturn(userJson); + mockMvc.perform(MockMvcRequestBuilders.get(url)) + .andExpect(status().isOk()) + .andExpect(content().contentType(MediaType.APPLICATION_JSON)) + .andExpect(content().json(objectMapper.writeValueAsString(List.of(userJson)))); + + /* If user doesn't get found it gets filtered out */ + when(userUtil.getUserIfExists(anyLong())).thenReturn(null); + mockMvc.perform(MockMvcRequestBuilders.get(url)) + .andExpect(status().isOk()) + .andExpect(content().json("[]")); + + + /* If user is not in course, or course not found or ... return corresponding status */ + when(courseUtil.getCourseIfUserInCourse(anyLong(), any())).thenReturn(new CheckResult<>(HttpStatus.I_AM_A_TEAPOT, "", null)); + mockMvc.perform(MockMvcRequestBuilders.get(url)) + .andExpect(status().isIAmATeapot()); - when(courseUtil.getCourseIfUserInCourse(anyLong(), any())).thenReturn(new CheckResult<>(HttpStatus.FORBIDDEN, "", null)); - mockMvc.perform(MockMvcRequestBuilders.get(ApiRoutes.COURSE_BASE_PATH + "/1/members")) - .andExpect(status().isForbidden()); } @Test public void testGetCourseKey() throws Exception { - CourseEntity course = new CourseEntity("name", "descripton",2024); - course.setId(1); - when(courseUtil.getCourseIfAdmin(anyLong(), any())).thenReturn(new CheckResult<>(HttpStatus.OK, "", course)); - mockMvc.perform(MockMvcRequestBuilders.get(ApiRoutes.COURSE_BASE_PATH + "/1/joinKey")) - .andExpect(status().isOk()); - + String url = ApiRoutes.COURSE_BASE_PATH + "/" + activeCourse.getId() + "/joinKey"; + /* If user is admin and course exists, returns joinKey */ + activeCourse.setJoinKey("1908"); + when(courseUtil.getCourseIfAdmin(activeCourse.getId(), getMockUser())).thenReturn(new CheckResult<>(HttpStatus.OK, "", activeCourse)); + mockMvc.perform(MockMvcRequestBuilders.get(url)) + .andExpect(status().isOk()) + .andExpect(content().string("1908")); + activeCourse.setJoinKey(null); + mockMvc.perform(MockMvcRequestBuilders.get(url)) + .andExpect(status().isOk()) + .andExpect(content().string("")); + + /* If any check fails, return corresponding status */ when(courseUtil.getCourseIfAdmin(anyLong(), any())).thenReturn(new CheckResult<>(HttpStatus.I_AM_A_TEAPOT, "", null)); mockMvc.perform(MockMvcRequestBuilders.get(ApiRoutes.COURSE_BASE_PATH + "/1/joinKey")) .andExpect(status().isIAmATeapot()); @@ -488,27 +1058,96 @@ public void testGetCourseKey() throws Exception { @Test public void testGetAndCreateCourseKey() throws Exception { - CourseEntity course = new CourseEntity("name", "descripton",2024); - course.setId(1); - when(courseUtil.getCourseIfAdmin(anyLong(), any())).thenReturn(new CheckResult<>(HttpStatus.OK, "", course)); - mockMvc.perform(MockMvcRequestBuilders.put(ApiRoutes.COURSE_BASE_PATH + "/1/joinKey")) - .andExpect(status().isOk()); + String url = ApiRoutes.COURSE_BASE_PATH + "/" + activeCourse.getId() + "/joinKey"; + /* If user is admin and course exists, update and returns joinKey */ + activeCourse.setJoinKey("1908"); + when(courseUtil.getCourseIfAdmin(activeCourse.getId(), getMockUser())).thenReturn( + new CheckResult<>(HttpStatus.OK, "", activeCourse)); + mockMvc.perform(MockMvcRequestBuilders.put(url)) + .andExpect(status().isOk()) + .andExpect(content().string(not(equalTo("")))) + .andExpect(content().string(not(equalTo("1908")))); + assertNotEquals("1908", activeCourse.getJoinKey()); + verify(courseRepository, times(1)).save(activeCourse); + + /* If any check fails, return corresponding status */ + when(courseUtil.getCourseIfAdmin(anyLong(), any())).thenReturn( + new CheckResult<>(HttpStatus.I_AM_A_TEAPOT, "", null)); + mockMvc.perform(MockMvcRequestBuilders.put(url)) + .andExpect(status().isIAmATeapot()); - when(courseUtil.getCourseIfAdmin(anyLong(), any())).thenReturn(new CheckResult<>(HttpStatus.I_AM_A_TEAPOT, "", null)); - mockMvc.perform(MockMvcRequestBuilders.put(ApiRoutes.COURSE_BASE_PATH + "/1/joinKey")) - .andExpect(status().isIAmATeapot()); } @Test public void testDeleteCourseKey() throws Exception { - CourseEntity course = new CourseEntity("name", "descripton",2024); - course.setId(1); - when(courseUtil.getCourseIfAdmin(anyLong(), any())).thenReturn(new CheckResult<>(HttpStatus.OK, "", course)); - mockMvc.perform(MockMvcRequestBuilders.delete(ApiRoutes.COURSE_BASE_PATH + "/1/joinKey")) - .andExpect(status().isOk()); + String url = ApiRoutes.COURSE_BASE_PATH + "/" + activeCourse.getId() + "/joinKey"; + /* If user is admin and course exists, update and returns joinKey */ + activeCourse.setJoinKey("1908"); + when(courseUtil.getCourseIfAdmin(activeCourse.getId(), getMockUser())).thenReturn( + new CheckResult<>(HttpStatus.OK, "", activeCourse)); + mockMvc.perform(MockMvcRequestBuilders.delete(url)) + .andExpect(status().isOk()) + .andExpect(content().string("")); + assertNull(activeCourse.getJoinKey()); + verify(courseRepository, times(1)).save(activeCourse); + + /* If any check fails, return corresponding status */ + when(courseUtil.getCourseIfAdmin(anyLong(), any())).thenReturn( + new CheckResult<>(HttpStatus.I_AM_A_TEAPOT, "", null)); + mockMvc.perform(MockMvcRequestBuilders.delete(url)) + .andExpect(status().isIAmATeapot()); + } + + @Test + public void testCopyCourse() throws Exception { + String url = ApiRoutes.COURSE_BASE_PATH + "/" + activeCourse.getId() + "/copy"; + CourseEntity copiedCourse = new CourseEntity("name", "description", 2024); + CourseWithInfoJson copiedCourseJson = new CourseWithInfoJson( + 2L, + "name", + "description", + new UserReferenceJson("", "", 0L), + new ArrayList<>(), + "", + "", + "", + OffsetDateTime.now(), + OffsetDateTime.now(), + 2024 + ); + /* If user is creator, can copy course */ + when(courseUtil.getCourseIfUserInCourse(activeCourse.getId(), getMockUser())) + .thenReturn(new CheckResult<>(HttpStatus.OK, "", new Pair<>(activeCourse, CourseRelation.creator))); + when(commonDatabaseActions.copyCourse(activeCourse, getMockUser().getId())).thenReturn(new CheckResult<>(HttpStatus.OK, "", copiedCourse)); + when(courseUtil.getJoinLink(copiedCourse.getJoinKey(), ""+copiedCourse.getId())).thenReturn(""); + when(entityToJsonConverter.courseEntityToCourseWithInfo(copiedCourse, "", false)).thenReturn(copiedCourseJson); + mockMvc.perform(MockMvcRequestBuilders.post(url)) + .andExpect(status().isCreated()) + .andExpect(content().contentType(MediaType.APPLICATION_JSON)) + .andExpect(content().json(objectMapper.writeValueAsString(copiedCourseJson))); + + /* If something goes wrong when copying course, return corresponding status */ + when(commonDatabaseActions.copyCourse(activeCourse, getMockUser().getId())).thenReturn(new CheckResult<>(HttpStatus.I_AM_A_TEAPOT, "", null)); + mockMvc.perform(MockMvcRequestBuilders.post(url)) + .andExpect(status().isIAmATeapot()); + + /* If user isn't the creator, return 403 */ + when(courseUtil.getCourseIfUserInCourse(activeCourse.getId(), getMockUser())) + .thenReturn(new CheckResult<>(HttpStatus.OK, "", new Pair<>(activeCourse, CourseRelation.course_admin))); + mockMvc.perform(MockMvcRequestBuilders.post(url)) + .andExpect(status().isForbidden()); + + /* If user isn't in course, or course not found return corresponding status code */ + when(courseUtil.getCourseIfUserInCourse(activeCourse.getId(), getMockUser())) + .thenReturn(new CheckResult<>(HttpStatus.NOT_FOUND, "", new Pair<>(null, null))); + mockMvc.perform(MockMvcRequestBuilders.post(url)) + .andExpect(status().isNotFound()); + + /* If an unexpected error occurs, return 500 */ + when(courseUtil.getCourseIfUserInCourse(activeCourse.getId(), getMockUser())).thenThrow(new RuntimeException()); + mockMvc.perform(MockMvcRequestBuilders.post(url)) + .andExpect(status().isInternalServerError()); - when(courseUtil.getCourseIfAdmin(anyLong(), any())).thenReturn(new CheckResult<>(HttpStatus.I_AM_A_TEAPOT, "", null)); - mockMvc.perform(MockMvcRequestBuilders.delete(ApiRoutes.COURSE_BASE_PATH + "/1/joinKey")) - .andExpect(status().isIAmATeapot()); } + } \ No newline at end of file diff --git a/backend/app/src/test/java/com/ugent/pidgeon/controllers/GroupControllerTest.java b/backend/app/src/test/java/com/ugent/pidgeon/controllers/GroupControllerTest.java index 9412904f..798e0d89 100644 --- a/backend/app/src/test/java/com/ugent/pidgeon/controllers/GroupControllerTest.java +++ b/backend/app/src/test/java/com/ugent/pidgeon/controllers/GroupControllerTest.java @@ -1,5 +1,8 @@ package com.ugent.pidgeon.controllers; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.ugent.pidgeon.CustomObjectMapper; +import com.ugent.pidgeon.model.json.GroupJson; import com.ugent.pidgeon.postgre.models.GroupEntity; import com.ugent.pidgeon.postgre.repository.GroupClusterRepository; import com.ugent.pidgeon.postgre.repository.GroupRepository; @@ -23,6 +26,7 @@ import java.util.Optional; +import static org.junit.jupiter.api.Assertions.assertEquals; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.anyLong; import static org.mockito.Mockito.when; @@ -39,67 +43,87 @@ public class GroupControllerTest extends ControllerTest { @Mock private CommonDatabaseActions commonDatabaseActions; + private ObjectMapper objectMapper = CustomObjectMapper.createObjectMapper(); + @InjectMocks private GroupController groupController; private GroupEntity groupEntity; + private GroupJson groupJson; + private Integer capacity = 40; @BeforeEach public void setup() { - mockMvc = MockMvcBuilders.standaloneSetup(groupController) - .defaultRequest(MockMvcRequestBuilders.get("/**") - .with(request -> { request.setUserPrincipal(SecurityContextHolder.getContext().getAuthentication()); return request; })) - .build(); + setUpController(groupController); groupEntity = new GroupEntity("Group test", 1L); + groupEntity.setId(5L); + groupJson = new GroupJson( + capacity, + groupEntity.getId(), + groupEntity.getName(), + "" + ); } @Test public void testGetGroupById() throws Exception { - when(groupUtil.getGroupIfExists(anyLong())) + String url = ApiRoutes.GROUP_BASE_PATH + "/" + groupEntity.getId(); + /* If group exists and users has acces, return groupJson */ + when(groupUtil.getGroupIfExists(groupEntity.getId())) .thenReturn(new CheckResult<>(HttpStatus.OK, "", groupEntity)); - when(groupUtil.canGetGroup(anyLong(), any())) + when(groupUtil.canGetGroup(groupEntity.getId(), getMockUser())) .thenReturn(new CheckResult<>(HttpStatus.OK, "", null)); - mockMvc.perform(MockMvcRequestBuilders.get(ApiRoutes.GROUP_BASE_PATH + "/1")) - .andExpect(status().isOk()); + when(entityToJsonConverter.groupEntityToJson(groupEntity)).thenReturn(groupJson); + mockMvc.perform(MockMvcRequestBuilders.get(url)) + .andExpect(status().isOk()) + .andExpect(content().contentType(MediaType.APPLICATION_JSON)) + .andExpect(content().string(objectMapper.writeValueAsString(groupJson))); + /* If the user doesn't have acces to group, return forbidden */ when(groupUtil.canGetGroup(anyLong(), any())) .thenReturn(new CheckResult<>(HttpStatus.BAD_REQUEST, "", null)); - mockMvc.perform(MockMvcRequestBuilders.get(ApiRoutes.GROUP_BASE_PATH + "/1")) + mockMvc.perform(MockMvcRequestBuilders.get(url)) .andExpect(status().isBadRequest()); + /* If group doesn't exist, return not found */ when(groupUtil.getGroupIfExists(anyLong())) .thenReturn(new CheckResult<>(HttpStatus.I_AM_A_TEAPOT, "", null)); - mockMvc.perform(MockMvcRequestBuilders.get(ApiRoutes.GROUP_BASE_PATH + "/1")) + mockMvc.perform(MockMvcRequestBuilders.get(url)) .andExpect(status().isIAmATeapot()); } //this function also fully tests doGroupNameUpdate @Test public void testUpdateGroupName() throws Exception { + String url = ApiRoutes.GROUP_BASE_PATH + "/" + groupEntity.getId(); + /* If all checks pass, update and return groupJson */ String request = "{\"name\":\"Test Group\"}\n"; - when(groupUtil.canUpdateGroup(anyLong(), any())) + when(groupUtil.canUpdateGroup(groupEntity.getId(), getMockUser())) .thenReturn(new CheckResult<>(HttpStatus.OK, "", groupEntity)); - mockMvc.perform(MockMvcRequestBuilders.put(ApiRoutes.GROUP_BASE_PATH + "/1") + mockMvc.perform(MockMvcRequestBuilders.put(url) .contentType(MediaType.APPLICATION_JSON) .content(request)) .andExpect(status().isOk()); + assertEquals(groupEntity.getName(), "Test Group"); + /* If user can't update group, return corresponding status */ when(groupUtil.canUpdateGroup(anyLong(), any())) .thenReturn(new CheckResult<>(HttpStatus.I_AM_A_TEAPOT, "", null)); - mockMvc.perform(MockMvcRequestBuilders.put(ApiRoutes.GROUP_BASE_PATH + "/1") + mockMvc.perform(MockMvcRequestBuilders.put(url) .contentType(MediaType.APPLICATION_JSON) .content(request)) .andExpect(status().isIAmATeapot()); + /* If name isn't provided, return bad request */ request = "{\"name\":\"\"}\n"; - mockMvc.perform(MockMvcRequestBuilders.put(ApiRoutes.GROUP_BASE_PATH + "/1") + mockMvc.perform(MockMvcRequestBuilders.put(url) .contentType(MediaType.APPLICATION_JSON) .content(request)) .andExpect(status().isBadRequest()); request = "{\"name\":null}\n"; - mockMvc.perform(MockMvcRequestBuilders.put(ApiRoutes.GROUP_BASE_PATH + "/1") + mockMvc.perform(MockMvcRequestBuilders.put(url) .contentType(MediaType.APPLICATION_JSON) .content(request)) .andExpect(status().isBadRequest()); @@ -107,26 +131,59 @@ public void testUpdateGroupName() throws Exception { @Test public void testPatchGroupName() throws Exception { + String url = ApiRoutes.GROUP_BASE_PATH + "/" + groupEntity.getId(); + /* If all checks pass, update and return groupJson */ String request = "{\"name\":\"Test Group\"}\n"; - when(groupUtil.canUpdateGroup(anyLong(), any())) + when(groupUtil.canUpdateGroup(groupEntity.getId(), getMockUser())) .thenReturn(new CheckResult<>(HttpStatus.OK, "", groupEntity)); - mockMvc.perform(MockMvcRequestBuilders.patch(ApiRoutes.GROUP_BASE_PATH + "/1") + mockMvc.perform(MockMvcRequestBuilders.patch(url) .contentType(MediaType.APPLICATION_JSON) .content(request)) .andExpect(status().isOk()); + assertEquals(groupEntity.getName(), "Test Group"); + + /* If user can't update group, return corresponding status */ + when(groupUtil.canUpdateGroup(anyLong(), any())) + .thenReturn(new CheckResult<>(HttpStatus.I_AM_A_TEAPOT, "", null)); + mockMvc.perform(MockMvcRequestBuilders.patch(url) + .contentType(MediaType.APPLICATION_JSON) + .content(request)) + .andExpect(status().isIAmATeapot()); + + /* If name isn't provided, return bad request */ + request = "{\"name\":\"\"}\n"; + mockMvc.perform(MockMvcRequestBuilders.patch(url) + .contentType(MediaType.APPLICATION_JSON) + .content(request)) + .andExpect(status().isBadRequest()); + + request = "{\"name\":null}\n"; + mockMvc.perform(MockMvcRequestBuilders.patch(url) + .contentType(MediaType.APPLICATION_JSON) + .content(request)) + .andExpect(status().isBadRequest()); } @Test public void testDeleteGroup() throws Exception { - when(groupUtil.canUpdateGroup(anyLong(), any())) + String url = ApiRoutes.GROUP_BASE_PATH + "/" + groupEntity.getId(); + /* If all checks pass, delete and return groupJson */ + when(groupUtil.canUpdateGroup(groupEntity.getId(), getMockUser())) .thenReturn(new CheckResult<>(HttpStatus.OK, "", groupEntity)); - mockMvc.perform(MockMvcRequestBuilders.delete(ApiRoutes.GROUP_BASE_PATH + "/1")) + when(commonDatabaseActions.removeGroup(groupEntity.getId())).thenReturn(true); + mockMvc.perform(MockMvcRequestBuilders.delete(url)) .andExpect(status().isNoContent()); + /* If something goes wrong while deleting, return internal server error */ + when(commonDatabaseActions.removeGroup(groupEntity.getId())).thenReturn(false); + mockMvc.perform(MockMvcRequestBuilders.delete(url)) + .andExpect(status().isInternalServerError()); + + /* If user can't update group, return corresponding status */ when(groupUtil.canUpdateGroup(anyLong(), any())) - .thenReturn(new CheckResult<>(HttpStatus.BAD_REQUEST, "", null)); - mockMvc.perform(MockMvcRequestBuilders.delete(ApiRoutes.GROUP_BASE_PATH + "/1")) - .andExpect(status().isBadRequest()); + .thenReturn(new CheckResult<>(HttpStatus.I_AM_A_TEAPOT, "", null)); + mockMvc.perform(MockMvcRequestBuilders.delete(url)) + .andExpect(status().isIAmATeapot()); } } diff --git a/backend/app/src/test/java/com/ugent/pidgeon/controllers/GroupFeedbackControllerTest.java b/backend/app/src/test/java/com/ugent/pidgeon/controllers/GroupFeedbackControllerTest.java index a4295874..df28a51d 100644 --- a/backend/app/src/test/java/com/ugent/pidgeon/controllers/GroupFeedbackControllerTest.java +++ b/backend/app/src/test/java/com/ugent/pidgeon/controllers/GroupFeedbackControllerTest.java @@ -1,23 +1,46 @@ package com.ugent.pidgeon.controllers; +import static org.junit.jupiter.api.Assertions.assertEquals; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.ArgumentMatchers.argThat; +import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.reset; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.ugent.pidgeon.CustomObjectMapper; +import com.ugent.pidgeon.model.json.GroupFeedbackJson; +import com.ugent.pidgeon.model.json.GroupFeedbackJsonWithProject; +import com.ugent.pidgeon.postgre.models.CourseEntity; import com.ugent.pidgeon.postgre.models.GroupFeedbackEntity; +import com.ugent.pidgeon.postgre.models.ProjectEntity; +import com.ugent.pidgeon.postgre.models.types.CourseRelation; import com.ugent.pidgeon.postgre.repository.GroupFeedbackRepository; +import com.ugent.pidgeon.postgre.repository.GroupRepository; +import com.ugent.pidgeon.postgre.repository.ProjectRepository; import com.ugent.pidgeon.util.CheckResult; +import com.ugent.pidgeon.util.CourseUtil; import com.ugent.pidgeon.util.EntityToJsonConverter; import com.ugent.pidgeon.util.GroupFeedbackUtil; import com.ugent.pidgeon.util.GroupUtil; +import com.ugent.pidgeon.util.Pair; +import java.time.OffsetDateTime; +import java.util.List; +import java.util.Objects; +import java.util.logging.Logger; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.InjectMocks; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.http.HttpMethod; import org.springframework.http.HttpStatus; import org.springframework.http.MediaType; import org.springframework.security.core.context.SecurityContextHolder; @@ -30,148 +53,273 @@ public class GroupFeedbackControllerTest extends ControllerTest { @Mock private GroupFeedbackRepository groupFeedbackRepository; @Mock + private ProjectRepository projectRepository; + @Mock private GroupFeedbackUtil groupFeedbackUtil; @Mock + private GroupRepository groupRepository; + @Mock private GroupUtil groupUtil; @Mock + private CourseUtil courseUtil; + @Mock private EntityToJsonConverter entityToJsonConverter; @InjectMocks private GroupFeedbackController groupFeedbackController; private GroupFeedbackEntity groupFeedbackEntity; + private GroupFeedbackJson groupFeedbackJson; + + private ObjectMapper objectMapper = CustomObjectMapper.createObjectMapper(); @BeforeEach public void setup() { - mockMvc = MockMvcBuilders.standaloneSetup(groupFeedbackController) - .defaultRequest(MockMvcRequestBuilders.get("/**") - .with(request -> { - request.setUserPrincipal(SecurityContextHolder.getContext().getAuthentication()); - return request; - })) - .build(); - groupFeedbackEntity = new GroupFeedbackEntity(1L, 1L, 0F, "good job.... NOT!"); + setUpController(groupFeedbackController); + groupFeedbackEntity = new GroupFeedbackEntity(4L, 6L, 1F, "good job.... NOT!"); + groupFeedbackJson = new GroupFeedbackJson(groupFeedbackEntity.getScore(), groupFeedbackEntity.getFeedback(), + groupFeedbackEntity.getGroupId(), groupFeedbackEntity.getProjectId()); + } @Test public void testUpdateGroupScore() throws Exception { - String request = "{\"score\": null,\"feedback\": null}"; - when(groupFeedbackUtil.checkGroupFeedbackUpdate(anyLong(), anyLong(), any(), any())).thenReturn( - new CheckResult<>(HttpStatus.OK, "", groupFeedbackEntity)); - when(groupFeedbackUtil.checkGroupFeedbackUpdateJson(any(), anyLong())).thenReturn( - new CheckResult<>(HttpStatus.OK, "", null)); - mockMvc.perform(MockMvcRequestBuilders.patch( - ApiRoutes.GROUP_FEEDBACK_PATH.replace("{groupid}", "1").replace("{projectid}", "1")) + String url = ApiRoutes.GROUP_FEEDBACK_PATH.replace("{groupid}", ""+groupFeedbackEntity.getGroupId()) + .replace("{projectid}", ""+groupFeedbackEntity.getProjectId()); + String requestAllNull = "{\"score\": null,\"feedback\": null}"; + String requestScoreNull = "{\"score\": null,\"feedback\": \"Heel goed gedaan\"}"; + String requestFeedbackNull = "{\"score\": 4.4,\"feedback\": null}"; + String request = "{\"score\": 4.4,\"feedback\": \"Heel goed gedaan\"}"; + String originalFeedback = groupFeedbackEntity.getFeedback(); + Float orginalScore = groupFeedbackEntity.getScore(); + /* If all checks succeed, group feedback is updated succesfully */ + /* If fields are null, nothing is changed */ + when(groupFeedbackUtil.checkGroupFeedbackUpdate(groupFeedbackEntity.getGroupId(), groupFeedbackEntity.getProjectId(), getMockUser(), + HttpMethod.PATCH)).thenReturn(new CheckResult<>(HttpStatus.OK, "", groupFeedbackEntity)); + when(groupFeedbackUtil.checkGroupFeedbackUpdateJson(argThat( + json -> json.getScore() == groupFeedbackEntity.getScore() && json.getFeedback() + .equals(groupFeedbackEntity.getFeedback())), eq(groupFeedbackEntity.getProjectId()))) + .thenReturn(new CheckResult<>(HttpStatus.OK, "", null)); + when(entityToJsonConverter.groupFeedbackEntityToJson(groupFeedbackEntity)).thenReturn(groupFeedbackJson); + mockMvc.perform(MockMvcRequestBuilders.patch(url) + .contentType(MediaType.APPLICATION_JSON) + .content(requestAllNull)) + .andExpect(status().isOk()) + .andExpect(content().contentType(MediaType.APPLICATION_JSON)) + .andExpect(content().json(objectMapper.writeValueAsString(groupFeedbackJson))); + assertEquals(originalFeedback, groupFeedbackEntity.getFeedback()); + assertEquals(orginalScore, groupFeedbackEntity.getScore()); + verify(groupFeedbackRepository, times(1)).save(groupFeedbackEntity); + /* If score is null, only feedback is updated */ + reset(groupFeedbackUtil); + when(groupFeedbackUtil.checkGroupFeedbackUpdate(groupFeedbackEntity.getGroupId(), groupFeedbackEntity.getProjectId(), getMockUser(), + HttpMethod.PATCH)).thenReturn(new CheckResult<>(HttpStatus.OK, "", groupFeedbackEntity)); + when(groupFeedbackUtil.checkGroupFeedbackUpdateJson(argThat( + json -> json.getScore() == groupFeedbackEntity.getScore() && json.getFeedback().equals("Heel goed gedaan")), eq(groupFeedbackEntity.getProjectId()))) + .thenReturn(new CheckResult<>(HttpStatus.OK, "", null)); + groupFeedbackJson.setFeedback("Heel goed gedaan"); + mockMvc.perform(MockMvcRequestBuilders.patch(url) + .contentType(MediaType.APPLICATION_JSON) + .content(requestScoreNull)) + .andExpect(status().isOk()) + .andExpect(content().contentType(MediaType.APPLICATION_JSON)) + .andExpect(content().json(objectMapper.writeValueAsString(groupFeedbackJson))); + assertEquals("Heel goed gedaan", groupFeedbackEntity.getFeedback()); + assertEquals(orginalScore, groupFeedbackEntity.getScore()); + verify(groupFeedbackRepository, times(2)).save(groupFeedbackEntity); + groupFeedbackEntity.setFeedback(originalFeedback); + groupFeedbackJson.setFeedback(originalFeedback); + /* If feedback is null, only score is updated */ + reset(groupFeedbackUtil); + when(groupFeedbackUtil.checkGroupFeedbackUpdate(groupFeedbackEntity.getGroupId(), groupFeedbackEntity.getProjectId(), getMockUser(), + HttpMethod.PATCH)).thenReturn(new CheckResult<>(HttpStatus.OK, "", groupFeedbackEntity)); + when(groupFeedbackUtil.checkGroupFeedbackUpdateJson(argThat( + json -> json.getScore() == 4.4F && json.getFeedback().equals(groupFeedbackEntity.getFeedback())), eq(groupFeedbackEntity.getProjectId()))) + .thenReturn(new CheckResult<>(HttpStatus.OK, "", null)); + groupFeedbackJson.setScore(4.4F); + mockMvc.perform(MockMvcRequestBuilders.patch(url) + .contentType(MediaType.APPLICATION_JSON) + .content(requestFeedbackNull)) + .andExpect(status().isOk()) + .andExpect(content().contentType(MediaType.APPLICATION_JSON)) + .andExpect(content().json(objectMapper.writeValueAsString(groupFeedbackJson))); + assertEquals(originalFeedback, groupFeedbackEntity.getFeedback()); + assertEquals(4.4F, groupFeedbackEntity.getScore()); + verify(groupFeedbackRepository, times(3)).save(groupFeedbackEntity); + groupFeedbackEntity.setScore(orginalScore); + groupFeedbackJson.setScore(orginalScore); + /* If all fields are filled, both are updated */ + reset(groupFeedbackUtil); + when(groupFeedbackUtil.checkGroupFeedbackUpdate(groupFeedbackEntity.getGroupId(), groupFeedbackEntity.getProjectId(), getMockUser(), + HttpMethod.PATCH)).thenReturn(new CheckResult<>(HttpStatus.OK, "", groupFeedbackEntity)); + when(groupFeedbackUtil.checkGroupFeedbackUpdateJson(argThat( + json -> json.getScore() == 4.4F && json.getFeedback().equals("Heel goed gedaan")), eq(groupFeedbackEntity.getProjectId()))) + .thenReturn(new CheckResult<>(HttpStatus.OK, "", null)); + groupFeedbackJson.setFeedback("Heel goed gedaan"); + groupFeedbackJson.setScore(4.4F); + mockMvc.perform(MockMvcRequestBuilders.patch(url) .contentType(MediaType.APPLICATION_JSON) .content(request)) - .andExpect(status().isOk()); + .andExpect(status().isOk()) + .andExpect(content().contentType(MediaType.APPLICATION_JSON)) + .andExpect(content().json(objectMapper.writeValueAsString(groupFeedbackJson))); + assertEquals("Heel goed gedaan", groupFeedbackEntity.getFeedback()); + assertEquals(4.4F, groupFeedbackEntity.getScore()); + verify(groupFeedbackRepository, times(4)).save(groupFeedbackEntity); - when(groupFeedbackRepository.save(any())).thenThrow(new RuntimeException()); - mockMvc.perform(MockMvcRequestBuilders.patch( - ApiRoutes.GROUP_FEEDBACK_PATH.replace("{groupid}", "1").replace("{projectid}", "1")) + /* If an exception is thrown, return internal server error */ + doThrow(new RuntimeException()).when(groupFeedbackRepository).save(any()); + mockMvc.perform(MockMvcRequestBuilders.patch(url) .contentType(MediaType.APPLICATION_JSON) .content(request)) .andExpect(status().isInternalServerError()); + /* If json check fails, return corresponding status code */ + reset(groupFeedbackUtil); + when(groupFeedbackUtil.checkGroupFeedbackUpdate(groupFeedbackEntity.getGroupId(), groupFeedbackEntity.getProjectId(), getMockUser(), + HttpMethod.PATCH)).thenReturn(new CheckResult<>(HttpStatus.OK, "", groupFeedbackEntity)); when(groupFeedbackUtil.checkGroupFeedbackUpdateJson(any(), anyLong())).thenReturn( - new CheckResult<>(HttpStatus.I_AM_A_TEAPOT, "", null)); - mockMvc.perform(MockMvcRequestBuilders.patch( - ApiRoutes.GROUP_FEEDBACK_PATH.replace("{groupid}", "1").replace("{projectid}", "1")) + new CheckResult<>(HttpStatus.BAD_REQUEST, "", null)); + mockMvc.perform(MockMvcRequestBuilders.patch(url) .contentType(MediaType.APPLICATION_JSON) - .content(request)) - .andExpect(status().isIAmATeapot()); + .content(requestAllNull)) + .andExpect(status().isBadRequest()); - when(groupFeedbackUtil.checkGroupFeedbackUpdate(anyLong(), anyLong(), any(), any())).thenReturn( - new CheckResult<>(HttpStatus.FORBIDDEN, "", null)); - mockMvc.perform(MockMvcRequestBuilders.patch( - ApiRoutes.GROUP_FEEDBACK_PATH.replace("{groupid}", "1").replace("{projectid}", "1")) + /* If group feedback check fails, return corresponding status code */ + when(groupFeedbackUtil.checkGroupFeedbackUpdate(groupFeedbackEntity.getGroupId(), groupFeedbackEntity.getProjectId(), getMockUser(), + HttpMethod.PATCH)).thenReturn(new CheckResult<>(HttpStatus.I_AM_A_TEAPOT, "", null)); + mockMvc.perform(MockMvcRequestBuilders.patch(url) .contentType(MediaType.APPLICATION_JSON) - .content(request)) - .andExpect(status().isForbidden()); - } - - @Test - public void testDeleteGroupScore() throws Exception { - when(groupFeedbackUtil.checkGroupFeedbackUpdate(anyLong(), anyLong(), any(), any())) - .thenReturn(new CheckResult<>(HttpStatus.OK, "", groupFeedbackEntity)); - mockMvc.perform(MockMvcRequestBuilders.delete( - ApiRoutes.GROUP_FEEDBACK_PATH.replace("{groupid}", "1").replace("{projectid}", "1"))) - .andExpect(status().isOk()); - - doThrow(new RuntimeException()).when(groupFeedbackRepository).delete(any()); - mockMvc.perform(MockMvcRequestBuilders.delete( - ApiRoutes.GROUP_FEEDBACK_PATH.replace("{groupid}", "1").replace("{projectid}", "1"))) - .andExpect(status().isInternalServerError()); - - when(groupFeedbackUtil.checkGroupFeedbackUpdate(anyLong(), anyLong(), any(), any())) - .thenReturn(new CheckResult<>(HttpStatus.I_AM_A_TEAPOT, "", null)); - mockMvc.perform(MockMvcRequestBuilders.delete( - ApiRoutes.GROUP_FEEDBACK_PATH.replace("{groupid}", "1").replace("{projectid}", "1"))) + .content(requestAllNull)) .andExpect(status().isIAmATeapot()); + } @Test public void testUpdateGroupScorePut() throws Exception { + String url = ApiRoutes.GROUP_FEEDBACK_PATH.replace("{groupid}", ""+groupFeedbackEntity.getGroupId()) + .replace("{projectid}", ""+groupFeedbackEntity.getProjectId()); String request = "{\"score\": 4.4,\"feedback\": \"Heel goed gedaan\"}"; - when(groupFeedbackUtil.checkGroupFeedbackUpdate(anyLong(), anyLong(), any(), any())) - .thenReturn(new CheckResult<>(HttpStatus.OK, "", groupFeedbackEntity)); - when(groupFeedbackUtil.checkGroupFeedbackUpdateJson(any(), anyLong())).thenReturn( - new CheckResult<>(HttpStatus.OK, "", null)); - mockMvc.perform(MockMvcRequestBuilders.put( - ApiRoutes.GROUP_FEEDBACK_PATH.replace("{groupid}", "1").replace("{projectid}", "1")) + /* If all checks succeed, group feedback is updated succesfully */ + /* If all fields are filled, both are updated */ + reset(groupFeedbackUtil); + when(groupFeedbackUtil.checkGroupFeedbackUpdate(groupFeedbackEntity.getGroupId(), groupFeedbackEntity.getProjectId(), getMockUser(), + HttpMethod.PUT)).thenReturn(new CheckResult<>(HttpStatus.OK, "", groupFeedbackEntity)); + when(groupFeedbackUtil.checkGroupFeedbackUpdateJson(argThat( + json -> json.getScore() == 4.4F && json.getFeedback().equals("Heel goed gedaan")), eq(groupFeedbackEntity.getProjectId()))) + .thenReturn(new CheckResult<>(HttpStatus.OK, "", null)); + when(entityToJsonConverter.groupFeedbackEntityToJson(groupFeedbackEntity)).thenReturn(groupFeedbackJson); + groupFeedbackJson.setFeedback("Heel goed gedaan"); + groupFeedbackJson.setScore(4.4F); + mockMvc.perform(MockMvcRequestBuilders.put(url) .contentType(MediaType.APPLICATION_JSON) .content(request)) - .andExpect(status().isOk()); + .andExpect(status().isOk()) + .andExpect(content().contentType(MediaType.APPLICATION_JSON)) + .andExpect(content().json(objectMapper.writeValueAsString(groupFeedbackJson))); + assertEquals("Heel goed gedaan", groupFeedbackEntity.getFeedback()); + assertEquals(4.4F, groupFeedbackEntity.getScore()); + verify(groupFeedbackRepository, times(1)).save(groupFeedbackEntity); + + /* If an exception is thrown, return internal server error */ + doThrow(new RuntimeException()).when(groupFeedbackRepository).save(any()); + mockMvc.perform(MockMvcRequestBuilders.put(url) + .contentType(MediaType.APPLICATION_JSON) + .content(request)) + .andExpect(status().isInternalServerError()); + /* If json check fails, return corresponding status code */ + reset(groupFeedbackUtil); + when(groupFeedbackUtil.checkGroupFeedbackUpdate(groupFeedbackEntity.getGroupId(), groupFeedbackEntity.getProjectId(), getMockUser(), + HttpMethod.PUT)).thenReturn(new CheckResult<>(HttpStatus.OK, "", groupFeedbackEntity)); when(groupFeedbackUtil.checkGroupFeedbackUpdateJson(any(), anyLong())).thenReturn( new CheckResult<>(HttpStatus.BAD_REQUEST, "", null)); - mockMvc.perform(MockMvcRequestBuilders.put( - ApiRoutes.GROUP_FEEDBACK_PATH.replace("{groupid}", "1").replace("{projectid}", "1")) + mockMvc.perform(MockMvcRequestBuilders.put(url) .contentType(MediaType.APPLICATION_JSON) .content(request)) .andExpect(status().isBadRequest()); - when(groupFeedbackUtil.checkGroupFeedbackUpdate(anyLong(), anyLong(), any(), any())) - .thenReturn(new CheckResult<>(HttpStatus.I_AM_A_TEAPOT, "", null)); - mockMvc.perform(MockMvcRequestBuilders.put( - ApiRoutes.GROUP_FEEDBACK_PATH.replace("{groupid}", "1").replace("{projectid}", "1")) + /* If group feedback check fails, return corresponding status code */ + when(groupFeedbackUtil.checkGroupFeedbackUpdate(groupFeedbackEntity.getGroupId(), groupFeedbackEntity.getProjectId(), getMockUser(), + HttpMethod.PUT)).thenReturn(new CheckResult<>(HttpStatus.I_AM_A_TEAPOT, "", null)); + mockMvc.perform(MockMvcRequestBuilders.put(url) .contentType(MediaType.APPLICATION_JSON) .content(request)) .andExpect(status().isIAmATeapot()); } @Test - public void testAddGroupScore() throws Exception { - String request = "{\"score\": 4.4,\"feedback\": \"Heel goed gedaan\"}"; - when(groupFeedbackUtil.checkGroupFeedbackUpdate(anyLong(), anyLong(), any(), any())) + public void testDeleteGroupScore() throws Exception { + String url = ApiRoutes.GROUP_FEEDBACK_PATH.replace("{groupid}", ""+groupFeedbackEntity.getGroupId()) + .replace("{projectid}", ""+groupFeedbackEntity.getProjectId()); + /* If user can delete group feedback, delete it */ + when(groupFeedbackUtil.checkGroupFeedbackUpdate(groupFeedbackEntity.getGroupId(), groupFeedbackEntity.getProjectId(), getMockUser(), HttpMethod.DELETE)) .thenReturn(new CheckResult<>(HttpStatus.OK, "", groupFeedbackEntity)); - when(groupFeedbackUtil.checkGroupFeedbackUpdateJson(any(), anyLong())).thenReturn( - new CheckResult<>(HttpStatus.OK, "", null)); - mockMvc.perform(MockMvcRequestBuilders.post( - ApiRoutes.GROUP_FEEDBACK_PATH.replace("{groupid}", "1").replace("{projectid}", "1")) - .contentType(MediaType.APPLICATION_JSON) - .content(request)) - .andExpect(status().isCreated()); + mockMvc.perform(MockMvcRequestBuilders.delete(url)) + .andExpect(status().isOk()); + verify(groupFeedbackRepository, times(1)).delete(groupFeedbackEntity); + + /* If an exception is thrown, return internal server error */ + doThrow(new RuntimeException()).when(groupFeedbackRepository).delete(any()); + mockMvc.perform(MockMvcRequestBuilders.delete(url)).andExpect(status().isInternalServerError()); + + /* If the groupfeedback can't be deleted by the user, return corresponding status code */ + when(groupFeedbackUtil.checkGroupFeedbackUpdate(anyLong(), anyLong(), any(), any())) + .thenReturn(new CheckResult<>(HttpStatus.I_AM_A_TEAPOT, "", null)); + mockMvc.perform(MockMvcRequestBuilders.delete(url)) + .andExpect(status().isIAmATeapot()); + } + + + + @Test + public void testAddGroupScore() throws Exception { + String url = ApiRoutes.GROUP_FEEDBACK_PATH.replace("{groupid}", ""+groupFeedbackEntity.getGroupId()) + .replace("{projectid}", ""+groupFeedbackEntity.getProjectId()); + String request = "{\"score\": " + groupFeedbackEntity.getScore() + ",\"feedback\": \"" + groupFeedbackEntity.getFeedback() + "\"}"; + /* If all checks succeed, group feedback is added succesfully */ + when(groupFeedbackUtil.checkGroupFeedbackUpdate(groupFeedbackEntity.getGroupId(), groupFeedbackEntity.getProjectId(), getMockUser(), HttpMethod.POST)) + .thenReturn(new CheckResult<>(HttpStatus.OK, "", null)); + when(groupFeedbackUtil.checkGroupFeedbackUpdateJson(argThat( + json -> json.getScore() == groupFeedbackEntity.getScore() && json.getFeedback().equals(groupFeedbackEntity.getFeedback())), eq(groupFeedbackEntity.getProjectId()))) + .thenReturn(new CheckResult<>(HttpStatus.OK, "", null)); + when(groupFeedbackRepository.save(any())).thenReturn(groupFeedbackEntity); + when(entityToJsonConverter.groupFeedbackEntityToJson(groupFeedbackEntity)).thenReturn(groupFeedbackJson); + mockMvc.perform(MockMvcRequestBuilders.post(url) + .contentType(MediaType.APPLICATION_JSON) + .content(request)) + .andExpect(status().isCreated()) + .andExpect(content().contentType(MediaType.APPLICATION_JSON)) + .andExpect(content().json(objectMapper.writeValueAsString(groupFeedbackJson))); + verify(groupFeedbackRepository, times(1)).save(argThat( + groupFeedback -> groupFeedback.getScore() == groupFeedbackEntity.getScore() && + groupFeedback.getFeedback().equals(groupFeedbackEntity.getFeedback()) && + groupFeedback.getGroupId() == groupFeedbackEntity.getGroupId() && + groupFeedback.getProjectId() == groupFeedbackEntity.getProjectId())); + /* If an exception is thrown, return internal server error */ + reset(groupFeedbackRepository); when(groupFeedbackRepository.save(any())).thenThrow(new RuntimeException()); - mockMvc.perform(MockMvcRequestBuilders.post( - ApiRoutes.GROUP_FEEDBACK_PATH.replace("{groupid}", "1").replace("{projectid}", "1")) + mockMvc.perform(MockMvcRequestBuilders.post(url) .contentType(MediaType.APPLICATION_JSON) .content(request)) .andExpect(status().isInternalServerError()); + /* If json check fails, return corresponding status code */ + reset(groupFeedbackUtil); + when(groupFeedbackUtil.checkGroupFeedbackUpdate(anyLong(), anyLong(), any(), any())).thenReturn( + new CheckResult<>(HttpStatus.OK, "", null)); when(groupFeedbackUtil.checkGroupFeedbackUpdateJson(any(), anyLong())).thenReturn( new CheckResult<>(HttpStatus.BAD_REQUEST, "", null)); - mockMvc.perform(MockMvcRequestBuilders.post( - ApiRoutes.GROUP_FEEDBACK_PATH.replace("{groupid}", "1").replace("{projectid}", "1")) + mockMvc.perform(MockMvcRequestBuilders.post(url) .contentType(MediaType.APPLICATION_JSON) .content(request)) .andExpect(status().isBadRequest()); + /* If user can't add group feedback, return corresponding status code */ when(groupFeedbackUtil.checkGroupFeedbackUpdate(anyLong(), anyLong(), any(), any())).thenReturn( new CheckResult<>(HttpStatus.FORBIDDEN, "", null)); - mockMvc.perform(MockMvcRequestBuilders.post( - ApiRoutes.GROUP_FEEDBACK_PATH.replace("{groupid}", "1").replace("{projectid}", "1")) + mockMvc.perform(MockMvcRequestBuilders.post(url) .contentType(MediaType.APPLICATION_JSON) .content(request)) .andExpect(status().isForbidden()); @@ -179,32 +327,112 @@ public void testAddGroupScore() throws Exception { @Test public void testGetGroupScore() throws Exception { - when(groupFeedbackUtil.checkGroupFeedback(anyLong(), anyLong())).thenReturn( - new CheckResult<>(HttpStatus.OK, "", null)); - when(groupUtil.canGetProjectGroupData(anyLong(), anyLong(), any())).thenReturn( - new CheckResult<>(HttpStatus.OK, "", null)); - when(groupFeedbackUtil.getGroupFeedbackIfExists(anyLong(), anyLong())).thenReturn( - new CheckResult<>(HttpStatus.OK, "", groupFeedbackEntity)); - mockMvc.perform(MockMvcRequestBuilders.get( - ApiRoutes.GROUP_FEEDBACK_PATH.replace("{groupid}", "1").replace("{projectid}", "1"))) - .andExpect(status().isOk()); + String url = ApiRoutes.GROUP_FEEDBACK_PATH.replace("{groupid}", ""+groupFeedbackEntity.getGroupId()) + .replace("{projectid}", ""+groupFeedbackEntity.getProjectId()); + /* If all checks succeed, group feedback is returned */ + when(groupFeedbackUtil.checkGroupFeedback(groupFeedbackEntity.getGroupId(), groupFeedbackEntity.getProjectId())) + .thenReturn(new CheckResult<>(HttpStatus.OK, "", null)); + when(entityToJsonConverter.groupFeedbackEntityToJson(groupFeedbackEntity)).thenReturn(groupFeedbackJson); + when(groupUtil.canGetProjectGroupData(groupFeedbackEntity.getGroupId(), groupFeedbackEntity.getProjectId(), getMockUser())) + .thenReturn(new CheckResult<>(HttpStatus.OK, "", null)); + when(groupFeedbackUtil.getGroupFeedbackIfExists(groupFeedbackEntity.getGroupId(), groupFeedbackEntity.getProjectId())) + .thenReturn(new CheckResult<>(HttpStatus.OK, "", groupFeedbackEntity)); + when(entityToJsonConverter.groupFeedbackEntityToJson(groupFeedbackEntity)).thenReturn(groupFeedbackJson); + mockMvc.perform(MockMvcRequestBuilders.get(url)) + .andExpect(status().isOk()) + .andExpect(content().contentType(MediaType.APPLICATION_JSON)) + .andExpect(content().json(objectMapper.writeValueAsString(groupFeedbackJson))); - when(groupFeedbackUtil.getGroupFeedbackIfExists(anyLong(), anyLong())).thenReturn( - new CheckResult<>(HttpStatus.BAD_REQUEST, "", null)); - mockMvc.perform(MockMvcRequestBuilders.get( - ApiRoutes.GROUP_FEEDBACK_PATH.replace("{groupid}", "1").replace("{projectid}", "1"))) + /* If feedback doesn't exist, return not found */ + reset(groupFeedbackUtil); + when(groupFeedbackUtil.checkGroupFeedback(anyLong(), anyLong())) + .thenReturn(new CheckResult<>(HttpStatus.OK, "", null)); + when(groupFeedbackUtil.getGroupFeedbackIfExists(anyLong(), anyLong())) + .thenReturn(new CheckResult<>(HttpStatus.NOT_FOUND, "", null)); + mockMvc.perform(MockMvcRequestBuilders.get(url)) + .andExpect(status().isNotFound()); + + /* User can't get project group data, return forbidden */ + reset(groupUtil); + when(groupUtil.canGetProjectGroupData(anyLong(), anyLong(), any())) + .thenReturn(new CheckResult<>(HttpStatus.FORBIDDEN, "", null)); + mockMvc.perform(MockMvcRequestBuilders.get(url)) + .andExpect(status().isForbidden()); + + /* If check fails, return corresponding status code */ + when(groupFeedbackUtil.checkGroupFeedback(anyLong(), anyLong())) + .thenReturn(new CheckResult<>(HttpStatus.I_AM_A_TEAPOT, "", null)); + mockMvc.perform(MockMvcRequestBuilders.get(url)) + .andExpect(status().isIAmATeapot()); + } + + @Test + public void testGetCourseGrades() throws Exception { + CourseEntity courseEntity = new CourseEntity("Test course", "TestCourseDescription", 2013); + courseEntity.setId(99L); + ProjectEntity project1 = new ProjectEntity(courseEntity.getId(),"Test project", "TestProjectDescription", 1L, 11L, true, 44, OffsetDateTime.now()); + ProjectEntity project2 = new ProjectEntity(courseEntity.getId(),"Test project", "TestProjectDescription", 2L, 11L, true, 44, OffsetDateTime.now()); + project2.setId(1L); + project1.setId(2L); + long project1GroupId = 4L; + long project2GroupId = 5L; + GroupFeedbackJsonWithProject groupFeedbackJsonWithProject1 = new GroupFeedbackJsonWithProject(project1.getName(), "UrlOfProject1", project1.getId(), groupFeedbackJson, project1.getMaxScore()); + GroupFeedbackJsonWithProject groupFeedbackJsonWithProject2 = new GroupFeedbackJsonWithProject(project2.getName(), "UrlOfProject2", project2.getId(), null, project2.getMaxScore()); + List groupFeedbackJsonWithProjects = List.of(groupFeedbackJsonWithProject1, groupFeedbackJsonWithProject2); + String url = ApiRoutes.COURSE_BASE_PATH + "/" + courseEntity.getId() + "/grades"; + /* If all checks succeed, course grades are returned */ + when(courseUtil.getCourseIfUserInCourse(courseEntity.getId(), getMockUser())) + .thenReturn(new CheckResult<>(HttpStatus.OK, "", new Pair<>(courseEntity, CourseRelation.enrolled))); + when(projectRepository.findByCourseId(courseEntity.getId())) + .thenReturn(List.of(project1, project2)); + when(groupRepository.groupIdByProjectAndUser(project1.getId(), getMockUser().getId())) + .thenReturn(project1GroupId); + when(groupRepository.groupIdByProjectAndUser(project2.getId(), getMockUser().getId())) + .thenReturn(project2GroupId); + when(groupFeedbackUtil.getGroupFeedbackIfExists(project1GroupId, project1.getId())) + .thenReturn(new CheckResult<>(HttpStatus.OK, "", groupFeedbackEntity)); + when(groupFeedbackUtil.getGroupFeedbackIfExists(project2GroupId, project2.getId())) + .thenReturn(new CheckResult<>(HttpStatus.NOT_FOUND, "", null)); + when(entityToJsonConverter.groupFeedbackEntityToJsonWithProject(groupFeedbackEntity, project1)) + .thenReturn(groupFeedbackJsonWithProject1); + when(entityToJsonConverter.groupFeedbackEntityToJsonWithProject(null, project2)) + .thenReturn(groupFeedbackJsonWithProject2); + + mockMvc.perform(MockMvcRequestBuilders.get(url)) + .andExpect(status().isOk()) + .andExpect(content().contentType(MediaType.APPLICATION_JSON)) + .andExpect(content().json(objectMapper.writeValueAsString(groupFeedbackJsonWithProjects))); + + /* If project is not visible, filter it out */ + project2.setVisible(false); + mockMvc.perform(MockMvcRequestBuilders.get(url)) + .andExpect(status().isOk()) + .andExpect(content().contentType(MediaType.APPLICATION_JSON)) + .andExpect(content().json(objectMapper.writeValueAsString(List.of(groupFeedbackJsonWithProject1)))); + project2.setVisible(true); + + /* If user is not yet in group also have null as group feedback */ + when(groupRepository.groupIdByProjectAndUser(project1.getId(), getMockUser().getId())) + .thenReturn(null); + GroupFeedbackJsonWithProject project1NoGroup = new GroupFeedbackJsonWithProject(project1.getName(), "UrlOfProject1", project1.getId(), null, project1.getMaxScore()); + groupFeedbackJsonWithProjects = List.of(project1NoGroup, groupFeedbackJsonWithProject2); + when(entityToJsonConverter.groupFeedbackEntityToJsonWithProject(null, project1)) + .thenReturn(project1NoGroup); + mockMvc.perform(MockMvcRequestBuilders.get(url)) + .andExpect(status().isOk()) + .andExpect(content().contentType(MediaType.APPLICATION_JSON)) + .andExpect(content().json(objectMapper.writeValueAsString(groupFeedbackJsonWithProjects))); + + /* If user isn't enrolled in the course, return BAD REQUEST */ + when(courseUtil.getCourseIfUserInCourse(courseEntity.getId(), getMockUser())) + .thenReturn(new CheckResult<>(HttpStatus.OK, "", new Pair<>(courseEntity, CourseRelation.course_admin))); + mockMvc.perform(MockMvcRequestBuilders.get(url)) .andExpect(status().isBadRequest()); - when(groupUtil.canGetProjectGroupData(anyLong(), anyLong(), any())).thenReturn( - new CheckResult<>(HttpStatus.CONFLICT, "", null)); - mockMvc.perform(MockMvcRequestBuilders.get( - ApiRoutes.GROUP_FEEDBACK_PATH.replace("{groupid}", "1").replace("{projectid}", "1"))) - .andExpect(status().isConflict()); - - when(groupFeedbackUtil.checkGroupFeedback(anyLong(), anyLong())).thenReturn( - new CheckResult<>(HttpStatus.BANDWIDTH_LIMIT_EXCEEDED, "", null)); - mockMvc.perform(MockMvcRequestBuilders.get( - ApiRoutes.GROUP_FEEDBACK_PATH.replace("{groupid}", "1").replace("{projectid}", "1"))) - .andExpect(status().isBandwidthLimitExceeded()); + /* If course check fails, return corresponding status code */ + when(courseUtil.getCourseIfUserInCourse(courseEntity.getId(), getMockUser())) + .thenReturn(new CheckResult<>(HttpStatus.I_AM_A_TEAPOT, "", null)); + mockMvc.perform(MockMvcRequestBuilders.get(url)) + .andExpect(status().isIAmATeapot()); } } diff --git a/backend/app/src/test/java/com/ugent/pidgeon/controllers/GroupMembersControllerTest.java b/backend/app/src/test/java/com/ugent/pidgeon/controllers/GroupMembersControllerTest.java index 9e90bb1c..70fd1ac9 100644 --- a/backend/app/src/test/java/com/ugent/pidgeon/controllers/GroupMembersControllerTest.java +++ b/backend/app/src/test/java/com/ugent/pidgeon/controllers/GroupMembersControllerTest.java @@ -2,9 +2,15 @@ import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.ugent.pidgeon.CustomObjectMapper; +import com.ugent.pidgeon.model.json.UserReferenceJson; import com.ugent.pidgeon.postgre.models.UserEntity; import com.ugent.pidgeon.postgre.models.types.UserRole; import com.ugent.pidgeon.postgre.repository.GroupMemberRepository; @@ -19,6 +25,7 @@ import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; import org.springframework.security.core.context.SecurityContextHolder; import org.springframework.test.web.servlet.request.MockMvcRequestBuilders; import org.springframework.test.web.servlet.setup.MockMvcBuilders; @@ -36,123 +43,152 @@ public class GroupMembersControllerTest extends ControllerTest { @InjectMocks private GroupMemberController groupMemberController; + private ObjectMapper objectMapper = CustomObjectMapper.createObjectMapper(); + private UserEntity userEntity; + private UserEntity userEntity2; + private UserReferenceJson userReferenceJson; + private UserReferenceJson userReferenceJson2; + private final long groupId = 10L; @BeforeEach public void setup() { - mockMvc = MockMvcBuilders.standaloneSetup(groupMemberController) - .defaultRequest(MockMvcRequestBuilders.get("/**") - .with(request -> { - request.setUserPrincipal(SecurityContextHolder.getContext().getAuthentication()); - return request; - })) - .build(); + setUpController(groupMemberController); userEntity = new UserEntity("name", "surname", "email", UserRole.student, "azureid"); - userEntity.setId(1L); + userEntity.setId(5L); + userEntity2 = new UserEntity("name2", "surname2", "email2", UserRole.student, "azureid2"); + userEntity2.setId(6L); + userReferenceJson = new UserReferenceJson(userEntity.getName(), userEntity.getEmail(), userEntity.getId()); + userReferenceJson2 = new UserReferenceJson(userEntity2.getName(), userEntity2.getEmail(), userEntity2.getId()); } @Test public void testRemoveMemberFromGroup() throws Exception { - when(groupUtil.canRemoveUserFromGroup(anyLong(), anyLong(), any())) + String url = ApiRoutes.GROUP_MEMBER_BASE_PATH.replace("{groupid}", ""+groupId) + "/" + userEntity.getId(); + /* If all checks pass, the user is removed from the group */ + when(groupUtil.canRemoveUserFromGroup(groupId, userEntity.getId(), getMockUser())) .thenReturn(new CheckResult<>(HttpStatus.OK, "", null)); - when(groupMemberRepository.removeMemberFromGroup(anyLong(), anyLong())).thenReturn(1); - mockMvc.perform(MockMvcRequestBuilders.delete( - ApiRoutes.GROUP_MEMBER_BASE_PATH.replace("{groupid}", "1") + "/1")) + when(groupMemberRepository.removeMemberFromGroup(groupId, userEntity.getId())).thenReturn(1); + mockMvc.perform(MockMvcRequestBuilders.delete(url)) .andExpect(status().isNoContent()); + verify(groupMemberRepository, times(1)).removeMemberFromGroup(groupId, userEntity.getId()); - when(groupMemberRepository.removeMemberFromGroup(anyLong(), anyLong())).thenReturn(0); - mockMvc.perform(MockMvcRequestBuilders.delete( - ApiRoutes.GROUP_MEMBER_BASE_PATH.replace("{groupid}", "1") + "/1")) + /* If something goes wrong return internal server error */ + when(groupMemberRepository.removeMemberFromGroup(groupId, userEntity.getId())).thenReturn(0); + mockMvc.perform(MockMvcRequestBuilders.delete(url)) .andExpect(status().isInternalServerError()); - when(groupUtil.canRemoveUserFromGroup(anyLong(), anyLong(), any())) + /* If use can't be removed from group return corresponding status */ + when(groupUtil.canRemoveUserFromGroup(groupId, userEntity.getId(), getMockUser())) .thenReturn(new CheckResult<>(HttpStatus.BAD_REQUEST, "", null)); - mockMvc.perform(MockMvcRequestBuilders.delete( - ApiRoutes.GROUP_MEMBER_BASE_PATH.replace("{groupid}", "1") + "/1")) + mockMvc.perform(MockMvcRequestBuilders.delete(url)) .andExpect(status().isBadRequest()); } @Test public void testRemoveMemberFromGroupInferred() throws Exception { - when(groupUtil.canRemoveUserFromGroup(anyLong(), anyLong(), any())) + String url = ApiRoutes.GROUP_MEMBER_BASE_PATH.replace("{groupid}", ""+groupId); + /* If all checks pass, the user is removed from the group */ + when(groupUtil.canRemoveUserFromGroup(groupId, getMockUser().getId(), getMockUser())) .thenReturn(new CheckResult<>(HttpStatus.OK, "", null)); - when(groupMemberRepository.removeMemberFromGroup(anyLong(), anyLong())).thenReturn(1); - mockMvc.perform( - MockMvcRequestBuilders.delete(ApiRoutes.GROUP_MEMBER_BASE_PATH.replace("{groupid}", "1"))) + when(groupMemberRepository.removeMemberFromGroup(groupId, getMockUser().getId())).thenReturn(1); + mockMvc.perform(MockMvcRequestBuilders.delete(url)) .andExpect(status().isNoContent()); + verify(groupMemberRepository, times(1)).removeMemberFromGroup(groupId, getMockUser().getId()); - when(groupMemberRepository.removeMemberFromGroup(anyLong(), anyLong())).thenReturn(0); - mockMvc.perform( - MockMvcRequestBuilders.delete(ApiRoutes.GROUP_MEMBER_BASE_PATH.replace("{groupid}", "1"))) + /* If something goes wrong return internal server error */ + when(groupMemberRepository.removeMemberFromGroup(groupId, getMockUser().getId())).thenReturn(0); + mockMvc.perform(MockMvcRequestBuilders.delete(url)) .andExpect(status().isInternalServerError()); - when(groupUtil.canRemoveUserFromGroup(anyLong(), anyLong(), any())) + /* If use can't be removed from group return corresponding status */ + when(groupUtil.canRemoveUserFromGroup(groupId, getMockUser().getId(), getMockUser())) .thenReturn(new CheckResult<>(HttpStatus.BAD_REQUEST, "", null)); - mockMvc.perform( - MockMvcRequestBuilders.delete(ApiRoutes.GROUP_MEMBER_BASE_PATH.replace("{groupid}", "1"))) + mockMvc.perform(MockMvcRequestBuilders.delete(url)) .andExpect(status().isBadRequest()); } @Test public void testAddMemberToGroup() throws Exception { - when(groupUtil.canAddUserToGroup(anyLong(), anyLong(), any())) + String url = ApiRoutes.GROUP_MEMBER_BASE_PATH.replace("{groupid}", ""+groupId) + "/" + userEntity.getId(); + + /* If all checks succeed, the user is added to the group */ + when(groupUtil.canAddUserToGroup(groupId, userEntity.getId(), getMockUser())) .thenReturn(new CheckResult<>(HttpStatus.OK, "", null)); - when(groupMemberRepository.findAllMembersByGroupId(anyLong())) - .thenReturn(List.of(userEntity)); - mockMvc.perform(MockMvcRequestBuilders.post( - ApiRoutes.GROUP_MEMBER_BASE_PATH.replace("{groupid}", "1") + "/1")) - .andExpect(status().isOk()); - - when(groupMemberRepository.findAllMembersByGroupId(anyLong())).thenThrow( - new RuntimeException()); - mockMvc.perform(MockMvcRequestBuilders.post( - ApiRoutes.GROUP_MEMBER_BASE_PATH.replace("{groupid}", "1") + "/1")) + when(groupMemberRepository.findAllMembersByGroupId(groupId)) + .thenReturn(List.of(userEntity, userEntity2)); + when(entityToJsonConverter.userEntityToUserReference(userEntity)).thenReturn(userReferenceJson); + when(entityToJsonConverter.userEntityToUserReference(userEntity2)).thenReturn(userReferenceJson2); + mockMvc.perform(MockMvcRequestBuilders.post(url)) + .andExpect(status().isOk()) + .andExpect(content().contentType(MediaType.APPLICATION_JSON)) + .andExpect(content().json(objectMapper.writeValueAsString(List.of(userReferenceJson, userReferenceJson2)))); + verify(groupMemberRepository, times(1)).addMemberToGroup(groupId, userEntity.getId()); + + /* If something goes wrong return internal server error */ + when(groupMemberRepository.addMemberToGroup(groupId, userEntity.getId())).thenThrow(new RuntimeException()); + mockMvc.perform(MockMvcRequestBuilders.post(url)) .andExpect(status().isInternalServerError()); - when(groupUtil.canAddUserToGroup(anyLong(), anyLong(), any())) - .thenReturn(new CheckResult<>(HttpStatus.BAD_REQUEST, "", null)); - mockMvc.perform(MockMvcRequestBuilders.post( - ApiRoutes.GROUP_MEMBER_BASE_PATH.replace("{groupid}", "1") + "/1")) - .andExpect(status().isBadRequest()); + /* If user can't be added to group return corresponding status */ + when(groupUtil.canAddUserToGroup(groupId, userEntity.getId(), getMockUser())) + .thenReturn(new CheckResult<>(HttpStatus.I_AM_A_TEAPOT, "", null)); + mockMvc.perform(MockMvcRequestBuilders.post(url)) + .andExpect(status().isIAmATeapot()); + } @Test public void testAddMemberToGroupInferred() throws Exception { - when(groupUtil.canAddUserToGroup(anyLong(), anyLong(), any())) + String url = ApiRoutes.GROUP_MEMBER_BASE_PATH.replace("{groupid}", ""+groupId); + UserReferenceJson mockUserJson = new UserReferenceJson(getMockUser().getName(), getMockUser().getEmail(), getMockUser().getId()); + + /* If all checks succeed, the user is added to the group */ + when(groupUtil.canAddUserToGroup(groupId, getMockUser().getId(), getMockUser())) .thenReturn(new CheckResult<>(HttpStatus.OK, "", null)); - when(groupMemberRepository.findAllMembersByGroupId(anyLong())) - .thenReturn(List.of(userEntity)); - mockMvc.perform( - MockMvcRequestBuilders.post(ApiRoutes.GROUP_MEMBER_BASE_PATH.replace("{groupid}", "1"))) - .andExpect(status().isOk()); - - when(groupMemberRepository.findAllMembersByGroupId(anyLong())).thenThrow( - new RuntimeException()); - mockMvc.perform( - MockMvcRequestBuilders.post(ApiRoutes.GROUP_MEMBER_BASE_PATH.replace("{groupid}", "1"))) + when(groupMemberRepository.findAllMembersByGroupId(groupId)) + .thenReturn(List.of(getMockUser(), userEntity2)); + when(entityToJsonConverter.userEntityToUserReference(getMockUser())).thenReturn(mockUserJson); + when(entityToJsonConverter.userEntityToUserReference(userEntity2)).thenReturn(userReferenceJson2); + mockMvc.perform(MockMvcRequestBuilders.post(url)) + .andExpect(status().isOk()) + .andExpect(content().contentType(MediaType.APPLICATION_JSON)) + .andExpect(content().json(objectMapper.writeValueAsString(List.of(mockUserJson, userReferenceJson2)))); + verify(groupMemberRepository, times(1)).addMemberToGroup(groupId, getMockUser().getId()); + + /* If something goes wrong return internal server error */ + when(groupMemberRepository.addMemberToGroup(groupId, getMockUser().getId())).thenThrow(new RuntimeException()); + mockMvc.perform(MockMvcRequestBuilders.post(url)) .andExpect(status().isInternalServerError()); - when(groupUtil.canAddUserToGroup(anyLong(), anyLong(), any())) - .thenReturn(new CheckResult<>(HttpStatus.BAD_REQUEST, "", null)); - mockMvc.perform( - MockMvcRequestBuilders.post(ApiRoutes.GROUP_MEMBER_BASE_PATH.replace("{groupid}", "1"))) - .andExpect(status().isBadRequest()); + /* If user can't be added to group return corresponding status */ + when(groupUtil.canAddUserToGroup(groupId, getMockUser().getId(), getMockUser())) + .thenReturn(new CheckResult<>(HttpStatus.I_AM_A_TEAPOT, "", null)); + mockMvc.perform(MockMvcRequestBuilders.post(url)) + .andExpect(status().isIAmATeapot()); } @Test public void testFindAllMembersByGroupId() throws Exception { - when(groupUtil.canGetGroup(anyLong(), any())) + String url = ApiRoutes.GROUP_MEMBER_BASE_PATH.replace("{groupid}", ""+groupId); + List members = List.of(userEntity, userEntity2); + List userReferenceJsons = List.of(userReferenceJson, userReferenceJson2); + when(groupMemberRepository.findAllMembersByGroupId(groupId)).thenReturn(members); + when(entityToJsonConverter.userEntityToUserReference(userEntity)).thenReturn(userReferenceJson); + when(entityToJsonConverter.userEntityToUserReference(userEntity2)).thenReturn(userReferenceJson2); + + /* If user can get group return list of members */ + when(groupUtil.canGetGroup(groupId, getMockUser())) .thenReturn(new CheckResult<>(HttpStatus.OK, "", null)); - when(groupMemberRepository.findAllMembersByGroupId(anyLong())) - .thenReturn(List.of(userEntity)); - mockMvc.perform( - MockMvcRequestBuilders.get(ApiRoutes.GROUP_MEMBER_BASE_PATH.replace("{groupid}", "1"))) - .andExpect(status().isOk()); - - when(groupUtil.canGetGroup(anyLong(), any())) - .thenReturn(new CheckResult<>(HttpStatus.BAD_REQUEST, "", null)); - mockMvc.perform( - MockMvcRequestBuilders.get(ApiRoutes.GROUP_MEMBER_BASE_PATH.replace("{groupid}", "1"))) - .andExpect(status().isBadRequest()); + mockMvc.perform(MockMvcRequestBuilders.get(url)) + .andExpect(status().isOk()) + .andExpect(content().contentType(MediaType.APPLICATION_JSON)) + .andExpect(content().json(objectMapper.writeValueAsString(userReferenceJsons))); + + /* If use can't get group return corresponding status */ + when(groupUtil.canGetGroup(groupId, getMockUser())) + .thenReturn(new CheckResult<>(HttpStatus.I_AM_A_TEAPOT, "", null)); + mockMvc.perform(MockMvcRequestBuilders.get(url)) + .andExpect(status().isIAmATeapot()); } } \ No newline at end of file diff --git a/backend/app/src/test/java/com/ugent/pidgeon/controllers/ProjectControllerTest.java b/backend/app/src/test/java/com/ugent/pidgeon/controllers/ProjectControllerTest.java index 255f599d..e95b0b5b 100644 --- a/backend/app/src/test/java/com/ugent/pidgeon/controllers/ProjectControllerTest.java +++ b/backend/app/src/test/java/com/ugent/pidgeon/controllers/ProjectControllerTest.java @@ -1,14 +1,15 @@ package com.ugent.pidgeon.controllers; -import com.ugent.pidgeon.model.Auth; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.ugent.pidgeon.CustomObjectMapper; import com.ugent.pidgeon.model.ProjectResponseJson; import com.ugent.pidgeon.model.json.CourseReferenceJson; -import com.ugent.pidgeon.model.json.ProjectJson; +import com.ugent.pidgeon.model.json.GroupJson; import com.ugent.pidgeon.model.json.ProjectProgressJson; -import com.ugent.pidgeon.model.json.userProjectsJson; +import com.ugent.pidgeon.model.json.ProjectResponseJsonWithStatus; +import com.ugent.pidgeon.model.json.UserProjectsJson; import com.ugent.pidgeon.postgre.models.*; import com.ugent.pidgeon.postgre.models.types.CourseRelation; -import com.ugent.pidgeon.postgre.models.types.UserRole; import com.ugent.pidgeon.postgre.repository.*; import com.ugent.pidgeon.util.CheckResult; import com.ugent.pidgeon.util.ClusterUtil; @@ -17,30 +18,28 @@ import com.ugent.pidgeon.util.EntityToJsonConverter; import com.ugent.pidgeon.util.Pair; import com.ugent.pidgeon.util.ProjectUtil; -import java.util.Objects; +import java.util.Collections; +import java.util.Optional; +import java.util.logging.Logger; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.mockito.InjectMocks; import org.mockito.Mock; -import org.mockito.MockitoAnnotations; -import org.mockito.ArgumentMatchers; -import org.springframework.data.jpa.repository.support.SimpleJpaRepository; import org.springframework.http.HttpStatus; -import org.springframework.http.ResponseEntity; +import org.springframework.http.MediaType; import org.springframework.test.web.servlet.MockMvc; import java.time.OffsetDateTime; -import java.util.ArrayList; import java.util.List; -import java.util.Optional; +import org.springframework.test.web.servlet.request.MockMvcRequestBuilders; -import static org.junit.jupiter.api.Assertions.*; +import static org.junit.jupiter.api.Assertions.assertEquals; import static org.mockito.Mockito.*; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; -public class ProjectControllerTest { - - protected MockMvc mockMvc; +public class ProjectControllerTest extends ControllerTest { @InjectMocks private ProjectController projectController; @@ -78,646 +77,631 @@ public class ProjectControllerTest { @Mock private GroupRepository grouprRepository; + private final ObjectMapper objectMapper = CustomObjectMapper.createObjectMapper(); + private ProjectEntity projectEntity; + private ProjectEntity projectEntity2; + private ProjectResponseJson projectResponseJson; + private ProjectResponseJson projectResponseJson2; + private CourseEntity courseEntity; + private CourseEntity courseEntity2; + private final long groupClusterId = 7L; + @BeforeEach void setUp() { - MockitoAnnotations.openMocks(this); - } - - - @Test - void testGetProjectShouldReturnOneProject() { - // Mock data - Auth auth = mock(Auth.class); - ProjectEntity project = new ProjectEntity(); - project.setName("Project 1"); - project.setId(1L); - project.setVisible(true); - List projects = new ArrayList<>(); - projects.add(project); - UserEntity user = new UserEntity("Test", "De Tester", "test.tester@test.com", UserRole.student, - "azure"); - user.setId(1L); - - // Mock repository behavior - when(projectRepository.findProjectsByUserId(anyLong())).thenReturn(projects); - when(auth.getUserEntity()).thenReturn(user); - when(courseUtil.getCourseIfUserInCourse(anyLong(), any())) - .thenReturn(new CheckResult<>(HttpStatus.OK, "", - new Pair<>(new CourseEntity(), CourseRelation.enrolled))); - - // Call controller method - ResponseEntity response = projectController.getProjects(auth); - - // Verify response - assertInstanceOf(userProjectsJson.class, response.getBody()); - userProjectsJson responseBody = (userProjectsJson) response.getBody(); - assertEquals(1, responseBody.enrolledProjects().size()); - assertEquals(HttpStatus.OK, response.getStatusCode()); - assertNotNull(response.getBody()); - } - - @Test - void testGetProjectShouldReturnMultipleProject() { - // Mock data - Auth auth = mock(Auth.class); - ProjectEntity project1 = new ProjectEntity(); - project1.setName("Project 1"); - project1.setId(1L); - project1.setVisible(true); - ProjectEntity project2 = new ProjectEntity(); - project2.setName("Project 2"); - project2.setId(2L); - project2.setVisible(true); - List projects = new ArrayList<>(); - projects.add(project1); - projects.add(project2); - UserEntity user = new UserEntity("Test", "De Tester", "test.tester@test.com", UserRole.student, - "azure"); - user.setId(1L); - - // Mock repository behavior - when(projectRepository.findProjectsByUserId(anyLong())).thenReturn(projects); - when(auth.getUserEntity()).thenReturn(user); - when(courseUtil.getCourseIfUserInCourse(anyLong(), any())) - .thenReturn(new CheckResult<>(HttpStatus.OK, "", - new Pair<>(new CourseEntity(), CourseRelation.enrolled))); - - // Call controller method - ResponseEntity response = projectController.getProjects(auth); - - // Verify response - assertEquals(HttpStatus.OK, response.getStatusCode()); - assertNotNull(response.getBody()); - assertInstanceOf(userProjectsJson.class, response.getBody()); - userProjectsJson responseBody = (userProjectsJson) response.getBody(); - assertEquals(2, responseBody.enrolledProjects().size()); - } - - - @Test - void testGetProjectByIdShouldReturnProject() { - // Mock data - // auth object - Auth auth = mock(Auth.class); - // projects - ProjectEntity project1 = new ProjectEntity(); - project1.setName("Project 1"); - project1.setId(1L); - project1.setVisible(true); - ProjectEntity project2 = new ProjectEntity(); - project2.setName("Project 2"); - project2.setId(2L); - project2.setVisible(true); - project2.setCourseId(1L); - ProjectEntity project3 = new ProjectEntity(); - project3.setName("Project 3"); - project3.setId(3L); - project3.setVisible(true); - List projects = new ArrayList<>(); - projects.add(project1); - projects.add(project2); - projects.add(project3); - // users - UserEntity user = new UserEntity("Test", "De Tester", "test.tester@test.com", UserRole.student, - "azure"); - user.setId(1L); - //check results - CourseEntity courseEntity = new CourseEntity(); - CheckResult checkResult = new CheckResult<>(HttpStatus.OK, "TestProject", - project2); - CheckResult> courseCheck = new CheckResult<>(HttpStatus.OK, "TestCourse", - new Pair<>(courseEntity, CourseRelation.enrolled)); - - // Mock repository behavior - when(projectUtil.canGetProject(2L, user)).thenReturn(checkResult); - when(courseUtil.getCourseIfUserInCourse(1L, user)).thenReturn(courseCheck); - when(auth.getUserEntity()).thenReturn(user); - when(entityToJsonConverter.projectEntityToProjectResponseJson(project2, courseCheck.getData().getFirst(), - user)).thenReturn(new ProjectResponseJson( - new CourseReferenceJson("TestCourse", ApiRoutes.COURSE_BASE_PATH + "/" + 1L, 1L, OffsetDateTime.now()), - OffsetDateTime.MAX, - "Test", 2L, "TestProject", "testUrl", "testUrl", 0, true, new ProjectProgressJson(0, 0), - 1L, 1L)); - - // Call controller method - ResponseEntity response = projectController.getProjectById(2L, auth); - // Verify response - assertEquals(HttpStatus.OK, response.getStatusCode()); - ProjectResponseJson responseBody = (ProjectResponseJson) response.getBody(); - assert responseBody != null; - assertEquals(2L, responseBody.projectId()); - - } - - - @Test - void testGetProjectByIdShouldFailReasonCanNotGetProject() { - // Mock data - // auth object - Auth auth = mock(Auth.class); - // projects - ProjectEntity project1 = new ProjectEntity(); - project1.setName("Project 1"); - project1.setId(1L); - ProjectEntity project2 = new ProjectEntity(); - project2.setName("Project 2"); - project2.setId(2L); - project2.setCourseId(1L); - ProjectEntity project3 = new ProjectEntity(); - project3.setName("Project 3"); - project3.setId(3L); - List projects = new ArrayList<>(); - projects.add(project1); - projects.add(project2); - projects.add(project3); - // users - UserEntity user = new UserEntity("Test", "De Tester", "test.tester@test.com", UserRole.student, - "azure"); - user.setId(1L); - //check results - CourseEntity courseEntity = new CourseEntity(); - CheckResult checkResult = new CheckResult<>(HttpStatus.FORBIDDEN, - "testProjectForbidden", - project2); - CheckResult courseCheck = new CheckResult<>(HttpStatus.OK, "TestCourse", - courseEntity); - - // Mock repository behavior - when(projectUtil.canGetProject(2L, user)).thenReturn(checkResult); - when(courseUtil.getCourseIfExists(1L)).thenReturn(courseCheck); - when(auth.getUserEntity()).thenReturn(user); - when(entityToJsonConverter.projectEntityToProjectResponseJson(project2, courseCheck.getData(), - user)).thenReturn(new ProjectResponseJson( - new CourseReferenceJson("TestCourse", ApiRoutes.COURSE_BASE_PATH + "/" + 1L, 1L, OffsetDateTime.now()), - OffsetDateTime.MAX, - "Test", 2L, "TestProject", "testUrl", "testUrl", 0, true, new ProjectProgressJson(0, 0), - 1L, 1L)); - - // Call controller method - ResponseEntity response = projectController.getProjectById(2L, auth); - // Verify response - assertEquals(HttpStatus.FORBIDDEN, response.getStatusCode()); - assertEquals("testProjectForbidden", Objects.requireNonNull(response.getBody()).toString()); - - } - - - @Test - void testGetProjectByIdShouldFailReasonCanNotGetCourse() { - // Mock data - // auth object - Auth auth = mock(Auth.class); - // projects - ProjectEntity project1 = new ProjectEntity(); - project1.setName("Project 1"); - project1.setId(1L); - project1.setVisible(true); - ProjectEntity project2 = new ProjectEntity(); - project2.setName("Project 2"); - project2.setId(2L); - project2.setVisible(true); - project2.setCourseId(1L); - ProjectEntity project3 = new ProjectEntity(); - project3.setName("Project 3"); - project3.setId(3L); - project3.setVisible(true); - List projects = new ArrayList<>(); - projects.add(project1); - projects.add(project2); - projects.add(project3); - // users - UserEntity user = new UserEntity("Test", "De Tester", "test.tester@test.com", UserRole.student, - "azure"); - user.setId(1L); - //check results - CourseEntity courseEntity = new CourseEntity(); - CheckResult checkResult = new CheckResult<>(HttpStatus.OK, "TestProject", - project2); - CheckResult> courseCheck = new CheckResult<>(HttpStatus.FORBIDDEN, "testCourseForbidden", - new Pair<>(courseEntity, CourseRelation.enrolled)); - - // Mock repository behavior - when(projectUtil.canGetProject(2L, user)).thenReturn(checkResult); - when(courseUtil.getCourseIfUserInCourse(1L, user)).thenReturn(courseCheck); - when(auth.getUserEntity()).thenReturn(user); - when(entityToJsonConverter.projectEntityToProjectResponseJson(project2, courseCheck.getData().getFirst(), - user)).thenReturn(new ProjectResponseJson( - new CourseReferenceJson("TestCourse", ApiRoutes.COURSE_BASE_PATH + "/" + 1L, 1L, OffsetDateTime.now()), - OffsetDateTime.MAX, - "Test", 2L, "TestProject", "testUrl", "testUrl", 0, true, new ProjectProgressJson(0, 0), - 1L, 1L)); - - // Call controller method - ResponseEntity response = projectController.getProjectById(2L, auth); - // Verify response - assertEquals(HttpStatus.FORBIDDEN, response.getStatusCode()); - assertEquals("testCourseForbidden", Objects.requireNonNull(response.getBody()).toString()); - - } - - @Test - public void testCreateProjectShouldMakeProject() { - // Mock data - long courseId = 1L; - ProjectJson projectJson = - new ProjectJson("Test Project", "Test Description", 1L, 1L, true, 100, OffsetDateTime.MAX); - ProjectEntity projectEntity = - new ProjectEntity(1, "Test Project", "Test Description", 1L, 1L, true, 100, - OffsetDateTime.MAX); - Auth auth = mock(Auth.class); - UserEntity user = new UserEntity(); - user.setId(1L); - when(auth.getUserEntity()).thenReturn(user); - - CourseEntity courseEntity = new CourseEntity(); - courseEntity.setId(courseId); - - CheckResult checkAcces = new CheckResult<>(HttpStatus.OK, "TestIsAdmin", - courseEntity); - - CheckResult checkResult = new CheckResult<>(HttpStatus.OK, "TestProjectJson", null); - - // Mock repository behavior - when(projectRepository.save(projectEntity)).thenReturn(projectEntity); - - when(courseUtil.getCourseIfAdmin(courseId, user)).thenReturn(checkAcces); - when(projectUtil.checkProjectJson(projectJson, courseId)).thenReturn(checkResult); - when(courseRepository.findById(courseId)).thenReturn(Optional.of(courseEntity)); - when(courseUserRepository.findById(ArgumentMatchers.any(CourseUserId.class))).thenReturn( - Optional.of(new CourseUserEntity(1, 1, CourseRelation.course_admin))); - when(groupClusterRepository.findById(projectJson.getGroupClusterId())).thenReturn( - Optional.of(new GroupClusterEntity(1L, 20, "Testcluster", 10))); - when(projectRepository.save(ArgumentMatchers.any(ProjectEntity.class))).thenReturn( - new ProjectEntity(1, "Test Project", "Test Description", 1L, 1L, true, 100, - OffsetDateTime.MAX)); - // Call controller method - ResponseEntity responseEntity = projectController.createProject(courseId, projectJson, - auth); - - // Verify response - assertEquals(HttpStatus.OK, responseEntity.getStatusCode()); - } - - - @Test - public void testCreateProjectShouldFailReasonCanNotGetCourse() { - // Mock data - long courseId = 1L; - ProjectJson projectJson = - new ProjectJson("Test Project", "Test Description", 1L, 1L, true, 100, OffsetDateTime.MAX); - ProjectEntity projectEntity = - new ProjectEntity(1, "Test Project", "Test Description", 1L, 1L, true, 100, - OffsetDateTime.MAX); - Auth auth = mock(Auth.class); - UserEntity user = new UserEntity(); - user.setId(1L); - when(auth.getUserEntity()).thenReturn(user); - - CourseEntity courseEntity = new CourseEntity(); - courseEntity.setId(courseId); - - CheckResult checkAcces = new CheckResult<>(HttpStatus.FORBIDDEN, "TestIsAdmin", - courseEntity); - - CheckResult checkResult = new CheckResult<>(HttpStatus.OK, "TestProjectJson", null); - - // Mock repository behavior - when(projectRepository.save(projectEntity)).thenReturn(projectEntity); - - when(courseUtil.getCourseIfAdmin(courseId, user)).thenReturn(checkAcces); - when(projectUtil.checkProjectJson(projectJson, courseId)).thenReturn(checkResult); - when(courseRepository.findById(courseId)).thenReturn(Optional.of(courseEntity)); - when(courseUserRepository.findById(ArgumentMatchers.any(CourseUserId.class))).thenReturn( - Optional.of(new CourseUserEntity(1, 1, CourseRelation.course_admin))); - when(groupClusterRepository.findById(projectJson.getGroupClusterId())).thenReturn( - Optional.of(new GroupClusterEntity(1L, 20, "Testcluster", 10))); - when(projectRepository.save(ArgumentMatchers.any(ProjectEntity.class))).thenReturn( - new ProjectEntity(1, "Test Project", "Test Description", 1L, 1L, true, 100, - OffsetDateTime.MAX)); - // Call controller method - ResponseEntity responseEntity = projectController.createProject(courseId, projectJson, - auth); - - // Verify response - assertEquals(HttpStatus.FORBIDDEN, responseEntity.getStatusCode()); - } - - @Test - public void testCreateProjectShouldFailReasonCanNotGetProjectJson() { - // Mock data - long courseId = 1L; - ProjectJson projectJson = - new ProjectJson("Test Project", "Test Description", 1L, 1L, true, 100, OffsetDateTime.MAX); - ProjectEntity projectEntity = - new ProjectEntity(1, "Test Project", "Test Description", 1L, 1L, true, 100, - OffsetDateTime.MAX); - Auth auth = mock(Auth.class); - UserEntity user = new UserEntity(); - user.setId(1L); - when(auth.getUserEntity()).thenReturn(user); - - CourseEntity courseEntity = new CourseEntity(); - courseEntity.setId(courseId); - - CheckResult checkAcces = new CheckResult<>(HttpStatus.OK, "TestIsAdmin", - courseEntity); - - CheckResult checkResult = new CheckResult<>(HttpStatus.FORBIDDEN, "TestProjectJson", - null); - - // Mock repository behavior - when(projectRepository.save(projectEntity)).thenReturn(projectEntity); - - when(courseUtil.getCourseIfAdmin(courseId, user)).thenReturn(checkAcces); - when(projectUtil.checkProjectJson(projectJson, courseId)).thenReturn(checkResult); - when(courseRepository.findById(courseId)).thenReturn(Optional.of(courseEntity)); - when(courseUserRepository.findById(ArgumentMatchers.any(CourseUserId.class))).thenReturn( - Optional.of(new CourseUserEntity(1, 1, CourseRelation.course_admin))); - when(groupClusterRepository.findById(projectJson.getGroupClusterId())).thenReturn( - Optional.of(new GroupClusterEntity(1L, 20, "Testcluster", 10))); - when(projectRepository.save(ArgumentMatchers.any(ProjectEntity.class))).thenReturn( - new ProjectEntity(1, "Test Project", "Test Description", 1L, 1L, true, 100, - OffsetDateTime.MAX)); - // Call controller method - ResponseEntity responseEntity = projectController.createProject(courseId, projectJson, - auth); - - // Verify response - assertEquals(HttpStatus.FORBIDDEN, responseEntity.getStatusCode()); - } + setUpController(projectController); + + courseEntity = new CourseEntity("courseName", "courseUrl", 2020); + courseEntity.setId(24L); + courseEntity2 = new CourseEntity("courseName2", "courseUrl2", 2021); + courseEntity2.setId(25L); + + projectEntity = new ProjectEntity( + courseEntity.getId(), + "projectName", + "projectDescription", + groupClusterId, + 38L, + true, + 34, + OffsetDateTime.now() + ); + projectEntity.setId(64); + projectResponseJson = new ProjectResponseJson( + new CourseReferenceJson(courseEntity.getName(), "course1URL", courseEntity.getId(), null), + OffsetDateTime.now(), + projectEntity.getName(), + projectEntity.getId(), + projectEntity.getDescription(), + "submissionUrl", + "testUrl", + projectEntity.getMaxScore(), + projectEntity.isVisible(), + new ProjectProgressJson(0, 0), + 1L, + groupClusterId + ); + + projectEntity2 = new ProjectEntity( + courseEntity2.getId(), + "projectName2", + "projectDescription2", + groupClusterId, + 39L, + true, + 32, + OffsetDateTime.now() + ); + projectEntity2.setId(65); + projectResponseJson2 = new ProjectResponseJson( + new CourseReferenceJson(courseEntity2.getName(), "course2URL", courseEntity2.getId(), null), + OffsetDateTime.now(), + projectEntity2.getName(), + projectEntity2.getId(), + projectEntity2.getDescription(), + "submissionUrl", + "testUrl", + projectEntity2.getMaxScore(), + projectEntity2.isVisible(), + new ProjectProgressJson(0, 0), + 1L, + groupClusterId + ); - @Test - public void testCreateProjectShouldFailReasonInternalServer1() { - // Mock data - long courseId = 1L; - ProjectJson projectJson = - new ProjectJson("Test Project", "Test Description", 1L, 1L, true, 100, OffsetDateTime.MAX); - ProjectEntity projectEntity = - new ProjectEntity(1, "Test Project", "Test Description", 1L, 1L, true, 100, - OffsetDateTime.MAX); - Auth auth = mock(Auth.class); - UserEntity user = new UserEntity(); - user.setId(1L); - when(auth.getUserEntity()).thenReturn(user); - - CheckResult checkResult = new CheckResult<>(HttpStatus.FORBIDDEN, "TestProjectJson", - null); - - // Mock repository behavior - when(projectRepository.save(projectEntity)).thenReturn(projectEntity); - - when(projectUtil.checkProjectJson(projectJson, courseId)).thenReturn(checkResult); - when(courseUserRepository.findById(ArgumentMatchers.any(CourseUserId.class))).thenReturn( - Optional.of(new CourseUserEntity(1, 1, CourseRelation.course_admin))); - when(groupClusterRepository.findById(projectJson.getGroupClusterId())).thenReturn( - Optional.of(new GroupClusterEntity(1L, 20, "Testcluster", 10))); - when(projectRepository.save(ArgumentMatchers.any(ProjectEntity.class))).thenReturn( - new ProjectEntity(1, "Test Project", "Test Description", 1L, 1L, true, 100, - OffsetDateTime.MAX)); - // Call controller method - ResponseEntity responseEntity = projectController.createProject(courseId, projectJson, - auth); - - // Verify response - assertEquals(HttpStatus.INTERNAL_SERVER_ERROR, responseEntity.getStatusCode()); } @Test - public void testCreateProjectShouldFailReasonInternalServer2() { - // Mock data - long courseId = 1L; - ProjectJson projectJson = - new ProjectJson("Test Project", "Test Description", null, 1L, true, 100, - OffsetDateTime.MAX); - ProjectEntity projectEntity = - new ProjectEntity(1, "Test Project", "Test Description", 1L, 1L, true, 100, - OffsetDateTime.MAX); - Auth auth = mock(Auth.class); - UserEntity user = new UserEntity(); - user.setId(1L); - when(auth.getUserEntity()).thenReturn(user); - - CourseEntity courseEntity = new CourseEntity(); - courseEntity.setId(courseId); - - CheckResult checkAcces = new CheckResult<>(HttpStatus.OK, "TestIsAdmin", - courseEntity); - - CheckResult checkResult = new CheckResult<>(HttpStatus.OK, "TestProjectJson", - null); - - // Mock repository behavior - when(projectRepository.save(projectEntity)).thenReturn(projectEntity); - when(courseUtil.getCourseIfAdmin(courseId, user)).thenReturn(checkAcces); - when(projectUtil.checkProjectJson(projectJson, courseId)).thenReturn(checkResult); - when(courseRepository.findById(courseId)).thenReturn(Optional.of(courseEntity)); - when(courseUserRepository.findById(ArgumentMatchers.any(CourseUserId.class))).thenReturn( - Optional.of(new CourseUserEntity(1, 1, CourseRelation.course_admin))); - when(groupClusterRepository.findById(projectJson.getGroupClusterId())).thenReturn( - Optional.of(new GroupClusterEntity(1L, 20, "Testcluster", 10))); - when(projectRepository.save(ArgumentMatchers.any(ProjectEntity.class))).thenReturn( - new ProjectEntity(1, "Test Project", "Test Description", 1L, 1L, true, 100, - OffsetDateTime.MAX)); - - // Call controller method - ResponseEntity responseEntity = projectController.createProject(courseId, projectJson, - auth); - - // Verify response - assertEquals(HttpStatus.INTERNAL_SERVER_ERROR, responseEntity.getStatusCode()); - assertEquals("Internal error while creating project without group, contact an administrator", - responseEntity.getBody()); + void testGetProjects() throws Exception { + String url = ApiRoutes.PROJECT_BASE_PATH; + List projectEntities = List.of(projectEntity, projectEntity2); + ProjectResponseJsonWithStatus projectJsonWithStatus = new ProjectResponseJsonWithStatus( + projectResponseJson2, + "completed" + ); + when(courseUtil.getCourseIfUserInCourse(courseEntity.getId(), getMockUser())).thenReturn( + new CheckResult<>(HttpStatus.OK, "", new Pair<>(courseEntity, CourseRelation.creator)) + ); + when(courseUtil.getCourseIfUserInCourse(courseEntity2.getId(), getMockUser())).thenReturn( + new CheckResult<>(HttpStatus.OK, "", new Pair<>(courseEntity2, CourseRelation.enrolled)) + ); + when(projectRepository.findProjectsByUserId(getMockUser().getId())).thenReturn(projectEntities); + when(entityToJsonConverter.projectEntityToProjectResponseJson(projectEntity, courseEntity, getMockUser())) + .thenReturn(projectResponseJson); + when(entityToJsonConverter.projectEntityToProjectResponseJsonWithStatus(projectEntity2, courseEntity2, getMockUser())) + .thenReturn(projectJsonWithStatus); + + /* Returns the user's projects */ + UserProjectsJson userProjectsJson = new UserProjectsJson( + List.of(projectJsonWithStatus), + List.of(projectResponseJson) + ); + mockMvc.perform(MockMvcRequestBuilders.get(url)) + .andExpect(status().isOk()) + .andExpect(content().contentType(MediaType.APPLICATION_JSON)) + .andExpect(content().json(objectMapper.writeValueAsString(userProjectsJson))); + + /* If project is visible and role enrolled, don't return it */ + projectEntity2.setVisible(false); + userProjectsJson = new UserProjectsJson( + Collections.emptyList(), + List.of(projectResponseJson) + ); + mockMvc.perform(MockMvcRequestBuilders.get(url)) + .andExpect(status().isOk()) + .andExpect(content().contentType(MediaType.APPLICATION_JSON)) + .andExpect(content().json(objectMapper.writeValueAsString(userProjectsJson))); + + /* If a coursecheck fails, return corresponding status */ + when(courseUtil.getCourseIfUserInCourse(courseEntity.getId(), getMockUser())).thenReturn( + new CheckResult<>(HttpStatus.I_AM_A_TEAPOT, "", null) + ); + mockMvc.perform(MockMvcRequestBuilders.get(url)) + .andExpect(status().isIAmATeapot()); } - @Test - void testPutProjectByIdShouldUpdateProject() { - // Mock data - long projectId = 1L; - long userId = 1L; - long courseId = 1L; - Auth auth = mock(Auth.class); - UserEntity user = new UserEntity(); - user.setId(userId); - ProjectEntity projectEntity = new ProjectEntity(1, "Test Project", "old description", 1L, 1L, - false, 100, OffsetDateTime.MAX); - CourseEntity courseEntity = new CourseEntity(); - courseEntity.setId(courseId); - ProjectJson projectJson = new ProjectJson("Test Project", "new description", 1L, 1L, true, 100, - OffsetDateTime.MAX); - - ProjectEntity newProjectEntity = new ProjectEntity(1, "Test Project", "new description", 1L, 1L, - true, 100, OffsetDateTime.MAX); - - CheckResult checkResult = new CheckResult<>(HttpStatus.OK, "TestProject", - projectEntity); - CheckResult checkProject = new CheckResult<>(HttpStatus.OK, "TestProjectJson", null); - - // Mock behavior - when(auth.getUserEntity()).thenReturn(user); - when(projectUtil.getProjectIfAdmin(projectId, user)).thenReturn(checkResult); - when(projectUtil.checkProjectJson(projectJson, projectEntity.getCourseId())).thenReturn( - checkProject); - when(projectRepository.save(projectEntity)).thenReturn(projectEntity); - when(courseRepository.findById(projectId)).thenReturn(Optional.of(courseEntity)); - when(entityToJsonConverter.projectEntityToProjectResponseJson(any(), any(), any())).thenReturn( - new ProjectResponseJson( - new CourseReferenceJson("TestCourse", ApiRoutes.COURSE_BASE_PATH + "/" + 1L, 1L, OffsetDateTime.now()), - OffsetDateTime.MAX, - "Test", 2L, "TestProject", "testUrl", "testUrl", 0, true, new ProjectProgressJson(0, 0), - 1L, 1L)); - // Call controller method - ResponseEntity responseEntity = projectController.putProjectById(projectId, projectJson, - auth); - - // Verify response - assertEquals(HttpStatus.OK, responseEntity.getStatusCode()); - - + void testGetProject() throws Exception { + String url = ApiRoutes.PROJECT_BASE_PATH + "/" + projectEntity.getId(); + + /* If user can get project, return project */ + when(projectUtil.canGetProject(projectEntity.getId(), getMockUser())).thenReturn( + new CheckResult<>(HttpStatus.OK, "", projectEntity) + ); + when(courseUtil.getCourseIfUserInCourse(projectEntity.getCourseId(), getMockUser())).thenReturn( + new CheckResult<>(HttpStatus.OK, "", new Pair<>(courseEntity, CourseRelation.enrolled)) + ); + when(entityToJsonConverter.projectEntityToProjectResponseJson(projectEntity, courseEntity, getMockUser())) + .thenReturn(projectResponseJson); + mockMvc.perform(MockMvcRequestBuilders.get(url)) + .andExpect(status().isOk()) + .andExpect(content().contentType(MediaType.APPLICATION_JSON)) + .andExpect(content().json(objectMapper.writeValueAsString(projectResponseJson))); + + /* If user is enrolled and project not visible, return forbidden */ + projectEntity.setVisible(false); + mockMvc.perform(MockMvcRequestBuilders.get(url)) + .andExpect(status().isNotFound()); + + /* If user is not enrolled and project not visible, return project */ + when(courseUtil.getCourseIfUserInCourse(projectEntity.getCourseId(), getMockUser())).thenReturn( + new CheckResult<>(HttpStatus.OK, "", new Pair<>(courseEntity, CourseRelation.course_admin)) + ); + mockMvc.perform(MockMvcRequestBuilders.get(url)) + .andExpect(status().isOk()) + .andExpect(content().contentType(MediaType.APPLICATION_JSON)) + .andExpect(content().json(objectMapper.writeValueAsString(projectResponseJson))); + + /* If get course with relation check fails, return correpsonding status */ + when(courseUtil.getCourseIfUserInCourse(projectEntity.getCourseId(), getMockUser())).thenReturn( + new CheckResult<>(HttpStatus.I_AM_A_TEAPOT, "", null) + ); + mockMvc.perform(MockMvcRequestBuilders.get(url)) + .andExpect(status().isIAmATeapot()); + + /* If user can't get project, return corresponding status */ + when(projectUtil.canGetProject(projectEntity.getId(), getMockUser())).thenReturn( + new CheckResult<>(HttpStatus.I_AM_A_TEAPOT, "", null) + ); + mockMvc.perform(MockMvcRequestBuilders.get(url)) + .andExpect(status().isIAmATeapot()); } @Test - void testPatchProjectByIdShouldUpdateProject() { - // Mock data - long projectId = 1L; - long userId = 1L; - long courseId = 1L; - Auth auth = mock(Auth.class); - UserEntity user = new UserEntity(); - user.setId(userId); - ProjectEntity projectEntity = new ProjectEntity(1, "Test Project", "old description", 1L, 1L, - false, 100, OffsetDateTime.MAX); - CourseEntity courseEntity = new CourseEntity(); - courseEntity.setId(courseId); - ProjectJson projectJson = new ProjectJson("Test Project", "new description", null, 1L, true, - 100, - OffsetDateTime.MAX); - - ProjectEntity newProjectEntity = new ProjectEntity(1, "Test Project", "new description", 1L, 1L, - true, 100, OffsetDateTime.MAX); - - CheckResult checkResult = new CheckResult<>(HttpStatus.OK, "TestProject", - projectEntity); - CheckResult checkProject = new CheckResult<>(HttpStatus.OK, "TestProjectJson", null); - - // Mock behavior - when(auth.getUserEntity()).thenReturn(user); - when(projectUtil.getProjectIfAdmin(projectId, user)).thenReturn(checkResult); - when(projectUtil.checkProjectJson(projectJson, projectEntity.getCourseId())).thenReturn( - checkProject); - when(projectRepository.save(projectEntity)).thenReturn(projectEntity); - when(courseRepository.findById(projectId)).thenReturn(Optional.of(courseEntity)); - when(entityToJsonConverter.projectEntityToProjectResponseJson(any(), any(), any())).thenReturn( - new ProjectResponseJson( - new CourseReferenceJson("TestCourse", ApiRoutes.COURSE_BASE_PATH + "/" + 1L, 1L, OffsetDateTime.now()), - OffsetDateTime.MAX, - "Test", 2L, "TestProject", "testUrl", "testUrl", 0, true, new ProjectProgressJson(0, 0), - 1L, 1L)); - // Call controller method - ResponseEntity responseEntity = projectController.patchProjectById(projectId, projectJson, - auth); - - // Verify response - assertEquals(HttpStatus.OK, responseEntity.getStatusCode()); + public void testCreateProject() throws Exception { + String url = ApiRoutes.COURSE_BASE_PATH + "/" + courseEntity.getId() + "/projects"; + String request = "{\n" + + " \"name\": \"" + projectEntity.getName() + "\",\n" + + " \"description\": \"" + projectEntity.getDescription() + "\",\n" + + " \"groupClusterId\": " + projectEntity.getGroupClusterId() + ",\n" + + " \"visible\": " + projectEntity.isVisible() + ",\n" + + " \"maxScore\": " + projectEntity.getMaxScore() + ",\n" + + " \"deadline\": \"" + projectEntity.getDeadline() + "\"\n" + + "}"; + + /* If all checks succeed, create course */ + when(courseUtil.getCourseIfAdmin(courseEntity.getId(), getMockUser())).thenReturn( + new CheckResult<>(HttpStatus.OK, "", courseEntity) + ); + when(projectUtil.checkProjectJson(argThat( + json -> json.getName().equals(projectEntity.getName() ) + && json.getDescription().equals(projectEntity.getDescription()) + && json.getGroupClusterId().equals(projectEntity.getGroupClusterId()) + && json.isVisible().equals(projectEntity.isVisible()) + && json.getMaxScore().equals(projectEntity.getMaxScore()) + && json.getDeadline().toInstant().equals(projectEntity.getDeadline().toInstant()) + ), eq(courseEntity.getId()))).thenReturn(new CheckResult<>(HttpStatus.OK, "", null)); + when(projectRepository.save(any())).thenReturn(projectEntity); + when(entityToJsonConverter.projectEntityToProjectResponseJson(projectEntity, courseEntity, + getMockUser())) + .thenReturn(projectResponseJson); + mockMvc.perform(MockMvcRequestBuilders.post(url) + .contentType(MediaType.APPLICATION_JSON) + .content(request)) + .andExpect(status().isOk()) + .andExpect(content().contentType(MediaType.APPLICATION_JSON)) + .andExpect(content().json(objectMapper.writeValueAsString(projectResponseJson))); + verify(projectRepository).save(argThat( + project -> project.getName().equals(projectEntity.getName()) + && project.getDescription().equals(projectEntity.getDescription()) + && project.getGroupClusterId() == projectEntity.getGroupClusterId() + && project.isVisible().equals(projectEntity.isVisible()) + && project.getMaxScore().equals(projectEntity.getMaxScore()) + && project.getDeadline().toInstant().equals(projectEntity.getDeadline().toInstant()) + )); + + /* If groupClusterId is not provided, use invalid groupClusterId */ + reset(projectUtil); + request = "{\n" + + " \"name\": \"" + projectEntity.getName() + "\",\n" + + " \"description\": \"" + projectEntity.getDescription() + "\",\n" + + " \"visible\": " + projectEntity.isVisible() + ",\n" + + " \"maxScore\": " + projectEntity.getMaxScore() + ",\n" + + " \"deadline\": \"" + projectEntity.getDeadline() + "\"\n" + + "}"; + GroupClusterEntity individualClusterEntity = new GroupClusterEntity(courseEntity.getId(), 2, "Individual", 1); + + when(groupClusterRepository.findIndividualClusterByCourseId(courseEntity.getId())).thenReturn( + Optional.of(individualClusterEntity)); + when(projectUtil.checkProjectJson(argThat( + json -> json.getName().equals(projectEntity.getName() ) + && json.getDescription().equals(projectEntity.getDescription()) + && json.getGroupClusterId().equals(individualClusterEntity.getId()) + && json.isVisible().equals(projectEntity.isVisible()) + && json.getMaxScore().equals(projectEntity.getMaxScore()) + && json.getDeadline().toInstant().equals(projectEntity.getDeadline().toInstant()) + ), eq(courseEntity.getId()))).thenReturn(new CheckResult<>(HttpStatus.OK, "", null)); + mockMvc.perform(MockMvcRequestBuilders.post(url) + .contentType(MediaType.APPLICATION_JSON) + .content(request)) + .andExpect(status().isOk()); + verify(projectRepository).save(argThat( + project -> project.getName().equals(projectEntity.getName()) + && project.getDescription().equals(projectEntity.getDescription()) + && project.getGroupClusterId() == individualClusterEntity.getId() + && project.isVisible().equals(projectEntity.isVisible()) + && project.getMaxScore().equals(projectEntity.getMaxScore()) + && project.getDeadline().toInstant().equals(projectEntity.getDeadline().toInstant()) + )); + + /* If unexpected error occurs, return internal server error */ + doThrow(new RuntimeException()).when(projectRepository).save(any()); + mockMvc.perform(MockMvcRequestBuilders.post(url) + .contentType(MediaType.APPLICATION_JSON) + .content(request)) + .andExpect(status().isInternalServerError()); + + /* If project json is invalid, return corresponding status */ + reset(projectUtil); + when(projectUtil.checkProjectJson(any(), anyLong())).thenReturn( + new CheckResult<>(HttpStatus.BAD_REQUEST, "", null) + ); + mockMvc.perform(MockMvcRequestBuilders.post(url) + .contentType(MediaType.APPLICATION_JSON) + .content(request)) + .andExpect(status().isBadRequest()); + + /* If no individual cluster is found, return internal server error */ + when(groupClusterRepository.findIndividualClusterByCourseId(courseEntity.getId())).thenReturn(Optional.empty()); + mockMvc.perform(MockMvcRequestBuilders.post(url) + .contentType(MediaType.APPLICATION_JSON) + .content(request)) + .andExpect(status().isInternalServerError()); + + /* If user no access to course, return corresponding status code */ + when(courseUtil.getCourseIfAdmin(courseEntity.getId(), getMockUser())).thenReturn( + new CheckResult<>(HttpStatus.I_AM_A_TEAPOT, "", null) + ); + mockMvc.perform(MockMvcRequestBuilders.post(url) + .contentType(MediaType.APPLICATION_JSON) + .content(request)) + .andExpect(status().isIAmATeapot()); } - @Test - void testDeleteProjectByIdShouldDeleteProject() { - // Mock data - long projectId = 1L; - long userId = 1L; - long courseId = 1L; - Auth auth = mock(Auth.class); - UserEntity user = new UserEntity(); - user.setId(userId); - ProjectEntity projectEntity = new ProjectEntity(1, "Test Project", "old description", 1L, 1L, - false, 100, OffsetDateTime.MAX); - CourseEntity courseEntity = new CourseEntity(); - courseEntity.setId(courseId); - - CheckResult projectCheck = new CheckResult<>(HttpStatus.OK, "TestProject", - projectEntity); - CheckResult deleteResult = new CheckResult<>(HttpStatus.OK, "TestDelete", null); - // Mock behavior - when(auth.getUserEntity()).thenReturn(user); - when(projectUtil.getProjectIfAdmin(projectId, user)).thenReturn(projectCheck); - when(commonDatabaseActions.deleteProject(projectId)).thenReturn(deleteResult); - - // Call controller method - ResponseEntity responseEntity = projectController.deleteProjectById(projectId, auth); - - // Verify response - assertEquals(HttpStatus.OK, responseEntity.getStatusCode()); + void testPutProjectById() throws Exception { + String url = ApiRoutes.PROJECT_BASE_PATH + "/" + projectEntity.getId(); + OffsetDateTime newDeadline = OffsetDateTime.now().plusDays(1); + String request = "{\n" + + " \"name\": \"" + "UpdatedName" + "\",\n" + + " \"description\": \"" + "UpdatedDescription" + "\",\n" + + " \"groupClusterId\": " + groupClusterId * 4 + ",\n" + + " \"visible\": " + false + ",\n" + + " \"maxScore\": " + (projectEntity.getMaxScore() + 33) + ",\n" + + " \"deadline\": \"" + newDeadline + "\"\n" + + "}"; + String orginalName = projectEntity.getName(); + String orginalDescription = projectEntity.getDescription(); + long orginalGroupClusterId = projectEntity.getGroupClusterId(); + boolean orginalVisible = projectEntity.isVisible(); + int orginalMaxScore = projectEntity.getMaxScore(); + OffsetDateTime orginalDeadline = projectEntity.getDeadline(); + ProjectResponseJson updatedJson = new ProjectResponseJson( + new CourseReferenceJson(courseEntity.getName(), "course1URL", courseEntity.getId(), null), + newDeadline, + "UpdatedName", + projectEntity.getId(), + "UpdatedDescription", + "submissionUrl", + "testUrl", + projectEntity.getMaxScore() + 33, + false, + new ProjectProgressJson(0, 0), + 1L, + groupClusterId * 4 + ); + /* If all checks pass, update and return the project */ + when(projectUtil.getProjectIfAdmin(projectEntity.getId(), getMockUser())).thenReturn( + new CheckResult<>(HttpStatus.OK, "", projectEntity) + ); + when(projectUtil.checkProjectJson(argThat( + json -> json.getName().equals("UpdatedName") + && json.getDescription().equals("UpdatedDescription") + && json.getGroupClusterId().equals(groupClusterId * 4) + && json.isVisible().equals(false) + && json.getMaxScore().equals(projectEntity.getMaxScore() + 33) + && json.getDeadline().toInstant().equals(newDeadline.toInstant()) + ), eq(courseEntity.getId()))).thenReturn(new CheckResult<>(HttpStatus.OK, "", null)); + when(projectRepository.save(any())).thenReturn(projectEntity); + when(courseRepository.findById(courseEntity.getId())).thenReturn(Optional.of(courseEntity)); + when(entityToJsonConverter.projectEntityToProjectResponseJson(projectEntity, courseEntity, + getMockUser())) + .thenReturn(updatedJson); + mockMvc.perform(MockMvcRequestBuilders.put(url) + .contentType(MediaType.APPLICATION_JSON) + .content(request)) + .andExpect(status().isOk()) + .andExpect(content().contentType(MediaType.APPLICATION_JSON)) + .andExpect(content().json(objectMapper.writeValueAsString(updatedJson))); + assertEquals(projectEntity.getName(), "UpdatedName"); + assertEquals(projectEntity.getDescription(), "UpdatedDescription"); + assertEquals(projectEntity.getGroupClusterId(), groupClusterId * 4); + assertEquals(projectEntity.isVisible(), false); + assertEquals(projectEntity.getMaxScore(), orginalMaxScore + 33); + assertEquals(projectEntity.getDeadline().toInstant(), newDeadline.toInstant()); + verify(projectRepository, times(1)).save(projectEntity); + projectEntity.setName(orginalName); + projectEntity.setDescription(orginalDescription); + projectEntity.setGroupClusterId(orginalGroupClusterId); + projectEntity.setVisible(orginalVisible); + projectEntity.setMaxScore(orginalMaxScore); + projectEntity.setDeadline(orginalDeadline); + + /* If groupClusterId is not provided, use invalid groupClusterId */ + reset(projectUtil); + request = "{\n" + + " \"name\": \"" + "UpdatedName" + "\",\n" + + " \"description\": \"" + "UpdatedDescription" + "\",\n" + + " \"visible\": " + false + ",\n" + + " \"maxScore\": " + (projectEntity.getMaxScore() + 33) + ",\n" + + " \"deadline\": \"" + newDeadline + "\"\n" + + "}"; + GroupClusterEntity individualClusterEntity = new GroupClusterEntity(courseEntity.getId(), 2, "Individual", 1); + + when(projectUtil.getProjectIfAdmin(projectEntity.getId(), getMockUser())).thenReturn( + new CheckResult<>(HttpStatus.OK, "", projectEntity) + ); + when(groupClusterRepository.findIndividualClusterByCourseId(courseEntity.getId())).thenReturn( + Optional.of(individualClusterEntity)); + when(projectUtil.checkProjectJson(argThat( + json -> json.getName().equals("UpdatedName") + && json.getDescription().equals("UpdatedDescription") + && json.getGroupClusterId().equals(individualClusterEntity.getId()) + && json.isVisible().equals(false) + && json.getMaxScore().equals(projectEntity.getMaxScore() + 33) + && json.getDeadline().toInstant().equals(newDeadline.toInstant()) + ), eq(courseEntity.getId()))).thenReturn(new CheckResult<>(HttpStatus.OK, "", null)); + + mockMvc.perform(MockMvcRequestBuilders.put(url) + .contentType(MediaType.APPLICATION_JSON) + .content(request)) + .andExpect(status().isOk()); + assertEquals(projectEntity.getName(), "UpdatedName"); + assertEquals(projectEntity.getDescription(), "UpdatedDescription"); + assertEquals(projectEntity.getGroupClusterId(), individualClusterEntity.getId()); + assertEquals(projectEntity.isVisible(), false); + assertEquals(projectEntity.getMaxScore(), orginalMaxScore + 33); + assertEquals(projectEntity.getDeadline().toInstant(), newDeadline.toInstant()); + verify(projectRepository, times(2)).save(projectEntity); + projectEntity.setGroupClusterId(orginalGroupClusterId); + + /* If project json is invalid, return corresponding status */ + reset(projectUtil); + when(projectUtil.getProjectIfAdmin(projectEntity.getId(), getMockUser())).thenReturn( + new CheckResult<>(HttpStatus.OK, "", projectEntity) + ); + when(projectUtil.checkProjectJson(any(), anyLong())).thenReturn( + new CheckResult<>(HttpStatus.BAD_REQUEST, "", null) + ); + mockMvc.perform(MockMvcRequestBuilders.put(url) + .contentType(MediaType.APPLICATION_JSON) + .content(request)) + .andExpect(status().isBadRequest()); + + /* If individual cluster is not found, return internal server error */ + when(groupClusterRepository.findIndividualClusterByCourseId(courseEntity.getId())).thenReturn(Optional.empty()); + mockMvc.perform(MockMvcRequestBuilders.put(url) + .contentType(MediaType.APPLICATION_JSON) + .content(request)) + .andExpect(status().isInternalServerError()); + + /* If user has no acces to project, return corresponding status */ + when(projectUtil.getProjectIfAdmin(projectEntity.getId(), getMockUser())).thenReturn( + new CheckResult<>(HttpStatus.I_AM_A_TEAPOT, "", null) + ); + mockMvc.perform(MockMvcRequestBuilders.put(url) + .contentType(MediaType.APPLICATION_JSON) + .content(request)) + .andExpect(status().isIAmATeapot()); } - @Test - void testDeleteProjectByIdShouldFailReasonCanNotGetProject() { - // Mock data - long projectId = 1L; - long userId = 1L; - long courseId = 1L; - Auth auth = mock(Auth.class); - UserEntity user = new UserEntity(); - user.setId(userId); - ProjectEntity projectEntity = new ProjectEntity(1, "Test Project", "old description", 1L, 1L, - false, 100, OffsetDateTime.MAX); - CourseEntity courseEntity = new CourseEntity(); - courseEntity.setId(courseId); - - CheckResult projectCheck = new CheckResult<>(HttpStatus.FORBIDDEN, "TestProject", - projectEntity); - CheckResult deleteResult = new CheckResult<>(HttpStatus.OK, "TestDelete", null); - // Mock behavior - when(auth.getUserEntity()).thenReturn(user); - when(projectUtil.getProjectIfAdmin(projectId, user)).thenReturn(projectCheck); - when(commonDatabaseActions.deleteProject(projectId)).thenReturn(deleteResult); - - // Call controller method - ResponseEntity responseEntity = projectController.deleteProjectById(projectId, auth); - - // Verify response - assertEquals(HttpStatus.FORBIDDEN, responseEntity.getStatusCode()); + @Test // Same as above but patch instead of put + void testPatchProjectById() throws Exception { + String url = ApiRoutes.PROJECT_BASE_PATH + "/" + projectEntity.getId(); + OffsetDateTime newDeadline = OffsetDateTime.now().plusDays(1); + String request = "{\n" + + " \"name\": \"" + "UpdatedName" + "\",\n" + + " \"description\": \"" + "UpdatedDescription" + "\",\n" + + " \"groupClusterId\": " + groupClusterId * 4 + ",\n" + + " \"visible\": " + false + ",\n" + + " \"maxScore\": " + (projectEntity.getMaxScore() + 33) + ",\n" + + " \"deadline\": \"" + newDeadline + "\"\n" + + "}"; + String orginalName = projectEntity.getName(); + String orginalDescription = projectEntity.getDescription(); + long orginalGroupClusterId = projectEntity.getGroupClusterId(); + boolean orginalVisible = projectEntity.isVisible(); + int orginalMaxScore = projectEntity.getMaxScore(); + OffsetDateTime orginalDeadline = projectEntity.getDeadline(); + ProjectResponseJson updatedJson = new ProjectResponseJson( + new CourseReferenceJson(courseEntity.getName(), "course1URL", courseEntity.getId(), null), + newDeadline, + "UpdatedName", + projectEntity.getId(), + "UpdatedDescription", + "submissionUrl", + "testUrl", + projectEntity.getMaxScore() + 33, + false, + new ProjectProgressJson(0, 0), + 1L, + groupClusterId * 4 + ); + /* If all checks pass, update and return the project */ + when(projectUtil.getProjectIfAdmin(projectEntity.getId(), getMockUser())).thenReturn( + new CheckResult<>(HttpStatus.OK, "", projectEntity) + ); + when(projectUtil.checkProjectJson(argThat( + json -> json.getName().equals("UpdatedName") + && json.getDescription().equals("UpdatedDescription") + && json.getGroupClusterId().equals(groupClusterId * 4) + && json.isVisible().equals(false) + && json.getMaxScore().equals(projectEntity.getMaxScore() + 33) + && json.getDeadline().toInstant().equals(newDeadline.toInstant()) + ), eq(courseEntity.getId()))).thenReturn(new CheckResult<>(HttpStatus.OK, "", null)); + when(projectRepository.save(any())).thenReturn(projectEntity); + when(courseRepository.findById(courseEntity.getId())).thenReturn(Optional.of(courseEntity)); + when(entityToJsonConverter.projectEntityToProjectResponseJson(projectEntity, courseEntity, + getMockUser())) + .thenReturn(updatedJson); + mockMvc.perform(MockMvcRequestBuilders.patch(url) + .contentType(MediaType.APPLICATION_JSON) + .content(request)) + .andExpect(status().isOk()) + .andExpect(content().contentType(MediaType.APPLICATION_JSON)) + .andExpect(content().json(objectMapper.writeValueAsString(updatedJson))); + assertEquals(projectEntity.getName(), "UpdatedName"); + assertEquals(projectEntity.getDescription(), "UpdatedDescription"); + assertEquals(projectEntity.getGroupClusterId(), groupClusterId * 4); + assertEquals(projectEntity.isVisible(), false); + assertEquals(projectEntity.getMaxScore(), orginalMaxScore + 33); + assertEquals(projectEntity.getDeadline().toInstant(), newDeadline.toInstant()); + verify(projectRepository, times(1)).save(projectEntity); + projectEntity.setName(orginalName); + projectEntity.setDescription(orginalDescription); + projectEntity.setGroupClusterId(orginalGroupClusterId); + projectEntity.setVisible(orginalVisible); + projectEntity.setMaxScore(orginalMaxScore); + projectEntity.setDeadline(orginalDeadline); + + /* If project json is invalid, return corresponding status */ + reset(projectUtil); + when(projectUtil.getProjectIfAdmin(projectEntity.getId(), getMockUser())).thenReturn( + new CheckResult<>(HttpStatus.OK, "", projectEntity) + ); + when(projectUtil.checkProjectJson(any(), anyLong())).thenReturn( + new CheckResult<>(HttpStatus.BAD_REQUEST, "", null) + ); + mockMvc.perform(MockMvcRequestBuilders.patch(url) + .contentType(MediaType.APPLICATION_JSON) + .content(request)) + .andExpect(status().isBadRequest()); + + /* Only update the fields that are provided */ + reset(projectUtil); + request = "{\n" + + " \"name\": \"" + "UpdatedName" + "\",\n" + + " \"description\": \"" + "UpdatedDescription" + "\"\n" + + "}"; + when(projectUtil.getProjectIfAdmin(projectEntity.getId(), getMockUser())).thenReturn( + new CheckResult<>(HttpStatus.OK, "", projectEntity) + ); + when(projectUtil.checkProjectJson(argThat( + json -> json.getName().equals("UpdatedName") + && json.getDescription().equals("UpdatedDescription") + && json.getGroupClusterId().equals(orginalGroupClusterId) + && json.isVisible().equals(orginalVisible) + && json.getMaxScore().equals(orginalMaxScore) + && json.getDeadline().toInstant().equals(orginalDeadline.toInstant()) + ), eq(courseEntity.getId()))).thenReturn(new CheckResult<>(HttpStatus.OK, "", null)); + mockMvc.perform(MockMvcRequestBuilders.patch(url) + .contentType(MediaType.APPLICATION_JSON) + .content(request)) + .andExpect(status().isOk()); + assertEquals(projectEntity.getName(), "UpdatedName"); + assertEquals(projectEntity.getDescription(), "UpdatedDescription"); + assertEquals(projectEntity.getGroupClusterId(), orginalGroupClusterId); + assertEquals(projectEntity.isVisible(), orginalVisible); + assertEquals(projectEntity.getMaxScore(), orginalMaxScore); + assertEquals(projectEntity.getDeadline().toInstant(), orginalDeadline.toInstant()); + verify(projectRepository, times(2)).save(projectEntity); + projectEntity.setName(orginalName); + projectEntity.setDescription(orginalDescription); + + /* Different fields not present */ + reset(projectUtil); + request = "{\n" + + " \"deadline\": \"" + newDeadline + "\"\n" + + "}"; + when(projectUtil.getProjectIfAdmin(projectEntity.getId(), getMockUser())).thenReturn( + new CheckResult<>(HttpStatus.OK, "", projectEntity) + ); + when(projectUtil.checkProjectJson(argThat( + json -> json.getName().equals(orginalName) + && json.getDescription().equals(orginalDescription) + && json.getGroupClusterId().equals(orginalGroupClusterId) + && json.isVisible().equals(orginalVisible) + && json.getMaxScore().equals(orginalMaxScore) + && json.getDeadline().toInstant().equals(newDeadline.toInstant()) + ), eq(courseEntity.getId()))).thenReturn(new CheckResult<>(HttpStatus.OK, "", null)); + mockMvc.perform(MockMvcRequestBuilders.patch(url) + .contentType(MediaType.APPLICATION_JSON) + .content(request)) + .andExpect(status().isOk()); + assertEquals(projectEntity.getName(), orginalName); + assertEquals(projectEntity.getDescription(), orginalDescription); + assertEquals(projectEntity.getGroupClusterId(), orginalGroupClusterId); + assertEquals(projectEntity.isVisible(), orginalVisible); + assertEquals(projectEntity.getMaxScore(), orginalMaxScore); + assertEquals(projectEntity.getDeadline().toInstant(), newDeadline.toInstant()); + verify(projectRepository, times(3)).save(projectEntity); + projectEntity.setDeadline(orginalDeadline); + + /* If user has no acces to project, return corresponding status */ + when(projectUtil.getProjectIfAdmin(projectEntity.getId(), getMockUser())).thenReturn( + new CheckResult<>(HttpStatus.I_AM_A_TEAPOT, "", null) + ); + mockMvc.perform(MockMvcRequestBuilders.patch(url) + .contentType(MediaType.APPLICATION_JSON) + .content(request)) + .andExpect(status().isIAmATeapot()); } @Test - void testGetGroupsOfProjectShouldReturnGroups() { - // Mock data - long projectId = 1L; - long userId = 1L; - long courseId = 1L; - long groupId = 1L; - Auth auth = mock(Auth.class); - UserEntity user = new UserEntity(); - user.setId(userId); - ProjectEntity projectEntity = new ProjectEntity(1, "Test Project", "old description", 1L, 1L, - false, 100, OffsetDateTime.MAX); - CourseEntity courseEntity = new CourseEntity(); - courseEntity.setId(courseId); - List groupIds = new ArrayList<>(); - groupIds.add(groupId); - List groups = new ArrayList<>(); - GroupEntity groupEntity = new GroupEntity(); + void getGroupsOfProject() throws Exception { + String url = ApiRoutes.PROJECT_BASE_PATH + "/" + projectEntity.getId() + "/groups"; + GroupEntity groupEntity = new GroupEntity("groupName", 1L); + long groupId = 83L; groupEntity.setId(groupId); - groups.add(groupEntity); - - CheckResult projectCheck = new CheckResult<>(HttpStatus.OK, "TestProject", - projectEntity); - CheckResult> groupCheck = new CheckResult<>(HttpStatus.OK, "TestGroups", - groups); - // Mock behavior - when(auth.getUserEntity()).thenReturn(user); - when(projectUtil.canGetProject(projectId, user)).thenReturn(projectCheck); + GroupJson groupJson = new GroupJson(44, groupEntity.getId(), groupEntity.getName(), "groupClusterUrl"); + + /* If all checks pass, return groups */ + when(projectUtil.canGetProject(projectEntity.getId(), getMockUser())).thenReturn( + new CheckResult<>(HttpStatus.OK, "", projectEntity) + ); when(clusterUtil.isIndividualCluster(projectEntity.getGroupClusterId())).thenReturn(false); - when(projectRepository.findGroupIdsByProjectId(projectId)).thenReturn(groupIds); + when(projectRepository.findGroupIdsByProjectId(projectEntity.getId())).thenReturn(List.of(groupId)); when(grouprRepository.findById(groupId)).thenReturn(Optional.of(groupEntity)); - // Call controller method - ResponseEntity responseEntity = projectController.getGroupsOfProject(projectId, auth); - - // Verify response - assertEquals(HttpStatus.OK, responseEntity.getStatusCode()); + when(entityToJsonConverter.groupEntityToJson(groupEntity)).thenReturn(groupJson); + mockMvc.perform(MockMvcRequestBuilders.get(url)) + .andExpect(status().isOk()) + .andExpect(content().contentType(MediaType.APPLICATION_JSON)) + .andExpect(content().json(objectMapper.writeValueAsString(List.of(groupJson)))); + + /* If inidividual cluster return no content */ + when(clusterUtil.isIndividualCluster(projectEntity.getGroupClusterId())).thenReturn(true); + mockMvc.perform(MockMvcRequestBuilders.get(url)) + .andExpect(status().isNoContent()); + + /* If user has no acces to project, return corresponding status */ + when(projectUtil.canGetProject(projectEntity.getId(), getMockUser())).thenReturn( + new CheckResult<>(HttpStatus.I_AM_A_TEAPOT, "", null) + ); + mockMvc.perform(MockMvcRequestBuilders.get(url)) + .andExpect(status().isIAmATeapot()); } - + @Test + void testDeleteProjectById() throws Exception { + String url = ApiRoutes.PROJECT_BASE_PATH + "/" + projectEntity.getId(); + + /* If all checks pass, delete project */ + when(projectUtil.getProjectIfAdmin(projectEntity.getId(), getMockUser())).thenReturn( + new CheckResult<>(HttpStatus.OK, "", projectEntity) + ); + when(commonDatabaseActions.deleteProject(projectEntity.getId())).thenReturn(new CheckResult<>(HttpStatus.OK, "", null)); + mockMvc.perform(MockMvcRequestBuilders.delete(url)) + .andExpect(status().isOk()); + + /* If deleting project fails, return corresponding status */ + when(commonDatabaseActions.deleteProject(projectEntity.getId())).thenReturn(new CheckResult<>(HttpStatus.INTERNAL_SERVER_ERROR, "", null)); + mockMvc.perform(MockMvcRequestBuilders.delete(url)) + .andExpect(status().isInternalServerError()); + + /* If user has no acces to project, return corresponding status */ + when(projectUtil.getProjectIfAdmin(projectEntity.getId(), getMockUser())).thenReturn( + new CheckResult<>(HttpStatus.I_AM_A_TEAPOT, "", null) + ); + mockMvc.perform(MockMvcRequestBuilders.delete(url)) + .andExpect(status().isIAmATeapot()); + } } \ No newline at end of file diff --git a/backend/app/src/test/java/com/ugent/pidgeon/controllers/SubmissionControllerTest.java b/backend/app/src/test/java/com/ugent/pidgeon/controllers/SubmissionControllerTest.java index e3db0139..21b496ae 100644 --- a/backend/app/src/test/java/com/ugent/pidgeon/controllers/SubmissionControllerTest.java +++ b/backend/app/src/test/java/com/ugent/pidgeon/controllers/SubmissionControllerTest.java @@ -1,27 +1,49 @@ package com.ugent.pidgeon.controllers; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.ugent.pidgeon.CustomObjectMapper; +import com.ugent.pidgeon.model.json.DockerTestFeedbackJson; import com.ugent.pidgeon.model.json.GroupFeedbackJson; import com.ugent.pidgeon.model.json.GroupJson; import com.ugent.pidgeon.model.json.LastGroupSubmissionJson; import com.ugent.pidgeon.model.json.SubmissionJson; +import com.ugent.pidgeon.model.submissionTesting.DockerOutput; +import com.ugent.pidgeon.model.submissionTesting.DockerTestOutput; +import com.ugent.pidgeon.model.submissionTesting.SubmissionTemplateModel.SubmissionResult; import com.ugent.pidgeon.postgre.models.FileEntity; import com.ugent.pidgeon.postgre.models.GroupEntity; import com.ugent.pidgeon.postgre.models.GroupFeedbackEntity; import com.ugent.pidgeon.postgre.models.SubmissionEntity; +import com.ugent.pidgeon.postgre.models.TestEntity; +import com.ugent.pidgeon.postgre.models.types.DockerTestState; +import com.ugent.pidgeon.postgre.models.types.DockerTestType; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; import com.ugent.pidgeon.postgre.repository.*; import com.ugent.pidgeon.util.*; +import java.io.File; +import java.io.FileOutputStream; +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.time.Duration; +import java.util.zip.ZipEntry; +import java.util.zip.ZipOutputStream; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.InjectMocks; import org.mockito.Mock; +import org.mockito.MockedStatic; import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.core.io.FileSystemResource; +import org.springframework.core.io.Resource; +import org.springframework.http.HttpHeaders; import org.springframework.http.HttpStatus; import org.springframework.http.MediaType; import org.springframework.mock.web.MockMultipartFile; -import org.springframework.security.core.context.SecurityContextHolder; import org.springframework.test.web.servlet.request.MockMvcRequestBuilders; -import org.springframework.test.web.servlet.setup.MockMvcBuilders; import java.time.OffsetDateTime; import java.util.List; @@ -29,11 +51,22 @@ import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.ArgumentMatchers.argThat; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.doReturn; +import static org.mockito.Mockito.mockStatic; +import static org.mockito.Mockito.reset; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.header; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + @ExtendWith(MockitoExtension.class) public class SubmissionControllerTest extends ControllerTest { + @Mock private GroupRepository groupRepository; @Mock @@ -46,6 +79,8 @@ public class SubmissionControllerTest extends ControllerTest { private TestRepository testRepository; @Mock private GroupFeedbackRepository groupFeedbackRepository; + @Mock + private TestRunner testRunner; @Mock private SubmissionUtil submissionUtil; @@ -60,6 +95,7 @@ public class SubmissionControllerTest extends ControllerTest { @InjectMocks private SubmissionController submissionController; + private ObjectMapper objectMapper = CustomObjectMapper.createObjectMapper(); private SubmissionEntity submission; private List groupIds; @@ -70,140 +106,425 @@ public class SubmissionControllerTest extends ControllerTest { private GroupFeedbackEntity groupFeedbackEntity; private MockMultipartFile mockMultipartFile; private FileEntity fileEntity; + private LastGroupSubmissionJson lastGroupSubmissionJson; + private TestEntity testEntity; + + + public static File createTestFile() throws IOException { + // Create a temporary directory + File tempDir = Files.createTempDirectory("test-dir").toFile(); + + // Create a temporary file within the directory + File tempFile = File.createTempFile("test-file", ".zip", tempDir); + + // Create some content to write into the zip file + String content = "Hello, this is a test file!"; + byte[] bytes = content.getBytes(); + + // Write the content into a file inside the zip file + try (ZipOutputStream zipOut = new ZipOutputStream(new FileOutputStream(tempFile))) { + ZipEntry entry = new ZipEntry("test.txt"); + zipOut.putNextEntry(entry); + zipOut.write(bytes); + zipOut.closeEntry(); + } + // Return the File object representing the zip file + return tempFile; + } @BeforeEach public void setup() { - mockMvc = MockMvcBuilders.standaloneSetup(submissionController) - .defaultRequest(MockMvcRequestBuilders.get("/**") - .with(request -> { - request.setUserPrincipal(SecurityContextHolder.getContext().getAuthentication()); - return request; - })) - .build(); - submission = new SubmissionEntity(1L, 1L, 1L, OffsetDateTime.MIN, true, true); - groupIds = List.of(1L); - submissionJson = new SubmissionJson(1L, "projecturl", "groupurl", 1L, - 1L, "fileurl", true, OffsetDateTime.MIN, true, - "structurefeedbackurl", "dockerfeedbackurl"); - groupJson = new GroupJson(1, 1L, "groupname", "groupclusterurl"); - groupFeedbackJson = new GroupFeedbackJson(0F, "feedback", 1L, 1L); - groupEntity = new GroupEntity("groupname", 1L); - groupFeedbackEntity = new GroupFeedbackEntity(1L, 1L, 0F, "feedback"); + setUpController(submissionController); + + submission = new SubmissionEntity(22, 45, 99L, OffsetDateTime.MIN, true, true); + submission.setId(56L); + groupIds = List.of(45L); + submissionJson = new SubmissionJson( + submission.getId(), + "projecturl", + "groupurl", + submission.getProjectId(), + submission.getGroupId(), + "fileurl", + true, + OffsetDateTime.MIN, + "structureFeedback", + new DockerTestFeedbackJson(DockerTestType.NONE, "", true), + null, + "artifacturl" + ); + groupEntity = new GroupEntity("groupname", 99L); + groupEntity.setId(submission.getGroupId()); + groupJson = new GroupJson(3, groupEntity.getId(), "groupname", "groupclusterurl"); + + groupFeedbackEntity = new GroupFeedbackEntity(groupEntity.getId(), + submission.getProjectId(), 3F, "feedback"); + groupFeedbackJson = new GroupFeedbackJson(groupFeedbackEntity.getScore(), + groupFeedbackEntity.getFeedback(), groupFeedbackEntity.getGroupId(), + groupFeedbackEntity.getProjectId()); + byte[] fileContent = "Your file content".getBytes(); - mockMultipartFile = new MockMultipartFile("file", "filename.txt", MediaType.TEXT_PLAIN_VALUE, fileContent); + mockMultipartFile = new MockMultipartFile("file", "filename.txt", + MediaType.TEXT_PLAIN_VALUE, fileContent); fileEntity = new FileEntity("name", "dir/name", 1L); + fileEntity.setId(submission.getFileId()); + + lastGroupSubmissionJson = new LastGroupSubmissionJson( + submissionJson, + groupJson, + groupFeedbackJson + ); + + testEntity = new TestEntity( + "dockerImage", + "dockerTestScript", + "dockerTestTemplate", + "structureTemplate" + ); + } @Test public void testGetSubmission() throws Exception { - when(submissionUtil.canGetSubmission(anyLong(), any())).thenReturn(new CheckResult<>(HttpStatus.OK, "", submission)); - mockMvc.perform(MockMvcRequestBuilders.get(ApiRoutes.SUBMISSION_BASE_PATH + "/1")) - .andExpect(status().isOk()); + String url = ApiRoutes.SUBMISSION_BASE_PATH + "/" + submission.getId(); + /* all checks succeed */ + when(submissionUtil.canGetSubmission(submission.getId(), getMockUser())).thenReturn(new CheckResult<>(HttpStatus.OK, "", submission)); + when(entityToJsonConverter.getSubmissionJson(submission)).thenReturn(submissionJson); + mockMvc.perform(MockMvcRequestBuilders.get(url)) + .andExpect(status().isOk()) + .andExpect(content().contentType(MediaType.APPLICATION_JSON)) + .andExpect(content().json(objectMapper.writeValueAsString(submissionJson))); - when(submissionUtil.canGetSubmission(anyLong(), any())).thenReturn(new CheckResult<>(HttpStatus.I_AM_A_TEAPOT, "", null)); - mockMvc.perform(MockMvcRequestBuilders.get(ApiRoutes.SUBMISSION_BASE_PATH + "/1")) + /* User can't get submission */ + when(submissionUtil.canGetSubmission(submission.getId(), getMockUser())).thenReturn(new CheckResult<>(HttpStatus.I_AM_A_TEAPOT, "", null)); + mockMvc.perform(MockMvcRequestBuilders.get(url)) .andExpect(status().isIAmATeapot()); } @Test public void testGetSubmissions() throws Exception { - List lastGroupSubmissionJsons = List.of(new LastGroupSubmissionJson(submissionJson, groupJson, groupFeedbackJson)); - when(projectUtil.isProjectAdmin(anyLong(), any())).thenReturn(new CheckResult<>(HttpStatus.OK, "", null)); - when(projectRepository.findGroupIdsByProjectId(anyLong())).thenReturn(groupIds); - when(groupRepository.findById(anyLong())).thenReturn(Optional.of(groupEntity)); - when(entityToJsonConverter.groupEntityToJson(any())).thenReturn(groupJson); - when(groupFeedbackRepository.getGroupFeedback(anyLong(), anyLong())).thenReturn(groupFeedbackEntity); - when(submissionRepository.findLatestsSubmissionIdsByProjectAndGroupId(anyLong(), anyLong())).thenReturn(Optional.of(submission)); - mockMvc.perform(MockMvcRequestBuilders.get(ApiRoutes.PROJECT_BASE_PATH + "/1/submissions")) - .andExpect(status().isOk()); + String url = ApiRoutes.PROJECT_BASE_PATH + "/" + submission.getProjectId() + "/submissions"; + /* all checks succeed */ + when(projectUtil.isProjectAdmin(submission.getProjectId(), getMockUser())).thenReturn(new CheckResult<>(HttpStatus.OK, "", null)); + when(projectRepository.findGroupIdsByProjectId(submission.getProjectId())).thenReturn(groupIds); + when(groupRepository.findById(groupIds.get(0))).thenReturn(Optional.of(groupEntity)); + when(entityToJsonConverter.groupEntityToJson(groupEntity)).thenReturn(groupJson); + when(groupFeedbackRepository.getGroupFeedback(groupEntity.getId(), submission.getProjectId())).thenReturn(groupFeedbackEntity); + when(entityToJsonConverter.groupFeedbackEntityToJson(groupFeedbackEntity)).thenReturn(groupFeedbackJson); + when(submissionRepository.findLatestsSubmissionIdsByProjectAndGroupId(submission.getProjectId(), groupEntity.getId())).thenReturn(Optional.of(submission)); + when(entityToJsonConverter.getSubmissionJson(submission)).thenReturn(submissionJson); + mockMvc.perform(MockMvcRequestBuilders.get(url)) + .andExpect(status().isOk()) + .andExpect(content().contentType(MediaType.APPLICATION_JSON)) + .andExpect(content().json(objectMapper.writeValueAsString(List.of(lastGroupSubmissionJson)))); - when(submissionRepository.findLatestsSubmissionIdsByProjectAndGroupId(anyLong(), anyLong())).thenReturn(Optional.empty()); - mockMvc.perform(MockMvcRequestBuilders.get(ApiRoutes.PROJECT_BASE_PATH + "/1/submissions")) - .andExpect(status().isOk()); + /* no submission */ + when(submissionRepository.findLatestsSubmissionIdsByProjectAndGroupId(submission.getProjectId(), groupEntity.getId())).thenReturn(Optional.empty()); + lastGroupSubmissionJson.setSubmission(null); + mockMvc.perform(MockMvcRequestBuilders.get(url)) + .andExpect(status().isOk()) + .andExpect(content().contentType(MediaType.APPLICATION_JSON)) + .andExpect(content().json(objectMapper.writeValueAsString(List.of(lastGroupSubmissionJson)))); - when(groupFeedbackRepository.getGroupFeedback(anyLong(), anyLong())).thenReturn(null); - mockMvc.perform(MockMvcRequestBuilders.get(ApiRoutes.PROJECT_BASE_PATH + "/1/submissions")) - .andExpect(status().isOk()); + /* no feedback */ + when(groupFeedbackRepository.getGroupFeedback(groupEntity.getId(), submission.getProjectId())).thenReturn(null); + lastGroupSubmissionJson.setFeedback(null); + mockMvc.perform(MockMvcRequestBuilders.get(url)) + .andExpect(status().isOk()) + .andExpect(content().contentType(MediaType.APPLICATION_JSON)) + .andExpect(content().json(objectMapper.writeValueAsString(List.of(lastGroupSubmissionJson)))); - when(groupRepository.findById(anyLong())).thenReturn(Optional.empty()); - mockMvc.perform(MockMvcRequestBuilders.get(ApiRoutes.PROJECT_BASE_PATH + "/1/submissions")) + /* Unexpected error */ + when(projectUtil.isProjectAdmin(submission.getProjectId(), getMockUser())).thenThrow(new RuntimeException()); + mockMvc.perform(MockMvcRequestBuilders.get(url)) .andExpect(status().isInternalServerError()); - when(projectUtil.isProjectAdmin(anyLong(), any())).thenReturn(new CheckResult<>(HttpStatus.BAD_REQUEST, "", null)); - mockMvc.perform(MockMvcRequestBuilders.get(ApiRoutes.PROJECT_BASE_PATH + "/1/submissions")) - .andExpect(status().isBadRequest()); + /* group not found */ + reset(projectUtil); + when(projectUtil.isProjectAdmin(submission.getProjectId(), getMockUser())).thenReturn(new CheckResult<>(HttpStatus.OK, "", null)); + when(groupRepository.findById(groupIds.get(0))).thenReturn(Optional.empty()); + mockMvc.perform(MockMvcRequestBuilders.get(url)) + .andExpect(status().isInternalServerError()); + + /* User can't get project */ + when(projectUtil.isProjectAdmin(submission.getProjectId(), getMockUser())).thenReturn(new CheckResult<>(HttpStatus.I_AM_A_TEAPOT, "", null)); + mockMvc.perform(MockMvcRequestBuilders.get(url)) + .andExpect(status().isIAmATeapot()); - when(projectUtil.isProjectAdmin(anyLong(), any())).thenThrow(new RuntimeException()); - mockMvc.perform(MockMvcRequestBuilders.get(ApiRoutes.PROJECT_BASE_PATH + "/1/submissions")) - .andExpect(status().isInternalServerError()); } @Test public void testSubmitFile() throws Exception { - //TODO: dit ook een correcte test laten uitvoeren met dummyfiles - when(submissionUtil.checkOnSubmit(anyLong(), any())).thenReturn(new CheckResult<>(HttpStatus.OK, "", 1L)); - when(fileRepository.save(any())).thenReturn(fileEntity); - when(submissionRepository.save(any())).thenReturn(submission); - mockMvc.perform(MockMvcRequestBuilders.multipart(ApiRoutes.PROJECT_BASE_PATH + "/1/submit") - .file(mockMultipartFile)) + String url = ApiRoutes.PROJECT_BASE_PATH + "/" + submission.getProjectId() + "/submit"; + /* all checks succeed */ + when(submissionUtil.checkOnSubmit(submission.getProjectId(), getMockUser())).thenReturn(new CheckResult<>(HttpStatus.OK, "", groupEntity.getId())); + when(fileRepository.save(argThat( + file -> file.getUploadedBy() == getMockUser().getId() + ))).thenReturn(fileEntity); + when(submissionRepository.save(argThat( + sub -> { + Duration duration = Duration.between(sub.getSubmissionTime(), OffsetDateTime.now()); + return sub.getProjectId() == submission.getProjectId() && + sub.getGroupId() == groupEntity.getId() && + sub.getFileId() == fileEntity.getId() && + duration.getSeconds() < 2; + } + ))).thenReturn(submission); + Path path = Path.of(fileEntity.getPath()); + Path artifactPath = Path.of("artifactPath"); + File file = createTestFile(); + try (MockedStatic mockedFileHandler = mockStatic(Filehandler.class)) { + mockedFileHandler.when(() -> Filehandler.getSubmissionPath(submission.getProjectId(), groupEntity.getId(), submission.getId())).thenReturn(path); + mockedFileHandler.when(() -> Filehandler.saveSubmission(path, mockMultipartFile)).thenReturn(file); + mockedFileHandler.when(() -> Filehandler.getSubmissionArtifactPath(anyLong(), anyLong(), anyLong())).thenReturn(artifactPath); + + when(testRunner.runStructureTest(any(), eq(testEntity), any())).thenReturn(null); + when(testRunner.runDockerTest(any(), eq(testEntity), eq(artifactPath), any())).thenReturn(null); + + when(entityToJsonConverter.getSubmissionJson(submission)).thenReturn(submissionJson); + + when(testRepository.findByProjectId(submission.getProjectId())).thenReturn(Optional.of(testEntity)); + when(entityToJsonConverter.getSubmissionJson(submission)).thenReturn(submissionJson); + + mockMvc.perform(MockMvcRequestBuilders.multipart(url) + .file(mockMultipartFile)) + .andExpect(status().isOk()) + .andExpect(content().contentType(MediaType.APPLICATION_JSON)) + .andExpect(content().json(objectMapper.writeValueAsString(submissionJson))); + + /* assertEquals(DockerTestState.running, submission.getDockerTestState()); */ // This executes too quickly so we can't test this + + Thread.sleep(1000); + + // File repository needs to save again after setting path + verify(fileRepository, times(1)).save(argThat( + f -> f.getId() == fileEntity.getId() && f.getPath().equals(fileEntity.getPath()) + )); + + // Submissions should be update 3 times, once for the initial save, once for structuretest, once for docker test. + // The first one is being checked by the when(...) + verify(submissionRepository, times(2)).save(argThat( + s -> s.getId() == submission.getId() + )); + + assertEquals(DockerTestState.aborted, submission.getDockerTestState()); + + /* structuretestResult isn't null */ + submission.setStructureAccepted(false); + submission.setStructureFeedback(""); + SubmissionResult submissionResult = new SubmissionResult(true, "structureFeedback-test"); + when(testRunner.runStructureTest(any(), eq(testEntity), any())).thenReturn(submissionResult); + mockMvc.perform(MockMvcRequestBuilders.multipart(url) + .file(mockMultipartFile)) + .andExpect(status().isOk()) + .andExpect(content().contentType(MediaType.APPLICATION_JSON)) + .andExpect(content().json(objectMapper.writeValueAsString(submissionJson))); + + assertTrue(submission.getStructureAccepted()); + assertEquals("structureFeedback-test", submission.getStructureFeedback()); + + /* Correctly updates the dockertype */ + testEntity.setDockerTestTemplate("dockerTestTemplate"); + testEntity.setDockerTestScript("dockerTestScript"); + submission.setDockerType(DockerTestType.NONE); + mockMvc.perform(MockMvcRequestBuilders.multipart(url) + .file(mockMultipartFile)) + .andExpect(status().isOk()) + .andExpect(content().contentType(MediaType.APPLICATION_JSON)) + .andExpect(content().json(objectMapper.writeValueAsString(submissionJson))); + assertEquals(DockerTestType.TEMPLATE, submission.getDockerTestType()); + + testEntity.setDockerTestTemplate(null); + mockMvc.perform(MockMvcRequestBuilders.multipart(url) + .file(mockMultipartFile)) + .andExpect(status().isOk()) + .andExpect(content().contentType(MediaType.APPLICATION_JSON)) + .andExpect(content().json(objectMapper.writeValueAsString(submissionJson))); + assertEquals(DockerTestType.SIMPLE, submission.getDockerTestType()); + + testEntity.setDockerTestScript(null); + testEntity.setDockerTestTemplate(null); + mockMvc.perform(MockMvcRequestBuilders.multipart(url) + .file(mockMultipartFile)) + .andExpect(status().isOk()) + .andExpect(content().contentType(MediaType.APPLICATION_JSON)) + .andExpect(content().json(objectMapper.writeValueAsString(submissionJson))); + assertEquals(DockerTestType.NONE, submission.getDockerTestType()); + + /* A valid docker result is returned */ + testEntity.setDockerImage("dockerImage"); + testEntity.setDockerTestScript("dockerTestScript"); + DockerOutput dockerOutput = new DockerTestOutput( List.of("dockerFeedback-test"), true); + when(testRunner.runDockerTest(any(), eq(testEntity), eq(artifactPath), any())).thenReturn(dockerOutput); + submission.setDockerAccepted(false); + submission.setDockerFeedback("dockerFeedback-test"); + mockMvc.perform(MockMvcRequestBuilders.multipart(url) + .file(mockMultipartFile)) + .andExpect(status().isOk()) + .andExpect(content().contentType(MediaType.APPLICATION_JSON)) + .andExpect(content().json(objectMapper.writeValueAsString(submissionJson))); + + Thread.sleep(1000); + + assertTrue(submission.getDockerAccepted()); + assertEquals("dockerFeedback-test", submission.getDockerFeedback()); + assertEquals(DockerTestState.finished, submission.getDockerTestState()); + + /* No testEntity */ + when(testRepository.findByProjectId(submission.getProjectId())).thenReturn(Optional.empty()); + reset(testRunner); + mockMvc.perform(MockMvcRequestBuilders.multipart(url) + .file(mockMultipartFile)) + .andExpect(status().isOk()) + .andExpect(content().contentType(MediaType.APPLICATION_JSON)) + .andExpect(content().json(objectMapper.writeValueAsString(submissionJson))); + verify(testRunner, times(0)).runStructureTest(any(), eq(testEntity), any()); + verify(testRunner, times(0)).runDockerTest(any(), eq(testEntity), eq(artifactPath), any()); + + /* Unexpected error */ + reset(fileRepository); + when(fileRepository.save(any())).thenThrow(new RuntimeException()); + mockMvc.perform(MockMvcRequestBuilders.multipart(url) + .file(mockMultipartFile)) .andExpect(status().isInternalServerError()); + + /* CheckOnSUbmit fails */ + when(submissionUtil.checkOnSubmit(submission.getProjectId(), getMockUser())).thenReturn(new CheckResult<>(HttpStatus.I_AM_A_TEAPOT, "", null)); + mockMvc.perform(MockMvcRequestBuilders.multipart(url) + .file(mockMultipartFile)) + .andExpect(status().isIAmATeapot()); + + + + } catch (Exception e) { + e.printStackTrace(); + throw e; + } + } @Test public void testGetSubmissionFile() throws Exception { - //TODO: dit ook een correcte test laten uitvoeren met dummyfiles - when(submissionUtil.canGetSubmission(anyLong(), any())).thenReturn(new CheckResult<>(HttpStatus.OK, "", submission)); - when(fileRepository.findById(anyLong())).thenReturn(Optional.of(fileEntity)); - mockMvc.perform(MockMvcRequestBuilders.get(ApiRoutes.SUBMISSION_BASE_PATH + "/1/file")) - .andExpect(status().isInternalServerError()); + try (MockedStatic mockedFileHandler = mockStatic(Filehandler.class)) { + String url = ApiRoutes.SUBMISSION_BASE_PATH + "/" + submission.getId() + "/file"; + Path path = Path.of(fileEntity.getPath()); + File file = createTestFile(); + Resource mockedResource = new FileSystemResource(file); + + /* all checks succeed */ + when(submissionUtil.canGetSubmission(submission.getId(), getMockUser())).thenReturn(new CheckResult<>(HttpStatus.OK, "", submission)); + when(fileRepository.findById(submission.getFileId())).thenReturn(Optional.of(fileEntity)); + mockedFileHandler.when(() -> Filehandler.getFileAsResource(path)).thenReturn(mockedResource); - when(fileRepository.findById(anyLong())).thenReturn(Optional.empty()); - mockMvc.perform(MockMvcRequestBuilders.get(ApiRoutes.SUBMISSION_BASE_PATH + "/1/file")) + mockMvc.perform(MockMvcRequestBuilders.get(url)) + .andExpect(status().isOk()) + .andExpect(content().contentType("application/zip")) + .andExpect(header().string( + HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=" + fileEntity.getName())) + .andExpect(content().bytes(mockedResource.getInputStream().readAllBytes())); + + /* Resource not found */ + mockedFileHandler.when(() -> Filehandler.getFileAsResource(path)).thenReturn(null); + mockMvc.perform(MockMvcRequestBuilders.get(url)) .andExpect(status().isNotFound()); - when(submissionUtil.canGetSubmission(anyLong(), any())).thenReturn(new CheckResult<>(HttpStatus.FORBIDDEN, "", null)); - mockMvc.perform(MockMvcRequestBuilders.get(ApiRoutes.SUBMISSION_BASE_PATH + "/1/file")) - .andExpect(status().isForbidden()); - } + /* file not found */ + when(fileRepository.findById(submission.getFileId())).thenReturn(Optional.empty()); + mockMvc.perform(MockMvcRequestBuilders.get(url)) + .andExpect(status().isNotFound()); - @Test - public void testGetStructureFeedback() throws Exception { - when(submissionUtil.canGetSubmission(anyLong(), any())).thenReturn(new CheckResult<>(HttpStatus.OK, "", submission)); - mockMvc.perform(MockMvcRequestBuilders.get(ApiRoutes.SUBMISSION_BASE_PATH + "/1/structurefeedback")) - .andExpect(status().isOk()); + /* Unexpected error */ + when(fileRepository.findById(submission.getFileId())).thenReturn(Optional.of(fileEntity)); + mockedFileHandler.reset(); + mockedFileHandler.when(() -> Filehandler.getFileAsResource(path)).thenThrow(new RuntimeException()); + mockMvc.perform(MockMvcRequestBuilders.get(url)) + .andExpect(status().isInternalServerError()); - when(submissionUtil.canGetSubmission(anyLong(), any())).thenReturn(new CheckResult<>(HttpStatus.I_AM_A_TEAPOT, "", null)); - mockMvc.perform(MockMvcRequestBuilders.get(ApiRoutes.SUBMISSION_BASE_PATH + "/1/structurefeedback")) + /* User can't get submission */ + when(submissionUtil.canGetSubmission(submission.getId(), getMockUser())).thenReturn(new CheckResult<>(HttpStatus.I_AM_A_TEAPOT, "", null)); + mockMvc.perform(MockMvcRequestBuilders.get(url)) .andExpect(status().isIAmATeapot()); + } } @Test - public void testGetDockerFeedback() throws Exception { - when(submissionUtil.canGetSubmission(anyLong(), any())).thenReturn(new CheckResult<>(HttpStatus.OK, "", submission)); - mockMvc.perform(MockMvcRequestBuilders.get(ApiRoutes.SUBMISSION_BASE_PATH + "/1/dockerfeedback")) - .andExpect(status().isOk()); + public void testGetSubmissionArtifacts() throws Exception { + try (MockedStatic mockedFileHandler = mockStatic(Filehandler.class)) { + String url = ApiRoutes.SUBMISSION_BASE_PATH + "/" + submission.getId() + "/artifacts"; + Path path = Path.of("artifactPath"); + File file = createTestFile(); + Resource mockedResource = new FileSystemResource(file); + + /* all checks succeed */ + when(submissionUtil.canGetSubmission(submission.getId(), getMockUser())).thenReturn(new CheckResult<>(HttpStatus.OK, "", submission)); + mockedFileHandler.when(() -> Filehandler.getSubmissionArtifactPath(submission.getProjectId(), submission.getGroupId(), submission.getId())).thenReturn(path); + mockedFileHandler.when(() -> Filehandler.getFileAsResource(path)).thenReturn(mockedResource); + + mockMvc.perform(MockMvcRequestBuilders.get(url)) + .andExpect(status().isOk()) + .andExpect(content().contentType("application/zip")) + .andExpect(header().string( + HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=artifacts.zip")) + .andExpect(content().bytes(mockedResource.getInputStream().readAllBytes())); + + + /* Resource not found */ + mockedFileHandler.when(() -> Filehandler.getFileAsResource(path)).thenReturn(null); + mockMvc.perform(MockMvcRequestBuilders.get(url)) + .andExpect(status().isNotFound()); + + /* Unexpected error */ + mockedFileHandler.reset(); + mockedFileHandler.when(() -> Filehandler.getSubmissionArtifactPath(submission.getProjectId(), submission.getGroupId(), submission.getId())).thenThrow(new RuntimeException()); + mockMvc.perform(MockMvcRequestBuilders.get(url)) + .andExpect(status().isInternalServerError()); + + /* User can't get submission */ + when(submissionUtil.canGetSubmission(submission.getId(), getMockUser())).thenReturn(new CheckResult<>(HttpStatus.I_AM_A_TEAPOT, "", null)); + mockMvc.perform(MockMvcRequestBuilders.get(url)) + .andExpect(status().isIAmATeapot()); + } } @Test public void testDeleteSubmissionById() throws Exception { - when(submissionUtil.canDeleteSubmission(anyLong(), any())).thenReturn(new CheckResult<>(HttpStatus.OK, "", submission)); - mockMvc.perform(MockMvcRequestBuilders.delete(ApiRoutes.SUBMISSION_BASE_PATH + "/1")) + String url = ApiRoutes.SUBMISSION_BASE_PATH + "/" + submission.getId(); + /* all checks succeed */ + when(submissionUtil.canDeleteSubmission(submission.getId(), getMockUser())).thenReturn(new CheckResult<>(HttpStatus.OK, "", submission)); + mockMvc.perform(MockMvcRequestBuilders.delete(url)) .andExpect(status().isOk()); - when(submissionUtil.canDeleteSubmission(anyLong(), any())).thenReturn(new CheckResult<>(HttpStatus.I_AM_A_TEAPOT, "", null)); - mockMvc.perform(MockMvcRequestBuilders.delete(ApiRoutes.SUBMISSION_BASE_PATH + "/1")) + verify(commonDatabaseActions, times(1)).deleteSubmissionById(submission.getId()); + + /* User can't delete submission */ + when(submissionUtil.canDeleteSubmission(submission.getId(), getMockUser())).thenReturn(new CheckResult<>(HttpStatus.I_AM_A_TEAPOT, "", null)); + mockMvc.perform(MockMvcRequestBuilders.delete(url)) .andExpect(status().isIAmATeapot()); } @Test public void testGetSubmissionsForGroup() throws Exception { - when(groupUtil.canGetProjectGroupData(anyLong(), anyLong(), any())).thenReturn(new CheckResult<>(HttpStatus.OK, "", null)); - mockMvc.perform(MockMvcRequestBuilders.get(ApiRoutes.PROJECT_BASE_PATH + "/1/submissions/1")) - .andExpect(status().isOk()); + String url = ApiRoutes.PROJECT_BASE_PATH + "/" + submission.getProjectId() + "/submissions/" + groupEntity.getId(); + /* all checks succeed */ + when(groupUtil.canGetProjectGroupData(groupEntity.getId(), submission.getProjectId(), getMockUser())).thenReturn(new CheckResult<>(HttpStatus.OK, "", null)); + when(submissionRepository.findByProjectIdAndGroupId(submission.getProjectId(), groupEntity.getId())).thenReturn(List.of(submission)); + when(entityToJsonConverter.getSubmissionJson(submission)).thenReturn(submissionJson); + mockMvc.perform(MockMvcRequestBuilders.get(url)) + .andExpect(status().isOk()) + .andExpect(content().contentType(MediaType.APPLICATION_JSON)) + .andExpect(content().json(objectMapper.writeValueAsString(List.of(submissionJson)))); + + /* No submissions */ + when(submissionRepository.findByProjectIdAndGroupId(submission.getProjectId(), groupEntity.getId())).thenReturn(List.of()); + mockMvc.perform(MockMvcRequestBuilders.get(url)) + .andExpect(status().isOk()) + .andExpect(content().contentType(MediaType.APPLICATION_JSON)) + .andExpect(content().json("[]")); - when(groupUtil.canGetProjectGroupData(anyLong(), anyLong(), any())).thenReturn(new CheckResult<>(HttpStatus.I_AM_A_TEAPOT, "", null)); - mockMvc.perform(MockMvcRequestBuilders.get(ApiRoutes.PROJECT_BASE_PATH + "/1/submissions/1")) + /* User can't get group */ + when(groupUtil.canGetProjectGroupData(groupEntity.getId(), submission.getProjectId(), getMockUser())).thenReturn(new CheckResult<>(HttpStatus.I_AM_A_TEAPOT, "", null)); + mockMvc.perform(MockMvcRequestBuilders.get(url)) .andExpect(status().isIAmATeapot()); } } \ No newline at end of file diff --git a/backend/app/src/test/java/com/ugent/pidgeon/controllers/TestControllerTest.java b/backend/app/src/test/java/com/ugent/pidgeon/controllers/TestControllerTest.java index ce87d826..a360aaff 100644 --- a/backend/app/src/test/java/com/ugent/pidgeon/controllers/TestControllerTest.java +++ b/backend/app/src/test/java/com/ugent/pidgeon/controllers/TestControllerTest.java @@ -1,16 +1,31 @@ package com.ugent.pidgeon.controllers; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.ugent.pidgeon.CustomObjectMapper; +import com.ugent.pidgeon.model.json.TestJson; +import com.ugent.pidgeon.model.json.TestUpdateJson; import com.ugent.pidgeon.postgre.models.GroupEntity; +import com.ugent.pidgeon.postgre.models.ProjectEntity; +import com.ugent.pidgeon.postgre.models.TestEntity; import com.ugent.pidgeon.postgre.repository.FileRepository; import com.ugent.pidgeon.postgre.repository.ProjectRepository; import com.ugent.pidgeon.postgre.repository.TestRepository; +import com.ugent.pidgeon.util.CheckResult; +import com.ugent.pidgeon.util.CommonDatabaseActions; +import com.ugent.pidgeon.util.EntityToJsonConverter; import com.ugent.pidgeon.util.Filehandler; +import com.ugent.pidgeon.util.Pair; +import com.ugent.pidgeon.util.ProjectUtil; +import com.ugent.pidgeon.util.TestUtil; +import java.time.OffsetDateTime; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.InjectMocks; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.http.HttpMethod; +import org.springframework.http.HttpStatus; import org.springframework.http.MediaType; import org.springframework.security.core.context.SecurityContextHolder; import org.springframework.test.web.servlet.request.MockMvcRequestBuilders; @@ -23,30 +38,638 @@ import java.nio.file.Paths; import java.util.Optional; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.ArgumentMatchers.argThat; +import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.reset; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + @ExtendWith(MockitoExtension.class) public class TestControllerTest extends ControllerTest{ @Mock - private ProjectRepository projectRepository; + private TestUtil testUtil; @Mock - private FileRepository fileRepository; + private TestRepository testRepository; + @Mock + private ProjectRepository projectRepository; @Mock - private TestRepository testRepository; + private EntityToJsonConverter entityToJsonConverter; + @Mock + private CommonDatabaseActions commonDatabaseActions; + + @InjectMocks - private ControllerTest testController; + private TestController testController; + + + private ObjectMapper objectMapper = CustomObjectMapper.createObjectMapper(); + + private ProjectEntity project; + private TestEntity test; + private TestJson testJson; @BeforeEach public void setup() { - mockMvc = MockMvcBuilders.standaloneSetup(testController) - .defaultRequest(MockMvcRequestBuilders.get("/**") - .with(request -> { request.setUserPrincipal(SecurityContextHolder.getContext().getAuthentication()); return request; })) - .build(); + setUpController(testController); + + project = new ProjectEntity( + 67, + "projectName", + "projectDescription", + 5, + 38L, + true, + 34, + OffsetDateTime.now() + ); + project.setId(64); + + test = new TestEntity( + "dockerImageBasic", + "dockerTestScriptBasic", + "dockerTestTemplateBasic", + "structureTemplateBasic" + ); + test.setId(990); + testJson = new TestJson( + "projectUrl", + test.getDockerImage(), + test.getDockerTestScript(), + test.getDockerTestTemplate(), + test.getStructureTemplate() + ); + + } + + @Test + public void testUpdateTest() throws Exception { + when(testRepository.imageIsUsed(any())).thenReturn(true); + String url = ApiRoutes.PROJECT_BASE_PATH + "/" + project.getId() + "/tests"; + String dockerImage = "dockerImage"; + String dockerTestScript = "dockerTestScript"; + String dockerTestTemplate = "dockerTestTemplate"; + String structureTemplate = "structureTemplate"; + + TestUpdateJson testUpdateJson = new TestUpdateJson( + dockerImage, + dockerTestScript, + dockerTestTemplate, + structureTemplate + ); + + TestJson testJson = new TestJson( + "projectUrl", + dockerImage, + dockerTestScript, + dockerTestTemplate, + structureTemplate + ); + /* All checks succeed */ + when(testUtil.checkForTestUpdate( + eq(project.getId()), + eq(getMockUser()), + eq(dockerImage), + eq(dockerTestScript), + eq(dockerTestTemplate), + eq(HttpMethod.POST) + )).thenReturn(new CheckResult<>(HttpStatus.OK, "",new Pair<>(null, project))); + + when(testRepository.save(argThat( + testEntity -> testEntity.getDockerImage().equals(dockerImage) && + testEntity.getDockerTestScript().equals(dockerTestScript) && + testEntity.getDockerTestTemplate().equals(dockerTestTemplate) && + testEntity.getStructureTemplate().equals(structureTemplate) + ))).thenReturn(test); + + when(entityToJsonConverter.testEntityToTestJson(test, project.getId())).thenReturn(testJson); + + mockMvc.perform(MockMvcRequestBuilders.post(url) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(testUpdateJson)) + ).andExpect(status().isOk()) + .andExpect(content().contentType(MediaType.APPLICATION_JSON)) + .andExpect(content().json(objectMapper.writeValueAsString(testJson))); + + verify(projectRepository, times(1)).save(project); + assertEquals(test.getId(), project.getTestId()); + + /* fields are blank */ + String dockerImageBlank = ""; + String dockerTestScriptBlank = ""; + String dockerTemplateBlank = ""; + String structureTemplateBlank = ""; + + testJson = new TestJson( + "projectUrl", + null, + null, + null, + null + ); + testUpdateJson = new TestUpdateJson( + dockerImageBlank, + dockerTestScriptBlank, + dockerTemplateBlank, + structureTemplateBlank + ); + reset(testUtil); + when(testUtil.checkForTestUpdate( + eq(project.getId()), + eq(getMockUser()), + eq(null), + eq(null), + eq(null), + eq(HttpMethod.POST) + )).thenReturn(new CheckResult<>(HttpStatus.OK, "",new Pair<>(null, project))); + + reset(testRepository); + when(testRepository.save(argThat( + testEntity -> testEntity.getDockerImage() == null && + testEntity.getDockerTestScript() == null && + testEntity.getDockerTestTemplate() == null && + testEntity.getStructureTemplate() == null + ))).thenReturn(test); + + when(entityToJsonConverter.testEntityToTestJson(test, project.getId())).thenReturn(testJson); + + mockMvc.perform(MockMvcRequestBuilders.post(url) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(testUpdateJson)) + ).andExpect(status().isOk()) + .andExpect(content().contentType(MediaType.APPLICATION_JSON)) + .andExpect(content().json(objectMapper.writeValueAsString(testJson))); + + /* Fields are null */ + String dockerImageNull = null; + String dockerTestScriptNull = null; + String dockerTemplateNull = null; + String structureTemplateNull = null; + + testUpdateJson = new TestUpdateJson( + dockerImageNull, + dockerTestScriptNull, + dockerTemplateNull, + structureTemplateNull + ); + + when(testRepository.imageIsUsed(any())).thenReturn(true); + + mockMvc.perform(MockMvcRequestBuilders.post(url) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(testUpdateJson)) + ).andExpect(status().isOk()) + .andExpect(content().contentType(MediaType.APPLICATION_JSON)) + .andExpect(content().json(objectMapper.writeValueAsString(testJson))); + + /* Check fails */ + when(testUtil.checkForTestUpdate( + eq(project.getId()), + eq(getMockUser()), + eq(dockerImage), + eq(dockerTestScript), + eq(dockerTestTemplate), + eq(HttpMethod.POST) + )).thenReturn(new CheckResult<>(HttpStatus.I_AM_A_TEAPOT, "I'm a teapot", null)); + + testUpdateJson = new TestUpdateJson( + dockerImage, + dockerTestScript, + dockerTestTemplate, + structureTemplate + ); + + mockMvc.perform(MockMvcRequestBuilders.post(url) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(testUpdateJson)) + ).andExpect(status().isIAmATeapot()); + } + + @Test + public void testPutTest() throws Exception { + when(testRepository.imageIsUsed(any())).thenReturn(true); + String url = ApiRoutes.PROJECT_BASE_PATH + "/" + project.getId() + "/tests"; + + String originalDockerImage = test.getDockerImage(); + String originalDockerTestScript = test.getDockerTestScript(); + String originalDockerTestTemplate = test.getDockerTestTemplate(); + String originalStructureTemplate = test.getStructureTemplate(); + + String dockerImage = "dockerImage"; + String dockerTestScript = "dockerTestScript"; + String dockerTestTemplate = "dockerTestTemplate"; + String structureTemplate = "structureTemplate"; + + TestUpdateJson testUpdateJson = new TestUpdateJson( + dockerImage, + dockerTestScript, + dockerTestTemplate, + structureTemplate + ); + + test.setDockerImage(null); + test.setDockerTestScript(null); + test.setDockerTestTemplate(null); + + TestJson testJson = new TestJson( + "projectUrl", + dockerImage, + dockerTestScript, + dockerTestTemplate, + structureTemplate + ); + /* All checks succeed */ + when(testUtil.checkForTestUpdate( + eq(project.getId()), + eq(getMockUser()), + eq(dockerImage), + eq(dockerTestScript), + eq(dockerTestTemplate), + eq(HttpMethod.PUT) + )).thenReturn(new CheckResult<>(HttpStatus.OK, "",new Pair<>(test, project))); + + when(testRepository.save(test)).thenReturn(test); + + when(entityToJsonConverter.testEntityToTestJson(test, project.getId())).thenReturn(testJson); + + mockMvc.perform(MockMvcRequestBuilders.put(url) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(testUpdateJson)) + ).andExpect(status().isOk()) + .andExpect(content().contentType(MediaType.APPLICATION_JSON)) + .andExpect(content().json(objectMapper.writeValueAsString(testJson))); + + verify(projectRepository, times(1)).save(project); + assertEquals(test.getId(), project.getTestId()); + assertEquals(dockerImage, test.getDockerImage()); + assertEquals(dockerTestScript, test.getDockerTestScript()); + assertEquals(dockerTestTemplate, test.getDockerTestTemplate()); + assertEquals(structureTemplate, test.getStructureTemplate()); + + test.setDockerImage(originalDockerImage); + test.setDockerTestScript(originalDockerTestScript); + test.setDockerTestTemplate(originalDockerTestTemplate); + + /* fields are blank */ + String dockerImageBlank = ""; + String dockerTestScriptBlank = ""; + String dockerTemplateBlank = ""; + String structureTemplateBlank = ""; + + testUpdateJson = new TestUpdateJson( + dockerImageBlank, + dockerTestScriptBlank, + dockerTemplateBlank, + structureTemplateBlank + ); + + testJson = new TestJson( + "projectUrl", + null, + null, + null, + null + ); + reset(testUtil); + when(testUtil.checkForTestUpdate( + eq(project.getId()), + eq(getMockUser()), + eq(null), + eq(null), + eq(null), + eq(HttpMethod.PUT) + )).thenReturn(new CheckResult<>(HttpStatus.OK, "",new Pair<>(test, project))); + + reset(testRepository); + when(testRepository.save(argThat( + testEntity -> testEntity.getDockerImage() == null && + testEntity.getDockerTestScript() == null && + testEntity.getDockerTestTemplate() == null && + testEntity.getStructureTemplate() == null + ))).thenReturn(test); + + when(entityToJsonConverter.testEntityToTestJson(test, project.getId())).thenReturn(testJson); + + mockMvc.perform(MockMvcRequestBuilders.put(url) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(testUpdateJson)) + ).andExpect(status().isOk()) + .andExpect(content().contentType(MediaType.APPLICATION_JSON)) + .andExpect(content().json(objectMapper.writeValueAsString(testJson))); + + assertNull(test.getDockerImage()); + assertNull(test.getDockerTestScript()); + assertNull(test.getDockerTestTemplate()); + assertNull(test.getStructureTemplate()); + + test.setDockerImage(originalDockerImage); + test.setDockerTestScript(originalDockerTestScript); + test.setDockerTestTemplate(originalDockerTestTemplate); + test.setStructureTemplate(originalStructureTemplate); + + /* Fields are null */ + String dockerImageNull = null; + String dockerTestScriptNull = null; + String dockerTemplateNull = null; + String structureTemplateNull = null; + + when(testRepository.imageIsUsed(any())).thenReturn(true); + + testUpdateJson = new TestUpdateJson( + dockerImageNull, + dockerTestScriptNull, + dockerTemplateNull, + structureTemplateNull + ); + + mockMvc.perform(MockMvcRequestBuilders.put(url) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(testUpdateJson)) + ).andExpect(status().isOk()) + .andExpect(content().contentType(MediaType.APPLICATION_JSON)) + .andExpect(content().json(objectMapper.writeValueAsString(testJson))); + + assertNull(test.getDockerImage()); + assertNull(test.getDockerTestScript()); + assertNull(test.getDockerTestTemplate()); + assertNull(test.getStructureTemplate()); + + test.setDockerImage(originalDockerImage); + test.setDockerTestScript(originalDockerTestScript); + test.setDockerTestTemplate(originalDockerTestTemplate); + test.setStructureTemplate(originalStructureTemplate); + + /* Check fails */ + when(testUtil.checkForTestUpdate( + eq(project.getId()), + eq(getMockUser()), + eq(dockerImage), + eq(dockerTestScript), + eq(dockerTestTemplate), + eq(HttpMethod.PUT) + )).thenReturn(new CheckResult<>(HttpStatus.I_AM_A_TEAPOT, "I'm a teapot", null)); + + testUpdateJson = new TestUpdateJson( + dockerImage, + dockerTestScript, + dockerTestTemplate, + structureTemplate + ); + + mockMvc.perform(MockMvcRequestBuilders.put(url) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(testUpdateJson)) + ).andExpect(status().isIAmATeapot()); + + } + + @Test + public void testGetPatch() throws Exception { + when(testRepository.imageIsUsed(any())).thenReturn(true); + String url = ApiRoutes.PROJECT_BASE_PATH + "/" + project.getId() + "/tests"; + + String dockerImage = "dockerImage"; + String dockerTestScript = "dockerTestScript"; + String dockerTestTemplate = "dockerTestTemplate"; + String structureTemplate = "structureTemplate"; + + when(testUtil.checkForTestUpdate( + eq(project.getId()), + eq(getMockUser()), + eq(dockerImage), + eq(null), + eq(null), + eq(HttpMethod.PATCH) + )).thenReturn(new CheckResult<>(HttpStatus.OK, "",new Pair<>(test, project))); + + when(testRepository.save(test)).thenReturn(test); + when(entityToJsonConverter.testEntityToTestJson(test, project.getId())).thenReturn(testJson); + + /* Start with test all null, fill them in one by one */ + test.setDockerImage(null); + test.setDockerTestScript(null); + test.setDockerTestTemplate(null); + test.setStructureTemplate(null); + + TestUpdateJson testUpdateJson = new TestUpdateJson( + dockerImage, + null, + null, + null + ); + + mockMvc.perform(MockMvcRequestBuilders.patch(url) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(testUpdateJson)) + ).andExpect(status().isOk()) + .andExpect(content().contentType(MediaType.APPLICATION_JSON)) + .andExpect(content().json(objectMapper.writeValueAsString(testJson))); + + assertEquals(dockerImage, test.getDockerImage()); + assertNull(test.getDockerTestScript()); + assertNull(test.getDockerTestTemplate()); + assertNull(test.getStructureTemplate()); + + verify(projectRepository, times(1)).save(project); + assertEquals(test.getId(), project.getTestId()); + + reset(testUtil); + when(testUtil.checkForTestUpdate( + eq(project.getId()), + eq(getMockUser()), + eq(null), + eq(dockerTestScript), + eq(null), + eq(HttpMethod.PATCH) + )).thenReturn(new CheckResult<>(HttpStatus.OK, "",new Pair<>(test, project))); + + testUpdateJson = new TestUpdateJson( + null, + dockerTestScript, + null, + null + ); + + mockMvc.perform(MockMvcRequestBuilders.patch(url) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(testUpdateJson)) + ).andExpect(status().isOk()) + .andExpect(content().contentType(MediaType.APPLICATION_JSON)) + .andExpect(content().json(objectMapper.writeValueAsString(testJson))); + + assertEquals(dockerImage, test.getDockerImage()); + assertEquals(dockerTestScript, test.getDockerTestScript()); + assertNull(test.getDockerTestTemplate()); + assertNull(test.getStructureTemplate()); + + verify(projectRepository, times(2)).save(project); + assertEquals(test.getId(), project.getTestId()); + + reset(testUtil); + when(testUtil.checkForTestUpdate( + eq(project.getId()), + eq(getMockUser()), + eq(null), + eq(null), + eq(dockerTestTemplate), + eq(HttpMethod.PATCH) + )).thenReturn(new CheckResult<>(HttpStatus.OK, "",new Pair<>(test, project))); + + testUpdateJson = new TestUpdateJson( + null, + null, + dockerTestTemplate, + null + ); + + mockMvc.perform(MockMvcRequestBuilders.patch(url) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(testUpdateJson)) + ).andExpect(status().isOk()) + .andExpect(content().contentType(MediaType.APPLICATION_JSON)) + .andExpect(content().json(objectMapper.writeValueAsString(testJson))); + + assertEquals(dockerImage, test.getDockerImage()); + assertEquals(dockerTestScript, test.getDockerTestScript()); + assertEquals(dockerTestTemplate, test.getDockerTestTemplate()); + assertNull(test.getStructureTemplate()); + + verify(projectRepository, times(3)).save(project); + assertEquals(test.getId(), project.getTestId()); + + reset(testUtil); + when(testUtil.checkForTestUpdate( + eq(project.getId()), + eq(getMockUser()), + eq(null), + eq(null), + eq(null), + eq(HttpMethod.PATCH) + )).thenReturn(new CheckResult<>(HttpStatus.OK, "",new Pair<>(test, project))); + + testUpdateJson = new TestUpdateJson( + null, + null, + null, + structureTemplate + ); + + mockMvc.perform(MockMvcRequestBuilders.patch(url) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(testUpdateJson)) + ).andExpect(status().isOk()) + .andExpect(content().contentType(MediaType.APPLICATION_JSON)) + .andExpect(content().json(objectMapper.writeValueAsString(testJson))); + + assertEquals(dockerImage, test.getDockerImage()); + assertEquals(dockerTestScript, test.getDockerTestScript()); + assertEquals(dockerTestTemplate, test.getDockerTestTemplate()); + assertEquals(structureTemplate, test.getStructureTemplate()); + + verify(projectRepository, times(4)).save(project); + assertEquals(test.getId(), project.getTestId()); + + /* Check fails */ + when(testUtil.checkForTestUpdate( + eq(project.getId()), + eq(getMockUser()), + eq(dockerImage), + eq(null), + eq(null), + eq(HttpMethod.PATCH) + )).thenReturn(new CheckResult<>(HttpStatus.I_AM_A_TEAPOT, "I'm a teapot", null)); + + testUpdateJson = new TestUpdateJson( + dockerImage, + null, + null, + null + ); + + mockMvc.perform(MockMvcRequestBuilders.patch(url) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(testUpdateJson)) + ).andExpect(status().isIAmATeapot()); + } + + @Test + public void testGetTest() throws Exception { + String url = ApiRoutes.PROJECT_BASE_PATH + "/" + project.getId() + "/tests"; + + /* All checks succeed */ + when(testUtil.getTestWithAdminStatus(project.getId(), getMockUser())) + .thenReturn(new CheckResult<>(HttpStatus.OK, "", new Pair<>(test, true))); + + when(entityToJsonConverter.testEntityToTestJson(test, project.getId())).thenReturn(testJson); + + mockMvc.perform(MockMvcRequestBuilders.get(url)) + .andExpect(status().isOk()) + .andExpect(content().contentType(MediaType.APPLICATION_JSON)) + .andExpect(content().json(objectMapper.writeValueAsString(testJson))); + + /* Check succeed but user isn't admin */ + when(testUtil.getTestWithAdminStatus(project.getId(), getMockUser())) + .thenReturn(new CheckResult<>(HttpStatus.OK, "", new Pair<>(test, false))); + + testJson.setDockerImage(null); + testJson.setDockerScript(null); + mockMvc.perform(MockMvcRequestBuilders.get(url)) + .andExpect(status().isOk()) + .andExpect(content().contentType(MediaType.APPLICATION_JSON)) + .andExpect(content().json(objectMapper.writeValueAsString(testJson))); + + /* Check fails */ + when(testUtil.getTestWithAdminStatus(project.getId(), getMockUser())) + .thenReturn(new CheckResult<>(HttpStatus.I_AM_A_TEAPOT, "I'm a teapot", null)); + + mockMvc.perform(MockMvcRequestBuilders.get(url)); + } + + @Test + public void testDeleteTest() throws Exception { + String url = ApiRoutes.PROJECT_BASE_PATH + "/" + project.getId() + "/tests"; + + /* All checks succeed */ + when(testUtil.checkForTestUpdate( + eq(project.getId()), + eq(getMockUser()), + eq(null), + eq(null), + eq(null), + eq(HttpMethod.DELETE) + )).thenReturn(new CheckResult<>(HttpStatus.OK, "", new Pair<>(test, project))); + + when(commonDatabaseActions.deleteTestById(project, test)).thenReturn(new CheckResult<>(HttpStatus.OK, "", null)); + + mockMvc.perform(MockMvcRequestBuilders.delete(url)) + .andExpect(status().isOk()); + + /* Deleting fails */ + when(commonDatabaseActions.deleteTestById(project, test)).thenReturn(new CheckResult<>(HttpStatus.I_AM_A_TEAPOT, "I'm a teapot", null)); + + mockMvc.perform(MockMvcRequestBuilders.delete(url)) + .andExpect(status().isIAmATeapot()); + + /* Check fails */ + when(testUtil.checkForTestUpdate( + eq(project.getId()), + eq(getMockUser()), + eq(null), + eq(null), + eq(null), + eq(HttpMethod.DELETE) + )).thenReturn(new CheckResult<>(HttpStatus.I_AM_A_TEAPOT, "I'm a teapot", null)); + + mockMvc.perform(MockMvcRequestBuilders.delete(url)) + .andExpect(status().isIAmATeapot()); } - //TODO: tests schrijven eens de backend stabiel is. } diff --git a/backend/app/src/test/java/com/ugent/pidgeon/controllers/UserControllerTest.java b/backend/app/src/test/java/com/ugent/pidgeon/controllers/UserControllerTest.java index 9f848b42..a60a6cf2 100644 --- a/backend/app/src/test/java/com/ugent/pidgeon/controllers/UserControllerTest.java +++ b/backend/app/src/test/java/com/ugent/pidgeon/controllers/UserControllerTest.java @@ -1,14 +1,25 @@ package com.ugent.pidgeon.controllers; +import static org.junit.jupiter.api.Assertions.assertEquals; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.ArgumentMatchers.argThat; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.reset; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.ugent.pidgeon.CustomObjectMapper; +import com.ugent.pidgeon.model.json.UserJson; import com.ugent.pidgeon.postgre.models.UserEntity; import com.ugent.pidgeon.postgre.models.types.UserRole; import com.ugent.pidgeon.util.CheckResult; import com.ugent.pidgeon.util.UserUtil; +import java.util.List; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; @@ -31,81 +42,384 @@ public class UserControllerTest extends ControllerTest { private UserController userController; private UserEntity userEntity; + private UserJson userJson; + private UserJson mockUserJson; + private ObjectMapper objectMapper = CustomObjectMapper.createObjectMapper(); @BeforeEach public void setup() { - mockMvc = MockMvcBuilders.standaloneSetup(userController) - .defaultRequest(MockMvcRequestBuilders.get("/**") - .with(request -> { - request.setUserPrincipal(SecurityContextHolder.getContext().getAuthentication()); - return request; - })) - .build(); - userEntity = new UserEntity("name", "surname", "email", UserRole.student, "azureId"); + setUpController(userController); + userEntity = new UserEntity("Bob", "Testman", "email", UserRole.student, "azureId"); + userEntity.setId(74L); + mockUserJson = new UserJson(getMockUser()); + userJson = new UserJson(userEntity); } @Test public void testGetUserById() throws Exception { + String url = ApiRoutes.USERS_BASE_PATH + "/" + getMockUser().getId(); + String urlSomeoneElse = ApiRoutes.USERS_BASE_PATH + "/" + userEntity.getId(); + /* Can get ur own user information */ + when(userUtil.getUserIfExists(getMockUser().getId())).thenReturn(getMockUser()); + mockMvc.perform(MockMvcRequestBuilders.get(url)) + .andExpect(status().isOk()) + .andExpect(content().contentType(MediaType.APPLICATION_JSON)) + .andExpect(content().json(objectMapper.writeValueAsString(mockUserJson))); + + /* Can't get someone else's user information */ when(userUtil.getUserIfExists(anyLong())).thenReturn(userEntity); - mockMvc.perform(MockMvcRequestBuilders.get(ApiRoutes.USERS_BASE_PATH + "/1")) - .andExpect(status().isOk()); + mockMvc.perform(MockMvcRequestBuilders.get(urlSomeoneElse)) + .andExpect(status().isForbidden()); + + /* Admin can get someone else's user information */ + getMockUser().setRole(UserRole.admin); + mockMvc.perform(MockMvcRequestBuilders.get(urlSomeoneElse)) + .andExpect(status().isOk()) + .andExpect(content().contentType(MediaType.APPLICATION_JSON)) + .andExpect(content().json(objectMapper.writeValueAsString(userJson))); + /* If user not found return 404 */ when(userUtil.getUserIfExists(anyLong())).thenReturn(null); - mockMvc.perform(MockMvcRequestBuilders.get(ApiRoutes.USERS_BASE_PATH + "/1")) + mockMvc.perform(MockMvcRequestBuilders.get(ApiRoutes.USERS_BASE_PATH + "/999")) .andExpect(status().isNotFound()); + } + + private String createGetUsersUrl(String name, String surname, String email) { + String start = ApiRoutes.USERS_BASE_PATH; + boolean first = true; + if (name != null) { + start += "?name=" + name; + first = false; + } + if (surname != null) { + if (first) { + start += "?surname=" + surname; + first = false; + } else { + start += "&surname=" + surname; + } + } + if (email != null) { + if (first) { + start += "?email=" + email; + first = false; + } else { + start += "&email=" + email; + } + } + return start; + } - mockMvc.perform(MockMvcRequestBuilders.get(ApiRoutes.USERS_BASE_PATH + "/2")) + @Test + public void testGetUsersByNameOrSurname() throws Exception { + setMockUserRoles(UserRole.admin); + /* If email is present in the url, user gets returned based on email */ + String url = createGetUsersUrl(null, null, "email"); + when(userRepository.findByEmail("email")).thenReturn(userEntity); + mockMvc.perform(MockMvcRequestBuilders.get(url)) + .andExpect(status().isOk()) + .andExpect(content().contentType(MediaType.APPLICATION_JSON)) + .andExpect(content().json(objectMapper.writeValueAsString(List.of(userJson)))); + + /* If email and name are present they need to match case insensitive */ + url = createGetUsersUrl("name", null, "email"); + mockMvc.perform(MockMvcRequestBuilders.get(url)) + .andExpect(status().isOk()) + .andExpect(content().contentType(MediaType.APPLICATION_JSON)) + .andExpect(content().json("[]")); + + url = createGetUsersUrl(userEntity.getName(), null, "email"); + mockMvc.perform(MockMvcRequestBuilders.get(url)) + .andExpect(status().isOk()) + .andExpect(content().contentType(MediaType.APPLICATION_JSON)) + .andExpect(content().json(objectMapper.writeValueAsString(List.of(userJson)))); + + url = createGetUsersUrl(userEntity.getName().toUpperCase(), null, "email"); + mockMvc.perform(MockMvcRequestBuilders.get(url)) + .andExpect(status().isOk()) + .andExpect(content().contentType(MediaType.APPLICATION_JSON)) + .andExpect(content().json(objectMapper.writeValueAsString(List.of(userJson)))); + + /* If email and surname are present they need to match case insensitive */ + url = createGetUsersUrl(null, "surname", "email"); + mockMvc.perform(MockMvcRequestBuilders.get(url)) + .andExpect(status().isOk()) + .andExpect(content().contentType(MediaType.APPLICATION_JSON)) + .andExpect(content().json("[]")); + + url = createGetUsersUrl(null, userEntity.getSurname(), "email"); + mockMvc.perform(MockMvcRequestBuilders.get(url)) + .andExpect(status().isOk()) + .andExpect(content().contentType(MediaType.APPLICATION_JSON)) + .andExpect(content().json(objectMapper.writeValueAsString(List.of(userJson)))); + + /* If all three are present they need to match case insensitive */ + url = createGetUsersUrl("name", "surname", "email"); + mockMvc.perform(MockMvcRequestBuilders.get(url)) + .andExpect(status().isOk()) + .andExpect(content().contentType(MediaType.APPLICATION_JSON)) + .andExpect(content().json("[]")); + + url = createGetUsersUrl(userEntity.getName(), userEntity.getSurname(), "email"); + mockMvc.perform(MockMvcRequestBuilders.get(url)) + .andExpect(status().isOk()) + .andExpect(content().contentType(MediaType.APPLICATION_JSON)) + .andExpect(content().json(objectMapper.writeValueAsString(List.of(userJson)))); + + url = createGetUsersUrl(userEntity.getName().toUpperCase(), userEntity.getSurname().toUpperCase(), "email"); + mockMvc.perform(MockMvcRequestBuilders.get(url)) + .andExpect(status().isOk()) + .andExpect(content().contentType(MediaType.APPLICATION_JSON)) + .andExpect(content().json(objectMapper.writeValueAsString(List.of(userJson)))); + + url = createGetUsersUrl(null, userEntity.getSurname().toUpperCase(), "email"); + mockMvc.perform(MockMvcRequestBuilders.get(url)) + .andExpect(status().isOk()) + .andExpect(content().contentType(MediaType.APPLICATION_JSON)) + .andExpect(content().json(objectMapper.writeValueAsString(List.of(userJson)))); + + /* If no user with email return empty list */ + when(userRepository.findByEmail("email")).thenReturn(null); + url = createGetUsersUrl(null, null, "email"); + mockMvc.perform(MockMvcRequestBuilders.get(url)) + .andExpect(status().isOk()) + .andExpect(content().contentType(MediaType.APPLICATION_JSON)) + .andExpect(content().json("[]")); + + /* If email isn't present in the url, users get returned based on name and surname */ + url = createGetUsersUrl("name", "surname", null); + when(userRepository.findByName("name", "surname")).thenReturn(List.of(userEntity)); + mockMvc.perform(MockMvcRequestBuilders.get(url)) + .andExpect(status().isOk()) + .andExpect(content().contentType(MediaType.APPLICATION_JSON)) + .andExpect(content().json(objectMapper.writeValueAsString(List.of(userJson)))); + + /* If both name and surname are less than 3 characters, return empty list */ + url = createGetUsersUrl("na", "su", null); + mockMvc.perform(MockMvcRequestBuilders.get(url)) + .andExpect(status().isOk()) + .andExpect(content().contentType(MediaType.APPLICATION_JSON)) + .andExpect(content().json("[]")); + + /* If one of the two is long enough, return the user */ + url = createGetUsersUrl("name", "su", null); + when(userRepository.findByName("name", "su")).thenReturn(List.of(userEntity)); + mockMvc.perform(MockMvcRequestBuilders.get(url)) + .andExpect(status().isOk()) + .andExpect(content().contentType(MediaType.APPLICATION_JSON)) + .andExpect(content().json(objectMapper.writeValueAsString(List.of(userJson)))); + + /* If only name, return based on name, needs to be longer then 3 characters */ + url = createGetUsersUrl("name", null, null); + when(userRepository.findByName("name", "")).thenReturn(List.of(userEntity)); + mockMvc.perform(MockMvcRequestBuilders.get(url)) + .andExpect(status().isOk()) + .andExpect(content().contentType(MediaType.APPLICATION_JSON)) + .andExpect(content().json(objectMapper.writeValueAsString(List.of(userJson)))); + + url = createGetUsersUrl("na", null, null); + mockMvc.perform(MockMvcRequestBuilders.get(url)) + .andExpect(status().isOk()) + .andExpect(content().contentType(MediaType.APPLICATION_JSON)) + .andExpect(content().json("[]")); + + /* If only surname, return based on surname, needs to be longer then 3 characters */ + url = createGetUsersUrl(null, "surname", null); + when(userRepository.findByName("", "surname")).thenReturn(List.of(userEntity)); + mockMvc.perform(MockMvcRequestBuilders.get(url)) + .andExpect(status().isOk()) + .andExpect(content().contentType(MediaType.APPLICATION_JSON)) + .andExpect(content().json(objectMapper.writeValueAsString(List.of(userJson)))); + + url = createGetUsersUrl(null, "su", null); + mockMvc.perform(MockMvcRequestBuilders.get(url)) + .andExpect(status().isOk()) + .andExpect(content().contentType(MediaType.APPLICATION_JSON)) + .andExpect(content().json("[]")); + + /* Only admin can use this route */ + setMockUserRoles(UserRole.student); + mockMvc.perform(MockMvcRequestBuilders.get(ApiRoutes.USERS_BASE_PATH)) + .andExpect(status().isForbidden()); + + setMockUserRoles(UserRole.teacher); + mockMvc.perform(MockMvcRequestBuilders.get(ApiRoutes.USERS_BASE_PATH)) .andExpect(status().isForbidden()); } @Test - public void testGetUserByAzureId() throws Exception { + public void testGetLoggedInUser() throws Exception { mockMvc.perform(MockMvcRequestBuilders.get(ApiRoutes.LOGGEDIN_USER_PATH)) - .andExpect(status().isOk()); + .andExpect(status().isOk()) + .andExpect(content().contentType(MediaType.APPLICATION_JSON)) + .andExpect(content().json(objectMapper.writeValueAsString(mockUserJson))); } @Test public void testUpdateUserById() throws Exception { - String request = "{\"name\":\"John\",\"surname\":\"Doe\",\"email\":\"john@example.com\",\"role\":\"admin\"}"; - when(userUtil.checkForUserUpdateJson(anyLong(), any())). - thenReturn(new CheckResult<>(HttpStatus.OK, "", userEntity)); - mockMvc.perform(MockMvcRequestBuilders.put(ApiRoutes.USERS_BASE_PATH + "/1") + setMockUserRoles(UserRole.admin); + String url = ApiRoutes.USERS_BASE_PATH + "/" + userEntity.getId(); + String request = "{\"name\":\"John\",\"surname\":\"Doe\",\"email\":\"john@example.com\",\"role\":\"teacher\"}"; + UserEntity updateUserEntity = new UserEntity("John", "Doe", "john@example.com", UserRole.teacher, "azureId"); + updateUserEntity.setId(userEntity.getId()); + UserJson updatedUserJson = new UserJson(updateUserEntity); + + when(userUtil.checkForUserUpdateJson(eq(userEntity.getId()), argThat( + json -> json.getName().equals("John") && + json.getSurname().equals("Doe") && + json.getEmail().equals("john@example.com") && + json.getRoleAsEnum().equals(UserRole.teacher))) + ) + .thenReturn(new CheckResult<>(HttpStatus.OK, "", userEntity)); + mockMvc.perform(MockMvcRequestBuilders.put(url) .contentType(MediaType.APPLICATION_JSON) .content(request)) - .andExpect(status().isOk()); + .andExpect(status().isOk()) + .andExpect(content().contentType(MediaType.APPLICATION_JSON)) + .andExpect(content().json(objectMapper.writeValueAsString(updatedUserJson))); + verify(userRepository, times(1)).save(userEntity); + assertEquals("John", userEntity.getName()); + assertEquals("Doe", userEntity.getSurname()); + assertEquals("john@example.com", userEntity.getEmail()); + assertEquals(UserRole.teacher, userEntity.getRole()); + + /* If updatecheck fails return corresponding status */ + reset(userUtil); + when(userUtil.checkForUserUpdateJson(anyLong(), any())) + .thenReturn(new CheckResult<>(HttpStatus.I_AM_A_TEAPOT, "", null)); + mockMvc.perform(MockMvcRequestBuilders.put(url) + .contentType(MediaType.APPLICATION_JSON) + .content(request)) + .andExpect(status().isIAmATeapot()); + + /* Only admin can update user */ + setMockUserRoles(UserRole.student); + mockMvc.perform(MockMvcRequestBuilders.put(url) + .contentType(MediaType.APPLICATION_JSON) + .content(request)) + .andExpect(status().isForbidden()); - when(userUtil.checkForUserUpdateJson(anyLong(), any())). - thenReturn(new CheckResult<>(HttpStatus.BAD_REQUEST, "", null)); - mockMvc.perform(MockMvcRequestBuilders.put(ApiRoutes.USERS_BASE_PATH + "/1") + setMockUserRoles(UserRole.teacher); + mockMvc.perform(MockMvcRequestBuilders.put(url) .contentType(MediaType.APPLICATION_JSON) .content(request)) - .andExpect(status().isBadRequest()); + .andExpect(status().isForbidden()); } @Test public void testPatchUserById() throws Exception { - String request = "{\"name\": null,\"surname\": null,\"email\": null,\"role\": null}"; - when(userUtil.getUserIfExists(anyLong())).thenReturn(userEntity); - when(userUtil.checkForUserUpdateJson(anyLong(), any())) + setMockUserRoles(UserRole.admin); + String url = ApiRoutes.USERS_BASE_PATH + "/" + userEntity.getId(); + String request = "{\"name\":\"John\",\"surname\":\"Doe\",\"email\":\"john@example.com\",\"role\":\"teacher\"}"; + UserEntity updateUserEntity = new UserEntity("John", "Doe", "john@example.com", UserRole.teacher, "azureId"); + updateUserEntity.setId(userEntity.getId()); + UserJson updatedUserJson = new UserJson(updateUserEntity); + String originalName = userEntity.getName(); + String originalSurname = userEntity.getSurname(); + String originalEmail = userEntity.getEmail(); + UserRole originalRole = userEntity.getRole(); + + /* If all fields are present, update them all */ + when(userUtil.getUserIfExists(userEntity.getId())).thenReturn(userEntity); + when(userUtil.checkForUserUpdateJson(eq(userEntity.getId()), argThat( + json -> json.getName().equals("John") && + json.getSurname().equals("Doe") && + json.getEmail().equals("john@example.com") && + json.getRoleAsEnum().equals(UserRole.teacher))) + ) + .thenReturn(new CheckResult<>(HttpStatus.OK, "", userEntity)); + mockMvc.perform(MockMvcRequestBuilders.patch(url) + .contentType(MediaType.APPLICATION_JSON) + .content(request)) + .andExpect(status().isOk()) + .andExpect(content().contentType(MediaType.APPLICATION_JSON)) + .andExpect(content().json(objectMapper.writeValueAsString(updatedUserJson))); + verify(userRepository, times(1)).save(userEntity); + assertEquals("John", userEntity.getName()); + assertEquals("Doe", userEntity.getSurname()); + assertEquals("john@example.com", userEntity.getEmail()); + assertEquals(UserRole.teacher, userEntity.getRole()); + userEntity.setName(originalName); + userEntity.setSurname(originalSurname); + userEntity.setEmail(originalEmail); + userEntity.setRole(originalRole); + + + /* If not all fields are present, update only the ones that are */ + request = "{\"name\":\"Tom\"}"; + reset(userUtil); + when(userUtil.getUserIfExists(userEntity.getId())).thenReturn(userEntity); + when(userUtil.checkForUserUpdateJson(eq(userEntity.getId()), argThat( + json -> json.getName().equals("Tom") && + json.getSurname().equals(userEntity.getSurname()) && + json.getEmail().equals(userEntity.getEmail()) && + json.getRoleAsEnum().equals(userEntity.getRole()))) + ) + .thenReturn(new CheckResult<>(HttpStatus.OK, "", userEntity)); + mockMvc.perform(MockMvcRequestBuilders.patch(url) + .contentType(MediaType.APPLICATION_JSON) + .content(request)) + .andExpect(status().isOk()); + + verify(userRepository, times(2)).save(userEntity); + assertEquals("Tom", userEntity.getName()); + assertEquals(originalSurname, userEntity.getSurname()); + assertEquals(originalEmail, userEntity.getEmail()); + assertEquals(originalRole, userEntity.getRole()); + userEntity.setName(originalName); + + request = "{\"surname\":\"Riddle\"}"; + reset(userUtil); + when(userUtil.getUserIfExists(userEntity.getId())).thenReturn(userEntity); + when(userUtil.checkForUserUpdateJson(eq(userEntity.getId()), argThat( + json -> json.getName().equals(userEntity.getName()) && + json.getSurname().equals("Riddle") && + json.getEmail().equals(userEntity.getEmail()) && + json.getRoleAsEnum().equals(userEntity.getRole()))) + ) .thenReturn(new CheckResult<>(HttpStatus.OK, "", userEntity)); - mockMvc.perform(MockMvcRequestBuilders.patch(ApiRoutes.USERS_BASE_PATH + "/1") + mockMvc.perform(MockMvcRequestBuilders.patch(url) .contentType(MediaType.APPLICATION_JSON) .content(request)) .andExpect(status().isOk()); + verify(userRepository, times(3)).save(userEntity); + assertEquals(originalName, userEntity.getName()); + assertEquals("Riddle", userEntity.getSurname()); + assertEquals(originalEmail, userEntity.getEmail()); + assertEquals(originalRole, userEntity.getRole()); + + /* If updatecheck fails return corresponding status */ + reset(userUtil); + when(userUtil.getUserIfExists(userEntity.getId())).thenReturn(userEntity); when(userUtil.checkForUserUpdateJson(anyLong(), any())) - .thenReturn(new CheckResult<>(HttpStatus.BAD_REQUEST, "", null)); - mockMvc.perform(MockMvcRequestBuilders.patch(ApiRoutes.USERS_BASE_PATH + "/1") + .thenReturn(new CheckResult<>(HttpStatus.I_AM_A_TEAPOT, "", null)); + mockMvc.perform(MockMvcRequestBuilders.patch(url) .contentType(MediaType.APPLICATION_JSON) .content(request)) - .andExpect(status().isBadRequest()); + .andExpect(status().isIAmATeapot()); - when(userUtil.getUserIfExists(anyLong())).thenReturn(null); - mockMvc.perform(MockMvcRequestBuilders.patch(ApiRoutes.USERS_BASE_PATH + "/1") + /* If user doesn't exist return 404 */ + when(userUtil.getUserIfExists(userEntity.getId())).thenReturn(null); + mockMvc.perform(MockMvcRequestBuilders.patch(url) .contentType(MediaType.APPLICATION_JSON) .content(request)) .andExpect(status().isNotFound()); + + /* Only admin can update user */ + setMockUserRoles(UserRole.student); + mockMvc.perform(MockMvcRequestBuilders.patch(url) + .contentType(MediaType.APPLICATION_JSON) + .content(request)) + .andExpect(status().isForbidden()); + + setMockUserRoles(UserRole.teacher); + mockMvc.perform(MockMvcRequestBuilders.patch(url) + .contentType(MediaType.APPLICATION_JSON) + .content(request)) + .andExpect(status().isForbidden()); } + } diff --git a/backend/app/src/test/java/com/ugent/pidgeon/docker/DockerSubmissionTestTest.java b/backend/app/src/test/java/com/ugent/pidgeon/docker/DockerSubmissionTestTest.java new file mode 100644 index 00000000..e0507156 --- /dev/null +++ b/backend/app/src/test/java/com/ugent/pidgeon/docker/DockerSubmissionTestTest.java @@ -0,0 +1,218 @@ +package com.ugent.pidgeon.docker; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import com.ugent.pidgeon.model.submissionTesting.DockerSubmissionTestModel; +import com.ugent.pidgeon.model.submissionTesting.DockerSubtestResult; +import com.ugent.pidgeon.model.submissionTesting.DockerTemplateTestOutput; +import com.ugent.pidgeon.model.submissionTesting.DockerTestOutput; +import java.io.File; +import java.io.FileOutputStream; +import java.io.IOException; +import java.util.List; +import java.util.zip.ZipEntry; +import java.util.zip.ZipFile; +import java.util.zip.ZipOutputStream; +import org.apache.commons.io.FileUtils; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Test; + +public class DockerSubmissionTestTest { + + @AfterEach + void cleanUp() { + File file = new File(System.getProperty("user.dir") + "/tmp/test"); + try { + FileUtils.deleteDirectory(file); + } catch (IOException e) { + e.printStackTrace(); + } + } + + File initTestFile(String text, String fileName) { + String localFileLocation = System.getProperty("user.dir") + "/tmp/test/" + fileName; + File file = new File(localFileLocation); + try { + file.getParentFile().mkdirs(); + file.createNewFile(); + FileUtils.writeStringToFile(file, text, "UTF-8"); + } catch (Exception e) { + e.printStackTrace(); + } + return file; + } + + // Check if we can catch the console output of a script. + @Test + void scriptSucceeds() throws InterruptedException { + DockerSubmissionTestModel.installImage("fedora:latest"); + // Load docker container + DockerSubmissionTestModel stm = new DockerSubmissionTestModel("fedora"); + // Run script + DockerTestOutput to = stm.runSubmission("echo 'PUSH ALLOWED' > /shared//output/testOutput"); + assertTrue(to.allowed); + stm.cleanUp(); + } + + @Test + void scriptFails() throws InterruptedException { + //make sure docker image is installed + DockerSubmissionTestModel.installImage("fedora:latest"); + // Load docker container + DockerSubmissionTestModel stm = new DockerSubmissionTestModel("fedora"); + // Run script + // Example for running a bash script correctly + DockerTestOutput to = stm.runSubmission("echo 'PUSH DENIED' > /shared/output/testOutput"); + assertFalse(to.allowed); + stm.cleanUp(); + } + + @Test + void catchesConsoleLogs() throws InterruptedException { + DockerSubmissionTestModel.installImage("alpine:latest"); + // Load docker container + DockerSubmissionTestModel stm = new DockerSubmissionTestModel("alpine"); + // Run script + // Example for running a bash script correctly + DockerTestOutput to = stm.runSubmission( + "echo 'Woopdie Woop Scoop! ~ KW'; echo 'PUSH ALLOWED' > /shared/output/testOutput"); + + assertTrue(to.allowed); + assertEquals(to.logs.get(0), "Woopdie Woop Scoop! ~ KW\n"); + stm.cleanUp(); + } + + @Test + void correctlyReceivesInputFiles() throws InterruptedException { + DockerSubmissionTestModel.installImage("alpine:latest"); + // Load docker container + DockerSubmissionTestModel stm = new DockerSubmissionTestModel("alpine"); + + // Create an input file in tmp/test/input + File file = initTestFile("This is a test input file\n", "testInput"); + stm.addInputFiles(new File[]{file}); + // Run script + // Example for running a bash script correctly + DockerTestOutput to = stm.runSubmission( + "cat /shared/input/testInput; echo PUSH ALLOWED > /shared/output/testOutput"); + assertEquals(to.logs.get(0), "This is a test input file\n"); + stm.cleanUp(); + } + + @Test + void templateTest() throws InterruptedException { + String testOne = "@HelloWorld\n" + + ">Description=\"Test for hello world!\"\n" + + ">Required\n" + + "HelloWorld!\n"; + String testTwo = "@HelloWorld2\n" + + ">Optional\n" + + "HelloWorld2!\n"; + String template = testOne + "\n" + testTwo + "\n"; + + File[] files = new File[]{initTestFile("#!/bin/sh\necho 'HelloWorld!'", "HelloWorld.sh"), + initTestFile("#!/bin/sh\necho 'HelloWorld2!'", "HelloWorld2.sh")}; + + String script = + "chmod +x /shared/input/HelloWorld.sh;" + + "chmod +x /shared/input/HelloWorld2.sh;" + + "/shared/input/HelloWorld.sh > /shared/output/HelloWorld;" + + "/shared/input/HelloWorld2.sh > /shared/output/HelloWorld2"; + + DockerSubmissionTestModel.installImage("alpine:latest"); + // Load docker container + DockerSubmissionTestModel stm = new DockerSubmissionTestModel("alpine:latest"); + stm.addInputFiles(files); + DockerTemplateTestOutput result = stm.runSubmissionWithTemplate(script, template); + + // Extract subtests + List results = result.getSubtestResults(); + + stm.cleanUp(); + + // Testing for the template parser capabilities + assertEquals(results.size(), 2); + + assertTrue(results.get(0).isRequired()); + assertFalse(results.get(1).isRequired()); + + assertEquals(results.get(0).getCorrect(), "HelloWorld!\n"); + assertEquals(results.get(1).getCorrect(), "HelloWorld2!\n"); + + assertEquals(results.get(0).getTestDescription(), "Test for hello world!"); + assertEquals(results.get(1).getTestDescription(), ""); + + // Test the docker output + assertEquals(results.get(0).getOutput(), "HelloWorld!\n"); + assertEquals(results.get(1).getOutput(), "HelloWorld2!\n"); + + assertTrue(result.isAllowed()); + } + + @Test + void artifactTest() throws IOException { + DockerSubmissionTestModel stm = new DockerSubmissionTestModel("alpine:latest"); + String script = + "echo 'HelloWorld!' > /shared/artifacts/HelloWorld"; + + DockerTestOutput to = stm.runSubmission(script); + assertFalse(to.allowed); + // check file properties + List files = stm.getArtifacts(); + assertEquals(files.size(), 1); + assertEquals(files.get(0).getName(), "HelloWorld"); + // check file contents + assertEquals("HelloWorld!\n", FileUtils.readFileToString(files.get(0), "UTF-8")); + stm.cleanUp(); + } + + @Test + void zipFileInputTest() throws IOException { + // construct zip with hello world contents + StringBuilder sb = new StringBuilder(); + sb.append("Hello Happy World!"); + + File f = new File("src/test/test-cases/DockerSubmissionTestTest/d__test.zip"); + ZipOutputStream out = new ZipOutputStream(new FileOutputStream(f)); + ZipEntry e = new ZipEntry("helloworld.txt"); + out.putNextEntry(e); + + byte[] data = sb.toString().getBytes(); + out.write(data, 0, data.length); + out.closeEntry(); + out.close(); + + DockerSubmissionTestModel stm = new DockerSubmissionTestModel("alpine:latest"); + // get zipfile + stm.addZipInputFiles(new ZipFile(f)); + DockerTestOutput output = stm.runSubmission("cat /shared/input/helloworld.txt"); + // run and check if zipfile was properly received + assertEquals( "Hello Happy World!", output.logs.get(0)); + stm.cleanUp(); + + } + @Test + void dockerImageDoesNotExist(){ + assertFalse(DockerSubmissionTestModel.imageExists("BADUBADUBADUBADUBADUBADUB")); + assertTrue(DockerSubmissionTestModel.imageExists("alpine:latest")); + } + + @Test + void isValidTemplate(){ + assertFalse(DockerSubmissionTestModel.isValidTemplate("This is not a valid template")); + assertTrue(DockerSubmissionTestModel.isValidTemplate("@HelloWorld\n" + + ">Description=\"Test for hello world!\"\n" + + ">Required\n" + + "HelloWorld!")); + assertTrue(DockerSubmissionTestModel.isValidTemplate("@helloworld\n" + + ">required\n" + + ">description=\"Helloworldtest\"\n" + + "Hello World\n" + + "\n" + + "@helloworld2\n" + + "bruh\n")); + } + +} diff --git a/backend/app/src/test/java/com/ugent/pidgeon/global/GlobalErrorHandlerTest.java b/backend/app/src/test/java/com/ugent/pidgeon/global/GlobalErrorHandlerTest.java new file mode 100644 index 00000000..7659c309 --- /dev/null +++ b/backend/app/src/test/java/com/ugent/pidgeon/global/GlobalErrorHandlerTest.java @@ -0,0 +1,72 @@ +package com.ugent.pidgeon.global; + +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.Mockito.when; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import com.ugent.pidgeon.controllers.ApiRoutes; +import com.ugent.pidgeon.controllers.ControllerTest; +import com.ugent.pidgeon.controllers.UserController; +import com.ugent.pidgeon.postgre.models.types.UserRole; +import com.ugent.pidgeon.postgre.repository.UserRepository; +import com.ugent.pidgeon.util.UserUtil; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.test.web.servlet.request.MockMvcRequestBuilders; + + + +@ExtendWith(MockitoExtension.class) +public class GlobalErrorHandlerTest extends ControllerTest { + + @Mock + private UserUtil userUtil; + + @InjectMocks + private UserController userController; + + + @BeforeEach + public void setUp() { + setUpController(userController); + } + + @Test + public void testHandleHttpMessageNotReadableException() throws Exception { + setMockUserRoles(UserRole.admin); + mockMvc.perform(MockMvcRequestBuilders.put(ApiRoutes.USERS_BASE_PATH + "/1") + .contentType("application/json") + .content("") + ).andExpect(status().isBadRequest()); + } + + @Test + public void testHandleNoHandlerFound() throws Exception { + mockMvc.perform(MockMvcRequestBuilders.get("/api/doesntexist", 1L) + ).andExpect(status().isNotFound()); + } + + @Test + public void testHandleMethodNotSupportedException() throws Exception { + mockMvc.perform(MockMvcRequestBuilders.multipart(ApiRoutes.USERS_BASE_PATH + "/1") + ).andExpect(status().isMethodNotAllowed()); + } + + @Test + public void testHandleMethodArgumentTypeMismatchException() throws Exception { + mockMvc.perform(MockMvcRequestBuilders.get(ApiRoutes.USERS_BASE_PATH + "/string") + ).andExpect(status().isBadRequest()); + } + + @Test + public void testUnexpectedException() throws Exception { + when(userUtil.getUserIfExists(anyLong())).thenThrow(new RuntimeException("Unexpected exception")); + mockMvc.perform(MockMvcRequestBuilders.get(ApiRoutes.USERS_BASE_PATH + "/1") + ).andExpect(status().isInternalServerError()); + } + +} diff --git a/backend/app/src/test/java/com/ugent/pidgeon/global/RolesInterceptorTest.java b/backend/app/src/test/java/com/ugent/pidgeon/global/RolesInterceptorTest.java new file mode 100644 index 00000000..bff3e4e1 --- /dev/null +++ b/backend/app/src/test/java/com/ugent/pidgeon/global/RolesInterceptorTest.java @@ -0,0 +1,88 @@ +package com.ugent.pidgeon.global; + + +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.ArgumentMatchers.argThat; +import static org.mockito.Mockito.reset; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import com.ugent.pidgeon.controllers.ApiRoutes; +import com.ugent.pidgeon.controllers.ControllerTest; +import com.ugent.pidgeon.controllers.UserController; +import com.ugent.pidgeon.postgre.models.types.UserRole; +import com.ugent.pidgeon.util.UserUtil; +import java.time.Duration; +import java.time.OffsetDateTime; +import java.util.Optional; +import java.util.logging.Logger; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.test.web.servlet.request.MockMvcRequestBuilders; + +@ExtendWith(MockitoExtension.class) +public class RolesInterceptorTest extends ControllerTest { + + @Mock + private UserUtil userUtil; + + @InjectMocks + private UserController userController; + + + @BeforeEach + public void setUp() { + setUpController(userController); + } + + @Test + void testEverthingWorks() throws Exception { + when(userUtil.getUserIfExists(getMockUser().getId())).thenReturn(getMockUser()); + mockMvc.perform(MockMvcRequestBuilders.get(ApiRoutes.USERS_BASE_PATH + "/1") + ).andExpect(status().isOk()); + } + + @Test + void testNotRequiredRole() throws Exception { + setMockUserRoles(UserRole.student); + mockMvc.perform(MockMvcRequestBuilders.get(ApiRoutes.USERS_BASE_PATH) + ).andExpect(status().isForbidden()); + } + + @Test + void adminSucceedsAllRoleCheck() throws Exception { + setMockUserRoles(UserRole.admin); + when(userUtil.getUserIfExists(getMockUser().getId())).thenReturn(getMockUser()); + mockMvc.perform(MockMvcRequestBuilders.get(ApiRoutes.USERS_BASE_PATH + "/1") + ).andExpect(status().isOk()); + } + + @Test + void testUserDoesntExistYet() throws Exception { + reset(userRepository); + when(userUtil.getUserIfExists(getMockUser().getId())).thenReturn(getMockUser()); + when(userRepository.findUserByAzureId(getMockUser().getAzureId())).thenReturn(Optional.empty()); + when(userRepository.save(argThat( + user -> { + Duration duration = Duration.between(user.getCreatedAt(), OffsetDateTime.now()); + return user.getRole() == UserRole.student && + user.getAzureId().equals(getMockUser().getAzureId()) && + user.getName().equals(getMockUser().getName()) && + user.getSurname().equals(getMockUser().getSurname()) && + user.getEmail().equals(getMockUser().getEmail()) && + duration.getSeconds() < 5; + } + ))).thenReturn(getMockUser()); + mockMvc.perform(MockMvcRequestBuilders.get(ApiRoutes.USERS_BASE_PATH + "/" + getMockUser().getId()) + ).andExpect(status().isOk()); + + } + + +} diff --git a/backend/app/src/test/java/com/ugent/pidgeon/model/DockerSubmissionTestTest.java b/backend/app/src/test/java/com/ugent/pidgeon/model/DockerSubmissionTestTest.java deleted file mode 100644 index 3a6dd5ee..00000000 --- a/backend/app/src/test/java/com/ugent/pidgeon/model/DockerSubmissionTestTest.java +++ /dev/null @@ -1,131 +0,0 @@ -package com.ugent.pidgeon.model; - -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertFalse; -import static org.junit.jupiter.api.Assertions.assertTrue; - -import com.ugent.pidgeon.model.submissionTesting.DockerSubmissionTestModel; -import com.ugent.pidgeon.model.submissionTesting.DockerSubtestResult; -import com.ugent.pidgeon.model.submissionTesting.DockerTemplateTestResult; -import com.ugent.pidgeon.model.submissionTesting.DockerTestOutput; -import java.io.File; -import java.util.List; -import org.apache.commons.io.FileUtils; -import org.junit.jupiter.api.Test; - -public class DockerSubmissionTestTest { - -// File initTestFile(String text, String fileName) { -// String localFileLocation = System.getProperty("user.dir") + "/tmp/test/" + fileName; -// File file = new File(localFileLocation); -// try { -// file.getParentFile().mkdirs(); -// file.createNewFile(); -// FileUtils.writeStringToFile(file, text, "UTF-8"); -// } catch (Exception e) { -// e.printStackTrace(); -// } -// return file; -// } -// -// // Check if we can catch the console output of a script. -// @Test -// void scriptSucceeds() throws InterruptedException { -// DockerSubmissionTestModel.addDocker("fedora:latest"); -// // Load docker container -// DockerSubmissionTestModel stm = new DockerSubmissionTestModel("fedora"); -// // Run script -// DockerTestOutput to = stm.runSubmission("echo 'PUSH ALLOWED' > /shared//output/testOutput"); -// assertTrue(to.allowed); -// } -// -// @Test -// void scriptFails() throws InterruptedException { -// //make sure docker image is installed -// DockerSubmissionTestModel.addDocker("fedora:latest"); -// // Load docker container -// DockerSubmissionTestModel stm = new DockerSubmissionTestModel("fedora"); -// // Run script -// // Example for running a bash script correctly -// DockerTestOutput to = stm.runSubmission("echo 'PUSH DENIED' > /shared/output/testOutput"); -// assertFalse(to.allowed); -// } -// -// @Test -// void catchesConsoleLogs() throws InterruptedException { -// DockerSubmissionTestModel.addDocker("alpine:latest"); -// // Load docker container -// DockerSubmissionTestModel stm = new DockerSubmissionTestModel("alpine"); -// // Run script -// // Example for running a bash script correctly -// DockerTestOutput to = stm.runSubmission("echo 'Woopdie Woop Scoop! ~ KW'; echo 'PUSH ALLOWED' > /shared/output/testOutput"); -// -// assertTrue(to.allowed); -// assertEquals(to.logs.get(0), "Woopdie Woop Scoop! ~ KW\n"); -// } -// -// @Test -// void correctlyReceivesInputFiles() throws InterruptedException { -// DockerSubmissionTestModel.addDocker("alpine:latest"); -// // Load docker container -// DockerSubmissionTestModel stm = new DockerSubmissionTestModel("alpine"); -// -// // Create an input file in tmp/test/input -// File file = initTestFile("This is a test input file\n", "testInput"); -// -// // Run script -// // Example for running a bash script correctly -// DockerTestOutput to = stm.runSubmission("cat /shared/input/testInput; echo PUSH ALLOWED > /shared/output/testOutput", new File[]{file}); -// assertEquals(to.logs.get(0), "This is a test input file\n"); -// } -// -// @Test -// void templateTest() throws InterruptedException { -// String testOne = "@HelloWorld\n" + -// ">Description=\"Test for hello world!\"\n" + -// ">Required\n" + -// "HelloWorld!"; -// String testTwo = "@HelloWorld2\n" + -// ">Optional\n" + -// "HelloWorld2!\n"; -// String template = testOne + "\n" + testTwo; -// -// File[] files = new File[]{initTestFile("#!/bin/sh\necho 'HelloWorld!'", "HelloWorld.sh"), -// initTestFile("#!/bin/sh\necho 'HelloWorld2!'", "HelloWorld2.sh")}; -// -// String script = -// "chmod +x /shared/input/HelloWorld.sh;" + -// "chmod +x /shared/input/HelloWorld2.sh;" + -// "/shared/input/HelloWorld.sh > /shared/output/HelloWorld;" + -// "/shared/input/HelloWorld2.sh > /shared/output/HelloWorld2"; -// -// DockerSubmissionTestModel.addDocker("alpine:latest"); -// // Load docker container -// DockerSubmissionTestModel stm = new DockerSubmissionTestModel("alpine"); -// DockerTemplateTestResult result = stm.runSubmissionWithTemplate(script, template, files); -// -// // Extract subtests -// List results = result.getSubtestResults(); -// -// // Testing for the template parser capabilities -// assertEquals(results.size(), 2); -// -// assertTrue(results.get(0).isRequired()); -// assertFalse(results.get(1).isRequired()); -// -// assertEquals(results.get(0).getCorrect(), "HelloWorld!\n"); -// assertEquals(results.get(1).getCorrect(), "HelloWorld2!\n"); -// -// assertEquals(results.get(0).getTestDescription(), "Test for hello world!"); -// assertEquals(results.get(1).getTestDescription(), ""); -// -// // Test the docker output -// assertEquals(results.get(0).getOutput(), "HelloWorld!\n"); -// assertEquals(results.get(1).getOutput(), "HelloWorld2!\n"); -// -// assertTrue(result.isAllowed()); -// -// } - - -} diff --git a/backend/app/src/test/java/com/ugent/pidgeon/postgre/models/TestEntityTest.java b/backend/app/src/test/java/com/ugent/pidgeon/postgre/models/TestEntityTest.java index ac9158c6..f18211bc 100644 --- a/backend/app/src/test/java/com/ugent/pidgeon/postgre/models/TestEntityTest.java +++ b/backend/app/src/test/java/com/ugent/pidgeon/postgre/models/TestEntityTest.java @@ -29,27 +29,32 @@ public void testDockerImage() { } @Test - public void testDockerTestId() { - long dockerTestId = 1L; - testEntity.setDockerTestId(dockerTestId); - assertEquals(dockerTestId, testEntity.getDockerTestId()); + public void testDockerTestScript() { + String dockerTestScript = "Docker Test Script"; + testEntity.setDockerTestScript(dockerTestScript); + assertEquals(dockerTestScript, testEntity.getDockerTestScript()); } @Test public void testStructureTestId() { - long structureTestId = 1L; - testEntity.setStructureTestId(structureTestId); - assertEquals(structureTestId, testEntity.getStructureTestId()); + String template = "@Testone\nHello World!"; + testEntity.setStructureTemplate(template); + assertEquals(template, testEntity.getStructureTemplate()); } @Test public void testConstructor() { - String dockerImage = "Docker Image"; - long dockerTestId = 1L; - long structureTestId = 1L; - TestEntity test = new TestEntity(dockerImage, dockerTestId, structureTestId); + String dockerImage = "Docker image"; + String dockerTestScript = "echo 'hello'"; + String dockerTestTemplate = "@testone\nHello World!"; + String structureTestId = "src/"; + + TestEntity test = new TestEntity(dockerImage, dockerTestScript, dockerTestTemplate, structureTestId); + assertEquals(dockerImage, test.getDockerImage()); - assertEquals(dockerTestId, test.getDockerTestId()); - assertEquals(structureTestId, test.getStructureTestId()); + assertEquals(dockerTestScript, test.getDockerTestScript()); + assertEquals(dockerTestTemplate, test.getDockerTestTemplate()); + assertEquals(structureTestId, test.getStructureTemplate()); + } } \ No newline at end of file diff --git a/backend/app/src/test/java/com/ugent/pidgeon/util/ClusterUtilTest.java b/backend/app/src/test/java/com/ugent/pidgeon/util/ClusterUtilTest.java new file mode 100644 index 00000000..37588085 --- /dev/null +++ b/backend/app/src/test/java/com/ugent/pidgeon/util/ClusterUtilTest.java @@ -0,0 +1,316 @@ +package com.ugent.pidgeon.util; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.argThat; +import static org.mockito.Mockito.doReturn; +import static org.mockito.Mockito.reset; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import com.ugent.pidgeon.model.json.ClusterFillJson; +import com.ugent.pidgeon.model.json.GroupClusterCreateJson; +import com.ugent.pidgeon.model.json.GroupClusterUpdateJson; +import com.ugent.pidgeon.postgre.models.CourseUserEntity; +import com.ugent.pidgeon.postgre.models.GroupClusterEntity; +import com.ugent.pidgeon.postgre.models.UserEntity; +import com.ugent.pidgeon.postgre.models.types.CourseRelation; +import com.ugent.pidgeon.postgre.models.types.UserRole; +import com.ugent.pidgeon.postgre.repository.CourseUserRepository; +import com.ugent.pidgeon.postgre.repository.GroupClusterRepository; +import java.util.Optional; +import org.hibernate.annotations.Check; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.Spy; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.http.HttpStatus; + +@ExtendWith(MockitoExtension.class) +public class ClusterUtilTest { + + @Mock + private GroupClusterRepository groupClusterRepository; + @Mock + private CourseUserRepository courseUserRepository; + @Mock + private CourseUtil courseUtil; + + @Spy + @InjectMocks + private ClusterUtil clusterUtil; + + private GroupClusterEntity clusterEntity; + + private UserEntity mockUser; + + @BeforeEach + public void setUp() { + clusterEntity = new GroupClusterEntity(1L, 20, "clustername", 5); + clusterEntity.setId(4L); + mockUser = new UserEntity("name", "surname", "email", UserRole.student, "azureid"); + } + + @Test + void testIsIndividualCluster() { + when(groupClusterRepository.findById(clusterEntity.getId())).thenReturn(Optional.of(clusterEntity)); + // Test if the cluster is an individual cluster + clusterEntity.setMaxSize(1); + assertTrue(clusterUtil.isIndividualCluster(clusterEntity)); + assertTrue(clusterUtil.isIndividualCluster(clusterEntity.getId())); + + + // Test if the cluster is not an individual cluster + clusterEntity.setMaxSize(2); + assertFalse(clusterUtil.isIndividualCluster(clusterEntity)); + assertFalse(clusterUtil.isIndividualCluster(clusterEntity.getId())); + + // Test if the cluster is null + when(groupClusterRepository.findById(clusterEntity.getId())).thenReturn(Optional.empty()); + assertFalse(clusterUtil.isIndividualCluster(null)); + assertFalse(clusterUtil.isIndividualCluster(clusterEntity.getId())); + } + + @Test + void testCanDeleteCluster() { + /* All checks succeed */ + doReturn(new CheckResult<>(HttpStatus.OK, "", clusterEntity)) + .when(clusterUtil) + .getGroupClusterEntityIfAdminAndNotIndividual(clusterEntity.getId(), mockUser); + + when(groupClusterRepository.usedInProject(clusterEntity.getId())).thenReturn(false); + + CheckResult result = clusterUtil.canDeleteCluster(clusterEntity.getId(), mockUser); + assertEquals(HttpStatus.OK, result.getStatus()); + + /* usedInProject returns true */ + when(groupClusterRepository.usedInProject(clusterEntity.getId())).thenReturn(true); + result = clusterUtil.canDeleteCluster(clusterEntity.getId(), mockUser); + assertEquals(HttpStatus.FORBIDDEN, result.getStatus()); + + /* getGroupClusterEntity fails */ + when(clusterUtil.getGroupClusterEntityIfAdminAndNotIndividual(clusterEntity.getId(), mockUser)) + .thenReturn(new CheckResult<>(HttpStatus.I_AM_A_TEAPOT, "Group cluster does not exist", null)); + + result = clusterUtil.canDeleteCluster(clusterEntity.getId(), mockUser); + assertEquals(HttpStatus.I_AM_A_TEAPOT, result.getStatus()); + + } + + @Test + void testGetGroupClusterEntityIfNotIndividual() { + /* All checks succeed */ + when(groupClusterRepository.findById(clusterEntity.getId())).thenReturn(Optional.of(clusterEntity)); + when(courseUtil.getCourseIfUserInCourse(clusterEntity.getCourseId(), mockUser)) + .thenReturn(new CheckResult<>(HttpStatus.OK, "", null)); + + doReturn(false).when(clusterUtil).isIndividualCluster(clusterEntity); + CheckResult result = + clusterUtil.getGroupClusterEntityIfNotIndividual(clusterEntity.getId(), mockUser); + assertEquals(HttpStatus.OK, result.getStatus()); + assertEquals(clusterEntity, result.getData()); + + /* Group cluster is individual cluster */ + doReturn(true).when(clusterUtil).isIndividualCluster(clusterEntity); + result = clusterUtil.getGroupClusterEntityIfNotIndividual(clusterEntity.getId(), mockUser); + assertEquals(HttpStatus.FORBIDDEN, result.getStatus()); + + /* Course check fails, return corresponding status */ + when(courseUtil.getCourseIfUserInCourse(clusterEntity.getCourseId(), mockUser)) + .thenReturn(new CheckResult<>(HttpStatus.I_AM_A_TEAPOT, "Course does not exist", null)); + result = clusterUtil.getGroupClusterEntityIfNotIndividual(clusterEntity.getId(), mockUser); + assertEquals(HttpStatus.I_AM_A_TEAPOT, result.getStatus()); + + /* Group cluster does not exist */ + when(groupClusterRepository.findById(clusterEntity.getId())).thenReturn(Optional.empty()); + result = clusterUtil.getGroupClusterEntityIfNotIndividual(clusterEntity.getId(), mockUser); + assertEquals(HttpStatus.NOT_FOUND, result.getStatus()); + } + + @Test + void testGetGroupClusterEntityIfAdminAndNotIndividual() { + /* All checks succeed */ + when(clusterUtil.getGroupClusterEntityIfNotIndividual(clusterEntity.getId(), mockUser)) + .thenReturn(new CheckResult<>(HttpStatus.OK, "", clusterEntity)); + + when(courseUtil.getCourseIfAdmin(clusterEntity.getCourseId(), mockUser)) + .thenReturn(new CheckResult<>(HttpStatus.OK, "", null)); + + CheckResult result = + clusterUtil.getGroupClusterEntityIfAdminAndNotIndividual(clusterEntity.getId(), mockUser); + assertEquals(HttpStatus.OK, result.getStatus()); + assertEquals(clusterEntity, result.getData()); + + /* Course check fails, return corresponding status */ + when(courseUtil.getCourseIfAdmin(clusterEntity.getCourseId(), mockUser)) + .thenReturn(new CheckResult<>(HttpStatus.I_AM_A_TEAPOT, "", null)); + result = clusterUtil.getGroupClusterEntityIfAdminAndNotIndividual(clusterEntity.getId(), mockUser); + assertEquals(HttpStatus.I_AM_A_TEAPOT, result.getStatus()); + + /* getGroupClusterEntityIfNotIndividual fails */ + when(clusterUtil.getGroupClusterEntityIfNotIndividual(clusterEntity.getId(), mockUser)) + .thenReturn(new CheckResult<>(HttpStatus.I_AM_A_TEAPOT, "", null)); + result = clusterUtil.getGroupClusterEntityIfAdminAndNotIndividual(clusterEntity.getId(), mockUser); + assertEquals(HttpStatus.I_AM_A_TEAPOT, result.getStatus()); + } + + @Test + void testPartOfCourse() { + /* All checks succeed */ + when(groupClusterRepository.findById(clusterEntity.getId())).thenReturn(Optional.of(clusterEntity)); + + CheckResult result = clusterUtil.partOfCourse(clusterEntity.getId(), clusterEntity.getCourseId()); + assertEquals(HttpStatus.OK, result.getStatus()); + + /* Group cluster not linked to course */ + result = clusterUtil.partOfCourse(clusterEntity.getId(), clusterEntity.getCourseId() + 1); + assertEquals(HttpStatus.FORBIDDEN, result.getStatus()); + + /* Group cluster does not exist */ + when(groupClusterRepository.findById(clusterEntity.getId())).thenReturn(Optional.empty()); + result = clusterUtil.partOfCourse(clusterEntity.getId(), clusterEntity.getCourseId()); + assertEquals(HttpStatus.NOT_FOUND, result.getStatus()); + } + + @Test + void testGetClusterIfExists() { + /* All checks succeed */ + when(groupClusterRepository.findById(clusterEntity.getId())).thenReturn(Optional.of(clusterEntity)); + + CheckResult result = clusterUtil.getClusterIfExists(clusterEntity.getId()); + assertEquals(HttpStatus.OK, result.getStatus()); + assertEquals(clusterEntity, result.getData()); + + /* Group cluster does not exist */ + when(groupClusterRepository.findById(clusterEntity.getId())).thenReturn(Optional.empty()); + result = clusterUtil.getClusterIfExists(clusterEntity.getId()); + assertEquals(HttpStatus.NOT_FOUND, result.getStatus()); + } + + @Test + void testCheckGroupClusterUpdateJson() { + GroupClusterUpdateJson json = new GroupClusterUpdateJson(); + /* All checks succeed */ + json.setCapacity(5); + json.setName("clustername"); + CheckResult result = clusterUtil.checkGroupClusterUpdateJson(json); + assertEquals(HttpStatus.OK, result.getStatus()); + + /* Capacity is smaller than 1 */ + json.setCapacity(0); + result = clusterUtil.checkGroupClusterUpdateJson(json); + assertEquals(HttpStatus.BAD_REQUEST, result.getStatus()); + + /* Name is empty */ + json.setName(""); + result = clusterUtil.checkGroupClusterUpdateJson(json); + assertEquals(HttpStatus.BAD_REQUEST, result.getStatus()); + + /* Capacity is null */ + json.setCapacity(null); + result = clusterUtil.checkGroupClusterUpdateJson(json); + assertEquals(HttpStatus.BAD_REQUEST, result.getStatus()); + + /* Name is null */ + json.setCapacity(5); + json.setName(null); + result = clusterUtil.checkGroupClusterUpdateJson(json); + assertEquals(HttpStatus.BAD_REQUEST, result.getStatus()); + } + + @Test + void testCheckGroupClusterCreateJson() { + GroupClusterCreateJson json = new GroupClusterCreateJson("clustername", 5, 5); + /* All checks succeed */ + CheckResult result = clusterUtil.checkGroupClusterCreateJson(json); + assertEquals(HttpStatus.OK, result.getStatus()); + + /* GroupCount is negative */ + json = new GroupClusterCreateJson("clustername", 5, -5); + result = clusterUtil.checkGroupClusterCreateJson(json); + assertEquals(HttpStatus.BAD_REQUEST, result.getStatus()); + + /* Capacity is smaller than 1 */ + json = new GroupClusterCreateJson("clustername", 0, 5); + result = clusterUtil.checkGroupClusterCreateJson(json); + assertEquals(HttpStatus.BAD_REQUEST, result.getStatus()); + + /* Name is empty */ + json = new GroupClusterCreateJson("", 5, 5); + result = clusterUtil.checkGroupClusterCreateJson(json); + assertEquals(HttpStatus.BAD_REQUEST, result.getStatus()); + + /* Capacity is null */ + json = new GroupClusterCreateJson("clustername", null, 5); + result = clusterUtil.checkGroupClusterCreateJson(json); + assertEquals(HttpStatus.BAD_REQUEST, result.getStatus()); + + /* Name is null */ + json = new GroupClusterCreateJson(null, 5, 5); + result = clusterUtil.checkGroupClusterCreateJson(json); + assertEquals(HttpStatus.BAD_REQUEST, result.getStatus()); + + /* GroupCount is null */ + json = new GroupClusterCreateJson("clustername", 5, null); + result = clusterUtil.checkGroupClusterCreateJson(json); + assertEquals(HttpStatus.BAD_REQUEST, result.getStatus()); + } + + @Test + public void testCheckFillClusterJson() { + ClusterFillJson fillJson = new ClusterFillJson(); + CourseUserEntity enrolledCU = new CourseUserEntity(22L, 5L, CourseRelation.enrolled); + fillJson.addClusterGroupMembers("Group1", new Long[]{5L, 2L}); + fillJson.addClusterGroupMembers("Group2", new Long[]{3L, 10L}); + + when(courseUserRepository.findById(any())).thenReturn(Optional.of(enrolledCU)); + + CheckResult result = clusterUtil.checkFillClusterJson(fillJson, clusterEntity); + assertEquals(HttpStatus.OK, result.getStatus()); + + verify(courseUserRepository, times(1)).findById(argThat( + arg -> arg.getCourseId() == clusterEntity.getCourseId() && arg.getUserId() == 5L)); + verify(courseUserRepository, times(1)).findById(argThat( + arg -> arg.getCourseId() == clusterEntity.getCourseId() && arg.getUserId() == 2L)); + verify(courseUserRepository, times(1)).findById(argThat( + arg -> arg.getCourseId() == clusterEntity.getCourseId() && arg.getUserId() == 3L)); + verify(courseUserRepository, times(1)).findById(argThat( + arg -> arg.getCourseId() == clusterEntity.getCourseId() && arg.getUserId() == 10L)); + + /* User admin in course */ + CourseUserEntity courseAdminCU = new CourseUserEntity(22L, 5L, CourseRelation.course_admin); + when(courseUserRepository.findById(argThat( + arg -> arg.getCourseId() == clusterEntity.getCourseId() && arg.getUserId() == 5L))) + .thenReturn(Optional.of(courseAdminCU)); + + result = clusterUtil.checkFillClusterJson(fillJson, clusterEntity); + assertEquals(HttpStatus.BAD_REQUEST, result.getStatus()); + + /* User not found in course */ + reset(courseUserRepository); + when(courseUserRepository.findById(any())).thenReturn(Optional.of(enrolledCU)); + when(courseUserRepository.findById(argThat( + arg -> arg.getCourseId() == clusterEntity.getCourseId() && arg.getUserId() == 3L))) + .thenReturn(Optional.empty()); + + result = clusterUtil.checkFillClusterJson(fillJson, clusterEntity); + assertEquals(HttpStatus.BAD_REQUEST, result.getStatus()); + + /* trying to add user twice */ + reset(courseUserRepository); + when(courseUserRepository.findById(any())).thenReturn(Optional.of(enrolledCU)); + fillJson.addClusterGroupMembers("Group3", new Long[]{5L, 4L}); + + result = clusterUtil.checkFillClusterJson(fillJson, clusterEntity); + assertEquals(HttpStatus.BAD_REQUEST, result.getStatus()); + + } + + +} diff --git a/backend/app/src/test/java/com/ugent/pidgeon/util/CommonDataBaseActionsTest.java b/backend/app/src/test/java/com/ugent/pidgeon/util/CommonDataBaseActionsTest.java new file mode 100644 index 00000000..8fedae77 --- /dev/null +++ b/backend/app/src/test/java/com/ugent/pidgeon/util/CommonDataBaseActionsTest.java @@ -0,0 +1,614 @@ +package com.ugent.pidgeon.util; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotEquals; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.junit.jupiter.api.Assertions.fail; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.ArgumentMatchers.argThat; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.doAnswer; +import static org.mockito.Mockito.doReturn; +import static org.mockito.Mockito.reset; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import com.ugent.pidgeon.postgre.models.CourseEntity; +import com.ugent.pidgeon.postgre.models.GroupClusterEntity; +import com.ugent.pidgeon.postgre.models.GroupEntity; +import com.ugent.pidgeon.postgre.models.GroupFeedbackEntity; +import com.ugent.pidgeon.postgre.models.ProjectEntity; +import com.ugent.pidgeon.postgre.models.SubmissionEntity; +import com.ugent.pidgeon.postgre.models.TestEntity; +import com.ugent.pidgeon.postgre.models.UserEntity; +import com.ugent.pidgeon.postgre.models.types.CourseRelation; +import com.ugent.pidgeon.postgre.repository.CourseRepository; +import com.ugent.pidgeon.postgre.repository.CourseUserRepository; +import com.ugent.pidgeon.postgre.repository.GroupClusterRepository; +import com.ugent.pidgeon.postgre.repository.GroupFeedbackRepository; +import com.ugent.pidgeon.postgre.repository.GroupRepository; +import com.ugent.pidgeon.postgre.repository.GroupUserRepository; +import com.ugent.pidgeon.postgre.repository.ProjectRepository; +import com.ugent.pidgeon.postgre.repository.SubmissionRepository; +import com.ugent.pidgeon.postgre.repository.TestRepository; +import java.time.OffsetDateTime; +import java.util.List; +import java.util.Objects; +import java.util.Optional; +import java.util.logging.Logger; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.Spy; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.http.HttpStatus; + +@ExtendWith(MockitoExtension.class) +public class CommonDataBaseActionsTest { + + @Mock + private GroupRepository groupRepository; + @Mock + private GroupClusterRepository groupClusterRepository; + @Mock + private GroupUserRepository groupUserRepository; + @Mock + private ProjectRepository projectRepository; + @Mock + private GroupFeedbackRepository groupFeedbackRepository; + @Mock + private SubmissionRepository submissionRepository; + @Mock + private TestRepository testRepository; + @Mock + private CourseUserRepository courseUserRepository; + @Mock + private CourseRepository courseRepository; + + @Mock + private FileUtil fileUtil; + + @Spy + @InjectMocks + private CommonDatabaseActions commonDatabaseActions; + + private GroupClusterEntity groupClusterEntity; + private GroupEntity groupEntity; + private UserEntity userEntity; + private CourseEntity courseEntity; + private ProjectEntity projectEntity; + private GroupFeedbackEntity groupFeedbackEntity; + private SubmissionEntity submissionEntity; + private TestEntity testEntity; + + + @BeforeEach + public void setUp() { + courseEntity = new CourseEntity("name", "description",2024); + courseEntity.setId(9L); + + groupClusterEntity = new GroupClusterEntity( + courseEntity.getId(), + 20, + "clusterName", + 5 + ); + groupClusterEntity.setGroupAmount(5); + groupClusterEntity.setId(9L); + + groupEntity = new GroupEntity( + "groupName", + groupClusterEntity.getId() + ); + groupEntity.setId(4L); + groupEntity.setClusterId(groupClusterEntity.getId()); + + userEntity = new UserEntity(); + userEntity.setId(44L); + + + + testEntity = new TestEntity( + "dockerImageBasic", + "dockerTestScriptBasic", + "dockerTestTemplateBasic", + "structureTemplateBasic" + ); + testEntity.setId(38L); + + projectEntity = new ProjectEntity( + courseEntity.getId(), + "projectName", + "projectDescription", + groupClusterEntity.getId(), + testEntity.getId(), + true, + 34, + OffsetDateTime.now() + ); + projectEntity.setId(64); + + groupFeedbackEntity = new GroupFeedbackEntity( + groupEntity.getId(), + projectEntity.getId(), + 5.0f, + "feedback" + ); + + submissionEntity = new SubmissionEntity( + 22, + 45, + 99L, + OffsetDateTime.MIN, + true, + true + ); + + + + } + + @Test + public void removeGroup() { + long groupId = groupEntity.getId(); + int originalGroupCount = groupClusterEntity.getGroupAmount(); + + when(groupRepository.findById(groupId)).thenReturn(Optional.of(groupEntity)); + when(groupClusterRepository.findById(groupEntity.getClusterId())).thenReturn(Optional.of(groupClusterEntity)); + + assertTrue(commonDatabaseActions.removeGroup(groupId)); + verify(groupRepository, times(1)).deleteGroupUsersByGroupId(groupId); + verify(groupRepository, times(1)).deleteSubmissionsByGroupId(groupId); + verify(groupRepository, times(1)).deleteGroupFeedbacksByGroupId(groupId); + verify(groupRepository, times(1)).deleteById(groupId); + verify(groupClusterRepository, times(1)).save(groupClusterEntity); + + assertEquals(originalGroupCount - 1, groupClusterEntity.getGroupAmount()); + + /* Group not found */ + when(groupRepository.findById(groupId)).thenReturn(Optional.empty()); + assertTrue(commonDatabaseActions.removeGroup(groupId)); + verify(groupRepository, times(1)).deleteGroupUsersByGroupId(groupId); + verify(groupRepository, times(1)).deleteSubmissionsByGroupId(groupId); + verify(groupRepository, times(1)).deleteGroupFeedbacksByGroupId(groupId); + verify(groupRepository, times(1)).deleteById(groupId); + verify(groupClusterRepository, times(1)).save(groupClusterEntity); + + assertEquals(originalGroupCount - 1, groupClusterEntity.getGroupAmount()); + + /* Unexpected error */ + when(groupRepository.findById(groupId)).thenThrow(new RuntimeException()); + assertFalse(commonDatabaseActions.removeGroup(groupId)); + } + + @Test + public void testCreateNewIndividualClusterGroup () { + int originalGroupCount = groupClusterEntity.getGroupAmount(); + + when(groupClusterRepository.findIndividualClusterByCourseId(courseEntity.getId())).thenReturn( + Optional.of(groupClusterEntity)); + when(groupRepository.save(argThat( + group -> + group.getClusterId() == groupClusterEntity.getId() && + group.getName().equals(userEntity.getName() + " " + userEntity.getSurname()) + ))).thenReturn(groupEntity); + assertTrue( + commonDatabaseActions.createNewIndividualClusterGroup(courseEntity.getId(), userEntity)); + + verify(groupClusterRepository, times(1)).save(groupClusterEntity); + verify(groupUserRepository, times(1)).save(argThat( + groupUser -> + groupUser.getGroupId() == groupEntity.getId() && + groupUser.getUserId() == userEntity.getId() + )); + assertEquals(originalGroupCount + 1, groupClusterEntity.getGroupAmount()); + + /* Group cluster not found */ + when(groupClusterRepository.findIndividualClusterByCourseId(courseEntity.getId())).thenReturn( + Optional.empty()); + assertFalse(commonDatabaseActions.createNewIndividualClusterGroup(courseEntity.getId(), userEntity)); + } + + @Test + public void testRemoveIndividualClusterGroup() { + long groupId = groupEntity.getId(); + int originalGroupCount = groupClusterEntity.getGroupAmount(); + + when(groupClusterRepository.findIndividualClusterByCourseId(courseEntity.getId())).thenReturn( + Optional.of(groupClusterEntity)); + when(groupRepository.groupByClusterAndUser(groupClusterEntity.getId(), userEntity.getId())) + .thenReturn(Optional.of(groupEntity)); + + assertTrue(commonDatabaseActions.removeIndividualClusterGroup(courseEntity.getId(), + userEntity.getId())); + + verify(commonDatabaseActions, times(1)).removeGroup(groupId); + verify(groupClusterRepository, times(1)).save(groupClusterEntity); + assertEquals(originalGroupCount - 1, groupClusterEntity.getGroupAmount()); + + /* Group not found */ + when(groupRepository.groupByClusterAndUser(groupClusterEntity.getId(), userEntity.getId())) + .thenReturn(Optional.empty()); + assertFalse(commonDatabaseActions.removeIndividualClusterGroup(courseEntity.getId(), + userEntity.getId())); + + /* Group cluster not found */ + when(groupClusterRepository.findIndividualClusterByCourseId(courseEntity.getId())).thenReturn( + Optional.empty()); + assertFalse(commonDatabaseActions.removeIndividualClusterGroup(courseEntity.getId(), + userEntity.getId())); + } + + @Test + public void testDeleteProject() { + List groupFeedbackEntities = List.of(groupFeedbackEntity); + List submissionEntities = List.of(submissionEntity); + when(projectRepository.findById(projectEntity.getId())).thenReturn(Optional.of(projectEntity)); + when(groupFeedbackRepository.findByProjectId(projectEntity.getId())).thenReturn(groupFeedbackEntities); + when(submissionRepository.findByProjectId(projectEntity.getId())).thenReturn(submissionEntities); + doReturn(new CheckResult<>(HttpStatus.OK, "", null)).when(commonDatabaseActions).deleteSubmissionById(submissionEntity.getId()); + when(testRepository.findById(projectEntity.getTestId())).thenReturn(Optional.of(testEntity)); + doReturn(new CheckResult<>(HttpStatus.OK, "", null)).when(commonDatabaseActions).deleteTestById(projectEntity, testEntity); + + CheckResult result = commonDatabaseActions.deleteProject(projectEntity.getId()); + assertEquals(HttpStatus.OK, result.getStatus()); + + verify(projectRepository, times(1)).delete(projectEntity); + verify(groupFeedbackRepository, times(1)).deleteAll(groupFeedbackEntities); + + /* No test */ + reset(testRepository); + projectEntity.setTestId(null); + result = commonDatabaseActions.deleteProject(projectEntity.getId()); + assertEquals(HttpStatus.OK, result.getStatus()); + + verify(testRepository, times(0)).delete(testEntity); + + + /* Test not found */ + projectEntity.setTestId(testEntity.getId()); + when(testRepository.findById(projectEntity.getTestId())).thenReturn(Optional.empty()); + result = commonDatabaseActions.deleteProject(projectEntity.getId()); + assertEquals(HttpStatus.NOT_FOUND, result.getStatus()); + + /* Test deletion failed */ + when(testRepository.findById(projectEntity.getTestId())).thenReturn(Optional.of(testEntity)); + doReturn(new CheckResult<>(HttpStatus.I_AM_A_TEAPOT, "I'm a teapot", null)).when(commonDatabaseActions).deleteTestById(projectEntity, testEntity); + result = commonDatabaseActions.deleteProject(projectEntity.getId()); + assertEquals(HttpStatus.I_AM_A_TEAPOT, result.getStatus()); + + /* Submission deletion failed */ + reset(commonDatabaseActions); + doReturn(new CheckResult<>(HttpStatus.I_AM_A_TEAPOT, "I'm a teapot", null)).when(commonDatabaseActions).deleteSubmissionById(submissionEntity.getId()); + result = commonDatabaseActions.deleteProject(projectEntity.getId()); + assertEquals(HttpStatus.I_AM_A_TEAPOT, result.getStatus()); + + /* Project not found */ + when(projectRepository.findById(projectEntity.getId())).thenReturn(Optional.empty()); + result = commonDatabaseActions.deleteProject(projectEntity.getId()); + assertEquals(HttpStatus.NOT_FOUND, result.getStatus()); + + /* Unexpected error */ + when(projectRepository.findById(projectEntity.getId())).thenThrow(new RuntimeException()); + result = commonDatabaseActions.deleteProject(projectEntity.getId()); + assertEquals(HttpStatus.INTERNAL_SERVER_ERROR, result.getStatus()); + } + + @Test + public void testDeleteSubmissionById() { + when(submissionRepository.findById(submissionEntity.getId())).thenReturn(Optional.of(submissionEntity)); + when(fileUtil.deleteFileById(submissionEntity.getFileId())).thenReturn(new CheckResult<>(HttpStatus.OK, "", null)); + CheckResult result = commonDatabaseActions.deleteSubmissionById(submissionEntity.getId()); + assertEquals(HttpStatus.OK, result.getStatus()); + + verify(submissionRepository, times(1)).delete(submissionEntity); + + /* File deletion failed */ + when(fileUtil.deleteFileById(submissionEntity.getFileId())).thenReturn(new CheckResult<>(HttpStatus.I_AM_A_TEAPOT, "I'm a teapot", null)); + result = commonDatabaseActions.deleteSubmissionById(submissionEntity.getId()); + assertEquals(HttpStatus.I_AM_A_TEAPOT, result.getStatus()); + + /* Submission not found */ + when(submissionRepository.findById(submissionEntity.getId())).thenReturn(Optional.empty()); + result = commonDatabaseActions.deleteSubmissionById(submissionEntity.getId()); + assertEquals(HttpStatus.NOT_FOUND, result.getStatus()); + + /* Unexpected error */ + when(submissionRepository.findById(submissionEntity.getId())).thenThrow(new RuntimeException()); + result = commonDatabaseActions.deleteSubmissionById(submissionEntity.getId()); + assertEquals(HttpStatus.INTERNAL_SERVER_ERROR, result.getStatus()); + } + + @Test + public void testDeleteTestById() { + when(testRepository.imageIsUsed(testEntity.getDockerImage())).thenReturn(false); + + CheckResult result = commonDatabaseActions.deleteTestById(projectEntity, testEntity); + assertEquals(HttpStatus.OK, result.getStatus()); + + verify(testRepository, times(1)).deleteById(testEntity.getId()); + verify(projectRepository, times(1)).save(projectEntity); + assertNull(projectEntity.getTestId()); + + /* Image is used */ + when(testRepository.imageIsUsed(testEntity.getDockerImage())).thenReturn(true); + result = commonDatabaseActions.deleteTestById(projectEntity, testEntity); + assertEquals(HttpStatus.OK, result.getStatus()); + + /* Unexpected error */ + when(testRepository.imageIsUsed(testEntity.getDockerImage())).thenThrow(new RuntimeException()); + result = commonDatabaseActions.deleteTestById(projectEntity, testEntity); + assertEquals(HttpStatus.INTERNAL_SERVER_ERROR, result.getStatus()); + } + + @Test + public void testDeleteClusterById() { + List groupEntities = List.of(groupEntity); + when(groupRepository.findAllByClusterId(groupClusterEntity.getId())).thenReturn(groupEntities); + doReturn(true).when(commonDatabaseActions).removeGroup(groupEntity.getId()); + + CheckResult result = commonDatabaseActions.deleteClusterById(groupClusterEntity.getId()); + assertEquals(HttpStatus.OK, result.getStatus()); + + verify(groupClusterRepository, times(1)).deleteById(groupClusterEntity.getId()); + + /* Group deletion failed */ + reset(commonDatabaseActions); + doReturn(false).when(commonDatabaseActions).removeGroup(groupEntity.getId()); + result = commonDatabaseActions.deleteClusterById(groupClusterEntity.getId()); + assertEquals(HttpStatus.INTERNAL_SERVER_ERROR, result.getStatus()); + + /* Unexpected error */ + when(groupRepository.findAllByClusterId(groupClusterEntity.getId())).thenThrow(new RuntimeException()); + result = commonDatabaseActions.deleteClusterById(groupClusterEntity.getId()); + assertEquals(HttpStatus.INTERNAL_SERVER_ERROR, result.getStatus()); + } + + @Test + public void testCopyCourse() { + String originalCourseKey = "courseKey"; + courseEntity.setJoinKey(originalCourseKey); + Long newCourseId = 39L; + CourseEntity newCourse = new CourseEntity(courseEntity.getName(), courseEntity.getDescription(), courseEntity.getCourseYear()); + newCourse.setJoinKey("randomnewkey"); + newCourse.setId(newCourseId); + + GroupClusterEntity individualCluster = new GroupClusterEntity( + courseEntity.getId(), 20, "clustername", 5 + ); + when(courseRepository.save(argThat( + course -> + course.getName().equals(courseEntity.getName()) && + course.getDescription().equals(courseEntity.getDescription()) && + course.getCourseYear() == courseEntity.getCourseYear() && + !course.getJoinKey().equals(originalCourseKey) + ))).thenReturn(newCourse); + + when(groupClusterRepository.findIndividualClusterByCourseId(courseEntity.getId())).thenReturn( + Optional.of(individualCluster)); + + doReturn(new CheckResult<>(HttpStatus.OK, "", individualCluster)) + .when(commonDatabaseActions).copyGroupCluster(individualCluster, newCourseId, false); + + List groupClusterEntities = List.of(groupClusterEntity); + long newGroupClusterId = 42L; + GroupClusterEntity groupClusterCopy = new GroupClusterEntity( + 67L, groupClusterEntity.getGroupAmount(), groupClusterEntity.getName(), groupClusterEntity.getMaxSize() + ); + groupClusterCopy.setId(newGroupClusterId); + when(groupClusterRepository.findClustersWithoutInvidualByCourseId(courseEntity.getId())) + .thenReturn(groupClusterEntities); + doReturn(new CheckResult<>(HttpStatus.OK, "", groupClusterCopy)) + .when(commonDatabaseActions).copyGroupCluster(groupClusterEntity, newCourseId, true); + + List projectEntities = List.of(projectEntity); + projectEntity.setGroupClusterId(groupClusterEntity.getId()); + when(projectRepository.findByCourseId(courseEntity.getId())).thenReturn(projectEntities); + doReturn(new CheckResult<>(HttpStatus.OK, "", null)) + .when(commonDatabaseActions).copyProject(projectEntity, newCourseId, newGroupClusterId); + + CheckResult result = commonDatabaseActions.copyCourse(courseEntity, userEntity.getId()); + assertEquals(HttpStatus.OK, result.getStatus()); + + verify(courseUserRepository, times(1)).save(argThat( + courseUser -> + courseUser.getCourseId() == result.getData().getId() && + courseUser.getUserId() == userEntity.getId() && + courseUser.getRelation().equals(CourseRelation.creator) + )); + + assertNotEquals(originalCourseKey, result.getData().getJoinKey()); + assertEquals(newCourseId, result.getData().getId()); + + CheckResult failedResult; + /* Copyproject fails */ + doReturn(new CheckResult<>(HttpStatus.I_AM_A_TEAPOT, "I'm a teapot", null)) + .when(commonDatabaseActions).copyProject(projectEntity, newCourseId, newGroupClusterId); + failedResult = commonDatabaseActions.copyCourse(courseEntity, userEntity.getId()); + assertEquals(HttpStatus.I_AM_A_TEAPOT, failedResult.getStatus()); + + /* CopyGroupCluster fails */ + doReturn(new CheckResult<>(HttpStatus.I_AM_A_TEAPOT, "I'm a teapot", null)) + .when(commonDatabaseActions).copyGroupCluster(groupClusterEntity, newCourseId, true); + failedResult = commonDatabaseActions.copyCourse(courseEntity, userEntity.getId()); + assertEquals(HttpStatus.I_AM_A_TEAPOT, failedResult.getStatus()); + + /* CopyIndividualCluster fails */ + doReturn(new CheckResult<>(HttpStatus.I_AM_A_TEAPOT, "I'm a teapot", null)) + .when(commonDatabaseActions).copyGroupCluster(individualCluster, newCourseId, false); + failedResult = commonDatabaseActions.copyCourse(courseEntity, userEntity.getId()); + assertEquals(HttpStatus.I_AM_A_TEAPOT, failedResult.getStatus()); + + /* Individual cluster isn't found */ + when(groupClusterRepository.findIndividualClusterByCourseId(courseEntity.getId())).thenReturn( + Optional.empty()); + failedResult = commonDatabaseActions.copyCourse(courseEntity, userEntity.getId()); + assertEquals(HttpStatus.INTERNAL_SERVER_ERROR, failedResult.getStatus()); + + } + + @Test + public void testCopyGroupCluster() { + long newCourseId = 39L; + GroupClusterEntity newGroupCluster = new GroupClusterEntity( + newCourseId, groupClusterEntity.getGroupAmount(), groupClusterEntity.getName(), groupClusterEntity.getMaxSize() + ); + newGroupCluster.setId(42L); + + when(groupClusterRepository.save(argThat( + groupCluster -> + groupCluster.getCourseId() == newCourseId && + groupCluster.getGroupAmount() == groupClusterEntity.getGroupAmount() && + groupCluster.getName().equals(groupClusterEntity.getName()) && + groupCluster.getMaxSize() == groupClusterEntity.getMaxSize() + ))).thenReturn(newGroupCluster); + + List groupEntities = List.of(groupEntity); + when(groupRepository.findAllByClusterId(groupClusterEntity.getId())).thenReturn(groupEntities); + when(groupRepository.save(argThat( + group -> + group.getClusterId() == newGroupCluster.getId() && + group.getName().equals(groupEntity.getName()) + ))).thenReturn(groupEntity); + + CheckResult result = commonDatabaseActions.copyGroupCluster(groupClusterEntity, newCourseId, true); + assertEquals(HttpStatus.OK, result.getStatus()); + assertEquals(newGroupCluster, result.getData()); + + /* Don't copy groups */ + reset(groupClusterRepository); + when(groupClusterRepository.save(argThat( + groupCluster -> + groupCluster.getCourseId() == newCourseId && + groupCluster.getGroupAmount() == 0 && + groupCluster.getName().equals(groupClusterEntity.getName()) && + groupCluster.getMaxSize() == groupClusterEntity.getMaxSize() + ))).thenReturn(newGroupCluster); + result = commonDatabaseActions.copyGroupCluster(groupClusterEntity, newCourseId, false); + assertEquals(HttpStatus.OK, result.getStatus()); + assertEquals(newGroupCluster, result.getData()); + + verify(groupRepository, times(1)).save(any()); + } + + @Test + public void testCopyProject() { + long newCourseId = 39L; + long newGroupClusterId = 42L; + long newProjectId = 99L; + long newTestId = 88L; + testEntity.setId(newTestId); + ProjectEntity newProject = new ProjectEntity( + newCourseId, + projectEntity.getName(), + projectEntity.getDescription(), + newGroupClusterId, + projectEntity.getTestId(), + projectEntity.isVisible(), + projectEntity.getMaxScore(), + projectEntity.getDeadline() + ); + newProject.setId(newProjectId); + + when(projectRepository.save(any())).thenReturn(newProject); + + when(testRepository.findById(projectEntity.getTestId())).thenReturn(Optional.of(testEntity)); + doReturn(new CheckResult<>(HttpStatus.OK, "", testEntity)) + .when(commonDatabaseActions).copyTest(testEntity); + + CheckResult result = commonDatabaseActions.copyProject(projectEntity, newCourseId, newGroupClusterId); + assertEquals(HttpStatus.OK, result.getStatus()); + assertEquals(newProject, result.getData()); + assertEquals(newProjectId, result.getData().getId()); + + + + verify(projectRepository, times(1)).save(argThat( + project -> project.getCourseId() == newCourseId && + project.getName().equals(projectEntity.getName()) && + project.getDescription().equals(projectEntity.getDescription()) && + project.getGroupClusterId() == newGroupClusterId && + Objects.equals(project.getTestId(), null) && + project.isVisible() == projectEntity.isVisible() && + Objects.equals(project.getMaxScore(), projectEntity.getMaxScore()) && + project.getDeadline().equals(projectEntity.getDeadline()) + )); + + verify(projectRepository, times(1)).save(argThat( + project -> + project.getCourseId() == newCourseId && + project.getName().equals(projectEntity.getName()) && + project.getDescription().equals(projectEntity.getDescription()) && + project.getGroupClusterId() == newGroupClusterId && + Objects.equals(project.getTestId(), newTestId) && + project.isVisible() == projectEntity.isVisible() && + Objects.equals(project.getMaxScore(), projectEntity.getMaxScore()) && + project.getDeadline().equals(projectEntity.getDeadline()) + )); + + /* CopyTestFails */ + doReturn(new CheckResult<>(HttpStatus.I_AM_A_TEAPOT, "I'm a teapot", null)) + .when(commonDatabaseActions).copyTest(testEntity); + result = commonDatabaseActions.copyProject(projectEntity, newCourseId, newGroupClusterId); + assertEquals(HttpStatus.I_AM_A_TEAPOT, result.getStatus()); + + /* Test not found */ + when(testRepository.findById(projectEntity.getTestId())).thenReturn(Optional.empty()); + result = commonDatabaseActions.copyProject(projectEntity, newCourseId, newGroupClusterId); + assertEquals(HttpStatus.INTERNAL_SERVER_ERROR, result.getStatus()); + + /* project has no test */ + reset(projectRepository); + reset(testRepository); + reset(commonDatabaseActions); + when(projectRepository.save(any())).thenReturn(newProject); + projectEntity.setTestId(null); + result = commonDatabaseActions.copyProject(projectEntity, newCourseId, newGroupClusterId); + assertEquals(HttpStatus.OK, result.getStatus()); + + verify(projectRepository, times(1)).save(argThat( + project -> project.getCourseId() == newCourseId && + project.getName().equals(projectEntity.getName()) && + project.getDescription().equals(projectEntity.getDescription()) && + project.getGroupClusterId() == newGroupClusterId && + Objects.equals(project.getTestId(), null) && + project.isVisible() == projectEntity.isVisible() && + Objects.equals(project.getMaxScore(), projectEntity.getMaxScore()) && + project.getDeadline().equals(projectEntity.getDeadline()) + )); + verify(testRepository, times(0)).findById(projectEntity.getTestId()); + verify(commonDatabaseActions, times(0)).copyTest(testEntity); + } + + @Test + public void testCopyTest() { + long newTestId = 9088L; + TestEntity newTest = new TestEntity( + testEntity.getDockerImage(), + testEntity.getDockerTestScript(), + testEntity.getDockerTestTemplate(), + testEntity.getStructureTemplate() + ); + newTest.setId(newTestId); + + when(testRepository.save(argThat( + test -> + test.getDockerImage().equals(testEntity.getDockerImage()) && + test.getDockerTestScript().equals(testEntity.getDockerTestScript()) && + test.getDockerTestTemplate().equals(testEntity.getDockerTestTemplate()) && + test.getStructureTemplate().equals(testEntity.getStructureTemplate()) + ))).thenReturn(newTest); + + CheckResult result = commonDatabaseActions.copyTest(testEntity); + assertEquals(HttpStatus.OK, result.getStatus()); + assertEquals(newTest, result.getData()); + assertEquals(newTestId, result.getData().getId()); + } +} diff --git a/backend/app/src/test/java/com/ugent/pidgeon/util/CourseUtilTest.java b/backend/app/src/test/java/com/ugent/pidgeon/util/CourseUtilTest.java index b2f5ca09..089851cb 100644 --- a/backend/app/src/test/java/com/ugent/pidgeon/util/CourseUtilTest.java +++ b/backend/app/src/test/java/com/ugent/pidgeon/util/CourseUtilTest.java @@ -4,6 +4,9 @@ import static org.junit.jupiter.api.Assertions.assertNull; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.ArgumentMatchers.argThat; +import static org.mockito.Mockito.doReturn; +import static org.mockito.Mockito.reset; import static org.mockito.Mockito.when; import com.ugent.pidgeon.model.json.CourseJson; @@ -11,17 +14,20 @@ import com.ugent.pidgeon.model.json.UserIdJson; import com.ugent.pidgeon.postgre.models.CourseEntity; import com.ugent.pidgeon.postgre.models.CourseUserEntity; +import com.ugent.pidgeon.postgre.models.CourseUserId; import com.ugent.pidgeon.postgre.models.UserEntity; import com.ugent.pidgeon.postgre.models.types.CourseRelation; import com.ugent.pidgeon.postgre.models.types.UserRole; import com.ugent.pidgeon.postgre.repository.CourseRepository; import com.ugent.pidgeon.postgre.repository.CourseUserRepository; +import java.time.OffsetDateTime; import java.util.Optional; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.InjectMocks; import org.mockito.Mock; +import org.mockito.Spy; import org.mockito.junit.jupiter.MockitoExtension; import org.springframework.http.HttpMethod; import org.springframework.http.HttpStatus; @@ -35,6 +41,10 @@ public class CourseUtilTest { @Mock private CourseRepository courseRepository; + @Mock + private UserUtil userUtil; + + @Spy @InjectMocks private CourseUtil courseUtil; @@ -47,9 +57,9 @@ public class CourseUtilTest { @BeforeEach public void setUp() { user = new UserEntity("name", "surname", "email", UserRole.student, "azureid"); - user.setId(1L); + user.setId(44L); course = new CourseEntity("name", "description",2024); - course.setId(1L); + course.setId(9L); course.setJoinKey("key"); cuEnrolled = new CourseUserEntity(1L, 1L, CourseRelation.enrolled); cuAdmin = new CourseUserEntity(1L, 2L, CourseRelation.course_admin); @@ -57,103 +67,426 @@ public void setUp() { } @Test - public void testGetCourseIfAdmin() throws Exception { - when(courseRepository.findById(anyLong())).thenReturn(Optional.of(course)); - when(courseUserRepository.findById(any())).thenReturn(Optional.of(cuAdmin)); - CheckResult result = courseUtil.getCourseIfAdmin(1L, user); - assertEquals(HttpStatus.OK, result.getStatus()); - assertEquals(course, result.getData()); + public void testGetCourseIfAdmin() { + /* All checks succeed */ + doReturn(new CheckResult<>(HttpStatus.OK, "", new Pair(course, CourseRelation.course_admin))) + .when(courseUtil).getCourseIfUserInCourse(course.getId(), user); + + CheckResult check = courseUtil.getCourseIfAdmin(course.getId(), user); + assertEquals(HttpStatus.OK, check.getStatus()); + assertEquals(course, check.getData()); + /* User is not a course admin */ + doReturn(new CheckResult<>(HttpStatus.OK, "", new Pair(course, CourseRelation.enrolled))) + .when(courseUtil).getCourseIfUserInCourse(course.getId(), user); + check = courseUtil.getCourseIfAdmin(course.getId(), user); + assertEquals(HttpStatus.FORBIDDEN, check.getStatus()); + + /* User is not a course admin, but a platform admin */ + user.setRole(UserRole.admin); + check = courseUtil.getCourseIfAdmin(course.getId(), user); + assertEquals(HttpStatus.OK, check.getStatus()); + + /* Get course fails */ + doReturn(new CheckResult<>(HttpStatus.I_AM_A_TEAPOT, "I'm a teapot", null)) + .when(courseUtil).getCourseIfUserInCourse(course.getId(), user); + check = courseUtil.getCourseIfAdmin(course.getId(), user); + assertEquals(HttpStatus.I_AM_A_TEAPOT, check.getStatus()); + } + + @Test + public void testGetCourseIfUserInCourse() { + /* All checks succeed */ + doReturn(new CheckResult<>(HttpStatus.OK, "", course)).when(courseUtil).getCourseIfExists(course.getId()); when(courseUserRepository.findById(any())).thenReturn(Optional.of(cuEnrolled)); - result = courseUtil.getCourseIfAdmin(1L, user); - assertEquals(HttpStatus.FORBIDDEN, result.getStatus()); - assertEquals("User is not an admin of the course", result.getMessage()); + CheckResult> check = courseUtil.getCourseIfUserInCourse(course.getId(), user); + assertEquals(HttpStatus.OK, check.getStatus()); + assertEquals(course, check.getData().getFirst()); + assertEquals(CourseRelation.enrolled, check.getData().getSecond()); + + when(courseUserRepository.findById(any())).thenReturn(Optional.of(cuAdmin)); + check = courseUtil.getCourseIfUserInCourse(course.getId(), user); + assertEquals(CourseRelation.course_admin, check.getData().getSecond()); + + when(courseUserRepository.findById(any())).thenReturn(Optional.of(cuCreator)); + check = courseUtil.getCourseIfUserInCourse(course.getId(), user); + assertEquals(CourseRelation.creator, check.getData().getSecond()); + + /* User isn't in course */ + when(courseUserRepository.findById(any())).thenReturn(Optional.empty()); + check = courseUtil.getCourseIfUserInCourse(course.getId(), user); + assertEquals(HttpStatus.FORBIDDEN, check.getStatus()); + + /* User isn't in course but is admin */ + user.setRole(UserRole.admin); + check = courseUtil.getCourseIfUserInCourse(course.getId(), user); + assertEquals(HttpStatus.OK, check.getStatus()); + + /* Get course fails */ + reset(courseUtil); + doReturn(new CheckResult<>(HttpStatus.I_AM_A_TEAPOT, "I'm a teapot", null)) + .when(courseUtil).getCourseIfExists(course.getId()); + check = courseUtil.getCourseIfUserInCourse(course.getId(), user); + assertEquals(HttpStatus.I_AM_A_TEAPOT, check.getStatus()); } @Test - public void testGetCourseIfExists() throws Exception { - when(courseRepository.findById(anyLong())).thenReturn(Optional.of(course)); - CheckResult check = courseUtil.getCourseIfExists(1L); + public void testGetCourseIfExists() { + reset(courseUtil); + /* All checks succeed */ + when(courseRepository.findById(course.getId())).thenReturn(Optional.of(course)); + CheckResult check = courseUtil.getCourseIfExists(course.getId()); assertEquals(HttpStatus.OK, check.getStatus()); assertEquals(course, check.getData()); - when(courseRepository.findById(anyLong())).thenReturn(Optional.empty()); - check = courseUtil.getCourseIfExists(1L); + /* Course does not exist */ + when(courseRepository.findById(course.getId())).thenReturn(Optional.empty()); + check = courseUtil.getCourseIfExists(course.getId()); assertEquals(HttpStatus.NOT_FOUND, check.getStatus()); - assertEquals("Course not found", check.getMessage()); assertNull(check.getData()); } @Test - public void testCanUpdateUserInCourse() throws Exception { + public void testCanUpdateUserInCourse() { CourseMemberRequestJson request = new CourseMemberRequestJson(); - request.setUserId(2L); - request.setRelation(String.valueOf(CourseRelation.enrolled)); - when(courseRepository.findById(anyLong())).thenReturn(Optional.of(course)); + request.setUserId(5L); + request.setRelation("course_admin"); + /* All checks succeed */ + doReturn(new CheckResult<>(HttpStatus.OK, "", new Pair(course, CourseRelation.creator))) + .when(courseUtil).getCourseIfUserInCourse(course.getId(), user); + when(courseUserRepository.findById(any())).thenReturn(Optional.of(cuAdmin)); - CheckResult checkResult = courseUtil.canUpdateUserInCourse( - 1L, request, user, HttpMethod.PATCH - ); - assertEquals(HttpStatus.OK, checkResult.getStatus()); + + CheckResult check = courseUtil.canUpdateUserInCourse(course.getId(), request, user, HttpMethod.PATCH); + assertEquals(HttpStatus.OK, check.getStatus()); + assertEquals(cuAdmin, check.getData()); + + /* User is not creator but trying to add admin */ + doReturn(new CheckResult<>(HttpStatus.OK, "", new Pair(course, CourseRelation.course_admin))) + .when(courseUtil).getCourseIfUserInCourse(course.getId(), user); + check = courseUtil.canUpdateUserInCourse(course.getId(), request, user, HttpMethod.PATCH); + assertEquals(HttpStatus.FORBIDDEN, check.getStatus()); + + /* User is not creator but trying to downgrade admin */ + request.setRelation("enrolled"); + check = courseUtil.canUpdateUserInCourse(course.getId(), request, user, HttpMethod.PATCH); + assertEquals(HttpStatus.FORBIDDEN, check.getStatus()); + + /* User is general admin and trying to add admin */ + request.setRelation("course_admin"); + user.setRole(UserRole.admin); + when(courseUserRepository.findById(any())).thenReturn(Optional.of(cuEnrolled)); + check = courseUtil.canUpdateUserInCourse(course.getId(), request, user, HttpMethod.PATCH); + assertEquals(HttpStatus.OK, check.getStatus()); + + /* User is trying to change the creator */ + request.setRelation("creator"); + user.setRole(UserRole.teacher); + doReturn(new CheckResult<>(HttpStatus.OK, "", new Pair(course, CourseRelation.creator))) + .when(courseUtil).getCourseIfUserInCourse(course.getId(), user); + check = courseUtil.canUpdateUserInCourse(course.getId(), request, user, HttpMethod.PATCH); + assertEquals(HttpStatus.FORBIDDEN, check.getStatus()); + + /* User is trying to change the creator as admin */ + user.setRole(UserRole.admin); + check = courseUtil.canUpdateUserInCourse(course.getId(), request, user, HttpMethod.PATCH); + assertEquals(HttpStatus.OK, check.getStatus()); + user.setRole(UserRole.teacher); + request.setRelation("enrolled"); + + /* User is trying to change it's own role */ + request.setUserId(user.getId()); + check = courseUtil.canUpdateUserInCourse(course.getId(), request, user, HttpMethod.PATCH); + assertEquals(HttpStatus.FORBIDDEN, check.getStatus()); + + /* User is trying to change it's own role as admin */ + user.setRole(UserRole.admin); + check = courseUtil.canUpdateUserInCourse(course.getId(), request, user, HttpMethod.PATCH); + assertEquals(HttpStatus.OK, check.getStatus()); + user.setRole(UserRole.teacher); + + /* User isn't in course on patch */ + request.setUserId(5L); + when(courseUserRepository.findById(any())).thenReturn(Optional.empty()); + check = courseUtil.canUpdateUserInCourse(course.getId(), request, user, HttpMethod.PATCH); + assertEquals(HttpStatus.BAD_REQUEST, check.getStatus()); + + /* Post everything succeeds */ + when(userUtil.userExists(request.getUserId())).thenReturn(true); + check = courseUtil.canUpdateUserInCourse(course.getId(), request, user, HttpMethod.POST); + assertEquals(HttpStatus.OK, check.getStatus()); + + /* User doesn't exist */ + when(userUtil.userExists(request.getUserId())).thenReturn(false); + check = courseUtil.canUpdateUserInCourse(course.getId(), request, user, HttpMethod.POST); + assertEquals(HttpStatus.NOT_FOUND, check.getStatus()); + + /* User is already in course on POST */ + when(courseUserRepository.findById(any())).thenReturn(Optional.of(cuEnrolled)); + check = courseUtil.canUpdateUserInCourse(course.getId(), request, user, HttpMethod.POST); + assertEquals(HttpStatus.BAD_REQUEST, check.getStatus()); + + /* Invalid relation */ + request.setRelation("invalid"); + check = courseUtil.canUpdateUserInCourse(course.getId(), request, user, HttpMethod.POST); + assertEquals(HttpStatus.BAD_REQUEST, check.getStatus()); + + /* Relation not present */ + request.setRelation(null); + check = courseUtil.canUpdateUserInCourse(course.getId(), request, user, HttpMethod.POST); + assertEquals(HttpStatus.BAD_REQUEST, check.getStatus()); + + /* UserId not present */ + request.setRelation("enrolled"); + request.setUserId(null); + check = courseUtil.canUpdateUserInCourse(course.getId(), request, user, HttpMethod.POST); + assertEquals(HttpStatus.BAD_REQUEST, check.getStatus()); + + /* User is not an admin */ + request.setUserId(5L); + when(courseUserRepository.findById(any())).thenReturn(Optional.empty()); + when(userUtil.userExists(request.getUserId())).thenReturn(true); + doReturn(new CheckResult<>(HttpStatus.OK, "", new Pair<>(course, CourseRelation.enrolled))) + .when(courseUtil).getCourseIfUserInCourse(course.getId(), user); + check = courseUtil.canUpdateUserInCourse(course.getId(), request, user, HttpMethod.POST); + assertEquals(HttpStatus.FORBIDDEN, check.getStatus()); + + /* User is not in course but is admin */ + user.setRole(UserRole.admin); + check = courseUtil.canUpdateUserInCourse(course.getId(), request, user, HttpMethod.POST); + assertEquals(HttpStatus.OK, check.getStatus()); + + /* get course fails */ + doReturn(new CheckResult<>(HttpStatus.I_AM_A_TEAPOT, "I'm a teapot", null)) + .when(courseUtil).getCourseIfUserInCourse(course.getId(), user); + check = courseUtil.canUpdateUserInCourse(course.getId(), request, user, HttpMethod.POST); + assertEquals(HttpStatus.I_AM_A_TEAPOT, check.getStatus()); + } @Test public void testCanLeaveCourse() throws Exception { - when(courseRepository.findById(anyLong())).thenReturn(Optional.of(course)); - when(courseUserRepository.findById(any())).thenReturn(Optional.of(cuAdmin)); - CheckResult checkResult = courseUtil.canLeaveCourse(1L, user); - assertEquals(HttpStatus.OK, checkResult.getStatus()); - assertEquals(CourseRelation.course_admin, checkResult.getData()); + /* All checks succeed */ + doReturn(new CheckResult<>(HttpStatus.OK, "", new Pair<>(course, CourseRelation.enrolled))) + .when(courseUtil).getCourseIfUserInCourse(course.getId(), user); + CheckResult check = courseUtil.canLeaveCourse(course.getId(), user); + assertEquals(HttpStatus.OK, check.getStatus()); + assertEquals(CourseRelation.enrolled, check.getData()); + + /* Course is archived */ + course.setArchivedAt(OffsetDateTime.now()); + check = courseUtil.canLeaveCourse(course.getId(), user); + assertEquals(HttpStatus.FORBIDDEN, check.getStatus()); + + /* User is course creator */ + doReturn(new CheckResult<>(HttpStatus.OK, "", new Pair<>(course, CourseRelation.creator))) + .when(courseUtil).getCourseIfUserInCourse(course.getId(), user); + check = courseUtil.canLeaveCourse(course.getId(), user); + assertEquals(HttpStatus.FORBIDDEN, check.getStatus()); + + /* get course fails */ + doReturn(new CheckResult<>(HttpStatus.I_AM_A_TEAPOT, "I'm a teapot", null)) + .when(courseUtil).getCourseIfUserInCourse(course.getId(), user); + check = courseUtil.canLeaveCourse(course.getId(), user); + assertEquals(HttpStatus.I_AM_A_TEAPOT, check.getStatus()); } @Test public void testCanDeleteUser() throws Exception { - when(courseRepository.findById(anyLong())).thenReturn(Optional.of(course)); - when(courseUserRepository.findById(any())).thenReturn(Optional.of(cuAdmin)); - CheckResult checkResult = courseUtil.canDeleteUser( - 1L, 5L, user - ); - assertEquals(HttpStatus.OK, checkResult.getStatus()); - assertEquals(CourseRelation.course_admin, checkResult.getData()); + /* All checks succeed */ + doReturn(new CheckResult<>(HttpStatus.OK, "", new Pair<>(course, CourseRelation.creator))) + .when(courseUtil).getCourseIfUserInCourse(course.getId(), user); + when(courseUserRepository.findById(argThat( + arg -> arg.getCourseId() == (course.getId()) && + arg.getUserId() == cuEnrolled.getUserId()) + )).thenReturn(Optional.of(cuEnrolled)); + CheckResult check = courseUtil.canDeleteUser(course.getId(), cuEnrolled.getUserId(), user); + assertEquals(HttpStatus.OK, check.getStatus()); + assertEquals(CourseRelation.enrolled, check.getData()); + + /* User is course admin */ + reset(courseUserRepository); + when(courseUserRepository.findById( + argThat(arg -> arg.getCourseId() == (course.getId()) && arg.getUserId() == cuAdmin.getUserId()) + )).thenReturn(Optional.of(cuAdmin)); + check = courseUtil.canDeleteUser(course.getId(), cuAdmin.getUserId(), user); + assertEquals(HttpStatus.OK, check.getStatus()); + + /* User isn't course creator but tries to delete admin */ + doReturn(new CheckResult<>(HttpStatus.OK, "", new Pair<>(course, CourseRelation.course_admin))) + .when(courseUtil).getCourseIfUserInCourse(course.getId(), user); + check = courseUtil.canDeleteUser(course.getId(), cuAdmin.getUserId(), user); + assertEquals(HttpStatus.FORBIDDEN, check.getStatus()); + + /* User is general admin and tries to delete admin */ + user.setRole(UserRole.admin); + check = courseUtil.canDeleteUser(course.getId(), cuAdmin.getUserId(), user); + assertEquals(HttpStatus.OK, check.getStatus()); + + /* User tries to delete creator */ + reset(courseUserRepository); + when(courseUserRepository.findById( + argThat(arg -> arg.getCourseId() == (course.getId()) && arg.getUserId() == cuCreator.getUserId()) + )).thenReturn(Optional.of(cuCreator)); + check = courseUtil.canDeleteUser(course.getId(), cuCreator.getUserId(), user); + assertEquals(HttpStatus.FORBIDDEN, check.getStatus()); + + /* User tries to delete itself */ + reset(courseUserRepository); + when(courseUserRepository.findById( + argThat(arg -> arg.getCourseId() == (course.getId()) && arg.getUserId() == user.getId()) + )).thenReturn(Optional.of(new CourseUserEntity(1L, 1L, CourseRelation.enrolled))); + check = courseUtil.canDeleteUser(course.getId(), user.getId(), user); + assertEquals(HttpStatus.FORBIDDEN, check.getStatus()); + + /* User is trying to delete non-existing user */ + reset(courseUserRepository); + when(courseUserRepository.findById( + argThat(arg -> arg.getCourseId() == (course.getId()) && arg.getUserId() == cuEnrolled.getUserId()) + )).thenReturn(Optional.empty()); + check = courseUtil.canDeleteUser(course.getId(), cuEnrolled.getUserId(), user); + assertEquals(HttpStatus.NOT_FOUND, check.getStatus()); + + /* User is not an admin */ + user.setRole(UserRole.student); + doReturn(new CheckResult<>(HttpStatus.OK, "", new Pair<>(course, CourseRelation.enrolled))) + .when(courseUtil).getCourseIfUserInCourse(course.getId(), user); + check = courseUtil.canDeleteUser(course.getId(), cuEnrolled.getUserId(), user); + assertEquals(HttpStatus.FORBIDDEN, check.getStatus()); + + /* User is not in course but is admin */ + user.setRole(UserRole.admin); + reset(courseUserRepository); + when(courseUserRepository.findById(argThat( + arg -> arg.getCourseId() == (course.getId()) && + arg.getUserId() == cuEnrolled.getUserId()) + )).thenReturn(Optional.of(cuEnrolled)); + check = courseUtil.canDeleteUser(course.getId(), cuEnrolled.getUserId(), user); + assertEquals(HttpStatus.OK, check.getStatus()); + + /* get course fails */ + doReturn(new CheckResult<>(HttpStatus.I_AM_A_TEAPOT, "I'm a teapot", null)) + .when(courseUtil).getCourseIfUserInCourse(course.getId(), user); + check = courseUtil.canDeleteUser(course.getId(), cuEnrolled.getUserId(), user); + assertEquals(HttpStatus.I_AM_A_TEAPOT, check.getStatus()); } @Test public void testGetJoinLink() throws Exception { - String link = courseUtil.getJoinLink("key", "1"); - assertEquals("/api/courses/1/join/key", link); - link = courseUtil.getJoinLink(null, "1"); - assertEquals("/api/courses/1/join", link); + /* Link with key */ + String link = courseUtil.getJoinLink("key", "898"); + assertEquals("/api/courses/898/join/key", link); + /* Link without key */ + link = courseUtil.getJoinLink(null, "334"); + assertEquals("/api/courses/334/join", link); } @Test public void testCheckJoinLink() throws Exception { - when(courseRepository.findById(anyLong())).thenReturn(Optional.of(course)); - when(courseUserRepository.findById(any())).thenReturn(Optional.empty()); - CheckResult result = courseUtil.checkJoinLink(1L, "key", user); - assertEquals(HttpStatus.OK, result.getStatus()); - assertEquals(course, result.getData()); + /* All checks succeed */ + when(courseRepository.findById(course.getId())).thenReturn(Optional.of(course)); + when(courseUserRepository.findById(argThat( + arg -> arg.getCourseId() == (course.getId()) && arg.getUserId() == user.getId()) + )).thenReturn(Optional.empty()); - result = courseUtil.checkJoinLink(1L, null, user); - assertEquals(HttpStatus.FORBIDDEN, result.getStatus()); - assertEquals("Course requires a join key. Use /api/courses/1/join/{courseKey}", - result.getMessage()); + /* Course without key */ + course.setJoinKey(null); + CheckResult check = courseUtil.checkJoinLink(course.getId(), null, user); + assertEquals(HttpStatus.OK, check.getStatus()); + assertEquals(course, check.getData()); + + /* Course with key */ + course.setJoinKey("key"); + check = courseUtil.checkJoinLink(course.getId(), "key", user); + assertEquals(HttpStatus.OK, check.getStatus()); + assertEquals(course, check.getData()); + + /* Check fails */ + /* User already in course */ + reset(courseUserRepository); + when(courseUserRepository.findById(argThat( + arg -> arg.getCourseId() == (course.getId()) && arg.getUserId() == user.getId()) + )).thenReturn(Optional.of(cuEnrolled)); + check = courseUtil.checkJoinLink(course.getId(), "key", user); + assertEquals(HttpStatus.BAD_REQUEST, check.getStatus()); + + /* Course with key but no key provided */ + check = courseUtil.checkJoinLink(course.getId(), null, user); + assertEquals(HttpStatus.FORBIDDEN, check.getStatus()); + + /* Course with key but wrong key provided */ + check = courseUtil.checkJoinLink(course.getId(), "wrong", user); + assertEquals(HttpStatus.FORBIDDEN, check.getStatus()); + + /* Course without key but key provided */ + course.setJoinKey(null); + check = courseUtil.checkJoinLink(course.getId(), "key", user); + assertEquals(HttpStatus.FORBIDDEN, check.getStatus()); + + /* Course does not exist */ + when(courseRepository.findById(course.getId())).thenReturn(Optional.empty()); + check = courseUtil.checkJoinLink(course.getId(), "key", user); + assertEquals(HttpStatus.NOT_FOUND, check.getStatus()); } @Test public void testCheckCourseJson() throws Exception { - CourseJson courseJson = new CourseJson("name", "description", null,2023); + CourseJson courseJson = new CourseJson( + "name", "description", null, 2024 + ); + /* Creating a course */ + user.setRole(UserRole.teacher); + when(courseUserRepository.findById(argThat( + arg -> arg.getCourseId() == (course.getId()) && arg.getUserId() == user.getId()) + )).thenReturn(Optional.of(cuCreator)); CheckResult result = courseUtil.checkCourseJson(courseJson, user, null); assertEquals(HttpStatus.OK, result.getStatus()); + /* Updating a course */ + CheckResult result2 = courseUtil.checkCourseJson(courseJson, user, course.getId()); + + /* Name is empty */ + courseJson.setName(""); + result = courseUtil.checkCourseJson(courseJson, user, null); + assertEquals(HttpStatus.BAD_REQUEST, result.getStatus()); + + /* name is null */ + courseJson.setName(null); + result = courseUtil.checkCourseJson(courseJson, user, null); + assertEquals(HttpStatus.BAD_REQUEST, result.getStatus()); + + /* description is null */ + courseJson.setName("name"); courseJson.setDescription(null); result = courseUtil.checkCourseJson(courseJson, user, null); assertEquals(HttpStatus.BAD_REQUEST, result.getStatus()); - assertEquals("name, description and year are required", result.getMessage()); + /* year is null */ courseJson.setDescription("description"); - courseJson.setName(""); + courseJson.setYear(null); result = courseUtil.checkCourseJson(courseJson, user, null); assertEquals(HttpStatus.BAD_REQUEST, result.getStatus()); - assertEquals("Name cannot be empty", result.getMessage()); + + /* creator can (un)archive course */ + courseJson.setYear(2024); + courseJson.setArchived(true); + result = courseUtil.checkCourseJson(courseJson, user, course.getId()); + assertEquals(HttpStatus.OK, result.getStatus()); + + /* not-creator can't (un)archive course */ + reset(courseUserRepository); + when(courseUserRepository.findById(argThat( + arg -> arg.getCourseId() == (course.getId()) && arg.getUserId() == user.getId()) + )).thenReturn(Optional.of(cuAdmin)); + result = courseUtil.checkCourseJson(courseJson, user, course.getId()); + assertEquals(HttpStatus.FORBIDDEN, result.getStatus()); + + /* User has to be in course to update */ + reset(courseUserRepository); + when(courseUserRepository.findById(argThat( + arg -> arg.getCourseId() == (course.getId()) && arg.getUserId() == user.getId()) + )).thenReturn(Optional.empty()); + result = courseUtil.checkCourseJson(courseJson, user, course.getId()); + assertEquals(HttpStatus.FORBIDDEN, result.getStatus()); } } diff --git a/backend/app/src/test/java/com/ugent/pidgeon/util/EntityToJsonConverterTest.java b/backend/app/src/test/java/com/ugent/pidgeon/util/EntityToJsonConverterTest.java index b199d564..fa1a2daa 100644 --- a/backend/app/src/test/java/com/ugent/pidgeon/util/EntityToJsonConverterTest.java +++ b/backend/app/src/test/java/com/ugent/pidgeon/util/EntityToJsonConverterTest.java @@ -1,30 +1,55 @@ package com.ugent.pidgeon.util; +import com.ugent.pidgeon.controllers.ApiRoutes; import com.ugent.pidgeon.model.ProjectResponseJson; +import com.ugent.pidgeon.model.json.CourseJson; +import com.ugent.pidgeon.model.json.CourseReferenceJson; import com.ugent.pidgeon.model.json.CourseWithInfoJson; +import com.ugent.pidgeon.model.json.CourseWithRelationJson; import com.ugent.pidgeon.model.json.GroupClusterJson; +import com.ugent.pidgeon.model.json.GroupFeedbackJson; +import com.ugent.pidgeon.model.json.GroupFeedbackJsonWithProject; import com.ugent.pidgeon.model.json.GroupJson; +import com.ugent.pidgeon.model.json.ProjectProgressJson; +import com.ugent.pidgeon.model.json.ProjectResponseJsonWithStatus; +import com.ugent.pidgeon.model.json.ProjectStatus; import com.ugent.pidgeon.model.json.SubmissionJson; import com.ugent.pidgeon.model.json.TestJson; import com.ugent.pidgeon.model.json.UserReferenceJson; +import com.ugent.pidgeon.model.json.UserReferenceWithRelation; import com.ugent.pidgeon.postgre.models.*; import com.ugent.pidgeon.postgre.models.types.CourseRelation; +import com.ugent.pidgeon.postgre.models.types.DockerTestState; +import com.ugent.pidgeon.postgre.models.types.DockerTestType; import com.ugent.pidgeon.postgre.models.types.UserRole; import com.ugent.pidgeon.postgre.repository.*; +import com.ugent.pidgeon.postgre.repository.GroupRepository.UserReference; import java.time.OffsetDateTime; +import java.util.List; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.InjectMocks; import org.mockito.Mock; +import org.mockito.Spy; import org.mockito.junit.jupiter.MockitoExtension; import java.util.Collections; import java.util.Optional; import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotEquals; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.ArgumentMatchers.argThat; +import static org.mockito.Mockito.doReturn; +import static org.mockito.Mockito.reset; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; @ExtendWith(MockitoExtension.class) @@ -51,63 +76,209 @@ public class EntityToJsonConverterTest { @Mock private SubmissionRepository submissionRepository; + @Spy @InjectMocks private EntityToJsonConverter entityToJsonConverter; - private GroupEntity groupEntity; private GroupClusterEntity groupClusterEntity; + private GroupEntity groupEntity; private UserEntity userEntity; + private UserEntity otherUser; private CourseEntity courseEntity; private ProjectEntity projectEntity; + private GroupFeedbackEntity groupFeedbackEntity; private SubmissionEntity submissionEntity; private TestEntity testEntity; + + private GroupJson groupJson; + private UserReferenceJson userReferenceJson; + private UserReferenceJson otherUserReferenceJson; + private GroupFeedbackJson groupFeedbackJson; + private ProjectResponseJson projectResponseJson; + private CourseReferenceJson courseJson; + @BeforeEach public void setUp() { - groupEntity = new GroupEntity("test group", 1L); - groupEntity.setId(1L); + courseEntity = new CourseEntity("name", "description",2024); + courseEntity.setJoinKey("joinKey"); + courseEntity.setId(9L); + + groupClusterEntity = new GroupClusterEntity( + courseEntity.getId(), + 20, + "clusterName", + 5 + ); + groupClusterEntity.setGroupAmount(5); + groupClusterEntity.setId(9L); + + groupEntity = new GroupEntity( + "groupName", + groupClusterEntity.getId() + ); + groupEntity.setId(4L); + + groupJson = new GroupJson( + 20, + 4L, + "groupName", + ApiRoutes.CLUSTER_BASE_PATH + "/" + groupClusterEntity.getId() + ); + + userEntity = new UserEntity( + "name", + "surname", + "email", + UserRole.student, + "azureId" + ); + userEntity.setId(44L); + userReferenceJson = new UserReferenceJson( + userEntity.getName() + " " + userEntity.getSurname(), + userEntity.getEmail(), + userEntity.getId() + ); + + otherUser = new UserEntity( + "otherName", + "otherSurname", + "otherEmail", + UserRole.student, + "otherAzureId" + ); + otherUserReferenceJson = new UserReferenceJson( + otherUser.getName() + " " + otherUser.getSurname(), + otherUser.getEmail(), + otherUser.getId() + ); + - groupClusterEntity = new GroupClusterEntity(1L, 5, "Test Cluster", 1); - groupClusterEntity.setId(1L); - userEntity = new UserEntity("name", "surname", "email", UserRole.student, "azureid"); - userEntity.setId(1L); + testEntity = new TestEntity( + "dockerImageBasic", + "dockerTestScriptBasic", + "dockerTestTemplateBasic", + "structureTemplateBasic" + ); + testEntity.setId(38L); - courseEntity = new CourseEntity(); - courseEntity.setId(1L); - courseEntity.setName("Test Course"); - courseEntity.setCourseYear(2024); + projectEntity = new ProjectEntity( + courseEntity.getId(), + "projectName", + "projectDescription", + groupClusterEntity.getId(), + testEntity.getId(), + true, + 34, + OffsetDateTime.now() + ); + projectEntity.setId(64); - projectEntity = new ProjectEntity(); - projectEntity.setId(1L); - projectEntity.setVisible(true); - projectEntity.setName("Test Project"); + courseJson = new CourseReferenceJson(courseEntity.getName(), "courseUrl", courseEntity.getId(), null); - submissionEntity = new SubmissionEntity(1L, 1L, 1L, OffsetDateTime.now(), true, true); - submissionEntity.setId(1L); - testEntity = new TestEntity(); - testEntity.setId(1L); - testEntity.setDockerImage("Test Docker Image"); + projectResponseJson = new ProjectResponseJson( + courseJson, + projectEntity.getDeadline(), + projectEntity.getDescription(), + projectEntity.getId(), + projectEntity.getName(), + "SubmissionURL", + "TestURL", + projectEntity.getMaxScore(), + projectEntity.isVisible(), + new ProjectProgressJson(44, 60), + groupEntity.getId(), + groupClusterEntity.getId() + ); + + groupFeedbackEntity = new GroupFeedbackEntity( + groupEntity.getId(), + projectEntity.getId(), + 5.0f, + "feedback" + ); + + groupFeedbackJson = new GroupFeedbackJson( + groupFeedbackEntity.getScore(), + groupFeedbackEntity.getFeedback(), + groupFeedbackEntity.getGroupId(), + groupFeedbackEntity.getProjectId() + ); + + submissionEntity = new SubmissionEntity( + 22, + 45, + 99L, + OffsetDateTime.MIN, + true, + true + ); } @Test public void testGroupEntityToJson() { - when(groupClusterRepository.findById(anyLong())).thenReturn(Optional.of(groupClusterEntity)); + when(groupClusterRepository.findById(groupEntity.getClusterId())).thenReturn(Optional.of(groupClusterEntity)); when(groupRepository.findGroupUsersReferencesByGroupId(anyLong())).thenReturn( - Collections.emptyList()); + List.of(new UserReference[]{ + new UserReference() { + @Override + public Long getUserId() { + return userEntity.getId(); + } + + @Override + public String getName() { + return userEntity.getName() + " " + userEntity.getSurname(); + } + + @Override + public String getEmail() { + return userEntity.getEmail(); + } + } + + }) + ); GroupJson result = entityToJsonConverter.groupEntityToJson(groupEntity); + assertEquals(groupClusterEntity.getMaxSize(), result.getCapacity()); + assertEquals(groupEntity.getId(), result.getGroupId()); assertEquals(groupEntity.getName(), result.getName()); + assertEquals(ApiRoutes.CLUSTER_BASE_PATH + "/" + groupClusterEntity.getId(), result.getGroupClusterUrl()); + assertEquals(1, result.getMembers().size()); + UserReferenceJson userReferenceJson = result.getMembers().get(0); + assertEquals(userEntity.getId(), userReferenceJson.getUserId()); + assertEquals(userEntity.getName() + " " + userEntity.getSurname(), userReferenceJson.getName()); + assertEquals(userEntity.getEmail(), userReferenceJson.getEmail()); + + /* Cluster is individual */ + groupClusterEntity.setMaxSize(1); + result = entityToJsonConverter.groupEntityToJson(groupEntity); + assertEquals(1, result.getCapacity()); + assertNull(result.getGroupClusterUrl()); + + /* Issue when groupClusterEntity is null */ + when(groupClusterRepository.findById(groupEntity.getClusterId())).thenReturn(Optional.empty()); + assertThrows(RuntimeException.class, () -> entityToJsonConverter.groupEntityToJson(groupEntity)); + } @Test public void testClusterEntityToClusterJson() { - when(groupRepository.findAllByClusterId(anyLong())).thenReturn( - Collections.singletonList(groupEntity)); - when(groupClusterRepository.findById(anyLong())).thenReturn(Optional.of(groupClusterEntity)); + when(groupRepository.findAllByClusterId(groupClusterEntity.getId())).thenReturn(List.of(groupEntity)); + doReturn(groupJson).when(entityToJsonConverter).groupEntityToJson(groupEntity); + GroupClusterJson result = entityToJsonConverter.clusterEntityToClusterJson(groupClusterEntity); + assertEquals(groupClusterEntity.getId(), result.clusterId()); assertEquals(groupClusterEntity.getName(), result.name()); + assertEquals(groupClusterEntity.getMaxSize(), result.capacity()); + assertEquals(groupClusterEntity.getGroupAmount(), result.groupCount()); + assertEquals(groupClusterEntity.getCreatedAt(), result.createdAt()); + assertEquals(1, result.groups().size()); + assertEquals(groupJson, result.groups().get(0)); + assertEquals(ApiRoutes.COURSE_BASE_PATH + "/" + courseEntity.getId(), result.courseUrl()); } @Test @@ -115,43 +286,285 @@ public void testUserEntityToUserReference() { UserReferenceJson result = entityToJsonConverter.userEntityToUserReference(userEntity); assertEquals(userEntity.getId(), result.getUserId()); assertEquals(userEntity.getName() + " " + userEntity.getSurname(), result.getName()); + assertEquals(userEntity.getEmail(), result.getEmail()); + } + + @Test + public void testUserEntityToUserReferenceWithRelation() { + doReturn(userReferenceJson).when(entityToJsonConverter).userEntityToUserReference(userEntity); + UserReferenceWithRelation result = entityToJsonConverter.userEntityToUserReferenceWithRelation(userEntity, CourseRelation.creator); + assertEquals(userReferenceJson, result.getUser()); + assertEquals(CourseRelation.creator.toString(), result.getRelation()); + + result = entityToJsonConverter.userEntityToUserReferenceWithRelation(userEntity, CourseRelation.course_admin); + assertEquals(CourseRelation.course_admin.toString(), result.getRelation()); + + result = entityToJsonConverter.userEntityToUserReferenceWithRelation(userEntity, CourseRelation.enrolled); + assertEquals(CourseRelation.enrolled.toString(), result.getRelation()); } @Test public void testCourseEntityToCourseWithInfo() { - when(courseRepository.findTeacherByCourseId(anyLong())).thenReturn(userEntity); - when(courseRepository.findAssistantsByCourseId(anyLong())).thenReturn(Collections.emptyList()); - CourseWithInfoJson result = entityToJsonConverter.courseEntityToCourseWithInfo(courseEntity, - "joinLink", false); + String joinLink = "JOIN LINK"; + courseEntity.setArchivedAt(OffsetDateTime.now()); + courseEntity.setCreatedAt(OffsetDateTime.MIN); + + when(courseRepository.findTeacherByCourseId(courseEntity.getId())).thenReturn(userEntity); + when(courseRepository.findAssistantsByCourseId(courseEntity.getId())).thenReturn(List.of(otherUser)); + + doReturn(userReferenceJson).when(entityToJsonConverter).userEntityToUserReference(userEntity); + doReturn(otherUserReferenceJson).when(entityToJsonConverter).userEntityToUserReference(otherUser); + + CourseWithInfoJson result = entityToJsonConverter.courseEntityToCourseWithInfo(courseEntity, joinLink, false); assertEquals(courseEntity.getId(), result.courseId()); assertEquals(courseEntity.getName(), result.name()); + assertEquals(courseEntity.getDescription(), result.description()); + assertEquals(userReferenceJson, result.teacher()); + assertEquals(List.of(otherUserReferenceJson), result.assistants()); + assertEquals(joinLink, result.joinUrl()); + assertEquals(courseEntity.getJoinKey(), result.joinKey()); + assertEquals(courseEntity.getArchivedAt().toInstant(), result.archivedAt().toInstant()); + assertEquals(courseEntity.getCreatedAt().toInstant(), result.createdAt().toInstant()); + assertEquals(courseEntity.getCourseYear(), result.year()); + + /* Hide key */ + result = entityToJsonConverter.courseEntityToCourseWithInfo(courseEntity, joinLink, true); + assertNull(result.joinKey()); + assertNull(result.joinUrl()); + + } + + @Test + public void testCourseEntityToCourseWithRelation() { + + int userCount = 5; + courseEntity.setArchivedAt(OffsetDateTime.now()); + courseEntity.setCreatedAt(OffsetDateTime.MIN); + + when(courseUserRepository.countUsersInCourse(courseEntity.getId())).thenReturn(userCount); + CourseWithRelationJson result = entityToJsonConverter.courseEntityToCourseWithRelation(courseEntity, CourseRelation.creator); + assertEquals(ApiRoutes.COURSE_BASE_PATH + "/" + courseEntity.getId(), result.url()); + assertEquals(CourseRelation.creator, result.relation()); + assertEquals(courseEntity.getName(), result.name()); + assertEquals(courseEntity.getId(), result.courseId()); + assertEquals(courseEntity.getArchivedAt().toInstant(), result.archivedAt().toInstant()); + assertEquals(userCount, result.memberCount()); + assertEquals(courseEntity.getCreatedAt().toInstant(), result.createdAt().toInstant()); + assertEquals(courseEntity.getCourseYear(), result.year()); + + } + + @Test + public void testGroupFeedbackEntityToJson() { + GroupFeedbackJson result = entityToJsonConverter.groupFeedbackEntityToJson(groupFeedbackEntity); + assertEquals(groupFeedbackEntity.getScore(), result.getScore()); + assertEquals(groupFeedbackEntity.getFeedback(), result.getFeedback()); + assertEquals(groupFeedbackEntity.getGroupId(), result.getGroupId()); + assertEquals(groupFeedbackEntity.getProjectId(), result.getProjectId()); + } + + @Test + public void testGroupFeedbackEntityToJsonWithProjec() { + doReturn(groupFeedbackJson).when(entityToJsonConverter).groupFeedbackEntityToJson(groupFeedbackEntity); + GroupFeedbackJsonWithProject result = entityToJsonConverter.groupFeedbackEntityToJsonWithProject(groupFeedbackEntity, projectEntity); + assertEquals(projectEntity.getName(), result.getProjectName()); + assertEquals(ApiRoutes.PROJECT_BASE_PATH + "/" + projectEntity.getId(), result.getProjectUrl()); + assertEquals(projectEntity.getId(), result.getProjectId()); + assertEquals(groupFeedbackJson, result.getGroupFeedback()); + assertEquals(projectEntity.getMaxScore().intValue(), result.getMaxScore()); + + /* No feedback */ + result = entityToJsonConverter.groupFeedbackEntityToJsonWithProject(null, projectEntity); + assertNull(result.getGroupFeedback()); + } + + @Test + public void testProjectEntityToProjectResponseJsonWithStatus() { + submissionEntity.setDockerAccepted(true); + submissionEntity.setStructureAccepted(true); + when(groupRepository.groupIdByProjectAndUser(projectEntity.getId(), userEntity.getId())).thenReturn(groupEntity.getId()); + when(submissionRepository.findLatestsSubmissionIdsByProjectAndGroupId(projectEntity.getId(), groupEntity.getId())).thenReturn(Optional.of(submissionEntity)); + + doReturn(projectResponseJson).when(entityToJsonConverter).projectEntityToProjectResponseJson(projectEntity, courseEntity, userEntity); + ProjectResponseJsonWithStatus result = entityToJsonConverter.projectEntityToProjectResponseJsonWithStatus(projectEntity, courseEntity, userEntity); + assertEquals(projectResponseJson, result.project()); + assertEquals(ProjectStatus.correct.toString(), result.status()); + + /* Check different statuses */ + + submissionEntity.setDockerAccepted(false); + result = entityToJsonConverter.projectEntityToProjectResponseJsonWithStatus(projectEntity, courseEntity, userEntity); + assertEquals(ProjectStatus.incorrect.toString(), result.status()); + + submissionEntity.setDockerAccepted(true); + submissionEntity.setStructureAccepted(false); + result = entityToJsonConverter.projectEntityToProjectResponseJsonWithStatus(projectEntity, courseEntity, userEntity); + assertEquals(ProjectStatus.incorrect.toString(), result.status()); + + when(submissionRepository.findLatestsSubmissionIdsByProjectAndGroupId(projectEntity.getId(), groupEntity.getId())).thenReturn(Optional.empty()); + result = entityToJsonConverter.projectEntityToProjectResponseJsonWithStatus(projectEntity, courseEntity, userEntity); + assertEquals(ProjectStatus.not_started.toString(), result.status()); + + /* User not in group yet */ + when(groupRepository.groupIdByProjectAndUser(projectEntity.getId(), userEntity.getId())).thenReturn(null); + result = entityToJsonConverter.projectEntityToProjectResponseJsonWithStatus(projectEntity, courseEntity, userEntity); + assertEquals(ProjectStatus.no_group.toString(), result.status()); } @Test public void testProjectEntityToProjectResponseJson() { - when(projectRepository.findGroupIdsByProjectId(anyLong())).thenReturn( - Collections.singletonList(1L)); - when(submissionRepository.findLatestsSubmissionIdsByProjectAndGroupId(anyLong(), anyLong())) - .thenReturn(Optional.of(submissionEntity)); - when(courseUserRepository.findById(any())).thenReturn( - Optional.of(new CourseUserEntity(1L, 1L, CourseRelation.enrolled))); - when(groupRepository.groupIdByProjectAndUser(anyLong(), anyLong())).thenReturn(1L); - ProjectResponseJson result = entityToJsonConverter.projectEntityToProjectResponseJson( - projectEntity, courseEntity, userEntity); + GroupEntity secondGroup = new GroupEntity("secondGroup", groupClusterEntity.getId()); + SubmissionEntity secondSubmission = new SubmissionEntity(22, 232, 90L, OffsetDateTime.MIN, true, true); + CourseUserEntity courseUser = new CourseUserEntity(projectEntity.getCourseId(), userEntity.getId(), CourseRelation.creator); + + when(projectRepository.findGroupIdsByProjectId(projectEntity.getId())).thenReturn(List.of(groupEntity.getId(), secondGroup.getId())); + when(submissionRepository.findLatestsSubmissionIdsByProjectAndGroupId(projectEntity.getId(), groupEntity.getId())).thenReturn(Optional.of(submissionEntity)); + when(submissionRepository.findLatestsSubmissionIdsByProjectAndGroupId(projectEntity.getId(), secondGroup.getId())).thenReturn(Optional.of(secondSubmission)); + when(courseUserRepository.findById(argThat( + id -> id.getCourseId() == projectEntity.getCourseId() && id.getUserId() == userEntity.getId() + ))).thenReturn(Optional.of(courseUser)); + + when(groupRepository.groupIdByProjectAndUser(projectEntity.getId(), userEntity.getId())).thenReturn(null); + when(clusterUtil.isIndividualCluster(projectEntity.getGroupClusterId())).thenReturn(false); + + doReturn(courseJson).when(entityToJsonConverter).courseEntityToCourseReference(courseEntity); + + ProjectResponseJson result = entityToJsonConverter.projectEntityToProjectResponseJson(projectEntity, courseEntity, userEntity); + assertEquals(courseJson, result.course()); + assertEquals(projectEntity.getDeadline(), result.deadline()); + assertEquals(projectEntity.getDescription(), result.description()); assertEquals(projectEntity.getId(), result.projectId()); assertEquals(projectEntity.getName(), result.name()); + assertEquals(ApiRoutes.PROJECT_BASE_PATH + "/" + projectEntity.getId() + "/submissions", result.submissionUrl()); + assertEquals(ApiRoutes.TEST_BASE_PATH + "/" + projectEntity.getTestId(), result.testUrl()); + assertEquals(projectEntity.getMaxScore(), result.maxScore()); + assertEquals(projectEntity.isVisible(), result.visible()); + assertEquals(2, result.progress().completed()); + assertEquals(2, result.progress().total()); + assertNull(result.groupId()); // User is a creator/course_admin -> no group + assertEquals(groupClusterEntity.getId(), result.clusterId()); + + /* TestId is null */ + projectEntity.setTestId(null); + result = entityToJsonConverter.projectEntityToProjectResponseJson(projectEntity, courseEntity, userEntity); + assertNull(result.testUrl()); + + /* Individual cluster */ + when(clusterUtil.isIndividualCluster(projectEntity.getGroupClusterId())).thenReturn(true); + result = entityToJsonConverter.projectEntityToProjectResponseJson(projectEntity, courseEntity, userEntity); + assertNull(result.clusterId()); + + /* User is enrolled and in group */ + courseUser.setRelation(CourseRelation.enrolled); + when(groupRepository.groupIdByProjectAndUser(projectEntity.getId(), userEntity.getId())).thenReturn(groupEntity.getId()); + result = entityToJsonConverter.projectEntityToProjectResponseJson(projectEntity, courseEntity, userEntity); + assertEquals(ApiRoutes.PROJECT_BASE_PATH + "/" + projectEntity.getId() + "/submissions/" + groupEntity.getId(), result.submissionUrl()); + assertEquals(groupEntity.getId(), result.groupId()); + + /* User is enrolled but not in group */ + when(groupRepository.groupIdByProjectAndUser(projectEntity.getId(), userEntity.getId())).thenReturn(null); + result = entityToJsonConverter.projectEntityToProjectResponseJson(projectEntity, courseEntity, userEntity); + assertNull(result.submissionUrl()); + + /* One submission is not correct */ + secondSubmission.setDockerAccepted(false); + result = entityToJsonConverter.projectEntityToProjectResponseJson(projectEntity, courseEntity, userEntity); + assertEquals(1, result.progress().completed()); + assertEquals(2, result.progress().total()); + + secondSubmission.setDockerAccepted(true); + secondSubmission.setStructureAccepted(false); + result = entityToJsonConverter.projectEntityToProjectResponseJson(projectEntity, courseEntity, userEntity); + assertEquals(1, result.progress().completed()); + assertEquals(2, result.progress().total()); + + /* One group didn't make a submission yet */ + when(submissionRepository.findLatestsSubmissionIdsByProjectAndGroupId(projectEntity.getId(), secondGroup.getId())).thenReturn(Optional.empty()); + result = entityToJsonConverter.projectEntityToProjectResponseJson(projectEntity, courseEntity, userEntity); + assertEquals(1, result.progress().completed()); + assertEquals(2, result.progress().total()); + + /* Error while getting courseUser */ + reset(courseUserRepository); + when(courseUserRepository.findById(argThat( + id -> id.getCourseId() == projectEntity.getCourseId() && id.getUserId() == userEntity.getId() + ))).thenReturn(Optional.empty()); + assertThrows(RuntimeException.class, () -> entityToJsonConverter.projectEntityToProjectResponseJson(projectEntity, courseEntity, userEntity)); + } + + @Test + public void testCourseEntityToCourseReference() { + CourseReferenceJson result = entityToJsonConverter.courseEntityToCourseReference(courseEntity); + assertEquals(courseEntity.getName(), result.getName()); + assertEquals(ApiRoutes.COURSE_BASE_PATH + "/" + courseEntity.getId(), result.getUrl()); + assertEquals(courseEntity.getId(), result.getCourseId()); + assertNull(result.getArchivedAt()); } @Test public void testGetSubmissionJson() { + submissionEntity.setDockerTestState(DockerTestState.running); + submissionEntity.setSubmissionTime(OffsetDateTime.now()); + submissionEntity.setStructureAccepted(true); + submissionEntity.setStructureFeedback("feedback"); SubmissionJson result = entityToJsonConverter.getSubmissionJson(submissionEntity); assertEquals(submissionEntity.getId(), result.getSubmissionId()); + assertEquals(ApiRoutes.PROJECT_BASE_PATH + "/" + submissionEntity.getProjectId(), result.getProjectUrl()); + assertEquals(ApiRoutes.GROUP_BASE_PATH + "/" + submissionEntity.getGroupId(), result.getGroupUrl()); assertEquals(submissionEntity.getProjectId(), result.getProjectId()); + assertEquals(submissionEntity.getGroupId(), result.getGroupId()); + assertEquals(ApiRoutes.SUBMISSION_BASE_PATH + "/" + submissionEntity.getId() + "/file", result.getFileUrl()); + assertTrue(result.getStructureAccepted()); + assertEquals(submissionEntity.getSubmissionTime(), result.getSubmissionTime()); + assertEquals(submissionEntity.getStructureFeedback(), result.getStructureFeedback()); + assertNull(result.getDockerFeedback()); + assertEquals(DockerTestState.running.toString(), result.getDockerStatus()); + assertEquals(ApiRoutes.SUBMISSION_BASE_PATH + "/" + submissionEntity.getId() + "/artifacts", result.getArtifactUrl()); + + /* Docker finished running */ + submissionEntity.setDockerTestState(DockerTestState.finished); + /* No docker test */ + submissionEntity.setDockerType(DockerTestType.NONE); + result = entityToJsonConverter.getSubmissionJson(submissionEntity); + assertEquals(DockerTestState.finished.toString(), result.getDockerStatus()); + assertEquals(DockerTestType.NONE, result.getDockerFeedback().type()); + + /* Simple docker test */ + submissionEntity.setDockerFeedback("dockerFeedback - simple"); + submissionEntity.setDockerAccepted(true); + submissionEntity.setDockerType(DockerTestType.SIMPLE); + result = entityToJsonConverter.getSubmissionJson(submissionEntity); + assertEquals(DockerTestType.SIMPLE, result.getDockerFeedback().type()); + assertEquals(submissionEntity.getDockerFeedback(), result.getDockerFeedback().feedback()); + assertTrue(result.getDockerFeedback().allowed()); + + /* Template docker test */ + submissionEntity.setDockerFeedback("dockerFeedback - template"); + submissionEntity.setDockerAccepted(false); + submissionEntity.setDockerType(DockerTestType.TEMPLATE); + result = entityToJsonConverter.getSubmissionJson(submissionEntity); + assertEquals(DockerTestType.TEMPLATE, result.getDockerFeedback().type()); + assertEquals(submissionEntity.getDockerFeedback(), result.getDockerFeedback().feedback()); + assertFalse(result.getDockerFeedback().allowed()); + + /* Docker aborted */ + submissionEntity.setDockerTestState(DockerTestState.aborted); + result = entityToJsonConverter.getSubmissionJson(submissionEntity); + assertEquals(DockerTestState.aborted.toString(), result.getDockerStatus()); + assertEquals(DockerTestType.TEMPLATE, result.getDockerFeedback().type()); + assertEquals(submissionEntity.getDockerFeedback(), result.getDockerFeedback().feedback()); + assertFalse(result.getDockerFeedback().allowed()); } @Test public void testTestEntityToTestJson() { - TestJson result = entityToJsonConverter.testEntityToTestJson(testEntity, 1L); + TestJson result = entityToJsonConverter.testEntityToTestJson(testEntity, projectEntity.getId()); + assertEquals(ApiRoutes.PROJECT_BASE_PATH + "/" + projectEntity.getId(), result.getProjectUrl()); assertEquals(testEntity.getDockerImage(), result.getDockerImage()); + assertEquals(testEntity.getDockerTestScript(), result.getDockerScript()); + assertEquals(testEntity.getDockerTestTemplate(), result.getDockerTemplate()); + assertEquals(testEntity.getStructureTemplate(), result.getStructureTest()); } + + } \ No newline at end of file diff --git a/backend/app/src/test/java/com/ugent/pidgeon/util/FileHandlerTest.java b/backend/app/src/test/java/com/ugent/pidgeon/util/FileHandlerTest.java new file mode 100644 index 00000000..89efd857 --- /dev/null +++ b/backend/app/src/test/java/com/ugent/pidgeon/util/FileHandlerTest.java @@ -0,0 +1,383 @@ +package com.ugent.pidgeon.util; + + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertInstanceOf; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import java.io.File; +import java.io.FileOutputStream; +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.List; +import java.util.stream.Stream; +import java.util.zip.ZipEntry; +import java.util.zip.ZipFile; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.core.io.FileSystemResource; +import org.springframework.core.io.Resource; +import org.springframework.mock.web.MockMultipartFile; + +@ExtendWith(MockitoExtension.class) +public class FileHandlerTest { + + + static Path tempDir; + + private MockMultipartFile file; + private final String basicZipFileName = "Testfile.zip"; + private byte[] fileContent; + private final Path testFilePath = Path.of("src/test/test-cases/FilehandlerTestFiles"); + + @AfterEach + public void cleanup() throws Exception { + // Cleanup the files + if (Files.exists(tempDir)) { + try (Stream paths = Files.walk(tempDir)) { + paths.map(Path::toFile) + .forEach(File::delete); + } + } + + } + + + @BeforeEach + public void setUp() throws IOException { + tempDir = Files.createTempDirectory("test"); + fileContent = Files.readAllBytes(testFilePath.resolve(basicZipFileName)); + file = new MockMultipartFile( + basicZipFileName, fileContent + ); + } + + @Test + public void testSaveSubmission() throws Exception { + File savedFile = Filehandler.saveSubmission(tempDir, file); + + assertTrue(savedFile.exists()); + assertEquals(Filehandler.SUBMISSION_FILENAME, savedFile.getName()); + assertEquals(fileContent.length, savedFile.length()); + byte[] savedFileContent = Files.readAllBytes(savedFile.toPath()); + assertEquals(fileContent.length, savedFileContent.length); + } + + @Test + public void testSaveSubmission_dirDoesntExist() throws Exception { + File savedFile = Filehandler.saveSubmission(tempDir.resolve("nonexistent"), file); + + assertTrue(savedFile.exists()); + assertEquals(Filehandler.SUBMISSION_FILENAME, savedFile.getName()); + assertEquals(fileContent.length, savedFile.length()); + byte[] savedFileContent = Files.readAllBytes(savedFile.toPath()); + assertEquals(fileContent.length, savedFileContent.length); + } + + @Test + public void testSaveSubmission_errorWhileCreatingDir() throws Exception { + assertThrows(IOException.class, () -> Filehandler.saveSubmission(Path.of(""), file)); + } + + @Test + public void testSaveSubmission_notAZipFile() { + MockMultipartFile notAZipFile = new MockMultipartFile( + "notAZipFile.txt", "This is not a zip file".getBytes() + ); + assertThrows(IOException.class, () -> Filehandler.saveSubmission(tempDir, notAZipFile)); + } + + @Test + public void testSaveSubmission_fileEmpty() { + MockMultipartFile emptyFile = new MockMultipartFile( + "emptyFile.txt", new byte[0] + ); + assertThrows(IOException.class, () -> Filehandler.saveSubmission(tempDir, emptyFile)); + } + + @Test + public void testSaveSubmission_fileNull() { + assertThrows(IOException.class, () -> Filehandler.saveSubmission(tempDir, null)); + } + + @Test + public void testDeleteLocation() throws Exception { + Path testDir = Files.createTempDirectory("test"); + Path tempFile = Files.createTempFile(testDir, "test", ".txt"); + Filehandler.deleteLocation(new File(tempFile.toString())); + assertFalse(Files.exists(testDir)); + } + + @Test + public void testDeleteLocation_parentDirNotEmpty() throws Exception { + Path testDir = Files.createTempDirectory("test"); + Path tempFile = Files.createTempFile(testDir, "test", ".txt"); + Files.createTempFile(testDir, "test2", ".txt"); + Filehandler.deleteLocation(new File(tempFile.toString())); + assertTrue(Files.exists(testDir)); + } + + @Test + public void testDeleteLocation_locationDoesntExist() throws Exception { + Filehandler.deleteLocation(new File("nonexistent")); + } + + @Test + public void testDeleteLocation_errorWhileDeleting() { + // Create a mock File object + File mockDir = mock(File.class); + + when(mockDir.exists()).thenReturn(true); + when(mockDir.delete()).thenReturn(false); + + assertThrows(IOException.class, () -> Filehandler.deleteLocation(mockDir)); + + verify(mockDir).exists(); + verify(mockDir).delete(); + } + + @Test + public void testDeleteLocation_errorWhileDeletingParentDir() { + File mockDir = mock(File.class); + File mockParentDir = mock(File.class); + File[] mockedFiles = new File[0]; + + when(mockDir.exists()).thenReturn(true); + when(mockDir.delete()).thenReturn(true); + when(mockDir.getParentFile()).thenReturn(mockParentDir); + when(mockParentDir.isDirectory()).thenReturn(true); + when(mockParentDir.listFiles()).thenReturn(mockedFiles); + when(mockParentDir.delete()).thenReturn(false); + + assertThrows(IOException.class, () -> Filehandler.deleteLocation(mockDir)); + + verify(mockDir).exists(); + verify(mockDir).delete(); + verify(mockDir).getParentFile(); + verify(mockParentDir).listFiles(); + verify(mockParentDir).delete(); + } + + @Test + public void testDeleteLocation_filesAreNotEmpty() throws IOException { + File mockDir = mock(File.class); + File mockParentDir = mock(File.class); + File[] mockedFiles = new File[1]; + mockedFiles[0] = mock(File.class); + + when(mockDir.exists()).thenReturn(true); + when(mockDir.delete()).thenReturn(true); + when(mockDir.getParentFile()).thenReturn(mockParentDir); + when(mockParentDir.isDirectory()).thenReturn(true); + when(mockParentDir.listFiles()).thenReturn(mockedFiles); + + Filehandler.deleteLocation(mockDir); + + verify(mockDir).exists(); + verify(mockDir).delete(); + verify(mockDir).getParentFile(); + verify(mockParentDir).listFiles(); + } + + @Test + public void testDeleteLocation_filesAreNull() throws IOException { + File mockDir = mock(File.class); + File mockParentDir = mock(File.class); + + when(mockDir.exists()).thenReturn(true); + when(mockDir.delete()).thenReturn(true); + when(mockDir.getParentFile()).thenReturn(mockParentDir); + when(mockParentDir.isDirectory()).thenReturn(true); + when(mockParentDir.listFiles()).thenReturn(null); + + Filehandler.deleteLocation(mockDir); + + verify(mockDir).exists(); + verify(mockDir).delete(); + verify(mockDir).getParentFile(); + verify(mockParentDir).listFiles(); + } + + @Test + public void testDeleteLocation_parentDirIsNotADir() throws IOException { + File mockDir = mock(File.class); + File mockParentDir = mock(File.class); + + when(mockDir.exists()).thenReturn(true); + when(mockDir.delete()).thenReturn(true); + when(mockDir.getParentFile()).thenReturn(mockParentDir); + when(mockParentDir.isDirectory()).thenReturn(false); + + Filehandler.deleteLocation(mockDir); + + verify(mockDir).exists(); + verify(mockDir).delete(); + verify(mockDir).getParentFile(); + verify(mockParentDir).isDirectory(); + } + + @Test + public void testDeleteLocation_parentDirIsNull() throws IOException { + File mockDir = mock(File.class); + + when(mockDir.exists()).thenReturn(true); + when(mockDir.delete()).thenReturn(true); + when(mockDir.getParentFile()).thenReturn(null); + + Filehandler.deleteLocation(mockDir); + + verify(mockDir).exists(); + verify(mockDir).delete(); + verify(mockDir).getParentFile(); + } + + @Test + public void testGetSubmissionPath() { + Path submissionPath = Filehandler.getSubmissionPath(1, 2, 3); + assertEquals(Path.of(Filehandler.BASEPATH, "projects", "1", "2", "3"), submissionPath); + } + + @Test + public void testGetSubmissionArtifactPath() { + Path submissionArtifactPath = Filehandler.getSubmissionArtifactPath(1, 2, 3); + assertEquals(Path.of(Filehandler.BASEPATH, "projects", "1", "2", "3", "artifacts.zip"), submissionArtifactPath); + } + + @Test + public void testGetFileAsResource_FileExists() { + try { + File tempFile = Files.createTempFile("testFile", ".txt").toFile(); + + Resource resource = Filehandler.getFileAsResource(tempFile.toPath()); + + assertNotNull(resource); + assertInstanceOf(FileSystemResource.class, resource); + assertEquals(tempFile.getAbsolutePath(), ((FileSystemResource) resource).getFile().getAbsolutePath()); + + assertTrue(tempFile.delete()); + } catch (IOException e) { + e.printStackTrace(); + } + } + + @Test + public void testGetFileAsResource_FileDoesNotExist() { + Resource resource = Filehandler.getFileAsResource(Path.of("nonexistent")); + + assertNull(resource); + } + + @Test + public void testCopyFilesAsZip() throws IOException { + List files = new ArrayList<>(); + File tempFile1 = Files.createTempFile("tempFile1", ".txt").toFile(); + File tempFile2 = Files.createTempFile("tempFile2", ".txt").toFile(); + + try { + files.add(tempFile1); + files.add(tempFile2); + + File zipFile = tempDir.resolve("files.zip").toFile(); + Filehandler.copyFilesAsZip(files, zipFile.toPath()); + + assertTrue(zipFile.exists()); + + try (ZipFile zip = new ZipFile(zipFile)) { + for (File file : files) { + String entryName = file.getName(); + ZipEntry zipEntry = zip.getEntry(entryName); + assertNotNull(zipEntry, "File " + entryName + " not found in the zip file."); + } + } + + + } catch (IOException e) { + e.printStackTrace(); + } + } + + @Test + public void testCopyFilesAsZip_zipFileAlreadyExist() throws IOException { + List files = new ArrayList<>(); + File tempFile1 = Files.createTempFile("tempFile1", ".txt").toFile(); + File tempFile2 = Files.createTempFile("tempFile2", ".txt").toFile(); + File zipFile = Files.createTempFile(tempDir, "files", ".zip").toFile(); + + try { + files.add(tempFile1); + files.add(tempFile2); + + assertTrue(zipFile.exists()); + + Filehandler.copyFilesAsZip(files, zipFile.toPath()); + + assertTrue(zipFile.exists()); + + try (ZipFile zip = new ZipFile(zipFile)) { + for (File file : files) { + String entryName = file.getName(); + ZipEntry zipEntry = zip.getEntry(entryName); + assertNotNull(zipEntry, "File " + entryName + " not found in the zip file."); + } + } + } catch (IOException e) { + e.printStackTrace(); + } + } + + private static File createTempFileWithContent(String prefix, String suffix, int fileSizeInBytes) throws IOException { + Path tempFilePath = Files.createTempFile(prefix, suffix); + + try (FileOutputStream outputStream = new FileOutputStream(tempFilePath.toFile())) { + // Write data to the file until it reaches the desired size + for (int i = 0; i < fileSizeInBytes; i++) { + outputStream.write('A'); // Write a byte to the file (in this case, the letter 'A') + } + } + + return tempFilePath.toFile(); + } + + @Test + public void testCopyFilesAsZip_zipFileAlreadyExistNonWriteable() throws IOException { + List files = new ArrayList<>(); + File tempFile1 = createTempFileWithContent("tempFile1", ".txt", 4095); + File tempFile2 = Files.createTempFile("tempFile2", ".txt").toFile(); + File zipFile = Files.createTempFile(tempDir, "files", ".zip").toFile(); + zipFile.setWritable(false); + + try { + files.add(tempFile1); + files.add(tempFile2); + + assertTrue(zipFile.exists()); + + Filehandler.copyFilesAsZip(files, zipFile.toPath()); + + assertTrue(zipFile.exists()); + + try (ZipFile zip = new ZipFile(zipFile)) { + for (File file : files) { + String entryName = file.getName(); + ZipEntry zipEntry = zip.getEntry(entryName); + assertNotNull(zipEntry, "File " + entryName + " not found in the zip file."); + } + } + } catch (IOException e) { + e.printStackTrace(); + } + } + +} diff --git a/backend/app/src/test/java/com/ugent/pidgeon/util/FileUtilTest.java b/backend/app/src/test/java/com/ugent/pidgeon/util/FileUtilTest.java index 7480658a..426ac8d8 100644 --- a/backend/app/src/test/java/com/ugent/pidgeon/util/FileUtilTest.java +++ b/backend/app/src/test/java/com/ugent/pidgeon/util/FileUtilTest.java @@ -2,12 +2,17 @@ import com.ugent.pidgeon.postgre.models.FileEntity; import com.ugent.pidgeon.postgre.repository.FileRepository; +import java.util.Optional; +import java.util.logging.FileHandler; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.InjectMocks; import org.mockito.Mock; +import org.mockito.MockedStatic; import org.mockito.Mockito; import org.mockito.MockitoAnnotations; +import org.mockito.junit.jupiter.MockitoExtension; import org.springframework.http.HttpStatus; import java.io.IOException; @@ -16,8 +21,12 @@ import static org.junit.jupiter.api.Assertions.assertEquals; import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.argThat; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; +@ExtendWith(MockitoExtension.class) public class FileUtilTest { @Mock @@ -26,28 +35,43 @@ public class FileUtilTest { @InjectMocks private FileUtil fileUtil; + private FileEntity fileEntity; + @BeforeEach public void setUp() { - MockitoAnnotations.openMocks(this); + fileEntity = new FileEntity("testName", "testPath", 5L); + fileEntity.setId(2L); } - @Test - public void testSaveFileEntity() throws IOException { - Path filePath = Paths.get("testPath"); - long projectId = 1L; - long userId = 1L; - FileEntity fileEntity = new FileEntity(filePath.getFileName().toString(), filePath.toString(), userId); - when(fileRepository.save(any(FileEntity.class))).thenReturn(fileEntity); - FileEntity result = fileUtil.saveFileEntity(filePath, projectId, userId); - assertEquals(fileEntity, result); - } @Test public void testDeleteFileById() { - long fileId = 1L; - FileEntity fileEntity = new FileEntity("testName", "testPath", 1L); - when(fileRepository.findById(fileId)).thenReturn(java.util.Optional.of(fileEntity)); - CheckResult result = fileUtil.deleteFileById(fileId); - assertEquals(HttpStatus.OK, result.getStatus()); + when(fileRepository.findById(fileEntity.getId())).thenReturn(Optional.of(fileEntity)); + try (MockedStatic mockedFileHandler = Mockito.mockStatic(Filehandler.class)) { + mockedFileHandler.when(() -> Filehandler.deleteLocation(argThat( + path -> path.toString().equals(fileEntity.getPath())) + )).thenAnswer(invocation -> { + // Do nothing + return null; + }); + CheckResult result = fileUtil.deleteFileById(fileEntity.getId()); + assertEquals(HttpStatus.OK, result.getStatus()); + verify(fileRepository, times(1)).delete(fileEntity); + + // Error when file is being deleted + mockedFileHandler.when(() -> Filehandler.deleteLocation(argThat( + path -> path.toString().equals(fileEntity.getPath())) + )).thenThrow(new IOException()); + result = fileUtil.deleteFileById(fileEntity.getId()); + assertEquals(HttpStatus.INTERNAL_SERVER_ERROR, result.getStatus()); + + // File not found + when(fileRepository.findById(fileEntity.getId())).thenReturn(Optional.empty()); + result = fileUtil.deleteFileById(fileEntity.getId()); + assertEquals(HttpStatus.NOT_FOUND, result.getStatus()); + + } catch (Exception e) { + e.printStackTrace(); + } } } \ No newline at end of file diff --git a/backend/app/src/test/java/com/ugent/pidgeon/util/GroupFeedbackUtilTest.java b/backend/app/src/test/java/com/ugent/pidgeon/util/GroupFeedbackUtilTest.java new file mode 100644 index 00000000..6eb7581c --- /dev/null +++ b/backend/app/src/test/java/com/ugent/pidgeon/util/GroupFeedbackUtilTest.java @@ -0,0 +1,224 @@ +package com.ugent.pidgeon.util; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.mockito.ArgumentMatchers.argThat; +import static org.mockito.Mockito.doReturn; +import static org.mockito.Mockito.reset; +import static org.mockito.Mockito.when; + +import com.ugent.pidgeon.model.json.UpdateGroupScoreRequest; +import com.ugent.pidgeon.postgre.models.GroupEntity; +import com.ugent.pidgeon.postgre.models.GroupFeedbackEntity; +import com.ugent.pidgeon.postgre.models.ProjectEntity; +import com.ugent.pidgeon.postgre.models.UserEntity; +import com.ugent.pidgeon.postgre.models.types.UserRole; +import com.ugent.pidgeon.postgre.repository.GroupFeedbackRepository; +import java.time.OffsetDateTime; +import java.util.Optional; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.Spy; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.http.HttpMethod; +import org.springframework.http.HttpStatus; + +@ExtendWith(MockitoExtension.class) +public class GroupFeedbackUtilTest { + @Mock + private ProjectUtil projectUtil; + @Mock + private GroupUtil groupUtil; + @Mock + private GroupFeedbackRepository groupFeedbackRepository; + + @Spy + @InjectMocks + private GroupFeedbackUtil groupFeedbackUtil; + + private GroupFeedbackEntity groupFeedbackEntity; + private UserEntity mockUser; + private ProjectEntity projectEntity; + private GroupEntity groupEntity; + + @BeforeEach + public void setup() { + groupFeedbackEntity = new GroupFeedbackEntity( + 5L, + 10L, + 10.0f, + "Good job!" + ); + mockUser = new UserEntity("name", "surname", "email", UserRole.student, "azureid"); + mockUser.setId(2L); + projectEntity = new ProjectEntity( + 13L, + "projectName", + "projectDescription", + 21L, + 38L, + true, + 34, + OffsetDateTime.now() + ); + projectEntity.setId(groupFeedbackEntity.getProjectId()); + groupEntity = new GroupEntity("test", projectEntity.getGroupClusterId()); + groupEntity.setId(groupFeedbackEntity.getGroupId()); + } + + @Test + public void testGetGroupFeedbackIfExists() { + /* GroupFeedback found */ + when(groupFeedbackRepository.findById(argThat( + id -> id.getGroupId() == groupFeedbackEntity.getGroupId() && id.getProjectId() == groupFeedbackEntity.getProjectId() + ))).thenReturn(java.util.Optional.of(groupFeedbackEntity)); + CheckResult result = groupFeedbackUtil.getGroupFeedbackIfExists(groupFeedbackEntity.getGroupId(), groupFeedbackEntity.getProjectId()); + assertEquals(HttpStatus.OK, result.getStatus()); + assertEquals(groupFeedbackEntity, result.getData()); + + /* GroupFeedback not found */ + reset(groupFeedbackRepository); + when(groupFeedbackRepository.findById(argThat( + id -> id.getGroupId() == groupFeedbackEntity.getGroupId() && id.getProjectId() == groupFeedbackEntity.getProjectId() + ))).thenReturn(java.util.Optional.empty()); + result = groupFeedbackUtil.getGroupFeedbackIfExists(groupFeedbackEntity.getGroupId(), groupFeedbackEntity.getProjectId()); + assertEquals(HttpStatus.NOT_FOUND, result.getStatus()); + assertNull(result.getData()); + } + + @Test + public void testCheckGroupFeedback() { + /* All schecks succeed */ + when(projectUtil.getProjectIfExists(groupFeedbackEntity.getProjectId())).thenReturn(new CheckResult<>(HttpStatus.OK, "", projectEntity)); + when(groupUtil.getGroupIfExists(groupFeedbackEntity.getGroupId())).thenReturn(new CheckResult<>(HttpStatus.OK, "", groupEntity)); + + CheckResult result = groupFeedbackUtil.checkGroupFeedback(groupFeedbackEntity.getGroupId(), groupFeedbackEntity.getProjectId()); + assertEquals(HttpStatus.OK, result.getStatus()); + + /* Group doesn't belong to project */ + groupEntity.setClusterId(0); + result = groupFeedbackUtil.checkGroupFeedback(groupFeedbackEntity.getGroupId(), groupFeedbackEntity.getProjectId()); + assertEquals(HttpStatus.FORBIDDEN, result.getStatus()); + + /* Group get fails */ + when(groupUtil.getGroupIfExists(groupFeedbackEntity.getGroupId())).thenReturn(new CheckResult<>(HttpStatus.I_AM_A_TEAPOT, "Group not found", null)); + result = groupFeedbackUtil.checkGroupFeedback(groupFeedbackEntity.getGroupId(), groupFeedbackEntity.getProjectId()); + assertEquals(HttpStatus.I_AM_A_TEAPOT, result.getStatus()); + + /* Project get fails */ + when(projectUtil.getProjectIfExists(groupFeedbackEntity.getProjectId())).thenReturn(new CheckResult<>(HttpStatus.BAD_REQUEST, "Project not found", null)); + result = groupFeedbackUtil.checkGroupFeedback(groupFeedbackEntity.getGroupId(), groupFeedbackEntity.getProjectId()); + assertEquals(HttpStatus.BAD_REQUEST, result.getStatus()); + } + + @Test + public void testCheckGroupFeedbackUpdate() { + /* All checks succeed: patch/put */ + doReturn(new CheckResult<>(HttpStatus.OK, "", null)).when(groupFeedbackUtil).checkGroupFeedback(groupFeedbackEntity.getGroupId(), groupFeedbackEntity.getProjectId()); + when(groupUtil.isAdminOfGroup(groupFeedbackEntity.getGroupId(), mockUser)).thenReturn(new CheckResult<>(HttpStatus.OK, "", null)); + when(groupFeedbackRepository.findById(argThat( + id -> id.getGroupId() == groupFeedbackEntity.getGroupId() && id.getProjectId() == groupFeedbackEntity.getProjectId() + ))).thenReturn(Optional.of(groupFeedbackEntity)); + + CheckResult result = groupFeedbackUtil.checkGroupFeedbackUpdate(groupFeedbackEntity.getGroupId(), groupFeedbackEntity.getProjectId(), mockUser, HttpMethod.PATCH); + assertEquals(HttpStatus.OK, result.getStatus()); + assertEquals(groupFeedbackEntity, result.getData()); + result = groupFeedbackUtil.checkGroupFeedbackUpdate(groupFeedbackEntity.getGroupId(), groupFeedbackEntity.getProjectId(), mockUser, HttpMethod.PUT); + assertEquals(HttpStatus.OK, result.getStatus()); + assertEquals(groupFeedbackEntity, result.getData()); + + /* Group already exists: post */ + result = groupFeedbackUtil.checkGroupFeedbackUpdate(groupFeedbackEntity.getGroupId(), groupFeedbackEntity.getProjectId(), mockUser, HttpMethod.POST); + assertEquals(HttpStatus.CONFLICT, result.getStatus()); + + /* All checks succeed: post */ + reset(groupFeedbackRepository); + when(groupFeedbackRepository.findById(argThat( + id -> id.getGroupId() == groupFeedbackEntity.getGroupId() && id.getProjectId() == groupFeedbackEntity.getProjectId() + ))).thenReturn(Optional.empty()); + + result = groupFeedbackUtil.checkGroupFeedbackUpdate(groupFeedbackEntity.getGroupId(), groupFeedbackEntity.getProjectId(), mockUser, HttpMethod.POST); + assertEquals(HttpStatus.OK, result.getStatus()); + assertNull(result.getData()); + + /* Group doesn't exist: patch/put */ + result = groupFeedbackUtil.checkGroupFeedbackUpdate(groupFeedbackEntity.getGroupId(), groupFeedbackEntity.getProjectId(), mockUser, HttpMethod.PATCH); + assertEquals(HttpStatus.NOT_FOUND, result.getStatus()); + + result = groupFeedbackUtil.checkGroupFeedbackUpdate(groupFeedbackEntity.getGroupId(), groupFeedbackEntity.getProjectId(), mockUser, HttpMethod.PUT); + assertEquals(HttpStatus.NOT_FOUND, result.getStatus()); + + /* Admin check fails */ + when(groupUtil.isAdminOfGroup(groupFeedbackEntity.getGroupId(), mockUser)).thenReturn(new CheckResult<>(HttpStatus.FORBIDDEN, "Not an admin", null)); + result = groupFeedbackUtil.checkGroupFeedbackUpdate(groupFeedbackEntity.getGroupId(), groupFeedbackEntity.getProjectId(), mockUser, HttpMethod.PATCH); + assertEquals(HttpStatus.FORBIDDEN, result.getStatus()); + result = groupFeedbackUtil.checkGroupFeedbackUpdate(groupFeedbackEntity.getGroupId(), groupFeedbackEntity.getProjectId(), mockUser, HttpMethod.PUT); + assertEquals(HttpStatus.FORBIDDEN, result.getStatus()); + result = groupFeedbackUtil.checkGroupFeedbackUpdate(groupFeedbackEntity.getGroupId(), groupFeedbackEntity.getProjectId(), mockUser, HttpMethod.POST); + assertEquals(HttpStatus.FORBIDDEN, result.getStatus()); + + /* groupFeedbackCheckFails */ + doReturn(new CheckResult<>(HttpStatus.I_AM_A_TEAPOT, "Group feedback not found", null)).when(groupFeedbackUtil).checkGroupFeedback(groupFeedbackEntity.getGroupId(), groupFeedbackEntity.getProjectId()); + result = groupFeedbackUtil.checkGroupFeedbackUpdate(groupFeedbackEntity.getGroupId(), groupFeedbackEntity.getProjectId(), mockUser, HttpMethod.PATCH); + assertEquals(HttpStatus.I_AM_A_TEAPOT, result.getStatus()); + result = groupFeedbackUtil.checkGroupFeedbackUpdate(groupFeedbackEntity.getGroupId(), groupFeedbackEntity.getProjectId(), mockUser, HttpMethod.PUT); + assertEquals(HttpStatus.I_AM_A_TEAPOT, result.getStatus()); + result = groupFeedbackUtil.checkGroupFeedbackUpdate(groupFeedbackEntity.getGroupId(), groupFeedbackEntity.getProjectId(), mockUser, HttpMethod.POST); + assertEquals(HttpStatus.I_AM_A_TEAPOT, result.getStatus()); + } + + @Test + public void testCheckGroupFeedbackUpdateJson() { + /* All checks succeed */ + UpdateGroupScoreRequest updateGroupScoreRequest = new UpdateGroupScoreRequest(); + updateGroupScoreRequest.setScore(Float.valueOf(projectEntity.getMaxScore())); + updateGroupScoreRequest.setFeedback("Good job!"); + when(projectUtil.getProjectIfExists(groupFeedbackEntity.getProjectId())).thenReturn(new CheckResult<>(HttpStatus.OK, "", projectEntity)); + + CheckResult result = groupFeedbackUtil.checkGroupFeedbackUpdateJson(updateGroupScoreRequest, groupFeedbackEntity.getProjectId()); + assertEquals(HttpStatus.OK, result.getStatus()); + + /* Score is too high */ + updateGroupScoreRequest.setScore((float) (projectEntity.getMaxScore() + 1)); + result = groupFeedbackUtil.checkGroupFeedbackUpdateJson(updateGroupScoreRequest, groupFeedbackEntity.getProjectId()); + assertEquals(HttpStatus.BAD_REQUEST, result.getStatus()); + + /* Maxscore is null while score is too high */ + projectEntity.setMaxScore(null); + result = groupFeedbackUtil.checkGroupFeedbackUpdateJson(updateGroupScoreRequest, groupFeedbackEntity.getProjectId()); + assertEquals(HttpStatus.OK, result.getStatus()); + projectEntity.setMaxScore(34); + + /* Score is negative */ + updateGroupScoreRequest.setScore(-1.0f); + result = groupFeedbackUtil.checkGroupFeedbackUpdateJson(updateGroupScoreRequest, groupFeedbackEntity.getProjectId()); + assertEquals(HttpStatus.BAD_REQUEST, result.getStatus()); + + /* Score is null */ + updateGroupScoreRequest.setScore(null); + result = groupFeedbackUtil.checkGroupFeedbackUpdateJson(updateGroupScoreRequest, groupFeedbackEntity.getProjectId()); + assertEquals(HttpStatus.BAD_REQUEST, result.getStatus()); + + /* Score is null but so is maxScore */ + projectEntity.setMaxScore(null); + result = groupFeedbackUtil.checkGroupFeedbackUpdateJson(updateGroupScoreRequest, groupFeedbackEntity.getProjectId()); + assertEquals(HttpStatus.OK, result.getStatus()); + projectEntity.setMaxScore(34); + + /* Feedback is null */ + updateGroupScoreRequest.setScore(Float.valueOf(projectEntity.getMaxScore())); + updateGroupScoreRequest.setFeedback(null); + result = groupFeedbackUtil.checkGroupFeedbackUpdateJson(updateGroupScoreRequest, groupFeedbackEntity.getProjectId()); + assertEquals(HttpStatus.BAD_REQUEST, result.getStatus()); + + /* Project get fails */ + when(projectUtil.getProjectIfExists(groupFeedbackEntity.getProjectId())).thenReturn(new CheckResult<>(HttpStatus.BAD_REQUEST, "Project not found", null)); + result = groupFeedbackUtil.checkGroupFeedbackUpdateJson(updateGroupScoreRequest, groupFeedbackEntity.getProjectId()); + assertEquals(HttpStatus.BAD_REQUEST, result.getStatus()); + } + + +} diff --git a/backend/app/src/test/java/com/ugent/pidgeon/util/GroupUtilTest.java b/backend/app/src/test/java/com/ugent/pidgeon/util/GroupUtilTest.java index 4a668191..145b1cc9 100644 --- a/backend/app/src/test/java/com/ugent/pidgeon/util/GroupUtilTest.java +++ b/backend/app/src/test/java/com/ugent/pidgeon/util/GroupUtilTest.java @@ -2,6 +2,7 @@ import static org.junit.jupiter.api.Assertions.assertEquals; import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.Mockito.doReturn; import static org.mockito.Mockito.when; import com.ugent.pidgeon.postgre.models.GroupClusterEntity; @@ -18,6 +19,7 @@ import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.InjectMocks; import org.mockito.Mock; +import org.mockito.Spy; import org.mockito.junit.jupiter.MockitoExtension; import org.springframework.http.HttpStatus; @@ -35,165 +37,287 @@ public class GroupUtilTest { @Mock private UserUtil userUtil; + @Spy @InjectMocks private GroupUtil groupUtil; private GroupEntity group; - private UserEntity user; + private UserEntity mockUser; private GroupClusterEntity groupCluster; private ProjectEntity project; @BeforeEach public void setup() { - group = new GroupEntity("Groupname", 1L); - group.setId(1L); - user = new UserEntity("name", "surname", "email", UserRole.student, "azureid"); - user.setId(1L); - groupCluster = new GroupClusterEntity(1L, 5, "cluster test", 20); - groupCluster.setId(1L); - project = new ProjectEntity(1L, "name", "description", 1L, 1L, true, 20, OffsetDateTime.now()); - project.setId(1L); + group = new GroupEntity("Groupname", 12L); + group.setId(54L); + mockUser = new UserEntity("name", "surname", "email", UserRole.student, "azureid"); + mockUser.setId(10L); + groupCluster = new GroupClusterEntity(9L, 5, "cluster test", 20); + groupCluster.setId(12L); + project = new ProjectEntity(9L, "name", "description", 12L, null, true, 20, OffsetDateTime.now()); + project.setId(88L); } @Test - public void testGetGroupIfExists() throws Exception { - when(groupRepository.findById(1L)).thenReturn(Optional.of(group)); - CheckResult result = groupUtil.getGroupIfExists(1L); + public void testGetGroupIfExists() { + /* Group exists */ + when(groupRepository.findById(group.getId())).thenReturn(Optional.of(group)); + CheckResult result = groupUtil.getGroupIfExists(group.getId()); assertEquals(HttpStatus.OK, result.getStatus()); assertEquals(group, result.getData()); + /* Group does not exist */ + when(groupRepository.findById(2L)).thenReturn(Optional.empty()); result = groupUtil.getGroupIfExists(2L); assertEquals(HttpStatus.NOT_FOUND, result.getStatus()); - assertEquals("Group not found", result.getMessage()); } @Test - public void testCanGetGroup() throws Exception { - when(groupRepository.userAccessToGroup(1L, 1L)).thenReturn(true); - CheckResult result = groupUtil.canGetGroup(1L, user); + public void testCanGetGroup() { + /* User has access to group */ + when(groupRepository.userAccessToGroup(mockUser.getId(), group.getId())).thenReturn(true); + CheckResult result = groupUtil.canGetGroup(group.getId(), mockUser); assertEquals(HttpStatus.OK, result.getStatus()); - result = groupUtil.canGetGroup(2L, user); + /* User doesn't have access to group */ + when(groupRepository.userAccessToGroup(mockUser.getId(), group.getId())).thenReturn(false); + result = groupUtil.canGetGroup(group.getId(), mockUser); assertEquals(HttpStatus.FORBIDDEN, result.getStatus()); - assertEquals("User does not have access to this group", result.getMessage()); + + /* User has no acces but is admin */ + mockUser.setRole(UserRole.admin); + result = groupUtil.canGetGroup(group.getId(), mockUser); + assertEquals(HttpStatus.OK, result.getStatus()); } @Test - public void testIsAdminOfGroup() throws Exception { - when(groupRepository.isAdminOfGroup(1L, user.getId())).thenReturn(true); - CheckResult result = groupUtil.isAdminOfGroup(1L, user); + public void testIsAdminOfGroup() { + /* User is admin of group */ + when(groupRepository.isAdminOfGroup(mockUser.getId(), group.getId())).thenReturn(true); + CheckResult result = groupUtil.isAdminOfGroup(group.getId(), mockUser); assertEquals(HttpStatus.OK, result.getStatus()); - result = groupUtil.isAdminOfGroup(2L, user); + /* User is not admin of group */ + when(groupRepository.isAdminOfGroup(mockUser.getId(), group.getId())).thenReturn(false); + result = groupUtil.isAdminOfGroup(group.getId(), mockUser); assertEquals(HttpStatus.FORBIDDEN, result.getStatus()); - assertEquals("User is not an admin of this group", result.getMessage()); + + /* User is admin */ + mockUser.setRole(UserRole.admin); + result = groupUtil.isAdminOfGroup(group.getId(), mockUser); + assertEquals(HttpStatus.OK, result.getStatus()); } @Test - public void testCanUpdateGroup() throws Exception { - when(groupRepository.findById(1L)).thenReturn(Optional.of(group)); - when(groupRepository.isAdminOfGroup(1L, user.getId())).thenReturn(true); - when(clusterUtil.isIndividualCluster(1L)).thenReturn(false); - CheckResult result = groupUtil.canUpdateGroup(group.getId(), user); + public void testCanUpdateGroup() { + /* All checks succeed */ + doReturn(new CheckResult<>(HttpStatus.OK, "", group)).when(groupUtil).getGroupIfExists(group.getId()); + doReturn(new CheckResult<>(HttpStatus.OK, "", null)).when(groupUtil).isAdminOfGroup(group.getId(), mockUser); + when(clusterUtil.isIndividualCluster(group.getClusterId())).thenReturn(false); + CheckResult result = groupUtil.canUpdateGroup(group.getId(), mockUser); assertEquals(HttpStatus.OK, result.getStatus()); assertEquals(group, result.getData()); - when(clusterUtil.isIndividualCluster(1L)).thenReturn(true); - result = groupUtil.canUpdateGroup(group.getId(), user); + /* Group is individual cluster */ + when(clusterUtil.isIndividualCluster(group.getClusterId())).thenReturn(true); + result = groupUtil.canUpdateGroup(group.getId(), mockUser); assertEquals(HttpStatus.FORBIDDEN, result.getStatus()); - assertEquals("Cannot update individual group", result.getMessage()); - when(groupRepository.isAdminOfGroup(1L, user.getId())).thenReturn(false); - result = groupUtil.canUpdateGroup(group.getId(), user); + /* User is not admin of group */ + doReturn(new CheckResult<>(HttpStatus.FORBIDDEN, "User is not an admin of this group", null)).when(groupUtil).isAdminOfGroup(group.getId(), mockUser); + result = groupUtil.canUpdateGroup(group.getId(), mockUser); assertEquals(HttpStatus.FORBIDDEN, result.getStatus()); - assertEquals("User is not an admin of this group", result.getMessage()); - when(groupRepository.findById(1L)).thenReturn(Optional.empty()); - result = groupUtil.canUpdateGroup(group.getId(), user); + /* Group does not exist */ + doReturn(new CheckResult<>(HttpStatus.NOT_FOUND, "Group not found", null)).when(groupUtil).getGroupIfExists(group.getId()); + result = groupUtil.canUpdateGroup(group.getId(), mockUser); assertEquals(HttpStatus.NOT_FOUND, result.getStatus()); - assertEquals("Group not found", result.getMessage()); } @Test - public void TestCanAddUserToGroup() throws Exception { - when(groupRepository.findById(1L)).thenReturn(Optional.of(group)); - when(groupRepository.isAdminOfGroup(user.getId(), 1L)).thenReturn(true); - when(groupClusterRepository.userInGroupForCluster(anyLong(), anyLong())).thenReturn(false); - when(groupRepository.userInGroup(anyLong(), anyLong())).thenReturn(false); - when(clusterUtil.getClusterIfExists(group.getClusterId())) - .thenReturn(new CheckResult<>(HttpStatus.OK, "", groupCluster)); - when(groupRepository.countUsersInGroup(group.getId())).thenReturn(1); - when(clusterUtil.isIndividualCluster(groupCluster.getId())).thenReturn(false); - when(groupRepository.findById(1L)).thenReturn(Optional.of(group)); - UserEntity userToAdd = new UserEntity(); - userToAdd.setId(2L); - userToAdd.setRole(UserRole.student); - when(userUtil.getUserIfExists(2L)).thenReturn(userToAdd); - when(groupRepository.isAdminOfGroup(2L, 1L)).thenReturn(false); - CheckResult result = groupUtil.canAddUserToGroup(group.getId(), 2L, user); + public void TestCanAddUserToGroup() { + long otherUserId = 5L; + UserEntity otherUser = new UserEntity("othername", "othersurname", "otheremail", UserRole.student, "otherazureid"); + /* All checks succeed */ + /* Trying to add yourself to the group */ + when(groupRepository.findById(group.getId())).thenReturn(Optional.of(group)); + when(groupRepository.userAccessToGroup(mockUser.getId(), group.getId())).thenReturn(true); + when(groupClusterRepository.inArchivedCourse(group.getClusterId())).thenReturn(false); + when(userUtil.getUserIfExists(mockUser.getId())).thenReturn(mockUser); + when(groupClusterRepository.userInGroupForCluster(group.getClusterId(), mockUser.getId())).thenReturn(false); + when(groupRepository.userInGroup(group.getId(), mockUser.getId())).thenReturn(false); + when(clusterUtil.getClusterIfExists(group.getClusterId())).thenReturn(new CheckResult<>(HttpStatus.OK, "", groupCluster)); + when(groupRepository.countUsersInGroup(group.getId())).thenReturn(groupCluster.getMaxSize() - 1); + when(clusterUtil.isIndividualCluster(group.getClusterId())).thenReturn(false); + doReturn(new CheckResult<>(HttpStatus.FORBIDDEN, "", null)). + when(groupUtil).isAdminOfGroup(group.getId(), mockUser); + + CheckResult result = groupUtil.canAddUserToGroup(group.getId(), mockUser.getId(), mockUser); assertEquals(HttpStatus.OK, result.getStatus()); + + /* Trying to add someone else as admin */ + doReturn(new CheckResult<>(HttpStatus.OK, "", null)). + when(groupUtil).isAdminOfGroup(group.getId(), mockUser); + when(userUtil.getUserIfExists(otherUserId)).thenReturn(otherUser); + when(groupClusterRepository.userInGroupForCluster(group.getClusterId(), otherUserId)).thenReturn(false); + when(groupRepository.userInGroup(group.getId(), otherUserId)).thenReturn(false); + doReturn(new CheckResult<>(HttpStatus.FORBIDDEN, "", null)). + when(groupUtil).isAdminOfGroup(group.getId(), otherUser); + result = groupUtil.canAddUserToGroup(group.getId(), otherUserId, mockUser); + assertEquals(HttpStatus.OK, result.getStatus()); + + /* Group is already full but it's an admin adding someone else */ + when(groupRepository.countUsersInGroup(group.getId())).thenReturn(groupCluster.getMaxSize()); + result = groupUtil.canAddUserToGroup(group.getId(), otherUserId, mockUser); + assertEquals(HttpStatus.OK, result.getStatus()); + when(groupRepository.countUsersInGroup(group.getId())).thenReturn(groupCluster.getMaxSize()-1); + + /* User trying to add is admin */ + doReturn(new CheckResult<>(HttpStatus.OK, "", null)). + when(groupUtil).isAdminOfGroup(group.getId(), otherUser); + result = groupUtil.canAddUserToGroup(group.getId(), otherUserId, mockUser); + assertEquals(HttpStatus.FORBIDDEN, result.getStatus()); + + /* Cluster is individual */ + when(clusterUtil.isIndividualCluster(group.getClusterId())).thenReturn(true); + result = groupUtil.canAddUserToGroup(group.getId(), otherUserId, mockUser); + assertEquals(HttpStatus.FORBIDDEN, result.getStatus()); + + /* Group is already full */ + when(groupRepository.countUsersInGroup(group.getId())).thenReturn(groupCluster.getMaxSize()); + result = groupUtil.canAddUserToGroup(group.getId(), mockUser.getId(), mockUser); + assertEquals(HttpStatus.FORBIDDEN, result.getStatus()); + + /* ClusterEntity is not found */ + when(clusterUtil.getClusterIfExists(group.getClusterId())).thenReturn(new CheckResult<>(HttpStatus.I_AM_A_TEAPOT, "", null)); + result = groupUtil.canAddUserToGroup(group.getId(), otherUserId, mockUser); + assertEquals(HttpStatus.INTERNAL_SERVER_ERROR, result.getStatus()); + + /* User is already in that group */ + when(groupRepository.userInGroup(group.getId(), otherUserId)).thenReturn(true); + result = groupUtil.canAddUserToGroup(group.getId(), otherUserId, mockUser); + assertEquals(HttpStatus.FORBIDDEN, result.getStatus()); + + /* User is already in group for cluster */ + when(groupClusterRepository.userInGroupForCluster(group.getClusterId(), otherUserId)).thenReturn(true); + result = groupUtil.canAddUserToGroup(group.getId(), otherUserId, mockUser); + assertEquals(HttpStatus.FORBIDDEN, result.getStatus()); + + /* User to add doesn't exist */ + when(userUtil.getUserIfExists(otherUserId)).thenReturn(null); + result = groupUtil.canAddUserToGroup(group.getId(), otherUserId, mockUser); + assertEquals(HttpStatus.NOT_FOUND, result.getStatus()); + + /* User trying to add a different user while not being admin */ + doReturn(new CheckResult<>(HttpStatus.FORBIDDEN, "", null)). + when(groupUtil).isAdminOfGroup(group.getId(), mockUser); + result = groupUtil.canAddUserToGroup(group.getId(), otherUserId, mockUser); + assertEquals(HttpStatus.FORBIDDEN, result.getStatus()); + + /* User trying to join group in archived course */ + when(groupClusterRepository.inArchivedCourse(group.getClusterId())).thenReturn(true); + result = groupUtil.canAddUserToGroup(group.getId(), mockUser.getId(), mockUser); + assertEquals(HttpStatus.FORBIDDEN, result.getStatus()); + + /* User trying to join group they don't have acces too */ + when(groupRepository.userAccessToGroup(mockUser.getId(), group.getId())).thenReturn(false); + result = groupUtil.canAddUserToGroup(group.getId(), mockUser.getId(), mockUser); + assertEquals(HttpStatus.FORBIDDEN, result.getStatus()); + + /* Group not found */ + when(groupRepository.findById(group.getId())).thenReturn(Optional.empty()); + result = groupUtil.canAddUserToGroup(group.getId(), mockUser.getId(), mockUser); + assertEquals(HttpStatus.NOT_FOUND, result.getStatus()); } @Test public void testCanRemoveUserFromGroup() throws Exception { - when(groupRepository.findById(1L)).thenReturn(Optional.of(group)); - when(groupRepository.isAdminOfGroup(1L, user.getId())).thenReturn(true); - when(groupRepository.userInGroup(1L, 2L)).thenReturn(true); - when(clusterUtil.isIndividualCluster(groupCluster.getId())).thenReturn(false); - CheckResult result = groupUtil.canRemoveUserFromGroup(group.getId(), 2L, user); + /* All checks succeed */ + /* Trying to leave group */ + when(groupRepository.findById(group.getId())).thenReturn(Optional.of(group)); + when(groupClusterRepository.inArchivedCourse(group.getClusterId())).thenReturn(false); + when(groupRepository.userInGroup(group.getId(), mockUser.getId())).thenReturn(true); + when(clusterUtil.isIndividualCluster(group.getClusterId())).thenReturn(false); + + CheckResult result = groupUtil.canRemoveUserFromGroup(group.getId(), mockUser.getId(), mockUser); assertEquals(HttpStatus.OK, result.getStatus()); - when(clusterUtil.isIndividualCluster(groupCluster.getId())).thenReturn(true); - result = groupUtil.canRemoveUserFromGroup(group.getId(), 2L, user); + /* Trying to remove someone else */ + long otherUserId = 5L; + doReturn(new CheckResult<>(HttpStatus.OK, "", null)). + when(groupUtil).isAdminOfGroup(group.getId(), mockUser); + when(groupRepository.userInGroup(group.getId(), otherUserId)).thenReturn(true); + + result = groupUtil.canRemoveUserFromGroup(group.getId(), otherUserId, mockUser); + assertEquals(HttpStatus.OK, result.getStatus()); + + /* Individual cluster */ + when(clusterUtil.isIndividualCluster(group.getClusterId())).thenReturn(true); + result = groupUtil.canRemoveUserFromGroup(group.getId(), otherUserId, mockUser); assertEquals(HttpStatus.FORBIDDEN, result.getStatus()); - assertEquals("Cannot remove user from individual group", result.getMessage()); - when(groupRepository.userInGroup(1L, 2L)).thenReturn(false); - result = groupUtil.canRemoveUserFromGroup(group.getId(), 2L, user); + /* User is not in group */ + when(groupRepository.userInGroup(group.getId(), otherUserId)).thenReturn(false); + result = groupUtil.canRemoveUserFromGroup(group.getId(), otherUserId, mockUser); assertEquals(HttpStatus.NOT_FOUND, result.getStatus()); - assertEquals("User is not in the group", result.getMessage()); - when(groupRepository.isAdminOfGroup(1L, user.getId())).thenReturn(false); - result = groupUtil.canRemoveUserFromGroup(group.getId(), 2L, user); + /* Trying to leave group in archived course */ + when(groupClusterRepository.inArchivedCourse(group.getClusterId())).thenReturn(true); + result = groupUtil.canRemoveUserFromGroup(group.getId(), mockUser.getId(), mockUser); + assertEquals(HttpStatus.FORBIDDEN, result.getStatus()); + + /* Trying to add someone else while not admin */ + doReturn(new CheckResult<>(HttpStatus.FORBIDDEN, "", null)). + when(groupUtil).isAdminOfGroup(group.getId(), mockUser); + result = groupUtil.canRemoveUserFromGroup(group.getId(), otherUserId, mockUser); assertEquals(HttpStatus.FORBIDDEN, result.getStatus()); - assertEquals("User is not an admin of this group", result.getMessage()); - when(groupRepository.findById(1L)).thenReturn(Optional.empty()); - result = groupUtil.canRemoveUserFromGroup(group.getId(), 2L, user); + /* Group not found */ + when(groupRepository.findById(group.getId())).thenReturn(Optional.empty()); + result = groupUtil.canRemoveUserFromGroup(group.getId(), mockUser.getId(), mockUser); assertEquals(HttpStatus.NOT_FOUND, result.getStatus()); - assertEquals("Group not found", result.getMessage()); } @Test public void testCanGetProjectGroupData() throws Exception { - when(projectUtil.getProjectIfExists(project.getId())) - .thenReturn(new CheckResult<>(HttpStatus.OK, "", project)); - when(groupRepository.findByIdAndClusterId(group.getId(), project.getGroupClusterId())) - .thenReturn(Optional.of(group)); - when(groupRepository.userInGroup(group.getId(), user.getId())).thenReturn(true); - when(projectUtil.isProjectAdmin(project.getId(), user)) - .thenReturn(new CheckResult<>(HttpStatus.OK, "", null)); - CheckResult result = groupUtil.canGetProjectGroupData(group.getId(), project.getId(), - user); + /* All checks succeed */ + when(projectUtil.getProjectIfExists(project.getId())).thenReturn(new CheckResult<>(HttpStatus.OK, "", project)); + when(groupRepository.findByIdAndClusterId(group.getId(), project.getGroupClusterId())).thenReturn(Optional.of(group)); + + /* User in the group */ + when(groupRepository.userInGroup(group.getId(), mockUser.getId())).thenReturn(true); + when(projectUtil.isProjectAdmin(project.getId(), mockUser)).thenReturn(new CheckResult<>(HttpStatus.I_AM_A_TEAPOT, "", null)); + CheckResult result = groupUtil.canGetProjectGroupData(group.getId(), project.getId(), mockUser); + assertEquals(HttpStatus.OK, result.getStatus()); + + /* User not in group but project admin */ + when(groupRepository.userInGroup(group.getId(), mockUser.getId())).thenReturn(false); + when(projectUtil.isProjectAdmin(project.getId(), mockUser)).thenReturn(new CheckResult<>(HttpStatus.OK, "", null)); + result = groupUtil.canGetProjectGroupData(group.getId(), project.getId(), mockUser); + assertEquals(HttpStatus.OK, result.getStatus()); + + /* User not in group but general admin */ + when(groupRepository.userInGroup(group.getId(), mockUser.getId())).thenReturn(false); + when(projectUtil.isProjectAdmin(project.getId(), mockUser)).thenReturn(new CheckResult<>(HttpStatus.FORBIDDEN, "", null)); + mockUser.setRole(UserRole.admin); + result = groupUtil.canGetProjectGroupData(group.getId(), project.getId(), mockUser); assertEquals(HttpStatus.OK, result.getStatus()); - when(groupRepository.userInGroup(group.getId(), user.getId())).thenReturn(false); - when(projectUtil.isProjectAdmin(project.getId(), user)) - .thenReturn(new CheckResult<>(HttpStatus.I_AM_A_TEAPOT, "", null)); - result = groupUtil.canGetProjectGroupData(group.getId(), project.getId(), user); + /* User not in group and not admin */ + mockUser.setRole(UserRole.student); + result = groupUtil.canGetProjectGroupData(group.getId(), project.getId(), mockUser); assertEquals(HttpStatus.FORBIDDEN, result.getStatus()); - assertEquals("User does not have access to the submissions of the group", result.getMessage()); - when(groupRepository.findByIdAndClusterId(group.getId(), project.getGroupClusterId())) - .thenReturn(Optional.empty()); - result = groupUtil.canGetProjectGroupData(group.getId(), project.getId(), user); + /* Group not part of the project */ + when(groupRepository.findByIdAndClusterId(group.getId(), project.getGroupClusterId())).thenReturn(Optional.empty()); + result = groupUtil.canGetProjectGroupData(group.getId(), project.getId(), mockUser); assertEquals(HttpStatus.NOT_FOUND, result.getStatus()); - assertEquals("Group not part of the project", result.getMessage()); - when(projectUtil.getProjectIfExists(project.getId())) - .thenReturn(new CheckResult<>(HttpStatus.I_AM_A_TEAPOT, "", project)); - result = groupUtil.canGetProjectGroupData(group.getId(), project.getId(), user); + /* Project not found */ + when(projectUtil.getProjectIfExists(project.getId())).thenReturn(new CheckResult<>(HttpStatus.I_AM_A_TEAPOT, "", null)); + result = groupUtil.canGetProjectGroupData(group.getId(), project.getId(), mockUser); assertEquals(HttpStatus.I_AM_A_TEAPOT, result.getStatus()); - assertEquals("", result.getMessage()); } -} + + + } diff --git a/backend/app/src/test/java/com/ugent/pidgeon/util/PairTest.java b/backend/app/src/test/java/com/ugent/pidgeon/util/PairTest.java new file mode 100644 index 00000000..c046313f --- /dev/null +++ b/backend/app/src/test/java/com/ugent/pidgeon/util/PairTest.java @@ -0,0 +1,14 @@ +package com.ugent.pidgeon.util; + +import org.junit.jupiter.api.Test; + +public class PairTest { + + @Test + public void testPair() { + Pair pair = new Pair<>("test", 1); + assert(pair.getFirst().equals("test")); + assert(pair.getSecond().equals(1)); + } + +} diff --git a/backend/app/src/test/java/com/ugent/pidgeon/util/ProjectUtilTest.java b/backend/app/src/test/java/com/ugent/pidgeon/util/ProjectUtilTest.java index 7a64a4da..85ad1db1 100644 --- a/backend/app/src/test/java/com/ugent/pidgeon/util/ProjectUtilTest.java +++ b/backend/app/src/test/java/com/ugent/pidgeon/util/ProjectUtilTest.java @@ -7,17 +7,22 @@ import com.ugent.pidgeon.model.json.ProjectJson; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.InjectMocks; import org.mockito.Mock; import org.mockito.MockitoAnnotations; +import org.mockito.Spy; +import org.mockito.junit.jupiter.MockitoExtension; import org.springframework.http.HttpStatus; import java.time.OffsetDateTime; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.doReturn; import static org.mockito.Mockito.when; +@ExtendWith(MockitoExtension.class) public class ProjectUtilTest { @Mock @@ -26,217 +31,195 @@ public class ProjectUtilTest { @Mock private ClusterUtil clusterUtil; + @Spy @InjectMocks private ProjectUtil projectUtil; + private ProjectEntity projectEntity; + private UserEntity mockUser; + @BeforeEach public void setUp() { - MockitoAnnotations.openMocks(this); + projectEntity = new ProjectEntity( + 99L, + "projectName", + "projectDescription", + 69L, + 38L, + true, + 34, + OffsetDateTime.now() + ); + projectEntity.setId(64); + + mockUser = new UserEntity("name", "surname", "email", UserRole.student, "azureid"); + mockUser.setId(10L); } @Test public void testUserPartOfProject() { - long projectId = 1L; - long userId = 1L; - when(projectRepository.userPartOfProject(projectId, userId)).thenReturn(true); - boolean result = projectUtil.userPartOfProject(projectId, userId); - assertEquals(true, result); - } + /* User in project */ + when(projectRepository.userPartOfProject(projectEntity.getId(), mockUser.getId())).thenReturn(true); + assertEquals(true, projectUtil.userPartOfProject(projectEntity.getId(), mockUser.getId())); - @Test - public void testGetProjectIfExists() { - long projectId = 1L; - ProjectEntity projectEntity = new ProjectEntity(); - when(projectRepository.findById(projectId)).thenReturn(java.util.Optional.of(projectEntity)); - CheckResult result = projectUtil.getProjectIfExists(projectId); - assertEquals(HttpStatus.OK, result.getStatus()); + /* User not in project */ + when(projectRepository.userPartOfProject(projectEntity.getId(), mockUser.getId())).thenReturn(false); + assertEquals(false, projectUtil.userPartOfProject(projectEntity.getId(), mockUser.getId())); } @Test - public void testGetProjectIfExistsNotFound() { - long projectId = 1L; - when(projectRepository.findById(projectId)).thenReturn(java.util.Optional.empty()); - CheckResult result = projectUtil.getProjectIfExists(projectId); - assertEquals(HttpStatus.NOT_FOUND, result.getStatus()); - } + public void testGetProjectIfExists() { + /* Project found */ + when(projectRepository.findById(projectEntity.getId())).thenReturn(java.util.Optional.of(projectEntity)); + CheckResult checkResult = projectUtil.getProjectIfExists(projectEntity.getId()); + assertEquals(HttpStatus.OK, checkResult.getStatus()); + assertEquals(projectEntity, checkResult.getData()); + /* Project not found */ + when(projectRepository.findById(projectEntity.getId())).thenReturn(java.util.Optional.empty()); + checkResult = projectUtil.getProjectIfExists(projectEntity.getId()); + assertEquals(HttpStatus.NOT_FOUND, checkResult.getStatus()); + } @Test public void testIsProjectAdmin() { - long projectId = 1L; - UserEntity user = new UserEntity(); - user.setId(1L); - user.setRole(UserRole.admin); - when(projectRepository.adminOfProject(projectId, user.getId())).thenReturn(true); - CheckResult result = projectUtil.isProjectAdmin(projectId, user); - assertEquals(HttpStatus.OK, result.getStatus()); - } + /* User is admin */ + when(projectRepository.adminOfProject(projectEntity.getId(), mockUser.getId())).thenReturn(true); + CheckResult checkResult = projectUtil.isProjectAdmin(projectEntity.getId(), mockUser); + assertEquals(HttpStatus.OK, checkResult.getStatus()); + /* User is not admin */ + when(projectRepository.adminOfProject(projectEntity.getId(), mockUser.getId())).thenReturn(false); + checkResult = projectUtil.isProjectAdmin(projectEntity.getId(), mockUser); + assertEquals(HttpStatus.FORBIDDEN, checkResult.getStatus()); - @Test - public void testIsProjectAdminForbidden() { - long projectId = 1L; - UserEntity user = new UserEntity(); - user.setId(1L); - user.setRole(UserRole.student); - when(projectRepository.adminOfProject(projectId, user.getId())).thenReturn(false); - CheckResult result = projectUtil.isProjectAdmin(projectId, user); - assertEquals(HttpStatus.FORBIDDEN, result.getStatus()); + /* User is general admin */ + mockUser.setRole(UserRole.admin); + checkResult = projectUtil.isProjectAdmin(projectEntity.getId(), mockUser); + assertEquals(HttpStatus.OK, checkResult.getStatus()); } @Test public void testGetProjectIfAdmin() { - long projectId = 1L; - UserEntity user = new UserEntity(); - user.setId(1L); - user.setRole(UserRole.admin); - ProjectEntity projectEntity = new ProjectEntity(); - when(projectRepository.findById(projectId)).thenReturn(java.util.Optional.of(projectEntity)); - when(projectRepository.adminOfProject(projectId, user.getId())).thenReturn(true); - CheckResult result = projectUtil.getProjectIfAdmin(projectId, user); - assertEquals(HttpStatus.OK, result.getStatus()); - } + /* All checks succeed */ + doReturn(new CheckResult<>(HttpStatus.OK, "", projectEntity)).when(projectUtil).getProjectIfExists(projectEntity.getId()); + when(projectRepository.adminOfProject(projectEntity.getId(), mockUser.getId())).thenReturn(true); - @Test - public void testGetProjectIfAdminForbidden() { - long projectId = 1L; - UserEntity user = new UserEntity(); - user.setId(1L); - user.setRole(UserRole.student); - ProjectEntity projectEntity = new ProjectEntity(); - when(projectRepository.findById(projectId)).thenReturn(java.util.Optional.of(projectEntity)); - when(projectRepository.adminOfProject(projectId, user.getId())).thenReturn(false); - CheckResult result = projectUtil.getProjectIfAdmin(projectId, user); - assertEquals(HttpStatus.FORBIDDEN, result.getStatus()); - } + CheckResult checkResult = projectUtil.getProjectIfAdmin(projectEntity.getId(), mockUser); + assertEquals(HttpStatus.OK, checkResult.getStatus()); - @Test - public void testCheckProjectJson() { - long courseId = 1L; - ProjectJson projectJson = new ProjectJson(); - projectJson.setName("Test Project"); - projectJson.setDescription("This is a test project."); - projectJson.setMaxScore(100); - projectJson.setGroupClusterId(1L); - projectJson.setDeadline(OffsetDateTime.now().plusDays(1)); - when(clusterUtil.partOfCourse(projectJson.getGroupClusterId(), courseId)).thenReturn( - new CheckResult<>(HttpStatus.OK, "", null)); - CheckResult result = projectUtil.checkProjectJson(projectJson, courseId); - assertEquals(HttpStatus.OK, result.getStatus()); - } + /* User is not admin */ + when(projectRepository.adminOfProject(projectEntity.getId(), mockUser.getId())).thenReturn(false); + checkResult = projectUtil.getProjectIfAdmin(projectEntity.getId(), mockUser); + assertEquals(HttpStatus.FORBIDDEN, checkResult.getStatus()); - @Test - public void testCheckProjectJsonNullName() { - long courseId = 1L; - ProjectJson projectJson = new ProjectJson(); - projectJson.setDescription("This is a test project."); - projectJson.setMaxScore(100); - projectJson.setGroupClusterId(1L); - projectJson.setDeadline(OffsetDateTime.now().plusDays(1)); - when(clusterUtil.partOfCourse(projectJson.getGroupClusterId(), courseId)).thenReturn( - new CheckResult<>(HttpStatus.OK, "", null)); - CheckResult result = projectUtil.checkProjectJson(projectJson, courseId); - assertEquals(HttpStatus.BAD_REQUEST, result.getStatus()); - } + /* User is not project admin but admin role */ + mockUser.setRole(UserRole.admin); + checkResult = projectUtil.getProjectIfAdmin(projectEntity.getId(), mockUser); + assertEquals(HttpStatus.OK, checkResult.getStatus()); - @Test - public void testCheckProjectJsonNullDescription() { - long courseId = 1L; - ProjectJson projectJson = new ProjectJson(); - projectJson.setName("Test Project"); - projectJson.setMaxScore(100); - projectJson.setGroupClusterId(1L); - projectJson.setDeadline(OffsetDateTime.now().plusDays(1)); - when(clusterUtil.partOfCourse(projectJson.getGroupClusterId(), courseId)).thenReturn( - new CheckResult<>(HttpStatus.OK, "", null)); - CheckResult result = projectUtil.checkProjectJson(projectJson, courseId); - assertEquals(HttpStatus.BAD_REQUEST, result.getStatus()); + /* Project not found */ + doReturn(new CheckResult<>(HttpStatus.NOT_FOUND, "Project not found", null)).when(projectUtil).getProjectIfExists(projectEntity.getId()); + checkResult = projectUtil.getProjectIfAdmin(projectEntity.getId(), mockUser); + assertEquals(HttpStatus.NOT_FOUND, checkResult.getStatus()); } @Test - public void testCheckProjectJsonNullMaxScore() { - long courseId = 1L; - ProjectJson projectJson = new ProjectJson(); - projectJson.setName("Test Project"); - projectJson.setDescription("This is a test project."); - projectJson.setGroupClusterId(1L); + public void testCheckProjectJson() { + ProjectJson projectJson = new ProjectJson( + "UpdateProjectName", + "UpdateProjectDescription", + 69L, + true, + 34, + OffsetDateTime.now().plusDays(1) + ); + + /* All checks succeed */ + when(clusterUtil.partOfCourse(projectJson.getGroupClusterId(), projectEntity.getCourseId())) + .thenReturn(new CheckResult<>(HttpStatus.OK, "", null)); + + CheckResult checkResult = projectUtil.checkProjectJson(projectJson, projectEntity.getCourseId()); + assertEquals(HttpStatus.OK, checkResult.getStatus()); + + /* projectJson maxScore is negative */ + projectJson.setMaxScore(-1); + checkResult = projectUtil.checkProjectJson(projectJson, projectEntity.getCourseId()); + assertEquals(HttpStatus.BAD_REQUEST, checkResult.getStatus()); + + /* projectJson maxScore is zero */ + projectJson.setMaxScore(0); + checkResult = projectUtil.checkProjectJson(projectJson, projectEntity.getCourseId()); + assertEquals(HttpStatus.BAD_REQUEST, checkResult.getStatus()); + + /* projectJson no max score */ + projectJson.setMaxScore(null); + checkResult = projectUtil.checkProjectJson(projectJson, projectEntity.getCourseId()); + assertEquals(HttpStatus.OK, checkResult.getStatus()); + + /* projectJson deadline is already passed */ + projectJson.setDeadline(OffsetDateTime.now().minusDays(1)); + checkResult = projectUtil.checkProjectJson(projectJson, projectEntity.getCourseId()); + assertEquals(HttpStatus.BAD_REQUEST, checkResult.getStatus()); + + /* Cluster not part of course */ + when(clusterUtil.partOfCourse(projectJson.getGroupClusterId(), projectEntity.getCourseId())) + .thenReturn(new CheckResult<>(HttpStatus.NOT_FOUND, "Cluster not part of course", null)); + checkResult = projectUtil.checkProjectJson(projectJson, projectEntity.getCourseId()); + assertEquals(HttpStatus.NOT_FOUND, checkResult.getStatus()); + + /* name is blank */ + projectJson.setName(""); + checkResult = projectUtil.checkProjectJson(projectJson, projectEntity.getCourseId()); + assertEquals(HttpStatus.BAD_REQUEST, checkResult.getStatus()); + + /* deadline is null */ + projectJson.setDeadline(null); + checkResult = projectUtil.checkProjectJson(projectJson, projectEntity.getCourseId()); + assertEquals(HttpStatus.BAD_REQUEST, checkResult.getStatus()); + + /* groupClusterId is null */ projectJson.setDeadline(OffsetDateTime.now().plusDays(1)); - when(clusterUtil.partOfCourse(projectJson.getGroupClusterId(), courseId)).thenReturn( - new CheckResult<>(HttpStatus.OK, "", null)); - CheckResult result = projectUtil.checkProjectJson(projectJson, courseId); - assertEquals(HttpStatus.BAD_REQUEST, result.getStatus()); - } + projectJson.setGroupClusterId(null); + checkResult = projectUtil.checkProjectJson(projectJson, projectEntity.getCourseId()); + assertEquals(HttpStatus.BAD_REQUEST, checkResult.getStatus()); + /* description is null */ + projectJson.setDescription(null); + checkResult = projectUtil.checkProjectJson(projectJson, projectEntity.getCourseId()); + assertEquals(HttpStatus.BAD_REQUEST, checkResult.getStatus()); - @Test - public void testCheckProjectJsonNullDeadline() { - long courseId = 1L; - ProjectJson projectJson = new ProjectJson(); - projectJson.setName("Test Project"); - projectJson.setDescription("This is a test project."); - projectJson.setMaxScore(100); - projectJson.setGroupClusterId(1L); - when(clusterUtil.partOfCourse(projectJson.getGroupClusterId(), courseId)).thenReturn( - new CheckResult<>(HttpStatus.OK, "", null)); - CheckResult result = projectUtil.checkProjectJson(projectJson, courseId); - assertEquals(HttpStatus.BAD_REQUEST, result.getStatus()); - } - - @Test - public void testCheckProjectDeadlinePast() { - long courseId = 1L; - ProjectJson projectJson = new ProjectJson(); - projectJson.setName("Test Project"); - projectJson.setDescription("This is a test project."); - projectJson.setMaxScore(100); - projectJson.setGroupClusterId(1L); - projectJson.setDeadline(OffsetDateTime.now().minusDays(1)); - when(clusterUtil.partOfCourse(projectJson.getGroupClusterId(), courseId)).thenReturn( - new CheckResult<>(HttpStatus.OK, "", null)); - CheckResult result = projectUtil.checkProjectJson(projectJson, courseId); - assertEquals(HttpStatus.BAD_REQUEST, result.getStatus()); + /* name is null */ + projectJson.setName(null); + checkResult = projectUtil.checkProjectJson(projectJson, projectEntity.getCourseId()); + assertEquals(HttpStatus.BAD_REQUEST, checkResult.getStatus()); } @Test public void testCanGetProject() { - long projectId = 1L; - UserEntity user = new UserEntity(); - user.setId(1L); - user.setRole(UserRole.admin); - ProjectEntity projectEntity = new ProjectEntity(); - when(projectRepository.findById(projectId)).thenReturn(java.util.Optional.of(projectEntity)); - when(projectRepository.userPartOfProject(projectId, user.getId())).thenReturn(true); - when(projectRepository.adminOfProject(projectId, user.getId())).thenReturn(true); - CheckResult result = projectUtil.canGetProject(projectId, user); - assertEquals(HttpStatus.OK, result.getStatus()); - } + /* User is student */ + when(projectRepository.findById(projectEntity.getId())).thenReturn(java.util.Optional.of(projectEntity)); + when(projectRepository.userPartOfProject(projectEntity.getId(), mockUser.getId())).thenReturn(true); - @Test - public void testCanGetProjectForbidden() { - long projectId = 1L; - UserEntity user = new UserEntity(); - user.setId(1L); - user.setRole(UserRole.student); - ProjectEntity projectEntity = new ProjectEntity(); - when(projectRepository.findById(projectId)).thenReturn(java.util.Optional.of(projectEntity)); - when(projectRepository.userPartOfProject(projectId, user.getId())).thenReturn(false); - when(projectRepository.adminOfProject(projectId, user.getId())).thenReturn(false); - CheckResult result = projectUtil.canGetProject(projectId, user); - assertEquals(HttpStatus.FORBIDDEN, result.getStatus()); - } + CheckResult checkResult = projectUtil.canGetProject(projectEntity.getId(), mockUser); + assertEquals(HttpStatus.OK, checkResult.getStatus()); - @Test - public void testCanGetProjectNotFound() { - long projectId = 1L; - UserEntity user = new UserEntity(); - user.setId(1L); - user.setRole(UserRole.admin); - when(projectRepository.findById(projectId)).thenReturn(java.util.Optional.empty()); - CheckResult result = projectUtil.canGetProject(projectId, user); - assertEquals(HttpStatus.NOT_FOUND, result.getStatus()); - } + /* User is admin */ + when(projectRepository.userPartOfProject(projectEntity.getId(), mockUser.getId())).thenReturn(false); + mockUser.setRole(UserRole.admin); + checkResult = projectUtil.canGetProject(projectEntity.getId(), mockUser); + assertEquals(HttpStatus.OK, checkResult.getStatus()); + /* User is not part of project */ + mockUser.setRole(UserRole.student); + checkResult = projectUtil.canGetProject(projectEntity.getId(), mockUser); + assertEquals(HttpStatus.FORBIDDEN, checkResult.getStatus()); + /* Project not found */ + when(projectRepository.findById(projectEntity.getId())).thenReturn(java.util.Optional.empty()); + checkResult = projectUtil.canGetProject(projectEntity.getId(), mockUser); + assertEquals(HttpStatus.NOT_FOUND, checkResult.getStatus()); + } } \ No newline at end of file diff --git a/backend/app/src/test/java/com/ugent/pidgeon/util/StringMatcherTest.java b/backend/app/src/test/java/com/ugent/pidgeon/util/StringMatcherTest.java new file mode 100644 index 00000000..c3be6e06 --- /dev/null +++ b/backend/app/src/test/java/com/ugent/pidgeon/util/StringMatcherTest.java @@ -0,0 +1,41 @@ +package com.ugent.pidgeon.util; + +import org.junit.jupiter.api.Test; + +public class StringMatcherTest { + + @Test + public void testIsValidEmail() { + assert (StringMatcher.isValidEmail("name.surname@UGent.be")); + assert (StringMatcher.isValidEmail("namesurname@UGent.be")); + + } + + @Test + public void testIsValidEmailNoEndPart() { + assert (!StringMatcher.isValidEmail("name.surname@UGent")); + } + + @Test + public void testIsValidEmailNoAt() { + assert (!StringMatcher.isValidEmail("name.surnameUGent.be")); + } + + @Test + public void testIsValidEmailNoStartPart() { + assert (!StringMatcher.isValidEmail("@UGent.be")); + } + + @Test + public void testIsValidEmailNoDot() { + assert (!StringMatcher.isValidEmail("name.surname@UGentbe")); + + } + + + + + + + +} diff --git a/backend/app/src/test/java/com/ugent/pidgeon/util/SubmissionUtilTest.java b/backend/app/src/test/java/com/ugent/pidgeon/util/SubmissionUtilTest.java index 162c793c..0875693c 100644 --- a/backend/app/src/test/java/com/ugent/pidgeon/util/SubmissionUtilTest.java +++ b/backend/app/src/test/java/com/ugent/pidgeon/util/SubmissionUtilTest.java @@ -4,6 +4,7 @@ import com.ugent.pidgeon.postgre.models.ProjectEntity; import com.ugent.pidgeon.postgre.models.SubmissionEntity; import com.ugent.pidgeon.postgre.models.UserEntity; +import com.ugent.pidgeon.postgre.models.types.UserRole; import com.ugent.pidgeon.postgre.repository.GroupClusterRepository; import com.ugent.pidgeon.postgre.repository.GroupRepository; import com.ugent.pidgeon.postgre.repository.SubmissionRepository; @@ -48,53 +49,136 @@ public class SubmissionUtilTest { private SubmissionEntity submissionEntity; private ProjectEntity projectEntity; private UserEntity userEntity; + private GroupEntity groupEntity; @BeforeEach public void setUp() { - submissionEntity = new SubmissionEntity(); - submissionEntity.setId(1L); - projectEntity = new ProjectEntity(); - projectEntity.setId(1L); - userEntity = new UserEntity(); - userEntity.setId(1L); + submissionEntity = new SubmissionEntity( + 22, + 45, + 99L, + OffsetDateTime.MIN, + true, + true + ); + submissionEntity.setId(78L); + projectEntity = new ProjectEntity( + 99L, + "projectName", + "projectDescription", + 2L, + 100L, + true, + 34, + OffsetDateTime.now() + ); + projectEntity.setId(64); + userEntity = new UserEntity( + "name", + "surname", + "email", + UserRole.student, + "azureId" + ); + userEntity.setId(44L); + + groupEntity = new GroupEntity( + "groupName", + 52L + ); + groupEntity.setId(4L); + } @Test public void testCanGetSubmission() { - when(submissionRepository.findById(anyLong())).thenReturn(Optional.of(submissionEntity)); - when(groupUtil.canGetProjectGroupData(anyLong(), anyLong(), any(UserEntity.class))) + /* All checks succeed */ + when(submissionRepository.findById(submissionEntity.getId())).thenReturn(Optional.of(submissionEntity)); + when(groupUtil.canGetProjectGroupData(submissionEntity.getGroupId(), submissionEntity.getProjectId(), userEntity)) .thenReturn(new CheckResult<>(HttpStatus.OK, "", null)); - assertEquals(submissionEntity, submissionUtil.canGetSubmission(1L, userEntity).getData()); - when(groupUtil.canGetProjectGroupData(anyLong(), anyLong(), any(UserEntity.class))) - .thenReturn(new CheckResult<>(HttpStatus.FORBIDDEN, "User does not have access to this submission", null)); - assertNull(submissionUtil.canGetSubmission(1L, userEntity).getData()); + CheckResult result = submissionUtil.canGetSubmission(submissionEntity.getId(), userEntity); + assertEquals(HttpStatus.OK, result.getStatus()); + assertEquals(submissionEntity, result.getData()); + + /* User does not have access to the submission */ + when(groupUtil.canGetProjectGroupData(submissionEntity.getGroupId(), submissionEntity.getProjectId(), userEntity)) + .thenReturn(new CheckResult<>(HttpStatus.I_AM_A_TEAPOT, "User does not have access to get this submission", null)); + result = submissionUtil.canGetSubmission(submissionEntity.getId(), userEntity); + assertEquals(HttpStatus.I_AM_A_TEAPOT, result.getStatus()); + + /* Submission not found */ + when(submissionRepository.findById(submissionEntity.getId())).thenReturn(Optional.empty()); + result = submissionUtil.canGetSubmission(submissionEntity.getId(), userEntity); + assertEquals(HttpStatus.NOT_FOUND, result.getStatus()); } @Test public void testCanDeleteSubmission() { - when(submissionRepository.findById(anyLong())).thenReturn(Optional.of(submissionEntity)); - when(projectUtil.isProjectAdmin(anyLong(), any(UserEntity.class))) + /* All checks succeed */ + when(submissionRepository.findById(submissionEntity.getId())).thenReturn(Optional.of(submissionEntity)); + when(projectUtil.isProjectAdmin(submissionEntity.getProjectId(), userEntity)) .thenReturn(new CheckResult<>(HttpStatus.OK, "", null)); - assertEquals(submissionEntity, submissionUtil.canDeleteSubmission(1L, userEntity).getData()); - when(projectUtil.isProjectAdmin(anyLong(), any(UserEntity.class))) - .thenReturn(new CheckResult<>(HttpStatus.FORBIDDEN, "User does not have access to delete this submission", null)); - assertNull(submissionUtil.canDeleteSubmission(1L, userEntity).getData()); - } + CheckResult result = submissionUtil.canDeleteSubmission(submissionEntity.getId(), userEntity); + assertEquals(HttpStatus.OK, result.getStatus()); + assertEquals(submissionEntity, result.getData()); + /* User does not have access to delete the submission */ + when(projectUtil.isProjectAdmin(submissionEntity.getProjectId(), userEntity)) + .thenReturn(new CheckResult<>(HttpStatus.I_AM_A_TEAPOT, "User does not have access to delete this submission", null)); + result = submissionUtil.canDeleteSubmission(submissionEntity.getId(), userEntity); + assertEquals(HttpStatus.I_AM_A_TEAPOT, result.getStatus()); + + /* Submission not found */ + when(submissionRepository.findById(submissionEntity.getId())).thenReturn(Optional.empty()); + result = submissionUtil.canDeleteSubmission(submissionEntity.getId(), userEntity); + assertEquals(HttpStatus.NOT_FOUND, result.getStatus()); + } + @Test public void testCheckOnSubmit() { - ProjectEntity projectEntity = new ProjectEntity(); - projectEntity.setId(1L); + /* All checks succeed */ projectEntity.setDeadline(OffsetDateTime.now().plusDays(1)); - CheckResult projectCheck = new CheckResult<>(HttpStatus.OK, "", projectEntity); - when(projectUtil.getProjectIfExists(anyLong())).thenReturn(projectCheck); - when(groupRepository.groupIdByProjectAndUser(anyLong(), anyLong())).thenReturn(1L); - when(projectUtil.userPartOfProject(anyLong(), anyLong())).thenReturn(true); - when(projectUtil.getProjectIfExists(anyLong())).thenReturn(new CheckResult<>(HttpStatus.OK, "", projectEntity)); - when(groupUtil.getGroupIfExists(anyLong())).thenReturn(new CheckResult<>(HttpStatus.OK, "", new GroupEntity())); - when(groupClusterRepository.inArchivedCourse(anyLong())).thenReturn(false); - assertEquals(1L, submissionUtil.checkOnSubmit(1L, userEntity).getData()); + when(projectUtil.userPartOfProject(projectEntity.getId(), userEntity.getId())).thenReturn(true); + when(groupRepository.groupIdByProjectAndUser(projectEntity.getId(), userEntity.getId())).thenReturn(groupEntity.getId()); + when(groupUtil.getGroupIfExists(groupEntity.getId())).thenReturn(new CheckResult<>(HttpStatus.OK, "", groupEntity)); + when(groupClusterRepository.inArchivedCourse(groupEntity.getClusterId())).thenReturn(false); + + when(projectUtil.getProjectIfExists(projectEntity.getId())).thenReturn(new CheckResult<>(HttpStatus.OK, "", projectEntity)); + CheckResult result = submissionUtil.checkOnSubmit(projectEntity.getId(), userEntity); + assertEquals(HttpStatus.OK, result.getStatus()); + + /* Deadline passed */ + projectEntity.setDeadline(OffsetDateTime.now().minusDays(1)); + result = submissionUtil.checkOnSubmit(projectEntity.getId(), userEntity); + assertEquals(HttpStatus.FORBIDDEN, result.getStatus()); + + /* Project not found */ + when(projectUtil.getProjectIfExists(projectEntity.getId())).thenReturn(new CheckResult<>(HttpStatus.I_AM_A_TEAPOT, "Project not found", null)); + result = submissionUtil.checkOnSubmit(projectEntity.getId(), userEntity); + assertEquals(HttpStatus.I_AM_A_TEAPOT, result.getStatus()); + + /* GroupCluster in archived course */ + when(groupClusterRepository.inArchivedCourse(groupEntity.getClusterId())).thenReturn(true); + result = submissionUtil.checkOnSubmit(projectEntity.getId(), userEntity); + assertEquals(HttpStatus.FORBIDDEN, result.getStatus()); + + /* Group not found */ + when(groupUtil.getGroupIfExists(groupEntity.getId())).thenReturn(new CheckResult<>(HttpStatus.I_AM_A_TEAPOT, "Group not found", null)); + result = submissionUtil.checkOnSubmit(projectEntity.getId(), userEntity); + assertEquals(HttpStatus.I_AM_A_TEAPOT, result.getStatus()); + + /* User not part of group */ + when(groupRepository.groupIdByProjectAndUser(projectEntity.getId(), userEntity.getId())).thenReturn(null); + result = submissionUtil.checkOnSubmit(projectEntity.getId(), userEntity); + assertEquals(HttpStatus.BAD_REQUEST, result.getStatus()); + + /* User not part of project */ + when(projectUtil.userPartOfProject(projectEntity.getId(), userEntity.getId())).thenReturn(false); + result = submissionUtil.checkOnSubmit(projectEntity.getId(), userEntity); + assertEquals(HttpStatus.FORBIDDEN, result.getStatus()); } + + } \ No newline at end of file diff --git a/backend/app/src/test/java/com/ugent/pidgeon/util/TestRunnerTest.java b/backend/app/src/test/java/com/ugent/pidgeon/util/TestRunnerTest.java new file mode 100644 index 00000000..828a0f7d --- /dev/null +++ b/backend/app/src/test/java/com/ugent/pidgeon/util/TestRunnerTest.java @@ -0,0 +1,155 @@ +package com.ugent.pidgeon.util; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import com.ugent.pidgeon.model.submissionTesting.DockerOutput; +import com.ugent.pidgeon.model.submissionTesting.DockerSubmissionTestModel; +import com.ugent.pidgeon.model.submissionTesting.DockerTemplateTestOutput; +import com.ugent.pidgeon.model.submissionTesting.DockerTestOutput; +import com.ugent.pidgeon.model.submissionTesting.SubmissionTemplateModel; +import com.ugent.pidgeon.model.submissionTesting.SubmissionTemplateModel.SubmissionResult; +import com.ugent.pidgeon.postgre.models.TestEntity; +import java.io.File; +import java.io.IOException; +import java.nio.file.Path; +import java.util.Collections; +import java.util.List; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.zip.ZipFile; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.MockedStatic; +import org.mockito.junit.jupiter.MockitoExtension; + +@ExtendWith(MockitoExtension.class) +public class TestRunnerTest { + + @Mock + private SubmissionTemplateModel structureModel; + @Mock + private DockerSubmissionTestModel dockerModel; + @Mock + private ZipFile file; + @Mock + private File artifactFile; + + + + + private List artifacts; + + private TestEntity testEntity; + private SubmissionResult submissionResult; + private DockerTestOutput dockerTestOutput; + private DockerTemplateTestOutput dockerTemplateTestOutput; + + @BeforeEach + public void setUp() { + testEntity = new TestEntity( + "dockerImageBasic", + "dockerTestScriptBasic", + "dockerTestTemplateBasic", + "structureTemplateBasic" + ); + testEntity.setId(38L); + + submissionResult = new SubmissionResult( + true, "submissionResultBasic" + ); + + dockerTestOutput = new DockerTestOutput( + List.of("logs"), true + ); + + dockerTemplateTestOutput = new DockerTemplateTestOutput( + Collections.emptyList(), true + ); + + artifacts = List.of(artifactFile); + } + + @Test + public void testRunStructureTest() throws IOException { + /* The test exists */ + when(structureModel.checkSubmission(file)).thenReturn(submissionResult); + SubmissionResult result = new TestRunner().runStructureTest(file, testEntity, structureModel); + assertEquals(submissionResult, result); + verify(structureModel).parseSubmissionTemplate(testEntity.getStructureTemplate()); + + /* Structure template is null */ + testEntity.setStructureTemplate(null); + result = new TestRunner().runStructureTest(file, testEntity, structureModel); + assertNull(result); + + /* Test entity is null */ + result = new TestRunner().runStructureTest(file, null, structureModel); + assertNull(result); + } + + @Test + public void testRunDockerTest() throws IOException { + try (MockedStatic filehandler = org.mockito.Mockito.mockStatic(Filehandler.class)) { + Path outputPath = Path.of("outputPath"); + AtomicInteger filehandlerCalled = new AtomicInteger(); + filehandlerCalled.set(0); + filehandler.when(() -> Filehandler.copyFilesAsZip(artifacts, outputPath)).thenAnswer( + invocation -> { + filehandlerCalled.getAndIncrement(); + return null; + }); + when(dockerModel.runSubmissionWithTemplate(testEntity.getDockerTestScript(), testEntity.getDockerTestTemplate())) + .thenReturn(dockerTemplateTestOutput); + when(dockerModel.getArtifacts()).thenReturn(artifacts); + + DockerOutput result = new TestRunner().runDockerTest(file, testEntity, outputPath, dockerModel); + assertEquals(dockerTemplateTestOutput, result); + + verify(dockerModel, times(1)).addZipInputFiles(file); + verify(dockerModel, times(1)).cleanUp(); + assertEquals(1, filehandlerCalled.get()); + + /* artifacts are empty */ + when(dockerModel.getArtifacts()).thenReturn(Collections.emptyList()); + result = new TestRunner().runDockerTest(file, testEntity, outputPath, dockerModel); + assertEquals(dockerTemplateTestOutput, result); + verify(dockerModel, times(2)).addZipInputFiles(file); + verify(dockerModel, times(2)).cleanUp(); + assertEquals(1, filehandlerCalled.get()); + + /* aritifacts are null */ + when(dockerModel.getArtifacts()).thenReturn(null); + result = new TestRunner().runDockerTest(file, testEntity, outputPath, dockerModel); + assertEquals(dockerTemplateTestOutput, result); + verify(dockerModel, times(3)).addZipInputFiles(file); + verify(dockerModel, times(3)).cleanUp(); + assertEquals(1, filehandlerCalled.get()); + + /* No template */ + testEntity.setDockerTestTemplate(null); + when(dockerModel.runSubmission(testEntity.getDockerTestScript())).thenReturn(dockerTestOutput); + result = new TestRunner().runDockerTest(file, testEntity, outputPath, dockerModel); + assertEquals(dockerTestOutput, result); + verify(dockerModel, times(4)).addZipInputFiles(file); + verify(dockerModel, times(4)).cleanUp(); + + /* Error gets thrown */ + when(dockerModel.runSubmission(testEntity.getDockerTestScript())).thenThrow(new RuntimeException("Error")); + assertThrows(Exception.class, () -> new TestRunner().runDockerTest(file, testEntity, outputPath, dockerModel)); + verify(dockerModel, times(5)).cleanUp(); + + /* No script */ + testEntity.setDockerTestScript(null); + result = new TestRunner().runDockerTest(file, testEntity, outputPath, dockerModel); + assertNull(result); + } + + + } +} diff --git a/backend/app/src/test/java/com/ugent/pidgeon/util/TestUtilTest.java b/backend/app/src/test/java/com/ugent/pidgeon/util/TestUtilTest.java index 7ad5e4d3..3f132ec0 100644 --- a/backend/app/src/test/java/com/ugent/pidgeon/util/TestUtilTest.java +++ b/backend/app/src/test/java/com/ugent/pidgeon/util/TestUtilTest.java @@ -1,14 +1,19 @@ package com.ugent.pidgeon.util; +import com.ugent.pidgeon.model.submissionTesting.DockerSubmissionTestModel; import com.ugent.pidgeon.postgre.models.ProjectEntity; import com.ugent.pidgeon.postgre.models.TestEntity; import com.ugent.pidgeon.postgre.models.UserEntity; +import com.ugent.pidgeon.postgre.models.types.UserRole; import com.ugent.pidgeon.postgre.repository.TestRepository; +import java.time.OffsetDateTime; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.InjectMocks; import org.mockito.Mock; +import org.mockito.MockedStatic; +import org.mockito.Spy; import org.mockito.junit.jupiter.MockitoExtension; import org.springframework.http.HttpMethod; import org.springframework.http.HttpStatus; @@ -19,7 +24,9 @@ import static org.junit.jupiter.api.Assertions.*; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.Mockito.doReturn; import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.mockStatic; import static org.mockito.Mockito.when; @ExtendWith(MockitoExtension.class) @@ -31,6 +38,7 @@ public class TestUtilTest { @Mock private ProjectUtil projectUtil; + @Spy @InjectMocks private TestUtil testUtil; @@ -40,59 +48,351 @@ public class TestUtilTest { @BeforeEach public void setUp() { - testEntity = new TestEntity(); - testEntity.setId(1L); - projectEntity = new ProjectEntity(); - projectEntity.setId(1L); - userEntity = new UserEntity(); - userEntity.setId(1L); + projectEntity = new ProjectEntity( + 99L, + "projectName", + "projectDescription", + 2L, + 100L, + true, + 34, + OffsetDateTime.now() + ); + projectEntity.setId(64); + userEntity = new UserEntity( + "name", + "surname", + "email", + UserRole.student, + "azureId" + ); + userEntity.setId(44L); + testEntity = new TestEntity( + "dockerImageBasic", + "dockerTestScriptBasic", + "dockerTestTemplateBasic", + "structureTemplateBasic" + ); + testEntity.setId(38L); } @Test public void testGetTestIfExists() { - when(testRepository.findByProjectId(anyLong())).thenReturn(Optional.of(testEntity)); - assertEquals(testEntity, testUtil.getTestIfExists(1L)); + /* TestEntity exists */ + when(testRepository.findByProjectId(projectEntity.getId())).thenReturn(Optional.of(testEntity)); + assertEquals(testEntity, testUtil.getTestIfExists(projectEntity.getId())); - when(testRepository.findByProjectId(anyLong())).thenReturn(Optional.empty()); - assertNull(testUtil.getTestIfExists(1L)); + /* TestEntity does not exist */ + when(testRepository.findByProjectId(projectEntity.getId())).thenReturn(Optional.empty()); + assertNull(testUtil.getTestIfExists(projectEntity.getId())); } @Test public void testCheckForTestUpdate() { - // Mock the projectUtil.getProjectIfAdmin method to return a CheckResult with HttpStatus.OK - when(projectUtil.getProjectIfAdmin(anyLong(), any(UserEntity.class))) + String dockerImage = "dockerImage"; + String dockerScript = "dockerScript"; + String dockerTemplate = "@dockerTemplate\nExpectedOutput"; + HttpMethod httpMethod = HttpMethod.POST; + + when(projectUtil.getProjectIfAdmin(projectEntity.getId(), userEntity)) .thenReturn(new CheckResult<>(HttpStatus.OK, "", projectEntity)); - // Mock the testRepository.findByProjectId method to return an Optional of testEntity - when(testRepository.findByProjectId(anyLong())).thenReturn(Optional.of(testEntity)); + doReturn(testEntity).when(testUtil).getTestIfExists(projectEntity.getId()); - // Create a mock MultipartFile - MultipartFile mockFile = mock(MultipartFile.class); + try (MockedStatic mockedTestModel = mockStatic(DockerSubmissionTestModel.class)) { + mockedTestModel.when(() -> DockerSubmissionTestModel.imageExists(dockerImage)).thenReturn(true); + mockedTestModel.when(() -> DockerSubmissionTestModel.isValidTemplate(any())).thenReturn(true); + projectEntity.setTestId(null); + CheckResult> result = testUtil.checkForTestUpdate( + projectEntity.getId(), + userEntity, + dockerImage, + dockerScript, + dockerTemplate, + httpMethod + ); + assertEquals(HttpStatus.OK, result.getStatus()); + assertEquals(testEntity, result.getData().getFirst()); + assertEquals(projectEntity, result.getData().getSecond()); - // Call the checkForTestUpdate method - CheckResult> result = testUtil.checkForTestUpdate(1L, - userEntity, "dockerImage", mockFile, mockFile, HttpMethod.POST); + /* TestEntity not found and method is post */ + doReturn(null).when(testUtil).getTestIfExists(projectEntity.getId()); + result = testUtil.checkForTestUpdate( + projectEntity.getId(), + userEntity, + dockerImage, + dockerScript, + dockerTemplate, + HttpMethod.POST + ); + assertEquals(HttpStatus.OK, result.getStatus()); + doReturn(testEntity).when(testUtil).getTestIfExists(projectEntity.getId()); - // Assert the result - assertEquals(HttpStatus.OK, result.getStatus()); - assertEquals(testEntity, result.getData().getFirst()); - assertEquals(projectEntity, result.getData().getSecond()); + + /* Not a valid template */ + when(DockerSubmissionTestModel.isValidTemplate(any())).thenReturn(false); + result = testUtil.checkForTestUpdate( + projectEntity.getId(), + userEntity, + dockerImage, + dockerScript, + dockerTemplate, + httpMethod + ); + assertEquals(HttpStatus.BAD_REQUEST, result.getStatus()); + when(DockerSubmissionTestModel.isValidTemplate(any())).thenReturn(true); + + + /* Method is patch and no template provided */ + projectEntity.setTestId(testEntity.getId()); + httpMethod = HttpMethod.PATCH; + result = testUtil.checkForTestUpdate( + projectEntity.getId(), + userEntity, + dockerImage, + dockerScript, + null, + httpMethod + ); + assertEquals(HttpStatus.OK, result.getStatus()); + + /* Method is patch and script is null while test has a dockerImage */ + testEntity.setDockerTestScript(null); + result = testUtil.checkForTestUpdate( + projectEntity.getId(), + userEntity, + dockerImage, + null, + dockerTemplate, + httpMethod + ); + assertEquals(HttpStatus.BAD_REQUEST, result.getStatus()); + testEntity.setDockerTestScript(dockerScript); + + /* Method is patch and script is null but test already has a script */ + result = testUtil.checkForTestUpdate( + projectEntity.getId(), + userEntity, + dockerImage, + null, + dockerTemplate, + httpMethod + ); + assertEquals(HttpStatus.OK, result.getStatus()); + + /* Method is patch and image is null while test has a dockerScript */ + testEntity.setDockerImage(null); + result = testUtil.checkForTestUpdate( + projectEntity.getId(), + userEntity, + null, + dockerScript, + dockerTemplate, + httpMethod + ); + assertEquals(HttpStatus.BAD_REQUEST, result.getStatus()); + testEntity.setDockerImage(dockerImage); + + /* Method is patch and image is null but test already has an image */ + result = testUtil.checkForTestUpdate( + projectEntity.getId(), + userEntity, + null, + dockerScript, + dockerTemplate, + httpMethod + ); + assertEquals(HttpStatus.OK, result.getStatus()); + + /* Patch method with everything present in request, nothing in test */ + testEntity.setDockerImage(null); + testEntity.setDockerTestScript(null); + result = testUtil.checkForTestUpdate( + projectEntity.getId(), + userEntity, + dockerImage, + dockerScript, + dockerTemplate, + httpMethod + ); + assertEquals(HttpStatus.OK, result.getStatus()); + testEntity.setDockerImage(dockerImage); + testEntity.setDockerTestScript(dockerScript); + + /* Method not patch and template provided without script */ + httpMethod = HttpMethod.PUT; + result = testUtil.checkForTestUpdate( + projectEntity.getId(), + userEntity, + null, + null, + dockerTemplate, + httpMethod + ); + assertEquals(HttpStatus.BAD_REQUEST, result.getStatus()); + + /* Method not patch and no args provided */ + result = testUtil.checkForTestUpdate( + projectEntity.getId(), + userEntity, + null, + null, + null, + httpMethod + ); + assertEquals(HttpStatus.OK, result.getStatus()); + + /* Invalid dockerImage */ + when(DockerSubmissionTestModel.imageExists(dockerImage)).thenReturn(false); + result = testUtil.checkForTestUpdate( + projectEntity.getId(), + userEntity, + dockerImage, + dockerScript, + dockerTemplate, + httpMethod + ); + assertEquals(HttpStatus.BAD_REQUEST, result.getStatus()); + when(DockerSubmissionTestModel.imageExists(dockerImage)).thenReturn(true); + + /* dockerImage without script */ + result = testUtil.checkForTestUpdate( + projectEntity.getId(), + userEntity, + dockerImage, + null, + dockerTemplate, + httpMethod + ); + assertEquals(HttpStatus.BAD_REQUEST, result.getStatus()); + + /* dockerScript without image */ + result = testUtil.checkForTestUpdate( + projectEntity.getId(), + userEntity, + null, + dockerScript, + dockerTemplate, + httpMethod + ); + assertEquals(HttpStatus.BAD_REQUEST, result.getStatus()); + + /* Method is post and test already exists */ + projectEntity.setTestId(99L); + result = testUtil.checkForTestUpdate( + projectEntity.getId(), + userEntity, + dockerImage, + dockerScript, + dockerTemplate, + HttpMethod.POST + ); + assertEquals(HttpStatus.CONFLICT, result.getStatus()); + + /* Method is delete and test is found */ + httpMethod = HttpMethod.DELETE; + result = testUtil.checkForTestUpdate( + projectEntity.getId(), + userEntity, + null, + null, + null, + httpMethod + ); + assertEquals(HttpStatus.OK, result.getStatus()); + + /* TestEntity not found and method is not post */ + doReturn(null).when(testUtil).getTestIfExists(projectEntity.getId()); + result = testUtil.checkForTestUpdate( + projectEntity.getId(), + userEntity, + dockerImage, + dockerScript, + dockerTemplate, + HttpMethod.PATCH + ); + assertEquals(HttpStatus.NOT_FOUND, result.getStatus()); + + + /* Project check fails */ + when(projectUtil.getProjectIfAdmin(projectEntity.getId(), userEntity)) + .thenReturn(new CheckResult<>(HttpStatus.I_AM_A_TEAPOT, "Project not found", null)); + result = testUtil.checkForTestUpdate( + projectEntity.getId(), + userEntity, + dockerImage, + dockerScript, + dockerTemplate, + httpMethod + ); + assertEquals(HttpStatus.I_AM_A_TEAPOT, result.getStatus()); + + } } @Test public void testGetTestIfAdmin() { - // Mock the testRepository.findByProjectId method to return an Optional of testEntity - when(testRepository.findByProjectId(anyLong())).thenReturn(Optional.of(testEntity)); + /* TestEntity exists */ + doReturn(testEntity).when(testUtil).getTestIfExists(projectEntity.getId()); + when(projectUtil.isProjectAdmin(projectEntity.getId(), userEntity)) + .thenReturn(new CheckResult<>(HttpStatus.OK, "", null)); + + CheckResult result = testUtil.getTestIfAdmin(projectEntity.getId(), userEntity); + assertEquals(HttpStatus.OK, result.getStatus()); + + /* User not admin */ + when(projectUtil.isProjectAdmin(projectEntity.getId(), userEntity)) + .thenReturn(new CheckResult<>(HttpStatus.I_AM_A_TEAPOT, "User is not an admin", null)); + result = testUtil.getTestIfAdmin(projectEntity.getId(), userEntity); + assertEquals(HttpStatus.I_AM_A_TEAPOT, result.getStatus()); + + /* TestEntity not found */ + doReturn(null).when(testUtil).getTestIfExists(projectEntity.getId()); + result = testUtil.getTestIfAdmin(projectEntity.getId(), userEntity); + assertEquals(HttpStatus.NOT_FOUND, result.getStatus()); + + } - // Mock the projectUtil.isProjectAdmin method to return a CheckResult with HttpStatus.OK - when(projectUtil.isProjectAdmin(anyLong(), any(UserEntity.class))) + @Test + public void testGetTestWithAdminStatus() { + doReturn(testEntity).when(testUtil).getTestIfExists(projectEntity.getId()); + when(projectUtil.userPartOfProject(projectEntity.getId(), userEntity.getId())).thenReturn(true); + when(projectUtil.isProjectAdmin(projectEntity.getId(), userEntity)) .thenReturn(new CheckResult<>(HttpStatus.OK, "", null)); - // Call the getTestIfAdmin method - CheckResult result = testUtil.getTestIfAdmin(1L, userEntity); + CheckResult> result = testUtil.getTestWithAdminStatus(projectEntity.getId(), userEntity); + assertEquals(HttpStatus.OK, result.getStatus()); + assertTrue(result.getData().getSecond()); - // Assert the result + /* User not admin */ + when(projectUtil.isProjectAdmin(projectEntity.getId(), userEntity)) + .thenReturn(new CheckResult<>(HttpStatus.FORBIDDEN, "User is not an admin", null)); + result = testUtil.getTestWithAdminStatus(projectEntity.getId(), userEntity); assertEquals(HttpStatus.OK, result.getStatus()); - assertEquals(testEntity, result.getData()); + assertFalse(result.getData().getSecond()); + + /* User not admin but general admin */ + userEntity.setRole(UserRole.admin); + result = testUtil.getTestWithAdminStatus(projectEntity.getId(), userEntity); + assertEquals(HttpStatus.OK, result.getStatus()); + assertTrue(result.getData().getSecond()); + + /* Project admin check returns unexpected status */ + when(projectUtil.isProjectAdmin(projectEntity.getId(), userEntity)) + .thenReturn(new CheckResult<>(HttpStatus.I_AM_A_TEAPOT, "Unexpected error", null)); + result = testUtil.getTestWithAdminStatus(projectEntity.getId(), userEntity); + assertEquals(HttpStatus.I_AM_A_TEAPOT, result.getStatus()); + + /* User not part of project */ + when(projectUtil.userPartOfProject(projectEntity.getId(), userEntity.getId())).thenReturn(false); + result = testUtil.getTestWithAdminStatus(projectEntity.getId(), userEntity); + assertEquals(HttpStatus.FORBIDDEN, result.getStatus()); + + /* TestEntity not found */ + doReturn(null).when(testUtil).getTestIfExists(projectEntity.getId()); + result = testUtil.getTestWithAdminStatus(projectEntity.getId(), userEntity); + assertEquals(HttpStatus.NOT_FOUND, result.getStatus()); } + + } \ No newline at end of file diff --git a/backend/app/src/test/java/com/ugent/pidgeon/util/UserUtilTest.java b/backend/app/src/test/java/com/ugent/pidgeon/util/UserUtilTest.java index 4f61e535..bd9a059f 100644 --- a/backend/app/src/test/java/com/ugent/pidgeon/util/UserUtilTest.java +++ b/backend/app/src/test/java/com/ugent/pidgeon/util/UserUtilTest.java @@ -9,6 +9,7 @@ import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.InjectMocks; import org.mockito.Mock; +import org.mockito.Spy; import org.mockito.junit.jupiter.MockitoExtension; import org.springframework.http.HttpStatus; @@ -16,6 +17,7 @@ import static org.junit.jupiter.api.Assertions.*; import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.Mockito.reset; import static org.mockito.Mockito.when; @ExtendWith(MockitoExtension.class) @@ -24,6 +26,7 @@ public class UserUtilTest { @Mock private UserRepository userRepository; + @Spy @InjectMocks private UserUtil userUtil; @@ -32,25 +35,29 @@ public class UserUtilTest { @BeforeEach public void setUp() { user = new UserEntity("name", "surname", "email", UserRole.student, "azureid"); - user.setId(1L); + user.setId(87L); } @Test public void testUserExists() { - when(userRepository.existsById(anyLong())).thenReturn(true); - assertTrue(userUtil.userExists(1L)); + /* The user exists */ + when(userRepository.existsById(user.getId())).thenReturn(true); + assertTrue(userUtil.userExists(user.getId())); - when(userRepository.existsById(anyLong())).thenReturn(false); - assertFalse(userUtil.userExists(1L)); + /* The user does not exist */ + when(userRepository.existsById(user.getId())).thenReturn(false); + assertFalse(userUtil.userExists(user.getId())); } @Test public void testGetUserIfExists() { - when(userRepository.findById(anyLong())).thenReturn(Optional.of(user)); - assertEquals(user, userUtil.getUserIfExists(1L)); + /* The user exists */ + when(userRepository.findById(user.getId())).thenReturn(Optional.of(user)); + assertEquals(user, userUtil.getUserIfExists(user.getId())); - when(userRepository.findById(anyLong())).thenReturn(Optional.empty()); - assertNull(userUtil.getUserIfExists(1L)); + /* The user does not exist */ + when(userRepository.findById(user.getId())).thenReturn(Optional.empty()); + assertNull(userUtil.getUserIfExists(user.getId())); } @Test @@ -61,14 +68,61 @@ public void testCheckForUserUpdateJson() { json.setEmail("newEmail@example.com"); json.setRole("student"); - when(userRepository.findById(anyLong())).thenReturn(Optional.of(user)); - CheckResult result = userUtil.checkForUserUpdateJson(1L, json); + /* All checks succeed */ + when(userRepository.findById(user.getId())).thenReturn(Optional.of(user)); + CheckResult result = userUtil.checkForUserUpdateJson(user.getId(), json); assertEquals(HttpStatus.OK, result.getStatus()); assertEquals(user, result.getData()); + /* Not a valid email */ json.setEmail("invalidEmail"); - result = userUtil.checkForUserUpdateJson(1L, json); + result = userUtil.checkForUserUpdateJson(user.getId(), json); assertEquals(HttpStatus.BAD_REQUEST, result.getStatus()); - assertEquals("Email is not valid", result.getMessage()); + json.setEmail("newEmail@example.com"); + + /* Surname is blank */ + json.setSurname(""); + result = userUtil.checkForUserUpdateJson(user.getId(), json); + assertEquals(HttpStatus.BAD_REQUEST, result.getStatus()); + + /* Name is blank */ + json.setSurname("newSurname"); + json.setName(""); + result = userUtil.checkForUserUpdateJson(user.getId(), json); + assertEquals(HttpStatus.BAD_REQUEST, result.getStatus()); + + /* Role is not valid */ + json.setName("newName"); + json.setRole("invalidRole"); + result = userUtil.checkForUserUpdateJson(user.getId(), json); + assertEquals(HttpStatus.BAD_REQUEST, result.getStatus()); + + /* Role is null */ + json.setRole(null); + result = userUtil.checkForUserUpdateJson(user.getId(), json); + assertEquals(HttpStatus.BAD_REQUEST, result.getStatus()); + + /* Email is null */ + json.setRole("student"); + json.setEmail(null); + result = userUtil.checkForUserUpdateJson(user.getId(), json); + assertEquals(HttpStatus.BAD_REQUEST, result.getStatus()); + + /* Surname is null */ + json.setEmail("email.email@email.email"); + json.setSurname(null); + result = userUtil.checkForUserUpdateJson(user.getId(), json); + assertEquals(HttpStatus.BAD_REQUEST, result.getStatus()); + + /* Name is null */ + json.setSurname("newSurname"); + json.setName(null); + result = userUtil.checkForUserUpdateJson(user.getId(), json); + assertEquals(HttpStatus.BAD_REQUEST, result.getStatus()); + + /* User not found */ + when(userRepository.findById(user.getId())).thenReturn(Optional.empty()); + result = userUtil.checkForUserUpdateJson(user.getId(), json); + assertEquals(HttpStatus.NOT_FOUND, result.getStatus()); } } \ No newline at end of file diff --git a/backend/app/src/test/test-cases/DockerSubmissionTestTest/d__test.zip b/backend/app/src/test/test-cases/DockerSubmissionTestTest/d__test.zip new file mode 100644 index 00000000..4e389ca3 Binary files /dev/null and b/backend/app/src/test/test-cases/DockerSubmissionTestTest/d__test.zip differ diff --git a/backend/app/src/test/test-cases/FilehandlerTestFiles/Testfile.zip b/backend/app/src/test/test-cases/FilehandlerTestFiles/Testfile.zip new file mode 100644 index 00000000..942dc031 Binary files /dev/null and b/backend/app/src/test/test-cases/FilehandlerTestFiles/Testfile.zip differ diff --git a/backend/database/populate_database.sql b/backend/database/populate_database.sql index 9adfb6e0..f0e1d506 100644 --- a/backend/database/populate_database.sql +++ b/backend/database/populate_database.sql @@ -11,11 +11,11 @@ INSERT INTO users (name, surname, email, azure_id, role) VALUES -- Inserting into `courses` INSERT INTO courses (course_id,course_name, description, course_year) VALUES - (1,'Math 101', 'Introduction to Mathematics',2023), - (2,'Science 101', 'Basics of Scientific Method',2023), - (3,'History 101', 'World History Overview',2023), - (4,'Computer Science 101', 'Introduction to Computing',2023), - (5,'English 101', 'English Literature',2023); + (1,'Math 101', 'Introduction to Mathematics',2023), + (2,'Science 101', 'Basics of Scientific Method',2023), + (3,'History 101', 'World History Overview',2023), + (4,'Computer Science 101', 'Introduction to Computing',2023), + (5,'English 101', 'English Literature',2023); -- Inserting into `course_users` -- Assume course_id and user_id start from 1 and match accordingly @@ -53,15 +53,6 @@ INSERT INTO files (file_path, file_name, uploaded_by) VALUES ('/path/to/file22', 'file22.txt', 3); --- Assume tests are created before projects for foreign key constraints --- Inserting into `tests` -INSERT INTO tests (docker_image, docker_test, structure_test_id) VALUES - ('docker/image1', 16, 17), - ('docker/image2', 5, 6), - ('docker/image3', 8, 9), - ('docker/image4', 12, 13), - ('docker/image5', 14, 15); - -- Inserting into `group_clusters` INSERT INTO group_clusters (course_id, cluster_name, max_size, group_amount) VALUES (1, 'Project: priemgetallen', 4, 20), @@ -93,8 +84,8 @@ INSERT INTO group_users (group_id, user_id) VALUES -- Linking solutions to projects and groups INSERT INTO projects (course_id, test_id, project_name, description, group_cluster_id, max_score, deadline) VALUES - (1, 1, 'Math project 1', 'Solve equations', 1, 20, '2024-03-20 09:00:00+02'), - (2, 2, 'Science Lab 1', 'Aparte reeks met enkel de opdracht-oefening zodat de interface van Dodona de deadline duidelijk maakt. Deze opdracht komt uit de hoofdstuk 5 en de eerdere oefeningen uit die reeks zullen je helpen tot een juiste oplossing te komen. + (1, null, 'Math project 1', 'Solve equations', 1, 20, '2024-03-20 09:00:00+02'), + (2, null, 'Science Lab 1', 'Aparte reeks met enkel de opdracht-oefening zodat de interface van Dodona de deadline duidelijk maakt. Deze opdracht komt uit de hoofdstuk 5 en de eerdere oefeningen uit die reeks zullen je helpen tot een juiste oplossing te komen. Jullie oplossing wordt geëvalueerd op basis van uitvoeringstijd (**50%**), geheugengebruik (25%) en codestijl (25%). @@ -140,9 +131,9 @@ def global_multiple_alignment(infile: str | Path, output: str | Path | None = No 212 156 65 -16 52 23 67 155 96 0 ```', 2, 6, '2024-06-22 12:00:00+02'), - (3, 3, 'History Essay 1', 'Discuss historical event', 3, NULL, '2024-03-22 12:00:00+02'), - (4, 4, 'Programming Assignment 1', 'Write code', 4, 4, '2024-03-23 14:45:00+02'), - (5, 5, 'Literature Analysis', 'Analyze text', 5, 10, '2024-03-24 10:00:00+02'); + (3, null, 'History Essay 1', 'Discuss historical event', 3, NULL, '2024-03-22 12:00:00+02'), + (4, null, 'Programming Assignment 1', 'Write code', 4, 4, '2024-03-23 14:45:00+02'), + (5, null, 'Literature Analysis', 'Analyze text', 5, 10, '2024-03-24 10:00:00+02'); -- Inserting into `group_grades` @@ -162,12 +153,14 @@ INSERT INTO submissions ( structure_accepted, docker_accepted, structure_feedback, - docker_feedback + docker_feedback, + docker_test_state, + docker_type ) VALUES - (1, 1, 1, true, true, NULL, NULL), - (2, 2, 2, false, true, 'ERROR: .....', NULL), - (3, 3, 3, true, false, NULL, 'Docker configuration needs improvement'), - (4, 4, 4, false, false, 'Structure needs improvement', 'Docker configuration needs improvement'); + (1, 1, 1, true, true, NULL, NULL, 'finished', 'simple'), + (2, 2, 2, false, true, 'ERROR: .....', NULL, 'finished', 'simple'), + (3, 3, 3, true, false, NULL, 'Docker configuration needs improvement', 'finished', 'simple'), + (4, 4, 4, false, false, 'Structure needs improvement', 'Docker configuration needs improvement', 'finished', 'simple'); diff --git a/backend/database/start_database.sql b/backend/database/start_database.sql index 1e076839..94cf30e6 100644 --- a/backend/database/start_database.sql +++ b/backend/database/start_database.sql @@ -3,57 +3,60 @@ CREATE SCHEMA public; -- Users table to store information about users CREATE TABLE users ( - user_id SERIAL PRIMARY KEY, - name VARCHAR(50) NOT NULL, - surname VARCHAR(50) NOT NULL, - email VARCHAR(100) UNIQUE NOT NULL, - azure_id VARCHAR(255) NOT NULL, - role VARCHAR(50) NOT NULL, - created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP + user_id SERIAL PRIMARY KEY, + name VARCHAR(50) NOT NULL, + surname VARCHAR(50) NOT NULL, + email VARCHAR(100) UNIQUE NOT NULL, + azure_id VARCHAR(255) NOT NULL, + role VARCHAR(50) NOT NULL, + created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP ); -- Courses table to store information about courses CREATE TABLE courses ( - course_id SERIAL PRIMARY KEY, - course_name VARCHAR(100) NOT NULL, - description TEXT, - course_year INTEGER, - created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP, - archived_at TIMESTAMP WITH TIME ZONE DEFAULT NULL, - join_key TEXT + course_id SERIAL PRIMARY KEY, + course_name VARCHAR(100) NOT NULL, + description TEXT, + course_year INTEGER, + created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP, + archived_at TIMESTAMP WITH TIME ZONE DEFAULT NULL, + join_key TEXT ); -- Linking table to associate users with courses and define their role in the course CREATE TABLE course_users ( - course_id INT REFERENCES courses(course_id), - user_id INT REFERENCES users(user_id), - course_relation VARCHAR(50) NOT NULL, - PRIMARY KEY (course_id, user_id) + course_id INT REFERENCES courses(course_id), + user_id INT REFERENCES users(user_id), + course_relation VARCHAR(50) NOT NULL, + PRIMARY KEY (course_id, user_id) ); CREATE TABLE group_clusters ( - group_cluster_id SERIAL PRIMARY KEY, - course_id INT REFERENCES courses(course_id), - max_size INT NOT NULL, - cluster_name VARCHAR(100) NOT NULL, - group_amount INT NOT NULL, - created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP + group_cluster_id SERIAL PRIMARY KEY, + course_id INT REFERENCES courses(course_id), + max_size INT NOT NULL, + cluster_name VARCHAR(100) NOT NULL, + group_amount INT NOT NULL, + created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP ); -- Files table to store file information CREATE TABLE files ( - file_id SERIAL PRIMARY KEY, - file_path VARCHAR(512) NOT NULL, - file_name VARCHAR(512) NOT NULL, - uploaded_by INT REFERENCES users(user_id) + file_id SERIAL PRIMARY KEY, + file_path VARCHAR(512) NOT NULL, + file_name VARCHAR(512) NOT NULL, + uploaded_by INT REFERENCES users(user_id) ); -- A id for the docker test and an id for the file test id +-- docker test is enabled if script is not null +-- docker test is in simple mode if template is null CREATE TABLE tests ( - test_id SERIAL PRIMARY KEY, - docker_image VARCHAR(256), - docker_test INT REFERENCES files(file_id), - structure_test_id INT REFERENCES files(file_id) + test_id SERIAL PRIMARY KEY, + docker_image VARCHAR(256), + docker_test_script TEXT, + docker_test_template TEXT, + structure_template TEXT ); @@ -62,33 +65,33 @@ CREATE TABLE tests ( -- test_id points to internal test, possibly a docker or a file structure test. CREATE TABLE projects ( - project_id SERIAL PRIMARY KEY, - course_id INT REFERENCES courses(course_id), - project_name VARCHAR(100) NOT NULL, - description TEXT, - group_cluster_id INT REFERENCES group_clusters(group_cluster_id), - deadline TIMESTAMP WITH TIME ZONE NOT NULL, - test_id INT REFERENCES tests(test_id), - visible BOOLEAN DEFAULT false NOT NULL, - max_score INT + project_id SERIAL PRIMARY KEY, + course_id INT REFERENCES courses(course_id), + project_name VARCHAR(100) NOT NULL, + description TEXT, + group_cluster_id INT REFERENCES group_clusters(group_cluster_id), + deadline TIMESTAMP WITH TIME ZONE NOT NULL, + test_id INT REFERENCES tests(test_id), + visible BOOLEAN DEFAULT false NOT NULL, + max_score INT ); -- Groups table to manage groups of students CREATE TABLE groups ( - group_id SERIAL PRIMARY KEY, - group_name VARCHAR(100) NOT NULL, - group_cluster INT REFERENCES group_clusters(group_cluster_id) + group_id SERIAL PRIMARY KEY, + group_name VARCHAR(100) NOT NULL, + group_cluster INT REFERENCES group_clusters(group_cluster_id) ); -- Group grades table to store grades for groups in projects CREATE TABLE group_feedback ( - group_id INT REFERENCES groups(group_id), - project_id INT REFERENCES projects(project_id), - grade FLOAT, - feedback TEXT, - PRIMARY KEY (group_id, project_id) + group_id INT REFERENCES groups(group_id), + project_id INT REFERENCES projects(project_id), + grade FLOAT, + feedback TEXT, + PRIMARY KEY (group_id, project_id) ); @@ -97,15 +100,17 @@ CREATE TABLE group_feedback ( -- Solo projects are done with group clusters with 1 person. CREATE TABLE submissions ( - submission_id SERIAL PRIMARY KEY, - project_id INT REFERENCES projects(project_id), - group_id INT REFERENCES groups(group_id), - file_id INT REFERENCES files(file_id), - structure_accepted BOOLEAN NOT NULL, - docker_accepted BOOLEAN NOT NULL, - structure_feedback TEXT, - docker_feedback TEXT, - submission_time TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP + submission_id SERIAL PRIMARY KEY, + project_id INT REFERENCES projects(project_id), + group_id INT REFERENCES groups(group_id), + file_id INT REFERENCES files(file_id), + structure_accepted BOOLEAN NOT NULL, + docker_accepted BOOLEAN NOT NULL, + structure_feedback TEXT, + docker_feedback TEXT, + docker_test_state VARCHAR(10) DEFAULT 'running', + docker_type VARCHAR(10) DEFAULT 'simple', + submission_time TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP ); @@ -114,7 +119,7 @@ CREATE TABLE submissions ( -- Linking table to associate users with groups CREATE TABLE group_users ( - group_id INT REFERENCES groups(group_id), - user_id INT REFERENCES users(user_id), - PRIMARY KEY (group_id, user_id) + group_id INT REFERENCES groups(group_id), + user_id INT REFERENCES users(user_id), + PRIMARY KEY (group_id, user_id) ); diff --git a/frontend/.gitignore b/frontend/.gitignore index 98497d56..a851d9f2 100644 --- a/frontend/.gitignore +++ b/frontend/.gitignore @@ -5,6 +5,7 @@ /.pnp .pnp.js package-lock.json +/package-lock.json # testing /coverage diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 71f887d7..323dd4dc 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -14,6 +14,7 @@ "@babel/plugin-proposal-private-property-in-object": "^7.21.11", "@fontsource/jetbrains-mono": "^5.0.19", "@fontsource/roboto-mono": "^5.0.17", + "@mdx-js/react": "^3.0.1", "@testing-library/jest-dom": "^5.17.0", "@testing-library/user-event": "^13.5.0", "@types/jest": "^27.5.2", @@ -231,19 +232,6 @@ "node": ">=6.9.0" } }, - "node_modules/@babel/generator/node_modules/@babel/types": { - "version": "7.24.5", - "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.24.5.tgz", - "integrity": "sha512-6mQNsaLeXTw0nxYUYu+NSa4Hx4BlF1x1x8/PMFbiR+GBSr+2DkECc69b8hgy2frEodNcvPffeH8YfWd3LI6jhQ==", - "dependencies": { - "@babel/helper-string-parser": "^7.24.1", - "@babel/helper-validator-identifier": "^7.24.5", - "to-fast-properties": "^2.0.0" - }, - "engines": { - "node": ">=6.9.0" - } - }, "node_modules/@babel/helper-annotate-as-pure": { "version": "7.22.5", "resolved": "https://registry.npmjs.org/@babel/helper-annotate-as-pure/-/helper-annotate-as-pure-7.22.5.tgz", @@ -279,18 +267,18 @@ } }, "node_modules/@babel/helper-create-class-features-plugin": { - "version": "7.23.10", - "resolved": "https://registry.npmjs.org/@babel/helper-create-class-features-plugin/-/helper-create-class-features-plugin-7.23.10.tgz", - "integrity": "sha512-2XpP2XhkXzgxecPNEEK8Vz8Asj9aRxt08oKOqtiZoqV2UGZ5T+EkyP9sXQ9nwMxBIG34a7jmasVqoMop7VdPUw==", + "version": "7.24.5", + "resolved": "https://registry.npmjs.org/@babel/helper-create-class-features-plugin/-/helper-create-class-features-plugin-7.24.5.tgz", + "integrity": "sha512-uRc4Cv8UQWnE4NXlYTIIdM7wfFkOqlFztcC/gVXDKohKoVB3OyonfelUBaJzSwpBntZ2KYGF/9S7asCHsXwW6g==", "dependencies": { "@babel/helper-annotate-as-pure": "^7.22.5", "@babel/helper-environment-visitor": "^7.22.20", "@babel/helper-function-name": "^7.23.0", - "@babel/helper-member-expression-to-functions": "^7.23.0", + "@babel/helper-member-expression-to-functions": "^7.24.5", "@babel/helper-optimise-call-expression": "^7.22.5", - "@babel/helper-replace-supers": "^7.22.20", + "@babel/helper-replace-supers": "^7.24.1", "@babel/helper-skip-transparent-expression-wrappers": "^7.22.5", - "@babel/helper-split-export-declaration": "^7.22.6", + "@babel/helper-split-export-declaration": "^7.24.5", "semver": "^6.3.1" }, "engines": { @@ -300,14 +288,6 @@ "@babel/core": "^7.0.0" } }, - "node_modules/@babel/helper-create-class-features-plugin/node_modules/semver": { - "version": "6.3.1", - "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", - "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", - "bin": { - "semver": "bin/semver.js" - } - }, "node_modules/@babel/helper-environment-visitor": { "version": "7.22.20", "resolved": "https://registry.npmjs.org/@babel/helper-environment-visitor/-/helper-environment-visitor-7.22.20.tgz", @@ -350,19 +330,6 @@ "node": ">=6.9.0" } }, - "node_modules/@babel/helper-member-expression-to-functions/node_modules/@babel/types": { - "version": "7.24.5", - "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.24.5.tgz", - "integrity": "sha512-6mQNsaLeXTw0nxYUYu+NSa4Hx4BlF1x1x8/PMFbiR+GBSr+2DkECc69b8hgy2frEodNcvPffeH8YfWd3LI6jhQ==", - "dependencies": { - "@babel/helper-string-parser": "^7.24.1", - "@babel/helper-validator-identifier": "^7.24.5", - "to-fast-properties": "^2.0.0" - }, - "engines": { - "node": ">=6.9.0" - } - }, "node_modules/@babel/helper-module-imports": { "version": "7.24.3", "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.24.3.tgz", @@ -374,19 +341,6 @@ "node": ">=6.9.0" } }, - "node_modules/@babel/helper-module-imports/node_modules/@babel/types": { - "version": "7.24.5", - "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.24.5.tgz", - "integrity": "sha512-6mQNsaLeXTw0nxYUYu+NSa4Hx4BlF1x1x8/PMFbiR+GBSr+2DkECc69b8hgy2frEodNcvPffeH8YfWd3LI6jhQ==", - "dependencies": { - "@babel/helper-string-parser": "^7.24.1", - "@babel/helper-validator-identifier": "^7.24.5", - "to-fast-properties": "^2.0.0" - }, - "engines": { - "node": ">=6.9.0" - } - }, "node_modules/@babel/helper-module-transforms": { "version": "7.24.5", "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.24.5.tgz", @@ -405,30 +359,6 @@ "@babel/core": "^7.0.0" } }, - "node_modules/@babel/helper-module-transforms/node_modules/@babel/helper-split-export-declaration": { - "version": "7.24.5", - "resolved": "https://registry.npmjs.org/@babel/helper-split-export-declaration/-/helper-split-export-declaration-7.24.5.tgz", - "integrity": "sha512-5CHncttXohrHk8GWOFCcCl4oRD9fKosWlIRgWm4ql9VYioKm52Mk2xsmoohvm7f3JoiLSM5ZgJuRaf5QZZYd3Q==", - "dependencies": { - "@babel/types": "^7.24.5" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-module-transforms/node_modules/@babel/types": { - "version": "7.24.5", - "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.24.5.tgz", - "integrity": "sha512-6mQNsaLeXTw0nxYUYu+NSa4Hx4BlF1x1x8/PMFbiR+GBSr+2DkECc69b8hgy2frEodNcvPffeH8YfWd3LI6jhQ==", - "dependencies": { - "@babel/helper-string-parser": "^7.24.1", - "@babel/helper-validator-identifier": "^7.24.5", - "to-fast-properties": "^2.0.0" - }, - "engines": { - "node": ">=6.9.0" - } - }, "node_modules/@babel/helper-optimise-call-expression": { "version": "7.22.5", "resolved": "https://registry.npmjs.org/@babel/helper-optimise-call-expression/-/helper-optimise-call-expression-7.22.5.tgz", @@ -441,9 +371,9 @@ } }, "node_modules/@babel/helper-plugin-utils": { - "version": "7.24.0", - "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.24.0.tgz", - "integrity": "sha512-9cUznXMG0+FxRuJfvL82QlTqIzhVW9sL0KjMPHhAOOvpQGL8QtdxnBKILjBqxlHyliz0yCa1G903ZXI/FuHy2w==", + "version": "7.24.5", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.24.5.tgz", + "integrity": "sha512-xjNLDopRzW2o6ba0gKbkZq5YWEBaK3PCyTOY1K2P/O07LGMhMqlMXPxwN4S5/RhWuCobT8z0jrlKGlYmeR1OhQ==", "engines": { "node": ">=6.9.0" } @@ -475,19 +405,6 @@ "node": ">=6.9.0" } }, - "node_modules/@babel/helper-simple-access/node_modules/@babel/types": { - "version": "7.24.5", - "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.24.5.tgz", - "integrity": "sha512-6mQNsaLeXTw0nxYUYu+NSa4Hx4BlF1x1x8/PMFbiR+GBSr+2DkECc69b8hgy2frEodNcvPffeH8YfWd3LI6jhQ==", - "dependencies": { - "@babel/helper-string-parser": "^7.24.1", - "@babel/helper-validator-identifier": "^7.24.5", - "to-fast-properties": "^2.0.0" - }, - "engines": { - "node": ">=6.9.0" - } - }, "node_modules/@babel/helper-skip-transparent-expression-wrappers": { "version": "7.22.5", "resolved": "https://registry.npmjs.org/@babel/helper-skip-transparent-expression-wrappers/-/helper-skip-transparent-expression-wrappers-7.22.5.tgz", @@ -500,11 +417,11 @@ } }, "node_modules/@babel/helper-split-export-declaration": { - "version": "7.22.6", - "resolved": "https://registry.npmjs.org/@babel/helper-split-export-declaration/-/helper-split-export-declaration-7.22.6.tgz", - "integrity": "sha512-AsUnxuLhRYsisFiaJwvp1QF+I3KjD5FOxut14q/GzovUe6orHLesW2C7d754kRm53h5gqrz6sFl6sxc4BVtE/g==", + "version": "7.24.5", + "resolved": "https://registry.npmjs.org/@babel/helper-split-export-declaration/-/helper-split-export-declaration-7.24.5.tgz", + "integrity": "sha512-5CHncttXohrHk8GWOFCcCl4oRD9fKosWlIRgWm4ql9VYioKm52Mk2xsmoohvm7f3JoiLSM5ZgJuRaf5QZZYd3Q==", "dependencies": { - "@babel/types": "^7.22.5" + "@babel/types": "^7.24.5" }, "engines": { "node": ">=6.9.0" @@ -547,43 +464,6 @@ "node": ">=6.9.0" } }, - "node_modules/@babel/helpers/node_modules/@babel/parser": { - "version": "7.24.5", - "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.24.5.tgz", - "integrity": "sha512-EOv5IK8arwh3LI47dz1b0tKUb/1uhHAnHJOrjgtQMIpu1uXd9mlFrJg9IUgGUgZ41Ch0K8REPTYpO7B76b4vJg==", - "bin": { - "parser": "bin/babel-parser.js" - }, - "engines": { - "node": ">=6.0.0" - } - }, - "node_modules/@babel/helpers/node_modules/@babel/template": { - "version": "7.24.0", - "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.24.0.tgz", - "integrity": "sha512-Bkf2q8lMB0AFpX0NFEqSbx1OkTHf0f+0j82mkw+ZpzBnkk7e9Ql0891vlfgi+kHwOk8tQjiQHpqh4LaSa0fKEA==", - "dependencies": { - "@babel/code-frame": "^7.23.5", - "@babel/parser": "^7.24.0", - "@babel/types": "^7.24.0" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helpers/node_modules/@babel/types": { - "version": "7.24.5", - "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.24.5.tgz", - "integrity": "sha512-6mQNsaLeXTw0nxYUYu+NSa4Hx4BlF1x1x8/PMFbiR+GBSr+2DkECc69b8hgy2frEodNcvPffeH8YfWd3LI6jhQ==", - "dependencies": { - "@babel/helper-string-parser": "^7.24.1", - "@babel/helper-validator-identifier": "^7.24.5", - "to-fast-properties": "^2.0.0" - }, - "engines": { - "node": ">=6.9.0" - } - }, "node_modules/@babel/highlight": { "version": "7.24.5", "resolved": "https://registry.npmjs.org/@babel/highlight/-/highlight-7.24.5.tgz", @@ -599,9 +479,9 @@ } }, "node_modules/@babel/parser": { - "version": "7.23.9", - "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.23.9.tgz", - "integrity": "sha512-9tcKgqKbs3xGJ+NtKF2ndOBBLVwPjl1SHxPQkd36r3Dlirw3xWUeGaTbqr7uGZcTaxkVNwc+03SVP7aCdWrTlA==", + "version": "7.24.5", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.24.5.tgz", + "integrity": "sha512-EOv5IK8arwh3LI47dz1b0tKUb/1uhHAnHJOrjgtQMIpu1uXd9mlFrJg9IUgGUgZ41Ch0K8REPTYpO7B76b4vJg==", "bin": { "parser": "bin/babel-parser.js" }, @@ -857,13 +737,13 @@ } }, "node_modules/@babel/template": { - "version": "7.23.9", - "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.23.9.tgz", - "integrity": "sha512-+xrD2BWLpvHKNmX2QbpdpsBaWnRxahMwJjO+KZk2JOElj5nSmKezyS1B4u+QbHMTX69t4ukm6hh9lsYQ7GHCKA==", + "version": "7.24.0", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.24.0.tgz", + "integrity": "sha512-Bkf2q8lMB0AFpX0NFEqSbx1OkTHf0f+0j82mkw+ZpzBnkk7e9Ql0891vlfgi+kHwOk8tQjiQHpqh4LaSa0fKEA==", "dependencies": { "@babel/code-frame": "^7.23.5", - "@babel/parser": "^7.23.9", - "@babel/types": "^7.23.9" + "@babel/parser": "^7.24.0", + "@babel/types": "^7.24.0" }, "engines": { "node": ">=6.9.0" @@ -901,29 +781,7 @@ "node": ">=6.9.0" } }, - "node_modules/@babel/traverse/node_modules/@babel/helper-split-export-declaration": { - "version": "7.24.5", - "resolved": "https://registry.npmjs.org/@babel/helper-split-export-declaration/-/helper-split-export-declaration-7.24.5.tgz", - "integrity": "sha512-5CHncttXohrHk8GWOFCcCl4oRD9fKosWlIRgWm4ql9VYioKm52Mk2xsmoohvm7f3JoiLSM5ZgJuRaf5QZZYd3Q==", - "dependencies": { - "@babel/types": "^7.24.5" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/traverse/node_modules/@babel/parser": { - "version": "7.24.5", - "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.24.5.tgz", - "integrity": "sha512-EOv5IK8arwh3LI47dz1b0tKUb/1uhHAnHJOrjgtQMIpu1uXd9mlFrJg9IUgGUgZ41Ch0K8REPTYpO7B76b4vJg==", - "bin": { - "parser": "bin/babel-parser.js" - }, - "engines": { - "node": ">=6.0.0" - } - }, - "node_modules/@babel/traverse/node_modules/@babel/types": { + "node_modules/@babel/types": { "version": "7.24.5", "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.24.5.tgz", "integrity": "sha512-6mQNsaLeXTw0nxYUYu+NSa4Hx4BlF1x1x8/PMFbiR+GBSr+2DkECc69b8hgy2frEodNcvPffeH8YfWd3LI6jhQ==", @@ -936,19 +794,6 @@ "node": ">=6.9.0" } }, - "node_modules/@babel/types": { - "version": "7.23.9", - "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.23.9.tgz", - "integrity": "sha512-dQjSq/7HaSjRM43FFGnv5keM2HsxpmyV1PfaSVm0nzzjwwTmjOe6J4bC8e3+pTEIgHaHj+1ZlLThRJ2auc/w1Q==", - "dependencies": { - "@babel/helper-string-parser": "^7.23.4", - "@babel/helper-validator-identifier": "^7.22.20", - "to-fast-properties": "^2.0.0" - }, - "engines": { - "node": ">=6.9.0" - } - }, "node_modules/@bcoe/v8-coverage": { "version": "0.2.3", "resolved": "https://registry.npmjs.org/@bcoe/v8-coverage/-/v8-coverage-0.2.3.tgz", @@ -973,402 +818,400 @@ "resolved": "https://registry.npmjs.org/@emotion/unitless/-/unitless-0.7.5.tgz", "integrity": "sha512-OWORNpfjMsSSUBVrRBVGECkhWcULOAJz9ZW8uK9qgxD+87M7jHRcvh/A96XXNhXTLmKcoYSQtBEX7lHMO7YRwg==" }, - "node_modules/@esbuild/aix-ppc64": { - "version": "0.19.12", - "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.19.12.tgz", - "integrity": "sha512-bmoCYyWdEL3wDQIVbcyzRyeKLgk2WtWLTWz1ZIAZF/EGbNOwSA6ew3PftJ1PqMiOOGu0OyFMzG53L0zqIpPeNA==", - "cpu": [ - "ppc64" - ], - "optional": true, - "os": [ - "aix" - ], - "engines": { - "node": ">=12" - } + "node_modules/@fontsource/jetbrains-mono": { + "version": "5.0.19", + "integrity": "sha512-SdwUuvdfuAvGWRRc4LOFRSmDrpkE+vFUpCtOIOUl1PpXdLfeU//93BZiGf7j/oFGSZJbHAurfux2uLT38/NIjw==" }, - "node_modules/@esbuild/android-arm": { - "version": "0.19.12", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.19.12.tgz", - "integrity": "sha512-qg/Lj1mu3CdQlDEEiWrlC4eaPZ1KztwGJ9B6J+/6G+/4ewxJg7gqj8eVYWvao1bXrqGiW2rsBZFSX3q2lcW05w==", - "cpu": [ - "arm" - ], - "optional": true, - "os": [ - "android" - ], + "node_modules/@fontsource/roboto-mono": { + "version": "5.0.17", + "integrity": "sha512-MU6FrAyG7DWMCL8mu0JDPvB2tnFcn/lYvVKixzqHb2uefRsLaD6OBFfF1q5RMFsKcFHyPySHM7ZcGw/Q6A1/FA==" + }, + "node_modules/@istanbuljs/load-nyc-config": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@istanbuljs/load-nyc-config/-/load-nyc-config-1.1.0.tgz", + "integrity": "sha512-VjeHSlIzpv/NyD3N0YuHfXOPDIixcA1q2ZV98wsMqcYlPmv2n3Yb2lYP9XMElnaFVXg5A7YLTeLu6V84uQDjmQ==", + "dev": true, + "dependencies": { + "camelcase": "^5.3.1", + "find-up": "^4.1.0", + "get-package-type": "^0.1.0", + "js-yaml": "^3.13.1", + "resolve-from": "^5.0.0" + }, "engines": { - "node": ">=12" + "node": ">=8" } }, - "node_modules/@esbuild/android-arm64": { - "version": "0.19.12", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.19.12.tgz", - "integrity": "sha512-P0UVNGIienjZv3f5zq0DP3Nt2IE/3plFzuaS96vihvD0Hd6H/q4WXUGpCxD/E8YrSXfNyRPbpTq+T8ZQioSuPA==", - "cpu": [ - "arm64" - ], - "optional": true, - "os": [ - "android" - ], + "node_modules/@istanbuljs/schema": { + "version": "0.1.3", + "resolved": "https://registry.npmjs.org/@istanbuljs/schema/-/schema-0.1.3.tgz", + "integrity": "sha512-ZXRY4jNvVgSVQ8DL3LTcakaAtXwTVUxE81hslsyD2AtoXW/wVob10HkOJ1X/pAlcI7D+2YoZKg5do8G/w6RYgA==", + "dev": true, "engines": { - "node": ">=12" + "node": ">=8" } }, - "node_modules/@esbuild/android-x64": { - "version": "0.19.12", - "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.19.12.tgz", - "integrity": "sha512-3k7ZoUW6Q6YqhdhIaq/WZ7HwBpnFBlW905Fa4s4qWJyiNOgT1dOqDiVAQFwBH7gBRZr17gLrlFCRzF6jFh7Kew==", - "cpu": [ - "x64" - ], - "optional": true, - "os": [ - "android" - ], + "node_modules/@jest/console": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/@jest/console/-/console-29.7.0.tgz", + "integrity": "sha512-5Ni4CU7XHQi32IJ398EEP4RrB8eV09sXP2ROqD4bksHrnTree52PsxvX8tpL8LvTZ3pFzXyPbNQReSN41CAhOg==", + "dev": true, + "dependencies": { + "@jest/types": "^29.6.3", + "@types/node": "*", + "chalk": "^4.0.0", + "jest-message-util": "^29.7.0", + "jest-util": "^29.7.0", + "slash": "^3.0.0" + }, "engines": { - "node": ">=12" + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" } }, - "node_modules/@esbuild/darwin-arm64": { - "version": "0.19.12", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.19.12.tgz", - "integrity": "sha512-B6IeSgZgtEzGC42jsI+YYu9Z3HKRxp8ZT3cqhvliEHovq8HSX2YX8lNocDn79gCKJXOSaEot9MVYky7AKjCs8g==", - "cpu": [ - "arm64" - ], - "optional": true, - "os": [ - "darwin" - ], + "node_modules/@jest/console/node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "dependencies": { + "color-convert": "^2.0.1" + }, "engines": { - "node": ">=12" + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, - "node_modules/@esbuild/darwin-x64": { - "version": "0.19.12", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.19.12.tgz", - "integrity": "sha512-hKoVkKzFiToTgn+41qGhsUJXFlIjxI/jSYeZf3ugemDYZldIXIxhvwN6erJGlX4t5h417iFuheZ7l+YVn05N3A==", - "cpu": [ - "x64" - ], - "optional": true, - "os": [ - "darwin" - ], + "node_modules/@jest/console/node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, "engines": { - "node": ">=12" + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" } }, - "node_modules/@esbuild/freebsd-arm64": { - "version": "0.19.12", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.19.12.tgz", - "integrity": "sha512-4aRvFIXmwAcDBw9AueDQ2YnGmz5L6obe5kmPT8Vd+/+x/JMVKCgdcRwH6APrbpNXsPz+K653Qg8HB/oXvXVukA==", - "cpu": [ - "arm64" - ], - "optional": true, - "os": [ - "freebsd" - ], + "node_modules/@jest/console/node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "dependencies": { + "color-name": "~1.1.4" + }, "engines": { - "node": ">=12" + "node": ">=7.0.0" } }, - "node_modules/@esbuild/freebsd-x64": { - "version": "0.19.12", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.19.12.tgz", - "integrity": "sha512-EYoXZ4d8xtBoVN7CEwWY2IN4ho76xjYXqSXMNccFSx2lgqOG/1TBPW0yPx1bJZk94qu3tX0fycJeeQsKovA8gg==", - "cpu": [ - "x64" - ], - "optional": true, - "os": [ - "freebsd" - ], + "node_modules/@jest/console/node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true + }, + "node_modules/@jest/console/node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, "engines": { - "node": ">=12" + "node": ">=8" } }, - "node_modules/@esbuild/linux-arm": { - "version": "0.19.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.19.12.tgz", - "integrity": "sha512-J5jPms//KhSNv+LO1S1TX1UWp1ucM6N6XuL6ITdKWElCu8wXP72l9MM0zDTzzeikVyqFE6U8YAV9/tFyj0ti+w==", - "cpu": [ - "arm" - ], - "optional": true, - "os": [ - "linux" - ], + "node_modules/@jest/console/node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "dependencies": { + "has-flag": "^4.0.0" + }, "engines": { - "node": ">=12" + "node": ">=8" } }, - "node_modules/@esbuild/linux-arm64": { - "version": "0.19.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.19.12.tgz", - "integrity": "sha512-EoTjyYyLuVPfdPLsGVVVC8a0p1BFFvtpQDB/YLEhaXyf/5bczaGeN15QkR+O4S5LeJ92Tqotve7i1jn35qwvdA==", - "cpu": [ - "arm64" - ], - "optional": true, - "os": [ - "linux" - ], + "node_modules/@jest/core": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/@jest/core/-/core-29.7.0.tgz", + "integrity": "sha512-n7aeXWKMnGtDA48y8TLWJPJmLmmZ642Ceo78cYWEpiD7FzDgmNDV/GCVRorPABdXLJZ/9wzzgZAlHjXjxDHGsg==", + "dev": true, + "dependencies": { + "@jest/console": "^29.7.0", + "@jest/reporters": "^29.7.0", + "@jest/test-result": "^29.7.0", + "@jest/transform": "^29.7.0", + "@jest/types": "^29.6.3", + "@types/node": "*", + "ansi-escapes": "^4.2.1", + "chalk": "^4.0.0", + "ci-info": "^3.2.0", + "exit": "^0.1.2", + "graceful-fs": "^4.2.9", + "jest-changed-files": "^29.7.0", + "jest-config": "^29.7.0", + "jest-haste-map": "^29.7.0", + "jest-message-util": "^29.7.0", + "jest-regex-util": "^29.6.3", + "jest-resolve": "^29.7.0", + "jest-resolve-dependencies": "^29.7.0", + "jest-runner": "^29.7.0", + "jest-runtime": "^29.7.0", + "jest-snapshot": "^29.7.0", + "jest-util": "^29.7.0", + "jest-validate": "^29.7.0", + "jest-watcher": "^29.7.0", + "micromatch": "^4.0.4", + "pretty-format": "^29.7.0", + "slash": "^3.0.0", + "strip-ansi": "^6.0.0" + }, "engines": { - "node": ">=12" + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + }, + "peerDependencies": { + "node-notifier": "^8.0.1 || ^9.0.0 || ^10.0.0" + }, + "peerDependenciesMeta": { + "node-notifier": { + "optional": true + } } }, - "node_modules/@esbuild/linux-ia32": { - "version": "0.19.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.19.12.tgz", - "integrity": "sha512-Thsa42rrP1+UIGaWz47uydHSBOgTUnwBwNq59khgIwktK6x60Hivfbux9iNR0eHCHzOLjLMLfUMLCypBkZXMHA==", - "cpu": [ - "ia32" - ], - "optional": true, - "os": [ - "linux" - ], + "node_modules/@jest/core/node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "dependencies": { + "color-convert": "^2.0.1" + }, "engines": { - "node": ">=12" + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, - "node_modules/@esbuild/linux-loong64": { - "version": "0.19.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.19.12.tgz", - "integrity": "sha512-LiXdXA0s3IqRRjm6rV6XaWATScKAXjI4R4LoDlvO7+yQqFdlr1Bax62sRwkVvRIrwXxvtYEHHI4dm50jAXkuAA==", - "cpu": [ - "loong64" - ], - "optional": true, - "os": [ - "linux" - ], + "node_modules/@jest/core/node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, "engines": { - "node": ">=12" + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" } }, - "node_modules/@esbuild/linux-mips64el": { - "version": "0.19.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.19.12.tgz", - "integrity": "sha512-fEnAuj5VGTanfJ07ff0gOA6IPsvrVHLVb6Lyd1g2/ed67oU1eFzL0r9WL7ZzscD+/N6i3dWumGE1Un4f7Amf+w==", - "cpu": [ - "mips64el" - ], - "optional": true, - "os": [ - "linux" - ], + "node_modules/@jest/core/node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "dependencies": { + "color-name": "~1.1.4" + }, "engines": { - "node": ">=12" + "node": ">=7.0.0" } }, - "node_modules/@esbuild/linux-ppc64": { - "version": "0.19.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.19.12.tgz", - "integrity": "sha512-nYJA2/QPimDQOh1rKWedNOe3Gfc8PabU7HT3iXWtNUbRzXS9+vgB0Fjaqr//XNbd82mCxHzik2qotuI89cfixg==", - "cpu": [ - "ppc64" - ], - "optional": true, - "os": [ - "linux" - ], + "node_modules/@jest/core/node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true + }, + "node_modules/@jest/core/node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, "engines": { - "node": ">=12" + "node": ">=8" } }, - "node_modules/@esbuild/linux-riscv64": { - "version": "0.19.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.19.12.tgz", - "integrity": "sha512-2MueBrlPQCw5dVJJpQdUYgeqIzDQgw3QtiAHUC4RBz9FXPrskyyU3VI1hw7C0BSKB9OduwSJ79FTCqtGMWqJHg==", - "cpu": [ - "riscv64" - ], - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/linux-s390x": { - "version": "0.19.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.19.12.tgz", - "integrity": "sha512-+Pil1Nv3Umes4m3AZKqA2anfhJiVmNCYkPchwFJNEJN5QxmTs1uzyy4TvmDrCRNT2ApwSari7ZIgrPeUx4UZDg==", - "cpu": [ - "s390x" - ], - "optional": true, - "os": [ - "linux" - ], + "node_modules/@jest/core/node_modules/pretty-format": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-29.7.0.tgz", + "integrity": "sha512-Pdlw/oPxN+aXdmM9R00JVC9WVFoCLTKJvDVLgmJ+qAffBMxsV85l/Lu7sNx4zSzPyoL2euImuEwHhOXdEgNFZQ==", + "dev": true, + "dependencies": { + "@jest/schemas": "^29.6.3", + "ansi-styles": "^5.0.0", + "react-is": "^18.0.0" + }, "engines": { - "node": ">=12" + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" } }, - "node_modules/@esbuild/linux-x64": { - "version": "0.19.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.19.12.tgz", - "integrity": "sha512-B71g1QpxfwBvNrfyJdVDexenDIt1CiDN1TIXLbhOw0KhJzE78KIFGX6OJ9MrtC0oOqMWf+0xop4qEU8JrJTwCg==", - "cpu": [ - "x64" - ], - "optional": true, - "os": [ - "linux" - ], + "node_modules/@jest/core/node_modules/pretty-format/node_modules/ansi-styles": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", + "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", + "dev": true, "engines": { - "node": ">=12" + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, - "node_modules/@esbuild/netbsd-x64": { - "version": "0.19.12", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.19.12.tgz", - "integrity": "sha512-3ltjQ7n1owJgFbuC61Oj++XhtzmymoCihNFgT84UAmJnxJfm4sYCiSLTXZtE00VWYpPMYc+ZQmB6xbSdVh0JWA==", - "cpu": [ - "x64" - ], - "optional": true, - "os": [ - "netbsd" - ], - "engines": { - "node": ">=12" - } + "node_modules/@jest/core/node_modules/react-is": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz", + "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==", + "dev": true }, - "node_modules/@esbuild/openbsd-x64": { - "version": "0.19.12", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.19.12.tgz", - "integrity": "sha512-RbrfTB9SWsr0kWmb9srfF+L933uMDdu9BIzdA7os2t0TXhCRjrQyCeOt6wVxr79CKD4c+p+YhCj31HBkYcXebw==", - "cpu": [ - "x64" - ], - "optional": true, - "os": [ - "openbsd" - ], + "node_modules/@jest/core/node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "dependencies": { + "has-flag": "^4.0.0" + }, "engines": { - "node": ">=12" + "node": ">=8" } }, - "node_modules/@esbuild/sunos-x64": { - "version": "0.19.12", - "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.19.12.tgz", - "integrity": "sha512-HKjJwRrW8uWtCQnQOz9qcU3mUZhTUQvi56Q8DPTLLB+DawoiQdjsYq+j+D3s9I8VFtDr+F9CjgXKKC4ss89IeA==", - "cpu": [ - "x64" - ], - "optional": true, - "os": [ - "sunos" - ], + "node_modules/@jest/environment": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/@jest/environment/-/environment-29.7.0.tgz", + "integrity": "sha512-aQIfHDq33ExsN4jP1NWGXhxgQ/wixs60gDiKO+XVMd8Mn0NWPWgc34ZQDTb2jKaUWQ7MuwoitXAsN2XVXNMpAw==", + "dev": true, + "dependencies": { + "@jest/fake-timers": "^29.7.0", + "@jest/types": "^29.6.3", + "@types/node": "*", + "jest-mock": "^29.7.0" + }, "engines": { - "node": ">=12" + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" } }, - "node_modules/@esbuild/win32-arm64": { - "version": "0.19.12", - "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.19.12.tgz", - "integrity": "sha512-URgtR1dJnmGvX864pn1B2YUYNzjmXkuJOIqG2HdU62MVS4EHpU2946OZoTMnRUHklGtJdJZ33QfzdjGACXhn1A==", - "cpu": [ - "arm64" - ], - "optional": true, - "os": [ - "win32" - ], + "node_modules/@jest/expect": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/@jest/expect/-/expect-29.7.0.tgz", + "integrity": "sha512-8uMeAMycttpva3P1lBHB8VciS9V0XAr3GymPpipdyQXbBcuhkLQOSe8E/p92RyAdToS6ZD1tFkX+CkhoECE0dQ==", + "dev": true, + "dependencies": { + "expect": "^29.7.0", + "jest-snapshot": "^29.7.0" + }, "engines": { - "node": ">=12" + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" } }, - "node_modules/@esbuild/win32-ia32": { - "version": "0.19.12", - "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.19.12.tgz", - "integrity": "sha512-+ZOE6pUkMOJfmxmBZElNOx72NKpIa/HFOMGzu8fqzQJ5kgf6aTGrcJaFsNiVMH4JKpMipyK+7k0n2UXN7a8YKQ==", - "cpu": [ - "ia32" - ], - "optional": true, - "os": [ - "win32" - ], + "node_modules/@jest/expect-utils": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/@jest/expect-utils/-/expect-utils-29.7.0.tgz", + "integrity": "sha512-GlsNBWiFQFCVi9QVSx7f5AgMeLxe9YCCs5PuP2O2LdjDAA8Jh9eX7lA1Jq/xdXw3Wb3hyvlFNfZIfcRetSzYcA==", + "dev": true, + "dependencies": { + "jest-get-type": "^29.6.3" + }, "engines": { - "node": ">=12" + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" } }, - "node_modules/@esbuild/win32-x64": { - "version": "0.19.12", - "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.19.12.tgz", - "integrity": "sha512-T1QyPSDCyMXaO3pzBkF96E8xMkiRYbUEZADd29SyPGabqxMViNoii+NcK7eWJAEoU6RZyEm5lVSIjTmcdoB9HA==", - "cpu": [ - "x64" - ], - "optional": true, - "os": [ - "win32" - ], + "node_modules/@jest/expect-utils/node_modules/jest-get-type": { + "version": "29.6.3", + "resolved": "https://registry.npmjs.org/jest-get-type/-/jest-get-type-29.6.3.tgz", + "integrity": "sha512-zrteXnqYxfQh7l5FHyL38jL39di8H8rHoecLH3JNxH3BwOrBsNeabdap5e0I23lD4HHI8W5VFBZqG4Eaq5LNcw==", + "dev": true, "engines": { - "node": ">=12" + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" } }, - "node_modules/@fontsource/jetbrains-mono": { - "version": "5.0.19", - "integrity": "sha512-SdwUuvdfuAvGWRRc4LOFRSmDrpkE+vFUpCtOIOUl1PpXdLfeU//93BZiGf7j/oFGSZJbHAurfux2uLT38/NIjw==" - }, - "node_modules/@fontsource/roboto-mono": { - "version": "5.0.17", - "integrity": "sha512-MU6FrAyG7DWMCL8mu0JDPvB2tnFcn/lYvVKixzqHb2uefRsLaD6OBFfF1q5RMFsKcFHyPySHM7ZcGw/Q6A1/FA==" - }, - "node_modules/@istanbuljs/load-nyc-config": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@istanbuljs/load-nyc-config/-/load-nyc-config-1.1.0.tgz", - "integrity": "sha512-VjeHSlIzpv/NyD3N0YuHfXOPDIixcA1q2ZV98wsMqcYlPmv2n3Yb2lYP9XMElnaFVXg5A7YLTeLu6V84uQDjmQ==", + "node_modules/@jest/fake-timers": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/@jest/fake-timers/-/fake-timers-29.7.0.tgz", + "integrity": "sha512-q4DH1Ha4TTFPdxLsqDXK1d3+ioSL7yL5oCMJZgDYm6i+6CygW5E5xVr/D1HdsGxjt1ZWSfUAs9OxSB/BNelWrQ==", "dev": true, "dependencies": { - "camelcase": "^5.3.1", - "find-up": "^4.1.0", - "get-package-type": "^0.1.0", - "js-yaml": "^3.13.1", - "resolve-from": "^5.0.0" + "@jest/types": "^29.6.3", + "@sinonjs/fake-timers": "^10.0.2", + "@types/node": "*", + "jest-message-util": "^29.7.0", + "jest-mock": "^29.7.0", + "jest-util": "^29.7.0" }, "engines": { - "node": ">=8" + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" } }, - "node_modules/@istanbuljs/schema": { - "version": "0.1.3", - "resolved": "https://registry.npmjs.org/@istanbuljs/schema/-/schema-0.1.3.tgz", - "integrity": "sha512-ZXRY4jNvVgSVQ8DL3LTcakaAtXwTVUxE81hslsyD2AtoXW/wVob10HkOJ1X/pAlcI7D+2YoZKg5do8G/w6RYgA==", + "node_modules/@jest/globals": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/@jest/globals/-/globals-29.7.0.tgz", + "integrity": "sha512-mpiz3dutLbkW2MNFubUGUEVLkTGiqW6yLVTA+JbP6fI6J5iL9Y0Nlg8k95pcF8ctKwCS7WVxteBs29hhfAotzQ==", "dev": true, + "dependencies": { + "@jest/environment": "^29.7.0", + "@jest/expect": "^29.7.0", + "@jest/types": "^29.6.3", + "jest-mock": "^29.7.0" + }, "engines": { - "node": ">=8" + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" } }, - "node_modules/@jest/console": { + "node_modules/@jest/reporters": { "version": "29.7.0", - "resolved": "https://registry.npmjs.org/@jest/console/-/console-29.7.0.tgz", - "integrity": "sha512-5Ni4CU7XHQi32IJ398EEP4RrB8eV09sXP2ROqD4bksHrnTree52PsxvX8tpL8LvTZ3pFzXyPbNQReSN41CAhOg==", + "resolved": "https://registry.npmjs.org/@jest/reporters/-/reporters-29.7.0.tgz", + "integrity": "sha512-DApq0KJbJOEzAFYjHADNNxAE3KbhxQB1y5Kplb5Waqw6zVbuWatSnMjE5gs8FUgEPmNsnZA3NCWl9NG0ia04Pg==", "dev": true, "dependencies": { + "@bcoe/v8-coverage": "^0.2.3", + "@jest/console": "^29.7.0", + "@jest/test-result": "^29.7.0", + "@jest/transform": "^29.7.0", "@jest/types": "^29.6.3", + "@jridgewell/trace-mapping": "^0.3.18", "@types/node": "*", "chalk": "^4.0.0", + "collect-v8-coverage": "^1.0.0", + "exit": "^0.1.2", + "glob": "^7.1.3", + "graceful-fs": "^4.2.9", + "istanbul-lib-coverage": "^3.0.0", + "istanbul-lib-instrument": "^6.0.0", + "istanbul-lib-report": "^3.0.0", + "istanbul-lib-source-maps": "^4.0.0", + "istanbul-reports": "^3.1.3", "jest-message-util": "^29.7.0", "jest-util": "^29.7.0", - "slash": "^3.0.0" + "jest-worker": "^29.7.0", + "slash": "^3.0.0", + "string-length": "^4.0.1", + "strip-ansi": "^6.0.0", + "v8-to-istanbul": "^9.0.1" }, "engines": { "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + }, + "peerDependencies": { + "node-notifier": "^8.0.1 || ^9.0.0 || ^10.0.0" + }, + "peerDependenciesMeta": { + "node-notifier": { + "optional": true + } } }, - "node_modules/@jest/console/node_modules/ansi-styles": { + "node_modules/@jest/reporters/node_modules/ansi-styles": { "version": "4.3.0", "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", @@ -1383,7 +1226,7 @@ "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, - "node_modules/@jest/console/node_modules/chalk": { + "node_modules/@jest/reporters/node_modules/chalk": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", @@ -1399,7 +1242,7 @@ "url": "https://github.com/chalk/chalk?sponsor=1" } }, - "node_modules/@jest/console/node_modules/color-convert": { + "node_modules/@jest/reporters/node_modules/color-convert": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", @@ -1411,13 +1254,13 @@ "node": ">=7.0.0" } }, - "node_modules/@jest/console/node_modules/color-name": { + "node_modules/@jest/reporters/node_modules/color-name": { "version": "1.1.4", "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", "dev": true }, - "node_modules/@jest/console/node_modules/has-flag": { + "node_modules/@jest/reporters/node_modules/has-flag": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", @@ -1426,7 +1269,7 @@ "node": ">=8" } }, - "node_modules/@jest/console/node_modules/supports-color": { + "node_modules/@jest/reporters/node_modules/supports-color": { "version": "7.2.0", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", @@ -1438,54 +1281,89 @@ "node": ">=8" } }, - "node_modules/@jest/core": { + "node_modules/@jest/schemas": { + "version": "29.6.3", + "resolved": "https://registry.npmjs.org/@jest/schemas/-/schemas-29.6.3.tgz", + "integrity": "sha512-mo5j5X+jIZmJQveBKeS/clAueipV7KgiX1vMgCxam1RNYiqE1w62n0/tJJnHtjW8ZHcQco5gY85jA3mi0L+nSA==", + "dev": true, + "dependencies": { + "@sinclair/typebox": "^0.27.8" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/@jest/source-map": { + "version": "29.6.3", + "resolved": "https://registry.npmjs.org/@jest/source-map/-/source-map-29.6.3.tgz", + "integrity": "sha512-MHjT95QuipcPrpLM+8JMSzFx6eHp5Bm+4XeFDJlwsvVBjmKNiIAvasGK2fxz2WbGRlnvqehFbh07MMa7n3YJnw==", + "dev": true, + "dependencies": { + "@jridgewell/trace-mapping": "^0.3.18", + "callsites": "^3.0.0", + "graceful-fs": "^4.2.9" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/@jest/test-result": { "version": "29.7.0", - "resolved": "https://registry.npmjs.org/@jest/core/-/core-29.7.0.tgz", - "integrity": "sha512-n7aeXWKMnGtDA48y8TLWJPJmLmmZ642Ceo78cYWEpiD7FzDgmNDV/GCVRorPABdXLJZ/9wzzgZAlHjXjxDHGsg==", + "resolved": "https://registry.npmjs.org/@jest/test-result/-/test-result-29.7.0.tgz", + "integrity": "sha512-Fdx+tv6x1zlkJPcWXmMDAG2HBnaR9XPSd5aDWQVsfrZmLVT3lU1cwyxLgRmXR9yrq4NBoEm9BMsfgFzTQAbJYA==", "dev": true, "dependencies": { "@jest/console": "^29.7.0", - "@jest/reporters": "^29.7.0", + "@jest/types": "^29.6.3", + "@types/istanbul-lib-coverage": "^2.0.0", + "collect-v8-coverage": "^1.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/@jest/test-sequencer": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/@jest/test-sequencer/-/test-sequencer-29.7.0.tgz", + "integrity": "sha512-GQwJ5WZVrKnOJuiYiAF52UNUJXgTZx1NHjFSEB0qEMmSZKAkdMoIzw/Cj6x6NF4AvV23AUqDpFzQkN/eYCYTxw==", + "dev": true, + "dependencies": { "@jest/test-result": "^29.7.0", - "@jest/transform": "^29.7.0", + "graceful-fs": "^4.2.9", + "jest-haste-map": "^29.7.0", + "slash": "^3.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/@jest/transform": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/@jest/transform/-/transform-29.7.0.tgz", + "integrity": "sha512-ok/BTPFzFKVMwO5eOHRrvnBVHdRy9IrsrW1GpMaQ9MCnilNLXQKmAX8s1YXDFaai9xJpac2ySzV0YeRRECr2Vw==", + "dev": true, + "dependencies": { + "@babel/core": "^7.11.6", "@jest/types": "^29.6.3", - "@types/node": "*", - "ansi-escapes": "^4.2.1", + "@jridgewell/trace-mapping": "^0.3.18", + "babel-plugin-istanbul": "^6.1.1", "chalk": "^4.0.0", - "ci-info": "^3.2.0", - "exit": "^0.1.2", + "convert-source-map": "^2.0.0", + "fast-json-stable-stringify": "^2.1.0", "graceful-fs": "^4.2.9", - "jest-changed-files": "^29.7.0", - "jest-config": "^29.7.0", "jest-haste-map": "^29.7.0", - "jest-message-util": "^29.7.0", "jest-regex-util": "^29.6.3", - "jest-resolve": "^29.7.0", - "jest-resolve-dependencies": "^29.7.0", - "jest-runner": "^29.7.0", - "jest-runtime": "^29.7.0", - "jest-snapshot": "^29.7.0", "jest-util": "^29.7.0", - "jest-validate": "^29.7.0", - "jest-watcher": "^29.7.0", "micromatch": "^4.0.4", - "pretty-format": "^29.7.0", + "pirates": "^4.0.4", "slash": "^3.0.0", - "strip-ansi": "^6.0.0" + "write-file-atomic": "^4.0.2" }, "engines": { "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - }, - "peerDependencies": { - "node-notifier": "^8.0.1 || ^9.0.0 || ^10.0.0" - }, - "peerDependenciesMeta": { - "node-notifier": { - "optional": true - } } }, - "node_modules/@jest/core/node_modules/ansi-styles": { + "node_modules/@jest/transform/node_modules/ansi-styles": { "version": "4.3.0", "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", @@ -1500,7 +1378,7 @@ "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, - "node_modules/@jest/core/node_modules/chalk": { + "node_modules/@jest/transform/node_modules/chalk": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", @@ -1516,7 +1394,7 @@ "url": "https://github.com/chalk/chalk?sponsor=1" } }, - "node_modules/@jest/core/node_modules/color-convert": { + "node_modules/@jest/transform/node_modules/color-convert": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", @@ -1528,13 +1406,13 @@ "node": ">=7.0.0" } }, - "node_modules/@jest/core/node_modules/color-name": { + "node_modules/@jest/transform/node_modules/color-name": { "version": "1.1.4", "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", "dev": true }, - "node_modules/@jest/core/node_modules/has-flag": { + "node_modules/@jest/transform/node_modules/has-flag": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", @@ -1543,39 +1421,7 @@ "node": ">=8" } }, - "node_modules/@jest/core/node_modules/pretty-format": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-29.7.0.tgz", - "integrity": "sha512-Pdlw/oPxN+aXdmM9R00JVC9WVFoCLTKJvDVLgmJ+qAffBMxsV85l/Lu7sNx4zSzPyoL2euImuEwHhOXdEgNFZQ==", - "dev": true, - "dependencies": { - "@jest/schemas": "^29.6.3", - "ansi-styles": "^5.0.0", - "react-is": "^18.0.0" - }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - } - }, - "node_modules/@jest/core/node_modules/pretty-format/node_modules/ansi-styles": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", - "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", - "dev": true, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" - } - }, - "node_modules/@jest/core/node_modules/react-is": { - "version": "18.3.1", - "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz", - "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==", - "dev": true - }, - "node_modules/@jest/core/node_modules/supports-color": { + "node_modules/@jest/transform/node_modules/supports-color": { "version": "7.2.0", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", @@ -1587,374 +1433,474 @@ "node": ">=8" } }, - "node_modules/@jest/environment": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/@jest/environment/-/environment-29.7.0.tgz", - "integrity": "sha512-aQIfHDq33ExsN4jP1NWGXhxgQ/wixs60gDiKO+XVMd8Mn0NWPWgc34ZQDTb2jKaUWQ7MuwoitXAsN2XVXNMpAw==", + "node_modules/@jest/types": { + "version": "29.6.3", + "resolved": "https://registry.npmjs.org/@jest/types/-/types-29.6.3.tgz", + "integrity": "sha512-u3UPsIilWKOM3F9CXtrG8LEJmNxwoCQC/XVj4IKYXvvpx7QIi/Kg1LI5uDmDpKlac62NUtX7eLjRh+jVZcLOzw==", "dev": true, "dependencies": { - "@jest/fake-timers": "^29.7.0", - "@jest/types": "^29.6.3", + "@jest/schemas": "^29.6.3", + "@types/istanbul-lib-coverage": "^2.0.0", + "@types/istanbul-reports": "^3.0.0", "@types/node": "*", - "jest-mock": "^29.7.0" + "@types/yargs": "^17.0.8", + "chalk": "^4.0.0" }, "engines": { "node": "^14.15.0 || ^16.10.0 || >=18.0.0" } }, - "node_modules/@jest/expect": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/@jest/expect/-/expect-29.7.0.tgz", - "integrity": "sha512-8uMeAMycttpva3P1lBHB8VciS9V0XAr3GymPpipdyQXbBcuhkLQOSe8E/p92RyAdToS6ZD1tFkX+CkhoECE0dQ==", + "node_modules/@jest/types/node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", "dev": true, "dependencies": { - "expect": "^29.7.0", - "jest-snapshot": "^29.7.0" + "color-convert": "^2.0.1" }, "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, - "node_modules/@jest/expect-utils": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/@jest/expect-utils/-/expect-utils-29.7.0.tgz", - "integrity": "sha512-GlsNBWiFQFCVi9QVSx7f5AgMeLxe9YCCs5PuP2O2LdjDAA8Jh9eX7lA1Jq/xdXw3Wb3hyvlFNfZIfcRetSzYcA==", + "node_modules/@jest/types/node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", "dev": true, "dependencies": { - "jest-get-type": "^29.6.3" + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" }, "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" } }, - "node_modules/@jest/expect-utils/node_modules/jest-get-type": { - "version": "29.6.3", - "resolved": "https://registry.npmjs.org/jest-get-type/-/jest-get-type-29.6.3.tgz", - "integrity": "sha512-zrteXnqYxfQh7l5FHyL38jL39di8H8rHoecLH3JNxH3BwOrBsNeabdap5e0I23lD4HHI8W5VFBZqG4Eaq5LNcw==", + "node_modules/@jest/types/node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", "dev": true, + "dependencies": { + "color-name": "~1.1.4" + }, "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + "node": ">=7.0.0" } }, - "node_modules/@jest/fake-timers": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/@jest/fake-timers/-/fake-timers-29.7.0.tgz", - "integrity": "sha512-q4DH1Ha4TTFPdxLsqDXK1d3+ioSL7yL5oCMJZgDYm6i+6CygW5E5xVr/D1HdsGxjt1ZWSfUAs9OxSB/BNelWrQ==", + "node_modules/@jest/types/node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true + }, + "node_modules/@jest/types/node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", "dev": true, - "dependencies": { - "@jest/types": "^29.6.3", - "@sinonjs/fake-timers": "^10.0.2", - "@types/node": "*", - "jest-message-util": "^29.7.0", - "jest-mock": "^29.7.0", - "jest-util": "^29.7.0" - }, "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + "node": ">=8" } }, - "node_modules/@jest/globals": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/@jest/globals/-/globals-29.7.0.tgz", - "integrity": "sha512-mpiz3dutLbkW2MNFubUGUEVLkTGiqW6yLVTA+JbP6fI6J5iL9Y0Nlg8k95pcF8ctKwCS7WVxteBs29hhfAotzQ==", + "node_modules/@jest/types/node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", "dev": true, "dependencies": { - "@jest/environment": "^29.7.0", - "@jest/expect": "^29.7.0", - "@jest/types": "^29.6.3", - "jest-mock": "^29.7.0" + "has-flag": "^4.0.0" }, "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + "node": ">=8" } }, - "node_modules/@jest/reporters": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/@jest/reporters/-/reporters-29.7.0.tgz", - "integrity": "sha512-DApq0KJbJOEzAFYjHADNNxAE3KbhxQB1y5Kplb5Waqw6zVbuWatSnMjE5gs8FUgEPmNsnZA3NCWl9NG0ia04Pg==", - "dev": true, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.5.tgz", + "integrity": "sha512-IzL8ZoEDIBRWEzlCcRhOaCupYyN5gdIK+Q6fbFdPDg6HqX6jpkItn7DFIpW9LQzXG6Df9sA7+OKnq0qlz/GaQg==", "dependencies": { - "@bcoe/v8-coverage": "^0.2.3", - "@jest/console": "^29.7.0", - "@jest/test-result": "^29.7.0", - "@jest/transform": "^29.7.0", - "@jest/types": "^29.6.3", - "@jridgewell/trace-mapping": "^0.3.18", - "@types/node": "*", - "chalk": "^4.0.0", - "collect-v8-coverage": "^1.0.0", - "exit": "^0.1.2", - "glob": "^7.1.3", - "graceful-fs": "^4.2.9", - "istanbul-lib-coverage": "^3.0.0", - "istanbul-lib-instrument": "^6.0.0", - "istanbul-lib-report": "^3.0.0", - "istanbul-lib-source-maps": "^4.0.0", - "istanbul-reports": "^3.1.3", - "jest-message-util": "^29.7.0", - "jest-util": "^29.7.0", - "jest-worker": "^29.7.0", - "slash": "^3.0.0", - "string-length": "^4.0.1", - "strip-ansi": "^6.0.0", - "v8-to-istanbul": "^9.0.1" - }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - }, - "peerDependencies": { - "node-notifier": "^8.0.1 || ^9.0.0 || ^10.0.0" - }, - "peerDependenciesMeta": { - "node-notifier": { - "optional": true - } - } - }, - "node_modules/@jest/reporters/node_modules/ansi-styles": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", - "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", - "dev": true, - "dependencies": { - "color-convert": "^2.0.1" + "@jridgewell/set-array": "^1.2.1", + "@jridgewell/sourcemap-codec": "^1.4.10", + "@jridgewell/trace-mapping": "^0.3.24" }, "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" + "node": ">=6.0.0" } }, - "node_modules/@jest/reporters/node_modules/chalk": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", - "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", - "dev": true, - "dependencies": { - "ansi-styles": "^4.1.0", - "supports-color": "^7.1.0" - }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/chalk?sponsor=1" + "node": ">=6.0.0" } }, - "node_modules/@jest/reporters/node_modules/color-convert": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", - "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", - "dev": true, - "dependencies": { - "color-name": "~1.1.4" - }, + "node_modules/@jridgewell/set-array": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@jridgewell/set-array/-/set-array-1.2.1.tgz", + "integrity": "sha512-R8gLRTZeyp03ymzP/6Lil/28tGeGEzhx1q2k703KGWRAI1VdvPIXdG70VJc2pAMw3NA6JKL5hhFu1sJX0Mnn/A==", "engines": { - "node": ">=7.0.0" + "node": ">=6.0.0" } }, - "node_modules/@jest/reporters/node_modules/color-name": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", - "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", - "dev": true - }, - "node_modules/@jest/reporters/node_modules/has-flag": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", - "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", - "dev": true, - "engines": { - "node": ">=8" - } + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.4.15", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.4.15.tgz", + "integrity": "sha512-eF2rxCRulEKXHTRiDrDy6erMYWqNw4LPdQ8UQA4huuxaQsVeRPFl2oM8oDGxMFhJUWZf9McpLtJasDDZb/Bpeg==" }, - "node_modules/@jest/reporters/node_modules/supports-color": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", - "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", - "dev": true, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.25", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.25.tgz", + "integrity": "sha512-vNk6aEwybGtawWmy/PzwnGDOjCkLWSD2wqvjGGAgOAwCGWySYXfYoxt00IJkTF+8Lb57DwOb3Aa0o9CApepiYQ==", "dependencies": { - "has-flag": "^4.0.0" - }, - "engines": { - "node": ">=8" + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" } }, - "node_modules/@jest/schemas": { - "version": "29.6.3", - "resolved": "https://registry.npmjs.org/@jest/schemas/-/schemas-29.6.3.tgz", - "integrity": "sha512-mo5j5X+jIZmJQveBKeS/clAueipV7KgiX1vMgCxam1RNYiqE1w62n0/tJJnHtjW8ZHcQco5gY85jA3mi0L+nSA==", - "dev": true, + "node_modules/@mdx-js/react": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/@mdx-js/react/-/react-3.0.1.tgz", + "integrity": "sha512-9ZrPIU4MGf6et1m1ov3zKf+q9+deetI51zprKB1D/z3NOb+rUxxtEl3mCjW5wTGh6VhRdwPueh1oRzi6ezkA8A==", "dependencies": { - "@sinclair/typebox": "^0.27.8" + "@types/mdx": "^2.0.0" }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + }, + "peerDependencies": { + "@types/react": ">=16", + "react": ">=16" } }, - "node_modules/@jest/source-map": { - "version": "29.6.3", - "resolved": "https://registry.npmjs.org/@jest/source-map/-/source-map-29.6.3.tgz", - "integrity": "sha512-MHjT95QuipcPrpLM+8JMSzFx6eHp5Bm+4XeFDJlwsvVBjmKNiIAvasGK2fxz2WbGRlnvqehFbh07MMa7n3YJnw==", - "dev": true, + "node_modules/@rc-component/color-picker": { + "version": "1.5.2", + "resolved": "https://registry.npmjs.org/@rc-component/color-picker/-/color-picker-1.5.2.tgz", + "integrity": "sha512-YJXujYzYFAEtlXJXy0yJUhwzUWPTcniBZto+wZ/vnACmFnUTNR7dH+NOeqSwMMsssh74e9H5Jfpr5LAH2PYqUw==", "dependencies": { - "@jridgewell/trace-mapping": "^0.3.18", - "callsites": "^3.0.0", - "graceful-fs": "^4.2.9" + "@babel/runtime": "^7.23.6", + "@ctrl/tinycolor": "^3.6.1", + "classnames": "^2.2.6", + "rc-util": "^5.38.1" }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" } }, - "node_modules/@jest/test-result": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/@jest/test-result/-/test-result-29.7.0.tgz", - "integrity": "sha512-Fdx+tv6x1zlkJPcWXmMDAG2HBnaR9XPSd5aDWQVsfrZmLVT3lU1cwyxLgRmXR9yrq4NBoEm9BMsfgFzTQAbJYA==", - "dev": true, + "node_modules/@rc-component/context": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/@rc-component/context/-/context-1.4.0.tgz", + "integrity": "sha512-kFcNxg9oLRMoL3qki0OMxK+7g5mypjgaaJp/pkOis/6rVxma9nJBF/8kCIuTYHUQNr0ii7MxqE33wirPZLJQ2w==", "dependencies": { - "@jest/console": "^29.7.0", - "@jest/types": "^29.6.3", - "@types/istanbul-lib-coverage": "^2.0.0", - "collect-v8-coverage": "^1.0.0" + "@babel/runtime": "^7.10.1", + "rc-util": "^5.27.0" }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" } }, - "node_modules/@jest/test-sequencer": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/@jest/test-sequencer/-/test-sequencer-29.7.0.tgz", - "integrity": "sha512-GQwJ5WZVrKnOJuiYiAF52UNUJXgTZx1NHjFSEB0qEMmSZKAkdMoIzw/Cj6x6NF4AvV23AUqDpFzQkN/eYCYTxw==", - "dev": true, + "node_modules/@rc-component/mini-decimal": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@rc-component/mini-decimal/-/mini-decimal-1.1.0.tgz", + "integrity": "sha512-jS4E7T9Li2GuYwI6PyiVXmxTiM6b07rlD9Ge8uGZSCz3WlzcG5ZK7g5bbuKNeZ9pgUuPK/5guV781ujdVpm4HQ==", "dependencies": { - "@jest/test-result": "^29.7.0", - "graceful-fs": "^4.2.9", - "jest-haste-map": "^29.7.0", - "slash": "^3.0.0" + "@babel/runtime": "^7.18.0" }, "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + "node": ">=8.x" } }, - "node_modules/@jest/transform": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/@jest/transform/-/transform-29.7.0.tgz", - "integrity": "sha512-ok/BTPFzFKVMwO5eOHRrvnBVHdRy9IrsrW1GpMaQ9MCnilNLXQKmAX8s1YXDFaai9xJpac2ySzV0YeRRECr2Vw==", - "dev": true, + "node_modules/@rc-component/mutate-observer": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@rc-component/mutate-observer/-/mutate-observer-1.1.0.tgz", + "integrity": "sha512-QjrOsDXQusNwGZPf4/qRQasg7UFEj06XiCJ8iuiq/Io7CrHrgVi6Uuetw60WAMG1799v+aM8kyc+1L/GBbHSlw==", "dependencies": { - "@babel/core": "^7.11.6", - "@jest/types": "^29.6.3", - "@jridgewell/trace-mapping": "^0.3.18", - "babel-plugin-istanbul": "^6.1.1", - "chalk": "^4.0.0", - "convert-source-map": "^2.0.0", - "fast-json-stable-stringify": "^2.1.0", - "graceful-fs": "^4.2.9", - "jest-haste-map": "^29.7.0", - "jest-regex-util": "^29.6.3", - "jest-util": "^29.7.0", - "micromatch": "^4.0.4", - "pirates": "^4.0.4", - "slash": "^3.0.0", - "write-file-atomic": "^4.0.2" + "@babel/runtime": "^7.18.0", + "classnames": "^2.3.2", + "rc-util": "^5.24.4" }, "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + "node": ">=8.x" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" } }, - "node_modules/@jest/transform/node_modules/ansi-styles": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", - "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", - "dev": true, + "node_modules/@rc-component/portal": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@rc-component/portal/-/portal-1.1.2.tgz", + "integrity": "sha512-6f813C0IsasTZms08kfA8kPAGxbbkYToa8ALaiDIGGECU4i9hj8Plgbx0sNJDrey3EtHO30hmdaxtT0138xZcg==", "dependencies": { - "color-convert": "^2.0.1" + "@babel/runtime": "^7.18.0", + "classnames": "^2.3.2", + "rc-util": "^5.24.4" }, "engines": { - "node": ">=8" + "node": ">=8.x" }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" } }, - "node_modules/@jest/transform/node_modules/chalk": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", - "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", - "dev": true, + "node_modules/@rc-component/tour": { + "version": "1.12.3", + "resolved": "https://registry.npmjs.org/@rc-component/tour/-/tour-1.12.3.tgz", + "integrity": "sha512-U4mf1FiUxGCwrX4ed8op77Y8VKur+8Y/61ylxtqGbcSoh1EBC7bWd/DkLu0ClTUrKZInqEi1FL7YgFtnT90vHA==", "dependencies": { - "ansi-styles": "^4.1.0", - "supports-color": "^7.1.0" + "@babel/runtime": "^7.18.0", + "@rc-component/portal": "^1.0.0-9", + "@rc-component/trigger": "^1.3.6", + "classnames": "^2.3.2", + "rc-util": "^5.24.4" }, "engines": { - "node": ">=10" + "node": ">=8.x" }, - "funding": { - "url": "https://github.com/chalk/chalk?sponsor=1" + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" } }, - "node_modules/@jest/transform/node_modules/color-convert": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", - "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", - "dev": true, + "node_modules/@rc-component/trigger": { + "version": "1.18.3", + "resolved": "https://registry.npmjs.org/@rc-component/trigger/-/trigger-1.18.3.tgz", + "integrity": "sha512-Ksr25pXreYe1gX6ayZ1jLrOrl9OAUHUqnuhEx6MeHnNa1zVM5Y2Aj3Q35UrER0ns8D2cJYtmJtVli+i+4eKrvA==", "dependencies": { - "color-name": "~1.1.4" + "@babel/runtime": "^7.23.2", + "@rc-component/portal": "^1.1.0", + "classnames": "^2.3.2", + "rc-motion": "^2.0.0", + "rc-resize-observer": "^1.3.1", + "rc-util": "^5.38.0" }, "engines": { - "node": ">=7.0.0" + "node": ">=8.x" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" } }, - "node_modules/@jest/transform/node_modules/color-name": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", - "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "node_modules/@remix-run/router": { + "version": "1.15.1", + "resolved": "https://registry.npmjs.org/@remix-run/router/-/router-1.15.1.tgz", + "integrity": "sha512-zcU0gM3z+3iqj8UX45AmWY810l3oUmXM7uH4dt5xtzvMhRtYVhKGOmgOd1877dOPPepfCjUv57w+syamWIYe7w==", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.13.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.13.0.tgz", + "integrity": "sha512-5ZYPOuaAqEH/W3gYsRkxQATBW3Ii1MfaT4EQstTnLKViLi2gLSQmlmtTpGucNP3sXEpOiI5tdGhjdE111ekyEg==", + "cpu": [ + "arm" + ], + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.13.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.13.0.tgz", + "integrity": "sha512-BSbaCmn8ZadK3UAQdlauSvtaJjhlDEjS5hEVVIN3A4bbl3X+otyf/kOJV08bYiRxfejP3DXFzO2jz3G20107+Q==", + "cpu": [ + "arm64" + ], + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.13.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.13.0.tgz", + "integrity": "sha512-Ovf2evVaP6sW5Ut0GHyUSOqA6tVKfrTHddtmxGQc1CTQa1Cw3/KMCDEEICZBbyppcwnhMwcDce9ZRxdWRpVd6g==", + "cpu": [ + "arm64" + ], + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.13.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.13.0.tgz", + "integrity": "sha512-U+Jcxm89UTK592vZ2J9st9ajRv/hrwHdnvyuJpa5A2ngGSVHypigidkQJP+YiGL6JODiUeMzkqQzbCG3At81Gg==", + "cpu": [ + "x64" + ], + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.13.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.13.0.tgz", + "integrity": "sha512-8wZidaUJUTIR5T4vRS22VkSMOVooG0F4N+JSwQXWSRiC6yfEsFMLTYRFHvby5mFFuExHa/yAp9juSphQQJAijQ==", + "cpu": [ + "arm" + ], + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.13.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.13.0.tgz", + "integrity": "sha512-Iu0Kno1vrD7zHQDxOmvweqLkAzjxEVqNhUIXBsZ8hu8Oak7/5VTPrxOEZXYC1nmrBVJp0ZcL2E7lSuuOVaE3+w==", + "cpu": [ + "arm64" + ], + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.13.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.13.0.tgz", + "integrity": "sha512-C31QrW47llgVyrRjIwiOwsHFcaIwmkKi3PCroQY5aVq4H0A5v/vVVAtFsI1nfBngtoRpeREvZOkIhmRwUKkAdw==", + "cpu": [ + "arm64" + ], + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.13.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.13.0.tgz", + "integrity": "sha512-Oq90dtMHvthFOPMl7pt7KmxzX7E71AfyIhh+cPhLY9oko97Zf2C9tt/XJD4RgxhaGeAraAXDtqxvKE1y/j35lA==", + "cpu": [ + "riscv64" + ], + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.13.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.13.0.tgz", + "integrity": "sha512-yUD/8wMffnTKuiIsl6xU+4IA8UNhQ/f1sAnQebmE/lyQ8abjsVyDkyRkWop0kdMhKMprpNIhPmYlCxgHrPoXoA==", + "cpu": [ + "x64" + ], + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.13.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.13.0.tgz", + "integrity": "sha512-9RyNqoFNdF0vu/qqX63fKotBh43fJQeYC98hCaf89DYQpv+xu0D8QFSOS0biA7cGuqJFOc1bJ+m2rhhsKcw1hw==", + "cpu": [ + "x64" + ], + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.13.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.13.0.tgz", + "integrity": "sha512-46ue8ymtm/5PUU6pCvjlic0z82qWkxv54GTJZgHrQUuZnVH+tvvSP0LsozIDsCBFO4VjJ13N68wqrKSeScUKdA==", + "cpu": [ + "arm64" + ], + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.13.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.13.0.tgz", + "integrity": "sha512-P5/MqLdLSlqxbeuJ3YDeX37srC8mCflSyTrUsgbU1c/U9j6l2g2GiIdYaGD9QjdMQPMSgYm7hgg0551wHyIluw==", + "cpu": [ + "ia32" + ], + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.13.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.13.0.tgz", + "integrity": "sha512-UKXUQNbO3DOhzLRwHSpa0HnhhCgNODvfoPWv2FCXme8N/ANFfhIPMGuOT+QuKd16+B5yxZ0HdpNlqPvTMS1qfw==", + "cpu": [ + "x64" + ], + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@sinclair/typebox": { + "version": "0.27.8", + "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.27.8.tgz", + "integrity": "sha512-+Fj43pSMwJs4KRrH/938Uf+uAELIgVBmQzg/q1YG10djyfA3TnrU8N8XzqCh/okZdszqBQTZf96idMfE5lnwTA==", "dev": true }, - "node_modules/@jest/transform/node_modules/has-flag": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", - "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "node_modules/@sinonjs/commons": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/@sinonjs/commons/-/commons-3.0.1.tgz", + "integrity": "sha512-K3mCHKQ9sVh8o1C9cxkwxaOmXoAMlDxC1mYyHrjqOWEcBjYr76t96zL2zlj5dUGZ3HSw240X1qgH3Mjf1yJWpQ==", "dev": true, - "engines": { - "node": ">=8" + "dependencies": { + "type-detect": "4.0.8" } }, - "node_modules/@jest/transform/node_modules/supports-color": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", - "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "node_modules/@sinonjs/fake-timers": { + "version": "10.3.0", + "resolved": "https://registry.npmjs.org/@sinonjs/fake-timers/-/fake-timers-10.3.0.tgz", + "integrity": "sha512-V4BG07kuYSUkTCSBHG8G8TNhM+F19jXFWnQtzj+we8DrkpSBCee9Z3Ms8yiGer/dlmhe35/Xdgyo3/0rQKg7YA==", "dev": true, "dependencies": { - "has-flag": "^4.0.0" - }, - "engines": { - "node": ">=8" + "@sinonjs/commons": "^3.0.0" } }, - "node_modules/@jest/types": { - "version": "29.6.3", - "resolved": "https://registry.npmjs.org/@jest/types/-/types-29.6.3.tgz", - "integrity": "sha512-u3UPsIilWKOM3F9CXtrG8LEJmNxwoCQC/XVj4IKYXvvpx7QIi/Kg1LI5uDmDpKlac62NUtX7eLjRh+jVZcLOzw==", - "dev": true, + "node_modules/@testing-library/dom": { + "version": "9.3.4", + "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-9.3.4.tgz", + "integrity": "sha512-FlS4ZWlp97iiNWig0Muq8p+3rVDjRiYE+YKGbAqXOu9nwJFFOdL00kFpz42M+4huzYi86vAK1sOOfyOG45muIQ==", "dependencies": { - "@jest/schemas": "^29.6.3", - "@types/istanbul-lib-coverage": "^2.0.0", - "@types/istanbul-reports": "^3.0.0", - "@types/node": "*", - "@types/yargs": "^17.0.8", - "chalk": "^4.0.0" + "@babel/code-frame": "^7.10.4", + "@babel/runtime": "^7.12.5", + "@types/aria-query": "^5.0.1", + "aria-query": "5.1.3", + "chalk": "^4.1.0", + "dom-accessibility-api": "^0.5.9", + "lz-string": "^1.5.0", + "pretty-format": "^27.0.2" }, "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + "node": ">=14" } }, - "node_modules/@jest/types/node_modules/ansi-styles": { + "node_modules/@testing-library/dom/node_modules/ansi-styles": { "version": "4.3.0", "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", - "dev": true, "dependencies": { "color-convert": "^2.0.1" }, @@ -1965,11 +1911,18 @@ "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, - "node_modules/@jest/types/node_modules/chalk": { + "node_modules/@testing-library/dom/node_modules/aria-query": { + "version": "5.1.3", + "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.1.3.tgz", + "integrity": "sha512-R5iJ5lkuHybztUfuOAznmboyjWq8O6sqNqtK7CLOqdydi54VNbORp49mb14KbWgG1QD3JFO9hJdZ+y4KutfdOQ==", + "dependencies": { + "deep-equal": "^2.0.5" + } + }, + "node_modules/@testing-library/dom/node_modules/chalk": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", - "dev": true, "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" @@ -1981,11 +1934,10 @@ "url": "https://github.com/chalk/chalk?sponsor=1" } }, - "node_modules/@jest/types/node_modules/color-convert": { + "node_modules/@testing-library/dom/node_modules/color-convert": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", - "dev": true, "dependencies": { "color-name": "~1.1.4" }, @@ -1993,26 +1945,23 @@ "node": ">=7.0.0" } }, - "node_modules/@jest/types/node_modules/color-name": { + "node_modules/@testing-library/dom/node_modules/color-name": { "version": "1.1.4", "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", - "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", - "dev": true + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==" }, - "node_modules/@jest/types/node_modules/has-flag": { + "node_modules/@testing-library/dom/node_modules/has-flag": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", - "dev": true, "engines": { "node": ">=8" } }, - "node_modules/@jest/types/node_modules/supports-color": { + "node_modules/@testing-library/dom/node_modules/supports-color": { "version": "7.2.0", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", - "dev": true, "dependencies": { "has-flag": "^4.0.0" }, @@ -2020,463 +1969,623 @@ "node": ">=8" } }, - "node_modules/@jridgewell/gen-mapping": { - "version": "0.3.5", - "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.5.tgz", - "integrity": "sha512-IzL8ZoEDIBRWEzlCcRhOaCupYyN5gdIK+Q6fbFdPDg6HqX6jpkItn7DFIpW9LQzXG6Df9sA7+OKnq0qlz/GaQg==", + "node_modules/@testing-library/jest-dom": { + "version": "5.17.0", + "integrity": "sha512-ynmNeT7asXyH3aSVv4vvX4Rb+0qjOhdNHnO/3vuZNqPmhDpV/+rCSGwQ7bLcmU2cJ4dvoheIO85LQj0IbJHEtg==", "dependencies": { - "@jridgewell/set-array": "^1.2.1", - "@jridgewell/sourcemap-codec": "^1.4.10", - "@jridgewell/trace-mapping": "^0.3.24" + "@adobe/css-tools": "^4.0.1", + "@babel/runtime": "^7.9.2", + "@types/testing-library__jest-dom": "^5.9.1", + "aria-query": "^5.0.0", + "chalk": "^3.0.0", + "css.escape": "^1.5.1", + "dom-accessibility-api": "^0.5.6", + "lodash": "^4.17.15", + "redent": "^3.0.0" }, "engines": { - "node": ">=6.0.0" - } - }, - "node_modules/@jridgewell/resolve-uri": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", - "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", - "engines": { - "node": ">=6.0.0" - } - }, - "node_modules/@jridgewell/set-array": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/@jridgewell/set-array/-/set-array-1.2.1.tgz", - "integrity": "sha512-R8gLRTZeyp03ymzP/6Lil/28tGeGEzhx1q2k703KGWRAI1VdvPIXdG70VJc2pAMw3NA6JKL5hhFu1sJX0Mnn/A==", - "engines": { - "node": ">=6.0.0" - } - }, - "node_modules/@jridgewell/sourcemap-codec": { - "version": "1.4.15", - "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.4.15.tgz", - "integrity": "sha512-eF2rxCRulEKXHTRiDrDy6erMYWqNw4LPdQ8UQA4huuxaQsVeRPFl2oM8oDGxMFhJUWZf9McpLtJasDDZb/Bpeg==" - }, - "node_modules/@jridgewell/trace-mapping": { - "version": "0.3.25", - "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.25.tgz", - "integrity": "sha512-vNk6aEwybGtawWmy/PzwnGDOjCkLWSD2wqvjGGAgOAwCGWySYXfYoxt00IJkTF+8Lb57DwOb3Aa0o9CApepiYQ==", - "dependencies": { - "@jridgewell/resolve-uri": "^3.1.0", - "@jridgewell/sourcemap-codec": "^1.4.14" + "node": ">=8", + "npm": ">=6", + "yarn": ">=1" } }, - "node_modules/@rc-component/color-picker": { - "version": "1.5.2", - "resolved": "https://registry.npmjs.org/@rc-component/color-picker/-/color-picker-1.5.2.tgz", - "integrity": "sha512-YJXujYzYFAEtlXJXy0yJUhwzUWPTcniBZto+wZ/vnACmFnUTNR7dH+NOeqSwMMsssh74e9H5Jfpr5LAH2PYqUw==", + "node_modules/@testing-library/jest-dom/node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", "dependencies": { - "@babel/runtime": "^7.23.6", - "@ctrl/tinycolor": "^3.6.1", - "classnames": "^2.2.6", - "rc-util": "^5.38.1" + "color-convert": "^2.0.1" }, - "peerDependencies": { - "react": ">=16.9.0", - "react-dom": ">=16.9.0" + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, - "node_modules/@rc-component/context": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/@rc-component/context/-/context-1.4.0.tgz", - "integrity": "sha512-kFcNxg9oLRMoL3qki0OMxK+7g5mypjgaaJp/pkOis/6rVxma9nJBF/8kCIuTYHUQNr0ii7MxqE33wirPZLJQ2w==", + "node_modules/@testing-library/jest-dom/node_modules/chalk": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-3.0.0.tgz", + "integrity": "sha512-4D3B6Wf41KOYRFdszmDqMCGq5VV/uMAB273JILmO+3jAlh8X4qDtdtgCR3fxtbLEMzSx22QdhnDcJvu2u1fVwg==", "dependencies": { - "@babel/runtime": "^7.10.1", - "rc-util": "^5.27.0" + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" }, - "peerDependencies": { - "react": ">=16.9.0", - "react-dom": ">=16.9.0" + "engines": { + "node": ">=8" } }, - "node_modules/@rc-component/mini-decimal": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@rc-component/mini-decimal/-/mini-decimal-1.1.0.tgz", - "integrity": "sha512-jS4E7T9Li2GuYwI6PyiVXmxTiM6b07rlD9Ge8uGZSCz3WlzcG5ZK7g5bbuKNeZ9pgUuPK/5guV781ujdVpm4HQ==", + "node_modules/@testing-library/jest-dom/node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", "dependencies": { - "@babel/runtime": "^7.18.0" + "color-name": "~1.1.4" }, "engines": { - "node": ">=8.x" + "node": ">=7.0.0" } }, - "node_modules/@rc-component/mutate-observer": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@rc-component/mutate-observer/-/mutate-observer-1.1.0.tgz", - "integrity": "sha512-QjrOsDXQusNwGZPf4/qRQasg7UFEj06XiCJ8iuiq/Io7CrHrgVi6Uuetw60WAMG1799v+aM8kyc+1L/GBbHSlw==", - "dependencies": { - "@babel/runtime": "^7.18.0", - "classnames": "^2.3.2", - "rc-util": "^5.24.4" - }, + "node_modules/@testing-library/jest-dom/node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==" + }, + "node_modules/@testing-library/jest-dom/node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", "engines": { - "node": ">=8.x" - }, - "peerDependencies": { - "react": ">=16.9.0", - "react-dom": ">=16.9.0" + "node": ">=8" } }, - "node_modules/@rc-component/portal": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/@rc-component/portal/-/portal-1.1.2.tgz", - "integrity": "sha512-6f813C0IsasTZms08kfA8kPAGxbbkYToa8ALaiDIGGECU4i9hj8Plgbx0sNJDrey3EtHO30hmdaxtT0138xZcg==", + "node_modules/@testing-library/jest-dom/node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", "dependencies": { - "@babel/runtime": "^7.18.0", - "classnames": "^2.3.2", - "rc-util": "^5.24.4" + "has-flag": "^4.0.0" }, "engines": { - "node": ">=8.x" - }, - "peerDependencies": { - "react": ">=16.9.0", - "react-dom": ">=16.9.0" + "node": ">=8" } }, - "node_modules/@rc-component/tour": { - "version": "1.12.3", - "resolved": "https://registry.npmjs.org/@rc-component/tour/-/tour-1.12.3.tgz", - "integrity": "sha512-U4mf1FiUxGCwrX4ed8op77Y8VKur+8Y/61ylxtqGbcSoh1EBC7bWd/DkLu0ClTUrKZInqEi1FL7YgFtnT90vHA==", + "node_modules/@testing-library/react": { + "version": "14.2.2", + "integrity": "sha512-SOUuM2ysCvjUWBXTNfQ/ztmnKDmqaiPV3SvoIuyxMUca45rbSWWAT/qB8CUs/JQ/ux/8JFs9DNdFQ3f6jH3crA==", + "dev": true, "dependencies": { - "@babel/runtime": "^7.18.0", - "@rc-component/portal": "^1.0.0-9", - "@rc-component/trigger": "^1.3.6", - "classnames": "^2.3.2", - "rc-util": "^5.24.4" + "@babel/runtime": "^7.12.5", + "@testing-library/dom": "^9.0.0", + "@types/react-dom": "^18.0.0" }, "engines": { - "node": ">=8.x" + "node": ">=14" }, "peerDependencies": { - "react": ">=16.9.0", - "react-dom": ">=16.9.0" + "react": "^18.0.0", + "react-dom": "^18.0.0" } }, - "node_modules/@rc-component/trigger": { - "version": "1.18.3", - "resolved": "https://registry.npmjs.org/@rc-component/trigger/-/trigger-1.18.3.tgz", - "integrity": "sha512-Ksr25pXreYe1gX6ayZ1jLrOrl9OAUHUqnuhEx6MeHnNa1zVM5Y2Aj3Q35UrER0ns8D2cJYtmJtVli+i+4eKrvA==", + "node_modules/@testing-library/user-event": { + "version": "13.5.0", + "integrity": "sha512-5Kwtbo3Y/NowpkbRuSepbyMFkZmHgD+vPzYB/RJ4oxt5Gj/avFFBYjhw27cqSVPVw/3a67NK1PbiIr9k4Gwmdg==", "dependencies": { - "@babel/runtime": "^7.23.2", - "@rc-component/portal": "^1.1.0", - "classnames": "^2.3.2", - "rc-motion": "^2.0.0", - "rc-resize-observer": "^1.3.1", - "rc-util": "^5.38.0" + "@babel/runtime": "^7.12.5" }, "engines": { - "node": ">=8.x" + "node": ">=10", + "npm": ">=6" }, "peerDependencies": { - "react": ">=16.9.0", - "react-dom": ">=16.9.0" + "@testing-library/dom": ">=7.21.4" } }, - "node_modules/@remix-run/router": { - "version": "1.15.1", - "resolved": "https://registry.npmjs.org/@remix-run/router/-/router-1.15.1.tgz", - "integrity": "sha512-zcU0gM3z+3iqj8UX45AmWY810l3oUmXM7uH4dt5xtzvMhRtYVhKGOmgOd1877dOPPepfCjUv57w+syamWIYe7w==", + "node_modules/@tootallnate/once": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@tootallnate/once/-/once-2.0.0.tgz", + "integrity": "sha512-XCuKFP5PS55gnMVu3dty8KPatLqUoy/ZYzDzAGCQ8JNFCkLXzmI7vNHCR+XpbZaMWQK/vQubr7PkYq8g470J/A==", + "dev": true, "engines": { - "node": ">=14.0.0" + "node": ">= 10" } }, - "node_modules/@rollup/rollup-android-arm-eabi": { - "version": "4.13.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.13.0.tgz", - "integrity": "sha512-5ZYPOuaAqEH/W3gYsRkxQATBW3Ii1MfaT4EQstTnLKViLi2gLSQmlmtTpGucNP3sXEpOiI5tdGhjdE111ekyEg==", - "cpu": [ - "arm" - ], - "optional": true, - "os": [ - "android" - ] + "node_modules/@types/aria-query": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz", + "integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==" }, - "node_modules/@rollup/rollup-android-arm64": { - "version": "4.13.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.13.0.tgz", - "integrity": "sha512-BSbaCmn8ZadK3UAQdlauSvtaJjhlDEjS5hEVVIN3A4bbl3X+otyf/kOJV08bYiRxfejP3DXFzO2jz3G20107+Q==", - "cpu": [ - "arm64" - ], - "optional": true, - "os": [ - "android" - ] + "node_modules/@types/babel__core": { + "version": "7.20.5", + "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", + "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==", + "dependencies": { + "@babel/parser": "^7.20.7", + "@babel/types": "^7.20.7", + "@types/babel__generator": "*", + "@types/babel__template": "*", + "@types/babel__traverse": "*" + } }, - "node_modules/@rollup/rollup-darwin-arm64": { - "version": "4.13.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.13.0.tgz", - "integrity": "sha512-Ovf2evVaP6sW5Ut0GHyUSOqA6tVKfrTHddtmxGQc1CTQa1Cw3/KMCDEEICZBbyppcwnhMwcDce9ZRxdWRpVd6g==", - "cpu": [ - "arm64" - ], - "optional": true, - "os": [ - "darwin" - ] + "node_modules/@types/babel__generator": { + "version": "7.6.8", + "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.6.8.tgz", + "integrity": "sha512-ASsj+tpEDsEiFr1arWrlN6V3mdfjRMZt6LtK/Vp/kreFLnr5QH5+DhvD5nINYZXzwJvXeGq+05iUXcAzVrqWtw==", + "dependencies": { + "@babel/types": "^7.0.0" + } }, - "node_modules/@rollup/rollup-darwin-x64": { - "version": "4.13.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.13.0.tgz", - "integrity": "sha512-U+Jcxm89UTK592vZ2J9st9ajRv/hrwHdnvyuJpa5A2ngGSVHypigidkQJP+YiGL6JODiUeMzkqQzbCG3At81Gg==", - "cpu": [ - "x64" - ], - "optional": true, - "os": [ - "darwin" - ] + "node_modules/@types/babel__template": { + "version": "7.4.4", + "resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.4.tgz", + "integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==", + "dependencies": { + "@babel/parser": "^7.1.0", + "@babel/types": "^7.0.0" + } }, - "node_modules/@rollup/rollup-linux-arm-gnueabihf": { - "version": "4.13.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.13.0.tgz", - "integrity": "sha512-8wZidaUJUTIR5T4vRS22VkSMOVooG0F4N+JSwQXWSRiC6yfEsFMLTYRFHvby5mFFuExHa/yAp9juSphQQJAijQ==", - "cpu": [ - "arm" - ], - "optional": true, - "os": [ - "linux" - ] + "node_modules/@types/babel__traverse": { + "version": "7.20.5", + "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.20.5.tgz", + "integrity": "sha512-WXCyOcRtH37HAUkpXhUduaxdm82b4GSlyTqajXviN4EfiuPgNYR109xMCKvpl6zPIpua0DGlMEDCq+g8EdoheQ==", + "dependencies": { + "@babel/types": "^7.20.7" + } }, - "node_modules/@rollup/rollup-linux-arm64-gnu": { - "version": "4.13.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.13.0.tgz", - "integrity": "sha512-Iu0Kno1vrD7zHQDxOmvweqLkAzjxEVqNhUIXBsZ8hu8Oak7/5VTPrxOEZXYC1nmrBVJp0ZcL2E7lSuuOVaE3+w==", - "cpu": [ - "arm64" - ], - "optional": true, - "os": [ - "linux" - ] + "node_modules/@types/debug": { + "version": "4.1.12", + "resolved": "https://registry.npmjs.org/@types/debug/-/debug-4.1.12.tgz", + "integrity": "sha512-vIChWdVG3LG1SMxEvI/AK+FWJthlrqlTu7fbrlywTkkaONwk/UAGaULXRlf8vkzFBLVm0zkMdCquhL5aOjhXPQ==", + "dependencies": { + "@types/ms": "*" + } }, - "node_modules/@rollup/rollup-linux-arm64-musl": { - "version": "4.13.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.13.0.tgz", - "integrity": "sha512-C31QrW47llgVyrRjIwiOwsHFcaIwmkKi3PCroQY5aVq4H0A5v/vVVAtFsI1nfBngtoRpeREvZOkIhmRwUKkAdw==", - "cpu": [ - "arm64" - ], - "optional": true, - "os": [ - "linux" - ] + "node_modules/@types/estree": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.5.tgz", + "integrity": "sha512-/kYRxGDLWzHOB7q+wtSUQlFrtcdUccpfy+X+9iMBpHK8QLLhx2wIPYuS5DYtR9Wa/YlZAbIovy7qVdB1Aq6Lyw==" }, - "node_modules/@rollup/rollup-linux-riscv64-gnu": { - "version": "4.13.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.13.0.tgz", - "integrity": "sha512-Oq90dtMHvthFOPMl7pt7KmxzX7E71AfyIhh+cPhLY9oko97Zf2C9tt/XJD4RgxhaGeAraAXDtqxvKE1y/j35lA==", - "cpu": [ - "riscv64" - ], - "optional": true, - "os": [ - "linux" - ] + "node_modules/@types/estree-jsx": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/@types/estree-jsx/-/estree-jsx-1.0.5.tgz", + "integrity": "sha512-52CcUVNFyfb1A2ALocQw/Dd1BQFNmSdkuC3BkZ6iqhdMfQz7JWOFRuJFloOzjk+6WijU56m9oKXFAXc7o3Towg==", + "dependencies": { + "@types/estree": "*" + } }, - "node_modules/@rollup/rollup-linux-x64-gnu": { - "version": "4.13.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.13.0.tgz", - "integrity": "sha512-yUD/8wMffnTKuiIsl6xU+4IA8UNhQ/f1sAnQebmE/lyQ8abjsVyDkyRkWop0kdMhKMprpNIhPmYlCxgHrPoXoA==", - "cpu": [ - "x64" - ], - "optional": true, - "os": [ - "linux" - ] + "node_modules/@types/graceful-fs": { + "version": "4.1.9", + "resolved": "https://registry.npmjs.org/@types/graceful-fs/-/graceful-fs-4.1.9.tgz", + "integrity": "sha512-olP3sd1qOEe5dXTSaFvQG+02VdRXcdytWLAZsAq1PecU8uqQAhkrnbli7DagjtXKW/Bl7YJbUsa8MPcuc8LHEQ==", + "dev": true, + "dependencies": { + "@types/node": "*" + } }, - "node_modules/@rollup/rollup-linux-x64-musl": { - "version": "4.13.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.13.0.tgz", - "integrity": "sha512-9RyNqoFNdF0vu/qqX63fKotBh43fJQeYC98hCaf89DYQpv+xu0D8QFSOS0biA7cGuqJFOc1bJ+m2rhhsKcw1hw==", - "cpu": [ - "x64" - ], - "optional": true, - "os": [ - "linux" - ] + "node_modules/@types/hast": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/hast/-/hast-3.0.4.tgz", + "integrity": "sha512-WPs+bbQw5aCj+x6laNGWLH3wviHtoCv/P3+otBhbOhJgG8qtpdAMlTCxLtsTWA7LH1Oh/bFCHsBn0TPS5m30EQ==", + "dependencies": { + "@types/unist": "*" + } }, - "node_modules/@rollup/rollup-win32-arm64-msvc": { - "version": "4.13.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.13.0.tgz", - "integrity": "sha512-46ue8ymtm/5PUU6pCvjlic0z82qWkxv54GTJZgHrQUuZnVH+tvvSP0LsozIDsCBFO4VjJ13N68wqrKSeScUKdA==", - "cpu": [ - "arm64" - ], - "optional": true, - "os": [ - "win32" - ] + "node_modules/@types/istanbul-lib-coverage": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/@types/istanbul-lib-coverage/-/istanbul-lib-coverage-2.0.6.tgz", + "integrity": "sha512-2QF/t/auWm0lsy8XtKVPG19v3sSOQlJe/YHZgfjb/KBBHOGSV+J2q/S671rcq9uTBrLAXmZpqJiaQbMT+zNU1w==", + "dev": true }, - "node_modules/@rollup/rollup-win32-ia32-msvc": { - "version": "4.13.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.13.0.tgz", - "integrity": "sha512-P5/MqLdLSlqxbeuJ3YDeX37srC8mCflSyTrUsgbU1c/U9j6l2g2GiIdYaGD9QjdMQPMSgYm7hgg0551wHyIluw==", - "cpu": [ - "ia32" - ], - "optional": true, - "os": [ - "win32" - ] + "node_modules/@types/istanbul-lib-report": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@types/istanbul-lib-report/-/istanbul-lib-report-3.0.3.tgz", + "integrity": "sha512-NQn7AHQnk/RSLOxrBbGyJM/aVQ+pjj5HCgasFxc0K/KhoATfQ/47AyUl15I2yBUpihjmas+a+VJBOqecrFH+uA==", + "dev": true, + "dependencies": { + "@types/istanbul-lib-coverage": "*" + } }, - "node_modules/@rollup/rollup-win32-x64-msvc": { - "version": "4.13.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.13.0.tgz", - "integrity": "sha512-UKXUQNbO3DOhzLRwHSpa0HnhhCgNODvfoPWv2FCXme8N/ANFfhIPMGuOT+QuKd16+B5yxZ0HdpNlqPvTMS1qfw==", - "cpu": [ - "x64" - ], - "optional": true, - "os": [ - "win32" - ] + "node_modules/@types/istanbul-reports": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/istanbul-reports/-/istanbul-reports-3.0.4.tgz", + "integrity": "sha512-pk2B1NWalF9toCRu6gjBzR69syFjP4Od8WRAX+0mmf9lAjCRicLOWc+ZrxZHx/0XRjotgkF9t6iaMJ+aXcOdZQ==", + "dev": true, + "dependencies": { + "@types/istanbul-lib-report": "*" + } }, - "node_modules/@sinclair/typebox": { - "version": "0.27.8", - "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.27.8.tgz", - "integrity": "sha512-+Fj43pSMwJs4KRrH/938Uf+uAELIgVBmQzg/q1YG10djyfA3TnrU8N8XzqCh/okZdszqBQTZf96idMfE5lnwTA==", + "node_modules/@types/jest": { + "version": "27.5.2", + "resolved": "https://registry.npmjs.org/@types/jest/-/jest-27.5.2.tgz", + "integrity": "sha512-mpT8LJJ4CMeeahobofYWIjFo0xonRS/HfxnVEPMPFSQdGUt1uHCnoPT7Zhb+sjDU2wz0oKV0OLUR0WzrHNgfeA==", + "dependencies": { + "jest-matcher-utils": "^27.0.0", + "pretty-format": "^27.0.0" + } + }, + "node_modules/@types/jsdom": { + "version": "20.0.1", + "resolved": "https://registry.npmjs.org/@types/jsdom/-/jsdom-20.0.1.tgz", + "integrity": "sha512-d0r18sZPmMQr1eG35u12FZfhIXNrnsPU/g5wvRKCUf/tOGilKKwYMYGqh33BNR6ba+2gkHw1EUiHoN3mn7E5IQ==", + "dev": true, + "dependencies": { + "@types/node": "*", + "@types/tough-cookie": "*", + "parse5": "^7.0.0" + } + }, + "node_modules/@types/mdast": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/@types/mdast/-/mdast-4.0.3.tgz", + "integrity": "sha512-LsjtqsyF+d2/yFOYaN22dHZI1Cpwkrj+g06G8+qtUKlhovPW89YhqSnfKtMbkgmEtYpH2gydRNULd6y8mciAFg==", + "dependencies": { + "@types/unist": "*" + } + }, + "node_modules/@types/mdx": { + "version": "2.0.13", + "resolved": "https://registry.npmjs.org/@types/mdx/-/mdx-2.0.13.tgz", + "integrity": "sha512-+OWZQfAYyio6YkJb3HLxDrvnx6SWWDbC0zVPfBRzUk0/nqoDyf6dNxQi3eArPe8rJ473nobTMQ/8Zk+LxJ+Yuw==" + }, + "node_modules/@types/ms": { + "version": "0.7.34", + "resolved": "https://registry.npmjs.org/@types/ms/-/ms-0.7.34.tgz", + "integrity": "sha512-nG96G3Wp6acyAgJqGasjODb+acrI7KltPiRxzHPXnP3NgI28bpQDRv53olbqGXbfcgF5aiiHmO3xpwEpS5Ld9g==" + }, + "node_modules/@types/node": { + "version": "20.12.7", + "resolved": "https://registry.npmjs.org/@types/node/-/node-20.12.7.tgz", + "integrity": "sha512-wq0cICSkRLVaf3UGLMGItu/PtdY7oaXaI/RVU+xliKVOtRna3PRY57ZDfztpDL0n11vfymMUnXv8QwYCO7L1wg==", + "dependencies": { + "undici-types": "~5.26.4" + } + }, + "node_modules/@types/prop-types": { + "version": "15.7.12", + "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.12.tgz", + "integrity": "sha512-5zvhXYtRNRluoE/jAp4GVsSduVUzNWKkOZrCDBWYtE7biZywwdC2AcEzg+cSMLFRfVgeAFqpfNabiPjxFddV1Q==" + }, + "node_modules/@types/react": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.1.tgz", + "integrity": "sha512-V0kuGBX3+prX+DQ/7r2qsv1NsdfnCLnTgnRJ1pYnxykBhGMz+qj+box5lq7XsO5mtZsBqpjwwTu/7wszPfMBcw==", + "dependencies": { + "@types/prop-types": "*", + "csstype": "^3.0.2" + } + }, + "node_modules/@types/react-dom": { + "version": "18.3.0", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.3.0.tgz", + "integrity": "sha512-EhwApuTmMBmXuFOikhQLIBUn6uFg81SwLMOAUgodJF14SOBOCMdU04gDoYi0WOJJHD144TL32z4yDqCW3dnkQg==", + "dependencies": { + "@types/react": "*" + } + }, + "node_modules/@types/react-syntax-highlighter": { + "version": "15.5.11", + "integrity": "sha512-ZqIJl+Pg8kD+47kxUjvrlElrraSUrYa4h0dauY/U/FTUuprSCqvUj+9PNQNQzVc6AJgIWUUxn87/gqsMHNbRjw==", + "dev": true, + "dependencies": { + "@types/react": "*" + } + }, + "node_modules/@types/stack-utils": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/@types/stack-utils/-/stack-utils-2.0.3.tgz", + "integrity": "sha512-9aEbYZ3TbYMznPdcdr3SmIrLXwC/AKZXQeCf9Pgao5CKb8CyHuEX5jzWPTkvregvhRJHcpRO6BFoGW9ycaOkYw==", "dev": true }, - "node_modules/@sinonjs/commons": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/@sinonjs/commons/-/commons-3.0.1.tgz", - "integrity": "sha512-K3mCHKQ9sVh8o1C9cxkwxaOmXoAMlDxC1mYyHrjqOWEcBjYr76t96zL2zlj5dUGZ3HSw240X1qgH3Mjf1yJWpQ==", + "node_modules/@types/testing-library__jest-dom": { + "version": "5.14.9", + "resolved": "https://registry.npmjs.org/@types/testing-library__jest-dom/-/testing-library__jest-dom-5.14.9.tgz", + "integrity": "sha512-FSYhIjFlfOpGSRyVoMBMuS3ws5ehFQODymf3vlI7U1K8c7PHwWwFY7VREfmsuzHSOnoKs/9/Y983ayOs7eRzqw==", + "dependencies": { + "@types/jest": "*" + } + }, + "node_modules/@types/tough-cookie": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/@types/tough-cookie/-/tough-cookie-4.0.5.tgz", + "integrity": "sha512-/Ad8+nIOV7Rl++6f1BdKxFSMgmoqEoYbHRpPcx3JEfv8VRsQe9Z4mCXeJBzxs7mbHY/XOZZuXlRNfhpVPbs6ZA==", + "dev": true + }, + "node_modules/@types/unist": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/unist/-/unist-3.0.2.tgz", + "integrity": "sha512-dqId9J8K/vGi5Zr7oo212BGii5m3q5Hxlkwy3WpYuKPklmBEvsbMYYyLxAQpSffdLl/gdW0XUpKWFvYmyoWCoQ==" + }, + "node_modules/@types/yargs": { + "version": "17.0.32", + "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-17.0.32.tgz", + "integrity": "sha512-xQ67Yc/laOG5uMfX/093MRlGGCIBzZMarVa+gfNKJxWAIgykYpVGkBdbqEzGDDfCrVUj6Hiff4mTZ5BA6TmAog==", "dev": true, "dependencies": { - "type-detect": "4.0.8" + "@types/yargs-parser": "*" } }, - "node_modules/@sinonjs/fake-timers": { - "version": "10.3.0", - "resolved": "https://registry.npmjs.org/@sinonjs/fake-timers/-/fake-timers-10.3.0.tgz", - "integrity": "sha512-V4BG07kuYSUkTCSBHG8G8TNhM+F19jXFWnQtzj+we8DrkpSBCee9Z3Ms8yiGer/dlmhe35/Xdgyo3/0rQKg7YA==", + "node_modules/@types/yargs-parser": { + "version": "21.0.3", + "resolved": "https://registry.npmjs.org/@types/yargs-parser/-/yargs-parser-21.0.3.tgz", + "integrity": "sha512-I4q9QU9MQv4oEOz4tAHJtNz1cwuLxn2F3xcc2iV5WdqLPpUnj30aUuxt1mAxYTG+oe8CZMV/+6rU4S4gRDzqtQ==", + "dev": true + }, + "node_modules/@ungap/structured-clone": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.2.0.tgz", + "integrity": "sha512-zuVdFrMJiuCDQUMCzQaD6KL28MjnqqN8XnAqiEq9PNm/hCPTSGfrXCOfwj1ow4LFb/tNymJPwsNbVePc1xFqrQ==" + }, + "node_modules/@vitejs/plugin-react": { + "version": "4.2.1", + "integrity": "sha512-oojO9IDc4nCUUi8qIR11KoQm0XFFLIwsRBwHRR4d/88IWghn1y6ckz/bJ8GHDCsYEJee8mDzqtJxh15/cisJNQ==", + "dependencies": { + "@babel/core": "^7.23.5", + "@babel/plugin-transform-react-jsx-self": "^7.23.3", + "@babel/plugin-transform-react-jsx-source": "^7.23.3", + "@types/babel__core": "^7.20.5", + "react-refresh": "^0.14.0" + }, + "engines": { + "node": "^14.18.0 || >=16.0.0" + }, + "peerDependencies": { + "vite": "^4.2.0 || ^5.0.0" + } + }, + "node_modules/abab": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/abab/-/abab-2.0.6.tgz", + "integrity": "sha512-j2afSsaIENvHZN2B8GOpF566vZ5WVk5opAiMTvWgaQT8DkbOqsTfvNAvHoRGU2zzP8cPoqys+xHTRDWW8L+/BA==", + "deprecated": "Use your platform's native atob() and btoa() methods instead", + "dev": true + }, + "node_modules/acorn": { + "version": "8.11.3", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.11.3.tgz", + "integrity": "sha512-Y9rRfJG5jcKOE0CLisYbojUjIrIEE7AGMzA/Sm4BslANhbS+cDMpgBdcPT91oJ7OuJ9hYJBx59RjbhxVnrF8Xg==", + "dev": true, + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/acorn-globals": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/acorn-globals/-/acorn-globals-7.0.1.tgz", + "integrity": "sha512-umOSDSDrfHbTNPuNpC2NSnnA3LUrqpevPb4T9jRx4MagXNS0rs+gwiTcAvqCRmsD6utzsrzNt+ebm00SNWiC3Q==", "dev": true, "dependencies": { - "@sinonjs/commons": "^3.0.0" + "acorn": "^8.1.0", + "acorn-walk": "^8.0.2" + } + }, + "node_modules/acorn-walk": { + "version": "8.3.2", + "resolved": "https://registry.npmjs.org/acorn-walk/-/acorn-walk-8.3.2.tgz", + "integrity": "sha512-cjkyv4OtNCIeqhHrfS81QWXoCBPExR/J62oyEqepVw8WaQeSqpW2uhuLPh1m9eWhDuOo/jUXVTlifvesOWp/4A==", + "dev": true, + "engines": { + "node": ">=0.4.0" } }, - "node_modules/@testing-library/dom": { - "version": "9.3.4", - "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-9.3.4.tgz", - "integrity": "sha512-FlS4ZWlp97iiNWig0Muq8p+3rVDjRiYE+YKGbAqXOu9nwJFFOdL00kFpz42M+4huzYi86vAK1sOOfyOG45muIQ==", + "node_modules/agent-base": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz", + "integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==", + "dev": true, "dependencies": { - "@babel/code-frame": "^7.10.4", - "@babel/runtime": "^7.12.5", - "@types/aria-query": "^5.0.1", - "aria-query": "5.1.3", - "chalk": "^4.1.0", - "dom-accessibility-api": "^0.5.9", - "lz-string": "^1.5.0", - "pretty-format": "^27.0.2" + "debug": "4" }, "engines": { - "node": ">=14" + "node": ">= 6.0.0" } }, - "node_modules/@testing-library/dom/node_modules/ansi-styles": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", - "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "node_modules/ansi-escapes": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-4.3.2.tgz", + "integrity": "sha512-gKXj5ALrKWQLsYG9jlTRmR/xKluxHV+Z9QEwNIgCfM1/uwPMCuzVVnh5mwTd+OuBZcwSIMbqssNWRm1lE51QaQ==", + "dev": true, "dependencies": { - "color-convert": "^2.0.1" + "type-fest": "^0.21.3" }, "engines": { "node": ">=8" }, "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" + "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/@testing-library/dom/node_modules/aria-query": { - "version": "5.1.3", - "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.1.3.tgz", - "integrity": "sha512-R5iJ5lkuHybztUfuOAznmboyjWq8O6sqNqtK7CLOqdydi54VNbORp49mb14KbWgG1QD3JFO9hJdZ+y4KutfdOQ==", - "dependencies": { - "deep-equal": "^2.0.5" + "node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "engines": { + "node": ">=8" } }, - "node_modules/@testing-library/dom/node_modules/chalk": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", - "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "node_modules/ansi-styles": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-3.2.1.tgz", + "integrity": "sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA==", "dependencies": { - "ansi-styles": "^4.1.0", - "supports-color": "^7.1.0" + "color-convert": "^1.9.0" }, "engines": { - "node": ">=10" + "node": ">=4" + } + }, + "node_modules/antd": { + "version": "5.14.2", + "integrity": "sha512-ur0oBI9U7hAeON4ZRs1cAF1suIpTR+uj3YliTZacWkiVxNTZYPaaTdnLuAZDRMT9P2IZ007dCQTqxn5t1Z+Dxw==", + "dependencies": { + "@ant-design/colors": "^7.0.2", + "@ant-design/cssinjs": "^1.18.4", + "@ant-design/icons": "^5.3.0", + "@ant-design/react-slick": "~1.0.2", + "@ctrl/tinycolor": "^3.6.1", + "@rc-component/color-picker": "~1.5.2", + "@rc-component/mutate-observer": "^1.1.0", + "@rc-component/tour": "~1.12.3", + "@rc-component/trigger": "^1.18.3", + "classnames": "^2.5.1", + "copy-to-clipboard": "^3.3.3", + "dayjs": "^1.11.10", + "qrcode.react": "^3.1.0", + "rc-cascader": "~3.21.2", + "rc-checkbox": "~3.1.0", + "rc-collapse": "~3.7.2", + "rc-dialog": "~9.3.4", + "rc-drawer": "~7.0.0", + "rc-dropdown": "~4.1.0", + "rc-field-form": "~1.41.0", + "rc-image": "~7.5.1", + "rc-input": "~1.4.3", + "rc-input-number": "~9.0.0", + "rc-mentions": "~2.10.1", + "rc-menu": "~9.12.4", + "rc-motion": "^2.9.0", + "rc-notification": "~5.3.0", + "rc-pagination": "~4.0.4", + "rc-picker": "~4.1.4", + "rc-progress": "~3.5.1", + "rc-rate": "~2.12.0", + "rc-resize-observer": "^1.4.0", + "rc-segmented": "~2.3.0", + "rc-select": "~14.11.0", + "rc-slider": "~10.5.0", + "rc-steps": "~6.0.1", + "rc-switch": "~4.1.0", + "rc-table": "~7.39.0", + "rc-tabs": "~14.0.0", + "rc-textarea": "~1.6.3", + "rc-tooltip": "~6.1.3", + "rc-tree": "~5.8.5", + "rc-tree-select": "~5.17.0", + "rc-upload": "~4.5.2", + "rc-util": "^5.38.2", + "scroll-into-view-if-needed": "^3.1.0", + "throttle-debounce": "^5.0.0" }, "funding": { - "url": "https://github.com/chalk/chalk?sponsor=1" + "type": "opencollective", + "url": "https://opencollective.com/ant-design" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" } }, - "node_modules/@testing-library/dom/node_modules/color-convert": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", - "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "node_modules/anymatch": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz", + "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==", + "dev": true, "dependencies": { - "color-name": "~1.1.4" + "normalize-path": "^3.0.0", + "picomatch": "^2.0.4" }, "engines": { - "node": ">=7.0.0" + "node": ">= 8" } }, - "node_modules/@testing-library/dom/node_modules/color-name": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", - "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==" + "node_modules/argparse": { + "version": "1.0.10", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-1.0.10.tgz", + "integrity": "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==", + "dev": true, + "dependencies": { + "sprintf-js": "~1.0.2" + } }, - "node_modules/@testing-library/dom/node_modules/has-flag": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", - "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "node_modules/aria-query": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.0.tgz", + "integrity": "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==", + "dependencies": { + "dequal": "^2.0.3" + } + }, + "node_modules/array-buffer-byte-length": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/array-buffer-byte-length/-/array-buffer-byte-length-1.0.1.tgz", + "integrity": "sha512-ahC5W1xgou+KTXix4sAO8Ki12Q+jf4i0+tmk3sC+zgcynshkHxzpXdImBehiUYKKKDwvfFiJl1tZt6ewscS1Mg==", + "dependencies": { + "call-bind": "^1.0.5", + "is-array-buffer": "^3.0.4" + }, "engines": { - "node": ">=8" + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/@testing-library/dom/node_modules/supports-color": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", - "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "node_modules/array-tree-filter": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/array-tree-filter/-/array-tree-filter-2.1.0.tgz", + "integrity": "sha512-4ROwICNlNw/Hqa9v+rk5h22KjmzB1JGTMVKP2AKJBOCgb0yL0ASf0+YvCcLNNwquOHNX48jkeZIJ3a+oOQqKcw==" + }, + "node_modules/async-validator": { + "version": "4.2.5", + "resolved": "https://registry.npmjs.org/async-validator/-/async-validator-4.2.5.tgz", + "integrity": "sha512-7HhHjtERjqlNbZtqNqy2rckN/SpOOlmDliet+lP7k+eKZEjPk3DgyeU9lIXLdeLz0uBbbVp+9Qdow9wJWgwwfg==" + }, + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==" + }, + "node_modules/available-typed-arrays": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/available-typed-arrays/-/available-typed-arrays-1.0.7.tgz", + "integrity": "sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==", "dependencies": { - "has-flag": "^4.0.0" + "possible-typed-array-names": "^1.0.0" }, "engines": { - "node": ">=8" + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/@testing-library/jest-dom": { - "version": "5.17.0", - "integrity": "sha512-ynmNeT7asXyH3aSVv4vvX4Rb+0qjOhdNHnO/3vuZNqPmhDpV/+rCSGwQ7bLcmU2cJ4dvoheIO85LQj0IbJHEtg==", + "node_modules/axios": { + "version": "1.6.7", + "integrity": "sha512-/hDJGff6/c7u0hDkvkGxR/oy6CbCs8ziCsC7SqmhjfozqiJGc8Z11wrv9z9lYfY4K8l+H9TpjcMDX0xOZmx+RA==", "dependencies": { - "@adobe/css-tools": "^4.0.1", - "@babel/runtime": "^7.9.2", - "@types/testing-library__jest-dom": "^5.9.1", - "aria-query": "^5.0.0", - "chalk": "^3.0.0", - "css.escape": "^1.5.1", - "dom-accessibility-api": "^0.5.6", - "lodash": "^4.17.15", - "redent": "^3.0.0" + "follow-redirects": "^1.15.4", + "form-data": "^4.0.0", + "proxy-from-env": "^1.1.0" + } + }, + "node_modules/babel-jest": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/babel-jest/-/babel-jest-29.7.0.tgz", + "integrity": "sha512-BrvGY3xZSwEcCzKvKsCi2GgHqDqsYkOP4/by5xCgIwGXQxIEh+8ew3gmrE1y7XRR6LHZIj6yLYnUi/mm2KXKBg==", + "dev": true, + "dependencies": { + "@jest/transform": "^29.7.0", + "@types/babel__core": "^7.1.14", + "babel-plugin-istanbul": "^6.1.1", + "babel-preset-jest": "^29.6.3", + "chalk": "^4.0.0", + "graceful-fs": "^4.2.9", + "slash": "^3.0.0" }, "engines": { - "node": ">=8", - "npm": ">=6", - "yarn": ">=1" + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + }, + "peerDependencies": { + "@babel/core": "^7.8.0" } }, - "node_modules/@testing-library/jest-dom/node_modules/ansi-styles": { + "node_modules/babel-jest/node_modules/ansi-styles": { "version": "4.3.0", "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, "dependencies": { "color-convert": "^2.0.1" }, @@ -2487,22 +2596,27 @@ "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, - "node_modules/@testing-library/jest-dom/node_modules/chalk": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-3.0.0.tgz", - "integrity": "sha512-4D3B6Wf41KOYRFdszmDqMCGq5VV/uMAB273JILmO+3jAlh8X4qDtdtgCR3fxtbLEMzSx22QdhnDcJvu2u1fVwg==", + "node_modules/babel-jest/node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" }, "engines": { - "node": ">=8" + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" } }, - "node_modules/@testing-library/jest-dom/node_modules/color-convert": { + "node_modules/babel-jest/node_modules/color-convert": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, "dependencies": { "color-name": "~1.1.4" }, @@ -2510,23 +2624,26 @@ "node": ">=7.0.0" } }, - "node_modules/@testing-library/jest-dom/node_modules/color-name": { + "node_modules/babel-jest/node_modules/color-name": { "version": "1.1.4", "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", - "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==" + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true }, - "node_modules/@testing-library/jest-dom/node_modules/has-flag": { + "node_modules/babel-jest/node_modules/has-flag": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, "engines": { "node": ">=8" } }, - "node_modules/@testing-library/jest-dom/node_modules/supports-color": { + "node_modules/babel-jest/node_modules/supports-color": { "version": "7.2.0", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, "dependencies": { "has-flag": "^4.0.0" }, @@ -2534,533 +2651,449 @@ "node": ">=8" } }, - "node_modules/@testing-library/react": { - "version": "14.2.2", - "integrity": "sha512-SOUuM2ysCvjUWBXTNfQ/ztmnKDmqaiPV3SvoIuyxMUca45rbSWWAT/qB8CUs/JQ/ux/8JFs9DNdFQ3f6jH3crA==", + "node_modules/babel-plugin-istanbul": { + "version": "6.1.1", + "resolved": "https://registry.npmjs.org/babel-plugin-istanbul/-/babel-plugin-istanbul-6.1.1.tgz", + "integrity": "sha512-Y1IQok9821cC9onCx5otgFfRm7Lm+I+wwxOx738M/WLPZ9Q42m4IG5W0FNX8WLL2gYMZo3JkuXIH2DOpWM+qwA==", "dev": true, "dependencies": { - "@babel/runtime": "^7.12.5", - "@testing-library/dom": "^9.0.0", - "@types/react-dom": "^18.0.0" + "@babel/helper-plugin-utils": "^7.0.0", + "@istanbuljs/load-nyc-config": "^1.0.0", + "@istanbuljs/schema": "^0.1.2", + "istanbul-lib-instrument": "^5.0.4", + "test-exclude": "^6.0.0" }, "engines": { - "node": ">=14" - }, - "peerDependencies": { - "react": "^18.0.0", - "react-dom": "^18.0.0" + "node": ">=8" } }, - "node_modules/@testing-library/user-event": { - "version": "13.5.0", - "integrity": "sha512-5Kwtbo3Y/NowpkbRuSepbyMFkZmHgD+vPzYB/RJ4oxt5Gj/avFFBYjhw27cqSVPVw/3a67NK1PbiIr9k4Gwmdg==", + "node_modules/babel-plugin-istanbul/node_modules/istanbul-lib-instrument": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/istanbul-lib-instrument/-/istanbul-lib-instrument-5.2.1.tgz", + "integrity": "sha512-pzqtp31nLv/XFOzXGuvhCb8qhjmTVo5vjVk19XE4CRlSWz0KoeJ3bw9XsA7nOp9YBf4qHjwBxkDzKcME/J29Yg==", + "dev": true, "dependencies": { - "@babel/runtime": "^7.12.5" - }, - "engines": { - "node": ">=10", - "npm": ">=6" + "@babel/core": "^7.12.3", + "@babel/parser": "^7.14.7", + "@istanbuljs/schema": "^0.1.2", + "istanbul-lib-coverage": "^3.2.0", + "semver": "^6.3.0" }, - "peerDependencies": { - "@testing-library/dom": ">=7.21.4" - } - }, - "node_modules/@tootallnate/once": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/@tootallnate/once/-/once-2.0.0.tgz", - "integrity": "sha512-XCuKFP5PS55gnMVu3dty8KPatLqUoy/ZYzDzAGCQ8JNFCkLXzmI7vNHCR+XpbZaMWQK/vQubr7PkYq8g470J/A==", - "dev": true, "engines": { - "node": ">= 10" - } - }, - "node_modules/@types/aria-query": { - "version": "5.0.4", - "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz", - "integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==" - }, - "node_modules/@types/babel__core": { - "version": "7.20.5", - "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", - "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==", - "dependencies": { - "@babel/parser": "^7.20.7", - "@babel/types": "^7.20.7", - "@types/babel__generator": "*", - "@types/babel__template": "*", - "@types/babel__traverse": "*" - } - }, - "node_modules/@types/babel__generator": { - "version": "7.6.8", - "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.6.8.tgz", - "integrity": "sha512-ASsj+tpEDsEiFr1arWrlN6V3mdfjRMZt6LtK/Vp/kreFLnr5QH5+DhvD5nINYZXzwJvXeGq+05iUXcAzVrqWtw==", - "dependencies": { - "@babel/types": "^7.0.0" - } - }, - "node_modules/@types/babel__template": { - "version": "7.4.4", - "resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.4.tgz", - "integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==", - "dependencies": { - "@babel/parser": "^7.1.0", - "@babel/types": "^7.0.0" - } - }, - "node_modules/@types/babel__traverse": { - "version": "7.20.5", - "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.20.5.tgz", - "integrity": "sha512-WXCyOcRtH37HAUkpXhUduaxdm82b4GSlyTqajXviN4EfiuPgNYR109xMCKvpl6zPIpua0DGlMEDCq+g8EdoheQ==", - "dependencies": { - "@babel/types": "^7.20.7" - } - }, - "node_modules/@types/debug": { - "version": "4.1.12", - "resolved": "https://registry.npmjs.org/@types/debug/-/debug-4.1.12.tgz", - "integrity": "sha512-vIChWdVG3LG1SMxEvI/AK+FWJthlrqlTu7fbrlywTkkaONwk/UAGaULXRlf8vkzFBLVm0zkMdCquhL5aOjhXPQ==", - "dependencies": { - "@types/ms": "*" - } - }, - "node_modules/@types/estree": { - "version": "1.0.5", - "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.5.tgz", - "integrity": "sha512-/kYRxGDLWzHOB7q+wtSUQlFrtcdUccpfy+X+9iMBpHK8QLLhx2wIPYuS5DYtR9Wa/YlZAbIovy7qVdB1Aq6Lyw==" - }, - "node_modules/@types/estree-jsx": { - "version": "1.0.5", - "resolved": "https://registry.npmjs.org/@types/estree-jsx/-/estree-jsx-1.0.5.tgz", - "integrity": "sha512-52CcUVNFyfb1A2ALocQw/Dd1BQFNmSdkuC3BkZ6iqhdMfQz7JWOFRuJFloOzjk+6WijU56m9oKXFAXc7o3Towg==", - "dependencies": { - "@types/estree": "*" - } - }, - "node_modules/@types/graceful-fs": { - "version": "4.1.9", - "resolved": "https://registry.npmjs.org/@types/graceful-fs/-/graceful-fs-4.1.9.tgz", - "integrity": "sha512-olP3sd1qOEe5dXTSaFvQG+02VdRXcdytWLAZsAq1PecU8uqQAhkrnbli7DagjtXKW/Bl7YJbUsa8MPcuc8LHEQ==", - "dev": true, - "dependencies": { - "@types/node": "*" - } - }, - "node_modules/@types/hast": { - "version": "3.0.4", - "resolved": "https://registry.npmjs.org/@types/hast/-/hast-3.0.4.tgz", - "integrity": "sha512-WPs+bbQw5aCj+x6laNGWLH3wviHtoCv/P3+otBhbOhJgG8qtpdAMlTCxLtsTWA7LH1Oh/bFCHsBn0TPS5m30EQ==", - "dependencies": { - "@types/unist": "*" + "node": ">=8" } }, - "node_modules/@types/istanbul-lib-coverage": { - "version": "2.0.6", - "resolved": "https://registry.npmjs.org/@types/istanbul-lib-coverage/-/istanbul-lib-coverage-2.0.6.tgz", - "integrity": "sha512-2QF/t/auWm0lsy8XtKVPG19v3sSOQlJe/YHZgfjb/KBBHOGSV+J2q/S671rcq9uTBrLAXmZpqJiaQbMT+zNU1w==", - "dev": true - }, - "node_modules/@types/istanbul-lib-report": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/@types/istanbul-lib-report/-/istanbul-lib-report-3.0.3.tgz", - "integrity": "sha512-NQn7AHQnk/RSLOxrBbGyJM/aVQ+pjj5HCgasFxc0K/KhoATfQ/47AyUl15I2yBUpihjmas+a+VJBOqecrFH+uA==", + "node_modules/babel-plugin-jest-hoist": { + "version": "29.6.3", + "resolved": "https://registry.npmjs.org/babel-plugin-jest-hoist/-/babel-plugin-jest-hoist-29.6.3.tgz", + "integrity": "sha512-ESAc/RJvGTFEzRwOTT4+lNDk/GNHMkKbNzsvT0qKRfDyyYTskxB5rnU2njIDYVxXCBHHEI1c0YwHob3WaYujOg==", "dev": true, "dependencies": { - "@types/istanbul-lib-coverage": "*" + "@babel/template": "^7.3.3", + "@babel/types": "^7.3.3", + "@types/babel__core": "^7.1.14", + "@types/babel__traverse": "^7.0.6" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" } }, - "node_modules/@types/istanbul-reports": { - "version": "3.0.4", - "resolved": "https://registry.npmjs.org/@types/istanbul-reports/-/istanbul-reports-3.0.4.tgz", - "integrity": "sha512-pk2B1NWalF9toCRu6gjBzR69syFjP4Od8WRAX+0mmf9lAjCRicLOWc+ZrxZHx/0XRjotgkF9t6iaMJ+aXcOdZQ==", + "node_modules/babel-preset-current-node-syntax": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/babel-preset-current-node-syntax/-/babel-preset-current-node-syntax-1.0.1.tgz", + "integrity": "sha512-M7LQ0bxarkxQoN+vz5aJPsLBn77n8QgTFmo8WK0/44auK2xlCXrYcUxHFxgU7qW5Yzw/CjmLRK2uJzaCd7LvqQ==", "dev": true, "dependencies": { - "@types/istanbul-lib-report": "*" - } - }, - "node_modules/@types/jest": { - "version": "27.5.2", - "resolved": "https://registry.npmjs.org/@types/jest/-/jest-27.5.2.tgz", - "integrity": "sha512-mpT8LJJ4CMeeahobofYWIjFo0xonRS/HfxnVEPMPFSQdGUt1uHCnoPT7Zhb+sjDU2wz0oKV0OLUR0WzrHNgfeA==", - "dependencies": { - "jest-matcher-utils": "^27.0.0", - "pretty-format": "^27.0.0" + "@babel/plugin-syntax-async-generators": "^7.8.4", + "@babel/plugin-syntax-bigint": "^7.8.3", + "@babel/plugin-syntax-class-properties": "^7.8.3", + "@babel/plugin-syntax-import-meta": "^7.8.3", + "@babel/plugin-syntax-json-strings": "^7.8.3", + "@babel/plugin-syntax-logical-assignment-operators": "^7.8.3", + "@babel/plugin-syntax-nullish-coalescing-operator": "^7.8.3", + "@babel/plugin-syntax-numeric-separator": "^7.8.3", + "@babel/plugin-syntax-object-rest-spread": "^7.8.3", + "@babel/plugin-syntax-optional-catch-binding": "^7.8.3", + "@babel/plugin-syntax-optional-chaining": "^7.8.3", + "@babel/plugin-syntax-top-level-await": "^7.8.3" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" } }, - "node_modules/@types/jsdom": { - "version": "20.0.1", - "resolved": "https://registry.npmjs.org/@types/jsdom/-/jsdom-20.0.1.tgz", - "integrity": "sha512-d0r18sZPmMQr1eG35u12FZfhIXNrnsPU/g5wvRKCUf/tOGilKKwYMYGqh33BNR6ba+2gkHw1EUiHoN3mn7E5IQ==", + "node_modules/babel-preset-jest": { + "version": "29.6.3", + "resolved": "https://registry.npmjs.org/babel-preset-jest/-/babel-preset-jest-29.6.3.tgz", + "integrity": "sha512-0B3bhxR6snWXJZtR/RliHTDPRgn1sNHOR0yVtq/IiQFyuOVjFS+wuio/R4gSNkyYmKmJB4wGZv2NZanmKmTnNA==", "dev": true, "dependencies": { - "@types/node": "*", - "@types/tough-cookie": "*", - "parse5": "^7.0.0" - } - }, - "node_modules/@types/mdast": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/@types/mdast/-/mdast-4.0.3.tgz", - "integrity": "sha512-LsjtqsyF+d2/yFOYaN22dHZI1Cpwkrj+g06G8+qtUKlhovPW89YhqSnfKtMbkgmEtYpH2gydRNULd6y8mciAFg==", - "dependencies": { - "@types/unist": "*" - } - }, - "node_modules/@types/ms": { - "version": "0.7.34", - "resolved": "https://registry.npmjs.org/@types/ms/-/ms-0.7.34.tgz", - "integrity": "sha512-nG96G3Wp6acyAgJqGasjODb+acrI7KltPiRxzHPXnP3NgI28bpQDRv53olbqGXbfcgF5aiiHmO3xpwEpS5Ld9g==" - }, - "node_modules/@types/node": { - "version": "20.12.7", - "resolved": "https://registry.npmjs.org/@types/node/-/node-20.12.7.tgz", - "integrity": "sha512-wq0cICSkRLVaf3UGLMGItu/PtdY7oaXaI/RVU+xliKVOtRna3PRY57ZDfztpDL0n11vfymMUnXv8QwYCO7L1wg==", - "dependencies": { - "undici-types": "~5.26.4" + "babel-plugin-jest-hoist": "^29.6.3", + "babel-preset-current-node-syntax": "^1.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" } }, - "node_modules/@types/prop-types": { - "version": "15.7.12", - "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.12.tgz", - "integrity": "sha512-5zvhXYtRNRluoE/jAp4GVsSduVUzNWKkOZrCDBWYtE7biZywwdC2AcEzg+cSMLFRfVgeAFqpfNabiPjxFddV1Q==" - }, - "node_modules/@types/react": { - "version": "18.3.1", - "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.1.tgz", - "integrity": "sha512-V0kuGBX3+prX+DQ/7r2qsv1NsdfnCLnTgnRJ1pYnxykBhGMz+qj+box5lq7XsO5mtZsBqpjwwTu/7wszPfMBcw==", - "dependencies": { - "@types/prop-types": "*", - "csstype": "^3.0.2" + "node_modules/bail": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/bail/-/bail-2.0.2.tgz", + "integrity": "sha512-0xO6mYd7JB2YesxDKplafRpsiOzPt9V02ddPCLbY1xYGPOX24NTyN50qnUxgCPcSoYMhKpAuBTjQoRZCAkUDRw==", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" } }, - "node_modules/@types/react-dom": { - "version": "18.3.0", - "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.3.0.tgz", - "integrity": "sha512-EhwApuTmMBmXuFOikhQLIBUn6uFg81SwLMOAUgodJF14SOBOCMdU04gDoYi0WOJJHD144TL32z4yDqCW3dnkQg==", + "node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true + }, + "node_modules/brace-expansion": { + "version": "1.1.11", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", + "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==", + "dev": true, "dependencies": { - "@types/react": "*" + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" } }, - "node_modules/@types/react-syntax-highlighter": { - "version": "15.5.11", - "integrity": "sha512-ZqIJl+Pg8kD+47kxUjvrlElrraSUrYa4h0dauY/U/FTUuprSCqvUj+9PNQNQzVc6AJgIWUUxn87/gqsMHNbRjw==", + "node_modules/braces": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.2.tgz", + "integrity": "sha512-b8um+L1RzM3WDSzvhm6gIz1yfTbBt6YTlcEKAvsmqCZZFw46z626lVj9j1yEPW33H5H+lBQpZMP1k8l+78Ha0A==", "dev": true, "dependencies": { - "@types/react": "*" + "fill-range": "^7.0.1" + }, + "engines": { + "node": ">=8" } }, - "node_modules/@types/stack-utils": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/@types/stack-utils/-/stack-utils-2.0.3.tgz", - "integrity": "sha512-9aEbYZ3TbYMznPdcdr3SmIrLXwC/AKZXQeCf9Pgao5CKb8CyHuEX5jzWPTkvregvhRJHcpRO6BFoGW9ycaOkYw==", - "dev": true - }, - "node_modules/@types/testing-library__jest-dom": { - "version": "5.14.9", - "resolved": "https://registry.npmjs.org/@types/testing-library__jest-dom/-/testing-library__jest-dom-5.14.9.tgz", - "integrity": "sha512-FSYhIjFlfOpGSRyVoMBMuS3ws5ehFQODymf3vlI7U1K8c7PHwWwFY7VREfmsuzHSOnoKs/9/Y983ayOs7eRzqw==", + "node_modules/browserslist": { + "version": "4.23.0", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.23.0.tgz", + "integrity": "sha512-QW8HiM1shhT2GuzkvklfjcKDiWFXHOeFCIA/huJPwHsslwcydgk7X+z2zXpEijP98UCY7HbubZt5J2Zgvf0CaQ==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], "dependencies": { - "@types/jest": "*" + "caniuse-lite": "^1.0.30001587", + "electron-to-chromium": "^1.4.668", + "node-releases": "^2.0.14", + "update-browserslist-db": "^1.0.13" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" } }, - "node_modules/@types/tough-cookie": { - "version": "4.0.5", - "resolved": "https://registry.npmjs.org/@types/tough-cookie/-/tough-cookie-4.0.5.tgz", - "integrity": "sha512-/Ad8+nIOV7Rl++6f1BdKxFSMgmoqEoYbHRpPcx3JEfv8VRsQe9Z4mCXeJBzxs7mbHY/XOZZuXlRNfhpVPbs6ZA==", - "dev": true - }, - "node_modules/@types/unist": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/@types/unist/-/unist-3.0.2.tgz", - "integrity": "sha512-dqId9J8K/vGi5Zr7oo212BGii5m3q5Hxlkwy3WpYuKPklmBEvsbMYYyLxAQpSffdLl/gdW0XUpKWFvYmyoWCoQ==" + "node_modules/bs-logger": { + "version": "0.2.6", + "resolved": "https://registry.npmjs.org/bs-logger/-/bs-logger-0.2.6.tgz", + "integrity": "sha512-pd8DCoxmbgc7hyPKOvxtqNcjYoOsABPQdcCUjGp3d42VR2CX1ORhk2A87oqqu5R1kk+76nsxZupkmyd+MVtCog==", + "dev": true, + "dependencies": { + "fast-json-stable-stringify": "2.x" + }, + "engines": { + "node": ">= 6" + } }, - "node_modules/@types/yargs": { - "version": "17.0.32", - "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-17.0.32.tgz", - "integrity": "sha512-xQ67Yc/laOG5uMfX/093MRlGGCIBzZMarVa+gfNKJxWAIgykYpVGkBdbqEzGDDfCrVUj6Hiff4mTZ5BA6TmAog==", + "node_modules/bser": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/bser/-/bser-2.1.1.tgz", + "integrity": "sha512-gQxTNE/GAfIIrmHLUE3oJyp5FO6HRBfhjnw4/wMmA63ZGDJnWBmgY/lyQBpnDUkGmAhbSe39tx2d/iTOAfglwQ==", "dev": true, "dependencies": { - "@types/yargs-parser": "*" + "node-int64": "^0.4.0" } }, - "node_modules/@types/yargs-parser": { - "version": "21.0.3", - "resolved": "https://registry.npmjs.org/@types/yargs-parser/-/yargs-parser-21.0.3.tgz", - "integrity": "sha512-I4q9QU9MQv4oEOz4tAHJtNz1cwuLxn2F3xcc2iV5WdqLPpUnj30aUuxt1mAxYTG+oe8CZMV/+6rU4S4gRDzqtQ==", + "node_modules/buffer-from": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", + "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==", "dev": true }, - "node_modules/@ungap/structured-clone": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.2.0.tgz", - "integrity": "sha512-zuVdFrMJiuCDQUMCzQaD6KL28MjnqqN8XnAqiEq9PNm/hCPTSGfrXCOfwj1ow4LFb/tNymJPwsNbVePc1xFqrQ==" - }, - "node_modules/@vitejs/plugin-react": { - "version": "4.2.1", - "integrity": "sha512-oojO9IDc4nCUUi8qIR11KoQm0XFFLIwsRBwHRR4d/88IWghn1y6ckz/bJ8GHDCsYEJee8mDzqtJxh15/cisJNQ==", + "node_modules/call-bind": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.7.tgz", + "integrity": "sha512-GHTSNSYICQ7scH7sZ+M2rFopRoLh8t2bLSW6BbgrtLsahOIB5iyAVJf9GjWK3cYTDaMj4XdBpM1cA6pIS0Kv2w==", "dependencies": { - "@babel/core": "^7.23.5", - "@babel/plugin-transform-react-jsx-self": "^7.23.3", - "@babel/plugin-transform-react-jsx-source": "^7.23.3", - "@types/babel__core": "^7.20.5", - "react-refresh": "^0.14.0" + "es-define-property": "^1.0.0", + "es-errors": "^1.3.0", + "function-bind": "^1.1.2", + "get-intrinsic": "^1.2.4", + "set-function-length": "^1.2.1" }, "engines": { - "node": "^14.18.0 || >=16.0.0" + "node": ">= 0.4" }, - "peerDependencies": { - "vite": "^4.2.0 || ^5.0.0" + "funding": { + "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/abab": { - "version": "2.0.6", - "resolved": "https://registry.npmjs.org/abab/-/abab-2.0.6.tgz", - "integrity": "sha512-j2afSsaIENvHZN2B8GOpF566vZ5WVk5opAiMTvWgaQT8DkbOqsTfvNAvHoRGU2zzP8cPoqys+xHTRDWW8L+/BA==", - "deprecated": "Use your platform's native atob() and btoa() methods instead", - "dev": true - }, - "node_modules/acorn": { - "version": "8.11.3", - "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.11.3.tgz", - "integrity": "sha512-Y9rRfJG5jcKOE0CLisYbojUjIrIEE7AGMzA/Sm4BslANhbS+cDMpgBdcPT91oJ7OuJ9hYJBx59RjbhxVnrF8Xg==", + "node_modules/callsites": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", + "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", "dev": true, - "bin": { - "acorn": "bin/acorn" - }, "engines": { - "node": ">=0.4.0" + "node": ">=6" } }, - "node_modules/acorn-globals": { - "version": "7.0.1", - "resolved": "https://registry.npmjs.org/acorn-globals/-/acorn-globals-7.0.1.tgz", - "integrity": "sha512-umOSDSDrfHbTNPuNpC2NSnnA3LUrqpevPb4T9jRx4MagXNS0rs+gwiTcAvqCRmsD6utzsrzNt+ebm00SNWiC3Q==", + "node_modules/camelcase": { + "version": "5.3.1", + "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz", + "integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==", "dev": true, - "dependencies": { - "acorn": "^8.1.0", - "acorn-walk": "^8.0.2" + "engines": { + "node": ">=6" } }, - "node_modules/acorn-walk": { - "version": "8.3.2", - "resolved": "https://registry.npmjs.org/acorn-walk/-/acorn-walk-8.3.2.tgz", - "integrity": "sha512-cjkyv4OtNCIeqhHrfS81QWXoCBPExR/J62oyEqepVw8WaQeSqpW2uhuLPh1m9eWhDuOo/jUXVTlifvesOWp/4A==", - "dev": true, - "engines": { - "node": ">=0.4.0" + "node_modules/caniuse-lite": { + "version": "1.0.30001614", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001614.tgz", + "integrity": "sha512-jmZQ1VpmlRwHgdP1/uiKzgiAuGOfLEJsYFP4+GBou/QQ4U6IOJCB4NP1c+1p9RGLpwObcT94jA5/uO+F1vBbog==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ] + }, + "node_modules/ccount": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/ccount/-/ccount-2.0.1.tgz", + "integrity": "sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" } }, - "node_modules/agent-base": { - "version": "6.0.2", - "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz", - "integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==", - "dev": true, + "node_modules/chalk": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-2.4.2.tgz", + "integrity": "sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ==", "dependencies": { - "debug": "4" + "ansi-styles": "^3.2.1", + "escape-string-regexp": "^1.0.5", + "supports-color": "^5.3.0" }, "engines": { - "node": ">= 6.0.0" + "node": ">=4" } }, - "node_modules/ansi-escapes": { - "version": "4.3.2", - "resolved": "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-4.3.2.tgz", - "integrity": "sha512-gKXj5ALrKWQLsYG9jlTRmR/xKluxHV+Z9QEwNIgCfM1/uwPMCuzVVnh5mwTd+OuBZcwSIMbqssNWRm1lE51QaQ==", + "node_modules/char-regex": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/char-regex/-/char-regex-1.0.2.tgz", + "integrity": "sha512-kWWXztvZ5SBQV+eRgKFeh8q5sLuZY2+8WUIzlxWVTg+oGwY14qylx1KbKzHd8P6ZYkAg0xyIDU9JMHhyJMZ1jw==", "dev": true, - "dependencies": { - "type-fest": "^0.21.3" - }, "engines": { - "node": ">=8" - }, + "node": ">=10" + } + }, + "node_modules/character-entities": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/character-entities/-/character-entities-2.0.2.tgz", + "integrity": "sha512-shx7oQ0Awen/BRIdkjkvz54PnEEI/EjwXDSIZp86/KKdbafHh1Df/RYGBhn4hbe2+uKC9FnT5UCEdyPz3ai9hQ==", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/character-entities-html4": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/character-entities-html4/-/character-entities-html4-2.1.0.tgz", + "integrity": "sha512-1v7fgQRj6hnSwFpq1Eu0ynr/CDEw0rXo2B61qXrLNdHZmPKgb7fqS1a2JwF0rISo9q77jDI8VMEHoApn8qDoZA==", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/character-entities-legacy": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/character-entities-legacy/-/character-entities-legacy-3.0.0.tgz", + "integrity": "sha512-RpPp0asT/6ufRm//AJVwpViZbGM/MkjQFxJccQRHmISF/22NBtsHqAWmL+/pmkPWoIUJdWyeVleTl1wydHATVQ==", "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "type": "github", + "url": "https://github.com/sponsors/wooorm" } }, - "node_modules/ansi-regex": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", - "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", - "engines": { - "node": ">=8" + "node_modules/character-reference-invalid": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/character-reference-invalid/-/character-reference-invalid-2.0.1.tgz", + "integrity": "sha512-iBZ4F4wRbyORVsu0jPV7gXkOsGYjGHPmAyv+HiHG8gi5PtC9KI2j1+v8/tlibRvjoWX027ypmG/n0HtO5t7unw==", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" } }, - "node_modules/ansi-styles": { - "version": "3.2.1", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-3.2.1.tgz", - "integrity": "sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA==", - "dependencies": { - "color-convert": "^1.9.0" - }, + "node_modules/ci-info": { + "version": "3.9.0", + "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-3.9.0.tgz", + "integrity": "sha512-NIxF55hv4nSqQswkAeiOi1r83xy8JldOFDTWiug55KBu9Jnblncd2U6ViHmYgHf01TPZS77NJBhBMKdWj9HQMQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/sibiraj-s" + } + ], "engines": { - "node": ">=4" + "node": ">=8" } }, - "node_modules/antd": { - "version": "5.14.2", - "integrity": "sha512-ur0oBI9U7hAeON4ZRs1cAF1suIpTR+uj3YliTZacWkiVxNTZYPaaTdnLuAZDRMT9P2IZ007dCQTqxn5t1Z+Dxw==", - "dependencies": { - "@ant-design/colors": "^7.0.2", - "@ant-design/cssinjs": "^1.18.4", - "@ant-design/icons": "^5.3.0", - "@ant-design/react-slick": "~1.0.2", - "@ctrl/tinycolor": "^3.6.1", - "@rc-component/color-picker": "~1.5.2", - "@rc-component/mutate-observer": "^1.1.0", - "@rc-component/tour": "~1.12.3", - "@rc-component/trigger": "^1.18.3", - "classnames": "^2.5.1", - "copy-to-clipboard": "^3.3.3", - "dayjs": "^1.11.10", - "qrcode.react": "^3.1.0", - "rc-cascader": "~3.21.2", - "rc-checkbox": "~3.1.0", - "rc-collapse": "~3.7.2", - "rc-dialog": "~9.3.4", - "rc-drawer": "~7.0.0", - "rc-dropdown": "~4.1.0", - "rc-field-form": "~1.41.0", - "rc-image": "~7.5.1", - "rc-input": "~1.4.3", - "rc-input-number": "~9.0.0", - "rc-mentions": "~2.10.1", - "rc-menu": "~9.12.4", - "rc-motion": "^2.9.0", - "rc-notification": "~5.3.0", - "rc-pagination": "~4.0.4", - "rc-picker": "~4.1.4", - "rc-progress": "~3.5.1", - "rc-rate": "~2.12.0", - "rc-resize-observer": "^1.4.0", - "rc-segmented": "~2.3.0", - "rc-select": "~14.11.0", - "rc-slider": "~10.5.0", - "rc-steps": "~6.0.1", - "rc-switch": "~4.1.0", - "rc-table": "~7.39.0", - "rc-tabs": "~14.0.0", - "rc-textarea": "~1.6.3", - "rc-tooltip": "~6.1.3", - "rc-tree": "~5.8.5", - "rc-tree-select": "~5.17.0", - "rc-upload": "~4.5.2", - "rc-util": "^5.38.2", - "scroll-into-view-if-needed": "^3.1.0", - "throttle-debounce": "^5.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/ant-design" - }, - "peerDependencies": { - "react": ">=16.9.0", - "react-dom": ">=16.9.0" - } + "node_modules/cjs-module-lexer": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/cjs-module-lexer/-/cjs-module-lexer-1.3.1.tgz", + "integrity": "sha512-a3KdPAANPbNE4ZUv9h6LckSl9zLsYOP4MBmhIPkRaeyybt+r4UghLvq+xw/YwUcC1gqylCkL4rdVs3Lwupjm4Q==", + "dev": true }, - "node_modules/anymatch": { - "version": "3.1.3", - "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz", - "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==", + "node_modules/classnames": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/classnames/-/classnames-2.5.1.tgz", + "integrity": "sha512-saHYOzhIQs6wy2sVxTM6bUDsQO4F50V9RQ22qBpEdCW+I+/Wmke2HOl6lS6dTpdxVhb88/I6+Hs+438c3lfUow==" + }, + "node_modules/cliui": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz", + "integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==", "dev": true, "dependencies": { - "normalize-path": "^3.0.0", - "picomatch": "^2.0.4" + "string-width": "^4.2.0", + "strip-ansi": "^6.0.1", + "wrap-ansi": "^7.0.0" }, "engines": { - "node": ">= 8" + "node": ">=12" } }, - "node_modules/argparse": { - "version": "1.0.10", - "resolved": "https://registry.npmjs.org/argparse/-/argparse-1.0.10.tgz", - "integrity": "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==", + "node_modules/co": { + "version": "4.6.0", + "resolved": "https://registry.npmjs.org/co/-/co-4.6.0.tgz", + "integrity": "sha512-QVb0dM5HvG+uaxitm8wONl7jltx8dqhfU33DcqtOZcLSVIKSDDLDi7+0LbAKiyI8hD9u42m2YxXSkMGWThaecQ==", "dev": true, - "dependencies": { - "sprintf-js": "~1.0.2" + "engines": { + "iojs": ">= 1.0.0", + "node": ">= 0.12.0" } }, - "node_modules/aria-query": { - "version": "5.3.0", - "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.0.tgz", - "integrity": "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==", + "node_modules/collect-v8-coverage": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/collect-v8-coverage/-/collect-v8-coverage-1.0.2.tgz", + "integrity": "sha512-lHl4d5/ONEbLlJvaJNtsF/Lz+WvB07u2ycqTYbdrq7UypDXailES4valYb2eWiJFxZlVmpGekfqoxQhzyFdT4Q==", + "dev": true + }, + "node_modules/color-convert": { + "version": "1.9.3", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-1.9.3.tgz", + "integrity": "sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg==", "dependencies": { - "dequal": "^2.0.3" + "color-name": "1.1.3" } }, - "node_modules/array-buffer-byte-length": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/array-buffer-byte-length/-/array-buffer-byte-length-1.0.1.tgz", - "integrity": "sha512-ahC5W1xgou+KTXix4sAO8Ki12Q+jf4i0+tmk3sC+zgcynshkHxzpXdImBehiUYKKKDwvfFiJl1tZt6ewscS1Mg==", + "node_modules/color-name": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.3.tgz", + "integrity": "sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw==" + }, + "node_modules/combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", "dependencies": { - "call-bind": "^1.0.5", - "is-array-buffer": "^3.0.4" + "delayed-stream": "~1.0.0" }, "engines": { - "node": ">= 0.4" - }, + "node": ">= 0.8" + } + }, + "node_modules/comma-separated-tokens": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/comma-separated-tokens/-/comma-separated-tokens-2.0.3.tgz", + "integrity": "sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg==", "funding": { - "url": "https://github.com/sponsors/ljharb" + "type": "github", + "url": "https://github.com/sponsors/wooorm" } }, - "node_modules/array-tree-filter": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/array-tree-filter/-/array-tree-filter-2.1.0.tgz", - "integrity": "sha512-4ROwICNlNw/Hqa9v+rk5h22KjmzB1JGTMVKP2AKJBOCgb0yL0ASf0+YvCcLNNwquOHNX48jkeZIJ3a+oOQqKcw==" + "node_modules/compute-scroll-into-view": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/compute-scroll-into-view/-/compute-scroll-into-view-3.1.0.tgz", + "integrity": "sha512-rj8l8pD4bJ1nx+dAkMhV1xB5RuZEyVysfxJqB1pRchh1KVvwOv9b7CGB8ZfjTImVv2oF+sYMUkMZq6Na5Ftmbg==" }, - "node_modules/async-validator": { - "version": "4.2.5", - "resolved": "https://registry.npmjs.org/async-validator/-/async-validator-4.2.5.tgz", - "integrity": "sha512-7HhHjtERjqlNbZtqNqy2rckN/SpOOlmDliet+lP7k+eKZEjPk3DgyeU9lIXLdeLz0uBbbVp+9Qdow9wJWgwwfg==" + "node_modules/concat-map": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", + "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", + "dev": true }, - "node_modules/asynckit": { - "version": "0.4.0", - "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", - "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==" + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==" }, - "node_modules/available-typed-arrays": { - "version": "1.0.7", - "resolved": "https://registry.npmjs.org/available-typed-arrays/-/available-typed-arrays-1.0.7.tgz", - "integrity": "sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==", + "node_modules/copy-to-clipboard": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/copy-to-clipboard/-/copy-to-clipboard-3.3.3.tgz", + "integrity": "sha512-2KV8NhB5JqC3ky0r9PMCAZKbUHSwtEo4CwCs0KXgruG43gX5PMqDEBbVU4OUzw2MuAWUfsuFmWvEKG5QRfSnJA==", "dependencies": { - "possible-typed-array-names": "^1.0.0" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" + "toggle-selection": "^1.0.6" } }, - "node_modules/axios": { - "version": "1.6.7", - "integrity": "sha512-/hDJGff6/c7u0hDkvkGxR/oy6CbCs8ziCsC7SqmhjfozqiJGc8Z11wrv9z9lYfY4K8l+H9TpjcMDX0xOZmx+RA==", - "dependencies": { - "follow-redirects": "^1.15.4", - "form-data": "^4.0.0", - "proxy-from-env": "^1.1.0" - } + "node_modules/core-util-is": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz", + "integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==" }, - "node_modules/babel-jest": { + "node_modules/create-jest": { "version": "29.7.0", - "resolved": "https://registry.npmjs.org/babel-jest/-/babel-jest-29.7.0.tgz", - "integrity": "sha512-BrvGY3xZSwEcCzKvKsCi2GgHqDqsYkOP4/by5xCgIwGXQxIEh+8ew3gmrE1y7XRR6LHZIj6yLYnUi/mm2KXKBg==", + "resolved": "https://registry.npmjs.org/create-jest/-/create-jest-29.7.0.tgz", + "integrity": "sha512-Adz2bdH0Vq3F53KEMJOoftQFutWCukm6J24wbPWRO4k1kMY7gS7ds/uoJkNuV8wDCtWWnuwGcJwpWcih+zEW1Q==", "dev": true, "dependencies": { - "@jest/transform": "^29.7.0", - "@types/babel__core": "^7.1.14", - "babel-plugin-istanbul": "^6.1.1", - "babel-preset-jest": "^29.6.3", + "@jest/types": "^29.6.3", "chalk": "^4.0.0", + "exit": "^0.1.2", "graceful-fs": "^4.2.9", - "slash": "^3.0.0" + "jest-config": "^29.7.0", + "jest-util": "^29.7.0", + "prompts": "^2.0.1" + }, + "bin": { + "create-jest": "bin/create-jest.js" }, "engines": { "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - }, - "peerDependencies": { - "@babel/core": "^7.8.0" } }, - "node_modules/babel-jest/node_modules/ansi-styles": { + "node_modules/create-jest/node_modules/ansi-styles": { "version": "4.3.0", "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", @@ -3075,7 +3108,7 @@ "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, - "node_modules/babel-jest/node_modules/chalk": { + "node_modules/create-jest/node_modules/chalk": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", @@ -3091,7 +3124,7 @@ "url": "https://github.com/chalk/chalk?sponsor=1" } }, - "node_modules/babel-jest/node_modules/color-convert": { + "node_modules/create-jest/node_modules/color-convert": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", @@ -3103,13 +3136,13 @@ "node": ">=7.0.0" } }, - "node_modules/babel-jest/node_modules/color-name": { + "node_modules/create-jest/node_modules/color-name": { "version": "1.1.4", "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", "dev": true }, - "node_modules/babel-jest/node_modules/has-flag": { + "node_modules/create-jest/node_modules/has-flag": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", @@ -3118,7 +3151,7 @@ "node": ">=8" } }, - "node_modules/babel-jest/node_modules/supports-color": { + "node_modules/create-jest/node_modules/supports-color": { "version": "7.2.0", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", @@ -3130,885 +3163,718 @@ "node": ">=8" } }, - "node_modules/babel-plugin-istanbul": { - "version": "6.1.1", - "resolved": "https://registry.npmjs.org/babel-plugin-istanbul/-/babel-plugin-istanbul-6.1.1.tgz", - "integrity": "sha512-Y1IQok9821cC9onCx5otgFfRm7Lm+I+wwxOx738M/WLPZ9Q42m4IG5W0FNX8WLL2gYMZo3JkuXIH2DOpWM+qwA==", - "dev": true, - "dependencies": { - "@babel/helper-plugin-utils": "^7.0.0", - "@istanbuljs/load-nyc-config": "^1.0.0", - "@istanbuljs/schema": "^0.1.2", - "istanbul-lib-instrument": "^5.0.4", - "test-exclude": "^6.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/babel-plugin-istanbul/node_modules/istanbul-lib-instrument": { - "version": "5.2.1", - "resolved": "https://registry.npmjs.org/istanbul-lib-instrument/-/istanbul-lib-instrument-5.2.1.tgz", - "integrity": "sha512-pzqtp31nLv/XFOzXGuvhCb8qhjmTVo5vjVk19XE4CRlSWz0KoeJ3bw9XsA7nOp9YBf4qHjwBxkDzKcME/J29Yg==", - "dev": true, - "dependencies": { - "@babel/core": "^7.12.3", - "@babel/parser": "^7.14.7", - "@istanbuljs/schema": "^0.1.2", - "istanbul-lib-coverage": "^3.2.0", - "semver": "^6.3.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/babel-plugin-jest-hoist": { - "version": "29.6.3", - "resolved": "https://registry.npmjs.org/babel-plugin-jest-hoist/-/babel-plugin-jest-hoist-29.6.3.tgz", - "integrity": "sha512-ESAc/RJvGTFEzRwOTT4+lNDk/GNHMkKbNzsvT0qKRfDyyYTskxB5rnU2njIDYVxXCBHHEI1c0YwHob3WaYujOg==", - "dev": true, - "dependencies": { - "@babel/template": "^7.3.3", - "@babel/types": "^7.3.3", - "@types/babel__core": "^7.1.14", - "@types/babel__traverse": "^7.0.6" - }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - } - }, - "node_modules/babel-preset-current-node-syntax": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/babel-preset-current-node-syntax/-/babel-preset-current-node-syntax-1.0.1.tgz", - "integrity": "sha512-M7LQ0bxarkxQoN+vz5aJPsLBn77n8QgTFmo8WK0/44auK2xlCXrYcUxHFxgU7qW5Yzw/CjmLRK2uJzaCd7LvqQ==", - "dev": true, - "dependencies": { - "@babel/plugin-syntax-async-generators": "^7.8.4", - "@babel/plugin-syntax-bigint": "^7.8.3", - "@babel/plugin-syntax-class-properties": "^7.8.3", - "@babel/plugin-syntax-import-meta": "^7.8.3", - "@babel/plugin-syntax-json-strings": "^7.8.3", - "@babel/plugin-syntax-logical-assignment-operators": "^7.8.3", - "@babel/plugin-syntax-nullish-coalescing-operator": "^7.8.3", - "@babel/plugin-syntax-numeric-separator": "^7.8.3", - "@babel/plugin-syntax-object-rest-spread": "^7.8.3", - "@babel/plugin-syntax-optional-catch-binding": "^7.8.3", - "@babel/plugin-syntax-optional-chaining": "^7.8.3", - "@babel/plugin-syntax-top-level-await": "^7.8.3" - }, - "peerDependencies": { - "@babel/core": "^7.0.0" - } - }, - "node_modules/babel-preset-jest": { - "version": "29.6.3", - "resolved": "https://registry.npmjs.org/babel-preset-jest/-/babel-preset-jest-29.6.3.tgz", - "integrity": "sha512-0B3bhxR6snWXJZtR/RliHTDPRgn1sNHOR0yVtq/IiQFyuOVjFS+wuio/R4gSNkyYmKmJB4wGZv2NZanmKmTnNA==", - "dev": true, - "dependencies": { - "babel-plugin-jest-hoist": "^29.6.3", - "babel-preset-current-node-syntax": "^1.0.0" - }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0" - } - }, - "node_modules/bail": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/bail/-/bail-2.0.2.tgz", - "integrity": "sha512-0xO6mYd7JB2YesxDKplafRpsiOzPt9V02ddPCLbY1xYGPOX24NTyN50qnUxgCPcSoYMhKpAuBTjQoRZCAkUDRw==", - "funding": { - "type": "github", - "url": "https://github.com/sponsors/wooorm" - } - }, - "node_modules/balanced-match": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", - "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", - "dev": true - }, - "node_modules/brace-expansion": { - "version": "1.1.11", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", - "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==", - "dev": true, - "dependencies": { - "balanced-match": "^1.0.0", - "concat-map": "0.0.1" - } - }, - "node_modules/braces": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.2.tgz", - "integrity": "sha512-b8um+L1RzM3WDSzvhm6gIz1yfTbBt6YTlcEKAvsmqCZZFw46z626lVj9j1yEPW33H5H+lBQpZMP1k8l+78Ha0A==", - "dev": true, - "dependencies": { - "fill-range": "^7.0.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/browserslist": { - "version": "4.23.0", - "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.23.0.tgz", - "integrity": "sha512-QW8HiM1shhT2GuzkvklfjcKDiWFXHOeFCIA/huJPwHsslwcydgk7X+z2zXpEijP98UCY7HbubZt5J2Zgvf0CaQ==", - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/browserslist" - }, - { - "type": "tidelift", - "url": "https://tidelift.com/funding/github/npm/browserslist" - }, - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "dependencies": { - "caniuse-lite": "^1.0.30001587", - "electron-to-chromium": "^1.4.668", - "node-releases": "^2.0.14", - "update-browserslist-db": "^1.0.13" - }, - "bin": { - "browserslist": "cli.js" - }, - "engines": { - "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" - } - }, - "node_modules/bs-logger": { - "version": "0.2.6", - "resolved": "https://registry.npmjs.org/bs-logger/-/bs-logger-0.2.6.tgz", - "integrity": "sha512-pd8DCoxmbgc7hyPKOvxtqNcjYoOsABPQdcCUjGp3d42VR2CX1ORhk2A87oqqu5R1kk+76nsxZupkmyd+MVtCog==", - "dev": true, - "dependencies": { - "fast-json-stable-stringify": "2.x" - }, - "engines": { - "node": ">= 6" - } - }, - "node_modules/bser": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/bser/-/bser-2.1.1.tgz", - "integrity": "sha512-gQxTNE/GAfIIrmHLUE3oJyp5FO6HRBfhjnw4/wMmA63ZGDJnWBmgY/lyQBpnDUkGmAhbSe39tx2d/iTOAfglwQ==", + "node_modules/cross-spawn": { + "version": "7.0.3", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.3.tgz", + "integrity": "sha512-iRDPJKUPVEND7dHPO8rkbOnPpyDygcDFtWjpeWNCgy8WP2rXcxXL8TskReQl6OrB2G7+UJrags1q15Fudc7G6w==", "dev": true, "dependencies": { - "node-int64": "^0.4.0" - } - }, - "node_modules/buffer-from": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", - "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==", - "dev": true - }, - "node_modules/call-bind": { - "version": "1.0.7", - "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.7.tgz", - "integrity": "sha512-GHTSNSYICQ7scH7sZ+M2rFopRoLh8t2bLSW6BbgrtLsahOIB5iyAVJf9GjWK3cYTDaMj4XdBpM1cA6pIS0Kv2w==", - "dependencies": { - "es-define-property": "^1.0.0", - "es-errors": "^1.3.0", - "function-bind": "^1.1.2", - "get-intrinsic": "^1.2.4", - "set-function-length": "^1.2.1" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/callsites": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", - "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", - "dev": true, - "engines": { - "node": ">=6" - } - }, - "node_modules/camelcase": { - "version": "5.3.1", - "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz", - "integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==", - "dev": true, - "engines": { - "node": ">=6" - } - }, - "node_modules/caniuse-lite": { - "version": "1.0.30001614", - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001614.tgz", - "integrity": "sha512-jmZQ1VpmlRwHgdP1/uiKzgiAuGOfLEJsYFP4+GBou/QQ4U6IOJCB4NP1c+1p9RGLpwObcT94jA5/uO+F1vBbog==", - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/browserslist" - }, - { - "type": "tidelift", - "url": "https://tidelift.com/funding/github/npm/caniuse-lite" - }, - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ] - }, - "node_modules/ccount": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/ccount/-/ccount-2.0.1.tgz", - "integrity": "sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==", - "funding": { - "type": "github", - "url": "https://github.com/sponsors/wooorm" + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" } }, - "node_modules/chalk": { - "version": "2.4.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-2.4.2.tgz", - "integrity": "sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ==", + "node_modules/css.escape": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/css.escape/-/css.escape-1.5.1.tgz", + "integrity": "sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg==" + }, + "node_modules/cssom": { + "version": "0.5.0", + "resolved": "https://registry.npmjs.org/cssom/-/cssom-0.5.0.tgz", + "integrity": "sha512-iKuQcq+NdHqlAcwUY0o/HL69XQrUaQdMjmStJ8JFmUaiiQErlhrmuigkg/CU4E2J0IyUKUrMAgl36TvN67MqTw==", + "dev": true + }, + "node_modules/cssstyle": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/cssstyle/-/cssstyle-2.3.0.tgz", + "integrity": "sha512-AZL67abkUzIuvcHqk7c09cezpGNcxUxU4Ioi/05xHk4DQeTkWmGYftIE6ctU6AEt+Gn4n1lDStOtj7FKycP71A==", + "dev": true, "dependencies": { - "ansi-styles": "^3.2.1", - "escape-string-regexp": "^1.0.5", - "supports-color": "^5.3.0" + "cssom": "~0.3.6" }, "engines": { - "node": ">=4" + "node": ">=8" } }, - "node_modules/char-regex": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/char-regex/-/char-regex-1.0.2.tgz", - "integrity": "sha512-kWWXztvZ5SBQV+eRgKFeh8q5sLuZY2+8WUIzlxWVTg+oGwY14qylx1KbKzHd8P6ZYkAg0xyIDU9JMHhyJMZ1jw==", + "node_modules/cssstyle/node_modules/cssom": { + "version": "0.3.8", + "resolved": "https://registry.npmjs.org/cssom/-/cssom-0.3.8.tgz", + "integrity": "sha512-b0tGHbfegbhPJpxpiBPU2sCkigAqtM9O121le6bbOlgyV+NyGyCmVfJ6QW9eRjz8CpNfWEOYBIMIGRYkLwsIYg==", + "dev": true + }, + "node_modules/csstype": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz", + "integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==" + }, + "node_modules/data-urls": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-3.0.2.tgz", + "integrity": "sha512-Jy/tj3ldjZJo63sVAvg6LHt2mHvl4V6AgRAmNDtLdm7faqtsx+aJG42rsyCo9JCoRVKwPFzKlIPx3DIibwSIaQ==", "dev": true, + "dependencies": { + "abab": "^2.0.6", + "whatwg-mimetype": "^3.0.0", + "whatwg-url": "^11.0.0" + }, "engines": { - "node": ">=10" + "node": ">=12" } }, - "node_modules/character-entities": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/character-entities/-/character-entities-2.0.2.tgz", - "integrity": "sha512-shx7oQ0Awen/BRIdkjkvz54PnEEI/EjwXDSIZp86/KKdbafHh1Df/RYGBhn4hbe2+uKC9FnT5UCEdyPz3ai9hQ==", - "funding": { - "type": "github", - "url": "https://github.com/sponsors/wooorm" - } + "node_modules/dayjs": { + "version": "1.11.10", + "resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.10.tgz", + "integrity": "sha512-vjAczensTgRcqDERK0SR2XMwsF/tSvnvlv6VcF2GIhg6Sx4yOIt/irsr1RDJsKiIyBzJDpCoXiWWq28MqH2cnQ==" }, - "node_modules/character-entities-html4": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/character-entities-html4/-/character-entities-html4-2.1.0.tgz", - "integrity": "sha512-1v7fgQRj6hnSwFpq1Eu0ynr/CDEw0rXo2B61qXrLNdHZmPKgb7fqS1a2JwF0rISo9q77jDI8VMEHoApn8qDoZA==", - "funding": { - "type": "github", - "url": "https://github.com/sponsors/wooorm" + "node_modules/debug": { + "version": "4.3.4", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.4.tgz", + "integrity": "sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ==", + "dependencies": { + "ms": "2.1.2" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } } }, - "node_modules/character-entities-legacy": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/character-entities-legacy/-/character-entities-legacy-3.0.0.tgz", - "integrity": "sha512-RpPp0asT/6ufRm//AJVwpViZbGM/MkjQFxJccQRHmISF/22NBtsHqAWmL+/pmkPWoIUJdWyeVleTl1wydHATVQ==", - "funding": { - "type": "github", - "url": "https://github.com/sponsors/wooorm" - } + "node_modules/decimal.js": { + "version": "10.4.3", + "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.4.3.tgz", + "integrity": "sha512-VBBaLc1MgL5XpzgIP7ny5Z6Nx3UrRkIViUkPUdtl9aya5amy3De1gsUUSB1g3+3sExYNjCAsAznmukyxCb1GRA==", + "dev": true }, - "node_modules/character-reference-invalid": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/character-reference-invalid/-/character-reference-invalid-2.0.1.tgz", - "integrity": "sha512-iBZ4F4wRbyORVsu0jPV7gXkOsGYjGHPmAyv+HiHG8gi5PtC9KI2j1+v8/tlibRvjoWX027ypmG/n0HtO5t7unw==", + "node_modules/decode-named-character-reference": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/decode-named-character-reference/-/decode-named-character-reference-1.0.2.tgz", + "integrity": "sha512-O8x12RzrUF8xyVcY0KJowWsmaJxQbmy0/EtnNtHRpsOcT7dFk5W598coHqBVpmWo1oQQfsCqfCmkZN5DJrZVdg==", + "dependencies": { + "character-entities": "^2.0.0" + }, "funding": { "type": "github", "url": "https://github.com/sponsors/wooorm" } }, - "node_modules/ci-info": { - "version": "3.9.0", - "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-3.9.0.tgz", - "integrity": "sha512-NIxF55hv4nSqQswkAeiOi1r83xy8JldOFDTWiug55KBu9Jnblncd2U6ViHmYgHf01TPZS77NJBhBMKdWj9HQMQ==", + "node_modules/dedent": { + "version": "1.5.3", + "resolved": "https://registry.npmjs.org/dedent/-/dedent-1.5.3.tgz", + "integrity": "sha512-NHQtfOOW68WD8lgypbLA5oT+Bt0xXJhiYvoR6SmmNXZfpzOGXwdKWmcwG8N7PwVVWV3eF/68nmD9BaJSsTBhyQ==", "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/sibiraj-s" + "peerDependencies": { + "babel-plugin-macros": "^3.1.0" + }, + "peerDependenciesMeta": { + "babel-plugin-macros": { + "optional": true } - ], - "engines": { - "node": ">=8" } }, - "node_modules/cjs-module-lexer": { - "version": "1.3.1", - "resolved": "https://registry.npmjs.org/cjs-module-lexer/-/cjs-module-lexer-1.3.1.tgz", - "integrity": "sha512-a3KdPAANPbNE4ZUv9h6LckSl9zLsYOP4MBmhIPkRaeyybt+r4UghLvq+xw/YwUcC1gqylCkL4rdVs3Lwupjm4Q==", - "dev": true - }, - "node_modules/classnames": { - "version": "2.5.1", - "resolved": "https://registry.npmjs.org/classnames/-/classnames-2.5.1.tgz", - "integrity": "sha512-saHYOzhIQs6wy2sVxTM6bUDsQO4F50V9RQ22qBpEdCW+I+/Wmke2HOl6lS6dTpdxVhb88/I6+Hs+438c3lfUow==" - }, - "node_modules/cliui": { - "version": "8.0.1", - "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz", - "integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==", - "dev": true, + "node_modules/deep-equal": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/deep-equal/-/deep-equal-2.2.3.tgz", + "integrity": "sha512-ZIwpnevOurS8bpT4192sqAowWM76JDKSHYzMLty3BZGSswgq6pBaH3DhCSW5xVAZICZyKdOBPjwww5wfgT/6PA==", "dependencies": { - "string-width": "^4.2.0", - "strip-ansi": "^6.0.1", - "wrap-ansi": "^7.0.0" + "array-buffer-byte-length": "^1.0.0", + "call-bind": "^1.0.5", + "es-get-iterator": "^1.1.3", + "get-intrinsic": "^1.2.2", + "is-arguments": "^1.1.1", + "is-array-buffer": "^3.0.2", + "is-date-object": "^1.0.5", + "is-regex": "^1.1.4", + "is-shared-array-buffer": "^1.0.2", + "isarray": "^2.0.5", + "object-is": "^1.1.5", + "object-keys": "^1.1.1", + "object.assign": "^4.1.4", + "regexp.prototype.flags": "^1.5.1", + "side-channel": "^1.0.4", + "which-boxed-primitive": "^1.0.2", + "which-collection": "^1.0.1", + "which-typed-array": "^1.1.13" }, "engines": { - "node": ">=12" + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/co": { - "version": "4.6.0", - "resolved": "https://registry.npmjs.org/co/-/co-4.6.0.tgz", - "integrity": "sha512-QVb0dM5HvG+uaxitm8wONl7jltx8dqhfU33DcqtOZcLSVIKSDDLDi7+0LbAKiyI8hD9u42m2YxXSkMGWThaecQ==", + "node_modules/deepmerge": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz", + "integrity": "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==", "dev": true, "engines": { - "iojs": ">= 1.0.0", - "node": ">= 0.12.0" + "node": ">=0.10.0" } }, - "node_modules/collect-v8-coverage": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/collect-v8-coverage/-/collect-v8-coverage-1.0.2.tgz", - "integrity": "sha512-lHl4d5/ONEbLlJvaJNtsF/Lz+WvB07u2ycqTYbdrq7UypDXailES4valYb2eWiJFxZlVmpGekfqoxQhzyFdT4Q==", - "dev": true - }, - "node_modules/color-convert": { - "version": "1.9.3", - "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-1.9.3.tgz", - "integrity": "sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg==", + "node_modules/define-data-property": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.4.tgz", + "integrity": "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==", "dependencies": { - "color-name": "1.1.3" + "es-define-property": "^1.0.0", + "es-errors": "^1.3.0", + "gopd": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/color-name": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.3.tgz", - "integrity": "sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw==" - }, - "node_modules/combined-stream": { - "version": "1.0.8", - "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", - "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "node_modules/define-properties": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/define-properties/-/define-properties-1.2.1.tgz", + "integrity": "sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==", "dependencies": { - "delayed-stream": "~1.0.0" + "define-data-property": "^1.0.1", + "has-property-descriptors": "^1.0.0", + "object-keys": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/dequal": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", + "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", + "engines": { + "node": ">=6" + } + }, + "node_modules/detect-newline": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/detect-newline/-/detect-newline-3.1.0.tgz", + "integrity": "sha512-TLz+x/vEXm/Y7P7wn1EJFNLxYpUD4TgMosxY6fAVJUnJMbupHBOncxyWUG9OpTaH9EBD7uFI5LfEgmMOc54DsA==", + "dev": true, "engines": { - "node": ">= 0.8" + "node": ">=8" } }, - "node_modules/comma-separated-tokens": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/comma-separated-tokens/-/comma-separated-tokens-2.0.3.tgz", - "integrity": "sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg==", + "node_modules/devlop": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/devlop/-/devlop-1.1.0.tgz", + "integrity": "sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA==", + "dependencies": { + "dequal": "^2.0.0" + }, "funding": { "type": "github", "url": "https://github.com/sponsors/wooorm" } }, - "node_modules/compute-scroll-into-view": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/compute-scroll-into-view/-/compute-scroll-into-view-3.1.0.tgz", - "integrity": "sha512-rj8l8pD4bJ1nx+dAkMhV1xB5RuZEyVysfxJqB1pRchh1KVvwOv9b7CGB8ZfjTImVv2oF+sYMUkMZq6Na5Ftmbg==" - }, - "node_modules/concat-map": { - "version": "0.0.1", - "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", - "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", - "dev": true - }, - "node_modules/convert-source-map": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", - "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==" - }, - "node_modules/copy-to-clipboard": { - "version": "3.3.3", - "resolved": "https://registry.npmjs.org/copy-to-clipboard/-/copy-to-clipboard-3.3.3.tgz", - "integrity": "sha512-2KV8NhB5JqC3ky0r9PMCAZKbUHSwtEo4CwCs0KXgruG43gX5PMqDEBbVU4OUzw2MuAWUfsuFmWvEKG5QRfSnJA==", - "dependencies": { - "toggle-selection": "^1.0.6" + "node_modules/diff-sequences": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/diff-sequences/-/diff-sequences-27.5.1.tgz", + "integrity": "sha512-k1gCAXAsNgLwEL+Y8Wvl+M6oEFj5bgazfZULpS5CneoPPXRaCCW7dm+q21Ky2VEE5X+VeRDBVg1Pcvvsr4TtNQ==", + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" } }, - "node_modules/core-util-is": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz", - "integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==" + "node_modules/dom-accessibility-api": { + "version": "0.5.16", + "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz", + "integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==" }, - "node_modules/create-jest": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/create-jest/-/create-jest-29.7.0.tgz", - "integrity": "sha512-Adz2bdH0Vq3F53KEMJOoftQFutWCukm6J24wbPWRO4k1kMY7gS7ds/uoJkNuV8wDCtWWnuwGcJwpWcih+zEW1Q==", + "node_modules/domexception": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/domexception/-/domexception-4.0.0.tgz", + "integrity": "sha512-A2is4PLG+eeSfoTMA95/s4pvAoSo2mKtiM5jlHkAVewmiO8ISFTFKZjH7UAM1Atli/OT/7JHOrJRJiMKUZKYBw==", + "deprecated": "Use your platform's native DOMException instead", "dev": true, "dependencies": { - "@jest/types": "^29.6.3", - "chalk": "^4.0.0", - "exit": "^0.1.2", - "graceful-fs": "^4.2.9", - "jest-config": "^29.7.0", - "jest-util": "^29.7.0", - "prompts": "^2.0.1" - }, - "bin": { - "create-jest": "bin/create-jest.js" + "webidl-conversions": "^7.0.0" }, "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + "node": ">=12" } }, - "node_modules/create-jest/node_modules/ansi-styles": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", - "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "node_modules/electron-to-chromium": { + "version": "1.4.751", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.4.751.tgz", + "integrity": "sha512-2DEPi++qa89SMGRhufWTiLmzqyuGmNF3SK4+PQetW1JKiZdEpF4XQonJXJCzyuYSA6mauiMhbyVhqYAP45Hvfw==" + }, + "node_modules/emittery": { + "version": "0.13.1", + "resolved": "https://registry.npmjs.org/emittery/-/emittery-0.13.1.tgz", + "integrity": "sha512-DeWwawk6r5yR9jFgnDKYt4sLS0LmHJJi3ZOnb5/JdbYwj3nW+FxQnHIjhBKz8YLC7oRNPVM9NQ47I3CVx34eqQ==", "dev": true, - "dependencies": { - "color-convert": "^2.0.1" - }, "engines": { - "node": ">=8" + "node": ">=12" }, "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" + "url": "https://github.com/sindresorhus/emittery?sponsor=1" } }, - "node_modules/create-jest/node_modules/chalk": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", - "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "dev": true + }, + "node_modules/entities": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz", + "integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==", "dev": true, - "dependencies": { - "ansi-styles": "^4.1.0", - "supports-color": "^7.1.0" - }, "engines": { - "node": ">=10" + "node": ">=0.12" }, "funding": { - "url": "https://github.com/chalk/chalk?sponsor=1" + "url": "https://github.com/fb55/entities?sponsor=1" } }, - "node_modules/create-jest/node_modules/color-convert": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", - "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "node_modules/error-ex": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.2.tgz", + "integrity": "sha512-7dFHNmqeFSEt2ZBsCriorKnn3Z2pj+fd9kmI6QoWw4//DL+icEBfc0U7qJCisqrTsKTjw4fNFy2pW9OqStD84g==", "dev": true, "dependencies": { - "color-name": "~1.1.4" + "is-arrayish": "^0.2.1" + } + }, + "node_modules/es-define-property": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.0.tgz", + "integrity": "sha512-jxayLKShrEqqzJ0eumQbVhTYQM27CfT1T35+gCgDFoL82JLsXqTJ76zv6A0YLOgEnLUMvLzsDsGIrl8NFpT2gQ==", + "dependencies": { + "get-intrinsic": "^1.2.4" }, "engines": { - "node": ">=7.0.0" + "node": ">= 0.4" } }, - "node_modules/create-jest/node_modules/color-name": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", - "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", - "dev": true - }, - "node_modules/create-jest/node_modules/has-flag": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", - "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", - "dev": true, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", "engines": { - "node": ">=8" + "node": ">= 0.4" } }, - "node_modules/create-jest/node_modules/supports-color": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", - "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", - "dev": true, + "node_modules/es-get-iterator": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/es-get-iterator/-/es-get-iterator-1.1.3.tgz", + "integrity": "sha512-sPZmqHBe6JIiTfN5q2pEi//TwxmAFHwj/XEuYjTuse78i8KxaqMTTzxPoFKuzRpDpTJ+0NAbpfenkmH2rePtuw==", "dependencies": { - "has-flag": "^4.0.0" + "call-bind": "^1.0.2", + "get-intrinsic": "^1.1.3", + "has-symbols": "^1.0.3", + "is-arguments": "^1.1.1", + "is-map": "^2.0.2", + "is-set": "^2.0.2", + "is-string": "^1.0.7", + "isarray": "^2.0.5", + "stop-iteration-iterator": "^1.0.0" }, - "engines": { - "node": ">=8" + "funding": { + "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/cross-spawn": { - "version": "7.0.3", - "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.3.tgz", - "integrity": "sha512-iRDPJKUPVEND7dHPO8rkbOnPpyDygcDFtWjpeWNCgy8WP2rXcxXL8TskReQl6OrB2G7+UJrags1q15Fudc7G6w==", - "dev": true, - "dependencies": { - "path-key": "^3.1.0", - "shebang-command": "^2.0.0", - "which": "^2.0.1" + "node_modules/esbuild": { + "version": "0.19.12", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.19.12.tgz", + "integrity": "sha512-aARqgq8roFBj054KvQr5f1sFu0D65G+miZRCuJyJ0G13Zwx7vRar5Zhn2tkQNzIXcBrNVsv/8stehpj+GAjgbg==", + "hasInstallScript": true, + "bin": { + "esbuild": "bin/esbuild" }, "engines": { - "node": ">= 8" + "node": ">=12" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.19.12", + "@esbuild/android-arm": "0.19.12", + "@esbuild/android-arm64": "0.19.12", + "@esbuild/android-x64": "0.19.12", + "@esbuild/darwin-arm64": "0.19.12", + "@esbuild/darwin-x64": "0.19.12", + "@esbuild/freebsd-arm64": "0.19.12", + "@esbuild/freebsd-x64": "0.19.12", + "@esbuild/linux-arm": "0.19.12", + "@esbuild/linux-arm64": "0.19.12", + "@esbuild/linux-ia32": "0.19.12", + "@esbuild/linux-loong64": "0.19.12", + "@esbuild/linux-mips64el": "0.19.12", + "@esbuild/linux-ppc64": "0.19.12", + "@esbuild/linux-riscv64": "0.19.12", + "@esbuild/linux-s390x": "0.19.12", + "@esbuild/linux-x64": "0.19.12", + "@esbuild/netbsd-x64": "0.19.12", + "@esbuild/openbsd-x64": "0.19.12", + "@esbuild/sunos-x64": "0.19.12", + "@esbuild/win32-arm64": "0.19.12", + "@esbuild/win32-ia32": "0.19.12", + "@esbuild/win32-x64": "0.19.12" } }, - "node_modules/css.escape": { - "version": "1.5.1", - "resolved": "https://registry.npmjs.org/css.escape/-/css.escape-1.5.1.tgz", - "integrity": "sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg==" - }, - "node_modules/cssom": { - "version": "0.5.0", - "resolved": "https://registry.npmjs.org/cssom/-/cssom-0.5.0.tgz", - "integrity": "sha512-iKuQcq+NdHqlAcwUY0o/HL69XQrUaQdMjmStJ8JFmUaiiQErlhrmuigkg/CU4E2J0IyUKUrMAgl36TvN67MqTw==", - "dev": true + "node_modules/esbuild/node_modules/@esbuild/aix-ppc64": { + "version": "0.19.12", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.19.12.tgz", + "integrity": "sha512-bmoCYyWdEL3wDQIVbcyzRyeKLgk2WtWLTWz1ZIAZF/EGbNOwSA6ew3PftJ1PqMiOOGu0OyFMzG53L0zqIpPeNA==", + "cpu": [ + "ppc64" + ], + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=12" + } }, - "node_modules/cssstyle": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/cssstyle/-/cssstyle-2.3.0.tgz", - "integrity": "sha512-AZL67abkUzIuvcHqk7c09cezpGNcxUxU4Ioi/05xHk4DQeTkWmGYftIE6ctU6AEt+Gn4n1lDStOtj7FKycP71A==", - "dev": true, - "dependencies": { - "cssom": "~0.3.6" - }, + "node_modules/esbuild/node_modules/@esbuild/android-arm": { + "version": "0.19.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.19.12.tgz", + "integrity": "sha512-qg/Lj1mu3CdQlDEEiWrlC4eaPZ1KztwGJ9B6J+/6G+/4ewxJg7gqj8eVYWvao1bXrqGiW2rsBZFSX3q2lcW05w==", + "cpu": [ + "arm" + ], + "optional": true, + "os": [ + "android" + ], "engines": { - "node": ">=8" + "node": ">=12" } }, - "node_modules/cssstyle/node_modules/cssom": { - "version": "0.3.8", - "resolved": "https://registry.npmjs.org/cssom/-/cssom-0.3.8.tgz", - "integrity": "sha512-b0tGHbfegbhPJpxpiBPU2sCkigAqtM9O121le6bbOlgyV+NyGyCmVfJ6QW9eRjz8CpNfWEOYBIMIGRYkLwsIYg==", - "dev": true - }, - "node_modules/csstype": { - "version": "3.1.3", - "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz", - "integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==" - }, - "node_modules/data-urls": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-3.0.2.tgz", - "integrity": "sha512-Jy/tj3ldjZJo63sVAvg6LHt2mHvl4V6AgRAmNDtLdm7faqtsx+aJG42rsyCo9JCoRVKwPFzKlIPx3DIibwSIaQ==", - "dev": true, - "dependencies": { - "abab": "^2.0.6", - "whatwg-mimetype": "^3.0.0", - "whatwg-url": "^11.0.0" - }, + "node_modules/esbuild/node_modules/@esbuild/android-arm64": { + "version": "0.19.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.19.12.tgz", + "integrity": "sha512-P0UVNGIienjZv3f5zq0DP3Nt2IE/3plFzuaS96vihvD0Hd6H/q4WXUGpCxD/E8YrSXfNyRPbpTq+T8ZQioSuPA==", + "cpu": [ + "arm64" + ], + "optional": true, + "os": [ + "android" + ], "engines": { "node": ">=12" } }, - "node_modules/dayjs": { - "version": "1.11.10", - "resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.10.tgz", - "integrity": "sha512-vjAczensTgRcqDERK0SR2XMwsF/tSvnvlv6VcF2GIhg6Sx4yOIt/irsr1RDJsKiIyBzJDpCoXiWWq28MqH2cnQ==" - }, - "node_modules/debug": { - "version": "4.3.4", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.4.tgz", - "integrity": "sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ==", - "dependencies": { - "ms": "2.1.2" - }, + "node_modules/esbuild/node_modules/@esbuild/android-x64": { + "version": "0.19.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.19.12.tgz", + "integrity": "sha512-3k7ZoUW6Q6YqhdhIaq/WZ7HwBpnFBlW905Fa4s4qWJyiNOgT1dOqDiVAQFwBH7gBRZr17gLrlFCRzF6jFh7Kew==", + "cpu": [ + "x64" + ], + "optional": true, + "os": [ + "android" + ], "engines": { - "node": ">=6.0" - }, - "peerDependenciesMeta": { - "supports-color": { - "optional": true - } + "node": ">=12" } }, - "node_modules/decimal.js": { - "version": "10.4.3", - "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.4.3.tgz", - "integrity": "sha512-VBBaLc1MgL5XpzgIP7ny5Z6Nx3UrRkIViUkPUdtl9aya5amy3De1gsUUSB1g3+3sExYNjCAsAznmukyxCb1GRA==", - "dev": true - }, - "node_modules/decode-named-character-reference": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/decode-named-character-reference/-/decode-named-character-reference-1.0.2.tgz", - "integrity": "sha512-O8x12RzrUF8xyVcY0KJowWsmaJxQbmy0/EtnNtHRpsOcT7dFk5W598coHqBVpmWo1oQQfsCqfCmkZN5DJrZVdg==", - "dependencies": { - "character-entities": "^2.0.0" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/wooorm" + "node_modules/esbuild/node_modules/@esbuild/darwin-arm64": { + "version": "0.19.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.19.12.tgz", + "integrity": "sha512-B6IeSgZgtEzGC42jsI+YYu9Z3HKRxp8ZT3cqhvliEHovq8HSX2YX8lNocDn79gCKJXOSaEot9MVYky7AKjCs8g==", + "cpu": [ + "arm64" + ], + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" } }, - "node_modules/dedent": { - "version": "1.5.3", - "resolved": "https://registry.npmjs.org/dedent/-/dedent-1.5.3.tgz", - "integrity": "sha512-NHQtfOOW68WD8lgypbLA5oT+Bt0xXJhiYvoR6SmmNXZfpzOGXwdKWmcwG8N7PwVVWV3eF/68nmD9BaJSsTBhyQ==", - "dev": true, - "peerDependencies": { - "babel-plugin-macros": "^3.1.0" - }, - "peerDependenciesMeta": { - "babel-plugin-macros": { - "optional": true - } + "node_modules/esbuild/node_modules/@esbuild/darwin-x64": { + "version": "0.19.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.19.12.tgz", + "integrity": "sha512-hKoVkKzFiToTgn+41qGhsUJXFlIjxI/jSYeZf3ugemDYZldIXIxhvwN6erJGlX4t5h417iFuheZ7l+YVn05N3A==", + "cpu": [ + "x64" + ], + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" } }, - "node_modules/deep-equal": { - "version": "2.2.3", - "resolved": "https://registry.npmjs.org/deep-equal/-/deep-equal-2.2.3.tgz", - "integrity": "sha512-ZIwpnevOurS8bpT4192sqAowWM76JDKSHYzMLty3BZGSswgq6pBaH3DhCSW5xVAZICZyKdOBPjwww5wfgT/6PA==", - "dependencies": { - "array-buffer-byte-length": "^1.0.0", - "call-bind": "^1.0.5", - "es-get-iterator": "^1.1.3", - "get-intrinsic": "^1.2.2", - "is-arguments": "^1.1.1", - "is-array-buffer": "^3.0.2", - "is-date-object": "^1.0.5", - "is-regex": "^1.1.4", - "is-shared-array-buffer": "^1.0.2", - "isarray": "^2.0.5", - "object-is": "^1.1.5", - "object-keys": "^1.1.1", - "object.assign": "^4.1.4", - "regexp.prototype.flags": "^1.5.1", - "side-channel": "^1.0.4", - "which-boxed-primitive": "^1.0.2", - "which-collection": "^1.0.1", - "which-typed-array": "^1.1.13" - }, + "node_modules/esbuild/node_modules/@esbuild/freebsd-arm64": { + "version": "0.19.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.19.12.tgz", + "integrity": "sha512-4aRvFIXmwAcDBw9AueDQ2YnGmz5L6obe5kmPT8Vd+/+x/JMVKCgdcRwH6APrbpNXsPz+K653Qg8HB/oXvXVukA==", + "cpu": [ + "arm64" + ], + "optional": true, + "os": [ + "freebsd" + ], "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" + "node": ">=12" } }, - "node_modules/deepmerge": { - "version": "4.3.1", - "resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz", - "integrity": "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==", - "dev": true, + "node_modules/esbuild/node_modules/@esbuild/freebsd-x64": { + "version": "0.19.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.19.12.tgz", + "integrity": "sha512-EYoXZ4d8xtBoVN7CEwWY2IN4ho76xjYXqSXMNccFSx2lgqOG/1TBPW0yPx1bJZk94qu3tX0fycJeeQsKovA8gg==", + "cpu": [ + "x64" + ], + "optional": true, + "os": [ + "freebsd" + ], "engines": { - "node": ">=0.10.0" + "node": ">=12" } }, - "node_modules/define-data-property": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.4.tgz", - "integrity": "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==", - "dependencies": { - "es-define-property": "^1.0.0", - "es-errors": "^1.3.0", - "gopd": "^1.0.1" - }, + "node_modules/esbuild/node_modules/@esbuild/linux-arm": { + "version": "0.19.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.19.12.tgz", + "integrity": "sha512-J5jPms//KhSNv+LO1S1TX1UWp1ucM6N6XuL6ITdKWElCu8wXP72l9MM0zDTzzeikVyqFE6U8YAV9/tFyj0ti+w==", + "cpu": [ + "arm" + ], + "optional": true, + "os": [ + "linux" + ], "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" + "node": ">=12" } }, - "node_modules/define-properties": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/define-properties/-/define-properties-1.2.1.tgz", - "integrity": "sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==", - "dependencies": { - "define-data-property": "^1.0.1", - "has-property-descriptors": "^1.0.0", - "object-keys": "^1.1.1" - }, + "node_modules/esbuild/node_modules/@esbuild/linux-arm64": { + "version": "0.19.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.19.12.tgz", + "integrity": "sha512-EoTjyYyLuVPfdPLsGVVVC8a0p1BFFvtpQDB/YLEhaXyf/5bczaGeN15QkR+O4S5LeJ92Tqotve7i1jn35qwvdA==", + "cpu": [ + "arm64" + ], + "optional": true, + "os": [ + "linux" + ], "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" + "node": ">=12" } }, - "node_modules/delayed-stream": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", - "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "node_modules/esbuild/node_modules/@esbuild/linux-ia32": { + "version": "0.19.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.19.12.tgz", + "integrity": "sha512-Thsa42rrP1+UIGaWz47uydHSBOgTUnwBwNq59khgIwktK6x60Hivfbux9iNR0eHCHzOLjLMLfUMLCypBkZXMHA==", + "cpu": [ + "ia32" + ], + "optional": true, + "os": [ + "linux" + ], "engines": { - "node": ">=0.4.0" + "node": ">=12" } }, - "node_modules/dequal": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", - "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", + "node_modules/esbuild/node_modules/@esbuild/linux-loong64": { + "version": "0.19.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.19.12.tgz", + "integrity": "sha512-LiXdXA0s3IqRRjm6rV6XaWATScKAXjI4R4LoDlvO7+yQqFdlr1Bax62sRwkVvRIrwXxvtYEHHI4dm50jAXkuAA==", + "cpu": [ + "loong64" + ], + "optional": true, + "os": [ + "linux" + ], "engines": { - "node": ">=6" + "node": ">=12" } }, - "node_modules/detect-newline": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/detect-newline/-/detect-newline-3.1.0.tgz", - "integrity": "sha512-TLz+x/vEXm/Y7P7wn1EJFNLxYpUD4TgMosxY6fAVJUnJMbupHBOncxyWUG9OpTaH9EBD7uFI5LfEgmMOc54DsA==", - "dev": true, + "node_modules/esbuild/node_modules/@esbuild/linux-mips64el": { + "version": "0.19.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.19.12.tgz", + "integrity": "sha512-fEnAuj5VGTanfJ07ff0gOA6IPsvrVHLVb6Lyd1g2/ed67oU1eFzL0r9WL7ZzscD+/N6i3dWumGE1Un4f7Amf+w==", + "cpu": [ + "mips64el" + ], + "optional": true, + "os": [ + "linux" + ], "engines": { - "node": ">=8" + "node": ">=12" } }, - "node_modules/devlop": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/devlop/-/devlop-1.1.0.tgz", - "integrity": "sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA==", - "dependencies": { - "dequal": "^2.0.0" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/wooorm" + "node_modules/esbuild/node_modules/@esbuild/linux-ppc64": { + "version": "0.19.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.19.12.tgz", + "integrity": "sha512-nYJA2/QPimDQOh1rKWedNOe3Gfc8PabU7HT3iXWtNUbRzXS9+vgB0Fjaqr//XNbd82mCxHzik2qotuI89cfixg==", + "cpu": [ + "ppc64" + ], + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" } }, - "node_modules/diff-sequences": { - "version": "27.5.1", - "resolved": "https://registry.npmjs.org/diff-sequences/-/diff-sequences-27.5.1.tgz", - "integrity": "sha512-k1gCAXAsNgLwEL+Y8Wvl+M6oEFj5bgazfZULpS5CneoPPXRaCCW7dm+q21Ky2VEE5X+VeRDBVg1Pcvvsr4TtNQ==", + "node_modules/esbuild/node_modules/@esbuild/linux-riscv64": { + "version": "0.19.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.19.12.tgz", + "integrity": "sha512-2MueBrlPQCw5dVJJpQdUYgeqIzDQgw3QtiAHUC4RBz9FXPrskyyU3VI1hw7C0BSKB9OduwSJ79FTCqtGMWqJHg==", + "cpu": [ + "riscv64" + ], + "optional": true, + "os": [ + "linux" + ], "engines": { - "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + "node": ">=12" } }, - "node_modules/dom-accessibility-api": { - "version": "0.5.16", - "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz", - "integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==" - }, - "node_modules/domexception": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/domexception/-/domexception-4.0.0.tgz", - "integrity": "sha512-A2is4PLG+eeSfoTMA95/s4pvAoSo2mKtiM5jlHkAVewmiO8ISFTFKZjH7UAM1Atli/OT/7JHOrJRJiMKUZKYBw==", - "deprecated": "Use your platform's native DOMException instead", - "dev": true, - "dependencies": { - "webidl-conversions": "^7.0.0" - }, + "node_modules/esbuild/node_modules/@esbuild/linux-s390x": { + "version": "0.19.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.19.12.tgz", + "integrity": "sha512-+Pil1Nv3Umes4m3AZKqA2anfhJiVmNCYkPchwFJNEJN5QxmTs1uzyy4TvmDrCRNT2ApwSari7ZIgrPeUx4UZDg==", + "cpu": [ + "s390x" + ], + "optional": true, + "os": [ + "linux" + ], "engines": { "node": ">=12" } }, - "node_modules/electron-to-chromium": { - "version": "1.4.751", - "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.4.751.tgz", - "integrity": "sha512-2DEPi++qa89SMGRhufWTiLmzqyuGmNF3SK4+PQetW1JKiZdEpF4XQonJXJCzyuYSA6mauiMhbyVhqYAP45Hvfw==" - }, - "node_modules/emittery": { - "version": "0.13.1", - "resolved": "https://registry.npmjs.org/emittery/-/emittery-0.13.1.tgz", - "integrity": "sha512-DeWwawk6r5yR9jFgnDKYt4sLS0LmHJJi3ZOnb5/JdbYwj3nW+FxQnHIjhBKz8YLC7oRNPVM9NQ47I3CVx34eqQ==", - "dev": true, + "node_modules/esbuild/node_modules/@esbuild/linux-x64": { + "version": "0.19.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.19.12.tgz", + "integrity": "sha512-B71g1QpxfwBvNrfyJdVDexenDIt1CiDN1TIXLbhOw0KhJzE78KIFGX6OJ9MrtC0oOqMWf+0xop4qEU8JrJTwCg==", + "cpu": [ + "x64" + ], + "optional": true, + "os": [ + "linux" + ], "engines": { "node": ">=12" - }, - "funding": { - "url": "https://github.com/sindresorhus/emittery?sponsor=1" } }, - "node_modules/emoji-regex": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", - "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", - "dev": true - }, - "node_modules/entities": { - "version": "4.5.0", - "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz", - "integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==", - "dev": true, + "node_modules/esbuild/node_modules/@esbuild/netbsd-x64": { + "version": "0.19.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.19.12.tgz", + "integrity": "sha512-3ltjQ7n1owJgFbuC61Oj++XhtzmymoCihNFgT84UAmJnxJfm4sYCiSLTXZtE00VWYpPMYc+ZQmB6xbSdVh0JWA==", + "cpu": [ + "x64" + ], + "optional": true, + "os": [ + "netbsd" + ], "engines": { - "node": ">=0.12" - }, - "funding": { - "url": "https://github.com/fb55/entities?sponsor=1" + "node": ">=12" } }, - "node_modules/error-ex": { - "version": "1.3.2", - "resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.2.tgz", - "integrity": "sha512-7dFHNmqeFSEt2ZBsCriorKnn3Z2pj+fd9kmI6QoWw4//DL+icEBfc0U7qJCisqrTsKTjw4fNFy2pW9OqStD84g==", - "dev": true, - "dependencies": { - "is-arrayish": "^0.2.1" + "node_modules/esbuild/node_modules/@esbuild/openbsd-x64": { + "version": "0.19.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.19.12.tgz", + "integrity": "sha512-RbrfTB9SWsr0kWmb9srfF+L933uMDdu9BIzdA7os2t0TXhCRjrQyCeOt6wVxr79CKD4c+p+YhCj31HBkYcXebw==", + "cpu": [ + "x64" + ], + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=12" } }, - "node_modules/es-define-property": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.0.tgz", - "integrity": "sha512-jxayLKShrEqqzJ0eumQbVhTYQM27CfT1T35+gCgDFoL82JLsXqTJ76zv6A0YLOgEnLUMvLzsDsGIrl8NFpT2gQ==", - "dependencies": { - "get-intrinsic": "^1.2.4" - }, + "node_modules/esbuild/node_modules/@esbuild/sunos-x64": { + "version": "0.19.12", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.19.12.tgz", + "integrity": "sha512-HKjJwRrW8uWtCQnQOz9qcU3mUZhTUQvi56Q8DPTLLB+DawoiQdjsYq+j+D3s9I8VFtDr+F9CjgXKKC4ss89IeA==", + "cpu": [ + "x64" + ], + "optional": true, + "os": [ + "sunos" + ], "engines": { - "node": ">= 0.4" + "node": ">=12" } }, - "node_modules/es-errors": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", - "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "node_modules/esbuild/node_modules/@esbuild/win32-arm64": { + "version": "0.19.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.19.12.tgz", + "integrity": "sha512-URgtR1dJnmGvX864pn1B2YUYNzjmXkuJOIqG2HdU62MVS4EHpU2946OZoTMnRUHklGtJdJZ33QfzdjGACXhn1A==", + "cpu": [ + "arm64" + ], + "optional": true, + "os": [ + "win32" + ], "engines": { - "node": ">= 0.4" + "node": ">=12" } }, - "node_modules/es-get-iterator": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/es-get-iterator/-/es-get-iterator-1.1.3.tgz", - "integrity": "sha512-sPZmqHBe6JIiTfN5q2pEi//TwxmAFHwj/XEuYjTuse78i8KxaqMTTzxPoFKuzRpDpTJ+0NAbpfenkmH2rePtuw==", - "dependencies": { - "call-bind": "^1.0.2", - "get-intrinsic": "^1.1.3", - "has-symbols": "^1.0.3", - "is-arguments": "^1.1.1", - "is-map": "^2.0.2", - "is-set": "^2.0.2", - "is-string": "^1.0.7", - "isarray": "^2.0.5", - "stop-iteration-iterator": "^1.0.0" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" + "node_modules/esbuild/node_modules/@esbuild/win32-ia32": { + "version": "0.19.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.19.12.tgz", + "integrity": "sha512-+ZOE6pUkMOJfmxmBZElNOx72NKpIa/HFOMGzu8fqzQJ5kgf6aTGrcJaFsNiVMH4JKpMipyK+7k0n2UXN7a8YKQ==", + "cpu": [ + "ia32" + ], + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" } }, - "node_modules/esbuild": { + "node_modules/esbuild/node_modules/@esbuild/win32-x64": { "version": "0.19.12", - "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.19.12.tgz", - "integrity": "sha512-aARqgq8roFBj054KvQr5f1sFu0D65G+miZRCuJyJ0G13Zwx7vRar5Zhn2tkQNzIXcBrNVsv/8stehpj+GAjgbg==", - "hasInstallScript": true, - "bin": { - "esbuild": "bin/esbuild" - }, + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.19.12.tgz", + "integrity": "sha512-T1QyPSDCyMXaO3pzBkF96E8xMkiRYbUEZADd29SyPGabqxMViNoii+NcK7eWJAEoU6RZyEm5lVSIjTmcdoB9HA==", + "cpu": [ + "x64" + ], + "optional": true, + "os": [ + "win32" + ], "engines": { "node": ">=12" - }, - "optionalDependencies": { - "@esbuild/aix-ppc64": "0.19.12", - "@esbuild/android-arm": "0.19.12", - "@esbuild/android-arm64": "0.19.12", - "@esbuild/android-x64": "0.19.12", - "@esbuild/darwin-arm64": "0.19.12", - "@esbuild/darwin-x64": "0.19.12", - "@esbuild/freebsd-arm64": "0.19.12", - "@esbuild/freebsd-x64": "0.19.12", - "@esbuild/linux-arm": "0.19.12", - "@esbuild/linux-arm64": "0.19.12", - "@esbuild/linux-ia32": "0.19.12", - "@esbuild/linux-loong64": "0.19.12", - "@esbuild/linux-mips64el": "0.19.12", - "@esbuild/linux-ppc64": "0.19.12", - "@esbuild/linux-riscv64": "0.19.12", - "@esbuild/linux-s390x": "0.19.12", - "@esbuild/linux-x64": "0.19.12", - "@esbuild/netbsd-x64": "0.19.12", - "@esbuild/openbsd-x64": "0.19.12", - "@esbuild/sunos-x64": "0.19.12", - "@esbuild/win32-arm64": "0.19.12", - "@esbuild/win32-ia32": "0.19.12", - "@esbuild/win32-x64": "0.19.12" } }, "node_modules/escalade": { @@ -4613,9 +4479,9 @@ } }, "node_modules/hasown": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.1.tgz", - "integrity": "sha512-1/th4MHjnwncwXsIW6QMzlvYL9kG5e/CpVvLRZe4XPa8TOUNbCELqmvhDmnkNsAjwaG4+I8gJJL0JBvTTLO9qA==", + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", + "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", "dependencies": { "function-bind": "^1.1.2" }, @@ -9800,7 +9666,6 @@ "version": "6.3.1", "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", - "dev": true, "bin": { "semver": "bin/semver.js" } diff --git a/frontend/package.json b/frontend/package.json index 165c39b5..d0d0e81f 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -9,6 +9,7 @@ "@babel/plugin-proposal-private-property-in-object": "^7.21.11", "@fontsource/jetbrains-mono": "^5.0.19", "@fontsource/roboto-mono": "^5.0.17", + "@mdx-js/react": "^3.0.1", "@testing-library/jest-dom": "^5.17.0", "@testing-library/user-event": "^13.5.0", "@types/jest": "^27.5.2", @@ -39,7 +40,8 @@ "start": "vite", "build": "tsc && vite build", "preview": "vite preview", - "test": "jest" + "test": "jest", + "docs": "docusaurus" }, "jest": { "preset": "ts-jest", diff --git a/frontend/src/@types/appTypes.ts b/frontend/src/@types/appTypes.ts index fd803f10..b2691ad5 100644 --- a/frontend/src/@types/appTypes.ts +++ b/frontend/src/@types/appTypes.ts @@ -5,7 +5,6 @@ export enum Themes { LIGHT = "light", DARK = "dark", - DODONA = "dodona" } export enum Language { diff --git a/frontend/src/@types/requests.d.ts b/frontend/src/@types/requests.d.ts index 6c0c3c67..c06bd1e1 100644 --- a/frontend/src/@types/requests.d.ts +++ b/frontend/src/@types/requests.d.ts @@ -4,16 +4,17 @@ import type {ProjectFormData} from "../pages/projectCreate/components/ProjectCre * Routes used to make API calls */ export enum ApiRoutes { - USER_COURSES = "api/courses", - COURSES = "api/courses", - - COURSE = "api/courses/:courseId", - COURSE_MEMBERS = "api/courses/:courseId/members", - COURSE_MEMBER = "api/courses/:courseId/members/:userId", - COURSE_PROJECTS = "api/courses/:id/projects", - COURSE_CLUSTERS = "api/courses/:id/clusters", - COURSE_GRADES = '/api/courses/:id/grades', - COURSE_LEAVE = "api/courses/:courseId/leave", + USER_COURSES = "api/courses", + COURSES = "api/courses", + + COURSE = "api/courses/:courseId", + COURSE_MEMBERS = "api/courses/:courseId/members", + COURSE_MEMBER = "api/courses/:courseId/members/:userId", + COURSE_PROJECTS = "api/courses/:id/projects", + COURSE_CLUSTERS = "api/courses/:id/clusters", + COURSE_GRADES = '/api/courses/:id/grades', + COURSE_LEAVE = "api/courses/:courseId/leave", + COURSE_COPY = "/api/courses/:courseId/copy", PROJECTS = "api/projects", PROJECT = "api/projects/:id", @@ -26,22 +27,24 @@ export enum ApiRoutes { PROJECT_GROUPS = "api/projects/:id/groups", PROJECT_GROUP_SUBMISSIONS = "api/projects/:projectId/submissions/:groupId", - SUBMISSION = "api/submissions/:id", - SUBMISSION_FILE = "api/submissions/:id/file", - SUBMISSION_STRUCTURE_FEEDBACK = "/api/submissions/:id/structurefeedback", - SUBMISSION_DOCKER_FEEDBACK = "/api/submissions/:id/dockerfeedback", + SUBMISSION = "api/submissions/:id", + SUBMISSION_FILE = "api/submissions/:id/file", + SUBMISSION_STRUCTURE_FEEDBACK= "/api/submissions/:id/structurefeedback", + SUBMISSION_DOCKER_FEEDBACK= "/api/submissions/:id/dockerfeedback", + SUBMISSION_ARTIFACT="/api/submissions/:id/artifacts", + - CLUSTER = "api/clusters/:id", + CLUSTER = "api/clusters/:id", + CLUSTER_FILL = "api/clusters/:id/fill", GROUP = "api/groups/:id", GROUP_MEMBERS = "api/groups/:id/members", GROUP_MEMBER = "api/groups/:id/members/:userId", GROUP_SUBMISSIONS = "api/projects/:id/groups/:id/submissions", - TEST = "api/test", - USER = "api/users/:id", - USERS = "api/users", - USER_AUTH = "api/user", + USER = "api/users/:id", + USERS = "api/users", + USER_AUTH = "api/user", } export type Timestamp = string @@ -50,12 +53,19 @@ export type Timestamp = string * the body of the POST requests */ export type POST_Requests = { - [ApiRoutes.COURSES]: { - name: string - description: string - } - [ApiRoutes.PROJECT_CREATE]: - ProjectFormData + [ApiRoutes.COURSES]: { + name: string + description:string + } + [ApiRoutes.PROJECT_CREATE]: { + name: string; + description: string; + groupClusterId: number; + testId: number | null; + visible: boolean; + maxScore: number; + deadline: Date | null; +} [ApiRoutes.GROUP_MEMBERS]: { id: number @@ -64,11 +74,14 @@ export type POST_Requests = { file: FormData } - [ApiRoutes.COURSE_CLUSTERS]: { - name: string - capacity: number - groupCount: number - } + [ApiRoutes.COURSE_CLUSTERS]: { + name: string + capacity: number + groupCount: number + }, + [ApiRoutes.PROJECT_TESTS]: Omit + [ApiRoutes.COURSE_COPY]: undefined + } /** @@ -81,17 +94,20 @@ export type POST_Responses = { [ApiRoutes.GROUP_MEMBERS]: GET_Responses[ApiRoutes.GROUP_MEMBERS] [ApiRoutes.COURSE_CLUSTERS]: GET_Responses[ApiRoutes.CLUSTER] [ApiRoutes.PROJECT_SUBMIT]: GET_Responses[ApiRoutes.SUBMISSION] + [ApiRoutes.PROJECT_TESTS]: GET_Responses[ApiRoutes.PROJECT_TESTS] + [ApiRoutes.COURSE_COPY]: GET_Responses[ApiRoutes.COURSE] } /** * the body of the DELETE requests */ export type DELETE_Requests = { - [ApiRoutes.COURSE]: undefined - [ApiRoutes.PROJECT]: undefined - [ApiRoutes.GROUP_MEMBER]: undefined - [ApiRoutes.COURSE_LEAVE]: undefined - [ApiRoutes.COURSE_MEMBER]: undefined + [ApiRoutes.COURSE]: undefined + [ApiRoutes.PROJECT]: undefined + [ApiRoutes.GROUP_MEMBER]: undefined + [ApiRoutes.COURSE_LEAVE]: undefined + [ApiRoutes.COURSE_MEMBER]: undefined + [ApiRoutes.PROJECT_TESTS]: undefined } @@ -99,18 +115,26 @@ export type DELETE_Requests = { * the body of the PUT & PATCH requests */ export type PUT_Requests = { - [ApiRoutes.COURSE]: POST_Requests[ApiRoutes.COURSE] - [ApiRoutes.PROJECT]: ProjectFormData - [ApiRoutes.COURSE_MEMBER]: { relation: CourseRelation } - [ApiRoutes.PROJECT_SCORE]: { score: number | null, feedback: string } + [ApiRoutes.COURSE]: POST_Requests[ApiRoutes.COURSE] + [ApiRoutes.PROJECT]: ProjectFormData + [ApiRoutes.COURSE_MEMBER]: { relation: CourseRelation } + [ApiRoutes.PROJECT_SCORE]: { score: number | null , feedback: string}, + [ApiRoutes.PROJECT_TESTS]: POST_Requests[ApiRoutes.PROJECT_TESTS] + + [ApiRoutes.CLUSTER_FILL]: { + [groupName:string]: number[] /* userId[] */ + } } + export type PUT_Responses = { - [ApiRoutes.COURSE]: GET_Responses[ApiRoutes.COURSE] - [ApiRoutes.PROJECT]: GET_Responses[ApiRoutes.PROJECT] - [ApiRoutes.COURSE_MEMBER]: GET_Responses[ApiRoutes.COURSE_MEMBERS] - [ApiRoutes.PROJECT_SCORE]: GET_Responses[ApiRoutes.PROJECT_SCORE] + [ApiRoutes.COURSE]: GET_Responses[ApiRoutes.COURSE] + [ApiRoutes.PROJECT]: GET_Responses[ApiRoutes.PROJECT] + [ApiRoutes.COURSE_MEMBER]: GET_Responses[ApiRoutes.COURSE_MEMBERS] + [ApiRoutes.PROJECT_SCORE]: GET_Responses[ApiRoutes.PROJECT_SCORE] + [ApiRoutes.PROJECT_TESTS]: GET_Responses[ApiRoutes.PROJECT_TESTS] + [ApiRoutes.CLUSTER_FILL]: PUT_Requests[ApiRoutes.CLUSTER_FILL] } @@ -125,93 +149,121 @@ type Course = { name: string } +export type DockerStatus = "no_test" | "running" | "finished" | "aborted" export type ProjectStatus = "correct" | "incorrect" | "not started" export type CourseRelation = "enrolled" | "course_admin" | "creator" export type UserRole = "student" | "teacher" | "admin" +type SubTest = { + testName: string, // naam van de test + testDescription: string, // beschrijving van de test + correct: string, // verwachte output + output: string, // gegenereerde output + required: boolean, // of de test verplicht is + succes: boolean, // of de test verplicht is +} + +type DockerFeedback = { + type: "SIMPLE", + feedback: string, // de logs van de dockerrun + allowed: boolean // vat samen of de test geslaagd is of niet +} | { + type: "TEMPLATE", + feedback: { + subtests: SubTest[] + } + allowed: boolean +} | { + type: "NONE", + feedback: "", + allowed: true +} + + + /** * The response you get from the GET request */ export type GET_Responses = { - - [ApiRoutes.TEST]: { - name: string - firstName: string - lastName: string - email: string - oid: string + [ApiRoutes.PROJECT_SUBMISSIONS]: { + feedback: GET_Responses[ApiRoutes.PROJECT_SCORE] | null, + group: GET_Responses[ApiRoutes.GROUP], + submission: GET_Responses[ApiRoutes.SUBMISSION] | null // null if no submission yet + }[], + [ApiRoutes.PROJECT_GROUP_SUBMISSIONS]: GET_Responses[ApiRoutes.SUBMISSION][] + [ApiRoutes.GROUP_SUBMISSIONS]: GET_Responses[ApiRoutes.SUBMISSION] + [ApiRoutes.SUBMISSION]: { + submissionId: number + projectId: number + groupId: number + structureAccepted: boolean, + dockerStatus: DockerStatus, + dockerAccepted: boolean + submissionTime: Timestamp + projectUrl: ApiRoutes.PROJECT + groupUrl: ApiRoutes.GROUP + fileUrl: ApiRoutes.SUBMISSION_FILE + structureFeedback: ApiRoutes.SUBMISSION_STRUCTURE_FEEDBACK + dockerFeedback: DockerFeedback, + artifactUrl: ApiRoutes.SUBMISSION_ARTIFACT + } + [ApiRoutes.SUBMISSION_FILE]: BlobPart + [ApiRoutes.COURSE_PROJECTS]: GET_Responses[ApiRoutes.PROJECT][] + [ApiRoutes.PROJECT]: { + course: { + name: string + url: string + courseId: number } - [ApiRoutes.PROJECT_SUBMISSIONS]: { - feedback: GET_Responses[ApiRoutes.PROJECT_SCORE] | null, - group: GET_Responses[ApiRoutes.GROUP], - submission: GET_Responses[ApiRoutes.SUBMISSION] | null // null if no submission yet - }[], - [ApiRoutes.PROJECT_GROUP_SUBMISSIONS]: GET_Responses[ApiRoutes.SUBMISSION][] - [ApiRoutes.GROUP_SUBMISSIONS]: GET_Responses[ApiRoutes.SUBMISSION] - [ApiRoutes.SUBMISSION]: { - submissionId: number - projectId: number - groupId: number - structureAccepted: boolean - dockerAccepted: boolean - submissionTime: Timestamp - projectUrl: ApiRoutes.PROJECT - groupUrl: ApiRoutes.GROUP - fileUrl: ApiRoutes.SUBMISSION_FILE - structureFeedbackUrl: ApiRoutes.SUBMISSION_STRUCTURE_FEEDBACK - dockerFeedbackUrl: ApiRoutes.SUBMISSION_DOCKER_FEEDBACK - } - [ApiRoutes.SUBMISSION_FILE]: BlobPart - [ApiRoutes.COURSE_PROJECTS]: GET_Responses[ApiRoutes.PROJECT][] - [ApiRoutes.PROJECT]: { - course: { - name: string - url: string - courseId: number - } - deadline: Timestamp - description: string - clusterId: number | null; - projectId: number - name: string - submissionUrl: ApiRoutes.PROJECT_GROUP_SUBMISSIONS - testsUrl: string - maxScore: number - visible: boolean - status?: ProjectStatus - progress: { - completed: number - total: number - }, - groupId: number | null // null if not in a group - } - [ApiRoutes.PROJECT_TESTS]: {} // ?? - [ApiRoutes.GROUP]: { - groupId: number, - capacity: number, - name: string - groupClusterUrl: ApiRoutes.CLUSTER - members: GET_Responses[ApiRoutes.GROUP_MEMBER][] - } - [ApiRoutes.PROJECT_SCORE]: { - score: number | null, - feedback: string | null, - projectId: number, - groupId: number + deadline: Timestamp + description: string + clusterId: number | null; + projectId: number + name: string + submissionUrl: ApiRoutes.PROJECT_GROUP_SUBMISSIONS + testsUrl: string + maxScore: number | null + visible: boolean + status?: ProjectStatus + progress: { + completed: number + total: number }, - [ApiRoutes.GROUP_MEMBER]: { - email: string - name: string - userId: number - } - [ApiRoutes.USERS]: { - name: string - userId: number - url: string - email: string - role: UserRole - } - [ApiRoutes.GROUP_MEMBERS]: GET_Responses[ApiRoutes.GROUP_MEMBER][] + groupId: number | null // null if not in a group + } + [ApiRoutes.PROJECT_TESTS]: { + projectUrl: ApiRoutes.PROJECT, + dockerImage: string | null, + dockerScript: string | null, + dockerTemplate: string | null, + structureTest: string | null + } + [ApiRoutes.GROUP]: { + groupId: number, + capacity: number, + name: string + groupClusterUrl: ApiRoutes.CLUSTER + members: GET_Responses[ApiRoutes.GROUP_MEMBER][] + } + [ApiRoutes.PROJECT_SCORE]: { + score: number | null, + feedback:string | null, + projectId: number, + groupId: number + }, + [ApiRoutes.GROUP_MEMBER]: { + email: string + name: string + userId: number + } + [ApiRoutes.USERS]: { + name: string + userId: number + url: string + email: string + role: UserRole + } + [ApiRoutes.GROUP_MEMBERS]: GET_Responses[ApiRoutes.GROUP_MEMBER][] [ApiRoutes.COURSE_CLUSTERS]: GET_Responses[ApiRoutes.CLUSTER][] @@ -268,14 +320,17 @@ export type GET_Responses = { adminProjects: Omit[] }, - [ApiRoutes.COURSE_GRADES]: { - projectName: string, - projectUrl: string, - projectId: number, - maxScore: number, - groupFeedback: GET_Responses[ApiRoutes.PROJECT_SCORE] | null - }[] + [ApiRoutes.COURSE_GRADES]: { + projectName: string, + projectUrl: string, + projectId: number, + maxScore: number | null, + groupFeedback: GET_Responses[ApiRoutes.PROJECT_SCORE] | null + }[] + + [ApiRoutes.SUBMISSION_STRUCTURE_FEEDBACK]: string | null // Null if no feedback is given + [ApiRoutes.SUBMISSION_DOCKER_FEEDBACK]: string | null // Null if no feedback is given + - [ApiRoutes.SUBMISSION_STRUCTURE_FEEDBACK]: string | null // Null if no feedback is given - [ApiRoutes.SUBMISSION_DOCKER_FEEDBACK]: string | null // Null if no feedback is given + [ApiRoutes.SUBMISSION_ARTIFACT]: Blob // returned het artifact als zip } diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 3eac0b40..2444d945 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -9,6 +9,7 @@ import ThemeProvider from "./theme/ThemeProvider" import { AppProvider } from "./providers/AppProvider" import { UserProvider } from "./providers/UserProvider" import AppApiProvider from "./providers/AppApiProvider" +import ErrorProvider from "./providers/ErrorProvider" type AppProps = { pca: IPublicClientApplication @@ -27,7 +28,9 @@ function App({ pca }: AppProps) { - + + + diff --git a/frontend/src/components/common/saveDockerForm.tsx b/frontend/src/components/common/saveDockerForm.tsx new file mode 100644 index 00000000..2a616ea4 --- /dev/null +++ b/frontend/src/components/common/saveDockerForm.tsx @@ -0,0 +1,32 @@ +import { FormInstance } from "antd"; +import { ApiRoutes, POST_Requests } from "../../@types/requests.d"; +import { UseApiType } from "../../hooks/useApi"; + + + +export type DockerFormData = POST_Requests[ApiRoutes.PROJECT_TESTS] + + +const saveDockerForm = async (form:FormInstance, initialDockerValues: DockerFormData | null, API: UseApiType, projectId:string) => { + if(!form.isFieldsTouched(["dockerImage", 'dockerScript', 'dockerTemplate', 'structureTest'])) return null + + let data:DockerFormData = form.getFieldsValue(['dockerImage', 'dockerScript', 'dockerTemplate', 'structureTest']) + + if(!initialDockerValues) { + // We do a POST request + console.log("POST", data); + return API.POST(ApiRoutes.PROJECT_TESTS, { body: data, pathValues: {id: projectId}}) + } + + if(data.dockerImage === null || data.dockerImage.length === 0 ) { + // We do a delete + console.log("DELETE", data); + return API.DELETE(ApiRoutes.PROJECT_TESTS, { pathValues: {id: projectId} }) + } + + // We do a PUT + console.log("PUT", data); + return API.PUT(ApiRoutes.PROJECT_TESTS, { body: data, pathValues: {id: projectId}}) +} + +export default saveDockerForm \ No newline at end of file diff --git a/frontend/src/components/forms/ProjectForm.tsx b/frontend/src/components/forms/ProjectForm.tsx index 1654ca6b..47a41c49 100644 --- a/frontend/src/components/forms/ProjectForm.tsx +++ b/frontend/src/components/forms/ProjectForm.tsx @@ -12,7 +12,7 @@ const VisibleTab: FC> = ({ visible, chil return
{children}
} -const ProjectForm: FC> = ({ children, cardProps, form }) => { +const ProjectForm: FC> = ({ children, cardProps, form,error }) => { const { t } = useTranslation() const location = useLocation() const navigate = useNavigate() @@ -57,6 +57,7 @@ const ProjectForm: FC + {error} diff --git a/frontend/src/components/forms/projectFormTabs/DockerFormTab.tsx b/frontend/src/components/forms/projectFormTabs/DockerFormTab.tsx index f9ab74d4..d8ae921d 100644 --- a/frontend/src/components/forms/projectFormTabs/DockerFormTab.tsx +++ b/frontend/src/components/forms/projectFormTabs/DockerFormTab.tsx @@ -33,11 +33,50 @@ const UploadBtn: React.FC<{ form: FormInstance; fieldName: string; textFieldProp ) } +function isValidTemplate(template: string): string { + if(!template?.length) return "" // Template is optional + let atLeastOne = false; // Template should not be empty + const lines = template.split("\n"); + if (lines[0].charAt(0) !== '@') { + return 'Error: The first character of the first line should be "@"'; + } + let isConfigurationLine = false; + for (const line of lines) { + if(line.length === 0){ // skip line if empty + continue; + } + if (line.charAt(0) === '@') { + atLeastOne = true; + isConfigurationLine = true; + continue; + } + if (isConfigurationLine) { + if (line.charAt(0) === '>') { + const isDescription = line.length >= 13 && line.substring(0, 13).toLowerCase() === ">description="; + // option lines + if (line.toLowerCase() !== ">required" && line.toLowerCase() !== ">optional" + && !isDescription) { + return 'Error: Option lines should be either ">Required", ">Optional" or start with ">Description="'; + } + } else { + isConfigurationLine = false; + } + } + } + if (!atLeastOne) { + return 'Error: Template should not be empty'; + } + return ''; +} + const DockerFormTab: FC<{ form: FormInstance }> = ({ form }) => { const { t } = useTranslation() const dockerImage = Form.useWatch("dockerImage", form) const dockerDisabled = !dockerImage?.length + + + return ( <> = ({ form }) => { /> { + const errorMessage = isValidTemplate(value); + return errorMessage === '' ? Promise.resolve() : Promise.reject(new Error(errorMessage)); + }, + }, + ]} > = ({ form }) => { diff --git a/frontend/src/components/forms/projectFormTabs/GeneralFormTab.tsx b/frontend/src/components/forms/projectFormTabs/GeneralFormTab.tsx index 7349b2b9..668d9583 100644 --- a/frontend/src/components/forms/projectFormTabs/GeneralFormTab.tsx +++ b/frontend/src/components/forms/projectFormTabs/GeneralFormTab.tsx @@ -6,7 +6,6 @@ import MarkdownEditor from "../../input/MarkdownEditor" const GeneralFormTab: FC<{ form: FormInstance }> = ({ form }) => { const { t } = useTranslation() const description = Form.useWatch("description", form) - console.log(description) return ( <> @@ -34,7 +33,8 @@ const GeneralFormTab: FC<{ form: FormInstance }> = ({ form }) => { = ({ form }) => { const { courseId } = useParams<{ courseId: string }>() const { t } = useTranslation() const [selectedCluster, setSelectedCluster] = useState(null) - + const API = useApi() const selectedClusterId = Form.useWatch("groupClusterId", form) - console.log(selectedClusterId) - useEffect(() => { if (selectedClusterId == null) setSelectedCluster(null) else { @@ -28,8 +25,9 @@ const GroupsFormTab: FC<{ form: FormInstance }> = ({ form }) => { }, [selectedClusterId]) const fetchCluster = async () => { - const response = await apiCall.get(ApiRoutes.CLUSTER, { id: selectedClusterId }) - setSelectedCluster(response.data) + const response = await API.GET(ApiRoutes.CLUSTER, { pathValues: { id: selectedClusterId } }) + if (!response.success) return + setSelectedCluster(response.response.data) } return ( @@ -42,6 +40,10 @@ const GroupsFormTab: FC<{ form: FormInstance }> = ({ form }) => { { + console.log("Setting clusterId:", clusterId) + form.setFieldValue("groupClusterId", clusterId) + }} /> @@ -49,11 +51,15 @@ const GroupsFormTab: FC<{ form: FormInstance }> = ({ form }) => { <> {selectedCluster ? ( <> - + + + ) : ( diff --git a/frontend/src/components/forms/projectFormTabs/StructureFormTab.tsx b/frontend/src/components/forms/projectFormTabs/StructureFormTab.tsx index aba0c5ed..6cfe2af4 100644 --- a/frontend/src/components/forms/projectFormTabs/StructureFormTab.tsx +++ b/frontend/src/components/forms/projectFormTabs/StructureFormTab.tsx @@ -7,14 +7,14 @@ import { useDebounceValue } from "usehooks-ts" const StructureFormTab: FC<{ form: FormInstance }> = ({ form }) => { const { t } = useTranslation() - const structure = Form.useWatch("structure", form) + const structure = Form.useWatch("structureTest", form) const [debouncedValue] = useDebounceValue(structure, 400) return ( <> = ({ form }) => { const end = e.currentTarget.selectionEnd e.currentTarget.value = e.currentTarget.value.substring(0, start) + "\t" + e.currentTarget.value.substring(end) e.currentTarget.selectionStart = e.currentTarget.selectionEnd = start + 1 - form.setFieldValue("structure", e.currentTarget.value) + form.setFieldValue("structureTest", e.currentTarget.value) } }} /> diff --git a/frontend/src/components/input/MarkdownTextfield.tsx b/frontend/src/components/input/MarkdownTextfield.tsx index e88bc313..fe03dc43 100644 --- a/frontend/src/components/input/MarkdownTextfield.tsx +++ b/frontend/src/components/input/MarkdownTextfield.tsx @@ -1,4 +1,4 @@ -import Markdown from "react-markdown" +import { MDXProvider } from "@mdx-js/react" import { Prism as SyntaxHighlighter } from "react-syntax-highlighter" import { oneDark, oneLight } from "react-syntax-highlighter/dist/esm/styles/prism" import useApp from "../../hooks/useApp" @@ -7,8 +7,8 @@ import { FC } from "react" const MarkdownTextfield: FC<{ content: string }> = ({ content }) => { const app = useApp() - const CodeBlock = { - code({ children, className, node, ...rest }: any) { + const components = { + code({ children, className, ...rest }: any) { const match = /language-(\w+)/.exec(className || "") return match ? ( = ({ content }) => { style={app.theme === "light" ? oneLight : oneDark} /> ) : ( - + {children} ) }, } - return {content} + return ( + +
{content}
+
+ ) } export default MarkdownTextfield diff --git a/frontend/src/components/layout/nav/AuthNav.tsx b/frontend/src/components/layout/nav/AuthNav.tsx index 709d5de4..96974971 100644 --- a/frontend/src/components/layout/nav/AuthNav.tsx +++ b/frontend/src/components/layout/nav/AuthNav.tsx @@ -33,12 +33,7 @@ const AuthNav = () => { { key: Themes.DARK, label: t("nav.dark"), - }, - { - key: Themes.DODONA, - label: "Dodona", - - }, + } ] }, { @@ -59,7 +54,6 @@ const AuthNav = () => { }) break case Themes.DARK: - case Themes.DODONA: case Themes.LIGHT: app.setTheme(menu.key as Themes) break diff --git a/frontend/src/components/layout/sidebar/Sidebar.tsx b/frontend/src/components/layout/sidebar/Sidebar.tsx index 8722e223..bacb262a 100644 --- a/frontend/src/components/layout/sidebar/Sidebar.tsx +++ b/frontend/src/components/layout/sidebar/Sidebar.tsx @@ -1,4 +1,4 @@ -import { MenuOutlined, UserOutlined } from "@ant-design/icons" +import { BookOutlined, MenuOutlined, UserOutlined } from "@ant-design/icons" import { Button, Drawer, Menu, MenuProps } from "antd" import { FC, useMemo, useState } from "react" import useUser from "../../../hooks/useUser" @@ -13,7 +13,6 @@ const Sidebar: FC = () => { const navigate = useNavigate() const onClick: MenuProps["onClick"] = (menu) => { - navigate(AppRoutes.COURSE.replace(":courseId", menu.key as string)) setOpen(false) } @@ -24,7 +23,7 @@ const Sidebar: FC = () => { key: "courses", label: t("home.allCourses"), type: "sub1", - children: (courses??[]).map((c) => ({ + children: (courses ?? []).map((c) => ({ key: c.courseId, label: c.name, })), @@ -61,6 +60,14 @@ const Sidebar: FC = () => { }} footer={ <> + : setSelectedYear(value)} + style={{ width: 120 }} + > + {yearOptions.map((year) => ( + + ))} + + + )} + + ) + + const showYourCourses = !!filteredCourseProjects?.length || !filteredAdminCourseProjects?.length + return ( + <> + {/* Dropdown for selecting year */} + + {!!showYourCourses && 2} + showPlus={!filteredAdminCourseProjects?.length} + extra={YearDropdown} + allOptions={showYourCourses} + type="enrolled" + />} + + + { !!filteredAdminCourseProjects?.length && 2} + extra={YearDropdown} + showPlus={!!filteredAdminCourseProjects?.length} + allOptions={!!filteredAdminCourseProjects?.length && !filteredCourseProjects?.length} + type="admin" + />} + + + + {filteredCourseProjects !== null && courseProjectsList.length === 0 && adminCourseProjectsList.length === 0 && ( + + {t("home.noCourses")} + + )} + + ) +} + +export default CourseSection diff --git a/frontend/src/pages/index/components/CreateCourseModal.tsx b/frontend/src/pages/index/components/CreateCourseModal.tsx index 9e4ae652..278edbc4 100644 --- a/frontend/src/pages/index/components/CreateCourseModal.tsx +++ b/frontend/src/pages/index/components/CreateCourseModal.tsx @@ -2,13 +2,12 @@ import { Alert, Form, Modal } from "antd" import { FC, useEffect, useState } from "react" import { useTranslation } from "react-i18next" import CourseForm from "../../../components/forms/CourseForm" -import apiCall from "../../../util/apiFetch" import { ApiRoutes } from "../../../@types/requests.d" import useAppApi from "../../../hooks/useAppApi" -import axios, { AxiosError } from "axios" import { useNavigate } from "react-router-dom" import { AppRoutes } from "../../../@types/routes" import useUser from "../../../hooks/useUser" +import useApi from "../../../hooks/useApi" const CreateCourseModal: FC<{ open: boolean,setOpen:(b:boolean)=>void }> = ({ open,setOpen }) => { const { t } = useTranslation() @@ -18,7 +17,7 @@ const CreateCourseModal: FC<{ open: boolean,setOpen:(b:boolean)=>void }> = ({ op const {message} = useAppApi() const navigate = useNavigate() const {updateCourses} = useUser() - + const API = useApi() useEffect(()=> { form.setFieldValue("year", new Date().getFullYear()-1) @@ -32,21 +31,13 @@ const CreateCourseModal: FC<{ open: boolean,setOpen:(b:boolean)=>void }> = ({ op console.log(values); values.description ??= "" setLoading(true) - try { - const course = await apiCall.post(ApiRoutes.COURSES, values) - message.success(t("home.courseCreated")) - await updateCourses() - navigate(AppRoutes.COURSE.replace(":courseId", course.data.courseId.toString())) - } catch(err){ - console.error(err); - if(axios.isAxiosError(err)){ - setError(err.response?.data.message || t("woops")) - } else { - message.error(t("woops")) - } - } finally { - setLoading(false) - } + const res = await API.POST(ApiRoutes.COURSES, { body:values}, "message") + if(!res.success) return setLoading(false) + const course= res.response + message.success(t("home.courseCreated")) + await updateCourses() + navigate(AppRoutes.COURSE.replace(":courseId", course.data.courseId.toString())) + } return ( diff --git a/frontend/src/pages/index/components/HorizontalCourseScroll.tsx b/frontend/src/pages/index/components/HorizontalCourseScroll.tsx index eeb27eb1..b4f139ca 100644 --- a/frontend/src/pages/index/components/HorizontalCourseScroll.tsx +++ b/frontend/src/pages/index/components/HorizontalCourseScroll.tsx @@ -1,120 +1,68 @@ import { Button, Card, Space, Typography } from "antd" -import useUser from "../../../hooks/useUser" import CourseCard from "./CourseCard" -import { FC, useEffect, useState } from "react" -import { ApiRoutes, GET_Responses } from "../../../@types/requests.d" -import { useTranslation } from "react-i18next" +import { FC } from "react" import { PlusOutlined, RightOutlined } from "@ant-design/icons" -import { ProjectsType } from "../Home" import TeacherView from "../../../hooks/TeacherView" import { useNavigate } from "react-router-dom" -import AppRouter from "../../../router/AppRouter" import { AppRoutes } from "../../../@types/routes" +import { CourseProjectList, CourseProjectsType } from "./CourseSection" +import { useTranslation } from "react-i18next" -export type CourseProjectsType = { - [courseId: string]: { - projects: GET_Responses[ApiRoutes.COURSE_PROJECTS] - course: GET_Responses[ApiRoutes.COURSES][number] - } -} - -const HorizontalCourseScroll: FC<{ projects: ProjectsType | null; onOpenNew: () => void }> = ({ projects, onOpenNew }) => { - const { courses } = useUser() - const [courseProjects, setCourseProjects] = useState(null) - const [adminCourseProjects, setAdminCourseProjects] = useState(null) - const [archivedCourses, setArchivedCourses] = useState(false) - const { t } = useTranslation() +const HorizontalCourseScroll: FC<{ title: string; projects: CourseProjectList | null, type:string; onOpenNew: () => void; showMore?: boolean; showPlus?:boolean, allOptions?: boolean; extra?: () => JSX.Element }> = ({ title, onOpenNew, projects, showMore, allOptions, extra,showPlus,type }) => { const navigate = useNavigate() - - useEffect(() => { - if (courses === null || projects === null) return () => {} - let courseProjects: CourseProjectsType = {} - let adminCourseProjects: CourseProjectsType = {} - let ignore = false - let hasArchivedCourses = false - - courses.forEach((course) => { - if(course.archivedAt) return hasArchivedCourses = true; // We don't want to show archived courses - - if (course.relation === "enrolled") { - courseProjects[course.courseId] = { course: course, projects: [] } - } else { - adminCourseProjects![course.courseId] = { course: course, projects: [] } - } - }) - - if (ignore) return - projects.forEach((project) => { - if (project.course.courseId in courseProjects) { - courseProjects[project.course.courseId].projects.push(project) - } else if (project.course.courseId in adminCourseProjects) { - adminCourseProjects[project.course.courseId].projects.push(project) - } else { - // This shouldn't happen unless there's a backend bug - console.error("User is in a project while not being in the course! ", project, courses) - } - }) - - setCourseProjects(courseProjects) - setAdminCourseProjects(adminCourseProjects) - setArchivedCourses(hasArchivedCourses) - - return () => (ignore = true) - }, [courses, projects]) - - const courseProjectsArray = courseProjects ? Object.values(courseProjects) : [] - const adminCourseProjectsArray = adminCourseProjects ? Object.values(adminCourseProjects) : [] + const { t } = useTranslation() return ( <> - {courseProjects && adminCourseProjectsArray.length && !courseProjectsArray.length ? null : ( - - {t("home.yourCourses")} - {adminCourseProjectsArray.length === 0 && - } +
+
+ + {title}{" "} + + {projects && showPlus && ( + +
+ + + {showMore && ( + + )} + - )} +
- {courseProjects && !adminCourseProjectsArray.length && !courseProjectsArray.length && ( - - {t("home.noCourses")} - - )} - {courseProjects !== null - ? courseProjectsArray.map((c) => ( - - )) - : Array(3) + {projects === null ? ( + + {Array(3) .fill(0) .map((_, i) => ( ))} - - - {adminCourseProjects && !!adminCourseProjectsArray.length && ( - <> - - {t("home.myCourses")} - - {courseProjects && ( - - } - - - {adminCourseProjectsArray.map((c) => ( + + ) : ( + <> + {projects.map((c) => ( ))} - - - )} + + )} + ) } diff --git a/frontend/src/pages/index/components/ProjectCard.tsx b/frontend/src/pages/index/components/ProjectCard.tsx index 980b0e91..70752eec 100644 --- a/frontend/src/pages/index/components/ProjectCard.tsx +++ b/frontend/src/pages/index/components/ProjectCard.tsx @@ -1,32 +1,33 @@ import { FC, useEffect, useState } from "react" import ProjectTable, { ProjectType } from "./ProjectTable" import { Button, Card } from "antd" -import apiCall from "../../../util/apiFetch" import { ApiRoutes } from "../../../@types/requests.d" -import useIsTeacher from "../../../hooks/useIsTeacher" import { useTranslation } from "react-i18next" import { AppRoutes } from "../../../@types/routes" -import { Link, useNavigate } from "react-router-dom" +import { useNavigate } from "react-router-dom" import CourseAdminView from "../../../hooks/CourseAdminView" import { PlusOutlined } from "@ant-design/icons" +import useApi from "../../../hooks/useApi" const ProjectCard: FC<{ courseId?: number }> = ({ courseId }) => { const [projects, setProjects] = useState(null) const { t } = useTranslation() const navigate = useNavigate() + const API = useApi() useEffect(() => { if (courseId) { - apiCall.get(ApiRoutes.COURSE_PROJECTS, { id: courseId }).then((res) => { - setProjects(res.data) + API.GET(ApiRoutes.COURSE_PROJECTS, { pathValues: { id: courseId } }).then((res) => { + if (!res.success) return + setProjects(res.response.data) }) } - }, [courseId]) + }, [courseId, API]) return ( <> -
+