From 2341fc32a86d7855d2450d1a6aa211bf3f5d393d Mon Sep 17 00:00:00 2001 From: ghtjr410 Date: Tue, 9 Dec 2025 22:02:11 +0900 Subject: [PATCH 01/45] =?UTF-8?q?docs:=20=EC=88=98=EA=B0=95=EC=8B=A0?= =?UTF-8?q?=EC=B2=AD=20=EB=8F=84=EB=A9=94=EC=9D=B8=20=EB=AA=A8=EB=8D=B8=20?= =?UTF-8?q?=EB=AC=B8=EC=84=9C=20=EC=B6=94=EA=B0=80=20=EB=B0=8F=20=EB=A7=81?= =?UTF-8?q?=ED=81=AC=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit README.md - 2단계 문서 링크를 `02-lms-domain-model.md`로 올바르게 연결하도록 수정 docs/02-lms-domain-model.md - 수강신청 도메인 모델 요구사항 문서 신규 추가 - LMS 개요 및 기능 요구사항 구조화 - 프로그래밍 요구사항 및 TDD 원칙 명시 --- README.md | 2 +- docs/02-lms-domain-model.md | 33 +++++++++++++++++++++++++++++++++ 2 files changed, 34 insertions(+), 1 deletion(-) create mode 100644 docs/02-lms-domain-model.md 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..234e52c03 --- /dev/null +++ b/docs/02-lms-domain-model.md @@ -0,0 +1,33 @@ +# 2단계 - 수강신청(도메인 모델) +*** +## 코드 리뷰 +> PR 링크: +> +## 나의 학습 목표 +- TDD 사이클로 구현 +- 객체지향 생활 체조 원칙 준수 +- 테스트 작성하기 쉬운 구조로 설계 +- 자기 점검 체크리스트 준수 +## 학습 관리 시스템(LMS) +- 넥스트스텝은 재직자를 대상으로 소프트웨어 교육을 진행하는 교육 기관이다. +- 2018년 교육 사업을 시작했다. +- 교육 사업을 시작하며 자체적으로 학습 관리 시스템을 개발해 수강생을 모집하고, 컨텐츠를 관리하고 있다. +## 수강 신청 기능 요구사항 +- 과정(Course)은 기수 단위로 운영하며, 여러 개의 강의(Session)를 가질 수 있다. +- 강의는 시작일과 종료일을 가진다. +- 강의는 강의 커버 이미지 정보를 가진다. + - 이미지 크기는 1MB 이하여야 한다. + - 이미지 타입은 gif, jpg(jpeg 포함),, png, svg만 허용한다. + - 이미지의 width는 300픽셀, height는 200픽셀 이상이어야 하며, width와 height의 비율은 3:2여야 한다. +- 강의는 무료 강의와 유료 강의로 나뉜다. + - 무료 강의는 최대 수강 인원 제한이 없다. + - 유료 강의는 강의 최대 수강 인원을 초과할 수 없다. + - 유료 강의는 수강생이 결제한 금액과 수강료가 일치할 때 수강 신청이 가능하다. +- 강의 상태는 준비중, 모집중, 종료 3가지 상태를 가진다. +- 강의 수강신청은 강의 상태가 모집중일 때만 가능하다. +- 유료 강의의 경우 결제는 이미 완료한 것으로 가정하고 이후 과정을 구현한다. + - 결제를 완료한 결제 정보는 payments 모듈을 통해 관리되며, 결제 정보는 Payment 객체에 담겨 반한된다. +## 프로그래밍 요구사항 +- DB 테이블 설꼐 없이 도메인 모델부터 구현한다. +- 도메인 모델은 TDD로 구현한다. + - 단, Service 클래스는 단위 테스트가 없어도 된다. \ No newline at end of file From 18aecc7330ca04c6533733357a39fc57e35a687c Mon Sep 17 00:00:00 2001 From: ghtjr410 Date: Tue, 9 Dec 2025 22:27:08 +0900 Subject: [PATCH 02/45] =?UTF-8?q?feat:=20Course=EC=97=90=20generation=20?= =?UTF-8?q?=ED=95=84=EB=93=9C=20=EC=B6=94=EA=B0=80=20=EB=B0=8F=20=EC=A0=80?= =?UTF-8?q?=EC=9E=A5/=EC=A1=B0=ED=9A=8C=20=EA=B8=B0=EB=8A=A5=20=ED=99=95?= =?UTF-8?q?=EC=9E=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Course.java - generation 필드 추가 및 Getter 구현 - 생성자 오버로드 수정 및 generation 반영 - 기존 필드 포함한 신규 전체 생성자 정의 JdbcCourseRepository.java - insert 쿼리에 generation 컬럼 추가 - findById 조회 시 generation 매핑 추가 및 파라미터 인덱스 조정 schema.sql - course 테이블에 generation 컬럼 추가 (NOT NULL) CourseTest.java - generation 포함 생성자 정상 생성 테스트 추가 CourseRepositoryTest.java - generation 포함한 Course 저장/조회 검증 추가 - createdAt 값 비교 테스트 추가 --- .../java/nextstep/courses/domain/Course.java | 18 +++++++++++++++--- .../infrastructure/JdbcCourseRepository.java | 14 ++++++++------ src/main/resources/schema.sql | 1 + .../nextstep/courses/domain/CourseTest.java | 18 ++++++++++++++++++ .../infrastructure/CourseRepositoryTest.java | 7 ++++++- 5 files changed, 48 insertions(+), 10 deletions(-) create mode 100644 src/test/java/nextstep/courses/domain/CourseTest.java diff --git a/src/main/java/nextstep/courses/domain/Course.java b/src/main/java/nextstep/courses/domain/Course.java index 176e0617b..97e5b383c 100644 --- a/src/main/java/nextstep/courses/domain/Course.java +++ b/src/main/java/nextstep/courses/domain/Course.java @@ -7,6 +7,8 @@ public class Course { private String title; + private int generation; + private Long creatorId; private LocalDateTime createdAt; @@ -15,13 +17,19 @@ public class Course { public Course() {} - public Course(String title, Long creatorId) { - this(0L, title, creatorId, LocalDateTime.now(), null); + public Course(String title, int generation, Long creatorId, LocalDateTime now) { + this(0L, title, generation, creatorId, now, null); + } + + public Course(String title, int generation, Long creatorId) { + this(0L, title, generation, creatorId, LocalDateTime.now(), null); } - public Course(Long id, String title, Long creatorId, LocalDateTime createdAt, LocalDateTime updatedAt) { + public Course( + Long id, String title, int generation, Long creatorId, LocalDateTime createdAt, LocalDateTime updatedAt) { this.id = id; this.title = title; + this.generation = generation; this.creatorId = creatorId; this.createdAt = createdAt; this.updatedAt = updatedAt; @@ -35,6 +43,10 @@ public Long getCreatorId() { return creatorId; } + public int getGeneration() { + return generation; + } + public LocalDateTime getCreatedAt() { return createdAt; } diff --git a/src/main/java/nextstep/courses/infrastructure/JdbcCourseRepository.java b/src/main/java/nextstep/courses/infrastructure/JdbcCourseRepository.java index 3075de280..3f21435f7 100644 --- a/src/main/java/nextstep/courses/infrastructure/JdbcCourseRepository.java +++ b/src/main/java/nextstep/courses/infrastructure/JdbcCourseRepository.java @@ -18,19 +18,21 @@ public JdbcCourseRepository(JdbcOperations jdbcTemplate) { @Override public int save(Course course) { - String sql = "insert into course (title, creator_id, created_at) values(?, ?, ?)"; - return jdbcTemplate.update(sql, course.getTitle(), course.getCreatorId(), course.getCreatedAt()); + String sql = "insert into course (title, generation, creator_id, created_at) values(?, ?, ?, ?)"; + return jdbcTemplate.update( + sql, course.getTitle(), course.getGeneration(), course.getCreatorId(), course.getCreatedAt()); } @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, generation, 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), - toLocalDateTime(rs.getTimestamp(4)), - toLocalDateTime(rs.getTimestamp(5))); + rs.getInt(3), + rs.getLong(4), + toLocalDateTime(rs.getTimestamp(5)), + toLocalDateTime(rs.getTimestamp(6))); return jdbcTemplate.queryForObject(sql, rowMapper, id); } diff --git a/src/main/resources/schema.sql b/src/main/resources/schema.sql index 8d5a988c8..746fcb244 100644 --- a/src/main/resources/schema.sql +++ b/src/main/resources/schema.sql @@ -1,6 +1,7 @@ create table course ( id bigint generated by default as identity, title varchar(255) not null, + generation INT NOT NULL, creator_id bigint not null, created_at timestamp not null, updated_at timestamp, 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..58e5bd30d --- /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("과정명", 1, 1L, fixedNow)).doesNotThrowAnyException(); + } +} diff --git a/src/test/java/nextstep/courses/infrastructure/CourseRepositoryTest.java b/src/test/java/nextstep/courses/infrastructure/CourseRepositoryTest.java index 1289b7fe5..c79525793 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,15 @@ void setUp() { @Test void crud() { - Course course = new Course("TDD, 클린 코드 with Java", 1L); + LocalDateTime fixednow = LocalDateTime.now(); + Course course = new Course("TDD, 클린 코드 with Java", 1, 1L, fixednow); int count = courseRepository.save(course); assertThat(count).isEqualTo(1); Course savedCourse = courseRepository.findById(1L); assertThat(course.getTitle()).isEqualTo(savedCourse.getTitle()); + assertThat(course.getGeneration()).isEqualTo(savedCourse.getGeneration()); + assertThat(course.getCreatorId()).isEqualTo(savedCourse.getCreatorId()); + assertThat(course.getCreatedAt()).isEqualTo(savedCourse.getCreatedAt()); LOGGER.debug("Course: {}", savedCourse); } } From ed41d5bc85ea0dbba1b85880ca60d2fc4e4dcdc5 Mon Sep 17 00:00:00 2001 From: ghtjr410 Date: Wed, 10 Dec 2025 06:34:03 +0900 Subject: [PATCH 03/45] =?UTF-8?q?feat:=20=EC=9D=B4=EB=AF=B8=EC=A7=80=20?= =?UTF-8?q?=ED=83=80=EC=9E=85=20enum=20=EC=B6=94=EA=B0=80=20=EB=B0=8F=20?= =?UTF-8?q?=EB=B3=80=ED=99=98=20=EB=A1=9C=EC=A7=81=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ImageType.java - GIF, JPG, PNG, SVG enum 정의 - jpeg 입력 시 JPG로 매핑하는 from() 메서드 구현 - 미지원 타입은 기존 valueOf에서 예외 발생 ImageTypeTest.java - jpeg 입력 시 JPG 반환 테스트 추가 - 미지원 타입 입력 시 예외 발생 테스트 추가 --- .../nextstep/courses/domain/ImageType.java | 15 +++++++++++++ .../courses/domain/ImageTypeTest.java | 21 +++++++++++++++++++ 2 files changed, 36 insertions(+) create mode 100644 src/main/java/nextstep/courses/domain/ImageType.java create mode 100644 src/test/java/nextstep/courses/domain/ImageTypeTest.java 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..8fddcd68a --- /dev/null +++ b/src/main/java/nextstep/courses/domain/ImageType.java @@ -0,0 +1,15 @@ +package nextstep.courses.domain; + +public enum ImageType { + GIF, + JPG, + PNG, + SVG; + + public static ImageType from(String extension) { + if (extension.equals("jpeg")) { + return JPG; + } + return valueOf(extension); + } +} 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..1a23bdcfa --- /dev/null +++ b/src/test/java/nextstep/courses/domain/ImageTypeTest.java @@ -0,0 +1,21 @@ +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 ImageTypeTest { + + @Test + void from_JPEG입력_JPG반환() { + assertThat(ImageType.from("jpeg")).isEqualTo(ImageType.JPG); + } + + @Test + void from_지원하지않는타입_예외발생() { + assertThatThrownBy(() -> ImageType.from("bmp")).isInstanceOf(IllegalArgumentException.class); + } +} From f7e3a1032fc45021f2e881f908fc32bd307f3f57 Mon Sep 17 00:00:00 2001 From: ghtjr410 Date: Wed, 10 Dec 2025 06:36:27 +0900 Subject: [PATCH 04/45] =?UTF-8?q?refactor:=20ImageType=20=EB=8C=80?= =?UTF-8?q?=EC=86=8C=EB=AC=B8=EC=9E=90=20=EC=B2=98=EB=A6=AC=20=EA=B0=9C?= =?UTF-8?q?=EC=84=A0=20=EB=B0=8F=20=ED=8C=8C=EB=9D=BC=EB=AF=B8=ED=84=B0?= =?UTF-8?q?=ED=99=94=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ImageType.java - 확장자 입력값을 대문자로 변환 후 처리하도록 개선 - JPEG → JPG 매핑 조건을 대문자 기준으로 수정 - valueOf 호출 시 대문자 사용으로 케이스 민감도 문제 해결 ImageTypeTest.java - PNG 입력값에 대해 파라미터화된 변환 성공 테스트 추가(png/PNG) - 기존 JPEG → JPG 테스트 유지 --- src/main/java/nextstep/courses/domain/ImageType.java | 5 +++-- src/test/java/nextstep/courses/domain/ImageTypeTest.java | 8 ++++++++ 2 files changed, 11 insertions(+), 2 deletions(-) diff --git a/src/main/java/nextstep/courses/domain/ImageType.java b/src/main/java/nextstep/courses/domain/ImageType.java index 8fddcd68a..9fb5d6d6c 100644 --- a/src/main/java/nextstep/courses/domain/ImageType.java +++ b/src/main/java/nextstep/courses/domain/ImageType.java @@ -7,9 +7,10 @@ public enum ImageType { SVG; public static ImageType from(String extension) { - if (extension.equals("jpeg")) { + String upper = extension.toUpperCase(); + if (upper.equals("JPEG")) { return JPG; } - return valueOf(extension); + return valueOf(upper); } } diff --git a/src/test/java/nextstep/courses/domain/ImageTypeTest.java b/src/test/java/nextstep/courses/domain/ImageTypeTest.java index 1a23bdcfa..27e530a32 100644 --- a/src/test/java/nextstep/courses/domain/ImageTypeTest.java +++ b/src/test/java/nextstep/courses/domain/ImageTypeTest.java @@ -5,10 +5,18 @@ 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); From 5ce49fc22b302f51d193b8b88db4e63669b70c67 Mon Sep 17 00:00:00 2001 From: ghtjr410 Date: Wed, 10 Dec 2025 06:39:00 +0900 Subject: [PATCH 05/45] =?UTF-8?q?feat:=20CoverImage=20=EB=8F=84=EB=A9=94?= =?UTF-8?q?=EC=9D=B8=20=EC=B6=94=EA=B0=80=20=EB=B0=8F=20=ED=8C=8C=EC=9D=BC?= =?UTF-8?q?=20=ED=81=AC=EA=B8=B0=20=EA=B2=80=EC=A6=9D=20=EB=A1=9C=EC=A7=81?= =?UTF-8?q?=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CoverImage.java - CoverImage 도메인 클래스 신규 추가 - 파일 크기 최대 1MB 제한 상수 정의 - 생성 시 파일 크기 검증 수행 - 검증 실패 시 IllegalArgumentException 발생하도록 구현 CoverImageTest.java - 정상 생성 테스트 추가 - 파일 크기 초과 시 예외 발생 테스트 추가 --- .../nextstep/courses/domain/CoverImage.java | 25 +++++++++++++++++++ .../courses/domain/CoverImageTest.java | 25 +++++++++++++++++++ 2 files changed, 50 insertions(+) create mode 100644 src/main/java/nextstep/courses/domain/CoverImage.java create mode 100644 src/test/java/nextstep/courses/domain/CoverImageTest.java 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..f0fa69d34 --- /dev/null +++ b/src/main/java/nextstep/courses/domain/CoverImage.java @@ -0,0 +1,25 @@ +package nextstep.courses.domain; + +public class CoverImage { + private static final long MAX_FILE_SIZE = 1024 * 1024; + + private String filename; + private long fileSize; + private ImageType imageType; + private int width; + private int height; + + public CoverImage(String filename, long fileSize, ImageType imageType, int width, int height) { + validateFileSize(fileSize); + this.filename = filename; + this.fileSize = fileSize; + this.imageType = imageType; + this.width = width; + this.height = height; + } + + private void validateFileSize(long fileSize) { + if (fileSize > MAX_FILE_SIZE) + throw new IllegalArgumentException("이미지 크기는 1MB 이하여야 합니다. (입력: %d bytes)", fileSize); + } +} 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 000000000..ba26d71d5 --- /dev/null +++ b/src/test/java/nextstep/courses/domain/CoverImageTest.java @@ -0,0 +1,25 @@ +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 CoverImageTest { + + @Test + void 생성자_정상입력_생성성공() { + assertThatCode(() -> new CoverImage("image.png", 1024 * 1024, ImageType.PNG, 300, 200)) + .doesNotThrowAnyException(); + } + + @Test + void 생성자_파일크기초과_예외발생() { + long overSize = 1024 * 1024 + 1; + assertThatThrownBy(() -> new CoverImage("image.png", overSize, ImageType.PNG, 300, 200)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("이미지 크기는 1MB 이하여야 합니다."); + } +} From ae72cccad08050e7164b9563110c0c0d83c9afa6 Mon Sep 17 00:00:00 2001 From: ghtjr410 Date: Wed, 10 Dec 2025 06:46:06 +0900 Subject: [PATCH 06/45] =?UTF-8?q?feat:=20CoverImage=EC=97=90=20=EC=B5=9C?= =?UTF-8?q?=EC=86=8C=20=ED=95=B4=EC=83=81=EB=8F=84=20=EA=B2=80=EC=A6=9D=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CoverImage.java - 최소 너비/높이 상수(MIN_WIDTH=300, MIN_HEIGHT=200) 추가 - 생성 시 validateDimension 호출하여 해상도 검증 추가 - 너비/높이 부족 시 상세 메시지 포함 예외 발생 CoverImageTest.java - 너비 부족 시 예외 발생 테스트 추가 - 높이 부족 시 예외 발생 테스트 추가 --- .../java/nextstep/courses/domain/CoverImage.java | 14 +++++++++++++- .../nextstep/courses/domain/CoverImageTest.java | 14 ++++++++++++++ 2 files changed, 27 insertions(+), 1 deletion(-) diff --git a/src/main/java/nextstep/courses/domain/CoverImage.java b/src/main/java/nextstep/courses/domain/CoverImage.java index f0fa69d34..434f2511b 100644 --- a/src/main/java/nextstep/courses/domain/CoverImage.java +++ b/src/main/java/nextstep/courses/domain/CoverImage.java @@ -2,6 +2,8 @@ public class CoverImage { private static final long MAX_FILE_SIZE = 1024 * 1024; + private static final int MIN_WIDTH = 300; + private static final int MIN_HEIGHT = 200; private String filename; private long fileSize; @@ -11,6 +13,7 @@ public class CoverImage { public CoverImage(String filename, long fileSize, ImageType imageType, int width, int height) { validateFileSize(fileSize); + validateDimension(width, height); this.filename = filename; this.fileSize = fileSize; this.imageType = imageType; @@ -20,6 +23,15 @@ public CoverImage(String filename, long fileSize, ImageType imageType, int width private void validateFileSize(long fileSize) { if (fileSize > MAX_FILE_SIZE) - throw new IllegalArgumentException("이미지 크기는 1MB 이하여야 합니다. (입력: %d bytes)", fileSize); + throw new IllegalArgumentException(String.format("이미지 크기는 1MB 이하여야 합니다. (입력: %d bytes)", fileSize)); + } + + 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)); + } } } diff --git a/src/test/java/nextstep/courses/domain/CoverImageTest.java b/src/test/java/nextstep/courses/domain/CoverImageTest.java index ba26d71d5..b9747cb42 100644 --- a/src/test/java/nextstep/courses/domain/CoverImageTest.java +++ b/src/test/java/nextstep/courses/domain/CoverImageTest.java @@ -22,4 +22,18 @@ class CoverImageTest { .isInstanceOf(IllegalArgumentException.class) .hasMessageContaining("이미지 크기는 1MB 이하여야 합니다."); } + + @Test + void 생성_너비부족_예외발생() { + assertThatThrownBy(() -> new CoverImage("image.png", 1024, ImageType.PNG, 299, 200)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("너비는 300픽셀 이상"); + } + + @Test + void 생성_높이부족_예외발생() { + assertThatThrownBy(() -> new CoverImage("image.png", 1024, ImageType.PNG, 300, 199)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("높이는 200픽셀 이상"); + } } From 44cfb1ccb9df2eb185eefa9a4f8ca1a6074bdb05 Mon Sep 17 00:00:00 2001 From: ghtjr410 Date: Wed, 10 Dec 2025 06:48:28 +0900 Subject: [PATCH 07/45] =?UTF-8?q?feat:=20CoverImage=EC=97=90=20=EC=9D=B4?= =?UTF-8?q?=EB=AF=B8=EC=A7=80=20=EB=B9=84=EC=9C=A8(3:2)=20=EA=B2=80?= =?UTF-8?q?=EC=A6=9D=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CoverImage.java - WIDTH_RATIO=3, HEIGHT_RATIO=2 상수 추가 - 생성 시 validateAspectRatio 실행하도록 추가 - 비율 불일치 시 상세 메시지 포함 예외 발생 CoverImageTest.java - 3:2 비율이 아닐 경우 예외 발생 테스트 추가 --- src/main/java/nextstep/courses/domain/CoverImage.java | 9 +++++++++ .../java/nextstep/courses/domain/CoverImageTest.java | 7 +++++++ 2 files changed, 16 insertions(+) diff --git a/src/main/java/nextstep/courses/domain/CoverImage.java b/src/main/java/nextstep/courses/domain/CoverImage.java index 434f2511b..1a9d7b1b7 100644 --- a/src/main/java/nextstep/courses/domain/CoverImage.java +++ b/src/main/java/nextstep/courses/domain/CoverImage.java @@ -4,6 +4,8 @@ public class CoverImage { private static final long MAX_FILE_SIZE = 1024 * 1024; 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 String filename; private long fileSize; @@ -14,6 +16,7 @@ public class CoverImage { public CoverImage(String filename, long fileSize, ImageType imageType, int width, int height) { validateFileSize(fileSize); validateDimension(width, height); + validateAspectRatio(width, height); this.filename = filename; this.fileSize = fileSize; this.imageType = imageType; @@ -34,4 +37,10 @@ private void validateDimension(int width, int 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/test/java/nextstep/courses/domain/CoverImageTest.java b/src/test/java/nextstep/courses/domain/CoverImageTest.java index b9747cb42..2169ae305 100644 --- a/src/test/java/nextstep/courses/domain/CoverImageTest.java +++ b/src/test/java/nextstep/courses/domain/CoverImageTest.java @@ -36,4 +36,11 @@ class CoverImageTest { .isInstanceOf(IllegalArgumentException.class) .hasMessageContaining("높이는 200픽셀 이상"); } + + @Test + void 생성_비율불일치_예외발생() { + assertThatThrownBy(() -> new CoverImage("image.png", 1024, ImageType.PNG, 300, 300)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("비율은 3:2"); + } } From 87608e0a0061dd5992599fe7ba54f561c4fa5a55 Mon Sep 17 00:00:00 2001 From: ghtjr410 Date: Wed, 10 Dec 2025 06:52:50 +0900 Subject: [PATCH 08/45] =?UTF-8?q?feat:=20CoverImage=EC=97=90=20=ED=99=95?= =?UTF-8?q?=EC=9E=A5=EC=9E=90=20=EC=9E=90=EB=8F=99=20=EC=B6=94=EC=B6=9C=20?= =?UTF-8?q?=EB=B0=8F=20=EA=B2=80=EC=A6=9D=20=EB=A1=9C=EC=A7=81=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CoverImage.java - filename 기반으로 확장자를 추출해 ImageType을 자동 결정하는 생성자 추가 - extractExtension 메서드 추가하여 확장자 누락/잘못된 형식 검증 - 기존 생성자 로직과 동일하게 파일 크기·해상도·비율 검증 수행 CoverImageTest.java - 확장자 미존재 시 예외 발생 테스트 추가 --- .../java/nextstep/courses/domain/CoverImage.java | 12 ++++++++++++ .../java/nextstep/courses/domain/CoverImageTest.java | 7 +++++++ 2 files changed, 19 insertions(+) diff --git a/src/main/java/nextstep/courses/domain/CoverImage.java b/src/main/java/nextstep/courses/domain/CoverImage.java index 1a9d7b1b7..be4a411cf 100644 --- a/src/main/java/nextstep/courses/domain/CoverImage.java +++ b/src/main/java/nextstep/courses/domain/CoverImage.java @@ -13,6 +13,10 @@ public class CoverImage { private int width; private int height; + public CoverImage(String filename, long fileSize, int width, int height) { + this(filename, fileSize, ImageType.from(extractExtension(filename)), width, height); + } + public CoverImage(String filename, long fileSize, ImageType imageType, int width, int height) { validateFileSize(fileSize); validateDimension(width, height); @@ -24,6 +28,14 @@ public CoverImage(String filename, long fileSize, ImageType imageType, int width this.height = height; } + private static String extractExtension(String filename) { + int dotIndex = filename.lastIndexOf('.'); + if (dotIndex == -1 || dotIndex == filename.length() - 1) { + throw new IllegalArgumentException("파일 확장자가 없습니다. (입력: " + filename + ")"); + } + return filename.substring(dotIndex + 1); + } + private void validateFileSize(long fileSize) { if (fileSize > MAX_FILE_SIZE) throw new IllegalArgumentException(String.format("이미지 크기는 1MB 이하여야 합니다. (입력: %d bytes)", fileSize)); diff --git a/src/test/java/nextstep/courses/domain/CoverImageTest.java b/src/test/java/nextstep/courses/domain/CoverImageTest.java index 2169ae305..8cbcccfd6 100644 --- a/src/test/java/nextstep/courses/domain/CoverImageTest.java +++ b/src/test/java/nextstep/courses/domain/CoverImageTest.java @@ -23,6 +23,13 @@ class CoverImageTest { .hasMessageContaining("이미지 크기는 1MB 이하여야 합니다."); } + @Test + void 생성_확장자없음_예외발생() { + assertThatThrownBy(() -> new CoverImage("image", 1024, 300, 200)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("파일 확장자가 없습니다"); + } + @Test void 생성_너비부족_예외발생() { assertThatThrownBy(() -> new CoverImage("image.png", 1024, ImageType.PNG, 299, 200)) From 95a274cd16cfa75250683b8ebbb63e59efb43fa0 Mon Sep 17 00:00:00 2001 From: ghtjr410 Date: Wed, 10 Dec 2025 07:13:01 +0900 Subject: [PATCH 09/45] =?UTF-8?q?feat:=20=EC=9D=B4=EB=AF=B8=EC=A7=80=20?= =?UTF-8?q?=ED=95=B4=EC=83=81=EB=8F=84=20=EC=A0=84=EC=9A=A9=20Value=20Obje?= =?UTF-8?q?ct=20ImageDimension=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ImageDimension.java - 최소 너비(300), 최소 높이(200), 비율(3:2) 검증 로직 포함한 VO 추가 - 생성 시 해상도 조건 및 비율 조건 유효성 검증 수행 - width, height 불변 값으로 보관 ImageDimensionTest.java - 정상 생성 테스트 추가 - 너비 부족, 높이 부족, 비율 불일치 경우 각각 예외 검증 테스트 추가 --- .../courses/domain/ImageDimension.java | 33 +++++++++++++++++ .../courses/domain/ImageDimensionTest.java | 36 +++++++++++++++++++ 2 files changed, 69 insertions(+) create mode 100644 src/main/java/nextstep/courses/domain/ImageDimension.java create mode 100644 src/test/java/nextstep/courses/domain/ImageDimensionTest.java 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/test/java/nextstep/courses/domain/ImageDimensionTest.java b/src/test/java/nextstep/courses/domain/ImageDimensionTest.java new file mode 100644 index 000000000..8ec352bef --- /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"); + } +} From 282297fc32434c4b02f18b8061bcda05bbaa48f2 Mon Sep 17 00:00:00 2001 From: ghtjr410 Date: Wed, 10 Dec 2025 07:15:46 +0900 Subject: [PATCH 10/45] =?UTF-8?q?feat:=20=EC=9D=B4=EB=AF=B8=EC=A7=80=20?= =?UTF-8?q?=ED=8C=8C=EC=9D=BC=20=EC=A0=84=EC=9A=A9=20Value=20Object=20Imag?= =?UTF-8?q?eFile=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ImageFile.java - 파일 크기 최대 1MB 제한 검증 추가 - 파일명에서 확장자를 추출하여 ImageType 자동 매핑 - 확장자 없음 또는 빈 확장자일 경우 예외 발생 ImageFileTest.java - 정상 생성 테스트 추가 - 파일 크기 초과 시 예외 발생 테스트 추가 - 확장자 누락 시 예외 발생 테스트 추가 --- .../nextstep/courses/domain/ImageFile.java | 30 ++++++++++++++++++ .../courses/domain/ImageFileTest.java | 31 +++++++++++++++++++ 2 files changed, 61 insertions(+) create mode 100644 src/main/java/nextstep/courses/domain/ImageFile.java create mode 100644 src/test/java/nextstep/courses/domain/ImageFileTest.java 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/test/java/nextstep/courses/domain/ImageFileTest.java b/src/test/java/nextstep/courses/domain/ImageFileTest.java new file mode 100644 index 000000000..c82306408 --- /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("파일 확장자가 없습니다"); + } +} From de23c2a47559016e508fef82b258ed118f16f2f2 Mon Sep 17 00:00:00 2001 From: ghtjr410 Date: Wed, 10 Dec 2025 07:18:38 +0900 Subject: [PATCH 11/45] =?UTF-8?q?refactor:=20CoverImage=EB=A5=BC=20ImageFi?= =?UTF-8?q?le=C2=B7ImageDimension=20VO=20=EC=A1=B0=ED=95=A9=20=EA=B5=AC?= =?UTF-8?q?=EC=A1=B0=EB=A1=9C=20=EB=8B=A8=EC=88=9C=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CoverImage.java - 파일 검증 → ImageFile로 위임 - 해상도·비율 검증 → ImageDimension으로 위임 - CoverImage는 두 VO를 구성요소로 보유하도록 변경 - 기존 개별 검증 로직 및 생성자 삭제 CoverImageTest.java - 파일/해상도/비율 검증 책임이 개별 VO로 이동함에 따라 테스트 제거 --- .../nextstep/courses/domain/CoverImage.java | 55 ++----------------- .../courses/domain/CoverImageTest.java | 53 ------------------ 2 files changed, 4 insertions(+), 104 deletions(-) delete mode 100644 src/test/java/nextstep/courses/domain/CoverImageTest.java diff --git a/src/main/java/nextstep/courses/domain/CoverImage.java b/src/main/java/nextstep/courses/domain/CoverImage.java index be4a411cf..62c15cc39 100644 --- a/src/main/java/nextstep/courses/domain/CoverImage.java +++ b/src/main/java/nextstep/courses/domain/CoverImage.java @@ -1,58 +1,11 @@ package nextstep.courses.domain; public class CoverImage { - private static final long MAX_FILE_SIZE = 1024 * 1024; - 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 String filename; - private long fileSize; - private ImageType imageType; - private int width; - private int height; + private final ImageFile imageFile; + private final ImageDimension dimension; public CoverImage(String filename, long fileSize, int width, int height) { - this(filename, fileSize, ImageType.from(extractExtension(filename)), width, height); - } - - public CoverImage(String filename, long fileSize, ImageType imageType, int width, int height) { - validateFileSize(fileSize); - validateDimension(width, height); - validateAspectRatio(width, height); - this.filename = filename; - this.fileSize = fileSize; - this.imageType = imageType; - this.width = width; - this.height = height; - } - - private static String extractExtension(String filename) { - int dotIndex = filename.lastIndexOf('.'); - if (dotIndex == -1 || dotIndex == filename.length() - 1) { - throw new IllegalArgumentException("파일 확장자가 없습니다. (입력: " + filename + ")"); - } - return filename.substring(dotIndex + 1); - } - - private void validateFileSize(long fileSize) { - if (fileSize > MAX_FILE_SIZE) - throw new IllegalArgumentException(String.format("이미지 크기는 1MB 이하여야 합니다. (입력: %d bytes)", fileSize)); - } - - 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)); - } + this.imageFile = new ImageFile(filename, fileSize); + this.dimension = new ImageDimension(width, height); } } diff --git a/src/test/java/nextstep/courses/domain/CoverImageTest.java b/src/test/java/nextstep/courses/domain/CoverImageTest.java deleted file mode 100644 index 8cbcccfd6..000000000 --- a/src/test/java/nextstep/courses/domain/CoverImageTest.java +++ /dev/null @@ -1,53 +0,0 @@ -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 CoverImageTest { - - @Test - void 생성자_정상입력_생성성공() { - assertThatCode(() -> new CoverImage("image.png", 1024 * 1024, ImageType.PNG, 300, 200)) - .doesNotThrowAnyException(); - } - - @Test - void 생성자_파일크기초과_예외발생() { - long overSize = 1024 * 1024 + 1; - assertThatThrownBy(() -> new CoverImage("image.png", overSize, ImageType.PNG, 300, 200)) - .isInstanceOf(IllegalArgumentException.class) - .hasMessageContaining("이미지 크기는 1MB 이하여야 합니다."); - } - - @Test - void 생성_확장자없음_예외발생() { - assertThatThrownBy(() -> new CoverImage("image", 1024, 300, 200)) - .isInstanceOf(IllegalArgumentException.class) - .hasMessageContaining("파일 확장자가 없습니다"); - } - - @Test - void 생성_너비부족_예외발생() { - assertThatThrownBy(() -> new CoverImage("image.png", 1024, ImageType.PNG, 299, 200)) - .isInstanceOf(IllegalArgumentException.class) - .hasMessageContaining("너비는 300픽셀 이상"); - } - - @Test - void 생성_높이부족_예외발생() { - assertThatThrownBy(() -> new CoverImage("image.png", 1024, ImageType.PNG, 300, 199)) - .isInstanceOf(IllegalArgumentException.class) - .hasMessageContaining("높이는 200픽셀 이상"); - } - - @Test - void 생성_비율불일치_예외발생() { - assertThatThrownBy(() -> new CoverImage("image.png", 1024, ImageType.PNG, 300, 300)) - .isInstanceOf(IllegalArgumentException.class) - .hasMessageContaining("비율은 3:2"); - } -} From 4c31460aff40589cb586b480061a11a965f9e64f Mon Sep 17 00:00:00 2001 From: ghtjr410 Date: Wed, 10 Dec 2025 07:24:37 +0900 Subject: [PATCH 12/45] =?UTF-8?q?feat:=20SessionStatus=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80=20=EB=B0=8F=20=EC=88=98=EA=B0=95=EC=8B=A0=EC=B2=AD=20?= =?UTF-8?q?=EA=B0=80=EB=8A=A5=20=EC=97=AC=EB=B6=80=20=ED=8C=90=EB=8B=A8=20?= =?UTF-8?q?=EB=A1=9C=EC=A7=81=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit SessionStatus.java - PREPARING, RECRUITING, CLOSED 상태 enum 정의 - 상태별 설명 필드 추가 - 모집중(RECRUITING)일 때만 수강신청 가능하도록 canEnroll() 메서드 구현 SessionStatusTest.java - 준비중/종료 상태에서는 신청 불가, 모집중일 때만 신청 가능함을 검증하는 테스트 추가 --- .../courses/domain/SessionStatus.java | 17 ++++++++++++ .../courses/domain/SessionStatusTest.java | 26 +++++++++++++++++++ 2 files changed, 43 insertions(+) create mode 100644 src/main/java/nextstep/courses/domain/SessionStatus.java create mode 100644 src/test/java/nextstep/courses/domain/SessionStatusTest.java 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/test/java/nextstep/courses/domain/SessionStatusTest.java b/src/test/java/nextstep/courses/domain/SessionStatusTest.java new file mode 100644 index 000000000..d4c17903d --- /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 준비중_수강신청불가() { + assertThat(SessionStatus.PREPARING.canEnroll()).isFalse(); + } + + @Test + void 모집중_수강신청가능() { + assertThat(SessionStatus.RECRUITING.canEnroll()).isTrue(); + } + + @Test + void 종료_수강신청불가() { + assertThat(SessionStatus.CLOSED.canEnroll()).isFalse(); + } +} From 6f596756c1e7903424c88fe10cde312351c527ce Mon Sep 17 00:00:00 2001 From: ghtjr410 Date: Wed, 10 Dec 2025 07:27:29 +0900 Subject: [PATCH 13/45] =?UTF-8?q?feat:=20SessionPeriod=20=EA=B0=92=20?= =?UTF-8?q?=EA=B0=9D=EC=B2=B4=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit SessionPeriod.java - 시작일·종료일을 표현하는 VO 추가 - 종료일이 시작일보다 이전일 경우 예외 발생하도록 검증 로직 구현 SessionPeriodTest.java - 정상 생성 테스트 추가 - 종료일 < 시작일일 때 예외 발생 테스트 추가 --- .../courses/domain/SessionPeriod.java | 21 ++++++++++++++ .../courses/domain/SessionPeriodTest.java | 29 +++++++++++++++++++ 2 files changed, 50 insertions(+) create mode 100644 src/main/java/nextstep/courses/domain/SessionPeriod.java create mode 100644 src/test/java/nextstep/courses/domain/SessionPeriodTest.java 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/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("종료일은 시작일 이후"); + } +} From 658f20b791c1eccc1a2a7d3828678e8c20aaaeae Mon Sep 17 00:00:00 2001 From: ghtjr410 Date: Wed, 10 Dec 2025 07:33:05 +0900 Subject: [PATCH 14/45] =?UTF-8?q?feat:=20=EA=B8=88=EC=95=A1=20=EA=B0=92=20?= =?UTF-8?q?=EA=B0=9D=EC=B2=B4=20Money=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Money.java - 음수 금액을 허용하지 않는 Money VO 추가 - 0 이상인지 검증하는 validate 로직 구현 MoneyTest.java - 0 및 양수 입력 시 정상 생성 테스트 추가 - 음수 입력 시 예외 발생 테스트 추가 --- .../java/nextstep/courses/domain/Money.java | 14 ++++++++++ .../nextstep/courses/domain/MoneyTest.java | 26 +++++++++++++++++++ 2 files changed, 40 insertions(+) create mode 100644 src/main/java/nextstep/courses/domain/Money.java create mode 100644 src/test/java/nextstep/courses/domain/MoneyTest.java 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..2fa73ebf7 --- /dev/null +++ b/src/main/java/nextstep/courses/domain/Money.java @@ -0,0 +1,14 @@ +package nextstep.courses.domain; + +public class Money { + private final int value; + + public Money(int value) { + validate(value); + this.value = value; + } + + private static void validate(int value) { + if (value < 0) throw new IllegalArgumentException(String.format("금액은 0 이상이어야 합니다. (입력: %d)", value)); + } +} 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..738e6fbba --- /dev/null +++ b/src/test/java/nextstep/courses/domain/MoneyTest.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; +import org.junit.jupiter.params.ParameterizedTest; +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 이상"); + } +} From b05d3c7b8064282f688d11a5891ed81f72b2a71d Mon Sep 17 00:00:00 2001 From: ghtjr410 Date: Wed, 10 Dec 2025 07:39:23 +0900 Subject: [PATCH 15/45] =?UTF-8?q?feat:=20Money=20=EB=B9=84=EA=B5=90=20?= =?UTF-8?q?=EA=B8=B0=EB=8A=A5=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Money.java - 필드명을 value → amount로 변경하여 의미 명확화 - 동일 금액 비교를 위한 isSameAs 메서드 추가 MoneyTest.java - isSameAs에 대한 비교 테스트(CsvSource 기반) 추가 - 동일 금액/상이한 금액에 대한 기대 결과 검증 --- src/main/java/nextstep/courses/domain/Money.java | 12 ++++++++---- src/test/java/nextstep/courses/domain/MoneyTest.java | 10 ++++++++++ 2 files changed, 18 insertions(+), 4 deletions(-) diff --git a/src/main/java/nextstep/courses/domain/Money.java b/src/main/java/nextstep/courses/domain/Money.java index 2fa73ebf7..4dc3bfd7f 100644 --- a/src/main/java/nextstep/courses/domain/Money.java +++ b/src/main/java/nextstep/courses/domain/Money.java @@ -1,14 +1,18 @@ package nextstep.courses.domain; public class Money { - private final int value; + private final int amount; - public Money(int value) { - validate(value); - this.value = value; + 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/test/java/nextstep/courses/domain/MoneyTest.java b/src/test/java/nextstep/courses/domain/MoneyTest.java index 738e6fbba..54fd6b32e 100644 --- a/src/test/java/nextstep/courses/domain/MoneyTest.java +++ b/src/test/java/nextstep/courses/domain/MoneyTest.java @@ -6,6 +6,7 @@ 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) @@ -23,4 +24,13 @@ class MoneyTest { .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); + } } From f77c36874f12cd2c9f106f7120f56b33f44ee92d Mon Sep 17 00:00:00 2001 From: ghtjr410 Date: Wed, 10 Dec 2025 07:41:16 +0900 Subject: [PATCH 16/45] =?UTF-8?q?refactor:=20=ED=85=8C=EC=8A=A4=ED=8A=B8?= =?UTF-8?q?=20=EB=A9=94=EC=84=9C=EB=93=9C=20=EB=AA=85=EC=9D=84=20=EC=83=9D?= =?UTF-8?q?=EC=84=B1=EC=9E=90/=EA=B8=B0=EB=8A=A5=20=EC=A4=91=EC=8B=AC=20?= =?UTF-8?q?=EB=84=A4=EC=9D=B4=EB=B0=8D=EC=9C=BC=EB=A1=9C=20=EC=A0=95?= =?UTF-8?q?=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ImageDimensionTest.java - 테스트 메서드명을 `생성자_*` 형태로 일관되게 변경 ImageFileTest.java - 테스트 메서드명을 `생성자_*` 형태로 일관되게 변경 SessionStatusTest.java - canEnroll 검증 메서드명을 `canEnroll_*` 형태로 명확하게 수정 --- .../java/nextstep/courses/domain/ImageDimensionTest.java | 8 ++++---- src/test/java/nextstep/courses/domain/ImageFileTest.java | 6 +++--- .../java/nextstep/courses/domain/SessionStatusTest.java | 6 +++--- 3 files changed, 10 insertions(+), 10 deletions(-) diff --git a/src/test/java/nextstep/courses/domain/ImageDimensionTest.java b/src/test/java/nextstep/courses/domain/ImageDimensionTest.java index 8ec352bef..9e2c00b62 100644 --- a/src/test/java/nextstep/courses/domain/ImageDimensionTest.java +++ b/src/test/java/nextstep/courses/domain/ImageDimensionTest.java @@ -9,26 +9,26 @@ @DisplayNameGeneration(DisplayNameGenerator.ReplaceUnderscores.class) class ImageDimensionTest { @Test - void 생성_정상입력_생성성공() { + void 생성자_정상입력_생성성공() { assertThatCode(() -> new ImageDimension(300, 200)).doesNotThrowAnyException(); } @Test - void 생성_너비부족_예외발생() { + void 생성자_너비부족_예외발생() { assertThatThrownBy(() -> new ImageDimension(299, 200)) .isInstanceOf(IllegalArgumentException.class) .hasMessageContaining("너비는 300픽셀 이상"); } @Test - void 생성_높이부족_예외발생() { + void 생성자_높이부족_예외발생() { assertThatThrownBy(() -> new ImageDimension(300, 199)) .isInstanceOf(IllegalArgumentException.class) .hasMessageContaining("높이는 200픽셀 이상"); } @Test - void 생성_비율불일치_예외발생() { + 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 index c82306408..e05f60c44 100644 --- a/src/test/java/nextstep/courses/domain/ImageFileTest.java +++ b/src/test/java/nextstep/courses/domain/ImageFileTest.java @@ -9,12 +9,12 @@ @DisplayNameGeneration(DisplayNameGenerator.ReplaceUnderscores.class) class ImageFileTest { @Test - void 생성_정상입력_생성성공() { + void 생성자_정상입력_생성성공() { assertThatCode(() -> new ImageFile("image.png", 1024 * 1024)).doesNotThrowAnyException(); } @Test - void 생성_파일크기초과_예외발생() { + void 생성자_파일크기초과_예외발생() { long overSize = 1024 * 1024 + 1; assertThatThrownBy(() -> new ImageFile("image.png", overSize)) @@ -23,7 +23,7 @@ class ImageFileTest { } @Test - void 생성_확장자없음_예외발생() { + void 생성자_확장자없음_예외발생() { assertThatThrownBy(() -> new ImageFile("image", 1024 * 1024)) .isInstanceOf(IllegalArgumentException.class) .hasMessageContaining("파일 확장자가 없습니다"); diff --git a/src/test/java/nextstep/courses/domain/SessionStatusTest.java b/src/test/java/nextstep/courses/domain/SessionStatusTest.java index d4c17903d..22f6f265c 100644 --- a/src/test/java/nextstep/courses/domain/SessionStatusTest.java +++ b/src/test/java/nextstep/courses/domain/SessionStatusTest.java @@ -10,17 +10,17 @@ class SessionStatusTest { @Test - void 준비중_수강신청불가() { + void canEnroll_준비중_수강신청불가() { assertThat(SessionStatus.PREPARING.canEnroll()).isFalse(); } @Test - void 모집중_수강신청가능() { + void canEnroll_모집중_수강신청가능() { assertThat(SessionStatus.RECRUITING.canEnroll()).isTrue(); } @Test - void 종료_수강신청불가() { + void canEnroll_종료_수강신청불가() { assertThat(SessionStatus.CLOSED.canEnroll()).isFalse(); } } From 45e6e2864bfa4027b8e23ebe48e86360c8f761fd Mon Sep 17 00:00:00 2001 From: ghtjr410 Date: Wed, 10 Dec 2025 07:58:03 +0900 Subject: [PATCH 17/45] =?UTF-8?q?feat:=20Session=20=EB=8F=84=EB=A9=94?= =?UTF-8?q?=EC=9D=B8=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Session.java - Session 엔티티 신규 추가 - 기본 생성 시 상태를 PREPARING으로 설정 - 수강신청 가능 여부를 SessionStatus에 위임하여 canEnroll 구현 SessionTest.java - 정상 생성 테스트 추가 - 기본 상태가 PREPARING인지 검증 --- .../java/nextstep/courses/domain/Session.java | 27 +++++++++++++++++++ .../nextstep/courses/domain/SessionTest.java | 23 ++++++++++++++++ 2 files changed, 50 insertions(+) create mode 100644 src/main/java/nextstep/courses/domain/Session.java create mode 100644 src/test/java/nextstep/courses/domain/SessionTest.java 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..b91974219 --- /dev/null +++ b/src/main/java/nextstep/courses/domain/Session.java @@ -0,0 +1,27 @@ +package nextstep.courses.domain; + +public class Session { + private Long id; + private CoverImage coverImage; + private SessionPeriod period; + private SessionStatus status; + + public Session(CoverImage coverImage, SessionPeriod period) { + this(0L, coverImage, period, SessionStatus.PREPARING); + } + + public Session(Long id, CoverImage coverImage, SessionPeriod period, SessionStatus status) { + this.id = id; + this.coverImage = coverImage; + this.period = period; + this.status = status; + } + + public boolean canEnroll() { + return this.status.canEnroll(); + } + + public SessionStatus getStatus() { + return this.status; + } +} 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..a2fe7ef64 --- /dev/null +++ b/src/test/java/nextstep/courses/domain/SessionTest.java @@ -0,0 +1,23 @@ +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 SessionTest { + + @Test + void 생성자_정상입력_생성성공() { + CoverImage coverImage = new CoverImage("image.png", 1024, 300, 200); + SessionPeriod period = new SessionPeriod(LocalDate.of(2024, 1, 1), LocalDate.of(2024, 3, 31)); + + Session session = new Session(coverImage, period); + + assertThat(session).isNotNull(); + assertThat(session.getStatus()).isEqualTo(SessionStatus.PREPARING); + } +} From 35de848f4f3a5e70bdc679380b06af52fe947e7a Mon Sep 17 00:00:00 2001 From: ghtjr410 Date: Wed, 10 Dec 2025 08:04:40 +0900 Subject: [PATCH 18/45] =?UTF-8?q?feat:=20=EB=AC=B4=EB=A3=8C/=EC=9C=A0?= =?UTF-8?q?=EB=A3=8C=20=EC=84=B8=EC=85=98=20=EB=8F=84=EB=A9=94=EC=9D=B8=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit FreeSession.java - Session을 상속한 무료 세션 도메인 추가 - 별도 제약 없이 기본 생성자만 제공 PaidSession.java - Session 확장하여 유료 세션 구현 - 최대 수강 인원(maxEnrollment) 및 수강료(fee) 보유 - maxEnrollment는 1명 이상이어야 하도록 검증 로직 추가 PaidSessionTest.java - 정상 생성 테스트 추가 - 최대 수강 인원이 0명 이하일 경우 예외 발생 테스트 추가 --- .../nextstep/courses/domain/FreeSession.java | 7 +++++ .../nextstep/courses/domain/PaidSession.java | 19 ++++++++++++ .../courses/domain/PaidSessionTest.java | 31 +++++++++++++++++++ 3 files changed, 57 insertions(+) create mode 100644 src/main/java/nextstep/courses/domain/FreeSession.java create mode 100644 src/main/java/nextstep/courses/domain/PaidSession.java create mode 100644 src/test/java/nextstep/courses/domain/PaidSessionTest.java diff --git a/src/main/java/nextstep/courses/domain/FreeSession.java b/src/main/java/nextstep/courses/domain/FreeSession.java new file mode 100644 index 000000000..dfb9f4f90 --- /dev/null +++ b/src/main/java/nextstep/courses/domain/FreeSession.java @@ -0,0 +1,7 @@ +package nextstep.courses.domain; + +public class FreeSession extends Session { + public FreeSession(CoverImage coverImage, SessionPeriod period) { + super(coverImage, period); + } +} diff --git a/src/main/java/nextstep/courses/domain/PaidSession.java b/src/main/java/nextstep/courses/domain/PaidSession.java new file mode 100644 index 000000000..0dc8aa870 --- /dev/null +++ b/src/main/java/nextstep/courses/domain/PaidSession.java @@ -0,0 +1,19 @@ +package nextstep.courses.domain; + +public class PaidSession extends Session { + private final int maxEnrollment; + private final Money fee; + + public PaidSession(CoverImage coverImage, SessionPeriod period, int maxEnrollment, Money fee) { + super(coverImage, period); + validateMaxEnrollment(maxEnrollment); + this.maxEnrollment = maxEnrollment; + this.fee = fee; + } + + private void validateMaxEnrollment(int maxEnrollment) { + if (maxEnrollment <= 0) { + throw new IllegalArgumentException(String.format("최대 수강 인원은 1명 이상이어야 합니다. (입력: %d)", maxEnrollment)); + } + } +} diff --git a/src/test/java/nextstep/courses/domain/PaidSessionTest.java b/src/test/java/nextstep/courses/domain/PaidSessionTest.java new file mode 100644 index 000000000..c8a8b343a --- /dev/null +++ b/src/test/java/nextstep/courses/domain/PaidSessionTest.java @@ -0,0 +1,31 @@ +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 PaidSessionTest { + @Test + void 생성자_정상입력_생성성공() { + CoverImage coverImage = new CoverImage("image.png", 1024, 300, 200); + SessionPeriod period = new SessionPeriod(LocalDate.of(2024, 1, 1), LocalDate.of(2024, 3, 31)); + Money fee = new Money(50000); + + assertThatCode(() -> new PaidSession(coverImage, period, 30, fee)).doesNotThrowAnyException(); + } + + @Test + void 생성자_최대인원0명_예외발생() { + CoverImage coverImage = new CoverImage("image.png", 1024, 300, 200); + SessionPeriod period = new SessionPeriod(LocalDate.of(2024, 1, 1), LocalDate.of(2024, 3, 31)); + Money fee = new Money(50000); + + assertThatThrownBy(() -> new PaidSession(coverImage, period, 0, fee)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("최대 수강 인원은 1명 이상"); + } +} From 1c5bcc93a8a2c5e19f41057d987179699da18486 Mon Sep 17 00:00:00 2001 From: ghtjr410 Date: Wed, 10 Dec 2025 08:12:15 +0900 Subject: [PATCH 19/45] =?UTF-8?q?refactor:=20Session=EC=9D=84=20=EC=B6=94?= =?UTF-8?q?=EC=83=81=20=ED=81=B4=EB=9E=98=EC=8A=A4=EB=A1=9C=20=EC=A0=84?= =?UTF-8?q?=ED=99=98=ED=95=98=EA=B3=A0=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20?= =?UTF-8?q?=EC=A0=95=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Session.java - Session 클래스를 abstract로 변경하여 FreeSession/PaidSession만 생성되도록 구조 개선 FreeSessionTest.java - 기존 SessionTest를 FreeSessionTest로 변경 - FreeSession 생성 테스트로 수정 - 기본 상태가 PREPARING인지 검증 PaidSessionTest.java - 정상 생성 테스트에서 객체 생성 후 기본 상태 PREPARING 검증하도록 수정 --- src/main/java/nextstep/courses/domain/Session.java | 2 +- .../domain/{SessionTest.java => FreeSessionTest.java} | 10 ++++------ .../java/nextstep/courses/domain/PaidSessionTest.java | 5 ++++- 3 files changed, 9 insertions(+), 8 deletions(-) rename src/test/java/nextstep/courses/domain/{SessionTest.java => FreeSessionTest.java} (60%) diff --git a/src/main/java/nextstep/courses/domain/Session.java b/src/main/java/nextstep/courses/domain/Session.java index b91974219..ce6a692f2 100644 --- a/src/main/java/nextstep/courses/domain/Session.java +++ b/src/main/java/nextstep/courses/domain/Session.java @@ -1,6 +1,6 @@ package nextstep.courses.domain; -public class Session { +public abstract class Session { private Long id; private CoverImage coverImage; private SessionPeriod period; diff --git a/src/test/java/nextstep/courses/domain/SessionTest.java b/src/test/java/nextstep/courses/domain/FreeSessionTest.java similarity index 60% rename from src/test/java/nextstep/courses/domain/SessionTest.java rename to src/test/java/nextstep/courses/domain/FreeSessionTest.java index a2fe7ef64..c43b675ae 100644 --- a/src/test/java/nextstep/courses/domain/SessionTest.java +++ b/src/test/java/nextstep/courses/domain/FreeSessionTest.java @@ -8,16 +8,14 @@ import org.junit.jupiter.api.Test; @DisplayNameGeneration(DisplayNameGenerator.ReplaceUnderscores.class) -class SessionTest { +class FreeSessionTest { @Test void 생성자_정상입력_생성성공() { - CoverImage coverImage = new CoverImage("image.png", 1024, 300, 200); - SessionPeriod period = new SessionPeriod(LocalDate.of(2024, 1, 1), LocalDate.of(2024, 3, 31)); + FreeSession session = new FreeSession( + new CoverImage("image.png", 1024, 300, 200), + new SessionPeriod(LocalDate.of(2024, 1, 1), LocalDate.of(2024, 3, 31))); - Session session = new Session(coverImage, period); - - assertThat(session).isNotNull(); assertThat(session.getStatus()).isEqualTo(SessionStatus.PREPARING); } } diff --git a/src/test/java/nextstep/courses/domain/PaidSessionTest.java b/src/test/java/nextstep/courses/domain/PaidSessionTest.java index c8a8b343a..4f2815c52 100644 --- a/src/test/java/nextstep/courses/domain/PaidSessionTest.java +++ b/src/test/java/nextstep/courses/domain/PaidSessionTest.java @@ -11,11 +11,14 @@ class PaidSessionTest { @Test void 생성자_정상입력_생성성공() { + // given CoverImage coverImage = new CoverImage("image.png", 1024, 300, 200); SessionPeriod period = new SessionPeriod(LocalDate.of(2024, 1, 1), LocalDate.of(2024, 3, 31)); Money fee = new Money(50000); - assertThatCode(() -> new PaidSession(coverImage, period, 30, fee)).doesNotThrowAnyException(); + PaidSession session = new PaidSession(coverImage, period, 30, fee); + + assertThat(session.getStatus()).isEqualTo(SessionStatus.PREPARING); } @Test From 781867a22a5e6b1d58351dc818349d5bb92be324 Mon Sep 17 00:00:00 2001 From: ghtjr410 Date: Wed, 10 Dec 2025 12:22:40 +0900 Subject: [PATCH 20/45] =?UTF-8?q?feat:=20=EC=88=98=EA=B0=95=EC=8B=A0?= =?UTF-8?q?=EC=B2=AD=20=EC=97=94=ED=8B=B0=ED=8B=B0=20=EB=B0=8F=20=EC=BB=AC?= =?UTF-8?q?=EB=A0=89=EC=85=98=20=EB=8F=84=EB=A9=94=EC=9D=B8=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Enrollment.java - 수강신청 정보(id, studentId, createdAt)를 표현하는 엔티티 추가 - 기본 생성 시 id=0L로 초기화하는 생성자 제공 Enrollments.java - Enrollment 리스트를 관리하는 도메인 컬렉션 추가 - 불변 리스트 복사 후 add 메서드로 수강신청 추가 기능 제공 --- .../nextstep/courses/domain/Enrollment.java | 19 ++++++++++++++++++ .../nextstep/courses/domain/Enrollments.java | 20 +++++++++++++++++++ 2 files changed, 39 insertions(+) create mode 100644 src/main/java/nextstep/courses/domain/Enrollment.java create mode 100644 src/main/java/nextstep/courses/domain/Enrollments.java 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..a9b8c4a7d --- /dev/null +++ b/src/main/java/nextstep/courses/domain/Enrollment.java @@ -0,0 +1,19 @@ +package nextstep.courses.domain; + +import java.time.LocalDateTime; + +public class Enrollment { + private Long id; + private Long studentId; + private LocalDateTime createdAt; + + public Enrollment(Long studentId, LocalDateTime now) { + this(0L, studentId, now); + } + + public Enrollment(Long id, Long studentId, LocalDateTime createdAt) { + this.id = id; + this.studentId = studentId; + this.createdAt = createdAt; + } +} 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..6b60f7b0c --- /dev/null +++ b/src/main/java/nextstep/courses/domain/Enrollments.java @@ -0,0 +1,20 @@ +package nextstep.courses.domain; + +import java.util.ArrayList; +import java.util.List; + +public class Enrollments { + private final List enrollments; + + public Enrollments() { + this(new ArrayList<>()); + } + + public Enrollments(List enrollments) { + this.enrollments = new ArrayList<>(enrollments); + } + + public void add(Enrollment enrollment) { + enrollments.add(enrollment); + } +} From c856a6004eb2649651ea4bcc08c313187b3a8e24 Mon Sep 17 00:00:00 2001 From: ghtjr410 Date: Wed, 10 Dec 2025 12:36:10 +0900 Subject: [PATCH 21/45] =?UTF-8?q?feat:=20Session=EC=97=90=20=EC=88=98?= =?UTF-8?q?=EA=B0=95=EC=8B=A0=EC=B2=AD=20=EA=B8=B0=EB=8A=A5=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80=20=EB=B0=8F=20Enrollment=EC=97=90=20sessionId=20?= =?UTF-8?q?=EB=B0=98=EC=98=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Enrollment.java - sessionId 필드 추가 - 생성자에 sessionId 포함하도록 변경 Session.java - Enrollments 컬렉션 필드 추가 - enroll(enrollment) 메서드 도입 - 모집 상태가 아닐 경우 예외 발생하도록 validateEnrollment 구현 - 기본 생성자는 빈 Enrollments로 초기화하도록 변경 SessionTest.java - 기본 생성 시 상태가 PREPARING인지 검증 - 모집중이 아닐 때 enroll 호출 시 예외 발생 테스트 추가 --- .../nextstep/courses/domain/Enrollment.java | 8 ++-- .../java/nextstep/courses/domain/Session.java | 18 +++++++-- .../nextstep/courses/domain/SessionTest.java | 39 +++++++++++++++++++ 3 files changed, 58 insertions(+), 7 deletions(-) create mode 100644 src/test/java/nextstep/courses/domain/SessionTest.java diff --git a/src/main/java/nextstep/courses/domain/Enrollment.java b/src/main/java/nextstep/courses/domain/Enrollment.java index a9b8c4a7d..91bee250e 100644 --- a/src/main/java/nextstep/courses/domain/Enrollment.java +++ b/src/main/java/nextstep/courses/domain/Enrollment.java @@ -4,15 +4,17 @@ public class Enrollment { private Long id; + private Long sessionId; private Long studentId; private LocalDateTime createdAt; - public Enrollment(Long studentId, LocalDateTime now) { - this(0L, studentId, now); + public Enrollment(Long sessionId, Long studentId, LocalDateTime now) { + this(0L, sessionId, studentId, now); } - public Enrollment(Long id, Long studentId, LocalDateTime createdAt) { + public Enrollment(Long id, Long sessionId, Long studentId, LocalDateTime createdAt) { this.id = id; + this.sessionId = sessionId; this.studentId = studentId; this.createdAt = createdAt; } diff --git a/src/main/java/nextstep/courses/domain/Session.java b/src/main/java/nextstep/courses/domain/Session.java index ce6a692f2..8e95ff993 100644 --- a/src/main/java/nextstep/courses/domain/Session.java +++ b/src/main/java/nextstep/courses/domain/Session.java @@ -5,20 +5,30 @@ public abstract class Session { private CoverImage coverImage; private SessionPeriod period; private SessionStatus status; + private final Enrollments enrollments; public Session(CoverImage coverImage, SessionPeriod period) { - this(0L, coverImage, period, SessionStatus.PREPARING); + this(0L, coverImage, period, SessionStatus.PREPARING, new Enrollments()); } - public Session(Long id, CoverImage coverImage, SessionPeriod period, SessionStatus status) { + public Session( + Long id, CoverImage coverImage, SessionPeriod period, SessionStatus status, Enrollments enrollments) { this.id = id; this.coverImage = coverImage; this.period = period; this.status = status; + this.enrollments = enrollments; } - public boolean canEnroll() { - return this.status.canEnroll(); + public void enroll(Enrollment enrollment) { + validateEnrollment(); + enrollments.add(enrollment); + } + + protected void validateEnrollment() { + if (!status.canEnroll()) { + throw new IllegalStateException(String.format("모집중인 강의만 수강 신청이 가능합니다. (현재 상태: %s)", status)); + } } public SessionStatus getStatus() { 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..69ab8857d --- /dev/null +++ b/src/test/java/nextstep/courses/domain/SessionTest.java @@ -0,0 +1,39 @@ +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; + +@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 FreeSession(coverImage, period); + + assertThat(session.getStatus()).isEqualTo(SessionStatus.PREPARING); + } + + @Test + void enroll_준비중_예외발생() { + Session session = new FreeSession(coverImage, period); + Enrollment enrollment = new Enrollment(1L, 1L, LocalDateTime.now()); + + assertThatThrownBy(() -> session.enroll(enrollment)) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("모집중인 강의만"); + } +} From 4a4c9a3e6f257c6361dc03d8f66c3c7ea4dee092 Mon Sep 17 00:00:00 2001 From: ghtjr410 Date: Wed, 10 Dec 2025 12:46:49 +0900 Subject: [PATCH 22/45] =?UTF-8?q?feat:=20=EC=84=B8=EC=85=98=20=EC=88=98?= =?UTF-8?q?=EA=B0=95=EC=8B=A0=EC=B2=AD=20=EA=B8=B0=EB=8A=A5=20=ED=99=95?= =?UTF-8?q?=EC=9E=A5=20=EB=B0=8F=20FreeSession=20=EC=83=9D=EC=84=B1?= =?UTF-8?q?=EC=9E=90=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Enrollments.java - 내부 리스트명을 values로 변경 - count() 메서드 추가로 총 신청 인원 조회 가능 FreeSession.java - 상태를 지정해 생성할 수 있는 생성자 추가 Session.java - 상태 지정 생성자 추가 - enrollmentCount()로 신청 인원 조회 기능 제공 - enroll 시 모집중 상태가 아니면 예외 발생하도록 유지 SessionTest.java - 모집중(enrolling)일 때 수강신청 성공 테스트 추가 - PREPARING/CLOSED 상태에서 예외 발생하는지 파라미터화 테스트로 검증 --- .../nextstep/courses/domain/Enrollments.java | 12 ++++++++---- .../nextstep/courses/domain/FreeSession.java | 4 ++++ .../java/nextstep/courses/domain/Session.java | 8 ++++++++ .../nextstep/courses/domain/SessionTest.java | 19 +++++++++++++++++-- 4 files changed, 37 insertions(+), 6 deletions(-) diff --git a/src/main/java/nextstep/courses/domain/Enrollments.java b/src/main/java/nextstep/courses/domain/Enrollments.java index 6b60f7b0c..ebb07909c 100644 --- a/src/main/java/nextstep/courses/domain/Enrollments.java +++ b/src/main/java/nextstep/courses/domain/Enrollments.java @@ -4,17 +4,21 @@ import java.util.List; public class Enrollments { - private final List enrollments; + private final List values; public Enrollments() { this(new ArrayList<>()); } - public Enrollments(List enrollments) { - this.enrollments = new ArrayList<>(enrollments); + public Enrollments(List values) { + this.values = new ArrayList<>(values); } public void add(Enrollment enrollment) { - enrollments.add(enrollment); + this.values.add(enrollment); + } + + public int count() { + return this.values.size(); } } diff --git a/src/main/java/nextstep/courses/domain/FreeSession.java b/src/main/java/nextstep/courses/domain/FreeSession.java index dfb9f4f90..283d2c219 100644 --- a/src/main/java/nextstep/courses/domain/FreeSession.java +++ b/src/main/java/nextstep/courses/domain/FreeSession.java @@ -4,4 +4,8 @@ public class FreeSession extends Session { public FreeSession(CoverImage coverImage, SessionPeriod period) { super(coverImage, period); } + + public FreeSession(CoverImage coverImage, SessionPeriod period, SessionStatus status) { + super(coverImage, period, status); + } } diff --git a/src/main/java/nextstep/courses/domain/Session.java b/src/main/java/nextstep/courses/domain/Session.java index 8e95ff993..a2ead1af4 100644 --- a/src/main/java/nextstep/courses/domain/Session.java +++ b/src/main/java/nextstep/courses/domain/Session.java @@ -11,6 +11,10 @@ public Session(CoverImage coverImage, SessionPeriod period) { this(0L, coverImage, period, SessionStatus.PREPARING, new Enrollments()); } + public Session(CoverImage coverImage, SessionPeriod period, SessionStatus status) { + this(0L, coverImage, period, status, new Enrollments()); + } + public Session( Long id, CoverImage coverImage, SessionPeriod period, SessionStatus status, Enrollments enrollments) { this.id = id; @@ -31,6 +35,10 @@ protected void validateEnrollment() { } } + public int enrollmentCount() { + return enrollments.count(); + } + public SessionStatus getStatus() { return this.status; } diff --git a/src/test/java/nextstep/courses/domain/SessionTest.java b/src/test/java/nextstep/courses/domain/SessionTest.java index 69ab8857d..5dabf0870 100644 --- a/src/test/java/nextstep/courses/domain/SessionTest.java +++ b/src/test/java/nextstep/courses/domain/SessionTest.java @@ -8,6 +8,8 @@ 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 { @@ -28,8 +30,21 @@ void setUp() { } @Test - void enroll_준비중_예외발생() { - Session session = new FreeSession(coverImage, period); + void enroll_모집중_성공() { + Session session = new FreeSession(coverImage, period, SessionStatus.RECRUITING); + Enrollment enrollment = new Enrollment(1L, 1L, LocalDateTime.now()); + + session.enroll(enrollment); + + assertThat(session.enrollmentCount()).isEqualTo(1); + } + + @ParameterizedTest(name = "상태:{0}") + @EnumSource( + value = SessionStatus.class, + names = {"PREPARING", "CLOSED"}) + void enroll_모집중이아닐시_예외발생(SessionStatus status) { + Session session = new FreeSession(coverImage, period, status); Enrollment enrollment = new Enrollment(1L, 1L, LocalDateTime.now()); assertThatThrownBy(() -> session.enroll(enrollment)) From bd668f30974dd48c2ec03c369ab8d4e9ea7dae5f Mon Sep 17 00:00:00 2001 From: ghtjr410 Date: Wed, 10 Dec 2025 13:06:25 +0900 Subject: [PATCH 23/45] =?UTF-8?q?feat:=20=EC=9C=A0=EB=A3=8C=20=EC=84=B8?= =?UTF-8?q?=EC=85=98=20=EA=B2=B0=EC=A0=9C=20=EA=B2=80=EC=A6=9D=20=EB=B0=8F?= =?UTF-8?q?=20=EC=83=81=ED=83=9C=20=EA=B2=80=EC=A6=9D=20=EB=A1=9C=EC=A7=81?= =?UTF-8?q?=20=EA=B0=95=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PaidSession.java - 상태 지정 생성자 추가 - enroll(enrollment, payment) 메서드로 결제 금액 일치 여부 검증 후 부모 enroll 호출 - 결제 불일치 시 예외 발생하도록 validatePayment 추가 Session.java - enroll 시 상태 검증 메서드명을 validateStatus로 변경하여 의미 명확화 FreeSessionTest.java - FreeSession 정상 생성 테스트 명확화 PaidSessionTest.java - 공통 객체 setUp으로 정리 - 정상 enroll 테스트 추가 - 결제 금액 불일치 시 예외 발생 테스트 추가 --- .../nextstep/courses/domain/PaidSession.java | 18 ++++++++ .../java/nextstep/courses/domain/Session.java | 4 +- .../courses/domain/FreeSessionTest.java | 7 ++- .../courses/domain/PaidSessionTest.java | 45 +++++++++++++++---- 4 files changed, 60 insertions(+), 14 deletions(-) diff --git a/src/main/java/nextstep/courses/domain/PaidSession.java b/src/main/java/nextstep/courses/domain/PaidSession.java index 0dc8aa870..49d5b391e 100644 --- a/src/main/java/nextstep/courses/domain/PaidSession.java +++ b/src/main/java/nextstep/courses/domain/PaidSession.java @@ -11,9 +11,27 @@ public PaidSession(CoverImage coverImage, SessionPeriod period, int maxEnrollmen this.fee = fee; } + public PaidSession( + CoverImage coverImage, SessionPeriod period, SessionStatus status, int maxEnrollment, Money fee) { + super(coverImage, period, status); + this.maxEnrollment = maxEnrollment; + this.fee = fee; + } + private void validateMaxEnrollment(int maxEnrollment) { if (maxEnrollment <= 0) { throw new IllegalArgumentException(String.format("최대 수강 인원은 1명 이상이어야 합니다. (입력: %d)", maxEnrollment)); } } + + public void enroll(Enrollment enrollment, Money payment) { + validatePayment(payment); + super.enroll(enrollment); + } + + private void validatePayment(Money payment) { + if (!fee.isSameAs(payment)) { + throw new IllegalArgumentException("결제 금액이 수강료와 일치하지 않습니다."); + } + } } diff --git a/src/main/java/nextstep/courses/domain/Session.java b/src/main/java/nextstep/courses/domain/Session.java index a2ead1af4..6c8eb8a6a 100644 --- a/src/main/java/nextstep/courses/domain/Session.java +++ b/src/main/java/nextstep/courses/domain/Session.java @@ -25,11 +25,11 @@ public Session( } public void enroll(Enrollment enrollment) { - validateEnrollment(); + validateStatus(); enrollments.add(enrollment); } - protected void validateEnrollment() { + protected void validateStatus() { if (!status.canEnroll()) { throw new IllegalStateException(String.format("모집중인 강의만 수강 신청이 가능합니다. (현재 상태: %s)", status)); } diff --git a/src/test/java/nextstep/courses/domain/FreeSessionTest.java b/src/test/java/nextstep/courses/domain/FreeSessionTest.java index c43b675ae..e9751f43f 100644 --- a/src/test/java/nextstep/courses/domain/FreeSessionTest.java +++ b/src/test/java/nextstep/courses/domain/FreeSessionTest.java @@ -12,10 +12,9 @@ class FreeSessionTest { @Test void 생성자_정상입력_생성성공() { - FreeSession session = new FreeSession( - new CoverImage("image.png", 1024, 300, 200), - new SessionPeriod(LocalDate.of(2024, 1, 1), LocalDate.of(2024, 3, 31))); + CoverImage coverImage = new CoverImage("image.png", 1024, 300, 200); + SessionPeriod sessionPeriod = new SessionPeriod(LocalDate.of(2024, 1, 1), LocalDate.of(2024, 3, 31)); - assertThat(session.getStatus()).isEqualTo(SessionStatus.PREPARING); + assertThatCode(() -> new FreeSession(coverImage, sessionPeriod)).doesNotThrowAnyException(); } } diff --git a/src/test/java/nextstep/courses/domain/PaidSessionTest.java b/src/test/java/nextstep/courses/domain/PaidSessionTest.java index 4f2815c52..b94809ab9 100644 --- a/src/test/java/nextstep/courses/domain/PaidSessionTest.java +++ b/src/test/java/nextstep/courses/domain/PaidSessionTest.java @@ -3,22 +3,29 @@ 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; @DisplayNameGeneration(DisplayNameGenerator.ReplaceUnderscores.class) class PaidSessionTest { - @Test - void 생성자_정상입력_생성성공() { - // given - CoverImage coverImage = new CoverImage("image.png", 1024, 300, 200); - SessionPeriod period = new SessionPeriod(LocalDate.of(2024, 1, 1), LocalDate.of(2024, 3, 31)); - Money fee = new Money(50000); - PaidSession session = new PaidSession(coverImage, period, 30, fee); + private CoverImage coverImage; + private SessionPeriod period; + private Money fee; - assertThat(session.getStatus()).isEqualTo(SessionStatus.PREPARING); + @BeforeEach + void setUp() { + coverImage = new CoverImage("image.png", 1024, 300, 200); + period = new SessionPeriod(LocalDate.of(2024, 1, 1), LocalDate.of(2024, 3, 31)); + fee = new Money(50000); + } + + @Test + void 생성자_정상입력_생성성공() { + assertThatCode(() -> new PaidSession(coverImage, period, 30, fee)).doesNotThrowAnyException(); } @Test @@ -31,4 +38,26 @@ class PaidSessionTest { .isInstanceOf(IllegalArgumentException.class) .hasMessageContaining("최대 수강 인원은 1명 이상"); } + + @Test + void enroll_정상입력_성공() { + PaidSession session = new PaidSession(coverImage, period, SessionStatus.RECRUITING, 3, fee); + Enrollment enrollment = new Enrollment(1L, 1L, LocalDateTime.now()); + Money payment = new Money(50000); + + session.enroll(enrollment, payment); + + assertThat(session.enrollmentCount()).isEqualTo(1); + } + + @Test + void enroll_결제금액불일치_예외발생() { + PaidSession session = new PaidSession(coverImage, period, SessionStatus.RECRUITING, 3, fee); + Enrollment enrollment = new Enrollment(1L, 1L, LocalDateTime.now()); + Money wrongPayment = new Money(30000); + + assertThatThrownBy(() -> session.enroll(enrollment, wrongPayment)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("결제 금액이 수강료와 일치하지 않습니다"); + } } From 50f0f02c1f0cb88ab7f19658c49414dd9c7d88b6 Mon Sep 17 00:00:00 2001 From: ghtjr410 Date: Wed, 10 Dec 2025 13:09:41 +0900 Subject: [PATCH 24/45] =?UTF-8?q?feat:=20PaidSession=20=EC=88=98=EA=B0=95?= =?UTF-8?q?=20=EC=9D=B8=EC=9B=90=20=EC=A0=9C=ED=95=9C=20=EA=B2=80=EC=A6=9D?= =?UTF-8?q?=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PaidSession.java - enroll 시 결제 검증 후 정원 초과 여부 validateCapacity 추가 - 정원 도달 시 IllegalStateException 발생 PaidSessionTest.java - 최대 정원 초과 시 예외 발생 테스트 추가 --- .../java/nextstep/courses/domain/PaidSession.java | 7 +++++++ .../nextstep/courses/domain/PaidSessionTest.java | 13 +++++++++++++ 2 files changed, 20 insertions(+) diff --git a/src/main/java/nextstep/courses/domain/PaidSession.java b/src/main/java/nextstep/courses/domain/PaidSession.java index 49d5b391e..6afd83e0a 100644 --- a/src/main/java/nextstep/courses/domain/PaidSession.java +++ b/src/main/java/nextstep/courses/domain/PaidSession.java @@ -26,6 +26,7 @@ private void validateMaxEnrollment(int maxEnrollment) { public void enroll(Enrollment enrollment, Money payment) { validatePayment(payment); + validateCapacity(); super.enroll(enrollment); } @@ -34,4 +35,10 @@ private void validatePayment(Money payment) { throw new IllegalArgumentException("결제 금액이 수강료와 일치하지 않습니다."); } } + + private void validateCapacity() { + if (enrollmentCount() >= maxEnrollment) { + throw new IllegalStateException(String.format("수강 인원이 초과되었습니다. (최대: %d명)", maxEnrollment)); + } + } } diff --git a/src/test/java/nextstep/courses/domain/PaidSessionTest.java b/src/test/java/nextstep/courses/domain/PaidSessionTest.java index b94809ab9..9cfbc6471 100644 --- a/src/test/java/nextstep/courses/domain/PaidSessionTest.java +++ b/src/test/java/nextstep/courses/domain/PaidSessionTest.java @@ -60,4 +60,17 @@ void setUp() { .isInstanceOf(IllegalArgumentException.class) .hasMessageContaining("결제 금액이 수강료와 일치하지 않습니다"); } + + @Test + void enroll_인원초과_예외발생() { + PaidSession session = new PaidSession(coverImage, period, SessionStatus.RECRUITING, 2, fee); + Money payment = new Money(50000); + + session.enroll(new Enrollment(1L, 1L, LocalDateTime.now()), payment); + session.enroll(new Enrollment(2L, 2L, LocalDateTime.now()), payment); + + assertThatThrownBy(() -> session.enroll(new Enrollment(3L, 3L, LocalDateTime.now()), payment)) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("수강 인원이 초과"); + } } From a7b32e6594371bc1c63b4e8b5826807cd1618ccb Mon Sep 17 00:00:00 2001 From: ghtjr410 Date: Wed, 10 Dec 2025 20:15:43 +0900 Subject: [PATCH 25/45] =?UTF-8?q?refactor:=20Course=EC=97=90=EC=84=9C=20ge?= =?UTF-8?q?neration=20=EC=A0=9C=EA=B1=B0=20=EB=B0=8F=20=EA=B4=80=EB=A0=A8?= =?UTF-8?q?=20=EC=BD=94=EB=93=9C=20=EC=A0=95=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Course.java - generation 필드 제거 - generation을 받던 생성자 삭제 및 기존 생성자 시그니처 정리 - findById 매핑 로직에서 generation 관련 코드 제거 JdbcCourseRepository.java - insert 쿼리에서 generation 제거 - select 쿼리 컬럼 목록에서 generation 제거 - RowMapper 매핑 인덱스 수정 schema.sql - course 테이블에서 generation 컬럼 삭제 CourseTest.java - 생성자 변경에 맞게 테스트 파라미터 수정 CourseRepositoryTest.java - generation 비교 제거 - 변경된 생성자 사용하도록 수정 --- .../java/nextstep/courses/domain/Course.java | 18 +++++------------- .../infrastructure/JdbcCourseRepository.java | 14 ++++++-------- src/main/resources/schema.sql | 1 - .../nextstep/courses/domain/CourseTest.java | 2 +- .../infrastructure/CourseRepositoryTest.java | 3 +-- 5 files changed, 13 insertions(+), 25 deletions(-) diff --git a/src/main/java/nextstep/courses/domain/Course.java b/src/main/java/nextstep/courses/domain/Course.java index 97e5b383c..96a2b65ca 100644 --- a/src/main/java/nextstep/courses/domain/Course.java +++ b/src/main/java/nextstep/courses/domain/Course.java @@ -7,8 +7,6 @@ public class Course { private String title; - private int generation; - private Long creatorId; private LocalDateTime createdAt; @@ -17,19 +15,17 @@ public class Course { public Course() {} - public Course(String title, int generation, Long creatorId, LocalDateTime now) { - this(0L, title, generation, creatorId, now, null); + public Course(String title, Long creatorId, LocalDateTime now) { + this(0L, title, creatorId, now, null); } - public Course(String title, int generation, Long creatorId) { - this(0L, title, generation, creatorId, LocalDateTime.now(), null); + public Course(String title, Long creatorId) { + this(0L, title, creatorId, LocalDateTime.now(), null); } - public Course( - Long id, String title, int generation, Long creatorId, LocalDateTime createdAt, LocalDateTime updatedAt) { + public Course(Long id, String title, Long creatorId, LocalDateTime createdAt, LocalDateTime updatedAt) { this.id = id; this.title = title; - this.generation = generation; this.creatorId = creatorId; this.createdAt = createdAt; this.updatedAt = updatedAt; @@ -43,10 +39,6 @@ public Long getCreatorId() { return creatorId; } - public int getGeneration() { - return generation; - } - public LocalDateTime getCreatedAt() { return createdAt; } diff --git a/src/main/java/nextstep/courses/infrastructure/JdbcCourseRepository.java b/src/main/java/nextstep/courses/infrastructure/JdbcCourseRepository.java index 3f21435f7..475bf9a6f 100644 --- a/src/main/java/nextstep/courses/infrastructure/JdbcCourseRepository.java +++ b/src/main/java/nextstep/courses/infrastructure/JdbcCourseRepository.java @@ -18,21 +18,19 @@ public JdbcCourseRepository(JdbcOperations jdbcTemplate) { @Override public int save(Course course) { - String sql = "insert into course (title, generation, creator_id, created_at) values(?, ?, ?, ?)"; - return jdbcTemplate.update( - sql, course.getTitle(), course.getGeneration(), course.getCreatorId(), course.getCreatedAt()); + String sql = "insert into course (title, creator_id, created_at) values(?, ?, ?)"; + return jdbcTemplate.update(sql, course.getTitle(), course.getCreatorId(), course.getCreatedAt()); } @Override public Course findById(Long id) { - String sql = "select id, title, generation, 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.getInt(3), - rs.getLong(4), - toLocalDateTime(rs.getTimestamp(5)), - toLocalDateTime(rs.getTimestamp(6))); + rs.getLong(3), + toLocalDateTime(rs.getTimestamp(4)), + toLocalDateTime(rs.getTimestamp(5))); return jdbcTemplate.queryForObject(sql, rowMapper, id); } diff --git a/src/main/resources/schema.sql b/src/main/resources/schema.sql index 746fcb244..8d5a988c8 100644 --- a/src/main/resources/schema.sql +++ b/src/main/resources/schema.sql @@ -1,7 +1,6 @@ create table course ( id bigint generated by default as identity, title varchar(255) not null, - generation INT NOT NULL, creator_id bigint not null, created_at timestamp not null, updated_at timestamp, diff --git a/src/test/java/nextstep/courses/domain/CourseTest.java b/src/test/java/nextstep/courses/domain/CourseTest.java index 58e5bd30d..6ab085660 100644 --- a/src/test/java/nextstep/courses/domain/CourseTest.java +++ b/src/test/java/nextstep/courses/domain/CourseTest.java @@ -13,6 +13,6 @@ class CourseTest { @Test void 생성자_정상입력_생성성공() { LocalDateTime fixedNow = LocalDateTime.now(); - assertThatCode(() -> new Course("과정명", 1, 1L, fixedNow)).doesNotThrowAnyException(); + assertThatCode(() -> new Course("과정명", 1L, fixedNow)).doesNotThrowAnyException(); } } diff --git a/src/test/java/nextstep/courses/infrastructure/CourseRepositoryTest.java b/src/test/java/nextstep/courses/infrastructure/CourseRepositoryTest.java index c79525793..e30a074e4 100644 --- a/src/test/java/nextstep/courses/infrastructure/CourseRepositoryTest.java +++ b/src/test/java/nextstep/courses/infrastructure/CourseRepositoryTest.java @@ -30,12 +30,11 @@ void setUp() { @Test void crud() { LocalDateTime fixednow = LocalDateTime.now(); - Course course = new Course("TDD, 클린 코드 with Java", 1, 1L, fixednow); + 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.getGeneration()).isEqualTo(savedCourse.getGeneration()); assertThat(course.getCreatorId()).isEqualTo(savedCourse.getCreatorId()); assertThat(course.getCreatedAt()).isEqualTo(savedCourse.getCreatedAt()); LOGGER.debug("Course: {}", savedCourse); From d3803a6941f9bd1341dd9933811dbd23f18cacc4 Mon Sep 17 00:00:00 2001 From: ghtjr410 Date: Wed, 10 Dec 2025 20:23:18 +0900 Subject: [PATCH 26/45] =?UTF-8?q?feat:=20Course=EC=97=90=20Sessions=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80=20=EB=B0=8F=20=EC=83=9D=EC=84=B1=EC=9E=90/?= =?UTF-8?q?=EB=A7=A4=ED=95=91=20=EA=B5=AC=EC=A1=B0=20=ED=99=95=EC=9E=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Course.java - Sessions 컬렉션 필드 추가 - 기본 생성자 제거 및 sessions 초기화 포함한 생성자 구조로 변경 - 신규 생성자에서 Sessions를 주입받도록 확장 - 기존 필드 초기화 로직 정리 Sessions.java - Session 리스트를 보관하는 컬렉션 VO 추가 - add(), count() 기능 제공 - 기본 생성 시 빈 리스트로 초기화 JdbcCourseRepository.java - RowMapper에서 sessions는 아직 영속화 대상이 아니므로 null로 매핑 처리 --- .../java/nextstep/courses/domain/Course.java | 19 ++++++++------- .../nextstep/courses/domain/Sessions.java | 24 +++++++++++++++++++ .../infrastructure/JdbcCourseRepository.java | 1 + 3 files changed, 36 insertions(+), 8 deletions(-) create mode 100644 src/main/java/nextstep/courses/domain/Sessions.java diff --git a/src/main/java/nextstep/courses/domain/Course.java b/src/main/java/nextstep/courses/domain/Course.java index 96a2b65ca..e94346175 100644 --- a/src/main/java/nextstep/courses/domain/Course.java +++ b/src/main/java/nextstep/courses/domain/Course.java @@ -9,24 +9,27 @@ public class Course { private Long creatorId; + private final Sessions sessions; + private LocalDateTime createdAt; private LocalDateTime updatedAt; - public Course() {} - public Course(String title, Long creatorId, LocalDateTime now) { - this(0L, title, creatorId, now, null); - } - - public Course(String title, Long creatorId) { - this(0L, title, creatorId, LocalDateTime.now(), null); + 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; } 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..dfd1d6536 --- /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 = 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 475bf9a6f..663695b18 100644 --- a/src/main/java/nextstep/courses/infrastructure/JdbcCourseRepository.java +++ b/src/main/java/nextstep/courses/infrastructure/JdbcCourseRepository.java @@ -29,6 +29,7 @@ public Course findById(Long id) { rs.getLong(1), rs.getString(2), rs.getLong(3), + null, toLocalDateTime(rs.getTimestamp(4)), toLocalDateTime(rs.getTimestamp(5))); return jdbcTemplate.queryForObject(sql, rowMapper, id); From ea535b88d778c07c4663ffa6dbab14a34692dbfc Mon Sep 17 00:00:00 2001 From: ghtjr410 Date: Wed, 10 Dec 2025 20:45:08 +0900 Subject: [PATCH 27/45] =?UTF-8?q?feat:=20Course=EC=97=90=20Session=20?= =?UTF-8?q?=EA=B4=80=EB=A6=AC=20=EA=B8=B0=EB=8A=A5=20=EC=B6=94=EA=B0=80=20?= =?UTF-8?q?=EB=B0=8F=20=EC=84=B8=EC=85=98=20=EC=BB=AC=EB=A0=89=EC=85=98=20?= =?UTF-8?q?=EC=95=88=EC=A0=95=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Course.java - addSession() 메서드 추가로 코스에 세션 등록 기능 제공 - sessionCount() 메서드로 세션 수 조회 기능 추가 PaidSession.java - 상태 지정 생성자에서 maxEnrollment 유효성 검증 누락 문제를 수정하여 validateMaxEnrollment 호출 추가 Sessions.java - 내부 리스트를 방어적 복사(new ArrayList<>(values))로 초기화해 불변성 강화 --- src/main/java/nextstep/courses/domain/Course.java | 8 ++++++++ src/main/java/nextstep/courses/domain/PaidSession.java | 1 + src/main/java/nextstep/courses/domain/Sessions.java | 2 +- 3 files changed, 10 insertions(+), 1 deletion(-) diff --git a/src/main/java/nextstep/courses/domain/Course.java b/src/main/java/nextstep/courses/domain/Course.java index e94346175..f3083f9e3 100644 --- a/src/main/java/nextstep/courses/domain/Course.java +++ b/src/main/java/nextstep/courses/domain/Course.java @@ -34,6 +34,14 @@ public Course( 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/PaidSession.java b/src/main/java/nextstep/courses/domain/PaidSession.java index 6afd83e0a..b7fe1bcb2 100644 --- a/src/main/java/nextstep/courses/domain/PaidSession.java +++ b/src/main/java/nextstep/courses/domain/PaidSession.java @@ -14,6 +14,7 @@ public PaidSession(CoverImage coverImage, SessionPeriod period, int maxEnrollmen public PaidSession( CoverImage coverImage, SessionPeriod period, SessionStatus status, int maxEnrollment, Money fee) { super(coverImage, period, status); + validateMaxEnrollment(maxEnrollment); this.maxEnrollment = maxEnrollment; this.fee = fee; } diff --git a/src/main/java/nextstep/courses/domain/Sessions.java b/src/main/java/nextstep/courses/domain/Sessions.java index dfd1d6536..5e5d6a1aa 100644 --- a/src/main/java/nextstep/courses/domain/Sessions.java +++ b/src/main/java/nextstep/courses/domain/Sessions.java @@ -11,7 +11,7 @@ public Sessions() { } public Sessions(List values) { - this.values = values; + this.values = new ArrayList<>(values); } public void add(Session session) { From 79a4ccb2e9959ff010a87851077e5067b9c0b668 Mon Sep 17 00:00:00 2001 From: ghtjr410 Date: Wed, 10 Dec 2025 20:50:38 +0900 Subject: [PATCH 28/45] =?UTF-8?q?refactor:=20PaidSession=20=ED=95=84?= =?UTF-8?q?=EB=93=9C=EB=AA=85=20=EC=9D=98=EB=AF=B8=20=EB=AA=85=ED=99=95?= =?UTF-8?q?=ED=99=94=20=EB=B0=8F=20=EC=9A=A9=EC=96=B4=20=EC=9D=BC=EA=B4=80?= =?UTF-8?q?=EC=84=B1=20=EA=B0=9C=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PaidSession.java - maxEnrollment → capacity로 필드명 변경 - fee → price로 필드명 변경 - 생성자 파라미터 및 필드 초기화 로직을 새로운 용어에 맞게 수정 - 결제 검증 로직에서 fee → price로 업데이트 - 정원 검증 로직에서 maxEnrollment → capacity로 일관성 유지 --- .../nextstep/courses/domain/PaidSession.java | 27 +++++++++---------- 1 file changed, 13 insertions(+), 14 deletions(-) diff --git a/src/main/java/nextstep/courses/domain/PaidSession.java b/src/main/java/nextstep/courses/domain/PaidSession.java index b7fe1bcb2..e9f2400d7 100644 --- a/src/main/java/nextstep/courses/domain/PaidSession.java +++ b/src/main/java/nextstep/courses/domain/PaidSession.java @@ -1,22 +1,21 @@ package nextstep.courses.domain; public class PaidSession extends Session { - private final int maxEnrollment; - private final Money fee; + private final int capacity; + private final Money price; - public PaidSession(CoverImage coverImage, SessionPeriod period, int maxEnrollment, Money fee) { + public PaidSession(CoverImage coverImage, SessionPeriod period, int capacity, Money price) { super(coverImage, period); - validateMaxEnrollment(maxEnrollment); - this.maxEnrollment = maxEnrollment; - this.fee = fee; + validateMaxEnrollment(capacity); + this.capacity = capacity; + this.price = price; } - public PaidSession( - CoverImage coverImage, SessionPeriod period, SessionStatus status, int maxEnrollment, Money fee) { + public PaidSession(CoverImage coverImage, SessionPeriod period, SessionStatus status, int capacity, Money price) { super(coverImage, period, status); - validateMaxEnrollment(maxEnrollment); - this.maxEnrollment = maxEnrollment; - this.fee = fee; + validateMaxEnrollment(capacity); + this.capacity = capacity; + this.price = price; } private void validateMaxEnrollment(int maxEnrollment) { @@ -32,14 +31,14 @@ public void enroll(Enrollment enrollment, Money payment) { } private void validatePayment(Money payment) { - if (!fee.isSameAs(payment)) { + if (!price.isSameAs(payment)) { throw new IllegalArgumentException("결제 금액이 수강료와 일치하지 않습니다."); } } private void validateCapacity() { - if (enrollmentCount() >= maxEnrollment) { - throw new IllegalStateException(String.format("수강 인원이 초과되었습니다. (최대: %d명)", maxEnrollment)); + if (enrollmentCount() >= capacity) { + throw new IllegalStateException(String.format("수강 인원이 초과되었습니다. (최대: %d명)", capacity)); } } } From d8cc1b81674e090c5972fc49b8fc63a6bd03bcb5 Mon Sep 17 00:00:00 2001 From: ghtjr410 Date: Wed, 10 Dec 2025 20:55:35 +0900 Subject: [PATCH 29/45] =?UTF-8?q?feat:=20=EC=88=98=EA=B0=95=20=EC=9D=B8?= =?UTF-8?q?=EC=9B=90=20=EA=B0=92=20=EA=B0=9D=EC=B2=B4=20Capacity=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Capacity.java - 최대 수강 인원을 표현하는 VO 추가 - 1명 이상인지 검증하는 로직 포함 - 0 이하 입력 시 IllegalArgumentException 발생 CapacityTest.java - 정상 생성 테스트 추가 - 0 이하 입력 시 예외 발생 테스트 추가 --- .../nextstep/courses/domain/Capacity.java | 12 ++++++++++ .../nextstep/courses/domain/CapacityTest.java | 23 +++++++++++++++++++ 2 files changed, 35 insertions(+) create mode 100644 src/main/java/nextstep/courses/domain/Capacity.java create mode 100644 src/test/java/nextstep/courses/domain/CapacityTest.java 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..db2dd20ed --- /dev/null +++ b/src/main/java/nextstep/courses/domain/Capacity.java @@ -0,0 +1,12 @@ +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; + } +} 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..ab6a1a1b1 --- /dev/null +++ b/src/test/java/nextstep/courses/domain/CapacityTest.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 CapacityTest { + + @Test + void 생성자_정상입력_생성성공() { + assertThatCode(() -> new Capacity(1)).doesNotThrowAnyException(); + } + + @Test + void 생성자_0명이하_예외발생() { + assertThatThrownBy(() -> new Capacity(0)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("최대 수강 인원은 1명 이상"); + } +} From 3c2f1a63826957b0eaadf0545058e46c84ee346c Mon Sep 17 00:00:00 2001 From: ghtjr410 Date: Wed, 10 Dec 2025 21:01:40 +0900 Subject: [PATCH 30/45] =?UTF-8?q?feat:=20Capacity=EC=97=90=20=EC=A0=95?= =?UTF-8?q?=EC=9B=90=20=EC=B4=88=EA=B3=BC=20=EC=97=AC=EB=B6=80=20=ED=8C=90?= =?UTF-8?q?=EB=8B=A8=20=EA=B8=B0=EB=8A=A5=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Capacity.java - isOver(currentCount) 메서드 추가로 현재 인원 대비 정원 초과 여부 판단 기능 제공 CapacityTest.java - isOver에 대한 파라미터화 테스트(CsvSource) 추가 - 정원 == 현재 인원 → 초과(true), 현재 인원 < 정원 → 미초과(false) 검증 --- src/main/java/nextstep/courses/domain/Capacity.java | 4 ++++ .../java/nextstep/courses/domain/CapacityTest.java | 11 +++++++++++ 2 files changed, 15 insertions(+) diff --git a/src/main/java/nextstep/courses/domain/Capacity.java b/src/main/java/nextstep/courses/domain/Capacity.java index db2dd20ed..b23515f9f 100644 --- a/src/main/java/nextstep/courses/domain/Capacity.java +++ b/src/main/java/nextstep/courses/domain/Capacity.java @@ -9,4 +9,8 @@ public Capacity(int value) { } this.value = value; } + + public boolean isOver(int currentCount) { + return currentCount >= value; + } } diff --git a/src/test/java/nextstep/courses/domain/CapacityTest.java b/src/test/java/nextstep/courses/domain/CapacityTest.java index ab6a1a1b1..f7944ca95 100644 --- a/src/test/java/nextstep/courses/domain/CapacityTest.java +++ b/src/test/java/nextstep/courses/domain/CapacityTest.java @@ -5,6 +5,8 @@ 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 { @@ -20,4 +22,13 @@ class CapacityTest { .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); + + // then + assertThat(result).isEqualTo(expected); + } } From e7d1082980b3dfe5fa206acde5a2f810c3900234 Mon Sep 17 00:00:00 2001 From: ghtjr410 Date: Wed, 10 Dec 2025 21:12:43 +0900 Subject: [PATCH 31/45] =?UTF-8?q?refactor:=20PaidSession=EC=97=90=EC=84=9C?= =?UTF-8?q?=20Capacity=C2=B7Money=20=EA=B0=92=20=EA=B0=9D=EC=B2=B4=20?= =?UTF-8?q?=EC=A0=81=EC=9A=A9=20=EB=B0=8F=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20?= =?UTF-8?q?=EC=A0=95=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Capacity.java - getValue() 추가하여 정원 출력 가능하도록 확장 PaidSession.java - 정원(capacity)와 수강료(price)를 VO(Capacity, Money)로 교체 - 기본 생성자에서 int 입력을 받아 VO로 변환하도록 변경 - 상태지정 생성자 역시 VO 기반 생성자로 연결 - validateCapacity에서 Capacity.isOver 사용하도록 변경 - 오류 메시지에서 capacity.getValue() 활용 PaidSessionTest.java - 생성자 테스트를 VO 적용 후 시그니처 변경에 맞게 수정 - fee 제거 후 price 정수 기반 생성 구조로 변경 - enroll 관련 테스트 모두 업데이트된 시그니처에 맞게 수정 --- .../nextstep/courses/domain/Capacity.java | 4 +++ .../nextstep/courses/domain/PaidSession.java | 27 ++++++++----------- .../courses/domain/PaidSessionTest.java | 21 +++------------ 3 files changed, 19 insertions(+), 33 deletions(-) diff --git a/src/main/java/nextstep/courses/domain/Capacity.java b/src/main/java/nextstep/courses/domain/Capacity.java index b23515f9f..dbfbab0bf 100644 --- a/src/main/java/nextstep/courses/domain/Capacity.java +++ b/src/main/java/nextstep/courses/domain/Capacity.java @@ -13,4 +13,8 @@ public Capacity(int value) { public boolean isOver(int currentCount) { return currentCount >= value; } + + public int getValue() { + return this.value; + } } diff --git a/src/main/java/nextstep/courses/domain/PaidSession.java b/src/main/java/nextstep/courses/domain/PaidSession.java index e9f2400d7..b0f13d6ca 100644 --- a/src/main/java/nextstep/courses/domain/PaidSession.java +++ b/src/main/java/nextstep/courses/domain/PaidSession.java @@ -1,29 +1,24 @@ package nextstep.courses.domain; public class PaidSession extends Session { - private final int capacity; + private final Capacity capacity; private final Money price; - public PaidSession(CoverImage coverImage, SessionPeriod period, int capacity, Money price) { - super(coverImage, period); - validateMaxEnrollment(capacity); - this.capacity = capacity; - this.price = price; + public PaidSession(CoverImage coverImage, SessionPeriod period, int capacity, int price) { + this(coverImage, period, SessionStatus.PREPARING, new Capacity(capacity), new Money(price)); + } + + public PaidSession(CoverImage coverImage, SessionPeriod period, SessionStatus status, int capacity, int price) { + this(coverImage, period, status, new Capacity(capacity), new Money(price)); } - public PaidSession(CoverImage coverImage, SessionPeriod period, SessionStatus status, int capacity, Money price) { + public PaidSession( + CoverImage coverImage, SessionPeriod period, SessionStatus status, Capacity capacity, Money price) { super(coverImage, period, status); - validateMaxEnrollment(capacity); this.capacity = capacity; this.price = price; } - private void validateMaxEnrollment(int maxEnrollment) { - if (maxEnrollment <= 0) { - throw new IllegalArgumentException(String.format("최대 수강 인원은 1명 이상이어야 합니다. (입력: %d)", maxEnrollment)); - } - } - public void enroll(Enrollment enrollment, Money payment) { validatePayment(payment); validateCapacity(); @@ -37,8 +32,8 @@ private void validatePayment(Money payment) { } private void validateCapacity() { - if (enrollmentCount() >= capacity) { - throw new IllegalStateException(String.format("수강 인원이 초과되었습니다. (최대: %d명)", capacity)); + if (this.capacity.isOver(enrollmentCount())) { + throw new IllegalStateException(String.format("수강 인원이 초과되었습니다. (최대: %d명)", capacity.getValue())); } } } diff --git a/src/test/java/nextstep/courses/domain/PaidSessionTest.java b/src/test/java/nextstep/courses/domain/PaidSessionTest.java index 9cfbc6471..f0b30bafd 100644 --- a/src/test/java/nextstep/courses/domain/PaidSessionTest.java +++ b/src/test/java/nextstep/courses/domain/PaidSessionTest.java @@ -14,34 +14,21 @@ class PaidSessionTest { private CoverImage coverImage; private SessionPeriod period; - private Money fee; @BeforeEach void setUp() { coverImage = new CoverImage("image.png", 1024, 300, 200); period = new SessionPeriod(LocalDate.of(2024, 1, 1), LocalDate.of(2024, 3, 31)); - fee = new Money(50000); } @Test void 생성자_정상입력_생성성공() { - assertThatCode(() -> new PaidSession(coverImage, period, 30, fee)).doesNotThrowAnyException(); - } - - @Test - void 생성자_최대인원0명_예외발생() { - CoverImage coverImage = new CoverImage("image.png", 1024, 300, 200); - SessionPeriod period = new SessionPeriod(LocalDate.of(2024, 1, 1), LocalDate.of(2024, 3, 31)); - Money fee = new Money(50000); - - assertThatThrownBy(() -> new PaidSession(coverImage, period, 0, fee)) - .isInstanceOf(IllegalArgumentException.class) - .hasMessageContaining("최대 수강 인원은 1명 이상"); + assertThatCode(() -> new PaidSession(coverImage, period, 30, 50000)).doesNotThrowAnyException(); } @Test void enroll_정상입력_성공() { - PaidSession session = new PaidSession(coverImage, period, SessionStatus.RECRUITING, 3, fee); + PaidSession session = new PaidSession(coverImage, period, SessionStatus.RECRUITING, 3, 50000); Enrollment enrollment = new Enrollment(1L, 1L, LocalDateTime.now()); Money payment = new Money(50000); @@ -52,7 +39,7 @@ void setUp() { @Test void enroll_결제금액불일치_예외발생() { - PaidSession session = new PaidSession(coverImage, period, SessionStatus.RECRUITING, 3, fee); + PaidSession session = new PaidSession(coverImage, period, SessionStatus.RECRUITING, 3, 50000); Enrollment enrollment = new Enrollment(1L, 1L, LocalDateTime.now()); Money wrongPayment = new Money(30000); @@ -63,7 +50,7 @@ void setUp() { @Test void enroll_인원초과_예외발생() { - PaidSession session = new PaidSession(coverImage, period, SessionStatus.RECRUITING, 2, fee); + PaidSession session = new PaidSession(coverImage, period, SessionStatus.RECRUITING, 2, 50000); Money payment = new Money(50000); session.enroll(new Enrollment(1L, 1L, LocalDateTime.now()), payment); From 99f90a8f368c4264825212ed91a558710b848fab Mon Sep 17 00:00:00 2001 From: ghtjr410 Date: Wed, 10 Dec 2025 21:27:42 +0900 Subject: [PATCH 32/45] =?UTF-8?q?refactor:=20Course=C2=B7Enrollment=20?= =?UTF-8?q?=EB=B6=88=EB=B3=80=EC=84=B1=20=EA=B0=95=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Course.java - createdAt, updatedAt 필드를 final로 변경하여 생성 시점 이후 변경 불가하도록 개선 Enrollment.java - id, sessionId, studentId, createdAt 필드를 final로 변경해 불변 객체로 전환 - 생성자 외에는 상태가 변하지 않도록 구조 명확화 --- src/main/java/nextstep/courses/domain/Course.java | 4 ++-- src/main/java/nextstep/courses/domain/Enrollment.java | 8 ++++---- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/src/main/java/nextstep/courses/domain/Course.java b/src/main/java/nextstep/courses/domain/Course.java index f3083f9e3..06fe9b5ff 100644 --- a/src/main/java/nextstep/courses/domain/Course.java +++ b/src/main/java/nextstep/courses/domain/Course.java @@ -11,9 +11,9 @@ public class Course { private final Sessions sessions; - private LocalDateTime createdAt; + private final LocalDateTime createdAt; - private LocalDateTime updatedAt; + private final LocalDateTime updatedAt; public Course(String title, Long creatorId, LocalDateTime now) { this(0L, title, creatorId, new Sessions(), now, null); diff --git a/src/main/java/nextstep/courses/domain/Enrollment.java b/src/main/java/nextstep/courses/domain/Enrollment.java index 91bee250e..18077f13f 100644 --- a/src/main/java/nextstep/courses/domain/Enrollment.java +++ b/src/main/java/nextstep/courses/domain/Enrollment.java @@ -3,10 +3,10 @@ import java.time.LocalDateTime; public class Enrollment { - private Long id; - private Long sessionId; - private Long studentId; - private LocalDateTime createdAt; + 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); From c65b23a1065fd94e640f2501e078752f5d808202 Mon Sep 17 00:00:00 2001 From: ghtjr410 Date: Wed, 10 Dec 2025 21:37:32 +0900 Subject: [PATCH 33/45] =?UTF-8?q?refactor:=20Session=20=EB=B6=88=EB=B3=80?= =?UTF-8?q?=20=ED=95=84=EB=93=9C=20=EC=A0=81=EC=9A=A9=20=EB=B0=8F=20?= =?UTF-8?q?=ED=85=8C=EC=8A=A4=ED=8A=B8=20=EC=A3=BC=EC=84=9D=20=EC=A0=95?= =?UTF-8?q?=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Session.java - id, coverImage, period, status 필드를 final로 변경하여 불변성 강화 - 생성 후 변경 불가능한 도메인 모델로 구조 개선 CapacityTest.java - 불필요한 주석 제거로 테스트 코드 간결화 --- src/main/java/nextstep/courses/domain/Session.java | 8 ++++---- src/test/java/nextstep/courses/domain/CapacityTest.java | 1 - 2 files changed, 4 insertions(+), 5 deletions(-) diff --git a/src/main/java/nextstep/courses/domain/Session.java b/src/main/java/nextstep/courses/domain/Session.java index 6c8eb8a6a..879fcc906 100644 --- a/src/main/java/nextstep/courses/domain/Session.java +++ b/src/main/java/nextstep/courses/domain/Session.java @@ -1,10 +1,10 @@ package nextstep.courses.domain; public abstract class Session { - private Long id; - private CoverImage coverImage; - private SessionPeriod period; - private SessionStatus status; + private final Long id; + private final CoverImage coverImage; + private final SessionPeriod period; + private final SessionStatus status; private final Enrollments enrollments; public Session(CoverImage coverImage, SessionPeriod period) { diff --git a/src/test/java/nextstep/courses/domain/CapacityTest.java b/src/test/java/nextstep/courses/domain/CapacityTest.java index f7944ca95..6be60f0ba 100644 --- a/src/test/java/nextstep/courses/domain/CapacityTest.java +++ b/src/test/java/nextstep/courses/domain/CapacityTest.java @@ -28,7 +28,6 @@ class CapacityTest { void isOver_정원초과여부_확인(int capacityValue, int currentCount, boolean expected) { boolean result = new Capacity(capacityValue).isOver(currentCount); - // then assertThat(result).isEqualTo(expected); } } From 10d47c2576262463238e9f290aa5d500f8ccc095 Mon Sep 17 00:00:00 2001 From: ghtjr410 Date: Wed, 10 Dec 2025 21:39:32 +0900 Subject: [PATCH 34/45] =?UTF-8?q?docs:=20=EB=8F=84=EB=A9=94=EC=9D=B8=20?= =?UTF-8?q?=EB=AA=A8=EB=8D=B8=20=EB=AC=B8=EC=84=9C=EC=97=90=20=EA=B7=9C?= =?UTF-8?q?=EC=B9=99=C2=B7=EC=B2=B4=ED=81=AC=EB=A6=AC=EC=8A=A4=ED=8A=B8?= =?UTF-8?q?=C2=B7=EA=B5=AC=ED=98=84=20=EB=AA=A9=EB=A1=9D=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 규칙 7(인스턴스 변수 3개 제한)을 명시적으로 문서에 포함해 설계 기준 강화 - PR 전 점검 섹션 추가 및 체크리스트 파일 링크 연결 - 전체 도메인 기능 구현 목록을 항목별로 정리해 진행 상황을 명확하게 표현 - Course, Session, Value Objects, 일급 컬렉션, Enum 등 모든 구성 요소의 요구 기능을 체크리스트 형태로 문서화 --- docs/02-lms-domain-model.md | 60 ++++++++++++++++++++++++++++++++++++- 1 file changed, 59 insertions(+), 1 deletion(-) diff --git a/docs/02-lms-domain-model.md b/docs/02-lms-domain-model.md index 234e52c03..ebec46a9d 100644 --- a/docs/02-lms-domain-model.md +++ b/docs/02-lms-domain-model.md @@ -8,6 +8,7 @@ - 객체지향 생활 체조 원칙 준수 - 테스트 작성하기 쉬운 구조로 설계 - 자기 점검 체크리스트 준수 +- 특히 "규칙 7: 3개 이상의 인스턴스 변수를 가진 클래스를 쓰지 않는다."를 지키기 위해 노력한다. ## 학습 관리 시스템(LMS) - 넥스트스텝은 재직자를 대상으로 소프트웨어 교육을 진행하는 교육 기관이다. - 2018년 교육 사업을 시작했다. @@ -30,4 +31,61 @@ ## 프로그래밍 요구사항 - DB 테이블 설꼐 없이 도메인 모델부터 구현한다. - 도메인 모델은 TDD로 구현한다. - - 단, Service 클래스는 단위 테스트가 없어도 된다. \ No newline at end of file + - 단, Service 클래스는 단위 테스트가 없어도 된다. +## PR 전 점검 +**[체크리스트 확인하기](checklist.md)** +## 구현 기능 목록 +#### Course +- [x] 강의 추가 +- [x] 강의 개수 조회 + +#### Sessions (일급 컬렉션) +- [x] 강의 추가 +- [x] 강의 개수 조회 + +#### Session (abstract) +- [x] 수강 신청 + - [x] 모집중 상태 검증 +- [x] 수강 인원 조회 + +#### FreeSession +- [x] Session 상속 +#### PaidSession +- [x] 수강 신청 + - [x] 결제 금액 검증 + - [x] 수강 인원 검증 + - [x] 부모 수강 신청 호출 + +#### Enrollments (일급 컬렉션) +- [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] 초과 여부 확인 From c6e13baa9efc35fc9750015e269c4525c6c33ac1 Mon Sep 17 00:00:00 2001 From: ghtjr410 Date: Wed, 10 Dec 2025 21:54:29 +0900 Subject: [PATCH 35/45] =?UTF-8?q?docs:=20PR=20=EB=A7=81=ED=81=AC=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/02-lms-domain-model.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/02-lms-domain-model.md b/docs/02-lms-domain-model.md index ebec46a9d..86fbc69c2 100644 --- a/docs/02-lms-domain-model.md +++ b/docs/02-lms-domain-model.md @@ -2,7 +2,7 @@ *** ## 코드 리뷰 > PR 링크: -> +> **[https://github.com/next-step/java-lms/pull/811](https://github.com/next-step/java-lms/pull/811)** ## 나의 학습 목표 - TDD 사이클로 구현 - 객체지향 생활 체조 원칙 준수 From e39012da09cdbcdc76add9d834648175e30bffaa Mon Sep 17 00:00:00 2001 From: ghtjr410 Date: Wed, 10 Dec 2025 22:37:20 +0900 Subject: [PATCH 36/45] =?UTF-8?q?feat:=20=EC=88=98=EA=B0=95=20=EC=8B=A0?= =?UTF-8?q?=EC=B2=AD=20=EC=A4=91=EB=B3=B5=20=EA=B2=80=EC=A6=9D=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Enrollment.java - studentId 조회를 위한 getter 추가 Enrollments.java - add 시 중복 수강 신청 여부 검증 추가 - 동일 studentId 존재 시 예외 발생 처리 EnrollmentsTest.java - 정상 추가 테스트 작성 - 중복 신청 시 예외 검증 테스트 추가 --- .../nextstep/courses/domain/Enrollment.java | 4 ++ .../nextstep/courses/domain/Enrollments.java | 8 ++++ .../courses/domain/EnrollmentsTest.java | 38 +++++++++++++++++++ 3 files changed, 50 insertions(+) create mode 100644 src/test/java/nextstep/courses/domain/EnrollmentsTest.java diff --git a/src/main/java/nextstep/courses/domain/Enrollment.java b/src/main/java/nextstep/courses/domain/Enrollment.java index 18077f13f..28442d304 100644 --- a/src/main/java/nextstep/courses/domain/Enrollment.java +++ b/src/main/java/nextstep/courses/domain/Enrollment.java @@ -18,4 +18,8 @@ public Enrollment(Long id, Long sessionId, Long studentId, LocalDateTime created this.studentId = studentId; this.createdAt = createdAt; } + + public Long getStudentId() { + return studentId; + } } diff --git a/src/main/java/nextstep/courses/domain/Enrollments.java b/src/main/java/nextstep/courses/domain/Enrollments.java index ebb07909c..147337a46 100644 --- a/src/main/java/nextstep/courses/domain/Enrollments.java +++ b/src/main/java/nextstep/courses/domain/Enrollments.java @@ -15,9 +15,17 @@ public Enrollments(List values) { } public void add(Enrollment enrollment) { + validateNotDuplicate(enrollment); this.values.add(enrollment); } + private void validateNotDuplicate(Enrollment enrollment) { + boolean exists = values.stream().anyMatch(e -> e.getStudentId().equals(enrollment.getStudentId())); + if (exists) { + throw new IllegalArgumentException("이미 수강 신청한 강의입니다."); + } + } + public int count() { return this.values.size(); } 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("이미 수강 신청한 강의입니다."); + } +} From 1fb78b798e62e7a28148b162e22a8e7d94663ed6 Mon Sep 17 00:00:00 2001 From: ghtjr410 Date: Wed, 10 Dec 2025 22:56:58 +0900 Subject: [PATCH 37/45] =?UTF-8?q?refactor:=20Session=20=EC=88=98=EA=B0=95?= =?UTF-8?q?=EC=8B=A0=EC=B2=AD=20=ED=85=9C=ED=94=8C=EB=A6=BF=20=EB=A9=94?= =?UTF-8?q?=EC=84=9C=EB=93=9C=20=ED=8C=A8=ED=84=B4=20=EC=A0=81=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit FreeSession.java - 결제/정원 정책 메서드 빈 구현 추가(무료 세션 특성 반영) Money.java - ZERO 상수 추가로 무료 결제 표현 단순화 PaidSession.java - 결제 검증을 validatePaymentPolicy로 이관 - 정원 검증을 validateCapacityPolicy로 이관 Session.java - enroll 메서드 final 처리 및 템플릿 메서드 패턴 적용 - validatePaymentPolicy, validateCapacityPolicy 추상 메서드 정의 SessionTest.java - 무료 세션 enroll 테스트에서 Money.ZERO 사용하도록 수정 - 상태 검증 테스트도 동일하게 Money.ZERO 적용 --- .../java/nextstep/courses/domain/FreeSession.java | 6 ++++++ src/main/java/nextstep/courses/domain/Money.java | 2 ++ .../java/nextstep/courses/domain/PaidSession.java | 12 ++++-------- src/main/java/nextstep/courses/domain/Session.java | 8 +++++++- .../java/nextstep/courses/domain/SessionTest.java | 4 ++-- 5 files changed, 21 insertions(+), 11 deletions(-) diff --git a/src/main/java/nextstep/courses/domain/FreeSession.java b/src/main/java/nextstep/courses/domain/FreeSession.java index 283d2c219..d8753d33a 100644 --- a/src/main/java/nextstep/courses/domain/FreeSession.java +++ b/src/main/java/nextstep/courses/domain/FreeSession.java @@ -8,4 +8,10 @@ public FreeSession(CoverImage coverImage, SessionPeriod period) { public FreeSession(CoverImage coverImage, SessionPeriod period, SessionStatus status) { super(coverImage, period, status); } + + @Override + protected void validatePaymentPolicy(Money payment) {} + + @Override + protected void validateCapacityPolicy() {} } diff --git a/src/main/java/nextstep/courses/domain/Money.java b/src/main/java/nextstep/courses/domain/Money.java index 4dc3bfd7f..770938861 100644 --- a/src/main/java/nextstep/courses/domain/Money.java +++ b/src/main/java/nextstep/courses/domain/Money.java @@ -1,6 +1,8 @@ package nextstep.courses.domain; public class Money { + public static final Money ZERO = new Money(0); + private final int amount; public Money(int amount) { diff --git a/src/main/java/nextstep/courses/domain/PaidSession.java b/src/main/java/nextstep/courses/domain/PaidSession.java index b0f13d6ca..891bb8b00 100644 --- a/src/main/java/nextstep/courses/domain/PaidSession.java +++ b/src/main/java/nextstep/courses/domain/PaidSession.java @@ -19,19 +19,15 @@ public PaidSession( this.price = price; } - public void enroll(Enrollment enrollment, Money payment) { - validatePayment(payment); - validateCapacity(); - super.enroll(enrollment); - } - - private void validatePayment(Money payment) { + @Override + protected void validatePaymentPolicy(Money payment) { if (!price.isSameAs(payment)) { throw new IllegalArgumentException("결제 금액이 수강료와 일치하지 않습니다."); } } - private void validateCapacity() { + @Override + protected void validateCapacityPolicy() { if (this.capacity.isOver(enrollmentCount())) { 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 index 879fcc906..dfa148956 100644 --- a/src/main/java/nextstep/courses/domain/Session.java +++ b/src/main/java/nextstep/courses/domain/Session.java @@ -24,8 +24,10 @@ public Session( this.enrollments = enrollments; } - public void enroll(Enrollment enrollment) { + public final void enroll(Enrollment enrollment, Money payment) { validateStatus(); + validatePaymentPolicy(payment); + validateCapacityPolicy(); enrollments.add(enrollment); } @@ -35,6 +37,10 @@ protected void validateStatus() { } } + protected abstract void validatePaymentPolicy(Money payment); + + protected abstract void validateCapacityPolicy(); + public int enrollmentCount() { return enrollments.count(); } diff --git a/src/test/java/nextstep/courses/domain/SessionTest.java b/src/test/java/nextstep/courses/domain/SessionTest.java index 5dabf0870..2d4a052ed 100644 --- a/src/test/java/nextstep/courses/domain/SessionTest.java +++ b/src/test/java/nextstep/courses/domain/SessionTest.java @@ -34,7 +34,7 @@ void setUp() { Session session = new FreeSession(coverImage, period, SessionStatus.RECRUITING); Enrollment enrollment = new Enrollment(1L, 1L, LocalDateTime.now()); - session.enroll(enrollment); + session.enroll(enrollment, Money.ZERO); assertThat(session.enrollmentCount()).isEqualTo(1); } @@ -47,7 +47,7 @@ void setUp() { Session session = new FreeSession(coverImage, period, status); Enrollment enrollment = new Enrollment(1L, 1L, LocalDateTime.now()); - assertThatThrownBy(() -> session.enroll(enrollment)) + assertThatThrownBy(() -> session.enroll(enrollment, Money.ZERO)) .isInstanceOf(IllegalStateException.class) .hasMessageContaining("모집중인 강의만"); } From 28c9199d5190f45280c2c2dfb75c450d6f39cd7a Mon Sep 17 00:00:00 2001 From: ghtjr410 Date: Wed, 10 Dec 2025 23:10:31 +0900 Subject: [PATCH 38/45] =?UTF-8?q?test:=20FreeSession=20=EC=A0=84=EC=9A=A9?= =?UTF-8?q?=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20=ED=99=95=EC=9E=A5=20=EB=B0=8F?= =?UTF-8?q?=20SessionTest=20=EC=82=AD=EC=A0=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit FreeSessionTest.java - 공통 픽스처 setUp 추가 - 모집중 수강 신청 성공 테스트 추가 - 상태별(준비중/종료) 수강 신청 실패 테스트 추가 - 기존 생성자 검증 로직 유지 및 보강 SessionTest.java - FreeSessionTest로 통합됨에 따라 클래스 삭제 --- .../courses/domain/FreeSessionTest.java | 42 +++++++++++++-- .../nextstep/courses/domain/SessionTest.java | 54 ------------------- 2 files changed, 39 insertions(+), 57 deletions(-) delete mode 100644 src/test/java/nextstep/courses/domain/SessionTest.java diff --git a/src/test/java/nextstep/courses/domain/FreeSessionTest.java b/src/test/java/nextstep/courses/domain/FreeSessionTest.java index e9751f43f..63f9c815a 100644 --- a/src/test/java/nextstep/courses/domain/FreeSessionTest.java +++ b/src/test/java/nextstep/courses/domain/FreeSessionTest.java @@ -3,18 +3,54 @@ 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 FreeSessionTest { + 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 생성자_정상입력_생성성공() { - CoverImage coverImage = new CoverImage("image.png", 1024, 300, 200); - SessionPeriod sessionPeriod = new SessionPeriod(LocalDate.of(2024, 1, 1), LocalDate.of(2024, 3, 31)); + FreeSession session = new FreeSession(coverImage, period); + + assertThat(session.getStatus()).isEqualTo(SessionStatus.PREPARING); + } + + @Test + void enroll_모집중_성공() { + Session session = new FreeSession(coverImage, period, SessionStatus.RECRUITING); + 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) { + FreeSession session = new FreeSession(coverImage, period, status); + + Enrollment enrollment = new Enrollment(1L, 1L, LocalDateTime.now()); - assertThatCode(() -> new FreeSession(coverImage, sessionPeriod)).doesNotThrowAnyException(); + assertThatThrownBy(() -> session.enroll(enrollment, Money.ZERO)) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("모집중인 강의만"); } } diff --git a/src/test/java/nextstep/courses/domain/SessionTest.java b/src/test/java/nextstep/courses/domain/SessionTest.java deleted file mode 100644 index 2d4a052ed..000000000 --- a/src/test/java/nextstep/courses/domain/SessionTest.java +++ /dev/null @@ -1,54 +0,0 @@ -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 FreeSession(coverImage, period); - - assertThat(session.getStatus()).isEqualTo(SessionStatus.PREPARING); - } - - @Test - void enroll_모집중_성공() { - Session session = new FreeSession(coverImage, period, SessionStatus.RECRUITING); - 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 FreeSession(coverImage, period, status); - Enrollment enrollment = new Enrollment(1L, 1L, LocalDateTime.now()); - - assertThatThrownBy(() -> session.enroll(enrollment, Money.ZERO)) - .isInstanceOf(IllegalStateException.class) - .hasMessageContaining("모집중인 강의만"); - } -} From 2fdb686a6c8e8c993dd12af1af220ef2294c7409 Mon Sep 17 00:00:00 2001 From: ghtjr410 Date: Wed, 10 Dec 2025 23:19:22 +0900 Subject: [PATCH 39/45] =?UTF-8?q?test:=20=EC=83=9D=EC=84=B1=EC=9E=90=20?= =?UTF-8?q?=EB=B0=8F=20=EC=88=98=EA=B0=95=EC=8B=A0=EC=B2=AD=20=ED=85=8C?= =?UTF-8?q?=EC=8A=A4=ED=8A=B8=20=EA=B0=84=EA=B2=B0=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit FreeSessionTest - 생성자 검증을 한 줄로 축약하여 가독성 개선 PaidSessionTest - 생성자 검증을 한 줄 표현으로 간결화 - 정상 수강 신청 테스트에서 payment 변수 제거 → 즉시 new Money() 전달 - 모집중이 아닐 때 실패 테스트 추가 (`EnumSource` 활용) --- .../courses/domain/FreeSessionTest.java | 4 +--- .../courses/domain/PaidSessionTest.java | 21 ++++++++++++++++--- 2 files changed, 19 insertions(+), 6 deletions(-) diff --git a/src/test/java/nextstep/courses/domain/FreeSessionTest.java b/src/test/java/nextstep/courses/domain/FreeSessionTest.java index 63f9c815a..6cc611b28 100644 --- a/src/test/java/nextstep/courses/domain/FreeSessionTest.java +++ b/src/test/java/nextstep/courses/domain/FreeSessionTest.java @@ -25,9 +25,7 @@ void setUp() { @Test void 생성자_정상입력_생성성공() { - FreeSession session = new FreeSession(coverImage, period); - - assertThat(session.getStatus()).isEqualTo(SessionStatus.PREPARING); + assertThat(new FreeSession(coverImage, period).getStatus()).isEqualTo(SessionStatus.PREPARING); } @Test diff --git a/src/test/java/nextstep/courses/domain/PaidSessionTest.java b/src/test/java/nextstep/courses/domain/PaidSessionTest.java index f0b30bafd..4321181d0 100644 --- a/src/test/java/nextstep/courses/domain/PaidSessionTest.java +++ b/src/test/java/nextstep/courses/domain/PaidSessionTest.java @@ -8,6 +8,8 @@ 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 PaidSessionTest { @@ -23,16 +25,15 @@ void setUp() { @Test void 생성자_정상입력_생성성공() { - assertThatCode(() -> new PaidSession(coverImage, period, 30, 50000)).doesNotThrowAnyException(); + assertThat(new PaidSession(coverImage, period, 30, 50000).getStatus()).isEqualTo(SessionStatus.PREPARING); } @Test void enroll_정상입력_성공() { PaidSession session = new PaidSession(coverImage, period, SessionStatus.RECRUITING, 3, 50000); Enrollment enrollment = new Enrollment(1L, 1L, LocalDateTime.now()); - Money payment = new Money(50000); - session.enroll(enrollment, payment); + session.enroll(enrollment, new Money(50000)); assertThat(session.enrollmentCount()).isEqualTo(1); } @@ -60,4 +61,18 @@ void setUp() { .isInstanceOf(IllegalStateException.class) .hasMessageContaining("수강 인원이 초과"); } + + @ParameterizedTest(name = "상태:{0}") + @EnumSource( + value = SessionStatus.class, + names = {"PREPARING", "CLOSED"}) + void enroll_모집중이아닐시_예외발생(SessionStatus status) { + PaidSession session = new PaidSession(coverImage, period, status, 2, 50000); + + Enrollment enrollment = new Enrollment(1L, 1L, LocalDateTime.now()); + + assertThatThrownBy(() -> session.enroll(enrollment, Money.ZERO)) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("모집중인 강의만"); + } } From d0f4f1a88c96de47d8bea6109161b6a367909900 Mon Sep 17 00:00:00 2001 From: ghtjr410 Date: Wed, 10 Dec 2025 23:25:08 +0900 Subject: [PATCH 40/45] =?UTF-8?q?docs:=20=EC=84=B8=EC=85=98=20=EA=B5=AC?= =?UTF-8?q?=EC=A1=B0=20=EB=B0=8F=20=ED=85=9C=ED=94=8C=EB=A6=BF=20=EB=A9=94?= =?UTF-8?q?=EC=84=9C=EB=93=9C=20=ED=8C=A8=ED=84=B4=20=EC=A0=81=EC=9A=A9=20?= =?UTF-8?q?=EB=82=B4=EC=9A=A9=20=EC=97=85=EB=8D=B0=EC=9D=B4=ED=8A=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 02-lms-domain-model.md - Session을 추상 클래스 + 템플릿 메서드 패턴 적용 방식으로 상세화 - FreeSession/PaidSession의 정책 검증 구조와 구현 방식 설명 추가 - Enrollments의 중복 수강 신청 검증 항목 추가 --- docs/02-lms-domain-model.md | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/docs/02-lms-domain-model.md b/docs/02-lms-domain-model.md index 86fbc69c2..fd98f596a 100644 --- a/docs/02-lms-domain-model.md +++ b/docs/02-lms-domain-model.md @@ -43,22 +43,29 @@ - [x] 강의 추가 - [x] 강의 개수 조회 -#### Session (abstract) -- [x] 수강 신청 +#### Session (추상 클래스, 템플릿 메서드 패턴 적용) +- [x] 수강 신청 – 템플릿 메서드 방식 + - [x] `final` enroll(enrollment, payment) 정의 - [x] 모집중 상태 검증 + - [x] 결제 정책 검증 – 하위에서 구현 (validatePaymentPolicy) + - [x] 정원 정책 검증 – 하위에서 구현 (validateCapacityPolicy) - [x] 수강 인원 조회 #### FreeSession - [x] Session 상속 +- [x] 수강 신청 + - [x] 결제 정책 없음 → validatePaymentPolicy(): 빈 구현 + - [x] 정원 정책 없음 → validateCapacityPolicy(): 빈 구현 #### PaidSession +- [x] Session 상속 - [x] 수강 신청 - [x] 결제 금액 검증 - [x] 수강 인원 검증 - - [x] 부모 수강 신청 호출 #### Enrollments (일급 컬렉션) - [x] 수강 신청 추가 - [x] 수강 인원 조회 +- [x] 중복 수강 신청 검증 #### CoverImage (VO) - [x] 이미지 파일 정보와 크기 정보 조합 생성 From 900c8efa224264c04d58b0e6e85dc077b724c5de Mon Sep 17 00:00:00 2001 From: ghtjr410 Date: Thu, 11 Dec 2025 07:14:42 +0900 Subject: [PATCH 41/45] =?UTF-8?q?refactor:=20=EC=A4=91=EB=B3=B5=20?= =?UTF-8?q?=EC=88=98=EA=B0=95=20=EC=8B=A0=EC=B2=AD=20=EA=B2=80=EC=A6=9D=20?= =?UTF-8?q?=EB=A1=9C=EC=A7=81=20=EA=B0=84=EC=86=8C=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Enrollments.java - 중복 검증을 stream anyMatch → values.contains(enrollment) 방식으로 단순화 --- src/main/java/nextstep/courses/domain/Enrollments.java | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/main/java/nextstep/courses/domain/Enrollments.java b/src/main/java/nextstep/courses/domain/Enrollments.java index 147337a46..2be746d9b 100644 --- a/src/main/java/nextstep/courses/domain/Enrollments.java +++ b/src/main/java/nextstep/courses/domain/Enrollments.java @@ -20,8 +20,7 @@ public void add(Enrollment enrollment) { } private void validateNotDuplicate(Enrollment enrollment) { - boolean exists = values.stream().anyMatch(e -> e.getStudentId().equals(enrollment.getStudentId())); - if (exists) { + if (values.contains(enrollment)) { throw new IllegalArgumentException("이미 수강 신청한 강의입니다."); } } From 8986d3b15e8b283030905caaade63a2152cd686b Mon Sep 17 00:00:00 2001 From: ghtjr410 Date: Thu, 11 Dec 2025 07:50:29 +0900 Subject: [PATCH 42/45] =?UTF-8?q?feat:=20=EC=88=98=EA=B0=95=20=EC=8B=A0?= =?UTF-8?q?=EC=B2=AD=20=EC=A0=95=EC=B1=85=20=EB=B6=84=EB=A6=AC=20=EB=B0=8F?= =?UTF-8?q?=20=EB=AC=B4=EB=A3=8C=20=EC=88=98=EA=B0=95=20=EC=A0=95=EC=B1=85?= =?UTF-8?q?=20=EA=B8=B0=EB=B3=B8=20=EA=B5=AC=ED=98=84=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit EnrollmentPolicy.java - 템플릿 메서드 패턴 기반의 공통 검증 구조 도입 - 결제·정원 검증 메서드를 추상화하여 정책별 구현 가능하도록 설계 FreeEnrollmentPolicy.java - 무료 강의용 정책 구현 - 결제·정원 검증을 비활성화 - FREE 타입 반환하도록 구현 SessionType.java - FREE/PAID 세션 타입 정의 및 설명 필드 추가 FreeEnrollmentPolicyTest.java - 무료 정책 검증 시 항상 성공하는지 테스트 추가 - 정책 타입이 FREE인지 검증하는 테스트 추가 --- .../courses/domain/EnrollmentPolicy.java | 14 +++++++++++ .../courses/domain/FreeEnrollmentPolicy.java | 19 +++++++++++++++ .../nextstep/courses/domain/SessionType.java | 12 ++++++++++ .../domain/FreeEnrollmentPolicyTest.java | 23 +++++++++++++++++++ 4 files changed, 68 insertions(+) create mode 100644 src/main/java/nextstep/courses/domain/EnrollmentPolicy.java create mode 100644 src/main/java/nextstep/courses/domain/FreeEnrollmentPolicy.java create mode 100644 src/main/java/nextstep/courses/domain/SessionType.java create mode 100644 src/test/java/nextstep/courses/domain/FreeEnrollmentPolicyTest.java 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/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/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/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); + } +} From 3597f2ca8005e6f8b3dee118e6ca65b21f7481b5 Mon Sep 17 00:00:00 2001 From: ghtjr410 Date: Thu, 11 Dec 2025 07:56:17 +0900 Subject: [PATCH 43/45] =?UTF-8?q?feat:=20=EC=9C=A0=EB=A3=8C=20=EC=88=98?= =?UTF-8?q?=EA=B0=95=20=EC=A0=95=EC=B1=85(PaidEnrollmentPolicy)=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80=20=EB=B0=8F=20=EA=B2=80=EC=A6=9D=20=ED=85=8C?= =?UTF-8?q?=EC=8A=A4=ED=8A=B8=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PaidEnrollmentPolicy.java - 유료 강의용 EnrollmentPolicy 구현 - 결제 금액 검증 로직 추가 (price.isSameAs) - 정원 초과 검증 로직 추가 (capacity.isOver) - PAID 타입 반환 PaidEnrollmentPolicyTest.java - 정상 결제·정원 입력 시 성공 테스트 추가 - 결제 금액 불일치 시 예외 발생 테스트 추가 - 정원 초과 시 예외 발생 테스트 추가 - PAID 타입 반환 검증 --- .../courses/domain/PaidEnrollmentPolicy.java | 34 +++++++++++++++ .../domain/PaidEnrollmentPolicyTest.java | 43 +++++++++++++++++++ 2 files changed, 77 insertions(+) create mode 100644 src/main/java/nextstep/courses/domain/PaidEnrollmentPolicy.java create mode 100644 src/test/java/nextstep/courses/domain/PaidEnrollmentPolicyTest.java 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/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); + } +} From c2844b4ec0ef784530e927b930ed5a0454467a5f Mon Sep 17 00:00:00 2001 From: ghtjr410 Date: Thu, 11 Dec 2025 08:06:24 +0900 Subject: [PATCH 44/45] =?UTF-8?q?refactor:=20Session=20=EA=B5=AC=EC=A1=B0?= =?UTF-8?q?=20=EB=8B=A8=EC=88=9C=ED=99=94=20=EB=B0=8F=20=EC=A0=95=EC=B1=85?= =?UTF-8?q?=20=EA=B8=B0=EB=B0=98=20=EA=B5=AC=EC=84=B1=EC=9C=BC=EB=A1=9C=20?= =?UTF-8?q?=ED=86=B5=ED=95=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit FreeSession.java / PaidSession.java - Session 하위 구현 제거 - 정책 기반 구조로 대체됨에 따라 클래스 삭제 Session.java - 추상 클래스 → 단일 클래스로 변경 - EnrollmentPolicy 의존성 추가하여 결제·정원 검증을 정책에 위임 - enroll 로직에서 상태 검증 후 정책 검증 호출하도록 통합 - getType() 추가하여 세션 타입 조회 가능 PaidSessionTest.java - 정책 기반 구조로 변경됨에 따라 기존 PaidSession 테스트 삭제 SessionTest.java - FreeSessionTest → SessionTest로 변경 - Session + FreeEnrollmentPolicy 조합으로 생성/수강 신청 테스트하도록 수정 - 모집 상태 검증 로직 유지 --- .../nextstep/courses/domain/FreeSession.java | 17 ---- .../nextstep/courses/domain/PaidSession.java | 35 --------- .../java/nextstep/courses/domain/Session.java | 32 ++++---- .../courses/domain/PaidSessionTest.java | 78 ------------------- ...{FreeSessionTest.java => SessionTest.java} | 12 +-- 5 files changed, 26 insertions(+), 148 deletions(-) delete mode 100644 src/main/java/nextstep/courses/domain/FreeSession.java delete mode 100644 src/main/java/nextstep/courses/domain/PaidSession.java delete mode 100644 src/test/java/nextstep/courses/domain/PaidSessionTest.java rename src/test/java/nextstep/courses/domain/{FreeSessionTest.java => SessionTest.java} (77%) diff --git a/src/main/java/nextstep/courses/domain/FreeSession.java b/src/main/java/nextstep/courses/domain/FreeSession.java deleted file mode 100644 index d8753d33a..000000000 --- a/src/main/java/nextstep/courses/domain/FreeSession.java +++ /dev/null @@ -1,17 +0,0 @@ -package nextstep.courses.domain; - -public class FreeSession extends Session { - public FreeSession(CoverImage coverImage, SessionPeriod period) { - super(coverImage, period); - } - - public FreeSession(CoverImage coverImage, SessionPeriod period, SessionStatus status) { - super(coverImage, period, status); - } - - @Override - protected void validatePaymentPolicy(Money payment) {} - - @Override - protected void validateCapacityPolicy() {} -} diff --git a/src/main/java/nextstep/courses/domain/PaidSession.java b/src/main/java/nextstep/courses/domain/PaidSession.java deleted file mode 100644 index 891bb8b00..000000000 --- a/src/main/java/nextstep/courses/domain/PaidSession.java +++ /dev/null @@ -1,35 +0,0 @@ -package nextstep.courses.domain; - -public class PaidSession extends Session { - private final Capacity capacity; - private final Money price; - - public PaidSession(CoverImage coverImage, SessionPeriod period, int capacity, int price) { - this(coverImage, period, SessionStatus.PREPARING, new Capacity(capacity), new Money(price)); - } - - public PaidSession(CoverImage coverImage, SessionPeriod period, SessionStatus status, int capacity, int price) { - this(coverImage, period, status, new Capacity(capacity), new Money(price)); - } - - public PaidSession( - CoverImage coverImage, SessionPeriod period, SessionStatus status, Capacity capacity, Money price) { - super(coverImage, period, status); - this.capacity = capacity; - this.price = price; - } - - @Override - protected void validatePaymentPolicy(Money payment) { - if (!price.isSameAs(payment)) { - throw new IllegalArgumentException("결제 금액이 수강료와 일치하지 않습니다."); - } - } - - @Override - protected void validateCapacityPolicy() { - if (this.capacity.isOver(enrollmentCount())) { - 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 index dfa148956..eafaaa950 100644 --- a/src/main/java/nextstep/courses/domain/Session.java +++ b/src/main/java/nextstep/courses/domain/Session.java @@ -1,46 +1,48 @@ package nextstep.courses.domain; -public abstract class Session { +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) { - this(0L, coverImage, period, SessionStatus.PREPARING, new 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) { - this(0L, coverImage, period, status, 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, Enrollments enrollments) { + 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(); - validatePaymentPolicy(payment); - validateCapacityPolicy(); + enrollmentPolicy.validate(payment, enrollmentCount()); enrollments.add(enrollment); } - protected void validateStatus() { + private void validateStatus() { if (!status.canEnroll()) { throw new IllegalStateException(String.format("모집중인 강의만 수강 신청이 가능합니다. (현재 상태: %s)", status)); } } - protected abstract void validatePaymentPolicy(Money payment); - - protected abstract void validateCapacityPolicy(); - public int enrollmentCount() { return enrollments.count(); } @@ -48,4 +50,8 @@ public int enrollmentCount() { public SessionStatus getStatus() { return this.status; } + + public SessionType getType() { + return enrollmentPolicy.getType(); + } } diff --git a/src/test/java/nextstep/courses/domain/PaidSessionTest.java b/src/test/java/nextstep/courses/domain/PaidSessionTest.java deleted file mode 100644 index 4321181d0..000000000 --- a/src/test/java/nextstep/courses/domain/PaidSessionTest.java +++ /dev/null @@ -1,78 +0,0 @@ -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 PaidSessionTest { - - 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 생성자_정상입력_생성성공() { - assertThat(new PaidSession(coverImage, period, 30, 50000).getStatus()).isEqualTo(SessionStatus.PREPARING); - } - - @Test - void enroll_정상입력_성공() { - PaidSession session = new PaidSession(coverImage, period, SessionStatus.RECRUITING, 3, 50000); - Enrollment enrollment = new Enrollment(1L, 1L, LocalDateTime.now()); - - session.enroll(enrollment, new Money(50000)); - - assertThat(session.enrollmentCount()).isEqualTo(1); - } - - @Test - void enroll_결제금액불일치_예외발생() { - PaidSession session = new PaidSession(coverImage, period, SessionStatus.RECRUITING, 3, 50000); - Enrollment enrollment = new Enrollment(1L, 1L, LocalDateTime.now()); - Money wrongPayment = new Money(30000); - - assertThatThrownBy(() -> session.enroll(enrollment, wrongPayment)) - .isInstanceOf(IllegalArgumentException.class) - .hasMessageContaining("결제 금액이 수강료와 일치하지 않습니다"); - } - - @Test - void enroll_인원초과_예외발생() { - PaidSession session = new PaidSession(coverImage, period, SessionStatus.RECRUITING, 2, 50000); - Money payment = new Money(50000); - - session.enroll(new Enrollment(1L, 1L, LocalDateTime.now()), payment); - session.enroll(new Enrollment(2L, 2L, LocalDateTime.now()), payment); - - assertThatThrownBy(() -> session.enroll(new Enrollment(3L, 3L, LocalDateTime.now()), payment)) - .isInstanceOf(IllegalStateException.class) - .hasMessageContaining("수강 인원이 초과"); - } - - @ParameterizedTest(name = "상태:{0}") - @EnumSource( - value = SessionStatus.class, - names = {"PREPARING", "CLOSED"}) - void enroll_모집중이아닐시_예외발생(SessionStatus status) { - PaidSession session = new PaidSession(coverImage, period, status, 2, 50000); - - 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/domain/FreeSessionTest.java b/src/test/java/nextstep/courses/domain/SessionTest.java similarity index 77% rename from src/test/java/nextstep/courses/domain/FreeSessionTest.java rename to src/test/java/nextstep/courses/domain/SessionTest.java index 6cc611b28..9cd1ae187 100644 --- a/src/test/java/nextstep/courses/domain/FreeSessionTest.java +++ b/src/test/java/nextstep/courses/domain/SessionTest.java @@ -12,8 +12,7 @@ import org.junit.jupiter.params.provider.EnumSource; @DisplayNameGeneration(DisplayNameGenerator.ReplaceUnderscores.class) -class FreeSessionTest { - +class SessionTest { private CoverImage coverImage; private SessionPeriod period; @@ -25,12 +24,15 @@ void setUp() { @Test void 생성자_정상입력_생성성공() { - assertThat(new FreeSession(coverImage, period).getStatus()).isEqualTo(SessionStatus.PREPARING); + 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 FreeSession(coverImage, period, SessionStatus.RECRUITING); + Session session = new Session(coverImage, period, SessionStatus.RECRUITING, new FreeEnrollmentPolicy()); Enrollment enrollment = new Enrollment(1L, 1L, LocalDateTime.now()); session.enroll(enrollment, Money.ZERO); @@ -43,7 +45,7 @@ void setUp() { value = SessionStatus.class, names = {"PREPARING", "CLOSED"}) void enroll_모집중이아닐시_예외발생(SessionStatus status) { - FreeSession session = new FreeSession(coverImage, period, status); + Session session = new Session(coverImage, period, status, new FreeEnrollmentPolicy()); Enrollment enrollment = new Enrollment(1L, 1L, LocalDateTime.now()); From a0cd14a716cab4b13c62e96716a9b81e0dbe86cc Mon Sep 17 00:00:00 2001 From: ghtjr410 Date: Thu, 11 Dec 2025 08:21:07 +0900 Subject: [PATCH 45/45] =?UTF-8?q?docs:=20=EC=A0=95=EC=B1=85=20=EA=B8=B0?= =?UTF-8?q?=EB=B0=98=20Session=20=EA=B5=AC=EC=A1=B0=EB=A1=9C=20=EB=AC=B8?= =?UTF-8?q?=EC=84=9C=20=EB=82=B4=EC=9A=A9=20=EC=97=85=EB=8D=B0=EC=9D=B4?= =?UTF-8?q?=ED=8A=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 02-lms-domain-model.md - Session을 추상 클래스 기반 → 정책 위임 방식으로 변경 - EnrollmentPolicy 중심 구조(템플릿 메서드) 설명 추가 - Free/Paid 정책의 역할 및 검증 규칙 명확히 정리 - Session 타입 조회가 Policy 반환값 기반임을 명시 --- docs/02-lms-domain-model.md | 36 +++++++++++++++++++++--------------- 1 file changed, 21 insertions(+), 15 deletions(-) diff --git a/docs/02-lms-domain-model.md b/docs/02-lms-domain-model.md index fd98f596a..a8543f3e3 100644 --- a/docs/02-lms-domain-model.md +++ b/docs/02-lms-domain-model.md @@ -43,24 +43,30 @@ - [x] 강의 추가 - [x] 강의 개수 조회 -#### Session (추상 클래스, 템플릿 메서드 패턴 적용) -- [x] 수강 신청 – 템플릿 메서드 방식 - - [x] `final` enroll(enrollment, payment) 정의 +#### Session +- [x] 수강 신청 - [x] 모집중 상태 검증 - - [x] 결제 정책 검증 – 하위에서 구현 (validatePaymentPolicy) - - [x] 정원 정책 검증 – 하위에서 구현 (validateCapacityPolicy) + - [x] Policy에 검증 위임 - [x] 수강 인원 조회 +- [x] 강의 타입 조회 (Policy에서 반환) -#### FreeSession -- [x] Session 상속 -- [x] 수강 신청 - - [x] 결제 정책 없음 → validatePaymentPolicy(): 빈 구현 - - [x] 정원 정책 없음 → validateCapacityPolicy(): 빈 구현 -#### PaidSession -- [x] Session 상속 -- [x] 수강 신청 - - [x] 결제 금액 검증 - - [x] 수강 인원 검증 +#### 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] 수강 신청 추가