Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
207 changes: 207 additions & 0 deletions .claude/resources/plans/PLAN-101.md

Large diffs are not rendered by default.

18 changes: 9 additions & 9 deletions .claude/spec/git-convention.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,15 +10,15 @@ description: 커밋, 브랜치, PR 등 Git 작업 시 적용되는 규칙

| type | 용도 | 이슈 라벨 |
|---|---|---|
| feat | 새로운 기능 | `🌼 Feat` |
| hotfix | 긴급 수정 | `🔥 HotFix` |
| fix | 버그 수정 | `🔨 Fix` |
| docs | 문서 변경 | `📚 Docs` |
| test | 테스트 추가/수정 | `🙆🏻‍♂️ Test` |
| cicd | CI/CD 설정 변경 | `🚦 CICD` |
| refactor | 리팩토링 | `🧑🏻‍💻 Refactor` |
| chore | 빌드, 설정 등 기타 | `⚡️ Chore` |
| analysis | 코드 동작 분석, 조사 | `🧪 Analysis` |
| feat | 새로운 기능 | `Feat` |
| hotfix | 긴급 수정 | `HotFix` |
| fix | 버그 수정 | `Fix` |
| docs | 문서 변경 | `Docs` |
| test | 테스트 추가/수정 | `Test` |
| cicd | CI/CD 설정 변경 | `CICD` |
| refactor | 리팩토링 | `Refactor` |
| chore | 빌드, 설정 등 기타 | `Chore` |
| analysis | 코드 동작 분석, 조사 | `Analysis` |

> `type`은 커밋 접두사(`{type}:`), 브랜치 접두사(`{type}/`), GitHub 이슈 라벨에 공통으로 쓰인다.
> 라벨 이름은 위 표기 그대로 써야 한다. 이모지 뒤 공백까지 일치해야 `gh issue create --label`이 통과한다.
Expand Down
3 changes: 2 additions & 1 deletion .claude/spec/service-policy/course.md
Original file line number Diff line number Diff line change
Expand Up @@ -82,7 +82,8 @@ description: 강의 조회와 검색, 정원, 시간표 충돌 판정, 과목
## 강의 검색

- 학수번호, 과목코드, 국문 강의명, 영문 강의명을 대상으로 검색한다
- 관련도가 높은 순으로 정렬한다
- 검색 결과는 **학년, 관련도, 학수번호 순**으로 정렬한다. 학년은 다른 조회와 같은 기준(코드 오름차순, 전학년이 앞)이고,
같은 학년 안에서는 검색어와의 관련도가 높은 순, 관련도가 같으면 학수번호 오름차순이다

## 정원

Expand Down
12 changes: 11 additions & 1 deletion .claude/spec/test-convention.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ description: 테스트 코드 작성 규칙 (모든 테스트는 통합 테스

# Test Convention

모든 서비스 테스트는 통합 테스트(`@IntegrationTest`)로 작성한다. Mockito 기반 단위 테스트는 쓰지 않는다.
모든 서비스 테스트는 통합 테스트로 작성한다. 기본은 `@IntegrationTest`(H2)다. Mockito 기반 단위 테스트는 쓰지 않는다.

## 네이밍 & 설정

Expand Down Expand Up @@ -36,6 +36,16 @@ assertThatThrownBy(() -> courseService.getMajorCourses(invalidMemberId))
.hasFieldOrPropertyWithValue("exceptionCode", MEMBER_NOT_FOUND);
```

## MySQL 전용 쿼리 테스트

FULLTEXT 등 H2가 실행하지 못하는 네이티브 쿼리는 `@MySqlIntegrationTest`(Testcontainers MySQL)로 검증한다.

- 별도 클래스로 분리하고 이름은 `{Class}{기능}Test`로 짓는다 (예: `CourseServiceSearchTest`)
- 트랜잭션 롤백 격리가 없다. InnoDB FULLTEXT는 커밋된 행만 검색하므로 저장이 그대로 커밋된다.
`@AfterEach`에서 `deleteAllInBatch()`로 직접 지워라
- Docker가 없는 환경에서는 skip된다. CI에서는 항상 실행된다
- 관련도를 검증할 때는 검색어와 무관한 행을 함께 넣어라. 모든 행이 검색어를 포함하면 idf가 0이라 관련도가 전부 같아진다

## Fixture

| 항목 | 규칙 |
Expand Down
3 changes: 3 additions & 0 deletions build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,9 @@ dependencies {

// Test
testImplementation 'org.springframework.boot:spring-boot-starter-test'
testImplementation 'org.springframework.boot:spring-boot-testcontainers'
testImplementation 'org.testcontainers:testcontainers-junit-jupiter'
testImplementation 'org.testcontainers:testcontainers-mysql'
testRuntimeOnly 'org.junit.platform:junit-platform-launcher'
implementation 'p6spy:p6spy:3.9.1'
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,9 @@ public interface CourseRepository extends JpaRepository<Course, Long> {
FROM courses c
WHERE MATCH(c.course_code, c.haksu_code, c.title_kr, c.title_en) AGAINST(:keyword IN BOOLEAN MODE)
AND c.status = 'ACTIVE'
ORDER BY MATCH(c.course_code, c.haksu_code, c.title_kr, c.title_en) AGAINST(:keyword IN BOOLEAN MODE)
ORDER BY c.grade_code,
MATCH(c.course_code, c.haksu_code, c.title_kr, c.title_en) AGAINST(:keyword IN BOOLEAN MODE) DESC,
c.haksu_code
""", nativeQuery = true)
List<Course> findByKeyword(@Param("keyword") final String keyword);

Expand Down
102 changes: 102 additions & 0 deletions src/test/java/uss/code/course/service/CourseServiceSearchTest.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
package uss.code.course.service;

import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Nested;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import uss.code.course.domain.Course;
import uss.code.course.dto.response.SearchedCourseResponse;
import uss.code.course.dto.response.SearchedCoursesResponse;
import uss.code.course.fixture.CourseFixture;
import uss.code.course.repository.CourseRepository;
import uss.code.global.infra.MySqlIntegrationTest;

import java.util.List;

import static org.assertj.core.api.Assertions.assertThat;
import static uss.code.course.domain.CourseGrade.ALL;
import static uss.code.course.domain.CourseGrade.FRESHMAN;
import static uss.code.course.domain.CourseGrade.SENIOR;

@MySqlIntegrationTest
class CourseServiceSearchTest {

@Autowired
private CourseService courseService;

@Autowired
private CourseRepository courseRepository;

@AfterEach
void tearDown() {
courseRepository.deleteAllInBatch();
}

@Nested
class 키워드_검색_정렬_테스트 {

private static final String KEYWORD = "정렬";

@BeforeEach
void setUp() {
final List<Course> courses = List.of(
CourseFixture.createCourseWithDetails("정렬과 정렬 응용과 정렬", "Advanced Sorting", "SRCH005", "SRCH005001", SENIOR),
CourseFixture.createCourseWithDetails("자료구조", "Data Structure", "NONE001", "NONE001001", ALL),
CourseFixture.createCourseWithDetails("정렬 기초", "Sorting Basics", "SRCH001", "SRCH001001", ALL),
CourseFixture.createCourseWithDetails("정렬 입문", "Sorting Introduction", "SRCH004", "SRCH004001", FRESHMAN),
CourseFixture.createCourseWithDetails("정렬과 정렬 응용", "Sorting Applications", "SRCH002", "SRCH002001", ALL),
CourseFixture.createCourseWithDetails("운영체제", "Operating System", "NONE002", "NONE002001", FRESHMAN),
CourseFixture.createCourseWithDetails("정렬 입문", "Sorting Introduction", "SRCH003", "SRCH003001", FRESHMAN)
);
courseRepository.saveAll(courses);
}

@Test
void 학년이_관련도보다_먼저_정렬된다() {
//when
final SearchedCoursesResponse response = courseService.searchCourses(KEYWORD);

//then
assertThat(response.searchedCourseResponses())
.extracting(SearchedCourseResponse::grade)
.containsExactly("전학년", "전학년", "1학년", "1학년", "4학년");
}

@Test
void 같은_학년_안에서는_관련도가_높은_강의가_먼저_온다() {
//when
final SearchedCoursesResponse response = courseService.searchCourses(KEYWORD);

//then
final List<SearchedCourseResponse> searchedCourses = response.searchedCourseResponses();
assertThat(searchedCourses.subList(0, 2))
.extracting(SearchedCourseResponse::haksuCode)
.containsExactly("SRCH002001", "SRCH001001");
}

@Test
void 관련도가_같으면_학수번호_순으로_정렬된다() {
//when
final SearchedCoursesResponse response = courseService.searchCourses(KEYWORD);

//then
final List<SearchedCourseResponse> searchedCourses = response.searchedCourseResponses();
assertThat(searchedCourses.subList(2, 4))
.extracting(SearchedCourseResponse::haksuCode)
.containsExactly("SRCH003001", "SRCH004001");
}

@Test
void 검색어와_무관한_강의는_결과에_포함되지_않는다() {
//when
final SearchedCoursesResponse response = courseService.searchCourses(KEYWORD);

//then
assertThat(response.searchedCourseResponses())
.hasSize(5)
.extracting(SearchedCourseResponse::haksuCode)
.doesNotContain("NONE001001", "NONE002001");
}
}
}
31 changes: 31 additions & 0 deletions src/test/java/uss/code/global/infra/MySqlContainerConfig.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
package uss.code.global.infra;

import org.springframework.boot.test.context.TestConfiguration;
import org.springframework.context.annotation.Bean;
import org.springframework.test.context.DynamicPropertyRegistrar;
import org.testcontainers.mysql.MySQLContainer;

@TestConfiguration(proxyBeanMethods = false)
public class MySqlContainerConfig {

private static final String MYSQL_IMAGE = "mysql:8.0";

@Bean
MySQLContainer mysqlContainer() {
return new MySQLContainer(MYSQL_IMAGE);
}

/**
* DataSourceConfig가 spring.datasource 프로퍼티로 DataSource를 직접 만들어 @ServiceConnection이 끼어들지 못한다.
* 컨테이너 접속 정보를 같은 프로퍼티에 직접 주입한다.
*/
@Bean
DynamicPropertyRegistrar mysqlPropertyRegistrar(final MySQLContainer mysqlContainer) {
return registry -> {
registry.add("spring.datasource.url", mysqlContainer::getJdbcUrl);
registry.add("spring.datasource.username", mysqlContainer::getUsername);
registry.add("spring.datasource.password", mysqlContainer::getPassword);
registry.add("spring.datasource.driver-class-name", mysqlContainer::getDriverClassName);
};
}
}
25 changes: 25 additions & 0 deletions src/test/java/uss/code/global/infra/MySqlIntegrationTest.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
package uss.code.global.infra;

import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.context.annotation.Import;
import org.springframework.test.context.TestPropertySource;
import org.testcontainers.junit.jupiter.Testcontainers;

import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;

@Target(ElementType.TYPE)
@Retention(RetentionPolicy.RUNTIME)
@Testcontainers(disabledWithoutDocker = true)
@SpringBootTest
@Import(MySqlContainerConfig.class)
@TestPropertySource(properties = {
"spring.jpa.hibernate.ddl-auto=none",
"spring.jpa.properties.hibernate.dialect=org.hibernate.dialect.MySQLDialect",
"spring.flyway.enabled=true",
"spring.flyway.locations=classpath:database/migration"
})
public @interface MySqlIntegrationTest {
}
Loading