From ebf9957492871144c5c4983b659e6e2bf977a952 Mon Sep 17 00:00:00 2001 From: yeobi Date: Fri, 24 Apr 2026 03:04:56 +0900 Subject: [PATCH 1/4] =?UTF-8?q?[Feat]=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20?= =?UTF-8?q?=EC=BC=80=EC=9D=B4=EC=8A=A4=20=EC=9E=90=EB=8F=99=20=EC=83=9D?= =?UTF-8?q?=EC=84=B1=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 --- .gitignore | 6 + CLAUDE.md | 47 ++++ README.md | 21 ++ build.gradle | 15 ++ scripts/ai-test.sh | 332 ++++++++++++++++++++++++ src/test/resources/application-test.yml | 79 ++++++ 6 files changed, 500 insertions(+) create mode 100755 scripts/ai-test.sh create mode 100644 src/test/resources/application-test.yml diff --git a/.gitignore b/.gitignore index 6fd5096..1393545 100644 --- a/.gitignore +++ b/.gitignore @@ -147,3 +147,9 @@ replay_pid* .env !gradle/wrapper/gradle-wrapper.jar +# AI Test Orchestrator +.ai-test/last-plan.md + +# Claude Code 로컬 설정 +.claude/ + diff --git a/CLAUDE.md b/CLAUDE.md index f45bc43..696410a 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -53,3 +53,50 @@ src/main/java/graduation/project/DoDutch_server/ - JPA: N+1 쿼리, cascade/fetch 타입, orphanRemoval 설정 - API: ResponseDTO 패턴 준수, HTTP 상태 코드 적절성 - Null safety 및 validation 어노테이션 활용 + +## 테스트 작성 규칙 + +### 테스트 스택 +- JUnit 5 + Mockito + AssertJ + Spring Boot Test + +### 파일 위치 규칙 +- 단위 테스트: `*Test.java`, 통합 테스트: `*IT.java` +- 패키지 구조는 `src/main/java`와 미러링 +- 소스 패키지명 그대로 사용: `graduation.project.DoDutch_server.*` + +### 네이밍 +- `@DisplayName`에 한글 사용 (예: `"여행 생성 시 시작일이 종료일 이후면 예외 발생"`) +- 메서드명: `should_기대동작_when_조건` 패턴 + +### 구조 +- Given-When-Then (Arrange-Act-Assert) +- `@BeforeEach`로 공통 셋업, 각 테스트 독립성 확보 + +### Spring 테스트 슬라이스 기준 + +| 대상 | 어노테이션 | 비고 | +|------|-----------|------| +| Controller | `@WebMvcTest` | MockMvc 사용, Security 설정 주의 | +| Repository | `@DataJpaTest` | H2 인메모리 DB | +| Service | `@ExtendWith(MockitoExtension.class)` | Spring 컨텍스트 로드 금지 | +| 통합 테스트 | `@SpringBootTest` | 최소한으로 사용 | + +### 프로파일 규칙 +- 모든 `@SpringBootTest`, `@DataJpaTest`, `@WebMvcTest` 클래스는 반드시 `@ActiveProfiles("test")` 추가 +- `application-test.yml`이 활성화되지 않으면 환경변수 누락으로 컨텍스트 로딩 실패 + +### Mocking 규칙 +- BDDMockito 스타일: `given(...).willReturn(...)` +- 외부 의존성만 목킹 (Repository, 외부 API 클라이언트) +- 도메인 객체(Entity, DTO)는 실제 인스턴스 사용 +- 외부 API(카카오, 네이버 CLOVA, OpenAI, AWS S3, KakaoPay)는 반드시 목킹 + +### 금지 사항 +- `@Autowired` 필드 주입 (→ 생성자 주입 또는 `@Mock`/`@InjectMocks`) +- `Thread.sleep()` (→ 비동기 대기 필요 시 Awaitility 도입) +- 테스트 통과시키려고 프로덕션 코드 수정 +- 허술한 assertion (`isNotNull`만 사용 등) +- 테스트에서 `@Transactional` 사용 (테스트 격리 오염) + +### 커버리지 목표 +- Line 80%, Branch 70% diff --git a/README.md b/README.md index 2c78684..c44585c 100644 --- a/README.md +++ b/README.md @@ -17,3 +17,24 @@ commit message는 `[Type] 작성 내용` 으로 통일하기 ### 📋 Issue 및 Branch 생성 Rule Issue로 코드 작성 관련 설명 작성 후, Issue Branch 생성하고 작업하기 참고 자료 🔗 https://codesyun.tistory.com/entry/Git-Issue-%EB%B0%8F-Issue-Branch-%EC%83%9D%EC%84%B1%ED%95%98%EC%97%AC-%ED%94%84%EB%A1%9C%EC%A0%9D%ED%8A%B8-%EA%B4%80%EB%A6%AC%ED%95%98%EA%B8%B0 + +### 🧪 AI 테스트 자동 생성 + +Claude Code를 활용한 AI 기반 테스트 자동 생성 도구입니다. + +**사전 조건** +- [Claude Code CLI](https://docs.anthropic.com/en/docs/claude-code) 설치 필요 +- Anthropic API 키 설정 필요 + +**실행 방법** +```bash +./gradlew aiTest +``` + +**동작 과정** +1. 테스트 대상 선택 (파일 경로 / 자연어 설명 / git diff) +2. AI가 테스트 계획서 생성 +3. 사용자가 계획 검토/승인 +4. AI가 테스트 코드 생성 및 실행/검증 + +**참고**: 개인 API 비용이 발생합니다. 계획 생성 $0.50, 테스트 생성 $3.00 예산 제한이 설정되어 있습니다. diff --git a/build.gradle b/build.gradle index eb2e76e..a527daa 100644 --- a/build.gradle +++ b/build.gradle @@ -31,6 +31,7 @@ dependencies { runtimeOnly 'com.mysql:mysql-connector-j' annotationProcessor 'org.projectlombok:lombok' testImplementation 'org.springframework.boot:spring-boot-starter-test' + testImplementation 'com.h2database:h2' testRuntimeOnly 'org.junit.platform:junit-platform-launcher' implementation 'org.springframework.boot:spring-boot-starter-security' implementation 'org.springframework.boot:spring-boot-starter-webflux' @@ -50,3 +51,17 @@ dependencies { tasks.named('test') { useJUnitPlatform() } + +tasks.register('aiTest', Exec) { + group = 'verification' + description = 'AI 기반 테스트 생성 및 실행' + workingDir = project.projectDir + commandLine 'bash', 'scripts/ai-test.sh' + standardInput = System.in + // Gradle 데몬은 stdin을 제대로 전달하지 못하므로 --no-daemon 필요 + doFirst { + if (gradle.startParameter.noDaemon == false) { + logger.warn('⚠️ 대화형 입력을 위해 ./gradlew aiTest --no-daemon 으로 실행하세요.') + } + } +} diff --git a/scripts/ai-test.sh b/scripts/ai-test.sh new file mode 100755 index 0000000..6b5bb39 --- /dev/null +++ b/scripts/ai-test.sh @@ -0,0 +1,332 @@ +#!/usr/bin/env bash +set -euo pipefail + +# ── 설정 ────────────────────────────── +readonly MODEL="sonnet" +readonly PLAN_FILE=".ai-test/last-plan.md" +readonly MAIN_SRC="src/main/java" +readonly TEST_SRC="src/test/java" + +readonly ALLOWED_TOOLS_PLAN=(Read Glob Grep Write) +readonly ALLOWED_TOOLS_TEST=(Read Glob Grep Write Edit + "Bash(./gradlew:*)" "Bash(find:*)" "Bash(ls:*)") +readonly BUDGET_PLAN="0.50" +readonly BUDGET_TEST="3.00" + +# ── 색상 코드 ────────────────────────── +readonly GREEN='\033[0;32m' +readonly YELLOW='\033[1;33m' +readonly RED='\033[0;31m' +readonly CYAN='\033[0;36m' +readonly BOLD='\033[1m' +readonly RESET='\033[0m' + +# ── 유틸 함수 ────────────────────────── +info() { echo -e "${CYAN}[INFO]${RESET} $*"; } +warn() { echo -e "${YELLOW}[WARN]${RESET} $*"; } +error() { echo -e "${RED}[ERROR]${RESET} $*" >&2; } +success() { echo -e "${GREEN}[OK]${RESET} $*"; } + +# ── 프로젝트 루트로 이동 ────────────────── +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +cd "${SCRIPT_DIR}/.." + +# ── 초기 검증 ────────────────────────── +if ! command -v claude &>/dev/null; then + error "Claude Code CLI가 설치되어 있지 않습니다." + echo "설치: npm install -g @anthropic-ai/claude-code" + exit 1 +fi + +mkdir -p .ai-test + +# ── 1단계: 테스트 대상 선택 ────────────── +echo "" +echo -e "${BOLD}🧪 AI 테스트 오케스트레이터${RESET}" +echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" +echo "" +echo "테스트 대상을 선택하세요:" +echo " 1) 파일/도메인 선택" +echo " 2) 자연어로 대상 설명" +echo " 3) git diff 기반 (변경된 파일)" +echo "" +echo -n "선택 (1/2/3): " +read -r choice + +# ── 파일 선택 헬퍼 ────────────────────── +# 파일 목록에서 번호로 선택하는 함수 +select_from_list() { + local -a files=() + while IFS= read -r line; do + [[ -n "$line" ]] && files+=("$line") + done <<< "$1" + + if [[ ${#files[@]} -eq 0 ]]; then + return 1 + fi + + if [[ ${#files[@]} -eq 1 ]]; then + echo "${files[0]}" + return 0 + fi + + echo "" >&2 + local i=1 + for f in "${files[@]}"; do + echo -e " ${CYAN}${i})${RESET} $f" >&2 + ((i++)) + done + echo "" >&2 + + echo -n "번호 선택 (1-${#files[@]}, 또는 a=전체): " >&2 + read -r num + + if [[ "$num" == "a" || "$num" == "A" ]]; then + printf '%s\n' "${files[@]}" + return 0 + fi + + if [[ "$num" =~ ^[0-9]+$ ]] && (( num >= 1 && num <= ${#files[@]} )); then + echo "${files[$((num-1))]}" + return 0 + fi + + echo "" >&2 + error "잘못된 선택입니다." + return 1 +} + +TARGET="" +case "$choice" in + 1) + echo "" + echo "입력 방법:" + echo " - 도메인명 (예: trip, expense, auth)" + echo " - 파일 경로 (예: src/main/java/.../TripService.java)" + if command -v fzf &>/dev/null; then + echo " - 빈 엔터 → fzf 파일 탐색기" + fi + echo "" + echo -n "입력: " + read -r input + + # 빈 입력 + fzf 설치 시 → fzf 탐색기 + if [[ -z "$input" ]]; then + if command -v fzf &>/dev/null; then + info "fzf 파일 탐색기를 엽니다..." + filepath=$(find "$MAIN_SRC" -name '*.java' -type f | fzf --height=20 --prompt="테스트 대상 선택: ") + if [[ -z "$filepath" ]]; then + info "선택이 취소되었습니다." + exit 0 + fi + TARGET="파일: $filepath" + else + error "입력이 비어 있습니다. 도메인명 또는 파일 경로를 입력하세요." + exit 1 + fi + + # 파일 경로인 경우 (/ 또는 .java 포함) + elif [[ -f "$input" ]]; then + TARGET="파일: $input" + + # 도메인명으로 검색 + else + info "'${input}'으로 파일을 검색합니다..." + found=$(find "$MAIN_SRC" -path "*/${input}/*" -name '*.java' -type f 2>/dev/null | sort) || true + + if [[ -z "$found" ]]; then + # 파일명 부분 매칭 시도 + found=$(find "$MAIN_SRC" -name "*${input}*" -name '*.java' -type f 2>/dev/null | sort) || true + fi + + if [[ -z "$found" ]]; then + error "'${input}'에 해당하는 파일을 찾을 수 없습니다." + exit 1 + fi + + selected=$(select_from_list "$found") || exit 1 + + # 여러 줄이면 복수 파일 + file_count=$(echo "$selected" | wc -l | tr -d ' ') + if [[ "$file_count" -eq 1 ]]; then + info "선택: $selected" + TARGET="파일: $selected" + else + echo "" + info "선택된 파일 ${file_count}개:" + echo "$selected" | while read -r f; do echo " - $f"; done + TARGET="변경된 파일들:\n$selected" + fi + fi + ;; + 2) + echo -n "테스트 대상 설명: " + read -r description + TARGET="설명: $description" + ;; + 3) + info "git diff에서 변경된 Java 파일을 수집합니다..." + # staged + unstaged 변경 + diff_files=$(git diff --name-only HEAD --diff-filter=ACMR 2>/dev/null || true) + staged_files=$(git diff --name-only --cached --diff-filter=ACMR 2>/dev/null || true) + all_files=$(echo -e "${diff_files}\n${staged_files}" | sort -u) + + # 비어있으면 HEAD~1 폴백 + if [[ -z "$all_files" || "$all_files" == $'\n' ]]; then + warn "현재 변경사항이 없습니다. HEAD~1 대비 diff를 사용합니다." + all_files=$(git diff --name-only HEAD~1 --diff-filter=ACMR 2>/dev/null || true) + fi + + # .java만, src/test 제외 + java_files=$(echo "$all_files" | grep '\.java$' | grep -v 'src/test' || true) + + if [[ -z "$java_files" ]]; then + error "변경된 Java 소스 파일이 없습니다." + exit 1 + fi + + echo "" + info "변경된 파일:" + echo "$java_files" | while read -r f; do echo " - $f"; done + echo "" + TARGET="변경된 파일들:\n$java_files" + ;; + *) + error "잘못된 선택입니다." + exit 1 + ;; +esac + +info "대상: $TARGET" +echo "" + +# ── 2단계: 테스트 계획 생성 ────────────── +info "테스트 계획을 생성합니다..." +echo "" + +PLAN_PROMPT="프로젝트 루트의 CLAUDE.md를 읽고 테스트 작성 규칙을 숙지하라. + +대상: ${TARGET} + +대상 소스 코드를 분석하고, .ai-test/last-plan.md에 테스트 계획서를 작성하라. + +계획서에 포함할 내용: +1. 대상 클래스/메서드 분석 요약 +2. 테스트 슬라이스 선택 이유 (단위 테스트 vs 통합 테스트) +3. 테스트 케이스 목록 (Given-When-Then 형식) +4. 목킹 대상 목록 +5. 예상 테스트 파일 경로 + +⚠️ 테스트 코드는 절대 작성하지 말 것. 계획서만 작성하라. +⚠️ 작업을 완료한 뒤 추가 작업 없이 즉시 종료하라." + +claude -p "$PLAN_PROMPT" \ + --output-format text \ + --model "$MODEL" \ + --max-budget-usd "$BUDGET_PLAN" \ + --allowed-tools "${ALLOWED_TOOLS_PLAN[@]}" + +if [[ ! -f "$PLAN_FILE" ]]; then + error "계획서 생성에 실패했습니다." + exit 1 +fi + +success "계획서가 생성되었습니다: $PLAN_FILE" +echo "" + +# ── 3단계: 사용자 검증 루프 ────────────── +while true; do + echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" + echo -e "${BOLD}📋 테스트 계획서${RESET}" + echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" + cat "$PLAN_FILE" + echo "" + echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" + echo "" + echo " y) 계획 승인 → 테스트 생성 진행" + echo " e) 피드백 입력 → 계획 재생성" + echo " v) 에디터로 계획 직접 편집" + echo " n) 종료" + echo "" + echo -n "선택 (y/e/v/n): " + read -r action + + case "$action" in + y|Y) + success "계획이 승인되었습니다. 테스트를 생성합니다." + break + ;; + e|E) + echo -n "피드백: " + read -r feedback + info "피드백을 반영하여 계획을 재생성합니다..." + echo "" + + FEEDBACK_PROMPT="프로젝트 루트의 CLAUDE.md를 읽고 테스트 작성 규칙을 숙지하라. + +기존 테스트 계획서(.ai-test/last-plan.md)를 읽고, 다음 피드백을 반영하여 수정하라: + +피드백: ${feedback} + +수정된 계획서를 .ai-test/last-plan.md에 덮어쓰기하라. +⚠️ 테스트 코드는 절대 작성하지 말 것. 계획서만 수정하라. +⚠️ 작업을 완료한 뒤 추가 작업 없이 즉시 종료하라." + + claude -p "$FEEDBACK_PROMPT" \ + --output-format text \ + --model "$MODEL" \ + --max-budget-usd "$BUDGET_PLAN" \ + --allowed-tools "${ALLOWED_TOOLS_PLAN[@]}" + + success "계획서가 재생성되었습니다." + echo "" + ;; + v|V) + ${EDITOR:-vi} "$PLAN_FILE" + success "계획서가 편집되었습니다." + echo "" + ;; + n|N) + info "종료합니다." + exit 0 + ;; + *) + warn "잘못된 입력입니다. y/e/v/n 중 선택하세요." + ;; + esac +done + +echo "" + +# ── 4단계: 테스트 생성 및 실행 ────────────── +info "테스트를 생성하고 실행합니다..." +echo "" + +GEN_PROMPT="프로젝트 루트의 CLAUDE.md를 읽고 테스트 작성 규칙을 엄격히 준수하라. + +.ai-test/last-plan.md의 계획서를 읽고, 계획에 따라 테스트를 작성하라. + +규칙: +- 각 테스트 파일 작성 후 ./gradlew test --tests '테스트클래스명'으로 실행하여 통과 확인 +- 테스트가 실패하면 원인을 분석하고 수정하라 — 최대 5회 반복 +- 5회 반복 후에도 실패하면 실패 원인을 보고하고 종료 +- 프로덕션 코드(src/main) 절대 수정 금지 +- assertion이 충분히 구체적인지 스스로 검증 +- @ActiveProfiles(\"test\") 반드시 추가 +⚠️ 작업을 완료한 뒤 추가 작업 없이 즉시 종료하라." + +claude -p "$GEN_PROMPT" \ + --output-format text \ + --model "$MODEL" \ + --max-budget-usd "$BUDGET_TEST" \ + --allowed-tools "${ALLOWED_TOOLS_TEST[@]}" + +echo "" + +# ── 결과 출력 ────────────────────────── +echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" +echo -e "${BOLD}📊 생성된 테스트 파일${RESET}" +echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" +git status --short "$TEST_SRC/" 2>/dev/null || echo "(변경사항 없음)" +echo "" +success "AI 테스트 오케스트레이터 완료!" diff --git a/src/test/resources/application-test.yml b/src/test/resources/application-test.yml new file mode 100644 index 0000000..ec19cff --- /dev/null +++ b/src/test/resources/application-test.yml @@ -0,0 +1,79 @@ +# 테스트 전용 설정 (H2 인메모리 DB, 환경변수 스텁) +spring: + config: + import: "" + + datasource: + url: jdbc:h2:mem:testdb;DB_CLOSE_DELAY=-1;DB_CLOSE_ON_EXIT=FALSE;MODE=MYSQL + username: sa + password: + driver-class-name: org.h2.Driver + + jpa: + database-platform: org.hibernate.dialect.H2Dialect + hibernate: + ddl-auto: create-drop + show-sql: true + properties: + hibernate: + format_sql: true + + servlet: + multipart: + max-request-size: 50MB + max-file-size: 50MB + enabled: true + +# Kakao OAuth2 Configuration (테스트 더미) +kakao: + client-id: test-kakao-client-id + redirect-url: http://localhost:8080/api/users/login/oauth/kakao + +# JWT Configuration (256비트 이상 테스트 키) +jwt: + secret-key: TestSecretKeyForJwtTokenThatIsLongEnoughToMeetTheMinimumRequirementOf256Bits1234567890 + +server: + port: 8080 + +# KakaoPay Configuration (테스트 더미) +kakaopay: + base-url: http://localhost:9999 + cid: TC0ONETIME + secret-key: test-kakaopay-secret-key + approval-url: http://localhost:8080/api/kakaopay/approve-callback + premium-approval-url: http://localhost:8080/auth/approve-callback + cancel-url: http://localhost:8080/api/kakaopay/cancel-callback + fail-url: http://localhost:8080/api/kakaopay/fail-callback + +# OpenAI Configuration (테스트 더미) +openai: + model: gpt-4.1 + api: + key: test-openai-api-key + url: http://localhost:9999/v1/chat/completions + +# Flask Configuration (테스트 더미) +flask: + url: http://localhost:9999/predict + +# AWS S3 (테스트 더미) +cloud: + aws: + credentials: + access-key: test-aws-access-key + secret-key: test-aws-secret-key + region: + static: ap-northeast-2 + s3: + bucket: test-bucket + path: + trip-main: trip-images + expense-main: expense-images + +# Naver OCR (테스트 더미) +naver: + service: + base-url: http://localhost:9999 + endpoint: /test-ocr + secretKey: test-naver-secret-key From dcce8d41951e117d5c90fbf2ed77f979b81a5704 Mon Sep 17 00:00:00 2001 From: yeobi Date: Fri, 24 Apr 2026 15:29:11 +0900 Subject: [PATCH 2/4] =?UTF-8?q?[Feat]=20AI=20=EA=B8=B0=EB=B0=98=20?= =?UTF-8?q?=ED=85=8C=EC=8A=A4=ED=8A=B8=20=EC=9E=90=EB=8F=99=20=EC=83=9D?= =?UTF-8?q?=EC=84=B1=20=EC=98=A4=EC=BC=80=EC=8A=A4=ED=8A=B8=EB=A0=88?= =?UTF-8?q?=EC=9D=B4=ED=84=B0=20=EA=B5=AC=EC=B6=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CLAUDE.md | 6 + .../DoDutchServerApplicationTests.java | 2 +- .../trip/converter/TripConverterTest.java | 421 ++++++++++++++++++ 3 files changed, 428 insertions(+), 1 deletion(-) create mode 100644 src/test/java/graduation/project/dodutch_server/domain/trip/converter/TripConverterTest.java diff --git a/CLAUDE.md b/CLAUDE.md index 696410a..4876827 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -64,6 +64,12 @@ src/main/java/graduation/project/DoDutch_server/ - 패키지 구조는 `src/main/java`와 미러링 - 소스 패키지명 그대로 사용: `graduation.project.DoDutch_server.*` +### ⚠️ macOS 패키지명 주의사항 +- macOS는 case-insensitive 파일시스템이므로 `DoDutch_server`와 `dodutch_server`가 같은 디렉토리로 취급됨 +- 테스트 패키지는 반드시 `graduation.project.DoDutch_server`로 작성 (대문자 D 주의) +- 임의의 패키지명(예: `trip.converter`)으로 우회하지 말 것 — 반드시 프로덕션 코드와 동일한 패키지 사용 +- 테스트 파일 생성 후 `package` 선언이 디렉토리 경로와 정확히 일치하는지 확인 + ### 네이밍 - `@DisplayName`에 한글 사용 (예: `"여행 생성 시 시작일이 종료일 이후면 예외 발생"`) - 메서드명: `should_기대동작_when_조건` 패턴 diff --git a/src/test/java/graduation/project/dodutch_server/DoDutchServerApplicationTests.java b/src/test/java/graduation/project/dodutch_server/DoDutchServerApplicationTests.java index 22579fb..dc748df 100644 --- a/src/test/java/graduation/project/dodutch_server/DoDutchServerApplicationTests.java +++ b/src/test/java/graduation/project/dodutch_server/DoDutchServerApplicationTests.java @@ -1,4 +1,4 @@ -package graduation.project.dodutch_server; +package graduation.project.DoDutch_server; import org.junit.jupiter.api.Test; import org.springframework.boot.test.context.SpringBootTest; diff --git a/src/test/java/graduation/project/dodutch_server/domain/trip/converter/TripConverterTest.java b/src/test/java/graduation/project/dodutch_server/domain/trip/converter/TripConverterTest.java new file mode 100644 index 0000000..469cf39 --- /dev/null +++ b/src/test/java/graduation/project/dodutch_server/domain/trip/converter/TripConverterTest.java @@ -0,0 +1,421 @@ +package graduation.project.DoDutch_server.domain.trip.converter; + +import graduation.project.DoDutch_server.domain.expense.entity.Expense; +import graduation.project.DoDutch_server.domain.member.entity.Member; +import graduation.project.DoDutch_server.domain.photo.entity.Photo; +import graduation.project.DoDutch_server.domain.photo.repository.PhotoRepository; +import graduation.project.DoDutch_server.domain.trip.dto.Request.TripRequestDTO; +import graduation.project.DoDutch_server.domain.trip.dto.Response.TripDetailResponseDTO; +import graduation.project.DoDutch_server.domain.trip.dto.Response.TripExpenseDTO; +import graduation.project.DoDutch_server.domain.trip.dto.Response.TripMemberDTO; +import graduation.project.DoDutch_server.domain.trip.dto.Response.TripResponseDTO; +import graduation.project.DoDutch_server.domain.trip.entity.Trip; +import graduation.project.DoDutch_server.domain.trip.entity.TripMember; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.time.LocalDate; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; + +@ExtendWith(MockitoExtension.class) +@DisplayName("TripConverter 단위 테스트") +class TripConverterTest { + + @Mock + private PhotoRepository photoRepository; + + // ─── TC-1 ─────────────────────────────────────────────────────────────── + + @Test + @DisplayName("정상 요청 → Trip 엔티티 변환") + void should_returnTrip_when_validRequest() { + TripRequestDTO dto = TripRequestDTO.builder() + .tripName("도쿄여행") + .place("일본") + .startDate(LocalDate.of(2026, 5, 1)) + .endDate(LocalDate.of(2026, 5, 7)) + .budget(500000) + .build(); + + Trip trip = TripConverter.toEntity(dto, "ABC123", "https://example.com/img.jpg"); + + assertThat(trip.getName()).isEqualTo("도쿄여행"); + assertThat(trip.getPlace()).isEqualTo("일본"); + assertThat(trip.getStartDate()).isEqualTo(LocalDate.of(2026, 5, 1)); + assertThat(trip.getEndDate()).isEqualTo(LocalDate.of(2026, 5, 7)); + assertThat(trip.getBudget()).isEqualTo(500000); + assertThat(trip.getJoinCode()).isEqualTo("ABC123"); + assertThat(trip.getTripImageUrl()).isEqualTo("https://example.com/img.jpg"); + assertThat(trip.getTotalCost()).isEqualTo(0); + } + + // ─── TC-2 ─────────────────────────────────────────────────────────────── + + @Test + @DisplayName("tripImageUrl이 null → Trip 엔티티 변환 (이미지 없는 경우)") + void should_returnTrip_when_tripImageUrlIsNull() { + TripRequestDTO dto = TripRequestDTO.builder() + .tripName("도쿄여행") + .place("일본") + .startDate(LocalDate.of(2026, 5, 1)) + .endDate(LocalDate.of(2026, 5, 7)) + .budget(500000) + .build(); + + Trip trip = TripConverter.toEntity(dto, "XYZ", null); + + assertThat(trip.getTripImageUrl()).isNull(); + assertThat(trip.getName()).isEqualTo("도쿄여행"); + assertThat(trip.getJoinCode()).isEqualTo("XYZ"); + assertThat(trip.getTotalCost()).isEqualTo(0); + } + + // ─── TC-3 ─────────────────────────────────────────────────────────────── + + @Test + @DisplayName("Trip 엔티티 → TripResponseDTO 변환") + void should_returnTripResponseDTO_when_tripEntity() { + Trip trip = Trip.builder() + .name("제주여행") + .place("제주도") + .startDate(LocalDate.of(2026, 6, 1)) + .endDate(LocalDate.of(2026, 6, 5)) + .joinCode("JJ999") + .tripImageUrl("https://s3/jeju.jpg") + .tripMembers(new ArrayList<>()) + .expenses(new ArrayList<>()) + .build(); + + TripResponseDTO dto = TripConverter.toDto(trip); + + assertThat(dto.getName()).isEqualTo("제주여행"); + assertThat(dto.getPlace()).isEqualTo("제주도"); + assertThat(dto.getJoinCode()).isEqualTo("JJ999"); + assertThat(dto.getTripImageUrl()).isEqualTo("https://s3/jeju.jpg"); + assertThat(dto.getStartDate()).isEqualTo(LocalDate.of(2026, 6, 1)); + assertThat(dto.getEndDate()).isEqualTo(LocalDate.of(2026, 6, 5)); + } + + // ─── TC-4 ─────────────────────────────────────────────────────────────── + + @Test + @DisplayName("모든 멤버가 유효한 경우 toMemberList1") + void should_returnMemberList1_when_allMembersValid() { + Member member1 = Member.builder().id(1L).nickname("홍길동").build(); + Member member2 = Member.builder().id(2L).nickname("김영희").build(); + + TripMember tm1 = TripMember.builder().member(member1).build(); + TripMember tm2 = TripMember.builder().member(member2).build(); + + List result = TripConverter.toMemberList1(List.of(tm1, tm2)); + + assertThat(result).hasSize(2); + assertThat(result.get(0).getMemberId()).isEqualTo(1L); + assertThat(result.get(0).getNickname()).isEqualTo("홍길동"); + assertThat(result.get(1).getMemberId()).isEqualTo(2L); + assertThat(result.get(1).getNickname()).isEqualTo("김영희"); + } + + // ─── TC-5 ─────────────────────────────────────────────────────────────── + + @Test + @DisplayName("member가 null인 탈퇴 멤버 포함 시 toMemberList1 — 알수없음 처리") + void should_returnUnknownMember_when_memberIsNull_inMemberList1() { + TripMember tmNull = TripMember.builder().member(null).build(); + Member member3 = Member.builder().id(3L).nickname("이철수").build(); + TripMember tm3 = TripMember.builder().member(member3).build(); + + List result = TripConverter.toMemberList1(List.of(tmNull, tm3)); + + assertThat(result).hasSize(2); + assertThat(result.get(0).getMemberId()).isEqualTo(-1L); + assertThat(result.get(0).getNickname()).isEqualTo("알수없음"); + assertThat(result.get(1).getMemberId()).isEqualTo(3L); + assertThat(result.get(1).getNickname()).isEqualTo("이철수"); + } + + // ─── TC-6 ─────────────────────────────────────────────────────────────── + + @Test + @DisplayName("빈 리스트 입력 시 toMemberList1 → 빈 리스트 반환") + void should_returnEmptyList_when_emptyInput_toMemberList1() { + List result = TripConverter.toMemberList1(new ArrayList<>()); + + assertThat(result).isEmpty(); + } + + // ─── TC-7 ─────────────────────────────────────────────────────────────── + + @Test + @DisplayName("모든 멤버가 유효한 경우 toMemberList2 — nickname은 항상 null") + void should_returnMemberList2WithNullNickname_when_allMembersValid() { + Member member1 = Member.builder().id(1L).nickname("홍길동").build(); + Member member2 = Member.builder().id(2L).nickname("김영희").build(); + + TripMember tm1 = TripMember.builder().member(member1).build(); + TripMember tm2 = TripMember.builder().member(member2).build(); + + List result = TripConverter.toMemberList2(List.of(tm1, tm2)); + + assertThat(result).hasSize(2); + assertThat(result.get(0).getMemberId()).isEqualTo(1L); + assertThat(result.get(0).getNickname()).isNull(); + assertThat(result.get(1).getMemberId()).isEqualTo(2L); + assertThat(result.get(1).getNickname()).isNull(); + } + + // ─── TC-8 ─────────────────────────────────────────────────────────────── + + @Test + @DisplayName("member가 null인 탈퇴 멤버 포함 시 toMemberList2 — id=-1, nickname=null") + void should_returnUnknownMember_when_memberIsNull_inMemberList2() { + TripMember tmNull = TripMember.builder().member(null).build(); + + List result = TripConverter.toMemberList2(List.of(tmNull)); + + assertThat(result).hasSize(1); + assertThat(result.get(0).getMemberId()).isEqualTo(-1L); + assertThat(result.get(0).getNickname()).isNull(); + } + + // ─── TC-9 ─────────────────────────────────────────────────────────────── + + @Test + @DisplayName("Expense 목록 변환 — 각 Expense에 사진 있음") + void should_returnExpenseDtoList_when_expensesHavePhotos() { + Expense expense1 = Expense.builder() + .id(1L) + .title("식사") + .amount(50000) + .memo("저녁 식사") + .expenseDate(LocalDate.of(2026, 5, 2)) + .expenseImageUrl("https://s3/img1.jpg") + .build(); + Expense expense2 = Expense.builder() + .id(2L) + .title("교통") + .amount(30000) + .memo("지하철") + .expenseDate(LocalDate.of(2026, 5, 3)) + .expenseImageUrl("https://s3/img2.jpg") + .build(); + + Photo photo1 = Photo.builder().photoUrl("url1").build(); + Photo photo2 = Photo.builder().photoUrl("url2").build(); + Photo photo3 = Photo.builder().photoUrl("url3").build(); + + given(photoRepository.findByExpense(expense1)).willReturn(List.of(photo1)); + given(photoRepository.findByExpense(expense2)).willReturn(List.of(photo2, photo3)); + + List result = TripConverter.toExpenseDtoList(List.of(expense1, expense2), photoRepository); + + assertThat(result).hasSize(2); + assertThat(result.get(0).getExpenseId()).isEqualTo(1L); + assertThat(result.get(0).getTitle()).isEqualTo("식사"); + assertThat(result.get(0).getAmount()).isEqualTo(50000); + assertThat(result.get(0).getMemo()).isEqualTo("저녁 식사"); + assertThat(result.get(0).getExpenseDate()).isEqualTo(LocalDate.of(2026, 5, 2)); + assertThat(result.get(0).getPhotoUrl()).isEqualTo("https://s3/img1.jpg"); + assertThat(result.get(0).getExpensePhotoUrls()).containsExactly("url1"); + assertThat(result.get(1).getExpensePhotoUrls()).containsExactly("url2", "url3"); + } + + // ─── TC-10 ────────────────────────────────────────────────────────────── + + @Test + @DisplayName("Expense에 사진이 없는 경우 → expensePhotoUrls 빈 리스트") + void should_returnEmptyPhotoUrls_when_expenseHasNoPhotos() { + Expense expense = Expense.builder() + .id(1L) + .title("식사") + .amount(50000) + .build(); + + given(photoRepository.findByExpense(expense)).willReturn(Collections.emptyList()); + + List result = TripConverter.toExpenseDtoList(List.of(expense), photoRepository); + + assertThat(result).hasSize(1); + assertThat(result.get(0).getExpensePhotoUrls()).isEmpty(); + } + + // ─── TC-11 ────────────────────────────────────────────────────────────── + + @Test + @DisplayName("빈 Expense 목록 → 빈 리스트 반환, photoRepository 미호출") + void should_returnEmptyList_when_emptyExpenses() { + List result = TripConverter.toExpenseDtoList(new ArrayList<>(), photoRepository); + + assertThat(result).isEmpty(); + verify(photoRepository, never()).findByExpense(any(Expense.class)); + } + + // ─── TC-12 ────────────────────────────────────────────────────────────── + + @Test + @DisplayName("Trip 상세 조회 변환 — 정상 케이스") + void should_returnDetailDto_when_validTrip() { + Member member1 = Member.builder().id(1L).nickname("홍길동").build(); + Member member2 = Member.builder().id(2L).nickname("김영희").build(); + TripMember tm1 = TripMember.builder().member(member1).build(); + TripMember tm2 = TripMember.builder().member(member2).build(); + + Expense expense = Expense.builder() + .id(1L) + .title("식사") + .amount(30000) + .expenseDate(LocalDate.of(2026, 5, 2)) + .build(); + + Trip trip = Trip.builder() + .id(10L) + .name("파리여행") + .place("프랑스") + .startDate(LocalDate.of(2026, 7, 1)) + .endDate(LocalDate.of(2026, 7, 10)) + .budget(2000000) + .totalCost(30000) + .joinCode("PARIS1") + .tripImageUrl("https://s3/paris.jpg") + .dutchCompleted(false) + .tripMembers(new ArrayList<>(List.of(tm1, tm2))) + .expenses(new ArrayList<>(List.of(expense))) + .build(); + + given(photoRepository.findByExpense(expense)).willReturn(Collections.emptyList()); + + TripDetailResponseDTO dto = TripConverter.toDetailDto(trip, photoRepository); + + assertThat(dto.getTripId()).isEqualTo(10L); + assertThat(dto.getTripName()).isEqualTo("파리여행"); + assertThat(dto.getPlace()).isEqualTo("프랑스"); + assertThat(dto.getStartDate()).isEqualTo(LocalDate.of(2026, 7, 1)); + assertThat(dto.getEndDate()).isEqualTo(LocalDate.of(2026, 7, 10)); + assertThat(dto.getBudget()).isEqualTo(2000000); + assertThat(dto.getTotalCost()).isEqualTo(30000); + assertThat(dto.getJoinCode()).isEqualTo("PARIS1"); + assertThat(dto.getTripImageUrl()).isEqualTo("https://s3/paris.jpg"); + assertThat(dto.getDutchCompleted()).isFalse(); + assertThat(dto.getMembers()).hasSize(2); + assertThat(dto.getPhotos()).hasSize(1); + } + + // ─── TC-13 ────────────────────────────────────────────────────────────── + + @Test + @DisplayName("탈퇴 멤버 포함 시 toDetailDto — 알수없음 처리 위임 확인") + void should_returnUnknownMember_when_nullMemberInDetailDto() { + TripMember tmNull = TripMember.builder().member(null).build(); + Member member = Member.builder().id(5L).nickname("정상멤버").build(); + TripMember tm = TripMember.builder().member(member).build(); + + Trip trip = Trip.builder() + .id(1L) + .name("여행") + .place("서울") + .startDate(LocalDate.of(2026, 5, 1)) + .endDate(LocalDate.of(2026, 5, 5)) + .budget(100000) + .totalCost(0) + .joinCode("CODE1") + .dutchCompleted(false) + .tripMembers(new ArrayList<>(List.of(tmNull, tm))) + .expenses(new ArrayList<>()) + .build(); + + TripDetailResponseDTO dto = TripConverter.toDetailDto(trip, photoRepository); + + assertThat(dto.getMembers()).hasSize(2); + assertThat(dto.getMembers().get(0).getMemberId()).isEqualTo(-1L); + assertThat(dto.getMembers().get(0).getNickname()).isEqualTo("알수없음"); + assertThat(dto.getMembers().get(1).getMemberId()).isEqualTo(5L); + assertThat(dto.getMembers().get(1).getNickname()).isEqualTo("정상멤버"); + } + + // ─── TC-14 ────────────────────────────────────────────────────────────── + + @Test + @DisplayName("Trip 목록 변환 — 복수 여행") + void should_returnDetailListDto_when_multipleTrips() { + Member member1 = Member.builder().id(1L).nickname("홍길동").build(); + TripMember tm1 = TripMember.builder().member(member1).build(); + + Trip trip1 = Trip.builder() + .id(1L) + .name("도쿄여행") + .place("일본") + .startDate(LocalDate.of(2026, 5, 1)) + .endDate(LocalDate.of(2026, 5, 7)) + .budget(500000) + .totalCost(100000) + .joinCode("TOKYO1") + .tripMembers(new ArrayList<>(List.of(tm1))) + .expenses(new ArrayList<>()) + .build(); + + Member member2 = Member.builder().id(2L).nickname("김영희").build(); + TripMember tm2 = TripMember.builder().member(member2).build(); + + Trip trip2 = Trip.builder() + .id(2L) + .name("제주여행") + .place("제주도") + .startDate(LocalDate.of(2026, 6, 1)) + .endDate(LocalDate.of(2026, 6, 5)) + .budget(300000) + .totalCost(50000) + .joinCode("JEJU1") + .tripMembers(new ArrayList<>(List.of(tm2))) + .expenses(new ArrayList<>()) + .build(); + + List result = TripConverter.toDetailListDto(List.of(trip1, trip2)); + + assertThat(result).hasSize(2); + + TripDetailResponseDTO dto1 = result.get(0); + assertThat(dto1.getTripId()).isEqualTo(1L); + assertThat(dto1.getTripName()).isEqualTo("도쿄여행"); + assertThat(dto1.getPlace()).isEqualTo("일본"); + assertThat(dto1.getStartDate()).isEqualTo(LocalDate.of(2026, 5, 1)); + assertThat(dto1.getEndDate()).isEqualTo(LocalDate.of(2026, 5, 7)); + assertThat(dto1.getBudget()).isEqualTo(500000); + assertThat(dto1.getTotalCost()).isEqualTo(100000); + assertThat(dto1.getJoinCode()).isEqualTo("TOKYO1"); + assertThat(dto1.getMembers()).hasSize(1); + assertThat(dto1.getMembers().get(0).getMemberId()).isEqualTo(1L); + assertThat(dto1.getMembers().get(0).getNickname()).isNull(); + assertThat(dto1.getTripImageUrl()).isNull(); + assertThat(dto1.getDutchCompleted()).isNull(); + assertThat(dto1.getPhotos()).isNull(); + + TripDetailResponseDTO dto2 = result.get(1); + assertThat(dto2.getTripId()).isEqualTo(2L); + assertThat(dto2.getTripName()).isEqualTo("제주여행"); + assertThat(dto2.getPlace()).isEqualTo("제주도"); + assertThat(dto2.getMembers()).hasSize(1); + assertThat(dto2.getMembers().get(0).getMemberId()).isEqualTo(2L); + assertThat(dto2.getMembers().get(0).getNickname()).isNull(); + } + + // ─── TC-15 ────────────────────────────────────────────────────────────── + + @Test + @DisplayName("빈 목록 입력 시 toDetailListDto → 빈 리스트 반환") + void should_returnEmptyList_when_emptyTripList() { + List result = TripConverter.toDetailListDto(new ArrayList<>()); + + assertThat(result).isEmpty(); + } +} From 1dddd620c7a4f5d618bc604555256f64422ca07e Mon Sep 17 00:00:00 2001 From: yeobi Date: Fri, 24 Apr 2026 16:59:10 +0900 Subject: [PATCH 3/4] =?UTF-8?q?[Chore]=20PR=20review=20=EB=B3=B4=EC=9D=B4?= =?UTF-8?q?=EB=8F=84=EB=A1=9D=20=EC=84=A4=EC=A0=95=206=ED=8A=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/claude-code-review.yml | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/.github/workflows/claude-code-review.yml b/.github/workflows/claude-code-review.yml index e1ce1b1..bdc68a2 100644 --- a/.github/workflows/claude-code-review.yml +++ b/.github/workflows/claude-code-review.yml @@ -25,6 +25,9 @@ jobs: with: claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }} github_token: ${{ github.token }} + claude_args: | + --max-turns 15 + --allowedTools "mcp__github_comment__update_claude_comment,mcp__github_inline_comment__create_inline_comment,Bash(git diff:*),Bash(git log:*),Bash(git show:*),Read,Grep,Glob" prompt: | 이 Pull Request의 코드 변경 사항을 리뷰해주세요. 다음 항목을 중점적으로 확인해주세요: @@ -32,11 +35,12 @@ jobs: 2. 보안: JWT 처리, 인증/인가, 입력 검증 3. JPA 성능: N+1 쿼리, cascade/fetch 설정 4. API 설계: ResponseDTO 패턴 준수 + 리뷰는 한국어로 작성해주세요. 심각한 문제만 지적하고, 사소한 스타일 이슈는 넘어가주세요. + 리뷰 결과는 반드시 PR에 코멘트로 남겨주세요. 문제를 발견하지 못한 경우에도 "검토 완료, 특이사항 없음" 코멘트를 남겨주세요. - claude_args: "--max-turns 15" interactive: if: | @@ -56,4 +60,4 @@ jobs: with: claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }} github_token: ${{ github.token }} - claude_args: "--max-turns 5" + claude_args: | \ No newline at end of file From 3682406109beab6a331678160a9deb367aef5515 Mon Sep 17 00:00:00 2001 From: yeobi Date: Fri, 24 Apr 2026 17:03:01 +0900 Subject: [PATCH 4/4] =?UTF-8?q?[Chore]=20PR=20review=20=EB=B3=B4=EC=9D=B4?= =?UTF-8?q?=EB=8F=84=EB=A1=9D=20=EC=84=A4=EC=A0=95=207=ED=8A=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/claude-code-review.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/claude-code-review.yml b/.github/workflows/claude-code-review.yml index bdc68a2..5de8664 100644 --- a/.github/workflows/claude-code-review.yml +++ b/.github/workflows/claude-code-review.yml @@ -27,7 +27,7 @@ jobs: github_token: ${{ github.token }} claude_args: | --max-turns 15 - --allowedTools "mcp__github_comment__update_claude_comment,mcp__github_inline_comment__create_inline_comment,Bash(git diff:*),Bash(git log:*),Bash(git show:*),Read,Grep,Glob" + --dangerously-skip-permissions prompt: | 이 Pull Request의 코드 변경 사항을 리뷰해주세요. 다음 항목을 중점적으로 확인해주세요: