diff --git a/README.md b/README.md index 4371c7e59..f2b4d34ea 100644 --- a/README.md +++ b/README.md @@ -1,11 +1,14 @@ # 학습 관리 시스템(Learning Management System) + ## 진행 방법 + * 학습 관리 시스템의 수강신청 요구사항을 파악한다. * 요구사항에 대한 구현을 완료한 후 자신의 github 아이디에 해당하는 브랜치에 Pull Request(이하 PR)를 통해 코드 리뷰 요청을 한다. * 코드 리뷰 피드백에 대한 개선 작업을 하고 다시 PUSH한다. * 모든 피드백을 완료하면 다음 단계를 도전하고 앞의 과정을 반복한다. ## 온라인 코드 리뷰 과정 + * [텍스트와 이미지로 살펴보는 온라인 코드 리뷰 과정](https://github.com/next-step/nextstep-docs/tree/master/codereview) --- @@ -14,10 +17,46 @@ ### 질문 삭제하기 -- 질문자 = 로그인 사용자 아니면 삭제 불가 -- 답변 중 다른 사람이 쓴 답변이 있으면 삭제 불가 -- 답변이 없으면 삭제 가능 -- 질문자와 답변자가 모두 동일하면 삭제 가능 -- 삭제되면 질문 상태 변경 -- 삭제되면 모든 답변 상태 변경 -- 삭제 이력 생성됨 \ No newline at end of file +- 질문자 = 로그인 사용자 아니면 삭제 불가 +- 답변 중 다른 사람이 쓴 답변이 있으면 삭제 불가 +- 답변이 없으면 삭제 가능 +- 질문자와 답변자가 모두 동일하면 삭제 가능 +- 삭제되면 질문 상태 변경 +- 삭제되면 모든 답변 상태 변경 +- 삭제 이력 생성됨 + +### 수강 신청 기능 + +#### Course + +- 기수 단위로 운영 +- 여러 개의 Session(강의)을 가질 수 있음 + +#### Session (강의) + +##### 속성 + +- 시작일(start_date), 종료일(end_date) +- 커버 이미지 + - 최대 1MB + - 타입: gif, jpg/jpeg, png, svg + - 최소 사이즈: width 300px, height 200px + - 비율: 3:2 +- 무료/유료 구분 +- 최대 수강 인원 (유료 강의만 적용) +- 상태: 준비중, 모집중, 종료 + +##### 규칙 + +- 강의 상태가 `모집중`일 때만 수강 신청 가능 +- 무료 강의는 수강 인원 제한 없음 +- 유료 강의 + - 최대 수강 인원을 초과할 수 없음 + - 결제 금액과 수강료가 일치해야 수강 신청 가능 + - 결제 정보는 `payments` 모듈의 Payment 객체를 통해 관리 + +#### Payment (결제) + +- 유료 강의 결제 정보 관리 +- Payment 객체 반환 +- 결제 완료 여부 확인 후 수강 신청 가능 diff --git a/src/main/java/nextstep/payments/domain/Payment.java b/src/main/java/nextstep/payments/domain/Payment.java index 57d833f85..552d28ac5 100644 --- a/src/main/java/nextstep/payments/domain/Payment.java +++ b/src/main/java/nextstep/payments/domain/Payment.java @@ -1,6 +1,8 @@ package nextstep.payments.domain; import java.time.LocalDateTime; +import java.util.Objects; +import nextstep.users.domain.NsUser; public class Payment { private String id; @@ -26,4 +28,35 @@ public Payment(String id, Long sessionId, Long nsUserId, Long amount) { this.amount = amount; this.createdAt = LocalDateTime.now(); } + + public Long nsUserId() { + return nsUserId; + } + + public boolean isPaidBy(NsUser user) { + return user.getId().equals(this.nsUserId); + } + + public boolean isPaidFor(int fee) { + return this.amount == fee; + } + + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } + if (o == null || getClass() != o.getClass()) { + return false; + } + Payment payment = (Payment) o; + return Objects.equals(id, payment.id) && Objects.equals(sessionId, payment.sessionId) + && Objects.equals(nsUserId, payment.nsUserId) && Objects.equals(amount, payment.amount) + && Objects.equals(createdAt, payment.createdAt); + } + + @Override + public int hashCode() { + return Objects.hash(id, sessionId, nsUserId, amount, createdAt); + } } diff --git a/src/main/java/nextstep/sessions/domain/Capacity.java b/src/main/java/nextstep/sessions/domain/Capacity.java new file mode 100644 index 000000000..2df65bfb9 --- /dev/null +++ b/src/main/java/nextstep/sessions/domain/Capacity.java @@ -0,0 +1,87 @@ +package nextstep.sessions.domain; + +public class Capacity { + + private static final String ERROR_MAX_CAPACITY_REQUIRED = "유료 강의는 최대 수강인원이 있어야 합니다"; + private static final String ERROR_ENROLL_COUNT_NEGATIVE = "수강 인원은 0 이상이어야 합니다"; + private static final String ERROR_ENROLL_COUNT_EXCEED = "수강 인원은 수강 정원을 초과할 수 없습니다"; + private static final String ERROR_CANNOT_ENROLL = "수강 신청을 할 수 없습니다"; + + private static final int DEFAULT_ENROLL_COUNT = 0; + + private final Integer maxCapacity; + private final boolean unlimited; + private final int enrollCount; + + + Capacity(Integer maxCapacity, boolean unlimited) { + this(maxCapacity, unlimited, DEFAULT_ENROLL_COUNT); + } + + Capacity(Integer maxCapacity, boolean unlimited, int enrollCount) { + validateMaxCapacity(maxCapacity); + validateEnrollCount(enrollCount); + validateEnrollCountWithinCapacity(maxCapacity, enrollCount); + + this.maxCapacity = maxCapacity; + this.unlimited = unlimited; + this.enrollCount = enrollCount; + } + + public static Capacity limited(int maxCapacity) { + return new Capacity(maxCapacity, false, DEFAULT_ENROLL_COUNT); + } + + public static Capacity unlimited() { + return new Capacity(Integer.MAX_VALUE, true, 0); + } + + public Integer maxCapacity() { + return maxCapacity; + } + + public int enrollCount() { + return enrollCount; + } + + public boolean canEnroll() { + return unlimited || enrollCount < maxCapacity; + } + + public boolean isUnlimited() { + return unlimited; + } + + public boolean isFull() { + return enrollCount >= maxCapacity; + } + + public boolean hasAvailableSeat() { + return !isFull(); + } + + public Capacity increaseEnrollCount() { + if (!canEnroll()) { + throw new IllegalArgumentException(ERROR_CANNOT_ENROLL); + } + return new Capacity(maxCapacity, unlimited, enrollCount + 1); + } + + private void validateMaxCapacity(Integer maxCapacity) { + if (!unlimited && (maxCapacity == null || maxCapacity <= 0)) { + throw new IllegalArgumentException(ERROR_MAX_CAPACITY_REQUIRED); + } + } + + private static void validateEnrollCount(int enrollCount) { + if (enrollCount < 0) { + throw new IllegalArgumentException(ERROR_ENROLL_COUNT_NEGATIVE); + } + } + + private static void validateEnrollCountWithinCapacity(Integer maxCapacity, int enrollCount) { + if (maxCapacity != null && enrollCount > maxCapacity) { + throw new IllegalArgumentException(ERROR_ENROLL_COUNT_EXCEED); + } + } +} diff --git a/src/main/java/nextstep/sessions/domain/Enrollment.java b/src/main/java/nextstep/sessions/domain/Enrollment.java new file mode 100644 index 000000000..e9f9343d5 --- /dev/null +++ b/src/main/java/nextstep/sessions/domain/Enrollment.java @@ -0,0 +1,56 @@ +package nextstep.sessions.domain; + +import java.time.LocalDateTime; +import java.util.Objects; +import nextstep.payments.domain.Payment; +import nextstep.users.domain.NsUser; + +public class Enrollment { + + static final String ERROR_USER_PAYMENT_MISMATCH = "결제한 사용자와 신청자가 일치하지 않습니다"; + + private final NsUser user; + private final Payment payment; + private final LocalDateTime enrolledAt; + + public Enrollment(NsUser user, Payment payment) { + validateUserPayMatch(user, payment); + this.user = user; + this.payment = payment; + this.enrolledAt = LocalDateTime.now(); + } + + public Payment payment() { + return payment; + } + + public boolean canPayFor(SessionPricing pricing) { + return !pricing.isPaid() || payment.isPaidFor(pricing.fee()); + } + + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } + if (o == null || getClass() != o.getClass()) { + return false; + } + Enrollment that = (Enrollment) o; + return Objects.equals(user, that.user) && Objects.equals(payment, that.payment) + && Objects.equals(enrolledAt, that.enrolledAt); + } + + @Override + public int hashCode() { + return Objects.hash(user, payment, enrolledAt); + } + + private static void validateUserPayMatch(NsUser user, Payment payment) { + if (payment.isPaidBy(user)) { + return; + } + throw new IllegalArgumentException(ERROR_USER_PAYMENT_MISMATCH); + } + +} diff --git a/src/main/java/nextstep/sessions/domain/Enrollments.java b/src/main/java/nextstep/sessions/domain/Enrollments.java new file mode 100644 index 000000000..2bd0760df --- /dev/null +++ b/src/main/java/nextstep/sessions/domain/Enrollments.java @@ -0,0 +1,23 @@ +package nextstep.sessions.domain; + +import java.util.HashSet; +import java.util.Set; + +public class Enrollments { + + static final String ERROR_ALREADY_ENROLLED = "이미 등록된 사용자입니다."; + + private final Set enrollments = new HashSet<>(); + + public void add(Enrollment enrollment) { + if (enrollments.contains(enrollment)) { + throw new IllegalArgumentException(ERROR_ALREADY_ENROLLED); + } + enrollments.add(enrollment); + } + + public int size() { + return enrollments.size(); + } +} + diff --git a/src/main/java/nextstep/sessions/domain/FileName.java b/src/main/java/nextstep/sessions/domain/FileName.java new file mode 100644 index 000000000..b7098bd63 --- /dev/null +++ b/src/main/java/nextstep/sessions/domain/FileName.java @@ -0,0 +1,27 @@ +package nextstep.sessions.domain; + +public class FileName { + + static final String ERROR_FILENAME_NOT_EMPTY = "파일명은 빈 값일 수 없습니다"; + private final String value; + + public FileName(String value) { + validate(value); + this.value = value; + } + + public String value() { + return value; + } + + public String extension() { + return value.substring(value.lastIndexOf('.') + 1); + } + + private void validate(String fileName) { + if (fileName == null || fileName.trim().isEmpty()) { + throw new IllegalArgumentException(ERROR_FILENAME_NOT_EMPTY); + } + } + +} diff --git a/src/main/java/nextstep/sessions/domain/ImageDimension.java b/src/main/java/nextstep/sessions/domain/ImageDimension.java new file mode 100644 index 000000000..fd5b033df --- /dev/null +++ b/src/main/java/nextstep/sessions/domain/ImageDimension.java @@ -0,0 +1,29 @@ +package nextstep.sessions.domain; + +public class ImageDimension { + + private static final int MIN_WIDTH = 300; + private static final int MIN_HEIGHT = 200; + + private static final String ERROR_MIN_SIZE = "이미지 크기가 최소 조건을 만족하지 않습니다"; + private static final String ERROR_RATIO = "이미지 비율은 3:2여야 합니다"; + + private final int width; + private final int height; + + public ImageDimension(int width, int height) { + validate(width, height); + this.width = width; + this.height = height; + } + + private void validate(int width, int height) { + if (width < MIN_WIDTH || height < MIN_HEIGHT) { + throw new IllegalArgumentException(ERROR_MIN_SIZE); + } + if (width * 2 != height * 3) { + throw new IllegalArgumentException(ERROR_RATIO); + } + } + +} diff --git a/src/main/java/nextstep/sessions/domain/ImageSize.java b/src/main/java/nextstep/sessions/domain/ImageSize.java new file mode 100644 index 000000000..f6380b617 --- /dev/null +++ b/src/main/java/nextstep/sessions/domain/ImageSize.java @@ -0,0 +1,24 @@ +package nextstep.sessions.domain; + +public class ImageSize { + + static final String ERROR_IMAGE_SIZE = "이미지 용량은 1MB 이하여야 합니다"; + private static final long MAX_SIZE = 1_000_000; + + private final long size; + + public ImageSize(long size) { + validate(size); + this.size = size; + } + + private static void validate(long size) { + if (size <= 0 || size > MAX_SIZE) { + throw new IllegalArgumentException(ERROR_IMAGE_SIZE); + } + } + + public long value() { + return size; + } +} 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 000000000..4ef58b549 --- /dev/null +++ b/src/main/java/nextstep/sessions/domain/ImageType.java @@ -0,0 +1,22 @@ +package nextstep.sessions.domain; + +import java.util.Arrays; + +public enum ImageType { + GIF, JPG, JPEG, PNG, SVG; + + private static final String ERROR_EMPTY_EXT = "확장자가 유효하지 않습니다"; + private static final String ERROR_UNSUPPORTED_EXT = "지원하지 않는 이미지 형식입니다: "; + + public static ImageType from(String ext) { + if (ext == null || ext.trim().isEmpty()) { + throw new IllegalArgumentException(ERROR_EMPTY_EXT); + } + + return Arrays.stream(ImageType.values()) + .filter(type -> type.name().equalsIgnoreCase(ext)) + .findFirst() + .orElseThrow(() -> new IllegalArgumentException(ERROR_UNSUPPORTED_EXT + ext)); + } +} + diff --git a/src/main/java/nextstep/sessions/domain/Period.java b/src/main/java/nextstep/sessions/domain/Period.java new file mode 100644 index 000000000..0d2fdd06e --- /dev/null +++ b/src/main/java/nextstep/sessions/domain/Period.java @@ -0,0 +1,25 @@ +package nextstep.sessions.domain; + +import java.time.LocalDate; + +public class Period { + + private static final String ERROR_INVALID_DATE = "시작일이 종료일보다 빨라야 합니다"; + + private final LocalDate startDate; + + private final LocalDate endDate; + + public Period(LocalDate startDate, LocalDate endDate) { + validateDate(startDate, endDate); + this.startDate = startDate; + this.endDate = endDate; + } + + private void validateDate(LocalDate startDate, LocalDate endDate) { + if (startDate.isAfter(endDate)) { + throw new IllegalArgumentException(ERROR_INVALID_DATE); + } + } + +} 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 000000000..e768d461d --- /dev/null +++ b/src/main/java/nextstep/sessions/domain/Session.java @@ -0,0 +1,81 @@ +package nextstep.sessions.domain; + +import java.time.LocalDate; + +public class Session { + + public static final String ERROR_SESSION_NOT_OPEN = "모집중인 강의만 수강 신청 가능합니다."; + public static final String ERROR_CAPACITY_EXCEEDED = "강의 정원이 초과되어 수강 신청할 수 없습니다."; + public static final String ERROR_PAYMENT_AMOUNT_MISMATCH = "결제 금액이 강의 수강료와 일치하지 않습니다."; + + private Long id; + + private SessionInfo sessionInfo; + + private SessionStatus status; + + private final SessionPricing pricing; + + private Capacity capacity; + + private final Enrollments enrollments = new Enrollments(); + + Session(Long id, SessionInfo sessionInfo, SessionPricing pricing, Capacity capacity) { + this.id = id; + this.sessionInfo = sessionInfo; + this.status = SessionStatus.PREPARING; + this.pricing = pricing; + this.capacity = capacity; + } + + public static Session paidLimited(Long id, LocalDate startDate, LocalDate endDate, int fee, int maxCapacity, + SessionImage image) { + SessionInfo info = new SessionInfo(new Period(startDate, endDate), image); + return new Session(id, info, SessionPricing.paid(fee), Capacity.limited(maxCapacity)); + } + + public static Session freeUnlimited(Long id, LocalDate startDate, LocalDate endDate, SessionImage image) { + SessionInfo info = new SessionInfo(new Period(startDate, endDate), image); + return new Session(id, info, SessionPricing.free(), Capacity.unlimited()); + } + + public SessionStatus status() { + return status; + } + + public void startRecruiting() { + this.status = SessionStatus.OPEN; + } + + public void enroll(Enrollment enrollment) { + validateOpen(); + validateCapacity(); + validatePaymentAmount(enrollment); + + enrollments.add(enrollment); + capacity = capacity.increaseEnrollCount(); + } + + private void validateOpen() { + if (!isOpen()) { + throw new IllegalStateException(ERROR_SESSION_NOT_OPEN); + } + } + + private void validateCapacity() { + if (capacity.isFull()) { + throw new IllegalStateException(ERROR_CAPACITY_EXCEEDED); + } + } + + private void validatePaymentAmount(Enrollment enrollment) { + if (!enrollment.canPayFor(pricing)) { + throw new IllegalArgumentException(ERROR_PAYMENT_AMOUNT_MISMATCH); + } + } + + private boolean isOpen() { + return status == SessionStatus.OPEN; + } + +} diff --git a/src/main/java/nextstep/sessions/domain/SessionImage.java b/src/main/java/nextstep/sessions/domain/SessionImage.java new file mode 100644 index 000000000..0523fa56f --- /dev/null +++ b/src/main/java/nextstep/sessions/domain/SessionImage.java @@ -0,0 +1,29 @@ +package nextstep.sessions.domain; + +public class SessionImage { + + private final FileName fileName; + private final ImageSize imageSize; + private final ImageDimension imageDimension; + private final ImageType type; + + SessionImage(String fileName, long size, int width, int height) { + this(new FileName(fileName), new ImageSize(size), new ImageDimension(width, height)); + } + + public SessionImage(FileName fileName, ImageSize imageSize, ImageDimension imageDimension) { + this.fileName = fileName; + this.imageSize = imageSize; + this.imageDimension = imageDimension; + this.type = ImageType.from(fileName.extension()); + } + + public String fileName() { + return fileName.value(); + } + + public long size() { + return imageSize.value(); + } + +} diff --git a/src/main/java/nextstep/sessions/domain/SessionInfo.java b/src/main/java/nextstep/sessions/domain/SessionInfo.java new file mode 100644 index 000000000..ebf0ea8ca --- /dev/null +++ b/src/main/java/nextstep/sessions/domain/SessionInfo.java @@ -0,0 +1,23 @@ +package nextstep.sessions.domain; + +public class SessionInfo { + + static final String ERROR_COVER_IMAGE_REQUIRED = "강의 커버 이미지는 필수입니다"; + + private final Period period; + + private SessionImage image; + + public SessionInfo(Period period, SessionImage image) { + validateImage(image); + this.period = period; + this.image = image; + } + + private static void validateImage(SessionImage image) { + if (image == null) { + throw new IllegalArgumentException(ERROR_COVER_IMAGE_REQUIRED); + } + } + +} diff --git a/src/main/java/nextstep/sessions/domain/SessionPricing.java b/src/main/java/nextstep/sessions/domain/SessionPricing.java new file mode 100644 index 000000000..3c0c4e07f --- /dev/null +++ b/src/main/java/nextstep/sessions/domain/SessionPricing.java @@ -0,0 +1,43 @@ +package nextstep.sessions.domain; + +public class SessionPricing { + + private static final String ERROR_PAID_FEE = "유료 강의는 0원 초과 여야 합니다"; + private static final String ERROR_FREE_FEE = "무료 강의는 0원 이어야 합니다"; + + private final boolean isPaid; + private final int fee; + + SessionPricing(boolean isPaid, int fee) { + validateFee(isPaid, fee); + this.isPaid = isPaid; + this.fee = fee; + } + + public static SessionPricing paid(int fee) { + return new SessionPricing(true, fee); + } + + public static SessionPricing free() { + return new SessionPricing(false, 0); + } + + public boolean isPaid() { + return isPaid; + } + + public int fee() { + return fee; + } + + private void validateFee(boolean isPaid, int fee) { + if (isPaid && fee <= 0) { + throw new IllegalArgumentException(ERROR_PAID_FEE); + } + if (!isPaid && fee != 0) { + throw new IllegalArgumentException(ERROR_FREE_FEE); + } + } + +} + 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 000000000..c55baf5d9 --- /dev/null +++ b/src/main/java/nextstep/sessions/domain/SessionStatus.java @@ -0,0 +1,7 @@ +package nextstep.sessions.domain; + +public enum SessionStatus { + PREPARING, + OPEN, + CLOSED +} diff --git a/src/main/java/nextstep/users/domain/NsUser.java b/src/main/java/nextstep/users/domain/NsUser.java index 62ec5138c..b83d4bae6 100755 --- a/src/main/java/nextstep/users/domain/NsUser.java +++ b/src/main/java/nextstep/users/domain/NsUser.java @@ -1,9 +1,8 @@ package nextstep.users.domain; -import nextstep.qna.UnAuthorizedException; - import java.time.LocalDateTime; import java.util.Objects; +import nextstep.qna.UnAuthorizedException; public class NsUser { public static final GuestNsUser GUEST_USER = new GuestNsUser(); @@ -29,7 +28,8 @@ public NsUser(Long id, String userId, String password, String name, String email this(id, userId, password, name, email, LocalDateTime.now(), null); } - public NsUser(Long id, String userId, String password, String name, String email, LocalDateTime createdAt, LocalDateTime updatedAt) { + public NsUser(Long id, String userId, String password, String name, String email, LocalDateTime createdAt, + LocalDateTime updatedAt) { this.id = id; this.userId = userId; this.password = password; @@ -124,6 +124,26 @@ public boolean isGuestUser() { } } + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } + if (o == null || getClass() != o.getClass()) { + return false; + } + NsUser nsUser = (NsUser) o; + return Objects.equals(id, nsUser.id) && Objects.equals(userId, nsUser.userId) + && Objects.equals(password, nsUser.password) && Objects.equals(name, nsUser.name) + && Objects.equals(email, nsUser.email) && Objects.equals(createdAt, nsUser.createdAt) + && Objects.equals(updatedAt, nsUser.updatedAt); + } + + @Override + public int hashCode() { + return Objects.hash(id, userId, password, name, email, createdAt, updatedAt); + } + @Override public String toString() { return "NsUser{" + diff --git a/src/test/java/nextstep/payments/domain/PaymentTest.java b/src/test/java/nextstep/payments/domain/PaymentTest.java new file mode 100644 index 000000000..e5029cce2 --- /dev/null +++ b/src/test/java/nextstep/payments/domain/PaymentTest.java @@ -0,0 +1,42 @@ +package nextstep.payments.domain; + +import static org.assertj.core.api.Assertions.assertThat; + +import nextstep.users.domain.NsUserTest; +import org.junit.jupiter.api.Test; + +public class PaymentTest { + public static final Payment PAYMENT_1000 = new Payment("p1", 1L, 1L, 1000L); + + @Test + void isPaidBy_returnsTrue_whenMatch() { + assertThat(PAYMENT_1000.isPaidBy(NsUserTest.JAVAJIGI)).isTrue(); + } + + @Test + void isPaidBy_returnsFalse_whenMisMatch() { + assertThat(PAYMENT_1000.isPaidBy(NsUserTest.SANJIGI)).isFalse(); + } + + @Test + void isPaidFor_returnsTrue_whenAmountMatchesFee() { + assertThat(PAYMENT_1000.isPaidFor(1000)).isTrue(); + } + + @Test + void isPaidFor_returnsFalse_whenAmountDoesNotMatchFee() { + assertThat(PAYMENT_1000.isPaidFor(1500)).isFalse(); + } + + @Test + void isPaidFor_returnsFalse_whenFeeIsZero() { + assertThat(PAYMENT_1000.isPaidFor(0)).isFalse(); + } + + @Test + void isPaidFor_returnsTrue_forLargeAmounts() { + Payment largePayment = new Payment("p1", 1L, 1L, 10_000_000L); + assertThat(largePayment.isPaidFor(10_000_000)).isTrue(); + } + +} diff --git a/src/test/java/nextstep/sessions/domain/CapacityTest.java b/src/test/java/nextstep/sessions/domain/CapacityTest.java new file mode 100644 index 000000000..6df007eee --- /dev/null +++ b/src/test/java/nextstep/sessions/domain/CapacityTest.java @@ -0,0 +1,58 @@ +package nextstep.sessions.domain; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import org.junit.jupiter.api.Test; + +class CapacityTest { + + public static final Capacity FREE_CAPACITY = new Capacity(Integer.MAX_VALUE, true); + public static final Capacity PAID_CAPACITY = new Capacity(10, true); + public static final Capacity PAID_CAPACITy_FULL = new Capacity(10, true, 10); + + @Test + void freeCapacity_isUnlimitedIsTrue() { + assertThat(FREE_CAPACITY.isUnlimited()).isTrue(); + assertThat(FREE_CAPACITY.canEnroll()).isTrue(); + } + + @Test + void paidCapacity_maxCapacityMustBePositive() { + assertThat(PAID_CAPACITY.maxCapacity()).isEqualTo(10); + assertThat(PAID_CAPACITY.canEnroll()).isTrue(); + } + + @Test + void paidCapacity_invalidMaxCapacity_throwsException() { + assertThatThrownBy(() -> new Capacity(0, false, 0)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("유료 강의는 최대 수강인원이 있어야 합니다"); + } + + @Test + void enrollCountExceedsMax_throwsException() { + assertThatThrownBy(() -> new Capacity(1, false, 2)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("수강 인원은 수강 정원을 초과할 수 없습니다"); + } + + @Test + void enrollEqualsMax_isFullReturnsTrue() { + Capacity capacity = new Capacity(3, false, 3); + assertThat(capacity.isFull()).isTrue(); + } + + @Test + void enrollLessThanMax_isFullReturnsFalse() { + Capacity capacity = new Capacity(3, false, 2); + assertThat(capacity.isFull()).isFalse(); + } + + @Test + void increaseEnrollCount_incrementsEnrollCountByOne() { + Capacity afterIncrease = FREE_CAPACITY.increaseEnrollCount(); + assertThat(afterIncrease.enrollCount() - FREE_CAPACITY.enrollCount()).isEqualTo(1); + } + +} diff --git a/src/test/java/nextstep/sessions/domain/EnrollmentTest.java b/src/test/java/nextstep/sessions/domain/EnrollmentTest.java new file mode 100644 index 000000000..4d19cbd0d --- /dev/null +++ b/src/test/java/nextstep/sessions/domain/EnrollmentTest.java @@ -0,0 +1,20 @@ +package nextstep.sessions.domain; + +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import nextstep.payments.domain.PaymentTest; +import nextstep.users.domain.NsUserTest; +import org.junit.jupiter.api.Test; + +class EnrollmentTest { + + public static Enrollment E1 = new Enrollment(NsUserTest.JAVAJIGI, PaymentTest.PAYMENT_1000); + + @Test + void validateUserAndPayment() { + assertThatThrownBy(() -> new Enrollment(NsUserTest.SANJIGI, PaymentTest.PAYMENT_1000)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining(Enrollment.ERROR_USER_PAYMENT_MISMATCH); + } + +} \ No newline at end of file diff --git a/src/test/java/nextstep/sessions/domain/EnrollmentsTest.java b/src/test/java/nextstep/sessions/domain/EnrollmentsTest.java new file mode 100644 index 000000000..2679e455c --- /dev/null +++ b/src/test/java/nextstep/sessions/domain/EnrollmentsTest.java @@ -0,0 +1,31 @@ +package nextstep.sessions.domain; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +class EnrollmentsTest { + + private Enrollments enrollments; + + @BeforeEach + void setUp() { + enrollments = new Enrollments(); + } + + @Test + void addEnrollment() { + enrollments.add(EnrollmentTest.E1); + assertThat(enrollments.size()).isEqualTo(1); + } + + @Test + void whenDuplicateEnrollment_thenThrows() { + enrollments.add(EnrollmentTest.E1); + assertThatThrownBy(() -> enrollments.add(EnrollmentTest.E1)) + .hasMessageContaining(Enrollments.ERROR_ALREADY_ENROLLED); + } + +} \ No newline at end of file diff --git a/src/test/java/nextstep/sessions/domain/FileNameTest.java b/src/test/java/nextstep/sessions/domain/FileNameTest.java new file mode 100644 index 000000000..9b2777d7b --- /dev/null +++ b/src/test/java/nextstep/sessions/domain/FileNameTest.java @@ -0,0 +1,21 @@ +package nextstep.sessions.domain; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatIllegalArgumentException; + +import org.junit.jupiter.api.Test; + +class FileNameTest { + + @Test + void whenFileNameIsEmpty_thenThrow() { + assertThatIllegalArgumentException().isThrownBy(() -> + new FileName("") + ).withMessageContaining("파일명"); + } + + @Test + void extension() { + assertThat(new FileName("testImage.jpg").extension()).isEqualTo("jpg"); + } +} \ No newline at end of file diff --git a/src/test/java/nextstep/sessions/domain/ImageDimensionTest.java b/src/test/java/nextstep/sessions/domain/ImageDimensionTest.java new file mode 100644 index 000000000..fef521c01 --- /dev/null +++ b/src/test/java/nextstep/sessions/domain/ImageDimensionTest.java @@ -0,0 +1,30 @@ +package nextstep.sessions.domain; + +import static org.assertj.core.api.Assertions.assertThatIllegalArgumentException; + +import org.junit.jupiter.api.Test; + +class ImageDimensionTest { + + @Test + void whenImageWidthIsTooSmall_thenThrow() { + assertThatIllegalArgumentException().isThrownBy(() -> + new ImageDimension(299, 200) + ).withMessageContaining("이미지 크기"); + } + + @Test + void whenImageHeightIsTooSmall_thenThrow() { + assertThatIllegalArgumentException().isThrownBy(() -> + new ImageDimension(300, 199) + ).withMessageContaining("이미지 크기"); + } + + @Test + void whenImageRatioIsNot3To2_thenThrow() { + assertThatIllegalArgumentException().isThrownBy(() -> + new ImageDimension(310, 200) + ).withMessageContaining("3:2"); + } + +} \ No newline at end of file diff --git a/src/test/java/nextstep/sessions/domain/ImageSizeTest.java b/src/test/java/nextstep/sessions/domain/ImageSizeTest.java new file mode 100644 index 000000000..a13e6948c --- /dev/null +++ b/src/test/java/nextstep/sessions/domain/ImageSizeTest.java @@ -0,0 +1,16 @@ +package nextstep.sessions.domain; + +import static org.assertj.core.api.Assertions.assertThatIllegalArgumentException; + +import org.junit.jupiter.api.Test; + +class ImageSizeTest { + + @Test + void whenImageSizeExceeds1MB_thenThrow() { + assertThatIllegalArgumentException().isThrownBy(() -> + new ImageSize(1_048_577) + ).withMessageContaining("1MB"); + } + +} \ No newline at end of file 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 000000000..cb065564f --- /dev/null +++ b/src/test/java/nextstep/sessions/domain/ImageTypeTest.java @@ -0,0 +1,28 @@ +package nextstep.sessions.domain; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import org.junit.jupiter.api.Test; + +class ImageTypeTest { + + @Test + void whenValidExtension_thenReturnsImageType() { + assertThat(ImageType.from("jpg")).isEqualTo(ImageType.JPG); + assertThat(ImageType.from("JPEG")).isEqualTo(ImageType.JPEG); + assertThat(ImageType.from("png")).isEqualTo(ImageType.PNG); + } + + @Test + void whenInvalidExtension_thenThrows() { + assertThatThrownBy(() -> ImageType.from("exe")) + .isInstanceOf(IllegalArgumentException.class); + } + + @Test + void whenBlank_thenThrows() { + assertThatThrownBy(() -> ImageType.from("")) + .isInstanceOf(IllegalArgumentException.class); + } +} diff --git a/src/test/java/nextstep/sessions/domain/PeriodTest.java b/src/test/java/nextstep/sessions/domain/PeriodTest.java new file mode 100644 index 000000000..4fd0b9d25 --- /dev/null +++ b/src/test/java/nextstep/sessions/domain/PeriodTest.java @@ -0,0 +1,21 @@ +package nextstep.sessions.domain; + +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import java.time.LocalDate; +import org.junit.jupiter.api.Test; + +class PeriodTest { + + static final LocalDate START_DATE = LocalDate.of(2025, 11, 3); + static final LocalDate END_DATE = LocalDate.of(2025, 12, 18); + public static final Period P1 = new Period(START_DATE, END_DATE); + + @Test + void startDateMustBeBeforeEndDate() { + assertThatThrownBy(() -> new Period(END_DATE, START_DATE)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("시작일이 종료일보다"); + } + +} \ No newline at end of file diff --git a/src/test/java/nextstep/sessions/domain/SessionImageTest.java b/src/test/java/nextstep/sessions/domain/SessionImageTest.java new file mode 100644 index 000000000..8e4e39bcb --- /dev/null +++ b/src/test/java/nextstep/sessions/domain/SessionImageTest.java @@ -0,0 +1,26 @@ +package nextstep.sessions.domain; + +import static org.assertj.core.api.Assertions.assertThat; + +import org.junit.jupiter.api.Test; + +class SessionImageTest { + + public static final SessionImage IMAGE = new SessionImage( + "cover.png", + 500_000, + 300, + 200 + ); + + @Test + void createImage_success() { + SessionImage image = new SessionImage( + "cover.png", + 500_000, + 300, + 200 + ); + assertThat(image.fileName()).isEqualTo("cover.png"); + } +} diff --git a/src/test/java/nextstep/sessions/domain/SessionInfoTest.java b/src/test/java/nextstep/sessions/domain/SessionInfoTest.java new file mode 100644 index 000000000..e34b5e148 --- /dev/null +++ b/src/test/java/nextstep/sessions/domain/SessionInfoTest.java @@ -0,0 +1,17 @@ +package nextstep.sessions.domain; + +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import org.junit.jupiter.api.Test; + +class SessionInfoTest { + + public static final SessionInfo INFO = new SessionInfo(PeriodTest.P1, SessionImageTest.IMAGE); + + @Test + void imageNull_throwsExcepiton() { + assertThatThrownBy(() -> new SessionInfo(PeriodTest.P1, null)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("강의 커버 이미지는 필수입니다"); + } +} \ No newline at end of file diff --git a/src/test/java/nextstep/sessions/domain/SessionPricingTest.java b/src/test/java/nextstep/sessions/domain/SessionPricingTest.java new file mode 100644 index 000000000..85b9da69d --- /dev/null +++ b/src/test/java/nextstep/sessions/domain/SessionPricingTest.java @@ -0,0 +1,38 @@ +package nextstep.sessions.domain; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import org.junit.jupiter.api.Test; + +class SessionPricingTest { + + public static final SessionPricing FREE_SP = new SessionPricing(false, 0); + public static final SessionPricing PAID_SP = new SessionPricing(true, 100_000); + + @Test + void freeSession_feeIsZero() { + SessionPricing freeSessionPricing = new SessionPricing(false, 0); + assertThat(freeSessionPricing.fee()).isEqualTo(0); + } + + @Test + void freeSession_invalidFee_throwsException() { + assertThatThrownBy(() -> new SessionPricing(false, 500_000)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("무료 강의는 0원 이어야 합니다"); + } + + @Test + void paidSession_feeIsPositive() { + SessionPricing paidSessionPricing = new SessionPricing(true, 100_000); + assertThat(paidSessionPricing.fee()).isEqualTo(100_000); + } + + @Test + void paidSession_invalidFee_throwsException() { + assertThatThrownBy(() -> new SessionPricing(true, 0)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("유료 강의는 0원 초과 여야 합니다"); + } +} diff --git a/src/test/java/nextstep/sessions/domain/SessionTest.java b/src/test/java/nextstep/sessions/domain/SessionTest.java new file mode 100644 index 000000000..9004612e3 --- /dev/null +++ b/src/test/java/nextstep/sessions/domain/SessionTest.java @@ -0,0 +1,50 @@ +package nextstep.sessions.domain; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import org.junit.jupiter.api.Test; + +class SessionTest { + + @Test + void sessionStatusIsPreparingOnCreation() { + Session session = new SessionTestBuilder().free().build(); + assertThat(session.status()).isEqualTo(SessionStatus.PREPARING); + } + + @Test + void whenStartRecruiting_StatusIsOpen() { + Session session = new SessionTestBuilder().free().build(); + session.startRecruiting(); + assertThat(session.status()).isEqualTo(SessionStatus.OPEN); + } + + @Test + void whenSessionStatusIsNotOpen_thenThrows() { + Session session = new SessionTestBuilder().free().build(); + assertThatThrownBy(() -> session.enroll(EnrollmentTest.E1)) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining(Session.ERROR_SESSION_NOT_OPEN); + } + + @Test + void whenCapacityFull_thenThrows() { + Session session = new Session(1L, SessionInfoTest.INFO, SessionPricingTest.PAID_SP, + CapacityTest.PAID_CAPACITy_FULL); + session.startRecruiting(); + assertThatThrownBy(() -> session.enroll(EnrollmentTest.E1)) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining(Session.ERROR_CAPACITY_EXCEEDED); + } + + @Test + void whenPaymentDifferent_thenThrows() { + Session session = new SessionTestBuilder().paid(5, 100_000).id(1L).build(); + session.startRecruiting(); + assertThatThrownBy(() -> session.enroll(EnrollmentTest.E1)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining(Session.ERROR_PAYMENT_AMOUNT_MISMATCH); + } + +} diff --git a/src/test/java/nextstep/sessions/domain/SessionTestBuilder.java b/src/test/java/nextstep/sessions/domain/SessionTestBuilder.java new file mode 100644 index 000000000..153771b2f --- /dev/null +++ b/src/test/java/nextstep/sessions/domain/SessionTestBuilder.java @@ -0,0 +1,60 @@ +package nextstep.sessions.domain; + +import java.time.LocalDate; + +class SessionTestBuilder { + + private Long id = 1L; + private LocalDate startDate = PeriodTest.START_DATE; + private LocalDate endDate = PeriodTest.END_DATE; + private SessionStatus status = SessionStatus.PREPARING; + private boolean paid = false; + private Integer maxCapacity = Integer.MAX_VALUE; + private boolean unlimited = true; + private int fee = 0; + private int enrollCount = 0; + private SessionImage image = SessionImageTest.IMAGE; + + public SessionTestBuilder paid(Integer maxCapacity, int fee) { + this.paid = true; + this.maxCapacity = maxCapacity; + this.unlimited = false; + this.fee = fee; + return this; + } + + public SessionTestBuilder free() { + this.paid = false; + this.maxCapacity = Integer.MAX_VALUE; + this.fee = 0; + return this; + } + + public SessionTestBuilder id(Long id) { + this.id = id; + return this; + } + + public SessionTestBuilder maxCapacity(Integer maxCapacity) { + this.maxCapacity = maxCapacity; + return this; + } + + public SessionTestBuilder fee(int fee) { + this.fee = fee; + return this; + } + + public SessionTestBuilder enrollCount(int count) { + this.enrollCount = count; + return this; + } + + public Session build() { + if (paid) { + return Session.paidLimited(id, startDate, endDate, fee, maxCapacity, image); + } + return Session.freeUnlimited(id, startDate, endDate, image); + } + +}