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 @@
+
+
+
+
+
+
+
+
+
+
+ The course could not be saved. Check the highlighted fields below.
+
+
+ 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.
+
+
+
+
+
+
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