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;
+ }
+}