From c68a933c7877f130b113a87bf92c82a56104c150 Mon Sep 17 00:00:00 2001 From: Eric Bouchut Date: Mon, 13 Jul 2026 16:07:24 +0200 Subject: [PATCH 1/3] feat(course): Let instructors author, order, and publish content The instructor area under /instructor/courses (already role-gated) lets an instructor create and edit their courses and lessons, publish, archive, and restore them, and reorder lessons. InstructorCourseService owns the write side: ownership is enforced at the service level (someone else's course answers 403), lifecycle transitions follow CONTRIBUTING.md (publish from DRAFT, archive from DRAFT or PUBLISHED, restore to DRAFT, anything else 409), and published_at is stamped on the first publish only. Publish, archive, and restore actions are recorded through AuditService with the client IP, like the password-reset flow. Lessons append at position max plus one, and reordering swaps with the neighbor through a temporary negative position because uq_lessons_course_position is not deferrable (proven against real Postgres in the repository test). Forms reuse the register.html error wiring (aria-invalid, aria-describedby, alert summary); the security matrix tightens the instructor gate to 200 with a seeded user. --- .../course/InstructorCourseController.java | 380 ++++++++++++++++++ .../course/InstructorCourseService.java | 242 +++++++++++ .../learndev/course/dto/CourseForm.java | 18 + .../learndev/course/dto/LessonForm.java | 20 + src/main/resources/static/css/base.css | 11 + .../templates/instructor/course-form.html | 156 +++++++ .../templates/instructor/courses.html | 37 ++ .../templates/instructor/lesson-form.html | 53 +++ .../common/config/SecurityMatrixTest.java | 34 +- .../course/InstructorCourseServiceTest.java | 185 +++++++++ .../CourseDomainRepositoryTest.java | 26 ++ 11 files changed, 1148 insertions(+), 14 deletions(-) create mode 100644 src/main/java/com/ericbouchut/learndev/course/InstructorCourseController.java create mode 100644 src/main/java/com/ericbouchut/learndev/course/InstructorCourseService.java create mode 100644 src/main/java/com/ericbouchut/learndev/course/dto/CourseForm.java create mode 100644 src/main/java/com/ericbouchut/learndev/course/dto/LessonForm.java create mode 100644 src/main/resources/templates/instructor/course-form.html create mode 100644 src/main/resources/templates/instructor/courses.html create mode 100644 src/main/resources/templates/instructor/lesson-form.html create mode 100644 src/test/java/com/ericbouchut/learndev/course/InstructorCourseServiceTest.java 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..b8d5e84 --- /dev/null +++ b/src/main/java/com/ericbouchut/learndev/course/InstructorCourseController.java @@ -0,0 +1,380 @@ +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.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 java.security.Principal; + +/** + * 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"; + } + + 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..0105c40 --- /dev/null +++ b/src/main/java/com/ericbouchut/learndev/course/InstructorCourseService.java @@ -0,0 +1,242 @@ +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.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 AuditService audit; + + public InstructorCourseService( + CourseRepository courses, + LessonRepository lessons, + AuditService audit + ) { + this.courses = courses; + this.lessons = lessons; + 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); + } + + 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..68fe5bf 100644 --- a/src/main/resources/static/css/base.css +++ b/src/main/resources/static/css/base.css @@ -416,6 +416,17 @@ 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; +} + .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..9e9bb53 --- /dev/null +++ b/src/main/resources/templates/instructor/course-form.html @@ -0,0 +1,156 @@ + + + + + + + + +

+ +

+ 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

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

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/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/InstructorCourseServiceTest.java b/src/test/java/com/ericbouchut/learndev/course/InstructorCourseServiceTest.java new file mode 100644 index 0000000..e259ece --- /dev/null +++ b/src/test/java/com/ericbouchut/learndev/course/InstructorCourseServiceTest.java @@ -0,0 +1,185 @@ +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.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 AuditService audit = mock(AuditService.class); + private final InstructorCourseService service = + new InstructorCourseService(courses, lessons, 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 From 6283d394fde4483effe8f7595c0f4407d42ab65f Mon Sep 17 00:00:00 2001 From: Eric Bouchut Date: Mon, 13 Jul 2026 16:08:46 +0200 Subject: [PATCH 2/3] feat(course): Give instructors a per-course student roster The course editor links to a roster page listing every enrollment of the course (username, status, enrollment and drop dates) in an accessible data table. The instructor can remove an actively enrolled student: this reuses the student-side drop rules (idempotent, COMPLETED untouched) and is audited as ENROLLMENT_DROPPED_BY_INSTRUCTOR, distinct from a self-drop. InstructorCourseFlowTest drives the whole instructor journey end to end: create with a validation error on the way, add and reorder lessons, publish lesson and course, watch the student catalogue pick the course up, get a 409 on a double publish and a 403 as another instructor, list the roster, drop the student, and check the audit trail and that drafts stay invisible. --- .../course/InstructorCourseController.java | 48 ++++ .../course/InstructorCourseService.java | 26 +++ src/main/resources/static/css/base.css | 26 +++ .../templates/instructor/course-form.html | 5 + .../templates/instructor/students.html | 61 +++++ .../course/InstructorCourseFlowTest.java | 210 ++++++++++++++++++ .../course/InstructorCourseServiceTest.java | 7 +- 7 files changed, 381 insertions(+), 2 deletions(-) create mode 100644 src/main/resources/templates/instructor/students.html create mode 100644 src/test/java/com/ericbouchut/learndev/course/InstructorCourseFlowTest.java diff --git a/src/main/java/com/ericbouchut/learndev/course/InstructorCourseController.java b/src/main/java/com/ericbouchut/learndev/course/InstructorCourseController.java index b8d5e84..643755c 100644 --- a/src/main/java/com/ericbouchut/learndev/course/InstructorCourseController.java +++ b/src/main/java/com/ericbouchut/learndev/course/InstructorCourseController.java @@ -8,6 +8,7 @@ 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; @@ -16,8 +17,10 @@ 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 @@ -372,6 +375,51 @@ public String moveLesson( 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( diff --git a/src/main/java/com/ericbouchut/learndev/course/InstructorCourseService.java b/src/main/java/com/ericbouchut/learndev/course/InstructorCourseService.java index 0105c40..f20e239 100644 --- a/src/main/java/com/ericbouchut/learndev/course/InstructorCourseService.java +++ b/src/main/java/com/ericbouchut/learndev/course/InstructorCourseService.java @@ -4,9 +4,11 @@ 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; @@ -33,15 +35,21 @@ 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; } @@ -219,6 +227,24 @@ private void swapWithNeighbor(Course course, Lesson lesson, int direction) { 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) diff --git a/src/main/resources/static/css/base.css b/src/main/resources/static/css/base.css index 68fe5bf..7f617e1 100644 --- a/src/main/resources/static/css/base.css +++ b/src/main/resources/static/css/base.css @@ -427,6 +427,32 @@ textarea.form__input { 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 index 9e9bb53..1b5233f 100644 --- a/src/main/resources/templates/instructor/course-form.html +++ b/src/main/resources/templates/instructor/course-form.html @@ -98,6 +98,11 @@

Publication actions

+

+ + View the students of this course +

+

Lessons

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/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 index e259ece..6528f08 100644 --- a/src/test/java/com/ericbouchut/learndev/course/InstructorCourseServiceTest.java +++ b/src/test/java/com/ericbouchut/learndev/course/InstructorCourseServiceTest.java @@ -7,6 +7,7 @@ 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; @@ -29,9 +30,11 @@ 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, audit); + private final InstructorCourseService service = new InstructorCourseService( + courses, lessons, enrollments, enrollmentService, audit); private final User owner = userWithId(); private final User stranger = userWithId(); From 78508022999a6b0cd97302302007955d29bccb53 Mon Sep 17 00:00:00 2001 From: Eric Bouchut Date: Mon, 13 Jul 2026 16:09:00 +0200 Subject: [PATCH 3/3] docs(glossary): Define roster and publish Both glossaries gain the two domain terms the instructor area introduces, EN and FR kept in sync per the glossary rule: Publish (make a draft visible to students; the first publication stamps published_at) and Roster (the enrolled students of a course, which instructors can remove a student from). Archive was already defined. --- GLOSSAIRE.md | 6 ++++++ GLOSSARY.md | 5 +++++ 2 files changed, 11 insertions(+) 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