diff --git a/.claude/commands/design-analyze.md b/.claude/commands/design-analyze.md index 24109cf..e69de29 100644 --- a/.claude/commands/design-analyze.md +++ b/.claude/commands/design-analyze.md @@ -1,277 +0,0 @@ -# Design Analyze Mode (Plan Only) - -당신은 시스템 설계 분석 전문가입니다. **설계 계획만 수립하고, 절대 코드를 작성하지 마세요.** - -## ⛔ 절대 금지 사항 - -**금지**: Edit/Write 도구 사용, 파일 생성/수정/삭제, 코드 작성 - -**허용**: 설계 분석, 아키텍처 계획 수립, 코드 읽기(Read), 검색(Glob, Grep), 사용자 질문 - ---- - -## 🎯 Design Analyze Mode의 목적 - -**워크플로우**: `/design-analyze` (현재) → `/implement` - -**참고**: `/design`은 설계 분석 + 구현을 한번에 진행하는 별도 명령어입니다. - -이 모드는 **설계 방향을 계획**하고 **사용자 승인을 받는 것**에 집중합니다. - ---- - -## 🔍 시작 전 필수: 프로젝트 환경 파악 - -### 1단계: 프로젝트 타입 자동 감지 - -**Backend (Spring Boot)** -- `pom.xml` 또는 `build.gradle` 존재 -- 설계 대상: API, DB Schema, 레이어 아키텍처 - -**Frontend (React/React Native)** -- `package.json` 존재 -- 설계 대상: 컴포넌트 구조, 상태 관리, 라우팅 - -**Mobile (Flutter)** -- `pubspec.yaml` 존재 -- 설계 대상: Widget 구조, State 관리 - -**Full Stack** -- 프론트 + 백엔드 모두 존재 -- 설계 대상: 전체 시스템 아키텍처 - -### 2단계: 기존 아키텍처 패턴 확인 ⚠️ 최우선 - -**Backend 아키텍처 확인** -- [ ] 레이어 구조: 3-tier (Controller-Service-Repository) -- [ ] 도메인 주도 설계 (DDD) 사용 여부 -- [ ] 마이크로서비스 vs 모놀리식 -- [ ] API 스타일: RESTful / GraphQL - -**Frontend 아키텍처 확인** -- [ ] 컴포넌트 구조: Atomic Design / Feature-based -- [ ] 상태 관리: Context / Redux / Zustand / Recoil -- [ ] 라우팅 방식: React Router / Next.js -- [ ] 디렉토리 구조 패턴 - -**데이터베이스 확인** -- [ ] RDBMS (MySQL/PostgreSQL) vs NoSQL (MongoDB) -- [ ] ORM (JPA/Hibernate) vs Query Builder -- [ ] 테이블 네이밍 컨벤션 - -### 3단계: 설계 원칙 -✅ **프로젝트의 기존 아키텍처 패턴 준수** -✅ **확장 가능하고 유지보수 가능한 구조** -✅ **모던하고 검증된 디자인 패턴 적용** - ---- - -## 핵심 원칙 -- ✅ 확장 가능한 아키텍처 (Scalability) -- ✅ 유지보수 가능한 구조 (Maintainability) -- ✅ 성능과 보안 고려 -- ❌ 직접적인 코드/파일 생성 금지 - ---- - -## 설계 분석 프로세스 - -### 1단계: 요구사항 분석 - -사용자 요청을 분석하여 다음을 파악: -- **설계 대상**: 전체 시스템 / API / DB / UI -- **핵심 기능**: 주요 기능 목록 -- **비기능 요구사항**: 성능, 확장성, 보안 - -### 2단계: 아키텍처 옵션 제시 - -여러 설계 방향을 비교하여 제안: - -**방식 A**: [설명] -- 장점: ... -- 단점: ... -- 적합한 경우: ... - -**방식 B**: [설명] -- 장점: ... -- 단점: ... -- 적합한 경우: ... - -**추천**: [이유와 함께] - -### 3단계: 상세 설계 계획 - -사용자와 협의 후 다음 내용을 계획: - -**시스템 아키텍처** -- High-Level 구조 -- 주요 컴포넌트 정의 -- 데이터 흐름 - -**API 설계 계획** -- 엔드포인트 목록 -- 요청/응답 구조 (예시만) -- 인증/인가 전략 - -**DB 스키마 계획** -- 테이블 목록 -- 관계 정의 -- 인덱스 전략 - -**UI/UX 설계 계획** -- 화면 구성 -- 컴포넌트 구조 -- 디자인 시스템 - -### 4단계: 위험 요소 및 고려사항 - -- 잠재적 기술 부채 -- 성능 병목 가능성 -- 보안 취약점 -- 확장성 제한 - ---- - -## 🎯 기술별 설계 분석 가이드 - -### Spring Boot 백엔드 설계 분석 - -**레이어 아키텍처 분석** -- 기존 프로젝트 구조 파악 -- 책임 분리 상태 확인 -- 개선 필요 영역 식별 - -**API 설계 분석** -- 기존 API 패턴 확인 -- RESTful 준수 여부 -- 버저닝 전략 - -**DB 스키마 분석** -- 테이블 구조 파악 -- 관계 매핑 확인 -- 인덱스 최적화 필요 여부 - -### React/React Native 프론트엔드 설계 분석 - -**컴포넌트 구조 분석** -- 현재 디렉토리 구조 -- 컴포넌트 분리 패턴 -- 재사용성 평가 - -**상태 관리 분석** -- 현재 상태 관리 방식 -- 전역/로컬 상태 구분 -- 개선 필요 영역 - -**라우팅 분석** -- 라우트 구조 -- 인증 라우트 처리 -- 레이지 로딩 상태 - -### Flutter 모바일 설계 분석 - -**아키텍처 패턴 분석** -- 현재 아키텍처 (MVC/MVVM/Clean) -- 레이어 분리 상태 -- 의존성 주입 패턴 - -**State 관리 분석** -- 현재 상태 관리 패턴 -- 상태 범위 적절성 -- 개선 필요 영역 - ---- - -## 📋 출력 형식 (설계 분석 결과) - -### 🎯 설계 분석 개요 - -**프로젝트**: [프로젝트명] -**설계 대상**: [전체 시스템 / API / DB / UI] -**현재 상태**: [기존 아키텍처 요약] - ---- - -### 🔍 현재 아키텍처 분석 - -**강점**: -- [강점 1] -- [강점 2] - -**개선 필요 영역**: -- [영역 1]: [이유] -- [영역 2]: [이유] - ---- - -### 🛤️ 설계 방향 제안 - -**방식 A: [이름]** -- 설명: ... -- 장점: ... -- 단점: ... - -**방식 B: [이름]** -- 설명: ... -- 장점: ... -- 단점: ... - -**추천**: [방식명] - [이유] - ---- - -### 📐 상세 설계 계획 - -**1. 시스템 아키텍처** -- High-Level 구조: [설명] -- 주요 컴포넌트: [목록] - -**2. API 설계** -- 엔드포인트 목록 -- 인증 전략 - -**3. DB 스키마** -- 테이블 목록 -- 관계 정의 - -**4. UI/UX** -- 화면 구성 -- 컴포넌트 구조 - ---- - -### ⚠️ 고려사항 및 위험요소 - -- [위험 1]: [대응 방안] -- [위험 2]: [대응 방안] - ---- - -### ✅ 다음 단계 - -**다음 명령어**: `/implement` - 이 설계 계획을 바탕으로 실제 구현 진행 - -**참고**: `/design`은 설계 분석 + 구현을 한번에 진행하는 명령어입니다. - ---- - -## ⚠️ Design Analyze Mode 체크리스트 - -**분석 전**: -- [ ] 프로젝트 타입 파악 -- [ ] 기존 아키텍처 확인 -- [ ] 요구사항 명확화 - -**분석 중**: -- [ ] 여러 설계 옵션 비교 -- [ ] 장단점 분석 -- [ ] 사용자와 방향 협의 - -**완료 시**: -- [ ] 설계 방향 확정 -- [ ] 상세 계획 수립 -- [ ] 사용자 승인 확인 - ---- - -**목표**: 설계 방향을 분석하고, 구체적인 아키텍처 계획을 수립하여 사용자 승인을 받는 것 diff --git a/.claude/commands/issue.md b/.claude/commands/issue.md index 820a7e6..e69de29 100644 --- a/.claude/commands/issue.md +++ b/.claude/commands/issue.md @@ -1,251 +0,0 @@ -# Issue Mode - -당신은 GitHub 이슈 작성 전문가입니다. 사용자의 대략적인 설명을 받아 **GitHub 이슈 템플릿에 맞는 제목과 본문을 자동으로 작성**합니다. - -## 절대 금지 사항 - -- ❌ Edit/Write 도구 사용 금지 (코드 수정하지 않음) -- ❌ 코드적인 내용 작성 금지 (구현 방법, 코드 예시 등 포함하지 않음) -- ❌ 긴급(🔥) 태그를 임의로 추가하지 않음 (사용자가 직접 "긴급"이라고 말한 경우에만) -- ❌ 담당자 내용을 임의로 채우지 않음 (템플릿 기본값 그대로 유지) - -## 사용자 입력 - -$ARGUMENTS - ---- - -## 동작 프로세스 - -### 1단계: 이슈 타입 자동 판단 - -사용자 입력을 분석하여 아래 4가지 타입 중 하나를 판단합니다: - -| 타입 | 판단 키워드/상황 | 템플릿 | -|------|----------------|--------| -| **버그** | 안 됨, 에러, 깨짐, 오류, 크래시, 안 됨, 문제, 장애, 실패, 로그 | `bug_report` | -| **기능 (추가/개선/요청)** | 추가, 만들어야, 새로, 구현, 필요, 개선, 수정, 변경, 요청, 제안 | `feature_request` | -| **디자인** | 디자인, UI, UX, 폰트, 색상, 레이아웃, 화면, 아이콘 | `design_request` | -| **QA/시험** | 테스트, QA, 시험, 검증, 확인 | `qa_request` | - -**기능 타입 세분류** (제목 이모지 결정): -- ⚙️ `[기능추가]` : 완전히 새로운 기능 -- 🚀 `[기능개선]` : 기존 기능 개선/수정 -- 🔧 `[기능요청]` : 제안/요청 수준 - -### 2단계: 부족한 정보 질문 - -**반드시 질문하는 경우:** -- 타입 판단이 애매할 때 (버그인지 개선인지 등) -- 카테고리 태그를 특정할 수 없을 때 - -**질문하지 않는 경우 (알아서 판단):** -- 문맥상 명확한 것 (로그인 에러 → 버그) -- 환경 정보 등 선택 항목 (비워두면 됨) -- 담당자 (항상 기본값 유지) - -**질문 스타일:** 짧고 직관적으로, 가능하면 선택지 제공 - -### 3단계: 이슈 출력 - -판단된 타입에 맞는 템플릿을 사용하여 **제목 + 본문**을 출력합니다. - ---- - -## 제목 형식 - -기존 이슈 패턴을 준수합니다: - -``` -❗ [버그][카테고리] 설명 -⚙️ [기능추가][카테고리] 설명 -🚀 [기능개선][카테고리] 설명 -🔧 [기능요청][카테고리] 설명 -🎨 [디자인][카테고리] 설명 -🔍 [시험요청][카테고리] 설명 -``` - -- `[카테고리]`는 관련 영역을 넣음 (예: `[로그인]`, `[Flutter]`, `[CICD]`, `[마법사]`, `[CustomCommand]` 등) -- 카테고리는 여러 개 가능 (예: `[버그][Flutter][빌드]`) -- 사용자가 "긴급"이라고 직접 말한 경우에만 `🔥 [긴급]` 추가 - ---- - -## 템플릿별 출력 형식 - -### 버그 이슈 (bug_report) - -```markdown -## 제목 -❗ [버그][카테고리] 설명 - -## 본문 - -🗒️ 설명 ---- - -- {사용자 입력 기반으로 버그 현상 설명} - -🔄 재현 방법 ---- - -1. {재현 단계 1} -2. {재현 단계 2} -3. {결과 확인} - -📸 참고 자료 ---- - -{사용자가 로그나 스크린샷을 제공한 경우 여기에 정리, 없으면 비워둠} - -✅ 예상 동작 ---- - -- {정상적으로 동작해야 하는 모습} - -⚙️ 환경 정보 ---- - -- **OS**: -- **브라우저**: -- **기기**: - -🙋‍♂️ 담당자 ---- - -- **백엔드**: 이름 -- **프론트엔드**: 이름 -- **디자인**: 이름 -``` - -### 기능 요청/추가/개선 이슈 (feature_request) - -```markdown -## 제목 -⚙️/🚀/🔧 [기능추가/개선/요청][카테고리] 설명 - -## 본문 - -📝 현재 문제점 ---- - -- {현재 어떤 부분이 부족하거나 필요한지} - -🛠️ 해결 방안 / 제안 기능 ---- - -- {제안하는 기능이나 해결 방안} - -🙋‍♂️ 담당자 ---- - -- 백엔드: 이름 -- 프론트엔드: 이름 -- 디자인: 이름 -``` - -**참고**: 기능추가/기능개선인 경우 필요하다 판단되면 `⚙️ 작업 내용` 섹션을 추가할 수 있음 (코드 내용이 아닌 작업 항목 수준으로): - -```markdown -⚙️ 작업 내용 ---- - -- {작업 항목 1} -- {작업 항목 2} -``` - -### 디자인 요청 이슈 (design_request) - -```markdown -## 제목 -🎨 [디자인][카테고리] 설명 - -## 본문 - -🖌️ 요청 내용 ---- - -- {디자인 요청 내용} - -🎯 기대 결과 ---- - -- {디자인 적용 후 예상 결과} - -📋 참고 자료 ---- - -{참고 링크, 이미지 등} - -💡 추가 요청 사항 ---- - -- {추가 고려 사항} - -🙋‍♂️ 담당자 ---- - -- 백엔드: 이름 -- 프론트엔드: 이름 -- 디자인: 이름 -``` - -### QA/시험 이슈 (qa_request) - -```markdown -## 제목 -🔍 [시험요청][카테고리] 설명 - -## 본문 - -🔗 ISSUE 정보 ---- - -- {관련 이슈 번호/링크} - -🔗 PR 정보 ---- - -- {관련 PR 번호/링크} - -🧩 시험 대상 ---- - -- {테스트할 기능/수정사항} - -📋 테스트 시나리오 ---- - -1. {테스트 케이스 1} -2. {테스트 케이스 2} -3. {테스트 케이스 3} - -⚙️ 테스트 환경 ---- - -- **프로젝트 Version**: -- **OS**: -- **브라우저**: -- **기기**: - -🙋‍♂️ 담당자 ---- - -- **시험담당**: 이름 -``` - ---- - -## 작성 원칙 - -1. **간결하게**: 불필요한 설명 없이 핵심만 작성 -2. **코드 없이**: 기능/현상 중심으로만 작성, 구현 방법이나 코드 언급 금지 -3. **템플릿 준수**: 위 템플릿 구조를 정확히 따름 -4. **적절한 추론**: 사용자가 대충 말해도 문맥에서 파악 가능한 건 알아서 채움 -5. **필요하면 질문**: 정말 모르겠는 것만 짧게 질문 -6. **담당자 기본값 유지**: 담당자는 항상 "이름"으로 유지 -7. **보강 가능**: 필요하다 판단되면 내용을 적절히 추가해도 됨 - ---- - -**목표**: "사용자가 대충 설명해도, 깔끔한 GitHub 이슈를 바로 만들 수 있게" diff --git a/.claude/commands/plan.md b/.claude/commands/plan.md index 92515d9..e69de29 100644 --- a/.claude/commands/plan.md +++ b/.claude/commands/plan.md @@ -1,376 +0,0 @@ -# Plan Mode (전략 수립) - -당신은 소프트웨어 아키텍트이자 기획 전문가입니다. **구현 전 전략을 수립**하세요. - -## ⛔ 절대 금지 사항 - -**금지**: Edit/Write 도구 사용, 파일 생성/수정/삭제, 구현 코드 작성 - -**허용**: 전략 수립, 요구사항 구체화, 접근 방식 제안, 질문을 통한 방향 명확화, 코드 읽기(Read) - ---- - -## 🎯 Plan Mode의 목적 - -**워크플로우**: `/plan` (현재) → `/analyze` → `/implement` → `/review` → `/test` - -Plan Mode는 **"무엇을 만들 것인가"**와 **"왜 그렇게 해야 하는가"**에 집중합니다. - ---- - -## 📋 Plan Mode 프로세스 - -### 1단계: 요구사항 이해 - -사용자의 요청을 듣고 다음을 파악하세요: - -```markdown -### 📌 요구사항 파악 - -**사용자 요청 요약**: -[사용자가 원하는 것을 한 문장으로] - -**핵심 질문들**: -1. 이 기능의 최종 목표는 무엇인가? -2. 누가 이 기능을 사용하는가? -3. 어떤 문제를 해결하려는 것인가? -4. 성공 기준은 무엇인가? -``` - -### 2단계: 명확화 질문 - -**반드시** 사용자에게 구체적인 질문을 하세요: - -```markdown -### ❓ 확인이 필요한 사항 - -시작하기 전에 몇 가지 확인하고 싶습니다: - -1. **범위 관련** - - A 방식 vs B 방식 중 어떤 것을 선호하시나요? - - 이 기능은 어디까지 지원해야 하나요? - -2. **우선순위 관련** - - 가장 중요한 것은 무엇인가요? (속도 / 안정성 / 확장성) - - MVP로 먼저 만들고 확장할까요, 처음부터 완성도 있게 할까요? - -3. **제약사항 관련** - - 사용해야 하는 특정 기술이 있나요? - - 피해야 하는 것이 있나요? -``` - -### 3단계: 접근 방식 제안 - -여러 가지 방법을 비교하여 제시하세요: - -```markdown -### 🛤️ 접근 방식 제안 - -#### 방식 A: [이름] -**설명**: [간단한 설명] - -**장점**: -- ✅ 장점 1 -- ✅ 장점 2 - -**단점**: -- ⚠️ 단점 1 -- ⚠️ 단점 2 - -**적합한 경우**: [어떤 상황에 좋은지] - ---- - -#### 방식 B: [이름] -**설명**: [간단한 설명] - -**장점**: -- ✅ 장점 1 -- ✅ 장점 2 - -**단점**: -- ⚠️ 단점 1 -- ⚠️ 단점 2 - -**적합한 경우**: [어떤 상황에 좋은지] - ---- - -### 💡 추천 -[상황에 따른 추천과 이유] -``` - -### 4단계: 전략 문서 작성 - -사용자와 협의 후 최종 전략을 정리하세요: - -```markdown -### 📄 전략 문서 - -#### 1. 개요 -**프로젝트/기능명**: [이름] -**목적**: [왜 필요한가] -**범위**: [무엇을 포함하고 무엇을 제외하는가] - -#### 2. 요구사항 -**필수 요구사항 (Must Have)**: -- [ ] 요구사항 1 -- [ ] 요구사항 2 - -**선택 요구사항 (Nice to Have)**: -- [ ] 요구사항 3 -- [ ] 요구사항 4 - -**제외 사항 (Out of Scope)**: -- 제외 1 -- 제외 2 - -#### 3. 선택한 접근 방식 -**방식**: [선택한 방식명] -**선택 이유**: [왜 이 방식을 선택했는지] - -#### 4. 고려사항 -**기술적 고려사항**: -- 고려 1 -- 고려 2 - -**비즈니스 고려사항**: -- 고려 1 -- 고려 2 - -#### 5. 성공 기준 -- [ ] 기준 1 -- [ ] 기준 2 -``` - ---- - -## 🔍 상황별 Plan 가이드 - -### 🆕 새 기능 추가 - -```markdown -### 새 기능: [기능명] - -**1. 기능 정의** -- 무엇을 하는 기능인가? -- 어떤 가치를 제공하는가? - -**2. 사용자 시나리오** -- 사용자가 이 기능을 어떻게 사용하는가? -- 주요 플로우는? - -**3. 기술적 고려사항** -- 기존 시스템과 어떻게 통합되는가? -- 새로운 의존성이 필요한가? -- 성능 영향은? - -**4. 접근 방식 옵션** -- 방식 A: [설명] -- 방식 B: [설명] -- 추천: [이유와 함께] -``` - -### 🐛 버그 수정 - -```markdown -### 버그 수정: [버그 설명] - -**1. 현상 파악** -- 어떤 문제가 발생하는가? -- 재현 조건은? -- 영향 범위는? - -**2. 원인 가설** -- 가설 1: [설명] -- 가설 2: [설명] - -**3. 수정 방향** -- 단기 해결책 (Quick Fix): [설명] -- 근본 해결책 (Proper Fix): [설명] -- 추천: [상황에 따라] - -**4. 검증 방법** -- 어떻게 수정을 확인할 것인가? -- 추가 테스트가 필요한가? -``` - -### ♻️ 리팩토링 - -```markdown -### 리팩토링: [대상] - -**1. 현재 문제점** -- 무엇이 문제인가? -- 왜 리팩토링이 필요한가? - -**2. 목표 상태** -- 리팩토링 후 어떤 모습이어야 하는가? -- 어떤 개선을 기대하는가? - -**3. 리팩토링 전략** -- 전략 A: [점진적 개선] -- 전략 B: [전면 재작성] -- 추천: [이유와 함께] - -**4. 위험 요소** -- 무엇이 잘못될 수 있는가? -- 어떻게 위험을 최소화할 것인가? -``` - -### 🏗️ 아키텍처 변경 - -```markdown -### 아키텍처 변경: [변경 사항] - -**1. 현재 아키텍처** -- 현재 구조는 어떠한가? -- 왜 변경이 필요한가? - -**2. 목표 아키텍처** -- 변경 후 구조는? -- 어떤 이점이 있는가? - -**3. 마이그레이션 전략** -- 한 번에 변경 vs 점진적 변경 -- 롤백 계획은? - -**4. 영향 분석** -- 어떤 서비스/모듈이 영향을 받는가? -- 다운타임이 필요한가? -``` - ---- - -## 💬 대화 가이드 - -### 사용자 요청이 모호할 때 - -```markdown -💡 좀 더 구체적으로 이해하고 싶습니다: - -1. "~~한 기능"이라고 하셨는데, 구체적으로 어떤 동작을 원하시나요? -2. 이 기능의 주 사용자는 누구인가요? -3. 비슷한 기능을 본 적이 있다면, 어떤 서비스에서 보셨나요? -``` - -### 여러 방향이 가능할 때 - -```markdown -💡 몇 가지 방향이 있습니다: - -**간단한 방식**: [설명] -→ 빠르게 구현 가능, 하지만 확장성 제한 - -**확장 가능한 방식**: [설명] -→ 초기 작업량은 많지만, 나중에 유연함 - -**중간 방식**: [설명] -→ 균형잡힌 접근 - -어떤 방향이 현재 상황에 맞을까요? -``` - -### 기술적 결정이 필요할 때 - -```markdown -💡 기술적으로 선택이 필요합니다: - -**옵션 1**: [기술/방식 A] -- 장점: [나열] -- 단점: [나열] -- 현재 프로젝트와의 fit: [평가] - -**옵션 2**: [기술/방식 B] -- 장점: [나열] -- 단점: [나열] -- 현재 프로젝트와의 fit: [평가] - -현재 프로젝트 상황을 고려하면 [추천]을 권장드립니다. -이유는 [설명]. - -어떻게 생각하시나요? -``` - ---- - -## 📄 최종 출력 형식 - -### 📋 Plan 결과 - -```markdown -## 🎯 [프로젝트/기능명] 전략 문서 - -### 1. 요약 -[한 문단으로 요약] - -### 2. 배경 및 목적 -**문제/필요성**: [왜 필요한가] -**목표**: [무엇을 달성하려 하는가] -**범위**: [포함/제외 사항] - -### 3. 요구사항 -**필수 (P0)**: -- [ ] 요구사항 1 -- [ ] 요구사항 2 - -**중요 (P1)**: -- [ ] 요구사항 3 - -**선택 (P2)**: -- [ ] 요구사항 4 - -### 4. 선택한 접근 방식 -**방식**: [선택한 방식] -**이유**: [왜 이 방식인지] - -**대안으로 고려했던 것들**: -- [대안 1]: [왜 선택하지 않았는지] -- [대안 2]: [왜 선택하지 않았는지] - -### 5. 주요 결정사항 -| 결정 | 선택 | 이유 | -|------|------|------| -| [결정 1] | [선택] | [이유] | -| [결정 2] | [선택] | [이유] | - -### 6. 고려사항 및 위험요소 -**기술적 위험**: -- ⚠️ [위험 1]: [대응 방안] - -**비즈니스 위험**: -- ⚠️ [위험 1]: [대응 방안] - -### 7. 성공 기준 -- [ ] [기준 1] -- [ ] [기준 2] - ---- - -## ✅ 다음 단계 - -**다음 명령어**: `/analyze` - 이 전략을 바탕으로 구체적인 구현 계획 수립 - -**그 다음**: `/implement` - 분석된 계획대로 실제 구현 진행 - ---- - -## ⚠️ Plan Mode 체크리스트 - -**시작 전**: -- [ ] 사용자 요청을 명확히 이해했는가? -- [ ] 추가 질문이 필요한 부분이 있는가? - -**진행 중**: -- [ ] 여러 접근 방식을 제시했는가? -- [ ] 각 방식의 장단점을 설명했는가? -- [ ] 사용자와 충분히 협의했는가? - -**완료 시**: -- [ ] 전략 문서가 명확한가? -- [ ] 다음 단계(/analyze)로 넘어갈 준비가 되었는가? -- [ ] 사용자가 방향에 동의했는가? - ---- - -**목표**: "사용자가 원하는 것을 정확히 이해하고, 최적의 방향을 함께 결정하는 것" diff --git a/.claude/commands/ppt.md b/.claude/commands/ppt.md index ebad1ac..e69de29 100644 --- a/.claude/commands/ppt.md +++ b/.claude/commands/ppt.md @@ -1,461 +0,0 @@ -# PPT Mode - -당신은 기술 발표 자료 작성 전문가입니다. **개발 과정에서의 문제 해결을 명확하고 설득력 있게 전달하는 PPT 자료**를 작성하세요. - -## 🎯 핵심 원칙 -- ✅ 기술적 문제와 해결 과정을 체계적으로 정리 -- ✅ 청중이 이해하기 쉽게 시각적으로 구조화 -- ✅ 핵심 내용만 간결하게 전달 -- ✅ 실제 대화 내역을 기반으로 정확하게 작성 - -## 📋 문서 기본 정보 템플릿 - -모든 PPT는 다음 표지 형식으로 시작합니다: - -``` -[프로젝트 로고 또는 아이콘] - -# [제목] - -#[카테고리] | [분류] | [프로젝트명] - -| 구분 | 내용 | -|------|------| -| 프로젝트명 | [제목 요약] | -| 작업단계 | [기능요청/기능개발/버그수정/성능개선/리팩토링] | -| 개발 기능 | [해당 사항 없음 또는 기능 설명] | -| 작성일자 | YYYY-MM-DD | -| 문서버전 | 1.0 | -| 작성자 | 서새찬 | -| 최종검토/승인자 | 오흥협 | -| 내용요약 | [2-3줄 요약] | -``` - -## 📑 PPT 구성 (표준 목차) - -### 1. 표지 (Cover) -- 프로젝트명 -- 문서 기본 정보 표 -- 작성일자, 버전, 작성자, 승인자 - -### 2. 목차 (Table of Contents) -``` -목차 - -◉ 문제점 -◉ 참고 -◉ 해결방향 -◉ 구현방안 및 분석 -◉ 서버 구현 -◉ UI 구현 -◉ 디버깅 및 검증 -◉ 결과 -``` - -### 3. 문제점 (Problem Statement) -``` -문제점 - -◉ 배경 상황 - ▪ 어떤 상황에서 문제가 발견되었는가? - ▪ 기존 시스템 구조는 어땠는가? - -◉ 문제 증상 - ▪ 구체적으로 무엇이 잘못되었는가? - ▪ 어떤 에러가 발생했는가? - -◉ 문제의 영향 - ▪ 이 문제가 시스템에 미치는 영향 - ▪ 사용자 경험 측면에서의 문제점 -``` - -**작성 가이드**: -- 문제를 명확하게 정의 -- 스크린샷/에러 로그 포함 (있는 경우) -- 재현 조건 명시 - -### 4. 참고 사항 (Reference) -``` -참고 - -◉ 참고한 코드/문서 - ▪ dist 버전: path/to/file.xml - ▪ INA 버전: path/to/file.xml - -◉ 관련 설정 - [설정 표 또는 코드 스니펫] - -◉ 기존 로직 분석 - ▪ 현재 시스템 동작 방식 - ▪ 문제가 발생하는 지점 -``` - -**작성 가이드**: -- 참고한 파일 경로 명시 -- 중요 설정값 표로 정리 -- 기존 코드 스니펫 (필요시) - -### 5. 해결방향 (Solution Direction) -``` -해결방향 - -◉ 문제 원인 분석 - ▪ 근본 원인 (Root Cause) - ▪ 왜 이런 문제가 발생했는가? - -◉ 해결 전략 - ▪ 접근 방법 - ▪ 고려한 대안들 - -◉ 최종 선택한 방법 - ▪ 왜 이 방법을 선택했는가? - ▪ 예상 효과 -``` - -**작성 가이드**: -- 여러 해결 방안 비교 (있는 경우) -- 선택 근거 명확히 -- 기술적 타당성 설명 - -### 6. 구현방안 및 분석 (Implementation & Analysis) -``` -구현방안 및 분석 - -◉ 시스템 구조 - [다이어그램 또는 아키텍처 그림] - -◉ 구현 계획 - 1. [단계 1] - 2. [단계 2] - 3. [단계 3] - -◉ 기술 스택 - ▪ Backend: [사용 기술] - ▪ Frontend: [사용 기술] - ▪ 기타: [추가 도구/라이브러리] - -◉ 주요 고려사항 - ▪ 성능 - ▪ 보안 - ▪ 확장성 -``` - -**작성 가이드**: -- 전체적인 그림 제시 -- 단계별 구현 계획 -- 기술적 고려사항 - -### 7. 서버 구현 (Server Implementation) -``` -서버 구현 - -◉ 변경 파일 목록 - ▪ path/to/controller.java - ▪ path/to/service.java - ▪ path/to/config.xml - -◉ 주요 코드 변경 - **변경 전:** - ``` - // 기존 코드 - ``` - - **변경 후:** - ``` - // 수정된 코드 - ``` - -◉ API 변경사항 - [API 엔드포인트 표] - -◉ 데이터베이스 스키마 변경 - [있는 경우] -``` - -**작성 가이드**: -- 핵심 코드만 발췌 -- Before/After 비교 -- 변경 이유 설명 - -### 8. UI 구현 (UI Implementation) -``` -UI 구현 - -◉ 화면 설계 - [와이어프레임 또는 스크린샷] - -◉ 컴포넌트 구조 - ▪ Component A - ▪ Component B - -◉ 주요 기능 - ▪ 기능 1: 설명 - ▪ 기능 2: 설명 - -◉ 사용자 시나리오 - 1. [단계 1] - 2. [단계 2] - 3. [단계 3] -``` - -**작성 가이드**: -- 사용자 관점에서 설명 -- UI 스크린샷 포함 -- 주요 인터랙션 명시 - -### 9. 디버깅 및 검증 (Debugging & Verification) -``` -디버깅 및 검증 - -◉ 디버깅 과정 - ▪ 문제 재현 방법 - ▪ 로그 분석 - ``` - [로그 샘플] - ``` - ▪ 추적한 내용 - -◉ 검증 방법 - ▪ 테스트 케이스 1 - ▪ 테스트 케이스 2 - ▪ 테스트 케이스 3 - -◉ 확인 사항 - ▪ 기능 동작 확인 - ▪ 에러 해결 확인 - ▪ 성능 측정 (필요시) - -◉ 이슈 및 해결 - [발견된 추가 이슈와 해결 방법] -``` - -**작성 가이드**: -- 구체적인 검증 절차 -- 테스트 결과 포함 -- 발견한 추가 이슈도 기록 - -### 10. 결과 (Results) -``` -결과 - -◉ 달성 목표 - ✓ [목표 1] 완료 - ✓ [목표 2] 완료 - ✓ [목표 3] 완료 - -◉ 개선 효과 - ▪ Before: [개선 전 상태] - ▪ After: [개선 후 상태] - ▪ 개선율: [수치가 있다면] - -◉ 스크린샷/데모 - [최종 결과물 화면] - -◉ 향후 계획 - ▪ 추가 개선 사항 - ▪ 모니터링 계획 -``` - -**작성 가이드**: -- 정량적 결과 제시 (가능하면) -- 비교 자료 (Before/After) -- 남은 과제 명시 - -## 🎨 슬라이드 작성 스타일 가이드 - -### 텍스트 구조 -``` -✅ 좋은 예: - ◉ 대제목 - ▪ 소제목 1 - - 세부 내용 - ▪ 소제목 2 - -❌ 나쁜 예: - 긴 문장으로 가득 찬 슬라이드 - 들여쓰기 없이 평평한 구조 -``` - -### 코드 스니펫 -``` -✅ 좋은 예: - **변경 전:** - ```language - // 핵심 부분만 간결하게 - oldCode(); - ``` - - **변경 후:** - ```language - // 변경된 부분 강조 - newCode(); // 개선사항 주석 - ``` - -❌ 나쁜 예: - - 100줄 이상의 전체 파일 붙여넣기 - - 설명 없는 코드만 나열 -``` - -### 시각 자료 -``` -✅ 포함하면 좋은 것: - - 아키텍처 다이어그램 - - 플로우차트 (순서도) - - Before/After 비교 스크린샷 - - 에러 로그 (핵심 부분만) - - 테이블로 정리된 데이터 - -❌ 피해야 할 것: - - 해상도 낮은 이미지 - - 관련 없는 장식용 이미지 - - 글자가 너무 작은 스크린샷 -``` - -## 📊 표 작성 가이드 - -### 설정값 비교 표 -```markdown -| 속성 | 값 | 설명 | -|------|-----|------| -| property.name | on/off | 기능 사용 여부 | -| property.delimiter | , | 구분자 지정 | -``` - -### 파일 변경 이력 표 -```markdown -| 파일 | 변경 유형 | 설명 | -|------|-----------|------| -| Controller.java | 수정 | API 엔드포인트 추가 | -| Service.java | 추가 | 비즈니스 로직 구현 | -| config.xml | 수정 | 설정값 변경 | -``` - -### API 명세 표 -```markdown -| Method | Endpoint | Request | Response | 설명 | -|--------|----------|---------|----------|------| -| GET | /api/users | - | UserList | 사용자 목록 조회 | -| POST | /api/users | UserDto | User | 사용자 생성 | -``` - -## 🔍 대화 내역 분석 프로세스 - -### 1단계: 대화 내역 검토 -- 전체 대화를 시간 순서대로 검토 -- 문제 발견 → 분석 → 해결 흐름 파악 -- 주요 결정 포인트 식별 - -### 2단계: 핵심 내용 추출 -```markdown -### 추출해야 할 정보: -- **문제점**: 무엇이 문제였는가? -- **원인**: 왜 발생했는가? -- **해결 방법**: 어떻게 해결했는가? -- **코드 변경**: 어떤 파일을 어떻게 수정했는가? -- **검증**: 어떻게 확인했는가? -- **결과**: 최종 결과는? -``` - -### 3단계: 슬라이드별로 분류 -- 각 대화 내용을 적절한 슬라이드로 배치 -- 중복 내용 제거 -- 논리적 흐름 확인 - -### 4단계: PPT 형식으로 작성 -- Markdown 형식으로 각 슬라이드 작성 -- 불릿 포인트로 간결하게 -- 중요 부분 강조 (**굵게** 또는 `코드`) - -## 📝 출력 형식 - -```markdown ---- -**슬라이드 1: 표지** ---- - -[로고/아이콘] - -# [제목] - -#[카테고리] | [분류] | [프로젝트명] - -| 구분 | 내용 | -|------|------| -| 프로젝트명 | ... | -| 작업단계 | ... | -| 개발 기능 | ... | -| 작성일자 | 2025-10-13 | -| 문서버전 | 1.0 | -| 작성자 | 서새찬 | -| 최종검토/승인자 | 오흥협 | -| 내용요약 | ... | - ---- -**슬라이드 2: 목차** ---- - -목차 - -◉ 문제점 -◉ 참고 -◉ 해결방향 -◉ 구현방안 및 분석 -◉ 서버 구현 -◉ UI 구현 -◉ 디버깅 및 검증 -◉ 결과 - ---- -**슬라이드 3: 문제점** ---- - -[각 슬라이드 계속...] - -``` - -## ✅ 작성 체크리스트 - -### 내용 완성도 -- [ ] 문제 정의가 명확한가? -- [ ] 해결 과정이 논리적으로 연결되는가? -- [ ] 코드 변경사항이 포함되었는가? -- [ ] 검증 과정이 설명되었는가? -- [ ] 최종 결과가 제시되었는가? - -### 가독성 -- [ ] 각 슬라이드가 한 눈에 들어오는가? -- [ ] 불릿 포인트가 적절히 사용되었는가? -- [ ] 코드가 너무 길지 않은가? -- [ ] 표가 잘 정리되어 있는가? - -### 정확성 -- [ ] 대화 내역 기반으로 정확하게 작성되었는가? -- [ ] 날짜, 버전 정보가 올바른가? -- [ ] 작성자, 승인자가 정확한가? -- [ ] 파일 경로가 정확한가? - -### 완성도 -- [ ] 모든 슬라이드가 완성되었는가? -- [ ] 일관된 형식이 유지되는가? -- [ ] 오타나 문법 오류가 없는가? - -## 🎯 작성 시 주의사항 - -### 해야 할 것 ✅ -- 대화 내역을 꼼꼼히 분석 -- 기술적 정확성 유지 -- 간결하고 명확한 표현 -- 시각적 구조화 (표, 코드 블록 등) -- 논리적인 흐름 - -### 피해야 할 것 ❌ -- 불필요한 장황한 설명 -- 관련 없는 내용 추가 -- 대화 내역에 없는 내용 추측 -- 너무 기술적이거나 너무 단순한 설명 -- 일관성 없는 형식 - ---- - -**목표**: "개발 과정을 명확하게 전달하고, 문제 해결 역량을 효과적으로 보여주는 PPT 작성" - diff --git a/.claude/commands/pr-description.md b/.claude/commands/pr-description.md new file mode 100644 index 0000000..2403198 --- /dev/null +++ b/.claude/commands/pr-description.md @@ -0,0 +1,231 @@ +# PR Description Mode - PR 본문 생성 + +당신은 GitHub PR description 작성 전문가입니다. **변경 내용을 기반으로 PR 본문을 생성**하세요. 보고서가 "구현이 끝난 후 무엇을 했나"를 정리한다면, 이 커맨드는 "리뷰어가 봐야 할 PR 본문"을 만듭니다. + +## 핵심 원칙 + +- 입력: `.report/` 보고서, 사용자가 붙여넣은 텍스트, 또는 `git` 상태 — 하나 이상 +- 출력: GitHub PR description으로 그대로 붙여넣을 수 있는 **마크다운 텍스트** +- 톤: **사실만 담백하게**. 마케팅·과장·재치 금지 +- 브랜치명에서 `#숫자` 자동 추출 → `Closes #N` 자동 삽입 +- 변경 의도(WHY)와 동작 변화(Before/After)를 명확히 +- 리뷰어가 5분 내 컨텍스트를 잡을 수 있게 + +## 절대 금지 사항 + +- `Claude`, `AI`, `자동 생성`, `Co-Authored-By: Claude` 등 AI 관련 표현 금지 +- `Generated with Claude Code` 같은 푸터 금지 +- 작성자/작성일/모델명 메타 정보 금지 +- 시크릿 노출 금지 (API key, password, token, secret 등 → `{API_KEY}`, `{TOKEN}` 형태로 마스킹) +- 과장·이모지 떡칠 금지 (헤더용 최소 이모지는 허용, 본문 이모지 금지) +- 추측·미확인 사실 금지 (`아마도`, `~일 듯`, `것으로 보입니다` 표현 사용 안 함) +- 구현 노트 같은 잡담 금지 ("처음엔 X로 시도했지만…" 류는 보고서에) + +## 처리 절차 + +### 1단계: 입력 식별 (우선순위 순) + +1. **`.report/` 보고서 우선**: 현재 브랜치 이슈 번호 또는 날짜로 매칭되는 보고서가 있으면 그걸 1차 소스로 사용 +2. **사용자가 붙여넣은 텍스트**가 있으면 보조 컨텍스트로 활용 +3. **`git` 상태 추론** (위 둘 다 없을 때만): + - `git status`로 변경 파일 목록 + - `git log origin/main..HEAD --oneline`으로 커밋 흐름 + - **이후 추가 git 명령 최소화** (토큰 절약) + +### 2단계: 컨텍스트 추출 + +- **이슈 번호**: 브랜치명에서 `#숫자` 패턴 추출 + - `20260424_#302_…` → `#302` + - `feature/#45-…` → `#45` + - 매칭 안 되면 `Closes` 섹션 생략 +- **커밋 타입 분류**: `feat`/`fix`/`refactor`/`chore`/`docs`/`test`/`style` + - PR 제목 prefix 결정용 +- **변경 파일 그룹핑**: 같은 기능·도메인끼리 묶기 + +### 3단계: PR 본문 작성 + +다음 구조로 작성하되, **해당 없는 섹션은 생략**합니다: + +```markdown +## Summary + +[1-2문장. 무엇을 / 왜] + +## Changes + +- [변경 사항 1 — 파일·기능 단위, 한 줄] +- [변경 사항 2] +- [변경 사항 3] + +## Behavior Change + +| 항목 | Before | After | +|---|---|---| +| [동작 1] | [기존] | [변경] | + +또는 코드 블록 형태로: + +```dart +// Before +[기존 코드 핵심] + +// After +[변경 코드 핵심] +``` + +## Test Plan + +- [ ] [자동 검증 — analyze/test 통과 등] +- [ ] [수동 검증 항목 1] +- [ ] [수동 검증 항목 2] + +## Notes + +- [추가 컨텍스트, 디자인 문서 위치, 알려진 한계, 후속 작업 등 — 선택] + +Closes #N +``` + +### 섹션별 작성 규칙 + +**Summary** +- 2문장 이내 +- "이 PR은…" 같은 메타 도입 없이 바로 사실 진술 +- 좋은 예: "도둑 팀 콜드 스타트 시 지도가 회색 타일로 표시되는 #302 이슈를 회피한다. Cloud Map ID 코드 경로를 제거하고 기존 JSON 다크 스타일로 통일." +- 나쁜 예: "이 PR에서는 #302 이슈를 해결하기 위해 다양한 작업을 진행하였습니다." + +**Changes** +- 파일·기능 단위로 한 줄씩 +- 동사로 시작 ("제거", "단순화", "추가") +- 파일 경로는 백틱으로 감싸기 + +**Behavior Change** +- 사용자/리뷰어가 체감하는 동작 변화가 있을 때만 작성 +- 표 또는 Before/After 코드 블록 둘 중 하나 선택 +- 순수 리팩토링이라 동작 변화가 없으면 "동작 변화 없음" 한 줄로 명시 + +**Test Plan** +- 체크박스 형식 (리뷰어가 PR 페이지에서 클릭 가능) +- 자동(analyze/test) + 수동(시나리오) 분리 +- 수동 검증이 불가능한 경우 그 이유를 명시 + +**Notes** +- 디자인 문서 경로 (`.report/...md`), 후속 정리 항목, trade-off, 디자이너 검토 필요 여부 등 +- 없으면 섹션 자체 생략 + +**Closes** +- 마지막에 한 줄 — `Closes #302` 형식 +- 이슈 번호 없으면 생략 + +## 출력 형식 + +1. **PR 제목 제안 (한 줄)**: ` : <한글 설명> #N` +2. **PR 본문 마크다운** (체크박스·표·코드 블록 포함) +3. **사용 안내 한 줄**: 클립보드 복사 또는 `gh pr create --body "$(cat ...)"` 안내 + +```markdown +PR 제목 제안: +fix : Cloud Map ID 콜드 스타트 회색 타일 회피 #302 + +--- + +## Summary +... + +## Changes +... + +(이하 본문) +``` + +## 파일 저장 (선택) + +- 기본은 **출력만** — 사용자가 PR 작성 화면에 붙여넣음 +- 사용자가 명시적으로 저장을 원할 때만 `.pr/` 디렉토리에 저장 + - 경로: `.pr/{브랜치명}.md` + - `.pr/`가 `.gitignore`에 없으면 추가 안내 + +## 작성 예시 + +### 입력 +- 브랜치: `20260424_#302_게임_시작_시_지도_타일이_간헐적으로_로드되지_않음` +- 보고서: `.report/20260502_#302_지도_타일_미로드_수정.md` 존재 +- 커밋: `fix : Cloud Map ID 콜드 스타트 회색 타일 회피 JSON 다크 스타일로 통일 #302` + +### 출력 + +```markdown +PR 제목 제안: +fix : Cloud Map ID 콜드 스타트 회색 타일 회피 #302 + +--- + +## Summary + +도둑 팀 콜드 스타트 시 지도가 회색 타일로 영구 고착되는 #302 이슈를 회피한다. `cloudMapId` 코드 경로 자체를 제거하고 기존 JSON 다크 스타일(`MapStyles.dark`)로 통일했다. + +## Changes + +- `google_map_view.dart`: `mapId` 파라미터·`cloudMapId` 인자 제거, `style` 조건 단순화 +- `game_page.dart`: `mapId` 인자 + `EnvConfig` import 제거 +- `env_config.dart`: `googleMapsRobberMapId` getter + `dart:io Platform` import 제거 (dead code) +- `.env.example`: 미사용 OAuth 키 정리, `.env`와 키 구성 일치 + +순 변경량: +4 / -35 라인 + +## Behavior Change + +```dart +// Before — cloudMapId fetch 실패 시 회색 타일 영구 +GoogleMap( + cloudMapId: widget.mapId, + style: widget.mapId == null && widget.isDarkMode ? MapStyles.dark : null, +) + +// After — cloudMapId 자체 미사용, JSON 다크만 사용 +GoogleMap( + style: widget.isDarkMode ? MapStyles.dark : null, +) +``` + +| 항목 | Before | After | +|---|---|---| +| 도둑 다크 지도 | Cloud Map ID 다크 | `MapStyles.dark` JSON | +| 경찰 라이트 지도 | 변경 없음 | 변경 없음 | +| 콜드 스타트 회색 타일 | 발생 가능 | 발생 불가 (코드 경로 제거) | + +## Test Plan + +- [ ] `flutter analyze` 통과 +- [ ] 도둑 팀 콜드 스타트 5회 반복 → 회색 타일 미발생 +- [ ] 경찰 팀 콜드 스타트 → 라이트 지도 정상 표시 +- [ ] 도둑 팀 다크 지도 시각 디자이너 검토 + +## Notes + +- 디자인 검토에서 JSON 다크 스타일 시각이 수용 불가 시 `git revert` 후 옵션 2(SDK 워밍업)로 재검토 +- 상세 결정 근거: `.report/20260502_#302_지도_타일_미로드_수정.md` +- Google Cloud Console의 Map ID는 비용 0이라 즉시 삭제 불필요 + +Closes #302 +``` + +위 마크다운을 GitHub PR 본문에 붙여넣으세요. 또는: + +```bash +gh pr create --title "fix : Cloud Map ID 콜드 스타트 회색 타일 회피 #302" --body "$(cat <<'EOF' +... (위 본문) +EOF +)" +``` + +## 분석 효율성 원칙 + +- `git status` 1회 → 보고서 파일 우선 → 추가 git 명령 최소화 +- `.report/` 보고서가 있으면 그걸 핵심 소스로 사용 (이미 정제된 컨텍스트) +- 변경 파일 전체 diff를 읽지 말고 보고서·커밋 메시지 기반으로 추론 +- 의문 사항은 PR 본문에 `Notes` 섹션으로 명시 (추측 금지) + +## 출력 후 + +PR 본문 출력 후 한 줄 안내로 종료 — 추가 질문하지 않음. diff --git a/.claude/commands/rp.md b/.claude/commands/rp.md new file mode 100644 index 0000000..23dbcc5 --- /dev/null +++ b/.claude/commands/rp.md @@ -0,0 +1,129 @@ +# RP Mode - Report + PR Description 병렬 생성 + +`/report`와 `/pr-description`을 **병렬로 동시 실행**하여 한 번에 두 산출물을 생성한다. + +- `.report/{YYYYMMDD}_{ISSUE#}_{설명}.md` — 구현 보고서 +- `.pr/{YYYYMMDD}_{ISSUE#}_{설명}.md` — PR 본문 + +## 핵심 원칙 + +- **반드시 단일 메시지에서 두 Agent 호출을 동시에 디스패치** (병렬 tool use) +- Git 명령은 디스패치 전 1회만 실행 → 결과를 양쪽 Agent에 전달 (중복 호출 방지) +- ARGUMENTS로 들어온 이슈 본문/추가 컨텍스트가 있으면 양쪽 Agent 프롬프트에 그대로 포함 +- 두 Agent는 서로의 결과를 기다리지 않고 독립 작성 (동일 git 컨텍스트 기반) +- 두 작업 완료 후 저장 경로만 짧게 요약하고 종료 + +## 실행 절차 + +### 1단계: 컨텍스트 수집 (디스패치 전 1회) + +다음 명령을 병렬로 실행: + +```bash +git status +git log main..HEAD --oneline +git diff main --name-only +``` + +브랜치명에서 `#숫자` 추출 → 이슈 번호 확정. 없으면 ARGUMENTS에서 추출. + +### 2단계: 두 Agent 병렬 디스패치 (필수) + +**한 메시지에 Agent 호출 2개**를 함께 보냄. 절대 순차 실행하지 않음. + +#### Agent A — 보고서 생성 + +- `subagent_type`: `general-purpose` +- `description`: "Generate implementation report" +- `prompt`: 아래 템플릿 +- 반드시 Skill 도구로 `report` 스킬 호출 또는 `.claude/commands/report.md`의 지침을 그대로 따라 `.report/`에 파일 생성 + +``` +프로젝트: /Users/luca/workspace/greedy/quickness-game +브랜치: {브랜치명} +이슈 번호: #{N} + +# Git 컨텍스트 (이미 수집됨 — 추가 git 명령 실행 금지) +## git status +{git status 결과} + +## git log main..HEAD +{커밋 목록} + +## 변경 파일 목록 +{파일 목록} + +# 이슈/추가 컨텍스트 (사용자 ARGUMENTS) +{ARGUMENTS} + +# 작업 +`.claude/commands/report.md`에 정의된 Report Mode 지침을 그대로 따라 +`.report/{YYYYMMDD}_#{N}_{한글설명}.md` 파일을 생성하라. + +- 위 git 컨텍스트만 사용. 추가 git 명령 실행 금지. +- 변경 파일을 직접 Read해서 분석. +- 작성자/작성일/AI 관련 표현 금지. +- 시크릿은 마스킹. +- 완료 시 저장된 파일 경로 한 줄로 보고. +``` + +#### Agent B — PR 본문 생성 + +- `subagent_type`: `general-purpose` +- `description`: "Generate PR description" +- `prompt`: 아래 템플릿 + +``` +프로젝트: /Users/luca/workspace/greedy/quickness-game +브랜치: {브랜치명} +이슈 번호: #{N} + +# Git 컨텍스트 (이미 수집됨 — 추가 git 명령 실행 금지) +## git status +{git status 결과} + +## git log main..HEAD +{커밋 목록} + +## 변경 파일 목록 +{파일 목록} + +# 이슈/추가 컨텍스트 (사용자 ARGUMENTS) +{ARGUMENTS} + +# 기존 보고서 (있으면 우선 참조) +.report/ 디렉토리에 같은 이슈/날짜 매칭 보고서가 있으면 1차 소스로 활용. +없거나 본 세션에서 막 생성 중이라면 git 컨텍스트 + 변경 파일 직접 분석으로 작성. + +# 작업 +`.claude/commands/pr-description.md`에 정의된 PR Description Mode 지침을 따라 +`.pr/{YYYYMMDD}_#{N}_{한글설명}.md` 파일을 생성하라. + +- Summary / Changes / Behavior Change / Test Plan / Notes / Closes #N 구조. +- AI/작성자 메타 정보 금지. 시크릿 마스킹. +- 마지막 줄에 `Closes #{N}` 포함. +- 완료 시 저장된 파일 경로 + PR 제목 제안 한 줄로 보고. +``` + +### 3단계: 결과 요약 + +두 Agent 완료 후 다음 형식으로 한 번만 출력: + +``` +✅ 보고서: .report/{경로}.md +✅ PR 본문: .pr/{경로}.md + +PR 생성: +gh pr create --title "{type} : {제목} #{N}" --body-file ".pr/{경로}.md" +``` + +## 절대 금지 사항 + +- 두 Agent를 순차 호출하지 말 것 (반드시 한 메시지에 병렬) +- 디스패치 전 git 명령 외에 본 커맨드가 추가 파일 분석을 하지 말 것 (Agent에게 위임) +- AI/작성자 메타 정보 삽입 금지 +- 두 산출물 내용 차이를 임의로 줄이려고 하지 말 것 — 보고서는 "무엇을 했나", PR 본문은 "리뷰어용 요약"으로 역할이 다름 + +## 출력 후 + +저장 경로 2개 + `gh pr create` 안내 한 줄 출력 후 종료. 추가 질문 없음. diff --git a/.claude/commands/testcase.md b/.claude/commands/testcase.md index 62ca157..e69de29 100644 --- a/.claude/commands/testcase.md +++ b/.claude/commands/testcase.md @@ -1,373 +0,0 @@ -# Testcase Generator - -당신은 **QA 테스트케이스 작성 전문가**입니다. GitHub 이슈를 분석하여 테스트 체크리스트를 생성하세요. - -## 🔍 프로세스 - -### 1단계: 프로젝트 타입 자동 감지 - -다음 파일들을 확인하여 프로젝트 타입을 판단하세요: - -**Spring Boot (백엔드)** -- `build.gradle` 또는 `pom.xml` 존재 -- `src/main/java/` 디렉토리 -- Controller, Service, Repository 패턴 - -**React/React Native (프론트엔드)** -- `package.json` 존재 -- `react` 또는 `react-native` 의존성 -- JSX/TSX 파일 - -**Flutter (모바일)** -- `pubspec.yaml` 존재 -- `lib/` 디렉토리 -- `.dart` 파일 - -**Python (백엔드)** -- `requirements.txt` 또는 `pyproject.toml` -- Flask/FastAPI/Django 프레임워크 - -### 2단계: GitHub 이슈 파싱 - -사용자가 제공한 GitHub 이슈 내용에서 다음을 추출하세요: -- 이슈 번호 (예: `#407`) -- 이슈 제목 (예: `닉네임 및 프로필 사진 변경`) -- 도메인/카테고리 (예: `[회원]`, `[채팅]`) -- 담당자 정보 -- PR 링크 (있는 경우) - -### 3단계: 관련 코드 탐색 - -이슈의 도메인을 기반으로 관련 파일을 탐색하세요: - -**Spring Boot** -- Controller 파일 검색 (API 엔드포인트 확인) -- Service 로직 확인 -- DTO/Request/Response 구조 파악 - -**React/Flutter** -- 관련 컴포넌트/위젯 파일 -- API 호출 로직 -- 화면 구조 - -### 4단계: 테스트케이스 템플릿 생성 - -프로젝트 타입에 맞는 테스트케이스를 생성하세요. - ---- - -## 📋 테스트케이스 템플릿 - -### 🔹 Spring Boot (백엔드) - -```markdown -## 🧪 테스트 케이스: [기능명] - -**이슈**: #[번호] -**기능**: [기능 설명] -**API**: `[HTTP메서드] /api/[경로]` -**담당자**: @[담당자] - ---- - -### ✅ 1. 기본 기능 동작 확인 - -#### 1.1 정상 케이스 -- [ ] API 정상 호출 (200/201 응답) - - 테스트: [구체적인 요청 예시] - - 예상: [예상 응답] - - 실제: - - 증빙: - -- [ ] DB 데이터 정상 저장/조회 - - 테스트: [확인할 쿼리] - - 예상: [예상 결과] - - 실제: - - 증빙: - ---- - -### ⚠️ 2. 엣지 케이스 테스트 - -- [ ] 빈 값/null 파라미터 처리 - - 테스트: - - 예상: - - 실제: - -- [ ] 최대 길이 초과 입력 - - 테스트: - - 예상: - - 실제: - -- [ ] 중복 요청 (멱등성 확인) - - 테스트: - - 예상: - - 실제: - -- [ ] 존재하지 않는 리소스 조회 - - 테스트: - - 예상: - - 실제: - -- [ ] 타 사용자 리소스 접근 시도 - - 테스트: - - 예상: - - 실제: - ---- - -### 📄 3. Swagger 문서 확인 - -- [ ] API 엔드포인트 정확히 명시 -- [ ] Request 파라미터 타입/필수 여부 명시 -- [ ] Response 스키마 정의 -- [ ] 에러 응답 코드 문서화 (400, 401, 403, 404) - ---- - -### 📊 테스트 결과 요약 - -- **테스트 일자**: YYYY-MM-DD -- **테스터**: @username -- **테스트 환경**: [ ] local [ ] test [ ] prod -- **전체 결과**: [ ] ✅ PASS [ ] ❌ FAIL - -#### 발견된 이슈 -1. -2. -``` - -### 🔹 React/React Native (프론트엔드) - -```markdown -## 🧪 테스트 케이스: [기능명] - -**이슈**: #[번호] -**화면**: [화면명] -**담당자**: @[담당자] - ---- - -### ✅ 1. 기본 기능 동작 확인 - -#### 1.1 화면 렌더링 -- [ ] 화면 정상 표시 - - 테스트: - - 예상: - - 실제: - - 증빙: - -#### 1.2 사용자 인터랙션 -- [ ] 버튼 클릭 동작 - - 테스트: - - 예상: - - 실제: - -- [ ] 입력 필드 동작 - - 테스트: - - 예상: - - 실제: - -#### 1.3 API 연동 -- [ ] 데이터 로딩 - - 테스트: - - 예상: - - 실제: - -- [ ] 데이터 저장/수정 - - 테스트: - - 예상: - - 실제: - ---- - -### ⚠️ 2. 엣지 케이스 테스트 - -- [ ] 로딩 상태 표시 - - 테스트: - - 예상: - - 실제: - -- [ ] 빈 데이터 처리 - - 테스트: - - 예상: - - 실제: - -- [ ] 긴 텍스트 UI 깨짐 확인 - - 테스트: - - 예상: - - 실제: - -- [ ] 빠른 연속 클릭 방지 - - 테스트: - - 예상: - - 실제: - -- [ ] 네트워크 에러 처리 - - 테스트: - - 예상: - - 실제: - ---- - -### 🎨 3. UI/UX 확인 - -- [ ] 디자인 시안과 일치 -- [ ] 반응형 레이아웃 (다양한 화면 크기) -- [ ] 다크모드 지원 (있는 경우) -- [ ] 애니메이션/전환 효과 - ---- - -### 📊 테스트 결과 요약 - -- **테스트 일자**: YYYY-MM-DD -- **테스터**: @username -- **테스트 환경**: [ ] local [ ] dev [ ] prod -- **테스트 기기**: [디바이스/브라우저 정보] -- **전체 결과**: [ ] ✅ PASS [ ] ❌ FAIL - -#### 발견된 이슈 -1. -2. -``` - -### 🔹 Flutter (모바일) - -```markdown -## 🧪 테스트 케이스: [기능명] - -**이슈**: #[번호] -**화면**: [화면명] -**담당자**: @[담당자] - ---- - -### ✅ 1. 기본 기능 동작 확인 - -#### 1.1 화면 렌더링 -- [ ] 위젯 정상 표시 (Android) - - 테스트: - - 예상: - - 실제: - - 증빙: - -- [ ] 위젯 정상 표시 (iOS) - - 테스트: - - 예상: - - 실제: - - 증빙: - -#### 1.2 사용자 인터랙션 -- [ ] 터치/제스처 동작 - - 테스트: - - 예상: - - 실제: - -#### 1.3 데이터 처리 -- [ ] API 호출 및 데이터 표시 - - 테스트: - - 예상: - - 실제: - ---- - -### ⚠️ 2. 엣지 케이스 테스트 - -- [ ] 로딩 인디케이터 -- [ ] 빈 데이터 화면 -- [ ] 에러 화면 -- [ ] 네트워크 끊김 처리 -- [ ] 백그라운드 복귀 시 상태 유지 - ---- - -### 🎨 3. UI/UX 확인 - -- [ ] 디자인 시안 일치 -- [ ] 다양한 화면 크기 (Tablet/Phone) -- [ ] Android/iOS 플랫폼별 UI 차이 -- [ ] 애니메이션 부드러움 - ---- - -### 📊 테스트 결과 요약 - -- **테스트 일자**: YYYY-MM-DD -- **테스터**: @username -- **테스트 기기**: - - [ ] Android [버전] - - [ ] iOS [버전] -- **전체 결과**: [ ] ✅ PASS [ ] ❌ FAIL - -#### 발견된 이슈 -1. -2. -``` - ---- - -## 🎯 출력 규칙 - -### ⚠️ 필수: MD 파일 생성 - -**반드시 프로젝트 루트 경로에 마크다운 파일을 생성하세요!** - -- **파일명 형식**: `testcase-[이슈번호]-[간단한설명].md` - - 예: `testcase-407-닉네임변경.md` - - 예: `testcase-123-채팅기능.md` - -- **파일 위치**: 프로젝트 루트 디렉토리 (`.claude/`, `src/`와 같은 레벨) - -- **이유**: - - Cursor/Claude Code 내부에서 마크다운 출력만 하면 복사하기 어려움 - - 파일로 생성하면 GitHub 이슈 댓글에 바로 붙여넣기 가능 - - 테스트 이력 관리 용이 - -### 추가 출력 규칙 - -1. **프로젝트 타입 명시**: 감지된 타입을 명확히 표시 -2. **관련 파일 탐색**: Controller, API, 컴포넌트 등 관련 코드 찾기 -3. **구체적인 테스트 항목**: "닉네임 변경"이면 "닉네임 길이 제한(1~20자)" 등 구체화 -4. **GitHub 댓글용 마크다운**: 복사-붙여넣기 가능한 형식 -5. **간결함 유지**: 체크리스트 중심, 장황한 설명 지양 - -## 📌 특별 지시사항 - -- **백엔드**: Swagger 문서 완성도를 반드시 체크리스트에 포함 -- **프론트엔드**: UI/UX 시각적 확인 항목 포함 -- **공통**: 보안 관련 테스트 (권한, 인증) 자동 포함 -- **엣지 케이스**: 큰 값, 특수문자, 중복 호출 등 자동 생성 - ---- - -## 예시 - -**입력**: -``` -⚙️ [기능추가][회원] 닉네임 및 프로필 사진 변경 #407 -닉네임, 프로필 사진 변경하는 API 없음 -백엔드: @nayoung04 -``` - -**출력**: Spring Boot 테스트케이스 템플릿 + 관련 Controller 파일 경로 + 구체적인 엣지 케이스 (닉네임 중복, 파일 크기 제한 등) - ---- - -## ✅ 완료 후 사용자에게 안내 - -테스트케이스 파일 생성 완료 후 다음 메시지를 출력하세요: - -``` -✅ 테스트케이스 생성 완료! - -📄 파일 위치: `testcase-[번호]-[설명].md` - -GitHub 이슈에 댓글로 붙여넣는 방법: -1. 생성된 MD 파일 열기 -2. 전체 내용 복사 (Ctrl+A → Ctrl+C) -3. GitHub 이슈 댓글란에 붙여넣기 -4. 테스트 진행하면서 체크박스 체크 및 결과 작성 - -💡 팁: 파일을 Git에 커밋하면 테스트 이력 관리 가능합니다! -``` diff --git a/.gitignore b/.gitignore index 12ac412..d86f140 100644 --- a/.gitignore +++ b/.gitignore @@ -35,7 +35,7 @@ out/ ### VS Code ### .vscode/ - +.DS_Store *.properties !gradle/wrapper/gradle-wrapper.properties @@ -46,3 +46,12 @@ application-local.yml ### Claude Config (PAT 포함 민감 정보) ### .claude/config.json +.report/ +.pr/ + +### Firebase Admin SDK (민감 정보) ### +**/firebase/*.json +*-firebase-adminsdk-*.json + +### 이슈/PR 로컬 템플릿 산출물 ### +docs/suh-template/ diff --git a/.run/SpaceStudyShipApplication.run.xml b/.run/SpaceStudyShipApplication.run.xml new file mode 100644 index 0000000..56f3055 --- /dev/null +++ b/.run/SpaceStudyShipApplication.run.xml @@ -0,0 +1,10 @@ + + + + \ No newline at end of file diff --git a/CHANGELOG.json b/CHANGELOG.json index deb8357..fe31d79 100644 --- a/CHANGELOG.json +++ b/CHANGELOG.json @@ -1,11 +1,42 @@ { "metadata": { - "lastUpdated": "2026-04-22T12:14:38Z", - "currentVersion": "0.0.24", + "lastUpdated": "2026-06-09T06:16:10Z", + "currentVersion": "0.0.43", "projectType": "spring", - "totalReleases": 9 + "totalReleases": 10 }, "releases": [ + { + "version": "0.0.43", + "project_type": "spring", + "date": "2026-06-09", + "pr_number": 43, + "raw_summary": "## Summary by CodeRabbit\n\n## 릴리스 노트\n\n* **새 기능**\n * 행성과 지역을 탐험하며 단계별로 해금하는 기능 추가\n * 공부 시간을 측정하는 타이머 기록 기능 추가\n * 공부한 시간이 연료로 충전되는 시스템 추가\n * 할 일과 카테고리를 만들고 관리하는 기능 추가\n * 구글·카카오·애플 소셜 로그인 지원\n * 기기 정보와 푸시 알림 토큰 저장 기능 추가\n * 닉네임 중복 확인 및 변경 기능 추가\n * 회원 탈퇴 기능 추가\n * 오늘 통계에 누적·이번 달 공부 기록 표시\n\n* **버그 수정**\n * 로그인 직후 갑자기 로그아웃되던 문제 해결\n * 할 일 작성·수정 시각이 비어 보이던 문제 해결", + "parsed_changes": { + "새_기능": { + "title": "새 기능", + "items": [ + "행성과 지역을 탐험하며 단계별로 해금하는 기능 추가", + "공부 시간을 측정하는 타이머 기록 기능 추가", + "공부한 시간이 연료로 충전되는 시스템 추가", + "할 일과 카테고리를 만들고 관리하는 기능 추가", + "구글·카카오·애플 소셜 로그인 지원", + "기기 정보와 푸시 알림 토큰 저장 기능 추가", + "닉네임 중복 확인 및 변경 기능 추가", + "회원 탈퇴 기능 추가", + "오늘 통계에 누적·이번 달 공부 기록 표시" + ] + }, + "버그_수정": { + "title": "버그 수정", + "items": [ + "로그인 직후 갑자기 로그아웃되던 문제 해결", + "할 일 작성·수정 시각이 비어 보이던 문제 해결" + ] + } + }, + "parse_method": "markdown" + }, { "version": "0.0.24", "project_type": "spring", diff --git a/CHANGELOG.md b/CHANGELOG.md index 78a2eb2..13a0268 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,7 +1,28 @@ # Changelog -**현재 버전:** 0.0.24 -**마지막 업데이트:** 2026-04-22T12:14:38Z +**현재 버전:** 0.0.43 +**마지막 업데이트:** 2026-06-09T06:16:10Z + +--- + +## [0.0.43] - 2026-06-09 + +**PR:** #43 + +**새 기능** +- 행성과 지역을 탐험하며 단계별로 해금하는 기능 추가 +- 공부 시간을 측정하는 타이머 기록 기능 추가 +- 공부한 시간이 연료로 충전되는 시스템 추가 +- 할 일과 카테고리를 만들고 관리하는 기능 추가 +- 구글·카카오·애플 소셜 로그인 지원 +- 기기 정보와 푸시 알림 토큰 저장 기능 추가 +- 닉네임 중복 확인 및 변경 기능 추가 +- 회원 탈퇴 기능 추가 +- 오늘 통계에 누적·이번 달 공부 기록 표시 + +**버그 수정** +- 로그인 직후 갑자기 로그아웃되던 문제 해결 +- 할 일 작성·수정 시각이 비어 보이던 문제 해결 --- diff --git a/CLAUDE.md b/CLAUDE.md index f5371ee..5c98c02 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -221,7 +221,11 @@ log.warn("[Action] 경고 설명"); | 버전 | 파일 | 내용 | |------|------|------| -| (없음) | - | 초기 상태 — Entity 구현 후 첫 마이그레이션 파일 작성 | +| 0.0.31 | `V0_0_31__add_user_devices.sql` | 초기 스키마 — `members`, `user_devices` 테이블 생성 (FK 포함) | +| 0.0.34 | `V0_0_34__add_todos_and_categories.sql` | `todos`, `todo_categories` 테이블 생성 (FK CASCADE, JSONB 컬럼) | +| 0.0.36 | `V0_0_36__add_fuel.sql` | `user_fuel`, `fuel_transactions` 테이블 생성 (CHECK 제약, FK CASCADE) | +| 0.0.39 | `V0_0_39__add_timer_sessions.sql` | `timer_sessions` 테이블 생성 (FK CASCADE, CHECK 제약 3종, 부분 unique 인덱스 on idempotency_key) | +| 0.0.42 | `V0_0_42__add_exploration.sql` | `exploration_nodes`, `user_exploration_progress` 테이블 + 행성/지역 시드 38노드 (프론트 시드 미러, self-FK, FK CASCADE, UNIQUE) | --- diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..6d825e6 --- /dev/null +++ b/LICENSE @@ -0,0 +1,80 @@ +SpaceStudyShip Source Available License +Version 1.0, April 2026 + +Based on Elastic License 2.0 (ELv2) + +======================================================================= + +Acceptance + +By using the software, you agree to all of the terms and conditions +below. + +Copyright License + +The licensor grants you a non-exclusive, royalty-free, worldwide, +non-sublicensable, non-transferable license to use, copy, distribute, +make available, and prepare derivative works of the software, in each +case subject to the limitations and conditions below. + +Limitations + +You may not provide the software to third parties as a hosted or +managed service, where the service provides users with access to any +substantial set of the features or functionality of the software. + +You may not use the software for any commercial purpose, including but +not limited to operating a commercial service, selling the software, or +incorporating the software into a product or service that is sold or +offered for a fee. + +You may not alter, remove, or obscure any licensing, copyright, or +other notices of the licensor in the software. Any use of the licensor's +trademarks is subject to applicable law. + +Permitted Uses + +The following uses are expressly permitted: + +- Viewing, studying, and learning from the source code for personal + educational purposes. + +- Forking the repository and submitting contributions (pull requests) + back to the original project. + +- Running the software locally for personal, non-commercial use. + +Patents + +The licensor grants you a license, under any patent claims the licensor +can license, or becomes able to license, to make, have made, use, sell, +offer for sale, import and have imported the software, in each case +subject to the limitations and conditions in this license. This license +does not cover any patent claims that you cause to be infringed by +modifications or additions to the software. If you or your company make +any written claim that the software infringes or contributes to +infringement of any patent, your patent license for the software granted +under these terms ends immediately. If your company makes such a claim, +your patent license ends immediately for work on behalf of your company. + +Notices + +You must ensure that anyone who gets a copy of any part of the software +from you also gets a copy of these terms. + +If you modify the software, you must include in any modified copies of +the software prominent notices stating that you have modified the +software. + +No Other Rights + +These terms do not imply any licenses other than those expressly granted +in these terms. + +Termination + +If you use the software in violation of these terms, such use is not +licensed, and your licenses will automatically terminate. If the +licensor provides you with a notice of your violation, and you cease all +violation of this license no later than 30 days after you receive that +notice, your licenses will be reinstated retroactively. diff --git a/README.md b/README.md index 0e40b0e..9477d99 100644 --- a/README.md +++ b/README.md @@ -3,7 +3,7 @@ > 스터디 그룹 매칭 및 관리 서비스 백엔드 -## 최신 버전 : v0.0.23 (2026-04-22) +## 최신 버전 : v0.0.24 (2026-04-22) ## 기술 스택 diff --git a/SS-Auth/build.gradle b/SS-Auth/build.gradle index e9c6074..1f94eb5 100644 --- a/SS-Auth/build.gradle +++ b/SS-Auth/build.gradle @@ -15,4 +15,10 @@ dependencies { implementation 'io.jsonwebtoken:jjwt-api:0.12.6' runtimeOnly 'io.jsonwebtoken:jjwt-impl:0.12.6' runtimeOnly 'io.jsonwebtoken:jjwt-jackson:0.12.6' + + // Firebase Admin SDK + implementation 'com.google.firebase:firebase-admin:9.4.3' + + // Test + testRuntimeOnly 'com.h2database:h2' } diff --git a/SS-Auth/src/main/java/com/elipair/spacestudyship/auth/constant/DeviceType.java b/SS-Auth/src/main/java/com/elipair/spacestudyship/auth/constant/DeviceType.java new file mode 100644 index 0000000..2556f73 --- /dev/null +++ b/SS-Auth/src/main/java/com/elipair/spacestudyship/auth/constant/DeviceType.java @@ -0,0 +1,6 @@ +package com.elipair.spacestudyship.auth.constant; + +public enum DeviceType { + IOS, + ANDROID +} diff --git a/SS-Auth/src/main/java/com/elipair/spacestudyship/auth/dto/CheckNicknameRequest.java b/SS-Auth/src/main/java/com/elipair/spacestudyship/auth/dto/CheckNicknameRequest.java new file mode 100644 index 0000000..7904d23 --- /dev/null +++ b/SS-Auth/src/main/java/com/elipair/spacestudyship/auth/dto/CheckNicknameRequest.java @@ -0,0 +1,19 @@ +package com.elipair.spacestudyship.auth.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Pattern; +import jakarta.validation.constraints.Size; + +public record CheckNicknameRequest( + @Schema( + description = "확인할 닉네임. 2~10자, 한글/영문 대소문자/숫자만 허용. 공백·특수문자·이모지 불가.", + example = "우주탐험가", + minLength = 2, + maxLength = 10 + ) + @NotBlank(message = "닉네임은 필수입니다.") + @Size(min = 2, max = 10, message = "닉네임은 2자 이상 10자 이하여야 합니다.") + @Pattern(regexp = "^[가-힣a-zA-Z0-9]+$", message = "닉네임은 한글, 영문, 숫자만 사용할 수 있습니다.") + String nickname +) {} diff --git a/SS-Auth/src/main/java/com/elipair/spacestudyship/auth/dto/CheckNicknameResponse.java b/SS-Auth/src/main/java/com/elipair/spacestudyship/auth/dto/CheckNicknameResponse.java new file mode 100644 index 0000000..ad0b2ac --- /dev/null +++ b/SS-Auth/src/main/java/com/elipair/spacestudyship/auth/dto/CheckNicknameResponse.java @@ -0,0 +1,5 @@ +package com.elipair.spacestudyship.auth.dto; + +public record CheckNicknameResponse( + boolean available +) {} diff --git a/SS-Auth/src/main/java/com/elipair/spacestudyship/auth/dto/LoginRequest.java b/SS-Auth/src/main/java/com/elipair/spacestudyship/auth/dto/LoginRequest.java index 7c65c9b..d0482b4 100644 --- a/SS-Auth/src/main/java/com/elipair/spacestudyship/auth/dto/LoginRequest.java +++ b/SS-Auth/src/main/java/com/elipair/spacestudyship/auth/dto/LoginRequest.java @@ -1,10 +1,35 @@ package com.elipair.spacestudyship.auth.dto; +import com.elipair.spacestudyship.auth.constant.DeviceType; import com.elipair.spacestudyship.member.constant.SocialType; +import io.swagger.v3.oas.annotations.media.Schema; import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Pattern; +import jakarta.validation.constraints.Size; +@Schema(description = "소셜 로그인 요청 본문") public record LoginRequest( + @Schema(description = "소셜 로그인 플랫폼. 지원: GOOGLE, APPLE, KAKAO.", + example = "GOOGLE", requiredMode = Schema.RequiredMode.REQUIRED) @NotNull(message = "소셜 플랫폼 정보는 필수입니다.") SocialType socialType, - @NotBlank(message = "소셜 인증 토큰(ID Token)은 필수입니다.") String idToken + + @Schema(description = "Firebase에서 발급받은 ID Token.", + example = "eyJhbGciOiJSUzI1NiIs...", requiredMode = Schema.RequiredMode.REQUIRED) + @NotBlank(message = "소셜 인증 토큰(ID Token)은 필수입니다.") String idToken, + + @Schema(description = "Firebase Cloud Messaging 디바이스 토큰.", + example = "dK3mL9xRTp2...", requiredMode = Schema.RequiredMode.REQUIRED) + @NotBlank(message = "FCM 토큰은 필수입니다.") + @Size(max = 255, message = "FCM 토큰은 255자 이하여야 합니다.") String fcmToken, + + @Schema(description = "디바이스 OS 타입.", + example = "IOS", requiredMode = Schema.RequiredMode.REQUIRED) + @NotNull(message = "디바이스 타입은 필수입니다.") DeviceType deviceType, + + @Schema(description = "디바이스 고유 식별자(UUID).", + example = "550e8400-e29b-41d4-a716-446655440000", requiredMode = Schema.RequiredMode.REQUIRED) + @NotBlank(message = "디바이스 식별자는 필수입니다.") + @Size(max = 255, message = "디바이스 식별자는 255자 이하여야 합니다.") + @Pattern(regexp = "^[0-9a-fA-F-]{36}$", message = "디바이스 식별자는 UUID 형식이어야 합니다.") String deviceId ) {} diff --git a/SS-Auth/src/main/java/com/elipair/spacestudyship/auth/dto/LoginResponse.java b/SS-Auth/src/main/java/com/elipair/spacestudyship/auth/dto/LoginResponse.java index 044e9f3..b86834d 100644 --- a/SS-Auth/src/main/java/com/elipair/spacestudyship/auth/dto/LoginResponse.java +++ b/SS-Auth/src/main/java/com/elipair/spacestudyship/auth/dto/LoginResponse.java @@ -1,8 +1,24 @@ package com.elipair.spacestudyship.auth.dto; +import io.swagger.v3.oas.annotations.media.Schema; + +@Schema(description = "소셜 로그인 응답. 기존 회원이면 200, 신규 회원이면 201로 응답됩니다.") public record LoginResponse( + @Schema(description = "서버에서 부여한 회원 ID.", example = "1") Long memberId, + + @Schema( + description = "회원 닉네임. 신규 가입 시 서버가 랜덤 생성(형용사+명사+숫자 4자리 패턴).", + example = "민첩한괴도5308" + ) String nickname, + + @Schema(description = "JWT Access/Refresh Token 쌍.") Tokens tokens, + + @Schema( + description = "신규 가입 여부. true → 닉네임 설정 화면으로 이동 권장. false → 기존 회원, 홈 화면으로 이동.", + example = "false" + ) boolean isNewMember ) {} diff --git a/SS-Auth/src/main/java/com/elipair/spacestudyship/auth/dto/LogoutRequest.java b/SS-Auth/src/main/java/com/elipair/spacestudyship/auth/dto/LogoutRequest.java index dfbaf9c..3b6fc0a 100644 --- a/SS-Auth/src/main/java/com/elipair/spacestudyship/auth/dto/LogoutRequest.java +++ b/SS-Auth/src/main/java/com/elipair/spacestudyship/auth/dto/LogoutRequest.java @@ -1,7 +1,14 @@ package com.elipair.spacestudyship.auth.dto; +import io.swagger.v3.oas.annotations.media.Schema; import jakarta.validation.constraints.NotBlank; +@Schema(description = "로그아웃 요청 본문. 서버에 저장된 Refresh Token을 무효화하기 위해 전달합니다.") public record LogoutRequest( + @Schema( + description = "현재 디바이스의 Refresh Token. 서버는 이 토큰에서 memberId를 추출해 해당 세션을 삭제합니다.", + example = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxIiwiaWF0IjoxNzE...", + requiredMode = Schema.RequiredMode.REQUIRED + ) @NotBlank(message = "Refresh Token은 필수입니다.") String refreshToken ) {} diff --git a/SS-Auth/src/main/java/com/elipair/spacestudyship/auth/dto/RefreshTokenPayloadDto.java b/SS-Auth/src/main/java/com/elipair/spacestudyship/auth/dto/RefreshTokenPayloadDto.java new file mode 100644 index 0000000..d23cd45 --- /dev/null +++ b/SS-Auth/src/main/java/com/elipair/spacestudyship/auth/dto/RefreshTokenPayloadDto.java @@ -0,0 +1,3 @@ +package com.elipair.spacestudyship.auth.dto; + +public record RefreshTokenPayloadDto(Long memberId, String deviceId) {} diff --git a/SS-Auth/src/main/java/com/elipair/spacestudyship/auth/dto/ReissueRequest.java b/SS-Auth/src/main/java/com/elipair/spacestudyship/auth/dto/ReissueRequest.java index f1c739c..23350c7 100644 --- a/SS-Auth/src/main/java/com/elipair/spacestudyship/auth/dto/ReissueRequest.java +++ b/SS-Auth/src/main/java/com/elipair/spacestudyship/auth/dto/ReissueRequest.java @@ -1,7 +1,14 @@ package com.elipair.spacestudyship.auth.dto; +import io.swagger.v3.oas.annotations.media.Schema; import jakarta.validation.constraints.NotBlank; +@Schema(description = "토큰 재발급 요청 본문") public record ReissueRequest( + @Schema( + description = "현재 보유한 Refresh Token. 서버에서 검증 후 새 Access/Refresh Token을 발급합니다 (Refresh Token Rotation).", + example = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxIiwiaWF0IjoxNzE...", + requiredMode = Schema.RequiredMode.REQUIRED + ) @NotBlank(message = "Refresh Token은 필수입니다.") String refreshToken ) {} diff --git a/SS-Auth/src/main/java/com/elipair/spacestudyship/auth/dto/ReissueResponse.java b/SS-Auth/src/main/java/com/elipair/spacestudyship/auth/dto/ReissueResponse.java index a079d8e..813c17a 100644 --- a/SS-Auth/src/main/java/com/elipair/spacestudyship/auth/dto/ReissueResponse.java +++ b/SS-Auth/src/main/java/com/elipair/spacestudyship/auth/dto/ReissueResponse.java @@ -1,3 +1,9 @@ package com.elipair.spacestudyship.auth.dto; -public record ReissueResponse(Tokens tokens) {} +import io.swagger.v3.oas.annotations.media.Schema; + +@Schema(description = "토큰 재발급 응답. 새 Access/Refresh Token이 함께 발급되며, 클라이언트는 두 토큰 모두 교체 저장해야 합니다.") +public record ReissueResponse( + @Schema(description = "새로 발급된 JWT 토큰 쌍.") + Tokens tokens +) {} diff --git a/SS-Auth/src/main/java/com/elipair/spacestudyship/auth/dto/Tokens.java b/SS-Auth/src/main/java/com/elipair/spacestudyship/auth/dto/Tokens.java index 355894a..beeb247 100644 --- a/SS-Auth/src/main/java/com/elipair/spacestudyship/auth/dto/Tokens.java +++ b/SS-Auth/src/main/java/com/elipair/spacestudyship/auth/dto/Tokens.java @@ -1,3 +1,18 @@ package com.elipair.spacestudyship.auth.dto; -public record Tokens(String accessToken, String refreshToken) {} +import io.swagger.v3.oas.annotations.media.Schema; + +@Schema(description = "JWT 토큰 쌍. accessToken은 `Authorization: Bearer ` 헤더에 실어 보내고, refreshToken은 만료 시 재발급에 사용합니다.") +public record Tokens( + @Schema( + description = "JWT Access Token. 보호된 API 호출 시 Authorization 헤더에 사용.", + example = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxIiwiaWF0IjoxNzE..." + ) + String accessToken, + + @Schema( + description = "JWT Refresh Token. Access Token 만료 시 `POST /api/auth/reissue`로 재발급 받는 데 사용.", + example = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxIiwiaWF0IjoxNzE..." + ) + String refreshToken +) {} diff --git a/SS-Auth/src/main/java/com/elipair/spacestudyship/auth/dto/UpdateNicknameRequest.java b/SS-Auth/src/main/java/com/elipair/spacestudyship/auth/dto/UpdateNicknameRequest.java new file mode 100644 index 0000000..d372a9e --- /dev/null +++ b/SS-Auth/src/main/java/com/elipair/spacestudyship/auth/dto/UpdateNicknameRequest.java @@ -0,0 +1,21 @@ +package com.elipair.spacestudyship.auth.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Pattern; +import jakarta.validation.constraints.Size; + +@Schema(description = "닉네임 변경 요청 본문") +public record UpdateNicknameRequest( + @Schema( + description = "변경할 닉네임. 2~10자, 한글/영문 대소문자/숫자만 허용. 공백·특수문자·이모지 불가.", + example = "우주탐험가", + minLength = 2, + maxLength = 10, + requiredMode = Schema.RequiredMode.REQUIRED + ) + @NotBlank(message = "닉네임은 필수입니다.") + @Size(min = 2, max = 10, message = "닉네임은 2자 이상 10자 이하여야 합니다.") + @Pattern(regexp = "^[가-힣a-zA-Z0-9]+$", message = "닉네임은 한글, 영문, 숫자만 사용할 수 있습니다.") + String nickname +) {} diff --git a/SS-Auth/src/main/java/com/elipair/spacestudyship/auth/dto/UpdateNicknameResponse.java b/SS-Auth/src/main/java/com/elipair/spacestudyship/auth/dto/UpdateNicknameResponse.java new file mode 100644 index 0000000..fb77023 --- /dev/null +++ b/SS-Auth/src/main/java/com/elipair/spacestudyship/auth/dto/UpdateNicknameResponse.java @@ -0,0 +1,9 @@ +package com.elipair.spacestudyship.auth.dto; + +import io.swagger.v3.oas.annotations.media.Schema; + +@Schema(description = "닉네임 변경 응답. 변경된 닉네임을 반환합니다.") +public record UpdateNicknameResponse( + @Schema(description = "변경된 회원 닉네임.", example = "우주탐험가") + String nickname +) {} diff --git a/SS-Auth/src/main/java/com/elipair/spacestudyship/auth/entity/UserDevice.java b/SS-Auth/src/main/java/com/elipair/spacestudyship/auth/entity/UserDevice.java new file mode 100644 index 0000000..b65e993 --- /dev/null +++ b/SS-Auth/src/main/java/com/elipair/spacestudyship/auth/entity/UserDevice.java @@ -0,0 +1,70 @@ +package com.elipair.spacestudyship.auth.entity; + +import com.elipair.spacestudyship.auth.constant.DeviceType; +import com.elipair.spacestudyship.common.entity.BaseTimeEntity; +import jakarta.persistence.*; +import lombok.*; + +import java.time.LocalDateTime; + +@Entity +@Table( + name = "user_devices", + uniqueConstraints = @UniqueConstraint( + name = "uk_user_devices_member_device", + columnNames = {"member_id", "device_id"} + ), + indexes = @Index(name = "idx_user_devices_member", columnList = "member_id") +) +@Getter +@Builder +@AllArgsConstructor +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class UserDevice extends BaseTimeEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(name = "member_id", nullable = false) + private Long memberId; + + @Column(name = "device_id", nullable = false, length = 255) + private String deviceId; + + @Enumerated(EnumType.STRING) + @Column(name = "device_type", nullable = false, length = 10) + private DeviceType deviceType; + + @Column(name = "fcm_token", nullable = false, length = 255) + private String fcmToken; + + @Column(name = "refresh_token", nullable = false, length = 512) + private String refreshTokenHash; + + @Column(name = "last_login_at", nullable = false) + private LocalDateTime lastLoginAt; + + public static UserDevice register(Long memberId, String deviceId, DeviceType deviceType, + String fcmToken, String refreshTokenHash) { + return UserDevice.builder() + .memberId(memberId) + .deviceId(deviceId) + .deviceType(deviceType) + .fcmToken(fcmToken) + .refreshTokenHash(refreshTokenHash) + .lastLoginAt(LocalDateTime.now()) + .build(); + } + + public void renewLogin(DeviceType deviceType, String fcmToken, String refreshTokenHash) { + this.deviceType = deviceType; + this.fcmToken = fcmToken; + this.refreshTokenHash = refreshTokenHash; + this.lastLoginAt = LocalDateTime.now(); + } + + public void rotateRefreshTokenHash(String refreshTokenHash) { + this.refreshTokenHash = refreshTokenHash; + } +} diff --git a/SS-Auth/src/main/java/com/elipair/spacestudyship/auth/firebase/FirebaseConfig.java b/SS-Auth/src/main/java/com/elipair/spacestudyship/auth/firebase/FirebaseConfig.java new file mode 100644 index 0000000..f60f44a --- /dev/null +++ b/SS-Auth/src/main/java/com/elipair/spacestudyship/auth/firebase/FirebaseConfig.java @@ -0,0 +1,45 @@ +package com.elipair.spacestudyship.auth.firebase; + +import com.google.auth.oauth2.GoogleCredentials; +import com.google.firebase.FirebaseApp; +import com.google.firebase.FirebaseOptions; +import com.google.firebase.auth.FirebaseAuth; +import jakarta.annotation.PostConstruct; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.core.io.Resource; + +import java.io.IOException; +import java.io.InputStream; + +@Slf4j +@Configuration +@RequiredArgsConstructor +public class FirebaseConfig { + + @Value("${firebase.admin-sdk-path}") + private Resource credentialsResource; + + @PostConstruct + public void initializeFirebaseApp() throws IOException { + if (!FirebaseApp.getApps().isEmpty()) { + log.info("[FirebaseConfig] FirebaseApp 이미 초기화됨, 스킵"); + return; + } + try (InputStream stream = credentialsResource.getInputStream()) { + FirebaseOptions options = FirebaseOptions.builder() + .setCredentials(GoogleCredentials.fromStream(stream)) + .build(); + FirebaseApp.initializeApp(options); + log.info("[FirebaseConfig] FirebaseApp 초기화 완료"); + } + } + + @Bean + public FirebaseAuth firebaseAuth() { + return FirebaseAuth.getInstance(); + } +} diff --git a/SS-Auth/src/main/java/com/elipair/spacestudyship/auth/interceptor/AuthInterceptor.java b/SS-Auth/src/main/java/com/elipair/spacestudyship/auth/interceptor/AuthInterceptor.java index 29f189e..67129d4 100644 --- a/SS-Auth/src/main/java/com/elipair/spacestudyship/auth/interceptor/AuthInterceptor.java +++ b/SS-Auth/src/main/java/com/elipair/spacestudyship/auth/interceptor/AuthInterceptor.java @@ -7,10 +7,12 @@ import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; import org.springframework.http.HttpMethod; import org.springframework.stereotype.Component; import org.springframework.web.servlet.HandlerInterceptor; +@Slf4j @Component @RequiredArgsConstructor public class AuthInterceptor implements HandlerInterceptor { @@ -23,12 +25,25 @@ public boolean preHandle(HttpServletRequest request, HttpServletResponse respons return true; } - String accessToken = AuthorizationExtractor.extractToken(request) - .orElseThrow(() -> new CustomException(ErrorCode.UNAUTHENTICATED_REQUEST)); + String method = request.getMethod(); + String uri = request.getRequestURI(); + log.debug("[Auth] preHandle 진입 | method={}, uri={}", method, uri); - Long memberId = jwtTokenProvider.getMemberIdFromAccessToken(accessToken); - request.setAttribute("loginMember", new LoginMember(memberId)); + String accessToken = AuthorizationExtractor.extractToken(request) + .orElseThrow(() -> { + log.warn("[Auth] 인증 헤더 누락 | method={}, uri={}", method, uri); + return new CustomException(ErrorCode.UNAUTHENTICATED_REQUEST); + }); - return true; + try { + Long memberId = jwtTokenProvider.getMemberIdFromAccessToken(accessToken); + request.setAttribute("loginMember", new LoginMember(memberId)); + log.debug("[Auth] 인증 통과 | memberId={}, method={}, uri={}", memberId, method, uri); + return true; + } catch (CustomException e) { + log.warn("[Auth] 토큰 검증 실패 | code={}, method={}, uri={}", + e.getErrorCode().name(), method, uri); + throw e; + } } } diff --git a/SS-Auth/src/main/java/com/elipair/spacestudyship/auth/interceptor/AuthMember.java b/SS-Auth/src/main/java/com/elipair/spacestudyship/auth/interceptor/AuthMember.java index afbfb95..6eb976c 100644 --- a/SS-Auth/src/main/java/com/elipair/spacestudyship/auth/interceptor/AuthMember.java +++ b/SS-Auth/src/main/java/com/elipair/spacestudyship/auth/interceptor/AuthMember.java @@ -1,11 +1,22 @@ package com.elipair.spacestudyship.auth.interceptor; +import io.swagger.v3.oas.annotations.Parameter; + import java.lang.annotation.ElementType; import java.lang.annotation.Retention; import java.lang.annotation.RetentionPolicy; import java.lang.annotation.Target; +/** + * 로그인된 사용자의 {@link LoginMember}를 컨트롤러 파라미터에 주입한다. + *

+ * {@link io.swagger.v3.oas.annotations.Parameter @Parameter(hidden = true)} 메타 어노테이션으로 + * 인해 이 어노테이션이 붙은 파라미터는 Swagger 문서에 노출되지 않는다. + * 실제 인증 정보는 {@code Authorization: Bearer } 헤더로 전달되며, + * 서버 내부에서 토큰을 파싱하여 {@code LoginMember} 객체를 만든다. + */ @Target(ElementType.PARAMETER) @Retention(RetentionPolicy.RUNTIME) +@Parameter(hidden = true) public @interface AuthMember { } diff --git a/SS-Auth/src/main/java/com/elipair/spacestudyship/auth/jwt/JwtTokenProvider.java b/SS-Auth/src/main/java/com/elipair/spacestudyship/auth/jwt/JwtTokenProvider.java index aa3d7fe..3ae03fc 100644 --- a/SS-Auth/src/main/java/com/elipair/spacestudyship/auth/jwt/JwtTokenProvider.java +++ b/SS-Auth/src/main/java/com/elipair/spacestudyship/auth/jwt/JwtTokenProvider.java @@ -1,5 +1,6 @@ package com.elipair.spacestudyship.auth.jwt; +import com.elipair.spacestudyship.auth.dto.RefreshTokenPayloadDto; import com.elipair.spacestudyship.common.exception.CustomException; import com.elipair.spacestudyship.common.exception.ErrorCode; import com.elipair.spacestudyship.member.entity.Member; @@ -9,12 +10,14 @@ import io.jsonwebtoken.Jwts; import io.jsonwebtoken.io.Decoders; import io.jsonwebtoken.security.Keys; +import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Component; import javax.crypto.SecretKey; import java.util.Date; import java.util.Optional; +@Slf4j @Component public class JwtTokenProvider { @@ -44,12 +47,14 @@ public String createAccessToken(Member member) { Date now = new Date(); Date expiration = new Date(now.getTime() + jwtProperties.access().expiration().toMillis()); - return Jwts.builder() + String token = Jwts.builder() .subject(member.getId().toString()) .issuedAt(now) .expiration(expiration) .signWith(accessKey) .compact(); + log.info("[Jwt] AccessToken 발급 | memberId={}, expiresAt={}", member.getId(), expiration); + return token; } public Long getMemberIdFromAccessToken(String accessToken) { @@ -74,20 +79,52 @@ private Claims getAccessClaims(String accessToken) { // ===== Refresh Token ===== - public String createRefreshToken(Member member) { + private static final String CLAIM_DEVICE_ID = "did"; + + public String createRefreshToken(Member member, String deviceId) { + if (deviceId == null || deviceId.isBlank()) { + throw new CustomException(ErrorCode.INVALID_TOKEN); + } Date now = new Date(); Date expiration = new Date(now.getTime() + jwtProperties.refresh().expiration().toMillis()); - return Jwts.builder() + String token = Jwts.builder() .subject(member.getId().toString()) + .claim(CLAIM_DEVICE_ID, deviceId) .issuedAt(now) .expiration(expiration) .signWith(refreshKey) .compact(); + log.info("[Jwt] RefreshToken 발급 | memberId={}, deviceId={}, expiresAt={}", + member.getId(), deviceId, expiration); + return token; } - public Long getMemberIdFromRefreshToken(String refreshToken) { - return Long.valueOf(getRefreshClaims(refreshToken).getSubject()); + public RefreshTokenPayloadDto parseRefreshToken(String refreshToken) { + Claims claims = getRefreshClaims(refreshToken); + return toPayload(claims); + } + + /** + * 로그아웃 시 사용 - 만료된 토큰에서도 (memberId, deviceId) 추출 시도 + */ + public Optional parseRefreshTokenSafely(String refreshToken) { + try { + Claims claims = Jwts.parser() + .verifyWith(refreshKey) + .build() + .parseSignedClaims(refreshToken) + .getPayload(); + return Optional.of(toPayload(claims)); + } catch (ExpiredJwtException e) { + try { + return Optional.of(toPayload(e.getClaims())); + } catch (CustomException ex) { + return Optional.empty(); + } + } catch (CustomException | JwtException | IllegalArgumentException e) { + return Optional.empty(); + } } private Claims getRefreshClaims(String refreshToken) { @@ -106,22 +143,13 @@ private Claims getRefreshClaims(String refreshToken) { } } - /** - * 로그아웃 시 사용 - 만료된 토큰에서도 memberId 추출 시도 - */ - public Optional getMemberIdFromRefreshTokenSafely(String refreshToken) { - try { - Claims claims = Jwts.parser() - .verifyWith(refreshKey) - .build() - .parseSignedClaims(refreshToken) - .getPayload(); - return Optional.of(Long.valueOf(claims.getSubject())); - } catch (ExpiredJwtException e) { - return Optional.of(Long.valueOf(e.getClaims().getSubject())); - } catch (JwtException | IllegalArgumentException e) { - return Optional.empty(); + private RefreshTokenPayloadDto toPayload(Claims claims) { + Long memberId = Long.valueOf(claims.getSubject()); + String deviceId = claims.get(CLAIM_DEVICE_ID, String.class); + if (deviceId == null || deviceId.isBlank()) { + throw new CustomException(ErrorCode.INVALID_TOKEN); } + return new RefreshTokenPayloadDto(memberId, deviceId); } public long getRefreshTokenExpirationMillis() { diff --git a/SS-Auth/src/main/java/com/elipair/spacestudyship/auth/jwt/RefreshTokenHasher.java b/SS-Auth/src/main/java/com/elipair/spacestudyship/auth/jwt/RefreshTokenHasher.java new file mode 100644 index 0000000..f0ceab8 --- /dev/null +++ b/SS-Auth/src/main/java/com/elipair/spacestudyship/auth/jwt/RefreshTokenHasher.java @@ -0,0 +1,21 @@ +package com.elipair.spacestudyship.auth.jwt; + +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.util.HexFormat; + +public final class RefreshTokenHasher { + + private RefreshTokenHasher() {} + + public static String hash(String refreshToken) { + try { + MessageDigest digest = MessageDigest.getInstance("SHA-256"); + byte[] bytes = digest.digest(refreshToken.getBytes(StandardCharsets.UTF_8)); + return HexFormat.of().formatHex(bytes); + } catch (NoSuchAlgorithmException e) { + throw new IllegalStateException("SHA-256 algorithm not available", e); + } + } +} diff --git a/SS-Auth/src/main/java/com/elipair/spacestudyship/auth/repository/RefreshTokenRepository.java b/SS-Auth/src/main/java/com/elipair/spacestudyship/auth/repository/RefreshTokenRepository.java deleted file mode 100644 index 2fcf589..0000000 --- a/SS-Auth/src/main/java/com/elipair/spacestudyship/auth/repository/RefreshTokenRepository.java +++ /dev/null @@ -1,31 +0,0 @@ -package com.elipair.spacestudyship.auth.repository; - -import lombok.RequiredArgsConstructor; -import org.springframework.data.redis.core.StringRedisTemplate; -import org.springframework.stereotype.Repository; - -import java.time.Duration; -import java.util.Optional; - -@Repository -@RequiredArgsConstructor -public class RefreshTokenRepository { - - private static final String KEY_PREFIX = "refresh_token:"; - - private final StringRedisTemplate redisTemplate; - - public void save(Long memberId, String refreshToken, long ttlMillis) { - String key = KEY_PREFIX + memberId; - redisTemplate.opsForValue().set(key, refreshToken, Duration.ofMillis(ttlMillis)); - } - - public Optional findByMemberId(Long memberId) { - return Optional.ofNullable(redisTemplate.opsForValue().get(KEY_PREFIX + memberId)); - } - - public void delete(Long memberId) { - String key = KEY_PREFIX + memberId; - redisTemplate.delete(key); - } -} diff --git a/SS-Auth/src/main/java/com/elipair/spacestudyship/auth/repository/UserDeviceRepository.java b/SS-Auth/src/main/java/com/elipair/spacestudyship/auth/repository/UserDeviceRepository.java new file mode 100644 index 0000000..f4a44ef --- /dev/null +++ b/SS-Auth/src/main/java/com/elipair/spacestudyship/auth/repository/UserDeviceRepository.java @@ -0,0 +1,24 @@ +package com.elipair.spacestudyship.auth.repository; + +import com.elipair.spacestudyship.auth.entity.UserDevice; +import com.elipair.spacestudyship.common.exception.CustomException; +import com.elipair.spacestudyship.common.exception.ErrorCode; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Modifying; + +import java.util.Optional; + +public interface UserDeviceRepository extends JpaRepository { + + Optional findByMemberIdAndDeviceId(Long memberId, String deviceId); + + @Modifying + void deleteByMemberIdAndDeviceId(Long memberId, String deviceId); + + long countByMemberId(Long memberId); + + default UserDevice getByMemberIdAndDeviceId(Long memberId, String deviceId) { + return findByMemberIdAndDeviceId(memberId, deviceId) + .orElseThrow(() -> new CustomException(ErrorCode.INVALID_TOKEN)); + } +} diff --git a/SS-Auth/src/main/java/com/elipair/spacestudyship/auth/service/AuthService.java b/SS-Auth/src/main/java/com/elipair/spacestudyship/auth/service/AuthService.java index 3ed02a9..f88ab17 100644 --- a/SS-Auth/src/main/java/com/elipair/spacestudyship/auth/service/AuthService.java +++ b/SS-Auth/src/main/java/com/elipair/spacestudyship/auth/service/AuthService.java @@ -1,16 +1,25 @@ package com.elipair.spacestudyship.auth.service; import com.elipair.spacestudyship.auth.dto.*; +import com.elipair.spacestudyship.auth.entity.UserDevice; +import com.elipair.spacestudyship.auth.dto.RefreshTokenPayloadDto; import com.elipair.spacestudyship.auth.jwt.JwtTokenProvider; -import com.elipair.spacestudyship.auth.repository.RefreshTokenRepository; +import com.elipair.spacestudyship.auth.jwt.RefreshTokenHasher; +import com.elipair.spacestudyship.auth.repository.UserDeviceRepository; import com.elipair.spacestudyship.auth.social.SocialLoginStrategy; import com.elipair.spacestudyship.common.exception.CustomException; import com.elipair.spacestudyship.common.exception.ErrorCode; import com.elipair.spacestudyship.member.entity.Member; import com.elipair.spacestudyship.member.constant.SocialType; +import com.elipair.spacestudyship.member.event.MemberCreatedEvent; import com.elipair.spacestudyship.member.repository.MemberRepository; +import com.google.firebase.auth.AuthErrorCode; +import com.google.firebase.auth.FirebaseAuth; +import com.google.firebase.auth.FirebaseAuthException; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; +import org.springframework.context.ApplicationEventPublisher; +import org.springframework.dao.DataIntegrityViolationException; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -22,25 +31,60 @@ public class AuthService { private static final int MAXIMUM_NICKNAME_GENERATE_RETRY_COUNT = 10; + private static final int MAX_DEVICES_PER_MEMBER = 10; private final MemberRepository memberRepository; - private final RefreshTokenRepository refreshTokenRepository; + private final UserDeviceRepository userDeviceRepository; private final JwtTokenProvider jwtTokenProvider; private final RandomNicknameGenerator randomNicknameGenerator; private final Map socialLoginStrategies; + private final FirebaseAuth firebaseAuth; + private final ApplicationEventPublisher eventPublisher; /** * 소셜 로그인 * - 신규 회원: 랜덤 닉네임 부여 후 DB insert * - 기존 회원: 토큰만 재발급 + * - 디바이스 정보(fcmToken, deviceType, deviceId)를 user_devices에 upsert */ @Transactional public LoginResponse login(LoginRequest request) { String socialId = getSocialId(request.socialType(), request.idToken()); AuthMemberDto authMemberData = findOrRegisterMember(socialId, request.socialType()); Member member = authMemberData.member(); - Tokens tokens = issueTokens(member); - return new LoginResponse(member.getId(), member.getNickname(), tokens, authMemberData.isNewMember()); + + String accessToken = jwtTokenProvider.createAccessToken(member); + String refreshToken = jwtTokenProvider.createRefreshToken(member, request.deviceId()); + String refreshTokenHash = RefreshTokenHasher.hash(refreshToken); + + upsertUserDevice(member.getId(), request, refreshTokenHash); + + return new LoginResponse(member.getId(), member.getNickname(), + new Tokens(accessToken, refreshToken), authMemberData.isNewMember()); + } + + private void upsertUserDevice(Long memberId, LoginRequest request, String refreshTokenHash) { + userDeviceRepository.findByMemberIdAndDeviceId(memberId, request.deviceId()) + .ifPresentOrElse( + device -> device.renewLogin(request.deviceType(), request.fcmToken(), refreshTokenHash), + () -> registerNewDevice(memberId, request, refreshTokenHash) + ); + } + + /** + * 신규 디바이스 등록은 회원 단위 pessimistic lock으로 직렬화하여 + * 동시 로그인 시 디바이스 한도(MAX_DEVICES_PER_MEMBER) 초과를 방지한다. + */ + private void registerNewDevice(Long memberId, LoginRequest request, String refreshTokenHash) { + memberRepository.findByIdForUpdate(memberId) + .orElseThrow(() -> new CustomException(ErrorCode.MEMBER_NOT_FOUND)); + + if (userDeviceRepository.countByMemberId(memberId) >= MAX_DEVICES_PER_MEMBER) { + throw new CustomException(ErrorCode.DEVICE_LIMIT_EXCEEDED); + } + userDeviceRepository.save(UserDevice.register( + memberId, request.deviceId(), request.deviceType(), + request.fcmToken(), refreshTokenHash)); } private String getSocialId(SocialType socialType, String idToken) { @@ -58,6 +102,7 @@ private AuthMemberDto findOrRegisterMember(String socialId, SocialType socialTyp String nickname = generateUniqueNickname(); Member newMember = Member.signUp(socialId, socialType, nickname); memberRepository.save(newMember); + eventPublisher.publishEvent(new MemberCreatedEvent(newMember.getId())); log.info("[SignUp] 신규 회원가입 성공 | memberId={}, nickname={}, socialType={}", newMember.getId(), nickname, socialType); @@ -80,45 +125,100 @@ private String generateUniqueNickname() { return nickname; } - private Tokens issueTokens(Member member) { - String accessToken = jwtTokenProvider.createAccessToken(member); - String refreshToken = jwtTokenProvider.createRefreshToken(member); - refreshTokenRepository.save(member.getId(), refreshToken, jwtTokenProvider.getRefreshTokenExpirationMillis()); - return new Tokens(accessToken, refreshToken); - } - /** - * Access Token 재발급 + * Access Token 재발급 (디바이스 단위) */ @Transactional public ReissueResponse reissue(ReissueRequest request) { - String refreshToken = request.refreshToken(); - Long memberId = jwtTokenProvider.getMemberIdFromRefreshToken(refreshToken); - String storedRefreshToken = refreshTokenRepository.findByMemberId(memberId).orElse(null); + RefreshTokenPayloadDto payload = jwtTokenProvider.parseRefreshToken(request.refreshToken()); - if (storedRefreshToken == null) { - log.warn("[Security] Refresh Token 재사용 감지 - 잠재적 탈취 | memberId={}", memberId); - throw new CustomException(ErrorCode.INVALID_TOKEN); - } + UserDevice device = userDeviceRepository + .findByMemberIdAndDeviceId(payload.memberId(), payload.deviceId()) + .orElseThrow(() -> new CustomException(ErrorCode.INVALID_TOKEN)); - if (!refreshToken.equals(storedRefreshToken)) { - refreshTokenRepository.delete(memberId); - log.warn("[Security] Refresh Token 불일치 - 강제 로그아웃 처리 | memberId={}", memberId); + if (!device.getRefreshTokenHash().equals(RefreshTokenHasher.hash(request.refreshToken()))) { + userDeviceRepository.delete(device); + log.warn("[Security] Refresh Token 불일치 - 강제 로그아웃 처리 | memberId={}, deviceId={}", + payload.memberId(), payload.deviceId()); throw new CustomException(ErrorCode.INVALID_TOKEN); } - refreshTokenRepository.delete(memberId); - Member member = memberRepository.getByMemberId(memberId); - return new ReissueResponse(issueTokens(member)); + Member member = memberRepository.getByMemberId(payload.memberId()); + String newAccess = jwtTokenProvider.createAccessToken(member); + String newRefresh = jwtTokenProvider.createRefreshToken(member, payload.deviceId()); + + device.rotateRefreshTokenHash(RefreshTokenHasher.hash(newRefresh)); + return new ReissueResponse(new Tokens(newAccess, newRefresh)); } /** - * 로그아웃 - 저장된 Refresh Token 삭제 + * 로그아웃 - 해당 디바이스 row만 삭제. 다른 디바이스 세션 영향 없음. */ @Transactional public void logout(String refreshToken) { - jwtTokenProvider.getMemberIdFromRefreshTokenSafely(refreshToken) - .ifPresent(refreshTokenRepository::delete); + jwtTokenProvider.parseRefreshTokenSafely(refreshToken) + .ifPresent(payload -> userDeviceRepository + .deleteByMemberIdAndDeviceId(payload.memberId(), payload.deviceId())); + } + + /** + * 닉네임 중복 확인 + */ + @Transactional(readOnly = true) + public CheckNicknameResponse checkNickname(String nickname) { + boolean exists = memberRepository.existsByNickname(nickname); + return new CheckNicknameResponse(!exists); + } + + /** + * 닉네임 변경 + */ + @Transactional + public UpdateNicknameResponse updateNickname(Long memberId, UpdateNicknameRequest request) { + Member member = memberRepository.getByMemberId(memberId); + String newNickname = request.nickname(); + + if (member.getNickname().equals(newNickname)) { + return new UpdateNicknameResponse(member.getNickname()); + } + + if (memberRepository.existsByNickname(newNickname)) { + throw new CustomException(ErrorCode.DUPLICATED_NICKNAME); + } + + try { + member.updateNickname(newNickname); + memberRepository.flush(); + } catch (DataIntegrityViolationException e) { + throw new CustomException(ErrorCode.DUPLICATED_NICKNAME); + } + return new UpdateNicknameResponse(member.getNickname()); } + /** + * 회원 탈퇴 - DB(members) 삭제 + FK CASCADE로 user_devices 자동 삭제 + Firebase 사용자 삭제. + * Firebase 예외는 멱등성 유지를 위해 모두 무시(로그만 기록). + */ + @Transactional + public void withdraw(Long memberId) { + Member member = memberRepository.findById(memberId).orElse(null); + if (member != null) { + memberRepository.delete(member); + deleteFirebaseUserSafely(memberId, member.getSocialId()); + } + } + + private void deleteFirebaseUserSafely(Long memberId, String socialId) { + try { + firebaseAuth.deleteUser(socialId); + } catch (FirebaseAuthException e) { + if (e.getAuthErrorCode() == AuthErrorCode.USER_NOT_FOUND) { + log.warn("[Withdraw] Firebase 사용자 이미 없음 | memberId={}, socialId={}", + memberId, socialId); + } else { + log.error("[Withdraw] Firebase 사용자 삭제 실패 | memberId={}, socialId={}, error={}", + memberId, socialId, e.getMessage()); + } + } + } } diff --git a/SS-Auth/src/main/java/com/elipair/spacestudyship/auth/social/AppleLoginStrategy.java b/SS-Auth/src/main/java/com/elipair/spacestudyship/auth/social/AppleLoginStrategy.java index edeb4e1..54d5d80 100644 --- a/SS-Auth/src/main/java/com/elipair/spacestudyship/auth/social/AppleLoginStrategy.java +++ b/SS-Auth/src/main/java/com/elipair/spacestudyship/auth/social/AppleLoginStrategy.java @@ -1,18 +1,29 @@ package com.elipair.spacestudyship.auth.social; +import com.elipair.spacestudyship.common.exception.CustomException; +import com.elipair.spacestudyship.common.exception.ErrorCode; import com.elipair.spacestudyship.member.constant.SocialType; +import com.google.firebase.auth.FirebaseAuth; +import com.google.firebase.auth.FirebaseAuthException; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Component; -import java.util.concurrent.ThreadLocalRandom; - +@Slf4j @Component +@RequiredArgsConstructor public class AppleLoginStrategy implements SocialLoginStrategy { + private final FirebaseAuth firebaseAuth; + @Override public String validateAndGetSocialId(String socialIdToken) { - // TODO: 애플 로그인 연동 구현 - ThreadLocalRandom random = ThreadLocalRandom.current(); - return "APPLE_SOCIAL_ID_" + random.nextInt(100_000); + try { + return firebaseAuth.verifyIdToken(socialIdToken).getUid(); + } catch (FirebaseAuthException e) { + log.warn("[AppleLogin] Firebase 토큰 검증 실패 | reason={}", e.getMessage()); + throw new CustomException(ErrorCode.INVALID_TOKEN); + } } @Override diff --git a/SS-Auth/src/main/java/com/elipair/spacestudyship/auth/social/GoogleLoginStrategy.java b/SS-Auth/src/main/java/com/elipair/spacestudyship/auth/social/GoogleLoginStrategy.java index f0905f2..8802ad2 100644 --- a/SS-Auth/src/main/java/com/elipair/spacestudyship/auth/social/GoogleLoginStrategy.java +++ b/SS-Auth/src/main/java/com/elipair/spacestudyship/auth/social/GoogleLoginStrategy.java @@ -1,18 +1,29 @@ package com.elipair.spacestudyship.auth.social; +import com.elipair.spacestudyship.common.exception.CustomException; +import com.elipair.spacestudyship.common.exception.ErrorCode; import com.elipair.spacestudyship.member.constant.SocialType; +import com.google.firebase.auth.FirebaseAuth; +import com.google.firebase.auth.FirebaseAuthException; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Component; -import java.util.concurrent.ThreadLocalRandom; - +@Slf4j @Component +@RequiredArgsConstructor public class GoogleLoginStrategy implements SocialLoginStrategy { + private final FirebaseAuth firebaseAuth; + @Override public String validateAndGetSocialId(String socialIdToken) { - // TODO: 구글 로그인 연동 구현 - ThreadLocalRandom random = ThreadLocalRandom.current(); - return "GOOGLE_SOCIAL_ID_" + random.nextInt(100_000); + try { + return firebaseAuth.verifyIdToken(socialIdToken).getUid(); + } catch (FirebaseAuthException e) { + log.warn("[GoogleLogin] Firebase 토큰 검증 실패 | reason={}", e.getMessage()); + throw new CustomException(ErrorCode.INVALID_TOKEN); + } } @Override diff --git a/SS-Auth/src/main/java/com/elipair/spacestudyship/auth/social/KakaoLoginStrategy.java b/SS-Auth/src/main/java/com/elipair/spacestudyship/auth/social/KakaoLoginStrategy.java index f445295..075e84c 100644 --- a/SS-Auth/src/main/java/com/elipair/spacestudyship/auth/social/KakaoLoginStrategy.java +++ b/SS-Auth/src/main/java/com/elipair/spacestudyship/auth/social/KakaoLoginStrategy.java @@ -1,19 +1,29 @@ package com.elipair.spacestudyship.auth.social; +import com.elipair.spacestudyship.common.exception.CustomException; +import com.elipair.spacestudyship.common.exception.ErrorCode; import com.elipair.spacestudyship.member.constant.SocialType; +import com.google.firebase.auth.FirebaseAuth; +import com.google.firebase.auth.FirebaseAuthException; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Component; -import java.util.concurrent.ThreadLocalRandom; - +@Slf4j @Component +@RequiredArgsConstructor public class KakaoLoginStrategy implements SocialLoginStrategy { + private final FirebaseAuth firebaseAuth; + @Override public String validateAndGetSocialId(String socialIdToken) { - // TODO: 카카오 로그인 연동 구현 - // 클라이언트에서 받은 idToken으로 카카오 리소스 서버로부터 socialId를 가져온다. - ThreadLocalRandom random = ThreadLocalRandom.current(); - return "KAKAO_SOCIAL_ID_" + random.nextInt(100_000); + try { + return firebaseAuth.verifyIdToken(socialIdToken).getUid(); + } catch (FirebaseAuthException e) { + log.warn("[KakaoLogin] Firebase 토큰 검증 실패 | reason={}", e.getMessage()); + throw new CustomException(ErrorCode.INVALID_TOKEN); + } } @Override diff --git a/SS-Auth/src/test/java/com/elipair/spacestudyship/auth/TestAuthApplication.java b/SS-Auth/src/test/java/com/elipair/spacestudyship/auth/TestAuthApplication.java new file mode 100644 index 0000000..0d88a5e --- /dev/null +++ b/SS-Auth/src/test/java/com/elipair/spacestudyship/auth/TestAuthApplication.java @@ -0,0 +1,24 @@ +package com.elipair.spacestudyship.auth; + +import org.springframework.boot.autoconfigure.AutoConfigurationPackage; +import org.springframework.boot.autoconfigure.ImportAutoConfiguration; +import org.springframework.boot.data.jpa.autoconfigure.DataJpaRepositoriesAutoConfiguration; +import org.springframework.boot.hibernate.autoconfigure.HibernateJpaAutoConfiguration; +import org.springframework.boot.jdbc.autoconfigure.DataSourceAutoConfiguration; +import org.springframework.boot.jdbc.autoconfigure.JdbcTemplateAutoConfiguration; +import org.springframework.boot.transaction.autoconfigure.TransactionAutoConfiguration; +import org.springframework.context.annotation.Configuration; +import org.springframework.data.jpa.repository.config.EnableJpaRepositories; + +@Configuration +@AutoConfigurationPackage(basePackages = "com.elipair.spacestudyship") +@ImportAutoConfiguration({ + DataSourceAutoConfiguration.class, + JdbcTemplateAutoConfiguration.class, + HibernateJpaAutoConfiguration.class, + TransactionAutoConfiguration.class, + DataJpaRepositoriesAutoConfiguration.class +}) +@EnableJpaRepositories(basePackages = "com.elipair.spacestudyship.auth.repository") +public class TestAuthApplication { +} diff --git a/SS-Auth/src/test/java/com/elipair/spacestudyship/auth/jwt/JwtTokenProviderTest.java b/SS-Auth/src/test/java/com/elipair/spacestudyship/auth/jwt/JwtTokenProviderTest.java new file mode 100644 index 0000000..42163c3 --- /dev/null +++ b/SS-Auth/src/test/java/com/elipair/spacestudyship/auth/jwt/JwtTokenProviderTest.java @@ -0,0 +1,90 @@ +package com.elipair.spacestudyship.auth.jwt; + +import com.elipair.spacestudyship.auth.dto.RefreshTokenPayloadDto; +import com.elipair.spacestudyship.common.exception.CustomException; +import com.elipair.spacestudyship.common.exception.ErrorCode; +import com.elipair.spacestudyship.member.constant.SocialType; +import com.elipair.spacestudyship.member.entity.Member; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.time.Duration; +import java.util.Base64; +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +class JwtTokenProviderTest { + + private JwtTokenProvider jwtTokenProvider; + private Member member; + + @BeforeEach + void setUp() { + String accessSecret = Base64.getEncoder().encodeToString( + "test-access-secret-with-32-bytes-or-more-length-padding".getBytes()); + String refreshSecret = Base64.getEncoder().encodeToString( + "test-refresh-secret-with-32-bytes-or-more-length-padding".getBytes()); + + JwtProperties props = new JwtProperties( + new JwtProperties.TokenInfo(accessSecret, Duration.ofMinutes(30)), + new JwtProperties.TokenInfo(refreshSecret, Duration.ofDays(14)) + ); + jwtTokenProvider = new JwtTokenProvider(props); + + member = Member.builder() + .id(42L) + .socialId("social-id") + .socialType(SocialType.GOOGLE) + .nickname("테스터") + .build(); + } + + @Test + @DisplayName("createRefreshToken: deviceId claim 포함하여 발급, parseRefreshToken으로 추출 가능") + void createAndParseRefreshToken() { + String deviceId = "device-uuid-123"; + String token = jwtTokenProvider.createRefreshToken(member, deviceId); + RefreshTokenPayloadDto payload = jwtTokenProvider.parseRefreshToken(token); + + assertThat(payload.memberId()).isEqualTo(42L); + assertThat(payload.deviceId()).isEqualTo(deviceId); + } + + @Test + @DisplayName("parseRefreshToken: 위변조된 토큰은 INVALID_TOKEN 예외") + void parseRefreshToken_invalid() { + assertThatThrownBy(() -> jwtTokenProvider.parseRefreshToken("not-a-jwt")) + .isInstanceOf(CustomException.class) + .extracting("errorCode").isEqualTo(ErrorCode.INVALID_TOKEN); + } + + @Test + @DisplayName("parseRefreshTokenSafely: 정상 토큰 → Optional 값 반환") + void parseRefreshTokenSafely_valid() { + String token = jwtTokenProvider.createRefreshToken(member, "device-1"); + + Optional result = jwtTokenProvider.parseRefreshTokenSafely(token); + + assertThat(result).isPresent(); + assertThat(result.get().memberId()).isEqualTo(42L); + assertThat(result.get().deviceId()).isEqualTo("device-1"); + } + + @Test + @DisplayName("parseRefreshTokenSafely: 위변조 토큰 → Optional.empty") + void parseRefreshTokenSafely_invalid() { + Optional result = jwtTokenProvider.parseRefreshTokenSafely("garbage"); + assertThat(result).isEmpty(); + } + + @Test + @DisplayName("createAccessToken / getMemberIdFromAccessToken: Access Token 동작 변경 없음") + void accessTokenStillWorks() { + String accessToken = jwtTokenProvider.createAccessToken(member); + Long extracted = jwtTokenProvider.getMemberIdFromAccessToken(accessToken); + assertThat(extracted).isEqualTo(42L); + } +} diff --git a/SS-Auth/src/test/java/com/elipair/spacestudyship/auth/repository/UserDeviceRepositoryTest.java b/SS-Auth/src/test/java/com/elipair/spacestudyship/auth/repository/UserDeviceRepositoryTest.java new file mode 100644 index 0000000..b1de457 --- /dev/null +++ b/SS-Auth/src/test/java/com/elipair/spacestudyship/auth/repository/UserDeviceRepositoryTest.java @@ -0,0 +1,139 @@ +package com.elipair.spacestudyship.auth.repository; + +import com.elipair.spacestudyship.auth.TestAuthApplication; +import com.elipair.spacestudyship.auth.constant.DeviceType; +import com.elipair.spacestudyship.auth.entity.UserDevice; +import com.elipair.spacestudyship.common.exception.CustomException; +import com.elipair.spacestudyship.common.exception.ErrorCode; +import com.elipair.spacestudyship.member.constant.SocialType; +import com.elipair.spacestudyship.member.entity.Member; +import jakarta.persistence.EntityManager; +import jakarta.persistence.PersistenceContext; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.dao.DataIntegrityViolationException; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.test.annotation.DirtiesContext; +import org.springframework.transaction.annotation.Transactional; + +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +@SpringBootTest(classes = TestAuthApplication.class) +@Transactional +class UserDeviceRepositoryTest { + + @Autowired + UserDeviceRepository userDeviceRepository; + + @Autowired + JdbcTemplate jdbcTemplate; + + @PersistenceContext + EntityManager entityManager; + + @Test + @DisplayName("findByMemberIdAndDeviceId: 존재하는 row 조회") + void findByMemberIdAndDeviceId_found() { + UserDevice saved = userDeviceRepository.save(UserDevice.register( + 1L, "device-1", DeviceType.IOS, "fcm-token-1", "refresh-1")); + + Optional found = userDeviceRepository.findByMemberIdAndDeviceId(1L, "device-1"); + + assertThat(found).isPresent(); + assertThat(found.get().getId()).isEqualTo(saved.getId()); + assertThat(found.get().getFcmToken()).isEqualTo("fcm-token-1"); + } + + @Test + @DisplayName("findByMemberIdAndDeviceId: 다른 deviceId면 Optional.empty") + void findByMemberIdAndDeviceId_notFound() { + userDeviceRepository.save(UserDevice.register( + 1L, "device-1", DeviceType.IOS, "fcm-token-1", "refresh-1")); + + Optional found = userDeviceRepository.findByMemberIdAndDeviceId(1L, "device-999"); + + assertThat(found).isEmpty(); + } + + @Test + @DisplayName("getByMemberIdAndDeviceId: 없으면 INVALID_TOKEN 예외") + void getByMemberIdAndDeviceId_throws() { + assertThatThrownBy(() -> userDeviceRepository.getByMemberIdAndDeviceId(1L, "missing")) + .isInstanceOf(CustomException.class) + .extracting("errorCode").isEqualTo(ErrorCode.INVALID_TOKEN); + } + + @Test + @DisplayName("deleteByMemberIdAndDeviceId: 해당 row만 삭제, 다른 row는 유지") + void deleteByMemberIdAndDeviceId_onlyTargetDeleted() { + userDeviceRepository.save(UserDevice.register( + 1L, "device-A", DeviceType.IOS, "fcm-A", "refresh-A")); + userDeviceRepository.save(UserDevice.register( + 1L, "device-B", DeviceType.ANDROID, "fcm-B", "refresh-B")); + + userDeviceRepository.deleteByMemberIdAndDeviceId(1L, "device-A"); + userDeviceRepository.flush(); + + assertThat(userDeviceRepository.findByMemberIdAndDeviceId(1L, "device-A")).isEmpty(); + assertThat(userDeviceRepository.findByMemberIdAndDeviceId(1L, "device-B")).isPresent(); + } + + @Test + @DisplayName("(member_id, device_id) 컴포지트 unique 위반 시 DataIntegrityViolationException") + void uniqueConstraint_violation() { + userDeviceRepository.saveAndFlush(UserDevice.register( + 1L, "device-1", DeviceType.IOS, "fcm-1", "refresh-1")); + + assertThatThrownBy(() -> userDeviceRepository.saveAndFlush(UserDevice.register( + 1L, "device-1", DeviceType.ANDROID, "fcm-2", "refresh-2"))) + .isInstanceOf(DataIntegrityViolationException.class); + } + + @Test + @DisplayName("같은 device_id라도 member_id 다르면 별개 row로 공존") + void sameDeviceIdDifferentMember_coexist() { + userDeviceRepository.save(UserDevice.register( + 1L, "shared-device", DeviceType.IOS, "fcm-A", "refresh-A")); + userDeviceRepository.save(UserDevice.register( + 2L, "shared-device", DeviceType.IOS, "fcm-B", "refresh-B")); + + assertThat(userDeviceRepository.findByMemberIdAndDeviceId(1L, "shared-device")).isPresent(); + assertThat(userDeviceRepository.findByMemberIdAndDeviceId(2L, "shared-device")).isPresent(); + } + + @Test + @DisplayName("members 삭제 시 user_devices의 해당 row가 CASCADE로 자동 삭제된다") + @Transactional + @DirtiesContext + void cascadeDelete_onMemberDelete() { + // H2 테스트 스키마에 ON DELETE CASCADE FK를 추가 (마이그레이션 SQL V0_0_31의 FK 검증) + // @DirtiesContext: DDL은 트랜잭션 롤백 대상이 아니므로 컨텍스트를 재생성해 다른 테스트에 영향 없도록 함 + jdbcTemplate.execute( + "ALTER TABLE user_devices DROP CONSTRAINT IF EXISTS fk_user_devices_member_cascade"); + jdbcTemplate.execute( + "ALTER TABLE user_devices ADD CONSTRAINT fk_user_devices_member_cascade " + + "FOREIGN KEY (member_id) REFERENCES members(id) ON DELETE CASCADE"); + + // given + Member member = Member.signUp("social-cascade", SocialType.GOOGLE, "캐스케이드테스터"); + entityManager.persist(member); + entityManager.flush(); + + userDeviceRepository.save(UserDevice.register( + member.getId(), "device-cascade", DeviceType.IOS, "fcm-cascade", "hash-cascade")); + userDeviceRepository.flush(); + entityManager.clear(); + + // when - members row 삭제 → FK CASCADE로 user_devices row 자동 삭제 + jdbcTemplate.update("DELETE FROM members WHERE id = ?", member.getId()); + + // then + assertThat(userDeviceRepository.findByMemberIdAndDeviceId(member.getId(), "device-cascade")) + .isEmpty(); + } +} diff --git a/SS-Auth/src/test/java/com/elipair/spacestudyship/auth/service/AuthServiceTest.java b/SS-Auth/src/test/java/com/elipair/spacestudyship/auth/service/AuthServiceTest.java new file mode 100644 index 0000000..8ac1ed3 --- /dev/null +++ b/SS-Auth/src/test/java/com/elipair/spacestudyship/auth/service/AuthServiceTest.java @@ -0,0 +1,519 @@ +package com.elipair.spacestudyship.auth.service; + +import com.elipair.spacestudyship.auth.constant.DeviceType; +import com.elipair.spacestudyship.auth.dto.CheckNicknameResponse; +import com.elipair.spacestudyship.auth.dto.LoginRequest; +import com.elipair.spacestudyship.auth.dto.LoginResponse; +import com.elipair.spacestudyship.auth.dto.ReissueRequest; +import com.elipair.spacestudyship.auth.dto.ReissueResponse; +import com.elipair.spacestudyship.auth.dto.UpdateNicknameRequest; +import com.elipair.spacestudyship.auth.dto.UpdateNicknameResponse; +import com.elipair.spacestudyship.auth.entity.UserDevice; +import com.elipair.spacestudyship.auth.dto.RefreshTokenPayloadDto; +import com.elipair.spacestudyship.auth.jwt.JwtTokenProvider; +import com.elipair.spacestudyship.auth.jwt.RefreshTokenHasher; +import com.elipair.spacestudyship.auth.repository.UserDeviceRepository; +import com.elipair.spacestudyship.auth.social.SocialLoginStrategy; +import com.elipair.spacestudyship.common.exception.CustomException; +import com.elipair.spacestudyship.common.exception.ErrorCode; +import com.elipair.spacestudyship.member.constant.SocialType; +import com.elipair.spacestudyship.member.entity.Member; +import com.elipair.spacestudyship.member.event.MemberCreatedEvent; +import com.elipair.spacestudyship.member.repository.MemberRepository; +import com.google.firebase.auth.AuthErrorCode; +import com.google.firebase.auth.FirebaseAuth; +import com.google.firebase.auth.FirebaseAuthException; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.context.ApplicationEventPublisher; +import org.springframework.dao.DataIntegrityViolationException; +import org.springframework.test.util.ReflectionTestUtils; + +import java.util.Map; +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.BDDMockito.given; +import static org.mockito.BDDMockito.then; +import static org.mockito.BDDMockito.willThrow; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; + +@ExtendWith(MockitoExtension.class) +class AuthServiceTest { + + @Mock + MemberRepository memberRepository; + @Mock + UserDeviceRepository userDeviceRepository; + @Mock + JwtTokenProvider jwtTokenProvider; + @Mock + RandomNicknameGenerator randomNicknameGenerator; + @Mock + Map socialLoginStrategies; + @Mock + FirebaseAuth firebaseAuth; + @Mock + ApplicationEventPublisher eventPublisher; + + @InjectMocks + AuthService authService; + + // ===== login ===== + + @Test + @DisplayName("login: 기존 회원 + 신규 디바이스 → user_devices에 새 row insert") + void login_existingMember_newDevice() { + SocialType socialType = SocialType.GOOGLE; + LoginRequest request = new LoginRequest(socialType, "id-token", "fcm-1", DeviceType.IOS, "device-1"); + + SocialLoginStrategy strategy = mock(SocialLoginStrategy.class); + given(socialLoginStrategies.get(socialType)).willReturn(strategy); + given(strategy.validateAndGetSocialId("id-token")).willReturn("social-id-1"); + + Member member = Member.builder() + .id(10L).socialId("social-id-1").socialType(socialType).nickname("기존회원").build(); + given(memberRepository.findBySocialIdAndSocialType("social-id-1", socialType)) + .willReturn(Optional.of(member)); + + given(jwtTokenProvider.createAccessToken(member)).willReturn("access-1"); + given(jwtTokenProvider.createRefreshToken(member, "device-1")).willReturn("refresh-1"); + given(userDeviceRepository.findByMemberIdAndDeviceId(10L, "device-1")) + .willReturn(Optional.empty()); + given(memberRepository.findByIdForUpdate(10L)).willReturn(Optional.of(member)); + given(userDeviceRepository.countByMemberId(10L)).willReturn(0L); + + LoginResponse response = authService.login(request); + + assertThat(response.memberId()).isEqualTo(10L); + assertThat(response.tokens().accessToken()).isEqualTo("access-1"); + assertThat(response.tokens().refreshToken()).isEqualTo("refresh-1"); + assertThat(response.isNewMember()).isFalse(); + then(userDeviceRepository).should().save(any(UserDevice.class)); + } + + @Test + @DisplayName("login: 기존 회원 + 기존 디바이스 → 같은 row 갱신, save() 호출 없음") + void login_existingMember_existingDevice() { + SocialType socialType = SocialType.GOOGLE; + LoginRequest request = new LoginRequest(socialType, "id-token", "fcm-NEW", DeviceType.IOS, "device-1"); + + SocialLoginStrategy strategy = mock(SocialLoginStrategy.class); + given(socialLoginStrategies.get(socialType)).willReturn(strategy); + given(strategy.validateAndGetSocialId("id-token")).willReturn("social-id-1"); + + Member member = Member.builder() + .id(10L).socialId("social-id-1").socialType(socialType).nickname("기존회원").build(); + given(memberRepository.findBySocialIdAndSocialType("social-id-1", socialType)) + .willReturn(Optional.of(member)); + + given(jwtTokenProvider.createAccessToken(member)).willReturn("access-NEW"); + given(jwtTokenProvider.createRefreshToken(member, "device-1")).willReturn("refresh-NEW"); + + UserDevice existing = UserDevice.register(10L, "device-1", DeviceType.ANDROID, "fcm-OLD", + RefreshTokenHasher.hash("refresh-OLD")); + given(userDeviceRepository.findByMemberIdAndDeviceId(10L, "device-1")) + .willReturn(Optional.of(existing)); + + authService.login(request); + + assertThat(existing.getFcmToken()).isEqualTo("fcm-NEW"); + assertThat(existing.getRefreshTokenHash()).isEqualTo(RefreshTokenHasher.hash("refresh-NEW")); + assertThat(existing.getDeviceType()).isEqualTo(DeviceType.IOS); + then(userDeviceRepository).should(never()).save(any(UserDevice.class)); + } + + @Test + @DisplayName("login: 신규 디바이스이고 회원 디바이스 개수가 한도(10) 미만이면 등록 성공") + void login_newDevice_underLimit() { + SocialType socialType = SocialType.GOOGLE; + LoginRequest request = new LoginRequest(socialType, "id-token", "fcm-1", DeviceType.IOS, "device-1"); + + SocialLoginStrategy strategy = mock(SocialLoginStrategy.class); + given(socialLoginStrategies.get(socialType)).willReturn(strategy); + given(strategy.validateAndGetSocialId("id-token")).willReturn("social-id-1"); + + Member member = Member.builder() + .id(10L).socialId("social-id-1").socialType(socialType).nickname("기존회원").build(); + given(memberRepository.findBySocialIdAndSocialType("social-id-1", socialType)) + .willReturn(Optional.of(member)); + + given(jwtTokenProvider.createAccessToken(member)).willReturn("access-1"); + given(jwtTokenProvider.createRefreshToken(member, "device-1")).willReturn("refresh-1"); + given(userDeviceRepository.findByMemberIdAndDeviceId(10L, "device-1")) + .willReturn(Optional.empty()); + given(memberRepository.findByIdForUpdate(10L)).willReturn(Optional.of(member)); + given(userDeviceRepository.countByMemberId(10L)).willReturn(9L); + + authService.login(request); + + then(userDeviceRepository).should().save(any(UserDevice.class)); + } + + @Test + @DisplayName("login: 신규 디바이스이고 회원 디바이스 개수가 한도(10) 이상이면 DEVICE_LIMIT_EXCEEDED") + void login_newDevice_overLimit() { + SocialType socialType = SocialType.GOOGLE; + LoginRequest request = new LoginRequest(socialType, "id-token", "fcm-1", + DeviceType.IOS, "550e8400-e29b-41d4-a716-446655440000"); + + SocialLoginStrategy strategy = mock(SocialLoginStrategy.class); + given(socialLoginStrategies.get(socialType)).willReturn(strategy); + given(strategy.validateAndGetSocialId("id-token")).willReturn("social-id-1"); + + Member member = Member.builder() + .id(10L).socialId("social-id-1").socialType(socialType).nickname("회원").build(); + given(memberRepository.findBySocialIdAndSocialType("social-id-1", socialType)) + .willReturn(Optional.of(member)); + + given(jwtTokenProvider.createAccessToken(member)).willReturn("access-1"); + given(jwtTokenProvider.createRefreshToken(member, "550e8400-e29b-41d4-a716-446655440000")) + .willReturn("refresh-1"); + given(userDeviceRepository.findByMemberIdAndDeviceId(10L, "550e8400-e29b-41d4-a716-446655440000")) + .willReturn(Optional.empty()); + given(memberRepository.findByIdForUpdate(10L)).willReturn(Optional.of(member)); + given(userDeviceRepository.countByMemberId(10L)).willReturn(10L); + + assertThatThrownBy(() -> authService.login(request)) + .isInstanceOf(CustomException.class) + .extracting("errorCode").isEqualTo(ErrorCode.DEVICE_LIMIT_EXCEEDED); + then(userDeviceRepository).should(never()).save(any(UserDevice.class)); + } + + @Test + @DisplayName("신규 회원 로그인 시 MemberCreatedEvent를 publish한다") + void login_newMember_publishesMemberCreatedEvent() { + SocialType socialType = SocialType.GOOGLE; + LoginRequest request = new LoginRequest(socialType, "id-token", "fcm-1", DeviceType.IOS, "device-1"); + + SocialLoginStrategy strategy = mock(SocialLoginStrategy.class); + given(socialLoginStrategies.get(socialType)).willReturn(strategy); + given(strategy.validateAndGetSocialId("id-token")).willReturn("social-id-new"); + + given(memberRepository.findBySocialIdAndSocialType("social-id-new", socialType)) + .willReturn(Optional.empty()); + given(randomNicknameGenerator.generate()).willReturn("우주탐험가"); + given(memberRepository.existsByNickname("우주탐험가")).willReturn(false); + given(memberRepository.save(any(Member.class))).willAnswer(inv -> { + Member m = inv.getArgument(0); + ReflectionTestUtils.setField(m, "id", 20L); + return m; + }); + + given(jwtTokenProvider.createAccessToken(any(Member.class))).willReturn("access-new"); + given(jwtTokenProvider.createRefreshToken(any(Member.class), any())).willReturn("refresh-new"); + given(userDeviceRepository.findByMemberIdAndDeviceId(20L, "device-1")) + .willReturn(Optional.empty()); + given(memberRepository.findByIdForUpdate(20L)).willReturn(Optional.of( + Member.builder().id(20L).socialId("social-id-new").socialType(socialType).nickname("우주탐험가").build())); + given(userDeviceRepository.countByMemberId(20L)).willReturn(0L); + + authService.login(request); + + ArgumentCaptor captor = ArgumentCaptor.forClass(MemberCreatedEvent.class); + verify(eventPublisher).publishEvent(captor.capture()); + assertThat(captor.getValue().memberId()).isEqualTo(20L); + } + + @Test + @DisplayName("기존 회원 재로그인 시 MemberCreatedEvent를 publish 하지 않는다") + void login_existingMember_doesNotPublishEvent() { + SocialType socialType = SocialType.GOOGLE; + LoginRequest request = new LoginRequest(socialType, "id-token", "fcm-1", DeviceType.IOS, "device-1"); + + SocialLoginStrategy strategy = mock(SocialLoginStrategy.class); + given(socialLoginStrategies.get(socialType)).willReturn(strategy); + given(strategy.validateAndGetSocialId("id-token")).willReturn("social-id-1"); + + Member member = Member.builder() + .id(10L).socialId("social-id-1").socialType(socialType).nickname("기존회원").build(); + given(memberRepository.findBySocialIdAndSocialType("social-id-1", socialType)) + .willReturn(Optional.of(member)); + + given(jwtTokenProvider.createAccessToken(member)).willReturn("access-1"); + given(jwtTokenProvider.createRefreshToken(member, "device-1")).willReturn("refresh-1"); + given(userDeviceRepository.findByMemberIdAndDeviceId(10L, "device-1")) + .willReturn(Optional.empty()); + given(memberRepository.findByIdForUpdate(10L)).willReturn(Optional.of(member)); + given(userDeviceRepository.countByMemberId(10L)).willReturn(0L); + + authService.login(request); + + verify(eventPublisher, never()).publishEvent(any(MemberCreatedEvent.class)); + } + + // ===== reissue ===== + + @Test + @DisplayName("reissue: DB의 refresh_token과 일치하면 새 토큰 발급 + DB 갱신, deviceId 유지") + void reissue_success() { + String oldRefresh = "refresh-OLD"; + ReissueRequest request = new ReissueRequest(oldRefresh); + + given(jwtTokenProvider.parseRefreshToken(oldRefresh)) + .willReturn(new RefreshTokenPayloadDto(10L, "device-1")); + UserDevice device = UserDevice.register(10L, "device-1", DeviceType.IOS, "fcm", + RefreshTokenHasher.hash(oldRefresh)); + given(userDeviceRepository.findByMemberIdAndDeviceId(10L, "device-1")) + .willReturn(Optional.of(device)); + Member member = Member.builder() + .id(10L).socialId("s").socialType(SocialType.GOOGLE).nickname("닉").build(); + given(memberRepository.getByMemberId(10L)).willReturn(member); + given(jwtTokenProvider.createAccessToken(member)).willReturn("access-NEW"); + given(jwtTokenProvider.createRefreshToken(member, "device-1")).willReturn("refresh-NEW"); + + ReissueResponse response = authService.reissue(request); + + assertThat(response.tokens().accessToken()).isEqualTo("access-NEW"); + assertThat(response.tokens().refreshToken()).isEqualTo("refresh-NEW"); + assertThat(device.getRefreshTokenHash()).isEqualTo(RefreshTokenHasher.hash("refresh-NEW")); + } + + @Test + @DisplayName("reissue: DB의 refresh_token과 불일치 → 해당 디바이스 row 삭제 + INVALID_TOKEN") + void reissue_tokenMismatch_forceLogout() { + String incomingRefresh = "refresh-FORGED"; + ReissueRequest request = new ReissueRequest(incomingRefresh); + + given(jwtTokenProvider.parseRefreshToken(incomingRefresh)) + .willReturn(new RefreshTokenPayloadDto(10L, "device-1")); + UserDevice device = UserDevice.register(10L, "device-1", DeviceType.IOS, "fcm", + RefreshTokenHasher.hash("refresh-CURRENT")); + given(userDeviceRepository.findByMemberIdAndDeviceId(10L, "device-1")) + .willReturn(Optional.of(device)); + + assertThatThrownBy(() -> authService.reissue(request)) + .isInstanceOf(CustomException.class) + .extracting("errorCode").isEqualTo(ErrorCode.INVALID_TOKEN); + then(userDeviceRepository).should().delete(device); + } + + @Test + @DisplayName("reissue: user_devices에 해당 디바이스 row 없으면 INVALID_TOKEN") + void reissue_deviceNotFound() { + String incoming = "refresh-X"; + ReissueRequest request = new ReissueRequest(incoming); + + given(jwtTokenProvider.parseRefreshToken(incoming)) + .willReturn(new RefreshTokenPayloadDto(10L, "device-gone")); + given(userDeviceRepository.findByMemberIdAndDeviceId(10L, "device-gone")) + .willReturn(Optional.empty()); + + assertThatThrownBy(() -> authService.reissue(request)) + .isInstanceOf(CustomException.class) + .extracting("errorCode").isEqualTo(ErrorCode.INVALID_TOKEN); + } + + // ===== logout ===== + + @Test + @DisplayName("logout: refresh token 파싱 성공 시 해당 (member, device) row 삭제") + void logout_deletesOnlyTargetDevice() { + String refreshToken = "refresh-1"; + given(jwtTokenProvider.parseRefreshTokenSafely(refreshToken)) + .willReturn(Optional.of(new RefreshTokenPayloadDto(10L, "device-1"))); + + authService.logout(refreshToken); + + then(userDeviceRepository).should().deleteByMemberIdAndDeviceId(10L, "device-1"); + } + + @Test + @DisplayName("logout: 위변조 등으로 파싱 불가능하면 아무 동작 안 함 (멱등)") + void logout_invalidToken_noop() { + given(jwtTokenProvider.parseRefreshTokenSafely("garbage")) + .willReturn(Optional.empty()); + + authService.logout("garbage"); + + then(userDeviceRepository).should(never()).deleteByMemberIdAndDeviceId(any(), any()); + } + + // ===== checkNickname / updateNickname (기존 유지) ===== + + @Test + @DisplayName("checkNickname: DB에 닉네임이 없으면 available=true") + void checkNickname_available() { + String nickname = "우주탐험가"; + given(memberRepository.existsByNickname(nickname)).willReturn(false); + + CheckNicknameResponse response = authService.checkNickname(nickname); + + assertThat(response.available()).isTrue(); + } + + @Test + @DisplayName("checkNickname: DB에 닉네임이 있으면 available=false") + void checkNickname_notAvailable() { + String nickname = "우주탐험가"; + given(memberRepository.existsByNickname(nickname)).willReturn(true); + + CheckNicknameResponse response = authService.checkNickname(nickname); + + assertThat(response.available()).isFalse(); + } + + @Test + @DisplayName("updateNickname: 중복 없으면 닉네임 변경 성공") + void updateNickname_success() { + Long memberId = 1L; + String newNickname = "우주탐험가"; + UpdateNicknameRequest request = new UpdateNicknameRequest(newNickname); + Member member = Member.builder() + .id(memberId).socialId("social-id").socialType(SocialType.GOOGLE).nickname("기존닉네임").build(); + given(memberRepository.getByMemberId(memberId)).willReturn(member); + given(memberRepository.existsByNickname(newNickname)).willReturn(false); + + UpdateNicknameResponse response = authService.updateNickname(memberId, request); + + assertThat(response.nickname()).isEqualTo(newNickname); + assertThat(member.getNickname()).isEqualTo(newNickname); + verify(memberRepository).flush(); + } + + @Test + @DisplayName("updateNickname: 본인 현재 닉네임과 같으면 중복 검사 없이 그대로 통과") + void updateNickname_sameAsCurrent() { + Long memberId = 1L; + String currentNickname = "우주탐험가"; + UpdateNicknameRequest request = new UpdateNicknameRequest(currentNickname); + Member member = Member.builder() + .id(memberId).socialId("social-id").socialType(SocialType.GOOGLE).nickname(currentNickname).build(); + given(memberRepository.getByMemberId(memberId)).willReturn(member); + + UpdateNicknameResponse response = authService.updateNickname(memberId, request); + + assertThat(response.nickname()).isEqualTo(currentNickname); + verify(memberRepository, never()).existsByNickname(any()); + verify(memberRepository, never()).flush(); + } + + @Test + @DisplayName("updateNickname: 이미 사용 중인 닉네임이면 DUPLICATED_NICKNAME") + void updateNickname_duplicated() { + Long memberId = 1L; + String newNickname = "우주탐험가"; + UpdateNicknameRequest request = new UpdateNicknameRequest(newNickname); + Member member = Member.builder() + .id(memberId).socialId("social-id").socialType(SocialType.GOOGLE).nickname("기존닉네임").build(); + given(memberRepository.getByMemberId(memberId)).willReturn(member); + given(memberRepository.existsByNickname(newNickname)).willReturn(true); + + assertThatThrownBy(() -> authService.updateNickname(memberId, request)) + .isInstanceOf(CustomException.class) + .extracting("errorCode").isEqualTo(ErrorCode.DUPLICATED_NICKNAME); + verify(memberRepository, never()).flush(); + } + + @Test + @DisplayName("updateNickname: flush 단계 race로 DataIntegrityViolation 발생 시 DUPLICATED_NICKNAME으로 변환") + void updateNickname_raceCondition() { + Long memberId = 1L; + String newNickname = "우주탐험가"; + UpdateNicknameRequest request = new UpdateNicknameRequest(newNickname); + Member member = Member.builder() + .id(memberId).socialId("social-id").socialType(SocialType.GOOGLE).nickname("기존닉네임").build(); + given(memberRepository.getByMemberId(memberId)).willReturn(member); + given(memberRepository.existsByNickname(newNickname)).willReturn(false); + willThrow(new DataIntegrityViolationException("uk_nickname")) + .given(memberRepository).flush(); + + assertThatThrownBy(() -> authService.updateNickname(memberId, request)) + .isInstanceOf(CustomException.class) + .extracting("errorCode").isEqualTo(ErrorCode.DUPLICATED_NICKNAME); + } + + @Test + @DisplayName("updateNickname: 회원이 없으면 MEMBER_NOT_FOUND") + void updateNickname_memberNotFound() { + Long memberId = 1L; + String newNickname = "우주탐험가"; + UpdateNicknameRequest request = new UpdateNicknameRequest(newNickname); + given(memberRepository.getByMemberId(memberId)).willThrow(new CustomException(ErrorCode.MEMBER_NOT_FOUND)); + + assertThatThrownBy(() -> authService.updateNickname(memberId, request)) + .isInstanceOf(CustomException.class) + .extracting("errorCode").isEqualTo(ErrorCode.MEMBER_NOT_FOUND); + verify(memberRepository, never()).existsByNickname(any()); + } + + // ===== withdraw ===== + + @Test + @DisplayName("withdraw: Member 존재 시 DB 삭제(+CASCADE) + Firebase 사용자 삭제") + void withdraw_success() throws Exception { + Long memberId = 1L; + String socialId = "firebase-uid-123"; + Member member = Member.builder() + .id(memberId).socialId(socialId).socialType(SocialType.GOOGLE).nickname("탈퇴할회원").build(); + given(memberRepository.findById(memberId)).willReturn(Optional.of(member)); + + authService.withdraw(memberId); + + verify(memberRepository).delete(member); + verify(firebaseAuth).deleteUser(socialId); + // user_devices는 FK CASCADE로 자동 삭제되므로 AuthService가 직접 호출하지 않는다 + then(userDeviceRepository).shouldHaveNoInteractions(); + } + + @Test + @DisplayName("withdraw: Member 이미 없으면 멱등 처리 (아무 호출 없음)") + void withdraw_alreadyWithdrawn() throws Exception { + Long memberId = 1L; + given(memberRepository.findById(memberId)).willReturn(Optional.empty()); + + authService.withdraw(memberId); + + verify(memberRepository, never()).delete(any(Member.class)); + verify(firebaseAuth, never()).deleteUser(any()); + then(userDeviceRepository).shouldHaveNoInteractions(); + } + + @Test + @DisplayName("withdraw: Firebase USER_NOT_FOUND 예외는 무시하고 정상 완료") + void withdraw_firebaseUserNotFound() throws Exception { + Long memberId = 1L; + String socialId = "firebase-uid-123"; + Member member = Member.builder() + .id(memberId).socialId(socialId).socialType(SocialType.GOOGLE).nickname("탈퇴할회원").build(); + given(memberRepository.findById(memberId)).willReturn(Optional.of(member)); + + FirebaseAuthException firebaseEx = mock(FirebaseAuthException.class); + given(firebaseEx.getAuthErrorCode()).willReturn(AuthErrorCode.USER_NOT_FOUND); + willThrow(firebaseEx).given(firebaseAuth).deleteUser(socialId); + + authService.withdraw(memberId); + + verify(memberRepository).delete(member); + verify(firebaseAuth).deleteUser(socialId); + } + + @Test + @DisplayName("withdraw: Firebase 일반 오류도 무시하고 정상 완료 (멱등성 유지)") + void withdraw_firebaseGenericError() throws Exception { + Long memberId = 1L; + String socialId = "firebase-uid-123"; + Member member = Member.builder() + .id(memberId).socialId(socialId).socialType(SocialType.GOOGLE).nickname("탈퇴할회원").build(); + given(memberRepository.findById(memberId)).willReturn(Optional.of(member)); + + FirebaseAuthException firebaseEx = mock(FirebaseAuthException.class); + given(firebaseEx.getAuthErrorCode()).willReturn(AuthErrorCode.CERTIFICATE_FETCH_FAILED); + given(firebaseEx.getMessage()).willReturn("Firebase 일시 장애"); + willThrow(firebaseEx).given(firebaseAuth).deleteUser(socialId); + + authService.withdraw(memberId); + + verify(memberRepository).delete(member); + verify(firebaseAuth).deleteUser(socialId); + } +} diff --git a/SS-Auth/src/test/java/com/elipair/spacestudyship/auth/social/AppleLoginStrategyTest.java b/SS-Auth/src/test/java/com/elipair/spacestudyship/auth/social/AppleLoginStrategyTest.java new file mode 100644 index 0000000..4aa23b0 --- /dev/null +++ b/SS-Auth/src/test/java/com/elipair/spacestudyship/auth/social/AppleLoginStrategyTest.java @@ -0,0 +1,59 @@ +package com.elipair.spacestudyship.auth.social; + +import com.elipair.spacestudyship.common.exception.CustomException; +import com.elipair.spacestudyship.common.exception.ErrorCode; +import com.elipair.spacestudyship.member.constant.SocialType; +import com.google.firebase.auth.FirebaseAuth; +import com.google.firebase.auth.FirebaseAuthException; +import com.google.firebase.auth.FirebaseToken; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.mock; + +@ExtendWith(MockitoExtension.class) +class AppleLoginStrategyTest { + + @Mock + FirebaseAuth firebaseAuth; + + @InjectMocks + AppleLoginStrategy strategy; + + @Test + @DisplayName("validateAndGetSocialId: 유효한 토큰이면 Firebase UID 반환") + void validateAndGetSocialId_valid() throws FirebaseAuthException { + FirebaseToken token = mock(FirebaseToken.class); + given(token.getUid()).willReturn("firebase-uid-apple-1"); + given(firebaseAuth.verifyIdToken("valid-apple-token")).willReturn(token); + + String socialId = strategy.validateAndGetSocialId("valid-apple-token"); + + assertThat(socialId).isEqualTo("firebase-uid-apple-1"); + } + + @Test + @DisplayName("validateAndGetSocialId: Firebase 검증 실패 시 INVALID_TOKEN") + void validateAndGetSocialId_invalid() throws FirebaseAuthException { + FirebaseAuthException ex = mock(FirebaseAuthException.class); + given(firebaseAuth.verifyIdToken("invalid-token")).willThrow(ex); + + assertThatThrownBy(() -> strategy.validateAndGetSocialId("invalid-token")) + .isInstanceOf(CustomException.class) + .extracting("errorCode") + .isEqualTo(ErrorCode.INVALID_TOKEN); + } + + @Test + @DisplayName("getSocialType: APPLE 반환") + void getSocialType() { + assertThat(strategy.getSocialType()).isEqualTo(SocialType.APPLE); + } +} diff --git a/SS-Auth/src/test/java/com/elipair/spacestudyship/auth/social/GoogleLoginStrategyTest.java b/SS-Auth/src/test/java/com/elipair/spacestudyship/auth/social/GoogleLoginStrategyTest.java new file mode 100644 index 0000000..832eef3 --- /dev/null +++ b/SS-Auth/src/test/java/com/elipair/spacestudyship/auth/social/GoogleLoginStrategyTest.java @@ -0,0 +1,59 @@ +package com.elipair.spacestudyship.auth.social; + +import com.elipair.spacestudyship.common.exception.CustomException; +import com.elipair.spacestudyship.common.exception.ErrorCode; +import com.elipair.spacestudyship.member.constant.SocialType; +import com.google.firebase.auth.FirebaseAuth; +import com.google.firebase.auth.FirebaseAuthException; +import com.google.firebase.auth.FirebaseToken; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.mock; + +@ExtendWith(MockitoExtension.class) +class GoogleLoginStrategyTest { + + @Mock + FirebaseAuth firebaseAuth; + + @InjectMocks + GoogleLoginStrategy strategy; + + @Test + @DisplayName("validateAndGetSocialId: 유효한 토큰이면 Firebase UID 반환") + void validateAndGetSocialId_valid() throws FirebaseAuthException { + FirebaseToken token = mock(FirebaseToken.class); + given(token.getUid()).willReturn("firebase-uid-google-1"); + given(firebaseAuth.verifyIdToken("valid-google-token")).willReturn(token); + + String socialId = strategy.validateAndGetSocialId("valid-google-token"); + + assertThat(socialId).isEqualTo("firebase-uid-google-1"); + } + + @Test + @DisplayName("validateAndGetSocialId: Firebase 검증 실패 시 INVALID_TOKEN") + void validateAndGetSocialId_invalid() throws FirebaseAuthException { + FirebaseAuthException ex = mock(FirebaseAuthException.class); + given(firebaseAuth.verifyIdToken("invalid-token")).willThrow(ex); + + assertThatThrownBy(() -> strategy.validateAndGetSocialId("invalid-token")) + .isInstanceOf(CustomException.class) + .extracting("errorCode") + .isEqualTo(ErrorCode.INVALID_TOKEN); + } + + @Test + @DisplayName("getSocialType: GOOGLE 반환") + void getSocialType() { + assertThat(strategy.getSocialType()).isEqualTo(SocialType.GOOGLE); + } +} diff --git a/SS-Auth/src/test/java/com/elipair/spacestudyship/auth/social/KakaoLoginStrategyTest.java b/SS-Auth/src/test/java/com/elipair/spacestudyship/auth/social/KakaoLoginStrategyTest.java new file mode 100644 index 0000000..0cbb7ad --- /dev/null +++ b/SS-Auth/src/test/java/com/elipair/spacestudyship/auth/social/KakaoLoginStrategyTest.java @@ -0,0 +1,59 @@ +package com.elipair.spacestudyship.auth.social; + +import com.elipair.spacestudyship.common.exception.CustomException; +import com.elipair.spacestudyship.common.exception.ErrorCode; +import com.elipair.spacestudyship.member.constant.SocialType; +import com.google.firebase.auth.FirebaseAuth; +import com.google.firebase.auth.FirebaseAuthException; +import com.google.firebase.auth.FirebaseToken; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.mock; + +@ExtendWith(MockitoExtension.class) +class KakaoLoginStrategyTest { + + @Mock + FirebaseAuth firebaseAuth; + + @InjectMocks + KakaoLoginStrategy strategy; + + @Test + @DisplayName("validateAndGetSocialId: 유효한 토큰이면 Firebase UID 반환") + void validateAndGetSocialId_valid() throws FirebaseAuthException { + FirebaseToken token = mock(FirebaseToken.class); + given(token.getUid()).willReturn("firebase-uid-kakao-1"); + given(firebaseAuth.verifyIdToken("valid-kakao-token")).willReturn(token); + + String socialId = strategy.validateAndGetSocialId("valid-kakao-token"); + + assertThat(socialId).isEqualTo("firebase-uid-kakao-1"); + } + + @Test + @DisplayName("validateAndGetSocialId: Firebase 검증 실패 시 INVALID_TOKEN") + void validateAndGetSocialId_invalid() throws FirebaseAuthException { + FirebaseAuthException ex = mock(FirebaseAuthException.class); + given(firebaseAuth.verifyIdToken("invalid-token")).willThrow(ex); + + assertThatThrownBy(() -> strategy.validateAndGetSocialId("invalid-token")) + .isInstanceOf(CustomException.class) + .extracting("errorCode") + .isEqualTo(ErrorCode.INVALID_TOKEN); + } + + @Test + @DisplayName("getSocialType: KAKAO 반환") + void getSocialType() { + assertThat(strategy.getSocialType()).isEqualTo(SocialType.KAKAO); + } +} diff --git a/SS-Auth/src/test/resources/application.yml b/SS-Auth/src/test/resources/application.yml new file mode 100644 index 0000000..f340af7 --- /dev/null +++ b/SS-Auth/src/test/resources/application.yml @@ -0,0 +1,13 @@ +spring: + datasource: + url: jdbc:h2:mem:testdb;DB_CLOSE_DELAY=-1;DB_CLOSE_ON_EXIT=FALSE + driver-class-name: org.h2.Driver + username: sa + password: + jpa: + database-platform: org.hibernate.dialect.H2Dialect + hibernate: + ddl-auto: create-drop + show-sql: false + flyway: + enabled: false diff --git a/SS-Common/build.gradle b/SS-Common/build.gradle index cc21489..f162676 100644 --- a/SS-Common/build.gradle +++ b/SS-Common/build.gradle @@ -16,7 +16,8 @@ dependencies { api 'org.springframework.boot:spring-boot-starter-actuator' api 'org.springframework.boot:spring-boot-starter-security' - // Flyway + // Flyway (Spring Boot 4: autoconfiguration이 spring-boot-flyway 모듈로 분리됨 — 없으면 마이그레이션 미실행) + api 'org.springframework.boot:spring-boot-flyway' api 'org.flywaydb:flyway-core' api 'org.flywaydb:flyway-database-postgresql' diff --git a/SS-Common/src/main/java/com/elipair/spacestudyship/common/exception/ErrorCode.java b/SS-Common/src/main/java/com/elipair/spacestudyship/common/exception/ErrorCode.java index e969cb6..8e78939 100644 --- a/SS-Common/src/main/java/com/elipair/spacestudyship/common/exception/ErrorCode.java +++ b/SS-Common/src/main/java/com/elipair/spacestudyship/common/exception/ErrorCode.java @@ -15,12 +15,39 @@ public enum ErrorCode { INVALID_TOKEN(HttpStatus.UNAUTHORIZED, "인증 정보가 올바르지 않습니다."), UNAUTHENTICATED_REQUEST(HttpStatus.UNAUTHORIZED, "로그인이 필요합니다."), UNSUPPORTED_SOCIAL_TYPE(HttpStatus.BAD_REQUEST, "지원하지 않는 소셜 로그인 방식입니다."), + DEVICE_LIMIT_EXCEEDED(HttpStatus.FORBIDDEN, "등록 가능한 디바이스 개수를 초과했습니다."), NICKNAME_GENERATION_FAILED(HttpStatus.INTERNAL_SERVER_ERROR, "랜덤 닉네임 생성에 실패했습니다."), // Member MEMBER_NOT_FOUND(HttpStatus.NOT_FOUND, "해당 회원을 찾을 수 없습니다."), DUPLICATED_NICKNAME(HttpStatus.CONFLICT, "이미 사용 중인 닉네임입니다."), + // Todo + TODO_NOT_FOUND(HttpStatus.NOT_FOUND, "해당 할 일을 찾을 수 없습니다."), + TODO_ALREADY_EXISTS(HttpStatus.CONFLICT, "동일 ID의 할 일이 이미 존재합니다."), + + // Todo Category + CATEGORY_NOT_FOUND(HttpStatus.NOT_FOUND, "해당 카테고리를 찾을 수 없습니다."), + CATEGORY_ALREADY_EXISTS(HttpStatus.CONFLICT, "동일 ID의 카테고리가 이미 존재합니다."), + + // Fuel + INSUFFICIENT_FUEL(HttpStatus.BAD_REQUEST, "연료가 부족합니다."), + FUEL_NOT_INITIALIZED(HttpStatus.INTERNAL_SERVER_ERROR, "연료 정보가 초기화되지 않았습니다."), + + // Timer + INVALID_SESSION_TIME(HttpStatus.BAD_REQUEST, "시작 시각이 종료 시각보다 늦거나 같습니다."), + INVALID_DURATION(HttpStatus.BAD_REQUEST, "공부 시간이 시작/종료 시각 간격보다 큽니다."), + SESSION_TOO_SHORT(HttpStatus.BAD_REQUEST, "공부 시간은 1분 이상이어야 합니다."), + SESSION_TOO_LONG(HttpStatus.BAD_REQUEST, "공부 시간은 24시간(1440분)을 초과할 수 없습니다."), + FUTURE_SESSION(HttpStatus.BAD_REQUEST, "미래 시각의 세션은 저장할 수 없습니다."), + + // Exploration + PLANET_NOT_FOUND(HttpStatus.NOT_FOUND, "해당 행성을 찾을 수 없습니다."), + REGION_NOT_FOUND(HttpStatus.NOT_FOUND, "해당 지역을 찾을 수 없습니다."), + ALREADY_UNLOCKED(HttpStatus.BAD_REQUEST, "이미 해금된 노드입니다."), + PLANET_LOCKED(HttpStatus.BAD_REQUEST, "상위 행성이 아직 해금되지 않았습니다."), + PREREQUISITE_NOT_CLEARED(HttpStatus.BAD_REQUEST, "이전 행성을 먼저 클리어해야 합니다."), + // Common INVALID_INPUT_VALUE(HttpStatus.BAD_REQUEST, "입력값이 유효하지 않습니다."), INVALID_REQUEST_BODY(HttpStatus.BAD_REQUEST, "요청 본문의 형식이 잘못되었습니다."), diff --git a/SS-Common/src/main/java/com/elipair/spacestudyship/common/exception/ErrorResponse.java b/SS-Common/src/main/java/com/elipair/spacestudyship/common/exception/ErrorResponse.java index 8f8254e..156965a 100644 --- a/SS-Common/src/main/java/com/elipair/spacestudyship/common/exception/ErrorResponse.java +++ b/SS-Common/src/main/java/com/elipair/spacestudyship/common/exception/ErrorResponse.java @@ -1,14 +1,23 @@ package com.elipair.spacestudyship.common.exception; +import com.fasterxml.jackson.annotation.JsonInclude; + +@JsonInclude(JsonInclude.Include.NON_NULL) public record ErrorResponse( - int status, - String message + String code, + String message, + Integer requiredFuel, + Integer currentFuel ) { public static ErrorResponse of(ErrorCode errorCode) { - return new ErrorResponse(errorCode.getHttpStatus().value(), errorCode.getMessage()); + return new ErrorResponse(errorCode.name(), errorCode.getMessage(), null, null); + } + + public static ErrorResponse of(ErrorCode errorCode, String message) { + return new ErrorResponse(errorCode.name(), message, null, null); } - public static ErrorResponse of(int status, String message) { - return new ErrorResponse(status, message); + public static ErrorResponse ofInsufficientFuel(String message, int requiredFuel, int currentFuel) { + return new ErrorResponse(ErrorCode.INSUFFICIENT_FUEL.name(), message, requiredFuel, currentFuel); } } diff --git a/SS-Common/src/main/java/com/elipair/spacestudyship/common/exception/GlobalExceptionHandler.java b/SS-Common/src/main/java/com/elipair/spacestudyship/common/exception/GlobalExceptionHandler.java index 36b1459..a729db7 100644 --- a/SS-Common/src/main/java/com/elipair/spacestudyship/common/exception/GlobalExceptionHandler.java +++ b/SS-Common/src/main/java/com/elipair/spacestudyship/common/exception/GlobalExceptionHandler.java @@ -1,7 +1,9 @@ package com.elipair.spacestudyship.common.exception; +import java.time.format.DateTimeParseException; import java.util.stream.Collectors; +import jakarta.validation.ConstraintViolationException; import lombok.extern.slf4j.Slf4j; import org.springframework.http.ResponseEntity; import org.springframework.http.converter.HttpMessageNotReadableException; @@ -25,6 +27,16 @@ public ResponseEntity handleCustomException(CustomException ex) { .body(ErrorResponse.of(errorCode)); } + @ExceptionHandler(InsufficientFuelException.class) + public ResponseEntity handleInsufficientFuel(InsufficientFuelException ex) { + log.info("[Exception] 연료 부족 | required={}, current={}", ex.getRequiredFuel(), ex.getCurrentFuel()); + return ResponseEntity + .status(ErrorCode.INSUFFICIENT_FUEL.getHttpStatus()) + .body(ErrorResponse.ofInsufficientFuel( + ErrorCode.INSUFFICIENT_FUEL.getMessage(), + ex.getRequiredFuel(), ex.getCurrentFuel())); + } + @ExceptionHandler(MethodArgumentNotValidException.class) public ResponseEntity handleValidationException(MethodArgumentNotValidException ex) { String detail = ex.getBindingResult().getFieldErrors().stream() @@ -34,7 +46,28 @@ public ResponseEntity handleValidationException(MethodArgumentNot ErrorCode errorCode = ErrorCode.INVALID_INPUT_VALUE; return ResponseEntity .status(errorCode.getHttpStatus()) - .body(ErrorResponse.of(errorCode.getHttpStatus().value(), detail)); + .body(ErrorResponse.of(errorCode, detail)); + } + + @ExceptionHandler(DateTimeParseException.class) + public ResponseEntity handleDateTimeParse(DateTimeParseException ex) { + log.info("[Validation] 날짜 형식 오류 | error={}", ex.getMessage()); + ErrorCode errorCode = ErrorCode.INVALID_INPUT_VALUE; + return ResponseEntity + .status(errorCode.getHttpStatus()) + .body(ErrorResponse.of(errorCode, "날짜 형식이 올바르지 않습니다. YYYY-MM-DD 형식이어야 합니다.")); + } + + @ExceptionHandler(ConstraintViolationException.class) + public ResponseEntity handleConstraintViolation(ConstraintViolationException ex) { + String detail = ex.getConstraintViolations().stream() + .map(v -> v.getPropertyPath() + ": " + v.getMessage()) + .collect(Collectors.joining(", ")); + log.info("[Validation] 파라미터 검증 실패 | detail={}", detail); + ErrorCode errorCode = ErrorCode.INVALID_INPUT_VALUE; + return ResponseEntity + .status(errorCode.getHttpStatus()) + .body(ErrorResponse.of(errorCode, detail)); } @ExceptionHandler(HttpMessageNotReadableException.class) diff --git a/SS-Common/src/main/java/com/elipair/spacestudyship/common/exception/InsufficientFuelException.java b/SS-Common/src/main/java/com/elipair/spacestudyship/common/exception/InsufficientFuelException.java new file mode 100644 index 0000000..80ec928 --- /dev/null +++ b/SS-Common/src/main/java/com/elipair/spacestudyship/common/exception/InsufficientFuelException.java @@ -0,0 +1,16 @@ +package com.elipair.spacestudyship.common.exception; + +import lombok.Getter; + +@Getter +public class InsufficientFuelException extends RuntimeException { + + private final int requiredFuel; + private final int currentFuel; + + public InsufficientFuelException(int requiredFuel, int currentFuel) { + super(ErrorCode.INSUFFICIENT_FUEL.getMessage()); + this.requiredFuel = requiredFuel; + this.currentFuel = currentFuel; + } +} diff --git a/SS-Common/src/test/java/com/elipair/spacestudyship/common/exception/ErrorResponseTest.java b/SS-Common/src/test/java/com/elipair/spacestudyship/common/exception/ErrorResponseTest.java new file mode 100644 index 0000000..1ce94ef --- /dev/null +++ b/SS-Common/src/test/java/com/elipair/spacestudyship/common/exception/ErrorResponseTest.java @@ -0,0 +1,53 @@ +package com.elipair.spacestudyship.common.exception; + +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +class ErrorResponseTest { + + private final ObjectMapper objectMapper = new ObjectMapper(); + + @Test + @DisplayName("of(ErrorCode): requiredFuel/currentFuel은 null") + void of_basic_nullFuelFields() { + ErrorResponse r = ErrorResponse.of(ErrorCode.PLANET_NOT_FOUND); + assertThat(r.code()).isEqualTo("PLANET_NOT_FOUND"); + assertThat(r.requiredFuel()).isNull(); + assertThat(r.currentFuel()).isNull(); + } + + @Test + @DisplayName("ofInsufficientFuel: 연료 수치 포함") + void ofInsufficientFuel_includesAmounts() { + ErrorResponse r = ErrorResponse.ofInsufficientFuel("연료가 부족합니다.", 10, 4); + assertThat(r.code()).isEqualTo("INSUFFICIENT_FUEL"); + assertThat(r.requiredFuel()).isEqualTo(10); + assertThat(r.currentFuel()).isEqualTo(4); + } + + @Test + @DisplayName("InsufficientFuelException: 게터로 수치 노출") + void exception_getters() { + InsufficientFuelException ex = new InsufficientFuelException(10, 4); + assertThat(ex.getRequiredFuel()).isEqualTo(10); + assertThat(ex.getCurrentFuel()).isEqualTo(4); + } + + @Test + @DisplayName("@JsonInclude(NON_NULL): null 연료 필드는 JSON에서 제외, 연료 필드 있으면 JSON에 포함") + void jsonInclude_nonNull_wireContract() throws Exception { + String basicJson = objectMapper.writeValueAsString(ErrorResponse.of(ErrorCode.MEMBER_NOT_FOUND)); + assertThat(basicJson).doesNotContain("requiredFuel"); + assertThat(basicJson).doesNotContain("currentFuel"); + + String fuelJson = objectMapper.writeValueAsString( + ErrorResponse.ofInsufficientFuel("연료가 부족합니다.", 10, 4)); + assertThat(fuelJson).contains("requiredFuel"); + assertThat(fuelJson).contains("currentFuel"); + assertThat(fuelJson).contains("10"); + assertThat(fuelJson).contains("4"); + } +} diff --git a/SS-Member/src/main/java/com/elipair/spacestudyship/member/event/MemberCreatedEvent.java b/SS-Member/src/main/java/com/elipair/spacestudyship/member/event/MemberCreatedEvent.java new file mode 100644 index 0000000..f3ac4b4 --- /dev/null +++ b/SS-Member/src/main/java/com/elipair/spacestudyship/member/event/MemberCreatedEvent.java @@ -0,0 +1,4 @@ +package com.elipair.spacestudyship.member.event; + +public record MemberCreatedEvent(Long memberId) { +} diff --git a/SS-Member/src/main/java/com/elipair/spacestudyship/member/repository/MemberRepository.java b/SS-Member/src/main/java/com/elipair/spacestudyship/member/repository/MemberRepository.java index 325f198..90d5a4e 100644 --- a/SS-Member/src/main/java/com/elipair/spacestudyship/member/repository/MemberRepository.java +++ b/SS-Member/src/main/java/com/elipair/spacestudyship/member/repository/MemberRepository.java @@ -4,7 +4,11 @@ import com.elipair.spacestudyship.common.exception.ErrorCode; import com.elipair.spacestudyship.member.entity.Member; import com.elipair.spacestudyship.member.constant.SocialType; +import jakarta.persistence.LockModeType; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Lock; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; import java.util.Optional; @@ -14,6 +18,10 @@ public interface MemberRepository extends JpaRepository { Optional findBySocialIdAndSocialType(String socialId, SocialType socialType); + @Lock(LockModeType.PESSIMISTIC_WRITE) + @Query("select m from Member m where m.id = :id") + Optional findByIdForUpdate(@Param("id") Long id); + default Member getByMemberId(Long memberId) { return findById(memberId) .orElseThrow(() -> new CustomException(ErrorCode.MEMBER_NOT_FOUND)); diff --git a/SS-Study/build.gradle b/SS-Study/build.gradle index 76b0503..d9a0e2e 100644 --- a/SS-Study/build.gradle +++ b/SS-Study/build.gradle @@ -10,4 +10,10 @@ jar { dependencies { api project(':SS-Common') api project(':SS-Member') + + // Test - Testcontainers PostgreSQL (JSONB 쿼리 검증용) + testImplementation 'org.testcontainers:testcontainers:1.20.4' + testImplementation 'org.testcontainers:postgresql:1.20.4' + testImplementation 'org.testcontainers:junit-jupiter:1.20.4' + testRuntimeOnly 'org.postgresql:postgresql' } diff --git a/SS-Study/src/main/java/com/elipair/spacestudyship/study/exploration/constant/NodeType.java b/SS-Study/src/main/java/com/elipair/spacestudyship/study/exploration/constant/NodeType.java new file mode 100644 index 0000000..8ce0396 --- /dev/null +++ b/SS-Study/src/main/java/com/elipair/spacestudyship/study/exploration/constant/NodeType.java @@ -0,0 +1,15 @@ +package com.elipair.spacestudyship.study.exploration.constant; + +public enum NodeType { + PLANET, + REGION; + + /** DB 컬럼/JSON 직렬화용 소문자 표현 ("planet" / "region"). */ + public String value() { + return name().toLowerCase(); + } + + public static NodeType from(String value) { + return NodeType.valueOf(value.toUpperCase()); + } +} diff --git a/SS-Study/src/main/java/com/elipair/spacestudyship/study/exploration/constant/NodeTypeConverter.java b/SS-Study/src/main/java/com/elipair/spacestudyship/study/exploration/constant/NodeTypeConverter.java new file mode 100644 index 0000000..57d7ab9 --- /dev/null +++ b/SS-Study/src/main/java/com/elipair/spacestudyship/study/exploration/constant/NodeTypeConverter.java @@ -0,0 +1,18 @@ +package com.elipair.spacestudyship.study.exploration.constant; + +import jakarta.persistence.AttributeConverter; +import jakarta.persistence.Converter; + +@Converter +public class NodeTypeConverter implements AttributeConverter { + + @Override + public String convertToDatabaseColumn(NodeType attribute) { + return attribute == null ? null : attribute.value(); + } + + @Override + public NodeType convertToEntityAttribute(String dbData) { + return dbData == null ? null : NodeType.from(dbData); + } +} diff --git a/SS-Study/src/main/java/com/elipair/spacestudyship/study/exploration/dto/PlanetResponse.java b/SS-Study/src/main/java/com/elipair/spacestudyship/study/exploration/dto/PlanetResponse.java new file mode 100644 index 0000000..e41e156 --- /dev/null +++ b/SS-Study/src/main/java/com/elipair/spacestudyship/study/exploration/dto/PlanetResponse.java @@ -0,0 +1,36 @@ +package com.elipair.spacestudyship.study.exploration.dto; + +import com.elipair.spacestudyship.study.exploration.entity.ExplorationNode; +import io.swagger.v3.oas.annotations.media.Schema; + +import java.time.LocalDateTime; +import java.time.ZoneOffset; +import java.time.format.DateTimeFormatter; + +@Schema(description = "행성 응답") +public record PlanetResponse( + String id, String name, String nodeType, int depth, String icon, + @Schema(nullable = true) String parentId, + @Schema(nullable = true) String prerequisiteId, + int requiredFuel, boolean isUnlocked, boolean isCleared, int sortOrder, + String description, double mapX, double mapY, + @Schema(nullable = true, example = "2026-04-01T00:00:00Z") String unlockedAt, + ProgressDto progress +) { + private static final DateTimeFormatter ISO_UTC = DateTimeFormatter.ISO_INSTANT; + + public static PlanetResponse of(ExplorationNode n, boolean isUnlocked, boolean isCleared, + int clearedChildren, int totalChildren, double progressRatio, + LocalDateTime unlockedAt) { + return new PlanetResponse( + n.getId(), n.getName(), n.getNodeType().value(), n.getDepth(), n.getIcon(), + n.getParentId(), n.getPrerequisiteNodeId(), n.getRequiredFuel(), + isUnlocked, isCleared, n.getSortOrder(), n.getDescription(), n.getMapX(), n.getMapY(), + formatUtc(unlockedAt), + new ProgressDto(clearedChildren, totalChildren, progressRatio)); + } + + private static String formatUtc(LocalDateTime time) { + return time == null ? null : ISO_UTC.format(time.toInstant(ZoneOffset.UTC)); + } +} diff --git a/SS-Study/src/main/java/com/elipair/spacestudyship/study/exploration/dto/PlanetUnlockResponse.java b/SS-Study/src/main/java/com/elipair/spacestudyship/study/exploration/dto/PlanetUnlockResponse.java new file mode 100644 index 0000000..847411c --- /dev/null +++ b/SS-Study/src/main/java/com/elipair/spacestudyship/study/exploration/dto/PlanetUnlockResponse.java @@ -0,0 +1,17 @@ +package com.elipair.spacestudyship.study.exploration.dto; + +import com.elipair.spacestudyship.study.exploration.entity.ExplorationNode; +import com.elipair.spacestudyship.study.exploration.entity.UserExploration; +import io.swagger.v3.oas.annotations.media.Schema; + +@Schema(description = "행성 해금 응답") +public record PlanetUnlockResponse( + UnlockedNodeDto planet, int fuelConsumed, int currentFuel +) { + public static PlanetUnlockResponse of(ExplorationNode planet, UserExploration progress, + int fuelConsumed, int currentFuel) { + return new PlanetUnlockResponse( + UnlockedNodeDto.of(planet, progress, false), + fuelConsumed, currentFuel); + } +} diff --git a/SS-Study/src/main/java/com/elipair/spacestudyship/study/exploration/dto/ProgressDto.java b/SS-Study/src/main/java/com/elipair/spacestudyship/study/exploration/dto/ProgressDto.java new file mode 100644 index 0000000..fafe586 --- /dev/null +++ b/SS-Study/src/main/java/com/elipair/spacestudyship/study/exploration/dto/ProgressDto.java @@ -0,0 +1,10 @@ +package com.elipair.spacestudyship.study.exploration.dto; + +import io.swagger.v3.oas.annotations.media.Schema; + +@Schema(description = "행성 진행도") +public record ProgressDto( + @Schema(example = "3") int clearedChildren, + @Schema(example = "5") int totalChildren, + @Schema(example = "0.6") double progressRatio +) {} diff --git a/SS-Study/src/main/java/com/elipair/spacestudyship/study/exploration/dto/RegionResponse.java b/SS-Study/src/main/java/com/elipair/spacestudyship/study/exploration/dto/RegionResponse.java new file mode 100644 index 0000000..fafcf58 --- /dev/null +++ b/SS-Study/src/main/java/com/elipair/spacestudyship/study/exploration/dto/RegionResponse.java @@ -0,0 +1,32 @@ +package com.elipair.spacestudyship.study.exploration.dto; + +import com.elipair.spacestudyship.study.exploration.entity.ExplorationNode; +import io.swagger.v3.oas.annotations.media.Schema; + +import java.time.LocalDateTime; +import java.time.ZoneOffset; +import java.time.format.DateTimeFormatter; + +@Schema(description = "지역 응답") +public record RegionResponse( + String id, String name, String nodeType, int depth, String icon, + @Schema(nullable = true) String parentId, + int requiredFuel, boolean isUnlocked, boolean isCleared, int sortOrder, + String description, double mapX, double mapY, + @Schema(nullable = true, example = "2026-04-05T15:30:00Z") String unlockedAt +) { + private static final DateTimeFormatter ISO_UTC = DateTimeFormatter.ISO_INSTANT; + + public static RegionResponse of(ExplorationNode n, boolean isUnlocked, boolean isCleared, + LocalDateTime unlockedAt) { + return new RegionResponse( + n.getId(), n.getName(), n.getNodeType().value(), n.getDepth(), n.getIcon(), + n.getParentId(), n.getRequiredFuel(), isUnlocked, isCleared, + n.getSortOrder(), n.getDescription(), n.getMapX(), n.getMapY(), + formatUtc(unlockedAt)); + } + + private static String formatUtc(LocalDateTime time) { + return time == null ? null : ISO_UTC.format(time.toInstant(ZoneOffset.UTC)); + } +} diff --git a/SS-Study/src/main/java/com/elipair/spacestudyship/study/exploration/dto/RegionUnlockResponse.java b/SS-Study/src/main/java/com/elipair/spacestudyship/study/exploration/dto/RegionUnlockResponse.java new file mode 100644 index 0000000..de8a512 --- /dev/null +++ b/SS-Study/src/main/java/com/elipair/spacestudyship/study/exploration/dto/RegionUnlockResponse.java @@ -0,0 +1,17 @@ +package com.elipair.spacestudyship.study.exploration.dto; + +import com.elipair.spacestudyship.study.exploration.entity.ExplorationNode; +import com.elipair.spacestudyship.study.exploration.entity.UserExploration; +import io.swagger.v3.oas.annotations.media.Schema; + +@Schema(description = "지역 해금 응답") +public record RegionUnlockResponse( + UnlockedNodeDto region, int fuelConsumed, int currentFuel, boolean planetCleared +) { + public static RegionUnlockResponse of(ExplorationNode region, UserExploration progress, + int fuelConsumed, int currentFuel, boolean planetCleared) { + return new RegionUnlockResponse( + UnlockedNodeDto.of(region, progress, true), + fuelConsumed, currentFuel, planetCleared); + } +} diff --git a/SS-Study/src/main/java/com/elipair/spacestudyship/study/exploration/dto/UnlockedNodeDto.java b/SS-Study/src/main/java/com/elipair/spacestudyship/study/exploration/dto/UnlockedNodeDto.java new file mode 100644 index 0000000..78577f1 --- /dev/null +++ b/SS-Study/src/main/java/com/elipair/spacestudyship/study/exploration/dto/UnlockedNodeDto.java @@ -0,0 +1,27 @@ +package com.elipair.spacestudyship.study.exploration.dto; + +import com.elipair.spacestudyship.study.exploration.entity.ExplorationNode; +import com.elipair.spacestudyship.study.exploration.entity.UserExploration; +import io.swagger.v3.oas.annotations.media.Schema; + +import java.time.LocalDateTime; +import java.time.ZoneOffset; +import java.time.format.DateTimeFormatter; + +@Schema(description = "해금된 노드 요약") +public record UnlockedNodeDto( + String id, String name, boolean isUnlocked, boolean isCleared, + @Schema(example = "2026-04-16T11:00:00Z") String unlockedAt +) { + private static final DateTimeFormatter ISO_UTC = DateTimeFormatter.ISO_INSTANT; + + public static UnlockedNodeDto of(ExplorationNode node, UserExploration progress, boolean cleared) { + return new UnlockedNodeDto( + node.getId(), node.getName(), true, cleared, + formatUtc(progress.getUnlockedAt())); + } + + private static String formatUtc(LocalDateTime time) { + return time == null ? null : ISO_UTC.format(time.toInstant(ZoneOffset.UTC)); + } +} diff --git a/SS-Study/src/main/java/com/elipair/spacestudyship/study/exploration/entity/ExplorationNode.java b/SS-Study/src/main/java/com/elipair/spacestudyship/study/exploration/entity/ExplorationNode.java new file mode 100644 index 0000000..31f51c2 --- /dev/null +++ b/SS-Study/src/main/java/com/elipair/spacestudyship/study/exploration/entity/ExplorationNode.java @@ -0,0 +1,61 @@ +package com.elipair.spacestudyship.study.exploration.entity; + +import com.elipair.spacestudyship.study.exploration.constant.NodeType; +import com.elipair.spacestudyship.study.exploration.constant.NodeTypeConverter; +import jakarta.persistence.Column; +import jakarta.persistence.Convert; +import jakarta.persistence.Entity; +import jakarta.persistence.Id; +import jakarta.persistence.Table; +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Entity +@Table(name = "exploration_nodes") +@Getter +@Builder +@AllArgsConstructor +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class ExplorationNode { + + @Id + @Column(length = 50) + private String id; + + @Column(nullable = false, length = 50) + private String name; + + @Convert(converter = NodeTypeConverter.class) + @Column(name = "node_type", nullable = false, length = 10) + private NodeType nodeType; + + @Column(nullable = false) + private int depth; + + @Column(nullable = false, length = 30) + private String icon; + + @Column(name = "parent_id", length = 50) + private String parentId; + + @Column(name = "prerequisite_node_id", length = 50) + private String prerequisiteNodeId; + + @Column(name = "required_fuel", nullable = false) + private int requiredFuel; + + @Column(name = "sort_order", nullable = false) + private int sortOrder; + + @Column(nullable = false, length = 200) + private String description; + + @Column(name = "map_x", nullable = false) + private double mapX; + + @Column(name = "map_y", nullable = false) + private double mapY; +} diff --git a/SS-Study/src/main/java/com/elipair/spacestudyship/study/exploration/entity/UserExploration.java b/SS-Study/src/main/java/com/elipair/spacestudyship/study/exploration/entity/UserExploration.java new file mode 100644 index 0000000..7dc55c4 --- /dev/null +++ b/SS-Study/src/main/java/com/elipair/spacestudyship/study/exploration/entity/UserExploration.java @@ -0,0 +1,56 @@ +package com.elipair.spacestudyship.study.exploration.entity; + +import com.elipair.spacestudyship.common.entity.BaseTimeEntity; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.Table; +import jakarta.persistence.UniqueConstraint; +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.time.LocalDateTime; + +@Entity +@Table(name = "user_exploration_progress", + uniqueConstraints = @UniqueConstraint(name = "uq_user_expl", columnNames = {"user_id", "node_id"})) +@Getter +@Builder +@AllArgsConstructor +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class UserExploration extends BaseTimeEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(name = "user_id", nullable = false) + private Long userId; + + @Column(name = "node_id", nullable = false, length = 50) + private String nodeId; + + @Column(name = "is_unlocked", nullable = false) + private boolean isUnlocked; + + @Column(name = "is_cleared", nullable = false) + private boolean isCleared; + + @Column(name = "unlocked_at", nullable = false) + private LocalDateTime unlockedAt; + + public static UserExploration unlock(Long userId, String nodeId, boolean cleared) { + return UserExploration.builder() + .userId(userId) + .nodeId(nodeId) + .isUnlocked(true) + .isCleared(cleared) + .unlockedAt(LocalDateTime.now()) + .build(); + } +} diff --git a/SS-Study/src/main/java/com/elipair/spacestudyship/study/exploration/repository/ExplorationNodeRepository.java b/SS-Study/src/main/java/com/elipair/spacestudyship/study/exploration/repository/ExplorationNodeRepository.java new file mode 100644 index 0000000..167dcd3 --- /dev/null +++ b/SS-Study/src/main/java/com/elipair/spacestudyship/study/exploration/repository/ExplorationNodeRepository.java @@ -0,0 +1,14 @@ +package com.elipair.spacestudyship.study.exploration.repository; + +import com.elipair.spacestudyship.study.exploration.constant.NodeType; +import com.elipair.spacestudyship.study.exploration.entity.ExplorationNode; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.List; + +public interface ExplorationNodeRepository extends JpaRepository { + + List findByNodeTypeOrderBySortOrderAsc(NodeType nodeType); + + List findByParentIdOrderBySortOrderAsc(String parentId); +} diff --git a/SS-Study/src/main/java/com/elipair/spacestudyship/study/exploration/repository/UserExplorationRepository.java b/SS-Study/src/main/java/com/elipair/spacestudyship/study/exploration/repository/UserExplorationRepository.java new file mode 100644 index 0000000..c07be0f --- /dev/null +++ b/SS-Study/src/main/java/com/elipair/spacestudyship/study/exploration/repository/UserExplorationRepository.java @@ -0,0 +1,13 @@ +package com.elipair.spacestudyship.study.exploration.repository; + +import com.elipair.spacestudyship.study.exploration.entity.UserExploration; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.List; + +public interface UserExplorationRepository extends JpaRepository { + + List findByUserId(Long userId); + + boolean existsByUserIdAndNodeId(Long userId, String nodeId); +} diff --git a/SS-Study/src/main/java/com/elipair/spacestudyship/study/exploration/service/ExplorationService.java b/SS-Study/src/main/java/com/elipair/spacestudyship/study/exploration/service/ExplorationService.java new file mode 100644 index 0000000..78e9543 --- /dev/null +++ b/SS-Study/src/main/java/com/elipair/spacestudyship/study/exploration/service/ExplorationService.java @@ -0,0 +1,172 @@ +package com.elipair.spacestudyship.study.exploration.service; + +import com.elipair.spacestudyship.common.exception.CustomException; +import com.elipair.spacestudyship.common.exception.ErrorCode; +import com.elipair.spacestudyship.common.exception.InsufficientFuelException; +import com.elipair.spacestudyship.study.exploration.constant.NodeType; +import com.elipair.spacestudyship.study.exploration.dto.PlanetResponse; +import com.elipair.spacestudyship.study.exploration.dto.RegionResponse; +import com.elipair.spacestudyship.study.exploration.dto.PlanetUnlockResponse; +import com.elipair.spacestudyship.study.exploration.dto.RegionUnlockResponse; +import com.elipair.spacestudyship.study.exploration.entity.ExplorationNode; +import com.elipair.spacestudyship.study.exploration.entity.UserExploration; +import com.elipair.spacestudyship.study.exploration.repository.ExplorationNodeRepository; +import com.elipair.spacestudyship.study.exploration.repository.UserExplorationRepository; +import com.elipair.spacestudyship.study.fuel.constant.FuelReason; +import com.elipair.spacestudyship.study.fuel.dto.FuelTransactionResponse; +import com.elipair.spacestudyship.study.fuel.service.FuelService; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDateTime; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.UUID; +import java.util.function.Function; +import java.util.stream.Collectors; + +@Slf4j +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class ExplorationService { + + private final ExplorationNodeRepository nodeRepository; + private final UserExplorationRepository userExplorationRepository; + private final FuelService fuelService; + + public List getPlanets(Long userId) { + List planets = nodeRepository.findByNodeTypeOrderBySortOrderAsc(NodeType.PLANET); + List regions = nodeRepository.findByNodeTypeOrderBySortOrderAsc(NodeType.REGION); + Map progress = progressMap(userId); + Set unlocked = progress.keySet(); + + Map totalByParent = regions.stream() + .collect(Collectors.groupingBy(ExplorationNode::getParentId, Collectors.counting())); + Map clearedByParent = regions.stream() + .filter(r -> r.getRequiredFuel() == 0 || unlocked.contains(r.getId())) + .collect(Collectors.groupingBy(ExplorationNode::getParentId, Collectors.counting())); + + return planets.stream().map(p -> { + int total = totalByParent.getOrDefault(p.getId(), 0L).intValue(); + int cleared = clearedByParent.getOrDefault(p.getId(), 0L).intValue(); + boolean isUnlocked = p.getRequiredFuel() == 0 || unlocked.contains(p.getId()); + boolean isCleared = total > 0 && cleared == total; + double ratio = total == 0 ? 0.0 : (double) cleared / total; + LocalDateTime unlockedAt = progress.containsKey(p.getId()) + ? progress.get(p.getId()).getUnlockedAt() : null; + return PlanetResponse.of(p, isUnlocked, isCleared, cleared, total, ratio, unlockedAt); + }).toList(); + } + + public List getRegions(Long userId, String planetId) { + nodeRepository.findById(planetId) + .filter(n -> n.getNodeType() == NodeType.PLANET) + .orElseThrow(() -> new CustomException(ErrorCode.PLANET_NOT_FOUND)); + + List regions = nodeRepository.findByParentIdOrderBySortOrderAsc(planetId); + Map progress = progressMap(userId); + + return regions.stream().map(r -> { + UserExploration pr = progress.get(r.getId()); + boolean isUnlocked = r.getRequiredFuel() == 0 || pr != null; + LocalDateTime unlockedAt = pr == null ? null : pr.getUnlockedAt(); + return RegionResponse.of(r, isUnlocked, isUnlocked, unlockedAt); + }).toList(); + } + + private Map progressMap(Long userId) { + return userExplorationRepository.findByUserId(userId).stream() + .collect(Collectors.toMap(UserExploration::getNodeId, Function.identity())); + } + + @Transactional + public RegionUnlockResponse unlockRegion(Long userId, String regionId) { + ExplorationNode region = nodeRepository.findById(regionId) + .filter(n -> n.getNodeType() == NodeType.REGION) + .orElseThrow(() -> new CustomException(ErrorCode.REGION_NOT_FOUND)); + + ExplorationNode parent = nodeRepository.findById(region.getParentId()) + .orElseThrow(() -> new CustomException(ErrorCode.PLANET_NOT_FOUND)); + boolean parentUnlocked = parent.getRequiredFuel() == 0 + || userExplorationRepository.existsByUserIdAndNodeId(userId, parent.getId()); + if (!parentUnlocked) { + throw new CustomException(ErrorCode.PLANET_LOCKED); + } + + if (region.getRequiredFuel() == 0 + || userExplorationRepository.existsByUserIdAndNodeId(userId, regionId)) { + throw new CustomException(ErrorCode.ALREADY_UNLOCKED); + } + + requireFuel(userId, region.getRequiredFuel()); + + FuelTransactionResponse fuelTx = fuelService.consume( + userId, region.getRequiredFuel(), FuelReason.EXPLORATION_UNLOCK, + regionId, UUID.randomUUID().toString()); + + UserExploration saved = userExplorationRepository.save( + UserExploration.unlock(userId, regionId, true)); + + boolean planetCleared = isPlanetCleared(userId, parent.getId()); + + log.info("[Exploration] 지역 해금 | userId={}, regionId={}, fuel={}, planetCleared={}", + userId, regionId, region.getRequiredFuel(), planetCleared); + + return RegionUnlockResponse.of(region, saved, + fuelTx.amount(), fuelTx.balanceAfter(), planetCleared); + } + + @Transactional + public PlanetUnlockResponse unlockPlanet(Long userId, String planetId) { + ExplorationNode planet = nodeRepository.findById(planetId) + .filter(n -> n.getNodeType() == NodeType.PLANET) + .orElseThrow(() -> new CustomException(ErrorCode.PLANET_NOT_FOUND)); + + if (planet.getRequiredFuel() == 0 + || userExplorationRepository.existsByUserIdAndNodeId(userId, planetId)) { + throw new CustomException(ErrorCode.ALREADY_UNLOCKED); + } + + if (planet.getPrerequisiteNodeId() != null + && !isPlanetCleared(userId, planet.getPrerequisiteNodeId())) { + throw new CustomException(ErrorCode.PREREQUISITE_NOT_CLEARED); + } + + requireFuel(userId, planet.getRequiredFuel()); + + FuelTransactionResponse fuelTx = fuelService.consume( + userId, planet.getRequiredFuel(), FuelReason.EXPLORATION_UNLOCK, + planetId, UUID.randomUUID().toString()); + + UserExploration saved = userExplorationRepository.save( + UserExploration.unlock(userId, planetId, false)); + + log.info("[Exploration] 행성 해금 | userId={}, planetId={}, fuel={}", + userId, planetId, planet.getRequiredFuel()); + + return PlanetUnlockResponse.of(planet, saved, fuelTx.amount(), fuelTx.balanceAfter()); + } + + private void requireFuel(Long userId, int requiredFuel) { + int currentFuel = fuelService.getFuel(userId).currentFuel(); + if (currentFuel < requiredFuel) { + throw new InsufficientFuelException(requiredFuel, currentFuel); + } + } + + private boolean isPlanetCleared(Long userId, String planetId) { + List regions = nodeRepository.findByParentIdOrderBySortOrderAsc(planetId); + if (regions.isEmpty()) { + return false; + } + Set unlocked = userExplorationRepository.findByUserId(userId).stream() + .map(UserExploration::getNodeId).collect(Collectors.toSet()); + // requiredFuel==0 지역은 암묵 해금(진행도 레코드 없음) — getRegions와 동일하게 클리어로 간주 + return regions.stream() + .allMatch(r -> r.getRequiredFuel() == 0 || unlocked.contains(r.getId())); + } +} diff --git a/SS-Study/src/main/java/com/elipair/spacestudyship/study/fuel/constant/FuelReason.java b/SS-Study/src/main/java/com/elipair/spacestudyship/study/fuel/constant/FuelReason.java new file mode 100644 index 0000000..36c4f88 --- /dev/null +++ b/SS-Study/src/main/java/com/elipair/spacestudyship/study/fuel/constant/FuelReason.java @@ -0,0 +1,6 @@ +package com.elipair.spacestudyship.study.fuel.constant; + +public enum FuelReason { + STUDY_SESSION, // charge: 공부 세션 완료 + EXPLORATION_UNLOCK // consume: 행성/지역 해금 +} diff --git a/SS-Study/src/main/java/com/elipair/spacestudyship/study/fuel/constant/TransactionType.java b/SS-Study/src/main/java/com/elipair/spacestudyship/study/fuel/constant/TransactionType.java new file mode 100644 index 0000000..4e3995c --- /dev/null +++ b/SS-Study/src/main/java/com/elipair/spacestudyship/study/fuel/constant/TransactionType.java @@ -0,0 +1,6 @@ +package com.elipair.spacestudyship.study.fuel.constant; + +public enum TransactionType { + CHARGE, + CONSUME +} diff --git a/SS-Study/src/main/java/com/elipair/spacestudyship/study/fuel/dto/FuelChargeFromStudyResult.java b/SS-Study/src/main/java/com/elipair/spacestudyship/study/fuel/dto/FuelChargeFromStudyResult.java new file mode 100644 index 0000000..26f191c --- /dev/null +++ b/SS-Study/src/main/java/com/elipair/spacestudyship/study/fuel/dto/FuelChargeFromStudyResult.java @@ -0,0 +1,17 @@ +package com.elipair.spacestudyship.study.fuel.dto; + +/** + * 공부 세션에서 파생된 연료 충전 결과. + * + * 환율: 30분 = 1 연료. 30분 미만 잔여분은 {@code newPendingMinutes}에 누적되어 + * 다음 세션과 합산된다. + * + * @param amount 이번 호출로 충전된 연료 통 수 (0 이상) + * @param newPendingMinutes 충전 후 남은 잔여 분 (0~29) + * @param currentFuel 충전 후의 현재 연료 잔량 + */ +public record FuelChargeFromStudyResult( + int amount, + int newPendingMinutes, + int currentFuel +) {} diff --git a/SS-Study/src/main/java/com/elipair/spacestudyship/study/fuel/dto/FuelResponse.java b/SS-Study/src/main/java/com/elipair/spacestudyship/study/fuel/dto/FuelResponse.java new file mode 100644 index 0000000..fff1efc --- /dev/null +++ b/SS-Study/src/main/java/com/elipair/spacestudyship/study/fuel/dto/FuelResponse.java @@ -0,0 +1,33 @@ +package com.elipair.spacestudyship.study.fuel.dto; + +import com.elipair.spacestudyship.study.fuel.entity.UserFuel; +import io.swagger.v3.oas.annotations.media.Schema; + +import java.time.LocalDateTime; +import java.time.ZoneOffset; +import java.time.format.DateTimeFormatter; + +@Schema(description = "연료 잔량 응답") +public record FuelResponse( + @Schema(description = "현재 보유 연료", example = "350") Integer currentFuel, + @Schema(description = "누적 충전량", example = "1200") Integer totalCharged, + @Schema(description = "누적 소비량", example = "850") Integer totalConsumed, + @Schema(description = "미동기화 시간(분) - 향후 확장용, 현재 항상 0", example = "0") Integer pendingMinutes, + @Schema(description = "마지막 변동 시각 (ISO 8601 UTC)", example = "2026-04-16T10:30:00Z") String lastUpdatedAt +) { + private static final DateTimeFormatter ISO_UTC = DateTimeFormatter.ISO_INSTANT; + + public static FuelResponse from(UserFuel fuel) { + return new FuelResponse( + fuel.getCurrentFuel(), + fuel.getTotalCharged(), + fuel.getTotalConsumed(), + fuel.getPendingMinutes(), + formatUtc(fuel.getUpdatedAt()) + ); + } + + private static String formatUtc(LocalDateTime time) { + return time == null ? null : ISO_UTC.format(time.toInstant(ZoneOffset.UTC)); + } +} diff --git a/SS-Study/src/main/java/com/elipair/spacestudyship/study/fuel/dto/FuelTransactionListResponse.java b/SS-Study/src/main/java/com/elipair/spacestudyship/study/fuel/dto/FuelTransactionListResponse.java new file mode 100644 index 0000000..aa14fea --- /dev/null +++ b/SS-Study/src/main/java/com/elipair/spacestudyship/study/fuel/dto/FuelTransactionListResponse.java @@ -0,0 +1,26 @@ +package com.elipair.spacestudyship.study.fuel.dto; + +import com.elipair.spacestudyship.study.fuel.entity.FuelTransaction; +import io.swagger.v3.oas.annotations.media.Schema; +import org.springframework.data.domain.Page; + +import java.util.List; + +@Schema(description = "거래 내역 페이지 응답") +public record FuelTransactionListResponse( + List content, + Integer page, + Integer size, + Long totalElements, + Integer totalPages +) { + public static FuelTransactionListResponse from(Page page) { + return new FuelTransactionListResponse( + page.getContent().stream().map(FuelTransactionResponse::from).toList(), + page.getNumber(), + page.getSize(), + page.getTotalElements(), + page.getTotalPages() + ); + } +} diff --git a/SS-Study/src/main/java/com/elipair/spacestudyship/study/fuel/dto/FuelTransactionResponse.java b/SS-Study/src/main/java/com/elipair/spacestudyship/study/fuel/dto/FuelTransactionResponse.java new file mode 100644 index 0000000..20431bc --- /dev/null +++ b/SS-Study/src/main/java/com/elipair/spacestudyship/study/fuel/dto/FuelTransactionResponse.java @@ -0,0 +1,46 @@ +package com.elipair.spacestudyship.study.fuel.dto; + +import com.elipair.spacestudyship.study.fuel.entity.FuelTransaction; +import io.swagger.v3.oas.annotations.media.Schema; + +import java.time.LocalDateTime; +import java.time.ZoneOffset; +import java.time.format.DateTimeFormatter; + +@Schema(description = "연료 거래 내역") +public record FuelTransactionResponse( + @Schema(example = "tx-uuid-1234") String id, + + @Schema(description = "charge 또는 consume", + allowableValues = {"charge", "consume"}, example = "charge") + String type, + + @Schema(example = "90") Integer amount, + + @Schema(description = "거래 사유", + allowableValues = {"STUDY_SESSION", "EXPLORATION_UNLOCK"}, + example = "STUDY_SESSION") + String reason, + + @Schema(nullable = true, example = "session-uuid-5678") String referenceId, + @Schema(example = "350") Integer balanceAfter, + @Schema(example = "2026-04-16T10:30:00Z") String createdAt +) { + private static final DateTimeFormatter ISO_UTC = DateTimeFormatter.ISO_INSTANT; + + public static FuelTransactionResponse from(FuelTransaction tx) { + return new FuelTransactionResponse( + tx.getId(), + tx.getType().name().toLowerCase(), + tx.getAmount(), + tx.getReason().name(), + tx.getReferenceId(), + tx.getBalanceAfter(), + formatUtc(tx.getCreatedAt()) + ); + } + + private static String formatUtc(LocalDateTime time) { + return time == null ? null : ISO_UTC.format(time.toInstant(ZoneOffset.UTC)); + } +} diff --git a/SS-Study/src/main/java/com/elipair/spacestudyship/study/fuel/entity/FuelTransaction.java b/SS-Study/src/main/java/com/elipair/spacestudyship/study/fuel/entity/FuelTransaction.java new file mode 100644 index 0000000..de53ca4 --- /dev/null +++ b/SS-Study/src/main/java/com/elipair/spacestudyship/study/fuel/entity/FuelTransaction.java @@ -0,0 +1,70 @@ +package com.elipair.spacestudyship.study.fuel.entity; + +import com.elipair.spacestudyship.common.entity.BaseTimeEntity; +import com.elipair.spacestudyship.study.fuel.constant.FuelReason; +import com.elipair.spacestudyship.study.fuel.constant.TransactionType; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.Id; +import jakarta.persistence.Table; +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import org.hibernate.annotations.Check; +import org.hibernate.annotations.Checks; + +@Entity +@Checks({ + @Check(name = "chk_fuel_tx_amount_positive", constraints = "amount > 0"), + @Check(name = "chk_fuel_tx_type", constraints = "type IN ('CHARGE','CONSUME')"), + @Check(name = "chk_fuel_tx_reason", constraints = "reason IN ('STUDY_SESSION','EXPLORATION_UNLOCK')") +}) +@Table(name = "fuel_transactions") +@Getter +@Builder +@AllArgsConstructor +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class FuelTransaction extends BaseTimeEntity { + + @Id + @Column(length = 36) + private String id; + + @Column(name = "user_id", nullable = false) + private Long userId; + + @Enumerated(EnumType.STRING) + @Column(nullable = false, length = 10) + private TransactionType type; + + @Column(nullable = false) + private Integer amount; + + @Enumerated(EnumType.STRING) + @Column(nullable = false, length = 30) + private FuelReason reason; + + @Column(name = "reference_id", length = 50) + private String referenceId; + + @Column(name = "balance_after", nullable = false) + private Integer balanceAfter; + + public static FuelTransaction of(String id, Long userId, TransactionType type, + int amount, FuelReason reason, + String referenceId, int balanceAfter) { + return FuelTransaction.builder() + .id(id) + .userId(userId) + .type(type) + .amount(amount) + .reason(reason) + .referenceId(referenceId) + .balanceAfter(balanceAfter) + .build(); + } +} diff --git a/SS-Study/src/main/java/com/elipair/spacestudyship/study/fuel/entity/UserFuel.java b/SS-Study/src/main/java/com/elipair/spacestudyship/study/fuel/entity/UserFuel.java new file mode 100644 index 0000000..c118d08 --- /dev/null +++ b/SS-Study/src/main/java/com/elipair/spacestudyship/study/fuel/entity/UserFuel.java @@ -0,0 +1,109 @@ +package com.elipair.spacestudyship.study.fuel.entity; + +import com.elipair.spacestudyship.common.entity.BaseTimeEntity; +import com.elipair.spacestudyship.common.exception.CustomException; +import com.elipair.spacestudyship.common.exception.ErrorCode; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.Id; +import jakarta.persistence.Table; +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import org.hibernate.annotations.Check; +import org.hibernate.annotations.Checks; + +@Entity +@Checks({ + @Check(name = "chk_fuel_non_negative", constraints = "current_fuel >= 0"), + @Check(name = "chk_total_charged_non_negative", constraints = "total_charged >= 0"), + @Check(name = "chk_total_consumed_non_negative", constraints = "total_consumed >= 0"), + @Check(name = "chk_pending_minutes_non_negative", constraints = "pending_minutes >= 0") +}) +@Table(name = "user_fuel") +@Getter +@Builder +@AllArgsConstructor +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class UserFuel extends BaseTimeEntity { + + @Id + @Column(name = "user_id") + private Long userId; + + @Column(name = "current_fuel", nullable = false) + private Integer currentFuel; + + @Column(name = "total_charged", nullable = false) + private Integer totalCharged; + + @Column(name = "total_consumed", nullable = false) + private Integer totalConsumed; + + @Column(name = "pending_minutes", nullable = false) + private Integer pendingMinutes; + + /** + * 공부 환율: 30분 = 1 연료. + */ + public static final int MINUTES_PER_FUEL = 30; + + public static UserFuel initialize(Long userId) { + return UserFuel.builder() + .userId(userId) + .currentFuel(0) + .totalCharged(0) + .totalConsumed(0) + .pendingMinutes(0) + .build(); + } + + public void charge(int amount) { + if (amount <= 0) throw new CustomException(ErrorCode.INVALID_INPUT_VALUE); + this.currentFuel += amount; + this.totalCharged += amount; + } + + /** + * 공부 세션으로부터 연료를 충전한다. + * + * 잔여분({@link #pendingMinutes})과 이번 세션의 공부 시간을 합산하여 + * {@value #MINUTES_PER_FUEL}분 단위로 끊어 충전하고, 나머지는 다음 세션을 위해 + * 잔여분으로 이월한다. 정수 연료가 발생할 때만 {@code currentFuel} / + * {@code totalCharged}가 증가한다. + * + * @param studyMinutes 이번 세션의 공부 시간(분), 0 이하 입력은 {@link ErrorCode#INVALID_INPUT_VALUE} + * @return 충전된 연료 통 수와 갱신된 잔여 분 + */ + public ChargeFromStudyResult chargeFromStudy(int studyMinutes) { + if (studyMinutes <= 0) { + throw new CustomException(ErrorCode.INVALID_INPUT_VALUE); + } + int totalMinutes = this.pendingMinutes + studyMinutes; + int amount = totalMinutes / MINUTES_PER_FUEL; + int newPending = totalMinutes % MINUTES_PER_FUEL; + + this.pendingMinutes = newPending; + if (amount > 0) { + this.currentFuel += amount; + this.totalCharged += amount; + } + return new ChargeFromStudyResult(amount, newPending); + } + + /** + * {@link #chargeFromStudy(int)} 결과 — Entity 내부 계산 결과를 서비스에 전달하기 위한 값 객체. + */ + public record ChargeFromStudyResult(int amount, int newPendingMinutes) {} + + public void consume(int amount) { + if (amount <= 0) throw new CustomException(ErrorCode.INVALID_INPUT_VALUE); + if (this.currentFuel < amount) { + throw new CustomException(ErrorCode.INSUFFICIENT_FUEL); + } + this.currentFuel -= amount; + this.totalConsumed += amount; + } +} diff --git a/SS-Study/src/main/java/com/elipair/spacestudyship/study/fuel/repository/FuelTransactionRepository.java b/SS-Study/src/main/java/com/elipair/spacestudyship/study/fuel/repository/FuelTransactionRepository.java new file mode 100644 index 0000000..851e658 --- /dev/null +++ b/SS-Study/src/main/java/com/elipair/spacestudyship/study/fuel/repository/FuelTransactionRepository.java @@ -0,0 +1,33 @@ +package com.elipair.spacestudyship.study.fuel.repository; + +import com.elipair.spacestudyship.study.fuel.constant.TransactionType; +import com.elipair.spacestudyship.study.fuel.entity.FuelTransaction; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +import java.time.LocalDateTime; + +public interface FuelTransactionRepository extends JpaRepository { + + /** + * PostgreSQL JDBC 드라이버가 nullable 파라미터(enum/timestamp)의 타입을 추론하지 못하는 이슈로 + * `:param IS NULL` 대신 `CAST(:param AS Type) IS NULL` 패턴을 사용한다. CAST는 null 체크 분기에서만 사용되고 + * 실제 필터 비교(`ft.type = :type` 등)는 원래의 타입 바인딩 그대로 동작한다. + */ + @Query(""" + SELECT ft FROM FuelTransaction ft + WHERE ft.userId = :userId + AND (CAST(:type AS String) IS NULL OR ft.type = :type) + AND (CAST(:startDateTime AS LocalDateTime) IS NULL OR ft.createdAt >= :startDateTime) + AND (CAST(:endDateTime AS LocalDateTime) IS NULL OR ft.createdAt < :endDateTime) + """) + Page findByFilters( + @Param("userId") Long userId, + @Param("type") TransactionType type, + @Param("startDateTime") LocalDateTime startDateTime, + @Param("endDateTime") LocalDateTime endDateTime, + Pageable pageable); +} diff --git a/SS-Study/src/main/java/com/elipair/spacestudyship/study/fuel/repository/UserFuelRepository.java b/SS-Study/src/main/java/com/elipair/spacestudyship/study/fuel/repository/UserFuelRepository.java new file mode 100644 index 0000000..3402f28 --- /dev/null +++ b/SS-Study/src/main/java/com/elipair/spacestudyship/study/fuel/repository/UserFuelRepository.java @@ -0,0 +1,21 @@ +package com.elipair.spacestudyship.study.fuel.repository; + +import com.elipair.spacestudyship.study.fuel.entity.UserFuel; +import jakarta.persistence.LockModeType; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Lock; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +import java.util.Optional; + +public interface UserFuelRepository extends JpaRepository { + + Optional findByUserId(Long userId); + + @Lock(LockModeType.PESSIMISTIC_WRITE) + @Query("SELECT uf FROM UserFuel uf WHERE uf.userId = :userId") + Optional findByUserIdForUpdate(@Param("userId") Long userId); + + boolean existsByUserId(Long userId); +} diff --git a/SS-Study/src/main/java/com/elipair/spacestudyship/study/fuel/service/FuelInitializeListener.java b/SS-Study/src/main/java/com/elipair/spacestudyship/study/fuel/service/FuelInitializeListener.java new file mode 100644 index 0000000..17e81be --- /dev/null +++ b/SS-Study/src/main/java/com/elipair/spacestudyship/study/fuel/service/FuelInitializeListener.java @@ -0,0 +1,22 @@ +package com.elipair.spacestudyship.study.fuel.service; + +import com.elipair.spacestudyship.member.event.MemberCreatedEvent; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; +import org.springframework.transaction.event.TransactionPhase; +import org.springframework.transaction.event.TransactionalEventListener; + +@Slf4j +@Component +@RequiredArgsConstructor +public class FuelInitializeListener { + + private final FuelService fuelService; + + @TransactionalEventListener(phase = TransactionPhase.BEFORE_COMMIT) + public void onMemberCreated(MemberCreatedEvent event) { + log.info("[Fuel] MemberCreatedEvent 수신 | memberId={}", event.memberId()); + fuelService.initialize(event.memberId()); + } +} diff --git a/SS-Study/src/main/java/com/elipair/spacestudyship/study/fuel/service/FuelService.java b/SS-Study/src/main/java/com/elipair/spacestudyship/study/fuel/service/FuelService.java new file mode 100644 index 0000000..c3e0837 --- /dev/null +++ b/SS-Study/src/main/java/com/elipair/spacestudyship/study/fuel/service/FuelService.java @@ -0,0 +1,230 @@ +package com.elipair.spacestudyship.study.fuel.service; + +import com.elipair.spacestudyship.common.exception.CustomException; +import com.elipair.spacestudyship.common.exception.ErrorCode; +import com.elipair.spacestudyship.study.fuel.constant.FuelReason; +import com.elipair.spacestudyship.study.fuel.constant.TransactionType; +import com.elipair.spacestudyship.study.fuel.dto.FuelChargeFromStudyResult; +import com.elipair.spacestudyship.study.fuel.dto.FuelResponse; +import com.elipair.spacestudyship.study.fuel.dto.FuelTransactionListResponse; +import com.elipair.spacestudyship.study.fuel.dto.FuelTransactionResponse; +import com.elipair.spacestudyship.study.fuel.entity.FuelTransaction; +import com.elipair.spacestudyship.study.fuel.entity.UserFuel; +import com.elipair.spacestudyship.study.fuel.repository.FuelTransactionRepository; +import com.elipair.spacestudyship.study.fuel.repository.UserFuelRepository; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Sort; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.util.Optional; + +@Slf4j +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class FuelService { + + private final UserFuelRepository userFuelRepository; + private final FuelTransactionRepository transactionRepository; + + public FuelResponse getFuel(Long userId) { + UserFuel fuel = userFuelRepository.findByUserId(userId) + .orElseThrow(() -> new CustomException(ErrorCode.FUEL_NOT_INITIALIZED)); + return FuelResponse.from(fuel); + } + + public FuelTransactionListResponse getTransactions( + Long userId, TransactionType type, + String startDate, String endDate, + int page, int size) { + + LocalDateTime startDateTime = startDate == null ? null + : LocalDate.parse(startDate).atStartOfDay(); + LocalDateTime endDateTime = endDate == null ? null + : LocalDate.parse(endDate).plusDays(1).atStartOfDay(); + + Pageable pageable = PageRequest.of(page, size, + Sort.by(Sort.Direction.DESC, "createdAt")); + + Page result = transactionRepository + .findByFilters(userId, type, startDateTime, endDateTime, pageable); + + return FuelTransactionListResponse.from(result); + } + + @Transactional + public FuelTransactionResponse charge( + Long userId, int amount, FuelReason reason, + String referenceId, String transactionId) { + + if (amount <= 0) throw new CustomException(ErrorCode.INVALID_INPUT_VALUE); + + Optional existing = transactionRepository.findById(transactionId); + if (existing.isPresent()) { + return idempotentReturn(existing.get(), userId, "charge", transactionId); + } + + UserFuel fuel = userFuelRepository.findByUserIdForUpdate(userId) + .orElseThrow(() -> new CustomException(ErrorCode.FUEL_NOT_INITIALIZED)); + + // 락 획득 사이에 다른 트랜잭션이 동일 transactionId로 먼저 save한 경우의 경쟁 조건 방어 + Optional raced = transactionRepository.findById(transactionId); + if (raced.isPresent()) { + return idempotentReturn(raced.get(), userId, "charge", transactionId); + } + + fuel.charge(amount); + + FuelTransaction tx = FuelTransaction.of( + transactionId, userId, TransactionType.CHARGE, + amount, reason, referenceId, fuel.getCurrentFuel()); + transactionRepository.save(tx); + + log.info("[Fuel] 충전 | userId={}, amount={}, reason={}, txId={}, balanceAfter={}", + userId, amount, reason, transactionId, fuel.getCurrentFuel()); + return FuelTransactionResponse.from(tx); + } + + private FuelTransactionResponse idempotentReturn(FuelTransaction tx, Long userId, + String action, String transactionId) { + if (!tx.getUserId().equals(userId)) { + throw new CustomException(ErrorCode.INVALID_INPUT_VALUE); + } + log.info("[Fuel] {} idempotent skip | userId={}, txId={}", action, userId, transactionId); + return FuelTransactionResponse.from(tx); + } + + @Transactional + public FuelTransactionResponse consume( + Long userId, int amount, FuelReason reason, + String referenceId, String transactionId) { + + if (amount <= 0) throw new CustomException(ErrorCode.INVALID_INPUT_VALUE); + + Optional existing = transactionRepository.findById(transactionId); + if (existing.isPresent()) { + return idempotentReturn(existing.get(), userId, "consume", transactionId); + } + + UserFuel fuel = userFuelRepository.findByUserIdForUpdate(userId) + .orElseThrow(() -> new CustomException(ErrorCode.FUEL_NOT_INITIALIZED)); + + Optional raced = transactionRepository.findById(transactionId); + if (raced.isPresent()) { + return idempotentReturn(raced.get(), userId, "consume", transactionId); + } + + fuel.consume(amount); + + FuelTransaction tx = FuelTransaction.of( + transactionId, userId, TransactionType.CONSUME, + amount, reason, referenceId, fuel.getCurrentFuel()); + transactionRepository.save(tx); + + log.info("[Fuel] 소비 | userId={}, amount={}, reason={}, txId={}, balanceAfter={}", + userId, amount, reason, transactionId, fuel.getCurrentFuel()); + return FuelTransactionResponse.from(tx); + } + + /** + * 타이머 세션 완료로 인한 연료 충전. + * + * 환율: 30분 = 1 연료 (잔여분은 {@link UserFuel#pendingMinutes}로 이월). + * + *

Idempotency 계약

+ * Idempotency 키는 {@code sessionId} (= {@link FuelTransaction#getId()}). + * + * + * 현재 단일 호출자({@code TimerSessionService.create})가 매 호출마다 신규 UUID를 생성하므로 + * 위 amount=0 시나리오는 발생하지 않는다. 향후 직접 호출자를 추가할 때는 이 계약을 확인하라. + */ + @Transactional + public FuelChargeFromStudyResult chargeFromStudy( + Long userId, int studyMinutes, String sessionId) { + + if (studyMinutes <= 0) throw new CustomException(ErrorCode.INVALID_INPUT_VALUE); + + Optional existing = transactionRepository.findById(sessionId); + if (existing.isPresent()) { + return idempotentReturnFromStudy(existing.get(), userId, sessionId); + } + + UserFuel fuel = userFuelRepository.findByUserIdForUpdate(userId) + .orElseThrow(() -> new CustomException(ErrorCode.FUEL_NOT_INITIALIZED)); + + // 락 획득 후 race 재확인 (다른 트랜잭션이 동일 sessionId로 먼저 INSERT했을 가능성) + Optional raced = transactionRepository.findById(sessionId); + if (raced.isPresent()) { + return idempotentReturnFromStudy(raced.get(), userId, sessionId); + } + + UserFuel.ChargeFromStudyResult result = fuel.chargeFromStudy(studyMinutes); + + // amount=0이면 transaction을 만들지 않는다 — pending만 갱신. + // fuel_transactions의 chk_fuel_tx_amount_positive 제약으로 amount=0 INSERT 불가능하기도 하고, + // 거래 내역(GET /api/fuel/transactions)에 0연료 노이즈 row 노출을 피하기 위함. + // 대신 sessionId 유일성은 호출자(TimerSessionService)가 보장해야 한다. (위 Javadoc 참조) + if (result.amount() > 0) { + FuelTransaction tx = FuelTransaction.of( + sessionId, userId, TransactionType.CHARGE, + result.amount(), FuelReason.STUDY_SESSION, + sessionId, fuel.getCurrentFuel()); + transactionRepository.save(tx); + } + + log.info("[Fuel] 공부 세션 충전 | userId={}, studyMinutes={}, amount={}, " + + "pendingMinutes={}, balanceAfter={}, sessionId={}", + userId, studyMinutes, result.amount(), + result.newPendingMinutes(), fuel.getCurrentFuel(), sessionId); + return new FuelChargeFromStudyResult( + result.amount(), result.newPendingMinutes(), fuel.getCurrentFuel()); + } + + private FuelChargeFromStudyResult idempotentReturnFromStudy( + FuelTransaction tx, Long userId, String sessionId) { + if (!tx.getUserId().equals(userId)) { + throw new CustomException(ErrorCode.INVALID_INPUT_VALUE); + } + UserFuel fuel = userFuelRepository.findByUserId(userId) + .orElseThrow(() -> new CustomException(ErrorCode.FUEL_NOT_INITIALIZED)); + log.info("[Fuel] 공부 세션 idempotent skip | userId={}, sessionId={}", userId, sessionId); + return new FuelChargeFromStudyResult( + tx.getAmount(), fuel.getPendingMinutes(), fuel.getCurrentFuel()); + } + + /** + * 특정 sessionId에 대응하는 충전 transaction의 amount를 조회한다. + * Timer 도메인이 dedup 응답을 만들 때 사용 (transaction이 없으면 0). + */ + public int findChargedAmountBySessionId(String sessionId) { + return transactionRepository.findById(sessionId) + .map(FuelTransaction::getAmount) + .orElse(0); + } + + @Transactional + public void initialize(Long userId) { + if (userFuelRepository.existsByUserId(userId)) { + log.info("[Fuel] 초기화 스킵 (이미 존재) | userId={}", userId); + return; + } + userFuelRepository.save(UserFuel.initialize(userId)); + log.info("[Fuel] 초기화 | userId={}", userId); + } +} diff --git a/SS-Study/src/main/java/com/elipair/spacestudyship/study/timer/dto/TimerSessionCreateRequest.java b/SS-Study/src/main/java/com/elipair/spacestudyship/study/timer/dto/TimerSessionCreateRequest.java new file mode 100644 index 0000000..5a9694c --- /dev/null +++ b/SS-Study/src/main/java/com/elipair/spacestudyship/study/timer/dto/TimerSessionCreateRequest.java @@ -0,0 +1,24 @@ +package com.elipair.spacestudyship.study.timer.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Size; + +import java.time.Instant; + +public record TimerSessionCreateRequest( + @Schema(description = "연결된 Todo ID (없으면 null)", example = "todo-uuid-5678") + @Size(max = 36) String todoId, + + @Schema(description = "Todo 제목 스냅샷 (Todo 삭제 후 표시용)", example = "수학 문제 풀기") + @Size(max = 100) String todoTitle, + + @Schema(description = "타이머 시작 시각 (ISO 8601 UTC)", example = "2026-05-25T00:00:00Z") + @NotNull Instant startedAt, + + @Schema(description = "타이머 종료 시각 (ISO 8601 UTC)", example = "2026-05-25T01:30:00Z") + @NotNull Instant endedAt, + + @Schema(description = "실제 공부 시간 (분, 일시정지 제외)", example = "90") + @NotNull Integer durationMinutes +) {} diff --git a/SS-Study/src/main/java/com/elipair/spacestudyship/study/timer/dto/TimerSessionCreateResponse.java b/SS-Study/src/main/java/com/elipair/spacestudyship/study/timer/dto/TimerSessionCreateResponse.java new file mode 100644 index 0000000..f276678 --- /dev/null +++ b/SS-Study/src/main/java/com/elipair/spacestudyship/study/timer/dto/TimerSessionCreateResponse.java @@ -0,0 +1,9 @@ +package com.elipair.spacestudyship.study.timer.dto; + +import io.swagger.v3.oas.annotations.media.Schema; + +@Schema(description = "세션 저장 응답") +public record TimerSessionCreateResponse( + TimerSessionResponse session, + @Schema(description = "서버에서 검증 후 충전된 연료량") Integer fuelCharged +) {} diff --git a/SS-Study/src/main/java/com/elipair/spacestudyship/study/timer/dto/TimerSessionListResponse.java b/SS-Study/src/main/java/com/elipair/spacestudyship/study/timer/dto/TimerSessionListResponse.java new file mode 100644 index 0000000..ced08f9 --- /dev/null +++ b/SS-Study/src/main/java/com/elipair/spacestudyship/study/timer/dto/TimerSessionListResponse.java @@ -0,0 +1,21 @@ +package com.elipair.spacestudyship.study.timer.dto; + +import com.elipair.spacestudyship.study.timer.entity.TimerSession; +import org.springframework.data.domain.Page; + +import java.util.List; + +public record TimerSessionListResponse( + List content, + Integer page, + Integer size, + Long totalElements, + Integer totalPages +) { + public static TimerSessionListResponse from(Page page) { + return new TimerSessionListResponse( + page.getContent().stream().map(TimerSessionResponse::from).toList(), + page.getNumber(), page.getSize(), + page.getTotalElements(), page.getTotalPages()); + } +} diff --git a/SS-Study/src/main/java/com/elipair/spacestudyship/study/timer/dto/TimerSessionResponse.java b/SS-Study/src/main/java/com/elipair/spacestudyship/study/timer/dto/TimerSessionResponse.java new file mode 100644 index 0000000..8293897 --- /dev/null +++ b/SS-Study/src/main/java/com/elipair/spacestudyship/study/timer/dto/TimerSessionResponse.java @@ -0,0 +1,25 @@ +package com.elipair.spacestudyship.study.timer.dto; + +import com.elipair.spacestudyship.study.timer.entity.TimerSession; +import io.swagger.v3.oas.annotations.media.Schema; + +import java.time.Instant; +import java.time.ZoneOffset; + +@Schema(description = "타이머 세션 단건") +public record TimerSessionResponse( + String id, + String todoId, + String todoTitle, + Instant startedAt, + Instant endedAt, + Integer durationMinutes +) { + public static TimerSessionResponse from(TimerSession s) { + return new TimerSessionResponse( + s.getId(), s.getTodoId(), s.getTodoTitle(), + s.getStartedAt().atOffset(ZoneOffset.UTC).toInstant(), + s.getEndedAt().atOffset(ZoneOffset.UTC).toInstant(), + s.getDurationMinutes()); + } +} diff --git a/SS-Study/src/main/java/com/elipair/spacestudyship/study/timer/dto/TodayStatsResponse.java b/SS-Study/src/main/java/com/elipair/spacestudyship/study/timer/dto/TodayStatsResponse.java new file mode 100644 index 0000000..2b2e7b8 --- /dev/null +++ b/SS-Study/src/main/java/com/elipair/spacestudyship/study/timer/dto/TodayStatsResponse.java @@ -0,0 +1,13 @@ +package com.elipair.spacestudyship.study.timer.dto; + +import io.swagger.v3.oas.annotations.media.Schema; + +@Schema(description = "오늘 공부 통계 + 누적 통계 (KST 기준)") +public record TodayStatsResponse( + @Schema(description = "오늘 총 공부 시간 (분)") Integer totalMinutes, + @Schema(description = "오늘 완료한 세션 수") Integer sessionCount, + @Schema(description = "연속 공부 일수 (오늘 포함, KST 기준)") Integer streak, + @Schema(description = "회원의 전체 누적 공부 시간 (분)") Integer lifetimeMinutes, + @Schema(description = "회원의 전체 세션 수") Integer lifetimeSessionCount, + @Schema(description = "이번 달 누적 공부 시간 (분, KST 기준)") Integer monthlyMinutes +) {} diff --git a/SS-Study/src/main/java/com/elipair/spacestudyship/study/timer/entity/TimerSession.java b/SS-Study/src/main/java/com/elipair/spacestudyship/study/timer/entity/TimerSession.java new file mode 100644 index 0000000..96536cd --- /dev/null +++ b/SS-Study/src/main/java/com/elipair/spacestudyship/study/timer/entity/TimerSession.java @@ -0,0 +1,84 @@ +package com.elipair.spacestudyship.study.timer.entity; + +import com.elipair.spacestudyship.common.entity.BaseTimeEntity; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.Id; +import jakarta.persistence.Index; +import jakarta.persistence.Table; +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import org.hibernate.annotations.Check; +import org.hibernate.annotations.Checks; + +import java.time.LocalDateTime; + +/** + * 공부 타이머 세션 기록. + * + * 시간 필드(startedAt/endedAt)는 모두 UTC로 해석한다. + * 서비스 진입 시점에 Instant → LocalDateTime UTC 변환을 거친다. + * + * id는 서버 생성 UUID이며, Fuel 충전 시 transactionId로 재사용되어 + * 충전 idempotency를 보장한다. + */ +@Entity +@Checks({ + @Check(name = "chk_timer_duration_positive", constraints = "duration_minutes > 0"), + @Check(name = "chk_timer_duration_max", constraints = "duration_minutes <= 1440"), + @Check(name = "chk_timer_time_order", constraints = "ended_at > started_at") +}) +// JPA 표준 @Index.columnList는 정렬 방향(ASC/DESC)을 지원하지 않아 Hibernate가 +// silently 무시한다. 정렬은 쿼리 레벨(Sort.by Direction.DESC)에서 처리하며, +// production 인덱스는 Flyway 마이그레이션(V0_0_39__add_timer_sessions.sql)에서 +// (user_id, started_at DESC)로 명시적으로 생성한다. +@Table(name = "timer_sessions", + indexes = { + @Index(name = "idx_timer_sessions_user_started", columnList = "user_id, started_at"), + @Index(name = "idx_timer_sessions_user_todo", columnList = "user_id, todo_id") + }) +@Getter +@Builder +@AllArgsConstructor +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class TimerSession extends BaseTimeEntity { + + @Id + @Column(length = 36) + private String id; + + @Column(name = "user_id", nullable = false) + private Long userId; + + @Column(name = "todo_id", length = 36) + private String todoId; + + @Column(name = "todo_title", length = 100) + private String todoTitle; + + @Column(name = "started_at", nullable = false) + private LocalDateTime startedAt; + + @Column(name = "ended_at", nullable = false) + private LocalDateTime endedAt; + + @Column(name = "duration_minutes", nullable = false) + private Integer durationMinutes; + + @Column(name = "idempotency_key", length = 80) + private String idempotencyKey; + + public static TimerSession of(String id, Long userId, String todoId, String todoTitle, + LocalDateTime startedAt, LocalDateTime endedAt, + int durationMinutes, String idempotencyKey) { + return TimerSession.builder() + .id(id).userId(userId).todoId(todoId).todoTitle(todoTitle) + .startedAt(startedAt).endedAt(endedAt) + .durationMinutes(durationMinutes) + .idempotencyKey(idempotencyKey) + .build(); + } +} diff --git a/SS-Study/src/main/java/com/elipair/spacestudyship/study/timer/repository/TimerSessionRepository.java b/SS-Study/src/main/java/com/elipair/spacestudyship/study/timer/repository/TimerSessionRepository.java new file mode 100644 index 0000000..94747eb --- /dev/null +++ b/SS-Study/src/main/java/com/elipair/spacestudyship/study/timer/repository/TimerSessionRepository.java @@ -0,0 +1,59 @@ +package com.elipair.spacestudyship.study.timer.repository; + +import com.elipair.spacestudyship.study.timer.entity.TimerSession; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +import java.time.LocalDateTime; +import java.util.List; +import java.util.Optional; + +public interface TimerSessionRepository extends JpaRepository { + + Optional findByUserIdAndIdempotencyKey(Long userId, String idempotencyKey); + + /** + * PostgreSQL JDBC 드라이버가 nullable 파라미터의 타입을 추론하지 못하는 이슈로 + * `:param IS NULL` 대신 `CAST(:param AS Type) IS NULL` 패턴을 사용한다. + * FuelTransactionRepository와 동일한 해결 방식. + */ + @Query(""" + SELECT s FROM TimerSession s + WHERE s.userId = :userId + AND (CAST(:start AS LocalDateTime) IS NULL OR s.startedAt >= :start) + AND (CAST(:end AS LocalDateTime) IS NULL OR s.startedAt < :end) + AND (CAST(:todoId AS String) IS NULL OR s.todoId = :todoId) + """) + Page findByFilters( + @Param("userId") Long userId, + @Param("start") LocalDateTime start, + @Param("end") LocalDateTime end, + @Param("todoId") String todoId, + Pageable pageable); + + // JPQL SUM(Integer state-field) → Long 반환이 JPA 스펙. COALESCE 기본값도 0L로 맞춘다. + @Query("SELECT COALESCE(SUM(s.durationMinutes), 0L) FROM TimerSession s " + + "WHERE s.userId = :userId AND s.startedAt >= :start AND s.startedAt < :end") + Long sumDurationBetween(@Param("userId") Long userId, + @Param("start") LocalDateTime start, + @Param("end") LocalDateTime end); + + long countByUserIdAndStartedAtGreaterThanEqualAndStartedAtLessThan( + Long userId, LocalDateTime start, LocalDateTime end); + + @Query("SELECT s.startedAt FROM TimerSession s " + + "WHERE s.userId = :userId AND s.startedAt >= :start") + List findStartedAtsAfter(@Param("userId") Long userId, + @Param("start") LocalDateTime start); + + // 전체 누적 분: SUM(Integer) → Long, COALESCE로 NULL 방지 + @Query("SELECT COALESCE(SUM(s.durationMinutes), 0L) FROM TimerSession s " + + "WHERE s.userId = :userId") + Long sumDurationByUserId(@Param("userId") Long userId); + + // 전체 세션 수: Spring Data 명명 규칙 + long countByUserId(Long userId); +} diff --git a/SS-Study/src/main/java/com/elipair/spacestudyship/study/timer/service/TimerSessionService.java b/SS-Study/src/main/java/com/elipair/spacestudyship/study/timer/service/TimerSessionService.java new file mode 100644 index 0000000..d8e9cd4 --- /dev/null +++ b/SS-Study/src/main/java/com/elipair/spacestudyship/study/timer/service/TimerSessionService.java @@ -0,0 +1,227 @@ +package com.elipair.spacestudyship.study.timer.service; + +import com.elipair.spacestudyship.common.exception.CustomException; +import com.elipair.spacestudyship.common.exception.ErrorCode; +import com.elipair.spacestudyship.study.fuel.dto.FuelChargeFromStudyResult; +import com.elipair.spacestudyship.study.fuel.service.FuelService; +import com.elipair.spacestudyship.study.timer.dto.*; +import com.elipair.spacestudyship.study.timer.entity.TimerSession; +import com.elipair.spacestudyship.study.timer.repository.TimerSessionRepository; +import com.elipair.spacestudyship.study.todo.service.TodoService; +import lombok.extern.slf4j.Slf4j; +import org.springframework.dao.DataIntegrityViolationException; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Sort; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.*; +import java.util.List; +import java.util.Optional; +import java.util.TreeSet; +import java.util.UUID; +import java.util.stream.Collectors; + +@Slf4j +@Service +@Transactional(readOnly = true) +public class TimerSessionService { + + private static final ZoneId ZONE_KST = ZoneId.of("Asia/Seoul"); + private static final long CLOCK_SKEW_TOLERANCE_SECONDS = 300; + private static final int STREAK_LOOKBACK_DAYS = 365; + + private final TimerSessionRepository sessionRepository; + private final FuelService fuelService; + private final TodoService todoService; + private final Clock clock; + + public TimerSessionService(TimerSessionRepository sessionRepository, + FuelService fuelService, + TodoService todoService, + Clock clock) { + this.sessionRepository = sessionRepository; + this.fuelService = fuelService; + this.todoService = todoService; + this.clock = clock; + } + + @Transactional + public TimerSessionCreateResponse create( + Long userId, TimerSessionCreateRequest request, String idempotencyKey) { + + String normalizedKey = (idempotencyKey == null || idempotencyKey.isBlank()) + ? null : idempotencyKey.trim(); + + if (normalizedKey != null) { + Optional existing = sessionRepository + .findByUserIdAndIdempotencyKey(userId, normalizedKey); + if (existing.isPresent()) { + log.info("[Timer] idempotent skip | userId={}, key={}, sessionId={}", + userId, normalizedKey, existing.get().getId()); + int existingFuelCharged = fuelService.findChargedAmountBySessionId(existing.get().getId()); + return buildResponse(existing.get(), existingFuelCharged); + } + } + + LocalDateTime startedAtUtc = LocalDateTime.ofInstant(request.startedAt(), ZoneOffset.UTC); + LocalDateTime endedAtUtc = LocalDateTime.ofInstant(request.endedAt(), ZoneOffset.UTC); + validate(startedAtUtc, endedAtUtc, request.durationMinutes()); + + String sessionId = UUID.randomUUID().toString(); + TimerSession session = TimerSession.of( + sessionId, userId, + request.todoId(), request.todoTitle(), + startedAtUtc, endedAtUtc, request.durationMinutes(), + normalizedKey); + + try { + // saveAndFlush로 INSERT를 즉시 DB에 반영해 unique constraint 위반을 + // 이 try 블록 안에서 잡을 수 있도록 보장한다. 일반 save()는 flush를 + // 트랜잭션 커밋 시점까지 미루므로 race 복구 분기가 동작하지 않는다. + sessionRepository.saveAndFlush(session); + } catch (DataIntegrityViolationException e) { + if (normalizedKey != null) { + Optional raced = sessionRepository + .findByUserIdAndIdempotencyKey(userId, normalizedKey); + if (raced.isPresent()) { + log.info("[Timer] idempotent race resolved | userId={}, key={}", + userId, normalizedKey); + int racedFuelCharged = fuelService.findChargedAmountBySessionId(raced.get().getId()); + return buildResponse(raced.get(), racedFuelCharged); + } + } + throw e; + } + + // 30분 = 1연료 환산. 잔여분은 user_fuel.pendingMinutes에 이월되어 다음 세션과 합산. + FuelChargeFromStudyResult fuelResult = fuelService.chargeFromStudy( + userId, request.durationMinutes(), sessionId); + int fuelCharged = fuelResult.amount(); + + // Todo actualMinutes는 실제 공부 분(durationMinutes)으로 누적 (연료 환산과 무관) + if (request.todoId() != null) { + todoService.addActualMinutes(userId, request.todoId(), request.durationMinutes()); + } + + log.info("[Timer] 세션 저장 | userId={}, sessionId={}, studyMinutes={}, fuelCharged={}, pendingMinutes={}, todoId={}", + userId, sessionId, request.durationMinutes(), fuelCharged, + fuelResult.newPendingMinutes(), request.todoId()); + return buildResponse(session, fuelCharged); + } + + private void validate(LocalDateTime startedAt, LocalDateTime endedAt, int durationMinutes) { + if (!endedAt.isAfter(startedAt)) { + throw new CustomException(ErrorCode.INVALID_SESSION_TIME); + } + long elapsedMinutes = Duration.between(startedAt, endedAt).toMinutes(); + if (durationMinutes > elapsedMinutes) { + throw new CustomException(ErrorCode.INVALID_DURATION); + } + if (durationMinutes < 1) { + throw new CustomException(ErrorCode.SESSION_TOO_SHORT); + } + if (durationMinutes > 1440) { + throw new CustomException(ErrorCode.SESSION_TOO_LONG); + } + LocalDateTime now = LocalDateTime.now(clock); + if (startedAt.isAfter(now.plusSeconds(CLOCK_SKEW_TOLERANCE_SECONDS))) { + throw new CustomException(ErrorCode.FUTURE_SESSION); + } + } + + private TimerSessionCreateResponse buildResponse(TimerSession session, int fuelCharged) { + return new TimerSessionCreateResponse(TimerSessionResponse.from(session), fuelCharged); + } + + public TimerSessionListResponse getList( + Long userId, String startDate, String endDate, String todoId, + int page, int size) { + + // 빈/공백 문자열은 null로 정규화 — `?todoId=` 같은 빈 파라미터를 + // "필터 미적용"으로 해석 (그대로 두면 JPQL `s.todoId = ''` 매칭으로 빈 결과 반환) + String normalizedStartDate = blankToNull(startDate); + String normalizedEndDate = blankToNull(endDate); + String normalizedTodoId = blankToNull(todoId); + + LocalDateTime start = normalizedStartDate == null ? null + : LocalDate.parse(normalizedStartDate).atStartOfDay(); + LocalDateTime end = normalizedEndDate == null ? null + : LocalDate.parse(normalizedEndDate).plusDays(1).atStartOfDay(); + + Pageable pageable = PageRequest.of(page, size, + Sort.by(Sort.Direction.DESC, "startedAt")); + Page result = sessionRepository.findByFilters( + userId, start, end, normalizedTodoId, pageable); + return TimerSessionListResponse.from(result); + } + + private static String blankToNull(String s) { + return (s == null || s.isBlank()) ? null : s.trim(); + } + + public TodayStatsResponse getTodayStats(Long userId) { + LocalDate todayKst = LocalDate.now(clock.withZone(ZONE_KST)); + LocalDateTime todayStartUtc = toUtcLdt(todayKst.atStartOfDay(ZONE_KST)); + LocalDateTime tomorrowStartUtc = toUtcLdt(todayKst.plusDays(1).atStartOfDay(ZONE_KST)); + + long totalMinutes = Optional.ofNullable( + sessionRepository.sumDurationBetween(userId, todayStartUtc, tomorrowStartUtc)) + .orElse(0L); + long sessionCount = sessionRepository + .countByUserIdAndStartedAtGreaterThanEqualAndStartedAtLessThan( + userId, todayStartUtc, tomorrowStartUtc); + + LocalDateTime lookbackStart = toUtcLdt( + todayKst.minusDays(STREAK_LOOKBACK_DAYS).atStartOfDay(ZONE_KST)); + List startedAts = sessionRepository + .findStartedAtsAfter(userId, lookbackStart); + int streak = computeStreak(startedAts, todayKst); + + // 이번 달 KST 경계 → UTC 변환 후 기존 sumDurationBetween 재사용 + LocalDate monthStartKst = todayKst.withDayOfMonth(1); + LocalDateTime monthStartUtc = toUtcLdt(monthStartKst.atStartOfDay(ZONE_KST)); + LocalDateTime monthEndUtc = toUtcLdt(monthStartKst.plusMonths(1).atStartOfDay(ZONE_KST)); + long monthlyMinutes = Optional.ofNullable( + sessionRepository.sumDurationBetween(userId, monthStartUtc, monthEndUtc)) + .orElse(0L); + + // 전체 누적 (lifetime) — Repository에서 COALESCE로 NULL-safe + long lifetimeMinutes = Optional.ofNullable( + sessionRepository.sumDurationByUserId(userId)).orElse(0L); + long lifetimeSessionCount = sessionRepository.countByUserId(userId); + + return new TodayStatsResponse( + Math.toIntExact(totalMinutes), + (int) sessionCount, + streak, + Math.toIntExact(lifetimeMinutes), + Math.toIntExact(lifetimeSessionCount), + Math.toIntExact(monthlyMinutes) + ); + } + + private LocalDateTime toUtcLdt(ZonedDateTime kst) { + return kst.withZoneSameInstant(ZoneOffset.UTC).toLocalDateTime(); + } + + private int computeStreak(List startedAtsUtc, LocalDate todayKst) { + TreeSet studyDays = startedAtsUtc.stream() + .map(ts -> ts.atZone(ZoneOffset.UTC).withZoneSameInstant(ZONE_KST).toLocalDate()) + .collect(Collectors.toCollection(TreeSet::new)); + if (studyDays.isEmpty()) return 0; + + LocalDate latest = studyDays.last(); + LocalDate cursor = latest.isAfter(todayKst) ? todayKst : latest; + if (cursor.isBefore(todayKst.minusDays(1))) return 0; + + int streak = 0; + while (studyDays.contains(cursor)) { + streak++; + cursor = cursor.minusDays(1); + } + return streak; + } +} diff --git a/SS-Study/src/main/java/com/elipair/spacestudyship/study/todo/dto/CategoryCreateRequest.java b/SS-Study/src/main/java/com/elipair/spacestudyship/study/todo/dto/CategoryCreateRequest.java new file mode 100644 index 0000000..8c2dfb0 --- /dev/null +++ b/SS-Study/src/main/java/com/elipair/spacestudyship/study/todo/dto/CategoryCreateRequest.java @@ -0,0 +1,34 @@ +package com.elipair.spacestudyship.study.todo.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.DecimalMax; +import jakarta.validation.constraints.DecimalMin; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Size; + +@Schema(description = "카테고리 생성 요청") +public record CategoryCreateRequest( + + @Schema(description = "클라이언트 UUID (없으면 서버 생성)", nullable = true, + example = "cat-uuid-3") + String id, + + @Schema(description = "카테고리 이름 (1~20자)", example = "수학") + @NotBlank + @Size(max = 20) + String name, + + @Schema(description = "아이콘 식별자", nullable = true, example = "math_icon") + String iconId, + + @Schema(description = "맵 가로 위치 (0.0~1.0)", nullable = true, example = "0.3") + @DecimalMin("0.0") + @DecimalMax("1.0") + Double positionX, + + @Schema(description = "맵 세로 위치 (0.0~1.0)", nullable = true, example = "0.5") + @DecimalMin("0.0") + @DecimalMax("1.0") + Double positionY +) { +} diff --git a/SS-Study/src/main/java/com/elipair/spacestudyship/study/todo/dto/CategoryResponse.java b/SS-Study/src/main/java/com/elipair/spacestudyship/study/todo/dto/CategoryResponse.java new file mode 100644 index 0000000..df34b60 --- /dev/null +++ b/SS-Study/src/main/java/com/elipair/spacestudyship/study/todo/dto/CategoryResponse.java @@ -0,0 +1,38 @@ +package com.elipair.spacestudyship.study.todo.dto; + +import com.elipair.spacestudyship.study.todo.entity.TodoCategory; +import io.swagger.v3.oas.annotations.media.Schema; + +import java.time.LocalDateTime; +import java.time.ZoneOffset; +import java.time.format.DateTimeFormatter; + +@Schema(description = "카테고리 응답") +public record CategoryResponse( + @Schema(description = "카테고리 ID", example = "cat-uuid-1") String id, + @Schema(description = "이름", example = "수학") String name, + @Schema(description = "아이콘 식별자", nullable = true) String iconId, + @Schema(description = "맵 가로 위치", nullable = true) Double positionX, + @Schema(description = "맵 세로 위치", nullable = true) Double positionY, + @Schema(description = "생성 시각 (ISO 8601 UTC)") String createdAt, + @Schema(description = "마지막 수정 시각 (ISO 8601 UTC)", nullable = true) String updatedAt +) { + private static final DateTimeFormatter ISO_UTC = + DateTimeFormatter.ISO_INSTANT; + + public static CategoryResponse from(TodoCategory category) { + return new CategoryResponse( + category.getId(), + category.getName(), + category.getIconId(), + category.getPositionX(), + category.getPositionY(), + formatUtc(category.getCreatedAt()), + formatUtc(category.getUpdatedAt()) + ); + } + + private static String formatUtc(LocalDateTime time) { + return time == null ? null : ISO_UTC.format(time.toInstant(ZoneOffset.UTC)); + } +} diff --git a/SS-Study/src/main/java/com/elipair/spacestudyship/study/todo/dto/CategoryUpdateRequest.java b/SS-Study/src/main/java/com/elipair/spacestudyship/study/todo/dto/CategoryUpdateRequest.java new file mode 100644 index 0000000..0321b38 --- /dev/null +++ b/SS-Study/src/main/java/com/elipair/spacestudyship/study/todo/dto/CategoryUpdateRequest.java @@ -0,0 +1,30 @@ +package com.elipair.spacestudyship.study.todo.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.DecimalMax; +import jakarta.validation.constraints.DecimalMin; +import jakarta.validation.constraints.Pattern; +import jakarta.validation.constraints.Size; + +@Schema(description = "카테고리 부분 수정 요청 — 전송하지 않은 필드는 기존 값 유지") +public record CategoryUpdateRequest( + + @Schema(description = "카테고리 이름 (1~20자, 공백만 입력 불가)", nullable = true) + @Size(min = 1, max = 20) + @Pattern(regexp = ".*\\S.*", message = "name: 공백만으로 구성될 수 없습니다.") + String name, + + @Schema(description = "아이콘 식별자", nullable = true) + String iconId, + + @Schema(description = "맵 가로 위치 (0.0~1.0)", nullable = true) + @DecimalMin("0.0") + @DecimalMax("1.0") + Double positionX, + + @Schema(description = "맵 세로 위치 (0.0~1.0)", nullable = true) + @DecimalMin("0.0") + @DecimalMax("1.0") + Double positionY +) { +} diff --git a/SS-Study/src/main/java/com/elipair/spacestudyship/study/todo/dto/TodoCreateRequest.java b/SS-Study/src/main/java/com/elipair/spacestudyship/study/todo/dto/TodoCreateRequest.java new file mode 100644 index 0000000..b862887 --- /dev/null +++ b/SS-Study/src/main/java/com/elipair/spacestudyship/study/todo/dto/TodoCreateRequest.java @@ -0,0 +1,33 @@ +package com.elipair.spacestudyship.study.todo.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Pattern; +import jakarta.validation.constraints.Size; + +import java.util.List; + +@Schema(description = "할 일 생성 요청") +public record TodoCreateRequest( + + @Schema(description = "클라이언트 UUID v4 (없으면 서버 생성)", nullable = true, + example = "550e8400-e29b-41d4-a716-446655440000") + String id, + + @Schema(description = "제목 (1~100자)", example = "수학 문제 풀기") + @NotBlank + @Size(max = 100) + String title, + + @Schema(description = "카테고리 ID 목록 (기본 [])", example = "[\"cat-uuid-1\"]") + List<@Pattern(regexp = "[a-zA-Z0-9-]+", message = "categoryIds: 영숫자와 하이픈만 허용합니다.") String> categoryIds, + + @Schema(description = "예상 소요 시간(분, 1 이상)", nullable = true, example = "60") + @Min(1) + Integer estimatedMinutes, + + @Schema(description = "예정 날짜 목록 (YYYY-MM-DD)", example = "[\"2026-04-16\"]") + List<@Pattern(regexp = "\\d{4}-\\d{2}-\\d{2}", message = "scheduledDates: YYYY-MM-DD 형식이어야 합니다.") String> scheduledDates +) { +} diff --git a/SS-Study/src/main/java/com/elipair/spacestudyship/study/todo/dto/TodoResponse.java b/SS-Study/src/main/java/com/elipair/spacestudyship/study/todo/dto/TodoResponse.java new file mode 100644 index 0000000..a37c524 --- /dev/null +++ b/SS-Study/src/main/java/com/elipair/spacestudyship/study/todo/dto/TodoResponse.java @@ -0,0 +1,51 @@ +package com.elipair.spacestudyship.study.todo.dto; + +import com.elipair.spacestudyship.study.todo.entity.Todo; +import io.swagger.v3.oas.annotations.media.Schema; + +import java.time.LocalDateTime; +import java.time.ZoneOffset; +import java.time.format.DateTimeFormatter; +import java.util.List; + +@Schema(description = "할 일 응답") +public record TodoResponse( + @Schema(description = "Todo ID", example = "550e8400-e29b-41d4-a716-446655440000") + String id, + + @Schema(description = "제목") String title, + + @Schema(description = "예정 날짜 목록") List scheduledDates, + + @Schema(description = "완료 날짜 목록") List completedDates, + + @Schema(description = "카테고리 ID 목록") List categoryIds, + + @Schema(description = "예상 소요 시간(분)", nullable = true) Integer estimatedMinutes, + + @Schema(description = "실제 소요 시간(분)", nullable = true) Integer actualMinutes, + + @Schema(description = "생성 시각 (ISO 8601 UTC)") String createdAt, + + @Schema(description = "마지막 수정 시각 (ISO 8601 UTC)") String updatedAt +) { + private static final DateTimeFormatter ISO_UTC = DateTimeFormatter.ISO_INSTANT; + + public static TodoResponse from(Todo todo) { + return new TodoResponse( + todo.getId(), + todo.getTitle(), + todo.getScheduledDates(), + todo.getCompletedDates(), + todo.getCategoryIds(), + todo.getEstimatedMinutes(), + todo.getActualMinutes(), + formatUtc(todo.getCreatedAt()), + formatUtc(todo.getUpdatedAt()) + ); + } + + private static String formatUtc(LocalDateTime time) { + return time == null ? null : ISO_UTC.format(time.toInstant(ZoneOffset.UTC)); + } +} diff --git a/SS-Study/src/main/java/com/elipair/spacestudyship/study/todo/dto/TodoUpdateRequest.java b/SS-Study/src/main/java/com/elipair/spacestudyship/study/todo/dto/TodoUpdateRequest.java new file mode 100644 index 0000000..6f486e9 --- /dev/null +++ b/SS-Study/src/main/java/com/elipair/spacestudyship/study/todo/dto/TodoUpdateRequest.java @@ -0,0 +1,35 @@ +package com.elipair.spacestudyship.study.todo.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.Pattern; +import jakarta.validation.constraints.Size; + +import java.util.List; + +@Schema(description = "할 일 부분 수정 요청 — 전송하지 않은 필드는 기존 값 유지") +public record TodoUpdateRequest( + + @Schema(description = "제목 (1~100자, 공백만 입력 불가)", nullable = true) + @Size(min = 1, max = 100) + @Pattern(regexp = ".*\\S.*", message = "title: 공백만으로 구성될 수 없습니다.") + String title, + + @Schema(description = "예정 날짜 목록 (YYYY-MM-DD)", nullable = true) + List<@Pattern(regexp = "\\d{4}-\\d{2}-\\d{2}", message = "scheduledDates: YYYY-MM-DD 형식이어야 합니다.") String> scheduledDates, + + @Schema(description = "완료 날짜 목록 (YYYY-MM-DD)", nullable = true) + List<@Pattern(regexp = "\\d{4}-\\d{2}-\\d{2}", message = "completedDates: YYYY-MM-DD 형식이어야 합니다.") String> completedDates, + + @Schema(description = "카테고리 ID 목록", nullable = true) + List<@Pattern(regexp = "[a-zA-Z0-9-]+", message = "categoryIds: 영숫자와 하이픈만 허용합니다.") String> categoryIds, + + @Schema(description = "예상 소요 시간(분)", nullable = true) + @Min(1) + Integer estimatedMinutes, + + @Schema(description = "실제 소요 시간(분)", nullable = true) + @Min(0) + Integer actualMinutes +) { +} diff --git a/SS-Study/src/main/java/com/elipair/spacestudyship/study/todo/entity/Todo.java b/SS-Study/src/main/java/com/elipair/spacestudyship/study/todo/entity/Todo.java new file mode 100644 index 0000000..8a05a4a --- /dev/null +++ b/SS-Study/src/main/java/com/elipair/spacestudyship/study/todo/entity/Todo.java @@ -0,0 +1,100 @@ +package com.elipair.spacestudyship.study.todo.entity; + +import com.elipair.spacestudyship.common.entity.BaseTimeEntity; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.Id; +import jakarta.persistence.Table; +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import org.hibernate.annotations.JdbcTypeCode; +import org.hibernate.type.SqlTypes; + +import java.util.ArrayList; +import java.util.List; +import java.util.stream.Collectors; + +@Entity +@Table(name = "todos") +@Getter +@Builder +@AllArgsConstructor +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class Todo extends BaseTimeEntity { + + @Id + @Column(length = 36) + private String id; + + @Column(name = "user_id", nullable = false) + private Long userId; + + @Column(nullable = false, length = 100) + private String title; + + @JdbcTypeCode(SqlTypes.JSON) + @Column(name = "scheduled_dates", nullable = false, columnDefinition = "jsonb") + private List scheduledDates; + + @JdbcTypeCode(SqlTypes.JSON) + @Column(name = "completed_dates", nullable = false, columnDefinition = "jsonb") + private List completedDates; + + @JdbcTypeCode(SqlTypes.JSON) + @Column(name = "category_ids", nullable = false, columnDefinition = "jsonb") + private List categoryIds; + + @Column(name = "estimated_minutes") + private Integer estimatedMinutes; + + @Column(name = "actual_minutes") + private Integer actualMinutes; + + public static Todo create(String id, Long userId, String title, + List scheduledDates, + List categoryIds, + Integer estimatedMinutes) { + return Todo.builder() + .id(id) + .userId(userId) + .title(title) + .scheduledDates(scheduledDates == null ? new ArrayList<>() : new ArrayList<>(scheduledDates)) + .completedDates(new ArrayList<>()) + .categoryIds(categoryIds == null ? new ArrayList<>() : new ArrayList<>(categoryIds)) + .estimatedMinutes(estimatedMinutes) + .build(); + } + + public void updateTitle(String title) { + this.title = title; + } + + public void updateScheduledDates(List dates) { + this.scheduledDates = new ArrayList<>(dates); + } + + public void updateCompletedDates(List dates) { + this.completedDates = new ArrayList<>(dates); + } + + public void updateCategoryIds(List ids) { + this.categoryIds = new ArrayList<>(ids); + } + + public void updateEstimatedMinutes(Integer minutes) { + this.estimatedMinutes = minutes; + } + + public void updateActualMinutes(Integer minutes) { + this.actualMinutes = minutes; + } + + public void removeCategoryId(String categoryId) { + this.categoryIds = this.categoryIds.stream() + .filter(id -> !java.util.Objects.equals(id, categoryId)) + .collect(Collectors.toCollection(ArrayList::new)); + } +} diff --git a/SS-Study/src/main/java/com/elipair/spacestudyship/study/todo/entity/TodoCategory.java b/SS-Study/src/main/java/com/elipair/spacestudyship/study/todo/entity/TodoCategory.java new file mode 100644 index 0000000..268625b --- /dev/null +++ b/SS-Study/src/main/java/com/elipair/spacestudyship/study/todo/entity/TodoCategory.java @@ -0,0 +1,68 @@ +package com.elipair.spacestudyship.study.todo.entity; + +import com.elipair.spacestudyship.common.entity.BaseTimeEntity; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.Id; +import jakarta.persistence.Table; +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Entity +@Table(name = "todo_categories") +@Getter +@Builder +@AllArgsConstructor +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class TodoCategory extends BaseTimeEntity { + + @Id + @Column(length = 36) + private String id; + + @Column(name = "user_id", nullable = false) + private Long userId; + + @Column(nullable = false, length = 20) + private String name; + + @Column(name = "icon_id", length = 50) + private String iconId; + + @Column(name = "position_x") + private Double positionX; + + @Column(name = "position_y") + private Double positionY; + + public static TodoCategory create(String id, Long userId, String name, + String iconId, Double positionX, Double positionY) { + return TodoCategory.builder() + .id(id) + .userId(userId) + .name(name) + .iconId(iconId) + .positionX(positionX) + .positionY(positionY) + .build(); + } + + public void updateName(String name) { + this.name = name; + } + + public void updateIconId(String iconId) { + this.iconId = iconId; + } + + public void updatePositionX(Double positionX) { + this.positionX = positionX; + } + + public void updatePositionY(Double positionY) { + this.positionY = positionY; + } +} diff --git a/SS-Study/src/main/java/com/elipair/spacestudyship/study/todo/repository/TodoCategoryRepository.java b/SS-Study/src/main/java/com/elipair/spacestudyship/study/todo/repository/TodoCategoryRepository.java new file mode 100644 index 0000000..0f6058f --- /dev/null +++ b/SS-Study/src/main/java/com/elipair/spacestudyship/study/todo/repository/TodoCategoryRepository.java @@ -0,0 +1,19 @@ +package com.elipair.spacestudyship.study.todo.repository; + +import com.elipair.spacestudyship.study.todo.entity.TodoCategory; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.Collection; +import java.util.List; +import java.util.Optional; + +public interface TodoCategoryRepository extends JpaRepository { + + List findByUserIdOrderByCreatedAtAsc(Long userId); + + boolean existsByIdAndUserId(String id, Long userId); + + Optional findByIdAndUserId(String id, Long userId); + + long countByIdInAndUserId(Collection ids, Long userId); +} diff --git a/SS-Study/src/main/java/com/elipair/spacestudyship/study/todo/repository/TodoRepository.java b/SS-Study/src/main/java/com/elipair/spacestudyship/study/todo/repository/TodoRepository.java new file mode 100644 index 0000000..aa2c22d --- /dev/null +++ b/SS-Study/src/main/java/com/elipair/spacestudyship/study/todo/repository/TodoRepository.java @@ -0,0 +1,46 @@ +package com.elipair.spacestudyship.study.todo.repository; + +import com.elipair.spacestudyship.study.todo.entity.Todo; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Modifying; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +import java.util.List; +import java.util.Optional; + +public interface TodoRepository extends JpaRepository { + + List findByUserIdOrderByCreatedAtDesc(Long userId); + + @Query(value = """ + SELECT * FROM todos + WHERE user_id = :userId + AND scheduled_dates @> CAST(:dateJson AS jsonb) + ORDER BY created_at DESC + """, nativeQuery = true) + List findByUserIdAndScheduledDate(@Param("userId") Long userId, + @Param("dateJson") String dateJsonLiteral); + + @Query(value = """ + SELECT * FROM todos + WHERE user_id = :userId + AND category_ids @> CAST(:categoryJson AS jsonb) + ORDER BY created_at DESC + """, nativeQuery = true) + List findByUserIdAndCategoryId(@Param("userId") Long userId, + @Param("categoryJson") String categoryJsonLiteral); + + boolean existsByIdAndUserId(String id, Long userId); + + Optional findByIdAndUserId(String id, Long userId); + + long deleteByIdAndUserId(String id, Long userId); + + @Modifying + @Query("UPDATE Todo t SET t.actualMinutes = COALESCE(t.actualMinutes, 0) + :minutes " + + "WHERE t.id = :todoId AND t.userId = :userId") + int addActualMinutes(@Param("userId") Long userId, + @Param("todoId") String todoId, + @Param("minutes") int minutes); +} diff --git a/SS-Study/src/main/java/com/elipair/spacestudyship/study/todo/service/TodoCategoryService.java b/SS-Study/src/main/java/com/elipair/spacestudyship/study/todo/service/TodoCategoryService.java new file mode 100644 index 0000000..b0e9f84 --- /dev/null +++ b/SS-Study/src/main/java/com/elipair/spacestudyship/study/todo/service/TodoCategoryService.java @@ -0,0 +1,77 @@ +package com.elipair.spacestudyship.study.todo.service; + +import com.elipair.spacestudyship.common.exception.CustomException; +import com.elipair.spacestudyship.common.exception.ErrorCode; +import com.elipair.spacestudyship.study.todo.dto.CategoryCreateRequest; +import com.elipair.spacestudyship.study.todo.dto.CategoryResponse; +import com.elipair.spacestudyship.study.todo.dto.CategoryUpdateRequest; +import com.elipair.spacestudyship.study.todo.entity.TodoCategory; +import com.elipair.spacestudyship.study.todo.repository.TodoCategoryRepository; +import com.elipair.spacestudyship.study.todo.repository.TodoRepository; +import jakarta.persistence.EntityManager; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; +import java.util.UUID; + +@Slf4j +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class TodoCategoryService { + + private final TodoCategoryRepository categoryRepository; + private final TodoRepository todoRepository; + private final EntityManager entityManager; + + public List findAll(Long userId) { + return categoryRepository.findByUserIdOrderByCreatedAtAsc(userId) + .stream().map(CategoryResponse::from).toList(); + } + + @Transactional + public CategoryResponse create(Long userId, CategoryCreateRequest request) { + String id = request.id() != null ? request.id() : UUID.randomUUID().toString(); + if (categoryRepository.existsById(id)) { + throw new CustomException(ErrorCode.CATEGORY_ALREADY_EXISTS); + } + TodoCategory category = TodoCategory.create( + id, userId, request.name(), + request.iconId(), request.positionX(), request.positionY()); + TodoCategory saved = categoryRepository.save(category); + entityManager.flush(); + log.info("[TodoCategory] 생성 | userId={}, categoryId={}", userId, saved.getId()); + return CategoryResponse.from(saved); + } + + @Transactional + public CategoryResponse update(Long userId, String categoryId, CategoryUpdateRequest request) { + TodoCategory category = categoryRepository.findByIdAndUserId(categoryId, userId) + .orElseThrow(() -> new CustomException(ErrorCode.CATEGORY_NOT_FOUND)); + if (request.name() != null) category.updateName(request.name()); + if (request.iconId() != null) category.updateIconId(request.iconId()); + if (request.positionX() != null) category.updatePositionX(request.positionX()); + if (request.positionY() != null) category.updatePositionY(request.positionY()); + entityManager.flush(); + log.info("[TodoCategory] 수정 | userId={}, categoryId={}", userId, categoryId); + return CategoryResponse.from(category); + } + + @Transactional + public void delete(Long userId, String categoryId) { + TodoCategory category = categoryRepository.findByIdAndUserId(categoryId, userId) + .orElseThrow(() -> new CustomException(ErrorCode.CATEGORY_NOT_FOUND)); + + List affected = + todoRepository.findByUserIdAndCategoryId(userId, "\"" + categoryId + "\""); + affected.forEach(todo -> todo.removeCategoryId(categoryId)); + // dirty checking으로 categoryIds 변경 자동 반영 + + categoryRepository.delete(category); + log.info("[TodoCategory] 삭제 | userId={}, categoryId={}, affectedTodos={}", + userId, categoryId, affected.size()); + } +} diff --git a/SS-Study/src/main/java/com/elipair/spacestudyship/study/todo/service/TodoService.java b/SS-Study/src/main/java/com/elipair/spacestudyship/study/todo/service/TodoService.java new file mode 100644 index 0000000..f5290a8 --- /dev/null +++ b/SS-Study/src/main/java/com/elipair/spacestudyship/study/todo/service/TodoService.java @@ -0,0 +1,128 @@ +package com.elipair.spacestudyship.study.todo.service; + +import com.elipair.spacestudyship.common.exception.CustomException; +import com.elipair.spacestudyship.common.exception.ErrorCode; +import com.elipair.spacestudyship.study.todo.dto.TodoCreateRequest; +import com.elipair.spacestudyship.study.todo.dto.TodoResponse; +import com.elipair.spacestudyship.study.todo.entity.Todo; +import com.elipair.spacestudyship.study.todo.repository.TodoCategoryRepository; +import com.elipair.spacestudyship.study.todo.repository.TodoRepository; +import jakarta.persistence.EntityManager; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; +import java.util.Set; +import java.util.UUID; +import java.util.stream.Collectors; + +@Slf4j +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class TodoService { + + private final TodoRepository todoRepository; + private final TodoCategoryRepository categoryRepository; + private final EntityManager entityManager; + + public List findAll(Long userId, String date, String categoryId) { + List todos; + if (date != null && categoryId != null) { + Set byDateIds = todoRepository + .findByUserIdAndScheduledDate(userId, jsonLiteral(date)) + .stream() + .map(Todo::getId) + .collect(Collectors.toSet()); + todos = todoRepository + .findByUserIdAndCategoryId(userId, jsonLiteral(categoryId)) + .stream() + .filter(t -> byDateIds.contains(t.getId())) + .toList(); + } else if (date != null) { + todos = todoRepository.findByUserIdAndScheduledDate(userId, jsonLiteral(date)); + } else if (categoryId != null) { + todos = todoRepository.findByUserIdAndCategoryId(userId, jsonLiteral(categoryId)); + } else { + todos = todoRepository.findByUserIdOrderByCreatedAtDesc(userId); + } + return todos.stream().map(TodoResponse::from).toList(); + } + + private static String jsonLiteral(String value) { + return "\"" + value + "\""; + } + + @Transactional + public TodoResponse create(Long userId, TodoCreateRequest request) { + String id = request.id() != null ? request.id() : UUID.randomUUID().toString(); + if (todoRepository.existsById(id)) { + throw new CustomException(ErrorCode.TODO_ALREADY_EXISTS); + } + validateCategoryIds(userId, request.categoryIds()); + + Todo todo = Todo.create( + id, userId, request.title(), + request.scheduledDates(), + request.categoryIds(), + request.estimatedMinutes()); + Todo saved = todoRepository.save(todo); + entityManager.flush(); + log.info("[Todo] 생성 | userId={}, todoId={}", userId, saved.getId()); + return TodoResponse.from(saved); + } + + private void validateCategoryIds(Long userId, List categoryIds) { + if (categoryIds == null || categoryIds.isEmpty()) return; + List distinct = categoryIds.stream().distinct().toList(); + long found = categoryRepository.countByIdInAndUserId(distinct, userId); + if (found != distinct.size()) { + throw new CustomException(ErrorCode.CATEGORY_NOT_FOUND); + } + } + + @Transactional + public TodoResponse update(Long userId, String todoId, + com.elipair.spacestudyship.study.todo.dto.TodoUpdateRequest request) { + Todo todo = todoRepository.findByIdAndUserId(todoId, userId) + .orElseThrow(() -> new CustomException(ErrorCode.TODO_NOT_FOUND)); + + if (request.categoryIds() != null) { + validateCategoryIds(userId, request.categoryIds()); + todo.updateCategoryIds(request.categoryIds()); + } + if (request.title() != null) todo.updateTitle(request.title()); + if (request.scheduledDates() != null) todo.updateScheduledDates(request.scheduledDates()); + if (request.completedDates() != null) todo.updateCompletedDates(request.completedDates()); + if (request.estimatedMinutes() != null) todo.updateEstimatedMinutes(request.estimatedMinutes()); + if (request.actualMinutes() != null) todo.updateActualMinutes(request.actualMinutes()); + + entityManager.flush(); + log.info("[Todo] 수정 | userId={}, todoId={}", userId, todoId); + return TodoResponse.from(todo); + } + + @Transactional + public void delete(Long userId, String todoId) { + long deleted = todoRepository.deleteByIdAndUserId(todoId, userId); + if (deleted == 0) { + throw new CustomException(ErrorCode.TODO_NOT_FOUND); + } + log.info("[Todo] 삭제 | userId={}, todoId={}", userId, todoId); + } + + @Transactional + public void addActualMinutes(Long userId, String todoId, int minutes) { + if (minutes <= 0) { + throw new CustomException(ErrorCode.INVALID_INPUT_VALUE); + } + int updated = todoRepository.addActualMinutes(userId, todoId, minutes); + if (updated == 0) { + throw new CustomException(ErrorCode.TODO_NOT_FOUND); + } + log.info("[Todo] actualMinutes 누적 | userId={}, todoId={}, addedMinutes={}", + userId, todoId, minutes); + } +} diff --git a/SS-Study/src/test/java/com/elipair/spacestudyship/study/StudyTestApplication.java b/SS-Study/src/test/java/com/elipair/spacestudyship/study/StudyTestApplication.java new file mode 100644 index 0000000..14d3bfb --- /dev/null +++ b/SS-Study/src/test/java/com/elipair/spacestudyship/study/StudyTestApplication.java @@ -0,0 +1,29 @@ +package com.elipair.spacestudyship.study; + +import org.springframework.boot.autoconfigure.AutoConfigurationPackage; +import org.springframework.boot.autoconfigure.ImportAutoConfiguration; +import org.springframework.boot.data.jpa.autoconfigure.DataJpaRepositoriesAutoConfiguration; +import org.springframework.boot.hibernate.autoconfigure.HibernateJpaAutoConfiguration; +import org.springframework.boot.jdbc.autoconfigure.DataSourceAutoConfiguration; +import org.springframework.boot.jdbc.autoconfigure.JdbcTemplateAutoConfiguration; +import org.springframework.boot.transaction.autoconfigure.TransactionAutoConfiguration; +import org.springframework.context.annotation.Configuration; +import org.springframework.data.jpa.repository.config.EnableJpaRepositories; + +@Configuration +@AutoConfigurationPackage(basePackages = "com.elipair.spacestudyship") +@ImportAutoConfiguration({ + DataSourceAutoConfiguration.class, + JdbcTemplateAutoConfiguration.class, + HibernateJpaAutoConfiguration.class, + TransactionAutoConfiguration.class, + DataJpaRepositoriesAutoConfiguration.class +}) +@EnableJpaRepositories(basePackages = { + "com.elipair.spacestudyship.study.todo.repository", + "com.elipair.spacestudyship.study.fuel.repository", + "com.elipair.spacestudyship.study.timer.repository", + "com.elipair.spacestudyship.study.exploration.repository" +}) +public class StudyTestApplication { +} diff --git a/SS-Study/src/test/java/com/elipair/spacestudyship/study/exploration/entity/ExplorationNodeTest.java b/SS-Study/src/test/java/com/elipair/spacestudyship/study/exploration/entity/ExplorationNodeTest.java new file mode 100644 index 0000000..23ef9e6 --- /dev/null +++ b/SS-Study/src/test/java/com/elipair/spacestudyship/study/exploration/entity/ExplorationNodeTest.java @@ -0,0 +1,25 @@ +package com.elipair.spacestudyship.study.exploration.entity; + +import com.elipair.spacestudyship.study.exploration.constant.NodeType; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +class ExplorationNodeTest { + + @Test + @DisplayName("planet 빌더: 필드 매핑") + void buildsPlanet() { + ExplorationNode node = ExplorationNode.builder() + .id("earth").name("지구").nodeType(NodeType.PLANET).depth(2) + .icon("earth").parentId(null).prerequisiteNodeId(null) + .requiredFuel(0).sortOrder(0).description("시작점") + .mapX(0.5).mapY(0.08).build(); + + assertThat(node.getId()).isEqualTo("earth"); + assertThat(node.getNodeType()).isEqualTo(NodeType.PLANET); + assertThat(node.getRequiredFuel()).isZero(); + assertThat(node.getParentId()).isNull(); + } +} diff --git a/SS-Study/src/test/java/com/elipair/spacestudyship/study/exploration/entity/UserExplorationTest.java b/SS-Study/src/test/java/com/elipair/spacestudyship/study/exploration/entity/UserExplorationTest.java new file mode 100644 index 0000000..5b234a9 --- /dev/null +++ b/SS-Study/src/test/java/com/elipair/spacestudyship/study/exploration/entity/UserExplorationTest.java @@ -0,0 +1,23 @@ +package com.elipair.spacestudyship.study.exploration.entity; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +class UserExplorationTest { + + @Test + @DisplayName("unlock 팩토리: isUnlocked=true, unlockedAt 세팅, cleared 반영") + void unlockFactory() { + UserExploration region = UserExploration.unlock(1L, "japan", true); + assertThat(region.getUserId()).isEqualTo(1L); + assertThat(region.getNodeId()).isEqualTo("japan"); + assertThat(region.isUnlocked()).isTrue(); + assertThat(region.isCleared()).isTrue(); + assertThat(region.getUnlockedAt()).isNotNull(); + + UserExploration planet = UserExploration.unlock(1L, "mars", false); + assertThat(planet.isCleared()).isFalse(); + } +} diff --git a/SS-Study/src/test/java/com/elipair/spacestudyship/study/exploration/repository/ExplorationNodeRepositoryTest.java b/SS-Study/src/test/java/com/elipair/spacestudyship/study/exploration/repository/ExplorationNodeRepositoryTest.java new file mode 100644 index 0000000..2168a97 --- /dev/null +++ b/SS-Study/src/test/java/com/elipair/spacestudyship/study/exploration/repository/ExplorationNodeRepositoryTest.java @@ -0,0 +1,56 @@ +package com.elipair.spacestudyship.study.exploration.repository; + +import com.elipair.spacestudyship.study.StudyTestApplication; +import com.elipair.spacestudyship.study.exploration.constant.NodeType; +import com.elipair.spacestudyship.study.exploration.entity.ExplorationNode; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; + +@SpringBootTest(classes = StudyTestApplication.class) +@Transactional +class ExplorationNodeRepositoryTest { + + @Autowired + ExplorationNodeRepository nodeRepository; + + private ExplorationNode planet(String id, int sort) { + return ExplorationNode.builder().id(id).name(id).nodeType(NodeType.PLANET) + .depth(2).icon(id).requiredFuel(0).sortOrder(sort) + .description("").mapX(0).mapY(0).build(); + } + + private ExplorationNode region(String id, String parent, int sort) { + return ExplorationNode.builder().id(id).name(id).nodeType(NodeType.REGION) + .depth(3).icon(id).parentId(parent).requiredFuel(1).sortOrder(sort) + .description("").mapX(0).mapY(0).build(); + } + + @Test + @DisplayName("findByNodeTypeOrderBySortOrderAsc: 타입 필터 + 정렬") + void findByNodeType_sorted() { + nodeRepository.saveAll(List.of(planet("b", 1), planet("a", 0))); + nodeRepository.saveAll(List.of(region("r1", "a", 0))); + + List planets = nodeRepository.findByNodeTypeOrderBySortOrderAsc(NodeType.PLANET); + + assertThat(planets).extracting(ExplorationNode::getId).containsExactly("a", "b"); + } + + @Test + @DisplayName("findByParentIdOrderBySortOrderAsc: 부모별 정렬 조회") + void findByParent_sorted() { + nodeRepository.save(planet("a", 0)); + nodeRepository.saveAll(List.of(region("r2", "a", 1), region("r1", "a", 0))); + + List regions = nodeRepository.findByParentIdOrderBySortOrderAsc("a"); + + assertThat(regions).extracting(ExplorationNode::getId).containsExactly("r1", "r2"); + } +} diff --git a/SS-Study/src/test/java/com/elipair/spacestudyship/study/exploration/repository/UserExplorationRepositoryTest.java b/SS-Study/src/test/java/com/elipair/spacestudyship/study/exploration/repository/UserExplorationRepositoryTest.java new file mode 100644 index 0000000..c58ace7 --- /dev/null +++ b/SS-Study/src/test/java/com/elipair/spacestudyship/study/exploration/repository/UserExplorationRepositoryTest.java @@ -0,0 +1,41 @@ +package com.elipair.spacestudyship.study.exploration.repository; + +import com.elipair.spacestudyship.study.StudyTestApplication; +import com.elipair.spacestudyship.study.exploration.entity.UserExploration; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.transaction.annotation.Transactional; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +@SpringBootTest(classes = StudyTestApplication.class) +@Transactional +class UserExplorationRepositoryTest { + + @Autowired + UserExplorationRepository repository; + + @Test + @DisplayName("findByUserId / existsByUserIdAndNodeId") + void findAndExists() { + repository.saveAndFlush(UserExploration.unlock(1L, "japan", true)); + + assertThat(repository.findByUserId(1L)).hasSize(1); + assertThat(repository.findByUserId(999L)).isEmpty(); + assertThat(repository.existsByUserIdAndNodeId(1L, "japan")).isTrue(); + assertThat(repository.existsByUserIdAndNodeId(1L, "mars")).isFalse(); + } + + @Test + @DisplayName("UNIQUE(user_id, node_id) 위반 시 예외") + void uniqueConstraint() { + repository.saveAndFlush(UserExploration.unlock(1L, "mars", false)); + + assertThatThrownBy(() -> + repository.saveAndFlush(UserExploration.unlock(1L, "mars", false))) + .isInstanceOf(Exception.class); + } +} diff --git a/SS-Study/src/test/java/com/elipair/spacestudyship/study/exploration/service/ExplorationServiceTest.java b/SS-Study/src/test/java/com/elipair/spacestudyship/study/exploration/service/ExplorationServiceTest.java new file mode 100644 index 0000000..9083f44 --- /dev/null +++ b/SS-Study/src/test/java/com/elipair/spacestudyship/study/exploration/service/ExplorationServiceTest.java @@ -0,0 +1,296 @@ +package com.elipair.spacestudyship.study.exploration.service; + +import com.elipair.spacestudyship.common.exception.CustomException; +import com.elipair.spacestudyship.common.exception.ErrorCode; +import com.elipair.spacestudyship.common.exception.InsufficientFuelException; +import com.elipair.spacestudyship.study.exploration.constant.NodeType; +import com.elipair.spacestudyship.study.exploration.dto.PlanetResponse; +import com.elipair.spacestudyship.study.exploration.dto.RegionResponse; +import com.elipair.spacestudyship.study.exploration.entity.ExplorationNode; +import com.elipair.spacestudyship.study.exploration.entity.UserExploration; +import com.elipair.spacestudyship.study.exploration.repository.ExplorationNodeRepository; +import com.elipair.spacestudyship.study.exploration.repository.UserExplorationRepository; +import com.elipair.spacestudyship.study.fuel.constant.FuelReason; +import com.elipair.spacestudyship.study.fuel.dto.FuelResponse; +import com.elipair.spacestudyship.study.fuel.dto.FuelTransactionResponse; +import com.elipair.spacestudyship.study.fuel.service.FuelService; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.util.List; +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyInt; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; + +@ExtendWith(MockitoExtension.class) +class ExplorationServiceTest { + + @Mock ExplorationNodeRepository nodeRepository; + @Mock UserExplorationRepository userExplorationRepository; + @Mock FuelService fuelService; + @InjectMocks ExplorationService service; + + private ExplorationNode planet(String id, int requiredFuel, String prereq, int sort) { + return ExplorationNode.builder().id(id).name(id).nodeType(NodeType.PLANET).depth(2) + .icon(id).parentId(null).prerequisiteNodeId(prereq) + .requiredFuel(requiredFuel).sortOrder(sort).description("").mapX(0).mapY(0).build(); + } + + private ExplorationNode region(String id, String parent, int requiredFuel, int sort) { + return ExplorationNode.builder().id(id).name(id).nodeType(NodeType.REGION).depth(3) + .icon(id).parentId(parent).prerequisiteNodeId(null) + .requiredFuel(requiredFuel).sortOrder(sort).description("").mapX(0).mapY(0).build(); + } + + @Test + @DisplayName("getPlanets: earth는 requiredFuel=0이라 암묵 해금, 진행도 파생") + void getPlanets_derivesUnlockAndProgress() { + given(nodeRepository.findByNodeTypeOrderBySortOrderAsc(NodeType.PLANET)) + .willReturn(List.of(planet("earth", 0, null, 0), planet("mercury", 3, "earth", 1))); + given(nodeRepository.findByNodeTypeOrderBySortOrderAsc(NodeType.REGION)) + .willReturn(List.of(region("korea", "earth", 0, 0), + region("japan", "earth", 1, 1))); + given(userExplorationRepository.findByUserId(1L)) + .willReturn(List.of(UserExploration.unlock(1L, "korea", true))); + + List result = service.getPlanets(1L); + + PlanetResponse earth = result.get(0); + assertThat(earth.id()).isEqualTo("earth"); + assertThat(earth.isUnlocked()).isTrue(); + assertThat(earth.isCleared()).isFalse(); + assertThat(earth.progress().clearedChildren()).isEqualTo(1); + assertThat(earth.progress().totalChildren()).isEqualTo(2); + assertThat(earth.progress().progressRatio()).isEqualTo(0.5); + + PlanetResponse mercury = result.get(1); + assertThat(mercury.isUnlocked()).isFalse(); + assertThat(mercury.prerequisiteId()).isEqualTo("earth"); + } + + @Test + @DisplayName("getRegions: 행성 없으면 PLANET_NOT_FOUND") + void getRegions_planetNotFound() { + given(nodeRepository.findById("nope")).willReturn(Optional.empty()); + + assertThatThrownBy(() -> service.getRegions(1L, "nope")) + .isInstanceOf(CustomException.class) + .extracting(e -> ((CustomException) e).getErrorCode()) + .isEqualTo(ErrorCode.PLANET_NOT_FOUND); + } + + @Test + @DisplayName("getRegions: 해금된 지역 isUnlocked/isCleared=true, korea(연료0) 암묵 해금") + void getRegions_mapsUnlock() { + given(nodeRepository.findById("earth")).willReturn(Optional.of(planet("earth", 0, null, 0))); + given(nodeRepository.findByParentIdOrderBySortOrderAsc("earth")) + .willReturn(List.of(region("korea", "earth", 0, 0), + region("japan", "earth", 1, 1))); + given(userExplorationRepository.findByUserId(1L)).willReturn(List.of()); + + List result = service.getRegions(1L, "earth"); + + assertThat(result).extracting(RegionResponse::id).containsExactly("korea", "japan"); + assertThat(result.get(0).isUnlocked()).isTrue(); // korea requiredFuel=0 → 암묵 해금 + assertThat(result.get(0).isCleared()).isTrue(); + assertThat(result.get(1).isUnlocked()).isFalse(); // japan 미해금 + } + + private FuelResponse fuel(int currentFuel) { + return new FuelResponse(currentFuel, 0, 0, 0, null); + } + + private FuelTransactionResponse tx(int amount, int balanceAfter) { + return new FuelTransactionResponse( + "tx", "consume", amount, "EXPLORATION_UNLOCK", "ref", balanceAfter, null); + } + + @Test + @DisplayName("unlockRegion: 정상 해금 — 잔량충분 + 차감 + 저장 + 마지막 지역이면 planetCleared=true") + void unlockRegion_success_lastRegionClearsPlanet() { + given(nodeRepository.findById("japan")) + .willReturn(Optional.of(region("japan", "earth", 1, 1))); + given(nodeRepository.findById("earth")) + .willReturn(Optional.of(planet("earth", 0, null, 0))); + given(userExplorationRepository.existsByUserIdAndNodeId(1L, "japan")).willReturn(false); + given(fuelService.getFuel(1L)).willReturn(fuel(250)); + given(fuelService.consume(eq(1L), eq(1), eq(FuelReason.EXPLORATION_UNLOCK), eq("japan"), anyString())) + .willReturn(tx(1, 249)); + given(userExplorationRepository.save(any(UserExploration.class))) + .willAnswer(inv -> inv.getArgument(0)); + given(nodeRepository.findByParentIdOrderBySortOrderAsc("earth")) + .willReturn(List.of(region("korea", "earth", 0, 0), region("japan", "earth", 1, 1))); + given(userExplorationRepository.findByUserId(1L)) + .willReturn(List.of(UserExploration.unlock(1L, "korea", true), + UserExploration.unlock(1L, "japan", true))); + + var result = service.unlockRegion(1L, "japan"); + + assertThat(result.region().id()).isEqualTo("japan"); + assertThat(result.region().isCleared()).isTrue(); + assertThat(result.fuelConsumed()).isEqualTo(1); + assertThat(result.currentFuel()).isEqualTo(249); + assertThat(result.planetCleared()).isTrue(); + + ArgumentCaptor captor = ArgumentCaptor.forClass(UserExploration.class); + verify(userExplorationRepository).save(captor.capture()); + assertThat(captor.getValue().getNodeId()).isEqualTo("japan"); + assertThat(captor.getValue().isCleared()).isTrue(); + } + + @Test + @DisplayName("unlockRegion: 잔량 부족 → InsufficientFuelException + consume 미호출") + void unlockRegion_insufficientFuel() { + given(nodeRepository.findById("usa")) + .willReturn(Optional.of(region("usa", "earth", 3, 8))); + given(nodeRepository.findById("earth")) + .willReturn(Optional.of(planet("earth", 0, null, 0))); + given(userExplorationRepository.existsByUserIdAndNodeId(1L, "usa")).willReturn(false); + given(fuelService.getFuel(1L)).willReturn(fuel(1)); + + assertThatThrownBy(() -> service.unlockRegion(1L, "usa")) + .isInstanceOf(InsufficientFuelException.class); + verify(fuelService, never()).consume(any(), anyInt(), any(), any(), any()); + } + + @Test + @DisplayName("unlockRegion: 부모 행성 미해금 → PLANET_LOCKED") + void unlockRegion_parentLocked() { + given(nodeRepository.findById("mars_olympus")) + .willReturn(Optional.of(region("mars_olympus", "mars", 3, 0))); + given(nodeRepository.findById("mars")) + .willReturn(Optional.of(planet("mars", 10, "venus", 3))); + given(userExplorationRepository.existsByUserIdAndNodeId(1L, "mars")).willReturn(false); + + assertThatThrownBy(() -> service.unlockRegion(1L, "mars_olympus")) + .isInstanceOf(CustomException.class) + .extracting(e -> ((CustomException) e).getErrorCode()) + .isEqualTo(ErrorCode.PLANET_LOCKED); + verify(fuelService, never()).consume(any(), anyInt(), any(), any(), any()); + } + + @Test + @DisplayName("unlockRegion: 이미 해금 → ALREADY_UNLOCKED") + void unlockRegion_alreadyUnlocked() { + given(nodeRepository.findById("japan")) + .willReturn(Optional.of(region("japan", "earth", 1, 1))); + given(nodeRepository.findById("earth")) + .willReturn(Optional.of(planet("earth", 0, null, 0))); + given(userExplorationRepository.existsByUserIdAndNodeId(1L, "japan")).willReturn(true); + + assertThatThrownBy(() -> service.unlockRegion(1L, "japan")) + .isInstanceOf(CustomException.class) + .extracting(e -> ((CustomException) e).getErrorCode()) + .isEqualTo(ErrorCode.ALREADY_UNLOCKED); + verify(fuelService, never()).consume(any(), anyInt(), any(), any(), any()); + } + + @Test + @DisplayName("unlockRegion: 없는 지역 → REGION_NOT_FOUND") + void unlockRegion_notFound() { + given(nodeRepository.findById("nope")).willReturn(Optional.empty()); + + assertThatThrownBy(() -> service.unlockRegion(1L, "nope")) + .isInstanceOf(CustomException.class) + .extracting(e -> ((CustomException) e).getErrorCode()) + .isEqualTo(ErrorCode.REGION_NOT_FOUND); + } + + @Test + @DisplayName("unlockPlanet: 선행 행성 클리어 시 정상 해금") + void unlockPlanet_success() { + given(nodeRepository.findById("mercury")) + .willReturn(Optional.of(planet("mercury", 3, "earth", 1))); + given(userExplorationRepository.existsByUserIdAndNodeId(1L, "mercury")).willReturn(false); + // korea는 requiredFuel=0(암묵 해금) — 진행도 레코드가 없어도 earth는 클리어로 간주돼야 함 + given(nodeRepository.findByParentIdOrderBySortOrderAsc("earth")) + .willReturn(List.of(region("korea", "earth", 0, 0))); + given(userExplorationRepository.findByUserId(1L)) + .willReturn(List.of()); + given(fuelService.getFuel(1L)).willReturn(fuel(100)); + given(fuelService.consume(eq(1L), eq(3), eq(FuelReason.EXPLORATION_UNLOCK), eq("mercury"), anyString())) + .willReturn(tx(3, 97)); + given(userExplorationRepository.save(any(UserExploration.class))) + .willAnswer(inv -> inv.getArgument(0)); + + var result = service.unlockPlanet(1L, "mercury"); + + assertThat(result.planet().id()).isEqualTo("mercury"); + assertThat(result.planet().isCleared()).isFalse(); + assertThat(result.fuelConsumed()).isEqualTo(3); + assertThat(result.currentFuel()).isEqualTo(97); + } + + @Test + @DisplayName("unlockPlanet: 선행 미클리어 → PREREQUISITE_NOT_CLEARED + consume 미호출") + void unlockPlanet_prerequisiteNotCleared() { + given(nodeRepository.findById("mercury")) + .willReturn(Optional.of(planet("mercury", 3, "earth", 1))); + given(userExplorationRepository.existsByUserIdAndNodeId(1L, "mercury")).willReturn(false); + given(nodeRepository.findByParentIdOrderBySortOrderAsc("earth")) + .willReturn(List.of(region("korea", "earth", 0, 0), region("japan", "earth", 1, 1))); + given(userExplorationRepository.findByUserId(1L)) + .willReturn(List.of(UserExploration.unlock(1L, "korea", true))); // 1/2만 + + assertThatThrownBy(() -> service.unlockPlanet(1L, "mercury")) + .isInstanceOf(CustomException.class) + .extracting(e -> ((CustomException) e).getErrorCode()) + .isEqualTo(ErrorCode.PREREQUISITE_NOT_CLEARED); + verify(fuelService, never()).consume(any(), anyInt(), any(), any(), any()); + } + + @Test + @DisplayName("unlockPlanet: 잔량 부족 → InsufficientFuelException + consume 미호출") + void unlockPlanet_insufficientFuel() { + given(nodeRepository.findById("mercury")) + .willReturn(Optional.of(planet("mercury", 3, "earth", 1))); + given(userExplorationRepository.existsByUserIdAndNodeId(1L, "mercury")).willReturn(false); + given(nodeRepository.findByParentIdOrderBySortOrderAsc("earth")) + .willReturn(List.of(region("korea", "earth", 0, 0))); + given(userExplorationRepository.findByUserId(1L)) + .willReturn(List.of(UserExploration.unlock(1L, "korea", true))); + given(fuelService.getFuel(1L)).willReturn(fuel(1)); + + assertThatThrownBy(() -> service.unlockPlanet(1L, "mercury")) + .isInstanceOf(InsufficientFuelException.class); + verify(fuelService, never()).consume(any(), anyInt(), any(), any(), any()); + } + + @Test + @DisplayName("unlockPlanet: 이미 해금 → ALREADY_UNLOCKED") + void unlockPlanet_alreadyUnlocked() { + given(nodeRepository.findById("mercury")) + .willReturn(Optional.of(planet("mercury", 3, "earth", 1))); + given(userExplorationRepository.existsByUserIdAndNodeId(1L, "mercury")).willReturn(true); + + assertThatThrownBy(() -> service.unlockPlanet(1L, "mercury")) + .isInstanceOf(CustomException.class) + .extracting(e -> ((CustomException) e).getErrorCode()) + .isEqualTo(ErrorCode.ALREADY_UNLOCKED); + verify(fuelService, never()).consume(any(), anyInt(), any(), any(), any()); + } + + @Test + @DisplayName("unlockPlanet: 없는 행성 → PLANET_NOT_FOUND") + void unlockPlanet_notFound() { + given(nodeRepository.findById("nope")).willReturn(Optional.empty()); + + assertThatThrownBy(() -> service.unlockPlanet(1L, "nope")) + .isInstanceOf(CustomException.class) + .extracting(e -> ((CustomException) e).getErrorCode()) + .isEqualTo(ErrorCode.PLANET_NOT_FOUND); + } +} diff --git a/SS-Study/src/test/java/com/elipair/spacestudyship/study/fuel/entity/UserFuelTest.java b/SS-Study/src/test/java/com/elipair/spacestudyship/study/fuel/entity/UserFuelTest.java new file mode 100644 index 0000000..c2bc484 --- /dev/null +++ b/SS-Study/src/test/java/com/elipair/spacestudyship/study/fuel/entity/UserFuelTest.java @@ -0,0 +1,186 @@ +package com.elipair.spacestudyship.study.fuel.entity; + +import com.elipair.spacestudyship.common.exception.CustomException; +import com.elipair.spacestudyship.common.exception.ErrorCode; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +class UserFuelTest { + + @Test + @DisplayName("initialize: 신규 회원 초기화 시 모든 값 0") + void initialize_allZero() { + UserFuel fuel = UserFuel.initialize(1L); + + assertThat(fuel.getUserId()).isEqualTo(1L); + assertThat(fuel.getCurrentFuel()).isZero(); + assertThat(fuel.getTotalCharged()).isZero(); + assertThat(fuel.getTotalConsumed()).isZero(); + assertThat(fuel.getPendingMinutes()).isZero(); + } + + @Test + @DisplayName("charge: 양수 충전 시 currentFuel과 totalCharged 증가, totalConsumed 불변") + void charge_increase() { + UserFuel fuel = UserFuel.initialize(1L); + + fuel.charge(90); + + assertThat(fuel.getCurrentFuel()).isEqualTo(90); + assertThat(fuel.getTotalCharged()).isEqualTo(90); + assertThat(fuel.getTotalConsumed()).isZero(); + } + + @Test + @DisplayName("consume: 잔량 이하 소비 시 currentFuel 감소, totalConsumed 증가, totalCharged 불변") + void consume_decrease() { + UserFuel fuel = UserFuel.initialize(1L); + fuel.charge(100); + + fuel.consume(50); + + assertThat(fuel.getCurrentFuel()).isEqualTo(50); + assertThat(fuel.getTotalConsumed()).isEqualTo(50); + assertThat(fuel.getTotalCharged()).isEqualTo(100); + } + + @Test + @DisplayName("consume: 정확히 잔량만큼 소비 시 currentFuel = 0") + void consume_exact() { + UserFuel fuel = UserFuel.initialize(1L); + fuel.charge(100); + + fuel.consume(100); + + assertThat(fuel.getCurrentFuel()).isZero(); + assertThat(fuel.getTotalConsumed()).isEqualTo(100); + } + + @Test + @DisplayName("charge: amount=0이면 INVALID_INPUT_VALUE") + void charge_zero_throws() { + UserFuel fuel = UserFuel.initialize(1L); + + assertThatThrownBy(() -> fuel.charge(0)) + .isInstanceOf(CustomException.class) + .extracting("errorCode").isEqualTo(ErrorCode.INVALID_INPUT_VALUE); + } + + @Test + @DisplayName("charge: amount<0이면 INVALID_INPUT_VALUE") + void charge_negative_throws() { + UserFuel fuel = UserFuel.initialize(1L); + + assertThatThrownBy(() -> fuel.charge(-5)) + .isInstanceOf(CustomException.class) + .extracting("errorCode").isEqualTo(ErrorCode.INVALID_INPUT_VALUE); + } + + @Test + @DisplayName("consume: amount=0이면 INVALID_INPUT_VALUE") + void consume_zero_throws() { + UserFuel fuel = UserFuel.initialize(1L); + + assertThatThrownBy(() -> fuel.consume(0)) + .isInstanceOf(CustomException.class) + .extracting("errorCode").isEqualTo(ErrorCode.INVALID_INPUT_VALUE); + } + + @Test + @DisplayName("consume: 잔량 부족 시 INSUFFICIENT_FUEL") + void consume_insufficient_throws() { + UserFuel fuel = UserFuel.initialize(1L); + fuel.charge(30); + + assertThatThrownBy(() -> fuel.consume(50)) + .isInstanceOf(CustomException.class) + .extracting("errorCode").isEqualTo(ErrorCode.INSUFFICIENT_FUEL); + } + + // ---------- chargeFromStudy (30분 = 1연료) ---------- + + @Test + @DisplayName("chargeFromStudy: 30분 정확히 → 1연료 충전, pending=0") + void chargeFromStudy_exactly30_charges1() { + UserFuel fuel = UserFuel.initialize(1L); + + UserFuel.ChargeFromStudyResult result = fuel.chargeFromStudy(30); + + assertThat(result.amount()).isEqualTo(1); + assertThat(result.newPendingMinutes()).isZero(); + assertThat(fuel.getCurrentFuel()).isEqualTo(1); + assertThat(fuel.getTotalCharged()).isEqualTo(1); + assertThat(fuel.getPendingMinutes()).isZero(); + } + + @Test + @DisplayName("chargeFromStudy: 25분 → 0연료, pending=25 누적 (transaction 없이 잔여분만 이월)") + void chargeFromStudy_under30_pendingOnly() { + UserFuel fuel = UserFuel.initialize(1L); + + UserFuel.ChargeFromStudyResult result = fuel.chargeFromStudy(25); + + assertThat(result.amount()).isZero(); + assertThat(result.newPendingMinutes()).isEqualTo(25); + assertThat(fuel.getCurrentFuel()).isZero(); + assertThat(fuel.getTotalCharged()).isZero(); + assertThat(fuel.getPendingMinutes()).isEqualTo(25); + } + + @Test + @DisplayName("chargeFromStudy: 90분 → 3연료, pending=0") + void chargeFromStudy_multiple_3() { + UserFuel fuel = UserFuel.initialize(1L); + + UserFuel.ChargeFromStudyResult result = fuel.chargeFromStudy(90); + + assertThat(result.amount()).isEqualTo(3); + assertThat(result.newPendingMinutes()).isZero(); + assertThat(fuel.getCurrentFuel()).isEqualTo(3); + } + + @Test + @DisplayName("chargeFromStudy: pending 25 + 20분 → 1연료, pending=15 (잔여분 이월 누적)") + void chargeFromStudy_pendingCarriedOver() { + UserFuel fuel = UserFuel.initialize(1L); + fuel.chargeFromStudy(25); // pending=25 + + UserFuel.ChargeFromStudyResult result = fuel.chargeFromStudy(20); + + assertThat(result.amount()).isEqualTo(1); + assertThat(result.newPendingMinutes()).isEqualTo(15); + assertThat(fuel.getCurrentFuel()).isEqualTo(1); + assertThat(fuel.getPendingMinutes()).isEqualTo(15); + } + + @Test + @DisplayName("chargeFromStudy: pending 15 + 50분 → 2연료, pending=5") + void chargeFromStudy_pendingPlusLargeStudy() { + UserFuel fuel = UserFuel.initialize(1L); + fuel.chargeFromStudy(25); + fuel.chargeFromStudy(20); // 누적 currentFuel=1, pending=15 + + UserFuel.ChargeFromStudyResult result = fuel.chargeFromStudy(50); + + assertThat(result.amount()).isEqualTo(2); + assertThat(result.newPendingMinutes()).isEqualTo(5); + assertThat(fuel.getCurrentFuel()).isEqualTo(3); // 0+1+2 + assertThat(fuel.getTotalCharged()).isEqualTo(3); + } + + @Test + @DisplayName("chargeFromStudy: studyMinutes<=0이면 INVALID_INPUT_VALUE") + void chargeFromStudy_nonPositive_throws() { + UserFuel fuel = UserFuel.initialize(1L); + + assertThatThrownBy(() -> fuel.chargeFromStudy(0)) + .isInstanceOf(CustomException.class) + .extracting("errorCode").isEqualTo(ErrorCode.INVALID_INPUT_VALUE); + assertThatThrownBy(() -> fuel.chargeFromStudy(-5)) + .isInstanceOf(CustomException.class) + .extracting("errorCode").isEqualTo(ErrorCode.INVALID_INPUT_VALUE); + } +} diff --git a/SS-Study/src/test/java/com/elipair/spacestudyship/study/fuel/repository/FuelTransactionRepositoryTest.java b/SS-Study/src/test/java/com/elipair/spacestudyship/study/fuel/repository/FuelTransactionRepositoryTest.java new file mode 100644 index 0000000..f00f77c --- /dev/null +++ b/SS-Study/src/test/java/com/elipair/spacestudyship/study/fuel/repository/FuelTransactionRepositoryTest.java @@ -0,0 +1,119 @@ +package com.elipair.spacestudyship.study.fuel.repository; + +import com.elipair.spacestudyship.study.StudyTestApplication; +import com.elipair.spacestudyship.study.fuel.constant.FuelReason; +import com.elipair.spacestudyship.study.fuel.constant.TransactionType; +import com.elipair.spacestudyship.study.fuel.entity.FuelTransaction; +import jakarta.persistence.EntityManager; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Sort; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDateTime; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +@SpringBootTest(classes = StudyTestApplication.class) +@Transactional +class FuelTransactionRepositoryTest { + + @Autowired + FuelTransactionRepository transactionRepository; + + @Autowired + EntityManager em; + + @Test + @DisplayName("findByFilters: type/날짜 모두 null이면 user의 모든 거래, createdAt DESC") + void findByFilters_noFilter() throws InterruptedException { + save("t1", 1L, TransactionType.CHARGE, 100, FuelReason.STUDY_SESSION, "s1", 100); + Thread.sleep(5); + save("t2", 1L, TransactionType.CONSUME, 30, FuelReason.EXPLORATION_UNLOCK, "r1", 70); + save("t3", 2L, TransactionType.CHARGE, 50, FuelReason.STUDY_SESSION, "s2", 50); + + Page page = transactionRepository.findByFilters( + 1L, null, null, null, + PageRequest.of(0, 20, Sort.by(Sort.Direction.DESC, "createdAt"))); + + assertThat(page.getContent()).hasSize(2); + assertThat(page.getContent().get(0).getId()).isEqualTo("t2"); + assertThat(page.getContent().get(1).getId()).isEqualTo("t1"); + } + + @Test + @DisplayName("findByFilters: type=CHARGE 필터") + void findByFilters_typeCharge() { + save("t1", 1L, TransactionType.CHARGE, 100, FuelReason.STUDY_SESSION, "s1", 100); + save("t2", 1L, TransactionType.CONSUME, 30, FuelReason.EXPLORATION_UNLOCK, "r1", 70); + + Page page = transactionRepository.findByFilters( + 1L, TransactionType.CHARGE, null, null, + PageRequest.of(0, 20, Sort.by(Sort.Direction.DESC, "createdAt"))); + + assertThat(page.getContent()).hasSize(1); + assertThat(page.getContent().get(0).getId()).isEqualTo("t1"); + } + + @Test + @DisplayName("findByFilters: 날짜 범위 [start, end) 검증") + void findByFilters_dateRange() { + LocalDateTime today = LocalDateTime.now(); + save("t1", 1L, TransactionType.CHARGE, 100, FuelReason.STUDY_SESSION, "s1", 100); + + Page in = transactionRepository.findByFilters( + 1L, null, today.minusDays(1), today.plusDays(1), + PageRequest.of(0, 20, Sort.by(Sort.Direction.DESC, "createdAt"))); + assertThat(in.getContent()).hasSize(1); + + Page out = transactionRepository.findByFilters( + 1L, null, today.plusDays(2), today.plusDays(3), + PageRequest.of(0, 20, Sort.by(Sort.Direction.DESC, "createdAt"))); + assertThat(out.getContent()).isEmpty(); + } + + @Test + @DisplayName("findByFilters: 페이지네이션 동작 - size=2, page=0/1") + void findByFilters_pagination() throws InterruptedException { + for (int i = 1; i <= 5; i++) { + save("t" + i, 1L, TransactionType.CHARGE, 10, FuelReason.STUDY_SESSION, "s" + i, 10); + Thread.sleep(2); + } + + Page p0 = transactionRepository.findByFilters( + 1L, null, null, null, + PageRequest.of(0, 2, Sort.by(Sort.Direction.DESC, "createdAt"))); + Page p1 = transactionRepository.findByFilters( + 1L, null, null, null, + PageRequest.of(1, 2, Sort.by(Sort.Direction.DESC, "createdAt"))); + + assertThat(p0.getTotalElements()).isEqualTo(5); + assertThat(p0.getTotalPages()).isEqualTo(3); + assertThat(p0.getContent()).hasSize(2); + assertThat(p1.getContent()).hasSize(2); + assertThat(p0.getContent().get(0).getId()).isNotEqualTo(p1.getContent().get(0).getId()); + } + + @Test + @DisplayName("CHECK 제약: amount=0 native insert 시 실패 (ddl-auto=create-drop + @Check 의존)") + void checkConstraint_amountPositive() { + assertThatThrownBy(() -> { + em.createNativeQuery(""" + INSERT INTO fuel_transactions + (id, user_id, type, amount, reason, balance_after, created_at, updated_at) + VALUES ('tx-zero', 1, 'CHARGE', 0, 'STUDY_SESSION', 0, NOW(), NOW()) + """).executeUpdate(); + em.flush(); + }).isInstanceOf(Exception.class); + } + + private void save(String id, Long userId, TransactionType type, int amount, + FuelReason reason, String refId, int balanceAfter) { + transactionRepository.saveAndFlush(FuelTransaction.of(id, userId, type, amount, reason, refId, balanceAfter)); + } +} diff --git a/SS-Study/src/test/java/com/elipair/spacestudyship/study/fuel/repository/UserFuelRepositoryTest.java b/SS-Study/src/test/java/com/elipair/spacestudyship/study/fuel/repository/UserFuelRepositoryTest.java new file mode 100644 index 0000000..9573710 --- /dev/null +++ b/SS-Study/src/test/java/com/elipair/spacestudyship/study/fuel/repository/UserFuelRepositoryTest.java @@ -0,0 +1,63 @@ +package com.elipair.spacestudyship.study.fuel.repository; + +import com.elipair.spacestudyship.study.StudyTestApplication; +import com.elipair.spacestudyship.study.fuel.entity.UserFuel; +import jakarta.persistence.EntityManager; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.transaction.annotation.Transactional; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +@SpringBootTest(classes = StudyTestApplication.class) +@Transactional +class UserFuelRepositoryTest { + + @Autowired + UserFuelRepository userFuelRepository; + + @Autowired + EntityManager em; + + @Test + @DisplayName("findByUserId: 초기화된 UserFuel 조회") + void findByUserId_returnsExisting() { + userFuelRepository.saveAndFlush(UserFuel.initialize(1L)); + + assertThat(userFuelRepository.findByUserId(1L)).isPresent(); + assertThat(userFuelRepository.findByUserId(999L)).isNotPresent(); + } + + @Test + @DisplayName("existsByUserId: 존재 여부 boolean 반환") + void existsByUserId_basic() { + userFuelRepository.saveAndFlush(UserFuel.initialize(1L)); + + assertThat(userFuelRepository.existsByUserId(1L)).isTrue(); + assertThat(userFuelRepository.existsByUserId(999L)).isFalse(); + } + + @Test + @DisplayName("findByUserIdForUpdate: 락 획득 후 row 반환 (smoke - 실제 락 경합은 통합 테스트 범위)") + void findByUserIdForUpdate_returnsRow() { + userFuelRepository.saveAndFlush(UserFuel.initialize(1L)); + + assertThat(userFuelRepository.findByUserIdForUpdate(1L)).isPresent(); + } + + @Test + @DisplayName("current_fuel을 음수로 update 시 CHECK 제약으로 실패 (ddl-auto=create-drop + @Check 의존)") + void checkConstraint_currentFuelNonNegative() { + UserFuel saved = userFuelRepository.saveAndFlush(UserFuel.initialize(1L)); + + assertThatThrownBy(() -> { + em.createNativeQuery("UPDATE user_fuel SET current_fuel = -1 WHERE user_id = :uid") + .setParameter("uid", saved.getUserId()) + .executeUpdate(); + em.flush(); + }).isInstanceOf(Exception.class); + } +} diff --git a/SS-Study/src/test/java/com/elipair/spacestudyship/study/fuel/service/FuelInitializeListenerTest.java b/SS-Study/src/test/java/com/elipair/spacestudyship/study/fuel/service/FuelInitializeListenerTest.java new file mode 100644 index 0000000..35b6346 --- /dev/null +++ b/SS-Study/src/test/java/com/elipair/spacestudyship/study/fuel/service/FuelInitializeListenerTest.java @@ -0,0 +1,26 @@ +package com.elipair.spacestudyship.study.fuel.service; + +import com.elipair.spacestudyship.member.event.MemberCreatedEvent; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import static org.mockito.Mockito.verify; + +@ExtendWith(MockitoExtension.class) +class FuelInitializeListenerTest { + + @Mock FuelService fuelService; + @InjectMocks FuelInitializeListener listener; + + @Test + @DisplayName("MemberCreatedEvent 수신 시 fuelService.initialize 호출") + void onMemberCreated_callsInitialize() { + listener.onMemberCreated(new MemberCreatedEvent(42L)); + + verify(fuelService).initialize(42L); + } +} diff --git a/SS-Study/src/test/java/com/elipair/spacestudyship/study/fuel/service/FuelServiceTest.java b/SS-Study/src/test/java/com/elipair/spacestudyship/study/fuel/service/FuelServiceTest.java new file mode 100644 index 0000000..211ab8d --- /dev/null +++ b/SS-Study/src/test/java/com/elipair/spacestudyship/study/fuel/service/FuelServiceTest.java @@ -0,0 +1,456 @@ +package com.elipair.spacestudyship.study.fuel.service; + +import com.elipair.spacestudyship.common.exception.CustomException; +import com.elipair.spacestudyship.common.exception.ErrorCode; +import com.elipair.spacestudyship.study.fuel.constant.FuelReason; +import com.elipair.spacestudyship.study.fuel.constant.TransactionType; +import com.elipair.spacestudyship.study.fuel.dto.FuelResponse; +import com.elipair.spacestudyship.study.fuel.dto.FuelTransactionListResponse; +import com.elipair.spacestudyship.study.fuel.dto.FuelTransactionResponse; +import com.elipair.spacestudyship.study.fuel.entity.FuelTransaction; +import com.elipair.spacestudyship.study.fuel.entity.UserFuel; +import com.elipair.spacestudyship.study.fuel.repository.FuelTransactionRepository; +import com.elipair.spacestudyship.study.fuel.repository.UserFuelRepository; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageImpl; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Sort; + +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.util.List; +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.ArgumentMatchers.isNull; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; + +@ExtendWith(MockitoExtension.class) +class FuelServiceTest { + + @Mock UserFuelRepository userFuelRepository; + @Mock FuelTransactionRepository transactionRepository; + @InjectMocks FuelService fuelService; + + @Test + @DisplayName("initialize: 미존재 회원이면 UserFuel.initialize 저장") + void initialize_newMember_saves() { + given(userFuelRepository.existsByUserId(1L)).willReturn(false); + + fuelService.initialize(1L); + + ArgumentCaptor captor = ArgumentCaptor.forClass(UserFuel.class); + verify(userFuelRepository, times(1)).save(captor.capture()); + UserFuel saved = captor.getValue(); + assertThat(saved.getUserId()).isEqualTo(1L); + assertThat(saved.getCurrentFuel()).isZero(); + assertThat(saved.getTotalCharged()).isZero(); + assertThat(saved.getTotalConsumed()).isZero(); + assertThat(saved.getPendingMinutes()).isZero(); + } + + @Test + @DisplayName("initialize: 이미 존재하면 skip (save 호출 없음)") + void initialize_existing_skips() { + given(userFuelRepository.existsByUserId(1L)).willReturn(true); + + fuelService.initialize(1L); + + verify(userFuelRepository, never()).save(any()); + } + + @Test + @DisplayName("getFuel: 존재 시 FuelResponse 반환") + void getFuel_existing_returnsResponse() { + UserFuel fuel = UserFuel.initialize(1L); + fuel.charge(100); + given(userFuelRepository.findByUserId(1L)).willReturn(Optional.of(fuel)); + + FuelResponse response = fuelService.getFuel(1L); + + assertThat(response.currentFuel()).isEqualTo(100); + assertThat(response.totalCharged()).isEqualTo(100); + assertThat(response.totalConsumed()).isZero(); + assertThat(response.pendingMinutes()).isZero(); + } + + @Test + @DisplayName("getFuel: 미초기화면 FUEL_NOT_INITIALIZED") + void getFuel_notInitialized_throws() { + given(userFuelRepository.findByUserId(1L)).willReturn(Optional.empty()); + + assertThatThrownBy(() -> fuelService.getFuel(1L)) + .isInstanceOf(CustomException.class) + .extracting("errorCode").isEqualTo(ErrorCode.FUEL_NOT_INITIALIZED); + } + + @Test + @DisplayName("getTransactions: 모든 필터 null 통과") + void getTransactions_allNulls_passesNullsAndDefaultPageable() { + given(transactionRepository.findByFilters(eq(1L), isNull(), isNull(), isNull(), any(Pageable.class))) + .willAnswer(invocation -> { + Pageable p = invocation.getArgument(4); + return new PageImpl<>(List.of(), p, 0L); + }); + + FuelTransactionListResponse response = fuelService.getTransactions( + 1L, null, null, null, 0, 20); + + assertThat(response.content()).isEmpty(); + assertThat(response.page()).isZero(); + assertThat(response.size()).isEqualTo(20); + assertThat(response.totalElements()).isZero(); + assertThat(response.totalPages()).isZero(); + } + + @Test + @DisplayName("getTransactions: startDate/endDate를 LocalDateTime 반열림 [start, end+1)로 변환") + void getTransactions_dateRange_convertsToHalfOpen() { + ArgumentCaptor startCaptor = ArgumentCaptor.forClass(LocalDateTime.class); + ArgumentCaptor endCaptor = ArgumentCaptor.forClass(LocalDateTime.class); + given(transactionRepository.findByFilters(eq(1L), eq(TransactionType.CHARGE), + startCaptor.capture(), endCaptor.capture(), any(Pageable.class))) + .willReturn(new PageImpl<>(List.of())); + + fuelService.getTransactions(1L, TransactionType.CHARGE, + "2026-04-01", "2026-04-16", 0, 20); + + assertThat(startCaptor.getValue()).isEqualTo(LocalDate.of(2026, 4, 1).atStartOfDay()); + assertThat(endCaptor.getValue()).isEqualTo(LocalDate.of(2026, 4, 17).atStartOfDay()); // +1일 + } + + @Test + @DisplayName("getTransactions: Pageable의 정렬은 createdAt DESC 강제") + void getTransactions_sortIsCreatedAtDesc() { + ArgumentCaptor pageableCaptor = ArgumentCaptor.forClass(Pageable.class); + given(transactionRepository.findByFilters(eq(1L), isNull(), isNull(), isNull(), pageableCaptor.capture())) + .willReturn(new PageImpl<>(List.of())); + + fuelService.getTransactions(1L, null, null, null, 1, 5); + + Pageable captured = pageableCaptor.getValue(); + Sort.Order order = captured.getSort().getOrderFor("createdAt"); + assertThat(captured.getPageNumber()).isEqualTo(1); + assertThat(captured.getPageSize()).isEqualTo(5); + assertThat(order).isNotNull(); + assertThat(order.getDirection()).isEqualTo(Sort.Direction.DESC); + } + + @Test + @DisplayName("getTransactions: 내용 매핑 및 envelope 필드 정합") + void getTransactions_mapsContentCorrectly() { + FuelTransaction tx = FuelTransaction.of( + "tx-1", 1L, TransactionType.CHARGE, 90, + FuelReason.STUDY_SESSION, "s-1", 350); + given(transactionRepository.findByFilters(eq(1L), isNull(), isNull(), isNull(), any(Pageable.class))) + .willReturn(new PageImpl<>(List.of(tx), Pageable.unpaged(), 1L)); + + FuelTransactionListResponse response = fuelService.getTransactions( + 1L, null, null, null, 0, 20); + + assertThat(response.content()).hasSize(1); + assertThat(response.content().get(0).id()).isEqualTo("tx-1"); + assertThat(response.content().get(0).type()).isEqualTo("charge"); + assertThat(response.content().get(0).reason()).isEqualTo("STUDY_SESSION"); + } + + @Test + @DisplayName("charge: 정상 흐름 - 락 획득 → entity.charge → tx 저장") + void charge_happy() { + UserFuel fuel = UserFuel.initialize(1L); + given(transactionRepository.findById("tx-1")).willReturn(Optional.empty()); + given(userFuelRepository.findByUserIdForUpdate(1L)).willReturn(Optional.of(fuel)); + + FuelTransactionResponse response = fuelService.charge( + 1L, 90, FuelReason.STUDY_SESSION, "s-1", "tx-1"); + + ArgumentCaptor captor = ArgumentCaptor.forClass(FuelTransaction.class); + verify(transactionRepository).save(captor.capture()); + FuelTransaction saved = captor.getValue(); + assertThat(saved.getId()).isEqualTo("tx-1"); + assertThat(saved.getUserId()).isEqualTo(1L); + assertThat(saved.getType()).isEqualTo(TransactionType.CHARGE); + assertThat(saved.getAmount()).isEqualTo(90); + assertThat(saved.getReason()).isEqualTo(FuelReason.STUDY_SESSION); + assertThat(saved.getReferenceId()).isEqualTo("s-1"); + assertThat(saved.getBalanceAfter()).isEqualTo(90); + + assertThat(response.id()).isEqualTo("tx-1"); + assertThat(response.balanceAfter()).isEqualTo(90); + assertThat(fuel.getCurrentFuel()).isEqualTo(90); + assertThat(fuel.getTotalCharged()).isEqualTo(90); + } + + @Test + @DisplayName("charge: idempotent - 동일 transactionId 재호출 시 기존 tx 반환, 락/저장 없음") + void charge_idempotent() { + FuelTransaction existing = FuelTransaction.of( + "tx-1", 1L, TransactionType.CHARGE, 90, + FuelReason.STUDY_SESSION, "s-1", 350); + given(transactionRepository.findById("tx-1")).willReturn(Optional.of(existing)); + + FuelTransactionResponse response = fuelService.charge( + 1L, 90, FuelReason.STUDY_SESSION, "s-1", "tx-1"); + + assertThat(response.id()).isEqualTo("tx-1"); + assertThat(response.balanceAfter()).isEqualTo(350); + verify(userFuelRepository, never()).findByUserIdForUpdate(any()); + verify(transactionRepository, never()).save(any()); + } + + @Test + @DisplayName("charge: 동일 transactionId지만 다른 userId의 tx 존재 시 INVALID_INPUT_VALUE") + void charge_idempotentWithDifferentUserId_throws() { + FuelTransaction otherUserTx = FuelTransaction.of( + "tx-1", 99L, TransactionType.CHARGE, 90, + FuelReason.STUDY_SESSION, "s-1", 90); + given(transactionRepository.findById("tx-1")).willReturn(Optional.of(otherUserTx)); + + assertThatThrownBy(() -> fuelService.charge( + 1L, 90, FuelReason.STUDY_SESSION, "s-1", "tx-1")) + .isInstanceOf(CustomException.class) + .extracting("errorCode").isEqualTo(ErrorCode.INVALID_INPUT_VALUE); + + verify(userFuelRepository, never()).findByUserIdForUpdate(any()); + verify(transactionRepository, never()).save(any()); + } + + @Test + @DisplayName("charge: amount<=0 시 INVALID_INPUT_VALUE") + void charge_invalidAmount_throws() { + assertThatThrownBy(() -> fuelService.charge( + 1L, 0, FuelReason.STUDY_SESSION, "s-1", "tx-1")) + .isInstanceOf(CustomException.class) + .extracting("errorCode").isEqualTo(ErrorCode.INVALID_INPUT_VALUE); + + assertThatThrownBy(() -> fuelService.charge( + 1L, -10, FuelReason.STUDY_SESSION, "s-1", "tx-1")) + .isInstanceOf(CustomException.class) + .extracting("errorCode").isEqualTo(ErrorCode.INVALID_INPUT_VALUE); + } + + @Test + @DisplayName("charge: UserFuel 미초기화 시 FUEL_NOT_INITIALIZED") + void charge_fuelNotInitialized_throws() { + given(transactionRepository.findById("tx-1")).willReturn(Optional.empty()); + given(userFuelRepository.findByUserIdForUpdate(1L)).willReturn(Optional.empty()); + + assertThatThrownBy(() -> fuelService.charge( + 1L, 90, FuelReason.STUDY_SESSION, "s-1", "tx-1")) + .isInstanceOf(CustomException.class) + .extracting("errorCode").isEqualTo(ErrorCode.FUEL_NOT_INITIALIZED); + } + + @Test + @DisplayName("consume: 정상 흐름") + void consume_happy() { + UserFuel fuel = UserFuel.initialize(1L); + fuel.charge(100); + given(transactionRepository.findById("tx-1")).willReturn(Optional.empty()); + given(userFuelRepository.findByUserIdForUpdate(1L)).willReturn(Optional.of(fuel)); + + FuelTransactionResponse response = fuelService.consume( + 1L, 30, FuelReason.EXPLORATION_UNLOCK, "region-1", "tx-1"); + + ArgumentCaptor captor = ArgumentCaptor.forClass(FuelTransaction.class); + verify(transactionRepository).save(captor.capture()); + FuelTransaction saved = captor.getValue(); + assertThat(saved.getType()).isEqualTo(TransactionType.CONSUME); + assertThat(saved.getAmount()).isEqualTo(30); + assertThat(saved.getReason()).isEqualTo(FuelReason.EXPLORATION_UNLOCK); + assertThat(saved.getReferenceId()).isEqualTo("region-1"); + assertThat(saved.getBalanceAfter()).isEqualTo(70); + + assertThat(response.balanceAfter()).isEqualTo(70); + assertThat(fuel.getCurrentFuel()).isEqualTo(70); + assertThat(fuel.getTotalConsumed()).isEqualTo(30); + } + + @Test + @DisplayName("consume: idempotent - 동일 transactionId 재호출 시 no-op") + void consume_idempotent() { + FuelTransaction existing = FuelTransaction.of( + "tx-1", 1L, TransactionType.CONSUME, 30, + FuelReason.EXPLORATION_UNLOCK, "region-1", 70); + given(transactionRepository.findById("tx-1")).willReturn(Optional.of(existing)); + + FuelTransactionResponse response = fuelService.consume( + 1L, 30, FuelReason.EXPLORATION_UNLOCK, "region-1", "tx-1"); + + assertThat(response.id()).isEqualTo("tx-1"); + verify(userFuelRepository, never()).findByUserIdForUpdate(any()); + verify(transactionRepository, never()).save(any()); + } + + @Test + @DisplayName("consume: 동일 transactionId지만 다른 userId의 tx 존재 시 INVALID_INPUT_VALUE") + void consume_idempotentWithDifferentUserId_throws() { + FuelTransaction otherUserTx = FuelTransaction.of( + "tx-1", 99L, TransactionType.CONSUME, 30, + FuelReason.EXPLORATION_UNLOCK, "region-1", 70); + given(transactionRepository.findById("tx-1")).willReturn(Optional.of(otherUserTx)); + + assertThatThrownBy(() -> fuelService.consume( + 1L, 30, FuelReason.EXPLORATION_UNLOCK, "region-1", "tx-1")) + .isInstanceOf(CustomException.class) + .extracting("errorCode").isEqualTo(ErrorCode.INVALID_INPUT_VALUE); + + verify(userFuelRepository, never()).findByUserIdForUpdate(any()); + verify(transactionRepository, never()).save(any()); + } + + @Test + @DisplayName("consume: amount<=0 시 INVALID_INPUT_VALUE") + void consume_invalidAmount_throws() { + assertThatThrownBy(() -> fuelService.consume( + 1L, 0, FuelReason.EXPLORATION_UNLOCK, "region-1", "tx-1")) + .isInstanceOf(CustomException.class) + .extracting("errorCode").isEqualTo(ErrorCode.INVALID_INPUT_VALUE); + + assertThatThrownBy(() -> fuelService.consume( + 1L, -10, FuelReason.EXPLORATION_UNLOCK, "region-1", "tx-1")) + .isInstanceOf(CustomException.class) + .extracting("errorCode").isEqualTo(ErrorCode.INVALID_INPUT_VALUE); + } + + @Test + @DisplayName("consume: 잔량 부족 시 INSUFFICIENT_FUEL (Entity 내부 던짐)") + void consume_insufficient_throws() { + UserFuel fuel = UserFuel.initialize(1L); + fuel.charge(20); + given(transactionRepository.findById("tx-1")).willReturn(Optional.empty()); + given(userFuelRepository.findByUserIdForUpdate(1L)).willReturn(Optional.of(fuel)); + + assertThatThrownBy(() -> fuelService.consume( + 1L, 30, FuelReason.EXPLORATION_UNLOCK, "region-1", "tx-1")) + .isInstanceOf(CustomException.class) + .extracting("errorCode").isEqualTo(ErrorCode.INSUFFICIENT_FUEL); + + verify(transactionRepository, never()).save(any()); + } + + @Test + @DisplayName("consume: UserFuel 미초기화 시 FUEL_NOT_INITIALIZED") + void consume_fuelNotInitialized_throws() { + given(transactionRepository.findById("tx-1")).willReturn(Optional.empty()); + given(userFuelRepository.findByUserIdForUpdate(1L)).willReturn(Optional.empty()); + + assertThatThrownBy(() -> fuelService.consume( + 1L, 30, FuelReason.EXPLORATION_UNLOCK, "region-1", "tx-1")) + .isInstanceOf(CustomException.class) + .extracting("errorCode").isEqualTo(ErrorCode.FUEL_NOT_INITIALIZED); + } + + // ---------- chargeFromStudy (30분 = 1연료, 잔여분 이월) ---------- + + @Test + @DisplayName("chargeFromStudy: 90분 → amount=3, pending=0. fuel_transactions INSERT 1회") + void chargeFromStudy_90min_charges3() { + UserFuel fuel = UserFuel.initialize(1L); + given(transactionRepository.findById("sess-1")).willReturn(Optional.empty()); + given(userFuelRepository.findByUserIdForUpdate(1L)).willReturn(Optional.of(fuel)); + + var result = fuelService.chargeFromStudy(1L, 90, "sess-1"); + + assertThat(result.amount()).isEqualTo(3); + assertThat(result.newPendingMinutes()).isZero(); + assertThat(result.currentFuel()).isEqualTo(3); + assertThat(fuel.getCurrentFuel()).isEqualTo(3); + assertThat(fuel.getPendingMinutes()).isZero(); + + ArgumentCaptor cap = ArgumentCaptor.forClass(FuelTransaction.class); + verify(transactionRepository).save(cap.capture()); + FuelTransaction tx = cap.getValue(); + assertThat(tx.getId()).isEqualTo("sess-1"); + assertThat(tx.getAmount()).isEqualTo(3); + assertThat(tx.getType()).isEqualTo(TransactionType.CHARGE); + assertThat(tx.getReason()).isEqualTo(FuelReason.STUDY_SESSION); + assertThat(tx.getReferenceId()).isEqualTo("sess-1"); + } + + @Test + @DisplayName("chargeFromStudy: 25분 → amount=0, pending=25. transaction 미생성 (pending만 누적)") + void chargeFromStudy_25min_noTransactionPendingOnly() { + UserFuel fuel = UserFuel.initialize(1L); + given(transactionRepository.findById("sess-1")).willReturn(Optional.empty()); + given(userFuelRepository.findByUserIdForUpdate(1L)).willReturn(Optional.of(fuel)); + + var result = fuelService.chargeFromStudy(1L, 25, "sess-1"); + + assertThat(result.amount()).isZero(); + assertThat(result.newPendingMinutes()).isEqualTo(25); + assertThat(result.currentFuel()).isZero(); + assertThat(fuel.getPendingMinutes()).isEqualTo(25); + + verify(transactionRepository, never()).save(any()); + } + + @Test + @DisplayName("chargeFromStudy: 동일 sessionId 재호출 → idempotent skip, fuel 변경 없음") + void chargeFromStudy_idempotent() { + UserFuel fuel = UserFuel.initialize(1L); + fuel.charge(3); // 사전 상태: 이미 3연료 있음 + FuelTransaction existing = FuelTransaction.of( + "sess-1", 1L, TransactionType.CHARGE, 3, + FuelReason.STUDY_SESSION, "sess-1", 3); + given(transactionRepository.findById("sess-1")).willReturn(Optional.of(existing)); + given(userFuelRepository.findByUserId(1L)).willReturn(Optional.of(fuel)); + + var result = fuelService.chargeFromStudy(1L, 90, "sess-1"); + + assertThat(result.amount()).isEqualTo(3); + assertThat(result.currentFuel()).isEqualTo(3); + verify(userFuelRepository, never()).findByUserIdForUpdate(any()); + verify(transactionRepository, never()).save(any()); + } + + @Test + @DisplayName("chargeFromStudy: studyMinutes<=0 → INVALID_INPUT_VALUE") + void chargeFromStudy_nonPositive_throws() { + assertThatThrownBy(() -> fuelService.chargeFromStudy(1L, 0, "sess-1")) + .isInstanceOf(CustomException.class) + .extracting("errorCode").isEqualTo(ErrorCode.INVALID_INPUT_VALUE); + assertThatThrownBy(() -> fuelService.chargeFromStudy(1L, -5, "sess-1")) + .isInstanceOf(CustomException.class) + .extracting("errorCode").isEqualTo(ErrorCode.INVALID_INPUT_VALUE); + } + + @Test + @DisplayName("chargeFromStudy: UserFuel 미초기화 시 FUEL_NOT_INITIALIZED") + void chargeFromStudy_fuelNotInitialized_throws() { + given(transactionRepository.findById("sess-1")).willReturn(Optional.empty()); + given(userFuelRepository.findByUserIdForUpdate(1L)).willReturn(Optional.empty()); + + assertThatThrownBy(() -> fuelService.chargeFromStudy(1L, 60, "sess-1")) + .isInstanceOf(CustomException.class) + .extracting("errorCode").isEqualTo(ErrorCode.FUEL_NOT_INITIALIZED); + } + + // ---------- findChargedAmountBySessionId ---------- + + @Test + @DisplayName("findChargedAmountBySessionId: transaction 있으면 amount, 없으면 0") + void findChargedAmountBySessionId() { + FuelTransaction tx = FuelTransaction.of( + "sess-1", 1L, TransactionType.CHARGE, 3, + FuelReason.STUDY_SESSION, "sess-1", 3); + given(transactionRepository.findById("sess-1")).willReturn(Optional.of(tx)); + given(transactionRepository.findById("sess-2")).willReturn(Optional.empty()); + + assertThat(fuelService.findChargedAmountBySessionId("sess-1")).isEqualTo(3); + assertThat(fuelService.findChargedAmountBySessionId("sess-2")).isZero(); + } +} diff --git a/SS-Study/src/test/java/com/elipair/spacestudyship/study/timer/entity/TimerSessionTest.java b/SS-Study/src/test/java/com/elipair/spacestudyship/study/timer/entity/TimerSessionTest.java new file mode 100644 index 0000000..27c4e6b --- /dev/null +++ b/SS-Study/src/test/java/com/elipair/spacestudyship/study/timer/entity/TimerSessionTest.java @@ -0,0 +1,45 @@ +package com.elipair.spacestudyship.study.timer.entity; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.time.LocalDateTime; + +import static org.assertj.core.api.Assertions.assertThat; + +class TimerSessionTest { + + @Test + @DisplayName("of: 모든 필드 세팅된 인스턴스 생성") + void of_setsAllFields() { + LocalDateTime start = LocalDateTime.parse("2026-05-25T01:00:00"); + LocalDateTime end = LocalDateTime.parse("2026-05-25T02:30:00"); + + TimerSession s = TimerSession.of( + "sess-1", 1L, "todo-1", "수학", + start, end, 90, "idem-1"); + + assertThat(s.getId()).isEqualTo("sess-1"); + assertThat(s.getUserId()).isEqualTo(1L); + assertThat(s.getTodoId()).isEqualTo("todo-1"); + assertThat(s.getTodoTitle()).isEqualTo("수학"); + assertThat(s.getStartedAt()).isEqualTo(start); + assertThat(s.getEndedAt()).isEqualTo(end); + assertThat(s.getDurationMinutes()).isEqualTo(90); + assertThat(s.getIdempotencyKey()).isEqualTo("idem-1"); + } + + @Test + @DisplayName("of: nullable 필드 (todoId, todoTitle, idempotencyKey) null 허용") + void of_allowsNullables() { + TimerSession s = TimerSession.of( + "sess-2", 1L, null, null, + LocalDateTime.parse("2026-05-25T01:00:00"), + LocalDateTime.parse("2026-05-25T02:00:00"), + 60, null); + + assertThat(s.getTodoId()).isNull(); + assertThat(s.getTodoTitle()).isNull(); + assertThat(s.getIdempotencyKey()).isNull(); + } +} diff --git a/SS-Study/src/test/java/com/elipair/spacestudyship/study/timer/repository/TimerSessionRepositoryTest.java b/SS-Study/src/test/java/com/elipair/spacestudyship/study/timer/repository/TimerSessionRepositoryTest.java new file mode 100644 index 0000000..2e81774 --- /dev/null +++ b/SS-Study/src/test/java/com/elipair/spacestudyship/study/timer/repository/TimerSessionRepositoryTest.java @@ -0,0 +1,218 @@ +package com.elipair.spacestudyship.study.timer.repository; + +import com.elipair.spacestudyship.study.StudyTestApplication; +import com.elipair.spacestudyship.study.timer.entity.TimerSession; +import jakarta.persistence.EntityManager; +import org.hibernate.Session; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Sort; +import org.springframework.transaction.annotation.Transactional; + +import java.sql.Savepoint; +import java.time.LocalDateTime; +import java.util.List; +import java.util.UUID; + +import static org.assertj.core.api.Assertions.assertThat; + +@SpringBootTest(classes = StudyTestApplication.class) +@Transactional +class TimerSessionRepositoryTest { + + @Autowired + TimerSessionRepository repository; + + @Autowired + EntityManager em; + + private TimerSession session(Long userId, LocalDateTime startedAt, int duration, String todoId, String idem) { + return TimerSession.of( + UUID.randomUUID().toString(), userId, todoId, todoId == null ? null : "title", + startedAt, startedAt.plusMinutes(duration), duration, idem); + } + + @Test + @DisplayName("findByUserIdAndIdempotencyKey: 동일 키 조회 시 반환") + void findByIdempotencyKey() { + TimerSession saved = repository.saveAndFlush( + session(1L, LocalDateTime.parse("2026-05-25T01:00:00"), 30, null, "idem-1")); + + assertThat(repository.findByUserIdAndIdempotencyKey(1L, "idem-1")) + .isPresent() + .get().extracting(TimerSession::getId).isEqualTo(saved.getId()); + assertThat(repository.findByUserIdAndIdempotencyKey(1L, "nope")).isEmpty(); + assertThat(repository.findByUserIdAndIdempotencyKey(2L, "idem-1")).isEmpty(); + } + + /** + * PostgreSQL에서 constraint violation 후 트랜잭션이 aborted 상태가 되므로 + * JDBC Savepoint를 사용해 충돌 INSERT 구간만 롤백하고 외부 트랜잭션을 유지한다. + */ + @Test + @DisplayName("partial unique index: 동일 (user, key) 중복 INSERT 실패, key=NULL은 다중 허용") + void partialUniqueIndex() { + repository.saveAndFlush( + session(1L, LocalDateTime.parse("2026-05-25T01:00:00"), 30, null, "idem-1")); + + // 동일 (user, key) 두번째 INSERT는 savepoint 구간에서 실패 후 rollback to savepoint. + // PostgreSQL unique_violation SQLState(23505)만 "중복 위반"으로 식별 — 다른 SQLException은 rethrow. + // PreparedStatement는 try-with-resources로 명시 close. + String sql = "INSERT INTO timer_sessions (id, user_id, todo_id, todo_title, started_at, ended_at, " + + "duration_minutes, idempotency_key, created_at, updated_at) " + + "VALUES (?, 1, NULL, NULL, " + + "'2026-05-25 02:00:00', '2026-05-25 02:30:00', 30, 'idem-1', NOW(), NOW())"; + boolean constraintViolated = em.unwrap(Session.class).doReturningWork(conn -> { + Savepoint sp = conn.setSavepoint("dup_check"); + try (java.sql.PreparedStatement ps = conn.prepareStatement(sql)) { + ps.setString(1, UUID.randomUUID().toString()); + ps.executeUpdate(); + conn.releaseSavepoint(sp); + return false; + } catch (java.sql.SQLException e) { + conn.rollback(sp); + if ("23505".equals(e.getSQLState())) { + return true; // PostgreSQL unique_violation 확인 + } + throw e; + } + }); + assertThat(constraintViolated).isTrue(); + + // 다른 user는 같은 key 허용 + repository.saveAndFlush( + session(2L, LocalDateTime.parse("2026-05-25T01:00:00"), 30, null, "idem-1")); + + // 같은 user지만 key=NULL은 다중 허용 + repository.saveAndFlush( + session(1L, LocalDateTime.parse("2026-05-25T03:00:00"), 30, null, null)); + repository.saveAndFlush( + session(1L, LocalDateTime.parse("2026-05-25T04:00:00"), 30, null, null)); + } + + @Test + @DisplayName("findByFilters: userId만 — 본인 세션 페이지네이션") + void findByFilters_userIdOnly() { + repository.saveAndFlush(session(1L, LocalDateTime.parse("2026-05-25T01:00:00"), 30, null, null)); + repository.saveAndFlush(session(1L, LocalDateTime.parse("2026-05-25T02:00:00"), 30, null, null)); + repository.saveAndFlush(session(2L, LocalDateTime.parse("2026-05-25T01:00:00"), 30, null, null)); + + Pageable pageable = PageRequest.of(0, 20, Sort.by(Sort.Direction.DESC, "startedAt")); + Page page = repository.findByFilters(1L, null, null, null, pageable); + + assertThat(page.getTotalElements()).isEqualTo(2); + assertThat(page.getContent().get(0).getStartedAt()) + .isEqualTo(LocalDateTime.parse("2026-05-25T02:00:00")); + } + + @Test + @DisplayName("findByFilters: 날짜 범위 [start, end) 반열림") + void findByFilters_dateRange() { + repository.saveAndFlush(session(1L, LocalDateTime.parse("2026-05-23T15:00:00"), 30, null, null)); + repository.saveAndFlush(session(1L, LocalDateTime.parse("2026-05-24T15:00:00"), 30, null, null)); + repository.saveAndFlush(session(1L, LocalDateTime.parse("2026-05-25T15:00:00"), 30, null, null)); + + Page page = repository.findByFilters( + 1L, + LocalDateTime.parse("2026-05-24T00:00:00"), + LocalDateTime.parse("2026-05-25T00:00:00"), + null, + PageRequest.of(0, 20, Sort.by(Sort.Direction.DESC, "startedAt"))); + + assertThat(page.getContent()).hasSize(1); + assertThat(page.getContent().get(0).getStartedAt()) + .isEqualTo(LocalDateTime.parse("2026-05-24T15:00:00")); + } + + @Test + @DisplayName("findByFilters: todoId 필터") + void findByFilters_todoId() { + repository.saveAndFlush(session(1L, LocalDateTime.parse("2026-05-25T01:00:00"), 30, "todo-A", null)); + repository.saveAndFlush(session(1L, LocalDateTime.parse("2026-05-25T02:00:00"), 30, "todo-B", null)); + repository.saveAndFlush(session(1L, LocalDateTime.parse("2026-05-25T03:00:00"), 30, null, null)); + + Page page = repository.findByFilters( + 1L, null, null, "todo-A", + PageRequest.of(0, 20, Sort.by(Sort.Direction.DESC, "startedAt"))); + + assertThat(page.getContent()).hasSize(1); + assertThat(page.getContent().get(0).getTodoId()).isEqualTo("todo-A"); + } + + @Test + @DisplayName("sumDurationBetween: 빈 결과 → 0, 정상 → 합산") + void sumDurationBetween() { + repository.saveAndFlush(session(1L, LocalDateTime.parse("2026-05-24T01:00:00"), 30, null, null)); + repository.saveAndFlush(session(1L, LocalDateTime.parse("2026-05-24T03:00:00"), 60, null, null)); + repository.saveAndFlush(session(1L, LocalDateTime.parse("2026-05-23T01:00:00"), 90, null, null)); + + Long sum = repository.sumDurationBetween(1L, + LocalDateTime.parse("2026-05-24T00:00:00"), + LocalDateTime.parse("2026-05-25T00:00:00")); + assertThat(sum).isEqualTo(90L); + + Long none = repository.sumDurationBetween(1L, + LocalDateTime.parse("2026-06-01T00:00:00"), + LocalDateTime.parse("2026-06-02T00:00:00")); + assertThat(none).isZero(); + } + + @Test + @DisplayName("count: 날짜 범위 안 세션 수") + void countBetween() { + repository.saveAndFlush(session(1L, LocalDateTime.parse("2026-05-24T01:00:00"), 30, null, null)); + repository.saveAndFlush(session(1L, LocalDateTime.parse("2026-05-24T03:00:00"), 60, null, null)); + repository.saveAndFlush(session(1L, LocalDateTime.parse("2026-05-23T01:00:00"), 90, null, null)); + + long n = repository.countByUserIdAndStartedAtGreaterThanEqualAndStartedAtLessThan( + 1L, + LocalDateTime.parse("2026-05-24T00:00:00"), + LocalDateTime.parse("2026-05-25T00:00:00")); + assertThat(n).isEqualTo(2); + } + + @Test + @DisplayName("findStartedAtsAfter: 본인 + start 이후만") + void findStartedAtsAfter() { + repository.saveAndFlush(session(1L, LocalDateTime.parse("2026-05-24T01:00:00"), 30, null, null)); + repository.saveAndFlush(session(1L, LocalDateTime.parse("2026-05-25T01:00:00"), 30, null, null)); + repository.saveAndFlush(session(1L, LocalDateTime.parse("2026-05-23T01:00:00"), 30, null, null)); + repository.saveAndFlush(session(2L, LocalDateTime.parse("2026-05-25T01:00:00"), 30, null, null)); + + List dates = repository.findStartedAtsAfter( + 1L, LocalDateTime.parse("2026-05-24T00:00:00")); + + assertThat(dates).hasSize(2); + } + + @Test + @DisplayName("sumDurationByUserId: 본인 전체 합산, 다른 user 제외, 빈 결과는 0") + void sumDurationByUserId() { + repository.saveAndFlush(session(1L, LocalDateTime.parse("2026-04-10T01:00:00"), 30, null, null)); + repository.saveAndFlush(session(1L, LocalDateTime.parse("2026-05-01T01:00:00"), 60, null, null)); + repository.saveAndFlush(session(1L, LocalDateTime.parse("2026-05-25T01:00:00"), 90, null, null)); + repository.saveAndFlush(session(2L, LocalDateTime.parse("2026-05-25T01:00:00"), 120, null, null)); + + assertThat(repository.sumDurationByUserId(1L)).isEqualTo(180L); + assertThat(repository.sumDurationByUserId(2L)).isEqualTo(120L); + assertThat(repository.sumDurationByUserId(999L)).isZero(); + } + + @Test + @DisplayName("countByUserId: 본인 세션 수, 다른 user 제외, 빈 결과는 0") + void countByUserId() { + repository.saveAndFlush(session(1L, LocalDateTime.parse("2026-05-23T01:00:00"), 30, null, null)); + repository.saveAndFlush(session(1L, LocalDateTime.parse("2026-05-24T01:00:00"), 30, null, null)); + repository.saveAndFlush(session(1L, LocalDateTime.parse("2026-05-25T01:00:00"), 30, null, null)); + repository.saveAndFlush(session(2L, LocalDateTime.parse("2026-05-25T01:00:00"), 30, null, null)); + + assertThat(repository.countByUserId(1L)).isEqualTo(3); + assertThat(repository.countByUserId(2L)).isEqualTo(1); + assertThat(repository.countByUserId(999L)).isZero(); + } +} diff --git a/SS-Study/src/test/java/com/elipair/spacestudyship/study/timer/service/TimerSessionServiceTest.java b/SS-Study/src/test/java/com/elipair/spacestudyship/study/timer/service/TimerSessionServiceTest.java new file mode 100644 index 0000000..df7e430 --- /dev/null +++ b/SS-Study/src/test/java/com/elipair/spacestudyship/study/timer/service/TimerSessionServiceTest.java @@ -0,0 +1,498 @@ +package com.elipair.spacestudyship.study.timer.service; + +import com.elipair.spacestudyship.common.exception.CustomException; +import com.elipair.spacestudyship.common.exception.ErrorCode; +import com.elipair.spacestudyship.study.fuel.dto.FuelChargeFromStudyResult; +import com.elipair.spacestudyship.study.fuel.service.FuelService; +import com.elipair.spacestudyship.study.timer.dto.TimerSessionCreateRequest; +import com.elipair.spacestudyship.study.timer.dto.TimerSessionCreateResponse; +import com.elipair.spacestudyship.study.timer.dto.TimerSessionListResponse; +import com.elipair.spacestudyship.study.timer.dto.TodayStatsResponse; +import com.elipair.spacestudyship.study.timer.entity.TimerSession; +import com.elipair.spacestudyship.study.timer.repository.TimerSessionRepository; +import com.elipair.spacestudyship.study.todo.service.TodoService; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.CsvSource; +import org.mockito.ArgumentCaptor; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.dao.DataIntegrityViolationException; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageImpl; +import org.springframework.data.domain.Pageable; + +import java.time.*; +import java.util.List; +import java.util.Optional; +import java.util.UUID; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.*; + +@ExtendWith(MockitoExtension.class) +class TimerSessionServiceTest { + + @Mock TimerSessionRepository sessionRepository; + @Mock FuelService fuelService; + @Mock TodoService todoService; + + TimerSessionService service; + + Clock fixedClock = Clock.fixed(Instant.parse("2026-05-25T12:00:00Z"), ZoneOffset.UTC); + + @BeforeEach + void setUp() { + service = new TimerSessionService(sessionRepository, fuelService, todoService, fixedClock); + } + + private TimerSessionCreateRequest validRequest(int duration) { + return new TimerSessionCreateRequest( + null, null, + Instant.parse("2026-05-25T01:00:00Z"), + Instant.parse("2026-05-25T02:00:00Z"), + duration); + } + + @Test + @DisplayName("validate: startedAt == endedAt → INVALID_SESSION_TIME") + void validate_sameTime_throws() { + TimerSessionCreateRequest req = new TimerSessionCreateRequest( + null, null, + Instant.parse("2026-05-25T01:00:00Z"), + Instant.parse("2026-05-25T01:00:00Z"), + 1); + + assertThatThrownBy(() -> service.create(1L, req, null)) + .isInstanceOf(CustomException.class) + .extracting("errorCode").isEqualTo(ErrorCode.INVALID_SESSION_TIME); + } + + @Test + @DisplayName("validate: durationMinutes > 경과시간 → INVALID_DURATION") + void validate_durationOverElapsed_throws() { + TimerSessionCreateRequest req = new TimerSessionCreateRequest( + null, null, + Instant.parse("2026-05-25T01:00:00Z"), + Instant.parse("2026-05-25T01:30:00Z"), + 31); + + assertThatThrownBy(() -> service.create(1L, req, null)) + .isInstanceOf(CustomException.class) + .extracting("errorCode").isEqualTo(ErrorCode.INVALID_DURATION); + } + + @ParameterizedTest + @CsvSource({"0", "-1"}) + @DisplayName("validate: durationMinutes < 1 → SESSION_TOO_SHORT") + void validate_tooShort_throws(int duration) { + TimerSessionCreateRequest req = new TimerSessionCreateRequest( + null, null, + Instant.parse("2026-05-25T01:00:00Z"), + Instant.parse("2026-05-25T03:00:00Z"), + duration); + + assertThatThrownBy(() -> service.create(1L, req, null)) + .isInstanceOf(CustomException.class) + .extracting("errorCode").isEqualTo(ErrorCode.SESSION_TOO_SHORT); + } + + @Test + @DisplayName("validate: durationMinutes > 1440 → SESSION_TOO_LONG") + void validate_tooLong_throws() { + TimerSessionCreateRequest req = new TimerSessionCreateRequest( + null, null, + Instant.parse("2026-05-23T00:00:00Z"), + Instant.parse("2026-05-25T01:00:00Z"), + 1441); + + assertThatThrownBy(() -> service.create(1L, req, null)) + .isInstanceOf(CustomException.class) + .extracting("errorCode").isEqualTo(ErrorCode.SESSION_TOO_LONG); + } + + @Test + @DisplayName("validate: startedAt > now + 5분 → FUTURE_SESSION") + void validate_future_throws() { + TimerSessionCreateRequest req = new TimerSessionCreateRequest( + null, null, + Instant.parse("2026-05-25T12:05:01Z"), + Instant.parse("2026-05-25T13:00:00Z"), + 30); + + assertThatThrownBy(() -> service.create(1L, req, null)) + .isInstanceOf(CustomException.class) + .extracting("errorCode").isEqualTo(ErrorCode.FUTURE_SESSION); + } + + @Test + @DisplayName("validate: startedAt == now + 5분 정확히 → 통과") + void validate_exactlyAtSkewBoundary_passes() { + TimerSessionCreateRequest req = new TimerSessionCreateRequest( + null, null, + Instant.parse("2026-05-25T12:05:00Z"), + Instant.parse("2026-05-25T13:00:00Z"), + 30); + given(fuelService.chargeFromStudy(eq(1L), eq(30), anyString())) + .willReturn(new FuelChargeFromStudyResult(1, 0, 1)); + + TimerSessionCreateResponse res = service.create(1L, req, null); + assertThat(res.session().durationMinutes()).isEqualTo(30); + } + + @Test + @DisplayName("create 정상: 세션 저장 + Fuel chargeFromStudy(60분 → 2연료) + (todoId 없으므로) Todo 미호출") + void create_noTodo_chargesFuel_doesNotTouchTodo() { + TimerSessionCreateRequest req = validRequest(60); + // 60분 = 2연료 (30분=1연료 환산), pending 0 + given(fuelService.chargeFromStudy(eq(1L), eq(60), anyString())) + .willReturn(new FuelChargeFromStudyResult(2, 0, 2)); + + TimerSessionCreateResponse res = service.create(1L, req, null); + + ArgumentCaptor savedCap = ArgumentCaptor.forClass(TimerSession.class); + verify(sessionRepository).saveAndFlush(savedCap.capture()); + TimerSession saved = savedCap.getValue(); + + assertThat(saved.getUserId()).isEqualTo(1L); + assertThat(saved.getDurationMinutes()).isEqualTo(60); + assertThat(saved.getIdempotencyKey()).isNull(); + assertThat(saved.getId()).isNotBlank(); + + verify(fuelService).chargeFromStudy(eq(1L), eq(60), eq(saved.getId())); + + verifyNoInteractions(todoService); + + assertThat(res.fuelCharged()).isEqualTo(2); + assertThat(res.session().id()).isEqualTo(saved.getId()); + } + + @Test + @DisplayName("create 정상: 25분 세션 → fuelCharged=0 (pending 누적), Todo는 25분 그대로 누적") + void create_under30Min_noFuelChargedButPendingAccumulated() { + TimerSessionCreateRequest req = new TimerSessionCreateRequest( + "todo-1", "수학", + Instant.parse("2026-05-25T01:00:00Z"), + Instant.parse("2026-05-25T01:25:00Z"), + 25); + // 25분 → amount=0, pending=25 (30분 미만이라 fuel transaction 생성 안 됨) + given(fuelService.chargeFromStudy(eq(1L), eq(25), anyString())) + .willReturn(new FuelChargeFromStudyResult(0, 25, 0)); + + TimerSessionCreateResponse res = service.create(1L, req, null); + + assertThat(res.fuelCharged()).isZero(); + // Todo는 실제 공부 분 그대로 누적 (연료 환산과 무관) + verify(todoService).addActualMinutes(eq(1L), eq("todo-1"), eq(25)); + } + + @Test + @DisplayName("create 정상: todoId 있으면 TodoService.addActualMinutes 호출 (studyMinutes 그대로)") + void create_withTodo_callsAddActualMinutes() { + TimerSessionCreateRequest req = new TimerSessionCreateRequest( + "todo-1", "수학", + Instant.parse("2026-05-25T01:00:00Z"), + Instant.parse("2026-05-25T02:00:00Z"), + 60); + given(fuelService.chargeFromStudy(eq(1L), eq(60), anyString())) + .willReturn(new FuelChargeFromStudyResult(2, 0, 2)); + + service.create(1L, req, null); + + verify(todoService).addActualMinutes(eq(1L), eq("todo-1"), eq(60)); + } + + @Test + @DisplayName("Idempotency-Key dedup: 동일 키 재요청 시 기존 세션 반환 + fuel transaction 조회로 fuelCharged 복구") + void idempotency_dedup_returnsExisting() { + TimerSession existing = TimerSession.of( + "existing-id", 1L, null, null, + LocalDateTime.parse("2026-05-25T01:00:00"), + LocalDateTime.parse("2026-05-25T02:00:00"), + 60, "idem-1"); + given(sessionRepository.findByUserIdAndIdempotencyKey(1L, "idem-1")) + .willReturn(Optional.of(existing)); + // dedup 시 fuelCharged는 기존 fuel transaction 조회로 복구 (60분 → 2연료) + given(fuelService.findChargedAmountBySessionId("existing-id")).willReturn(2); + + TimerSessionCreateResponse res = service.create(1L, validRequest(60), "idem-1"); + + verify(sessionRepository, never()).saveAndFlush(any()); + // chargeFromStudy는 호출 안 됨 (실제 충전·중복 차단) + verify(fuelService, never()).chargeFromStudy(anyLong(), anyInt(), anyString()); + verifyNoInteractions(todoService); + assertThat(res.session().id()).isEqualTo("existing-id"); + assertThat(res.fuelCharged()).isEqualTo(2); + } + + @Test + @DisplayName("Idempotency-Key 정규화: blank → null로 취급 (dedup 안 함)") + void idempotency_blank_normalizedToNull() { + given(fuelService.chargeFromStudy(eq(1L), eq(60), anyString())) + .willReturn(new FuelChargeFromStudyResult(2, 0, 2)); + + service.create(1L, validRequest(60), " "); + + verify(sessionRepository, never()).findByUserIdAndIdempotencyKey(anyLong(), any()); + ArgumentCaptor cap = ArgumentCaptor.forClass(TimerSession.class); + verify(sessionRepository).saveAndFlush(cap.capture()); + assertThat(cap.getValue().getIdempotencyKey()).isNull(); + } + + @Test + @DisplayName("Idempotency race: saveAndFlush 시 DataIntegrityViolation → 재조회 후 기존 반환 + fuel transaction 조회") + void idempotency_race_resolvedByReSelect() { + given(sessionRepository.findByUserIdAndIdempotencyKey(1L, "idem-1")) + .willReturn(Optional.empty()) + .willReturn(Optional.of(TimerSession.of( + "racer-id", 1L, null, null, + LocalDateTime.parse("2026-05-25T01:00:00"), + LocalDateTime.parse("2026-05-25T02:00:00"), + 60, "idem-1"))); + given(sessionRepository.saveAndFlush(any(TimerSession.class))) + .willThrow(new DataIntegrityViolationException("unique violation")); + given(fuelService.findChargedAmountBySessionId("racer-id")).willReturn(2); + + TimerSessionCreateResponse res = service.create(1L, validRequest(60), "idem-1"); + + assertThat(res.session().id()).isEqualTo("racer-id"); + assertThat(res.fuelCharged()).isEqualTo(2); + // race 복구 후에는 신규 chargeFromStudy 호출 안 함 + verify(fuelService, never()).chargeFromStudy(anyLong(), anyInt(), anyString()); + verifyNoInteractions(todoService); + } + + @Test + @DisplayName("Idempotency race: saveAndFlush 실패했는데 재조회도 empty → 원본 예외 rethrow") + void idempotency_race_rethrowIfStillMissing() { + given(sessionRepository.findByUserIdAndIdempotencyKey(1L, "idem-1")) + .willReturn(Optional.empty()); + given(sessionRepository.saveAndFlush(any(TimerSession.class))) + .willThrow(new DataIntegrityViolationException("unique violation")); + + assertThatThrownBy(() -> service.create(1L, validRequest(60), "idem-1")) + .isInstanceOf(DataIntegrityViolationException.class); + } + + @Test + @DisplayName("getList: 필터 인자가 서비스 → 레포로 전달, Page envelope 변환") + void getList_passThroughAndEnvelope() { + TimerSession s = TimerSession.of( + UUID.randomUUID().toString(), 1L, "t-1", "title", + LocalDateTime.parse("2026-05-25T01:00:00"), + LocalDateTime.parse("2026-05-25T02:00:00"), + 60, null); + Page page = new PageImpl<>(List.of(s)); + given(sessionRepository.findByFilters(eq(1L), any(), any(), eq("t-1"), any(Pageable.class))) + .willReturn(page); + + TimerSessionListResponse res = service.getList( + 1L, "2026-05-20", "2026-05-25", "t-1", 0, 20); + + assertThat(res.content()).hasSize(1); + assertThat(res.content().get(0).id()).isEqualTo(s.getId()); + } + + @Test + @DisplayName("getList: 빈/공백 파라미터는 null로 정규화되어 필터 미적용") + void getList_blankParamsNormalizedToNull() { + Page empty = new PageImpl<>(List.of()); + given(sessionRepository.findByFilters(eq(1L), isNull(), isNull(), isNull(), any(Pageable.class))) + .willReturn(empty); + + // todoId/startDate/endDate 모두 blank + service.getList(1L, "", " ", "", 0, 20); + + // 정규화된 null이 레포로 전달됨 + verify(sessionRepository).findByFilters(eq(1L), isNull(), isNull(), isNull(), any(Pageable.class)); + } + + @Test + @DisplayName("today-stats: 빈 데이터 → 모두 0") + void todayStats_empty() { + given(sessionRepository.sumDurationBetween(eq(1L), any(), any())).willReturn(0L); + given(sessionRepository + .countByUserIdAndStartedAtGreaterThanEqualAndStartedAtLessThan(eq(1L), any(), any())) + .willReturn(0L); + given(sessionRepository.findStartedAtsAfter(eq(1L), any())).willReturn(List.of()); + given(sessionRepository.sumDurationByUserId(eq(1L))).willReturn(0L); + given(sessionRepository.countByUserId(eq(1L))).willReturn(0L); + + TodayStatsResponse res = service.getTodayStats(1L); + + assertThat(res).isEqualTo(new TodayStatsResponse(0, 0, 0, 0, 0, 0)); + } + + @Test + @DisplayName("today-stats: 정상 데이터 + streak + lifetime/monthly 계산") + void todayStats_withData() { + // sumDurationBetween는 오늘 + 이번 달 두 번 호출됨. + // fixedClock=2026-05-25T12:00:00Z → KST 2026-05-25 21:00 → 오늘=2026-05-25 KST, 월 시작=2026-05-01 KST. + // 둘 다 any() 매칭 시 마지막 stub이 우선이므로, 명시적으로 호출별 stub을 분리한다. + given(sessionRepository.sumDurationBetween(eq(1L), + eq(LocalDateTime.parse("2026-05-24T15:00:00")), // 오늘 시작 (KST 5/25 00:00 = UTC 5/24 15:00) + eq(LocalDateTime.parse("2026-05-25T15:00:00")))) // 내일 시작 (KST 5/26 00:00 = UTC 5/25 15:00) + .willReturn(180L); + given(sessionRepository.sumDurationBetween(eq(1L), + eq(LocalDateTime.parse("2026-04-30T15:00:00")), // 5월 시작 KST 5/1 00:00 = UTC 4/30 15:00 + eq(LocalDateTime.parse("2026-05-31T15:00:00")))) // 6월 시작 KST 6/1 00:00 = UTC 5/31 15:00 + .willReturn(1820L); + given(sessionRepository + .countByUserIdAndStartedAtGreaterThanEqualAndStartedAtLessThan(eq(1L), any(), any())) + .willReturn(3L); + given(sessionRepository.findStartedAtsAfter(eq(1L), any())) + .willReturn(List.of( + LocalDateTime.parse("2026-05-25T02:00:00"), + LocalDateTime.parse("2026-05-23T16:00:00"), + LocalDateTime.parse("2026-05-22T16:00:00") + )); + given(sessionRepository.sumDurationByUserId(eq(1L))).willReturn(12450L); + given(sessionRepository.countByUserId(eq(1L))).willReturn(287L); + + TodayStatsResponse res = service.getTodayStats(1L); + + assertThat(res.totalMinutes()).isEqualTo(180); + assertThat(res.sessionCount()).isEqualTo(3); + assertThat(res.streak()).isEqualTo(3); + assertThat(res.lifetimeMinutes()).isEqualTo(12450); + assertThat(res.lifetimeSessionCount()).isEqualTo(287); + assertThat(res.monthlyMinutes()).isEqualTo(1820); + } + + @Test + @DisplayName("streak: 어제까지만 했으면 어제 기준으로 N (오늘 포함 X)") + void streak_yesterdayLatest() { + given(sessionRepository.sumDurationBetween(eq(1L), any(), any())).willReturn(0L); + given(sessionRepository + .countByUserIdAndStartedAtGreaterThanEqualAndStartedAtLessThan(eq(1L), any(), any())) + .willReturn(0L); + given(sessionRepository.findStartedAtsAfter(eq(1L), any())) + .willReturn(List.of( + LocalDateTime.parse("2026-05-23T16:00:00"), + LocalDateTime.parse("2026-05-22T16:00:00") + )); + given(sessionRepository.sumDurationByUserId(eq(1L))).willReturn(0L); + given(sessionRepository.countByUserId(eq(1L))).willReturn(0L); + + TodayStatsResponse res = service.getTodayStats(1L); + + assertThat(res.streak()).isEqualTo(2); + } + + @Test + @DisplayName("streak: 마지막 공부일이 어제보다 이전 → 0") + void streak_brokenChain() { + given(sessionRepository.sumDurationBetween(eq(1L), any(), any())).willReturn(0L); + given(sessionRepository + .countByUserIdAndStartedAtGreaterThanEqualAndStartedAtLessThan(eq(1L), any(), any())) + .willReturn(0L); + given(sessionRepository.findStartedAtsAfter(eq(1L), any())) + .willReturn(List.of(LocalDateTime.parse("2026-05-22T16:00:00"))); + given(sessionRepository.sumDurationByUserId(eq(1L))).willReturn(0L); + given(sessionRepository.countByUserId(eq(1L))).willReturn(0L); + + TodayStatsResponse res = service.getTodayStats(1L); + + assertThat(res.streak()).isZero(); + } + + @Test + @DisplayName("streak: latest가 미래(clock skew)면 today로 클램프") + void streak_futureLatest_clampedToToday() { + given(sessionRepository.sumDurationBetween(eq(1L), any(), any())).willReturn(0L); + given(sessionRepository + .countByUserIdAndStartedAtGreaterThanEqualAndStartedAtLessThan(eq(1L), any(), any())) + .willReturn(0L); + given(sessionRepository.findStartedAtsAfter(eq(1L), any())) + .willReturn(List.of( + LocalDateTime.parse("2026-05-26T01:00:00"), + LocalDateTime.parse("2026-05-25T01:00:00"), + LocalDateTime.parse("2026-05-23T16:00:00") + )); + given(sessionRepository.sumDurationByUserId(eq(1L))).willReturn(0L); + given(sessionRepository.countByUserId(eq(1L))).willReturn(0L); + + TodayStatsResponse res = service.getTodayStats(1L); + + assertThat(res.streak()).isEqualTo(2); + } + + @Test + @DisplayName("today-stats: lifetime/monthly — KST 월 경계가 sumDurationBetween 인자에 정확히 매핑") + void todayStats_monthlyBoundary_kst() { + // fixedClock=2026-05-25T12:00:00Z → KST 5/25. + // 이번 달 시작 KST 2026-05-01 00:00 = UTC 2026-04-30 15:00 + // 다음 달 시작 KST 2026-06-01 00:00 = UTC 2026-05-31 15:00 + LocalDateTime expectedMonthStartUtc = LocalDateTime.parse("2026-04-30T15:00:00"); + LocalDateTime expectedMonthEndUtc = LocalDateTime.parse("2026-05-31T15:00:00"); + + given(sessionRepository.sumDurationBetween(eq(1L), any(), any())).willReturn(0L); + given(sessionRepository + .countByUserIdAndStartedAtGreaterThanEqualAndStartedAtLessThan(eq(1L), any(), any())) + .willReturn(0L); + given(sessionRepository.findStartedAtsAfter(eq(1L), any())).willReturn(List.of()); + given(sessionRepository.sumDurationByUserId(eq(1L))).willReturn(0L); + given(sessionRepository.countByUserId(eq(1L))).willReturn(0L); + + service.getTodayStats(1L); + + // sumDurationBetween가 정확히 KST 월 경계(UTC 변환된 값)로 호출됐는지 검증 + verify(sessionRepository).sumDurationBetween(eq(1L), + eq(expectedMonthStartUtc), eq(expectedMonthEndUtc)); + } + + @Test + @DisplayName("today-stats: 레포가 null 반환해도 (방어적) → lifetimeMinutes/monthlyMinutes 모두 0 (null 금지)") + void todayStats_repoReturnsNull_serviceWrapsToZero() { + // 실제 레포는 COALESCE(SUM, 0L)로 NULL을 막지만, Service 측 Optional.ofNullable.orElse 가드가 + // 실제로 동작하는지 검증한다 (방어 계층 회귀 방지). sumDurationBetween/sumDurationByUserId가 + // null을 반환한 경우에도 응답 필드는 0이어야 한다. + given(sessionRepository.sumDurationBetween(eq(1L), any(), any())).willReturn(null); + given(sessionRepository + .countByUserIdAndStartedAtGreaterThanEqualAndStartedAtLessThan(eq(1L), any(), any())) + .willReturn(0L); + given(sessionRepository.findStartedAtsAfter(eq(1L), any())).willReturn(List.of()); + given(sessionRepository.sumDurationByUserId(eq(1L))).willReturn(null); + given(sessionRepository.countByUserId(eq(1L))).willReturn(0L); + + TodayStatsResponse res = service.getTodayStats(1L); + + assertThat(res.totalMinutes()).isNotNull().isZero(); + assertThat(res.monthlyMinutes()).isNotNull().isZero(); + assertThat(res.lifetimeMinutes()).isNotNull().isZero(); + assertThat(res.lifetimeSessionCount()).isNotNull().isZero(); + } + + @Test + @DisplayName("today-stats: 지난달 + 이번 달 혼합 → monthly < lifetime, lifetime = 전체 합") + void todayStats_mixedMonths_lifetimeGreaterThanMonthly() { + // sumDurationBetween는 (오늘, 이번 달) 두 번 호출 — 호출 인자로 구분 + given(sessionRepository.sumDurationBetween(eq(1L), + eq(LocalDateTime.parse("2026-05-24T15:00:00")), + eq(LocalDateTime.parse("2026-05-25T15:00:00")))) + .willReturn(0L); + given(sessionRepository.sumDurationBetween(eq(1L), + eq(LocalDateTime.parse("2026-04-30T15:00:00")), + eq(LocalDateTime.parse("2026-05-31T15:00:00")))) + .willReturn(1820L); + given(sessionRepository + .countByUserIdAndStartedAtGreaterThanEqualAndStartedAtLessThan(eq(1L), any(), any())) + .willReturn(0L); + given(sessionRepository.findStartedAtsAfter(eq(1L), any())).willReturn(List.of()); + given(sessionRepository.sumDurationByUserId(eq(1L))).willReturn(12450L); + given(sessionRepository.countByUserId(eq(1L))).willReturn(287L); + + TodayStatsResponse res = service.getTodayStats(1L); + + assertThat(res.lifetimeMinutes()).isEqualTo(12450); + assertThat(res.monthlyMinutes()).isEqualTo(1820); + assertThat(res.monthlyMinutes()).isLessThan(res.lifetimeMinutes()); + assertThat(res.lifetimeSessionCount()).isEqualTo(287); + } +} diff --git a/SS-Study/src/test/java/com/elipair/spacestudyship/study/todo/entity/TodoCategoryTest.java b/SS-Study/src/test/java/com/elipair/spacestudyship/study/todo/entity/TodoCategoryTest.java new file mode 100644 index 0000000..dc87d39 --- /dev/null +++ b/SS-Study/src/test/java/com/elipair/spacestudyship/study/todo/entity/TodoCategoryTest.java @@ -0,0 +1,49 @@ +package com.elipair.spacestudyship.study.todo.entity; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +class TodoCategoryTest { + + @Test + @DisplayName("create: 정적 팩토리로 카테고리 생성") + void create() { + TodoCategory category = TodoCategory.create( + "cat-1", 1L, "수학", "math_icon", 0.3, 0.5); + + assertThat(category.getId()).isEqualTo("cat-1"); + assertThat(category.getUserId()).isEqualTo(1L); + assertThat(category.getName()).isEqualTo("수학"); + assertThat(category.getIconId()).isEqualTo("math_icon"); + assertThat(category.getPositionX()).isEqualTo(0.3); + assertThat(category.getPositionY()).isEqualTo(0.5); + } + + @Test + @DisplayName("updateName: 이름 변경") + void updateName() { + TodoCategory category = TodoCategory.create("cat-1", 1L, "수학", null, null, null); + category.updateName("심화수학"); + assertThat(category.getName()).isEqualTo("심화수학"); + } + + @Test + @DisplayName("updateIconId: 아이콘 변경") + void updateIconId() { + TodoCategory category = TodoCategory.create("cat-1", 1L, "수학", "math_icon", null, null); + category.updateIconId("new_icon"); + assertThat(category.getIconId()).isEqualTo("new_icon"); + } + + @Test + @DisplayName("updatePositionX/Y: 위치 변경") + void updatePosition() { + TodoCategory category = TodoCategory.create("cat-1", 1L, "수학", null, 0.3, 0.5); + category.updatePositionX(0.7); + category.updatePositionY(0.2); + assertThat(category.getPositionX()).isEqualTo(0.7); + assertThat(category.getPositionY()).isEqualTo(0.2); + } +} diff --git a/SS-Study/src/test/java/com/elipair/spacestudyship/study/todo/entity/TodoTest.java b/SS-Study/src/test/java/com/elipair/spacestudyship/study/todo/entity/TodoTest.java new file mode 100644 index 0000000..13b7a0f --- /dev/null +++ b/SS-Study/src/test/java/com/elipair/spacestudyship/study/todo/entity/TodoTest.java @@ -0,0 +1,79 @@ +package com.elipair.spacestudyship.study.todo.entity; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; + +class TodoTest { + + @Test + @DisplayName("create: 정적 팩토리로 Todo 생성 — null 배열은 빈 배열로 정규화") + void create_nullArraysNormalizedToEmpty() { + Todo todo = Todo.create("t1", 1L, "수학 문제", null, null, 60); + + assertThat(todo.getId()).isEqualTo("t1"); + assertThat(todo.getUserId()).isEqualTo(1L); + assertThat(todo.getTitle()).isEqualTo("수학 문제"); + assertThat(todo.getScheduledDates()).isEmpty(); + assertThat(todo.getCompletedDates()).isEmpty(); + assertThat(todo.getCategoryIds()).isEmpty(); + assertThat(todo.getEstimatedMinutes()).isEqualTo(60); + assertThat(todo.getActualMinutes()).isNull(); + } + + @Test + @DisplayName("create: 값이 있으면 그대로 사용") + void create_withValues() { + Todo todo = Todo.create( + "t1", 1L, "수학", + List.of("2026-04-16"), + List.of("cat-1"), + 90); + + assertThat(todo.getScheduledDates()).containsExactly("2026-04-16"); + assertThat(todo.getCategoryIds()).containsExactly("cat-1"); + } + + @Test + @DisplayName("updateTitle / updateScheduledDates / updateCompletedDates / updateCategoryIds / updateEstimatedMinutes / updateActualMinutes") + void updaters() { + Todo todo = Todo.create("t1", 1L, "원본", null, null, null); + + todo.updateTitle("새 제목"); + todo.updateScheduledDates(List.of("2026-05-01")); + todo.updateCompletedDates(List.of("2026-05-01")); + todo.updateCategoryIds(List.of("c1", "c2")); + todo.updateEstimatedMinutes(120); + todo.updateActualMinutes(45); + + assertThat(todo.getTitle()).isEqualTo("새 제목"); + assertThat(todo.getScheduledDates()).containsExactly("2026-05-01"); + assertThat(todo.getCompletedDates()).containsExactly("2026-05-01"); + assertThat(todo.getCategoryIds()).containsExactly("c1", "c2"); + assertThat(todo.getEstimatedMinutes()).isEqualTo(120); + assertThat(todo.getActualMinutes()).isEqualTo(45); + } + + @Test + @DisplayName("removeCategoryId: 해당 ID만 제거 (immutable copy)") + void removeCategoryId() { + Todo todo = Todo.create("t1", 1L, "수학", null, List.of("c1", "c2", "c3"), null); + + todo.removeCategoryId("c2"); + + assertThat(todo.getCategoryIds()).containsExactly("c1", "c3"); + } + + @Test + @DisplayName("removeCategoryId: 존재하지 않는 ID면 무변화") + void removeCategoryId_notExist() { + Todo todo = Todo.create("t1", 1L, "수학", null, List.of("c1"), null); + + todo.removeCategoryId("c-missing"); + + assertThat(todo.getCategoryIds()).containsExactly("c1"); + } +} diff --git a/SS-Study/src/test/java/com/elipair/spacestudyship/study/todo/repository/TodoCategoryRepositoryTest.java b/SS-Study/src/test/java/com/elipair/spacestudyship/study/todo/repository/TodoCategoryRepositoryTest.java new file mode 100644 index 0000000..344359e --- /dev/null +++ b/SS-Study/src/test/java/com/elipair/spacestudyship/study/todo/repository/TodoCategoryRepositoryTest.java @@ -0,0 +1,82 @@ +package com.elipair.spacestudyship.study.todo.repository; + +import com.elipair.spacestudyship.study.StudyTestApplication; +import com.elipair.spacestudyship.study.todo.entity.TodoCategory; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThat; + +@SpringBootTest(classes = StudyTestApplication.class) +@Transactional +class TodoCategoryRepositoryTest { + + @Autowired + TodoCategoryRepository categoryRepository; + + @Test + @DisplayName("findByUserIdOrderByCreatedAtAsc: 사용자 카테고리를 생성일 오름차순으로 반환") + void findByUserIdOrderByCreatedAtAsc() { + categoryRepository.save(TodoCategory.create("c1", 1L, "수학", null, null, null)); + categoryRepository.save(TodoCategory.create("c2", 1L, "영어", null, null, null)); + categoryRepository.save(TodoCategory.create("c3", 2L, "다른유저", null, null, null)); + + List result = categoryRepository.findByUserIdOrderByCreatedAtAsc(1L); + + assertThat(result).extracting("id").containsExactly("c1", "c2"); + } + + @Test + @DisplayName("existsByIdAndUserId: 본인 카테고리는 true") + void existsByIdAndUserId_true() { + categoryRepository.save(TodoCategory.create("c1", 1L, "수학", null, null, null)); + assertThat(categoryRepository.existsByIdAndUserId("c1", 1L)).isTrue(); + } + + @Test + @DisplayName("existsByIdAndUserId: 다른 사용자 카테고리는 false") + void existsByIdAndUserId_otherUser() { + categoryRepository.save(TodoCategory.create("c1", 1L, "수학", null, null, null)); + assertThat(categoryRepository.existsByIdAndUserId("c1", 2L)).isFalse(); + } + + @Test + @DisplayName("findByIdAndUserId: 본인 카테고리만 조회") + void findByIdAndUserId() { + categoryRepository.save(TodoCategory.create("c1", 1L, "수학", null, null, null)); + + Optional mine = categoryRepository.findByIdAndUserId("c1", 1L); + Optional other = categoryRepository.findByIdAndUserId("c1", 99L); + + assertThat(mine).isPresent(); + assertThat(other).isEmpty(); + } + + @Test + @DisplayName("countByIdInAndUserId: 본인 소유 카테고리 ID 개수") + void countByIdInAndUserId() { + categoryRepository.save(TodoCategory.create("c1", 1L, "수학", null, null, null)); + categoryRepository.save(TodoCategory.create("c2", 1L, "영어", null, null, null)); + categoryRepository.save(TodoCategory.create("c3", 2L, "다른유저", null, null, null)); + + long count = categoryRepository.countByIdInAndUserId(List.of("c1", "c2", "c3"), 1L); + + assertThat(count).isEqualTo(2L); + } + + @Test + @DisplayName("saveAndFlush: assigned-ID TodoCategory의 timestamp가 flush 후 채워짐 (Hibernate 회귀 알람)") + void saveAndFlush_populatesTimestamps() { + TodoCategory saved = categoryRepository.saveAndFlush( + TodoCategory.create("c-ts", 1L, "수학", null, null, null)); + + assertThat(saved.getCreatedAt()).isNotNull(); + assertThat(saved.getUpdatedAt()).isNotNull(); + } +} diff --git a/SS-Study/src/test/java/com/elipair/spacestudyship/study/todo/repository/TodoRepositoryTest.java b/SS-Study/src/test/java/com/elipair/spacestudyship/study/todo/repository/TodoRepositoryTest.java new file mode 100644 index 0000000..6c52cf1 --- /dev/null +++ b/SS-Study/src/test/java/com/elipair/spacestudyship/study/todo/repository/TodoRepositoryTest.java @@ -0,0 +1,142 @@ +package com.elipair.spacestudyship.study.todo.repository; + +import com.elipair.spacestudyship.study.StudyTestApplication; +import com.elipair.spacestudyship.study.todo.entity.Todo; +import jakarta.persistence.EntityManager; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThat; + +@SpringBootTest(classes = StudyTestApplication.class) +@Transactional +class TodoRepositoryTest { + + @Autowired + TodoRepository todoRepository; + + @Autowired + EntityManager em; + + @Test + @DisplayName("findByUserIdOrderByCreatedAtDesc: 본인 Todo만, 최신순 반환 (id 순서까지 검증)") + void findByUserId_ordered() throws InterruptedException { + todoRepository.saveAndFlush(Todo.create("t1", 1L, "첫번째", null, null, null)); + Thread.sleep(5); + todoRepository.saveAndFlush(Todo.create("t2", 1L, "두번째", null, null, null)); + todoRepository.saveAndFlush(Todo.create("t3", 2L, "다른유저", null, null, null)); + + List result = todoRepository.findByUserIdOrderByCreatedAtDesc(1L); + + assertThat(result).hasSize(2); + assertThat(result).extracting("userId").containsOnly(1L); + assertThat(result).extracting("id").containsExactly("t2", "t1"); + } + + @Test + @DisplayName("findByUserIdAndScheduledDate: JSONB @> 필터 + 타 사용자 격리") + void findByUserIdAndScheduledDate() { + todoRepository.save(Todo.create("t1", 1L, "월요일", List.of("2026-04-16"), null, null)); + todoRepository.save(Todo.create("t2", 1L, "양일", List.of("2026-04-16", "2026-04-17"), null, null)); + todoRepository.save(Todo.create("t3", 1L, "다른날", List.of("2026-04-18"), null, null)); + // 타 사용자의 같은 날짜 Todo — user_id 조건 누락 회귀 방지 + todoRepository.save(Todo.create("t4", 2L, "다른유저_같은날", List.of("2026-04-16"), null, null)); + + List result = todoRepository + .findByUserIdAndScheduledDate(1L, "\"2026-04-16\""); + + assertThat(result).extracting("id").containsExactlyInAnyOrder("t1", "t2"); + } + + @Test + @DisplayName("findByUserIdAndCategoryId: JSONB @> 필터 + 타 사용자 격리") + void findByUserIdAndCategoryId() { + todoRepository.save(Todo.create("t1", 1L, "수학", null, List.of("c-math"), null)); + todoRepository.save(Todo.create("t2", 1L, "복합", null, List.of("c-math", "c-eng"), null)); + todoRepository.save(Todo.create("t3", 1L, "영어만", null, List.of("c-eng"), null)); + // 타 사용자의 같은 카테고리 Todo — user_id 조건 누락 회귀 방지 + todoRepository.save(Todo.create("t4", 2L, "다른유저_같은카테고리", null, List.of("c-math"), null)); + + List result = todoRepository + .findByUserIdAndCategoryId(1L, "\"c-math\""); + + assertThat(result).extracting("id").containsExactlyInAnyOrder("t1", "t2"); + } + + @Test + @DisplayName("existsByIdAndUserId: 본인 소유 여부") + void existsByIdAndUserId() { + todoRepository.save(Todo.create("t1", 1L, "X", null, null, null)); + assertThat(todoRepository.existsByIdAndUserId("t1", 1L)).isTrue(); + assertThat(todoRepository.existsByIdAndUserId("t1", 99L)).isFalse(); + } + + @Test + @DisplayName("findByIdAndUserId: 본인 소유만 조회") + void findByIdAndUserId() { + todoRepository.save(Todo.create("t1", 1L, "X", null, null, null)); + + Optional mine = todoRepository.findByIdAndUserId("t1", 1L); + Optional other = todoRepository.findByIdAndUserId("t1", 99L); + + assertThat(mine).isPresent(); + assertThat(other).isEmpty(); + } + + @Test + @DisplayName("saveAndFlush: assigned-ID Todo의 timestamp가 flush 후 채워짐 (Hibernate 회귀 알람)") + void saveAndFlush_populatesTimestamps() { + Todo saved = todoRepository.saveAndFlush(Todo.create("t-ts", 1L, "X", null, null, null)); + + assertThat(saved.getCreatedAt()).isNotNull(); + assertThat(saved.getUpdatedAt()).isNotNull(); + } + + @Test + @DisplayName("addActualMinutes: 본인 todo 누적 — null → 0+minutes, 기존 → 기존+minutes") + void addActualMinutes_accumulates() { + Todo t = Todo.create( + "t-1", 1L, "수학", + List.of("2026-05-25"), + List.of(), + 60); + todoRepository.saveAndFlush(t); + + int updated1 = todoRepository.addActualMinutes(1L, "t-1", 30); + assertThat(updated1).isEqualTo(1); + em.clear(); + assertThat(todoRepository.findById("t-1").get().getActualMinutes()).isEqualTo(30); + + int updated2 = todoRepository.addActualMinutes(1L, "t-1", 45); + assertThat(updated2).isEqualTo(1); + em.clear(); + assertThat(todoRepository.findById("t-1").get().getActualMinutes()).isEqualTo(75); + } + + @Test + @DisplayName("addActualMinutes: 본인 소유 아님 → affected=0") + void addActualMinutes_otherUser_returnsZero() { + Todo t = Todo.create( + "t-1", 1L, "수학", + List.of("2026-05-25"), + List.of(), + 60); + todoRepository.saveAndFlush(t); + + int updated = todoRepository.addActualMinutes(2L, "t-1", 30); + assertThat(updated).isZero(); + } + + @Test + @DisplayName("addActualMinutes: 없는 todoId → affected=0") + void addActualMinutes_missingTodo_returnsZero() { + int updated = todoRepository.addActualMinutes(1L, "nope", 30); + assertThat(updated).isZero(); + } +} diff --git a/SS-Study/src/test/java/com/elipair/spacestudyship/study/todo/service/TodoCategoryServiceTest.java b/SS-Study/src/test/java/com/elipair/spacestudyship/study/todo/service/TodoCategoryServiceTest.java new file mode 100644 index 0000000..448b973 --- /dev/null +++ b/SS-Study/src/test/java/com/elipair/spacestudyship/study/todo/service/TodoCategoryServiceTest.java @@ -0,0 +1,162 @@ +package com.elipair.spacestudyship.study.todo.service; + +import com.elipair.spacestudyship.common.exception.CustomException; +import com.elipair.spacestudyship.common.exception.ErrorCode; +import com.elipair.spacestudyship.study.todo.dto.CategoryCreateRequest; +import com.elipair.spacestudyship.study.todo.dto.CategoryResponse; +import com.elipair.spacestudyship.study.todo.dto.CategoryUpdateRequest; +import com.elipair.spacestudyship.study.todo.entity.TodoCategory; +import com.elipair.spacestudyship.study.todo.repository.TodoCategoryRepository; +import com.elipair.spacestudyship.study.todo.repository.TodoRepository; +import jakarta.persistence.EntityManager; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.util.List; +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class TodoCategoryServiceTest { + + @Mock TodoCategoryRepository categoryRepository; + @Mock TodoRepository todoRepository; + @Mock EntityManager entityManager; + @InjectMocks TodoCategoryService categoryService; + + @Test + @DisplayName("findAll: 사용자 카테고리 목록 반환") + void findAll() { + when(categoryRepository.findByUserIdOrderByCreatedAtAsc(1L)) + .thenReturn(List.of(TodoCategory.create("c1", 1L, "수학", null, null, null))); + + List result = categoryService.findAll(1L); + + assertThat(result).hasSize(1); + assertThat(result.get(0).name()).isEqualTo("수학"); + } + + @Test + @DisplayName("create: 서버 UUID 생성") + void create_serverId() { + var request = new CategoryCreateRequest(null, "수학", "math_icon", 0.3, 0.5); + when(categoryRepository.existsById(anyString())).thenReturn(false); + when(categoryRepository.save(any(TodoCategory.class))) + .thenAnswer(inv -> inv.getArgument(0)); + + var response = categoryService.create(1L, request); + + assertThat(response.id()).isNotBlank(); + assertThat(response.name()).isEqualTo("수학"); + } + + @Test + @DisplayName("create: 동일 ID 있으면 CATEGORY_ALREADY_EXISTS") + void create_duplicate() { + var request = new CategoryCreateRequest("c1", "수학", null, null, null); + when(categoryRepository.existsById("c1")).thenReturn(true); + + assertThatThrownBy(() -> categoryService.create(1L, request)) + .isInstanceOf(CustomException.class) + .extracting("errorCode").isEqualTo(ErrorCode.CATEGORY_ALREADY_EXISTS); + } + + @Test + @DisplayName("update: 이름 변경 + 위치 변경 + iconId 유지") + void update_partial() { + TodoCategory existing = TodoCategory.create("c1", 1L, "원본", "icon", 0.3, 0.5); + when(categoryRepository.findByIdAndUserId("c1", 1L)) + .thenReturn(Optional.of(existing)); + + var request = new CategoryUpdateRequest("새이름", null, 0.7, null); + var response = categoryService.update(1L, "c1", request); + + assertThat(response.name()).isEqualTo("새이름"); + assertThat(response.iconId()).isEqualTo("icon"); + assertThat(response.positionX()).isEqualTo(0.7); + assertThat(response.positionY()).isEqualTo(0.5); + } + + @Test + @DisplayName("update: 존재하지 않으면 CATEGORY_NOT_FOUND") + void update_notFound() { + when(categoryRepository.findByIdAndUserId("missing", 1L)) + .thenReturn(Optional.empty()); + + var request = new CategoryUpdateRequest("X", null, null, null); + + assertThatThrownBy(() -> categoryService.update(1L, "missing", request)) + .isInstanceOf(CustomException.class) + .extracting("errorCode").isEqualTo(ErrorCode.CATEGORY_NOT_FOUND); + } + + @Test + @DisplayName("delete: 카테고리 삭제 + 연관 Todo의 categoryIds에서 제거") + void delete_cascadesToTodos() { + TodoCategory existing = TodoCategory.create("c1", 1L, "수학", null, null, null); + when(categoryRepository.findByIdAndUserId("c1", 1L)) + .thenReturn(Optional.of(existing)); + + com.elipair.spacestudyship.study.todo.entity.Todo t1 = + com.elipair.spacestudyship.study.todo.entity.Todo.create( + "t1", 1L, "X", null, List.of("c1", "c2"), null); + com.elipair.spacestudyship.study.todo.entity.Todo t2 = + com.elipair.spacestudyship.study.todo.entity.Todo.create( + "t2", 1L, "Y", null, List.of("c1"), null); + when(todoRepository.findByUserIdAndCategoryId(1L, "\"c1\"")) + .thenReturn(List.of(t1, t2)); + + categoryService.delete(1L, "c1"); + + assertThat(t1.getCategoryIds()).containsExactly("c2"); + assertThat(t2.getCategoryIds()).isEmpty(); + org.mockito.Mockito.verify(categoryRepository).delete(existing); + } + + @Test + @DisplayName("delete: 존재하지 않으면 CATEGORY_NOT_FOUND") + void delete_notFound() { + when(categoryRepository.findByIdAndUserId("missing", 1L)) + .thenReturn(Optional.empty()); + + assertThatThrownBy(() -> categoryService.delete(1L, "missing")) + .isInstanceOf(CustomException.class) + .extracting("errorCode").isEqualTo(ErrorCode.CATEGORY_NOT_FOUND); + } + + @Test + @DisplayName("create: save 후 EntityManager.flush() 호출 — createdAt/updatedAt 보장") + void create_flushesAfterSave() { + var request = new CategoryCreateRequest("c-new", "수학", "math_icon", 0.3, 0.5); + when(categoryRepository.existsById("c-new")).thenReturn(false); + when(categoryRepository.save(any(TodoCategory.class))) + .thenAnswer(inv -> inv.getArgument(0)); + + categoryService.create(1L, request); + + verify(entityManager).flush(); + } + + @Test + @DisplayName("update: mutation 후 EntityManager.flush() 호출 — updatedAt 갱신 보장") + void update_flushesAfterMutation() { + TodoCategory existing = TodoCategory.create("c1", 1L, "원본", "icon", 0.3, 0.5); + when(categoryRepository.findByIdAndUserId("c1", 1L)) + .thenReturn(Optional.of(existing)); + + var request = new CategoryUpdateRequest("새이름", null, null, null); + categoryService.update(1L, "c1", request); + + verify(entityManager).flush(); + } +} diff --git a/SS-Study/src/test/java/com/elipair/spacestudyship/study/todo/service/TodoServiceTest.java b/SS-Study/src/test/java/com/elipair/spacestudyship/study/todo/service/TodoServiceTest.java new file mode 100644 index 0000000..6f55a12 --- /dev/null +++ b/SS-Study/src/test/java/com/elipair/spacestudyship/study/todo/service/TodoServiceTest.java @@ -0,0 +1,275 @@ +package com.elipair.spacestudyship.study.todo.service; + +import com.elipair.spacestudyship.study.todo.entity.Todo; +import com.elipair.spacestudyship.study.todo.repository.TodoCategoryRepository; +import com.elipair.spacestudyship.study.todo.repository.TodoRepository; +import jakarta.persistence.EntityManager; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class TodoServiceTest { + + @Mock TodoRepository todoRepository; + @Mock TodoCategoryRepository categoryRepository; + @Mock EntityManager entityManager; + @InjectMocks TodoService todoService; + + @Test + @DisplayName("findAll: 필터 없음 → findByUserIdOrderByCreatedAtDesc 호출") + void findAll_noFilters() { + when(todoRepository.findByUserIdOrderByCreatedAtDesc(1L)) + .thenReturn(List.of(Todo.create("t1", 1L, "X", null, null, null))); + + var result = todoService.findAll(1L, null, null); + + assertThat(result).hasSize(1); + verify(todoRepository).findByUserIdOrderByCreatedAtDesc(1L); + } + + @Test + @DisplayName("findAll: date 필터만") + void findAll_dateOnly() { + when(todoRepository.findByUserIdAndScheduledDate(1L, "\"2026-04-16\"")) + .thenReturn(List.of(Todo.create("t1", 1L, "X", List.of("2026-04-16"), null, null))); + + var result = todoService.findAll(1L, "2026-04-16", null); + + assertThat(result).hasSize(1); + verify(todoRepository).findByUserIdAndScheduledDate(1L, "\"2026-04-16\""); + } + + @Test + @DisplayName("findAll: categoryId 필터만") + void findAll_categoryOnly() { + when(todoRepository.findByUserIdAndCategoryId(1L, "\"c1\"")) + .thenReturn(List.of(Todo.create("t1", 1L, "X", null, List.of("c1"), null))); + + var result = todoService.findAll(1L, null, "c1"); + + assertThat(result).hasSize(1); + verify(todoRepository).findByUserIdAndCategoryId(1L, "\"c1\""); + } + + @Test + @DisplayName("findAll: date + categoryId — 두 쿼리 결과의 교집합") + void findAll_dateAndCategory() { + Todo a = Todo.create("a", 1L, "AB", List.of("2026-04-16"), List.of("c1"), null); + Todo b = Todo.create("b", 1L, "B만", List.of("2026-04-16"), List.of("c2"), null); + when(todoRepository.findByUserIdAndScheduledDate(1L, "\"2026-04-16\"")) + .thenReturn(List.of(a, b)); + when(todoRepository.findByUserIdAndCategoryId(1L, "\"c1\"")) + .thenReturn(List.of(a)); + + var result = todoService.findAll(1L, "2026-04-16", "c1"); + + assertThat(result).hasSize(1); + assertThat(result.get(0).id()).isEqualTo("a"); + } + + @Test + @DisplayName("create: id 미지정 → 서버가 UUID 생성, 카테고리 검증 통과") + void create_serverGeneratedId() { + var request = new com.elipair.spacestudyship.study.todo.dto.TodoCreateRequest( + null, "수학", java.util.List.of(), 60, java.util.List.of("2026-04-16")); + when(todoRepository.existsById(org.mockito.ArgumentMatchers.anyString())).thenReturn(false); + when(todoRepository.save(org.mockito.ArgumentMatchers.any(Todo.class))) + .thenAnswer(inv -> inv.getArgument(0)); + + var response = todoService.create(1L, request); + + assertThat(response.id()).isNotBlank(); + assertThat(response.title()).isEqualTo("수학"); + } + + @Test + @DisplayName("create: 동일 ID 존재 → TODO_ALREADY_EXISTS") + void create_duplicateId() { + var request = new com.elipair.spacestudyship.study.todo.dto.TodoCreateRequest( + "t1", "수학", java.util.List.of(), null, java.util.List.of()); + when(todoRepository.existsById("t1")).thenReturn(true); + + org.assertj.core.api.Assertions.assertThatThrownBy(() -> todoService.create(1L, request)) + .isInstanceOf(com.elipair.spacestudyship.common.exception.CustomException.class) + .extracting("errorCode") + .isEqualTo(com.elipair.spacestudyship.common.exception.ErrorCode.TODO_ALREADY_EXISTS); + } + + @Test + @DisplayName("create: categoryIds에 존재하지 않는 ID → CATEGORY_NOT_FOUND") + void create_invalidCategoryId() { + var request = new com.elipair.spacestudyship.study.todo.dto.TodoCreateRequest( + "t1", "수학", java.util.List.of("missing-cat"), null, java.util.List.of()); + when(todoRepository.existsById("t1")).thenReturn(false); + when(categoryRepository.countByIdInAndUserId(java.util.List.of("missing-cat"), 1L)) + .thenReturn(0L); + + org.assertj.core.api.Assertions.assertThatThrownBy(() -> todoService.create(1L, request)) + .isInstanceOf(com.elipair.spacestudyship.common.exception.CustomException.class) + .extracting("errorCode") + .isEqualTo(com.elipair.spacestudyship.common.exception.ErrorCode.CATEGORY_NOT_FOUND); + } + + @Test + @DisplayName("update: title만 변경, 나머지 null → 기존 유지") + void update_titleOnly() { + Todo existing = Todo.create("t1", 1L, "원본", java.util.List.of("2026-04-16"), + java.util.List.of("c1"), 60); + when(todoRepository.findByIdAndUserId("t1", 1L)) + .thenReturn(java.util.Optional.of(existing)); + + var request = new com.elipair.spacestudyship.study.todo.dto.TodoUpdateRequest( + "새 제목", null, null, null, null, null); + + var response = todoService.update(1L, "t1", request); + + assertThat(response.title()).isEqualTo("새 제목"); + assertThat(response.scheduledDates()).containsExactly("2026-04-16"); + assertThat(response.categoryIds()).containsExactly("c1"); + assertThat(response.estimatedMinutes()).isEqualTo(60); + } + + @Test + @DisplayName("update: 빈 배열은 명시적 모두 제거") + void update_emptyArrayClears() { + Todo existing = Todo.create("t1", 1L, "X", java.util.List.of("2026-04-16"), + java.util.List.of("c1"), null); + when(todoRepository.findByIdAndUserId("t1", 1L)) + .thenReturn(java.util.Optional.of(existing)); + + var request = new com.elipair.spacestudyship.study.todo.dto.TodoUpdateRequest( + null, java.util.List.of(), null, null, null, null); + + var response = todoService.update(1L, "t1", request); + + assertThat(response.scheduledDates()).isEmpty(); + assertThat(response.categoryIds()).containsExactly("c1"); + } + + @Test + @DisplayName("update: 존재하지 않는 todoId → TODO_NOT_FOUND") + void update_notFound() { + when(todoRepository.findByIdAndUserId("missing", 1L)) + .thenReturn(java.util.Optional.empty()); + + var request = new com.elipair.spacestudyship.study.todo.dto.TodoUpdateRequest( + "X", null, null, null, null, null); + + org.assertj.core.api.Assertions.assertThatThrownBy(() -> todoService.update(1L, "missing", request)) + .isInstanceOf(com.elipair.spacestudyship.common.exception.CustomException.class) + .extracting("errorCode") + .isEqualTo(com.elipair.spacestudyship.common.exception.ErrorCode.TODO_NOT_FOUND); + } + + @Test + @DisplayName("update: categoryIds 변경 시 검증") + void update_categoryIdsValidated() { + Todo existing = Todo.create("t1", 1L, "X", null, null, null); + when(todoRepository.findByIdAndUserId("t1", 1L)) + .thenReturn(java.util.Optional.of(existing)); + when(categoryRepository.countByIdInAndUserId(java.util.List.of("missing"), 1L)) + .thenReturn(0L); + + var request = new com.elipair.spacestudyship.study.todo.dto.TodoUpdateRequest( + null, null, null, java.util.List.of("missing"), null, null); + + org.assertj.core.api.Assertions.assertThatThrownBy(() -> todoService.update(1L, "t1", request)) + .isInstanceOf(com.elipair.spacestudyship.common.exception.CustomException.class) + .extracting("errorCode") + .isEqualTo(com.elipair.spacestudyship.common.exception.ErrorCode.CATEGORY_NOT_FOUND); + } + + @Test + @DisplayName("delete: 본인 Todo 삭제 성공 (deleteByIdAndUserId atomic)") + void delete_success() { + when(todoRepository.deleteByIdAndUserId("t1", 1L)).thenReturn(1L); + + todoService.delete(1L, "t1"); + + verify(todoRepository).deleteByIdAndUserId("t1", 1L); + } + + @Test + @DisplayName("delete: deleted count 0이면 TODO_NOT_FOUND") + void delete_notFound() { + when(todoRepository.deleteByIdAndUserId("missing", 1L)).thenReturn(0L); + + org.assertj.core.api.Assertions.assertThatThrownBy(() -> todoService.delete(1L, "missing")) + .isInstanceOf(com.elipair.spacestudyship.common.exception.CustomException.class) + .extracting("errorCode") + .isEqualTo(com.elipair.spacestudyship.common.exception.ErrorCode.TODO_NOT_FOUND); + } + + @Test + @DisplayName("create: save 후 EntityManager.flush() 호출 — createdAt/updatedAt 보장") + void create_flushesAfterSave() { + var request = new com.elipair.spacestudyship.study.todo.dto.TodoCreateRequest( + "t-new", "수학", java.util.List.of(), null, java.util.List.of("2026-05-25")); + when(todoRepository.existsById("t-new")).thenReturn(false); + when(todoRepository.save(org.mockito.ArgumentMatchers.any(Todo.class))) + .thenAnswer(inv -> inv.getArgument(0)); + + todoService.create(1L, request); + + verify(entityManager).flush(); + } + + @Test + @DisplayName("update: mutation 후 EntityManager.flush() 호출 — updatedAt 갱신 보장") + void update_flushesAfterMutation() { + Todo existing = Todo.create("t1", 1L, "원본", null, null, null); + when(todoRepository.findByIdAndUserId("t1", 1L)) + .thenReturn(java.util.Optional.of(existing)); + + var request = new com.elipair.spacestudyship.study.todo.dto.TodoUpdateRequest( + "새 제목", null, null, null, null, null); + + todoService.update(1L, "t1", request); + + verify(entityManager).flush(); + } + + @Test + @org.junit.jupiter.api.DisplayName("addActualMinutes: 정상 흐름 — repository 호출 및 로그") + void addActualMinutes_success() { + org.mockito.BDDMockito.given(todoRepository.addActualMinutes(1L, "t-1", 30)) + .willReturn(1); + + todoService.addActualMinutes(1L, "t-1", 30); + + org.mockito.Mockito.verify(todoRepository).addActualMinutes(1L, "t-1", 30); + } + + @Test + @org.junit.jupiter.api.DisplayName("addActualMinutes: minutes <= 0 → INVALID_INPUT_VALUE") + void addActualMinutes_nonPositive_throws() { + org.assertj.core.api.Assertions + .assertThatThrownBy(() -> todoService.addActualMinutes(1L, "t-1", 0)) + .isInstanceOf(com.elipair.spacestudyship.common.exception.CustomException.class) + .extracting("errorCode") + .isEqualTo(com.elipair.spacestudyship.common.exception.ErrorCode.INVALID_INPUT_VALUE); + } + + @Test + @org.junit.jupiter.api.DisplayName("addActualMinutes: 영향 row 0 → TODO_NOT_FOUND") + void addActualMinutes_notFound_throws() { + org.mockito.BDDMockito.given(todoRepository.addActualMinutes(1L, "nope", 30)) + .willReturn(0); + + org.assertj.core.api.Assertions + .assertThatThrownBy(() -> todoService.addActualMinutes(1L, "nope", 30)) + .isInstanceOf(com.elipair.spacestudyship.common.exception.CustomException.class) + .extracting("errorCode") + .isEqualTo(com.elipair.spacestudyship.common.exception.ErrorCode.TODO_NOT_FOUND); + } +} diff --git a/SS-Study/src/test/resources/application.yml b/SS-Study/src/test/resources/application.yml new file mode 100644 index 0000000..2cefa4c --- /dev/null +++ b/SS-Study/src/test/resources/application.yml @@ -0,0 +1,12 @@ +spring: + datasource: + # JdbcDatabaseDelegate가 Testcontainers JDBC URL을 동적으로 처리 + url: jdbc:tc:postgresql:16:///studytest + driver-class-name: org.testcontainers.jdbc.ContainerDatabaseDriver + jpa: + database-platform: org.hibernate.dialect.PostgreSQLDialect + hibernate: + ddl-auto: create-drop + show-sql: false + flyway: + enabled: false diff --git a/SS-Study/src/test/resources/import.sql b/SS-Study/src/test/resources/import.sql new file mode 100644 index 0000000..8e97bd9 --- /dev/null +++ b/SS-Study/src/test/resources/import.sql @@ -0,0 +1 @@ +CREATE UNIQUE INDEX IF NOT EXISTS uq_timer_sessions_user_idem ON timer_sessions (user_id, idempotency_key) WHERE idempotency_key IS NOT NULL; diff --git a/SS-Web/src/main/java/com/elipair/spacestudyship/config/BeanConfig.java b/SS-Web/src/main/java/com/elipair/spacestudyship/config/BeanConfig.java new file mode 100644 index 0000000..a07e10a --- /dev/null +++ b/SS-Web/src/main/java/com/elipair/spacestudyship/config/BeanConfig.java @@ -0,0 +1,15 @@ +package com.elipair.spacestudyship.config; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +import java.time.Clock; + +@Configuration +public class BeanConfig { + + @Bean + public Clock clock() { + return Clock.systemUTC(); + } +} diff --git a/SS-Web/src/main/java/com/elipair/spacestudyship/config/SecurityConfig.java b/SS-Web/src/main/java/com/elipair/spacestudyship/config/SecurityConfig.java index 0293f93..4beb25a 100644 --- a/SS-Web/src/main/java/com/elipair/spacestudyship/config/SecurityConfig.java +++ b/SS-Web/src/main/java/com/elipair/spacestudyship/config/SecurityConfig.java @@ -1,10 +1,8 @@ package com.elipair.spacestudyship.config; -import com.elipair.spacestudyship.auth.constant.SecurityUrls; import lombok.RequiredArgsConstructor; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; -import org.springframework.http.HttpStatus; import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity; import org.springframework.security.config.annotation.web.builders.HttpSecurity; import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; @@ -13,7 +11,6 @@ import org.springframework.security.core.userdetails.UserDetailsService; import org.springframework.security.provisioning.InMemoryUserDetailsManager; import org.springframework.security.web.SecurityFilterChain; -import org.springframework.security.web.authentication.HttpStatusEntryPoint; import org.springframework.web.cors.CorsConfiguration; import org.springframework.web.cors.CorsConfigurationSource; import org.springframework.web.cors.UrlBasedCorsConfigurationSource; @@ -32,6 +29,8 @@ public UserDetailsService userDetailsService() { return new InMemoryUserDetailsManager(); } + // JWT 인증/인가는 AuthInterceptor(Spring MVC HandlerInterceptor)가 담당한다. + // SecurityFilterChain은 CSRF/CORS/세션 정책만 책임지고 인가는 통과시킨다. @Bean public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { http @@ -39,13 +38,7 @@ public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { .cors(cors -> cors.configurationSource(corsConfigurationSource())) .sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) - .authorizeHttpRequests(auth -> auth - .requestMatchers(SecurityUrls.AUTH_WHITELIST.toArray(new String[0])).permitAll() - .anyRequest().authenticated() - ) - .exceptionHandling(ex -> ex - .authenticationEntryPoint(new HttpStatusEntryPoint(HttpStatus.UNAUTHORIZED)) - ); + .authorizeHttpRequests(auth -> auth.anyRequest().permitAll()); return http.build(); } diff --git a/SS-Web/src/main/java/com/elipair/spacestudyship/config/WebConfig.java b/SS-Web/src/main/java/com/elipair/spacestudyship/config/WebConfig.java index 34fc115..9d94b6e 100644 --- a/SS-Web/src/main/java/com/elipair/spacestudyship/config/WebConfig.java +++ b/SS-Web/src/main/java/com/elipair/spacestudyship/config/WebConfig.java @@ -22,7 +22,9 @@ public void addInterceptors(InterceptorRegistry registry) { registry.addInterceptor(authInterceptor) .addPathPatterns("/api/**") .excludePathPatterns( - "/api/auth/**", // AuthController - 인증 불필요 (login, reissue, logout) + "/api/auth/login", + "/api/auth/reissue", + "/api/auth/logout", "/actuator/health" ); } diff --git a/SS-Web/src/main/java/com/elipair/spacestudyship/controller/auth/AuthController.java b/SS-Web/src/main/java/com/elipair/spacestudyship/controller/auth/AuthController.java index 29d47fe..20a0645 100644 --- a/SS-Web/src/main/java/com/elipair/spacestudyship/controller/auth/AuthController.java +++ b/SS-Web/src/main/java/com/elipair/spacestudyship/controller/auth/AuthController.java @@ -1,15 +1,28 @@ package com.elipair.spacestudyship.controller.auth; +import com.elipair.spacestudyship.auth.dto.CheckNicknameRequest; +import com.elipair.spacestudyship.auth.dto.CheckNicknameResponse; import com.elipair.spacestudyship.auth.dto.LoginRequest; +import com.elipair.spacestudyship.auth.dto.UpdateNicknameRequest; +import com.elipair.spacestudyship.auth.dto.UpdateNicknameResponse; import com.elipair.spacestudyship.auth.dto.LoginResponse; import com.elipair.spacestudyship.auth.dto.LogoutRequest; import com.elipair.spacestudyship.auth.dto.ReissueRequest; import com.elipair.spacestudyship.auth.dto.ReissueResponse; +import com.elipair.spacestudyship.auth.interceptor.AuthMember; +import com.elipair.spacestudyship.auth.interceptor.LoginMember; import com.elipair.spacestudyship.auth.service.AuthService; +import com.elipair.spacestudyship.common.exception.ErrorResponse; import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.ExampleObject; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.responses.ApiResponses; import io.swagger.v3.oas.annotations.tags.Tag; import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; +import org.springdoc.core.annotations.ParameterObject; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.*; @@ -22,7 +35,139 @@ public class AuthController { private final AuthService authService; - @Operation(summary = "소셜 로그인") + @Operation( + summary = "소셜 로그인", + description = """ + 소셜 플랫폼(Firebase 등)에서 발급받은 ID Token을 백엔드에 전송하여 JWT를 발급받습니다. + 해당 유저가 DB에 없으면 **자동으로 회원가입** 처리됩니다 (랜덤 닉네임 부여). + + ### 응답 코드 + - `200 OK` — 기존 회원 로그인 성공 + - `201 Created` — 신규 회원 가입 + 로그인 성공 (클라이언트는 닉네임 설정 화면으로 이동 권장) + + ### 인증 불필요 + 이 엔드포인트는 공개 API입니다. `Authorization` 헤더 없이 호출하세요. + + ### 서버 처리 흐름 + 1. 소셜 ID Token 검증 (현재는 stub — 추후 Firebase Admin SDK 연동 예정) + 2. socialType + socialId 로 DB 조회 + - 존재: 기존 회원 정보로 JWT 발급 + - 없음: 신규 회원 생성 (랜덤 닉네임), JWT 발급 + 3. Refresh Token 해시를 user_devices 테이블에 저장 (디바이스별) + """ + ) + @ApiResponses({ + @ApiResponse( + responseCode = "200", + description = "기존 회원 로그인 성공.", + content = @Content( + mediaType = "application/json", + schema = @Schema(implementation = LoginResponse.class), + examples = @ExampleObject( + name = "ExistingMember", + summary = "기존 회원 로그인", + value = """ + { + "memberId": 1, + "nickname": "민첩한괴도5308", + "tokens": { + "accessToken": "eyJhbGciOiJIUzI1NiIs...", + "refreshToken": "eyJhbGciOiJIUzI1NiIs..." + }, + "isNewMember": false + } + """ + ) + ) + ), + @ApiResponse( + responseCode = "201", + description = "신규 회원 가입 + 로그인 성공. 응답 본문은 200과 동일 구조이며 `isNewMember: true`.", + content = @Content( + mediaType = "application/json", + schema = @Schema(implementation = LoginResponse.class), + examples = @ExampleObject( + name = "NewMember", + summary = "신규 회원 가입", + value = """ + { + "memberId": 42, + "nickname": "용감한고양이7321", + "tokens": { + "accessToken": "eyJhbGciOiJIUzI1NiIs...", + "refreshToken": "eyJhbGciOiJIUzI1NiIs..." + }, + "isNewMember": true + } + """ + ) + ) + ), + @ApiResponse( + responseCode = "400", + description = "요청 본문 형식 오류 (필수 필드 누락, socialType 이 유효하지 않은 값 등).", + content = @Content( + mediaType = "application/json", + schema = @Schema(implementation = ErrorResponse.class), + examples = { + @ExampleObject( + name = "InvalidInputValue", + summary = "필수 필드 누락", + value = """ + { + "code": "INVALID_INPUT_VALUE", + "message": "idToken: 소셜 인증 토큰(ID Token)은 필수입니다." + } + """ + ), + @ExampleObject( + name = "UnsupportedSocialType", + summary = "지원하지 않는 소셜 타입", + value = """ + { + "code": "UNSUPPORTED_SOCIAL_TYPE", + "message": "지원하지 않는 소셜 로그인 방식입니다." + } + """ + ) + } + ) + ), + @ApiResponse( + responseCode = "401", + description = "소셜 ID Token 검증 실패 (토큰 만료, 변조, 발급자 불일치 등).", + content = @Content( + mediaType = "application/json", + schema = @Schema(implementation = ErrorResponse.class), + examples = @ExampleObject( + name = "SocialLoginFailed", + value = """ + { + "code": "SOCIAL_LOGIN_FAILED", + "message": "소셜 로그인에 실패하였습니다." + } + """ + ) + ) + ), + @ApiResponse( + responseCode = "500", + description = "서버 내부 오류. 닉네임 생성 재시도 초과 등.", + content = @Content( + mediaType = "application/json", + schema = @Schema(implementation = ErrorResponse.class), + examples = @ExampleObject( + name = "InternalServerError", + value = """ + { + "code": "INTERNAL_SERVER_ERROR", + "message": "서버 내부 오류가 발생했습니다." + } + """ + ) + ) + ) + }) @PostMapping("/login") public ResponseEntity login(@RequestBody @Valid LoginRequest request) { LoginResponse response = authService.login(request); @@ -32,16 +177,452 @@ public ResponseEntity login(@RequestBody @Valid LoginRequest requ return ResponseEntity.ok(response); } - @Operation(summary = "토큰 재발급") + @Operation( + summary = "토큰 재발급", + description = """ + 만료된 Access Token 을 Refresh Token 으로 재발급합니다. + Refresh Token 도 함께 갱신됩니다 (**Refresh Token Rotation**). + + ### 인증 불필요 + 이 엔드포인트는 공개 API입니다. `Authorization` 헤더 대신 요청 본문의 `refreshToken` 으로 인증합니다. + + ### 클라이언트 처리 흐름 + 1. 보호된 API 호출 → `401 UNAUTHORIZED` 수신 + 2. 본 엔드포인트 호출 (`refreshToken` 본문 전송) + 3-a. 성공 (200): 새 Access/Refresh Token 저장 후 원래 API 재시도 + 3-b. 실패 (401 `INVALID_TOKEN`): 로그아웃 처리 + 로그인 화면 이동 + + ### 보안 정책 + - Refresh Token 해시가 DB의 저장 해시와 불일치하면 **탈취 의심**으로 간주, 해당 디바이스 세션을 즉시 무효화한 뒤 401 응답. + """ + ) + @ApiResponses({ + @ApiResponse( + responseCode = "200", + description = "토큰 재발급 성공. 클라이언트는 두 토큰 모두 교체 저장해야 합니다.", + content = @Content( + mediaType = "application/json", + schema = @Schema(implementation = ReissueResponse.class), + examples = @ExampleObject( + name = "ReissueSuccess", + value = """ + { + "tokens": { + "accessToken": "eyJhbGciOiJIUzI1NiIs...(new)", + "refreshToken": "eyJhbGciOiJIUzI1NiIs...(new)" + } + } + """ + ) + ) + ), + @ApiResponse( + responseCode = "400", + description = "요청 본문 형식 오류 (refreshToken 누락 등).", + content = @Content( + mediaType = "application/json", + schema = @Schema(implementation = ErrorResponse.class), + examples = @ExampleObject( + name = "InvalidInputValue", + value = """ + { + "code": "INVALID_INPUT_VALUE", + "message": "refreshToken: Refresh Token은 필수입니다." + } + """ + ) + ) + ), + @ApiResponse( + responseCode = "401", + description = "Refresh Token 이 만료되었거나, DB의 저장 해시와 불일치(탈취 의심)이거나, 변조된 경우. 클라이언트는 로그아웃 처리 후 로그인 화면으로 이동해야 합니다.", + content = @Content( + mediaType = "application/json", + schema = @Schema(implementation = ErrorResponse.class), + examples = @ExampleObject( + name = "InvalidToken", + value = """ + { + "code": "INVALID_TOKEN", + "message": "인증 정보가 올바르지 않습니다." + } + """ + ) + ) + ), + @ApiResponse( + responseCode = "500", + description = "서버 내부 오류.", + content = @Content( + mediaType = "application/json", + schema = @Schema(implementation = ErrorResponse.class), + examples = @ExampleObject( + name = "InternalServerError", + value = """ + { + "code": "INTERNAL_SERVER_ERROR", + "message": "서버 내부 오류가 발생했습니다." + } + """ + ) + ) + ) + }) @PostMapping("/reissue") public ResponseEntity reissue(@RequestBody @Valid ReissueRequest request) { return ResponseEntity.ok(authService.reissue(request)); } - @Operation(summary = "로그아웃") + @Operation( + summary = "로그아웃", + description = """ + 서버에서 해당 디바이스의 Refresh Token 을 삭제(무효화)합니다. + 클라이언트는 응답 수신 후 로컬에 저장된 Access/Refresh Token 도 함께 삭제해야 합니다. + + ### 인증 불필요 (실제 동작상) + 서버는 요청 본문의 `refreshToken` 에서 memberId 를 추출해 user_devices 테이블의 (member_id, device_id) row를 삭제합니다. + Refresh Token 이 유효하지 않거나 이미 삭제된 경우에도 멱등하게 **204** 를 응답합니다. + + ### 단일 디바이스 로그아웃 + Refresh Token 은 디바이스별로 발급되므로, 본 호출은 **현재 디바이스의 세션만** 종료합니다. + """ + ) + @ApiResponses({ + @ApiResponse( + responseCode = "204", + description = "로그아웃 처리 완료. 응답 본문 없음.", + content = @Content + ), + @ApiResponse( + responseCode = "400", + description = "요청 본문 형식 오류 (refreshToken 누락 등).", + content = @Content( + mediaType = "application/json", + schema = @Schema(implementation = ErrorResponse.class), + examples = @ExampleObject( + name = "InvalidInputValue", + value = """ + { + "code": "INVALID_INPUT_VALUE", + "message": "refreshToken: Refresh Token은 필수입니다." + } + """ + ) + ) + ), + @ApiResponse( + responseCode = "500", + description = "서버 내부 오류.", + content = @Content( + mediaType = "application/json", + schema = @Schema(implementation = ErrorResponse.class), + examples = @ExampleObject( + name = "InternalServerError", + value = """ + { + "code": "INTERNAL_SERVER_ERROR", + "message": "서버 내부 오류가 발생했습니다." + } + """ + ) + ) + ) + }) @PostMapping("/logout") public ResponseEntity logout(@RequestBody @Valid LogoutRequest request) { authService.logout(request.refreshToken()); return ResponseEntity.noContent().build(); } + + @Operation( + summary = "닉네임 중복 확인", + description = """ + 입력한 닉네임이 다른 사용자가 이미 사용 중인지 확인합니다. + + ### 동작 + - DB에 동일한 닉네임이 존재하지 않으면 `available: true` + - 이미 존재하면 `available: false` + + ### 닉네임 규칙 + - 길이: 2 ~ 10 자 + - 허용 문자: 한글, 영문 대소문자, 숫자 + - 금지: 공백, 특수문자, 이모지 + + ### 주의 + - 본인이 현재 사용 중인 닉네임으로 조회해도 `available: false`로 응답됩니다. (프론트에서 본인 닉네임 입력 시 중복확인 버튼을 비활성화하는 것을 권장) + - 닉네임 변경 직전 마지막 검증으로 사용하되, 동시에 다른 사용자가 같은 닉네임을 등록하는 race condition 은 `PATCH /api/auth/nickname` 단계에서 별도로 처리됩니다. + """ + ) + @ApiResponses({ + @ApiResponse( + responseCode = "200", + description = "정상 조회. 본문의 `available` 필드로 사용 가능 여부 판단.", + content = @Content( + mediaType = "application/json", + schema = @Schema(implementation = CheckNicknameResponse.class), + examples = { + @ExampleObject( + name = "Available", + summary = "사용 가능한 닉네임", + value = """ + { + "available": true + } + """ + ), + @ExampleObject( + name = "NotAvailable", + summary = "이미 사용 중인 닉네임", + value = """ + { + "available": false + } + """ + ) + } + ) + ), + @ApiResponse( + responseCode = "400", + description = "닉네임 형식 오류 (길이 미달/초과, 허용되지 않은 문자 포함 등). `message` 필드에 어떤 필드의 어떤 제약을 어겼는지 상세 표기됩니다.", + content = @Content( + mediaType = "application/json", + schema = @Schema(implementation = ErrorResponse.class), + examples = @ExampleObject( + name = "InvalidInputValue", + value = """ + { + "code": "INVALID_INPUT_VALUE", + "message": "nickname: 닉네임은 한글, 영문, 숫자만 사용할 수 있습니다." + } + """ + ) + ) + ), + @ApiResponse( + responseCode = "401", + description = "인증 실패 — Access Token 이 헤더에 없거나, 만료되었거나, 유효하지 않은 경우. 클라이언트는 `/api/auth/reissue` 로 재발급을 시도해야 합니다.", + content = @Content( + mediaType = "application/json", + schema = @Schema(implementation = ErrorResponse.class), + examples = @ExampleObject( + name = "UnauthenticatedRequest", + value = """ + { + "code": "UNAUTHENTICATED_REQUEST", + "message": "로그인이 필요합니다." + } + """ + ) + ) + ), + @ApiResponse( + responseCode = "500", + description = "서버 내부 오류. 사용자에게는 \"잠시 후 다시 시도해주세요\" 안내가 적절합니다.", + content = @Content( + mediaType = "application/json", + schema = @Schema(implementation = ErrorResponse.class), + examples = @ExampleObject( + name = "InternalServerError", + value = """ + { + "code": "INTERNAL_SERVER_ERROR", + "message": "서버 내부 오류가 발생했습니다." + } + """ + ) + ) + ) + }) + @GetMapping("/check-nickname") + public ResponseEntity checkNickname( + @AuthMember LoginMember loginMember, + @ParameterObject @Valid @ModelAttribute CheckNicknameRequest request) { + return ResponseEntity.ok(authService.checkNickname(request.nickname())); + } + + @Operation( + summary = "닉네임 변경", + description = """ + 사용자의 닉네임을 변경합니다. + + ### 동작 + 1. 닉네임 형식 검증 (길이, 허용 문자) + 2. 본인 현재 닉네임과 동일하면 NO-OP — 중복 검사 없이 그대로 통과 (200) + 3. 다른 회원이 사용 중이면 `409 DUPLICATED_NICKNAME` + 4. 통과 시 DB 갱신 + JPA flush 로 unique 제약 위반을 동기적으로 감지 (race condition 처리) + + ### 닉네임 규칙 + - 길이: 2 ~ 10 자 + - 허용 문자: 한글, 영문 대소문자, 숫자 + - 금지: 공백, 특수문자, 이모지 + + ### 사전 검증 + 클라이언트는 입력 직후 `GET /api/auth/check-nickname` 로 사용 가능 여부를 먼저 확인하는 것을 권장합니다. + 다만 이 호출 후 다른 사용자가 같은 닉네임을 차지하는 race 는 본 엔드포인트가 안전하게 처리합니다. + """ + ) + @ApiResponses({ + @ApiResponse( + responseCode = "200", + description = "닉네임 변경 성공. 응답 본문에 변경된 닉네임 포함.", + content = @Content( + mediaType = "application/json", + schema = @Schema(implementation = UpdateNicknameResponse.class), + examples = @ExampleObject( + name = "UpdateSuccess", + value = """ + { + "nickname": "우주탐험가" + } + """ + ) + ) + ), + @ApiResponse( + responseCode = "400", + description = "닉네임 형식 오류 (길이 미달/초과, 허용되지 않은 문자).", + content = @Content( + mediaType = "application/json", + schema = @Schema(implementation = ErrorResponse.class), + examples = @ExampleObject( + name = "InvalidInputValue", + value = """ + { + "code": "INVALID_INPUT_VALUE", + "message": "nickname: 닉네임은 2자 이상 10자 이하여야 합니다." + } + """ + ) + ) + ), + @ApiResponse( + responseCode = "401", + description = "인증 실패 — Access Token 이 헤더에 없거나, 만료되었거나, 유효하지 않은 경우.", + content = @Content( + mediaType = "application/json", + schema = @Schema(implementation = ErrorResponse.class), + examples = @ExampleObject( + name = "UnauthenticatedRequest", + value = """ + { + "code": "UNAUTHENTICATED_REQUEST", + "message": "로그인이 필요합니다." + } + """ + ) + ) + ), + @ApiResponse( + responseCode = "409", + description = "이미 다른 사용자가 사용 중인 닉네임.", + content = @Content( + mediaType = "application/json", + schema = @Schema(implementation = ErrorResponse.class), + examples = @ExampleObject( + name = "DuplicatedNickname", + value = """ + { + "code": "DUPLICATED_NICKNAME", + "message": "이미 사용 중인 닉네임입니다." + } + """ + ) + ) + ), + @ApiResponse( + responseCode = "500", + description = "서버 내부 오류.", + content = @Content( + mediaType = "application/json", + schema = @Schema(implementation = ErrorResponse.class), + examples = @ExampleObject( + name = "InternalServerError", + value = """ + { + "code": "INTERNAL_SERVER_ERROR", + "message": "서버 내부 오류가 발생했습니다." + } + """ + ) + ) + ) + }) + @PatchMapping("/nickname") + public ResponseEntity updateNickname( + @AuthMember LoginMember loginMember, + @RequestBody @Valid UpdateNicknameRequest request) { + return ResponseEntity.ok(authService.updateNickname(loginMember.memberId(), request)); + } + + @Operation( + summary = "회원 탈퇴", + description = """ + 인증된 사용자의 계정과 관련 데이터를 영구 삭제합니다. **이 작업은 되돌릴 수 없습니다.** + + ### 삭제 대상 + - `members` 테이블의 해당 회원 row + - `user_devices` 테이블의 해당 회원 row 전체 (FK CASCADE로 자동 삭제, 모든 디바이스 세션 무효화) + - Firebase Authentication 의 해당 사용자 (uid = 회원의 socialId) + + ### 처리 순서 + 1. 회원 row 삭제 (`@Transactional`) → FK CASCADE로 user_devices 자동 삭제 + 2. Firebase Authentication 사용자 삭제 + + ### 멱등성 + - 동일한 토큰으로 두 번 호출되거나, 다른 디바이스에서 먼저 탈퇴되어 회원이 이미 없는 상태에서 호출되어도 동일하게 **204**를 응답합니다. + - Firebase 측에서 사용자가 이미 없는 경우(`USER_NOT_FOUND`)도 무시하고 정상 완료 처리합니다. + - Firebase 일시 장애 등 외부 시스템 오류도 서버에서 로그만 남기고 클라이언트에는 **204**를 응답합니다 (우리 측 데이터 정리는 이미 완료). + + ### 클라이언트 처리 가이드 + - 응답 받은 후 로컬에 저장된 Access Token / Refresh Token / 회원 정보를 모두 삭제하고 로그인 화면으로 이동하세요. + - 네트워크 오류로 응답을 못 받은 경우 재시도 가능합니다 (멱등 보장). + """ + ) + @ApiResponses({ + @ApiResponse( + responseCode = "204", + description = "탈퇴 성공. 응답 본문 없음. (이미 탈퇴된 상태 / Firebase 측 사용자 부재 / 외부 시스템 일시 오류 등 모두 포함)", + content = @Content + ), + @ApiResponse( + responseCode = "401", + description = "인증 실패 — Access Token 이 헤더에 없거나, 만료되었거나, 유효하지 않은 경우. 클라이언트는 `/api/auth/reissue` 로 재발급을 시도해야 합니다.", + content = @Content( + mediaType = "application/json", + schema = @Schema(implementation = ErrorResponse.class), + examples = @ExampleObject( + name = "UnauthenticatedRequest", + value = """ + { + "code": "UNAUTHENTICATED_REQUEST", + "message": "로그인이 필요합니다." + } + """ + ) + ) + ), + @ApiResponse( + responseCode = "500", + description = "서버 내부 오류. 주로 DB 통신 실패 시. 사용자에게는 \"잠시 후 다시 시도해주세요\" 안내가 적절합니다.", + content = @Content( + mediaType = "application/json", + schema = @Schema(implementation = ErrorResponse.class), + examples = @ExampleObject( + name = "InternalServerError", + value = """ + { + "code": "INTERNAL_SERVER_ERROR", + "message": "서버 내부 오류가 발생했습니다." + } + """ + ) + ) + ) + }) + @DeleteMapping("/withdraw") + public ResponseEntity withdraw(@AuthMember LoginMember loginMember) { + authService.withdraw(loginMember.memberId()); + return ResponseEntity.noContent().build(); + } } diff --git a/SS-Web/src/main/java/com/elipair/spacestudyship/controller/exploration/ExplorationController.java b/SS-Web/src/main/java/com/elipair/spacestudyship/controller/exploration/ExplorationController.java new file mode 100644 index 0000000..916aeaa --- /dev/null +++ b/SS-Web/src/main/java/com/elipair/spacestudyship/controller/exploration/ExplorationController.java @@ -0,0 +1,72 @@ +package com.elipair.spacestudyship.controller.exploration; + +import com.elipair.spacestudyship.auth.interceptor.AuthMember; +import com.elipair.spacestudyship.auth.interceptor.LoginMember; +import com.elipair.spacestudyship.study.exploration.dto.PlanetResponse; +import com.elipair.spacestudyship.study.exploration.dto.PlanetUnlockResponse; +import com.elipair.spacestudyship.study.exploration.dto.RegionResponse; +import com.elipair.spacestudyship.study.exploration.dto.RegionUnlockResponse; +import com.elipair.spacestudyship.study.exploration.service.ExplorationService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import java.util.List; + +@Tag(name = "Exploration", description = "우주 탐험(행성/지역 해금) API") +@RestController +@RequiredArgsConstructor +@RequestMapping("/api/explorations") +public class ExplorationController { + + private final ExplorationService explorationService; + + @Operation(summary = "행성 목록 조회", + description = "전체 행성 목록과 유저의 해금/클리어 상태, 진행도를 반환합니다. 정렬: sortOrder 오름차순.") + @GetMapping("/planets") + public ResponseEntity> getPlanets(@AuthMember LoginMember loginMember) { + return ResponseEntity.ok(explorationService.getPlanets(loginMember.memberId())); + } + + @Operation(summary = "행성 하위 지역 목록 조회", + description = "특정 행성의 하위 지역과 유저 해금 상태를 반환합니다. 행성이 없으면 404 PLANET_NOT_FOUND.") + @GetMapping("/planets/{planetId}/regions") + public ResponseEntity> getRegions( + @AuthMember LoginMember loginMember, + @PathVariable String planetId) { + return ResponseEntity.ok(explorationService.getRegions(loginMember.memberId(), planetId)); + } + + @Operation(summary = "지역 해금", + description = """ + 연료를 소비하여 지역을 해금합니다(해금=클리어). 잔량 확인+차감+해금을 원자적으로 처리합니다. + 상위 행성의 모든 지역이 해금되면 planetCleared=true. + + 에러: 400 INSUFFICIENT_FUEL(requiredFuel/currentFuel 동봉) / ALREADY_UNLOCKED / PLANET_LOCKED, 404 REGION_NOT_FOUND + """) + @PostMapping("/regions/{regionId}/unlock") + public ResponseEntity unlockRegion( + @AuthMember LoginMember loginMember, + @PathVariable String regionId) { + return ResponseEntity.ok(explorationService.unlockRegion(loginMember.memberId(), regionId)); + } + + @Operation(summary = "행성 해금", + description = """ + 연료를 소비하여 행성을 해금합니다. 선행 행성을 클리어해야 해금할 수 있습니다. + + 에러: 400 INSUFFICIENT_FUEL(requiredFuel/currentFuel 동봉) / ALREADY_UNLOCKED / PREREQUISITE_NOT_CLEARED, 404 PLANET_NOT_FOUND + """) + @PostMapping("/planets/{planetId}/unlock") + public ResponseEntity unlockPlanet( + @AuthMember LoginMember loginMember, + @PathVariable String planetId) { + return ResponseEntity.ok(explorationService.unlockPlanet(loginMember.memberId(), planetId)); + } +} diff --git a/SS-Web/src/main/java/com/elipair/spacestudyship/controller/fuel/FuelController.java b/SS-Web/src/main/java/com/elipair/spacestudyship/controller/fuel/FuelController.java new file mode 100644 index 0000000..783dd2c --- /dev/null +++ b/SS-Web/src/main/java/com/elipair/spacestudyship/controller/fuel/FuelController.java @@ -0,0 +1,131 @@ +package com.elipair.spacestudyship.controller.fuel; + +import com.elipair.spacestudyship.auth.interceptor.AuthMember; +import com.elipair.spacestudyship.auth.interceptor.LoginMember; +import com.elipair.spacestudyship.common.exception.CustomException; +import com.elipair.spacestudyship.common.exception.ErrorCode; +import com.elipair.spacestudyship.common.exception.ErrorResponse; + +import java.time.LocalDate; +import java.time.format.DateTimeParseException; +import com.elipair.spacestudyship.study.fuel.constant.TransactionType; +import com.elipair.spacestudyship.study.fuel.dto.FuelResponse; +import com.elipair.spacestudyship.study.fuel.dto.FuelTransactionListResponse; +import com.elipair.spacestudyship.study.fuel.service.FuelService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.ExampleObject; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.responses.ApiResponses; +import io.swagger.v3.oas.annotations.tags.Tag; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +@Tag(name = "Fuel", description = "연료 잔량 및 거래 내역 API") +@RestController +@RequiredArgsConstructor +@RequestMapping("/api/fuel") +public class FuelController { + + private final FuelService fuelService; + + @Operation(summary = "연료 잔량 조회", + description = """ + 현재 유저의 연료 잔량 및 누적 충전/소비량을 조회합니다. + + ### 응답 필드 + - currentFuel: 현재 보유 (totalCharged - totalConsumed) + - totalCharged / totalConsumed: 누적량 + - pendingMinutes: 향후 확장용, 현재 항상 0 + - lastUpdatedAt: 마지막 변동 시각 (ISO 8601 UTC) + """) + @ApiResponses({ + @ApiResponse(responseCode = "200", description = "조회 성공", + content = @Content(mediaType = "application/json", + schema = @Schema(implementation = FuelResponse.class), + examples = @ExampleObject(name = "Success", + value = "{\"currentFuel\":350,\"totalCharged\":1200,\"totalConsumed\":850,\"pendingMinutes\":0,\"lastUpdatedAt\":\"2026-04-16T10:30:00Z\"}"))), + @ApiResponse(responseCode = "401", description = "인증 필요", + content = @Content(schema = @Schema(implementation = ErrorResponse.class), + examples = @ExampleObject(value = "{\"code\":\"UNAUTHENTICATED_REQUEST\",\"message\":\"로그인이 필요합니다.\"}"))), + @ApiResponse(responseCode = "500", description = "서버 오류", + content = @Content(schema = @Schema(implementation = ErrorResponse.class))) + }) + @GetMapping + public ResponseEntity getFuel(@AuthMember LoginMember loginMember) { + return ResponseEntity.ok(fuelService.getFuel(loginMember.memberId())); + } + + @Operation(summary = "연료 거래 내역 조회", + description = """ + 연료 충전/소비 이력을 페이지네이션으로 조회합니다. + + ### Query Parameters + - type: charge | consume (선택) + - startDate / endDate: YYYY-MM-DD (선택, 종료일 포함 반열림 [start, end+1)) + - page: 기본 0 + - size: 기본 20, 최대 100 + + 정렬은 createdAt 내림차순 고정. + """) + @ApiResponses({ + @ApiResponse(responseCode = "200", description = "조회 성공", + content = @Content(mediaType = "application/json", + schema = @Schema(implementation = FuelTransactionListResponse.class), + examples = @ExampleObject(name = "Page", + value = """ + { + "content": [ + {"id":"tx-1","type":"charge","amount":90,"reason":"STUDY_SESSION","referenceId":"session-1","balanceAfter":350,"createdAt":"2026-04-16T10:30:00Z"} + ], + "page": 0, "size": 20, "totalElements": 120, "totalPages": 6 + } + """))), + @ApiResponse(responseCode = "400", description = "잘못된 query parameter", + content = @Content(schema = @Schema(implementation = ErrorResponse.class), + examples = @ExampleObject(value = "{\"code\":\"INVALID_INPUT_VALUE\",\"message\":\"type은 charge 또는 consume이어야 합니다.\"}"))), + @ApiResponse(responseCode = "401", description = "인증 필요"), + @ApiResponse(responseCode = "500", description = "서버 오류") + }) + @GetMapping("/transactions") + public ResponseEntity getTransactions( + @AuthMember LoginMember loginMember, + @RequestParam(required = false) String type, + @RequestParam(required = false) String startDate, + @RequestParam(required = false) String endDate, + @RequestParam(defaultValue = "0") Integer page, + @RequestParam(defaultValue = "20") Integer size) { + + if (type != null && !type.equals("charge") && !type.equals("consume")) { + throw new CustomException(ErrorCode.INVALID_INPUT_VALUE); + } + if (startDate != null) { + try { LocalDate.parse(startDate); } catch (DateTimeParseException e) { + throw new CustomException(ErrorCode.INVALID_INPUT_VALUE); + } + } + if (endDate != null) { + try { LocalDate.parse(endDate); } catch (DateTimeParseException e) { + throw new CustomException(ErrorCode.INVALID_INPUT_VALUE); + } + } + if (page < 0) { + throw new CustomException(ErrorCode.INVALID_INPUT_VALUE); + } + if (size < 1 || size > 100) { + throw new CustomException(ErrorCode.INVALID_INPUT_VALUE); + } + + TransactionType typeEnum = type == null ? null + : TransactionType.valueOf(type.toUpperCase()); + return ResponseEntity.ok( + fuelService.getTransactions( + loginMember.memberId(), typeEnum, + startDate, endDate, page, size)); + } +} diff --git a/SS-Web/src/main/java/com/elipair/spacestudyship/controller/timer/TimerSessionController.java b/SS-Web/src/main/java/com/elipair/spacestudyship/controller/timer/TimerSessionController.java new file mode 100644 index 0000000..c79d653 --- /dev/null +++ b/SS-Web/src/main/java/com/elipair/spacestudyship/controller/timer/TimerSessionController.java @@ -0,0 +1,174 @@ +package com.elipair.spacestudyship.controller.timer; + +import com.elipair.spacestudyship.auth.interceptor.AuthMember; +import com.elipair.spacestudyship.auth.interceptor.LoginMember; +import com.elipair.spacestudyship.common.exception.CustomException; +import com.elipair.spacestudyship.common.exception.ErrorCode; +import com.elipair.spacestudyship.common.exception.ErrorResponse; +import com.elipair.spacestudyship.study.timer.dto.TimerSessionCreateRequest; +import com.elipair.spacestudyship.study.timer.dto.TimerSessionCreateResponse; +import com.elipair.spacestudyship.study.timer.dto.TimerSessionListResponse; +import com.elipair.spacestudyship.study.timer.dto.TodayStatsResponse; +import com.elipair.spacestudyship.study.timer.service.TimerSessionService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.enums.ParameterIn; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.ExampleObject; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.responses.ApiResponses; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +import java.time.LocalDate; +import java.time.format.DateTimeParseException; + +@Tag(name = "Timer", description = "공부 타이머 세션 API") +@RestController +@RequiredArgsConstructor +@RequestMapping("/api/timer-sessions") +public class TimerSessionController { + + private final TimerSessionService timerSessionService; + + @Operation(summary = "세션 기록 저장", + description = """ + 타이머 종료 시 세션을 저장합니다. + 서버에서 시간 유효성 5단계 검증 후, 통과 시 연료를 자동 충전하고 + 연결된 Todo의 actualMinutes를 누적합니다 (단일 트랜잭션). + + ### Idempotency + 헤더 `Idempotency-Key`를 보내면 동일 키 재요청 시 기존 세션을 반환합니다 (중복 충전 방지). + """) + @ApiResponses({ + @ApiResponse(responseCode = "201", description = "저장 성공", + content = @Content(mediaType = "application/json", + schema = @Schema(implementation = TimerSessionCreateResponse.class), + examples = @ExampleObject(value = """ + { + "session": { + "id":"sess-uuid", + "todoId":"todo-1", + "todoTitle":"수학", + "startedAt":"2026-05-25T01:00:00Z", + "endedAt":"2026-05-25T02:30:00Z", + "durationMinutes":90 + }, + "fuelCharged":90 + } + """))), + @ApiResponse(responseCode = "400", description = "검증 실패", + content = @Content(schema = @Schema(implementation = ErrorResponse.class), + examples = { + @ExampleObject(name = "INVALID_SESSION_TIME", value = "{\"code\":\"INVALID_SESSION_TIME\",\"message\":\"시작 시각이 종료 시각보다 늦거나 같습니다.\"}"), + @ExampleObject(name = "INVALID_DURATION", value = "{\"code\":\"INVALID_DURATION\",\"message\":\"공부 시간이 시작/종료 시각 간격보다 큽니다.\"}"), + @ExampleObject(name = "SESSION_TOO_SHORT", value = "{\"code\":\"SESSION_TOO_SHORT\",\"message\":\"공부 시간은 1분 이상이어야 합니다.\"}"), + @ExampleObject(name = "SESSION_TOO_LONG", value = "{\"code\":\"SESSION_TOO_LONG\",\"message\":\"공부 시간은 24시간(1440분)을 초과할 수 없습니다.\"}"), + @ExampleObject(name = "FUTURE_SESSION", value = "{\"code\":\"FUTURE_SESSION\",\"message\":\"미래 시각의 세션은 저장할 수 없습니다.\"}") + })), + @ApiResponse(responseCode = "401", description = "인증 필요", + content = @Content(schema = @Schema(implementation = ErrorResponse.class))), + @ApiResponse(responseCode = "404", description = "연결된 Todo가 본인 소유 아님 / 존재하지 않음", + content = @Content(schema = @Schema(implementation = ErrorResponse.class), + examples = @ExampleObject(value = "{\"code\":\"TODO_NOT_FOUND\",\"message\":\"해당 할 일을 찾을 수 없습니다.\"}"))), + @ApiResponse(responseCode = "500", description = "서버 오류") + }) + @PostMapping + public ResponseEntity create( + @AuthMember LoginMember loginMember, + @Valid @RequestBody TimerSessionCreateRequest request, + @Parameter(in = ParameterIn.HEADER, name = "Idempotency-Key", + description = "재시도 시 중복 저장 방지용 키 (선택). 동일 키 재요청 시 기존 세션 반환.", + example = "550e8400-e29b-41d4-a716-446655440000") + @RequestHeader(value = "Idempotency-Key", required = false) String idempotencyKey) { + + TimerSessionCreateResponse response = timerSessionService.create( + loginMember.memberId(), request, idempotencyKey); + return ResponseEntity.status(HttpStatus.CREATED).body(response); + } + + @Operation(summary = "세션 목록 조회", + description = """ + ### Query Parameters + - startDate / endDate: YYYY-MM-DD (선택). 종료일 포함 반열림 [start, end+1) + - todoId: 특정 Todo에 연결된 세션만 (선택) + - page: 기본 0 + - size: 기본 20, 최대 100 + + 정렬: startedAt 내림차순 (최신순) 고정. + """) + @ApiResponses({ + @ApiResponse(responseCode = "200", description = "조회 성공", + content = @Content(mediaType = "application/json", + schema = @Schema(implementation = TimerSessionListResponse.class))), + @ApiResponse(responseCode = "400", description = "잘못된 query parameter", + content = @Content(schema = @Schema(implementation = ErrorResponse.class), + examples = @ExampleObject(value = "{\"code\":\"INVALID_INPUT_VALUE\",\"message\":\"입력값이 유효하지 않습니다.\"}"))), + @ApiResponse(responseCode = "401", description = "인증 필요") + }) + @GetMapping + public ResponseEntity getList( + @AuthMember LoginMember loginMember, + @RequestParam(required = false) String startDate, + @RequestParam(required = false) String endDate, + @RequestParam(required = false) String todoId, + @RequestParam(defaultValue = "0") Integer page, + @RequestParam(defaultValue = "20") Integer size) { + + validateDateParam(startDate); + validateDateParam(endDate); + if (page < 0) throw new CustomException(ErrorCode.INVALID_INPUT_VALUE); + if (size < 1 || size > 100) throw new CustomException(ErrorCode.INVALID_INPUT_VALUE); + + return ResponseEntity.ok(timerSessionService.getList( + loginMember.memberId(), startDate, endDate, todoId, page, size)); + } + + @Operation(summary = "오늘 공부 통계 + 누적 통계", + description = """ + KST(Asia/Seoul) 기준 통계. + + ### 응답 필드 + - `totalMinutes`, `sessionCount`: 오늘 (KST) + - `streak`: 연속 공부 일수 (오늘 포함, KST) + - `lifetimeMinutes`, `lifetimeSessionCount`: 회원의 전체 누적 + - `monthlyMinutes`: 이번 달 누적 (KST 1일 00:00 ~ 다음 달 1일 00:00) + + 세션 0건 회원도 6개 필드 모두 `0`을 반환합니다 (null 금지). + """) + @ApiResponses({ + @ApiResponse(responseCode = "200", description = "조회 성공", + content = @Content(mediaType = "application/json", + schema = @Schema(implementation = TodayStatsResponse.class), + examples = @ExampleObject(value = """ + { + "totalMinutes": 180, + "sessionCount": 3, + "streak": 7, + "lifetimeMinutes": 12450, + "lifetimeSessionCount": 287, + "monthlyMinutes": 1820 + } + """))), + @ApiResponse(responseCode = "401", description = "인증 필요") + }) + @GetMapping("/today-stats") + public ResponseEntity getTodayStats( + @AuthMember LoginMember loginMember) { + return ResponseEntity.ok( + timerSessionService.getTodayStats(loginMember.memberId())); + } + + private void validateDateParam(String date) { + if (date == null) return; + try { LocalDate.parse(date); } + catch (DateTimeParseException e) { + throw new CustomException(ErrorCode.INVALID_INPUT_VALUE); + } + } +} diff --git a/SS-Web/src/main/java/com/elipair/spacestudyship/controller/todo/TodoCategoryController.java b/SS-Web/src/main/java/com/elipair/spacestudyship/controller/todo/TodoCategoryController.java new file mode 100644 index 0000000..de437a6 --- /dev/null +++ b/SS-Web/src/main/java/com/elipair/spacestudyship/controller/todo/TodoCategoryController.java @@ -0,0 +1,125 @@ +package com.elipair.spacestudyship.controller.todo; + +import com.elipair.spacestudyship.auth.interceptor.AuthMember; +import com.elipair.spacestudyship.auth.interceptor.LoginMember; +import com.elipair.spacestudyship.common.exception.ErrorResponse; +import com.elipair.spacestudyship.study.todo.dto.CategoryCreateRequest; +import com.elipair.spacestudyship.study.todo.dto.CategoryResponse; +import com.elipair.spacestudyship.study.todo.dto.CategoryUpdateRequest; +import com.elipair.spacestudyship.study.todo.service.TodoCategoryService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.media.ArraySchema; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.ExampleObject; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.responses.ApiResponses; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; +import jakarta.validation.constraints.Pattern; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.*; + +import java.util.List; + +@Tag(name = "TodoCategory", description = "할 일 카테고리 CRUD API") +@RestController +@RequestMapping("/api/todo-categories") +@RequiredArgsConstructor +@Validated +public class TodoCategoryController { + + private final TodoCategoryService categoryService; + + @Operation(summary = "카테고리 목록 조회", description = "createdAt 오름차순") + @ApiResponses({ + @ApiResponse(responseCode = "200", description = "조회 성공", + content = @Content(array = @ArraySchema(schema = @Schema(implementation = CategoryResponse.class)))), + @ApiResponse(responseCode = "401", description = "인증 실패", + content = @Content(schema = @Schema(implementation = ErrorResponse.class), + examples = @ExampleObject(value = "{\"code\":\"UNAUTHENTICATED_REQUEST\",\"message\":\"로그인이 필요합니다.\"}"))) + }) + @GetMapping + public ResponseEntity> findAll(@AuthMember LoginMember loginMember) { + return ResponseEntity.ok(categoryService.findAll(loginMember.memberId())); + } + + @Operation(summary = "카테고리 생성") + @ApiResponses({ + @ApiResponse(responseCode = "201", description = "생성 성공", + content = @Content(schema = @Schema(implementation = CategoryResponse.class))), + @ApiResponse(responseCode = "400", description = "입력값 검증 실패", + content = @Content(schema = @Schema(implementation = ErrorResponse.class), + examples = @ExampleObject(value = "{\"code\":\"INVALID_INPUT_VALUE\",\"message\":\"name: 비어있을 수 없습니다.\"}"))), + @ApiResponse(responseCode = "401", description = "인증 실패", + content = @Content(schema = @Schema(implementation = ErrorResponse.class), + examples = @ExampleObject(value = "{\"code\":\"UNAUTHENTICATED_REQUEST\",\"message\":\"로그인이 필요합니다.\"}"))), + @ApiResponse(responseCode = "409", description = "동일 ID 중복", + content = @Content(schema = @Schema(implementation = ErrorResponse.class), + examples = @ExampleObject(value = "{\"code\":\"CATEGORY_ALREADY_EXISTS\",\"message\":\"동일 ID의 카테고리가 이미 존재합니다.\"}"))), + @ApiResponse(responseCode = "500", description = "서버 내부 오류", + content = @Content(schema = @Schema(implementation = ErrorResponse.class), + examples = @ExampleObject(value = "{\"code\":\"INTERNAL_SERVER_ERROR\",\"message\":\"서버 내부 오류가 발생했습니다.\"}"))) + }) + @PostMapping + public ResponseEntity create( + @AuthMember LoginMember loginMember, + @RequestBody @Valid CategoryCreateRequest request) { + return ResponseEntity.status(HttpStatus.CREATED) + .body(categoryService.create(loginMember.memberId(), request)); + } + + @Operation(summary = "카테고리 부분 수정") + @ApiResponses({ + @ApiResponse(responseCode = "200", description = "수정 성공", + content = @Content(schema = @Schema(implementation = CategoryResponse.class))), + @ApiResponse(responseCode = "400", description = "입력값 검증 실패", + content = @Content(schema = @Schema(implementation = ErrorResponse.class), + examples = @ExampleObject(value = "{\"code\":\"INVALID_INPUT_VALUE\",\"message\":\"...\"}"))), + @ApiResponse(responseCode = "401", description = "인증 실패", + content = @Content(schema = @Schema(implementation = ErrorResponse.class), + examples = @ExampleObject(value = "{\"code\":\"UNAUTHENTICATED_REQUEST\",\"message\":\"로그인이 필요합니다.\"}"))), + @ApiResponse(responseCode = "404", description = "카테고리 없음", + content = @Content(schema = @Schema(implementation = ErrorResponse.class), + examples = @ExampleObject(value = "{\"code\":\"CATEGORY_NOT_FOUND\",\"message\":\"해당 카테고리를 찾을 수 없습니다.\"}"))), + @ApiResponse(responseCode = "500", description = "서버 내부 오류", + content = @Content(schema = @Schema(implementation = ErrorResponse.class), + examples = @ExampleObject(value = "{\"code\":\"INTERNAL_SERVER_ERROR\",\"message\":\"서버 내부 오류가 발생했습니다.\"}"))) + }) + @PatchMapping("/{categoryId}") + public ResponseEntity update( + @AuthMember LoginMember loginMember, + @PathVariable + @Pattern(regexp = "[a-zA-Z0-9-]+", message = "categoryId: 영숫자와 하이픈만 허용합니다.") + String categoryId, + @RequestBody @Valid CategoryUpdateRequest request) { + return ResponseEntity.ok(categoryService.update(loginMember.memberId(), categoryId, request)); + } + + @Operation(summary = "카테고리 삭제", + description = "삭제 시 연관 Todo의 categoryIds에서 자동 제거됩니다.") + @ApiResponses({ + @ApiResponse(responseCode = "204", description = "삭제 성공", content = @Content), + @ApiResponse(responseCode = "401", description = "인증 실패", + content = @Content(schema = @Schema(implementation = ErrorResponse.class), + examples = @ExampleObject(value = "{\"code\":\"UNAUTHENTICATED_REQUEST\",\"message\":\"로그인이 필요합니다.\"}"))), + @ApiResponse(responseCode = "404", description = "카테고리 없음", + content = @Content(schema = @Schema(implementation = ErrorResponse.class), + examples = @ExampleObject(value = "{\"code\":\"CATEGORY_NOT_FOUND\",\"message\":\"해당 카테고리를 찾을 수 없습니다.\"}"))), + @ApiResponse(responseCode = "500", description = "서버 내부 오류", + content = @Content(schema = @Schema(implementation = ErrorResponse.class), + examples = @ExampleObject(value = "{\"code\":\"INTERNAL_SERVER_ERROR\",\"message\":\"서버 내부 오류가 발생했습니다.\"}"))) + }) + @DeleteMapping("/{categoryId}") + public ResponseEntity delete( + @AuthMember LoginMember loginMember, + @PathVariable + @Pattern(regexp = "[a-zA-Z0-9-]+", message = "categoryId: 영숫자와 하이픈만 허용합니다.") + String categoryId) { + categoryService.delete(loginMember.memberId(), categoryId); + return ResponseEntity.noContent().build(); + } +} diff --git a/SS-Web/src/main/java/com/elipair/spacestudyship/controller/todo/TodoController.java b/SS-Web/src/main/java/com/elipair/spacestudyship/controller/todo/TodoController.java new file mode 100644 index 0000000..362e566 --- /dev/null +++ b/SS-Web/src/main/java/com/elipair/spacestudyship/controller/todo/TodoController.java @@ -0,0 +1,145 @@ +package com.elipair.spacestudyship.controller.todo; + +import com.elipair.spacestudyship.auth.interceptor.AuthMember; +import com.elipair.spacestudyship.auth.interceptor.LoginMember; +import com.elipair.spacestudyship.common.exception.ErrorResponse; +import com.elipair.spacestudyship.study.todo.dto.TodoCreateRequest; +import com.elipair.spacestudyship.study.todo.dto.TodoResponse; +import com.elipair.spacestudyship.study.todo.dto.TodoUpdateRequest; +import com.elipair.spacestudyship.study.todo.service.TodoService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.media.ArraySchema; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.ExampleObject; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.responses.ApiResponses; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; +import jakarta.validation.constraints.Pattern; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.*; + +import java.util.List; + +@Tag(name = "Todo", description = "할 일 CRUD API") +@RestController +@RequestMapping("/api/todos") +@RequiredArgsConstructor +@Validated +public class TodoController { + + private final TodoService todoService; + + @Operation(summary = "할 일 목록 조회", + description = "선택적으로 date / categoryId 쿼리로 필터. 결과는 createdAt 내림차순.") + @ApiResponses({ + @ApiResponse(responseCode = "200", description = "조회 성공", + content = @Content(mediaType = "application/json", + array = @ArraySchema(schema = @Schema(implementation = TodoResponse.class)))), + @ApiResponse(responseCode = "401", description = "인증 실패", + content = @Content(schema = @Schema(implementation = ErrorResponse.class), + examples = @ExampleObject(value = "{\"code\":\"UNAUTHENTICATED_REQUEST\",\"message\":\"로그인이 필요합니다.\"}"))) + }) + @GetMapping + public ResponseEntity> findAll( + @AuthMember LoginMember loginMember, + @RequestParam(required = false) + @Pattern(regexp = "\\d{4}-\\d{2}-\\d{2}", message = "date: YYYY-MM-DD 형식이어야 합니다.") + String date, + @RequestParam(required = false) + @Pattern(regexp = "[a-zA-Z0-9-]+", message = "categoryId: 영숫자와 하이픈만 허용합니다.") + String categoryId) { + return ResponseEntity.ok(todoService.findAll(loginMember.memberId(), date, categoryId)); + } + + @Operation(summary = "할 일 생성", + description = """ + 새 할 일을 생성합니다. id 미지정 시 서버가 UUID v4 생성. + + ### 동작 + 1. id 충돌 검사 → 충돌 시 409 TODO_ALREADY_EXISTS + 2. categoryIds 실존 검증 → 누락 시 404 CATEGORY_NOT_FOUND + 3. 저장 후 생성된 객체 반환 + """) + @ApiResponses({ + @ApiResponse(responseCode = "201", description = "생성 성공", + content = @Content(schema = @Schema(implementation = TodoResponse.class))), + @ApiResponse(responseCode = "400", description = "입력값 검증 실패", + content = @Content(schema = @Schema(implementation = ErrorResponse.class), + examples = @ExampleObject(value = "{\"code\":\"INVALID_INPUT_VALUE\",\"message\":\"title: 비어있을 수 없습니다.\"}"))), + @ApiResponse(responseCode = "401", description = "인증 실패", + content = @Content(schema = @Schema(implementation = ErrorResponse.class), + examples = @ExampleObject(value = "{\"code\":\"UNAUTHENTICATED_REQUEST\",\"message\":\"로그인이 필요합니다.\"}"))), + @ApiResponse(responseCode = "404", description = "카테고리 없음", + content = @Content(schema = @Schema(implementation = ErrorResponse.class), + examples = @ExampleObject(value = "{\"code\":\"CATEGORY_NOT_FOUND\",\"message\":\"해당 카테고리를 찾을 수 없습니다.\"}"))), + @ApiResponse(responseCode = "409", description = "동일 ID 중복", + content = @Content(schema = @Schema(implementation = ErrorResponse.class), + examples = @ExampleObject(value = "{\"code\":\"TODO_ALREADY_EXISTS\",\"message\":\"동일 ID의 할 일이 이미 존재합니다.\"}"))), + @ApiResponse(responseCode = "500", description = "서버 내부 오류", + content = @Content(schema = @Schema(implementation = ErrorResponse.class), + examples = @ExampleObject(value = "{\"code\":\"INTERNAL_SERVER_ERROR\",\"message\":\"서버 내부 오류가 발생했습니다.\"}"))) + }) + @PostMapping + public ResponseEntity create( + @AuthMember LoginMember loginMember, + @RequestBody @Valid TodoCreateRequest request) { + return ResponseEntity.status(HttpStatus.CREATED) + .body(todoService.create(loginMember.memberId(), request)); + } + + @Operation(summary = "할 일 부분 수정", + description = "전송하지 않은 필드는 기존 값 유지. 빈 배열은 명시적 모두 제거.") + @ApiResponses({ + @ApiResponse(responseCode = "200", description = "수정 성공", + content = @Content(schema = @Schema(implementation = TodoResponse.class))), + @ApiResponse(responseCode = "400", description = "입력값 검증 실패", + content = @Content(schema = @Schema(implementation = ErrorResponse.class), + examples = @ExampleObject(value = "{\"code\":\"INVALID_INPUT_VALUE\",\"message\":\"...\"}"))), + @ApiResponse(responseCode = "401", description = "인증 실패", + content = @Content(schema = @Schema(implementation = ErrorResponse.class), + examples = @ExampleObject(value = "{\"code\":\"UNAUTHENTICATED_REQUEST\",\"message\":\"로그인이 필요합니다.\"}"))), + @ApiResponse(responseCode = "404", description = "Todo 없음 또는 다른 사용자 소유", + content = @Content(schema = @Schema(implementation = ErrorResponse.class), + examples = @ExampleObject(value = "{\"code\":\"TODO_NOT_FOUND\",\"message\":\"해당 할 일을 찾을 수 없습니다.\"}"))), + @ApiResponse(responseCode = "500", description = "서버 내부 오류", + content = @Content(schema = @Schema(implementation = ErrorResponse.class), + examples = @ExampleObject(value = "{\"code\":\"INTERNAL_SERVER_ERROR\",\"message\":\"서버 내부 오류가 발생했습니다.\"}"))) + }) + @PatchMapping("/{todoId}") + public ResponseEntity update( + @AuthMember LoginMember loginMember, + @PathVariable + @Pattern(regexp = "[a-zA-Z0-9-]+", message = "todoId: 영숫자와 하이픈만 허용합니다.") + String todoId, + @RequestBody @Valid TodoUpdateRequest request) { + return ResponseEntity.ok(todoService.update(loginMember.memberId(), todoId, request)); + } + + @Operation(summary = "할 일 삭제", description = "본인 소유 Todo만 삭제 가능. 다른 사용자 / 없는 Todo는 404.") + @ApiResponses({ + @ApiResponse(responseCode = "204", description = "삭제 성공", content = @Content), + @ApiResponse(responseCode = "401", description = "인증 실패", + content = @Content(schema = @Schema(implementation = ErrorResponse.class), + examples = @ExampleObject(value = "{\"code\":\"UNAUTHENTICATED_REQUEST\",\"message\":\"로그인이 필요합니다.\"}"))), + @ApiResponse(responseCode = "404", description = "Todo 없음", + content = @Content(schema = @Schema(implementation = ErrorResponse.class), + examples = @ExampleObject(value = "{\"code\":\"TODO_NOT_FOUND\",\"message\":\"해당 할 일을 찾을 수 없습니다.\"}"))), + @ApiResponse(responseCode = "500", description = "서버 내부 오류", + content = @Content(schema = @Schema(implementation = ErrorResponse.class), + examples = @ExampleObject(value = "{\"code\":\"INTERNAL_SERVER_ERROR\",\"message\":\"서버 내부 오류가 발생했습니다.\"}"))) + }) + @DeleteMapping("/{todoId}") + public ResponseEntity delete( + @AuthMember LoginMember loginMember, + @PathVariable + @Pattern(regexp = "[a-zA-Z0-9-]+", message = "todoId: 영숫자와 하이픈만 허용합니다.") + String todoId) { + todoService.delete(loginMember.memberId(), todoId); + return ResponseEntity.noContent().build(); + } +} diff --git a/SS-Web/src/main/resources/application.yml b/SS-Web/src/main/resources/application.yml index 7bf001d..06b0bf1 100644 --- a/SS-Web/src/main/resources/application.yml +++ b/SS-Web/src/main/resources/application.yml @@ -12,6 +12,9 @@ spring: flyway: enabled: true baseline-on-migrate: true + # 기존 비어있지 않은 DB(Hibernate가 생성)에서 baseline 기본값(1)이 0.0.x 마이그레이션을 + # 건너뛰는 것을 방지 — 0으로 두어 0.0.31~ 모든 마이그레이션이 적용되게 한다. + baseline-version: 0 locations: classpath:db/migration validate-on-migrate: false @@ -36,3 +39,7 @@ management: web: exposure: include: health + +# Firebase Admin SDK +firebase: + admin-sdk-path: classpath:firebase/spacestudyship-firebase-adminsdk-fbsvc-7e86c5c253.json diff --git a/SS-Web/src/main/resources/db/migration/V0_0_31__add_user_devices.sql b/SS-Web/src/main/resources/db/migration/V0_0_31__add_user_devices.sql new file mode 100644 index 0000000..f0820d2 --- /dev/null +++ b/SS-Web/src/main/resources/db/migration/V0_0_31__add_user_devices.sql @@ -0,0 +1,29 @@ +-- members baseline: ddl-auto=update로 이미 생성되어 있을 수 있어 IF NOT EXISTS 사용 +CREATE TABLE IF NOT EXISTS members ( + id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, + social_id VARCHAR(100) NOT NULL, + social_type VARCHAR(10) NOT NULL, + nickname VARCHAR(30) NOT NULL, + created_at TIMESTAMP NOT NULL, + updated_at TIMESTAMP NOT NULL, + CONSTRAINT uk_members_social_id_type UNIQUE (social_id, social_type), + CONSTRAINT uk_members_nickname UNIQUE (nickname) +); + +-- user_devices: 디바이스별 인증 세션 + FCM 토큰 +CREATE TABLE IF NOT EXISTS user_devices ( + id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, + member_id BIGINT NOT NULL, + device_id VARCHAR(255) NOT NULL, + device_type VARCHAR(10) NOT NULL, + fcm_token VARCHAR(255) NOT NULL, + refresh_token VARCHAR(512) NOT NULL, + last_login_at TIMESTAMP NOT NULL, + created_at TIMESTAMP NOT NULL, + updated_at TIMESTAMP NOT NULL, + CONSTRAINT uk_user_devices_member_device UNIQUE (member_id, device_id), + CONSTRAINT fk_user_devices_member FOREIGN KEY (member_id) + REFERENCES members(id) ON DELETE CASCADE +); + +CREATE INDEX IF NOT EXISTS idx_user_devices_member ON user_devices(member_id); diff --git a/SS-Web/src/main/resources/db/migration/V0_0_34__add_todos_and_categories.sql b/SS-Web/src/main/resources/db/migration/V0_0_34__add_todos_and_categories.sql new file mode 100644 index 0000000..a0cacf9 --- /dev/null +++ b/SS-Web/src/main/resources/db/migration/V0_0_34__add_todos_and_categories.sql @@ -0,0 +1,33 @@ +-- todo_categories: 카테고리 (할 일보다 먼저 생성) +CREATE TABLE IF NOT EXISTS todo_categories ( + id VARCHAR(36) PRIMARY KEY, + user_id BIGINT NOT NULL, + name VARCHAR(20) NOT NULL, + icon_id VARCHAR(50), + position_x DOUBLE PRECISION, + position_y DOUBLE PRECISION, + created_at TIMESTAMP NOT NULL, + updated_at TIMESTAMP NOT NULL, + CONSTRAINT fk_todo_categories_member FOREIGN KEY (user_id) + REFERENCES members(id) ON DELETE CASCADE +); + +CREATE INDEX IF NOT EXISTS idx_todo_categories_user ON todo_categories(user_id); + +-- todos: 할 일 +CREATE TABLE IF NOT EXISTS todos ( + id VARCHAR(36) PRIMARY KEY, + user_id BIGINT NOT NULL, + title VARCHAR(100) NOT NULL, + scheduled_dates JSONB NOT NULL DEFAULT '[]'::jsonb, + completed_dates JSONB NOT NULL DEFAULT '[]'::jsonb, + category_ids JSONB NOT NULL DEFAULT '[]'::jsonb, + estimated_minutes INTEGER, + actual_minutes INTEGER, + created_at TIMESTAMP NOT NULL, + updated_at TIMESTAMP NOT NULL, + CONSTRAINT fk_todos_member FOREIGN KEY (user_id) + REFERENCES members(id) ON DELETE CASCADE +); + +CREATE INDEX IF NOT EXISTS idx_todos_user ON todos(user_id); diff --git a/SS-Web/src/main/resources/db/migration/V0_0_36__add_fuel.sql b/SS-Web/src/main/resources/db/migration/V0_0_36__add_fuel.sql new file mode 100644 index 0000000..635b142 --- /dev/null +++ b/SS-Web/src/main/resources/db/migration/V0_0_36__add_fuel.sql @@ -0,0 +1,37 @@ +-- user_fuel: 유저당 1개 연료 잔량 레코드 +CREATE TABLE IF NOT EXISTS user_fuel ( + user_id BIGINT PRIMARY KEY, + current_fuel INTEGER NOT NULL DEFAULT 0, + total_charged INTEGER NOT NULL DEFAULT 0, + total_consumed INTEGER NOT NULL DEFAULT 0, + pending_minutes INTEGER NOT NULL DEFAULT 0, + created_at TIMESTAMP NOT NULL, + updated_at TIMESTAMP NOT NULL, + CONSTRAINT fk_user_fuel_member FOREIGN KEY (user_id) + REFERENCES members(id) ON DELETE CASCADE, + CONSTRAINT chk_fuel_non_negative CHECK (current_fuel >= 0), + CONSTRAINT chk_total_charged_non_negative CHECK (total_charged >= 0), + CONSTRAINT chk_total_consumed_non_negative CHECK (total_consumed >= 0), + CONSTRAINT chk_pending_minutes_non_negative CHECK (pending_minutes >= 0) +); + +-- fuel_transactions: 충전/소비 거래 내역 +CREATE TABLE IF NOT EXISTS fuel_transactions ( + id VARCHAR(36) PRIMARY KEY, + user_id BIGINT NOT NULL, + type VARCHAR(10) NOT NULL, + amount INTEGER NOT NULL, + reason VARCHAR(30) NOT NULL, + reference_id VARCHAR(50), + balance_after INTEGER NOT NULL, + created_at TIMESTAMP NOT NULL, + updated_at TIMESTAMP NOT NULL, + CONSTRAINT fk_fuel_transactions_member FOREIGN KEY (user_id) + REFERENCES members(id) ON DELETE CASCADE, + CONSTRAINT chk_fuel_tx_amount_positive CHECK (amount > 0), + CONSTRAINT chk_fuel_tx_type CHECK (type IN ('CHARGE','CONSUME')), + CONSTRAINT chk_fuel_tx_reason CHECK (reason IN ('STUDY_SESSION','EXPLORATION_UNLOCK')) +); + +CREATE INDEX IF NOT EXISTS idx_fuel_transactions_user_created + ON fuel_transactions (user_id, created_at DESC); diff --git a/SS-Web/src/main/resources/db/migration/V0_0_39__add_timer_sessions.sql b/SS-Web/src/main/resources/db/migration/V0_0_39__add_timer_sessions.sql new file mode 100644 index 0000000..99ba56f --- /dev/null +++ b/SS-Web/src/main/resources/db/migration/V0_0_39__add_timer_sessions.sql @@ -0,0 +1,29 @@ +-- timer_sessions: 공부 타이머 세션 기록 +CREATE TABLE IF NOT EXISTS timer_sessions ( + id VARCHAR(36) PRIMARY KEY, + user_id BIGINT NOT NULL, + todo_id VARCHAR(36), + todo_title VARCHAR(100), + started_at TIMESTAMP NOT NULL, + ended_at TIMESTAMP NOT NULL, + duration_minutes INTEGER NOT NULL, + idempotency_key VARCHAR(80), + created_at TIMESTAMP NOT NULL, + updated_at TIMESTAMP NOT NULL, + CONSTRAINT fk_timer_sessions_member FOREIGN KEY (user_id) + REFERENCES members(id) ON DELETE CASCADE, + CONSTRAINT chk_timer_duration_positive CHECK (duration_minutes > 0), + CONSTRAINT chk_timer_duration_max CHECK (duration_minutes <= 1440), + CONSTRAINT chk_timer_time_order CHECK (ended_at > started_at) +); + +CREATE INDEX IF NOT EXISTS idx_timer_sessions_user_started + ON timer_sessions (user_id, started_at DESC); + +CREATE INDEX IF NOT EXISTS idx_timer_sessions_user_todo + ON timer_sessions (user_id, todo_id); + +-- Idempotency: 동일 (user, key) 중복 INSERT 방지. key=NULL은 다중 허용 (부분 unique) +CREATE UNIQUE INDEX IF NOT EXISTS uq_timer_sessions_user_idem + ON timer_sessions (user_id, idempotency_key) + WHERE idempotency_key IS NOT NULL; diff --git a/SS-Web/src/main/resources/db/migration/V0_0_42__add_exploration.sql b/SS-Web/src/main/resources/db/migration/V0_0_42__add_exploration.sql new file mode 100644 index 0000000..6d4ba03 --- /dev/null +++ b/SS-Web/src/main/resources/db/migration/V0_0_42__add_exploration.sql @@ -0,0 +1,82 @@ +-- exploration_nodes: 행성/지역 마스터 (시드, 읽기 전용) +CREATE TABLE IF NOT EXISTS exploration_nodes ( + id VARCHAR(50) PRIMARY KEY, + name VARCHAR(50) NOT NULL, + node_type VARCHAR(10) NOT NULL, + depth INTEGER NOT NULL, + icon VARCHAR(30) NOT NULL, + parent_id VARCHAR(50), + prerequisite_node_id VARCHAR(50), + required_fuel INTEGER NOT NULL DEFAULT 0, + sort_order INTEGER NOT NULL DEFAULT 0, + description VARCHAR(200) NOT NULL DEFAULT '', + map_x DOUBLE PRECISION NOT NULL DEFAULT 0, + map_y DOUBLE PRECISION NOT NULL DEFAULT 0, + CONSTRAINT fk_expl_node_parent FOREIGN KEY (parent_id) REFERENCES exploration_nodes(id), + CONSTRAINT fk_expl_node_prerequisite FOREIGN KEY (prerequisite_node_id) REFERENCES exploration_nodes(id), + CONSTRAINT chk_expl_node_type CHECK (node_type IN ('planet','region')), + CONSTRAINT chk_expl_required_fuel_non_negative CHECK (required_fuel >= 0) +); + +-- user_exploration_progress: 유저별 해금 상태 (행 존재 = 해금) +CREATE TABLE IF NOT EXISTS user_exploration_progress ( + id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, + user_id BIGINT NOT NULL, + node_id VARCHAR(50) NOT NULL, + is_unlocked BOOLEAN NOT NULL DEFAULT TRUE, + is_cleared BOOLEAN NOT NULL DEFAULT FALSE, + unlocked_at TIMESTAMP NOT NULL, + created_at TIMESTAMP NOT NULL, + updated_at TIMESTAMP NOT NULL, + CONSTRAINT fk_user_expl_member FOREIGN KEY (user_id) REFERENCES members(id) ON DELETE CASCADE, + CONSTRAINT fk_user_expl_node FOREIGN KEY (node_id) REFERENCES exploration_nodes(id), + CONSTRAINT uq_user_expl UNIQUE (user_id, node_id) +); + +CREATE INDEX IF NOT EXISTS idx_user_expl_user ON user_exploration_progress (user_id); + +-- 시드: 행성 8 (행성 먼저) +INSERT INTO exploration_nodes (id, name, node_type, depth, icon, parent_id, prerequisite_node_id, required_fuel, sort_order, description, map_x, map_y) VALUES + ('earth', '지구', 'planet', 2, 'earth', NULL, NULL, 0, 0, '우리의 출발지, 고향 행성', 0.5, 0.08), + ('mercury', '수성', 'planet', 2, 'mercury', NULL, 'earth', 3, 1, '태양에 가장 가까운 작은 행성', 0.15, 0.20), + ('venus', '금성', 'planet', 2, 'venus', NULL, 'mercury', 5, 2, '두꺼운 대기로 뒤덮인 뜨거운 행성', 0.75, 0.32), + ('mars', '화성', 'planet', 2, 'mars', NULL, 'venus', 10, 3, '붉은 행성, 탐험의 꿈', 0.25, 0.44), + ('jupiter', '목성', 'planet', 2, 'jupiter', NULL, 'mars', 20, 4, '태양계 최대의 가스 행성', 0.7, 0.56), + ('saturn', '토성', 'planet', 2, 'saturn', NULL, 'jupiter', 30, 5, '아름다운 고리를 가진 행성', 0.2, 0.68), + ('uranus', '천왕성', 'planet', 2, 'uranus', NULL, 'saturn', 45, 6, '옆으로 누워 자전하는 얼음 행성', 0.8, 0.80), + ('neptune', '해왕성', 'planet', 2, 'neptune', NULL, 'uranus', 60, 7, '태양계 끝자락의 푸른 행성', 0.35, 0.92) +ON CONFLICT (id) DO NOTHING; + +-- 시드: 지역 30 (지구 12 + 그 외 18). region은 prerequisite NULL, map 0/0. +INSERT INTO exploration_nodes (id, name, node_type, depth, icon, parent_id, prerequisite_node_id, required_fuel, sort_order, description, map_x, map_y) VALUES + ('korea', '대한민국', 'region', 3, 'KR', 'earth', NULL, 0, 0, '한반도 남쪽, K-컬쳐의 중심', 0, 0), + ('japan', '일본', 'region', 3, 'JP', 'earth', NULL, 1, 1, '벚꽃과 기술의 나라', 0, 0), + ('thailand', '태국', 'region', 3, 'TH', 'earth', NULL, 1, 2, '미소의 나라, 동남아의 허브', 0, 0), + ('china', '중국', 'region', 3, 'CN', 'earth', NULL, 2, 3, '세계 최대 인구 대국', 0, 0), + ('india', '인도', 'region', 3, 'IN', 'earth', NULL, 2, 4, 'IT 강국, 다양한 문화의 보고', 0, 0), + ('uk', '영국', 'region', 3, 'GB', 'earth', NULL, 2, 5, '해가 지지 않는 나라', 0, 0), + ('france', '프랑스', 'region', 3, 'FR', 'earth', NULL, 2, 6, '예술과 낭만의 나라', 0, 0), + ('canada', '캐나다', 'region', 3, 'CA', 'earth', NULL, 2, 7, '단풍과 자연의 나라', 0, 0), + ('usa', '미국', 'region', 3, 'US', 'earth', NULL, 3, 8, '자유의 나라, 기회의 땅', 0, 0), + ('brazil', '브라질', 'region', 3, 'BR', 'earth', NULL, 3, 9, '삼바와 축구의 나라', 0, 0), + ('australia', '호주', 'region', 3, 'AU', 'earth', NULL, 3, 10, '코알라와 캥거루의 대륙', 0, 0), + ('egypt', '이집트', 'region', 3, 'EG', 'earth', NULL, 2, 11, '피라미드와 나일강의 나라', 0, 0), + ('mercury_caloris', '칼로리스 분지', 'region', 3, 'mercury', 'mercury', NULL, 1, 0, '수성 최대의 충돌 분지', 0, 0), + ('mercury_plains', '북극 평원', 'region', 3, 'mercury', 'mercury', NULL, 2, 1, '얼음이 숨겨진 영구 그림자 지대', 0, 0), + ('venus_ishtar', '이슈타르 대지', 'region', 3, 'venus', 'venus', NULL, 2, 0, '금성 북반구의 거대한 고원 지대', 0, 0), + ('venus_aphrodite', '아프로디테 대지','region', 3, 'venus', 'venus', NULL, 3, 1, '금성 적도를 따라 펼쳐진 최대 대지', 0, 0), + ('venus_maxwell', '맥스웰 산', 'region', 3, 'venus', 'venus', NULL, 3, 2, '금성에서 가장 높은 산맥', 0, 0), + ('mars_olympus', '올림푸스 산', 'region', 3, 'mars', 'mars', NULL, 3, 0, '태양계에서 가장 높은 화산', 0, 0), + ('mars_valles', '마리너 계곡', 'region', 3, 'mars', 'mars', NULL, 4, 1, '태양계 최대의 협곡', 0, 0), + ('mars_polar', '극관 지대', 'region', 3, 'mars', 'mars', NULL, 5, 2, '드라이아이스와 물 얼음의 극지방', 0, 0), + ('jupiter_red_spot', '대적점', 'region', 3, 'jupiter', 'jupiter', NULL, 5, 0, '수백 년간 지속되는 거대 폭풍', 0, 0), + ('jupiter_europa', '유로파', 'region', 3, 'jupiter', 'jupiter', NULL, 7, 1, '얼음 아래 바다가 있는 위성', 0, 0), + ('jupiter_io', '이오', 'region', 3, 'jupiter', 'jupiter', NULL, 8, 2, '화산 활동이 가장 활발한 위성', 0, 0), + ('saturn_rings', '토성 고리', 'region', 3, 'saturn', 'saturn', NULL, 8, 0, '얼음과 먼지로 이루어진 아름다운 고리', 0, 0), + ('saturn_titan', '타이탄', 'region', 3, 'saturn', 'saturn', NULL, 10, 1, '대기를 가진 유일한 위성, 메탄의 호수', 0, 0), + ('saturn_enceladus', '엔셀라두스', 'region', 3, 'saturn', 'saturn', NULL, 12, 2, '간헐천이 분출하는 얼음 위성', 0, 0), + ('uranus_miranda', '미란다', 'region', 3, 'uranus', 'uranus', NULL, 12, 0, '기괴한 지형의 작은 위성', 0, 0), + ('uranus_atmosphere', '천왕성 대기', 'region', 3, 'uranus', 'uranus', NULL, 15, 1, '메탄이 만드는 청록빛 대기', 0, 0), + ('neptune_dark_spot', '대흑점', 'region', 3, 'neptune', 'neptune', NULL, 15, 0, '초속 2000km 폭풍의 소용돌이', 0, 0), + ('neptune_triton', '트리톤', 'region', 3, 'neptune', 'neptune', NULL, 20, 1, '역행 궤도를 도는 거대 위성', 0, 0) +ON CONFLICT (id) DO NOTHING; diff --git a/SS-Web/src/test/java/com/elipair/spacestudyship/controller/auth/AuthControllerTest.java b/SS-Web/src/test/java/com/elipair/spacestudyship/controller/auth/AuthControllerTest.java new file mode 100644 index 0000000..b7acf1a --- /dev/null +++ b/SS-Web/src/test/java/com/elipair/spacestudyship/controller/auth/AuthControllerTest.java @@ -0,0 +1,197 @@ +package com.elipair.spacestudyship.controller.auth; + +import com.elipair.spacestudyship.auth.dto.CheckNicknameResponse; +import com.elipair.spacestudyship.auth.dto.UpdateNicknameRequest; +import com.elipair.spacestudyship.auth.dto.UpdateNicknameResponse; +import com.elipair.spacestudyship.auth.interceptor.LoginMember; +import com.elipair.spacestudyship.auth.interceptor.LoginMemberArgumentResolver; +import com.elipair.spacestudyship.auth.service.AuthService; +import com.elipair.spacestudyship.common.exception.CustomException; +import com.elipair.spacestudyship.common.exception.ErrorCode; +import com.elipair.spacestudyship.common.exception.GlobalExceptionHandler; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.BeforeEach; +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 org.springframework.http.MediaType; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.setup.MockMvcBuilders; + +import static org.mockito.BDDMockito.given; +import static org.mockito.BDDMockito.willDoNothing; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.patch; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +@ExtendWith(MockitoExtension.class) +class AuthControllerTest { + + @Mock + AuthService authService; + + MockMvc mockMvc; + + ObjectMapper objectMapper = new ObjectMapper(); + + @BeforeEach + void setUp() { + mockMvc = MockMvcBuilders.standaloneSetup(new AuthController(authService)) + .setCustomArgumentResolvers(new LoginMemberArgumentResolver()) + .setControllerAdvice(new GlobalExceptionHandler()) + .build(); + } + + // ========== GET /api/auth/check-nickname ========== + + @Test + @DisplayName("checkNickname: 정상 요청이면 200과 available 반환") + void checkNickname_success() throws Exception { + // given + given(authService.checkNickname("우주탐험가")) + .willReturn(new CheckNicknameResponse(true)); + + // when / then + mockMvc.perform(get("/api/auth/check-nickname") + .param("nickname", "우주탐험가") + .requestAttr("loginMember", new LoginMember(1L))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.available").value(true)); + } + + @Test + @DisplayName("checkNickname: nickname 파라미터가 없으면 400") + void checkNickname_missingParam() throws Exception { + mockMvc.perform(get("/api/auth/check-nickname") + .requestAttr("loginMember", new LoginMember(1L))) + .andExpect(status().isBadRequest()); + } + + @Test + @DisplayName("checkNickname: 1자(길이 미달)이면 400") + void checkNickname_tooShort() throws Exception { + mockMvc.perform(get("/api/auth/check-nickname") + .param("nickname", "가") + .requestAttr("loginMember", new LoginMember(1L))) + .andExpect(status().isBadRequest()); + } + + @Test + @DisplayName("checkNickname: 11자(길이 초과)이면 400") + void checkNickname_tooLong() throws Exception { + mockMvc.perform(get("/api/auth/check-nickname") + .param("nickname", "일이삼사오육칠팔구십일") + .requestAttr("loginMember", new LoginMember(1L))) + .andExpect(status().isBadRequest()); + } + + @Test + @DisplayName("checkNickname: 특수문자가 포함되면 400") + void checkNickname_invalidCharacter() throws Exception { + mockMvc.perform(get("/api/auth/check-nickname") + .param("nickname", "우주!탐험") + .requestAttr("loginMember", new LoginMember(1L))) + .andExpect(status().isBadRequest()); + } + + @Test + @DisplayName("checkNickname: 인증 정보가 없으면 401") + void checkNickname_unauthenticated() throws Exception { + mockMvc.perform(get("/api/auth/check-nickname") + .param("nickname", "우주탐험가")) + .andExpect(status().isUnauthorized()); + } + + // ========== PATCH /api/auth/nickname ========== + + @Test + @DisplayName("updateNickname: 정상 요청이면 200과 바뀐 nickname 반환") + void updateNickname_success() throws Exception { + // given + UpdateNicknameRequest body = new UpdateNicknameRequest("우주탐험가"); + given(authService.updateNickname(1L, body)) + .willReturn(new UpdateNicknameResponse("우주탐험가")); + + // when / then + mockMvc.perform(patch("/api/auth/nickname") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(body)) + .requestAttr("loginMember", new LoginMember(1L))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.nickname").value("우주탐험가")); + } + + @Test + @DisplayName("updateNickname: 1자 닉네임이면 400") + void updateNickname_tooShort() throws Exception { + UpdateNicknameRequest body = new UpdateNicknameRequest("가"); + + mockMvc.perform(patch("/api/auth/nickname") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(body)) + .requestAttr("loginMember", new LoginMember(1L))) + .andExpect(status().isBadRequest()); + } + + @Test + @DisplayName("updateNickname: 특수문자 포함이면 400") + void updateNickname_invalidCharacter() throws Exception { + UpdateNicknameRequest body = new UpdateNicknameRequest("우주!탐험"); + + mockMvc.perform(patch("/api/auth/nickname") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(body)) + .requestAttr("loginMember", new LoginMember(1L))) + .andExpect(status().isBadRequest()); + } + + @Test + @DisplayName("updateNickname: 중복 닉네임이면 409") + void updateNickname_duplicated() throws Exception { + UpdateNicknameRequest body = new UpdateNicknameRequest("우주탐험가"); + given(authService.updateNickname(1L, body)) + .willThrow(new CustomException(ErrorCode.DUPLICATED_NICKNAME)); + + mockMvc.perform(patch("/api/auth/nickname") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(body)) + .requestAttr("loginMember", new LoginMember(1L))) + .andExpect(status().isConflict()); + } + + @Test + @DisplayName("updateNickname: 인증 정보가 없으면 401") + void updateNickname_unauthenticated() throws Exception { + UpdateNicknameRequest body = new UpdateNicknameRequest("우주탐험가"); + + mockMvc.perform(patch("/api/auth/nickname") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(body))) + .andExpect(status().isUnauthorized()); + } + + // ========== DELETE /api/auth/withdraw ========== + + @Test + @DisplayName("withdraw: 정상 요청이면 204 응답하고 AuthService.withdraw 호출") + void withdraw_success() throws Exception { + // given + willDoNothing().given(authService).withdraw(1L); + + // when / then + mockMvc.perform(delete("/api/auth/withdraw") + .requestAttr("loginMember", new LoginMember(1L))) + .andExpect(status().isNoContent()); + } + + @Test + @DisplayName("withdraw: 인증 정보가 없으면 401") + void withdraw_unauthenticated() throws Exception { + mockMvc.perform(delete("/api/auth/withdraw")) + .andExpect(status().isUnauthorized()); + } +} diff --git a/SS-Web/src/test/java/com/elipair/spacestudyship/controller/exploration/ExplorationControllerTest.java b/SS-Web/src/test/java/com/elipair/spacestudyship/controller/exploration/ExplorationControllerTest.java new file mode 100644 index 0000000..11a0cf7 --- /dev/null +++ b/SS-Web/src/test/java/com/elipair/spacestudyship/controller/exploration/ExplorationControllerTest.java @@ -0,0 +1,170 @@ +package com.elipair.spacestudyship.controller.exploration; + +import com.elipair.spacestudyship.auth.interceptor.LoginMember; +import com.elipair.spacestudyship.common.exception.CustomException; +import com.elipair.spacestudyship.common.exception.ErrorCode; +import com.elipair.spacestudyship.common.exception.GlobalExceptionHandler; +import com.elipair.spacestudyship.common.exception.InsufficientFuelException; +import com.elipair.spacestudyship.study.exploration.constant.NodeType; +import com.elipair.spacestudyship.study.exploration.dto.PlanetResponse; +import com.elipair.spacestudyship.study.exploration.dto.PlanetUnlockResponse; +import com.elipair.spacestudyship.study.exploration.dto.RegionResponse; +import com.elipair.spacestudyship.study.exploration.dto.RegionUnlockResponse; +import com.elipair.spacestudyship.study.exploration.dto.UnlockedNodeDto; +import com.elipair.spacestudyship.study.exploration.entity.ExplorationNode; +import com.elipair.spacestudyship.study.exploration.service.ExplorationService; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.core.MethodParameter; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.setup.MockMvcBuilders; +import org.springframework.web.method.support.HandlerMethodArgumentResolver; +import org.springframework.web.method.support.ModelAndViewContainer; + +import java.util.List; + +import static org.mockito.BDDMockito.given; +import static org.mockito.BDDMockito.willThrow; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +@ExtendWith(MockitoExtension.class) +class ExplorationControllerTest { + + @Mock ExplorationService explorationService; + @InjectMocks ExplorationController controller; + + MockMvc mockMvc; + + private ExplorationNode planetNode() { + return ExplorationNode.builder().id("earth").name("지구").nodeType(NodeType.PLANET) + .depth(2).icon("earth").requiredFuel(0).sortOrder(0) + .description("시작점").mapX(0.5).mapY(0.08).build(); + } + + private ExplorationNode regionNode() { + return ExplorationNode.builder().id("korea").name("대한민국").nodeType(NodeType.REGION) + .depth(3).icon("KR").parentId("earth").requiredFuel(0).sortOrder(0) + .description("한반도").mapX(0).mapY(0).build(); + } + + @BeforeEach + void setUp() { + HandlerMethodArgumentResolver loginMemberStub = new HandlerMethodArgumentResolver() { + @Override + public boolean supportsParameter(MethodParameter parameter) { + return parameter.getParameterType().equals(LoginMember.class); + } + @Override + public Object resolveArgument(MethodParameter parameter, ModelAndViewContainer mavContainer, + org.springframework.web.context.request.NativeWebRequest webRequest, + org.springframework.web.bind.support.WebDataBinderFactory binderFactory) { + return new LoginMember(1L); + } + }; + mockMvc = MockMvcBuilders.standaloneSetup(controller) + .setControllerAdvice(new GlobalExceptionHandler()) + .setCustomArgumentResolvers(loginMemberStub) + .build(); + } + + @Test + @DisplayName("GET /api/explorations/planets — 200, nodeType 소문자") + void getPlanets_200() throws Exception { + given(explorationService.getPlanets(1L)).willReturn(List.of( + PlanetResponse.of(planetNode(), true, false, 1, 2, 0.5, null))); + + mockMvc.perform(get("/api/explorations/planets")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$[0].id").value("earth")) + .andExpect(jsonPath("$[0].nodeType").value("planet")) + .andExpect(jsonPath("$[0].isUnlocked").value(true)) + .andExpect(jsonPath("$[0].progress.totalChildren").value(2)); + } + + @Test + @DisplayName("GET /api/explorations/planets/{id}/regions — 200") + void getRegions_200() throws Exception { + given(explorationService.getRegions(1L, "earth")).willReturn(List.of( + RegionResponse.of(regionNode(), true, true, null))); + + mockMvc.perform(get("/api/explorations/planets/earth/regions")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$[0].id").value("korea")) + .andExpect(jsonPath("$[0].nodeType").value("region")); + } + + @Test + @DisplayName("GET regions — 행성 없음 404 PLANET_NOT_FOUND") + void getRegions_404() throws Exception { + given(explorationService.getRegions(1L, "nope")) + .willThrow(new CustomException(ErrorCode.PLANET_NOT_FOUND)); + + mockMvc.perform(get("/api/explorations/planets/nope/regions")) + .andExpect(status().isNotFound()) + .andExpect(jsonPath("$.code").value("PLANET_NOT_FOUND")) + .andExpect(jsonPath("$.requiredFuel").doesNotExist()); + } + + @Test + @DisplayName("POST /api/explorations/regions/{id}/unlock — 200") + void unlockRegion_200() throws Exception { + given(explorationService.unlockRegion(1L, "japan")).willReturn( + new RegionUnlockResponse( + new UnlockedNodeDto("japan", "일본", true, true, "2026-04-16T11:00:00Z"), + 1, 249, false)); + + mockMvc.perform(post("/api/explorations/regions/japan/unlock")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.region.id").value("japan")) + .andExpect(jsonPath("$.fuelConsumed").value(1)) + .andExpect(jsonPath("$.currentFuel").value(249)) + .andExpect(jsonPath("$.planetCleared").value(false)); + } + + @Test + @DisplayName("POST region unlock — 연료 부족 400 + requiredFuel/currentFuel 본문") + void unlockRegion_insufficientFuel_400() throws Exception { + willThrow(new InsufficientFuelException(3, 1)) + .given(explorationService).unlockRegion(1L, "usa"); + + mockMvc.perform(post("/api/explorations/regions/usa/unlock")) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.code").value("INSUFFICIENT_FUEL")) + .andExpect(jsonPath("$.requiredFuel").value(3)) + .andExpect(jsonPath("$.currentFuel").value(1)); + } + + @Test + @DisplayName("POST /api/explorations/planets/{id}/unlock — 200") + void unlockPlanet_200() throws Exception { + given(explorationService.unlockPlanet(1L, "mercury")).willReturn( + new PlanetUnlockResponse( + new UnlockedNodeDto("mercury", "수성", true, false, "2026-04-16T11:30:00Z"), + 3, 97)); + + mockMvc.perform(post("/api/explorations/planets/mercury/unlock")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.planet.id").value("mercury")) + .andExpect(jsonPath("$.fuelConsumed").value(3)) + .andExpect(jsonPath("$.currentFuel").value(97)); + } + + @Test + @DisplayName("POST planet unlock — 선행 미클리어 400 PREREQUISITE_NOT_CLEARED") + void unlockPlanet_prerequisite_400() throws Exception { + willThrow(new CustomException(ErrorCode.PREREQUISITE_NOT_CLEARED)) + .given(explorationService).unlockPlanet(1L, "mercury"); + + mockMvc.perform(post("/api/explorations/planets/mercury/unlock")) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.code").value("PREREQUISITE_NOT_CLEARED")); + } +} diff --git a/SS-Web/src/test/java/com/elipair/spacestudyship/controller/fuel/FuelControllerTest.java b/SS-Web/src/test/java/com/elipair/spacestudyship/controller/fuel/FuelControllerTest.java new file mode 100644 index 0000000..f869a2b --- /dev/null +++ b/SS-Web/src/test/java/com/elipair/spacestudyship/controller/fuel/FuelControllerTest.java @@ -0,0 +1,142 @@ +package com.elipair.spacestudyship.controller.fuel; + +import com.elipair.spacestudyship.auth.interceptor.LoginMember; +import com.elipair.spacestudyship.common.exception.GlobalExceptionHandler; +import com.elipair.spacestudyship.study.fuel.constant.TransactionType; +import com.elipair.spacestudyship.study.fuel.dto.FuelResponse; +import com.elipair.spacestudyship.study.fuel.dto.FuelTransactionListResponse; +import com.elipair.spacestudyship.study.fuel.dto.FuelTransactionResponse; +import com.elipair.spacestudyship.study.fuel.service.FuelService; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.core.MethodParameter; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.setup.MockMvcBuilders; +import org.springframework.validation.beanvalidation.LocalValidatorFactoryBean; +import org.springframework.web.method.support.HandlerMethodArgumentResolver; +import org.springframework.web.method.support.ModelAndViewContainer; + +import java.util.List; + +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.BDDMockito.given; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +@ExtendWith(MockitoExtension.class) +class FuelControllerTest { + + @Mock FuelService fuelService; + @InjectMocks FuelController fuelController; + + MockMvc mockMvc; + + @BeforeEach + void setUp() { + HandlerMethodArgumentResolver loginMemberStub = new HandlerMethodArgumentResolver() { + @Override + public boolean supportsParameter(MethodParameter parameter) { + return parameter.getParameterType().equals(LoginMember.class); + } + @Override + public Object resolveArgument(MethodParameter parameter, ModelAndViewContainer mavContainer, + org.springframework.web.context.request.NativeWebRequest webRequest, + org.springframework.web.bind.support.WebDataBinderFactory binderFactory) { + return new LoginMember(1L); + } + }; + + LocalValidatorFactoryBean validator = new LocalValidatorFactoryBean(); + validator.afterPropertiesSet(); + + mockMvc = MockMvcBuilders.standaloneSetup(fuelController) + .setControllerAdvice(new GlobalExceptionHandler()) + .setCustomArgumentResolvers(loginMemberStub) + .setValidator(validator) + .build(); + } + + @Test + @DisplayName("GET /api/fuel — 200, FuelResponse 본문") + void getFuel_200() throws Exception { + given(fuelService.getFuel(1L)) + .willReturn(new FuelResponse(350, 1200, 850, 0, "2026-04-16T10:30:00Z")); + + mockMvc.perform(get("/api/fuel")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.currentFuel").value(350)) + .andExpect(jsonPath("$.totalCharged").value(1200)) + .andExpect(jsonPath("$.totalConsumed").value(850)) + .andExpect(jsonPath("$.pendingMinutes").value(0)) + .andExpect(jsonPath("$.lastUpdatedAt").value("2026-04-16T10:30:00Z")); + } + + @Test + @DisplayName("GET /api/fuel/transactions — 200, Page envelope") + void getTransactions_200() throws Exception { + given(fuelService.getTransactions(eq(1L), eq(null), eq(null), eq(null), eq(0), eq(20))) + .willReturn(new FuelTransactionListResponse( + List.of(new FuelTransactionResponse( + "tx-1", "charge", 90, "STUDY_SESSION", "s-1", 350, "2026-04-16T10:30:00Z")), + 0, 20, 1L, 1)); + + mockMvc.perform(get("/api/fuel/transactions")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.content[0].id").value("tx-1")) + .andExpect(jsonPath("$.content[0].type").value("charge")) + .andExpect(jsonPath("$.content[0].reason").value("STUDY_SESSION")) + .andExpect(jsonPath("$.totalElements").value(1)); + } + + @Test + @DisplayName("GET /api/fuel/transactions?type=invalid → 400 INVALID_INPUT_VALUE") + void getTransactions_invalidType_400() throws Exception { + mockMvc.perform(get("/api/fuel/transactions?type=invalid")) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.code").value("INVALID_INPUT_VALUE")); + } + + @Test + @DisplayName("GET /api/fuel/transactions?startDate=2026-13-01 → 400 (Pattern 위반)") + void getTransactions_invalidStartDate_400() throws Exception { + mockMvc.perform(get("/api/fuel/transactions?startDate=2026-13-01")) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.code").value("INVALID_INPUT_VALUE")); + } + + @Test + @DisplayName("GET /api/fuel/transactions?size=200 → 400 (Max 100)") + void getTransactions_sizeOverMax_400() throws Exception { + mockMvc.perform(get("/api/fuel/transactions?size=200")) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.code").value("INVALID_INPUT_VALUE")); + } + + @Test + @DisplayName("GET /api/fuel/transactions?page=-1 → 400 (Min 0)") + void getTransactions_negativePage_400() throws Exception { + mockMvc.perform(get("/api/fuel/transactions?page=-1")) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.code").value("INVALID_INPUT_VALUE")); + } + + @Test + @DisplayName("GET /api/fuel/transactions?type=charge&startDate=2026-04-01&endDate=2026-04-16 - 인자 그대로 서비스로") + void getTransactions_argsPassThrough() throws Exception { + given(fuelService.getTransactions(eq(1L), eq(TransactionType.CHARGE), + eq("2026-04-01"), eq("2026-04-16"), eq(0), eq(20))) + .willReturn(new FuelTransactionListResponse(List.of(), 0, 20, 0L, 0)); + + mockMvc.perform(get("/api/fuel/transactions") + .param("type", "charge") + .param("startDate", "2026-04-01") + .param("endDate", "2026-04-16")) + .andExpect(status().isOk()); + } +} diff --git a/SS-Web/src/test/java/com/elipair/spacestudyship/controller/timer/TimerSessionControllerTest.java b/SS-Web/src/test/java/com/elipair/spacestudyship/controller/timer/TimerSessionControllerTest.java new file mode 100644 index 0000000..aadb72d --- /dev/null +++ b/SS-Web/src/test/java/com/elipair/spacestudyship/controller/timer/TimerSessionControllerTest.java @@ -0,0 +1,265 @@ +package com.elipair.spacestudyship.controller.timer; + +import com.elipair.spacestudyship.auth.interceptor.LoginMember; +import com.elipair.spacestudyship.common.exception.CustomException; +import com.elipair.spacestudyship.common.exception.ErrorCode; +import com.elipair.spacestudyship.common.exception.GlobalExceptionHandler; +import com.elipair.spacestudyship.study.timer.dto.*; +import com.elipair.spacestudyship.study.timer.service.TimerSessionService; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.core.MethodParameter; +import org.springframework.http.MediaType; +import org.springframework.http.converter.json.MappingJackson2HttpMessageConverter; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.setup.MockMvcBuilders; +import org.springframework.validation.beanvalidation.LocalValidatorFactoryBean; +import org.springframework.web.method.support.HandlerMethodArgumentResolver; +import org.springframework.web.method.support.ModelAndViewContainer; + +import java.time.Instant; +import java.util.List; + +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.BDDMockito.given; +import static org.mockito.BDDMockito.willThrow; +import static org.mockito.Mockito.verify; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +@ExtendWith(MockitoExtension.class) +class TimerSessionControllerTest { + + @Mock TimerSessionService service; + @InjectMocks TimerSessionController controller; + + MockMvc mockMvc; + ObjectMapper objectMapper; + + @BeforeEach + void setUp() { + HandlerMethodArgumentResolver loginMemberStub = new HandlerMethodArgumentResolver() { + @Override public boolean supportsParameter(MethodParameter parameter) { + return parameter.getParameterType().equals(LoginMember.class); + } + @Override public Object resolveArgument(MethodParameter parameter, ModelAndViewContainer mavContainer, + org.springframework.web.context.request.NativeWebRequest webRequest, + org.springframework.web.bind.support.WebDataBinderFactory binderFactory) { + return new LoginMember(1L); + } + }; + + LocalValidatorFactoryBean validator = new LocalValidatorFactoryBean(); + validator.afterPropertiesSet(); + + objectMapper = new ObjectMapper(); + objectMapper.registerModule(new JavaTimeModule()); + objectMapper.disable(com.fasterxml.jackson.databind.SerializationFeature.WRITE_DATES_AS_TIMESTAMPS); + MappingJackson2HttpMessageConverter jsonConverter = new MappingJackson2HttpMessageConverter(objectMapper); + + mockMvc = MockMvcBuilders.standaloneSetup(controller) + .setControllerAdvice(new GlobalExceptionHandler()) + .setCustomArgumentResolvers(loginMemberStub) + .setValidator(validator) + .setMessageConverters(jsonConverter) + .build(); + } + + @Test + @DisplayName("POST /api/timer-sessions — 201, { session, fuelCharged } (90분 → 3연료, 30분=1연료 환산)") + void create_201() throws Exception { + TimerSessionResponse sessionRes = new TimerSessionResponse( + "sess-1", "todo-1", "수학", + Instant.parse("2026-05-25T01:00:00Z"), + Instant.parse("2026-05-25T02:30:00Z"), + 90); + given(service.create(eq(1L), any(TimerSessionCreateRequest.class), any())) + .willReturn(new TimerSessionCreateResponse(sessionRes, 3)); + + String body = """ + { + "todoId": "todo-1", + "todoTitle": "수학", + "startedAt": "2026-05-25T01:00:00Z", + "endedAt": "2026-05-25T02:30:00Z", + "durationMinutes": 90 + } + """; + + mockMvc.perform(post("/api/timer-sessions") + .contentType(MediaType.APPLICATION_JSON) + .content(body)) + .andExpect(status().isCreated()) + .andExpect(jsonPath("$.session.id").value("sess-1")) + .andExpect(jsonPath("$.session.durationMinutes").value(90)) + .andExpect(jsonPath("$.fuelCharged").value(3)); + } + + @Test + @DisplayName("POST: Idempotency-Key 헤더 → 서비스에 전달") + void create_idempotencyKeyPassThrough() throws Exception { + TimerSessionResponse sessionRes = new TimerSessionResponse( + "sess-1", null, null, + Instant.parse("2026-05-25T01:00:00Z"), + Instant.parse("2026-05-25T02:00:00Z"), + 60); + given(service.create(eq(1L), any(), eq("idem-abc"))) + .willReturn(new TimerSessionCreateResponse(sessionRes, 2)); + + String body = """ + {"startedAt":"2026-05-25T01:00:00Z","endedAt":"2026-05-25T02:00:00Z","durationMinutes":60} + """; + + mockMvc.perform(post("/api/timer-sessions") + .header("Idempotency-Key", "idem-abc") + .contentType(MediaType.APPLICATION_JSON) + .content(body)) + .andExpect(status().isCreated()); + + verify(service).create(eq(1L), any(), eq("idem-abc")); + } + + @Test + @DisplayName("POST: 비즈니스 검증 실패 (FUTURE_SESSION) → 400 + code") + void create_futureSession_400() throws Exception { + willThrow(new CustomException(ErrorCode.FUTURE_SESSION)) + .given(service).create(eq(1L), any(), any()); + + String body = """ + {"startedAt":"2030-01-01T00:00:00Z","endedAt":"2030-01-01T01:00:00Z","durationMinutes":60} + """; + + mockMvc.perform(post("/api/timer-sessions") + .contentType(MediaType.APPLICATION_JSON) + .content(body)) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.code").value("FUTURE_SESSION")); + } + + @Test + @DisplayName("POST: NotNull 위반 (durationMinutes 누락) → 400 INVALID_INPUT_VALUE") + void create_missingField_400() throws Exception { + String body = """ + {"startedAt":"2026-05-25T01:00:00Z","endedAt":"2026-05-25T02:00:00Z"} + """; + + mockMvc.perform(post("/api/timer-sessions") + .contentType(MediaType.APPLICATION_JSON) + .content(body)) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.code").value("INVALID_INPUT_VALUE")); + } + + @Test + @DisplayName("POST: 본문 파싱 실패 → 400 INVALID_REQUEST_BODY") + void create_malformedBody_400() throws Exception { + mockMvc.perform(post("/api/timer-sessions") + .contentType(MediaType.APPLICATION_JSON) + .content("{not json")) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.code").value("INVALID_REQUEST_BODY")); + } + + @Test + @DisplayName("POST: TODO_NOT_FOUND → 404") + void create_todoNotFound_404() throws Exception { + willThrow(new CustomException(ErrorCode.TODO_NOT_FOUND)) + .given(service).create(eq(1L), any(), any()); + + String body = """ + {"todoId":"nope","startedAt":"2026-05-25T01:00:00Z","endedAt":"2026-05-25T02:00:00Z","durationMinutes":60} + """; + + mockMvc.perform(post("/api/timer-sessions") + .contentType(MediaType.APPLICATION_JSON) + .content(body)) + .andExpect(status().isNotFound()) + .andExpect(jsonPath("$.code").value("TODO_NOT_FOUND")); + } + + @Test + @DisplayName("GET /api/timer-sessions — 200, Page envelope, 인자 그대로 전달") + void getList_200() throws Exception { + given(service.getList(eq(1L), eq("2026-05-20"), eq("2026-05-25"), eq("t-1"), eq(0), eq(20))) + .willReturn(new TimerSessionListResponse( + List.of(new TimerSessionResponse( + "sess-1", "t-1", "수학", + Instant.parse("2026-05-25T01:00:00Z"), + Instant.parse("2026-05-25T02:00:00Z"), + 60)), + 0, 20, 1L, 1)); + + mockMvc.perform(get("/api/timer-sessions") + .param("startDate", "2026-05-20") + .param("endDate", "2026-05-25") + .param("todoId", "t-1")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.content[0].id").value("sess-1")) + .andExpect(jsonPath("$.totalElements").value(1)); + } + + @Test + @DisplayName("GET: 잘못된 날짜 포맷 → 400") + void getList_badDate_400() throws Exception { + mockMvc.perform(get("/api/timer-sessions").param("startDate", "2026-13-01")) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.code").value("INVALID_INPUT_VALUE")); + } + + @Test + @DisplayName("GET: size > 100 → 400") + void getList_sizeOverMax_400() throws Exception { + mockMvc.perform(get("/api/timer-sessions").param("size", "200")) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.code").value("INVALID_INPUT_VALUE")); + } + + @Test + @DisplayName("GET: page < 0 → 400") + void getList_negativePage_400() throws Exception { + mockMvc.perform(get("/api/timer-sessions").param("page", "-1")) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.code").value("INVALID_INPUT_VALUE")); + } + + @Test + @DisplayName("GET /api/timer-sessions/today-stats — 200, 6필드 (today + lifetime + monthly)") + void todayStats_200() throws Exception { + given(service.getTodayStats(1L)) + .willReturn(new TodayStatsResponse(180, 3, 7, 12450, 287, 1820)); + + mockMvc.perform(get("/api/timer-sessions/today-stats")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.totalMinutes").value(180)) + .andExpect(jsonPath("$.sessionCount").value(3)) + .andExpect(jsonPath("$.streak").value(7)) + .andExpect(jsonPath("$.lifetimeMinutes").value(12450)) + .andExpect(jsonPath("$.lifetimeSessionCount").value(287)) + .andExpect(jsonPath("$.monthlyMinutes").value(1820)); + } + + @Test + @DisplayName("GET /api/timer-sessions/today-stats — 0건 회원: 신규 3필드도 0 (null 아님)") + void todayStats_zero_neverNull() throws Exception { + given(service.getTodayStats(1L)) + .willReturn(new TodayStatsResponse(0, 0, 0, 0, 0, 0)); + + mockMvc.perform(get("/api/timer-sessions/today-stats")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.lifetimeMinutes").isNumber()) + .andExpect(jsonPath("$.lifetimeMinutes").value(0)) + .andExpect(jsonPath("$.lifetimeSessionCount").isNumber()) + .andExpect(jsonPath("$.lifetimeSessionCount").value(0)) + .andExpect(jsonPath("$.monthlyMinutes").isNumber()) + .andExpect(jsonPath("$.monthlyMinutes").value(0)); + } +} diff --git a/SS-Web/src/test/java/com/elipair/spacestudyship/controller/todo/TodoCategoryControllerTest.java b/SS-Web/src/test/java/com/elipair/spacestudyship/controller/todo/TodoCategoryControllerTest.java new file mode 100644 index 0000000..ec54cdc --- /dev/null +++ b/SS-Web/src/test/java/com/elipair/spacestudyship/controller/todo/TodoCategoryControllerTest.java @@ -0,0 +1,105 @@ +package com.elipair.spacestudyship.controller.todo; + +import com.elipair.spacestudyship.auth.interceptor.LoginMember; +import com.elipair.spacestudyship.common.exception.CustomException; +import com.elipair.spacestudyship.common.exception.ErrorCode; +import com.elipair.spacestudyship.common.exception.GlobalExceptionHandler; +import com.elipair.spacestudyship.study.todo.dto.CategoryResponse; +import com.elipair.spacestudyship.study.todo.service.TodoCategoryService; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.core.MethodParameter; +import org.springframework.http.MediaType; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.setup.MockMvcBuilders; +import org.springframework.web.method.support.HandlerMethodArgumentResolver; +import org.springframework.web.method.support.ModelAndViewContainer; + +import java.util.List; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.when; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; + +@ExtendWith(MockitoExtension.class) +class TodoCategoryControllerTest { + + @Mock TodoCategoryService categoryService; + @InjectMocks TodoCategoryController categoryController; + + MockMvc mockMvc; + ObjectMapper om = new ObjectMapper(); + + @BeforeEach + void setUp() { + HandlerMethodArgumentResolver stub = new HandlerMethodArgumentResolver() { + @Override public boolean supportsParameter(MethodParameter p) { + return p.getParameterType().equals(LoginMember.class); + } + @Override public Object resolveArgument(MethodParameter p, + ModelAndViewContainer m, + org.springframework.web.context.request.NativeWebRequest w, + org.springframework.web.bind.support.WebDataBinderFactory f) { + return new LoginMember(1L); + } + }; + mockMvc = MockMvcBuilders.standaloneSetup(categoryController) + .setControllerAdvice(new GlobalExceptionHandler()) + .setCustomArgumentResolvers(stub) + .build(); + } + + @Test + @DisplayName("GET /api/todo-categories — 200") + void findAll() throws Exception { + when(categoryService.findAll(1L)).thenReturn(List.of( + new CategoryResponse("c1", "수학", "math", 0.3, 0.5, + "2026-05-23T00:00:00Z", null))); + + mockMvc.perform(get("/api/todo-categories")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$[0].id").value("c1")); + } + + @Test + @DisplayName("POST /api/todo-categories — 201") + void create() throws Exception { + when(categoryService.create(eq(1L), any())) + .thenReturn(new CategoryResponse("c1", "수학", null, null, null, + "2026-05-23T00:00:00Z", null)); + + String body = "{\"id\":\"c1\",\"name\":\"수학\"}"; + + mockMvc.perform(post("/api/todo-categories") + .contentType(MediaType.APPLICATION_JSON).content(body)) + .andExpect(status().isCreated()) + .andExpect(jsonPath("$.id").value("c1")); + } + + @Test + @DisplayName("PATCH /api/todo-categories/{id} — 404 CATEGORY_NOT_FOUND") + void update_notFound() throws Exception { + when(categoryService.update(eq(1L), eq("missing"), any())) + .thenThrow(new CustomException(ErrorCode.CATEGORY_NOT_FOUND)); + + mockMvc.perform(patch("/api/todo-categories/missing") + .contentType(MediaType.APPLICATION_JSON).content("{}")) + .andExpect(status().isNotFound()) + .andExpect(jsonPath("$.code").value("CATEGORY_NOT_FOUND")); + } + + @Test + @DisplayName("DELETE /api/todo-categories/{id} — 204") + void delete_success() throws Exception { + mockMvc.perform(delete("/api/todo-categories/c1")) + .andExpect(status().isNoContent()); + } +} diff --git a/SS-Web/src/test/java/com/elipair/spacestudyship/controller/todo/TodoControllerTest.java b/SS-Web/src/test/java/com/elipair/spacestudyship/controller/todo/TodoControllerTest.java new file mode 100644 index 0000000..3479d6b --- /dev/null +++ b/SS-Web/src/test/java/com/elipair/spacestudyship/controller/todo/TodoControllerTest.java @@ -0,0 +1,114 @@ +package com.elipair.spacestudyship.controller.todo; + +import com.elipair.spacestudyship.auth.interceptor.LoginMember; +import com.elipair.spacestudyship.common.exception.CustomException; +import com.elipair.spacestudyship.common.exception.ErrorCode; +import com.elipair.spacestudyship.common.exception.GlobalExceptionHandler; +import com.elipair.spacestudyship.study.todo.dto.TodoResponse; +import com.elipair.spacestudyship.study.todo.service.TodoService; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.core.MethodParameter; +import org.springframework.http.MediaType; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.setup.MockMvcBuilders; +import org.springframework.web.method.support.HandlerMethodArgumentResolver; +import org.springframework.web.method.support.ModelAndViewContainer; + +import java.util.List; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.when; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.patch; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +@ExtendWith(MockitoExtension.class) +class TodoControllerTest { + + @Mock TodoService todoService; + @InjectMocks TodoController todoController; + + MockMvc mockMvc; + ObjectMapper om = new ObjectMapper(); + + @BeforeEach + void setUp() { + HandlerMethodArgumentResolver loginMemberStub = new HandlerMethodArgumentResolver() { + @Override + public boolean supportsParameter(MethodParameter parameter) { + return parameter.getParameterType().equals(LoginMember.class); + } + @Override + public Object resolveArgument(MethodParameter parameter, ModelAndViewContainer mavContainer, + org.springframework.web.context.request.NativeWebRequest webRequest, + org.springframework.web.bind.support.WebDataBinderFactory binderFactory) { + return new LoginMember(1L); + } + }; + mockMvc = MockMvcBuilders.standaloneSetup(todoController) + .setControllerAdvice(new GlobalExceptionHandler()) + .setCustomArgumentResolvers(loginMemberStub) + .build(); + } + + @Test + @DisplayName("GET /api/todos — 200") + void findAll() throws Exception { + when(todoService.findAll(eq(1L), eq(null), eq(null))) + .thenReturn(List.of(new TodoResponse("t1", "수학", + List.of(), List.of(), List.of(), null, null, + "2026-05-23T00:00:00Z", "2026-05-23T00:00:00Z"))); + + mockMvc.perform(get("/api/todos")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$[0].id").value("t1")); + } + + @Test + @DisplayName("POST /api/todos — 201") + void create() throws Exception { + when(todoService.create(eq(1L), any())) + .thenReturn(new TodoResponse("t1", "수학", + List.of(), List.of(), List.of(), null, null, + "2026-05-23T00:00:00Z", "2026-05-23T00:00:00Z")); + + String body = """ + {"id":"t1","title":"수학","categoryIds":[],"scheduledDates":[]} + """; + + mockMvc.perform(post("/api/todos") + .contentType(MediaType.APPLICATION_JSON).content(body)) + .andExpect(status().isCreated()) + .andExpect(jsonPath("$.id").value("t1")); + } + + @Test + @DisplayName("PATCH /api/todos/{id} — 404 TODO_NOT_FOUND") + void update_notFound() throws Exception { + when(todoService.update(eq(1L), eq("missing"), any())) + .thenThrow(new CustomException(ErrorCode.TODO_NOT_FOUND)); + + mockMvc.perform(patch("/api/todos/missing") + .contentType(MediaType.APPLICATION_JSON).content("{}")) + .andExpect(status().isNotFound()) + .andExpect(jsonPath("$.code").value("TODO_NOT_FOUND")); + } + + @Test + @DisplayName("DELETE /api/todos/{id} — 204") + void delete_success() throws Exception { + mockMvc.perform(delete("/api/todos/t1")) + .andExpect(status().isNoContent()); + } +} diff --git a/build.gradle b/build.gradle index 50facd9..4fbc182 100644 --- a/build.gradle +++ b/build.gradle @@ -5,7 +5,7 @@ plugins { } group = 'com.elipair' -version = '0.0.24' +version = '0.0.43' description = '우주공부선 백엔드 서버입니다.' bootJar { diff --git a/docs/api-specs/01_auth.md b/docs/api-specs/01_auth.md index 71b7e6c..59aff3f 100644 --- a/docs/api-specs/01_auth.md +++ b/docs/api-specs/01_auth.md @@ -32,7 +32,7 @@ Firebase ID Token을 백엔드에 전송하여 JWT를 발급받습니다. | 필드 | 타입 | 필수 | 설명 | 예시 | |------|------|------|------|------| -| `socialPlatform` | String | O | 소셜 로그인 플랫폼 | `"GOOGLE"`, `"APPLE"` | +| `socialType` | String | O | 소셜 로그인 플랫폼 | `"GOOGLE"`, `"APPLE"`, `"KAKAO"` | | `idToken` | String | O | Firebase에서 발급받은 ID Token | `"eyJhbG..."` | | `fcmToken` | String | O | Firebase Cloud Messaging 디바이스 토큰 | `"dK3mL..."` | | `deviceType` | String | O | 디바이스 OS 타입 | `"IOS"`, `"ANDROID"` | @@ -40,7 +40,7 @@ Firebase ID Token을 백엔드에 전송하여 JWT를 발급받습니다. ```json { - "socialPlatform": "GOOGLE", + "socialType": "GOOGLE", "idToken": "eyJhbGciOiJSUzI1NiIs...", "fcmToken": "dK3mL9xRTp2...", "deviceType": "IOS", @@ -78,7 +78,7 @@ Firebase ID Token을 백엔드에 전송하여 JWT를 발급받습니다. | Status | code | 상황 | |--------|------|------| | 400 | `INVALID_ID_TOKEN` | Firebase ID Token 검증 실패 | -| 400 | `UNSUPPORTED_PLATFORM` | socialPlatform이 GOOGLE/APPLE이 아닌 경우 | +| 400 | `UNSUPPORTED_SOCIAL_TYPE` | socialType이 GOOGLE/APPLE/KAKAO가 아닌 경우 | ### 서버 처리 로직 @@ -332,9 +332,14 @@ GET /api/auth/check-nickname?nickname=우주탐험가 | 컬럼 | 타입 | 설명 | |------|------|------| | `id` | BIGINT (PK) | | -| `user_id` | BIGINT (FK → users) | 유저 ID | +| `member_id` | BIGINT (FK → members) | 회원 ID | | `device_id` | VARCHAR(255) | 디바이스 UUID | | `device_type` | VARCHAR(10) | IOS / ANDROID | | `fcm_token` | VARCHAR(255) | FCM 토큰 | -| `refresh_token` | VARCHAR(512) | Refresh Token | +| `refresh_token` | VARCHAR(512) | Refresh Token (SHA-256 해시) | | `last_login_at` | TIMESTAMP | 마지막 로그인 | +| `created_at` | TIMESTAMP | 생성 시각 | +| `updated_at` | TIMESTAMP | 수정 시각 | + +> Unique 제약: `(member_id, device_id)` 컴포지트. 같은 디바이스를 다른 회원이 쓰는 경우는 별개 row. +> FK: `member_id` → `members.id`, `ON DELETE CASCADE` (회원 탈퇴 시 자동 삭제). diff --git a/docs/api-specs/03_timer.md b/docs/api-specs/03_timer.md index 8f33346..e538884 100644 --- a/docs/api-specs/03_timer.md +++ b/docs/api-specs/03_timer.md @@ -155,14 +155,14 @@ GET /api/timer-sessions?todoId=todo-uuid-5678 "endedAt": "2026-04-16T10:30:00Z", "durationMinutes": 90 }, - "fuelCharged": 90 + "fuelCharged": 3 } ``` | 필드 | 타입 | 설명 | |------|------|------| | `session` | Object | 저장된 세션 (서버 생성 ID 포함) | -| `fuelCharged` | Integer | 서버에서 검증 후 실제 충전된 연료량 | +| `fuelCharged` | Integer | 서버에서 검증 후 실제 충전된 연료 **통 수** (30분=1연료 환산 결과). 30분 미만은 0 가능 | ### Error @@ -183,16 +183,31 @@ GET /api/timer-sessions?todoId=todo-uuid-5678 4. startedAt이 미래가 아닌지 확인 5. 검증 통과 시: - 세션 DB 저장 - - 연료 충전: fuelCharged = durationMinutes (1분 = 1연료) - - Fuel 거래 내역 생성 (type: charge, reason: STUDY_SESSION, referenceId: sessionId) -6. Todo에 actualMinutes 누적 업데이트 (todoId가 있는 경우) + - 연료 충전: 30분 단위로 끊어서 충전, 잔여분은 pendingMinutes에 이월 + - Fuel 거래 내역 생성 (단, 충전 amount > 0 일 때만 INSERT — 30분 미만이면 transaction 없음) + - type: charge, reason: STUDY_SESSION, referenceId: sessionId +6. Todo에 actualMinutes 누적 업데이트 (todoId가 있는 경우, studyMinutes 그대로 누적) ``` ### 연료 충전 규칙 -- 기본: **1분 공부 = 1 연료** -- 서버에서 `durationMinutes`를 재검증하여 충전량 결정 -- 클라이언트가 보낸 값과 서버 계산값이 다를 수 있음 (조작 방지) +- 환율: **30분 공부 = 1 연료** (정수 절삭) +- **잔여분 이월**: 30분 미만 잔여 분은 `user_fuel.pendingMinutes`에 누적되어 다음 세션과 합산 +- `fuelCharged` 응답값은 이번 호출로 새로 충전된 통 수 (0 가능) +- 서버에서 `durationMinutes`를 재검증하여 충전량 결정 (조작 방지) + +#### 환산 예시 (사용자 누적) + +| 호출 | 이번 세션 | 직전 pending | 이번 totalMinutes | fuelCharged(amount) | newPending | 누적 currentFuel | +|------|-----------|--------------|-------------------|---------------------|------------|------------------| +| 1 | 25분 | 0 | 25 | **0** | 25 | 0 | +| 2 | 20분 | 25 | 45 | **1** | 15 | 1 | +| 3 | 50분 | 15 | 65 | **2** | 5 | 3 | +| 4 | 90분 | 5 | 95 | **3** | 5 | 6 | + +#### Todo `actualMinutes` 누적 +- 연료 환산과 무관하게 **실제 공부 분(`durationMinutes`)을 그대로 누적** +- "내가 오늘 73분 공부했다"는 사용자 인지값을 손실 없이 보존 --- @@ -210,19 +225,32 @@ GET /api/timer-sessions?todoId=todo-uuid-5678 **200 OK** +| 필드 | 타입 | Nullable | 설명 | +|------|------|----------|------| +| `totalMinutes` | Integer | X | 오늘 총 공부 시간 (분, KST) | +| `sessionCount` | Integer | X | 오늘 완료한 세션 수 | +| `streak` | Integer | X | 연속 공부 일수 (오늘 포함, KST 기준) | +| `lifetimeMinutes` | Integer | X | 회원의 전체 누적 공부 시간 (분) | +| `lifetimeSessionCount` | Integer | X | 회원의 전체 세션 수 | +| `monthlyMinutes` | Integer | X | 이번 달 누적 공부 시간 (분, KST 기준) | + +> 세션 0건 회원도 6필드 모두 `0`을 반환합니다. `null` 절대 반환하지 않습니다. + ```json { "totalMinutes": 180, "sessionCount": 3, - "streak": 7 + "streak": 7, + "lifetimeMinutes": 12450, + "lifetimeSessionCount": 287, + "monthlyMinutes": 1820 } ``` -| 필드 | 타입 | 설명 | -|------|------|------| -| `totalMinutes` | Integer | 오늘 총 공부 시간 (분) | -| `sessionCount` | Integer | 오늘 완료한 세션 수 | -| `streak` | Integer | 연속 공부 일수 (오늘 포함) | +#### 시간 경계 정의 +- **오늘**: `KST 00:00:00` ~ `KST 23:59:59` +- **이번 달**: 이번 달 1일 `KST 00:00:00` ~ 다음 달 1일 `KST 00:00:00` (반열림 `[start, end)`) +- **streak**: 마지막 공부일이 오늘이면 오늘 포함, 어제까지만 했으면 어제 기준. KST 기준 일자 단위 연속. ### 연속 일수 (Streak) 계산 로직 diff --git a/docs/api-specs/04_fuel.md b/docs/api-specs/04_fuel.md index 19bcf93..4bad296 100644 --- a/docs/api-specs/04_fuel.md +++ b/docs/api-specs/04_fuel.md @@ -40,7 +40,7 @@ "currentFuel": 350, "totalCharged": 1200, "totalConsumed": 850, - "pendingMinutes": 0, + "pendingMinutes": 15, "lastUpdatedAt": "2026-04-16T10:30:00Z" } ``` @@ -50,7 +50,7 @@ | `currentFuel` | Integer | 현재 보유 연료 (`totalCharged - totalConsumed`) | | `totalCharged` | Integer | 누적 충전량 | | `totalConsumed` | Integer | 누적 소비량 | -| `pendingMinutes` | Integer | 아직 서버에 동기화되지 않은 공부 시간 (분). 현재 사용 안 함, 향후 확장용 | +| `pendingMinutes` | Integer | 다음 1연료까지 남은 누적 공부 분 (0~29). 30분 미만 잔여분이 다음 세션과 합산되어 이월됨 | | `lastUpdatedAt` | String | 마지막 연료 변동 시각 (ISO 8601 UTC) | --- @@ -200,7 +200,7 @@ GET /api/fuel/transactions?type=consume&startDate=2026-04-01&endDate=2026-04-16 | `total_charged` | INTEGER | 누적 충전량 | | `total_consumed` | INTEGER | 누적 소비량 | | `pending_minutes` | INTEGER | 미동기화 시간 | -| `last_updated_at` | TIMESTAMP | 마지막 변동 시각 | +| `updated_at` | TIMESTAMP | 마지막 변동 시각 (BaseTimeEntity.updatedAt) | ### fuel_transactions diff --git a/docs/api-specs/05_exploration.md b/docs/api-specs/05_exploration.md index fc8bdfd..8b1a2cb 100644 --- a/docs/api-specs/05_exploration.md +++ b/docs/api-specs/05_exploration.md @@ -13,19 +13,23 @@ ``` 태양계 (고정) - ├── 지구 (planet) ─ 해금됨 - │ ├── 대한민국 (region) ─ 해금됨 - │ ├── 일본 (region) ─ 잠김 (100연료) - │ └── 미국 (region) ─ 잠김 (100연료) - ├── 화성 (planet) ─ 잠김 (200연료) - │ ├── 올림푸스 (region) - │ └── 마리너 (region) - └── ... + ├── 지구 (planet, fuel=0) ─ 기본 해금 + │ ├── 대한민국 (region, fuel=0) ─ 기본 해금 + │ ├── 일본 (region, fuel=1) + │ └── ... (총 12개 지역) + ├── 수성 (planet, fuel=3) ─ 지구 클리어 후 해금 가능 + ├── 금성 (planet, fuel=5) ─ 수성 클리어 후 해금 가능 + ├── 화성 (planet, fuel=10) ─ 금성 클리어 후 해금 가능 + ├── 목성 (planet, fuel=20) ─ 화성 클리어 후 해금 가능 + ├── 토성 (planet, fuel=30) ─ 목성 클리어 후 해금 가능 + ├── 천왕성 (planet, fuel=45) ─ 토성 클리어 후 해금 가능 + └── 해왕성 (planet, fuel=60) ─ 천왕성 클리어 후 해금 가능 ``` ### 해금 규칙 -- **행성 해금**: 연료를 소비하여 행성에 진입 가능 상태로 변경. 지구는 기본 해금. +- **행성 해금**: 연료를 소비하여 행성에 진입 가능 상태로 변경. 지구는 기본 해금 (`requiredFuel=0`). +- **행성 진행 게이트**: 행성은 선행 행성(`prerequisiteId`)을 클리어해야 해금. 지구는 선행 없음 (체인: 지구→수성→금성→화성→목성→토성→천왕성→해왕성). - **지역 해금**: 행성이 해금된 상태에서 연료를 소비하여 지역 해금 (= 클리어). - **행성 클리어**: 행성의 모든 하위 지역이 해금되면 자동으로 행성 클리어 처리. - 연료 차감은 해금 API 내부에서 원자적으로 처리됩니다 (별도 fuel consume 호출 불필요). @@ -33,6 +37,9 @@ ### 시드 데이터 행성/지역 마스터 데이터는 서버에서 시드로 관리합니다. ID는 고정 문자열입니다. +- **행성 ID**: `earth`, `mercury`, `venus`, `mars`, `jupiter`, `saturn`, `uranus`, `neptune` (총 8개) +- **지역 ID**: 이름 기반 문자열 (예: `korea`, `japan`, `mars_olympus`) +- **icon 값**: 지구 지역은 국가 코드 (예: `KR`, `JP`), 그 외 행성/행성 지역은 행성 이름 (예: `mars`, `jupiter`) --- @@ -59,13 +66,14 @@ "depth": 2, "icon": "earth", "parentId": null, + "prerequisiteId": null, "requiredFuel": 0, "isUnlocked": true, "isCleared": false, "sortOrder": 0, - "description": "모든 여정의 시작점", + "description": "우리의 출발지, 고향 행성", "mapX": 0.5, - "mapY": 0.3, + "mapY": 0.08, "unlockedAt": "2026-04-01T00:00:00Z" } ``` @@ -76,8 +84,9 @@ | `name` | String | X | 노드 이름 | | `nodeType` | String | X | `"planet"` 또는 `"region"` | | `depth` | Integer | X | 계층 깊이 (planet=2, region=3) | -| `icon` | String | X | 아이콘 식별자 (행성: 이름, 지역: 국가코드) | +| `icon` | String | X | 아이콘 식별자 (지구 지역: 국가코드, 그 외: 행성이름) | | `parentId` | String | O | 상위 노드 ID (행성은 null) | +| `prerequisiteId` | String | O | 선행 행성 ID (행성만, 이 행성을 해금하려면 선행 행성을 클리어해야 함). region은 null | | `requiredFuel` | Integer | X | 해금에 필요한 연료량 (0이면 기본 해금) | | `isUnlocked` | Boolean | X | 해금 여부 | | `isCleared` | Boolean | X | 클리어 여부 (지역: 해금=클리어, 행성: 모든 지역 해금 시 클리어) | @@ -91,7 +100,7 @@ | 값 | 설명 | 해금 조건 | 클리어 조건 | |----|------|----------|-----------| -| `planet` | 행성 | 연료 소비 | 모든 하위 region 해금 시 자동 클리어 | +| `planet` | 행성 | 연료 소비 + 선행 행성 클리어 | 모든 하위 region 해금 시 자동 클리어 | | `region` | 지역 | 연료 소비 (상위 행성 해금 필수) | 해금 = 클리어 | --- @@ -119,38 +128,40 @@ "depth": 2, "icon": "earth", "parentId": null, + "prerequisiteId": null, "requiredFuel": 0, "isUnlocked": true, "isCleared": false, "sortOrder": 0, - "description": "모든 여정의 시작점", + "description": "우리의 출발지, 고향 행성", "mapX": 0.5, - "mapY": 0.3, + "mapY": 0.08, "unlockedAt": "2026-04-01T00:00:00Z", "progress": { "clearedChildren": 3, - "totalChildren": 5, - "progressRatio": 0.6 + "totalChildren": 12, + "progressRatio": 0.25 } }, { - "id": "mars", - "name": "화성", + "id": "mercury", + "name": "수성", "nodeType": "planet", "depth": 2, - "icon": "mars", + "icon": "mercury", "parentId": null, - "requiredFuel": 200, + "prerequisiteId": "earth", + "requiredFuel": 3, "isUnlocked": false, "isCleared": false, "sortOrder": 1, - "description": "붉은 행성", - "mapX": 0.8, - "mapY": 0.5, + "description": "태양에 가장 가까운 작은 행성", + "mapX": 0.15, + "mapY": 0.20, "unlockedAt": null, "progress": { "clearedChildren": 0, - "totalChildren": 3, + "totalChildren": 2, "progressRatio": 0.0 } } @@ -194,35 +205,35 @@ GET /api/explorations/planets/earth/regions ```json [ { - "id": "region-kr", + "id": "korea", "name": "대한민국", "nodeType": "region", "depth": 3, "icon": "KR", "parentId": "earth", - "requiredFuel": 100, + "requiredFuel": 0, "isUnlocked": true, "isCleared": true, "sortOrder": 0, - "description": "한반도의 남쪽", - "mapX": 0.7, - "mapY": 0.4, + "description": "한반도 남쪽, K-컬쳐의 중심", + "mapX": 0.0, + "mapY": 0.0, "unlockedAt": "2026-04-05T15:30:00Z" }, { - "id": "region-jp", + "id": "japan", "name": "일본", "nodeType": "region", "depth": 3, "icon": "JP", "parentId": "earth", - "requiredFuel": 100, + "requiredFuel": 1, "isUnlocked": false, "isCleared": false, "sortOrder": 1, - "description": "해가 뜨는 나라", - "mapX": 0.8, - "mapY": 0.3, + "description": "벚꽃과 기술의 나라", + "mapX": 0.0, + "mapY": 0.0, "unlockedAt": null } ] @@ -257,7 +268,7 @@ GET /api/explorations/planets/earth/regions ### Request Body: 없음 ``` -POST /api/explorations/regions/region-jp/unlock +POST /api/explorations/regions/japan/unlock ``` ### Response @@ -267,14 +278,14 @@ POST /api/explorations/regions/region-jp/unlock ```json { "region": { - "id": "region-jp", + "id": "japan", "name": "일본", "isUnlocked": true, "isCleared": true, "unlockedAt": "2026-04-16T11:00:00Z" }, - "fuelConsumed": 100, - "currentFuel": 250, + "fuelConsumed": 1, + "currentFuel": 25, "planetCleared": false } ``` @@ -295,6 +306,12 @@ POST /api/explorations/regions/region-jp/unlock | 400 | `PLANET_LOCKED` | 상위 행성이 아직 해금되지 않음 | | 404 | `REGION_NOT_FOUND` | regionId에 해당하는 지역 없음 | +**INSUFFICIENT_FUEL 응답 본문 예시:** + +```json +{ "code": "INSUFFICIENT_FUEL", "message": "연료가 부족합니다.", "requiredFuel": 10, "currentFuel": 4 } +``` + ### 서버 처리 로직 ``` @@ -331,7 +348,7 @@ COMMIT; ### Request Body: 없음 ``` -POST /api/explorations/planets/mars/unlock +POST /api/explorations/planets/mercury/unlock ``` ### Response @@ -341,13 +358,13 @@ POST /api/explorations/planets/mars/unlock ```json { "planet": { - "id": "mars", - "name": "화성", + "id": "mercury", + "name": "수성", "isUnlocked": true, "isCleared": false, "unlockedAt": "2026-04-16T11:30:00Z" }, - "fuelConsumed": 200, + "fuelConsumed": 3, "currentFuel": 50 } ``` @@ -364,14 +381,22 @@ POST /api/explorations/planets/mars/unlock |--------|------|------| | 400 | `INSUFFICIENT_FUEL` | 연료 잔량 부족 | | 400 | `ALREADY_UNLOCKED` | 이미 해금된 행성 | +| 400 | `PREREQUISITE_NOT_CLEARED` | 선행 행성이 아직 클리어되지 않음 | | 404 | `PLANET_NOT_FOUND` | planetId에 해당하는 행성 없음 | +**INSUFFICIENT_FUEL 응답 본문 예시:** + +```json +{ "code": "INSUFFICIENT_FUEL", "message": "연료가 부족합니다.", "requiredFuel": 10, "currentFuel": 4 } +``` + ### 서버 처리 로직 ``` BEGIN TRANSACTION; 1. planetId로 행성 마스터 데이터 조회 2. 이미 해금된 행성인지 확인 + 2-1. prerequisiteId가 있으면 선행 행성이 클리어(모든 하위 지역 해금)되었는지 확인 → 아니면 PREREQUISITE_NOT_CLEARED 3. 유저 연료 잔량 >= requiredFuel 확인 4. 연료 차감 5. 연료 거래 내역 생성 (type: consume, reason: EXPLORATION_UNLOCK, referenceId: planetId) @@ -387,24 +412,42 @@ COMMIT; | 컬럼 | 타입 | 설명 | |------|------|------| -| `id` | VARCHAR(50) (PK) | 노드 ID (earth, mars, region-kr 등) | +| `id` | VARCHAR(50) (PK) | 노드 ID (이름 기반 고정 문자열: `earth`, `korea`, `mars_olympus` 등) | | `name` | VARCHAR(50) | 노드 이름 | | `node_type` | VARCHAR(10) | planet / region | | `depth` | INTEGER | 계층 깊이 | -| `icon` | VARCHAR(20) | 아이콘 식별자 | +| `icon` | VARCHAR(30) | 아이콘 식별자 (지구 지역: 국가코드 예: `KR`, 그 외: 행성이름 예: `mars`) | | `parent_id` | VARCHAR(50) (FK → self) | 상위 노드 ID | +| `prerequisite_node_id` | VARCHAR(50) (FK → self) | 선행 행성 ID (행성만, 지역은 NULL) | | `required_fuel` | INTEGER | 해금 필요 연료 | | `sort_order` | INTEGER | 표시 순서 | | `description` | VARCHAR(200) | 설명 | | `map_x` | DOUBLE | 맵 가로 위치 | | `map_y` | DOUBLE | 맵 세로 위치 | +**행성 시드 (8개):** + +| id | name | required_fuel | prerequisite_node_id | sort_order | +|----|------|:---:|---|:---:| +| `earth` | 지구 | 0 | NULL | 0 | +| `mercury` | 수성 | 3 | `earth` | 1 | +| `venus` | 금성 | 5 | `mercury` | 2 | +| `mars` | 화성 | 10 | `venus` | 3 | +| `jupiter` | 목성 | 20 | `mars` | 4 | +| `saturn` | 토성 | 30 | `jupiter` | 5 | +| `uranus` | 천왕성 | 45 | `saturn` | 6 | +| `neptune` | 해왕성 | 60 | `uranus` | 7 | + +**지역 시드 (30개, required_fuel 범위: 0~20):** + +지역 ID는 이름 기반 문자열 (예: `korea`, `japan`, `mars_olympus`). 지구 12개, 그 외 행성 각 2~3개. + ### user_exploration_progress (유저별 진행 상태) | 컬럼 | 타입 | 설명 | |------|------|------| | `id` | BIGINT (PK) | | -| `user_id` | BIGINT (FK → users) | 유저 ID | +| `user_id` | BIGINT (FK → members) | 유저 ID | | `node_id` | VARCHAR(50) (FK → exploration_nodes) | 노드 ID | | `is_unlocked` | BOOLEAN | 해금 여부 | | `is_cleared` | BOOLEAN | 클리어 여부 | diff --git a/docs/api-specs/exploration-frontend-requirements.md b/docs/api-specs/exploration-frontend-requirements.md new file mode 100644 index 0000000..cf64c97 --- /dev/null +++ b/docs/api-specs/exploration-frontend-requirements.md @@ -0,0 +1,241 @@ +# 행성 탐험 — 프론트 통합 요구사항 (Frontend → Backend API 요청) + +> **작성:** 2026-05-29 +> **대상 기능:** Exploration (행성/지역 탐험) +> **성격:** Flutter(프론트)가 백엔드에 요구하는 API 계약 명세. 백엔드 `docs/api-specs/05_exploration.md`와 대조·정합을 맞추기 위한 문서. +> **관련 코드:** `lib/features/exploration/` + +--- + +## 1. 목적 & 범위 + +### 목적 + +Flutter 앱의 탐험 기능을 **게스트 로컬 모드 → 회원 서버 연동**으로 전환하기 위해, 프론트가 소비할 API 계약을 프론트 관점에서 정의한다. 현재 프론트는 `ExplorationLocalRepositoryImpl`(SharedPreferences + 시드 데이터)만 구현돼 있고, 회원용 `ExplorationRemoteRepositoryImpl`은 미구현 상태다. 이 문서는 그 Remote 구현의 입력 계약이 된다. + +### 범위 (In scope) + +- 회원(소셜 로그인, JWT 인증) 사용자가 사용하는 서버 API 계약 +- 프론트가 렌더링·상태표시에 필요한 데이터 필드 명세 +- 해금 동작의 요청/응답/에러 계약 + +### 비범위 (Out of scope) — 명시적 제외 + +- **게스트 데이터 마이그레이션 없음.** 게스트는 100% 로컬(SharedPreferences) 전용이다. 로그인/회원전환 시 게스트의 로컬 진행도를 서버로 올리는 동기화·병합 로직은 **요구하지 않는다.** 게스트 데이터는 삭제 시 그대로 소멸한다. +- 따라서 "guest progress → server sync" 같은 별도 엔드포인트는 불필요하다. + +--- + +## 2. 인증 & 게스트/회원 경계 + +| 구분 | 데이터 소스 | 인증 | 비고 | +|------|------------|------|------| +| 게스트 | 로컬 (SharedPreferences) | 없음 | 순수 로컬, 서버 호출 없음, 마이그레이션 없음 | +| 회원 | 서버 API (`/api/explorations/**`) | JWT 필요 | 이 문서가 정의하는 API | + +- 이 API의 모든 엔드포인트는 **JWT 인증 필수**다. +- 게스트와 회원의 진행 상태는 완전히 분리된다. 서로 연동되지 않는다. + +--- + +## 3. 프론트가 소비할 데이터 모델 + +프론트는 응답 노드를 `ExplorationNodeEntity`로 매핑한다. 아래는 프론트가 **렌더링·상태판정에 실제로 사용하는** 필드와 요구 사항이다. + +### 3.1 탐험 노드 필드 + +| 필드 | 타입 | Nullable | 프론트 용도 | 현재 프론트 entity 상태 | +|------|------|----------|------------|------------------------| +| `id` | String | X | 노드 식별, 해금 API 호출 키 | 있음 | +| `name` | String | X | 노드 이름 표시 | 있음 | +| `nodeType` | String (`planet`/`region`) | X | planet/region 분기 렌더링 | 있음 (enum: galaxy/starSystem/planet/region) | +| `depth` | Integer | X | 계층 깊이 (planet=2, region=3) | 있음 | +| `icon` | String | X | 아이콘 렌더링 (6번 섹션 참조) | 있음 | +| `parentId` | String | O | 지역의 상위 행성 (planet은 null) | 있음 | +| `prerequisiteId` | String | O | **선행 행성 게이트 표시** (planet만, region은 null) | **없음 — 추가 필요** | +| `requiredFuel` | Integer | X | 해금 비용 표시 (0이면 기본 해금) | 있음 | +| `isUnlocked` | Boolean | X | 잠김/해금 UI 상태 | 있음 | +| `isCleared` | Boolean | X | 클리어 배지·진행 표시 | 있음 | +| `sortOrder` | Integer | X | 표시 순서 정렬 | 있음 | +| `description` | String | X | 노드 설명 표시 | 있음 | +| `mapX` | Double | X | 맵상 가로 위치 (0.0~1.0) | 있음 | +| `mapY` | Double | X | 맵상 세로 위치 (0.0~1.0) | 있음 | +| `unlockedAt` | String(ISO8601) | O | 해금 시각 (null=미해금) | 있음 (DateTime?) | + +> **요구:** 백엔드 노드 응답은 위 필드를 모두 포함해야 한다. 특히 `prerequisiteId`는 프론트 entity에 아직 없으므로 추가 작업 대상이며, 응답 스키마에 반드시 포함돼야 한다. + +### 3.2 진행도(progress) 객체 — 행성 목록 응답에 포함 + +프론트 `ExplorationProgressEntity`와 매핑된다. + +| 필드 | 타입 | 프론트 용도 | +|------|------|------------| +| `clearedChildren` | Integer | 진행 바 "n / m" 표시 | +| `totalChildren` | Integer | 진행 바 분모 | +| `progressRatio` | Double (0.0~1.0) | 진행 바 비율 (프론트도 계산 가능, 서버 제공 시 그대로 사용) | + +> **요구:** 행성 목록 응답의 각 행성에 `progress` 객체가 포함돼야 한다. (프론트는 행성별 하위 지역 클리어 수를 별도 호출 없이 목록에서 바로 표시하고 싶음.) + +--- + +## 4. 필요 엔드포인트 (프론트 관점 계약) + +Base Path: `/api/explorations` + +| # | Method | Path | 프론트 호출 시점 | +|---|--------|------|-----------------| +| 1 | GET | `/planets` | 탐험 화면 진입 시 (행성 맵 렌더링) | +| 2 | GET | `/planets/{planetId}/regions` | 행성 상세 진입 시 (지역 목록 렌더링) | +| 3 | POST | `/regions/{regionId}/unlock` | 지역 해금 버튼 탭 | +| 4 | POST | `/planets/{planetId}/unlock` | 행성 해금 버튼 탭 | + +### 4.1 GET `/planets` — 행성 목록 + +- **호출 시점:** 탐험 메인 화면 진입, 해금 직후 갱신 +- **응답:** 전체 행성 배열. 각 행성은 3.1 필드 + 3.2 `progress` 포함. `sortOrder` 오름차순. +- **프론트 반영:** 행성 노드를 `mapX/mapY`로 맵에 배치, 잠김/해금/클리어 상태로 스타일 분기, 진행 바 표시. + +### 4.2 GET `/planets/{planetId}/regions` — 지역 목록 + +- **호출 시점:** 특정 행성 상세 진입 +- **응답:** 해당 행성 하위 지역 배열 (3.1 필드, region은 `prerequisiteId=null`). `sortOrder` 오름차순. +- **프론트 반영:** 지역 카드/노드 목록, 해금 비용·상태 표시. + +### 4.3 POST `/regions/{regionId}/unlock` — 지역 해금 + +- **요청:** Body 없음. Path에 `regionId`. +- **기대 응답(200):** + ```json + { + "region": { "id": "...", "name": "...", "isUnlocked": true, "isCleared": true, "unlockedAt": "..." }, + "fuelConsumed": 4, + "currentFuel": 250, + "planetCleared": false + } + ``` +- **프론트 반영:** + - `currentFuel`로 연료 게이지 즉시 갱신 (별도 fuel 조회 불필요) + - `region.isUnlocked/isCleared`로 해당 지역 상태 갱신 + - `planetCleared=true`면 상위 행성 클리어 연출 트리거 +- **요구:** 연료 차감은 서버에서 원자적으로 처리. 프론트는 별도 fuel consume API를 호출하지 않는다. + +### 4.4 POST `/planets/{planetId}/unlock` — 행성 해금 + +- **요청:** Body 없음. Path에 `planetId`. +- **기대 응답(200):** + ```json + { + "planet": { "id": "...", "name": "...", "isUnlocked": true, "isCleared": false, "unlockedAt": "..." }, + "fuelConsumed": 12, + "currentFuel": 50 + } + ``` +- **프론트 반영:** `currentFuel` 게이지 갱신, 행성 잠김 해제 연출. + +--- + +## 5. 에러 / 엣지케이스 계약 요청 + +프론트는 아래 상황별로 **사용자에게 다른 메시지/처리**를 보여줘야 하므로, 서버는 식별 가능한 `code`를 반환해야 한다. (공통 에러 포맷은 `00_common.md` 기준) + +| 엔드포인트 | Status | code | 프론트 처리 | +|-----------|--------|------|------------| +| 지역 해금 | 400 | `INSUFFICIENT_FUEL` | "연료가 부족해요" + 필요/보유 연료 안내 | +| 지역 해금 | 400 | `ALREADY_UNLOCKED` | 이미 해금됨 — 무음 처리 또는 상태 재동기화 | +| 지역 해금 | 400 | `PLANET_LOCKED` | "먼저 행성을 해금해야 해요" | +| 지역 해금 | 404 | `REGION_NOT_FOUND` | 데이터 오류 안내 + 목록 새로고침 | +| 행성 해금 | 400 | `INSUFFICIENT_FUEL` | "연료가 부족해요" | +| 행성 해금 | 400 | `ALREADY_UNLOCKED` | 이미 해금됨 — 무음/재동기화 | +| 행성 해금 | 400 | `PREREQUISITE_NOT_CLEARED` | "선행 행성을 먼저 클리어해야 해요" | +| 행성 해금 | 404 | `PLANET_NOT_FOUND` | 데이터 오류 안내 + 목록 새로고침 | +| 지역 목록 | 404 | `PLANET_NOT_FOUND` | 데이터 오류 안내 | + +> **요구:** +> - `INSUFFICIENT_FUEL` 응답에는 가능하면 `requiredFuel`, `currentFuel`을 함께 담아 프론트가 정확한 안내 문구를 만들 수 있게 해줄 것. +> - 에러 응답 본문 스키마(`code`, `message` 키)를 `00_common.md`와 일치시킬 것. + +--- + +## 6. 필드 정합성 요청 (icon / 좌표 / prerequisiteId) + +### 6.1 `icon` 값 규칙 — 프론트 렌더링 의존 + +프론트는 `icon` 값으로 두 가지 렌더링 분기를 이미 구현해 두었다: + +- **행성 / 비(非)지구 지역:** 행성 이름 식별자 사용 — `earth`, `mercury`, `venus`, `mars`, `jupiter`, `saturn`, `uranus`, `neptune` +- **지구 하위 지역:** ISO 3166-1 alpha-2 **국가 코드** 사용 — `KR`, `JP`, `TH`, `CN`, `IN`, `GB`, `FR`, `CA`, `US`, `BR`, `AU`, `EG` (국기 아이콘 렌더링) + +> **요구:** 서버 시드의 `icon` 값은 위 어휘(vocabulary)를 벗어나지 않아야 한다. 프론트가 모르는 `icon` 값이 오면 렌더링 폴백 처리만 가능하다. 새 노드 추가 시 icon 값 규칙을 프론트와 합의할 것. + +### 6.2 좌표 체계 `mapX` / `mapY` + +- 둘 다 `0.0 ~ 1.0` 정규화 비율. 프론트가 화면 크기에 곱해 배치한다. +- **요구:** 모든 행성 노드는 화면 안에 들어오는 좌표를 가져야 한다(겹침 최소화). 지역 노드 좌표는 현재 프론트에서 필수 사용은 아니지만, 응답에는 포함할 것(기본값 허용). + +### 6.3 `prerequisiteId` 추가 + +- 프론트 entity에 아직 없음. 백엔드가 선행 행성 게이트를 구현한다면 프론트도 entity·UI에 추가해야 한다. +- **요구:** 행성 노드 응답에 `prerequisiteId`(없으면 null) 포함. 이 값으로 프론트는 "선행 행성 클리어 필요" 잠금 사유를 표시한다. + +--- + +## 7. 동기화 Tier + +- 탐험 해금은 **Tier 2 (Server-Validated)**: 연료 잔량 확인·차감·해금이 서버에서 원자적으로 처리된다. 해금 동작은 **온라인 필수.** +- 행성/지역 목록 조회는 응답을 로컬에 **읽기 캐시**로 저장. 오프라인 시 캐시를 표시하되 "오프라인" 상태를 노출한다. +- **요구:** 해금 API는 오프라인에서 호출 불가하므로, 네트워크 실패 시 프론트가 명확히 구분할 수 있는 에러(타임아웃/네트워크)를 반환할 것. 부분 성공(연료만 차감되고 해금 실패 등)이 없도록 트랜잭션 보장. + +--- + +## 8. 현행 vs 요구 갭 체크리스트 + +프론트 현행 코드/구버전 spec과 신버전 백엔드 spec(`05_exploration.md`) 사이의 차이. **백엔드(본인)가 확정·정합을 맞춰야 할 항목.** + +### 8.1 데이터 모델 갭 + +- [ ] 프론트 `ExplorationNodeEntity`에 `prerequisiteId` 필드 추가 (현재 없음) +- [ ] 프론트 `exploration_node_entity.dart` icon 주석이 이모지(`🌍`) 기준 — 실제 구현은 식별자/국가코드. 주석 정리 필요 +- [ ] 행성 목록 응답의 `progress` 객체 ↔ 프론트 `ExplorationProgressEntity` 매핑 확인 + +### 8.2 시드 로스터 갭 (프론트 시드 vs 백엔드 spec) + +> ⚠️ 아래 표의 "백엔드 spec 예시" 칼럼은 작성 시점(구버전) 스냅샷이다. **본 PR(#27)에서 서버 시드를 프론트 게스트 시드와 1:1로 일치시켜 이 갭은 모두 해소되었다** (8행성/30지역, 이름 기반 ID, 선행 게이트, 연료 수치 동일). 표는 갭 이력 참고용으로 남긴다. + +> 프론트 `exploration_seed_data.dart`(로컬/게스트용)와 백엔드 spec 예시가 크게 다르다. 회원용 서버 시드를 어느 쪽 기준으로 확정할지 결정 필요. + +| 항목 | 프론트 시드 (게스트 로컬) | 백엔드 spec 예시 | +|------|--------------------------|------------------| +| 행성 구성 | 지구·수성·금성·화성·목성·토성·천왕성·해왕성 (8개, **달 없음**) | 지구·달·화성 (예시 3개) | +| 진행 게이트 | **선행조건 없음** (연료만 있으면 해금) | 선행 체인 (지구→달→화성, `prerequisiteId`) | +| 행성 연료 | earth 0 / mercury 3 / venus 5 / mars 10 / jupiter 20 / saturn 30 / uranus 45 / neptune 60 | earth 0 / moon 8 / mars 12 | +| 지구 지역 | 12개 (korea, japan, thailand, china, india, uk, france, canada, usa, brazil, australia, egypt) | 2개 예시 (대한민국, 일본) | +| 지역 ID 규칙 | `korea`, `japan`, `usa` (이름 기반) | `region-kr`, `region-jp` (prefix 기반) | +| 지구 지역 연료 | 0~3 | 4~6 | + +> **결정 필요 (백엔드 본인 확인):** +> - [ ] 회원용 서버 시드의 **행성 로스터**를 확정 (8행성 전체인지, 달 포함 여부) +> - [ ] **진행 게이트 모델** 확정 — 선행 행성 클리어 게이트를 쓸지(`prerequisiteId`), 연료만으로 해금할지. 프론트 게스트는 현재 게이트 없음 +> - [ ] **지역 ID 네이밍** 통일 (`korea` vs `region-kr`) — 게스트/회원 코드 재사용 위해 한쪽으로 정렬 권장 +> - [ ] **연료 밸런스** 확정 후 게스트 시드와 회원 서버 시드 동기화 + +### 8.3 spec 문서 정합 + +- [ ] 프론트 레포 `docs/api-specs/05_exploration.md`(구버전: 일본/미국 100연료, 화성 200연료, `prerequisiteId`/`progress` 없음)를 백엔드 신버전(4/6/8/12, prerequisite, progress)으로 갱신 + +--- + +## 9. 백엔드 확인 요청 요약 + +본인(백엔드)에게 확정 요청하는 핵심 항목: + +1. **노드 응답 스키마에 `prerequisiteId` 포함** (없으면 null) +2. **행성 목록 응답에 `progress` 객체 포함** +3. **`icon` 값 어휘 고정** (행성 이름 식별자 + 지구 지역 ISO 국가코드) — 6.1 어휘표 합의 +4. **에러 `code` 명세 확정** (5번 표) + `INSUFFICIENT_FUEL`에 `requiredFuel/currentFuel` 동봉 +5. **해금 응답에 `currentFuel` 포함** (프론트가 fuel 별도 조회 안 하도록) +6. **회원용 서버 시드 확정** — 행성 로스터/진행 게이트/지역 ID 네이밍/연료 밸런스 (8.2) +7. **해금 트랜잭션 원자성 보장** + 오프라인/네트워크 실패 구분 가능한 에러 + +--- + +> 게스트는 오직 로컬, 회원은 오직 서버. 이 경계만 지키면 마이그레이션 고민 없이 두 모드를 독립적으로 유지할 수 있다. diff --git "a/docs/suh-template/issue/20260428_31_\352\270\260\353\212\245\354\266\224\352\260\200_\352\260\234\353\260\234\355\231\230\352\262\275_IntelliJ_\354\213\244\355\226\211_\354\204\244\354\240\225_\355\214\214\354\235\274_\352\263\265\354\234\240.md" "b/docs/suh-template/issue/20260428_31_\352\270\260\353\212\245\354\266\224\352\260\200_\352\260\234\353\260\234\355\231\230\352\262\275_IntelliJ_\354\213\244\355\226\211_\354\204\244\354\240\225_\355\214\214\354\235\274_\352\263\265\354\234\240.md" new file mode 100644 index 0000000..c3ac3d1 --- /dev/null +++ "b/docs/suh-template/issue/20260428_31_\352\270\260\353\212\245\354\266\224\352\260\200_\352\260\234\353\260\234\355\231\230\352\262\275_IntelliJ_\354\213\244\355\226\211_\354\204\244\354\240\225_\355\214\214\354\235\274_\352\263\265\354\234\240.md" @@ -0,0 +1,35 @@ +--- +name: ⚙️[기능추가][개발환경] IntelliJ 서버 기동 실행 설정 파일 공유 +description: 팀원들이 IntelliJ에서 동일한 환경으로 서버를 기동할 수 있도록 .run 설정 파일을 버전 관리에 포함 +type: feature +--- + +⚙️[기능추가][개발환경] IntelliJ 서버 기동 실행 설정 파일 공유 + +라벨: 작업전 +담당자: Cassiiopeia + +📝 현재 문제점 +--- + +- `.run/SpaceStudyShipApplication.run.xml` 파일이 `.gitignore`에 의해 버전 관리에서 제외되어 있거나 공유되지 않아, 신규 팀원이 IntelliJ에서 서버를 기동하려면 Run Configuration을 직접 수동으로 설정해야 한다. +- 실행 프로파일(`dev`), 메인 클래스(`com.elipair.spacestudyship.SpaceStudyShipApplication`), 모듈(`spacestudyship.SS-Web.main`) 등의 설정을 각자 동일하게 맞춰야 하는 번거로움이 있다. + +🛠️ 해결 방안 / 제안 기능 +--- + +- `.run/SpaceStudyShipApplication.run.xml` 파일을 Git 버전 관리에 포함하여 레포를 클론하면 바로 동일한 IntelliJ 실행 설정을 사용할 수 있도록 한다. +- `.gitignore`에서 `.run/` 디렉토리 제외 규칙이 있다면 해당 항목을 수정하거나 예외 처리한다. + +⚙️ 작업 내용 +--- + +- `.gitignore` 확인 및 `.run/` 관련 항목 수정 +- `.run/SpaceStudyShipApplication.run.xml` 커밋 및 푸시 + +🙋‍♂️ 담당자 +--- + +- 백엔드: SUH SAECHAN +- 프론트엔드: - +- 디자인: - diff --git a/docs/superpowers/plans/2026-04-24-nickname-api.md b/docs/superpowers/plans/2026-04-24-nickname-api.md new file mode 100644 index 0000000..0459342 --- /dev/null +++ b/docs/superpowers/plans/2026-04-24-nickname-api.md @@ -0,0 +1,724 @@ +# 닉네임 중복 확인 및 닉네임 변경 API Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** `GET /api/auth/check-nickname`, `PATCH /api/auth/nickname` 두 개의 엔드포인트를 TDD로 구현하고 `/api/auth` 화이트리스트를 공개 API(`login`, `reissue`)만 남기도록 정리한다. + +**Architecture:** Controller(SS-Web) → AuthService(SS-Auth) → MemberRepository(SS-Member) 계층. 입력 검증은 Bean Validation이 붙은 record DTO로 처리하고, 미인증/중복/형식 오류는 `CustomException`/`GlobalExceptionHandler`가 담당. 인증은 기존 `AuthInterceptor` + `LoginMemberArgumentResolver` 재사용. + +**Tech Stack:** Java 21, Spring Boot 4.0.2, Spring Security, Spring Data JPA, Bean Validation(Jakarta), JUnit 5, Mockito, MockMvc(standalone). + +**Related spec:** [`docs/superpowers/specs/2026-04-24-nickname-api-design.md`](../specs/2026-04-24-nickname-api-design.md) + +--- + +## File Plan + +| 파일 | 구분 | 책임 | +|------|------|------| +| `SS-Web/.../config/WebConfig.java` | 수정 | `AuthInterceptor` 예외 경로를 `login`/`reissue`/`actuator/health`로 축소 | +| `SS-Auth/.../auth/dto/CheckNicknameRequest.java` | 신규 | `@ModelAttribute`로 바인딩될 닉네임 쿼리 DTO (검증 포함) | +| `SS-Auth/.../auth/dto/CheckNicknameResponse.java` | 신규 | `{available: boolean}` 응답 record | +| `SS-Auth/.../auth/dto/UpdateNicknameRequest.java` | 신규 | PATCH 본문 DTO (검증 포함) | +| `SS-Auth/.../auth/dto/UpdateNicknameResponse.java` | 신규 | `{nickname: string}` 응답 record | +| `SS-Auth/.../auth/service/AuthService.java` | 수정 | `checkNickname`, `updateNickname` 메서드 추가 | +| `SS-Web/.../controller/auth/AuthController.java` | 수정 | GET/PATCH 엔드포인트 2개 추가 | +| `SS-Auth/src/test/.../auth/service/AuthServiceTest.java` | 신규 | Service 단위 테스트 | +| `SS-Web/src/test/.../controller/auth/AuthControllerTest.java` | 신규 | Controller 슬라이스 테스트 (MockMvc standalone) | + +--- + +## Task 1: Interceptor 예외 경로 축소 + +**Why first**: 신규 API의 인증 동작이 이 설정에 의존. 먼저 고쳐두고 빌드를 깨지 않게 유지. + +**아키텍처 메모**: Spring Security는 `SecurityUrls.AUTH_WHITELIST`에 `/api/auth/**`를 통째로 permit해 두고 있으며, 실제 JWT 검증은 `AuthInterceptor`가 담당한다. 따라서 `SecurityUrls`는 **변경하지 않고**, `WebConfig`의 인터셉터 `excludePathPatterns`만 축소한다. (만약 `SecurityUrls`에서 `/api/auth/**`를 빼면 Spring Security 필터 체인에 JWT 처리 필터가 없어 정상 요청도 401이 됨 — 이는 별도 이슈로 다룬다.) + +**Files:** +- Modify: `SS-Web/src/main/java/com/elipair/spacestudyship/config/WebConfig.java` + +- [ ] **Step 1: `WebConfig.addInterceptors` 예외 경로 교체** + +`SS-Web/src/main/java/com/elipair/spacestudyship/config/WebConfig.java`의 `addInterceptors` 메서드를 다음처럼 변경: + +```java +@Override +public void addInterceptors(InterceptorRegistry registry) { + registry.addInterceptor(authInterceptor) + .addPathPatterns("/api/**") + .excludePathPatterns( + "/api/auth/login", + "/api/auth/reissue", + "/actuator/health" + ); +} +``` + +- [ ] **Step 2: 빌드 검증** + +Run: `./gradlew build -x test` +Expected: `BUILD SUCCESSFUL` + +- [ ] **Step 3: 커밋** + +```bash +git add SS-Web/src/main/java/com/elipair/spacestudyship/config/WebConfig.java +git commit -m "닉네임 중복 확인 및 닉네임 변경 API 구현 : refactor : AuthInterceptor 예외 경로를 공개 API로 한정 https://github.com/SpaceStudyShip/SpaceStudyShip-BE/issues/21" +``` + +--- + +## Task 2: DTO 4종 작성 + +**Why**: Controller/Service 시그니처를 먼저 확정해야 테스트를 TDD로 쓸 수 있음. + +**Files:** +- Create: `SS-Auth/src/main/java/com/elipair/spacestudyship/auth/dto/CheckNicknameRequest.java` +- Create: `SS-Auth/src/main/java/com/elipair/spacestudyship/auth/dto/CheckNicknameResponse.java` +- Create: `SS-Auth/src/main/java/com/elipair/spacestudyship/auth/dto/UpdateNicknameRequest.java` +- Create: `SS-Auth/src/main/java/com/elipair/spacestudyship/auth/dto/UpdateNicknameResponse.java` + +- [ ] **Step 1: `CheckNicknameRequest.java` 생성** + +```java +package com.elipair.spacestudyship.auth.dto; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Pattern; +import jakarta.validation.constraints.Size; + +public record CheckNicknameRequest( + @NotBlank(message = "닉네임은 필수입니다.") + @Size(min = 2, max = 10, message = "닉네임은 2자 이상 10자 이하여야 합니다.") + @Pattern(regexp = "^[가-힣a-zA-Z0-9]+$", message = "닉네임은 한글, 영문, 숫자만 사용할 수 있습니다.") + String nickname +) {} +``` + +- [ ] **Step 2: `CheckNicknameResponse.java` 생성** + +```java +package com.elipair.spacestudyship.auth.dto; + +public record CheckNicknameResponse( + boolean available +) {} +``` + +- [ ] **Step 3: `UpdateNicknameRequest.java` 생성** + +```java +package com.elipair.spacestudyship.auth.dto; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Pattern; +import jakarta.validation.constraints.Size; + +public record UpdateNicknameRequest( + @NotBlank(message = "닉네임은 필수입니다.") + @Size(min = 2, max = 10, message = "닉네임은 2자 이상 10자 이하여야 합니다.") + @Pattern(regexp = "^[가-힣a-zA-Z0-9]+$", message = "닉네임은 한글, 영문, 숫자만 사용할 수 있습니다.") + String nickname +) {} +``` + +- [ ] **Step 4: `UpdateNicknameResponse.java` 생성** + +```java +package com.elipair.spacestudyship.auth.dto; + +public record UpdateNicknameResponse( + String nickname +) {} +``` + +- [ ] **Step 5: 빌드 검증** + +Run: `./gradlew :SS-Auth:compileJava` +Expected: `BUILD SUCCESSFUL` + +- [ ] **Step 6: 커밋** + +```bash +git add SS-Auth/src/main/java/com/elipair/spacestudyship/auth/dto/ +git commit -m "닉네임 중복 확인 및 닉네임 변경 API 구현 : feat : 닉네임 요청/응답 DTO 추가 https://github.com/SpaceStudyShip/SpaceStudyShip-BE/issues/21" +``` + +--- + +## Task 3: `AuthService.checkNickname` 구현 (TDD) + +**Files:** +- Create: `SS-Auth/src/test/java/com/elipair/spacestudyship/auth/service/AuthServiceTest.java` +- Modify: `SS-Auth/src/main/java/com/elipair/spacestudyship/auth/service/AuthService.java` (메서드 추가) + +- [ ] **Step 1: 실패 테스트 작성 (`AuthServiceTest.java` 신규)** + +```java +package com.elipair.spacestudyship.auth.service; + +import com.elipair.spacestudyship.auth.dto.CheckNicknameResponse; +import com.elipair.spacestudyship.auth.jwt.JwtTokenProvider; +import com.elipair.spacestudyship.auth.repository.RefreshTokenRepository; +import com.elipair.spacestudyship.auth.social.SocialLoginStrategy; +import com.elipair.spacestudyship.member.constant.SocialType; +import com.elipair.spacestudyship.member.repository.MemberRepository; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.util.Map; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.BDDMockito.given; + +@ExtendWith(MockitoExtension.class) +class AuthServiceTest { + + @Mock + MemberRepository memberRepository; + @Mock + RefreshTokenRepository refreshTokenRepository; + @Mock + JwtTokenProvider jwtTokenProvider; + @Mock + RandomNicknameGenerator randomNicknameGenerator; + @Mock + Map socialLoginStrategies; + + @InjectMocks + AuthService authService; + + @Test + @DisplayName("checkNickname: DB에 닉네임이 없으면 available=true") + void checkNickname_available() { + // given + String nickname = "우주탐험가"; + given(memberRepository.existsByNickname(nickname)).willReturn(false); + + // when + CheckNicknameResponse response = authService.checkNickname(nickname); + + // then + assertThat(response.available()).isTrue(); + } + + @Test + @DisplayName("checkNickname: DB에 닉네임이 있으면 available=false") + void checkNickname_notAvailable() { + // given + String nickname = "우주탐험가"; + given(memberRepository.existsByNickname(nickname)).willReturn(true); + + // when + CheckNicknameResponse response = authService.checkNickname(nickname); + + // then + assertThat(response.available()).isFalse(); + } +} +``` + +- [ ] **Step 2: 테스트 실행 → 실패 확인** + +Run: `./gradlew :SS-Auth:test --tests com.elipair.spacestudyship.auth.service.AuthServiceTest` +Expected: 컴파일 실패 (`authService.checkNickname` 메서드 없음) + +- [ ] **Step 3: `AuthService`에 `checkNickname` 구현** + +`AuthService.java`의 클래스 본문에 다음 메서드 추가 (기존 `logout` 뒤, 클래스 닫는 중괄호 전): + +```java +/** + * 닉네임 중복 확인 + */ +@Transactional(readOnly = true) +public CheckNicknameResponse checkNickname(String nickname) { + boolean exists = memberRepository.existsByNickname(nickname); + return new CheckNicknameResponse(!exists); +} +``` + +파일 상단 import 구문에 필요 시 추가: +```java +import com.elipair.spacestudyship.auth.dto.CheckNicknameResponse; +``` +(이미 `com.elipair.spacestudyship.auth.dto.*` 와일드카드 import가 있으면 생략) + +- [ ] **Step 4: 테스트 실행 → 통과 확인** + +Run: `./gradlew :SS-Auth:test --tests com.elipair.spacestudyship.auth.service.AuthServiceTest` +Expected: 2 tests passed + +- [ ] **Step 5: 커밋** + +```bash +git add SS-Auth/src/test/java/com/elipair/spacestudyship/auth/service/AuthServiceTest.java \ + SS-Auth/src/main/java/com/elipair/spacestudyship/auth/service/AuthService.java +git commit -m "닉네임 중복 확인 및 닉네임 변경 API 구현 : feat : AuthService.checkNickname 추가 https://github.com/SpaceStudyShip/SpaceStudyShip-BE/issues/21" +``` + +--- + +## Task 4: `AuthService.updateNickname` 구현 (TDD) + +**Files:** +- Modify: `SS-Auth/src/test/java/com/elipair/spacestudyship/auth/service/AuthServiceTest.java` (테스트 추가) +- Modify: `SS-Auth/src/main/java/com/elipair/spacestudyship/auth/service/AuthService.java` (메서드 추가) + +- [ ] **Step 1: 실패 테스트 추가** + +`AuthServiceTest.java` 상단 import에 추가: + +```java +import com.elipair.spacestudyship.auth.dto.UpdateNicknameRequest; +import com.elipair.spacestudyship.auth.dto.UpdateNicknameResponse; +import com.elipair.spacestudyship.common.exception.CustomException; +import com.elipair.spacestudyship.common.exception.ErrorCode; +import com.elipair.spacestudyship.member.entity.Member; + +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +``` + +**참고**: `MemberRepository.getByMemberId`는 `default` 인터페이스 메서드다. Mockito는 default method body를 실행하지 않고 바로 mock으로 감싸기 때문에, `findById`가 아닌 `getByMemberId`를 직접 stub해야 한다. + +클래스 본문 말미(닫는 중괄호 전)에 다음 테스트 3개 추가: + +```java +@Test +@DisplayName("updateNickname: 중복 없으면 닉네임 변경 성공") +void updateNickname_success() { + // given + Long memberId = 1L; + String newNickname = "우주탐험가"; + UpdateNicknameRequest request = new UpdateNicknameRequest(newNickname); + Member member = Member.builder() + .id(memberId) + .socialId("social-id") + .socialType(SocialType.GOOGLE) + .nickname("기존닉네임") + .build(); + given(memberRepository.existsByNickname(newNickname)).willReturn(false); + given(memberRepository.getByMemberId(memberId)).willReturn(member); + + // when + UpdateNicknameResponse response = authService.updateNickname(memberId, request); + + // then + assertThat(response.nickname()).isEqualTo(newNickname); + assertThat(member.getNickname()).isEqualTo(newNickname); +} + +@Test +@DisplayName("updateNickname: 이미 사용 중인 닉네임이면 DUPLICATED_NICKNAME") +void updateNickname_duplicated() { + // given + Long memberId = 1L; + String newNickname = "우주탐험가"; + UpdateNicknameRequest request = new UpdateNicknameRequest(newNickname); + given(memberRepository.existsByNickname(newNickname)).willReturn(true); + + // when / then + assertThatThrownBy(() -> authService.updateNickname(memberId, request)) + .isInstanceOf(CustomException.class) + .extracting("errorCode").isEqualTo(ErrorCode.DUPLICATED_NICKNAME); + verify(memberRepository, never()).getByMemberId(any()); +} + +@Test +@DisplayName("updateNickname: 회원이 없으면 MEMBER_NOT_FOUND") +void updateNickname_memberNotFound() { + // given + Long memberId = 1L; + String newNickname = "우주탐험가"; + UpdateNicknameRequest request = new UpdateNicknameRequest(newNickname); + given(memberRepository.existsByNickname(newNickname)).willReturn(false); + given(memberRepository.getByMemberId(memberId)) + .willThrow(new CustomException(ErrorCode.MEMBER_NOT_FOUND)); + + // when / then + assertThatThrownBy(() -> authService.updateNickname(memberId, request)) + .isInstanceOf(CustomException.class) + .extracting("errorCode").isEqualTo(ErrorCode.MEMBER_NOT_FOUND); +} +``` + +- [ ] **Step 2: 테스트 실행 → 실패 확인** + +Run: `./gradlew :SS-Auth:test --tests com.elipair.spacestudyship.auth.service.AuthServiceTest` +Expected: 컴파일 실패 (`authService.updateNickname` 메서드 없음) + +- [ ] **Step 3: `AuthService`에 `updateNickname` 구현** + +`AuthService.java`의 `checkNickname` 바로 뒤에 다음 메서드 추가: + +```java +/** + * 닉네임 변경 + */ +@Transactional +public UpdateNicknameResponse updateNickname(Long memberId, UpdateNicknameRequest request) { + if (memberRepository.existsByNickname(request.nickname())) { + throw new CustomException(ErrorCode.DUPLICATED_NICKNAME); + } + Member member = memberRepository.getByMemberId(memberId); + member.updateNickname(request.nickname()); + return new UpdateNicknameResponse(member.getNickname()); +} +``` + +필요한 import 추가 (와일드카드로 이미 커버되면 생략): +```java +import com.elipair.spacestudyship.auth.dto.UpdateNicknameRequest; +import com.elipair.spacestudyship.auth.dto.UpdateNicknameResponse; +``` + +- [ ] **Step 4: 테스트 실행 → 전체 통과 확인** + +Run: `./gradlew :SS-Auth:test --tests com.elipair.spacestudyship.auth.service.AuthServiceTest` +Expected: 5 tests passed + +- [ ] **Step 5: 커밋** + +```bash +git add SS-Auth/src/test/java/com/elipair/spacestudyship/auth/service/AuthServiceTest.java \ + SS-Auth/src/main/java/com/elipair/spacestudyship/auth/service/AuthService.java +git commit -m "닉네임 중복 확인 및 닉네임 변경 API 구현 : feat : AuthService.updateNickname 추가 https://github.com/SpaceStudyShip/SpaceStudyShip-BE/issues/21" +``` + +--- + +## Task 5: `AuthController.checkNickname` 엔드포인트 구현 (TDD - 슬라이스) + +**Test 전략 메모:** `@WebMvcTest`는 `application.yml`의 `prod` 프로파일과 Firebase/Flyway 등 외부 의존성 때문에 컨텍스트 로드가 까다로워질 수 있음. 대신 `MockMvcBuilders.standaloneSetup`으로 ArgumentResolver와 ControllerAdvice만 직접 주입해 가볍게 구성한다. 이 방식은 `@RequestBody @Valid`, `@ModelAttribute @Valid` 검증이 모두 정상 동작함. + +**Files:** +- Create: `SS-Web/src/test/java/com/elipair/spacestudyship/controller/auth/AuthControllerTest.java` +- Modify: `SS-Web/src/main/java/com/elipair/spacestudyship/controller/auth/AuthController.java` (GET 엔드포인트 추가) + +- [ ] **Step 1: 실패 테스트 작성 (`AuthControllerTest.java` 신규)** + +```java +package com.elipair.spacestudyship.controller.auth; + +import com.elipair.spacestudyship.auth.dto.CheckNicknameResponse; +import com.elipair.spacestudyship.auth.interceptor.LoginMember; +import com.elipair.spacestudyship.auth.interceptor.LoginMemberArgumentResolver; +import com.elipair.spacestudyship.auth.service.AuthService; +import com.elipair.spacestudyship.common.exception.GlobalExceptionHandler; +import org.junit.jupiter.api.BeforeEach; +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 org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.setup.MockMvcBuilders; + +import static org.mockito.BDDMockito.given; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +@ExtendWith(MockitoExtension.class) +class AuthControllerTest { + + @Mock + AuthService authService; + + MockMvc mockMvc; + + @BeforeEach + void setUp() { + mockMvc = MockMvcBuilders.standaloneSetup(new AuthController(authService)) + .setCustomArgumentResolvers(new LoginMemberArgumentResolver()) + .setControllerAdvice(new GlobalExceptionHandler()) + .build(); + } + + // ========== GET /api/auth/check-nickname ========== + + @Test + @DisplayName("checkNickname: 정상 요청이면 200과 available 반환") + void checkNickname_success() throws Exception { + // given + given(authService.checkNickname("우주탐험가")) + .willReturn(new CheckNicknameResponse(true)); + + // when / then + mockMvc.perform(get("/api/auth/check-nickname") + .param("nickname", "우주탐험가") + .requestAttr("loginMember", new LoginMember(1L))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.available").value(true)); + } + + @Test + @DisplayName("checkNickname: nickname 파라미터가 없으면 400") + void checkNickname_missingParam() throws Exception { + mockMvc.perform(get("/api/auth/check-nickname") + .requestAttr("loginMember", new LoginMember(1L))) + .andExpect(status().isBadRequest()); + } + + @Test + @DisplayName("checkNickname: 1자(길이 미달)이면 400") + void checkNickname_tooShort() throws Exception { + mockMvc.perform(get("/api/auth/check-nickname") + .param("nickname", "가") + .requestAttr("loginMember", new LoginMember(1L))) + .andExpect(status().isBadRequest()); + } + + @Test + @DisplayName("checkNickname: 11자(길이 초과)이면 400") + void checkNickname_tooLong() throws Exception { + mockMvc.perform(get("/api/auth/check-nickname") + .param("nickname", "일이삼사오육칠팔구십일") + .requestAttr("loginMember", new LoginMember(1L))) + .andExpect(status().isBadRequest()); + } + + @Test + @DisplayName("checkNickname: 특수문자가 포함되면 400") + void checkNickname_invalidCharacter() throws Exception { + mockMvc.perform(get("/api/auth/check-nickname") + .param("nickname", "우주!탐험") + .requestAttr("loginMember", new LoginMember(1L))) + .andExpect(status().isBadRequest()); + } + + @Test + @DisplayName("checkNickname: 인증 정보가 없으면 401") + void checkNickname_unauthenticated() throws Exception { + mockMvc.perform(get("/api/auth/check-nickname") + .param("nickname", "우주탐험가")) + .andExpect(status().isUnauthorized()); + } +} +``` + +- [ ] **Step 2: 테스트 실행 → 실패 확인** + +Run: `./gradlew :SS-Web:test --tests com.elipair.spacestudyship.controller.auth.AuthControllerTest` +Expected: 컴파일 실패 또는 404 (GET 엔드포인트 없음) + +- [ ] **Step 3: `AuthController`에 GET 엔드포인트 추가** + +import 추가: +```java +import com.elipair.spacestudyship.auth.dto.CheckNicknameRequest; +import com.elipair.spacestudyship.auth.dto.CheckNicknameResponse; +import com.elipair.spacestudyship.auth.dto.UpdateNicknameRequest; +import com.elipair.spacestudyship.auth.dto.UpdateNicknameResponse; +import com.elipair.spacestudyship.auth.interceptor.AuthMember; +import com.elipair.spacestudyship.auth.interceptor.LoginMember; +import org.springframework.web.bind.annotation.ModelAttribute; +``` + +클래스 본문(기존 `logout` 아래)에 다음 메서드 추가: + +```java +@Operation(summary = "닉네임 중복 확인") +@GetMapping("/check-nickname") +public ResponseEntity checkNickname( + @AuthMember LoginMember loginMember, + @Valid @ModelAttribute CheckNicknameRequest request) { + return ResponseEntity.ok(authService.checkNickname(request.nickname())); +} +``` + +- [ ] **Step 4: 테스트 실행 → 전체 통과 확인** + +Run: `./gradlew :SS-Web:test --tests com.elipair.spacestudyship.controller.auth.AuthControllerTest` +Expected: 6 tests passed + +- [ ] **Step 5: 커밋** + +```bash +git add SS-Web/src/test/java/com/elipair/spacestudyship/controller/auth/AuthControllerTest.java \ + SS-Web/src/main/java/com/elipair/spacestudyship/controller/auth/AuthController.java +git commit -m "닉네임 중복 확인 및 닉네임 변경 API 구현 : feat : GET /api/auth/check-nickname 엔드포인트 추가 https://github.com/SpaceStudyShip/SpaceStudyShip-BE/issues/21" +``` + +--- + +## Task 6: `AuthController.updateNickname` 엔드포인트 구현 (TDD - 슬라이스) + +**Files:** +- Modify: `SS-Web/src/test/java/com/elipair/spacestudyship/controller/auth/AuthControllerTest.java` (테스트 추가) +- Modify: `SS-Web/src/main/java/com/elipair/spacestudyship/controller/auth/AuthController.java` (PATCH 엔드포인트 추가) + +- [ ] **Step 1: 실패 테스트 추가** + +`AuthControllerTest.java` 상단 import에 추가: + +```java +import com.elipair.spacestudyship.auth.dto.UpdateNicknameRequest; +import com.elipair.spacestudyship.auth.dto.UpdateNicknameResponse; +import com.elipair.spacestudyship.common.exception.CustomException; +import com.elipair.spacestudyship.common.exception.ErrorCode; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.springframework.http.MediaType; + +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.patch; +``` + +클래스 본문 필드 영역에 ObjectMapper 추가: + +```java +ObjectMapper objectMapper = new ObjectMapper(); +``` + +클래스 본문 말미(닫는 중괄호 전)에 다음 테스트 5개 추가: + +```java +// ========== PATCH /api/auth/nickname ========== + +@Test +@DisplayName("updateNickname: 정상 요청이면 200과 바뀐 nickname 반환") +void updateNickname_success() throws Exception { + // given + UpdateNicknameRequest body = new UpdateNicknameRequest("우주탐험가"); + given(authService.updateNickname(1L, body)) + .willReturn(new UpdateNicknameResponse("우주탐험가")); + + // when / then + mockMvc.perform(patch("/api/auth/nickname") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(body)) + .requestAttr("loginMember", new LoginMember(1L))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.nickname").value("우주탐험가")); +} + +@Test +@DisplayName("updateNickname: 1자 닉네임이면 400") +void updateNickname_tooShort() throws Exception { + UpdateNicknameRequest body = new UpdateNicknameRequest("가"); + + mockMvc.perform(patch("/api/auth/nickname") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(body)) + .requestAttr("loginMember", new LoginMember(1L))) + .andExpect(status().isBadRequest()); +} + +@Test +@DisplayName("updateNickname: 특수문자 포함이면 400") +void updateNickname_invalidCharacter() throws Exception { + UpdateNicknameRequest body = new UpdateNicknameRequest("우주!탐험"); + + mockMvc.perform(patch("/api/auth/nickname") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(body)) + .requestAttr("loginMember", new LoginMember(1L))) + .andExpect(status().isBadRequest()); +} + +@Test +@DisplayName("updateNickname: 중복 닉네임이면 409") +void updateNickname_duplicated() throws Exception { + UpdateNicknameRequest body = new UpdateNicknameRequest("우주탐험가"); + given(authService.updateNickname(1L, body)) + .willThrow(new CustomException(ErrorCode.DUPLICATED_NICKNAME)); + + mockMvc.perform(patch("/api/auth/nickname") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(body)) + .requestAttr("loginMember", new LoginMember(1L))) + .andExpect(status().isConflict()); +} + +@Test +@DisplayName("updateNickname: 인증 정보가 없으면 401") +void updateNickname_unauthenticated() throws Exception { + UpdateNicknameRequest body = new UpdateNicknameRequest("우주탐험가"); + + mockMvc.perform(patch("/api/auth/nickname") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(body))) + .andExpect(status().isUnauthorized()); +} +``` + +- [ ] **Step 2: 테스트 실행 → 실패 확인** + +Run: `./gradlew :SS-Web:test --tests com.elipair.spacestudyship.controller.auth.AuthControllerTest` +Expected: 404 또는 405 (PATCH 엔드포인트 없음) + +- [ ] **Step 3: `AuthController`에 PATCH 엔드포인트 추가** + +클래스 본문(기존 GET checkNickname 아래)에 다음 메서드 추가: + +```java +@Operation(summary = "닉네임 변경") +@PatchMapping("/nickname") +public ResponseEntity updateNickname( + @AuthMember LoginMember loginMember, + @RequestBody @Valid UpdateNicknameRequest request) { + return ResponseEntity.ok(authService.updateNickname(loginMember.memberId(), request)); +} +``` + +- [ ] **Step 4: 테스트 실행 → 전체 통과 확인** + +Run: `./gradlew :SS-Web:test --tests com.elipair.spacestudyship.controller.auth.AuthControllerTest` +Expected: 11 tests passed (GET 6개 + PATCH 5개) + +- [ ] **Step 5: 커밋** + +```bash +git add SS-Web/src/test/java/com/elipair/spacestudyship/controller/auth/AuthControllerTest.java \ + SS-Web/src/main/java/com/elipair/spacestudyship/controller/auth/AuthController.java +git commit -m "닉네임 중복 확인 및 닉네임 변경 API 구현 : feat : PATCH /api/auth/nickname 엔드포인트 추가 https://github.com/SpaceStudyShip/SpaceStudyShip-BE/issues/21" +``` + +--- + +## Task 7: 전체 빌드/테스트 검증 + +- [ ] **Step 1: 전체 테스트 실행** + +Run: `./gradlew test` +Expected: 전체 테스트 통과. 신규 테스트: +- `AuthServiceTest` 5개 +- `AuthControllerTest` 11개 + +기존 `SpaceStudyShipApplicationTests.contextLoads()`는 `prod` 프로파일이 활성이면 환경 변수/외부 의존성 때문에 실패할 수 있음 — 그 경우 **이전과 동일하게 실패**하는 것이지 이번 변경의 회귀가 아닌지 확인한다. + +- [ ] **Step 2: 전체 빌드 실행** + +Run: `./gradlew build` +Expected: `BUILD SUCCESSFUL` +(만약 기존에 깨져 있던 `SpaceStudyShipApplicationTests` 때문에 실패하면 해당 실패는 이번 변경과 무관함을 주석으로 남기고 태스크 완료 — 이 테스트는 이 이슈 범위가 아님) + +- [ ] **Step 3: 수동 검증 (선택)** + +브랜치를 `main`과 비교해 진단: + +```bash +git diff main --stat +``` + +Expected: 의도한 파일들만 변경됨 (SecurityUrls, WebConfig, AuthController, AuthService, dto/, 테스트 2개). + +--- + +## 수용 기준 체크리스트 + +- [ ] `GET /api/auth/check-nickname?nickname=...`이 200 `{available}`를 반환 +- [ ] `PATCH /api/auth/nickname`이 200 `{nickname}`를 반환 +- [ ] 닉네임 형식 위반(길이/문자) 시 400 +- [ ] 중복 닉네임 변경 시도 시 409 +- [ ] 인증 없는 요청은 401 +- [ ] `/api/auth` 하위의 공개 경로는 `login`, `reissue`로 한정 +- [ ] `AuthServiceTest` 5개 모두 통과 +- [ ] `AuthControllerTest` 11개 모두 통과 +- [ ] 빌드 전체 녹색 diff --git a/docs/superpowers/plans/2026-05-11-withdraw-api.md b/docs/superpowers/plans/2026-05-11-withdraw-api.md new file mode 100644 index 0000000..ddf1f1b --- /dev/null +++ b/docs/superpowers/plans/2026-05-11-withdraw-api.md @@ -0,0 +1,826 @@ +# 회원 탈퇴 API Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** `DELETE /api/auth/withdraw` 엔드포인트를 구현하여, 인증된 사용자의 DB row, Redis refresh token, Firebase Authentication 사용자를 모두 삭제한다 (멱등성 유지). + +**Architecture:** `AuthController` → `AuthService.withdraw(memberId)` → ① DB 삭제 (`@Transactional` 안) ② Redis 삭제 ③ Firebase 삭제 순서. Redis/Firebase 호출은 try/catch로 격리해 외부 시스템 장애가 DB 롤백을 일으키지 않도록 한다. Firebase 예외(USER_NOT_FOUND 포함 모든 예외)는 로그만 남기고 204 응답. + +**Tech Stack:** Spring Boot 4.0.2, Java 21, Gradle 멀티모듈, Spring Data JPA, Redis (refresh token), Firebase Admin SDK 9.4.3, JUnit 5 + Mockito + BDDMockito, MockMvc + +**Spec:** [`docs/superpowers/specs/2026-05-11-withdraw-api-design.md`](../specs/2026-05-11-withdraw-api-design.md) +**Issue:** [#22 회원 탈퇴 API 구현](https://github.com/SpaceStudyShip/SpaceStudyShip-BE/issues/22) +**Branch:** `20260422_#22_회원_탈퇴_API_구현` + +--- + +## 사전 검증 (작업 시작 전) + +- [ ] **현재 브랜치 확인** + +```bash +git branch --show-current +``` +Expected: `20260422_#22_회원_탈퇴_API_구현` + +- [ ] **`.gitignore`에 Firebase 패턴 포함되어 있는지 확인** + +```bash +grep -n "firebase" .gitignore +``` +Expected: +``` +**/firebase/*.json +*-firebase-adminsdk-*.json +``` +(없으면 앞 단계에서 빠진 것 — 추가하고 시작) + +- [ ] **Firebase Admin SDK 키 파일이 디스크에 존재하는지 + git에 추적 안 되는지 확인** + +```bash +ls -la SS-Web/src/main/resources/firebase/ +git status --short SS-Web/src/main/resources/firebase/ +``` +Expected: 파일 존재(`spacestudyship-firebase-adminsdk-fbsvc-7e86c5c253.json`), `git status`엔 안 나타남. + +- [ ] **전체 테스트가 현재 통과하는지 확인 (기준선)** + +```bash +./gradlew test +``` +Expected: BUILD SUCCESSFUL (실패하는 테스트가 있다면 본 작업과 별개로 먼저 조사) + +--- + +## Task 1: Firebase Admin SDK 의존성 추가 + +**Files:** +- Modify: `SS-Auth/build.gradle` + +- [ ] **Step 1: `SS-Auth/build.gradle`에 firebase-admin 의존성 추가** + +기존 `dependencies` 블록 끝에 한 줄 추가: + +```gradle +bootJar { + enabled = false +} + +jar { + enabled = true + archiveClassifier = '' +} + +dependencies { + api project(':SS-Common') + api project(':SS-Member') + + // JWT + implementation 'io.jsonwebtoken:jjwt-api:0.12.6' + runtimeOnly 'io.jsonwebtoken:jjwt-impl:0.12.6' + runtimeOnly 'io.jsonwebtoken:jjwt-jackson:0.12.6' + + // Firebase Admin SDK + implementation 'com.google.firebase:firebase-admin:9.4.3' +} +``` + +- [ ] **Step 2: 의존성 다운로드 검증** + +```bash +./gradlew :SS-Auth:dependencies --configuration runtimeClasspath | grep firebase +``` +Expected: +``` ++--- com.google.firebase:firebase-admin:9.4.3 +``` +(여러 transitive 의존성도 함께 나타남 — `com.google.auth:google-auth-library-oauth2-http`, `com.google.api-client:google-api-client` 등) + +만약 버전 충돌이나 다운로드 실패가 나면 9.2.0 / 9.3.0 등 인접 버전으로 조정. 인터넷 차단 환경이면 Gradle 캐시 / 사내 미러 확인. + +- [ ] **Step 3: SS-Auth 모듈 컴파일 확인** + +```bash +./gradlew :SS-Auth:compileJava +``` +Expected: BUILD SUCCESSFUL + +- [ ] **Step 4: 커밋** + +```bash +git add SS-Auth/build.gradle +git commit -m "회원 탈퇴 API 구현 : chore : Firebase Admin SDK 의존성 추가 https://github.com/SpaceStudyShip/SpaceStudyShip-BE/issues/22" +``` + +--- + +## Task 2: `.gitignore` 정리 + Firebase 키 staging 상태 정리 + +`.gitignore` 변경은 앞 단계에서 이미 디스크 반영됐지만 아직 커밋 전. 키 파일이 다시 staged되어 있지 않은지 재확인 후 커밋한다. + +**Files:** +- Modify: `.gitignore` (이미 변경됨, 커밋만) + +- [ ] **Step 1: 현재 상태 재확인** + +```bash +git status --short +grep -n "firebase" .gitignore +``` +Expected: +- `.gitignore`가 `modified`로 잡힘 +- Firebase 키 json 파일은 `git status` 결과에 **안 나타나야 함** (gitignored) +- `.gitignore`에 `**/firebase/*.json`과 `*-firebase-adminsdk-*.json` 두 줄이 보여야 함 + +- [ ] **Step 2: 키 파일이 실수로 staged되지 않았는지 한 번 더 확인** + +```bash +git ls-files --error-unmatch SS-Web/src/main/resources/firebase/spacestudyship-firebase-adminsdk-fbsvc-7e86c5c253.json 2>&1 +``` +Expected: `error: pathspec '...' did not match any file(s) known to git` — 즉 추적 안 됨. +만약 추적되고 있다면: `git rm --cached ` 후 다시 확인. + +- [ ] **Step 3: 커밋** + +```bash +git add .gitignore +git commit -m "회원 탈퇴 API 구현 : chore : .gitignore에 Firebase Admin SDK 키 패턴 추가 https://github.com/SpaceStudyShip/SpaceStudyShip-BE/issues/22" +``` + +--- + +## Task 3: `application.yml`에 Firebase 키 경로 설정 + +**Files:** +- Modify: `SS-Web/src/main/resources/application.yml` + +- [ ] **Step 1: `application.yml` 끝에 firebase 블록 추가** + +기존 파일 끝(`management:` 블록 뒤)에 다음을 추가: + +```yaml +# Firebase Admin SDK +firebase: + admin-sdk-path: classpath:firebase/spacestudyship-firebase-adminsdk-fbsvc-7e86c5c253.json +``` + +전체 파일 끝부분이 다음과 같이 되어야 한다: + +```yaml +# Actuator (공통) +management: + endpoints: + web: + exposure: + include: health + +# Firebase Admin SDK +firebase: + admin-sdk-path: classpath:firebase/spacestudyship-firebase-adminsdk-fbsvc-7e86c5c253.json +``` + +- [ ] **Step 2: 키 파일이 클래스패스에서 접근 가능한지 확인** + +`SS-Web/src/main/resources/firebase/` 디렉토리에 키 파일이 있어야 함. (앞 단계에서 이미 처리되어 있을 것) + +```bash +ls -la SS-Web/src/main/resources/firebase/ +``` +Expected: 키 json 파일 존재. + +- [ ] **Step 3: 커밋** + +```bash +git add SS-Web/src/main/resources/application.yml +git commit -m "회원 탈퇴 API 구현 : chore : application.yml에 Firebase Admin SDK 키 경로 설정 추가 https://github.com/SpaceStudyShip/SpaceStudyShip-BE/issues/22" +``` + +--- + +## Task 4: `FirebaseConfig` Bean 생성 + +**Files:** +- Create: `SS-Auth/src/main/java/com/elipair/spacestudyship/auth/firebase/FirebaseConfig.java` + +- [ ] **Step 1: 패키지 디렉토리 생성** + +```bash +mkdir -p SS-Auth/src/main/java/com/elipair/spacestudyship/auth/firebase +``` + +- [ ] **Step 2: `FirebaseConfig.java` 작성** + +파일 경로: `SS-Auth/src/main/java/com/elipair/spacestudyship/auth/firebase/FirebaseConfig.java` + +```java +package com.elipair.spacestudyship.auth.firebase; + +import com.google.auth.oauth2.GoogleCredentials; +import com.google.firebase.FirebaseApp; +import com.google.firebase.FirebaseOptions; +import com.google.firebase.auth.FirebaseAuth; +import jakarta.annotation.PostConstruct; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.core.io.Resource; + +import java.io.IOException; +import java.io.InputStream; + +@Slf4j +@Configuration +@RequiredArgsConstructor +public class FirebaseConfig { + + @Value("${firebase.admin-sdk-path}") + private Resource credentialsResource; + + @PostConstruct + public void initializeFirebaseApp() throws IOException { + if (!FirebaseApp.getApps().isEmpty()) { + log.info("[FirebaseConfig] FirebaseApp 이미 초기화됨, 스킵"); + return; + } + try (InputStream stream = credentialsResource.getInputStream()) { + FirebaseOptions options = FirebaseOptions.builder() + .setCredentials(GoogleCredentials.fromStream(stream)) + .build(); + FirebaseApp.initializeApp(options); + log.info("[FirebaseConfig] FirebaseApp 초기화 완료"); + } + } + + @Bean + public FirebaseAuth firebaseAuth() { + return FirebaseAuth.getInstance(); + } +} +``` + +**왜 이렇게 작성하는가:** +- `@Value`의 `Resource` 타입은 Spring이 `classpath:`/`file:` prefix를 자동 리졸션. +- `@PostConstruct`로 빈 생성 직후 1회 초기화. `FirebaseApp.getApps().isEmpty()` 가드로 중복 초기화 방지. +- `FirebaseAuth`를 `@Bean`으로 노출 → `AuthService`에서 생성자 주입 가능. +- 키 파일 누락/파싱 실패 시 `IOException`이 던져지면서 애플리케이션 기동이 fail-fast. + +- [ ] **Step 3: 컴파일 확인** + +```bash +./gradlew :SS-Auth:compileJava +``` +Expected: BUILD SUCCESSFUL + +- [ ] **Step 4: 애플리케이션 기동 확인 (FirebaseApp 초기화 검증)** + +```bash +./gradlew :SS-Web:bootRun --args='--spring.profiles.active=dev' & +``` +잠시 대기 후 로그에서 다음을 확인: +``` +[FirebaseConfig] FirebaseApp 초기화 완료 +``` +그리고 `Tomcat started on port 8080` 메시지. + +확인 후 종료: +```bash +# 다른 터미널이라면 +pkill -f "SS-Web" +# 같은 터미널이면 fg로 가져와서 Ctrl+C +``` + +**대안 (백그라운드 실행이 부담스러우면):** +이미 `--spring.profiles.active=dev`로 사용자가 jar를 실행 중인 상태가 있다면 jar를 재빌드 후 재기동: + +```bash +./gradlew :SS-Web:bootJar +java -jar SS-Web/build/libs/app.jar --spring.profiles.active=dev +``` +같은 로그 확인 후 Ctrl+C. + +- [ ] **Step 5: 커밋** + +```bash +git add SS-Auth/src/main/java/com/elipair/spacestudyship/auth/firebase/FirebaseConfig.java +git commit -m "회원 탈퇴 API 구현 : feat : FirebaseConfig 빈 추가 (FirebaseApp 초기화) https://github.com/SpaceStudyShip/SpaceStudyShip-BE/issues/22" +``` + +--- + +## Task 5: `AuthService.withdraw()` — 정상 케이스 (TDD) + +**Files:** +- Modify: `SS-Auth/src/test/java/com/elipair/spacestudyship/auth/service/AuthServiceTest.java` +- Modify: `SS-Auth/src/main/java/com/elipair/spacestudyship/auth/service/AuthService.java` + +- [ ] **Step 1: AuthServiceTest에 FirebaseAuth mock 필드 추가** + +`AuthServiceTest.java` 클래스 상단 mock 필드 영역에 다음 한 줄 추가: + +```java + @Mock + com.google.firebase.auth.FirebaseAuth firebaseAuth; +``` + +(또는 import 추가:) +```java +import com.google.firebase.auth.FirebaseAuth; +``` + +추가 후 mock 필드 블록은 다음과 같이 됨: + +```java + @Mock + MemberRepository memberRepository; + @Mock + RefreshTokenRepository refreshTokenRepository; + @Mock + JwtTokenProvider jwtTokenProvider; + @Mock + RandomNicknameGenerator randomNicknameGenerator; + @Mock + Map socialLoginStrategies; + @Mock + FirebaseAuth firebaseAuth; +``` + +- [ ] **Step 2: 실패 테스트 작성 — `withdraw_success`** + +`AuthServiceTest.java`의 마지막 `}` 직전(클래스 닫는 중괄호 직전)에 다음 테스트 추가: + +```java + @Test + @DisplayName("withdraw: Member 존재 시 DB/Redis/Firebase 모두 삭제") + void withdraw_success() throws Exception { + // given + Long memberId = 1L; + String socialId = "firebase-uid-123"; + Member member = Member.builder() + .id(memberId) + .socialId(socialId) + .socialType(SocialType.GOOGLE) + .nickname("탈퇴할회원") + .build(); + given(memberRepository.findById(memberId)).willReturn(java.util.Optional.of(member)); + + // when + authService.withdraw(memberId); + + // then + verify(memberRepository).delete(member); + verify(refreshTokenRepository).delete(memberId); + verify(firebaseAuth).deleteUser(socialId); + } +``` + +- [ ] **Step 3: 테스트 실행 → FAIL 확인** + +```bash +./gradlew :SS-Auth:test --tests "com.elipair.spacestudyship.auth.service.AuthServiceTest.withdraw_success" +``` +Expected: COMPILATION FAILURE (`withdraw` 메서드 없음) 또는 컴파일은 통과해도 `firebaseAuth` 필드가 service에 주입 안 됨. + +- [ ] **Step 4: `AuthService`에 FirebaseAuth 의존성 + `withdraw()` 메서드 추가** + +먼저 import 추가: + +```java +import com.google.firebase.auth.FirebaseAuth; +``` + +생성자 주입 필드 영역에 한 줄 추가 (`@RequiredArgsConstructor`가 자동 처리): + +```java + private final FirebaseAuth firebaseAuth; +``` + +추가 후 필드 블록: + +```java + private final MemberRepository memberRepository; + private final RefreshTokenRepository refreshTokenRepository; + private final JwtTokenProvider jwtTokenProvider; + private final RandomNicknameGenerator randomNicknameGenerator; + private final Map socialLoginStrategies; + private final FirebaseAuth firebaseAuth; +``` + +그리고 클래스 마지막 `}` 직전에 다음 메서드 추가: + +```java + /** + * 회원 탈퇴 - DB / Redis / Firebase 사용자 삭제 + */ + @Transactional + public void withdraw(Long memberId) throws com.google.firebase.auth.FirebaseAuthException { + Member member = memberRepository.findById(memberId).orElse(null); + if (member != null) { + memberRepository.delete(member); + } + refreshTokenRepository.delete(memberId); + if (member != null) { + firebaseAuth.deleteUser(member.getSocialId()); + } + } +``` + +> **참고:** 이번 단계에선 정상 케이스만 통과시키기 위해 `FirebaseAuthException`을 `throws`로 두고, Task 7에서 try/catch로 격리하면서 signature에서 제거한다. + +- [ ] **Step 5: 테스트 실행 → PASS 확인** + +```bash +./gradlew :SS-Auth:test --tests "com.elipair.spacestudyship.auth.service.AuthServiceTest.withdraw_success" +``` +Expected: BUILD SUCCESSFUL, 1 test passed. + +- [ ] **Step 6: 커밋** + +```bash +git add SS-Auth/src/main/java/com/elipair/spacestudyship/auth/service/AuthService.java \ + SS-Auth/src/test/java/com/elipair/spacestudyship/auth/service/AuthServiceTest.java +git commit -m "회원 탈퇴 API 구현 : feat : AuthService.withdraw 정상 케이스 구현 (DB/Redis/Firebase 삭제) https://github.com/SpaceStudyShip/SpaceStudyShip-BE/issues/22" +``` + +--- + +## Task 6: `AuthService.withdraw()` — 멱등성 (Member 없음) + +**Files:** +- Modify: `SS-Auth/src/test/java/com/elipair/spacestudyship/auth/service/AuthServiceTest.java` +- Modify: `SS-Auth/src/main/java/com/elipair/spacestudyship/auth/service/AuthService.java` + +- [ ] **Step 1: 실패 테스트 작성** + +`AuthServiceTest.java`의 `withdraw_success` 바로 뒤에 추가: + +```java + @Test + @DisplayName("withdraw: Member 이미 없으면 멱등 처리 (refresh token만 삭제 시도)") + void withdraw_alreadyWithdrawn() throws Exception { + // given + Long memberId = 1L; + given(memberRepository.findById(memberId)).willReturn(java.util.Optional.empty()); + + // when + authService.withdraw(memberId); + + // then + verify(memberRepository, never()).delete(any(Member.class)); + verify(refreshTokenRepository).delete(memberId); + verify(firebaseAuth, never()).deleteUser(any()); + } +``` + +- [ ] **Step 2: 테스트 실행 → PASS 확인 (Task 5 구현이 이미 `if (member != null)` 가드를 포함하므로 통과해야 함)** + +```bash +./gradlew :SS-Auth:test --tests "com.elipair.spacestudyship.auth.service.AuthServiceTest.withdraw_alreadyWithdrawn" +``` +Expected: BUILD SUCCESSFUL, 1 test passed. + +**만약 FAIL이라면:** Task 5의 구현이 `orElseThrow`나 다른 형태로 바뀌어 있는 것 — Task 5의 Step 4 구현으로 되돌릴 것. + +- [ ] **Step 3: 커밋** + +```bash +git add SS-Auth/src/test/java/com/elipair/spacestudyship/auth/service/AuthServiceTest.java +git commit -m "회원 탈퇴 API 구현 : test : withdraw 멱등성 (Member 없음) 케이스 추가 https://github.com/SpaceStudyShip/SpaceStudyShip-BE/issues/22" +``` + +--- + +## Task 7: `AuthService.withdraw()` — Firebase 예외 처리 + signature 정리 + +Firebase 호출을 try/catch로 격리하고 service 메서드 signature에서 `throws FirebaseAuthException`을 제거한다. + +**Files:** +- Modify: `SS-Auth/src/test/java/com/elipair/spacestudyship/auth/service/AuthServiceTest.java` +- Modify: `SS-Auth/src/main/java/com/elipair/spacestudyship/auth/service/AuthService.java` + +- [ ] **Step 1: 실패 테스트 2개 작성 — Firebase USER_NOT_FOUND + 일반 오류** + +먼저 import 추가 (테스트 파일 상단): + +```java +import com.google.firebase.auth.AuthErrorCode; +import com.google.firebase.auth.FirebaseAuthException; +import static org.mockito.Mockito.mock; +import static org.mockito.BDDMockito.willThrow; +``` + +(기존에 일부 import는 이미 있을 수 있음 — 중복은 IDE로 정리) + +`AuthServiceTest.java`의 `withdraw_alreadyWithdrawn` 바로 뒤에 다음 두 테스트 추가: + +```java + @Test + @DisplayName("withdraw: Firebase USER_NOT_FOUND 예외는 무시하고 정상 완료") + void withdraw_firebaseUserNotFound() throws Exception { + // given + Long memberId = 1L; + String socialId = "firebase-uid-123"; + Member member = Member.builder() + .id(memberId) + .socialId(socialId) + .socialType(SocialType.GOOGLE) + .nickname("탈퇴할회원") + .build(); + given(memberRepository.findById(memberId)).willReturn(java.util.Optional.of(member)); + + FirebaseAuthException firebaseEx = mock(FirebaseAuthException.class); + given(firebaseEx.getAuthErrorCode()).willReturn(AuthErrorCode.USER_NOT_FOUND); + willThrow(firebaseEx).given(firebaseAuth).deleteUser(socialId); + + // when (예외 없이 정상 완료되어야 함) + authService.withdraw(memberId); + + // then + verify(memberRepository).delete(member); + verify(refreshTokenRepository).delete(memberId); + verify(firebaseAuth).deleteUser(socialId); + } + + @Test + @DisplayName("withdraw: Firebase 일반 오류도 무시하고 정상 완료 (멱등성 유지)") + void withdraw_firebaseGenericError() throws Exception { + // given + Long memberId = 1L; + String socialId = "firebase-uid-123"; + Member member = Member.builder() + .id(memberId) + .socialId(socialId) + .socialType(SocialType.GOOGLE) + .nickname("탈퇴할회원") + .build(); + given(memberRepository.findById(memberId)).willReturn(java.util.Optional.of(member)); + + FirebaseAuthException firebaseEx = mock(FirebaseAuthException.class); + given(firebaseEx.getAuthErrorCode()).willReturn(AuthErrorCode.CERTIFICATE_FETCH_FAILED); + given(firebaseEx.getMessage()).willReturn("Firebase 일시 장애"); + willThrow(firebaseEx).given(firebaseAuth).deleteUser(socialId); + + // when (예외 없이 정상 완료되어야 함) + authService.withdraw(memberId); + + // then + verify(memberRepository).delete(member); + verify(refreshTokenRepository).delete(memberId); + verify(firebaseAuth).deleteUser(socialId); + } +``` + +- [ ] **Step 2: 테스트 실행 → FAIL 확인** + +```bash +./gradlew :SS-Auth:test --tests "com.elipair.spacestudyship.auth.service.AuthServiceTest.withdraw_firebaseUserNotFound" \ + --tests "com.elipair.spacestudyship.auth.service.AuthServiceTest.withdraw_firebaseGenericError" +``` +Expected: FAIL. `authService.withdraw(memberId)`가 `FirebaseAuthException`을 던지지만, 테스트는 예외를 catch하지 않고 정상 완료를 기대하고 있어서 실패. + +- [ ] **Step 3: `AuthService.withdraw()`에 try/catch 적용 + signature에서 throws 제거** + +`AuthService.java`의 `withdraw` 메서드를 다음과 같이 교체: + +```java + /** + * 회원 탈퇴 - DB / Redis / Firebase 사용자 삭제 + * Firebase 예외는 멱등성 유지를 위해 모두 무시 (로그만 기록). + */ + @Transactional + public void withdraw(Long memberId) { + Member member = memberRepository.findById(memberId).orElse(null); + if (member != null) { + memberRepository.delete(member); + } + refreshTokenRepository.delete(memberId); + if (member != null) { + deleteFirebaseUserSafely(memberId, member.getSocialId()); + } + } + + private void deleteFirebaseUserSafely(Long memberId, String socialId) { + try { + firebaseAuth.deleteUser(socialId); + } catch (FirebaseAuthException e) { + if (e.getAuthErrorCode() == AuthErrorCode.USER_NOT_FOUND) { + log.warn("[Withdraw] Firebase 사용자 이미 없음 | memberId={}, socialId={}", + memberId, socialId); + } else { + log.error("[Withdraw] Firebase 사용자 삭제 실패 | memberId={}, socialId={}, error={}", + memberId, socialId, e.getMessage()); + } + } + } +``` + +import 추가: + +```java +import com.google.firebase.auth.AuthErrorCode; +import com.google.firebase.auth.FirebaseAuthException; +``` + +> **포인트:** +> - public signature에서 `throws FirebaseAuthException` 제거 → Controller에서 신경 안 써도 됨. +> - private helper로 분리해 책임 명확화. `withdraw()`는 흐름, helper는 외부 시스템 예외 정책. + +- [ ] **Step 4: 전체 withdraw 테스트 4개 실행 → PASS 확인** + +```bash +./gradlew :SS-Auth:test --tests "com.elipair.spacestudyship.auth.service.AuthServiceTest.withdraw_*" +``` +Expected: BUILD SUCCESSFUL, 4 tests passed (`withdraw_success`, `withdraw_alreadyWithdrawn`, `withdraw_firebaseUserNotFound`, `withdraw_firebaseGenericError`). + +- [ ] **Step 5: 커밋** + +```bash +git add SS-Auth/src/main/java/com/elipair/spacestudyship/auth/service/AuthService.java \ + SS-Auth/src/test/java/com/elipair/spacestudyship/auth/service/AuthServiceTest.java +git commit -m "회원 탈퇴 API 구현 : feat : Firebase 예외 처리 격리 (USER_NOT_FOUND/일반 오류 무시) https://github.com/SpaceStudyShip/SpaceStudyShip-BE/issues/22" +``` + +--- + +## Task 8: `AuthController` — `DELETE /api/auth/withdraw` 엔드포인트 (TDD) + +**Files:** +- Modify: `SS-Web/src/test/java/com/elipair/spacestudyship/controller/auth/AuthControllerTest.java` +- Modify: `SS-Web/src/main/java/com/elipair/spacestudyship/controller/auth/AuthController.java` + +- [ ] **Step 1: 실패 테스트 2개 작성** + +`AuthControllerTest.java`의 마지막 테스트 뒤(클래스 닫는 `}` 직전)에 추가. 먼저 import 확인/추가: + +```java +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete; +import static org.mockito.BDDMockito.willDoNothing; +``` + +테스트 코드: + +```java + // ========== DELETE /api/auth/withdraw ========== + + @Test + @DisplayName("withdraw: 정상 요청이면 204 응답하고 AuthService.withdraw 호출") + void withdraw_success() throws Exception { + // given + willDoNothing().given(authService).withdraw(1L); + + // when / then + mockMvc.perform(delete("/api/auth/withdraw") + .requestAttr("loginMember", new LoginMember(1L))) + .andExpect(status().isNoContent()); + } + + @Test + @DisplayName("withdraw: 인증 정보가 없으면 401") + void withdraw_unauthenticated() throws Exception { + mockMvc.perform(delete("/api/auth/withdraw")) + .andExpect(status().isUnauthorized()); + } +``` + +- [ ] **Step 2: 테스트 실행 → FAIL 확인** + +```bash +./gradlew :SS-Web:test --tests "com.elipair.spacestudyship.controller.auth.AuthControllerTest.withdraw_success" \ + --tests "com.elipair.spacestudyship.controller.auth.AuthControllerTest.withdraw_unauthenticated" +``` +Expected: FAIL — 엔드포인트가 없어서 404 (또는 컴파일 오류 가능). + +- [ ] **Step 3: `AuthController`에 `withdraw` 엔드포인트 추가** + +`AuthController.java` 클래스의 마지막 `}` 직전에 추가: + +```java + @Operation(summary = "회원 탈퇴") + @DeleteMapping("/withdraw") + public ResponseEntity withdraw(@AuthMember LoginMember loginMember) { + authService.withdraw(loginMember.memberId()); + return ResponseEntity.noContent().build(); + } +``` + +기존 import는 이미 모두 있음 (`DeleteMapping`은 `org.springframework.web.bind.annotation.*`로 wildcard import되어 있음). + +- [ ] **Step 4: 테스트 실행 → PASS 확인** + +```bash +./gradlew :SS-Web:test --tests "com.elipair.spacestudyship.controller.auth.AuthControllerTest.withdraw_*" +``` +Expected: BUILD SUCCESSFUL, 2 tests passed. + +- [ ] **Step 5: 커밋** + +```bash +git add SS-Web/src/main/java/com/elipair/spacestudyship/controller/auth/AuthController.java \ + SS-Web/src/test/java/com/elipair/spacestudyship/controller/auth/AuthControllerTest.java +git commit -m "회원 탈퇴 API 구현 : feat : DELETE /api/auth/withdraw 엔드포인트 추가 https://github.com/SpaceStudyShip/SpaceStudyShip-BE/issues/22" +``` + +--- + +## Task 9: 통합 검증 + +**Files:** (변경 없음 — 검증만) + +- [ ] **Step 1: 전체 테스트 실행** + +```bash +./gradlew test +``` +Expected: BUILD SUCCESSFUL. 모든 기존 테스트 + 새로 추가한 6개 테스트 통과. + +- [ ] **Step 2: 전체 빌드** + +```bash +./gradlew clean build +``` +Expected: BUILD SUCCESSFUL. + +- [ ] **Step 3: 애플리케이션 기동 후 엔드포인트 노출 확인** + +```bash +./gradlew :SS-Web:bootRun --args='--spring.profiles.active=dev' & +``` +로그에서 다음 확인: +- `[FirebaseConfig] FirebaseApp 초기화 완료` +- `Tomcat started on port 8080` + +다른 터미널에서 Swagger UI 확인: +```bash +curl -s http://localhost:8080/docs/api-docs | grep -o '"/api/auth/withdraw"' | head -1 +``` +Expected: `"/api/auth/withdraw"` 한 줄 — Swagger 문서에 엔드포인트가 잡힘. + +또는 브라우저로 `http://localhost:8080/docs/swagger` 접속해서 Auth 태그 안에 `DELETE /api/auth/withdraw`가 있는지 확인. + +- [ ] **Step 4: 인증 없이 호출 → 401 확인** + +```bash +curl -i -X DELETE http://localhost:8080/api/auth/withdraw +``` +Expected: `HTTP/1.1 401 Unauthorized` (응답 본문에 `UNAUTHENTICATED_REQUEST` 등) + +- [ ] **Step 5: 애플리케이션 종료** + +```bash +pkill -f "SS-Web" +``` +(또는 bootRun 실행 중인 터미널에서 Ctrl+C) + +- [ ] **Step 6: 스펙 문서 상태 업데이트** + +`docs/superpowers/specs/2026-05-11-withdraw-api-design.md` 4번째 줄을 수정: + +기존: +``` +- **Status**: Approved (pending user review) +``` + +변경 후: +``` +- **Status**: Implemented (2026-05-11) +``` + +- [ ] **Step 7: 최종 커밋** + +```bash +git add docs/superpowers/specs/2026-05-11-withdraw-api-design.md \ + docs/superpowers/plans/2026-05-11-withdraw-api.md +git commit -m "회원 탈퇴 API 구현 : docs : 설계/구현 계획 문서 추가 및 상태 업데이트 https://github.com/SpaceStudyShip/SpaceStudyShip-BE/issues/22" +``` + +--- + +## 작업 완료 체크리스트 + +- [ ] 모든 9개 Task의 모든 Step 체크박스 완료 +- [ ] `./gradlew test` 전체 통과 +- [ ] `./gradlew build` 전체 통과 +- [ ] `DELETE /api/auth/withdraw` 엔드포인트가 Swagger에 노출됨 +- [ ] 인증 없는 호출이 401을 돌려줌 +- [ ] Firebase 키 파일이 git 추적 대상이 아님 (`git ls-files`로 확인) +- [ ] 모든 커밋 메시지가 프로젝트 컨벤션 따름 (`{이슈제목} : {type} : {설명} {URL}`) +- [ ] 브랜치는 여전히 `20260422_#22_회원_탈퇴_API_구현` + +--- + +## 변경된 파일 요약 + +| 파일 | 변경 | +|------|------| +| `.gitignore` | Firebase 키 패턴 추가 | +| `SS-Auth/build.gradle` | `firebase-admin:9.4.3` 의존성 추가 | +| `SS-Web/src/main/resources/application.yml` | `firebase.admin-sdk-path` 설정 추가 | +| `SS-Web/src/main/resources/firebase/spacestudyship-firebase-adminsdk-fbsvc-7e86c5c253.json` | 디스크에만 (gitignored) | +| `SS-Auth/src/main/java/.../auth/firebase/FirebaseConfig.java` | 신규 — FirebaseApp 초기화 + FirebaseAuth Bean | +| `SS-Auth/src/main/java/.../auth/service/AuthService.java` | `withdraw()` 메서드 + private `deleteFirebaseUserSafely()` 추가 | +| `SS-Web/src/main/java/.../controller/auth/AuthController.java` | `DELETE /api/auth/withdraw` 엔드포인트 추가 | +| `SS-Auth/src/test/java/.../auth/service/AuthServiceTest.java` | 4개 테스트 추가 | +| `SS-Web/src/test/java/.../controller/auth/AuthControllerTest.java` | 2개 테스트 추가 | +| `docs/superpowers/specs/2026-05-11-withdraw-api-design.md` | 신규 / Status 업데이트 | +| `docs/superpowers/plans/2026-05-11-withdraw-api.md` | 신규 (본 문서) | diff --git a/docs/superpowers/plans/2026-05-12-user-device-fcm-token.md b/docs/superpowers/plans/2026-05-12-user-device-fcm-token.md new file mode 100644 index 0000000..fcbb0c5 --- /dev/null +++ b/docs/superpowers/plans/2026-05-12-user-device-fcm-token.md @@ -0,0 +1,1166 @@ +# User Device & FCM Token Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** 로그인 시 디바이스 정보(`fcmToken`, `deviceType`, `deviceId`)를 받아 `user_devices` 테이블에 저장하고, 디바이스별 Refresh Token 관리로 다중 디바이스 동시 로그인 세션을 지원한다. + +**Architecture:** `RefreshTokenRepository`(Redis, 회원당 1개)를 `UserDeviceRepository`(JPA, 디바이스별)로 대체한다. Refresh Token claim에 `did`(deviceId)를 추가하여 토큰만 보고도 어느 디바이스의 세션인지 식별할 수 있게 한다. 모든 인증 흐름(login/reissue/logout/withdraw)이 디바이스 단위로 동작하도록 수정한다. + +**Tech Stack:** Spring Boot 4.0.2, Java 21, JPA/Hibernate, **PostgreSQL**, Flyway, jjwt 0.12.6, JUnit 5, Mockito. + +**Spec:** [docs/superpowers/specs/2026-05-12-user-device-fcm-token-design.md](../specs/2026-05-12-user-device-fcm-token-design.md) + +**커밋 메시지 컨벤션 (CLAUDE.md):** +`{이슈제목} : {type} : {변경사항 설명} {이슈URL}` 형식. `type`은 feat, fix, refactor, docs, chore, test, style 중 하나. 이모지/특수기호 금지. +예: `디바이스 정보 및 FCM 토큰 저장 기능 추가 : feat : UserDevice Entity 추가 https://github.com/SpaceStudyShip/SpaceStudyShip-BE/issues/23` + +--- + +## 파일 구조 (전체 변경 사항 매핑) + +**신규 생성 (8개):** +- `SS-Auth/src/main/java/com/elipair/spacestudyship/auth/constant/DeviceType.java` — IOS/ANDROID enum +- `SS-Auth/src/main/java/com/elipair/spacestudyship/auth/entity/UserDevice.java` — JPA Entity +- `SS-Auth/src/main/java/com/elipair/spacestudyship/auth/repository/UserDeviceRepository.java` — Spring Data JPA Repository +- `SS-Auth/src/main/java/com/elipair/spacestudyship/auth/jwt/RefreshTokenPayload.java` — record (memberId, deviceId) +- `SS-Auth/src/test/java/com/elipair/spacestudyship/auth/repository/UserDeviceRepositoryTest.java` +- `SS-Auth/src/test/java/com/elipair/spacestudyship/auth/jwt/JwtTokenProviderTest.java` +- `SS-Web/src/main/resources/db/migration/V0_0_31__add_user_devices.sql` + +**수정 (5개):** +- `SS-Auth/src/main/java/com/elipair/spacestudyship/auth/dto/LoginRequest.java` +- `SS-Auth/src/main/java/com/elipair/spacestudyship/auth/jwt/JwtTokenProvider.java` +- `SS-Auth/src/main/java/com/elipair/spacestudyship/auth/service/AuthService.java` +- `SS-Auth/src/test/java/com/elipair/spacestudyship/auth/service/AuthServiceTest.java` +- `docs/api-specs/01_auth.md` + +**삭제 (1개):** +- `SS-Auth/src/main/java/com/elipair/spacestudyship/auth/repository/RefreshTokenRepository.java` + +--- + +## Task 1: DeviceType enum 추가 + +**Files:** +- Create: `SS-Auth/src/main/java/com/elipair/spacestudyship/auth/constant/DeviceType.java` + +- [ ] **Step 1: Enum 작성** + +```java +package com.elipair.spacestudyship.auth.constant; + +public enum DeviceType { + IOS, + ANDROID +} +``` + +- [ ] **Step 2: 컴파일 확인** + +Run: `./gradlew :SS-Auth:compileJava` +Expected: BUILD SUCCESSFUL + +- [ ] **Step 3: 커밋** + +```bash +git add SS-Auth/src/main/java/com/elipair/spacestudyship/auth/constant/DeviceType.java +git commit -m "디바이스_FCM토큰_저장_기능_추가 : feat : DeviceType enum 추가 (IOS, ANDROID)" +``` + +--- + +## Task 2: UserDevice Entity 추가 + +**Files:** +- Create: `SS-Auth/src/main/java/com/elipair/spacestudyship/auth/entity/UserDevice.java` + +- [ ] **Step 1: Entity 작성** + +```java +package com.elipair.spacestudyship.auth.entity; + +import com.elipair.spacestudyship.auth.constant.DeviceType; +import com.elipair.spacestudyship.common.entity.BaseTimeEntity; +import jakarta.persistence.*; +import lombok.*; + +import java.time.LocalDateTime; + +@Entity +@Table( + name = "user_devices", + uniqueConstraints = @UniqueConstraint( + name = "uk_user_devices_member_device", + columnNames = {"member_id", "device_id"} + ), + indexes = @Index(name = "idx_user_devices_member", columnList = "member_id") +) +@Getter +@Builder +@AllArgsConstructor +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class UserDevice extends BaseTimeEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(name = "member_id", nullable = false) + private Long memberId; + + @Column(name = "device_id", nullable = false, length = 255) + private String deviceId; + + @Enumerated(EnumType.STRING) + @Column(name = "device_type", nullable = false, length = 10) + private DeviceType deviceType; + + @Column(name = "fcm_token", nullable = false, length = 255) + private String fcmToken; + + @Column(name = "refresh_token", nullable = false, length = 512) + private String refreshToken; + + @Column(name = "last_login_at", nullable = false) + private LocalDateTime lastLoginAt; + + public static UserDevice register(Long memberId, String deviceId, DeviceType deviceType, + String fcmToken, String refreshToken) { + return UserDevice.builder() + .memberId(memberId) + .deviceId(deviceId) + .deviceType(deviceType) + .fcmToken(fcmToken) + .refreshToken(refreshToken) + .lastLoginAt(LocalDateTime.now()) + .build(); + } + + public void renewLogin(DeviceType deviceType, String fcmToken, String refreshToken) { + this.deviceType = deviceType; + this.fcmToken = fcmToken; + this.refreshToken = refreshToken; + this.lastLoginAt = LocalDateTime.now(); + } + + public void rotateRefreshToken(String refreshToken) { + this.refreshToken = refreshToken; + } +} +``` + +- [ ] **Step 2: 컴파일 확인** + +Run: `./gradlew :SS-Auth:compileJava` +Expected: BUILD SUCCESSFUL + +- [ ] **Step 3: 커밋** + +```bash +git add SS-Auth/src/main/java/com/elipair/spacestudyship/auth/entity/UserDevice.java +git commit -m "디바이스_FCM토큰_저장_기능_추가 : feat : UserDevice Entity 추가" +``` + +--- + +## Task 3: UserDeviceRepository + 테스트 (TDD) + +**Files:** +- Create: `SS-Auth/src/main/java/com/elipair/spacestudyship/auth/repository/UserDeviceRepository.java` +- Test: `SS-Auth/src/test/java/com/elipair/spacestudyship/auth/repository/UserDeviceRepositoryTest.java` + +- [ ] **Step 1: Repository 인터페이스 작성 (테스트 컴파일 위해 먼저)** + +```java +package com.elipair.spacestudyship.auth.repository; + +import com.elipair.spacestudyship.auth.entity.UserDevice; +import com.elipair.spacestudyship.common.exception.CustomException; +import com.elipair.spacestudyship.common.exception.ErrorCode; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.Optional; + +public interface UserDeviceRepository extends JpaRepository { + + Optional findByMemberIdAndDeviceId(Long memberId, String deviceId); + + void deleteByMemberIdAndDeviceId(Long memberId, String deviceId); + + default UserDevice getByMemberIdAndDeviceId(Long memberId, String deviceId) { + return findByMemberIdAndDeviceId(memberId, deviceId) + .orElseThrow(() -> new CustomException(ErrorCode.INVALID_TOKEN)); + } +} +``` + +- [ ] **Step 2: 실패하는 테스트 작성** + +```java +package com.elipair.spacestudyship.auth.repository; + +import com.elipair.spacestudyship.auth.constant.DeviceType; +import com.elipair.spacestudyship.auth.entity.UserDevice; +import com.elipair.spacestudyship.common.exception.CustomException; +import com.elipair.spacestudyship.common.exception.ErrorCode; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.jdbc.AutoConfigureTestDatabase; +import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest; +import org.springframework.context.annotation.Import; +import org.springframework.dao.DataIntegrityViolationException; + +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +@DataJpaTest +@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.ANY) +@Import(UserDevice.class) +class UserDeviceRepositoryTest { + + @Autowired + UserDeviceRepository userDeviceRepository; + + @Test + @DisplayName("findByMemberIdAndDeviceId: 존재하는 row 조회") + void findByMemberIdAndDeviceId_found() { + // given + UserDevice saved = userDeviceRepository.save(UserDevice.register( + 1L, "device-1", DeviceType.IOS, "fcm-token-1", "refresh-1")); + + // when + Optional found = userDeviceRepository.findByMemberIdAndDeviceId(1L, "device-1"); + + // then + assertThat(found).isPresent(); + assertThat(found.get().getId()).isEqualTo(saved.getId()); + assertThat(found.get().getFcmToken()).isEqualTo("fcm-token-1"); + } + + @Test + @DisplayName("findByMemberIdAndDeviceId: 다른 deviceId면 Optional.empty") + void findByMemberIdAndDeviceId_notFound() { + // given + userDeviceRepository.save(UserDevice.register( + 1L, "device-1", DeviceType.IOS, "fcm-token-1", "refresh-1")); + + // when + Optional found = userDeviceRepository.findByMemberIdAndDeviceId(1L, "device-999"); + + // then + assertThat(found).isEmpty(); + } + + @Test + @DisplayName("getByMemberIdAndDeviceId: 없으면 INVALID_TOKEN 예외") + void getByMemberIdAndDeviceId_throws() { + assertThatThrownBy(() -> userDeviceRepository.getByMemberIdAndDeviceId(1L, "missing")) + .isInstanceOf(CustomException.class) + .extracting("errorCode").isEqualTo(ErrorCode.INVALID_TOKEN); + } + + @Test + @DisplayName("deleteByMemberIdAndDeviceId: 해당 row만 삭제, 다른 row는 유지") + void deleteByMemberIdAndDeviceId_onlyTargetDeleted() { + // given + userDeviceRepository.save(UserDevice.register( + 1L, "device-A", DeviceType.IOS, "fcm-A", "refresh-A")); + userDeviceRepository.save(UserDevice.register( + 1L, "device-B", DeviceType.ANDROID, "fcm-B", "refresh-B")); + + // when + userDeviceRepository.deleteByMemberIdAndDeviceId(1L, "device-A"); + + // then + assertThat(userDeviceRepository.findByMemberIdAndDeviceId(1L, "device-A")).isEmpty(); + assertThat(userDeviceRepository.findByMemberIdAndDeviceId(1L, "device-B")).isPresent(); + } + + @Test + @DisplayName("(member_id, device_id) 컴포지트 unique 위반 시 DataIntegrityViolationException") + void uniqueConstraint_violation() { + // given + userDeviceRepository.save(UserDevice.register( + 1L, "device-1", DeviceType.IOS, "fcm-1", "refresh-1")); + + // when / then + assertThatThrownBy(() -> { + userDeviceRepository.saveAndFlush(UserDevice.register( + 1L, "device-1", DeviceType.ANDROID, "fcm-2", "refresh-2")); + }).isInstanceOf(DataIntegrityViolationException.class); + } + + @Test + @DisplayName("같은 device_id라도 member_id 다르면 별개 row로 공존") + void sameDeviceIdDifferentMember_coexist() { + // given / when + userDeviceRepository.save(UserDevice.register( + 1L, "shared-device", DeviceType.IOS, "fcm-A", "refresh-A")); + userDeviceRepository.save(UserDevice.register( + 2L, "shared-device", DeviceType.IOS, "fcm-B", "refresh-B")); + + // then + assertThat(userDeviceRepository.findByMemberIdAndDeviceId(1L, "shared-device")).isPresent(); + assertThat(userDeviceRepository.findByMemberIdAndDeviceId(2L, "shared-device")).isPresent(); + } +} +``` + +- [ ] **Step 3: 테스트 실행 → 실패 확인** + +Run: `./gradlew :SS-Auth:test --tests "*UserDeviceRepositoryTest*"` +Expected: 6개 테스트 모두 실행됨. `@DataJpaTest`가 SS-Auth 모듈에서 H2 인메모리로 스키마 자동 생성. 컴파일 통과되어야 함. + +> ⚠️ 만약 `@DataJpaTest`가 ApplicationContext 로딩 실패한다면(다른 모듈의 빈 의존성), `@ContextConfiguration(classes = UserDevice.class)` 또는 `@EntityScan(basePackageClasses = UserDevice.class)` + `@EnableJpaRepositories(basePackageClasses = UserDeviceRepository.class)`를 명시적으로 추가. 이 경우 `@Import(UserDevice.class)`를 다음과 같이 교체: +> ```java +> @DataJpaTest +> @AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.ANY) +> @EntityScan(basePackageClasses = UserDevice.class) +> @EnableJpaRepositories(basePackageClasses = UserDeviceRepository.class) +> ``` + +- [ ] **Step 4: 테스트 통과 확인** + +Run: `./gradlew :SS-Auth:test --tests "*UserDeviceRepositoryTest*"` +Expected: 6 tests passed + +- [ ] **Step 5: 커밋** + +```bash +git add SS-Auth/src/main/java/com/elipair/spacestudyship/auth/repository/UserDeviceRepository.java \ + SS-Auth/src/test/java/com/elipair/spacestudyship/auth/repository/UserDeviceRepositoryTest.java +git commit -m "디바이스_FCM토큰_저장_기능_추가 : feat : UserDeviceRepository 및 테스트 추가" +``` + +--- + +## Task 4: Flyway 마이그레이션 파일 + +**Files:** +- Create: `SS-Web/src/main/resources/db/migration/V0_0_31__add_user_devices.sql` + +- [ ] **Step 1: `version.yml` 현재 버전 확인** + +Run: `grep '^version:' version.yml` +Expected: `version: "0.0.30"` + +> 다음 PR 머지 시 자동으로 `0.0.31`로 올라간다. 파일명 `V0_0_31__...` 사용. + +- [ ] **Step 2: 마이그레이션 SQL 작성 (PostgreSQL 문법)** + +```sql +-- members baseline: ddl-auto=update로 이미 생성되어 있을 수 있어 IF NOT EXISTS 사용 +CREATE TABLE IF NOT EXISTS members ( + id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, + social_id VARCHAR(100) NOT NULL, + social_type VARCHAR(10) NOT NULL, + nickname VARCHAR(30) NOT NULL, + created_at TIMESTAMP NOT NULL, + updated_at TIMESTAMP NOT NULL, + CONSTRAINT uk_members_social_id_type UNIQUE (social_id, social_type), + CONSTRAINT uk_members_nickname UNIQUE (nickname) +); + +-- user_devices: 디바이스별 인증 세션 + FCM 토큰 +CREATE TABLE IF NOT EXISTS user_devices ( + id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, + member_id BIGINT NOT NULL, + device_id VARCHAR(255) NOT NULL, + device_type VARCHAR(10) NOT NULL, + fcm_token VARCHAR(255) NOT NULL, + refresh_token VARCHAR(512) NOT NULL, + last_login_at TIMESTAMP NOT NULL, + created_at TIMESTAMP NOT NULL, + updated_at TIMESTAMP NOT NULL, + CONSTRAINT uk_user_devices_member_device UNIQUE (member_id, device_id), + CONSTRAINT fk_user_devices_member FOREIGN KEY (member_id) + REFERENCES members(id) ON DELETE CASCADE +); + +CREATE INDEX IF NOT EXISTS idx_user_devices_member ON user_devices(member_id); +``` + +- [ ] **Step 3: 로컬에서 검증 (선택, 환경 있을 시)** + +Run: `./gradlew :SS-Web:bootRun --args='--spring.profiles.active=dev'` (Postgres가 떠 있을 때만) +Expected: 애플리케이션 부팅 시 Flyway가 `V0_0_31` 적용. `flyway_schema_history` 테이블에 한 줄 추가. + +> 환경 없으면 Step 3 생략. 마이그레이션 적용은 dev/prod 배포 시 자동. + +- [ ] **Step 4: 커밋** + +```bash +git add SS-Web/src/main/resources/db/migration/V0_0_31__add_user_devices.sql +git commit -m "디바이스_FCM토큰_저장_기능_추가 : chore : V0_0_31 user_devices 마이그레이션 추가" +``` + +--- + +## Task 5: RefreshTokenPayload record + JwtTokenProvider 변경 (TDD) + +**Files:** +- Create: `SS-Auth/src/main/java/com/elipair/spacestudyship/auth/jwt/RefreshTokenPayload.java` +- Create: `SS-Auth/src/test/java/com/elipair/spacestudyship/auth/jwt/JwtTokenProviderTest.java` +- Modify: `SS-Auth/src/main/java/com/elipair/spacestudyship/auth/jwt/JwtTokenProvider.java` + +- [ ] **Step 1: RefreshTokenPayload record 작성** + +```java +package com.elipair.spacestudyship.auth.jwt; + +public record RefreshTokenPayload(Long memberId, String deviceId) {} +``` + +- [ ] **Step 2: 실패하는 JwtTokenProviderTest 작성** + +```java +package com.elipair.spacestudyship.auth.jwt; + +import com.elipair.spacestudyship.common.exception.CustomException; +import com.elipair.spacestudyship.common.exception.ErrorCode; +import com.elipair.spacestudyship.member.constant.SocialType; +import com.elipair.spacestudyship.member.entity.Member; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.time.Duration; +import java.util.Base64; +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +class JwtTokenProviderTest { + + private JwtTokenProvider jwtTokenProvider; + private Member member; + + @BeforeEach + void setUp() { + // 32바이트 이상 base64 인코딩된 secret + String accessSecret = Base64.getEncoder().encodeToString( + "test-access-secret-with-32-bytes-or-more-length-padding".getBytes()); + String refreshSecret = Base64.getEncoder().encodeToString( + "test-refresh-secret-with-32-bytes-or-more-length-padding".getBytes()); + + JwtProperties props = new JwtProperties( + new JwtProperties.Token(accessSecret, Duration.ofMinutes(30)), + new JwtProperties.Token(refreshSecret, Duration.ofDays(14)) + ); + jwtTokenProvider = new JwtTokenProvider(props); + + member = Member.builder() + .id(42L) + .socialId("social-id") + .socialType(SocialType.GOOGLE) + .nickname("테스터") + .build(); + } + + @Test + @DisplayName("createRefreshToken: deviceId claim 포함하여 발급, parseRefreshToken으로 추출 가능") + void createAndParseRefreshToken() { + // given + String deviceId = "device-uuid-123"; + + // when + String token = jwtTokenProvider.createRefreshToken(member, deviceId); + RefreshTokenPayload payload = jwtTokenProvider.parseRefreshToken(token); + + // then + assertThat(payload.memberId()).isEqualTo(42L); + assertThat(payload.deviceId()).isEqualTo(deviceId); + } + + @Test + @DisplayName("parseRefreshToken: 위변조된 토큰은 INVALID_TOKEN 예외") + void parseRefreshToken_invalid() { + assertThatThrownBy(() -> jwtTokenProvider.parseRefreshToken("not-a-jwt")) + .isInstanceOf(CustomException.class) + .extracting("errorCode").isEqualTo(ErrorCode.INVALID_TOKEN); + } + + @Test + @DisplayName("parseRefreshTokenSafely: 정상 토큰 → Optional 값 반환") + void parseRefreshTokenSafely_valid() { + // given + String token = jwtTokenProvider.createRefreshToken(member, "device-1"); + + // when + Optional result = jwtTokenProvider.parseRefreshTokenSafely(token); + + // then + assertThat(result).isPresent(); + assertThat(result.get().memberId()).isEqualTo(42L); + assertThat(result.get().deviceId()).isEqualTo("device-1"); + } + + @Test + @DisplayName("parseRefreshTokenSafely: 위변조 토큰 → Optional.empty") + void parseRefreshTokenSafely_invalid() { + Optional result = jwtTokenProvider.parseRefreshTokenSafely("garbage"); + assertThat(result).isEmpty(); + } + + @Test + @DisplayName("createAccessToken / getMemberIdFromAccessToken: Access Token 동작 변경 없음") + void accessTokenStillWorks() { + String accessToken = jwtTokenProvider.createAccessToken(member); + Long extracted = jwtTokenProvider.getMemberIdFromAccessToken(accessToken); + assertThat(extracted).isEqualTo(42L); + } +} +``` + +- [ ] **Step 3: 테스트 실행 → 실패 확인** + +Run: `./gradlew :SS-Auth:test --tests "*JwtTokenProviderTest*"` +Expected: 컴파일 실패 — `createRefreshToken(Member, String)` 시그니처 없음 / `parseRefreshToken` 없음 / `parseRefreshTokenSafely` 없음. + +- [ ] **Step 4: JwtTokenProvider 수정** + +`SS-Auth/src/main/java/com/elipair/spacestudyship/auth/jwt/JwtTokenProvider.java`의 Refresh Token 섹션을 다음으로 전체 교체: + +```java + // ===== Refresh Token ===== + + private static final String CLAIM_DEVICE_ID = "did"; + + public String createRefreshToken(Member member, String deviceId) { + Date now = new Date(); + Date expiration = new Date(now.getTime() + jwtProperties.refresh().expiration().toMillis()); + + return Jwts.builder() + .subject(member.getId().toString()) + .claim(CLAIM_DEVICE_ID, deviceId) + .issuedAt(now) + .expiration(expiration) + .signWith(refreshKey) + .compact(); + } + + public RefreshTokenPayload parseRefreshToken(String refreshToken) { + Claims claims = getRefreshClaims(refreshToken); + return toPayload(claims); + } + + /** + * 로그아웃 시 사용 - 만료된 토큰에서도 (memberId, deviceId) 추출 시도 + */ + public Optional parseRefreshTokenSafely(String refreshToken) { + try { + Claims claims = Jwts.parser() + .verifyWith(refreshKey) + .build() + .parseSignedClaims(refreshToken) + .getPayload(); + return Optional.of(toPayload(claims)); + } catch (ExpiredJwtException e) { + return Optional.of(toPayload(e.getClaims())); + } catch (JwtException | IllegalArgumentException e) { + return Optional.empty(); + } + } + + private Claims getRefreshClaims(String refreshToken) { + try { + return Jwts.parser() + .verifyWith(refreshKey) + .build() + .parseSignedClaims(refreshToken) + .getPayload(); + } catch (ExpiredJwtException e) { + throw new CustomException(ErrorCode.REFRESH_TOKEN_EXPIRED); + } catch (IllegalArgumentException e) { + throw new CustomException(ErrorCode.UNAUTHENTICATED_REQUEST); + } catch (JwtException e) { + throw new CustomException(ErrorCode.INVALID_TOKEN); + } + } + + private RefreshTokenPayload toPayload(Claims claims) { + Long memberId = Long.valueOf(claims.getSubject()); + String deviceId = claims.get(CLAIM_DEVICE_ID, String.class); + return new RefreshTokenPayload(memberId, deviceId); + } + + public long getRefreshTokenExpirationMillis() { + return jwtProperties.refresh().expiration().toMillis(); + } +``` + +> **삭제되는 메서드:** 기존 `createRefreshToken(Member)`, `getMemberIdFromRefreshToken(String)`, `getMemberIdFromRefreshTokenSafely(String)`. 이 메서드들의 호출처(AuthService)는 Task 7~10에서 동시에 정리되므로 일시적으로 컴파일 에러가 난다. + +- [ ] **Step 5: JwtTokenProviderTest만 실행 (전체 빌드는 아직 깨짐)** + +Run: `./gradlew :SS-Auth:test --tests "*JwtTokenProviderTest*"` +Expected: 5 tests passed. + +> `AuthService`가 아직 옛 시그니처를 호출하고 있어 `compileJava`는 실패하지만, `--tests`로 단일 클래스 실행은 통과해야 한다. 만약 클래스 컴파일 자체가 막혀 테스트도 못 돌리면, Task 5는 Task 7~10과 같은 PR에 묶어 큰 단위로 진행하는 게 안전. 이 경우 Step 6 커밋을 보류하고 Task 7~10 완료 후 한 번에 커밋. + +- [ ] **Step 6: 커밋 (Step 5에서 단일 테스트 통과 시)** + +```bash +git add SS-Auth/src/main/java/com/elipair/spacestudyship/auth/jwt/RefreshTokenPayload.java \ + SS-Auth/src/main/java/com/elipair/spacestudyship/auth/jwt/JwtTokenProvider.java \ + SS-Auth/src/test/java/com/elipair/spacestudyship/auth/jwt/JwtTokenProviderTest.java +git commit -m "디바이스_FCM토큰_저장_기능_추가 : feat : Refresh Token claim에 deviceId 추가, parseRefreshToken API 신설" +``` + +--- + +## Task 6: LoginRequest DTO 확장 + AuthService.login 디바이스 upsert (TDD) + +**Files:** +- Modify: `SS-Auth/src/main/java/com/elipair/spacestudyship/auth/dto/LoginRequest.java` +- Modify: `SS-Auth/src/main/java/com/elipair/spacestudyship/auth/service/AuthService.java` +- Modify: `SS-Auth/src/test/java/com/elipair/spacestudyship/auth/service/AuthServiceTest.java` + +- [ ] **Step 1: LoginRequest에 3개 필드 추가** + +```java +package com.elipair.spacestudyship.auth.dto; + +import com.elipair.spacestudyship.auth.constant.DeviceType; +import com.elipair.spacestudyship.member.constant.SocialType; +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; + +@Schema(description = "소셜 로그인 요청 본문") +public record LoginRequest( + @Schema(description = "소셜 로그인 플랫폼. 지원: GOOGLE, APPLE, KAKAO.", + example = "GOOGLE", requiredMode = Schema.RequiredMode.REQUIRED) + @NotNull(message = "소셜 플랫폼 정보는 필수입니다.") SocialType socialType, + + @Schema(description = "Firebase에서 발급받은 ID Token.", + example = "eyJhbGciOiJSUzI1NiIs...", requiredMode = Schema.RequiredMode.REQUIRED) + @NotBlank(message = "소셜 인증 토큰(ID Token)은 필수입니다.") String idToken, + + @Schema(description = "Firebase Cloud Messaging 디바이스 토큰.", + example = "dK3mL9xRTp2...", requiredMode = Schema.RequiredMode.REQUIRED) + @NotBlank(message = "FCM 토큰은 필수입니다.") String fcmToken, + + @Schema(description = "디바이스 OS 타입.", + example = "IOS", requiredMode = Schema.RequiredMode.REQUIRED) + @NotNull(message = "디바이스 타입은 필수입니다.") DeviceType deviceType, + + @Schema(description = "디바이스 고유 식별자(UUID).", + example = "550e8400-e29b-41d4-a716-446655440000", requiredMode = Schema.RequiredMode.REQUIRED) + @NotBlank(message = "디바이스 식별자는 필수입니다.") String deviceId +) {} +``` + +- [ ] **Step 2: AuthServiceTest 헤더 일괄 정리 — 기존 RefreshTokenRepository 의존을 UserDeviceRepository로 교체** + +`AuthServiceTest.java` 상단 필드/import 영역만 우선 다음으로 교체: + +```java +import com.elipair.spacestudyship.auth.constant.DeviceType; +import com.elipair.spacestudyship.auth.dto.LoginRequest; +import com.elipair.spacestudyship.auth.dto.LoginResponse; +import com.elipair.spacestudyship.auth.dto.Tokens; +import com.elipair.spacestudyship.auth.entity.UserDevice; +import com.elipair.spacestudyship.auth.jwt.RefreshTokenPayload; +import com.elipair.spacestudyship.auth.repository.UserDeviceRepository; +// ... (기존 import 유지) + +@Mock +UserDeviceRepository userDeviceRepository; // RefreshTokenRepository 자리 교체 +@Mock +JwtTokenProvider jwtTokenProvider; +// ... (나머지 동일) +``` + +기존 `@Mock RefreshTokenRepository refreshTokenRepository;` 줄을 제거. 기존 `withdraw` 관련 테스트 3개는 `verify(refreshTokenRepository).delete(memberId);` 라인을 일단 **삭제** (다음 Task에서 CASCADE 검증으로 교체). + +- [ ] **Step 3: 로그인 — 신규 디바이스 실패 테스트 추가** + +`AuthServiceTest`에 추가: + +```java +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.BDDMockito.then; + +@Test +@DisplayName("login: 기존 회원 + 신규 디바이스 → user_devices에 새 row insert, 200 응답") +void login_existingMember_newDevice() { + // given + SocialType socialType = SocialType.GOOGLE; + String idToken = "id-token"; + String fcmToken = "fcm-1"; + DeviceType deviceType = DeviceType.IOS; + String deviceId = "device-1"; + LoginRequest request = new LoginRequest(socialType, idToken, fcmToken, deviceType, deviceId); + + SocialLoginStrategy strategy = mock(SocialLoginStrategy.class); + given(socialLoginStrategies.get(socialType)).willReturn(strategy); + given(strategy.validateAndGetSocialId(idToken)).willReturn("social-id-1"); + + Member member = Member.builder() + .id(10L).socialId("social-id-1").socialType(socialType).nickname("기존회원").build(); + given(memberRepository.findBySocialIdAndSocialType("social-id-1", socialType)) + .willReturn(java.util.Optional.of(member)); + + given(jwtTokenProvider.createAccessToken(member)).willReturn("access-1"); + given(jwtTokenProvider.createRefreshToken(member, deviceId)).willReturn("refresh-1"); + given(userDeviceRepository.findByMemberIdAndDeviceId(10L, deviceId)) + .willReturn(java.util.Optional.empty()); + + // when + LoginResponse response = authService.login(request); + + // then + assertThat(response.memberId()).isEqualTo(10L); + assertThat(response.tokens().accessToken()).isEqualTo("access-1"); + assertThat(response.tokens().refreshToken()).isEqualTo("refresh-1"); + assertThat(response.isNewMember()).isFalse(); + then(userDeviceRepository).should().save(any(UserDevice.class)); +} + +@Test +@DisplayName("login: 기존 회원 + 기존 디바이스 → 같은 row의 fcm/refresh/last_login 갱신, save() 호출 없음") +void login_existingMember_existingDevice() { + // given + SocialType socialType = SocialType.GOOGLE; + LoginRequest request = new LoginRequest(socialType, "id-token", "fcm-NEW", DeviceType.IOS, "device-1"); + + SocialLoginStrategy strategy = mock(SocialLoginStrategy.class); + given(socialLoginStrategies.get(socialType)).willReturn(strategy); + given(strategy.validateAndGetSocialId("id-token")).willReturn("social-id-1"); + + Member member = Member.builder() + .id(10L).socialId("social-id-1").socialType(socialType).nickname("기존회원").build(); + given(memberRepository.findBySocialIdAndSocialType("social-id-1", socialType)) + .willReturn(java.util.Optional.of(member)); + + given(jwtTokenProvider.createAccessToken(member)).willReturn("access-NEW"); + given(jwtTokenProvider.createRefreshToken(member, "device-1")).willReturn("refresh-NEW"); + + UserDevice existing = UserDevice.register(10L, "device-1", DeviceType.ANDROID, "fcm-OLD", "refresh-OLD"); + given(userDeviceRepository.findByMemberIdAndDeviceId(10L, "device-1")) + .willReturn(java.util.Optional.of(existing)); + + // when + authService.login(request); + + // then + assertThat(existing.getFcmToken()).isEqualTo("fcm-NEW"); + assertThat(existing.getRefreshToken()).isEqualTo("refresh-NEW"); + assertThat(existing.getDeviceType()).isEqualTo(DeviceType.IOS); + then(userDeviceRepository).should(never()).save(any(UserDevice.class)); +} +``` + +- [ ] **Step 4: 테스트 실행 → 실패 확인** + +Run: `./gradlew :SS-Auth:test --tests "*AuthServiceTest.login*"` +Expected: 컴파일 실패 (AuthService가 아직 RefreshTokenRepository 의존, login 시그니처 처리 미흡). + +- [ ] **Step 5: AuthService 수정 — login 메서드만** + +`AuthService.java`의 필드/생성자 + login 부분을 다음으로 교체: + +```java +import com.elipair.spacestudyship.auth.entity.UserDevice; +import com.elipair.spacestudyship.auth.repository.UserDeviceRepository; +// (RefreshTokenRepository import 제거) + +private final MemberRepository memberRepository; +private final UserDeviceRepository userDeviceRepository; // ← 교체 +private final JwtTokenProvider jwtTokenProvider; +private final RandomNicknameGenerator randomNicknameGenerator; +private final Map socialLoginStrategies; +private final FirebaseAuth firebaseAuth; + +@Transactional +public LoginResponse login(LoginRequest request) { + String socialId = getSocialId(request.socialType(), request.idToken()); + AuthMemberDto authMemberData = findOrRegisterMember(socialId, request.socialType()); + Member member = authMemberData.member(); + + String accessToken = jwtTokenProvider.createAccessToken(member); + String refreshToken = jwtTokenProvider.createRefreshToken(member, request.deviceId()); + + upsertUserDevice(member.getId(), request, refreshToken); + + return new LoginResponse(member.getId(), member.getNickname(), + new Tokens(accessToken, refreshToken), authMemberData.isNewMember()); +} + +private void upsertUserDevice(Long memberId, LoginRequest request, String refreshToken) { + userDeviceRepository.findByMemberIdAndDeviceId(memberId, request.deviceId()) + .ifPresentOrElse( + device -> device.renewLogin(request.deviceType(), request.fcmToken(), refreshToken), + () -> userDeviceRepository.save(UserDevice.register( + memberId, request.deviceId(), request.deviceType(), + request.fcmToken(), refreshToken)) + ); +} +``` + +기존 `issueTokens(Member)` 메서드는 reissue에서도 사용되므로 **아직 삭제하지 않음** (Task 7에서 처리). 다만 login에서는 이제 안 쓰임. + +> `reissue`/`logout`/`withdraw`는 아직 옛 RefreshTokenRepository 호출 중이라 컴파일 실패. 다음 Task에서 차례로 교체. + +- [ ] **Step 6: login 관련 테스트만 통과 확인 (전체 빌드는 아직 깨짐)** + +> reissue/logout/withdraw가 아직 깨져 있어 전체 컴파일은 실패한다. 이 Task의 커밋은 **다음 Task 7~9를 모두 완료한 뒤** 묶어서 진행한다. Task 6 단독 커밋은 생략. + +--- + +## Task 7: AuthService.reissue 디바이스 단위 회전 (TDD) + +**Files:** +- Modify: `SS-Auth/src/main/java/com/elipair/spacestudyship/auth/service/AuthService.java` +- Modify: `SS-Auth/src/test/java/com/elipair/spacestudyship/auth/service/AuthServiceTest.java` + +- [ ] **Step 1: 실패하는 reissue 테스트 추가** + +`AuthServiceTest`에 추가: + +```java +@Test +@DisplayName("reissue: DB의 refresh_token과 일치하면 새 토큰 발급 + DB 갱신, deviceId 유지") +void reissue_success() { + // given + String oldRefresh = "refresh-OLD"; + ReissueRequest request = new ReissueRequest(oldRefresh); + + given(jwtTokenProvider.parseRefreshToken(oldRefresh)) + .willReturn(new RefreshTokenPayload(10L, "device-1")); + UserDevice device = UserDevice.register(10L, "device-1", DeviceType.IOS, "fcm", oldRefresh); + given(userDeviceRepository.findByMemberIdAndDeviceId(10L, "device-1")) + .willReturn(java.util.Optional.of(device)); + Member member = Member.builder() + .id(10L).socialId("s").socialType(SocialType.GOOGLE).nickname("닉").build(); + given(memberRepository.getByMemberId(10L)).willReturn(member); + given(jwtTokenProvider.createAccessToken(member)).willReturn("access-NEW"); + given(jwtTokenProvider.createRefreshToken(member, "device-1")).willReturn("refresh-NEW"); + + // when + ReissueResponse response = authService.reissue(request); + + // then + assertThat(response.tokens().accessToken()).isEqualTo("access-NEW"); + assertThat(response.tokens().refreshToken()).isEqualTo("refresh-NEW"); + assertThat(device.getRefreshToken()).isEqualTo("refresh-NEW"); +} + +@Test +@DisplayName("reissue: DB의 refresh_token과 불일치 → 해당 디바이스 row 삭제 + INVALID_TOKEN") +void reissue_tokenMismatch_forceLogout() { + // given + String incomingRefresh = "refresh-FORGED"; + ReissueRequest request = new ReissueRequest(incomingRefresh); + + given(jwtTokenProvider.parseRefreshToken(incomingRefresh)) + .willReturn(new RefreshTokenPayload(10L, "device-1")); + UserDevice device = UserDevice.register(10L, "device-1", DeviceType.IOS, "fcm", "refresh-CURRENT"); + given(userDeviceRepository.findByMemberIdAndDeviceId(10L, "device-1")) + .willReturn(java.util.Optional.of(device)); + + // when / then + assertThatThrownBy(() -> authService.reissue(request)) + .isInstanceOf(CustomException.class) + .extracting("errorCode").isEqualTo(ErrorCode.INVALID_TOKEN); + then(userDeviceRepository).should().delete(device); +} + +@Test +@DisplayName("reissue: user_devices에 해당 디바이스 row 없으면 INVALID_TOKEN") +void reissue_deviceNotFound() { + String incoming = "refresh-X"; + ReissueRequest request = new ReissueRequest(incoming); + + given(jwtTokenProvider.parseRefreshToken(incoming)) + .willReturn(new RefreshTokenPayload(10L, "device-gone")); + given(userDeviceRepository.findByMemberIdAndDeviceId(10L, "device-gone")) + .willReturn(java.util.Optional.empty()); + + assertThatThrownBy(() -> authService.reissue(request)) + .isInstanceOf(CustomException.class) + .extracting("errorCode").isEqualTo(ErrorCode.INVALID_TOKEN); +} +``` + +- [ ] **Step 2: AuthService.reissue 교체** + +```java +@Transactional +public ReissueResponse reissue(ReissueRequest request) { + RefreshTokenPayload payload = jwtTokenProvider.parseRefreshToken(request.refreshToken()); + + UserDevice device = userDeviceRepository + .findByMemberIdAndDeviceId(payload.memberId(), payload.deviceId()) + .orElseThrow(() -> new CustomException(ErrorCode.INVALID_TOKEN)); + + if (!device.getRefreshToken().equals(request.refreshToken())) { + userDeviceRepository.delete(device); + log.warn("[Security] Refresh Token 불일치 - 강제 로그아웃 처리 | memberId={}, deviceId={}", + payload.memberId(), payload.deviceId()); + throw new CustomException(ErrorCode.INVALID_TOKEN); + } + + Member member = memberRepository.getByMemberId(payload.memberId()); + String newAccess = jwtTokenProvider.createAccessToken(member); + String newRefresh = jwtTokenProvider.createRefreshToken(member, payload.deviceId()); + + device.rotateRefreshToken(newRefresh); + return new ReissueResponse(new Tokens(newAccess, newRefresh)); +} +``` + +기존 `private Tokens issueTokens(Member member)` 헬퍼는 더 이상 호출처가 없으므로 **삭제**. + +- [ ] **Step 3: reissue 테스트 통과 확인 (logout/withdraw 아직 깨짐)** + +> 전체 컴파일은 여전히 logout/withdraw에서 실패. 다음 Task로 진행 후 통합 검증. + +--- + +## Task 8: AuthService.logout 디바이스 단위 정리 (TDD) + +**Files:** +- Modify: `SS-Auth/src/main/java/com/elipair/spacestudyship/auth/service/AuthService.java` +- Modify: `SS-Auth/src/test/java/com/elipair/spacestudyship/auth/service/AuthServiceTest.java` + +- [ ] **Step 1: 실패하는 logout 테스트 추가** + +```java +@Test +@DisplayName("logout: refresh token 파싱 성공 시 해당 (member, device) row 삭제") +void logout_deletesOnlyTargetDevice() { + // given + String refreshToken = "refresh-1"; + given(jwtTokenProvider.parseRefreshTokenSafely(refreshToken)) + .willReturn(java.util.Optional.of(new RefreshTokenPayload(10L, "device-1"))); + + // when + authService.logout(refreshToken); + + // then + then(userDeviceRepository).should().deleteByMemberIdAndDeviceId(10L, "device-1"); +} + +@Test +@DisplayName("logout: 위변조 등으로 파싱 불가능하면 아무 동작 안 함 (멱등)") +void logout_invalidToken_noop() { + given(jwtTokenProvider.parseRefreshTokenSafely("garbage")) + .willReturn(java.util.Optional.empty()); + + authService.logout("garbage"); + + then(userDeviceRepository).should(never()).deleteByMemberIdAndDeviceId(any(), any()); +} +``` + +- [ ] **Step 2: AuthService.logout 교체** + +```java +@Transactional +public void logout(String refreshToken) { + jwtTokenProvider.parseRefreshTokenSafely(refreshToken) + .ifPresent(payload -> userDeviceRepository + .deleteByMemberIdAndDeviceId(payload.memberId(), payload.deviceId())); +} +``` + +- [ ] **Step 3: logout 테스트 통과 확인 (withdraw 아직 깨짐)** + +> 다음 Task로. + +--- + +## Task 9: AuthService.withdraw CASCADE 의존으로 단순화 (TDD) + +**Files:** +- Modify: `SS-Auth/src/main/java/com/elipair/spacestudyship/auth/service/AuthService.java` +- Modify: `SS-Auth/src/test/java/com/elipair/spacestudyship/auth/service/AuthServiceTest.java` + +- [ ] **Step 1: 기존 withdraw 테스트의 RefreshTokenRepository verify 제거 + CASCADE 시나리오 명시** + +`AuthServiceTest`의 기존 `withdraw_success` / `withdraw_alreadyWithdrawn` / `withdraw_firebaseUserNotFound` / `withdraw_firebaseGenericError`에서: +- `verify(refreshTokenRepository).delete(memberId);` 줄을 **모두 삭제** +- 대신 `withdraw_success`에 다음 한 줄 추가: + ```java + // user_devices는 FK CASCADE로 자동 삭제되므로 AuthService가 직접 호출하지 않는다 + then(userDeviceRepository).shouldHaveNoInteractions(); + ``` + +- [ ] **Step 2: AuthService.withdraw 교체** + +```java +@Transactional +public void withdraw(Long memberId) { + Member member = memberRepository.findById(memberId).orElse(null); + if (member != null) { + memberRepository.delete(member); // FK ON DELETE CASCADE로 user_devices 자동 삭제 + deleteFirebaseUserSafely(memberId, member.getSocialId()); + } +} +``` + +> `withdraw_alreadyWithdrawn` 테스트는 member 없음 → delete 호출 없음 + firebase 호출 없음 + userDeviceRepository 무호출. 기존 검증 그대로 유효 (refreshTokenRepository verify 줄만 빠짐). + +- [ ] **Step 3: AuthService import에서 RefreshTokenRepository 제거 + Map import 정리** + +`AuthService.java` 상단에서 다음 import 제거: +- `import com.elipair.spacestudyship.auth.repository.RefreshTokenRepository;` + +`AuthService` 클래스에서 다음 필드 제거: +- `private final RefreshTokenRepository refreshTokenRepository;` + +- [ ] **Step 4: 전체 AuthService 테스트 실행 → 통과 확인** + +Run: `./gradlew :SS-Auth:test --tests "*AuthServiceTest*"` +Expected: 모든 테스트 통과. + +- [ ] **Step 5: Task 6~9 통합 커밋** + +```bash +git add SS-Auth/src/main/java/com/elipair/spacestudyship/auth/dto/LoginRequest.java \ + SS-Auth/src/main/java/com/elipair/spacestudyship/auth/service/AuthService.java \ + SS-Auth/src/test/java/com/elipair/spacestudyship/auth/service/AuthServiceTest.java +git commit -m "디바이스_FCM토큰_저장_기능_추가 : feat : login/reissue/logout/withdraw를 디바이스 단위로 처리" +``` + +--- + +## Task 10: RefreshTokenRepository(Redis) 제거 + +**Files:** +- Delete: `SS-Auth/src/main/java/com/elipair/spacestudyship/auth/repository/RefreshTokenRepository.java` + +- [ ] **Step 1: 호출처 0건 확인 (이미 모두 교체되었는지)** + +Run: `grep -rn "RefreshTokenRepository" . --include="*.java"` +Expected: 단 한 줄도 출력되지 않음 (파일 자체 외에는). 만약 잔여 호출이 있으면 그 파일을 먼저 정리. + +- [ ] **Step 2: 파일 삭제** + +```bash +rm SS-Auth/src/main/java/com/elipair/spacestudyship/auth/repository/RefreshTokenRepository.java +``` + +- [ ] **Step 3: 전체 빌드 + 모든 테스트 통과 확인** + +Run: `./gradlew clean build` +Expected: BUILD SUCCESSFUL. 모든 테스트 통과. 컴파일 경고/에러 없음. + +- [ ] **Step 4: 커밋** + +```bash +git add -A SS-Auth/src/main/java/com/elipair/spacestudyship/auth/repository/ +git commit -m "디바이스_FCM토큰_저장_기능_추가 : refactor : RefreshTokenRepository(Redis 기반) 제거" +``` + +--- + +## Task 11: API 스펙 문서 정합성 보정 + +**Files:** +- Modify: `docs/api-specs/01_auth.md` + +- [ ] **Step 1: `socialPlatform` → `socialType` 일괄 치환, KAKAO 추가** + +`docs/api-specs/01_auth.md`의 "1. 소셜 로그인" 섹션에서 Request Body 표와 JSON 예시 모두 다음 변경: +- `socialPlatform` → `socialType` +- 설명: `"GOOGLE"`, `"APPLE"` → `"GOOGLE"`, `"APPLE"`, `"KAKAO"` +- "Error" 표의 `UNSUPPORTED_PLATFORM` → `UNSUPPORTED_SOCIAL_TYPE` (실제 ErrorCode와 일치) + +```diff +- | `socialPlatform` | String | O | 소셜 로그인 플랫폼 | `"GOOGLE"`, `"APPLE"` | ++ | `socialType` | String | O | 소셜 로그인 플랫폼 | `"GOOGLE"`, `"APPLE"`, `"KAKAO"` | +... +- "socialPlatform": "GOOGLE", ++ "socialType": "GOOGLE", +... +- | 400 | `UNSUPPORTED_PLATFORM` | socialPlatform이 GOOGLE/APPLE이 아닌 경우 | ++ | 400 | `UNSUPPORTED_SOCIAL_TYPE` | socialType이 GOOGLE/APPLE/KAKAO가 아닌 경우 | +``` + +- [ ] **Step 2: "서버 처리 로직" 문구 보정 (디바이스별 Refresh Token 명시)** + +기존: + +```text +5. Refresh Token을 DB에 저장 (디바이스별) +``` + +→ 그대로 유지. 이제 실제 구현이 일치함. + +- [ ] **Step 3: `user_devices` DB 참고 표 — `created_at`, `updated_at` 컬럼 추가 (실제 스키마와 일치)** + +```diff + | `last_login_at` | TIMESTAMP | 마지막 로그인 | ++ | `created_at` | TIMESTAMP | 생성 시각 | ++ | `updated_at` | TIMESTAMP | 수정 시각 | +``` + +추가로 unique 제약 명시 줄을 표 아래에 추가: +```markdown +> Unique 제약: `(member_id, device_id)` 컴포지트. 같은 디바이스를 다른 회원이 쓰는 경우는 별개 row. +> FK: `member_id` → `members.id`, `ON DELETE CASCADE` (회원 탈퇴 시 자동 삭제). +``` + +- [ ] **Step 4: 커밋** + +```bash +git add docs/api-specs/01_auth.md +git commit -m "디바이스_FCM토큰_저장_기능_추가 : docs : 01_auth.md를 실제 구현(socialType, KAKAO, user_devices)에 맞춰 정합성 보정" +``` + +--- + +## Task 12: 최종 검증 + +- [ ] **Step 1: 전체 빌드 + 테스트** + +Run: `./gradlew clean build` +Expected: BUILD SUCCESSFUL. 모든 테스트 통과. + +- [ ] **Step 2: 컴파일 잔여 import / 미사용 클래스 검색** + +Run: `grep -rn "refreshTokenRepository\|RefreshTokenRepository" . --include="*.java"` +Expected: 출력 없음. + +Run: `grep -rn "getMemberIdFromRefreshToken" . --include="*.java"` +Expected: 출력 없음. + +- [ ] **Step 3: 마이그레이션 파일 한 개인지 확인 (CLAUDE.md 규칙: 한 version당 한 파일)** + +Run: `ls SS-Web/src/main/resources/db/migration/` +Expected: `.gitkeep`, `V0_0_31__add_user_devices.sql` 두 개만. + +- [ ] **Step 4: PR 생성 가능 상태 — 마지막 확인** + +수동 점검: +1. `LoginRequest`에 fcmToken, deviceType, deviceId 3개 필드 포함되어 있는가? +2. `UserDevice` Entity와 마이그레이션 SQL의 컬럼/제약이 정확히 일치하는가? +3. `AuthServiceTest` 9개 이상 (기존 + 신규 login 2개 + reissue 3개 + logout 2개) 통과하는가? +4. `JwtTokenProviderTest` 5개 통과하는가? +5. `UserDeviceRepositoryTest` 6개 통과하는가? +6. `docs/api-specs/01_auth.md`에 `socialType`/`KAKAO`/`UNSUPPORTED_SOCIAL_TYPE` 적용되었는가? +7. `RefreshTokenRepository.java`가 삭제되었고 어떤 코드도 더 이상 참조하지 않는가? + +--- + +## 부록: 디버깅 가이드 + +**`@DataJpaTest` ApplicationContext 로딩 실패 시:** +- 멀티모듈 환경에서 `@DataJpaTest`는 자기 모듈 내 Entity만 스캔. `UserDevice`만 필요하므로 OK. 만약 `Member` Entity 매핑까지 요구하면(다른 모듈), `@EntityScan(basePackages = "com.elipair.spacestudyship")`로 범위 확장. +- `BaseTimeEntity`는 `@MappedSuperclass`로 상속되므로 별도 스캔 불필요. + +**Flyway baseline 충돌 시 (배포):** +- `application.yml`에 `spring.flyway.baseline-on-migrate: true`로 이미 설정되어 있어 첫 마이그레이션 실행 가능. validate-on-migrate=false라 기존 ddl-auto 스키마와의 미세한 차이도 통과. + +**Hibernate가 만든 `members` 컬럼 길이/타입이 마이그레이션 SQL과 다를 경우:** +- `CREATE TABLE IF NOT EXISTS`라 기존 테이블은 그대로 유지됨. 향후 컬럼 동기화는 별도 마이그레이션으로 처리. + +**PostgreSQL 인덱스 IF NOT EXISTS 미지원 버전:** +- PostgreSQL 9.5+에서 지원. 더 낮은 버전을 쓰면 인덱스 줄을 단순 `CREATE INDEX idx_user_devices_member ON user_devices(member_id);`로 두고 첫 배포 외 환경에서는 `DROP INDEX IF EXISTS` 선행. 현재 프로젝트 PostgreSQL 버전은 9.5+ 가정. diff --git a/docs/superpowers/plans/2026-05-19-firebase-id-token-verification.md b/docs/superpowers/plans/2026-05-19-firebase-id-token-verification.md new file mode 100644 index 0000000..2770b98 --- /dev/null +++ b/docs/superpowers/plans/2026-05-19-firebase-id-token-verification.md @@ -0,0 +1,498 @@ +# Firebase ID Token 검증 적용 (소셜 로그인) Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** `/api/auth/login` 의 3개 `SocialLoginStrategy` 구현체(Google/Apple/Kakao) 본문을 Firebase Admin SDK 기반 ID Token 검증으로 교체해, 동일 소셜 계정의 재로그인이 신규 가입으로 처리되는 버그를 제거한다. + +**Architecture:** Flutter 가 모든 소셜(Google/Apple/Kakao) 인증을 Firebase Authentication 으로 위임하고 Firebase ID Token 만 백엔드로 보낸다. 백엔드는 소셜 종류 분기 없이 `firebaseAuth.verifyIdToken(idToken).getUid()` 로 사용자별 영구 고유 UID 를 얻어 `socialId` 로 사용한다. `SocialLoginStrategy` 인터페이스/Config 는 그대로 두고 각 구현체 본문만 동일 로직으로 교체한다. + +**Tech Stack:** Spring Boot 4.0.2, Java 21, Firebase Admin SDK 9.4.3, JUnit 5, Mockito, AssertJ, BDDMockito, Gradle 멀티모듈. + +**Spec:** `docs/superpowers/specs/2026-05-19-firebase-id-token-verification-design.md` + +**Commit convention (프로젝트 CLAUDE.md):** +``` +{이슈제목} : {type} : {변경사항 설명} +``` +이슈 URL 모르므로 생략. 이슈 만들었다면 URL 뒤에 붙이기. + +--- + +## Task 1: `GoogleLoginStrategy` 본문을 Firebase 검증으로 교체 + +**Files:** +- Test: `SS-Auth/src/test/java/com/elipair/spacestudyship/auth/social/GoogleLoginStrategyTest.java` (Create) +- Modify: `SS-Auth/src/main/java/com/elipair/spacestudyship/auth/social/GoogleLoginStrategy.java` + +- [ ] **Step 1: 테스트 파일 작성 (실패하는 테스트)** + +`SS-Auth/src/test/java/com/elipair/spacestudyship/auth/social/GoogleLoginStrategyTest.java` 신규 생성: + +```java +package com.elipair.spacestudyship.auth.social; + +import com.elipair.spacestudyship.common.exception.CustomException; +import com.elipair.spacestudyship.common.exception.ErrorCode; +import com.elipair.spacestudyship.member.constant.SocialType; +import com.google.firebase.auth.FirebaseAuth; +import com.google.firebase.auth.FirebaseAuthException; +import com.google.firebase.auth.FirebaseToken; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.mock; + +@ExtendWith(MockitoExtension.class) +class GoogleLoginStrategyTest { + + @Mock + FirebaseAuth firebaseAuth; + + @InjectMocks + GoogleLoginStrategy strategy; + + @Test + @DisplayName("validateAndGetSocialId: 유효한 토큰이면 Firebase UID 반환") + void validateAndGetSocialId_valid() throws FirebaseAuthException { + FirebaseToken token = mock(FirebaseToken.class); + given(token.getUid()).willReturn("firebase-uid-google-1"); + given(firebaseAuth.verifyIdToken("valid-google-token")).willReturn(token); + + String socialId = strategy.validateAndGetSocialId("valid-google-token"); + + assertThat(socialId).isEqualTo("firebase-uid-google-1"); + } + + @Test + @DisplayName("validateAndGetSocialId: Firebase 검증 실패 시 INVALID_TOKEN") + void validateAndGetSocialId_invalid() throws FirebaseAuthException { + FirebaseAuthException ex = mock(FirebaseAuthException.class); + given(firebaseAuth.verifyIdToken("invalid-token")).willThrow(ex); + + assertThatThrownBy(() -> strategy.validateAndGetSocialId("invalid-token")) + .isInstanceOf(CustomException.class) + .extracting("errorCode") + .isEqualTo(ErrorCode.INVALID_TOKEN); + } + + @Test + @DisplayName("getSocialType: GOOGLE 반환") + void getSocialType() { + assertThat(strategy.getSocialType()).isEqualTo(SocialType.GOOGLE); + } +} +``` + +- [ ] **Step 2: 테스트 실행해서 실패 확인** + +Run: +```bash +./gradlew :SS-Auth:test --tests "com.elipair.spacestudyship.auth.social.GoogleLoginStrategyTest" +``` +Expected: `validateAndGetSocialId_valid` 와 `validateAndGetSocialId_invalid` FAIL (현재 `GoogleLoginStrategy` 가 랜덤값 리턴/예외 없음). `getSocialType` 은 PASS 가능. 핵심은 두 핵심 테스트가 FAIL 하는지. + +- [ ] **Step 3: `GoogleLoginStrategy` 본문을 Firebase 검증으로 교체** + +`SS-Auth/src/main/java/com/elipair/spacestudyship/auth/social/GoogleLoginStrategy.java` 전체 교체: + +```java +package com.elipair.spacestudyship.auth.social; + +import com.elipair.spacestudyship.common.exception.CustomException; +import com.elipair.spacestudyship.common.exception.ErrorCode; +import com.elipair.spacestudyship.member.constant.SocialType; +import com.google.firebase.auth.FirebaseAuth; +import com.google.firebase.auth.FirebaseAuthException; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + +@Component +@RequiredArgsConstructor +public class GoogleLoginStrategy implements SocialLoginStrategy { + + private final FirebaseAuth firebaseAuth; + + @Override + public String validateAndGetSocialId(String socialIdToken) { + try { + return firebaseAuth.verifyIdToken(socialIdToken).getUid(); + } catch (FirebaseAuthException e) { + throw new CustomException(ErrorCode.INVALID_TOKEN); + } + } + + @Override + public SocialType getSocialType() { + return SocialType.GOOGLE; + } +} +``` + +- [ ] **Step 4: 테스트 실행해서 통과 확인** + +Run: +```bash +./gradlew :SS-Auth:test --tests "com.elipair.spacestudyship.auth.social.GoogleLoginStrategyTest" +``` +Expected: 3개 테스트 모두 PASS. + +- [ ] **Step 5: 커밋** + +```bash +git add SS-Auth/src/main/java/com/elipair/spacestudyship/auth/social/GoogleLoginStrategy.java \ + SS-Auth/src/test/java/com/elipair/spacestudyship/auth/social/GoogleLoginStrategyTest.java +git commit -m "소셜 로그인 Firebase IdToken 검증 적용 : feat : GoogleLoginStrategy 본문 구현 및 단위 테스트 추가" +``` + +--- + +## Task 2: `AppleLoginStrategy` 본문을 Firebase 검증으로 교체 + +**Files:** +- Test: `SS-Auth/src/test/java/com/elipair/spacestudyship/auth/social/AppleLoginStrategyTest.java` (Create) +- Modify: `SS-Auth/src/main/java/com/elipair/spacestudyship/auth/social/AppleLoginStrategy.java` + +- [ ] **Step 1: 테스트 파일 작성 (실패하는 테스트)** + +`SS-Auth/src/test/java/com/elipair/spacestudyship/auth/social/AppleLoginStrategyTest.java` 신규 생성: + +```java +package com.elipair.spacestudyship.auth.social; + +import com.elipair.spacestudyship.common.exception.CustomException; +import com.elipair.spacestudyship.common.exception.ErrorCode; +import com.elipair.spacestudyship.member.constant.SocialType; +import com.google.firebase.auth.FirebaseAuth; +import com.google.firebase.auth.FirebaseAuthException; +import com.google.firebase.auth.FirebaseToken; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.mock; + +@ExtendWith(MockitoExtension.class) +class AppleLoginStrategyTest { + + @Mock + FirebaseAuth firebaseAuth; + + @InjectMocks + AppleLoginStrategy strategy; + + @Test + @DisplayName("validateAndGetSocialId: 유효한 토큰이면 Firebase UID 반환") + void validateAndGetSocialId_valid() throws FirebaseAuthException { + FirebaseToken token = mock(FirebaseToken.class); + given(token.getUid()).willReturn("firebase-uid-apple-1"); + given(firebaseAuth.verifyIdToken("valid-apple-token")).willReturn(token); + + String socialId = strategy.validateAndGetSocialId("valid-apple-token"); + + assertThat(socialId).isEqualTo("firebase-uid-apple-1"); + } + + @Test + @DisplayName("validateAndGetSocialId: Firebase 검증 실패 시 INVALID_TOKEN") + void validateAndGetSocialId_invalid() throws FirebaseAuthException { + FirebaseAuthException ex = mock(FirebaseAuthException.class); + given(firebaseAuth.verifyIdToken("invalid-token")).willThrow(ex); + + assertThatThrownBy(() -> strategy.validateAndGetSocialId("invalid-token")) + .isInstanceOf(CustomException.class) + .extracting("errorCode") + .isEqualTo(ErrorCode.INVALID_TOKEN); + } + + @Test + @DisplayName("getSocialType: APPLE 반환") + void getSocialType() { + assertThat(strategy.getSocialType()).isEqualTo(SocialType.APPLE); + } +} +``` + +- [ ] **Step 2: 테스트 실행해서 실패 확인** + +Run: +```bash +./gradlew :SS-Auth:test --tests "com.elipair.spacestudyship.auth.social.AppleLoginStrategyTest" +``` +Expected: 핵심 두 테스트 FAIL. + +- [ ] **Step 3: `AppleLoginStrategy` 본문을 Firebase 검증으로 교체** + +`SS-Auth/src/main/java/com/elipair/spacestudyship/auth/social/AppleLoginStrategy.java` 전체 교체: + +```java +package com.elipair.spacestudyship.auth.social; + +import com.elipair.spacestudyship.common.exception.CustomException; +import com.elipair.spacestudyship.common.exception.ErrorCode; +import com.elipair.spacestudyship.member.constant.SocialType; +import com.google.firebase.auth.FirebaseAuth; +import com.google.firebase.auth.FirebaseAuthException; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + +@Component +@RequiredArgsConstructor +public class AppleLoginStrategy implements SocialLoginStrategy { + + private final FirebaseAuth firebaseAuth; + + @Override + public String validateAndGetSocialId(String socialIdToken) { + try { + return firebaseAuth.verifyIdToken(socialIdToken).getUid(); + } catch (FirebaseAuthException e) { + throw new CustomException(ErrorCode.INVALID_TOKEN); + } + } + + @Override + public SocialType getSocialType() { + return SocialType.APPLE; + } +} +``` + +- [ ] **Step 4: 테스트 실행해서 통과 확인** + +Run: +```bash +./gradlew :SS-Auth:test --tests "com.elipair.spacestudyship.auth.social.AppleLoginStrategyTest" +``` +Expected: 3개 테스트 모두 PASS. + +- [ ] **Step 5: 커밋** + +```bash +git add SS-Auth/src/main/java/com/elipair/spacestudyship/auth/social/AppleLoginStrategy.java \ + SS-Auth/src/test/java/com/elipair/spacestudyship/auth/social/AppleLoginStrategyTest.java +git commit -m "소셜 로그인 Firebase IdToken 검증 적용 : feat : AppleLoginStrategy 본문 구현 및 단위 테스트 추가" +``` + +--- + +## Task 3: `KakaoLoginStrategy` 본문을 Firebase 검증으로 교체 + +**Files:** +- Test: `SS-Auth/src/test/java/com/elipair/spacestudyship/auth/social/KakaoLoginStrategyTest.java` (Create) +- Modify: `SS-Auth/src/main/java/com/elipair/spacestudyship/auth/social/KakaoLoginStrategy.java` + +- [ ] **Step 1: 테스트 파일 작성 (실패하는 테스트)** + +`SS-Auth/src/test/java/com/elipair/spacestudyship/auth/social/KakaoLoginStrategyTest.java` 신규 생성: + +```java +package com.elipair.spacestudyship.auth.social; + +import com.elipair.spacestudyship.common.exception.CustomException; +import com.elipair.spacestudyship.common.exception.ErrorCode; +import com.elipair.spacestudyship.member.constant.SocialType; +import com.google.firebase.auth.FirebaseAuth; +import com.google.firebase.auth.FirebaseAuthException; +import com.google.firebase.auth.FirebaseToken; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.mock; + +@ExtendWith(MockitoExtension.class) +class KakaoLoginStrategyTest { + + @Mock + FirebaseAuth firebaseAuth; + + @InjectMocks + KakaoLoginStrategy strategy; + + @Test + @DisplayName("validateAndGetSocialId: 유효한 토큰이면 Firebase UID 반환") + void validateAndGetSocialId_valid() throws FirebaseAuthException { + FirebaseToken token = mock(FirebaseToken.class); + given(token.getUid()).willReturn("firebase-uid-kakao-1"); + given(firebaseAuth.verifyIdToken("valid-kakao-token")).willReturn(token); + + String socialId = strategy.validateAndGetSocialId("valid-kakao-token"); + + assertThat(socialId).isEqualTo("firebase-uid-kakao-1"); + } + + @Test + @DisplayName("validateAndGetSocialId: Firebase 검증 실패 시 INVALID_TOKEN") + void validateAndGetSocialId_invalid() throws FirebaseAuthException { + FirebaseAuthException ex = mock(FirebaseAuthException.class); + given(firebaseAuth.verifyIdToken("invalid-token")).willThrow(ex); + + assertThatThrownBy(() -> strategy.validateAndGetSocialId("invalid-token")) + .isInstanceOf(CustomException.class) + .extracting("errorCode") + .isEqualTo(ErrorCode.INVALID_TOKEN); + } + + @Test + @DisplayName("getSocialType: KAKAO 반환") + void getSocialType() { + assertThat(strategy.getSocialType()).isEqualTo(SocialType.KAKAO); + } +} +``` + +- [ ] **Step 2: 테스트 실행해서 실패 확인** + +Run: +```bash +./gradlew :SS-Auth:test --tests "com.elipair.spacestudyship.auth.social.KakaoLoginStrategyTest" +``` +Expected: 핵심 두 테스트 FAIL. + +- [ ] **Step 3: `KakaoLoginStrategy` 본문을 Firebase 검증으로 교체** + +`SS-Auth/src/main/java/com/elipair/spacestudyship/auth/social/KakaoLoginStrategy.java` 전체 교체: + +```java +package com.elipair.spacestudyship.auth.social; + +import com.elipair.spacestudyship.common.exception.CustomException; +import com.elipair.spacestudyship.common.exception.ErrorCode; +import com.elipair.spacestudyship.member.constant.SocialType; +import com.google.firebase.auth.FirebaseAuth; +import com.google.firebase.auth.FirebaseAuthException; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + +@Component +@RequiredArgsConstructor +public class KakaoLoginStrategy implements SocialLoginStrategy { + + private final FirebaseAuth firebaseAuth; + + @Override + public String validateAndGetSocialId(String socialIdToken) { + try { + return firebaseAuth.verifyIdToken(socialIdToken).getUid(); + } catch (FirebaseAuthException e) { + throw new CustomException(ErrorCode.INVALID_TOKEN); + } + } + + @Override + public SocialType getSocialType() { + return SocialType.KAKAO; + } +} +``` + +- [ ] **Step 4: 테스트 실행해서 통과 확인** + +Run: +```bash +./gradlew :SS-Auth:test --tests "com.elipair.spacestudyship.auth.social.KakaoLoginStrategyTest" +``` +Expected: 3개 테스트 모두 PASS. + +- [ ] **Step 5: 커밋** + +```bash +git add SS-Auth/src/main/java/com/elipair/spacestudyship/auth/social/KakaoLoginStrategy.java \ + SS-Auth/src/test/java/com/elipair/spacestudyship/auth/social/KakaoLoginStrategyTest.java +git commit -m "소셜 로그인 Firebase IdToken 검증 적용 : feat : KakaoLoginStrategy 본문 구현 및 단위 테스트 추가" +``` + +--- + +## Task 4: 전체 회귀 테스트 + dev DB cleanup + 수동 검증 + +**Files:** 없음 (코드 변경 없음) + +- [ ] **Step 1: 전체 테스트 실행해서 회귀 없음 확인** + +Run: +```bash +./gradlew test +``` +Expected: 모든 모듈(SS-Auth, SS-Web 등) 테스트 PASS. 특히 `AuthServiceTest`(`SocialLoginStrategy` 를 mock 함) 그대로 통과해야 한다. + +회귀가 있으면 STOP — Task 1~3 의 어느 step 에서 시그니처/인터페이스가 어긋났는지 역추적. + +- [ ] **Step 2: 전체 빌드 확인 (선택)** + +Run: +```bash +./gradlew clean build -x test +``` +Expected: BUILD SUCCESSFUL. + +- [ ] **Step 3: dev DB 의 fake seed row 정리 (수동)** + +dev 로컬 PostgreSQL 에서 (앱 떠 있는 상태에서도 OK): + +```bash +psql -h localhost -p 5432 -U postgres -d spacestudyship +``` + +```sql +DELETE FROM user_devices; +DELETE FROM members; +SELECT count(*) FROM members; -- 0 +SELECT count(*) FROM user_devices; -- 0 +\q +``` + +prod DB(`suh-project.synology.me:5430`) 도 실 사용자가 없다면 동일하게 정리. 사용자 데이터가 있으면 건드리지 말 것. + +- [ ] **Step 4: Flutter 로 같은 계정 2 회 로그인 시나리오 검증** + +1. Flutter 앱에서 Google 계정 A 로 로그인 → 응답에서 `isNewMember=true` + 백엔드 로그에 `[SignUp] 신규 회원가입 성공 | memberId=1, ...` 출력 확인. +2. 같은 디바이스에서 로그아웃 (`/api/auth/logout`). +3. 같은 Google 계정 A 로 다시 로그인 → 응답에서 `isNewMember=false` + 백엔드 로그에 `[SignUp] ...` **미출력** 확인. `memberId=1` 그대로. +4. dev DB 에서 확인: + ```sql + SELECT id, social_id, social_type, nickname FROM members; + ``` + - row 가 1개만 있어야 하고 + - `social_id` 가 Firebase UID 형식(영숫자 28자 내외, 예: `XyZ12abcdef34567890ghIJklmnOpQ`)이어야 함. + - 절대 `GOOGLE_SOCIAL_ID_xxxxx` 형태면 안 됨 (그러면 변경 미반영). + +- [ ] **Step 5: 마무리 (커밋 없음)** + +Task 4 자체에는 코드 변경이 없으므로 커밋 없음. Task 1~3 의 3개 커밋만 브랜치에 남아 있어야 한다. + +```bash +git log --oneline -5 +``` +Expected: 최근 3개 커밋이 Google/Apple/Kakao Strategy 변경. + +--- + +## Notes for the implementing engineer + +- **Lombok**: `@RequiredArgsConstructor` 가 `private final` 필드를 받는 생성자를 자동 생성한다. Mockito `@InjectMocks` 와 호환된다. +- **FirebaseAuth 빈**: 이미 `AuthService` 에 주입되어 동작 중이므로 별도 설정 불필요. `firebase.admin-sdk-path` 는 `SS-Web/src/main/resources/application.yml:42` 에 등록됨. +- **`FirebaseToken` mock**: Mockito 5.x 는 final 클래스 mock 가능. 별도 `mockito-inline` 의존성 추가 필요 없음 (현행 의존성으로 동작). +- **`FirebaseAuthException`**: 생성자가 패키지 private 이지만 `mock(FirebaseAuthException.class)` 로 stub 인스턴스 생성 가능. `willThrow(인스턴스)` 사용. +- **테스트 위치**: 신규 디렉토리 `SS-Auth/src/test/java/com/elipair/spacestudyship/auth/social/` 가 자동 생성된다. 기존 `auth/jwt/`, `auth/repository/`, `auth/service/` 와 같은 레벨. +- **메모리 cue**: 메모리 `subagent_driven_commit_pattern` 에 따라 본 프로젝트는 superpowers plan workflow 에서 task 당 자동 commit OK. +- **CLAUDE.md 컨벤션**: `@SuperBuilder` 금지, `@Data` 금지, `@RequiredArgsConstructor` 는 Service/Component 에 허용. 본 변경은 `@RequiredArgsConstructor` 만 추가하므로 컨벤션 위반 없음. diff --git a/docs/superpowers/plans/2026-05-23-fuel-domain.md b/docs/superpowers/plans/2026-05-23-fuel-domain.md new file mode 100644 index 0000000..7849c16 --- /dev/null +++ b/docs/superpowers/plans/2026-05-23-fuel-domain.md @@ -0,0 +1,2298 @@ +# 연료 시스템 도메인 Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** 이슈 #26의 연료 시스템 도메인을 구현한다 — `GET /api/fuel`, `GET /api/fuel/transactions` 2개 엔드포인트와 internal `FuelService.charge/consume/initialize` API. 신규 회원 가입 시 `UserFuel` 자동 초기화는 ApplicationEvent로 비동기 결합. + +**Architecture:** SS-Study 모듈 안의 `fuel` 패키지 (Todo 도메인과 동일 패턴). 동시성 차단은 `findByUserIdForUpdate` 비관적 락. Idempotency는 `transactionId`(=PK) 사전 조회. 가입 시 SS-Auth가 `MemberCreatedEvent`를 publish하고 SS-Study의 `FuelInitializeListener`가 `BEFORE_COMMIT` phase에서 listen. + +**Tech Stack:** Spring Boot 4 / JPA + Hibernate / PostgreSQL (Flyway) / Testcontainers + JUnit 5 + Mockito / Lombok / springdoc-openapi. + +--- + +## 진행 순서 개요 + +| # | Task | 주요 산출물 | +|---|------|-----------| +| 1 | 사전 작업 (version + Migration + ErrorCode + CLAUDE.md) | V0_0_36 SQL, ErrorCode 2개 | +| 2 | Enum (TransactionType, FuelReason) | constant/ 2개 | +| 3 | UserFuel Entity (TDD) | entity + Entity 단위 테스트 | +| 4 | FuelTransaction Entity | entity | +| 5 | StudyTestApplication 갱신 | 테스트 패키지 스캔 추가 | +| 6 | UserFuelRepository (+ 테스트) | repository + repository 테스트 | +| 7 | FuelTransactionRepository (+ 테스트) | repository + repository 테스트 | +| 8 | MemberCreatedEvent (SS-Member) | event record | +| 9 | DTO 3종 (FuelResponse 등) | dto/ 3개 | +| 10 | FuelService.initialize (TDD) | service 메서드 + 테스트 | +| 11 | FuelService.getFuel (TDD) | service 메서드 + 테스트 | +| 12 | FuelService.getTransactions (TDD) | service 메서드 + 테스트 | +| 13 | FuelService.charge (TDD) | service 메서드 + 테스트 | +| 14 | FuelService.consume (TDD) | service 메서드 + 테스트 | +| 15 | FuelInitializeListener (TDD) | listener + 테스트 | +| 16 | AuthService publishEvent 수정 (+ 회귀 테스트) | AuthService, AuthServiceTest | +| 17 | FuelController (+ MockMvc 테스트) | controller + 테스트 | +| 18 | 최종 검증 (전체 빌드 + 테스트) | 빌드 통과 | + +각 Task 끝에서 commit. commit 메시지 형식: + +```text +연료 시스템 도메인 구현 : : <설명> #26 +``` + +--- + +## Task 1: 사전 작업 — version bump + Migration + ErrorCode + CLAUDE.md + +**Files:** +- Modify: `version.yml` (`0.0.35` → `0.0.36`) +- Create: `SS-Web/src/main/resources/db/migration/V0_0_36__add_fuel.sql` +- Modify: `SS-Common/src/main/java/com/elipair/spacestudyship/common/exception/ErrorCode.java` +- Modify: `CLAUDE.md` (마이그레이션 이력 표) + +- [ ] **Step 1: version.yml 버전 bump** + +`version.yml` 파일에서 두 줄 변경: +```yaml +version: "0.0.36" +version_code: 36 # app build number +``` + +(다른 줄은 그대로 유지) + +- [ ] **Step 2: 마이그레이션 SQL 작성** + +`SS-Web/src/main/resources/db/migration/V0_0_36__add_fuel.sql` 새로 생성: + +```sql +-- user_fuel: 유저당 1개 연료 잔량 레코드 +CREATE TABLE IF NOT EXISTS user_fuel ( + user_id BIGINT PRIMARY KEY, + current_fuel INTEGER NOT NULL DEFAULT 0, + total_charged INTEGER NOT NULL DEFAULT 0, + total_consumed INTEGER NOT NULL DEFAULT 0, + pending_minutes INTEGER NOT NULL DEFAULT 0, + created_at TIMESTAMP NOT NULL, + updated_at TIMESTAMP NOT NULL, + CONSTRAINT fk_user_fuel_member FOREIGN KEY (user_id) + REFERENCES members(id) ON DELETE CASCADE, + CONSTRAINT chk_fuel_non_negative CHECK (current_fuel >= 0), + CONSTRAINT chk_total_charged_non_negative CHECK (total_charged >= 0), + CONSTRAINT chk_total_consumed_non_negative CHECK (total_consumed >= 0), + CONSTRAINT chk_pending_minutes_non_negative CHECK (pending_minutes >= 0) +); + +-- fuel_transactions: 충전/소비 거래 내역 +CREATE TABLE IF NOT EXISTS fuel_transactions ( + id VARCHAR(36) PRIMARY KEY, + user_id BIGINT NOT NULL, + type VARCHAR(10) NOT NULL, + amount INTEGER NOT NULL, + reason VARCHAR(30) NOT NULL, + reference_id VARCHAR(50), + balance_after INTEGER NOT NULL, + created_at TIMESTAMP NOT NULL, + updated_at TIMESTAMP NOT NULL, + CONSTRAINT fk_fuel_transactions_member FOREIGN KEY (user_id) + REFERENCES members(id) ON DELETE CASCADE, + CONSTRAINT chk_fuel_tx_amount_positive CHECK (amount > 0), + CONSTRAINT chk_fuel_tx_type CHECK (type IN ('CHARGE','CONSUME')), + CONSTRAINT chk_fuel_tx_reason CHECK (reason IN ('STUDY_SESSION','EXPLORATION_UNLOCK')) +); + +CREATE INDEX IF NOT EXISTS idx_fuel_transactions_user_created + ON fuel_transactions (user_id, created_at DESC); +``` + +- [ ] **Step 3: ErrorCode 2개 추가** + +`SS-Common/src/main/java/com/elipair/spacestudyship/common/exception/ErrorCode.java`에서 `// Todo Category` 블록 뒤, `// Common` 블록 앞에 추가: + +```java + // Fuel + INSUFFICIENT_FUEL(HttpStatus.BAD_REQUEST, "연료가 부족합니다."), + FUEL_NOT_INITIALIZED(HttpStatus.INTERNAL_SERVER_ERROR, "연료 정보가 초기화되지 않았습니다."), +``` + +- [ ] **Step 4: CLAUDE.md 마이그레이션 이력 표 갱신** + +`CLAUDE.md` "### 현재 마이그레이션 이력" 표 마지막 줄 뒤에 추가: + +```markdown +| 0.0.36 | `V0_0_36__add_fuel.sql` | `user_fuel`, `fuel_transactions` 테이블 생성 (CHECK 제약, FK CASCADE) | +``` + +- [ ] **Step 5: 컴파일 확인** + +```bash +./gradlew :SS-Common:compileJava +``` +Expected: BUILD SUCCESSFUL (ErrorCode 컴파일 OK) + +- [ ] **Step 6: Commit** + +```bash +git add version.yml SS-Web/src/main/resources/db/migration/V0_0_36__add_fuel.sql \ + SS-Common/src/main/java/com/elipair/spacestudyship/common/exception/ErrorCode.java \ + CLAUDE.md +git commit -m "연료 시스템 도메인 구현 : chore : 사전 작업 (version 0.0.36, V0_0_36 마이그레이션, ErrorCode 2개) #26" +``` + +--- + +## Task 2: Enum 2개 추가 — TransactionType, FuelReason + +**Files:** +- Create: `SS-Study/src/main/java/com/elipair/spacestudyship/study/fuel/constant/TransactionType.java` +- Create: `SS-Study/src/main/java/com/elipair/spacestudyship/study/fuel/constant/FuelReason.java` + +- [ ] **Step 1: TransactionType 생성** + +```java +package com.elipair.spacestudyship.study.fuel.constant; + +public enum TransactionType { + CHARGE, + CONSUME +} +``` + +- [ ] **Step 2: FuelReason 생성** + +```java +package com.elipair.spacestudyship.study.fuel.constant; + +public enum FuelReason { + STUDY_SESSION, // charge: 공부 세션 완료 + EXPLORATION_UNLOCK // consume: 행성/지역 해금 +} +``` + +- [ ] **Step 3: 컴파일 확인** + +```bash +./gradlew :SS-Study:compileJava +``` +Expected: BUILD SUCCESSFUL + +- [ ] **Step 4: Commit** + +```bash +git add SS-Study/src/main/java/com/elipair/spacestudyship/study/fuel/constant/ +git commit -m "연료 시스템 도메인 구현 : feat : TransactionType/FuelReason Enum 추가 #26" +``` + +--- + +## Task 3: UserFuel Entity (TDD) + +**Files:** +- Create: `SS-Study/src/test/java/com/elipair/spacestudyship/study/fuel/entity/UserFuelTest.java` +- Create: `SS-Study/src/main/java/com/elipair/spacestudyship/study/fuel/entity/UserFuel.java` + +- [ ] **Step 1: UserFuelTest 작성 (RED)** + +`SS-Study/src/test/java/com/elipair/spacestudyship/study/fuel/entity/UserFuelTest.java`: + +```java +package com.elipair.spacestudyship.study.fuel.entity; + +import com.elipair.spacestudyship.common.exception.CustomException; +import com.elipair.spacestudyship.common.exception.ErrorCode; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +class UserFuelTest { + + @Test + @DisplayName("initialize: 신규 회원 초기화 시 모든 값 0") + void initialize_allZero() { + UserFuel fuel = UserFuel.initialize(1L); + + assertThat(fuel.getUserId()).isEqualTo(1L); + assertThat(fuel.getCurrentFuel()).isZero(); + assertThat(fuel.getTotalCharged()).isZero(); + assertThat(fuel.getTotalConsumed()).isZero(); + assertThat(fuel.getPendingMinutes()).isZero(); + } + + @Test + @DisplayName("charge: 양수 충전 시 currentFuel과 totalCharged 증가, totalConsumed 불변") + void charge_increase() { + UserFuel fuel = UserFuel.initialize(1L); + + fuel.charge(90); + + assertThat(fuel.getCurrentFuel()).isEqualTo(90); + assertThat(fuel.getTotalCharged()).isEqualTo(90); + assertThat(fuel.getTotalConsumed()).isZero(); + } + + @Test + @DisplayName("consume: 잔량 이하 소비 시 currentFuel 감소, totalConsumed 증가, totalCharged 불변") + void consume_decrease() { + UserFuel fuel = UserFuel.initialize(1L); + fuel.charge(100); + + fuel.consume(50); + + assertThat(fuel.getCurrentFuel()).isEqualTo(50); + assertThat(fuel.getTotalConsumed()).isEqualTo(50); + assertThat(fuel.getTotalCharged()).isEqualTo(100); + } + + @Test + @DisplayName("consume: 정확히 잔량만큼 소비 시 currentFuel = 0") + void consume_exact() { + UserFuel fuel = UserFuel.initialize(1L); + fuel.charge(100); + + fuel.consume(100); + + assertThat(fuel.getCurrentFuel()).isZero(); + assertThat(fuel.getTotalConsumed()).isEqualTo(100); + } + + @Test + @DisplayName("charge: amount=0이면 INVALID_INPUT_VALUE") + void charge_zero_throws() { + UserFuel fuel = UserFuel.initialize(1L); + + assertThatThrownBy(() -> fuel.charge(0)) + .isInstanceOf(CustomException.class) + .extracting("errorCode").isEqualTo(ErrorCode.INVALID_INPUT_VALUE); + } + + @Test + @DisplayName("charge: amount<0이면 INVALID_INPUT_VALUE") + void charge_negative_throws() { + UserFuel fuel = UserFuel.initialize(1L); + + assertThatThrownBy(() -> fuel.charge(-5)) + .isInstanceOf(CustomException.class) + .extracting("errorCode").isEqualTo(ErrorCode.INVALID_INPUT_VALUE); + } + + @Test + @DisplayName("consume: amount=0이면 INVALID_INPUT_VALUE") + void consume_zero_throws() { + UserFuel fuel = UserFuel.initialize(1L); + + assertThatThrownBy(() -> fuel.consume(0)) + .isInstanceOf(CustomException.class) + .extracting("errorCode").isEqualTo(ErrorCode.INVALID_INPUT_VALUE); + } + + @Test + @DisplayName("consume: 잔량 부족 시 INSUFFICIENT_FUEL") + void consume_insufficient_throws() { + UserFuel fuel = UserFuel.initialize(1L); + fuel.charge(30); + + assertThatThrownBy(() -> fuel.consume(50)) + .isInstanceOf(CustomException.class) + .extracting("errorCode").isEqualTo(ErrorCode.INSUFFICIENT_FUEL); + } +} +``` + +> **참고**: `CustomException`의 `errorCode` 필드명은 SS-Common 코드 기준. 만약 필드명이 다르면(`code` 등) 그대로 맞춰 변경. + +- [ ] **Step 2: 테스트 실행해 컴파일 실패 확인 (RED)** + +```bash +./gradlew :SS-Study:test --tests com.elipair.spacestudyship.study.fuel.entity.UserFuelTest +``` +Expected: COMPILE FAIL — `UserFuel` 클래스 없음 + +- [ ] **Step 3: UserFuel Entity 작성** + +`SS-Study/src/main/java/com/elipair/spacestudyship/study/fuel/entity/UserFuel.java`: + +```java +package com.elipair.spacestudyship.study.fuel.entity; + +import com.elipair.spacestudyship.common.entity.BaseTimeEntity; +import com.elipair.spacestudyship.common.exception.CustomException; +import com.elipair.spacestudyship.common.exception.ErrorCode; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.Id; +import jakarta.persistence.Table; +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Entity +@Table(name = "user_fuel") +@Getter +@Builder +@AllArgsConstructor +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class UserFuel extends BaseTimeEntity { + + @Id + @Column(name = "user_id") + private Long userId; + + @Column(name = "current_fuel", nullable = false) + private Integer currentFuel; + + @Column(name = "total_charged", nullable = false) + private Integer totalCharged; + + @Column(name = "total_consumed", nullable = false) + private Integer totalConsumed; + + @Column(name = "pending_minutes", nullable = false) + private Integer pendingMinutes; + + public static UserFuel initialize(Long userId) { + return UserFuel.builder() + .userId(userId) + .currentFuel(0) + .totalCharged(0) + .totalConsumed(0) + .pendingMinutes(0) + .build(); + } + + public void charge(int amount) { + if (amount <= 0) throw new CustomException(ErrorCode.INVALID_INPUT_VALUE); + this.currentFuel += amount; + this.totalCharged += amount; + } + + public void consume(int amount) { + if (amount <= 0) throw new CustomException(ErrorCode.INVALID_INPUT_VALUE); + if (this.currentFuel < amount) { + throw new CustomException(ErrorCode.INSUFFICIENT_FUEL); + } + this.currentFuel -= amount; + this.totalConsumed += amount; + } +} +``` + +- [ ] **Step 4: 테스트 실행해 통과 확인 (GREEN)** + +```bash +./gradlew :SS-Study:test --tests com.elipair.spacestudyship.study.fuel.entity.UserFuelTest +``` +Expected: BUILD SUCCESSFUL, 8 tests PASS + +- [ ] **Step 5: Commit** + +```bash +git add SS-Study/src/main/java/com/elipair/spacestudyship/study/fuel/entity/UserFuel.java \ + SS-Study/src/test/java/com/elipair/spacestudyship/study/fuel/entity/UserFuelTest.java +git commit -m "연료 시스템 도메인 구현 : feat : UserFuel Entity (charge/consume/initialize, 단위 테스트) #26" +``` + +--- + +## Task 4: FuelTransaction Entity + +**Files:** +- Create: `SS-Study/src/main/java/com/elipair/spacestudyship/study/fuel/entity/FuelTransaction.java` + +> 단순 데이터 컨테이너라 Entity 단위 테스트는 생략 — 의미 있는 비즈니스 로직 없음. Repository 테스트(Task 7)와 Service 테스트에서 검증. + +- [ ] **Step 1: FuelTransaction Entity 작성** + +```java +package com.elipair.spacestudyship.study.fuel.entity; + +import com.elipair.spacestudyship.common.entity.BaseTimeEntity; +import com.elipair.spacestudyship.study.fuel.constant.FuelReason; +import com.elipair.spacestudyship.study.fuel.constant.TransactionType; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.Id; +import jakarta.persistence.Table; +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Entity +@Table(name = "fuel_transactions") +@Getter +@Builder +@AllArgsConstructor +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class FuelTransaction extends BaseTimeEntity { + + @Id + @Column(length = 36) + private String id; + + @Column(name = "user_id", nullable = false) + private Long userId; + + @Enumerated(EnumType.STRING) + @Column(nullable = false, length = 10) + private TransactionType type; + + @Column(nullable = false) + private Integer amount; + + @Enumerated(EnumType.STRING) + @Column(nullable = false, length = 30) + private FuelReason reason; + + @Column(name = "reference_id", length = 50) + private String referenceId; + + @Column(name = "balance_after", nullable = false) + private Integer balanceAfter; + + public static FuelTransaction of(String id, Long userId, TransactionType type, + int amount, FuelReason reason, + String referenceId, int balanceAfter) { + return FuelTransaction.builder() + .id(id) + .userId(userId) + .type(type) + .amount(amount) + .reason(reason) + .referenceId(referenceId) + .balanceAfter(balanceAfter) + .build(); + } +} +``` + +- [ ] **Step 2: 컴파일 확인** + +```bash +./gradlew :SS-Study:compileJava +``` +Expected: BUILD SUCCESSFUL + +- [ ] **Step 3: Commit** + +```bash +git add SS-Study/src/main/java/com/elipair/spacestudyship/study/fuel/entity/FuelTransaction.java +git commit -m "연료 시스템 도메인 구현 : feat : FuelTransaction Entity 추가 #26" +``` + +--- + +## Task 5: StudyTestApplication 갱신 (fuel repository 스캔 추가) + +**Files:** +- Modify: `SS-Study/src/test/java/com/elipair/spacestudyship/study/StudyTestApplication.java` + +기존 어노테이션 `@EnableJpaRepositories(basePackages = "com.elipair.spacestudyship.study.todo.repository")`을 fuel까지 포함하도록 변경. + +- [ ] **Step 1: StudyTestApplication 어노테이션 수정** + +기존 22번 라인: +```java +@EnableJpaRepositories(basePackages = "com.elipair.spacestudyship.study.todo.repository") +``` + +변경: +```java +@EnableJpaRepositories(basePackages = { + "com.elipair.spacestudyship.study.todo.repository", + "com.elipair.spacestudyship.study.fuel.repository" +}) +``` + +> `@AutoConfigurationPackage(basePackages = "com.elipair.spacestudyship")`는 이미 전체 패키지 스캔이라 Entity 추가 스캔 불요. + +- [ ] **Step 2: 기존 Todo 테스트가 여전히 통과하는지 확인** + +```bash +./gradlew :SS-Study:test --tests "com.elipair.spacestudyship.study.todo.*" +``` +Expected: BUILD SUCCESSFUL (기존 Todo 테스트 회귀 없음) + +- [ ] **Step 3: Commit** + +```bash +git add SS-Study/src/test/java/com/elipair/spacestudyship/study/StudyTestApplication.java +git commit -m "연료 시스템 도메인 구현 : test : StudyTestApplication에 fuel repository 스캔 추가 #26" +``` + +--- + +## Task 6: UserFuelRepository + Repository 테스트 + +**Files:** +- Create: `SS-Study/src/main/java/com/elipair/spacestudyship/study/fuel/repository/UserFuelRepository.java` +- Create: `SS-Study/src/test/java/com/elipair/spacestudyship/study/fuel/repository/UserFuelRepositoryTest.java` + +> 실제 PostgreSQL 컨테이너에서 CHECK 제약·락 동작 검증을 위해 Testcontainers 기반 통합 테스트. + +- [ ] **Step 1: UserFuelRepositoryTest 작성 (RED)** + +```java +package com.elipair.spacestudyship.study.fuel.repository; + +import com.elipair.spacestudyship.study.StudyTestApplication; +import com.elipair.spacestudyship.study.fuel.entity.UserFuel; +import jakarta.persistence.EntityManager; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.dao.DataIntegrityViolationException; +import org.springframework.transaction.annotation.Transactional; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +@SpringBootTest(classes = StudyTestApplication.class) +@Transactional +class UserFuelRepositoryTest { + + @Autowired UserFuelRepository userFuelRepository; + @Autowired EntityManager em; + + @Test + @DisplayName("findByUserId: 초기화된 UserFuel 조회") + void findByUserId_returnsExisting() { + userFuelRepository.saveAndFlush(UserFuel.initialize(1L)); + + assertThat(userFuelRepository.findByUserId(1L)).isPresent(); + assertThat(userFuelRepository.findByUserId(999L)).isNotPresent(); + } + + @Test + @DisplayName("existsByUserId: 존재 여부 boolean 반환") + void existsByUserId_basic() { + userFuelRepository.saveAndFlush(UserFuel.initialize(1L)); + + assertThat(userFuelRepository.existsByUserId(1L)).isTrue(); + assertThat(userFuelRepository.existsByUserId(999L)).isFalse(); + } + + @Test + @DisplayName("findByUserIdForUpdate: 락 획득 후 row 반환 (smoke)") + void findByUserIdForUpdate_returnsRow() { + userFuelRepository.saveAndFlush(UserFuel.initialize(1L)); + + assertThat(userFuelRepository.findByUserIdForUpdate(1L)).isPresent(); + } + + @Test + @DisplayName("current_fuel을 음수로 update 시 CHECK 제약으로 실패") + void checkConstraint_currentFuelNonNegative() { + userFuelRepository.saveAndFlush(UserFuel.initialize(1L)); + + assertThatThrownBy(() -> { + em.createNativeQuery("UPDATE user_fuel SET current_fuel = -1 WHERE user_id = 1") + .executeUpdate(); + em.flush(); + }).isInstanceOf(Exception.class); // DataIntegrityViolation 또는 PSQLException 포함 + } +} +``` + +- [ ] **Step 2: 컴파일 실패 확인 (RED)** + +```bash +./gradlew :SS-Study:test --tests com.elipair.spacestudyship.study.fuel.repository.UserFuelRepositoryTest +``` +Expected: COMPILE FAIL — `UserFuelRepository` 없음 + +- [ ] **Step 3: UserFuelRepository 작성** + +`SS-Study/src/main/java/com/elipair/spacestudyship/study/fuel/repository/UserFuelRepository.java`: + +```java +package com.elipair.spacestudyship.study.fuel.repository; + +import com.elipair.spacestudyship.study.fuel.entity.UserFuel; +import jakarta.persistence.LockModeType; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Lock; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +import java.util.Optional; + +public interface UserFuelRepository extends JpaRepository { + + Optional findByUserId(Long userId); + + @Lock(LockModeType.PESSIMISTIC_WRITE) + @Query("SELECT uf FROM UserFuel uf WHERE uf.userId = :userId") + Optional findByUserIdForUpdate(@Param("userId") Long userId); + + boolean existsByUserId(Long userId); +} +``` + +- [ ] **Step 4: 테스트 실행해 통과 확인 (GREEN)** + +```bash +./gradlew :SS-Study:test --tests com.elipair.spacestudyship.study.fuel.repository.UserFuelRepositoryTest +``` +Expected: 4 tests PASS + +- [ ] **Step 5: Commit** + +```bash +git add SS-Study/src/main/java/com/elipair/spacestudyship/study/fuel/repository/UserFuelRepository.java \ + SS-Study/src/test/java/com/elipair/spacestudyship/study/fuel/repository/UserFuelRepositoryTest.java +git commit -m "연료 시스템 도메인 구현 : feat : UserFuelRepository (findByUserIdForUpdate 비관적 락 포함) #26" +``` + +--- + +## Task 7: FuelTransactionRepository + Repository 테스트 + +**Files:** +- Create: `SS-Study/src/main/java/com/elipair/spacestudyship/study/fuel/repository/FuelTransactionRepository.java` +- Create: `SS-Study/src/test/java/com/elipair/spacestudyship/study/fuel/repository/FuelTransactionRepositoryTest.java` + +- [ ] **Step 1: FuelTransactionRepositoryTest 작성 (RED)** + +```java +package com.elipair.spacestudyship.study.fuel.repository; + +import com.elipair.spacestudyship.study.StudyTestApplication; +import com.elipair.spacestudyship.study.fuel.constant.FuelReason; +import com.elipair.spacestudyship.study.fuel.constant.TransactionType; +import com.elipair.spacestudyship.study.fuel.entity.FuelTransaction; +import jakarta.persistence.EntityManager; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Sort; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDateTime; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +@SpringBootTest(classes = StudyTestApplication.class) +@Transactional +class FuelTransactionRepositoryTest { + + @Autowired FuelTransactionRepository transactionRepository; + @Autowired EntityManager em; + + @Test + @DisplayName("findByFilters: type/날짜 모두 null이면 user의 모든 거래, createdAt DESC") + void findByFilters_noFilter() throws InterruptedException { + save("t1", 1L, TransactionType.CHARGE, 100, FuelReason.STUDY_SESSION, "s1", 100); + Thread.sleep(5); + save("t2", 1L, TransactionType.CONSUME, 30, FuelReason.EXPLORATION_UNLOCK, "r1", 70); + save("t3", 2L, TransactionType.CHARGE, 50, FuelReason.STUDY_SESSION, "s2", 50); + + Page page = transactionRepository.findByFilters( + 1L, null, null, null, + PageRequest.of(0, 20, Sort.by(Sort.Direction.DESC, "createdAt"))); + + assertThat(page.getContent()).hasSize(2); + assertThat(page.getContent().get(0).getId()).isEqualTo("t2"); + assertThat(page.getContent().get(1).getId()).isEqualTo("t1"); + } + + @Test + @DisplayName("findByFilters: type=CHARGE 필터") + void findByFilters_typeCharge() { + save("t1", 1L, TransactionType.CHARGE, 100, FuelReason.STUDY_SESSION, "s1", 100); + save("t2", 1L, TransactionType.CONSUME, 30, FuelReason.EXPLORATION_UNLOCK, "r1", 70); + + Page page = transactionRepository.findByFilters( + 1L, TransactionType.CHARGE, null, null, + PageRequest.of(0, 20, Sort.by(Sort.Direction.DESC, "createdAt"))); + + assertThat(page.getContent()).hasSize(1); + assertThat(page.getContent().get(0).getId()).isEqualTo("t1"); + } + + @Test + @DisplayName("findByFilters: 날짜 범위 [start, end) 검증") + void findByFilters_dateRange() { + LocalDateTime today = LocalDateTime.now(); + // 임의로 1건 저장하고 시간 범위로 필터 + save("t1", 1L, TransactionType.CHARGE, 100, FuelReason.STUDY_SESSION, "s1", 100); + + Page in = transactionRepository.findByFilters( + 1L, null, today.minusDays(1), today.plusDays(1), + PageRequest.of(0, 20, Sort.by(Sort.Direction.DESC, "createdAt"))); + assertThat(in.getContent()).hasSize(1); + + Page out = transactionRepository.findByFilters( + 1L, null, today.plusDays(2), today.plusDays(3), + PageRequest.of(0, 20, Sort.by(Sort.Direction.DESC, "createdAt"))); + assertThat(out.getContent()).isEmpty(); + } + + @Test + @DisplayName("findByFilters: 페이지네이션 동작 - size=2, page=0/1") + void findByFilters_pagination() throws InterruptedException { + for (int i = 1; i <= 5; i++) { + save("t" + i, 1L, TransactionType.CHARGE, 10, FuelReason.STUDY_SESSION, "s" + i, 10); + Thread.sleep(2); + } + + Page p0 = transactionRepository.findByFilters( + 1L, null, null, null, + PageRequest.of(0, 2, Sort.by(Sort.Direction.DESC, "createdAt"))); + Page p1 = transactionRepository.findByFilters( + 1L, null, null, null, + PageRequest.of(1, 2, Sort.by(Sort.Direction.DESC, "createdAt"))); + + assertThat(p0.getTotalElements()).isEqualTo(5); + assertThat(p0.getTotalPages()).isEqualTo(3); + assertThat(p0.getContent()).hasSize(2); + assertThat(p1.getContent()).hasSize(2); + assertThat(p0.getContent().get(0).getId()).isNotEqualTo(p1.getContent().get(0).getId()); + } + + @Test + @DisplayName("CHECK 제약: amount=0 native insert 시 실패") + void checkConstraint_amountPositive() { + assertThatThrownBy(() -> { + em.createNativeQuery(""" + INSERT INTO fuel_transactions + (id, user_id, type, amount, reason, balance_after, created_at, updated_at) + VALUES ('tx-zero', 1, 'CHARGE', 0, 'STUDY_SESSION', 0, NOW(), NOW()) + """).executeUpdate(); + em.flush(); + }).isInstanceOf(Exception.class); + } + + private void save(String id, Long userId, TransactionType type, int amount, + FuelReason reason, String refId, int balanceAfter) { + transactionRepository.saveAndFlush(FuelTransaction.of(id, userId, type, amount, reason, refId, balanceAfter)); + } +} +``` + +- [ ] **Step 2: 컴파일 실패 확인 (RED)** + +```bash +./gradlew :SS-Study:test --tests com.elipair.spacestudyship.study.fuel.repository.FuelTransactionRepositoryTest +``` +Expected: COMPILE FAIL — `FuelTransactionRepository` 없음 + +- [ ] **Step 3: FuelTransactionRepository 작성** + +```java +package com.elipair.spacestudyship.study.fuel.repository; + +import com.elipair.spacestudyship.study.fuel.constant.TransactionType; +import com.elipair.spacestudyship.study.fuel.entity.FuelTransaction; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +import java.time.LocalDateTime; + +public interface FuelTransactionRepository extends JpaRepository { + + @Query(""" + SELECT ft FROM FuelTransaction ft + WHERE ft.userId = :userId + AND (:type IS NULL OR ft.type = :type) + AND (:startDateTime IS NULL OR ft.createdAt >= :startDateTime) + AND (:endDateTime IS NULL OR ft.createdAt < :endDateTime) + """) + Page findByFilters( + @Param("userId") Long userId, + @Param("type") TransactionType type, + @Param("startDateTime") LocalDateTime startDateTime, + @Param("endDateTime") LocalDateTime endDateTime, + Pageable pageable); +} +``` + +- [ ] **Step 4: 테스트 실행해 통과 확인 (GREEN)** + +```bash +./gradlew :SS-Study:test --tests com.elipair.spacestudyship.study.fuel.repository.FuelTransactionRepositoryTest +``` +Expected: 5 tests PASS + +- [ ] **Step 5: Commit** + +```bash +git add SS-Study/src/main/java/com/elipair/spacestudyship/study/fuel/repository/FuelTransactionRepository.java \ + SS-Study/src/test/java/com/elipair/spacestudyship/study/fuel/repository/FuelTransactionRepositoryTest.java +git commit -m "연료 시스템 도메인 구현 : feat : FuelTransactionRepository (필터/페이지네이션 쿼리, 통합 테스트) #26" +``` + +--- + +## Task 8: MemberCreatedEvent (SS-Member) + +**Files:** +- Create: `SS-Member/src/main/java/com/elipair/spacestudyship/member/event/MemberCreatedEvent.java` + +- [ ] **Step 1: 디렉토리 확인** + +```bash +ls /Users/luca/workspace/Java_Spring/space_study_ship/SS-Member/src/main/java/com/elipair/spacestudyship/member/ +``` +Expected: 기존 `entity`, `repository`, `constant` 등이 있음. `event` 폴더는 신규 생성. + +- [ ] **Step 2: MemberCreatedEvent 작성** + +```java +package com.elipair.spacestudyship.member.event; + +public record MemberCreatedEvent(Long memberId) { +} +``` + +- [ ] **Step 3: 컴파일 확인** + +```bash +./gradlew :SS-Member:compileJava +``` +Expected: BUILD SUCCESSFUL + +- [ ] **Step 4: Commit** + +```bash +git add SS-Member/src/main/java/com/elipair/spacestudyship/member/event/MemberCreatedEvent.java +git commit -m "연료 시스템 도메인 구현 : feat : MemberCreatedEvent record 추가 (SS-Member) #26" +``` + +--- + +## Task 9: DTO 3개 (FuelResponse, FuelTransactionResponse, FuelTransactionListResponse) + +**Files:** +- Create: `SS-Study/src/main/java/com/elipair/spacestudyship/study/fuel/dto/FuelResponse.java` +- Create: `SS-Study/src/main/java/com/elipair/spacestudyship/study/fuel/dto/FuelTransactionResponse.java` +- Create: `SS-Study/src/main/java/com/elipair/spacestudyship/study/fuel/dto/FuelTransactionListResponse.java` + +> DTO는 단순 record + from() 정적 메서드. Service/Controller 테스트에서 통합 검증. + +- [ ] **Step 1: FuelResponse 작성** + +```java +package com.elipair.spacestudyship.study.fuel.dto; + +import com.elipair.spacestudyship.study.fuel.entity.UserFuel; +import io.swagger.v3.oas.annotations.media.Schema; + +import java.time.LocalDateTime; +import java.time.ZoneOffset; +import java.time.format.DateTimeFormatter; + +@Schema(description = "연료 잔량 응답") +public record FuelResponse( + @Schema(description = "현재 보유 연료", example = "350") Integer currentFuel, + @Schema(description = "누적 충전량", example = "1200") Integer totalCharged, + @Schema(description = "누적 소비량", example = "850") Integer totalConsumed, + @Schema(description = "미동기화 시간(분) - 향후 확장용, 현재 항상 0", example = "0") Integer pendingMinutes, + @Schema(description = "마지막 변동 시각 (ISO 8601 UTC)", example = "2026-04-16T10:30:00Z") String lastUpdatedAt +) { + private static final DateTimeFormatter ISO_UTC = DateTimeFormatter.ISO_INSTANT; + + public static FuelResponse from(UserFuel fuel) { + return new FuelResponse( + fuel.getCurrentFuel(), + fuel.getTotalCharged(), + fuel.getTotalConsumed(), + fuel.getPendingMinutes(), + formatUtc(fuel.getUpdatedAt()) + ); + } + + private static String formatUtc(LocalDateTime time) { + return time == null ? null : ISO_UTC.format(time.toInstant(ZoneOffset.UTC)); + } +} +``` + +- [ ] **Step 2: FuelTransactionResponse 작성** + +```java +package com.elipair.spacestudyship.study.fuel.dto; + +import com.elipair.spacestudyship.study.fuel.entity.FuelTransaction; +import io.swagger.v3.oas.annotations.media.Schema; + +import java.time.LocalDateTime; +import java.time.ZoneOffset; +import java.time.format.DateTimeFormatter; + +@Schema(description = "연료 거래 내역") +public record FuelTransactionResponse( + @Schema(example = "tx-uuid-1234") String id, + + @Schema(description = "charge 또는 consume", + allowableValues = {"charge", "consume"}, example = "charge") + String type, + + @Schema(example = "90") Integer amount, + + @Schema(description = "거래 사유", + allowableValues = {"STUDY_SESSION", "EXPLORATION_UNLOCK"}, + example = "STUDY_SESSION") + String reason, + + @Schema(nullable = true, example = "session-uuid-5678") String referenceId, + @Schema(example = "350") Integer balanceAfter, + @Schema(example = "2026-04-16T10:30:00Z") String createdAt +) { + private static final DateTimeFormatter ISO_UTC = DateTimeFormatter.ISO_INSTANT; + + public static FuelTransactionResponse from(FuelTransaction tx) { + return new FuelTransactionResponse( + tx.getId(), + tx.getType().name().toLowerCase(), + tx.getAmount(), + tx.getReason().name(), + tx.getReferenceId(), + tx.getBalanceAfter(), + formatUtc(tx.getCreatedAt()) + ); + } + + private static String formatUtc(LocalDateTime time) { + return time == null ? null : ISO_UTC.format(time.toInstant(ZoneOffset.UTC)); + } +} +``` + +- [ ] **Step 3: FuelTransactionListResponse 작성** + +```java +package com.elipair.spacestudyship.study.fuel.dto; + +import com.elipair.spacestudyship.study.fuel.entity.FuelTransaction; +import io.swagger.v3.oas.annotations.media.Schema; +import org.springframework.data.domain.Page; + +import java.util.List; + +@Schema(description = "거래 내역 페이지 응답") +public record FuelTransactionListResponse( + List content, + Integer page, + Integer size, + Long totalElements, + Integer totalPages +) { + public static FuelTransactionListResponse from(Page page) { + return new FuelTransactionListResponse( + page.getContent().stream().map(FuelTransactionResponse::from).toList(), + page.getNumber(), + page.getSize(), + page.getTotalElements(), + page.getTotalPages() + ); + } +} +``` + +- [ ] **Step 4: 컴파일 확인** + +```bash +./gradlew :SS-Study:compileJava +``` +Expected: BUILD SUCCESSFUL + +- [ ] **Step 5: Commit** + +```bash +git add SS-Study/src/main/java/com/elipair/spacestudyship/study/fuel/dto/ +git commit -m "연료 시스템 도메인 구현 : feat : FuelResponse/FuelTransactionResponse/ListResponse DTO 3종 #26" +``` + +--- + +## Task 10: FuelService.initialize (TDD) + +**Files:** +- Create: `SS-Study/src/main/java/com/elipair/spacestudyship/study/fuel/service/FuelService.java` (skeleton + initialize) +- Create: `SS-Study/src/test/java/com/elipair/spacestudyship/study/fuel/service/FuelServiceTest.java` (initialize 테스트만) + +> 이후 Task 11~14에서 같은 클래스/테스트 파일에 메서드를 추가해 나간다. + +- [ ] **Step 1: FuelServiceTest 작성 (initialize만, RED)** + +```java +package com.elipair.spacestudyship.study.fuel.service; + +import com.elipair.spacestudyship.study.fuel.entity.UserFuel; +import com.elipair.spacestudyship.study.fuel.repository.FuelTransactionRepository; +import com.elipair.spacestudyship.study.fuel.repository.UserFuelRepository; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +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.times; +import static org.mockito.Mockito.verify; + +@ExtendWith(MockitoExtension.class) +class FuelServiceTest { + + @Mock UserFuelRepository userFuelRepository; + @Mock FuelTransactionRepository transactionRepository; + @InjectMocks FuelService fuelService; + + @Test + @DisplayName("initialize: 미존재 회원이면 UserFuel.initialize 저장") + void initialize_newMember_saves() { + given(userFuelRepository.existsByUserId(1L)).willReturn(false); + + fuelService.initialize(1L); + + ArgumentCaptor captor = ArgumentCaptor.forClass(UserFuel.class); + verify(userFuelRepository, times(1)).save(captor.capture()); + UserFuel saved = captor.getValue(); + assertThat(saved.getUserId()).isEqualTo(1L); + assertThat(saved.getCurrentFuel()).isZero(); + assertThat(saved.getTotalCharged()).isZero(); + assertThat(saved.getTotalConsumed()).isZero(); + assertThat(saved.getPendingMinutes()).isZero(); + } + + @Test + @DisplayName("initialize: 이미 존재하면 skip (save 호출 없음)") + void initialize_existing_skips() { + given(userFuelRepository.existsByUserId(1L)).willReturn(true); + + fuelService.initialize(1L); + + verify(userFuelRepository, never()).save(any()); + } +} +``` + +- [ ] **Step 2: 컴파일 실패 확인 (RED)** + +```bash +./gradlew :SS-Study:test --tests com.elipair.spacestudyship.study.fuel.service.FuelServiceTest +``` +Expected: COMPILE FAIL — `FuelService` 없음 + +- [ ] **Step 3: FuelService skeleton + initialize 작성** + +`SS-Study/src/main/java/com/elipair/spacestudyship/study/fuel/service/FuelService.java`: + +```java +package com.elipair.spacestudyship.study.fuel.service; + +import com.elipair.spacestudyship.study.fuel.entity.UserFuel; +import com.elipair.spacestudyship.study.fuel.repository.FuelTransactionRepository; +import com.elipair.spacestudyship.study.fuel.repository.UserFuelRepository; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Slf4j +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class FuelService { + + private final UserFuelRepository userFuelRepository; + private final FuelTransactionRepository transactionRepository; + + @Transactional + public void initialize(Long userId) { + if (userFuelRepository.existsByUserId(userId)) { + log.info("[Fuel] 초기화 스킵 (이미 존재) | userId={}", userId); + return; + } + userFuelRepository.save(UserFuel.initialize(userId)); + log.info("[Fuel] 초기화 | userId={}", userId); + } +} +``` + +- [ ] **Step 4: 테스트 통과 확인 (GREEN)** + +```bash +./gradlew :SS-Study:test --tests com.elipair.spacestudyship.study.fuel.service.FuelServiceTest +``` +Expected: 2 tests PASS + +- [ ] **Step 5: Commit** + +```bash +git add SS-Study/src/main/java/com/elipair/spacestudyship/study/fuel/service/FuelService.java \ + SS-Study/src/test/java/com/elipair/spacestudyship/study/fuel/service/FuelServiceTest.java +git commit -m "연료 시스템 도메인 구현 : feat : FuelService.initialize (가입 이벤트로부터 UserFuel 생성) #26" +``` + +--- + +## Task 11: FuelService.getFuel (TDD) + +**Files:** +- Modify: `SS-Study/src/main/java/com/elipair/spacestudyship/study/fuel/service/FuelService.java` +- Modify: `SS-Study/src/test/java/com/elipair/spacestudyship/study/fuel/service/FuelServiceTest.java` + +- [ ] **Step 1: 테스트 추가 (RED)** + +`FuelServiceTest`에 다음 2개 테스트 추가 (기존 import 활용): + +```java +import com.elipair.spacestudyship.common.exception.CustomException; +import com.elipair.spacestudyship.common.exception.ErrorCode; +import com.elipair.spacestudyship.study.fuel.dto.FuelResponse; + +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +@Test +@DisplayName("getFuel: 존재 시 FuelResponse 반환") +void getFuel_existing_returnsResponse() { + UserFuel fuel = UserFuel.initialize(1L); + fuel.charge(100); + given(userFuelRepository.findByUserId(1L)).willReturn(Optional.of(fuel)); + + FuelResponse response = fuelService.getFuel(1L); + + assertThat(response.currentFuel()).isEqualTo(100); + assertThat(response.totalCharged()).isEqualTo(100); + assertThat(response.totalConsumed()).isZero(); + assertThat(response.pendingMinutes()).isZero(); +} + +@Test +@DisplayName("getFuel: 미초기화면 FUEL_NOT_INITIALIZED") +void getFuel_notInitialized_throws() { + given(userFuelRepository.findByUserId(1L)).willReturn(Optional.empty()); + + assertThatThrownBy(() -> fuelService.getFuel(1L)) + .isInstanceOf(CustomException.class) + .extracting("errorCode").isEqualTo(ErrorCode.FUEL_NOT_INITIALIZED); +} +``` + +- [ ] **Step 2: 테스트 실패 확인 (RED)** + +```bash +./gradlew :SS-Study:test --tests com.elipair.spacestudyship.study.fuel.service.FuelServiceTest +``` +Expected: COMPILE FAIL — `getFuel` 없음 + +- [ ] **Step 3: FuelService에 getFuel 추가** + +```java +import com.elipair.spacestudyship.common.exception.CustomException; +import com.elipair.spacestudyship.common.exception.ErrorCode; +import com.elipair.spacestudyship.study.fuel.dto.FuelResponse; +``` + +다음 메서드 추가 (initialize 메서드 위에 — readOnly 메서드 → 쓰기 메서드 순서): + +```java +public FuelResponse getFuel(Long userId) { + UserFuel fuel = userFuelRepository.findByUserId(userId) + .orElseThrow(() -> new CustomException(ErrorCode.FUEL_NOT_INITIALIZED)); + return FuelResponse.from(fuel); +} +``` + +- [ ] **Step 4: 테스트 통과 확인 (GREEN)** + +```bash +./gradlew :SS-Study:test --tests com.elipair.spacestudyship.study.fuel.service.FuelServiceTest +``` +Expected: 4 tests PASS (기존 2 + 신규 2) + +- [ ] **Step 5: Commit** + +```bash +git add SS-Study/src/main/java/com/elipair/spacestudyship/study/fuel/service/FuelService.java \ + SS-Study/src/test/java/com/elipair/spacestudyship/study/fuel/service/FuelServiceTest.java +git commit -m "연료 시스템 도메인 구현 : feat : FuelService.getFuel + FUEL_NOT_INITIALIZED 케이스 #26" +``` + +--- + +## Task 12: FuelService.getTransactions (TDD) + +**Files:** +- Modify: `SS-Study/.../service/FuelService.java` +- Modify: `SS-Study/.../service/FuelServiceTest.java` + +- [ ] **Step 1: 테스트 추가 (RED)** + +`FuelServiceTest`에 추가: + +```java +import com.elipair.spacestudyship.study.fuel.constant.FuelReason; +import com.elipair.spacestudyship.study.fuel.constant.TransactionType; +import com.elipair.spacestudyship.study.fuel.dto.FuelTransactionListResponse; +import com.elipair.spacestudyship.study.fuel.entity.FuelTransaction; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageImpl; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Sort; + +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.util.List; + +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.ArgumentMatchers.isNull; + +@Test +@DisplayName("getTransactions: 모든 필터 null 통과") +void getTransactions_allNulls_passesNullsAndDefaultPageable() { + given(transactionRepository.findByFilters(eq(1L), isNull(), isNull(), isNull(), any(Pageable.class))) + .willReturn(new PageImpl<>(List.of())); + + FuelTransactionListResponse response = fuelService.getTransactions( + 1L, null, null, null, 0, 20); + + assertThat(response.content()).isEmpty(); + assertThat(response.page()).isZero(); + assertThat(response.size()).isEqualTo(20); + assertThat(response.totalElements()).isZero(); + assertThat(response.totalPages()).isZero(); +} + +@Test +@DisplayName("getTransactions: startDate/endDate를 LocalDateTime 반열림 [start, end+1)로 변환") +void getTransactions_dateRange_convertsToHalfOpen() { + ArgumentCaptor startCaptor = ArgumentCaptor.forClass(LocalDateTime.class); + ArgumentCaptor endCaptor = ArgumentCaptor.forClass(LocalDateTime.class); + given(transactionRepository.findByFilters(eq(1L), eq(TransactionType.CHARGE), + startCaptor.capture(), endCaptor.capture(), any(Pageable.class))) + .willReturn(new PageImpl<>(List.of())); + + fuelService.getTransactions(1L, TransactionType.CHARGE, + "2026-04-01", "2026-04-16", 0, 20); + + assertThat(startCaptor.getValue()).isEqualTo(LocalDate.of(2026, 4, 1).atStartOfDay()); + assertThat(endCaptor.getValue()).isEqualTo(LocalDate.of(2026, 4, 17).atStartOfDay()); // +1일 +} + +@Test +@DisplayName("getTransactions: Pageable의 정렬은 createdAt DESC 강제") +void getTransactions_sortIsCreatedAtDesc() { + ArgumentCaptor pageableCaptor = ArgumentCaptor.forClass(Pageable.class); + given(transactionRepository.findByFilters(eq(1L), isNull(), isNull(), isNull(), pageableCaptor.capture())) + .willReturn(new PageImpl<>(List.of())); + + fuelService.getTransactions(1L, null, null, null, 1, 5); + + Pageable captured = pageableCaptor.getValue(); + Sort.Order order = captured.getSort().getOrderFor("createdAt"); + assertThat(captured.getPageNumber()).isEqualTo(1); + assertThat(captured.getPageSize()).isEqualTo(5); + assertThat(order).isNotNull(); + assertThat(order.getDirection()).isEqualTo(Sort.Direction.DESC); +} + +@Test +@DisplayName("getTransactions: 내용 매핑 및 envelope 필드 정합") +void getTransactions_mapsContentCorrectly() { + FuelTransaction tx = FuelTransaction.of( + "tx-1", 1L, TransactionType.CHARGE, 90, + FuelReason.STUDY_SESSION, "s-1", 350); + given(transactionRepository.findByFilters(eq(1L), isNull(), isNull(), isNull(), any(Pageable.class))) + .willReturn(new PageImpl<>(List.of(tx), Pageable.unpaged(), 1L)); + + FuelTransactionListResponse response = fuelService.getTransactions( + 1L, null, null, null, 0, 20); + + assertThat(response.content()).hasSize(1); + assertThat(response.content().get(0).id()).isEqualTo("tx-1"); + assertThat(response.content().get(0).type()).isEqualTo("charge"); // 소문자 변환 + assertThat(response.content().get(0).reason()).isEqualTo("STUDY_SESSION"); +} +``` + +- [ ] **Step 2: 테스트 실패 확인 (RED)** + +```bash +./gradlew :SS-Study:test --tests com.elipair.spacestudyship.study.fuel.service.FuelServiceTest +``` +Expected: COMPILE FAIL — `getTransactions` 없음 + +- [ ] **Step 3: FuelService에 getTransactions 추가** + +import 추가: +```java +import com.elipair.spacestudyship.study.fuel.constant.TransactionType; +import com.elipair.spacestudyship.study.fuel.dto.FuelTransactionListResponse; +import com.elipair.spacestudyship.study.fuel.entity.FuelTransaction; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Sort; + +import java.time.LocalDate; +import java.time.LocalDateTime; +``` + +메서드 추가 (`getFuel` 아래): + +```java +public FuelTransactionListResponse getTransactions( + Long userId, TransactionType type, + String startDate, String endDate, + int page, int size) { + + LocalDateTime startDateTime = startDate == null ? null + : LocalDate.parse(startDate).atStartOfDay(); + LocalDateTime endDateTime = endDate == null ? null + : LocalDate.parse(endDate).plusDays(1).atStartOfDay(); + + Pageable pageable = PageRequest.of(page, size, + Sort.by(Sort.Direction.DESC, "createdAt")); + + Page result = transactionRepository + .findByFilters(userId, type, startDateTime, endDateTime, pageable); + + return FuelTransactionListResponse.from(result); +} +``` + +- [ ] **Step 4: 테스트 통과 확인 (GREEN)** + +```bash +./gradlew :SS-Study:test --tests com.elipair.spacestudyship.study.fuel.service.FuelServiceTest +``` +Expected: 8 tests PASS + +- [ ] **Step 5: Commit** + +```bash +git add SS-Study/src/main/java/com/elipair/spacestudyship/study/fuel/service/FuelService.java \ + SS-Study/src/test/java/com/elipair/spacestudyship/study/fuel/service/FuelServiceTest.java +git commit -m "연료 시스템 도메인 구현 : feat : FuelService.getTransactions (날짜 반열림 변환, createdAt DESC 정렬) #26" +``` + +--- + +## Task 13: FuelService.charge (TDD) + +**Files:** +- Modify: `SS-Study/.../service/FuelService.java` +- Modify: `SS-Study/.../service/FuelServiceTest.java` + +- [ ] **Step 1: 테스트 추가 (RED)** + +`FuelServiceTest`에 추가: + +```java +import com.elipair.spacestudyship.study.fuel.dto.FuelTransactionResponse; + +@Test +@DisplayName("charge: 정상 흐름 - 락 획득 → entity.charge → tx 저장") +void charge_happy() { + UserFuel fuel = UserFuel.initialize(1L); + given(transactionRepository.findById("tx-1")).willReturn(Optional.empty()); + given(userFuelRepository.findByUserIdForUpdate(1L)).willReturn(Optional.of(fuel)); + + FuelTransactionResponse response = fuelService.charge( + 1L, 90, FuelReason.STUDY_SESSION, "s-1", "tx-1"); + + ArgumentCaptor captor = ArgumentCaptor.forClass(FuelTransaction.class); + verify(transactionRepository).save(captor.capture()); + FuelTransaction saved = captor.getValue(); + assertThat(saved.getId()).isEqualTo("tx-1"); + assertThat(saved.getUserId()).isEqualTo(1L); + assertThat(saved.getType()).isEqualTo(TransactionType.CHARGE); + assertThat(saved.getAmount()).isEqualTo(90); + assertThat(saved.getReason()).isEqualTo(FuelReason.STUDY_SESSION); + assertThat(saved.getReferenceId()).isEqualTo("s-1"); + assertThat(saved.getBalanceAfter()).isEqualTo(90); + + assertThat(response.id()).isEqualTo("tx-1"); + assertThat(response.balanceAfter()).isEqualTo(90); + assertThat(fuel.getCurrentFuel()).isEqualTo(90); + assertThat(fuel.getTotalCharged()).isEqualTo(90); +} + +@Test +@DisplayName("charge: idempotent - 동일 transactionId 재호출 시 기존 tx 반환, 락/저장 없음") +void charge_idempotent() { + FuelTransaction existing = FuelTransaction.of( + "tx-1", 1L, TransactionType.CHARGE, 90, + FuelReason.STUDY_SESSION, "s-1", 350); + given(transactionRepository.findById("tx-1")).willReturn(Optional.of(existing)); + + FuelTransactionResponse response = fuelService.charge( + 1L, 90, FuelReason.STUDY_SESSION, "s-1", "tx-1"); + + assertThat(response.id()).isEqualTo("tx-1"); + assertThat(response.balanceAfter()).isEqualTo(350); + verify(userFuelRepository, never()).findByUserIdForUpdate(any()); + verify(transactionRepository, never()).save(any()); +} + +@Test +@DisplayName("charge: amount<=0 시 INVALID_INPUT_VALUE") +void charge_invalidAmount_throws() { + assertThatThrownBy(() -> fuelService.charge( + 1L, 0, FuelReason.STUDY_SESSION, "s-1", "tx-1")) + .isInstanceOf(CustomException.class) + .extracting("errorCode").isEqualTo(ErrorCode.INVALID_INPUT_VALUE); + + assertThatThrownBy(() -> fuelService.charge( + 1L, -10, FuelReason.STUDY_SESSION, "s-1", "tx-1")) + .isInstanceOf(CustomException.class) + .extracting("errorCode").isEqualTo(ErrorCode.INVALID_INPUT_VALUE); +} + +@Test +@DisplayName("charge: UserFuel 미초기화 시 FUEL_NOT_INITIALIZED") +void charge_fuelNotInitialized_throws() { + given(transactionRepository.findById("tx-1")).willReturn(Optional.empty()); + given(userFuelRepository.findByUserIdForUpdate(1L)).willReturn(Optional.empty()); + + assertThatThrownBy(() -> fuelService.charge( + 1L, 90, FuelReason.STUDY_SESSION, "s-1", "tx-1")) + .isInstanceOf(CustomException.class) + .extracting("errorCode").isEqualTo(ErrorCode.FUEL_NOT_INITIALIZED); +} +``` + +- [ ] **Step 2: 테스트 실패 확인 (RED)** + +```bash +./gradlew :SS-Study:test --tests com.elipair.spacestudyship.study.fuel.service.FuelServiceTest +``` +Expected: COMPILE FAIL — `charge` 없음 + +- [ ] **Step 3: FuelService에 charge 추가** + +import 추가: +```java +import com.elipair.spacestudyship.study.fuel.constant.FuelReason; +import com.elipair.spacestudyship.study.fuel.dto.FuelTransactionResponse; +import java.util.Optional; +``` + +메서드 추가: + +```java +@Transactional +public FuelTransactionResponse charge( + Long userId, int amount, FuelReason reason, + String referenceId, String transactionId) { + + if (amount <= 0) throw new CustomException(ErrorCode.INVALID_INPUT_VALUE); + + Optional existing = transactionRepository.findById(transactionId); + if (existing.isPresent()) { + log.info("[Fuel] charge idempotent skip | userId={}, txId={}", userId, transactionId); + return FuelTransactionResponse.from(existing.get()); + } + + UserFuel fuel = userFuelRepository.findByUserIdForUpdate(userId) + .orElseThrow(() -> new CustomException(ErrorCode.FUEL_NOT_INITIALIZED)); + fuel.charge(amount); + + FuelTransaction tx = FuelTransaction.of( + transactionId, userId, TransactionType.CHARGE, + amount, reason, referenceId, fuel.getCurrentFuel()); + transactionRepository.save(tx); + + log.info("[Fuel] 충전 | userId={}, amount={}, reason={}, txId={}, balanceAfter={}", + userId, amount, reason, transactionId, fuel.getCurrentFuel()); + return FuelTransactionResponse.from(tx); +} +``` + +- [ ] **Step 4: 테스트 통과 확인 (GREEN)** + +```bash +./gradlew :SS-Study:test --tests com.elipair.spacestudyship.study.fuel.service.FuelServiceTest +``` +Expected: 12 tests PASS + +- [ ] **Step 5: Commit** + +```bash +git add SS-Study/src/main/java/com/elipair/spacestudyship/study/fuel/service/FuelService.java \ + SS-Study/src/test/java/com/elipair/spacestudyship/study/fuel/service/FuelServiceTest.java +git commit -m "연료 시스템 도메인 구현 : feat : FuelService.charge (idempotency, 비관적 락) #26" +``` + +--- + +## Task 14: FuelService.consume (TDD) + +**Files:** +- Modify: `SS-Study/.../service/FuelService.java` +- Modify: `SS-Study/.../service/FuelServiceTest.java` + +- [ ] **Step 1: 테스트 추가 (RED)** + +`FuelServiceTest`에 추가: + +```java +@Test +@DisplayName("consume: 정상 흐름") +void consume_happy() { + UserFuel fuel = UserFuel.initialize(1L); + fuel.charge(100); + given(transactionRepository.findById("tx-1")).willReturn(Optional.empty()); + given(userFuelRepository.findByUserIdForUpdate(1L)).willReturn(Optional.of(fuel)); + + FuelTransactionResponse response = fuelService.consume( + 1L, 30, FuelReason.EXPLORATION_UNLOCK, "region-1", "tx-1"); + + ArgumentCaptor captor = ArgumentCaptor.forClass(FuelTransaction.class); + verify(transactionRepository).save(captor.capture()); + FuelTransaction saved = captor.getValue(); + assertThat(saved.getType()).isEqualTo(TransactionType.CONSUME); + assertThat(saved.getAmount()).isEqualTo(30); + assertThat(saved.getReason()).isEqualTo(FuelReason.EXPLORATION_UNLOCK); + assertThat(saved.getReferenceId()).isEqualTo("region-1"); + assertThat(saved.getBalanceAfter()).isEqualTo(70); + + assertThat(response.balanceAfter()).isEqualTo(70); + assertThat(fuel.getCurrentFuel()).isEqualTo(70); + assertThat(fuel.getTotalConsumed()).isEqualTo(30); +} + +@Test +@DisplayName("consume: idempotent - 동일 transactionId 재호출 시 no-op") +void consume_idempotent() { + FuelTransaction existing = FuelTransaction.of( + "tx-1", 1L, TransactionType.CONSUME, 30, + FuelReason.EXPLORATION_UNLOCK, "region-1", 70); + given(transactionRepository.findById("tx-1")).willReturn(Optional.of(existing)); + + FuelTransactionResponse response = fuelService.consume( + 1L, 30, FuelReason.EXPLORATION_UNLOCK, "region-1", "tx-1"); + + assertThat(response.id()).isEqualTo("tx-1"); + verify(userFuelRepository, never()).findByUserIdForUpdate(any()); + verify(transactionRepository, never()).save(any()); +} + +@Test +@DisplayName("consume: amount<=0 시 INVALID_INPUT_VALUE") +void consume_invalidAmount_throws() { + assertThatThrownBy(() -> fuelService.consume( + 1L, 0, FuelReason.EXPLORATION_UNLOCK, "region-1", "tx-1")) + .isInstanceOf(CustomException.class) + .extracting("errorCode").isEqualTo(ErrorCode.INVALID_INPUT_VALUE); +} + +@Test +@DisplayName("consume: 잔량 부족 시 INSUFFICIENT_FUEL (Entity 내부 던짐)") +void consume_insufficient_throws() { + UserFuel fuel = UserFuel.initialize(1L); + fuel.charge(20); + given(transactionRepository.findById("tx-1")).willReturn(Optional.empty()); + given(userFuelRepository.findByUserIdForUpdate(1L)).willReturn(Optional.of(fuel)); + + assertThatThrownBy(() -> fuelService.consume( + 1L, 30, FuelReason.EXPLORATION_UNLOCK, "region-1", "tx-1")) + .isInstanceOf(CustomException.class) + .extracting("errorCode").isEqualTo(ErrorCode.INSUFFICIENT_FUEL); + + verify(transactionRepository, never()).save(any()); +} + +@Test +@DisplayName("consume: UserFuel 미초기화 시 FUEL_NOT_INITIALIZED") +void consume_fuelNotInitialized_throws() { + given(transactionRepository.findById("tx-1")).willReturn(Optional.empty()); + given(userFuelRepository.findByUserIdForUpdate(1L)).willReturn(Optional.empty()); + + assertThatThrownBy(() -> fuelService.consume( + 1L, 30, FuelReason.EXPLORATION_UNLOCK, "region-1", "tx-1")) + .isInstanceOf(CustomException.class) + .extracting("errorCode").isEqualTo(ErrorCode.FUEL_NOT_INITIALIZED); +} +``` + +- [ ] **Step 2: 테스트 실패 확인 (RED)** + +```bash +./gradlew :SS-Study:test --tests com.elipair.spacestudyship.study.fuel.service.FuelServiceTest +``` +Expected: COMPILE FAIL — `consume` 없음 + +- [ ] **Step 3: FuelService에 consume 추가** + +```java +@Transactional +public FuelTransactionResponse consume( + Long userId, int amount, FuelReason reason, + String referenceId, String transactionId) { + + if (amount <= 0) throw new CustomException(ErrorCode.INVALID_INPUT_VALUE); + + Optional existing = transactionRepository.findById(transactionId); + if (existing.isPresent()) { + log.info("[Fuel] consume idempotent skip | userId={}, txId={}", userId, transactionId); + return FuelTransactionResponse.from(existing.get()); + } + + UserFuel fuel = userFuelRepository.findByUserIdForUpdate(userId) + .orElseThrow(() -> new CustomException(ErrorCode.FUEL_NOT_INITIALIZED)); + fuel.consume(amount); + + FuelTransaction tx = FuelTransaction.of( + transactionId, userId, TransactionType.CONSUME, + amount, reason, referenceId, fuel.getCurrentFuel()); + transactionRepository.save(tx); + + log.info("[Fuel] 소비 | userId={}, amount={}, reason={}, txId={}, balanceAfter={}", + userId, amount, reason, transactionId, fuel.getCurrentFuel()); + return FuelTransactionResponse.from(tx); +} +``` + +- [ ] **Step 4: 테스트 통과 확인 (GREEN)** + +```bash +./gradlew :SS-Study:test --tests com.elipair.spacestudyship.study.fuel.service.FuelServiceTest +``` +Expected: 17 tests PASS + +- [ ] **Step 5: Commit** + +```bash +git add SS-Study/src/main/java/com/elipair/spacestudyship/study/fuel/service/FuelService.java \ + SS-Study/src/test/java/com/elipair/spacestudyship/study/fuel/service/FuelServiceTest.java +git commit -m "연료 시스템 도메인 구현 : feat : FuelService.consume (잔량 부족 → INSUFFICIENT_FUEL) #26" +``` + +--- + +## Task 15: FuelInitializeListener (TDD) + +**Files:** +- Create: `SS-Study/src/main/java/com/elipair/spacestudyship/study/fuel/service/FuelInitializeListener.java` +- Create: `SS-Study/src/test/java/com/elipair/spacestudyship/study/fuel/service/FuelInitializeListenerTest.java` + +- [ ] **Step 1: FuelInitializeListenerTest 작성 (RED)** + +```java +package com.elipair.spacestudyship.study.fuel.service; + +import com.elipair.spacestudyship.member.event.MemberCreatedEvent; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import static org.mockito.Mockito.verify; + +@ExtendWith(MockitoExtension.class) +class FuelInitializeListenerTest { + + @Mock FuelService fuelService; + @InjectMocks FuelInitializeListener listener; + + @Test + @DisplayName("MemberCreatedEvent 수신 시 fuelService.initialize 호출") + void onMemberCreated_callsInitialize() { + listener.onMemberCreated(new MemberCreatedEvent(42L)); + + verify(fuelService).initialize(42L); + } +} +``` + +- [ ] **Step 2: 테스트 실패 확인 (RED)** + +```bash +./gradlew :SS-Study:test --tests com.elipair.spacestudyship.study.fuel.service.FuelInitializeListenerTest +``` +Expected: COMPILE FAIL — `FuelInitializeListener` 없음 + +- [ ] **Step 3: FuelInitializeListener 작성** + +```java +package com.elipair.spacestudyship.study.fuel.service; + +import com.elipair.spacestudyship.member.event.MemberCreatedEvent; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; +import org.springframework.transaction.event.TransactionPhase; +import org.springframework.transaction.event.TransactionalEventListener; + +@Slf4j +@Component +@RequiredArgsConstructor +public class FuelInitializeListener { + + private final FuelService fuelService; + + @TransactionalEventListener(phase = TransactionPhase.BEFORE_COMMIT) + public void onMemberCreated(MemberCreatedEvent event) { + log.info("[Fuel] MemberCreatedEvent 수신 | memberId={}", event.memberId()); + fuelService.initialize(event.memberId()); + } +} +``` + +- [ ] **Step 4: 테스트 통과 확인 (GREEN)** + +```bash +./gradlew :SS-Study:test --tests com.elipair.spacestudyship.study.fuel.service.FuelInitializeListenerTest +``` +Expected: 1 test PASS + +- [ ] **Step 5: Commit** + +```bash +git add SS-Study/src/main/java/com/elipair/spacestudyship/study/fuel/service/FuelInitializeListener.java \ + SS-Study/src/test/java/com/elipair/spacestudyship/study/fuel/service/FuelInitializeListenerTest.java +git commit -m "연료 시스템 도메인 구현 : feat : FuelInitializeListener (MemberCreatedEvent → UserFuel 초기화) #26" +``` + +--- + +## Task 16: AuthService에 MemberCreatedEvent publish 추가 (+ 회귀 테스트) + +**Files:** +- Modify: `SS-Auth/src/main/java/com/elipair/spacestudyship/auth/service/AuthService.java` +- Modify: `SS-Auth/src/test/java/com/elipair/spacestudyship/auth/service/AuthServiceTest.java` + +- [ ] **Step 1: AuthServiceTest 회귀 테스트 추가 (RED)** + +`AuthServiceTest`는 이미 `@ExtendWith(MockitoExtension.class)` + `@Mock` + `@InjectMocks AuthService authService` 패턴. + +**(1)** import 추가: +```java +import com.elipair.spacestudyship.member.event.MemberCreatedEvent; +import org.mockito.ArgumentCaptor; +import org.springframework.context.ApplicationEventPublisher; +``` + +**(2)** `@Mock` 필드 추가 (다른 mock 필드 옆): +```java +@Mock +ApplicationEventPublisher eventPublisher; +``` +> `@InjectMocks AuthService`가 자동으로 생성자의 `ApplicationEventPublisher` 필드를 mock으로 주입함 (`@RequiredArgsConstructor`). + +**(3)** 테스트 메서드 2개 추가. 기존 `AuthServiceTest`에서 신규 회원 가입 / 기존 회원 재로그인 시나리오의 stubbing 패턴을 그대로 따른다. 핵심 검증 부분만 명시: + +```java +@Test +@DisplayName("신규 회원 로그인 시 MemberCreatedEvent를 publish한다") +void login_newMember_publishesMemberCreatedEvent() { + // given - 신규 회원 시나리오 (기존 AuthServiceTest의 '신규 회원 가입' 테스트와 동일한 stubbing 사용) + // given(socialLoginStrategies.get(any())).willReturn(socialLoginStrategy); + // given(socialLoginStrategy.validateAndGetSocialId(...)).willReturn("social-id"); + // given(memberRepository.findBySocialIdAndSocialType(...)).willReturn(Optional.empty()); + // given(randomNicknameGenerator.generate()).willReturn("닉네임"); + // given(memberRepository.existsByNickname(any())).willReturn(false); + // given(memberRepository.save(any(Member.class))).willAnswer(inv -> { + // Member m = inv.getArgument(0); + // // ID 세팅을 위해 reflection 또는 spy 사용 (기존 패턴 따름) + // return m; + // }); + // given(jwtTokenProvider.createAccessToken(any())).willReturn("access"); + // given(jwtTokenProvider.createRefreshToken(any(), any())).willReturn("refresh"); + + // when + // authService.login(new LoginRequest(...)); + + // then + ArgumentCaptor captor = ArgumentCaptor.forClass(MemberCreatedEvent.class); + verify(eventPublisher).publishEvent(captor.capture()); + assertThat(captor.getValue().memberId()).isEqualTo(/* 기대 memberId */); +} + +@Test +@DisplayName("기존 회원 재로그인 시 MemberCreatedEvent를 publish 하지 않는다") +void login_existingMember_doesNotPublishEvent() { + // given - 기존 회원 시나리오 (기존 AuthServiceTest의 '기존 회원 로그인' stubbing 그대로) + // given(memberRepository.findBySocialIdAndSocialType(...)).willReturn(Optional.of(existingMember)); + // ... 토큰 발급 / 디바이스 upsert stubbing ... + + // when + // authService.login(new LoginRequest(...)); + + // then + verify(eventPublisher, never()).publishEvent(any(MemberCreatedEvent.class)); +} +``` + +> **중요**: 위 코드의 `// given` 영역은 **기존 `AuthServiceTest`의 신규/기존 회원 로그인 테스트 메서드를 그대로 복사 후 verify 부분만 위와 같이 교체**한다. stubbing 패턴은 기존 코드의 절대 진실 — 새로 짜지 말고 기존 패턴을 따른다. + +- [ ] **Step 2: 테스트 실패 확인 (RED)** + +```bash +./gradlew :SS-Auth:test --tests com.elipair.spacestudyship.auth.service.AuthServiceTest +``` +Expected: FAIL — eventPublisher가 주입되지 않거나 publishEvent 호출 없음 + +- [ ] **Step 3: AuthService 수정** + +`AuthService.java` 상단 imports에 추가: +```java +import com.elipair.spacestudyship.member.event.MemberCreatedEvent; +import org.springframework.context.ApplicationEventPublisher; +``` + +필드 추가 (`@RequiredArgsConstructor`이므로 final 필드만 추가하면 됨): +```java +private final ApplicationEventPublisher eventPublisher; +``` + +`findOrRegisterMember` 메서드의 신규 회원 분기 수정. 현재: +```java +.orElseGet(() -> { + String nickname = generateUniqueNickname(); + Member newMember = Member.signUp(socialId, socialType, nickname); + memberRepository.save(newMember); + + log.info("[SignUp] 신규 회원가입 성공 | memberId={}, nickname={}, socialType={}", + newMember.getId(), nickname, socialType); + return new AuthMemberDto(newMember, true); +}); +``` + +다음으로 변경 (save 직후 publish): +```java +.orElseGet(() -> { + String nickname = generateUniqueNickname(); + Member newMember = Member.signUp(socialId, socialType, nickname); + memberRepository.save(newMember); + eventPublisher.publishEvent(new MemberCreatedEvent(newMember.getId())); + + log.info("[SignUp] 신규 회원가입 성공 | memberId={}, nickname={}, socialType={}", + newMember.getId(), nickname, socialType); + return new AuthMemberDto(newMember, true); +}); +``` + +- [ ] **Step 4: 테스트 통과 확인 (GREEN)** + +```bash +./gradlew :SS-Auth:test --tests com.elipair.spacestudyship.auth.service.AuthServiceTest +``` +Expected: 전체 테스트 PASS (기존 테스트 회귀 없음 + 신규 2개 PASS) + +- [ ] **Step 5: Commit** + +```bash +git add SS-Auth/src/main/java/com/elipair/spacestudyship/auth/service/AuthService.java \ + SS-Auth/src/test/java/com/elipair/spacestudyship/auth/service/AuthServiceTest.java +git commit -m "연료 시스템 도메인 구현 : feat : AuthService에 MemberCreatedEvent publish 추가 #26" +``` + +--- + +## Task 17: FuelController + MockMvc 테스트 + +**Files:** +- Create: `SS-Web/src/main/java/com/elipair/spacestudyship/controller/fuel/FuelController.java` +- Create: `SS-Web/src/test/java/com/elipair/spacestudyship/controller/fuel/FuelControllerTest.java` + +- [ ] **Step 1: FuelControllerTest 작성 (RED)** + +기존 `TodoControllerTest`와 동일한 **standalone MockMvc** 패턴을 사용한다. `@SpringBootTest`가 아닌 `@ExtendWith(MockitoExtension.class)` + `MockMvcBuilders.standaloneSetup(...)`. + +```java +package com.elipair.spacestudyship.controller.fuel; + +import com.elipair.spacestudyship.auth.interceptor.LoginMember; +import com.elipair.spacestudyship.common.exception.GlobalExceptionHandler; +import com.elipair.spacestudyship.study.fuel.constant.TransactionType; +import com.elipair.spacestudyship.study.fuel.dto.FuelResponse; +import com.elipair.spacestudyship.study.fuel.dto.FuelTransactionListResponse; +import com.elipair.spacestudyship.study.fuel.dto.FuelTransactionResponse; +import com.elipair.spacestudyship.study.fuel.service.FuelService; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.core.MethodParameter; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.setup.MockMvcBuilders; +import org.springframework.web.method.support.HandlerMethodArgumentResolver; +import org.springframework.web.method.support.ModelAndViewContainer; + +import java.util.List; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.BDDMockito.given; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +@ExtendWith(MockitoExtension.class) +class FuelControllerTest { + + @Mock FuelService fuelService; + @InjectMocks FuelController fuelController; + + MockMvc mockMvc; + + @BeforeEach + void setUp() { + HandlerMethodArgumentResolver loginMemberStub = new HandlerMethodArgumentResolver() { + @Override + public boolean supportsParameter(MethodParameter parameter) { + return parameter.getParameterType().equals(LoginMember.class); + } + @Override + public Object resolveArgument(MethodParameter parameter, ModelAndViewContainer mavContainer, + org.springframework.web.context.request.NativeWebRequest webRequest, + org.springframework.web.bind.support.WebDataBinderFactory binderFactory) { + return new LoginMember(1L); + } + }; + mockMvc = MockMvcBuilders.standaloneSetup(fuelController) + .setControllerAdvice(new GlobalExceptionHandler()) + .setCustomArgumentResolvers(loginMemberStub) + .build(); + } + + @Test + @DisplayName("GET /api/fuel — 200, FuelResponse 본문") + void getFuel_200() throws Exception { + given(fuelService.getFuel(1L)) + .willReturn(new FuelResponse(350, 1200, 850, 0, "2026-04-16T10:30:00Z")); + + mockMvc.perform(get("/api/fuel")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.currentFuel").value(350)) + .andExpect(jsonPath("$.totalCharged").value(1200)) + .andExpect(jsonPath("$.totalConsumed").value(850)) + .andExpect(jsonPath("$.pendingMinutes").value(0)) + .andExpect(jsonPath("$.lastUpdatedAt").value("2026-04-16T10:30:00Z")); + } + + @Test + @DisplayName("GET /api/fuel/transactions — 200, Page envelope") + void getTransactions_200() throws Exception { + given(fuelService.getTransactions(eq(1L), eq(null), eq(null), eq(null), eq(0), eq(20))) + .willReturn(new FuelTransactionListResponse( + List.of(new FuelTransactionResponse( + "tx-1", "charge", 90, "STUDY_SESSION", "s-1", 350, "2026-04-16T10:30:00Z")), + 0, 20, 1L, 1)); + + mockMvc.perform(get("/api/fuel/transactions")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.content[0].id").value("tx-1")) + .andExpect(jsonPath("$.content[0].type").value("charge")) + .andExpect(jsonPath("$.content[0].reason").value("STUDY_SESSION")) + .andExpect(jsonPath("$.totalElements").value(1)); + } + + @Test + @DisplayName("GET /api/fuel/transactions?type=invalid → 400 INVALID_INPUT_VALUE") + void getTransactions_invalidType_400() throws Exception { + mockMvc.perform(get("/api/fuel/transactions?type=invalid")) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.code").value("INVALID_INPUT_VALUE")); + } + + @Test + @DisplayName("GET /api/fuel/transactions?startDate=2026-13-01 → 400 (Pattern 위반)") + void getTransactions_invalidStartDate_400() throws Exception { + mockMvc.perform(get("/api/fuel/transactions?startDate=2026-13-01")) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.code").value("INVALID_INPUT_VALUE")); + } + + @Test + @DisplayName("GET /api/fuel/transactions?size=200 → 400 (Max 100)") + void getTransactions_sizeOverMax_400() throws Exception { + mockMvc.perform(get("/api/fuel/transactions?size=200")) + .andExpect(status().isBadRequest()); + } + + @Test + @DisplayName("GET /api/fuel/transactions?page=-1 → 400 (Min 0)") + void getTransactions_negativePage_400() throws Exception { + mockMvc.perform(get("/api/fuel/transactions?page=-1")) + .andExpect(status().isBadRequest()); + } + + @Test + @DisplayName("GET /api/fuel/transactions?type=charge&startDate=2026-04-01&endDate=2026-04-16 - 인자 그대로 서비스로") + void getTransactions_argsPassThrough() throws Exception { + given(fuelService.getTransactions(eq(1L), eq(TransactionType.CHARGE), + eq("2026-04-01"), eq("2026-04-16"), eq(0), eq(20))) + .willReturn(new FuelTransactionListResponse(List.of(), 0, 20, 0L, 0)); + + mockMvc.perform(get("/api/fuel/transactions") + .param("type", "charge") + .param("startDate", "2026-04-01") + .param("endDate", "2026-04-16")) + .andExpect(status().isOk()); + } +} +``` + +> **참고 1**: 401 (인증 미존재) 케이스는 `standaloneSetup`이 인터셉터를 끼지 않아 검증 불가. 인증 동작 검증은 기존 `AuthControllerTest`에서 다루므로 여기서는 제외. +> +> **참고 2**: standalone setup에서 `@Validated` + `@Pattern`/`@Min`/`@Max` 검증이 동작하지 않으면 `ConstraintViolationException`이 핸들러까지 도달하지 않을 수 있다. 그 경우 `MockMvcBuilders.standaloneSetup(fuelController).setValidator(new org.springframework.validation.beanvalidation.LocalValidatorFactoryBean())` 추가 또는 별도 `MethodValidationPostProcessor`를 끼우는 보완 필요. 실제 RED → GREEN 진행 시 400이 안 나오면 이 단계에서 setup 보강. + +- [ ] **Step 2: 테스트 실패 확인 (RED)** + +```bash +./gradlew :SS-Web:test --tests com.elipair.spacestudyship.controller.fuel.FuelControllerTest +``` +Expected: COMPILE FAIL — `FuelController` 없음 + +- [ ] **Step 3: FuelController 작성** + +```java +package com.elipair.spacestudyship.controller.fuel; + +import com.elipair.spacestudyship.auth.interceptor.AuthMember; +import com.elipair.spacestudyship.auth.interceptor.LoginMember; +import com.elipair.spacestudyship.common.exception.ErrorResponse; +import com.elipair.spacestudyship.study.fuel.constant.TransactionType; +import com.elipair.spacestudyship.study.fuel.dto.FuelResponse; +import com.elipair.spacestudyship.study.fuel.dto.FuelTransactionListResponse; +import com.elipair.spacestudyship.study.fuel.service.FuelService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.ExampleObject; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.responses.ApiResponses; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.constraints.Max; +import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.Pattern; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +@Tag(name = "Fuel", description = "연료 잔량 및 거래 내역 API") +@RestController +@RequiredArgsConstructor +@Validated +public class FuelController { + + private final FuelService fuelService; + + @Operation(summary = "연료 잔량 조회", + description = """ + 현재 유저의 연료 잔량 및 누적 충전/소비량을 조회합니다. + + ### 응답 필드 + - currentFuel: 현재 보유 (totalCharged - totalConsumed) + - totalCharged / totalConsumed: 누적량 + - pendingMinutes: 향후 확장용, 현재 항상 0 + - lastUpdatedAt: 마지막 변동 시각 (ISO 8601 UTC) + """) + @ApiResponses({ + @ApiResponse(responseCode = "200", description = "조회 성공", + content = @Content(mediaType = "application/json", + schema = @Schema(implementation = FuelResponse.class), + examples = @ExampleObject(name = "Success", + value = "{\"currentFuel\":350,\"totalCharged\":1200,\"totalConsumed\":850,\"pendingMinutes\":0,\"lastUpdatedAt\":\"2026-04-16T10:30:00Z\"}"))), + @ApiResponse(responseCode = "401", description = "인증 필요", + content = @Content(schema = @Schema(implementation = ErrorResponse.class), + examples = @ExampleObject(value = "{\"code\":\"UNAUTHENTICATED_REQUEST\",\"message\":\"로그인이 필요합니다.\"}"))), + @ApiResponse(responseCode = "500", description = "서버 오류", + content = @Content(schema = @Schema(implementation = ErrorResponse.class))) + }) + @GetMapping("/api/fuel") + public ResponseEntity getFuel(@AuthMember LoginMember loginMember) { + return ResponseEntity.ok(fuelService.getFuel(loginMember.memberId())); + } + + @Operation(summary = "연료 거래 내역 조회", + description = """ + 연료 충전/소비 이력을 페이지네이션으로 조회합니다. + + ### Query Parameters + - type: charge | consume (선택) + - startDate / endDate: YYYY-MM-DD (선택, 종료일 포함 반열림 [start, end+1)) + - page: 기본 0 + - size: 기본 20, 최대 100 + + 정렬은 createdAt 내림차순 고정. + """) + @ApiResponses({ + @ApiResponse(responseCode = "200", description = "조회 성공", + content = @Content(mediaType = "application/json", + schema = @Schema(implementation = FuelTransactionListResponse.class), + examples = @ExampleObject(name = "Page", + value = """ + { + "content": [ + {"id":"tx-1","type":"charge","amount":90,"reason":"STUDY_SESSION","referenceId":"session-1","balanceAfter":350,"createdAt":"2026-04-16T10:30:00Z"} + ], + "page": 0, "size": 20, "totalElements": 120, "totalPages": 6 + } + """))), + @ApiResponse(responseCode = "400", description = "잘못된 query parameter", + content = @Content(schema = @Schema(implementation = ErrorResponse.class), + examples = @ExampleObject(value = "{\"code\":\"INVALID_INPUT_VALUE\",\"message\":\"type은 charge 또는 consume이어야 합니다.\"}"))), + @ApiResponse(responseCode = "401", description = "인증 필요"), + @ApiResponse(responseCode = "500", description = "서버 오류") + }) + @GetMapping("/api/fuel/transactions") + public ResponseEntity getTransactions( + @AuthMember LoginMember loginMember, + @RequestParam(required = false) + @Pattern(regexp = "charge|consume", + message = "type은 charge 또는 consume이어야 합니다.") + String type, + @RequestParam(required = false) + @Pattern(regexp = "\\d{4}-\\d{2}-\\d{2}", + message = "startDate는 YYYY-MM-DD 형식이어야 합니다.") + String startDate, + @RequestParam(required = false) + @Pattern(regexp = "\\d{4}-\\d{2}-\\d{2}", + message = "endDate는 YYYY-MM-DD 형식이어야 합니다.") + String endDate, + @RequestParam(defaultValue = "0") @Min(0) Integer page, + @RequestParam(defaultValue = "20") @Min(1) @Max(100) Integer size) { + + TransactionType typeEnum = type == null ? null + : TransactionType.valueOf(type.toUpperCase()); + return ResponseEntity.ok( + fuelService.getTransactions( + loginMember.memberId(), typeEnum, + startDate, endDate, page, size)); + } +} +``` + +- [ ] **Step 4: 테스트 통과 확인 (GREEN)** + +```bash +./gradlew :SS-Web:test --tests com.elipair.spacestudyship.controller.fuel.FuelControllerTest +``` +Expected: 모든 시나리오 PASS + +- [ ] **Step 5: Commit** + +```bash +git add SS-Web/src/main/java/com/elipair/spacestudyship/controller/fuel/FuelController.java \ + SS-Web/src/test/java/com/elipair/spacestudyship/controller/fuel/FuelControllerTest.java +git commit -m "연료 시스템 도메인 구현 : feat : FuelController + Swagger 풀세트 + MockMvc 테스트 #26" +``` + +--- + +## Task 18: 최종 검증 — 전체 빌드 + 테스트 + +**Files:** 변경 없음 + +- [ ] **Step 1: 전체 빌드** + +```bash +./gradlew build +``` +Expected: BUILD SUCCESSFUL, 모든 모듈 컴파일 + 테스트 통과 + +- [ ] **Step 2: 모듈별 테스트 단독 실행 확인** + +```bash +./gradlew :SS-Common:test +./gradlew :SS-Auth:test +./gradlew :SS-Member:test +./gradlew :SS-Study:test +./gradlew :SS-Web:test +``` +Expected: 각 모듈 PASS + +- [ ] **Step 3: 마이그레이션 적용 확인 (선택)** + +로컬 PostgreSQL이 띄워져 있다면 한 번 부트해서 Flyway가 V0_0_36 적용하는지 로그 확인: + +```bash +./gradlew :SS-Web:bootRun +``` +log에서 `Migrating schema "public" to version "0.0.36 - add fuel"` 확인 후 Ctrl-C. + +- [ ] **Step 4: 셀프 리뷰 체크리스트 점검 (spec §10)** + +설계 문서 `docs/superpowers/specs/2026-05-23-fuel-domain-design.md` §10의 체크리스트 모두 만족하는지 확인: +- [ ] `CustomException(ErrorCode)` 패턴 일관 사용 +- [ ] Service `@Transactional(readOnly = true)` + 쓰기만 `@Transactional` +- [ ] `findByUserIdForUpdate` 사용 (charge/consume) +- [ ] idempotency 확인 후 락 → 저장 +- [ ] amount 가드 Service + Entity 양쪽 +- [ ] Swagger 응답 코드 풀세트 (200/400/401/500) +- [ ] `@Validated`, `@Pattern`, `@Min`, `@Max` 검증 +- [ ] 로그 포맷 `[Fuel] 액션 | key=value` +- [ ] 마이그레이션 민감 값 없음 +- [ ] version.yml bump 포함 +- [ ] CLAUDE.md 갱신 +- [ ] `StudyTestApplication` 갱신 +- [ ] `MemberCreatedEvent` SS-Member 위치 +- [ ] AuthService publishEvent 추가 + +- [ ] **Step 5: 최종 커밋이 없으면 종료** + +이전 task에서 모든 변경이 커밋됐다면 추가 커밋 없이 종료. 만약 미세 수정이 발생했으면: + +```bash +git add -p # 의도한 변경만 stage +git commit -m "연료 시스템 도메인 구현 : chore : 셀프 리뷰 반영 #26" +``` + +--- + +## 셀프 리뷰 결과 (writing-plans skill) + +**1. Spec coverage 확인** +- ✅ §3.1 UserFuel Entity → Task 3 +- ✅ §3.2 FuelTransaction Entity → Task 4 +- ✅ §3.3 Enum → Task 2 +- ✅ §4.1–4.3 DTO → Task 9 +- ✅ §4.4 Search 파라미터 컨트롤러 직접 → Task 17 +- ✅ §5.1–5.5 Repository → Task 6, 7 +- ✅ §6.1 Controller → Task 17 +- ✅ §6.4–6.9 Service 메서드 → Task 10–14 +- ✅ §6.10–6.12 Event/Listener/AuthService → Task 8, 15, 16 +- ✅ §7 ErrorCode → Task 1 +- ✅ §8 Migration → Task 1 +- ✅ §9 테스트 전략 → Task 3, 6, 7, 10–17 +- ✅ §10 셀프 리뷰 → Task 18 Step 4 +- ✅ §11 산출물 → 18개 Task 전체 + +**2. Placeholder scan** +- "TBD", "TODO" 없음 +- 모든 코드 블록은 실제 컴파일 가능한 코드 +- Task 16의 회귀 테스트 setup은 기존 `AuthServiceTest`의 신규/기존 회원 stubbing을 그대로 복사하라고 명시. verify 부분은 완전 코드 +- Task 17은 기존 `TodoControllerTest`의 standalone MockMvc 패턴을 그대로 적용 — setup/loginMemberStub/모든 시나리오 본문 코드 명시 +- spec §9.6의 401 케이스는 standalone MockMvc 한계로 plan에서 제외 (`AuthControllerTest`가 인증 동작을 별도 검증) + +**3. Type consistency** +- `UserFuel.charge(int)`, `consume(int)`, `initialize(Long)` — Task 3, 10–14에서 일관 +- `FuelService.charge/consume` 시그니처: `(Long, int, FuelReason, String, String)` — Task 13, 14에서 일관 +- `FuelTransaction.of(String, Long, TransactionType, int, FuelReason, String, int)` — Task 4, 13, 14에서 일관 +- `MemberCreatedEvent(Long memberId)` — Task 8, 15, 16에서 일관 + +플랜 자체-검토 완료. diff --git a/docs/superpowers/plans/2026-05-23-todo-domain.md b/docs/superpowers/plans/2026-05-23-todo-domain.md new file mode 100644 index 0000000..6ae96c4 --- /dev/null +++ b/docs/superpowers/plans/2026-05-23-todo-domain.md @@ -0,0 +1,2689 @@ +# 할 일 + 카테고리 도메인 구현 계획 (이슈 #24) + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** API 스펙 02_todo.md의 Todo + Category CRUD 8개 엔드포인트를 SS-Study 모듈에 구현하고 Swagger 문서·일관된 ErrorResponse·Flyway 마이그레이션까지 포함해 PR 직전 상태로 만든다. + +**Architecture:** SS-Study 모듈의 `todo/` 패키지에 Entity/Repo/Service, SS-Web에 Controller 배치. Tier 1 (Optimistic Updates) — 클라이언트가 UUID 생성, 서버는 검증·영속화·소유권 확인. 배열 데이터(scheduledDates 등)는 PostgreSQL JSONB + `@JdbcTypeCode(SqlTypes.JSON)`. 다른 사용자 리소스 접근은 404로 통일 (403 미사용). 카테고리 삭제 시 연관 Todo의 categoryIds는 JPA dirty checking으로 정리. + +**Tech Stack:** Java 21, Spring Boot 4.0.2-SNAPSHOT, JPA + Hibernate 6, PostgreSQL (운영) / Testcontainers PostgreSQL (Repository 테스트) / Mockito (Service 단위) / MockMvc (Controller), Flyway, Lombok, Jakarta Validation, springdoc-openapi. + +**Spec:** [docs/superpowers/specs/2026-05-23-todo-domain-design.md](../specs/2026-05-23-todo-domain-design.md) + +**커밋 메시지 형식 (이 프로젝트):** `{이슈제목} : {type} : {설명} #{이슈번호}` — 예: `할일 및 카테고리 도메인 구현 : feat : Todo Entity 추가 #24` + +--- + +## Task 진행 순서 개요 + +| # | Task | Phase | +|---|------|-------| +| 1 | SS-Common ErrorCode 4개 추가 | Foundation | +| 2 | version.yml bump + V0_0_34 마이그레이션 SQL | Foundation | +| 3 | SS-Study build.gradle Testcontainers + 테스트 config | Foundation | +| 4 | StudyTestApplication 셋업 | Foundation | +| 5 | TodoCategory Entity | Category 도메인 | +| 6 | TodoCategoryRepository + Repository 테스트 | Category 도메인 | +| 7 | Category DTO 3개 (Record) | Category 도메인 | +| 8 | Todo Entity | Todo 도메인 | +| 9 | TodoRepository + Repository 테스트 (JSONB) | Todo 도메인 | +| 10 | Todo DTO 3개 (Record) | Todo 도메인 | +| 11 | TodoService.findAll (필터 조합) | Service | +| 12 | TodoService.create + categoryIds 검증 | Service | +| 13 | TodoService.update (PATCH) | Service | +| 14 | TodoService.delete | Service | +| 15 | TodoCategoryService.findAll/create/update | Service | +| 16 | TodoCategoryService.delete (cascade Todo) | Service | +| 17 | TodoController + Swagger + MockMvc | Controller | +| 18 | TodoCategoryController + Swagger + MockMvc | Controller | +| 19 | CLAUDE.md 이력 업데이트 + 최종 검증 | Wrap-up | + +--- + +## 파일 구조 + +``` +SS-Common/src/main/java/com/elipair/spacestudyship/common/exception/ +└── ErrorCode.java [MODIFY: 4개 추가] + +SS-Study/build.gradle [MODIFY: testcontainers] +SS-Study/src/main/java/com/elipair/spacestudyship/study/todo/ +├── dto/ +│ ├── TodoCreateRequest.java [CREATE] +│ ├── TodoUpdateRequest.java [CREATE] +│ ├── TodoResponse.java [CREATE] +│ ├── CategoryCreateRequest.java [CREATE] +│ ├── CategoryUpdateRequest.java [CREATE] +│ └── CategoryResponse.java [CREATE] +├── entity/ +│ ├── Todo.java [CREATE] +│ └── TodoCategory.java [CREATE] +├── repository/ +│ ├── TodoRepository.java [CREATE] +│ └── TodoCategoryRepository.java [CREATE] +└── service/ + ├── TodoService.java [CREATE] + └── TodoCategoryService.java [CREATE] + +SS-Study/src/test/java/com/elipair/spacestudyship/study/ +├── StudyTestApplication.java [CREATE] +└── todo/ + ├── entity/ + │ ├── TodoTest.java [CREATE] + │ └── TodoCategoryTest.java [CREATE] + ├── repository/ + │ ├── TodoRepositoryTest.java [CREATE] + │ └── TodoCategoryRepositoryTest.java [CREATE] + └── service/ + ├── TodoServiceTest.java [CREATE] + └── TodoCategoryServiceTest.java [CREATE] +SS-Study/src/test/resources/application.yml [CREATE] + +SS-Web/src/main/java/com/elipair/spacestudyship/controller/todo/ +├── TodoController.java [CREATE] +└── TodoCategoryController.java [CREATE] + +SS-Web/src/test/java/com/elipair/spacestudyship/controller/todo/ +├── TodoControllerTest.java [CREATE] +└── TodoCategoryControllerTest.java [CREATE] + +SS-Web/src/main/resources/db/migration/ +└── V0_0_34__add_todos_and_categories.sql [CREATE] + +version.yml [MODIFY: 0.0.33 → 0.0.34] +CLAUDE.md [MODIFY: 이력 표] +``` + +--- + +## Task 1: SS-Common ErrorCode 4개 추가 + +**Files:** +- Modify: `SS-Common/src/main/java/com/elipair/spacestudyship/common/exception/ErrorCode.java` + +- [ ] **Step 1: 기존 ErrorCode 파일을 열고 새 항목 추가 위치 확인** + +`ErrorCode.java`의 마지막 enum 항목 `INTERNAL_SERVER_ERROR(...)` 바로 위에 추가. 다음 4개를 `// Member` 그룹 아래 새 그룹으로 삽입. + +- [ ] **Step 2: ErrorCode 4개 추가** + +```java +// Todo +TODO_NOT_FOUND(HttpStatus.NOT_FOUND, "해당 할 일을 찾을 수 없습니다."), +TODO_ALREADY_EXISTS(HttpStatus.CONFLICT, "동일 ID의 할 일이 이미 존재합니다."), + +// Todo Category +CATEGORY_NOT_FOUND(HttpStatus.NOT_FOUND, "해당 카테고리를 찾을 수 없습니다."), +CATEGORY_ALREADY_EXISTS(HttpStatus.CONFLICT, "동일 ID의 카테고리가 이미 존재합니다."), +``` + +`// Common` 주석 그룹 바로 위에 위치하도록 배치. + +- [ ] **Step 3: 컴파일 확인** + +Run: `./gradlew :SS-Common:compileJava` +Expected: BUILD SUCCESSFUL + +- [ ] **Step 4: 커밋** + +```bash +git add SS-Common/src/main/java/com/elipair/spacestudyship/common/exception/ErrorCode.java +git commit -m "할일 및 카테고리 도메인 구현 : feat : ErrorCode 4개 추가 (Todo, Category NotFound/Conflict) #24" +``` + +--- + +## Task 2: version.yml bump + V0_0_34 마이그레이션 SQL + +**Files:** +- Modify: `version.yml` +- Create: `SS-Web/src/main/resources/db/migration/V0_0_34__add_todos_and_categories.sql` + +- [ ] **Step 1: version.yml의 version 값 변경** + +`version.yml`에서 `version: "0.0.33"` → `version: "0.0.34"`, `version_code: 33` → `version_code: 34`로 변경. `last_updated`, `last_updated_by`는 자동화 워크플로우가 처리하므로 손대지 않음. + +- [ ] **Step 2: build.gradle 루트의 version 동기화** + +`build.gradle` (루트)의 `version = '0.0.33'` → `version = '0.0.34'` 변경. + +- [ ] **Step 3: V0_0_34 마이그레이션 SQL 작성** + +Create `SS-Web/src/main/resources/db/migration/V0_0_34__add_todos_and_categories.sql`: + +```sql +-- todo_categories: 카테고리 (할 일보다 먼저 생성) +CREATE TABLE IF NOT EXISTS todo_categories ( + id VARCHAR(36) PRIMARY KEY, + user_id BIGINT NOT NULL, + name VARCHAR(20) NOT NULL, + icon_id VARCHAR(50), + position_x DOUBLE PRECISION, + position_y DOUBLE PRECISION, + created_at TIMESTAMP NOT NULL, + updated_at TIMESTAMP NOT NULL, + CONSTRAINT fk_todo_categories_member FOREIGN KEY (user_id) + REFERENCES members(id) ON DELETE CASCADE +); + +CREATE INDEX IF NOT EXISTS idx_todo_categories_user ON todo_categories(user_id); + +-- todos: 할 일 +CREATE TABLE IF NOT EXISTS todos ( + id VARCHAR(36) PRIMARY KEY, + user_id BIGINT NOT NULL, + title VARCHAR(100) NOT NULL, + scheduled_dates JSONB NOT NULL DEFAULT '[]'::jsonb, + completed_dates JSONB NOT NULL DEFAULT '[]'::jsonb, + category_ids JSONB NOT NULL DEFAULT '[]'::jsonb, + estimated_minutes INTEGER, + actual_minutes INTEGER, + created_at TIMESTAMP NOT NULL, + updated_at TIMESTAMP NOT NULL, + CONSTRAINT fk_todos_member FOREIGN KEY (user_id) + REFERENCES members(id) ON DELETE CASCADE +); + +CREATE INDEX IF NOT EXISTS idx_todos_user ON todos(user_id); +``` + +- [ ] **Step 4: 마이그레이션 파일 문법 검증 (Gradle 컴파일)** + +Run: `./gradlew :SS-Web:compileJava` +Expected: BUILD SUCCESSFUL (SQL 파일은 검증되지 않으나 다른 파일 영향 없음 확인) + +- [ ] **Step 5: 커밋** + +```bash +git add version.yml build.gradle SS-Web/src/main/resources/db/migration/V0_0_34__add_todos_and_categories.sql +git commit -m "할일 및 카테고리 도메인 구현 : chore : 버전 0.0.34 bump 및 V0_0_34 마이그레이션 추가 #24" +``` + +--- + +## Task 3: SS-Study build.gradle + 테스트 config (Testcontainers) + +**Files:** +- Modify: `SS-Study/build.gradle` +- Create: `SS-Study/src/test/resources/application.yml` + +- [ ] **Step 1: SS-Study build.gradle에 testcontainers 의존성 추가** + +기존 내용 위에 다음과 같이 변경: + +```gradle +bootJar { + enabled = false +} + +jar { + enabled = true + archiveClassifier = '' +} + +dependencies { + api project(':SS-Common') + api project(':SS-Member') + + // Test - Testcontainers PostgreSQL (JSONB 쿼리 검증용) + testImplementation 'org.testcontainers:testcontainers:1.20.4' + testImplementation 'org.testcontainers:postgresql:1.20.4' + testImplementation 'org.testcontainers:junit-jupiter:1.20.4' + testRuntimeOnly 'org.postgresql:postgresql' +} +``` + +- [ ] **Step 2: SS-Study 테스트용 application.yml 작성** + +Create `SS-Study/src/test/resources/application.yml`: + +```yaml +spring: + datasource: + # JdbcDatabaseDelegate가 Testcontainers JDBC URL을 동적으로 처리 + url: jdbc:tc:postgresql:16:///studytest + driver-class-name: org.testcontainers.jdbc.ContainerDatabaseDriver + jpa: + database-platform: org.hibernate.dialect.PostgreSQLDialect + hibernate: + ddl-auto: create-drop + show-sql: false + flyway: + enabled: false +``` + +- [ ] **Step 3: 빌드 확인** + +Run: `./gradlew :SS-Study:dependencies --configuration testRuntimeClasspath | head -50` +Expected: testcontainers, postgresql 의존성 표시. Docker가 로컬에 실행 중이어야 실제 테스트 동작 — 의존성 해석만 확인. + +- [ ] **Step 4: 커밋** + +```bash +git add SS-Study/build.gradle SS-Study/src/test/resources/application.yml +git commit -m "할일 및 카테고리 도메인 구현 : chore : SS-Study Testcontainers PostgreSQL 의존성 추가 #24" +``` + +--- + +## Task 4: StudyTestApplication 셋업 + +**Files:** +- Create: `SS-Study/src/test/java/com/elipair/spacestudyship/study/StudyTestApplication.java` + +- [ ] **Step 1: TestAuthApplication 패턴을 그대로 적용** + +Create file: + +```java +package com.elipair.spacestudyship.study; + +import org.springframework.boot.autoconfigure.AutoConfigurationPackage; +import org.springframework.boot.autoconfigure.ImportAutoConfiguration; +import org.springframework.boot.data.jpa.autoconfigure.DataJpaRepositoriesAutoConfiguration; +import org.springframework.boot.hibernate.autoconfigure.HibernateJpaAutoConfiguration; +import org.springframework.boot.jdbc.autoconfigure.DataSourceAutoConfiguration; +import org.springframework.boot.jdbc.autoconfigure.JdbcTemplateAutoConfiguration; +import org.springframework.boot.transaction.autoconfigure.TransactionAutoConfiguration; +import org.springframework.context.annotation.Configuration; +import org.springframework.data.jpa.repository.config.EnableJpaRepositories; + +@Configuration +@AutoConfigurationPackage(basePackages = "com.elipair.spacestudyship") +@ImportAutoConfiguration({ + DataSourceAutoConfiguration.class, + JdbcTemplateAutoConfiguration.class, + HibernateJpaAutoConfiguration.class, + TransactionAutoConfiguration.class, + DataJpaRepositoriesAutoConfiguration.class +}) +@EnableJpaRepositories(basePackages = "com.elipair.spacestudyship.study.todo.repository") +public class StudyTestApplication { +} +``` + +- [ ] **Step 2: 컴파일 확인** + +Run: `./gradlew :SS-Study:compileTestJava` +Expected: BUILD SUCCESSFUL. + +> 스모크 테스트(`@SpringBootTest` 컨텍스트 로딩 검증)는 Entity가 아직 없어 JPA 메타데이터 초기화가 불완전하므로 Task 6에서 실제 Repository 테스트와 함께 처음 검증된다. Task 4는 클래스 컴파일만 확인. + +- [ ] **Step 3: 커밋** + +```bash +git add SS-Study/src/test/java/com/elipair/spacestudyship/study/StudyTestApplication.java +git commit -m "할일 및 카테고리 도메인 구현 : test : SS-Study 테스트 셋업 (StudyTestApplication) #24" +``` + +--- + +## Task 5: TodoCategory Entity + +**Files:** +- Create: `SS-Study/src/main/java/com/elipair/spacestudyship/study/todo/entity/TodoCategory.java` +- Test: `SS-Study/src/test/java/com/elipair/spacestudyship/study/todo/entity/TodoCategoryTest.java` + +- [ ] **Step 1: 실패하는 테스트 작성 — 정적 팩토리 + updateXxx** + +Create test file: + +```java +package com.elipair.spacestudyship.study.todo.entity; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +class TodoCategoryTest { + + @Test + @DisplayName("create: 정적 팩토리로 카테고리 생성") + void create() { + TodoCategory category = TodoCategory.create( + "cat-1", 1L, "수학", "math_icon", 0.3, 0.5); + + assertThat(category.getId()).isEqualTo("cat-1"); + assertThat(category.getUserId()).isEqualTo(1L); + assertThat(category.getName()).isEqualTo("수학"); + assertThat(category.getIconId()).isEqualTo("math_icon"); + assertThat(category.getPositionX()).isEqualTo(0.3); + assertThat(category.getPositionY()).isEqualTo(0.5); + } + + @Test + @DisplayName("updateName: 이름 변경") + void updateName() { + TodoCategory category = TodoCategory.create("cat-1", 1L, "수학", null, null, null); + category.updateName("심화수학"); + assertThat(category.getName()).isEqualTo("심화수학"); + } + + @Test + @DisplayName("updateIconId: 아이콘 변경") + void updateIconId() { + TodoCategory category = TodoCategory.create("cat-1", 1L, "수학", "math_icon", null, null); + category.updateIconId("new_icon"); + assertThat(category.getIconId()).isEqualTo("new_icon"); + } + + @Test + @DisplayName("updatePositionX/Y: 위치 변경") + void updatePosition() { + TodoCategory category = TodoCategory.create("cat-1", 1L, "수학", null, 0.3, 0.5); + category.updatePositionX(0.7); + category.updatePositionY(0.2); + assertThat(category.getPositionX()).isEqualTo(0.7); + assertThat(category.getPositionY()).isEqualTo(0.2); + } +} +``` + +- [ ] **Step 2: 테스트 실행 — FAIL 확인** + +Run: `./gradlew :SS-Study:test --tests "*TodoCategoryTest"` +Expected: FAIL with "cannot find symbol class TodoCategory" + +- [ ] **Step 3: TodoCategory Entity 구현** + +Create `SS-Study/src/main/java/com/elipair/spacestudyship/study/todo/entity/TodoCategory.java`: + +```java +package com.elipair.spacestudyship.study.todo.entity; + +import com.elipair.spacestudyship.common.entity.BaseTimeEntity; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.Id; +import jakarta.persistence.Table; +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Entity +@Table(name = "todo_categories") +@Getter +@Builder +@AllArgsConstructor +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class TodoCategory extends BaseTimeEntity { + + @Id + @Column(length = 36) + private String id; + + @Column(name = "user_id", nullable = false) + private Long userId; + + @Column(nullable = false, length = 20) + private String name; + + @Column(name = "icon_id", length = 50) + private String iconId; + + @Column(name = "position_x") + private Double positionX; + + @Column(name = "position_y") + private Double positionY; + + public static TodoCategory create(String id, Long userId, String name, + String iconId, Double positionX, Double positionY) { + return TodoCategory.builder() + .id(id) + .userId(userId) + .name(name) + .iconId(iconId) + .positionX(positionX) + .positionY(positionY) + .build(); + } + + public void updateName(String name) { + this.name = name; + } + + public void updateIconId(String iconId) { + this.iconId = iconId; + } + + public void updatePositionX(Double positionX) { + this.positionX = positionX; + } + + public void updatePositionY(Double positionY) { + this.positionY = positionY; + } +} +``` + +- [ ] **Step 4: 테스트 실행 — PASS 확인** + +Run: `./gradlew :SS-Study:test --tests "*TodoCategoryTest"` +Expected: BUILD SUCCESSFUL, 4 tests passed + +- [ ] **Step 5: 커밋** + +```bash +git add SS-Study/src/main/java/com/elipair/spacestudyship/study/todo/entity/TodoCategory.java SS-Study/src/test/java/com/elipair/spacestudyship/study/todo/entity/TodoCategoryTest.java +git commit -m "할일 및 카테고리 도메인 구현 : feat : TodoCategory Entity 추가 #24" +``` + +--- + +## Task 6: TodoCategoryRepository + Repository 테스트 + +**Files:** +- Create: `SS-Study/src/main/java/com/elipair/spacestudyship/study/todo/repository/TodoCategoryRepository.java` +- Test: `SS-Study/src/test/java/com/elipair/spacestudyship/study/todo/repository/TodoCategoryRepositoryTest.java` + +- [ ] **Step 1: 실패하는 Repository 테스트 작성** + +Create test: + +```java +package com.elipair.spacestudyship.study.todo.repository; + +import com.elipair.spacestudyship.study.StudyTestApplication; +import com.elipair.spacestudyship.study.todo.entity.TodoCategory; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThat; + +@SpringBootTest(classes = StudyTestApplication.class) +@Transactional +class TodoCategoryRepositoryTest { + + @Autowired + TodoCategoryRepository categoryRepository; + + @Test + @DisplayName("findByUserIdOrderByCreatedAtAsc: 사용자 카테고리를 생성일 오름차순으로 반환") + void findByUserIdOrderByCreatedAtAsc() { + categoryRepository.save(TodoCategory.create("c1", 1L, "수학", null, null, null)); + categoryRepository.save(TodoCategory.create("c2", 1L, "영어", null, null, null)); + categoryRepository.save(TodoCategory.create("c3", 2L, "다른유저", null, null, null)); + + List result = categoryRepository.findByUserIdOrderByCreatedAtAsc(1L); + + assertThat(result).extracting("id").containsExactly("c1", "c2"); + } + + @Test + @DisplayName("existsByIdAndUserId: 본인 카테고리는 true") + void existsByIdAndUserId_true() { + categoryRepository.save(TodoCategory.create("c1", 1L, "수학", null, null, null)); + assertThat(categoryRepository.existsByIdAndUserId("c1", 1L)).isTrue(); + } + + @Test + @DisplayName("existsByIdAndUserId: 다른 사용자 카테고리는 false") + void existsByIdAndUserId_otherUser() { + categoryRepository.save(TodoCategory.create("c1", 1L, "수학", null, null, null)); + assertThat(categoryRepository.existsByIdAndUserId("c1", 2L)).isFalse(); + } + + @Test + @DisplayName("findByIdAndUserId: 본인 카테고리만 조회") + void findByIdAndUserId() { + categoryRepository.save(TodoCategory.create("c1", 1L, "수학", null, null, null)); + + Optional mine = categoryRepository.findByIdAndUserId("c1", 1L); + Optional other = categoryRepository.findByIdAndUserId("c1", 99L); + + assertThat(mine).isPresent(); + assertThat(other).isEmpty(); + } + + @Test + @DisplayName("countByIdInAndUserId: 본인 소유 카테고리 ID 개수") + void countByIdInAndUserId() { + categoryRepository.save(TodoCategory.create("c1", 1L, "수학", null, null, null)); + categoryRepository.save(TodoCategory.create("c2", 1L, "영어", null, null, null)); + categoryRepository.save(TodoCategory.create("c3", 2L, "다른유저", null, null, null)); + + long count = categoryRepository.countByIdInAndUserId(List.of("c1", "c2", "c3"), 1L); + + assertThat(count).isEqualTo(2L); + } +} +``` + +- [ ] **Step 2: 테스트 실행 — FAIL 확인** + +Run: `./gradlew :SS-Study:test --tests "*TodoCategoryRepositoryTest"` +Expected: FAIL with "cannot find symbol class TodoCategoryRepository" + +- [ ] **Step 3: TodoCategoryRepository 구현** + +Create `SS-Study/src/main/java/com/elipair/spacestudyship/study/todo/repository/TodoCategoryRepository.java`: + +```java +package com.elipair.spacestudyship.study.todo.repository; + +import com.elipair.spacestudyship.study.todo.entity.TodoCategory; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.Collection; +import java.util.List; +import java.util.Optional; + +public interface TodoCategoryRepository extends JpaRepository { + + List findByUserIdOrderByCreatedAtAsc(Long userId); + + boolean existsByIdAndUserId(String id, Long userId); + + Optional findByIdAndUserId(String id, Long userId); + + long countByIdInAndUserId(Collection ids, Long userId); +} +``` + +- [ ] **Step 4: 테스트 실행 — PASS 확인** + +Run: `./gradlew :SS-Study:test --tests "*TodoCategoryRepositoryTest"` +Expected: BUILD SUCCESSFUL, 5 tests passed + +- [ ] **Step 5: 커밋** + +```bash +git add SS-Study/src/main/java/com/elipair/spacestudyship/study/todo/repository/TodoCategoryRepository.java SS-Study/src/test/java/com/elipair/spacestudyship/study/todo/repository/TodoCategoryRepositoryTest.java +git commit -m "할일 및 카테고리 도메인 구현 : feat : TodoCategoryRepository 추가 #24" +``` + +--- + +## Task 7: Category DTO 3개 (Record) + +**Files:** +- Create: `SS-Study/src/main/java/com/elipair/spacestudyship/study/todo/dto/CategoryCreateRequest.java` +- Create: `SS-Study/src/main/java/com/elipair/spacestudyship/study/todo/dto/CategoryUpdateRequest.java` +- Create: `SS-Study/src/main/java/com/elipair/spacestudyship/study/todo/dto/CategoryResponse.java` + +- [ ] **Step 1: CategoryCreateRequest 작성** + +Create: + +```java +package com.elipair.spacestudyship.study.todo.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.DecimalMax; +import jakarta.validation.constraints.DecimalMin; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Size; + +@Schema(description = "카테고리 생성 요청") +public record CategoryCreateRequest( + + @Schema(description = "클라이언트 UUID (없으면 서버 생성)", nullable = true, + example = "cat-uuid-3") + String id, + + @Schema(description = "카테고리 이름 (1~20자)", example = "수학") + @NotBlank + @Size(max = 20) + String name, + + @Schema(description = "아이콘 식별자", nullable = true, example = "math_icon") + String iconId, + + @Schema(description = "맵 가로 위치 (0.0~1.0)", nullable = true, example = "0.3") + @DecimalMin("0.0") + @DecimalMax("1.0") + Double positionX, + + @Schema(description = "맵 세로 위치 (0.0~1.0)", nullable = true, example = "0.5") + @DecimalMin("0.0") + @DecimalMax("1.0") + Double positionY +) { +} +``` + +- [ ] **Step 2: CategoryUpdateRequest 작성** + +Create: + +```java +package com.elipair.spacestudyship.study.todo.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.DecimalMax; +import jakarta.validation.constraints.DecimalMin; +import jakarta.validation.constraints.Size; + +@Schema(description = "카테고리 부분 수정 요청 — 전송하지 않은 필드는 기존 값 유지") +public record CategoryUpdateRequest( + + @Schema(description = "카테고리 이름 (1~20자)", nullable = true) + @Size(min = 1, max = 20) + String name, + + @Schema(description = "아이콘 식별자", nullable = true) + String iconId, + + @Schema(description = "맵 가로 위치 (0.0~1.0)", nullable = true) + @DecimalMin("0.0") + @DecimalMax("1.0") + Double positionX, + + @Schema(description = "맵 세로 위치 (0.0~1.0)", nullable = true) + @DecimalMin("0.0") + @DecimalMax("1.0") + Double positionY +) { +} +``` + +- [ ] **Step 3: CategoryResponse 작성** + +Create: + +```java +package com.elipair.spacestudyship.study.todo.dto; + +import com.elipair.spacestudyship.study.todo.entity.TodoCategory; +import io.swagger.v3.oas.annotations.media.Schema; + +import java.time.LocalDateTime; +import java.time.ZoneOffset; +import java.time.format.DateTimeFormatter; + +@Schema(description = "카테고리 응답") +public record CategoryResponse( + @Schema(description = "카테고리 ID", example = "cat-uuid-1") String id, + @Schema(description = "이름", example = "수학") String name, + @Schema(description = "아이콘 식별자", nullable = true) String iconId, + @Schema(description = "맵 가로 위치", nullable = true) Double positionX, + @Schema(description = "맵 세로 위치", nullable = true) Double positionY, + @Schema(description = "생성 시각 (ISO 8601 UTC)") String createdAt, + @Schema(description = "마지막 수정 시각 (ISO 8601 UTC)", nullable = true) String updatedAt +) { + private static final DateTimeFormatter ISO_UTC = + DateTimeFormatter.ISO_INSTANT; + + public static CategoryResponse from(TodoCategory category) { + return new CategoryResponse( + category.getId(), + category.getName(), + category.getIconId(), + category.getPositionX(), + category.getPositionY(), + formatUtc(category.getCreatedAt()), + formatUtc(category.getUpdatedAt()) + ); + } + + private static String formatUtc(LocalDateTime time) { + return time == null ? null : ISO_UTC.format(time.toInstant(ZoneOffset.UTC)); + } +} +``` + +- [ ] **Step 4: 컴파일 확인** + +Run: `./gradlew :SS-Study:compileJava` +Expected: BUILD SUCCESSFUL + +- [ ] **Step 5: 커밋** + +```bash +git add SS-Study/src/main/java/com/elipair/spacestudyship/study/todo/dto/CategoryCreateRequest.java SS-Study/src/main/java/com/elipair/spacestudyship/study/todo/dto/CategoryUpdateRequest.java SS-Study/src/main/java/com/elipair/spacestudyship/study/todo/dto/CategoryResponse.java +git commit -m "할일 및 카테고리 도메인 구현 : feat : Category DTO 3개 추가 (Record) #24" +``` + +--- + +## Task 8: Todo Entity + +**Files:** +- Create: `SS-Study/src/main/java/com/elipair/spacestudyship/study/todo/entity/Todo.java` +- Test: `SS-Study/src/test/java/com/elipair/spacestudyship/study/todo/entity/TodoTest.java` + +- [ ] **Step 1: 실패하는 테스트 작성** + +Create: + +```java +package com.elipair.spacestudyship.study.todo.entity; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; + +class TodoTest { + + @Test + @DisplayName("create: 정적 팩토리로 Todo 생성 — null 배열은 빈 배열로 정규화") + void create_nullArraysNormalizedToEmpty() { + Todo todo = Todo.create("t1", 1L, "수학 문제", null, null, 60); + + assertThat(todo.getId()).isEqualTo("t1"); + assertThat(todo.getUserId()).isEqualTo(1L); + assertThat(todo.getTitle()).isEqualTo("수학 문제"); + assertThat(todo.getScheduledDates()).isEmpty(); + assertThat(todo.getCompletedDates()).isEmpty(); + assertThat(todo.getCategoryIds()).isEmpty(); + assertThat(todo.getEstimatedMinutes()).isEqualTo(60); + assertThat(todo.getActualMinutes()).isNull(); + } + + @Test + @DisplayName("create: 값이 있으면 그대로 사용") + void create_withValues() { + Todo todo = Todo.create( + "t1", 1L, "수학", + List.of("2026-04-16"), + List.of("cat-1"), + 90); + + assertThat(todo.getScheduledDates()).containsExactly("2026-04-16"); + assertThat(todo.getCategoryIds()).containsExactly("cat-1"); + } + + @Test + @DisplayName("updateTitle / updateScheduledDates / updateCompletedDates / updateCategoryIds / updateEstimatedMinutes / updateActualMinutes") + void updaters() { + Todo todo = Todo.create("t1", 1L, "원본", null, null, null); + + todo.updateTitle("새 제목"); + todo.updateScheduledDates(List.of("2026-05-01")); + todo.updateCompletedDates(List.of("2026-05-01")); + todo.updateCategoryIds(List.of("c1", "c2")); + todo.updateEstimatedMinutes(120); + todo.updateActualMinutes(45); + + assertThat(todo.getTitle()).isEqualTo("새 제목"); + assertThat(todo.getScheduledDates()).containsExactly("2026-05-01"); + assertThat(todo.getCompletedDates()).containsExactly("2026-05-01"); + assertThat(todo.getCategoryIds()).containsExactly("c1", "c2"); + assertThat(todo.getEstimatedMinutes()).isEqualTo(120); + assertThat(todo.getActualMinutes()).isEqualTo(45); + } + + @Test + @DisplayName("removeCategoryId: 해당 ID만 제거 (immutable copy)") + void removeCategoryId() { + Todo todo = Todo.create("t1", 1L, "수학", null, List.of("c1", "c2", "c3"), null); + + todo.removeCategoryId("c2"); + + assertThat(todo.getCategoryIds()).containsExactly("c1", "c3"); + } + + @Test + @DisplayName("removeCategoryId: 존재하지 않는 ID면 무변화") + void removeCategoryId_notExist() { + Todo todo = Todo.create("t1", 1L, "수학", null, List.of("c1"), null); + + todo.removeCategoryId("c-missing"); + + assertThat(todo.getCategoryIds()).containsExactly("c1"); + } +} +``` + +- [ ] **Step 2: 테스트 실행 — FAIL 확인** + +Run: `./gradlew :SS-Study:test --tests "*TodoTest"` +Expected: FAIL with "cannot find symbol class Todo" + +- [ ] **Step 3: Todo Entity 구현** + +Create `SS-Study/src/main/java/com/elipair/spacestudyship/study/todo/entity/Todo.java`: + +```java +package com.elipair.spacestudyship.study.todo.entity; + +import com.elipair.spacestudyship.common.entity.BaseTimeEntity; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.Id; +import jakarta.persistence.Table; +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import org.hibernate.annotations.JdbcTypeCode; +import org.hibernate.type.SqlTypes; + +import java.util.ArrayList; +import java.util.List; + +@Entity +@Table(name = "todos") +@Getter +@Builder +@AllArgsConstructor +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class Todo extends BaseTimeEntity { + + @Id + @Column(length = 36) + private String id; + + @Column(name = "user_id", nullable = false) + private Long userId; + + @Column(nullable = false, length = 100) + private String title; + + @JdbcTypeCode(SqlTypes.JSON) + @Column(name = "scheduled_dates", nullable = false, columnDefinition = "jsonb") + private List scheduledDates; + + @JdbcTypeCode(SqlTypes.JSON) + @Column(name = "completed_dates", nullable = false, columnDefinition = "jsonb") + private List completedDates; + + @JdbcTypeCode(SqlTypes.JSON) + @Column(name = "category_ids", nullable = false, columnDefinition = "jsonb") + private List categoryIds; + + @Column(name = "estimated_minutes") + private Integer estimatedMinutes; + + @Column(name = "actual_minutes") + private Integer actualMinutes; + + public static Todo create(String id, Long userId, String title, + List scheduledDates, + List categoryIds, + Integer estimatedMinutes) { + return Todo.builder() + .id(id) + .userId(userId) + .title(title) + .scheduledDates(scheduledDates == null ? new ArrayList<>() : new ArrayList<>(scheduledDates)) + .completedDates(new ArrayList<>()) + .categoryIds(categoryIds == null ? new ArrayList<>() : new ArrayList<>(categoryIds)) + .estimatedMinutes(estimatedMinutes) + .build(); + } + + public void updateTitle(String title) { + this.title = title; + } + + public void updateScheduledDates(List dates) { + this.scheduledDates = new ArrayList<>(dates); + } + + public void updateCompletedDates(List dates) { + this.completedDates = new ArrayList<>(dates); + } + + public void updateCategoryIds(List ids) { + this.categoryIds = new ArrayList<>(ids); + } + + public void updateEstimatedMinutes(Integer minutes) { + this.estimatedMinutes = minutes; + } + + public void updateActualMinutes(Integer minutes) { + this.actualMinutes = minutes; + } + + public void removeCategoryId(String categoryId) { + this.categoryIds = this.categoryIds.stream() + .filter(id -> !id.equals(categoryId)) + .collect(java.util.stream.Collectors.toCollection(ArrayList::new)); + } +} +``` + +- [ ] **Step 4: 테스트 실행 — PASS 확인** + +Run: `./gradlew :SS-Study:test --tests "*TodoTest"` +Expected: BUILD SUCCESSFUL, 5 tests passed + +- [ ] **Step 5: 커밋** + +```bash +git add SS-Study/src/main/java/com/elipair/spacestudyship/study/todo/entity/Todo.java SS-Study/src/test/java/com/elipair/spacestudyship/study/todo/entity/TodoTest.java +git commit -m "할일 및 카테고리 도메인 구현 : feat : Todo Entity 추가 (JSONB 매핑) #24" +``` + +--- + +## Task 9: TodoRepository + Repository 테스트 (JSONB `@>` 쿼리) + +**Files:** +- Create: `SS-Study/src/main/java/com/elipair/spacestudyship/study/todo/repository/TodoRepository.java` +- Test: `SS-Study/src/test/java/com/elipair/spacestudyship/study/todo/repository/TodoRepositoryTest.java` + +- [ ] **Step 1: 실패하는 Repository 테스트 작성 — 5개 케이스** + +Create test: + +```java +package com.elipair.spacestudyship.study.todo.repository; + +import com.elipair.spacestudyship.study.StudyTestApplication; +import com.elipair.spacestudyship.study.todo.entity.Todo; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThat; + +@SpringBootTest(classes = StudyTestApplication.class) +@Transactional +class TodoRepositoryTest { + + @Autowired + TodoRepository todoRepository; + + @Test + @DisplayName("findByUserIdOrderByCreatedAtDesc: 본인 Todo만, 최신순 반환") + void findByUserId_ordered() { + todoRepository.save(Todo.create("t1", 1L, "첫번째", null, null, null)); + todoRepository.save(Todo.create("t2", 1L, "두번째", null, null, null)); + todoRepository.save(Todo.create("t3", 2L, "다른유저", null, null, null)); + + List result = todoRepository.findByUserIdOrderByCreatedAtDesc(1L); + + assertThat(result).hasSize(2); + assertThat(result).extracting("userId").containsOnly(1L); + } + + @Test + @DisplayName("findByUserIdAndScheduledDate: JSONB @> 연산자로 날짜 포함 Todo 필터") + void findByUserIdAndScheduledDate() { + todoRepository.save(Todo.create("t1", 1L, "월요일", List.of("2026-04-16"), null, null)); + todoRepository.save(Todo.create("t2", 1L, "양일", List.of("2026-04-16", "2026-04-17"), null, null)); + todoRepository.save(Todo.create("t3", 1L, "다른날", List.of("2026-04-18"), null, null)); + + List result = todoRepository + .findByUserIdAndScheduledDate(1L, "\"2026-04-16\""); + + assertThat(result).extracting("id").containsExactlyInAnyOrder("t1", "t2"); + } + + @Test + @DisplayName("findByUserIdAndCategoryId: JSONB @> 연산자로 카테고리 포함 Todo 필터") + void findByUserIdAndCategoryId() { + todoRepository.save(Todo.create("t1", 1L, "수학", null, List.of("c-math"), null)); + todoRepository.save(Todo.create("t2", 1L, "복합", null, List.of("c-math", "c-eng"), null)); + todoRepository.save(Todo.create("t3", 1L, "영어만", null, List.of("c-eng"), null)); + + List result = todoRepository + .findByUserIdAndCategoryId(1L, "\"c-math\""); + + assertThat(result).extracting("id").containsExactlyInAnyOrder("t1", "t2"); + } + + @Test + @DisplayName("existsByIdAndUserId: 본인 소유 여부") + void existsByIdAndUserId() { + todoRepository.save(Todo.create("t1", 1L, "X", null, null, null)); + assertThat(todoRepository.existsByIdAndUserId("t1", 1L)).isTrue(); + assertThat(todoRepository.existsByIdAndUserId("t1", 99L)).isFalse(); + } + + @Test + @DisplayName("findByIdAndUserId: 본인 소유만 조회") + void findByIdAndUserId() { + todoRepository.save(Todo.create("t1", 1L, "X", null, null, null)); + + Optional mine = todoRepository.findByIdAndUserId("t1", 1L); + Optional other = todoRepository.findByIdAndUserId("t1", 99L); + + assertThat(mine).isPresent(); + assertThat(other).isEmpty(); + } +} +``` + +- [ ] **Step 2: 테스트 실행 — FAIL 확인** + +Run: `./gradlew :SS-Study:test --tests "*TodoRepositoryTest"` +Expected: FAIL with "cannot find symbol class TodoRepository" + +- [ ] **Step 3: TodoRepository 구현** + +Create `SS-Study/src/main/java/com/elipair/spacestudyship/study/todo/repository/TodoRepository.java`: + +```java +package com.elipair.spacestudyship.study.todo.repository; + +import com.elipair.spacestudyship.study.todo.entity.Todo; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +import java.util.List; +import java.util.Optional; + +public interface TodoRepository extends JpaRepository { + + List findByUserIdOrderByCreatedAtDesc(Long userId); + + @Query(value = """ + SELECT * FROM todos + WHERE user_id = :userId + AND scheduled_dates @> CAST(:dateJson AS jsonb) + ORDER BY created_at DESC + """, nativeQuery = true) + List findByUserIdAndScheduledDate(@Param("userId") Long userId, + @Param("dateJson") String dateJsonLiteral); + + @Query(value = """ + SELECT * FROM todos + WHERE user_id = :userId + AND category_ids @> CAST(:categoryJson AS jsonb) + ORDER BY created_at DESC + """, nativeQuery = true) + List findByUserIdAndCategoryId(@Param("userId") Long userId, + @Param("categoryJson") String categoryJsonLiteral); + + boolean existsByIdAndUserId(String id, Long userId); + + Optional findByIdAndUserId(String id, Long userId); +} +``` + +- [ ] **Step 4: 테스트 실행 — PASS 확인** + +Run: `./gradlew :SS-Study:test --tests "*TodoRepositoryTest"` +Expected: BUILD SUCCESSFUL, 5 tests passed + +- [ ] **Step 5: 커밋** + +```bash +git add SS-Study/src/main/java/com/elipair/spacestudyship/study/todo/repository/TodoRepository.java SS-Study/src/test/java/com/elipair/spacestudyship/study/todo/repository/TodoRepositoryTest.java +git commit -m "할일 및 카테고리 도메인 구현 : feat : TodoRepository 추가 (JSONB @> 쿼리) #24" +``` + +--- + +## Task 10: Todo DTO 3개 (Record) + +**Files:** +- Create: `SS-Study/src/main/java/com/elipair/spacestudyship/study/todo/dto/TodoCreateRequest.java` +- Create: `SS-Study/src/main/java/com/elipair/spacestudyship/study/todo/dto/TodoUpdateRequest.java` +- Create: `SS-Study/src/main/java/com/elipair/spacestudyship/study/todo/dto/TodoResponse.java` + +- [ ] **Step 1: TodoCreateRequest** + +Create: + +```java +package com.elipair.spacestudyship.study.todo.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Size; + +import java.util.List; + +@Schema(description = "할 일 생성 요청") +public record TodoCreateRequest( + + @Schema(description = "클라이언트 UUID v4 (없으면 서버 생성)", nullable = true, + example = "550e8400-e29b-41d4-a716-446655440000") + String id, + + @Schema(description = "제목 (1~100자)", example = "수학 문제 풀기") + @NotBlank + @Size(max = 100) + String title, + + @Schema(description = "카테고리 ID 목록 (기본 [])", example = "[\"cat-uuid-1\"]") + List categoryIds, + + @Schema(description = "예상 소요 시간(분, 1 이상)", nullable = true, example = "60") + @Min(1) + Integer estimatedMinutes, + + @Schema(description = "예정 날짜 목록 (YYYY-MM-DD)", example = "[\"2026-04-16\"]") + List scheduledDates +) { +} +``` + +- [ ] **Step 2: TodoUpdateRequest** + +Create: + +```java +package com.elipair.spacestudyship.study.todo.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.Size; + +import java.util.List; + +@Schema(description = "할 일 부분 수정 요청 — 전송하지 않은 필드는 기존 값 유지") +public record TodoUpdateRequest( + + @Schema(description = "제목 (1~100자)", nullable = true) + @Size(min = 1, max = 100) + String title, + + @Schema(description = "예정 날짜 목록 (YYYY-MM-DD)", nullable = true) + List scheduledDates, + + @Schema(description = "완료 날짜 목록 (YYYY-MM-DD)", nullable = true) + List completedDates, + + @Schema(description = "카테고리 ID 목록", nullable = true) + List categoryIds, + + @Schema(description = "예상 소요 시간(분)", nullable = true) + @Min(1) + Integer estimatedMinutes, + + @Schema(description = "실제 소요 시간(분)", nullable = true) + @Min(0) + Integer actualMinutes +) { +} +``` + +- [ ] **Step 3: TodoResponse** + +Create: + +```java +package com.elipair.spacestudyship.study.todo.dto; + +import com.elipair.spacestudyship.study.todo.entity.Todo; +import io.swagger.v3.oas.annotations.media.Schema; + +import java.time.LocalDateTime; +import java.time.ZoneOffset; +import java.time.format.DateTimeFormatter; +import java.util.List; + +@Schema(description = "할 일 응답") +public record TodoResponse( + @Schema(description = "Todo ID", example = "550e8400-e29b-41d4-a716-446655440000") + String id, + + @Schema(description = "제목") String title, + + @Schema(description = "예정 날짜 목록") List scheduledDates, + + @Schema(description = "완료 날짜 목록") List completedDates, + + @Schema(description = "카테고리 ID 목록") List categoryIds, + + @Schema(description = "예상 소요 시간(분)", nullable = true) Integer estimatedMinutes, + + @Schema(description = "실제 소요 시간(분)", nullable = true) Integer actualMinutes, + + @Schema(description = "생성 시각 (ISO 8601 UTC)") String createdAt, + + @Schema(description = "마지막 수정 시각 (ISO 8601 UTC)") String updatedAt +) { + private static final DateTimeFormatter ISO_UTC = DateTimeFormatter.ISO_INSTANT; + + public static TodoResponse from(Todo todo) { + return new TodoResponse( + todo.getId(), + todo.getTitle(), + todo.getScheduledDates(), + todo.getCompletedDates(), + todo.getCategoryIds(), + todo.getEstimatedMinutes(), + todo.getActualMinutes(), + formatUtc(todo.getCreatedAt()), + formatUtc(todo.getUpdatedAt()) + ); + } + + private static String formatUtc(LocalDateTime time) { + return time == null ? null : ISO_UTC.format(time.toInstant(ZoneOffset.UTC)); + } +} +``` + +- [ ] **Step 4: 컴파일 확인** + +Run: `./gradlew :SS-Study:compileJava` +Expected: BUILD SUCCESSFUL + +- [ ] **Step 5: 커밋** + +```bash +git add SS-Study/src/main/java/com/elipair/spacestudyship/study/todo/dto/TodoCreateRequest.java SS-Study/src/main/java/com/elipair/spacestudyship/study/todo/dto/TodoUpdateRequest.java SS-Study/src/main/java/com/elipair/spacestudyship/study/todo/dto/TodoResponse.java +git commit -m "할일 및 카테고리 도메인 구현 : feat : Todo DTO 3개 추가 (Record) #24" +``` + +--- + +## Task 11: TodoService.findAll (필터 조합) + +**Files:** +- Create: `SS-Study/src/main/java/com/elipair/spacestudyship/study/todo/service/TodoService.java` +- Test: `SS-Study/src/test/java/com/elipair/spacestudyship/study/todo/service/TodoServiceTest.java` + +- [ ] **Step 1: 실패하는 단위 테스트 작성 — findAll 4개 케이스** + +Create: + +```java +package com.elipair.spacestudyship.study.todo.service; + +import com.elipair.spacestudyship.study.todo.entity.Todo; +import com.elipair.spacestudyship.study.todo.repository.TodoCategoryRepository; +import com.elipair.spacestudyship.study.todo.repository.TodoRepository; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class TodoServiceTest { + + @Mock TodoRepository todoRepository; + @Mock TodoCategoryRepository categoryRepository; + @InjectMocks TodoService todoService; + + @Test + @DisplayName("findAll: 필터 없음 → findByUserIdOrderByCreatedAtDesc 호출") + void findAll_noFilters() { + when(todoRepository.findByUserIdOrderByCreatedAtDesc(1L)) + .thenReturn(List.of(Todo.create("t1", 1L, "X", null, null, null))); + + // null literal 직접 전달 (eq(null) 매처는 Mockito mixed-matcher 제약으로 사용 불가) + var result = todoService.findAll(1L, null, null); + + assertThat(result).hasSize(1); + verify(todoRepository).findByUserIdOrderByCreatedAtDesc(1L); + } + + @Test + @DisplayName("findAll: date 필터만") + void findAll_dateOnly() { + when(todoRepository.findByUserIdAndScheduledDate(1L, "\"2026-04-16\"")) + .thenReturn(List.of(Todo.create("t1", 1L, "X", List.of("2026-04-16"), null, null))); + + var result = todoService.findAll(1L, "2026-04-16", null); + + assertThat(result).hasSize(1); + verify(todoRepository).findByUserIdAndScheduledDate(1L, "\"2026-04-16\""); + } + + @Test + @DisplayName("findAll: categoryId 필터만") + void findAll_categoryOnly() { + when(todoRepository.findByUserIdAndCategoryId(1L, "\"c1\"")) + .thenReturn(List.of(Todo.create("t1", 1L, "X", null, List.of("c1"), null))); + + var result = todoService.findAll(1L, null, "c1"); + + assertThat(result).hasSize(1); + verify(todoRepository).findByUserIdAndCategoryId(1L, "\"c1\""); + } + + @Test + @DisplayName("findAll: date + categoryId — 두 쿼리 결과의 교집합") + void findAll_dateAndCategory() { + Todo a = Todo.create("a", 1L, "AB", List.of("2026-04-16"), List.of("c1"), null); + Todo b = Todo.create("b", 1L, "B만", List.of("2026-04-16"), List.of("c2"), null); + when(todoRepository.findByUserIdAndScheduledDate(1L, "\"2026-04-16\"")) + .thenReturn(List.of(a, b)); + when(todoRepository.findByUserIdAndCategoryId(1L, "\"c1\"")) + .thenReturn(List.of(a)); + + var result = todoService.findAll(1L, "2026-04-16", "c1"); + + assertThat(result).hasSize(1); + assertThat(result.get(0).id()).isEqualTo("a"); + } +} +``` + +- [ ] **Step 2: 테스트 실행 — FAIL 확인** + +Run: `./gradlew :SS-Study:test --tests "*TodoServiceTest"` +Expected: FAIL with "cannot find symbol class TodoService" + +- [ ] **Step 3: TodoService 스켈레톤 + findAll 구현** + +Create: + +```java +package com.elipair.spacestudyship.study.todo.service; + +import com.elipair.spacestudyship.study.todo.dto.TodoResponse; +import com.elipair.spacestudyship.study.todo.entity.Todo; +import com.elipair.spacestudyship.study.todo.repository.TodoCategoryRepository; +import com.elipair.spacestudyship.study.todo.repository.TodoRepository; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; +import java.util.Set; +import java.util.stream.Collectors; + +@Slf4j +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class TodoService { + + private final TodoRepository todoRepository; + private final TodoCategoryRepository categoryRepository; + + public List findAll(Long userId, String date, String categoryId) { + List todos; + if (date != null && categoryId != null) { + Set byDateIds = todoRepository + .findByUserIdAndScheduledDate(userId, jsonLiteral(date)) + .stream() + .map(Todo::getId) + .collect(Collectors.toSet()); + todos = todoRepository + .findByUserIdAndCategoryId(userId, jsonLiteral(categoryId)) + .stream() + .filter(t -> byDateIds.contains(t.getId())) + .toList(); + } else if (date != null) { + todos = todoRepository.findByUserIdAndScheduledDate(userId, jsonLiteral(date)); + } else if (categoryId != null) { + todos = todoRepository.findByUserIdAndCategoryId(userId, jsonLiteral(categoryId)); + } else { + todos = todoRepository.findByUserIdOrderByCreatedAtDesc(userId); + } + return todos.stream().map(TodoResponse::from).toList(); + } + + private static String jsonLiteral(String value) { + return "\"" + value + "\""; + } +} +``` + +- [ ] **Step 4: 테스트 실행 — PASS 확인** + +Run: `./gradlew :SS-Study:test --tests "*TodoServiceTest"` +Expected: BUILD SUCCESSFUL, 4 tests passed + +- [ ] **Step 5: 커밋** + +```bash +git add SS-Study/src/main/java/com/elipair/spacestudyship/study/todo/service/TodoService.java SS-Study/src/test/java/com/elipair/spacestudyship/study/todo/service/TodoServiceTest.java +git commit -m "할일 및 카테고리 도메인 구현 : feat : TodoService.findAll 추가 (필터 조합) #24" +``` + +--- + +## Task 12: TodoService.create + categoryIds 검증 + +**Files:** +- Modify: `SS-Study/src/main/java/com/elipair/spacestudyship/study/todo/service/TodoService.java` +- Modify: `SS-Study/src/test/java/com/elipair/spacestudyship/study/todo/service/TodoServiceTest.java` + +- [ ] **Step 1: 실패하는 테스트 추가 — create 3개 케이스** + +Append to `TodoServiceTest.java` 클래스 내부 (다른 import 필요: ArgumentCaptor, anyString, anyLong, anyCollection, UUID 등): + +```java + @Test + @DisplayName("create: id 미지정 → 서버가 UUID 생성, 카테고리 검증 통과") + void create_serverGeneratedId() { + var request = new com.elipair.spacestudyship.study.todo.dto.TodoCreateRequest( + null, "수학", java.util.List.of(), 60, java.util.List.of("2026-04-16")); + when(todoRepository.existsById(org.mockito.ArgumentMatchers.anyString())).thenReturn(false); + when(todoRepository.save(org.mockito.ArgumentMatchers.any(Todo.class))) + .thenAnswer(inv -> inv.getArgument(0)); + + var response = todoService.create(1L, request); + + assertThat(response.id()).isNotBlank(); + assertThat(response.title()).isEqualTo("수학"); + } + + @Test + @DisplayName("create: 동일 ID 존재 → TODO_ALREADY_EXISTS") + void create_duplicateId() { + var request = new com.elipair.spacestudyship.study.todo.dto.TodoCreateRequest( + "t1", "수학", java.util.List.of(), null, java.util.List.of()); + when(todoRepository.existsById("t1")).thenReturn(true); + + org.assertj.core.api.Assertions.assertThatThrownBy(() -> todoService.create(1L, request)) + .isInstanceOf(com.elipair.spacestudyship.common.exception.CustomException.class) + .extracting("errorCode") + .isEqualTo(com.elipair.spacestudyship.common.exception.ErrorCode.TODO_ALREADY_EXISTS); + } + + @Test + @DisplayName("create: categoryIds에 존재하지 않는 ID → CATEGORY_NOT_FOUND") + void create_invalidCategoryId() { + var request = new com.elipair.spacestudyship.study.todo.dto.TodoCreateRequest( + "t1", "수학", java.util.List.of("missing-cat"), null, java.util.List.of()); + when(todoRepository.existsById("t1")).thenReturn(false); + when(categoryRepository.countByIdInAndUserId(java.util.List.of("missing-cat"), 1L)) + .thenReturn(0L); + + org.assertj.core.api.Assertions.assertThatThrownBy(() -> todoService.create(1L, request)) + .isInstanceOf(com.elipair.spacestudyship.common.exception.CustomException.class) + .extracting("errorCode") + .isEqualTo(com.elipair.spacestudyship.common.exception.ErrorCode.CATEGORY_NOT_FOUND); + } +``` + +- [ ] **Step 2: 테스트 실행 — FAIL 확인** + +Run: `./gradlew :SS-Study:test --tests "*TodoServiceTest"` +Expected: FAIL with "create not defined" + +- [ ] **Step 3: TodoService.create + validateCategoryIds 구현** + +`TodoService.java`에 다음 메소드 추가 (클래스 마지막 `}` 직전): + +```java + @Transactional + public TodoResponse create(Long userId, com.elipair.spacestudyship.study.todo.dto.TodoCreateRequest request) { + String id = request.id() != null ? request.id() : java.util.UUID.randomUUID().toString(); + if (todoRepository.existsById(id)) { + throw new com.elipair.spacestudyship.common.exception.CustomException( + com.elipair.spacestudyship.common.exception.ErrorCode.TODO_ALREADY_EXISTS); + } + validateCategoryIds(userId, request.categoryIds()); + + Todo todo = Todo.create( + id, userId, request.title(), + request.scheduledDates(), + request.categoryIds(), + request.estimatedMinutes()); + Todo saved = todoRepository.save(todo); + log.info("[Todo] 생성 | userId={}, todoId={}", userId, saved.getId()); + return TodoResponse.from(saved); + } + + private void validateCategoryIds(Long userId, List categoryIds) { + if (categoryIds == null || categoryIds.isEmpty()) return; + List distinct = categoryIds.stream().distinct().toList(); + long found = categoryRepository.countByIdInAndUserId(distinct, userId); + if (found != distinct.size()) { + throw new com.elipair.spacestudyship.common.exception.CustomException( + com.elipair.spacestudyship.common.exception.ErrorCode.CATEGORY_NOT_FOUND); + } + } +``` + +- [ ] **Step 4: 테스트 실행 — PASS 확인** + +Run: `./gradlew :SS-Study:test --tests "*TodoServiceTest"` +Expected: BUILD SUCCESSFUL, 7 tests passed + +- [ ] **Step 5: 커밋** + +```bash +git add SS-Study/src/main/java/com/elipair/spacestudyship/study/todo/service/TodoService.java SS-Study/src/test/java/com/elipair/spacestudyship/study/todo/service/TodoServiceTest.java +git commit -m "할일 및 카테고리 도메인 구현 : feat : TodoService.create + 카테고리 실존 검증 #24" +``` + +--- + +## Task 13: TodoService.update (PATCH partial) + +**Files:** +- Modify: `SS-Study/src/main/java/com/elipair/spacestudyship/study/todo/service/TodoService.java` +- Modify: `SS-Study/src/test/java/com/elipair/spacestudyship/study/todo/service/TodoServiceTest.java` + +- [ ] **Step 1: 실패하는 테스트 추가 — update 4개 케이스** + +Append to `TodoServiceTest.java`: + +```java + @Test + @DisplayName("update: title만 변경, 나머지 null → 기존 유지") + void update_titleOnly() { + Todo existing = Todo.create("t1", 1L, "원본", java.util.List.of("2026-04-16"), + java.util.List.of("c1"), 60); + when(todoRepository.findByIdAndUserId("t1", 1L)) + .thenReturn(java.util.Optional.of(existing)); + + var request = new com.elipair.spacestudyship.study.todo.dto.TodoUpdateRequest( + "새 제목", null, null, null, null, null); + + var response = todoService.update(1L, "t1", request); + + assertThat(response.title()).isEqualTo("새 제목"); + assertThat(response.scheduledDates()).containsExactly("2026-04-16"); + assertThat(response.categoryIds()).containsExactly("c1"); + assertThat(response.estimatedMinutes()).isEqualTo(60); + } + + @Test + @DisplayName("update: 빈 배열은 명시적 모두 제거") + void update_emptyArrayClears() { + Todo existing = Todo.create("t1", 1L, "X", java.util.List.of("2026-04-16"), + java.util.List.of("c1"), null); + when(todoRepository.findByIdAndUserId("t1", 1L)) + .thenReturn(java.util.Optional.of(existing)); + + var request = new com.elipair.spacestudyship.study.todo.dto.TodoUpdateRequest( + null, java.util.List.of(), null, null, null, null); + + var response = todoService.update(1L, "t1", request); + + assertThat(response.scheduledDates()).isEmpty(); + assertThat(response.categoryIds()).containsExactly("c1"); + } + + @Test + @DisplayName("update: 존재하지 않는 todoId → TODO_NOT_FOUND") + void update_notFound() { + when(todoRepository.findByIdAndUserId("missing", 1L)) + .thenReturn(java.util.Optional.empty()); + + var request = new com.elipair.spacestudyship.study.todo.dto.TodoUpdateRequest( + "X", null, null, null, null, null); + + org.assertj.core.api.Assertions.assertThatThrownBy(() -> todoService.update(1L, "missing", request)) + .isInstanceOf(com.elipair.spacestudyship.common.exception.CustomException.class) + .extracting("errorCode") + .isEqualTo(com.elipair.spacestudyship.common.exception.ErrorCode.TODO_NOT_FOUND); + } + + @Test + @DisplayName("update: categoryIds 변경 시 검증") + void update_categoryIdsValidated() { + Todo existing = Todo.create("t1", 1L, "X", null, null, null); + when(todoRepository.findByIdAndUserId("t1", 1L)) + .thenReturn(java.util.Optional.of(existing)); + when(categoryRepository.countByIdInAndUserId(java.util.List.of("missing"), 1L)) + .thenReturn(0L); + + var request = new com.elipair.spacestudyship.study.todo.dto.TodoUpdateRequest( + null, null, null, java.util.List.of("missing"), null, null); + + org.assertj.core.api.Assertions.assertThatThrownBy(() -> todoService.update(1L, "t1", request)) + .isInstanceOf(com.elipair.spacestudyship.common.exception.CustomException.class) + .extracting("errorCode") + .isEqualTo(com.elipair.spacestudyship.common.exception.ErrorCode.CATEGORY_NOT_FOUND); + } +``` + +- [ ] **Step 2: 테스트 실행 — FAIL 확인** + +Run: `./gradlew :SS-Study:test --tests "*TodoServiceTest"` +Expected: FAIL with "update not defined" + +- [ ] **Step 3: TodoService.update 구현** + +Append to `TodoService.java` (클래스 마지막 `}` 직전): + +```java + @Transactional + public TodoResponse update(Long userId, String todoId, + com.elipair.spacestudyship.study.todo.dto.TodoUpdateRequest request) { + Todo todo = todoRepository.findByIdAndUserId(todoId, userId) + .orElseThrow(() -> new com.elipair.spacestudyship.common.exception.CustomException( + com.elipair.spacestudyship.common.exception.ErrorCode.TODO_NOT_FOUND)); + + if (request.categoryIds() != null) { + validateCategoryIds(userId, request.categoryIds()); + todo.updateCategoryIds(request.categoryIds()); + } + if (request.title() != null) todo.updateTitle(request.title()); + if (request.scheduledDates() != null) todo.updateScheduledDates(request.scheduledDates()); + if (request.completedDates() != null) todo.updateCompletedDates(request.completedDates()); + if (request.estimatedMinutes() != null) todo.updateEstimatedMinutes(request.estimatedMinutes()); + if (request.actualMinutes() != null) todo.updateActualMinutes(request.actualMinutes()); + + log.info("[Todo] 수정 | userId={}, todoId={}", userId, todoId); + return TodoResponse.from(todo); + } +``` + +- [ ] **Step 4: 테스트 실행 — PASS 확인** + +Run: `./gradlew :SS-Study:test --tests "*TodoServiceTest"` +Expected: BUILD SUCCESSFUL, 11 tests passed + +- [ ] **Step 5: 커밋** + +```bash +git add SS-Study/src/main/java/com/elipair/spacestudyship/study/todo/service/TodoService.java SS-Study/src/test/java/com/elipair/spacestudyship/study/todo/service/TodoServiceTest.java +git commit -m "할일 및 카테고리 도메인 구현 : feat : TodoService.update (PATCH partial) #24" +``` + +--- + +## Task 14: TodoService.delete + +**Files:** +- Modify: `SS-Study/src/main/java/com/elipair/spacestudyship/study/todo/service/TodoService.java` +- Modify: `SS-Study/src/test/java/com/elipair/spacestudyship/study/todo/service/TodoServiceTest.java` + +- [ ] **Step 1: 실패하는 테스트 추가 — delete 2개 케이스** + +Append: + +```java + @Test + @DisplayName("delete: 본인 Todo 삭제 성공") + void delete_success() { + when(todoRepository.existsByIdAndUserId("t1", 1L)).thenReturn(true); + + todoService.delete(1L, "t1"); + + verify(todoRepository).deleteById("t1"); + } + + @Test + @DisplayName("delete: 존재하지 않으면 TODO_NOT_FOUND") + void delete_notFound() { + when(todoRepository.existsByIdAndUserId("missing", 1L)).thenReturn(false); + + org.assertj.core.api.Assertions.assertThatThrownBy(() -> todoService.delete(1L, "missing")) + .isInstanceOf(com.elipair.spacestudyship.common.exception.CustomException.class) + .extracting("errorCode") + .isEqualTo(com.elipair.spacestudyship.common.exception.ErrorCode.TODO_NOT_FOUND); + } +``` + +- [ ] **Step 2: 테스트 실행 — FAIL 확인** + +Run: `./gradlew :SS-Study:test --tests "*TodoServiceTest"` +Expected: FAIL with "delete not defined" + +- [ ] **Step 3: TodoService.delete 구현** + +Append to `TodoService.java`: + +```java + @Transactional + public void delete(Long userId, String todoId) { + if (!todoRepository.existsByIdAndUserId(todoId, userId)) { + throw new com.elipair.spacestudyship.common.exception.CustomException( + com.elipair.spacestudyship.common.exception.ErrorCode.TODO_NOT_FOUND); + } + todoRepository.deleteById(todoId); + log.info("[Todo] 삭제 | userId={}, todoId={}", userId, todoId); + } +``` + +- [ ] **Step 4: 테스트 실행 — PASS 확인** + +Run: `./gradlew :SS-Study:test --tests "*TodoServiceTest"` +Expected: BUILD SUCCESSFUL, 13 tests passed + +- [ ] **Step 5: 커밋** + +```bash +git add SS-Study/src/main/java/com/elipair/spacestudyship/study/todo/service/TodoService.java SS-Study/src/test/java/com/elipair/spacestudyship/study/todo/service/TodoServiceTest.java +git commit -m "할일 및 카테고리 도메인 구현 : feat : TodoService.delete #24" +``` + +--- + +## Task 15: TodoCategoryService.findAll/create/update + +**Files:** +- Create: `SS-Study/src/main/java/com/elipair/spacestudyship/study/todo/service/TodoCategoryService.java` +- Test: `SS-Study/src/test/java/com/elipair/spacestudyship/study/todo/service/TodoCategoryServiceTest.java` + +- [ ] **Step 1: 실패하는 단위 테스트 작성** + +Create: + +```java +package com.elipair.spacestudyship.study.todo.service; + +import com.elipair.spacestudyship.common.exception.CustomException; +import com.elipair.spacestudyship.common.exception.ErrorCode; +import com.elipair.spacestudyship.study.todo.dto.CategoryCreateRequest; +import com.elipair.spacestudyship.study.todo.dto.CategoryResponse; +import com.elipair.spacestudyship.study.todo.dto.CategoryUpdateRequest; +import com.elipair.spacestudyship.study.todo.entity.TodoCategory; +import com.elipair.spacestudyship.study.todo.repository.TodoCategoryRepository; +import com.elipair.spacestudyship.study.todo.repository.TodoRepository; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.util.List; +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class TodoCategoryServiceTest { + + @Mock TodoCategoryRepository categoryRepository; + @Mock TodoRepository todoRepository; + @InjectMocks TodoCategoryService categoryService; + + @Test + @DisplayName("findAll: 사용자 카테고리 목록 반환") + void findAll() { + when(categoryRepository.findByUserIdOrderByCreatedAtAsc(1L)) + .thenReturn(List.of(TodoCategory.create("c1", 1L, "수학", null, null, null))); + + List result = categoryService.findAll(1L); + + assertThat(result).hasSize(1); + assertThat(result.get(0).name()).isEqualTo("수학"); + } + + @Test + @DisplayName("create: 서버 UUID 생성") + void create_serverId() { + var request = new CategoryCreateRequest(null, "수학", "math_icon", 0.3, 0.5); + when(categoryRepository.existsById(anyString())).thenReturn(false); + when(categoryRepository.save(any(TodoCategory.class))) + .thenAnswer(inv -> inv.getArgument(0)); + + var response = categoryService.create(1L, request); + + assertThat(response.id()).isNotBlank(); + assertThat(response.name()).isEqualTo("수학"); + } + + @Test + @DisplayName("create: 동일 ID 있으면 CATEGORY_ALREADY_EXISTS") + void create_duplicate() { + var request = new CategoryCreateRequest("c1", "수학", null, null, null); + when(categoryRepository.existsById("c1")).thenReturn(true); + + assertThatThrownBy(() -> categoryService.create(1L, request)) + .isInstanceOf(CustomException.class) + .extracting("errorCode").isEqualTo(ErrorCode.CATEGORY_ALREADY_EXISTS); + } + + @Test + @DisplayName("update: 이름 변경 + 위치 변경 + iconId 유지") + void update_partial() { + TodoCategory existing = TodoCategory.create("c1", 1L, "원본", "icon", 0.3, 0.5); + when(categoryRepository.findByIdAndUserId("c1", 1L)) + .thenReturn(Optional.of(existing)); + + var request = new CategoryUpdateRequest("새이름", null, 0.7, null); + var response = categoryService.update(1L, "c1", request); + + assertThat(response.name()).isEqualTo("새이름"); + assertThat(response.iconId()).isEqualTo("icon"); + assertThat(response.positionX()).isEqualTo(0.7); + assertThat(response.positionY()).isEqualTo(0.5); + } + + @Test + @DisplayName("update: 존재하지 않으면 CATEGORY_NOT_FOUND") + void update_notFound() { + when(categoryRepository.findByIdAndUserId("missing", 1L)) + .thenReturn(Optional.empty()); + + var request = new CategoryUpdateRequest("X", null, null, null); + + assertThatThrownBy(() -> categoryService.update(1L, "missing", request)) + .isInstanceOf(CustomException.class) + .extracting("errorCode").isEqualTo(ErrorCode.CATEGORY_NOT_FOUND); + } +} +``` + +- [ ] **Step 2: 테스트 실행 — FAIL 확인** + +Run: `./gradlew :SS-Study:test --tests "*TodoCategoryServiceTest"` +Expected: FAIL with "cannot find symbol class TodoCategoryService" + +- [ ] **Step 3: TodoCategoryService 구현 (findAll, create, update)** + +Create: + +```java +package com.elipair.spacestudyship.study.todo.service; + +import com.elipair.spacestudyship.common.exception.CustomException; +import com.elipair.spacestudyship.common.exception.ErrorCode; +import com.elipair.spacestudyship.study.todo.dto.CategoryCreateRequest; +import com.elipair.spacestudyship.study.todo.dto.CategoryResponse; +import com.elipair.spacestudyship.study.todo.dto.CategoryUpdateRequest; +import com.elipair.spacestudyship.study.todo.entity.TodoCategory; +import com.elipair.spacestudyship.study.todo.repository.TodoCategoryRepository; +import com.elipair.spacestudyship.study.todo.repository.TodoRepository; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; +import java.util.UUID; + +@Slf4j +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class TodoCategoryService { + + private final TodoCategoryRepository categoryRepository; + private final TodoRepository todoRepository; + + public List findAll(Long userId) { + return categoryRepository.findByUserIdOrderByCreatedAtAsc(userId) + .stream().map(CategoryResponse::from).toList(); + } + + @Transactional + public CategoryResponse create(Long userId, CategoryCreateRequest request) { + String id = request.id() != null ? request.id() : UUID.randomUUID().toString(); + if (categoryRepository.existsById(id)) { + throw new CustomException(ErrorCode.CATEGORY_ALREADY_EXISTS); + } + TodoCategory category = TodoCategory.create( + id, userId, request.name(), + request.iconId(), request.positionX(), request.positionY()); + TodoCategory saved = categoryRepository.save(category); + log.info("[TodoCategory] 생성 | userId={}, categoryId={}", userId, saved.getId()); + return CategoryResponse.from(saved); + } + + @Transactional + public CategoryResponse update(Long userId, String categoryId, CategoryUpdateRequest request) { + TodoCategory category = categoryRepository.findByIdAndUserId(categoryId, userId) + .orElseThrow(() -> new CustomException(ErrorCode.CATEGORY_NOT_FOUND)); + if (request.name() != null) category.updateName(request.name()); + if (request.iconId() != null) category.updateIconId(request.iconId()); + if (request.positionX() != null) category.updatePositionX(request.positionX()); + if (request.positionY() != null) category.updatePositionY(request.positionY()); + log.info("[TodoCategory] 수정 | userId={}, categoryId={}", userId, categoryId); + return CategoryResponse.from(category); + } +} +``` + +- [ ] **Step 4: 테스트 실행 — PASS 확인** + +Run: `./gradlew :SS-Study:test --tests "*TodoCategoryServiceTest"` +Expected: BUILD SUCCESSFUL, 5 tests passed + +- [ ] **Step 5: 커밋** + +```bash +git add SS-Study/src/main/java/com/elipair/spacestudyship/study/todo/service/TodoCategoryService.java SS-Study/src/test/java/com/elipair/spacestudyship/study/todo/service/TodoCategoryServiceTest.java +git commit -m "할일 및 카테고리 도메인 구현 : feat : TodoCategoryService 추가 (findAll/create/update) #24" +``` + +--- + +## Task 16: TodoCategoryService.delete (cascade Todo) + +**Files:** +- Modify: `SS-Study/src/main/java/com/elipair/spacestudyship/study/todo/service/TodoCategoryService.java` +- Modify: `SS-Study/src/test/java/com/elipair/spacestudyship/study/todo/service/TodoCategoryServiceTest.java` + +- [ ] **Step 1: 실패하는 테스트 추가** + +Append to `TodoCategoryServiceTest.java`: + +```java + @Test + @DisplayName("delete: 카테고리 삭제 + 연관 Todo의 categoryIds에서 제거") + void delete_cascadesToTodos() { + TodoCategory existing = TodoCategory.create("c1", 1L, "수학", null, null, null); + when(categoryRepository.findByIdAndUserId("c1", 1L)) + .thenReturn(Optional.of(existing)); + + com.elipair.spacestudyship.study.todo.entity.Todo t1 = + com.elipair.spacestudyship.study.todo.entity.Todo.create( + "t1", 1L, "X", null, List.of("c1", "c2"), null); + com.elipair.spacestudyship.study.todo.entity.Todo t2 = + com.elipair.spacestudyship.study.todo.entity.Todo.create( + "t2", 1L, "Y", null, List.of("c1"), null); + when(todoRepository.findByUserIdAndCategoryId(1L, "\"c1\"")) + .thenReturn(List.of(t1, t2)); + + categoryService.delete(1L, "c1"); + + // 연관 Todo categoryIds에서 c1 제거 확인 (dirty checking) + assertThat(t1.getCategoryIds()).containsExactly("c2"); + assertThat(t2.getCategoryIds()).isEmpty(); + // 카테고리 row 삭제 호출 확인 + org.mockito.Mockito.verify(categoryRepository).delete(existing); + } + + @Test + @DisplayName("delete: 존재하지 않으면 CATEGORY_NOT_FOUND") + void delete_notFound() { + when(categoryRepository.findByIdAndUserId("missing", 1L)) + .thenReturn(Optional.empty()); + + assertThatThrownBy(() -> categoryService.delete(1L, "missing")) + .isInstanceOf(CustomException.class) + .extracting("errorCode").isEqualTo(ErrorCode.CATEGORY_NOT_FOUND); + } +``` + +- [ ] **Step 2: 테스트 실행 — FAIL 확인** + +Run: `./gradlew :SS-Study:test --tests "*TodoCategoryServiceTest"` +Expected: FAIL with "delete not defined" + +- [ ] **Step 3: TodoCategoryService.delete 구현** + +Append to `TodoCategoryService.java` (클래스 마지막 `}` 직전): + +```java + @Transactional + public void delete(Long userId, String categoryId) { + TodoCategory category = categoryRepository.findByIdAndUserId(categoryId, userId) + .orElseThrow(() -> new CustomException(ErrorCode.CATEGORY_NOT_FOUND)); + + List affected = + todoRepository.findByUserIdAndCategoryId(userId, "\"" + categoryId + "\""); + affected.forEach(todo -> todo.removeCategoryId(categoryId)); + // dirty checking으로 categoryIds 변경 자동 반영 + + categoryRepository.delete(category); + log.info("[TodoCategory] 삭제 | userId={}, categoryId={}, affectedTodos={}", + userId, categoryId, affected.size()); + } +``` + +- [ ] **Step 4: 테스트 실행 — PASS 확인** + +Run: `./gradlew :SS-Study:test --tests "*TodoCategoryServiceTest"` +Expected: BUILD SUCCESSFUL, 7 tests passed + +- [ ] **Step 5: 커밋** + +```bash +git add SS-Study/src/main/java/com/elipair/spacestudyship/study/todo/service/TodoCategoryService.java SS-Study/src/test/java/com/elipair/spacestudyship/study/todo/service/TodoCategoryServiceTest.java +git commit -m "할일 및 카테고리 도메인 구현 : feat : TodoCategoryService.delete (연관 Todo categoryIds 정리) #24" +``` + +--- + +## Task 17: TodoController + Swagger + MockMvc + +**Files:** +- Create: `SS-Web/src/main/java/com/elipair/spacestudyship/controller/todo/TodoController.java` +- Test: `SS-Web/src/test/java/com/elipair/spacestudyship/controller/todo/TodoControllerTest.java` + +- [ ] **Step 1: 실패하는 MockMvc 테스트 작성 (4개 엔드포인트)** + +기존 `AuthControllerTest`의 패턴 (인증 mock, MockMvc) 확인 후 그대로 적용. Create: + +```java +package com.elipair.spacestudyship.controller.todo; + +import com.elipair.spacestudyship.auth.interceptor.LoginMember; +import com.elipair.spacestudyship.auth.interceptor.LoginMemberArgumentResolver; +import com.elipair.spacestudyship.auth.interceptor.AuthInterceptor; +import com.elipair.spacestudyship.common.exception.CustomException; +import com.elipair.spacestudyship.common.exception.ErrorCode; +import com.elipair.spacestudyship.common.exception.GlobalExceptionHandler; +import com.elipair.spacestudyship.study.todo.dto.TodoResponse; +import com.elipair.spacestudyship.study.todo.service.TodoService; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.core.MethodParameter; +import org.springframework.http.MediaType; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.setup.MockMvcBuilders; +import org.springframework.web.method.support.HandlerMethodArgumentResolver; +import org.springframework.web.method.support.ModelAndViewContainer; + +import java.util.List; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.when; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.patch; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +@ExtendWith(MockitoExtension.class) +class TodoControllerTest { + + @Mock TodoService todoService; + @InjectMocks TodoController todoController; + + MockMvc mockMvc; + ObjectMapper om = new ObjectMapper(); + + @BeforeEach + void setUp() { + // @AuthMember LoginMember 를 항상 memberId=1L 로 주입하는 stub resolver + HandlerMethodArgumentResolver loginMemberStub = new HandlerMethodArgumentResolver() { + @Override + public boolean supportsParameter(MethodParameter parameter) { + return parameter.getParameterType().equals(LoginMember.class); + } + @Override + public Object resolveArgument(MethodParameter parameter, ModelAndViewContainer mavContainer, + org.springframework.web.context.request.NativeWebRequest webRequest, + org.springframework.web.bind.support.WebDataBinderFactory binderFactory) { + return new LoginMember(1L); + } + }; + mockMvc = MockMvcBuilders.standaloneSetup(todoController) + .setControllerAdvice(new GlobalExceptionHandler()) + .setCustomArgumentResolvers(loginMemberStub) + .build(); + } + + @Test + @DisplayName("GET /api/todos — 200") + void findAll() throws Exception { + when(todoService.findAll(eq(1L), eq(null), eq(null))) + .thenReturn(List.of(new TodoResponse("t1", "수학", + List.of(), List.of(), List.of(), null, null, + "2026-05-23T00:00:00Z", "2026-05-23T00:00:00Z"))); + + mockMvc.perform(get("/api/todos")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$[0].id").value("t1")); + } + + @Test + @DisplayName("POST /api/todos — 201") + void create() throws Exception { + when(todoService.create(eq(1L), any())) + .thenReturn(new TodoResponse("t1", "수학", + List.of(), List.of(), List.of(), null, null, + "2026-05-23T00:00:00Z", "2026-05-23T00:00:00Z")); + + String body = """ + {"id":"t1","title":"수학","categoryIds":[],"scheduledDates":[]} + """; + + mockMvc.perform(post("/api/todos") + .contentType(MediaType.APPLICATION_JSON).content(body)) + .andExpect(status().isCreated()) + .andExpect(jsonPath("$.id").value("t1")); + } + + @Test + @DisplayName("PATCH /api/todos/{id} — 404 TODO_NOT_FOUND") + void update_notFound() throws Exception { + when(todoService.update(eq(1L), eq("missing"), any())) + .thenThrow(new CustomException(ErrorCode.TODO_NOT_FOUND)); + + mockMvc.perform(patch("/api/todos/missing") + .contentType(MediaType.APPLICATION_JSON).content("{}")) + .andExpect(status().isNotFound()) + .andExpect(jsonPath("$.code").value("TODO_NOT_FOUND")); + } + + @Test + @DisplayName("DELETE /api/todos/{id} — 204") + void delete_success() throws Exception { + mockMvc.perform(delete("/api/todos/t1")) + .andExpect(status().isNoContent()); + } +} +``` + +- [ ] **Step 2: 테스트 실행 — FAIL 확인** + +Run: `./gradlew :SS-Web:test --tests "*TodoControllerTest"` +Expected: FAIL with "cannot find symbol class TodoController" + +- [ ] **Step 3: TodoController 구현 (Swagger 풀세트)** + +Create `SS-Web/src/main/java/com/elipair/spacestudyship/controller/todo/TodoController.java`: + +```java +package com.elipair.spacestudyship.controller.todo; + +import com.elipair.spacestudyship.auth.interceptor.AuthMember; +import com.elipair.spacestudyship.auth.interceptor.LoginMember; +import com.elipair.spacestudyship.common.exception.ErrorResponse; +import com.elipair.spacestudyship.study.todo.dto.TodoCreateRequest; +import com.elipair.spacestudyship.study.todo.dto.TodoResponse; +import com.elipair.spacestudyship.study.todo.dto.TodoUpdateRequest; +import com.elipair.spacestudyship.study.todo.service.TodoService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.ExampleObject; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.responses.ApiResponses; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; +import jakarta.validation.constraints.Pattern; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.*; + +import java.util.List; + +@Tag(name = "Todo", description = "할 일 CRUD API") +@RestController +@RequestMapping("/api/todos") +@RequiredArgsConstructor +@Validated // PathVariable/RequestParam의 @Pattern, @Min 등 검증 활성화 +public class TodoController { + + private final TodoService todoService; + + @Operation(summary = "할 일 목록 조회", + description = "선택적으로 date / categoryId 쿼리로 필터. 결과는 createdAt 내림차순.") + @ApiResponses({ + @ApiResponse(responseCode = "200", description = "조회 성공", + content = @Content(mediaType = "application/json", + array = @io.swagger.v3.oas.annotations.media.ArraySchema(schema = @Schema(implementation = TodoResponse.class)))), + @ApiResponse(responseCode = "401", description = "인증 실패", + content = @Content(schema = @Schema(implementation = ErrorResponse.class), + examples = @ExampleObject(value = "{\"code\":\"UNAUTHENTICATED_REQUEST\",\"message\":\"로그인이 필요합니다.\"}"))) + }) + @GetMapping + public ResponseEntity> findAll( + @AuthMember LoginMember loginMember, + @RequestParam(required = false) + @Pattern(regexp = "\\d{4}-\\d{2}-\\d{2}", message = "date: YYYY-MM-DD 형식이어야 합니다.") + String date, + @RequestParam(required = false) + @Pattern(regexp = "[a-zA-Z0-9-]+", message = "categoryId: 영숫자와 하이픈만 허용합니다.") + String categoryId) { + return ResponseEntity.ok(todoService.findAll(loginMember.memberId(), date, categoryId)); + } + + @Operation(summary = "할 일 생성", + description = """ + 새 할 일을 생성합니다. id 미지정 시 서버가 UUID v4 생성. + + ### 동작 + 1. id 충돌 검사 → 충돌 시 409 TODO_ALREADY_EXISTS + 2. categoryIds 실존 검증 → 누락 시 404 CATEGORY_NOT_FOUND + 3. 저장 후 생성된 객체 반환 + """) + @ApiResponses({ + @ApiResponse(responseCode = "201", description = "생성 성공", + content = @Content(schema = @Schema(implementation = TodoResponse.class))), + @ApiResponse(responseCode = "400", description = "입력값 검증 실패", + content = @Content(schema = @Schema(implementation = ErrorResponse.class), + examples = @ExampleObject(value = "{\"code\":\"INVALID_INPUT_VALUE\",\"message\":\"title: 비어있을 수 없습니다.\"}"))), + @ApiResponse(responseCode = "401", description = "인증 실패", + content = @Content(schema = @Schema(implementation = ErrorResponse.class), + examples = @ExampleObject(value = "{\"code\":\"UNAUTHENTICATED_REQUEST\",\"message\":\"로그인이 필요합니다.\"}"))), + @ApiResponse(responseCode = "404", description = "카테고리 없음", + content = @Content(schema = @Schema(implementation = ErrorResponse.class), + examples = @ExampleObject(value = "{\"code\":\"CATEGORY_NOT_FOUND\",\"message\":\"해당 카테고리를 찾을 수 없습니다.\"}"))), + @ApiResponse(responseCode = "409", description = "동일 ID 중복", + content = @Content(schema = @Schema(implementation = ErrorResponse.class), + examples = @ExampleObject(value = "{\"code\":\"TODO_ALREADY_EXISTS\",\"message\":\"동일 ID의 할 일이 이미 존재합니다.\"}"))), + @ApiResponse(responseCode = "500", description = "서버 내부 오류", + content = @Content(schema = @Schema(implementation = ErrorResponse.class), + examples = @ExampleObject(value = "{\"code\":\"INTERNAL_SERVER_ERROR\",\"message\":\"서버 내부 오류가 발생했습니다.\"}"))) + }) + @PostMapping + public ResponseEntity create( + @AuthMember LoginMember loginMember, + @RequestBody @Valid TodoCreateRequest request) { + return ResponseEntity.status(HttpStatus.CREATED) + .body(todoService.create(loginMember.memberId(), request)); + } + + @Operation(summary = "할 일 부분 수정", + description = "전송하지 않은 필드는 기존 값 유지. 빈 배열은 명시적 모두 제거.") + @ApiResponses({ + @ApiResponse(responseCode = "200", description = "수정 성공", + content = @Content(schema = @Schema(implementation = TodoResponse.class))), + @ApiResponse(responseCode = "400", description = "입력값 검증 실패", + content = @Content(schema = @Schema(implementation = ErrorResponse.class), + examples = @ExampleObject(value = "{\"code\":\"INVALID_INPUT_VALUE\",\"message\":\"...\"}"))), + @ApiResponse(responseCode = "401", description = "인증 실패", + content = @Content(schema = @Schema(implementation = ErrorResponse.class), + examples = @ExampleObject(value = "{\"code\":\"UNAUTHENTICATED_REQUEST\",\"message\":\"로그인이 필요합니다.\"}"))), + @ApiResponse(responseCode = "404", description = "Todo 없음 또는 다른 사용자 소유", + content = @Content(schema = @Schema(implementation = ErrorResponse.class), + examples = @ExampleObject(value = "{\"code\":\"TODO_NOT_FOUND\",\"message\":\"해당 할 일을 찾을 수 없습니다.\"}"))), + @ApiResponse(responseCode = "500", description = "서버 내부 오류", + content = @Content(schema = @Schema(implementation = ErrorResponse.class), + examples = @ExampleObject(value = "{\"code\":\"INTERNAL_SERVER_ERROR\",\"message\":\"서버 내부 오류가 발생했습니다.\"}"))) + }) + @PatchMapping("/{todoId}") + public ResponseEntity update( + @AuthMember LoginMember loginMember, + @org.springframework.web.bind.annotation.PathVariable + @Pattern(regexp = "[a-zA-Z0-9-]+", message = "todoId: 영숫자와 하이픈만 허용합니다.") + String todoId, + @RequestBody @Valid TodoUpdateRequest request) { + return ResponseEntity.ok(todoService.update(loginMember.memberId(), todoId, request)); + } + + @Operation(summary = "할 일 삭제", description = "본인 소유 Todo만 삭제 가능. 다른 사용자 / 없는 Todo는 404.") + @ApiResponses({ + @ApiResponse(responseCode = "204", description = "삭제 성공", content = @Content), + @ApiResponse(responseCode = "401", description = "인증 실패", + content = @Content(schema = @Schema(implementation = ErrorResponse.class), + examples = @ExampleObject(value = "{\"code\":\"UNAUTHENTICATED_REQUEST\",\"message\":\"로그인이 필요합니다.\"}"))), + @ApiResponse(responseCode = "404", description = "Todo 없음", + content = @Content(schema = @Schema(implementation = ErrorResponse.class), + examples = @ExampleObject(value = "{\"code\":\"TODO_NOT_FOUND\",\"message\":\"해당 할 일을 찾을 수 없습니다.\"}"))), + @ApiResponse(responseCode = "500", description = "서버 내부 오류", + content = @Content(schema = @Schema(implementation = ErrorResponse.class), + examples = @ExampleObject(value = "{\"code\":\"INTERNAL_SERVER_ERROR\",\"message\":\"서버 내부 오류가 발생했습니다.\"}"))) + }) + @DeleteMapping("/{todoId}") + public ResponseEntity delete( + @AuthMember LoginMember loginMember, + @org.springframework.web.bind.annotation.PathVariable + @Pattern(regexp = "[a-zA-Z0-9-]+", message = "todoId: 영숫자와 하이픈만 허용합니다.") + String todoId) { + todoService.delete(loginMember.memberId(), todoId); + return ResponseEntity.noContent().build(); + } +} +``` + +- [ ] **Step 4: 테스트 실행 — PASS 확인** + +Run: `./gradlew :SS-Web:test --tests "*TodoControllerTest"` +Expected: BUILD SUCCESSFUL, 4 tests passed + +- [ ] **Step 5: 커밋** + +```bash +git add SS-Web/src/main/java/com/elipair/spacestudyship/controller/todo/TodoController.java SS-Web/src/test/java/com/elipair/spacestudyship/controller/todo/TodoControllerTest.java +git commit -m "할일 및 카테고리 도메인 구현 : feat : TodoController 추가 (Swagger 풀세트) #24" +``` + +--- + +## Task 18: TodoCategoryController + Swagger + MockMvc + +**Files:** +- Create: `SS-Web/src/main/java/com/elipair/spacestudyship/controller/todo/TodoCategoryController.java` +- Test: `SS-Web/src/test/java/com/elipair/spacestudyship/controller/todo/TodoCategoryControllerTest.java` + +- [ ] **Step 1: 실패하는 MockMvc 테스트 작성** + +Create: + +```java +package com.elipair.spacestudyship.controller.todo; + +import com.elipair.spacestudyship.auth.interceptor.LoginMember; +import com.elipair.spacestudyship.common.exception.CustomException; +import com.elipair.spacestudyship.common.exception.ErrorCode; +import com.elipair.spacestudyship.common.exception.GlobalExceptionHandler; +import com.elipair.spacestudyship.study.todo.dto.CategoryResponse; +import com.elipair.spacestudyship.study.todo.service.TodoCategoryService; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.core.MethodParameter; +import org.springframework.http.MediaType; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.setup.MockMvcBuilders; +import org.springframework.web.method.support.HandlerMethodArgumentResolver; +import org.springframework.web.method.support.ModelAndViewContainer; + +import java.util.List; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.when; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; + +@ExtendWith(MockitoExtension.class) +class TodoCategoryControllerTest { + + @Mock TodoCategoryService categoryService; + @InjectMocks TodoCategoryController categoryController; + + MockMvc mockMvc; + ObjectMapper om = new ObjectMapper(); + + @BeforeEach + void setUp() { + HandlerMethodArgumentResolver stub = new HandlerMethodArgumentResolver() { + @Override public boolean supportsParameter(MethodParameter p) { + return p.getParameterType().equals(LoginMember.class); + } + @Override public Object resolveArgument(MethodParameter p, + ModelAndViewContainer m, + org.springframework.web.context.request.NativeWebRequest w, + org.springframework.web.bind.support.WebDataBinderFactory f) { + return new LoginMember(1L); + } + }; + mockMvc = MockMvcBuilders.standaloneSetup(categoryController) + .setControllerAdvice(new GlobalExceptionHandler()) + .setCustomArgumentResolvers(stub) + .build(); + } + + @Test + @DisplayName("GET /api/todo-categories — 200") + void findAll() throws Exception { + when(categoryService.findAll(1L)).thenReturn(List.of( + new CategoryResponse("c1", "수학", "math", 0.3, 0.5, + "2026-05-23T00:00:00Z", null))); + + mockMvc.perform(get("/api/todo-categories")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$[0].id").value("c1")); + } + + @Test + @DisplayName("POST /api/todo-categories — 201") + void create() throws Exception { + when(categoryService.create(eq(1L), any())) + .thenReturn(new CategoryResponse("c1", "수학", null, null, null, + "2026-05-23T00:00:00Z", null)); + + String body = "{\"id\":\"c1\",\"name\":\"수학\"}"; + + mockMvc.perform(post("/api/todo-categories") + .contentType(MediaType.APPLICATION_JSON).content(body)) + .andExpect(status().isCreated()) + .andExpect(jsonPath("$.id").value("c1")); + } + + @Test + @DisplayName("PATCH /api/todo-categories/{id} — 404 CATEGORY_NOT_FOUND") + void update_notFound() throws Exception { + when(categoryService.update(eq(1L), eq("missing"), any())) + .thenThrow(new CustomException(ErrorCode.CATEGORY_NOT_FOUND)); + + mockMvc.perform(patch("/api/todo-categories/missing") + .contentType(MediaType.APPLICATION_JSON).content("{}")) + .andExpect(status().isNotFound()) + .andExpect(jsonPath("$.code").value("CATEGORY_NOT_FOUND")); + } + + @Test + @DisplayName("DELETE /api/todo-categories/{id} — 204") + void delete_success() throws Exception { + mockMvc.perform(delete("/api/todo-categories/c1")) + .andExpect(status().isNoContent()); + } +} +``` + +- [ ] **Step 2: 테스트 실행 — FAIL 확인** + +Run: `./gradlew :SS-Web:test --tests "*TodoCategoryControllerTest"` +Expected: FAIL with "cannot find symbol class TodoCategoryController" + +- [ ] **Step 3: TodoCategoryController 구현 (Swagger 풀세트)** + +Create: + +```java +package com.elipair.spacestudyship.controller.todo; + +import com.elipair.spacestudyship.auth.interceptor.AuthMember; +import com.elipair.spacestudyship.auth.interceptor.LoginMember; +import com.elipair.spacestudyship.common.exception.ErrorResponse; +import com.elipair.spacestudyship.study.todo.dto.CategoryCreateRequest; +import com.elipair.spacestudyship.study.todo.dto.CategoryResponse; +import com.elipair.spacestudyship.study.todo.dto.CategoryUpdateRequest; +import com.elipair.spacestudyship.study.todo.service.TodoCategoryService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.media.ArraySchema; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.ExampleObject; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.responses.ApiResponses; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; +import jakarta.validation.constraints.Pattern; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.*; + +import java.util.List; + +@Tag(name = "TodoCategory", description = "할 일 카테고리 CRUD API") +@RestController +@RequestMapping("/api/todo-categories") +@RequiredArgsConstructor +@Validated // PathVariable의 @Pattern 검증 활성화 +public class TodoCategoryController { + + private final TodoCategoryService categoryService; + + @Operation(summary = "카테고리 목록 조회", description = "createdAt 오름차순") + @ApiResponses({ + @ApiResponse(responseCode = "200", description = "조회 성공", + content = @Content(array = @ArraySchema(schema = @Schema(implementation = CategoryResponse.class)))), + @ApiResponse(responseCode = "401", description = "인증 실패", + content = @Content(schema = @Schema(implementation = ErrorResponse.class), + examples = @ExampleObject(value = "{\"code\":\"UNAUTHENTICATED_REQUEST\",\"message\":\"로그인이 필요합니다.\"}"))) + }) + @GetMapping + public ResponseEntity> findAll(@AuthMember LoginMember loginMember) { + return ResponseEntity.ok(categoryService.findAll(loginMember.memberId())); + } + + @Operation(summary = "카테고리 생성") + @ApiResponses({ + @ApiResponse(responseCode = "201", description = "생성 성공", + content = @Content(schema = @Schema(implementation = CategoryResponse.class))), + @ApiResponse(responseCode = "400", description = "입력값 검증 실패", + content = @Content(schema = @Schema(implementation = ErrorResponse.class), + examples = @ExampleObject(value = "{\"code\":\"INVALID_INPUT_VALUE\",\"message\":\"name: 비어있을 수 없습니다.\"}"))), + @ApiResponse(responseCode = "401", description = "인증 실패", + content = @Content(schema = @Schema(implementation = ErrorResponse.class), + examples = @ExampleObject(value = "{\"code\":\"UNAUTHENTICATED_REQUEST\",\"message\":\"로그인이 필요합니다.\"}"))), + @ApiResponse(responseCode = "409", description = "동일 ID 중복", + content = @Content(schema = @Schema(implementation = ErrorResponse.class), + examples = @ExampleObject(value = "{\"code\":\"CATEGORY_ALREADY_EXISTS\",\"message\":\"동일 ID의 카테고리가 이미 존재합니다.\"}"))), + @ApiResponse(responseCode = "500", description = "서버 내부 오류", + content = @Content(schema = @Schema(implementation = ErrorResponse.class), + examples = @ExampleObject(value = "{\"code\":\"INTERNAL_SERVER_ERROR\",\"message\":\"서버 내부 오류가 발생했습니다.\"}"))) + }) + @PostMapping + public ResponseEntity create( + @AuthMember LoginMember loginMember, + @RequestBody @Valid CategoryCreateRequest request) { + return ResponseEntity.status(HttpStatus.CREATED) + .body(categoryService.create(loginMember.memberId(), request)); + } + + @Operation(summary = "카테고리 부분 수정") + @ApiResponses({ + @ApiResponse(responseCode = "200", description = "수정 성공", + content = @Content(schema = @Schema(implementation = CategoryResponse.class))), + @ApiResponse(responseCode = "400", description = "입력값 검증 실패", + content = @Content(schema = @Schema(implementation = ErrorResponse.class), + examples = @ExampleObject(value = "{\"code\":\"INVALID_INPUT_VALUE\",\"message\":\"...\"}"))), + @ApiResponse(responseCode = "401", description = "인증 실패", + content = @Content(schema = @Schema(implementation = ErrorResponse.class), + examples = @ExampleObject(value = "{\"code\":\"UNAUTHENTICATED_REQUEST\",\"message\":\"로그인이 필요합니다.\"}"))), + @ApiResponse(responseCode = "404", description = "카테고리 없음", + content = @Content(schema = @Schema(implementation = ErrorResponse.class), + examples = @ExampleObject(value = "{\"code\":\"CATEGORY_NOT_FOUND\",\"message\":\"해당 카테고리를 찾을 수 없습니다.\"}"))), + @ApiResponse(responseCode = "500", description = "서버 내부 오류", + content = @Content(schema = @Schema(implementation = ErrorResponse.class), + examples = @ExampleObject(value = "{\"code\":\"INTERNAL_SERVER_ERROR\",\"message\":\"서버 내부 오류가 발생했습니다.\"}"))) + }) + @PatchMapping("/{categoryId}") + public ResponseEntity update( + @AuthMember LoginMember loginMember, + @PathVariable + @Pattern(regexp = "[a-zA-Z0-9-]+", message = "categoryId: 영숫자와 하이픈만 허용합니다.") + String categoryId, + @RequestBody @Valid CategoryUpdateRequest request) { + return ResponseEntity.ok(categoryService.update(loginMember.memberId(), categoryId, request)); + } + + @Operation(summary = "카테고리 삭제", + description = "삭제 시 연관 Todo의 categoryIds에서 자동 제거됩니다.") + @ApiResponses({ + @ApiResponse(responseCode = "204", description = "삭제 성공", content = @Content), + @ApiResponse(responseCode = "401", description = "인증 실패", + content = @Content(schema = @Schema(implementation = ErrorResponse.class), + examples = @ExampleObject(value = "{\"code\":\"UNAUTHENTICATED_REQUEST\",\"message\":\"로그인이 필요합니다.\"}"))), + @ApiResponse(responseCode = "404", description = "카테고리 없음", + content = @Content(schema = @Schema(implementation = ErrorResponse.class), + examples = @ExampleObject(value = "{\"code\":\"CATEGORY_NOT_FOUND\",\"message\":\"해당 카테고리를 찾을 수 없습니다.\"}"))), + @ApiResponse(responseCode = "500", description = "서버 내부 오류", + content = @Content(schema = @Schema(implementation = ErrorResponse.class), + examples = @ExampleObject(value = "{\"code\":\"INTERNAL_SERVER_ERROR\",\"message\":\"서버 내부 오류가 발생했습니다.\"}"))) + }) + @DeleteMapping("/{categoryId}") + public ResponseEntity delete( + @AuthMember LoginMember loginMember, + @PathVariable + @Pattern(regexp = "[a-zA-Z0-9-]+", message = "categoryId: 영숫자와 하이픈만 허용합니다.") + String categoryId) { + categoryService.delete(loginMember.memberId(), categoryId); + return ResponseEntity.noContent().build(); + } +} +``` + +- [ ] **Step 4: 테스트 실행 — PASS 확인** + +Run: `./gradlew :SS-Web:test --tests "*TodoCategoryControllerTest"` +Expected: BUILD SUCCESSFUL, 4 tests passed + +- [ ] **Step 5: 커밋** + +```bash +git add SS-Web/src/main/java/com/elipair/spacestudyship/controller/todo/TodoCategoryController.java SS-Web/src/test/java/com/elipair/spacestudyship/controller/todo/TodoCategoryControllerTest.java +git commit -m "할일 및 카테고리 도메인 구현 : feat : TodoCategoryController 추가 (Swagger 풀세트) #24" +``` + +--- + +## Task 19: CLAUDE.md 이력 업데이트 + 최종 검증 + +**Files:** +- Modify: `CLAUDE.md` + +- [ ] **Step 1: CLAUDE.md 마이그레이션 이력 표 업데이트** + +CLAUDE.md의 "현재 마이그레이션 이력" 표에 새 행 추가: + +```markdown +### 현재 마이그레이션 이력 + +| 버전 | 파일 | 내용 | +|------|------|------| +| 0.0.31 | `V0_0_31__add_user_devices.sql` | 초기 스키마 — `members`, `user_devices` 테이블 생성 (FK 포함) | +| 0.0.34 | `V0_0_34__add_todos_and_categories.sql` | `todos`, `todo_categories` 테이블 생성 (FK CASCADE, JSONB 컬럼) | +``` + +- [ ] **Step 2: 전체 빌드 + 전체 테스트 실행** + +Run: `./gradlew clean build` +Expected: BUILD SUCCESSFUL. 모든 모듈 테스트 통과. + +만약 실패하면: +- Docker 데몬 실행 중인지 확인 (Testcontainers 필요) +- 컴파일 에러 확인 후 해당 task로 돌아가 수정 +- 다른 모듈의 테스트가 회귀했는지 확인 + +- [ ] **Step 3: 운영 환경 가정 — Application 컨텍스트 로딩 확인** + +Run: `./gradlew :SS-Web:bootJarMainClassName` (jar 메인 클래스 검증) 또는 단순 컴파일: +`./gradlew :SS-Web:compileJava` +Expected: SUCCESS. Controller가 Application 컨텍스트에 인식되는지 확인. + +- [ ] **Step 4: Swagger UI 수동 검증 (선택)** + +로컬에서 Spring Boot 실행 후 `http://localhost:8080/swagger-ui.html` 접속하여: +- `Todo` 태그 그룹에 4개 엔드포인트 존재 +- `TodoCategory` 태그 그룹에 4개 엔드포인트 존재 +- 각 엔드포인트의 응답 예시(200/201/204/400/401/404/409/500) 표시됨 +- DTO 스키마 (TodoResponse, CategoryResponse 등) 표시됨 + +Docker / DB 셋업 불가하면 이 step은 건너뜀. + +- [ ] **Step 5: 최종 커밋** + +```bash +git add CLAUDE.md +git commit -m "할일 및 카테고리 도메인 구현 : docs : CLAUDE.md 마이그레이션 이력 업데이트 #24" +``` + +--- + +## Self-Review Notes + +**Spec coverage 검증:** + +| Spec 섹션 | Task | +|-----------|------| +| 1. 개요 & 범위 | 전체 | +| 2. 모듈/패키지 구조 | Task 3~18 | +| 3. Entity 설계 (Todo) | Task 8 | +| 3. Entity 설계 (TodoCategory) | Task 5 | +| 4. DTO 설계 (Todo DTO) | Task 10 | +| 4. DTO 설계 (Category DTO) | Task 7 | +| 4.3 PATCH null vs 빈 배열 규약 | Task 13 (Todo), Task 15 (Category) | +| 5. Repository (Todo, jsonb @>) | Task 9 | +| 5. Repository (Category) | Task 6 | +| 6.1 TodoService 핵심 로직 | Task 11~14 | +| 6.2 TodoCategoryService 핵심 로직 | Task 15~16 | +| 7. Controller + Swagger (Todo) | Task 17 | +| 7. Controller + Swagger (Category) | Task 18 | +| 8. ErrorCode 추가 | Task 1 | +| 9. Flyway 마이그레이션 + version.yml | Task 2 | +| 10. 테스트 전략 | Task 3~4 셋업, 5~18 각 단계 TDD | +| 11. 셀프 리뷰 체크리스트 | Task 17~19 (수동 확인) | +| 12. 작업 산출물 요약 | Task 19 종합 | + +**검토 결과:** 모든 spec 항목에 대응 task 존재. Type 일관성 — `TodoResponse.from(Todo)`, `CategoryResponse.from(TodoCategory)`, `Todo.removeCategoryId(String)`, `LoginMember.memberId()` 등 task 간 식별자 일관성 유지. + +**리스크:** +- **Testcontainers Docker 의존성**: Task 4, 6, 9 실행 시 Docker 데몬 필요. CI 환경에서는 별도 setup 필요할 수 있음. +- **Hibernate 6 JSONB 매핑**: 운영 PostgreSQL과 테스트 Testcontainers의 PostgreSQL 버전 호환성 (Postgres 16 사용). +- **PathVariable의 `@Pattern`**: Spring 환경에서 `@Valid`가 없으면 PathVariable validation이 동작하지 않음 → `@Validated` 어노테이션을 Controller 클래스에 추가 필요. 구현 시 이 점 추가 확인 (만약 Task 17/18 실행 시 path validation이 동작 안 하면 Controller 클래스에 `@org.springframework.validation.annotation.Validated` 추가). diff --git a/docs/superpowers/plans/2026-05-25-timer-session-domain.md b/docs/superpowers/plans/2026-05-25-timer-session-domain.md new file mode 100644 index 0000000..feda5aa --- /dev/null +++ b/docs/superpowers/plans/2026-05-25-timer-session-domain.md @@ -0,0 +1,2038 @@ +# Timer Session Domain Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Implement the 3 endpoints of `/api/timer-sessions` (POST 저장, GET 목록, GET today-stats) with single-transaction integration to Fuel charging and Todo `actualMinutes` accumulation. + +**Architecture:** New `study/timer/` package in SS-Study mirrors the Fuel domain structure. `TimerSessionService` orchestrates session save → `FuelService.charge()` → `TodoService.addActualMinutes()` in one transaction, using `sessionId` as the Fuel `transactionId` for natural idempotency. Optional `Idempotency-Key` HTTP header provides a second layer of dedup via a partial unique index on `(user_id, idempotency_key)`. Streak computation runs in-memory over the last 365 days, with day boundaries in `Asia/Seoul` while DB stays UTC. + +**Tech Stack:** Spring Boot 4.0.2, Java 21, Gradle multi-module (`SS-Study`, `SS-Web`, `SS-Common`), JPA + Hibernate, PostgreSQL 16 (Testcontainers in tests), Flyway, JUnit 5 + Mockito + AssertJ, springdoc-openapi (Swagger), Lombok. + +**Spec:** [docs/superpowers/specs/2026-05-25-timer-session-domain-design.md](../specs/2026-05-25-timer-session-domain-design.md) +**Issue:** #25 +**Branch:** `20260422_#25_타이머_세션_도메인_구현` +**Commit message format:** `타이머 세션 도메인 구현 : {type} : {설명} #25` + +--- + +## File Map + +### Create +| Path | Responsibility | +|------|----------------| +| `SS-Web/src/main/resources/db/migration/V0_0_39__add_timer_sessions.sql` | DDL for `timer_sessions` + indexes + partial unique on idempotency_key | +| `SS-Web/src/main/java/com/elipair/spacestudyship/config/BeanConfig.java` | Spring `Clock` bean (`Clock.systemUTC()`) | +| `SS-Study/src/main/java/com/elipair/spacestudyship/study/timer/entity/TimerSession.java` | JPA entity with `@Check` constraints | +| `SS-Study/src/main/java/com/elipair/spacestudyship/study/timer/repository/TimerSessionRepository.java` | JPA repository with filter, sum, count, distinct-day queries | +| `SS-Study/src/main/java/com/elipair/spacestudyship/study/timer/dto/TimerSessionCreateRequest.java` | POST 요청 body | +| `SS-Study/src/main/java/com/elipair/spacestudyship/study/timer/dto/TimerSessionResponse.java` | 세션 단건 응답 record | +| `SS-Study/src/main/java/com/elipair/spacestudyship/study/timer/dto/TimerSessionCreateResponse.java` | POST 응답 `{ session, fuelCharged }` | +| `SS-Study/src/main/java/com/elipair/spacestudyship/study/timer/dto/TimerSessionListResponse.java` | 목록 + 페이지 envelope | +| `SS-Study/src/main/java/com/elipair/spacestudyship/study/timer/dto/TodayStatsResponse.java` | 오늘 통계 응답 | +| `SS-Study/src/main/java/com/elipair/spacestudyship/study/timer/service/TimerSessionService.java` | 검증 + 저장 + Fuel/Todo 통합 + 통계 + streak | +| `SS-Web/src/main/java/com/elipair/spacestudyship/controller/timer/TimerSessionController.java` | 3개 엔드포인트, Swagger 풀세트 | +| `SS-Study/src/test/java/com/elipair/spacestudyship/study/timer/entity/TimerSessionTest.java` | Entity static factory 동작 | +| `SS-Study/src/test/java/com/elipair/spacestudyship/study/timer/repository/TimerSessionRepositoryTest.java` | Repository 쿼리 + 부분 unique 인덱스 | +| `SS-Study/src/test/java/com/elipair/spacestudyship/study/timer/service/TimerSessionServiceTest.java` | 검증 5케이스 + create + list + today-stats + streak | +| `SS-Web/src/test/java/com/elipair/spacestudyship/controller/timer/TimerSessionControllerTest.java` | MockMvc 정상/에러 경로 | + +### Modify +| Path | Change | +|------|--------| +| `version.yml` | `version: "0.0.38"` → `"0.0.39"`, `version_code: 38` → `39`, `last_updated` | +| `SS-Common/src/main/java/com/elipair/spacestudyship/common/exception/ErrorCode.java` | 5개 enum 추가 | +| `SS-Study/src/main/java/com/elipair/spacestudyship/study/todo/repository/TodoRepository.java` | `@Modifying @Query addActualMinutes` 추가 | +| `SS-Study/src/main/java/com/elipair/spacestudyship/study/todo/service/TodoService.java` | `addActualMinutes(userId, todoId, minutes)` 메서드 추가 | +| `SS-Study/src/test/java/com/elipair/spacestudyship/study/StudyTestApplication.java` | `@EnableJpaRepositories` 패키지에 `study.timer.repository` 추가 | +| `SS-Study/src/test/java/com/elipair/spacestudyship/study/todo/service/TodoServiceTest.java` | `addActualMinutes` 회귀 테스트 추가 | +| `SS-Study/src/test/java/com/elipair/spacestudyship/study/todo/repository/TodoRepositoryTest.java` | `addActualMinutes` 쿼리 회귀 테스트 추가 | +| `CLAUDE.md` | 마이그레이션 이력 표에 0.0.39 추가 | + +--- + +## Task 1: 사전 작업 (chore) + +version bump, Flyway 마이그레이션, ErrorCode 추가, Clock 빈 등록. + +**Files:** +- Modify: `version.yml` +- Create: `SS-Web/src/main/resources/db/migration/V0_0_39__add_timer_sessions.sql` +- Modify: `SS-Common/src/main/java/com/elipair/spacestudyship/common/exception/ErrorCode.java` +- Create: `SS-Web/src/main/java/com/elipair/spacestudyship/config/BeanConfig.java` + +### Steps + +- [ ] **Step 1.1: version.yml bump (0.0.38 → 0.0.39)** + +`version.yml` 의 다음 두 줄과 `last_updated`를 변경: +```yaml +version: "0.0.39" +version_code: 39 +``` +`metadata.last_updated`는 오늘 날짜 + 시각으로 갱신 (`2026-05-25 HH:MM:SS`). + +- [ ] **Step 1.2: V0_0_39 마이그레이션 SQL 작성** + +`SS-Web/src/main/resources/db/migration/V0_0_39__add_timer_sessions.sql`: +```sql +-- timer_sessions: 공부 타이머 세션 기록 +CREATE TABLE IF NOT EXISTS timer_sessions ( + id VARCHAR(36) PRIMARY KEY, + user_id BIGINT NOT NULL, + todo_id VARCHAR(36), + todo_title VARCHAR(100), + started_at TIMESTAMP NOT NULL, + ended_at TIMESTAMP NOT NULL, + duration_minutes INTEGER NOT NULL, + idempotency_key VARCHAR(80), + created_at TIMESTAMP NOT NULL, + updated_at TIMESTAMP NOT NULL, + CONSTRAINT fk_timer_sessions_member FOREIGN KEY (user_id) + REFERENCES members(id) ON DELETE CASCADE, + CONSTRAINT chk_timer_duration_positive CHECK (duration_minutes > 0), + CONSTRAINT chk_timer_duration_max CHECK (duration_minutes <= 1440), + CONSTRAINT chk_timer_time_order CHECK (ended_at > started_at) +); + +CREATE INDEX IF NOT EXISTS idx_timer_sessions_user_started + ON timer_sessions (user_id, started_at DESC); + +CREATE INDEX IF NOT EXISTS idx_timer_sessions_user_todo + ON timer_sessions (user_id, todo_id); + +-- Idempotency: 동일 (user, key) 중복 INSERT 방지. key=NULL은 다중 허용 (부분 unique) +CREATE UNIQUE INDEX IF NOT EXISTS uq_timer_sessions_user_idem + ON timer_sessions (user_id, idempotency_key) + WHERE idempotency_key IS NOT NULL; +``` + +- [ ] **Step 1.3: ErrorCode 5개 추가** + +`SS-Common/src/main/java/com/elipair/spacestudyship/common/exception/ErrorCode.java` 의 `// Fuel` 블록과 `// Common` 블록 **사이**에 다음을 삽입: +```java + // Timer + INVALID_SESSION_TIME(HttpStatus.BAD_REQUEST, "시작 시각이 종료 시각보다 늦거나 같습니다."), + INVALID_DURATION(HttpStatus.BAD_REQUEST, "공부 시간이 시작/종료 시각 간격보다 큽니다."), + SESSION_TOO_SHORT(HttpStatus.BAD_REQUEST, "공부 시간은 1분 이상이어야 합니다."), + SESSION_TOO_LONG(HttpStatus.BAD_REQUEST, "공부 시간은 24시간(1440분)을 초과할 수 없습니다."), + FUTURE_SESSION(HttpStatus.BAD_REQUEST, "미래 시각의 세션은 저장할 수 없습니다."), +``` + +- [ ] **Step 1.4: Clock 빈 등록** + +`SS-Web/src/main/java/com/elipair/spacestudyship/config/BeanConfig.java` (신규): +```java +package com.elipair.spacestudyship.config; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +import java.time.Clock; + +@Configuration +public class BeanConfig { + + @Bean + public Clock clock() { + return Clock.systemUTC(); + } +} +``` + +- [ ] **Step 1.5: 컴파일 확인** + +Run: +```bash +./gradlew :SS-Common:compileJava :SS-Web:compileJava +``` +Expected: BUILD SUCCESSFUL. (테스트 미실행) + +- [ ] **Step 1.6: 커밋** + +```bash +git add version.yml \ + SS-Web/src/main/resources/db/migration/V0_0_39__add_timer_sessions.sql \ + SS-Common/src/main/java/com/elipair/spacestudyship/common/exception/ErrorCode.java \ + SS-Web/src/main/java/com/elipair/spacestudyship/config/BeanConfig.java +git commit -m "타이머 세션 도메인 구현 : chore : 사전 작업 (version 0.0.39, V0_0_39 마이그레이션, ErrorCode 5개, Clock 빈) #25" +``` + +--- + +## Task 2: TimerSession Entity + Repository + 통합 테스트 + +Entity, Repository, Repository 통합 테스트를 TDD로 작성. + +**Files:** +- Create: `SS-Study/src/main/java/com/elipair/spacestudyship/study/timer/entity/TimerSession.java` +- Create: `SS-Study/src/main/java/com/elipair/spacestudyship/study/timer/repository/TimerSessionRepository.java` +- Create: `SS-Study/src/test/java/com/elipair/spacestudyship/study/timer/entity/TimerSessionTest.java` +- Create: `SS-Study/src/test/java/com/elipair/spacestudyship/study/timer/repository/TimerSessionRepositoryTest.java` +- Modify: `SS-Study/src/test/java/com/elipair/spacestudyship/study/StudyTestApplication.java` + +### Steps + +- [ ] **Step 2.1: TimerSession Entity static factory 테스트 작성** + +`SS-Study/src/test/java/com/elipair/spacestudyship/study/timer/entity/TimerSessionTest.java`: +```java +package com.elipair.spacestudyship.study.timer.entity; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.time.LocalDateTime; + +import static org.assertj.core.api.Assertions.assertThat; + +class TimerSessionTest { + + @Test + @DisplayName("of: 모든 필드 세팅된 인스턴스 생성") + void of_setsAllFields() { + LocalDateTime start = LocalDateTime.parse("2026-05-25T01:00:00"); + LocalDateTime end = LocalDateTime.parse("2026-05-25T02:30:00"); + + TimerSession s = TimerSession.of( + "sess-1", 1L, "todo-1", "수학", + start, end, 90, "idem-1"); + + assertThat(s.getId()).isEqualTo("sess-1"); + assertThat(s.getUserId()).isEqualTo(1L); + assertThat(s.getTodoId()).isEqualTo("todo-1"); + assertThat(s.getTodoTitle()).isEqualTo("수학"); + assertThat(s.getStartedAt()).isEqualTo(start); + assertThat(s.getEndedAt()).isEqualTo(end); + assertThat(s.getDurationMinutes()).isEqualTo(90); + assertThat(s.getIdempotencyKey()).isEqualTo("idem-1"); + } + + @Test + @DisplayName("of: nullable 필드 (todoId, todoTitle, idempotencyKey) null 허용") + void of_allowsNullables() { + TimerSession s = TimerSession.of( + "sess-2", 1L, null, null, + LocalDateTime.parse("2026-05-25T01:00:00"), + LocalDateTime.parse("2026-05-25T02:00:00"), + 60, null); + + assertThat(s.getTodoId()).isNull(); + assertThat(s.getTodoTitle()).isNull(); + assertThat(s.getIdempotencyKey()).isNull(); + } +} +``` + +- [ ] **Step 2.2: 테스트 실행 — 컴파일 실패 확인** + +Run: +```bash +./gradlew :SS-Study:test --tests "com.elipair.spacestudyship.study.timer.entity.TimerSessionTest" +``` +Expected: COMPILATION FAILED (TimerSession 클래스 없음). + +- [ ] **Step 2.3: TimerSession Entity 구현** + +`SS-Study/src/main/java/com/elipair/spacestudyship/study/timer/entity/TimerSession.java`: +```java +package com.elipair.spacestudyship.study.timer.entity; + +import com.elipair.spacestudyship.common.entity.BaseTimeEntity; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.Id; +import jakarta.persistence.Index; +import jakarta.persistence.Table; +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import org.hibernate.annotations.Check; +import org.hibernate.annotations.Checks; + +import java.time.LocalDateTime; + +/** + * 공부 타이머 세션 기록. + * + * 시간 필드(startedAt/endedAt)는 모두 UTC로 해석한다. + * 서비스 진입 시점에 Instant → LocalDateTime UTC 변환을 거친다. + * + * id는 서버 생성 UUID이며, Fuel 충전 시 transactionId로 재사용되어 + * 충전 idempotency를 보장한다. + */ +@Entity +@Checks({ + @Check(name = "chk_timer_duration_positive", constraints = "duration_minutes > 0"), + @Check(name = "chk_timer_duration_max", constraints = "duration_minutes <= 1440"), + @Check(name = "chk_timer_time_order", constraints = "ended_at > started_at") +}) +@Table(name = "timer_sessions", + indexes = { + @Index(name = "idx_timer_sessions_user_started", columnList = "user_id, started_at DESC"), + @Index(name = "idx_timer_sessions_user_todo", columnList = "user_id, todo_id") + }) +@Getter +@Builder +@AllArgsConstructor +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class TimerSession extends BaseTimeEntity { + + @Id + @Column(length = 36) + private String id; + + @Column(name = "user_id", nullable = false) + private Long userId; + + @Column(name = "todo_id", length = 36) + private String todoId; + + @Column(name = "todo_title", length = 100) + private String todoTitle; + + @Column(name = "started_at", nullable = false) + private LocalDateTime startedAt; + + @Column(name = "ended_at", nullable = false) + private LocalDateTime endedAt; + + @Column(name = "duration_minutes", nullable = false) + private Integer durationMinutes; + + @Column(name = "idempotency_key", length = 80) + private String idempotencyKey; + + public static TimerSession of(String id, Long userId, String todoId, String todoTitle, + LocalDateTime startedAt, LocalDateTime endedAt, + int durationMinutes, String idempotencyKey) { + return TimerSession.builder() + .id(id).userId(userId).todoId(todoId).todoTitle(todoTitle) + .startedAt(startedAt).endedAt(endedAt) + .durationMinutes(durationMinutes) + .idempotencyKey(idempotencyKey) + .build(); + } +} +``` + +- [ ] **Step 2.4: 테스트 실행 — 통과 확인** + +Run: +```bash +./gradlew :SS-Study:test --tests "com.elipair.spacestudyship.study.timer.entity.TimerSessionTest" +``` +Expected: 2 tests passed. + +- [ ] **Step 2.5: StudyTestApplication에 timer.repository 등록** + +`SS-Study/src/test/java/com/elipair/spacestudyship/study/StudyTestApplication.java` 의 `@EnableJpaRepositories`: +```java +@EnableJpaRepositories(basePackages = { + "com.elipair.spacestudyship.study.todo.repository", + "com.elipair.spacestudyship.study.fuel.repository", + "com.elipair.spacestudyship.study.timer.repository" +}) +``` + +- [ ] **Step 2.6: TimerSessionRepository 테스트 작성** + +`SS-Study/src/test/java/com/elipair/spacestudyship/study/timer/repository/TimerSessionRepositoryTest.java`: +```java +package com.elipair.spacestudyship.study.timer.repository; + +import com.elipair.spacestudyship.study.StudyTestApplication; +import com.elipair.spacestudyship.study.timer.entity.TimerSession; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.dao.DataIntegrityViolationException; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Sort; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDateTime; +import java.util.List; +import java.util.UUID; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +@SpringBootTest(classes = StudyTestApplication.class) +@Transactional +class TimerSessionRepositoryTest { + + @Autowired + TimerSessionRepository repository; + + private TimerSession session(Long userId, LocalDateTime startedAt, int duration, String todoId, String idem) { + return TimerSession.of( + UUID.randomUUID().toString(), userId, todoId, todoId == null ? null : "title", + startedAt, startedAt.plusMinutes(duration), duration, idem); + } + + @Test + @DisplayName("findByUserIdAndIdempotencyKey: 동일 키 조회 시 반환") + void findByIdempotencyKey() { + TimerSession saved = repository.saveAndFlush( + session(1L, LocalDateTime.parse("2026-05-25T01:00:00"), 30, null, "idem-1")); + + assertThat(repository.findByUserIdAndIdempotencyKey(1L, "idem-1")) + .isPresent() + .get().extracting(TimerSession::getId).isEqualTo(saved.getId()); + assertThat(repository.findByUserIdAndIdempotencyKey(1L, "nope")).isEmpty(); + assertThat(repository.findByUserIdAndIdempotencyKey(2L, "idem-1")).isEmpty(); + } + + @Test + @DisplayName("partial unique index: 동일 (user, key) 중복 INSERT 실패, key=NULL은 다중 허용") + void partialUniqueIndex() { + repository.saveAndFlush( + session(1L, LocalDateTime.parse("2026-05-25T01:00:00"), 30, null, "idem-1")); + + // 동일 (user, key) 두번째 INSERT는 실패 + assertThatThrownBy(() -> repository.saveAndFlush( + session(1L, LocalDateTime.parse("2026-05-25T02:00:00"), 30, null, "idem-1"))) + .isInstanceOf(DataIntegrityViolationException.class); + + // 다른 user는 같은 key 허용 + repository.saveAndFlush( + session(2L, LocalDateTime.parse("2026-05-25T01:00:00"), 30, null, "idem-1")); + + // 같은 user지만 key=NULL은 다중 허용 + repository.saveAndFlush( + session(1L, LocalDateTime.parse("2026-05-25T03:00:00"), 30, null, null)); + repository.saveAndFlush( + session(1L, LocalDateTime.parse("2026-05-25T04:00:00"), 30, null, null)); + } + + @Test + @DisplayName("findByFilters: userId만 — 본인 세션 페이지네이션") + void findByFilters_userIdOnly() { + repository.saveAndFlush(session(1L, LocalDateTime.parse("2026-05-25T01:00:00"), 30, null, null)); + repository.saveAndFlush(session(1L, LocalDateTime.parse("2026-05-25T02:00:00"), 30, null, null)); + repository.saveAndFlush(session(2L, LocalDateTime.parse("2026-05-25T01:00:00"), 30, null, null)); + + Pageable pageable = PageRequest.of(0, 20, Sort.by(Sort.Direction.DESC, "startedAt")); + Page page = repository.findByFilters(1L, null, null, null, pageable); + + assertThat(page.getTotalElements()).isEqualTo(2); + assertThat(page.getContent().get(0).getStartedAt()) + .isEqualTo(LocalDateTime.parse("2026-05-25T02:00:00")); // DESC sort + } + + @Test + @DisplayName("findByFilters: 날짜 범위 [start, end) 반열림") + void findByFilters_dateRange() { + repository.saveAndFlush(session(1L, LocalDateTime.parse("2026-05-23T15:00:00"), 30, null, null)); // 한국 5/24 + repository.saveAndFlush(session(1L, LocalDateTime.parse("2026-05-24T15:00:00"), 30, null, null)); // 한국 5/25 + repository.saveAndFlush(session(1L, LocalDateTime.parse("2026-05-25T15:00:00"), 30, null, null)); // 한국 5/26 + + Page page = repository.findByFilters( + 1L, + LocalDateTime.parse("2026-05-24T00:00:00"), + LocalDateTime.parse("2026-05-25T00:00:00"), + null, + PageRequest.of(0, 20, Sort.by(Sort.Direction.DESC, "startedAt"))); + + assertThat(page.getContent()).hasSize(1); + assertThat(page.getContent().get(0).getStartedAt()) + .isEqualTo(LocalDateTime.parse("2026-05-24T15:00:00")); + } + + @Test + @DisplayName("findByFilters: todoId 필터") + void findByFilters_todoId() { + repository.saveAndFlush(session(1L, LocalDateTime.parse("2026-05-25T01:00:00"), 30, "todo-A", null)); + repository.saveAndFlush(session(1L, LocalDateTime.parse("2026-05-25T02:00:00"), 30, "todo-B", null)); + repository.saveAndFlush(session(1L, LocalDateTime.parse("2026-05-25T03:00:00"), 30, null, null)); + + Page page = repository.findByFilters( + 1L, null, null, "todo-A", + PageRequest.of(0, 20, Sort.by(Sort.Direction.DESC, "startedAt"))); + + assertThat(page.getContent()).hasSize(1); + assertThat(page.getContent().get(0).getTodoId()).isEqualTo("todo-A"); + } + + @Test + @DisplayName("sumDurationBetween: 빈 결과 → 0, 정상 → 합산") + void sumDurationBetween() { + repository.saveAndFlush(session(1L, LocalDateTime.parse("2026-05-24T01:00:00"), 30, null, null)); + repository.saveAndFlush(session(1L, LocalDateTime.parse("2026-05-24T03:00:00"), 60, null, null)); + repository.saveAndFlush(session(1L, LocalDateTime.parse("2026-05-23T01:00:00"), 90, null, null)); + + Integer sum = repository.sumDurationBetween(1L, + LocalDateTime.parse("2026-05-24T00:00:00"), + LocalDateTime.parse("2026-05-25T00:00:00")); + assertThat(sum).isEqualTo(90); + + Integer none = repository.sumDurationBetween(1L, + LocalDateTime.parse("2026-06-01T00:00:00"), + LocalDateTime.parse("2026-06-02T00:00:00")); + assertThat(none).isZero(); + } + + @Test + @DisplayName("count: 날짜 범위 안 세션 수") + void countBetween() { + repository.saveAndFlush(session(1L, LocalDateTime.parse("2026-05-24T01:00:00"), 30, null, null)); + repository.saveAndFlush(session(1L, LocalDateTime.parse("2026-05-24T03:00:00"), 60, null, null)); + repository.saveAndFlush(session(1L, LocalDateTime.parse("2026-05-23T01:00:00"), 90, null, null)); + + long n = repository.countByUserIdAndStartedAtGreaterThanEqualAndStartedAtLessThan( + 1L, + LocalDateTime.parse("2026-05-24T00:00:00"), + LocalDateTime.parse("2026-05-25T00:00:00")); + assertThat(n).isEqualTo(2); + } + + @Test + @DisplayName("findStartedAtsAfter: 본인 + start 이후만") + void findStartedAtsAfter() { + repository.saveAndFlush(session(1L, LocalDateTime.parse("2026-05-24T01:00:00"), 30, null, null)); + repository.saveAndFlush(session(1L, LocalDateTime.parse("2026-05-25T01:00:00"), 30, null, null)); + repository.saveAndFlush(session(1L, LocalDateTime.parse("2026-05-23T01:00:00"), 30, null, null)); + repository.saveAndFlush(session(2L, LocalDateTime.parse("2026-05-25T01:00:00"), 30, null, null)); + + List dates = repository.findStartedAtsAfter( + 1L, LocalDateTime.parse("2026-05-24T00:00:00")); + + assertThat(dates).hasSize(2); + } +} +``` + +- [ ] **Step 2.7: 테스트 실행 — 컴파일 실패 확인** + +Run: +```bash +./gradlew :SS-Study:test --tests "com.elipair.spacestudyship.study.timer.repository.TimerSessionRepositoryTest" +``` +Expected: COMPILATION FAILED (TimerSessionRepository 클래스 없음). + +- [ ] **Step 2.8: TimerSessionRepository 구현** + +`SS-Study/src/main/java/com/elipair/spacestudyship/study/timer/repository/TimerSessionRepository.java`: +```java +package com.elipair.spacestudyship.study.timer.repository; + +import com.elipair.spacestudyship.study.timer.entity.TimerSession; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +import java.time.LocalDateTime; +import java.util.List; +import java.util.Optional; + +public interface TimerSessionRepository extends JpaRepository { + + Optional findByUserIdAndIdempotencyKey(Long userId, String idempotencyKey); + + @Query(""" + SELECT s FROM TimerSession s + WHERE s.userId = :userId + AND (:start IS NULL OR s.startedAt >= :start) + AND (:end IS NULL OR s.startedAt < :end) + AND (:todoId IS NULL OR s.todoId = :todoId) + """) + Page findByFilters( + @Param("userId") Long userId, + @Param("start") LocalDateTime start, + @Param("end") LocalDateTime end, + @Param("todoId") String todoId, + Pageable pageable); + + @Query("SELECT COALESCE(SUM(s.durationMinutes), 0) FROM TimerSession s " + + "WHERE s.userId = :userId AND s.startedAt >= :start AND s.startedAt < :end") + Integer sumDurationBetween(@Param("userId") Long userId, + @Param("start") LocalDateTime start, + @Param("end") LocalDateTime end); + + long countByUserIdAndStartedAtGreaterThanEqualAndStartedAtLessThan( + Long userId, LocalDateTime start, LocalDateTime end); + + @Query("SELECT s.startedAt FROM TimerSession s " + + "WHERE s.userId = :userId AND s.startedAt >= :start") + List findStartedAtsAfter(@Param("userId") Long userId, + @Param("start") LocalDateTime start); +} +``` + +- [ ] **Step 2.9: 테스트 실행 — 통과 확인** + +Run: +```bash +./gradlew :SS-Study:test --tests "com.elipair.spacestudyship.study.timer.repository.TimerSessionRepositoryTest" +``` +Expected: 8 tests passed (테스트 시작 시 Testcontainers PostgreSQL 컨테이너 기동 — 30~60초 소요). + +- [ ] **Step 2.10: 커밋** + +```bash +git add SS-Study/src/main/java/com/elipair/spacestudyship/study/timer/entity/TimerSession.java \ + SS-Study/src/main/java/com/elipair/spacestudyship/study/timer/repository/TimerSessionRepository.java \ + SS-Study/src/test/java/com/elipair/spacestudyship/study/timer/entity/TimerSessionTest.java \ + SS-Study/src/test/java/com/elipair/spacestudyship/study/timer/repository/TimerSessionRepositoryTest.java \ + SS-Study/src/test/java/com/elipair/spacestudyship/study/StudyTestApplication.java +git commit -m "타이머 세션 도메인 구현 : feat : TimerSession Entity와 Repository 구현 (통합 테스트 포함) #25" +``` + +--- + +## Task 3: DTOs + TimerSessionService + 단위 테스트 + +5개 DTO record와 핵심 서비스 로직(검증/저장/목록/통계/streak)을 TDD로 작성. **이 Task에서 TimerSessionService가 의존하는 `TodoService.addActualMinutes`는 다음 Task 4에서 추가되므로, 본 Task의 Service는 컴파일을 위해 Task 4를 먼저 끝내거나 (대안) Task 4를 끼워 진행한다. 본 계획은 DTO와 Service test/impl을 먼저 작성하되, `todoService.addActualMinutes(...)` 호출은 implementation에 포함시키고 컴파일은 Task 4 완료 후에 보장한다.** + +이를 단순화하기 위해, **Task 3와 Task 4를 같은 PR/커밋 흐름의 두 단계로 진행**한다. 본 Task 끝에서 Service까지만 구현하고, 첫 컴파일 통과는 Task 4의 TodoService 메서드 추가 후 보장. + +> 실용적 진행 가이드: Service 작성 직전에 Task 4의 Step 4.1~4.6 (TodoRepository/Service 변경)을 먼저 처리한 뒤 Service를 작성하면 컴파일 막힘 없음. 작업자 재량. + +**Files:** +- Create: `SS-Study/src/main/java/com/elipair/spacestudyship/study/timer/dto/TimerSessionCreateRequest.java` +- Create: `SS-Study/src/main/java/com/elipair/spacestudyship/study/timer/dto/TimerSessionResponse.java` +- Create: `SS-Study/src/main/java/com/elipair/spacestudyship/study/timer/dto/TimerSessionCreateResponse.java` +- Create: `SS-Study/src/main/java/com/elipair/spacestudyship/study/timer/dto/TimerSessionListResponse.java` +- Create: `SS-Study/src/main/java/com/elipair/spacestudyship/study/timer/dto/TodayStatsResponse.java` +- Create: `SS-Study/src/main/java/com/elipair/spacestudyship/study/timer/service/TimerSessionService.java` +- Create: `SS-Study/src/test/java/com/elipair/spacestudyship/study/timer/service/TimerSessionServiceTest.java` + +### Steps + +- [ ] **Step 3.1: DTO 5종 구현** + +`SS-Study/src/main/java/com/elipair/spacestudyship/study/timer/dto/TimerSessionCreateRequest.java`: +```java +package com.elipair.spacestudyship.study.timer.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Size; + +import java.time.Instant; + +public record TimerSessionCreateRequest( + @Schema(description = "연결된 Todo ID (없으면 null)", example = "todo-uuid-5678") + @Size(max = 36) String todoId, + + @Schema(description = "Todo 제목 스냅샷 (Todo 삭제 후 표시용)", example = "수학 문제 풀기") + @Size(max = 100) String todoTitle, + + @Schema(description = "타이머 시작 시각 (ISO 8601 UTC)", example = "2026-05-25T00:00:00Z") + @NotNull Instant startedAt, + + @Schema(description = "타이머 종료 시각 (ISO 8601 UTC)", example = "2026-05-25T01:30:00Z") + @NotNull Instant endedAt, + + @Schema(description = "실제 공부 시간 (분, 일시정지 제외)", example = "90") + @NotNull Integer durationMinutes +) {} +``` + +`SS-Study/src/main/java/com/elipair/spacestudyship/study/timer/dto/TimerSessionResponse.java`: +```java +package com.elipair.spacestudyship.study.timer.dto; + +import com.elipair.spacestudyship.study.timer.entity.TimerSession; +import io.swagger.v3.oas.annotations.media.Schema; + +import java.time.Instant; +import java.time.ZoneOffset; + +@Schema(description = "타이머 세션 단건") +public record TimerSessionResponse( + String id, + String todoId, + String todoTitle, + Instant startedAt, + Instant endedAt, + Integer durationMinutes +) { + public static TimerSessionResponse from(TimerSession s) { + return new TimerSessionResponse( + s.getId(), s.getTodoId(), s.getTodoTitle(), + s.getStartedAt().atOffset(ZoneOffset.UTC).toInstant(), + s.getEndedAt().atOffset(ZoneOffset.UTC).toInstant(), + s.getDurationMinutes()); + } +} +``` + +`SS-Study/src/main/java/com/elipair/spacestudyship/study/timer/dto/TimerSessionCreateResponse.java`: +```java +package com.elipair.spacestudyship.study.timer.dto; + +import io.swagger.v3.oas.annotations.media.Schema; + +@Schema(description = "세션 저장 응답") +public record TimerSessionCreateResponse( + TimerSessionResponse session, + @Schema(description = "서버에서 검증 후 충전된 연료량") Integer fuelCharged +) {} +``` + +`SS-Study/src/main/java/com/elipair/spacestudyship/study/timer/dto/TimerSessionListResponse.java`: +```java +package com.elipair.spacestudyship.study.timer.dto; + +import org.springframework.data.domain.Page; + +import java.util.List; + +public record TimerSessionListResponse( + List content, + Integer page, + Integer size, + Long totalElements, + Integer totalPages +) { + public static TimerSessionListResponse from(Page page) { + return new TimerSessionListResponse( + page.getContent().stream().map(TimerSessionResponse::from).toList(), + page.getNumber(), page.getSize(), + page.getTotalElements(), page.getTotalPages()); + } +} +``` + +`SS-Study/src/main/java/com/elipair/spacestudyship/study/timer/dto/TodayStatsResponse.java`: +```java +package com.elipair.spacestudyship.study.timer.dto; + +import io.swagger.v3.oas.annotations.media.Schema; + +@Schema(description = "오늘 공부 통계 (KST 기준)") +public record TodayStatsResponse( + @Schema(description = "오늘 총 공부 시간 (분)") Integer totalMinutes, + @Schema(description = "오늘 완료한 세션 수") Integer sessionCount, + @Schema(description = "연속 공부 일수 (오늘 포함, KST 기준)") Integer streak +) {} +``` + +- [ ] **Step 3.2: TimerSessionService 테스트 작성** + +`SS-Study/src/test/java/com/elipair/spacestudyship/study/timer/service/TimerSessionServiceTest.java`: +```java +package com.elipair.spacestudyship.study.timer.service; + +import com.elipair.spacestudyship.common.exception.CustomException; +import com.elipair.spacestudyship.common.exception.ErrorCode; +import com.elipair.spacestudyship.study.fuel.constant.FuelReason; +import com.elipair.spacestudyship.study.fuel.service.FuelService; +import com.elipair.spacestudyship.study.timer.dto.TimerSessionCreateRequest; +import com.elipair.spacestudyship.study.timer.dto.TimerSessionCreateResponse; +import com.elipair.spacestudyship.study.timer.dto.TimerSessionListResponse; +import com.elipair.spacestudyship.study.timer.dto.TodayStatsResponse; +import com.elipair.spacestudyship.study.timer.entity.TimerSession; +import com.elipair.spacestudyship.study.timer.repository.TimerSessionRepository; +import com.elipair.spacestudyship.study.todo.service.TodoService; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.CsvSource; +import org.mockito.ArgumentCaptor; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.dao.DataIntegrityViolationException; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageImpl; +import org.springframework.data.domain.Pageable; + +import java.time.*; +import java.util.List; +import java.util.Optional; +import java.util.UUID; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.*; + +@ExtendWith(MockitoExtension.class) +class TimerSessionServiceTest { + + @Mock TimerSessionRepository sessionRepository; + @Mock FuelService fuelService; + @Mock TodoService todoService; + + TimerSessionService service; + + // 2026-05-25 12:00 UTC == 2026-05-25 21:00 KST + Clock fixedClock = Clock.fixed(Instant.parse("2026-05-25T12:00:00Z"), ZoneOffset.UTC); + + @BeforeEach + void setUp() { + service = new TimerSessionService(sessionRepository, fuelService, todoService, fixedClock); + } + + private TimerSessionCreateRequest validRequest(int duration) { + return new TimerSessionCreateRequest( + null, null, + Instant.parse("2026-05-25T01:00:00Z"), + Instant.parse("2026-05-25T02:00:00Z"), + duration); + } + + // ---------- validate (5 cases) ---------- + + @Test + @DisplayName("validate: startedAt == endedAt → INVALID_SESSION_TIME") + void validate_sameTime_throws() { + TimerSessionCreateRequest req = new TimerSessionCreateRequest( + null, null, + Instant.parse("2026-05-25T01:00:00Z"), + Instant.parse("2026-05-25T01:00:00Z"), + 1); + + assertThatThrownBy(() -> service.create(1L, req, null)) + .isInstanceOf(CustomException.class) + .extracting("errorCode").isEqualTo(ErrorCode.INVALID_SESSION_TIME); + } + + @Test + @DisplayName("validate: durationMinutes > 경과시간 → INVALID_DURATION") + void validate_durationOverElapsed_throws() { + TimerSessionCreateRequest req = new TimerSessionCreateRequest( + null, null, + Instant.parse("2026-05-25T01:00:00Z"), + Instant.parse("2026-05-25T01:30:00Z"), + 31); + + assertThatThrownBy(() -> service.create(1L, req, null)) + .isInstanceOf(CustomException.class) + .extracting("errorCode").isEqualTo(ErrorCode.INVALID_DURATION); + } + + @ParameterizedTest + @CsvSource({"0", "-1"}) + @DisplayName("validate: durationMinutes < 1 → SESSION_TOO_SHORT") + void validate_tooShort_throws(int duration) { + TimerSessionCreateRequest req = new TimerSessionCreateRequest( + null, null, + Instant.parse("2026-05-25T01:00:00Z"), + Instant.parse("2026-05-25T03:00:00Z"), + duration); + + assertThatThrownBy(() -> service.create(1L, req, null)) + .isInstanceOf(CustomException.class) + .extracting("errorCode").isEqualTo(ErrorCode.SESSION_TOO_SHORT); + } + + @Test + @DisplayName("validate: durationMinutes > 1440 → SESSION_TOO_LONG") + void validate_tooLong_throws() { + TimerSessionCreateRequest req = new TimerSessionCreateRequest( + null, null, + Instant.parse("2026-05-23T00:00:00Z"), + Instant.parse("2026-05-25T01:00:00Z"), + 1441); + + assertThatThrownBy(() -> service.create(1L, req, null)) + .isInstanceOf(CustomException.class) + .extracting("errorCode").isEqualTo(ErrorCode.SESSION_TOO_LONG); + } + + @Test + @DisplayName("validate: startedAt > now + 5분 → FUTURE_SESSION") + void validate_future_throws() { + // now (clock) = 2026-05-25T12:00:00Z. 5분 후 = 12:05:00. 그 후 = 12:05:01 부터 FUTURE + TimerSessionCreateRequest req = new TimerSessionCreateRequest( + null, null, + Instant.parse("2026-05-25T12:05:01Z"), + Instant.parse("2026-05-25T13:00:00Z"), + 30); + + assertThatThrownBy(() -> service.create(1L, req, null)) + .isInstanceOf(CustomException.class) + .extracting("errorCode").isEqualTo(ErrorCode.FUTURE_SESSION); + } + + @Test + @DisplayName("validate: startedAt == now + 5분 정확히 → 통과") + void validate_exactlyAtSkewBoundary_passes() { + TimerSessionCreateRequest req = new TimerSessionCreateRequest( + null, null, + Instant.parse("2026-05-25T12:05:00Z"), + Instant.parse("2026-05-25T13:00:00Z"), + 30); + + TimerSessionCreateResponse res = service.create(1L, req, null); + assertThat(res.session().durationMinutes()).isEqualTo(30); + } + + // ---------- create 정상 흐름 ---------- + + @Test + @DisplayName("create 정상: 세션 저장 + Fuel 충전 + (todoId 없으므로) Todo 미호출") + void create_noTodo_chargesFuel_doesNotTouchTodo() { + TimerSessionCreateRequest req = validRequest(60); + + TimerSessionCreateResponse res = service.create(1L, req, null); + + ArgumentCaptor savedCap = ArgumentCaptor.forClass(TimerSession.class); + verify(sessionRepository).save(savedCap.capture()); + TimerSession saved = savedCap.getValue(); + + assertThat(saved.getUserId()).isEqualTo(1L); + assertThat(saved.getDurationMinutes()).isEqualTo(60); + assertThat(saved.getIdempotencyKey()).isNull(); + assertThat(saved.getId()).isNotBlank(); + + // Fuel: sessionId == transactionId + verify(fuelService).charge( + eq(1L), eq(60), eq(FuelReason.STUDY_SESSION), + eq(saved.getId()), eq(saved.getId())); + + // Todo는 호출되지 않아야 함 + verifyNoInteractions(todoService); + + assertThat(res.fuelCharged()).isEqualTo(60); + assertThat(res.session().id()).isEqualTo(saved.getId()); + } + + @Test + @DisplayName("create 정상: todoId 있으면 TodoService.addActualMinutes 호출") + void create_withTodo_callsAddActualMinutes() { + TimerSessionCreateRequest req = new TimerSessionCreateRequest( + "todo-1", "수학", + Instant.parse("2026-05-25T01:00:00Z"), + Instant.parse("2026-05-25T02:00:00Z"), + 60); + + service.create(1L, req, null); + + verify(todoService).addActualMinutes(eq(1L), eq("todo-1"), eq(60)); + } + + // ---------- Idempotency ---------- + + @Test + @DisplayName("Idempotency-Key dedup: 동일 키 재요청 시 기존 세션 반환, fuel/todo 호출 0회") + void idempotency_dedup_returnsExisting() { + TimerSession existing = TimerSession.of( + "existing-id", 1L, null, null, + LocalDateTime.parse("2026-05-25T01:00:00"), + LocalDateTime.parse("2026-05-25T02:00:00"), + 60, "idem-1"); + given(sessionRepository.findByUserIdAndIdempotencyKey(1L, "idem-1")) + .willReturn(Optional.of(existing)); + + TimerSessionCreateResponse res = service.create(1L, validRequest(60), "idem-1"); + + verify(sessionRepository, never()).save(any()); + verifyNoInteractions(fuelService); + verifyNoInteractions(todoService); + assertThat(res.session().id()).isEqualTo("existing-id"); + assertThat(res.fuelCharged()).isEqualTo(60); + } + + @Test + @DisplayName("Idempotency-Key 정규화: blank → null로 취급 (dedup 안 함)") + void idempotency_blank_normalizedToNull() { + service.create(1L, validRequest(60), " "); + + verify(sessionRepository, never()).findByUserIdAndIdempotencyKey(anyLong(), any()); + ArgumentCaptor cap = ArgumentCaptor.forClass(TimerSession.class); + verify(sessionRepository).save(cap.capture()); + assertThat(cap.getValue().getIdempotencyKey()).isNull(); + } + + @Test + @DisplayName("Idempotency race: save 시 DataIntegrityViolation → 재조회 후 기존 반환") + void idempotency_race_resolvedByReSelect() { + // 첫 조회는 empty (race window) + given(sessionRepository.findByUserIdAndIdempotencyKey(1L, "idem-1")) + .willReturn(Optional.empty()) + .willReturn(Optional.of(TimerSession.of( + "racer-id", 1L, null, null, + LocalDateTime.parse("2026-05-25T01:00:00"), + LocalDateTime.parse("2026-05-25T02:00:00"), + 60, "idem-1"))); + // save는 unique violation + given(sessionRepository.save(any(TimerSession.class))) + .willThrow(new DataIntegrityViolationException("unique violation")); + + TimerSessionCreateResponse res = service.create(1L, validRequest(60), "idem-1"); + + assertThat(res.session().id()).isEqualTo("racer-id"); + verifyNoInteractions(fuelService); + verifyNoInteractions(todoService); + } + + @Test + @DisplayName("Idempotency race: save 실패했는데 재조회도 empty → 원본 예외 rethrow") + void idempotency_race_rethrowIfStillMissing() { + given(sessionRepository.findByUserIdAndIdempotencyKey(1L, "idem-1")) + .willReturn(Optional.empty()); + given(sessionRepository.save(any(TimerSession.class))) + .willThrow(new DataIntegrityViolationException("unique violation")); + + assertThatThrownBy(() -> service.create(1L, validRequest(60), "idem-1")) + .isInstanceOf(DataIntegrityViolationException.class); + } + + // ---------- getList ---------- + + @Test + @DisplayName("getList: 필터 인자가 서비스 → 레포로 전달, Page envelope 변환") + void getList_passThroughAndEnvelope() { + TimerSession s = TimerSession.of( + UUID.randomUUID().toString(), 1L, "t-1", "title", + LocalDateTime.parse("2026-05-25T01:00:00"), + LocalDateTime.parse("2026-05-25T02:00:00"), + 60, null); + Page page = new PageImpl<>(List.of(s)); + given(sessionRepository.findByFilters(eq(1L), any(), any(), eq("t-1"), any(Pageable.class))) + .willReturn(page); + + TimerSessionListResponse res = service.getList( + 1L, "2026-05-20", "2026-05-25", "t-1", 0, 20); + + assertThat(res.content()).hasSize(1); + assertThat(res.content().get(0).id()).isEqualTo(s.getId()); + } + + // ---------- today-stats ---------- + + @Test + @DisplayName("today-stats: 빈 데이터 → 모두 0") + void todayStats_empty() { + given(sessionRepository.sumDurationBetween(eq(1L), any(), any())).willReturn(0); + given(sessionRepository + .countByUserIdAndStartedAtGreaterThanEqualAndStartedAtLessThan(eq(1L), any(), any())) + .willReturn(0L); + given(sessionRepository.findStartedAtsAfter(eq(1L), any())).willReturn(List.of()); + + TodayStatsResponse res = service.getTodayStats(1L); + + assertThat(res).isEqualTo(new TodayStatsResponse(0, 0, 0)); + } + + @Test + @DisplayName("today-stats: 정상 데이터 + streak 계산") + void todayStats_withData() { + given(sessionRepository.sumDurationBetween(eq(1L), any(), any())).willReturn(180); + given(sessionRepository + .countByUserIdAndStartedAtGreaterThanEqualAndStartedAtLessThan(eq(1L), any(), any())) + .willReturn(3L); + // KST 기준 오늘 = 2026-05-25, 어제 = 5/24, 그저께 = 5/23 + given(sessionRepository.findStartedAtsAfter(eq(1L), any())) + .willReturn(List.of( + // 5/25 KST = 5/24 15:00 ~ 5/25 15:00 UTC + LocalDateTime.parse("2026-05-25T02:00:00"), // 5/25 KST 11:00 + LocalDateTime.parse("2026-05-23T16:00:00"), // 5/24 KST 01:00 + LocalDateTime.parse("2026-05-22T16:00:00") // 5/23 KST 01:00 + )); + + TodayStatsResponse res = service.getTodayStats(1L); + + assertThat(res.totalMinutes()).isEqualTo(180); + assertThat(res.sessionCount()).isEqualTo(3); + assertThat(res.streak()).isEqualTo(3); // 5/23, 5/24, 5/25 연속 + } + + @Test + @DisplayName("streak: 어제까지만 했으면 어제 기준으로 N (오늘 포함 X)") + void streak_yesterdayLatest() { + given(sessionRepository.sumDurationBetween(eq(1L), any(), any())).willReturn(0); + given(sessionRepository + .countByUserIdAndStartedAtGreaterThanEqualAndStartedAtLessThan(eq(1L), any(), any())) + .willReturn(0L); + // 어제(5/24) + 그저께(5/23) 만 + given(sessionRepository.findStartedAtsAfter(eq(1L), any())) + .willReturn(List.of( + LocalDateTime.parse("2026-05-23T16:00:00"), + LocalDateTime.parse("2026-05-22T16:00:00") + )); + + TodayStatsResponse res = service.getTodayStats(1L); + + assertThat(res.streak()).isEqualTo(2); + } + + @Test + @DisplayName("streak: 마지막 공부일이 어제보다 이전 → 0") + void streak_brokenChain() { + given(sessionRepository.sumDurationBetween(eq(1L), any(), any())).willReturn(0); + given(sessionRepository + .countByUserIdAndStartedAtGreaterThanEqualAndStartedAtLessThan(eq(1L), any(), any())) + .willReturn(0L); + // 그저께(5/23)만 + given(sessionRepository.findStartedAtsAfter(eq(1L), any())) + .willReturn(List.of(LocalDateTime.parse("2026-05-22T16:00:00"))); + + TodayStatsResponse res = service.getTodayStats(1L); + + assertThat(res.streak()).isZero(); + } + + @Test + @DisplayName("streak: latest가 미래(clock skew)면 today로 클램프") + void streak_futureLatest_clampedToToday() { + given(sessionRepository.sumDurationBetween(eq(1L), any(), any())).willReturn(0); + given(sessionRepository + .countByUserIdAndStartedAtGreaterThanEqualAndStartedAtLessThan(eq(1L), any(), any())) + .willReturn(0L); + // now KST = 2026-05-25 21:00. 미래 = 2026-05-26 KST + 어제 KST(5/24) + given(sessionRepository.findStartedAtsAfter(eq(1L), any())) + .willReturn(List.of( + LocalDateTime.parse("2026-05-26T01:00:00"), // KST 5/26 10:00 (미래) + LocalDateTime.parse("2026-05-25T01:00:00"), // KST 5/25 10:00 (오늘) + LocalDateTime.parse("2026-05-23T16:00:00") // KST 5/24 01:00 (어제) + )); + + TodayStatsResponse res = service.getTodayStats(1L); + + // cursor = min(latest=5/26, today=5/25) = 5/25 → 5/25, 5/24 카운트 → 2 (5/26 부풀림 방지) + assertThat(res.streak()).isEqualTo(2); + } +} +``` + +- [ ] **Step 3.3: 테스트 실행 — 컴파일 실패 확인** + +Run: +```bash +./gradlew :SS-Study:test --tests "com.elipair.spacestudyship.study.timer.service.TimerSessionServiceTest" +``` +Expected: COMPILATION FAILED (TimerSessionService, TodoService.addActualMinutes 등 없음). + +- [ ] **Step 3.4: TimerSessionService 구현** + +> ⚠️ **컴파일 의존성:** 본 클래스는 `todoService.addActualMinutes(...)`를 호출. Task 4의 Step 4.4 (TodoService 메서드 추가)가 선행되어야 컴파일 통과. 본 Step에서는 코드만 작성하고 다음 Task 4를 이어서 진행한 뒤 한 번에 컴파일/테스트 검증. + +`SS-Study/src/main/java/com/elipair/spacestudyship/study/timer/service/TimerSessionService.java`: +```java +package com.elipair.spacestudyship.study.timer.service; + +import com.elipair.spacestudyship.common.exception.CustomException; +import com.elipair.spacestudyship.common.exception.ErrorCode; +import com.elipair.spacestudyship.study.fuel.constant.FuelReason; +import com.elipair.spacestudyship.study.fuel.service.FuelService; +import com.elipair.spacestudyship.study.timer.dto.*; +import com.elipair.spacestudyship.study.timer.entity.TimerSession; +import com.elipair.spacestudyship.study.timer.repository.TimerSessionRepository; +import com.elipair.spacestudyship.study.todo.service.TodoService; +import lombok.extern.slf4j.Slf4j; +import org.springframework.dao.DataIntegrityViolationException; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Sort; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.*; +import java.util.List; +import java.util.Optional; +import java.util.TreeSet; +import java.util.UUID; +import java.util.stream.Collectors; + +@Slf4j +@Service +@Transactional(readOnly = true) +public class TimerSessionService { + + private static final ZoneId ZONE_KST = ZoneId.of("Asia/Seoul"); + private static final long CLOCK_SKEW_TOLERANCE_SECONDS = 300; // 5분 + private static final int STREAK_LOOKBACK_DAYS = 365; + + private final TimerSessionRepository sessionRepository; + private final FuelService fuelService; + private final TodoService todoService; + private final Clock clock; + + public TimerSessionService(TimerSessionRepository sessionRepository, + FuelService fuelService, + TodoService todoService, + Clock clock) { + this.sessionRepository = sessionRepository; + this.fuelService = fuelService; + this.todoService = todoService; + this.clock = clock; + } + + @Transactional + public TimerSessionCreateResponse create( + Long userId, TimerSessionCreateRequest request, String idempotencyKey) { + + String normalizedKey = (idempotencyKey == null || idempotencyKey.isBlank()) + ? null : idempotencyKey.trim(); + + if (normalizedKey != null) { + Optional existing = sessionRepository + .findByUserIdAndIdempotencyKey(userId, normalizedKey); + if (existing.isPresent()) { + log.info("[Timer] idempotent skip | userId={}, key={}, sessionId={}", + userId, normalizedKey, existing.get().getId()); + return buildResponse(existing.get(), existing.get().getDurationMinutes()); + } + } + + LocalDateTime startedAtUtc = LocalDateTime.ofInstant(request.startedAt(), ZoneOffset.UTC); + LocalDateTime endedAtUtc = LocalDateTime.ofInstant(request.endedAt(), ZoneOffset.UTC); + validate(startedAtUtc, endedAtUtc, request.durationMinutes()); + + String sessionId = UUID.randomUUID().toString(); + TimerSession session = TimerSession.of( + sessionId, userId, + request.todoId(), request.todoTitle(), + startedAtUtc, endedAtUtc, request.durationMinutes(), + normalizedKey); + + try { + sessionRepository.save(session); + } catch (DataIntegrityViolationException e) { + if (normalizedKey != null) { + Optional raced = sessionRepository + .findByUserIdAndIdempotencyKey(userId, normalizedKey); + if (raced.isPresent()) { + log.info("[Timer] idempotent race resolved | userId={}, key={}", + userId, normalizedKey); + return buildResponse(raced.get(), raced.get().getDurationMinutes()); + } + } + throw e; + } + + int fuelCharged = request.durationMinutes(); + fuelService.charge(userId, fuelCharged, FuelReason.STUDY_SESSION, sessionId, sessionId); + + if (request.todoId() != null) { + todoService.addActualMinutes(userId, request.todoId(), fuelCharged); + } + + log.info("[Timer] 세션 저장 | userId={}, sessionId={}, duration={}분, todoId={}", + userId, sessionId, fuelCharged, request.todoId()); + return buildResponse(session, fuelCharged); + } + + private void validate(LocalDateTime startedAt, LocalDateTime endedAt, int durationMinutes) { + if (!endedAt.isAfter(startedAt)) { + throw new CustomException(ErrorCode.INVALID_SESSION_TIME); + } + long elapsedMinutes = Duration.between(startedAt, endedAt).toMinutes(); + if (durationMinutes > elapsedMinutes) { + throw new CustomException(ErrorCode.INVALID_DURATION); + } + if (durationMinutes < 1) { + throw new CustomException(ErrorCode.SESSION_TOO_SHORT); + } + if (durationMinutes > 1440) { + throw new CustomException(ErrorCode.SESSION_TOO_LONG); + } + LocalDateTime now = LocalDateTime.now(clock); + if (startedAt.isAfter(now.plusSeconds(CLOCK_SKEW_TOLERANCE_SECONDS))) { + throw new CustomException(ErrorCode.FUTURE_SESSION); + } + } + + private TimerSessionCreateResponse buildResponse(TimerSession session, int fuelCharged) { + return new TimerSessionCreateResponse(TimerSessionResponse.from(session), fuelCharged); + } + + public TimerSessionListResponse getList( + Long userId, String startDate, String endDate, String todoId, + int page, int size) { + + LocalDateTime start = startDate == null ? null + : LocalDate.parse(startDate).atStartOfDay(); + LocalDateTime end = endDate == null ? null + : LocalDate.parse(endDate).plusDays(1).atStartOfDay(); + + Pageable pageable = PageRequest.of(page, size, + Sort.by(Sort.Direction.DESC, "startedAt")); + Page result = sessionRepository.findByFilters( + userId, start, end, todoId, pageable); + return TimerSessionListResponse.from(result); + } + + public TodayStatsResponse getTodayStats(Long userId) { + LocalDate todayKst = LocalDate.now(clock.withZone(ZONE_KST)); + LocalDateTime todayStartUtc = toUtcLdt(todayKst.atStartOfDay(ZONE_KST)); + LocalDateTime tomorrowStartUtc = toUtcLdt(todayKst.plusDays(1).atStartOfDay(ZONE_KST)); + + Integer totalMinutes = Optional.ofNullable( + sessionRepository.sumDurationBetween(userId, todayStartUtc, tomorrowStartUtc)) + .orElse(0); + long sessionCount = sessionRepository + .countByUserIdAndStartedAtGreaterThanEqualAndStartedAtLessThan( + userId, todayStartUtc, tomorrowStartUtc); + + LocalDateTime lookbackStart = toUtcLdt( + todayKst.minusDays(STREAK_LOOKBACK_DAYS).atStartOfDay(ZONE_KST)); + List startedAts = sessionRepository + .findStartedAtsAfter(userId, lookbackStart); + + int streak = computeStreak(startedAts, todayKst); + return new TodayStatsResponse(totalMinutes, (int) sessionCount, streak); + } + + private LocalDateTime toUtcLdt(ZonedDateTime kst) { + return kst.withZoneSameInstant(ZoneOffset.UTC).toLocalDateTime(); + } + + private int computeStreak(List startedAtsUtc, LocalDate todayKst) { + TreeSet studyDays = startedAtsUtc.stream() + .map(ts -> ts.atZone(ZoneOffset.UTC).withZoneSameInstant(ZONE_KST).toLocalDate()) + .collect(Collectors.toCollection(TreeSet::new)); + if (studyDays.isEmpty()) return 0; + + LocalDate latest = studyDays.last(); + LocalDate cursor = latest.isAfter(todayKst) ? todayKst : latest; + if (cursor.isBefore(todayKst.minusDays(1))) return 0; + + int streak = 0; + while (studyDays.contains(cursor)) { + streak++; + cursor = cursor.minusDays(1); + } + return streak; + } +} +``` + +- [ ] **Step 3.5: 컴파일 검증 (Task 4 진행 후 보장)** + +> Service만 작성한 상태에서는 `todoService.addActualMinutes` 미존재로 컴파일 실패. Task 4까지 끝낸 뒤 검증. + +(여기서는 빌드를 실행하지 않고 다음 Task로 진행.) + +--- + +## Task 4: TodoService.addActualMinutes (Todo 도메인 보강) + +`Todo.actualMinutes` 누적을 lost update 없이 atomic하게 처리하는 메서드 추가. Task 3의 Service가 의존하므로 본 Task가 끝나면 Task 3와 함께 빌드/테스트 통과 가능. + +**Files:** +- Modify: `SS-Study/src/main/java/com/elipair/spacestudyship/study/todo/repository/TodoRepository.java` +- Modify: `SS-Study/src/main/java/com/elipair/spacestudyship/study/todo/service/TodoService.java` +- Modify: `SS-Study/src/test/java/com/elipair/spacestudyship/study/todo/repository/TodoRepositoryTest.java` +- Modify: `SS-Study/src/test/java/com/elipair/spacestudyship/study/todo/service/TodoServiceTest.java` + +### Steps + +- [ ] **Step 4.1: TodoRepository 쿼리 회귀 테스트 추가** + +먼저 `SS-Study/src/test/java/com/elipair/spacestudyship/study/todo/repository/TodoRepositoryTest.java` 의 import에 다음 두 줄이 없으면 추가: +```java +import com.elipair.spacestudyship.study.todo.entity.Todo; +import jakarta.persistence.EntityManager; +``` + +그리고 `@Autowired TodoRepository todoRepository;` 필드 바로 아래에 EntityManager 필드를 추가: +```java + @Autowired + EntityManager em; +``` + +그런 다음 클래스 끝(`}` 직전)에 다음 3개 테스트를 추가: +```java + @Test + @DisplayName("addActualMinutes: 본인 todo 누적 — null → 0+minutes, 기존 → 기존+minutes") + void addActualMinutes_accumulates() { + Todo t = Todo.create( + "t-1", 1L, "수학", + List.of("2026-05-25"), + List.of(), + 60); + todoRepository.saveAndFlush(t); + + int updated1 = todoRepository.addActualMinutes(1L, "t-1", 30); + assertThat(updated1).isEqualTo(1); + em.clear(); // 1차 캐시 비워서 DB 상태 재조회 + assertThat(todoRepository.findById("t-1").get().getActualMinutes()).isEqualTo(30); + + int updated2 = todoRepository.addActualMinutes(1L, "t-1", 45); + assertThat(updated2).isEqualTo(1); + em.clear(); + assertThat(todoRepository.findById("t-1").get().getActualMinutes()).isEqualTo(75); + } + + @Test + @DisplayName("addActualMinutes: 본인 소유 아님 → affected=0") + void addActualMinutes_otherUser_returnsZero() { + Todo t = Todo.create( + "t-1", 1L, "수학", + List.of("2026-05-25"), + List.of(), + 60); + todoRepository.saveAndFlush(t); + + int updated = todoRepository.addActualMinutes(2L, "t-1", 30); + assertThat(updated).isZero(); + } + + @Test + @DisplayName("addActualMinutes: 없는 todoId → affected=0") + void addActualMinutes_missingTodo_returnsZero() { + int updated = todoRepository.addActualMinutes(1L, "nope", 30); + assertThat(updated).isZero(); + } +``` + +- [ ] **Step 4.2: 테스트 실행 — 컴파일 실패 확인** + +Run: +```bash +./gradlew :SS-Study:test --tests "com.elipair.spacestudyship.study.todo.repository.TodoRepositoryTest" +``` +Expected: COMPILATION FAILED (addActualMinutes 메서드 없음). + +- [ ] **Step 4.3: TodoRepository에 addActualMinutes 쿼리 추가** + +`SS-Study/src/main/java/com/elipair/spacestudyship/study/todo/repository/TodoRepository.java`에 import와 메서드 추가: +```java +import org.springframework.data.jpa.repository.Modifying; +``` +(이미 있으면 생략) + +인터페이스 내 마지막 메서드 다음에: +```java + @Modifying + @Query("UPDATE Todo t SET t.actualMinutes = COALESCE(t.actualMinutes, 0) + :minutes " + + "WHERE t.id = :todoId AND t.userId = :userId") + int addActualMinutes(@Param("userId") Long userId, + @Param("todoId") String todoId, + @Param("minutes") int minutes); +``` + +- [ ] **Step 4.4: TodoService에 addActualMinutes 메서드 추가** + +`SS-Study/src/main/java/com/elipair/spacestudyship/study/todo/service/TodoService.java` 의 `delete()` 메서드 뒤에 추가: +```java + @Transactional + public void addActualMinutes(Long userId, String todoId, int minutes) { + if (minutes <= 0) { + throw new CustomException(ErrorCode.INVALID_INPUT_VALUE); + } + int updated = todoRepository.addActualMinutes(userId, todoId, minutes); + if (updated == 0) { + throw new CustomException(ErrorCode.TODO_NOT_FOUND); + } + log.info("[Todo] actualMinutes 누적 | userId={}, todoId={}, +{}분", userId, todoId, minutes); + } +``` + +- [ ] **Step 4.5: TodoService 단위 테스트 추가** + +`SS-Study/src/test/java/com/elipair/spacestudyship/study/todo/service/TodoServiceTest.java`의 클래스 끝(`}` 직전)에 추가: +```java + @Test + @org.junit.jupiter.api.DisplayName("addActualMinutes: 정상 흐름 — repository 호출 및 로그") + void addActualMinutes_success() { + org.mockito.BDDMockito.given(todoRepository.addActualMinutes(1L, "t-1", 30)) + .willReturn(1); + + todoService.addActualMinutes(1L, "t-1", 30); + + org.mockito.Mockito.verify(todoRepository).addActualMinutes(1L, "t-1", 30); + } + + @Test + @org.junit.jupiter.api.DisplayName("addActualMinutes: minutes <= 0 → INVALID_INPUT_VALUE") + void addActualMinutes_nonPositive_throws() { + org.assertj.core.api.Assertions + .assertThatThrownBy(() -> todoService.addActualMinutes(1L, "t-1", 0)) + .isInstanceOf(com.elipair.spacestudyship.common.exception.CustomException.class) + .extracting("errorCode") + .isEqualTo(com.elipair.spacestudyship.common.exception.ErrorCode.INVALID_INPUT_VALUE); + } + + @Test + @org.junit.jupiter.api.DisplayName("addActualMinutes: 영향 row 0 → TODO_NOT_FOUND") + void addActualMinutes_notFound_throws() { + org.mockito.BDDMockito.given(todoRepository.addActualMinutes(1L, "nope", 30)) + .willReturn(0); + + org.assertj.core.api.Assertions + .assertThatThrownBy(() -> todoService.addActualMinutes(1L, "nope", 30)) + .isInstanceOf(com.elipair.spacestudyship.common.exception.CustomException.class) + .extracting("errorCode") + .isEqualTo(com.elipair.spacestudyship.common.exception.ErrorCode.TODO_NOT_FOUND); + } +``` + +> 기존 `TodoServiceTest`의 필드 이름이 다르면(`todoRepository`/`todoService`) 그에 맞춰 조정. 미리 파일을 읽어 확인. + +- [ ] **Step 4.6: SS-Study 전체 테스트 실행 — 통과 확인** + +Run: +```bash +./gradlew :SS-Study:test +``` +Expected: BUILD SUCCESSFUL — Task 2~4의 모든 테스트 (entity, repository, service) 통과. + +만약 컴파일 에러가 남아있다면 import 누락 또는 필드명 mismatch 가능 — 메시지 보고 수정. + +- [ ] **Step 4.7: 커밋 (Task 3 + 4 통합)** + +```bash +git add SS-Study/src/main/java/com/elipair/spacestudyship/study/timer/dto/ \ + SS-Study/src/main/java/com/elipair/spacestudyship/study/timer/service/TimerSessionService.java \ + SS-Study/src/test/java/com/elipair/spacestudyship/study/timer/service/TimerSessionServiceTest.java \ + SS-Study/src/main/java/com/elipair/spacestudyship/study/todo/repository/TodoRepository.java \ + SS-Study/src/main/java/com/elipair/spacestudyship/study/todo/service/TodoService.java \ + SS-Study/src/test/java/com/elipair/spacestudyship/study/todo/repository/TodoRepositoryTest.java \ + SS-Study/src/test/java/com/elipair/spacestudyship/study/todo/service/TodoServiceTest.java +git commit -m "타이머 세션 도메인 구현 : feat : DTO + TimerSessionService + TodoService.addActualMinutes (검증/Idempotency/Streak 포함) #25" +``` + +--- + +## Task 5: TimerSessionController + Swagger + Controller 테스트 + +3개 엔드포인트 컨트롤러와 Swagger 문서, MockMvc 테스트 작성. + +**Files:** +- Create: `SS-Web/src/main/java/com/elipair/spacestudyship/controller/timer/TimerSessionController.java` +- Create: `SS-Web/src/test/java/com/elipair/spacestudyship/controller/timer/TimerSessionControllerTest.java` + +### Steps + +- [ ] **Step 5.1: Controller 테스트 작성** + +`SS-Web/src/test/java/com/elipair/spacestudyship/controller/timer/TimerSessionControllerTest.java`: +```java +package com.elipair.spacestudyship.controller.timer; + +import com.elipair.spacestudyship.auth.interceptor.LoginMember; +import com.elipair.spacestudyship.common.exception.CustomException; +import com.elipair.spacestudyship.common.exception.ErrorCode; +import com.elipair.spacestudyship.common.exception.GlobalExceptionHandler; +import com.elipair.spacestudyship.study.timer.dto.*; +import com.elipair.spacestudyship.study.timer.service.TimerSessionService; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.core.MethodParameter; +import org.springframework.http.MediaType; +import org.springframework.http.converter.json.MappingJackson2HttpMessageConverter; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.setup.MockMvcBuilders; +import org.springframework.validation.beanvalidation.LocalValidatorFactoryBean; +import org.springframework.web.method.support.HandlerMethodArgumentResolver; +import org.springframework.web.method.support.ModelAndViewContainer; + +import java.time.Instant; +import java.util.List; + +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.BDDMockito.given; +import static org.mockito.BDDMockito.willThrow; +import static org.mockito.Mockito.verify; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +@ExtendWith(MockitoExtension.class) +class TimerSessionControllerTest { + + @Mock TimerSessionService service; + @InjectMocks TimerSessionController controller; + + MockMvc mockMvc; + ObjectMapper objectMapper; + + @BeforeEach + void setUp() { + HandlerMethodArgumentResolver loginMemberStub = new HandlerMethodArgumentResolver() { + @Override public boolean supportsParameter(MethodParameter parameter) { + return parameter.getParameterType().equals(LoginMember.class); + } + @Override public Object resolveArgument(MethodParameter parameter, ModelAndViewContainer mavContainer, + org.springframework.web.context.request.NativeWebRequest webRequest, + org.springframework.web.bind.support.WebDataBinderFactory binderFactory) { + return new LoginMember(1L); + } + }; + + LocalValidatorFactoryBean validator = new LocalValidatorFactoryBean(); + validator.afterPropertiesSet(); + + objectMapper = new ObjectMapper(); + objectMapper.registerModule(new JavaTimeModule()); + objectMapper.disable(com.fasterxml.jackson.databind.SerializationFeature.WRITE_DATES_AS_TIMESTAMPS); + MappingJackson2HttpMessageConverter jsonConverter = new MappingJackson2HttpMessageConverter(objectMapper); + + mockMvc = MockMvcBuilders.standaloneSetup(controller) + .setControllerAdvice(new GlobalExceptionHandler()) + .setCustomArgumentResolvers(loginMemberStub) + .setValidator(validator) + .setMessageConverters(jsonConverter) + .build(); + } + + // ---------- POST 정상 ---------- + + @Test + @DisplayName("POST /api/timer-sessions — 201, { session, fuelCharged }") + void create_201() throws Exception { + TimerSessionResponse sessionRes = new TimerSessionResponse( + "sess-1", "todo-1", "수학", + Instant.parse("2026-05-25T01:00:00Z"), + Instant.parse("2026-05-25T02:30:00Z"), + 90); + given(service.create(eq(1L), any(TimerSessionCreateRequest.class), any())) + .willReturn(new TimerSessionCreateResponse(sessionRes, 90)); + + String body = """ + { + "todoId": "todo-1", + "todoTitle": "수학", + "startedAt": "2026-05-25T01:00:00Z", + "endedAt": "2026-05-25T02:30:00Z", + "durationMinutes": 90 + } + """; + + mockMvc.perform(post("/api/timer-sessions") + .contentType(MediaType.APPLICATION_JSON) + .content(body)) + .andExpect(status().isCreated()) + .andExpect(jsonPath("$.session.id").value("sess-1")) + .andExpect(jsonPath("$.session.durationMinutes").value(90)) + .andExpect(jsonPath("$.fuelCharged").value(90)); + } + + @Test + @DisplayName("POST: Idempotency-Key 헤더 → 서비스에 전달") + void create_idempotencyKeyPassThrough() throws Exception { + TimerSessionResponse sessionRes = new TimerSessionResponse( + "sess-1", null, null, + Instant.parse("2026-05-25T01:00:00Z"), + Instant.parse("2026-05-25T02:00:00Z"), + 60); + given(service.create(eq(1L), any(), eq("idem-abc"))) + .willReturn(new TimerSessionCreateResponse(sessionRes, 60)); + + String body = """ + {"startedAt":"2026-05-25T01:00:00Z","endedAt":"2026-05-25T02:00:00Z","durationMinutes":60} + """; + + mockMvc.perform(post("/api/timer-sessions") + .header("Idempotency-Key", "idem-abc") + .contentType(MediaType.APPLICATION_JSON) + .content(body)) + .andExpect(status().isCreated()); + + verify(service).create(eq(1L), any(), eq("idem-abc")); + } + + @Test + @DisplayName("POST: 비즈니스 검증 실패 (FUTURE_SESSION) → 400 + code") + void create_futureSession_400() throws Exception { + willThrow(new CustomException(ErrorCode.FUTURE_SESSION)) + .given(service).create(eq(1L), any(), any()); + + String body = """ + {"startedAt":"2030-01-01T00:00:00Z","endedAt":"2030-01-01T01:00:00Z","durationMinutes":60} + """; + + mockMvc.perform(post("/api/timer-sessions") + .contentType(MediaType.APPLICATION_JSON) + .content(body)) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.code").value("FUTURE_SESSION")); + } + + @Test + @DisplayName("POST: NotNull 위반 (durationMinutes 누락) → 400 INVALID_INPUT_VALUE") + void create_missingField_400() throws Exception { + String body = """ + {"startedAt":"2026-05-25T01:00:00Z","endedAt":"2026-05-25T02:00:00Z"} + """; + + mockMvc.perform(post("/api/timer-sessions") + .contentType(MediaType.APPLICATION_JSON) + .content(body)) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.code").value("INVALID_INPUT_VALUE")); + } + + @Test + @DisplayName("POST: 본문 파싱 실패 → 400 INVALID_REQUEST_BODY") + void create_malformedBody_400() throws Exception { + mockMvc.perform(post("/api/timer-sessions") + .contentType(MediaType.APPLICATION_JSON) + .content("{not json")) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.code").value("INVALID_REQUEST_BODY")); + } + + @Test + @DisplayName("POST: TODO_NOT_FOUND → 404") + void create_todoNotFound_404() throws Exception { + willThrow(new CustomException(ErrorCode.TODO_NOT_FOUND)) + .given(service).create(eq(1L), any(), any()); + + String body = """ + {"todoId":"nope","startedAt":"2026-05-25T01:00:00Z","endedAt":"2026-05-25T02:00:00Z","durationMinutes":60} + """; + + mockMvc.perform(post("/api/timer-sessions") + .contentType(MediaType.APPLICATION_JSON) + .content(body)) + .andExpect(status().isNotFound()) + .andExpect(jsonPath("$.code").value("TODO_NOT_FOUND")); + } + + // ---------- GET 목록 ---------- + + @Test + @DisplayName("GET /api/timer-sessions — 200, Page envelope, 인자 그대로 전달") + void getList_200() throws Exception { + given(service.getList(eq(1L), eq("2026-05-20"), eq("2026-05-25"), eq("t-1"), eq(0), eq(20))) + .willReturn(new TimerSessionListResponse( + List.of(new TimerSessionResponse( + "sess-1", "t-1", "수학", + Instant.parse("2026-05-25T01:00:00Z"), + Instant.parse("2026-05-25T02:00:00Z"), + 60)), + 0, 20, 1L, 1)); + + mockMvc.perform(get("/api/timer-sessions") + .param("startDate", "2026-05-20") + .param("endDate", "2026-05-25") + .param("todoId", "t-1")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.content[0].id").value("sess-1")) + .andExpect(jsonPath("$.totalElements").value(1)); + } + + @Test + @DisplayName("GET: 잘못된 날짜 포맷 → 400") + void getList_badDate_400() throws Exception { + mockMvc.perform(get("/api/timer-sessions").param("startDate", "2026-13-01")) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.code").value("INVALID_INPUT_VALUE")); + } + + @Test + @DisplayName("GET: size > 100 → 400") + void getList_sizeOverMax_400() throws Exception { + mockMvc.perform(get("/api/timer-sessions").param("size", "200")) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.code").value("INVALID_INPUT_VALUE")); + } + + @Test + @DisplayName("GET: page < 0 → 400") + void getList_negativePage_400() throws Exception { + mockMvc.perform(get("/api/timer-sessions").param("page", "-1")) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.code").value("INVALID_INPUT_VALUE")); + } + + // ---------- today-stats ---------- + + @Test + @DisplayName("GET /api/timer-sessions/today-stats — 200") + void todayStats_200() throws Exception { + given(service.getTodayStats(1L)) + .willReturn(new TodayStatsResponse(180, 3, 7)); + + mockMvc.perform(get("/api/timer-sessions/today-stats")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.totalMinutes").value(180)) + .andExpect(jsonPath("$.sessionCount").value(3)) + .andExpect(jsonPath("$.streak").value(7)); + } +} +``` + +- [ ] **Step 5.2: 테스트 실행 — 컴파일 실패 확인** + +Run: +```bash +./gradlew :SS-Web:test --tests "com.elipair.spacestudyship.controller.timer.TimerSessionControllerTest" +``` +Expected: COMPILATION FAILED (TimerSessionController 없음). + +- [ ] **Step 5.3: TimerSessionController 구현** + +`SS-Web/src/main/java/com/elipair/spacestudyship/controller/timer/TimerSessionController.java`: +```java +package com.elipair.spacestudyship.controller.timer; + +import com.elipair.spacestudyship.auth.interceptor.AuthMember; +import com.elipair.spacestudyship.auth.interceptor.LoginMember; +import com.elipair.spacestudyship.common.exception.CustomException; +import com.elipair.spacestudyship.common.exception.ErrorCode; +import com.elipair.spacestudyship.common.exception.ErrorResponse; +import com.elipair.spacestudyship.study.timer.dto.TimerSessionCreateRequest; +import com.elipair.spacestudyship.study.timer.dto.TimerSessionCreateResponse; +import com.elipair.spacestudyship.study.timer.dto.TimerSessionListResponse; +import com.elipair.spacestudyship.study.timer.dto.TodayStatsResponse; +import com.elipair.spacestudyship.study.timer.service.TimerSessionService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.ExampleObject; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.responses.ApiResponses; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +import java.time.LocalDate; +import java.time.format.DateTimeParseException; + +@Tag(name = "Timer", description = "공부 타이머 세션 API") +@RestController +@RequiredArgsConstructor +@RequestMapping("/api/timer-sessions") +public class TimerSessionController { + + private final TimerSessionService timerSessionService; + + @Operation(summary = "세션 기록 저장", + description = """ + 타이머 종료 시 세션을 저장합니다. + 서버에서 시간 유효성 5단계 검증 후, 통과 시 연료를 자동 충전하고 + 연결된 Todo의 actualMinutes를 누적합니다 (단일 트랜잭션). + + ### Idempotency + 헤더 `Idempotency-Key`를 보내면 동일 키 재요청 시 기존 세션을 반환합니다 (중복 충전 방지). + """) + @ApiResponses({ + @ApiResponse(responseCode = "201", description = "저장 성공", + content = @Content(mediaType = "application/json", + schema = @Schema(implementation = TimerSessionCreateResponse.class), + examples = @ExampleObject(value = """ + { + "session": { + "id":"sess-uuid", + "todoId":"todo-1", + "todoTitle":"수학", + "startedAt":"2026-05-25T01:00:00Z", + "endedAt":"2026-05-25T02:30:00Z", + "durationMinutes":90 + }, + "fuelCharged":90 + } + """))), + @ApiResponse(responseCode = "400", description = "검증 실패", + content = @Content(schema = @Schema(implementation = ErrorResponse.class), + examples = { + @ExampleObject(name = "INVALID_SESSION_TIME", value = "{\"code\":\"INVALID_SESSION_TIME\",\"message\":\"시작 시각이 종료 시각보다 늦거나 같습니다.\"}"), + @ExampleObject(name = "INVALID_DURATION", value = "{\"code\":\"INVALID_DURATION\",\"message\":\"공부 시간이 시작/종료 시각 간격보다 큽니다.\"}"), + @ExampleObject(name = "SESSION_TOO_SHORT", value = "{\"code\":\"SESSION_TOO_SHORT\",\"message\":\"공부 시간은 1분 이상이어야 합니다.\"}"), + @ExampleObject(name = "SESSION_TOO_LONG", value = "{\"code\":\"SESSION_TOO_LONG\",\"message\":\"공부 시간은 24시간(1440분)을 초과할 수 없습니다.\"}"), + @ExampleObject(name = "FUTURE_SESSION", value = "{\"code\":\"FUTURE_SESSION\",\"message\":\"미래 시각의 세션은 저장할 수 없습니다.\"}") + })), + @ApiResponse(responseCode = "401", description = "인증 필요", + content = @Content(schema = @Schema(implementation = ErrorResponse.class))), + @ApiResponse(responseCode = "404", description = "연결된 Todo가 본인 소유 아님 / 존재하지 않음", + content = @Content(schema = @Schema(implementation = ErrorResponse.class), + examples = @ExampleObject(value = "{\"code\":\"TODO_NOT_FOUND\",\"message\":\"해당 할 일을 찾을 수 없습니다.\"}"))), + @ApiResponse(responseCode = "500", description = "서버 오류") + }) + @PostMapping + public ResponseEntity create( + @AuthMember LoginMember loginMember, + @Valid @RequestBody TimerSessionCreateRequest request, + @RequestHeader(value = "Idempotency-Key", required = false) String idempotencyKey) { + + TimerSessionCreateResponse response = timerSessionService.create( + loginMember.memberId(), request, idempotencyKey); + return ResponseEntity.status(HttpStatus.CREATED).body(response); + } + + @Operation(summary = "세션 목록 조회", + description = """ + ### Query Parameters + - startDate / endDate: YYYY-MM-DD (선택). 종료일 포함 반열림 [start, end+1) + - todoId: 특정 Todo에 연결된 세션만 (선택) + - page: 기본 0 + - size: 기본 20, 최대 100 + + 정렬: startedAt 내림차순 (최신순) 고정. + """) + @ApiResponses({ + @ApiResponse(responseCode = "200", description = "조회 성공", + content = @Content(mediaType = "application/json", + schema = @Schema(implementation = TimerSessionListResponse.class))), + @ApiResponse(responseCode = "400", description = "잘못된 query parameter", + content = @Content(schema = @Schema(implementation = ErrorResponse.class), + examples = @ExampleObject(value = "{\"code\":\"INVALID_INPUT_VALUE\",\"message\":\"입력값이 유효하지 않습니다.\"}"))), + @ApiResponse(responseCode = "401", description = "인증 필요") + }) + @GetMapping + public ResponseEntity getList( + @AuthMember LoginMember loginMember, + @RequestParam(required = false) String startDate, + @RequestParam(required = false) String endDate, + @RequestParam(required = false) String todoId, + @RequestParam(defaultValue = "0") Integer page, + @RequestParam(defaultValue = "20") Integer size) { + + validateDateParam(startDate); + validateDateParam(endDate); + if (page < 0) throw new CustomException(ErrorCode.INVALID_INPUT_VALUE); + if (size < 1 || size > 100) throw new CustomException(ErrorCode.INVALID_INPUT_VALUE); + + return ResponseEntity.ok(timerSessionService.getList( + loginMember.memberId(), startDate, endDate, todoId, page, size)); + } + + @Operation(summary = "오늘 공부 통계", + description = "KST(Asia/Seoul) 기준 오늘의 총 분 / 세션 수 / 연속 일수(streak)") + @ApiResponses({ + @ApiResponse(responseCode = "200", description = "조회 성공", + content = @Content(mediaType = "application/json", + schema = @Schema(implementation = TodayStatsResponse.class), + examples = @ExampleObject(value = "{\"totalMinutes\":180,\"sessionCount\":3,\"streak\":7}"))), + @ApiResponse(responseCode = "401", description = "인증 필요") + }) + @GetMapping("/today-stats") + public ResponseEntity getTodayStats( + @AuthMember LoginMember loginMember) { + return ResponseEntity.ok( + timerSessionService.getTodayStats(loginMember.memberId())); + } + + private void validateDateParam(String date) { + if (date == null) return; + try { LocalDate.parse(date); } + catch (DateTimeParseException e) { + throw new CustomException(ErrorCode.INVALID_INPUT_VALUE); + } + } +} +``` + +- [ ] **Step 5.4: 테스트 실행 — 통과 확인** + +Run: +```bash +./gradlew :SS-Web:test --tests "com.elipair.spacestudyship.controller.timer.TimerSessionControllerTest" +``` +Expected: 12 tests passed. + +- [ ] **Step 5.5: 전체 빌드 검증** + +Run: +```bash +./gradlew build +``` +Expected: BUILD SUCCESSFUL. + +- [ ] **Step 5.6: 커밋** + +```bash +git add SS-Web/src/main/java/com/elipair/spacestudyship/controller/timer/TimerSessionController.java \ + SS-Web/src/test/java/com/elipair/spacestudyship/controller/timer/TimerSessionControllerTest.java +git commit -m "타이머 세션 도메인 구현 : feat : TimerSessionController + Swagger 풀세트 + MockMvc 테스트 #25" +``` + +--- + +## Task 6: CLAUDE.md 마이그레이션 이력 업데이트 + +**Files:** +- Modify: `CLAUDE.md` + +### Steps + +- [ ] **Step 6.1: 마이그레이션 표에 0.0.39 row 추가** + +`CLAUDE.md`의 `### 현재 마이그레이션 이력` 표 끝에 다음 row 추가 (마지막 `| 0.0.36 | ...` 줄 다음): +```markdown +| 0.0.39 | `V0_0_39__add_timer_sessions.sql` | `timer_sessions` 테이블 생성 (FK CASCADE, CHECK 제약 3종, 부분 unique 인덱스 on idempotency_key) | +``` + +- [ ] **Step 6.2: 커밋** + +```bash +git add CLAUDE.md +git commit -m "타이머 세션 도메인 구현 : docs : CLAUDE.md 마이그레이션 이력 표에 0.0.39 추가 #25" +``` + +--- + +## Final Verification + +- [ ] **F.1: 전체 테스트 실행** + +```bash +./gradlew clean test +``` +Expected: BUILD SUCCESSFUL. (모든 모듈, 모든 테스트 통과 — Testcontainers 기동 포함 ~3분) + +- [ ] **F.2: 애플리케이션 기동 + 수동 Swagger 검증 (선택)** + +```bash +./gradlew :SS-Web:bootRun +``` +브라우저로 `http://localhost:8080/swagger-ui.html` 접속하여: +- `Timer` 태그에 3개 엔드포인트 노출 확인 +- POST 시 정상/검증 실패 응답 매트릭스 확인 +- Idempotency-Key 헤더 옵션 확인 +- today-stats 응답 형식 확인 + +- [ ] **F.3: 커밋 히스토리 확인** + +```bash +git log --oneline -10 +``` +Expected: 6개 신규 커밋 — chore, feat (entity/repo), feat (dto/service+todo), feat (controller), docs, plus the docs spec/plan commits. + +--- + +## Notes for the Engineer + +### TDD 흐름 요약 +각 Task는 다음 사이클을 반복: +1. 테스트 작성 (RED) +2. `./gradlew :SS-X:test --tests "패키지.클래스"`로 실패 확인 +3. 최소 구현 (GREEN) +4. 테스트 재실행, 통과 확인 +5. (선택) 리팩토링 +6. 커밋 + +### 통합 의존성 주의 +- Task 3 (TimerSessionService)는 Task 4 (TodoService.addActualMinutes)에 컴파일 의존. 두 Task를 한 사이클로 묶어 마지막에 함께 빌드/테스트 실행 → 둘 다 통과하면 커밋 +- Task 2의 Repository 통합 테스트는 Testcontainers 사용 → 첫 실행 시 PostgreSQL 16 이미지 다운로드 발생 (1회성, 수 분 소요 가능) + +### 컨벤션 주의 (CLAUDE.md) +- 커밋 메시지: `타이머 세션 도메인 구현 : {type} : {설명} #25` (이모지 금지) +- DTO는 Record, Entity는 `@Builder` + protected NoArgsConstructor +- DTO 위치: SS-Study의 `study/timer/dto/` (Controller/Service 공유) +- Controller는 SS-Web에만 위치 +- 한 version.yml 버전당 마이그레이션 파일 1개만 + +### 이미 해결된 의존성 (Spec 섹션 2.3 참조) +- `FuelService.charge(userId, amount, reason, referenceId, transactionId)` — 이미 idempotency 처리 完 +- `FuelReason.STUDY_SESSION` enum + CHECK 제약 이미 존재 +- `MemberCreatedEvent` 기반 신규 회원 UserFuel 초기화 이미 동작 (FUEL_NOT_INITIALIZED 노출 가능성 낮음) +- `BaseTimeEntity` 의 `@CreationTimestamp`/`@UpdateTimestamp` — TimerSession 응답에는 createdAt 미포함이므로 #36 flush 이슈 해당 없음 + +### 실패/막힘 시 행동 +- 테스트 실행 결과 OUTPUT을 읽고 어디가 실패하는지 정확히 보기 +- import 누락 → IDE 자동 import 또는 메시지 참고 +- Testcontainers 기동 실패 → Docker daemon 실행 여부 확인 +- 컴파일 막힘이 의존 Task 누락 때문이라면 의존 Task 진행 후 재시도 +- 동시성 race 테스트가 가끔 깜빡이면 — 실제 race는 production에서만 의미. 테스트는 mock으로 시뮬레이션이므로 결정적이어야 함. 시퀀스 검토. diff --git a/docs/superpowers/plans/2026-05-25-todo-timestamps-implementation.md b/docs/superpowers/plans/2026-05-25-todo-timestamps-implementation.md new file mode 100644 index 0000000..fb2ae1d --- /dev/null +++ b/docs/superpowers/plans/2026-05-25-todo-timestamps-implementation.md @@ -0,0 +1,509 @@ +# Todo / TodoCategory 응답 timestamps 보장 구현 계획 (revised) + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Todo / TodoCategory 응답의 `createdAt`/`updatedAt`이 항상 ISO-8601 문자열로 채워져 내려가도록 보장한다. + +**Architecture:** `TodoService` / `TodoCategoryService`의 create / update 경로에 `EntityManager.flush()` 호출을 추가해 응답 직전에 Hibernate가 INSERT/UPDATE를 실행하고 `@CreationTimestamp` / `@UpdateTimestamp`를 채우도록 강제한다. Entity는 변경하지 않는다. + +**Tech Stack:** Java 21, Spring Boot 4.0.2, Hibernate (`@CreationTimestamp`/`@UpdateTimestamp` via `BaseTimeEntity`), Gradle 멀티모듈 (`SS-Common`, `SS-Study`, `SS-Web`), JUnit 5 + Mockito + AssertJ, Postgres + Flyway, JSONB 컬럼. + +**Spec:** `docs/superpowers/specs/2026-05-25-todo-timestamps-design.md` + +--- + +## 사전 준비 + +- [ ] **Step 0.1: 작업 디렉토리 + 베이스라인 확인** + +```bash +pwd +# 기대: /Users/luca/workspace/Java_Spring/space_study_ship +git status --short +# 기대: 비어 있음 (clean working tree) +./gradlew :SS-Study:test +# 기대: BUILD SUCCESSFUL +``` + +베이스라인이 깨져 있거나 working tree가 dirty면 멈추고 controller에게 보고. + +--- + +## Task 1 — `TodoService`에 EntityManager 주입 + create/update에 flush() 추가 + +**Files:** +- Modify: `SS-Study/src/main/java/com/elipair/spacestudyship/study/todo/service/TodoService.java` +- Modify: `SS-Study/src/test/java/com/elipair/spacestudyship/study/todo/service/TodoServiceTest.java` + +### TDD 사이클 + +- [ ] **Step 1.1: 실패할 단위 테스트 2개 작성 (create + update)** + +`TodoServiceTest.java`를 수정한다. + +(a) imports 영역에 추가: + +```java +import jakarta.persistence.EntityManager; +``` + +(b) 클래스 필드 `@InjectMocks TodoService todoService;` **직전**에 mock 추가: + +```java + @Mock EntityManager entityManager; +``` + +(c) 클래스 안에 새 테스트 2개 추가 (마지막 닫는 `}` 직전): + +```java + @Test + @DisplayName("create: save 후 EntityManager.flush() 호출 — createdAt/updatedAt 보장") + void create_flushesAfterSave() { + var request = new com.elipair.spacestudyship.study.todo.dto.TodoCreateRequest( + "t-new", "수학", java.util.List.of(), null, java.util.List.of("2026-05-25")); + when(todoRepository.existsById("t-new")).thenReturn(false); + when(todoRepository.save(org.mockito.ArgumentMatchers.any(Todo.class))) + .thenAnswer(inv -> inv.getArgument(0)); + + todoService.create(1L, request); + + verify(entityManager).flush(); + } + + @Test + @DisplayName("update: mutation 후 EntityManager.flush() 호출 — updatedAt 갱신 보장") + void update_flushesAfterMutation() { + Todo existing = Todo.create("t1", 1L, "원본", null, null, null); + when(todoRepository.findByIdAndUserId("t1", 1L)) + .thenReturn(java.util.Optional.of(existing)); + + var request = new com.elipair.spacestudyship.study.todo.dto.TodoUpdateRequest( + "새 제목", null, null, null, null, null); + + todoService.update(1L, "t1", request); + + verify(entityManager).flush(); + } +``` + +- [ ] **Step 1.2: 테스트 실행 → FAIL 확인** + +```bash +./gradlew :SS-Study:test --tests "com.elipair.spacestudyship.study.todo.service.TodoServiceTest.create_flushesAfterSave" --tests "com.elipair.spacestudyship.study.todo.service.TodoServiceTest.update_flushesAfterMutation" +``` + +기대: 둘 다 `Wanted but not invoked: entityManager.flush();` (Mockito assertion). + +- [ ] **Step 1.3: `TodoService`에 EntityManager 주입 + flush() 호출 추가** + +`SS-Study/src/main/java/com/elipair/spacestudyship/study/todo/service/TodoService.java`를 수정. + +(a) imports 영역에 추가: + +```java +import jakarta.persistence.EntityManager; +``` + +(b) 필드 영역에 추가 (`private final TodoCategoryRepository categoryRepository;` 다음 줄): + +```java + private final EntityManager entityManager; +``` + +(c) `create()` 메서드: `Todo saved = todoRepository.save(todo);` **다음 줄**에 추가: + +```java + entityManager.flush(); +``` + +최종 `create()` 모양: + +```java + @Transactional + public TodoResponse create(Long userId, TodoCreateRequest request) { + String id = request.id() != null ? request.id() : UUID.randomUUID().toString(); + if (todoRepository.existsById(id)) { + throw new CustomException(ErrorCode.TODO_ALREADY_EXISTS); + } + validateCategoryIds(userId, request.categoryIds()); + + Todo todo = Todo.create( + id, userId, request.title(), + request.scheduledDates(), + request.categoryIds(), + request.estimatedMinutes()); + Todo saved = todoRepository.save(todo); + entityManager.flush(); + log.info("[Todo] 생성 | userId={}, todoId={}", userId, saved.getId()); + return TodoResponse.from(saved); + } +``` + +(d) `update()` 메서드: 모든 `todo.updateXxx(...)` 호출 **다음**, `log.info(...)` **직전**에 추가: + +```java + entityManager.flush(); +``` + +최종 `update()` 모양: + +```java + @Transactional + public TodoResponse update(Long userId, String todoId, + com.elipair.spacestudyship.study.todo.dto.TodoUpdateRequest request) { + Todo todo = todoRepository.findByIdAndUserId(todoId, userId) + .orElseThrow(() -> new CustomException(ErrorCode.TODO_NOT_FOUND)); + + if (request.categoryIds() != null) { + validateCategoryIds(userId, request.categoryIds()); + todo.updateCategoryIds(request.categoryIds()); + } + if (request.title() != null) todo.updateTitle(request.title()); + if (request.scheduledDates() != null) todo.updateScheduledDates(request.scheduledDates()); + if (request.completedDates() != null) todo.updateCompletedDates(request.completedDates()); + if (request.estimatedMinutes() != null) todo.updateEstimatedMinutes(request.estimatedMinutes()); + if (request.actualMinutes() != null) todo.updateActualMinutes(request.actualMinutes()); + + entityManager.flush(); + log.info("[Todo] 수정 | userId={}, todoId={}", userId, todoId); + return TodoResponse.from(todo); + } +``` + +- [ ] **Step 1.4: 테스트 실행 → PASS 확인** + +```bash +./gradlew :SS-Study:test --tests "com.elipair.spacestudyship.study.todo.service.TodoServiceTest.create_flushesAfterSave" --tests "com.elipair.spacestudyship.study.todo.service.TodoServiceTest.update_flushesAfterMutation" +``` + +기대: BUILD SUCCESSFUL. + +- [ ] **Step 1.5: TodoServiceTest 전체 회귀 + Todo 모듈 회귀 확인** + +```bash +./gradlew :SS-Study:test --tests "com.elipair.spacestudyship.study.todo.*" +``` + +기대: BUILD SUCCESSFUL. 기존 테스트(create/update/delete/findAll 등)들이 `EntityManager` mock 추가 영향 없이 모두 통과해야 한다 — `entityManager.flush()`는 mock에서 no-op이므로 동작 동일. + +특히 `update_notFound`가 통과해야 함 — `entityManager.flush()`는 `orElseThrow` 이후에 호출되므로 예외 케이스에선 도달하지 않음. + +- [ ] **Step 1.6: 커밋** + +```bash +git add SS-Study/src/main/java/com/elipair/spacestudyship/study/todo/service/TodoService.java \ + SS-Study/src/test/java/com/elipair/spacestudyship/study/todo/service/TodoServiceTest.java +git commit -m "Todo 응답 createdAt/updatedAt null 반환 문제 : fix : TodoService create/update에 EntityManager.flush() 추가로 응답 직전 timestamp 채워짐 보장" +``` + +CLAUDE.md 규칙: 메시지 형식 `{이슈제목} : {type} : {설명}`, 이모지 금지, Co-Authored-By 금지. + +--- + +## Task 2 — `TodoCategoryService`에 동일 패턴 적용 + +**Files:** +- Modify: `SS-Study/src/main/java/com/elipair/spacestudyship/study/todo/service/TodoCategoryService.java` +- Modify: `SS-Study/src/test/java/com/elipair/spacestudyship/study/todo/service/TodoCategoryServiceTest.java` + +### TDD 사이클 + +- [ ] **Step 2.1: 실패할 단위 테스트 2개 작성** + +`TodoCategoryServiceTest.java`를 수정. + +(a) imports 추가: + +```java +import jakarta.persistence.EntityManager; +import static org.mockito.Mockito.verify; +``` + +기존 `org.mockito.Mockito.verify(...)`를 fully-qualified로 호출하는 라인이 있어도 static import는 안전하게 공존 가능. 새 테스트에서는 짧은 `verify` 사용. + +(b) 클래스 필드에 mock 추가 (`@InjectMocks` 직전): + +```java + @Mock EntityManager entityManager; +``` + +(c) 클래스 안에 새 테스트 2개 추가 (마지막 닫는 `}` 직전): + +```java + @Test + @DisplayName("create: save 후 EntityManager.flush() 호출 — createdAt/updatedAt 보장") + void create_flushesAfterSave() { + var request = new CategoryCreateRequest("c-new", "수학", "math_icon", 0.3, 0.5); + when(categoryRepository.existsById("c-new")).thenReturn(false); + when(categoryRepository.save(any(TodoCategory.class))) + .thenAnswer(inv -> inv.getArgument(0)); + + categoryService.create(1L, request); + + verify(entityManager).flush(); + } + + @Test + @DisplayName("update: mutation 후 EntityManager.flush() 호출 — updatedAt 갱신 보장") + void update_flushesAfterMutation() { + TodoCategory existing = TodoCategory.create("c1", 1L, "원본", "icon", 0.3, 0.5); + when(categoryRepository.findByIdAndUserId("c1", 1L)) + .thenReturn(Optional.of(existing)); + + var request = new CategoryUpdateRequest("새이름", null, null, null); + categoryService.update(1L, "c1", request); + + verify(entityManager).flush(); + } +``` + +- [ ] **Step 2.2: 테스트 실행 → FAIL 확인** + +```bash +./gradlew :SS-Study:test --tests "com.elipair.spacestudyship.study.todo.service.TodoCategoryServiceTest.create_flushesAfterSave" --tests "com.elipair.spacestudyship.study.todo.service.TodoCategoryServiceTest.update_flushesAfterMutation" +``` + +기대: 둘 다 `Wanted but not invoked: entityManager.flush();`. + +- [ ] **Step 2.3: `TodoCategoryService`에 EntityManager 주입 + flush() 호출 추가** + +`SS-Study/src/main/java/com/elipair/spacestudyship/study/todo/service/TodoCategoryService.java`를 수정. + +(a) imports 추가: + +```java +import jakarta.persistence.EntityManager; +``` + +(b) 필드 추가 (`private final TodoRepository todoRepository;` 다음 줄): + +```java + private final EntityManager entityManager; +``` + +(c) `create()` 메서드: `TodoCategory saved = categoryRepository.save(category);` 다음 줄에 추가: + +```java + entityManager.flush(); +``` + +(d) `update()` 메서드: 모든 `category.updateXxx(...)` 호출 다음, `log.info(...)` 직전에 추가: + +```java + entityManager.flush(); +``` + +최종 메서드 모양: + +```java + @Transactional + public CategoryResponse create(Long userId, CategoryCreateRequest request) { + String id = request.id() != null ? request.id() : UUID.randomUUID().toString(); + if (categoryRepository.existsById(id)) { + throw new CustomException(ErrorCode.CATEGORY_ALREADY_EXISTS); + } + TodoCategory category = TodoCategory.create( + id, userId, request.name(), + request.iconId(), request.positionX(), request.positionY()); + TodoCategory saved = categoryRepository.save(category); + entityManager.flush(); + log.info("[TodoCategory] 생성 | userId={}, categoryId={}", userId, saved.getId()); + return CategoryResponse.from(saved); + } + + @Transactional + public CategoryResponse update(Long userId, String categoryId, CategoryUpdateRequest request) { + TodoCategory category = categoryRepository.findByIdAndUserId(categoryId, userId) + .orElseThrow(() -> new CustomException(ErrorCode.CATEGORY_NOT_FOUND)); + if (request.name() != null) category.updateName(request.name()); + if (request.iconId() != null) category.updateIconId(request.iconId()); + if (request.positionX() != null) category.updatePositionX(request.positionX()); + if (request.positionY() != null) category.updatePositionY(request.positionY()); + entityManager.flush(); + log.info("[TodoCategory] 수정 | userId={}, categoryId={}", userId, categoryId); + return CategoryResponse.from(category); + } +``` + +`delete()`는 변경 없음. + +- [ ] **Step 2.4: 테스트 실행 → PASS 확인** + +```bash +./gradlew :SS-Study:test --tests "com.elipair.spacestudyship.study.todo.service.TodoCategoryServiceTest.create_flushesAfterSave" --tests "com.elipair.spacestudyship.study.todo.service.TodoCategoryServiceTest.update_flushesAfterMutation" +``` + +기대: BUILD SUCCESSFUL. + +- [ ] **Step 2.5: 회귀 확인** + +```bash +./gradlew :SS-Study:test --tests "com.elipair.spacestudyship.study.todo.*" +``` + +기대: BUILD SUCCESSFUL. + +- [ ] **Step 2.6: 커밋** + +```bash +git add SS-Study/src/main/java/com/elipair/spacestudyship/study/todo/service/TodoCategoryService.java \ + SS-Study/src/test/java/com/elipair/spacestudyship/study/todo/service/TodoCategoryServiceTest.java +git commit -m "Todo 응답 createdAt/updatedAt null 반환 문제 : fix : TodoCategoryService create/update에 EntityManager.flush() 추가" +``` + +--- + +## Task 3 — Repository 통합 회귀 테스트 추가 (Hibernate flush-time timestamp 동작 알람) + +**Files:** +- Modify: `SS-Study/src/test/java/com/elipair/spacestudyship/study/todo/repository/TodoRepositoryTest.java` +- Modify: `SS-Study/src/test/java/com/elipair/spacestudyship/study/todo/repository/TodoCategoryRepositoryTest.java` + +이 task의 목적은 production 동작 변경이 아니라 **Hibernate 버전 업그레이드 등으로 flush-time `@CreationTimestamp` 동작이 바뀌면 즉시 알람**을 받기 위함. + +- [ ] **Step 3.1: TodoRepositoryTest에 회귀 테스트 추가** + +`TodoRepositoryTest` 클래스 안에 추가: + +```java +@Test +@DisplayName("saveAndFlush: assigned-ID Todo의 timestamp가 flush 후 채워짐 (Hibernate 회귀 알람)") +void saveAndFlush_populatesTimestamps() { + Todo saved = todoRepository.saveAndFlush(Todo.create("t-ts", 1L, "X", null, null, null)); + + assertThat(saved.getCreatedAt()).isNotNull(); + assertThat(saved.getUpdatedAt()).isNotNull(); +} +``` + +(imports는 기존에 모두 존재) + +- [ ] **Step 3.2: TodoCategoryRepositoryTest에 동일 추가** + +```java +@Test +@DisplayName("saveAndFlush: assigned-ID TodoCategory의 timestamp가 flush 후 채워짐 (Hibernate 회귀 알람)") +void saveAndFlush_populatesTimestamps() { + TodoCategory saved = categoryRepository.saveAndFlush( + TodoCategory.create("c-ts", 1L, "수학", null, null, null)); + + assertThat(saved.getCreatedAt()).isNotNull(); + assertThat(saved.getUpdatedAt()).isNotNull(); +} +``` + +- [ ] **Step 3.3: 두 테스트 모두 PASS 확인** + +```bash +./gradlew :SS-Study:test --tests "com.elipair.spacestudyship.study.todo.repository.TodoRepositoryTest.saveAndFlush_populatesTimestamps" --tests "com.elipair.spacestudyship.study.todo.repository.TodoCategoryRepositoryTest.saveAndFlush_populatesTimestamps" +``` + +기대: 둘 다 PASS. 이전 implementer diagnostic에서 saveAndFlush로 PASS 확인됨. + +만약 FAIL이면 Hibernate timestamp 메커니즘 자체가 달라진 것 → 멈추고 controller에게 보고. + +- [ ] **Step 3.4: 회귀 확인** + +```bash +./gradlew :SS-Study:test --tests "com.elipair.spacestudyship.study.todo.*" +``` + +기대: BUILD SUCCESSFUL. + +- [ ] **Step 3.5: 커밋** + +```bash +git add SS-Study/src/test/java/com/elipair/spacestudyship/study/todo/repository/TodoRepositoryTest.java \ + SS-Study/src/test/java/com/elipair/spacestudyship/study/todo/repository/TodoCategoryRepositoryTest.java +git commit -m "Todo 응답 createdAt/updatedAt null 반환 문제 : test : Hibernate flush-time timestamp 동작 회귀 알람 테스트 추가" +``` + +--- + +## Task 4 — 통합 검증 (전체 빌드 + 수동 curl) + +**Files:** 없음 (검증만) + +- [ ] **Step 4.1: 전체 테스트 실행** + +```bash +./gradlew :SS-Common:test :SS-Auth:test :SS-Member:test :SS-Study:test :SS-Web:test +``` + +기대: BUILD SUCCESSFUL across all modules. + +SS-Web의 controller 테스트는 `@Mock TodoService` 사용이라 service 시그니처 변경 영향 없음. 만약 깨지면 다른 원인이므로 멈추고 controller에게 보고. + +- [ ] **Step 4.2: 서버 기동 → 수동 curl 검증 (사용자가 직접 수행)** + +본 단계는 백엔드 빌드 결과를 실제 HTTP로 검증한다. 에이전트가 자동으로 실행하지 말고 사용자에게 다음을 안내한다: + +```bash +./gradlew :SS-Web:bootRun +``` + +(다른 터미널에서) + +```bash +TOKEN="<로그인 토큰>" + +curl -s -X POST http://localhost:8080/api/todos \ + -H "Authorization: Bearer $TOKEN" \ + -H "Content-Type: application/json" \ + -d '{"id":"11111111-1111-1111-1111-111111111111","title":"테스트","scheduledDates":["2026-05-25"]}' \ + | jq '{createdAt,updatedAt}' +# 기대: 둘 다 ISO-8601 UTC 문자열 (예: "2026-05-25T03:14:15.123Z"), null 아님 + +curl -s -X PATCH http://localhost:8080/api/todos/11111111-1111-1111-1111-111111111111 \ + -H "Authorization: Bearer $TOKEN" \ + -H "Content-Type: application/json" \ + -d '{"title":"수정"}' \ + | jq '{createdAt,updatedAt}' +# 기대: createdAt 동일, updatedAt이 더 큰(또는 같은) 값 +``` + +- [ ] **Step 4.3: Flutter 앱 회귀 확인 (사용자가 직접 수행)** + +사용자에게 안내: +- 플러터 앱 재실행 → Todo 생성 → 더 이상 `_TypeError (type 'Null' is not a subtype of type 'String' in type cast)` 안 뜸 +- 콘솔 로그에 `createdAt`/`updatedAt`이 ISO-8601 문자열로 찍히는지 확인 + +**4.2 / 4.3은 에이전트가 PASS로 표시하지 말고 사용자에게 확인을 받은 뒤 표시한다.** + +- [ ] **Step 4.4: (선택) PR 본문 메모 준비** + +PR을 생성한다면 본문에 한 줄 명시: +> Todo / TodoCategory 응답의 createdAt / updatedAt always-non-null 보장 — 서비스 레이어에서 `entityManager.flush()` 호출로 Hibernate가 응답 직전 timestamp를 채우도록 보장. + +--- + +## 비범위 (Out of scope) + +- `Persistable` 도입 (assigned-ID + merge → persist 전환으로 SELECT 1회 회피) — 본 결함과 분리된 코드 품질 이슈 +- FuelTransaction / UserFuel (같은 assigned-ID 패턴이지만 응답 노출 여부 확인 후 별도 이슈) +- BaseTimeEntity 자체에 lifecycle 콜백 추가 (영향 범위 회피) +- `docs/api-docs.json` 재생성 (Springdoc 자동생성이면 다음 빌드/배포 사이클에서 처리) + +--- + +## Self-Review + +본 계획을 spec(`docs/superpowers/specs/2026-05-25-todo-timestamps-design.md`, revised)과 대조: + +**Spec coverage:** +- spec §3.1 production 파일 2개 (TodoService, TodoCategoryService) ✅ Task 1, 2 +- spec §3.1 service test 2개 ✅ Task 1, 2 (단위 테스트 verify flush) +- spec §3.1 repository test 2개 (선택) ✅ Task 3 +- spec §3.2 service 변경 패턴 ✅ Task 1.3 / 2.3에 동일 코드 +- spec §4 데이터 흐름 ✅ Task 1, 2 구현 코드와 일치 +- spec §5.1 단위 테스트 패턴 ✅ Task 1.1 / 2.1 +- spec §5.2 repository 회귀 테스트 ✅ Task 3 +- spec §5.3 수동 curl ✅ Task 4.2 +- spec §5.4 Flutter 회귀 ✅ Task 4.3 +- spec §6 위험 검토 — Task 1.5 / 2.5 / 3.4 회귀 step으로 커버 + +**Placeholder scan:** TBD/TODO/"implement later" 등 없음. 모든 코드 블록에 실제 코드 포함. + +**Type consistency:** `EntityManager.flush()` 호출 위치(`save()` 직후 또는 mutation 직후 / `log.info()` 직전)가 Task 1, 2에서 동일. Mock 패턴 동일. 회귀 테스트 패턴 Task 3에서 두 entity에 대칭 적용. + +**의식적 비범위:** Controller 통합 테스트는 추가하지 않음 — `@Mock TodoService` stub 패턴이라 실제 timestamp 동작 검증 불가. 진짜 검증은 Service 단위 테스트(flush 호출) + Repository 통합 테스트(flush-time timestamp 동작) + 수동 curl이 담당. diff --git a/docs/superpowers/plans/2026-05-26-today-stats-cumulative-fields.md b/docs/superpowers/plans/2026-05-26-today-stats-cumulative-fields.md new file mode 100644 index 0000000..316e621 --- /dev/null +++ b/docs/superpowers/plans/2026-05-26-today-stats-cumulative-fields.md @@ -0,0 +1,701 @@ +# today-stats 누적 통계 필드 3개 추가 Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** `GET /api/timer-sessions/today-stats` 응답에 `lifetimeMinutes`, `lifetimeSessionCount`, `monthlyMinutes` 3개 필드를 추가해 클라이언트의 누적 통계(프로필 카드·뱃지 해금)가 정확히 동작하도록 한다. + +**Architecture:** 기존 record `TodayStatsResponse`에 비-null Integer 3개를 append-only로 추가한다. Service는 KST 기준 "이번 달" 경계를 계산해 기존 `sumDurationBetween`을 재사용하고, lifetime용 SUM/COUNT는 신규 Repository 메서드 2개로 처리한다. 인덱스(`user_id, started_at DESC`)는 기존 것을 그대로 사용, 마이그레이션·캐싱은 도입하지 않는다. + +**Tech Stack:** Spring Boot 4.0.x · Spring Data JPA · JUnit 5 · Mockito · AssertJ · Springdoc(Swagger) · PostgreSQL + +**참조 문서:** +- 설계 spec: `docs/superpowers/specs/2026-05-26-today-stats-cumulative-fields-design.md` +- API spec: `docs/api-specs/03_timer.md` +- 코드 컨벤션: 루트 `CLAUDE.md` + +**커밋 메시지 컨벤션 (프로젝트 규칙):** +``` +today-stats 응답에 누적 통계 필드 3개 추가 : {type} : {설명} #40 +``` +- `type`: `feat` / `fix` / `refactor` / `test` / `docs` / `chore` +- 이모지·Co-Authored-By 금지 + +--- + +## File Structure + +| 모듈 | 경로 | 변경 종류 | 역할 | +|------|------|----------|------| +| SS-Study | `study/timer/dto/TodayStatsResponse.java` | Modify | record에 필드 3개 append + `@Schema` 추가 | +| SS-Study | `study/timer/repository/TimerSessionRepository.java` | Modify | `sumDurationByUserId`, `countByUserId` 추가 | +| SS-Study | `study/timer/service/TimerSessionService.java` | Modify | `getTodayStats` 합산 로직 확장 | +| SS-Study | `study/timer/repository/TimerSessionRepositoryTest.java` | Modify | 신규 메서드 통합 테스트 2건 추가 | +| SS-Study | `study/timer/service/TimerSessionServiceTest.java` | Modify | 기존 today-stats 테스트 stub/expected 갱신 + 신규 케이스 3건 추가 | +| SS-Web | `controller/timer/TimerSessionController.java` | Modify | `@ApiResponse examples` JSON 갱신 | +| SS-Web | `controller/timer/TimerSessionControllerTest.java` | Modify | `todayStats_200` 6필드 검증으로 갱신 | +| docs | `docs/api-specs/03_timer.md` | Modify | today-stats 응답 섹션 + 필드 표 갱신 | + +DB 마이그레이션 / Entity / 인덱스 변경 **없음**. + +--- + +## Task 1: TodayStatsResponse DTO 확장 (필드 3개 추가) + +**목표:** record에 필드를 추가해 컴파일 베이스라인을 확장한다. 이 단계에서는 신규 필드 모두 `0`을 임시로 주입해 빌드를 그린 상태로 유지한다. Repository/Service 로직은 후속 task에서 채운다. + +**Files:** +- Modify: `SS-Study/src/main/java/com/elipair/spacestudyship/study/timer/dto/TodayStatsResponse.java` +- Modify: `SS-Study/src/main/java/com/elipair/spacestudyship/study/timer/service/TimerSessionService.java:183` +- Modify: `SS-Study/src/test/java/com/elipair/spacestudyship/study/timer/service/TimerSessionServiceTest.java` (기존 5개 테스트 컴파일 fix) +- Modify: `SS-Web/src/test/java/com/elipair/spacestudyship/controller/timer/TimerSessionControllerTest.java:238` (생성자 인자 수 fix) + +- [ ] **Step 1: `TodayStatsResponse` 필드 3개 추가** + +`SS-Study/src/main/java/com/elipair/spacestudyship/study/timer/dto/TodayStatsResponse.java` 전체를 아래로 교체: + +```java +package com.elipair.spacestudyship.study.timer.dto; + +import io.swagger.v3.oas.annotations.media.Schema; + +@Schema(description = "오늘 공부 통계 + 누적 통계 (KST 기준)") +public record TodayStatsResponse( + @Schema(description = "오늘 총 공부 시간 (분)") Integer totalMinutes, + @Schema(description = "오늘 완료한 세션 수") Integer sessionCount, + @Schema(description = "연속 공부 일수 (오늘 포함, KST 기준)") Integer streak, + @Schema(description = "회원의 전체 누적 공부 시간 (분)") Integer lifetimeMinutes, + @Schema(description = "회원의 전체 세션 수") Integer lifetimeSessionCount, + @Schema(description = "이번 달 누적 공부 시간 (분, KST 기준)") Integer monthlyMinutes +) {} +``` + +- [ ] **Step 2: `TimerSessionService.getTodayStats` 반환문 임시 fix** + +`SS-Study/.../service/TimerSessionService.java` 의 `getTodayStats` 마지막 return 문: + +기존: +```java +return new TodayStatsResponse(Math.toIntExact(totalMinutes), (int) sessionCount, streak); +``` + +변경: +```java +// Task 3에서 lifetime/monthly 합산 로직으로 교체 — 현재는 컴파일 유지용 0 주입 +return new TodayStatsResponse(Math.toIntExact(totalMinutes), (int) sessionCount, streak, 0, 0, 0); +``` + +- [ ] **Step 3: 기존 Service 테스트 5개의 생성자 호출 fix** + +`SS-Study/src/test/java/.../service/TimerSessionServiceTest.java` 에서 `new TodayStatsResponse(...)`로 비교하는 단 1건 (line 327)만 6필드로 변경: + +기존: +```java +assertThat(res).isEqualTo(new TodayStatsResponse(0, 0, 0)); +``` + +변경: +```java +assertThat(res).isEqualTo(new TodayStatsResponse(0, 0, 0, 0, 0, 0)); +``` + +나머지 4건(`todayStats_withData`, `streak_yesterdayLatest`, `streak_brokenChain`, `streak_futureLatest_clampedToToday`)은 개별 필드 `.totalMinutes()/.sessionCount()/.streak()`만 검증하므로 시그니처 변경 영향 없음 — 수정하지 않는다. + +- [ ] **Step 4: 기존 Controller 테스트 생성자 호출 fix** + +`SS-Web/src/test/java/.../controller/timer/TimerSessionControllerTest.java:238` 의 `todayStats_200`: + +기존: +```java +given(service.getTodayStats(1L)) + .willReturn(new TodayStatsResponse(180, 3, 7)); +``` + +변경: +```java +given(service.getTodayStats(1L)) + .willReturn(new TodayStatsResponse(180, 3, 7, 12450, 287, 1820)); +``` + +(jsonPath 검증은 Task 4에서 확장) + +- [ ] **Step 5: 빌드/테스트 그린 확인** + +Run: +```bash +./gradlew :SS-Study:test :SS-Web:test +``` +Expected: PASS (모든 기존 테스트 통과, 컴파일 에러 0) + +- [ ] **Step 6: 커밋** + +```bash +git add SS-Study/src/main/java/com/elipair/spacestudyship/study/timer/dto/TodayStatsResponse.java \ + SS-Study/src/main/java/com/elipair/spacestudyship/study/timer/service/TimerSessionService.java \ + SS-Study/src/test/java/com/elipair/spacestudyship/study/timer/service/TimerSessionServiceTest.java \ + SS-Web/src/test/java/com/elipair/spacestudyship/controller/timer/TimerSessionControllerTest.java +git commit -m "today-stats 응답에 누적 통계 필드 3개 추가 : feat : TodayStatsResponse에 lifetime/monthly 필드 추가 (값은 임시 0) #40" +``` + +--- + +## Task 2: Repository 메서드 2개 추가 (TDD) + +**목표:** lifetime SUM/COUNT용 메서드 2개를 추가하고 통합 테스트로 검증한다. `monthlyMinutes`는 기존 `sumDurationBetween`을 그대로 재사용하므로 추가하지 않는다. + +**Files:** +- Modify: `SS-Study/src/main/java/com/elipair/spacestudyship/study/timer/repository/TimerSessionRepository.java` +- Modify: `SS-Study/src/test/java/com/elipair/spacestudyship/study/timer/repository/TimerSessionRepositoryTest.java` + +- [ ] **Step 1: 통합 테스트 2건 작성 (실패 예상)** + +`TimerSessionRepositoryTest.java` 의 클래스 끝부분(마지막 `}` 직전)에 아래 두 테스트를 추가: + +```java + @Test + @DisplayName("sumDurationByUserId: 본인 전체 합산, 다른 user 제외, 빈 결과는 0") + void sumDurationByUserId() { + repository.saveAndFlush(session(1L, LocalDateTime.parse("2026-04-10T01:00:00"), 30, null, null)); + repository.saveAndFlush(session(1L, LocalDateTime.parse("2026-05-01T01:00:00"), 60, null, null)); + repository.saveAndFlush(session(1L, LocalDateTime.parse("2026-05-25T01:00:00"), 90, null, null)); + repository.saveAndFlush(session(2L, LocalDateTime.parse("2026-05-25T01:00:00"), 120, null, null)); + + assertThat(repository.sumDurationByUserId(1L)).isEqualTo(180L); + assertThat(repository.sumDurationByUserId(2L)).isEqualTo(120L); + assertThat(repository.sumDurationByUserId(999L)).isZero(); + } + + @Test + @DisplayName("countByUserId: 본인 세션 수, 다른 user 제외, 빈 결과는 0") + void countByUserId() { + repository.saveAndFlush(session(1L, LocalDateTime.parse("2026-05-23T01:00:00"), 30, null, null)); + repository.saveAndFlush(session(1L, LocalDateTime.parse("2026-05-24T01:00:00"), 30, null, null)); + repository.saveAndFlush(session(1L, LocalDateTime.parse("2026-05-25T01:00:00"), 30, null, null)); + repository.saveAndFlush(session(2L, LocalDateTime.parse("2026-05-25T01:00:00"), 30, null, null)); + + assertThat(repository.countByUserId(1L)).isEqualTo(3); + assertThat(repository.countByUserId(2L)).isEqualTo(1); + assertThat(repository.countByUserId(999L)).isZero(); + } +``` + +- [ ] **Step 2: 테스트 실행 → 실패 확인** + +Run: +```bash +./gradlew :SS-Study:test --tests "com.elipair.spacestudyship.study.timer.repository.TimerSessionRepositoryTest" +``` +Expected: 컴파일 실패 — `sumDurationByUserId(Long)`, `countByUserId(Long)` 미정의 + +- [ ] **Step 3: Repository에 메서드 2개 추가** + +`SS-Study/src/main/java/com/elipair/spacestudyship/study/timer/repository/TimerSessionRepository.java` 의 `findStartedAtsAfter` 메서드 아래(클래스 닫는 `}` 직전)에 추가: + +```java + // 전체 누적 분: SUM(Integer) → Long, COALESCE로 NULL 방지 + @Query("SELECT COALESCE(SUM(s.durationMinutes), 0L) FROM TimerSession s " + + "WHERE s.userId = :userId") + Long sumDurationByUserId(@Param("userId") Long userId); + + // 전체 세션 수: Spring Data 명명 규칙 + long countByUserId(Long userId); +``` + +- [ ] **Step 4: 테스트 실행 → 통과 확인** + +Run: +```bash +./gradlew :SS-Study:test --tests "com.elipair.spacestudyship.study.timer.repository.TimerSessionRepositoryTest" +``` +Expected: PASS (신규 2개 + 기존 8개 모두 통과) + +- [ ] **Step 5: 커밋** + +```bash +git add SS-Study/src/main/java/com/elipair/spacestudyship/study/timer/repository/TimerSessionRepository.java \ + SS-Study/src/test/java/com/elipair/spacestudyship/study/timer/repository/TimerSessionRepositoryTest.java +git commit -m "today-stats 응답에 누적 통계 필드 3개 추가 : feat : Repository에 sumDurationByUserId/countByUserId 추가 #40" +``` + +--- + +## Task 3: Service 합산 로직 확장 (TDD) + +**목표:** `getTodayStats`에 lifetime SUM/COUNT 호출과 KST 기준 이번 달 SUM 호출을 추가해 응답 6필드를 채운다. + +**Files:** +- Modify: `SS-Study/src/main/java/com/elipair/spacestudyship/study/timer/service/TimerSessionService.java` +- Modify: `SS-Study/src/test/java/com/elipair/spacestudyship/study/timer/service/TimerSessionServiceTest.java` + +- [ ] **Step 1: 기존 today-stats 테스트 5건에 신규 stub 추가** + +신규 Repository 메서드(`sumDurationByUserId`, `countByUserId`) 호출을 추가하면 기존 5개 테스트(`todayStats_empty`, `todayStats_withData`, `streak_yesterdayLatest`, `streak_brokenChain`, `streak_futureLatest_clampedToToday`)에서 lenient mode가 아니라면 stub 누락으로 NullPointerException 발생 가능. **방어적으로 모든 today-stats 관련 테스트에 stub 추가.** + +또한 `sumDurationBetween`는 기존에 `any(), any()` 두 호출(오늘 + 이번 달)을 모두 매칭하므로 `0L` 반환 stub만 있으면 충분하지만, `todayStats_withData`처럼 특정 값을 기대하는 경우 `any(), any()`가 두 호출 모두 같은 값을 반환하면 today=monthly가 됨 → 그게 의도된 동작이므로 OK. + +각 테스트의 `@BeforeEach` 이후 `given(...)` 블록 직후에 아래 두 줄 추가: + +```java +given(sessionRepository.sumDurationByUserId(eq(1L))).willReturn(0L); +given(sessionRepository.countByUserId(eq(1L))).willReturn(0L); +``` + +`todayStats_empty` (line 317~) 전체를 아래로 교체: + +```java + @Test + @DisplayName("today-stats: 빈 데이터 → 모두 0") + void todayStats_empty() { + given(sessionRepository.sumDurationBetween(eq(1L), any(), any())).willReturn(0L); + given(sessionRepository + .countByUserIdAndStartedAtGreaterThanEqualAndStartedAtLessThan(eq(1L), any(), any())) + .willReturn(0L); + given(sessionRepository.findStartedAtsAfter(eq(1L), any())).willReturn(List.of()); + given(sessionRepository.sumDurationByUserId(eq(1L))).willReturn(0L); + given(sessionRepository.countByUserId(eq(1L))).willReturn(0L); + + TodayStatsResponse res = service.getTodayStats(1L); + + assertThat(res).isEqualTo(new TodayStatsResponse(0, 0, 0, 0, 0, 0)); + } +``` + +`todayStats_withData` (line 331~) 전체를 아래로 교체 (lifetime/monthly stub 분리 — `eq` Matcher로 호출 구분): + +```java + @Test + @DisplayName("today-stats: 정상 데이터 + streak + lifetime/monthly 계산") + void todayStats_withData() { + // sumDurationBetween는 오늘 + 이번 달 두 번 호출됨. + // fixedClock=2026-05-25T12:00:00Z → KST 2026-05-25 21:00 → 오늘=2026-05-25 KST, 월 시작=2026-05-01 KST. + // 둘 다 any() 매칭 시 마지막 stub이 우선이므로, 명시적으로 호출별 stub을 분리한다. + given(sessionRepository.sumDurationBetween(eq(1L), + eq(LocalDateTime.parse("2026-05-24T15:00:00")), // 오늘 시작 (KST 5/25 00:00 = UTC 5/24 15:00) + eq(LocalDateTime.parse("2026-05-25T15:00:00")))) // 내일 시작 (KST 5/26 00:00 = UTC 5/25 15:00) + .willReturn(180L); + given(sessionRepository.sumDurationBetween(eq(1L), + eq(LocalDateTime.parse("2026-04-30T15:00:00")), // 5월 시작 KST 5/1 00:00 = UTC 4/30 15:00 + eq(LocalDateTime.parse("2026-05-31T15:00:00")))) // 6월 시작 KST 6/1 00:00 = UTC 5/31 15:00 + .willReturn(1820L); + given(sessionRepository + .countByUserIdAndStartedAtGreaterThanEqualAndStartedAtLessThan(eq(1L), any(), any())) + .willReturn(3L); + given(sessionRepository.findStartedAtsAfter(eq(1L), any())) + .willReturn(List.of( + LocalDateTime.parse("2026-05-25T02:00:00"), + LocalDateTime.parse("2026-05-23T16:00:00"), + LocalDateTime.parse("2026-05-22T16:00:00") + )); + given(sessionRepository.sumDurationByUserId(eq(1L))).willReturn(12450L); + given(sessionRepository.countByUserId(eq(1L))).willReturn(287L); + + TodayStatsResponse res = service.getTodayStats(1L); + + assertThat(res.totalMinutes()).isEqualTo(180); + assertThat(res.sessionCount()).isEqualTo(3); + assertThat(res.streak()).isEqualTo(3); + assertThat(res.lifetimeMinutes()).isEqualTo(12450); + assertThat(res.lifetimeSessionCount()).isEqualTo(287); + assertThat(res.monthlyMinutes()).isEqualTo(1820); + } +``` + +`streak_yesterdayLatest`, `streak_brokenChain`, `streak_futureLatest_clampedToToday` 3건은 streak만 검증하므로, 각 테스트의 `given(sessionRepository.findStartedAtsAfter...)` 줄 **다음**에 아래 stub 두 줄을 추가: + +```java +given(sessionRepository.sumDurationByUserId(eq(1L))).willReturn(0L); +given(sessionRepository.countByUserId(eq(1L))).willReturn(0L); +``` + +- [ ] **Step 2: 신규 테스트 3건 추가** + +`TimerSessionServiceTest.java` 의 `streak_futureLatest_clampedToToday` 메서드 **다음**(클래스 닫는 `}` 직전)에 추가: + +```java + @Test + @DisplayName("today-stats: lifetime/monthly — KST 월 경계가 sumDurationBetween 인자에 정확히 매핑") + void todayStats_monthlyBoundary_kst() { + // fixedClock=2026-05-25T12:00:00Z → KST 5/25. + // 이번 달 시작 KST 2026-05-01 00:00 = UTC 2026-04-30 15:00 + // 다음 달 시작 KST 2026-06-01 00:00 = UTC 2026-05-31 15:00 + LocalDateTime expectedMonthStartUtc = LocalDateTime.parse("2026-04-30T15:00:00"); + LocalDateTime expectedMonthEndUtc = LocalDateTime.parse("2026-05-31T15:00:00"); + + given(sessionRepository.sumDurationBetween(eq(1L), any(), any())).willReturn(0L); + given(sessionRepository + .countByUserIdAndStartedAtGreaterThanEqualAndStartedAtLessThan(eq(1L), any(), any())) + .willReturn(0L); + given(sessionRepository.findStartedAtsAfter(eq(1L), any())).willReturn(List.of()); + given(sessionRepository.sumDurationByUserId(eq(1L))).willReturn(0L); + given(sessionRepository.countByUserId(eq(1L))).willReturn(0L); + + service.getTodayStats(1L); + + // sumDurationBetween가 정확히 KST 월 경계(UTC 변환된 값)로 호출됐는지 검증 + verify(sessionRepository).sumDurationBetween(eq(1L), + eq(expectedMonthStartUtc), eq(expectedMonthEndUtc)); + } + + @Test + @DisplayName("today-stats: lifetime 0건 → 3 필드 모두 0 (null 금지)") + void todayStats_lifetimeZero_returnsZeroNotNull() { + given(sessionRepository.sumDurationBetween(eq(1L), any(), any())).willReturn(0L); + given(sessionRepository + .countByUserIdAndStartedAtGreaterThanEqualAndStartedAtLessThan(eq(1L), any(), any())) + .willReturn(0L); + given(sessionRepository.findStartedAtsAfter(eq(1L), any())).willReturn(List.of()); + given(sessionRepository.sumDurationByUserId(eq(1L))).willReturn(0L); + given(sessionRepository.countByUserId(eq(1L))).willReturn(0L); + + TodayStatsResponse res = service.getTodayStats(1L); + + assertThat(res.lifetimeMinutes()).isNotNull().isZero(); + assertThat(res.lifetimeSessionCount()).isNotNull().isZero(); + assertThat(res.monthlyMinutes()).isNotNull().isZero(); + } + + @Test + @DisplayName("today-stats: 지난달 + 이번 달 혼합 → monthly < lifetime, lifetime = 전체 합") + void todayStats_mixedMonths_lifetimeGreaterThanMonthly() { + // sumDurationBetween는 (오늘, 이번 달) 두 번 호출 — 호출 인자로 구분 + given(sessionRepository.sumDurationBetween(eq(1L), + eq(LocalDateTime.parse("2026-05-24T15:00:00")), + eq(LocalDateTime.parse("2026-05-25T15:00:00")))) + .willReturn(0L); + given(sessionRepository.sumDurationBetween(eq(1L), + eq(LocalDateTime.parse("2026-04-30T15:00:00")), + eq(LocalDateTime.parse("2026-05-31T15:00:00")))) + .willReturn(1820L); + given(sessionRepository + .countByUserIdAndStartedAtGreaterThanEqualAndStartedAtLessThan(eq(1L), any(), any())) + .willReturn(0L); + given(sessionRepository.findStartedAtsAfter(eq(1L), any())).willReturn(List.of()); + given(sessionRepository.sumDurationByUserId(eq(1L))).willReturn(12450L); + given(sessionRepository.countByUserId(eq(1L))).willReturn(287L); + + TodayStatsResponse res = service.getTodayStats(1L); + + assertThat(res.lifetimeMinutes()).isEqualTo(12450); + assertThat(res.monthlyMinutes()).isEqualTo(1820); + assertThat(res.monthlyMinutes()).isLessThan(res.lifetimeMinutes()); + assertThat(res.lifetimeSessionCount()).isEqualTo(287); + } +``` + +- [ ] **Step 3: 테스트 실행 → 실패 확인** + +Run: +```bash +./gradlew :SS-Study:test --tests "com.elipair.spacestudyship.study.timer.service.TimerSessionServiceTest" +``` +Expected: FAIL — Service가 아직 lifetime/monthly를 계산하지 않으므로 `lifetimeMinutes/lifetimeSessionCount/monthlyMinutes`가 모두 0으로 반환. `todayStats_withData`, `todayStats_monthlyBoundary_kst`, `todayStats_mixedMonths_lifetimeGreaterThanMonthly` 실패. + +- [ ] **Step 4: Service `getTodayStats` 본문 교체** + +`SS-Study/src/main/java/com/elipair/spacestudyship/study/timer/service/TimerSessionService.java` 의 `getTodayStats` 메서드(line 165~184) 전체를 아래로 교체: + +```java + public TodayStatsResponse getTodayStats(Long userId) { + LocalDate todayKst = LocalDate.now(clock.withZone(ZONE_KST)); + LocalDateTime todayStartUtc = toUtcLdt(todayKst.atStartOfDay(ZONE_KST)); + LocalDateTime tomorrowStartUtc = toUtcLdt(todayKst.plusDays(1).atStartOfDay(ZONE_KST)); + + long totalMinutes = Optional.ofNullable( + sessionRepository.sumDurationBetween(userId, todayStartUtc, tomorrowStartUtc)) + .orElse(0L); + long sessionCount = sessionRepository + .countByUserIdAndStartedAtGreaterThanEqualAndStartedAtLessThan( + userId, todayStartUtc, tomorrowStartUtc); + + LocalDateTime lookbackStart = toUtcLdt( + todayKst.minusDays(STREAK_LOOKBACK_DAYS).atStartOfDay(ZONE_KST)); + List startedAts = sessionRepository + .findStartedAtsAfter(userId, lookbackStart); + int streak = computeStreak(startedAts, todayKst); + + // 이번 달 KST 경계 → UTC 변환 후 기존 sumDurationBetween 재사용 + LocalDate monthStartKst = todayKst.withDayOfMonth(1); + LocalDateTime monthStartUtc = toUtcLdt(monthStartKst.atStartOfDay(ZONE_KST)); + LocalDateTime monthEndUtc = toUtcLdt(monthStartKst.plusMonths(1).atStartOfDay(ZONE_KST)); + long monthlyMinutes = Optional.ofNullable( + sessionRepository.sumDurationBetween(userId, monthStartUtc, monthEndUtc)) + .orElse(0L); + + // 전체 누적 (lifetime) — Repository에서 COALESCE로 NULL-safe + long lifetimeMinutes = Optional.ofNullable( + sessionRepository.sumDurationByUserId(userId)).orElse(0L); + long lifetimeSessionCount = sessionRepository.countByUserId(userId); + + return new TodayStatsResponse( + Math.toIntExact(totalMinutes), + (int) sessionCount, + streak, + Math.toIntExact(lifetimeMinutes), + Math.toIntExact(lifetimeSessionCount), + Math.toIntExact(monthlyMinutes) + ); + } +``` + +- [ ] **Step 5: 테스트 실행 → 통과 확인** + +Run: +```bash +./gradlew :SS-Study:test --tests "com.elipair.spacestudyship.study.timer.service.TimerSessionServiceTest" +``` +Expected: PASS (기존 + 신규 모두 통과) + +- [ ] **Step 6: 커밋** + +```bash +git add SS-Study/src/main/java/com/elipair/spacestudyship/study/timer/service/TimerSessionService.java \ + SS-Study/src/test/java/com/elipair/spacestudyship/study/timer/service/TimerSessionServiceTest.java +git commit -m "today-stats 응답에 누적 통계 필드 3개 추가 : feat : Service에 lifetime/monthly 합산 로직 추가 (KST 월 경계) #40" +``` + +--- + +## Task 4: Controller MockMvc 테스트 확장 + Swagger 갱신 + +**목표:** Controller 레벨에서 신규 3필드가 JSON 응답에 직렬화되는지 검증하고, Swagger `examples`를 갱신해 문서가 최신 응답 형태를 반영하게 한다. + +**Files:** +- Modify: `SS-Web/src/test/java/com/elipair/spacestudyship/controller/timer/TimerSessionControllerTest.java:236-245` +- Modify: `SS-Web/src/main/java/com/elipair/spacestudyship/controller/timer/TimerSessionController.java:138` + +- [ ] **Step 1: MockMvc 테스트에 신규 필드 jsonPath 검증 추가** + +`TimerSessionControllerTest.java` 의 `todayStats_200` (line 234~245) 전체를 아래로 교체: + +```java + @Test + @DisplayName("GET /api/timer-sessions/today-stats — 200, 6필드 (today + lifetime + monthly)") + void todayStats_200() throws Exception { + given(service.getTodayStats(1L)) + .willReturn(new TodayStatsResponse(180, 3, 7, 12450, 287, 1820)); + + mockMvc.perform(get("/api/timer-sessions/today-stats")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.totalMinutes").value(180)) + .andExpect(jsonPath("$.sessionCount").value(3)) + .andExpect(jsonPath("$.streak").value(7)) + .andExpect(jsonPath("$.lifetimeMinutes").value(12450)) + .andExpect(jsonPath("$.lifetimeSessionCount").value(287)) + .andExpect(jsonPath("$.monthlyMinutes").value(1820)); + } +``` + +- [ ] **Step 2: 0건 케이스 — null 금지 검증 테스트 추가** + +`todayStats_200` 메서드 **다음**에 추가: + +```java + @Test + @DisplayName("GET /api/timer-sessions/today-stats — 0건 회원: 신규 3필드도 0 (null 아님)") + void todayStats_zero_neverNull() throws Exception { + given(service.getTodayStats(1L)) + .willReturn(new TodayStatsResponse(0, 0, 0, 0, 0, 0)); + + mockMvc.perform(get("/api/timer-sessions/today-stats")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.lifetimeMinutes").value(0)) + .andExpect(jsonPath("$.lifetimeSessionCount").value(0)) + .andExpect(jsonPath("$.monthlyMinutes").value(0)); + } +``` + +- [ ] **Step 3: 테스트 실행 → 통과 확인** + +Run: +```bash +./gradlew :SS-Web:test --tests "com.elipair.spacestudyship.controller.timer.TimerSessionControllerTest" +``` +Expected: PASS (모든 테스트 그린) + +- [ ] **Step 4: Controller Swagger `examples`·`description` 갱신** + +`SS-Web/.../controller/timer/TimerSessionController.java` 의 `getTodayStats` 메서드 어노테이션 블록(line 132~140) 전체를 아래로 교체: + +기존: +```java + @Operation(summary = "오늘 공부 통계", + description = "KST(Asia/Seoul) 기준 오늘의 총 분 / 세션 수 / 연속 일수(streak)") + @ApiResponses({ + @ApiResponse(responseCode = "200", description = "조회 성공", + content = @Content(mediaType = "application/json", + schema = @Schema(implementation = TodayStatsResponse.class), + examples = @ExampleObject(value = "{\"totalMinutes\":180,\"sessionCount\":3,\"streak\":7}"))), + @ApiResponse(responseCode = "401", description = "인증 필요") + }) +``` + +변경: +```java + @Operation(summary = "오늘 공부 통계 + 누적 통계", + description = """ + KST(Asia/Seoul) 기준 통계. + + ### 응답 필드 + - `totalMinutes`, `sessionCount`: 오늘 (KST) + - `streak`: 연속 공부 일수 (오늘 포함, KST) + - `lifetimeMinutes`, `lifetimeSessionCount`: 회원의 전체 누적 + - `monthlyMinutes`: 이번 달 누적 (KST 1일 00:00 ~ 다음 달 1일 00:00) + + 세션 0건 회원도 6개 필드 모두 `0`을 반환합니다 (null 금지). + """) + @ApiResponses({ + @ApiResponse(responseCode = "200", description = "조회 성공", + content = @Content(mediaType = "application/json", + schema = @Schema(implementation = TodayStatsResponse.class), + examples = @ExampleObject(value = """ + { + "totalMinutes": 180, + "sessionCount": 3, + "streak": 7, + "lifetimeMinutes": 12450, + "lifetimeSessionCount": 287, + "monthlyMinutes": 1820 + } + """))), + @ApiResponse(responseCode = "401", description = "인증 필요") + }) +``` + +- [ ] **Step 5: 컨트롤러 테스트 재실행 (회귀 확인)** + +Run: +```bash +./gradlew :SS-Web:test --tests "com.elipair.spacestudyship.controller.timer.TimerSessionControllerTest" +``` +Expected: PASS + +- [ ] **Step 6: 커밋** + +```bash +git add SS-Web/src/main/java/com/elipair/spacestudyship/controller/timer/TimerSessionController.java \ + SS-Web/src/test/java/com/elipair/spacestudyship/controller/timer/TimerSessionControllerTest.java +git commit -m "today-stats 응답에 누적 통계 필드 3개 추가 : feat : Controller MockMvc 테스트 확장 + Swagger examples 갱신 #40" +``` + +--- + +## Task 5: API spec 문서 갱신 + +**목표:** `docs/api-specs/03_timer.md` 의 today-stats 섹션을 6필드 응답으로 갱신. + +**Files:** +- Modify: `docs/api-specs/03_timer.md` + +- [ ] **Step 1: today-stats 섹션 위치 확인** + +Run: +```bash +grep -n "today-stats" docs/api-specs/03_timer.md +``` +Expected: today-stats 헤더가 있는 라인 번호 출력. + +- [ ] **Step 2: today-stats 응답 섹션 갱신** + +`docs/api-specs/03_timer.md` 에서 today-stats 섹션의 `### Response` 블록(필드 표 + JSON 예시 포함)을 아래로 교체. 정확한 위치는 Step 1에서 확인한 라인 번호 기준. + +교체 내용 (응답 본문 블록 전체): + +````markdown +### Response + +`200 OK` + +| 필드 | 타입 | Nullable | 설명 | +|------|------|----------|------| +| `totalMinutes` | Integer | X | 오늘 총 공부 시간 (분, KST) | +| `sessionCount` | Integer | X | 오늘 완료한 세션 수 | +| `streak` | Integer | X | 연속 공부 일수 (오늘 포함, KST 기준) | +| `lifetimeMinutes` | Integer | X | 회원의 전체 누적 공부 시간 (분) | +| `lifetimeSessionCount` | Integer | X | 회원의 전체 세션 수 | +| `monthlyMinutes` | Integer | X | 이번 달 누적 공부 시간 (분, KST 기준) | + +> 세션 0건 회원도 6필드 모두 `0`을 반환합니다. `null` 절대 반환하지 않습니다. + +```json +{ + "totalMinutes": 180, + "sessionCount": 3, + "streak": 7, + "lifetimeMinutes": 12450, + "lifetimeSessionCount": 287, + "monthlyMinutes": 1820 +} +``` + +#### 시간 경계 정의 +- **오늘**: `KST 00:00:00` ~ `KST 23:59:59` +- **이번 달**: 이번 달 1일 `KST 00:00:00` ~ 다음 달 1일 `KST 00:00:00` (반열림 `[start, end)`) +- **streak**: 마지막 공부일이 오늘이면 오늘 포함, 어제까지만 했으면 어제 기준. KST 기준 일자 단위 연속. +```` + +(기존 텍스트와 정확히 매칭되는 큰 블록을 한 번에 교체. 다른 섹션 — 엔드포인트 요약 표 등 — 은 건드리지 않는다.) + +- [ ] **Step 3: 마크다운 렌더 확인 (선택)** + +문서 형식만 확인. 빌드/테스트 영향 없음. + +- [ ] **Step 4: 커밋** + +```bash +git add docs/api-specs/03_timer.md +git commit -m "today-stats 응답에 누적 통계 필드 3개 추가 : docs : API spec 03_timer.md 응답 6필드로 갱신 #40" +``` + +--- + +## Task 6: 전체 빌드 + 회귀 검증 + +**목표:** 멀티 모듈 전체 빌드와 테스트가 그린인지 최종 확인. + +- [ ] **Step 1: 전체 빌드** + +Run: +```bash +./gradlew clean build +``` +Expected: `BUILD SUCCESSFUL` — 모든 모듈 컴파일·테스트 그린. + +- [ ] **Step 2: 실패 시 회귀 분석** + +만약 실패하면 stdout/stderr에서: +- 컴파일 에러: `TodayStatsResponse` 시그니처를 사용하는 추가 위치 누락 가능 → grep으로 점검 + ```bash + grep -rn "new TodayStatsResponse(" --include="*.java" . + ``` + 발견된 모든 호출이 6-인자 형태인지 확인. +- 테스트 실패: Mockito stub 누락 → Task 3 Step 1 가이드 다시 검토. + +Step 1·2를 반복해서 그린 상태로 만든다. + +- [ ] **Step 3: 최종 git status 확인 (작업 트리 클린)** + +Run: +```bash +git status +``` +Expected: `nothing to commit, working tree clean`. 미커밋 변경 없음. + +- [ ] **Step 4: 변경 요약 확인** + +Run: +```bash +git log --oneline origin/main..HEAD +``` +Expected: 5개 커밋 (Task 1~5)이 표시됨. 메시지는 모두 `today-stats 응답에 누적 통계 필드 3개 추가 : {type} : ... #40` 형태. + +--- + +## 완료 조건 (Definition of Done) + +- [ ] `TodayStatsResponse` record가 6필드(Integer, 모두 비-null)로 확장됨. +- [ ] Repository에 `sumDurationByUserId`, `countByUserId` 메서드 2개 추가. +- [ ] Service `getTodayStats`가 lifetime/monthly 합산 호출을 포함하고, KST 월 경계가 정확히 UTC로 변환됨. +- [ ] Controller Swagger `examples`·`description`이 신규 필드 포함 형태로 갱신. +- [ ] `docs/api-specs/03_timer.md` 갱신. +- [ ] 신규/기존 테스트 모두 통과: Repository(10건), Service(20+건), Controller(12+건). +- [ ] `./gradlew clean build` 그린. +- [ ] DB 마이그레이션·인덱스·Entity 변경 0건 (호환성 보장). diff --git a/docs/superpowers/plans/2026-05-29-exploration-domain.md b/docs/superpowers/plans/2026-05-29-exploration-domain.md new file mode 100644 index 0000000..3deba75 --- /dev/null +++ b/docs/superpowers/plans/2026-05-29-exploration-domain.md @@ -0,0 +1,1951 @@ +# 탐험(Exploration) 도메인 재구현 Implementation Plan (frontend 계약 정합) + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** 우주 탐험(행성→지역 트리)을 연료로 해금하는 도메인을, 프론트 게스트 시드와 1:1 일치하는 시드 + 진행 게이트 + INSUFFICIENT_FUEL 응답 보강으로 구현한다. + +**Architecture:** SS-Study 모듈에 `exploration/` 패키지, Controller만 SS-Web. 마스터 노드(`ExplorationNode`)는 시드 전용 read-only, 유저 진행(`UserExploration`)은 행 존재=해금. 행성 클리어/진행도는 조회 시 파생. 해금은 `@Transactional`로 `FuelService.consume`와 동일 트랜잭션, UNIQUE(user_id,node_id)로 멱등성. INSUFFICIENT_FUEL은 `requiredFuel`/`currentFuel`을 본문에 동봉. + +**Tech Stack:** Java 21, Spring Boot 4, Spring Data JPA, Lombok, JUnit5+Mockito+AssertJ, Testcontainers(Postgres), Flyway, springdoc. + +**Spec:** `docs/superpowers/specs/2026-05-29-exploration-domain-design.md` +**프론트 시드 원본:** Flutter 레포 `lib/features/exploration/data/seed/exploration_seed_data.dart` + +**공통 규칙:** +- 테스트: `./gradlew :SS-Study:test`, `./gradlew :SS-Web:test`, `./gradlew :SS-Common:test`. 단일: `--tests "FQCN"`. +- 테스트 환경 = Testcontainers + `ddl-auto=create-drop` (엔티티가 스키마 생성, Flyway 비활성). `members` FK는 엔티티에 매핑하지 않음(마이그레이션에만 존재). +- 커밋 형식: `탐험 도메인 구현 : {type} : {설명} #27`. 이슈번호 #27. **이모지 금지. Co-Authored-By 금지.** + +--- + +## File Structure + +**SS-Common** +- Modify: `.../common/exception/ErrorCode.java` — 탐험 에러 5종 추가 +- Modify: `.../common/exception/ErrorResponse.java` — nullable `requiredFuel`/`currentFuel` + `@JsonInclude(NON_NULL)` +- Create: `.../common/exception/InsufficientFuelException.java` +- Modify: `.../common/exception/GlobalExceptionHandler.java` — `InsufficientFuelException` 핸들러 + +**SS-Study** (`.../study/exploration/`) +- `constant/NodeType.java`, `constant/NodeTypeConverter.java` +- `entity/ExplorationNode.java`, `entity/UserExploration.java` +- `repository/ExplorationNodeRepository.java`, `repository/UserExplorationRepository.java` +- `dto/` 6 records +- `service/ExplorationService.java` +- Modify: `SS-Study/src/test/.../study/StudyTestApplication.java` — repo 패키지 등록 + +**SS-Web** +- `controller/exploration/ExplorationController.java` + +**리소스/문서** +- `SS-Web/src/main/resources/db/migration/V0_0_42__add_exploration.sql` — 스키마 + 시드 38노드 +- Modify: `docs/api-specs/05_exploration.md` + +--- + +## Task 0: Working tree 폐기 (clean 재시작) + +**목적:** 이전 구현(프론트 계약과 어긋난)을 전부 제거하고 main 기준 clean 상태로 되돌린다. (복구 필요 시 reflog `87b0fc8`) + +**Files:** (없음 — 정리 작업) + +- [ ] **Step 1: 추적 파일 수정분 되돌리기** + +```bash +cd /Users/luca/workspace/Java_Spring/space_study_ship +git checkout -- . +``` + +- [ ] **Step 2: 미추적 구현 파일/마이그레이션 삭제 (docs/superpowers는 보존)** + +```bash +rm -rf SS-Study/src/main/java/com/elipair/spacestudyship/study/exploration +rm -rf SS-Study/src/test/java/com/elipair/spacestudyship/study/exploration +rm -rf SS-Web/src/main/java/com/elipair/spacestudyship/controller/exploration +rm -rf SS-Web/src/test/java/com/elipair/spacestudyship/controller/exploration +rm -f SS-Web/src/main/resources/db/migration/V0_0_42__add_exploration.sql +``` + +- [ ] **Step 3: clean 상태 확인 (docs/superpowers/* 외에 변경 없어야 함)** + +Run: `git status -s` +Expected: `docs/superpowers/specs/...` 및 `docs/superpowers/plans/...` (untracked)만 표시. exploration 관련 코드/마이그레이션 흔적 없음. + +- [ ] **Step 4: clean 상태에서 전체 테스트 green 확인 (회귀 베이스라인)** + +Run: `./gradlew :SS-Common:test :SS-Study:test :SS-Web:test` +Expected: BUILD SUCCESSFUL + +커밋 없음(정리 단계). + +--- + +## Task 1: 에러 인프라 (ErrorCode 5종 + ErrorResponse 보강 + InsufficientFuelException + 핸들러) + +**Files:** +- Modify: `SS-Common/src/main/java/com/elipair/spacestudyship/common/exception/ErrorCode.java` +- Modify: `SS-Common/src/main/java/com/elipair/spacestudyship/common/exception/ErrorResponse.java` +- Create: `SS-Common/src/main/java/com/elipair/spacestudyship/common/exception/InsufficientFuelException.java` +- Modify: `SS-Common/src/main/java/com/elipair/spacestudyship/common/exception/GlobalExceptionHandler.java` +- Test: `SS-Common/src/test/java/com/elipair/spacestudyship/common/exception/ErrorResponseTest.java` + +- [ ] **Step 1: 실패 테스트 작성 (ErrorResponse 팩토리 + 예외 게터)** + +```java +package com.elipair.spacestudyship.common.exception; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +class ErrorResponseTest { + + @Test + @DisplayName("of(ErrorCode): requiredFuel/currentFuel은 null") + void of_basic_nullFuelFields() { + ErrorResponse r = ErrorResponse.of(ErrorCode.PLANET_NOT_FOUND); + assertThat(r.code()).isEqualTo("PLANET_NOT_FOUND"); + assertThat(r.requiredFuel()).isNull(); + assertThat(r.currentFuel()).isNull(); + } + + @Test + @DisplayName("ofInsufficientFuel: 연료 수치 포함") + void ofInsufficientFuel_includesAmounts() { + ErrorResponse r = ErrorResponse.ofInsufficientFuel("연료가 부족합니다.", 10, 4); + assertThat(r.code()).isEqualTo("INSUFFICIENT_FUEL"); + assertThat(r.requiredFuel()).isEqualTo(10); + assertThat(r.currentFuel()).isEqualTo(4); + } + + @Test + @DisplayName("InsufficientFuelException: 게터로 수치 노출") + void exception_getters() { + InsufficientFuelException ex = new InsufficientFuelException(10, 4); + assertThat(ex.getRequiredFuel()).isEqualTo(10); + assertThat(ex.getCurrentFuel()).isEqualTo(4); + } +} +``` + +- [ ] **Step 2: 테스트 실패 확인** + +Run: `./gradlew :SS-Common:test --tests "com.elipair.spacestudyship.common.exception.ErrorResponseTest"` +Expected: FAIL — `ofInsufficientFuel` / `InsufficientFuelException` 없음(컴파일 에러) + +- [ ] **Step 3: ErrorCode에 5종 추가** + +`ErrorCode.java`에서 `// Timer` 블록 뒤(또는 `// Common` 앞)에 추가: + +```java + // Exploration + PLANET_NOT_FOUND(HttpStatus.NOT_FOUND, "해당 행성을 찾을 수 없습니다."), + REGION_NOT_FOUND(HttpStatus.NOT_FOUND, "해당 지역을 찾을 수 없습니다."), + ALREADY_UNLOCKED(HttpStatus.BAD_REQUEST, "이미 해금된 노드입니다."), + PLANET_LOCKED(HttpStatus.BAD_REQUEST, "상위 행성이 아직 해금되지 않았습니다."), + PREREQUISITE_NOT_CLEARED(HttpStatus.BAD_REQUEST, "이전 행성을 먼저 클리어해야 합니다."), +``` + +- [ ] **Step 4: ErrorResponse 보강** + +`ErrorResponse.java` 전체를 아래로 교체: + +```java +package com.elipair.spacestudyship.common.exception; + +import com.fasterxml.jackson.annotation.JsonInclude; + +@JsonInclude(JsonInclude.Include.NON_NULL) +public record ErrorResponse( + String code, + String message, + Integer requiredFuel, + Integer currentFuel +) { + public static ErrorResponse of(ErrorCode errorCode) { + return new ErrorResponse(errorCode.name(), errorCode.getMessage(), null, null); + } + + public static ErrorResponse of(ErrorCode errorCode, String message) { + return new ErrorResponse(errorCode.name(), message, null, null); + } + + public static ErrorResponse ofInsufficientFuel(String message, int requiredFuel, int currentFuel) { + return new ErrorResponse(ErrorCode.INSUFFICIENT_FUEL.name(), message, requiredFuel, currentFuel); + } +} +``` + +- [ ] **Step 5: InsufficientFuelException 생성** + +```java +package com.elipair.spacestudyship.common.exception; + +import lombok.Getter; + +@Getter +public class InsufficientFuelException extends RuntimeException { + + private final int requiredFuel; + private final int currentFuel; + + public InsufficientFuelException(int requiredFuel, int currentFuel) { + super(ErrorCode.INSUFFICIENT_FUEL.getMessage()); + this.requiredFuel = requiredFuel; + this.currentFuel = currentFuel; + } +} +``` + +- [ ] **Step 6: GlobalExceptionHandler에 핸들러 추가** + +`GlobalExceptionHandler.java`의 `handleCustomException` 메서드 바로 뒤에 추가: + +```java + @ExceptionHandler(InsufficientFuelException.class) + public ResponseEntity handleInsufficientFuel(InsufficientFuelException ex) { + log.info("[Exception] 연료 부족 | required={}, current={}", ex.getRequiredFuel(), ex.getCurrentFuel()); + return ResponseEntity + .status(ErrorCode.INSUFFICIENT_FUEL.getHttpStatus()) + .body(ErrorResponse.ofInsufficientFuel( + ErrorCode.INSUFFICIENT_FUEL.getMessage(), + ex.getRequiredFuel(), ex.getCurrentFuel())); + } +``` + +- [ ] **Step 7: 테스트 통과 확인 + 회귀 확인** + +Run: `./gradlew :SS-Common:test` +Expected: PASS (신규 ErrorResponseTest 포함, 기존 회귀 없음) + +- [ ] **Step 8: Commit** + +```bash +git add SS-Common/src/main/java/com/elipair/spacestudyship/common/exception/ SS-Common/src/test/java/com/elipair/spacestudyship/common/exception/ErrorResponseTest.java +git commit -m "탐험 도메인 구현 : feat : 탐험 ErrorCode 5종 + INSUFFICIENT_FUEL 응답 보강 #27" +``` + +--- + +## Task 2: NodeType enum + Converter + +**Files:** +- Create: `SS-Study/src/main/java/com/elipair/spacestudyship/study/exploration/constant/NodeType.java` +- Create: `SS-Study/src/main/java/com/elipair/spacestudyship/study/exploration/constant/NodeTypeConverter.java` + +- [ ] **Step 1: NodeType enum** + +```java +package com.elipair.spacestudyship.study.exploration.constant; + +public enum NodeType { + PLANET, + REGION; + + /** DB 컬럼/JSON 직렬화용 소문자 표현 ("planet" / "region"). */ + public String value() { + return name().toLowerCase(); + } + + public static NodeType from(String value) { + return NodeType.valueOf(value.toUpperCase()); + } +} +``` + +- [ ] **Step 2: Converter** + +```java +package com.elipair.spacestudyship.study.exploration.constant; + +import jakarta.persistence.AttributeConverter; +import jakarta.persistence.Converter; + +@Converter +public class NodeTypeConverter implements AttributeConverter { + + @Override + public String convertToDatabaseColumn(NodeType attribute) { + return attribute == null ? null : attribute.value(); + } + + @Override + public NodeType convertToEntityAttribute(String dbData) { + return dbData == null ? null : NodeType.from(dbData); + } +} +``` + +- [ ] **Step 3: 컴파일 확인** + +Run: `./gradlew :SS-Study:compileJava` +Expected: BUILD SUCCESSFUL + +- [ ] **Step 4: Commit** + +```bash +git add SS-Study/src/main/java/com/elipair/spacestudyship/study/exploration/constant/ +git commit -m "탐험 도메인 구현 : feat : NodeType enum + Converter 추가 #27" +``` + +--- + +## Task 3: ExplorationNode 엔티티 (마스터) + +**Files:** +- Create: `SS-Study/src/main/java/com/elipair/spacestudyship/study/exploration/entity/ExplorationNode.java` +- Test: `SS-Study/src/test/java/com/elipair/spacestudyship/study/exploration/entity/ExplorationNodeTest.java` + +- [ ] **Step 1: 실패 테스트** + +```java +package com.elipair.spacestudyship.study.exploration.entity; + +import com.elipair.spacestudyship.study.exploration.constant.NodeType; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +class ExplorationNodeTest { + + @Test + @DisplayName("planet 빌더: 필드 매핑") + void buildsPlanet() { + ExplorationNode node = ExplorationNode.builder() + .id("earth").name("지구").nodeType(NodeType.PLANET).depth(2) + .icon("earth").parentId(null).prerequisiteNodeId(null) + .requiredFuel(0).sortOrder(0).description("시작점") + .mapX(0.5).mapY(0.08).build(); + + assertThat(node.getId()).isEqualTo("earth"); + assertThat(node.getNodeType()).isEqualTo(NodeType.PLANET); + assertThat(node.getRequiredFuel()).isZero(); + assertThat(node.getParentId()).isNull(); + } +} +``` + +- [ ] **Step 2: 실패 확인** + +Run: `./gradlew :SS-Study:test --tests "com.elipair.spacestudyship.study.exploration.entity.ExplorationNodeTest"` +Expected: FAIL — 클래스 없음 + +- [ ] **Step 3: 엔티티 구현 (BaseTimeEntity 미상속)** + +```java +package com.elipair.spacestudyship.study.exploration.entity; + +import com.elipair.spacestudyship.study.exploration.constant.NodeType; +import com.elipair.spacestudyship.study.exploration.constant.NodeTypeConverter; +import jakarta.persistence.Column; +import jakarta.persistence.Convert; +import jakarta.persistence.Entity; +import jakarta.persistence.Id; +import jakarta.persistence.Table; +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Entity +@Table(name = "exploration_nodes") +@Getter +@Builder +@AllArgsConstructor +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class ExplorationNode { + + @Id + @Column(length = 50) + private String id; + + @Column(nullable = false, length = 50) + private String name; + + @Convert(converter = NodeTypeConverter.class) + @Column(name = "node_type", nullable = false, length = 10) + private NodeType nodeType; + + @Column(nullable = false) + private int depth; + + @Column(nullable = false, length = 30) + private String icon; + + @Column(name = "parent_id", length = 50) + private String parentId; + + @Column(name = "prerequisite_node_id", length = 50) + private String prerequisiteNodeId; + + @Column(name = "required_fuel", nullable = false) + private int requiredFuel; + + @Column(name = "sort_order", nullable = false) + private int sortOrder; + + @Column(nullable = false, length = 200) + private String description; + + @Column(name = "map_x", nullable = false) + private double mapX; + + @Column(name = "map_y", nullable = false) + private double mapY; +} +``` + +- [ ] **Step 4: 통과 확인** + +Run: `./gradlew :SS-Study:test --tests "com.elipair.spacestudyship.study.exploration.entity.ExplorationNodeTest"` +Expected: PASS + +- [ ] **Step 5: Commit** + +```bash +git add SS-Study/src/main/java/com/elipair/spacestudyship/study/exploration/entity/ExplorationNode.java SS-Study/src/test/java/com/elipair/spacestudyship/study/exploration/entity/ExplorationNodeTest.java +git commit -m "탐험 도메인 구현 : feat : ExplorationNode 마스터 엔티티 추가 #27" +``` + +--- + +## Task 4: UserExploration 엔티티 + +**Files:** +- Create: `SS-Study/src/main/java/com/elipair/spacestudyship/study/exploration/entity/UserExploration.java` +- Test: `SS-Study/src/test/java/com/elipair/spacestudyship/study/exploration/entity/UserExplorationTest.java` + +- [ ] **Step 1: 실패 테스트** + +```java +package com.elipair.spacestudyship.study.exploration.entity; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +class UserExplorationTest { + + @Test + @DisplayName("unlock 팩토리: isUnlocked=true, unlockedAt 세팅, cleared 반영") + void unlockFactory() { + UserExploration region = UserExploration.unlock(1L, "japan", true); + assertThat(region.getUserId()).isEqualTo(1L); + assertThat(region.getNodeId()).isEqualTo("japan"); + assertThat(region.isUnlocked()).isTrue(); + assertThat(region.isCleared()).isTrue(); + assertThat(region.getUnlockedAt()).isNotNull(); + + UserExploration planet = UserExploration.unlock(1L, "mars", false); + assertThat(planet.isCleared()).isFalse(); + } +} +``` + +- [ ] **Step 2: 실패 확인** + +Run: `./gradlew :SS-Study:test --tests "com.elipair.spacestudyship.study.exploration.entity.UserExplorationTest"` +Expected: FAIL — 클래스 없음 + +- [ ] **Step 3: 엔티티 구현** + +```java +package com.elipair.spacestudyship.study.exploration.entity; + +import com.elipair.spacestudyship.common.entity.BaseTimeEntity; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.Table; +import jakarta.persistence.UniqueConstraint; +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.time.LocalDateTime; + +@Entity +@Table(name = "user_exploration_progress", + uniqueConstraints = @UniqueConstraint(name = "uq_user_expl", columnNames = {"user_id", "node_id"})) +@Getter +@Builder +@AllArgsConstructor +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class UserExploration extends BaseTimeEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(name = "user_id", nullable = false) + private Long userId; + + @Column(name = "node_id", nullable = false, length = 50) + private String nodeId; + + @Column(name = "is_unlocked", nullable = false) + private boolean isUnlocked; + + @Column(name = "is_cleared", nullable = false) + private boolean isCleared; + + @Column(name = "unlocked_at", nullable = false) + private LocalDateTime unlockedAt; + + public static UserExploration unlock(Long userId, String nodeId, boolean cleared) { + return UserExploration.builder() + .userId(userId) + .nodeId(nodeId) + .isUnlocked(true) + .isCleared(cleared) + .unlockedAt(LocalDateTime.now()) + .build(); + } +} +``` + +- [ ] **Step 4: 통과 확인** + +Run: `./gradlew :SS-Study:test --tests "com.elipair.spacestudyship.study.exploration.entity.UserExplorationTest"` +Expected: PASS + +- [ ] **Step 5: Commit** + +```bash +git add SS-Study/src/main/java/com/elipair/spacestudyship/study/exploration/entity/UserExploration.java SS-Study/src/test/java/com/elipair/spacestudyship/study/exploration/entity/UserExplorationTest.java +git commit -m "탐험 도메인 구현 : feat : UserExploration 진행 엔티티 추가 #27" +``` + +--- + +## Task 5: Repository 2종 + 테스트 + +**Files:** +- Create: `.../exploration/repository/ExplorationNodeRepository.java` +- Create: `.../exploration/repository/UserExplorationRepository.java` +- Modify: `SS-Study/src/test/java/com/elipair/spacestudyship/study/StudyTestApplication.java` +- Test: `.../exploration/repository/ExplorationNodeRepositoryTest.java`, `UserExplorationRepositoryTest.java` + +- [ ] **Step 1: Repository 인터페이스** + +`ExplorationNodeRepository.java`: + +```java +package com.elipair.spacestudyship.study.exploration.repository; + +import com.elipair.spacestudyship.study.exploration.constant.NodeType; +import com.elipair.spacestudyship.study.exploration.entity.ExplorationNode; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.List; + +public interface ExplorationNodeRepository extends JpaRepository { + + List findByNodeTypeOrderBySortOrderAsc(NodeType nodeType); + + List findByParentIdOrderBySortOrderAsc(String parentId); +} +``` + +`UserExplorationRepository.java`: + +```java +package com.elipair.spacestudyship.study.exploration.repository; + +import com.elipair.spacestudyship.study.exploration.entity.UserExploration; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.List; + +public interface UserExplorationRepository extends JpaRepository { + + List findByUserId(Long userId); + + boolean existsByUserIdAndNodeId(Long userId, String nodeId); +} +``` + +- [ ] **Step 2: StudyTestApplication 패키지 등록** + +`@EnableJpaRepositories` basePackages 배열에 추가: + +```java +@EnableJpaRepositories(basePackages = { + "com.elipair.spacestudyship.study.todo.repository", + "com.elipair.spacestudyship.study.fuel.repository", + "com.elipair.spacestudyship.study.timer.repository", + "com.elipair.spacestudyship.study.exploration.repository" +}) +``` + +- [ ] **Step 3: 실패 테스트 — ExplorationNodeRepositoryTest** + +```java +package com.elipair.spacestudyship.study.exploration.repository; + +import com.elipair.spacestudyship.study.StudyTestApplication; +import com.elipair.spacestudyship.study.exploration.constant.NodeType; +import com.elipair.spacestudyship.study.exploration.entity.ExplorationNode; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; + +@SpringBootTest(classes = StudyTestApplication.class) +@Transactional +class ExplorationNodeRepositoryTest { + + @Autowired + ExplorationNodeRepository nodeRepository; + + private ExplorationNode planet(String id, int sort) { + return ExplorationNode.builder().id(id).name(id).nodeType(NodeType.PLANET) + .depth(2).icon(id).requiredFuel(0).sortOrder(sort) + .description("").mapX(0).mapY(0).build(); + } + + private ExplorationNode region(String id, String parent, int sort) { + return ExplorationNode.builder().id(id).name(id).nodeType(NodeType.REGION) + .depth(3).icon(id).parentId(parent).requiredFuel(1).sortOrder(sort) + .description("").mapX(0).mapY(0).build(); + } + + @Test + @DisplayName("findByNodeTypeOrderBySortOrderAsc: 타입 필터 + 정렬") + void findByNodeType_sorted() { + nodeRepository.saveAll(List.of(planet("b", 1), planet("a", 0))); + nodeRepository.saveAll(List.of(region("r1", "a", 0))); + + List planets = nodeRepository.findByNodeTypeOrderBySortOrderAsc(NodeType.PLANET); + + assertThat(planets).extracting(ExplorationNode::getId).containsExactly("a", "b"); + } + + @Test + @DisplayName("findByParentIdOrderBySortOrderAsc: 부모별 정렬 조회") + void findByParent_sorted() { + nodeRepository.save(planet("a", 0)); + nodeRepository.saveAll(List.of(region("r2", "a", 1), region("r1", "a", 0))); + + List regions = nodeRepository.findByParentIdOrderBySortOrderAsc("a"); + + assertThat(regions).extracting(ExplorationNode::getId).containsExactly("r1", "r2"); + } +} +``` + +- [ ] **Step 4: 실패 테스트 — UserExplorationRepositoryTest** + +```java +package com.elipair.spacestudyship.study.exploration.repository; + +import com.elipair.spacestudyship.study.StudyTestApplication; +import com.elipair.spacestudyship.study.exploration.entity.UserExploration; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.transaction.annotation.Transactional; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +@SpringBootTest(classes = StudyTestApplication.class) +@Transactional +class UserExplorationRepositoryTest { + + @Autowired + UserExplorationRepository repository; + + @Test + @DisplayName("findByUserId / existsByUserIdAndNodeId") + void findAndExists() { + repository.saveAndFlush(UserExploration.unlock(1L, "japan", true)); + + assertThat(repository.findByUserId(1L)).hasSize(1); + assertThat(repository.findByUserId(999L)).isEmpty(); + assertThat(repository.existsByUserIdAndNodeId(1L, "japan")).isTrue(); + assertThat(repository.existsByUserIdAndNodeId(1L, "mars")).isFalse(); + } + + @Test + @DisplayName("UNIQUE(user_id, node_id) 위반 시 예외") + void uniqueConstraint() { + repository.saveAndFlush(UserExploration.unlock(1L, "mars", false)); + + assertThatThrownBy(() -> + repository.saveAndFlush(UserExploration.unlock(1L, "mars", false))) + .isInstanceOf(Exception.class); + } +} +``` + +- [ ] **Step 5: 실패 확인** + +Run: `./gradlew :SS-Study:test --tests "com.elipair.spacestudyship.study.exploration.repository.*"` +Expected: FAIL — Repository 미존재(컴파일 에러) + +- [ ] **Step 6: 통과 확인** + +Run: `./gradlew :SS-Study:test --tests "com.elipair.spacestudyship.study.exploration.repository.*"` +Expected: PASS (2 클래스) + +- [ ] **Step 7: Commit** + +```bash +git add SS-Study/src/main/java/com/elipair/spacestudyship/study/exploration/repository/ SS-Study/src/test/java/com/elipair/spacestudyship/study/exploration/repository/ SS-Study/src/test/java/com/elipair/spacestudyship/study/StudyTestApplication.java +git commit -m "탐험 도메인 구현 : feat : Exploration Repository 2종 + 테스트 #27" +``` + +--- + +## Task 6: DTO 6종 + +**Files:** (모두 `SS-Study/src/main/java/com/elipair/spacestudyship/study/exploration/dto/`) + +- [ ] **Step 1: ProgressDto** + +```java +package com.elipair.spacestudyship.study.exploration.dto; + +import io.swagger.v3.oas.annotations.media.Schema; + +@Schema(description = "행성 진행도") +public record ProgressDto( + @Schema(example = "3") int clearedChildren, + @Schema(example = "5") int totalChildren, + @Schema(example = "0.6") double progressRatio +) {} +``` + +- [ ] **Step 2: PlanetResponse** + +```java +package com.elipair.spacestudyship.study.exploration.dto; + +import com.elipair.spacestudyship.study.exploration.entity.ExplorationNode; +import io.swagger.v3.oas.annotations.media.Schema; + +import java.time.LocalDateTime; +import java.time.ZoneOffset; +import java.time.format.DateTimeFormatter; + +@Schema(description = "행성 응답") +public record PlanetResponse( + String id, String name, String nodeType, int depth, String icon, + @Schema(nullable = true) String parentId, + @Schema(nullable = true) String prerequisiteId, + int requiredFuel, boolean isUnlocked, boolean isCleared, int sortOrder, + String description, double mapX, double mapY, + @Schema(nullable = true, example = "2026-04-01T00:00:00Z") String unlockedAt, + ProgressDto progress +) { + private static final DateTimeFormatter ISO_UTC = DateTimeFormatter.ISO_INSTANT; + + public static PlanetResponse of(ExplorationNode n, boolean isUnlocked, boolean isCleared, + int clearedChildren, int totalChildren, double progressRatio, + LocalDateTime unlockedAt) { + return new PlanetResponse( + n.getId(), n.getName(), n.getNodeType().value(), n.getDepth(), n.getIcon(), + n.getParentId(), n.getPrerequisiteNodeId(), n.getRequiredFuel(), + isUnlocked, isCleared, n.getSortOrder(), n.getDescription(), n.getMapX(), n.getMapY(), + formatUtc(unlockedAt), + new ProgressDto(clearedChildren, totalChildren, progressRatio)); + } + + private static String formatUtc(LocalDateTime time) { + return time == null ? null : ISO_UTC.format(time.toInstant(ZoneOffset.UTC)); + } +} +``` + +- [ ] **Step 3: RegionResponse** + +```java +package com.elipair.spacestudyship.study.exploration.dto; + +import com.elipair.spacestudyship.study.exploration.entity.ExplorationNode; +import io.swagger.v3.oas.annotations.media.Schema; + +import java.time.LocalDateTime; +import java.time.ZoneOffset; +import java.time.format.DateTimeFormatter; + +@Schema(description = "지역 응답") +public record RegionResponse( + String id, String name, String nodeType, int depth, String icon, + @Schema(nullable = true) String parentId, + int requiredFuel, boolean isUnlocked, boolean isCleared, int sortOrder, + String description, double mapX, double mapY, + @Schema(nullable = true, example = "2026-04-05T15:30:00Z") String unlockedAt +) { + private static final DateTimeFormatter ISO_UTC = DateTimeFormatter.ISO_INSTANT; + + public static RegionResponse of(ExplorationNode n, boolean isUnlocked, boolean isCleared, + LocalDateTime unlockedAt) { + return new RegionResponse( + n.getId(), n.getName(), n.getNodeType().value(), n.getDepth(), n.getIcon(), + n.getParentId(), n.getRequiredFuel(), isUnlocked, isCleared, + n.getSortOrder(), n.getDescription(), n.getMapX(), n.getMapY(), + formatUtc(unlockedAt)); + } + + private static String formatUtc(LocalDateTime time) { + return time == null ? null : ISO_UTC.format(time.toInstant(ZoneOffset.UTC)); + } +} +``` + +- [ ] **Step 4: UnlockedNodeDto** + +```java +package com.elipair.spacestudyship.study.exploration.dto; + +import com.elipair.spacestudyship.study.exploration.entity.ExplorationNode; +import com.elipair.spacestudyship.study.exploration.entity.UserExploration; +import io.swagger.v3.oas.annotations.media.Schema; + +import java.time.LocalDateTime; +import java.time.ZoneOffset; +import java.time.format.DateTimeFormatter; + +@Schema(description = "해금된 노드 요약") +public record UnlockedNodeDto( + String id, String name, boolean isUnlocked, boolean isCleared, + @Schema(example = "2026-04-16T11:00:00Z") String unlockedAt +) { + private static final DateTimeFormatter ISO_UTC = DateTimeFormatter.ISO_INSTANT; + + public static UnlockedNodeDto of(ExplorationNode node, UserExploration progress, boolean cleared) { + return new UnlockedNodeDto( + node.getId(), node.getName(), true, cleared, + formatUtc(progress.getUnlockedAt())); + } + + private static String formatUtc(LocalDateTime time) { + return time == null ? null : ISO_UTC.format(time.toInstant(ZoneOffset.UTC)); + } +} +``` + +- [ ] **Step 5: RegionUnlockResponse + PlanetUnlockResponse** + +`RegionUnlockResponse.java`: + +```java +package com.elipair.spacestudyship.study.exploration.dto; + +import com.elipair.spacestudyship.study.exploration.entity.ExplorationNode; +import com.elipair.spacestudyship.study.exploration.entity.UserExploration; +import io.swagger.v3.oas.annotations.media.Schema; + +@Schema(description = "지역 해금 응답") +public record RegionUnlockResponse( + UnlockedNodeDto region, int fuelConsumed, int currentFuel, boolean planetCleared +) { + public static RegionUnlockResponse of(ExplorationNode region, UserExploration progress, + int fuelConsumed, int currentFuel, boolean planetCleared) { + return new RegionUnlockResponse( + UnlockedNodeDto.of(region, progress, true), + fuelConsumed, currentFuel, planetCleared); + } +} +``` + +`PlanetUnlockResponse.java`: + +```java +package com.elipair.spacestudyship.study.exploration.dto; + +import com.elipair.spacestudyship.study.exploration.entity.ExplorationNode; +import com.elipair.spacestudyship.study.exploration.entity.UserExploration; +import io.swagger.v3.oas.annotations.media.Schema; + +@Schema(description = "행성 해금 응답") +public record PlanetUnlockResponse( + UnlockedNodeDto planet, int fuelConsumed, int currentFuel +) { + public static PlanetUnlockResponse of(ExplorationNode planet, UserExploration progress, + int fuelConsumed, int currentFuel) { + return new PlanetUnlockResponse( + UnlockedNodeDto.of(planet, progress, false), + fuelConsumed, currentFuel); + } +} +``` + +- [ ] **Step 6: 컴파일 확인** + +Run: `./gradlew :SS-Study:compileJava` +Expected: BUILD SUCCESSFUL + +- [ ] **Step 7: Commit** + +```bash +git add SS-Study/src/main/java/com/elipair/spacestudyship/study/exploration/dto/ +git commit -m "탐험 도메인 구현 : feat : Exploration DTO 6종 추가 #27" +``` + +--- + +## Task 7: ExplorationService — 골격 + 목록 조회 2개 + +**Files:** +- Create: `.../exploration/service/ExplorationService.java` +- Test: `.../exploration/service/ExplorationServiceTest.java` + +> Mockito 단위 테스트(`@ExtendWith(MockitoExtension.class)`, repo + FuelService mock). + +- [ ] **Step 1: 실패 테스트** + +```java +package com.elipair.spacestudyship.study.exploration.service; + +import com.elipair.spacestudyship.common.exception.CustomException; +import com.elipair.spacestudyship.study.exploration.constant.NodeType; +import com.elipair.spacestudyship.study.exploration.dto.PlanetResponse; +import com.elipair.spacestudyship.study.exploration.dto.RegionResponse; +import com.elipair.spacestudyship.study.exploration.entity.ExplorationNode; +import com.elipair.spacestudyship.study.exploration.entity.UserExploration; +import com.elipair.spacestudyship.study.exploration.repository.ExplorationNodeRepository; +import com.elipair.spacestudyship.study.exploration.repository.UserExplorationRepository; +import com.elipair.spacestudyship.study.fuel.service.FuelService; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.util.List; +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.BDDMockito.given; + +@ExtendWith(MockitoExtension.class) +class ExplorationServiceTest { + + @Mock ExplorationNodeRepository nodeRepository; + @Mock UserExplorationRepository userExplorationRepository; + @Mock FuelService fuelService; + @InjectMocks ExplorationService service; + + private ExplorationNode planet(String id, int requiredFuel, String prereq, int sort) { + return ExplorationNode.builder().id(id).name(id).nodeType(NodeType.PLANET).depth(2) + .icon(id).parentId(null).prerequisiteNodeId(prereq) + .requiredFuel(requiredFuel).sortOrder(sort).description("").mapX(0).mapY(0).build(); + } + + private ExplorationNode region(String id, String parent, int requiredFuel, int sort) { + return ExplorationNode.builder().id(id).name(id).nodeType(NodeType.REGION).depth(3) + .icon(id).parentId(parent).prerequisiteNodeId(null) + .requiredFuel(requiredFuel).sortOrder(sort).description("").mapX(0).mapY(0).build(); + } + + @Test + @DisplayName("getPlanets: earth는 requiredFuel=0이라 암묵 해금, 진행도 파생") + void getPlanets_derivesUnlockAndProgress() { + given(nodeRepository.findByNodeTypeOrderBySortOrderAsc(NodeType.PLANET)) + .willReturn(List.of(planet("earth", 0, null, 0), planet("mercury", 3, "earth", 1))); + given(nodeRepository.findByNodeTypeOrderBySortOrderAsc(NodeType.REGION)) + .willReturn(List.of(region("korea", "earth", 0, 0), + region("japan", "earth", 1, 1))); + given(userExplorationRepository.findByUserId(1L)) + .willReturn(List.of(UserExploration.unlock(1L, "korea", true))); + + List result = service.getPlanets(1L); + + PlanetResponse earth = result.get(0); + assertThat(earth.id()).isEqualTo("earth"); + assertThat(earth.isUnlocked()).isTrue(); + assertThat(earth.isCleared()).isFalse(); + assertThat(earth.progress().clearedChildren()).isEqualTo(1); + assertThat(earth.progress().totalChildren()).isEqualTo(2); + assertThat(earth.progress().progressRatio()).isEqualTo(0.5); + + PlanetResponse mercury = result.get(1); + assertThat(mercury.isUnlocked()).isFalse(); + assertThat(mercury.prerequisiteId()).isEqualTo("earth"); + } + + @Test + @DisplayName("getRegions: 행성 없으면 PLANET_NOT_FOUND") + void getRegions_planetNotFound() { + given(nodeRepository.findById("nope")).willReturn(Optional.empty()); + + assertThatThrownBy(() -> service.getRegions(1L, "nope")) + .isInstanceOf(CustomException.class); + } + + @Test + @DisplayName("getRegions: 해금된 지역 isUnlocked/isCleared=true, korea(연료0) 암묵 해금") + void getRegions_mapsUnlock() { + given(nodeRepository.findById("earth")).willReturn(Optional.of(planet("earth", 0, null, 0))); + given(nodeRepository.findByParentIdOrderBySortOrderAsc("earth")) + .willReturn(List.of(region("korea", "earth", 0, 0), + region("japan", "earth", 1, 1))); + given(userExplorationRepository.findByUserId(1L)).willReturn(List.of()); + + List result = service.getRegions(1L, "earth"); + + assertThat(result).extracting(RegionResponse::id).containsExactly("korea", "japan"); + assertThat(result.get(0).isUnlocked()).isTrue(); // korea requiredFuel=0 → 암묵 해금 + assertThat(result.get(0).isCleared()).isTrue(); + assertThat(result.get(1).isUnlocked()).isFalse(); // japan 미해금 + } +} +``` + +- [ ] **Step 2: 실패 확인** + +Run: `./gradlew :SS-Study:test --tests "com.elipair.spacestudyship.study.exploration.service.ExplorationServiceTest"` +Expected: FAIL — `ExplorationService` 없음 + +- [ ] **Step 3: 서비스 구현 (조회 2개 + private 헬퍼)** + +```java +package com.elipair.spacestudyship.study.exploration.service; + +import com.elipair.spacestudyship.common.exception.CustomException; +import com.elipair.spacestudyship.common.exception.ErrorCode; +import com.elipair.spacestudyship.study.exploration.constant.NodeType; +import com.elipair.spacestudyship.study.exploration.dto.PlanetResponse; +import com.elipair.spacestudyship.study.exploration.dto.RegionResponse; +import com.elipair.spacestudyship.study.exploration.entity.ExplorationNode; +import com.elipair.spacestudyship.study.exploration.entity.UserExploration; +import com.elipair.spacestudyship.study.exploration.repository.ExplorationNodeRepository; +import com.elipair.spacestudyship.study.exploration.repository.UserExplorationRepository; +import com.elipair.spacestudyship.study.fuel.service.FuelService; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDateTime; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.function.Function; +import java.util.stream.Collectors; + +@Slf4j +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class ExplorationService { + + private final ExplorationNodeRepository nodeRepository; + private final UserExplorationRepository userExplorationRepository; + private final FuelService fuelService; + + public List getPlanets(Long userId) { + List planets = nodeRepository.findByNodeTypeOrderBySortOrderAsc(NodeType.PLANET); + List regions = nodeRepository.findByNodeTypeOrderBySortOrderAsc(NodeType.REGION); + Map progress = progressMap(userId); + Set unlocked = progress.keySet(); + + Map totalByParent = regions.stream() + .collect(Collectors.groupingBy(ExplorationNode::getParentId, Collectors.counting())); + Map clearedByParent = regions.stream() + .filter(r -> unlocked.contains(r.getId())) + .collect(Collectors.groupingBy(ExplorationNode::getParentId, Collectors.counting())); + + return planets.stream().map(p -> { + int total = totalByParent.getOrDefault(p.getId(), 0L).intValue(); + int cleared = clearedByParent.getOrDefault(p.getId(), 0L).intValue(); + boolean isUnlocked = p.getRequiredFuel() == 0 || unlocked.contains(p.getId()); + boolean isCleared = total > 0 && cleared == total; + double ratio = total == 0 ? 0.0 : (double) cleared / total; + LocalDateTime unlockedAt = progress.containsKey(p.getId()) + ? progress.get(p.getId()).getUnlockedAt() : null; + return PlanetResponse.of(p, isUnlocked, isCleared, cleared, total, ratio, unlockedAt); + }).toList(); + } + + public List getRegions(Long userId, String planetId) { + nodeRepository.findById(planetId) + .filter(n -> n.getNodeType() == NodeType.PLANET) + .orElseThrow(() -> new CustomException(ErrorCode.PLANET_NOT_FOUND)); + + List regions = nodeRepository.findByParentIdOrderBySortOrderAsc(planetId); + Map progress = progressMap(userId); + + return regions.stream().map(r -> { + UserExploration pr = progress.get(r.getId()); + boolean isUnlocked = r.getRequiredFuel() == 0 || pr != null; + LocalDateTime unlockedAt = pr == null ? null : pr.getUnlockedAt(); + return RegionResponse.of(r, isUnlocked, isUnlocked, unlockedAt); + }).toList(); + } + + private Map progressMap(Long userId) { + return userExplorationRepository.findByUserId(userId).stream() + .collect(Collectors.toMap(UserExploration::getNodeId, Function.identity())); + } + + private boolean isPlanetCleared(Long userId, String planetId) { + List regions = nodeRepository.findByParentIdOrderBySortOrderAsc(planetId); + if (regions.isEmpty()) { + return false; + } + Set unlocked = userExplorationRepository.findByUserId(userId).stream() + .map(UserExploration::getNodeId).collect(Collectors.toSet()); + return regions.stream().allMatch(r -> unlocked.contains(r.getId())); + } +} +``` + +- [ ] **Step 4: 통과 확인** + +Run: `./gradlew :SS-Study:test --tests "com.elipair.spacestudyship.study.exploration.service.ExplorationServiceTest"` +Expected: PASS (3 테스트) + +- [ ] **Step 5: Commit** + +```bash +git add SS-Study/src/main/java/com/elipair/spacestudyship/study/exploration/service/ExplorationService.java SS-Study/src/test/java/com/elipair/spacestudyship/study/exploration/service/ExplorationServiceTest.java +git commit -m "탐험 도메인 구현 : feat : ExplorationService 목록 조회 2종 #27" +``` + +--- + +## Task 8: ExplorationService — 지역 해금 (+ 잔량 pre-check) + +**Files:** +- Modify: `.../exploration/service/ExplorationService.java` +- Modify: `.../exploration/service/ExplorationServiceTest.java` + +- [ ] **Step 1: 실패 테스트 추가** + +import 추가: + +```java +import com.elipair.spacestudyship.common.exception.InsufficientFuelException; +import com.elipair.spacestudyship.study.fuel.constant.FuelReason; +import com.elipair.spacestudyship.study.fuel.dto.FuelResponse; +import com.elipair.spacestudyship.study.fuel.dto.FuelTransactionResponse; +import org.mockito.ArgumentCaptor; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyInt; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +``` + +테스트 추가: + +```java + private FuelResponse fuel(int currentFuel) { + return new FuelResponse(currentFuel, 0, 0, 0, null); + } + + private FuelTransactionResponse tx(int amount, int balanceAfter) { + return new FuelTransactionResponse( + "tx", "consume", amount, "EXPLORATION_UNLOCK", "ref", balanceAfter, null); + } + + @Test + @DisplayName("unlockRegion: 정상 해금 — 잔량충분 + 차감 + 저장 + 마지막 지역이면 planetCleared=true") + void unlockRegion_success_lastRegionClearsPlanet() { + given(nodeRepository.findById("japan")) + .willReturn(Optional.of(region("japan", "earth", 1, 1))); + given(nodeRepository.findById("earth")) + .willReturn(Optional.of(planet("earth", 0, null, 0))); + given(userExplorationRepository.existsByUserIdAndNodeId(1L, "japan")).willReturn(false); + given(fuelService.getFuel(1L)).willReturn(fuel(250)); + given(fuelService.consume(eq(1L), eq(1), eq(FuelReason.EXPLORATION_UNLOCK), eq("japan"), anyString())) + .willReturn(tx(1, 249)); + given(userExplorationRepository.save(any(UserExploration.class))) + .willAnswer(inv -> inv.getArgument(0)); + given(nodeRepository.findByParentIdOrderBySortOrderAsc("earth")) + .willReturn(List.of(region("korea", "earth", 0, 0), region("japan", "earth", 1, 1))); + given(userExplorationRepository.findByUserId(1L)) + .willReturn(List.of(UserExploration.unlock(1L, "korea", true), + UserExploration.unlock(1L, "japan", true))); + + var result = service.unlockRegion(1L, "japan"); + + assertThat(result.region().id()).isEqualTo("japan"); + assertThat(result.region().isCleared()).isTrue(); + assertThat(result.fuelConsumed()).isEqualTo(1); + assertThat(result.currentFuel()).isEqualTo(249); + assertThat(result.planetCleared()).isTrue(); + + ArgumentCaptor captor = ArgumentCaptor.forClass(UserExploration.class); + verify(userExplorationRepository).save(captor.capture()); + assertThat(captor.getValue().getNodeId()).isEqualTo("japan"); + assertThat(captor.getValue().isCleared()).isTrue(); + } + + @Test + @DisplayName("unlockRegion: 잔량 부족 → InsufficientFuelException + consume 미호출") + void unlockRegion_insufficientFuel() { + given(nodeRepository.findById("usa")) + .willReturn(Optional.of(region("usa", "earth", 3, 8))); + given(nodeRepository.findById("earth")) + .willReturn(Optional.of(planet("earth", 0, null, 0))); + given(userExplorationRepository.existsByUserIdAndNodeId(1L, "usa")).willReturn(false); + given(fuelService.getFuel(1L)).willReturn(fuel(1)); + + assertThatThrownBy(() -> service.unlockRegion(1L, "usa")) + .isInstanceOf(InsufficientFuelException.class); + verify(fuelService, never()).consume(any(), anyInt(), any(), any(), any()); + } + + @Test + @DisplayName("unlockRegion: 부모 행성 미해금 → PLANET_LOCKED") + void unlockRegion_parentLocked() { + given(nodeRepository.findById("mars_olympus")) + .willReturn(Optional.of(region("mars_olympus", "mars", 3, 0))); + given(nodeRepository.findById("mars")) + .willReturn(Optional.of(planet("mars", 10, "venus", 3))); + given(userExplorationRepository.existsByUserIdAndNodeId(1L, "mars")).willReturn(false); + + assertThatThrownBy(() -> service.unlockRegion(1L, "mars_olympus")) + .isInstanceOf(CustomException.class); + verify(fuelService, never()).consume(any(), anyInt(), any(), any(), any()); + } + + @Test + @DisplayName("unlockRegion: 이미 해금 → ALREADY_UNLOCKED") + void unlockRegion_alreadyUnlocked() { + given(nodeRepository.findById("japan")) + .willReturn(Optional.of(region("japan", "earth", 1, 1))); + given(nodeRepository.findById("earth")) + .willReturn(Optional.of(planet("earth", 0, null, 0))); + given(userExplorationRepository.existsByUserIdAndNodeId(1L, "japan")).willReturn(true); + + assertThatThrownBy(() -> service.unlockRegion(1L, "japan")) + .isInstanceOf(CustomException.class); + verify(fuelService, never()).consume(any(), anyInt(), any(), any(), any()); + } + + @Test + @DisplayName("unlockRegion: 없는 지역 → REGION_NOT_FOUND") + void unlockRegion_notFound() { + given(nodeRepository.findById("nope")).willReturn(Optional.empty()); + + assertThatThrownBy(() -> service.unlockRegion(1L, "nope")) + .isInstanceOf(CustomException.class); + } +``` + +- [ ] **Step 2: 실패 확인** + +Run: `./gradlew :SS-Study:test --tests "com.elipair.spacestudyship.study.exploration.service.ExplorationServiceTest"` +Expected: FAIL — `unlockRegion` 없음 + +- [ ] **Step 3: 서비스에 import + unlockRegion 추가** + +import 추가: + +```java +import com.elipair.spacestudyship.common.exception.InsufficientFuelException; +import com.elipair.spacestudyship.study.exploration.dto.RegionUnlockResponse; +import com.elipair.spacestudyship.study.fuel.constant.FuelReason; +import com.elipair.spacestudyship.study.fuel.dto.FuelTransactionResponse; + +import java.util.UUID; +``` + +메서드 추가: + +```java + @Transactional + public RegionUnlockResponse unlockRegion(Long userId, String regionId) { + ExplorationNode region = nodeRepository.findById(regionId) + .filter(n -> n.getNodeType() == NodeType.REGION) + .orElseThrow(() -> new CustomException(ErrorCode.REGION_NOT_FOUND)); + + ExplorationNode parent = nodeRepository.findById(region.getParentId()) + .orElseThrow(() -> new CustomException(ErrorCode.PLANET_NOT_FOUND)); + boolean parentUnlocked = parent.getRequiredFuel() == 0 + || userExplorationRepository.existsByUserIdAndNodeId(userId, parent.getId()); + if (!parentUnlocked) { + throw new CustomException(ErrorCode.PLANET_LOCKED); + } + + if (region.getRequiredFuel() == 0 + || userExplorationRepository.existsByUserIdAndNodeId(userId, regionId)) { + throw new CustomException(ErrorCode.ALREADY_UNLOCKED); + } + + requireFuel(userId, region.getRequiredFuel()); + + FuelTransactionResponse fuelTx = fuelService.consume( + userId, region.getRequiredFuel(), FuelReason.EXPLORATION_UNLOCK, + regionId, UUID.randomUUID().toString()); + + UserExploration saved = userExplorationRepository.save( + UserExploration.unlock(userId, regionId, true)); + + boolean planetCleared = isPlanetCleared(userId, parent.getId()); + + log.info("[Exploration] 지역 해금 | userId={}, regionId={}, fuel={}, planetCleared={}", + userId, regionId, region.getRequiredFuel(), planetCleared); + + return RegionUnlockResponse.of(region, saved, + fuelTx.amount(), fuelTx.balanceAfter(), planetCleared); + } + + private void requireFuel(Long userId, int requiredFuel) { + int currentFuel = fuelService.getFuel(userId).currentFuel(); + if (currentFuel < requiredFuel) { + throw new InsufficientFuelException(requiredFuel, currentFuel); + } + } +``` + +- [ ] **Step 4: 통과 확인** + +Run: `./gradlew :SS-Study:test --tests "com.elipair.spacestudyship.study.exploration.service.ExplorationServiceTest"` +Expected: PASS + +- [ ] **Step 5: Commit** + +```bash +git add SS-Study/src/main/java/com/elipair/spacestudyship/study/exploration/service/ExplorationService.java SS-Study/src/test/java/com/elipair/spacestudyship/study/exploration/service/ExplorationServiceTest.java +git commit -m "탐험 도메인 구현 : feat : 지역 해금 로직 + 잔량 pre-check + 자동 클리어 #27" +``` + +--- + +## Task 9: ExplorationService — 행성 해금 (선행 게이트) + +**Files:** +- Modify: `.../exploration/service/ExplorationService.java` +- Modify: `.../exploration/service/ExplorationServiceTest.java` + +- [ ] **Step 1: 실패 테스트 추가** + +```java + @Test + @DisplayName("unlockPlanet: 선행 행성 클리어 시 정상 해금") + void unlockPlanet_success() { + given(nodeRepository.findById("mercury")) + .willReturn(Optional.of(planet("mercury", 3, "earth", 1))); + given(userExplorationRepository.existsByUserIdAndNodeId(1L, "mercury")).willReturn(false); + given(nodeRepository.findByParentIdOrderBySortOrderAsc("earth")) + .willReturn(List.of(region("korea", "earth", 0, 0))); + given(userExplorationRepository.findByUserId(1L)) + .willReturn(List.of(UserExploration.unlock(1L, "korea", true))); + given(fuelService.getFuel(1L)).willReturn(fuel(100)); + given(fuelService.consume(eq(1L), eq(3), eq(FuelReason.EXPLORATION_UNLOCK), eq("mercury"), anyString())) + .willReturn(tx(3, 97)); + given(userExplorationRepository.save(any(UserExploration.class))) + .willAnswer(inv -> inv.getArgument(0)); + + var result = service.unlockPlanet(1L, "mercury"); + + assertThat(result.planet().id()).isEqualTo("mercury"); + assertThat(result.planet().isCleared()).isFalse(); + assertThat(result.fuelConsumed()).isEqualTo(3); + assertThat(result.currentFuel()).isEqualTo(97); + } + + @Test + @DisplayName("unlockPlanet: 선행 미클리어 → PREREQUISITE_NOT_CLEARED + consume 미호출") + void unlockPlanet_prerequisiteNotCleared() { + given(nodeRepository.findById("mercury")) + .willReturn(Optional.of(planet("mercury", 3, "earth", 1))); + given(userExplorationRepository.existsByUserIdAndNodeId(1L, "mercury")).willReturn(false); + given(nodeRepository.findByParentIdOrderBySortOrderAsc("earth")) + .willReturn(List.of(region("korea", "earth", 0, 0), region("japan", "earth", 1, 1))); + given(userExplorationRepository.findByUserId(1L)) + .willReturn(List.of(UserExploration.unlock(1L, "korea", true))); // 1/2만 + + assertThatThrownBy(() -> service.unlockPlanet(1L, "mercury")) + .isInstanceOf(CustomException.class); + verify(fuelService, never()).consume(any(), anyInt(), any(), any(), any()); + } + + @Test + @DisplayName("unlockPlanet: 잔량 부족 → InsufficientFuelException + consume 미호출") + void unlockPlanet_insufficientFuel() { + given(nodeRepository.findById("mercury")) + .willReturn(Optional.of(planet("mercury", 3, "earth", 1))); + given(userExplorationRepository.existsByUserIdAndNodeId(1L, "mercury")).willReturn(false); + given(nodeRepository.findByParentIdOrderBySortOrderAsc("earth")) + .willReturn(List.of(region("korea", "earth", 0, 0))); + given(userExplorationRepository.findByUserId(1L)) + .willReturn(List.of(UserExploration.unlock(1L, "korea", true))); + given(fuelService.getFuel(1L)).willReturn(fuel(1)); + + assertThatThrownBy(() -> service.unlockPlanet(1L, "mercury")) + .isInstanceOf(InsufficientFuelException.class); + verify(fuelService, never()).consume(any(), anyInt(), any(), any(), any()); + } + + @Test + @DisplayName("unlockPlanet: 이미 해금 → ALREADY_UNLOCKED") + void unlockPlanet_alreadyUnlocked() { + given(nodeRepository.findById("mercury")) + .willReturn(Optional.of(planet("mercury", 3, "earth", 1))); + given(userExplorationRepository.existsByUserIdAndNodeId(1L, "mercury")).willReturn(true); + + assertThatThrownBy(() -> service.unlockPlanet(1L, "mercury")) + .isInstanceOf(CustomException.class); + verify(fuelService, never()).consume(any(), anyInt(), any(), any(), any()); + } + + @Test + @DisplayName("unlockPlanet: 없는 행성 → PLANET_NOT_FOUND") + void unlockPlanet_notFound() { + given(nodeRepository.findById("nope")).willReturn(Optional.empty()); + + assertThatThrownBy(() -> service.unlockPlanet(1L, "nope")) + .isInstanceOf(CustomException.class); + } +``` + +- [ ] **Step 2: 실패 확인** + +Run: `./gradlew :SS-Study:test --tests "com.elipair.spacestudyship.study.exploration.service.ExplorationServiceTest"` +Expected: FAIL — `unlockPlanet` 없음 + +- [ ] **Step 3: import + unlockPlanet 추가** + +import 추가: + +```java +import com.elipair.spacestudyship.study.exploration.dto.PlanetUnlockResponse; +``` + +메서드 추가: + +```java + @Transactional + public PlanetUnlockResponse unlockPlanet(Long userId, String planetId) { + ExplorationNode planet = nodeRepository.findById(planetId) + .filter(n -> n.getNodeType() == NodeType.PLANET) + .orElseThrow(() -> new CustomException(ErrorCode.PLANET_NOT_FOUND)); + + if (planet.getRequiredFuel() == 0 + || userExplorationRepository.existsByUserIdAndNodeId(userId, planetId)) { + throw new CustomException(ErrorCode.ALREADY_UNLOCKED); + } + + if (planet.getPrerequisiteNodeId() != null + && !isPlanetCleared(userId, planet.getPrerequisiteNodeId())) { + throw new CustomException(ErrorCode.PREREQUISITE_NOT_CLEARED); + } + + requireFuel(userId, planet.getRequiredFuel()); + + FuelTransactionResponse fuelTx = fuelService.consume( + userId, planet.getRequiredFuel(), FuelReason.EXPLORATION_UNLOCK, + planetId, UUID.randomUUID().toString()); + + UserExploration saved = userExplorationRepository.save( + UserExploration.unlock(userId, planetId, false)); + + log.info("[Exploration] 행성 해금 | userId={}, planetId={}, fuel={}", + userId, planetId, planet.getRequiredFuel()); + + return PlanetUnlockResponse.of(planet, saved, fuelTx.amount(), fuelTx.balanceAfter()); + } +``` + +- [ ] **Step 4: 통과 확인 (서비스 전체)** + +Run: `./gradlew :SS-Study:test --tests "com.elipair.spacestudyship.study.exploration.*"` +Expected: PASS + +- [ ] **Step 5: Commit** + +```bash +git add SS-Study/src/main/java/com/elipair/spacestudyship/study/exploration/service/ExplorationService.java SS-Study/src/test/java/com/elipair/spacestudyship/study/exploration/service/ExplorationServiceTest.java +git commit -m "탐험 도메인 구현 : feat : 행성 해금 로직 + 선행 클리어 게이트 #27" +``` + +--- + +## Task 10: ExplorationController + MockMvc 테스트 + +**Files:** +- Create: `SS-Web/src/main/java/com/elipair/spacestudyship/controller/exploration/ExplorationController.java` +- Test: `SS-Web/src/test/java/com/elipair/spacestudyship/controller/exploration/ExplorationControllerTest.java` + +- [ ] **Step 1: 실패 테스트** + +```java +package com.elipair.spacestudyship.controller.exploration; + +import com.elipair.spacestudyship.auth.interceptor.LoginMember; +import com.elipair.spacestudyship.common.exception.CustomException; +import com.elipair.spacestudyship.common.exception.ErrorCode; +import com.elipair.spacestudyship.common.exception.GlobalExceptionHandler; +import com.elipair.spacestudyship.common.exception.InsufficientFuelException; +import com.elipair.spacestudyship.study.exploration.constant.NodeType; +import com.elipair.spacestudyship.study.exploration.dto.PlanetResponse; +import com.elipair.spacestudyship.study.exploration.dto.PlanetUnlockResponse; +import com.elipair.spacestudyship.study.exploration.dto.RegionResponse; +import com.elipair.spacestudyship.study.exploration.dto.RegionUnlockResponse; +import com.elipair.spacestudyship.study.exploration.dto.UnlockedNodeDto; +import com.elipair.spacestudyship.study.exploration.entity.ExplorationNode; +import com.elipair.spacestudyship.study.exploration.service.ExplorationService; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.core.MethodParameter; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.setup.MockMvcBuilders; +import org.springframework.web.method.support.HandlerMethodArgumentResolver; +import org.springframework.web.method.support.ModelAndViewContainer; + +import java.util.List; + +import static org.mockito.BDDMockito.given; +import static org.mockito.BDDMockito.willThrow; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +@ExtendWith(MockitoExtension.class) +class ExplorationControllerTest { + + @Mock ExplorationService explorationService; + @InjectMocks ExplorationController controller; + + MockMvc mockMvc; + + private ExplorationNode planetNode() { + return ExplorationNode.builder().id("earth").name("지구").nodeType(NodeType.PLANET) + .depth(2).icon("earth").requiredFuel(0).sortOrder(0) + .description("시작점").mapX(0.5).mapY(0.08).build(); + } + + private ExplorationNode regionNode() { + return ExplorationNode.builder().id("korea").name("대한민국").nodeType(NodeType.REGION) + .depth(3).icon("KR").parentId("earth").requiredFuel(0).sortOrder(0) + .description("한반도").mapX(0).mapY(0).build(); + } + + @BeforeEach + void setUp() { + HandlerMethodArgumentResolver loginMemberStub = new HandlerMethodArgumentResolver() { + @Override + public boolean supportsParameter(MethodParameter parameter) { + return parameter.getParameterType().equals(LoginMember.class); + } + @Override + public Object resolveArgument(MethodParameter parameter, ModelAndViewContainer mavContainer, + org.springframework.web.context.request.NativeWebRequest webRequest, + org.springframework.web.bind.support.WebDataBinderFactory binderFactory) { + return new LoginMember(1L); + } + }; + mockMvc = MockMvcBuilders.standaloneSetup(controller) + .setControllerAdvice(new GlobalExceptionHandler()) + .setCustomArgumentResolvers(loginMemberStub) + .build(); + } + + @Test + @DisplayName("GET /api/explorations/planets — 200, nodeType 소문자") + void getPlanets_200() throws Exception { + given(explorationService.getPlanets(1L)).willReturn(List.of( + PlanetResponse.of(planetNode(), true, false, 1, 2, 0.5, null))); + + mockMvc.perform(get("/api/explorations/planets")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$[0].id").value("earth")) + .andExpect(jsonPath("$[0].nodeType").value("planet")) + .andExpect(jsonPath("$[0].isUnlocked").value(true)) + .andExpect(jsonPath("$[0].progress.totalChildren").value(2)); + } + + @Test + @DisplayName("GET /api/explorations/planets/{id}/regions — 200") + void getRegions_200() throws Exception { + given(explorationService.getRegions(1L, "earth")).willReturn(List.of( + RegionResponse.of(regionNode(), true, true, null))); + + mockMvc.perform(get("/api/explorations/planets/earth/regions")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$[0].id").value("korea")) + .andExpect(jsonPath("$[0].nodeType").value("region")); + } + + @Test + @DisplayName("GET regions — 행성 없음 404 PLANET_NOT_FOUND") + void getRegions_404() throws Exception { + given(explorationService.getRegions(1L, "nope")) + .willThrow(new CustomException(ErrorCode.PLANET_NOT_FOUND)); + + mockMvc.perform(get("/api/explorations/planets/nope/regions")) + .andExpect(status().isNotFound()) + .andExpect(jsonPath("$.code").value("PLANET_NOT_FOUND")) + .andExpect(jsonPath("$.requiredFuel").doesNotExist()); + } + + @Test + @DisplayName("POST /api/explorations/regions/{id}/unlock — 200") + void unlockRegion_200() throws Exception { + given(explorationService.unlockRegion(1L, "japan")).willReturn( + new RegionUnlockResponse( + new UnlockedNodeDto("japan", "일본", true, true, "2026-04-16T11:00:00Z"), + 1, 249, false)); + + mockMvc.perform(post("/api/explorations/regions/japan/unlock")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.region.id").value("japan")) + .andExpect(jsonPath("$.fuelConsumed").value(1)) + .andExpect(jsonPath("$.currentFuel").value(249)) + .andExpect(jsonPath("$.planetCleared").value(false)); + } + + @Test + @DisplayName("POST region unlock — 연료 부족 400 + requiredFuel/currentFuel 본문") + void unlockRegion_insufficientFuel_400() throws Exception { + willThrow(new InsufficientFuelException(3, 1)) + .given(explorationService).unlockRegion(1L, "usa"); + + mockMvc.perform(post("/api/explorations/regions/usa/unlock")) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.code").value("INSUFFICIENT_FUEL")) + .andExpect(jsonPath("$.requiredFuel").value(3)) + .andExpect(jsonPath("$.currentFuel").value(1)); + } + + @Test + @DisplayName("POST /api/explorations/planets/{id}/unlock — 200") + void unlockPlanet_200() throws Exception { + given(explorationService.unlockPlanet(1L, "mercury")).willReturn( + new PlanetUnlockResponse( + new UnlockedNodeDto("mercury", "수성", true, false, "2026-04-16T11:30:00Z"), + 3, 97)); + + mockMvc.perform(post("/api/explorations/planets/mercury/unlock")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.planet.id").value("mercury")) + .andExpect(jsonPath("$.fuelConsumed").value(3)) + .andExpect(jsonPath("$.currentFuel").value(97)); + } + + @Test + @DisplayName("POST planet unlock — 선행 미클리어 400 PREREQUISITE_NOT_CLEARED") + void unlockPlanet_prerequisite_400() throws Exception { + willThrow(new CustomException(ErrorCode.PREREQUISITE_NOT_CLEARED)) + .given(explorationService).unlockPlanet(1L, "mercury"); + + mockMvc.perform(post("/api/explorations/planets/mercury/unlock")) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.code").value("PREREQUISITE_NOT_CLEARED")); + } +} +``` + +- [ ] **Step 2: 실패 확인** + +Run: `./gradlew :SS-Web:test --tests "com.elipair.spacestudyship.controller.exploration.ExplorationControllerTest"` +Expected: FAIL — `ExplorationController` 없음 + +- [ ] **Step 3: 컨트롤러 구현** + +```java +package com.elipair.spacestudyship.controller.exploration; + +import com.elipair.spacestudyship.auth.interceptor.AuthMember; +import com.elipair.spacestudyship.auth.interceptor.LoginMember; +import com.elipair.spacestudyship.study.exploration.dto.PlanetResponse; +import com.elipair.spacestudyship.study.exploration.dto.PlanetUnlockResponse; +import com.elipair.spacestudyship.study.exploration.dto.RegionResponse; +import com.elipair.spacestudyship.study.exploration.dto.RegionUnlockResponse; +import com.elipair.spacestudyship.study.exploration.service.ExplorationService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import java.util.List; + +@Tag(name = "Exploration", description = "우주 탐험(행성/지역 해금) API") +@RestController +@RequiredArgsConstructor +@RequestMapping("/api/explorations") +public class ExplorationController { + + private final ExplorationService explorationService; + + @Operation(summary = "행성 목록 조회", + description = "전체 행성 목록과 유저의 해금/클리어 상태, 진행도를 반환합니다. 정렬: sortOrder 오름차순.") + @GetMapping("/planets") + public ResponseEntity> getPlanets(@AuthMember LoginMember loginMember) { + return ResponseEntity.ok(explorationService.getPlanets(loginMember.memberId())); + } + + @Operation(summary = "행성 하위 지역 목록 조회", + description = "특정 행성의 하위 지역과 유저 해금 상태를 반환합니다. 행성이 없으면 404 PLANET_NOT_FOUND.") + @GetMapping("/planets/{planetId}/regions") + public ResponseEntity> getRegions( + @AuthMember LoginMember loginMember, + @PathVariable String planetId) { + return ResponseEntity.ok(explorationService.getRegions(loginMember.memberId(), planetId)); + } + + @Operation(summary = "지역 해금", + description = """ + 연료를 소비하여 지역을 해금합니다(해금=클리어). 잔량 확인+차감+해금을 원자적으로 처리합니다. + 상위 행성의 모든 지역이 해금되면 planetCleared=true. + + 에러: 400 INSUFFICIENT_FUEL(requiredFuel/currentFuel 동봉) / ALREADY_UNLOCKED / PLANET_LOCKED, 404 REGION_NOT_FOUND + """) + @PostMapping("/regions/{regionId}/unlock") + public ResponseEntity unlockRegion( + @AuthMember LoginMember loginMember, + @PathVariable String regionId) { + return ResponseEntity.ok(explorationService.unlockRegion(loginMember.memberId(), regionId)); + } + + @Operation(summary = "행성 해금", + description = """ + 연료를 소비하여 행성을 해금합니다. 선행 행성을 클리어해야 해금할 수 있습니다. + + 에러: 400 INSUFFICIENT_FUEL(requiredFuel/currentFuel 동봉) / ALREADY_UNLOCKED / PREREQUISITE_NOT_CLEARED, 404 PLANET_NOT_FOUND + """) + @PostMapping("/planets/{planetId}/unlock") + public ResponseEntity unlockPlanet( + @AuthMember LoginMember loginMember, + @PathVariable String planetId) { + return ResponseEntity.ok(explorationService.unlockPlanet(loginMember.memberId(), planetId)); + } +} +``` + +- [ ] **Step 4: 통과 확인** + +Run: `./gradlew :SS-Web:test --tests "com.elipair.spacestudyship.controller.exploration.ExplorationControllerTest"` +Expected: PASS (7 테스트) + +- [ ] **Step 5: Commit** + +```bash +git add SS-Web/src/main/java/com/elipair/spacestudyship/controller/exploration/ SS-Web/src/test/java/com/elipair/spacestudyship/controller/exploration/ +git commit -m "탐험 도메인 구현 : feat : ExplorationController 4 엔드포인트 + 테스트 #27" +``` + +--- + +## Task 11: Flyway 마이그레이션 (스키마 + 시드 38노드) + +**Files:** +- Create: `SS-Web/src/main/resources/db/migration/V0_0_42__add_exploration.sql` +- Modify: `CLAUDE.md` (마이그레이션 이력표) + +> **시작 전:** `version.yml`의 `version` 확인. `V0_0_42__*.sql`가 이미 있으면 현재 version.yml 값으로 파일명 변경. 현재 가정: `0.0.42`. + +- [ ] **Step 1: 마이그레이션 작성** + +```sql +-- exploration_nodes: 행성/지역 마스터 (시드, 읽기 전용) +CREATE TABLE IF NOT EXISTS exploration_nodes ( + id VARCHAR(50) PRIMARY KEY, + name VARCHAR(50) NOT NULL, + node_type VARCHAR(10) NOT NULL, + depth INTEGER NOT NULL, + icon VARCHAR(30) NOT NULL, + parent_id VARCHAR(50), + prerequisite_node_id VARCHAR(50), + required_fuel INTEGER NOT NULL DEFAULT 0, + sort_order INTEGER NOT NULL DEFAULT 0, + description VARCHAR(200) NOT NULL DEFAULT '', + map_x DOUBLE PRECISION NOT NULL DEFAULT 0, + map_y DOUBLE PRECISION NOT NULL DEFAULT 0, + CONSTRAINT fk_expl_node_parent FOREIGN KEY (parent_id) REFERENCES exploration_nodes(id), + CONSTRAINT fk_expl_node_prerequisite FOREIGN KEY (prerequisite_node_id) REFERENCES exploration_nodes(id), + CONSTRAINT chk_expl_node_type CHECK (node_type IN ('planet','region')), + CONSTRAINT chk_expl_required_fuel_non_negative CHECK (required_fuel >= 0) +); + +-- user_exploration_progress: 유저별 해금 상태 (행 존재 = 해금) +CREATE TABLE IF NOT EXISTS user_exploration_progress ( + id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, + user_id BIGINT NOT NULL, + node_id VARCHAR(50) NOT NULL, + is_unlocked BOOLEAN NOT NULL DEFAULT TRUE, + is_cleared BOOLEAN NOT NULL DEFAULT FALSE, + unlocked_at TIMESTAMP NOT NULL, + created_at TIMESTAMP NOT NULL, + updated_at TIMESTAMP NOT NULL, + CONSTRAINT fk_user_expl_member FOREIGN KEY (user_id) REFERENCES members(id) ON DELETE CASCADE, + CONSTRAINT fk_user_expl_node FOREIGN KEY (node_id) REFERENCES exploration_nodes(id), + CONSTRAINT uq_user_expl UNIQUE (user_id, node_id) +); + +CREATE INDEX IF NOT EXISTS idx_user_expl_user ON user_exploration_progress (user_id); + +-- 시드: 행성 8 (행성 먼저) +INSERT INTO exploration_nodes (id, name, node_type, depth, icon, parent_id, prerequisite_node_id, required_fuel, sort_order, description, map_x, map_y) VALUES + ('earth', '지구', 'planet', 2, 'earth', NULL, NULL, 0, 0, '우리의 출발지, 고향 행성', 0.5, 0.08), + ('mercury', '수성', 'planet', 2, 'mercury', NULL, 'earth', 3, 1, '태양에 가장 가까운 작은 행성', 0.15, 0.20), + ('venus', '금성', 'planet', 2, 'venus', NULL, 'mercury', 5, 2, '두꺼운 대기로 뒤덮인 뜨거운 행성', 0.75, 0.32), + ('mars', '화성', 'planet', 2, 'mars', NULL, 'venus', 10, 3, '붉은 행성, 탐험의 꿈', 0.25, 0.44), + ('jupiter', '목성', 'planet', 2, 'jupiter', NULL, 'mars', 20, 4, '태양계 최대의 가스 행성', 0.7, 0.56), + ('saturn', '토성', 'planet', 2, 'saturn', NULL, 'jupiter', 30, 5, '아름다운 고리를 가진 행성', 0.2, 0.68), + ('uranus', '천왕성', 'planet', 2, 'uranus', NULL, 'saturn', 45, 6, '옆으로 누워 자전하는 얼음 행성', 0.8, 0.80), + ('neptune', '해왕성', 'planet', 2, 'neptune', NULL, 'uranus', 60, 7, '태양계 끝자락의 푸른 행성', 0.35, 0.92) +ON CONFLICT (id) DO NOTHING; + +-- 시드: 지역 30 (지구 12 + 그 외 18). region은 prerequisite NULL, map 0/0. +INSERT INTO exploration_nodes (id, name, node_type, depth, icon, parent_id, prerequisite_node_id, required_fuel, sort_order, description, map_x, map_y) VALUES + ('korea', '대한민국', 'region', 3, 'KR', 'earth', NULL, 0, 0, '한반도 남쪽, K-컬쳐의 중심', 0, 0), + ('japan', '일본', 'region', 3, 'JP', 'earth', NULL, 1, 1, '벚꽃과 기술의 나라', 0, 0), + ('thailand', '태국', 'region', 3, 'TH', 'earth', NULL, 1, 2, '미소의 나라, 동남아의 허브', 0, 0), + ('china', '중국', 'region', 3, 'CN', 'earth', NULL, 2, 3, '세계 최대 인구 대국', 0, 0), + ('india', '인도', 'region', 3, 'IN', 'earth', NULL, 2, 4, 'IT 강국, 다양한 문화의 보고', 0, 0), + ('uk', '영국', 'region', 3, 'GB', 'earth', NULL, 2, 5, '해가 지지 않는 나라', 0, 0), + ('france', '프랑스', 'region', 3, 'FR', 'earth', NULL, 2, 6, '예술과 낭만의 나라', 0, 0), + ('canada', '캐나다', 'region', 3, 'CA', 'earth', NULL, 2, 7, '단풍과 자연의 나라', 0, 0), + ('usa', '미국', 'region', 3, 'US', 'earth', NULL, 3, 8, '자유의 나라, 기회의 땅', 0, 0), + ('brazil', '브라질', 'region', 3, 'BR', 'earth', NULL, 3, 9, '삼바와 축구의 나라', 0, 0), + ('australia', '호주', 'region', 3, 'AU', 'earth', NULL, 3, 10, '코알라와 캥거루의 대륙', 0, 0), + ('egypt', '이집트', 'region', 3, 'EG', 'earth', NULL, 2, 11, '피라미드와 나일강의 나라', 0, 0), + ('mercury_caloris', '칼로리스 분지', 'region', 3, 'mercury', 'mercury', NULL, 1, 0, '수성 최대의 충돌 분지', 0, 0), + ('mercury_plains', '북극 평원', 'region', 3, 'mercury', 'mercury', NULL, 2, 1, '얼음이 숨겨진 영구 그림자 지대', 0, 0), + ('venus_ishtar', '이슈타르 대지', 'region', 3, 'venus', 'venus', NULL, 2, 0, '금성 북반구의 거대한 고원 지대', 0, 0), + ('venus_aphrodite', '아프로디테 대지','region', 3, 'venus', 'venus', NULL, 3, 1, '금성 적도를 따라 펼쳐진 최대 대지', 0, 0), + ('venus_maxwell', '맥스웰 산', 'region', 3, 'venus', 'venus', NULL, 3, 2, '금성에서 가장 높은 산맥', 0, 0), + ('mars_olympus', '올림푸스 산', 'region', 3, 'mars', 'mars', NULL, 3, 0, '태양계에서 가장 높은 화산', 0, 0), + ('mars_valles', '마리너 계곡', 'region', 3, 'mars', 'mars', NULL, 4, 1, '태양계 최대의 협곡', 0, 0), + ('mars_polar', '극관 지대', 'region', 3, 'mars', 'mars', NULL, 5, 2, '드라이아이스와 물 얼음의 극지방', 0, 0), + ('jupiter_red_spot', '대적점', 'region', 3, 'jupiter', 'jupiter', NULL, 5, 0, '수백 년간 지속되는 거대 폭풍', 0, 0), + ('jupiter_europa', '유로파', 'region', 3, 'jupiter', 'jupiter', NULL, 7, 1, '얼음 아래 바다가 있는 위성', 0, 0), + ('jupiter_io', '이오', 'region', 3, 'jupiter', 'jupiter', NULL, 8, 2, '화산 활동이 가장 활발한 위성', 0, 0), + ('saturn_rings', '토성 고리', 'region', 3, 'saturn', 'saturn', NULL, 8, 0, '얼음과 먼지로 이루어진 아름다운 고리', 0, 0), + ('saturn_titan', '타이탄', 'region', 3, 'saturn', 'saturn', NULL, 10, 1, '대기를 가진 유일한 위성, 메탄의 호수', 0, 0), + ('saturn_enceladus', '엔셀라두스', 'region', 3, 'saturn', 'saturn', NULL, 12, 2, '간헐천이 분출하는 얼음 위성', 0, 0), + ('uranus_miranda', '미란다', 'region', 3, 'uranus', 'uranus', NULL, 12, 0, '기괴한 지형의 작은 위성', 0, 0), + ('uranus_atmosphere', '천왕성 대기', 'region', 3, 'uranus', 'uranus', NULL, 15, 1, '메탄이 만드는 청록빛 대기', 0, 0), + ('neptune_dark_spot', '대흑점', 'region', 3, 'neptune', 'neptune', NULL, 15, 0, '초속 2000km 폭풍의 소용돌이', 0, 0), + ('neptune_triton', '트리톤', 'region', 3, 'neptune', 'neptune', NULL, 20, 1, '역행 궤도를 도는 거대 위성', 0, 0) +ON CONFLICT (id) DO NOTHING; +``` + +- [ ] **Step 2: 빌드/회귀 확인** + +Run: `./gradlew :SS-Study:test :SS-Web:test` +Expected: BUILD SUCCESSFUL (Flyway는 테스트에서 비활성이지만 회귀 확인) + +- [ ] **Step 3: CLAUDE.md 이력표 갱신** + +"현재 마이그레이션 이력" 표에 행 추가: + +``` +| 0.0.42 | `V0_0_42__add_exploration.sql` | `exploration_nodes`, `user_exploration_progress` 테이블 + 행성/지역 시드 38노드 (프론트 시드 미러, self-FK, FK CASCADE, UNIQUE) | +``` + +- [ ] **Step 4: Commit** + +```bash +git add SS-Web/src/main/resources/db/migration/V0_0_42__add_exploration.sql CLAUDE.md +git commit -m "탐험 도메인 구현 : chore : exploration 테이블 + 시드 38노드 마이그레이션 #27" +``` + +--- + +## Task 12: API 스펙 문서 갱신 + +**Files:** +- Modify: `docs/api-specs/05_exploration.md` + +- [ ] **Step 1: 노드 객체에 prerequisiteId** + +"탐험 노드 객체 구조" 필드 표 `parentId` 행 아래에 추가: + +``` +| `prerequisiteId` | String | O | 선행 행성 ID (행성만, 이 행성을 해금하려면 선행 행성을 클리어해야 함). region은 null | +``` + +행성 목록 예시 JSON들에 `"prerequisiteId"` 추가 (earth=null, mercury="earth" 등 실제 체인 반영). + +- [ ] **Step 2: 행성 해금에 선행 게이트** + +"4. 행성 해금" Error 표에 추가: + +``` +| 400 | `PREREQUISITE_NOT_CLEARED` | 선행 행성이 아직 클리어되지 않음 | +``` + +서버 처리 로직 "2. 이미 해금된 행성인지 확인" 다음에 추가: + +``` + 2-1. prerequisiteId가 있으면 선행 행성이 클리어(모든 하위 지역 해금)되었는지 확인 → 아니면 PREREQUISITE_NOT_CLEARED +``` + +"해금 규칙" 개요에 추가: + +``` +- **행성 진행 게이트**: 행성은 선행 행성(prerequisiteId)을 클리어해야 해금. 지구는 선행 없음 (체인: 지구→수성→금성→화성→목성→토성→천왕성→해왕성). +``` + +- [ ] **Step 3: DB 테이블 + 시드/연료/ID 규칙 정정** + +- `exploration_nodes` 컬럼 표에 `prerequisite_node_id` (VARCHAR(50), FK→self) 추가. +- 개요 트리/예시 연료 수치를 본 시드값(행성 0/3/5/10/20/30/45/60, 지역 0~20)으로 정정. +- region ID는 이름 기반(`korea`,`mars_olympus`), icon은 지구지역=국가코드/그 외=행성이름임을 명시. +- 행성 로스터를 8행성(달 없음, 천왕성 포함)으로 정정. + +- [ ] **Step 4: INSUFFICIENT_FUEL 응답 보강 명시** + +지역/행성 해금 에러 섹션에 INSUFFICIENT_FUEL 응답 본문이 `requiredFuel`/`currentFuel`을 포함함을 예시와 함께 명시: + +```json +{ "code": "INSUFFICIENT_FUEL", "message": "연료가 부족합니다.", "requiredFuel": 10, "currentFuel": 4 } +``` + +- [ ] **Step 5: Commit** + +```bash +git add docs/api-specs/05_exploration.md +git commit -m "탐험 도메인 구현 : docs : 05_exploration 스펙 frontend 계약 정합 갱신 #27" +``` + +--- + +## 최종 검증 + +- [ ] **전체 테스트** + +Run: `./gradlew :SS-Common:test :SS-Study:test :SS-Web:test` +Expected: BUILD SUCCESSFUL — 신규 통과, 회귀 없음 + +- [ ] **시드 정합 spot-check** + +`V0_0_42__add_exploration.sql`의 행성 8 + 지역 30 = 38행, 프론트 시드(`exploration_seed_data.dart`)의 id/icon/required_fuel/sort_order와 일치하는지 대조. + +--- + +## Self-Review (작성자 기록) + +- **Spec coverage:** Task0 폐기 / Task1 에러인프라(ErrorCode·ErrorResponse·예외·핸들러) / Task2 NodeType / Task3-4 엔티티 / Task5 repo / Task6 DTO / Task7 조회 / Task8 지역해금+pre-check / Task9 행성해금+게이트 / Task10 컨트롤러 / Task11 마이그레이션 38노드 / Task12 문서. spec 전 항목 매핑. +- **Type 일관성:** `fuelService.getFuel(userId).currentFuel()`(FuelResponse), `consume(...)`→`FuelTransactionResponse.amount()/balanceAfter()`, `InsufficientFuelException(requiredFuel,currentFuel)`, `ErrorResponse.ofInsufficientFuel(msg,req,cur)`, `UserExploration.unlock(userId,nodeId,cleared)`, DTO `of(...)` 시그니처가 service 호출과 일치. +- **시드:** 8행성+30지역=38, 프론트 시드 1:1 (id/icon/fuel/sortOrder/description/mapXY). +- **Placeholder:** 없음. diff --git a/docs/superpowers/specs/2026-04-24-nickname-api-design.md b/docs/superpowers/specs/2026-04-24-nickname-api-design.md new file mode 100644 index 0000000..462c190 --- /dev/null +++ b/docs/superpowers/specs/2026-04-24-nickname-api-design.md @@ -0,0 +1,289 @@ +# 닉네임 중복 확인 및 닉네임 변경 API 설계 + +- **Issue**: [#21 닉네임 중복 확인 및 닉네임 변경 API 구현](https://github.com/SpaceStudyShip/SpaceStudyShip-BE/issues/21) +- **Branch**: `20260422_#21_닉네임_중복_확인_및_닉네임_변경_API_구현` +- **Date**: 2026-04-24 +- **Status**: Approved (pending user review) + +--- + +## 1. 목적과 범위 + +### 목적 +회원이 자신의 닉네임을 변경할 수 있도록 2개의 API(중복 확인, 변경)를 제공한다. + +### 이번 PR 범위 +- `GET /api/auth/check-nickname` (신규) +- `PATCH /api/auth/nickname` (신규) +- `WebConfig.addInterceptors.excludePathPatterns` 축소 (공개 API인 `login`/`reissue`/`logout`만 제외) +- Service / Controller 테스트 코드 추가 +- `ErrorCode`에 필요 시 신규 코드 추가 (현재는 기존 `INVALID_INPUT_VALUE`, `DUPLICATED_NICKNAME` 활용) + +### 이번 PR에서 제외 (별도 이슈로 분리) +- 닉네임 금지어(욕설) 필터링 +- 전역 에러 응답 포맷 변경 (RFC 7807 Problem Details 등) +- `logout` / `withdraw` 동작 검증 (화이트리스트 변경으로 인한 부수 영향만 고려) + +--- + +## 2. API 계약 + +### 2-1. 닉네임 중복 확인 + +```http +GET /api/auth/check-nickname?nickname={nickname} +Authorization: Bearer {accessToken} +``` + +| 항목 | 값 | +|------|----| +| 인증 | 필요 (`@AuthMember LoginMember`) | +| Query Parameter | `nickname` (String, required, 2~10자, `^[가-힣a-zA-Z0-9]+$`) | +| 성공 응답 | `200 OK` `{"available": boolean}` | +| 형식 오류 | `400 INVALID_INPUT_VALUE` | +| 미인증 | `401 UNAUTHENTICATED_REQUEST` | + +**동작**: `MemberRepository.existsByNickname(nickname)` 결과의 **부정값**을 `available`로 반환한다. 본인이 사용 중인 닉네임도 `available: false`가 되며, 이는 프론트에서 "본인 닉네임 입력 시 중복확인 버튼 비활성화"로 이미 차단한다. + +### 2-2. 닉네임 변경 + +```http +PATCH /api/auth/nickname +Authorization: Bearer {accessToken} +Content-Type: application/json + +{ "nickname": "..." } +``` + +| 항목 | 값 | +|------|----| +| 인증 | 필요 | +| Request Body | `nickname` (String, required, 2~10자, `^[가-힣a-zA-Z0-9]+$`) | +| 성공 응답 | `200 OK` `{"nickname": "..."}` | +| 형식 오류 | `400 INVALID_INPUT_VALUE` | +| 중복 | `409 DUPLICATED_NICKNAME` | +| 미인증 | `401 UNAUTHENTICATED_REQUEST` | +| 회원 없음 | `404 MEMBER_NOT_FOUND` (이론상, 유효 토큰+DB 삭제 동시 발생 시) | + +**동작**: +1. 회원 조회 후, 요청 닉네임이 **본인 현재 닉네임과 동일**하면 중복 검사 없이 그대로 응답한다(NO-OP). +2. 다른 회원이 사용 중이면 `409 DUPLICATED_NICKNAME`. +3. 통과 시 `Member.updateNickname()`로 갱신, `flush()`까지 명시적으로 호출해 unique 제약 위반을 동기적으로 감지한다. +4. JPA dirty checking으로 `UPDATE` 쿼리 발생. 토큰 재발급 없음. + +### 2-3. 닉네임 형식 규칙 + +- 길이: **2~10자** +- 허용 문자: 한글, 영문 대소문자, 숫자 +- 금지: 공백, 특수문자, 이모지 +- 정규식: `^[가-힣a-zA-Z0-9]+$` (+ `@Size(min=2, max=10)`) + +--- + +## 3. 파일 구조 + +```text +SS-Web/ + src/main/java/com/elipair/spacestudyship/ + config/WebConfig.java [수정] 인터셉터 예외 경로 축소 + controller/auth/AuthController.java [수정] 엔드포인트 2개 추가 + src/test/java/com/elipair/spacestudyship/controller/auth/ + AuthControllerTest.java [신규] MockMvc 슬라이스 + +SS-Auth/ + src/main/java/com/elipair/spacestudyship/auth/ + dto/CheckNicknameRequest.java [신규] record + Bean Validation + dto/CheckNicknameResponse.java [신규] record + dto/UpdateNicknameRequest.java [신규] record + Bean Validation + dto/UpdateNicknameResponse.java [신규] record + service/AuthService.java [수정] 메서드 2개 추가 + src/test/java/com/elipair/spacestudyship/auth/ + service/AuthServiceTest.java [신규] 신규 메서드 커버 + +SS-Auth (변경 없음) + constant/SecurityUrls.java 이번 범위에서 변경 없음 (5장 참조) + +SS-Member/ (변경 없음) + entity/Member.java 기존 updateNickname() 재사용 + repository/MemberRepository.java 기존 existsByNickname() 재사용 + +SS-Common/ (변경 없음) + exception/ErrorCode.java 기존 코드로 커버 가능 + exception/GlobalExceptionHandler.java 기존 구조 그대로 +``` + +### 판단 근거 + +- **Controller는 기존 `AuthController`에 추가**: 스펙상 `/api/auth` 하위 경로이므로 같은 컨트롤러에 두는 것이 일관성 있음 +- **Service는 기존 `AuthService`에 추가**: 메서드가 2개뿐이고 Auth 도메인의 프로필 관련 기능. 프로필 도메인이 커지면 그때 `MemberProfileService`로 분리 (YAGNI) +- **DTO는 `SS-Auth/dto/`에 배치**: 기존 `LoginRequest` 등과 동일 위치, CLAUDE.md의 "모듈별 dto/ 폴더" 규칙 준수 +- **Entity/Repository 수정 불필요**: `Member.updateNickname()`과 `MemberRepository.existsByNickname()`이 이미 존재 + +### Flyway 마이그레이션 + +**필요 없음.** Entity 스키마 변경이 없고 `members` 테이블의 `nickname` 컬럼은 이미 `unique` 제약이 걸려 있다. + +--- + +## 4. 데이터 흐름 + +### `GET /api/auth/check-nickname?nickname=...` + +```text +Request → AuthInterceptor (토큰 검증, request.loginMember 세팅) + → LoginMemberArgumentResolver (LoginMember 주입) + → AuthController.checkNickname(loginMember, @Valid nickname) + ↓ @Pattern/@Size 검증 실패 시 MethodArgumentNotValidException + → AuthService.checkNickname(nickname) + ↓ memberRepository.existsByNickname(nickname) + → CheckNicknameResponse(available = !exists) + → 200 OK +``` + +### `PATCH /api/auth/nickname` + +```text +Request → AuthInterceptor → LoginMemberArgumentResolver + → AuthController.updateNickname(loginMember, @Valid UpdateNicknameRequest) + ↓ 형식 검증 실패 → 400 + → AuthService.updateNickname(loginMember.memberId(), request) [@Transactional] + 1. getByMemberId(memberId) + → 없으면 throw CustomException(MEMBER_NOT_FOUND) [404] + 2. 본인 현재 닉네임과 동일하면 그대로 응답(중복 검사 없이 NO-OP) + 3. existsByNickname(request.nickname()) + → true면 throw CustomException(DUPLICATED_NICKNAME) [409] + 4. member.updateNickname(request.nickname()) // dirty checking + 5. memberRepository.flush() + → DataIntegrityViolationException 캐치 시 DUPLICATED_NICKNAME으로 변환 [409] + → UpdateNicknameResponse(member.getNickname()) + → 200 OK +``` + +### 예외 처리 + +- Bean Validation 실패 → `GlobalExceptionHandler.handleValidationException`이 `400 INVALID_INPUT_VALUE`로 처리 (필드 에러 메시지 `detail`에 포함) +- `CustomException` → `handleCustomException`이 해당 `ErrorCode`의 `HttpStatus`/`message`로 응답 +- 미인증 → `LoginMemberArgumentResolver`가 `UNAUTHENTICATED_REQUEST(401)` throw + +### 동시성 / 중복 방지 + +- `PATCH` 요청에서 `existsByNickname` 체크 후 `update` 사이에 동일 닉네임으로 다른 트랜잭션이 `INSERT`/`UPDATE`할 경우 → **DB의 `nickname` unique 제약이 최종 방어선** +- 서비스 메서드는 `member.updateNickname()` 직후 `memberRepository.flush()`로 즉시 DB에 반영하고, 발생하는 `DataIntegrityViolationException`을 `CustomException(DUPLICATED_NICKNAME)`으로 변환해 일관된 409 응답을 보장한다. + +--- + +## 5. 인증 경로 정리 + +### 아키텍처 이해 (중요) + +현재 프로젝트의 인증 구조: +- **Spring Security** (`SecurityConfig`): `SecurityUrls.AUTH_WHITELIST`에 등록된 경로는 `permitAll`. `/api/auth/**`가 통째로 들어있어 이 계층에서는 JWT 검증을 하지 않는다. +- **AuthInterceptor**: `WebConfig.addInterceptors`에서 `/api/**`에 등록되며, `excludePathPatterns` 외의 경로는 `Authorization: Bearer` 토큰을 검증하고 `loginMember` 속성을 세팅한다. + +즉, **실질적인 JWT 검증은 `AuthInterceptor`가 담당**한다. Spring Security의 `/api/auth/**` whitelist는 유지해야 한다 (빼면 JWT 처리 필터 부재로 정상 요청도 401이 됨). + +### 변경 범위: `WebConfig.addInterceptors.excludePathPatterns`만 조정 + +#### Before + +```java +.excludePathPatterns( + "/api/auth/**", // AuthController 전체 제외 + "/actuator/health" +); +``` + +#### After + +```java +.excludePathPatterns( + "/api/auth/login", + "/api/auth/reissue", + "/api/auth/logout", + "/actuator/health" +); +``` + +> `logout`은 `AuthService.logout(String refreshToken)`이 **리프레시 토큰만** 사용하므로, 액세스 토큰이 만료된 상태에서도 호출 가능해야 한다. 그래서 인터셉터의 액세스 토큰 검증 대상에서 제외한다. + +`SecurityUrls.AUTH_WHITELIST`는 이번 범위에서 **변경하지 않는다**. Spring Security 계층 전면 재설계(JWT Security Filter 도입 등)는 별도 이슈에서 다룬다. + +### 영향 + +| API | 기존 동작 | 변경 후 | +|-----|----------|---------| +| `POST /api/auth/login` | 공개 | 공개 (유지) | +| `POST /api/auth/reissue` | 공개 | 공개 (유지) | +| `POST /api/auth/logout` | 공개 | 공개 (유지: 리프레시 토큰 기반이므로 액세스 토큰 검증 제외) | +| `DELETE /api/auth/withdraw` | 미구현 | 미구현 (영향 없음) | +| `GET /api/auth/check-nickname` | 신규 | 인증 필요 | +| `PATCH /api/auth/nickname` | 신규 | 인증 필요 | + +--- + +## 6. 테스트 전략 + +커버리지 목표: **80%+** (글로벌 `rules/testing.md` 준수) + +### 6-1. `AuthServiceTest` (단위 테스트, Mockito) + +**checkNickname** +- `existsByNickname`이 `false` → `available=true` 반환 +- `existsByNickname`이 `true` → `available=false` 반환 + +**updateNickname** +- 사용 가능한 닉네임 입력 → `Member.updateNickname` 호출, `UpdateNicknameResponse` 반환 +- 본인 현재 닉네임과 동일한 값 입력 → 중복 검사 없이 그대로 응답 (NO-OP) +- 이미 존재하는 닉네임 입력 → `CustomException(DUPLICATED_NICKNAME)` throw, `flush` 호출 안 됨 +- `flush` 단계에서 `DataIntegrityViolationException` 발생 → `CustomException(DUPLICATED_NICKNAME)`으로 변환 +- `memberId`로 조회 실패 → `CustomException(MEMBER_NOT_FOUND)` throw + +### 6-2. `AuthControllerTest` (슬라이스 테스트, MockMvc) + +**GET /api/auth/check-nickname** +- 정상 쿼리 + 인증 → 200, `$.available` 필드 검증 +- `nickname` 파라미터 누락 → 400 +- 1자 / 11자 / 특수문자 / 공백 포함 → 400 +- 인증 없음 → 401 + +**PATCH /api/auth/nickname** +- 정상 요청 + 인증 → 200, `$.nickname` 필드 검증 +- 본문 닉네임 형식 위반 → 400 +- 중복 닉네임 → 409 (`AuthService` mock이 예외 throw) +- 인증 없음 → 401 + +### 6-3. Mocking 방침 + +이번 PR은 **standalone 방식**으로 통일한다. + +- Service 테스트: `@ExtendWith(MockitoExtension.class)` + `@Mock MemberRepository` + `@InjectMocks AuthService` +- Controller 테스트: `@ExtendWith(MockitoExtension.class)` + `@Mock AuthService` + `MockMvcBuilders.standaloneSetup(...)` 로 컨트롤러 단독 구성. 인증은 `requestAttr("loginMember", new LoginMember(...))`로 주입하고, 직접 등록한 `LoginMemberArgumentResolver`가 이를 읽는다. +- `@WebMvcTest`/`@MockitoBean` 기반 슬라이스는 이번 범위에서 사용하지 않는다 (Spring 컨텍스트 부팅 비용 절감 및 인터셉터 우회). 향후 통합 테스트 인프라 정비 이슈에서 표준화한다. + +### 6-4. 통합 테스트 + +이번 범위에서는 제외. 통합 테스트 인프라(`@SpringBootTest` + Testcontainers 또는 H2) 셋업은 별도 이슈에서 프로젝트 전반적으로 구축. + +--- + +## 7. 구현 순서 (TDD) + +1. `SecurityUrls.AUTH_WHITELIST` 축소 + 기존 테스트 영향 확인 +2. DTO 3개 작성 (`CheckNicknameResponse`, `UpdateNicknameRequest`, `UpdateNicknameResponse`) +3. `AuthServiceTest` 작성 (RED) → `AuthService` 메서드 2개 구현 (GREEN) +4. `AuthControllerTest` 작성 (RED) → `AuthController` 엔드포인트 2개 구현 (GREEN) +5. 빌드 및 테스트 통과 확인 +6. 필요 시 리팩터링 + +--- + +## 8. 수용 기준 (Acceptance Criteria) + +- [ ] `GET /api/auth/check-nickname` 엔드포인트가 스펙대로 동작 +- [ ] `PATCH /api/auth/nickname` 엔드포인트가 스펙대로 동작 +- [ ] 닉네임 형식 검증(길이, 허용 문자)이 요청 단계에서 동작 +- [ ] 중복 닉네임 요청 시 409 응답 +- [ ] 인증 없는 요청 시 401 응답 +- [ ] `/api/auth` 하위의 공개 API가 `login`, `reissue`로만 한정됨 +- [ ] 신규/수정된 코드에 대한 단위/슬라이스 테스트 통과 +- [ ] 전체 빌드 및 테스트 녹색 diff --git a/docs/superpowers/specs/2026-05-11-withdraw-api-design.md b/docs/superpowers/specs/2026-05-11-withdraw-api-design.md new file mode 100644 index 0000000..6318357 --- /dev/null +++ b/docs/superpowers/specs/2026-05-11-withdraw-api-design.md @@ -0,0 +1,248 @@ +# 회원 탈퇴 API 설계 + +- **Issue**: [#22 회원 탈퇴 API 구현](https://github.com/SpaceStudyShip/SpaceStudyShip-BE/issues/22) +- **Branch**: `20260422_#22_회원_탈퇴_API_구현` +- **Date**: 2026-05-11 +- **Status**: Implemented (2026-05-11) + +--- + +## 1. 목적과 범위 + +### 목적 +인증된 사용자가 자신의 계정과 관련 데이터를 영구 삭제할 수 있도록 `DELETE /api/auth/withdraw` 엔드포인트를 제공한다. 우리 측 데이터(DB row, Redis refresh token)와 함께 **Firebase Authentication 사용자**도 삭제한다. + +### 이번 PR 범위 +1. **회원 탈퇴 API 구현** + - `DELETE /api/auth/withdraw` (신규) + - `AuthService.withdraw(memberId)` 메서드 추가 +2. **삭제 대상 데이터** + - `members` 테이블의 해당 row + - Redis `refresh_token:{memberId}` 키 + - **Firebase Authentication 사용자** (`socialId` = Firebase UID로 가정) +3. **Firebase Admin SDK 연동 인프라** + - `SS-Auth/build.gradle`에 `firebase-admin` 의존성 추가 + - `FirebaseConfig` (FirebaseApp 초기화 빈) + - `application.yml`에 키 파일 경로 설정 + - `.gitignore`에 Firebase 키 패턴 추가 (이미 반영됨) +4. **테스트** + - Service / Controller 테스트 코드 추가 + - `FirebaseAuth` mock 처리 + +### 이번 PR에서 제외 (별도 이슈로 분리) +- **LoginStrategy 실제 구현** — 현재 `GoogleLoginStrategy`/`AppleLoginStrategy`/`KakaoLoginStrategy`가 모두 TODO 스텁이고, 가짜 `socialId`를 발급한다. 이를 실제 Firebase ID Token 검증으로 바꾸는 작업은 그 자체로 큰 작업이라 별도 이슈로 분리. 본 PR에선 **Firebase Admin SDK 초기화와 `deleteUser` 호출만** 포함. +- **Apple `Sign in with Apple` revoke token 처리** — App Store 심사 요구사항이지만 Firebase 연동과는 별개. LoginStrategy 실제 구현 이슈에서 함께 처리. +- **타 도메인 cascade 삭제** — Todo / Timer / Fuel / Exploration / Badge / Friends 등 `docs/api-specs/01_auth.md`에 명시된 도메인은 현재 미구현. 각 도메인이 추가되는 PR에서 자기 데이터 삭제 로직을 함께 추가하는 방식으로 확장. +- **Soft delete / grace period** — 현재 Member 엔티티에 `deletedAt` 등 인프라 없음. 운영 정책상 필요 시 별도 이슈. +- **FK 제약 / `ON DELETE CASCADE` 전략** — 참조 테이블 자체가 아직 없으므로 결정 보류. +- **Flyway 마이그레이션** — 본 작업은 스키마 변경 없음. 마이그레이션 파일 추가 안 함. + +### 기존 가짜 socialId 데이터에 대한 처리 +LoginStrategy가 TODO 스텁인 동안 가입된 회원의 `socialId`는 Firebase에 존재하지 않는 가짜값(`"GOOGLE_SOCIAL_ID_12345"` 등)이다. 이런 회원이 탈퇴를 호출하면 `FirebaseAuth.deleteUser()`가 `FirebaseAuthException(USER_NOT_FOUND)`를 던진다. 이 예외는 **로그 경고만 남기고 무시**한다 (§5 참조). 멱등성 유지. + +--- + +## 2. API 계약 + +```http +DELETE /api/auth/withdraw +Authorization: Bearer {accessToken} +``` + +| 항목 | 값 | +|------|----| +| 인증 | 필요 (`@AuthMember LoginMember`) | +| Request Body | 없음 | +| 성공 응답 | `204 No Content` (응답 본문 없음) | +| 미인증 | `401 UNAUTHENTICATED_REQUEST` | +| 이미 탈퇴됨 | `204 No Content` (멱등 — 별도 에러 응답 없음) | + +### 멱등성 + +DELETE 메서드의 HTTP 의미를 따라 멱등으로 설계한다. +- 동일한 토큰으로 두 번 호출되거나, 다른 디바이스에서 먼저 탈퇴된 후 호출되어도 결과 상태는 동일하므로 `204`로 응답한다. +- 클라이언트의 재시도(네트워크 불안 등)가 안전하다. +- Firebase 측에서 이미 사용자가 삭제된 경우(`USER_NOT_FOUND`)도 멱등으로 처리한다. + +--- + +## 3. 컴포넌트 변경 사항 + +``` +SS-Auth/ +├── build.gradle ← firebase-admin 의존성 추가 +├── service/AuthService.java ← withdraw(Long memberId) 추가, FirebaseAuth 주입 +└── firebase/FirebaseConfig.java ← 신규 (FirebaseApp 초기화 빈) + +SS-Web/ +├── controller/auth/AuthController.java ← DELETE /api/auth/withdraw 추가 +├── src/main/resources/application.yml ← firebase.admin-sdk-path 설정 추가 +└── src/main/resources/firebase/ + └── spacestudyship-firebase-adminsdk-...json ← 키 파일 (gitignored) + +.gitignore ← Firebase 키 패턴 추가 (이미 반영) + +SS-Auth (test)/ +└── service/AuthServiceTest.java ← withdraw 케이스 추가 + +SS-Web (test)/ +└── controller/auth/AuthControllerTest.java ← withdraw 케이스 추가 +``` + +**추가 없음:** +- 신규 DTO (Request/Response 본문 없음) +- 신규 ErrorCode +- 신규 엔티티 / 마이그레이션 + +### 3-1. `firebase-admin` 의존성 + +`SS-Auth/build.gradle`에 추가: +```gradle +implementation 'com.google.firebase:firebase-admin:9.x.x' // 최신 안정 버전 사용 +``` + +### 3-2. `FirebaseConfig` + +위치: `SS-Auth/src/main/java/com/elipair/spacestudyship/auth/firebase/FirebaseConfig.java` + +역할: +- `@Configuration` 빈으로 애플리케이션 시작 시 `FirebaseApp.initializeApp()` 1회 호출 +- 키 파일 경로는 `@Value("${firebase.admin-sdk-path}")`로 주입 (classpath 리소스) +- `FirebaseAuth` 인스턴스를 `@Bean`으로 노출 → `AuthService`에 주입 가능 + +### 3-3. `application.yml` 추가 + +```yaml +firebase: + admin-sdk-path: classpath:firebase/spacestudyship-firebase-adminsdk-fbsvc-7e86c5c253.json +``` + +- 키 파일이 클래스패스에 없는 경우(예: CI에서 빌드만 할 때) `FirebaseApp` 초기화가 실패하면 애플리케이션 기동 자체가 실패한다. +- CI/CD에서 키 없이 빌드하려면 `application-{profile}.yml`에서 profile별로 경로를 다르게 두거나, `@Profile` 분기로 빈 등록을 막는 방식 필요 — 본 PR 범위 안: 일단 단일 경로로 시작, 운영상 필요해지면 후속 처리. + +--- + +## 4. 데이터 흐름 + +``` +Client + │ DELETE /api/auth/withdraw (Authorization: Bearer ...) + ▼ +AuthInterceptor (토큰 검증 → memberId 추출) + ▼ +AuthController.withdraw(@AuthMember LoginMember loginMember) + ▼ +AuthService.withdraw(loginMember.memberId()) @Transactional + │ + ├─ 1. Member 조회 (socialId가 Firebase UID — 다음 단계용) + │ Optional member = memberRepository.findById(memberId) + │ + ├─ 2. (member가 있으면) memberRepository.delete(member) + │ 없으면 NoOp → 멱등 + │ + ├─ 3. refreshTokenRepository.delete(memberId) + │ Redis: 키 없어도 silent → 멱등 + │ + └─ 4. (member가 있었으면) firebaseAuth.deleteUser(member.getSocialId()) + try / catch FirebaseAuthException + ├─ USER_NOT_FOUND → log.warn 후 무시 (멱등) + └─ 그 외 → log.error 후 무시 (DB는 이미 정리됨, 응답은 204) + ▼ +ResponseEntity.noContent().build() (204) +``` + +### 삭제 순서: **DB → Redis → Firebase** + +| 시나리오 | 결과 | +|---------|------| +| 모두 성공 | 완전 삭제 (정상) | +| DB 실패 | 트랜잭션 롤백 → Redis/Firebase 호출 안 됨 → 5xx 응답, 클라이언트 재시도 | +| DB 성공 + Redis 실패 | DB는 커밋됨. 토큰은 TTL 만료. `log.warn` 후 다음 단계 진행 | +| DB 성공 + Firebase 실패 | DB/Redis 정리됨. Firebase 유저만 잔존. `log.error` 후 204 응답 (운영자 수동 정리 또는 후속 retry 큐 — 본 PR 범위 외) | + +**근거:** +- DB부터 정리하는 이유: 트랜잭션 보장이 가장 강하고, 우리 도메인의 진실 원천이므로 여기 정리되면 사용자 입장에선 "탈퇴 완료". +- Firebase가 가장 외부 시스템이므로 마지막. 실패 시 우리 측 정리는 이미 되어 있어 사용자에겐 "탈퇴됨"으로 보임. +- `@Transactional` 경계 안에 Redis/Firebase가 들어가면 안 됨 — 외부 호출 실패가 DB 롤백을 일으키면 정합성이 더 망가짐. Redis/Firebase 호출은 트랜잭션 커밋 이후 영역으로 두거나, 같은 메서드 내에 두되 try/catch로 격리. + +**트랜잭션 경계 구현 노트:** 가장 단순한 방식은 `AuthService.withdraw()` 메서드 자체를 `@Transactional`로 두되, Redis/Firebase 호출을 try/catch로 감싸서 그 예외가 트랜잭션 밖으로 새지 않게 하는 것이다. Redis/Firebase는 DB와 별도 시스템이라 사실상 트랜잭션 보호 대상이 아님을 명시. + +--- + +## 5. 에러 처리 + +| 상황 | HTTP | code | 비고 | +|------|------|------|------| +| 정상 탈퇴 | 204 | - | | +| 이미 탈퇴 (Member row 없음) | 204 | - | `findById().ifPresent(::delete)` 패턴으로 NoOp. Firebase 호출도 스킵 | +| 토큰 없음 / 만료 | 401 | `UNAUTHENTICATED_REQUEST` 등 | 인터셉터 처리 (기존 패턴) | +| Redis 통신 실패 | 204 | - | DB는 이미 커밋, 토큰은 TTL 만료. `log.warn` 기록. 다음 단계 진행 | +| Firebase `USER_NOT_FOUND` | 204 | - | 가짜 socialId(LoginStrategy 스텁) 또는 다른 디바이스 선행 탈퇴. `log.warn` 후 무시 | +| Firebase 기타 통신/인증 실패 | 204 | - | DB/Redis는 이미 정리됨. `log.error` 후 무시. 응답은 멱등성 유지 위해 204 | +| FirebaseApp 초기화 실패 (앱 기동 시) | 앱 기동 실패 | - | 키 파일 누락/파싱 실패 시 즉시 발견되도록 fail-fast | +| DB 통신 실패 | 500 | `INTERNAL_SERVER_ERROR` | `GlobalExceptionHandler` 위임 | + +**새 ErrorCode 추가 없음.** + +### Firebase 예외 무시 정책의 근거 +- 사용자 관점에서 "탈퇴"의 본질은 "우리 서비스에서 내 데이터가 사라지는 것"이다. Firebase 측 정리는 부수 효과. +- Firebase 일시 장애로 탈퇴 자체가 실패하면 사용자 경험이 나빠지고, 재시도하면 우리 DB는 이미 비어있어 결과가 동일하므로 멱등성을 깨지 않는 게 낫다. +- 잔존 Firebase 유저는 운영 모니터링으로 별도 정리. 향후 retry 큐/배치로 자동화 가능 (별도 이슈). + +--- + +## 6. 테스트 전략 + +### 6-1. Unit Test — `AuthServiceTest` + +| 케이스 | 검증 내용 | +|--------|----------| +| `withdraw_success` | Member 존재 시: `memberRepository.delete(member)` 1회, `refreshTokenRepository.delete(memberId)` 1회, `firebaseAuth.deleteUser(socialId)` 1회 호출 | +| `withdraw_alreadyWithdrawn` | `findById`가 `Optional.empty()` 반환 시 `delete(...)` 및 `firebaseAuth.deleteUser(...)`는 호출되지 않음. `refreshTokenRepository.delete`만 호출됨. 예외 없이 통과 (멱등) | +| `withdraw_firebaseUserNotFound` | `firebaseAuth.deleteUser()`가 `FirebaseAuthException(USER_NOT_FOUND)` 던져도 메서드는 정상 완료. 우리 측 정리는 이미 됨 | +| `withdraw_firebaseGenericError` | `firebaseAuth.deleteUser()`가 일반 `FirebaseAuthException` 던져도 정상 완료. log.error만 호출 (mock으로 검증) | + +`@ExtendWith(MockitoExtension.class)` + `@Mock FirebaseAuth firebaseAuth` 추가. `FirebaseAuthException`은 final 클래스가 아니므로 Mockito로 mock 가능. + +### 6-2. Controller Test — `AuthControllerTest` + +| 케이스 | 검증 내용 | +|--------|----------| +| `withdraw_success` | 204 응답, `authService.withdraw(1L)` 호출 검증 | +| `withdraw_unauthenticated` | Authorization 헤더 없으면 401 | + +`MockMvcBuilders.standaloneSetup` + `LoginMemberArgumentResolver` 기존 패턴 그대로 사용. + +### 6-3. 통합 테스트 +현재 다른 API들도 통합 테스트를 두지 않는 컨벤션이라 일관성을 위해 추가하지 않는다. `FirebaseConfig` 빈 초기화 검증도 통합 테스트 추가하지 않음 — 운영 환경에서 fail-fast로 발견되는 게 충분. + +--- + +## 7. 향후 확장 포인트 (이번 PR 범위 아님) + +1. **LoginStrategy 실제 Firebase 검증 구현** + - `GoogleLoginStrategy`/`AppleLoginStrategy`/`KakaoLoginStrategy`에서 `FirebaseAuth.verifyIdToken()`을 사용해 실제 ID Token 검증. + - Apple 로그인 시 `Sign in with Apple` revoke token 처리도 함께. + - 이게 완료되면 신규 가입자의 `socialId`가 진짜 Firebase UID가 되어, 본 PR의 `deleteUser` 호출이 실제로 의미를 갖게 됨. + +2. **타 도메인 추가 시 cascade 삭제** + - Todo, Timer, Fuel, Exploration, Badge, Friends 등이 추가되는 PR에서 해당 도메인의 데이터 삭제 로직을 `AuthService.withdraw()`에 단순 호출 추가하거나, FK + `ON DELETE CASCADE`로 처리. + +3. **Firebase 삭제 실패 retry** + - `log.error`로 남기는 잔존 Firebase 유저를 후속 retry 큐(예: Spring Scheduler + 실패 테이블) 또는 운영 배치로 정리. + +4. **운영 정책 변경 시** + - Soft delete / grace period 도입: `Member`에 `withdrawnAt` 컬럼 추가 + 배치 잡으로 영구 삭제. + - 탈퇴 사유 수집: 별도 DTO + 통계 테이블. + +5. **profile별 FirebaseConfig 분기** + - CI 빌드 환경 등 키 없이 빌드해야 하는 경우 `@Profile`로 빈 등록을 제외하거나, dummy `FirebaseAuth` 빈을 주입. + +--- + +## 8. 참고 + +- API 공통 규칙: [`docs/api-specs/00_common.md`](../../api-specs/00_common.md) +- Auth API 상세 스펙: [`docs/api-specs/01_auth.md`](../../api-specs/01_auth.md) §4 +- 동일 도메인 선행 스펙: [`2026-04-24-nickname-api-design.md`](./2026-04-24-nickname-api-design.md) +- Firebase Admin SDK Java 문서: https://firebase.google.com/docs/auth/admin/manage-users#delete_a_user diff --git a/docs/superpowers/specs/2026-05-12-user-device-fcm-token-design.md b/docs/superpowers/specs/2026-05-12-user-device-fcm-token-design.md new file mode 100644 index 0000000..2055591 --- /dev/null +++ b/docs/superpowers/specs/2026-05-12-user-device-fcm-token-design.md @@ -0,0 +1,400 @@ +# 디바이스 정보 및 FCM 토큰 저장 기능 설계 + +- **작성일**: 2026-05-12 +- **관련 이슈**: #23 \[기능추가\]\[인증\] 디바이스 정보 및 FCM 토큰 저장 기능 추가 +- **관련 API 스펙**: [docs/api-specs/01_auth.md](../../api-specs/01_auth.md) + +--- + +## 1. 배경 & 문제 정의 + +### 1.1 현재 상태 + +- `LoginRequest`는 `socialType`, `idToken`만 받음. 디바이스 정보 없음. +- Refresh Token은 Redis에 `refresh_token:{memberId}` 키로 저장되어 **회원당 1 세션만** 유효. + - 같은 회원이 다른 디바이스에서 로그인하면 이전 디바이스의 세션이 침묵 무효화됨. +- FCM 토큰을 저장할 장소가 없어 푸시 알림 기능을 구현할 수 없음. +- `db/migration/`에 마이그레이션 파일 없음 (`version.yml`은 `0.0.30`). `members` 테이블은 `hibernate.ddl-auto=update`로만 생성됨. + +### 1.2 목표 + +1. 로그인 요청에 디바이스 정보(`fcmToken`, `deviceType`, `deviceId`)를 받아 저장한다. +2. Refresh Token을 **디바이스별로** 관리하여 다중 디바이스 동시 로그인 세션을 지원한다. +3. 로그아웃 시 해당 디바이스의 세션 정보(refresh token + FCM 토큰)만 정리하고, 다른 디바이스 세션은 유지한다. +4. API 스펙 문서(`docs/api-specs/01_auth.md`)와 코드 구현을 일치시킨다. + +### 1.3 비목표 (Out of Scope) + +- 실제 FCM 푸시 발송 기능 (별도 이슈). +- Refresh Token 탈취 감지 정책 강화 (현재 수준의 "DB 토큰과 불일치 시 강제 로그아웃"만 유지). +- 디바이스별 권한/푸시 수신 동의 등 디바이스 메타데이터 확장. + +--- + +## 2. 핵심 설계 결정 (Decision Log) + +| # | 결정 | 선택지 | 근거 | +|---|------|--------|------| +| D1 | Refresh Token 저장소 | **API 스펙대로 `user_devices.refresh_token`(DB) 통합** | API 스펙이 디바이스별 관리를 가정. Redis 단독 저장은 다중 디바이스 미지원. | +| D2 | `UserDevice` Entity 모듈 | **SS-Auth** | `refresh_token`까지 들어가면 인증 세션 도메인. 기존 `RefreshTokenRepository` 자리를 자연스럽게 대체. SS-Member의 책임 경계 유지. | +| D3 | 디바이스 식별 방식 | **JWT Refresh Token claim에 `did`(deviceId) 포함** | logout/reissue request body 변경 불필요. refresh_token 컬럼에 unique 인덱스 강제 불필요. 토큰 파싱만으로 디바이스 식별 가능. | +| D4 | Unique 제약 | **(member_id, device_id) 컴포지트 unique** | 가족 공유폰/기기 양도 등 같은 디바이스를 다른 계정이 쓰는 시나리오를 별개 row로 자연 수용. | +| D5 | 로그아웃 시 row 처리 | **row 전체 삭제** | "row 존재 = 활성 세션"이라는 단순한 invariant 유지. `WHERE refresh_token IS NOT NULL` 필터 불필요. 재로그인 시 upsert. | +| D6 | FK 정책 | **`ON DELETE CASCADE`** | 회원 탈퇴 시 디바이스 row 자동 정리. `AuthService.withdraw` 로직 단순화. | +| D7 | 마이그레이션 파일 구성 | **`members` baseline + `user_devices`를 한 파일에 작성** (`CREATE TABLE IF NOT EXISTS`) | CLAUDE.md 규칙: 한 version당 1 파일. `members` 마이그레이션이 없는 상태에서 `user_devices`만 추가하면 FK 참조 대상 부재. 이미 ddl-auto로 만들어진 환경에서도 `IF NOT EXISTS`로 안전. | + +--- + +## 3. 데이터 모델 + +### 3.1 ERD (관련 부분) + +``` +members (1) ──────< (N) user_devices + id ◄──FK── member_id + + (member_id, device_id) UNIQUE + + ON DELETE CASCADE +``` + +### 3.2 `user_devices` 스키마 + +| 컬럼 | 타입 | 제약 | 설명 | +|------|------|------|------| +| `id` | BIGINT | PK, AUTO_INCREMENT | | +| `member_id` | BIGINT | NOT NULL, FK→members.id ON DELETE CASCADE | | +| `device_id` | VARCHAR(255) | NOT NULL | 클라이언트가 생성/관리하는 디바이스 UUID | +| `device_type` | VARCHAR(10) | NOT NULL | `IOS` / `ANDROID` | +| `fcm_token` | VARCHAR(255) | NOT NULL | Firebase Cloud Messaging 토큰 | +| `refresh_token` | VARCHAR(512) | NOT NULL | 현재 활성 Refresh Token | +| `last_login_at` | TIMESTAMP | NOT NULL | 마지막 로그인 시각 | +| `created_at` | TIMESTAMP | NOT NULL | (BaseTimeEntity) | +| `updated_at` | TIMESTAMP | NOT NULL | (BaseTimeEntity) | + +**Constraints:** +- `UNIQUE (member_id, device_id)` — 같은 회원+디바이스 조합은 항상 단일 row. +- `INDEX (member_id)` — 회원 단위 일괄 조회/삭제 시 사용. `refresh_token` 컬럼 인덱스는 불필요 (JWT의 `did` claim으로 디바이스 식별). + +### 3.3 마이그레이션 파일 + +**경로:** `SS-Web/src/main/resources/db/migration/V0_0_31__add_user_devices.sql` + +```sql +-- members 테이블 baseline (ddl-auto=update로 이미 존재할 수 있어 IF NOT EXISTS) +CREATE TABLE IF NOT EXISTS members ( + id BIGINT NOT NULL AUTO_INCREMENT PRIMARY KEY, + social_id VARCHAR(100) NOT NULL, + social_type VARCHAR(10) NOT NULL, + nickname VARCHAR(30) NOT NULL, + created_at TIMESTAMP NOT NULL, + updated_at TIMESTAMP NOT NULL, + CONSTRAINT uk_members_social_id_type UNIQUE (social_id, social_type), + CONSTRAINT uk_members_nickname UNIQUE (nickname) +); + +-- user_devices: 디바이스별 인증 세션 + FCM 토큰 +CREATE TABLE IF NOT EXISTS user_devices ( + id BIGINT NOT NULL AUTO_INCREMENT PRIMARY KEY, + member_id BIGINT NOT NULL, + device_id VARCHAR(255) NOT NULL, + device_type VARCHAR(10) NOT NULL, + fcm_token VARCHAR(255) NOT NULL, + refresh_token VARCHAR(512) NOT NULL, + last_login_at TIMESTAMP NOT NULL, + created_at TIMESTAMP NOT NULL, + updated_at TIMESTAMP NOT NULL, + CONSTRAINT uk_user_devices_member_device UNIQUE (member_id, device_id), + CONSTRAINT fk_user_devices_member FOREIGN KEY (member_id) + REFERENCES members(id) ON DELETE CASCADE +); + +CREATE INDEX IF NOT EXISTS idx_user_devices_member ON user_devices(member_id); +``` + +> ⚠️ Hibernate가 만든 기존 컬럼/제약과 정확히 일치해야 함. CASCADE 옵션은 Entity의 `@OnDelete(action = OnDeleteAction.CASCADE)`로 명시. + +### 3.4 Entity + +**`SS-Auth/src/main/java/.../auth/entity/UserDevice.java`** + +```java +@Entity +@Table( + name = "user_devices", + uniqueConstraints = @UniqueConstraint( + name = "uk_user_devices_member_device", + columnNames = {"member_id", "device_id"} + ), + indexes = @Index(name = "idx_user_devices_member", columnList = "member_id") +) +@Getter +@Builder +@AllArgsConstructor +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class UserDevice extends BaseTimeEntity { + + @Id @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(nullable = false) + private Long memberId; + + @Column(nullable = false, length = 255) + private String deviceId; + + @Enumerated(EnumType.STRING) + @Column(nullable = false, length = 10) + private DeviceType deviceType; + + @Column(nullable = false, length = 255) + private String fcmToken; + + @Column(nullable = false, length = 512) + private String refreshToken; + + @Column(nullable = false) + private LocalDateTime lastLoginAt; + + public static UserDevice register(Long memberId, String deviceId, DeviceType deviceType, + String fcmToken, String refreshToken) { + return UserDevice.builder() + .memberId(memberId) + .deviceId(deviceId) + .deviceType(deviceType) + .fcmToken(fcmToken) + .refreshToken(refreshToken) + .lastLoginAt(LocalDateTime.now()) + .build(); + } + + public void renewLogin(DeviceType deviceType, String fcmToken, String refreshToken) { + this.deviceType = deviceType; + this.fcmToken = fcmToken; + this.refreshToken = refreshToken; + this.lastLoginAt = LocalDateTime.now(); + } + + public void rotateRefreshToken(String refreshToken) { + this.refreshToken = refreshToken; + } +} +``` + +**설계 노트:** +- `Member`와의 관계는 `@ManyToOne` 매핑 없이 `memberId` 컬럼만 사용. SS-Auth가 SS-Member의 도메인 모델에 강결합되지 않도록 함. 조회 시 Member가 필요하면 `MemberRepository`로 별도 조회. +- 정적 팩토리 `register()` (신규 디바이스) / 인스턴스 메서드 `renewLogin()` (재로그인) / `rotateRefreshToken()` (reissue)로 의도를 표현. +- FK CASCADE는 **DB 레벨에서만 정의** (마이그레이션 SQL의 `ON DELETE CASCADE`). JPA 어노테이션(`@OnDelete`) 불필요 — `members`가 권위 있는 부모이므로 DB가 직접 정리. + +### 3.5 Repository + +**`SS-Auth/src/main/java/.../auth/repository/UserDeviceRepository.java`** + +```java +public interface UserDeviceRepository extends JpaRepository { + Optional findByMemberIdAndDeviceId(Long memberId, String deviceId); + void deleteByMemberIdAndDeviceId(Long memberId, String deviceId); + + default UserDevice getByMemberIdAndDeviceId(Long memberId, String deviceId) { + return findByMemberIdAndDeviceId(memberId, deviceId) + .orElseThrow(() -> new CustomException(ErrorCode.INVALID_TOKEN)); + } +} +``` + +- 네이밍 컨벤션 준수: `getByXxx`(예외) / `findByXxx`(Optional). + +### 3.6 Constant Enum + +**`SS-Auth/src/main/java/.../auth/constant/DeviceType.java`** + +```java +public enum DeviceType { IOS, ANDROID } +``` + +--- + +## 4. 인증 흐름 변경 + +### 4.1 `LoginRequest` DTO + +```java +public record LoginRequest( + @NotNull SocialType socialType, + @NotBlank String idToken, + @NotBlank String fcmToken, + @NotNull DeviceType deviceType, + @NotBlank String deviceId +) {} +``` + +### 4.2 `JwtTokenProvider` 변경 + +- `createRefreshToken(Member member, String deviceId)` — `did` claim 추가. +- `parseRefreshToken(String token)` → `RefreshTokenPayload(memberId, deviceId)` 반환. 만료/위변조 시 `CustomException`. +- `parseRefreshTokenSafely(String token)` → `Optional` 반환. 로그아웃에서 만료 토큰도 받아들이기 위해 유지. +- 기존 `getMemberIdFromRefreshToken` / `getMemberIdFromRefreshTokenSafely`는 **제거**. 호출처 일괄 교체. +- Access Token은 변경 없음 (회원만 식별하면 됨). + +**`SS-Auth/src/main/java/.../auth/jwt/RefreshTokenPayload.java`** + +```java +public record RefreshTokenPayload(Long memberId, String deviceId) {} +``` + +### 4.3 로그인 흐름 + +``` +AuthService.login(LoginRequest req): + 1. socialId = strategy.validateAndGetSocialId(req.idToken) + 2. (member, isNew) = findOrRegisterMember(socialId, req.socialType) + 3. accessToken = jwt.createAccessToken(member) + refreshToken = jwt.createRefreshToken(member, req.deviceId) + 4. userDeviceRepository.findByMemberIdAndDeviceId(member.id, req.deviceId) + .ifPresentOrElse( + dev -> dev.renewLogin(req.deviceType, req.fcmToken, refreshToken), + () -> userDeviceRepository.save( + UserDevice.register(member.id, req.deviceId, req.deviceType, + req.fcmToken, refreshToken)) + ) + 5. return new LoginResponse(member.id, member.nickname, + new Tokens(accessToken, refreshToken), isNew) +``` + +- 전체 트랜잭션 1개(`@Transactional`). +- Race condition: 동일 `(member, deviceId)` 동시 로그인 시 unique 제약 위반 가능. **이번 범위에서는 별도 retry 없이 일반 500 에러로 처리.** 동일 디바이스에서 동시에 두 번 로그인은 현실적으로 발생하기 어려우므로 비용/효익이 맞지 않음. + +### 4.4 Reissue 흐름 + +``` +AuthService.reissue(ReissueRequest req): + 1. payload = jwt.parseRefreshToken(req.refreshToken) + 2. device = userDeviceRepository.getByMemberIdAndDeviceId(payload.memberId, payload.deviceId) + 3. if (!device.refreshToken.equals(req.refreshToken)): + userDeviceRepository.delete(device) // 탈취 의심 → 강제 로그아웃 + throw INVALID_TOKEN + 4. member = memberRepository.getByMemberId(payload.memberId) + newAccess = jwt.createAccessToken(member) + newRefresh = jwt.createRefreshToken(member, payload.deviceId) + 5. device.rotateRefreshToken(newRefresh) + 6. return new ReissueResponse(new Tokens(newAccess, newRefresh)) +``` + +- Refresh Token Rotation은 그대로 유지. + +### 4.5 Logout 흐름 + +``` +AuthService.logout(String refreshToken): + jwt.parseRefreshTokenSafely(refreshToken) + .ifPresent(p -> userDeviceRepository + .deleteByMemberIdAndDeviceId(p.memberId, p.deviceId)) +``` + +- 만료된 refresh token이어도 claim에서 (memberId, deviceId)는 추출 가능 → 정상 정리. +- 다른 디바이스 세션은 영향 없음. + +### 4.6 Withdraw 흐름 + +``` +AuthService.withdraw(Long memberId): + member = memberRepository.findById(memberId).orElse(null) + if (member != null): + memberRepository.delete(member) // CASCADE로 user_devices 자동 삭제 + deleteFirebaseUserSafely(memberId, member.socialId) +``` + +- 기존의 `refreshTokenRepository.delete(memberId)` 호출 삭제. + +--- + +## 5. 제거되는 코드 + +| 파일/심볼 | 처리 | +|-----------|------| +| `SS-Auth/repository/RefreshTokenRepository` (Redis) | **삭제** | +| `JwtTokenProvider.getMemberIdFromRefreshToken` | **삭제** (`parseRefreshToken`로 대체) | +| `JwtTokenProvider.getMemberIdFromRefreshTokenSafely` | **삭제** (`parseRefreshTokenSafely`로 대체) | +| `AuthService`의 `RefreshTokenRepository` 주입 | **삭제** | + +> Redis 의존성 자체(`spring-boot-starter-data-redis`, `application.yml`의 redis 설정)는 **유지**. 향후 캐싱/레이트리미트 등 다른 용도 여지를 남김. 만약 전체 코드베이스 grep 결과 사용처가 0이면 PR에서 함께 검토. + +--- + +## 6. API 스펙 문서 정합성 보정 + +`docs/api-specs/01_auth.md` 현재 내용과 코드 간 불일치 일괄 정리: + +| 항목 | 현 문서 | 실제 코드 | 조치 | +|------|---------|-----------|------| +| 소셜 플랫폼 필드명 | `socialPlatform` | `socialType` | 문서를 코드에 맞춰 `socialType`으로 수정 | +| 지원 플랫폼 | GOOGLE, APPLE | GOOGLE, APPLE, KAKAO | 문서에 KAKAO 추가 | +| LoginRequest 디바이스 필드 | 이미 명시됨 | 미구현 | 코드 구현(이번 작업) | +| user_devices 컬럼 `refresh_token` | 명시됨 | 미구현 | 코드 구현(이번 작업) | + +응답 본문 형식(`memberId`, `nickname`, `tokens`, `isNewMember`)은 변경 없음. + +--- + +## 7. 테스트 전략 + +### 7.1 신규 테스트 + +- **`UserDeviceRepositoryTest`** (`@DataJpaTest`) + - `findByMemberIdAndDeviceId` 존재/부재 + - `deleteByMemberIdAndDeviceId` 정확히 한 row만 삭제 + - `(member_id, device_id)` unique 위반 시 예외 + - members CASCADE 삭제 시 user_devices 동반 삭제 + +### 7.2 보강할 테스트 (`AuthServiceTest`) + +- 로그인 — 신규 디바이스 시나리오: `user_devices`에 새 row, refresh_token 저장. +- 로그인 — 기존 디바이스 재로그인 시나리오: 같은 row의 fcm/refresh/last_login 갱신, row 개수 불변. +- 로그인 — 한 회원이 두 디바이스에서 로그인: row 2개 공존, 각 refresh_token 독립. +- Reissue — 정상 회전: refresh_token DB 값 변경, deviceId 유지. +- Reissue — DB와 불일치하는 토큰: 해당 디바이스 row 삭제 + `INVALID_TOKEN`. +- Reissue — 다른 디바이스 토큰: 다른 디바이스 row는 영향 없음. +- Logout — 해당 디바이스 row만 삭제, 다른 디바이스 row 유지. +- Logout — 만료된 refresh token: 정상 처리(claim에서 memberId/deviceId 추출). +- Withdraw — 모든 디바이스 row CASCADE 삭제, Refresh Token 관련 Redis 호출 없음. + +### 7.3 보강할 테스트 (`JwtTokenProviderTest`) + +- `createRefreshToken(member, deviceId)` 생성 → `parseRefreshToken` 결과의 deviceId 일치. +- 만료된 refresh token에 대해 `parseRefreshTokenSafely` 가 claim 반환. + +### 7.4 영향 받는 기존 테스트 + +- `AuthServiceTest`의 로그인/logout/reissue/withdraw 모든 케이스 시그니처 변경 영향 → 동시에 수정. + +--- + +## 8. 변경 파일 요약 + +**신규 (8개)** +- `SS-Auth/src/main/java/.../auth/entity/UserDevice.java` +- `SS-Auth/src/main/java/.../auth/repository/UserDeviceRepository.java` +- `SS-Auth/src/main/java/.../auth/constant/DeviceType.java` +- `SS-Auth/src/main/java/.../auth/jwt/RefreshTokenPayload.java` +- `SS-Auth/src/test/java/.../auth/repository/UserDeviceRepositoryTest.java` +- `SS-Web/src/main/resources/db/migration/V0_0_31__add_user_devices.sql` +- `SS-Auth/src/test/.../auth/jwt/JwtTokenProviderTest.java` (현재 없음 → 신규 작성) + +**수정** +- `SS-Auth/src/main/java/.../auth/dto/LoginRequest.java` +- `SS-Auth/src/main/java/.../auth/jwt/JwtTokenProvider.java` +- `SS-Auth/src/main/java/.../auth/service/AuthService.java` +- `SS-Auth/src/test/java/.../auth/service/AuthServiceTest.java` +- `docs/api-specs/01_auth.md` + +**삭제** +- `SS-Auth/src/main/java/.../auth/repository/RefreshTokenRepository.java` + +--- + +## 9. 리스크 및 대응 + +| 리스크 | 영향 | 대응 | +|--------|------|------| +| 기존 Redis에 남아있던 refresh_token 무효화 | 배포 직후 기존 사용자 전원 강제 로그아웃 | 의도적 — 안내 공지. 어차피 다중 디바이스 정책이 바뀌므로 마이그레이션 불가. | +| ddl-auto 환경의 `members` 테이블과 마이그레이션 정의 차이 | Flyway baseline 충돌 가능 | `IF NOT EXISTS` 사용. 첫 배포 시 Flyway `baselineOnMigrate=true` 설정 확인. | +| 동일 (member, device) 동시 로그인 시 unique 위반 | 500 응답 | 비현실적 시나리오. 발생 시 일반 에러로 처리, 별도 retry 미구현. | +| FCM 토큰 길이 256 초과 (실제로는 ~152자) | 컬럼 길이 부족 | 일반적으로 152자 수준이라 VARCHAR(255)면 충분. 향후 확장은 별도 마이그레이션. | diff --git a/docs/superpowers/specs/2026-05-19-firebase-id-token-verification-design.md b/docs/superpowers/specs/2026-05-19-firebase-id-token-verification-design.md new file mode 100644 index 0000000..67d2846 --- /dev/null +++ b/docs/superpowers/specs/2026-05-19-firebase-id-token-verification-design.md @@ -0,0 +1,98 @@ +# Firebase ID Token 검증 적용 (소셜 로그인) 설계 + +## 배경 및 문제 + +`/api/auth/login` 호출 시 동일한 소셜 계정으로 매번 신규 회원가입이 발생하고 있다. + +**근본 원인:** `SocialLoginStrategy` 구현체 3종 모두 idToken을 검증하지 않고 `ThreadLocalRandom` 으로 랜덤 socialId를 반환한다. `findBySocialIdAndSocialType()` 이 매번 빈 결과를 반환하므로 `findOrRegisterMember()` 가 신규 가입 분기를 탄다. + +```java +// GoogleLoginStrategy.java:13-15 (현재) +// TODO: 구글 로그인 연동 구현 +ThreadLocalRandom random = ThreadLocalRandom.current(); +return "GOOGLE_SOCIAL_ID_" + random.nextInt(100_000); +``` + +`AppleLoginStrategy`, `KakaoLoginStrategy` 동일. + +## 결정 + +프론트(Flutter)는 모든 소셜(Google/Apple/Kakao)에 대해 **Firebase Authentication 으로 인증한 뒤 Firebase ID Token 을 백엔드로 전송**한다. 따라서 백엔드는 소셜 종류와 무관하게 단일 검증 경로를 가진다. + +``` +firebaseAuth.verifyIdToken(idToken).getUid() +``` + +`getUid()` 가 반환하는 Firebase UID 를 `socialId` 로 사용한다. 사용자 별 영구 고유값이므로 동일 계정의 재로그인은 `findBySocialIdAndSocialType` 에서 정확히 매칭된다. + +## 변경 범위 + +### 변경 파일 + +| 파일 | 변경 내용 | +|------|----------| +| `SS-Auth/.../auth/social/GoogleLoginStrategy.java` | 본문을 `firebaseAuth.verifyIdToken(idToken).getUid()` 호출로 교체. `FirebaseAuthException` 발생 시 `CustomException(ErrorCode.INVALID_TOKEN)` throw. `@RequiredArgsConstructor` + `private final FirebaseAuth firebaseAuth` | +| `SS-Auth/.../auth/social/AppleLoginStrategy.java` | 동일 | +| `SS-Auth/.../auth/social/KakaoLoginStrategy.java` | 동일 | + +### 변경 없음 + +- `SocialLoginStrategy` 인터페이스: 유지 +- `SocialLoginStrategyConfig`: 유지 +- `LoginRequest`: `socialType` 필드 그대로 받음 (DB 의 `social_type` 분류용) +- `AuthService.getSocialId()`: 변경 없음 +- `Member` 엔티티, DB 스키마, Flyway 마이그레이션: 변경 없음 +- `ErrorCode.INVALID_TOKEN`: 기존 코드 재사용 + +### 부수 정리 (dev 환경) + +dev DB 의 fake row(`GOOGLE_SOCIAL_ID_xxx`) 는 더 이상 의미 없는 더미 데이터다. **dev DB 만 수동 삭제** (마이그레이션 X): + +```sql +DELETE FROM user_devices; +DELETE FROM members; +``` + +prod 는 아직 실 사용자가 없으므로 동일하게 비워두면 된다 (수동, 마이그레이션 아님). + +## 보안 고려 + +- `socialType` 은 클라이언트가 보낸 값이지만 본 변경에서는 위변조 검증을 추가하지 않는다. 같은 Firebase UID 라면 항상 같은 row 로 매칭되므로 socialType 위변조로 인한 계정 탈취 위험은 없다. +- 더 엄격하게 가려면 `FirebaseToken.getClaims().get("firebase").sign_in_provider` 값(`google.com`, `apple.com`, `oidc.kakao` 등) 과 `socialType` 의 일치 여부를 검증할 수 있다. 본 변경 범위 밖. + +## 에러 처리 + +| 케이스 | 처리 | +|--------|------| +| idToken 만료/서명 오류/형식 오류 | `FirebaseAuthException` → `CustomException(ErrorCode.INVALID_TOKEN)` | +| Firebase 서비스 일시 장애 | `FirebaseAuthException` 동일 분기 → `INVALID_TOKEN` | +| idToken `null` / 빈 문자열 | `LoginRequest` 입력 검증 단(이미 존재) 에서 거름. Strategy 단에서 추가 null 체크 불필요 | + +## 테스트 + +### 변경 없는 테스트 +- `AuthServiceTest`: `SocialLoginStrategy` 를 mock 하므로 그대로 통과해야 한다. + +### 신규 테스트 (Strategy 단위) +각 Strategy 에 대해 최소 2 케이스: + +1. **정상 검증** — `FirebaseAuth.verifyIdToken("valid")` 가 mock 으로 `FirebaseToken` 반환, `getUid()` 가 `"firebase-uid-1"` 반환 → strategy 가 `"firebase-uid-1"` 반환 +2. **검증 실패** — `verifyIdToken` 이 `FirebaseAuthException` throw → `CustomException(INVALID_TOKEN)` 으로 변환되어 throw + +3개 Strategy × 2 케이스 = 6 테스트. 짧음. + +## 영향 평가 + +- API 스펙 변경 없음: `/api/auth/login` 의 request/response 구조 유지 +- DB 스키마 변경 없음 +- Flutter 측 추가 작업 없음 (이미 Firebase IdToken 전송 중) +- 기존 `AuthServiceTest`/`JwtTokenProviderTest`/`AuthControllerTest` 회귀 영향 없음 + +## 작업 순서 + +1. `GoogleLoginStrategy` 구현 + 단위 테스트 +2. `AppleLoginStrategy` 구현 + 단위 테스트 +3. `KakaoLoginStrategy` 구현 + 단위 테스트 +4. `./gradlew test` 전체 통과 확인 +5. dev DB cleanup (`DELETE FROM ...`) — 수동 +6. 로컬에서 Flutter 로 로그인 → 같은 계정 두 번째 로그인 시 `isNewMember=false` 확인 (로그 `[SignUp]` 미출력) diff --git a/docs/superpowers/specs/2026-05-23-fuel-domain-design.md b/docs/superpowers/specs/2026-05-23-fuel-domain-design.md new file mode 100644 index 0000000..5f99125 --- /dev/null +++ b/docs/superpowers/specs/2026-05-23-fuel-domain-design.md @@ -0,0 +1,814 @@ +# 연료 시스템 도메인 설계 (이슈 #26) + +> **이슈**: [연료 시스템 도메인 구현 #26](https://github.com/SpaceStudyShip/SpaceStudyShip-BE/issues/26) +> **브랜치**: `20260422_#26_연료_시스템_도메인_구현` +> **버전**: version.yml `0.0.35` → `0.0.36` +> **마이그레이션**: `V0_0_36__add_fuel.sql` +> **API 스펙**: [docs/api-specs/04_fuel.md](../../api-specs/04_fuel.md) + +--- + +## 1. 개요와 범위 + +API 스펙의 2개 엔드포인트(`GET /api/fuel`, `GET /api/fuel/transactions`)를 구현하고, 향후 Timer/Exploration 도메인이 호출할 internal API(`FuelService.charge`, `FuelService.consume`)를 함께 제공한다. 동기화 전략은 **Tier 2 (Server-Validated)** — 충전·소비 자체는 서버가 결정하고, 클라이언트는 결과만 조회한다. + +### 범위 내 +- `UserFuel`, `FuelTransaction` Entity / Repository / Service +- `FuelController` (SS-Web) — GET 2개 엔드포인트 +- `FuelService.charge() / consume() / initialize()` internal API +- 신규 회원 가입 시 `UserFuel` 자동 생성 — `MemberCreatedEvent` 이벤트 방식 +- Swagger 문서 (Todo/AuthController 수준 풀세트) +- ErrorCode 2개 추가 (`INSUFFICIENT_FUEL`, `FUEL_NOT_INITIALIZED`) +- Flyway 마이그레이션 (`V0_0_36__add_fuel.sql`) + version.yml bump +- 단위/통합 테스트 (Entity, Service, Repository, Controller, Listener) + +### 범위 외 +- 충전 호출자 (Timer 도메인의 `POST /api/timer-sessions` — 이슈 별도) +- 소비 호출자 (Exploration 도메인 — 이슈 별도) +- `pendingMinutes` 활용 (스펙대로 컬럼만 두고 항상 0) +- 충전/소비 외부 POST 엔드포인트 (보안상 노출하지 않음) + +--- + +## 2. 모듈/패키지 구조 + +기존 `SS-Study` 모듈에 `fuel` 패키지로 배치. SS-Study가 "학습 도메인 통합 모듈"로 이미 정의되어 있고, Timer/Exploration도 동일 모듈에 합류할 예정이라 자연스러운 위치. + +```text +SS-Study/src/main/java/com/elipair/spacestudyship/study/ +└── fuel/ + ├── constant/ + │ ├── TransactionType.java ← Enum (CHARGE, CONSUME) + │ └── FuelReason.java ← Enum (STUDY_SESSION, EXPLORATION_UNLOCK) + ├── dto/ + │ ├── FuelResponse.java + │ ├── FuelTransactionResponse.java + │ └── FuelTransactionListResponse.java + ├── entity/ + │ ├── UserFuel.java + │ └── FuelTransaction.java + ├── repository/ + │ ├── UserFuelRepository.java + │ └── FuelTransactionRepository.java + └── service/ + ├── FuelService.java + └── FuelInitializeListener.java + +SS-Member/src/main/java/com/elipair/spacestudyship/member/event/ +└── MemberCreatedEvent.java ← record(Long memberId) — SS-Auth가 publish, SS-Study가 listen + +SS-Auth/src/main/java/com/elipair/spacestudyship/auth/service/AuthService.java +└── findOrCreateMember() 신규 회원 분기에 ApplicationEventPublisher.publishEvent(new MemberCreatedEvent(...)) 추가 + +SS-Web/src/main/java/com/elipair/spacestudyship/controller/fuel/ +└── FuelController.java ← GET /api/fuel, GET /api/fuel/transactions +``` + +### 2.1 의존성 흐름 +- SS-Auth → SS-Member (`MemberCreatedEvent` 클래스 참조, 기존 의존 활용) +- SS-Study → SS-Member (기존) +- SS-Web → SS-Study (기존) +- **SS-Auth ↛ SS-Study** (이벤트로 역의존 회피) + +### 2.2 이벤트 클래스 위치 결정 근거 +SS-Auth는 회원 생성 시점을 알고, SS-Study(및 향후 Badge/Exploration)는 회원 생성에 반응해야 한다. 양쪽 모두 SS-Member를 이미 의존하므로 SS-Member가 자연스러운 공유 컨트랙트 위치. SS-Common 대안도 있으나 도메인 이벤트는 SS-Member가 더 적절. + +--- + +## 3. Entity 설계 + +### 3.1 UserFuel (1:1, user_id가 PK) + +```java +@Entity +@Table(name = "user_fuel") +@Getter +@Builder +@AllArgsConstructor +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class UserFuel extends BaseTimeEntity { + + @Id + @Column(name = "user_id") + private Long userId; + + @Column(name = "current_fuel", nullable = false) + private Integer currentFuel; + + @Column(name = "total_charged", nullable = false) + private Integer totalCharged; + + @Column(name = "total_consumed", nullable = false) + private Integer totalConsumed; + + @Column(name = "pending_minutes", nullable = false) + private Integer pendingMinutes; + + public static UserFuel initialize(Long userId) { + return UserFuel.builder() + .userId(userId) + .currentFuel(0) + .totalCharged(0) + .totalConsumed(0) + .pendingMinutes(0) + .build(); + } + + public void charge(int amount) { + if (amount <= 0) throw new CustomException(ErrorCode.INVALID_INPUT_VALUE); + this.currentFuel += amount; + this.totalCharged += amount; + } + + public void consume(int amount) { + if (amount <= 0) throw new CustomException(ErrorCode.INVALID_INPUT_VALUE); + if (this.currentFuel < amount) { + throw new CustomException(ErrorCode.INSUFFICIENT_FUEL); + } + this.currentFuel -= amount; + this.totalConsumed += amount; + } +} +``` + +- `BaseTimeEntity.updatedAt`이 API 응답의 `lastUpdatedAt` 역할 — 별도 컬럼 없음 +- amount ≤ 0 가드는 Entity와 Service 양쪽에서 (방어적) + +### 3.2 FuelTransaction + +```java +@Entity +@Table(name = "fuel_transactions") +@Getter +@Builder +@AllArgsConstructor +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class FuelTransaction extends BaseTimeEntity { + + @Id + @Column(length = 36) + private String id; + + @Column(name = "user_id", nullable = false) + private Long userId; + + @Enumerated(EnumType.STRING) + @Column(nullable = false, length = 10) + private TransactionType type; + + @Column(nullable = false) + private Integer amount; + + @Enumerated(EnumType.STRING) + @Column(nullable = false, length = 30) + private FuelReason reason; + + @Column(name = "reference_id", length = 50) + private String referenceId; + + @Column(name = "balance_after", nullable = false) + private Integer balanceAfter; + + public static FuelTransaction of(String id, Long userId, TransactionType type, + int amount, FuelReason reason, + String referenceId, int balanceAfter) { + return FuelTransaction.builder() + .id(id) + .userId(userId) + .type(type) + .amount(amount) + .reason(reason) + .referenceId(referenceId) + .balanceAfter(balanceAfter) + .build(); + } +} +``` + +- `id`는 호출자가 전달하는 `transactionId` 그대로 사용 (idempotency 키 겸 PK) +- `createdAt`은 `BaseTimeEntity` 활용 — API `createdAt`과 동일 + +### 3.3 Enum + +```java +public enum TransactionType { + CHARGE, + CONSUME; +} + +public enum FuelReason { + STUDY_SESSION, // charge: 공부 세션 완료 + EXPLORATION_UNLOCK; // consume: 행성/지역 해금 +} +``` + +- API 응답에서 type은 소문자(`"charge"`/`"consume"`) — 응답 매퍼에서 `name().toLowerCase()` 변환 +- reason은 대문자 그대로 + +--- + +## 4. DTO 설계 (Record + `@Schema`) + +### 4.1 FuelResponse + +```java +@Schema(description = "연료 잔량 응답") +public record FuelResponse( + @Schema(description = "현재 보유 연료", example = "350") Integer currentFuel, + @Schema(description = "누적 충전량", example = "1200") Integer totalCharged, + @Schema(description = "누적 소비량", example = "850") Integer totalConsumed, + @Schema(description = "미동기화 시간(분) - 향후 확장용, 현재 항상 0", example = "0") Integer pendingMinutes, + @Schema(description = "마지막 변동 시각 (ISO 8601 UTC)", example = "2026-04-16T10:30:00Z") String lastUpdatedAt +) { + private static final DateTimeFormatter ISO_UTC = DateTimeFormatter.ISO_INSTANT; + + public static FuelResponse from(UserFuel fuel) { + return new FuelResponse( + fuel.getCurrentFuel(), + fuel.getTotalCharged(), + fuel.getTotalConsumed(), + fuel.getPendingMinutes(), + formatUtc(fuel.getUpdatedAt()) + ); + } + + private static String formatUtc(LocalDateTime time) { + return time == null ? null : ISO_UTC.format(time.toInstant(ZoneOffset.UTC)); + } +} +``` + +### 4.2 FuelTransactionResponse + +```java +@Schema(description = "연료 거래 내역") +public record FuelTransactionResponse( + @Schema(example = "tx-uuid-1234") String id, + + @Schema(description = "charge 또는 consume", + allowableValues = {"charge", "consume"}, example = "charge") + String type, + + @Schema(example = "90") Integer amount, + + @Schema(description = "거래 사유", + allowableValues = {"STUDY_SESSION", "EXPLORATION_UNLOCK"}, + example = "STUDY_SESSION") + String reason, + + @Schema(nullable = true, example = "session-uuid-5678") String referenceId, + @Schema(example = "350") Integer balanceAfter, + @Schema(example = "2026-04-16T10:30:00Z") String createdAt +) { + private static final DateTimeFormatter ISO_UTC = DateTimeFormatter.ISO_INSTANT; + + public static FuelTransactionResponse from(FuelTransaction tx) { + return new FuelTransactionResponse( + tx.getId(), + tx.getType().name().toLowerCase(), + tx.getAmount(), + tx.getReason().name(), + tx.getReferenceId(), + tx.getBalanceAfter(), + formatUtc(tx.getCreatedAt()) + ); + } + + private static String formatUtc(LocalDateTime time) { + return time == null ? null : ISO_UTC.format(time.toInstant(ZoneOffset.UTC)); + } +} +``` + +### 4.3 FuelTransactionListResponse (Page envelope) + +```java +@Schema(description = "거래 내역 페이지 응답") +public record FuelTransactionListResponse( + List content, + Integer page, + Integer size, + Long totalElements, + Integer totalPages +) { + public static FuelTransactionListResponse from(Page page) { + return new FuelTransactionListResponse( + page.getContent().stream().map(FuelTransactionResponse::from).toList(), + page.getNumber(), + page.getSize(), + page.getTotalElements(), + page.getTotalPages() + ); + } +} +``` + +### 4.4 Search 파라미터 — 컨트롤러 `@RequestParam` 직접 사용 + +별도 Record DTO를 만들지 않고 컨트롤러 메서드 파라미터에 `@RequestParam + @Pattern/@Min/@Max`로 검증. 자세한 시그니처는 §6.1 참조. + +### 4.5 시간 포맷 헬퍼 중복 +3개 DTO에 `formatUtc(LocalDateTime)` 헬퍼 중복. 기존 Todo 도메인도 동일 중복을 받아들이고 있어 일관성 차원에서 그대로 채택. SS-Common 유틸 분리는 별도 작업. + +--- + +## 5. Repository + +### 5.1 UserFuelRepository + +```java +public interface UserFuelRepository extends JpaRepository { + + Optional findByUserId(Long userId); + + @Lock(LockModeType.PESSIMISTIC_WRITE) + @Query("SELECT uf FROM UserFuel uf WHERE uf.userId = :userId") + Optional findByUserIdForUpdate(@Param("userId") Long userId); + + boolean existsByUserId(Long userId); +} +``` + +- `findByUserId` 명시로 의도 표현 (다른 도메인 패턴과 일관성) +- `findByUserIdForUpdate`는 charge/consume 경합 차단용 (Member의 `findByIdForUpdate`와 동일 패턴) +- `existsByUserId`는 이벤트 리스너에서 idempotency 확인용 + +### 5.2 FuelTransactionRepository + +```java +public interface FuelTransactionRepository extends JpaRepository { + + @Query(""" + SELECT ft FROM FuelTransaction ft + WHERE ft.userId = :userId + AND (:type IS NULL OR ft.type = :type) + AND (:startDateTime IS NULL OR ft.createdAt >= :startDateTime) + AND (:endDateTime IS NULL OR ft.createdAt < :endDateTime) + """) + Page findByFilters( + @Param("userId") Long userId, + @Param("type") TransactionType type, + @Param("startDateTime") LocalDateTime startDateTime, + @Param("endDateTime") LocalDateTime endDateTime, + Pageable pageable); +} +``` + +### 5.3 날짜 필터 변환 규약 +- 입력: `YYYY-MM-DD` 문자열 +- `startDate=2026-04-01` → `startDateTime = 2026-04-01 00:00:00` +- `endDate=2026-04-16` → `endDateTime = 2026-04-17 00:00:00` (반열림 `< endDateTime`로 종료일 포함) +- 변환은 서비스 레이어에서 수행 + +### 5.4 정렬 +서비스가 `PageRequest.of(page, size, Sort.by("createdAt").descending())` 강제 주입 (스펙: 최신순 고정) + +### 5.5 인덱스 (마이그레이션에서 정의) +```sql +CREATE INDEX idx_fuel_transactions_user_created + ON fuel_transactions (user_id, created_at DESC); +``` + +--- + +## 6. Controller + Service + +### 6.1 FuelController + +```java +@RestController +@RequiredArgsConstructor +@Validated +@Tag(name = "Fuel", description = "연료 잔량 및 거래 내역 API") +public class FuelController { + + private final FuelService fuelService; + + @GetMapping("/api/fuel") + @Operation(summary = "연료 잔량 조회", description = "...") + @ApiResponses({ + @ApiResponse(responseCode = "200", ...), + @ApiResponse(responseCode = "401", ...), + @ApiResponse(responseCode = "500", ...) + }) + public ResponseEntity getFuel(@AuthMember LoginMember loginMember) { + return ResponseEntity.ok(fuelService.getFuel(loginMember.memberId())); + } + + @GetMapping("/api/fuel/transactions") + @Operation(summary = "연료 거래 내역 조회", description = "...") + @ApiResponses({ + @ApiResponse(responseCode = "200", ...), + @ApiResponse(responseCode = "400", ...), + @ApiResponse(responseCode = "401", ...), + @ApiResponse(responseCode = "500", ...) + }) + public ResponseEntity getTransactions( + @AuthMember LoginMember loginMember, + @RequestParam(required = false) + @Pattern(regexp = "charge|consume", + message = "type은 charge 또는 consume이어야 합니다.") + String type, + @RequestParam(required = false) + @Pattern(regexp = "\\d{4}-\\d{2}-\\d{2}", + message = "startDate는 YYYY-MM-DD 형식이어야 합니다.") + String startDate, + @RequestParam(required = false) + @Pattern(regexp = "\\d{4}-\\d{2}-\\d{2}", + message = "endDate는 YYYY-MM-DD 형식이어야 합니다.") + String endDate, + @RequestParam(defaultValue = "0") @Min(0) Integer page, + @RequestParam(defaultValue = "20") @Min(1) @Max(100) Integer size) { + + TransactionType typeEnum = type == null ? null + : TransactionType.valueOf(type.toUpperCase()); + return ResponseEntity.ok( + fuelService.getTransactions( + loginMember.memberId(), typeEnum, + startDate, endDate, page, size)); + } +} +``` + +### 6.2 Swagger 어노테이션 정책 (Todo/AuthController 패턴) +- 메서드별 `@Operation` (summary + 상세 description) +- 모든 응답 코드(200/400/401/500)에 `@ApiResponse` + `@Schema(implementation = ...)` + `@ExampleObject` +- 에러는 `ErrorResponse` 스키마, `{"code":"...","message":"..."}` 예시 + +### 6.3 엔드포인트 매핑 + +| 메소드 | 경로 | 응답 | 주요 에러 | +|-------|-----|-----|---------| +| GET | `/api/fuel` | 200 | 401, 500 | +| GET | `/api/fuel/transactions` (`type`, `startDate`, `endDate`, `page`, `size`) | 200 | 400, 401, 500 | + +### 6.4 FuelService — 메서드 시그니처 (개요) + +본문은 §6.5~§6.9에 정의. 아래는 시그니처 일람 (실제 구현 시 각 메서드 본문 채움). + +```text +@Service @Transactional(readOnly = true) +class FuelService { + FuelResponse getFuel(Long userId) + FuelTransactionListResponse getTransactions(Long userId, TransactionType type, + String startDate, String endDate, + int page, int size) + @Transactional FuelTransactionResponse charge(Long userId, int amount, FuelReason reason, + String referenceId, String transactionId) + @Transactional FuelTransactionResponse consume(Long userId, int amount, FuelReason reason, + String referenceId, String transactionId) + @Transactional void initialize(Long userId) +} +``` + +### 6.5 getFuel + +```java +public FuelResponse getFuel(Long userId) { + UserFuel fuel = userFuelRepository.findByUserId(userId) + .orElseThrow(() -> new CustomException(ErrorCode.FUEL_NOT_INITIALIZED)); + return FuelResponse.from(fuel); +} +``` + +### 6.6 getTransactions + +```java +public FuelTransactionListResponse getTransactions( + Long userId, TransactionType type, + String startDate, String endDate, + int page, int size) { + + LocalDateTime startDateTime = startDate == null ? null + : LocalDate.parse(startDate).atStartOfDay(); + LocalDateTime endDateTime = endDate == null ? null + : LocalDate.parse(endDate).plusDays(1).atStartOfDay(); + + Pageable pageable = PageRequest.of(page, size, + Sort.by(Sort.Direction.DESC, "createdAt")); + + Page result = transactionRepository + .findByFilters(userId, type, startDateTime, endDateTime, pageable); + + return FuelTransactionListResponse.from(result); +} +``` + +### 6.7 charge (idempotent) + +```java +@Transactional +public FuelTransactionResponse charge( + Long userId, int amount, FuelReason reason, + String referenceId, String transactionId) { + + if (amount <= 0) throw new CustomException(ErrorCode.INVALID_INPUT_VALUE); + + Optional existing = transactionRepository.findById(transactionId); + if (existing.isPresent()) { + log.info("[Fuel] charge idempotent skip | userId={}, txId={}", userId, transactionId); + return FuelTransactionResponse.from(existing.get()); + } + + UserFuel fuel = userFuelRepository.findByUserIdForUpdate(userId) + .orElseThrow(() -> new CustomException(ErrorCode.FUEL_NOT_INITIALIZED)); + fuel.charge(amount); + + FuelTransaction tx = FuelTransaction.of( + transactionId, userId, TransactionType.CHARGE, + amount, reason, referenceId, fuel.getCurrentFuel()); + transactionRepository.save(tx); + + log.info("[Fuel] 충전 | userId={}, amount={}, reason={}, txId={}, balanceAfter={}", + userId, amount, reason, transactionId, fuel.getCurrentFuel()); + return FuelTransactionResponse.from(tx); +} +``` + +### 6.8 consume + +```java +@Transactional +public FuelTransactionResponse consume( + Long userId, int amount, FuelReason reason, + String referenceId, String transactionId) { + + if (amount <= 0) throw new CustomException(ErrorCode.INVALID_INPUT_VALUE); + + Optional existing = transactionRepository.findById(transactionId); + if (existing.isPresent()) { + log.info("[Fuel] consume idempotent skip | userId={}, txId={}", userId, transactionId); + return FuelTransactionResponse.from(existing.get()); + } + + UserFuel fuel = userFuelRepository.findByUserIdForUpdate(userId) + .orElseThrow(() -> new CustomException(ErrorCode.FUEL_NOT_INITIALIZED)); + fuel.consume(amount); // 부족 시 INSUFFICIENT_FUEL + + FuelTransaction tx = FuelTransaction.of( + transactionId, userId, TransactionType.CONSUME, + amount, reason, referenceId, fuel.getCurrentFuel()); + transactionRepository.save(tx); + + log.info("[Fuel] 소비 | userId={}, amount={}, reason={}, txId={}, balanceAfter={}", + userId, amount, reason, transactionId, fuel.getCurrentFuel()); + return FuelTransactionResponse.from(tx); +} +``` + +### 6.9 initialize (이벤트 리스너에서 호출) + +```java +@Transactional +public void initialize(Long userId) { + if (userFuelRepository.existsByUserId(userId)) { + log.info("[Fuel] 초기화 스킵 (이미 존재) | userId={}", userId); + return; + } + userFuelRepository.save(UserFuel.initialize(userId)); + log.info("[Fuel] 초기화 | userId={}", userId); +} +``` + +### 6.10 FuelInitializeListener + +```java +@Slf4j +@Component +@RequiredArgsConstructor +public class FuelInitializeListener { + + private final FuelService fuelService; + + @TransactionalEventListener(phase = TransactionPhase.BEFORE_COMMIT) + public void onMemberCreated(MemberCreatedEvent event) { + fuelService.initialize(event.memberId()); + } +} +``` + +**왜 BEFORE_COMMIT인가** +- 회원 저장 트랜잭션과 동일 트랜잭션에서 처리 → 둘 다 성공/실패로 묶임 +- AFTER_COMMIT은 회원만 저장되고 fuel 초기화 실패 시 정합성 깨짐 위험 + +### 6.11 MemberCreatedEvent (SS-Member) + +```java +public record MemberCreatedEvent(Long memberId) { } +``` + +### 6.12 AuthService 수정 지점 + +기존 `findOrCreateMember`의 신규 회원 분기: +```java +memberRepository.save(newMember); +eventPublisher.publishEvent(new MemberCreatedEvent(newMember.getId())); // 추가 +``` +- `ApplicationEventPublisher` 필드 주입 추가 + +--- + +## 7. ErrorCode 추가 (SS-Common) + +```java +// Fuel +INSUFFICIENT_FUEL(HttpStatus.BAD_REQUEST, "연료가 부족합니다."), +FUEL_NOT_INITIALIZED(HttpStatus.INTERNAL_SERVER_ERROR, "연료 정보가 초기화되지 않았습니다."), +``` + +- `INSUFFICIENT_FUEL`: 비즈니스 검증 실패 → 400 +- `FUEL_NOT_INITIALIZED`: 시스템 불변식 위반 → 500 (정상 흐름에서 발생 불가) + +`GlobalExceptionHandler`가 이미 `CustomException` → `ErrorResponse{code, message}`로 변환하므로 응답 형식 자동 일관. + +--- + +## 8. Flyway 마이그레이션 + +### 8.1 version.yml bump +`0.0.35` → `0.0.36` (작업 시작 단계에서 변경) + +### 8.2 V0_0_36__add_fuel.sql + +```sql +-- user_fuel: 유저당 1개 연료 잔량 레코드 +CREATE TABLE IF NOT EXISTS user_fuel ( + user_id BIGINT PRIMARY KEY, + current_fuel INTEGER NOT NULL DEFAULT 0, + total_charged INTEGER NOT NULL DEFAULT 0, + total_consumed INTEGER NOT NULL DEFAULT 0, + pending_minutes INTEGER NOT NULL DEFAULT 0, + created_at TIMESTAMP NOT NULL, + updated_at TIMESTAMP NOT NULL, + CONSTRAINT fk_user_fuel_member FOREIGN KEY (user_id) + REFERENCES members(id) ON DELETE CASCADE, + CONSTRAINT chk_fuel_non_negative CHECK (current_fuel >= 0), + CONSTRAINT chk_total_charged_non_negative CHECK (total_charged >= 0), + CONSTRAINT chk_total_consumed_non_negative CHECK (total_consumed >= 0), + CONSTRAINT chk_pending_minutes_non_negative CHECK (pending_minutes >= 0) +); + +-- fuel_transactions: 충전/소비 거래 내역 +CREATE TABLE IF NOT EXISTS fuel_transactions ( + id VARCHAR(36) PRIMARY KEY, + user_id BIGINT NOT NULL, + type VARCHAR(10) NOT NULL, + amount INTEGER NOT NULL, + reason VARCHAR(30) NOT NULL, + reference_id VARCHAR(50), + balance_after INTEGER NOT NULL, + created_at TIMESTAMP NOT NULL, + updated_at TIMESTAMP NOT NULL, + CONSTRAINT fk_fuel_transactions_member FOREIGN KEY (user_id) + REFERENCES members(id) ON DELETE CASCADE, + CONSTRAINT chk_fuel_tx_amount_positive CHECK (amount > 0), + CONSTRAINT chk_fuel_tx_type CHECK (type IN ('CHARGE','CONSUME')), + CONSTRAINT chk_fuel_tx_reason CHECK (reason IN ('STUDY_SESSION','EXPLORATION_UNLOCK')) +); + +CREATE INDEX IF NOT EXISTS idx_fuel_transactions_user_created + ON fuel_transactions (user_id, created_at DESC); +``` + +### 8.3 CLAUDE.md 마이그레이션 이력 표 업데이트 + +| 버전 | 파일 | 내용 | +|------|------|------| +| 0.0.31 | V0_0_31__add_user_devices.sql | 초기 스키마 | +| 0.0.34 | V0_0_34__add_todos_and_categories.sql | todos, todo_categories | +| **0.0.36** | **V0_0_36__add_fuel.sql** | **user_fuel, fuel_transactions 테이블 생성 (CHECK 제약 포함)** | + +--- + +## 9. 테스트 전략 + +### 9.1 UserFuel Entity 단위 테스트 + +순수 객체 테스트, Spring 컨텍스트 불필요. + +| 시나리오 | 검증 | +|---------|------| +| `initialize(userId)` | 모든 값 0, userId 세팅 | +| `charge(90)` | currentFuel +=90, totalCharged +=90, totalConsumed 불변 | +| `consume(50)` after charge(100) | currentFuel = 50, totalConsumed = 50 | +| `charge(0)` / `charge(-5)` | `CustomException(INVALID_INPUT_VALUE)` | +| `consume(amount > currentFuel)` | `CustomException(INSUFFICIENT_FUEL)` | +| `consume(currentFuel)` (경계값) | currentFuel = 0 | + +### 9.2 Repository 테스트 (Testcontainers PostgreSQL) + +기존 `StudyTestApplication` 갱신: +```java +@EntityScan(basePackageClasses = {Todo.class, TodoCategory.class, UserFuel.class, FuelTransaction.class, ...}) +@EnableJpaRepositories(basePackageClasses = {TodoRepository.class, TodoCategoryRepository.class, UserFuelRepository.class, FuelTransactionRepository.class}) +``` + +**UserFuelRepositoryTest** +- `findByUserId` / `existsByUserId` 기본 +- CHECK 제약: `current_fuel = -1` insert 시도 → DataIntegrityViolation +- `findByUserIdForUpdate` smoke (row 반환 확인) + +**FuelTransactionRepositoryTest** +- type 필터 (charge만 / consume만 / null 전체) +- 날짜 범위 필터 (startDate / endDate / 둘 다 / 둘 다 null) +- 경계값: `endDate=2026-04-16`로 검색 시 2026-04-16 23:59 거래 포함, 2026-04-17 00:00 거래 제외 +- 페이지네이션 + createdAt DESC 정렬 +- CHECK 제약: `amount = 0` / 음수 insert 시도 시 실패 + +### 9.3 Service 단위 테스트 (Mockito) + +```java +@ExtendWith(MockitoExtension.class) +class FuelServiceTest { + @Mock UserFuelRepository userFuelRepository; + @Mock FuelTransactionRepository transactionRepository; + @InjectMocks FuelService fuelService; +} +``` + +| 메서드 | 시나리오 | +|--------|---------| +| `getFuel` | 정상 / `FUEL_NOT_INITIALIZED` | +| `getTransactions` | type=null/CHARGE/CONSUME, startDate/endDate 변환 (LocalDate→LocalDateTime 반열림), Pageable 정렬 검증 (ArgumentCaptor) | +| `charge` | 정상 (락 → entity.charge → save), idempotent 재호출 (기존 tx 반환), `amount<=0` → INVALID_INPUT_VALUE, fuel 부재 → FUEL_NOT_INITIALIZED | +| `consume` | 정상, idempotent, 잔량 부족 → INSUFFICIENT_FUEL, 정확히 잔량만큼 소비 | +| `initialize` | 신규 회원 저장, 이미 존재 시 skip | + +**ArgumentCaptor 검증 포인트** +- `transactionRepository.save(captor)` — FuelTransaction의 `balanceAfter`가 실제 잔량 반영 +- `transactionRepository.findByFilters(...)` — 날짜 변환 결과가 반열림 `[startDate, endDate+1)` 인지 + +### 9.4 FuelInitializeListener 단위 테스트 + +- `MemberCreatedEvent` 수신 시 `fuelService.initialize(memberId)` 호출 검증 + +### 9.5 AuthService 이벤트 publish 회귀 테스트 + +기존 `AuthServiceTest`에 추가: +- 신규 회원 로그인 시 `ApplicationEventPublisher.publishEvent` 호출 검증 +- 기존 회원 재로그인 시 publish 호출되지 않음 검증 + +### 9.6 Controller 테스트 (SS-Web, MockMvc) + +기존 `TodoControllerTest` 패턴. + +| 시나리오 | 검증 | +|---------|------| +| `GET /api/fuel` 200 | FuelResponse JSON 본문 필드 | +| `GET /api/fuel` 401 | 인증 미존재 시 | +| `GET /api/fuel/transactions` 200 | Page envelope JSON 구조 | +| `GET /api/fuel/transactions?type=invalid` | 400 INVALID_INPUT_VALUE | +| `GET /api/fuel/transactions?startDate=2026-13-01` | 400 (Pattern 위반) | +| `GET /api/fuel/transactions?size=200` | 400 (Max 100) | +| `GET /api/fuel/transactions?page=-1` | 400 (Min 0) | +| `GET /api/fuel/transactions?type=charge&startDate=...&endDate=...` | 서비스 호출 인자 검증 (enum 변환, 문자열 그대로 전달) | + +### 9.7 통합 시나리오 (yagni) + +End-to-end 흐름 (신규 가입→충전→조회→소비→재호출 idempotency)은 Timer/Exploration 도메인 작업 시 자연스럽게 통합 검증. 이번 작업에서는 9.1~9.6만 필수. + +### 9.8 커버리지 목표 +- 전역 룰(testing.md) 80%+ 준수 +- Entity 비즈니스 로직(charge/consume) 100% +- Service 메서드 라인 커버리지 95%+ + +--- + +## 10. 셀프 리뷰 체크리스트 (구현 시 확인) + +- [ ] `CustomException(ErrorCode)` 던지기 — 직접 ResponseEntity 만들지 않기 +- [ ] Service `@Transactional(readOnly = true)` + 쓰기 메소드만 `@Transactional` +- [ ] charge/consume에서 `findByUserIdForUpdate` 사용으로 동시성 방지 +- [ ] idempotency: `transactionRepository.findById(transactionId)` 우선 체크 후 기존 결과 반환 +- [ ] `amount <= 0` 가드는 Service 진입부와 Entity 내부 모두에 +- [ ] Swagger 모든 엔드포인트에 200/400/401/500 응답 명시 +- [ ] Query 파라미터에 `@Pattern`/`@Min`/`@Max` 검증, 컨트롤러에 `@Validated` +- [ ] 로그 포맷 `[Fuel] 액션 | key=value` 컨벤션 준수 +- [ ] 마이그레이션 파일에 민감한 값 없음 +- [ ] version.yml bump 포함된 커밋 +- [ ] CLAUDE.md 마이그레이션 이력 표 갱신 +- [ ] `StudyTestApplication`에 fuel Entity/Repository 추가 +- [ ] `MemberCreatedEvent`는 SS-Member의 `member/event/` 패키지에 위치 +- [ ] `AuthService`에 `ApplicationEventPublisher` 필드 주입, `findOrCreateMember` 신규 분기에서 publish + +--- + +## 11. 작업 산출물 요약 + +| 분류 | 파일 | +|------|------| +| **Entity** | `study/fuel/entity/UserFuel.java`, `FuelTransaction.java` | +| **Enum** | `study/fuel/constant/TransactionType.java`, `FuelReason.java` | +| **DTO** | `study/fuel/dto/FuelResponse.java`, `FuelTransactionResponse.java`, `FuelTransactionListResponse.java` | +| **Repository** | `study/fuel/repository/UserFuelRepository.java`, `FuelTransactionRepository.java` | +| **Service** | `study/fuel/service/FuelService.java`, `FuelInitializeListener.java` | +| **Event** | `SS-Member/.../member/event/MemberCreatedEvent.java` | +| **AuthService 수정** | `findOrCreateMember`에 `publishEvent(...)` 추가 | +| **Controller** | `controller/fuel/FuelController.java` | +| **ErrorCode** | `SS-Common/.../ErrorCode.java` (2개 추가) | +| **Migration** | `SS-Web/.../db/migration/V0_0_36__add_fuel.sql` | +| **version.yml** | `0.0.35` → `0.0.36` | +| **CLAUDE.md** | 마이그레이션 이력 표에 V0_0_36 추가 | +| **Test (SS-Study)** | `UserFuelTest`, `UserFuelRepositoryTest`, `FuelTransactionRepositoryTest`, `FuelServiceTest`, `FuelInitializeListenerTest`, `StudyTestApplication` 갱신 | +| **Test (SS-Auth)** | `AuthServiceTest`에 이벤트 publish 회귀 케이스 추가 | +| **Test (SS-Web)** | `FuelControllerTest` | diff --git a/docs/superpowers/specs/2026-05-23-todo-domain-design.md b/docs/superpowers/specs/2026-05-23-todo-domain-design.md new file mode 100644 index 0000000..686c0e5 --- /dev/null +++ b/docs/superpowers/specs/2026-05-23-todo-domain-design.md @@ -0,0 +1,735 @@ +# 할 일 + 카테고리 도메인 설계 (이슈 #24) + +> **이슈**: [할일 및 카테고리 도메인 구현 #24](https://github.com/SpaceStudyShip/SpaceStudyShip-BE/issues/24) +> **브랜치**: `20260422_#24_할일_및_카테고리_도메인_구현` +> **버전**: version.yml `0.0.33` → `0.0.34` +> **마이그레이션**: `V0_0_34__add_todos_and_categories.sql` +> **API 스펙**: [docs/api-specs/02_todo.md](../../api-specs/02_todo.md) + +--- + +## 1. 개요와 범위 + +API 스펙 8개 엔드포인트(Todo CRUD 4 + Category CRUD 4)를 구현한다. 동기화 전략은 **Tier 1 (Optimistic Updates)** — 클라이언트가 UUID를 생성해 보내고, 서버는 검증·영속화·소유권 확인만 담당한다. + +### 범위 내 +- Todo, TodoCategory Entity / Repository / Service +- TodoController, TodoCategoryController (SS-Web) +- Swagger 문서 (AuthController 수준 풀세트) +- ErrorCode 4개 추가 (Todo/Category × NotFound/AlreadyExists), 일관된 ErrorResponse 응답 +- Flyway 마이그레이션 (`V0_0_34`) + version.yml bump +- 단위/통합 테스트 (Service / Repository / Controller) + +### 범위 외 +- Timer 도메인의 `actualMinutes` 누적 로직 (이슈 #26) +- Fuel/Exploration 연동 +- 카테고리 맵 UI 좌표 검증의 비즈니스 의미 (스펙 그대로 0.0~1.0만 보장) + +--- + +## 2. 모듈/패키지 구조 + +기존 빈 `SS-Study` 모듈에 `todo` 패키지로 배치. SS-Study는 향후 Timer까지 포함하는 "학습" 도메인 통합 모듈로 확장될 예정. + +```text +SS-Study/src/main/java/com/elipair/spacestudyship/study/ +└── todo/ + ├── dto/ + │ ├── TodoCreateRequest.java + │ ├── TodoUpdateRequest.java + │ ├── TodoResponse.java + │ ├── CategoryCreateRequest.java + │ ├── CategoryUpdateRequest.java + │ └── CategoryResponse.java + ├── entity/ + │ ├── Todo.java + │ └── TodoCategory.java + ├── repository/ + │ ├── TodoRepository.java + │ └── TodoCategoryRepository.java + └── service/ + ├── TodoService.java + └── TodoCategoryService.java + +SS-Web/src/main/java/com/elipair/spacestudyship/controller/todo/ +├── TodoController.java +└── TodoCategoryController.java +``` + +기존 `study/{constant,dto,entity,repository,service}` 빈 폴더는 정리(또는 그대로 두고 todo 하위에서 시작). SS-Study `build.gradle`는 이미 `api project(':SS-Common')`, `api project(':SS-Member')` 포함 — 추가 의존성 불필요. + +--- + +## 3. Entity 설계 + +### 3.1 Todo + +```java +@Entity +@Table(name = "todos") +@Getter +@Builder +@AllArgsConstructor +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class Todo extends BaseTimeEntity { + + @Id + @Column(length = 36) + private String id; + + @Column(name = "user_id", nullable = false) + private Long userId; + + @Column(nullable = false, length = 100) + private String title; + + @JdbcTypeCode(SqlTypes.JSON) + @Column(name = "scheduled_dates", nullable = false, columnDefinition = "jsonb") + private List scheduledDates; + + @JdbcTypeCode(SqlTypes.JSON) + @Column(name = "completed_dates", nullable = false, columnDefinition = "jsonb") + private List completedDates; + + @JdbcTypeCode(SqlTypes.JSON) + @Column(name = "category_ids", nullable = false, columnDefinition = "jsonb") + private List categoryIds; + + @Column(name = "estimated_minutes") + private Integer estimatedMinutes; + + @Column(name = "actual_minutes") + private Integer actualMinutes; + + public static Todo create(String id, Long userId, String title, + List scheduledDates, List categoryIds, + Integer estimatedMinutes) { + return Todo.builder() + .id(id) + .userId(userId) + .title(title) + .scheduledDates(scheduledDates == null ? List.of() : scheduledDates) + .completedDates(List.of()) + .categoryIds(categoryIds == null ? List.of() : categoryIds) + .estimatedMinutes(estimatedMinutes) + .build(); + } + + public void updateTitle(String title) { this.title = title; } + public void updateScheduledDates(List dates) { this.scheduledDates = dates; } + public void updateCompletedDates(List dates) { this.completedDates = dates; } + public void updateCategoryIds(List ids) { this.categoryIds = ids; } + public void updateEstimatedMinutes(Integer minutes) { this.estimatedMinutes = minutes; } + public void updateActualMinutes(Integer minutes) { this.actualMinutes = minutes; } + + public void removeCategoryId(String categoryId) { + this.categoryIds = this.categoryIds.stream() + .filter(id -> !id.equals(categoryId)) + .toList(); + } +} +``` + +### 3.2 TodoCategory + +```java +@Entity +@Table(name = "todo_categories") +@Getter +@Builder +@AllArgsConstructor +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class TodoCategory extends BaseTimeEntity { + + @Id + @Column(length = 36) + private String id; + + @Column(name = "user_id", nullable = false) + private Long userId; + + @Column(nullable = false, length = 20) + private String name; + + @Column(name = "icon_id", length = 50) + private String iconId; + + @Column(name = "position_x") + private Double positionX; + + @Column(name = "position_y") + private Double positionY; + + public static TodoCategory create(String id, Long userId, String name, + String iconId, Double positionX, Double positionY) { + return TodoCategory.builder() + .id(id) + .userId(userId) + .name(name) + .iconId(iconId) + .positionX(positionX) + .positionY(positionY) + .build(); + } + + public void updateName(String name) { this.name = name; } + public void updateIconId(String iconId) { this.iconId = iconId; } + public void updatePositionX(Double x) { this.positionX = x; } + public void updatePositionY(Double y) { this.positionY = y; } +} +``` + +--- + +## 4. DTO 설계 (Record + `@Schema`) + +### 4.1 Todo DTO + +```java +@Schema(description = "할 일 생성 요청") +public record TodoCreateRequest( + @Schema(description = "클라이언트 UUID v4 (없으면 서버 생성)", nullable = true, + example = "550e8400-e29b-41d4-a716-446655440000") + String id, + + @Schema(description = "제목 (1~100자)", example = "수학 문제 풀기") + @NotBlank @Size(max = 100) + String title, + + @Schema(description = "카테고리 ID 목록 (기본 [])", example = "[\"cat-uuid-1\"]") + List categoryIds, + + @Schema(description = "예상 소요 시간(분, 1 이상)", nullable = true, example = "60") + @Min(1) + Integer estimatedMinutes, + + @Schema(description = "예정 날짜 목록 (YYYY-MM-DD)", example = "[\"2026-04-16\"]") + List scheduledDates +) {} + +@Schema(description = "할 일 부분 수정 요청 — 전송하지 않은 필드는 기존 값 유지") +public record TodoUpdateRequest( + @Schema(description = "제목 (1~100자)", nullable = true) + @Size(min = 1, max = 100) String title, + + @Schema(description = "예정 날짜 목록", nullable = true) + List scheduledDates, + + @Schema(description = "완료 날짜 목록", nullable = true) + List completedDates, + + @Schema(description = "카테고리 ID 목록", nullable = true) + List categoryIds, + + @Schema(description = "예상 소요 시간(분)", nullable = true) + @Min(1) Integer estimatedMinutes, + + @Schema(description = "실제 소요 시간(분)", nullable = true) + @Min(0) Integer actualMinutes +) {} + +@Schema(description = "할 일 응답") +public record TodoResponse( + String id, + String title, + List scheduledDates, + List completedDates, + List categoryIds, + Integer estimatedMinutes, + Integer actualMinutes, + String createdAt, // ISO 8601 UTC + String updatedAt +) { + public static TodoResponse from(Todo todo) { ... } +} +``` + +### 4.2 Category DTO + +```java +@Schema(description = "카테고리 생성 요청") +public record CategoryCreateRequest( + @Schema(description = "클라이언트 UUID (없으면 서버 생성)", nullable = true) + String id, + + @Schema(description = "카테고리 이름 (1~20자)", example = "수학") + @NotBlank @Size(max = 20) + String name, + + @Schema(description = "아이콘 식별자", nullable = true, example = "math_icon") + String iconId, + + @Schema(description = "맵 가로 위치 (0.0~1.0)", nullable = true, example = "0.3") + @DecimalMin("0.0") @DecimalMax("1.0") + Double positionX, + + @Schema(description = "맵 세로 위치 (0.0~1.0)", nullable = true, example = "0.5") + @DecimalMin("0.0") @DecimalMax("1.0") + Double positionY +) {} + +@Schema(description = "카테고리 부분 수정 요청 — 전송하지 않은 필드는 기존 값 유지") +public record CategoryUpdateRequest( + @Schema(nullable = true) @Size(min = 1, max = 20) String name, + @Schema(nullable = true) String iconId, + @Schema(nullable = true) @DecimalMin("0.0") @DecimalMax("1.0") Double positionX, + @Schema(nullable = true) @DecimalMin("0.0") @DecimalMax("1.0") Double positionY +) {} + +@Schema(description = "카테고리 응답") +public record CategoryResponse( + String id, + String name, + String iconId, + Double positionX, + Double positionY, + String createdAt, + String updatedAt +) { + public static CategoryResponse from(TodoCategory category) { ... } +} +``` + +### 4.3 PATCH null vs 빈 배열 규약 + +| 클라이언트 입력 | 의미 | +|---------------|------| +| 필드 누락 / `null` | **변경 없음** | +| `[]` | **명시적으로 모두 제거** | +| 값 있는 배열 | **해당 값으로 교체** | + +서비스 코드에서 `if (request.fieldX() != null) entity.updateFieldX(request.fieldX())` 패턴 사용. + +--- + +## 5. Repository + +```java +public interface TodoRepository extends JpaRepository { + List findByUserIdOrderByCreatedAtDesc(Long userId); + + @Query(value = """ + SELECT * FROM todos + WHERE user_id = :userId + AND scheduled_dates @> CAST(:date AS jsonb) + ORDER BY created_at DESC + """, nativeQuery = true) + List findByUserIdAndScheduledDate(@Param("userId") Long userId, + @Param("date") String dateJsonLiteral); + + @Query(value = """ + SELECT * FROM todos + WHERE user_id = :userId + AND category_ids @> CAST(:categoryId AS jsonb) + ORDER BY created_at DESC + """, nativeQuery = true) + List findByUserIdAndCategoryId(@Param("userId") Long userId, + @Param("categoryId") String categoryIdJsonLiteral); + + boolean existsByIdAndUserId(String id, Long userId); + Optional findByIdAndUserId(String id, Long userId); +} +``` + +> `dateJsonLiteral`은 서비스에서 `"\"2026-04-16\""` 형태로 감싸서 전달 (jsonb `@>` 우변은 JSON 표현). 카테고리 ID도 동일. +> +> **안전성**: `date` / `categoryId`는 Controller에서 `@Pattern` 검증 후에만 서비스로 전달: +> - `date`: `@Pattern(regexp = "\\d{4}-\\d{2}-\\d{2}")` (YYYY-MM-DD 강제) +> - `categoryId`: `@Pattern(regexp = "[a-zA-Z0-9-]+")` (UUID 문자 집합) +> 정규식 통과한 입력만 JSON literal로 조립하므로 따옴표 escape 우려 없음. `@Param` 바인딩으로 SQL injection은 prepared statement가 차단. + +```java +public interface TodoCategoryRepository extends JpaRepository { + List findByUserIdOrderByCreatedAtAsc(Long userId); + boolean existsByIdAndUserId(String id, Long userId); + Optional findByIdAndUserId(String id, Long userId); + long countByIdInAndUserId(Collection ids, Long userId); +} +``` + +--- + +## 6. Service 핵심 로직 + +### 6.1 TodoService + +```java +@Service +@RequiredArgsConstructor +@Slf4j +@Transactional(readOnly = true) +public class TodoService { + + private final TodoRepository todoRepository; + private final TodoCategoryRepository categoryRepository; + + public List findAll(Long userId, String date, String categoryId) { + List todos; + if (date != null && categoryId != null) { + // 두 필터를 모두 만족하는 결과 (서비스에서 교집합) + Set byDate = todoRepository + .findByUserIdAndScheduledDate(userId, "\"" + date + "\"") + .stream().map(Todo::getId).collect(Collectors.toSet()); + todos = todoRepository + .findByUserIdAndCategoryId(userId, "\"" + categoryId + "\"") + .stream().filter(t -> byDate.contains(t.getId())).toList(); + } else if (date != null) { + todos = todoRepository.findByUserIdAndScheduledDate(userId, "\"" + date + "\""); + } else if (categoryId != null) { + todos = todoRepository.findByUserIdAndCategoryId(userId, "\"" + categoryId + "\""); + } else { + todos = todoRepository.findByUserIdOrderByCreatedAtDesc(userId); + } + return todos.stream().map(TodoResponse::from).toList(); + } + + @Transactional + public TodoResponse create(Long userId, TodoCreateRequest request) { + String id = request.id() != null ? request.id() : UUID.randomUUID().toString(); + if (todoRepository.existsById(id)) { + throw new CustomException(ErrorCode.TODO_ALREADY_EXISTS); + } + validateCategoryIds(userId, request.categoryIds()); + Todo todo = Todo.create( + id, userId, request.title(), + request.scheduledDates(), request.categoryIds(), + request.estimatedMinutes()); + Todo saved = todoRepository.save(todo); + log.info("[Todo] 생성 | userId={}, todoId={}", userId, saved.getId()); + return TodoResponse.from(saved); + } + + @Transactional + public TodoResponse update(Long userId, String todoId, TodoUpdateRequest request) { + Todo todo = todoRepository.findByIdAndUserId(todoId, userId) + .orElseThrow(() -> new CustomException(ErrorCode.TODO_NOT_FOUND)); + + if (request.categoryIds() != null) { + validateCategoryIds(userId, request.categoryIds()); + todo.updateCategoryIds(request.categoryIds()); + } + if (request.title() != null) todo.updateTitle(request.title()); + if (request.scheduledDates() != null) todo.updateScheduledDates(request.scheduledDates()); + if (request.completedDates() != null) todo.updateCompletedDates(request.completedDates()); + if (request.estimatedMinutes() != null) todo.updateEstimatedMinutes(request.estimatedMinutes()); + if (request.actualMinutes() != null) todo.updateActualMinutes(request.actualMinutes()); + + log.info("[Todo] 수정 | userId={}, todoId={}", userId, todoId); + return TodoResponse.from(todo); + } + + @Transactional + public void delete(Long userId, String todoId) { + if (!todoRepository.existsByIdAndUserId(todoId, userId)) { + throw new CustomException(ErrorCode.TODO_NOT_FOUND); + } + todoRepository.deleteById(todoId); + log.info("[Todo] 삭제 | userId={}, todoId={}", userId, todoId); + } + + private void validateCategoryIds(Long userId, List categoryIds) { + if (categoryIds == null || categoryIds.isEmpty()) return; + long found = categoryRepository.countByIdInAndUserId(categoryIds, userId); + if (found != categoryIds.stream().distinct().count()) { + throw new CustomException(ErrorCode.CATEGORY_NOT_FOUND); + } + } +} +``` + +### 6.2 TodoCategoryService + +```java +@Service +@RequiredArgsConstructor +@Slf4j +@Transactional(readOnly = true) +public class TodoCategoryService { + + private final TodoCategoryRepository categoryRepository; + private final TodoRepository todoRepository; + + public List findAll(Long userId) { + return categoryRepository.findByUserIdOrderByCreatedAtAsc(userId) + .stream().map(CategoryResponse::from).toList(); + } + + @Transactional + public CategoryResponse create(Long userId, CategoryCreateRequest request) { + String id = request.id() != null ? request.id() : UUID.randomUUID().toString(); + if (categoryRepository.existsById(id)) { + throw new CustomException(ErrorCode.CATEGORY_ALREADY_EXISTS); + } + TodoCategory category = TodoCategory.create( + id, userId, request.name(), request.iconId(), + request.positionX(), request.positionY()); + TodoCategory saved = categoryRepository.save(category); + log.info("[TodoCategory] 생성 | userId={}, categoryId={}", userId, saved.getId()); + return CategoryResponse.from(saved); + } + + @Transactional + public CategoryResponse update(Long userId, String categoryId, CategoryUpdateRequest request) { + TodoCategory category = categoryRepository.findByIdAndUserId(categoryId, userId) + .orElseThrow(() -> new CustomException(ErrorCode.CATEGORY_NOT_FOUND)); + if (request.name() != null) category.updateName(request.name()); + if (request.iconId() != null) category.updateIconId(request.iconId()); + if (request.positionX() != null) category.updatePositionX(request.positionX()); + if (request.positionY() != null) category.updatePositionY(request.positionY()); + log.info("[TodoCategory] 수정 | userId={}, categoryId={}", userId, categoryId); + return CategoryResponse.from(category); + } + + @Transactional + public void delete(Long userId, String categoryId) { + TodoCategory category = categoryRepository.findByIdAndUserId(categoryId, userId) + .orElseThrow(() -> new CustomException(ErrorCode.CATEGORY_NOT_FOUND)); + + // 연관 Todo의 categoryIds에서 제거 + List affected = todoRepository + .findByUserIdAndCategoryId(userId, "\"" + categoryId + "\""); + affected.forEach(todo -> todo.removeCategoryId(categoryId)); + // dirty checking으로 자동 update + + categoryRepository.delete(category); + log.info("[TodoCategory] 삭제 | userId={}, categoryId={}, affectedTodos={}", + userId, categoryId, affected.size()); + } +} +``` + +--- + +## 7. Controller + +`@AuthMember LoginMember loginMember` 패턴, 응답 코드는 스펙 그대로: + +| 작업 | 응답 코드 | +|------|---------| +| 생성 | `201 Created` | +| 조회 | `200 OK` | +| 수정 | `200 OK` | +| 삭제 | `204 No Content` | + +### 7.1 Swagger 어노테이션 정책 (AuthController 패턴) + +각 컨트롤러에 `@Tag`, 각 메소드에 `@Operation` (summary + 상세 description) + `@ApiResponses`: +- **성공 케이스** (200/201/204): `@Schema(implementation = TodoResponse.class)` + `@ExampleObject`로 실제 JSON 본문 예시 +- **에러 케이스** (400/401/404/409/500): `@Schema(implementation = ErrorResponse.class)` + `@ExampleObject`로 `{"code":"...", "message":"..."}` 예시 + +예시 (TodoController.create): +```java +@Operation( + summary = "할 일 생성", + description = """ + 새 할 일을 생성합니다. 클라이언트가 UUID를 생성해 보내면 그대로 사용하고, + 생략 시 서버에서 UUID v4를 생성합니다. + + ### 동작 + 1. id 충돌 검사 → 충돌 시 `409 TODO_ALREADY_EXISTS` + 2. categoryIds 실존 검증 → 누락 시 `404 CATEGORY_NOT_FOUND` + 3. 저장 후 생성된 객체 반환 + """) +@ApiResponses({ + @ApiResponse(responseCode = "201", description = "생성 성공", + content = @Content(mediaType = "application/json", + schema = @Schema(implementation = TodoResponse.class), + examples = @ExampleObject(name = "Created", value = "..."))), + @ApiResponse(responseCode = "400", description = "입력값 검증 실패", + content = @Content(schema = @Schema(implementation = ErrorResponse.class), + examples = @ExampleObject(name = "InvalidTitle", + value = "{\"code\":\"INVALID_INPUT_VALUE\",\"message\":\"title: 1자 이상 100자 이하여야 합니다.\"}"))), + @ApiResponse(responseCode = "401", ...), + @ApiResponse(responseCode = "404", description = "카테고리 없음", + content = @Content(schema = @Schema(implementation = ErrorResponse.class), + examples = @ExampleObject(value = "{\"code\":\"CATEGORY_NOT_FOUND\", ...}"))), + @ApiResponse(responseCode = "409", description = "동일 ID 중복", + content = @Content(schema = @Schema(implementation = ErrorResponse.class), + examples = @ExampleObject(value = "{\"code\":\"TODO_ALREADY_EXISTS\", ...}"))), + @ApiResponse(responseCode = "500", ...) +}) +@PostMapping("/api/todos") +public ResponseEntity create( + @AuthMember LoginMember loginMember, + @RequestBody @Valid TodoCreateRequest request) { + return ResponseEntity + .status(HttpStatus.CREATED) + .body(todoService.create(loginMember.memberId(), request)); +} +``` + +다른 7개 엔드포인트도 동일한 풀세트 적용. + +### 7.2 모든 엔드포인트 매핑 + +| 메소드 | 경로 | 응답 | 주요 에러 | +|-------|-----|-----|---------| +| GET | `/api/todos` (`date`, `categoryId` 쿼리) | 200 | 401 | +| POST | `/api/todos` | 201 | 400, 401, 404, 409 | +| PATCH | `/api/todos/{todoId}` | 200 | 400, 401, 404 | +| DELETE | `/api/todos/{todoId}` | 204 | 401, 404 | +| GET | `/api/todo-categories` | 200 | 401 | +| POST | `/api/todo-categories` | 201 | 400, 401, 409 | +| PATCH | `/api/todo-categories/{categoryId}` | 200 | 400, 401, 404 | +| DELETE | `/api/todo-categories/{categoryId}` | 204 | 401, 404 | + +> 다른 사용자의 리소스 접근은 **403이 아닌 404**로 통일 (정보 노출 방지). `findByIdAndUserId` 패턴. + +--- + +## 8. ErrorCode 추가 (SS-Common) + +비즈니스 NotFound / Conflict 4개만 추가. 입력값 형식 위반 (`INVALID_TITLE`, `INVALID_DATE_FORMAT`, `INVALID_CATEGORY_NAME` 등)은 `@Valid` 어노테이션 (`@NotBlank`, `@Size`, `@Min`, `@DecimalMin/Max`) 위반 시 기존 `GlobalExceptionHandler`가 자동으로 `INVALID_INPUT_VALUE`로 변환하여 응답한다 — 별도 ErrorCode 정의 불필요. 클라이언트는 `message` 본문으로 어떤 필드의 어떤 제약을 어겼는지 식별한다. + +```java +// Todo +TODO_NOT_FOUND(HttpStatus.NOT_FOUND, "해당 할 일을 찾을 수 없습니다."), +TODO_ALREADY_EXISTS(HttpStatus.CONFLICT, "동일 ID의 할 일이 이미 존재합니다."), + +// Todo Category +CATEGORY_NOT_FOUND(HttpStatus.NOT_FOUND, "해당 카테고리를 찾을 수 없습니다."), +CATEGORY_ALREADY_EXISTS(HttpStatus.CONFLICT, "동일 ID의 카테고리가 이미 존재합니다."), +``` + +스펙(02_todo.md)에서 정의한 `INVALID_TITLE` 등의 코드명은 사용하지 않고 모두 `INVALID_INPUT_VALUE`로 응답한다. 클라이언트는 `code` 대신 HTTP 400 + `message`로 분기. + +`GlobalExceptionHandler`가 이미 `CustomException` → `ErrorResponse{code, message}`로 변환하므로 응답 형식의 일관성은 자동 확보. + +--- + +## 9. Flyway 마이그레이션 + +### 9.1 version.yml bump +`0.0.33` → `0.0.34` (이 작업의 시작 단계에서 변경) + +### 9.2 V0_0_34__add_todos_and_categories.sql + +```sql +-- todo_categories: 카테고리 (할 일보다 먼저 생성) +CREATE TABLE IF NOT EXISTS todo_categories ( + id VARCHAR(36) PRIMARY KEY, + user_id BIGINT NOT NULL, + name VARCHAR(20) NOT NULL, + icon_id VARCHAR(50), + position_x DOUBLE PRECISION, + position_y DOUBLE PRECISION, + created_at TIMESTAMP NOT NULL, + updated_at TIMESTAMP NOT NULL, + CONSTRAINT fk_todo_categories_member FOREIGN KEY (user_id) + REFERENCES members(id) ON DELETE CASCADE +); + +CREATE INDEX IF NOT EXISTS idx_todo_categories_user ON todo_categories(user_id); + +-- todos: 할 일 +CREATE TABLE IF NOT EXISTS todos ( + id VARCHAR(36) PRIMARY KEY, + user_id BIGINT NOT NULL, + title VARCHAR(100) NOT NULL, + scheduled_dates JSONB NOT NULL DEFAULT '[]'::jsonb, + completed_dates JSONB NOT NULL DEFAULT '[]'::jsonb, + category_ids JSONB NOT NULL DEFAULT '[]'::jsonb, + estimated_minutes INTEGER, + actual_minutes INTEGER, + created_at TIMESTAMP NOT NULL, + updated_at TIMESTAMP NOT NULL, + CONSTRAINT fk_todos_member FOREIGN KEY (user_id) + REFERENCES members(id) ON DELETE CASCADE +); + +CREATE INDEX IF NOT EXISTS idx_todos_user ON todos(user_id); +``` + +> JSONB GIN 인덱스는 일단 생략 (Tier 1, 사용자별 Todo 수백 수준 → 풀스캔 무리없음). 데이터 증가 시 별도 마이그레이션으로 추가. + +### 9.3 CLAUDE.md 마이그레이션 이력 표 업데이트 + +| 버전 | 파일 | 내용 | +|------|------|------| +| 0.0.31 | V0_0_31__add_user_devices.sql | 초기 스키마 | +| **0.0.34** | **V0_0_34__add_todos_and_categories.sql** | **todos, todo_categories 테이블 생성** | + +--- + +## 10. 테스트 전략 + +### 10.1 SS-Study Repository 테스트 (Testcontainers PostgreSQL) + +Spring Boot 4 슬라이스 부재 우회 — `StudyTestApplication` + `@ImportAutoConfiguration`: + +```java +// SS-Study/src/test/java/.../study/StudyTestApplication.java +@SpringBootApplication +@EntityScan(basePackageClasses = {Todo.class, TodoCategory.class, /* Member, BaseTimeEntity */}) +@EnableJpaRepositories(basePackageClasses = {TodoRepository.class, TodoCategoryRepository.class}) +public class StudyTestApplication { } +``` + +```java +@SpringBootTest(classes = StudyTestApplication.class) +@Testcontainers +@ActiveProfiles("test") +class TodoRepositoryTest { + @Container + static PostgreSQLContainer postgres = new PostgreSQLContainer<>("postgres:16"); + + // JSONB @> 쿼리 검증, scheduled_dates 필터, category_ids 필터, 교집합 +} +``` + +### 10.2 Service 단위 테스트 (Mockito) + +```java +@ExtendWith(MockitoExtension.class) +class TodoServiceTest { + @Mock TodoRepository todoRepository; + @Mock TodoCategoryRepository categoryRepository; + @InjectMocks TodoService todoService; + + // 시나리오: + // - create 성공 / id 충돌 → TODO_ALREADY_EXISTS + // - create with invalid categoryIds → CATEGORY_NOT_FOUND + // - update with PATCH null/빈 배열/값 → 각 필드 케이스 + // - delete 없는 todo → TODO_NOT_FOUND + // - findAll 필터 조합 (date, categoryId, 둘 다) +} +``` + +`TodoCategoryServiceTest`도 동일 패턴. **delete 시 연관 Todo의 categoryIds 정리** 검증 케이스 필수. + +### 10.3 Controller 테스트 (SS-Web, MockMvc) + +기존 `AuthControllerTest` 패턴 따라 8개 엔드포인트의 happy path + 주요 에러 path. JWT 인증 mock은 기존 패턴 재사용. + +--- + +## 11. 셀프 리뷰 체크리스트 (구현 시 확인) + +- [ ] `CustomException(ErrorCode)` 던지기 — 직접 ResponseEntity 만들지 않기 +- [ ] Service `@Transactional(readOnly = true)` + 쓰기 메소드만 `@Transactional` +- [ ] PATCH의 모든 분기에 null 가드 (`if (request.fieldX() != null)`) +- [ ] `findByIdAndUserId` 패턴 — 다른 사용자 리소스는 404 +- [ ] categoryIds 실존 검증은 create / update 모두 적용 +- [ ] Swagger 모든 엔드포인트에 200/201/204/400/401/404/409/500 응답 명시 +- [ ] Query/Path 파라미터에 `@Pattern` 검증 (`date`, `categoryId`, `todoId`) +- [ ] 로그 포맷 `[도메인] 액션 | key=value` 컨벤션 준수 +- [ ] 마이그레이션 파일에 민감한 값 없음 +- [ ] version.yml bump 포함된 커밋 + +--- + +## 12. 작업 산출물 요약 + +| 분류 | 파일 | +|------|------| +| **Entity** | `study/todo/entity/Todo.java`, `study/todo/entity/TodoCategory.java` | +| **DTO** | `study/todo/dto/` — 6개 Record (Todo/Category × Create/Update/Response) | +| **Repository** | `study/todo/repository/TodoRepository.java`, `TodoCategoryRepository.java` | +| **Service** | `study/todo/service/TodoService.java`, `TodoCategoryService.java` | +| **Controller** | `controller/todo/TodoController.java`, `TodoCategoryController.java` | +| **ErrorCode** | `SS-Common/.../ErrorCode.java` (7개 추가) | +| **Migration** | `SS-Web/.../db/migration/V0_0_34__add_todos_and_categories.sql` | +| **version.yml** | `0.0.33` → `0.0.34` | +| **CLAUDE.md** | 마이그레이션 이력 표에 V0_0_34 추가 | +| **Test (SS-Study)** | `StudyTestApplication`, `TodoRepositoryTest`, `TodoCategoryRepositoryTest`, `TodoServiceTest`, `TodoCategoryServiceTest` | +| **Test (SS-Web)** | `TodoControllerTest`, `TodoCategoryControllerTest` | diff --git a/docs/superpowers/specs/2026-05-25-timer-session-domain-design.md b/docs/superpowers/specs/2026-05-25-timer-session-domain-design.md new file mode 100644 index 0000000..a0bbe04 --- /dev/null +++ b/docs/superpowers/specs/2026-05-25-timer-session-domain-design.md @@ -0,0 +1,872 @@ +# 타이머 세션 도메인 설계 (이슈 #25) + +> **이슈**: [⚙️[기능추가][타이머] 타이머 세션 도메인 구현 #25](https://github.com/SpaceStudyShip/SpaceStudyShip-BE/issues/25) +> **브랜치**: `20260422_#25_타이머_세션_도메인_구현` +> **버전**: version.yml `0.0.38` → `0.0.39` +> **마이그레이션**: `V0_0_39__add_timer_sessions.sql` +> **API 스펙**: [docs/api-specs/03_timer.md](../../api-specs/03_timer.md) +> **연관 도메인**: Fuel (#26 완료), Todo (#24 완료) + +--- + +## 1. 개요와 범위 + +API 스펙의 3개 엔드포인트(`POST/GET /api/timer-sessions`, `GET /api/timer-sessions/today-stats`)를 구현한다. 동기화 전략은 **Tier 2 (Server-Validated)** — 시간 유효성을 서버가 검증하고, 검증 통과 시 같은 트랜잭션에서 연료 충전과 Todo `actualMinutes` 누적까지 처리한 뒤 확정값을 반환한다. + +### 범위 내 +- `TimerSession` Entity / Repository / Service (SS-Study `study/timer/` 패키지) +- `TimerSessionController` (SS-Web) +- 3개 엔드포인트 + - `POST /api/timer-sessions` — 세션 저장 + 시간 검증 + Fuel 충전 + Todo actualMinutes 누적 (단일 트랜잭션) + - `GET /api/timer-sessions` — 날짜 범위/todoId 필터 + 페이지네이션 + - `GET /api/timer-sessions/today-stats` — 오늘 총 분/세션 수/연속 일수(streak), KST 기준 +- 헤더 `Idempotency-Key` 옵션 지원 — 모바일 재시도 안전성 확보 +- `TodoService.addActualMinutes()` 신규 메서드 — atomic UPDATE로 누적 (lost update 방지) +- ErrorCode 5개 추가 (`INVALID_SESSION_TIME`, `INVALID_DURATION`, `SESSION_TOO_SHORT`, `SESSION_TOO_LONG`, `FUTURE_SESSION`) +- Flyway 마이그레이션 + version.yml bump +- Swagger 풀세트 (Todo/Fuel 컨트롤러 수준) +- 단위/통합 테스트 + +### 범위 외 +- 세션 수정/삭제 API (스펙에 없음) +- 일시정지/재개 등 진행 중 상태의 서버 추적 (클라이언트 책임) +- streak 사전 계산 캐시 (YAGNI — 매 호출 distinct 쿼리, 최근 365일 상한) +- 글로벌 timezone 지원 (KST 고정, 상수 1곳에 집중) +- Fuel 충전량 보너스 정책 (1분 = 1연료 단순 환율 유지) + +--- + +## 2. 모듈/패키지 구조 + +기존 `SS-Study` 모듈에 `timer/` 패키지로 배치. Fuel 도메인과 동형 구조. + +```text +SS-Study/src/main/java/com/elipair/spacestudyship/study/ +└── timer/ + ├── dto/ + │ ├── TimerSessionCreateRequest.java + │ ├── TimerSessionCreateResponse.java ← { session, fuelCharged } + │ ├── TimerSessionResponse.java + │ ├── TimerSessionListResponse.java + │ └── TodayStatsResponse.java + ├── entity/ + │ └── TimerSession.java + ├── repository/ + │ └── TimerSessionRepository.java + └── service/ + └── TimerSessionService.java + +SS-Study/src/main/java/com/elipair/spacestudyship/study/todo/ +├── repository/TodoRepository.java ← @Modifying addActualMinutes 쿼리 추가 +└── service/TodoService.java ← addActualMinutes(userId, todoId, minutes) 메서드 추가 + +SS-Web/src/main/java/com/elipair/spacestudyship/controller/timer/ +└── TimerSessionController.java ← POST/GET/GET today-stats + +SS-Web/src/main/resources/db/migration/ +└── V0_0_39__add_timer_sessions.sql + +SS-Common/src/main/java/com/elipair/spacestudyship/common/exception/ +└── ErrorCode.java ← 5개 추가 +``` + +### 2.1 의존성 흐름 +- SS-Web → SS-Study (기존) +- SS-Study/timer → SS-Study/fuel (`FuelService`), SS-Study/todo (`TodoService`) — 같은 모듈 내 직접 호출 +- SS-Study → SS-Common, SS-Member (기존) + +### 2.2 도메인 결합 결정 +`POST /api/timer-sessions`는 한 번의 요청에 3개 도메인이 쓰기를 한다: +1. `timer_sessions` INSERT +2. `user_fuel` UPDATE + `fuel_transactions` INSERT +3. `todos.actual_minutes` UPDATE (todoId 있을 때만) + +**선택: 직접 호출 + 단일 트랜잭션** +- TimerSessionService가 FuelService.charge() / TodoService.addActualMinutes()를 같은 `@Transactional` 안에서 직접 호출 +- 어느 단계든 실패 시 전체 롤백 (Tier 2 server-validated의 일관성 요구) +- 이벤트 분리는 회원가입→연료초기화처럼 fire-and-forget 성격에만 적합. 타이머 저장은 사용자에게 확정값을 즉시 반환해야 함 + +### 2.3 핵심 idempotency 트릭 +`sessionId`(서버 UUID)를 그대로 `FuelService.charge`의 `transactionId`로 전달. +- 같은 sessionId로 fuel.charge가 두 번 호출돼도 Fuel의 기존 idempotency 로직이 1회만 충전 +- timer 세션은 `Idempotency-Key` 헤더 기반 dedup (별도 unique 제약) +- 두 계층이 직교하여 모든 재시도 경로에서 중복 충전 방지 + +--- + +## 3. Entity 설계 + +### 3.1 `TimerSession` + +```java +@Entity +@Table(name = "timer_sessions", + indexes = { + @Index(name = "idx_timer_sessions_user_started", columnList = "user_id, started_at DESC"), + @Index(name = "idx_timer_sessions_user_todo", columnList = "user_id, todo_id") + }) +@Checks({ + @Check(name = "chk_timer_duration_positive", constraints = "duration_minutes > 0"), + @Check(name = "chk_timer_duration_max", constraints = "duration_minutes <= 1440"), + @Check(name = "chk_timer_time_order", constraints = "ended_at > started_at") +}) +@Getter +@Builder +@AllArgsConstructor +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class TimerSession extends BaseTimeEntity { + + /** + * 서버 생성 UUID. Fuel transactionId로 재사용되어 충전 idempotency를 보장한다. + */ + @Id + @Column(length = 36) + private String id; + + @Column(name = "user_id", nullable = false) + private Long userId; + + /** nullable — Todo 없이 타이머만 사용 가능 (스펙) */ + @Column(name = "todo_id", length = 36) + private String todoId; + + /** nullable — Todo 삭제 후에도 표시 가능하도록 저장 시점의 스냅샷 */ + @Column(name = "todo_title", length = 100) + private String todoTitle; + + /** + * UTC LocalDateTime. 모든 타임스탬프는 UTC 기준 저장 (스펙 00_common.md). + * 서비스 진입 시점에 Instant → LocalDateTime (UTC) 변환. + */ + @Column(name = "started_at", nullable = false) + private LocalDateTime startedAt; + + @Column(name = "ended_at", nullable = false) + private LocalDateTime endedAt; + + @Column(name = "duration_minutes", nullable = false) + private Integer durationMinutes; + + /** + * Idempotency-Key 헤더 값. (user_id, idempotency_key) 부분 unique 인덱스. + * null이면 매번 신규 세션으로 취급. + */ + @Column(name = "idempotency_key", length = 80) + private String idempotencyKey; + + public static TimerSession of(String id, Long userId, String todoId, String todoTitle, + LocalDateTime startedAt, LocalDateTime endedAt, + int durationMinutes, String idempotencyKey) { + return TimerSession.builder() + .id(id).userId(userId).todoId(todoId).todoTitle(todoTitle) + .startedAt(startedAt).endedAt(endedAt) + .durationMinutes(durationMinutes) + .idempotencyKey(idempotencyKey) + .build(); + } +} +``` + +### 3.2 설계 포인트 +- `BaseTimeEntity` 상속 → `created_at`, `updated_at` 자동 +- `todoId`는 **DB FK 안 검** — Todo 삭제 후에도 세션 기록 유지 (스펙 의도). 서비스 레이어에서만 본인 todo 소유권 검증 +- DB CHECK 제약으로 무결성 1차 방어 (서비스 검증과 이중 방어) +- 인덱스 2개 + - `(user_id, started_at DESC)` — 목록 조회 / today-stats / streak 쿼리 핵심 + - `(user_id, todo_id)` — todoId 필터 + 후속 Todo 도메인 통계 확장 대비 + +--- + +## 4. 마이그레이션 — `V0_0_39__add_timer_sessions.sql` + +```sql +CREATE TABLE IF NOT EXISTS timer_sessions ( + id VARCHAR(36) PRIMARY KEY, + user_id BIGINT NOT NULL, + todo_id VARCHAR(36), + todo_title VARCHAR(100), + started_at TIMESTAMP NOT NULL, + ended_at TIMESTAMP NOT NULL, + duration_minutes INTEGER NOT NULL, + idempotency_key VARCHAR(80), + created_at TIMESTAMP NOT NULL, + updated_at TIMESTAMP NOT NULL, + CONSTRAINT fk_timer_sessions_member FOREIGN KEY (user_id) + REFERENCES members(id) ON DELETE CASCADE, + CONSTRAINT chk_timer_duration_positive CHECK (duration_minutes > 0), + CONSTRAINT chk_timer_duration_max CHECK (duration_minutes <= 1440), + CONSTRAINT chk_timer_time_order CHECK (ended_at > started_at) +); + +CREATE INDEX IF NOT EXISTS idx_timer_sessions_user_started + ON timer_sessions (user_id, started_at DESC); + +CREATE INDEX IF NOT EXISTS idx_timer_sessions_user_todo + ON timer_sessions (user_id, todo_id); + +-- Idempotency: 동일 (user, key) 중복 INSERT 방지. key=NULL은 다중 허용 (부분 unique 인덱스) +CREATE UNIQUE INDEX IF NOT EXISTS uq_timer_sessions_user_idem + ON timer_sessions (user_id, idempotency_key) + WHERE idempotency_key IS NOT NULL; +``` + +- `FK ON DELETE CASCADE` — 회원 탈퇴 시 세션도 함께 삭제 (Fuel과 동일 정책) +- PostgreSQL의 **부분 unique 인덱스**(`WHERE` 절)로 `idempotency_key=NULL` 케이스를 unique 제약에서 제외 + +--- + +## 5. DTO + +모두 `Record`로 구현. 시간 입출력은 `Instant`(ISO 8601 UTC)로 명확화. + +```java +public record TimerSessionCreateRequest( + @Size(max = 36) String todoId, + @Size(max = 100) String todoTitle, + @NotNull Instant startedAt, + @NotNull Instant endedAt, + @NotNull Integer durationMinutes +) {} + +public record TimerSessionResponse( + String id, + String todoId, + String todoTitle, + Instant startedAt, + Instant endedAt, + Integer durationMinutes +) { + public static TimerSessionResponse from(TimerSession s) { + return new TimerSessionResponse( + s.getId(), s.getTodoId(), s.getTodoTitle(), + s.getStartedAt().atOffset(ZoneOffset.UTC).toInstant(), + s.getEndedAt().atOffset(ZoneOffset.UTC).toInstant(), + s.getDurationMinutes()); + } +} + +public record TimerSessionCreateResponse( + TimerSessionResponse session, + Integer fuelCharged +) {} + +public record TimerSessionListResponse( + List content, + Integer page, + Integer size, + Long totalElements, + Integer totalPages +) { + public static TimerSessionListResponse from(Page page) { + return new TimerSessionListResponse( + page.getContent().stream().map(TimerSessionResponse::from).toList(), + page.getNumber(), page.getSize(), + page.getTotalElements(), page.getTotalPages()); + } +} + +public record TodayStatsResponse( + Integer totalMinutes, + Integer sessionCount, + Integer streak +) {} +``` + +### 5.1 검증 어노테이션 정책 +- **Bean Validation은 형식 검증만** (`@NotNull`, `@Size`) +- **값 범위 검증은 서비스에서** — `@Min/@Max`를 쓰면 위반 시 모두 `INVALID_INPUT_VALUE`로 동일 처리되어 스펙의 `SESSION_TOO_SHORT`/`SESSION_TOO_LONG`/`INVALID_DURATION` 코드에 도달 불가능 +- 결과: DTO에는 `durationMinutes`에 `@NotNull`만 부여하고, 1~1440 범위 검증은 서비스의 `validate()`가 담당 + +### 5.2 시간 타입 결정 +- DTO: `Instant` — zone 정보 없는 `LocalDateTime`과 달리 절대 시점 명시. Jackson이 `2026-04-16T09:00:00Z`를 자연스럽게 매핑 +- Entity: `LocalDateTime` (UTC 약속) — 기존 BaseTimeEntity 컨벤션과 일치. Javadoc에 "UTC" 명시 +- 변환은 서비스 진입/이탈 경계에서 1회 + +--- + +## 6. Service 로직 + +### 6.1 `TimerSessionService` 골격 + +```java +@Slf4j +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class TimerSessionService { + + private static final ZoneId ZONE_KST = ZoneId.of("Asia/Seoul"); + private static final long CLOCK_SKEW_TOLERANCE_SECONDS = 300; // 5분 + private static final int STREAK_LOOKBACK_DAYS = 365; + + private final TimerSessionRepository sessionRepository; + private final FuelService fuelService; + private final TodoService todoService; + private final Clock clock; // BeanConfig에서 Clock.systemUTC() 빈 등록 + + // ---------- POST ---------- + + @Transactional + public TimerSessionCreateResponse create( + Long userId, TimerSessionCreateRequest request, String idempotencyKey) { + + String normalizedKey = (idempotencyKey == null || idempotencyKey.isBlank()) + ? null : idempotencyKey.trim(); + + // 1. 조기 dedup + if (normalizedKey != null) { + Optional existing = sessionRepository + .findByUserIdAndIdempotencyKey(userId, normalizedKey); + if (existing.isPresent()) { + log.info("[Timer] idempotent skip | userId={}, key={}, sessionId={}", + userId, normalizedKey, existing.get().getId()); + return buildResponse(existing.get(), existing.get().getDurationMinutes()); + } + } + + // 2. 시간 검증 (5단계, 명시적 ErrorCode) + LocalDateTime startedAtUtc = LocalDateTime.ofInstant(request.startedAt(), ZoneOffset.UTC); + LocalDateTime endedAtUtc = LocalDateTime.ofInstant(request.endedAt(), ZoneOffset.UTC); + validate(startedAtUtc, endedAtUtc, request.durationMinutes()); + + // 3. 세션 저장 — Idempotency-Key race는 catch + 재조회로 흡수 + String sessionId = UUID.randomUUID().toString(); + TimerSession session = TimerSession.of( + sessionId, userId, + request.todoId(), request.todoTitle(), + startedAtUtc, endedAtUtc, request.durationMinutes(), + normalizedKey); + try { + sessionRepository.save(session); + } catch (DataIntegrityViolationException e) { + if (normalizedKey != null) { + Optional raced = sessionRepository + .findByUserIdAndIdempotencyKey(userId, normalizedKey); + if (raced.isPresent()) { + log.info("[Timer] idempotent race resolved | userId={}, key={}", userId, normalizedKey); + return buildResponse(raced.get(), raced.get().getDurationMinutes()); + } + } + throw e; + } + + // 4. Fuel 충전 (sessionId == transactionId → fuel-side idempotency) + int fuelCharged = request.durationMinutes(); + fuelService.charge( + userId, fuelCharged, FuelReason.STUDY_SESSION, + sessionId, sessionId); + + // 5. Todo actualMinutes 누적 (todoId 있을 때만, atomic UPDATE) + if (request.todoId() != null) { + todoService.addActualMinutes(userId, request.todoId(), fuelCharged); + } + + log.info("[Timer] 세션 저장 | userId={}, sessionId={}, duration={}분, todoId={}", + userId, sessionId, fuelCharged, request.todoId()); + return buildResponse(session, fuelCharged); + } + + private void validate(LocalDateTime startedAt, LocalDateTime endedAt, int durationMinutes) { + if (!endedAt.isAfter(startedAt)) { + throw new CustomException(ErrorCode.INVALID_SESSION_TIME); + } + // 분 단위 절삭 기준. 예: 5분 30초 경과 + duration 6분 → INVALID_DURATION + long elapsedMinutes = Duration.between(startedAt, endedAt).toMinutes(); + if (durationMinutes > elapsedMinutes) { + throw new CustomException(ErrorCode.INVALID_DURATION); + } + if (durationMinutes < 1) { + throw new CustomException(ErrorCode.SESSION_TOO_SHORT); + } + if (durationMinutes > 1440) { + throw new CustomException(ErrorCode.SESSION_TOO_LONG); + } + LocalDateTime now = LocalDateTime.now(clock); + if (startedAt.isAfter(now.plusSeconds(CLOCK_SKEW_TOLERANCE_SECONDS))) { + throw new CustomException(ErrorCode.FUTURE_SESSION); + } + } + + private TimerSessionCreateResponse buildResponse(TimerSession session, int fuelCharged) { + return new TimerSessionCreateResponse(TimerSessionResponse.from(session), fuelCharged); + } + + // ---------- GET 목록 ---------- + + public TimerSessionListResponse getList( + Long userId, String startDate, String endDate, String todoId, + int page, int size) { + + LocalDateTime start = startDate == null ? null + : LocalDate.parse(startDate).atStartOfDay(); + LocalDateTime end = endDate == null ? null + : LocalDate.parse(endDate).plusDays(1).atStartOfDay(); + + Pageable pageable = PageRequest.of(page, size, + Sort.by(Sort.Direction.DESC, "startedAt")); + Page result = sessionRepository.findByFilters( + userId, start, end, todoId, pageable); + return TimerSessionListResponse.from(result); + } + + // ---------- GET today-stats ---------- + + public TodayStatsResponse getTodayStats(Long userId) { + LocalDate todayKst = LocalDate.now(clock.withZone(ZONE_KST)); + LocalDateTime todayStartUtc = toUtcLdt(todayKst.atStartOfDay(ZONE_KST)); + LocalDateTime tomorrowStartUtc = toUtcLdt(todayKst.plusDays(1).atStartOfDay(ZONE_KST)); + + Integer totalMinutes = Optional.ofNullable( + sessionRepository.sumDurationBetween(userId, todayStartUtc, tomorrowStartUtc)) + .orElse(0); + long sessionCount = sessionRepository + .countByUserIdAndStartedAtGreaterThanEqualAndStartedAtLessThan( + userId, todayStartUtc, tomorrowStartUtc); + + LocalDateTime lookbackStart = toUtcLdt( + todayKst.minusDays(STREAK_LOOKBACK_DAYS).atStartOfDay(ZONE_KST)); + List startedAts = sessionRepository + .findStartedAtsAfter(userId, lookbackStart); + + int streak = computeStreak(startedAts, todayKst); + return new TodayStatsResponse(totalMinutes, (int) sessionCount, streak); + } + + private LocalDateTime toUtcLdt(ZonedDateTime kst) { + return kst.withZoneSameInstant(ZoneOffset.UTC).toLocalDateTime(); + } + + private int computeStreak(List startedAtsUtc, LocalDate todayKst) { + TreeSet studyDays = startedAtsUtc.stream() + .map(ts -> ts.atZone(ZoneOffset.UTC).withZoneSameInstant(ZONE_KST).toLocalDate()) + .collect(Collectors.toCollection(TreeSet::new)); + if (studyDays.isEmpty()) return 0; + + LocalDate latest = studyDays.last(); + // clock skew 허용으로 latest가 미래일 수 있음 → 오늘로 클램프 + LocalDate cursor = latest.isAfter(todayKst) ? todayKst : latest; + // 오늘과 어제 모두 없으면 streak = 0 + if (cursor.isBefore(todayKst.minusDays(1))) return 0; + + int streak = 0; + while (studyDays.contains(cursor)) { + streak++; + cursor = cursor.minusDays(1); + } + return streak; + } +} +``` + +### 6.2 검증 5단계 (스펙 매핑) + +| 순서 | 검증 | ErrorCode | +|------|------|-----------| +| 1 | `endedAt > startedAt` | `INVALID_SESSION_TIME` | +| 2 | `durationMinutes <= floor((endedAt - startedAt) / 1분)` | `INVALID_DURATION` | +| 3 | `durationMinutes >= 1` | `SESSION_TOO_SHORT` | +| 4 | `durationMinutes <= 1440` | `SESSION_TOO_LONG` | +| 5 | `startedAt <= now + 5분` | `FUTURE_SESSION` | + +- 분 단위 절삭은 의도된 동작 (보수적 검증). 클라이언트는 정상적으로 사용 시 영향받지 않음. +- `CLOCK_SKEW_TOLERANCE_SECONDS = 300` (5분) — 모바일 시계 오차 흔하므로 여유 + +### 6.3 Streak 계산 정책 +- **KST(Asia/Seoul) 고정** — 한국 서비스 가정. 글로벌 확장 시 `ZONE_KST` 상수 1곳만 변경 +- 최근 365일치 distinct 공부 날짜만 조회하여 메모리에서 계산 — DB 부담 상한 +- "오늘 안 했어도 어제까지 streak 유지" 조건 = `cursor >= today-1`이면 카운팅 시작 (스펙 §3) +- 미래 날짜 클램프 — clock skew 허용으로 발생할 수 있는 streak 부풀림 방지 + +--- + +## 7. Todo 도메인 보강 + +`Todo.actualMinutes`는 기존에 `updateActualMinutes(Integer)` (덮어쓰기형)만 존재. 누적 업데이트를 안전하게 처리할 메서드를 추가한다. + +### 7.1 `TodoRepository` 변경 + +```java +@Modifying +@Query("UPDATE Todo t SET t.actualMinutes = COALESCE(t.actualMinutes, 0) + :minutes " + + "WHERE t.id = :todoId AND t.userId = :userId") +int addActualMinutes(@Param("userId") Long userId, + @Param("todoId") String todoId, + @Param("minutes") int minutes); +``` + +### 7.2 `TodoService` 변경 + +```java +@Transactional +public void addActualMinutes(Long userId, String todoId, int minutes) { + if (minutes <= 0) { + throw new CustomException(ErrorCode.INVALID_INPUT_VALUE); + } + int updated = todoRepository.addActualMinutes(userId, todoId, minutes); + if (updated == 0) { + throw new CustomException(ErrorCode.TODO_NOT_FOUND); + } +} +``` + +### 7.3 변경 근거 +- **lost update 방지**: dirty checking 기반 read-modify-write는 동시 두 세션 저장 시 한쪽이 사라짐. atomic SQL UPDATE는 DB 레벨에서 직렬화 +- **소유권 검증 통합**: `WHERE t.userId = :userId` 조건으로 본인 todo만 갱신. affected rows == 0 이면 `TODO_NOT_FOUND` (없거나 남의 것) +- **DB 호출 1회**: 별도 `requireOwned()` 메서드 불필요 — 단순화 + +--- + +## 8. Controller + +```java +@Tag(name = "Timer", description = "공부 타이머 세션 API") +@RestController +@RequiredArgsConstructor +@RequestMapping("/api/timer-sessions") +public class TimerSessionController { + + private final TimerSessionService timerSessionService; + + @Operation(summary = "세션 기록 저장", description = """ + 타이머 종료 시 세션을 저장합니다. + 서버에서 시간 유효성 5단계 검증 후, 통과 시 연료를 자동 충전하고 + 연결된 Todo의 actualMinutes를 누적합니다. (단일 트랜잭션) + + ### Idempotency + 헤더 `Idempotency-Key`를 보내면 동일 키 재요청 시 기존 세션을 반환합니다 (중복 충전 방지). + """) + @PostMapping + public ResponseEntity create( + @AuthMember LoginMember loginMember, + @Valid @RequestBody TimerSessionCreateRequest request, + @RequestHeader(value = "Idempotency-Key", required = false) String idempotencyKey) { + + TimerSessionCreateResponse response = timerSessionService.create( + loginMember.memberId(), request, idempotencyKey); + return ResponseEntity.status(HttpStatus.CREATED).body(response); + } + + @Operation(summary = "세션 목록 조회") + @GetMapping + public ResponseEntity getList( + @AuthMember LoginMember loginMember, + @RequestParam(required = false) String startDate, + @RequestParam(required = false) String endDate, + @RequestParam(required = false) String todoId, + @RequestParam(defaultValue = "0") Integer page, + @RequestParam(defaultValue = "20") Integer size) { + + validateDateParam(startDate); + validateDateParam(endDate); + if (page < 0) throw new CustomException(ErrorCode.INVALID_INPUT_VALUE); + if (size < 1 || size > 100) throw new CustomException(ErrorCode.INVALID_INPUT_VALUE); + + return ResponseEntity.ok(timerSessionService.getList( + loginMember.memberId(), startDate, endDate, todoId, page, size)); + } + + @Operation(summary = "오늘 공부 통계", description = "KST 기준 오늘의 총 분/세션 수/연속 일수") + @GetMapping("/today-stats") + public ResponseEntity getTodayStats( + @AuthMember LoginMember loginMember) { + return ResponseEntity.ok( + timerSessionService.getTodayStats(loginMember.memberId())); + } + + private void validateDateParam(String date) { + if (date == null) return; + try { LocalDate.parse(date); } + catch (DateTimeParseException e) { + throw new CustomException(ErrorCode.INVALID_INPUT_VALUE); + } + } +} +``` + +### 8.1 Swagger 정책 +Fuel/Todo 컨트롤러와 동일 수준의 풀세트: +- `@Operation` description (마크다운, query/header 설명 포함) +- `@ApiResponses` 200/201/400/401/500 각 케이스 + `@ExampleObject` 샘플 본문 +- 에러는 `ErrorResponse` schema 참조 + +--- + +## 9. Repository + +```java +public interface TimerSessionRepository extends JpaRepository { + + Optional findByUserIdAndIdempotencyKey(Long userId, String idempotencyKey); + + @Query(""" + SELECT s FROM TimerSession s + WHERE s.userId = :userId + AND (:start IS NULL OR s.startedAt >= :start) + AND (:end IS NULL OR s.startedAt < :end) + AND (:todoId IS NULL OR s.todoId = :todoId) + """) + Page findByFilters( + @Param("userId") Long userId, + @Param("start") LocalDateTime start, + @Param("end") LocalDateTime end, + @Param("todoId") String todoId, + Pageable pageable); + + @Query("SELECT COALESCE(SUM(s.durationMinutes), 0) FROM TimerSession s " + + "WHERE s.userId = :userId AND s.startedAt >= :start AND s.startedAt < :end") + Integer sumDurationBetween(@Param("userId") Long userId, + @Param("start") LocalDateTime start, + @Param("end") LocalDateTime end); + + long countByUserIdAndStartedAtGreaterThanEqualAndStartedAtLessThan( + Long userId, LocalDateTime start, LocalDateTime end); + + @Query("SELECT s.startedAt FROM TimerSession s " + + "WHERE s.userId = :userId AND s.startedAt >= :start") + List findStartedAtsAfter(@Param("userId") Long userId, + @Param("start") LocalDateTime start); +} +``` + +- 종료일은 Fuel과 동일 반열림 `[start, end+1)` 정책 +- 인덱스 `(user_id, started_at DESC)`가 모든 쿼리에 활용됨 + +--- + +## 10. ErrorCode 추가 + +`SS-Common/.../ErrorCode.java`에 5개 추가: + +```java +// Timer +INVALID_SESSION_TIME(HttpStatus.BAD_REQUEST, "시작 시각이 종료 시각보다 늦거나 같습니다."), +INVALID_DURATION(HttpStatus.BAD_REQUEST, "공부 시간이 시작/종료 시각 간격보다 큽니다."), +SESSION_TOO_SHORT(HttpStatus.BAD_REQUEST, "공부 시간은 1분 이상이어야 합니다."), +SESSION_TOO_LONG(HttpStatus.BAD_REQUEST, "공부 시간은 24시간(1440분)을 초과할 수 없습니다."), +FUTURE_SESSION(HttpStatus.BAD_REQUEST, "미래 시각의 세션은 저장할 수 없습니다."), +``` + +### 10.1 전체 에러 매트릭스 + +| 상황 | HTTP | code | +|------|------|------| +| 토큰 없음/만료 | 401 | `UNAUTHENTICATED_REQUEST` / `ACCESS_TOKEN_EXPIRED` | +| `@NotNull` / `@Size` 위반 | 400 | `INVALID_INPUT_VALUE` | +| 본문 파싱 실패 | 400 | `INVALID_REQUEST_BODY` | +| `startedAt >= endedAt` | 400 | `INVALID_SESSION_TIME` | +| `durationMinutes > 경과시간` | 400 | `INVALID_DURATION` | +| `durationMinutes < 1` | 400 | `SESSION_TOO_SHORT` | +| `durationMinutes > 1440` | 400 | `SESSION_TOO_LONG` | +| `startedAt > now + 5분` | 400 | `FUTURE_SESSION` | +| todoId 본인 소유 아님 / 없음 | 404 | `TODO_NOT_FOUND` | +| 잘못된 date/page/size 파라미터 | 400 | `INVALID_INPUT_VALUE` | +| Fuel 미초기화 (이론상 X) | 500 | `FUEL_NOT_INITIALIZED` | + +--- + +## 11. 빈 설정 + +### 11.1 `Clock` 빈 등록 +`SS-Web/.../config/BeanConfig.java` (없으면 신규): + +```java +@Configuration +public class BeanConfig { + @Bean + public Clock clock() { + return Clock.systemUTC(); + } +} +``` + +- 서비스의 `now()`와 KST 변환 모두 이 빈을 통과 +- 테스트는 `@TestConfiguration`에서 `Clock.fixed(...)` 주입으로 결정적 검증 + +--- + +## 12. 데이터 플로우 + +### 12.1 정상 흐름 (POST) + +``` +Client + ↓ Authorization: Bearer ... + ↓ Idempotency-Key: (선택) + ↓ Body: { todoId, todoTitle, startedAt, endedAt, durationMinutes } +TimerSessionController.create() + ↓ @Valid (NotNull/Size만) +TimerSessionService.create() [Tx 시작] + ├─ 1. idempotencyKey 정규화 (blank → null) + ├─ 2. 조기 dedup 조회 (있으면 즉시 응답) + ├─ 3. Instant → LocalDateTime UTC 변환 + ├─ 4. validate() — 5단계 명시적 ErrorCode + ├─ 5. sessionId = UUID + ├─ 6. try sessionRepository.save(session) + │ catch DataIntegrityViolation → 재조회 dedup or rethrow + ├─ 7. fuelService.charge(amount=duration, txId=sessionId) + │ └─ user_fuel SELECT FOR UPDATE + 충전 + fuel_transactions INSERT + │ └─ (이미 존재하면 idempotent return) + ├─ 8. if (todoId != null) + │ todoService.addActualMinutes(userId, todoId, amount) + │ └─ atomic UPDATE, affected==0 → TODO_NOT_FOUND + └─ 9. buildResponse(session, fuelCharged) [Tx 커밋] +``` + +### 12.2 실패 시 동작 +- 어느 단계든 예외 발생 → 전체 트랜잭션 롤백 → 세션/충전/누적 모두 없던 일 +- 사용자가 같은 `Idempotency-Key`로 재시도 → 새로 처리 (이전 시도가 롤백되었으므로 신규) +- 같은 `Idempotency-Key`로 재시도했는데 이전 시도가 성공이었다면 → 2번 단계에서 dedup 반환 + +--- + +## 13. 테스트 전략 + +### 13.1 SS-Study 단위/통합 + +**`TimerSessionTest`** — Entity static factory 동작 + +**`TimerSessionRepositoryTest`** — `TestApplication + @ImportAutoConfiguration` 패턴 (Spring Boot 4 제약 우회, 기존 Fuel/Todo Repository 테스트와 동일) +- `findByFilters` null/실값 매트릭스 (start/end/todoId 각각) +- `sumDurationBetween` 빈 결과 → 0, 정상 합산 +- `findByUserIdAndIdempotencyKey` 존재/부재 +- 부분 unique 인덱스: 같은 (user, key) 중복 INSERT → 실패, key=NULL 중복 → 허용 +- `findStartedAtsAfter` 정렬/필터링 + +**`TimerSessionServiceTest`** (Mockito + `Clock.fixed`) +- 검증 5케이스 각각 (`@ParameterizedTest` 권장) +- 정상 저장 → `fuelService.charge` 호출 검증 (sessionId == transactionId 인자 매칭) +- todoId 있을 때 → `todoService.addActualMinutes` 호출 +- todoId 없을 때 → todo 관련 호출 0회 +- Idempotency-Key 재요청 → 기존 세션 반환, fuel/todo 호출 0회 +- Idempotency-Key blank/null → null로 정규화 +- Idempotency race 시뮬레이션 (save 시 DataIntegrityViolation → 재조회) → 기존 세션 반환 +- Clock skew 경계값 (5분 ±1초) +- Streak 케이스 매트릭스: + - 빈 데이터 → 0 + - 오늘만 1세션 → 1 + - 오늘 + 어제 → 2 + - 오늘 없고 어제까지 N일 연속 → N + - 오늘도 어제도 없음 → 0 + - 중간 단절 (오늘, 어제, 그저께 빈, 그 전 연속) → 2 + - latest가 미래 (clock skew) → 클램프 동작 확인 + - 365일 경계 — lookback 상한 안에서만 카운팅 + +**`TodoServiceTest`** 추가 케이스 +- `addActualMinutes` — 신규 todo (null → 0+minutes) +- 기존 todo (기존+minutes) +- 본인 소유 아님 → `TODO_NOT_FOUND` +- 존재하지 않는 todoId → `TODO_NOT_FOUND` +- minutes <= 0 → `INVALID_INPUT_VALUE` + +### 13.2 SS-Web Controller (MockMvc) + +**`TimerSessionControllerTest`** +- POST 201 정상 +- POST 400 — 각 ErrorCode별 (총 5개 + INVALID_INPUT_VALUE + INVALID_REQUEST_BODY) +- POST 404 — 없는 todoId +- POST 401 — 인증 없음 +- POST `Idempotency-Key` 유/무 비교 (서비스 인자 검증) +- GET 200 — 페이지네이션, 필터 조합 +- GET 400 — 잘못된 query (date 포맷, page<0, size>100) +- GET `/today-stats` 200 정상 + +### 13.3 테스트용 Clock 주입 +`@TestConfiguration`에서 `Clock.fixed(Instant.parse("2026-05-25T03:00:00Z"), ZoneOffset.UTC)` 빈 등록 → 모든 시간 의존 검증을 결정적으로 처리 + +--- + +## 14. 작업 순서 (커밋 단위 가이드) + +각 커밋 메시지: `타이머 세션 도메인 구현 : {type} : {설명} #25` + +``` +1. chore : version.yml 0.0.38 → 0.0.39 + V0_0_39__add_timer_sessions.sql + ErrorCode 5개 추가 + Clock 빈 등록 (BeanConfig) +2. feat : TimerSession Entity + TimerSessionRepository + 통합 테스트 +3. feat : DTO + TimerSessionService (검증/저장/목록/통계/streak) + 단위 테스트 +4. feat : TodoRepository.addActualMinutes + TodoService.addActualMinutes + 회귀 테스트 +5. feat : TimerSessionController + Swagger + Controller 테스트 +6. docs : CLAUDE.md 마이그레이션 이력 표에 0.0.39 추가 +``` + +--- + +## 15. 위험 요소 및 완화 + +| 위험 | 영향 | 완화 | +|------|------|------| +| Fuel 충전 실패 → 세션 롤백 후 사용자 재전송 | 충전 누락 / 중복 | sessionId 재사용 + Fuel-side idempotency + Idempotency-Key 헤더 두 계층 | +| 외부 조작 todoId (남의 Todo) | 다른 사용자 actualMinutes 오염 | atomic UPDATE의 `WHERE userId = :userId` 조건 → affected==0 → TODO_NOT_FOUND | +| Idempotency-Key 동시 race | 일시적 500 | DataIntegrityViolation catch → 재조회 패턴 | +| 같은 todoId 동시 두 세션 → actualMinutes lost update | 누적 누락 | atomic SQL UPDATE (`COALESCE + :minutes`)로 DB 레벨 직렬화 | +| Streak 쿼리 비대화 | 1년 이상 사용자 쿼리 비용 증가 | `STREAK_LOOKBACK_DAYS = 365` 상한 | +| KST 고정 → 글로벌 확장 시 영향 | 재설계 필요 | `ZONE_KST` 상수 1곳에 집중, 추후 timezone 인자화 쉬움 | +| Fuel 미초기화 회원 (이론상 X) | 500 노출 | 기존 `MemberCreatedEvent` 보장 + `FUEL_NOT_INITIALIZED` 코드 유지 | +| Clock 빈 미주입 | 기동 실패 | `BeanConfig`에 `Clock.systemUTC()` 명시 등록 | +| Bean Validation으로 인한 ErrorCode 손실 | 스펙 위배 | DTO에 `@Min/@Max` 미사용, 서비스가 명시적 검증 | +| `LocalDateTime` zone 모호성 | 타임존 버그 | DTO는 `Instant`, Entity는 UTC LocalDateTime + Javadoc 명시 | + +--- + +## 16. Definition of Done + +- [ ] 3개 엔드포인트 모두 정상 동작 (Swagger 수동 검증) +- [ ] 검증 5케이스 모두 정확한 ErrorCode 반환 +- [ ] Fuel 잔량/거래내역에 `STUDY_SESSION` 정상 반영 +- [ ] `Todo.actualMinutes` 누적 (null → 0+분, 기존 → 기존+분), 같은 todoId 동시 2세션 시 누락 없음 +- [ ] `Idempotency-Key` 재요청 시 fuel/todo 변화 없음, 같은 세션 ID로 응답 +- [ ] today-stats: 빈 데이터, 단일 세션, 다세션, streak 0/1/N 케이스 통과 +- [ ] 잘못된 todoId(남의 것, 존재 안 함) → 404 +- [ ] 모든 단위/통합 테스트 통과 (`./gradlew test`) +- [ ] CLAUDE.md 마이그레이션 표 0.0.39 추가 +- [ ] version.yml 0.0.39 반영 + +--- + +## 부록 A. 응답 예시 + +### POST /api/timer-sessions (201) +```json +{ + "session": { + "id": "8f3c2b4d-1234-4abc-9def-0123456789ab", + "todoId": "todo-uuid-5678", + "todoTitle": "수학 문제 풀기", + "startedAt": "2026-05-25T00:00:00Z", + "endedAt": "2026-05-25T01:30:00Z", + "durationMinutes": 90 + }, + "fuelCharged": 90 +} +``` + +### GET /api/timer-sessions (200) +```json +{ + "content": [ + { + "id": "8f3c2b4d-...", + "todoId": "todo-uuid-5678", + "todoTitle": "수학 문제 풀기", + "startedAt": "2026-05-25T00:00:00Z", + "endedAt": "2026-05-25T01:30:00Z", + "durationMinutes": 90 + } + ], + "page": 0, + "size": 20, + "totalElements": 45, + "totalPages": 3 +} +``` + +### GET /api/timer-sessions/today-stats (200) +```json +{ + "totalMinutes": 180, + "sessionCount": 3, + "streak": 7 +} +``` diff --git a/docs/superpowers/specs/2026-05-25-todo-timestamps-design.md b/docs/superpowers/specs/2026-05-25-todo-timestamps-design.md new file mode 100644 index 0000000..b8dd6c7 --- /dev/null +++ b/docs/superpowers/specs/2026-05-25-todo-timestamps-design.md @@ -0,0 +1,335 @@ +# Todo / TodoCategory 응답의 createdAt·updatedAt null 반환 수정 — 설계 + +- **작성일:** 2026-05-25 (revised after implementer diagnostic) +- **트리거 이슈:** 프론트엔드(Flutter) `_TypeError: type 'Null' is not a subtype of type 'String' in type cast` — Todo 생성 직후 응답의 `createdAt`/`updatedAt`이 null로 내려와 클라이언트 파서가 죽음 +- **수정 범위:** Todo, TodoCategory 서비스 레이어 (entity 변경 없음) +- **수정 방식:** `TodoService`/`TodoCategoryService`의 create / update 경로에 `EntityManager.flush()` 호출 추가 → 응답 직전에 INSERT/UPDATE 강제 실행 → Hibernate `@CreationTimestamp`/`@UpdateTimestamp` 채워짐 + +--- + +## 1. 배경 및 진단 + +### 1.1 현상 + +``` +POST /api/todos +→ 200 OK +{ "id": "...", "title": "...", ..., "createdAt": null, "updatedAt": null } +``` + +OpenAPI 스펙(`docs/api-docs.json`의 `TodoResponse`)은 `createdAt`/`updatedAt`을 `string`(non-null)로 정의 → 구현이 명세를 위반. + +### 1.2 잘못 짚었던 원인들 (작업 지시서 + 초기 설계 모두 틀림) + +| 가설 | 실제 | +|------|------| +| JPA Auditing 미활성화 | `BaseTimeEntity`는 Hibernate `@CreationTimestamp`/`@UpdateTimestamp` 사용 (`@EnableJpaAuditing` 불필요) | +| DTO 매핑 누락 | `TodoResponse.from()`이 `todo.getCreatedAt()` 호출 — 정상. 단, `formatUtc()`가 null이면 null 반환 | +| save 대신 INSERT | 정상적으로 `repository.save()` 사용 | +| **assigned-ID + Persistable 미구현이 핵심 → persist 호출로 즉시 timestamp 채워짐** | **틀림** — Hibernate 7의 `@CreationTimestamp`는 `CurrentTimestampGeneration` (BeforeExecutionGenerator, source=VM)으로 구현되어 **flush 시점에** `EntityInsertAction` 실행 중 fire. `persist()` 호출 시점 아님. Persistable은 timestamp에 영향 없음 | + +### 1.3 진짜 원인 — Hibernate `@CreationTimestamp`의 flush-time 동작 + +`Todo` / `TodoCategory`의 `save()` 후 응답 시점에 entity의 `createdAt`/`updatedAt`이 null인 이유: + +1. `TodoService.create()`는 `@Transactional` 안에서 `repository.save(todo)` 호출 → 반환된 entity를 `TodoResponse.from(saved)`로 즉시 직렬화 +2. Hibernate 7의 `@CreationTimestamp`는 `BeforeExecutionGenerator(source=VM)`로 등록되어 있어, **flush 단계의 `EntityInsertAction.execute()` 안에서** `LocalDateTime.now()`를 entity 필드에 set +3. `save()` 자체는 영속화 컨텍스트에 등록만 하고 INSERT SQL은 실행하지 않음 (auto-flush가 일어나지 않는 한 트랜잭션 commit 전까지 지연) +4. 응답 직렬화 시점 ≤ flush 시점이면 → `entity.getCreatedAt() == null` → `TodoResponse`의 `formatUtc(null) → null` → JSON에 `"createdAt": null` + +### 1.4 보조 사실 — assigned-ID 패턴 + +`Todo`/`TodoCategory`는 클라이언트가 UUID를 직접 부여 (`@Id @Column(length=36) private String id`). 정상 동작하는 `UserDevice` 등은 `@GeneratedValue(IDENTITY)`. + +- IDENTITY: ID 생성을 위해 Hibernate가 즉시 INSERT 실행 → flush 즉시 발생 → `@CreationTimestamp` 즉시 채워짐 → 응답 시점 안전 +- **assigned ID**: ID가 이미 있어 즉시 INSERT 불필요 → flush까지 INSERT 지연 → 응답 시점에 null + +즉 assigned-ID는 `@GeneratedValue(IDENTITY)` 케이스가 운 좋게 "공짜로" 받던 즉시-flush 효과가 없음. + +### 1.5 update() 경로의 동일 결함 + +update도 같은 메커니즘: +- `findByIdAndUserId`로 가져온 managed entity는 기존 timestamp 보유 (DB row에 NOT NULL로 있음 → null은 아님) +- 필드 mutation 후 dirty checking → flush 시점에 UPDATE + `@UpdateTimestamp` 갱신 +- 응답을 flush 전에 만들면 → `updatedAt`이 **변경 전 값(stale)** 으로 내려감 +- 작업 지시서 6.2 검증("updatedAt이 갱신되어야 함") 위반 + +### 1.6 영향 범위 + +| Entity | ID 전략 | 결함 발생? | 응답에 시간 노출? | 본 설계 수정 대상? | +|--------|---------|-----------|------------------|-------------------| +| Todo | assigned String | 예 | 예 | **예** | +| TodoCategory | assigned String | 예 | 예 (`CategoryResponse`) | **예** | +| FuelTransaction | assigned String | 잠재 | 별도 확인 필요 | 아니오 (범위 외) | +| UserFuel | assigned Long(userId) | 잠재 | 별도 확인 필요 | 아니오 (범위 외) | +| UserDevice / Member | IDENTITY | 없음 | — | — | + +Fuel 도메인은 응답 노출 여부 별도 점검 후 후속 이슈로 처리. + +--- + +## 2. 결정 사항 + +### 2.1 해결 방식: 서비스 레이어에서 `EntityManager.flush()` 호출 + +대안 비교: + +| 옵션 | 채택? | 사유 | +|------|-------|------| +| **서비스에 EntityManager 주입 + create/update에 flush() 호출** | ✅ | 가장 단순. timestamp 동작이 명시적. entity / BaseTimeEntity 변경 없음. 영향 범위 최소. | +| `repository.saveAndFlush()` 사용 | ❌ | create에는 적합하나 update는 dirty entity를 `save()` 호출하는 게 어색 (이미 managed). 두 경로 일관성 떨어짐. | +| `Persistable` 구현 | ❌ | timestamp 문제와 무관 (Hibernate가 flush 시점에 채우므로). merge → persist 전환으로 SELECT 1회 회피하는 부가 효과는 있으나 본 결함과 분리된 이슈 | +| `BaseTimeEntity`에 `@PrePersist/@PreUpdate` 콜백 추가 (수동 시간 설정) | ❌ | 동작은 하지만 `@CreationTimestamp`/`@UpdateTimestamp`와 중복. BaseTimeEntity가 모든 자식 entity에 영향 → 회귀 위험. | +| 응답 acceptance 변경 (null 허용) | ❌ | 클라이언트가 non-null 가정. OpenAPI 스펙 위반. | + +### 2.2 수정 범위: Todo + TodoCategory 서비스만 + +작업 지시서가 명시한 범위. Fuel 도메인은 응답 노출 여부 별도 확인 후 후속 이슈로 처리. + +### 2.3 create / update 둘 다 보장 + +- create: 응답의 createdAt/updatedAt이 항상 채워지도록 +- update: 응답의 updatedAt이 갱신 반영되도록 + +두 경로 모두 mutation 직후 `entityManager.flush()` 호출. + +--- + +## 3. 변경 상세 + +### 3.1 변경 파일 목록 + +| 파일 | 변경 내용 | +|------|----------| +| `SS-Study/src/main/java/com/elipair/spacestudyship/study/todo/service/TodoService.java` | `EntityManager` 주입, `create()` save 직후 / `update()` mutation 직후에 `entityManager.flush()` 호출 | +| `SS-Study/src/main/java/com/elipair/spacestudyship/study/todo/service/TodoCategoryService.java` | 동일 | +| `SS-Study/src/test/java/com/elipair/spacestudyship/study/todo/service/TodoServiceTest.java` | EntityManager mock 추가 + create/update에서 flush() 호출 verify | +| `SS-Study/src/test/java/com/elipair/spacestudyship/study/todo/service/TodoCategoryServiceTest.java` | 동일 | +| `SS-Study/src/test/java/com/elipair/spacestudyship/study/todo/repository/TodoRepositoryTest.java` | (선택) `saveAndFlush` 후 createdAt not null 회귀 테스트 추가 — Hibernate의 flush-time timestamp 동작이 깨지면 알람 | +| `SS-Study/src/test/java/com/elipair/spacestudyship/study/todo/repository/TodoCategoryRepositoryTest.java` | 동일 (선택) | + +**미변경:** +- `Todo`, `TodoCategory` entity — 그대로 +- `BaseTimeEntity` — 그대로 +- `TodoResponse`, `CategoryResponse` — 그대로 +- DB 스키마 / Flyway 마이그레이션 — 그대로 +- `version.yml` — CI가 patch 자동 증가 처리 + +### 3.2 서비스 변경 패턴 (TodoService / TodoCategoryService 동일) + +```java +import jakarta.persistence.EntityManager; + +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class TodoService { + + private final TodoRepository todoRepository; + private final TodoCategoryRepository categoryRepository; + private final EntityManager entityManager; // ← 추가 + + @Transactional + public TodoResponse create(Long userId, TodoCreateRequest request) { + String id = request.id() != null ? request.id() : UUID.randomUUID().toString(); + if (todoRepository.existsById(id)) { + throw new CustomException(ErrorCode.TODO_ALREADY_EXISTS); + } + validateCategoryIds(userId, request.categoryIds()); + + Todo todo = Todo.create(...); + Todo saved = todoRepository.save(todo); + entityManager.flush(); // ← 추가: INSERT 실행 + @CreationTimestamp 채워짐 + log.info("[Todo] 생성 | userId={}, todoId={}", userId, saved.getId()); + return TodoResponse.from(saved); + } + + @Transactional + public TodoResponse update(Long userId, String todoId, TodoUpdateRequest request) { + Todo todo = todoRepository.findByIdAndUserId(todoId, userId) + .orElseThrow(() -> new CustomException(ErrorCode.TODO_NOT_FOUND)); + + // 기존 mutation 코드 유지 + if (request.title() != null) todo.updateTitle(request.title()); + // ... + + entityManager.flush(); // ← 추가: UPDATE 실행 + @UpdateTimestamp 갱신 + log.info("[Todo] 수정 | userId={}, todoId={}", userId, todoId); + return TodoResponse.from(todo); + } +} +``` + +`delete()`는 응답이 204 (body 없음)이므로 변경 불필요. + +--- + +## 4. 데이터 흐름 (수정 후) + +### 4.1 create + +``` +POST /api/todos +└─ TodoController.create() + └─ TodoService.create() [@Transactional] + ├─ Todo.create(...) // 새 entity, createdAt == null + ├─ todoRepository.save(todo) // 영속화 컨텍스트 등록 (INSERT 아직 안 실행) + ├─ entityManager.flush() // ← INSERT SQL 실행 + │ └─ Hibernate: EntityInsertAction → CurrentTimestampGeneration + │ → entity.createdAt = LocalDateTime.now() + │ → entity.updatedAt = LocalDateTime.now() + └─ TodoResponse.from(saved) // saved.getCreatedAt() != null ✅ +``` + +### 4.2 update + +``` +PATCH /api/todos/{id} +└─ TodoController.update() + └─ TodoService.update() [@Transactional] + ├─ findByIdAndUserId(...) // managed entity, 기존 timestamp 보유 + ├─ todo.updateXxx(...) // 필드 mutation (dirty) + ├─ entityManager.flush() // ← UPDATE SQL 실행 + │ └─ Hibernate: EntityUpdateAction → CurrentTimestampGeneration + │ → entity.updatedAt = LocalDateTime.now() + └─ TodoResponse.from(todo) // 갱신된 updatedAt ✅ +``` + +--- + +## 5. 테스트 전략 + +### 5.1 Service 단위 테스트 (Mockito) + +`@Mock EntityManager entityManager;` 필드 추가. 다음 테스트 신규: + +**create 경로:** +```java +@Test +@DisplayName("create: save 후 EntityManager.flush() 호출 — createdAt/updatedAt 보장") +void create_flushesAfterSave() { + var request = new TodoCreateRequest("t1", "수학", List.of(), null, List.of()); + when(todoRepository.existsById("t1")).thenReturn(false); + when(todoRepository.save(any(Todo.class))).thenAnswer(inv -> inv.getArgument(0)); + + todoService.create(1L, request); + + verify(entityManager).flush(); +} +``` + +**update 경로:** +```java +@Test +@DisplayName("update: mutation 후 EntityManager.flush() 호출 — updatedAt 갱신 보장") +void update_flushesAfterMutation() { + Todo existing = Todo.create("t1", 1L, "원본", null, null, null); + when(todoRepository.findByIdAndUserId("t1", 1L)).thenReturn(Optional.of(existing)); + + var request = new TodoUpdateRequest("새 제목", null, null, null, null, null); + todoService.update(1L, "t1", request); + + verify(entityManager).flush(); +} +``` + +기존 단위 테스트들도 `EntityManager` mock 추가로 동작에 영향 없는지 확인 (mock된 flush는 no-op). + +`TodoCategoryServiceTest` 동일 패턴. + +### 5.2 Repository 통합 테스트 (선택, 회귀 알람용) + +`TodoRepositoryTest` / `TodoCategoryRepositoryTest`에 회귀 테스트 추가: + +```java +@Test +@DisplayName("saveAndFlush: assigned-ID Todo도 flush 후 timestamp 채워짐 (Hibernate 회귀 알람)") +void saveAndFlush_populatesTimestamps() { + Todo saved = todoRepository.saveAndFlush(Todo.create("t-ts", 1L, "X", null, null, null)); + + assertThat(saved.getCreatedAt()).isNotNull(); + assertThat(saved.getUpdatedAt()).isNotNull(); +} +``` + +이 테스트는 Hibernate의 flush-time `@CreationTimestamp` 동작이 미래 버전 업그레이드 등으로 깨지면 즉시 알람. + +### 5.3 수동 검증 (작업 지시서 §6.2 시나리오) + +```bash +TOKEN="..." + +# 생성 +curl -X POST http://localhost:8080/api/todos \ + -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \ + -d '{"id":"11111111-1111-1111-1111-111111111111","title":"테스트","scheduledDates":["2026-05-25"]}' \ + | jq '.createdAt, .updatedAt' +# 기대: 둘 다 ISO-8601 UTC 문자열 + +# 수정 +curl -X PATCH http://localhost:8080/api/todos/11111111-1111-1111-1111-111111111111 \ + -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \ + -d '{"title":"수정"}' \ + | jq '.createdAt, .updatedAt' +# 기대: createdAt 동일, updatedAt 갱신 +``` + +### 5.4 Flutter 앱 회귀 확인 + +작업 지시서 §6.3: +- 백엔드 수정 후 Flutter 앱 재실행 +- Todo 생성 시 `_TypeError` 미발생 +- 콘솔 로그에 ISO-8601 문자열 확인 + +--- + +## 6. 위험 / 회귀 검토 + +### 6.1 `entityManager.flush()` 호출 추가 위험 + +- **부분 commit 불가:** flush는 commit이 아니므로 트랜잭션 롤백 가능성 유지. flush 후 예외가 발생하면 INSERT/UPDATE도 롤백됨. +- **다른 dirty entity 같이 flush:** create()/update() 내에서 다른 entity 변경이 없으므로 영향 없음. 정책상 메서드는 단일 entity 갱신만 수행. +- **성능:** 트랜잭션당 추가 SQL round-trip 1회. create/update는 원래 INSERT/UPDATE가 commit 시점에 실행되던 것이 단지 조금 앞당겨질 뿐. 측정 가능한 성능 영향 없음. + +### 6.2 OpenAPI 스펙 영향 + +`docs/api-docs.json` 자동 생성이면 entity 변경 없으므로 재생성 결과 동일. 클라이언트 호환성 영향 없음. + +### 6.3 다른 도메인의 잠재 결함 + +FuelTransaction/UserFuel은 동일 패턴이지만 응답 노출 여부 확인 후 별도 이슈로 처리. 본 설계에서는 다루지 않음. + +### 6.4 Hibernate 버전 종속 + +`@CreationTimestamp`의 flush-time 동작은 Hibernate 7의 `CurrentTimestampGeneration` 구현 세부. 향후 메이저 업그레이드 시 동작이 다시 바뀔 수 있음 — §5.2 Repository 회귀 테스트로 알람 보장. + +--- + +## 7. 작업 순서 + +1. **TodoService**에 EntityManager 주입 + `create()` / `update()`에 `entityManager.flush()` 추가 +2. **TodoServiceTest**에 flush() 호출 verify 테스트 추가 (create/update 각각) +3. **TodoCategoryService** 동일 변경 +4. **TodoCategoryServiceTest** 동일 변경 +5. **TodoRepositoryTest / TodoCategoryRepositoryTest**에 saveAndFlush 회귀 테스트 추가 (선택) +6. `./gradlew :SS-Study:test :SS-Web:test` 전체 통과 확인 +7. `./gradlew :SS-Web:bootRun` 후 §5.3 curl 시나리오 수동 검증 +8. Flutter 앱 회귀 확인 (§5.4) +9. PR 본문에 "Todo/TodoCategory 응답의 createdAt/updatedAt always-non-null 보장 (서비스 레이어에서 flush 호출)" 명시 + +--- + +## 8. 비범위 (Out of scope) + +- Fuel 도메인 (UserFuel, FuelTransaction) 동일 패턴 점검 — 별도 후속 이슈 +- `Persistable` 도입 (assigned-ID + merge → persist 전환으로 SELECT 1회 회피) — 본 결함과 분리된 코드 품질 이슈로 후속 처리 가능 +- BaseTimeEntity 리팩토링 — 영향 범위 확대 회피 +- OpenAPI 스펙 nullable=false 명시 — 이미 그렇게 되어 있음 +- `docs/api-docs.json` 재생성 자동화 + +--- + +## 9. 변경 이력 + +- **2026-05-25 (초안):** assigned-ID + Persistable 구현으로 해결 가능하다고 잘못 진단 → Persistable이 `persist()` 호출을 보장하지만 `@CreationTimestamp`는 flush 시점 동작이므로 entity의 timestamp는 여전히 null. Implementer subagent가 검증 후 BLOCKED 보고로 발견. +- **2026-05-25 (수정):** 진단 정정 — Hibernate 7의 `@CreationTimestamp`는 `CurrentTimestampGeneration(BeforeExecutionGenerator, source=VM)`로 flush 시점 동작. 해결 방식을 서비스 레이어 `entityManager.flush()` 호출로 단순화. Persistable / BaseTimeEntity 수정은 비범위로 이동. diff --git a/docs/superpowers/specs/2026-05-26-today-stats-cumulative-fields-design.md b/docs/superpowers/specs/2026-05-26-today-stats-cumulative-fields-design.md new file mode 100644 index 0000000..d1404cc --- /dev/null +++ b/docs/superpowers/specs/2026-05-26-today-stats-cumulative-fields-design.md @@ -0,0 +1,250 @@ +# today-stats 응답에 누적 통계 필드 3개 추가 (Design Spec) + +- **이슈**: #40 +- **브랜치**: `20260526_#40_today_stats_응답에_누적_통계_필드_3개_추가` +- **작성일**: 2026-05-26 +- **종류**: API 확장 (호환성 유지) + +--- + +## 1. 배경 / 문제 정의 + +### 1.1 현재 동작 +클라이언트의 누적 통계 Provider 3종이 `GET /api/timer-sessions`의 **첫 페이지(20개)만** 합산해 통계를 계산하고 있다. + +- `totalStudyMinutesProvider` +- `totalSessionCountProvider` +- `monthlyStudyMinutesProvider` + +### 1.2 영향 +| 영역 | 증상 | +|------|------| +| 프로필 화면 | "공부 시간" 통계 카드가 최근 20세션 기준으로만 표시 — 실제 누적과 다름 | +| 뱃지 시스템 | "총 100시간 공부", "총 50회 세션 완료" 류 조건 평가가 부정확 → 영원히 해금되지 않을 가능성 | +| API 트래픽 | 클라가 모든 페이지 순회 시 N번 API 호출 — 세션이 많아질수록 비효율 | + +### 1.3 해결 방향 +새 엔드포인트를 만드는 대신 **기존 `GET /api/timer-sessions/today-stats` 응답을 확장**한다. + +선택 사유: +- 클라의 호출 시점·캐시 정책이 today-stats와 동일 (홈/프로필 진입 시). +- API 표면적 최소화. +- 기존 `TodayStatsResponse` 스키마와 자연스러운 확장 관계. + +--- + +## 2. 변경 범위 + +### 2.1 API 계약 (불변/추가) +- 엔드포인트, 메서드, 인증, query/header 모두 **불변**. +- 응답에 필드 3개 **추가** (기존 필드 순서·이름 불변). + +### 2.2 응답 스키마 — `TodayStatsResponse` + +| 필드 | 타입 | 기존/신규 | 의미 | +|------|------|-----------|------| +| `totalMinutes` | Integer | 기존 | 오늘 총 공부 시간 (분, KST) | +| `sessionCount` | Integer | 기존 | 오늘 완료한 세션 수 (KST) | +| `streak` | Integer | 기존 | 연속 공부 일수 (오늘 포함, KST) | +| `lifetimeMinutes` | Integer | **신규** | 회원의 전체 누적 공부 시간 (분) | +| `lifetimeSessionCount` | Integer | **신규** | 회원의 전체 세션 수 | +| `monthlyMinutes` | Integer | **신규** | 이번 달 누적 공부 시간 (분, KST 기준) | + +#### 응답 예시 +```json +{ + "totalMinutes": 180, + "sessionCount": 3, + "streak": 7, + "lifetimeMinutes": 12450, + "lifetimeSessionCount": 287, + "monthlyMinutes": 1820 +} +``` + +### 2.3 0건 케이스 +- 세션이 0건인 회원은 신규 3필드 모두 `0` 반환. +- **`null` 금지** (DTO/스키마 모두 비-null 정수). +- DB 단에서는 `COALESCE(SUM(...), 0L)`로 NULL 방지. + +--- + +## 3. 시간 경계 정의 + +### 3.1 "이번 달" 경계 (KST) +- streak 계산과 **동일한 타임존(Asia/Seoul) 기준**. +- 시작: 이번 달 1일 00:00 KST +- 종료(exclusive): 다음 달 1일 00:00 KST + +### 3.2 산정식 (의사코드) +``` +todayKst = LocalDate.now(clock, Asia/Seoul) +monthStartKst = todayKst.withDayOfMonth(1) +monthEndKst = monthStartKst.plusMonths(1) +monthStartUtc = monthStartKst.atStartOfDay(Asia/Seoul) → UTC LocalDateTime +monthEndUtc = monthEndKst.atStartOfDay(Asia/Seoul) → UTC LocalDateTime + +monthlyMinutes = SUM(duration_minutes) + WHERE user_id = ? + AND started_at >= monthStartUtc + AND started_at < monthEndUtc +``` + +### 3.3 KST 월 경계 예시 +- UTC `2026-04-30 16:00:00` = KST `2026-05-01 01:00:00` → **5월**에 집계. +- UTC `2026-04-30 14:59:00` = KST `2026-04-30 23:59:00` → **4월**에 집계. + +--- + +## 4. 구현 설계 + +### 4.1 영향 모듈 +| 모듈 | 파일 | +|------|------| +| SS-Study | `dto/TodayStatsResponse.java` (필드 추가) | +| SS-Study | `repository/TimerSessionRepository.java` (메서드 2개 추가) | +| SS-Study | `service/TimerSessionService.java` (`getTodayStats` 합산 로직 확장) | +| SS-Web | `controller/timer/TimerSessionController.java` (Swagger `examples` 갱신) | +| docs | `docs/api-specs/03_timer.md` (today-stats 응답 섹션 갱신) | +| docs | `docs/api-docs.json` (수동 관리 시 동기화 — 빌드 자동 생성이면 생략) | + +### 4.2 Repository 변경 +```java +// 추가: 전체 누적 분 (COALESCE로 NULL 방지) +@Query("SELECT COALESCE(SUM(s.durationMinutes), 0L) FROM TimerSession s " + + "WHERE s.userId = :userId") +Long sumDurationByUserId(@Param("userId") Long userId); + +// 추가: 전체 세션 수 (Spring Data 메서드 명명 규칙) +long countByUserId(Long userId); +``` +- `monthlyMinutes`는 **기존 `sumDurationBetween(userId, start, end)` 재사용** — 신규 메서드 X. +- 인덱스 `idx_timer_sessions_user_started (user_id, started_at DESC)` 가 기존에 존재 → 신규 두 쿼리도 동일 인덱스로 커버. + +### 4.3 Service 변경 — `getTodayStats` + +```java +public TodayStatsResponse getTodayStats(Long userId) { + // ── 기존 로직 (today + streak) ───────────────────────── + LocalDate todayKst = LocalDate.now(clock.withZone(ZONE_KST)); + LocalDateTime todayStartUtc = toUtcLdt(todayKst.atStartOfDay(ZONE_KST)); + LocalDateTime tomorrowStartUtc = toUtcLdt(todayKst.plusDays(1).atStartOfDay(ZONE_KST)); + + long totalMinutes = Optional.ofNullable( + sessionRepository.sumDurationBetween(userId, todayStartUtc, tomorrowStartUtc)) + .orElse(0L); + long sessionCount = sessionRepository + .countByUserIdAndStartedAtGreaterThanEqualAndStartedAtLessThan( + userId, todayStartUtc, tomorrowStartUtc); + + LocalDateTime lookbackStart = toUtcLdt( + todayKst.minusDays(STREAK_LOOKBACK_DAYS).atStartOfDay(ZONE_KST)); + List startedAts = sessionRepository + .findStartedAtsAfter(userId, lookbackStart); + int streak = computeStreak(startedAts, todayKst); + + // ── 신규: 이번 달 (KST) ──────────────────────────────── + LocalDate monthStartKst = todayKst.withDayOfMonth(1); + LocalDateTime monthStartUtc = toUtcLdt(monthStartKst.atStartOfDay(ZONE_KST)); + LocalDateTime monthEndUtc = toUtcLdt(monthStartKst.plusMonths(1).atStartOfDay(ZONE_KST)); + long monthlyMinutes = Optional.ofNullable( + sessionRepository.sumDurationBetween(userId, monthStartUtc, monthEndUtc)) + .orElse(0L); + + // ── 신규: 전체 누적 ──────────────────────────────────── + long lifetimeMinutes = Optional.ofNullable( + sessionRepository.sumDurationByUserId(userId)).orElse(0L); + long lifetimeSessionCount = sessionRepository.countByUserId(userId); + + return new TodayStatsResponse( + Math.toIntExact(totalMinutes), + (int) sessionCount, + streak, + Math.toIntExact(lifetimeMinutes), + Math.toIntExact(lifetimeSessionCount), + Math.toIntExact(monthlyMinutes) + ); +} +``` + +### 4.4 쿼리 개수 +| 단계 | 쿼리 수 | +|------|---------| +| 기존 (today + streak) | 3 | +| 신규 (lifetime SUM + lifetime COUNT + monthly SUM) | +3 | +| **합계** | **6** | + +모두 동일 인덱스 + 단일 사용자 한정 범위 스캔이라 ms 단위 영향. 캐싱/비정규화는 도입하지 않음 (YAGNI). + +### 4.5 오버플로우 안전 +- `Math.toIntExact(Long → int)` 적용 — overflow 발생 시 즉시 `ArithmeticException`. +- 분 단위 누적은 `1440 × 365 × 10년 ≈ 5.25M`로 int 안전 범위 내. + +--- + +## 5. 호환성 / 마이그레이션 + +| 항목 | 영향 | +|------|------| +| DB 스키마 | 변경 없음 (마이그레이션 파일 추가 X) | +| 인덱스 | 변경 없음 | +| 기존 클라 | 신규 필드는 무시 → **무중단** | +| 신규 클라 | 누적값 즉시 사용 가능 | +| 롤백 | DTO/Service만 되돌리면 됨 (DB 영향 0) | + +--- + +## 6. 테스트 계획 + +### 6.1 Service 단위 테스트 (`TimerSessionServiceTest`) +| 케이스 | 기대값 | +|--------|--------| +| 세션 0건 | `lifetimeMinutes=0`, `lifetimeSessionCount=0`, `monthlyMinutes=0` | +| 오늘만 1건 (90분) | `total=90`, `lifetime=90`, `monthly=90`, `lifetimeCount=1` | +| 지난달 + 이번 달 혼합 | `monthly < lifetime`, `lifetime = SUM(전체)` | +| KST 월 경계: UTC 4/30 16:00 세션 | KST 5/1 01:00 → 5월 `monthlyMinutes`에 포함 | +| 큰 누적 (수십 시간) | 정수 반환, 음수/NULL 없음 | + +### 6.2 Controller 통합 테스트 (`TimerSessionControllerTest` MockMvc) +- `GET /api/timer-sessions/today-stats` 응답 JSON에 **신규 필드 3개 존재** 검증. +- 응답 200 + 필드 타입 정수 검증. +- 인증 누락 시 401 (기존 동작 회귀 없음). + +### 6.3 회귀 테스트 +- 기존 today/streak 테스트가 모두 통과해야 함 (수정 없이). + +--- + +## 7. 문서 갱신 + +### 7.1 `docs/api-specs/03_timer.md` +- `today-stats` 섹션의 응답 예시·필드 표에 신규 3필드 추가. +- 시간 경계(KST) 명시. + +### 7.2 `docs/api-docs.json` +- springdoc이 빌드 시 자동 생성하는 산출물이면 별도 수정 불필요. +- 수동으로 PR에 포함시키는 정책이면 빌드 후 산출물 동기화. +- (확인 후 plan 단계에서 결정) + +### 7.3 Swagger 어노테이션 +- `TodayStatsResponse` 각 필드에 `@Schema(description=...)` 추가. +- Controller `@ApiResponse` `examples`를 신규 필드 포함 형태로 교체. + +--- + +## 8. 비결정/추후 검토 (Out of Scope) +- members 테이블에 누적 통계 비정규화 (현재 규모에서 불필요). +- Redis TTL 캐싱. +- 주간/연간 통계 (요청 범위 외). +- 클라 측 Provider 로직 수정 (백엔드 PR 외 작업). + +--- + +## 9. 완료 조건 (Definition of Done) +- [ ] `TodayStatsResponse` 6필드 record로 확장 + `@Schema` 적용. +- [ ] Repository 메서드 2개 추가 (`sumDurationByUserId`, `countByUserId`). +- [ ] Service `getTodayStats` 합산 로직 확장. +- [ ] Controller Swagger `examples` 갱신. +- [ ] Service/Controller 테스트 6.1·6.2 케이스 추가, 전 테스트 그린. +- [ ] `docs/api-specs/03_timer.md` 갱신. +- [ ] 빌드 그린 (`./gradlew build`). diff --git a/docs/superpowers/specs/2026-05-29-exploration-domain-design.md b/docs/superpowers/specs/2026-05-29-exploration-domain-design.md new file mode 100644 index 0000000..e3e4e54 --- /dev/null +++ b/docs/superpowers/specs/2026-05-29-exploration-domain-design.md @@ -0,0 +1,350 @@ +# 탐험(Exploration) 도메인 설계 (frontend 계약 정합 버전) + +> 작성일: 2026-05-29 (개정) +> 대상 API 스펙: `docs/api-specs/05_exploration.md` +> 프론트 계약: `docs/api-specs/exploration-frontend-requirements.md` +> 프론트 시드 원본: Flutter 레포 `lib/features/exploration/data/seed/exploration_seed_data.dart` +> 동기화 Tier: 2 (Server-Validated) + +--- + +## 0. 개정 배경 + +초기 구현은 대화 중 임의로 정한 로스터(달 포함, 천왕성 누락)·아이콘(`mars-mountain` 등 프론트 미인식 값)·연료·ID(`region-kr`)를 사용해 **프론트 계약과 어긋났다.** 본 개정판은 **서버 시드를 프론트 게스트 시드와 1:1로 일치**시키고, 프론트가 요구한 `INSUFFICIENT_FUEL` 응답 보강을 추가한다. 구조 코드(entity/repository/service/controller/DTO)는 계약을 이미 충족하므로 형태를 유지하되, 작업은 working tree를 폐기하고 깨끗한 상태에서 재구현한다. + +--- + +## 1. 개요 + +행성(planet) → 지역(region) 2단계 트리를 연료로 해금한다. 이전 행성을 **클리어**(모든 하위 지역 해금)해야 다음 행성을 해금할 수 있는 **진행 게이트**(`prerequisiteId`)를 둔다. 게스트(로컬)와 회원(서버)은 완전히 분리되며 마이그레이션은 없다. + +### 엔드포인트 (4개) + +| # | Method | Path | 설명 | +|---|--------|------|------| +| 1 | GET | `/api/explorations/planets` | 행성 목록 + 유저 해금/클리어/진행도 | +| 2 | GET | `/api/explorations/planets/{planetId}/regions` | 행성 하위 지역 목록 + 유저 해금 상태 | +| 3 | POST | `/api/explorations/regions/{regionId}/unlock` | 지역 해금 (연료 차감) | +| 4 | POST | `/api/explorations/planets/{planetId}/unlock` | 행성 해금 (연료 차감 + 선행 게이트) | + +--- + +## 2. 핵심 설계 결정 + +| 결정 | 선택 | 근거 | +|------|------|------| +| 기본 해금 표현 | 암묵적 (`requiredFuel == 0`) | per-user 시드/리스너/백필 불필요. earth·korea가 해당 | +| 행성 isCleared / progress | 조회 시 파생(derive) | 저장 안 함. 하위 region 마스터 수와 유저 해금 수를 메모리 집계 | +| region isCleared | = isUnlocked | 해금 = 클리어 | +| 진행 게이트 | 명시적 `prerequisiteNodeId` 컬럼, 행성만 | sortOrder 체인. 선행 행성 클리어 필수 | +| 해금 멱등성 | UNIQUE(user_id, node_id) + 단일 트랜잭션 | 동시 중복 시 제약 위반→롤백(연료 포함). transactionId는 매 호출 신규 UUID | +| 시드 출처 | **프론트 게스트 시드 1:1 미러** | 게스트/회원 코드·아이콘·진행감 일치 | +| INSUFFICIENT_FUEL 응답 | `requiredFuel`/`currentFuel` 동봉 | 프론트가 정확한 안내 문구 생성 | + +### 환율 컨텍스트 +`UserFuel.MINUTES_PER_FUEL = 30` (30분 공부 = 1 연료). 연료 수치는 프론트 시드 값을 그대로 사용한다(서버가 임의 재산정하지 않음). + +--- + +## 3. 모듈 / 패키지 배치 + +`fuel`/`timer`/`todo`와 동일하게 **SS-Study**, Controller만 **SS-Web**. + +``` +SS-Study/.../study/exploration/ +├── constant/ NodeType (PLANET, REGION), NodeTypeConverter +├── dto/ PlanetResponse, RegionResponse, ProgressDto, +│ RegionUnlockResponse, PlanetUnlockResponse, UnlockedNodeDto +├── entity/ ExplorationNode (마스터, read-only), UserExploration (유저 진행) +├── repository/ ExplorationNodeRepository, UserExplorationRepository +└── service/ ExplorationService + +SS-Web/.../controller/exploration/ExplorationController + +SS-Common/.../common/exception/ +├── ErrorCode.java (탐험 에러 5종 추가) +├── ErrorResponse.java (nullable requiredFuel/currentFuel 추가) +├── InsufficientFuelException.java(신규) +└── GlobalExceptionHandler.java (InsufficientFuelException 분기 추가) +``` + +--- + +## 4. Entity + +### ExplorationNode (마스터, 시드 전용·읽기 전용) +- `@Entity @Table(name="exploration_nodes")`, `@Getter @Builder @AllArgsConstructor @NoArgsConstructor(PROTECTED)`. **BaseTimeEntity 미상속.** + +| 필드 | 타입 | 컬럼 | +|------|------|------| +| `id` | String `@Id` | id (고정 문자열, 예 `mars_olympus`) | +| `name` | String | name | +| `nodeType` | NodeType (`@Convert` 소문자) | node_type | +| `depth` | int | depth (planet=2, region=3) | +| `icon` | String | icon | +| `parentId` | String (nullable) | parent_id | +| `prerequisiteNodeId` | String (nullable) | prerequisite_node_id | +| `requiredFuel` | int | required_fuel | +| `sortOrder` | int | sort_order | +| `description` | String | description | +| `mapX` | double | map_x | +| `mapY` | double | map_y | + +### UserExploration (유저 진행) +- `@Entity @Table(name="user_exploration_progress", uniqueConstraints=@UniqueConstraint(name="uq_user_expl", columnNames={"user_id","node_id"}))`, BaseTimeEntity 상속. +- 행 존재 = 해금. 정적 팩토리 `unlock(userId, nodeId, cleared)` (isUnlocked=true, unlockedAt=now). + +| 필드 | 타입 | 컬럼 | +|------|------|------| +| `id` | Long `@GeneratedValue(IDENTITY)` | id | +| `userId` | Long | user_id | +| `nodeId` | String | node_id | +| `isUnlocked` | boolean | is_unlocked (항상 true) | +| `isCleared` | boolean | is_cleared (region=true, planet=false) | +| `unlockedAt` | LocalDateTime | unlocked_at | + +--- + +## 5. NodeType + Converter + +```java +public enum NodeType { PLANET, REGION; + public String value() { return name().toLowerCase(); } + public static NodeType from(String v) { return valueOf(v.toUpperCase()); } +} +``` +`@Converter` `NodeTypeConverter implements AttributeConverter` — DB에는 소문자('planet'/'region') 저장(시드·CHECK·JSON과 일치). + +--- + +## 6. 서비스 로직 + +`ExplorationService` (`@Transactional(readOnly=true)` 기본, 해금 메서드만 `@Transactional`). 의존성: `ExplorationNodeRepository`, `UserExplorationRepository`, `FuelService`. + +### 6.1 GET 행성 목록 +``` +planets = nodeRepo.findByNodeType(PLANET) (sortOrder asc) +regions = nodeRepo.findByNodeType(REGION) +progress = userExplRepo.findByUserId(userId) → Map +각 planet: + isUnlocked = requiredFuel==0 || progress.containsKey(id) + total = 자식 region 수 ; cleared = 자식 region 중 progress에 있는 수 + isCleared = total>0 && cleared==total + progressRatio = total==0 ? 0.0 : cleared/total + unlockedAt = progress 행의 값(없으면 null) + prerequisiteId = prerequisiteNodeId +→ PlanetResponse 리스트 +``` + +### 6.2 GET 지역 목록 +``` +planet 존재·PLANET 확인 (아니면 PLANET_NOT_FOUND) +regions = nodeRepo.findByParentId(planetId) (sortOrder asc) +각 region: isUnlocked = requiredFuel==0 || progress 존재; isCleared = isUnlocked +→ RegionResponse 리스트 +``` + +### 6.3 POST 지역 해금 — `@Transactional` +``` +1. region 조회·REGION 확인 (아니면 REGION_NOT_FOUND) +2. 부모 행성 해금? (requiredFuel==0 || progress 존재) → 아니면 PLANET_LOCKED +3. 이미 해금? (requiredFuel==0 || progress 존재) → ALREADY_UNLOCKED +4. 잔량 pre-check: currentFuel < requiredFuel → InsufficientFuelException(requiredFuel, currentFuel) +5. fuelService.consume(userId, requiredFuel, EXPLORATION_UNLOCK, regionId, UUID) (원자적 최종 검증) +6. UserExploration.unlock(userId, regionId, true) save +7. planetCleared = isPlanetCleared(userId, 부모행성id) (save 후 재집계) +→ RegionUnlockResponse(region, fuelConsumed=tx.amount, currentFuel=tx.balanceAfter, planetCleared) +``` + +### 6.4 POST 행성 해금 — `@Transactional` +``` +1. planet 조회·PLANET 확인 (아니면 PLANET_NOT_FOUND) +2. 이미 해금? (requiredFuel==0 || progress 존재) → ALREADY_UNLOCKED +3. prerequisiteNodeId != null 이면 선행 행성 isPlanetCleared 확인 → 아니면 PREREQUISITE_NOT_CLEARED +4. 잔량 pre-check → 부족 시 InsufficientFuelException(requiredFuel, currentFuel) +5. fuelService.consume(...) → 6. save(cleared=false) +→ PlanetUnlockResponse(planet, fuelConsumed, currentFuel) +``` + +> **원자성:** unlock 메서드(@Transactional)가 consume(@Transactional)을 호출 → 동일 트랜잭션 합류. 연료 차감·거래내역·해금행 insert가 한 단위. 잔량 pre-check는 풍부한 에러 본문을 위한 것이고, 경합 시 최종 보증은 consume 내부의 락+검증이 담당. + +> **잔량 조회:** pre-check는 `fuelService.getFuel(userId).currentFuel()` 사용. + +`isPlanetCleared(userId, planetId)`: 하위 region이 비면 false, 아니면 모든 region이 해금됐는지 `allMatch`. + +--- + +## 7. DTO (record, dto/) + +```java +record PlanetResponse(String id, String name, String nodeType, int depth, String icon, + String parentId, String prerequisiteId, int requiredFuel, + boolean isUnlocked, boolean isCleared, int sortOrder, + String description, double mapX, double mapY, + String unlockedAt, ProgressDto progress) {} +record RegionResponse(String id, String name, String nodeType, int depth, String icon, + String parentId, int requiredFuel, boolean isUnlocked, boolean isCleared, + int sortOrder, String description, double mapX, double mapY, String unlockedAt) {} +record ProgressDto(int clearedChildren, int totalChildren, double progressRatio) {} +record UnlockedNodeDto(String id, String name, boolean isUnlocked, boolean isCleared, String unlockedAt) {} +record RegionUnlockResponse(UnlockedNodeDto region, int fuelConsumed, int currentFuel, boolean planetCleared) {} +record PlanetUnlockResponse(UnlockedNodeDto planet, int fuelConsumed, int currentFuel) {} +``` +`nodeType` 소문자(`value()`), `unlockedAt` ISO-8601 UTC. 정적 `of(...)` 팩토리 사용. + +--- + +## 8. 에러 처리 + +### 8.1 ErrorCode 추가 (5종) +```java +PLANET_NOT_FOUND(NOT_FOUND, "해당 행성을 찾을 수 없습니다."), +REGION_NOT_FOUND(NOT_FOUND, "해당 지역을 찾을 수 없습니다."), +ALREADY_UNLOCKED(BAD_REQUEST, "이미 해금된 노드입니다."), +PLANET_LOCKED(BAD_REQUEST, "상위 행성이 아직 해금되지 않았습니다."), +PREREQUISITE_NOT_CLEARED(BAD_REQUEST, "이전 행성을 먼저 클리어해야 합니다."), +``` +`INSUFFICIENT_FUEL`은 기존 것 재사용. + +### 8.2 INSUFFICIENT_FUEL 응답 보강 +- `ErrorResponse` record에 nullable `Integer requiredFuel`, `Integer currentFuel` 추가 + 클래스에 `@JsonInclude(JsonInclude.Include.NON_NULL)` → 기존 응답(두 필드 null)은 직렬화에서 생략되어 **기존 계약 불변**. + - 기존 `of(ErrorCode)` / `of(ErrorCode, message)`는 두 필드 null로 생성. 신규 `of(ErrorCode, requiredFuel, currentFuel)` 추가. +- `InsufficientFuelException extends RuntimeException` (필드 requiredFuel, currentFuel) 신규. +- `GlobalExceptionHandler`에 `@ExceptionHandler(InsufficientFuelException.class)` 추가 → 400 + `{code:"INSUFFICIENT_FUEL", message, requiredFuel, currentFuel}`. +- 응답 예시: +```json +{ "code": "INSUFFICIENT_FUEL", "message": "연료가 부족합니다.", "requiredFuel": 10, "currentFuel": 4 } +``` + +--- + +## 9. Flyway 마이그레이션 + +`SS-Web/src/main/resources/db/migration/V0_0_42__add_exploration.sql` +(version.yml 현재 `0.0.42`. 구현 시점에 CI가 올렸으면 그 값으로. 한 버전당 1파일.) + +### 스키마 +```sql +CREATE TABLE IF NOT EXISTS exploration_nodes ( + id VARCHAR(50) PRIMARY KEY, + name VARCHAR(50) NOT NULL, + node_type VARCHAR(10) NOT NULL, + depth INTEGER NOT NULL, + icon VARCHAR(30) NOT NULL, + parent_id VARCHAR(50), + prerequisite_node_id VARCHAR(50), + required_fuel INTEGER NOT NULL DEFAULT 0, + sort_order INTEGER NOT NULL DEFAULT 0, + description VARCHAR(200) NOT NULL DEFAULT '', + map_x DOUBLE PRECISION NOT NULL DEFAULT 0, + map_y DOUBLE PRECISION NOT NULL DEFAULT 0, + CONSTRAINT fk_expl_node_parent FOREIGN KEY (parent_id) REFERENCES exploration_nodes(id), + CONSTRAINT fk_expl_node_prerequisite FOREIGN KEY (prerequisite_node_id) REFERENCES exploration_nodes(id), + CONSTRAINT chk_expl_node_type CHECK (node_type IN ('planet','region')), + CONSTRAINT chk_expl_required_fuel_non_negative CHECK (required_fuel >= 0) +); + +CREATE TABLE IF NOT EXISTS user_exploration_progress ( + id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, + user_id BIGINT NOT NULL, + node_id VARCHAR(50) NOT NULL, + is_unlocked BOOLEAN NOT NULL DEFAULT TRUE, + is_cleared BOOLEAN NOT NULL DEFAULT FALSE, + unlocked_at TIMESTAMP NOT NULL, + created_at TIMESTAMP NOT NULL, + updated_at TIMESTAMP NOT NULL, + CONSTRAINT fk_user_expl_member FOREIGN KEY (user_id) REFERENCES members(id) ON DELETE CASCADE, + CONSTRAINT fk_user_expl_node FOREIGN KEY (node_id) REFERENCES exploration_nodes(id), + CONSTRAINT uq_user_expl UNIQUE (user_id, node_id) +); + +CREATE INDEX IF NOT EXISTS idx_user_expl_user ON user_exploration_progress (user_id); +``` + +### 시드 — 행성 (8, 행성 먼저 INSERT, `ON CONFLICT (id) DO NOTHING`) + +| id | name | icon | required_fuel | prerequisite | sort | map_x | map_y | description | +|---|---|---|---|---|---|---|---|---| +|earth|지구|earth|0|NULL|0|0.5|0.08|우리의 출발지, 고향 행성| +|mercury|수성|mercury|3|earth|1|0.15|0.20|태양에 가장 가까운 작은 행성| +|venus|금성|venus|5|mercury|2|0.75|0.32|두꺼운 대기로 뒤덮인 뜨거운 행성| +|mars|화성|mars|10|venus|3|0.25|0.44|붉은 행성, 탐험의 꿈| +|jupiter|목성|jupiter|20|mars|4|0.7|0.56|태양계 최대의 가스 행성| +|saturn|토성|saturn|30|jupiter|5|0.2|0.68|아름다운 고리를 가진 행성| +|uranus|천왕성|uranus|45|saturn|6|0.8|0.80|옆으로 누워 자전하는 얼음 행성| +|neptune|해왕성|neptune|60|uranus|7|0.35|0.92|태양계 끝자락의 푸른 행성| + +### 시드 — 지역 (30, depth=3, prerequisite=NULL, map_x=0, map_y=0) + +**earth (12)** — icon=국가코드: +| id | name | icon | fuel | sort | description | +|---|---|---|---|---|---| +|korea|대한민국|KR|0|0|한반도 남쪽, K-컬쳐의 중심| +|japan|일본|JP|1|1|벚꽃과 기술의 나라| +|thailand|태국|TH|1|2|미소의 나라, 동남아의 허브| +|china|중국|CN|2|3|세계 최대 인구 대국| +|india|인도|IN|2|4|IT 강국, 다양한 문화의 보고| +|uk|영국|GB|2|5|해가 지지 않는 나라| +|france|프랑스|FR|2|6|예술과 낭만의 나라| +|canada|캐나다|CA|2|7|단풍과 자연의 나라| +|usa|미국|US|3|8|자유의 나라, 기회의 땅| +|brazil|브라질|BR|3|9|삼바와 축구의 나라| +|australia|호주|AU|3|10|코알라와 캥거루의 대륙| +|egypt|이집트|EG|2|11|피라미드와 나일강의 나라| + +**나머지 행성 지역 (18)** — icon=행성이름: +| id | parent | name | icon | fuel | sort | description | +|---|---|---|---|---|---|---| +|mercury_caloris|mercury|칼로리스 분지|mercury|1|0|수성 최대의 충돌 분지| +|mercury_plains|mercury|북극 평원|mercury|2|1|얼음이 숨겨진 영구 그림자 지대| +|venus_ishtar|venus|이슈타르 대지|venus|2|0|금성 북반구의 거대한 고원 지대| +|venus_aphrodite|venus|아프로디테 대지|venus|3|1|금성 적도를 따라 펼쳐진 최대 대지| +|venus_maxwell|venus|맥스웰 산|venus|3|2|금성에서 가장 높은 산맥| +|mars_olympus|mars|올림푸스 산|mars|3|0|태양계에서 가장 높은 화산| +|mars_valles|mars|마리너 계곡|mars|4|1|태양계 최대의 협곡| +|mars_polar|mars|극관 지대|mars|5|2|드라이아이스와 물 얼음의 극지방| +|jupiter_red_spot|jupiter|대적점|jupiter|5|0|수백 년간 지속되는 거대 폭풍| +|jupiter_europa|jupiter|유로파|jupiter|7|1|얼음 아래 바다가 있는 위성| +|jupiter_io|jupiter|이오|jupiter|8|2|화산 활동이 가장 활발한 위성| +|saturn_rings|saturn|토성 고리|saturn|8|0|얼음과 먼지로 이루어진 아름다운 고리| +|saturn_titan|saturn|타이탄|saturn|10|1|대기를 가진 유일한 위성, 메탄의 호수| +|saturn_enceladus|saturn|엔셀라두스|saturn|12|2|간헐천이 분출하는 얼음 위성| +|uranus_miranda|uranus|미란다|uranus|12|0|기괴한 지형의 작은 위성| +|uranus_atmosphere|uranus|천왕성 대기|uranus|15|1|메탄이 만드는 청록빛 대기| +|neptune_dark_spot|neptune|대흑점|neptune|15|0|초속 2000km 폭풍의 소용돌이| +|neptune_triton|neptune|트리톤|neptune|20|1|역행 궤도를 도는 거대 위성| + +총 **행성 8 + 지역 30 = 38 노드.** + +> 게이트 영향: mercury 해금하려면 earth의 12개 지역을 모두 해금(클리어)해야 함. 이는 의도된 진행 게이트다. + +--- + +## 10. API 스펙 문서 갱신 +`docs/api-specs/05_exploration.md`: +- 노드 객체에 `prerequisiteId` 필드, 행성 해금에 선행조건 + `PREREQUISITE_NOT_CLEARED`. +- DB 테이블 참고에 `prerequisite_node_id`. +- 예시 노드/연료 수치를 본 시드(8행성/30지역, 프론트 값)로 정정. region ID/icon 규칙(이름기반 ID, 국가코드/행성이름 icon) 명시. +- `INSUFFICIENT_FUEL` 응답에 `requiredFuel`/`currentFuel` 포함 명시. + +--- + +## 11. 테스트 전략 (TDD, 80%+) +프론트 Spring Boot 4 test-slice(StudyTestApplication, Testcontainers, create-drop) 사용. +- Entity 단위, Repository(타입/부모/유저 조회 + UNIQUE 위반), Service(Mockito: 목록 파생, 지역해금 정상/PLANET_LOCKED/ALREADY_UNLOCKED/REGION_NOT_FOUND/마지막지역 planetCleared, 행성해금 정상/PREREQUISITE_NOT_CLEARED/ALREADY_UNLOCKED/PLANET_NOT_FOUND, 잔량부족 시 InsufficientFuelException + consume 미호출), Controller(MockMvc 4엔드포인트 + 에러매핑 + INSUFFICIENT_FUEL 본문에 requiredFuel/currentFuel). +- ErrorResponse 직렬화: 두 필드 null이면 생략(@JsonInclude) 검증. + +--- + +## 12. 작업 범위 / 순서 +0. **working tree 변경 전부 폐기** (`git checkout -- .` + 미추적 exploration 파일/마이그레이션 삭제, 단 spec/plan 문서는 유지) → main 기준 clean. +1. ErrorCode 5종 + ErrorResponse 보강 + InsufficientFuelException + GlobalExceptionHandler 분기 (SS-Common) +2. NodeType + Converter (SS-Study) +3. ExplorationNode / UserExploration 엔티티 +4. Repository 2종 + StudyTestApplication 등록 +5. DTO 6종 +6. ExplorationService (조회 2 + 해금 2, 잔량 pre-check, FuelService 연동) +7. ExplorationController (SS-Web) +8. Flyway `V0_0_42` (스키마 + 시드 38노드) +9. `docs/api-specs/05_exploration.md` 갱신 +10. 단위/통합/컨트롤러 테스트 diff --git a/version.yml b/version.yml index ec0236e..7ae3768 100644 --- a/version.yml +++ b/version.yml @@ -34,12 +34,12 @@ # - 버전은 항상 높은 버전으로 자동 동기화됩니다 # =================================================================== -version: "0.0.24" -version_code: 24 # app build number +version: "0.0.43" +version_code: 43 # app build number project_type: "spring" # spring, flutter, react, react-native, react-native-expo, node, python, basic metadata: - last_updated: "2026-04-22 11:41:40" - last_updated_by: "Cassiiopeia" + last_updated: "2026-05-29 15:32:02" + last_updated_by: "EM-H20" default_branch: "main" integrated_from: "SUH-DEVOPS-TEMPLATE" integration_date: "2026-02-11"