diff --git a/README.md b/README.md index 41b56cfc4a..8bbf1de40f 100644 --- a/README.md +++ b/README.md @@ -21,4 +21,26 @@ * nextstep.qna.service.QnaService의 deleteQuestion()는 앞의 질문 삭제 기능을 구현한 코드이다. 이 메소드는 단위 테스트하기 어려운 코드와 단위 테스트 가능한 코드가 섞여 있다. * QnaService의 deleteQuestion() 메서드에 단위 테스트 가능한 코드(핵심 비지니스 로직)를 도메인 모델 객체에 구현한다. * QnaService의 비지니스 로직을 도메인 모델로 이동하는 리팩터링을 진행할 때 TDD로 구현한다. -* QnaService의 deleteQuestion() 메서드에 대한 단위 테스트는 src/test/java 폴더 nextstep.qna.service.QnaServiceTest이다. 도메인 모델로 로직을 이동한 후에도 QnaServiceTest의 모든 테스트는 통과해야 한다. \ No newline at end of file +* QnaService의 deleteQuestion() 메서드에 대한 단위 테스트는 src/test/java 폴더 nextstep.qna.service.QnaServiceTest이다. 도메인 모델로 로직을 이동한 후에도 QnaServiceTest의 모든 테스트는 통과해야 한다. + +## 수강 신청 기능 요구사항 +* 과정(Course)은 기수 단위로 운영하며, 여러 개의 강의(Session)를 가질 수 있다. +* 강의는 시작일과 종료일을 가진다. +* 강의는 강의 커버 이미지 정보를 가진다. + * 이미지 크기는 1MB 이하여야 한다. + * 이미지 타입은 gif, jpg(jpeg 포함),, png, svg만 허용한다. + * 이미지의 width는 300픽셀, height는 200픽셀 이상이어야 하며, width와 height의 비율은 3:2여야 한다. +* 강의는 무료 강의와 유료 강의로 나뉜다. + * 무료 강의는 최대 수강 인원 제한이 없다. + * 유료 강의는 강의 최대 수강 인원을 초과할 수 없다. + * 유료 강의는 수강생이 결제한 금액과 수강료가 일치할 때 수강 신청이 가능하다. +* 강의 상태는 준비중, 모집중, 종료 3가지 상태를 가진다. +* 강의 수강신청은 강의 상태가 모집중일 때만 가능하다. +* 유료 강의의 경우 결제는 이미 완료한 것으로 가정하고 이후 과정을 구현한다. (???) + * 결제를 완료한 결제 정보는 payments 모듈을 통해 관리되며, 결제 정보는 Payment 객체에 담겨 반한된다. + +## 프로그래밍 요구사항 +* DB 테이블 설계 없이 도메인 모델부터 구현한다. +* 도메인 모델은 TDD로 구현한다. +* 단, Service 클래스는 단위 테스트가 없어도 된다. +* 다음 동영상을 참고해 DB 테이블보다 도메인 모델을 먼저 설계하고 구현한다. \ No newline at end of file diff --git a/src/main/java/nextstep/courses/domain/Conditional.java b/src/main/java/nextstep/courses/domain/Conditional.java new file mode 100644 index 0000000000..d3132305ff --- /dev/null +++ b/src/main/java/nextstep/courses/domain/Conditional.java @@ -0,0 +1,5 @@ +package nextstep.courses.domain; + +public interface Conditional { + public boolean test(int currentNum); +} diff --git a/src/main/java/nextstep/courses/domain/Course.java b/src/main/java/nextstep/courses/domain/Course.java index 0f69716043..0bae3dabdf 100644 --- a/src/main/java/nextstep/courses/domain/Course.java +++ b/src/main/java/nextstep/courses/domain/Course.java @@ -4,16 +4,25 @@ public class Course { private Long id; - private String title; - private Long creatorId; - private LocalDateTime createdAt; - private LocalDateTime updatedAt; + private int classNo; + private Sessions sessions; + + public int getClassNo() { + return classNo; + } + + public Sessions getSessions() { + return this.sessions; + } + public Course() { + classNo = 0; + sessions = new Sessions(); } public Course(String title, Long creatorId) { 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..42a18d196d --- /dev/null +++ b/src/main/java/nextstep/courses/domain/CoverImage.java @@ -0,0 +1,40 @@ +package nextstep.courses.domain; + +public class CoverImage { + + private int width; + private int height; + private ImageType imageType; + private long size; + + CoverImage(){ + + } + + public CoverImage(String imageType, long size, int width, int height) { + if (size > 1_000) { + throw new IllegalArgumentException("이미지 크기는 1MB 이하여야 합니다."); + } + if (width < 300) { + throw new IllegalArgumentException("Width는 300픽셀 이상이여야 합니다."); + } + if (height < 200) { + throw new IllegalArgumentException("Height는 200픽셀 이상이여야 합니다."); + } + if ((double) width / (double) height < 1.5) { + throw new IllegalArgumentException("Width와 Height의 비율은 3:2여야 합니다."); + } + this.size = size; + this.width = width; + this.height = height; + this.imageType = ImageType.getCoverImageType(imageType); + } + + public long getSize() { + return size; + } + + public ImageType getImageType() { + return this.imageType; + } +} diff --git a/src/main/java/nextstep/courses/domain/ImageType.java b/src/main/java/nextstep/courses/domain/ImageType.java new file mode 100644 index 0000000000..d95971d2e8 --- /dev/null +++ b/src/main/java/nextstep/courses/domain/ImageType.java @@ -0,0 +1,35 @@ +package nextstep.courses.domain; + +import java.util.Arrays; + +public enum ImageType { + + GIF("GIF"), + JPG("JPG"), + JPEG("JPEG"), + PNG("PNG"), + SVG("SVG"); + + private String imageType; + + ImageType(String imageType) { + this.imageType = imageType; + } + + public static ImageType getCoverImageType(String imageType) { + imageType = imageType.toUpperCase(); + isCoverImageType(imageType); + return valueOf(imageType); + } + + public String getImageType() { + return imageType; + } + + public static boolean isCoverImageType(String imageType) { + return Arrays.stream(values()) + .anyMatch(type -> type.getImageType().equals(imageType.toUpperCase())); + } + + +} 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..c4f70b7c00 --- /dev/null +++ b/src/main/java/nextstep/courses/domain/Session.java @@ -0,0 +1,85 @@ +package nextstep.courses.domain; + +import nextstep.users.domain.NsUser; + +import java.math.BigDecimal; +import java.time.LocalDate; +import java.util.ArrayList; +import java.util.List; + +public class Session { + + private final CoverImage coverImage; + private final LocalDate startDt; + private final LocalDate endDt; + + private int capacity; + private Conditional conditional; + private BigDecimal cost; + private SessionStatus status; + private List students; + + public Session() { + this(new CoverImage(), LocalDate.now(), LocalDate.now()); + } + + public Session(CoverImage coverImage, LocalDate startDt, LocalDate endDt) { + this.coverImage = coverImage; + this.startDt = startDt; + this.endDt = endDt; + this.status = SessionStatus.READY; + this.students = new ArrayList<>(); + } + + public void asFree() { + this.capacity = Integer.MAX_VALUE; + this.cost = BigDecimal.ZERO; + conditional = (currentNum) -> true; + } + + public void asPaid(int capacity, BigDecimal cost) { + this.capacity = capacity; + this.cost = cost; + conditional = (currentNum) -> currentNum < this.capacity; + } + + public boolean isAvailable() { + return conditional.test(students.size()); + } + + public boolean isExactCost(BigDecimal cost) { + return cost.compareTo(this.cost) == 0; + } + + public CoverImage getCoverImage() { + return this.coverImage; + } + + public LocalDate getStartDt() { + return startDt; + } + + public LocalDate getEndDt() { + return endDt; + } + + public SessionStatus getSessionStatus() { + return status; + } + + public void apply(NsUser nsUser) { + SessionStatus.isReady(status); + + if (this.isAvailable()) { + students.add(nsUser); + } + } + + public void open() { + status = SessionStatus.open(); + } + + public void close() { + status = SessionStatus.close(); + } +} 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..88ad5c8605 --- /dev/null +++ b/src/main/java/nextstep/courses/domain/SessionStatus.java @@ -0,0 +1,19 @@ +package nextstep.courses.domain; + +public enum SessionStatus { + READY, OPEN, CLOSE; + + public static void isReady(SessionStatus status) { + if(!status.equals(READY)){ + throw new IllegalArgumentException("강의 수강신청은 강의 상태가 모집중일 때만 가능합니다"); + } + } + + public static SessionStatus open(){ + return OPEN; + } + + public static SessionStatus close(){ + return CLOSE; + } +} 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..a1170fe679 --- /dev/null +++ b/src/main/java/nextstep/courses/domain/Sessions.java @@ -0,0 +1,16 @@ +package nextstep.courses.domain; + +import java.util.ArrayList; +import java.util.List; + +public class Sessions { + private List sessions; + + public Sessions() { + sessions = new ArrayList<>(); + } + + public List getSessions() { + return sessions; + } +} diff --git a/src/main/java/nextstep/qna/domain/Question.java b/src/main/java/nextstep/qna/domain/Question.java index 77b4ae04e2..e1c83daa0f 100644 --- a/src/main/java/nextstep/qna/domain/Question.java +++ b/src/main/java/nextstep/qna/domain/Question.java @@ -1,5 +1,6 @@ package nextstep.qna.domain; +import nextstep.qna.CannotDeleteException; import nextstep.users.domain.NsUser; import java.time.LocalDateTime; @@ -90,7 +91,12 @@ public boolean hasOthersAnswers() { return answers.hasOthersAnswers(writer); } - public List delete() { + public List delete() throws CannotDeleteException { + + if(this.hasOthersAnswers()){ + throw new CannotDeleteException("다른 사람이 쓴 답변이 있어 삭제할 수 없습니다."); + } + List deleteHistories = new ArrayList<>(); deleteHistories.add(new DeleteHistory(this)); if(!answers.isEmpty()) { @@ -100,4 +106,7 @@ public List delete() { return deleteHistories; } + public boolean isNotOwner(NsUser loginUser) { + return !this.isOwner(loginUser); + } } diff --git a/src/main/java/nextstep/qna/service/QnAService.java b/src/main/java/nextstep/qna/service/QnAService.java index f365c1e626..fbcc299c80 100644 --- a/src/main/java/nextstep/qna/service/QnAService.java +++ b/src/main/java/nextstep/qna/service/QnAService.java @@ -28,14 +28,10 @@ public void deleteQuestion(NsUser loginUser, long questionId) throws CannotDelet Question question = questionRepository.findById(questionId).orElseThrow(NotFoundException::new); - if (!question.isOwner(loginUser)) { + if (question.isNotOwner(loginUser)) { throw new CannotDeleteException("질문을 삭제할 권한이 없습니다."); } - if(question.hasOthersAnswers()){ - throw new CannotDeleteException("다른 사람이 쓴 답변이 있어 삭제할 수 없습니다."); - } - deleteHistoryService.saveAll(question.delete()); } } diff --git a/src/test/java/nextstep/courses/domain/CourseTest.java b/src/test/java/nextstep/courses/domain/CourseTest.java new file mode 100644 index 0000000000..583173a155 --- /dev/null +++ b/src/test/java/nextstep/courses/domain/CourseTest.java @@ -0,0 +1,22 @@ +package nextstep.courses.domain; + +import org.junit.jupiter.api.Test; + +import java.math.BigDecimal; + +import static org.assertj.core.api.Assertions.*; + +class CourseTest { + + Course course = new Course(); + + @Test + void 기수를_가진다() { + assertThat(course.getClassNo()).isNotNull(); + } + + @Test + void 여러개의_강의를_가진다() { + assertThat(course.getSessions()).isNotNull(); + } +} 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..2b4a801b11 --- /dev/null +++ b/src/test/java/nextstep/courses/domain/CoverImageTest.java @@ -0,0 +1,55 @@ +package nextstep.courses.domain; + +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.*; + +class CoverImageTest { + + @Test + void 이미지_크기는_1MB_이하() { + assertThat(new CoverImage("jpg", 1000, 300, 200).getSize()).isEqualTo(1000); + } + + @Test + void 이미지_크기_1MB_초과() { + assertThatThrownBy(() -> { + new CoverImage("jpg", 1100, 300, 200); + }).isInstanceOf(IllegalArgumentException.class); + } + + @Test + void 이미지_타입은_peg_입력_에러() { + assertThatThrownBy(() -> { + new CoverImage("peg", 300, 300, 200); + }).isInstanceOf(IllegalArgumentException.class); + } + + @Test + void 이미지_타입은_jpg_타입_성공() { + assertThat(new CoverImage("jpg", 300, 300, 200).getImageType()).isEqualTo(ImageType.JPG); + } + + @Test + void width_300_이상_height_200_이상() { + assertThat(new CoverImage("jpg", 300, 300, 200)).isNotNull(); + } + + @Test + void width_300_이하() { + assertThatThrownBy(() -> new CoverImage("jpg", 300, 299, 200)) + .isInstanceOf(IllegalArgumentException.class); + } + + @Test + void width_200_미만() { + assertThatThrownBy(() -> new CoverImage("jpg", 300, 300, 199)) + .isInstanceOf(IllegalArgumentException.class); + } + + @Test + void 비율이_3대2가_아닐때() { + assertThatThrownBy(() -> new CoverImage("jpg", 300, 300, 300)) + .isInstanceOf(IllegalArgumentException.class); + } +} \ No newline at end of file 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..2d8c8fa788 --- /dev/null +++ b/src/test/java/nextstep/courses/domain/SessionTest.java @@ -0,0 +1,76 @@ +package nextstep.courses.domain; + +import nextstep.users.domain.NsUser; +import org.junit.jupiter.api.Test; + +import java.math.BigDecimal; +import java.util.Arrays; + +import static org.assertj.core.api.Assertions.*; + +class SessionTest { + + Session session = new Session(); + + @Test + void 시작일을_가진다() { + assertThat(session.getStartDt()).isNotNull(); + } + + @Test + void 종료일을_가진다() { + assertThat(session.getEndDt()).isNotNull(); + } + + @Test + void 강의커버_이미지를_가진다() { + assertThat(session.getCoverImage()).isNotNull(); + } + + + @Test + void 유료강의() { + session.asPaid(1, BigDecimal.valueOf(1000)); + assertThat(session.isAvailable()).isTrue(); + session.apply(new NsUser()); + assertThat(session.isAvailable()).isFalse(); + } + + @Test + void 무료강의() { + session.asFree(); + assertThat(session.isAvailable()).isTrue(); + } + + @Test + void 수강생이_결제한_금액과_수강료가_일치(){ + session.asPaid(10, BigDecimal.valueOf(800_000)); + assertThat(session.isExactCost(BigDecimal.valueOf(800_000))).isTrue(); + } + + @Test + void 수강생이_결제한_금액과_수강료가_불일치(){ + session.asPaid(10, BigDecimal.valueOf(800_000)); + assertThat(session.isExactCost(BigDecimal.valueOf(600_000))).isFalse(); + } + + @Test + void 강의_상태는_준비중_상태를_가진다(){ + session.asFree(); + assertThat(session.getSessionStatus()).isEqualTo(SessionStatus.READY); + } + + @Test + void 강의_상태는_모집중_상태를_가진다(){ + session.asFree(); + session.open(); + assertThat(session.getSessionStatus()).isEqualTo(SessionStatus.OPEN); + } + + @Test + void 강의_상태는_종료_상태를_가진다(){ + session.asFree(); + session.close(); + assertThat(session.getSessionStatus()).isEqualTo(SessionStatus.CLOSE); + } +} \ 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 96a196f55f..06280ac869 100644 --- a/src/test/java/nextstep/qna/domain/AnswerTest.java +++ b/src/test/java/nextstep/qna/domain/AnswerTest.java @@ -11,8 +11,8 @@ public class AnswerTest { public static final Answer A2 = new Answer(NsUserTest.SANJIGI, QuestionTest.Q1, "Answers Contents2"); @Test - @DisplayName(value="delete 테스트") - void delete(){ + @DisplayName(value = "delete 테스트") + void delete() { A1.delete(); assertThat(A1.isDeleted()).isTrue(); } diff --git a/src/test/java/nextstep/qna/domain/AnswersTest.java b/src/test/java/nextstep/qna/domain/AnswersTest.java index 9ef8fe34ec..f0fc43564e 100644 --- a/src/test/java/nextstep/qna/domain/AnswersTest.java +++ b/src/test/java/nextstep/qna/domain/AnswersTest.java @@ -7,7 +7,7 @@ class AnswersTest { - NsUser writer = new NsUser(2L, "user1", "1234", "jhm9595","jhm@gmail.com"); + NsUser writer = new NsUser(2L, "user1", "1234", "jhm9595", "jhm@gmail.com"); Question question = new Question(writer, "Question title", "Question Content"); @@ -15,7 +15,7 @@ class AnswersTest { Answer answer = new Answer(writer, question, "Answer contents"); - NsUser otherWriter = new NsUser(3L, "user2", "1234", "jhm9595","jhm@gmail.com"); + NsUser otherWriter = new NsUser(3L, "user2", "1234", "jhm9595", "jhm@gmail.com"); Answer otherAnswer = new Answer(otherWriter, question, "Answer contents"); @Test diff --git a/src/test/java/nextstep/qna/domain/QuestionTest.java b/src/test/java/nextstep/qna/domain/QuestionTest.java index 90908436e7..f511fc02c4 100644 --- a/src/test/java/nextstep/qna/domain/QuestionTest.java +++ b/src/test/java/nextstep/qna/domain/QuestionTest.java @@ -1,8 +1,10 @@ package nextstep.qna.domain; +import nextstep.qna.CannotDeleteException; import nextstep.users.domain.NsUserTest; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; + import static org.assertj.core.api.Assertions.*; public class QuestionTest { @@ -10,8 +12,8 @@ public class QuestionTest { public static final Question Q2 = new Question(NsUserTest.SANJIGI, "title2", "contents2"); @Test - @DisplayName(value="delete 테스트") - void delete(){ + @DisplayName(value = "delete 테스트") + void delete() throws CannotDeleteException { Q1.delete(); assertThat(Q1.isDeleted()).isTrue(); }