diff --git a/src/main/kotlin/edu/ntnu/idi/idatt/backend/bootstrap/DevDataBootstrap.kt b/src/main/kotlin/edu/ntnu/idi/idatt/backend/bootstrap/DevDataBootstrap.kt index f85841ad..47472d81 100644 --- a/src/main/kotlin/edu/ntnu/idi/idatt/backend/bootstrap/DevDataBootstrap.kt +++ b/src/main/kotlin/edu/ntnu/idi/idatt/backend/bootstrap/DevDataBootstrap.kt @@ -65,6 +65,7 @@ class DevDataBootstrap( private val xpBootstrap: XpBootstrap, private val pupilProgressSeedingService: PupilProgressSeedingService, private val pupilStopProgressRepository: PupilStopProgressRepository, + private val mysteryBootstrap: MysteryBootstrap, ) : ApplicationRunner { private val logger = LoggerFactory.getLogger(javaClass) @@ -194,7 +195,13 @@ class DevDataBootstrap( pupilProgressSeedingService.seedIfAbsent(pupil = pupilNoah, classroom = sampleClassroom) seedAllStopsUnlocked(pupil = pupilSofia, classroom = sampleClassroom, stops = activeStops) - + mysteryBootstrap.seedAllMysteries( + classroom = sampleClassroom, + teacher = teacher, + pupilAda = pupilAda, + pupilNoah = pupilNoah, + pupilSofia = pupilSofia, + ) logger.info( "Development bootstrap ensured sample rows for users, profiles, refresh tokens, classroom {} with join code {}, and seeded tasks for {} active map stops", sampleClassroom.title, diff --git a/src/main/kotlin/edu/ntnu/idi/idatt/backend/bootstrap/MysteryBootstrap.kt b/src/main/kotlin/edu/ntnu/idi/idatt/backend/bootstrap/MysteryBootstrap.kt new file mode 100644 index 00000000..f86d77b5 --- /dev/null +++ b/src/main/kotlin/edu/ntnu/idi/idatt/backend/bootstrap/MysteryBootstrap.kt @@ -0,0 +1,116 @@ +package edu.ntnu.idi.idatt.backend.bootstrap + +import edu.ntnu.idi.idatt.backend.classroom.domain.Classroom +import edu.ntnu.idi.idatt.backend.iam.domain.User +import edu.ntnu.idi.idatt.backend.mystery.domain.MysterySubmission +import edu.ntnu.idi.idatt.backend.mystery.domain.ReviewStatus +import edu.ntnu.idi.idatt.backend.mystery.domain.WeeklyMystery +import edu.ntnu.idi.idatt.backend.mystery.infrastructure.MysterySubmissionRepository +import edu.ntnu.idi.idatt.backend.mystery.infrastructure.WeeklyMysteryRepository +import org.springframework.stereotype.Component +import java.time.DayOfWeek +import java.time.LocalDate +import java.time.temporal.TemporalAdjusters + +/** + * Seeds deterministic mystery submissions and weekly featured mysteries for local development. + * + * Produces: + * - Three approved submissions (one per pupil), two of which are featured this week + * - One pending submission from Ada + * - One rejected submission from Noah + * - One approved submission featured in the previous week (to test week navigation) + * + * Idempotent — skips seeding if any submission already exists for the classroom. + */ +@Component +class MysteryBootstrap( + private val submissionRepository: MysterySubmissionRepository, + private val weeklyMysteryRepository: WeeklyMysteryRepository, +) { + fun seedAllMysteries(classroom: Classroom, teacher: User, pupilAda: User, pupilNoah: User, pupilSofia: User) { + if (submissionRepository.findAllByClassroomId(classroom.id).isNotEmpty()) return + + val thisWeek = currentWeekStart() + val lastWeek = thisWeek.minusWeeks(1) + + // Ada — approved, featured this week + val adaSubmission = submissionRepository.save( + MysterySubmission( + classroom = classroom, + pupil = pupilAda, + description = "Jeg fant denne artikkelen på nettet som hevder at sjokolade kurerer forkjølelse. Ingen kilder er oppgitt og avsender er ukjent.", + attachmentUrl = null, + ).also { it.approve(teacher, "Flott funn! Du la godt merke til at det mangler kilder.") } + ) + + // Noah — approved, featured this week + val noahSubmission = submissionRepository.save( + MysterySubmission( + classroom = classroom, + pupil = pupilNoah, + description = "Et innlegg på sosiale medier som sier at en kjendis har sagt noe de aldri har sagt. Bildet ser manipulert ut.", + attachmentUrl = null, + ).also { it.approve(teacher, "Godt observert! Bildesøk er et nyttig verktøy.") } + ) + + // Sofia — approved, featured last week (tests week navigation) + val sofiaSubmission = submissionRepository.save( + MysterySubmission( + classroom = classroom, + pupil = pupilSofia, + description = "En epost som ser ut som den er fra banken, men avsenderadressen er veldig merkelig og lenken går til et ukjent nettsted.", + attachmentUrl = null, + ).also { it.approve(teacher, "Veldig bra! Dette er et typisk phishing-forsøk.") } + ) + + // Ada — pending (tests the 'mine' tab with pending status) + submissionRepository.save( + MysterySubmission( + classroom = classroom, + pupil = pupilAda, + description = "Jeg fant et innlegg som påstår at jordet er flat, med mange likes og delinger. Ingen vitenskapelige kilder.", + attachmentUrl = null, + ) + ) + + // Noah — rejected (tests the 'mine' tab with rejected status) + submissionRepository.save( + MysterySubmission( + classroom = classroom, + pupil = pupilNoah, + description = "En reklame som sier du har vunnet en premie.", + attachmentUrl = null, + ).also { it.reject(teacher, "Dette er reklame, ikke fake news. Prøv å finne et bedre eksempel.") } + ) + + // Feature ada + noah this week, sofia last week + weeklyMysteryRepository.save( + WeeklyMystery( + classroom = classroom, + submission = adaSubmission, + weekStart = thisWeek, + featuredBy = teacher, + ) + ) + weeklyMysteryRepository.save( + WeeklyMystery( + classroom = classroom, + submission = noahSubmission, + weekStart = thisWeek, + featuredBy = teacher, + ) + ) + weeklyMysteryRepository.save( + WeeklyMystery( + classroom = classroom, + submission = sofiaSubmission, + weekStart = lastWeek, + featuredBy = teacher, + ) + ) + } + + private fun currentWeekStart(): LocalDate = + LocalDate.now().with(TemporalAdjusters.previousOrSame(DayOfWeek.MONDAY)) +} \ No newline at end of file diff --git a/src/main/kotlin/edu/ntnu/idi/idatt/backend/iam/security/SecurityConfig.kt b/src/main/kotlin/edu/ntnu/idi/idatt/backend/iam/security/SecurityConfig.kt index 235ee36f..d2c3b31f 100644 --- a/src/main/kotlin/edu/ntnu/idi/idatt/backend/iam/security/SecurityConfig.kt +++ b/src/main/kotlin/edu/ntnu/idi/idatt/backend/iam/security/SecurityConfig.kt @@ -83,6 +83,9 @@ class SecurityConfig( "/error", ).permitAll() .requestMatchers(HttpMethod.GET, "/api/v1/auth/csrf").permitAll() + // public uploads endpoint for now, since images are stored directly on the backend + // instead of a dedicated file storage system + .requestMatchers(HttpMethod.GET, "/uploads/**").permitAll() .requestMatchers( HttpMethod.POST, "/api/v1/auth/teacher/register", diff --git a/src/main/kotlin/edu/ntnu/idi/idatt/backend/mystery/api/FeatureMysteryRequest.kt b/src/main/kotlin/edu/ntnu/idi/idatt/backend/mystery/api/FeatureMysteryRequest.kt new file mode 100644 index 00000000..60198e56 --- /dev/null +++ b/src/main/kotlin/edu/ntnu/idi/idatt/backend/mystery/api/FeatureMysteryRequest.kt @@ -0,0 +1,13 @@ +package edu.ntnu.idi.idatt.backend.mystery.api +import java.time.LocalDate + +/** + * Teacher request to feature an approved submission for a given week. + * + * @property submissionId The approved submission to feature. + * @property weekStart The Monday of the ISO week to feature it in. + */ +data class FeatureMysteryRequest( + val submissionId: Long, + val weekStart: LocalDate, +) \ No newline at end of file diff --git a/src/main/kotlin/edu/ntnu/idi/idatt/backend/mystery/api/MysteryApiMappings.kt b/src/main/kotlin/edu/ntnu/idi/idatt/backend/mystery/api/MysteryApiMappings.kt new file mode 100644 index 00000000..4e495da5 --- /dev/null +++ b/src/main/kotlin/edu/ntnu/idi/idatt/backend/mystery/api/MysteryApiMappings.kt @@ -0,0 +1,28 @@ +package edu.ntnu.idi.idatt.backend.mystery.api + +import edu.ntnu.idi.idatt.backend.mystery.domain.MysterySubmission +import edu.ntnu.idi.idatt.backend.mystery.domain.WeeklyMystery + +fun MysterySubmission.toResponse(): MysterySubmissionResponse = + MysterySubmissionResponse( + id = id, + classroomId = requireNotNull(classroom.id) { "Classroom id missing" }, + pupilUserId = requireNotNull(pupil.id) { "Pupil id missing" }, + description = description, + attachmentUrl = attachmentUrl, + submittedAt = submittedAt, + reviewStatus = reviewStatus.name, + reviewedByTeacherId = reviewedBy?.id, + reviewedAt = reviewedAt, + teacherComment = teacherComment, + ) + +fun WeeklyMystery.toResponse(): WeeklyMysteryResponse = + WeeklyMysteryResponse( + id = id, + classroomId = requireNotNull(classroom.id) { "Classroom id missing" }, + weekStart = weekStart, + featuredByTeacherId = requireNotNull(featuredBy.id) { "Teacher id missing" }, + featuredAt = featuredAt, + submission = submission.toResponse(), + ) \ No newline at end of file diff --git a/src/main/kotlin/edu/ntnu/idi/idatt/backend/mystery/api/MysteryController.kt b/src/main/kotlin/edu/ntnu/idi/idatt/backend/mystery/api/MysteryController.kt new file mode 100644 index 00000000..2b9fd7ad --- /dev/null +++ b/src/main/kotlin/edu/ntnu/idi/idatt/backend/mystery/api/MysteryController.kt @@ -0,0 +1,150 @@ +package edu.ntnu.idi.idatt.backend.mystery.api + +import edu.ntnu.idi.idatt.backend.iam.application.ClassroomUserResolver +import edu.ntnu.idi.idatt.backend.iam.application.CurrentUserResolver +import edu.ntnu.idi.idatt.backend.iam.security.CurrentUser +import edu.ntnu.idi.idatt.backend.mystery.application.MysteryService +import edu.ntnu.idi.idatt.backend.mystery.application.SubmitMysteryCommand +import edu.ntnu.idi.idatt.backend.upload.application.FileStorageService +import jakarta.validation.Valid +import org.springframework.http.HttpStatus +import org.springframework.http.MediaType +import org.springframework.security.access.prepost.PreAuthorize +import org.springframework.security.core.annotation.AuthenticationPrincipal +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.RequestBody +import org.springframework.web.bind.annotation.RequestMapping +import org.springframework.web.bind.annotation.RequestParam +import org.springframework.web.bind.annotation.ResponseStatus +import org.springframework.web.bind.annotation.RestController +import org.springframework.web.multipart.MultipartFile + +/** + * Pupil-facing endpoints for the weekly mystery feature. + * + * Pupils can browse this week's featured mysteries, view all past mysteries, + * submit new examples, and track the review status of their own submissions. + * + * @property mysteryService application service handling mystery operations + * @property currentUserResolver resolves and validates the authenticated user + * @property classroomUserResolver enforces classroom membership + * @property fileStorageService handles image file persistence + */ +@RestController +@RequestMapping("/api/v1/classrooms/{classroomId}/mystery") +@PreAuthorize("hasRole('PUPIL')") +class MysteryController( + private val mysteryService: MysteryService, + private val currentUserResolver: CurrentUserResolver, + private val classroomUserResolver: ClassroomUserResolver, + private val fileStorageService: FileStorageService, +) { + /** + * Returns all mysteries featured for the current ISO week. + * + * @param currentUser authenticated principal resolved from the JWT access token + * @param classroomId classroom to scope the lookup to + * @return featured mysteries for this week, may be empty + */ + @GetMapping("/weekly/current") + fun getCurrentWeekMysteries( + @AuthenticationPrincipal currentUser: CurrentUser, + @PathVariable classroomId: Long, + ): List { + val pupil = currentUserResolver.requirePupil(currentUser.subject) + classroomUserResolver.requireClassroomMember(pupil, classroomId) + return mysteryService.getCurrentWeekMysteries(classroomId).map { it.toResponse() } + } + + /** + * Returns all weekly mysteries for the classroom ordered by week descending. + * Used by pupils to browse previous weeks. + * + * @param currentUser authenticated principal resolved from the JWT access token + * @param classroomId classroom to scope the lookup to + * @return all weekly mysteries, newest week first + */ + @GetMapping("/weekly") + fun getAllWeeklyMysteries( + @AuthenticationPrincipal currentUser: CurrentUser, + @PathVariable classroomId: Long, + ): List { + val pupil = currentUserResolver.requirePupil(currentUser.subject) + classroomUserResolver.requireClassroomMember(pupil, classroomId) + return mysteryService.getAllWeeklyMysteries(classroomId).map { it.toResponse() } + } + + /** + * Submits a new mystery example for teacher review. + * + * @param currentUser authenticated principal resolved from the JWT access token + * @param classroomId classroom to submit the example to + * @param request submission payload + * @return the created submission + */ + @PostMapping("/submissions") + @ResponseStatus(HttpStatus.CREATED) + fun submitExample( + @AuthenticationPrincipal currentUser: CurrentUser, + @PathVariable classroomId: Long, + @Valid @RequestBody request: SubmitMysteryRequest, + ): MysterySubmissionResponse { + val pupil = currentUserResolver.requirePupil(currentUser.subject) + val classroom = classroomUserResolver.requireClassroomMember(pupil, classroomId) + return mysteryService.submitExample( + SubmitMysteryCommand( + classroomId = classroomId, + classroom = classroom, + pupilUserId = requireNotNull(pupil.id) { "Pupil id missing" }, + description = request.description, + attachmentUrl = request.attachmentUrl, + ) + ).toResponse() + } + + /** + * Uploads an image attachment for a mystery submission. + * + * Files are stored under uploads/mystery/classroom-{classroomId}/. + * Returns the public URL to include as [attachmentUrl] in a subsequent submission. + * + * @param currentUser authenticated principal resolved from the JWT access token + * @param classroomId classroom to scope the upload to + * @param file the image file to upload + * @return the public URL of the stored file + */ + @PostMapping("/upload", consumes = [MediaType.MULTIPART_FORM_DATA_VALUE]) + @ResponseStatus(HttpStatus.CREATED) + fun uploadAttachment( + @AuthenticationPrincipal currentUser: CurrentUser, + @PathVariable classroomId: Long, + @RequestParam("file") file: MultipartFile, + ): Map { + val pupil = currentUserResolver.requirePupil(currentUser.subject) + classroomUserResolver.requireClassroomMember(pupil, classroomId) + val url = fileStorageService.store(file, "mystery/classroom-$classroomId") + return mapOf("url" to url) + } + + /** + * Returns the authenticated pupil's own submissions and their review status. + * + * @param currentUser authenticated principal resolved from the JWT access token + * @param classroomId classroom to scope the lookup to + * @return the pupil's submissions in this classroom + */ + @GetMapping("/submissions/my") + fun getMySubmissions( + @AuthenticationPrincipal currentUser: CurrentUser, + @PathVariable classroomId: Long, + ): List { + val pupil = currentUserResolver.requirePupil(currentUser.subject) + classroomUserResolver.requireClassroomMember(pupil, classroomId) + return mysteryService.getMySubmissions( + classroomId = classroomId, + pupilUserId = requireNotNull(pupil.id) { "Pupil id missing" }, + ).map { it.toResponse() } + } +} \ No newline at end of file diff --git a/src/main/kotlin/edu/ntnu/idi/idatt/backend/mystery/api/MysterySubmissionResponse.kt b/src/main/kotlin/edu/ntnu/idi/idatt/backend/mystery/api/MysterySubmissionResponse.kt new file mode 100644 index 00000000..bca6cb11 --- /dev/null +++ b/src/main/kotlin/edu/ntnu/idi/idatt/backend/mystery/api/MysterySubmissionResponse.kt @@ -0,0 +1,28 @@ +package edu.ntnu.idi.idatt.backend.mystery.api + +/** + * Serialized mystery submission returned by API endpoints. + * + * @property id Submission identifier. + * @property classroomId Classroom the submission belongs to. + * @property pupilUserId Pupil who submitted the example. + * @property description The pupil's written comment. + * @property attachmentUrl Optional URL to an uploaded image. + * @property submittedAt Timestamp when the submission was created. + * @property reviewStatus Current review state. + * @property reviewedByTeacherId Teacher who reviewed the submission, if reviewed. + * @property reviewedAt Timestamp when the submission was reviewed. + * @property teacherComment Optional feedback from the reviewing teacher. + */ +data class MysterySubmissionResponse( + val id: Long, + val classroomId: Long, + val pupilUserId: Long, + val description: String, + val attachmentUrl: String?, + val submittedAt: java.time.Instant?, + val reviewStatus: String, + val reviewedByTeacherId: Long?, + val reviewedAt: java.time.Instant?, + val teacherComment: String?, +) \ No newline at end of file diff --git a/src/main/kotlin/edu/ntnu/idi/idatt/backend/mystery/api/ReviewSubmissionRequest.kt b/src/main/kotlin/edu/ntnu/idi/idatt/backend/mystery/api/ReviewSubmissionRequest.kt new file mode 100644 index 00000000..f89f6b11 --- /dev/null +++ b/src/main/kotlin/edu/ntnu/idi/idatt/backend/mystery/api/ReviewSubmissionRequest.kt @@ -0,0 +1,12 @@ +package edu.ntnu.idi.idatt.backend.mystery.api + +import jakarta.validation.constraints.Size +/** + * Teacher request to approve or reject a submission. + * + * @property comment Optional feedback shown to the pupil. + */ +data class ReviewSubmissionRequest( + @field:Size(max = 1000) + val comment: String?, +) \ No newline at end of file diff --git a/src/main/kotlin/edu/ntnu/idi/idatt/backend/mystery/api/SubmitMysteryRequest.kt b/src/main/kotlin/edu/ntnu/idi/idatt/backend/mystery/api/SubmitMysteryRequest.kt new file mode 100644 index 00000000..9d1bf59a --- /dev/null +++ b/src/main/kotlin/edu/ntnu/idi/idatt/backend/mystery/api/SubmitMysteryRequest.kt @@ -0,0 +1,16 @@ +package edu.ntnu.idi.idatt.backend.mystery.api +import jakarta.validation.constraints.NotBlank +import jakarta.validation.constraints.Size +/** + * Pupil request to submit a new mystery example. + * + * @property description The pupil's written comment describing what they found. + * @property attachmentUrl Optional URL of an already-uploaded image or screenshot. + */ +data class SubmitMysteryRequest( + @field:NotBlank + @field:Size(max = 2000) + val description: String, + + val attachmentUrl: String?, +) \ No newline at end of file diff --git a/src/main/kotlin/edu/ntnu/idi/idatt/backend/mystery/api/TeacherMysteryController.kt b/src/main/kotlin/edu/ntnu/idi/idatt/backend/mystery/api/TeacherMysteryController.kt new file mode 100644 index 00000000..76139cc3 --- /dev/null +++ b/src/main/kotlin/edu/ntnu/idi/idatt/backend/mystery/api/TeacherMysteryController.kt @@ -0,0 +1,204 @@ +package edu.ntnu.idi.idatt.backend.mystery.api + +import edu.ntnu.idi.idatt.backend.iam.application.ClassroomUserResolver +import edu.ntnu.idi.idatt.backend.iam.application.CurrentUserResolver +import edu.ntnu.idi.idatt.backend.iam.security.CurrentUser +import edu.ntnu.idi.idatt.backend.mystery.application.FeatureMysteryCommand +import edu.ntnu.idi.idatt.backend.mystery.application.MysteryService +import edu.ntnu.idi.idatt.backend.mystery.application.ReviewSubmissionCommand +import edu.ntnu.idi.idatt.backend.mystery.application.UnfeatureMysteryCommand +import jakarta.validation.Valid +import org.springframework.http.HttpStatus +import org.springframework.security.access.prepost.PreAuthorize +import org.springframework.security.core.annotation.AuthenticationPrincipal +import org.springframework.web.bind.annotation.DeleteMapping +import org.springframework.web.bind.annotation.GetMapping +import org.springframework.web.bind.annotation.PatchMapping +import org.springframework.web.bind.annotation.PathVariable +import org.springframework.web.bind.annotation.PostMapping +import org.springframework.web.bind.annotation.RequestBody +import org.springframework.web.bind.annotation.RequestMapping +import org.springframework.web.bind.annotation.ResponseStatus +import org.springframework.web.bind.annotation.RestController + +/** + * Teacher-facing endpoints for managing mystery submissions and weekly featured mysteries. + * + * Teachers can review (approve/reject) pupil submissions and control which approved + * submissions are featured for a given week. + * + * @property mysteryService application service handling mystery operations + * @property currentUserResolver resolves and validates the authenticated user + * @property classroomUserResolver enforces classroom teacher access + */ +@RestController +@RequestMapping("/api/v1/teacher/classrooms/{classroomId}/mystery") +@PreAuthorize("hasRole('TEACHER')") +class TeacherMysteryController( + private val mysteryService: MysteryService, + private val currentUserResolver: CurrentUserResolver, + private val classroomUserResolver: ClassroomUserResolver, +) { + /** + * Returns all submissions for the classroom regardless of review status. + * + * @param currentUser authenticated principal resolved from the JWT access token + * @param classroomId classroom to scope the lookup to + * @return all submissions for the classroom + */ + @GetMapping("/submissions") + fun getAllSubmissions( + @AuthenticationPrincipal currentUser: CurrentUser, + @PathVariable classroomId: Long, + ): List { + val teacher = currentUserResolver.requireTeacherOrAdmin(currentUser.subject) + classroomUserResolver.requireClassroomTeacherAccess(teacher, classroomId) + return mysteryService.getAllSubmissions(classroomId).map { it.toResponse() } + } + + /** + * Returns only the pending submissions awaiting review. + * + * @param currentUser authenticated principal resolved from the JWT access token + * @param classroomId classroom to scope the lookup to + * @return submissions with status PENDING + */ + @GetMapping("/submissions/pending") + fun getPendingSubmissions( + @AuthenticationPrincipal currentUser: CurrentUser, + @PathVariable classroomId: Long, + ): List { + val teacher = currentUserResolver.requireTeacherOrAdmin(currentUser.subject) + classroomUserResolver.requireClassroomTeacherAccess(teacher, classroomId) + return mysteryService.getPendingSubmissions(classroomId).map { it.toResponse() } + } + + /** + * Approves a submission, making it eligible to be featured as a weekly mystery. + * Triggers a medal award for the pupil via a domain event. + * + * @param currentUser authenticated principal resolved from the JWT access token + * @param classroomId classroom to scope the operation to + * @param submissionId submission to approve + * @param request optional teacher comment + * @return the updated submission + */ + @PatchMapping("/submissions/{submissionId}/approve") + fun approveSubmission( + @AuthenticationPrincipal currentUser: CurrentUser, + @PathVariable classroomId: Long, + @PathVariable submissionId: Long, + @Valid @RequestBody request: ReviewSubmissionRequest, + ): MysterySubmissionResponse { + val teacher = currentUserResolver.requireTeacherOrAdmin(currentUser.subject) + classroomUserResolver.requireClassroomTeacherAccess(teacher, classroomId) + return mysteryService.approveSubmission( + ReviewSubmissionCommand( + submissionId = submissionId, + classroomId = classroomId, + teacherUserId = requireNotNull(teacher.id) { "Teacher id missing" }, + comment = request.comment, + ) + ).toResponse() + } + + /** + * Rejects a submission with an optional comment. + * + * @param currentUser authenticated principal resolved from the JWT access token + * @param classroomId classroom to scope the operation to + * @param submissionId submission to reject + * @param request optional teacher comment + * @return the updated submission + */ + @PatchMapping("/submissions/{submissionId}/reject") + fun rejectSubmission( + @AuthenticationPrincipal currentUser: CurrentUser, + @PathVariable classroomId: Long, + @PathVariable submissionId: Long, + @Valid @RequestBody request: ReviewSubmissionRequest, + ): MysterySubmissionResponse { + val teacher = currentUserResolver.requireTeacherOrAdmin(currentUser.subject) + classroomUserResolver.requireClassroomTeacherAccess(teacher, classroomId) + return mysteryService.rejectSubmission( + ReviewSubmissionCommand( + submissionId = submissionId, + classroomId = classroomId, + teacherUserId = requireNotNull(teacher.id) { "Teacher id missing" }, + comment = request.comment, + ) + ).toResponse() + } + + /** + * Features an approved submission for a given week. + * The [FeatureMysteryRequest.weekStart] is normalized to the Monday of its ISO week + * by the service layer, so any date within the target week is acceptable. + * + * @param currentUser authenticated principal resolved from the JWT access token + * @param classroomId classroom to scope the operation to + * @param request feature payload including submission id and target week + * @return the created weekly mystery entry + */ + @PostMapping("/weekly") + @ResponseStatus(HttpStatus.CREATED) + fun featureSubmission( + @AuthenticationPrincipal currentUser: CurrentUser, + @PathVariable classroomId: Long, + @Valid @RequestBody request: FeatureMysteryRequest, + ): WeeklyMysteryResponse { + val teacher = currentUserResolver.requireTeacherOrAdmin(currentUser.subject) + val classroom = classroomUserResolver.requireClassroomTeacherAccess(teacher, classroomId) + return mysteryService.featureSubmission( + FeatureMysteryCommand( + submissionId = request.submissionId, + classroomId = classroomId, + classroom = classroom, + teacherUserId = requireNotNull(teacher.id) { "Teacher id missing" }, + weekStart = request.weekStart, + ) + ).toResponse() + } + + /** + * Removes a featured mystery entry for a given week. + * + * @param currentUser authenticated principal resolved from the JWT access token + * @param classroomId classroom to scope the operation to + * @param weeklyMysteryId the weekly mystery entry to remove + */ + @DeleteMapping("/weekly/{weeklyMysteryId}") + @ResponseStatus(HttpStatus.NO_CONTENT) + fun unfeatureSubmission( + @AuthenticationPrincipal currentUser: CurrentUser, + @PathVariable classroomId: Long, + @PathVariable weeklyMysteryId: Long, + ) { + val teacher = currentUserResolver.requireTeacherOrAdmin(currentUser.subject) + classroomUserResolver.requireClassroomTeacherAccess(teacher, classroomId) + mysteryService.unfeatureSubmission( + UnfeatureMysteryCommand( + weeklyMysteryId = weeklyMysteryId, + classroomId = classroomId, + ) + ) + } + + /** + * Returns all featured mysteries for the classroom ordered by week descending. + * Used by the teacher management view to see current and past featured mysteries. + * + * @param currentUser authenticated principal resolved from the JWT access token + * @param classroomId classroom to scope the lookup to + * @return all weekly mystery entries, newest week first + */ + @GetMapping("/weekly") + fun getFeaturedMysteries( + @AuthenticationPrincipal currentUser: CurrentUser, + @PathVariable classroomId: Long, + ): List { + val teacher = currentUserResolver.requireTeacherOrAdmin(currentUser.subject) + classroomUserResolver.requireClassroomTeacherAccess(teacher, classroomId) + return mysteryService.getFeaturedMysteries(classroomId).map { it.toResponse() } + } +} diff --git a/src/main/kotlin/edu/ntnu/idi/idatt/backend/mystery/api/WeeklyMysteryResponse.kt b/src/main/kotlin/edu/ntnu/idi/idatt/backend/mystery/api/WeeklyMysteryResponse.kt new file mode 100644 index 00000000..6927c6c5 --- /dev/null +++ b/src/main/kotlin/edu/ntnu/idi/idatt/backend/mystery/api/WeeklyMysteryResponse.kt @@ -0,0 +1,21 @@ +package edu.ntnu.idi.idatt.backend.mystery.api +import java.time.LocalDate + +/** + * Serialized weekly mystery entry returned by API endpoints. + * + * @property id Weekly mystery entry identifier. + * @property classroomId Classroom this entry belongs to. + * @property weekStart The Monday of the ISO week this mystery is active. + * @property featuredByTeacherId Teacher who featured this submission. + * @property featuredAt Timestamp when the submission was featured. + * @property submission The featured submission. + */ +data class WeeklyMysteryResponse( + val id: Long, + val classroomId: Long, + val weekStart: LocalDate, + val featuredByTeacherId: Long, + val featuredAt: java.time.Instant?, + val submission: MysterySubmissionResponse, +) \ No newline at end of file diff --git a/src/main/kotlin/edu/ntnu/idi/idatt/backend/mystery/application/MysteryCommands.kt b/src/main/kotlin/edu/ntnu/idi/idatt/backend/mystery/application/MysteryCommands.kt new file mode 100644 index 00000000..072e425f --- /dev/null +++ b/src/main/kotlin/edu/ntnu/idi/idatt/backend/mystery/application/MysteryCommands.kt @@ -0,0 +1,32 @@ +package edu.ntnu.idi.idatt.backend.mystery.application + +import edu.ntnu.idi.idatt.backend.classroom.domain.Classroom +import java.time.LocalDate + +data class SubmitMysteryCommand( + val classroomId: Long, + val classroom: Classroom, + val pupilUserId: Long, + val description: String, + val attachmentUrl: String?, +) + +data class ReviewSubmissionCommand( + val submissionId: Long, + val classroomId: Long, + val teacherUserId: Long, + val comment: String?, +) + +data class FeatureMysteryCommand( + val submissionId: Long, + val classroomId: Long, + val classroom: Classroom, + val teacherUserId: Long, + val weekStart: LocalDate, +) + +data class UnfeatureMysteryCommand( + val weeklyMysteryId: Long, + val classroomId: Long, +) \ No newline at end of file diff --git a/src/main/kotlin/edu/ntnu/idi/idatt/backend/mystery/application/MysteryService.kt b/src/main/kotlin/edu/ntnu/idi/idatt/backend/mystery/application/MysteryService.kt new file mode 100644 index 00000000..783b2b0e --- /dev/null +++ b/src/main/kotlin/edu/ntnu/idi/idatt/backend/mystery/application/MysteryService.kt @@ -0,0 +1,252 @@ +package edu.ntnu.idi.idatt.backend.mystery.application + +import edu.ntnu.idi.idatt.backend.iam.infrastructure.UserRepository +import edu.ntnu.idi.idatt.backend.mystery.domain.MysterySubmission +import edu.ntnu.idi.idatt.backend.mystery.domain.ReviewStatus +import edu.ntnu.idi.idatt.backend.mystery.domain.WeeklyMystery +import edu.ntnu.idi.idatt.backend.mystery.infrastructure.MysterySubmissionRepository +import edu.ntnu.idi.idatt.backend.mystery.infrastructure.WeeklyMysteryRepository +import edu.ntnu.idi.idatt.backend.shared.events.DomainEventPublisher +import edu.ntnu.idi.idatt.backend.shared.events.WeeklyMysteryApprovedEvent +import edu.ntnu.idi.idatt.backend.shared.events.WeeklyMysterySubmittedEvent +import org.springframework.http.HttpStatus +import org.springframework.stereotype.Service +import org.springframework.transaction.annotation.Transactional +import org.springframework.web.server.ResponseStatusException +import java.time.DayOfWeek +import java.time.LocalDate +import java.time.temporal.TemporalAdjusters + +/** + * Application service for the weekly mystery feature. + * + * Pupil operations: submit examples, view their own submission status, browse weekly mysteries. + * Teacher operations: review (approve/reject) submissions, feature/unfeature approved submissions + * for a given week. + * + * @property submissionRepository Repository for mystery submissions. + * @property weeklyMysteryRepository Repository for weekly featured mysteries. + * @property userRepository Repository for loading user entities by id. + * @property domainEventPublisher Publisher for domain events. + */ +@Service +class MysteryService( + private val submissionRepository: MysterySubmissionRepository, + private val weeklyMysteryRepository: WeeklyMysteryRepository, + private val userRepository: UserRepository, + private val domainEventPublisher: DomainEventPublisher, +) { + + /** + * Creates a new mystery submission for a pupil and notifies teachers via a domain event. + * + * @param command the submission payload + * @return the persisted submission + * @throws ResponseStatusException 404 if the pupil or classroom cannot be resolved + */ + @Transactional + fun submitExample(command: SubmitMysteryCommand): MysterySubmission { + val pupil = userRepository.findById(command.pupilUserId).orElseThrow { + ResponseStatusException(HttpStatus.NOT_FOUND, "Pupil not found") + } + val submission = submissionRepository.save( + MysterySubmission( + classroom = command.classroom, + pupil = pupil, + description = command.description, + attachmentUrl = command.attachmentUrl, + ) + ) + + domainEventPublisher.publish( + WeeklyMysterySubmittedEvent( + pupilUserId = command.pupilUserId, + classroomId = command.classroomId, + submissionId = requireNotNull(submission.id) { "Submission id missing after save" }, + ) + ) + + return submission + } + + /** + * Returns all submissions made by a specific pupil in a classroom. + * + * @param classroomId classroom to scope the lookup to + * @param pupilUserId pupil whose submissions to retrieve + * @return list of the pupil's submissions, in no guaranteed order + */ + @Transactional(readOnly = true) + fun getMySubmissions(classroomId: Long, pupilUserId: Long): List = + submissionRepository.findAllByClassroomIdAndPupilId(classroomId, pupilUserId) + + /** + * Returns all mysteries featured for the current ISO week in a classroom. + * + * @param classroomId classroom to scope the lookup to + * @return featured mysteries for this week, may be empty + */ + @Transactional(readOnly = true) + fun getCurrentWeekMysteries(classroomId: Long): List = + weeklyMysteryRepository.findAllByClassroomIdAndWeekStart(classroomId, currentWeekStart()) + + /** + * Returns all featured mysteries for a classroom ordered by week descending. + * Used by pupils to browse previous weeks. + * + * @param classroomId classroom to scope the lookup to + * @return all weekly mysteries, newest week first + */ + @Transactional(readOnly = true) + fun getAllWeeklyMysteries(classroomId: Long): List = + weeklyMysteryRepository.findAllByClassroomIdOrderByWeekStartDesc(classroomId) + + /** + * Returns all submissions for a classroom regardless of review status. + * + * @param classroomId classroom to scope the lookup to + * @return all submissions for the classroom + */ + @Transactional(readOnly = true) + fun getAllSubmissions(classroomId: Long): List = + submissionRepository.findAllByClassroomId(classroomId) + + /** + * Returns all pending submissions for a classroom. + * + * @param classroomId classroom to scope the lookup to + * @return submissions awaiting teacher review + */ + @Transactional(readOnly = true) + fun getPendingSubmissions(classroomId: Long): List = + submissionRepository.findAllByClassroomIdAndReviewStatus(classroomId, ReviewStatus.PENDING) + + /** + * Approves a submission and publishes a [MysterySubmissionApprovedEvent] which triggers + * the medal award for the pupil. + * + * @param command approval payload including the reviewing teacher + * @return the updated submission + * @throws ResponseStatusException 404 if the submission does not exist in the classroom + * @throws ResponseStatusException 404 if the reviewing teacher cannot be resolved + * @throws IllegalStateException if the submission has already been reviewed + */ + @Transactional + fun approveSubmission(command: ReviewSubmissionCommand): MysterySubmission { + val submission = requireSubmissionInClassroom(command.submissionId, command.classroomId) + val teacher = userRepository.findById(command.teacherUserId).orElseThrow { + ResponseStatusException(HttpStatus.NOT_FOUND, "Teacher not found") + } + + submission.approve(teacher, command.comment) + val saved = submissionRepository.save(submission) + + domainEventPublisher.publish( + WeeklyMysteryApprovedEvent( + submissionId = requireNotNull(saved.id) { "Submission id missing after save" }, + pupilUserId = requireNotNull(saved.pupil.id) { "Pupil id missing" }, + classroomId = command.classroomId, + ) + ) + + return saved + } + + /** + * Rejects a submission with an optional teacher comment. + * + * @param command rejection payload including the reviewing teacher + * @return the updated submission + * @throws ResponseStatusException 404 if the submission does not exist in the classroom + * @throws ResponseStatusException 404 if the reviewing teacher cannot be resolved + * @throws IllegalStateException if the submission has already been reviewed + */ + @Transactional + fun rejectSubmission(command: ReviewSubmissionCommand): MysterySubmission { + val submission = requireSubmissionInClassroom(command.submissionId, command.classroomId) + val teacher = userRepository.findById(command.teacherUserId).orElseThrow { + ResponseStatusException(HttpStatus.NOT_FOUND, "Teacher not found") + } + + submission.reject(teacher, command.comment) + return submissionRepository.save(submission) + } + + /** + * Features an approved submission for a given week in the classroom. + * + * The [FeatureMysteryCommand.weekStart] must be the Monday of the target ISO week. + * Use [toWeekStart] to normalize any date before building the command. + * + * @param command feature payload including the target week + * @return the created [WeeklyMystery] entry + * @throws ResponseStatusException 404 if the submission does not exist in the classroom + * @throws ResponseStatusException 404 if the featuring teacher cannot be resolved + * @throws ResponseStatusException 422 if the submission has not been approved + */ + @Transactional + fun featureSubmission(command: FeatureMysteryCommand): WeeklyMystery { + val submission = requireSubmissionInClassroom(command.submissionId, command.classroomId) + val teacher = userRepository.findById(command.teacherUserId).orElseThrow { + ResponseStatusException(HttpStatus.NOT_FOUND, "Teacher not found") + } + + if (!submission.isApproved) { + throw ResponseStatusException( + HttpStatus.UNPROCESSABLE_ENTITY, + "Only approved submissions can be featured", + ) + } + + return weeklyMysteryRepository.save( + WeeklyMystery( + classroom = command.classroom, + submission = submission, + weekStart = command.weekStart.toWeekStart(), + featuredBy = teacher, + ) + ) + } + + /** + * Removes a featured mystery entry so the teacher can un-feature a submission from a week. + * + * @param command unfeature payload scoped to the classroom + * @throws ResponseStatusException 404 if the entry does not exist in the classroom + */ + @Transactional + fun unfeatureSubmission(command: UnfeatureMysteryCommand) { + val deleted = weeklyMysteryRepository.deleteByIdAndClassroomId( + command.weeklyMysteryId, + command.classroomId, + ) + if (deleted == 0L) { + throw ResponseStatusException(HttpStatus.NOT_FOUND, "Weekly mystery entry not found") + } + } + + /** + * Returns all featured mysteries for the teacher management view, ordered by week descending. + * + * @param classroomId classroom to scope the lookup to + * @return all weekly mysteries, newest week first + */ + @Transactional(readOnly = true) + fun getFeaturedMysteries(classroomId: Long): List = + weeklyMysteryRepository.findAllByClassroomIdOrderByWeekStartDesc(classroomId) + + + private fun requireSubmissionInClassroom(submissionId: Long, classroomId: Long): MysterySubmission = + submissionRepository.findByIdAndClassroomId(submissionId, classroomId) + ?: throw ResponseStatusException(HttpStatus.NOT_FOUND, "Submission not found") + + companion object { + /** Returns the Monday of the ISO week containing [this] date. */ + fun LocalDate.toWeekStart(): LocalDate = + with(TemporalAdjusters.previousOrSame(DayOfWeek.MONDAY)) + + /** Returns the Monday of the current ISO week. */ + fun currentWeekStart(): LocalDate = + LocalDate.now().toWeekStart() + } +} \ No newline at end of file diff --git a/src/main/kotlin/edu/ntnu/idi/idatt/backend/mystery/domain/MysterySubmission.kt b/src/main/kotlin/edu/ntnu/idi/idatt/backend/mystery/domain/MysterySubmission.kt new file mode 100644 index 00000000..639a44f9 --- /dev/null +++ b/src/main/kotlin/edu/ntnu/idi/idatt/backend/mystery/domain/MysterySubmission.kt @@ -0,0 +1,111 @@ +package edu.ntnu.idi.idatt.backend.mystery.domain + +import edu.ntnu.idi.idatt.backend.classroom.domain.Classroom +import edu.ntnu.idi.idatt.backend.iam.domain.User +import jakarta.persistence.Column +import jakarta.persistence.Entity +import jakarta.persistence.EnumType +import jakarta.persistence.Enumerated +import jakarta.persistence.FetchType +import jakarta.persistence.GeneratedValue +import jakarta.persistence.GenerationType +import jakarta.persistence.Id +import jakarta.persistence.JoinColumn +import jakarta.persistence.ManyToOne +import jakarta.persistence.Table +import org.hibernate.annotations.CreationTimestamp +import java.time.Instant + +/** + * A pupil-submitted example for the weekly mystery feature. + * + * Submissions start as [ReviewStatus.PENDING] and are either [ReviewStatus.APPROVED] + * or [ReviewStatus.REJECTED] by a teacher. Approved submissions are eligible to be + * featured as a [WeeklyMystery]. + * + * @property id Auto-generated primary key. + * @property classroom The classroom this submission belongs to. + * @property pupil The pupil who submitted the example. + * @property description The pupil's written comment describing what they found. + * @property attachmentUrl Optional URL to an uploaded image or screenshot. + * @property submittedAt Timestamp when the submission was created. + * @property reviewStatus Current review state of the submission. + * @property reviewedBy The teacher who reviewed this submission, if reviewed. + * @property reviewedAt Timestamp when the submission was reviewed. + * @property teacherComment Optional feedback from the reviewing teacher. + */ +@Entity +@Table(name = "mystery_submissions") +class MysterySubmission( + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + val id: Long = 0, + + @ManyToOne(fetch = FetchType.LAZY, optional = false) + @JoinColumn(name = "classroom_id", nullable = false) + val classroom: Classroom, + + @ManyToOne(fetch = FetchType.LAZY, optional = false) + @JoinColumn(name = "pupil_user_id", nullable = false) + val pupil: User, + + @Column(name = "description", nullable = false, columnDefinition = "TEXT") + val description: String, + + @Column(name = "attachment_url", length = 512) + val attachmentUrl: String? = null, + + @CreationTimestamp + @Column(name = "submitted_at", nullable = false, updatable = false) + val submittedAt: Instant? = null, + + @Enumerated(EnumType.STRING) + @Column(name = "review_status", nullable = false) + var reviewStatus: ReviewStatus = ReviewStatus.PENDING, + + @ManyToOne(fetch = FetchType.LAZY, optional = true) + @JoinColumn(name = "reviewed_by_teacher_id", nullable = true) + var reviewedBy: User? = null, + + @Column(name = "reviewed_at") + var reviewedAt: Instant? = null, + + @Column(name = "teacher_comment", columnDefinition = "TEXT") + var teacherComment: String? = null, +) { + /** + * Approves this submission, recording the reviewing teacher and an optional comment. + * + * @param teacher The teacher approving the submission. + * @param comment Optional feedback to show the pupil. + * @throws IllegalStateException if the submission has already been reviewed. + */ + fun approve(teacher: User, comment: String? = null) { + check(reviewStatus == ReviewStatus.PENDING) { + "Cannot approve a submission that is already $reviewStatus" + } + reviewStatus = ReviewStatus.APPROVED + reviewedBy = teacher + reviewedAt = Instant.now() + teacherComment = comment + } + + /** + * Rejects this submission, recording the reviewing teacher and an optional comment. + * + * @param teacher The teacher rejecting the submission. + * @param comment Optional feedback to show the pupil. + * @throws IllegalStateException if the submission has already been reviewed. + */ + fun reject(teacher: User, comment: String? = null) { + check(reviewStatus == ReviewStatus.PENDING) { + "Cannot reject a submission that is already $reviewStatus" + } + reviewStatus = ReviewStatus.REJECTED + reviewedBy = teacher + reviewedAt = Instant.now() + teacherComment = comment + } + + val isApproved: Boolean get() = reviewStatus == ReviewStatus.APPROVED +} \ No newline at end of file diff --git a/src/main/kotlin/edu/ntnu/idi/idatt/backend/mystery/domain/ReviewStatus.kt b/src/main/kotlin/edu/ntnu/idi/idatt/backend/mystery/domain/ReviewStatus.kt new file mode 100644 index 00000000..52e2d7cf --- /dev/null +++ b/src/main/kotlin/edu/ntnu/idi/idatt/backend/mystery/domain/ReviewStatus.kt @@ -0,0 +1,7 @@ +package edu.ntnu.idi.idatt.backend.mystery.domain + +enum class ReviewStatus { + PENDING, + APPROVED, + REJECTED, +} \ No newline at end of file diff --git a/src/main/kotlin/edu/ntnu/idi/idatt/backend/mystery/domain/WeeklyMystery.kt b/src/main/kotlin/edu/ntnu/idi/idatt/backend/mystery/domain/WeeklyMystery.kt new file mode 100644 index 00000000..739e7f3b --- /dev/null +++ b/src/main/kotlin/edu/ntnu/idi/idatt/backend/mystery/domain/WeeklyMystery.kt @@ -0,0 +1,59 @@ +package edu.ntnu.idi.idatt.backend.mystery.domain + +import edu.ntnu.idi.idatt.backend.classroom.domain.Classroom +import edu.ntnu.idi.idatt.backend.iam.domain.User +import jakarta.persistence.Column +import jakarta.persistence.Entity +import jakarta.persistence.FetchType +import jakarta.persistence.GeneratedValue +import jakarta.persistence.GenerationType +import jakarta.persistence.Id +import jakarta.persistence.JoinColumn +import jakarta.persistence.ManyToOne +import jakarta.persistence.Table +import org.hibernate.annotations.CreationTimestamp +import java.time.Instant +import java.time.LocalDate + +/** + * Represents a teacher-curated mystery displayed to pupils for a given week. + * + * A teacher promotes a [MysterySubmission] that has been approved into the weekly + * spotlight. Multiple mysteries can be featured per classroom per week, and the same + * submission can be re-featured across different weeks if needed. + * + * [weekStart] is always the Monday of the ISO week the mystery is active. + * + * @property id Auto-generated primary key. + * @property classroom The classroom this weekly mystery belongs to. + * @property submission The approved submission being featured. + * @property weekStart The Monday of the ISO week this mystery is active. + * @property featuredBy The teacher who promoted this submission. + * @property featuredAt Timestamp when the submission was promoted. + */ +@Entity +@Table(name = "weekly_mysteries") +class WeeklyMystery( + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + val id: Long = 0, + + @ManyToOne(fetch = FetchType.LAZY, optional = false) + @JoinColumn(name = "classroom_id", nullable = false) + val classroom: Classroom, + + @ManyToOne(fetch = FetchType.LAZY, optional = false) + @JoinColumn(name = "submission_id", nullable = false) + val submission: MysterySubmission, + + @Column(name = "week_start", nullable = false) + val weekStart: LocalDate, + + @ManyToOne(fetch = FetchType.LAZY, optional = false) + @JoinColumn(name = "featured_by_teacher_id", nullable = false) + val featuredBy: User, + + @CreationTimestamp + @Column(name = "featured_at", nullable = false, updatable = false) + val featuredAt: Instant? = null, +) \ No newline at end of file diff --git a/src/main/kotlin/edu/ntnu/idi/idatt/backend/mystery/infrastructure/MysterySubmissionRepository.kt b/src/main/kotlin/edu/ntnu/idi/idatt/backend/mystery/infrastructure/MysterySubmissionRepository.kt new file mode 100644 index 00000000..b991a701 --- /dev/null +++ b/src/main/kotlin/edu/ntnu/idi/idatt/backend/mystery/infrastructure/MysterySubmissionRepository.kt @@ -0,0 +1,31 @@ +package edu.ntnu.idi.idatt.backend.mystery.infrastructure + +import edu.ntnu.idi.idatt.backend.mystery.domain.MysterySubmission +import edu.ntnu.idi.idatt.backend.mystery.domain.ReviewStatus +import org.springframework.data.jpa.repository.JpaRepository +import org.springframework.stereotype.Repository + +@Repository +interface MysterySubmissionRepository : JpaRepository { + + /** All submissions for a classroom, regardless of status. Used by the teacher review view. */ + fun findAllByClassroomId(classroomId: Long): List + + /** Submissions filtered by review status. Used to show only pending or only approved. */ + fun findAllByClassroomIdAndReviewStatus( + classroomId: Long, + reviewStatus: ReviewStatus, + ): List + + /** A pupil's own submissions in a classroom, so they can track approval status. */ + fun findAllByClassroomIdAndPupilId( + classroomId: Long, + pupilId: Long, + ): List + + /** + * Classroom-scoped fetch by id. Prevents a teacher from reviewing a submission + * that belongs to a different classroom. + */ + fun findByIdAndClassroomId(id: Long, classroomId: Long): MysterySubmission? +} \ No newline at end of file diff --git a/src/main/kotlin/edu/ntnu/idi/idatt/backend/mystery/infrastructure/WeeklyMysteryRepository.kt b/src/main/kotlin/edu/ntnu/idi/idatt/backend/mystery/infrastructure/WeeklyMysteryRepository.kt new file mode 100644 index 00000000..2e19d4e7 --- /dev/null +++ b/src/main/kotlin/edu/ntnu/idi/idatt/backend/mystery/infrastructure/WeeklyMysteryRepository.kt @@ -0,0 +1,29 @@ +package edu.ntnu.idi.idatt.backend.mystery.infrastructure + +import edu.ntnu.idi.idatt.backend.mystery.domain.WeeklyMystery +import org.springframework.data.jpa.repository.JpaRepository +import org.springframework.stereotype.Repository +import java.time.LocalDate + +@Repository +interface WeeklyMysteryRepository : JpaRepository { + + /** All mysteries featured for a specific week. The primary pupil-facing query. */ + fun findAllByClassroomIdAndWeekStart( + classroomId: Long, + weekStart: LocalDate, + ): List + + /** Full history ordered newest-first. Used by the teacher management view. */ + fun findAllByClassroomIdOrderByWeekStartDesc(classroomId: Long): List + + /** Whether a given submission is already featured in a specific week. Used for UI hints. */ + fun existsByClassroomIdAndSubmissionIdAndWeekStart( + classroomId: Long, + submissionId: Long, + weekStart: LocalDate, + ): Boolean + + /** Removes a specific featured entry so a teacher can un-feature a submission from a week. */ + fun deleteByIdAndClassroomId(id: Long, classroomId: Long): Long +} \ No newline at end of file diff --git a/src/main/kotlin/edu/ntnu/idi/idatt/backend/shared/events/WeeklyMysteryApprovedEvent.kt b/src/main/kotlin/edu/ntnu/idi/idatt/backend/shared/events/WeeklyMysteryApprovedEvent.kt new file mode 100644 index 00000000..98e56e4e --- /dev/null +++ b/src/main/kotlin/edu/ntnu/idi/idatt/backend/shared/events/WeeklyMysteryApprovedEvent.kt @@ -0,0 +1,10 @@ +package edu.ntnu.idi.idatt.backend.shared.events + +import java.time.Instant + +data class WeeklyMysteryApprovedEvent( + val submissionId: Long, + val pupilUserId: Long, + val classroomId: Long, + override val occurredAt: Instant = Instant.now(), +) : DomainEvent \ No newline at end of file diff --git a/src/main/kotlin/edu/ntnu/idi/idatt/backend/upload/FileUploadService.kt b/src/main/kotlin/edu/ntnu/idi/idatt/backend/upload/FileUploadService.kt new file mode 100644 index 00000000..5cffd15e --- /dev/null +++ b/src/main/kotlin/edu/ntnu/idi/idatt/backend/upload/FileUploadService.kt @@ -0,0 +1,63 @@ +package edu.ntnu.idi.idatt.backend.upload.application + +import org.springframework.beans.factory.annotation.Value +import org.springframework.http.HttpStatus +import org.springframework.http.MediaType +import org.springframework.stereotype.Service +import org.springframework.web.multipart.MultipartFile +import org.springframework.web.server.ResponseStatusException +import java.nio.file.Files +import java.nio.file.Paths +import java.util.UUID + +/** + * Handles storage of uploaded files to the local filesystem. + * + * Files are written under [baseStorageDir]/[subdirectory] and served under [baseUrl]. + * Callers supply the subdirectory to keep uploads organised by feature and context + * (e.g. "mystery/classroom-42"). + * + * To swap storage backends (S3, MinIO, etc.), replace this service + * without touching any controller. + */ +@Service +class FileStorageService( + @Value("\${app.uploads.base-url}") private val baseUrl: String, + @Value("\${app.uploads.base-dir:\${user.home}/nettdetektivene/uploads}") private val baseDirPath: String, +) { + + private val baseStorageDir by lazy { Paths.get(baseDirPath).toAbsolutePath() } + private val allowedMediaTypes = mapOf( + MediaType.IMAGE_JPEG to "jpg", + MediaType.IMAGE_PNG to "png", + MediaType.IMAGE_GIF to "gif", + MediaType.parseMediaType("image/webp") to "webp", + ) + + /** + * Validates and stores an uploaded file under [subdirectory]. + * + * @param file The multipart file to store. + * @param subdirectory Relative path under the base storage dir, e.g. "mystery/classroom-42". + * @return The absolute public URL the file can be accessed at. + * @throws IllegalArgumentException if the file type is missing or not an allowed image type. + */ + fun store(file: MultipartFile, subdirectory: String): String { + val contentType = file.contentType + ?.let { runCatching { MediaType.parseMediaType(it) }.getOrNull() } + ?: throw ResponseStatusException(HttpStatus.BAD_REQUEST, "Missing content type") + + val extension = allowedMediaTypes.entries + .firstOrNull { (mediaType, _) -> mediaType.isCompatibleWith(contentType) } + ?.value + ?: throw ResponseStatusException(HttpStatus.BAD_REQUEST, "Unsupported file type: $contentType") + + val targetDir = baseStorageDir.resolve(subdirectory).normalize() + Files.createDirectories(targetDir) + + val filename = "${UUID.randomUUID()}.$extension" + file.transferTo(targetDir.resolve(filename)) + + return "${baseUrl.trimEnd('/')}/uploads/$subdirectory/$filename" + } +} \ No newline at end of file diff --git a/src/main/kotlin/edu/ntnu/idi/idatt/backend/upload/WebMvcConfig.kt b/src/main/kotlin/edu/ntnu/idi/idatt/backend/upload/WebMvcConfig.kt new file mode 100644 index 00000000..95b84b6c --- /dev/null +++ b/src/main/kotlin/edu/ntnu/idi/idatt/backend/upload/WebMvcConfig.kt @@ -0,0 +1,31 @@ +package edu.ntnu.idi.idatt.backend.app.config + +import org.springframework.beans.factory.annotation.Value +import org.springframework.context.annotation.Configuration +import org.springframework.web.servlet.config.annotation.ResourceHandlerRegistry +import org.springframework.web.servlet.config.annotation.WebMvcConfigurer +import java.nio.file.Paths + +/** + * Serves locally uploaded files under /uploads/ as static resources. + * + * Uses the same [baseDirPath] as [FileStorageService] so the served directory + * always matches where files are written. + * + * In production, replace this with a CDN or object storage URL — the + * upload controller returns whatever URL is configured, so no other + * code needs to change. +*/ +@Configuration +class WebMvcConfig( +@Value("\${app.uploads.base-dir:\${user.home}/nettdetektivene/uploads}") private val baseDirPath: String, +) : WebMvcConfigurer { + +override fun addResourceHandlers(registry: ResourceHandlerRegistry) { +val uploadPath = Paths.get(baseDirPath).toAbsolutePath().toUri().toString() + +registry +.addResourceHandler("/uploads/**") +.addResourceLocations(uploadPath) +} +} \ No newline at end of file diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties index bc20d06b..771590a6 100644 --- a/src/main/resources/application.properties +++ b/src/main/resources/application.properties @@ -13,3 +13,6 @@ app.security.refresh.same-site=${APP_SECURITY_REFRESH_SAME_SITE} app.security.refresh.secure-cookie=${APP_SECURITY_REFRESH_SECURE_COOKIE} app.security.refresh.ttl=${APP_SECURITY_REFRESH_TTL} app.security.cors.allowed-origins=${APP_SECURITY_CORS_ALLOWED_ORIGINS} + +app.uploads.base-url=${APP_UPLOADS_BASE_URL:http://localhost:8080} +app.uploads.base-dir=uploads \ No newline at end of file diff --git a/src/main/resources/db/migration/V24__create_mystery_tables.sql b/src/main/resources/db/migration/V24__create_mystery_tables.sql new file mode 100644 index 00000000..1389ab43 --- /dev/null +++ b/src/main/resources/db/migration/V24__create_mystery_tables.sql @@ -0,0 +1,41 @@ +CREATE TABLE mystery_submissions ( + id BIGINT NOT NULL AUTO_INCREMENT, + classroom_id BIGINT NOT NULL, + pupil_user_id BIGINT NOT NULL, + description TEXT NOT NULL, + attachment_url VARCHAR(512), + submitted_at DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6), + review_status ENUM('PENDING', 'APPROVED', 'REJECTED') NOT NULL DEFAULT 'PENDING', + reviewed_by_teacher_id BIGINT, + reviewed_at DATETIME(6), + teacher_comment TEXT, + CONSTRAINT pk_mystery_submissions PRIMARY KEY (id), + CONSTRAINT fk_mystery_submissions_classroom + FOREIGN KEY (classroom_id) REFERENCES classrooms (id) ON DELETE CASCADE, + CONSTRAINT fk_mystery_submissions_pupil + FOREIGN KEY (pupil_user_id) REFERENCES users (id) ON DELETE CASCADE, + CONSTRAINT fk_mystery_submissions_reviewer + FOREIGN KEY (reviewed_by_teacher_id) REFERENCES users (id) ON DELETE SET NULL +); + +CREATE INDEX idx_mystery_submissions_classroom ON mystery_submissions (classroom_id); +CREATE INDEX idx_mystery_submissions_pupil ON mystery_submissions (pupil_user_id); +CREATE INDEX idx_mystery_submissions_status ON mystery_submissions (classroom_id, review_status); + +CREATE TABLE weekly_mysteries ( + id BIGINT NOT NULL AUTO_INCREMENT, + classroom_id BIGINT NOT NULL, + submission_id BIGINT NOT NULL, + week_start DATE NOT NULL, + featured_by_teacher_id BIGINT NOT NULL, + featured_at DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6), + CONSTRAINT pk_weekly_mysteries PRIMARY KEY (id), + CONSTRAINT fk_weekly_mysteries_classroom + FOREIGN KEY (classroom_id) REFERENCES classrooms (id) ON DELETE CASCADE, + CONSTRAINT fk_weekly_mysteries_submission + FOREIGN KEY (submission_id) REFERENCES mystery_submissions (id) ON DELETE CASCADE, + CONSTRAINT fk_weekly_mysteries_teacher + FOREIGN KEY (featured_by_teacher_id) REFERENCES users (id) ON DELETE CASCADE +); + +CREATE INDEX idx_weekly_mysteries_classroom_week ON weekly_mysteries (classroom_id, week_start); \ No newline at end of file diff --git a/src/test/kotlin/edu/ntnu/idi/idatt/backend/mystery/MysteryControllerIntegrationTests.kt b/src/test/kotlin/edu/ntnu/idi/idatt/backend/mystery/MysteryControllerIntegrationTests.kt new file mode 100644 index 00000000..2b2afc8c --- /dev/null +++ b/src/test/kotlin/edu/ntnu/idi/idatt/backend/mystery/MysteryControllerIntegrationTests.kt @@ -0,0 +1,292 @@ +package edu.ntnu.idi.idatt.backend.mystery + +import com.fasterxml.jackson.databind.json.JsonMapper +import edu.ntnu.idi.idatt.backend.classroom.domain.Classroom +import edu.ntnu.idi.idatt.backend.classroom.domain.ClassroomStatus +import edu.ntnu.idi.idatt.backend.classroom.infrastructure.ClassroomRepository +import edu.ntnu.idi.idatt.backend.iam.domain.User +import edu.ntnu.idi.idatt.backend.iam.domain.UserRole +import edu.ntnu.idi.idatt.backend.iam.domain.UserStatus +import edu.ntnu.idi.idatt.backend.iam.infrastructure.RefreshTokenRepository +import edu.ntnu.idi.idatt.backend.iam.infrastructure.UserRepository +import edu.ntnu.idi.idatt.backend.mystery.domain.MysterySubmission +import edu.ntnu.idi.idatt.backend.mystery.domain.ReviewStatus +import edu.ntnu.idi.idatt.backend.mystery.domain.WeeklyMystery +import edu.ntnu.idi.idatt.backend.mystery.infrastructure.MysterySubmissionRepository +import edu.ntnu.idi.idatt.backend.mystery.infrastructure.WeeklyMysteryRepository +import edu.ntnu.idi.idatt.backend.users.avatar.infrastructure.UserAvatarRepository +import edu.ntnu.idi.idatt.backend.users.profile.infrastructure.PupilProfileRepository +import edu.ntnu.idi.idatt.backend.users.profile.infrastructure.TeacherProfileRepository +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.boot.test.context.SpringBootTest +import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc +import org.springframework.http.HttpHeaders +import org.springframework.http.MediaType +import org.springframework.security.crypto.password.PasswordEncoder +import org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf +import org.springframework.test.web.servlet.MockMvc +import org.springframework.test.web.servlet.get +import org.springframework.test.web.servlet.post +import java.time.LocalDate + +@SpringBootTest +@AutoConfigureMockMvc +class MysteryControllerIntegrationTests { + + @Autowired private lateinit var mockMvc: MockMvc + @Autowired private lateinit var userRepository: UserRepository + @Autowired private lateinit var classroomRepository: ClassroomRepository + @Autowired private lateinit var submissionRepository: MysterySubmissionRepository + @Autowired private lateinit var weeklyMysteryRepository: WeeklyMysteryRepository + @Autowired private lateinit var refreshTokenRepository: RefreshTokenRepository + @Autowired private lateinit var userAvatarRepository: UserAvatarRepository + @Autowired private lateinit var pupilProfileRepository: PupilProfileRepository + @Autowired private lateinit var teacherProfileRepository: TeacherProfileRepository + @Autowired private lateinit var passwordEncoder: PasswordEncoder + + private val jsonMapper = JsonMapper.builder().build() + + private lateinit var classroom: Classroom + private lateinit var teacher: User + private lateinit var pupil: User + + @BeforeEach + fun setUp() { + weeklyMysteryRepository.deleteAll() + submissionRepository.deleteAll() + refreshTokenRepository.deleteAll() + classroomRepository.deleteAll() + userAvatarRepository.deleteAll() + pupilProfileRepository.deleteAll() + teacherProfileRepository.deleteAll() + userRepository.deleteAll() + + teacher = userRepository.save(activeTeacher("teacher-mystery@test.com")) + pupil = userRepository.save(activePupil("mystery-pupil")) + classroom = classroomRepository.save( + Classroom( + ownerTeacherId = requireNotNull(teacher.id), + title = "Mystery Class", + description = "desc", + joinCode = "MYST1", + status = ClassroomStatus.ACTIVE, + ) + ) + classroom.addTeacher(teacher) + classroom.addPupilMembership(pupil) + classroomRepository.save(classroom) + } + + @Test + fun `GET weekly-current requires authentication`() { + mockMvc.get("/api/v1/classrooms/${classroom.id}/mystery/weekly/current") { + with(csrf()) + }.andExpect { + status { isUnauthorized() } + } + } + + @Test + fun `GET weekly-current returns featured mysteries for this week`() { + val submission = approvedSubmission() + weeklyMysteryRepository.save( + WeeklyMystery( + classroom = classroom, + submission = submission, + weekStart = mondayOfCurrentWeek(), + featuredBy = teacher, + ) + ) + + mockMvc.get("/api/v1/classrooms/${classroom.id}/mystery/weekly/current") { + with(csrf()) + header(HttpHeaders.AUTHORIZATION, "Bearer ${pupilToken()}") + }.andExpect { + status { isOk() } + jsonPath("$.length()") { value(1) } + jsonPath("$[0].submission.reviewStatus") { value("APPROVED") } + } + } + + @Test + fun `GET weekly-current returns empty list when no mysteries featured this week`() { + mockMvc.get("/api/v1/classrooms/${classroom.id}/mystery/weekly/current") { + with(csrf()) + header(HttpHeaders.AUTHORIZATION, "Bearer ${pupilToken()}") + }.andExpect { + status { isOk() } + jsonPath("$.length()") { value(0) } + } + } + + @Test + fun `GET weekly returns all featured mysteries sorted newest-first`() { + val submission1 = approvedSubmission() + val submission2 = approvedSubmission() + weeklyMysteryRepository.save( + WeeklyMystery(classroom = classroom, submission = submission1, weekStart = mondayOfCurrentWeek(), featuredBy = teacher) + ) + weeklyMysteryRepository.save( + WeeklyMystery(classroom = classroom, submission = submission2, weekStart = mondayOfCurrentWeek().minusWeeks(1), featuredBy = teacher) + ) + + mockMvc.get("/api/v1/classrooms/${classroom.id}/mystery/weekly") { + with(csrf()) + header(HttpHeaders.AUTHORIZATION, "Bearer ${pupilToken()}") + }.andExpect { + status { isOk() } + jsonPath("$.length()") { value(2) } + } + } + + @Test + fun `POST submissions creates a pending submission`() { + mockMvc.post("/api/v1/classrooms/${classroom.id}/mystery/submissions") { + with(csrf()) + contentType = MediaType.APPLICATION_JSON + header(HttpHeaders.AUTHORIZATION, "Bearer ${pupilToken()}") + content = """{"description":"I found a fake news article","attachmentUrl":null}""" + }.andExpect { + status { isCreated() } + jsonPath("$.reviewStatus") { value("PENDING") } + jsonPath("$.description") { value("I found a fake news article") } + jsonPath("$.pupilUserId") { value(pupil.id!!.toInt()) } + } + + assertEquals(1, submissionRepository.findAllByClassroomId(classroom.id).size) + } + + @Test + fun `POST submissions requires authentication`() { + mockMvc.post("/api/v1/classrooms/${classroom.id}/mystery/submissions") { + with(csrf()) + contentType = MediaType.APPLICATION_JSON + content = """{"description":"test","attachmentUrl":null}""" + }.andExpect { + status { isUnauthorized() } + } + } + + @Test + fun `POST submissions rejects blank description`() { + mockMvc.post("/api/v1/classrooms/${classroom.id}/mystery/submissions") { + with(csrf()) + contentType = MediaType.APPLICATION_JSON + header(HttpHeaders.AUTHORIZATION, "Bearer ${pupilToken()}") + content = """{"description":"","attachmentUrl":null}""" + }.andExpect { + status { isBadRequest() } + } + } + + @Test + fun `POST submissions rejects description exceeding 2000 characters`() { + val tooLong = "a".repeat(2001) + mockMvc.post("/api/v1/classrooms/${classroom.id}/mystery/submissions") { + with(csrf()) + contentType = MediaType.APPLICATION_JSON + header(HttpHeaders.AUTHORIZATION, "Bearer ${pupilToken()}") + content = """{"description":"$tooLong","attachmentUrl":null}""" + }.andExpect { + status { isBadRequest() } + } + } + + @Test + fun `POST submissions is forbidden for teachers`() { + mockMvc.post("/api/v1/classrooms/${classroom.id}/mystery/submissions") { + with(csrf()) + contentType = MediaType.APPLICATION_JSON + header(HttpHeaders.AUTHORIZATION, "Bearer ${teacherToken()}") + content = """{"description":"test","attachmentUrl":null}""" + }.andExpect { + status { isForbidden() } + } + } + + @Test + fun `GET submissions-my returns only the authenticated pupil's submissions`() { + val otherPupil = userRepository.save(activePupil("other-pupil")) + classroom.addPupilMembership(otherPupil) + classroomRepository.save(classroom) + + // own submission + submissionRepository.save( + MysterySubmission(classroom = classroom, pupil = pupil, description = "mine") + ) + // other pupil's submission — should NOT appear + submissionRepository.save( + MysterySubmission(classroom = classroom, pupil = otherPupil, description = "not mine") + ) + + mockMvc.get("/api/v1/classrooms/${classroom.id}/mystery/submissions/my") { + with(csrf()) + header(HttpHeaders.AUTHORIZATION, "Bearer ${pupilToken()}") + }.andExpect { + status { isOk() } + jsonPath("$.length()") { value(1) } + jsonPath("$[0].description") { value("mine") } + } + } + + @Test + fun `GET submissions-my returns empty list when pupil has no submissions`() { + mockMvc.get("/api/v1/classrooms/${classroom.id}/mystery/submissions/my") { + with(csrf()) + header(HttpHeaders.AUTHORIZATION, "Bearer ${pupilToken()}") + }.andExpect { + status { isOk() } + jsonPath("$.length()") { value(0) } + } + } + + private fun approvedSubmission(): MysterySubmission { + val sub = MysterySubmission(classroom = classroom, pupil = pupil, description = "test submission") + sub.approve(teacher, "Good find") + return submissionRepository.save(sub) + } + + private fun mondayOfCurrentWeek(): LocalDate = + LocalDate.now().with(java.time.temporal.TemporalAdjusters.previousOrSame(java.time.DayOfWeek.MONDAY)) + + private fun pupilToken(): String = + jsonMapper.readTree( + mockMvc.post("/api/v1/auth/pupil/login") { + with(csrf()) + contentType = MediaType.APPLICATION_JSON + content = """{"username":"mystery-pupil","password":"secret123"}""" + }.andExpect { + status { isOk() } + }.andReturn().response.contentAsString + ).get("accessToken").asText() + + private fun teacherToken(): String = + jsonMapper.readTree( + mockMvc.post("/api/v1/auth/teacher/login") { + with(csrf()) + contentType = MediaType.APPLICATION_JSON + content = """{"email":"teacher-mystery@test.com","password":"secret123"}""" + }.andExpect { + status { isOk() } + }.andReturn().response.contentAsString + ).get("accessToken").asText() + + private fun activePupil(username: String): User = + User( + username = username, + passwordHash = requireNotNull(passwordEncoder.encode("secret123")), + role = UserRole.PUPIL, + status = UserStatus.ACTIVE, + ) + + private fun activeTeacher(email: String): User = + User( + email = email, + passwordHash = requireNotNull(passwordEncoder.encode("secret123")), + role = UserRole.TEACHER, + status = UserStatus.ACTIVE, + ) +} \ No newline at end of file diff --git a/src/test/kotlin/edu/ntnu/idi/idatt/backend/mystery/MysteryServicetests.kt b/src/test/kotlin/edu/ntnu/idi/idatt/backend/mystery/MysteryServicetests.kt new file mode 100644 index 00000000..dbc2df63 --- /dev/null +++ b/src/test/kotlin/edu/ntnu/idi/idatt/backend/mystery/MysteryServicetests.kt @@ -0,0 +1,469 @@ +package edu.ntnu.idi.idatt.backend.mystery + +import edu.ntnu.idi.idatt.backend.classroom.domain.Classroom +import edu.ntnu.idi.idatt.backend.classroom.domain.ClassroomStatus +import edu.ntnu.idi.idatt.backend.iam.domain.User +import edu.ntnu.idi.idatt.backend.iam.domain.UserRole +import edu.ntnu.idi.idatt.backend.iam.domain.UserStatus +import edu.ntnu.idi.idatt.backend.iam.infrastructure.UserRepository +import edu.ntnu.idi.idatt.backend.mystery.application.FeatureMysteryCommand +import edu.ntnu.idi.idatt.backend.mystery.application.MysteryService +import edu.ntnu.idi.idatt.backend.mystery.application.MysteryService.Companion.currentWeekStart +import edu.ntnu.idi.idatt.backend.mystery.application.MysteryService.Companion.toWeekStart +import edu.ntnu.idi.idatt.backend.mystery.application.ReviewSubmissionCommand +import edu.ntnu.idi.idatt.backend.mystery.application.SubmitMysteryCommand +import edu.ntnu.idi.idatt.backend.mystery.application.UnfeatureMysteryCommand +import edu.ntnu.idi.idatt.backend.mystery.domain.MysterySubmission +import edu.ntnu.idi.idatt.backend.mystery.domain.ReviewStatus +import edu.ntnu.idi.idatt.backend.mystery.domain.WeeklyMystery +import edu.ntnu.idi.idatt.backend.mystery.infrastructure.MysterySubmissionRepository +import edu.ntnu.idi.idatt.backend.mystery.infrastructure.WeeklyMysteryRepository +import edu.ntnu.idi.idatt.backend.shared.events.DomainEvent +import edu.ntnu.idi.idatt.backend.shared.events.DomainEventPublisher +import edu.ntnu.idi.idatt.backend.shared.events.WeeklyMysteryApprovedEvent +import edu.ntnu.idi.idatt.backend.shared.events.WeeklyMysterySubmittedEvent +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertNotNull +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.assertThrows +import org.mockito.Mockito.mock +import org.mockito.Mockito.never +import org.mockito.Mockito.verify +import org.mockito.Mockito.`when` +import org.springframework.http.HttpStatus +import org.springframework.web.server.ResponseStatusException +import java.time.DayOfWeek +import java.time.LocalDate +import java.util.Optional + +class MysteryServiceTests { + + private lateinit var submissionRepository: MysterySubmissionRepository + private lateinit var weeklyMysteryRepository: WeeklyMysteryRepository + private lateinit var userRepository: UserRepository + private lateinit var domainEventPublisher: DomainEventPublisher + private lateinit var mysteryService: MysteryService + + private val classroom = Classroom( + id = 1L, + ownerTeacherId = 10L, + title = "Nettdetektivene", + description = "desc", + joinCode = "ABCD1", + status = ClassroomStatus.ACTIVE, + ) + + private val teacher = User( + id = 10L, + email = "teacher@test.com", + passwordHash = "hash", + role = UserRole.TEACHER, + status = UserStatus.ACTIVE, + ) + + private val pupil = User( + id = 20L, + username = "ada", + passwordHash = "hash", + role = UserRole.PUPIL, + status = UserStatus.ACTIVE, + ) + + @BeforeEach + fun setUp() { + submissionRepository = mock(MysterySubmissionRepository::class.java) + weeklyMysteryRepository = mock(WeeklyMysteryRepository::class.java) + userRepository = mock(UserRepository::class.java) + domainEventPublisher = mock(DomainEventPublisher::class.java) + mysteryService = MysteryService( + submissionRepository, + weeklyMysteryRepository, + userRepository, + domainEventPublisher, + ) + } + + @Test + fun `submitExample saves submission and publishes WeeklyMysterySubmittedEvent`() { + val savedSubmission = MysterySubmission( + id = 99L, + classroom = classroom, + pupil = pupil, + description = "Fake news example", + ) + `when`(userRepository.findById(pupil.id!!)).thenReturn(Optional.of(pupil)) + `when`(submissionRepository.save(any(MysterySubmission::class.java))).thenReturn(savedSubmission) + + var capturedEvent: DomainEvent? = null + `when`(domainEventPublisher.publish(any(DomainEvent::class.java))) + .thenAnswer { capturedEvent = it.arguments[0] as DomainEvent } + + val result = mysteryService.submitExample( + SubmitMysteryCommand( + classroomId = classroom.id, + classroom = classroom, + pupilUserId = pupil.id!!, + description = "Fake news example", + attachmentUrl = null, + ) + ) + + assertEquals(99L, result.id) + assertEquals(ReviewStatus.PENDING, result.reviewStatus) + + val event = capturedEvent as? WeeklyMysterySubmittedEvent + assertNotNull(event, "Expected a WeeklyMysterySubmittedEvent to be published") + assertEquals(pupil.id, event!!.pupilUserId) + assertEquals(classroom.id, event.classroomId) + assertEquals(99L, event.submissionId) + } + + @Test + fun `submitExample throws 404 when pupil not found`() { + `when`(userRepository.findById(pupil.id!!)).thenReturn(Optional.empty()) + + val ex = assertThrows { + mysteryService.submitExample( + SubmitMysteryCommand( + classroomId = classroom.id, + classroom = classroom, + pupilUserId = pupil.id!!, + description = "desc", + attachmentUrl = null, + ) + ) + } + + assertEquals(HttpStatus.NOT_FOUND, ex.statusCode) + verify(submissionRepository, never()).save(any(MysterySubmission::class.java)) + verify(domainEventPublisher, never()).publish(any(DomainEvent::class.java)) + } + + @Test + fun `getMySubmissions delegates to repository`() { + val submissions = listOf( + MysterySubmission(id = 1L, classroom = classroom, pupil = pupil, description = "a"), + MysterySubmission(id = 2L, classroom = classroom, pupil = pupil, description = "b"), + ) + `when`(submissionRepository.findAllByClassroomIdAndPupilId(classroom.id, pupil.id!!)) + .thenReturn(submissions) + + val result = mysteryService.getMySubmissions(classroom.id, pupil.id!!) + + assertEquals(2, result.size) + verify(submissionRepository).findAllByClassroomIdAndPupilId(classroom.id, pupil.id!!) + } + + @Test + fun `getCurrentWeekMysteries queries with the current Monday`() { + val expectedWeekStart = currentWeekStart() + `when`(weeklyMysteryRepository.findAllByClassroomIdAndWeekStart(classroom.id, expectedWeekStart)) + .thenReturn(emptyList()) + + mysteryService.getCurrentWeekMysteries(classroom.id) + + verify(weeklyMysteryRepository).findAllByClassroomIdAndWeekStart(classroom.id, expectedWeekStart) + } + + @Test + fun `currentWeekStart always returns a Monday`() { + assertEquals(DayOfWeek.MONDAY, currentWeekStart().dayOfWeek) + } + + @Test + fun `getAllWeeklyMysteries returns results ordered newest-first from repository`() { + val mysteries = listOf() + `when`(weeklyMysteryRepository.findAllByClassroomIdOrderByWeekStartDesc(classroom.id)) + .thenReturn(mysteries) + + val result = mysteryService.getAllWeeklyMysteries(classroom.id) + + assertEquals(mysteries, result) + verify(weeklyMysteryRepository).findAllByClassroomIdOrderByWeekStartDesc(classroom.id) + } + + @Test + fun `getAllSubmissions returns all classroom submissions`() { + val submissions = listOf( + MysterySubmission(id = 1L, classroom = classroom, pupil = pupil, description = "a"), + ) + `when`(submissionRepository.findAllByClassroomId(classroom.id)).thenReturn(submissions) + + val result = mysteryService.getAllSubmissions(classroom.id) + + assertEquals(1, result.size) + } + + @Test + fun `getPendingSubmissions filters by PENDING status`() { + `when`(submissionRepository.findAllByClassroomIdAndReviewStatus(classroom.id, ReviewStatus.PENDING)) + .thenReturn(emptyList()) + + mysteryService.getPendingSubmissions(classroom.id) + + verify(submissionRepository).findAllByClassroomIdAndReviewStatus(classroom.id, ReviewStatus.PENDING) + } + + @Test + fun `approveSubmission transitions submission to APPROVED and publishes event`() { + val submission = MysterySubmission(id = 5L, classroom = classroom, pupil = pupil, description = "desc") + `when`(submissionRepository.findByIdAndClassroomId(5L, classroom.id)).thenReturn(submission) + `when`(userRepository.findById(teacher.id!!)).thenReturn(Optional.of(teacher)) + `when`(submissionRepository.save(any(MysterySubmission::class.java))).thenReturn(submission) + + var capturedEvent: DomainEvent? = null + `when`(domainEventPublisher.publish(any(DomainEvent::class.java))) + .thenAnswer { capturedEvent = it.arguments[0] as DomainEvent } + + val result = mysteryService.approveSubmission( + ReviewSubmissionCommand( + submissionId = 5L, + classroomId = classroom.id, + teacherUserId = teacher.id!!, + comment = "Good find!", + ) + ) + + assertEquals(ReviewStatus.APPROVED, result.reviewStatus) + assertEquals(teacher, result.reviewedBy) + assertEquals("Good find!", result.teacherComment) + assertNotNull(result.reviewedAt) + + val event = capturedEvent as? WeeklyMysteryApprovedEvent + assertNotNull(event, "Expected a WeeklyMysteryApprovedEvent to be published") + assertEquals(5L, event!!.submissionId) + assertEquals(pupil.id, event.pupilUserId) + } + + @Test + fun `approveSubmission throws 404 when submission not found in classroom`() { + `when`(submissionRepository.findByIdAndClassroomId(99L, classroom.id)).thenReturn(null) + + val ex = assertThrows { + mysteryService.approveSubmission( + ReviewSubmissionCommand( + submissionId = 99L, + classroomId = classroom.id, + teacherUserId = teacher.id!!, + comment = null, + ) + ) + } + + assertEquals(HttpStatus.NOT_FOUND, ex.statusCode) + verify(domainEventPublisher, never()).publish(any(DomainEvent::class.java)) + } + + @Test + fun `approveSubmission throws 404 when teacher not found`() { + val submission = MysterySubmission(id = 5L, classroom = classroom, pupil = pupil, description = "desc") + `when`(submissionRepository.findByIdAndClassroomId(5L, classroom.id)).thenReturn(submission) + `when`(userRepository.findById(teacher.id!!)).thenReturn(Optional.empty()) + + val ex = assertThrows { + mysteryService.approveSubmission( + ReviewSubmissionCommand( + submissionId = 5L, + classroomId = classroom.id, + teacherUserId = teacher.id!!, + comment = null, + ) + ) + } + + assertEquals(HttpStatus.NOT_FOUND, ex.statusCode) + verify(domainEventPublisher, never()).publish(any(DomainEvent::class.java)) + } + + @Test + fun `approveSubmission throws when submission already reviewed`() { + val submission = MysterySubmission(id = 5L, classroom = classroom, pupil = pupil, description = "desc") + submission.approve(teacher, "already done") + `when`(submissionRepository.findByIdAndClassroomId(5L, classroom.id)).thenReturn(submission) + `when`(userRepository.findById(teacher.id!!)).thenReturn(Optional.of(teacher)) + + assertThrows { + mysteryService.approveSubmission( + ReviewSubmissionCommand( + submissionId = 5L, + classroomId = classroom.id, + teacherUserId = teacher.id!!, + comment = null, + ) + ) + } + } + + @Test + fun `rejectSubmission transitions submission to REJECTED`() { + val submission = MysterySubmission(id = 6L, classroom = classroom, pupil = pupil, description = "desc") + `when`(submissionRepository.findByIdAndClassroomId(6L, classroom.id)).thenReturn(submission) + `when`(userRepository.findById(teacher.id!!)).thenReturn(Optional.of(teacher)) + `when`(submissionRepository.save(any(MysterySubmission::class.java))).thenReturn(submission) + + val result = mysteryService.rejectSubmission( + ReviewSubmissionCommand( + submissionId = 6L, + classroomId = classroom.id, + teacherUserId = teacher.id!!, + comment = "Not a good example.", + ) + ) + + assertEquals(ReviewStatus.REJECTED, result.reviewStatus) + assertEquals("Not a good example.", result.teacherComment) + } + + @Test + fun `rejectSubmission throws 404 when submission not found`() { + `when`(submissionRepository.findByIdAndClassroomId(99L, classroom.id)).thenReturn(null) + + val ex = assertThrows { + mysteryService.rejectSubmission( + ReviewSubmissionCommand( + submissionId = 99L, + classroomId = classroom.id, + teacherUserId = teacher.id!!, + comment = null, + ) + ) + } + + assertEquals(HttpStatus.NOT_FOUND, ex.statusCode) + } + + @Test + fun `rejectSubmission throws when submission already reviewed`() { + val submission = MysterySubmission(id = 6L, classroom = classroom, pupil = pupil, description = "desc") + submission.reject(teacher, "already rejected") + `when`(submissionRepository.findByIdAndClassroomId(6L, classroom.id)).thenReturn(submission) + `when`(userRepository.findById(teacher.id!!)).thenReturn(Optional.of(teacher)) + + assertThrows { + mysteryService.rejectSubmission( + ReviewSubmissionCommand( + submissionId = 6L, + classroomId = classroom.id, + teacherUserId = teacher.id!!, + comment = null, + ) + ) + } + } + + @Test + fun `featureSubmission saves WeeklyMystery with normalized weekStart`() { + val submission = MysterySubmission(id = 7L, classroom = classroom, pupil = pupil, description = "desc") + submission.approve(teacher) + val wednesday = LocalDate.of(2025, 5, 7) // Wednesday inside a week + val expectedMonday = LocalDate.of(2025, 5, 5) + + `when`(submissionRepository.findByIdAndClassroomId(7L, classroom.id)).thenReturn(submission) + `when`(userRepository.findById(teacher.id!!)).thenReturn(Optional.of(teacher)) + `when`(weeklyMysteryRepository.save(any(WeeklyMystery::class.java))).thenAnswer { it.arguments[0] } + + val result = mysteryService.featureSubmission( + FeatureMysteryCommand( + submissionId = 7L, + classroomId = classroom.id, + classroom = classroom, + teacherUserId = teacher.id!!, + weekStart = wednesday, + ) + ) + + assertEquals(expectedMonday, result.weekStart) + assertEquals(teacher, result.featuredBy) + assertEquals(submission, result.submission) + } + + @Test + fun `featureSubmission throws 422 when submission not yet approved`() { + val submission = MysterySubmission(id = 8L, classroom = classroom, pupil = pupil, description = "desc") + // still PENDING + `when`(submissionRepository.findByIdAndClassroomId(8L, classroom.id)).thenReturn(submission) + `when`(userRepository.findById(teacher.id!!)).thenReturn(Optional.of(teacher)) + + val ex = assertThrows { + mysteryService.featureSubmission( + FeatureMysteryCommand( + submissionId = 8L, + classroomId = classroom.id, + classroom = classroom, + teacherUserId = teacher.id!!, + weekStart = LocalDate.now(), + ) + ) + } + + assertEquals(HttpStatus.UNPROCESSABLE_ENTITY, ex.statusCode) + verify(weeklyMysteryRepository, never()).save(any(WeeklyMystery::class.java)) + } + + @Test + fun `featureSubmission throws 404 when submission not found`() { + `when`(submissionRepository.findByIdAndClassroomId(99L, classroom.id)).thenReturn(null) + + val ex = assertThrows { + mysteryService.featureSubmission( + FeatureMysteryCommand( + submissionId = 99L, + classroomId = classroom.id, + classroom = classroom, + teacherUserId = teacher.id!!, + weekStart = LocalDate.now(), + ) + ) + } + + assertEquals(HttpStatus.NOT_FOUND, ex.statusCode) + } + + @Test + fun `unfeatureSubmission deletes the entry when it exists`() { + `when`(weeklyMysteryRepository.deleteByIdAndClassroomId(3L, classroom.id)).thenReturn(1L) + + mysteryService.unfeatureSubmission(UnfeatureMysteryCommand(weeklyMysteryId = 3L, classroomId = classroom.id)) + + verify(weeklyMysteryRepository).deleteByIdAndClassroomId(3L, classroom.id) + } + + @Test + fun `unfeatureSubmission throws 404 when entry does not exist`() { + `when`(weeklyMysteryRepository.deleteByIdAndClassroomId(99L, classroom.id)).thenReturn(0L) + + val ex = assertThrows { + mysteryService.unfeatureSubmission(UnfeatureMysteryCommand(weeklyMysteryId = 99L, classroomId = classroom.id)) + } + + assertEquals(HttpStatus.NOT_FOUND, ex.statusCode) + } + + @Test + fun `toWeekStart returns same date when already a Monday`() { + val monday = LocalDate.of(2025, 5, 5) + assertEquals(monday, monday.toWeekStart()) + } + + @Test + fun `toWeekStart normalizes Sunday to the preceding Monday`() { + val sunday = LocalDate.of(2025, 5, 11) + val expectedMonday = LocalDate.of(2025, 5, 5) + assertEquals(expectedMonday, sunday.toWeekStart()) + } + + @Test + fun `toWeekStart normalizes Saturday to the preceding Monday`() { + val saturday = LocalDate.of(2025, 5, 10) + val expectedMonday = LocalDate.of(2025, 5, 5) + assertEquals(expectedMonday, saturday.toWeekStart()) + } + + /** + * Typed wrapper around [org.mockito.Mockito.any] that avoids returning null + * for Kotlin non-null parameters — the untyped overload returns null, which + * causes an NPE before Mockito can intercept the call. + */ + private fun any(type: Class): T = org.mockito.Mockito.any(type) +} \ No newline at end of file diff --git a/src/test/kotlin/edu/ntnu/idi/idatt/backend/mystery/TeacherMysteryControllerIntegrationTests.kt b/src/test/kotlin/edu/ntnu/idi/idatt/backend/mystery/TeacherMysteryControllerIntegrationTests.kt new file mode 100644 index 00000000..0dd151b6 --- /dev/null +++ b/src/test/kotlin/edu/ntnu/idi/idatt/backend/mystery/TeacherMysteryControllerIntegrationTests.kt @@ -0,0 +1,432 @@ +package edu.ntnu.idi.idatt.backend.mystery + +import com.fasterxml.jackson.databind.json.JsonMapper +import edu.ntnu.idi.idatt.backend.classroom.domain.Classroom +import edu.ntnu.idi.idatt.backend.classroom.domain.ClassroomStatus +import edu.ntnu.idi.idatt.backend.classroom.infrastructure.ClassroomRepository +import edu.ntnu.idi.idatt.backend.iam.domain.User +import edu.ntnu.idi.idatt.backend.iam.domain.UserRole +import edu.ntnu.idi.idatt.backend.iam.domain.UserStatus +import edu.ntnu.idi.idatt.backend.iam.infrastructure.RefreshTokenRepository +import edu.ntnu.idi.idatt.backend.iam.infrastructure.UserRepository +import edu.ntnu.idi.idatt.backend.mystery.domain.MysterySubmission +import edu.ntnu.idi.idatt.backend.mystery.domain.ReviewStatus +import edu.ntnu.idi.idatt.backend.mystery.domain.WeeklyMystery +import edu.ntnu.idi.idatt.backend.mystery.infrastructure.MysterySubmissionRepository +import edu.ntnu.idi.idatt.backend.mystery.infrastructure.WeeklyMysteryRepository +import edu.ntnu.idi.idatt.backend.users.avatar.infrastructure.UserAvatarRepository +import edu.ntnu.idi.idatt.backend.users.profile.infrastructure.PupilProfileRepository +import edu.ntnu.idi.idatt.backend.users.profile.infrastructure.TeacherProfileRepository +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.boot.test.context.SpringBootTest +import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc +import org.springframework.http.HttpHeaders +import org.springframework.http.MediaType +import org.springframework.security.crypto.password.PasswordEncoder +import org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf +import org.springframework.test.web.servlet.MockMvc +import org.springframework.test.web.servlet.delete +import org.springframework.test.web.servlet.get +import org.springframework.test.web.servlet.patch +import org.springframework.test.web.servlet.post +import java.time.DayOfWeek +import java.time.LocalDate +import java.time.temporal.TemporalAdjusters + +@SpringBootTest +@AutoConfigureMockMvc +class TeacherMysteryControllerIntegrationTests { + + @Autowired private lateinit var mockMvc: MockMvc + @Autowired private lateinit var userRepository: UserRepository + @Autowired private lateinit var classroomRepository: ClassroomRepository + @Autowired private lateinit var submissionRepository: MysterySubmissionRepository + @Autowired private lateinit var weeklyMysteryRepository: WeeklyMysteryRepository + @Autowired private lateinit var refreshTokenRepository: RefreshTokenRepository + @Autowired private lateinit var userAvatarRepository: UserAvatarRepository + @Autowired private lateinit var pupilProfileRepository: PupilProfileRepository + @Autowired private lateinit var teacherProfileRepository: TeacherProfileRepository + @Autowired private lateinit var passwordEncoder: PasswordEncoder + + private val jsonMapper = JsonMapper.builder().build() + + private lateinit var classroom: Classroom + private lateinit var teacher: User + private lateinit var pupil: User + + @BeforeEach + fun setUp() { + weeklyMysteryRepository.deleteAll() + submissionRepository.deleteAll() + refreshTokenRepository.deleteAll() + classroomRepository.deleteAll() + userAvatarRepository.deleteAll() + pupilProfileRepository.deleteAll() + teacherProfileRepository.deleteAll() + userRepository.deleteAll() + + teacher = userRepository.save(activeTeacher("teacher-mgr@test.com")) + pupil = userRepository.save(activePupil("mystery-kid")) + classroom = classroomRepository.save( + Classroom( + ownerTeacherId = requireNotNull(teacher.id), + title = "Teacher Mystery Class", + description = "desc", + joinCode = "TMYST", + status = ClassroomStatus.ACTIVE, + ) + ) + classroom.addTeacher(teacher) + classroom.addPupilMembership(pupil) + classroomRepository.save(classroom) + } + + @Test + fun `GET submissions returns all submissions for the classroom`() { + submissionRepository.save(MysterySubmission(classroom = classroom, pupil = pupil, description = "a")) + submissionRepository.save(MysterySubmission(classroom = classroom, pupil = pupil, description = "b")) + + mockMvc.get("/api/v1/teacher/classrooms/${classroom.id}/mystery/submissions") { + with(csrf()) + header(HttpHeaders.AUTHORIZATION, "Bearer ${teacherToken()}") + }.andExpect { + status { isOk() } + jsonPath("$.length()") { value(2) } + } + } + + @Test + fun `GET submissions requires authentication`() { + mockMvc.get("/api/v1/teacher/classrooms/${classroom.id}/mystery/submissions") { + with(csrf()) + }.andExpect { + status { isUnauthorized() } + } + } + + @Test + fun `GET submissions is forbidden for pupils`() { + mockMvc.get("/api/v1/teacher/classrooms/${classroom.id}/mystery/submissions") { + with(csrf()) + header(HttpHeaders.AUTHORIZATION, "Bearer ${pupilToken()}") + }.andExpect { + status { isForbidden() } + } + } + + @Test + fun `GET submissions-pending returns only pending submissions`() { + val pending = submissionRepository.save( + MysterySubmission(classroom = classroom, pupil = pupil, description = "pending one") + ) + val approved = MysterySubmission(classroom = classroom, pupil = pupil, description = "approved one") + approved.approve(teacher, null) + submissionRepository.save(approved) + + mockMvc.get("/api/v1/teacher/classrooms/${classroom.id}/mystery/submissions/pending") { + with(csrf()) + header(HttpHeaders.AUTHORIZATION, "Bearer ${teacherToken()}") + }.andExpect { + status { isOk() } + jsonPath("$.length()") { value(1) } + jsonPath("$[0].reviewStatus") { value("PENDING") } + } + } + + @Test + fun `PATCH approve transitions submission to APPROVED`() { + val submission = submissionRepository.save( + MysterySubmission(classroom = classroom, pupil = pupil, description = "great example") + ) + + mockMvc.patch("/api/v1/teacher/classrooms/${classroom.id}/mystery/submissions/${submission.id}/approve") { + with(csrf()) + contentType = MediaType.APPLICATION_JSON + header(HttpHeaders.AUTHORIZATION, "Bearer ${teacherToken()}") + content = """{"comment":"Well spotted!"}""" + }.andExpect { + status { isOk() } + jsonPath("$.reviewStatus") { value("APPROVED") } + jsonPath("$.teacherComment") { value("Well spotted!") } + } + + val persisted = submissionRepository.findById(requireNotNull(submission.id)).orElseThrow() + assertEquals(ReviewStatus.APPROVED, persisted.reviewStatus) + } + + @Test + fun `PATCH approve returns 404 for a submission in another classroom`() { + val otherClassroom = classroomRepository.save( + Classroom( + ownerTeacherId = requireNotNull(teacher.id), + title = "Other", + description = "d", + joinCode = "OTHER", + status = ClassroomStatus.ACTIVE, + ) + ) + otherClassroom.addTeacher(teacher) + classroomRepository.save(otherClassroom) + + val submission = submissionRepository.save( + MysterySubmission(classroom = otherClassroom, pupil = pupil, description = "belongs elsewhere") + ) + + mockMvc.patch("/api/v1/teacher/classrooms/${classroom.id}/mystery/submissions/${submission.id}/approve") { + with(csrf()) + contentType = MediaType.APPLICATION_JSON + header(HttpHeaders.AUTHORIZATION, "Bearer ${teacherToken()}") + content = """{"comment":null}""" + }.andExpect { + status { isNotFound() } + } + } + + @Test + fun `PATCH approve throws when submission already reviewed`() { + val submission = MysterySubmission(classroom = classroom, pupil = pupil, description = "already done") + submission.approve(teacher, "first approval") + val saved = submissionRepository.save(submission) + + // MysterySubmission.approve throws IllegalStateException for an already-reviewed submission. + // MockMvc re-throws it as ServletException at the dispatcher level. + // If the project adds a @ExceptionHandler mapping this to 409, switch to andExpect { status { isConflict() } }. + val ex = org.junit.jupiter.api.Assertions.assertThrows( + jakarta.servlet.ServletException::class.java + ) { + mockMvc.patch("/api/v1/teacher/classrooms/${classroom.id}/mystery/submissions/${saved.id}/approve") { + with(csrf()) + contentType = MediaType.APPLICATION_JSON + header(HttpHeaders.AUTHORIZATION, "Bearer ${teacherToken()}") + content = """{"comment":null}""" + }.andReturn() + } + val cause = generateSequence(ex.cause) { it.cause } + org.junit.jupiter.api.Assertions.assertTrue( + cause.any { it is IllegalStateException }, + "Expected root cause to be IllegalStateException" + ) + } + + @Test + fun `PATCH reject transitions submission to REJECTED`() { + val submission = submissionRepository.save( + MysterySubmission(classroom = classroom, pupil = pupil, description = "bad example") + ) + + mockMvc.patch("/api/v1/teacher/classrooms/${classroom.id}/mystery/submissions/${submission.id}/reject") { + with(csrf()) + contentType = MediaType.APPLICATION_JSON + header(HttpHeaders.AUTHORIZATION, "Bearer ${teacherToken()}") + content = """{"comment":"This is just an ad."}""" + }.andExpect { + status { isOk() } + jsonPath("$.reviewStatus") { value("REJECTED") } + jsonPath("$.teacherComment") { value("This is just an ad.") } + } + + val persisted = submissionRepository.findById(requireNotNull(submission.id)).orElseThrow() + assertEquals(ReviewStatus.REJECTED, persisted.reviewStatus) + } + + @Test + fun `PATCH reject returns 404 when submission not found`() { + mockMvc.patch("/api/v1/teacher/classrooms/${classroom.id}/mystery/submissions/99999/reject") { + with(csrf()) + contentType = MediaType.APPLICATION_JSON + header(HttpHeaders.AUTHORIZATION, "Bearer ${teacherToken()}") + content = """{"comment":null}""" + }.andExpect { + status { isNotFound() } + } + } + + @Test + fun `PATCH reject comment may be null`() { + val submission = submissionRepository.save( + MysterySubmission(classroom = classroom, pupil = pupil, description = "weak example") + ) + + mockMvc.patch("/api/v1/teacher/classrooms/${classroom.id}/mystery/submissions/${submission.id}/reject") { + with(csrf()) + contentType = MediaType.APPLICATION_JSON + header(HttpHeaders.AUTHORIZATION, "Bearer ${teacherToken()}") + content = """{"comment":null}""" + }.andExpect { + status { isOk() } + jsonPath("$.reviewStatus") { value("REJECTED") } + } + } + + @Test + fun `POST weekly features an approved submission and returns 201`() { + val submission = approvedSubmission() + val thisMonday = mondayOfCurrentWeek() + + mockMvc.post("/api/v1/teacher/classrooms/${classroom.id}/mystery/weekly") { + with(csrf()) + contentType = MediaType.APPLICATION_JSON + header(HttpHeaders.AUTHORIZATION, "Bearer ${teacherToken()}") + content = """{"submissionId":${submission.id},"weekStart":"$thisMonday"}""" + }.andExpect { + status { isCreated() } + jsonPath("$.weekStart") { value(thisMonday.toString()) } + jsonPath("$.submission.id") { value(submission.id!!.toInt()) } + } + + assertEquals(1, weeklyMysteryRepository.findAllByClassroomIdOrderByWeekStartDesc(classroom.id).size) + } + + @Test + fun `POST weekly returns 422 when submission is not approved`() { + val pending = submissionRepository.save( + MysterySubmission(classroom = classroom, pupil = pupil, description = "still pending") + ) + + mockMvc.post("/api/v1/teacher/classrooms/${classroom.id}/mystery/weekly") { + with(csrf()) + contentType = MediaType.APPLICATION_JSON + header(HttpHeaders.AUTHORIZATION, "Bearer ${teacherToken()}") + content = """{"submissionId":${pending.id},"weekStart":"${mondayOfCurrentWeek()}"}""" + }.andExpect { + status { isUnprocessableEntity() } + } + } + + @Test + fun `POST weekly accepts any date within the target week and normalizes to Monday`() { + val submission = approvedSubmission() + val wednesday = mondayOfCurrentWeek().plusDays(2) + val expectedMonday = mondayOfCurrentWeek() + + mockMvc.post("/api/v1/teacher/classrooms/${classroom.id}/mystery/weekly") { + with(csrf()) + contentType = MediaType.APPLICATION_JSON + header(HttpHeaders.AUTHORIZATION, "Bearer ${teacherToken()}") + content = """{"submissionId":${submission.id},"weekStart":"$wednesday"}""" + }.andExpect { + status { isCreated() } + jsonPath("$.weekStart") { value(expectedMonday.toString()) } + } + } + + @Test + fun `DELETE weekly removes the featured entry and returns 204`() { + val submission = approvedSubmission() + val weekly = weeklyMysteryRepository.save( + WeeklyMystery(classroom = classroom, submission = submission, weekStart = mondayOfCurrentWeek(), featuredBy = teacher) + ) + + mockMvc.delete("/api/v1/teacher/classrooms/${classroom.id}/mystery/weekly/${weekly.id}") { + with(csrf()) + header(HttpHeaders.AUTHORIZATION, "Bearer ${teacherToken()}") + }.andExpect { + status { isNoContent() } + } + + assertEquals(0, weeklyMysteryRepository.findAllByClassroomIdOrderByWeekStartDesc(classroom.id).size) + } + + @Test + fun `DELETE weekly returns 404 for a non-existent entry`() { + mockMvc.delete("/api/v1/teacher/classrooms/${classroom.id}/mystery/weekly/99999") { + with(csrf()) + header(HttpHeaders.AUTHORIZATION, "Bearer ${teacherToken()}") + }.andExpect { + status { isNotFound() } + } + } + + @Test + fun `DELETE weekly returns 404 when entry belongs to a different classroom`() { + val otherClassroom = classroomRepository.save( + Classroom( + ownerTeacherId = requireNotNull(teacher.id), + title = "Other", + description = "d", + joinCode = "OTH2", + status = ClassroomStatus.ACTIVE, + ) + ) + otherClassroom.addTeacher(teacher) + classroomRepository.save(otherClassroom) + + val submission = approvedSubmission() + val weekly = weeklyMysteryRepository.save( + WeeklyMystery(classroom = otherClassroom, submission = submission, weekStart = mondayOfCurrentWeek(), featuredBy = teacher) + ) + + mockMvc.delete("/api/v1/teacher/classrooms/${classroom.id}/mystery/weekly/${weekly.id}") { + with(csrf()) + header(HttpHeaders.AUTHORIZATION, "Bearer ${teacherToken()}") + }.andExpect { + status { isNotFound() } + } + } + + @Test + fun `GET weekly returns all featured mysteries for the classroom`() { + val sub1 = approvedSubmission() + val sub2 = approvedSubmission() + weeklyMysteryRepository.save(WeeklyMystery(classroom = classroom, submission = sub1, weekStart = mondayOfCurrentWeek(), featuredBy = teacher)) + weeklyMysteryRepository.save(WeeklyMystery(classroom = classroom, submission = sub2, weekStart = mondayOfCurrentWeek().minusWeeks(1), featuredBy = teacher)) + + mockMvc.get("/api/v1/teacher/classrooms/${classroom.id}/mystery/weekly") { + with(csrf()) + header(HttpHeaders.AUTHORIZATION, "Bearer ${teacherToken()}") + }.andExpect { + status { isOk() } + jsonPath("$.length()") { value(2) } + } + } + + private fun approvedSubmission(): MysterySubmission { + val sub = MysterySubmission(classroom = classroom, pupil = pupil, description = "approved example") + sub.approve(teacher, "Good find") + return submissionRepository.save(sub) + } + + private fun mondayOfCurrentWeek(): LocalDate = + LocalDate.now().with(TemporalAdjusters.previousOrSame(DayOfWeek.MONDAY)) + + private fun teacherToken(): String = + jsonMapper.readTree( + mockMvc.post("/api/v1/auth/teacher/login") { + with(csrf()) + contentType = MediaType.APPLICATION_JSON + content = """{"email":"teacher-mgr@test.com","password":"secret123"}""" + }.andExpect { + status { isOk() } + }.andReturn().response.contentAsString + ).get("accessToken").asText() + + private fun pupilToken(): String = + jsonMapper.readTree( + mockMvc.post("/api/v1/auth/pupil/login") { + with(csrf()) + contentType = MediaType.APPLICATION_JSON + content = """{"username":"mystery-kid","password":"secret123"}""" + }.andExpect { + status { isOk() } + }.andReturn().response.contentAsString + ).get("accessToken").asText() + + private fun activePupil(username: String): User = + User( + username = username, + passwordHash = requireNotNull(passwordEncoder.encode("secret123")), + role = UserRole.PUPIL, + status = UserStatus.ACTIVE, + ) + + private fun activeTeacher(email: String): User = + User( + email = email, + passwordHash = requireNotNull(passwordEncoder.encode("secret123")), + role = UserRole.TEACHER, + status = UserStatus.ACTIVE, + ) +} \ No newline at end of file diff --git a/src/test/resources/application.properties b/src/test/resources/application.properties index 97e22ea6..c15c2f62 100644 --- a/src/test/resources/application.properties +++ b/src/test/resources/application.properties @@ -15,3 +15,6 @@ app.security.refresh.same-site=Lax app.security.refresh.secure-cookie=false app.security.refresh.ttl=P14D app.security.cors.allowed-origins=http://localhost:3000,http://localhost:5173 + +app.uploads.base-url=http://localhost:8080 +app.uploads.base-dir=uploads \ No newline at end of file