Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

2단계 - 수강신청(도메인 모델) #656

Open
wants to merge 1 commit into
base: developer-shkim
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 23 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,14 +1,33 @@
# 학습 관리 시스템(Learning Management System)
## 기능 요구사항
- 질문 삭제 처리
- [x] 질문 삭제 처리
- 질문을 삭제하는 것이 아니라 질문의 삭제 상태를 true 로 변경하여 처리한다.
- 질문의 답변도 삭제 상태를 true 로 변경하여 함께 처리한다.
- 질문, 답변 삭제 이력 정보를 이력에 남긴다.
- 질문 삭제 가능 조건
- [x] 질문 삭제 가능 조건
- 로그인 사용자와 질문한 사람이 같은 경우
- 답변이 없는 경우
- 질문자와 답변글의 모든 답변자가 같은 경우
- [x] 과정(Course)은 기수 단위로 운영하며, 여러 개의 강의(Session)를 가질 수 있다.
- [x] 강의는 시작일과 종료일을 가진다.
- [x] 강의는 강의 커버 이미지 정보를 가진다.
- 이미지 크기는 1MB 이하여야 한다.
- 이미지 타입은 gif, jpg(jpeg 포함),, png, svg만 허용한다.
- 이미지의 width는 300픽셀, height는 200픽셀 이상이어야 하며, width와 height의 비율은 3:2여야 한다.
- [x] 강의는 무료 강의와 유료 강의로 나뉜다.
- [x] 무료 강의는 최대 수강 인원 제한이 없다.
- [x] 유료 강의는 강의 최대 수강 인원을 초과할 수 없다.
- [x] 유료 강의는 수강생이 결제한 금액과 수강료가 일치할 때 수강 신청이 가능하다.
- [x] 강의 상태는 준비중, 모집중, 종료 3가지 상태를 가진다.
- [x] 강의 수강신청은 강의 상태가 모집중일 때만 가능하다.
- [x] 유료 강의의 경우 결제는 이미 완료한 것으로 가정하고 이후 과정을 구현한다.
- 결제를 완료한 결제 정보는 payments 모듈을 통해 관리되며, 결제 정보는 Payment 객체에 담겨 반한된다.

## 리팩터링 요구사항
- QnaService.deleteQuestion 의 비즈니스 로직을 각 도메인 모델로 이동시키고 TDD로 구현한다.
- 리팩터링 후에도 QnaServiceTest 가 통과해야 한다.
- [x] QnaService.deleteQuestion 의 비즈니스 로직을 각 도메인 모델로 이동시키고 TDD로 구현한다.
- [x] 리팩터링 후에도 QnaServiceTest 가 통과해야 한다.

## 프로그래밍 요구사항
- DB 테이블 설계 없이 도메인 모델부터 구현한다.
- 도메인 모델은 TDD로 구현한다.
- 단, Service 클래스는 단위 테스트가 없어도 된다.
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
package nextstep.courses;

public class CannotRegisterSessionException extends RuntimeException {
public CannotRegisterSessionException(String message) {
super(message);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
package nextstep.courses;

public class InvalidCoverImageException extends RuntimeException {
public InvalidCoverImageException(String message) {
super(message);
}
}
5 changes: 5 additions & 0 deletions src/main/java/nextstep/courses/domain/Course.java
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package nextstep.courses.domain;

import java.time.LocalDateTime;
import java.util.List;

public class Course {
private Long id;
Expand All @@ -13,6 +14,10 @@ public class Course {

private LocalDateTime updatedAt;

private List<Session> sessions;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Session을 추가하는 add와 같은 메서드도 있어야 하지 않을까?


private int cardinal;

public Course() {
}

Expand Down
77 changes: 77 additions & 0 deletions src/main/java/nextstep/courses/domain/CoverImage.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
package nextstep.courses.domain;

import nextstep.courses.InvalidCoverImageException;

import javax.imageio.ImageIO;
import java.awt.image.BufferedImage;
import java.io.File;
import java.io.IOException;
import java.util.List;

public class CoverImage {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

👍

private static final int LIMIT_OF_BYTES = 1048576;
private static final int MINIMUM_OF_WIDTH_PIXEL = 300;
private static final int MINIMUM_OF_HEIGHT_PIXEL = 200;
private static final int RATIO_OF_WIDTH = 3;
private static final int RATIO_OF_HEIGHT = 2;
private static final List<String> VALID_EXTENSIONS = List.of("gif", "jpg", "jpeg", "png", "svg");
private String coverImage;

public CoverImage() {}

public CoverImage(String coverImage) throws IOException {
this.validate(coverImage);

this.coverImage = coverImage;
}

private void validate(String coverImage) throws IOException {
if (this.isInvalidExtension(coverImage)) {
throw new InvalidCoverImageException("유효하지 않은 확장자의 커버 이미지입니다.");
}

if (this.isInvalidVolume(coverImage)) {
throw new InvalidCoverImageException("유효하지 않은 용량의 커버 이미지입니다.");
}

if (this.isInvalidSize(coverImage)) {
throw new InvalidCoverImageException("유효하지 않은 크기의 커버 이미지입니다.");
}

if (this.isInvalidRatio(coverImage)) {
throw new InvalidCoverImageException("유효하지 않은 비율의 커버 이미지입니다.");
}
}

private boolean isInvalidExtension(String coverImage) {
return ! VALID_EXTENSIONS.contains(this.getExtension(coverImage));
}

private boolean isInvalidVolume(String coverImage) {
return new File(coverImage).length() > LIMIT_OF_BYTES;
}

private boolean isInvalidSize(String coverImage) throws IOException {
BufferedImage image = ImageIO.read(new File(coverImage));

if (image.getWidth() < MINIMUM_OF_WIDTH_PIXEL) {
return true;
}

return image.getHeight() < MINIMUM_OF_HEIGHT_PIXEL;
}

private boolean isInvalidRatio(String coverImage) throws IOException {
BufferedImage image = ImageIO.read(new File(coverImage));

return image.getHeight() / image.getWidth() != RATIO_OF_HEIGHT / RATIO_OF_WIDTH;
}

private String getExtension(String fileName) {
int lastIndexOfDot = fileName.lastIndexOf(".");
if (lastIndexOfDot == -1) {
return null;
}
return fileName.substring(lastIndexOfDot + 1);
}
}
25 changes: 25 additions & 0 deletions src/main/java/nextstep/courses/domain/FreeSession.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
package nextstep.courses.domain;

import nextstep.courses.CannotRegisterSessionException;

import java.time.LocalDateTime;

public class FreeSession extends Session {
public FreeSession(
LocalDateTime startDate, LocalDateTime endDate, CoverImage coverImage, SessionStatus status, int numberOfApplicants
) {
super(startDate, endDate, coverImage, 0L, status, numberOfApplicants);
}

public FreeSession(SessionStatus status) {
super(0L, status);
}

protected void register() {
if (status != SessionStatus.ACCEPTING) {
throw new CannotRegisterSessionException("강의가 등록할 수 있는 상태가 아닙니다.");
}

numberOfApplicants += 1;
}
}
39 changes: 39 additions & 0 deletions src/main/java/nextstep/courses/domain/PaidSession.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
package nextstep.courses.domain;

import nextstep.courses.CannotRegisterSessionException;

import java.time.LocalDateTime;

public class PaidSession extends Session {
int limitOfApplicants;

public PaidSession(
LocalDateTime startDate,
LocalDateTime endDate,
CoverImage coverImage,
Long price,
SessionStatus status,
int limitOfApplicants,
int numberOfApplicants
) {
super(startDate, endDate, coverImage, price, status, numberOfApplicants);
this.limitOfApplicants = limitOfApplicants;
}

public PaidSession(Long price, SessionStatus status) {
super(price, status);
}

@Override
protected void register() {
if (status != SessionStatus.ACCEPTING) {
throw new CannotRegisterSessionException("강의가 등록할 수 있는 상태가 아닙니다.");
}

if (limitOfApplicants == numberOfApplicants) {
throw new CannotRegisterSessionException("강의 수강 가능 인원을 초과하였습니다.");
}

numberOfApplicants += 1;
}
}
72 changes: 72 additions & 0 deletions src/main/java/nextstep/courses/domain/Session.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
package nextstep.courses.domain;

import nextstep.courses.CannotRegisterSessionException;
import nextstep.payments.domain.Payment;

import java.time.LocalDateTime;
import java.util.Objects;


public abstract class Session {
protected LocalDateTime startDate;

protected LocalDateTime endDate;
Comment on lines +11 to +13
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

이 두 개의 값은 서로 연관되어 있는 값이다.
이 두 개의 값을 가지는 객체를 분리하는 것은 어떨까?
분리한 후 시작일은 종료일보다 빨라야 한다와 같은 유효성 체크 로직을 추가하는 것은 어떨까?


protected CoverImage coverImage;

protected Long price;

protected SessionStatus status;

protected int numberOfApplicants;
Comment on lines +11 to +21
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

특별한 이유가 없으면 객체의 모든 인스턴스 변수는 private final로 구현해 보는 연습을 하면 어떨까?


public Session(
LocalDateTime startDate,
LocalDateTime endDate,
CoverImage coverImage,
Long price,
SessionStatus status,
int numberOfApplicants
) {
this.startDate = startDate;
this.endDate = endDate;
this.coverImage = coverImage;
this.price = price;
this.status = status;
this.numberOfApplicants = numberOfApplicants;
}

public Session(Long price, SessionStatus status) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

이 생성자가 필요한 이유는?
반드시 필요하다면 부생성자는 주생성자를 호출하는 방식으로 구현한다.

this.price = price;
this.status = status;
}

public void register(Payment payment) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

메서드가 있는 이 메서드를 추상 메서드로 구현하는 것은 어떨까?

if (!payment.hasAmount(price)) {
throw new CannotRegisterSessionException("결제 금액과 수강료가 일치하지 않습니다.");
}

this.register();
}

protected abstract void register();

@Override
public boolean equals(Object o) {
if (this == o) {
return true;
}
if (o == null || getClass() != o.getClass()) {
return false;
}
Session session = (Session) o;
return numberOfApplicants == session.numberOfApplicants && Objects.equals(startDate, session.startDate) &&
Objects.equals(endDate, session.endDate) && Objects.equals(coverImage, session.coverImage) &&
Objects.equals(price, session.price) && status == session.status;
}

@Override
public int hashCode() {
return Objects.hash(startDate, endDate, coverImage, price, status, numberOfApplicants);
}
}
7 changes: 7 additions & 0 deletions src/main/java/nextstep/courses/domain/SessionStatus.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
package nextstep.courses.domain;

public enum SessionStatus {
PREPARING,
ACCEPTING,
ENDED
}
11 changes: 11 additions & 0 deletions src/main/java/nextstep/courses/service/SessionService.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
package nextstep.courses.service;

import nextstep.courses.domain.Session;
import nextstep.payments.domain.Payment;

public class SessionService {

public void register(Payment payment, Session session) {
session.register(payment);
}
}
9 changes: 9 additions & 0 deletions src/main/java/nextstep/payments/domain/Payment.java
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package nextstep.payments.domain;

import java.time.LocalDateTime;
import java.util.Objects;

public class Payment {
private String id;
Expand All @@ -26,4 +27,12 @@ public Payment(String id, Long sessionId, Long nsUserId, Long amount) {
this.amount = amount;
this.createdAt = LocalDateTime.now();
}

public Payment(Long amount) {
this.amount = amount;
}

public boolean hasAmount(Long amount) {
return Objects.equals(this.amount, amount);
}
}
48 changes: 48 additions & 0 deletions src/test/java/nextstep/courses/domain/CoverImageTest.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
package nextstep.courses.domain;

import nextstep.courses.InvalidCoverImageException;
import nextstep.courses.domain.CoverImage;
import org.assertj.core.api.Assertions;
import org.junit.jupiter.api.Test;

import java.io.IOException;

import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;

public class CoverImageTest {
private static final String IMAGE_PATH = System.getProperty("user.dir") + "/src/test/java/nextstep/courses/resources/";

@Test
void new_커버_이미지를_생성하면_커버_이미지가_생성된다() throws IOException {
Assertions.assertThat(new CoverImage(IMAGE_PATH + "validCoverImage.png")).isInstanceOf(CoverImage.class);
}

@Test
void new_1MB를_초과하는_커버_이미지는_생성할_수_없다() {
assertThatThrownBy(() -> new CoverImage(IMAGE_PATH + "exceededCoverImage.png")).isInstanceOf(
InvalidCoverImageException.class)
.hasMessage("유효하지 않은 용량의 커버 이미지입니다.");
}

@Test
void new_허용되지_않은_확장자의_커버_이미지는_생성할_수_없다() {
assertThatThrownBy(() -> new CoverImage(IMAGE_PATH + "exceededCoverImage.xlsx")).isInstanceOf(
InvalidCoverImageException.class)
.hasMessage("유효하지 않은 확장자의 커버 이미지입니다.");
}

@Test
void new_허용되지_않은_크기의_커버_이미지는_생성할_수_없다() {
assertThatThrownBy(() -> new CoverImage(IMAGE_PATH + "smallCoverImage.png")).isInstanceOf(
InvalidCoverImageException.class)
.hasMessage("유효하지 않은 크기의 커버 이미지입니다.");
}

@Test
void new_허용되지_않은_비율의_커버_이미지는_생성할_수_없다() {
assertThatThrownBy(() -> new CoverImage(IMAGE_PATH + "unbalancedCoverImage.png")).isInstanceOf(
InvalidCoverImageException.class)
.hasMessage("유효하지 않은 비율의 커버 이미지입니다.");
}
}
Loading