diff --git a/README.md b/README.md index 498506166..f3fdb5d71 100644 --- a/README.md +++ b/README.md @@ -6,4 +6,26 @@ * 모든 피드백을 완료하면 다음 단계를 도전하고 앞의 과정을 반복한다. ## 온라인 코드 리뷰 과정 -* [텍스트와 이미지로 살펴보는 온라인 코드 리뷰 과정](https://github.com/next-step/nextstep-docs/tree/master/codereview) \ No newline at end of file +* [텍스트와 이미지로 살펴보는 온라인 코드 리뷰 과정](https://github.com/next-step/nextstep-docs/tree/master/codereview) + +# 수강 신청 기능 요구사항 +### Course +과정(Course)은 기수 단위로 운영, 여러 개의 강의(Session)를 가질 수 있다. +### Session +강의는 시작일과 종료일을 가진다. (Period) +강의는 강의 커버 이미지 정보를 가진다. (Image) +### Image +이미지 크기는 1MB 이하여야 한다. +이미지 타입은 gif, jpg(jpeg 포함), png, svg만 허용한다. +이미지의 width는 300픽셀, height는 200픽셀 이상이어야 하며, width와 height의 비율은 3:2여야 한다. +### SessionType +강의는 무료 강의와 유료 강의로 나뉜다. +무료 강의는 최대 수강 인원 제한이 없다. +유료 강의는 강의 최대 수강 인원을 초과할 수 없다. +유료 강의는 수강생이 결제한 금액과 수강료가 일치할 때 수강 신청이 가능하다. +### SessionStatus +강의 상태는 준비중, 모집중, 종료 3가지 상태를 가진다. +강의 수강신청은 강의 상태가 모집중일 때만 가능하다. +유료 강의의 경우 결제는 이미 완료한 것으로 가정하고 이후 과정을 구현한다. +### Payment +결제를 완료한 결제 정보는 payments 모듈을 통해 관리되며, 결제 정보는 Payment 객체에 담겨 반한된다. diff --git a/src/main/java/nextstep/courses/domain/Course.java b/src/main/java/nextstep/courses/domain/Course.java index 0f6971604..2ede936eb 100644 --- a/src/main/java/nextstep/courses/domain/Course.java +++ b/src/main/java/nextstep/courses/domain/Course.java @@ -1,6 +1,7 @@ package nextstep.courses.domain; import java.time.LocalDateTime; +import java.util.List; public class Course { private Long id; @@ -13,6 +14,8 @@ public class Course { private LocalDateTime updatedAt; + private List sessionList; + public Course() { } diff --git a/src/main/java/nextstep/courses/domain/FreeSessionType.java b/src/main/java/nextstep/courses/domain/FreeSessionType.java new file mode 100644 index 000000000..c01c11150 --- /dev/null +++ b/src/main/java/nextstep/courses/domain/FreeSessionType.java @@ -0,0 +1,17 @@ +package nextstep.courses.domain; + +public class FreeSessionType implements SessionType { + + private static final int ZERO = 0; + + private final int pirce; + + public FreeSessionType() { + this.pirce = ZERO; + } + + public FreeSessionType registered() { + return new FreeSessionType(); + } + +} 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 000000000..d58e8c0fe --- /dev/null +++ b/src/main/java/nextstep/courses/domain/ImageExtension.java @@ -0,0 +1,5 @@ +package nextstep.courses.domain; + +public enum ImageExtension { + GIF, JPG, JPEG, PNG, SVG +} diff --git a/src/main/java/nextstep/courses/domain/PaidSessionType.java b/src/main/java/nextstep/courses/domain/PaidSessionType.java new file mode 100644 index 000000000..0bca64d46 --- /dev/null +++ b/src/main/java/nextstep/courses/domain/PaidSessionType.java @@ -0,0 +1,29 @@ +package nextstep.courses.domain; + +import nextstep.payments.domain.Payment; + +public class PaidSessionType implements SessionType { + + private int maximumHeadCount; + private long price; + + public PaidSessionType(int maximumHeadCount, long price) { + this.maximumHeadCount = maximumHeadCount; + this.price = price; + } + + private void canRegistered(long payedPrice, int headCount) { + int nextHeadCount = headCount + 1; + if (maximumHeadCount < nextHeadCount) { + throw new IllegalArgumentException("인원수가 초과했습니다."); + } + if (payedPrice < this.price) { + throw new IllegalArgumentException("지불하신 금액이 모자랍니다."); + } + } + + public PaidSessionType registered(long payedPrice, int headCount) { + canRegistered(payedPrice, headCount); + return new PaidSessionType(headCount + 1, this.price); + } +} 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 000000000..3d063cc99 --- /dev/null +++ b/src/main/java/nextstep/courses/domain/Session.java @@ -0,0 +1,13 @@ +package nextstep.courses.domain; + +public class Session { + + private Long id; + private SessionPeriod period; + private SessionImage image; + private SessionType type; + private SessionStatus status; + private int price; + private int headCount; + +} diff --git a/src/main/java/nextstep/courses/domain/SessionImage.java b/src/main/java/nextstep/courses/domain/SessionImage.java new file mode 100644 index 000000000..3a803b651 --- /dev/null +++ b/src/main/java/nextstep/courses/domain/SessionImage.java @@ -0,0 +1,28 @@ +package nextstep.courses.domain; + +public class SessionImage { + + private static final int ONE_MEGA_BYTE = 1_000_000; + + private Integer capacity; + private ImageExtension extension; + private SessionImageSize size; + + public SessionImage(int capacity, ImageExtension extension, int width, int height) { + validate(capacity); + this.capacity = capacity; + this.extension = extension; + this.size = new SessionImageSize(width, height); + } + + private void validate(int capacity) { + capacityValidate(capacity); + } + + private void capacityValidate(int capacity) { + if (ONE_MEGA_BYTE < capacity) { + throw new IllegalArgumentException(" 이미지 용량은 1MB 이하만 가능합니다. "); + } + } + +} diff --git a/src/main/java/nextstep/courses/domain/SessionImageSize.java b/src/main/java/nextstep/courses/domain/SessionImageSize.java new file mode 100644 index 000000000..3f5a21640 --- /dev/null +++ b/src/main/java/nextstep/courses/domain/SessionImageSize.java @@ -0,0 +1,57 @@ +package nextstep.courses.domain; + +public class SessionImageSize { + + private static final int WIDTH_RATIO = 3; + private static final int HEIGHT_RATIO = 2; + private static final int WIDTH_MAXIMUM = 300; + private static final int HEIGHT_MAXIMUM = 200; + + private int width; + private int height; + private String ratio; + + public SessionImageSize(int width, int height) { + validation(width, height); + this.width = width; + this.height = height; + this.ratio = calculateRatio(); + } + + private void validation(int width, int height) { + widthAndHeightValidate(width, height); + ratioValidate(width, height); + } + + private void ratioValidate(int width, int height) { + if (WIDTH_RATIO != calculaateWidthRatio(width) && HEIGHT_RATIO != calculaateHeightRatio(height)) { + throw new IllegalArgumentException(" 이미지 비율을 3:2만 가능합니다."); + } + } + + private void widthAndHeightValidate(int width, int height) { + if (width < WIDTH_MAXIMUM || height < HEIGHT_MAXIMUM) { + throw new IllegalArgumentException(" 이미지의 크기는 세로 300, 가로 200 픽셀 이상만 가능합니다. "); + } + } + + private String calculateRatio() { + return calculaateWidthRatio(width) + ":" + calculaateHeightRatio(height); + } + + private int calculaateWidthRatio(int width) { + int divided = width / 100; + if(WIDTH_RATIO != divided){ + divided = divided / WIDTH_RATIO; + } + return divided; + } + + private int calculaateHeightRatio(int height) { + int divided = height / 100; + if(HEIGHT_RATIO != divided){ + divided = divided / HEIGHT_RATIO; + } + return divided; + } +} diff --git a/src/main/java/nextstep/courses/domain/SessionPeriod.java b/src/main/java/nextstep/courses/domain/SessionPeriod.java new file mode 100644 index 000000000..9a569fec2 --- /dev/null +++ b/src/main/java/nextstep/courses/domain/SessionPeriod.java @@ -0,0 +1,11 @@ +package nextstep.courses.domain; + +import java.time.LocalDateTime; + +public class SessionPeriod { + + private LocalDateTime startAt; + + private LocalDateTime endAt; + +} 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 000000000..13c35628b --- /dev/null +++ b/src/main/java/nextstep/courses/domain/SessionStatus.java @@ -0,0 +1,5 @@ +package nextstep.courses.domain; + +public enum SessionStatus { + PREPARING, RECRUITING, END +} diff --git a/src/main/java/nextstep/courses/domain/SessionType.java b/src/main/java/nextstep/courses/domain/SessionType.java new file mode 100644 index 000000000..543c66449 --- /dev/null +++ b/src/main/java/nextstep/courses/domain/SessionType.java @@ -0,0 +1,4 @@ +package nextstep.courses.domain; + +public interface SessionType { +} diff --git a/src/main/java/nextstep/qna/domain/Answer.java b/src/main/java/nextstep/qna/domain/Answer.java index f7d9c7f5b..c71c6a241 100644 --- a/src/main/java/nextstep/qna/domain/Answer.java +++ b/src/main/java/nextstep/qna/domain/Answer.java @@ -79,9 +79,9 @@ public String toString() { } public Answer delete(NsUser writer) throws CannotDeleteException { - if (!this.isOwner(writer)) { + if (!isOwner(writer)) { throw new CannotDeleteException("다른 사람이 쓴 답변이 있어 삭제할 수 없습니다."); } - return this.setDeleted(true); + return setDeleted(true); } } diff --git a/src/main/java/nextstep/qna/domain/Question.java b/src/main/java/nextstep/qna/domain/Question.java index f1ecac7ea..a10d29472 100644 --- a/src/main/java/nextstep/qna/domain/Question.java +++ b/src/main/java/nextstep/qna/domain/Question.java @@ -1,13 +1,14 @@ package nextstep.qna.domain; -import nextstep.qna.CannotDeleteException; -import nextstep.users.domain.NsUser; - import java.time.LocalDateTime; import java.util.ArrayList; import java.util.List; +import java.util.stream.Collectors; +import nextstep.qna.CannotDeleteException; +import nextstep.users.domain.NsUser; public class Question { + private Long id; private String title; @@ -88,13 +89,41 @@ public List getAnswers() { @Override public String toString() { - return "Question [id=" + getId() + ", title=" + title + ", contents=" + contents + ", writer=" + writer + "]"; + return "Question [id=" + getId() + ", title=" + title + ", contents=" + contents + + ", writer=" + writer + "]"; } - public Question delete(NsUser writer) throws CannotDeleteException { + private Question deleteQuestion(NsUser writer) throws CannotDeleteException { if (!this.isOwner(writer)) { throw new CannotDeleteException("질문을 삭제할 권한이 없습니다."); } return this.setDeleted(true); } + + private List deleteAnswersOfThisQuestion(NsUser writer) { + return getAnswers().stream().map(answer -> { + try { + return answer.delete(writer); + } catch (CannotDeleteException e) { + throw new RuntimeException(e); + } + }).collect(Collectors.toList()); + } + + public List deleteQuestionAndRelatedAnswer(NsUser writer, LocalDateTime now) + throws CannotDeleteException { + Question question = deleteQuestion(writer); + List answers = deleteAnswersOfThisQuestion(writer); + + List deleteHistories = new ArrayList<>(); + deleteHistories.add( + new DeleteHistory(ContentType.QUESTION, question.getId(), question.getWriter(), now)); + + for (Answer answer : answers) { + deleteHistories.add( + new DeleteHistory(ContentType.ANSWER, answer.getId(), answer.getWriter(), now)); + } + + return deleteHistories; + } } diff --git a/src/main/java/nextstep/qna/service/DeleteHistoryService.java b/src/main/java/nextstep/qna/service/DeleteHistoryService.java index 53ce9e6f0..88cbbbbaf 100644 --- a/src/main/java/nextstep/qna/service/DeleteHistoryService.java +++ b/src/main/java/nextstep/qna/service/DeleteHistoryService.java @@ -1,21 +1,16 @@ package nextstep.qna.service; -import java.time.LocalDateTime; -import java.util.ArrayList; -import nextstep.qna.domain.Answer; -import nextstep.qna.domain.ContentType; +import java.util.List; +import javax.annotation.Resource; import nextstep.qna.domain.DeleteHistory; import nextstep.qna.domain.DeleteHistoryRepository; -import nextstep.qna.domain.Question; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Propagation; import org.springframework.transaction.annotation.Transactional; -import javax.annotation.Resource; -import java.util.List; - @Service("deleteHistoryService") public class DeleteHistoryService { + @Resource(name = "deleteHistoryRepository") private DeleteHistoryRepository deleteHistoryRepository; @@ -24,16 +19,4 @@ public void saveAll(List deleteHistories) { deleteHistoryRepository.saveAll(deleteHistories); } - public void saveAll(Question question, List answers) { - List deleteHistories = new ArrayList<>(); - deleteHistories.add( - new DeleteHistory(ContentType.QUESTION, question.getId(), question.getWriter(), - LocalDateTime.now())); - for (Answer answer : answers) { - deleteHistories.add( - new DeleteHistory(ContentType.ANSWER, answer.getId(), answer.getWriter(), - LocalDateTime.now())); - } - this.saveAll(deleteHistories); - } } diff --git a/src/main/java/nextstep/qna/service/QnAService.java b/src/main/java/nextstep/qna/service/QnAService.java index ac06d89a5..807ae633a 100644 --- a/src/main/java/nextstep/qna/service/QnAService.java +++ b/src/main/java/nextstep/qna/service/QnAService.java @@ -1,13 +1,12 @@ package nextstep.qna.service; +import java.time.LocalDateTime; import java.util.List; -import java.util.stream.Collectors; import javax.annotation.Resource; import nextstep.qna.CannotDeleteException; import nextstep.qna.NotFoundException; -import nextstep.qna.domain.Answer; import nextstep.qna.domain.AnswerRepository; -import nextstep.qna.domain.Question; +import nextstep.qna.domain.DeleteHistory; import nextstep.qna.domain.QuestionRepository; import nextstep.users.domain.NsUser; import org.springframework.stereotype.Service; @@ -27,18 +26,10 @@ public class QnAService { @Transactional public void deleteQuestion(NsUser loginUser, long questionId) throws CannotDeleteException { - Question deletedQuestion = questionRepository.findById(questionId) + List deleteHistories = questionRepository.findById(questionId) .orElseThrow(NotFoundException::new) - .delete(loginUser); + .deleteQuestionAndRelatedAnswer(loginUser, LocalDateTime.now()); - List deletedAnswers = deletedQuestion.getAnswers().stream().map(answer -> { - try { - return answer.delete(loginUser); - } catch (CannotDeleteException e) { - throw new RuntimeException(e); - } - }).collect(Collectors.toList()); - - deleteHistoryService.saveAll(deletedQuestion, deletedAnswers); + deleteHistoryService.saveAll(deleteHistories); } } diff --git a/src/test/java/nextstep/courses/domain/SessionImageTest.java b/src/test/java/nextstep/courses/domain/SessionImageTest.java new file mode 100644 index 000000000..80d2e6ffb --- /dev/null +++ b/src/test/java/nextstep/courses/domain/SessionImageTest.java @@ -0,0 +1,71 @@ +package nextstep.courses.domain; + +import static org.assertj.core.api.Assertions.assertThatIllegalArgumentException; +import static org.assertj.core.api.Assertions.assertThatNoException; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +class SessionImageTest { + + private static final int NORMAL_IMAGE_CAPACITY = 1_000_000; + private static final int NORMAL_IMAGE_WIDTH = 300; + private static final int NORMAL_IMAGE_HEIGHT = 200; + + @DisplayName("강의 이미지 용량이 1000000 이하이면 정상") + @Test + void a() { + int imageCapacity = 1_000_000; + ImageExtension imageExtension = ImageExtension.JPEG; + assertThatNoException().isThrownBy(() -> { + new SessionImage(imageCapacity, imageExtension, NORMAL_IMAGE_WIDTH, NORMAL_IMAGE_HEIGHT); + }); + } + + @DisplayName("강의 이미지 용량이 1MB 이상이면 예외") + @Test + void a1() { + int imageCapacity = 100_000_000; + ImageExtension imageExtension = ImageExtension.JPEG; + assertThatIllegalArgumentException().isThrownBy(() -> { + new SessionImage(imageCapacity, imageExtension, NORMAL_IMAGE_WIDTH, NORMAL_IMAGE_HEIGHT); + }); + } + + @DisplayName("강의 이미지 크기는 300x200 이상이면 정상") + @Test + void b() { + ImageExtension imageExtension = ImageExtension.JPEG; + assertThatNoException().isThrownBy(() -> { + new SessionImage(NORMAL_IMAGE_CAPACITY, imageExtension, 699, 499); + }); + } + + @DisplayName("강의 이미지 크기는 300x200 이하이면 예외") + @Test + void b1() { + ImageExtension imageExtension = ImageExtension.JPEG; + assertThatIllegalArgumentException().isThrownBy(() -> { + new SessionImage(NORMAL_IMAGE_CAPACITY, imageExtension, 299, 199); + }); + } + + @DisplayName("강의 이미지 비율은 3:2가 정상") + @Test + void c() { + ImageExtension imageExtension = ImageExtension.JPEG; + assertThatNoException().isThrownBy(() -> { + new SessionImage(NORMAL_IMAGE_CAPACITY, imageExtension, 300, 200); + }); + } + + @DisplayName("강의 이미지 비율은 3:2가 아니면 예외") + @Test + void c1() { + ImageExtension imageExtension = ImageExtension.JPEG; + assertThatIllegalArgumentException().isThrownBy(() -> { + new SessionImage(NORMAL_IMAGE_CAPACITY, imageExtension, 2, 200); + }); + } + +} diff --git a/src/test/java/nextstep/courses/domain/SessionTypeTest.java b/src/test/java/nextstep/courses/domain/SessionTypeTest.java new file mode 100644 index 000000000..a8caee12a --- /dev/null +++ b/src/test/java/nextstep/courses/domain/SessionTypeTest.java @@ -0,0 +1,42 @@ +package nextstep.courses.domain; + +import static org.assertj.core.api.Assertions.assertThatIllegalArgumentException; +import static org.assertj.core.api.Assertions.assertThatNoException; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +public class SessionTypeTest { + + @DisplayName("무료 강의는 누구나 수강신청이 가능합니다.") + @Test + void a() { + FreeSessionType freeSessionType = new FreeSessionType(); + assertThatNoException().isThrownBy(freeSessionType::registered); + } + + @DisplayName("2명이 최대 정원인 유료 강의는 최대 수강 인원을 초과할 수 없습니다.") + @Test + void b() { + long pirce = 10_000; + int maximumHeadCount = 100; + PaidSessionType paidSessionType = new PaidSessionType(maximumHeadCount, pirce); + + assertThatNoException().isThrownBy(() -> paidSessionType.registered(pirce, 99)); + assertThatIllegalArgumentException().isThrownBy(() -> paidSessionType.registered(pirce, 100)); + assertThatIllegalArgumentException().isThrownBy(() -> paidSessionType.registered(pirce, 101)); + } + + @DisplayName("유료 강의는 결제금이 강의료와 같아야 수강신청이 가능합니다.") + @Test + void c() { + long pirce = 10_000; + int maximumHeadCount = 2; + PaidSessionType paidSessionType = new PaidSessionType(maximumHeadCount, pirce); + + assertThatNoException().isThrownBy(() -> paidSessionType.registered(pirce, 1)); + assertThatIllegalArgumentException().isThrownBy(() -> paidSessionType.registered(1000, 1)); + } + + +} diff --git a/src/test/java/nextstep/qna/domain/QuestionTest.java b/src/test/java/nextstep/qna/domain/QuestionTest.java index 750f78b6f..a0fa52b23 100644 --- a/src/test/java/nextstep/qna/domain/QuestionTest.java +++ b/src/test/java/nextstep/qna/domain/QuestionTest.java @@ -4,10 +4,10 @@ import static org.assertj.core.api.Assertions.assertThatNoException; import static org.assertj.core.api.Assertions.assertThatThrownBy; +import java.time.LocalDateTime; import nextstep.qna.CannotDeleteException; import nextstep.users.domain.NsUser; import nextstep.users.domain.NsUserTest; -import org.assertj.core.api.Assertions; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; @@ -20,7 +20,7 @@ public class QuestionTest { @Test void canDeleteIfYouHavePermission() throws CannotDeleteException { NsUser writer = Q1.getWriter(); - assertThatNoException().isThrownBy(() -> Q1.delete(writer)); + assertThatNoException().isThrownBy(() -> Q1.deleteQuestionAndRelatedAnswer(writer, LocalDateTime.now())); assertThat(Q1.isDeleted()).isTrue(); } @@ -28,7 +28,7 @@ void canDeleteIfYouHavePermission() throws CannotDeleteException { @Test void canNotDeleteIfYouDontHavePermission() throws CannotDeleteException { NsUser writer = Q1.getWriter(); - assertThatThrownBy(() -> Q2.delete(writer)).isInstanceOf(CannotDeleteException.class); + assertThatThrownBy(() -> Q2.deleteQuestionAndRelatedAnswer(writer, LocalDateTime.now())).isInstanceOf(CannotDeleteException.class); assertThat(Q2.isDeleted()).isFalse(); }