diff --git a/docs/step2.md b/docs/step2.md new file mode 100644 index 0000000000..e65356cc1d --- /dev/null +++ b/docs/step2.md @@ -0,0 +1,28 @@ +## 기능 요구사항 +- 과정(Course)은 기수 단위로 운영하며, 여러 개의 강의(Session)를 가질 수 있다. +- 강의는 시작일과 종료일을 가진다. +- 강의는 강의 커버 이미지 정보를 가진다. +- 이미지 크기는 1MB 이하여야 한다. +- 이미지 타입은 gif, jpg(jpeg 포함),, png, svg만 허용한다. +- 이미지의 width는 300픽셀, height는 200픽셀 이상이어야 하며, width와 height의 비율은 3:2여야 한다. +- 강의는 무료 강의와 유료 강의로 나뉜다. +- 무료 강의는 최대 수강 인원 제한이 없다. +- 유료 강의는 강의 최대 수강 인원을 초과할 수 없다. +- 유료 강의는 수강생이 결제한 금액과 수강료가 일치할 때 수강 신청이 가능하다. +- 강의 상태는 준비중, 모집중, 종료 3가지 상태를 가진다. +- 강의 수강신청은 강의 상태가 모집중일 때만 가능하다. +- 유료 강의의 경우 결제는 이미 완료한 것으로 가정하고 이후 과정을 구현한다. +- 결제를 완료한 결제 정보는 payments 모듈을 통해 관리되며, 결제 정보는 Payment 객체에 담겨 반한된다. + +## 기능 목록 +- [x] 강의의 종료일보다 시작일이 더 이후인지 확인한다. +- [x] 강의 커버 이미지의 크기가 1MB 이하인지 확인한다. +- [x] 이미지 타입이 gif, jpg, png, svg인지 확인한다. +- [x] 이미지의 width와 height가 각각 300, 200 픽셀 이상인지 확인한다. +- [x] 이미지의 width와 height 비율이 3:2인지 확인한다. +- [x] 유료 강의의 강의 최대 수강 인원을 초과하는지 확인한다. +- [x] 현재 수강 인원을 증가시킨다. +- [x] 유료 강의의 수강료와 수강생이 결제한 금액이 일치하는지 확인한다. +- [x] 수강 신청을 한다. +- [x] 강의 상태가 모집중인지 확인한다. +- [x] 결제 금액을 반환한다. \ No newline at end of file diff --git a/src/main/java/nextstep/enrollment/domain/Enrollment.java b/src/main/java/nextstep/enrollment/domain/Enrollment.java new file mode 100644 index 0000000000..145ae78000 --- /dev/null +++ b/src/main/java/nextstep/enrollment/domain/Enrollment.java @@ -0,0 +1,44 @@ +package nextstep.enrollment.domain; + +import nextstep.payments.domain.Payment; +import nextstep.sessions.domain.Session; +import nextstep.users.domain.NsUser; + +import java.time.LocalDateTime; +import java.util.Objects; + +public class Enrollment { + private final NsUser student; + private final Session session; + private final LocalDateTime enrolledAt; + private final Payment payment; + + public Enrollment(NsUser student, Session session, Payment payment) { + validate(session, payment); + this.student = student; + this.session = session; + this.enrolledAt = LocalDateTime.now(); + this.payment = payment; + } + + private void validate(Session session, Payment payment) { + validateSessionStatus(session); + validatePaidCorrectly(session, payment); + } + + private void validateSessionStatus(Session session) { + if (!session.isRecruiting()) { + throw new IllegalArgumentException("강의 상태가 모집중일 때만 수강 신청이 가능합니다."); + } + } + + private void validatePaidCorrectly(Session session, Payment payment) { + if (!hasPaidCorrectly(session, payment)) { + throw new IllegalArgumentException("결제 금액이 수강료와 일치하지 않습니다."); + } + } + + private boolean hasPaidCorrectly(Session session, Payment payment) { + return Objects.equals(payment.amount(), session.price()); + } +} diff --git a/src/main/java/nextstep/payments/domain/Payment.java b/src/main/java/nextstep/payments/domain/Payment.java index 57d833f851..5af1257cbe 100644 --- a/src/main/java/nextstep/payments/domain/Payment.java +++ b/src/main/java/nextstep/payments/domain/Payment.java @@ -26,4 +26,8 @@ public Payment(String id, Long sessionId, Long nsUserId, Long amount) { this.amount = amount; this.createdAt = LocalDateTime.now(); } + + public Long amount() { + return this.amount; + } } diff --git a/src/main/java/nextstep/qna/domain/Answer.java b/src/main/java/nextstep/qna/domain/Answer.java index 684245e349..13f1605f06 100644 --- a/src/main/java/nextstep/qna/domain/Answer.java +++ b/src/main/java/nextstep/qna/domain/Answer.java @@ -53,7 +53,7 @@ public void validateOwnership(NsUser loginUser) throws CannotDeleteException { public DeleteHistory delete(NsUser loginUser) throws CannotDeleteException { validateOwnership(loginUser); deleted = true; - return DeleteHistory.deleteOfAnswer(this); + return DeleteHistory.ofAnswer(this); } public Long getId() { diff --git a/src/main/java/nextstep/qna/domain/DeleteHistory.java b/src/main/java/nextstep/qna/domain/DeleteHistory.java index 93751d76e0..4f2abf4737 100644 --- a/src/main/java/nextstep/qna/domain/DeleteHistory.java +++ b/src/main/java/nextstep/qna/domain/DeleteHistory.java @@ -19,12 +19,12 @@ public class DeleteHistory { public DeleteHistory() { } - public static DeleteHistory deleteOfQuestion(Question q) { - return new DeleteHistory(ContentType.QUESTION, q.getId(), q.getWriter(), LocalDateTime.now()); + public static DeleteHistory ofQuestion(Question question) { + return new DeleteHistory(ContentType.QUESTION, question.getId(), question.getWriter(), LocalDateTime.now()); } - public static DeleteHistory deleteOfAnswer(Answer a) { - return new DeleteHistory(ContentType.ANSWER, a.getId(), a.getWriter(), LocalDateTime.now()); + public static DeleteHistory ofAnswer(Answer answer) { + return new DeleteHistory(ContentType.ANSWER, answer.getId(), answer.getWriter(), LocalDateTime.now()); } public DeleteHistory(ContentType contentType, Long contentId, NsUser deletedBy, LocalDateTime createdDate) { diff --git a/src/main/java/nextstep/qna/domain/Question.java b/src/main/java/nextstep/qna/domain/Question.java index 291eae6c81..ecdd2544a2 100644 --- a/src/main/java/nextstep/qna/domain/Question.java +++ b/src/main/java/nextstep/qna/domain/Question.java @@ -44,7 +44,7 @@ public List delete(NsUser loginUser) throws CannotDeleteException this.deleted = true; List deleteHistories = new ArrayList<>(); - deleteHistories.add(DeleteHistory.deleteOfQuestion(this)); + deleteHistories.add(DeleteHistory.ofQuestion(this)); deleteHistories.addAll(answers.deleteAnswers(loginUser)); return deleteHistories; diff --git a/src/main/java/nextstep/sessions/domain/EnrollmentCapacity.java b/src/main/java/nextstep/sessions/domain/EnrollmentCapacity.java new file mode 100644 index 0000000000..0efc6444a4 --- /dev/null +++ b/src/main/java/nextstep/sessions/domain/EnrollmentCapacity.java @@ -0,0 +1,30 @@ +package nextstep.sessions.domain; + +public class EnrollmentCapacity { + private int maxEnrollment; + private int currentEnrollment; + + public EnrollmentCapacity(int maxEnrollment) { + this(maxEnrollment, 0); + } + + public EnrollmentCapacity(int maxEnrollment, int currentEnrollment) { + this.maxEnrollment = maxEnrollment; + this.currentEnrollment = currentEnrollment; + } + + public boolean isFull() { + return currentEnrollment >= maxEnrollment; + } + + public void increaseEnrollment() { + validate(); + currentEnrollment++; + } + + private void validate() { + if (isFull()) { + throw new IllegalStateException("정원이 초과되었습니다."); + } + } +} diff --git a/src/main/java/nextstep/sessions/domain/Image.java b/src/main/java/nextstep/sessions/domain/Image.java new file mode 100644 index 0000000000..d643304d79 --- /dev/null +++ b/src/main/java/nextstep/sessions/domain/Image.java @@ -0,0 +1,61 @@ +package nextstep.sessions.domain; + +public class Image { + private static final Long MAX_SIZE_BYTES = 1024 * 1024L; + private static final Float MIN_SIZE_WIDTH = 300F; + private static final Float MIN_SIZE_HEIGHT = 200F; + private static final Float ASPECT_RATIO_TOLERANCE = 0.01f; + + private Long id; + private Long sizeInBytes; + private String title; + private ImageType type; + private Float width; + private Float height; + + public Image(Long sizeInBytes, String title, ImageType type, Float width, Float height) { + this(sizeInBytes, 0L, title, type, width, height); + } + + public Image(Long sizeInBytes, Long id, String title, ImageType type, Float width, Float height) { + validate(sizeInBytes, title, type, width, height); + this.sizeInBytes = sizeInBytes; + this.id = id; + this.title = title; + this.type = type; + this.width = width; + this.height = height; + } + + private void validate(Long sizeInBytes, String title, ImageType type, Float width, Float height) { + validateSize(sizeInBytes); + validateWidth(width); + validateHeight(height); + validateAspectRatio(width, height); + } + + private void validateSize(Long sizeInBytes) { + if (sizeInBytes > MAX_SIZE_BYTES) { + throw new IllegalArgumentException("이미지 크기는 최대 1MB 이하여야 합니다."); + } + } + + private void validateWidth(Float width) { + if (width < MIN_SIZE_WIDTH) { + throw new IllegalArgumentException("이미지 가로 길이는 최소 300픽셀 이상이어야 합니다."); + } + } + + private void validateHeight(Float height) { + if (height < MIN_SIZE_HEIGHT) { + throw new IllegalArgumentException("이미지 세로 길이는 최소 200픽셀 이상이어야 합니다."); + } + } + + private void validateAspectRatio(float width, float height) { + float ratio = width / height; + if (Math.abs(ratio - (MIN_SIZE_WIDTH / MIN_SIZE_HEIGHT)) > ASPECT_RATIO_TOLERANCE) { + throw new IllegalArgumentException("이미지 비율은 3:2이어야 합니다."); + } + } +} diff --git a/src/main/java/nextstep/sessions/domain/ImageType.java b/src/main/java/nextstep/sessions/domain/ImageType.java new file mode 100644 index 0000000000..4f642050f5 --- /dev/null +++ b/src/main/java/nextstep/sessions/domain/ImageType.java @@ -0,0 +1,13 @@ +package nextstep.sessions.domain; + +public enum ImageType { + GIF, JPG, JPEG, PNG, SVG; + + public static ImageType from(String type) { + try { + return ImageType.valueOf(type.toUpperCase()); + } catch (IllegalArgumentException e) { + throw new IllegalArgumentException("지원하지 않는 이미지 타입입니다."); + } + } +} diff --git a/src/main/java/nextstep/sessions/domain/Session.java b/src/main/java/nextstep/sessions/domain/Session.java new file mode 100644 index 0000000000..ee81f021a7 --- /dev/null +++ b/src/main/java/nextstep/sessions/domain/Session.java @@ -0,0 +1,40 @@ +package nextstep.sessions.domain; + +public class Session { + private Long id; + private String title; + private SessionType type; + private SessionStatus status; + private SessionPeriod period; + private Image coverImage; + private EnrollmentCapacity enrollmentCapacity; + private Long price; + + public Session(Long price) { + this.price = price; + } + + public Session(SessionStatus status, Long price) { + this.status = status; + this.price = price; + } + + public Session(Long id, String title, SessionType type, SessionStatus status, SessionPeriod period, Image coverImage, EnrollmentCapacity enrollmentCapacity, Long price) { + this.id = id; + this.title = title; + this.type = type; + this.status = status; + this.period = period; + this.coverImage = coverImage; + this.enrollmentCapacity = enrollmentCapacity; + this.price = price; + } + + public Long price() { + return this.price; + } + + public boolean isRecruiting() { + return status.equals(SessionStatus.RECRUITING); + } +} diff --git a/src/main/java/nextstep/sessions/domain/SessionPeriod.java b/src/main/java/nextstep/sessions/domain/SessionPeriod.java new file mode 100644 index 0000000000..b2031a4b1f --- /dev/null +++ b/src/main/java/nextstep/sessions/domain/SessionPeriod.java @@ -0,0 +1,20 @@ +package nextstep.sessions.domain; + +import java.time.LocalDate; + +public class SessionPeriod { + private final LocalDate startDate; + private final LocalDate endDate; + + public SessionPeriod(LocalDate startDate, LocalDate endDate) { + validate(startDate, endDate); + this.startDate = startDate; + this.endDate = endDate; + } + + private void validate(LocalDate startDate, LocalDate endDate) { + if (startDate.isAfter(endDate)) { + throw new IllegalArgumentException("시작일은 종료일보다 이전이어야 합니다."); + } + } +} diff --git a/src/main/java/nextstep/sessions/domain/SessionStatus.java b/src/main/java/nextstep/sessions/domain/SessionStatus.java new file mode 100644 index 0000000000..9e00695a04 --- /dev/null +++ b/src/main/java/nextstep/sessions/domain/SessionStatus.java @@ -0,0 +1,5 @@ +package nextstep.sessions.domain; + +public enum SessionStatus { + READY, RECRUITING, CLOSED +} \ No newline at end of file diff --git a/src/main/java/nextstep/sessions/domain/SessionType.java b/src/main/java/nextstep/sessions/domain/SessionType.java new file mode 100644 index 0000000000..fb938b5332 --- /dev/null +++ b/src/main/java/nextstep/sessions/domain/SessionType.java @@ -0,0 +1,5 @@ +package nextstep.sessions.domain; + +public enum SessionType { + FREE, PAID +} \ No newline at end of file diff --git a/src/test/java/nextstep/enrollment/domain/EnrollmentTest.java b/src/test/java/nextstep/enrollment/domain/EnrollmentTest.java new file mode 100644 index 0000000000..2c4253ec62 --- /dev/null +++ b/src/test/java/nextstep/enrollment/domain/EnrollmentTest.java @@ -0,0 +1,40 @@ +package nextstep.enrollment.domain; + +import nextstep.payments.domain.Payment; +import nextstep.sessions.domain.Session; +import nextstep.sessions.domain.SessionStatus; +import nextstep.users.domain.NsUser; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.*; + +class EnrollmentTest { + @Test + @DisplayName("결제 금액이 강의 수강료와 일치하면 수강 신청에 성공한다.") + void 결제_금액_강의_금액_일치() { + new Enrollment(new NsUser(), new Session(10_000L), new Payment("user_id", 0L, 0L, 10_000L)); + } + + @Test + @DisplayName("결제 금액이 강의 수강료와 다르면 예외를 던진다.") + void 결제_금액_강의_금액_불일치() { + assertThatIllegalArgumentException().isThrownBy(() -> + new Enrollment(new NsUser(), new Session(10_000L), new Payment("user_id", 0L, 0L, 20_000L))) + .withMessage("결제 금액이 수강료와 일치하지 않습니다."); + } + + @Test + @DisplayName("강의 상태가 모집중이면 수강 신청이 가능하다.") + void 모집중_강의_수강_신청() { + new Enrollment(new NsUser(), new Session(SessionStatus.RECRUITING, 10_000L), new Payment("user_id", 0L, 0L, 10_000L)); + } + + @Test + @DisplayName("강의 상태가 모집중이 아니면 수강 신청 시 예외를 던진다.") + void 모집중_아닌_강의_수강_신청() { + assertThatIllegalArgumentException().isThrownBy(() -> + new Enrollment(new NsUser(), new Session(SessionStatus.CLOSED, 10_000L), new Payment("user_id", 0L, 0L, 10_000L))) + .withMessage("강의 상태가 모집중일 때만 수강 신청이 가능합니다."); + } +} \ No newline at end of file diff --git a/src/test/java/nextstep/qna/domain/AnswerTest.java b/src/test/java/nextstep/qna/domain/AnswerTest.java index 6558877335..d5d119fd73 100644 --- a/src/test/java/nextstep/qna/domain/AnswerTest.java +++ b/src/test/java/nextstep/qna/domain/AnswerTest.java @@ -18,6 +18,6 @@ public class AnswerTest { void 답변_작성자가_타인일_경우_예외가_발생한다() { Assertions.assertThatThrownBy(() -> A1.validateOwnership(NsUserTest.SANJIGI)) .isInstanceOf(CannotDeleteException.class) - .hasMessageContaining("다른 사람이 쓴 답변이 있어 삭제할 수 없습니다."); + .hasMessageContaining("답변을 삭제할 권한이 없습니다."); } } diff --git a/src/test/java/nextstep/sessions/domain/EnrollmentCapacityTest.java b/src/test/java/nextstep/sessions/domain/EnrollmentCapacityTest.java new file mode 100644 index 0000000000..c6bb6f8e16 --- /dev/null +++ b/src/test/java/nextstep/sessions/domain/EnrollmentCapacityTest.java @@ -0,0 +1,24 @@ +package nextstep.sessions.domain; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.*; + +class EnrollmentCapacityTest { + @Test + @DisplayName("정원이 가득 찬 경우 인원 증가 시 예외를 던진다.") + void 등록_인원_초과() { + EnrollmentCapacity enrollmentCapacity = new EnrollmentCapacity(1, 1); + assertThatThrownBy(enrollmentCapacity::increaseEnrollment) + .isInstanceOf(IllegalStateException.class) + .hasMessage("정원이 초과되었습니다."); + } + + @Test + @DisplayName("정원이 가득 차지 않은 경우 등록 인원을 정상적으로 증가시킨다..") + void 등록_인원_증가() { + EnrollmentCapacity enrollmentCapacity = new EnrollmentCapacity(1, 0); + enrollmentCapacity.increaseEnrollment(); + } +} \ No newline at end of file diff --git a/src/test/java/nextstep/sessions/domain/ImageTest.java b/src/test/java/nextstep/sessions/domain/ImageTest.java new file mode 100644 index 0000000000..7f16df112e --- /dev/null +++ b/src/test/java/nextstep/sessions/domain/ImageTest.java @@ -0,0 +1,62 @@ +package nextstep.sessions.domain; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.*; + +public class ImageTest { + @Test + @DisplayName("이미지 최대 크기를 초과할 시 IllegalArgumentException을 던진다.") + void 이미지_크기_초과() { + assertThatIllegalArgumentException().isThrownBy(() -> + new Image(2_000_000L, "이미지", ImageType.JPEG, 300F, 200F)) + .withMessage("이미지 크기는 최대 1MB 이하여야 합니다."); + } + + @Test + @DisplayName("이미지 최대 크기를 초과하지 않으면 이미지가 잘 생성된다.") + void 이미지_크기_적정() { + new Image(1_000_000L, "이미지", ImageType.JPEG, 300F, 200F); + } + + @Test + @DisplayName("이미지 가로 길이가 300보다 작을 시 IllegalArgumentException을 던진다.") + void 이미지_최소_가로_크기_부적절() { + assertThatIllegalArgumentException().isThrownBy(() -> + new Image(1_000_000L, "이미지", ImageType.JPEG, 200F, 200F)) + .withMessage("이미지 가로 길이는 최소 300픽셀 이상이어야 합니다."); + } + + @Test + @DisplayName("이미지 가로 길이가 300이상이면 정상적으로 생성된다.") + void 이미지_최소_가로_크기_적절() { + new Image(1_000_000L, "이미지", ImageType.JPEG, 300F, 200F); + } + + @Test + @DisplayName("이미지 세로 길이가 200이상이면 정상적으로 생성된다.") + void 이미지_최소_세로_크기_적절() { + new Image(1_000_000L, "이미지", ImageType.JPEG, 300F, 200F); + } + + @Test + @DisplayName("이미지 가로 세로 비율이 3:2가 아니면 IllegalArgumentException을 던진다.") + void 이미지_가로_세로_비율_부적절() { + assertThatIllegalArgumentException().isThrownBy(() -> + new Image(1_000_000L, "이미지", ImageType.JPEG, 400F, 200F)) + .withMessage("이미지 비율은 3:2이어야 합니다."); + } + + @Test + @DisplayName("이미지 가로 세로 비율이 3:2면 정상적으로 생성된다.") + void 이미지_가로_세로_비율_적절() { + new Image(1_000_000L, "이미지", ImageType.JPEG, 300F, 200F); + } + + @Test + @DisplayName("이미지 가로 세로 비율이 약 3:2면 정상적으로 생성된다.") + void 이미지_가로_세로_비율_오차_범위_이내() { + new Image(1_000_000L, "이미지", ImageType.JPEG, 301F, 200F); + } +} diff --git a/src/test/java/nextstep/sessions/domain/ImageTypeTest.java b/src/test/java/nextstep/sessions/domain/ImageTypeTest.java new file mode 100644 index 0000000000..6152054633 --- /dev/null +++ b/src/test/java/nextstep/sessions/domain/ImageTypeTest.java @@ -0,0 +1,26 @@ +package nextstep.sessions.domain; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.*; + +public class ImageTypeTest { + + @Test + @DisplayName("지원하는 이미지 타입은 enum으로 정상 변환된다.") + void 지원하는_이미지_타입_변환_성공() { + assertThat(ImageType.from("gif")).isEqualTo(ImageType.GIF); + assertThat(ImageType.from("jpg")).isEqualTo(ImageType.JPG); + assertThat(ImageType.from("jpeg")).isEqualTo(ImageType.JPEG); + assertThat(ImageType.from("png")).isEqualTo(ImageType.PNG); + assertThat(ImageType.from("svg")).isEqualTo(ImageType.SVG); + } + + @Test + @DisplayName("지원하지 않는 이미지 타입은 IllegalArgumentException을 던진다.") + void 지원하지_않는_이미지_타입_예외() { + assertThatIllegalArgumentException().isThrownBy(() -> ImageType.from("java")) + .withMessage("지원하지 않는 이미지 타입입니다."); + } +} diff --git a/src/test/java/nextstep/sessions/domain/SessionPeriodTest.java b/src/test/java/nextstep/sessions/domain/SessionPeriodTest.java new file mode 100644 index 0000000000..187c3a5ce9 --- /dev/null +++ b/src/test/java/nextstep/sessions/domain/SessionPeriodTest.java @@ -0,0 +1,34 @@ +package nextstep.sessions.domain; + +import org.assertj.core.api.Assertions; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.time.LocalDate; + +public class SessionPeriodTest { + private LocalDate earlier; + private LocalDate later; + + @BeforeEach + void setUp() { + this.earlier = LocalDate.of(2025, 1, 1); + this.later = LocalDate.of(2025, 3, 1); + } + + @Test + @DisplayName("시작일이 종료일보다 이전이면 기간을 정상 생성한다.") + void 기간_정상_생성() { + SessionPeriod sessionPeriod = new SessionPeriod(earlier, later); + } + + @Test + @DisplayName("시작일이 종료일보다 늦으면 IllegalArgumentException을 던진다.") + void 시작일이_종료일보다_늦으면_예외발생() { + Assertions.assertThatIllegalArgumentException().isThrownBy(() + -> new SessionPeriod(later, earlier)) + .withMessage("시작일은 종료일보다 이전이어야 합니다."); + + } +}