diff --git a/README.md b/README.md index c3596da16..53b7c3ff0 100644 --- a/README.md +++ b/README.md @@ -3,7 +3,7 @@ 본 저장소는 TDD 학습을 위한 단계별 과제를 포함합니다. ## 단계별 문서 - **[1단계 - 레거시 코드 리팩터링](./docs/01-legacy-code-refactoring.md)** -- **[2단계 - 수강신청(도메인 모델)]()** +- **[2단계 - 수강신청(도메인 모델)](./docs/02-lms-domain-model.md)** - **[3단계 - 수강신청(DB 신청)]()** - **[4단계 - 수강신청(요구사항 변경)]()** ## 진행 방법 diff --git a/docs/02-lms-domain-model.md b/docs/02-lms-domain-model.md new file mode 100644 index 000000000..a8543f3e3 --- /dev/null +++ b/docs/02-lms-domain-model.md @@ -0,0 +1,104 @@ +# 2단계 - 수강신청(도메인 모델) +*** +## 코드 리뷰 +> PR 링크: +> **[https://github.com/next-step/java-lms/pull/811](https://github.com/next-step/java-lms/pull/811)** +## 나의 학습 목표 +- TDD 사이클로 구현 +- 객체지향 생활 체조 원칙 준수 +- 테스트 작성하기 쉬운 구조로 설계 +- 자기 점검 체크리스트 준수 +- 특히 "규칙 7: 3개 이상의 인스턴스 변수를 가진 클래스를 쓰지 않는다."를 지키기 위해 노력한다. +## 학습 관리 시스템(LMS) +- 넥스트스텝은 재직자를 대상으로 소프트웨어 교육을 진행하는 교육 기관이다. +- 2018년 교육 사업을 시작했다. +- 교육 사업을 시작하며 자체적으로 학습 관리 시스템을 개발해 수강생을 모집하고, 컨텐츠를 관리하고 있다. +## 수강 신청 기능 요구사항 +- 과정(Course)은 기수 단위로 운영하며, 여러 개의 강의(Session)를 가질 수 있다. +- 강의는 시작일과 종료일을 가진다. +- 강의는 강의 커버 이미지 정보를 가진다. + - 이미지 크기는 1MB 이하여야 한다. + - 이미지 타입은 gif, jpg(jpeg 포함),, png, svg만 허용한다. + - 이미지의 width는 300픽셀, height는 200픽셀 이상이어야 하며, width와 height의 비율은 3:2여야 한다. +- 강의는 무료 강의와 유료 강의로 나뉜다. + - 무료 강의는 최대 수강 인원 제한이 없다. + - 유료 강의는 강의 최대 수강 인원을 초과할 수 없다. + - 유료 강의는 수강생이 결제한 금액과 수강료가 일치할 때 수강 신청이 가능하다. +- 강의 상태는 준비중, 모집중, 종료 3가지 상태를 가진다. +- 강의 수강신청은 강의 상태가 모집중일 때만 가능하다. +- 유료 강의의 경우 결제는 이미 완료한 것으로 가정하고 이후 과정을 구현한다. + - 결제를 완료한 결제 정보는 payments 모듈을 통해 관리되며, 결제 정보는 Payment 객체에 담겨 반한된다. +## 프로그래밍 요구사항 +- DB 테이블 설꼐 없이 도메인 모델부터 구현한다. +- 도메인 모델은 TDD로 구현한다. + - 단, Service 클래스는 단위 테스트가 없어도 된다. +## PR 전 점검 +**[체크리스트 확인하기](checklist.md)** +## 구현 기능 목록 +#### Course +- [x] 강의 추가 +- [x] 강의 개수 조회 + +#### Sessions (일급 컬렉션) +- [x] 강의 추가 +- [x] 강의 개수 조회 + +#### Session +- [x] 수강 신청 + - [x] 모집중 상태 검증 + - [x] Policy에 검증 위임 +- [x] 수강 인원 조회 +- [x] 강의 타입 조회 (Policy에서 반환) + +#### EnrollmentPolicy (추상 클래스, 템플릿 메서드 패턴) +- [x] final validate(payment, currentCount) - 알고리즘 순서 강제 +- [x] 결제 검증 (validatePayment) - 하위에서 구현 +- [x] 정원 검증 (validateCapacity) - 하위에서 구현 +- [x] 강의 타입 반환 (getType) - 하위에서 구현 + +#### FreeEnrollmentPolicy +- [x] EnrollmentPolicy 상속 +- [x] 결제 검증: 빈 구현 (무료) +- [x] 정원 검증: 빈 구현 (제한 없음) +- [x] 타입 반환: FREE + +#### PaidEnrollmentPolicy +- [x] EnrollmentPolicy 상속 +- [x] 결제 금액 검증 (수강료 일치) +- [x] 수강 인원 검증 (정원 초과) +- [x] 타입 반환: PAID + +#### Enrollments (일급 컬렉션) +- [x] 수강 신청 추가 +- [x] 수강 인원 조회 +- [x] 중복 수강 신청 검증 + +#### CoverImage (VO) +- [x] 이미지 파일 정보와 크기 정보 조합 생성 + +#### ImageFile (VO) +- [x] 파일 크기 검증 (1MB 이하) +- [x] 확장자 추출 +- [x] 이미지 타입 변환 + +#### ImageDimension (VO) +- [x] 너비/높이 최소값 검증 (300x200 이상) +- [x] 비율 검증 (3:2) + +#### ImageType (Enum) +- [x] 확장자 → ImageType 변환 (from(String)) +- [x] JPEG → JPG 변환 지원 + +#### SessionPeriod (VO) +- [x] 시작일/종료일 검증 (종료일 >= 시작일) + +#### SessionStatus (Enum) +- [x] 수강 신청 가능 여부 + +#### Money (VO) +- [x] 금액 검증 (0 이상) +- [x] 금액 비교 + +#### Capacity (VO) +- [x] 최대 인원 검증 (1명 이상) +- [x] 초과 여부 확인 diff --git a/src/main/java/nextstep/courses/domain/Capacity.java b/src/main/java/nextstep/courses/domain/Capacity.java new file mode 100644 index 000000000..dbfbab0bf --- /dev/null +++ b/src/main/java/nextstep/courses/domain/Capacity.java @@ -0,0 +1,20 @@ +package nextstep.courses.domain; + +public class Capacity { + private final int value; + + public Capacity(int value) { + if (value <= 0) { + throw new IllegalArgumentException(String.format("최대 수강 인원은 1명 이상이어야 합니다. (입력: %d)", value)); + } + this.value = value; + } + + public boolean isOver(int currentCount) { + return currentCount >= value; + } + + public int getValue() { + return this.value; + } +} diff --git a/src/main/java/nextstep/courses/domain/Course.java b/src/main/java/nextstep/courses/domain/Course.java index 176e0617b..06fe9b5ff 100644 --- a/src/main/java/nextstep/courses/domain/Course.java +++ b/src/main/java/nextstep/courses/domain/Course.java @@ -9,24 +9,39 @@ public class Course { private Long creatorId; - private LocalDateTime createdAt; + private final Sessions sessions; - private LocalDateTime updatedAt; + private final LocalDateTime createdAt; - public Course() {} + private final LocalDateTime updatedAt; - public Course(String title, Long creatorId) { - this(0L, title, creatorId, LocalDateTime.now(), null); + public Course(String title, Long creatorId, LocalDateTime now) { + this(0L, title, creatorId, new Sessions(), now, null); } - public Course(Long id, String title, Long creatorId, LocalDateTime createdAt, LocalDateTime updatedAt) { + public Course( + Long id, + String title, + Long creatorId, + Sessions sessions, + LocalDateTime createdAt, + LocalDateTime updatedAt) { this.id = id; this.title = title; this.creatorId = creatorId; + this.sessions = sessions; this.createdAt = createdAt; this.updatedAt = updatedAt; } + public void addSession(Session session) { + sessions.add(session); + } + + public int sessionCount() { + return sessions.count(); + } + public String getTitle() { return title; } 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 000000000..62c15cc39 --- /dev/null +++ b/src/main/java/nextstep/courses/domain/CoverImage.java @@ -0,0 +1,11 @@ +package nextstep.courses.domain; + +public class CoverImage { + private final ImageFile imageFile; + private final ImageDimension dimension; + + public CoverImage(String filename, long fileSize, int width, int height) { + this.imageFile = new ImageFile(filename, fileSize); + this.dimension = new ImageDimension(width, height); + } +} diff --git a/src/main/java/nextstep/courses/domain/Enrollment.java b/src/main/java/nextstep/courses/domain/Enrollment.java new file mode 100644 index 000000000..28442d304 --- /dev/null +++ b/src/main/java/nextstep/courses/domain/Enrollment.java @@ -0,0 +1,25 @@ +package nextstep.courses.domain; + +import java.time.LocalDateTime; + +public class Enrollment { + private final Long id; + private final Long sessionId; + private final Long studentId; + private final LocalDateTime createdAt; + + public Enrollment(Long sessionId, Long studentId, LocalDateTime now) { + this(0L, sessionId, studentId, now); + } + + public Enrollment(Long id, Long sessionId, Long studentId, LocalDateTime createdAt) { + this.id = id; + this.sessionId = sessionId; + this.studentId = studentId; + this.createdAt = createdAt; + } + + public Long getStudentId() { + return studentId; + } +} diff --git a/src/main/java/nextstep/courses/domain/EnrollmentPolicy.java b/src/main/java/nextstep/courses/domain/EnrollmentPolicy.java new file mode 100644 index 000000000..360f18dc3 --- /dev/null +++ b/src/main/java/nextstep/courses/domain/EnrollmentPolicy.java @@ -0,0 +1,14 @@ +package nextstep.courses.domain; + +public abstract class EnrollmentPolicy { + public final void validate(Money payment, int currentCount) { + validatePayment(payment); + validateCapacity(currentCount); + } + + public abstract SessionType getType(); + + protected abstract void validatePayment(Money payment); + + protected abstract void validateCapacity(int currentCount); +} diff --git a/src/main/java/nextstep/courses/domain/Enrollments.java b/src/main/java/nextstep/courses/domain/Enrollments.java new file mode 100644 index 000000000..2be746d9b --- /dev/null +++ b/src/main/java/nextstep/courses/domain/Enrollments.java @@ -0,0 +1,31 @@ +package nextstep.courses.domain; + +import java.util.ArrayList; +import java.util.List; + +public class Enrollments { + private final List values; + + public Enrollments() { + this(new ArrayList<>()); + } + + public Enrollments(List values) { + this.values = new ArrayList<>(values); + } + + public void add(Enrollment enrollment) { + validateNotDuplicate(enrollment); + this.values.add(enrollment); + } + + private void validateNotDuplicate(Enrollment enrollment) { + if (values.contains(enrollment)) { + throw new IllegalArgumentException("이미 수강 신청한 강의입니다."); + } + } + + public int count() { + return this.values.size(); + } +} diff --git a/src/main/java/nextstep/courses/domain/FreeEnrollmentPolicy.java b/src/main/java/nextstep/courses/domain/FreeEnrollmentPolicy.java new file mode 100644 index 000000000..84f4c150c --- /dev/null +++ b/src/main/java/nextstep/courses/domain/FreeEnrollmentPolicy.java @@ -0,0 +1,19 @@ +package nextstep.courses.domain; + +public class FreeEnrollmentPolicy extends EnrollmentPolicy { + + @Override + public SessionType getType() { + return SessionType.FREE; + } + + @Override + protected void validatePayment(Money payment) { + // 무료는 검증 X + } + + @Override + protected void validateCapacity(int currentCount) { + // 무료는 검증 X + } +} diff --git a/src/main/java/nextstep/courses/domain/ImageDimension.java b/src/main/java/nextstep/courses/domain/ImageDimension.java new file mode 100644 index 000000000..80684887c --- /dev/null +++ b/src/main/java/nextstep/courses/domain/ImageDimension.java @@ -0,0 +1,33 @@ +package nextstep.courses.domain; + +public class ImageDimension { + private static final int MIN_WIDTH = 300; + private static final int MIN_HEIGHT = 200; + private static final int WIDTH_RATIO = 3; + private static final int HEIGHT_RATIO = 2; + + private final int width; + private final int height; + + public ImageDimension(int width, int height) { + validateDimension(width, height); + validateAspectRatio(width, height); + this.width = width; + this.height = height; + } + + private void validateDimension(int width, int height) { + if (width < MIN_WIDTH) { + throw new IllegalArgumentException(String.format("이미지 너비는 %d픽셀 이상이어야 합니다. (입력: %d)", MIN_WIDTH, width)); + } + if (height < MIN_HEIGHT) { + throw new IllegalArgumentException(String.format("이미지 높이는 %d픽셀 이상이어야 합니다. (입력: %d)", MIN_HEIGHT, height)); + } + } + + private void validateAspectRatio(int width, int height) { + if (width * HEIGHT_RATIO != height * WIDTH_RATIO) { + throw new IllegalArgumentException(String.format("이미지 비율은 3:2여야 합니다. (입력: %d:%d)", width, height)); + } + } +} diff --git a/src/main/java/nextstep/courses/domain/ImageFile.java b/src/main/java/nextstep/courses/domain/ImageFile.java new file mode 100644 index 000000000..8baf8f463 --- /dev/null +++ b/src/main/java/nextstep/courses/domain/ImageFile.java @@ -0,0 +1,30 @@ +package nextstep.courses.domain; + +public class ImageFile { + private static final long MAX_FILE_SIZE = 1024 * 1024; + + private final String filename; + private final long fileSize; + private final ImageType imageType; + + public ImageFile(String filename, long fileSize) { + validateFileSize(fileSize); + this.filename = filename; + this.fileSize = fileSize; + this.imageType = ImageType.from(extractExtension(filename)); + } + + private void validateFileSize(long fileSize) { + if (fileSize > MAX_FILE_SIZE) { + throw new IllegalArgumentException(String.format("이미지 크기는 1MB 이하여야 합니다. (입력: %d bytes)", fileSize)); + } + } + + private String extractExtension(String filename) { + int dotIndex = filename.lastIndexOf('.'); + if (dotIndex == -1 || dotIndex == filename.length() - 1) { + throw new IllegalArgumentException("파일 확장자가 없습니다. (입력: " + filename + ")"); + } + return filename.substring(dotIndex + 1); + } +} 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 000000000..9fb5d6d6c --- /dev/null +++ b/src/main/java/nextstep/courses/domain/ImageType.java @@ -0,0 +1,16 @@ +package nextstep.courses.domain; + +public enum ImageType { + GIF, + JPG, + PNG, + SVG; + + public static ImageType from(String extension) { + String upper = extension.toUpperCase(); + if (upper.equals("JPEG")) { + return JPG; + } + return valueOf(upper); + } +} diff --git a/src/main/java/nextstep/courses/domain/Money.java b/src/main/java/nextstep/courses/domain/Money.java new file mode 100644 index 000000000..770938861 --- /dev/null +++ b/src/main/java/nextstep/courses/domain/Money.java @@ -0,0 +1,20 @@ +package nextstep.courses.domain; + +public class Money { + public static final Money ZERO = new Money(0); + + private final int amount; + + public Money(int amount) { + validate(amount); + this.amount = amount; + } + + private static void validate(int value) { + if (value < 0) throw new IllegalArgumentException(String.format("금액은 0 이상이어야 합니다. (입력: %d)", value)); + } + + public boolean isSameAs(Money other) { + return this.amount == other.amount; + } +} diff --git a/src/main/java/nextstep/courses/domain/PaidEnrollmentPolicy.java b/src/main/java/nextstep/courses/domain/PaidEnrollmentPolicy.java new file mode 100644 index 000000000..1f8895465 --- /dev/null +++ b/src/main/java/nextstep/courses/domain/PaidEnrollmentPolicy.java @@ -0,0 +1,34 @@ +package nextstep.courses.domain; + +public class PaidEnrollmentPolicy extends EnrollmentPolicy { + private final Capacity capacity; + private final Money price; + + public PaidEnrollmentPolicy(int capacity, int price) { + this(new Capacity(capacity), new Money(price)); + } + + public PaidEnrollmentPolicy(Capacity capacity, Money price) { + this.capacity = capacity; + this.price = price; + } + + @Override + public SessionType getType() { + return SessionType.PAID; + } + + @Override + protected void validatePayment(Money payment) { + if (!price.isSameAs(payment)) { + throw new IllegalArgumentException("결제 금액이 수강료와 일치하지 않습니다."); + } + } + + @Override + protected void validateCapacity(int currentCount) { + if (capacity.isOver(currentCount)) { + throw new IllegalStateException(String.format("수강 인원이 초과되었습니다. (최대: %d명)", capacity.getValue())); + } + } +} 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..eafaaa950 --- /dev/null +++ b/src/main/java/nextstep/courses/domain/Session.java @@ -0,0 +1,57 @@ +package nextstep.courses.domain; + +public class Session { + private final Long id; + private final CoverImage coverImage; + private final SessionPeriod period; + private final SessionStatus status; + private final EnrollmentPolicy enrollmentPolicy; + private final Enrollments enrollments; + + public Session(CoverImage coverImage, SessionPeriod period, EnrollmentPolicy policy) { + this(0L, coverImage, period, SessionStatus.PREPARING, policy, new Enrollments()); + } + + public Session(CoverImage coverImage, SessionPeriod period, SessionStatus status, EnrollmentPolicy policy) { + this(0L, coverImage, period, status, policy, new Enrollments()); + } + + public Session( + Long id, + CoverImage coverImage, + SessionPeriod period, + SessionStatus status, + EnrollmentPolicy policy, + Enrollments enrollments) { + this.id = id; + this.coverImage = coverImage; + this.period = period; + this.status = status; + this.enrollmentPolicy = policy; + this.enrollments = enrollments; + } + + public final void enroll(Enrollment enrollment, Money payment) { + validateStatus(); + enrollmentPolicy.validate(payment, enrollmentCount()); + enrollments.add(enrollment); + } + + private void validateStatus() { + if (!status.canEnroll()) { + throw new IllegalStateException(String.format("모집중인 강의만 수강 신청이 가능합니다. (현재 상태: %s)", status)); + } + } + + public int enrollmentCount() { + return enrollments.count(); + } + + public SessionStatus getStatus() { + return this.status; + } + + public SessionType getType() { + return enrollmentPolicy.getType(); + } +} 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..aafa7fb58 --- /dev/null +++ b/src/main/java/nextstep/courses/domain/SessionPeriod.java @@ -0,0 +1,21 @@ +package nextstep.courses.domain; + +import java.time.LocalDate; + +public class SessionPeriod { + private final LocalDate startDate; + private final LocalDate endDate; + + public SessionPeriod(LocalDate startDate, LocalDate endDate) { + validatePeriod(startDate, endDate); + this.startDate = startDate; + this.endDate = endDate; + } + + private void validatePeriod(LocalDate startDate, LocalDate endDate) { + if (endDate.isBefore(startDate)) { + throw new IllegalArgumentException( + String.format("종료일은 시작일 이후여야 합니다. (시작: %s, 종료: %s)", startDate, endDate)); + } + } +} 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..6a535e5be --- /dev/null +++ b/src/main/java/nextstep/courses/domain/SessionStatus.java @@ -0,0 +1,17 @@ +package nextstep.courses.domain; + +public enum SessionStatus { + PREPARING("준비중"), + RECRUITING("모집중"), + CLOSED("종료"); + + SessionStatus(String description) { + this.description = description; + } + + private final String description; + + public boolean canEnroll() { + return this == RECRUITING; + } +} 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..21843f73e --- /dev/null +++ b/src/main/java/nextstep/courses/domain/SessionType.java @@ -0,0 +1,12 @@ +package nextstep.courses.domain; + +public enum SessionType { + FREE("무료"), + PAID("유료"); + + SessionType(String description) { + this.description = description; + } + + private final String description; +} 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 000000000..5e5d6a1aa --- /dev/null +++ b/src/main/java/nextstep/courses/domain/Sessions.java @@ -0,0 +1,24 @@ +package nextstep.courses.domain; + +import java.util.ArrayList; +import java.util.List; + +public class Sessions { + private final List values; + + public Sessions() { + this(new ArrayList<>()); + } + + public Sessions(List values) { + this.values = new ArrayList<>(values); + } + + public void add(Session session) { + this.values.add(session); + } + + public int count() { + return this.values.size(); + } +} diff --git a/src/main/java/nextstep/courses/infrastructure/JdbcCourseRepository.java b/src/main/java/nextstep/courses/infrastructure/JdbcCourseRepository.java index 3075de280..663695b18 100644 --- a/src/main/java/nextstep/courses/infrastructure/JdbcCourseRepository.java +++ b/src/main/java/nextstep/courses/infrastructure/JdbcCourseRepository.java @@ -24,11 +24,12 @@ public int save(Course course) { @Override public Course findById(Long id) { - String sql = "select id, title, creator_id, created_at, updated_at from course where id = ?"; + String sql = "select id, title,creator_id, created_at, updated_at from course where id = ?"; RowMapper rowMapper = (rs, rowNum) -> new Course( rs.getLong(1), rs.getString(2), rs.getLong(3), + null, toLocalDateTime(rs.getTimestamp(4)), toLocalDateTime(rs.getTimestamp(5))); return jdbcTemplate.queryForObject(sql, rowMapper, id); diff --git a/src/test/java/nextstep/courses/domain/CapacityTest.java b/src/test/java/nextstep/courses/domain/CapacityTest.java new file mode 100644 index 000000000..6be60f0ba --- /dev/null +++ b/src/test/java/nextstep/courses/domain/CapacityTest.java @@ -0,0 +1,33 @@ +package nextstep.courses.domain; + +import static org.assertj.core.api.Assertions.*; + +import org.junit.jupiter.api.DisplayNameGeneration; +import org.junit.jupiter.api.DisplayNameGenerator; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.CsvSource; + +@DisplayNameGeneration(DisplayNameGenerator.ReplaceUnderscores.class) +class CapacityTest { + + @Test + void 생성자_정상입력_생성성공() { + assertThatCode(() -> new Capacity(1)).doesNotThrowAnyException(); + } + + @Test + void 생성자_0명이하_예외발생() { + assertThatThrownBy(() -> new Capacity(0)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("최대 수강 인원은 1명 이상"); + } + + @ParameterizedTest(name = "정원 {0}명 / 현재 {1}명 → 초과 여부: {2}") + @CsvSource({"10, 10, true", "10, 9, false"}) + void isOver_정원초과여부_확인(int capacityValue, int currentCount, boolean expected) { + boolean result = new Capacity(capacityValue).isOver(currentCount); + + assertThat(result).isEqualTo(expected); + } +} 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 000000000..6ab085660 --- /dev/null +++ b/src/test/java/nextstep/courses/domain/CourseTest.java @@ -0,0 +1,18 @@ +package nextstep.courses.domain; + +import static org.assertj.core.api.Assertions.*; + +import java.time.LocalDateTime; +import org.junit.jupiter.api.DisplayNameGeneration; +import org.junit.jupiter.api.DisplayNameGenerator; +import org.junit.jupiter.api.Test; + +@DisplayNameGeneration(DisplayNameGenerator.ReplaceUnderscores.class) +class CourseTest { + + @Test + void 생성자_정상입력_생성성공() { + LocalDateTime fixedNow = LocalDateTime.now(); + assertThatCode(() -> new Course("과정명", 1L, fixedNow)).doesNotThrowAnyException(); + } +} diff --git a/src/test/java/nextstep/courses/domain/EnrollmentsTest.java b/src/test/java/nextstep/courses/domain/EnrollmentsTest.java new file mode 100644 index 000000000..3bee9fd89 --- /dev/null +++ b/src/test/java/nextstep/courses/domain/EnrollmentsTest.java @@ -0,0 +1,38 @@ +package nextstep.courses.domain; + +import static org.assertj.core.api.Assertions.*; + +import java.time.LocalDateTime; +import org.junit.jupiter.api.DisplayNameGeneration; +import org.junit.jupiter.api.DisplayNameGenerator; +import org.junit.jupiter.api.Test; + +@DisplayNameGeneration(DisplayNameGenerator.ReplaceUnderscores.class) +class EnrollmentsTest { + + @Test + void add_정상입력_성공() { + var e1 = new Enrollment(1L, 1L, LocalDateTime.now()); + var e2 = new Enrollment(1L, 2L, LocalDateTime.now()); + var enrollments = new Enrollments(); + + assertThatCode(() -> { + enrollments.add(e1); + enrollments.add(e2); + }) + .doesNotThrowAnyException(); + } + + @Test + void add_중복신청_예외발생() { + var enrollment = new Enrollment(1L, 1L, LocalDateTime.now()); + var enrollments = new Enrollments(); + + assertThatThrownBy(() -> { + enrollments.add(enrollment); + enrollments.add(enrollment); + }) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("이미 수강 신청한 강의입니다."); + } +} diff --git a/src/test/java/nextstep/courses/domain/FreeEnrollmentPolicyTest.java b/src/test/java/nextstep/courses/domain/FreeEnrollmentPolicyTest.java new file mode 100644 index 000000000..278cbd2fd --- /dev/null +++ b/src/test/java/nextstep/courses/domain/FreeEnrollmentPolicyTest.java @@ -0,0 +1,23 @@ +package nextstep.courses.domain; + +import static org.assertj.core.api.Assertions.*; + +import org.junit.jupiter.api.DisplayNameGeneration; +import org.junit.jupiter.api.DisplayNameGenerator; +import org.junit.jupiter.api.Test; + +@DisplayNameGeneration(DisplayNameGenerator.ReplaceUnderscores.class) +class FreeEnrollmentPolicyTest { + + @Test + void validate_무료강의_항상성공() { + FreeEnrollmentPolicy policy = new FreeEnrollmentPolicy(); + + assertThatCode(() -> policy.validate(null, 1000000000)).doesNotThrowAnyException(); + } + + @Test + void getType_FREE_반환() { + assertThat(new FreeEnrollmentPolicy().getType()).isEqualTo(SessionType.FREE); + } +} diff --git a/src/test/java/nextstep/courses/domain/ImageDimensionTest.java b/src/test/java/nextstep/courses/domain/ImageDimensionTest.java new file mode 100644 index 000000000..9e2c00b62 --- /dev/null +++ b/src/test/java/nextstep/courses/domain/ImageDimensionTest.java @@ -0,0 +1,36 @@ +package nextstep.courses.domain; + +import static org.assertj.core.api.Assertions.*; + +import org.junit.jupiter.api.DisplayNameGeneration; +import org.junit.jupiter.api.DisplayNameGenerator; +import org.junit.jupiter.api.Test; + +@DisplayNameGeneration(DisplayNameGenerator.ReplaceUnderscores.class) +class ImageDimensionTest { + @Test + void 생성자_정상입력_생성성공() { + assertThatCode(() -> new ImageDimension(300, 200)).doesNotThrowAnyException(); + } + + @Test + void 생성자_너비부족_예외발생() { + assertThatThrownBy(() -> new ImageDimension(299, 200)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("너비는 300픽셀 이상"); + } + + @Test + void 생성자_높이부족_예외발생() { + assertThatThrownBy(() -> new ImageDimension(300, 199)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("높이는 200픽셀 이상"); + } + + @Test + void 생성자_비율불일치_예외발생() { + assertThatThrownBy(() -> new ImageDimension(300, 300)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("비율은 3:2"); + } +} diff --git a/src/test/java/nextstep/courses/domain/ImageFileTest.java b/src/test/java/nextstep/courses/domain/ImageFileTest.java new file mode 100644 index 000000000..e05f60c44 --- /dev/null +++ b/src/test/java/nextstep/courses/domain/ImageFileTest.java @@ -0,0 +1,31 @@ +package nextstep.courses.domain; + +import static org.assertj.core.api.Assertions.*; + +import org.junit.jupiter.api.DisplayNameGeneration; +import org.junit.jupiter.api.DisplayNameGenerator; +import org.junit.jupiter.api.Test; + +@DisplayNameGeneration(DisplayNameGenerator.ReplaceUnderscores.class) +class ImageFileTest { + @Test + void 생성자_정상입력_생성성공() { + assertThatCode(() -> new ImageFile("image.png", 1024 * 1024)).doesNotThrowAnyException(); + } + + @Test + void 생성자_파일크기초과_예외발생() { + long overSize = 1024 * 1024 + 1; + + assertThatThrownBy(() -> new ImageFile("image.png", overSize)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("이미지 크기는 1MB 이하여야 합니다."); + } + + @Test + void 생성자_확장자없음_예외발생() { + assertThatThrownBy(() -> new ImageFile("image", 1024 * 1024)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("파일 확장자가 없습니다"); + } +} diff --git a/src/test/java/nextstep/courses/domain/ImageTypeTest.java b/src/test/java/nextstep/courses/domain/ImageTypeTest.java new file mode 100644 index 000000000..27e530a32 --- /dev/null +++ b/src/test/java/nextstep/courses/domain/ImageTypeTest.java @@ -0,0 +1,29 @@ +package nextstep.courses.domain; + +import static org.assertj.core.api.Assertions.*; + +import org.junit.jupiter.api.DisplayNameGeneration; +import org.junit.jupiter.api.DisplayNameGenerator; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; + +@DisplayNameGeneration(DisplayNameGenerator.ReplaceUnderscores.class) +class ImageTypeTest { + + @ParameterizedTest(name = "입력값:{0}") + @ValueSource(strings = {"png", "PNG"}) + void from_정상입력_변환성공(String input) { + assertThat(ImageType.from(input)).isEqualTo(ImageType.PNG); + } + + @Test + void from_JPEG입력_JPG반환() { + assertThat(ImageType.from("jpeg")).isEqualTo(ImageType.JPG); + } + + @Test + void from_지원하지않는타입_예외발생() { + assertThatThrownBy(() -> ImageType.from("bmp")).isInstanceOf(IllegalArgumentException.class); + } +} diff --git a/src/test/java/nextstep/courses/domain/MoneyTest.java b/src/test/java/nextstep/courses/domain/MoneyTest.java new file mode 100644 index 000000000..54fd6b32e --- /dev/null +++ b/src/test/java/nextstep/courses/domain/MoneyTest.java @@ -0,0 +1,36 @@ +package nextstep.courses.domain; + +import static org.assertj.core.api.Assertions.*; + +import org.junit.jupiter.api.DisplayNameGeneration; +import org.junit.jupiter.api.DisplayNameGenerator; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.CsvSource; +import org.junit.jupiter.params.provider.ValueSource; + +@DisplayNameGeneration(DisplayNameGenerator.ReplaceUnderscores.class) +class MoneyTest { + + @ParameterizedTest(name = "입력값:{0}") + @ValueSource(ints = {0, 10000}) + void 생성자_정상입력_생성성공(int input) { + assertThatCode(() -> new Money(input)).doesNotThrowAnyException(); + } + + @Test + void 생성자_음수금액_예외발생() { + assertThatThrownBy(() -> new Money(-1)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("금액은 0 이상"); + } + + @ParameterizedTest(name = "금액 비교: {0} == {1} → {2}") + @CsvSource({"10000, 10000, true", "10000, 9999, false"}) + void isSameAs_다른금액_비교(int amount1, int amount2, boolean expected) { + Money moneyA = new Money(amount1); + Money moneyB = new Money(amount2); + + assertThat(moneyA.isSameAs(moneyB)).isEqualTo(expected); + } +} diff --git a/src/test/java/nextstep/courses/domain/PaidEnrollmentPolicyTest.java b/src/test/java/nextstep/courses/domain/PaidEnrollmentPolicyTest.java new file mode 100644 index 000000000..a3554f638 --- /dev/null +++ b/src/test/java/nextstep/courses/domain/PaidEnrollmentPolicyTest.java @@ -0,0 +1,43 @@ +package nextstep.courses.domain; + +import static org.assertj.core.api.Assertions.*; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayNameGeneration; +import org.junit.jupiter.api.DisplayNameGenerator; +import org.junit.jupiter.api.Test; + +@DisplayNameGeneration(DisplayNameGenerator.ReplaceUnderscores.class) +class PaidEnrollmentPolicyTest { + + private PaidEnrollmentPolicy policy; + + @BeforeEach + void setUp() { + policy = new PaidEnrollmentPolicy(3, 50000); + } + + @Test + void validate_정상입력_성공() { + assertThatCode(() -> policy.validate(new Money(50000), 0)).doesNotThrowAnyException(); + } + + @Test + void validate_결제금액불일치_예외발생() { + assertThatThrownBy(() -> policy.validate(new Money(30000), 0)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("결제 금액이 수강료와 일치하지 않습니다"); + } + + @Test + void validate_인원초과_예외발생() { + assertThatThrownBy(() -> policy.validate(new Money(50000), 3)) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("수강 인원이 초과"); + } + + @Test + void getType_PAID반환() { + assertThat(policy.getType()).isEqualTo(SessionType.PAID); + } +} diff --git a/src/test/java/nextstep/courses/domain/SessionPeriodTest.java b/src/test/java/nextstep/courses/domain/SessionPeriodTest.java new file mode 100644 index 000000000..c4f76b1be --- /dev/null +++ b/src/test/java/nextstep/courses/domain/SessionPeriodTest.java @@ -0,0 +1,29 @@ +package nextstep.courses.domain; + +import static org.assertj.core.api.Assertions.*; + +import java.time.LocalDate; +import org.junit.jupiter.api.DisplayNameGeneration; +import org.junit.jupiter.api.DisplayNameGenerator; +import org.junit.jupiter.api.Test; + +@DisplayNameGeneration(DisplayNameGenerator.ReplaceUnderscores.class) +class SessionPeriodTest { + @Test + void 생성자_정상입력_생성성공() { + LocalDate start = LocalDate.of(2024, 1, 1); + LocalDate end = LocalDate.of(2024, 1, 2); + + assertThatCode(() -> new SessionPeriod(start, end)).doesNotThrowAnyException(); + } + + @Test + void 생성자_종료일이시작일이전_예외발생() { + LocalDate start = LocalDate.of(2024, 1, 2); + LocalDate end = LocalDate.of(2024, 1, 1); + + assertThatThrownBy(() -> new SessionPeriod(start, end)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("종료일은 시작일 이후"); + } +} diff --git a/src/test/java/nextstep/courses/domain/SessionStatusTest.java b/src/test/java/nextstep/courses/domain/SessionStatusTest.java new file mode 100644 index 000000000..22f6f265c --- /dev/null +++ b/src/test/java/nextstep/courses/domain/SessionStatusTest.java @@ -0,0 +1,26 @@ +package nextstep.courses.domain; + +import static org.assertj.core.api.Assertions.*; + +import org.junit.jupiter.api.DisplayNameGeneration; +import org.junit.jupiter.api.DisplayNameGenerator; +import org.junit.jupiter.api.Test; + +@DisplayNameGeneration(DisplayNameGenerator.ReplaceUnderscores.class) +class SessionStatusTest { + + @Test + void canEnroll_준비중_수강신청불가() { + assertThat(SessionStatus.PREPARING.canEnroll()).isFalse(); + } + + @Test + void canEnroll_모집중_수강신청가능() { + assertThat(SessionStatus.RECRUITING.canEnroll()).isTrue(); + } + + @Test + void canEnroll_종료_수강신청불가() { + assertThat(SessionStatus.CLOSED.canEnroll()).isFalse(); + } +} 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 000000000..9cd1ae187 --- /dev/null +++ b/src/test/java/nextstep/courses/domain/SessionTest.java @@ -0,0 +1,56 @@ +package nextstep.courses.domain; + +import static org.assertj.core.api.Assertions.*; + +import java.time.LocalDate; +import java.time.LocalDateTime; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayNameGeneration; +import org.junit.jupiter.api.DisplayNameGenerator; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.EnumSource; + +@DisplayNameGeneration(DisplayNameGenerator.ReplaceUnderscores.class) +class SessionTest { + private CoverImage coverImage; + private SessionPeriod period; + + @BeforeEach + void setUp() { + coverImage = new CoverImage("image.png", 1024, 300, 200); + period = new SessionPeriod(LocalDate.of(2024, 1, 1), LocalDate.of(2024, 3, 31)); + } + + @Test + void 생성자_정상입력_생성성공() { + Session session = new Session(coverImage, period, new FreeEnrollmentPolicy()); + + assertThat(session.getStatus()).isEqualTo(SessionStatus.PREPARING); + assertThat(session.getType()).isEqualTo(SessionType.FREE); + } + + @Test + void enroll_모집중_성공() { + Session session = new Session(coverImage, period, SessionStatus.RECRUITING, new FreeEnrollmentPolicy()); + Enrollment enrollment = new Enrollment(1L, 1L, LocalDateTime.now()); + + session.enroll(enrollment, Money.ZERO); + + assertThat(session.enrollmentCount()).isEqualTo(1); + } + + @ParameterizedTest(name = "상태:{0}") + @EnumSource( + value = SessionStatus.class, + names = {"PREPARING", "CLOSED"}) + void enroll_모집중이아닐시_예외발생(SessionStatus status) { + Session session = new Session(coverImage, period, status, new FreeEnrollmentPolicy()); + + Enrollment enrollment = new Enrollment(1L, 1L, LocalDateTime.now()); + + assertThatThrownBy(() -> session.enroll(enrollment, Money.ZERO)) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("모집중인 강의만"); + } +} diff --git a/src/test/java/nextstep/courses/infrastructure/CourseRepositoryTest.java b/src/test/java/nextstep/courses/infrastructure/CourseRepositoryTest.java index 1289b7fe5..e30a074e4 100644 --- a/src/test/java/nextstep/courses/infrastructure/CourseRepositoryTest.java +++ b/src/test/java/nextstep/courses/infrastructure/CourseRepositoryTest.java @@ -2,6 +2,7 @@ import static org.assertj.core.api.Assertions.assertThat; +import java.time.LocalDateTime; import nextstep.courses.domain.Course; import nextstep.courses.domain.CourseRepository; import org.junit.jupiter.api.BeforeEach; @@ -28,11 +29,14 @@ void setUp() { @Test void crud() { - Course course = new Course("TDD, 클린 코드 with Java", 1L); + LocalDateTime fixednow = LocalDateTime.now(); + Course course = new Course("TDD, 클린 코드 with Java", 1L, fixednow); int count = courseRepository.save(course); assertThat(count).isEqualTo(1); Course savedCourse = courseRepository.findById(1L); assertThat(course.getTitle()).isEqualTo(savedCourse.getTitle()); + assertThat(course.getCreatorId()).isEqualTo(savedCourse.getCreatorId()); + assertThat(course.getCreatedAt()).isEqualTo(savedCourse.getCreatedAt()); LOGGER.debug("Course: {}", savedCourse); } }