diff --git a/src/test/java/de/tum/cit/aet/artemis/programming/icl/ProgrammingExerciseLocalVCLocalCIIntegrationTest.java b/src/test/java/de/tum/cit/aet/artemis/programming/icl/ProgrammingExerciseLocalVCLocalCIIntegrationTest.java index a75d3837ec34..f0b0ffef3d98 100644 --- a/src/test/java/de/tum/cit/aet/artemis/programming/icl/ProgrammingExerciseLocalVCLocalCIIntegrationTest.java +++ b/src/test/java/de/tum/cit/aet/artemis/programming/icl/ProgrammingExerciseLocalVCLocalCIIntegrationTest.java @@ -7,7 +7,8 @@ import static org.mockito.Mockito.timeout; import static org.mockito.Mockito.verify; -import java.io.IOException; +import java.nio.file.Path; +import java.nio.file.Paths; import java.time.ZonedDateTime; import java.util.Map; import java.util.Optional; @@ -25,6 +26,9 @@ import org.junit.jupiter.api.TestInstance; import org.junit.jupiter.api.parallel.Execution; import org.junit.jupiter.api.parallel.ExecutionMode; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ArgumentsSource; +import org.springframework.beans.factory.annotation.Autowired; import org.springframework.http.HttpStatus; import org.springframework.security.test.context.support.WithMockUser; import org.springframework.util.LinkedMultiValueMap; @@ -32,6 +36,8 @@ import de.tum.cit.aet.artemis.atlas.domain.competency.Competency; import de.tum.cit.aet.artemis.atlas.domain.competency.CompetencyExerciseLink; import de.tum.cit.aet.artemis.core.domain.Course; +import de.tum.cit.aet.artemis.exam.util.InvalidExamExerciseDatesArgumentProvider; +import de.tum.cit.aet.artemis.exam.util.InvalidExamExerciseDatesArgumentProvider.InvalidExamExerciseDateConfiguration; import de.tum.cit.aet.artemis.programming.AbstractProgrammingIntegrationLocalCILocalVCTestBase; import de.tum.cit.aet.artemis.programming.domain.AeolusTarget; import de.tum.cit.aet.artemis.programming.domain.ProgrammingExercise; @@ -43,6 +49,9 @@ import de.tum.cit.aet.artemis.programming.service.localvc.LocalVCRepositoryUri; import de.tum.cit.aet.artemis.programming.util.LocalRepository; import de.tum.cit.aet.artemis.programming.util.ProgrammingExerciseFactory; +import de.tum.cit.aet.artemis.programming.util.ProgrammingExerciseImportTestService; +import de.tum.cit.aet.artemis.programming.util.ProgrammingExerciseImportTestService.ImportFileResult; +import de.tum.cit.aet.artemis.programming.util.ProgrammingExerciseTestService; // TestInstance.Lifecycle.PER_CLASS allows all test methods in this class to share the same instance of the test class. // This reduces the overhead of repeatedly creating and tearing down a new Spring application context for each test method. @@ -73,6 +82,12 @@ class ProgrammingExerciseLocalVCLocalCIIntegrationTest extends AbstractProgrammi private Competency competency; + @Autowired + private ProgrammingExerciseTestService programmingExerciseTestService; + + @Autowired + private ProgrammingExerciseImportTestService programmingExerciseImportTestService; + @BeforeAll void setupAll() { CredentialsProvider.setDefault(new UsernamePasswordCredentialsProvider(localVCUsername, localVCPassword)); @@ -124,6 +139,9 @@ void setup() throws Exception { localVCLocalCITestService.verifyRepositoryFoldersExist(programmingExercise, localVCBasePath); competency = competencyUtilService.createCompetency(course); + + programmingExerciseTestService.setupTestUsers(TEST_PREFIX, 0, 0, 0, 0); + programmingExerciseTestService.setup(this, versionControlService, localVCGitBranchService); } @Override @@ -132,11 +150,12 @@ protected String getTestPrefix() { } @AfterEach - void tearDown() throws IOException { + void tearDown() throws Exception { templateRepository.resetLocalRepo(); solutionRepository.resetLocalRepo(); testsRepository.resetLocalRepo(); assignmentRepository.resetLocalRepo(); + programmingExerciseTestService.tearDown(); } @Disabled @@ -217,7 +236,6 @@ void testUpdateProgrammingExercise_templateRepositoryUriIsInvalid() throws Excep request.put("/api/programming/programming-exercises", programmingExercise, HttpStatus.BAD_REQUEST); } - @Disabled @Test @WithMockUser(username = TEST_PREFIX + "instructor1", roles = "INSTRUCTOR") void testDeleteProgrammingExercise() throws Exception { @@ -290,6 +308,196 @@ void testImportProgrammingExercise() throws Exception { verify(competencyProgressApi).updateProgressByLearningObjectAsync(eq(importedExercise)); } + @Test + @WithMockUser(username = TEST_PREFIX + "instructor1", roles = "INSTRUCTOR") + void importFromFile_missingExerciseDetailsJson_badRequest() throws Exception { + programmingExerciseTestService.importFromFile_missingExerciseDetailsJson_badRequest(); + } + + @Test + @WithMockUser(username = TEST_PREFIX + "instructor1", roles = "INSTRUCTOR") + void importFromFile_fileNoZip_badRequest() throws Exception { + programmingExerciseTestService.importFromFile_fileNoZip_badRequest(); + } + + @Test + @WithMockUser(username = TEST_PREFIX + "instructor1", roles = "INSTRUCTOR") + void importFromFile_tutor_forbidden() throws Exception { + programmingExerciseTestService.importFromFile_tutor_forbidden(); + } + + @Test + @WithMockUser(username = TEST_PREFIX + "instructor1", roles = "INSTRUCTOR") + void importFromFile_missingRepository_BadRequest() throws Exception { + programmingExerciseTestService.importFromFile_missingRepository_BadRequest(); + } + + @Test + @WithMockUser(username = TEST_PREFIX + "instructor1", roles = "INSTRUCTOR") + void importFromFile_exception_DirectoryDeleted() throws Exception { + programmingExerciseTestService.importFromFile_exception_DirectoryDeleted(); + } + + @Test + @WithMockUser(username = TEST_PREFIX + "instructor1", roles = "INSTRUCTOR") + void createProgrammingExercise_failToCreateProjectInCi() throws Exception { + programmingExerciseTestService.createProgrammingExercise_failToCreateProjectInCi(); + } + + @ParameterizedTest(name = "{displayName} [{index}] {argumentsWithNames}") + @ArgumentsSource(InvalidExamExerciseDatesArgumentProvider.class) + @WithMockUser(username = TEST_PREFIX + "instructor1", roles = "INSTRUCTOR") + void createProgrammingExerciseForExam_invalidExercise_dates(InvalidExamExerciseDateConfiguration dates) throws Exception { + programmingExerciseTestService.createProgrammingExerciseForExam_invalidExercise_dates(dates); + } + + @Test + @WithMockUser(username = TEST_PREFIX + "instructor1", roles = "INSTRUCTOR") + void createProgrammingExerciseForExam_DatesSet() throws Exception { + programmingExerciseTestService.createProgrammingExerciseForExam_DatesSet(); + } + + @Test + @WithMockUser(username = TEST_PREFIX + "instructor1", roles = "INSTRUCTOR") + void createProgrammingExercise_setInvalidExampleSolutionPublicationDate_badRequest() throws Exception { + programmingExerciseTestService.createProgrammingExercise_setInvalidExampleSolutionPublicationDate_badRequest(); + } + + /** + * Ensures issue #7188 does not occur again + * + */ + @Test + @WithMockUser(username = TEST_PREFIX + "instructor1", roles = "INSTRUCTOR") + void importFromFile_validImportZip_changeTitle_success() throws Exception { + aeolusRequestMockProvider.enableMockingOfRequests(); + aeolusRequestMockProvider.mockFailedGenerateBuildPlan(AeolusTarget.CLI); + + String uniqueSuffix = java.util.UUID.randomUUID().toString().replace("-", "").substring(0, 20).toUpperCase(); + String newTitle = "TITLE" + uniqueSuffix; + String newShortName = "SHORT" + uniqueSuffix; + + ImportFileResult importResult = programmingExerciseImportTestService.prepareExerciseImport("test-data/import-from-file/valid-import.zip", exercise -> { + String oldTitle = exercise.getTitle(); + exercise.setTitle(newTitle); + exercise.setShortName(newShortName); + return oldTitle; + }, course); + + ProgrammingExercise importedExercise = importResult.importedExercise(); + String oldTitle = (String) importResult.additionalData(); + + assertThat(importedExercise).isNotNull(); + assertThat(importedExercise.getTitle()).isEqualTo(newTitle); + assertThat(importedExercise.getProgrammingLanguage()).isEqualTo(importResult.parsedExercise().getProgrammingLanguage()); + assertThat(importedExercise.getCourseViaExerciseGroupOrCourseMember()).isEqualTo(course); + + String repoClonePath = System.getProperty("artemis.repo-clone-path", "repos"); + String projectKey = importResult.parsedExercise().getProjectKey(); + Path exercisePath = Paths.get(repoClonePath, projectKey); + int newTitleCount = programmingExerciseImportTestService.countOccurrencesInDirectory(exercisePath, newTitle); + int oldTitleCount = programmingExerciseImportTestService.countOccurrencesInDirectory(exercisePath, oldTitle); + + assertThat(newTitleCount).isEqualTo(programmingExerciseImportTestService.countOccurrencesInZip(importResult.resource(), oldTitle)); + assertThat(oldTitleCount).isZero(); + } + + @Test + @WithMockUser(username = TEST_PREFIX + "instructor1", roles = "INSTRUCTOR") + void importFromFile_validImportZip() throws Exception { + aeolusRequestMockProvider.enableMockingOfRequests(); + aeolusRequestMockProvider.mockFailedGenerateBuildPlan(AeolusTarget.CLI); + + ImportFileResult importResult = programmingExerciseImportTestService.prepareExerciseImport("test-data/import-from-file/valid-import.zip", exercise -> null, course); + ProgrammingExercise importedExercise = importResult.importedExercise(); + + assertThat(importedExercise).isNotNull(); + assertThat(importedExercise.getTitle()).isEqualTo(importResult.parsedExercise().getTitle()); + assertThat(importedExercise.getProgrammingLanguage()).isEqualTo(importResult.parsedExercise().getProgrammingLanguage()); + assertThat(importedExercise.getCourseViaExerciseGroupOrCourseMember()).isEqualTo(course); + } + + /** + * Ensures issue #8562 does not occur again + * + */ + @Test + @WithMockUser(username = TEST_PREFIX + "instructor1", roles = "INSTRUCTOR") + void importFromFile_verifyBuildPlansCreated() throws Exception { + aeolusRequestMockProvider.enableMockingOfRequests(); + aeolusRequestMockProvider.mockFailedGenerateBuildPlan(AeolusTarget.CLI); + + // Mock commit hash retrieval + dockerClientTestService.mockInputStreamReturnedFromContainer(dockerClient, LOCAL_CI_DOCKER_CONTAINER_WORKING_DIRECTORY + "/testing-dir/assignment/.git/refs/heads/[^/]+", + Map.of("assignmentCommitHash", DUMMY_COMMIT_HASH), Map.of("assignmentCommitHash", DUMMY_COMMIT_HASH)); + dockerClientTestService.mockInputStreamReturnedFromContainer(dockerClient, LOCAL_CI_DOCKER_CONTAINER_WORKING_DIRECTORY + "/testing-dir/.git/refs/heads/[^/]+", + Map.of("testsCommitHash", DUMMY_COMMIT_HASH), Map.of("testsCommitHash", DUMMY_COMMIT_HASH)); + + // Mock image inspection + dockerClientTestService.mockInspectImage(dockerClient); + + ImportFileResult importResult = programmingExerciseImportTestService.prepareExerciseImport("test-data/import-from-file/valid-import.zip", exercise -> null, course); + ProgrammingExercise importedExercise = importResult.importedExercise(); + + assertThat(importedExercise).isNotNull(); + + // Mock test results for builds + Map templateBuildTestResults = dockerClientTestService.createMapFromTestResultsFolder(ALL_FAIL_TEST_RESULTS_PATH); + Map solutionBuildTestResults = dockerClientTestService.createMapFromTestResultsFolder(ALL_SUCCEED_TEST_RESULTS_PATH); + dockerClientTestService.mockInputStreamReturnedFromContainer(dockerClient, LOCAL_CI_DOCKER_CONTAINER_WORKING_DIRECTORY + LOCAL_CI_RESULTS_DIRECTORY, + templateBuildTestResults, solutionBuildTestResults); + + try { + // Refresh the exercise to get latest participation data + ProgrammingExercise refreshedExercise = programmingExerciseRepository.findWithAllParticipationsAndBuildConfigById(importedExercise.getId()).orElseThrow(); + + // Verify template build plan + TemplateProgrammingExerciseParticipation templateParticipation = templateProgrammingExerciseParticipationRepository + .findByProgrammingExerciseId(refreshedExercise.getId()).orElseThrow(); + + localVCLocalCITestService.testLatestSubmission(templateParticipation.getId(), null, 0, false, 30); + + // Verify solution build plan + SolutionProgrammingExerciseParticipation solutionParticipation = solutionProgrammingExerciseParticipationRepository + .findByProgrammingExerciseId(refreshedExercise.getId()).orElseThrow(); + + localVCLocalCITestService.testLatestSubmission(solutionParticipation.getId(), null, 13, false, 30); + } + catch (Exception e) { + throw new AssertionError("Failed to verify build plans", e); + } + } + + @Test + @WithMockUser(username = TEST_PREFIX + "instructor1", roles = "INSTRUCTOR") + void testCreateProgrammingExerciseWithSequentialTestRuns() throws Exception { + ProgrammingExercise newExercise = ProgrammingExerciseFactory.generateProgrammingExercise(ZonedDateTime.now().minusDays(1), ZonedDateTime.now().plusDays(7), course); + newExercise.setProjectType(ProjectType.PLAIN_GRADLE); + // Enable sequential test runs + newExercise.getBuildConfig().setSequentialTestRuns(true); + + // Mock dockerClient.copyArchiveFromContainerCmd() such that it returns a dummy commitHash for both the assignment and the test repository. + dockerClientTestService.mockInputStreamReturnedFromContainer(dockerClient, LOCAL_CI_DOCKER_CONTAINER_WORKING_DIRECTORY + "/testing-dir/assignment/.git/refs/heads/[^/]+", + Map.of("assignmentCommitHash", DUMMY_COMMIT_HASH), Map.of("assignmentCommitHash", DUMMY_COMMIT_HASH)); + dockerClientTestService.mockInputStreamReturnedFromContainer(dockerClient, LOCAL_CI_DOCKER_CONTAINER_WORKING_DIRECTORY + "/testing-dir/.git/refs/heads/[^/]+", + Map.of("testsCommitHash", DUMMY_COMMIT_HASH), Map.of("testsCommitHash", DUMMY_COMMIT_HASH)); + + dockerClientTestService.mockInspectImage(dockerClient); + + // Mock dockerClient.copyArchiveFromContainerCmd() such that it returns the XMLs containing the test results. + // Mock the results for the template repository build and for the solution repository build that will both be triggered as a result of creating the exercise. + Map templateBuildTestResults = dockerClientTestService.createMapFromTestResultsFolder(ALL_FAIL_TEST_RESULTS_PATH); + Map solutionBuildTestResults = dockerClientTestService.createMapFromTestResultsFolder(ALL_SUCCEED_TEST_RESULTS_PATH); + dockerClientTestService.mockInputStreamReturnedFromContainer(dockerClient, LOCAL_CI_DOCKER_CONTAINER_WORKING_DIRECTORY + LOCAL_CI_RESULTS_DIRECTORY, + templateBuildTestResults, solutionBuildTestResults); + newExercise.setChannelName("testchannelname-pe-sequential"); + aeolusRequestMockProvider.enableMockingOfRequests(); + aeolusRequestMockProvider.mockFailedGenerateBuildPlan(AeolusTarget.CLI); + + // Create the exercise and verify status code 201 + request.postWithResponseBody("/api/programming/programming-exercises/setup", newExercise, ProgrammingExercise.class, HttpStatus.CREATED); + } + @Nested class TestGetCheckoutDirectories { diff --git a/src/test/java/de/tum/cit/aet/artemis/programming/util/ProgrammingExerciseImportTestService.java b/src/test/java/de/tum/cit/aet/artemis/programming/util/ProgrammingExerciseImportTestService.java new file mode 100644 index 000000000000..3cae10581b43 --- /dev/null +++ b/src/test/java/de/tum/cit/aet/artemis/programming/util/ProgrammingExerciseImportTestService.java @@ -0,0 +1,167 @@ +package de.tum.cit.aet.artemis.programming.util; + +import static org.assertj.core.api.Assertions.assertThat; +import static tech.jhipster.config.JHipsterConstants.SPRING_PROFILE_TEST; + +import java.io.ByteArrayOutputStream; +import java.io.FileInputStream; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.List; +import java.util.zip.ZipEntry; +import java.util.zip.ZipInputStream; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.context.annotation.Profile; +import org.springframework.core.io.ClassPathResource; +import org.springframework.http.HttpStatus; +import org.springframework.mock.web.MockMultipartFile; +import org.springframework.stereotype.Service; + +import com.fasterxml.jackson.databind.ObjectMapper; + +import de.tum.cit.aet.artemis.core.domain.Course; +import de.tum.cit.aet.artemis.core.util.RequestUtilService; +import de.tum.cit.aet.artemis.programming.domain.ProgrammingExercise; +import de.tum.cit.aet.artemis.programming.domain.ProgrammingExerciseBuildConfig; + +/** + * Test service for handling programming exercise imports + */ +@Service +@Profile(SPRING_PROFILE_TEST) +public class ProgrammingExerciseImportTestService { + + @Autowired + private RequestUtilService request; + + @Autowired + private ObjectMapper objectMapper; + + /** + * Functional interface to modify the exercise before import + */ + public interface ExerciseModifier { + + T modify(ProgrammingExercise exercise); + } + + /** + * Result record holding data related to a programming exercise import + */ + public record ImportFileResult(ClassPathResource resource, ProgrammingExercise parsedExercise, ProgrammingExercise importedExercise, Object additionalData) { + } + + /** + * Prepares and imports a programming exercise from a zip file + * + * @param resourcePath Path to the resource zip file + * @param modifier Function to modify the exercise before import + * @param course Course to import the exercise into + * @return ImportFileResult containing the resource, parsed exercise, imported exercise and any additional data + * @throws Exception if the import fails + */ + public ImportFileResult prepareExerciseImport(String resourcePath, ExerciseModifier modifier, Course course) throws Exception { + var resource = new ClassPathResource(resourcePath); + ZipInputStream zipInputStream = new ZipInputStream(resource.getInputStream()); + String detailsJsonString = null; + ZipEntry entry; + while ((entry = zipInputStream.getNextEntry()) != null) { + if (entry.getName().endsWith(".json")) { + ByteArrayOutputStream outputStream = new ByteArrayOutputStream(); + byte[] buffer = new byte[1024]; + int bytesRead; + while ((bytesRead = zipInputStream.read(buffer)) > 0) { + outputStream.write(buffer, 0, bytesRead); + } + detailsJsonString = outputStream.toString(StandardCharsets.UTF_8); + break; + } + } + zipInputStream.close(); + assertThat(detailsJsonString).isNotNull(); + + objectMapper.configure(com.fasterxml.jackson.databind.DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false); + objectMapper.findAndRegisterModules(); + ProgrammingExercise parsedExercise = objectMapper.readValue(detailsJsonString, ProgrammingExercise.class); + + if (parsedExercise.getBuildConfig() == null) { + parsedExercise.setBuildConfig(new ProgrammingExerciseBuildConfig()); + } + + Object additionalData = modifier.modify(parsedExercise); + + parsedExercise.setCourse(course); + parsedExercise.setId(null); + parsedExercise.setChannelName("testchannel-pe-imported"); + parsedExercise.forceNewProjectKey(); + + MockMultipartFile file = new MockMultipartFile("file", "test.zip", "application/zip", resource.getInputStream()); + + ProgrammingExercise importedExercise = request.postWithMultipartFile("/api/programming/courses/" + course.getId() + "/programming-exercises/import-from-file", + parsedExercise, "programmingExercise", file, ProgrammingExercise.class, HttpStatus.OK); + + return new ImportFileResult(resource, parsedExercise, importedExercise, additionalData); + } + + /** + * Counts occurrences of a string in a zip file + * + * @param resource Resource containing the zip file + * @param searchString String to search for + * @return Count of occurrences + * @throws Exception if the zip file cannot be read + */ + public int countOccurrencesInZip(ClassPathResource resource, String searchString) throws Exception { + int occurrenceCount = 0; + try (ZipInputStream zipInputStream = new ZipInputStream(new FileInputStream(resource.getFile()))) { + ZipEntry zipEntry; + while ((zipEntry = zipInputStream.getNextEntry()) != null) { + if (!zipEntry.isDirectory() && zipEntry.getName().endsWith(".zip")) { + ByteArrayOutputStream outputStream = new ByteArrayOutputStream(); + byte[] buffer = new byte[1024]; + int bytesRead; + while ((bytesRead = zipInputStream.read(buffer)) > 0) { + outputStream.write(buffer, 0, bytesRead); + } + String content = outputStream.toString(StandardCharsets.UTF_8); + int currentPosition = 0; + while ((currentPosition = content.indexOf(searchString, currentPosition)) != -1) { + occurrenceCount++; + currentPosition += searchString.length(); + } + } + } + } + return occurrenceCount; + } + + /** + * Counts occurrences of a string in files within a directory + * + * @param path Directory path to search in + * @param searchString String to search for + * @return Count of occurrences + * @throws IOException if the directory cannot be read + */ + public int countOccurrencesInDirectory(Path path, String searchString) throws IOException { + int occurrenceCount = 0; + if (!Files.exists(path)) { + throw new IOException("Directory does not exist"); + } + List files = new ArrayList<>(); + Files.walk(path).filter(Files::isRegularFile).forEach(files::add); + for (Path filePath : files) { + String content = new String(Files.readAllBytes(filePath), StandardCharsets.UTF_8); + int currentPosition = 0; + while ((currentPosition = content.indexOf(searchString, currentPosition)) != -1) { + occurrenceCount++; + currentPosition += searchString.length(); + } + } + return occurrenceCount; + } +}