From 34f44102fcd8b2a6bdb2735e245d769c16b6bd42 Mon Sep 17 00:00:00 2001 From: Simon Entholzer <33342534+SimonEntholzer@users.noreply.github.com> Date: Sat, 19 Oct 2024 09:43:31 +0200 Subject: [PATCH] Integrated code lifecycle: Add auxiliary repository view (#9321) --- ...ogrammingExerciseParticipationService.java | 17 + .../service/ProgrammingExerciseService.java | 11 + ...grammingExerciseParticipationResource.java | 24 +- .../web/ProgrammingExerciseResource.java | 15 + .../AuxiliaryRepositoryResource.java | 220 +++++++ ...y-repository-buttons-detail.component.html | 9 +- .../detail-overview-list.component.html | 2 +- ...ming-exercise-management-routing.module.ts | 36 ++ ...gramming-exercise-participation.service.ts | 12 + .../services/programming-exercise.service.ts | 10 + .../code-editor/model/code-editor.model.ts | 5 +- .../code-editor-conflict-state.service.ts | 3 + ...ditor-domain-dependent-endpoint.service.ts | 2 + .../service/code-editor-domain.service.ts | 9 +- .../commit-history.component.ts | 63 +- .../repository-view.component.ts | 26 + ...iaryRepositoryResourceIntegrationTest.java | 552 ++++++++++++++++++ ...rammingExerciseIntegrationTestService.java | 8 + ...gExerciseParticipationIntegrationTest.java | 156 +++++ .../localvc/commit-history.component.spec.ts | 21 + .../localvc/repository-view.component.spec.ts | 39 ++ ...gramming-exercise-participation.service.ts | 1 + .../mock-programming-exercise.service.ts | 2 + .../programming-exercise.service.spec.ts | 22 + 24 files changed, 1242 insertions(+), 23 deletions(-) create mode 100644 src/main/java/de/tum/cit/aet/artemis/programming/web/repository/AuxiliaryRepositoryResource.java create mode 100644 src/test/java/de/tum/cit/aet/artemis/exercise/programming/AuxiliaryRepositoryResourceIntegrationTest.java diff --git a/src/main/java/de/tum/cit/aet/artemis/programming/service/ProgrammingExerciseParticipationService.java b/src/main/java/de/tum/cit/aet/artemis/programming/service/ProgrammingExerciseParticipationService.java index 4731295ed3c3..fd5d93cc660c 100644 --- a/src/main/java/de/tum/cit/aet/artemis/programming/service/ProgrammingExerciseParticipationService.java +++ b/src/main/java/de/tum/cit/aet/artemis/programming/service/ProgrammingExerciseParticipationService.java @@ -31,6 +31,7 @@ import de.tum.cit.aet.artemis.exercise.domain.participation.Participation; import de.tum.cit.aet.artemis.exercise.repository.ParticipationRepository; import de.tum.cit.aet.artemis.exercise.repository.TeamRepository; +import de.tum.cit.aet.artemis.programming.domain.AuxiliaryRepository; import de.tum.cit.aet.artemis.programming.domain.ProgrammingExercise; import de.tum.cit.aet.artemis.programming.domain.ProgrammingExerciseParticipation; import de.tum.cit.aet.artemis.programming.domain.ProgrammingExerciseStudentParticipation; @@ -517,6 +518,22 @@ public List getCommitInfos(ProgrammingExerciseParticipation parti } } + /** + * Get the commits information for the given auxiliary repository. + * + * @param auxiliaryRepository the auxiliary repository for which to get the commits. + * @return a list of CommitInfo DTOs containing author, timestamp, commit-hash and commit message. + */ + public List getAuxiliaryRepositoryCommitInfos(AuxiliaryRepository auxiliaryRepository) { + try { + return gitService.getCommitInfos(auxiliaryRepository.getVcsRepositoryUri()); + } + catch (GitAPIException e) { + log.error("Could not get commit infos for auxiliaryRepository {} with repository uri {}", auxiliaryRepository.getId(), auxiliaryRepository.getVcsRepositoryUri()); + return List.of(); + } + } + /** * Get the commits information for the test repository of the given participation's exercise. * diff --git a/src/main/java/de/tum/cit/aet/artemis/programming/service/ProgrammingExerciseService.java b/src/main/java/de/tum/cit/aet/artemis/programming/service/ProgrammingExerciseService.java index 0cbf73da21ff..d4e7de493f52 100644 --- a/src/main/java/de/tum/cit/aet/artemis/programming/service/ProgrammingExerciseService.java +++ b/src/main/java/de/tum/cit/aet/artemis/programming/service/ProgrammingExerciseService.java @@ -1048,4 +1048,15 @@ public ProgrammingExercise loadProgrammingExercise(long exerciseId, boolean with programmingExerciseTaskService.replaceTestIdsWithNames(programmingExercise); return programmingExercise; } + + /** + * Load a programming exercise, only with eager auxiliary repositories + * + * @param exerciseId the ID of the programming exercise to load + * @return the loaded programming exercise entity + */ + public ProgrammingExercise loadProgrammingExerciseWithAuxiliaryRepositories(long exerciseId) { + final Set fetchOptions = Set.of(AuxiliaryRepositories); + return programmingExerciseRepository.findByIdWithDynamicFetchElseThrow(exerciseId, fetchOptions); + } } diff --git a/src/main/java/de/tum/cit/aet/artemis/programming/web/ProgrammingExerciseParticipationResource.java b/src/main/java/de/tum/cit/aet/artemis/programming/web/ProgrammingExerciseParticipationResource.java index d06fdf5fb975..be1c99c67be6 100644 --- a/src/main/java/de/tum/cit/aet/artemis/programming/web/ProgrammingExerciseParticipationResource.java +++ b/src/main/java/de/tum/cit/aet/artemis/programming/web/ProgrammingExerciseParticipationResource.java @@ -49,6 +49,7 @@ import de.tum.cit.aet.artemis.programming.domain.VcsRepositoryUri; import de.tum.cit.aet.artemis.programming.dto.CommitInfoDTO; import de.tum.cit.aet.artemis.programming.dto.VcsAccessLogDTO; +import de.tum.cit.aet.artemis.programming.repository.AuxiliaryRepositoryRepository; import de.tum.cit.aet.artemis.programming.repository.ProgrammingExerciseRepository; import de.tum.cit.aet.artemis.programming.repository.ProgrammingExerciseStudentParticipationRepository; import de.tum.cit.aet.artemis.programming.repository.VcsAccessLogRepository; @@ -89,11 +90,13 @@ public class ProgrammingExerciseParticipationResource { private final Optional vcsAccessLogRepository; + private final AuxiliaryRepositoryRepository auxiliaryRepositoryRepository; + public ProgrammingExerciseParticipationResource(ProgrammingExerciseParticipationService programmingExerciseParticipationService, ResultRepository resultRepository, ParticipationRepository participationRepository, ProgrammingExerciseStudentParticipationRepository programmingExerciseStudentParticipationRepository, ProgrammingSubmissionService submissionService, ProgrammingExerciseRepository programmingExerciseRepository, AuthorizationCheckService authCheckService, ResultService resultService, ParticipationAuthorizationCheckService participationAuthCheckService, RepositoryService repositoryService, - StudentExamRepository studentExamRepository, Optional vcsAccessLogRepository) { + StudentExamRepository studentExamRepository, Optional vcsAccessLogRepository, AuxiliaryRepositoryRepository auxiliaryRepositoryRepository) { this.programmingExerciseParticipationService = programmingExerciseParticipationService; this.participationRepository = participationRepository; this.programmingExerciseStudentParticipationRepository = programmingExerciseStudentParticipationRepository; @@ -105,6 +108,7 @@ public ProgrammingExerciseParticipationResource(ProgrammingExerciseParticipation this.participationAuthCheckService = participationAuthCheckService; this.repositoryService = repositoryService; this.studentExamRepository = studentExamRepository; + this.auxiliaryRepositoryRepository = auxiliaryRepositoryRepository; this.vcsAccessLogRepository = vcsAccessLogRepository; } @@ -339,22 +343,25 @@ public ResponseEntity> getVcsAccessLogForParticipationRepo /** * GET /programming-exercise/{exerciseID}/commit-history/{repositoryType} : Get the commit history of a programming exercise repository. The repository type can be TEMPLATE or - * SOLUTION or TESTS. + * SOLUTION, TESTS or AUXILIARY. * Here we check is at least a teaching assistant for the exercise. * * @param exerciseID the id of the exercise for which to retrieve the commit history * @param repositoryType the type of the repository for which to retrieve the commit history + * @param repositoryId the id of the repository * @return the ResponseEntity with status 200 (OK) and with body a list of commitInfo DTOs with the commits information of the repository */ @GetMapping("programming-exercise/{exerciseID}/commit-history/{repositoryType}") @EnforceAtLeastTutor - public ResponseEntity> getCommitHistoryForTemplateSolutionOrTestRepo(@PathVariable long exerciseID, @PathVariable RepositoryType repositoryType) { + public ResponseEntity> getCommitHistoryForTemplateSolutionTestOrAuxRepo(@PathVariable long exerciseID, @PathVariable RepositoryType repositoryType, + @RequestParam Optional repositoryId) { boolean isTemplateRepository = repositoryType.equals(RepositoryType.TEMPLATE); boolean isSolutionRepository = repositoryType.equals(RepositoryType.SOLUTION); boolean isTestRepository = repositoryType.equals(RepositoryType.TESTS); + boolean isAuxiliaryRepository = repositoryType.equals(RepositoryType.AUXILIARY); ProgrammingExerciseParticipation participation; - if (!isTemplateRepository && !isSolutionRepository && !isTestRepository) { + if (!isTemplateRepository && !isSolutionRepository && !isTestRepository && !isAuxiliaryRepository) { throw new BadRequestAlertException("Invalid repository type", ENTITY_NAME, "invalidRepositoryType"); } else if (isTemplateRepository) { @@ -364,6 +371,15 @@ else if (isTemplateRepository) { participation = programmingExerciseParticipationService.findSolutionParticipationByProgrammingExerciseId(exerciseID); } participationAuthCheckService.checkCanAccessParticipationElseThrow(participation); + + if (isAuxiliaryRepository) { + var auxiliaryRepo = auxiliaryRepositoryRepository.findByIdElseThrow(repositoryId.orElseThrow()); + if (!auxiliaryRepo.getExercise().getId().equals(exerciseID)) { + throw new BadRequestAlertException("Invalid repository id", ENTITY_NAME, "invalidRepositoryId"); + } + return ResponseEntity.ok(programmingExerciseParticipationService.getAuxiliaryRepositoryCommitInfos(auxiliaryRepo)); + } + if (isTestRepository) { return ResponseEntity.ok(programmingExerciseParticipationService.getCommitInfosTestRepo(participation)); } diff --git a/src/main/java/de/tum/cit/aet/artemis/programming/web/ProgrammingExerciseResource.java b/src/main/java/de/tum/cit/aet/artemis/programming/web/ProgrammingExerciseResource.java index 2225baa81759..957f7435ce0d 100644 --- a/src/main/java/de/tum/cit/aet/artemis/programming/web/ProgrammingExerciseResource.java +++ b/src/main/java/de/tum/cit/aet/artemis/programming/web/ProgrammingExerciseResource.java @@ -534,6 +534,21 @@ public ResponseEntity getProgrammingExerciseWithTemplateAnd return ResponseEntity.ok(programmingExercise); } + /** + * GET /programming-exercises/:exerciseId/with-auxiliary-repository + * + * @param exerciseId the id of the programmingExercise to retrieve + * @return the ResponseEntity with status 200 (OK) and the programming exercise with template and solution participation, or with status 404 (Not Found) + */ + @GetMapping("programming-exercises/{exerciseId}/with-auxiliary-repository") + @EnforceAtLeastTutorInExercise + public ResponseEntity getProgrammingExerciseWithAuxiliaryRepository(@PathVariable long exerciseId) { + + log.debug("REST request to get programming exercise with auxiliary repositories: {}", exerciseId); + final var programmingExercise = programmingExerciseService.loadProgrammingExerciseWithAuxiliaryRepositories(exerciseId); + return ResponseEntity.ok(programmingExercise); + } + /** * DELETE /programming-exercises/:id : delete the "id" programmingExercise. * diff --git a/src/main/java/de/tum/cit/aet/artemis/programming/web/repository/AuxiliaryRepositoryResource.java b/src/main/java/de/tum/cit/aet/artemis/programming/web/repository/AuxiliaryRepositoryResource.java new file mode 100644 index 000000000000..8c03cf7dae19 --- /dev/null +++ b/src/main/java/de/tum/cit/aet/artemis/programming/web/repository/AuxiliaryRepositoryResource.java @@ -0,0 +1,220 @@ +package de.tum.cit.aet.artemis.programming.web.repository; + +import static de.tum.cit.aet.artemis.core.config.Constants.PROFILE_CORE; + +import java.security.Principal; +import java.util.List; +import java.util.Map; +import java.util.Optional; + +import jakarta.servlet.http.HttpServletRequest; + +import org.eclipse.jgit.api.errors.CheckoutConflictException; +import org.eclipse.jgit.api.errors.GitAPIException; +import org.eclipse.jgit.api.errors.WrongRepositoryStateException; +import org.springframework.context.annotation.Profile; +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.PutMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.server.ResponseStatusException; + +import de.tum.cit.aet.artemis.core.domain.User; +import de.tum.cit.aet.artemis.core.exception.AccessForbiddenException; +import de.tum.cit.aet.artemis.core.repository.UserRepository; +import de.tum.cit.aet.artemis.core.security.annotations.EnforceAtLeastTutor; +import de.tum.cit.aet.artemis.core.service.AuthorizationCheckService; +import de.tum.cit.aet.artemis.core.service.ProfileService; +import de.tum.cit.aet.artemis.core.service.feature.Feature; +import de.tum.cit.aet.artemis.core.service.feature.FeatureToggle; +import de.tum.cit.aet.artemis.programming.domain.AuxiliaryRepository; +import de.tum.cit.aet.artemis.programming.domain.FileType; +import de.tum.cit.aet.artemis.programming.domain.ProgrammingExercise; +import de.tum.cit.aet.artemis.programming.domain.Repository; +import de.tum.cit.aet.artemis.programming.domain.VcsRepositoryUri; +import de.tum.cit.aet.artemis.programming.dto.FileMove; +import de.tum.cit.aet.artemis.programming.dto.RepositoryStatusDTO; +import de.tum.cit.aet.artemis.programming.repository.AuxiliaryRepositoryRepository; +import de.tum.cit.aet.artemis.programming.repository.ProgrammingExerciseRepository; +import de.tum.cit.aet.artemis.programming.service.GitService; +import de.tum.cit.aet.artemis.programming.service.RepositoryAccessService; +import de.tum.cit.aet.artemis.programming.service.RepositoryService; +import de.tum.cit.aet.artemis.programming.service.localvc.LocalVCServletService; +import de.tum.cit.aet.artemis.programming.service.vcs.VersionControlService; + +/** + * Executes requested actions on the auxiliary repository of a programming exercise. Only available to TAs, Instructors and Admins. + */ +@Profile(PROFILE_CORE) +@RestController +@RequestMapping("api/auxiliary-repository/") +public class AuxiliaryRepositoryResource extends RepositoryResource { + + private final AuxiliaryRepositoryRepository auxiliaryRepositoryRepository; + + public AuxiliaryRepositoryResource(ProfileService profileService, UserRepository userRepository, AuthorizationCheckService authCheckService, GitService gitService, + RepositoryService repositoryService, Optional versionControlService, ProgrammingExerciseRepository programmingExerciseRepository, + RepositoryAccessService repositoryAccessService, Optional localVCServletService, AuxiliaryRepositoryRepository auxiliaryRepositoryRepository) { + super(profileService, userRepository, authCheckService, gitService, repositoryService, versionControlService, programmingExerciseRepository, repositoryAccessService, + localVCServletService); + this.auxiliaryRepositoryRepository = auxiliaryRepositoryRepository; + } + + @Override + Repository getRepository(Long auxiliaryRepositoryId, RepositoryActionType repositoryActionType, boolean pullOnGet) throws GitAPIException { + final var auxiliaryRepository = auxiliaryRepositoryRepository.findByIdElseThrow(auxiliaryRepositoryId); + User user = userRepository.getUserWithGroupsAndAuthorities(); + repositoryAccessService.checkAccessTestOrAuxRepositoryElseThrow(false, auxiliaryRepository.getExercise(), user, "auxiliary"); + final var repoUri = auxiliaryRepository.getVcsRepositoryUri(); + return gitService.getOrCheckoutRepository(repoUri, pullOnGet); + } + + @Override + VcsRepositoryUri getRepositoryUri(Long auxiliaryRepositoryId) { + var auxRepo = auxiliaryRepositoryRepository.findByIdElseThrow(auxiliaryRepositoryId); + return auxRepo.getVcsRepositoryUri(); + } + + @Override + boolean canAccessRepository(Long auxiliaryRepositoryId) { + try { + repositoryAccessService.checkAccessTestOrAuxRepositoryElseThrow(false, auxiliaryRepositoryRepository.findByIdElseThrow(auxiliaryRepositoryId).getExercise(), + userRepository.getUserWithGroupsAndAuthorities(), "auxiliary"); + } + catch (AccessForbiddenException e) { + return false; + } + return true; + } + + @Override + String getOrRetrieveBranchOfDomainObject(Long auxiliaryRepositoryId) { + AuxiliaryRepository auxiliaryRepo = auxiliaryRepositoryRepository.findByIdElseThrow(auxiliaryRepositoryId); + ProgrammingExercise exercise = programmingExerciseRepository.findByIdElseThrow(auxiliaryRepo.getExercise().getId()); + return versionControlService.orElseThrow().getOrRetrieveBranchOfExercise(exercise); + } + + @Override + @GetMapping(value = "{auxiliaryRepositoryId}/files", produces = MediaType.APPLICATION_JSON_VALUE) + @EnforceAtLeastTutor + public ResponseEntity> getFiles(@PathVariable Long auxiliaryRepositoryId) { + return super.getFiles(auxiliaryRepositoryId); + } + + @Override + @GetMapping(value = "{auxiliaryRepositoryId}/file", produces = MediaType.APPLICATION_OCTET_STREAM_VALUE) + @EnforceAtLeastTutor + public ResponseEntity getFile(@PathVariable Long auxiliaryRepositoryId, @RequestParam("file") String filename) { + return super.getFile(auxiliaryRepositoryId, filename); + } + + @Override + @PostMapping(value = "{auxiliaryRepositoryId}/file", produces = MediaType.APPLICATION_JSON_VALUE) + @EnforceAtLeastTutor + @FeatureToggle(Feature.ProgrammingExercises) + public ResponseEntity createFile(@PathVariable Long auxiliaryRepositoryId, @RequestParam("file") String filePath, HttpServletRequest request) { + return super.createFile(auxiliaryRepositoryId, filePath, request); + } + + @Override + @PostMapping(value = "{auxiliaryRepositoryId}/folder", produces = MediaType.APPLICATION_JSON_VALUE) + @EnforceAtLeastTutor + @FeatureToggle(Feature.ProgrammingExercises) + public ResponseEntity createFolder(@PathVariable Long auxiliaryRepositoryId, @RequestParam("folder") String folderPath, HttpServletRequest request) { + return super.createFolder(auxiliaryRepositoryId, folderPath, request); + } + + @Override + @PostMapping(value = "{auxiliaryRepositoryId}/rename-file", produces = MediaType.APPLICATION_JSON_VALUE) + @EnforceAtLeastTutor + @FeatureToggle(Feature.ProgrammingExercises) + public ResponseEntity renameFile(@PathVariable Long auxiliaryRepositoryId, @RequestBody FileMove fileMove) { + return super.renameFile(auxiliaryRepositoryId, fileMove); + } + + @Override + @DeleteMapping(value = "{auxiliaryRepositoryId}/file", produces = MediaType.APPLICATION_JSON_VALUE) + @EnforceAtLeastTutor + @FeatureToggle(Feature.ProgrammingExercises) + public ResponseEntity deleteFile(@PathVariable Long auxiliaryRepositoryId, @RequestParam("file") String filename) { + return super.deleteFile(auxiliaryRepositoryId, filename); + } + + @Override + @GetMapping(value = "{auxiliaryRepositoryId}/pull", produces = MediaType.APPLICATION_JSON_VALUE) + @EnforceAtLeastTutor + public ResponseEntity pullChanges(@PathVariable Long auxiliaryRepositoryId) { + return super.pullChanges(auxiliaryRepositoryId); + } + + @Override + @PostMapping(value = "{auxiliaryRepositoryId}/commit", produces = MediaType.APPLICATION_JSON_VALUE) + @EnforceAtLeastTutor + @FeatureToggle(Feature.ProgrammingExercises) + public ResponseEntity commitChanges(@PathVariable Long auxiliaryRepositoryId) { + return super.commitChanges(auxiliaryRepositoryId); + } + + @Override + @PostMapping(value = "{auxiliaryRepositoryId}/reset", produces = MediaType.APPLICATION_JSON_VALUE) + @EnforceAtLeastTutor + @FeatureToggle(Feature.ProgrammingExercises) + public ResponseEntity resetToLastCommit(@PathVariable Long auxiliaryRepositoryId) { + return super.resetToLastCommit(auxiliaryRepositoryId); + } + + @Override + @GetMapping(value = "{auxiliaryRepositoryId}", produces = MediaType.APPLICATION_JSON_VALUE) + @EnforceAtLeastTutor + public ResponseEntity getStatus(@PathVariable Long auxiliaryRepositoryId) throws GitAPIException { + return super.getStatus(auxiliaryRepositoryId); + } + + /** + * Update a list of files in an auxiliary repository based on the submission's content. + * + * @param auxiliaryRepositoryId of exercise to which the files belong + * @param submissions information about the file updates + * @param commit whether to commit after updating the files + * @param principal used to check if the user can update the files + * @return {Map} file submissions or the appropriate http error + */ + @PutMapping("{auxiliaryRepositoryId}/files") + @EnforceAtLeastTutor + public ResponseEntity> updateAuxiliaryFiles(@PathVariable("auxiliaryRepositoryId") Long auxiliaryRepositoryId, + @RequestBody List submissions, @RequestParam Boolean commit, Principal principal) { + + if (versionControlService.isEmpty()) { + throw new ResponseStatusException(HttpStatus.SERVICE_UNAVAILABLE, "VCSNotPresent"); + } + AuxiliaryRepository auxiliaryRepository = auxiliaryRepositoryRepository.findByIdElseThrow(auxiliaryRepositoryId); + ProgrammingExercise exercise = auxiliaryRepository.getExercise(); + + Repository repository; + try { + repositoryAccessService.checkAccessTestOrAuxRepositoryElseThrow(true, exercise, userRepository.getUserWithGroupsAndAuthorities(principal.getName()), "test"); + repository = gitService.getOrCheckoutRepository(auxiliaryRepository.getVcsRepositoryUri(), true); + } + catch (AccessForbiddenException e) { + FileSubmissionError error = new FileSubmissionError(auxiliaryRepositoryId, "noPermissions"); + throw new ResponseStatusException(HttpStatus.FORBIDDEN, error.getMessage(), error); + } + catch (CheckoutConflictException | WrongRepositoryStateException ex) { + FileSubmissionError error = new FileSubmissionError(auxiliaryRepositoryId, "checkoutConflict"); + throw new ResponseStatusException(HttpStatus.CONFLICT, error.getMessage(), error); + } + catch (GitAPIException ex) { + FileSubmissionError error = new FileSubmissionError(auxiliaryRepositoryId, "checkoutFailed"); + throw new ResponseStatusException(HttpStatus.SERVICE_UNAVAILABLE, error.getMessage(), error); + } + return saveFilesAndCommitChanges(auxiliaryRepositoryId, submissions, commit, repository); + } +} diff --git a/src/main/webapp/app/detail-overview-list/components/programming-auxiliary-repository-buttons-detail/programming-auxiliary-repository-buttons-detail.component.html b/src/main/webapp/app/detail-overview-list/components/programming-auxiliary-repository-buttons-detail/programming-auxiliary-repository-buttons-detail.component.html index c22ec3d50039..26cf22829413 100644 --- a/src/main/webapp/app/detail-overview-list/components/programming-auxiliary-repository-buttons-detail/programming-auxiliary-repository-buttons-detail.component.html +++ b/src/main/webapp/app/detail-overview-list/components/programming-auxiliary-repository-buttons-detail/programming-auxiliary-repository-buttons-detail.component.html @@ -1,9 +1,14 @@
    - @for (auxiliaryRepository of detail.data.auxiliaryRepositories; track auxiliaryRepository) { + @for (auxiliaryRepository of detail.data.auxiliaryRepositories; track auxiliaryRepository.id) { @if (auxiliaryRepository.id && auxiliaryRepository.repositoryUri && detail.data.exerciseId) {
  • Repository: {{ auxiliaryRepository.name }} - + {{ section.headline | artemisTranslate }}
    - @for (detail of section.details; track detail) { + @for (detail of section.details; track $index) { @if (!!detail) { @if (detail.title) {
    diff --git a/src/main/webapp/app/exercises/programming/manage/programming-exercise-management-routing.module.ts b/src/main/webapp/app/exercises/programming/manage/programming-exercise-management-routing.module.ts index cf949670dcf9..583c85ec8b49 100644 --- a/src/main/webapp/app/exercises/programming/manage/programming-exercise-management-routing.module.ts +++ b/src/main/webapp/app/exercises/programming/manage/programming-exercise-management-routing.module.ts @@ -172,6 +172,18 @@ export const routes: Routes = [ }, canActivate: [UserRouteAccessService, LocalVCGuard], }, + { + path: ':courseId/programming-exercises/:exerciseId/repository/:repositoryType/repo/:repositoryId', + component: RepositoryViewComponent, + data: { + authorities: [Authority.ADMIN, Authority.INSTRUCTOR, Authority.EDITOR, Authority.TA], + pageTitle: 'artemisApp.repository.title', + flushRepositoryCacheAfter: 900000, // 15 min + participationCache: {}, + repositoryCache: {}, + }, + canActivate: [UserRouteAccessService, LocalVCGuard], + }, { path: ':courseId/programming-exercises/:exerciseId/repository/:repositoryType/commit-history', component: CommitHistoryComponent, @@ -184,6 +196,18 @@ export const routes: Routes = [ }, canActivate: [LocalVCGuard], }, + { + path: ':courseId/programming-exercises/:exerciseId/repository/:repositoryType/repo/:repositoryId/commit-history', + component: CommitHistoryComponent, + data: { + authorities: [Authority.ADMIN, Authority.INSTRUCTOR, Authority.EDITOR], + pageTitle: 'artemisApp.repository.title', + flushRepositoryCacheAfter: 900000, // 15 min + participationCache: {}, + repositoryCache: {}, + }, + canActivate: [LocalVCGuard], + }, { path: ':courseId/programming-exercises/:exerciseId/repository/:repositoryType/vcs-access-log', component: VcsRepositoryAccessLogViewComponent, @@ -196,6 +220,18 @@ export const routes: Routes = [ }, canActivate: [LocalVCGuard], }, + { + path: ':courseId/programming-exercises/:exerciseId/repository/:repositoryType/repo/:repositoryId/vcs-access-log', + component: VcsRepositoryAccessLogViewComponent, + data: { + authorities: [Authority.ADMIN, Authority.INSTRUCTOR], + pageTitle: 'artemisApp.repository.title', + flushRepositoryCacheAfter: 900000, // 15 min + participationCache: {}, + repositoryCache: {}, + }, + canActivate: [LocalVCGuard], + }, { path: ':courseId/programming-exercises/:exerciseId/repository/:repositoryType/commit-history/:commitHash', component: CommitDetailsViewComponent, diff --git a/src/main/webapp/app/exercises/programming/manage/services/programming-exercise-participation.service.ts b/src/main/webapp/app/exercises/programming/manage/services/programming-exercise-participation.service.ts index e83414219bb6..b44b87386158 100644 --- a/src/main/webapp/app/exercises/programming/manage/services/programming-exercise-participation.service.ts +++ b/src/main/webapp/app/exercises/programming/manage/services/programming-exercise-participation.service.ts @@ -185,4 +185,16 @@ export class ProgrammingExerciseParticipationService implements IProgrammingExer retrieveCommitHistoryForTemplateSolutionOrTests(exerciseId: number, repositoryType: string): Observable { return this.http.get(`${this.resourceUrl}${exerciseId}/commit-history/${repositoryType}`); } + + /** + * Get the commit history for a specific auxiliary repository + * @param exerciseId the exercise the repository belongs to + * @param repositoryType the repositories type + * @param auxiliaryRepositoryId the id of the repository + */ + retrieveCommitHistoryForAuxiliaryRepository(exerciseId: number, auxiliaryRepositoryId: number): Observable { + const params: { [key: string]: number } = {}; + params['repositoryId'] = auxiliaryRepositoryId; + return this.http.get(`${this.resourceUrl}${exerciseId}/commit-history/AUXILIARY`, { params: params }); + } } diff --git a/src/main/webapp/app/exercises/programming/manage/services/programming-exercise.service.ts b/src/main/webapp/app/exercises/programming/manage/services/programming-exercise.service.ts index 82077ff4ffd6..5b1a793288ff 100644 --- a/src/main/webapp/app/exercises/programming/manage/services/programming-exercise.service.ts +++ b/src/main/webapp/app/exercises/programming/manage/services/programming-exercise.service.ts @@ -283,6 +283,16 @@ export class ProgrammingExerciseService { ); } + /** + * Finds the programming exercise for the given exerciseId with its auxiliary repositories + * @param programmingExerciseId of the programming exercise to retrieve + */ + findWithAuxiliaryRepository(programmingExerciseId: number): Observable { + return this.http.get(`${this.resourceUrl}/${programmingExerciseId}/with-auxiliary-repository`, { + observe: 'response', + }); + } + private setLatestResultForTemplateAndSolution(programmingExercise: ProgrammingExercise) { if (programmingExercise.templateParticipation) { const latestTemplateResult = this.getLatestResult(programmingExercise.templateParticipation); diff --git a/src/main/webapp/app/exercises/programming/shared/code-editor/model/code-editor.model.ts b/src/main/webapp/app/exercises/programming/shared/code-editor/model/code-editor.model.ts index a3215aa04c48..555b5d0e84d8 100644 --- a/src/main/webapp/app/exercises/programming/shared/code-editor/model/code-editor.model.ts +++ b/src/main/webapp/app/exercises/programming/shared/code-editor/model/code-editor.model.ts @@ -2,6 +2,7 @@ import { StudentParticipation } from 'app/entities/participation/student-partici import { TemplateProgrammingExerciseParticipation } from 'app/entities/participation/template-programming-exercise-participation.model'; import { SolutionProgrammingExerciseParticipation } from 'app/entities/participation/solution-programming-exercise-participation.model'; import { ProgrammingExercise } from 'app/entities/programming/programming-exercise.model'; +import { AuxiliaryRepository } from 'app/entities/programming/programming-exercise-auxiliary-repository-model'; /** * Enumeration defining type of the exported file. @@ -49,6 +50,7 @@ export type FileSubmission = { [fileName: string]: string | undefined }; export enum DomainType { PARTICIPATION = 'PARTICIPATION', TEST_REPOSITORY = 'TEST_REPOSITORY', + AUXILIARY_REPOSITORY = 'AUXILIARY_REPOSITORY', } /** @@ -94,7 +96,8 @@ export enum ResizeType { export type DomainParticipationChange = [DomainType.PARTICIPATION, StudentParticipation | TemplateProgrammingExerciseParticipation | SolutionProgrammingExerciseParticipation]; export type DomainTestRepositoryChange = [DomainType.TEST_REPOSITORY, ProgrammingExercise]; -export type DomainChange = DomainParticipationChange | DomainTestRepositoryChange; +export type DomainAuxiliaryRepositoryChange = [DomainType.AUXILIARY_REPOSITORY, AuxiliaryRepository]; +export type DomainChange = DomainParticipationChange | DomainTestRepositoryChange | DomainAuxiliaryRepositoryChange; /** * Enumeration defining the state of git. diff --git a/src/main/webapp/app/exercises/programming/shared/code-editor/service/code-editor-conflict-state.service.ts b/src/main/webapp/app/exercises/programming/shared/code-editor/service/code-editor-conflict-state.service.ts index eba9259107c1..a045a3b2b62b 100644 --- a/src/main/webapp/app/exercises/programming/shared/code-editor/service/code-editor-conflict-state.service.ts +++ b/src/main/webapp/app/exercises/programming/shared/code-editor/service/code-editor-conflict-state.service.ts @@ -66,6 +66,9 @@ export class CodeEditorConflictStateService extends DomainDependentService imple private getDomainKey = () => { const [domainType, domainValue] = this.domain; + if (domainType === DomainType.AUXILIARY_REPOSITORY) { + return `auxiliary-${domainValue.id!.toString()}`; + } return `${domainType === DomainType.PARTICIPATION ? 'participation' : 'test'}-${domainValue.id!.toString()}`; }; } diff --git a/src/main/webapp/app/exercises/programming/shared/code-editor/service/code-editor-domain-dependent-endpoint.service.ts b/src/main/webapp/app/exercises/programming/shared/code-editor/service/code-editor-domain-dependent-endpoint.service.ts index fe6e71ac586a..b124042fec13 100644 --- a/src/main/webapp/app/exercises/programming/shared/code-editor/service/code-editor-domain-dependent-endpoint.service.ts +++ b/src/main/webapp/app/exercises/programming/shared/code-editor/service/code-editor-domain-dependent-endpoint.service.ts @@ -35,6 +35,8 @@ export abstract class DomainDependentEndpointService extends DomainDependentServ return `api/repository/${domainValue.id}`; case DomainType.TEST_REPOSITORY: return `api/test-repository/${domainValue.id}`; + case DomainType.AUXILIARY_REPOSITORY: + return `api/auxiliary-repository/${domainValue.id}`; } } } diff --git a/src/main/webapp/app/exercises/programming/shared/code-editor/service/code-editor-domain.service.ts b/src/main/webapp/app/exercises/programming/shared/code-editor/service/code-editor-domain.service.ts index 84d080b67efb..cebaae778af9 100644 --- a/src/main/webapp/app/exercises/programming/shared/code-editor/service/code-editor-domain.service.ts +++ b/src/main/webapp/app/exercises/programming/shared/code-editor/service/code-editor-domain.service.ts @@ -1,6 +1,11 @@ import { Injectable } from '@angular/core'; import { BehaviorSubject, Observable } from 'rxjs'; -import { DomainChange, DomainParticipationChange, DomainTestRepositoryChange } from 'app/exercises/programming/shared/code-editor/model/code-editor.model'; +import { + DomainAuxiliaryRepositoryChange, + DomainChange, + DomainParticipationChange, + DomainTestRepositoryChange, +} from 'app/exercises/programming/shared/code-editor/model/code-editor.model'; /** * This service provides subscribing services with the most recently selected domain (participation vs repository). @@ -9,7 +14,7 @@ import { DomainChange, DomainParticipationChange, DomainTestRepositoryChange } f @Injectable({ providedIn: 'root' }) export class DomainService { protected domain: DomainChange; - private subject = new BehaviorSubject(undefined); + private subject = new BehaviorSubject(undefined); constructor() {} diff --git a/src/main/webapp/app/localvc/commit-history/commit-history.component.ts b/src/main/webapp/app/localvc/commit-history/commit-history.component.ts index fe98dd02cb53..f734f9f2eec3 100644 --- a/src/main/webapp/app/localvc/commit-history/commit-history.component.ts +++ b/src/main/webapp/app/localvc/commit-history/commit-history.component.ts @@ -24,6 +24,7 @@ export class CommitHistoryComponent implements OnInit, OnDestroy { participationId: number; exerciseId: number; repositoryType: string; + repositoryId?: number; paramSub: Subscription; commits: CommitInfo[]; commitsInfoSubscription: Subscription; @@ -53,6 +54,7 @@ export class CommitHistoryComponent implements OnInit, OnDestroy { this.participationId = Number(params['participationId']); this.exerciseId = Number(params['exerciseId']); this.repositoryType = params['repositoryType']; + this.repositoryId = Number(params['repositoryId']); if (this.repositoryType) { this.loadDifferentParticipation(); } else { @@ -88,6 +90,8 @@ export class CommitHistoryComponent implements OnInit, OnDestroy { } else if (this.repositoryType === 'TESTS') { this.isTestRepository = true; this.participation = this.exercise.templateParticipation!; + } else if (this.repositoryType === 'AUXILIARY') { + this.participation = this.exercise.templateParticipation!; } }), ) @@ -121,26 +125,59 @@ export class CommitHistoryComponent implements OnInit, OnDestroy { } /** - * Retrieves the commit history for the participation and filters out the commits that have no submission. + * Retrieves the commit history and handles it depending on repository type * The last commit is always the template commit and is added to the list of commits. * @private */ private handleCommits() { - if (this.repositoryType) { - this.commitsInfoSubscription = this.programmingExerciseParticipationService - .retrieveCommitHistoryForTemplateSolutionOrTests(this.exerciseId, this.repositoryType) - .subscribe((commits) => { - this.commits = this.sortCommitsByTimestampDesc(commits); - if (!this.isTestRepository) { - this.setCommitResults(); - } - }); + if (!this.repositoryType) { + this.handleParticipationCommits(); + } else if (this.repositoryType === 'AUXILIARY') { + this.handleAuxiliaryRepositoryCommits(); } else { - this.commitsInfoSubscription = this.programmingExerciseParticipationService.retrieveCommitHistoryForParticipation(this.participation.id!).subscribe((commits) => { + this.handleTemplateSolutionTestRepositoryCommits(); + } + } + + /** + * Retrieves the commit history and filters out the commits that have no submission. + * The last commit is always the template commit and is added to the list of commits. + * @private + */ + private handleParticipationCommits() { + this.commitsInfoSubscription = this.programmingExerciseParticipationService.retrieveCommitHistoryForParticipation(this.participation.id!).subscribe((commits) => { + this.commits = this.sortCommitsByTimestampDesc(commits); + this.setCommitResults(); + }); + } + + /** + * Retrieves the commit history for an auxiliary repository + * The last commit is always the template commit and is added to the list of commits. + * @private + */ + private handleAuxiliaryRepositoryCommits() { + this.commitsInfoSubscription = this.programmingExerciseParticipationService + .retrieveCommitHistoryForAuxiliaryRepository(this.exerciseId, this.repositoryId!) + .subscribe((commits) => { this.commits = this.sortCommitsByTimestampDesc(commits); - this.setCommitResults(); }); - } + } + + /** + * Retrieves the commit history for template/solution/test repositories. + * The last commit is always the template commit and is added to the list of commits. + * @private + */ + private handleTemplateSolutionTestRepositoryCommits() { + this.commitsInfoSubscription = this.programmingExerciseParticipationService + .retrieveCommitHistoryForTemplateSolutionOrTests(this.exerciseId, this.repositoryType) + .subscribe((commits) => { + this.commits = this.sortCommitsByTimestampDesc(commits); + if (!this.isTestRepository) { + this.setCommitResults(); + } + }); } /** diff --git a/src/main/webapp/app/localvc/repository-view/repository-view.component.ts b/src/main/webapp/app/localvc/repository-view/repository-view.component.ts index 72e9bbdfc3ad..8e0b537d7abd 100644 --- a/src/main/webapp/app/localvc/repository-view/repository-view.component.ts +++ b/src/main/webapp/app/localvc/repository-view/repository-view.component.ts @@ -91,11 +91,14 @@ export class RepositoryViewComponent implements OnInit, OnDestroy { this.participationCouldNotBeFetched = false; const exerciseId = Number(params['exerciseId']); const participationId = Number(params['participationId']); + const repositoryId = Number(params['repositoryId']); this.repositoryType = participationId ? 'USER' : params['repositoryType']; this.vcsAccessLogRoute = this.router.url + '/vcs-access-log'; this.enableVcsAccessLog = this.router.url.includes('course-management') && params['repositoryType'] !== 'TESTS'; if (this.repositoryType === 'USER') { this.loadStudentParticipation(participationId); + } else if (this.repositoryType === 'AUXILIARY') { + this.loadAuxiliaryRepository(exerciseId, repositoryId); } else { this.loadDifferentParticipation(this.repositoryType, exerciseId); } @@ -190,4 +193,27 @@ export class RepositoryViewComponent implements OnInit, OnDestroy { }), ); } + + private loadAuxiliaryRepository(exerciseId: number, auxiliaryRepositoryId: number) { + this.programmingExerciseService + .findWithAuxiliaryRepository(exerciseId) + .pipe( + tap((exerciseResponse) => { + this.exercise = exerciseResponse.body!; + const auxiliaryRepo = this.exercise.auxiliaryRepositories?.find((repo) => repo.id === auxiliaryRepositoryId); + if (auxiliaryRepo) { + this.domainService.setDomain([DomainType.AUXILIARY_REPOSITORY, auxiliaryRepo]); + this.repositoryUri = auxiliaryRepo.repositoryUri!; + } + }), + ) + .subscribe({ + next: () => { + this.loadingParticipation = false; + }, + error: () => { + this.participationCouldNotBeFetched = true; + }, + }); + } } diff --git a/src/test/java/de/tum/cit/aet/artemis/exercise/programming/AuxiliaryRepositoryResourceIntegrationTest.java b/src/test/java/de/tum/cit/aet/artemis/exercise/programming/AuxiliaryRepositoryResourceIntegrationTest.java new file mode 100644 index 000000000000..e8f6ae3c38ec --- /dev/null +++ b/src/test/java/de/tum/cit/aet/artemis/exercise/programming/AuxiliaryRepositoryResourceIntegrationTest.java @@ -0,0 +1,552 @@ +package de.tum.cit.aet.artemis.exercise.programming; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.any; +import static org.mockito.Mockito.argThat; +import static org.mockito.Mockito.doReturn; +import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.eq; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.reset; + +import java.io.IOException; +import java.nio.charset.Charset; +import java.nio.file.Files; +import java.nio.file.Path; +import java.time.ZonedDateTime; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.Optional; + +import org.apache.commons.io.FileUtils; +import org.eclipse.jgit.api.ListBranchCommand; +import org.eclipse.jgit.api.MergeResult; +import org.eclipse.jgit.api.errors.TransportException; +import org.eclipse.jgit.api.errors.WrongRepositoryStateException; +import org.eclipse.jgit.lib.Ref; +import org.eclipse.jgit.merge.MergeStrategy; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +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; + +import de.tum.cit.aet.artemis.core.domain.Course; +import de.tum.cit.aet.artemis.programming.domain.AuxiliaryRepository; +import de.tum.cit.aet.artemis.programming.domain.File; +import de.tum.cit.aet.artemis.programming.domain.FileType; +import de.tum.cit.aet.artemis.programming.domain.ProgrammingExercise; +import de.tum.cit.aet.artemis.programming.domain.Repository; +import de.tum.cit.aet.artemis.programming.dto.FileMove; +import de.tum.cit.aet.artemis.programming.dto.RepositoryStatusDTO; +import de.tum.cit.aet.artemis.programming.repository.AuxiliaryRepositoryRepository; +import de.tum.cit.aet.artemis.programming.repository.ProgrammingExerciseBuildConfigRepository; +import de.tum.cit.aet.artemis.programming.service.GitService; +import de.tum.cit.aet.artemis.programming.test_repository.ProgrammingExerciseTestRepository; +import de.tum.cit.aet.artemis.programming.util.GitUtilService; +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.web.repository.FileSubmission; +import de.tum.cit.aet.artemis.shared.base.AbstractSpringIntegrationJenkinsGitlabTest; + +class AuxiliaryRepositoryResourceIntegrationTest extends AbstractSpringIntegrationJenkinsGitlabTest { + + private static final String TEST_PREFIX = "auxiliaryrepositoryresourceint"; + + @Autowired + private ProgrammingExerciseTestRepository programmingExerciseRepository; + + @Autowired + private ProgrammingExerciseBuildConfigRepository programmingExerciseBuildConfigRepository; + + @Autowired + private AuxiliaryRepositoryRepository auxiliaryRepositoryRepository; + + private final String testRepoBaseUrl = "/api/auxiliary-repository/"; + + private ProgrammingExercise programmingExercise; + + private AuxiliaryRepository auxiliaryRepository; + + private final String currentLocalFileName = "currentFileName"; + + private final String currentLocalFileContent = "testContent"; + + private final String currentLocalFolderName = "currentFolderName"; + + private final LocalRepository localAuxiliaryRepo = new LocalRepository(defaultBranch); + + private GitUtilService.MockFileRepositoryUri auxRepoUri; + + @BeforeEach + void setup() throws Exception { + userUtilService.addUsers(TEST_PREFIX, 1, 1, 0, 1); + Course course = courseUtilService.addEmptyCourse(); + programmingExercise = ProgrammingExerciseFactory.generateProgrammingExercise(ZonedDateTime.now().minusDays(1), ZonedDateTime.now().plusDays(7), course); + programmingExercise.setBuildConfig(programmingExerciseBuildConfigRepository.save(programmingExercise.getBuildConfig())); + + // Instantiate the remote repository as non-bare so its files can be manipulated + localAuxiliaryRepo.configureRepos("auxLocalRepo", "auxOriginRepo", false); + + // add file to the repository folder + Path filePath = Path.of(localAuxiliaryRepo.localRepoFile + "/" + currentLocalFileName); + var file = Files.createFile(filePath).toFile(); + // write content to the created file + FileUtils.write(file, currentLocalFileContent, Charset.defaultCharset()); + + // add folder to the repository folder + filePath = Path.of(localAuxiliaryRepo.localRepoFile + "/" + currentLocalFolderName); + Files.createDirectory(filePath); + + // add the auxiliary repository + auxiliaryRepositoryRepository.deleteAll(); + auxRepoUri = new GitUtilService.MockFileRepositoryUri(localAuxiliaryRepo.localRepoFile); + programmingExercise.setTestRepositoryUri(auxRepoUri.toString()); + var newAuxiliaryRepo = new AuxiliaryRepository(); + newAuxiliaryRepo.setName("AuxiliaryRepo"); + newAuxiliaryRepo.setRepositoryUri(auxRepoUri.toString()); + newAuxiliaryRepo.setCheckoutDirectory(localAuxiliaryRepo.localRepoFile.toPath().toString()); + newAuxiliaryRepo.setExercise(programmingExercise); + programmingExercise.setAuxiliaryRepositories(List.of(newAuxiliaryRepo)); + programmingExercise = programmingExerciseRepository.save(programmingExercise); + auxiliaryRepository = programmingExercise.getAuxiliaryRepositories().getFirst(); + + doReturn(gitService.getExistingCheckedOutRepositoryByLocalPath(localAuxiliaryRepo.localRepoFile.toPath(), null)).when(gitService).getOrCheckoutRepository(auxRepoUri, true); + doReturn(gitService.getExistingCheckedOutRepositoryByLocalPath(localAuxiliaryRepo.localRepoFile.toPath(), null)).when(gitService).getOrCheckoutRepository(auxRepoUri, + false); + + doReturn(gitService.getExistingCheckedOutRepositoryByLocalPath(localAuxiliaryRepo.localRepoFile.toPath(), null)).when(gitService).getOrCheckoutRepository(eq(auxRepoUri), + eq(true), any()); + doReturn(gitService.getExistingCheckedOutRepositoryByLocalPath(localAuxiliaryRepo.localRepoFile.toPath(), null)).when(gitService).getOrCheckoutRepository(eq(auxRepoUri), + eq(false), any()); + + doReturn(defaultBranch).when(versionControlService).getOrRetrieveBranchOfExercise(programmingExercise); + } + + @AfterEach + void tearDown() throws IOException { + reset(gitService); + localAuxiliaryRepo.resetLocalRepo(); + } + + @Test + @WithMockUser(username = TEST_PREFIX + "instructor1", roles = "INSTRUCTOR") + void testGetFiles() throws Exception { + programmingExerciseRepository.save(programmingExercise); + var files = request.getMap(testRepoBaseUrl + auxiliaryRepository.getId() + "/files", HttpStatus.OK, String.class, FileType.class); + assertThat(files).isNotEmpty(); + + // Check if all files exist + for (String key : files.keySet()) { + assertThat(Path.of(localAuxiliaryRepo.localRepoFile + "/" + key)).exists(); + } + } + + @Test + @WithMockUser(username = TEST_PREFIX + "student1", roles = "STUDENT") + void testGetFilesAsStudent_accessForbidden() throws Exception { + programmingExerciseRepository.save(programmingExercise); + request.getMap(testRepoBaseUrl + auxiliaryRepository.getId() + "/files", HttpStatus.FORBIDDEN, String.class, FileType.class); + } + + @Test + @WithMockUser(username = TEST_PREFIX + "instructor1", roles = "INSTRUCTOR") + void testGetFilesAsInstructor_checkoutConflict() throws Exception { + programmingExerciseRepository.save(programmingExercise); + doThrow(new WrongRepositoryStateException("conflict")).when(gitService).getOrCheckoutRepository(auxRepoUri, true); + + request.getMap(testRepoBaseUrl + auxiliaryRepository.getId() + "/files", HttpStatus.CONFLICT, String.class, FileType.class); + } + + @Test + @WithMockUser(username = TEST_PREFIX + "instructor1", roles = "INSTRUCTOR") + void testGetFile() throws Exception { + programmingExerciseRepository.save(programmingExercise); + LinkedMultiValueMap params = new LinkedMultiValueMap<>(); + params.add("file", currentLocalFileName); + var file = request.get(testRepoBaseUrl + auxiliaryRepository.getId() + "/file", HttpStatus.OK, byte[].class, params); + assertThat(file).isNotEmpty(); + assertThat(Path.of(localAuxiliaryRepo.localRepoFile + "/" + currentLocalFileName)).exists(); + assertThat(new String(file)).isEqualTo(currentLocalFileContent); + } + + @Test + @WithMockUser(username = TEST_PREFIX + "instructor1", roles = "INSTRUCTOR") + void testCreateFile() throws Exception { + programmingExerciseRepository.save(programmingExercise); + LinkedMultiValueMap params = new LinkedMultiValueMap<>(); + assertThat(Path.of(localAuxiliaryRepo.localRepoFile + "/newFile")).doesNotExist(); + params.add("file", "newFile"); + request.postWithoutResponseBody(testRepoBaseUrl + auxiliaryRepository.getId() + "/file", HttpStatus.OK, params); + assertThat(Path.of(localAuxiliaryRepo.localRepoFile + "/newFile")).isRegularFile(); + } + + @Test + @WithMockUser(username = TEST_PREFIX + "instructor1", roles = "INSTRUCTOR") + void testCreateFile_alreadyExists() throws Exception { + programmingExerciseRepository.save(programmingExercise); + LinkedMultiValueMap params = new LinkedMultiValueMap<>(); + assertThat((Path.of(localAuxiliaryRepo.localRepoFile + "/newFile"))).doesNotExist(); + params.add("file", "newFile"); + + doReturn(Optional.of(true)).when(gitService).getFileByName(any(), any()); + request.postWithoutResponseBody(testRepoBaseUrl + auxiliaryRepository.getId() + "/file", HttpStatus.BAD_REQUEST, params); + } + + @Test + @WithMockUser(username = TEST_PREFIX + "instructor1", roles = "INSTRUCTOR") + void testCreateFile_invalidRepository() throws Exception { + programmingExerciseRepository.save(programmingExercise); + LinkedMultiValueMap params = new LinkedMultiValueMap<>(); + assertThat((Path.of(localAuxiliaryRepo.localRepoFile + "/newFile"))).doesNotExist(); + params.add("file", "newFile"); + + Repository mockRepository = mock(Repository.class); + doReturn(mockRepository).when(gitService).getOrCheckoutRepository(any(), eq(true)); + doReturn(localAuxiliaryRepo.localRepoFile.toPath()).when(mockRepository).getLocalPath(); + doReturn(false).when(mockRepository).isValidFile(any()); + request.postWithoutResponseBody(testRepoBaseUrl + auxiliaryRepository.getId() + "/file", HttpStatus.BAD_REQUEST, params); + } + + @Test + @WithMockUser(username = TEST_PREFIX + "instructor1", roles = "INSTRUCTOR") + void testCreateFolder() throws Exception { + programmingExerciseRepository.save(programmingExercise); + LinkedMultiValueMap params = new LinkedMultiValueMap<>(); + assertThat(Path.of(localAuxiliaryRepo.localRepoFile + "/newFolder")).doesNotExist(); + params.add("folder", "newFolder"); + request.postWithoutResponseBody(testRepoBaseUrl + auxiliaryRepository.getId() + "/folder", HttpStatus.OK, params); + assertThat(Path.of(localAuxiliaryRepo.localRepoFile + "/newFolder")).isDirectory(); + } + + @Test + @WithMockUser(username = TEST_PREFIX + "instructor1", roles = "INSTRUCTOR") + void testRenameFile() throws Exception { + programmingExerciseRepository.save(programmingExercise); + assertThat((Path.of(localAuxiliaryRepo.localRepoFile + "/" + currentLocalFileName))).exists(); + String newLocalFileName = "newFileName"; + assertThat(Path.of(localAuxiliaryRepo.localRepoFile + "/" + newLocalFileName)).doesNotExist(); + FileMove fileMove = new FileMove(currentLocalFileName, newLocalFileName); + request.postWithoutLocation(testRepoBaseUrl + auxiliaryRepository.getId() + "/rename-file", fileMove, HttpStatus.OK, null); + assertThat(Path.of(localAuxiliaryRepo.localRepoFile + "/" + currentLocalFileName)).doesNotExist(); + assertThat(Path.of(localAuxiliaryRepo.localRepoFile + "/" + newLocalFileName)).exists(); + } + + @Test + @WithMockUser(username = TEST_PREFIX + "instructor1", roles = "INSTRUCTOR") + void testRenameFile_alreadyExists() throws Exception { + programmingExerciseRepository.save(programmingExercise); + FileMove fileMove = createRenameFileMove(); + + doReturn(Optional.empty()).when(gitService).getFileByName(any(), any()); + request.postWithoutLocation(testRepoBaseUrl + auxiliaryRepository.getId() + "/rename-file", fileMove, HttpStatus.NOT_FOUND, null); + } + + @Test + @WithMockUser(username = TEST_PREFIX + "instructor1", roles = "INSTRUCTOR") + void testRenameFile_invalidExistingFile() throws Exception { + programmingExerciseRepository.save(programmingExercise); + FileMove fileMove = createRenameFileMove(); + + doReturn(Optional.of(localAuxiliaryRepo.localRepoFile)).when(gitService).getFileByName(any(), eq(fileMove.currentFilePath())); + + Repository mockRepository = mock(Repository.class); + doReturn(mockRepository).when(gitService).getOrCheckoutRepository(any(), eq(true)); + doReturn(localAuxiliaryRepo.localRepoFile.toPath()).when(mockRepository).getLocalPath(); + doReturn(false).when(mockRepository).isValidFile(argThat(file -> file.getName().contains(currentLocalFileName))); + request.postWithoutLocation(testRepoBaseUrl + auxiliaryRepository.getId() + "/rename-file", fileMove, HttpStatus.BAD_REQUEST, null); + } + + private FileMove createRenameFileMove() { + String newLocalFileName = "newFileName"; + + assertThat(Path.of(localAuxiliaryRepo.localRepoFile + "/" + currentLocalFileName)).exists(); + assertThat(Path.of(localAuxiliaryRepo.localRepoFile + "/" + newLocalFileName)).doesNotExist(); + + return new FileMove(currentLocalFileName, newLocalFileName); + } + + @Test + @WithMockUser(username = TEST_PREFIX + "instructor1", roles = "INSTRUCTOR") + void testRenameFolder() throws Exception { + programmingExerciseRepository.save(programmingExercise); + assertThat(Path.of(localAuxiliaryRepo.localRepoFile + "/" + currentLocalFolderName)).exists(); + String newLocalFolderName = "newFolderName"; + assertThat(Path.of(localAuxiliaryRepo.localRepoFile + "/" + newLocalFolderName)).doesNotExist(); + FileMove fileMove = new FileMove(currentLocalFolderName, newLocalFolderName); + request.postWithoutLocation(testRepoBaseUrl + auxiliaryRepository.getId() + "/rename-file", fileMove, HttpStatus.OK, null); + assertThat(Path.of(localAuxiliaryRepo.localRepoFile + "/" + currentLocalFolderName)).doesNotExist(); + assertThat(Path.of(localAuxiliaryRepo.localRepoFile + "/" + newLocalFolderName)).exists(); + } + + @Test + @WithMockUser(username = TEST_PREFIX + "instructor1", roles = "INSTRUCTOR") + void testDeleteFile() throws Exception { + programmingExerciseRepository.save(programmingExercise); + LinkedMultiValueMap params = new LinkedMultiValueMap<>(); + assertThat(Path.of(localAuxiliaryRepo.localRepoFile + "/" + currentLocalFileName)).exists(); + params.add("file", currentLocalFileName); + request.delete(testRepoBaseUrl + auxiliaryRepository.getId() + "/file", HttpStatus.OK, params); + assertThat(Path.of(localAuxiliaryRepo.localRepoFile + "/" + currentLocalFileName)).doesNotExist(); + } + + @Test + @WithMockUser(username = TEST_PREFIX + "instructor1", roles = "INSTRUCTOR") + void testDeleteFile_notFound() throws Exception { + programmingExerciseRepository.save(programmingExercise); + LinkedMultiValueMap params = new LinkedMultiValueMap<>(); + assertThat(Path.of(localAuxiliaryRepo.localRepoFile + "/" + currentLocalFileName)).exists(); + params.add("file", currentLocalFileName); + + doReturn(Optional.empty()).when(gitService).getFileByName(any(), any()); + + request.delete(testRepoBaseUrl + auxiliaryRepository.getId() + "/file", HttpStatus.NOT_FOUND, params); + } + + @Test + @WithMockUser(username = TEST_PREFIX + "instructor1", roles = "INSTRUCTOR") + void testDeleteFile_invalidFile() throws Exception { + programmingExerciseRepository.save(programmingExercise); + LinkedMultiValueMap params = new LinkedMultiValueMap<>(); + assertThat(Path.of(localAuxiliaryRepo.localRepoFile + "/" + currentLocalFileName)).exists(); + params.add("file", currentLocalFileName); + + doReturn(Optional.of(localAuxiliaryRepo.localRepoFile)).when(gitService).getFileByName(any(), eq(currentLocalFileName)); + + Repository mockRepository = mock(Repository.class); + doReturn(mockRepository).when(gitService).getOrCheckoutRepository(any(), eq(true)); + doReturn(localAuxiliaryRepo.localRepoFile.toPath()).when(mockRepository).getLocalPath(); + doReturn(false).when(mockRepository).isValidFile(argThat(file -> file.getName().contains(currentLocalFileName))); + + request.delete(testRepoBaseUrl + auxiliaryRepository.getId() + "/file", HttpStatus.BAD_REQUEST, params); + } + + @Test + @WithMockUser(username = TEST_PREFIX + "instructor1", roles = "INSTRUCTOR") + void testDeleteFile_validFile() throws Exception { + programmingExerciseRepository.save(programmingExercise); + LinkedMultiValueMap params = new LinkedMultiValueMap<>(); + assertThat(Path.of(localAuxiliaryRepo.localRepoFile + "/" + currentLocalFileName)).exists(); + params.add("file", currentLocalFileName); + + File mockFile = mock(File.class); + doReturn(Optional.of(mockFile)).when(gitService).getFileByName(any(), eq(currentLocalFileName)); + doReturn(currentLocalFileName).when(mockFile).getName(); + doReturn(false).when(mockFile).isFile(); + + Repository mockRepository = mock(Repository.class); + doReturn(mockRepository).when(gitService).getOrCheckoutRepository(any(), eq(true)); + doReturn(localAuxiliaryRepo.localRepoFile.toPath()).when(mockRepository).getLocalPath(); + doReturn(true).when(mockRepository).isValidFile(argThat(file -> file.getName().contains(currentLocalFileName))); + + request.delete(testRepoBaseUrl + auxiliaryRepository.getId() + "/file", HttpStatus.OK, params); + } + + @Test + @WithMockUser(username = TEST_PREFIX + "instructor1", roles = "INSTRUCTOR") + void testCommitChanges() throws Exception { + programmingExerciseRepository.save(programmingExercise); + var receivedStatusBeforeCommit = request.get(testRepoBaseUrl + auxiliaryRepository.getId(), HttpStatus.OK, RepositoryStatusDTO.class); + assertThat(receivedStatusBeforeCommit.repositoryStatus()).hasToString("UNCOMMITTED_CHANGES"); + request.postWithoutLocation(testRepoBaseUrl + auxiliaryRepository.getId() + "/commit", null, HttpStatus.OK, null); + var receivedStatusAfterCommit = request.get(testRepoBaseUrl + auxiliaryRepository.getId(), HttpStatus.OK, RepositoryStatusDTO.class); + assertThat(receivedStatusAfterCommit.repositoryStatus()).hasToString("CLEAN"); + var testRepoCommits = localAuxiliaryRepo.getAllLocalCommits(); + assertThat(testRepoCommits).hasSize(1); + assertThat(userUtilService.getUserByLogin(TEST_PREFIX + "instructor1").getName()).isEqualTo(testRepoCommits.getFirst().getAuthorIdent().getName()); + } + + private List getFileSubmissions() { + List fileSubmissions = new ArrayList<>(); + FileSubmission fileSubmission = new FileSubmission(); + fileSubmission.setFileName(currentLocalFileName); + fileSubmission.setFileContent("updatedFileContent"); + fileSubmissions.add(fileSubmission); + return fileSubmissions; + } + + @Test + @WithMockUser(username = TEST_PREFIX + "instructor1", roles = "INSTRUCTOR") + void testSaveFiles() throws Exception { + programmingExerciseRepository.save(programmingExercise); + assertThat(Path.of(localAuxiliaryRepo.localRepoFile + "/" + currentLocalFileName)).exists(); + request.put(testRepoBaseUrl + auxiliaryRepository.getId() + "/files?commit=false", getFileSubmissions(), HttpStatus.OK); + + Path filePath = Path.of(localAuxiliaryRepo.localRepoFile + "/" + currentLocalFileName); + assertThat(filePath).hasContent("updatedFileContent"); + } + + @Test + @WithMockUser(username = TEST_PREFIX + "instructor1", roles = "INSTRUCTOR") + void testSaveFilesAndCommit() throws Exception { + programmingExerciseRepository.save(programmingExercise); + assertThat(Path.of(localAuxiliaryRepo.localRepoFile + "/" + currentLocalFileName)).exists(); + + var receivedStatusBeforeCommit = request.get(testRepoBaseUrl + auxiliaryRepository.getId(), HttpStatus.OK, RepositoryStatusDTO.class); + assertThat(receivedStatusBeforeCommit.repositoryStatus()).hasToString("UNCOMMITTED_CHANGES"); + + request.put(testRepoBaseUrl + auxiliaryRepository.getId() + "/files?commit=true", getFileSubmissions(), HttpStatus.OK); + + var receivedStatusAfterCommit = request.get(testRepoBaseUrl + auxiliaryRepository.getId(), HttpStatus.OK, RepositoryStatusDTO.class); + assertThat(receivedStatusAfterCommit.repositoryStatus()).hasToString("CLEAN"); + + Path filePath = Path.of(localAuxiliaryRepo.localRepoFile + "/" + currentLocalFileName); + assertThat(filePath).hasContent("updatedFileContent"); + + var testRepoCommits = localAuxiliaryRepo.getAllLocalCommits(); + assertThat(testRepoCommits).hasSize(1); + assertThat(userUtilService.getUserByLogin(TEST_PREFIX + "instructor1").getName()).isEqualTo(testRepoCommits.getFirst().getAuthorIdent().getName()); + } + + @Test + @WithMockUser(username = TEST_PREFIX + "student1", roles = "INSTRUCTOR") + void testSaveFiles_accessForbidden() throws Exception { + programmingExerciseRepository.save(programmingExercise); + // student1 should not have access to instructor1's tests repository even if they assume an INSTRUCTOR role. + request.put(testRepoBaseUrl + auxiliaryRepository.getId() + "/files?commit=true", List.of(), HttpStatus.FORBIDDEN); + } + + @Test + @WithMockUser(username = TEST_PREFIX + "instructor1", roles = "INSTRUCTOR") + void testSaveFiles_conflict() throws Exception { + programmingExerciseRepository.save(programmingExercise); + doThrow(new WrongRepositoryStateException("conflict")).when(gitService).getOrCheckoutRepository(auxRepoUri, true); + + request.put(testRepoBaseUrl + auxiliaryRepository.getId() + "/files?commit=true", List.of(), HttpStatus.CONFLICT); + } + + @Test + @WithMockUser(username = TEST_PREFIX + "instructor1", roles = "INSTRUCTOR") + void testSaveFiles_serviceUnavailable() throws Exception { + programmingExerciseRepository.save(programmingExercise); + doThrow(new TransportException("unavailable")).when(gitService).getOrCheckoutRepository(auxRepoUri, true); + + request.put(testRepoBaseUrl + auxiliaryRepository.getId() + "/files?commit=true", List.of(), HttpStatus.SERVICE_UNAVAILABLE); + } + + @Test + @WithMockUser(username = TEST_PREFIX + "instructor1", roles = "INSTRUCTOR") + void testPullChanges() throws Exception { + programmingExerciseRepository.save(programmingExercise); + String fileName = "remoteFile"; + + // Create a commit for the local and the remote repository + request.postWithoutLocation(testRepoBaseUrl + auxiliaryRepository.getId() + "/commit", null, HttpStatus.OK, null); + try (var remoteRepository = gitService.getExistingCheckedOutRepositoryByLocalPath(localAuxiliaryRepo.originRepoFile.toPath(), null)) { + + // Create file in the remote repository + Path filePath = Path.of(localAuxiliaryRepo.originRepoFile + "/" + fileName); + Files.createFile(filePath); + + // Check if the file exists in the remote repository and that it doesn't yet exist in the local repository + assertThat(Path.of(localAuxiliaryRepo.originRepoFile + "/" + fileName)).exists(); + assertThat(Path.of(localAuxiliaryRepo.localRepoFile + "/" + fileName)).doesNotExist(); + + // Stage all changes and make a second commit in the remote repository + gitService.stageAllChanges(remoteRepository); + GitService.commit(localAuxiliaryRepo.originGit).setMessage("TestCommit").setAllowEmpty(true).setCommitter("testname", "test@email").call(); + + // Checks if the current commit is not equal on the local and the remote repository + assertThat(localAuxiliaryRepo.getAllLocalCommits().getFirst()).isNotEqualTo(localAuxiliaryRepo.getAllOriginCommits().getFirst()); + + // Execute the Rest call + request.get(testRepoBaseUrl + auxiliaryRepository.getId() + "/pull", HttpStatus.OK, Void.class); + + // Check if the current commit is the same on the local and the remote repository and if the file exists on the local repository + assertThat(localAuxiliaryRepo.getAllLocalCommits().getFirst()).isEqualTo(localAuxiliaryRepo.getAllOriginCommits().getFirst()); + assertThat(Path.of(localAuxiliaryRepo.localRepoFile + "/" + fileName)).exists(); + } + } + + @Test + @WithMockUser(username = TEST_PREFIX + "instructor1", roles = "INSTRUCTOR") + void testResetToLastCommit() throws Exception { + programmingExerciseRepository.save(programmingExercise); + String fileName = "testFile"; + try (var localRepo = gitService.getExistingCheckedOutRepositoryByLocalPath(localAuxiliaryRepo.localRepoFile.toPath(), null); + var remoteRepo = gitService.getExistingCheckedOutRepositoryByLocalPath(localAuxiliaryRepo.originRepoFile.toPath(), null)) { + + // Check status of git before the commit + var receivedStatusBeforeCommit = request.get(testRepoBaseUrl + auxiliaryRepository.getId(), HttpStatus.OK, RepositoryStatusDTO.class); + assertThat(receivedStatusBeforeCommit.repositoryStatus()).hasToString("UNCOMMITTED_CHANGES"); + + // Create a commit for the local and the remote repository + request.postWithoutLocation(testRepoBaseUrl + auxiliaryRepository.getId() + "/commit", null, HttpStatus.OK, null); + + // Check status of git after the commit + var receivedStatusAfterCommit = request.get(testRepoBaseUrl + auxiliaryRepository.getId(), HttpStatus.OK, RepositoryStatusDTO.class); + assertThat(receivedStatusAfterCommit.repositoryStatus()).hasToString("CLEAN"); + + // Create file in the local repository and commit it + Path localFilePath = Path.of(localAuxiliaryRepo.localRepoFile + "/" + fileName); + var localFile = Files.createFile(localFilePath).toFile(); + // write content to the created file + FileUtils.write(localFile, "local", Charset.defaultCharset()); + gitService.stageAllChanges(localRepo); + GitService.commit(localAuxiliaryRepo.localGit).setMessage("local").call(); + + // Create file in the remote repository and commit it + Path remoteFilePath = Path.of(localAuxiliaryRepo.originRepoFile + "/" + fileName); + var remoteFile = Files.createFile(remoteFilePath).toFile(); + // write content to the created file + FileUtils.write(remoteFile, "remote", Charset.defaultCharset()); + gitService.stageAllChanges(remoteRepo); + GitService.commit(localAuxiliaryRepo.originGit).setMessage("remote").call(); + + // Merge the two and a conflict will occur + localAuxiliaryRepo.localGit.fetch().setRemote("origin").call(); + List refs = localAuxiliaryRepo.localGit.branchList().setListMode(ListBranchCommand.ListMode.REMOTE).call(); + var result = localAuxiliaryRepo.localGit.merge().include(refs.getFirst().getObjectId()).setStrategy(MergeStrategy.RESOLVE).call(); + var status = localAuxiliaryRepo.localGit.status().call(); + assertThat(status.getConflicting()).isNotEmpty(); + assertThat(result.getMergeStatus()).isEqualTo(MergeResult.MergeStatus.CONFLICTING); + + // Execute the reset Rest call + request.postWithoutLocation(testRepoBaseUrl + auxiliaryRepository.getId() + "/reset", null, HttpStatus.OK, null); + + // Check the git status after the reset + status = localAuxiliaryRepo.localGit.status().call(); + assertThat(status.getConflicting()).isEmpty(); + assertThat(localAuxiliaryRepo.getAllLocalCommits().getFirst()).isEqualTo(localAuxiliaryRepo.getAllOriginCommits().getFirst()); + var receivedStatusAfterReset = request.get(testRepoBaseUrl + auxiliaryRepository.getId(), HttpStatus.OK, RepositoryStatusDTO.class); + assertThat(receivedStatusAfterReset.repositoryStatus()).hasToString("CLEAN"); + } + } + + @Test + @WithMockUser(username = TEST_PREFIX + "instructor1", roles = "INSTRUCTOR") + void testGetStatus() throws Exception { + programmingExerciseRepository.save(programmingExercise); + var receivedStatusBeforeCommit = request.get(testRepoBaseUrl + auxiliaryRepository.getId(), HttpStatus.OK, RepositoryStatusDTO.class); + + // The current status is "uncommited changes", since we added files and folders, but we didn't commit yet + assertThat(receivedStatusBeforeCommit.repositoryStatus()).hasToString("UNCOMMITTED_CHANGES"); + + // Perform a commit to check if the status changes + request.postWithoutLocation(testRepoBaseUrl + auxiliaryRepository.getId() + "/commit", null, HttpStatus.OK, null); + + // Check if the status of git is "clean" after the commit + var receivedStatusAfterCommit = request.get(testRepoBaseUrl + auxiliaryRepository.getId(), HttpStatus.OK, RepositoryStatusDTO.class); + assertThat(receivedStatusAfterCommit.repositoryStatus()).hasToString("CLEAN"); + } + + @Test + @WithMockUser(username = TEST_PREFIX + "student1", roles = "INSTRUCTOR") + void testGetStatus_cannotAccessRepository() throws Exception { + programmingExerciseRepository.save(programmingExercise); + // student1 should not have access to instructor1's tests repository even if they assume the role of an INSTRUCTOR. + request.get(testRepoBaseUrl + auxiliaryRepository.getId(), HttpStatus.FORBIDDEN, RepositoryStatusDTO.class); + } + + @Test + @WithMockUser(username = TEST_PREFIX + "instructor1", roles = "INSTRUCTOR") + void testIsClean() throws Exception { + programmingExerciseRepository.save(programmingExercise); + doReturn(true).when(gitService).isRepositoryCached(any()); + var status = request.get(testRepoBaseUrl + auxiliaryRepository.getId(), HttpStatus.OK, Map.class); + assertThat(status).isNotEmpty(); + } +} diff --git a/src/test/java/de/tum/cit/aet/artemis/programming/ProgrammingExerciseIntegrationTestService.java b/src/test/java/de/tum/cit/aet/artemis/programming/ProgrammingExerciseIntegrationTestService.java index 7bfbb4253069..49696af61d96 100644 --- a/src/test/java/de/tum/cit/aet/artemis/programming/ProgrammingExerciseIntegrationTestService.java +++ b/src/test/java/de/tum/cit/aet/artemis/programming/ProgrammingExerciseIntegrationTestService.java @@ -2188,6 +2188,14 @@ private AuxiliaryRepository addAuxiliaryRepositoryToExercise() { return repository; } + public void addAuxiliaryRepositoryToExercise(ProgrammingExercise exercise) { + AuxiliaryRepository repository = AuxiliaryRepositoryBuilder.defaults().get(); + auxiliaryRepositoryRepository.save(repository); + exercise.setAuxiliaryRepositories(new ArrayList<>()); + exercise.addAuxiliaryRepository(repository); + programmingExerciseRepository.save(exercise); + } + private String defaultAuxiliaryRepositoryEndpoint() { return "/api/programming-exercises/setup"; } diff --git a/src/test/java/de/tum/cit/aet/artemis/programming/ProgrammingExerciseParticipationIntegrationTest.java b/src/test/java/de/tum/cit/aet/artemis/programming/ProgrammingExerciseParticipationIntegrationTest.java index fb6d19002d15..1e47b62e649b 100644 --- a/src/test/java/de/tum/cit/aet/artemis/programming/ProgrammingExerciseParticipationIntegrationTest.java +++ b/src/test/java/de/tum/cit/aet/artemis/programming/ProgrammingExerciseParticipationIntegrationTest.java @@ -1,9 +1,13 @@ package de.tum.cit.aet.artemis.programming; import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.doNothing; import static org.mockito.Mockito.doReturn; import static org.mockito.Mockito.doThrow; +import java.io.IOException; +import java.net.URISyntaxException; import java.time.ZoneId; import java.time.ZonedDateTime; import java.util.HashMap; @@ -11,8 +15,10 @@ import java.util.Map; import java.util.stream.Stream; +import org.eclipse.jgit.api.errors.GitAPIException; import org.eclipse.jgit.api.errors.NoHeadException; import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; import org.junit.jupiter.params.ParameterizedTest; import org.junit.jupiter.params.provider.Arguments; @@ -35,11 +41,15 @@ import de.tum.cit.aet.artemis.exercise.test_repository.ParticipationTestRepository; import de.tum.cit.aet.artemis.exercise.test_repository.StudentParticipationTestRepository; import de.tum.cit.aet.artemis.programming.domain.ProgrammingExercise; +import de.tum.cit.aet.artemis.programming.domain.ProgrammingExerciseParticipation; import de.tum.cit.aet.artemis.programming.domain.ProgrammingExerciseStudentParticipation; import de.tum.cit.aet.artemis.programming.domain.ProgrammingSubmission; +import de.tum.cit.aet.artemis.programming.domain.Repository; import de.tum.cit.aet.artemis.programming.domain.SolutionProgrammingExerciseParticipation; import de.tum.cit.aet.artemis.programming.domain.TemplateProgrammingExerciseParticipation; +import de.tum.cit.aet.artemis.programming.domain.VcsRepositoryUri; import de.tum.cit.aet.artemis.programming.dto.CommitInfoDTO; +import de.tum.cit.aet.artemis.programming.repository.AuxiliaryRepositoryRepository; import de.tum.cit.aet.artemis.programming.test_repository.ProgrammingExerciseStudentParticipationTestRepository; import de.tum.cit.aet.artemis.programming.test_repository.ProgrammingExerciseTestRepository; import de.tum.cit.aet.artemis.programming.util.ProgrammingExerciseUtilService; @@ -71,6 +81,13 @@ class ProgrammingExerciseParticipationIntegrationTest extends AbstractSpringInte @Autowired private ParticipationUtilService participationUtilService; + @Autowired + private AuxiliaryRepositoryRepository auxiliaryRepositoryRepository; + + // TODO remove again after refactoring and cleanup + @Autowired + private ProgrammingExerciseIntegrationTestService programmingExerciseIntegrationTestService; + private ProgrammingExercise programmingExercise; private Participation programmingExerciseParticipation; @@ -81,6 +98,7 @@ void initTestCase() { var course = programmingExerciseUtilService.addCourseWithOneProgrammingExerciseAndTestCases(); programmingExercise = exerciseUtilService.getFirstExerciseWithType(course, ProgrammingExercise.class); programmingExercise = programmingExerciseRepository.findWithEagerStudentParticipationsById(programmingExercise.getId()).orElseThrow(); + programmingExerciseIntegrationTestService.addAuxiliaryRepositoryToExercise(programmingExercise); } private static Stream argumentsForGetParticipationResults() { @@ -704,6 +722,144 @@ void checkResetRepository_exam_badRequest() throws Exception { request.put("/api/programming-exercise-participations/" + programmingExerciseParticipation.getId() + "/reset-repository", null, HttpStatus.BAD_REQUEST); } + /** + * TODO move the following test into a different test file, as they do not use the programming-exercise-participations/.. endpoint, but programming-exercise/.. + * move the endpoint itself too + *

    + * Test for GET - programming-exercise/{exerciseID}/commit-history/{repositoryType} + */ + @Nested + class GetCommitHistoryForTemplateSolutionTestOrAuxRepo { + + String PATH_PREFIX; + + ProgrammingExercise programmingExerciseWithAuxRepo; + + @BeforeEach + void setup() throws GitAPIException { + userUtilService.addUsers(TEST_PREFIX, 4, 2, 0, 2); + var course = programmingExerciseUtilService.addCourseWithOneProgrammingExerciseAndTestCases(); + programmingExerciseWithAuxRepo = exerciseUtilService.getFirstExerciseWithType(course, ProgrammingExercise.class); + programmingExerciseWithAuxRepo = programmingExerciseRepository.findWithEagerStudentParticipationsById(programmingExerciseWithAuxRepo.getId()).orElseThrow(); + programmingExerciseIntegrationTestService.addAuxiliaryRepositoryToExercise(programmingExerciseWithAuxRepo); + + doThrow(new NoHeadException("error")).when(gitService).getCommitInfos(any()); + PATH_PREFIX = "/api/programming-exercise/" + programmingExerciseWithAuxRepo.getId() + "/commit-history/"; + } + + @Test + @WithMockUser(username = TEST_PREFIX + "instructor1", roles = "INSTRUCTOR") + void shouldReturnBadRequestForInvalidRepositoryType() throws Exception { + request.getList(PATH_PREFIX + "INVALIDTYPE", HttpStatus.BAD_REQUEST, CommitInfoDTO.class); + } + + @Test + @WithMockUser(username = TEST_PREFIX + "instructor1", roles = "INSTRUCTOR") + void shouldGetListForTemplateRepository() throws Exception { + assertThat(request.getList(PATH_PREFIX + "TEMPLATE", HttpStatus.OK, CommitInfoDTO.class)).isEmpty(); + } + + @Test + @WithMockUser(username = TEST_PREFIX + "instructor1", roles = "INSTRUCTOR") + void shouldGetListForSolutionRepository() throws Exception { + assertThat(request.getList(PATH_PREFIX + "SOLUTION", HttpStatus.OK, CommitInfoDTO.class)).isEmpty(); + } + + @Test + @WithMockUser(username = TEST_PREFIX + "instructor1", roles = "INSTRUCTOR") + void shouldGetListForTestsRepository() throws Exception { + assertThat(request.getList(PATH_PREFIX + "TESTS", HttpStatus.OK, CommitInfoDTO.class)).isEmpty(); + } + + @Test + @WithMockUser(username = TEST_PREFIX + "instructor1", roles = "INSTRUCTOR") + void shouldGetListForAuxiliaryRepository() throws Exception { + assertThat(request.getList(PATH_PREFIX + "AUXILIARY?repositoryId=" + programmingExerciseWithAuxRepo.getAuxiliaryRepositories().getFirst().getId(), HttpStatus.OK, + CommitInfoDTO.class)).isEmpty(); + } + + @Test + @WithMockUser(username = TEST_PREFIX + "instructor1", roles = "INSTRUCTOR") + void shouldThrowWithInvalidAuxiliaryRepositoryId() throws Exception { + request.getList(PATH_PREFIX + "AUXILIARY?repositoryId=" + 128, HttpStatus.NOT_FOUND, CommitInfoDTO.class); + } + } + + /** + * Tests for programming-exercise-participations/{participationId}/files-content/{commitId} + */ + @Test + @WithMockUser(username = TEST_PREFIX + "instructor1", roles = "INSTRUCTOR") + void getParticipationRepositoryFilesInstructorSuccess() throws Exception { + var commitHash = "commitHash"; + var participation = participationUtilService.addStudentParticipationForProgrammingExercise(programmingExercise, TEST_PREFIX + "student1"); + var commitInfo = new CommitInfoDTO("hash", "msg1", ZonedDateTime.of(2020, 1, 1, 0, 0, 0, 0, ZoneId.of("UTC")), "author", "authorEmail"); + var commitInfo2 = new CommitInfoDTO("hash2", "msg2", ZonedDateTime.of(2020, 1, 2, 0, 0, 0, 0, ZoneId.of("UTC")), "author2", "authorEmail2"); + doReturn(List.of(commitInfo, commitInfo2)).when(gitService).getCommitInfos(participation.getVcsRepositoryUri()); + doReturn(new Repository("ab", new VcsRepositoryUri("uri"))).when(gitService).checkoutRepositoryAtCommit(participation.getVcsRepositoryUri(), commitHash, true); + doReturn(Map.of()).when(gitService).listFilesAndFolders(any()); + doNothing().when(gitService).switchBackToDefaultBranchHead(any()); + + request.getMap("/api/programming-exercise-participations/" + participation.getId() + "/files-content/" + commitHash, HttpStatus.OK, String.class, String.class); + } + + /** + * TODO refactor endpoint to contain participation -> programming-exercise-participations + * tests GET - programming-exercise/{exerciseId}/files-content-commit-details/{commitId} + */ + @Nested + class GetParticipationRepositoryFilesForCommitsDetailsView { + + String PATH_PREFIX; + + String COMMIT_HASH; + + ProgrammingExerciseParticipation participation; + + @BeforeEach + void setup() throws GitAPIException, URISyntaxException, IOException { + userUtilService.addUsers(TEST_PREFIX, 4, 2, 0, 2); + participation = participationUtilService.addStudentParticipationForProgrammingExercise(programmingExercise, TEST_PREFIX + "student1"); + var course = programmingExerciseUtilService.addCourseWithOneProgrammingExerciseAndTestCases(); + programmingExercise = exerciseUtilService.getFirstExerciseWithType(course, ProgrammingExercise.class); + programmingExercise = programmingExerciseRepository.findWithEagerStudentParticipationsById(programmingExercise.getId()).orElseThrow(); + programmingExerciseIntegrationTestService.addAuxiliaryRepositoryToExercise(programmingExercise); + COMMIT_HASH = "commitHash"; + + doReturn(Map.of()).when(gitService).listFilesAndFolders(any()); + doNothing().when(gitService).switchBackToDefaultBranchHead(any()); + doReturn(new Repository("ab", new VcsRepositoryUri("uri"))).when(gitService).checkoutRepositoryAtCommit(any(VcsRepositoryUri.class), any(String.class), + any(Boolean.class)); + doThrow(new NoHeadException("error")).when(gitService).getCommitInfos(any()); + PATH_PREFIX = "/api/programming-exercise/" + participation.getProgrammingExercise().getId() + "/files-content-commit-details/" + COMMIT_HASH; + } + + @Test + @WithMockUser(username = TEST_PREFIX + "instructor1", roles = "INSTRUCTOR") + void shouldReturnBadRequestWithoutAnyProvidedParameters() throws Exception { + request.getMap(PATH_PREFIX, HttpStatus.BAD_REQUEST, String.class, String.class); + } + + @Test + @WithMockUser(username = TEST_PREFIX + "instructor1", roles = "INSTRUCTOR") + void shouldReturnForParticipation() throws Exception { + assertThat(request.getMap(PATH_PREFIX + "?participationId=" + participation.getId(), HttpStatus.OK, String.class, String.class)).isEmpty(); + } + + @Test + @WithMockUser(username = TEST_PREFIX + "instructor1", roles = "INSTRUCTOR") + void shouldReturnFilesForTemplateRepository() throws Exception { + assertThat(request.getMap(PATH_PREFIX + "?repositoryType=TEMPLATE", HttpStatus.OK, String.class, String.class)).isEmpty(); + } + + @Test + @WithMockUser(username = TEST_PREFIX + "instructor1", roles = "INSTRUCTOR") + void shouldReturnFilesForSolutionRepository() throws Exception { + assertThat(request.getMap(PATH_PREFIX + "?repositoryType=SOLUTION", HttpStatus.OK, String.class, String.class)).isEmpty(); + } + + } + @Test @WithMockUser(username = TEST_PREFIX + "instructor1", roles = "INSTRUCTOR") void retrieveCommitInfoInstructorSuccess() throws Exception { diff --git a/src/test/javascript/spec/component/localvc/commit-history.component.spec.ts b/src/test/javascript/spec/component/localvc/commit-history.component.spec.ts index 8c3a628980a6..672cfc49982f 100644 --- a/src/test/javascript/spec/component/localvc/commit-history.component.spec.ts +++ b/src/test/javascript/spec/component/localvc/commit-history.component.spec.ts @@ -272,4 +272,25 @@ describe('CommitHistoryComponent', () => { expect(component.paramSub?.closed).toBeTrue(); expect(component.participationSub?.closed).toBeTrue(); }); + + it('should load auxiliary repository commits', () => { + setupComponent(); + activatedRoute.setParameters({ repositoryType: 'AUXILIARY', repositoryId: 5 }); + jest.spyOn(programmingExerciseParticipationService, 'retrieveCommitHistoryForAuxiliaryRepository').mockReturnValue(of(mockTestCommits)); + + // Trigger ngOnInit + component.ngOnInit(); + + // Expectations + expect(component.commits).toEqual(mockTestCommits); // Updated to reflect the correct order + expect(component.commits[0].result).toBeUndefined(); + expect(component.commits[1].result).toBeUndefined(); + + // Trigger ngOnDestroy + component.ngOnDestroy(); + + // Expect subscription to be unsubscribed + expect(component.paramSub?.closed).toBeTrue(); + expect(component.participationSub?.closed).toBeTrue(); + }); }); diff --git a/src/test/javascript/spec/component/localvc/repository-view.component.spec.ts b/src/test/javascript/spec/component/localvc/repository-view.component.spec.ts index ff004d03c91b..512e1abae71a 100644 --- a/src/test/javascript/spec/component/localvc/repository-view.component.spec.ts +++ b/src/test/javascript/spec/component/localvc/repository-view.component.spec.ts @@ -18,6 +18,7 @@ import { DueDateStat } from 'app/course/dashboards/due-date-stat.model'; import { ProgrammingExerciseStudentParticipation } from 'app/entities/participation/programming-exercise-student-participation.model'; import { ProfileService } from 'app/shared/layouts/profiles/profile.service'; import { MockProfileService } from '../../helpers/mocks/service/mock-profile.service'; +import { AuxiliaryRepository } from 'app/entities/programming/programming-exercise-auxiliary-repository-model'; describe('RepositoryViewComponent', () => { let component: RepositoryViewComponent; @@ -170,6 +171,44 @@ describe('RepositoryViewComponent', () => { expect(component.paramSub?.closed).toBeTrue(); }); + it('should load AUXILIARY repository type', () => { + // Mock exercise and participation data + const mockAuxiliaryRepository: AuxiliaryRepository = { id: 5, repositoryUri: 'repositoryUri', checkoutDirectory: 'dir', name: 'AuxRepo', description: 'description' }; + const mockExercise: ProgrammingExercise = { + id: 1, + numberOfAssessmentsOfCorrectionRounds: [new DueDateStat()], + auxiliaryRepositories: [mockAuxiliaryRepository], + studentAssignedTeamIdComputed: true, + secondCorrectionEnabled: true, + }; + const mockExerciseResponse: HttpResponse = new HttpResponse({ body: mockExercise }); + const exerciseId = 1; + const auxiliaryRepositoryId = 5; + + activatedRoute.setParameters({ exerciseId: exerciseId, repositoryType: 'AUXILIARY', repositoryId: auxiliaryRepositoryId }); + jest.spyOn(programmingExerciseService, 'findWithAuxiliaryRepository').mockReturnValue(of(mockExerciseResponse)); + + // Trigger ngOnInit + component.ngOnInit(); + + // Expect loadingParticipation to be false after loading + expect(component.loadingParticipation).toBeFalse(); + + // Expect exercise and participation to be set correctly + expect(component.exercise).toEqual(mockExercise); + expect(component.participation).toBeUndefined(); + + // Expect domainService method to be called with the correct arguments + expect(component.domainService.setDomain).toHaveBeenCalledWith([DomainType.AUXILIARY_REPOSITORY, mockAuxiliaryRepository]); + + // Trigger ngOnDestroy + component.ngOnDestroy(); + + // Expect subscription to be unsubscribed + expect(component.differentParticipationSub?.closed).toBeTrue(); + expect(component.paramSub?.closed).toBeTrue(); + }); + it('should handle unknown repository type', () => { // Mock exercise and participation data const mockExercise: ProgrammingExercise = { diff --git a/src/test/javascript/spec/helpers/mocks/service/mock-programming-exercise-participation.service.ts b/src/test/javascript/spec/helpers/mocks/service/mock-programming-exercise-participation.service.ts index 472de3e839b5..3bca2029aa5c 100644 --- a/src/test/javascript/spec/helpers/mocks/service/mock-programming-exercise-participation.service.ts +++ b/src/test/javascript/spec/helpers/mocks/service/mock-programming-exercise-participation.service.ts @@ -11,6 +11,7 @@ export class MockProgrammingExerciseParticipationService implements IProgramming getStudentParticipationWithAllResults = (participationId: number) => of({} as ProgrammingExerciseStudentParticipation); retrieveCommitHistoryForParticipation = (participationId: number) => of([] as CommitInfo[]); retrieveCommitHistoryForTemplateSolutionOrTests = (participationId: number, repositoryType: string) => of([] as CommitInfo[]); + retrieveCommitHistoryForAuxiliaryRepository = (exerciseId: number, repositoryId: number) => of([] as CommitInfo[]); getParticipationRepositoryFilesWithContentAtCommitForCommitDetailsView = (exerciseId: number, participationId: number, commitId: string, repositoryType: string) => of(new Map()); checkIfParticipationHasResult = (participationId: number) => of(true); diff --git a/src/test/javascript/spec/helpers/mocks/service/mock-programming-exercise.service.ts b/src/test/javascript/spec/helpers/mocks/service/mock-programming-exercise.service.ts index b164cc8e700c..27c111ba7665 100644 --- a/src/test/javascript/spec/helpers/mocks/service/mock-programming-exercise.service.ts +++ b/src/test/javascript/spec/helpers/mocks/service/mock-programming-exercise.service.ts @@ -2,12 +2,14 @@ import { of } from 'rxjs'; import { ProgrammingExerciseInstructorRepositoryType } from 'app/exercises/programming/manage/services/programming-exercise.service'; import { Participation } from 'app/entities/participation/participation.model'; import { ProgrammingLanguage } from 'app/entities/programming/programming-exercise.model'; +import { ProgrammingExerciseStudentParticipation } from 'app/entities/participation/programming-exercise-student-participation.model'; export class MockProgrammingExerciseService { updateProblemStatement = (exerciseId: number, problemStatement: string) => of(); findWithTemplateAndSolutionParticipation = (exerciseId: number) => of(); findWithTemplateAndSolutionParticipationAndResults = (exerciseId: number) => of(); findWithTemplateAndSolutionParticipationAndLatestResults = (exerciseId: number) => of(); + findWithAuxiliaryRepository = (programmingExerciseId: number) => of(); find = (exerciseId: number) => of({ body: { id: 4 } }); getProgrammingExerciseTestCaseState = (exerciseId: number) => of({ body: { released: true, hasStudentResult: true, testCasesChanged: false } }); exportInstructorExercise = (exerciseId: number) => of({ body: undefined }); diff --git a/src/test/javascript/spec/service/programming-exercise.service.spec.ts b/src/test/javascript/spec/service/programming-exercise.service.spec.ts index 9d450bbf7f8f..bcc07eeecb3a 100644 --- a/src/test/javascript/spec/service/programming-exercise.service.spec.ts +++ b/src/test/javascript/spec/service/programming-exercise.service.spec.ts @@ -20,6 +20,7 @@ import { SolutionProgrammingExerciseParticipation } from 'app/entities/participa import { Submission } from 'app/entities/submission.model'; import { ProgrammingExerciseGitDiffReport } from 'app/entities/hestia/programming-exercise-git-diff-report.model'; import { ProgrammingExerciseGitDiffEntry } from 'app/entities/hestia/programming-exercise-git-diff-entry.model'; +import { AuxiliaryRepository } from 'app/entities/programming/programming-exercise-auxiliary-repository-model'; import { provideHttpClient } from '@angular/common/http'; describe('ProgrammingExercise Service', () => { @@ -89,6 +90,27 @@ describe('ProgrammingExercise Service', () => { tick(); })); + it('should find with auxiliary repositories', fakeAsync(() => { + const auxiliaryRepository: AuxiliaryRepository = { id: 5 }; + const returnedFromService = { + ...defaultProgrammingExercise, + auxiliaryRepositories: [auxiliaryRepository], + releaseDate: undefined, + dueDate: undefined, + assessmentDueDate: undefined, + buildAndTestStudentSubmissionsAfterDueDate: undefined, + studentParticipations: [], + }; + const expected = { ...returnedFromService }; + service + .findWithAuxiliaryRepository(returnedFromService.id!) + .pipe(take(1)) + .subscribe((resp) => expect(resp.body).toEqual(expected)); + const req = httpMock.expectOne({ method: 'GET' }); + req.flush(returnedFromService); + tick(); + })); + it('should create a ProgrammingExercise', fakeAsync(() => { const returnedFromService = { ...defaultProgrammingExercise,