diff --git a/GLOSSAIRE.md b/GLOSSAIRE.md index ab8a53a..e9ce2ff 100644 --- a/GLOSSAIRE.md +++ b/GLOSSAIRE.md @@ -27,9 +27,15 @@ pour la justification des décisions de conception, voir les [ADR](docs/adr/READ qui suit le cycle de vie de la progression de l'étudiant (voir CONTRIBUTING.md). - **Lesson (leçon)** — Un élément de contenu individuel au sein d'un cours. +- **Publish (publier)** — Rendre un cours ou une leçon en brouillon visible des + étudiants. La première publication d'un cours fixe sa date `published_at` ; + l'archiver puis le restaurer ne remet pas cette date à zéro. - **Role (rôle)** — Un ensemble nommé de permissions accordées à un utilisateur. Les rôles fournis par défaut sont `STUDENT`, `INSTRUCTOR` et `ADMIN` ; `SUPERADMIN` est prévu (voir l'issue #65). +- **Roster (liste des inscrits)** — La liste des étudiants inscrits à un cours, + avec le statut et les dates de leur inscription ; les formateurs peuvent en + retirer un étudiant. ## Authentification et sécurité diff --git a/GLOSSARY.md b/GLOSSARY.md index 6174320..8eebaee 100644 --- a/GLOSSARY.md +++ b/GLOSSARY.md @@ -23,8 +23,13 @@ rationale behind design decisions, see the [ADRs](docs/adr/README.md). the (user, course) pair, with a status following the student course progress lifecycle (see CONTRIBUTING.md). - **Lesson** — An individual piece of content within a course. +- **Publish** — Make a draft course or lesson visible to students. The first + publication of a course stamps its `published_at` date; archiving then + restoring it does not reset that date. - **Role** — A named set of permissions granted to a user. The seeded roles are `STUDENT`, `INSTRUCTOR`, and `ADMIN`; `SUPERADMIN` is planned (see issue #65). +- **Roster** — The list of students enrolled in a course, with their enrollment + status and dates; instructors can remove a student from it. ## Authentication and security diff --git a/src/main/java/com/ericbouchut/learndev/course/InstructorCourseController.java b/src/main/java/com/ericbouchut/learndev/course/InstructorCourseController.java new file mode 100644 index 0000000..643755c --- /dev/null +++ b/src/main/java/com/ericbouchut/learndev/course/InstructorCourseController.java @@ -0,0 +1,428 @@ +package com.ericbouchut.learndev.course; + +import com.ericbouchut.learndev.course.dto.CourseForm; +import com.ericbouchut.learndev.course.dto.LessonForm; +import com.ericbouchut.learndev.course.entity.Course; +import com.ericbouchut.learndev.course.entity.Lesson; +import com.ericbouchut.learndev.user.entity.User; +import com.ericbouchut.learndev.user.repository.UserRepository; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.validation.Valid; +import org.springframework.http.HttpStatus; +import org.springframework.stereotype.Controller; +import org.springframework.ui.Model; +import org.springframework.validation.BindingResult; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.ModelAttribute; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.server.ResponseStatusException; + +import java.security.Principal; +import java.util.UUID; + +/** + * Web endpoints of the instructor authoring area under + * {@code /instructor/**} (gated to {@code ROLE_INSTRUCTOR} by the security + * rules): the instructor's course list, the course create/edit forms, and + * the publication lifecycle actions. Ownership and transition rules live in + * {@link InstructorCourseService}. + */ +@Controller +@RequestMapping("/instructor/courses") +public class InstructorCourseController { + + private final InstructorCourseService instructorCourses; + private final UserRepository users; + + public InstructorCourseController( + InstructorCourseService instructorCourses, + UserRepository users + ) { + this.instructorCourses = instructorCourses; + this.users = users; + } + + /** + * Display the instructor's courses, all statuses included. + * + * @param principal the logged-in instructor + * @param model receives the instructor's courses + * @return the instructor course list view name + */ + @GetMapping + public String myCourses(Principal principal, Model model) { + model.addAttribute("courses", + instructorCourses.myCourses(currentUser(principal))); + return "instructor/courses"; + } + + /** + * Display the course creation form. + * + * @param model receives an empty form + * @return the course form view name + */ + @GetMapping("/new") + public String newCourseForm(Model model) { + model.addAttribute("form", new CourseForm("", "")); + model.addAttribute("course", null); + return "instructor/course-form"; + } + + /** + * Create a DRAFT course from the submitted form. + * + * @param form the submitted form, validated by {@code @Valid} + * @param binding collects validation errors + * @param principal the logged-in instructor + * @param model re-exposes the (null) course on re-render + * @return a redirect to the edit page, or the re-rendered form on error + */ + @PostMapping("/new") + public String createCourse( + @Valid @ModelAttribute("form") CourseForm form, + BindingResult binding, + Principal principal, + Model model + ) { + if (binding.hasErrors()) { + model.addAttribute("course", null); + return "instructor/course-form"; + } + Course course = instructorCourses.create(currentUser(principal), form); + return "redirect:/instructor/courses/" + course.getCourseId() + "/edit?created"; + } + + /** + * Display the edit form of one of the instructor's courses. + * + * @param courseId the course to edit + * @param principal the logged-in instructor (ownership check) + * @param model receives the course and its pre-filled form + * @return the course form view name + */ + @GetMapping("/{courseId}/edit") + public String editCourseForm( + @PathVariable Long courseId, + Principal principal, + Model model + ) { + Course course = instructorCourses.ownedCourse(courseId, currentUser(principal)); + model.addAttribute("course", course); + model.addAttribute("lessons", instructorCourses.lessonsOf(course)); + model.addAttribute("form", + new CourseForm(course.getTitle(), course.getDescription())); + return "instructor/course-form"; + } + + /** + * Update the course from the submitted form. + * + * @param courseId the course to update + * @param form the submitted form, validated by {@code @Valid} + * @param binding collects validation errors + * @param principal the logged-in instructor (ownership check) + * @param model re-exposes the course on re-render + * @return a redirect to the edit page, or the re-rendered form on error + */ + @PostMapping("/{courseId}/edit") + public String updateCourse( + @PathVariable Long courseId, + @Valid @ModelAttribute("form") CourseForm form, + BindingResult binding, + Principal principal, + Model model + ) { + Course course = instructorCourses.ownedCourse(courseId, currentUser(principal)); + if (binding.hasErrors()) { + model.addAttribute("course", course); + return "instructor/course-form"; + } + instructorCourses.update(course, form); + return "redirect:/instructor/courses/" + courseId + "/edit?saved"; + } + + /** + * Publish the course (PRG with a {@code published} flag). + * + * @param courseId the course to publish + * @param principal the logged-in instructor (ownership check) + * @param request provides the client IP for the audit trail + * @return a redirect to the edit page + */ + @PostMapping("/{courseId}/publish") + public String publishCourse( + @PathVariable Long courseId, + Principal principal, + HttpServletRequest request + ) { + User instructor = currentUser(principal); + Course course = instructorCourses.ownedCourse(courseId, instructor); + instructorCourses.publish(course, instructor, request.getRemoteAddr()); + return "redirect:/instructor/courses/" + courseId + "/edit?published"; + } + + /** + * Archive the course (PRG with an {@code archived} flag). + * + * @param courseId the course to archive + * @param principal the logged-in instructor (ownership check) + * @param request provides the client IP for the audit trail + * @return a redirect to the edit page + */ + @PostMapping("/{courseId}/archive") + public String archiveCourse( + @PathVariable Long courseId, + Principal principal, + HttpServletRequest request + ) { + User instructor = currentUser(principal); + Course course = instructorCourses.ownedCourse(courseId, instructor); + instructorCourses.archive(course, instructor, request.getRemoteAddr()); + return "redirect:/instructor/courses/" + courseId + "/edit?archived"; + } + + /** + * Restore an archived course to draft (PRG with a {@code restored} flag). + * + * @param courseId the course to restore + * @param principal the logged-in instructor (ownership check) + * @param request provides the client IP for the audit trail + * @return a redirect to the edit page + */ + @PostMapping("/{courseId}/restore") + public String restoreCourse( + @PathVariable Long courseId, + Principal principal, + HttpServletRequest request + ) { + User instructor = currentUser(principal); + Course course = instructorCourses.ownedCourse(courseId, instructor); + instructorCourses.restore(course, instructor, request.getRemoteAddr()); + return "redirect:/instructor/courses/" + courseId + "/edit?restored"; + } + + /** + * Display the lesson creation form for one of the instructor's courses. + * + * @param courseId the course the lesson will belong to + * @param principal the logged-in instructor (ownership check) + * @param model receives the course and an empty form + * @return the lesson form view name + */ + @GetMapping("/{courseId}/lessons/new") + public String newLessonForm( + @PathVariable Long courseId, + Principal principal, + Model model + ) { + Course course = instructorCourses.ownedCourse(courseId, currentUser(principal)); + model.addAttribute("course", course); + model.addAttribute("lesson", null); + model.addAttribute("form", new LessonForm("", "")); + return "instructor/lesson-form"; + } + + /** + * Create a DRAFT lesson at the end of the course. + * + * @param courseId the course the lesson belongs to + * @param form the submitted form, validated by {@code @Valid} + * @param binding collects validation errors + * @param principal the logged-in instructor (ownership check) + * @param model re-exposes the course on re-render + * @return a redirect to the course editor, or the re-rendered form + */ + @PostMapping("/{courseId}/lessons/new") + public String createLesson( + @PathVariable Long courseId, + @Valid @ModelAttribute("form") LessonForm form, + BindingResult binding, + Principal principal, + Model model + ) { + Course course = instructorCourses.ownedCourse(courseId, currentUser(principal)); + if (binding.hasErrors()) { + model.addAttribute("course", course); + model.addAttribute("lesson", null); + return "instructor/lesson-form"; + } + instructorCourses.createLesson(course, form); + return "redirect:/instructor/courses/" + courseId + "/edit?lesson-created"; + } + + /** + * Display the edit form of one lesson. + * + * @param courseId the course the lesson belongs to + * @param lessonId the lesson to edit + * @param principal the logged-in instructor (ownership check) + * @param model receives the course, the lesson, and its form + * @return the lesson form view name + */ + @GetMapping("/{courseId}/lessons/{lessonId}/edit") + public String editLessonForm( + @PathVariable Long courseId, + @PathVariable Long lessonId, + Principal principal, + Model model + ) { + Course course = instructorCourses.ownedCourse(courseId, currentUser(principal)); + Lesson lesson = instructorCourses.ownedLesson(course, lessonId); + model.addAttribute("course", course); + model.addAttribute("lesson", lesson); + model.addAttribute("form", + new LessonForm(lesson.getTitle(), lesson.getContentMarkdown())); + return "instructor/lesson-form"; + } + + /** + * Update the lesson from the submitted form. + * + * @param courseId the course the lesson belongs to + * @param lessonId the lesson to update + * @param form the submitted form, validated by {@code @Valid} + * @param binding collects validation errors + * @param principal the logged-in instructor (ownership check) + * @param model re-exposes the course and lesson on re-render + * @return a redirect to the course editor, or the re-rendered form + */ + @PostMapping("/{courseId}/lessons/{lessonId}/edit") + public String updateLesson( + @PathVariable Long courseId, + @PathVariable Long lessonId, + @Valid @ModelAttribute("form") LessonForm form, + BindingResult binding, + Principal principal, + Model model + ) { + Course course = instructorCourses.ownedCourse(courseId, currentUser(principal)); + Lesson lesson = instructorCourses.ownedLesson(course, lessonId); + if (binding.hasErrors()) { + model.addAttribute("course", course); + model.addAttribute("lesson", lesson); + return "instructor/lesson-form"; + } + instructorCourses.updateLesson(lesson, form); + return "redirect:/instructor/courses/" + courseId + "/edit?lesson-saved"; + } + + /** + * Publish, archive, or restore a lesson (PRG back to the course editor). + * + * @param courseId the course the lesson belongs to + * @param lessonId the lesson to transition + * @param action one of {@code publish}, {@code archive}, {@code restore} + * @param principal the logged-in instructor (ownership check) + * @param request provides the client IP for the audit trail + * @return a redirect to the course editor + */ + @PostMapping("/{courseId}/lessons/{lessonId}/{action:publish|archive|restore}") + public String transitionLesson( + @PathVariable Long courseId, + @PathVariable Long lessonId, + @PathVariable String action, + Principal principal, + HttpServletRequest request + ) { + User instructor = currentUser(principal); + Course course = instructorCourses.ownedCourse(courseId, instructor); + Lesson lesson = instructorCourses.ownedLesson(course, lessonId); + String ip = request.getRemoteAddr(); + String flag; + switch (action) { + case "publish" -> { + instructorCourses.publishLesson(lesson, instructor, ip); + flag = "lesson-published"; + } + case "archive" -> { + instructorCourses.archiveLesson(lesson, instructor, ip); + flag = "lesson-archived"; + } + default -> { + instructorCourses.restoreLesson(lesson, instructor, ip); + flag = "lesson-restored"; + } + } + return "redirect:/instructor/courses/" + courseId + "/edit?" + flag; + } + + /** + * Move a lesson one step up or down in reading order (PRG). + * + * @param courseId the course the lesson belongs to + * @param lessonId the lesson to move + * @param direction {@code move-up} or {@code move-down} + * @param principal the logged-in instructor (ownership check) + * @return a redirect to the course editor + */ + @PostMapping("/{courseId}/lessons/{lessonId}/{direction:move-up|move-down}") + public String moveLesson( + @PathVariable Long courseId, + @PathVariable Long lessonId, + @PathVariable String direction, + Principal principal + ) { + Course course = instructorCourses.ownedCourse(courseId, currentUser(principal)); + Lesson lesson = instructorCourses.ownedLesson(course, lessonId); + if ("move-up".equals(direction)) { + instructorCourses.moveLessonUp(course, lesson); + } else { + instructorCourses.moveLessonDown(course, lesson); + } + return "redirect:/instructor/courses/" + courseId + "/edit"; + } + + /** + * Display the roster of the course: every enrollment with its status + * and lifecycle dates. + * + * @param courseId the course whose roster to show + * @param principal the logged-in instructor (ownership check) + * @param model receives the course and its enrollments + * @return the roster view name + */ + @GetMapping("/{courseId}/students") + public String roster( + @PathVariable Long courseId, + Principal principal, + Model model + ) { + Course course = instructorCourses.ownedCourse(courseId, currentUser(principal)); + model.addAttribute("course", course); + model.addAttribute("enrollments", instructorCourses.roster(course)); + return "instructor/students"; + } + + /** + * Remove a student from the course (PRG with a {@code dropped} flag). + * + * @param courseId the course to remove the student from + * @param userId the student to remove + * @param principal the logged-in instructor (ownership check) + * @param request provides the client IP for the audit trail + * @return a redirect to the roster + */ + @PostMapping("/{courseId}/students/{userId}/drop") + public String dropStudent( + @PathVariable Long courseId, + @PathVariable UUID userId, + Principal principal, + HttpServletRequest request + ) { + User instructor = currentUser(principal); + Course course = instructorCourses.ownedCourse(courseId, instructor); + User student = users.findById(userId) + .orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND)); + instructorCourses.dropStudent(course, student, instructor, request.getRemoteAddr()); + return "redirect:/instructor/courses/" + courseId + "/students?dropped"; + } + + private User currentUser(Principal principal) { + return users.findByUsername(principal.getName()) + .orElseThrow(() -> new IllegalStateException( + "Authenticated user not found: " + principal.getName())); + } +} diff --git a/src/main/java/com/ericbouchut/learndev/course/InstructorCourseService.java b/src/main/java/com/ericbouchut/learndev/course/InstructorCourseService.java new file mode 100644 index 0000000..f20e239 --- /dev/null +++ b/src/main/java/com/ericbouchut/learndev/course/InstructorCourseService.java @@ -0,0 +1,268 @@ +package com.ericbouchut.learndev.course; + +import com.ericbouchut.learndev.audit.AuditService; +import com.ericbouchut.learndev.course.dto.CourseForm; +import com.ericbouchut.learndev.course.dto.LessonForm; +import com.ericbouchut.learndev.course.entity.Course; +import com.ericbouchut.learndev.course.entity.Enrollment; +import com.ericbouchut.learndev.course.entity.Lesson; +import com.ericbouchut.learndev.course.entity.PublicationStatus; +import com.ericbouchut.learndev.course.repository.CourseRepository; +import com.ericbouchut.learndev.course.repository.EnrollmentRepository; +import com.ericbouchut.learndev.course.repository.LessonRepository; +import com.ericbouchut.learndev.user.entity.User; +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.OffsetDateTime; +import java.util.List; + +/** + * Write side of the course domain for instructors: authoring and the + * publication lifecycle of {@code CONTRIBUTING.md} (publish from DRAFT, + * archive from DRAFT or PUBLISHED, restore an ARCHIVED item to DRAFT). + * An instructor manages only their own courses: another instructor's course + * answers 403 (the course may be public, its existence is not a secret, + * unlike student-invisible drafts which answer 404 on the student side). + * Invalid lifecycle transitions answer 409. + *

{@code published_at} is set on the first publish only and kept on a + * re-publish after restore, so it records when the course first went live. + */ +@Service +public class InstructorCourseService { + + private final CourseRepository courses; + private final LessonRepository lessons; + private final EnrollmentRepository enrollments; + private final EnrollmentService enrollmentService; + private final AuditService audit; + + public InstructorCourseService( + CourseRepository courses, + LessonRepository lessons, + EnrollmentRepository enrollments, + EnrollmentService enrollmentService, + AuditService audit + ) { + this.courses = courses; + this.lessons = lessons; + this.enrollments = enrollments; + this.enrollmentService = enrollmentService; + this.audit = audit; + } + + /** The instructor's own courses, whatever their status. */ + public List myCourses(User instructor) { + return courses.findByInstructor(instructor); + } + + /** + * The course as manageable by the given instructor: 404 when it does + * not exist, 403 when it belongs to another instructor. + */ + public Course ownedCourse(Long courseId, User instructor) { + Course course = courses.findById(courseId) + .orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND)); + if (!course.getInstructor().getUserId().equals(instructor.getUserId())) { + throw new ResponseStatusException(HttpStatus.FORBIDDEN); + } + return course; + } + + /** Create a new DRAFT course owned by the instructor. */ + @Transactional + public Course create(User instructor, CourseForm form) { + Course course = new Course(); + course.setTitle(form.title()); + course.setDescription(blankToNull(form.description())); + course.setInstructor(instructor); + return courses.save(course); + } + + /** Update the title and description of the instructor's course. */ + @Transactional + public void update(Course course, CourseForm form) { + course.setTitle(form.title()); + course.setDescription(blankToNull(form.description())); + courses.save(course); + } + + /** Publish a DRAFT course; the first publish stamps {@code published_at}. */ + @Transactional + public void publish(Course course, User actor, String ipAddress) { + requireStatus(course.getStatus(), PublicationStatus.DRAFT); + course.setStatus(PublicationStatus.PUBLISHED); + if (course.getPublishedAt() == null) { + course.setPublishedAt(OffsetDateTime.now()); + } + courses.save(course); + audit.record("COURSE_PUBLISHED", actor, ipAddress, true, + "Course " + course.getCourseId() + " published"); + } + + /** Archive a DRAFT or PUBLISHED course (v1 has no destructive delete). */ + @Transactional + public void archive(Course course, User actor, String ipAddress) { + requireStatus(course.getStatus(), + PublicationStatus.DRAFT, PublicationStatus.PUBLISHED); + course.setStatus(PublicationStatus.ARCHIVED); + courses.save(course); + audit.record("COURSE_ARCHIVED", actor, ipAddress, true, + "Course " + course.getCourseId() + " archived"); + } + + /** Restore an ARCHIVED course to DRAFT ("re-open" in the lifecycle). */ + @Transactional + public void restore(Course course, User actor, String ipAddress) { + requireStatus(course.getStatus(), PublicationStatus.ARCHIVED); + course.setStatus(PublicationStatus.DRAFT); + courses.save(course); + audit.record("COURSE_RESTORED", actor, ipAddress, true, + "Course " + course.getCourseId() + " restored to draft"); + } + + /** The lessons of the course in reading order, all statuses included. */ + public List lessonsOf(Course course) { + return lessons.findByCourseOrderByPositionAsc(course); + } + + /** + * One lesson of the given course, whatever its status: 404 when the + * lesson does not exist or belongs to another course. + */ + public Lesson ownedLesson(Course course, Long lessonId) { + return lessons.findById(lessonId) + .filter(lesson -> lesson.getCourse().getCourseId().equals(course.getCourseId())) + .orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND)); + } + + /** Create a DRAFT lesson at the end of the course (position max + 1). */ + @Transactional + public Lesson createLesson(Course course, LessonForm form) { + Lesson lesson = new Lesson(); + lesson.setCourse(course); + lesson.setTitle(form.title()); + lesson.setContentMarkdown(form.contentMarkdown() == null ? "" : form.contentMarkdown()); + lesson.setPosition(nextPosition(course)); + return lessons.save(lesson); + } + + /** Update the title and Markdown content of a lesson. */ + @Transactional + public void updateLesson(Lesson lesson, LessonForm form) { + lesson.setTitle(form.title()); + lesson.setContentMarkdown(form.contentMarkdown() == null ? "" : form.contentMarkdown()); + lessons.save(lesson); + } + + /** Publish a DRAFT lesson. */ + @Transactional + public void publishLesson(Lesson lesson, User actor, String ipAddress) { + requireStatus(lesson.getStatus(), PublicationStatus.DRAFT); + lesson.setStatus(PublicationStatus.PUBLISHED); + lessons.save(lesson); + audit.record("LESSON_PUBLISHED", actor, ipAddress, true, + "Lesson " + lesson.getLessonId() + " published"); + } + + /** Archive a DRAFT or PUBLISHED lesson. */ + @Transactional + public void archiveLesson(Lesson lesson, User actor, String ipAddress) { + requireStatus(lesson.getStatus(), + PublicationStatus.DRAFT, PublicationStatus.PUBLISHED); + lesson.setStatus(PublicationStatus.ARCHIVED); + lessons.save(lesson); + audit.record("LESSON_ARCHIVED", actor, ipAddress, true, + "Lesson " + lesson.getLessonId() + " archived"); + } + + /** Restore an ARCHIVED lesson to DRAFT. */ + @Transactional + public void restoreLesson(Lesson lesson, User actor, String ipAddress) { + requireStatus(lesson.getStatus(), PublicationStatus.ARCHIVED); + lesson.setStatus(PublicationStatus.DRAFT); + lessons.save(lesson); + audit.record("LESSON_RESTORED", actor, ipAddress, true, + "Lesson " + lesson.getLessonId() + " restored to draft"); + } + + /** Swap the lesson with the one before it in reading order (no-op at the top). */ + @Transactional + public void moveLessonUp(Course course, Lesson lesson) { + swapWithNeighbor(course, lesson, -1); + } + + /** Swap the lesson with the one after it in reading order (no-op at the bottom). */ + @Transactional + public void moveLessonDown(Course course, Lesson lesson) { + swapWithNeighbor(course, lesson, +1); + } + + /** + * Swap positions with the previous/next lesson. The DB constraint + * uq_lessons_course_position is NOT deferrable, so the swap flushes + * through a temporary negative position: A to -A, B to oldA, A to oldB + * (instructor positions are always positive, no collision possible). + */ + private void swapWithNeighbor(Course course, Lesson lesson, int direction) { + List ordered = lessonsOf(course); + int index = ordered.indexOf(ordered.stream() + .filter(l -> l.getLessonId().equals(lesson.getLessonId())) + .findFirst().orElseThrow()); + int neighborIndex = index + direction; + if (neighborIndex < 0 || neighborIndex >= ordered.size()) { + return; + } + Lesson current = ordered.get(index); + Lesson neighbor = ordered.get(neighborIndex); + int currentPosition = current.getPosition(); + int neighborPosition = neighbor.getPosition(); + current.setPosition(-currentPosition); + lessons.saveAndFlush(current); + neighbor.setPosition(currentPosition); + lessons.saveAndFlush(neighbor); + current.setPosition(neighborPosition); + lessons.saveAndFlush(current); + } + + /** The roster of a course: every enrollment, dropped ones included. */ + public List roster(Course course) { + return enrollments.findByCourse(course); + } + + /** + * Remove a student from the course on the instructor's behalf. Reuses + * the student-side drop rules (idempotent, COMPLETED untouched) and + * audits the action separately from a self-drop. + */ + @Transactional + public void dropStudent(Course course, User student, User actor, String ipAddress) { + enrollmentService.drop(student, course); + audit.record("ENROLLMENT_DROPPED_BY_INSTRUCTOR", actor, ipAddress, true, + "Student " + student.getUserId() + " dropped from course " + + course.getCourseId()); + } + + private int nextPosition(Course course) { + return lessonsOf(course).stream() + .mapToInt(Lesson::getPosition) + .max() + .orElse(0) + 1; + } + + private static void requireStatus(PublicationStatus actual, PublicationStatus... allowed) { + for (PublicationStatus status : allowed) { + if (actual == status) { + return; + } + } + throw new ResponseStatusException(HttpStatus.CONFLICT, + "Transition not allowed from " + actual); + } + + private static String blankToNull(String value) { + return (value == null || value.isBlank()) ? null : value; + } +} diff --git a/src/main/java/com/ericbouchut/learndev/course/dto/CourseForm.java b/src/main/java/com/ericbouchut/learndev/course/dto/CourseForm.java new file mode 100644 index 0000000..75ea724 --- /dev/null +++ b/src/main/java/com/ericbouchut/learndev/course/dto/CourseForm.java @@ -0,0 +1,18 @@ +package com.ericbouchut.learndev.course.dto; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Size; + +/** + * Form-backing record for creating or editing a course + * (the description is optional; the column is TEXT, no length cap). + */ +public record CourseForm( + + @NotBlank + @Size(max = 255) + String title, + + String description +) { +} diff --git a/src/main/java/com/ericbouchut/learndev/course/dto/LessonForm.java b/src/main/java/com/ericbouchut/learndev/course/dto/LessonForm.java new file mode 100644 index 0000000..4de4528 --- /dev/null +++ b/src/main/java/com/ericbouchut/learndev/course/dto/LessonForm.java @@ -0,0 +1,20 @@ +package com.ericbouchut.learndev.course.dto; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Size; + +/** + * Form-backing record for creating or editing a lesson. The content is + * Markdown, optional while drafting (the column is TEXT with a '' default); + * the reading position is managed by the service (move up/down), not typed + * in by the instructor. + */ +public record LessonForm( + + @NotBlank + @Size(max = 255) + String title, + + String contentMarkdown +) { +} diff --git a/src/main/resources/static/css/base.css b/src/main/resources/static/css/base.css index 8a14a57..7f617e1 100644 --- a/src/main/resources/static/css/base.css +++ b/src/main/resources/static/css/base.css @@ -416,6 +416,43 @@ main:focus { padding: 0.6em 0.8em; } +/* Markdown editors and long descriptions */ +textarea.form__input { + min-height: 8rem; + resize: vertical; +} + +/* Action buttons that sit inside a line of text or a list item */ +.form--inline { + display: inline; +} + +/* ---------- Data tables (e.g. the course roster) ---------- */ + +.table { + width: 100%; + border-collapse: collapse; + background: var(--color-surface); + border: 1px solid var(--color-border); + border-radius: var(--radius); +} + +.table th, +.table td { + text-align: left; + padding: var(--space-2) var(--space-3); + border-bottom: 1px solid var(--color-border); +} + +.table th { + font-family: var(--font-display); + font-size: var(--font-size-sm); +} + +.table tr:last-child td { + border-bottom: 0; +} + .form__input:focus-visible { outline: 2px solid var(--color-focus); outline-offset: 1px; diff --git a/src/main/resources/templates/instructor/course-form.html b/src/main/resources/templates/instructor/course-form.html new file mode 100644 index 0000000..1b5233f --- /dev/null +++ b/src/main/resources/templates/instructor/course-form.html @@ -0,0 +1,161 @@ + + + + + + + + +

+ +

+ Course created as a draft. +

+

+ Course saved. +

+

+ Course published. Students can now see it in the catalogue. +

+

+ Course archived. It is no longer listed in the catalogue. +

+

+ Course restored as a draft. +

+

+ Lesson added at the end of the course. +

+

+ Lesson saved. +

+

+ Lesson published. +

+

+ Lesson archived. +

+

+ Lesson restored as a draft. +

+ +
+

Create a course

+ +

+ Status: Draft +

+ +
+
+ + Up to 255 characters. + + +
+ +
+ + + Optional. Shown to students in the catalogue. + + +
+ + +
+
+ + +
+

Publication actions

+
+ +
+
+ +
+
+ +
+
+ +

+ + View the students of this course +

+ +
+

Lessons

+ +

+ Add a lesson +

+ +

No lesson yet.

+ +
    +
  1. + Lesson title + (Draft) +
    + +
    +
    + +
    +
    + +
    +
    + +
    +
    + +
    +
  2. +
+
+ +

Back to my courses

+
+ + + + diff --git a/src/main/resources/templates/instructor/courses.html b/src/main/resources/templates/instructor/courses.html new file mode 100644 index 0000000..231a6f2 --- /dev/null +++ b/src/main/resources/templates/instructor/courses.html @@ -0,0 +1,37 @@ + + + + + + +
+

My courses

+ +

+ Create a course +

+ +

+ You have no course yet. Create your first one! +

+ + +
+ + + + diff --git a/src/main/resources/templates/instructor/lesson-form.html b/src/main/resources/templates/instructor/lesson-form.html new file mode 100644 index 0000000..460b745 --- /dev/null +++ b/src/main/resources/templates/instructor/lesson-form.html @@ -0,0 +1,53 @@ + + + + + + + +
+ + +

+ Course title +

+ +

Add a lesson

+ +
+
+ + Up to 255 characters. + + +
+ +
+ + + Markdown is rendered and sanitized before students see it. + + +
+ + +
+
+ + + + diff --git a/src/main/resources/templates/instructor/students.html b/src/main/resources/templates/instructor/students.html new file mode 100644 index 0000000..da1ff26 --- /dev/null +++ b/src/main/resources/templates/instructor/students.html @@ -0,0 +1,61 @@ + + + + + + +
+

+ The student has been removed from the course. +

+ +

+ Course title +

+ +

Students

+ +

No student has enrolled yet.

+ + + + + + + + + + + + + + + + + + + + + +
Roster
StudentStatusEnrolled onLeft onAction
studentEnrolleddate- +
+ +
+
+ +

Back to my courses

+
+ + + + diff --git a/src/test/java/com/ericbouchut/learndev/common/config/SecurityMatrixTest.java b/src/test/java/com/ericbouchut/learndev/common/config/SecurityMatrixTest.java index c91fef6..5cb206a 100644 --- a/src/test/java/com/ericbouchut/learndev/common/config/SecurityMatrixTest.java +++ b/src/test/java/com/ericbouchut/learndev/common/config/SecurityMatrixTest.java @@ -19,9 +19,9 @@ /** * Access matrix for the role-gated URL prefixes: anonymous users are sent to * the login page, the wrong role is denied (403), and the right role passes - * the gate. The instructor and admin controllers do not exist yet, so "passes - * the gate" is asserted as 404 (the request reached MVC dispatch); tighten - * those assertions to 200 as each feature phase lands. + * the gate. The admin controller does not exist yet, so its "passes the + * gate" is asserted as 404 (the request reached MVC dispatch); tighten it to + * 200 when the admin phase lands. * *

Named with the {@code Test} suffix (not {@code IT}) so Surefire runs it * as part of {@code mvn test}; this project does not use the Failsafe plugin. @@ -40,16 +40,21 @@ class SecurityMatrixTest extends AbstractPostgresIT { @Autowired UserRepository userRepository; - // The catalogue controller resolves the domain user, so the gate-pass - // check needs a real users row, not just a mock principal. + // The catalogue and instructor controllers resolve the domain user, so + // the gate-pass checks need real users rows, not just mock principals. @BeforeEach - void seedStudent() { - if (userRepository.findByUsername("matrix-student").isEmpty()) { - User student = new User(); - student.setUsername("matrix-student"); - student.setEmail("matrix-student@example.com"); - student.setPassword("hashed"); - userRepository.save(student); + void seedUsers() { + seedUser("matrix-student"); + seedUser("matrix-instructor"); + } + + private void seedUser(String username) { + if (userRepository.findByUsername(username).isEmpty()) { + User user = new User(); + user.setUsername(username); + user.setEmail(username + "@example.com"); + user.setPassword("hashed"); + userRepository.save(user); } } @@ -82,9 +87,10 @@ void student_is_denied_instructor_and_admin() throws Exception { // Instructor: may pass the /instructor gate, denied on admin. @Test - @WithMockUser(roles = "INSTRUCTOR") void instructor_passes_instructor_gate() throws Exception { - mvc.perform(get("/instructor/courses")).andExpect(status().isNotFound()); + mvc.perform(get("/instructor/courses") + .with(user("matrix-instructor").roles("INSTRUCTOR"))) + .andExpect(status().isOk()); } @Test diff --git a/src/test/java/com/ericbouchut/learndev/course/InstructorCourseFlowTest.java b/src/test/java/com/ericbouchut/learndev/course/InstructorCourseFlowTest.java new file mode 100644 index 0000000..201e3b8 --- /dev/null +++ b/src/test/java/com/ericbouchut/learndev/course/InstructorCourseFlowTest.java @@ -0,0 +1,210 @@ +package com.ericbouchut.learndev.course; + +import com.ericbouchut.learndev.audit.repository.AuditLogRepository; +import com.ericbouchut.learndev.course.entity.Course; +import com.ericbouchut.learndev.course.entity.EnrollmentStatus; +import com.ericbouchut.learndev.course.entity.Lesson; +import com.ericbouchut.learndev.course.entity.PublicationStatus; +import com.ericbouchut.learndev.course.repository.CourseRepository; +import com.ericbouchut.learndev.course.repository.EnrollmentRepository; +import com.ericbouchut.learndev.course.repository.LessonRepository; +import com.ericbouchut.learndev.support.AbstractPostgresIT; +import com.ericbouchut.learndev.user.entity.User; +import com.ericbouchut.learndev.user.repository.UserRepository; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.UserRequestPostProcessor; +import org.springframework.test.web.servlet.MockMvc; + +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.hamcrest.Matchers.containsString; +import static org.hamcrest.Matchers.not; +import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf; +import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.user; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.redirectedUrl; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +/** + * End-to-end journey of an instructor through the authoring area: create a + * course, add and reorder lessons, publish, watch the student side see it, + * enforce ownership against another instructor, and manage the roster. + * Boots the full context against the shared Postgres container. + * + *

Named with the {@code Test} suffix (not {@code IT}) so Surefire runs it + * as part of {@code mvn test}; this project does not use the Failsafe plugin. + */ +@SpringBootTest(properties = { + // This feature does not use MongoDB; keep the test context Postgres-only. + "spring.autoconfigure.exclude=org.springframework.boot.autoconfigure.mongo.MongoAutoConfiguration," + + "org.springframework.boot.autoconfigure.data.mongo.MongoDataAutoConfiguration" +}) +@AutoConfigureMockMvc +class InstructorCourseFlowTest extends AbstractPostgresIT { + + @Autowired + MockMvc mvc; + + @Autowired + UserRepository userRepository; + + @Autowired + CourseRepository courseRepository; + + @Autowired + LessonRepository lessonRepository; + + @Autowired + EnrollmentRepository enrollmentRepository; + + @Autowired + AuditLogRepository auditLogRepository; + + private User registeredUser(String username) throws Exception { + if (userRepository.findByUsername(username).isEmpty()) { + mvc.perform(post("/auth/register").with(csrf()) + .param("username", username) + .param("email", username + "@example.com") + .param("password", "secret12")) + .andExpect(status().is3xxRedirection()); + } + return userRepository.findByUsername(username).orElseThrow(); + } + + private UserRequestPostProcessor asInstructor(String username) { + return user(username).roles("INSTRUCTOR"); + } + + @Test + void instructor_authors_publishes_and_manages_the_roster() throws Exception { + registeredUser("author1"); + registeredUser("author2"); + User student = registeredUser("learner1"); + + // Create a course: lands on the editor as a draft. + String editUrl = mvc.perform(post("/instructor/courses/new") + .with(asInstructor("author1")).with(csrf()) + .param("title", "Java from scratch") + .param("description", "Objects, tests, and taste.")) + .andExpect(status().is3xxRedirection()) + .andReturn().getResponse().getRedirectedUrl(); + assertThat(editUrl).matches("/instructor/courses/\\d+/edit\\?created"); + Long courseId = Long.parseLong(editUrl.replaceAll("\\D+", "")); + + // A blank title is rejected with a re-rendered form. + mvc.perform(post("/instructor/courses/new") + .with(asInstructor("author1")).with(csrf()) + .param("title", " ")) + .andExpect(status().isOk()) + .andExpect(content().string(containsString("form__error"))); + + // Add two lessons; they take positions 1 and 2. + mvc.perform(post("/instructor/courses/{c}/lessons/new", courseId) + .with(asInstructor("author1")).with(csrf()) + .param("title", "Variables") + .param("contentMarkdown", "Some **basics**.")) + .andExpect(redirectedUrl(editUrl.replace("?created", "?lesson-created"))); + mvc.perform(post("/instructor/courses/{c}/lessons/new", courseId) + .with(asInstructor("author1")).with(csrf()) + .param("title", "Objects") + .param("contentMarkdown", "More.")) + .andExpect(status().is3xxRedirection()); + + Course course = courseRepository.findById(courseId).orElseThrow(); + List ordered = lessonRepository.findByCourseOrderByPositionAsc(course); + assertThat(ordered).extracting(Lesson::getTitle) + .containsExactly("Variables", "Objects"); + + // Move the second lesson up: the reading order swaps. + mvc.perform(post("/instructor/courses/{c}/lessons/{l}/move-up", + courseId, ordered.get(1).getLessonId()) + .with(asInstructor("author1")).with(csrf())) + .andExpect(status().is3xxRedirection()); + assertThat(lessonRepository.findByCourseOrderByPositionAsc(course)) + .extracting(Lesson::getTitle) + .containsExactly("Objects", "Variables"); + + // Publish a lesson and the course; published_at gets stamped. + mvc.perform(post("/instructor/courses/{c}/lessons/{l}/publish", + courseId, ordered.get(1).getLessonId()) + .with(asInstructor("author1")).with(csrf())) + .andExpect(status().is3xxRedirection()); + mvc.perform(post("/instructor/courses/{c}/publish", courseId) + .with(asInstructor("author1")).with(csrf())) + .andExpect(status().is3xxRedirection()); + assertThat(courseRepository.findById(courseId).orElseThrow().getPublishedAt()) + .isNotNull(); + + // The published course reaches the student catalogue. + mvc.perform(get("/courses").with(user("learner1").roles("STUDENT"))) + .andExpect(content().string(containsString("Java from scratch"))); + + // Publishing an already published course is a 409. + mvc.perform(post("/instructor/courses/{c}/publish", courseId) + .with(asInstructor("author1")).with(csrf())) + .andExpect(status().isConflict()); + + // Another instructor cannot touch the course. + mvc.perform(get("/instructor/courses/{c}/edit", courseId) + .with(asInstructor("author2"))) + .andExpect(status().isForbidden()); + + // A student enrolls; the roster lists them. + mvc.perform(post("/courses/{c}/enroll", courseId) + .with(user("learner1").roles("STUDENT")).with(csrf())) + .andExpect(status().is3xxRedirection()); + mvc.perform(get("/instructor/courses/{c}/students", courseId) + .with(asInstructor("author1"))) + .andExpect(status().isOk()) + .andExpect(content().string(containsString("learner1"))); + + // The instructor removes the student: DROPPED and audited. + mvc.perform(post("/instructor/courses/{c}/students/{u}/drop", + courseId, student.getUserId()) + .with(asInstructor("author1")).with(csrf())) + .andExpect(redirectedUrl( + "/instructor/courses/" + courseId + "/students?dropped")); + assertThat(enrollmentRepository + .findByUserAndCourse(student, course).orElseThrow().getStatus()) + .isEqualTo(EnrollmentStatus.DROPPED); + assertThat(auditLogRepository.findAll()) + .anyMatch(log -> "ENROLLMENT_DROPPED_BY_INSTRUCTOR".equals(log.getActionType())); + + // A draft course never reaches the student catalogue. + mvc.perform(post("/instructor/courses/new") + .with(asInstructor("author1")).with(csrf()) + .param("title", "Secret draft class")) + .andExpect(status().is3xxRedirection()); + mvc.perform(get("/courses").with(user("learner1").roles("STUDENT"))) + .andExpect(content().string(not(containsString("Secret draft class")))); + } + + @Test + void archived_course_can_be_restored_to_draft() throws Exception { + registeredUser("author3"); + + String editUrl = mvc.perform(post("/instructor/courses/new") + .with(asInstructor("author3")).with(csrf()) + .param("title", "Pause me")) + .andReturn().getResponse().getRedirectedUrl(); + Long courseId = Long.parseLong(editUrl.replaceAll("\\D+", "")); + + mvc.perform(post("/instructor/courses/{c}/archive", courseId) + .with(asInstructor("author3")).with(csrf())) + .andExpect(status().is3xxRedirection()); + assertThat(courseRepository.findById(courseId).orElseThrow().getStatus()) + .isEqualTo(PublicationStatus.ARCHIVED); + + mvc.perform(post("/instructor/courses/{c}/restore", courseId) + .with(asInstructor("author3")).with(csrf())) + .andExpect(status().is3xxRedirection()); + assertThat(courseRepository.findById(courseId).orElseThrow().getStatus()) + .isEqualTo(PublicationStatus.DRAFT); + } +} diff --git a/src/test/java/com/ericbouchut/learndev/course/InstructorCourseServiceTest.java b/src/test/java/com/ericbouchut/learndev/course/InstructorCourseServiceTest.java new file mode 100644 index 0000000..6528f08 --- /dev/null +++ b/src/test/java/com/ericbouchut/learndev/course/InstructorCourseServiceTest.java @@ -0,0 +1,188 @@ +package com.ericbouchut.learndev.course; + +import com.ericbouchut.learndev.audit.AuditService; +import com.ericbouchut.learndev.course.dto.CourseForm; +import com.ericbouchut.learndev.course.dto.LessonForm; +import com.ericbouchut.learndev.course.entity.Course; +import com.ericbouchut.learndev.course.entity.Lesson; +import com.ericbouchut.learndev.course.entity.PublicationStatus; +import com.ericbouchut.learndev.course.repository.CourseRepository; +import com.ericbouchut.learndev.course.repository.EnrollmentRepository; +import com.ericbouchut.learndev.course.repository.LessonRepository; +import com.ericbouchut.learndev.user.entity.User; +import org.junit.jupiter.api.Test; +import org.springframework.web.server.ResponseStatusException; + +import java.time.OffsetDateTime; +import java.util.List; +import java.util.Optional; +import java.util.UUID; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyBoolean; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.*; + +class InstructorCourseServiceTest { + + private final CourseRepository courses = mock(CourseRepository.class); + private final LessonRepository lessons = mock(LessonRepository.class); + private final EnrollmentRepository enrollments = mock(EnrollmentRepository.class); + private final EnrollmentService enrollmentService = mock(EnrollmentService.class); + private final AuditService audit = mock(AuditService.class); + private final InstructorCourseService service = new InstructorCourseService( + courses, lessons, enrollments, enrollmentService, audit); + + private final User owner = userWithId(); + private final User stranger = userWithId(); + + private static User userWithId() { + User user = new User(); + user.setUserId(UUID.randomUUID()); + return user; + } + + private Course draftCourseOf(User instructor) { + Course course = new Course(); + course.setCourseId(7L); + course.setTitle("Draft course"); + course.setInstructor(instructor); + return course; + } + + @Test + void refuses_a_course_owned_by_another_instructor() { + // Arrange (Given): a course owned by someone else + Course course = draftCourseOf(owner); + when(courses.findById(7L)).thenReturn(Optional.of(course)); + + // Act + Assert (When/Then): 403, ownership is enforced + assertThatThrownBy(() -> service.ownedCourse(7L, stranger)) + .isInstanceOf(ResponseStatusException.class) + .hasMessageContaining("403"); + } + + @Test + void first_publish_stamps_published_at_and_a_republish_keeps_it() { + // Arrange (Given): a draft that was never published + Course course = draftCourseOf(owner); + + // Act (When): publish it + service.publish(course, owner, "127.0.0.1"); + + // Assert (Then): published, stamped, audited + assertThat(course.getStatus()).isEqualTo(PublicationStatus.PUBLISHED); + OffsetDateTime firstPublish = course.getPublishedAt(); + assertThat(firstPublish).isNotNull(); + verify(audit).record(eq("COURSE_PUBLISHED"), eq(owner), anyString(), + anyBoolean(), anyString()); + + // Act (When): archive, restore, publish again + service.archive(course, owner, "127.0.0.1"); + service.restore(course, owner, "127.0.0.1"); + service.publish(course, owner, "127.0.0.1"); + + // Assert (Then): the original first-publish timestamp is kept + assertThat(course.getPublishedAt()).isEqualTo(firstPublish); + } + + @Test + void refuses_to_publish_a_published_course() { + // Arrange (Given): an already published course + Course course = draftCourseOf(owner); + course.setStatus(PublicationStatus.PUBLISHED); + + // Act + Assert (When/Then): 409, the lifecycle only publishes drafts + assertThatThrownBy(() -> service.publish(course, owner, "127.0.0.1")) + .isInstanceOf(ResponseStatusException.class) + .hasMessageContaining("409"); + verify(audit, never()).record(any(), any(), any(), anyBoolean(), any()); + } + + @Test + void refuses_to_archive_an_archived_course() { + // Arrange (Given): an archived course + Course course = draftCourseOf(owner); + course.setStatus(PublicationStatus.ARCHIVED); + + // Act + Assert (When/Then): 409 + assertThatThrownBy(() -> service.archive(course, owner, "127.0.0.1")) + .isInstanceOf(ResponseStatusException.class) + .hasMessageContaining("409"); + } + + @Test + void a_new_lesson_goes_to_the_end_of_the_course() { + // Arrange (Given): a course whose lessons end at position 4 + Course course = draftCourseOf(owner); + when(lessons.findByCourseOrderByPositionAsc(course)) + .thenReturn(List.of(lessonAt(1L, 2), lessonAt(2L, 4))); + when(lessons.save(any(Lesson.class))).thenAnswer(inv -> inv.getArgument(0)); + + // Act (When) + Lesson created = service.createLesson(course, new LessonForm("Outro", null)); + + // Assert (Then): appended after the last position, DRAFT, empty content + assertThat(created.getPosition()).isEqualTo(5); + assertThat(created.getStatus()).isEqualTo(PublicationStatus.DRAFT); + assertThat(created.getContentMarkdown()).isEmpty(); + } + + @Test + void moving_a_lesson_up_swaps_the_two_positions() { + // Arrange (Given): lessons at positions 1 and 2 + Course course = draftCourseOf(owner); + Lesson first = lessonAt(1L, 1); + Lesson second = lessonAt(2L, 2); + when(lessons.findByCourseOrderByPositionAsc(course)) + .thenReturn(List.of(first, second)); + + // Act (When): move the second lesson up + service.moveLessonUp(course, second); + + // Assert (Then): positions are exchanged + assertThat(second.getPosition()).isEqualTo(1); + assertThat(first.getPosition()).isEqualTo(2); + } + + @Test + void moving_the_first_lesson_up_is_a_no_op() { + // Arrange (Given): the lesson is already first + Course course = draftCourseOf(owner); + Lesson first = lessonAt(1L, 1); + when(lessons.findByCourseOrderByPositionAsc(course)) + .thenReturn(List.of(first, lessonAt(2L, 2))); + + // Act (When) + service.moveLessonUp(course, first); + + // Assert (Then): nothing changes, nothing is written + assertThat(first.getPosition()).isEqualTo(1); + verify(lessons, never()).saveAndFlush(any()); + } + + private static Lesson lessonAt(Long id, int position) { + Lesson lesson = new Lesson(); + lesson.setLessonId(id); + lesson.setTitle("Lesson " + id); + lesson.setPosition(position); + return lesson; + } + + @Test + void creates_a_draft_course_with_a_null_blank_description() { + // Arrange (Given): a form with a blank description + when(courses.save(any(Course.class))).thenAnswer(inv -> inv.getArgument(0)); + + // Act (When) + Course created = service.create(owner, new CourseForm("New course", " ")); + + // Assert (Then): DRAFT, owned, description normalized to null + assertThat(created.getStatus()).isEqualTo(PublicationStatus.DRAFT); + assertThat(created.getInstructor()).isSameAs(owner); + assertThat(created.getDescription()).isNull(); + } +} diff --git a/src/test/java/com/ericbouchut/learndev/course/repository/CourseDomainRepositoryTest.java b/src/test/java/com/ericbouchut/learndev/course/repository/CourseDomainRepositoryTest.java index 5584c98..10bed98 100644 --- a/src/test/java/com/ericbouchut/learndev/course/repository/CourseDomainRepositoryTest.java +++ b/src/test/java/com/ericbouchut/learndev/course/repository/CourseDomainRepositoryTest.java @@ -198,6 +198,32 @@ void finds_the_enrollment_of_a_user_in_a_course() { .isEmpty(); } + @Test + void swapping_two_lesson_positions_through_a_temporary_value_satisfies_the_constraint() { + // Arrange (Given): two lessons at positions 1 and 2; the unique + // constraint is NOT deferrable, so a direct swap would collide + User instructor = newUser("instructor8"); + Course course = newCourse(instructor, "Reorder course"); + Lesson first = newLesson(course, 1, "First"); + Lesson second = newLesson(course, 2, "Second"); + + // Act (When): the three-step swap used by InstructorCourseService + // (A to a temporary negative position, B to A's, A to B's) + first.setPosition(-1); + lessonRepository.saveAndFlush(first); + second.setPosition(1); + lessonRepository.saveAndFlush(second); + first.setPosition(2); + lessonRepository.saveAndFlush(first); + entityManager.clear(); + + // Assert (Then): the reading order is swapped + Course reloaded = courseRepository.findById(course.getCourseId()).orElseThrow(); + assertThat(lessonRepository.findByCourseOrderByPositionAsc(reloaded)) + .extracting(Lesson::getTitle) + .containsExactly("Second", "First"); + } + @Test void deleting_an_instructor_with_courses_is_rejected() { // Arrange (Given): an instructor who teaches a course