feat : 수강신청 도메인 구성

This commit is contained in:
donghyeon-ka
2026-09-18 15:20:54 +09:00
parent 3e33388e02
commit 70d71008c6
61 changed files with 2835 additions and 258 deletions
@@ -0,0 +1,14 @@
package com.study.course_registration.config;
import java.time.Clock;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
@Configuration
public class ClockConfig {
@Bean
Clock clock() {
return Clock.systemUTC();
}
}
@@ -0,0 +1,18 @@
package com.study.course_registration.config;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import io.swagger.v3.oas.models.OpenAPI;
import io.swagger.v3.oas.models.info.Info;
@Configuration
public class OpenApiConfig {
@Bean
OpenAPI courseRegistrationOpenApi() {
return new OpenAPI().info(new Info()
.title("Course Registration API")
.version("v1")
.description("수강신청, 취소, 신청 목록 및 개설 강의 조회 API"));
}
}
@@ -1,38 +1,70 @@
package com.study.course_registration.controller;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.responses.ApiResponse;
import io.swagger.v3.oas.annotations.responses.ApiResponses;
import org.springframework.http.HttpStatus;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.ResponseStatus;
import org.springframework.web.bind.annotation.RestController;
import com.study.course_registration.dto.registration.CourseRegistrationRequest;
import com.study.course_registration.dto.registration.CourseRegistrationResponse;
import com.study.course_registration.dto.registration.RegistrationListResponse;
import com.study.course_registration.service.CourseRegistrationService;
import com.study.course_registration.dto.RequestDto;
import com.study.course_registration.dto.ResponseDto;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.media.Content;
import io.swagger.v3.oas.annotations.responses.ApiResponse;
import io.swagger.v3.oas.annotations.responses.ApiResponses;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.validation.Valid;
@RestController
@RequestMapping ("/api/v1")
@Validated
@RequestMapping("/api/v1/users/{userId}/registrations")
@Tag(name = "Registrations", description = "수강신청/조회/취소 API")
public class CourseRegistrationController {
private final CourseRegistrationService courseRegistrationService;
public CourseRegistrationController(CourseRegistrationService courseRegistrationService) {
this.courseRegistrationService = courseRegistrationService;
}
@PostMapping("/course-register")
@Operation(summary = "Register a course",
description = "Registers a new course with the provided details.")
@PostMapping
@ResponseStatus(HttpStatus.CREATED)
@Operation(summary = "수강신청")
@ApiResponses({
@ApiResponse (responseCode = "200", description = "Course registered successfully"),
@ApiResponse (responseCode = "400", description = "Invalid request data")
@ApiResponse(responseCode = "201", description = "신청 성공"),
@ApiResponse(responseCode = "403", description = "학년 또는 역할 제한", content = @Content),
@ApiResponse(responseCode = "404", description = "사용자 또는 강의 없음", content = @Content),
@ApiResponse(responseCode = "409", description = "기간/중복/시간표/학점/정원 충돌", content = @Content),
@ApiResponse(responseCode = "503", description = "락 대기 타임아웃", content = @Content)
})
public ResponseDto registerCourse(@Validated RequestDto requestDto) {
return courseRegistrationService.registerCourse(requestDto);
public CourseRegistrationResponse register(
@PathVariable String userId,
@Valid @RequestBody CourseRegistrationRequest request) {
return courseRegistrationService.register(userId, request);
}
@GetMapping
@Operation(summary = "내 수강신청 목록 조회")
public RegistrationListResponse getRegistrations(
@PathVariable String userId,
@RequestParam(required = false) String semesterId) {
return courseRegistrationService.getRegistrations(userId, semesterId);
}
@DeleteMapping("/{registrationId}")
@ResponseStatus(HttpStatus.NO_CONTENT)
@Operation(summary = "수강신청 취소")
public void cancel(
@PathVariable String userId,
@PathVariable String registrationId) {
courseRegistrationService.cancel(userId, registrationId);
}
}
@@ -0,0 +1,45 @@
package com.study.course_registration.controller;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import com.study.course_registration.dto.lesson.LessonDetailResponse;
import com.study.course_registration.dto.lesson.LessonListResponse;
import com.study.course_registration.service.LessonQueryService;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.validation.constraints.Max;
import jakarta.validation.constraints.Min;
@RestController
@Validated
@RequestMapping("/api/v1/lessons")
@Tag(name = "Lessons", description = "개설 강의 조회 API")
public class LessonController {
private final LessonQueryService lessonQueryService;
public LessonController(LessonQueryService lessonQueryService) {
this.lessonQueryService = lessonQueryService;
}
@GetMapping
@Operation(summary = "개설 강의 목록 조회")
public LessonListResponse getLessons(
@RequestParam(required = false) String semesterId,
@RequestParam(required = false) String subjectId,
@RequestParam(defaultValue = "0") @Min(0) int page,
@RequestParam(defaultValue = "20") @Min(1) @Max(100) int size) {
return lessonQueryService.getLessons(semesterId, subjectId, page, size);
}
@GetMapping("/{lessonId}")
@Operation(summary = "개설 강의 상세 조회")
public LessonDetailResponse getLesson(@PathVariable String lessonId) {
return lessonQueryService.getLesson(lessonId);
}
}
@@ -1,7 +0,0 @@
package com.study.course_registration.dto;
public record RequestDto(
) {
}
@@ -1,7 +0,0 @@
package com.study.course_registration.dto;
public record ResponseDto(
) {
}
@@ -0,0 +1,15 @@
package com.study.course_registration.dto;
import java.time.DayOfWeek;
import java.time.LocalTime;
import com.study.course_registration.entity.LessonSchedule;
public record ScheduleResponse(
DayOfWeek dayOfWeek,
LocalTime startTime,
LocalTime endTime) {
public static ScheduleResponse from(LessonSchedule schedule) {
return new ScheduleResponse(schedule.getDayOfWeek(), schedule.getStartTime(), schedule.getEndTime());
}
}
@@ -0,0 +1,25 @@
package com.study.course_registration.dto.lesson;
import java.time.Instant;
import java.util.List;
import com.study.course_registration.dto.ScheduleResponse;
import com.study.course_registration.enums.UserRole;
public record LessonDetailResponse(
String lessonId,
String lessonName,
String subjectCode,
String subjectName,
String subjectDescription,
Integer credit,
String professorName,
String semesterName,
Integer capacity,
Long enrolledCount,
Long minGrade,
UserRole allowedRole,
Instant registrationStartAt,
Instant registrationEndAt,
List<ScheduleResponse> schedules) {
}
@@ -0,0 +1,20 @@
package com.study.course_registration.dto.lesson;
import java.util.List;
import com.study.course_registration.dto.ScheduleResponse;
import com.study.course_registration.enums.UserRole;
public record LessonItemResponse(
String lessonId,
String lessonName,
String subjectCode,
String subjectName,
Integer credit,
String professorName,
Integer capacity,
Long enrolledCount,
Long minGrade,
UserRole allowedRole,
List<ScheduleResponse> schedules) {
}
@@ -0,0 +1,10 @@
package com.study.course_registration.dto.lesson;
import java.util.List;
public record LessonListResponse(
Integer page,
Integer size,
Long totalElements,
List<LessonItemResponse> lessons) {
}
@@ -0,0 +1,7 @@
package com.study.course_registration.dto.registration;
import jakarta.validation.constraints.NotBlank;
public record CourseRegistrationRequest(
@NotBlank(message = "lessonId는 필수입니다.") String lessonId) {
}
@@ -0,0 +1,14 @@
package com.study.course_registration.dto.registration;
import java.time.Instant;
public record CourseRegistrationResponse(
String registrationId,
String userId,
String lessonId,
String lessonName,
String subjectCode,
Integer credit,
Instant registeredAt,
Integer totalCredits) {
}
@@ -0,0 +1,18 @@
package com.study.course_registration.dto.registration;
import java.time.Instant;
import java.util.List;
import com.study.course_registration.dto.ScheduleResponse;
public record RegistrationItemResponse(
String registrationId,
String lessonId,
String lessonName,
String subjectCode,
String subjectName,
Integer credit,
String professorName,
List<ScheduleResponse> schedules,
Instant registeredAt) {
}
@@ -0,0 +1,12 @@
package com.study.course_registration.dto.registration;
import java.util.List;
public record RegistrationListResponse(
String userId,
String semesterId,
String semesterName,
Integer totalCredits,
Integer maxCredits,
List<RegistrationItemResponse> registrations) {
}
@@ -5,11 +5,23 @@ import java.time.Instant;
import org.springframework.data.annotation.CreatedDate;
import org.springframework.data.jpa.domain.support.AuditingEntityListener;
import jakarta.persistence.*;
import jakarta.persistence.Column;
import jakarta.persistence.EntityListeners;
import jakarta.persistence.MappedSuperclass;
import lombok.Getter;
@MappedSuperclass
@EntityListeners (AuditingEntityListener.class)
public class BaseCreateEntity {
@CreatedDate
@Getter
@MappedSuperclass
@EntityListeners(AuditingEntityListener.class)
public abstract class BaseCreateEntity extends BaseEntity {
@CreatedDate
@Column(nullable = false, updatable = false)
private Instant createdAt;
protected BaseCreateEntity() {
}
protected BaseCreateEntity(String id) {
super(id);
}
}
@@ -1,15 +1,31 @@
package com.study.course_registration.entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import java.util.UUID;
import jakarta.persistence.Column;
import jakarta.persistence.Id;
import jakarta.persistence.MappedSuperclass;
import jakarta.persistence.PrePersist;
import lombok.Getter;
@Getter
@MappedSuperclass
public class BaseEntity {
@Id
@GeneratedValue(strategy = GenerationType.UUID)
@MappedSuperclass
public abstract class BaseEntity {
@Id
@Column(length = 36, nullable = false, updatable = false)
private String id;
protected BaseEntity() {
}
protected BaseEntity(String id) {
this.id = id;
}
@PrePersist
protected void assignId() {
if (id == null) {
id = UUID.randomUUID().toString();
}
}
}
@@ -5,15 +5,28 @@ import java.time.Instant;
import org.springframework.data.annotation.CreatedDate;
import org.springframework.data.annotation.LastModifiedDate;
import org.springframework.data.jpa.domain.support.AuditingEntityListener;
import jakarta.persistence.Column;
import jakarta.persistence.EntityListeners;
import jakarta.persistence.MappedSuperclass;
import lombok.Getter;
@MappedSuperclass
@EntityListeners (AuditingEntityListener.class)
public class BaseTimeZoneEntity extends BaseEntity {
@CreatedDate
@Getter
@MappedSuperclass
@EntityListeners(AuditingEntityListener.class)
public abstract class BaseTimeZoneEntity extends BaseEntity {
@CreatedDate
@Column(nullable = false, updatable = false)
private Instant createdAt;
@LastModifiedDate
@LastModifiedDate
@Column(nullable = false)
private Instant updatedAt;
protected BaseTimeZoneEntity() {
}
protected BaseTimeZoneEntity(String id) {
super(id);
}
}
@@ -1,53 +1,74 @@
package com.study.course_registration.entity;
import jakarta.persistence.*;
import lombok.Getter;
import lombok.NoArgsConstructor;
import java.time.Instant;
import java.util.Objects;
import com.study.course_registration.enums.UserRole;
@Entity
@NoArgsConstructor
@Getter
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.EnumType;
import jakarta.persistence.Enumerated;
import jakarta.persistence.FetchType;
import jakarta.persistence.ForeignKey;
import jakarta.persistence.JoinColumn;
import jakarta.persistence.ManyToOne;
import jakarta.persistence.Table;
import lombok.Getter;
import lombok.NoArgsConstructor;
@Entity
@NoArgsConstructor
@Getter
@Table(name = "lessons")
public class Lesson extends BaseTimeZoneEntity {
@Column(nullable = false)
private String name;
@ManyToOne(fetch = FetchType.LAZY)
@ManyToOne(fetch = FetchType.LAZY, optional = false)
@JoinColumn(name = "subject_id", nullable = false, foreignKey = @ForeignKey(name = "fk_lesson_subject"))
private Subject subject;
@ManyToOne(fetch = FetchType.LAZY)
@ManyToOne(fetch = FetchType.LAZY, optional = false)
@JoinColumn(name = "professor_id", nullable = false, foreignKey = @ForeignKey(name = "fk_lesson_professor"))
private Professor professor;
private Instant startTime;
private Instant endTime;
@ManyToOne(fetch = FetchType.LAZY)
@Column (name = "semester_id", nullable = false)
@ManyToOne(fetch = FetchType.LAZY, optional = false)
@JoinColumn(name = "semester_id", nullable = false, foreignKey = @ForeignKey(name = "fk_lesson_semester"))
private Semester semester;
@Column (name = "capacity", nullable = false)
@Column(nullable = false)
private Integer capacity;
@Column (name = "min_grade", nullable = false)
@Column(name = "min_grade")
private Long minGrade;
@Column (name = "allowed_role", nullable = false)
@Enumerated(EnumType.STRING)
@Column(name = "allowed_role")
private UserRole allowedRole;
public Lesson(String name, Subject subject, Professor professor, Instant startTime, Instant endTime, Semester semester, Integer capacity, Long minGrade, UserRole allowedRole) {
public Lesson(String name, Subject subject, Professor professor, Semester semester,
Integer capacity, Long minGrade, UserRole allowedRole) {
this(null, name, subject, professor, semester, capacity, minGrade, allowedRole);
}
public Lesson(String id, String name, Subject subject, Professor professor, Semester semester,
Integer capacity, Long minGrade, UserRole allowedRole) {
super(id);
if (name == null || name.isBlank()) {
throw new IllegalArgumentException("name must not be blank");
}
if (capacity == null || capacity < 1) {
throw new IllegalArgumentException("capacity must be at least 1");
}
if (minGrade != null && minGrade < 1) {
throw new IllegalArgumentException("minGrade must be at least 1 when present");
}
this.name = name;
this.subject = subject;
this.professor = professor;
this.startTime = startTime;
this.endTime = endTime;
this.semester = semester;
this.subject = Objects.requireNonNull(subject, "subject");
this.professor = Objects.requireNonNull(professor, "professor");
this.semester = Objects.requireNonNull(semester, "semester");
this.capacity = capacity;
this.minGrade = minGrade;
this.allowedRole = allowedRole;
}
}
@@ -2,26 +2,51 @@ package com.study.course_registration.entity;
import java.time.DayOfWeek;
import java.time.LocalTime;
import java.util.Objects;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.EnumType;
import jakarta.persistence.Enumerated;
import jakarta.persistence.FetchType;
import jakarta.persistence.ForeignKey;
import jakarta.persistence.JoinColumn;
import jakarta.persistence.ManyToOne;
import jakarta.persistence.Table;
import lombok.Getter;
import lombok.NoArgsConstructor;
@Entity
@Entity
@Getter
@NoArgsConstructor
@NoArgsConstructor
@Table(name = "lesson_schedules")
public class LessonSchedule extends BaseTimeZoneEntity {
@ManyToOne(fetch = FetchType.LAZY)
@Column()
@ManyToOne(fetch = FetchType.LAZY, optional = false)
@JoinColumn(name = "lesson_id", nullable = false, foreignKey = @ForeignKey(name = "fk_schedule_lesson"))
private Lesson lesson;
@Column()
@Enumerated(EnumType.STRING)
@Column(nullable = false, length = 16)
private DayOfWeek dayOfWeek;
@Column(nullable = false)
private LocalTime startTime;
@Column(nullable = false)
private LocalTime endTime;
public LessonSchedule(Lesson lesson, DayOfWeek dayOfWeek, LocalTime startTime, LocalTime endTime) {
this(null, lesson, dayOfWeek, startTime, endTime);
}
public LessonSchedule(String id, Lesson lesson, DayOfWeek dayOfWeek, LocalTime startTime, LocalTime endTime) {
super(id);
this.lesson = Objects.requireNonNull(lesson, "lesson");
this.dayOfWeek = Objects.requireNonNull(dayOfWeek, "dayOfWeek");
this.startTime = Objects.requireNonNull(startTime, "startTime");
this.endTime = Objects.requireNonNull(endTime, "endTime");
if (!startTime.isBefore(endTime)) {
throw new IllegalArgumentException("startTime must be before endTime");
}
}
}
@@ -1,17 +1,28 @@
package com.study.course_registration.entity;
import jakarta.persistence.*;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.Table;
import lombok.Getter;
import lombok.NoArgsConstructor;
@Entity
@NoArgsConstructor
@Entity
@NoArgsConstructor
@Getter
@Table(name = "professors")
public class Professor extends BaseTimeZoneEntity {
@Column(nullable = false)
private String name;
public Professor(String name) {
this(null, name);
}
public Professor(String id, String name) {
super(id);
if (name == null || name.isBlank()) {
throw new IllegalArgumentException("name must not be blank");
}
this.name = name;
}
}
@@ -2,28 +2,67 @@ package com.study.course_registration.entity;
import java.time.Instant;
import java.time.LocalDate;
import java.util.Objects;
import jakarta.persistence.*;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.Table;
import jakarta.persistence.UniqueConstraint;
import lombok.Getter;
import lombok.NoArgsConstructor;
@Entity
@NoArgsConstructor
@Getter
@Entity
@Getter
@NoArgsConstructor
@Table(name = "semesters", uniqueConstraints = @UniqueConstraint(name = "uk_semester_name", columnNames = "name"))
public class Semester extends BaseTimeZoneEntity {
@Column(nullable = false)
private String name;
@Column(nullable = false)
private LocalDate startDate;
@Column(nullable = false)
private LocalDate endDate;
@Column(nullable = false)
private Instant registrationStartAt;
@Column(nullable = false)
private Instant registrationEndAt;
@Column(nullable = false)
private Integer maxCredits;
public Semester(String name) {
this.name = name;
public Semester(String name, LocalDate startDate, LocalDate endDate,
Instant registrationStartAt, Instant registrationEndAt, Integer maxCredits) {
this(null, name, startDate, endDate, registrationStartAt, registrationEndAt, maxCredits);
}
public Semester(String id, String name, LocalDate startDate, LocalDate endDate,
Instant registrationStartAt, Instant registrationEndAt, Integer maxCredits) {
super(id);
this.name = requireText(name, "name");
this.startDate = Objects.requireNonNull(startDate, "startDate");
this.endDate = Objects.requireNonNull(endDate, "endDate");
this.registrationStartAt = Objects.requireNonNull(registrationStartAt, "registrationStartAt");
this.registrationEndAt = Objects.requireNonNull(registrationEndAt, "registrationEndAt");
if (endDate.isBefore(startDate)) {
throw new IllegalArgumentException("endDate must be on or after startDate");
}
if (!registrationStartAt.isBefore(registrationEndAt)) {
throw new IllegalArgumentException("registrationStartAt must be before registrationEndAt");
}
if (maxCredits == null || maxCredits < 1) {
throw new IllegalArgumentException("maxCredits must be at least 1");
}
this.maxCredits = maxCredits;
}
private static String requireText(String value, String field) {
if (value == null || value.isBlank()) {
throw new IllegalArgumentException(field + " must not be blank");
}
return value;
}
}
@@ -1,23 +1,48 @@
package com.study.course_registration.entity;
import jakarta.persistence.*;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.Table;
import jakarta.persistence.UniqueConstraint;
import lombok.Getter;
import lombok.NoArgsConstructor;
@Entity
@NoArgsConstructor
@Getter
@Table(name = "subjects")
@Entity
@NoArgsConstructor
@Getter
@Table(name = "subjects", uniqueConstraints = @UniqueConstraint(name = "uk_subject_code", columnNames = "code"))
public class Subject extends BaseTimeZoneEntity {
@Column(nullable = false)
private String name;
@Column(nullable = false)
private String code;
@Column(nullable = false, length = 1000)
private String description;
public Subject(String name, String code, String description) {
this.name = name;
this.code = code;
this.description = description;
@Column(nullable = false)
private Integer credit;
public Subject(String name, String code, String description, Integer credit) {
this(null, name, code, description, credit);
}
public Subject(String id, String name, String code, String description, Integer credit) {
super(id);
if (credit == null || credit < 1) {
throw new IllegalArgumentException("credit must be at least 1");
}
this.name = requireText(name, "name");
this.code = requireText(code, "code");
this.description = description == null ? "" : description;
this.credit = credit;
}
private static String requireText(String value, String field) {
if (value == null || value.isBlank()) {
throw new IllegalArgumentException(field + " must not be blank");
}
return value;
}
}
@@ -1,26 +1,46 @@
package com.study.course_registration.entity;
import java.util.Objects;
import com.study.course_registration.enums.UserRole;
import jakarta.persistence.*;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.EnumType;
import jakarta.persistence.Enumerated;
import jakarta.persistence.Table;
import lombok.Getter;
import lombok.NoArgsConstructor;
@Entity
@Getter
@Getter
@NoArgsConstructor
@Table(name = "users")
public class User extends BaseTimeZoneEntity {
@Column(nullable = false)
private String name;
@Column(nullable = false)
private Long grade;
@Enumerated(EnumType.STRING)
@Column(nullable = false)
private UserRole role;
public User(String name, Long grade, UserRole role) {
this(null, name, grade, role);
}
public User(String id, String name, Long grade, UserRole role) {
super(id);
if (name == null || name.isBlank()) {
throw new IllegalArgumentException("name must not be blank");
}
if (grade == null || grade < 1) {
throw new IllegalArgumentException("grade must be at least 1");
}
this.name = name;
this.grade = grade;
this.role = role;
this.role = Objects.requireNonNull(role, "role");
}
}
@@ -1,36 +1,52 @@
package com.study.course_registration.entity;
import jakarta.persistence.*;
import java.time.Instant;
import java.util.Objects;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.FetchType;
import jakarta.persistence.ForeignKey;
import jakarta.persistence.JoinColumn;
import jakarta.persistence.ManyToOne;
import jakarta.persistence.Table;
import lombok.Getter;
import lombok.NoArgsConstructor;
@Entity
@Entity
@NoArgsConstructor
@Getter
@Table(name = "user_lessons")
public class UserLesson extends BaseCreateEntity {
@EmbeddedId
private UserLessonId id;
@MapsId("userId")
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn (
name = "user_id",
foreignKey = @ForeignKey(name = "fk_user_lesson_user_id")
)
@ManyToOne(fetch = FetchType.LAZY, optional = false)
@JoinColumn(name = "user_id", nullable = false, foreignKey = @ForeignKey(name = "fk_user_lesson_user"))
private User user;
@MapsId("lessonId")
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn (
name = "lesson_id",
foreignKey = @ForeignKey(name = "fk_user_lesson_lesson_id")
)
@ManyToOne(fetch = FetchType.LAZY, optional = false)
@JoinColumn(name = "lesson_id", nullable = false, foreignKey = @ForeignKey(name = "fk_user_lesson_lesson"))
private Lesson lesson;
@Column(name = "canceled_at")
private Instant canceledAt;
public UserLesson(User user, Lesson lesson) {
this.user = user;
this.lesson = lesson;
this(null, user, lesson);
}
public UserLesson(String id, User user, Lesson lesson) {
super(id);
this.user = Objects.requireNonNull(user, "user");
this.lesson = Objects.requireNonNull(lesson, "lesson");
}
public void cancel(Instant canceledAt) {
if (this.canceledAt != null) {
throw new IllegalStateException("registration is already canceled");
}
this.canceledAt = Objects.requireNonNull(canceledAt, "canceledAt");
}
public boolean isCanceled() {
return canceledAt != null;
}
}
@@ -1,21 +0,0 @@
package com.study.course_registration.entity;
import jakarta.persistence.Embeddable;
import java.io.Serializable;
import lombok.EqualsAndHashCode;
import lombok.Getter;
import lombok.NoArgsConstructor;
@Embeddable
@Getter
@NoArgsConstructor
@EqualsAndHashCode
public class UserLessonId implements Serializable {
private String userId;
private String lessonId;
public UserLessonId(String userId, String lessonId) {
this.userId = userId;
this.lessonId = lessonId;
}
}
@@ -0,0 +1,32 @@
package com.study.course_registration.enums;
import org.springframework.http.HttpStatus;
public enum RegistrationErrorCode {
VALIDATION_ERROR(HttpStatus.BAD_REQUEST),
USER_NOT_FOUND(HttpStatus.NOT_FOUND),
LESSON_NOT_FOUND(HttpStatus.NOT_FOUND),
SEMESTER_NOT_FOUND(HttpStatus.NOT_FOUND),
REGISTRATION_NOT_FOUND(HttpStatus.NOT_FOUND),
NOT_ELIGIBLE_GRADE(HttpStatus.FORBIDDEN),
NOT_ELIGIBLE_ROLE(HttpStatus.FORBIDDEN),
REGISTRATION_FORBIDDEN(HttpStatus.FORBIDDEN),
REGISTRATION_PERIOD_CLOSED(HttpStatus.CONFLICT),
ALREADY_REGISTERED(HttpStatus.CONFLICT),
DUPLICATE_SUBJECT(HttpStatus.CONFLICT),
SCHEDULE_CONFLICT(HttpStatus.CONFLICT),
CREDIT_LIMIT_EXCEEDED(HttpStatus.CONFLICT),
CAPACITY_EXCEEDED(HttpStatus.CONFLICT),
ALREADY_CANCELED(HttpStatus.CONFLICT),
LOCK_TIMEOUT(HttpStatus.SERVICE_UNAVAILABLE);
private final HttpStatus status;
RegistrationErrorCode(HttpStatus status) {
this.status = status;
}
public HttpStatus getStatus() {
return status;
}
}
@@ -0,0 +1,6 @@
package com.study.course_registration.exception;
import java.time.Instant;
public record ErrorResponse(String code, String message, Instant timestamp) {
}
@@ -0,0 +1,54 @@
package com.study.course_registration.exception;
import java.time.Clock;
import org.springframework.dao.PessimisticLockingFailureException;
import org.springframework.http.ResponseEntity;
import org.springframework.http.converter.HttpMessageNotReadableException;
import org.springframework.web.bind.MethodArgumentNotValidException;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;
import com.study.course_registration.enums.RegistrationErrorCode;
import jakarta.persistence.LockTimeoutException;
import jakarta.persistence.PessimisticLockException;
import jakarta.validation.ConstraintViolationException;
@RestControllerAdvice
public class GlobalExceptionHandler {
private final Clock clock;
public GlobalExceptionHandler(Clock clock) {
this.clock = clock;
}
@ExceptionHandler(RegistrationException.class)
ResponseEntity<ErrorResponse> handleRegistrationException(RegistrationException exception) {
return response(exception.getErrorCode(), exception.getMessage());
}
@ExceptionHandler(MethodArgumentNotValidException.class)
ResponseEntity<ErrorResponse> handleValidation(MethodArgumentNotValidException exception) {
String message = exception.getBindingResult().getFieldErrors().stream()
.findFirst()
.map(error -> error.getField() + ": " + error.getDefaultMessage())
.orElse("요청 값이 올바르지 않습니다.");
return response(RegistrationErrorCode.VALIDATION_ERROR, message);
}
@ExceptionHandler({ConstraintViolationException.class, HttpMessageNotReadableException.class})
ResponseEntity<ErrorResponse> handleBadRequest(Exception exception) {
return response(RegistrationErrorCode.VALIDATION_ERROR, "요청 값이 올바르지 않습니다.");
}
@ExceptionHandler({LockTimeoutException.class, PessimisticLockException.class, PessimisticLockingFailureException.class})
ResponseEntity<ErrorResponse> handleLockTimeout(Exception exception) {
return response(RegistrationErrorCode.LOCK_TIMEOUT, "다른 요청이 처리 중입니다. 잠시 후 다시 시도해 주세요.");
}
private ResponseEntity<ErrorResponse> response(RegistrationErrorCode code, String message) {
return ResponseEntity.status(code.getStatus())
.body(new ErrorResponse(code.name(), message, clock.instant()));
}
}
@@ -0,0 +1,16 @@
package com.study.course_registration.exception;
import com.study.course_registration.enums.RegistrationErrorCode;
public class RegistrationException extends RuntimeException {
private final RegistrationErrorCode errorCode;
public RegistrationException(RegistrationErrorCode errorCode, String message) {
super(message);
this.errorCode = errorCode;
}
public RegistrationErrorCode getErrorCode() {
return errorCode;
}
}
@@ -1,9 +1,34 @@
package com.study.course_registration.repository;
import java.util.Optional;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.data.jpa.repository.EntityGraph;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Lock;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.jpa.repository.QueryHints;
import org.springframework.data.repository.query.Param;
import com.study.course_registration.entity.Lesson;
import jakarta.persistence.LockModeType;
import jakarta.persistence.QueryHint;
public interface LessonRepository extends JpaRepository<Lesson, String> {
@Lock(LockModeType.PESSIMISTIC_WRITE)
@QueryHints(@QueryHint(name = "jakarta.persistence.lock.timeout", value = "3000"))
@Query("select l from Lesson l where l.id = :id")
Optional<Lesson> findByIdForUpdate(@Param("id") String id);
@EntityGraph(attributePaths = {"subject", "professor", "semester"})
Page<Lesson> findBySemester_Id(String semesterId, Pageable pageable);
@EntityGraph(attributePaths = {"subject", "professor", "semester"})
Page<Lesson> findBySemester_IdAndSubject_Id(String semesterId, String subjectId, Pageable pageable);
@EntityGraph(attributePaths = {"subject", "professor", "semester"})
@Query("select l from Lesson l where l.id = :id")
Optional<Lesson> findDetailedById(@Param("id") String id);
}
@@ -0,0 +1,14 @@
package com.study.course_registration.repository;
import java.util.Collection;
import java.util.List;
import org.springframework.data.jpa.repository.JpaRepository;
import com.study.course_registration.entity.LessonSchedule;
public interface LessonScheduleRepository extends JpaRepository<LessonSchedule, String> {
List<LessonSchedule> findByLesson_IdOrderByDayOfWeekAscStartTimeAsc(String lessonId);
List<LessonSchedule> findByLesson_IdInOrderByDayOfWeekAscStartTimeAsc(Collection<String> lessonIds);
}
@@ -0,0 +1,13 @@
package com.study.course_registration.repository;
import java.time.LocalDate;
import java.util.Optional;
import org.springframework.data.jpa.repository.JpaRepository;
import com.study.course_registration.entity.Semester;
public interface SemesterRepository extends JpaRepository<Semester, String> {
Optional<Semester> findFirstByStartDateLessThanEqualAndEndDateGreaterThanEqualOrderByStartDateDesc(
LocalDate startDate, LocalDate endDate);
}
@@ -1,10 +1,54 @@
package com.study.course_registration.repository;
import java.util.Collection;
import java.util.List;
import java.util.Optional;
import org.springframework.data.jpa.repository.EntityGraph;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import com.study.course_registration.entity.UserLesson;
import com.study.course_registration.entity.UserLessonId;
public interface UserLessonRepository extends JpaRepository<UserLesson, UserLessonId> {
public interface UserLessonRepository extends JpaRepository<UserLesson, String> {
@EntityGraph(attributePaths = {"lesson", "lesson.subject", "lesson.professor", "lesson.semester"})
@Query("""
select ul from UserLesson ul
where ul.user.id = :userId
and ul.lesson.semester.id = :semesterId
and ul.canceledAt is null
order by ul.createdAt asc
""")
List<UserLesson> findActiveByUserIdAndSemesterId(
@Param("userId") String userId,
@Param("semesterId") String semesterId);
long countByLesson_IdAndCanceledAtIsNull(String lessonId);
@Query("""
select ul.user.id as userId, ul.lesson.id as lessonId
from UserLesson ul
where ul.id = :registrationId
""")
Optional<RegistrationTarget> findTargetById(@Param("registrationId") String registrationId);
@Query("""
select ul.lesson.id as lessonId, count(ul) as enrolledCount
from UserLesson ul
where ul.lesson.id in :lessonIds
and ul.canceledAt is null
group by ul.lesson.id
""")
List<LessonEnrollmentCount> countActiveByLessonIds(@Param("lessonIds") Collection<String> lessonIds);
interface RegistrationTarget {
String getUserId();
String getLessonId();
}
interface LessonEnrollmentCount {
String getLessonId();
long getEnrolledCount();
}
}
@@ -1,9 +1,21 @@
package com.study.course_registration.repository;
import java.util.Optional;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Lock;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.jpa.repository.QueryHints;
import org.springframework.data.repository.query.Param;
import com.study.course_registration.entity.User;
import jakarta.persistence.LockModeType;
import jakarta.persistence.QueryHint;
public interface UserRepository extends JpaRepository<User, String> {
@Lock(LockModeType.PESSIMISTIC_WRITE)
@QueryHints(@QueryHint(name = "jakarta.persistence.lock.timeout", value = "3000"))
@Query("select u from User u where u.id = :id")
Optional<User> findByIdForUpdate(@Param("id") String id);
}
@@ -1,91 +1,121 @@
package com.study.course_registration.seeder;
import org.springframework.context.annotation.Profile;
import java.time.Clock;
import java.time.DayOfWeek;
import java.time.Instant;
import java.time.LocalDate;
import java.time.LocalTime;
import java.util.List;
import org.springframework.boot.CommandLineRunner;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.stereotype.Component;
import org.springframework.transaction.annotation.Transactional;
import com.study.course_registration.entity.Lesson;
import com.study.course_registration.entity.LessonSchedule;
import com.study.course_registration.entity.Professor;
import com.study.course_registration.entity.Semester;
import com.study.course_registration.entity.Subject;
import com.study.course_registration.entity.User;
import com.study.course_registration.entity.UserLesson;
import com.study.course_registration.enums.UserRole;
import com.study.course_registration.repository.LessonRepository;
import com.study.course_registration.repository.UserRepository;
import com.study.course_registration.repository.SubjectRepository;
import com.study.course_registration.repository.LessonScheduleRepository;
import com.study.course_registration.repository.ProfessorRepository;
import com.study.course_registration.repository.UserLessonRepository;
import java.time.Instant;
import java.time.ZoneId;
import java.time.ZonedDateTime;
import java.time.temporal.ChronoUnit;
import java.util.List;
import org.springframework.boot.CommandLineRunner;
import com.study.course_registration.repository.SemesterRepository;
import com.study.course_registration.repository.SubjectRepository;
import com.study.course_registration.repository.UserRepository;
@Component
@Profile ("local")
@ConditionalOnProperty(name = "app.seed.enabled", havingValue = "true")
public class DataSeeder implements CommandLineRunner {
public static final String SEMESTER_ID = "00000000-0000-0000-0000-000000000001";
public static final String STUDENT_GRADE_1_ID = "30000000-0000-0000-0000-000000000001";
public static final String STUDENT_GRADE_2_ID = "30000000-0000-0000-0000-000000000002";
public static final String POSTGRADUATE_ID = "30000000-0000-0000-0000-000000000003";
public static final String DATA_STRUCTURES_01_ID = "40000000-0000-0000-0000-000000000001";
public static final String DATA_STRUCTURES_02_ID = "40000000-0000-0000-0000-000000000002";
public static final String OVERLAP_OS_ID = "40000000-0000-0000-0000-000000000003";
public static final String CAPACITY_ONE_ID = "40000000-0000-0000-0000-000000000004";
public static final String MIN_GRADE_ID = "40000000-0000-0000-0000-000000000005";
public static final String POSTGRADUATE_ONLY_ID = "40000000-0000-0000-0000-000000000006";
public static final String BOUNDARY_LESSON_ID = "40000000-0000-0000-0000-000000000007";
private static final ZoneId KST = ZoneId.of("Asia/Seoul");
private final UserRepository userRepository;
private final LessonRepository lessonRepository;
private final SubjectRepository subjectRepository;
private final Clock clock;
private final SemesterRepository semesterRepository;
private final ProfessorRepository professorRepository;
private final UserLessonRepository userLessonRepository;
private final SubjectRepository subjectRepository;
private final LessonRepository lessonRepository;
private final LessonScheduleRepository lessonScheduleRepository;
private final UserRepository userRepository;
public DataSeeder(UserRepository userRepository, LessonRepository lessonRepository, SubjectRepository subjectRepository, ProfessorRepository professorRepository, UserLessonRepository userLessonRepository) {
this.userRepository = userRepository;
this.lessonRepository = lessonRepository;
this.subjectRepository = subjectRepository;
public DataSeeder(Clock clock,
SemesterRepository semesterRepository,
ProfessorRepository professorRepository,
SubjectRepository subjectRepository,
LessonRepository lessonRepository,
LessonScheduleRepository lessonScheduleRepository,
UserRepository userRepository) {
this.clock = clock;
this.semesterRepository = semesterRepository;
this.professorRepository = professorRepository;
this.userLessonRepository = userLessonRepository;
this.subjectRepository = subjectRepository;
this.lessonRepository = lessonRepository;
this.lessonScheduleRepository = lessonScheduleRepository;
this.userRepository = userRepository;
}
@Override
@Transactional
public void run(String... args) throws Exception {
if(userRepository.count() > 0) {
public void run(String... args) {
if (semesterRepository.existsById(SEMESTER_ID)) {
return;
}
Instant now = clock.instant();
LocalDate today = LocalDate.now(clock);
Semester semester = semesterRepository.save(new Semester(
SEMESTER_ID, "2026-1", today.minusDays(30), today.plusDays(120),
now.minusSeconds(86_400), now.plusSeconds(7 * 86_400), 18));
List<Professor> professors = professorRepository.saveAll(List.of(
new Professor("김영한"),
new Professor("최태영"),
new Professor("조재한")
));
new Professor("10000000-0000-0000-0000-000000000001", "김영한"),
new Professor("10000000-0000-0000-0000-000000000002", "최태영"),
new Professor("10000000-0000-0000-0000-000000000003", "조재한")));
List<Subject> subjects = subjectRepository.saveAll(List.of(
new Subject("자바 프로그래밍", "CS201", "자바 프로그래밍 기초"),
new Subject("자료구조", "CS202", "자료구조 및 알고리즘"),
new Subject("데이터베이스", "CS203", "데이터베이스 시스템")
));
new Subject("20000000-0000-0000-0000-000000000001", "자바 프로그래밍", "CS201", "자바 프로그래밍 기초", 3),
new Subject("20000000-0000-0000-0000-000000000002", "자료구조", "CS202", "자료구조 및 알고리즘", 3),
new Subject("20000000-0000-0000-0000-000000000003", "데이터베이스", "CS203", "데이터베이스 시스템", 3),
new Subject("20000000-0000-0000-0000-000000000004", "컴퓨터 네트워크", "CS204", "네트워크 기초", 3),
new Subject("20000000-0000-0000-0000-000000000005", "운영체제", "CS301", "운영체제 핵심", 3),
new Subject("20000000-0000-0000-0000-000000000006", "대학원 세미나", "CS401", "대학원 전용 세미나", 3)));
Instant base = Instant.now().truncatedTo(ChronoUnit.HOURS);
List<Lesson> lessons = lessonRepository.saveAll(List.of(
new Lesson("자바 프로그래밍", subjects.get(0), professors.get(0), kst(3, 2, 9), kst(3, 2, 11)),
new Lesson("자료구조", subjects.get(1), professors.get(1), kst(3, 2, 13), kst(3, 2, 17)),
new Lesson("데이터베이스", subjects.get(2), professors.get(2), kst(3, 3, 10), kst(3, 3, 14))
));
Lesson data01 = new Lesson(DATA_STRUCTURES_01_ID, "자료구조 01분반", subjects.get(1), professors.get(0), semester, 30, null, null);
Lesson data02 = new Lesson(DATA_STRUCTURES_02_ID, "자료구조 02분반", subjects.get(1), professors.get(1), semester, 30, null, null);
Lesson os = new Lesson(OVERLAP_OS_ID, "운영체제", subjects.get(4), professors.get(1), semester, 30, null, null);
Lesson capacityOne = new Lesson(CAPACITY_ONE_ID, "데이터베이스 소수정예", subjects.get(2), professors.get(2), semester, 1, null, null);
Lesson minGrade = new Lesson(MIN_GRADE_ID, "고급 네트워크", subjects.get(3), professors.get(2), semester, 30, 3L, null);
Lesson postgraduate = new Lesson(POSTGRADUATE_ONLY_ID, "대학원 세미나", subjects.get(5), professors.get(0), semester, 20, null, UserRole.POSTGRADUATE);
Lesson boundary = new Lesson(BOUNDARY_LESSON_ID, "자바 프로그래밍", subjects.get(0), professors.get(0), semester, 30, null, null);
lessonRepository.saveAll(List.of(data01, data02, os, capacityOne, minGrade, postgraduate, boundary));
List<User> users = userRepository.saveAll(List.of(
new User("학생1", 1L, UserRole.STUDENT),
new User("학생2", 2L, UserRole.STUDENT),
new User("대학원생", 3L, UserRole.POSTGRADUATE)
));
lessonScheduleRepository.saveAll(List.of(
schedule("50000000-0000-0000-0000-000000000001", data01, DayOfWeek.MONDAY, 9, 11),
schedule("50000000-0000-0000-0000-000000000002", data02, DayOfWeek.TUESDAY, 9, 11),
schedule("50000000-0000-0000-0000-000000000003", os, DayOfWeek.MONDAY, 10, 12),
schedule("50000000-0000-0000-0000-000000000004", capacityOne, DayOfWeek.WEDNESDAY, 9, 11),
schedule("50000000-0000-0000-0000-000000000005", minGrade, DayOfWeek.THURSDAY, 9, 11),
schedule("50000000-0000-0000-0000-000000000006", postgraduate, DayOfWeek.FRIDAY, 9, 11),
schedule("50000000-0000-0000-0000-000000000007", boundary, DayOfWeek.MONDAY, 11, 13)));
List<UserLesson> userLessons = userLessonRepository.saveAll(List.of(
new UserLesson(users.get(0), lessons.get(0)),
new UserLesson(users.get(0), lessons.get(1)),
new UserLesson(users.get(1), lessons.get(1)),
new UserLesson(users.get(2), lessons.get(2))
));
userRepository.saveAll(List.of(
new User(STUDENT_GRADE_1_ID, "학생1", 1L, UserRole.STUDENT),
new User(STUDENT_GRADE_2_ID, "학생2", 2L, UserRole.STUDENT),
new User(POSTGRADUATE_ID, "대학원생", 3L, UserRole.POSTGRADUATE)));
}
private Instant kst(int month, int day, int hour) {
return ZonedDateTime.of(2026, month, day, hour, 0, 0, 0, KST).toInstant();
private static LessonSchedule schedule(String id, Lesson lesson, DayOfWeek day, int startHour, int endHour) {
return new LessonSchedule(id, lesson, day, LocalTime.of(startHour, 0), LocalTime.of(endHour, 0));
}
}
@@ -1,36 +1,183 @@
package com.study.course_registration.service;
import org.springframework.stereotype.Service;
import java.time.Clock;
import java.time.LocalDate;
import java.util.LinkedHashMap;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.stream.Collectors;
import com.study.course_registration.dto.RequestDto;
import com.study.course_registration.dto.ResponseDto;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import com.study.course_registration.dto.ScheduleResponse;
import com.study.course_registration.dto.registration.CourseRegistrationRequest;
import com.study.course_registration.dto.registration.CourseRegistrationResponse;
import com.study.course_registration.dto.registration.RegistrationItemResponse;
import com.study.course_registration.dto.registration.RegistrationListResponse;
import com.study.course_registration.entity.Lesson;
import com.study.course_registration.entity.LessonSchedule;
import com.study.course_registration.entity.Semester;
import com.study.course_registration.entity.User;
import com.study.course_registration.entity.UserLesson;
import com.study.course_registration.enums.RegistrationErrorCode;
import com.study.course_registration.exception.RegistrationException;
import com.study.course_registration.repository.LessonRepository;
import com.study.course_registration.repository.LessonScheduleRepository;
import com.study.course_registration.repository.SemesterRepository;
import com.study.course_registration.repository.UserLessonRepository;
import com.study.course_registration.repository.UserRepository;
import com.study.course_registration.repository.SubjectRepository;
import com.study.course_registration.repository.ProfessorRepository;
import com.study.course_registration.service.policy.RegistrationValidator;
@Service
public class CourseRegistrationService {
private final Clock clock;
private final UserRepository userRepository;
private final LessonRepository lessonRepository;
private final SubjectRepository subjectRepository;
private final ProfessorRepository professorRepository;
private final SemesterRepository semesterRepository;
private final LessonScheduleRepository lessonScheduleRepository;
private final UserLessonRepository userLessonRepository;
private final RegistrationValidator validator;
public CourseRegistrationService(UserRepository userRepository, LessonRepository lessonRepository, SubjectRepository subjectRepository, ProfessorRepository professorRepository, UserLessonRepository userLessonRepository) {
public CourseRegistrationService(Clock clock,
UserRepository userRepository,
LessonRepository lessonRepository,
SemesterRepository semesterRepository,
LessonScheduleRepository lessonScheduleRepository,
UserLessonRepository userLessonRepository,
RegistrationValidator validator) {
this.clock = clock;
this.userRepository = userRepository;
this.lessonRepository = lessonRepository;
this.subjectRepository = subjectRepository;
this.professorRepository = professorRepository;
this.semesterRepository = semesterRepository;
this.lessonScheduleRepository = lessonScheduleRepository;
this.userLessonRepository = userLessonRepository;
this.validator = validator;
}
public ResponseDto registerCourse(RequestDto requestDto) {
return new ResponseDto();
@Transactional
public CourseRegistrationResponse register(String userId, CourseRegistrationRequest request) {
User user = userRepository.findById(userId)
.orElseThrow(() -> error(RegistrationErrorCode.USER_NOT_FOUND, "사용자를 찾을 수 없습니다."));
Lesson lesson = lessonRepository.findById(request.lessonId())
.orElseThrow(() -> error(RegistrationErrorCode.LESSON_NOT_FOUND, "강의를 찾을 수 없습니다."));
var now = clock.instant();
validator.validatePreconditions(user, lesson, now);
// Lock order is a system invariant: User -> Lesson.
User lockedUser = userRepository.findByIdForUpdate(userId)
.orElseThrow(() -> error(RegistrationErrorCode.USER_NOT_FOUND, "사용자를 찾을 수 없습니다."));
Lesson lockedLesson = lessonRepository.findByIdForUpdate(request.lessonId())
.orElseThrow(() -> error(RegistrationErrorCode.LESSON_NOT_FOUND, "강의를 찾을 수 없습니다."));
List<UserLesson> activeRegistrations = userLessonRepository.findActiveByUserIdAndSemesterId(
lockedUser.getId(), lockedLesson.getSemester().getId());
Map<String, List<LessonSchedule>> schedules = loadSchedules(activeRegistrations, lockedLesson);
long enrolledCount = userLessonRepository.countByLesson_IdAndCanceledAtIsNull(lockedLesson.getId());
validator.validateLocked(lockedLesson, activeRegistrations, schedules, enrolledCount);
int totalCredits = validator.totalCreditsAfter(lockedLesson, activeRegistrations);
UserLesson registration = userLessonRepository.saveAndFlush(new UserLesson(lockedUser, lockedLesson));
return new CourseRegistrationResponse(
registration.getId(),
lockedUser.getId(),
lockedLesson.getId(),
lockedLesson.getName(),
lockedLesson.getSubject().getCode(),
lockedLesson.getSubject().getCredit(),
registration.getCreatedAt(),
totalCredits);
}
@Transactional(readOnly = true)
public RegistrationListResponse getRegistrations(String userId, String semesterId) {
if (!userRepository.existsById(userId)) {
throw error(RegistrationErrorCode.USER_NOT_FOUND, "사용자를 찾을 수 없습니다.");
}
Semester semester = resolveSemester(semesterId);
List<UserLesson> activeRegistrations = userLessonRepository.findActiveByUserIdAndSemesterId(userId, semester.getId());
Set<String> lessonIds = activeRegistrations.stream()
.map(UserLesson::getLesson)
.map(Lesson::getId)
.collect(Collectors.toCollection(LinkedHashSet::new));
Map<String, List<LessonSchedule>> schedulesByLesson = groupSchedules(
lessonIds.isEmpty() ? List.of() : lessonScheduleRepository.findByLesson_IdInOrderByDayOfWeekAscStartTimeAsc(lessonIds));
List<RegistrationItemResponse> items = activeRegistrations.stream()
.map(registration -> {
Lesson lesson = registration.getLesson();
List<ScheduleResponse> schedules = schedulesByLesson.getOrDefault(lesson.getId(), List.of()).stream()
.map(ScheduleResponse::from)
.toList();
return new RegistrationItemResponse(
registration.getId(), lesson.getId(), lesson.getName(),
lesson.getSubject().getCode(), lesson.getSubject().getName(), lesson.getSubject().getCredit(),
lesson.getProfessor().getName(), schedules, registration.getCreatedAt());
})
.toList();
return new RegistrationListResponse(
userId, semester.getId(), semester.getName(), validator.currentCredits(activeRegistrations),
semester.getMaxCredits(), items);
}
@Transactional
public void cancel(String userId, String registrationId) {
UserLessonRepository.RegistrationTarget target = userLessonRepository.findTargetById(registrationId)
.orElseThrow(() -> error(RegistrationErrorCode.REGISTRATION_NOT_FOUND, "수강신청 건을 찾을 수 없습니다."));
if (!target.getUserId().equals(userId)) {
throw error(RegistrationErrorCode.REGISTRATION_FORBIDDEN, "다른 사용자의 수강신청은 취소할 수 없습니다.");
}
// Mutation paths use the same fixed lock order as registration.
userRepository.findByIdForUpdate(userId)
.orElseThrow(() -> error(RegistrationErrorCode.USER_NOT_FOUND, "사용자를 찾을 수 없습니다."));
Lesson lesson = lessonRepository.findByIdForUpdate(target.getLessonId())
.orElseThrow(() -> error(RegistrationErrorCode.LESSON_NOT_FOUND, "강의를 찾을 수 없습니다."));
UserLesson registration = userLessonRepository.findById(registrationId)
.orElseThrow(() -> error(RegistrationErrorCode.REGISTRATION_NOT_FOUND, "수강신청 건을 찾을 수 없습니다."));
if (registration.isCanceled()) {
throw error(RegistrationErrorCode.ALREADY_CANCELED, "이미 취소된 수강신청입니다.");
}
var now = clock.instant();
validator.validateRegistrationPeriod(lesson, now);
registration.cancel(now);
}
private Semester resolveSemester(String semesterId) {
if (semesterId != null && !semesterId.isBlank()) {
return semesterRepository.findById(semesterId)
.orElseThrow(() -> error(RegistrationErrorCode.SEMESTER_NOT_FOUND, "학기를 찾을 수 없습니다."));
}
LocalDate today = LocalDate.now(clock);
return semesterRepository
.findFirstByStartDateLessThanEqualAndEndDateGreaterThanEqualOrderByStartDateDesc(today, today)
.orElseThrow(() -> error(RegistrationErrorCode.SEMESTER_NOT_FOUND, "현재 진행 중인 학기를 찾을 수 없습니다."));
}
private Map<String, List<LessonSchedule>> loadSchedules(List<UserLesson> registrations, Lesson candidate) {
Set<String> lessonIds = registrations.stream()
.map(UserLesson::getLesson)
.map(Lesson::getId)
.collect(Collectors.toCollection(LinkedHashSet::new));
lessonIds.add(candidate.getId());
return groupSchedules(lessonScheduleRepository.findByLesson_IdInOrderByDayOfWeekAscStartTimeAsc(lessonIds));
}
private static Map<String, List<LessonSchedule>> groupSchedules(List<LessonSchedule> schedules) {
return schedules.stream().collect(Collectors.groupingBy(
schedule -> schedule.getLesson().getId(),
LinkedHashMap::new,
Collectors.toList()));
}
private static RegistrationException error(RegistrationErrorCode code, String message) {
return new RegistrationException(code, message);
}
}
// 저장 전 log
// 검증 로직
//
@@ -0,0 +1,126 @@
package com.study.course_registration.service;
import java.time.Clock;
import java.time.LocalDate;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.function.Function;
import java.util.stream.Collectors;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.PageRequest;
import org.springframework.data.domain.Pageable;
import org.springframework.data.domain.Sort;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import com.study.course_registration.dto.ScheduleResponse;
import com.study.course_registration.dto.lesson.LessonDetailResponse;
import com.study.course_registration.dto.lesson.LessonItemResponse;
import com.study.course_registration.dto.lesson.LessonListResponse;
import com.study.course_registration.entity.Lesson;
import com.study.course_registration.entity.LessonSchedule;
import com.study.course_registration.entity.Semester;
import com.study.course_registration.enums.RegistrationErrorCode;
import com.study.course_registration.exception.RegistrationException;
import com.study.course_registration.repository.LessonRepository;
import com.study.course_registration.repository.LessonScheduleRepository;
import com.study.course_registration.repository.SemesterRepository;
import com.study.course_registration.repository.UserLessonRepository;
@Service
@Transactional(readOnly = true)
public class LessonQueryService {
private final Clock clock;
private final LessonRepository lessonRepository;
private final LessonScheduleRepository lessonScheduleRepository;
private final UserLessonRepository userLessonRepository;
private final SemesterRepository semesterRepository;
public LessonQueryService(Clock clock,
LessonRepository lessonRepository,
LessonScheduleRepository lessonScheduleRepository,
UserLessonRepository userLessonRepository,
SemesterRepository semesterRepository) {
this.clock = clock;
this.lessonRepository = lessonRepository;
this.lessonScheduleRepository = lessonScheduleRepository;
this.userLessonRepository = userLessonRepository;
this.semesterRepository = semesterRepository;
}
public LessonListResponse getLessons(String semesterId, String subjectId, int page, int size) {
Semester semester = resolveSemester(semesterId);
Pageable pageable = PageRequest.of(page, size, Sort.by(Sort.Order.asc("name"), Sort.Order.asc("id")));
Page<Lesson> lessonPage = subjectId == null || subjectId.isBlank()
? lessonRepository.findBySemester_Id(semester.getId(), pageable)
: lessonRepository.findBySemester_IdAndSubject_Id(semester.getId(), subjectId, pageable);
List<String> lessonIds = lessonPage.getContent().stream().map(Lesson::getId).toList();
Map<String, List<LessonSchedule>> schedules = loadSchedules(lessonIds);
Map<String, Long> counts = loadCounts(lessonIds);
List<LessonItemResponse> items = lessonPage.getContent().stream()
.map(lesson -> toItem(lesson, schedules.getOrDefault(lesson.getId(), List.of()), counts.getOrDefault(lesson.getId(), 0L)))
.toList();
return new LessonListResponse(page, size, lessonPage.getTotalElements(), items);
}
public LessonDetailResponse getLesson(String lessonId) {
Lesson lesson = lessonRepository.findDetailedById(lessonId)
.orElseThrow(() -> error(RegistrationErrorCode.LESSON_NOT_FOUND, "강의를 찾을 수 없습니다."));
List<ScheduleResponse> schedules = lessonScheduleRepository.findByLesson_IdOrderByDayOfWeekAscStartTimeAsc(lessonId)
.stream().map(ScheduleResponse::from).toList();
long enrolledCount = userLessonRepository.countByLesson_IdAndCanceledAtIsNull(lessonId);
return new LessonDetailResponse(
lesson.getId(), lesson.getName(), lesson.getSubject().getCode(), lesson.getSubject().getName(),
lesson.getSubject().getDescription(), lesson.getSubject().getCredit(), lesson.getProfessor().getName(),
lesson.getSemester().getName(), lesson.getCapacity(), enrolledCount, lesson.getMinGrade(), lesson.getAllowedRole(),
lesson.getSemester().getRegistrationStartAt(), lesson.getSemester().getRegistrationEndAt(), schedules);
}
private LessonItemResponse toItem(Lesson lesson, List<LessonSchedule> schedules, long enrolledCount) {
return new LessonItemResponse(
lesson.getId(), lesson.getName(), lesson.getSubject().getCode(), lesson.getSubject().getName(),
lesson.getSubject().getCredit(), lesson.getProfessor().getName(), lesson.getCapacity(), enrolledCount,
lesson.getMinGrade(), lesson.getAllowedRole(), schedules.stream().map(ScheduleResponse::from).toList());
}
private Map<String, List<LessonSchedule>> loadSchedules(List<String> lessonIds) {
if (lessonIds.isEmpty()) {
return Map.of();
}
return lessonScheduleRepository.findByLesson_IdInOrderByDayOfWeekAscStartTimeAsc(lessonIds).stream()
.collect(Collectors.groupingBy(
schedule -> schedule.getLesson().getId(), LinkedHashMap::new, Collectors.toList()));
}
private Map<String, Long> loadCounts(List<String> lessonIds) {
if (lessonIds.isEmpty()) {
return Map.of();
}
return userLessonRepository.countActiveByLessonIds(lessonIds).stream()
.collect(Collectors.toMap(
UserLessonRepository.LessonEnrollmentCount::getLessonId,
UserLessonRepository.LessonEnrollmentCount::getEnrolledCount,
(left, right) -> left,
LinkedHashMap::new));
}
private Semester resolveSemester(String semesterId) {
if (semesterId != null && !semesterId.isBlank()) {
return semesterRepository.findById(semesterId)
.orElseThrow(() -> error(RegistrationErrorCode.SEMESTER_NOT_FOUND, "학기를 찾을 수 없습니다."));
}
LocalDate today = LocalDate.now(clock);
return semesterRepository.findFirstByStartDateLessThanEqualAndEndDateGreaterThanEqualOrderByStartDateDesc(today, today)
.orElseThrow(() -> error(RegistrationErrorCode.SEMESTER_NOT_FOUND, "현재 진행 중인 학기를 찾을 수 없습니다."));
}
private static RegistrationException error(RegistrationErrorCode code, String message) {
return new RegistrationException(code, message);
}
}
@@ -0,0 +1,100 @@
package com.study.course_registration.service.policy;
import java.time.Instant;
import java.util.List;
import java.util.Map;
import org.springframework.stereotype.Component;
import com.study.course_registration.entity.Lesson;
import com.study.course_registration.entity.LessonSchedule;
import com.study.course_registration.entity.User;
import com.study.course_registration.entity.UserLesson;
import com.study.course_registration.enums.RegistrationErrorCode;
import com.study.course_registration.exception.RegistrationException;
@Component
public class RegistrationValidator {
private final ScheduleConflictChecker scheduleConflictChecker;
public RegistrationValidator(ScheduleConflictChecker scheduleConflictChecker) {
this.scheduleConflictChecker = scheduleConflictChecker;
}
public void validatePreconditions(User user, Lesson lesson, Instant now) {
validateRegistrationPeriod(lesson, now);
if (lesson.getMinGrade() != null && user.getGrade() < lesson.getMinGrade()) {
throw error(RegistrationErrorCode.NOT_ELIGIBLE_GRADE,
"수강 가능한 학년이 아닙니다. (현재 " + user.getGrade() + "학년, 최소 " + lesson.getMinGrade() + "학년)");
}
if (lesson.getAllowedRole() != null && user.getRole() != lesson.getAllowedRole()) {
throw error(RegistrationErrorCode.NOT_ELIGIBLE_ROLE,
"수강 가능한 역할이 아닙니다. (필요 역할: " + lesson.getAllowedRole() + ")");
}
}
public void validateRegistrationPeriod(Lesson lesson, Instant now) {
if (now.isBefore(lesson.getSemester().getRegistrationStartAt())
|| now.isAfter(lesson.getSemester().getRegistrationEndAt())) {
throw error(RegistrationErrorCode.REGISTRATION_PERIOD_CLOSED, "수강신청 기간이 아닙니다.");
}
}
public void validateLocked(Lesson lesson,
List<UserLesson> activeRegistrations,
Map<String, List<LessonSchedule>> schedulesByLessonId,
long enrolledCount) {
if (activeRegistrations.stream().anyMatch(registration -> registration.getLesson().getId().equals(lesson.getId()))) {
throw error(RegistrationErrorCode.ALREADY_REGISTERED, "이미 신청한 강의입니다.");
}
if (activeRegistrations.stream().anyMatch(registration ->
registration.getLesson().getSubject().getId().equals(lesson.getSubject().getId()))) {
throw error(RegistrationErrorCode.DUPLICATE_SUBJECT, "같은 과목의 다른 분반을 이미 신청했습니다.");
}
List<LessonSchedule> candidateSchedules = schedulesByLessonId.getOrDefault(lesson.getId(), List.of());
for (UserLesson registration : activeRegistrations) {
List<LessonSchedule> existingSchedules = schedulesByLessonId.getOrDefault(registration.getLesson().getId(), List.of());
for (LessonSchedule candidate : candidateSchedules) {
for (LessonSchedule existing : existingSchedules) {
if (scheduleConflictChecker.overlaps(candidate, existing)) {
throw error(RegistrationErrorCode.SCHEDULE_CONFLICT,
"기존 수업과 시간이 겹칩니다. (" + candidate.getDayOfWeek() + " "
+ candidate.getStartTime() + "-" + candidate.getEndTime() + ")");
}
}
}
}
int currentCredits = currentCredits(activeRegistrations);
int requestedCredits = lesson.getSubject().getCredit();
int maxCredits = lesson.getSemester().getMaxCredits();
if (currentCredits + requestedCredits > maxCredits) {
throw error(RegistrationErrorCode.CREDIT_LIMIT_EXCEEDED,
"신청 학점이 상한을 넘습니다. (" + currentCredits + " + " + requestedCredits + " > " + maxCredits + ")");
}
if (enrolledCount >= lesson.getCapacity()) {
throw error(RegistrationErrorCode.CAPACITY_EXCEEDED,
"정원이 가득 찼습니다. (" + enrolledCount + "/" + lesson.getCapacity() + ")");
}
}
public int totalCreditsAfter(Lesson lesson, List<UserLesson> activeRegistrations) {
return currentCredits(activeRegistrations) + lesson.getSubject().getCredit();
}
public int currentCredits(List<UserLesson> activeRegistrations) {
return activeRegistrations.stream()
.map(UserLesson::getLesson)
.map(Lesson::getSubject)
.mapToInt(subject -> subject.getCredit())
.sum();
}
private static RegistrationException error(RegistrationErrorCode code, String message) {
return new RegistrationException(code, message);
}
}
@@ -0,0 +1,14 @@
package com.study.course_registration.service.policy;
import org.springframework.stereotype.Component;
import com.study.course_registration.entity.LessonSchedule;
@Component
public class ScheduleConflictChecker {
public boolean overlaps(LessonSchedule left, LessonSchedule right) {
return left.getDayOfWeek() == right.getDayOfWeek()
&& left.getStartTime().isBefore(right.getEndTime())
&& right.getStartTime().isBefore(left.getEndTime());
}
}
+16
View File
@@ -0,0 +1,16 @@
spring:
datasource:
url: jdbc:postgresql://${DB_HOST:localhost}:${DB_PORT:5432}/${DB_NAME:course_registration}
username: ${DB_USERNAME:course}
password: ${DB_PASSWORD:course}
jpa:
hibernate:
ddl-auto: update
show-sql: ${JPA_SHOW_SQL:false}
h2:
console:
enabled: false
app:
seed:
enabled: ${APP_SEED_ENABLED:false}
+22 -18
View File
@@ -1,21 +1,25 @@
spring:
datasource:
url: jdbc:h2:mem:course_db
driver-class-name: org.h2.Driver
username: sa
password:
jpa:
hibernate:
ddl-auto: create-drop
show-sql: true
properties:
hibernate:
format_sql: true
h2:
console:
enabled: true
path: /h2-console
datasource:
url: jdbc:h2:mem:course_db;DB_CLOSE_DELAY=-1;MODE=PostgreSQL;LOCK_TIMEOUT=3000
driver-class-name: org.h2.Driver
username: sa
password:
jpa:
hibernate:
ddl-auto: create-drop
show-sql: true
properties:
hibernate:
format_sql: true
h2:
console:
enabled: true
path: /h2-console
app:
seed:
enabled: true
logging:
level:
org.hibernate.SQL: debug
level:
org.hibernate.SQL: debug
+22
View File
@@ -0,0 +1,22 @@
spring:
datasource:
url: jdbc:postgresql://${DB_HOST}:${DB_PORT}/${DB_NAME}
username: ${DB_USERNAME}
password: ${DB_PASSWORD}
jpa:
hibernate:
ddl-auto: validate
show-sql: false
h2:
console:
enabled: false
app:
seed:
enabled: false
springdoc:
api-docs:
enabled: ${SWAGGER_ENABLED:false}
swagger-ui:
enabled: ${SWAGGER_ENABLED:false}
+26 -24
View File
@@ -1,28 +1,30 @@
server:
application:
name: ${APP_NAME:course-registration}
profiles:
active: ${APP_PROFILE:local}
address: ${SERVER_ADDRESS:127.0.0.1}
port: ${SERVER_PORT:8080}
spring:
jackson:
time-zone: UTC
h2:
console:
enabled: true
path: /h2-console
datasource:
url: jdbc:h2:mem:course_db
username: sa
password:
driver-class-name: org.h2.Driver
jpa:
hibernate:
ddl-auto: create
show-sql: true
properties:
hibernate:
format_sql: true
application:
name: ${APP_NAME:course-registration}
profiles:
default: local
jackson:
time-zone: UTC
jpa:
open-in-view: false
logging.level:
org.hibernate.SQL: debug
springdoc:
api-docs:
enabled: true
swagger-ui:
enabled: true
path: /swagger-ui.html
management:
endpoints:
web:
exposure:
include: health
endpoint:
health:
probes:
enabled: true
@@ -0,0 +1,202 @@
package com.study.course_registration.service;
import static org.assertj.core.api.Assertions.assertThat;
import java.time.DayOfWeek;
import java.time.Instant;
import java.time.LocalDate;
import java.time.LocalTime;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.TimeUnit;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.test.context.ActiveProfiles;
import com.study.course_registration.dto.registration.CourseRegistrationRequest;
import com.study.course_registration.entity.Lesson;
import com.study.course_registration.entity.LessonSchedule;
import com.study.course_registration.entity.Professor;
import com.study.course_registration.entity.Semester;
import com.study.course_registration.entity.Subject;
import com.study.course_registration.entity.User;
import com.study.course_registration.enums.RegistrationErrorCode;
import com.study.course_registration.enums.UserRole;
import com.study.course_registration.exception.RegistrationException;
import com.study.course_registration.repository.*;
@SpringBootTest
@ActiveProfiles("test")
class CourseRegistrationConcurrencyIntegrationTest {
@Autowired CourseRegistrationService service;
@Autowired UserLessonRepository userLessonRepository;
@Autowired LessonScheduleRepository lessonScheduleRepository;
@Autowired LessonRepository lessonRepository;
@Autowired SubjectRepository subjectRepository;
@Autowired ProfessorRepository professorRepository;
@Autowired SemesterRepository semesterRepository;
@Autowired UserRepository userRepository;
private ExecutorService executor;
private Semester semester;
private Professor professor;
@BeforeEach
void setUp() {
clearData();
Instant now = Instant.now();
semester = semesterRepository.save(new Semester("semester", "2026-1", LocalDate.now().minusDays(1),
LocalDate.now().plusDays(100), now.minusSeconds(3600), now.plusSeconds(3600), 18));
professor = professorRepository.save(new Professor("professor", "교수"));
}
@AfterEach
void tearDown() throws InterruptedException {
if (executor != null) {
executor.shutdownNow();
executor.awaitTermination(5, TimeUnit.SECONDS);
}
}
@Test
void capacityOneWithTenConcurrentUsersHasExactlyOneSuccess() throws Exception {
Lesson lesson = lesson("lesson-cap1", "subject-cap1", 3, 1, DayOfWeek.MONDAY, 9, 11);
List<User> users = users(10, "cap1-user-");
List<RegistrationErrorCode> results = runConcurrent(users.stream()
.<ThrowingAction>map(user -> () -> service.register(user.getId(), new CourseRegistrationRequest(lesson.getId())))
.toList());
assertThat(results.stream().filter(code -> code == null)).hasSize(1);
assertThat(results.stream().filter(code -> code == RegistrationErrorCode.CAPACITY_EXCEEDED)).hasSize(9);
assertThat(userLessonRepository.countByLesson_IdAndCanceledAtIsNull(lesson.getId())).isEqualTo(1);
}
@Test
void sameStudentConcurrentLessonsCannotExceedCreditLimit() throws Exception {
replaceSemesterWithMaxCredits(3);
User user = userRepository.save(new User("credit-user", "학생", 2L, UserRole.STUDENT));
Lesson lessonA = lesson("credit-a", "credit-subject-a", 3, 30, DayOfWeek.MONDAY, 9, 11);
Lesson lessonB = lesson("credit-b", "credit-subject-b", 3, 30, DayOfWeek.TUESDAY, 9, 11);
List<RegistrationErrorCode> results = runConcurrent(List.of(
() -> service.register(user.getId(), new CourseRegistrationRequest(lessonA.getId())),
() -> service.register(user.getId(), new CourseRegistrationRequest(lessonB.getId()))));
assertThat(results.stream().filter(code -> code == null)).hasSize(1);
assertThat(results.stream().filter(code -> code == RegistrationErrorCode.CREDIT_LIMIT_EXCEEDED)).hasSize(1);
}
@Test
void sameStudentConcurrentOverlappingLessonsRejectOneConflict() throws Exception {
User user = userRepository.save(new User("schedule-user", "학생", 2L, UserRole.STUDENT));
Lesson lessonA = lesson("schedule-a", "schedule-subject-a", 3, 30, DayOfWeek.MONDAY, 9, 11);
Lesson lessonB = lesson("schedule-b", "schedule-subject-b", 3, 30, DayOfWeek.MONDAY, 10, 12);
List<RegistrationErrorCode> results = runConcurrent(List.of(
() -> service.register(user.getId(), new CourseRegistrationRequest(lessonA.getId())),
() -> service.register(user.getId(), new CourseRegistrationRequest(lessonB.getId()))));
assertThat(results.stream().filter(code -> code == null)).hasSize(1);
assertThat(results.stream().filter(code -> code == RegistrationErrorCode.SCHEDULE_CONFLICT)).hasSize(1);
}
@Test
void sameStudentSameLessonConcurrentRequestsRejectDuplicate() throws Exception {
User user = userRepository.save(new User("duplicate-user", "학생", 2L, UserRole.STUDENT));
Lesson lesson = lesson("duplicate-lesson", "duplicate-subject", 3, 30, DayOfWeek.WEDNESDAY, 9, 11);
List<RegistrationErrorCode> results = runConcurrent(List.of(
() -> service.register(user.getId(), new CourseRegistrationRequest(lesson.getId())),
() -> service.register(user.getId(), new CourseRegistrationRequest(lesson.getId()))));
assertThat(results.stream().filter(code -> code == null)).hasSize(1);
assertThat(results.stream().filter(code -> code == RegistrationErrorCode.ALREADY_REGISTERED)).hasSize(1);
}
@Test
void capacityThirtyWithFiftyConcurrentUsersStopsAtThirty() throws Exception {
Lesson lesson = lesson("lesson-cap30", "subject-cap30", 3, 30, DayOfWeek.THURSDAY, 9, 11);
List<User> users = users(50, "cap30-user-");
List<RegistrationErrorCode> results = runConcurrent(users.stream()
.<ThrowingAction>map(user -> () -> service.register(user.getId(), new CourseRegistrationRequest(lesson.getId())))
.toList());
assertThat(results.stream().filter(code -> code == null)).hasSize(30);
assertThat(results.stream().filter(code -> code == RegistrationErrorCode.CAPACITY_EXCEEDED)).hasSize(20);
assertThat(userLessonRepository.countByLesson_IdAndCanceledAtIsNull(lesson.getId())).isEqualTo(30);
}
private void replaceSemesterWithMaxCredits(int maxCredits) {
userLessonRepository.deleteAll();
lessonScheduleRepository.deleteAll();
lessonRepository.deleteAll();
semesterRepository.deleteAll();
Instant now = Instant.now();
semester = semesterRepository.save(new Semester("semester", "2026-1", LocalDate.now().minusDays(1),
LocalDate.now().plusDays(100), now.minusSeconds(3600), now.plusSeconds(3600), maxCredits));
}
private void clearData() {
userLessonRepository.deleteAll();
lessonScheduleRepository.deleteAll();
lessonRepository.deleteAll();
subjectRepository.deleteAll();
professorRepository.deleteAll();
semesterRepository.deleteAll();
userRepository.deleteAll();
}
private Lesson lesson(String lessonId, String subjectId, int credit, int capacity,
DayOfWeek day, int startHour, int endHour) {
Subject subject = subjectRepository.save(new Subject(subjectId, subjectId, subjectId, subjectId, credit));
Lesson lesson = lessonRepository.save(new Lesson(lessonId, lessonId, subject, professor, semester, capacity, null, null));
lessonScheduleRepository.save(new LessonSchedule("schedule-" + lessonId, lesson, day,
LocalTime.of(startHour, 0), LocalTime.of(endHour, 0)));
return lesson;
}
private List<User> users(int count, String prefix) {
List<User> users = new ArrayList<>();
for (int index = 0; index < count; index++) {
users.add(new User(prefix + index, "학생" + index, 2L, UserRole.STUDENT));
}
return userRepository.saveAll(users);
}
private List<RegistrationErrorCode> runConcurrent(List<ThrowingAction> actions) throws Exception {
executor = Executors.newFixedThreadPool(actions.size());
CountDownLatch ready = new CountDownLatch(actions.size());
CountDownLatch start = new CountDownLatch(1);
CountDownLatch done = new CountDownLatch(actions.size());
List<RegistrationErrorCode> results = Collections.synchronizedList(new ArrayList<>());
List<Throwable> unexpected = Collections.synchronizedList(new ArrayList<>());
for (ThrowingAction action : actions) {
executor.submit(() -> {
ready.countDown();
try {
start.await();
action.run();
results.add(null);
} catch (RegistrationException exception) {
results.add(exception.getErrorCode());
} catch (Throwable throwable) {
unexpected.add(throwable);
} finally {
done.countDown();
}
});
}
assertThat(ready.await(10, TimeUnit.SECONDS)).isTrue();
start.countDown();
assertThat(done.await(30, TimeUnit.SECONDS)).isTrue();
assertThat(unexpected).isEmpty();
return results;
}
@FunctionalInterface
private interface ThrowingAction {
void run() throws Exception;
}
}
@@ -0,0 +1,171 @@
package com.study.course_registration.service;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import java.time.DayOfWeek;
import java.time.Instant;
import java.time.LocalDate;
import java.time.LocalTime;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.test.context.ActiveProfiles;
import com.study.course_registration.dto.registration.CourseRegistrationRequest;
import com.study.course_registration.dto.registration.CourseRegistrationResponse;
import com.study.course_registration.entity.Lesson;
import com.study.course_registration.entity.LessonSchedule;
import com.study.course_registration.entity.Professor;
import com.study.course_registration.entity.Semester;
import com.study.course_registration.entity.Subject;
import com.study.course_registration.entity.User;
import com.study.course_registration.enums.RegistrationErrorCode;
import com.study.course_registration.enums.UserRole;
import com.study.course_registration.exception.RegistrationException;
import com.study.course_registration.repository.LessonRepository;
import com.study.course_registration.repository.LessonScheduleRepository;
import com.study.course_registration.repository.ProfessorRepository;
import com.study.course_registration.repository.SemesterRepository;
import com.study.course_registration.repository.SubjectRepository;
import com.study.course_registration.repository.UserLessonRepository;
import com.study.course_registration.repository.UserRepository;
@SpringBootTest
@ActiveProfiles("test")
class CourseRegistrationServiceIntegrationTest {
@Autowired CourseRegistrationService service;
@Autowired UserLessonRepository userLessonRepository;
@Autowired LessonScheduleRepository lessonScheduleRepository;
@Autowired LessonRepository lessonRepository;
@Autowired SubjectRepository subjectRepository;
@Autowired ProfessorRepository professorRepository;
@Autowired SemesterRepository semesterRepository;
@Autowired UserRepository userRepository;
private Semester semester;
private User user;
private Subject subjectA;
private Subject subjectB;
private Professor professor;
private Lesson lessonA;
private Lesson lessonB;
@BeforeEach
void setUp() {
userLessonRepository.deleteAll();
lessonScheduleRepository.deleteAll();
lessonRepository.deleteAll();
subjectRepository.deleteAll();
professorRepository.deleteAll();
semesterRepository.deleteAll();
userRepository.deleteAll();
Instant now = Instant.now();
semester = semesterRepository.save(new Semester("semester", "2026-1", LocalDate.now().minusDays(1),
LocalDate.now().plusDays(100), now.minusSeconds(3600), now.plusSeconds(3600), 18));
professor = professorRepository.save(new Professor("professor", "교수"));
subjectA = subjectRepository.save(new Subject("subject-a", "과목A", "A101", "A", 3));
subjectB = subjectRepository.save(new Subject("subject-b", "과목B", "B101", "B", 3));
user = userRepository.save(new User("user", "학생", 2L, UserRole.STUDENT));
lessonA = lessonRepository.save(new Lesson("lesson-a", "강의A", subjectA, professor, semester, 30, null, null));
lessonB = lessonRepository.save(new Lesson("lesson-b", "강의B", subjectB, professor, semester, 30, null, null));
lessonScheduleRepository.save(new LessonSchedule("schedule-a", lessonA, DayOfWeek.MONDAY, LocalTime.of(9, 0), LocalTime.of(11, 0)));
lessonScheduleRepository.save(new LessonSchedule("schedule-b", lessonB, DayOfWeek.TUESDAY, LocalTime.of(9, 0), LocalTime.of(11, 0)));
}
@Test
void registerCreatesActiveRegistrationAndReturnsTotalCredits() {
CourseRegistrationResponse response = service.register(user.getId(), new CourseRegistrationRequest(lessonA.getId()));
assertThat(response.userId()).isEqualTo(user.getId());
assertThat(response.lessonId()).isEqualTo(lessonA.getId());
assertThat(response.totalCredits()).isEqualTo(3);
assertThat(userLessonRepository.countByLesson_IdAndCanceledAtIsNull(lessonA.getId())).isEqualTo(1);
}
@Test
void duplicateRegistrationIsRejected() {
service.register(user.getId(), new CourseRegistrationRequest(lessonA.getId()));
assertThatThrownBy(() -> service.register(user.getId(), new CourseRegistrationRequest(lessonA.getId())))
.isInstanceOfSatisfying(RegistrationException.class,
ex -> assertThat(ex.getErrorCode()).isEqualTo(RegistrationErrorCode.ALREADY_REGISTERED));
}
@Test
void cancelThenReregisterKeepsHistoryAndOneActiveRow() {
CourseRegistrationResponse first = service.register(user.getId(), new CourseRegistrationRequest(lessonA.getId()));
service.cancel(user.getId(), first.registrationId());
CourseRegistrationResponse second = service.register(user.getId(), new CourseRegistrationRequest(lessonA.getId()));
assertThat(second.registrationId()).isNotEqualTo(first.registrationId());
assertThat(userLessonRepository.findAll()).hasSize(2);
assertThat(userLessonRepository.findAll().stream().filter(it -> it.getCanceledAt() == null)).hasSize(1);
}
@Test
void otherUserCannotCancelRegistration() {
CourseRegistrationResponse first = service.register(user.getId(), new CourseRegistrationRequest(lessonA.getId()));
User other = userRepository.save(new User("other-user", "다른학생", 2L, UserRole.STUDENT));
assertThatThrownBy(() -> service.cancel(other.getId(), first.registrationId()))
.isInstanceOfSatisfying(RegistrationException.class,
ex -> assertThat(ex.getErrorCode()).isEqualTo(RegistrationErrorCode.REGISTRATION_FORBIDDEN));
}
@Test
void cancelingAlreadyCanceledRegistrationIsRejected() {
CourseRegistrationResponse first = service.register(user.getId(), new CourseRegistrationRequest(lessonA.getId()));
service.cancel(user.getId(), first.registrationId());
assertThatThrownBy(() -> service.cancel(user.getId(), first.registrationId()))
.isInstanceOfSatisfying(RegistrationException.class,
ex -> assertThat(ex.getErrorCode()).isEqualTo(RegistrationErrorCode.ALREADY_CANCELED));
}
@Test
void cancelingFreesCapacityForNextRegistration() {
Lesson capacityOne = lessonRepository.save(new Lesson("capacity-one", "정원1", subjectB, professor, semester, 1, null, null));
lessonScheduleRepository.save(new LessonSchedule("capacity-one-schedule", capacityOne, DayOfWeek.WEDNESDAY, LocalTime.of(13, 0), LocalTime.of(15, 0)));
User other = userRepository.save(new User("capacity-other", "다른학생", 2L, UserRole.STUDENT));
CourseRegistrationResponse first = service.register(user.getId(), new CourseRegistrationRequest(capacityOne.getId()));
service.cancel(user.getId(), first.registrationId());
CourseRegistrationResponse second = service.register(other.getId(), new CourseRegistrationRequest(capacityOne.getId()));
assertThat(second.userId()).isEqualTo(other.getId());
assertThat(userLessonRepository.countByLesson_IdAndCanceledAtIsNull(capacityOne.getId())).isEqualTo(1);
}
@Test
void cancellationOutsideRegistrationPeriodIsRejected() {
CourseRegistrationResponse registration = service.register(user.getId(), new CourseRegistrationRequest(lessonA.getId()));
Semester closedSemester = semesterRepository.save(new Semester("closed-semester", "closed", LocalDate.now().minusDays(100),
LocalDate.now().plusDays(100), Instant.now().minusSeconds(7200), Instant.now().minusSeconds(3600), 18));
Lesson closedLesson = lessonRepository.save(new Lesson("closed-lesson", "마감강의", subjectB, professor, closedSemester, 30, null, null));
lessonScheduleRepository.save(new LessonSchedule("closed-schedule", closedLesson, DayOfWeek.THURSDAY, LocalTime.of(9, 0), LocalTime.of(11, 0)));
var closedRegistration = userLessonRepository.saveAndFlush(new com.study.course_registration.entity.UserLesson(user, closedLesson));
assertThatThrownBy(() -> service.cancel(user.getId(), closedRegistration.getId()))
.isInstanceOfSatisfying(RegistrationException.class,
ex -> assertThat(ex.getErrorCode()).isEqualTo(RegistrationErrorCode.REGISTRATION_PERIOD_CLOSED));
assertThat(userLessonRepository.findById(registration.registrationId())).isPresent();
}
@Test
void activeRegistrationListOmitsCanceledRows() {
CourseRegistrationResponse first = service.register(user.getId(), new CourseRegistrationRequest(lessonA.getId()));
service.cancel(user.getId(), first.registrationId());
service.register(user.getId(), new CourseRegistrationRequest(lessonB.getId()));
var response = service.getRegistrations(user.getId(), semester.getId());
assertThat(response.totalCredits()).isEqualTo(3);
assertThat(response.registrations()).extracting(it -> it.lessonId()).containsExactly(lessonB.getId());
}
}
@@ -0,0 +1,96 @@
package com.study.course_registration.service;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import java.time.DayOfWeek;
import java.time.Instant;
import java.time.LocalDate;
import java.time.LocalTime;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.test.context.ActiveProfiles;
import com.study.course_registration.dto.registration.CourseRegistrationRequest;
import com.study.course_registration.entity.Lesson;
import com.study.course_registration.entity.LessonSchedule;
import com.study.course_registration.entity.Professor;
import com.study.course_registration.entity.Semester;
import com.study.course_registration.entity.Subject;
import com.study.course_registration.entity.User;
import com.study.course_registration.enums.RegistrationErrorCode;
import com.study.course_registration.enums.UserRole;
import com.study.course_registration.exception.RegistrationException;
import com.study.course_registration.repository.*;
@SpringBootTest
@ActiveProfiles("test")
class LessonQueryServiceIntegrationTest {
@Autowired LessonQueryService lessonQueryService;
@Autowired CourseRegistrationService registrationService;
@Autowired UserLessonRepository userLessonRepository;
@Autowired LessonScheduleRepository lessonScheduleRepository;
@Autowired LessonRepository lessonRepository;
@Autowired SubjectRepository subjectRepository;
@Autowired ProfessorRepository professorRepository;
@Autowired SemesterRepository semesterRepository;
@Autowired UserRepository userRepository;
private Semester semester;
private Lesson lesson;
private User user;
@BeforeEach
void setUp() {
userLessonRepository.deleteAll();
lessonScheduleRepository.deleteAll();
lessonRepository.deleteAll();
subjectRepository.deleteAll();
professorRepository.deleteAll();
semesterRepository.deleteAll();
userRepository.deleteAll();
Instant now = Instant.now();
semester = semesterRepository.save(new Semester("semester-query", "2026-1", LocalDate.now().minusDays(1),
LocalDate.now().plusDays(100), now.minusSeconds(3600), now.plusSeconds(3600), 18));
Professor professor = professorRepository.save(new Professor("prof-query", "조회교수"));
Subject subject = subjectRepository.save(new Subject("subject-query", "자료구조", "CS202", "자료구조 설명", 3));
lesson = lessonRepository.save(new Lesson("lesson-query", "자료구조 01", subject, professor, semester, 30, null, null));
lessonScheduleRepository.save(new LessonSchedule("schedule-query", lesson, DayOfWeek.MONDAY,
LocalTime.of(9, 0), LocalTime.of(11, 0)));
user = userRepository.save(new User("user-query", "조회학생", 2L, UserRole.STUDENT));
registrationService.register(user.getId(), new CourseRegistrationRequest(lesson.getId()));
}
@Test
void listUsesCurrentSemesterAndReturnsBulkDerivedFields() {
var response = lessonQueryService.getLessons(null, null, 0, 20);
assertThat(response.totalElements()).isEqualTo(1);
assertThat(response.lessons()).singleElement().satisfies(item -> {
assertThat(item.lessonId()).isEqualTo(lesson.getId());
assertThat(item.enrolledCount()).isEqualTo(1);
assertThat(item.schedules()).hasSize(1);
});
}
@Test
void detailContainsSubjectAndSemesterRegistrationMetadata() {
var response = lessonQueryService.getLesson(lesson.getId());
assertThat(response.subjectDescription()).isEqualTo("자료구조 설명");
assertThat(response.semesterName()).isEqualTo(semester.getName());
assertThat(response.enrolledCount()).isEqualTo(1);
assertThat(response.registrationStartAt().toEpochMilli()).isEqualTo(semester.getRegistrationStartAt().toEpochMilli());
}
@Test
void unknownSemesterIsRejected() {
assertThatThrownBy(() -> lessonQueryService.getLessons("missing", null, 0, 20))
.isInstanceOfSatisfying(RegistrationException.class,
ex -> assertThat(ex.getErrorCode()).isEqualTo(RegistrationErrorCode.SEMESTER_NOT_FOUND));
}
}
@@ -0,0 +1,163 @@
package com.study.course_registration.service.policy;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatCode;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import java.time.DayOfWeek;
import java.time.Instant;
import java.util.List;
import java.util.Map;
import org.junit.jupiter.api.Test;
import com.study.course_registration.entity.Lesson;
import com.study.course_registration.entity.LessonSchedule;
import com.study.course_registration.entity.Semester;
import com.study.course_registration.entity.Subject;
import com.study.course_registration.entity.User;
import com.study.course_registration.entity.UserLesson;
import com.study.course_registration.enums.RegistrationErrorCode;
import com.study.course_registration.enums.UserRole;
import com.study.course_registration.exception.RegistrationException;
class RegistrationValidatorTest {
private final RegistrationValidator validator = new RegistrationValidator(new ScheduleConflictChecker());
@Test
void registrationEndAtIsInclusive() {
Semester semester = TestFixtures.semester(18);
User user = TestFixtures.student("user", 1);
Lesson lesson = TestFixtures.lesson("lesson", TestFixtures.subject("subject", 3), semester, 30, null, null);
assertThatCode(() -> validator.validatePreconditions(user, lesson, semester.getRegistrationEndAt()))
.doesNotThrowAnyException();
}
@Test
void rejectsClosedRegistrationPeriodBeforeEligibilityChecks() {
User user = TestFixtures.student("user", 1);
Lesson lesson = TestFixtures.lesson("lesson", TestFixtures.subject("subject", 3),
TestFixtures.closedSemester(), 30, 3L, UserRole.POSTGRADUATE);
assertError(RegistrationErrorCode.REGISTRATION_PERIOD_CLOSED,
() -> validator.validatePreconditions(user, lesson, TestFixtures.NOW));
}
@Test
void rejectsGradeBelowMinimum() {
User user = TestFixtures.student("user", 1);
Lesson lesson = TestFixtures.lesson("lesson", TestFixtures.subject("subject", 3),
TestFixtures.semester(18), 30, 3L, null);
assertError(RegistrationErrorCode.NOT_ELIGIBLE_GRADE,
() -> validator.validatePreconditions(user, lesson, TestFixtures.NOW));
}
@Test
void rejectsRoleMismatch() {
User user = TestFixtures.student("user", 3);
Lesson lesson = TestFixtures.lesson("lesson", TestFixtures.subject("subject", 3),
TestFixtures.semester(18), 30, null, UserRole.POSTGRADUATE);
assertError(RegistrationErrorCode.NOT_ELIGIBLE_ROLE,
() -> validator.validatePreconditions(user, lesson, TestFixtures.NOW));
}
@Test
void duplicateLessonTakesPrecedenceOverDuplicateSubject() {
Semester semester = TestFixtures.semester(18);
Subject subject = TestFixtures.subject("subject", 3);
User user = TestFixtures.student("user", 2);
Lesson lesson = TestFixtures.lesson("lesson", subject, semester, 30, null, null);
UserLesson existing = TestFixtures.registration("reg", user, lesson);
assertError(RegistrationErrorCode.ALREADY_REGISTERED,
() -> validator.validateLocked(lesson, List.of(existing), Map.of(), 1));
}
@Test
void rejectsDifferentSectionOfSameSubject() {
Semester semester = TestFixtures.semester(18);
Subject subject = TestFixtures.subject("subject", 3);
User user = TestFixtures.student("user", 2);
Lesson existingLesson = TestFixtures.lesson("lesson-a", subject, semester, 30, null, null);
Lesson newLesson = TestFixtures.lesson("lesson-b", subject, semester, 30, null, null);
UserLesson existing = TestFixtures.registration("reg", user, existingLesson);
assertError(RegistrationErrorCode.DUPLICATE_SUBJECT,
() -> validator.validateLocked(newLesson, List.of(existing), Map.of(), 1));
}
@Test
void rejectsScheduleConflict() {
Semester semester = TestFixtures.semester(18);
User user = TestFixtures.student("user", 2);
Lesson existingLesson = TestFixtures.lesson("lesson-a", TestFixtures.subject("subject-a", 3), semester, 30, null, null);
Lesson newLesson = TestFixtures.lesson("lesson-b", TestFixtures.subject("subject-b", 3), semester, 30, null, null);
UserLesson existing = TestFixtures.registration("reg", user, existingLesson);
LessonSchedule existingSchedule = TestFixtures.schedule("schedule-a", existingLesson, DayOfWeek.MONDAY, 9, 11);
LessonSchedule newSchedule = TestFixtures.schedule("schedule-b", newLesson, DayOfWeek.MONDAY, 10, 12);
assertError(RegistrationErrorCode.SCHEDULE_CONFLICT,
() -> validator.validateLocked(newLesson, List.of(existing),
Map.of(existingLesson.getId(), List.of(existingSchedule), newLesson.getId(), List.of(newSchedule)), 1));
}
@Test
void scheduleBoundaryIsAllowed() {
Semester semester = TestFixtures.semester(18);
User user = TestFixtures.student("user", 2);
Lesson existingLesson = TestFixtures.lesson("lesson-a", TestFixtures.subject("subject-a", 3), semester, 30, null, null);
Lesson newLesson = TestFixtures.lesson("lesson-b", TestFixtures.subject("subject-b", 3), semester, 30, null, null);
UserLesson existing = TestFixtures.registration("reg", user, existingLesson);
LessonSchedule existingSchedule = TestFixtures.schedule("schedule-a", existingLesson, DayOfWeek.MONDAY, 9, 11);
LessonSchedule newSchedule = TestFixtures.schedule("schedule-b", newLesson, DayOfWeek.MONDAY, 11, 13);
assertThatCode(() -> validator.validateLocked(newLesson, List.of(existing),
Map.of(existingLesson.getId(), List.of(existingSchedule), newLesson.getId(), List.of(newSchedule)), 1))
.doesNotThrowAnyException();
}
@Test
void exactCreditLimitIsAllowed() {
Semester semester = TestFixtures.semester(6);
User user = TestFixtures.student("user", 2);
Lesson existingLesson = TestFixtures.lesson("lesson-a", TestFixtures.subject("subject-a", 3), semester, 30, null, null);
Lesson newLesson = TestFixtures.lesson("lesson-b", TestFixtures.subject("subject-b", 3), semester, 30, null, null);
UserLesson existing = TestFixtures.registration("reg", user, existingLesson);
assertThatCode(() -> validator.validateLocked(newLesson, List.of(existing), Map.of(), 1))
.doesNotThrowAnyException();
assertThat(validator.totalCreditsAfter(newLesson, List.of(existing))).isEqualTo(6);
}
@Test
void rejectsCreditAboveLimit() {
Semester semester = TestFixtures.semester(5);
User user = TestFixtures.student("user", 2);
Lesson existingLesson = TestFixtures.lesson("lesson-a", TestFixtures.subject("subject-a", 3), semester, 30, null, null);
Lesson newLesson = TestFixtures.lesson("lesson-b", TestFixtures.subject("subject-b", 3), semester, 30, null, null);
UserLesson existing = TestFixtures.registration("reg", user, existingLesson);
assertError(RegistrationErrorCode.CREDIT_LIMIT_EXCEEDED,
() -> validator.validateLocked(newLesson, List.of(existing), Map.of(), 1));
}
@Test
void lastCapacitySlotIsAllowedButFullClassIsRejected() {
Lesson lesson = TestFixtures.lesson("lesson", TestFixtures.subject("subject", 3),
TestFixtures.semester(18), 30, null, null);
assertThatCode(() -> validator.validateLocked(lesson, List.of(), Map.of(), 29))
.doesNotThrowAnyException();
assertError(RegistrationErrorCode.CAPACITY_EXCEEDED,
() -> validator.validateLocked(lesson, List.of(), Map.of(), 30));
}
private static void assertError(RegistrationErrorCode code, Runnable action) {
assertThatThrownBy(action::run)
.isInstanceOfSatisfying(RegistrationException.class,
ex -> assertThat(ex.getErrorCode()).isEqualTo(code));
}
}
@@ -0,0 +1,46 @@
package com.study.course_registration.service.policy;
import static org.assertj.core.api.Assertions.assertThat;
import java.time.DayOfWeek;
import java.time.LocalTime;
import org.junit.jupiter.api.Test;
import com.study.course_registration.entity.LessonSchedule;
class ScheduleConflictCheckerTest {
private final ScheduleConflictChecker checker = new ScheduleConflictChecker();
@Test
void overlappingTimesOnSameDayConflict() {
LessonSchedule a = TestFixtures.schedule(DayOfWeek.MONDAY, 9, 11);
LessonSchedule b = TestFixtures.schedule(DayOfWeek.MONDAY, 10, 12);
assertThat(checker.overlaps(a, b)).isTrue();
}
@Test
void touchingBoundaryDoesNotConflict() {
LessonSchedule a = TestFixtures.schedule(DayOfWeek.MONDAY, 9, 11);
LessonSchedule b = TestFixtures.schedule(DayOfWeek.MONDAY, 11, 13);
assertThat(checker.overlaps(a, b)).isFalse();
}
@Test
void separatedTimesOnSameDayDoNotConflict() {
LessonSchedule a = TestFixtures.schedule(DayOfWeek.MONDAY, 9, 11);
LessonSchedule b = TestFixtures.schedule(DayOfWeek.MONDAY, 13, 15);
assertThat(checker.overlaps(a, b)).isFalse();
}
@Test
void sameTimeOnDifferentDayDoesNotConflict() {
LessonSchedule a = TestFixtures.schedule(DayOfWeek.MONDAY, 9, 11);
LessonSchedule b = TestFixtures.schedule(DayOfWeek.TUESDAY, 9, 11);
assertThat(checker.overlaps(a, b)).isFalse();
}
}
@@ -0,0 +1,63 @@
package com.study.course_registration.service.policy;
import java.time.DayOfWeek;
import java.time.Instant;
import java.time.LocalDate;
import java.time.LocalTime;
import com.study.course_registration.entity.Lesson;
import com.study.course_registration.entity.LessonSchedule;
import com.study.course_registration.entity.Professor;
import com.study.course_registration.entity.Semester;
import com.study.course_registration.entity.Subject;
import com.study.course_registration.entity.User;
import com.study.course_registration.entity.UserLesson;
import com.study.course_registration.enums.UserRole;
final class TestFixtures {
static final Instant NOW = Instant.parse("2026-09-17T10:00:00Z");
private TestFixtures() {
}
static Semester semester(int maxCredits) {
return new Semester("semester-1", "2026-1", LocalDate.of(2026, 9, 1), LocalDate.of(2026, 12, 31),
NOW.minusSeconds(3600), NOW.plusSeconds(3600), maxCredits);
}
static Semester closedSemester() {
return new Semester("semester-closed", "closed", LocalDate.of(2026, 1, 1), LocalDate.of(2026, 2, 1),
NOW.minusSeconds(7200), NOW.minusSeconds(3600), 18);
}
static User student(String id, long grade) {
return new User(id, id, grade, UserRole.STUDENT);
}
static User postgraduate(String id) {
return new User(id, id, 3L, UserRole.POSTGRADUATE);
}
static Lesson lesson(String id, Subject subject, Semester semester, int capacity, Long minGrade, UserRole role) {
return new Lesson(id, id, subject, new Professor("prof-" + id, "prof"), semester, capacity, minGrade, role);
}
static Subject subject(String id, int credit) {
return new Subject(id, id, id, id, credit);
}
static UserLesson registration(String id, User user, Lesson lesson) {
return new UserLesson(id, user, lesson);
}
static LessonSchedule schedule(DayOfWeek day, int startHour, int endHour) {
Subject subject = subject("subject-" + day + startHour, 3);
Lesson lesson = lesson("lesson-" + day + startHour, subject, semester(18), 30, null, null);
return new LessonSchedule("schedule-" + day + startHour, lesson, day,
LocalTime.of(startHour, 0), LocalTime.of(endHour, 0));
}
static LessonSchedule schedule(String id, Lesson lesson, DayOfWeek day, int startHour, int endHour) {
return new LessonSchedule(id, lesson, day, LocalTime.of(startHour, 0), LocalTime.of(endHour, 0));
}
}
+21
View File
@@ -0,0 +1,21 @@
spring:
datasource:
url: jdbc:h2:mem:course_test;DB_CLOSE_DELAY=-1;MODE=PostgreSQL;LOCK_TIMEOUT=3000
driver-class-name: org.h2.Driver
username: sa
password:
jpa:
hibernate:
ddl-auto: create-drop
open-in-view: false
h2:
console:
enabled: false
app:
seed:
enabled: false
springdoc:
api-docs:
enabled: false
swagger-ui:
enabled: false