diff --git a/src/main/java/de/tum/in/www1/artemis/service/exam/ExamAccessService.java b/src/main/java/de/tum/in/www1/artemis/service/exam/ExamAccessService.java index b1f5e37b9a79..0beec6700dfc 100644 --- a/src/main/java/de/tum/in/www1/artemis/service/exam/ExamAccessService.java +++ b/src/main/java/de/tum/in/www1/artemis/service/exam/ExamAccessService.java @@ -81,8 +81,9 @@ public StudentExam getExamInCourseElseThrow(Long courseId, Long examId) { Exam examWithExerciseGroupsAndExercises = examRepository.findWithExerciseGroupsAndExercisesByIdOrElseThrow(examId); if (!examWithExerciseGroupsAndExercises.isTestExam()) { + // We skip the alert since this can happen when a tutor sees the exam card or the user did not participate yet is registered for the exam throw new BadRequestAlertException("The requested Exam is no test exam and thus no student exam can be created", ENTITY_NAME, - "StudentExamGenerationOnlyForTestExams"); + "StudentExamGenerationOnlyForTestExams", true); } studentExam = studentExamService.generateTestExam(examWithExerciseGroupsAndExercises, currentUser); // For the start of the exam, the exercises are not needed. They are later loaded via StudentExamResource diff --git a/src/main/java/de/tum/in/www1/artemis/web/rest/ExamResource.java b/src/main/java/de/tum/in/www1/artemis/web/rest/ExamResource.java index fe8898f682a6..25ba7982aa8c 100644 --- a/src/main/java/de/tum/in/www1/artemis/web/rest/ExamResource.java +++ b/src/main/java/de/tum/in/www1/artemis/web/rest/ExamResource.java @@ -1114,7 +1114,7 @@ public ResponseEntity removeAllStudentsFromExam(@PathVariable Long courseI } /** - * GET /courses/{courseId}/exams/{examId}/start : Get an exam for the exam start. + * GET /courses/{courseId}/exams/{examId}/own-student-exam: Get the own student exam for the exam * Real Exams: StudentExam needs to be generated by an instructor * Test Exam: StudentExam can be self-created by the user * Note: The Access control is performed in the {@link ExamAccessService#getExamInCourseElseThrow(Long, Long)} to limit the DB-calls @@ -1123,9 +1123,9 @@ public ResponseEntity removeAllStudentsFromExam(@PathVariable Long courseI * @param examId the id of the exam * @return the ResponseEntity with status 200 (OK) and with the found student exam (without exercises) as body */ - @GetMapping("courses/{courseId}/exams/{examId}/start") + @GetMapping("courses/{courseId}/exams/{examId}/own-student-exam") @EnforceAtLeastStudent - public ResponseEntity getStudentExamForStart(@PathVariable Long courseId, @PathVariable Long examId) { + public ResponseEntity getOwnStudentExam(@PathVariable Long courseId, @PathVariable Long examId) { log.debug("REST request to get exam {} for conduction", examId); StudentExam exam = examAccessService.getExamInCourseElseThrow(courseId, examId); return ResponseEntity.ok(exam); diff --git a/src/main/webapp/app/exam/participate/exam-participation.component.html b/src/main/webapp/app/exam/participate/exam-participation.component.html index a82f18f8e971..a5632f264ec6 100644 --- a/src/main/webapp/app/exam/participate/exam-participation.component.html +++ b/src/main/webapp/app/exam/participate/exam-participation.component.html @@ -149,7 +149,16 @@

-
-
-
-
+ + + +
+
+
+
+
diff --git a/src/main/webapp/app/exam/participate/exam-participation.component.ts b/src/main/webapp/app/exam/participate/exam-participation.component.ts index 6d3c5f4ef94f..999a4c25f80f 100644 --- a/src/main/webapp/app/exam/participate/exam-participation.component.ts +++ b/src/main/webapp/app/exam/participate/exam-participation.component.ts @@ -32,7 +32,9 @@ import { ExamPage } from 'app/entities/exam-page.model'; import { ExamPageComponent } from 'app/exam/participate/exercises/exam-page.component'; import { AUTOSAVE_CHECK_INTERVAL, AUTOSAVE_EXERCISE_INTERVAL } from 'app/shared/constants/exercise-exam-constants'; import { CourseExerciseService } from 'app/exercises/shared/course-exercises/course-exercise.service'; -import { faCheckCircle } from '@fortawesome/free-solid-svg-icons'; +import { faCheckCircle, faGraduationCap } from '@fortawesome/free-solid-svg-icons'; +import { CourseManagementService } from 'app/course/manage/course-management.service'; +import { CourseStorageService } from 'app/course/manage/course-storage.service'; import { ExamLiveEventType, ExamParticipationLiveEventsService, WorkingTimeUpdateEvent } from 'app/exam/participate/exam-participation-live-events.service'; type GenerateParticipationStatus = 'generating' | 'failed' | 'success'; @@ -114,9 +116,13 @@ export class ExamParticipationComponent implements OnInit, OnDestroy, ComponentC private programmingSubmissionSubscriptions: Subscription[] = []; loadingExam: boolean; + isAtLeastTutor?: boolean; generateParticipationStatus: BehaviorSubject = new BehaviorSubject('success'); + // Icons + faGraduationCap = faGraduationCap; + constructor( private websocketService: JhiWebsocketService, private route: ActivatedRoute, @@ -130,6 +136,8 @@ export class ExamParticipationComponent implements OnInit, OnDestroy, ComponentC private alertService: AlertService, private courseExerciseService: CourseExerciseService, private liveEventsService: ExamParticipationLiveEventsService, + private courseService: CourseManagementService, + private courseStorageService: CourseStorageService, ) { // show only one synchronization error every 5s this.errorSubscription = this.synchronizationAlert.pipe(throttleTime(5000)).subscribe(() => { @@ -167,37 +175,13 @@ export class ExamParticipationComponent implements OnInit, OnDestroy, ComponentC error: () => (this.loadingExam = false), }); } else { - this.examParticipationService.loadStudentExam(this.courseId, this.examId).subscribe({ + this.examParticipationService.getOwnStudentExam(this.courseId, this.examId).subscribe({ next: (studentExam) => { - this.studentExam = studentExam; - this.exam = studentExam.exam!; - this.testExam = this.exam.testExam!; - if (!this.exam.testExam) { - this.initIndividualEndDates(this.exam.startDate!); - } - - // only show the summary if the student was able to submit on time. - if (this.isOver() && this.studentExam.submitted) { - this.loadAndDisplaySummary(); - } else { - // Directly start the exam when we continue from a failed save - if (this.examParticipationService.lastSaveFailed(this.courseId, this.examId)) { - this.examParticipationService - .loadStudentExamWithExercisesForConductionFromLocalStorage(this.courseId, this.examId) - .subscribe((localExam: StudentExam) => { - // Keep the working time from the server - localExam.workingTime = this.studentExam.workingTime ?? localExam.workingTime; - - this.studentExam = localExam; - this.loadingExam = false; - this.examStarted(this.studentExam); - }); - } else { - this.loadingExam = false; - } - } + this.handleStudentExam(studentExam); + }, + error: () => { + this.handleNoStudentExam(); }, - error: () => (this.loadingExam = false), }); } }); @@ -405,7 +389,7 @@ export class ExamParticipationComponent implements OnInit, OnDestroy, ComponentC }, }); } else { - this.examParticipationService.loadStudentExam(this.courseId, this.examId).subscribe({ + this.examParticipationService.getOwnStudentExam(this.courseId, this.examId).subscribe({ next: (existingExam: StudentExam) => { this.studentExam = existingExam; }, @@ -518,6 +502,50 @@ export class ExamParticipationComponent implements OnInit, OnDestroy, ComponentC window.clearInterval(this.autoSaveInterval); } + handleStudentExam(studentExam: StudentExam) { + this.studentExam = studentExam; + this.exam = studentExam.exam!; + this.testExam = this.exam.testExam!; + if (!this.exam.testExam) { + this.initIndividualEndDates(this.exam.startDate!); + } + + // only show the summary if the student was able to submit on time. + if (this.isOver() && this.studentExam.submitted) { + this.loadAndDisplaySummary(); + } else { + // Directly start the exam when we continue from a failed save + if (this.examParticipationService.lastSaveFailed(this.courseId, this.examId)) { + this.examParticipationService.loadStudentExamWithExercisesForConductionFromLocalStorage(this.courseId, this.examId).subscribe((localExam: StudentExam) => { + // Keep the working time from the server + localExam.workingTime = this.studentExam.workingTime ?? localExam.workingTime; + + this.studentExam = localExam; + this.loadingExam = false; + this.examStarted(this.studentExam); + }); + } else { + this.loadingExam = false; + } + } + } + + /** + * Handles the case when there is no student exam. Here we have to check if the user is at least tutor to show the redirect to the exam management page. + * This check is not done in the normal case due to performance reasons of 2000 students sending additional requests + */ + handleNoStudentExam() { + const course = this.courseStorageService.getCourse(this.courseId); + if (!course) { + this.courseService.find(this.courseId).subscribe((courseResponse) => { + this.isAtLeastTutor = courseResponse.body?.isAtLeastTutor; + }); + } else { + this.isAtLeastTutor = course.isAtLeastTutor; + } + this.loadingExam = false; + } + /** * Initializes the individual end dates and sets up a subscription for potential changes during the conduction * @param startDate the start date of the exam diff --git a/src/main/webapp/app/exam/participate/exam-participation.service.ts b/src/main/webapp/app/exam/participate/exam-participation.service.ts index 73cd394243eb..9780daa931df 100644 --- a/src/main/webapp/app/exam/participate/exam-participation.service.ts +++ b/src/main/webapp/app/exam/participate/exam-participation.service.ts @@ -117,8 +117,8 @@ export class ExamParticipationService { * @param courseId the id of the course the exam is created in * @param examId the id of the exam */ - public loadStudentExam(courseId: number, examId: number): Observable { - const url = this.getResourceURL(courseId, examId) + '/start'; + public getOwnStudentExam(courseId: number, examId: number): Observable { + const url = this.getResourceURL(courseId, examId) + '/own-student-exam'; return this.httpClient.get(url).pipe( map((studentExam: StudentExam) => { const convertedStudentExam = ExamParticipationService.convertStudentExamDateFromServer(studentExam); diff --git a/src/main/webapp/app/overview/course-exams/course-exam-detail/course-exam-detail.component.html b/src/main/webapp/app/overview/course-exams/course-exam-detail/course-exam-detail.component.html index 1f7490c49fe3..7e582409e42c 100644 --- a/src/main/webapp/app/overview/course-exams/course-exam-detail/course-exam-detail.component.html +++ b/src/main/webapp/app/overview/course-exams/course-exam-detail/course-exam-detail.component.html @@ -50,7 +50,6 @@
{{ 'artemisApp.exam.overview.' + (exam.testExam ? 'testExam.' : '') + 'conducting' | artemisTranslate }}
-
{{ 'artemisApp.exam.overview.' + (exam.testExam ? 'testExam.' : '') + 'conductingExplanation' | artemisTranslate }}
{{ 'artemisApp.exam.overview.timeExtension' | artemisTranslate }}
@@ -58,15 +57,14 @@
{{ 'artemisApp.exam.overview.timeExtension' | artemisTra
{{ 'artemisApp.exam.overview.' + (exam.testExam ? 'testExam.' : '') + 'closed' | artemisTranslate }}
-
{{ 'artemisApp.exam.overview.' + (exam.testExam ? 'testExam.' : '') + 'closedExplanation' | artemisTranslate }}
{{ 'artemisApp.exam.overview.review' | artemisTranslate }}
{{ 'artemisApp.exam.overview.reviewExplanation' | artemisTranslate }} {{ exam.examStudentReviewEnd | artemisDate }}
-
{{ 'artemisApp.exam.overview.noMoreAttempts' | artemisTranslate }}
-
{{ 'artemisApp.exam.overview.noMoreAttemptsExplanation' | artemisTranslate }}
+
{{ 'artemisApp.exam.overview.testExam.noMoreAttempts' | artemisTranslate }}
+
{{ 'artemisApp.exam.overview.testExam.noMoreAttemptsExplanation' | artemisTranslate }}
diff --git a/src/main/webapp/app/overview/course-exams/course-exam-detail/course-exam-detail.component.ts b/src/main/webapp/app/overview/course-exams/course-exam-detail/course-exam-detail.component.ts index f7ec02dcc90b..4d2b2e922df8 100644 --- a/src/main/webapp/app/overview/course-exams/course-exam-detail/course-exam-detail.component.ts +++ b/src/main/webapp/app/overview/course-exams/course-exam-detail/course-exam-detail.component.ts @@ -5,6 +5,8 @@ import { Course } from 'app/entities/course.model'; import dayjs from 'dayjs/esm'; import { faBook, faCalendarDay, faCirclePlay, faCircleStop, faMagnifyingGlass, faPenAlt, faPlay, faUserClock } from '@fortawesome/free-solid-svg-icons'; import { Subscription, interval } from 'rxjs'; +import { StudentExam } from 'app/entities/student-exam.model'; +import { ExamParticipationService } from 'app/exam/participate/exam-participation.service'; // Enum to dynamically change the template-content export const enum ExamState { @@ -22,7 +24,7 @@ export const enum ExamState { STUDENTREVIEW = 'STUDENTREVIEW', // Case 7: Fallback UNDEFINED = 'UNDEFINED', - // Case 99: No more attempts + // Case 8: No more attempts NO_MORE_ATTEMPTS = 'NO_MORE_ATTEMPTS', } @@ -40,6 +42,8 @@ export class CourseExamDetailComponent implements OnInit, OnDestroy { examStateSubscription: Subscription; timeLeftToStart: number; + studentExam?: StudentExam; + // Icons faPenAlt = faPenAlt; faCirclePlay = faCirclePlay; @@ -50,7 +54,10 @@ export class CourseExamDetailComponent implements OnInit, OnDestroy { faBook = faBook; faCircleStop = faCircleStop; - constructor(private router: Router) {} + constructor( + private router: Router, + private examParticipationService: ExamParticipationService, + ) {} ngOnInit() { // A subscription is used here to limit the number of calls @@ -93,8 +100,8 @@ export class CourseExamDetailComponent implements OnInit, OnDestroy { this.cancelExamStateSubscription(); return; } - if (dayjs(this.exam.startDate).isAfter(dayjs())) { - if (dayjs(this.exam.startDate).diff(dayjs(), 's') < 600) { + if (this.exam.startDate && dayjs().isBefore(this.exam.startDate)) { + if (dayjs(this.exam.startDate).diff(dayjs(), 'seconds') < 600) { this.examState = ExamState.IMMINENT; } else { this.examState = ExamState.UPCOMING; @@ -102,23 +109,48 @@ export class CourseExamDetailComponent implements OnInit, OnDestroy { this.timeLeftToStartInSeconds(); return; } - if ( - this.exam.examStudentReviewStart && - this.exam.examStudentReviewEnd && - dayjs().isBetween(dayjs(this.exam.examStudentReviewStart), dayjs(this.exam.examStudentReviewEnd)) - ) { - this.examState = ExamState.STUDENTREVIEW; + if (this.exam.endDate && dayjs().isBefore(this.exam.endDate)) { + this.examState = ExamState.CONDUCTING; return; } - if (dayjs(this.exam.endDate).isAfter(dayjs())) { - this.examState = ExamState.CONDUCTING; + + this.updateExamStateWithStudentExamOrTestExam(); + } + + updateExamStateWithStudentExamOrTestExam() { + if (!this.studentExam && !this.exam.testExam && this.course?.id && this.exam?.id) { + this.examParticipationService + .getOwnStudentExam(this.course.id, this.exam.id) + .subscribe({ + next: (studentExam) => { + this.studentExam = studentExam; + }, + }) + .add(() => this.updateExamStateWithLoadedStudentExamOrTestExam()); + } else { + this.updateExamStateWithLoadedStudentExamOrTestExam(); + } + } + + updateExamStateWithLoadedStudentExamOrTestExam() { + const potentialLaterEndDate = this.studentExam?.workingTime ? dayjs(this.exam.startDate).add(this.studentExam.workingTime, 'seconds') : undefined; + const noOrOverPotentialLaterEndDate = !potentialLaterEndDate || dayjs().isAfter(potentialLaterEndDate); + if ((!this.studentExam || (!this.studentExam.submitted && noOrOverPotentialLaterEndDate)) && !this.exam.testExam) { + // Normal exam is over and student did not participate (no student exam was loaded nor submitted). We can cancel the subscription and the exam is closed + this.examState = ExamState.CLOSED; + this.cancelExamStateSubscription(); + return; + } + + if (this.exam.examStudentReviewStart && this.exam.examStudentReviewEnd && dayjs().isBetween(this.exam.examStudentReviewStart, this.exam.examStudentReviewEnd)) { + this.examState = ExamState.STUDENTREVIEW; return; } - if (dayjs(this.exam.endDate).isBefore(dayjs())) { + if (dayjs().isAfter(this.exam.endDate)) { if (!this.exam.testExam) { // The longest individual working time is stored on the server side, but should not be extra loaded. Therefore, a sufficiently large time extension is selected. const endDateWithTimeExtension = dayjs(this.exam.endDate).add(this.exam.workingTime! * 3, 'seconds'); - if (endDateWithTimeExtension.isAfter(dayjs())) { + if (dayjs().isBefore(endDateWithTimeExtension)) { this.examState = ExamState.TIMEEXTENSION; return; } else { diff --git a/src/main/webapp/app/overview/course-exams/course-exams.component.html b/src/main/webapp/app/overview/course-exams/course-exams.component.html index 806041d7b58c..0bc608e9be8e 100644 --- a/src/main/webapp/app/overview/course-exams/course-exams.component.html +++ b/src/main/webapp/app/overview/course-exams/course-exams.component.html @@ -5,7 +5,7 @@

Exams< diff --git a/src/main/webapp/app/overview/course-exams/course-exams.component.scss b/src/main/webapp/app/overview/course-exams/course-exams.component.scss index c091f3ecbd1e..8635db45d20b 100644 --- a/src/main/webapp/app/overview/course-exams/course-exams.component.scss +++ b/src/main/webapp/app/overview/course-exams/course-exams.component.scss @@ -10,7 +10,7 @@ pointer-events: none; } -.hover-effect:hover { +.hover-effect:has(.clickable):hover { transition: transform 0.2s linear; transform: scale(1.012); background-color: var(--hover-slightly-darker-body-bg); diff --git a/src/main/webapp/i18n/de/exam.json b/src/main/webapp/i18n/de/exam.json index c352956e85d3..adb9582608fe 100644 --- a/src/main/webapp/i18n/de/exam.json +++ b/src/main/webapp/i18n/de/exam.json @@ -33,27 +33,21 @@ "maxPoints": "Erreichbare Punkte: {{points}}", "upcoming": "Demnächst stattfindende Klausur", "imminent": "Die Klausur beginnt in", - "imminentExplanation": "Du kannst die Klausur durch Klicken auf die Kachel bereits öffnen", + "imminentExplanation": "Du kannst diese Klausur bereits öffnen", "conducting": "Laufende Klausur", - "conductingExplanation": "Du kannst die Klausur durch Klicken auf die Kachel öffnen", "timeExtension": "Die normale Arbeitszeit ist vorbei", - "timeExtensionExplanation": "Wenn du eine Zeitverlängerung hast, kannst du die Klausur durch Klicken auf die Kachel öffnen. Ansonsten kannst du deine Abgabe einsehen", + "timeExtensionExplanation": "Wenn du eine noch laufende Zeitverlängerung hast, kannst du die Klausur öffnen", "closed": "Klausur beendet", - "closedExplanation": "Du kannst deine Abgabe durch Klicken auf die Kachel einsehen", "review": "Klausureinsicht möglich", "reviewExplanation": "Die Einsicht findet statt bis ", - "noMoreAttempts": "Keine weiteren Versuche", - "noMoreAttemptsExplanation": "Derzeit sind keine weiteren Versuche verfügbar", "realExamsHeading": "Klausuren", "testExamsHeading": "Testklausuren", "testExam": { "upcoming": "Demnächst stattfindende Testklausur", "imminent": "Die Testklausur beginnt in ", - "imminentExplanation": "Du kannst die Testklausur durch Klicken auf die Kachel bereits öffnen", + "imminentExplanation": "Du kannst diese Testklausur bereits öffnen", "conducting": "Beginne neue Testklausur", - "conductingExplanation": "Du kannst eine neue Klausur durch Klicken auf die Kachel starten", "closed": "Testklausur beendet", - "closedExplanation": "Das Beginnen einer neuen Testklausur ist nicht mehr möglich", "notSubmitted": "Du hast deine Testklausur nicht rechtzeitig eingereicht!", "reviewAttempt": "Versuch #{{attempt}}", "resumeAttempt": "Versuch #{{attempt}} fortführen", @@ -62,7 +56,9 @@ "submissionDate": "Eingereicht am: ", "available": "Verfügbar von {{startDate}} bis {{endDate}}", "showMoreAttempts": "Zeige alle Versuche", - "showLessAttempts": "Zeige aktuellste Versuche" + "showLessAttempts": "Zeige aktuellste Versuche", + "noMoreAttempts": "Keine weiteren Versuche", + "noMoreAttemptsExplanation": "Derzeit sind keine weiteren Versuche verfügbar" } }, "examSummary": { @@ -270,6 +266,8 @@ "exerciseName": "Name", "noStudentExam": "Du bist nicht für die Klausur angemeldet. Bitte wende dich an die entsprechende Lehrkraft.", "noFurtherAttempts": "Derzeit sind keine weiteren Versuche für die Testklausur möglich. Um deine Abgabe zu betrachten, navigiere zur Klausurseite ", + "atLeastTutorStudentExam": "Da du nicht Studierende:r bist, kannst du nicht an der Klausur teilnehmen:", + "goToExamManagement": "Zur Klausurverwaltung gehen", "submitProgrammingExercise": "Weiter", "submitOtherExercise": "Speichern & Weiter", "submitLastExercise": "Speichern", diff --git a/src/main/webapp/i18n/en/exam.json b/src/main/webapp/i18n/en/exam.json index b18034f4a158..941100e03f9a 100644 --- a/src/main/webapp/i18n/en/exam.json +++ b/src/main/webapp/i18n/en/exam.json @@ -33,27 +33,21 @@ "maxPoints": "Attainable Points: {{points}}", "upcoming": "Upcoming Exam", "imminent": "The Exam starts in", - "imminentExplanation": "You can already open the exam by clicking on the tile", + "imminentExplanation": "You can already open this exam", "conducting": "Exam in Progress", - "conductingExplanation": "You can open the exam by clicking on the tile", "timeExtension": "The normal working time is over", - "timeExtensionExplanation": "In case you have a time extension, the exam can be opened by clicking on the tile. Otherwise, you can review your submission.", + "timeExtensionExplanation": "In case you have an ongoing time extension, you can continue with the exam", "closed": "Exam Closed", - "closedExplanation": "You can review your submission by clicking on the tile", "review": "Exam Review Open", "reviewExplanation": "You can review the assessment until ", - "noMoreAttempts": "No More Attempts", - "noMoreAttemptsExplanation": "There are currently no further attempts available", "realExamsHeading": "Exams", "testExamsHeading": "Test Exams", "testExam": { "upcoming": "Upcoming Test Exam", "imminent": "The Test Exam will start ", - "imminentExplanation": "You can already open the test exam by clicking on the tile", + "imminentExplanation": "You can already open this test exam", "conducting": "Start New Test Exam", - "conductingExplanation": "You can start a new test exam by clicking on the tile", "closed": "Test Exam Closed", - "closedExplanation": "Starting a new attempt is no longer possible", "notSubmitted": "You have not submitted your Test Exam on time!", "reviewAttempt": "Attempt #{{attempt}}", "resumeAttempt": "Resume Attempt #{{attempt}}", @@ -62,7 +56,9 @@ "submissionDate": "Submitted on: ", "available": "Available from {{startDate}} until {{endDate}}", "showMoreAttempts": "Show all attempts", - "showLessAttempts": "Show latest attempts" + "showLessAttempts": "Show latest attempts", + "noMoreAttempts": "No More Attempts", + "noMoreAttemptsExplanation": "There are currently no further attempts available" } }, "examSummary": { @@ -270,6 +266,8 @@ "exerciseName": "Name", "noStudentExam": "You are not registered for the exam. Please contact your instructor.", "noFurtherAttempts": "Currently no further attempts for the Test Exam are possible. To review your submission, navigate back to the Exam page", + "atLeastTutorStudentExam": "Since you are not a student, you cannot participate in this exam:", + "goToExamManagement": "Go to exam management page", "submitProgrammingExercise": "Continue", "submitOtherExercise": "Save & continue", "submitLastExercise": "Save", diff --git a/src/test/java/de/tum/in/www1/artemis/exam/ExamIntegrationTest.java b/src/test/java/de/tum/in/www1/artemis/exam/ExamIntegrationTest.java index ad387468e955..2a6f1a534da5 100644 --- a/src/test/java/de/tum/in/www1/artemis/exam/ExamIntegrationTest.java +++ b/src/test/java/de/tum/in/www1/artemis/exam/ExamIntegrationTest.java @@ -120,6 +120,9 @@ class ExamIntegrationTest extends AbstractSpringIntegrationBambooBitbucketJiraTe @Autowired private PageableSearchUtilService pageableSearchUtilService; + @Autowired + private ExamUserRepository examUserRepository; + private Course course1; private Course course2; @@ -744,7 +747,7 @@ void testGetExamForTestRunDashboard_ok() throws Exception { void testGetStudentExamForStart() throws Exception { Exam exam = examUtilService.addActiveExamWithRegisteredUser(course1, userUtilService.getUserByLogin(TEST_PREFIX + "student1")); exam.setVisibleDate(ZonedDateTime.now().minusHours(1).minusMinutes(5)); - StudentExam response = request.get("/api/courses/" + course1.getId() + "/exams/" + exam.getId() + "/start", HttpStatus.OK, StudentExam.class); + StudentExam response = request.get("/api/courses/" + course1.getId() + "/exams/" + exam.getId() + "/own-student-exam", HttpStatus.OK, StudentExam.class); assertThat(response.getExam()).isEqualTo(exam); verify(examAccessService).getExamInCourseElseThrow(course1.getId(), exam.getId()); } @@ -1147,6 +1150,46 @@ void testGetExamTitleForNonExistingExam() throws Exception { request.get("/api/exams/123124123123/title", HttpStatus.NOT_FOUND, String.class); } + @Test + @WithMockUser(username = TEST_PREFIX + "student1", roles = "USER") + void testRetrieveOwnStudentExam_noInformationLeaked() throws Exception { + User student1 = userUtilService.getUserByLogin(TEST_PREFIX + "student1"); + Exam exam = examUtilService.addExamWithModellingAndTextAndFileUploadAndQuizAndEmptyGroup(course1); + ExamUser examUser = new ExamUser(); + examUser.setUser(student1); + exam.addExamUser(examUser); + examUserRepository.save(examUser); + StudentExam studentExam = examUtilService.addStudentExam(exam); + studentExam.setUser(student1); + studentExamRepository.save(studentExam); + + StudentExam receivedStudentExam = request.get("/api/courses/" + course1.getId() + "/exams/" + exam.getId() + "/own-student-exam", HttpStatus.OK, StudentExam.class); + assertThat(receivedStudentExam.getExercises()).isEmpty(); + assertThat(receivedStudentExam.getExam().getStudentExams()).isEmpty(); + assertThat(receivedStudentExam.getExam().getExamUsers()).isEmpty(); + assertThat(receivedStudentExam.getExam().getExerciseGroups()).isEmpty(); + } + + @Test + @WithMockUser(username = TEST_PREFIX + "student1", roles = "USER") + void testRetrieveOwnStudentExam_noStudentExam() throws Exception { + Exam exam = examUtilService.addExam(course1); + User student1 = userUtilService.getUserByLogin(TEST_PREFIX + "student1"); + var examUser1 = new ExamUser(); + examUser1.setExam(exam); + examUser1.setUser(student1); + examUser1 = examUserRepository.save(examUser1); + exam.addExamUser(examUser1); + examRepository.save(exam); + request.get("/api/courses/" + course1.getId() + "/exams/" + exam1.getId() + "/own-student-exam", HttpStatus.BAD_REQUEST, StudentExam.class); + } + + @Test + @WithMockUser(username = TEST_PREFIX + "instructor1", roles = "INSTRUCTOR") + void testRetrieveOwnStudentExam_instructor() throws Exception { + request.get("/api/courses/" + course1.getId() + "/exams/" + exam1.getId() + "/own-student-exam", HttpStatus.BAD_REQUEST, StudentExam.class); + } + @Test @WithMockUser(username = TEST_PREFIX + "instructor1", roles = "INSTRUCTOR") void testGetExamForImportWithExercises_successful() throws Exception { diff --git a/src/test/java/de/tum/in/www1/artemis/exam/StudentExamIntegrationTest.java b/src/test/java/de/tum/in/www1/artemis/exam/StudentExamIntegrationTest.java index 1e4ca1a4f925..a6058d7e35f7 100644 --- a/src/test/java/de/tum/in/www1/artemis/exam/StudentExamIntegrationTest.java +++ b/src/test/java/de/tum/in/www1/artemis/exam/StudentExamIntegrationTest.java @@ -466,7 +466,7 @@ void testGetStudentExamForConduction_testExam() throws Exception { final var repoName = projectKey.toLowerCase() + "-" + student1.getLogin().toLowerCase(); bitbucketRequestMockProvider.mockProtectBranches(programmingExercise, repoName); - StudentExam studentExamForStart = request.get("/api/courses/" + course1.getId() + "/exams/" + exam.getId() + "/start", HttpStatus.OK, StudentExam.class); + StudentExam studentExamForStart = request.get("/api/courses/" + course1.getId() + "/exams/" + exam.getId() + "/own-student-exam", HttpStatus.OK, StudentExam.class); final HttpHeaders headers = getHttpHeadersForExamSession(); var response = request.get("/api/courses/" + course1.getId() + "/exams/" + exam.getId() + "/student-exams/" + studentExamForStart.getId() + "/conduction", HttpStatus.OK, @@ -2601,7 +2601,8 @@ void testConductionOfTestExam_successful() throws Exception { testExamWithExercises = examRepository.save(testExamWithExercises); // Step 1: Call /start - StudentExam studentExamForStart = request.get("/api/courses/" + course1.getId() + "/exams/" + testExamWithExercises.getId() + "/start", HttpStatus.OK, StudentExam.class); + StudentExam studentExamForStart = request.get("/api/courses/" + course1.getId() + "/exams/" + testExamWithExercises.getId() + "/own-student-exam", HttpStatus.OK, + StudentExam.class); assertThat(studentExamForStart.getUser()).isEqualTo(student1); assertThat(studentExamForStart.getExam().getId()).isEqualTo(testExamWithExercises.getId()); diff --git a/src/test/java/de/tum/in/www1/artemis/exam/TestExamIntegrationTest.java b/src/test/java/de/tum/in/www1/artemis/exam/TestExamIntegrationTest.java index c23ac2a21094..18ab77d87d5e 100644 --- a/src/test/java/de/tum/in/www1/artemis/exam/TestExamIntegrationTest.java +++ b/src/test/java/de/tum/in/www1/artemis/exam/TestExamIntegrationTest.java @@ -208,7 +208,7 @@ void testDeleteStudentForTestExam_badRequest() throws Exception { @Test @WithMockUser(username = TEST_PREFIX + "student42", roles = "USER") void testGetStudentExamForTestExamForStart_notRegisteredInCourse() throws Exception { - request.get("/api/courses/" + course1.getId() + "/exams/" + testExam1.getId() + "/start", HttpStatus.FORBIDDEN, String.class); + request.get("/api/courses/" + course1.getId() + "/exams/" + testExam1.getId() + "/own-student-exam", HttpStatus.FORBIDDEN, String.class); } @Test @@ -217,7 +217,7 @@ void testGetStudentExamForTestExamForStart_notVisible() throws Exception { testExam1.setVisibleDate(now().plusMinutes(60)); testExam1 = examRepository.save(testExam1); - request.get("/api/courses/" + course1.getId() + "/exams/" + testExam1.getId() + "/start", HttpStatus.FORBIDDEN, StudentExam.class); + request.get("/api/courses/" + course1.getId() + "/exams/" + testExam1.getId() + "/own-student-exam", HttpStatus.FORBIDDEN, StudentExam.class); } @Test @@ -225,7 +225,7 @@ void testGetStudentExamForTestExamForStart_notVisible() throws Exception { void testGetStudentExamForTestExamForStart_ExamDoesNotBelongToCourse() throws Exception { Exam testExam = examUtilService.addTestExam(course2); - request.get("/api/courses/" + course1.getId() + "/exams/" + testExam.getId() + "/start", HttpStatus.CONFLICT, StudentExam.class); + request.get("/api/courses/" + course1.getId() + "/exams/" + testExam.getId() + "/own-student-exam", HttpStatus.CONFLICT, StudentExam.class); } @Test @@ -240,7 +240,7 @@ void testGetStudentExamForTestExamForStart_fetchExam_successful() throws Excepti testExam.addExamUser(examUser); examRepository.save(testExam); var studentExam5 = examUtilService.addStudentExamForTestExam(testExam, student1); - StudentExam studentExamReceived = request.get("/api/courses/" + course2.getId() + "/exams/" + testExam.getId() + "/start", HttpStatus.OK, StudentExam.class); + StudentExam studentExamReceived = request.get("/api/courses/" + course2.getId() + "/exams/" + testExam.getId() + "/own-student-exam", HttpStatus.OK, StudentExam.class); assertThat(studentExamReceived).isEqualTo(studentExam5); } } diff --git a/src/test/java/de/tum/in/www1/artemis/service/exam/ExamAccessServiceTest.java b/src/test/java/de/tum/in/www1/artemis/service/exam/ExamAccessServiceTest.java index 7879349a1bb7..0e560ea59092 100644 --- a/src/test/java/de/tum/in/www1/artemis/service/exam/ExamAccessServiceTest.java +++ b/src/test/java/de/tum/in/www1/artemis/service/exam/ExamAccessServiceTest.java @@ -2,6 +2,7 @@ import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.assertj.core.api.InstanceOfAssertFactories.type; import java.time.ZonedDateTime; import java.util.Collections; @@ -33,7 +34,7 @@ class ExamAccessServiceTest extends AbstractSpringIntegrationIndependentTest { - private static final String TEST_PREFIX = "examaccessservicetest"; // only lower case is supported + private static final String TEST_PREFIX = "examaccessservicetest"; @Autowired private CourseRepository courseRepository; @@ -357,7 +358,9 @@ void testGetExamInCourseElseThrow_noCourseAccess() { @Test @WithMockUser(username = TEST_PREFIX + "student1", roles = "USER") void testGetExamInCourseElseThrow_realExam() { - assertThatThrownBy(() -> examAccessService.getExamInCourseElseThrow(course1.getId(), exam1.getId())).isInstanceOf(BadRequestAlertException.class); + assertThatThrownBy(() -> examAccessService.getExamInCourseElseThrow(course1.getId(), exam1.getId())).asInstanceOf(type(BadRequestAlertException.class)) + .satisfies(error -> assertThat(error.getParameters().get("skipAlert")).isEqualTo(Boolean.TRUE)); + } @Test @@ -378,4 +381,10 @@ void testGetExamInCourseElseThrow_success_studentExamPresent() { assertThat(studentExam2.equals(studentExamForTestExam2)).isEqualTo(true); } + @Test + @WithMockUser(username = TEST_PREFIX + "tutor1", roles = "TA") + void testGetExamInCourseElseThrow_tutor_skipAlert() { + assertThatThrownBy(() -> examAccessService.getExamInCourseElseThrow(course1.getId(), exam1.getId())).asInstanceOf(type(BadRequestAlertException.class)) + .satisfies(error -> assertThat(error.getParameters().get("skipAlert")).isEqualTo(Boolean.TRUE)); + } } diff --git a/src/test/javascript/spec/component/exam/participate/exam-participation.component.spec.ts b/src/test/javascript/spec/component/exam/participate/exam-participation.component.spec.ts index 9c83f3d8bf74..16d352c4b407 100644 --- a/src/test/javascript/spec/component/exam/participate/exam-participation.component.spec.ts +++ b/src/test/javascript/spec/component/exam/participate/exam-participation.component.spec.ts @@ -47,6 +47,8 @@ import { MockWebsocketService } from '../../../helpers/mocks/service/mock-websoc import { MockLocalStorageService } from '../../../helpers/mocks/service/mock-local-storage.service'; import { JhiWebsocketService } from 'app/core/websocket/websocket.service'; import { LocalStorageService } from 'ngx-webstorage'; +import { CourseManagementService } from 'app/course/manage/course-management.service'; +import { CourseStorageService } from 'app/course/manage/course-storage.service'; import { ExamLiveEvent, ExamParticipationLiveEventsService } from 'app/exam/participate/exam-participation-live-events.service'; import { MockExamParticipationLiveEventsService } from '../../../helpers/mocks/service/mock-exam-participation-live-events.service'; @@ -61,6 +63,8 @@ describe('ExamParticipationComponent', () => { let alertService: AlertService; let artemisServerDateService: ArtemisServerDateService; let examParticipationLiveEventsService: ExamParticipationLiveEventsService; + let courseService: CourseManagementService; + let courseStorageService: CourseStorageService; beforeEach(() => { TestBed.configureTestingModule({ @@ -116,6 +120,8 @@ describe('ExamParticipationComponent', () => { alertService = TestBed.inject(AlertService); artemisServerDateService = TestBed.inject(ArtemisServerDateService); examParticipationLiveEventsService = TestBed.inject(ExamParticipationLiveEventsService); + courseService = TestBed.inject(CourseManagementService); + courseStorageService = TestBed.inject(CourseStorageService); fixture.detectChanges(); comp.exam = new Exam(); }); @@ -199,7 +205,7 @@ describe('ExamParticipationComponent', () => { studentExam.workingTime = 100; const studentExamWithExercises = { id: 1, numberOfExamSessions: 0 }; TestBed.inject(ActivatedRoute).params = of({ courseId: '1', examId: '2' }); - const loadStudentExamSpy = jest.spyOn(examParticipationService, 'loadStudentExam').mockReturnValue(of(studentExam)); + const loadStudentExamSpy = jest.spyOn(examParticipationService, 'getOwnStudentExam').mockReturnValue(of(studentExam)); const loadStudentExamWithExercisesForSummary = jest.spyOn(examParticipationService, 'loadStudentExamWithExercisesForSummary').mockReturnValue(of(studentExamWithExercises)); comp.ngOnInit(); expect(loadStudentExamSpy).toHaveBeenCalledOnce(); @@ -240,7 +246,7 @@ describe('ExamParticipationComponent', () => { studentExam.exam.course = new Course(); studentExam.workingTime = 100; TestBed.inject(ActivatedRoute).params = of({ courseId: '1', examId: '2', studentExamId: 'start' }); - const loadTestRunStub = jest.spyOn(examParticipationService, 'loadStudentExam').mockReturnValue(of(studentExam)); + const loadTestRunStub = jest.spyOn(examParticipationService, 'getOwnStudentExam').mockReturnValue(of(studentExam)); comp.ngOnInit(); expect(loadTestRunStub).toHaveBeenCalledOnce(); expect(comp.studentExam).toEqual(studentExam); @@ -257,7 +263,7 @@ describe('ExamParticipationComponent', () => { const studentExamWithExercises = new StudentExam(); studentExamWithExercises.id = 4; TestBed.inject(ActivatedRoute).params = of({ courseId: '1', examId: '2', studentExamId: '4' }); - const loadStudentExamSpy = jest.spyOn(examParticipationService, 'loadStudentExam').mockReturnValue(of(studentExam)); + const loadStudentExamSpy = jest.spyOn(examParticipationService, 'getOwnStudentExam').mockReturnValue(of(studentExam)); const loadStudentExamWithExercisesForSummary = jest.spyOn(examParticipationService, 'loadStudentExamWithExercisesForSummary').mockReturnValue(of(studentExamWithExercises)); comp.ngOnInit(); expect(loadStudentExamSpy).toHaveBeenCalledOnce(); @@ -277,7 +283,7 @@ describe('ExamParticipationComponent', () => { const studentExamWithExercises = new StudentExam(); studentExamWithExercises.id = 3; TestBed.inject(ActivatedRoute).params = of({ courseId: '1', examId: '2', studentExamId: '3' }); - const loadStudentExamSpy = jest.spyOn(examParticipationService, 'loadStudentExam').mockReturnValue(of(studentExam)); + const loadStudentExamSpy = jest.spyOn(examParticipationService, 'getOwnStudentExam').mockReturnValue(of(studentExam)); const loadStudentExamWithExercisesForSummary = jest.spyOn(examParticipationService, 'loadStudentExamWithExercisesForSummary').mockReturnValue(of(studentExamWithExercises)); studentExam.exam.course = new Course(); studentExam.ended = true; @@ -294,7 +300,7 @@ describe('ExamParticipationComponent', () => { const studentExam = new StudentExam(); studentExam.exam = new Exam(); studentExam.id = 1; - const loadStudentExamStub = jest.spyOn(examParticipationService, 'loadStudentExam').mockReturnValue(of(studentExam)); + const loadStudentExamStub = jest.spyOn(examParticipationService, 'getOwnStudentExam').mockReturnValue(of(studentExam)); const localStudentExam = new StudentExam(); localStudentExam.exam = studentExam.exam; @@ -314,6 +320,40 @@ describe('ExamParticipationComponent', () => { expect(comp.exam).toEqual(studentExam.exam); }); + it('should determine tutor status if no exam was loaded', () => { + const httpError = new HttpErrorResponse({ + error: { errorKey: 'No student exam for you' }, + status: 400, + }); + const course: Course = { isAtLeastTutor: true }; + + TestBed.inject(ActivatedRoute).params = of({ courseId: '1', examId: '2', studentExamId: '4' }); + const loadStudentExamSpy = jest.spyOn(examParticipationService, 'getOwnStudentExam').mockReturnValue(throwError(() => httpError)); + const courseStorageServiceSpy = jest.spyOn(courseStorageService, 'getCourse').mockReturnValue(course); + comp.ngOnInit(); + expect(loadStudentExamSpy).toHaveBeenCalledOnce(); + expect(courseStorageServiceSpy).toHaveBeenCalledOnce(); + expect(comp.isAtLeastTutor).toBeTrue(); + }); + + it('should determine tutor status if no exam was loaded and course was not cached', () => { + const httpError = new HttpErrorResponse({ + error: { errorKey: 'No student exam for you' }, + status: 400, + }); + const course: Course = { isAtLeastTutor: true }; + + TestBed.inject(ActivatedRoute).params = of({ courseId: '1', examId: '2', studentExamId: '4' }); + const loadStudentExamSpy = jest.spyOn(examParticipationService, 'getOwnStudentExam').mockReturnValue(throwError(() => httpError)); + const courseStorageServiceSpy = jest.spyOn(courseStorageService, 'getCourse').mockReturnValue(undefined); + const courseServiceSpy = jest.spyOn(courseService, 'find').mockReturnValue(of(new HttpResponse({ body: course }))); + comp.ngOnInit(); + expect(loadStudentExamSpy).toHaveBeenCalledOnce(); + expect(courseStorageServiceSpy).toHaveBeenCalledOnce(); + expect(courseServiceSpy).toHaveBeenCalledOnce(); + expect(comp.isAtLeastTutor).toBeTrue(); + }); + const testExamStarted = (studentExam: StudentExam) => { const exerciseWithParticipation = (type: 'programming' | 'modeling', withSubmission: boolean) => { let exercise = new ProgrammingExercise(new Course(), undefined); @@ -516,6 +556,7 @@ describe('ExamParticipationComponent', () => { const syncedSubmission = new TextSubmission(); syncedSubmission.isSynced = true; participation.submissions = [submission, syncedSubmission]; + participation.submissions = [submission, syncedSubmission]; textExercise.studentParticipations = [participation]; comp.studentExam.exercises = [textExercise]; textSubmissionUpdateSpy = jest.spyOn(textSubmissionService, 'update').mockReturnValue(of(new HttpResponse({ body: submission }))); diff --git a/src/test/javascript/spec/component/overview/course-exams/course-exam-detail.component.spec.ts b/src/test/javascript/spec/component/overview/course-exams/course-exam-detail.component.spec.ts index 5d9ac63bfa04..3eef66c559cb 100644 --- a/src/test/javascript/spec/component/overview/course-exams/course-exam-detail.component.spec.ts +++ b/src/test/javascript/spec/component/overview/course-exams/course-exam-detail.component.spec.ts @@ -1,5 +1,5 @@ import { ComponentFixture, TestBed } from '@angular/core/testing'; -import { CourseExamDetailComponent } from 'app/overview/course-exams/course-exam-detail/course-exam-detail.component'; +import { CourseExamDetailComponent, ExamState } from 'app/overview/course-exams/course-exam-detail/course-exam-detail.component'; import { Exam } from 'app/entities/exam.model'; import { ArtemisTestModule } from '../../../test.module'; import dayjs from 'dayjs/esm'; @@ -10,11 +10,18 @@ import { ArtemisDurationFromSecondsPipe } from 'app/shared/pipes/artemis-duratio import { MockRouter } from '../../../helpers/mocks/mock-router'; import { Router } from '@angular/router'; import { CourseExamAttemptReviewDetailComponent } from 'app/overview/course-exams/course-exam-attempt-review-detail/course-exam-attempt-review-detail.component'; +import { of } from 'rxjs'; +import { StudentExam } from 'app/entities/student-exam.model'; +import { ExamParticipationService } from 'app/exam/participate/exam-participation.service'; +import { MockExamParticipationService } from '../../../helpers/mocks/service/mock-exam-participation.service'; describe('CourseExamDetailComponent', () => { let component: CourseExamDetailComponent; let componentFixture: ComponentFixture; + let examParticipationService: ExamParticipationService; + let examParticipationServiceSpy: jest.SpyInstance; + const currentDate = dayjs(); const currentDateMinus60 = currentDate.subtract(60, 'minutes'); const currentDateMinus35 = currentDate.subtract(35, 'minutes'); @@ -26,6 +33,8 @@ describe('CourseExamDetailComponent', () => { const currentDatePlus60 = currentDate.add(60, 'minutes'); const currentDatePlus90 = currentDate.add(90, 'minutes'); + const studentExam = { submitted: true } as StudentExam; + beforeEach(() => { return TestBed.configureTestingModule({ imports: [ArtemisTestModule], @@ -36,24 +45,35 @@ describe('CourseExamDetailComponent', () => { MockPipe(ArtemisDatePipe), MockPipe(ArtemisDurationFromSecondsPipe), ], - providers: [{ provide: Router, useClass: MockRouter }], + providers: [ + { provide: ExamParticipationService, useClass: MockExamParticipationService }, + { provide: Router, useClass: MockRouter }, + ], }) .compileComponents() .then(() => { componentFixture = TestBed.createComponent(CourseExamDetailComponent); component = componentFixture.componentInstance; + examParticipationService = TestBed.inject(ExamParticipationService); + examParticipationServiceSpy = jest.spyOn(examParticipationService, 'getOwnStudentExam').mockReturnValue(of(studentExam)); }); }); + afterEach(() => { + jest.restoreAllMocks(); + }); + it('should determine the exam state to be undefined', () => { - component.exam = new Exam(); + component.exam = { id: 1 }; + component.course = { id: 2 }; component.ngOnInit(); component.updateExamState(); expect(component.examState).toBe('UNDEFINED'); }); it('should determine the exam state to be upcoming', () => { - component.exam = new Exam(); + component.exam = { id: 1 }; + component.course = { id: 2 }; component.exam.startDate = currentDatePlus15; component.exam.endDate = currentDatePlus30; component.exam.workingTime = 15 * 60; @@ -68,7 +88,8 @@ describe('CourseExamDetailComponent', () => { }); it('should determine the exam state to be imminent', () => { - component.exam = new Exam(); + component.exam = { id: 1 }; + component.course = { id: 2 }; component.exam.startDate = currentDatePlus5; component.exam.endDate = currentDatePlus30; component.exam.workingTime = 25 * 60; @@ -78,7 +99,8 @@ describe('CourseExamDetailComponent', () => { }); it('should determine the exam state to be conducting', () => { - component.exam = new Exam(); + component.exam = { id: 1 }; + component.course = { id: 2 }; component.exam.startDate = currentDate; component.exam.endDate = currentDatePlus15; component.exam.workingTime = 15 * 60; @@ -88,7 +110,8 @@ describe('CourseExamDetailComponent', () => { }); it('should determine the exam state to be timeExtension', () => { - component.exam = new Exam(); + component.exam = { id: 1 }; + component.course = { id: 2 }; component.exam.startDate = currentDateMinus60; component.exam.endDate = currentDateMinus30; component.exam.workingTime = 30 * 60; @@ -97,8 +120,23 @@ describe('CourseExamDetailComponent', () => { expect(component.examState).toBe('TIMEEXTENSION'); }); + it('should determine the exam state to be timeExtension with started exam', () => { + component.exam = { id: 1 }; + component.course = { id: 2 }; + component.exam.startDate = currentDateMinus60; + component.exam.endDate = currentDateMinus35; + component.exam.workingTime = 25 * 60; + const studentExam: StudentExam = { numberOfExamSessions: 1, workingTime: 65 * 60 }; + examParticipationServiceSpy.mockReturnValue(of(studentExam)); + + component.ngOnInit(); + component.updateExamState(); + expect(component.examState).toBe('TIMEEXTENSION'); + }); + it('should determine the exam state to be closed', () => { - component.exam = new Exam(); + component.exam = { id: 1 }; + component.course = { id: 2 }; component.exam.startDate = currentDateMinus35; component.exam.endDate = currentDateMinus30; component.exam.workingTime = 5 * 60; @@ -131,7 +169,8 @@ describe('CourseExamDetailComponent', () => { }); it('should determine the exam state to be studentReview', () => { - component.exam = new Exam(); + component.exam = { id: 1 }; + component.course = { id: 2 }; component.exam.startDate = currentDateMinus35; component.exam.endDate = currentDateMinus30; component.exam.workingTime = 5 * 60; @@ -143,7 +182,8 @@ describe('CourseExamDetailComponent', () => { }); it('should determine the time left to exam start correctly', () => { - component.exam = new Exam(); + component.exam = { id: 1 }; + component.course = { id: 2 }; component.exam.startDate = currentDatePlus15; component.exam.endDate = currentDatePlus30; component.exam.workingTime = 15 * 60; @@ -161,10 +201,39 @@ describe('CourseExamDetailComponent', () => { }); it('should determine the exam state to be no_more_attempts', () => { - component.exam = new Exam(); + component.exam = { id: 1 }; + component.course = { id: 2 }; component.maxAttemptsReached = true; component.ngOnInit(); component.updateExamState(); expect(component.examState).toBe('NO_MORE_ATTEMPTS'); }); + + it.each([ + [undefined, false, ExamState.CLOSED], + [undefined, true, ExamState.CLOSED], + [{}, false, ExamState.CLOSED], + [{}, true, ExamState.CLOSED], + [{ submitted: false }, false, ExamState.CLOSED], + [{ submitted: false }, true, ExamState.CLOSED], + [{ submitted: true }, false, ExamState.STUDENTREVIEW], + [{ submitted: true }, true, ExamState.CLOSED], + ])('should determine the exam state after end date with different student exams', (studentExam: StudentExam | undefined, testExam: boolean, examState: ExamState) => { + component.exam = { id: 1 }; + component.course = { id: 2 }; + component.exam.startDate = currentDateMinus60; + component.exam.endDate = currentDateMinus35; + component.exam.workingTime = 25 * 60; + if (!testExam) { + component.exam.examStudentReviewStart = currentDateMinus30; + component.exam.examStudentReviewEnd = currentDatePlus5; + } + component.exam.testExam = testExam; + + examParticipationServiceSpy.mockReturnValue(of(studentExam)); + + component.ngOnInit(); + component.updateExamState(); + expect(component.examState).toBe(examState); + }); }); diff --git a/src/test/javascript/spec/helpers/mocks/service/mock-exam-participation.service.ts b/src/test/javascript/spec/helpers/mocks/service/mock-exam-participation.service.ts index f3004e49d880..4d8c0e2a63fc 100644 --- a/src/test/javascript/spec/helpers/mocks/service/mock-exam-participation.service.ts +++ b/src/test/javascript/spec/helpers/mocks/service/mock-exam-participation.service.ts @@ -12,6 +12,9 @@ studentExamInstance.exercises = exercises as Exercise[]; const examParticipationSubjectMock = new BehaviorSubject(studentExamInstance); export class MockExamParticipationService { + loadStudentExam = (courseId: number, examId: number): Observable => { + return examParticipationSubjectMock; + }; loadStudentExamWithExercisesForSummary = (): Observable => { return examParticipationSubjectMock; }; @@ -33,4 +36,8 @@ export class MockExamParticipationService { }; saveStudentExamToLocalStorage(courseId: number, examId: number, studentExam: StudentExam): void {} + + public getOwnStudentExam(courseId: number, examId: number): Observable { + return of({} as StudentExam); + } } diff --git a/src/test/javascript/spec/service/exam-participation.service.spec.ts b/src/test/javascript/spec/service/exam-participation.service.spec.ts index f97b2b31a1ca..d12be7312ce2 100644 --- a/src/test/javascript/spec/service/exam-participation.service.spec.ts +++ b/src/test/javascript/spec/service/exam-participation.service.spec.ts @@ -117,7 +117,7 @@ describe('Exam Participation Service', () => { studentExam, ); service - .loadStudentExam(1, 1) + .getOwnStudentExam(1, 1) .pipe(take(1)) .subscribe((resp) => expect(resp).toMatchObject({ body: studentExam })); @@ -144,7 +144,7 @@ describe('Exam Participation Service', () => { studentExam, ); service - .loadStudentExam(1, 1) + .getOwnStudentExam(1, 1) .pipe(take(1)) .subscribe((resp) => expect(resp).toMatchObject({ body: studentExam }));