diff --git a/README.md b/README.md index 4ba6c8e721..3fa8e65b34 100644 --- a/README.md +++ b/README.md @@ -8,9 +8,24 @@ ## 온라인 코드 리뷰 과정 * [텍스트와 이미지로 살펴보는 온라인 코드 리뷰 과정](https://github.com/next-step/nextstep-docs/tree/master/codereview) -## 요구사항 +## 기능 요구사항 +### 1단계 - 레거시 코드 리팩토링 - deleteQuestion 에서 비즈니스 로직을 도메인 모델로 리팩토링 - [X] 로그인 사용자와 질문한 사람이 같은 경우 삭제 가능 - [X] 질문자와 답변글의 모든 답변자가 같은 경우 삭제 가능 - [X] 삭제 이력 남기기 +### 2단계 - 수강신청(도메인 모델) +- [X] 과정(Course)은 기수 단위로 운영, 여러개의 강의(Session)존재 +- [X] 강의는 시작일과 종료일이 존재 +- [X] 강의는 강의 커버 이미지가 존재 + - [X] 이미지 크기는 1MB 이하 + - [X] 이미지 확장자는 gif, jpg, jpeg, png, svg 만 가능 + - [X] width는 300픽셀, height는 200픽셀 이상이, width와 height의 비율은 3:2 +- [X] 강의는 무료강의와 유료강의 분류 + - [X] 무료강의 - 최대 수강 인원 제한 X + - [X] 유료강의 - 최대 수강 인원 제한 O + - [X] 유료강의 - 수강생이 결제한 금액과 수강료가 일치할 때 수강 신청이 가능 +- [X] 강의 상태 : 준비중, 모집중, 종료 +- [X] 강의 상태가 모집중일 때만 수강신청 가능 +- [X] 결제 정보는 payments 모듈을 통해 관리, 결제 정보는 Payment 객체 diff --git a/src/main/java/nextstep/courses/CannotEnrollSessionException.java b/src/main/java/nextstep/courses/CannotEnrollSessionException.java new file mode 100644 index 0000000000..819368ef9f --- /dev/null +++ b/src/main/java/nextstep/courses/CannotEnrollSessionException.java @@ -0,0 +1,7 @@ +package nextstep.courses; + +public class CannotEnrollSessionException extends RuntimeException { + public CannotEnrollSessionException(String message) { + super(message); + } +} diff --git a/src/main/java/nextstep/courses/InvalidCoverImageException.java b/src/main/java/nextstep/courses/InvalidCoverImageException.java new file mode 100644 index 0000000000..cb8d060e7f --- /dev/null +++ b/src/main/java/nextstep/courses/InvalidCoverImageException.java @@ -0,0 +1,8 @@ +package nextstep.courses; + +public class InvalidCoverImageException extends RuntimeException { + + public InvalidCoverImageException(String message) { + super(message); + } +} diff --git a/src/main/java/nextstep/courses/domain/Course.java b/src/main/java/nextstep/courses/domain/Course.java index 0f69716043..ad47f3d286 100644 --- a/src/main/java/nextstep/courses/domain/Course.java +++ b/src/main/java/nextstep/courses/domain/Course.java @@ -4,6 +4,7 @@ public class Course { private Long id; + private Long generation; private String title; @@ -13,17 +14,25 @@ public class Course { private LocalDateTime updatedAt; + private Sessions sessions; + public Course() { } public Course(String title, Long creatorId) { - this(0L, title, creatorId, LocalDateTime.now(), null); + this(0L, 0L, title, creatorId, new Sessions(), LocalDateTime.now(), null); } public Course(Long id, String title, Long creatorId, LocalDateTime createdAt, LocalDateTime updatedAt) { + this(id, 0L, title, creatorId, new Sessions(), createdAt, updatedAt); + } + + public Course(Long id, Long generation, String title, Long creatorId, Sessions sessions, LocalDateTime createdAt, LocalDateTime updatedAt) { this.id = id; + this.generation = generation; this.title = title; this.creatorId = creatorId; + this.sessions = sessions; this.createdAt = createdAt; this.updatedAt = updatedAt; } @@ -50,4 +59,5 @@ public String toString() { ", updatedAt=" + updatedAt + '}'; } + } diff --git a/src/main/java/nextstep/courses/domain/CoverImage.java b/src/main/java/nextstep/courses/domain/CoverImage.java new file mode 100644 index 0000000000..03251a011a --- /dev/null +++ b/src/main/java/nextstep/courses/domain/CoverImage.java @@ -0,0 +1,38 @@ +package nextstep.courses.domain; + +import nextstep.courses.InvalidCoverImageException; + +public class CoverImage { + + private final int width; + private final int height; + private final int sizeInBytes; + private final ImageExtension extension; + + public CoverImage(int width, int height, int sizeInBytes, ImageExtension extension) { + this.width = width; + this.height = height; + this.sizeInBytes = sizeInBytes; + this.extension = extension; + validate(); + } + + private void validate() { + if (sizeInBytes > 1_000_000) { + throw new InvalidCoverImageException("1MB를 초과하는 이미지입니다."); + } + if (width < 300) { + throw new InvalidCoverImageException("너비는 300px 이상이어야 합니다."); + } + if (height < 200) { + throw new InvalidCoverImageException("높이는 200px 이상이어야 합니다."); + } + if (width * 2 != height * 3) { + throw new InvalidCoverImageException("비율은 3:2여야 합니다."); + } + if (extension == null) { + throw new InvalidCoverImageException("지원하지 않는 확장자입니다."); + } + } + +} diff --git a/src/main/java/nextstep/courses/domain/ImageExtension.java b/src/main/java/nextstep/courses/domain/ImageExtension.java new file mode 100644 index 0000000000..98c4817f70 --- /dev/null +++ b/src/main/java/nextstep/courses/domain/ImageExtension.java @@ -0,0 +1,16 @@ +package nextstep.courses.domain; + +public enum ImageExtension { + GIF("gif"), + JPG("jpg"), + JPEG("jpeg"), + PNG("png"), + SVG("svg"); + + private final String value; + + ImageExtension(String value) { + this.value = value; + } + +} diff --git a/src/main/java/nextstep/courses/domain/Session.java b/src/main/java/nextstep/courses/domain/Session.java new file mode 100644 index 0000000000..c23638653e --- /dev/null +++ b/src/main/java/nextstep/courses/domain/Session.java @@ -0,0 +1,43 @@ +package nextstep.courses.domain; + +import nextstep.courses.CannotEnrollSessionException; +import nextstep.payments.domain.EnrollmentPolicy; +import nextstep.payments.domain.Payment; + +import java.time.LocalDate; + +public class Session { + + private final LocalDate startDate; + private final LocalDate endDate; + private final CoverImage coverImage; + private final SessionStatus status; + private final EnrollmentPolicy enrollmentPolicy; + + private int currentEnrolledCount = 0; + + public Session(LocalDate startDate, + LocalDate endDate, + CoverImage coverImage, + SessionStatus status, + EnrollmentPolicy enrollmentPolicy) { + this.startDate = startDate; + this.endDate = endDate; + this.coverImage = coverImage; + this.status = status; + this.enrollmentPolicy = enrollmentPolicy; + } + + public void enroll(Payment payment) { + if (!this.status.canEnroll()) { + throw new CannotEnrollSessionException("모집 중이 아닙니다."); + } + + if (!enrollmentPolicy.canEnroll(currentEnrolledCount, payment)) { + throw new CannotEnrollSessionException("수강 조건이 맞지 않습니다."); + } + + currentEnrolledCount++; + } + +} diff --git a/src/main/java/nextstep/courses/domain/SessionStatus.java b/src/main/java/nextstep/courses/domain/SessionStatus.java new file mode 100644 index 0000000000..19b3a74b64 --- /dev/null +++ b/src/main/java/nextstep/courses/domain/SessionStatus.java @@ -0,0 +1,11 @@ +package nextstep.courses.domain; + +public enum SessionStatus { + READY, + ENROLLING, + COMPLETED; + + public boolean canEnroll() { + return this == ENROLLING; + } +} diff --git a/src/main/java/nextstep/courses/domain/Sessions.java b/src/main/java/nextstep/courses/domain/Sessions.java new file mode 100644 index 0000000000..4dbfc0bb1e --- /dev/null +++ b/src/main/java/nextstep/courses/domain/Sessions.java @@ -0,0 +1,16 @@ +package nextstep.courses.domain; + +import java.util.List; + +public class Sessions { + + private List sessions; + + public Sessions() { + } + + public Sessions(List sessions) { + this.sessions = sessions; + } + +} diff --git a/src/main/java/nextstep/courses/infrastructure/JdbcCourseRepository.java b/src/main/java/nextstep/courses/infrastructure/JdbcCourseRepository.java index f9122cbe33..10970910af 100644 --- a/src/main/java/nextstep/courses/infrastructure/JdbcCourseRepository.java +++ b/src/main/java/nextstep/courses/infrastructure/JdbcCourseRepository.java @@ -2,6 +2,7 @@ import nextstep.courses.domain.Course; import nextstep.courses.domain.CourseRepository; +import nextstep.courses.domain.Sessions; import org.springframework.jdbc.core.JdbcOperations; import org.springframework.jdbc.core.RowMapper; import org.springframework.stereotype.Repository; diff --git a/src/main/java/nextstep/payments/domain/EnrollmentPolicy.java b/src/main/java/nextstep/payments/domain/EnrollmentPolicy.java new file mode 100644 index 0000000000..e1845f7ae3 --- /dev/null +++ b/src/main/java/nextstep/payments/domain/EnrollmentPolicy.java @@ -0,0 +1,5 @@ +package nextstep.payments.domain; + +public interface EnrollmentPolicy { + boolean canEnroll(int currentEnrolledCount, Payment payment); +} diff --git a/src/main/java/nextstep/payments/domain/FreeEnrollmentPolicy.java b/src/main/java/nextstep/payments/domain/FreeEnrollmentPolicy.java new file mode 100644 index 0000000000..92145f74d5 --- /dev/null +++ b/src/main/java/nextstep/payments/domain/FreeEnrollmentPolicy.java @@ -0,0 +1,9 @@ +package nextstep.payments.domain; + +public class FreeEnrollmentPolicy implements EnrollmentPolicy { + + @Override + public boolean canEnroll(int currentEnrolledCount, Payment payment) { + return payment.isSameAmount(0L); + } +} diff --git a/src/main/java/nextstep/payments/domain/PaidEnrollmentPolicy.java b/src/main/java/nextstep/payments/domain/PaidEnrollmentPolicy.java new file mode 100644 index 0000000000..796bf39a66 --- /dev/null +++ b/src/main/java/nextstep/payments/domain/PaidEnrollmentPolicy.java @@ -0,0 +1,18 @@ +package nextstep.payments.domain; + +public class PaidEnrollmentPolicy implements EnrollmentPolicy { + + private final int maxEnrolledCount; + private final int price; + + + public PaidEnrollmentPolicy(int maxEnrolledCount, int price) { + this.maxEnrolledCount = maxEnrolledCount; + this.price = price; + } + + @Override + public boolean canEnroll(int currentEnrolledCount, Payment payment) { + return currentEnrolledCount < maxEnrolledCount && payment.isSameAmount((long) price); + } +} diff --git a/src/main/java/nextstep/payments/domain/Payment.java b/src/main/java/nextstep/payments/domain/Payment.java index 57d833f851..ef093c387a 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 boolean isSameAmount(Long amount) { + return this.amount.equals(amount); + } } diff --git a/src/main/java/nextstep/qna/domain/Answers.java b/src/main/java/nextstep/qna/domain/Answers.java index 544cdb1cb1..add1489d24 100644 --- a/src/main/java/nextstep/qna/domain/Answers.java +++ b/src/main/java/nextstep/qna/domain/Answers.java @@ -5,6 +5,7 @@ import java.util.ArrayList; import java.util.List; +import java.util.stream.Collectors; public class Answers { private final List answers; @@ -22,16 +23,15 @@ public List getAnswers() { } public List deleteAll(NsUser loginUser) { - for (Answer answer : answers) { - if (!answer.isOwner(loginUser)) { - throw new CannotDeleteException("다른 사람이 쓴 답변이 있어 삭제할 수 없습니다."); - } + if(!canAllDelete(loginUser)) { + throw new CannotDeleteException("다른 사람이 쓴 답변이 있어 삭제할 수 없습니다."); } + return answers.stream().map(Answer::delete).collect(Collectors.toList()); + } + + private boolean canAllDelete(NsUser loginUser) { + return answers.stream().filter(answer -> answer.isOwner(loginUser)).count() + == answers.size(); - List deleteHistories = new ArrayList<>(); - for (Answer answer : answers) { - deleteHistories.add(answer.delete()); - } - return deleteHistories; } } diff --git a/src/test/java/nextstep/courses/domain/CoverImageTest.java b/src/test/java/nextstep/courses/domain/CoverImageTest.java new file mode 100644 index 0000000000..9d1f9f2edd --- /dev/null +++ b/src/test/java/nextstep/courses/domain/CoverImageTest.java @@ -0,0 +1,52 @@ +package nextstep.courses.domain; + +import nextstep.courses.InvalidCoverImageException; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.AssertionsForClassTypes.assertThatCode; +import static org.assertj.core.api.AssertionsForClassTypes.assertThatThrownBy; + +public class CoverImageTest { + + @Test + void 유효한_커버이미지는_예외없이_생성되고_검증_통과() { + assertThatCode(() -> + new CoverImage(600, 400, 500_000, ImageExtension.JPG) + ).doesNotThrowAnyException(); + } + + @Test + void 크기가_1MB_초과하면_예외발생() { + assertThatThrownBy(() -> + new CoverImage(600, 400, 2_000_000, ImageExtension.PNG) + ).isInstanceOf(InvalidCoverImageException.class); + } + + @Test + void 너비가_300미만이면_예외발생() { + assertThatThrownBy(() -> + new CoverImage(299, 400, 500_000, ImageExtension.PNG) + ).isInstanceOf(InvalidCoverImageException.class); + } + + @Test + void 높이가_200미만이면_예외발생() { + assertThatThrownBy(() -> + new CoverImage(600, 199, 500_000, ImageExtension.PNG) + ).isInstanceOf(InvalidCoverImageException.class); + } + + @Test + void 비율이_3대2_아니면_예외발생() { + assertThatThrownBy(() -> + new CoverImage(600, 500, 500_000, ImageExtension.PNG) + ).isInstanceOf(InvalidCoverImageException.class); + } + + @Test + void 지원하지_않는_확장자면_예외발생() { + assertThatThrownBy(() -> + new CoverImage(600, 400, 500_000, null) + ).isInstanceOf(InvalidCoverImageException.class); + } +} diff --git a/src/test/java/nextstep/courses/domain/SessionTest.java b/src/test/java/nextstep/courses/domain/SessionTest.java new file mode 100644 index 0000000000..954cc4d4a5 --- /dev/null +++ b/src/test/java/nextstep/courses/domain/SessionTest.java @@ -0,0 +1,42 @@ +package nextstep.courses.domain; + +import nextstep.courses.CannotEnrollSessionException; +import nextstep.payments.domain.PaidEnrollmentPolicy; +import org.junit.jupiter.api.Test; + +import java.time.LocalDate; + +import static nextstep.payments.PaymentTest.PAYMENT_1000; +import static org.assertj.core.api.AssertionsForClassTypes.assertThatCode; +import static org.assertj.core.api.AssertionsForClassTypes.assertThatThrownBy; + +public class SessionTest { + + public static final Session ReadySession = new Session( + LocalDate.of(2025, 1, 1), + LocalDate.of(2025, 12, 31), + new CoverImage(600, 400, 500_000, ImageExtension.JPG), + SessionStatus.READY, + new PaidEnrollmentPolicy(100, 1000)); + + public static final Session EnrollingSession = new Session( + LocalDate.of(2025, 1, 1), + LocalDate.of(2025, 12, 31), + new CoverImage(600, 400, 500_000, ImageExtension.JPG), + SessionStatus.ENROLLING, + new PaidEnrollmentPolicy(100, 1000)); + + @Test + void 모집중이_아닐_때_수강신청_불가능() { + assertThatThrownBy(() -> + ReadySession.enroll(PAYMENT_1000) + ).isInstanceOf(CannotEnrollSessionException.class); + } + + @Test + void 모집중일_때_수강신청_가능() { + assertThatCode(() -> EnrollingSession.enroll(PAYMENT_1000)) + .doesNotThrowAnyException(); + + } +} diff --git a/src/test/java/nextstep/payments/EnrollmentPolicyTest.java b/src/test/java/nextstep/payments/EnrollmentPolicyTest.java new file mode 100644 index 0000000000..62648f0545 --- /dev/null +++ b/src/test/java/nextstep/payments/EnrollmentPolicyTest.java @@ -0,0 +1,40 @@ +package nextstep.payments; + +import nextstep.payments.domain.EnrollmentPolicy; +import nextstep.payments.domain.FreeEnrollmentPolicy; +import nextstep.payments.domain.PaidEnrollmentPolicy; +import org.junit.jupiter.api.Test; + +import static nextstep.payments.PaymentTest.PAYMENT_1000; +import static nextstep.payments.PaymentTest.PAYMENT_FREE; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +public class EnrollmentPolicyTest { + + @Test + void 무료_강의_최대_수강_인원_제한이_없다() { + EnrollmentPolicy enrollmentPolicy = new FreeEnrollmentPolicy(); + assertTrue(enrollmentPolicy.canEnroll(9999, PAYMENT_FREE)); + } + + @Test + void 유료_강의_최대_수강_인원_제한이_있다() { + EnrollmentPolicy enrollmentPolicy = new PaidEnrollmentPolicy(10, 1000); + assertTrue(enrollmentPolicy.canEnroll(3, PAYMENT_1000)); + assertFalse(enrollmentPolicy.canEnroll(10, PAYMENT_1000)); + } + + @Test + void 유료_강의_수강료가_같으면_수강할_수_있다() { + EnrollmentPolicy enrollmentPolicy = new PaidEnrollmentPolicy(10, 1000); + assertTrue(enrollmentPolicy.canEnroll(3, PAYMENT_1000)); + } + + @Test + void 유료_강의_수강료가_다르면_수강할_수_없다() { + EnrollmentPolicy enrollmentPolicy = new PaidEnrollmentPolicy(10, 5000); + assertFalse(enrollmentPolicy.canEnroll(3, PAYMENT_1000)); + } + +} diff --git a/src/test/java/nextstep/payments/PaymentTest.java b/src/test/java/nextstep/payments/PaymentTest.java new file mode 100644 index 0000000000..b326aaea51 --- /dev/null +++ b/src/test/java/nextstep/payments/PaymentTest.java @@ -0,0 +1,28 @@ +package nextstep.payments; + +import nextstep.payments.domain.Payment; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +public class PaymentTest { + + public static final Payment PAYMENT_FREE = new Payment( + "12345", 1L, 100L, 0L + ); + + public static final Payment PAYMENT_1000 = new Payment( + "12345", 1L, 100L, 1000L + ); + + @Test + void 결제금액이_같으면_true를_반환한다() { + assertTrue(PAYMENT_1000.isSameAmount(1000L)); + } + + @Test + void 결제금액이_다르면_false를_반환한다() { + assertFalse(PAYMENT_1000.isSameAmount(2000L)); + } +}