diff --git a/.gitignore b/.gitignore index 3f175e9f..083ce094 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,10 @@ +# Eclipse-specific files +.classpath +.project +.settings/**/* +bin/**/* + + # Compiled class file *.class @@ -164,4 +171,5 @@ course_repositories/ runner/ courses_db -#load_testing/ \ No newline at end of file +#load_testing/ + diff --git a/src/main/java/ch/uzh/ifi/access/ServerInfoController.java b/src/main/java/ch/uzh/ifi/access/ServerInfoController.java new file mode 100644 index 00000000..fb0506ae --- /dev/null +++ b/src/main/java/ch/uzh/ifi/access/ServerInfoController.java @@ -0,0 +1,46 @@ +package ch.uzh.ifi.access; + +import lombok.Data; +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.context.annotation.Configuration; +import org.springframework.http.ResponseEntity; +import org.springframework.stereotype.Component; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RestController; + +import java.time.Instant; +import java.time.ZoneId; +import java.time.ZonedDateTime; +import java.util.HashMap; +import java.util.Map; + +@RestController +public class ServerInfoController { + + private final ServerInfo serverInfo; + + public ServerInfoController(ServerInfo serverInfo) { + this.serverInfo = serverInfo; + } + + @GetMapping("/info") + public ResponseEntity info() { + Map response = new HashMap<>(); + response.put("offsetDateTime", ZonedDateTime.now().toOffsetDateTime().toString()); + response.put("utcTime", Instant.now().toString()); + response.put("zoneId", ZoneId.systemDefault().toString()); + + if (serverInfo != null) { + response.put("version", serverInfo.version); + } + return ResponseEntity.ok(response); + } + + @Component + @Data + @Configuration + @ConfigurationProperties(prefix = "server.info") + static class ServerInfo { + private String version; + } +} diff --git a/src/main/java/ch/uzh/ifi/access/coderunner/CodeRunner.java b/src/main/java/ch/uzh/ifi/access/coderunner/CodeRunner.java index 1d364da9..30fdbe3a 100644 --- a/src/main/java/ch/uzh/ifi/access/coderunner/CodeRunner.java +++ b/src/main/java/ch/uzh/ifi/access/coderunner/CodeRunner.java @@ -113,7 +113,7 @@ private RunResult createAndRunContainer(ContainerConfig containerConfig, String ContainerCreation creation = docker.createContainer(containerConfig); String containerId = creation.id(); - logger.trace(String.format("Created container %s", containerId)); + logger.debug("Created container {}", containerId); if (creation.warnings() != null) { creation.warnings().forEach(logger::warn); @@ -148,6 +148,8 @@ private RunResult createAndRunContainer(ContainerConfig containerConfig, String stopAndRemoveContainer(containerId); + logger.trace("Code execution logs start --------------------\n{}\n-------------------- Code execution logs end", console); + return new RunResult(console, stdOut, stdErr, executionTime, didTimeout, isOomKilled); } @@ -184,7 +186,7 @@ private void copyDirectoryToContainer(String containerId, Path folder) throws In } catch (IOException e) { logger.warn(e.getMessage(), e); } - logger.trace(joiner.toString()); + logger.debug(joiner.toString()); } private void startAndWaitContainer(String id) throws DockerException, InterruptedException { @@ -205,7 +207,7 @@ private String readStdErr(String containerId) throws DockerException, Interrupte } private void stopAndRemoveContainer(String id) throws DockerException, InterruptedException { - logger.debug(String.format("Stopping and removing container %s", id)); + logger.debug("Stopping and removing container {}", id); docker.stopContainer(id, 1); docker.removeContainer(id); } diff --git a/src/main/java/ch/uzh/ifi/access/config/AsyncConfig.java b/src/main/java/ch/uzh/ifi/access/config/AsyncConfig.java index d32f6bd9..d56997fa 100644 --- a/src/main/java/ch/uzh/ifi/access/config/AsyncConfig.java +++ b/src/main/java/ch/uzh/ifi/access/config/AsyncConfig.java @@ -42,6 +42,19 @@ public Executor getAsyncExecutor() { executor.setCorePoolSize(THREAD_POOL_SIZE); executor.setMaxPoolSize(MAX_POOL_SIZE); executor.setQueueCapacity(QUEUE_CAPACITY); + executor.setWaitForTasksToCompleteOnShutdown(true); + executor.setAwaitTerminationSeconds(60); + executor.initialize(); + return executor; + } + + @Bean("courseUpdateWorkerExecutor") + public Executor getCourseUpdateExecutor() { + ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor(); + executor.setThreadNamePrefix("course-update-worker-"); + executor.setCorePoolSize(THREAD_POOL_SIZE); + executor.setMaxPoolSize(MAX_POOL_SIZE); + executor.setQueueCapacity(QUEUE_CAPACITY); executor.initialize(); return executor; } diff --git a/src/main/java/ch/uzh/ifi/access/config/GracefulShutdown.java b/src/main/java/ch/uzh/ifi/access/config/GracefulShutdown.java new file mode 100644 index 00000000..a5400012 --- /dev/null +++ b/src/main/java/ch/uzh/ifi/access/config/GracefulShutdown.java @@ -0,0 +1,25 @@ +package ch.uzh.ifi.access.config; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.context.event.ContextClosedEvent; +import org.springframework.context.event.EventListener; +import org.springframework.stereotype.Component; + +@Component +public class GracefulShutdown { + + private static final Logger logger = LoggerFactory.getLogger(GracefulShutdown.class); + + private boolean isShutdown = false; + + @EventListener(ContextClosedEvent.class) + public void rejectSubmissionsOnShutdown() { + logger.warn("Received shutdown signal. Will reject new submissions"); + this.isShutdown = true; + } + + public boolean isShutdown() { + return isShutdown; + } +} diff --git a/src/main/java/ch/uzh/ifi/access/config/SecurityConfigurer.java b/src/main/java/ch/uzh/ifi/access/config/SecurityConfigurer.java index 544211b4..801818ac 100644 --- a/src/main/java/ch/uzh/ifi/access/config/SecurityConfigurer.java +++ b/src/main/java/ch/uzh/ifi/access/config/SecurityConfigurer.java @@ -45,7 +45,7 @@ public void configure(ResourceServerSecurityConfigurer resources) { @Override public void configure(final HttpSecurity http) throws Exception { - final String[] swaggerPaths = new String[]{"/v2/api-docs", "/configuration/ui", "/swagger-resources/**", "/configuration/**", "/swagger-ui.html", "/webjars/**"}; + final String[] permittedPaths = new String[]{"/info", "/v2/api-docs", "/configuration/ui", "/swagger-resources/**", "/configuration/**", "/swagger-ui.html", "/webjars/**"}; http.cors() .configurationSource(corsConfigurationSource()) @@ -58,7 +58,7 @@ public void configure(final HttpSecurity http) throws Exception { .disable() .addFilterAfter(filter, AbstractPreAuthenticatedProcessingFilter.class) .authorizeRequests() - .antMatchers(swaggerPaths) + .antMatchers(permittedPaths) .permitAll() .antMatchers(securityProperties.getApiMatcher()) .authenticated(); diff --git a/src/main/java/ch/uzh/ifi/access/course/controller/CourseController.java b/src/main/java/ch/uzh/ifi/access/course/controller/CourseController.java index 9deb67ec..53a2ca10 100644 --- a/src/main/java/ch/uzh/ifi/access/course/controller/CourseController.java +++ b/src/main/java/ch/uzh/ifi/access/course/controller/CourseController.java @@ -1,6 +1,5 @@ package ch.uzh.ifi.access.course.controller; -import ch.uzh.ifi.access.config.ApiTokenAuthenticationProvider; import ch.uzh.ifi.access.course.CheckCoursePermission; import ch.uzh.ifi.access.course.FilterByPublishingDate; import ch.uzh.ifi.access.course.config.CourseAuthentication; @@ -14,8 +13,10 @@ import org.slf4j.LoggerFactory; import org.springframework.http.ResponseEntity; import org.springframework.security.access.prepost.PreAuthorize; -import org.springframework.security.authentication.BadCredentialsException; -import org.springframework.web.bind.annotation.*; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; import springfox.documentation.annotations.ApiIgnore; import java.util.ArrayList; @@ -87,18 +88,4 @@ public ResponseEntity getCourseAssistants(@PathVariable String courseId) { UserService.UserQueryResult users = userService.getCourseAdmins(course); return ResponseEntity.ok(users.getUsersFound()); } - - @PostMapping(path = "{id}/update") - public void updateCourse(@PathVariable("id") String id, @RequestBody String json, - ApiTokenAuthenticationProvider.GithubHeaderAuthentication authentication) { - logger.debug("Received web hook"); - - if (!authentication.matchesHmacSignature(json)) { - throw new BadCredentialsException("Hmac signature does not match!"); - } - - logger.debug("Updating courses"); - courseService.updateCourseById(id); - } - } diff --git a/src/main/java/ch/uzh/ifi/access/course/controller/ExerciseController.java b/src/main/java/ch/uzh/ifi/access/course/controller/ExerciseController.java index 520e9b85..c70bd140 100644 --- a/src/main/java/ch/uzh/ifi/access/course/controller/ExerciseController.java +++ b/src/main/java/ch/uzh/ifi/access/course/controller/ExerciseController.java @@ -15,7 +15,6 @@ import java.io.File; import java.io.IOException; -import java.nio.file.Files; import java.util.Map; import java.util.Optional; @@ -64,7 +63,7 @@ public ResponseEntity getFile( File fileHandle = file.get().getFile(); FileSystemResource r = new FileSystemResource(fileHandle); return ResponseEntity.ok() - .contentType(MediaType.parseMediaType(Files.probeContentType(fileHandle.toPath()))) + .contentType(MediaType.APPLICATION_OCTET_STREAM) .body(r); } } @@ -94,7 +93,7 @@ public ResponseEntity searchForFile( File fileHandle = file.get().getFile(); FileSystemResource r = new FileSystemResource(fileHandle); return ResponseEntity.ok() - .contentType(MediaType.parseMediaType(Files.probeContentType(fileHandle.toPath()))) + .contentType(MediaType.APPLICATION_OCTET_STREAM) .body(r); } } diff --git a/src/main/java/ch/uzh/ifi/access/course/controller/WebhooksController.java b/src/main/java/ch/uzh/ifi/access/course/controller/WebhooksController.java index 115e18b2..eb83bc3a 100644 --- a/src/main/java/ch/uzh/ifi/access/course/controller/WebhooksController.java +++ b/src/main/java/ch/uzh/ifi/access/course/controller/WebhooksController.java @@ -1,12 +1,18 @@ package ch.uzh.ifi.access.course.controller; import ch.uzh.ifi.access.config.ApiTokenAuthenticationProvider; +import ch.uzh.ifi.access.course.model.Course; import ch.uzh.ifi.access.course.service.CourseService; +import com.fasterxml.jackson.databind.JsonNode; +import lombok.Value; import org.slf4j.Logger; import org.slf4j.LoggerFactory; +import org.springframework.http.ResponseEntity; import org.springframework.security.authentication.BadCredentialsException; import org.springframework.web.bind.annotation.*; +import java.util.Optional; + @RestController @RequestMapping("/webhooks") public class WebhooksController { @@ -32,6 +38,17 @@ public void updateCourse(@PathVariable("id") String id, @RequestBody String json courseService.updateCourseById(id); } + @PostMapping(path = "/courses/update/github") + public ResponseEntity updateCourse(@RequestBody JsonNode payload, ApiTokenAuthenticationProvider.GithubHeaderAuthentication authentication) { + logger.info("Received github web hook"); + + if (!authentication.matchesHmacSignature(payload.toString())) { + throw new BadCredentialsException("Hmac signature does not match!"); + } + + return processWebhook(payload, false); + } + @PostMapping(path = "/courses/{id}/update/gitlab") public void updateCourse(@PathVariable("id") String id, ApiTokenAuthenticationProvider.GitlabHeaderAuthentication authentication) { @@ -44,4 +61,61 @@ public void updateCourse(@PathVariable("id") String id, logger.info("Updating courses"); courseService.updateCourseById(id); } + + @PostMapping(path = "/courses/update/gitlab") + public ResponseEntity updateCourse(@RequestBody JsonNode payload, ApiTokenAuthenticationProvider.GitlabHeaderAuthentication authentication) { + logger.info("Received gitlab web hook"); + + if (!authentication.isMatchesSecret()) { + throw new BadCredentialsException("Header secret does not match!"); + } + + return processWebhook(payload, true); + } + + private ResponseEntity processWebhook(JsonNode payload, boolean isGitlab) { + logger.info("Updating course"); + WebhookPayload webhookPayload = new WebhookPayload(payload, isGitlab); + Optional courseToUpdate = courseService.getAllCourses().stream().filter(course -> webhookPayload.matchesCourseUrl(course.getGitURL())).findFirst(); + courseToUpdate.ifPresent(c -> courseService.updateCourseById(c.getId())); + return courseToUpdate.map(c -> ResponseEntity.accepted().body(c.getId())).orElse(ResponseEntity.notFound().build()); + } + + @Value + public static class WebhookPayload { + + private JsonNode repository; + + private boolean isGitlab; + + public WebhookPayload(JsonNode root, boolean isGitlab) { + this.repository = root.get("repository"); + this.isGitlab = isGitlab; + } + + public String getHtmlUrl() { + if (isGitlab) { + return repository.get("homepage").asText(); + } + return repository.get("html_url").asText(); + } + + public String getGitUrl() { + if (isGitlab) { + return repository.get("git_http_url").asText(); + } + return repository.get("clone_url").asText(); + } + + public String getSshUrl() { + if (isGitlab) { + return repository.get("git_ssh_url").asText(); + } + return repository.get("ssh_url").asText(); + } + + public boolean matchesCourseUrl(String courseUrl) { + return courseUrl.equalsIgnoreCase(getHtmlUrl()) || courseUrl.equalsIgnoreCase(getGitUrl()) || courseUrl.equalsIgnoreCase(getSshUrl()); + } + } } diff --git a/src/main/java/ch/uzh/ifi/access/course/dao/CourseDAO.java b/src/main/java/ch/uzh/ifi/access/course/dao/CourseDAO.java index 011b4920..5bfbd48e 100644 --- a/src/main/java/ch/uzh/ifi/access/course/dao/CourseDAO.java +++ b/src/main/java/ch/uzh/ifi/access/course/dao/CourseDAO.java @@ -10,6 +10,7 @@ import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.ObjectWriter; import lombok.Data; +import org.apache.commons.lang.SerializationUtils; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.core.io.ClassPathResource; @@ -40,15 +41,18 @@ public class CourseDAO { private BreakingChangeNotifier breakingChangeNotifier; - public CourseDAO(BreakingChangeNotifier breakingChangeNotifier) { + private RepoCacher repoCacher; + + public CourseDAO(BreakingChangeNotifier breakingChangeNotifier, RepoCacher repoCacher) { this.breakingChangeNotifier = breakingChangeNotifier; + this.repoCacher = repoCacher; ClassPathResource resource = new ClassPathResource(CONFIG_FILE); if (resource.exists()) { try { ObjectMapper mapper = new ObjectMapper(); URLList conf = mapper.readValue(resource.getFile(), URLList.class); - courseList = RepoCacher.retrieveCourseData(conf.repositories); + courseList = repoCacher.retrieveCourseData(conf.repositories); exerciseIndex = buildExerciseIndex(courseList); logger.info(String.format("Parsed %d courses", courseList.size())); @@ -95,14 +99,35 @@ protected Map buildExerciseIndex(List courses) { public Course updateCourseById(String id) { Course c = selectCourseById(id) .orElseThrow(() -> new ResourceNotFoundException("No course found")); + + logger.info("Updating course {} {}", c.getTitle(), id); + + return updateCourse(c); + } + + protected Course updateCourse(Course c) { + Course clone = (Course) SerializationUtils.clone(c); + Course newCourse; + + // Try to pull new course try { - Course courseUpdate = RepoCacher.retrieveCourseData(new String[]{c.getGitURL()}).get(0); - updateCourse(c, courseUpdate); - return c; + newCourse = repoCacher.retrieveCourseData(new String[]{c.getGitURL()}).get(0); } catch (Exception e) { + logger.error("Failed to generate new course", e); + return null; + } + + // Try to update + try { + updateCourse(c, newCourse); + } catch (Exception e) { + // If we fail during updating we try to revert to original logger.error("Failed to update course", e); + updateCourse(c, clone); + return null; } - return null; + + return c; } void updateCourse(Course before, Course after) { diff --git a/src/main/java/ch/uzh/ifi/access/course/dto/AssignmentMetadataDTO.java b/src/main/java/ch/uzh/ifi/access/course/dto/AssignmentMetadataDTO.java index a18ddac8..df2c4158 100644 --- a/src/main/java/ch/uzh/ifi/access/course/dto/AssignmentMetadataDTO.java +++ b/src/main/java/ch/uzh/ifi/access/course/dto/AssignmentMetadataDTO.java @@ -1,23 +1,25 @@ package ch.uzh.ifi.access.course.dto; -import ch.uzh.ifi.access.course.model.Assignment; -import ch.uzh.ifi.access.course.model.Exercise; -import ch.uzh.ifi.access.course.model.HasPublishingDate; +import ch.uzh.ifi.access.course.model.*; import lombok.Data; -import java.time.LocalDateTime; +import java.time.ZonedDateTime; import java.util.ArrayList; import java.util.List; @Data -public class AssignmentMetadataDTO implements HasPublishingDate { +public class AssignmentMetadataDTO implements HasPublishingDate, HasDueDate { private final String id; private String title; private String description; - private LocalDateTime publishDate; - private LocalDateTime dueDate; + private ZonedDateTime publishDate; + private ZonedDateTime dueDate; + private boolean isPublished; + private boolean isPastDueDate; + + private List breadCrumbs; private List exercises = new ArrayList<>(); public AssignmentMetadataDTO(Assignment assignment) { @@ -26,6 +28,9 @@ public AssignmentMetadataDTO(Assignment assignment) { this.description = assignment.getDescription(); this.publishDate = assignment.getPublishDate(); this.dueDate = assignment.getDueDate(); + this.isPublished = assignment.isPublished(); + this.isPastDueDate = assignment.isPastDueDate(); + this.breadCrumbs = assignment.getBreadCrumbs(); if (assignment.getExercises() != null) { for (Exercise e : assignment.getExercises()) { diff --git a/src/main/java/ch/uzh/ifi/access/course/dto/CourseMetadataDTO.java b/src/main/java/ch/uzh/ifi/access/course/dto/CourseMetadataDTO.java index f56a8457..9f2d5a1c 100644 --- a/src/main/java/ch/uzh/ifi/access/course/dto/CourseMetadataDTO.java +++ b/src/main/java/ch/uzh/ifi/access/course/dto/CourseMetadataDTO.java @@ -1,11 +1,11 @@ package ch.uzh.ifi.access.course.dto; import ch.uzh.ifi.access.course.model.Assignment; +import ch.uzh.ifi.access.course.model.BreadCrumb; import ch.uzh.ifi.access.course.model.Course; -import ch.uzh.ifi.access.course.util.Utils; import lombok.Data; -import java.time.LocalDateTime; +import java.time.ZonedDateTime; import java.util.ArrayList; import java.util.List; @@ -18,8 +18,10 @@ public class CourseMetadataDTO { private String description; private String owner; private String gitHash; - private LocalDateTime startDate; - private LocalDateTime endDate; + private ZonedDateTime startDate; + private ZonedDateTime endDate; + + private List breadCrumbs; private List assignments = new ArrayList<>(); public CourseMetadataDTO(Course course) { @@ -30,14 +32,10 @@ public CourseMetadataDTO(Course course) { this.gitHash = course.getGitHash(); this.startDate = course.getStartDate(); this.endDate = course.getEndDate(); + this.breadCrumbs = course.getBreadCrumbs(); for (Assignment a : course.getAssignments()) { this.assignments.add(new AssignmentMetadataDTO(a)); } } - - public CourseMetadataDTO() { - this.id = new Utils().getID(); - } - } diff --git a/src/main/java/ch/uzh/ifi/access/course/dto/ExerciseMetadataDTO.java b/src/main/java/ch/uzh/ifi/access/course/dto/ExerciseMetadataDTO.java index 3fd9476d..7089ffc6 100644 --- a/src/main/java/ch/uzh/ifi/access/course/dto/ExerciseMetadataDTO.java +++ b/src/main/java/ch/uzh/ifi/access/course/dto/ExerciseMetadataDTO.java @@ -2,29 +2,28 @@ import ch.uzh.ifi.access.course.model.Exercise; import ch.uzh.ifi.access.course.model.ExerciseType; -import ch.uzh.ifi.access.course.util.Utils; import lombok.Data; @Data public class ExerciseMetadataDTO { private final String id; + private String title; + private String longTile; private String gitHash; private ExerciseType type; private String language; private Boolean isGraded; private int maxScore; - public ExerciseMetadataDTO(Exercise exercise){ + public ExerciseMetadataDTO(Exercise exercise) { this.id = exercise.getId(); + this.title = exercise.getTitle(); + this.longTile = exercise.getLongTitle(); this.gitHash = exercise.getGitHash(); this.type = exercise.getType(); this.language = exercise.getLanguage(); this.isGraded = exercise.getIsGraded(); this.maxScore = exercise.getMaxScore(); } - - public ExerciseMetadataDTO(){ - this.id = new Utils().getID(); - } } diff --git a/src/main/java/ch/uzh/ifi/access/course/dto/ExerciseWithSolutionsDTO.java b/src/main/java/ch/uzh/ifi/access/course/dto/ExerciseWithSolutionsDTO.java index ea8f7123..fa7754c7 100644 --- a/src/main/java/ch/uzh/ifi/access/course/dto/ExerciseWithSolutionsDTO.java +++ b/src/main/java/ch/uzh/ifi/access/course/dto/ExerciseWithSolutionsDTO.java @@ -1,5 +1,6 @@ package ch.uzh.ifi.access.course.dto; +import ch.uzh.ifi.access.course.model.BreadCrumb; import ch.uzh.ifi.access.course.model.Exercise; import ch.uzh.ifi.access.course.model.ExerciseConfig; import ch.uzh.ifi.access.course.model.VirtualFile; @@ -16,15 +17,20 @@ public class ExerciseWithSolutionsDTO extends ExerciseConfig { private String question; + private List breadCrumbs; + private List solution_files; private List resource_files; private List public_files; + private List private_files; private String courseId; private String assignmentId; public ExerciseWithSolutionsDTO(Exercise exercise) { this.id = exercise.getId(); + this.title = exercise.getTitle(); + this.longTitle = exercise.getLongTitle(); this.gitHash = exercise.getGitHash(); this.type = exercise.getType(); this.language = exercise.getLanguage(); @@ -37,8 +43,10 @@ public ExerciseWithSolutionsDTO(Exercise exercise) { this.solution_files = exercise.getSolution_files(); this.resource_files = exercise.getResource_files(); this.public_files = exercise.getPublic_files(); + this.private_files = exercise.getPrivate_files(); this.executionLimits = exercise.getExecutionLimits(); this.courseId = exercise.getCourseId(); this.assignmentId = exercise.getAssignmentId(); + this.breadCrumbs = exercise.getBreadCrumbs(); } } diff --git a/src/main/java/ch/uzh/ifi/access/course/model/Assignment.java b/src/main/java/ch/uzh/ifi/access/course/model/Assignment.java index dc20dd92..098b8474 100644 --- a/src/main/java/ch/uzh/ifi/access/course/model/Assignment.java +++ b/src/main/java/ch/uzh/ifi/access/course/model/Assignment.java @@ -7,16 +7,16 @@ import lombok.EqualsAndHashCode; import lombok.ToString; -import java.time.LocalDateTime; +import java.time.ZonedDateTime; import java.util.ArrayList; import java.util.Comparator; import java.util.List; import java.util.Optional; @Data -@ToString(callSuper=true) +@ToString(callSuper = true) @EqualsAndHashCode(callSuper = true) -public class Assignment extends AssignmentConfig implements IndexedCollection, Indexed { +public class Assignment extends AssignmentConfig implements IndexedCollection, Indexed, HasBreadCrumbs { private final String id; private int index; @@ -33,7 +33,7 @@ public Assignment(String name) { } @Builder - private Assignment(String title, String description, LocalDateTime publishDate, LocalDateTime dueDate, String id, int index, Course course, List exercises) { + private Assignment(String title, String description, ZonedDateTime publishDate, ZonedDateTime dueDate, String id, int index, Course course, List exercises) { super(title, description, publishDate, dueDate); this.id = id; this.index = index; @@ -69,10 +69,6 @@ public Optional findExerciseById(String id) { return exercises.stream().filter(e -> e.getId().equals(id)).findFirst(); } - public boolean isPastDueDate() { - return LocalDateTime.now().isAfter(this.getDueDate()); - } - public int getMaxScore() { return exercises.stream().mapToInt(e -> e.getMaxScore()).sum(); } @@ -82,5 +78,16 @@ public int getMaxScore() { public List getIndexedItems() { return exercises; } + + @Override + public List getBreadCrumbs() { + List bc = new ArrayList<>(); + BreadCrumb c = new BreadCrumb(this.getCourse().title, "courses/" + this.getCourse().getId()); + BreadCrumb a = new BreadCrumb(this.title, "courses/" + this.getCourse().getId() + "/assignments/" + this.id); + bc.add(c); + bc.add(a); + + return bc; + } } diff --git a/src/main/java/ch/uzh/ifi/access/course/model/AssignmentConfig.java b/src/main/java/ch/uzh/ifi/access/course/model/AssignmentConfig.java index d1fbd797..91984d40 100644 --- a/src/main/java/ch/uzh/ifi/access/course/model/AssignmentConfig.java +++ b/src/main/java/ch/uzh/ifi/access/course/model/AssignmentConfig.java @@ -2,19 +2,19 @@ import lombok.AllArgsConstructor; import lombok.Data; -import lombok.NoArgsConstructor; -import java.time.LocalDateTime; +import java.io.Serializable; +import java.time.ZonedDateTime; @Data @AllArgsConstructor -public class AssignmentConfig implements HasPublishingDate { +public class AssignmentConfig implements HasPublishingDate, HasDueDate, Serializable { protected String title; protected String description; - protected LocalDateTime publishDate; - protected LocalDateTime dueDate; + protected ZonedDateTime publishDate; + protected ZonedDateTime dueDate; - public AssignmentConfig(){ + public AssignmentConfig() { this.description = ""; } } diff --git a/src/main/java/ch/uzh/ifi/access/course/model/BreadCrumb.java b/src/main/java/ch/uzh/ifi/access/course/model/BreadCrumb.java new file mode 100644 index 00000000..71bba84d --- /dev/null +++ b/src/main/java/ch/uzh/ifi/access/course/model/BreadCrumb.java @@ -0,0 +1,11 @@ +package ch.uzh.ifi.access.course.model; + +public class BreadCrumb { + public String title; + public String url; + + public BreadCrumb(String title, String url){ + this.title = title; + this.url = url; + } +} diff --git a/src/main/java/ch/uzh/ifi/access/course/model/CodeExecutionLimits.java b/src/main/java/ch/uzh/ifi/access/course/model/CodeExecutionLimits.java index 0a8a07cb..79d8e5f2 100644 --- a/src/main/java/ch/uzh/ifi/access/course/model/CodeExecutionLimits.java +++ b/src/main/java/ch/uzh/ifi/access/course/model/CodeExecutionLimits.java @@ -4,10 +4,12 @@ import lombok.Data; import lombok.NoArgsConstructor; +import java.io.Serializable; + @Data @NoArgsConstructor @AllArgsConstructor -public class CodeExecutionLimits { +public class CodeExecutionLimits implements Serializable { private static final long MB_TO_Bytes = 1000000L; /** diff --git a/src/main/java/ch/uzh/ifi/access/course/model/Course.java b/src/main/java/ch/uzh/ifi/access/course/model/Course.java index d835a965..fbf88a98 100644 --- a/src/main/java/ch/uzh/ifi/access/course/model/Course.java +++ b/src/main/java/ch/uzh/ifi/access/course/model/Course.java @@ -7,7 +7,7 @@ import lombok.EqualsAndHashCode; import lombok.ToString; -import java.time.LocalDateTime; +import java.time.ZonedDateTime; import java.util.ArrayList; import java.util.Comparator; import java.util.List; @@ -15,9 +15,9 @@ import java.util.stream.Collectors; @Data -@ToString(callSuper=true) +@ToString(callSuper = true) @EqualsAndHashCode(callSuper = true) -public class Course extends CourseConfig implements IndexedCollection { +public class Course extends CourseConfig implements IndexedCollection, HasBreadCrumbs { private final String id; @ToString.Exclude @@ -35,7 +35,7 @@ public Course(String name) { } @Builder - public Course(String title, String description, String owner, LocalDateTime startDate, LocalDateTime endDate, List assistants, List students, String id, String gitHash, String gitURL, String directory, List assignments) { + public Course(String title, String description, String owner, ZonedDateTime startDate, ZonedDateTime endDate, List assistants, List students, String id, String gitHash, String gitURL, String directory, List assignments) { super(title, description, owner, startDate, endDate, assistants, students); this.id = id; this.gitHash = gitHash; @@ -88,4 +88,13 @@ public List getIndexedItems() { public List getExercises() { return assignments.stream().flatMap(assignment -> assignment.getExercises().stream()).collect(Collectors.toList()); } + + @Override + public List getBreadCrumbs() { + List bc = new ArrayList<>(); + BreadCrumb c = new BreadCrumb(this.title, "courses/" + this.id); + bc.add(c); + + return bc; + } } \ No newline at end of file diff --git a/src/main/java/ch/uzh/ifi/access/course/model/CourseConfig.java b/src/main/java/ch/uzh/ifi/access/course/model/CourseConfig.java index cdff4439..fa066235 100644 --- a/src/main/java/ch/uzh/ifi/access/course/model/CourseConfig.java +++ b/src/main/java/ch/uzh/ifi/access/course/model/CourseConfig.java @@ -2,24 +2,24 @@ import lombok.AllArgsConstructor; import lombok.Data; -import lombok.NoArgsConstructor; -import java.time.LocalDateTime; +import java.io.Serializable; +import java.time.ZonedDateTime; import java.util.List; @Data @AllArgsConstructor -public class CourseConfig { +public class CourseConfig implements Serializable { protected String title; protected String description; protected String owner; - protected LocalDateTime startDate; - protected LocalDateTime endDate; + protected ZonedDateTime startDate; + protected ZonedDateTime endDate; protected List assistants = List.of(); protected List students = List.of(); - public CourseConfig(){ + public CourseConfig() { this.description = ""; this.owner = ""; } diff --git a/src/main/java/ch/uzh/ifi/access/course/model/Exercise.java b/src/main/java/ch/uzh/ifi/access/course/model/Exercise.java index 8be34ad7..090b75d8 100644 --- a/src/main/java/ch/uzh/ifi/access/course/model/Exercise.java +++ b/src/main/java/ch/uzh/ifi/access/course/model/Exercise.java @@ -5,16 +5,16 @@ import lombok.*; import org.apache.commons.lang.StringUtils; -import java.time.LocalDateTime; +import java.time.ZonedDateTime; import java.util.*; import java.util.stream.Collectors; import java.util.stream.Stream; @Data -@ToString(callSuper=true) +@ToString(callSuper = true) @EqualsAndHashCode(callSuper = true) @AllArgsConstructor -public class Exercise extends ExerciseConfig implements Indexed { +public class Exercise extends ExerciseConfig implements Indexed, HasBreadCrumbs { private final String id; private int index; @@ -51,8 +51,8 @@ public Exercise(String name) { } @Builder - private Exercise(ExerciseType type, String language, Boolean isGraded, int maxScore, int maxSubmits, List options, List solutions, List hints, String id, int index, String gitHash, Assignment assignment, String question, List private_files, List solution_files, List resource_files, List public_files, CodeExecutionLimits executionLimits) { - super(type, language, isGraded, maxScore, maxSubmits, options, solutions, hints, executionLimits); + private Exercise(ExerciseType type, String language, Boolean isGraded, int maxScore, int maxSubmits, List options, List solutions, List hints, String id, int index, String gitHash, Assignment assignment, String question, List private_files, List solution_files, List resource_files, List public_files, CodeExecutionLimits executionLimits, String title, String longTitle) { + super(title, longTitle, type, language, isGraded, maxScore, maxSubmits, options, solutions, hints, executionLimits); this.id = id; this.index = index; this.gitHash = gitHash; @@ -64,7 +64,14 @@ private Exercise(ExerciseType type, String language, Boolean isGraded, int maxSc this.public_files = public_files; } + /** + * Copy all values from ExerciseConfig ino this Exercise object + * + * @param other The other ExerciseConfig + */ public void set(ExerciseConfig other) { + this.title = other.getTitle(); + this.longTitle = other.getLongTitle() == null ? other.getTitle() : other.getLongTitle(); this.type = other.getType(); this.language = other.getLanguage(); this.isGraded = other.getIsGraded(); @@ -76,6 +83,11 @@ public void set(ExerciseConfig other) { this.executionLimits = other.getExecutionLimits(); } + /** + * Update this instance of Exercise with all attributes of the other Exercise object + * + * @param other The other Exercise + */ public void update(Exercise other) { set(other); this.gitHash = other.gitHash; @@ -124,7 +136,7 @@ public boolean isPastDueDate() { return assignment.isPastDueDate(); } - public LocalDateTime getDueDate() { + public ZonedDateTime getDueDate() { return assignment.getDueDate(); } @@ -168,8 +180,8 @@ public CodeExecutionLimits getExecutionLimits() { public boolean isBreakingChange(Exercise other) { return (!Objects.equals(this.gitHash, other.gitHash) && ( - // From config - !Objects.equals(this.type, other.type) || + // From config + !Objects.equals(this.type, other.type) || !Objects.equals(this.language, other.language) || !Objects.equals(this.options, other.options) || !Objects.equals(this.solutions, other.solutions) || @@ -180,7 +192,20 @@ public boolean isBreakingChange(Exercise other) { !Objects.equals(this.private_files, other.private_files) || !Objects.equals(this.resource_files, other.resource_files) || !Objects.equals(this.public_files, other.public_files) - ) + ) ); } + + @Override + public List getBreadCrumbs() { + List bc = new ArrayList<>(); + BreadCrumb c = new BreadCrumb(this.getAssignment().getCourse().getTitle(), "courses/" + this.getAssignment().getCourse().getId()); + BreadCrumb a = new BreadCrumb(this.getAssignment().getTitle(), "courses/" + this.getAssignment().getCourse().getId() + "/assignments/" + this.getAssignment().getId()); + BreadCrumb e = new BreadCrumb(this.getTitle(), "exercises/" + this.id); + bc.add(c); + bc.add(a); + bc.add(e); + + return bc; + } } diff --git a/src/main/java/ch/uzh/ifi/access/course/model/ExerciseConfig.java b/src/main/java/ch/uzh/ifi/access/course/model/ExerciseConfig.java index 4bccfdbd..ae4eafd2 100644 --- a/src/main/java/ch/uzh/ifi/access/course/model/ExerciseConfig.java +++ b/src/main/java/ch/uzh/ifi/access/course/model/ExerciseConfig.java @@ -1,6 +1,5 @@ package ch.uzh.ifi.access.course.model; -import com.fasterxml.jackson.annotation.JsonProperty; import lombok.AllArgsConstructor; import lombok.Data; @@ -10,7 +9,9 @@ @Data @AllArgsConstructor public class ExerciseConfig implements Serializable { - @JsonProperty(required=true) + + protected String title; + protected String longTitle; protected ExerciseType type; protected String language; protected Boolean isGraded; diff --git a/src/main/java/ch/uzh/ifi/access/course/model/HasBreadCrumbs.java b/src/main/java/ch/uzh/ifi/access/course/model/HasBreadCrumbs.java new file mode 100644 index 00000000..5221644f --- /dev/null +++ b/src/main/java/ch/uzh/ifi/access/course/model/HasBreadCrumbs.java @@ -0,0 +1,7 @@ +package ch.uzh.ifi.access.course.model; + +import java.util.List; + +public interface HasBreadCrumbs { + List getBreadCrumbs(); +} diff --git a/src/main/java/ch/uzh/ifi/access/course/model/HasDueDate.java b/src/main/java/ch/uzh/ifi/access/course/model/HasDueDate.java new file mode 100644 index 00000000..437cc31d --- /dev/null +++ b/src/main/java/ch/uzh/ifi/access/course/model/HasDueDate.java @@ -0,0 +1,12 @@ +package ch.uzh.ifi.access.course.model; + +import java.time.ZonedDateTime; + +public interface HasDueDate { + + ZonedDateTime getDueDate(); + + default boolean isPastDueDate() { + return getDueDate() != null && ZonedDateTime.now().isAfter(this.getDueDate()); + } +} diff --git a/src/main/java/ch/uzh/ifi/access/course/model/HasPublishingDate.java b/src/main/java/ch/uzh/ifi/access/course/model/HasPublishingDate.java index 1114650c..8786c739 100644 --- a/src/main/java/ch/uzh/ifi/access/course/model/HasPublishingDate.java +++ b/src/main/java/ch/uzh/ifi/access/course/model/HasPublishingDate.java @@ -1,12 +1,12 @@ package ch.uzh.ifi.access.course.model; -import java.time.LocalDateTime; +import java.time.ZonedDateTime; public interface HasPublishingDate { - LocalDateTime getPublishDate(); + ZonedDateTime getPublishDate(); default boolean isPublished() { - return getPublishDate() != null && getPublishDate().isBefore(LocalDateTime.now()); + return getPublishDate() != null && getPublishDate().isBefore(ZonedDateTime.now()); } } diff --git a/src/main/java/ch/uzh/ifi/access/course/model/VirtualFile.java b/src/main/java/ch/uzh/ifi/access/course/model/VirtualFile.java index 25a21a32..4a9fe14b 100644 --- a/src/main/java/ch/uzh/ifi/access/course/model/VirtualFile.java +++ b/src/main/java/ch/uzh/ifi/access/course/model/VirtualFile.java @@ -11,6 +11,7 @@ import java.io.File; import java.io.IOException; +import java.io.Serializable; import java.nio.file.Files; import java.nio.file.Paths; import java.util.Arrays; @@ -20,7 +21,7 @@ @ToString @Getter @Setter -public class VirtualFile { +public class VirtualFile implements Serializable { private static final List MEDIA_EXTENSIONS = Arrays.asList("jpg", "jpeg", "png", "mp3", "mp4"); private String id; diff --git a/src/main/java/ch/uzh/ifi/access/course/service/CourseService.java b/src/main/java/ch/uzh/ifi/access/course/service/CourseService.java index 5e7807a5..a7e7d302 100644 --- a/src/main/java/ch/uzh/ifi/access/course/service/CourseService.java +++ b/src/main/java/ch/uzh/ifi/access/course/service/CourseService.java @@ -11,6 +11,7 @@ import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Qualifier; import org.springframework.core.io.FileSystemResource; +import org.springframework.scheduling.annotation.Async; import org.springframework.stereotype.Service; import java.util.List; @@ -29,6 +30,7 @@ public CourseService(@Qualifier("gitrepo") CourseDAO courseDao, CourseServiceSet this.courseSetup = courseSetup; } + @Async("courseUpdateWorkerExecutor") public void updateCourseById(String id) { Course course = courseDao.updateCourseById(id); if (course != null) { diff --git a/src/main/java/ch/uzh/ifi/access/course/util/RepoCacher.java b/src/main/java/ch/uzh/ifi/access/course/util/RepoCacher.java index f7f91b5e..26fc340e 100644 --- a/src/main/java/ch/uzh/ifi/access/course/util/RepoCacher.java +++ b/src/main/java/ch/uzh/ifi/access/course/util/RepoCacher.java @@ -4,20 +4,19 @@ import com.fasterxml.jackson.databind.DeserializationFeature; import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule; -import com.fasterxml.jackson.datatype.jsr310.deser.LocalDateTimeDeserializer; +import org.apache.commons.lang.StringUtils; import org.slf4j.Logger; import org.slf4j.LoggerFactory; +import org.springframework.stereotype.Component; import java.io.File; import java.nio.file.Files; -import java.time.LocalDateTime; -import java.time.format.DateTimeFormatter; -import java.time.format.DateTimeFormatterBuilder; -import java.time.temporal.ChronoField; +import java.time.ZonedDateTime; import java.util.ArrayList; import java.util.Arrays; import java.util.List; +@Component public class RepoCacher { private static final Logger logger = LoggerFactory.getLogger(RepoCacher.class); @@ -43,7 +42,7 @@ public class RepoCacher { private List ignore_dir = Arrays.asList(".git"); private List ignore_file = Arrays.asList(".gitattributes", ".gitignore", "README.md"); - public static List retrieveCourseData(String urls[]) { + public List retrieveCourseData(String[] urls) { initializeMapper(); List courses = new ArrayList<>(); @@ -87,27 +86,6 @@ public static List retrieveCourseData(List repos) { return courses; } - private static void initializeMapper() { - DateTimeFormatter fmt = new DateTimeFormatterBuilder() - .appendPattern("yyyy-MM-dd") - .optionalStart() - .appendPattern(" HH:mm") - .optionalEnd() - .parseDefaulting(ChronoField.HOUR_OF_DAY, 0) - .parseDefaulting(ChronoField.MINUTE_OF_HOUR, 0) - .toFormatter(); - - JavaTimeModule javaTimeModule = new JavaTimeModule(); - LocalDateTimeDeserializer deserializer = new LocalDateTimeDeserializer(fmt); - javaTimeModule.addDeserializer(LocalDateTime.class, deserializer); - - mapper = new ObjectMapper(); - mapper.disable(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES); - mapper.enable(DeserializationFeature.FAIL_ON_IGNORED_PROPERTIES); - - mapper.registerModule(javaTimeModule); - } - private static String readFile(File file) { try { String content = Files.readString(file.toPath()); @@ -126,14 +104,22 @@ private void cacheRepo(File file, Object context) { Object next_context = context; if (file.getName().startsWith(ASSIGNMENT_FOLDER_PREFIX)) { Course c = ((Course) context); - Assignment assignment = new Assignment(c.getGitURL() + file.getName()); - assignment.setIndex(Integer.parseInt(file.getName().replace(ASSIGNMENT_FOLDER_PREFIX, ""))); + + String cleanName = cleanFolderName(file.getName()); + int index = Integer.parseInt(cleanName.replace(ASSIGNMENT_FOLDER_PREFIX, "")); + + Assignment assignment = new Assignment(c.getGitURL() + cleanName); + assignment.setIndex(index); c.addAssignment(assignment); next_context = assignment; } else if (file.getName().startsWith(EXERCISE_FOLDER_PREFIX)) { Assignment a = ((Assignment) context); - Exercise exercise = new Exercise(a.getId() + file.getName()); - exercise.setIndex(Integer.parseInt(file.getName().replace(EXERCISE_FOLDER_PREFIX, ""))); + + String cleanName = cleanFolderName(file.getName()); + int index = Integer.parseInt(cleanName.replace(EXERCISE_FOLDER_PREFIX, "")); + + Exercise exercise = new Exercise(a.getId() + cleanName); + exercise.setIndex(index); exercise.setGitHash(((Assignment) context).getCourse().getGitHash()); a.addExercise(exercise); next_context = exercise; @@ -216,4 +202,25 @@ private static String loadFilesFromGit(String url) throws Exception { return new GitClient().clone(url, gitDir); } } + + private static String cleanFolderName(String name) { + String cleanName = name; + int commentIndex = StringUtils.ordinalIndexOf(name, "_", 2); + + if (commentIndex != -1) + cleanName = name.substring(0, commentIndex); + + return cleanName; + } + + private static void initializeMapper() { + JavaTimeModule javaTimeModule = new JavaTimeModule(); + javaTimeModule.addDeserializer(ZonedDateTime.class, new ZonedDateTimeDeserializer()); + + mapper = new ObjectMapper(); + mapper.disable(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES); + mapper.enable(DeserializationFeature.FAIL_ON_IGNORED_PROPERTIES); + + mapper.registerModule(javaTimeModule); + } } \ No newline at end of file diff --git a/src/main/java/ch/uzh/ifi/access/course/util/ZonedDateTimeDeserializer.java b/src/main/java/ch/uzh/ifi/access/course/util/ZonedDateTimeDeserializer.java new file mode 100644 index 00000000..7a8e8ad1 --- /dev/null +++ b/src/main/java/ch/uzh/ifi/access/course/util/ZonedDateTimeDeserializer.java @@ -0,0 +1,31 @@ +package ch.uzh.ifi.access.course.util; + +import com.fasterxml.jackson.core.JsonParser; +import com.fasterxml.jackson.databind.DeserializationContext; +import com.fasterxml.jackson.databind.JsonDeserializer; + +import java.io.IOException; +import java.time.LocalDateTime; +import java.time.ZoneId; +import java.time.ZonedDateTime; +import java.time.format.DateTimeFormatter; +import java.time.format.DateTimeFormatterBuilder; +import java.time.temporal.ChronoField; + +public class ZonedDateTimeDeserializer extends JsonDeserializer { + + @Override + public ZonedDateTime deserialize(JsonParser jsonParser, DeserializationContext deserializationContext) throws IOException { + DateTimeFormatter fmt = new DateTimeFormatterBuilder() + .appendPattern("yyyy-MM-dd") + .optionalStart() + .appendPattern(" HH:mm") + .optionalEnd() + .parseDefaulting(ChronoField.HOUR_OF_DAY, 0) + .parseDefaulting(ChronoField.MINUTE_OF_HOUR, 0) + .toFormatter(); + LocalDateTime localDateTime = LocalDateTime.parse(jsonParser.getText(), fmt); + + return localDateTime.atZone(ZoneId.of("Europe/Zurich")); + } + } \ No newline at end of file diff --git a/src/main/java/ch/uzh/ifi/access/student/config/SchedulingConfig.java b/src/main/java/ch/uzh/ifi/access/student/config/SchedulingConfig.java new file mode 100644 index 00000000..67699507 --- /dev/null +++ b/src/main/java/ch/uzh/ifi/access/student/config/SchedulingConfig.java @@ -0,0 +1,34 @@ +package ch.uzh.ifi.access.student.config; + +import ch.uzh.ifi.access.student.evaluation.process.EvalMachineRepoService; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.context.annotation.Configuration; +import org.springframework.scheduling.annotation.EnableScheduling; +import org.springframework.scheduling.annotation.Scheduled; + +import java.time.Instant; +import java.time.temporal.ChronoUnit; + +@Configuration +@EnableScheduling +public class SchedulingConfig { + + private static Logger logger = LoggerFactory.getLogger(SchedulingConfig.class); + + private static final long FIXED_DELAY_IN_MINUTES = 5; + + private EvalMachineRepoService machineRepository; + + public SchedulingConfig(EvalMachineRepoService machineRepository) { + this.machineRepository = machineRepository; + } + + @Scheduled(fixedDelay = FIXED_DELAY_IN_MINUTES * 60000) + public void cleanUpRepo() { + Instant threshold = Instant.now().minus(5, ChronoUnit.MINUTES); + logger.debug("Starting state machine cleanup. Repository size {}, removing machine older than {}", machineRepository.count(), threshold); + machineRepository.removeMachinesOlderThan(threshold); + logger.debug("Completed cleanup. Repository size {}", machineRepository.count()); + } +} diff --git a/src/main/java/ch/uzh/ifi/access/student/controller/SubmissionController.java b/src/main/java/ch/uzh/ifi/access/student/controller/SubmissionController.java index fb96cd27..ad56ecc4 100644 --- a/src/main/java/ch/uzh/ifi/access/student/controller/SubmissionController.java +++ b/src/main/java/ch/uzh/ifi/access/student/controller/SubmissionController.java @@ -1,5 +1,6 @@ package ch.uzh.ifi.access.student.controller; +import ch.uzh.ifi.access.config.GracefulShutdown; import ch.uzh.ifi.access.course.config.CourseAuthentication; import ch.uzh.ifi.access.course.config.CoursePermissionEvaluator; import ch.uzh.ifi.access.course.controller.ResourceNotFoundException; @@ -20,8 +21,11 @@ import org.springframework.web.bind.annotation.*; import springfox.documentation.annotations.ApiIgnore; -import java.time.LocalDateTime; -import java.util.*; +import java.time.ZonedDateTime; +import java.util.AbstractMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; @RestController @RequestMapping("/submissions") @@ -37,11 +41,14 @@ public class SubmissionController { private final CoursePermissionEvaluator permissionEvaluator; - public SubmissionController(StudentSubmissionService studentSubmissionService, CourseService courseService, EvalProcessService processService, CoursePermissionEvaluator permissionEvaluator) { + private final GracefulShutdown gracefulShutdown; + + public SubmissionController(StudentSubmissionService studentSubmissionService, CourseService courseService, EvalProcessService processService, CoursePermissionEvaluator permissionEvaluator, GracefulShutdown gracefulShutdown) { this.studentSubmissionService = studentSubmissionService; this.courseService = courseService; this.processService = processService; this.permissionEvaluator = permissionEvaluator; + this.gracefulShutdown = gracefulShutdown; } @GetMapping("/{submissionId}") @@ -79,9 +86,13 @@ public ResponseEntity submit(@PathVariable String exerciseId, @RequestBody St logger.info(String.format("User %s submitted exercise: %s", username, exerciseId)); + if (gracefulShutdown.isShutdown()) { + return ResponseEntity.status(HttpStatus.SERVICE_UNAVAILABLE).header("Retry-After", "60").build(); + } + if (studentSubmissionService.isUserRateLimited(authentication.getUserId())) { return new ResponseEntity<>( - "Submition rejected: User has an other running submisison.", HttpStatus.TOO_MANY_REQUESTS); + "Submission rejected: User has an other running submission.", HttpStatus.TOO_MANY_REQUESTS); } Exercise exercise = courseService.getExerciseById(exerciseId).orElseThrow(() -> new ResourceNotFoundException("Referenced exercise does not exist")); @@ -127,7 +138,7 @@ public SubmissionHistoryDTO getAllSubmissionsForExercise(@PathVariable String ex SubmissionCount submissionCount = getAvailableSubmissionCount(exerciseId, authentication); Optional exercise = courseService.getExerciseById(exerciseId); boolean isPastDueDate = exercise.map(Exercise::isPastDueDate).orElse(false); - LocalDateTime dueDate = exercise.map(Exercise::getDueDate).orElse(null); + ZonedDateTime dueDate = exercise.map(Exercise::getDueDate).orElse(null); return new SubmissionHistoryDTO(submissions, runs, submissionCount, dueDate, isPastDueDate); } diff --git a/src/main/java/ch/uzh/ifi/access/student/dto/SubmissionHistoryDTO.java b/src/main/java/ch/uzh/ifi/access/student/dto/SubmissionHistoryDTO.java index fd8c5f8a..4b468747 100644 --- a/src/main/java/ch/uzh/ifi/access/student/dto/SubmissionHistoryDTO.java +++ b/src/main/java/ch/uzh/ifi/access/student/dto/SubmissionHistoryDTO.java @@ -6,7 +6,7 @@ import lombok.Value; import java.time.Instant; -import java.time.LocalDateTime; +import java.time.ZonedDateTime; import java.util.List; import java.util.stream.Collectors; @@ -20,9 +20,9 @@ public class SubmissionHistoryDTO { private final boolean isPastDueDate; - private final LocalDateTime dueDate; + private final ZonedDateTime dueDate; - public SubmissionHistoryDTO(List submissions, List runs, SubmissionCount submissionCount, LocalDateTime dueDate, boolean isPastDueDate) { + public SubmissionHistoryDTO(List submissions, List runs, SubmissionCount submissionCount, ZonedDateTime dueDate, boolean isPastDueDate) { this.submissions = submissions.stream().map(SubmissionMetadata::new).collect(Collectors.toList()); this.runs = runs.stream().map(SubmissionMetadata::new).collect(Collectors.toList()); this.submissionCount = submissionCount; @@ -44,6 +44,8 @@ public static class SubmissionMetadata { private final boolean isInvalid; + private final boolean isTriggeredReSubmission; + private SubmissionEvaluation result; SubmissionMetadata(StudentSubmission submission) { @@ -53,9 +55,10 @@ public static class SubmissionMetadata { this.commitHash = submission.getCommitId(); this.result = submission.getResult(); this.isInvalid = submission.isInvalid(); + this.isTriggeredReSubmission = submission.isTriggeredReSubmission(); if (submission instanceof CodeSubmission) { - this.graded = ((CodeSubmission) submission).isGraded(); + this.graded = submission.isGraded(); } else { this.graded = true; } diff --git a/src/main/java/ch/uzh/ifi/access/student/evaluation/evaluator/CodeEvaluator.java b/src/main/java/ch/uzh/ifi/access/student/evaluation/evaluator/CodeEvaluator.java index 233c4fc3..73413877 100644 --- a/src/main/java/ch/uzh/ifi/access/student/evaluation/evaluator/CodeEvaluator.java +++ b/src/main/java/ch/uzh/ifi/access/student/evaluation/evaluator/CodeEvaluator.java @@ -1,123 +1,175 @@ package ch.uzh.ifi.access.student.evaluation.evaluator; -import ch.uzh.ifi.access.course.model.Exercise; -import ch.uzh.ifi.access.course.model.ExerciseType; -import ch.uzh.ifi.access.student.model.CodeSubmission; -import ch.uzh.ifi.access.student.model.StudentSubmission; -import ch.uzh.ifi.access.student.model.SubmissionEvaluation; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; -import org.springframework.util.Assert; - import java.util.ArrayList; import java.util.Arrays; import java.util.List; import java.util.regex.Matcher; import java.util.regex.Pattern; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.util.Assert; +import org.springframework.util.StringUtils; + +import ch.uzh.ifi.access.course.model.Exercise; +import ch.uzh.ifi.access.course.model.ExerciseType; +import ch.uzh.ifi.access.student.model.CodeSubmission; +import ch.uzh.ifi.access.student.model.StudentSubmission; +import ch.uzh.ifi.access.student.model.SubmissionEvaluation; + public class CodeEvaluator implements StudentSubmissionEvaluator { - private static final Logger logger = LoggerFactory.getLogger(CodeEvaluator.class); - - private static final String HINT_ANNOTATION = "@@"; - private static final String HINT_PATTERN = "^Assertion.*:.*("+HINT_ANNOTATION+".*"+HINT_ANNOTATION+")$"; - - private final String runNTestPattern = "^Ran (\\d++) test.*"; - private final String nokNTestPattern = "^FAILED \\p{Punct}(failures|errors)=(\\d++)\\p{Punct}.*"; - - private Pattern hintPattern; - - public CodeEvaluator() { - this.hintPattern = Pattern.compile(HINT_PATTERN, Pattern.MULTILINE); - //this.hintPattern = Pattern.compile(HINT_PATTERN); - } - - @Override - public SubmissionEvaluation evaluate(StudentSubmission submission, Exercise exercise) { - validate(submission, exercise); - CodeSubmission codeSub = (CodeSubmission) submission; - - SubmissionEvaluation.Points scoredPoints = parseScoreFromLog(codeSub.getConsole().getEvalLog()); - List hints = parseHintsFromLog(codeSub.getConsole().getEvalLog()); - - return SubmissionEvaluation.builder() - .points(scoredPoints) - .maxScore(exercise.getMaxScore()) - .hints(hints) - .build(); - } - - private List parseHintsFromLog(String evalLog) { - List hints = new ArrayList<>(); - - Matcher matcher = hintPattern.matcher(evalLog); - while (matcher.find()) { - String s = matcher.group(1); - if(s != null && s.length()>0 && s.contains("@@")){ - hints.add(s.replace(HINT_ANNOTATION, "")); - } - } - - return hints; - } - - private SubmissionEvaluation.Points parseScoreFromLog(String log) { - int points = 0; - int nrOfTest = -1; - - if (log != null && !log.trim().isEmpty()) { - List lines = Arrays.asList(log.split("\n")); - String resultLine = lines.get(lines.size() - 1); - - nrOfTest = extractNrOfTests(lines.get(lines.size() - 3)); - - if (resultLine.startsWith("OK")) { - points = nrOfTest; - } else if (resultLine.startsWith("FAILED")) { - points = nrOfTest - extractNrOfNOKTests(resultLine); - } - } else { - logger.info("No console log to evaluate."); - } - - return new SubmissionEvaluation.Points(points, nrOfTest); - } - - private int extractNrOfTests(String line) { - int nrTests = 0; - Pattern p = Pattern.compile(runNTestPattern); - Matcher m = p.matcher(line); - if (m.find()) { - // group0 = line - // group1 = nr of tests - String group1 = m.group(1); - nrTests = Integer.parseInt(group1); - } - logger.debug(String.format("Exracted nr of test (%s) from line: %s", nrTests, line)); - return nrTests; - } - - private int extractNrOfNOKTests(String line) { - int nrTests = 0; - Pattern p = Pattern.compile(nokNTestPattern); - Matcher m = p.matcher(line); - if (m.find()) { - // group0 = line - // group1 = failures / errors - // group2 = nr of tests - String nrOfTests = m.group(2); - nrTests = Integer.parseInt(nrOfTests); - } - logger.debug(String.format("Exracted nr of NOK tests (%s) from line: %s", nrTests, line)); - return nrTests; - } - - private void validate(StudentSubmission submission, Exercise exercise) throws IllegalArgumentException { - Assert.notNull(submission, "Submission object for evaluation cannot be null."); - Assert.isInstanceOf(CodeSubmission.class, submission); - - Assert.notNull(exercise, "Exercise object for evaluation cannot be null."); - Assert.isTrue(exercise.getType().isCodeType(), String.format("Exercise object for evaluation must be of type %s or %s", ExerciseType.code, ExerciseType.codeSnippet)); - } + private static final Logger logger = LoggerFactory.getLogger(CodeEvaluator.class); + + private static final String HINT_ANNOTATION = "@@"; + private static final String HINT_PATTERN = "^Assertion.*?:.*?(" + HINT_ANNOTATION + ".*?" + HINT_ANNOTATION + ")$"; + private static final String LAST_CRASH_PATTERN = "^(.*?Error):.*?"; + + private static final String PYTHON_ASSERTION_ERROR = "AssertionError"; + static final String TEST_FAILED_WITHOUT_HINTS = "Test failed without solution hints"; + + private final String runNTestPattern = "^Ran (\\d++) test.*"; + private final String nokNTestPattern = "^FAILED \\p{Punct}(failures|errors)=(\\d++)\\p{Punct}.*"; + + private Pattern hintPattern; + private Pattern crashPattern; + + private Pattern failedTestPattern; + + public CodeEvaluator() { + this.hintPattern = Pattern.compile(HINT_PATTERN, Pattern.MULTILINE | Pattern.DOTALL); + this.crashPattern = Pattern.compile(LAST_CRASH_PATTERN, Pattern.MULTILINE); + this.failedTestPattern = Pattern.compile(nokNTestPattern, Pattern.MULTILINE); + } + + @Override + public SubmissionEvaluation evaluate(StudentSubmission submission, Exercise exercise) { + validate(submission, exercise); + CodeSubmission codeSub = (CodeSubmission) submission; + + String log = codeSub.getConsole().getEvalLog(); + + SubmissionEvaluation.Points testResults = parseScoreFromLog(log); + List hints = testResults.isEverythingCorrect() ? new ArrayList() : parseHintsFromLog(log); + + return SubmissionEvaluation.builder().points(testResults).maxScore(exercise.getMaxScore()).hints(hints).build(); + } + + public List parseHintsFromLog(String evalLog) { + List hints = new ArrayList<>(); + + Matcher matcher = hintPattern.matcher(evalLog); + while (matcher.find()) { + String possibleHint = matcher.group(1); + if (!StringUtils.isEmpty(possibleHint) && possibleHint.contains(HINT_ANNOTATION)) { + hints.add(possibleHint.replace(HINT_ANNOTATION, "")); + } + } + + boolean hasFailedTests = failedTestPattern.matcher(evalLog).find(); + if (hints.isEmpty() && hasFailedTests) { + matcher = crashPattern.matcher(evalLog); + + String lastCrash = null; + while (matcher.find()) { + String error = matcher.group(1); + + if (!error.equals(PYTHON_ASSERTION_ERROR)) { + lastCrash = error; + } + } + if (lastCrash != null) { + hints.add("Error during execution: " + lastCrash); + } + + if (hints.isEmpty()) { + hints.add(TEST_FAILED_WITHOUT_HINTS); + } + } + + if (hints.isEmpty()) { + String[] lines = evalLog.split("\n"); + if (lines.length > 0) { + String lastLine = lines[lines.length - 1]; + int idxColon = lastLine.indexOf(':'); + if (idxColon != -1) { + String everythingBeforeColon = lastLine.substring(0, idxColon).trim(); + hints.add("Error during import: " + everythingBeforeColon); + } + } + } + + if (hints.isEmpty()) { + hints.add("No hint could be provided. This is likely caused by a crash during the execution."); + } + + return hints; + } + + private SubmissionEvaluation.Points parseScoreFromLog(String log) { + int points = 0; + int nrOfTest = -1; + + if (log != null && !log.trim().isEmpty()) { + List lines = Arrays.asList(log.split("\n")); + if (lines.size() >= 3) { + String resultLine = lines.get(lines.size() - 1); + + nrOfTest = extractNrOfTests(lines.get(lines.size() - 3)); + + if (resultLine.startsWith("OK")) { + points = nrOfTest; + } else if (resultLine.startsWith("FAILED")) { + points = nrOfTest - extractNrOfNOKTests(resultLine); + } + } else { + points = 0; + nrOfTest = 1; + logger.info("Log is too short, likely not a valid test output."); + } + } else { + logger.info("No console log to evaluate."); + } + + return new SubmissionEvaluation.Points(points, nrOfTest); + } + + private int extractNrOfTests(String line) { + int nrTests = 0; + Pattern p = Pattern.compile(runNTestPattern); + Matcher m = p.matcher(line); + if (m.find()) { + // group0 = line + // group1 = nr of tests + String group1 = m.group(1); + nrTests = Integer.parseInt(group1); + } + logger.debug(String.format("Exracted nr of test (%s) from line: %s", nrTests, line)); + return nrTests; + } + + private int extractNrOfNOKTests(String line) { + int nrTests = 0; + Matcher m = failedTestPattern.matcher(line); + if (m.find()) { + // group0 = line + // group1 = failures / errors + // group2 = nr of tests + String nrOfTests = m.group(2); + nrTests = Integer.parseInt(nrOfTests); + } + logger.debug(String.format("Exracted nr of NOK tests (%s) from line: %s", nrTests, line)); + return nrTests; + } + + private void validate(StudentSubmission submission, Exercise exercise) throws IllegalArgumentException { + Assert.notNull(submission, "Submission object for evaluation cannot be null."); + Assert.isInstanceOf(CodeSubmission.class, submission); + + Assert.notNull(exercise, "Exercise object for evaluation cannot be null."); + Assert.isTrue(exercise.getType().isCodeType(), + String.format("Exercise object for evaluation must be of type %s or %s", ExerciseType.code, + ExerciseType.codeSnippet)); + } } diff --git a/src/main/java/ch/uzh/ifi/access/student/evaluation/process/EvalMachineFactory.java b/src/main/java/ch/uzh/ifi/access/student/evaluation/process/EvalMachineFactory.java index db0855e1..dab1f144 100644 --- a/src/main/java/ch/uzh/ifi/access/student/evaluation/process/EvalMachineFactory.java +++ b/src/main/java/ch/uzh/ifi/access/student/evaluation/process/EvalMachineFactory.java @@ -16,6 +16,7 @@ public class EvalMachineFactory { public static final String EXTENDED_VAR_SUBMISSION_ID = "submissionId"; public static final String EXTENDED_VAR_NEXT_STEP = "nextStep"; public static final String EXTENDED_VAR_NEXT_STEP_DELAY = "nextStepDelay"; + public static final String EXTENDED_VAR_COMPLETION_TIME = "completionTime"; public static StateMachine initSMForSubmission(String submissionId) throws Exception { @@ -57,6 +58,7 @@ public static StateMachine initSMForSubm StateMachine machine = builder.build(); machine.getExtendedState().getVariables().put(EXTENDED_VAR_SUBMISSION_ID, submissionId); + machine.addStateListener(new StateMachineEventListener(machine)); return machine; } diff --git a/src/main/java/ch/uzh/ifi/access/student/evaluation/process/EvalMachineRepoService.java b/src/main/java/ch/uzh/ifi/access/student/evaluation/process/EvalMachineRepoService.java index 73e9283a..f80c5900 100644 --- a/src/main/java/ch/uzh/ifi/access/student/evaluation/process/EvalMachineRepoService.java +++ b/src/main/java/ch/uzh/ifi/access/student/evaluation/process/EvalMachineRepoService.java @@ -3,6 +3,7 @@ import org.springframework.statemachine.StateMachine; import org.springframework.stereotype.Service; +import java.time.Instant; import java.util.Map; import java.util.concurrent.ConcurrentHashMap; @@ -27,4 +28,15 @@ public void store(String key, StateMachine machine) { machines.put(key, machine); } + public long count() { + return machines.size(); + } + + public void removeMachinesOlderThan(Instant threshold) { + machines.entrySet().removeIf(entry -> { + StateMachine machine = entry.getValue(); + Instant completionTime = (Instant) machine.getExtendedState().getVariables().get(EvalMachineFactory.EXTENDED_VAR_COMPLETION_TIME); + return completionTime.isBefore(threshold); + }); + } } diff --git a/src/main/java/ch/uzh/ifi/access/student/evaluation/process/StateMachineEventListener.java b/src/main/java/ch/uzh/ifi/access/student/evaluation/process/StateMachineEventListener.java new file mode 100644 index 00000000..3f084208 --- /dev/null +++ b/src/main/java/ch/uzh/ifi/access/student/evaluation/process/StateMachineEventListener.java @@ -0,0 +1,24 @@ +package ch.uzh.ifi.access.student.evaluation.process; + +import org.springframework.statemachine.StateMachine; +import org.springframework.statemachine.listener.StateMachineListenerAdapter; +import org.springframework.statemachine.state.State; + +import java.time.Instant; + +public class StateMachineEventListener + extends StateMachineListenerAdapter { + + private StateMachine machine; + + public StateMachineEventListener(StateMachine machine) { + this.machine = machine; + } + + @Override + public void stateEntered(State state) { + if (EvalMachine.States.FINISHED.equals(state.getId())) { + machine.getExtendedState().getVariables().put(EvalMachineFactory.EXTENDED_VAR_COMPLETION_TIME, Instant.now()); + } + } +} \ No newline at end of file diff --git a/src/main/java/ch/uzh/ifi/access/student/model/SubmissionEvaluation.java b/src/main/java/ch/uzh/ifi/access/student/model/SubmissionEvaluation.java index 68fb40ef..11d86401 100644 --- a/src/main/java/ch/uzh/ifi/access/student/model/SubmissionEvaluation.java +++ b/src/main/java/ch/uzh/ifi/access/student/model/SubmissionEvaluation.java @@ -1,51 +1,60 @@ package ch.uzh.ifi.access.student.model; -import com.fasterxml.jackson.annotation.JsonProperty; -import lombok.*; - import java.time.Instant; import java.util.Arrays; import java.util.Collections; import java.util.List; +import com.fasterxml.jackson.annotation.JsonProperty; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; +import lombok.Value; + @SuppressWarnings("unused") @Value @Data @Builder public class SubmissionEvaluation { - public static SubmissionEvaluation NO_SUBMISSION = new SubmissionEvaluation(new Points(0, 0), 0, Instant.MIN, Collections.emptyList()); + public static SubmissionEvaluation NO_SUBMISSION = new SubmissionEvaluation(new Points(0, 0), 0, Instant.MIN, + Collections.emptyList()); - private Points points; + private Points points; - private int maxScore; + private int maxScore; - private Instant timestamp; + private Instant timestamp; - private List hints; + private List hints; - @JsonProperty - public boolean hasSubmitted() { - return !NO_SUBMISSION.equals(this); - } + @JsonProperty + public boolean hasSubmitted() { + return !NO_SUBMISSION.equals(this); + } - public double getScore() { - if(points.getMax() == 0){ - return 0.0; - } - return Math.round((points.getCorrect() / (double) points.getMax() * maxScore) * 4) / 4d; - } + public double getScore() { + if (points.getMax() == 0) { + return 0.0; + } + return Math.round((points.getCorrect() / (double) points.getMax() * maxScore) * 4) / 4d; + } - public List getHints() { - return hints != null && hints.size() > 1 ? Arrays.asList(hints.get(0)) : hints; - } + public List getHints() { + return hints != null && hints.size() > 1 ? Arrays.asList(hints.get(0)) : hints; + } - @Data - @AllArgsConstructor - @NoArgsConstructor - public static class Points { - private int correct; - private int max; - } + @Data + @AllArgsConstructor + @NoArgsConstructor + public static class Points { + private int correct; + private int max; + public boolean isEverythingCorrect() { + return correct == max; + } + } } diff --git a/src/main/resources/application-prod.properties b/src/main/resources/application-prod.properties index 5541b9b5..eee92d1b 100644 --- a/src/main/resources/application-prod.properties +++ b/src/main/resources/application-prod.properties @@ -19,8 +19,6 @@ rest.security.authorization-endpoint=${rest.security.issuer-uri}/protocol/openid security.oauth2.resource.id=course-service security.oauth2.resource.jwk.key-set-uri=${JWK_URI} # Others -logging.level.ch.uzh.ifi.access.coderunner=trace -logging.level.ch.uzh.ifi.access.course.config=debug server.servlet.context-path=/api # Initialize course participants course.users.init-on-startup=true @@ -37,4 +35,7 @@ spring.data.mongodb.password=${MONGO_DB_PASSWORD} submission.eval.thread-pool-size=10 submission.eval.max-pool-size=20 submission.eval.queue-capacity=1000 -submission.eval.user-rate-limit=true \ No newline at end of file +submission.eval.user-rate-limit=true + +# Server info +server.info.version=${BACKEND_VERSION:version-unknown} \ No newline at end of file diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties index 0dcf8713..83badf24 100644 --- a/src/main/resources/application.properties +++ b/src/main/resources/application.properties @@ -34,4 +34,7 @@ spring.data.mongodb.port=27017 submission.eval.thread-pool-size=10 submission.eval.max-pool-size=20 submission.eval.queue-capacity=500 -submission.eval.user-rate-limit=false \ No newline at end of file +submission.eval.user-rate-limit=false + +# Version info +server.info.version=${BACKEND_VERSION:version-unknown} \ No newline at end of file diff --git a/src/main/resources/logback-spring.xml b/src/main/resources/logback-spring.xml index fc8c1fe5..8fe2c4f4 100644 --- a/src/main/resources/logback-spring.xml +++ b/src/main/resources/logback-spring.xml @@ -9,8 +9,11 @@ - %black(%d{ISO8601}) %highlight(%-5level) [${springAppName},%X{X-B3-TraceId:-},%X{X-B3-SpanId:-},%X{X-Span-Export:-}] ${PID:-} -- [%blue(%t)] %yellow(%C{1.}): %msg%n%throwable + %black(%d{ISO8601}) %highlight(%-5level) [%X{X-B3-TraceId:-}] ${PID:-} -- [%blue(%t)] %yellow(%C{40}): %msg%n%throwable + + DEBUG + ${LOGS}/spring-boot-logger.log - %d{ISO8601} %-5level [${springAppName},%X{X-B3-TraceId:-},%X{X-B3-SpanId:-},%X{X-Span-Export:-}] ${PID:-} -- [%t] %C{1.}: %msg%n%throwable + %d{ISO8601} %-5level [%X{X-B3-TraceId:-}] ${PID:-} -- [%t] %C{40}: %msg%n%throwable - + - ${LOGS}/archived/spring-boot-logger-%d{yyyy-MM-dd}.%i.log + + ${LOGS}/archived/spring-boot-logger-%d{yyyy-MM-dd}.%i.log - + + 10MB + + + + + + ${LOGS}/code-exec.log + + + %d{ISO8601} %-5level [%X{X-B3-TraceId:-}] ${PID:-} -- [%t] %C{40}: %msg%n%throwable + + + + + + ${LOGS}/archived/code-exec-%d{yyyy-MM-dd}.%i.log + + 60 + 100MB + 10MB @@ -37,7 +60,7 @@ class="ch.qos.logback.core.rolling.RollingFileAppender"> ${LOGS}/perf.log - %d{ISO8601} %-5level [${springAppName},%X{X-B3-TraceId:-},%X{X-B3-SpanId:-},%X{X-Span-Export:-}] %m%n + %d{ISO8601} %-5level [%X{X-B3-TraceId:-}] %m%n - - - - - - - - + - - + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/src/test/java/ch/uzh/ifi/access/TestObjectFactory.java b/src/test/java/ch/uzh/ifi/access/TestObjectFactory.java index d00f0619..9c8f7c6e 100644 --- a/src/test/java/ch/uzh/ifi/access/TestObjectFactory.java +++ b/src/test/java/ch/uzh/ifi/access/TestObjectFactory.java @@ -9,7 +9,7 @@ import org.springframework.security.oauth2.provider.OAuth2Request; import java.time.Instant; -import java.time.LocalDateTime; +import java.time.ZonedDateTime; import java.util.List; import java.util.Map; import java.util.Set; @@ -36,8 +36,8 @@ public static Assignment createAssignment(String title) { Assignment assignment = new Assignment(UUID.randomUUID().toString()); assignment.setTitle(title); assignment.setDescription("Some description"); - assignment.setDueDate(LocalDateTime.now().plusDays(7)); - assignment.setPublishDate(LocalDateTime.now()); + assignment.setDueDate(ZonedDateTime.now().plusDays(7)); + assignment.setPublishDate(ZonedDateTime.now()); return assignment; } diff --git a/src/test/java/ch/uzh/ifi/access/course/controller/WebhooksControllerTest.java b/src/test/java/ch/uzh/ifi/access/course/controller/WebhooksControllerTest.java new file mode 100644 index 00000000..c99af339 --- /dev/null +++ b/src/test/java/ch/uzh/ifi/access/course/controller/WebhooksControllerTest.java @@ -0,0 +1,286 @@ +package ch.uzh.ifi.access.course.controller; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.assertj.core.api.Assertions; +import org.junit.Test; + +import java.io.IOException; + +public class WebhooksControllerTest { + + private String gitlabPayload = "{\n" + + " \"object_kind\": \"push\",\n" + + " \"event_name\": \"push\",\n" + + " \"before\": \"02ed94097a0636908a4b56ad70a4b9a8e575995d\",\n" + + " \"after\": \"67b1027ed1bc67edf03c02b59b3dd3d6c01b8475\",\n" + + " \"ref\": \"refs/heads/master\",\n" + + " \"checkout_sha\": \"67b1027ed1bc67edf03c02b59b3dd3d6c01b8475\",\n" + + " \"message\": null,\n" + + " \"user_id\": 260104,\n" + + " \"user_name\": \"Alexander Hofmann\",\n" + + " \"user_username\": \"alexhofmann\",\n" + + " \"user_email\": \"\",\n" + + " \"user_avatar\": \"https://secure.gravatar.com/avatar/294ece14a82bcccf2b99661a13a66070?s=80&d=identicon\",\n" + + " \"project_id\": 14185099,\n" + + " \"project\": {\n" + + " \"id\": 14185099,\n" + + " \"name\": \"TestPrivateRepo\",\n" + + " \"description\": \"\",\n" + + " \"web_url\": \"https://gitlab.com/alexhofmann/testprivaterepo\",\n" + + " \"avatar_url\": null,\n" + + " \"git_ssh_url\": \"git@gitlab.com:alexhofmann/testprivaterepo.git\",\n" + + " \"git_http_url\": \"https://gitlab.com/alexhofmann/testprivaterepo.git\",\n" + + " \"namespace\": \"Alexander Hofmann\",\n" + + " \"visibility_level\": 0,\n" + + " \"path_with_namespace\": \"alexhofmann/testprivaterepo\",\n" + + " \"default_branch\": \"master\",\n" + + " \"ci_config_path\": null,\n" + + " \"homepage\": \"https://gitlab.com/alexhofmann/testprivaterepo\",\n" + + " \"url\": \"git@gitlab.com:alexhofmann/testprivaterepo.git\",\n" + + " \"ssh_url\": \"git@gitlab.com:alexhofmann/testprivaterepo.git\",\n" + + " \"http_url\": \"https://gitlab.com/alexhofmann/testprivaterepo.git\"\n" + + " },\n" + + " \"commits\": [\n" + + " {\n" + + " \"id\": \"67b1027ed1bc67edf03c02b59b3dd3d6c01b8475\",\n" + + " \"message\": \"Add config.json\\n\",\n" + + " \"timestamp\": \"2019-09-06T22:27:34Z\",\n" + + " \"url\": \"https://gitlab.com/alexhofmann/testprivaterepo/commit/67b1027ed1bc67edf03c02b59b3dd3d6c01b8475\",\n" + + " \"author\": {\n" + + " \"name\": \"Alexander Hofmann\",\n" + + " \"email\": \"alexhofmann@gmail.com\"\n" + + " },\n" + + " \"added\": [\"config.json\"],\n" + + " \"modified\": [],\n" + + " \"removed\": []\n" + + " },\n" + + " {\n" + + " \"id\": \"02ed94097a0636908a4b56ad70a4b9a8e575995d\",\n" + + " \"message\": \"Initial commit\",\n" + + " \"timestamp\": \"2019-09-06T21:35:54Z\",\n" + + " \"url\": \"https://gitlab.com/alexhofmann/testprivaterepo/commit/02ed94097a0636908a4b56ad70a4b9a8e575995d\",\n" + + " \"author\": {\n" + + " \"name\": \"Alexander Hofmann\",\n" + + " \"email\": \"alexhofmann@gmail.com\"\n" + + " },\n" + + " \"added\": [\"README.md\"],\n" + + " \"modified\": [],\n" + + " \"removed\": []\n" + + " }\n" + + " ],\n" + + " \"total_commits_count\": 2,\n" + + " \"push_options\": {},\n" + + " \"repository\": {\n" + + " \"name\": \"TestPrivateRepo\",\n" + + " \"url\": \"git@gitlab.com:alexhofmann/testprivaterepo.git\",\n" + + " \"description\": \"\",\n" + + " \"homepage\": \"https://gitlab.com/alexhofmann/testprivaterepo\",\n" + + " \"git_http_url\": \"https://gitlab.com/alexhofmann/testprivaterepo.git\",\n" + + " \"git_ssh_url\": \"git@gitlab.com:alexhofmann/testprivaterepo.git\",\n" + + " \"visibility_level\": 0\n" + + " }\n" + + "}\n"; + + private String githubPayload = "{\n" + + " \"ref\": \"refs/heads/feature/new-admin-auth-group-#326\",\n" + + " \"before\": \"0000000000000000000000000000000000000000\",\n" + + " \"after\": \"a8e6a62ded8064bf295f659c0cc9dbe3b78ad443\",\n" + + " \"repository\": {\n" + + " \"id\": 179278769,\n" + + " \"node_id\": \"MDEwOlJlcG9zaXRvcnkxNzkyNzg3Njk=\",\n" + + " \"name\": \"Mock-Course\",\n" + + " \"full_name\": \"mp-access/Mock-Course\",\n" + + " \"private\": false,\n" + + " \"owner\": {\n" + + " \"name\": \"mp-access\",\n" + + " \"email\": null,\n" + + " \"login\": \"mp-access\",\n" + + " \"id\": 48990413,\n" + + " \"node_id\": \"MDEyOk9yZ2FuaXphdGlvbjQ4OTkwNDEz\",\n" + + " \"avatar_url\": \"https://avatars1.githubusercontent.com/u/48990413?v=4\",\n" + + " \"gravatar_id\": \"\",\n" + + " \"url\": \"https://api.github.com/users/mp-access\",\n" + + " \"html_url\": \"https://github.com/mp-access\",\n" + + " \"followers_url\": \"https://api.github.com/users/mp-access/followers\",\n" + + " \"following_url\": \"https://api.github.com/users/mp-access/following{/other_user}\",\n" + + " \"gists_url\": \"https://api.github.com/users/mp-access/gists{/gist_id}\",\n" + + " \"starred_url\": \"https://api.github.com/users/mp-access/starred{/owner}{/repo}\",\n" + + " \"subscriptions_url\": \"https://api.github.com/users/mp-access/subscriptions\",\n" + + " \"organizations_url\": \"https://api.github.com/users/mp-access/orgs\",\n" + + " \"repos_url\": \"https://api.github.com/users/mp-access/repos\",\n" + + " \"events_url\": \"https://api.github.com/users/mp-access/events{/privacy}\",\n" + + " \"received_events_url\": \"https://api.github.com/users/mp-access/received_events\",\n" + + " \"type\": \"Organization\",\n" + + " \"site_admin\": false\n" + + " },\n" + + " \"html_url\": \"https://github.com/mp-access/Mock-Course\",\n" + + " \"description\": \"Course file directory structure \",\n" + + " \"fork\": false,\n" + + " \"url\": \"https://github.com/mp-access/Mock-Course\",\n" + + " \"forks_url\": \"https://api.github.com/repos/mp-access/Mock-Course/forks\",\n" + + " \"keys_url\": \"https://api.github.com/repos/mp-access/Mock-Course/keys{/key_id}\",\n" + + " \"collaborators_url\": \"https://api.github.com/repos/mp-access/Mock-Course/collaborators{/collaborator}\",\n" + + " \"teams_url\": \"https://api.github.com/repos/mp-access/Mock-Course/teams\",\n" + + " \"hooks_url\": \"https://api.github.com/repos/mp-access/Mock-Course/hooks\",\n" + + " \"issue_events_url\": \"https://api.github.com/repos/mp-access/Mock-Course/issues/events{/number}\",\n" + + " \"events_url\": \"https://api.github.com/repos/mp-access/Mock-Course/events\",\n" + + " \"assignees_url\": \"https://api.github.com/repos/mp-access/Mock-Course/assignees{/user}\",\n" + + " \"branches_url\": \"https://api.github.com/repos/mp-access/Mock-Course/branches{/branch}\",\n" + + " \"tags_url\": \"https://api.github.com/repos/mp-access/Mock-Course/tags\",\n" + + " \"blobs_url\": \"https://api.github.com/repos/mp-access/Mock-Course/git/blobs{/sha}\",\n" + + " \"git_tags_url\": \"https://api.github.com/repos/mp-access/Mock-Course/git/tags{/sha}\",\n" + + " \"git_refs_url\": \"https://api.github.com/repos/mp-access/Mock-Course/git/refs{/sha}\",\n" + + " \"trees_url\": \"https://api.github.com/repos/mp-access/Mock-Course/git/trees{/sha}\",\n" + + " \"statuses_url\": \"https://api.github.com/repos/mp-access/Mock-Course/statuses/{sha}\",\n" + + " \"languages_url\": \"https://api.github.com/repos/mp-access/Mock-Course/languages\",\n" + + " \"stargazers_url\": \"https://api.github.com/repos/mp-access/Mock-Course/stargazers\",\n" + + " \"contributors_url\": \"https://api.github.com/repos/mp-access/Mock-Course/contributors\",\n" + + " \"subscribers_url\": \"https://api.github.com/repos/mp-access/Mock-Course/subscribers\",\n" + + " \"subscription_url\": \"https://api.github.com/repos/mp-access/Mock-Course/subscription\",\n" + + " \"commits_url\": \"https://api.github.com/repos/mp-access/Mock-Course/commits{/sha}\",\n" + + " \"git_commits_url\": \"https://api.github.com/repos/mp-access/Mock-Course/git/commits{/sha}\",\n" + + " \"comments_url\": \"https://api.github.com/repos/mp-access/Mock-Course/comments{/number}\",\n" + + " \"issue_comment_url\": \"https://api.github.com/repos/mp-access/Mock-Course/issues/comments{/number}\",\n" + + " \"contents_url\": \"https://api.github.com/repos/mp-access/Mock-Course/contents/{+path}\",\n" + + " \"compare_url\": \"https://api.github.com/repos/mp-access/Mock-Course/compare/{base}...{head}\",\n" + + " \"merges_url\": \"https://api.github.com/repos/mp-access/Mock-Course/merges\",\n" + + " \"archive_url\": \"https://api.github.com/repos/mp-access/Mock-Course/{archive_format}{/ref}\",\n" + + " \"downloads_url\": \"https://api.github.com/repos/mp-access/Mock-Course/downloads\",\n" + + " \"issues_url\": \"https://api.github.com/repos/mp-access/Mock-Course/issues{/number}\",\n" + + " \"pulls_url\": \"https://api.github.com/repos/mp-access/Mock-Course/pulls{/number}\",\n" + + " \"milestones_url\": \"https://api.github.com/repos/mp-access/Mock-Course/milestones{/number}\",\n" + + " \"notifications_url\": \"https://api.github.com/repos/mp-access/Mock-Course/notifications{?since,all,participating}\",\n" + + " \"labels_url\": \"https://api.github.com/repos/mp-access/Mock-Course/labels{/name}\",\n" + + " \"releases_url\": \"https://api.github.com/repos/mp-access/Mock-Course/releases{/id}\",\n" + + " \"deployments_url\": \"https://api.github.com/repos/mp-access/Mock-Course/deployments\",\n" + + " \"created_at\": 1554292040,\n" + + " \"updated_at\": \"2019-09-28T16:03:29Z\",\n" + + " \"pushed_at\": 1570048010,\n" + + " \"git_url\": \"git://github.com/mp-access/Mock-Course.git\",\n" + + " \"ssh_url\": \"git@github.com:mp-access/Mock-Course.git\",\n" + + " \"clone_url\": \"https://github.com/mp-access/Mock-Course.git\",\n" + + " \"svn_url\": \"https://github.com/mp-access/Mock-Course\",\n" + + " \"homepage\": null,\n" + + " \"size\": 988,\n" + + " \"stargazers_count\": 0,\n" + + " \"watchers_count\": 0,\n" + + " \"language\": \"C\",\n" + + " \"has_issues\": true,\n" + + " \"has_projects\": true,\n" + + " \"has_downloads\": true,\n" + + " \"has_wiki\": true,\n" + + " \"has_pages\": false,\n" + + " \"forks_count\": 0,\n" + + " \"mirror_url\": null,\n" + + " \"archived\": false,\n" + + " \"disabled\": false,\n" + + " \"open_issues_count\": 0,\n" + + " \"license\": null,\n" + + " \"forks\": 0,\n" + + " \"open_issues\": 0,\n" + + " \"watchers\": 0,\n" + + " \"default_branch\": \"master\",\n" + + " \"stargazers\": 0,\n" + + " \"master_branch\": \"master\",\n" + + " \"organization\": \"mp-access\"\n" + + " },\n" + + " \"pusher\": {\n" + + " \"name\": \"mech-studi\",\n" + + " \"email\": \"32181052+mech-studi@users.noreply.github.com\"\n" + + " },\n" + + " \"organization\": {\n" + + " \"login\": \"mp-access\",\n" + + " \"id\": 48990413,\n" + + " \"node_id\": \"MDEyOk9yZ2FuaXphdGlvbjQ4OTkwNDEz\",\n" + + " \"url\": \"https://api.github.com/orgs/mp-access\",\n" + + " \"repos_url\": \"https://api.github.com/orgs/mp-access/repos\",\n" + + " \"events_url\": \"https://api.github.com/orgs/mp-access/events\",\n" + + " \"hooks_url\": \"https://api.github.com/orgs/mp-access/hooks\",\n" + + " \"issues_url\": \"https://api.github.com/orgs/mp-access/issues\",\n" + + " \"members_url\": \"https://api.github.com/orgs/mp-access/members{/member}\",\n" + + " \"public_members_url\": \"https://api.github.com/orgs/mp-access/public_members{/member}\",\n" + + " \"avatar_url\": \"https://avatars1.githubusercontent.com/u/48990413?v=4\",\n" + + " \"description\": null\n" + + " },\n" + + " \"sender\": {\n" + + " \"login\": \"mech-studi\",\n" + + " \"id\": 32181052,\n" + + " \"node_id\": \"MDQ6VXNlcjMyMTgxMDUy\",\n" + + " \"avatar_url\": \"https://avatars2.githubusercontent.com/u/32181052?v=4\",\n" + + " \"gravatar_id\": \"\",\n" + + " \"url\": \"https://api.github.com/users/mech-studi\",\n" + + " \"html_url\": \"https://github.com/mech-studi\",\n" + + " \"followers_url\": \"https://api.github.com/users/mech-studi/followers\",\n" + + " \"following_url\": \"https://api.github.com/users/mech-studi/following{/other_user}\",\n" + + " \"gists_url\": \"https://api.github.com/users/mech-studi/gists{/gist_id}\",\n" + + " \"starred_url\": \"https://api.github.com/users/mech-studi/starred{/owner}{/repo}\",\n" + + " \"subscriptions_url\": \"https://api.github.com/users/mech-studi/subscriptions\",\n" + + " \"organizations_url\": \"https://api.github.com/users/mech-studi/orgs\",\n" + + " \"repos_url\": \"https://api.github.com/users/mech-studi/repos\",\n" + + " \"events_url\": \"https://api.github.com/users/mech-studi/events{/privacy}\",\n" + + " \"received_events_url\": \"https://api.github.com/users/mech-studi/received_events\",\n" + + " \"type\": \"User\",\n" + + " \"site_admin\": false\n" + + " },\n" + + " \"created\": true,\n" + + " \"deleted\": false,\n" + + " \"forced\": false,\n" + + " \"base_ref\": \"refs/heads/master\",\n" + + " \"compare\": \"https://github.com/mp-access/Mock-Course/compare/feature/new-admin-auth-group-#326\",\n" + + " \"commits\": [],\n" + + " \"head_commit\": {\n" + + " \"id\": \"a8e6a62ded8064bf295f659c0cc9dbe3b78ad443\",\n" + + " \"tree_id\": \"3e090ad385e4dcca0442b71d6f3fd4c7a40b2a53\",\n" + + " \"distinct\": true,\n" + + " \"message\": \"Add absolute link to image\",\n" + + " \"timestamp\": \"2019-09-28T18:03:21+02:00\",\n" + + " \"url\": \"https://github.com/mp-access/Mock-Course/commit/a8e6a62ded8064bf295f659c0cc9dbe3b78ad443\",\n" + + " \"author\": {\n" + + " \"name\": \"Alexander Hofmann\",\n" + + " \"email\": \"alexhofmann@gmail.com\",\n" + + " \"username\": \"a-a-hofmann\"\n" + + " },\n" + + " \"committer\": {\n" + + " \"name\": \"Alexander Hofmann\",\n" + + " \"email\": \"alexhofmann@gmail.com\",\n" + + " \"username\": \"a-a-hofmann\"\n" + + " },\n" + + " \"added\": [],\n" + + " \"removed\": [],\n" + + " \"modified\": [\"assignment_01/exercise_01/description.md\"]\n" + + " }\n" + + "}\n"; + + @Test + public void parseGitlabPayload() throws IOException { + ObjectMapper mapper = new ObjectMapper(); + WebhooksController.WebhookPayload payload = new WebhooksController.WebhookPayload(mapper.readTree(gitlabPayload), true); + + JsonNode repository = payload.getRepository(); + Assertions.assertThat(repository).isNotNull(); + Assertions.assertThat(payload.getHtmlUrl()).isEqualTo("https://gitlab.com/alexhofmann/testprivaterepo"); + Assertions.assertThat(payload.getGitUrl()).isEqualTo("https://gitlab.com/alexhofmann/testprivaterepo.git"); + Assertions.assertThat(payload.getSshUrl()).isEqualTo("git@gitlab.com:alexhofmann/testprivaterepo.git"); + + Assertions.assertThat(payload.matchesCourseUrl("https://gitlab.com/alexhofmann/testprivaterepo")).isTrue(); + Assertions.assertThat(payload.matchesCourseUrl("https://gitlab.com/alexhofmann/testprivaterepo.git")).isTrue(); + Assertions.assertThat(payload.matchesCourseUrl("git@gitlab.com:alexhofmann/testprivaterepo.git")).isTrue(); + } + + @Test + public void parseGithubPayload() throws IOException { + ObjectMapper mapper = new ObjectMapper(); + WebhooksController.WebhookPayload payload = new WebhooksController.WebhookPayload(mapper.readTree(githubPayload), false); + + JsonNode repository = payload.getRepository(); + Assertions.assertThat(repository).isNotNull(); + Assertions.assertThat(payload.getHtmlUrl()).isEqualTo("https://github.com/mp-access/Mock-Course"); + Assertions.assertThat(payload.getGitUrl()).isEqualTo("https://github.com/mp-access/Mock-Course.git"); + Assertions.assertThat(payload.getSshUrl()).isEqualTo("git@github.com:mp-access/Mock-Course.git"); + + Assertions.assertThat(payload.matchesCourseUrl("https://github.com/mp-access/Mock-Course")).isTrue(); + Assertions.assertThat(payload.matchesCourseUrl("https://github.com/mp-access/Mock-Course.git")).isTrue(); + Assertions.assertThat(payload.matchesCourseUrl("git@github.com:mp-access/Mock-Course.git")).isTrue(); + } +} \ No newline at end of file diff --git a/src/test/java/ch/uzh/ifi/access/course/dao/CourseDAOTest.java b/src/test/java/ch/uzh/ifi/access/course/dao/CourseDAOTest.java index a724d331..6a2323c5 100644 --- a/src/test/java/ch/uzh/ifi/access/course/dao/CourseDAOTest.java +++ b/src/test/java/ch/uzh/ifi/access/course/dao/CourseDAOTest.java @@ -5,24 +5,38 @@ import ch.uzh.ifi.access.course.model.Assignment; import ch.uzh.ifi.access.course.model.Course; import ch.uzh.ifi.access.course.model.Exercise; +import ch.uzh.ifi.access.course.util.RepoCacher; import org.assertj.core.api.Assertions; import org.junit.Before; import org.junit.Test; +import org.mockito.Mock; +import org.mockito.Mockito; +import org.mockito.MockitoAnnotations; import org.springframework.context.ApplicationEventPublisher; import java.util.ArrayList; import java.util.List; import java.util.Map; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.when; + public class CourseDAOTest { private CourseDAO courseDAO; + @Mock + private RepoCacher repoCacher; + @Before - public void setUp() throws Exception { - ApplicationEventPublisher noOpPublisher = (event) -> {}; + public void setUp() { + MockitoAnnotations.initMocks(this); + + ApplicationEventPublisher noOpPublisher = (event) -> { + }; BreakingChangeNotifier breakingChangeNotifier = new BreakingChangeNotifier(noOpPublisher); - courseDAO = new CourseDAO(breakingChangeNotifier); + + courseDAO = new CourseDAO(breakingChangeNotifier, repoCacher); } @Test @@ -221,4 +235,49 @@ public void addNewAssignmentShouldNotBeBreakingChange() { Assertions.assertThat(breakingChanges).size().isEqualTo(0); } + + @Test + public void rollbackNoGitUrlSetTest() { + Course before = TestObjectFactory.createCourse("title"); + Course after = TestObjectFactory.createCourse(before.getTitle()); + Assignment assignmentBefore = TestObjectFactory.createAssignment("assignment"); + Assignment assignmentAfter = TestObjectFactory.createAssignment("assignment"); + Exercise exerciseBefore1 = TestObjectFactory.createCodeExercise(""); + Exercise exerciseAfter1 = TestObjectFactory.createTextExercise(""); + exerciseBefore1.setIndex(1); + exerciseAfter1.setIndex(2); + exerciseBefore1.setPublic_files(List.of(TestObjectFactory.createVirtualFile("name", "py", false))); + + Exercise exerciseBefore2 = TestObjectFactory.createTextExercise(""); + Exercise exerciseAfter2 = TestObjectFactory.createTextExercise(""); + exerciseBefore2.setIndex(3); + exerciseAfter2.setIndex(exerciseBefore2.getIndex()); + + before.addAssignment(assignmentBefore); + assignmentBefore.addExercise(exerciseBefore1); + assignmentBefore.addExercise(exerciseBefore2); + + after.addAssignment(assignmentAfter); + assignmentAfter.addExercise(exerciseAfter1); + assignmentAfter.addExercise(exerciseAfter2); + + Course updated = courseDAO.updateCourse(before); + Assertions.assertThat(updated).isNull(); + } + + @Test + public void rollbackDuringUpdateTest() { + String oldTitle = "title"; + String newTitle = "New title"; + Course before = TestObjectFactory.createCourse(oldTitle); + Course after = Mockito.spy(TestObjectFactory.createCourse(newTitle)); + + when(repoCacher.retrieveCourseData(any(String[].class))).thenReturn(List.of(after)); + when(after.getIndexedItems()).thenThrow(new UnsupportedOperationException()); + + Course updated = courseDAO.updateCourse(before); + // Should have rolled back -> title should still be oldTitle + Assertions.assertThat(updated).isNull(); + Assertions.assertThat(before.getTitle()).isEqualTo(oldTitle); + } } \ No newline at end of file diff --git a/src/test/java/ch/uzh/ifi/access/course/service/CourseServiceTest.java b/src/test/java/ch/uzh/ifi/access/course/service/CourseServiceTest.java index 969553ee..0f57f5ab 100644 --- a/src/test/java/ch/uzh/ifi/access/course/service/CourseServiceTest.java +++ b/src/test/java/ch/uzh/ifi/access/course/service/CourseServiceTest.java @@ -13,7 +13,7 @@ import org.mockito.MockitoAnnotations; import org.springframework.core.io.FileSystemResource; -import java.time.LocalDateTime; +import java.time.ZonedDateTime; import java.util.Arrays; import java.util.List; import java.util.Optional; @@ -63,8 +63,8 @@ public void getFileCheckingPrivilegesHasNoAccessToSolutions() { Exercise exercise = TestObjectFactory.createCodeExercise(""); course.addAssignment(assignment); assignment.addExercise(exercise); - assignment.setPublishDate(LocalDateTime.now().minusYears(1)); - assignment.setDueDate(LocalDateTime.now().plusYears(1)); + assignment.setPublishDate(ZonedDateTime.now().minusYears(1)); + assignment.setDueDate(ZonedDateTime.now().plusYears(1)); VirtualFile vFile1 = TestObjectFactory.createVirtualFile("name1", "py", false); VirtualFile vFile2 = TestObjectFactory.createVirtualFile("name2", "py", false); @@ -99,8 +99,8 @@ public void getFileCheckingPrivilegesStudentHasAccessToSolutionsAfterDueDate() { Exercise exercise = TestObjectFactory.createCodeExercise(""); course.addAssignment(assignment); assignment.addExercise(exercise); - assignment.setPublishDate(LocalDateTime.now().minusYears(1)); - assignment.setDueDate(LocalDateTime.now().minusDays(1)); + assignment.setPublishDate(ZonedDateTime.now().minusYears(1)); + assignment.setDueDate(ZonedDateTime.now().minusDays(1)); VirtualFile vFile1 = TestObjectFactory.createVirtualFile("name1", "py", false); VirtualFile vFile2 = TestObjectFactory.createVirtualFile("name2", "py", false); @@ -134,8 +134,8 @@ public void getFileCheckingPrivilegesAdminAccess() { Course course = TestObjectFactory.createCourseWithOneAssignmentAndOneExercise("Course", "Assignment", "exercise question"); Assignment assignment = course.getAssignments().get(0); Exercise exercise = assignment.getExercises().get(0); - assignment.setPublishDate(LocalDateTime.now().minusYears(1)); - assignment.setDueDate(LocalDateTime.now().plusYears(1)); + assignment.setPublishDate(ZonedDateTime.now().minusYears(1)); + assignment.setDueDate(ZonedDateTime.now().plusYears(1)); VirtualFile vFile1 = TestObjectFactory.createVirtualFile("name1", "py", false); VirtualFile vFile2 = TestObjectFactory.createVirtualFile("name2", "py", false); diff --git a/src/test/java/ch/uzh/ifi/access/course/util/CoursePermissionEnforcerTest.java b/src/test/java/ch/uzh/ifi/access/course/util/CoursePermissionEnforcerTest.java index 0229c492..0eaed477 100644 --- a/src/test/java/ch/uzh/ifi/access/course/util/CoursePermissionEnforcerTest.java +++ b/src/test/java/ch/uzh/ifi/access/course/util/CoursePermissionEnforcerTest.java @@ -9,7 +9,7 @@ import org.assertj.core.api.Assertions; import org.junit.Test; -import java.time.LocalDateTime; +import java.time.ZonedDateTime; import java.util.Optional; import java.util.Set; @@ -69,11 +69,17 @@ public void shouldAccessPublishedAssignmentAdmin() { } private Assignment publishedAssignment() { - return Assignment.builder().publishDate(LocalDateTime.now().minusYears(1)).build(); + Course course = TestObjectFactory.createCourse(""); + Assignment assignment = Assignment.builder().publishDate(ZonedDateTime.now().minusYears(1)).build(); + assignment.setCourse(course); + return assignment; } private Assignment notYetPublishedAssignment() { - return Assignment.builder().publishDate(LocalDateTime.now().plusYears(1)).build(); + Course course = TestObjectFactory.createCourse(""); + Assignment assignment = Assignment.builder().publishDate(ZonedDateTime.now().plusYears(1)).build(); + assignment.setCourse(course); + return assignment; } private GrantedCourseAccess adminAccess() { diff --git a/src/test/java/ch/uzh/ifi/access/student/controller/SubmissionControllerTest.java b/src/test/java/ch/uzh/ifi/access/student/controller/SubmissionControllerTest.java index 7a5c97cf..d2de65b4 100644 --- a/src/test/java/ch/uzh/ifi/access/student/controller/SubmissionControllerTest.java +++ b/src/test/java/ch/uzh/ifi/access/student/controller/SubmissionControllerTest.java @@ -29,7 +29,7 @@ import org.springframework.test.web.servlet.setup.MockMvcBuilders; import org.springframework.web.context.WebApplicationContext; -import java.time.LocalDateTime; +import java.time.ZonedDateTime; import java.util.Optional; import java.util.Set; import java.util.UUID; @@ -86,16 +86,16 @@ public void setUp() { Assignment assignment = course.getAssignments().get(0); Exercise exercise = assignment.getExercises().get(0); exerciseIdAlreadyPublished = exercise.getId(); - assignment.setPublishDate(LocalDateTime.now().minusDays(1)); - assignment.setDueDate(LocalDateTime.now().plusDays(7)); + assignment.setPublishDate(ZonedDateTime.now().minusDays(1)); + assignment.setDueDate(ZonedDateTime.now().plusDays(7)); when(courseDAO.selectExerciseById(exerciseIdAlreadyPublished)).thenReturn(Optional.of(exercise)); assignment = TestObjectFactory.createAssignment("asdf"); exercise = TestObjectFactory.createCodeExercise("adfsf"); assignment.addExercise(exercise); course.addAssignment(assignment); - assignment.setPublishDate(LocalDateTime.now().plusDays(1)); - assignment.setDueDate(LocalDateTime.now().plusDays(7)); + assignment.setPublishDate(ZonedDateTime.now().plusDays(1)); + assignment.setDueDate(ZonedDateTime.now().plusDays(7)); exerciseIdNotYetPublished = exercise.getId(); when(courseDAO.selectExerciseById(exerciseIdNotYetPublished)).thenReturn(Optional.of(exercise)); diff --git a/src/test/java/ch/uzh/ifi/access/student/evaluation/evaluator/CodeEvaluatorTest.java b/src/test/java/ch/uzh/ifi/access/student/evaluation/evaluator/CodeEvaluatorTest.java index 23debc80..5725a4ff 100644 --- a/src/test/java/ch/uzh/ifi/access/student/evaluation/evaluator/CodeEvaluatorTest.java +++ b/src/test/java/ch/uzh/ifi/access/student/evaluation/evaluator/CodeEvaluatorTest.java @@ -1,229 +1,450 @@ package ch.uzh.ifi.access.student.evaluation.evaluator; +import static ch.uzh.ifi.access.student.evaluation.evaluator.CodeEvaluator.TEST_FAILED_WITHOUT_HINTS; +import static com.spotify.docker.client.shaded.com.google.common.collect.Lists.newArrayList; +import static org.junit.Assert.assertEquals; + +import java.util.List; + +import org.junit.Before; +import org.junit.Test; + import ch.uzh.ifi.access.course.model.Exercise; import ch.uzh.ifi.access.course.model.ExerciseType; import ch.uzh.ifi.access.student.model.CodeSubmission; import ch.uzh.ifi.access.student.model.ExecResult; import ch.uzh.ifi.access.student.model.SubmissionEvaluation; -import org.junit.Assert; -import org.junit.Before; -import org.junit.Test; public class CodeEvaluatorTest { - private Exercise exercise; - private String errorTestLog; - private String failsTestLog; - private String hintsLog; - private String hints2; - private String okTestLog; - - @Before - public void setUp() { - exercise = Exercise.builder() - .id("e1") - .maxScore(10) - .type(ExerciseType.code).build(); - - errorTestLog = "runner/test/* (unittest.loader._FailedTest) ... ERROR\n" + - "\n" + - "======================================================================\n" + - "ERROR: runner/test/* (unittest.loader._FailedTest)\n" + - "----------------------------------------------------------------------\n" + - "ImportError: Failed to import test module: runner/test/*\n" + - "Traceback (most recent call last):\n" + - " File \"/usr/lib/python3.7/unittest/loader.py\", line 154, in loadTestsFromName\n" + - " module = __import__(module_name)\n" + - "ModuleNotFoundError: No module named 'runner/test/*'\n" + - "\n" + - "\n" + - "----------------------------------------------------------------------\n" + - "Ran 1 test in 0.001s\n" + - "\n" + - "FAILED (errors=1)\n"; - - failsTestLog = "test_isupper (test.TestStringMethods1.TestStringMethods1) ... ok\n" + - "test_split (test.TestStringMethods1.TestStringMethods1) ... ok\n" + - "test_upper (test.TestStringMethods1.TestStringMethods1) ... FAIL\n" + - "test_isupper (test.TestStringMethods2.TestStringMethods2) ... ok\n" + - "test_split (test.TestStringMethods2.TestStringMethods2) ... ok\n" + - "test_upper (test.TestStringMethods2.TestStringMethods2) ... ok\n" + - "\n" + - "======================================================================\n" + - "FAIL: test_upper (test.TestStringMethods1.TestStringMethods1)\n" + - "----------------------------------------------------------------------\n" + - "Traceback (most recent call last):\n" + - " File \"/home/mangoman/Workspace/MasterProject/CourseService/runner/test/TestStringMethods1.py\", line 6, in test_upper\n" + - " self.assertEqual('FOO'.upper(), 'Foo')\n" + - "AssertionError: 'FOO' != 'Foo'\n" + - "- FOO\n" + - "+ Foo\n" + - "\n" + - "\n" + - "----------------------------------------------------------------------\n" + - "Ran 6 tests in 0.001s\n" + - "\n" + - "FAILED (failures=1)"; - - hintsLog = "test_isupper (test.TestStringMethods1.TestStringMethods1) ... ok\n" + - "test_split (test.TestStringMethods1.TestStringMethods1) ... ok\n" + - "test_upper (test.TestStringMethods1.TestStringMethods1) ... FAIL\n" + - "test_isupper (test.TestStringMethods2.TestStringMethods2) ... ok\n" + - "\n" + - "======================================================================\n" + - "FAIL: test_upper (test.TestStringMethods1.TestStringMethods1)\n" + - "----------------------------------------------------------------------\n" + - "Traceback (most recent call last):\n" + - " File \"/home/mangoman/Workspace/MasterProject/CourseService/runner/test/TestStringMethods1.py\", line 6, in test_upper\n" + - " self.assertEqual('FOO'.upper(), 'Foo')\n" + - "AssertionError: 'FOO' != 'Foo'@@Erster Hinweis@@\n"+ - "- FOO\n" + - "+ Foo\n" + - "\n" + - "----------------------------------------------------------------------\n" + - "Ran 6 tests in 0.001s\n" + - "\n" + - "FAILED (failures=1)"; - - hints2= "test_doghouse (testSuite.Task2B) ... FAIL\n" + - "\n" + - "======================================================================\n" + - "FAIL: test_doghouse (testSuite.Task2B)\n" + - "----------------------------------------------------------------------\n" + - "Traceback (most recent call last):\n" + - " File \"/home/mangoman/Workspace/MasterProject/CourseStructure/assignment_01/exercise_04/private/testSuite.py\", line 29, in test_doghouse\n" + - " self.assertTrue(hasattr(self.exercise, \"dog\"), \"@@You must declare '{}'@@\".format(\"dog\"))\n" + - "AssertionError: False is not true : @@You must declare 'dog'@@\n" + - "\n" + - "----------------------------------------------------------------------\n" + - "Ran 1 test in 0.001s\n" + - "\n" + - "FAILED (failures=1)"; - - - okTestLog = "test_isupper (test.TestStringMethods1.TestStringMethods1) ... ok\n" + - "test_split (test.TestStringMethods1.TestStringMethods1) ... ok\n" + - "test_upper (test.TestStringMethods1.TestStringMethods1) ... ok\n" + - "test_isupper (test.TestStringMethods2.TestStringMethods2) ... ok\n" + - "test_split (test.TestStringMethods2.TestStringMethods2) ... ok\n" + - "test_upper (test.TestStringMethods2.TestStringMethods2) ... ok\n" + - "\n" + - "----------------------------------------------------------------------\n" + - "Ran 6 tests in 0.001s\n" + - "\n" + - "OK\n"; - } - - @Test - public void execWithErrors() { - ExecResult console = new ExecResult(); - console.setEvalLog(errorTestLog); - - CodeSubmission sub = CodeSubmission.builder() - .exerciseId(exercise.getId()) - .console(console) - .build(); - - SubmissionEvaluation grade = new CodeEvaluator().evaluate(sub, exercise); - - Assert.assertEquals(0.0, grade.getScore(), 0.25); - } - - @Test - public void execWithFailures() { - ExecResult console = new ExecResult(); - console.setEvalLog(failsTestLog); - - CodeSubmission sub = CodeSubmission.builder() - .exerciseId(exercise.getId()) - .console(console) - .build(); - - SubmissionEvaluation grade = new CodeEvaluator().evaluate(sub, exercise); - - Assert.assertEquals(5, grade.getPoints().getCorrect()); - Assert.assertEquals(8.25, grade.getScore(), 0.25); - } - - @Test - public void execOK() { - ExecResult console = new ExecResult(); - console.setEvalLog(okTestLog); - - CodeSubmission sub = CodeSubmission.builder() - .exerciseId(exercise.getId()) - .console(console) - .build(); - - SubmissionEvaluation grade = new CodeEvaluator().evaluate(sub, exercise); - - Assert.assertEquals(6, grade.getPoints().getCorrect()); - Assert.assertEquals(10.0, grade.getScore(), 0.25); - } - - @Test - public void parseHints() { - ExecResult console = new ExecResult(); - console.setEvalLog(hintsLog); - - CodeSubmission sub = CodeSubmission.builder() - .exerciseId(exercise.getId()) - .console(console) - .build(); - - SubmissionEvaluation grade = new CodeEvaluator().evaluate(sub, exercise); - - Assert.assertEquals(5, grade.getPoints().getCorrect()); - Assert.assertEquals(1, grade.getHints().size()); - } - - @Test - public void parseHints_OnlyAssertionErrorMsg() { - ExecResult console = new ExecResult(); - console.setEvalLog(hints2); - - CodeSubmission sub = CodeSubmission.builder() - .exerciseId(exercise.getId()) - .console(console) - .build(); - - SubmissionEvaluation grade = new CodeEvaluator().evaluate(sub, exercise); - - Assert.assertEquals(1, grade.getHints().size()); - Assert.assertEquals("You must declare 'dog'", grade.getHints().get(0)); - - } - - @Test - public void outOfMemoryHasEmptyEvalLog() { - ExecResult console = new ExecResult(); - console.setEvalLog(""); - - CodeSubmission sub = CodeSubmission.builder() - .exerciseId(exercise.getId()) - .console(console) - .build(); - - SubmissionEvaluation grade = new CodeEvaluator().evaluate(sub, exercise); - - Assert.assertEquals(0, grade.getPoints().getCorrect()); - Assert.assertEquals(exercise.getMaxScore(), grade.getMaxScore()); - } - - @Test - public void nonsenseLog() { - ExecResult console = new ExecResult(); - console.setEvalLog("asdfklajd blkasjd falsdjf \n aljöflkjsd fasdf \n asdfjkl adsflkja sdf"); - - CodeSubmission sub = CodeSubmission.builder() - .exerciseId(exercise.getId()) - .console(console) - .build(); - - SubmissionEvaluation grade = new CodeEvaluator().evaluate(sub, exercise); - - Assert.assertEquals(0, grade.getPoints().getCorrect()); - Assert.assertEquals(0.0, grade.getScore(), 0.1); - Assert.assertEquals(0, grade.getHints().size()); - } + private Exercise exercise; + + @Before + public void setUp() { + exercise = Exercise.builder().id("e1").maxScore(10).type(ExerciseType.code).build(); + + } + + private SubmissionEvaluation evaluate(String output) { + ExecResult console = new ExecResult(); + console.setEvalLog(output); + + CodeSubmission sub = CodeSubmission.builder().exerciseId(exercise.getId()).console(console).build(); + + SubmissionEvaluation grade = new CodeEvaluator().evaluate(sub, exercise); + return grade; + } + + private static List extractAllHints(String output) { + List hints = new CodeEvaluator().parseHintsFromLog(output); + return hints; + } + + private static List hints(String... hints) { + return newArrayList(hints); + } + + @Test + public void execWithErrors() { + SubmissionEvaluation grade = evaluate("runner/test/* (unittest.loader._FailedTest) ... ERROR\n" + "\n" + + "======================================================================\n" + + "ERROR: runner/test/* (unittest.loader._FailedTest)\n" + + "----------------------------------------------------------------------\n" + + "ImportError: Failed to import test module: runner/test/*\n" + "Traceback (most recent call last):\n" + + " File \"/usr/lib/python3.7/unittest/loader.py\", line 154, in loadTestsFromName\n" + + " module = __import__(module_name)\n" + "ModuleNotFoundError: No module named 'runner/test/*'\n" + + "\n" + "\n" + "----------------------------------------------------------------------\n" + + "Ran 1 test in 0.001s\n" + "\n" + "FAILED (errors=1)\n"); + + assertEquals(0.0, grade.getScore(), 0.25); + } + + @Test + public void execWithFailures() { + SubmissionEvaluation grade = evaluate("test_isupper (test.TestStringMethods1.TestStringMethods1) ... ok\n" + + "test_split (test.TestStringMethods1.TestStringMethods1) ... ok\n" + + "test_upper (test.TestStringMethods1.TestStringMethods1) ... FAIL\n" + + "test_isupper (test.TestStringMethods2.TestStringMethods2) ... ok\n" + + "test_split (test.TestStringMethods2.TestStringMethods2) ... ok\n" + + "test_upper (test.TestStringMethods2.TestStringMethods2) ... ok\n" + "\n" + + "======================================================================\n" + + "FAIL: test_upper (test.TestStringMethods1.TestStringMethods1)\n" + + "----------------------------------------------------------------------\n" + + "Traceback (most recent call last):\n" + + " File \"/home/mangoman/Workspace/MasterProject/CourseService/runner/test/TestStringMethods1.py\", line 6, in test_upper\n" + + " self.assertEqual('FOO'.upper(), 'Foo')\n" + "AssertionError: 'FOO' != 'Foo'\n" + "- FOO\n" + + "+ Foo\n" + "\n" + "\n" + "----------------------------------------------------------------------\n" + + "Ran 6 tests in 0.001s\n" + "\n" + "FAILED (failures=1)"); + + assertEquals(5, grade.getPoints().getCorrect()); + assertEquals(8.25, grade.getScore(), 0.25); + assertEquals(hints(TEST_FAILED_WITHOUT_HINTS), grade.getHints()); + } + + @Test + public void execOK() { + SubmissionEvaluation grade = evaluate("test_isupper (test.TestStringMethods1.TestStringMethods1) ... ok\n" + + "test_split (test.TestStringMethods1.TestStringMethods1) ... ok\n" + + "test_upper (test.TestStringMethods1.TestStringMethods1) ... ok\n" + + "test_isupper (test.TestStringMethods2.TestStringMethods2) ... ok\n" + + "test_split (test.TestStringMethods2.TestStringMethods2) ... ok\n" + + "test_upper (test.TestStringMethods2.TestStringMethods2) ... ok\n" + "\n" + + "----------------------------------------------------------------------\n" + "Ran 6 tests in 0.001s\n" + + "\n" + "OK\n"); + + assertEquals(6, grade.getPoints().getCorrect()); + assertEquals(10.0, grade.getScore(), 0.25); + } + + @Test + public void parseHints() { + SubmissionEvaluation grade = evaluate("test_isupper (test.TestStringMethods1.TestStringMethods1) ... ok\n" + + "test_split (test.TestStringMethods1.TestStringMethods1) ... ok\n" + + "test_upper (test.TestStringMethods1.TestStringMethods1) ... FAIL\n" + + "test_isupper (test.TestStringMethods2.TestStringMethods2) ... ok\n" + "\n" + + "======================================================================\n" + + "FAIL: test_upper (test.TestStringMethods1.TestStringMethods1)\n" + + "----------------------------------------------------------------------\n" + + "Traceback (most recent call last):\n" + + " File \"/home/mangoman/Workspace/MasterProject/CourseService/runner/test/TestStringMethods1.py\", line 6, in test_upper\n" + + " self.assertEqual('FOO'.upper(), 'Foo')\n" + "AssertionError: 'FOO' != 'Foo'@@Erster Hinweis@@\n" + + "- FOO\n" + "+ Foo\n" + "\n" + + "----------------------------------------------------------------------\n" + "Ran 6 tests in 0.001s\n" + + "\n" + "FAILED (failures=1)"); + + assertEquals(5, grade.getPoints().getCorrect()); + assertEquals(hints("Erster Hinweis"), grade.getHints()); + } + + @Test + public void parseHintsOnlyAssertionErrorMsg() { + SubmissionEvaluation grade = evaluate("test_doghouse (testSuite.Task2B) ... FAIL\n" + "\n" + + "======================================================================\n" + + "FAIL: test_doghouse (testSuite.Task2B)\n" + + "----------------------------------------------------------------------\n" + + "Traceback (most recent call last):\n" + + " File \"/home/mangoman/Workspace/MasterProject/CourseStructure/assignment_01/exercise_04/private/testSuite.py\", line 29, in test_doghouse\n" + + " self.assertTrue(hasattr(self.exercise, \"dog\"), \"@@You must declare '{}'@@\".format(\"dog\"))\n" + + "AssertionError: False is not true : @@You must declare 'dog'@@\n" + "\n" + + "----------------------------------------------------------------------\n" + "Ran 1 test in 0.001s\n" + + "\n" + "FAILED (failures=1)"); + + assertEquals(hints("You must declare 'dog'"), grade.getHints()); + + } + + @Test + public void outOfMemoryHasEmptyEvalLog() { + SubmissionEvaluation grade = evaluate(""); + + assertEquals(0, grade.getPoints().getCorrect()); + assertEquals(exercise.getMaxScore(), grade.getMaxScore()); + } + + @Test + public void hintsNotParsed() { + String output = "........FF..\n" + "======================================================================\n" + + "FAIL: test_case6 (tests.PrivateTestSuite)\n" + + "----------------------------------------------------------------------\n" + + "Traceback (most recent call last):\n" + + " File \"/Users/alexhofmann/Downloads/exercise_03/private/tests.py\", line 53, in test_case6\n" + + " self._assert(\"abzAZ!\", 27, \"bcaBA!\", None)\n" + + " File \"/Users/alexhofmann/Downloads/exercise_03/private/tests.py\", line 32, in _assert\n" + + " self.assertEqual(expected, actual, msg)\n" + "AssertionError: 'bcaBA!' != 'abzAZ!'\n" + + "- bcaBA!\n" + "+ abzAZ!\n" + " : @@ROT27 of 'abzAZ!' should be 'bcaBA!'\n" + + ", but was 'abzAZ!'.@@\n" + "\n" + + "======================================================================\n" + + "FAIL: test_case7 (tests.PrivateTestSuite)\n" + + "----------------------------------------------------------------------\n" + + "Traceback (most recent call last):\n" + + " File \"/Users/alexhofmann/Downloads/exercise_03/private/tests.py\", line 56, in test_case7\n" + + " self._assert(\"abzAZ!\", -27, \"zayZY!\", None)\n" + + " File \"/Users/alexhofmann/Downloads/exercise_03/private/tests.py\", line 32, in _assert\n" + + " self.assertEqual(expected, actual, msg)\n" + "AssertionError: 'zayZY!' != 'abzAZ!'\n" + + "- zayZY!\n" + "+ abzAZ!\n" + " : @@ROT-27 of 'abzAZ!' should be 'zayZY!'\n" + + ", but was 'abzAZ!'.@@\n" + "\n" + + "----------------------------------------------------------------------\n" + + "Ran 10 tests in 0.016s\n" + "\n" + "FAILED (failures=2)\n"; + + List actuals = extractAllHints(output); + List expecteds = hints("ROT27 of 'abzAZ!' should be 'bcaBA!'\n, but was 'abzAZ!'.", + "ROT-27 of 'abzAZ!' should be 'zayZY!'\n, but was 'abzAZ!'."); + assertEquals(expecteds, actuals); + + actuals = evaluate(output).getHints(); + expecteds = hints("ROT27 of 'abzAZ!' should be 'bcaBA!'\n, but was 'abzAZ!'."); + assertEquals(expecteds, actuals); + } + + @Test + public void failLogHint() { + List actuals = extractAllHints( + "F\n" + "======================================================================\n" + + "FAIL: testFail (tests.PrivateTestSuite)\n" + + "----------------------------------------------------------------------\n" + + "Traceback (most recent call last):\n" + + " File \"/Users/alexhofmann/Downloads/exercise_03/private/tests.py\", line 84, in testFail\n" + + " self.fail(m)\n" + + "AssertionError: @@After the encoding, some letters have become non-letters.@@\n" + "\n" + + "----------------------------------------------------------------------\n" + + "Ran 1 test in 0.001s\n" + "\n" + "FAILED (failures=1)\n"); + List expecteds = hints("After the encoding, some letters have become non-letters."); + assertEquals(expecteds, actuals); + } + + @Test + public void assertCountHint() { + List actuals = extractAllHints("FF\n" + + "======================================================================\n" + + "FAIL: testFail (tests.PrivateTestSuite)\n" + + "----------------------------------------------------------------------\n" + + "Traceback (most recent call last):\n" + + " File \"/Users/alexhofmann/Downloads/exercise_03/private/tests.py\", line 84, in testFail\n" + + " self.assertCountEqual([], [1], m)\n" + "AssertionError: Element counts were not equal:\n" + + "First has 0, Second has 1: 1 : @@blablabla@@\n" + "\n" + + "======================================================================\n" + + "FAIL: testFail2 (tests.PrivateTestSuite)\n" + + "----------------------------------------------------------------------\n" + + "Traceback (most recent call last):\n" + + " File \"/Users/alexhofmann/Downloads/exercise_03/private/tests.py\", line 88, in testFail2\n" + + " self.assertCountEqual([], [1], m)\n" + "AssertionError: Element counts were not equal:\n" + + "First has 0, Second has 1: 1 : @@blablablablablabla@@\n" + "\n" + + "----------------------------------------------------------------------\n" + "Ran 2 tests in 0.001s\n" + + "\n" + "FAILED (failures=2)\n"); + List expecteds = hints("blablabla", "blablablablablabla"); + assertEquals(expecteds, actuals); + } + + @Test + public void assertListEqualHint() { + List actuals = extractAllHints("FF\n" + + "======================================================================\n" + + "FAIL: testFail (tests.PrivateTestSuite)\n" + + "----------------------------------------------------------------------\n" + + "Traceback (most recent call last):\n" + + " File \"/Users/alexhofmann/Downloads/exercise_03/private/tests.py\", line 84, in testFail\n" + + " self.assertListEqual([], [1], m)\n" + "AssertionError: Lists differ: [] != [1]\n" + "\n" + + "Second list contains 1 additional elements.\n" + "First extra element 0:\n" + "1\n" + "\n" + "- []\n" + + "+ [1]\n" + "? +\n" + " : @@blablabla@@\n" + "\n" + + "======================================================================\n" + + "FAIL: testFail2 (tests.PrivateTestSuite)\n" + + "----------------------------------------------------------------------\n" + + "Traceback (most recent call last):\n" + + " File \"/Users/alexhofmann/Downloads/exercise_03/private/tests.py\", line 88, in testFail2\n" + + " self.assertListEqual([], [1], m)\n" + "AssertionError: Lists differ: [] != [1]\n" + "\n" + + "Second list contains 1 additional elements.\n" + "First extra element 0:\n" + "1\n" + "\n" + "- []\n" + + "+ [1]\n" + "? +\n" + " : @@blablablablablabla@@\n" + "\n" + + "----------------------------------------------------------------------\n" + "Ran 2 tests in 0.001s\n" + + "\n" + "FAILED (failures=2)\n"); + List expecteds = hints("blablabla", "blablablablablabla"); + assertEquals(expecteds, actuals); + } + + @Test + public void errorDueToIndentation() { + List actuals = extractAllHints("E\n" + + "======================================================================\n" + + "ERROR: tests (unittest.loader._FailedTest)\n" + + "----------------------------------------------------------------------\n" + + "ImportError: Failed to import test module: tests\n" + "Traceback (most recent call last):\n" + + " File \"//anaconda3/lib/python3.7/unittest/loader.py\", line 436, in _find_test_path\n" + + " module = self._get_module_from_name(name)\n" + + " File \"//anaconda3/lib/python3.7/unittest/loader.py\", line 377, in _get_module_from_name\n" + + " __import__(name)\n" + + " File \"/Users/alexhofmann/Downloads/exercise_03/private/tests.py\", line 17, in \n" + + " from public import script\n" + + " File \"/Users/alexhofmann/Downloads/exercise_03/public/script.py\", line 14\n" + + " from string import ascii_lowercase as lc, ascii_uppercase as uc\n" + " ^\n" + + "IndentationError: expected an indented block\n" + "\n" + "\n" + + "----------------------------------------------------------------------\n" + "Ran 1 test in 0.000s\n" + + "\n" + "FAILED (errors=1)\n"); + List expecteds = hints("Error during execution: IndentationError"); + assertEquals(expecteds, actuals); + } + + @Test + public void extractionWorksWithMultipleHints() { + List actuals = extractAllHints("FF\n" + + "======================================================================\n" + + "FAIL: test_1 (private.tests.PrivateTestSuite)\n" + + "----------------------------------------------------------------------\n" + + "Traceback (most recent call last):\n" + + " File \"/Users/seb/versioned/access/access-playground-staging-tutors/assignment_05/exercise_01_merge_lists/private/tests.py\", line 52, in test_1\n" + + " self._assert([], [], [], \"empty\")\n" + + " File \"/Users/seb/versioned/access/access-playground-staging-tutors/assignment_05/exercise_01_merge_lists/private/tests.py\", line 41, in _assert\n" + + " self._assertType(list, actual)\n" + + " File \"/Users/seb/versioned/access/access-playground-staging-tutors/assignment_05/exercise_01_merge_lists/private/tests.py\", line 23, in _assertType\n" + + " self.fail(m)\n" + "AssertionError: @@First hint@@\n" + "\n" + + "======================================================================\n" + + "FAIL: test_2 (private.tests.PrivateTestSuite)\n" + + "----------------------------------------------------------------------\n" + + "Traceback (most recent call last):\n" + + " File \"/Users/seb/versioned/access/access-playground-staging-tutors/assignment_05/exercise_01_merge_lists/private/tests.py\", line 55, in test_2\n" + + " self._assert([1], [2], [(1, 2)])\n" + + " File \"/Users/seb/versioned/access/access-playground-staging-tutors/assignment_05/exercise_01_merge_lists/private/tests.py\", line 41, in _assert\n" + + " self._assertType(list, actual)\n" + + " File \"/Users/seb/versioned/access/access-playground-staging-tutors/assignment_05/exercise_01_merge_lists/private/tests.py\", line 23, in _assertType\n" + + " self.fail(m)\n" + "AssertionError: @@second hint@@\n" + "\n" + + "----------------------------------------------------------------------\n" + "Ran 2 tests in 0.001s\n" + + "\n" + "FAILED (failures=2)"); + List expecteds = hints("First hint", "second hint"); + assertEquals(expecteds, actuals); + } + + @Test + public void importError() { + List actuals = extractAllHints("F\n" + + "======================================================================\n" + + "FAIL: test_1 (private.tests.PrivateTestSuite)\n" + + "----------------------------------------------------------------------\n" + + "Traceback (most recent call last):\n" + + " File \"/Users/seb/versioned/access/access-playground-staging-tutors/assignment_05/exercise_01_merge_lists/private/tests.py\", line 52, in test_1\n" + + " self._assert([], [], [], \"empty\")\n" + + " File \"/Users/seb/versioned/access/access-playground-staging-tutors/assignment_05/exercise_01_merge_lists/private/tests.py\", line 30, in _assert\n" + + " self.fail(m)\n" + "AssertionError: @@Could not import solution for testing: NameError@@\n" + "\n" + + "----------------------------------------------------------------------\n" + "Ran 1 test in 0.000s\n" + + "\n" + "FAILED (failures=1)"); + List expecteds = hints("Could not import solution for testing: NameError"); + assertEquals(expecteds, actuals); + } + + @Test + public void executionError() { + List actuals = extractAllHints("F\n" + + "======================================================================\n" + + "FAIL: test_1 (private.tests.PrivateTestSuite)\n" + + "----------------------------------------------------------------------\n" + + "Traceback (most recent call last):\n" + + " File \"/Users/seb/versioned/access/access-playground-staging-tutors/assignment_05/exercise_01_merge_lists/private/tests.py\", line 34, in _assert\n" + + " actual = merge(a, b)\n" + + " File \"/Users/seb/versioned/access/access-playground-staging-tutors/assignment_05/exercise_01_merge_lists/public/script.py\", line 4, in merge\n" + + " xxx\n" + "NameError: name 'xxx' is not defined\n" + "\n" + + "During handling of the above exception, another exception occurred:\n" + "\n" + + "Traceback (most recent call last):\n" + + " File \"/Users/seb/versioned/access/access-playground-staging-tutors/assignment_05/exercise_01_merge_lists/private/tests.py\", line 52, in test_1\n" + + " self._assert([], [], [], \"empty\")\n" + + " File \"/Users/seb/versioned/access/access-playground-staging-tutors/assignment_05/exercise_01_merge_lists/private/tests.py\", line 38, in _assert\n" + + " self.fail(m)\n" + "AssertionError: @@Could not execute the solution for testing: NameError@@\n" + + "\n" + "----------------------------------------------------------------------\n" + + "Ran 1 test in 0.000s\n" + "\n" + "FAILED (failures=1)"); + List expecteds = hints("Could not execute the solution for testing: NameError"); + assertEquals(expecteds, actuals); + } + + @Test + public void assertEqualDists() { + List actuals = extractAllHints("F\n" + + "======================================================================\n" + + "FAIL: test_1 (private.tests.PrivateTestSuite)\n" + + "----------------------------------------------------------------------\n" + + "Traceback (most recent call last):\n" + + " File \"/Users/seb/versioned/access/access-playground-staging-tutors/assignment_05/exercise_01_merge_lists/private/tests.py\", line 52, in test_1\n" + + " self._assert([], [], [], \"@@Empty lists are not handled correctly.@@\")\n" + + " File \"/Users/seb/versioned/access/access-playground-staging-tutors/assignment_05/exercise_01_merge_lists/private/tests.py\", line 48, in _assert\n" + + " self.assertEqual(expected, actual, m)\n" + "AssertionError: Lists differ: [] != ['a']\n" + "\n" + + "Second list contains 1 additional elements.\n" + "First extra element 0:\n" + "'a'\n" + "\n" + + "- []\n" + "+ ['a'] : @@Empty lists are not handled correctly.@@\n" + "\n" + + "----------------------------------------------------------------------\n" + "Ran 1 test in 0.000s\n" + + "\n" + "FAILED (failures=1)"); + List expecteds = hints("Empty lists are not handled correctly."); + assertEquals(expecteds, actuals); + } + + @Test + public void assertEqualDicts() { + List actuals = extractAllHints("F\n" + + "======================================================================\n" + + "FAIL: test_2 (private.tests.PrivateTestSuite)\n" + + "----------------------------------------------------------------------\n" + + "Traceback (most recent call last):\n" + + " File \"/Users/seb/versioned/access/access-playground-staging-tutors/assignment_05/exercise_02_invert_dict/private/tests.py\", line 59, in test_2\n" + + " self._assert({1:2}, {2:[1]})\n" + + " File \"/Users/seb/versioned/access/access-playground-staging-tutors/assignment_05/exercise_02_invert_dict/private/tests.py\", line 52, in _assert\n" + + " self.assertEqual(expected, actual, m)\n" + "AssertionError: {2: [1]} != {}\n" + "- {2: [1]}\n" + + "+ {} : @@Result is incorrect for input {1: 2}.@@\n" + "\n" + + "----------------------------------------------------------------------\n" + "Ran 1 test in 0.001s\n" + + "\n" + "FAILED (failures=1)"); + List expecteds = hints("Result is incorrect for input {1: 2}."); + assertEquals(expecteds, actuals); + } + + @Test + public void errorDuringImport() { + List actuals = extractAllHints("Traceback (most recent call last):\n" + + " File \"/opt/local/Library/Frameworks/Python.framework/Versions/3.6/lib/python3.6/runpy.py\", line 193, in _run_module_as_main\n" + + " \"__main__\", mod_spec)\n" + + " File \"/opt/local/Library/Frameworks/Python.framework/Versions/3.6/lib/python3.6/runpy.py\", line 85, in _run_code\n" + + " exec(code, run_globals)\n" + + " File \"/opt/local/Library/Frameworks/Python.framework/Versions/3.6/lib/python3.6/unittest/__main__.py\", line 18, in \n" + + " main(module=None)\n" + + " File \"/opt/local/Library/Frameworks/Python.framework/Versions/3.6/lib/python3.6/unittest/main.py\", line 94, in __init__\n" + + " self.parseArgs(argv)\n" + + " File \"/opt/local/Library/Frameworks/Python.framework/Versions/3.6/lib/python3.6/unittest/main.py\", line 141, in parseArgs\n" + + " self.createTests()\n" + + " File \"/opt/local/Library/Frameworks/Python.framework/Versions/3.6/lib/python3.6/unittest/main.py\", line 148, in createTests\n" + + " self.module)\n" + + " File \"/opt/local/Library/Frameworks/Python.framework/Versions/3.6/lib/python3.6/unittest/loader.py\", line 219, in loadTestsFromNames\n" + + " suites = [self.loadTestsFromName(name, module) for name in names]\n" + + " File \"/opt/local/Library/Frameworks/Python.framework/Versions/3.6/lib/python3.6/unittest/loader.py\", line 219, in \n" + + " suites = [self.loadTestsFromName(name, module) for name in names]\n" + + " File \"/opt/local/Library/Frameworks/Python.framework/Versions/3.6/lib/python3.6/unittest/loader.py\", line 153, in loadTestsFromName\n" + + " module = __import__(module_name)\n" + + " File \"/Users/seb/versioned/access/access-playground-staging-tutors/assignment_05/exercise_03_count_hashtags/private/tests.py\", line 12, in \n" + + " from public.script import analyze\n" + + " File \"/Users/seb/versioned/access/access-playground-staging-tutors/assignment_05/exercise_03_count_hashtags/public/script.py\", line 3, in \n" + + " xxx\n" + "NameError: name 'xxx' is not defined"); + List expecteds = hints("Error during import: NameError"); + assertEquals(expecteds, actuals); + } + + @Test + public void errorDuringExecution() { + List actuals = extractAllHints("E\n" + + "======================================================================\n" + + "ERROR: test01_empty_list (private.tests.PrivateTestSuite)\n" + + "----------------------------------------------------------------------\n" + + "Traceback (most recent call last):\n" + + " File \"/Users/seb/versioned/access/access-playground-staging-tutors/assignment_05/exercise_03_count_hashtags/private/tests.py\", line 60, in test01_empty_list\n" + + " self._assert([], {}, \"@@The result is not correct for an empty list of posts.@@\")\n" + + " File \"/Users/seb/versioned/access/access-playground-staging-tutors/assignment_05/exercise_03_count_hashtags/private/tests.py\", line 42, in _assert\n" + + " actual = self._exec(_in)\n" + + " File \"/Users/seb/versioned/access/access-playground-staging-tutors/assignment_05/exercise_03_count_hashtags/private/tests.py\", line 29, in _exec\n" + + " return analyze(_in)\n" + + " File \"/Users/seb/versioned/access/access-playground-staging-tutors/assignment_05/exercise_03_count_hashtags/public/script.py\", line 4, in analyze\n" + + " xxx\n" + "NameError: name 'xxx' is not defined\n" + "\n" + + "----------------------------------------------------------------------\n" + "Ran 1 test in 0.001s\n" + + "\n" + "FAILED (errors=1)"); + List expecteds = hints("Error during execution: NameError"); + assertEquals(expecteds, actuals); + } + + @Test + public void noPointsErrorUnspecified() { + List actuals = extractAllHints("Some output, no ok, no testing..."); + List expecteds = hints( + "No hint could be provided. This is likely caused by a crash during the execution."); + assertEquals(expecteds, actuals); + } + + @Test + public void maxPointsNoError() { + + String in = "test_isupper (test.TestStringMethods1.TestStringMethods1) ... ok\n" + + "test_split (test.TestStringMethods1.TestStringMethods1) ... ok\n" + + "test_upper (test.TestStringMethods1.TestStringMethods1) ... ok\n" + + "test_isupper (test.TestStringMethods2.TestStringMethods2) ... ok\n" + + "test_split (test.TestStringMethods2.TestStringMethods2) ... ok\n" + + "test_upper (test.TestStringMethods2.TestStringMethods2) ... ok\n" + "\n" + + "----------------------------------------------------------------------\n" + "Ran 6 tests in 0.001s\n" + + "\n" + "OK\n"; + + List actuals = extractAllHints(in); + List expecteds = hints( + "No hint could be provided. This is likely caused by a crash during the execution."); + assertEquals(expecteds, actuals); + + actuals = evaluate(in).getHints(); + expecteds = hints(); + assertEquals(expecteds, actuals); + } } diff --git a/src/test/java/ch/uzh/ifi/access/student/evaluation/process/EvalMachineRepoServiceTest.java b/src/test/java/ch/uzh/ifi/access/student/evaluation/process/EvalMachineRepoServiceTest.java new file mode 100644 index 00000000..96cec634 --- /dev/null +++ b/src/test/java/ch/uzh/ifi/access/student/evaluation/process/EvalMachineRepoServiceTest.java @@ -0,0 +1,66 @@ +package ch.uzh.ifi.access.student.evaluation.process; + +import org.assertj.core.api.Assertions; +import org.junit.Test; +import org.springframework.statemachine.StateMachine; + +import java.time.Instant; +import java.time.temporal.ChronoUnit; +import java.util.UUID; + +public class EvalMachineRepoServiceTest { + + @Test + public void cleanUpNoMachines() { + EvalMachineRepoService repo = new EvalMachineRepoService(); + repo.removeMachinesOlderThan(Instant.now()); + } + + @Test + public void cleanUp() throws Exception { + String id1 = UUID.randomUUID().toString(); + String id2 = UUID.randomUUID().toString(); + StateMachine m1 = EvalMachineFactory.initSMForSubmission("123"); + StateMachine m2 = EvalMachineFactory.initSMForSubmission("345"); + + m1.getExtendedState().getVariables().put(EvalMachineFactory.EXTENDED_VAR_COMPLETION_TIME, Instant.now().minus(1, ChronoUnit.MINUTES)); + m2.getExtendedState().getVariables().put(EvalMachineFactory.EXTENDED_VAR_COMPLETION_TIME, Instant.now().minus(30, ChronoUnit.MINUTES)); + + EvalMachineRepoService repo = new EvalMachineRepoService(); + repo.store(id1, m1); + repo.store(id2, m2); + + Assertions.assertThat(repo.get(id1)).isNotNull(); + Assertions.assertThat(repo.get(id2)).isNotNull(); + + Instant fiveMinutesAgo = Instant.now().minus(5, ChronoUnit.MINUTES); + repo.removeMachinesOlderThan(fiveMinutesAgo); + + Assertions.assertThat(repo.get(id1)).isNotNull(); + Assertions.assertThat(repo.get(id2)).isNull(); + } + + @Test + public void noMachinesToClean() throws Exception { + String id1 = UUID.randomUUID().toString(); + String id2 = UUID.randomUUID().toString(); + StateMachine m1 = EvalMachineFactory.initSMForSubmission("123"); + StateMachine m2 = EvalMachineFactory.initSMForSubmission("345"); + + m1.getExtendedState().getVariables().put(EvalMachineFactory.EXTENDED_VAR_COMPLETION_TIME, Instant.now().minus(1, ChronoUnit.MINUTES)); + m2.getExtendedState().getVariables().put(EvalMachineFactory.EXTENDED_VAR_COMPLETION_TIME, Instant.now().minus(1, ChronoUnit.MINUTES)); + + EvalMachineRepoService repo = new EvalMachineRepoService(); + repo.store(id1, m1); + repo.store(id2, m2); + + Assertions.assertThat(repo.get(id1)).isNotNull(); + Assertions.assertThat(repo.get(id2)).isNotNull(); + + Instant fiveMinutesAgo = Instant.now().minus(5, ChronoUnit.MINUTES); + repo.removeMachinesOlderThan(fiveMinutesAgo); + + Assertions.assertThat(repo.get(id1)).isNotNull(); + Assertions.assertThat(repo.get(id2)).isNotNull(); + } +} \ No newline at end of file