diff --git a/README.md b/README.md index 592db63c..c64b6ebc 100644 --- a/README.md +++ b/README.md @@ -20,13 +20,78 @@ ## ✨ 주요 기능 -🎯 **내돈내산 인증** | 👥 **소셜 네트워킹** | 📍 **위치 기반 검색** | 🏆 **신뢰도 시스템** +### 🎯 내돈내산 리뷰 시스템 + +- 게시글 CRUD (생성, 조회, 수정, 삭제) +- 맛집 정보 및 평점 (1-5점) +- 이미지 업로드 (최대 5장, AWS S3 Presigned URL) +- 좋아요 및 조회수 관리 +- 댓글 및 대댓글 (계층적 구조) + +### 👥 소셜 네트워킹 + +- 친구 시스템 (요청, 수락, 거절, 해제) +- 팔로우/언팔로우 (단방향 관계) +- 컬렉션 (게시글 모음, 공개/비공개 설정) +- 프로필 페이지 (권한별 UI 분리) +- 추천 사용자 (무작위 선택 기반) + +### 🏆 신뢰도 시스템 + +- 내돈내산 리뷰: +5점 +- 광고성 리뷰: +1점 +- 위반 페널티: -20점 +- 신뢰도 레벨: BRONZE/SILVER/GOLD/PLATINUM (0-1000점) + +### 🔐 회원 관리 + +- 이메일 기반 회원가입 및 인증 +- 비밀번호 재설정 (이메일 토큰) +- 프로필 수정 (닉네임, 소개, 프로필 주소) +- 회원 상태 관리 (PENDING → ACTIVE → DEACTIVATED) ## 🛠 기술 스택 -**Backend**: Kotlin, Spring Boot, JPA, MySQL -**Testing**: JUnit5, MockK, Testcontainers -**DevOps**: Docker, GitHub Actions, SonarCloud +**Backend** + +- `Kotlin 2.1.21` `Spring Boot 3.x` `Spring Security` `JPA` `QueryDSL` + +**Database & Migration** + +- `MySQL 8.0` `Flyway` (데이터베이스 버전 관리) + +**Cloud & Infrastructure** + +- `AWS S3` (이미지 스토리지, Presigned URL) +- `AWS ECS` (컨테이너 오케스트레이션) +- `AWS ECR` (컨테이너 레지스트리) +- `AWS RDS` (MySQL Multi-AZ) +- `AWS ALB` (Application Load Balancer) +- `AWS Route 53` (DNS 관리) + +**DevOps & CI/CD** + +- `Docker` (Multi-stage 빌드) +- `GitHub Actions` (완전 자동화 파이프라인) +- `SonarCloud` (코드 품질 분석, 커버리지 80%+) + +**Testing** + +- `JUnit5` `MockK` (단위 테스트) +- `Testcontainers` (통합 테스트, 실제 DB 환경) +- `LocalStack` (AWS S3 로컬 테스트) + +**Architecture** + +- `Hexagonal Architecture` (Ports & Adapters) +- `DDD` (Domain-Driven Design) +- `Clean Architecture` (의존성 역전) + +**Frontend** + +- `HTMX` (동적 SPA-like 경험) +- `Thymeleaf` (서버 사이드 렌더링) +- `Bootstrap 5` ## 🌐 배포 @@ -34,6 +99,18 @@ - **인프라**: AWS 기반 컨테이너 오케스트레이션 (ECS, RDS, ALB) - **CI/CD**: GitHub Actions 기반 자동화 파이프라인 +### 📈 성과 지표 + +| 항목 | 성과 | +|---------------|-----------------------------| +| **배포 자동화** | 배포 시간 83% 단축 (30분 → 5분) | +| **코드 품질** | SonarCloud 커버리지 80%+ 유지 | +| **테스트 코드** | 약 15,000줄 (단위/통합/API 테스트) | +| **아키텍처** | 헥사고날 아키텍처 + DDD 완벽 적용 | +| **이미지 시스템** | S3 Presigned URL로 서버 부하 최소화 | +| **DB 마이그레이션** | Flyway 기반 자동 스키마 버전 관리 | +| **문서화** | 2,800줄+ 기술 문서 | + ### ☁️ 클라우드 아키텍처 ``` @@ -64,12 +141,14 @@ │ ECS Task │ │ (rmrt-task) │ └─────────────────┘ - ▲ │ - ┌─────────────────┐ - │ CloudWatch │ - │ (Monitoring) │ - └─────────────────┘ + ┌────────┴────────┐ + ▼ ▼ + ┌─────────────┐ ┌─────────────────┐ + │ Amazon S3 │ │ CloudWatch │ + │ (Image │ │ (Monitoring) │ + │ Storage) │ └─────────────────┘ + └─────────────┘ ``` ### 📊 데이터베이스 구조 @@ -83,6 +162,7 @@ - [🏛 아키텍처](docs/ARCHITECTURE.md) - [🚀 빠른 시작](docs/QUICK_START.md) - [📖 API 문서](docs/API_DOCUMENTATION.md) +- [📷 이미지 관리 시스템](docs/IMAGE_MANAGEMENT.md) - [🧪 테스트 가이드](docs/TESTING_GUIDE.md) - [✅ TODO 리스트](docs/TODO.md) diff --git a/docs/API_DOCUMENTATION.md b/docs/API_DOCUMENTATION.md index 3186bc38..0a06d342 100644 --- a/docs/API_DOCUMENTATION.md +++ b/docs/API_DOCUMENTATION.md @@ -4,7 +4,7 @@ RMRT는 RESTful API와 WebView 기반의 하이브리드 구조를 제공합니다. -- **Base URL**: `http://localhost:8080` +- **Base URL**: `http://localhost:8080`,`https://rmrt.albert-im.com/` - **인증**: Spring Security 기반 Session 인증 - **Content-Type**: `application/json` (API) / `application/x-www-form-urlencoded` (Form) @@ -14,6 +14,9 @@ RMRT는 RESTful API와 WebView 기반의 하이브리드 구조를 제공합니 - [멤버 프로필 조회](#멤버-프로필-조회) - [추천 사용자 목록](#추천-사용자-목록) +- [멤버 인증](#멤버-인증) +- [멤버 설정](#멤버-설정) +- [멤버 프래그먼트](#멤버-프래그먼트) ### 🍽️ 포스트 관련 API @@ -23,6 +26,15 @@ RMRT는 RESTful API와 WebView 기반의 하이브리드 구조를 제공합니 - [포스트 생성](#포스트-생성) - [포스트 수정](#포스트-수정) +### 📷 이미지 관련 API (NEW!) + +- [Presigned URL 요청](#presigned-url-요청) +- [업로드 확인](#업로드-확인) +- [업로드 상태 조회](#업로드-상태-조회) +- [이미지 URL 조회](#이미지-url-조회) +- [내 이미지 목록](#내-이미지-목록) +- [이미지 삭제](#이미지-삭제) + ### 💬 댓글 관련 API - [댓글 수 조회](#댓글-수-조회) @@ -44,6 +56,7 @@ RMRT는 RESTful API와 WebView 기반의 하이브리드 구조를 제공합니 - [팔로워 목록](#팔로워-목록) - [팔로우 생성](#팔로우-생성) - [팔로우 삭제](#팔로우-삭제) +- [팔로우 프래그먼트](#팔로우-프래그먼트) ### 📚 컬렉션 관련 API @@ -51,6 +64,7 @@ RMRT는 RESTful API와 WebView 기반의 하이브리드 구조를 제공합니 - [컬렉션 수정](#컬렉션-수정) - [컬렉션 삭제](#컬렉션-삭제) - [컬렉션 게시글 추가/제거](#컬렉션-게시글-추가제거) +- [컬렉션 상세 조회](#컬렉션-상세-조회) --- @@ -74,6 +88,61 @@ GET /members/fragment/suggest-users-sidebar **응답:** HTML 프래그먼트 +### 멤버 인증 + +**컨트롤러 리팩토링 (feature36):** + +```kotlin +@Controller +class MemberAuthView // 인증 전담 컨트롤러 +``` + +**담당 기능:** + +- 로그인/로그아웃 +- 회원가입 +- 이메일 인증 +- 비밀번호 재설정 (이메일 토큰) +- 커스텀 PasswordResetFormValidator 적용 + +### 멤버 설정 + +**컨트롤러 리팩토링 (feature36):** + +```kotlin +@Controller +class MemberSettingsView // 설정 전담 컨트롤러 +``` + +**담당 기능:** + +- 계정 정보 수정 +- 비밀번호 변경 +- 프로필 이미지 변경 +- 회원 탈퇴 + +### 멤버 프래그먼트 + +**컨트롤러 리팩토링 (feature36):** + +```kotlin +@Controller +class MemberFragmentView // 프래그먼트 전담 컨트롤러 +``` + +**담당 기능:** + +- 추천 사용자 사이드바 프래그먼트 +- 프로필 프래그먼트 +- 동적 컨텐츠 로딩 + +**아키텍처 개선:** + +- MemberView (700줄+) → 4개 전문 컨트롤러 분리 +- 단일 책임 원칙 적용 +- 패키지 구조 체계화: form, validator, converter, util, message +- 테스트 클래스 분리 (MemberAuthViewTest, MemberSettingsViewTest 등) + --- ## 🍽️ 포스트 관련 API @@ -140,6 +209,239 @@ Content-Type: application/x-www-form-urlencoded --- +## 📷 이미지 관련 API + +> **AWS S3 Presigned URL 방식**을 사용하여 서버 부하를 최소화하고 안전한 이미지 업로드를 제공합니다. + +### Presigned URL 요청 + +```http +POST /api/images/upload-request +Content-Type: application/json +``` + +**인증 필요** + +**요청:** + +```json +{ + "fileName": "photo.jpg", + "contentType": "image/jpeg", + "fileSize": 1024000, + "imageType": "POST_IMAGE", + "width": 1920, + "height": 1080 +} +``` + +**요청 필드:** + +| 필드 | 타입 | 필수 | 설명 | +|---------------|--------|----|-----------------------------------------------| +| `fileName` | String | ✅ | 원본 파일명 | +| `contentType` | String | ✅ | MIME 타입 (image/jpeg, image/png 등) | +| `fileSize` | Long | ✅ | 파일 크기 (바이트, 최대 5MB) | +| `imageType` | String | ✅ | 이미지 타입 (POST_IMAGE, PROFILE_IMAGE, THUMBNAIL) | +| `width` | Int | ✅ | 이미지 가로 크기 | +| `height` | Int | ✅ | 이미지 세로 크기 | + +**응답:** + +```json +{ + "uploadUrl": "https://bucket.s3.region.amazonaws.com/path/to/file?X-Amz-Algorithm=...", + "key": "posts/123/uuid-photo.jpg", + "expiresAt": "2025-11-30T12:15:00Z", + "metadata": { + "original-name": "photo.jpg", + "content-type": "image/jpeg", + "file-size": "1024000", + "width": "1920", + "height": "1080" + } +} +``` + +**응답 필드:** + +| 필드 | 타입 | 설명 | +|-------------|----------|-------------------------------| +| `uploadUrl` | String | S3 Presigned PUT URL (15분 유효) | +| `key` | String | S3 파일 키 | +| `expiresAt` | DateTime | URL 만료 시간 | +| `metadata` | Map | 이미지 메타데이터 | + +**제한 사항:** + +- 일일 업로드 제한: 100개 +- 파일 크기 제한: 5MB +- 지원 형식: JPEG, PNG, GIF, WebP + +### 업로드 확인 + +Presigned URL로 S3에 업로드 완료 후, 데이터베이스에 메타데이터를 저장합니다. + +```http +POST /api/images/upload-confirm?key={fileKey} +``` + +**인증 필요** + +**쿼리 파라미터:** + +- `key`: S3 파일 키 (Presigned URL 응답에서 받은 값) + +**응답:** + +```json +{ + "success": true, + "imageId": 123 +} +``` + +**응답 필드:** + +| 필드 | 타입 | 설명 | +|-----------|---------|------------| +| `success` | Boolean | 업로드 성공 여부 | +| `imageId` | Long | 생성된 이미지 ID | + +### 업로드 상태 조회 + +```http +GET /api/images/upload-status/{fileKey} +``` + +**인증 필요** + +**응답:** + +```json +{ + "success": true, + "imageId": 123 +} +``` + +### 이미지 URL 조회 + +```http +GET /api/images/{imageId}/url +``` + +**인증 필요** + +**응답:** + +```json +{ + "url": "https://bucket.s3.region.amazonaws.com/path/to/file?X-Amz-Algorithm=..." +} +``` + +**설명:** + +- Presigned GET URL (15분 유효) +- 이미지 조회 권한이 있는 사용자만 접근 가능 + +### 내 이미지 목록 + +```http +GET /api/images/my-images +``` + +**인증 필요** + +**응답:** + +```json +[ + { + "imageId": 123, + "fileKey": "posts/123/uuid-photo.jpg", + "imageType": "POST_IMAGE", + "createdAt": "2025-11-30T10:00:00Z" + }, + { + "imageId": 124, + "fileKey": "profiles/456/uuid-avatar.jpg", + "imageType": "PROFILE_IMAGE", + "createdAt": "2025-11-30T09:00:00Z" + } +] +``` + +**응답 필드:** + +| 필드 | 타입 | 설명 | +|-------------|----------|---------| +| `imageId` | Long | 이미지 ID | +| `fileKey` | String | S3 파일 키 | +| `imageType` | String | 이미지 타입 | +| `createdAt` | DateTime | 생성 일시 | + +### 이미지 삭제 + +```http +DELETE /api/images/{imageId} +``` + +**인증 필요** + +**응답:** + +```json +{ + "message": "이미지가 성공적으로 삭제되었습니다" +} +``` + +**설명:** + +- 소프트 삭제 방식 (is_deleted 플래그) +- 업로드한 사용자만 삭제 가능 + +### 이미지 타입 + +| 타입 | 설명 | 용도 | +|-----------------|---------|--------------| +| `POST_IMAGE` | 게시글 이미지 | 게시글에 첨부되는 사진 | +| `PROFILE_IMAGE` | 프로필 이미지 | 사용자 프로필 사진 | +| `THUMBNAIL` | 썸네일 이미지 | 미리보기용 작은 이미지 | + +### 보안 고려사항 + +1. **인증 필수**: 모든 이미지 API는 인증 필요 +2. **업로드 제한**: 일일 100개, 파일당 5MB +3. **파일 키 안전성**: UUID 기반 고유 파일명, 경로 탐색 공격 방지 +4. **시간 제한**: Presigned URL 15분 유효 +5. **CSRF 보호**: 모든 POST/PUT/DELETE 요청에 CSRF 토큰 필요 + +### 에러 응답 + +```json +{ + "success": false, + "error": "일일 업로드 제한을 초과했습니다", + "timestamp": "2025-11-30T12:00:00Z", + "path": "/api/images/upload-request" +} +``` + +**주요 에러:** + +| 상태 코드 | 에러 메시지 | 원인 | +|-------|-------------------|-----------------| +| 400 | 지원하지 않는 이미지 형식입니다 | 허용되지 않은 MIME 타입 | +| 400 | 파일 크기가 제한을 초과했습니다 | 5MB 초과 | +| 403 | 일일 업로드 제한을 초과했습니다 | 100개 제한 초과 | +| 403 | 이미지에 대한 권한이 없습니다 | 다른 사용자의 이미지 | +| 404 | 이미지를 찾을 수 없습니다 | 존재하지 않는 이미지 ID | + +--- + ## 💬 댓글 관련 API ### 댓글 수 조회 @@ -336,6 +638,36 @@ DELETE /members/{targetId}/follow **응답:** HTML 버튼 프래그먼트 +### 팔로우 프래그먼트 + +**팔로워 목록 프래그먼트:** + +```http +GET /follow/followers?memberId={memberId} +``` + +**응답:** HTML 프래그먼트 (followers.html) + +**기술적 특징:** + +- HTMX 기반 동적 로딩 +- 팔로우 상태 실시간 업데이트 +- 페이지 새로고침 없는 UX + +**팔로잉 목록 프래그먼트:** + +```http +GET /follow/following?memberId={memberId} +``` + +**응답:** HTML 프래그먼트 (following.html) + +**리팩토링 내역 (feature36):** + +- `fragment.html` (275줄) → `followers.html` (133줄) + `following.html` (135줄) 분리 +- 기능별 프래그먼트 분리로 유지보수성 향상 +- 팔로우 관계 생성 시 닉네임 정보 저장으로 데이터 완전성 확보 + --- ## 📚 컬렉션 관련 API @@ -418,6 +750,34 @@ DELETE /api/collections/{collectionId}/posts/{postId} **인증 필요** +### 컬렉션 상세 조회 + +```http +GET /collections/{collectionId} +``` + +**응답:** HTML 페이지 (컬렉션 상세) + +**Backend 개선사항 (feature36):** + +```kotlin +// DTO 표준화 +data class PostCollectionDetailResponse( + val collection: PostCollectionResponse, + val posts: List, + val authorPosts: List, + @JsonFormat(pattern = "yyyy-MM-dd'T'HH:mm:ss.SSSXXX") + val timestamp: Instant = Instant.now() +) +``` + +**개선 내용:** + +- 단일 API 호출로 컬렉션 정보 + 포스트 목록 + 작성자 포스트 통합 제공 +- 뷰 컨트롤러에서 서비스 계층으로 데이터 집계 책임 이관 +- @JsonFormat으로 타임존 형식 표준화 (클라이언트 호환성 확보) +- CollectionReadService.readDetail() 메서드 추가 + **응답:** 추가는 HTTP 200, 제거는 HTTP 204 --- @@ -491,7 +851,7 @@ GET /posts/fragment ### 인증 필요 API -大部分의 API는 인증이 필요하며, Spring Security가 자동으로 처리합니다. +대부분의 API는 인증이 필요하며, Spring Security가 자동으로 처리합니다. --- @@ -528,6 +888,7 @@ API 테스트는 다음 명령으로 실행할 수 있습니다: ## 📚 추가 자료 +- [이미지 관리 시스템 상세 문서](IMAGE_MANAGEMENT.md) - [테스트 가이드](TESTING_GUIDE.md) - [빠른 시작](QUICK_START.md) - [아키텍처 문서](ARCHITECTURE.md) diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index c6b24b4f..4d007b09 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -25,44 +25,78 @@ RMRT는 **클린 아키텍처**와 **도메인 주도 설계**를 기반으로 ### 1.2 헥사고날 아키텍처 (Ports and Adapters) -``` -┌──────────────────────────────────────────────────────────┐ -│ Adapter Layer │ -│ ┌─────────────────┐ ┌─────────────────────────┐ │ -│ │ Web Adapter │ │ Infrastructure Adapter │ │ -│ │ Controllers │ │ Repositories, Email │ │ -│ │ Views, Forms │ │ External Services │ │ -│ │ Exception │ │ Configuration │ │ -│ │ Handlers │ │ │ │ -│ └─────────────────┘ └─────────────────────────┘ │ -├──────────────────────────────────────────────────────────┤ -│ Application Layer │ -│ ┌─────────────────────────────────────────────────────┐ │ -│ │ Use Case Services │ │ -│ │ Registration, Authentication, Post Management │ │ -│ │ │ │ -│ │ ┌─────────────┐ ┌─────────────────────┐ │ │ -│ │ │ Provided │ │ Required │ │ │ -│ │ │ Ports │◄─────────►│ Ports │ │ │ -│ │ │ (Interfaces │ │ (Interfaces) │ │ │ -│ │ │ for Inbound)│ │ for Outbound) │ │ │ -│ │ └─────────────┘ └─────────────────────┘ │ │ -│ └─────────────────────────────────────────────────────┘ │ -├──────────────────────────────────────────────────────────┤ -│ Domain Layer │ -│ ┌─────────────────────────────────────────────────────┐ │ -│ │ Business Logic │ │ -│ │ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │ │ -│ │ │ Member │ │ Post │ │ Token │ │ │ -│ │ │ Aggregate │ │ Aggregate │ │ Aggregates │ │ │ -│ │ └─────────────┘ └─────────────┘ └─────────────┘ │ │ -│ │ │ │ -│ │ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │ │ -│ │ │ Value │ │ Domain │ │ Domain │ │ │ -│ │ │ Objects │ │ Events │ │ Services │ │ │ -│ │ └─────────────┘ └─────────────┘ └─────────────┘ │ │ -│ └─────────────────────────────────────────────────────┘ │ -└──────────────────────────────────────────────────────────┘ +```mermaid +graph TB + subgraph AL["Adapter Layer"] + subgraph WA["Web Adapter"] + Controllers[Controllers] + Views[Views, Forms] + ExceptionHandlers[Exception Handlers] + end + + subgraph IA["Infrastructure Adapter"] + Repositories[Repositories] + Email[Email Service] + S3[S3 Service] + ExternalServices[External Services] + end + end + + subgraph APL["Application Layer"] + subgraph UC["Use Case Services"] + Registration[Registration] + Authentication[Authentication] + PostManagement[Post Management] + ImageManagement[Image Management] + end + + subgraph PP["Provided Ports (Inbound)"] + MemberRegister[MemberRegister] + PostCreator[PostCreator] + ImageUploadRequester[ImageUploadRequester] + end + + subgraph RP["Required Ports (Outbound)"] + MemberRepository[MemberRepository] + PostRepository[PostRepository] + ImageRepository[ImageRepository] + PresignedUrlGenerator[PresignedUrlGenerator] + end + end + + subgraph DL["Domain Layer"] + subgraph AG["Aggregates"] + Member[Member Aggregate] + Post[Post Aggregate] + Image[Image Aggregate] + Collection[Collection Aggregate] + end + + subgraph VO["Value Objects"] + Email_VO[Email] + Nickname[Nickname] + FileKey[FileKey] + end + + subgraph DE["Domain Events"] + MemberRegistered[MemberRegisteredEvent] + PostCreated[PostCreatedEvent] + end + + subgraph DS["Domain Services"] + PasswordEncoder[PasswordEncoder] + end + end + + WA --> PP + IA --> RP + PP --> UC + UC --> RP + UC --> DL + + style AL fill:#e1f5ff + style APL fill:#fff4e1 + style DL fill:#ffe1f5 ``` **의존성 방향**: Adapter → Application → Domain (단방향) @@ -202,6 +236,9 @@ src/main/kotlin/com/albert/realmoneyrealtaste/ │ │ ├── comment/ # 댓글 API │ │ │ ├── CommentReadApi.kt │ │ │ └── CommentWriteApi.kt +│ │ ├── image/ # 이미지 API +│ │ │ ├── ImageApi.kt +│ │ │ └── ImageRestExceptionHandler.kt │ │ └── exception/ # API 예외 처리 │ ├── webview/ # WebView 어댑터 (MVC) │ │ ├── auth/ # 인증 뷰 @@ -210,11 +247,18 @@ src/main/kotlin/com/albert/realmoneyrealtaste/ │ │ │ ├── AuthViews.kt │ │ │ ├── SignupForm.kt │ │ │ └── SigninForm.kt -│ │ ├── member/ # 회원 뷰 -│ │ │ ├── MemberView.kt +│ │ ├── member/ # 회원 뷰 (리팩토링 완료) +│ │ │ ├── MemberAuthView.kt # 인증 컨트롤러 +│ │ │ ├── MemberProfileView.kt # 프로필 컨트롤러 +│ │ │ ├── MemberSettingsView.kt # 설정 컨트롤러 +│ │ │ ├── MemberFragmentView.kt # 프래그먼트 컨트롤러 │ │ │ ├── MemberUrls.kt │ │ │ ├── MemberViews.kt -│ │ │ └── forms/ +│ │ │ ├── form/ # 폼 클래스 +│ │ │ ├── validator/ # 검증 로직 +│ │ │ ├── converter/ # 데이터 변환 +│ │ │ ├── util/ # 유틸리티 +│ │ │ └── message/ # 메시지 상수 │ │ ├── post/ # 게시글 뷰 │ │ │ ├── PostView.kt │ │ │ ├── PostUrls.kt @@ -229,29 +273,38 @@ src/main/kotlin/com/albert/realmoneyrealtaste/ │ │ │ ├── FriendWriteView.kt │ │ │ ├── FriendUrls.kt │ │ │ └── FriendViews.kt -│ │ ├── follow/ # 팔로우 뷰 -│ │ │ ├── FollowView.kt -│ │ │ ├── FollowCreateView.kt -│ │ │ ├── FollowTerminateView.kt -│ │ │ └── FollowUrls.kt +│ │ ├── follow/ # 팔로우 뷰 (리팩토링 완료) +│ │ │ ├── FollowReadView.kt # 조회 컨트롤러 +│ │ │ ├── FollowCreateView.kt # 생성 컨트롤러 +│ │ │ ├── FollowTerminateView.kt # 삭제 컨트롤러 +│ │ │ ├── FollowUrls.kt +│ │ │ └── fragments/ # 프래그먼트 분리 +│ │ │ ├── followers.html # 팔로워 목록 +│ │ │ └── following.html # 팔로잉 목록 │ │ └── comment/ # 댓글 뷰 │ │ ├── CommentReadView.kt │ │ ├── CommentWriteView.kt │ │ ├── CommentUrls.kt │ │ └── CommentViews.kt │ ├── infrastructure/ # 인프라 어댑터 +│ │ ├── s3/ # AWS S3 어댑터 +│ │ │ ├── S3Config.kt +│ │ │ └── S3PresignedUrlGenerator.kt +│ │ ├── email/ # 이메일 어댑터 +│ │ │ ├── EmailSenderImpl.kt +│ │ │ └── EmailTemplateService.kt +│ │ ├── security/ # 보안 어댑터 +│ │ │ ├── SecurityConfig.kt +│ │ │ ├── MemberPrincipal.kt +│ │ │ └── CustomAuthenticationProvider.kt │ │ ├── persistence/ # 데이터베이스 어댑터 │ │ │ ├── member/ │ │ │ ├── post/ +│ │ │ ├── image/ │ │ │ ├── collection/ │ │ │ ├── friend/ │ │ │ ├── follow/ │ │ │ └── comment/ -│ │ ├── email/ # 이메일 어댑터 -│ │ │ └── EmailTemplateService.kt -│ │ └── security/ # 보안 설정 -│ │ ├── SecurityConfig.kt -│ │ └── MemberPrincipal.kt │ └── configuration/ # 설정 클래스 ├── application/ # 애플리케이션 레이어 │ ├── member/ # 회원 유스케이스 @@ -322,18 +375,31 @@ src/main/kotlin/com/albert/realmoneyrealtaste/ │ │ │ └── FollowTerminator.kt │ │ └── required/ │ │ └── FollowRepository.kt -│ └── comment/ # 댓글 유스케이스 +│ ├── comment/ # 댓글 유스케이스 +│ │ ├── service/ +│ │ │ ├── CommentCreationService.kt +│ │ │ ├── CommentReadService.kt +│ │ │ └── CommentUpdateService.kt +│ │ ├── dto/ +│ │ ├── provided/ +│ │ │ ├── CommentCreator.kt +│ │ │ ├── CommentReader.kt +│ │ │ └── CommentUpdater.kt +│ │ └── required/ +│ │ └── CommentRepository.kt +│ └── image/ # 이미지 유스케이스 │ ├── service/ -│ │ ├── CommentCreationService.kt -│ │ ├── CommentReadService.kt -│ │ └── CommentUpdateService.kt +│ │ ├── ImageUploadService.kt +│ │ ├── ImageReadService.kt +│ │ └── ImageDeleteService.kt │ ├── dto/ │ ├── provided/ -│ │ ├── CommentCreator.kt -│ │ ├── CommentReader.kt -│ │ └── CommentUpdater.kt +│ │ ├── ImageUploadRequester.kt +│ │ ├── ImageReader.kt +│ │ └── ImageDeleter.kt │ └── required/ -│ └── CommentRepository.kt +│ ├── ImageRepository.kt +│ └── PresignedUrlGenerator.kt └── domain/ # 도메인 레이어 ├── member/ # 회원 도메인 │ ├── Member.kt # 회원 애그리게이트 루트 @@ -415,9 +481,17 @@ src/main/kotlin/com/albert/realmoneyrealtaste/ │ │ └── CommentDeletedEvent.kt │ └── exceptions/ │ └── CommentApplicationException.kt + ├── image/ # 이미지 도메인 + │ ├── Image.kt # 이미지 애그리게이트 루트 + │ ├── ImageType.kt + │ ├── ImageStatus.kt + │ ├── value/ + │ │ ├── FileKey.kt + │ │ └── ImageMetadata.kt + │ └── exceptions/ + │ └── ImageApplicationException.kt └── common/ # 공통 도메인 요소 ├── BaseEntity.kt └── exceptions/ └── DomainException.kt ``` - diff --git a/docs/DOMAIN_MODEL.md b/docs/DOMAIN_MODEL.md index 6291f676..493aacba 100644 --- a/docs/DOMAIN_MODEL.md +++ b/docs/DOMAIN_MODEL.md @@ -7,14 +7,15 @@ - [1. 도메인 설계 개요](#1-도메인-설계-개요) - [2. 회원 애그리거트](#2-회원-애그리거트) - [3. 게시글 애그리거트](#3-게시글-애그리거트) -- [4. 컬렉션 애그리거트](#4-컬렉션-애그리거트) -- [5. 친구 관계 애그리거트](#5-친구-관계-애그리거트) -- [6. 팔로우 애그리거트](#6-팔로우-애그리거트) -- [7. 댓글 애그리거트](#7-댓글-애그리거트) -- [8. 토큰 애그리거트](#8-토큰-애그리거트) -- [9. 공통 설계 요소](#9-공통-설계-요소) -- [10. 애플리케이션 레이어](#10-애플리케이션-레이어) -- [11. 설계 특징 및 패턴](#11-설계-특징-및-패턴) +- [4. 이미지 애그리거트](#4-이미지-애그리거트) +- [5. 컬렉션 애그리거트](#5-컬렉션-애그리거트) +- [6. 친구 관계 애그리거트](#6-친구-관계-애그리거트) +- [7. 팔로우 애그리거트](#7-팔로우-애그리거트) +- [8. 댓글 애그리거트](#8-댓글-애그리거트) +- [9. 토큰 애그리거트](#9-토큰-애그리거트) +- [10. 공통 설계 요소](#10-공통-설계-요소) +- [11. 애플리케이션 레이어](#11-애플리케이션-레이어) +- [12. 설계 특징 및 패턴](#12-설계-특징-및-패턴) --- @@ -40,10 +41,14 @@ Member Aggregate ├── TrustScore └── Roles -Post Aggregate +Post Aggregate ├── Post (Root) └── PostImages (ElementCollection) +Image Aggregate +├── Image (Root) +└── FileKey (Value Object) + PostCollection Aggregate ├── PostCollection (Root) ├── CollectionInfo @@ -423,9 +428,106 @@ data class PostHeartRemovedEvent( --- -## 4. 컬렉션 애그리거트 +## 4. 이미지 애그리거트 + +### 4.1 이미지 (Image) + +**_Aggregate Root, Entity_** + +#### 속성 + +* `id: Long` - 이미지 식별자 (PK) +* `fileKey: FileKey` - S3 파일 키 (Embedded, Unique) +* `uploadedBy: Long` - 업로드한 회원 ID +* `imageType: ImageType` - 이미지 타입 (Enum) +* `isDeleted: Boolean` - 삭제 여부 (Soft Delete) +* `createdAt: LocalDateTime` - 업로드 일시 +* `updatedAt: LocalDateTime` - 수정 일시 + +#### 주요 행위 + +**생성** + +```kotlin +static create( + fileKey: FileKey, + uploadedBy: Long, + imageType: ImageType +): Image +``` + +**삭제** + +- `delete()` - 이미지 삭제 (Soft Delete) + - `isDeleted = true`로 설정 + - 실제 S3 파일은 별도 배치 작업으로 삭제 + +#### 비즈니스 규칙 + +* 초기 상태: isDeleted = false +* 이미 삭제된 이미지는 재삭제 불가 +* 하루 업로드 제한: 회원당 50장 +* 파일 크기 제한: 5MB +* 지원 타입: PROFILE, POST, COLLECTION + +#### 인덱스 + +* `idx_image_uploaded_by`: uploaded_by +* `idx_image_type`: image_type +* `uidx_image_file_key`: file_key (UNIQUE) + +--- + +### 4.2 Value Objects + +#### FileKey + +**경로 안전성 검증** + +```kotlin +@Embeddable +data class FileKey( + @Column(name = "file_key", unique = true, length = 512) + val value: String +) { + init { + require(!value.contains("..")) { "경로 탐색 공격 방지" } + require(!value.startsWith("/")) { "절대 경로 사용 불가" } + require(value.length <= MAX_LENGTH) { "파일 키는 512자를 초과할 수 없습니다" } + } + + companion object { + const val MAX_LENGTH = 512 + } +} +``` + +**특징** +- 경로 탐색 공격 (`../`) 방지 +- 절대 경로 사용 금지 +- S3 객체 키 직접 매핑 + +#### ImageType + +```kotlin +enum class ImageType { + PROFILE, // 프로필 이미지 + POST, // 게시글 이미지 + COLLECTION // 컬렉션 커버 이미지 +} +``` + +--- + +### 4.3 도메인 이벤트 -### 4.1 컬렉션 (PostCollection) +현재 이미지 관련 도메인 이벤트는 정의되지 않음 (향후 확장 가능) + +--- + +## 5. 컬렉션 애그리거트 + +### 5.1 컬렉션 (PostCollection) **_Aggregate Root, Entity_** @@ -486,9 +588,25 @@ privacy: CollectionPrivacy #### 주요 행위 -**생성** +**생성 (feature36 개선)** -- `static request(fromMemberId: Long, toMemberId: Long): Friendship` - 친구 요청 +```kotlin +companion object { + fun request(command: FriendRequestCommand): Friendship { + // 친구 요청 시 양방향 닉네임 정보 저장 + val relationship = FriendRelationship( + fromMemberId = command.fromMemberId, + fromMemberNickname = command.fromMemberNickname, // 추가 + toMemberId = command.toMemberId, + toMemberNickname = command.toMemberNickname // 추가 + ) + return Friendship( + relationship = relationship, + status = FriendshipStatus.PENDING + ) + } +} +``` **상태 전이** @@ -502,6 +620,7 @@ privacy: CollectionPrivacy * PENDING → ACCEPTED/REJECTED만 가능 * ACCEPTED → TERMINATED만 가능 * 양방향 관계 관리 +* **데이터 완전성 확보 (feature36)**: 친구 요청 시 양방향 닉네임 저장으로 조회 성능 개선 #### 도메인 이벤트 @@ -543,9 +662,22 @@ data class FriendshipTerminatedEvent( #### 주요 행위 -**생성** +**생성 (feature36 개선)** -- `static start(followerId: Long, followingId: Long): Follow` - 팔로우 시작 +```kotlin +companion object { + fun start(command: FollowCreateCommand): Follow { + // 팔로우 관계 생성 시 닉네임 정보 저장 + val relationship = FollowRelationship( + followerId = command.followerId, + followerNickname = command.followerNickname, // 추가 + followingId = command.followingId, + followingNickname = command.followingNickname // 추가 + ) + return Follow(relationship = relationship, status = FollowStatus.ACTIVE) + } +} +``` **관리** @@ -557,6 +689,7 @@ data class FriendshipTerminatedEvent( * ACTIVE → TERMINATED만 가능 * 단방향 관계 (팔로워 → 팔로잉) * 자기 자신 팔로우 불가 +* **데이터 완전성 확보 (feature36)**: 팔로우 관계 생성 시 닉네임 저장으로 조회 성능 개선 #### 도메인 이벤트 diff --git a/docs/IMAGE_MANAGEMENT.md b/docs/IMAGE_MANAGEMENT.md new file mode 100644 index 00000000..970679c9 --- /dev/null +++ b/docs/IMAGE_MANAGEMENT.md @@ -0,0 +1,484 @@ +# 이미지 관리 시스템 + +## 개요 + +RMRT 이미지 관리 시스템은 AWS S3 기반의 안전하고 확장 가능한 이미지 업로드/조회/삭제 기능을 제공합니다. Presigned URL 방식을 사용하여 서버 부하를 최소화하고, 헥사고날 아키텍처를 통해 높은 +유지보수성을 확보했습니다. + +## 주요 기능 + +### 1. 이미지 업로드 + +- **AWS S3 Presigned PUT URL** 방식 사용 +- 클라이언트가 S3에 직접 업로드하여 서버 부하 최소화 +- 업로드 완료 후 데이터베이스에 메타데이터 저장 +- 실시간 업로드 진행률 표시 + +### 2. 이미지 조회 + +- Presigned GET URL을 통한 안전한 이미지 접근 +- 이미지 캐러셀 UI 지원 +- 사용자별 이미지 목록 조회 + +### 3. 이미지 삭제 + +- 소프트 삭제 방식 (is_deleted 플래그) +- 사용자 권한 검증 +- 게시글 연동 이미지 관리 + +## 아키텍처 + +### 헥사고날 아키텍처 적용 + +``` +┌─────────────────────────────────────────────────────────────┐ +│ Adapter Layer │ +│ ┌──────────────┐ ┌──────────────┐ ┌──────────────────┐ │ +│ │ ImageApi │ │ ImageView │ │ S3PresignedUrl │ │ +│ │ (REST API) │ │ (Thymeleaf) │ │ Generator │ │ +│ └──────────────┘ └──────────────┘ └──────────────────┘ │ +└─────────────────────────────────────────────────────────────┘ + │ + ▼ +┌─────────────────────────────────────────────────────────────┐ +│ Application Layer │ +│ ┌──────────────────────────────────────────────────────┐ │ +│ │ Provided Ports (Inbound) │ │ +│ │ - ImageUploadRequester │ │ +│ │ - ImageUploadTracker │ │ +│ │ - ImageReader │ │ +│ │ - ImageDeleter │ │ +│ │ - ImageKeyGenerator │ │ +│ │ - ImageUploadValidator │ │ +│ └──────────────────────────────────────────────────────┘ │ +│ │ +│ ┌──────────────────────────────────────────────────────┐ │ +│ │ Required Ports (Outbound) │ │ +│ │ - ImageRepository │ │ +│ │ - PresignedUrlGenerator │ │ +│ └──────────────────────────────────────────────────────┘ │ +└─────────────────────────────────────────────────────────────┘ + │ + ▼ +┌─────────────────────────────────────────────────────────────┐ +│ Domain Layer │ +│ ┌──────────────┐ ┌──────────────┐ ┌──────────────────┐ │ +│ │ Image │ │ ImageType │ │ FileKey │ │ +│ │ (Entity) │ │ (Enum) │ │ (Value Object) │ │ +│ └──────────────┘ └──────────────┘ └──────────────────┘ │ +└─────────────────────────────────────────────────────────────┘ +``` + +### 레이어별 역할 + +#### Domain Layer + +- `Image`: 이미지 도메인 엔티티 (업로드자, 이미지 타입, 삭제 여부 관리) +- `ImageType`: 이미지 타입 (PROFILE_IMAGE, POST_IMAGE, THUMBNAIL) +- `FileKey`: S3 파일 키 값 객체 (경로 안전성 검증) + +#### Application Layer + +**Provided Ports (애플리케이션이 제공하는 기능)** + +- `ImageUploadRequester`: Presigned URL 요청 +- `ImageUploadTracker`: 업로드 완료 추적 및 메타데이터 저장 +- `ImageReader`: 이미지 조회 및 URL 생성 +- `ImageDeleter`: 이미지 삭제 +- `ImageKeyGenerator`: 안전한 파일 키 생성 +- `ImageUploadValidator`: 업로드 요청 검증 + +**Required Ports (애플리케이션이 필요로 하는 기능)** + +- `ImageRepository`: 이미지 영속성 관리 +- `PresignedUrlGenerator`: AWS S3 Presigned URL 생성 + +#### Adapter Layer + +- `ImageApi`: REST API 엔드포인트 +- `ImageView`: Thymeleaf 뷰 모델 +- `S3PresignedUrlGenerator`: AWS S3 연동 구현체 + +## 이미지 업로드 플로우 + +### 전체 플로우 + +```mermaid +sequenceDiagram + participant Client + participant Backend + participant S3 as AWS S3 + Note over Client, S3: 1. Presigned URL 요청 단계 + Client ->> Backend: POST /api/images/upload-request + Note right of Backend: 1. 사용자 검증
2. 업로드 제한 확인
3. 이미지 메타데이터 검증
4. 파일 키 생성 + Backend ->> S3: Presigned PUT URL 생성 요청 + S3 -->> Backend: Presigned PUT URL + Backend -->> Client: PresignedPutResponse
- uploadUrl
- key
- expiresAt
- metadata + Note over Client, S3: 2. 이미지 업로드 단계 + Client ->> S3: PUT {uploadUrl}
with metadata headers + Note right of S3: 파일 저장 + S3 -->> Client: 200 OK + Note over Client, S3: 3. 업로드 확인 단계 + Client ->> Backend: POST /api/images/upload-confirm
key={fileKey} + Note right of Backend: 1. Image 엔티티 생성
2. DB 저장 + Backend -->> Client: ImageUploadResult
- success: true
- imageId: 123 +``` + +### 1. Presigned URL 요청 단계 + +```mermaid +sequenceDiagram + participant Client + participant Backend + participant S3 as AWS S3 + Client ->> Backend: POST /api/images/upload-request + activate Backend + Note right of Backend: 사용자 검증 + Note right of Backend: 업로드 제한 확인 + Note right of Backend: 이미지 메타데이터 검증 + Note right of Backend: 파일 키 생성 + Backend ->> S3: Presigned PUT URL 생성 요청 + activate S3 + S3 -->> Backend: Presigned PUT URL + deactivate S3 + Backend -->> Client: PresignedPutResponse
- uploadUrl
- key
- expiresAt
- metadata + deactivate Backend +``` + +### 2. 이미지 업로드 단계 + +```mermaid +sequenceDiagram + participant Client + participant S3 as AWS S3 + Client ->> S3: PUT {uploadUrl}
with metadata headers + activate S3 + Note right of S3: 파일 저장 + S3 -->> Client: 200 OK + deactivate S3 +``` + +### 3. 업로드 확인 단계 + +```mermaid +sequenceDiagram + participant Client + participant Backend + participant DB as Database + Client ->> Backend: POST /api/images/upload-confirm
key={fileKey} + activate Backend + Backend ->> Backend: Image 엔티티 생성 + Backend ->> DB: 이미지 메타데이터 저장 + activate DB + DB -->> Backend: 저장 완료 + deactivate DB + Backend -->> Client: ImageUploadResult
- success: true
- imageId: 123 + deactivate Backend +``` + +## 데이터베이스 스키마 + +### images 테이블 + +```sql +CREATE TABLE images +( + id BIGINT AUTO_INCREMENT PRIMARY KEY, + file_key VARCHAR(512) NOT NULL UNIQUE COMMENT 'S3 파일 키', + uploaded_by BIGINT NOT NULL COMMENT '업로드한 사용자 ID', + image_type VARCHAR(20) NOT NULL COMMENT '이미지 타입 (PROFILE_IMAGE, POST_IMAGE, THUMBNAIL)', + is_deleted BOOLEAN NOT NULL DEFAULT FALSE COMMENT '삭제 여부', + created_at DATETIME(6) NOT NULL COMMENT '생성 일시', + updated_at DATETIME(6) NOT NULL COMMENT '수정 일시', + + INDEX idx_uploaded_by (uploaded_by), + INDEX idx_image_type (image_type), + FOREIGN KEY (uploaded_by) REFERENCES members (id) +) ENGINE = InnoDB + DEFAULT CHARSET = utf8mb4 + COLLATE = utf8mb4_unicode_ci; +``` + +### 인덱스 전략 + +- `idx_uploaded_by`: 사용자별 이미지 조회 성능 최적화 +- `idx_image_type`: 이미지 타입별 필터링 성능 최적화 + +## API 명세 + +### 1. Presigned URL 요청 + +**Endpoint**: `POST /api/images/upload-request` + +**Request Body**: + +```json +{ + "fileName": "photo.jpg", + "contentType": "image/jpeg", + "fileSize": 1024000, + "imageType": "POST_IMAGE", + "width": 1920, + "height": 1080 +} +``` + +**Response**: + +```json +{ + "uploadUrl": "https://bucket.s3.region.amazonaws.com/path/to/file?X-Amz-...", + "key": "posts/123/uuid-photo.jpg", + "expiresAt": "2025-11-30T12:00:00Z", + "metadata": { + "original-name": "photo.jpg", + "content-type": "image/jpeg", + "file-size": "1024000", + "width": "1920", + "height": "1080" + } +} +``` + +### 2. 업로드 확인 + +**Endpoint**: `POST /api/images/upload-confirm?key={fileKey}` + +**Response**: + +```json +{ + "success": true, + "imageId": 123 +} +``` + +### 3. 이미지 URL 조회 + +**Endpoint**: `GET /api/images/{imageId}/url` + +**Response**: + +```json +{ + "url": "https://bucket.s3.region.amazonaws.com/path/to/file?X-Amz-..." +} +``` + +### 4. 내 이미지 목록 조회 + +**Endpoint**: `GET /api/images/my-images` + +**Response**: + +```json +[ + { + "imageId": 123, + "fileKey": "posts/123/uuid-photo.jpg", + "imageType": "POST_IMAGE", + "createdAt": "2025-11-30T10:00:00Z" + } +] +``` + +### 5. 이미지 삭제 + +**Endpoint**: `DELETE /api/images/{imageId}` + +**Response**: + +```json +{ + "message": "이미지가 성공적으로 삭제되었습니다" +} +``` + +## 보안 고려사항 + +### 1. 업로드 제한 + +- 일일 업로드 횟수 제한 (기본값: 100개) +- 파일 크기 제한 (최대 5MB) +- 지원 이미지 형식: JPEG, PNG, GIF, WebP + +### 2. 파일 키 안전성 + +- UUID 기반 고유 파일명 생성 +- 경로 탐색 공격 방지 (`..` 검증) +- 사용자별 디렉토리 분리 + +### 3. 접근 제어 + +- 인증된 사용자만 업로드 가능 +- 업로드한 사용자만 삭제 가능 +- Presigned URL 유효 시간 제한 (기본값: 15분) + +### 4. 메타데이터 보안 + +- 민감정보 로깅 제거 +- S3 메타데이터 헤더를 통한 파일 정보 전송 +- CSRF 보호 적용 + +## 테스트 전략 + +### 1. 테스트 환경 + +- **Testcontainers + LocalStack**: 로컬 S3 환경 구축 +- **CORS 설정**: 테스트 환경에서 클라이언트 업로드 지원 +- **격리성**: 테스트별 독립적인 S3 버킷 사용 + +### 2. 테스트 커버리지 + +- 도메인 로직 테스트 (Image, FileKey, ImageType) +- 애플리케이션 서비스 테스트 (업로드, 조회, 삭제) +- API 통합 테스트 (MockMvc) +- S3 연동 테스트 (LocalStack) + +### 3. 주요 테스트 케이스 + +```kotlin +// 도메인 테스트 +-Image 생성 및 권한 검증 +-FileKey 경로 안전성 검증 + -이미지 타입별 기능 테스트 + +// 애플리케이션 테스트 + -Presigned URL 생성 +-업로드 완료 추적 +-일일 업로드 제한 검증 + -이미지 삭제 권한 검증 + +// API 테스트 + -REST API 엔드포인트 테스트 + -예외 처리 테스트 +-보안 검증 테스트 +``` + +## 설정 + +### application-prod.yml + +```yaml +aws: + s3: + access-key: ${AWS_ACCESS_KEY_ID} + secret-key: ${AWS_SECRET_ACCESS_KEY} + region: ${AWS_REGION:ap-northeast-2} + bucket-name: ${S3_BUCKET_NAME} + +image: + upload: + expiration-minutes: 15 # Presigned PUT URL 유효 시간 + get: + expiration-minutes: 15 # Presigned GET URL 유효 시간 +``` + +### 환경 변수 + +```bash +AWS_ACCESS_KEY_ID=your-access-key +AWS_SECRET_ACCESS_KEY=your-secret-key +AWS_REGION=ap-northeast-2 +S3_BUCKET_NAME=your-bucket-name +``` + +## 프론트엔드 통합 + +### 이미지 업로드 컴포넌트 (Thymeleaf) + +```html + +
+ + + + +
+
+
+ + +
+
+``` + +### 이미지 캐러셀 (Bootstrap) + +```html + + +``` + +## 데이터베이스 마이그레이션 + +### Flyway 도입 + +- 버전 관리된 스키마 변경 추적 +- CI/CD 파이프라인 통합 +- 롤백 지원 + +### V1\_\_init.sql + +```sql +-- 초기 스키마에 images 테이블 포함 +-- 인덱스 및 외래키 제약조건 자동 생성 +``` + +## 성능 최적화 + +### 1. 서버 부하 최소화 + +- 클라이언트가 S3에 직접 업로드 +- 서버는 URL 생성과 메타데이터 관리만 담당 + +### 2. 데이터베이스 쿼리 최적화 + +- 인덱스 활용한 빠른 조회 +- N+1 문제 방지 (findByIds 배치 조회) + +### 3. CDN 활용 가능 + +- S3 Presigned GET URL을 CloudFront와 연동 가능 +- 이미지 캐싱으로 응답 속도 개선 + +## 향후 개선 계획 + +### 1. 이미지 처리 + +- [ ] 썸네일 자동 생성 (Lambda) +- [ ] 이미지 리사이징 +- [ ] WebP 자동 변환 + +### 2. 성능 개선 + +- [ ] CloudFront CDN 연동 +- [ ] 이미지 캐싱 전략 +- [ ] 레이지 로딩 + +### 3. 기능 확장 + +- [ ] 이미지 편집 기능 +- [ ] 이미지 태깅 +- [ ] 중복 이미지 감지 + +## 참고 자료 + +- [AWS S3 Presigned URL 가이드](https://docs.aws.amazon.com/AmazonS3/latest/userguide/PresignedUrlUploadObject.html) +- [Testcontainers LocalStack 모듈](https://www.testcontainers.org/modules/localstack/) +- [헥사고날 아키텍처](https://alistair.cockburn.us/hexagonal-architecture/) +- [Spring Boot S3 통합](https://docs.awspring.io/spring-cloud-aws/docs/current/reference/html/index.html) diff --git a/docs/QUICK_START.md b/docs/QUICK_START.md index e7d05e51..d3dae88a 100644 --- a/docs/QUICK_START.md +++ b/docs/QUICK_START.md @@ -33,6 +33,14 @@ vim .env docker-compose up -d mysql ``` +#### Flyway 마이그레이션 + +애플리케이션 시작 시 Flyway가 자동으로 데이터베이스 스키마를 설정합니다: + +- 마이그레이션 파일 위치: `src/main/resources/db/migration/` +- 자동 실행: 애플리케이션 부트 시 +- 버전 관리: V1, V2, V3... 순차적 적용 + ### 4. 애플리케이션 실행 ```bash @@ -89,9 +97,10 @@ docker-compose up -d mysql ### Testcontainers -프로젝트는 Testcontainers를 사용하여 테스트용 데이터베이스를 자동으로 관리합니다: +프로젝트는 Testcontainers를 사용하여 테스트용 데이터베이스 및 AWS 서비스를 자동으로 관리합니다: - **MySQL 8.0** 컨테이너 자동 시작 +- **LocalStack** S3 컨테이너 자동 시작 (이미지 업로드 테스트) - **CI 환경** 최적화 설정 - **성능 튜닝** 적용 (메모리 64MB, 시작 타임아웃 5분) @@ -160,9 +169,11 @@ export GRADLE_OPTS="-Xmx2g -XX:MaxMetaspaceSize=512m" ## 🔄 다음 단계 +- [이미지 관리 시스템](IMAGE_MANAGEMENT.md) 이해 - [API 문서](API_DOCUMENTATION.md) 확인 - [도메인 모델](DOMAIN_MODEL.md) 이해 - [테스트 가이드](TESTING_GUIDE.md) 참고 +- [아키텍처](ARCHITECTURE.md) 학습 --- diff --git a/docs/TESTING_GUIDE.md b/docs/TESTING_GUIDE.md index e12c35cc..286671bf 100644 --- a/docs/TESTING_GUIDE.md +++ b/docs/TESTING_GUIDE.md @@ -18,6 +18,7 @@ RMRT는 **실제 사용 시나리오**를 중심으로 테스트합니다. Mock - **JUnit 5**: 테스트 프레임워크 - **MockK**: Mock 객체 생성 (Kotlin 친화적) - **Testcontainers**: 실제 Docker MySQL 컨테이너 +- **LocalStack**: AWS S3 로컬 테스트 환경 - **MockMvc**: 웹 계층 테스트 - **Spring Boot Test**: 통합 테스트 지원 @@ -308,3 +309,190 @@ fun `unfriend - success - publishes friendship terminated event`() { ./gradlew jacocoTestReport # 결과: build/reports/jacoco/test/html/index.html ``` + +--- + +## 🖼 이미지 관리 시스템 테스트 + +### LocalStack S3 테스트 + +이미지 업로드/조회/삭제 기능은 LocalStack을 통해 실제 S3 환경과 동일하게 테스트됩니다: + +```kotlin +@SpringBootTest +@Import(TestcontainersConfiguration::class) +class ImageUploadServiceTest { + + @Autowired + private lateinit var imageUploadRequester: ImageUploadRequester + + @Autowired + private lateinit var imageUploadTracker: ImageUploadTracker + + @Test + fun `requestPresignedPutUrl - success - returns valid presigned URL`() { + // Given + val request = ImageUploadRequest( + memberId = 1L, + imageType = ImageType.POST_IMAGE, + contentType = "image/jpeg" + ) + + // When + val response = imageUploadRequester.requestPresignedPutUrl(request) + + // Then + assertNotNull(response.uploadUrl) + assertTrue(response.uploadUrl.contains("localhost")) + assertNotNull(response.key) + assertNotNull(response.expiresAt) + } + + @Test + fun `trackUploadCompletion - success - saves image metadata`() { + // Given: Presigned URL 발급 + val uploadRequest = ImageUploadRequest(...) + val presignedResponse = imageUploadRequester.requestPresignedPutUrl(uploadRequest) + + // When: 업로드 완료 추적 + val result = imageUploadTracker.trackUploadCompletion( + key = presignedResponse.key, + memberId = 1L + ) + + // Then: 메타데이터 저장 확인 + assertTrue(result.success) + assertNotNull(result.imageId) + } +} +``` + +### 이미지 테스트 시나리오 + +#### 1. Presigned URL 발급 테스트 + +```kotlin +@Test +fun `image upload request - success - returns presigned PUT URL`() { + mockMvc.perform( + post("/api/images/upload-request") + .with(csrf()) + .contentType(MediaType.APPLICATION_JSON) + .content( + """ + { + "imageType": "POST_IMAGE", + "contentType": "image/jpeg" + } + """.trimIndent() + ) + ) + .andExpect(status().isOk) + .andExpect(jsonPath("$.uploadUrl").exists()) + .andExpect(jsonPath("$.key").exists()) + .andExpect(jsonPath("$.expiresAt").exists()) +} +``` + +#### 2. 업로드 완료 추적 테스트 + +```kotlin +@Test +fun `image upload confirmation - success - saves metadata to database`() { + // Given: Presigned URL 발급 + val uploadResponse = requestPresignedUrl() + + // When: 업로드 완료 알림 + mockMvc.perform( + post("/api/images/upload-confirm") + .with(csrf()) + .param("key", uploadResponse.key) + ) + .andExpect(status().isOk) + .andExpect(jsonPath("$.success").value(true)) + .andExpect(jsonPath("$.imageId").exists()) +} +``` + +#### 3. 이미지 삭제 테스트 + +```kotlin +@Test +fun `delete image - success - soft deletes image`() { + // Given: 이미지 업로드 + val image = createTestImage() + + // When: 삭제 요청 + mockMvc.perform( + delete("/api/images/${image.id}") + .with(csrf()) + ) + .andExpect(status().isOk) + + // Then: Soft Delete 확인 + val deletedImage = imageRepository.findById(image.id!!).get() + assertTrue(deletedImage.isDeleted) +} +``` + +#### 4. 일일 업로드 제한 테스트 + +```kotlin +@Test +fun `upload request - failure - when daily limit exceeded`() { + // Given: 100개 이미지 업로드 완료 + repeat(100) { uploadImage() } + + // When: 101번째 업로드 시도 + mockMvc.perform( + post("/api/images/upload-request") + .with(csrf()) + .contentType(MediaType.APPLICATION_JSON) + .content("""{"imageType": "POST_IMAGE", "contentType": "image/jpeg"}""") + ) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.error").value("일일 업로드 한도를 초과했습니다")) +} +``` + +### LocalStack 설정 확인 + +Testcontainers Configuration에서 LocalStack S3가 자동으로 시작됩니다: + +```kotlin +@TestConfiguration(proxyBeanMethods = false) +class TestcontainersConfiguration { + + @Bean + @ServiceConnection + fun localStackContainer(): LocalStackContainer { + return LocalStackContainer(DockerImageName.parse("localstack/localstack:latest")) + .withServices(LocalStackContainer.Service.S3) + .withEnv("DEBUG", "1") + } +} +``` + +--- + +## 📊 테스트 커버리지 목표 + +- **전체 커버리지**: 80% 이상 유지 (SonarCloud 품질 게이트) +- **도메인 계층**: 90% 이상 +- **애플리케이션 계층**: 85% 이상 +- **어댑터 계층**: 75% 이상 + +--- + +## 🔍 테스트 작성 체크리스트 + +새로운 기능을 구현할 때 다음 테스트들을 작성하세요: + +- [ ] **성공 시나리오**: 정상적인 입력에 대한 성공 케이스 +- [ ] **실패 시나리오**: 잘못된 입력, 권한 없음, 리소스 없음 등 +- [ ] **경계값 테스트**: 최소/최대 길이, 0, null 등 +- [ ] **동시성 테스트**: 여러 사용자가 동시에 접근하는 경우 +- [ ] **권한 테스트**: 인증/인가 검증 +- [ ] **이벤트 발행 테스트**: 도메인 이벤트가 올바르게 발행되는지 + +--- diff --git a/docs/TODO.md b/docs/TODO.md index de991c6c..b03bd949 100644 --- a/docs/TODO.md +++ b/docs/TODO.md @@ -1,269 +1,476 @@ -# RMRT 프로젝트 TODO 목록 +# 🧪 테스트 가이드 -> 본 문서는 RMRT(Real Money Real Taste) 프로젝트의 향후 개발 및 개선 작업 목록을 정리합니다. +## 🎯 테스트 철학 -## 📋 목차 +RMRT는 **실제 사용 시나리오**를 중심으로 테스트합니다. Mock을 최소화하고 실제 데이터베이스와 Spring Context를 사용하여 통합 테스트를 선호합니다. -- [1. 인프라 및 배포](#1-인프라-및-배포) -- [2. 파일 관리 및 이미지 처리](#2-파일-관리-및-이미지-처리) -- [3. 이벤트 시스템 고도화](#3-이벤트-시스템-고도화) -- [4. 핵심 도메인 기능 현황](#4-핵심-도메인-기능-현황) -- [5. 성능 및 최적화](#5-성능-및-최적화) -- [6. 보안 강화](#6-보안-강화) -- [7. 기능 확장](#7-기능-확장) -- [8. 모니터링 및 운영](#8-모니터링-및-운영) -- [9. 문서 및 테스트](#9-문서-및-테스트) +- **통합 테스트 우선**: 단위 테스트보다 통합 테스트 중심 +- **실제 데이터**: Testcontainers MySQL 사용 +- **인증 테스트**: `@WithMockMember`로 실제 인증 시나리오 +- **CSRF 보호**: 모든 POST/PUT/DELETE 요청에 CSRF 적용 --- -## 1. 인프라 및 배포 +## 🛠 테스트 도구 스택 -### 🚀 배포 자동화 +### 핵심 도구 -| 작업 항목 | 상태 | 우선순위 | 상세 설명 | -|----------------------|----|-------|-------------------------------| -| Dockerfile 생성 | ✅ | 🔴 P0 | Multi-stage 빌드 구성, 경량 이미지 최적화 | -| Docker Compose 설정 | ✅ | 🔴 P0 | 개발 환경 구성 (앱 + DB + Redis) | -| GitHub Actions 확장 | ✅ | 🟡 P1 | 현재 SonarQube → 완전한 CI/CD로 확장 | -| Blue-Green 배포 구현 | ✅ | 🟡 P1 | ECS 롤링 업데이트, 헬스체크 자동화 | -| ECS/EKS 컨테이너 오케스트레이션 | ✅ | 🟢 P2 | 서비스 디스커버리, 오토스케일링 | -| RDS 데이터베이스 구성 | ✅ | 🟢 P2 | MySQL 8.0, 읽기 전용 복제본 | -| ElastiCache Redis 구성 | ☐ | 🟢 P2 | 캐시 레이어, 세션 저장소 | +- **JUnit 5**: 테스트 프레임워크 +- **MockK**: Mock 객체 생성 (Kotlin 친화적) +- **Testcontainers**: 실제 Docker MySQL 컨테이너 +- **LocalStack**: AWS S3 로컬 테스트 환경 +- **MockMvc**: 웹 계층 테스트 +- **Spring Boot Test**: 통합 테스트 지원 ---- - -## 2. 파일 관리 및 이미지 처리 +### 테스트 유틸리티 -### 📸 이미지 업로드 시스템 - -| 작업 항목 | 상태 | 우선순위 | 상세 설명 | -|-------------------|----|-------|---------------------------------| -| AWS S3 버킷 구성 | ✅ | 🔴 P0 | 버킷 정책, CORS, 접근 제어 | -| 이미지 업로드 API 개발 | ✅ | 🔴 P0 | Multipart 파일 업로드, Presigned URL | -| 이미지 최적화 | ☐ | 🟡 P1 | 리사이징, WebP 변환, 압축 | -| CloudFront CDN 연동 | ☐ | 🟡 P1 | 이미지 캐싱, 글로벌 배포 | -| 파일 메타데이터 관리 | ☐ | 🟢 P2 | 업로드 이력, 용량 모니터링 | -| 파일 보안 강화 | ☐ | 🟢 P2 | 타입 검증, 바이러스 스캔 | +- **IntegrationTestBase**: 모든 통합 테스트의 기본 클래스 +- **TestMemberHelper**: 멤버 생성 유틸리티 +- **TestPostHelper**: 포스트 생성 유틸리티 +- **@WithMockMember**: 인증된 사용자 시뮬레이션 --- -## 3. 이벤트 시스템 고도화 - -### 🔄 이벤트 기반 아키텍처 - -| 작업 항목 | 상태 | 우선순위 | 상세 설명 | -|-------------------------------|----|-------|----------------------------| -| Spring Events → Message Queue | ☐ | 🟡 P1 | RabbitMQ/Kafka 연동, 이벤트 직렬화 | -| 이벤트 저장소 구현 | ☐ | 🟡 P1 | Event Sourcing, 스냅샷 전략 | -| 도메인 이벤트 추가 | ☐ | 🟢 P2 | CollectionUpdatedEvent 등 | -| 이벤트 핸들러 고도화 | ☐ | 🟢 P2 | 실패 재시도, 데드 레터 큐 | - -### ✅ 현재 구현된 이벤트 시스템 - -| 이벤트 리스너 | 구현 상태 | 설명 | -|---------------------|-------|----------------------| -| PostEventListener | ✅ | 좋아요/조회수 비동기 처리 | -| MemberEventListener | ✅ | 회원가입/비밀번호 재설정 이메일 발송 | +## 🏗 테스트 구조 + +### 기본 클래스 설정 + +```kotlin +@SpringBootTest +@Transactional +@TestConstructor(autowireMode = TestConstructor.AutowireMode.ALL) +@Import(TestcontainersConfiguration::class, TestConfig::class) +@AutoConfigureMockMvc +abstract class IntegrationTestBase() { + @Autowired + protected lateinit var entityManager: EntityManager + + protected fun flushAndClear() { + entityManager.flush() + entityManager.clear() + } +} +``` + +### 테스트 클래스 패턴 + +```kotlin +class CollectionDeleteApiTest : IntegrationTestBase() { + + @Autowired + private lateinit var mockMvc: MockMvc + + @Autowired + private lateinit var testMemberHelper: TestMemberHelper + + @Autowired + private lateinit var collectionCreator: CollectionCreator + + @WithMockMember(email = MemberFixture.DEFAULT_USERNAME) + @Test + fun `deleteCollection - success - deletes own collection`() { + // Given: 테스트 데이터 준비 + val owner = testMemberHelper.getDefaultMember() + val collection = collectionCreator.createCollection(...) + + // When: API 호출 + mockMvc.perform( + delete("/api/collections/${collection.requireId()}") + .with(csrf()) + ) + .andExpect(status().isOk) + .andExpect(content().string("")) + + // Then: 결과 검증 + assertFailsWith { + collectionReader.readById(collection.requireId()) + } + } +} +``` --- -## 4. 핵심 도메인 기능 현황 - -### 👥 회원 관리 시스템 - -| 작업 항목 | 상태 | 우선순위 | 상세 설명 | -|----------------|----|-------|--------------------------------| -| 회원가입 및 이메일 인증 | ✅ | - | 이메일 인증, ActivationToken 관리 | -| 로그인/로그아웃 | ✅ | - | Spring Security 기반 인증 | -| 비밀번호 재설정 | ✅ | - | 이메일 기반 비밀번호 재설정 | -| 회원 정보 수정 | ✅ | - | 프로필, 자기소개 등 개인정보 수정 | -| 소셜 로그인 연동 | ☐ | 🟡 P1 | Google, Kakao, Naver OAuth 2.0 | -| 2FA (2단계 인증) | ☐ | 🟡 P1 | SMS, Google Authenticator | -| 회원 탈퇴 및 데이터 보존 | ☐ | 🟢 P2 | Soft Delete, 개인정보 보관 정책 | -| 프로필 이미지 S3 연동 | ✅ | 🔴 P0 | 이미지 업로드, 리사이징, CDN | - -### 📝 게시글 및 리뷰 시스템 - -| 작업 항목 | 상태 | 우선순위 | 상세 설명 | -|--------------|----|-------|---------------------------------| -| 게시글 생성/수정/삭제 | ✅ | - | 내돈내산/광고성 리뷰 구분, 이미지 최대 5장 | -| 게시글 조회 및 검색 | ✅ | - | 페이징, 정렬, 위치 기반 검색 | -| 좋아요 기능 | ✅ | - | 비동기 좋아요/취소, 카운트 관리 | -| 게시글 이미지 관리 | ✅ | - | PostImages Value Object, URL 관리 | -| 게시글 위치 기반 검색 | ☐ | 🔴 P0 | 카카오/네이버 지도 API 연동, 주소 검색 | -| 게시글 신고 기능 | ☐ | 🟡 P1 | 부적절한 콘텐츠 신고, 관리자 검토 | -| 게시글 추천 알고리즘 | ☐ | 🟢 P2 | 사용자 기반 추천, 인기 게시글 | -| 게시글 통계 및 분석 | ☐ | 🟢 P2 | 조회수, 좋아요, 작성 시간 등 분석 | -| 게시글 임시 저장 기능 | ☐ | 🟢 P2 | 자동 저장, 초안 관리 | - -### 📁 컬렉션 시스템 - -| 작업 항목 | 상태 | 우선순위 | 상세 설명 | -|----------------|----|-------|----------------------| -| 컬렉션 생성/수정/삭제 | ✅ | - | 공개/비공개 설정, 소유자 권한 관리 | -| 컬렉션에 게시글 추가/제거 | ✅ | - | 모달 기반 동적 관리, HTMX 통합 | -| 컬렉션 조회 및 공유 | ✅ | - | 소유자별 조회, 공유 링크 | -| 컬렉션 공유 기능 | ☐ | 🟢 P2 | 소셜 미디어 연동, 임베드 코드 | -| 컬렉션 추천 시스템 | ☐ | ⚪ P3 | AI 기반 추천, 인기 컬렉션 순위 | -| 컬렉션 협업 기능 | ☐ | 🟢 P2 | 여러 사용자의 컬렉션 공동 편집 | -| 컬렉션 카테고리 및 태그 | ☐ | 🟢 P2 | 체계적인 분류, 검색 기능 강화 | - -### 👥 친구 관계 시스템 - -| 작업 항목 | 상태 | 우선순위 | 상세 설명 | -|---------------|----|-------|-----------------------------------| -| 친구 요청 및 수락/거절 | ✅ | - | PENDING → ACCEPTED/REJECTED 상태 전이 | -| 친구 목록 조회 및 관리 | ✅ | - | 양방향 친구 관계, 상태별 조회 | -| 친구 관계 해제 | ✅ | - | UNFRIENDED 상태, 이벤트 발행 | -| 친구 추천 시스템 | ☐ | 🟢 P2 | 상호 친구, 관심사 기반 추천 | -| 친구 그룹/리스트 관리 | ☐ | 🟢 P2 | 친구 분류, 그룹별 공유 기능 | -| 친구 활동 피드 | ☐ | 🟡 P1 | 친구들의 최신 활동 표시 | - -### 🔔 팔로우 시스템 - -| 작업 항목 | 상태 | 우선순위 | 상세 설명 | -|---------------|----|-------|------------------------------| -| 팔로우/언팔로우 기능 | ✅ | - | 단방향 관계, ACTIVE/UNFOLLOWED 상태 | -| 팔로워/팔로잉 목록 조회 | ✅ | - | 페이징, 정렬, 상세 정보 조회 | -| 팔로우 상태 확인 | ✅ | - | 특정 사용자와의 팔로우 관계 확인 | -| 실시간 팔로우 알림 | ☐ | 🟡 P1 | WebSocket, 푸시 알림 | -| 팔로우 추천 | ☐ | 🟢 P2 | 관심사 기반, 맞팔 추천 | -| 팔로우 차단 기능 | ☐ | 🟢 P2 | BLOCKED 상태, 특정 사용자 차단 | - -### 💬 댓글 시스템 - -| 작업 항목 | 상태 | 우선순위 | 상세 설명 | -|-------------|----|-------|----------------------| -| 댓글 생성/수정/삭제 | ✅ | - | Soft Delete, 댓글 수 계산 | -| 대댓글 기능 | ✅ | - | 계층적 댓글 구조, 부모-자식 관계 | -| 댓글 조회 및 페이징 | ✅ | - | 게시글별 댓글, 정렬 기능 | -| 댓글 신고 기능 | ☐ | 🟡 P1 | 부적절한 댓글 신고, 관리자 검토 | -| 댓글 좋아요 기능 | ☐ | 🟢 P2 | 댓글별 좋아요, 비동기 처리 | -| 댓글 알림 | ☐ | 🟡 P1 | 게시글 작성자에게 댓글 알림 | -| 댓글 필터링 및 정렬 | ☐ | 🟢 P2 | 최신순, 인기순, 작성자 필터 | +## 🔐 인증 테스트 ---- +### @WithMockMember 사용 -## 5. 성능 및 최적화 +```kotlin +@WithMockMember(email = "test@example.com", nickname = "테스트") +@Test +fun `authenticated request test`() { + // 인증된 상태로 테스트 실행 +} +``` -### ⚡ 데이터베이스 최적화 +### 인증 실패 테스트 -| 작업 항목 | 상태 | 우선순위 | 상세 설명 | -|----------------|----|-------|----------------------------| -| 인덱스 전략 개선 | ☐ | 🟡 P1 | 복합 인덱스 추가, 쿼리 실행 계획 분석 | -| N+1 문제 해결 | ☐ | 🟡 P1 | Fetch Join, DTO Projection | -| Redis 캐시 구현 | ☐ | 🟡 P1 | 조회 성능 개선, 캐시 무효화 | -| Caffeine 로컬 캐시 | ☐ | 🟢 P2 | 애플리케이션 레벨 캐싱 | -| JVM 튜닝 | ☐ | 🟢 P2 | G1GC, 메모리 할당 최적화 | -| 프로파일링 도구 연동 | ☐ | 🟢 P2 | Micrometer, APM 도구 | +```kotlin +@Test +fun `deleteCollection - forbidden - when not authenticated`() { + mockMvc.perform( + delete("/api/collections/1") + .with(csrf()) + ) + .andExpect(status().isForbidden()) +} +``` --- -## 6. 보안 강화 - -### 🔐 인증 및 인가 - -| 작업 항목 | 상태 | 우선순위 | 상세 설명 | -|------------------|----|-------|--------------------------------| -| 소셜 로그인 연동 | ☐ | 🟡 P1 | Google, Kakao, Naver OAuth 2.0 | -| 2FA (2단계 인증) | ☐ | 🟡 P1 | SMS, Google Authenticator | -| Rate Limiting 구현 | ☐ | 🔴 P0 | API 호출 제한, IP 차단 | -| 데이터 암호화 | ☐ | 🟢 P2 | 민감정보, 전송 계층 보안 | +## 📝 MockMvc 테스트 패턴 + +### API 테스트 + +```kotlin +// 성공 케이스 +mockMvc.perform( + delete("/api/collections/${collection.requireId()}") + .with(csrf()) +) + .andExpect(status().isOk) + .andExpect(content().string("")) + +// 에러 응답 검증 +mockMvc.perform( + delete("/api/collections/${collection.requireId()}") + .with(csrf()) +) + .andExpect(status().isBadRequest()) + .andExpect(content().contentType("application/json")) + .andExpect(jsonPath("$.success").value(false)) + .andExpect(jsonPath("$.error").value("컬렉션을 삭제할 수 없습니다.")) +``` + +### WebView 테스트 + +```kotlin +mockMvc.perform( + get(CollectionUrls.MY_LIST_FRAGMENT) + .param("filter", CollectionFilter.PUBLIC.name) +) + .andExpect(status().isOk) + .andExpect(view().name(CollectionViews.MY_LIST)) + .andExpect(model().attributeExists("collections")) + .andExpect(model().attributeExists("member")) + +// 반환된 모델 데이터 검증 +val result = mockMvc.perform(...).andReturn() +val collections = result.modelAndView!!.model["collections"] as Page<*> +assertEquals(1, collections.content.size) +``` --- -## 7. 기능 확장 - -### 🎯 핵심 기능 고도화 - -| 작업 항목 | 상태 | 우선순위 | 상세 설명 | -|------------|----|-------|------------------| -| 컬렉션 공유 기능 | ☐ | 🟢 P2 | 링크 공유, 소셜 미디어 연동 | -| 컬렉션 추천 시스템 | ☐ | ⚪ P3 | AI 기반 추천, 인기 순위 | -| 실시간 알림 | ☐ | 🟡 P1 | WebSocket, 푸시 알림 | -| 커뮤니티 기능 | ☐ | ⚪ P3 | 그룹 채팅, 게시판, 해시태그 | - -### 📊 분석 및 리포팅 - -| 작업 항목 | 상태 | 우선순위 | 상세 설명 | -|---------------------|----|-------|--------------------| -| Google Analytics 연동 | ☐ | 🟢 P2 | 사용자 행동 분석, 커스텀 이벤트 | -| 비즈니스 대시보드 | ☐ | 🟢 P2 | 리포트 자동화, 예측 분석 | +## 🏭 테스트 데이터 생성 + +### TestMemberHelper 사용 + +```kotlin +// 기본 멤버 생성 +val member = testMemberHelper.getDefaultMember() + +// 커스텀 멤버 생성 +val customMember = testMemberHelper.createActivatedMember( + email = "custom@test.com", + nickname = "커스텀" +) + +// 비활성 멤버 생성 +val inactiveMember = testMemberHelper.createMember( + email = "inactive@test.com" +) +``` + +### TestPostHelper 사용 + +```kotlin +val post = testPostHelper.createPost( + authorMemberId = member.requireId(), + authorNickname = "작성자", + restaurant = Restaurant("식당", "주소"), + content = PostContent("맛있어요!") +) +``` + +### 직접 도메인 생성 + +```kotlin +val collection = collectionCreator.createCollection( + CollectionCreateCommand( + name = "테스트 컬렉션", + description = "설명", + ownerMemberId = owner.requireId(), + ownerName = owner.nickname.value + ) +) +``` --- -## 8. 모니터링 및 운영 - -### 📈 모니터링 시스템 - -| 작업 항목 | 상태 | 우선순위 | 상세 설명 | -|--------------|----|-------|--------------------------| -| ELK Stack 구축 | ☐ | 🟡 P1 | 중앙 로그 수집, 분석 | -| 애플리케이션 헬스체크 | ☐ | 🟡 P1 | Health Indicator, 의존성 상태 | -| 알림 시스템 연동 | ☐ | 🟢 P2 | Slack, SMS, 이메일 알림 | +## 🎯 테스트 시나리오 예제 + +### 성공 시나리오 + +```kotlin +@WithMockMember(email = MemberFixture.DEFAULT_USERNAME) +@Test +fun `createCollection - success - creates new collection`() { + val member = testMemberHelper.getDefaultMember() + + val result = mockMvc.perform( + post("/api/collections") + .with(csrf()) + .contentType(MediaType.APPLICATION_JSON) + .content( + """ + { + "name": "새 컬렉션", + "description": "설명" + } + """.trimIndent() + ) + ) + .andExpect(status().isOk) + .andExpect(jsonPath("$.success").value(true)) + .andExpect(jsonPath("$.collectionId").exists()) + .andReturn() + + // 생성된 컬렉션 확인 + val collectionId = result.response.jsonPath.getLong("collectionId") + assertNotNull(collectionReader.readById(collectionId)) +} +``` + +### 권한 없음 시나리오 + +```kotlin +@WithMockMember(email = MemberFixture.DEFAULT_USERNAME) +@Test +fun `deleteCollection - failure - when trying to delete other's collection`() { + val owner = testMemberHelper.createActivatedMember("other@user.com") + val collection = createCollectionForOwner(owner) + + mockMvc.perform( + delete("/api/collections/${collection.requireId()}") + .with(csrf()) + ) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.error").value("컬렉션을 삭제할 수 없습니다.")) +} +``` + +### 이벤트 발행 테스트 + +```kotlin +@RecordApplicationEvents +@Test +fun `unfriend - success - publishes friendship terminated event`() { + // 친구 관계 생성 및 삭제 + + val events = applicationEvents + .stream(FriendshipTerminatedEvent::class.java) + .toList() + + assertEquals(1, events.size) + assertEquals(member1.requireId(), events[0].memberId) +} +``` --- -## 9. 문서 및 테스트 +## 🔄 테스트 실행 방법 -### 📚 문서화 +### 전체 테스트 실행 -| 작업 항목 | 상태 | 우선순위 | 상세 설명 | -|----------------|----|-------|-----------------------| -| OpenAPI 3.0 명세 | ☐ | 🟢 P2 | Swagger UI, API 예제 코드 | -| 기술 문서 완성 | ☐ | 🟢 P2 | 아키텍처 다이어그램, 배포 가이드 | +```bash +./gradlew test +``` -### 🧪 테스트 자동화 +### 특정 테스트만 실행 -| 작업 항목 | 상태 | 우선순위 | 상세 설명 | -|-------------------|----|-------|----------------------| -| 통합 테스트 확장 | ☐ | 🟡 P1 | API 테스트, E2E 테스트 | -| TestContainers 활용 | ☐ | 🟢 P2 | 테스트 데이터 관리, Mock 데이터 | +```bash +# API 테스트만 +./gradlew test --tests "*ApiTest*" ---- +# WebView 테스트만 +./gradlew test --tests "*ViewTest*" -## 🎯 우선순위 가이드 +# 애플리케이션 테스트만 +./gradlew test --tests "*application*" -### 우선순위 표기 +# 특정 클래스 테스트 +./gradlew test --tests "*CollectionDeleteApiTest*" +``` -- 🔴 **긴급 (P0)**: 즉시 필요한 핵심 기능 -- 🟡 **중요 (P1)**: 조기 구현이 필요한 기능 -- 🟢 **보통 (P2)**: 단계적으로 구현할 기능 -- ⚪ **낮음 (P3)**: 장기적으로 고려할 기능 +### 테스트 커버리지 확인 -### 상태 표기 - -- ✅ **완료**: 이미 구현된 기능 -- ☐ **미구현**: 구현이 필요한 기능 -- 🚧 **진행중**: 현재 개발 중인 기능 -- ⚠️ **이슈**: 문제가 있는 기능 -- 🔍 **검토**: 기술 검토가 필요한 기능 +```bash +./gradlew jacocoTestReport +# 결과: build/reports/jacoco/test/html/index.html +``` --- -## 📝 사용 가이드 - -### 체크박스 사용법 - - -| 작업 항목 | 상태 | 우선순위 | 상세 설명 | -|--------|----|-------|-------| -| 새로운 작업 | ☐ | 🔴 P0 | 작업 설명 | - -### 상태 업데이트 - -- 작업 완료 시: `☐` → `✅` -- 진행 중 시: `☐` → `🚧` -- 이슈 발생 시: `☐` → `⚠️` -- 검토 필요 시: `☐` → `🔍` - -### 새로운 항목 추가 - -1. 해당 섹션의 테이블에 새 행 추가 -2. 적절한 우선순위 지정 -3. 상세 설명 작성 -4. 상태는 `☐`으로 시작 +## 🖼 이미지 관리 시스템 테스트 + +### LocalStack S3 테스트 + +이미지 업로드/조회/삭제 기능은 LocalStack을 통해 실제 S3 환경과 동일하게 테스트됩니다: + +```kotlin +@SpringBootTest +@Import(TestcontainersConfiguration::class) +class ImageUploadServiceTest { + + @Autowired + private lateinit var imageUploadRequester: ImageUploadRequester + + @Autowired + private lateinit var imageUploadTracker: ImageUploadTracker + + @Test + fun `requestPresignedPutUrl - success - returns valid presigned URL`() { + // Given + val request = ImageUploadRequest( + memberId = 1L, + imageType = ImageType.POST_IMAGE, + contentType = "image/jpeg" + ) + + // When + val response = imageUploadRequester.requestPresignedPutUrl(request) + + // Then + assertNotNull(response.uploadUrl) + assertTrue(response.uploadUrl.contains("localhost")) + assertNotNull(response.key) + assertNotNull(response.expiresAt) + } + + @Test + fun `trackUploadCompletion - success - saves image metadata`() { + // Given: Presigned URL 발급 + val uploadRequest = ImageUploadRequest(...) + val presignedResponse = imageUploadRequester.requestPresignedPutUrl(uploadRequest) + + // When: 업로드 완료 추적 + val result = imageUploadTracker.trackUploadCompletion( + key = presignedResponse.key, + memberId = 1L + ) + + // Then: 메타데이터 저장 확인 + assertTrue(result.success) + assertNotNull(result.imageId) + } +} +``` + +### 이미지 테스트 시나리오 + +#### 1. Presigned URL 발급 테스트 + +```kotlin +@Test +fun `image upload request - success - returns presigned PUT URL`() { + mockMvc.perform( + post("/api/images/upload-request") + .with(csrf()) + .contentType(MediaType.APPLICATION_JSON) + .content( + """ + { + "imageType": "POST_IMAGE", + "contentType": "image/jpeg" + } + """.trimIndent() + ) + ) + .andExpect(status().isOk) + .andExpect(jsonPath("$.uploadUrl").exists()) + .andExpect(jsonPath("$.key").exists()) + .andExpect(jsonPath("$.expiresAt").exists()) +} +``` + +#### 2. 업로드 완료 추적 테스트 + +```kotlin +@Test +fun `image upload confirmation - success - saves metadata to database`() { + // Given: Presigned URL 발급 + val uploadResponse = requestPresignedUrl() + + // When: 업로드 완료 알림 + mockMvc.perform( + post("/api/images/upload-confirm") + .with(csrf()) + .param("key", uploadResponse.key) + ) + .andExpect(status().isOk) + .andExpect(jsonPath("$.success").value(true)) + .andExpect(jsonPath("$.imageId").exists()) +} +``` + +#### 3. 이미지 삭제 테스트 + +```kotlin +@Test +fun `delete image - success - soft deletes image`() { + // Given: 이미지 업로드 + val image = createTestImage() + + // When: 삭제 요청 + mockMvc.perform( + delete("/api/images/${image.id}") + .with(csrf()) + ) + .andExpect(status().isOk) + + // Then: Soft Delete 확인 + val deletedImage = imageRepository.findById(image.id!!).get() + assertTrue(deletedImage.isDeleted) +} +``` + +#### 4. 일일 업로드 제한 테스트 + +```kotlin +@Test +fun `upload request - failure - when daily limit exceeded`() { + // Given: 100개 이미지 업로드 완료 + repeat(100) { uploadImage() } + + // When: 101번째 업로드 시도 + mockMvc.perform( + post("/api/images/upload-request") + .with(csrf()) + .contentType(MediaType.APPLICATION_JSON) + .content("""{"imageType": "POST_IMAGE", "contentType": "image/jpeg"}""") + ) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.error").value("일일 업로드 한도를 초과했습니다")) +} +``` + +### LocalStack 설정 확인 + +Testcontainers Configuration에서 LocalStack S3가 자동으로 시작됩니다: + +```kotlin +@TestConfiguration(proxyBeanMethods = false) +class TestcontainersConfiguration { + + @Bean + @ServiceConnection + fun localStackContainer(): LocalStackContainer { + return LocalStackContainer(DockerImageName.parse("localstack/localstack:latest")) + .withServices(LocalStackContainer.Service.S3) + .withEnv("DEBUG", "1") + } +} +``` --- - -*본 TODO 목록은 프로젝트 진행 상황에 따라 지속적으로 업데이트됩니다.* diff --git a/src/main/kotlin/com/albert/realmoneyrealtaste/adapter/infrastructure/s3/S3PresignedUrlGenerator.kt b/src/main/kotlin/com/albert/realmoneyrealtaste/adapter/infrastructure/s3/S3PresignedUrlGenerator.kt index b96c9cad..7fb53347 100644 --- a/src/main/kotlin/com/albert/realmoneyrealtaste/adapter/infrastructure/s3/S3PresignedUrlGenerator.kt +++ b/src/main/kotlin/com/albert/realmoneyrealtaste/adapter/infrastructure/s3/S3PresignedUrlGenerator.kt @@ -49,8 +49,6 @@ class S3PresignedUrlGenerator( val presignedRequest = s3Presigner.presignPutObject(presignRequest) - logger.info("Generated presigned PUT URL for key: $imageKey") - return PresignedPutResponse( uploadUrl = presignedRequest.url().toString(), key = imageKey, @@ -75,8 +73,6 @@ class S3PresignedUrlGenerator( val presignedRequest = s3Presigner.presignGetObject(presignRequest) - logger.info("Generated presigned GET URL for key: $imageKey") - return presignedRequest.url().toString() } } diff --git a/src/main/kotlin/com/albert/realmoneyrealtaste/adapter/infrastructure/security/MemberPrincipal.kt b/src/main/kotlin/com/albert/realmoneyrealtaste/adapter/infrastructure/security/MemberPrincipal.kt index 270ed068..bb1051e5 100644 --- a/src/main/kotlin/com/albert/realmoneyrealtaste/adapter/infrastructure/security/MemberPrincipal.kt +++ b/src/main/kotlin/com/albert/realmoneyrealtaste/adapter/infrastructure/security/MemberPrincipal.kt @@ -17,10 +17,10 @@ data class MemberPrincipal( val introduction: String, val address: String, val createdAt: LocalDateTime, - val profileImageUrl: String = "#", + val imageId: Long, private val roles: Set, - val followersCount: Long = 0L, - val followingsCount: Long = 0L, + val followersCount: Long, + val followingsCount: Long, ) : Serializable { fun getAuthorities(): Collection { @@ -33,16 +33,18 @@ data class MemberPrincipal( companion object { fun from(member: Member): MemberPrincipal { - val introValue = member.detail.introduction?.value return MemberPrincipal( id = member.requireId(), email = member.email, nickname = member.nickname, roles = member.roles.getRoles(), active = member.isActive(), - introduction = introValue ?: "아직 자기소개가 없어요!", - address = member.detail.address ?: "아직 주소가 없어요!", - createdAt = member.detail.registeredAt, + introduction = member.introduction, + address = member.address, + createdAt = member.registeredAt, + imageId = member.imageId, + followersCount = member.followersCount, + followingsCount = member.followingsCount, ) } } diff --git a/src/main/kotlin/com/albert/realmoneyrealtaste/adapter/webapi/image/ImageApi.kt b/src/main/kotlin/com/albert/realmoneyrealtaste/adapter/webapi/image/ImageApi.kt index ad376bce..afa00a66 100644 --- a/src/main/kotlin/com/albert/realmoneyrealtaste/adapter/webapi/image/ImageApi.kt +++ b/src/main/kotlin/com/albert/realmoneyrealtaste/adapter/webapi/image/ImageApi.kt @@ -61,6 +61,15 @@ class ImageApi( return ResponseEntity.ok(result) } + @GetMapping("/{imageId}") + fun getImageRedirect( + @PathVariable imageId: Long, + @AuthenticationPrincipal member: MemberPrincipal, + ): ResponseEntity { + val url = imageReader.getImageUrl(imageId, member.id) + return ResponseEntity.status(302).header("Location", url).build() + } + @GetMapping("/{imageId}/url") fun getImageUrl( @PathVariable imageId: Long, diff --git a/src/main/kotlin/com/albert/realmoneyrealtaste/adapter/webview/HomeView.kt b/src/main/kotlin/com/albert/realmoneyrealtaste/adapter/webview/HomeView.kt index c9821ed2..953723e0 100644 --- a/src/main/kotlin/com/albert/realmoneyrealtaste/adapter/webview/HomeView.kt +++ b/src/main/kotlin/com/albert/realmoneyrealtaste/adapter/webview/HomeView.kt @@ -1,105 +1,22 @@ package com.albert.realmoneyrealtaste.adapter.webview import com.albert.realmoneyrealtaste.adapter.infrastructure.security.MemberPrincipal -import com.albert.realmoneyrealtaste.adapter.webview.post.PostCreateForm -import com.albert.realmoneyrealtaste.application.follow.provided.FollowReader -import com.albert.realmoneyrealtaste.application.member.provided.MemberReader -import com.albert.realmoneyrealtaste.application.post.provided.PostHeartReader -import com.albert.realmoneyrealtaste.application.post.provided.PostReader -import com.albert.realmoneyrealtaste.domain.member.Member -import com.albert.realmoneyrealtaste.domain.post.Post -import com.albert.realmoneyrealtaste.domain.post.PostHeart -import org.springframework.data.domain.Page -import org.springframework.data.domain.Pageable -import org.springframework.data.domain.Sort -import org.springframework.data.web.PageableDefault +import com.albert.realmoneyrealtaste.adapter.webview.post.form.PostCreateForm import org.springframework.security.core.annotation.AuthenticationPrincipal import org.springframework.stereotype.Controller import org.springframework.ui.Model import org.springframework.web.bind.annotation.GetMapping @Controller -class HomeView( - private val postReader: PostReader, - private val memberReader: MemberReader, - private val postHeartReader: PostHeartReader, - private val followReader: FollowReader, -) { +class HomeView { @GetMapping("/") fun home( model: Model, - @PageableDefault(size = 10, sort = ["createdAt"], direction = Sort.Direction.DESC) pageable: Pageable, @AuthenticationPrincipal memberPrincipal: MemberPrincipal?, ): String { model.addAttribute("postCreateForm", PostCreateForm()) - - val posts = addPosts(pageable, model) - - val postIds = posts.content.map { it.requireId() } - - if (memberPrincipal != null) { - val memberId = memberPrincipal.id - // 사용자 정보 추가 - addMemberInfo(memberId, model) - - // 사용자 통계 정보 추가 - addMemberStats(model, memberId) - - // 좋아요 정보 추가 - addHeartsStats(memberId, postIds, model) - - // 추천 사용자 목록 추가 (옵션) - val addSuggestedMembers = addSuggestedMembers(model, memberId) - val suggestedMemberIds = addSuggestedMembers.map { it.requireId() } - - // following - addFollowingStatus(model, memberPrincipal.id, suggestedMemberIds) - } - + model.addAttribute("member", memberPrincipal) return "index" } - - private fun addPosts( - pageable: Pageable, - model: Model, - ): Page { - val posts = postReader.readAllPosts(pageable) - model.addAttribute("posts", posts) - return posts - } - - private fun addMemberInfo(memberId: Long, model: Model) { - val member = memberReader.readMemberById(memberId) - model.addAttribute("member", member) - } - - private fun addHeartsStats( - memberId: Long, - postIds: List, - model: Model, - ) { - val hearts = postHeartReader.findByMemberIdAndPostIds(memberId, postIds) - .map(PostHeart::postId) - model.addAttribute("hearts", hearts) - } - - private fun addMemberStats(model: Model, memberId: Long) { - val followStats = followReader.getFollowStats(memberId) - model.addAttribute("followStats", followStats) - - val postCount = postReader.countPostsByMemberId(memberId) - model.addAttribute("postCount", postCount) - } - - private fun addSuggestedMembers(model: Model, memberId: Long): List { - val suggestedUsers = memberReader.findSuggestedMembers(memberId, 5) - model.addAttribute("suggestedUsers", suggestedUsers) - return suggestedUsers - } - - private fun addFollowingStatus(model: Model, followerId: Long, suggestedIds: List) { - val followings = followReader.findFollowings(followerId, suggestedIds) - model.addAttribute("followings", followings) - } } diff --git a/src/main/kotlin/com/albert/realmoneyrealtaste/adapter/webview/collection/CollectionReadView.kt b/src/main/kotlin/com/albert/realmoneyrealtaste/adapter/webview/collection/CollectionReadView.kt index 3727648e..084bc3d8 100644 --- a/src/main/kotlin/com/albert/realmoneyrealtaste/adapter/webview/collection/CollectionReadView.kt +++ b/src/main/kotlin/com/albert/realmoneyrealtaste/adapter/webview/collection/CollectionReadView.kt @@ -2,8 +2,6 @@ package com.albert.realmoneyrealtaste.adapter.webview.collection import com.albert.realmoneyrealtaste.adapter.infrastructure.security.MemberPrincipal import com.albert.realmoneyrealtaste.application.collection.provided.CollectionReader -import com.albert.realmoneyrealtaste.application.member.provided.MemberReader -import com.albert.realmoneyrealtaste.application.post.provided.PostReader import com.albert.realmoneyrealtaste.domain.collection.value.CollectionFilter import org.springframework.data.domain.Pageable import org.springframework.data.domain.Sort @@ -18,8 +16,6 @@ import org.springframework.web.bind.annotation.RequestParam @Controller class CollectionReadView( private val collectionReader: CollectionReader, - private val memberReader: MemberReader, - private val postReader: PostReader, ) { @GetMapping(CollectionUrls.MY_LIST_FRAGMENT) @@ -42,7 +38,8 @@ class CollectionReadView( ) } model.addAttribute("collections", collections) - setCommonModelAttributes(model, principal, principal.id) + model.addAttribute("member", principal) + model.addAttribute("authorId", principal.id) return CollectionViews.MY_LIST } @@ -53,16 +50,11 @@ class CollectionReadView( model: Model, @PageableDefault(sort = ["createdAt"], direction = Sort.Direction.DESC, size = 5) pageRequest: Pageable, ): String { - val collection = collectionReader.readById(collectionId) - - setCommonModelAttributes(model, principal) - - model.addAttribute("collection", collection) - - model.addAttribute("posts", postReader.readPostsByIds(collection.posts.postIds)) - val myPosts = postReader.readPostsByAuthor(principal.id, pageRequest) - model.addAttribute("myPosts", myPosts) + val collectionDetail = collectionReader.readDetail(principal.id, collectionId, pageRequest) + model.addAttribute("collection", collectionDetail.collection) + model.addAttribute("posts", collectionDetail.posts) + model.addAttribute("myPosts", collectionDetail.myPosts) return CollectionViews.COLLECTION_POSTS_FRAGMENT } @@ -75,7 +67,6 @@ class CollectionReadView( ): String { val collection = collectionReader.readById(collectionId) model.addAttribute("collection", collection) - setCommonModelAttributes(model, principal) return CollectionViews.DETAIL_EDIT_FRAGMENT } @@ -88,7 +79,7 @@ class CollectionReadView( val collection = collectionReader.readById(collectionId) model.addAttribute("collection", collection) - setCommonModelAttributes(model, principal) + model.addAttribute("member", principal) return CollectionViews.DETAIL_FRAGMENT } @@ -113,7 +104,8 @@ class CollectionReadView( ) model.addAttribute("collections", collections) - setCommonModelAttributes(model, principal, id) + model.addAttribute("member", principal) + model.addAttribute("authorId", id) return CollectionViews.MY_LIST } @@ -130,21 +122,7 @@ class CollectionReadView( val collection = collectionReader.readById(collectionId) model.addAttribute("collection", collection) - setCommonModelAttributes(model, principal, memberId) - return CollectionViews.DETAIL_FRAGMENT - } - - /** - * 공통 Model 속성 설정 - */ - private fun setCommonModelAttributes( - model: Model, - principal: MemberPrincipal?, - authorId: Long? = null, - ) { model.addAttribute("member", principal) - authorId?.let { - model.addAttribute("author", memberReader.readMemberById(it)) - } + return CollectionViews.DETAIL_FRAGMENT } } diff --git a/src/main/kotlin/com/albert/realmoneyrealtaste/adapter/webview/follow/FollowCreateView.kt b/src/main/kotlin/com/albert/realmoneyrealtaste/adapter/webview/follow/FollowCreateView.kt index 9d717540..76e7b5d8 100644 --- a/src/main/kotlin/com/albert/realmoneyrealtaste/adapter/webview/follow/FollowCreateView.kt +++ b/src/main/kotlin/com/albert/realmoneyrealtaste/adapter/webview/follow/FollowCreateView.kt @@ -3,12 +3,11 @@ package com.albert.realmoneyrealtaste.adapter.webview.follow import com.albert.realmoneyrealtaste.adapter.infrastructure.security.MemberPrincipal import com.albert.realmoneyrealtaste.application.follow.dto.FollowCreateRequest import com.albert.realmoneyrealtaste.application.follow.provided.FollowCreator -import org.springframework.http.ResponseEntity import org.springframework.security.core.annotation.AuthenticationPrincipal import org.springframework.stereotype.Controller +import org.springframework.ui.Model import org.springframework.web.bind.annotation.PathVariable import org.springframework.web.bind.annotation.PostMapping -import org.springframework.web.bind.annotation.ResponseBody /** * 팔로우 생성 API @@ -22,11 +21,11 @@ class FollowCreateView( * 사용자 팔로우 */ @PostMapping("/members/{targetId}/follow") - @ResponseBody fun follow( @AuthenticationPrincipal principal: MemberPrincipal, @PathVariable targetId: Long, - ): ResponseEntity { + model: Model, + ): String { followCreator.follow( FollowCreateRequest( followerId = principal.id, @@ -34,16 +33,9 @@ class FollowCreateView( ) ) - // 언팔로우 버튼 HTML 조각 반환 - val followButtonHtml = """ - - """.trimIndent() + model.addAttribute("authorId", targetId) + model.addAttribute("isFollowing", true) - return ResponseEntity.ok(followButtonHtml) + return FollowViews.FOLLOW_TERMINATE_BUTTON } } diff --git a/src/main/kotlin/com/albert/realmoneyrealtaste/adapter/webview/follow/FollowView.kt b/src/main/kotlin/com/albert/realmoneyrealtaste/adapter/webview/follow/FollowReadView.kt similarity index 82% rename from src/main/kotlin/com/albert/realmoneyrealtaste/adapter/webview/follow/FollowView.kt rename to src/main/kotlin/com/albert/realmoneyrealtaste/adapter/webview/follow/FollowReadView.kt index 9f959837..57a5b03d 100644 --- a/src/main/kotlin/com/albert/realmoneyrealtaste/adapter/webview/follow/FollowView.kt +++ b/src/main/kotlin/com/albert/realmoneyrealtaste/adapter/webview/follow/FollowReadView.kt @@ -2,7 +2,6 @@ package com.albert.realmoneyrealtaste.adapter.webview.follow import com.albert.realmoneyrealtaste.adapter.infrastructure.security.MemberPrincipal import com.albert.realmoneyrealtaste.application.follow.provided.FollowReader -import com.albert.realmoneyrealtaste.application.member.provided.MemberReader import org.springframework.data.domain.Pageable import org.springframework.data.domain.Sort import org.springframework.data.web.PageableDefault @@ -18,10 +17,30 @@ import org.springframework.web.bind.annotation.RequestParam * 팔로잉/팔로워 목록을 조회하는 웹 페이지를 제공합니다. */ @Controller -class FollowView( +class FollowReadView( private val followReader: FollowReader, - private val memberReader: MemberReader, ) { + /** + * 팔로우 버튼 Fragment + */ + @GetMapping(FollowUrls.FOLLOW_BUTTON) + fun followButtonFragment( + @PathVariable authorId: Long, + @AuthenticationPrincipal principal: MemberPrincipal, + model: Model, + ): String { + // 현재 로그인한 사용자 정보 + val currentMemberId = principal.id + + // author 정보 조회 + model.addAttribute("authorId", authorId) + + val isFollowing = followReader.checkIsFollowing(currentMemberId, authorId) + model.addAttribute("isFollowing", isFollowing) + + return FollowViews.FOLLOW_BUTTON + } + /** * 내 팔로잉 목록 조회 */ @@ -47,14 +66,12 @@ class FollowView( pageable = pageRequest ) } - val author = memberReader.readMemberById(targetMemberId) // 현재 사용자가 팔로우하는 사용자 ID 목록 추가 val followingIds = followings.content.map { it.followingId } - val currentUserFollowingIds = followReader.findFollowings(principal.id, followingIds) - model.addAttribute("currentUserFollowingIds", currentUserFollowingIds) + + + setupFollowModelAttributes(followingIds, model, principal) model.addAttribute("followings", followings) - model.addAttribute("member", principal) - model.addAttribute("author", author) return FollowViews.FOLLOWING_FRAGMENT } @@ -83,13 +100,11 @@ class FollowView( ) } + model.addAttribute("followers", followers) // 현재 사용자가 팔로우하는 사용자 ID 목록 추가 val followerIds = followers.content.map { it.followerId } - val currentUserFollowingIds = followReader.findFollowings(principal.id, followerIds) - model.addAttribute("followers", followers) - model.addAttribute("currentUserFollowingIds", currentUserFollowingIds) - model.addAttribute("member", principal) + setupFollowModelAttributes(followerIds, model, principal) return FollowViews.FOLLOWERS_FRAGMENT } @@ -108,7 +123,7 @@ class FollowView( pageable = pageRequest ) - setupFollowModelAttributes(memberId, followings.content.map { it.followingId }, model, principal) + setupFollowModelAttributes(followings.content.map { it.followingId }, model, principal) model.addAttribute("followings", followings) return FollowViews.FOLLOWING_FRAGMENT @@ -129,20 +144,17 @@ class FollowView( pageable = pageRequest ) - setupFollowModelAttributes(memberId, followers.content.map { it.followerId }, model, principal) + setupFollowModelAttributes(followers.content.map { it.followerId }, model, principal) model.addAttribute("followers", followers) return FollowViews.FOLLOWERS_FRAGMENT } private fun setupFollowModelAttributes( - memberId: Long, followIds: List, model: Model, principal: MemberPrincipal?, ) { - val author = memberReader.readMemberById(memberId) - model.addAttribute("author", author) if (principal != null) { val currentUserFollowingIds = followReader.findFollowings(principal.id, followIds) model.addAttribute("member", principal) diff --git a/src/main/kotlin/com/albert/realmoneyrealtaste/adapter/webview/follow/FollowTerminateView.kt b/src/main/kotlin/com/albert/realmoneyrealtaste/adapter/webview/follow/FollowTerminateView.kt index fa77d196..9e475bed 100644 --- a/src/main/kotlin/com/albert/realmoneyrealtaste/adapter/webview/follow/FollowTerminateView.kt +++ b/src/main/kotlin/com/albert/realmoneyrealtaste/adapter/webview/follow/FollowTerminateView.kt @@ -1,14 +1,12 @@ package com.albert.realmoneyrealtaste.adapter.webview.follow import com.albert.realmoneyrealtaste.adapter.infrastructure.security.MemberPrincipal -import com.albert.realmoneyrealtaste.application.follow.dto.UnfollowRequest import com.albert.realmoneyrealtaste.application.follow.provided.FollowTerminator -import org.springframework.http.ResponseEntity import org.springframework.security.core.annotation.AuthenticationPrincipal import org.springframework.stereotype.Controller +import org.springframework.ui.Model import org.springframework.web.bind.annotation.DeleteMapping import org.springframework.web.bind.annotation.PathVariable -import org.springframework.web.bind.annotation.ResponseBody /** * 팔로우 삭제 API @@ -22,32 +20,19 @@ class FollowTerminateView( * 사용자 언팔로우 */ @DeleteMapping("/members/{targetId}/follow") - @ResponseBody fun unfollow( @AuthenticationPrincipal principal: MemberPrincipal, @PathVariable targetId: Long, - ): ResponseEntity { - val request = try { - UnfollowRequest( - followerId = principal.id, - followingId = targetId, - ) - } catch (e: IllegalArgumentException) { - return ResponseEntity.badRequest().body(e.message) - } + model: Model, + ): String { + followTerminator.unfollow( + followerId = principal.id, + followingId = targetId + ) - followTerminator.unfollow(request) + model.addAttribute("authorId", targetId) + model.addAttribute("isFollowing", false) - // 팔로우 버튼 HTML 조각 반환 - val followButtonHtml = """ - - """.trimIndent() - - return ResponseEntity.ok(followButtonHtml) + return FollowViews.FOLLOW_CREATE_BUTTON } } diff --git a/src/main/kotlin/com/albert/realmoneyrealtaste/adapter/webview/follow/FollowUrls.kt b/src/main/kotlin/com/albert/realmoneyrealtaste/adapter/webview/follow/FollowUrls.kt index 447b9ff7..990b6d52 100644 --- a/src/main/kotlin/com/albert/realmoneyrealtaste/adapter/webview/follow/FollowUrls.kt +++ b/src/main/kotlin/com/albert/realmoneyrealtaste/adapter/webview/follow/FollowUrls.kt @@ -8,4 +8,7 @@ object FollowUrls { // 다른 사용자의 팔로우 목록 const val USER_FOLLOWING_FRAGMENT = "/members/{memberId}/following/fragment" const val USER_FOLLOWERS_FRAGMENT = "/members/{memberId}/followers/fragment" + + // 팔로우 버튼 + const val FOLLOW_BUTTON = "/follows/button/{authorId}" } diff --git a/src/main/kotlin/com/albert/realmoneyrealtaste/adapter/webview/follow/FollowViews.kt b/src/main/kotlin/com/albert/realmoneyrealtaste/adapter/webview/follow/FollowViews.kt index 4aa45059..f8469c94 100644 --- a/src/main/kotlin/com/albert/realmoneyrealtaste/adapter/webview/follow/FollowViews.kt +++ b/src/main/kotlin/com/albert/realmoneyrealtaste/adapter/webview/follow/FollowViews.kt @@ -2,6 +2,12 @@ package com.albert.realmoneyrealtaste.adapter.webview.follow object FollowViews { // 팔로우 목록 - const val FOLLOWING_FRAGMENT = "follow/fragment :: following" - const val FOLLOWERS_FRAGMENT = "follow/fragment :: followers" + const val FOLLOWING_FRAGMENT = "follow/fragments/following :: following" + const val FOLLOWERS_FRAGMENT = "follow/fragments/followers :: followers" + + // 팔로우 버튼 + const val FOLLOW_BUTTON = "follow/fragments/follow-button :: follow-button" + const val FOLLOW_TERMINATE_BUTTON = "follow/fragments/terminate-button :: terminate-button" + const val FOLLOW_CREATE_BUTTON = "follow/fragments/create-button :: create-button" } + diff --git a/src/main/kotlin/com/albert/realmoneyrealtaste/adapter/webview/friend/FriendReadView.kt b/src/main/kotlin/com/albert/realmoneyrealtaste/adapter/webview/friend/FriendReadView.kt index a8f5279a..ac3087b0 100644 --- a/src/main/kotlin/com/albert/realmoneyrealtaste/adapter/webview/friend/FriendReadView.kt +++ b/src/main/kotlin/com/albert/realmoneyrealtaste/adapter/webview/friend/FriendReadView.kt @@ -2,7 +2,6 @@ package com.albert.realmoneyrealtaste.adapter.webview.friend import com.albert.realmoneyrealtaste.adapter.infrastructure.security.MemberPrincipal import com.albert.realmoneyrealtaste.application.friend.provided.FriendshipReader -import com.albert.realmoneyrealtaste.application.member.provided.MemberReader import org.springframework.data.domain.Pageable import org.springframework.data.domain.Sort import org.springframework.data.web.PageableDefault @@ -20,7 +19,6 @@ import org.springframework.web.bind.annotation.RequestMapping @RequestMapping class FriendReadView( private val friendshipReader: FriendshipReader, - private val memberReader: MemberReader, ) { /** @@ -46,11 +44,8 @@ class FriendReadView( 0 } - val author = memberReader.readMemberById(memberId) - model.addAttribute("recentFriends", recentFriends) model.addAttribute("pendingRequestsCount", pendingRequestsCount) - model.addAttribute("author", author) model.addAttribute("member", principal) return FriendViews.FRIEND_WIDGET @@ -73,4 +68,19 @@ class FriendReadView( return FriendViews.FRIEND_REQUESTS } + + @GetMapping(FriendUrls.FRIEND_BUTTON) + fun readFriendButton( + @AuthenticationPrincipal principal: MemberPrincipal, + @PathVariable authorId: Long, + model: Model, + ): String { + val isFriend = friendshipReader.isFriend(principal.id, authorId) + val hasSentFriendRequest = friendshipReader.isSent(principal.id, authorId) + + model.addAttribute("authorId", authorId) + model.addAttribute("isFriend", isFriend) + model.addAttribute("hasSentFriendRequest", hasSentFriendRequest) + return FriendViews.FRIEND_BUTTON + } } diff --git a/src/main/kotlin/com/albert/realmoneyrealtaste/adapter/webview/friend/FriendUrls.kt b/src/main/kotlin/com/albert/realmoneyrealtaste/adapter/webview/friend/FriendUrls.kt index f918aeaf..4f30aa6d 100644 --- a/src/main/kotlin/com/albert/realmoneyrealtaste/adapter/webview/friend/FriendUrls.kt +++ b/src/main/kotlin/com/albert/realmoneyrealtaste/adapter/webview/friend/FriendUrls.kt @@ -8,7 +8,7 @@ object FriendUrls { const val FRIEND_WIDGET = "/members/{memberId}/friends/widget/fragment" const val FRIEND_REQUESTS_FRAGMENT = "/friends/requests/fragment" const val SEND_FRIEND_REQUEST = "/friendships" - const val FRIEND_BUTTON = "/friendships/{friendshipId}" - const val RESPOND_TO_FRIEND_REQUEST = FRIEND_BUTTON + const val FRIEND_BUTTON = "/friendships/button/{authorId}" + const val RESPOND_TO_FRIEND_REQUEST = "/friendships/{friendshipId}" const val UNFRIEND = "/friendships/{friendshipId}/{friendMemberId}" } diff --git a/src/main/kotlin/com/albert/realmoneyrealtaste/adapter/webview/friend/FriendViews.kt b/src/main/kotlin/com/albert/realmoneyrealtaste/adapter/webview/friend/FriendViews.kt index 8bfa60b8..af39cc5e 100644 --- a/src/main/kotlin/com/albert/realmoneyrealtaste/adapter/webview/friend/FriendViews.kt +++ b/src/main/kotlin/com/albert/realmoneyrealtaste/adapter/webview/friend/FriendViews.kt @@ -4,8 +4,6 @@ package com.albert.realmoneyrealtaste.adapter.webview.friend * Friends 관련 뷰 이름 상수 */ object FriendViews { - const val FRIENDS_FRAGMENT = "friend/fragment" - const val FRIEND_BUTTON = "friend/fragments/friend-button :: friend-button" const val FRIEND_WIDGET = "friend/fragments/friend-widget :: friend-widget" const val FRIEND_REQUESTS = "friend/fragments/friend-requests :: friend-requests" diff --git a/src/main/kotlin/com/albert/realmoneyrealtaste/adapter/webview/friend/FriendWriteView.kt b/src/main/kotlin/com/albert/realmoneyrealtaste/adapter/webview/friend/FriendWriteView.kt index d820341b..564d304b 100644 --- a/src/main/kotlin/com/albert/realmoneyrealtaste/adapter/webview/friend/FriendWriteView.kt +++ b/src/main/kotlin/com/albert/realmoneyrealtaste/adapter/webview/friend/FriendWriteView.kt @@ -8,8 +8,6 @@ import com.albert.realmoneyrealtaste.application.friend.provided.FriendRequestor import com.albert.realmoneyrealtaste.application.friend.provided.FriendResponder import com.albert.realmoneyrealtaste.application.friend.provided.FriendshipReader import com.albert.realmoneyrealtaste.application.friend.provided.FriendshipTerminator -import com.albert.realmoneyrealtaste.application.member.provided.MemberReader -import com.albert.realmoneyrealtaste.domain.friend.command.FriendRequestCommand import org.springframework.security.core.annotation.AuthenticationPrincipal import org.springframework.stereotype.Controller import org.springframework.ui.Model @@ -17,7 +15,6 @@ import org.springframework.web.bind.annotation.DeleteMapping import org.springframework.web.bind.annotation.PathVariable import org.springframework.web.bind.annotation.PostMapping import org.springframework.web.bind.annotation.PutMapping -import org.springframework.web.bind.annotation.RequestBody import org.springframework.web.bind.annotation.RequestMapping import org.springframework.web.bind.annotation.RequestParam @@ -28,25 +25,22 @@ class FriendWriteView( private val friendResponder: FriendResponder, private val friendshipTerminator: FriendshipTerminator, private val friendshipReader: FriendshipReader, - private val memberReader: MemberReader, ) { - @PostMapping(FriendUrls.SEND_FRIEND_REQUEST) - fun sendFriendRequest( + fun sendFriendRequestNew( @AuthenticationPrincipal principal: MemberPrincipal, - @RequestBody request: SendFriendRequest, + @RequestParam authorId: Long, model: Model, ): String { - val command = FriendRequestCommand( + friendRequestor.sendFriendRequest( fromMemberId = principal.id, - toMemberId = request.toMemberId, - toMemberNickname = request.toMemberNickname, + toMemberId = authorId ) - val friendship = friendRequestor.sendFriendRequest(command) // 상태 업데이트를 위해 모델에 데이터 추가 - model.addAttribute("friendshipId", friendship.id) - updateFriendButtonModel(principal, request.toMemberId, model) + model.addAttribute("isFriend", false) + model.addAttribute("hasSentFriendRequest", true) + updateFriendButtonModel(principal, authorId, model) return FriendViews.FRIEND_BUTTON } @@ -113,11 +107,5 @@ class FriendWriteView( val friendship = friendshipReader.sentedFriendRequest(principal.id, targetMemberId) val hasSentFriendRequest = friendship != null model.addAttribute("hasSentFriendRequest", hasSentFriendRequest) - - // 템플릿에 필요한 author.id 설정 - model.addAttribute( - "author", - memberReader.readMemberById(targetMemberId) - ) } } diff --git a/src/main/kotlin/com/albert/realmoneyrealtaste/adapter/webview/member/MemberAuthView.kt b/src/main/kotlin/com/albert/realmoneyrealtaste/adapter/webview/member/MemberAuthView.kt new file mode 100644 index 00000000..19c69208 --- /dev/null +++ b/src/main/kotlin/com/albert/realmoneyrealtaste/adapter/webview/member/MemberAuthView.kt @@ -0,0 +1,161 @@ +package com.albert.realmoneyrealtaste.adapter.webview.member + +import com.albert.realmoneyrealtaste.adapter.infrastructure.security.MemberPrincipal +import com.albert.realmoneyrealtaste.adapter.webview.member.form.PasswordResetEmailForm +import com.albert.realmoneyrealtaste.adapter.webview.member.form.PasswordResetForm +import com.albert.realmoneyrealtaste.adapter.webview.member.message.MemberMessages +import com.albert.realmoneyrealtaste.adapter.webview.member.util.MemberUtils +import com.albert.realmoneyrealtaste.adapter.webview.member.validator.PasswordResetFormValidator +import com.albert.realmoneyrealtaste.adapter.webview.util.BindingResultUtils +import com.albert.realmoneyrealtaste.application.member.provided.MemberActivate +import com.albert.realmoneyrealtaste.application.member.provided.PasswordResetter +import com.albert.realmoneyrealtaste.domain.member.value.RawPassword +import jakarta.validation.Valid +import org.springframework.security.core.annotation.AuthenticationPrincipal +import org.springframework.stereotype.Controller +import org.springframework.ui.Model +import org.springframework.validation.BindingResult +import org.springframework.web.bind.annotation.GetMapping +import org.springframework.web.bind.annotation.ModelAttribute +import org.springframework.web.bind.annotation.PostMapping +import org.springframework.web.bind.annotation.RequestParam +import org.springframework.web.servlet.mvc.support.RedirectAttributes + +/** + * 회원 인증 및 비밀번호 재설정 관련 뷰 컨트롤러 + */ +@Controller +class MemberAuthView( + private val memberActivate: MemberActivate, + private val passwordResetter: PasswordResetter, + private val passwordResetFormValidator: PasswordResetFormValidator, +) { + /** + * 이메일 활성화 처리 + */ + @GetMapping(MemberUrls.ACTIVATION) + fun activate( + @RequestParam("token") token: String, + model: Model, + ): String { + val member = memberActivate.activate(token) + + model.addAttribute("nickname", member.nickname.value) + return MemberViews.ACTIVATE + } + + /** + * 활성화 이메일 재전송 폼 + */ + @GetMapping(MemberUrls.RESEND_ACTIVATION) + fun resendActivationEmail( + @AuthenticationPrincipal memberPrincipal: MemberPrincipal, + model: Model, + ): String { + model.addAttribute("email", memberPrincipal.email) + return MemberViews.RESEND_ACTIVATION + } + + /** + * 활성화 이메일 재전송 처리 + */ + @PostMapping(MemberUrls.RESEND_ACTIVATION) + fun resendActivationEmail( + @AuthenticationPrincipal memberPrincipal: MemberPrincipal, + redirectAttributes: RedirectAttributes, + ): String { + memberActivate.resendActivationEmail(memberPrincipal.email) + + MemberUtils.setSuccessFlashAttribute( + redirectAttributes, + MemberMessages.Auth.ACTIVATION_EMAIL_RESENT + ) + + return "redirect:${MemberUrls.RESEND_ACTIVATION}" + } + + /** + * 비밀번호 찾기 폼 + */ + @GetMapping(MemberUrls.PASSWORD_FORGOT) + fun passwordForgot(): String { + return MemberViews.PASSWORD_FORGOT + } + + /** + * 비밀번호 재설정 이메일 전송 처리 + */ + @PostMapping(MemberUrls.PASSWORD_FORGOT) + fun sendPasswordResetEmail( + @Valid @ModelAttribute form: PasswordResetEmailForm, + bindingResult: BindingResult, + redirectAttributes: RedirectAttributes, + ): String { + if (bindingResult.hasErrors()) { + val errorMessages = BindingResultUtils.extractFirstErrorMessage(bindingResult) + MemberUtils.setErrorFlashAttribute( + redirectAttributes, + errorMessages + ) + return "redirect:${MemberUrls.PASSWORD_FORGOT}" + } + + passwordResetter.sendPasswordResetEmail(form.email) + + MemberUtils.setSuccessFlashAttribute( + redirectAttributes, + MemberMessages.Auth.PASSWORD_RESET_EMAIL_SENT + ) + + return "redirect:${MemberUrls.PASSWORD_FORGOT}" + } + + @GetMapping(MemberUrls.PASSWORD_RESET) + fun passwordReset( + @RequestParam("token") token: String, + model: Model, + ): String { + model.addAttribute("token", token) + return MemberViews.PASSWORD_RESET + } + + @PostMapping(MemberUrls.PASSWORD_RESET) + fun resetPassword( + @RequestParam token: String, + @Valid @ModelAttribute form: PasswordResetForm, + bindingResult: BindingResult, + redirectAttributes: RedirectAttributes, + ): String { + if (bindingResult.hasErrors()) { + return handlePasswordResetErrors(bindingResult, token, redirectAttributes) + } + + passwordResetFormValidator.validate(form, bindingResult) + + if (bindingResult.hasErrors()) { + return handlePasswordResetErrors(bindingResult, token, redirectAttributes) + } + + passwordResetter.resetPassword(token, RawPassword(form.newPassword)) + + MemberUtils.setSuccessFlashAttribute( + redirectAttributes, + MemberMessages.Auth.PASSWORD_RESET_SUCCESS + ) + return "redirect:/" + } + + /** + * 비밀번호 재설정 폼 검증 에러 처리 + */ + private fun handlePasswordResetErrors( + bindingResult: BindingResult, + token: String, + redirectAttributes: RedirectAttributes, + ): String { + val errorMessages = BindingResultUtils.extractFirstErrorMessage(bindingResult) + redirectAttributes.addFlashAttribute("token", token) + redirectAttributes.addFlashAttribute("error", errorMessages) + return "redirect:${MemberUrls.PASSWORD_RESET}?token=$token" + } +} diff --git a/src/main/kotlin/com/albert/realmoneyrealtaste/adapter/webview/member/MemberExceptionHandler.kt b/src/main/kotlin/com/albert/realmoneyrealtaste/adapter/webview/member/MemberExceptionHandler.kt index 27ca8805..ad989af1 100644 --- a/src/main/kotlin/com/albert/realmoneyrealtaste/adapter/webview/member/MemberExceptionHandler.kt +++ b/src/main/kotlin/com/albert/realmoneyrealtaste/adapter/webview/member/MemberExceptionHandler.kt @@ -1,5 +1,7 @@ package com.albert.realmoneyrealtaste.adapter.webview.member +import com.albert.realmoneyrealtaste.adapter.webview.member.message.MemberMessages +import com.albert.realmoneyrealtaste.adapter.webview.member.util.MemberUtils import com.albert.realmoneyrealtaste.application.member.exception.MemberActivateException import com.albert.realmoneyrealtaste.application.member.exception.MemberDeactivateException import com.albert.realmoneyrealtaste.application.member.exception.MemberNotFoundException @@ -13,72 +15,104 @@ import org.springframework.web.bind.annotation.ExceptionHandler import org.springframework.web.bind.annotation.ResponseStatus import org.springframework.web.servlet.mvc.support.RedirectAttributes +/** + * 회원 관련 예외 핸들러 + */ @ControllerAdvice(annotations = [Controller::class]) class MemberExceptionHandler { - + /** + * 회원 활성화 예외 처리 + */ @ExceptionHandler(MemberActivateException::class) fun handleMemberActivateException( ex: MemberActivateException, redirectAttributes: RedirectAttributes, ): String { - redirectAttributes.addFlashAttribute("success", false) - redirectAttributes.addFlashAttribute("error", "회원 활성화에 실패했습니다. 다시 시도해주세요.") + MemberUtils.setErrorFlashAttribute( + redirectAttributes, + MemberMessages.Error.MEMBER_ACTIVATE_FAILED + ) return "redirect:${MemberUrls.ACTIVATION}" } + /** + * 활성화 이메일 재전송 예외 처리 + */ @ExceptionHandler(MemberResendActivationEmailException::class) fun handleMemberResendActivationEmailException( ex: MemberResendActivationEmailException, redirectAttributes: RedirectAttributes, ): String { - redirectAttributes.addFlashAttribute("success", false) - redirectAttributes.addFlashAttribute("error", "활성화 이메일 재전송에 실패했습니다. 다시 시도해주세요.") + MemberUtils.setErrorFlashAttribute( + redirectAttributes, + MemberMessages.Error.RESEND_ACTIVATION_EMAIL_FAILED + ) return "redirect:${MemberUrls.RESEND_ACTIVATION}" } + /** + * 회원 정보 업데이트 예외 처리 + */ @ExceptionHandler(MemberUpdateException::class) fun handleMemberUpdateException( ex: MemberUpdateException, redirectAttributes: RedirectAttributes, ): String { - redirectAttributes.addFlashAttribute("success", false) - redirectAttributes.addFlashAttribute("error", "회원 정보 수정에 실패했습니다. 다시 시도해주세요.") + MemberUtils.setErrorFlashAttribute( + redirectAttributes, + MemberMessages.Error.MEMBER_UPDATE_FAILED + ) return "redirect:${MemberUrls.SETTING}#account" } + /** + * 회원 탈퇴 예외 처리 + */ @ExceptionHandler(MemberDeactivateException::class) fun handleMemberDeactivateException( ex: MemberDeactivateException, redirectAttributes: RedirectAttributes, ): String { - redirectAttributes.addFlashAttribute("success", false) - redirectAttributes.addFlashAttribute("error", "회원 탈퇴에 실패했습니다. 다시 시도해주세요.") + MemberUtils.setErrorFlashAttribute( + redirectAttributes, + MemberMessages.Error.MEMBER_DEACTIVATE_FAILED + ) return "redirect:${MemberUrls.SETTING}#delete" } + /** + * 비밀번호 재설정 예외 처리 + */ @ExceptionHandler(PassWordResetException::class) fun handlePassWordResetException( ex: PassWordResetException, redirectAttributes: RedirectAttributes, ): String { - redirectAttributes.addFlashAttribute("success", false) - redirectAttributes.addFlashAttribute("error", "비밀번호 재설정에 실패했습니다. 다시 시도해주세요.") + MemberUtils.setErrorFlashAttribute( + redirectAttributes, + MemberMessages.Error.PASSWORD_RESET_FAILED + ) return "redirect:/" } + /** + * 회원 찾기 실패 예외 처리 + */ @ResponseStatus(value = HttpStatus.BAD_REQUEST) @ExceptionHandler(MemberNotFoundException::class) fun handleMemberNotFoundException( ex: MemberNotFoundException, redirectAttributes: RedirectAttributes, ): String { - redirectAttributes.addFlashAttribute("success", false) - redirectAttributes.addFlashAttribute("error", "회원 정보를 찾을 수 없습니다.") + MemberUtils.setErrorFlashAttribute( + redirectAttributes, + MemberMessages.Error.MEMBER_NOT_FOUND + ) return "error/404" } diff --git a/src/main/kotlin/com/albert/realmoneyrealtaste/adapter/webview/member/MemberFragmentView.kt b/src/main/kotlin/com/albert/realmoneyrealtaste/adapter/webview/member/MemberFragmentView.kt new file mode 100644 index 00000000..68ed2e48 --- /dev/null +++ b/src/main/kotlin/com/albert/realmoneyrealtaste/adapter/webview/member/MemberFragmentView.kt @@ -0,0 +1,50 @@ +package com.albert.realmoneyrealtaste.adapter.webview.member + +import com.albert.realmoneyrealtaste.adapter.infrastructure.security.MemberPrincipal +import com.albert.realmoneyrealtaste.application.member.provided.MemberReader +import org.springframework.security.core.annotation.AuthenticationPrincipal +import org.springframework.stereotype.Controller +import org.springframework.ui.Model +import org.springframework.web.bind.annotation.GetMapping + +/** + * 회원 관련 프래그먼트 조각들 뷰 컨트롤러 + */ +@Controller +class MemberFragmentView( + private val memberReader: MemberReader, +) { + /** + * 추천 사용자 사이드바 프래그먼트 + */ + @GetMapping(MemberUrls.FRAGMENT_SUGGEST_USERS_SIDEBAR) + fun readSidebarFragment( + @AuthenticationPrincipal memberPrincipal: MemberPrincipal, + model: Model, + ): String { + val result = memberReader.findSuggestedMembersWithFollowStatus(memberPrincipal.id, 5) + + model.addAttribute("suggestedUsers", result.suggestedUsers) + model.addAttribute("followings", result.followingIds) + model.addAttribute("member", memberPrincipal) + return MemberViews.SUGGEST_USERS_SIDEBAR_CONTENT + } + + /** + * 회원 프로필 프래그먼트 + */ + @GetMapping(MemberUrls.FRAGMENT_MEMBER_PROFILE) + fun memberProfileFragment( + model: Model, + @AuthenticationPrincipal memberPrincipal: MemberPrincipal, + ): String { + val member = memberReader.readMemberById(memberPrincipal.id) + + model.addAttribute("member", member) + model.addAttribute("followersCount", member.followersCount) + model.addAttribute("followingCount", member.followingsCount) + model.addAttribute("postCount", member.postCount) + + return MemberViews.MEMBER_PROFILE_FRAGMENT + } +} diff --git a/src/main/kotlin/com/albert/realmoneyrealtaste/adapter/webview/member/MemberProfileView.kt b/src/main/kotlin/com/albert/realmoneyrealtaste/adapter/webview/member/MemberProfileView.kt new file mode 100644 index 00000000..c349121e --- /dev/null +++ b/src/main/kotlin/com/albert/realmoneyrealtaste/adapter/webview/member/MemberProfileView.kt @@ -0,0 +1,35 @@ +package com.albert.realmoneyrealtaste.adapter.webview.member + +import com.albert.realmoneyrealtaste.adapter.infrastructure.security.MemberPrincipal +import com.albert.realmoneyrealtaste.adapter.webview.post.form.PostCreateForm +import com.albert.realmoneyrealtaste.application.member.provided.MemberReader +import org.springframework.security.core.annotation.AuthenticationPrincipal +import org.springframework.stereotype.Controller +import org.springframework.ui.Model +import org.springframework.web.bind.annotation.GetMapping +import org.springframework.web.bind.annotation.PathVariable + +/** + * 회원 프로필 조회 관련 뷰 컨트롤러 + */ +@Controller +class MemberProfileView( + private val memberReader: MemberReader, +) { + /** + * 회원 프로필 조회 + */ + @GetMapping(MemberUrls.PROFILE) + fun readProfile( + @PathVariable id: Long, + @AuthenticationPrincipal principal: MemberPrincipal?, + model: Model, + ): String { + val profileMember = memberReader.readActiveMemberById(id) + + model.addAttribute("author", profileMember) // 프로필 주인 + model.addAttribute("member", principal) // 현재 로그인한 사용자 + model.addAttribute("postCreateForm", PostCreateForm()) + return MemberViews.PROFILE + } +} diff --git a/src/main/kotlin/com/albert/realmoneyrealtaste/adapter/webview/member/MemberSettingsView.kt b/src/main/kotlin/com/albert/realmoneyrealtaste/adapter/webview/member/MemberSettingsView.kt new file mode 100644 index 00000000..26233b0e --- /dev/null +++ b/src/main/kotlin/com/albert/realmoneyrealtaste/adapter/webview/member/MemberSettingsView.kt @@ -0,0 +1,133 @@ +package com.albert.realmoneyrealtaste.adapter.webview.member + +import com.albert.realmoneyrealtaste.adapter.infrastructure.security.MemberPrincipal +import com.albert.realmoneyrealtaste.adapter.webview.member.form.AccountUpdateForm +import com.albert.realmoneyrealtaste.adapter.webview.member.form.PasswordUpdateForm +import com.albert.realmoneyrealtaste.adapter.webview.member.message.MemberMessages +import com.albert.realmoneyrealtaste.adapter.webview.member.util.MemberUtils +import com.albert.realmoneyrealtaste.adapter.webview.member.validator.PasswordUpdateFormValidator +import com.albert.realmoneyrealtaste.adapter.webview.util.BindingResultUtils +import com.albert.realmoneyrealtaste.application.member.provided.MemberReader +import com.albert.realmoneyrealtaste.application.member.provided.MemberUpdater +import com.albert.realmoneyrealtaste.domain.member.value.RawPassword +import jakarta.servlet.http.HttpServletRequest +import jakarta.validation.Valid +import org.springframework.security.core.annotation.AuthenticationPrincipal +import org.springframework.security.core.context.SecurityContextHolder +import org.springframework.stereotype.Controller +import org.springframework.ui.Model +import org.springframework.validation.BindingResult +import org.springframework.web.bind.annotation.GetMapping +import org.springframework.web.bind.annotation.ModelAttribute +import org.springframework.web.bind.annotation.PostMapping +import org.springframework.web.bind.annotation.RequestParam +import org.springframework.web.servlet.mvc.support.RedirectAttributes + +/** + * 회원 설정 관련 뷰 컨트롤러 + */ +@Controller +class MemberSettingsView( + private val memberReader: MemberReader, + private val memberUpdater: MemberUpdater, + private val validator: PasswordUpdateFormValidator, +) { + + /** + * 회원 설정 페이지 + */ + @GetMapping(MemberUrls.SETTING) + fun setting( + @AuthenticationPrincipal memberPrincipal: MemberPrincipal, + model: Model, + ): String { + val member = memberReader.readMemberById(memberPrincipal.id) + + model.addAttribute("member", member) + return MemberViews.SETTING + } + + /** + * 계정 정보 업데이트 처리 + */ + @PostMapping(MemberUrls.SETTING_ACCOUNT) + fun updateAccount( + @AuthenticationPrincipal memberPrincipal: MemberPrincipal, + @Valid @ModelAttribute form: AccountUpdateForm, + bindingResult: BindingResult, + redirectAttributes: RedirectAttributes, + ): String { + if (bindingResult.hasErrors()) { + val errorMessages = BindingResultUtils.extractFirstErrorMessage(bindingResult) + return MemberUtils.handleSettingError( + redirectAttributes, "account", errorMessages, MemberUrls.SETTING + ) + } + + memberUpdater.updateInfo(memberPrincipal.id, form.toAccountUpdateRequest()) + + return MemberUtils.handleSettingSuccess( + redirectAttributes, "account", MemberMessages.Settings.ACCOUNT_UPDATE_SUCCESS, MemberUrls.SETTING + ) + } + + /** + * 비밀번호 변경 처리 + */ + @PostMapping(MemberUrls.SETTING_PASSWORD) + fun updatePassword( + @AuthenticationPrincipal memberPrincipal: MemberPrincipal, + @Valid @ModelAttribute request: PasswordUpdateForm, + bindingResult: BindingResult, + redirectAttributes: RedirectAttributes, + ): String { + if (bindingResult.hasErrors()) { + val errorMessages = BindingResultUtils.extractFirstErrorMessage(bindingResult) + return MemberUtils.handleSettingError( + redirectAttributes, "password", errorMessages, MemberUrls.SETTING + ) + } + + validator.validate(request, bindingResult) + + if (bindingResult.hasErrors()) { + val errorMessages = BindingResultUtils.extractFirstErrorMessage(bindingResult) + return MemberUtils.handleSettingError( + redirectAttributes, "password", errorMessages, MemberUrls.SETTING + ) + } + + memberUpdater.updatePassword( + memberPrincipal.id, RawPassword(request.currentPassword), RawPassword(request.newPassword) + ) + + return MemberUtils.handleSettingSuccess( + redirectAttributes, "password", MemberMessages.Settings.PASSWORD_UPDATE_SUCCESS, MemberUrls.SETTING + ) + } + + /** + * 계정 삭제 처리 + */ + @PostMapping(MemberUrls.SETTING_DELETE) + fun deleteAccount( + @AuthenticationPrincipal memberPrincipal: MemberPrincipal, + @RequestParam confirmed: Boolean?, + redirectAttributes: RedirectAttributes, + request: HttpServletRequest, + ): String { + if (confirmed != true) { + return MemberUtils.handleSettingError( + redirectAttributes, "delete", MemberMessages.Settings.ACCOUNT_DELETE_NOT_CONFIRMED, MemberUrls.SETTING + ) + } + + memberUpdater.deactivate(memberPrincipal.id) + + // 세션 무효화 및 로그아웃 처리 + request.session.invalidate() + SecurityContextHolder.clearContext() + + return "redirect:/" + } +} diff --git a/src/main/kotlin/com/albert/realmoneyrealtaste/adapter/webview/member/MemberUrls.kt b/src/main/kotlin/com/albert/realmoneyrealtaste/adapter/webview/member/MemberUrls.kt index b80fc84f..57c5119e 100644 --- a/src/main/kotlin/com/albert/realmoneyrealtaste/adapter/webview/member/MemberUrls.kt +++ b/src/main/kotlin/com/albert/realmoneyrealtaste/adapter/webview/member/MemberUrls.kt @@ -1,15 +1,25 @@ package com.albert.realmoneyrealtaste.adapter.webview.member +/** + * Member 관련 URL 상수 + */ object MemberUrls { + // ========== 프로필 관련 ========== const val PROFILE = "/members/{id}" + + // ========== 인증 관련 ========== const val ACTIVATION = "/members/activate" const val RESEND_ACTIVATION = "/members/resend-activation" + const val PASSWORD_FORGOT = "/members/password-forgot" + const val PASSWORD_RESET = "/members/password-reset" + + // ========== 설정 관련 ========== const val SETTING = "/members/setting" const val SETTING_ACCOUNT = "/members/setting/account" const val SETTING_PASSWORD = "/members/setting/password" const val SETTING_DELETE = "/members/setting/delete" - const val PASSWORD_FORGOT = "/members/password-forgot" - const val PASSWORD_RESET = "/members/password-reset" + // ========== 프래그먼트 관련 ========== const val FRAGMENT_SUGGEST_USERS_SIDEBAR = "/fragments/members/suggest-users-sidebar" + const val FRAGMENT_MEMBER_PROFILE = "/fragments/member-profile" } diff --git a/src/main/kotlin/com/albert/realmoneyrealtaste/adapter/webview/member/MemberView.kt b/src/main/kotlin/com/albert/realmoneyrealtaste/adapter/webview/member/MemberView.kt deleted file mode 100644 index f434a0c3..00000000 --- a/src/main/kotlin/com/albert/realmoneyrealtaste/adapter/webview/member/MemberView.kt +++ /dev/null @@ -1,262 +0,0 @@ -package com.albert.realmoneyrealtaste.adapter.webview.member - -import com.albert.realmoneyrealtaste.adapter.infrastructure.security.MemberPrincipal -import com.albert.realmoneyrealtaste.adapter.webview.post.PostCreateForm -import com.albert.realmoneyrealtaste.application.follow.provided.FollowReader -import com.albert.realmoneyrealtaste.application.friend.provided.FriendshipReader -import com.albert.realmoneyrealtaste.application.member.provided.MemberActivate -import com.albert.realmoneyrealtaste.application.member.provided.MemberReader -import com.albert.realmoneyrealtaste.application.member.provided.MemberUpdater -import com.albert.realmoneyrealtaste.application.member.provided.PasswordResetter -import com.albert.realmoneyrealtaste.domain.member.value.Email -import com.albert.realmoneyrealtaste.domain.member.value.RawPassword -import jakarta.servlet.http.HttpServletRequest -import jakarta.validation.Valid -import org.springframework.security.core.annotation.AuthenticationPrincipal -import org.springframework.security.core.context.SecurityContextHolder -import org.springframework.stereotype.Controller -import org.springframework.ui.Model -import org.springframework.validation.BindingResult -import org.springframework.web.bind.annotation.GetMapping -import org.springframework.web.bind.annotation.ModelAttribute -import org.springframework.web.bind.annotation.PathVariable -import org.springframework.web.bind.annotation.PostMapping -import org.springframework.web.bind.annotation.RequestParam -import org.springframework.web.servlet.mvc.support.RedirectAttributes - -@Controller -class MemberView( - private val memberActivate: MemberActivate, - private val memberReader: MemberReader, - private val memberUpdater: MemberUpdater, - private val validator: PasswordUpdateFormValidator, - private val passwordResetter: PasswordResetter, - private val followReader: FollowReader, - private val friendshipReader: FriendshipReader, -) { - - @GetMapping(MemberUrls.PROFILE) - fun readProfile( - @PathVariable id: Long, - @AuthenticationPrincipal principal: MemberPrincipal?, - model: Model, - ): String { - val profileMember = memberReader.readActiveMemberById(id) - - model.addAttribute("author", profileMember) // 프로필 주인 - model.addAttribute("member", principal) // 현재 로그인한 사용자 - model.addAttribute("postCreateForm", PostCreateForm()) - - // 팔로우 및 친구 관계 상태 확인 - if (principal != null && principal.id != id) { - val followingIds = followReader.findFollowings(principal.id, listOf(id)) - model.addAttribute("isFollowing", followingIds.contains(id)) - - // 친구 관계 상태 확인 - val isFriend = friendshipReader.isFriend(principal.id, id) - model.addAttribute("isFriend", isFriend) - - // 친구 요청을 보냈는지 확인 (내가 보낸 요청이 대기 중인지) - val friendship = friendshipReader.sentedFriendRequest(principal.id, id) - val hasSentFriendRequest = friendship != null - model.addAttribute("hasSentFriendRequest", hasSentFriendRequest) - } - - return MemberViews.PROFILE - } - - @GetMapping(MemberUrls.ACTIVATION) - fun activate( - @RequestParam("token") token: String, - model: Model, - ): String { - val member = memberActivate.activate(token) - - model.addAttribute("nickname", member.nickname.value) - model.addAttribute("success", true) - - return MemberViews.ACTIVATE - } - - @GetMapping(MemberUrls.RESEND_ACTIVATION) - fun resendActivationEmail( - @AuthenticationPrincipal memberPrincipal: MemberPrincipal, - model: Model, - ): String { - model.addAttribute("email", memberPrincipal.email) - return MemberViews.RESEND_ACTIVATION - } - - @PostMapping(MemberUrls.RESEND_ACTIVATION) - fun resendActivationEmail( - @AuthenticationPrincipal memberPrincipal: MemberPrincipal, - redirectAttributes: RedirectAttributes, - ): String { - memberActivate.resendActivationEmail(memberPrincipal.email) - - redirectAttributes.addFlashAttribute("success", true) - redirectAttributes.addFlashAttribute("message", "인증 이메일이 재발송되었습니다. 이메일을 확인해주세요.") - - return "redirect:${MemberUrls.RESEND_ACTIVATION}" - } - - @GetMapping(MemberUrls.SETTING) - fun setting( - @AuthenticationPrincipal memberPrincipal: MemberPrincipal, - model: Model, - ): String { - val member = memberReader.readMemberById(memberPrincipal.id) - model.addAttribute("member", member) - return MemberViews.SETTING - } - - @PostMapping(MemberUrls.SETTING_ACCOUNT) - fun updateAccount( - @AuthenticationPrincipal memberPrincipal: MemberPrincipal, - @Valid @ModelAttribute form: AccountUpdateForm, - bindingResult: BindingResult, - redirectAttributes: RedirectAttributes, - ): String { - redirectAttributes.addFlashAttribute("tab", "account") - - if (bindingResult.hasErrors()) { - val errorMessages = bindingResult.fieldErrors.first().defaultMessage - redirectAttributes.addFlashAttribute("error", errorMessages) - return "redirect:${MemberUrls.SETTING}#account" - } - - memberUpdater.updateInfo(memberPrincipal.id, form.toAccountUpdateRequest()) - - redirectAttributes.addFlashAttribute("success", "계정 정보가 성공적으로 업데이트되었습니다.") - return "redirect:${MemberUrls.SETTING}#account" - } - - @PostMapping(MemberUrls.SETTING_PASSWORD) - fun updatePassword( - @AuthenticationPrincipal memberPrincipal: MemberPrincipal, - @Valid @ModelAttribute request: PasswordUpdateForm, - bindingResult: BindingResult, - redirectAttributes: RedirectAttributes, - ): String { - redirectAttributes.addFlashAttribute("tab", "password") - - if (bindingResult.hasErrors()) { - redirectAttributes.addFlashAttribute("error", "비밀번호 변경이 실패했습니다. 비밀번호는 영문, 숫자, 특수문자를 포함해야 합니다") - return "redirect:${MemberUrls.SETTING}#password" - } - - validator.validate(request, bindingResult) - - if (bindingResult.hasErrors()) { - redirectAttributes.addFlashAttribute("error", "비밀번호 변경이 실패했습니다. 새 비밀번호와 비밀번호 확인이 일치하지 않습니다.") - return "redirect:${MemberUrls.SETTING}#password" - } - - memberUpdater.updatePassword( - memberPrincipal.id, RawPassword(request.currentPassword), RawPassword(request.newPassword) - ) - redirectAttributes.addFlashAttribute("success", "비밀번호가 성공적으로 변경되었습니다.") - - return "redirect:${MemberUrls.SETTING}#password" - } - - @PostMapping(MemberUrls.SETTING_DELETE) - fun deleteAccount( - @AuthenticationPrincipal memberPrincipal: MemberPrincipal, - @RequestParam confirmed: Boolean?, - redirectAttributes: RedirectAttributes, - request: HttpServletRequest, - ): String { - redirectAttributes.addFlashAttribute("tab", "delete") - - if (confirmed != true) { - redirectAttributes.addFlashAttribute("error", "계정 삭제 확인이 필요합니다.") - return "redirect:${MemberUrls.SETTING}#delete" - } - - memberUpdater.deactivate(memberPrincipal.id) - - // 세션 무효화 및 로그아웃 처리 - request.session.invalidate() - - SecurityContextHolder.clearContext() - - return "redirect:/" - } - - @GetMapping(MemberUrls.PASSWORD_FORGOT) - fun passwordForgot(): String { - return MemberViews.PASSWORD_FORGOT - } - - @PostMapping(MemberUrls.PASSWORD_FORGOT) - fun sendPasswordResetEmail( - @RequestParam email: String, - redirectAttributes: RedirectAttributes, - ): String { - - val emailObj = try { - Email(email) - } catch (_: IllegalArgumentException) { - redirectAttributes.addFlashAttribute("success", false) - redirectAttributes.addFlashAttribute("error", "올바른 이메일 형식을 입력해주세요.") - return "redirect:${MemberUrls.PASSWORD_FORGOT}" - } - - passwordResetter.sendPasswordResetEmail(emailObj) - - redirectAttributes.addFlashAttribute("success", true) - redirectAttributes.addFlashAttribute("message", "비밀번호 재설정 이메일이 발송되었습니다. 이메일을 확인해주세요.") - - return "redirect:${MemberUrls.PASSWORD_FORGOT}" - } - - @GetMapping(MemberUrls.PASSWORD_RESET) - fun passwordReset( - @RequestParam("token") token: String, - model: Model, - ): String { - model.addAttribute("token", token) - return MemberViews.PASSWORD_RESET - } - - @PostMapping(MemberUrls.PASSWORD_RESET) - fun resetPassword( - @RequestParam token: String, - @Valid @ModelAttribute form: PasswordResetForm, - bindingResult: BindingResult, - redirectAttributes: RedirectAttributes, - ): String { - if (bindingResult.hasErrors()) { - redirectAttributes.addFlashAttribute("error", "비밀번호 형식이 올바르지 않습니다.") - redirectAttributes.addFlashAttribute("token", token) - return "redirect:${MemberUrls.PASSWORD_RESET}?token=$token" - } - - if (form.newPassword != form.newPasswordConfirm) { - redirectAttributes.addFlashAttribute("error", "새 비밀번호와 비밀번호 확인이 일치하지 않습니다.") - redirectAttributes.addFlashAttribute("token", token) - return "redirect:${MemberUrls.PASSWORD_RESET}?token=$token" - } - - passwordResetter.resetPassword(token, RawPassword(form.newPassword)) - redirectAttributes.addFlashAttribute("success", true) - redirectAttributes.addFlashAttribute("message", "비밀번호가 성공적으로 재설정되었습니다. 새로운 비밀번호로 로그인해주세요.") - return "redirect:/" - } - - @GetMapping(MemberUrls.FRAGMENT_SUGGEST_USERS_SIDEBAR) - fun readSidebarFragment( - @AuthenticationPrincipal memberPrincipal: MemberPrincipal, - model: Model, - ): String { - val suggestedUsers = memberReader.findSuggestedMembers(memberPrincipal.id, 5) - val targetIds = suggestedUsers.map { it.requireId() } - val followingIds = followReader.findFollowings(memberPrincipal.id, targetIds) - - model.addAttribute("suggestedUsers", suggestedUsers) - model.addAttribute("followings", followingIds) - model.addAttribute("member", memberPrincipal) - return MemberViews.SUGGEST_USERS_SIDEBAR_CONTENT - } -} diff --git a/src/main/kotlin/com/albert/realmoneyrealtaste/adapter/webview/member/MemberViews.kt b/src/main/kotlin/com/albert/realmoneyrealtaste/adapter/webview/member/MemberViews.kt index af274aab..3adcedec 100644 --- a/src/main/kotlin/com/albert/realmoneyrealtaste/adapter/webview/member/MemberViews.kt +++ b/src/main/kotlin/com/albert/realmoneyrealtaste/adapter/webview/member/MemberViews.kt @@ -1,13 +1,24 @@ package com.albert.realmoneyrealtaste.adapter.webview.member +/** + * Member 관련 뷰 이름 상수 + */ object MemberViews { + // ========== 프로필 관련 ========== const val PROFILE = "member/profile" + + // ========== 인증 관련 ========== const val ACTIVATE = "member/activate" const val ACTIVATION = "member/activation" const val RESEND_ACTIVATION = "member/resend-activation" - const val SETTING = "member/setting" const val PASSWORD_FORGOT = "member/password-forgot" const val PASSWORD_RESET = "member/password-reset" const val PASSWORD_RESET_EMAIL = "member/password-reset-email" - const val SUGGEST_USERS_SIDEBAR_CONTENT = "fragments/sidebar :: right-sidebar" + + // ========== 설정 관련 ========== + const val SETTING = "member/setting" + + // ========== 프래그먼트 관련 ========== + const val SUGGEST_USERS_SIDEBAR_CONTENT = "member/fragments/sidebar :: right-sidebar" + const val MEMBER_PROFILE_FRAGMENT = "member/fragments/member-profile :: memberProfile" } diff --git a/src/main/kotlin/com/albert/realmoneyrealtaste/adapter/webview/member/config/MemberWeConfig.kt b/src/main/kotlin/com/albert/realmoneyrealtaste/adapter/webview/member/config/MemberWeConfig.kt new file mode 100644 index 00000000..342a7e5c --- /dev/null +++ b/src/main/kotlin/com/albert/realmoneyrealtaste/adapter/webview/member/config/MemberWeConfig.kt @@ -0,0 +1,14 @@ +package com.albert.realmoneyrealtaste.adapter.webview.member.config + +import com.albert.realmoneyrealtaste.adapter.webview.member.converter.StringToEmailConverter +import org.springframework.context.annotation.Configuration +import org.springframework.format.FormatterRegistry +import org.springframework.web.servlet.config.annotation.WebMvcConfigurer + +@Configuration +class MemberWeConfig : WebMvcConfigurer { + + override fun addFormatters(registry: FormatterRegistry) { + registry.addConverter(StringToEmailConverter()) + } +} diff --git a/src/main/kotlin/com/albert/realmoneyrealtaste/adapter/webview/member/converter/StringToEmailConverter.kt b/src/main/kotlin/com/albert/realmoneyrealtaste/adapter/webview/member/converter/StringToEmailConverter.kt new file mode 100644 index 00000000..283f4ab6 --- /dev/null +++ b/src/main/kotlin/com/albert/realmoneyrealtaste/adapter/webview/member/converter/StringToEmailConverter.kt @@ -0,0 +1,16 @@ +package com.albert.realmoneyrealtaste.adapter.webview.member.converter + +import com.albert.realmoneyrealtaste.domain.member.value.Email +import org.springframework.core.convert.converter.Converter +import org.springframework.stereotype.Component + +/** + * String을 Email 도메인 객체로 변환하는 컨버터 + */ +@Component +class StringToEmailConverter : Converter { + + override fun convert(source: String): Email { + return Email(source) + } +} diff --git a/src/main/kotlin/com/albert/realmoneyrealtaste/adapter/webview/member/AccountUpdateForm.kt b/src/main/kotlin/com/albert/realmoneyrealtaste/adapter/webview/member/form/AccountUpdateForm.kt similarity index 80% rename from src/main/kotlin/com/albert/realmoneyrealtaste/adapter/webview/member/AccountUpdateForm.kt rename to src/main/kotlin/com/albert/realmoneyrealtaste/adapter/webview/member/form/AccountUpdateForm.kt index d8d8d29f..08a7a9e2 100644 --- a/src/main/kotlin/com/albert/realmoneyrealtaste/adapter/webview/member/AccountUpdateForm.kt +++ b/src/main/kotlin/com/albert/realmoneyrealtaste/adapter/webview/member/form/AccountUpdateForm.kt @@ -1,4 +1,4 @@ -package com.albert.realmoneyrealtaste.adapter.webview.member +package com.albert.realmoneyrealtaste.adapter.webview.member.form import com.albert.realmoneyrealtaste.application.member.dto.AccountUpdateRequest import com.albert.realmoneyrealtaste.domain.member.value.Introduction @@ -7,6 +7,9 @@ import com.albert.realmoneyrealtaste.domain.member.value.ProfileAddress import jakarta.validation.constraints.NotBlank import jakarta.validation.constraints.Size +/** + * 계정 정보 업데이트 폼 + */ data class AccountUpdateForm( @field:NotBlank(message = "닉네임을 입력해주세요.") @field:Size(min = 2, max = 20, message = "닉네임은 2자 이상 20자 이하로 입력해주세요.") @@ -17,11 +20,20 @@ data class AccountUpdateForm( @field:Size(max = 500, message = "소개글은 최대 500자까지 입력 가능합니다.") val introduction: String?, + + val address: String?, + + val imageId: Long?, ) { + /** + * AccountUpdateRequest로 변환 + */ fun toAccountUpdateRequest(): AccountUpdateRequest = AccountUpdateRequest( nickname = this.nickname?.let { Nickname(it) }, profileAddress = this.profileAddress?.let { ProfileAddress(it) }, - introduction = this.introduction?.let { Introduction(it) } + introduction = this.introduction?.let { Introduction(it) }, + address = this.address, + imageId = this.imageId ) } diff --git a/src/main/kotlin/com/albert/realmoneyrealtaste/adapter/webview/member/form/PasswordResetEmailForm.kt b/src/main/kotlin/com/albert/realmoneyrealtaste/adapter/webview/member/form/PasswordResetEmailForm.kt new file mode 100644 index 00000000..f4bea12f --- /dev/null +++ b/src/main/kotlin/com/albert/realmoneyrealtaste/adapter/webview/member/form/PasswordResetEmailForm.kt @@ -0,0 +1,10 @@ +package com.albert.realmoneyrealtaste.adapter.webview.member.form + +import jakarta.validation.constraints.Email +import jakarta.validation.constraints.NotBlank + +data class PasswordResetEmailForm( + @field:NotBlank(message = "이메일은 필수입니다") + @field:Email(message = "올바른 이메일 형식이 아닙니다") + val email: String, +) diff --git a/src/main/kotlin/com/albert/realmoneyrealtaste/adapter/webview/member/PasswordResetForm.kt b/src/main/kotlin/com/albert/realmoneyrealtaste/adapter/webview/member/form/PasswordResetForm.kt similarity index 86% rename from src/main/kotlin/com/albert/realmoneyrealtaste/adapter/webview/member/PasswordResetForm.kt rename to src/main/kotlin/com/albert/realmoneyrealtaste/adapter/webview/member/form/PasswordResetForm.kt index 237f19ad..51290925 100644 --- a/src/main/kotlin/com/albert/realmoneyrealtaste/adapter/webview/member/PasswordResetForm.kt +++ b/src/main/kotlin/com/albert/realmoneyrealtaste/adapter/webview/member/form/PasswordResetForm.kt @@ -1,9 +1,12 @@ -package com.albert.realmoneyrealtaste.adapter.webview.member +package com.albert.realmoneyrealtaste.adapter.webview.member.form import jakarta.validation.constraints.NotBlank import jakarta.validation.constraints.Pattern import jakarta.validation.constraints.Size +/** + * 비밀번호 재설정 폼 + */ data class PasswordResetForm( @field:NotBlank(message = "새 비밀번호는 필수입니다") @field:Size(min = 8, max = 20, message = "비밀번호는 8-20자 사이여야 합니다") diff --git a/src/main/kotlin/com/albert/realmoneyrealtaste/adapter/webview/member/PasswordUpdateForm.kt b/src/main/kotlin/com/albert/realmoneyrealtaste/adapter/webview/member/form/PasswordUpdateForm.kt similarity index 90% rename from src/main/kotlin/com/albert/realmoneyrealtaste/adapter/webview/member/PasswordUpdateForm.kt rename to src/main/kotlin/com/albert/realmoneyrealtaste/adapter/webview/member/form/PasswordUpdateForm.kt index 7592fbfe..5d180f71 100644 --- a/src/main/kotlin/com/albert/realmoneyrealtaste/adapter/webview/member/PasswordUpdateForm.kt +++ b/src/main/kotlin/com/albert/realmoneyrealtaste/adapter/webview/member/form/PasswordUpdateForm.kt @@ -1,9 +1,12 @@ -package com.albert.realmoneyrealtaste.adapter.webview.member +package com.albert.realmoneyrealtaste.adapter.webview.member.form import jakarta.validation.constraints.NotBlank import jakarta.validation.constraints.Pattern import jakarta.validation.constraints.Size +/** + * 비밀번호 변경 폼 + */ data class PasswordUpdateForm( @field:Size(min = 8, max = 20, message = "비밀번호는 8자 이상 20자 이하로 입력해주세요.") @field:Pattern( diff --git a/src/main/kotlin/com/albert/realmoneyrealtaste/adapter/webview/member/message/MemberMessages.kt b/src/main/kotlin/com/albert/realmoneyrealtaste/adapter/webview/member/message/MemberMessages.kt new file mode 100644 index 00000000..a5f9500c --- /dev/null +++ b/src/main/kotlin/com/albert/realmoneyrealtaste/adapter/webview/member/message/MemberMessages.kt @@ -0,0 +1,40 @@ +package com.albert.realmoneyrealtaste.adapter.webview.member.message + +/** + * Member 관련 모든 메시지 상수 + */ +object MemberMessages { + + // ========== 인증 관련 메시지 ========== + object Auth { + const val ACTIVATION_EMAIL_RESENT = "활성화 이메일이 재전송되었습니다." + const val PASSWORD_RESET_EMAIL_SENT = "비밀번호 재설정 이메일이 전송되었습니다." + const val PASSWORD_RESET_SUCCESS = "비밀번호가 성공적으로 재설정되었습니다." + + const val INVALID_EMAIL_FORMAT = "올바른 이메일 형식을 입력해주세요." + const val INVALID_PASSWORD_FORMAT = "비밀번호 형식이 올바르지 않습니다." + const val PASSWORD_MISMATCH = "새 비밀번호와 비밀번호 확인이 일치하지 않습니다." + } + + // ========== 설정 관련 메시지 ========== + object Settings { + const val ACCOUNT_UPDATE_SUCCESS = "계정 정보가 성공적으로 업데이트되었습니다." + const val PASSWORD_UPDATE_SUCCESS = "비밀번호가 성공적으로 변경되었습니다." + const val ACCOUNT_DELETE_SUCCESS = "계정이 성공적으로 삭제되었습니다." + + const val INVALID_INPUT_ERROR = "입력값이 올바르지 않습니다." + const val INVALID_PASSWORD_FORMAT = "비밀번호 형식이 올바르지 않습니다." + const val PASSWORD_MISMATCH = "비밀번호 확인이 일치하지 않습니다." + const val ACCOUNT_DELETE_NOT_CONFIRMED = "계정 삭제 확인이 필요합니다." + } + + // ========== 시스템 에러 메시지 ========== + object Error { + const val MEMBER_ACTIVATE_FAILED = "회원 활성화에 실패했습니다. 다시 시도해주세요." + const val RESEND_ACTIVATION_EMAIL_FAILED = "활성화 이메일 재전송에 실패했습니다. 다시 시도해주세요." + const val MEMBER_UPDATE_FAILED = "회원 정보 수정에 실패했습니다. 다시 시도해주세요." + const val MEMBER_DEACTIVATE_FAILED = "회원 탈퇴에 실패했습니다. 다시 시도해주세요." + const val PASSWORD_RESET_FAILED = "비밀번호 재설정에 실패했습니다. 다시 시도해주세요." + const val MEMBER_NOT_FOUND = "회원 정보를 찾을 수 없습니다." + } +} diff --git a/src/main/kotlin/com/albert/realmoneyrealtaste/adapter/webview/member/util/MemberUtils.kt b/src/main/kotlin/com/albert/realmoneyrealtaste/adapter/webview/member/util/MemberUtils.kt new file mode 100644 index 00000000..bf00fcc7 --- /dev/null +++ b/src/main/kotlin/com/albert/realmoneyrealtaste/adapter/webview/member/util/MemberUtils.kt @@ -0,0 +1,57 @@ +package com.albert.realmoneyrealtaste.adapter.webview.member.util + +import org.springframework.web.servlet.mvc.support.RedirectAttributes + +/** + * Member 관련 유틸리티 함수 + */ +object MemberUtils { + + /** + * 성공 메시지를 Flash Attribute로 설정 + */ + fun setSuccessFlashAttribute( + redirectAttributes: RedirectAttributes, + message: String, + ) { + redirectAttributes.addFlashAttribute("success", true) + redirectAttributes.addFlashAttribute("message", message) + } + + /** + * 에러 메시지를 Flash Attribute로 설정 + */ + fun setErrorFlashAttribute( + redirectAttributes: RedirectAttributes, + errorMessage: String, + ) { + redirectAttributes.addFlashAttribute("success", false) + redirectAttributes.addFlashAttribute("error", errorMessage) + } + + /** + * 설정 페이지 에러 처리 (탭 포함 리다이렉트) + */ + fun handleSettingError( + redirectAttributes: RedirectAttributes, + tab: String, + errorMessage: String, + redirectUrl: String, + ): String { + setErrorFlashAttribute(redirectAttributes, errorMessage) + return "redirect:$redirectUrl#$tab" + } + + /** + * 설정 페이지 성공 처리 (탭 포함 리다이렉트) + */ + fun handleSettingSuccess( + redirectAttributes: RedirectAttributes, + tab: String, + successMessage: String, + redirectUrl: String, + ): String { + setSuccessFlashAttribute(redirectAttributes, successMessage) + return "redirect:$redirectUrl#$tab" + } +} diff --git a/src/main/kotlin/com/albert/realmoneyrealtaste/adapter/webview/member/validator/PasswordResetFormValidator.kt b/src/main/kotlin/com/albert/realmoneyrealtaste/adapter/webview/member/validator/PasswordResetFormValidator.kt new file mode 100644 index 00000000..dcd4595f --- /dev/null +++ b/src/main/kotlin/com/albert/realmoneyrealtaste/adapter/webview/member/validator/PasswordResetFormValidator.kt @@ -0,0 +1,25 @@ +package com.albert.realmoneyrealtaste.adapter.webview.member.validator + +import com.albert.realmoneyrealtaste.adapter.webview.member.form.PasswordResetForm +import org.springframework.stereotype.Component +import org.springframework.validation.Errors +import org.springframework.validation.Validator + +/** + * 비밀번호 재설정 폼 검증기 - 비밀번호 확인 일치 여부 검증 + */ +@Component +class PasswordResetFormValidator : Validator { + + override fun supports(clazz: Class<*>): Boolean { + return PasswordResetForm::class.java.isAssignableFrom(clazz) + } + + override fun validate(target: Any, errors: Errors) { + val form = target as PasswordResetForm + + if (form.newPassword != form.newPasswordConfirm) { + errors.rejectValue("newPasswordConfirm", "passwordMismatch", "새 비밀번호와 비밀번호 확인이 일치하지 않습니다.") + } + } +} diff --git a/src/main/kotlin/com/albert/realmoneyrealtaste/adapter/webview/member/PasswordUpdateFormValidator.kt b/src/main/kotlin/com/albert/realmoneyrealtaste/adapter/webview/member/validator/PasswordUpdateFormValidator.kt similarity index 72% rename from src/main/kotlin/com/albert/realmoneyrealtaste/adapter/webview/member/PasswordUpdateFormValidator.kt rename to src/main/kotlin/com/albert/realmoneyrealtaste/adapter/webview/member/validator/PasswordUpdateFormValidator.kt index a8de564b..6390ead1 100644 --- a/src/main/kotlin/com/albert/realmoneyrealtaste/adapter/webview/member/PasswordUpdateFormValidator.kt +++ b/src/main/kotlin/com/albert/realmoneyrealtaste/adapter/webview/member/validator/PasswordUpdateFormValidator.kt @@ -1,9 +1,13 @@ -package com.albert.realmoneyrealtaste.adapter.webview.member +package com.albert.realmoneyrealtaste.adapter.webview.member.validator +import com.albert.realmoneyrealtaste.adapter.webview.member.form.PasswordUpdateForm import org.springframework.stereotype.Component import org.springframework.validation.Errors import org.springframework.validation.Validator +/** + * 비밀번호 변경 폼 검증기 - 새 비밀번호 확인 일치 여부 검증 + */ @Component class PasswordUpdateFormValidator : Validator { diff --git a/src/main/kotlin/com/albert/realmoneyrealtaste/adapter/webview/post/PostCreationView.kt b/src/main/kotlin/com/albert/realmoneyrealtaste/adapter/webview/post/PostCreationView.kt new file mode 100644 index 00000000..a145f792 --- /dev/null +++ b/src/main/kotlin/com/albert/realmoneyrealtaste/adapter/webview/post/PostCreationView.kt @@ -0,0 +1,36 @@ +package com.albert.realmoneyrealtaste.adapter.webview.post + +import com.albert.realmoneyrealtaste.adapter.infrastructure.security.MemberPrincipal +import com.albert.realmoneyrealtaste.adapter.webview.post.form.PostCreateForm +import com.albert.realmoneyrealtaste.application.post.provided.PostCreator +import jakarta.validation.Valid +import org.springframework.security.core.annotation.AuthenticationPrincipal +import org.springframework.stereotype.Controller +import org.springframework.web.bind.annotation.ModelAttribute +import org.springframework.web.bind.annotation.PostMapping + +/** + * 게시글 생성을 담당하는 뷰 컨트롤러 + * 단일 책임 원칙에 따라 게시글 생성 관련 기능만 처리합니다. + */ +@Controller +class PostCreationView( + private val postCreator: PostCreator, +) { + + /** + * 새로운 게시글을 생성합니다. + * + * @param memberPrincipal 현재 인증된 사용자 정보 + * @param form 게시글 생성 폼 데이터 (유효성 검증됨) + * @return 생성된 게시글 상세 페이지로 리다이렉트 + */ + @PostMapping(PostUrls.CREATE) + fun createPost( + @AuthenticationPrincipal memberPrincipal: MemberPrincipal, + @Valid @ModelAttribute form: PostCreateForm, + ): String { + val post = postCreator.createPost(memberPrincipal.id, form.toPostCreateRequest()) + return PostUrls.REDIRECT_READ_DETAIL.format(post.requireId()) + } +} diff --git a/src/main/kotlin/com/albert/realmoneyrealtaste/adapter/webview/post/PostView.kt b/src/main/kotlin/com/albert/realmoneyrealtaste/adapter/webview/post/PostReadView.kt similarity index 60% rename from src/main/kotlin/com/albert/realmoneyrealtaste/adapter/webview/post/PostView.kt rename to src/main/kotlin/com/albert/realmoneyrealtaste/adapter/webview/post/PostReadView.kt index bf8b5e86..1b3c5c85 100644 --- a/src/main/kotlin/com/albert/realmoneyrealtaste/adapter/webview/post/PostView.kt +++ b/src/main/kotlin/com/albert/realmoneyrealtaste/adapter/webview/post/PostReadView.kt @@ -1,11 +1,8 @@ package com.albert.realmoneyrealtaste.adapter.webview.post import com.albert.realmoneyrealtaste.adapter.infrastructure.security.MemberPrincipal -import com.albert.realmoneyrealtaste.application.member.provided.MemberReader -import com.albert.realmoneyrealtaste.application.post.provided.PostCreator +import com.albert.realmoneyrealtaste.adapter.webview.post.form.PostCreateForm import com.albert.realmoneyrealtaste.application.post.provided.PostReader -import com.albert.realmoneyrealtaste.application.post.provided.PostUpdater -import jakarta.validation.Valid import org.springframework.data.domain.Pageable import org.springframework.data.domain.Sort import org.springframework.data.web.PageableDefault @@ -13,27 +10,26 @@ import org.springframework.security.core.annotation.AuthenticationPrincipal import org.springframework.stereotype.Controller import org.springframework.ui.Model import org.springframework.web.bind.annotation.GetMapping -import org.springframework.web.bind.annotation.ModelAttribute import org.springframework.web.bind.annotation.PathVariable -import org.springframework.web.bind.annotation.PostMapping import org.springframework.web.bind.annotation.RequestParam +/** + * 게시글 조회를 담당하는 뷰 컨트롤러 + * 단일 책임 원칙에 따라 게시글 조회 관련 기능만 처리합니다. + */ @Controller -class PostView( - private val postCreator: PostCreator, +class PostReadView( private val postReader: PostReader, - private val postUpdater: PostUpdater, - private val memberReader: MemberReader, ) { - @PostMapping(PostUrls.CREATE) - fun createPost( - @AuthenticationPrincipal memberPrincipal: MemberPrincipal, - @Valid @ModelAttribute form: PostCreateForm, - ): String { - val post = postCreator.createPost(memberPrincipal.id, form.toPostCreateRequest()) - return PostUrls.REDIRECT_READ_DETAIL.format(post.requireId()) - } + /** + * 현재 로그인한 사용자의 게시글 목록 페이지를 조회합니다. + * + * @param memberPrincipal 현재 인증된 사용자 정보 + * @param pageable 페이징 정보 (기본: 생성일 내림차순, 10개씩) + * @param model 뷰에 전달할 데이터 모델 + * @return 내 게시글 목록 뷰 + */ @GetMapping(PostUrls.READ_MY_LIST) fun readMyPosts( @AuthenticationPrincipal memberPrincipal: MemberPrincipal, @@ -53,7 +49,17 @@ class PostView( return PostViews.MY_LIST } - @GetMapping("/members/{id}/posts/fragment") + /** + * 특정 멤버의 게시글 목록 프래그먼트를 조회합니다. + * 비동기 AJAX 요청에 사용됩니다. + * + * @param id 게시글 작성자 ID + * @param memberPrincipal 현재 인증된 사용자 정보 (선택사항) + * @param pageable 페이징 정보 (기본: 생성일 내림차순, 10개씩) + * @param model 뷰에 전달할 데이터 모델 + * @return 게시글 목록 프래그먼트 뷰 + */ + @GetMapping(PostUrls.READ_MEMBER_POSTS_FRAGMENT) fun readMemberPostsFragment( @PathVariable id: Long, @AuthenticationPrincipal memberPrincipal: MemberPrincipal?, @@ -61,15 +67,23 @@ class PostView( model: Model, ): String { val postsPage = postReader.readPostsByAuthor(id, pageable) - val author = memberReader.readMemberById(id) - model.addAttribute("author", author) + model.addAttribute("authorId", id) model.addAttribute("posts", postsPage) model.addAttribute("member", memberPrincipal) // 현재 로그인한 사용자 return PostViews.POSTS_CONTENT } + /** + * 현재 로그인한 사용자의 게시글 목록 프래그먼트를 조회합니다. + * 비동기 AJAX 요청에 사용됩니다. + * + * @param memberPrincipal 현재 인증된 사용자 정보 + * @param pageable 페이징 정보 (기본: 생성일 내림차순, 10개씩) + * @param model 뷰에 전달할 데이터 모델 + * @return 게시글 목록 프래그먼트 뷰 + */ @GetMapping(PostUrls.READ_MY_LIST_FRAGMENT) fun readMyPostsFragment( @AuthenticationPrincipal memberPrincipal: MemberPrincipal, @@ -86,6 +100,15 @@ class PostView( return PostViews.POSTS_CONTENT } + /** + * 전체 게시글 목록 프래그먼트를 조회합니다. + * 인증된 사용자와 비인증 사용자 모두 접근 가능합니다. + * + * @param memberPrincipal 현재 인증된 사용자 정보 (선택사항) + * @param pageable 페이징 정보 (기본: 생성일 내림차순, 10개씩) + * @param model 뷰에 전달할 데이터 모델 + * @return 게시글 목록 프래그먼트 뷰 + */ @GetMapping(PostUrls.READ_LIST_FRAGMENT) fun readPosts( @AuthenticationPrincipal memberPrincipal: MemberPrincipal?, @@ -103,6 +126,15 @@ class PostView( return PostViews.POSTS_CONTENT } + /** + * 특정 게시글의 상세 페이지를 조회합니다. + * 인증된 사용자와 비인증 사용자 모두 접근 가능합니다. + * + * @param memberPrincipal 현재 인증된 사용자 정보 (선택사항) + * @param postId 조회할 게시글 ID + * @param model 뷰에 전달할 데이터 모델 + * @return 게시글 상세 뷰 + */ @GetMapping(PostUrls.READ_DETAIL) fun readPost( @AuthenticationPrincipal memberPrincipal: MemberPrincipal?, @@ -118,27 +150,15 @@ class PostView( return PostViews.DETAIL } - @GetMapping(PostUrls.UPDATE) - fun editPost( - @AuthenticationPrincipal memberPrincipal: MemberPrincipal, - @PathVariable postId: Long, - model: Model, - ): String { - val post = postReader.readPostByAuthorAndId(memberPrincipal.id, postId) - model.addAttribute("postEditForm", PostEditForm.fromPost(post)) - return PostViews.EDIT - } - - @PostMapping(PostUrls.UPDATE) - fun updatePost( - @AuthenticationPrincipal memberPrincipal: MemberPrincipal, - @PathVariable postId: Long, - @Valid @ModelAttribute postEditForm: PostEditForm, - ): String { - postUpdater.updatePost(postId, memberPrincipal.id, postEditForm.toPostEditRequest()) - return PostUrls.REDIRECT_READ_DETAIL.format(postId) - } - + /** + * 게시글 상세 정보를 모달 형태로 조회합니다. + * 인증된 사용자와 비인증 사용자 모두 접근 가능합니다. + * + * @param memberPrincipal 현재 인증된 사용자 정보 (선택사항) + * @param postId 조회할 게시글 ID + * @param model 뷰에 전달할 데이터 모델 + * @return 게시글 상세 모달 뷰 + */ @GetMapping(PostUrls.READ_DETAIL_MODAL) fun readPostDetailModal( @AuthenticationPrincipal memberPrincipal: MemberPrincipal?, @@ -155,22 +175,18 @@ class PostView( return PostViews.DETAIL_MODAL } - private fun postDetailModelSetup( - memberPrincipal: MemberPrincipal, - postId: Long, - model: Model, - ) { - val currentUserId = memberPrincipal.id - val post = postReader.readPostById(currentUserId, postId) - model.addAttribute("post", post) - model.addAttribute("currentUserId", currentUserId) - model.addAttribute("currentUserNickname", memberPrincipal.nickname.value) - } - /** - * 컬렉션 게시글 목록 프래그먼트 조회 + * 컬렉션에 속한 게시글 목록 프래그먼트를 조회합니다. + * 특정 컬렉션의 게시글들을 ID 목록으로 조회하여 표시합니다. + * + * @param postIds 조회할 게시글 ID 목록 + * @param collectionId 컬렉션 ID + * @param authorId 게시글 작성자 ID + * @param principal 현재 인증된 사용자 정보 (선택사항) + * @param model 뷰에 전달할 데이터 모델 + * @return 컬렉션 게시글 목록 프래그먼트 뷰 */ - @GetMapping("members/{authorId}/collections/{collectionId}/posts/fragment") + @GetMapping(PostUrls.READ_COLLECTION_POSTS_FRAGMENT) fun readPostListFragment( @RequestParam postIds: List, @PathVariable collectionId: Long, @@ -194,4 +210,24 @@ class PostView( return PostViews.POST_LIST_FRAGMENT } + + /** + * 인증된 사용자를 위한 게시글 상세 페이지 모델 설정을 수행합니다. + * 사용자 권한에 따른 접근 제어와 추가 정보를 설정합니다. + * + * @param memberPrincipal 현재 인증된 사용자 정보 + * @param postId 조회할 게시글 ID + * @param model 뷰에 전달할 데이터 모델 + */ + private fun postDetailModelSetup( + memberPrincipal: MemberPrincipal, + postId: Long, + model: Model, + ) { + val currentUserId = memberPrincipal.id + val post = postReader.readPostById(currentUserId, postId) + model.addAttribute("post", post) + model.addAttribute("currentUserId", currentUserId) + model.addAttribute("currentUserNickname", memberPrincipal.nickname.value) + } } diff --git a/src/main/kotlin/com/albert/realmoneyrealtaste/adapter/webview/post/PostUpdateView.kt b/src/main/kotlin/com/albert/realmoneyrealtaste/adapter/webview/post/PostUpdateView.kt new file mode 100644 index 00000000..a9c65ce9 --- /dev/null +++ b/src/main/kotlin/com/albert/realmoneyrealtaste/adapter/webview/post/PostUpdateView.kt @@ -0,0 +1,64 @@ +package com.albert.realmoneyrealtaste.adapter.webview.post + +import com.albert.realmoneyrealtaste.adapter.infrastructure.security.MemberPrincipal +import com.albert.realmoneyrealtaste.adapter.webview.post.form.PostEditForm +import com.albert.realmoneyrealtaste.application.post.provided.PostReader +import com.albert.realmoneyrealtaste.application.post.provided.PostUpdater +import jakarta.validation.Valid +import org.springframework.security.core.annotation.AuthenticationPrincipal +import org.springframework.stereotype.Controller +import org.springframework.ui.Model +import org.springframework.web.bind.annotation.GetMapping +import org.springframework.web.bind.annotation.ModelAttribute +import org.springframework.web.bind.annotation.PathVariable +import org.springframework.web.bind.annotation.PostMapping + +/** + * 게시글 수정을 담당하는 뷰 컨트롤러 + * 단일 책임 원칙에 따라 게시글 수정 관련 기능만 처리합니다. + */ +@Controller +class PostUpdateView( + private val postReader: PostReader, + private val postUpdater: PostUpdater, +) { + + /** + * 게시글 수정 페이지를 조회합니다. + * 게시글 작성자만 접근 가능합니다. + * + * @param memberPrincipal 현재 인증된 사용자 정보 + * @param postId 수정할 게시글 ID + * @param model 뷰에 전달할 데이터 모델 + * @return 게시글 수정 뷰 + */ + @GetMapping(PostUrls.UPDATE) + fun editPost( + @AuthenticationPrincipal memberPrincipal: MemberPrincipal, + @PathVariable postId: Long, + model: Model, + ): String { + val post = postReader.readPostByAuthorAndId(memberPrincipal.id, postId) + model.addAttribute("postEditForm", PostEditForm.fromPost(post)) + return PostViews.EDIT + } + + /** + * 게시글을 수정합니다. + * 게시글 작성자만 수정 가능합니다. + * + * @param memberPrincipal 현재 인증된 사용자 정보 + * @param postId 수정할 게시글 ID + * @param postEditForm 게시글 수정 폼 데이터 (유효성 검증됨) + * @return 수정된 게시글 상세 페이지로 리다이렉트 + */ + @PostMapping(PostUrls.UPDATE) + fun updatePost( + @AuthenticationPrincipal memberPrincipal: MemberPrincipal, + @PathVariable postId: Long, + @Valid @ModelAttribute postEditForm: PostEditForm, + ): String { + postUpdater.updatePost(postId, memberPrincipal.id, postEditForm.toPostEditRequest()) + return PostUrls.REDIRECT_READ_DETAIL.format(postId) + } +} diff --git a/src/main/kotlin/com/albert/realmoneyrealtaste/adapter/webview/post/PostUrls.kt b/src/main/kotlin/com/albert/realmoneyrealtaste/adapter/webview/post/PostUrls.kt index f50c442f..664d4c70 100644 --- a/src/main/kotlin/com/albert/realmoneyrealtaste/adapter/webview/post/PostUrls.kt +++ b/src/main/kotlin/com/albert/realmoneyrealtaste/adapter/webview/post/PostUrls.kt @@ -11,4 +11,6 @@ object PostUrls { const val REDIRECT_READ_DETAIL = "redirect:/posts/%d" const val READ_MY_LIST_FRAGMENT = "/posts/mine/fragment" + const val READ_MEMBER_POSTS_FRAGMENT = "/members/{id}/posts/fragment" + const val READ_COLLECTION_POSTS_FRAGMENT = "members/{authorId}/collections/{collectionId}/posts/fragment" } diff --git a/src/main/kotlin/com/albert/realmoneyrealtaste/adapter/webview/post/PostViews.kt b/src/main/kotlin/com/albert/realmoneyrealtaste/adapter/webview/post/PostViews.kt index eb903f6a..fcfb84d5 100644 --- a/src/main/kotlin/com/albert/realmoneyrealtaste/adapter/webview/post/PostViews.kt +++ b/src/main/kotlin/com/albert/realmoneyrealtaste/adapter/webview/post/PostViews.kt @@ -5,7 +5,7 @@ object PostViews { const val EDIT = "post/edit" const val MY_LIST = "post/my-list" - const val DETAIL_MODAL = "post/modal-detail :: post-detail-modal" - const val POSTS_CONTENT = "post/fragments :: posts-content" + const val DETAIL_MODAL = "post/fragments/modal-detail :: post-detail-modal" + const val POSTS_CONTENT = "post/fragments/posts-content :: posts-content" const val POST_LIST_FRAGMENT = "post/fragments/post-list :: post-list" } diff --git a/src/main/kotlin/com/albert/realmoneyrealtaste/adapter/webview/post/PostCreateForm.kt b/src/main/kotlin/com/albert/realmoneyrealtaste/adapter/webview/post/form/PostCreateForm.kt similarity index 96% rename from src/main/kotlin/com/albert/realmoneyrealtaste/adapter/webview/post/PostCreateForm.kt rename to src/main/kotlin/com/albert/realmoneyrealtaste/adapter/webview/post/form/PostCreateForm.kt index 9f52a2a2..789b7ec3 100644 --- a/src/main/kotlin/com/albert/realmoneyrealtaste/adapter/webview/post/PostCreateForm.kt +++ b/src/main/kotlin/com/albert/realmoneyrealtaste/adapter/webview/post/form/PostCreateForm.kt @@ -1,4 +1,4 @@ -package com.albert.realmoneyrealtaste.adapter.webview.post +package com.albert.realmoneyrealtaste.adapter.webview.post.form import com.albert.realmoneyrealtaste.application.post.dto.PostCreateRequest import com.albert.realmoneyrealtaste.domain.post.value.PostContent diff --git a/src/main/kotlin/com/albert/realmoneyrealtaste/adapter/webview/post/PostEditForm.kt b/src/main/kotlin/com/albert/realmoneyrealtaste/adapter/webview/post/form/PostEditForm.kt similarity index 96% rename from src/main/kotlin/com/albert/realmoneyrealtaste/adapter/webview/post/PostEditForm.kt rename to src/main/kotlin/com/albert/realmoneyrealtaste/adapter/webview/post/form/PostEditForm.kt index df348085..c2a636a8 100644 --- a/src/main/kotlin/com/albert/realmoneyrealtaste/adapter/webview/post/PostEditForm.kt +++ b/src/main/kotlin/com/albert/realmoneyrealtaste/adapter/webview/post/form/PostEditForm.kt @@ -1,4 +1,4 @@ -package com.albert.realmoneyrealtaste.adapter.webview.post +package com.albert.realmoneyrealtaste.adapter.webview.post.form import com.albert.realmoneyrealtaste.application.post.dto.PostUpdateRequest import com.albert.realmoneyrealtaste.domain.post.Post diff --git a/src/main/kotlin/com/albert/realmoneyrealtaste/adapter/webview/util/BindingResultUtils.kt b/src/main/kotlin/com/albert/realmoneyrealtaste/adapter/webview/util/BindingResultUtils.kt new file mode 100644 index 00000000..dad125d0 --- /dev/null +++ b/src/main/kotlin/com/albert/realmoneyrealtaste/adapter/webview/util/BindingResultUtils.kt @@ -0,0 +1,19 @@ +package com.albert.realmoneyrealtaste.adapter.webview.util + +import org.springframework.validation.BindingResult + +/** + * BindingResult 관련 유틸리티 클래스 + */ +object BindingResultUtils { + + /** + * BindingResult에서 첫 번째 에러 메시지만 추출 + * + * @param bindingResult 검증 결과 + * @return 첫 번째 에러 메시지 또는 빈 문자열 + */ + fun extractFirstErrorMessage(bindingResult: BindingResult): String { + return bindingResult.allErrors.firstOrNull { it.defaultMessage != null }?.defaultMessage.orEmpty() + } +} diff --git a/src/main/kotlin/com/albert/realmoneyrealtaste/application/collection/dto/PostCollectionDetailResponse.kt b/src/main/kotlin/com/albert/realmoneyrealtaste/application/collection/dto/PostCollectionDetailResponse.kt new file mode 100644 index 00000000..aeedcffe --- /dev/null +++ b/src/main/kotlin/com/albert/realmoneyrealtaste/application/collection/dto/PostCollectionDetailResponse.kt @@ -0,0 +1,11 @@ +package com.albert.realmoneyrealtaste.application.collection.dto + +import com.albert.realmoneyrealtaste.domain.collection.PostCollection +import com.albert.realmoneyrealtaste.domain.post.Post +import org.springframework.data.domain.Page + +data class PostCollectionDetailResponse( + val collection: PostCollection, + val posts: List, + val myPosts: Page, +) diff --git a/src/main/kotlin/com/albert/realmoneyrealtaste/application/collection/provided/CollectionReader.kt b/src/main/kotlin/com/albert/realmoneyrealtaste/application/collection/provided/CollectionReader.kt index d404e744..c558c302 100644 --- a/src/main/kotlin/com/albert/realmoneyrealtaste/application/collection/provided/CollectionReader.kt +++ b/src/main/kotlin/com/albert/realmoneyrealtaste/application/collection/provided/CollectionReader.kt @@ -1,5 +1,6 @@ package com.albert.realmoneyrealtaste.application.collection.provided +import com.albert.realmoneyrealtaste.application.collection.dto.PostCollectionDetailResponse import com.albert.realmoneyrealtaste.domain.collection.PostCollection import org.springframework.data.domain.Page import org.springframework.data.domain.Pageable @@ -11,4 +12,6 @@ interface CollectionReader { fun readMyPublicCollections(ownerMemberId: Long, pageRequest: Pageable): Page fun readById(collectionId: Long): PostCollection + + fun readDetail(memberId: Long, collectionId: Long, pageRequest: Pageable): PostCollectionDetailResponse } diff --git a/src/main/kotlin/com/albert/realmoneyrealtaste/application/collection/service/CollectionReadService.kt b/src/main/kotlin/com/albert/realmoneyrealtaste/application/collection/service/CollectionReadService.kt index f0ad8bec..88804466 100644 --- a/src/main/kotlin/com/albert/realmoneyrealtaste/application/collection/service/CollectionReadService.kt +++ b/src/main/kotlin/com/albert/realmoneyrealtaste/application/collection/service/CollectionReadService.kt @@ -1,8 +1,10 @@ package com.albert.realmoneyrealtaste.application.collection.service +import com.albert.realmoneyrealtaste.application.collection.dto.PostCollectionDetailResponse import com.albert.realmoneyrealtaste.application.collection.exception.CollectionNotFoundException import com.albert.realmoneyrealtaste.application.collection.provided.CollectionReader import com.albert.realmoneyrealtaste.application.collection.required.CollectionRepository +import com.albert.realmoneyrealtaste.application.post.provided.PostReader import com.albert.realmoneyrealtaste.domain.collection.CollectionPrivacy.PUBLIC import com.albert.realmoneyrealtaste.domain.collection.CollectionStatus import com.albert.realmoneyrealtaste.domain.collection.CollectionStatus.ACTIVE @@ -14,6 +16,7 @@ import org.springframework.stereotype.Service @Service class CollectionReadService( private val collectionRepository: CollectionRepository, + private val postReader: PostReader, ) : CollectionReader { override fun readMyCollections( ownerMemberId: Long, @@ -42,4 +45,11 @@ class CollectionReadService( return collectionRepository.findByIdAndStatusNot(collectionId, CollectionStatus.DELETED) ?: throw CollectionNotFoundException("컬렉션을 찾을 수 없습니다.") } + + override fun readDetail(memberId: Long, collectionId: Long, pageRequest: Pageable): PostCollectionDetailResponse { + val collection = readById(collectionId) + val posts = postReader.readPostsByIds(collection.posts.postIds) + val myPosts = postReader.readPostsByAuthor(memberId, pageRequest) + return PostCollectionDetailResponse(collection, posts, myPosts) + } } diff --git a/src/main/kotlin/com/albert/realmoneyrealtaste/application/follow/dto/FollowResponse.kt b/src/main/kotlin/com/albert/realmoneyrealtaste/application/follow/dto/FollowResponse.kt index d79a2f83..cbfc1f5c 100644 --- a/src/main/kotlin/com/albert/realmoneyrealtaste/application/follow/dto/FollowResponse.kt +++ b/src/main/kotlin/com/albert/realmoneyrealtaste/application/follow/dto/FollowResponse.kt @@ -9,10 +9,12 @@ import java.time.LocalDateTime */ data class FollowResponse( val followId: Long, - val followerId: Long, - val followerNickname: String, val followingId: Long, + val followingProfileImageId: Long, val followingNickname: String, + val followerId: Long, + val followerNickname: String, + val followerProfileImageId: Long, val status: FollowStatus, val createdAt: LocalDateTime, val updatedAt: LocalDateTime, @@ -27,8 +29,10 @@ data class FollowResponse( followId = follow.requireId(), followerId = follow.relationship.followerId, followerNickname = follow.relationship.followerNickname, + followerProfileImageId = follow.relationship.followerProfileImageId, followingId = follow.relationship.followingId, followingNickname = follow.relationship.followingNickname, + followingProfileImageId = follow.relationship.followingProfileImageId, status = follow.status, createdAt = follow.createdAt, updatedAt = follow.updatedAt diff --git a/src/main/kotlin/com/albert/realmoneyrealtaste/application/follow/provided/FollowTerminator.kt b/src/main/kotlin/com/albert/realmoneyrealtaste/application/follow/provided/FollowTerminator.kt index 30d44d16..56aa9683 100644 --- a/src/main/kotlin/com/albert/realmoneyrealtaste/application/follow/provided/FollowTerminator.kt +++ b/src/main/kotlin/com/albert/realmoneyrealtaste/application/follow/provided/FollowTerminator.kt @@ -1,10 +1,11 @@ package com.albert.realmoneyrealtaste.application.follow.provided -import com.albert.realmoneyrealtaste.application.follow.dto.UnfollowRequest - /** * 팔로우 해제 포트 */ fun interface FollowTerminator { - fun unfollow(request: UnfollowRequest) + fun unfollow( + followerId: Long, + followingId: Long, + ) } diff --git a/src/main/kotlin/com/albert/realmoneyrealtaste/application/follow/required/FollowRepository.kt b/src/main/kotlin/com/albert/realmoneyrealtaste/application/follow/required/FollowRepository.kt index eb88aef0..54ad8d92 100644 --- a/src/main/kotlin/com/albert/realmoneyrealtaste/application/follow/required/FollowRepository.kt +++ b/src/main/kotlin/com/albert/realmoneyrealtaste/application/follow/required/FollowRepository.kt @@ -36,7 +36,10 @@ interface FollowRepository : Repository { @Query( """ SELECT f FROM Follow f - WHERE f.relationship.followingId = :followingId AND f.status = :status + JOIN Member m ON f.relationship.followerId = m.id + WHERE f.relationship.followingId = :followingId + AND f.status = :status + AND m.status = 'ACTIVE' """ ) fun findFollowersByFollowingIdAndStatus(followingId: Long, status: FollowStatus, pageable: Pageable): Page @@ -44,7 +47,9 @@ interface FollowRepository : Repository { @Query( """ SELECT f FROM Follow f + JOIN Member m ON f.relationship.followingId = m.id WHERE f.relationship.followerId = :followerId AND f.status = :status + AND m.status = 'ACTIVE' """ ) fun findFollowingsByFollowerIdAndStatus(followerId: Long, status: FollowStatus, pageable: Pageable): Page @@ -121,9 +126,11 @@ interface FollowRepository : Repository { @Query( """ SELECT f FROM Follow f + JOIN Member m ON f.relationship.followerId = m.id WHERE f.relationship.followingId = :memberId AND f.status = :status AND f.relationship.followerNickname LIKE %:keyword% + AND m.status = 'ACTIVE' """ ) fun searchFollowersByFollowingIdAndStatus( @@ -140,9 +147,11 @@ interface FollowRepository : Repository { @Query( """ SELECT f FROM Follow f + JOIN Member m ON f.relationship.followingId = m.id WHERE f.relationship.followerId = :memberId AND f.status = :status AND f.relationship.followingNickname LIKE %:keyword% + AND m.status = 'ACTIVE' """ ) fun searchFollowingsByFollowerIdAndStatus( diff --git a/src/main/kotlin/com/albert/realmoneyrealtaste/application/follow/service/FollowCreationService.kt b/src/main/kotlin/com/albert/realmoneyrealtaste/application/follow/service/FollowCreationService.kt index 6ee1182d..fbe41721 100644 --- a/src/main/kotlin/com/albert/realmoneyrealtaste/application/follow/service/FollowCreationService.kt +++ b/src/main/kotlin/com/albert/realmoneyrealtaste/application/follow/service/FollowCreationService.kt @@ -47,8 +47,10 @@ class FollowCreationService( val command = FollowCreateCommand( followerId = follower.requireId(), followerNickname = follower.nickname.value, + followerProfileImageId = follower.imageId, followingId = following.requireId(), followingNickname = following.nickname.value, + followingProfileImageId = following.imageId, ) val follow = Follow.create(command) val savedFollow = followRepository.save(follow) diff --git a/src/main/kotlin/com/albert/realmoneyrealtaste/application/follow/service/FollowListWithAuthorReaderImpl.kt b/src/main/kotlin/com/albert/realmoneyrealtaste/application/follow/service/FollowListWithAuthorReaderImpl.kt new file mode 100644 index 00000000..e69de29b diff --git a/src/main/kotlin/com/albert/realmoneyrealtaste/application/follow/service/FollowTerminationService.kt b/src/main/kotlin/com/albert/realmoneyrealtaste/application/follow/service/FollowTerminationService.kt index 7ee4192d..28420d6a 100644 --- a/src/main/kotlin/com/albert/realmoneyrealtaste/application/follow/service/FollowTerminationService.kt +++ b/src/main/kotlin/com/albert/realmoneyrealtaste/application/follow/service/FollowTerminationService.kt @@ -22,8 +22,15 @@ class FollowTerminationService( const val ERROR_UNFOLLOW_FAILED = "언팔로우에 실패했습니다." } - override fun unfollow(request: UnfollowRequest) { + override fun unfollow( + followerId: Long, + followingId: Long, + ) { try { + val request = UnfollowRequest( + followerId = followerId, + followingId = followingId, + ) // 요청자가 활성 회원인지 확인 memberReader.readActiveMemberById(request.followerId) diff --git a/src/main/kotlin/com/albert/realmoneyrealtaste/application/friend/dto/FriendshipResponse.kt b/src/main/kotlin/com/albert/realmoneyrealtaste/application/friend/dto/FriendshipResponse.kt index e24c8058..f296e634 100644 --- a/src/main/kotlin/com/albert/realmoneyrealtaste/application/friend/dto/FriendshipResponse.kt +++ b/src/main/kotlin/com/albert/realmoneyrealtaste/application/friend/dto/FriendshipResponse.kt @@ -2,6 +2,7 @@ package com.albert.realmoneyrealtaste.application.friend.dto import com.albert.realmoneyrealtaste.domain.friend.Friendship import com.albert.realmoneyrealtaste.domain.friend.FriendshipStatus +import com.albert.realmoneyrealtaste.domain.member.Member import java.time.LocalDateTime /** @@ -15,31 +16,29 @@ data class FriendshipResponse( val status: FriendshipStatus, val createdAt: LocalDateTime, val updatedAt: LocalDateTime, - // 템플릿용 추가 필드 - val id: Long = friendMemberId, // 템플릿에서 사용할 ID - val nickname: String = friendNickname, // 템플릿에서 사용할 닉네임 - val mutualFriendsCount: Int = 0, // 상호 친구 수 (기본값) - val friendSince: LocalDateTime = createdAt, // 친구가 된 날짜 - val profileImageUrl: String? = null, // 프로필 이미지 URL + val mutualFriendsCount: Int = 0, + val memberNickname: String, + val memberProfileImageId: Long, + val friendProfileImageId: Long, ) { companion object { fun from( friendship: Friendship, - friendNickname: String, - mutualFriendsCount: Int = 0, - profileImageUrl: String? = null, + member: Member, + friend: Member, ): FriendshipResponse { return FriendshipResponse( friendshipId = friendship.requireId(), memberId = friendship.relationShip.memberId, friendMemberId = friendship.relationShip.friendMemberId, - friendNickname = friendNickname, + friendNickname = friend.nickname.value, status = friendship.status, createdAt = friendship.createdAt, updatedAt = friendship.updatedAt, - mutualFriendsCount = mutualFriendsCount, - profileImageUrl = profileImageUrl + memberNickname = member.nickname.value, + memberProfileImageId = member.detail.imageId ?: 0L, + friendProfileImageId = friend.detail.imageId ?: 0L, ) } } diff --git a/src/main/kotlin/com/albert/realmoneyrealtaste/application/friend/provided/FriendRequestor.kt b/src/main/kotlin/com/albert/realmoneyrealtaste/application/friend/provided/FriendRequestor.kt index 469e2414..fdcf361c 100644 --- a/src/main/kotlin/com/albert/realmoneyrealtaste/application/friend/provided/FriendRequestor.kt +++ b/src/main/kotlin/com/albert/realmoneyrealtaste/application/friend/provided/FriendRequestor.kt @@ -1,11 +1,10 @@ package com.albert.realmoneyrealtaste.application.friend.provided import com.albert.realmoneyrealtaste.domain.friend.Friendship -import com.albert.realmoneyrealtaste.domain.friend.command.FriendRequestCommand /** * 친구 요청 생성 포트 */ fun interface FriendRequestor { - fun sendFriendRequest(command: FriendRequestCommand): Friendship + fun sendFriendRequest(fromMemberId: Long, toMemberId: Long): Friendship } diff --git a/src/main/kotlin/com/albert/realmoneyrealtaste/application/friend/provided/FriendshipReader.kt b/src/main/kotlin/com/albert/realmoneyrealtaste/application/friend/provided/FriendshipReader.kt index 64ec96c5..35674320 100644 --- a/src/main/kotlin/com/albert/realmoneyrealtaste/application/friend/provided/FriendshipReader.kt +++ b/src/main/kotlin/com/albert/realmoneyrealtaste/application/friend/provided/FriendshipReader.kt @@ -20,7 +20,7 @@ interface FriendshipReader { fun findActiveFriendship(memberId: Long, friendMemberId: Long): Friendship? /** - * 두 회원 간의 친구 관계를 조회합니다. + * 보낸 친구 요청을 조회합니다. * * @param memberId 회원 ID * @param friendMemberId 친구 회원 ID @@ -116,7 +116,9 @@ interface FriendshipReader { */ fun countPendingRequests(memberId: Long): Long - fun findPendingRequests(memberId: Long, pageable: Pageable): Page + fun findPendingRequests(memberId: Long, pageable: Pageable): Page fun findByMembersId(memberId: Long, friendMemberId: Long): Friendship? + + fun isSent(memberId: Long, friendMemberId: Long): Boolean } diff --git a/src/main/kotlin/com/albert/realmoneyrealtaste/application/friend/provided/FriendshipTerminator.kt b/src/main/kotlin/com/albert/realmoneyrealtaste/application/friend/provided/FriendshipTerminator.kt index 4e3db16b..d455be9d 100644 --- a/src/main/kotlin/com/albert/realmoneyrealtaste/application/friend/provided/FriendshipTerminator.kt +++ b/src/main/kotlin/com/albert/realmoneyrealtaste/application/friend/provided/FriendshipTerminator.kt @@ -6,5 +6,5 @@ import com.albert.realmoneyrealtaste.application.friend.dto.UnfriendRequest * 친구 관계 해제 포트 */ fun interface FriendshipTerminator { - fun unfriend(request: UnfriendRequest): Unit + fun unfriend(request: UnfriendRequest) } diff --git a/src/main/kotlin/com/albert/realmoneyrealtaste/application/friend/required/FriendshipRepository.kt b/src/main/kotlin/com/albert/realmoneyrealtaste/application/friend/required/FriendshipRepository.kt index b6d7550d..8071b359 100644 --- a/src/main/kotlin/com/albert/realmoneyrealtaste/application/friend/required/FriendshipRepository.kt +++ b/src/main/kotlin/com/albert/realmoneyrealtaste/application/friend/required/FriendshipRepository.kt @@ -25,7 +25,9 @@ interface FriendshipRepository : Repository { @Query( """ SELECT f FROM Friendship f + JOIN Member m ON f.relationShip.memberId = m.id WHERE f.relationShip.friendMemberId = :friendMemberId AND f.status = :status + AND m.status = 'ACTIVE' ORDER BY f.createdAt DESC """ ) @@ -41,7 +43,10 @@ interface FriendshipRepository : Repository { @Query( """ SELECT f FROM Friendship f - WHERE f.relationShip.memberId = :memberId AND f.status = :status + JOIN Member m ON f.relationShip.friendMemberId = m.id + WHERE f.relationShip.memberId = :memberId + AND f.status = :status + AND m.status = 'ACTIVE' ORDER BY f.createdAt DESC """ ) @@ -97,8 +102,10 @@ interface FriendshipRepository : Repository { @Query( """ SELECT f1 FROM Friendship f1 + JOIN Member m ON f1.relationShip.friendMemberId = m.id WHERE f1.relationShip.memberId = :memberId AND f1.status = :status + AND m.status = 'ACTIVE' AND EXISTS ( SELECT 1 FROM Friendship f2 WHERE f2.relationShip.friendMemberId = :memberId @@ -121,8 +128,10 @@ interface FriendshipRepository : Repository { @Query( """ SELECT f1 FROM Friendship f1 + JOIN Member m ON f1.relationShip.friendMemberId = m.id WHERE f1.relationShip.memberId = :memberId AND f1.status = :status + AND m.status = 'ACTIVE' AND EXISTS ( SELECT 1 FROM Friendship f2 WHERE f2.relationShip.friendMemberId = :memberId @@ -146,8 +155,10 @@ interface FriendshipRepository : Repository { @Query( """ SELECT f1 FROM Friendship f1 + JOIN Member m ON f1.relationShip.friendMemberId = m.id WHERE f1.relationShip.memberId = :memberId AND f1.status = :status + AND m.status = 'ACTIVE' AND EXISTS ( SELECT 1 FROM Friendship f2 WHERE f2.relationShip.friendMemberId = :memberId @@ -162,4 +173,10 @@ interface FriendshipRepository : Repository { status: FriendshipStatus, pageable: Pageable, ): Page + + fun existsByRelationShipMemberIdAndRelationShipFriendMemberIdAndStatusIsIn( + memberId: Long, + friendMemberId: Long, + statusList: List, + ): Boolean } diff --git a/src/main/kotlin/com/albert/realmoneyrealtaste/application/friend/service/FriendRequestService.kt b/src/main/kotlin/com/albert/realmoneyrealtaste/application/friend/service/FriendRequestService.kt index 4e43dad1..971f841e 100644 --- a/src/main/kotlin/com/albert/realmoneyrealtaste/application/friend/service/FriendRequestService.kt +++ b/src/main/kotlin/com/albert/realmoneyrealtaste/application/friend/service/FriendRequestService.kt @@ -26,8 +26,17 @@ class FriendRequestService( const val ERROR_FRIEND_REQUEST_FAILED = "친구 요청에 실패했습니다." } - override fun sendFriendRequest(command: FriendRequestCommand): Friendship { + override fun sendFriendRequest(fromMemberId: Long, toMemberId: Long): Friendship { try { + val fromMember = memberReader.readActiveMemberById(fromMemberId) + val toMember = memberReader.readActiveMemberById(toMemberId) + + val command = FriendRequestCommand( + fromMemberId = fromMemberId, + fromMemberNickName = fromMember.nickname.value, + toMemberId = toMemberId, + toMemberNickname = toMember.nickname.value + ) // 요청자와 대상자가 모두 활성 회원인지 확인 validateMembersExist(command) diff --git a/src/main/kotlin/com/albert/realmoneyrealtaste/application/friend/service/FriendResponseService.kt b/src/main/kotlin/com/albert/realmoneyrealtaste/application/friend/service/FriendResponseService.kt index 196efe0c..b68a307f 100644 --- a/src/main/kotlin/com/albert/realmoneyrealtaste/application/friend/service/FriendResponseService.kt +++ b/src/main/kotlin/com/albert/realmoneyrealtaste/application/friend/service/FriendResponseService.kt @@ -7,7 +7,6 @@ import com.albert.realmoneyrealtaste.application.friend.provided.FriendResponder import com.albert.realmoneyrealtaste.application.friend.provided.FriendshipReader import com.albert.realmoneyrealtaste.application.member.provided.MemberReader import com.albert.realmoneyrealtaste.domain.friend.Friendship -import com.albert.realmoneyrealtaste.domain.friend.command.FriendRequestCommand import com.albert.realmoneyrealtaste.domain.friend.event.FriendRequestAcceptedEvent import com.albert.realmoneyrealtaste.domain.friend.event.FriendRequestRejectedEvent import com.albert.realmoneyrealtaste.domain.member.Member @@ -81,13 +80,8 @@ class FriendResponseService( } private fun createReverseFriendship(fromMemberId: Long, toMember: Member) { - val reverseFriendship = friendRequestor.sendFriendRequest( - FriendRequestCommand( - fromMemberId = fromMemberId, - toMemberId = toMember.requireId(), - toMemberNickname = toMember.nickname.value, - ) - ) + val reverseFriendship = + friendRequestor.sendFriendRequest(fromMemberId = fromMemberId, toMemberId = toMember.requireId()) reverseFriendship.accept() // 즉시 수락 상태로 설정 } } diff --git a/src/main/kotlin/com/albert/realmoneyrealtaste/application/friend/service/FriendshipReadService.kt b/src/main/kotlin/com/albert/realmoneyrealtaste/application/friend/service/FriendshipReadService.kt index df09be0a..bb6a1681 100644 --- a/src/main/kotlin/com/albert/realmoneyrealtaste/application/friend/service/FriendshipReadService.kt +++ b/src/main/kotlin/com/albert/realmoneyrealtaste/application/friend/service/FriendshipReadService.kt @@ -4,6 +4,7 @@ import com.albert.realmoneyrealtaste.application.friend.dto.FriendshipResponse import com.albert.realmoneyrealtaste.application.friend.exception.FriendshipNotFoundException import com.albert.realmoneyrealtaste.application.friend.provided.FriendshipReader import com.albert.realmoneyrealtaste.application.friend.required.FriendshipRepository +import com.albert.realmoneyrealtaste.application.member.provided.MemberReader import com.albert.realmoneyrealtaste.domain.friend.Friendship import com.albert.realmoneyrealtaste.domain.friend.FriendshipStatus import org.springframework.data.domain.Page @@ -15,12 +16,11 @@ import org.springframework.transaction.annotation.Transactional @Transactional(readOnly = true) class FriendshipReadService( private val friendshipRepository: FriendshipRepository, + private val memberReader: MemberReader, ) : FriendshipReader { companion object { const val ERROR_FRIENDSHIP_NOT_FOUND = "친구 관계를 찾을 수 없습니다." - - const val UNKNOWN_NICKNAME = "Unknown" } override fun findActiveFriendship(memberId: Long, friendMemberId: Long): Friendship? { @@ -116,16 +116,36 @@ class FriendshipReadService( ) } - override fun findPendingRequests(memberId: Long, pageable: Pageable): Page { - return friendshipRepository.findReceivedFriendships(memberId, FriendshipStatus.PENDING, pageable) + override fun findPendingRequests(memberId: Long, pageable: Pageable): Page { + val receivedFriendships = + friendshipRepository.findReceivedFriendships(memberId, FriendshipStatus.PENDING, pageable) + + return mapToFriendshipResponses(receivedFriendships) + } + + override fun isSent(memberId: Long, friendMemberId: Long): Boolean { + return friendshipRepository.existsByRelationShipMemberIdAndRelationShipFriendMemberIdAndStatusIsIn( + memberId, + friendMemberId, + listOf(FriendshipStatus.PENDING, FriendshipStatus.ACCEPTED) + ) } private fun mapToFriendshipResponses(friendships: Page): Page { + val membersMap = memberReader.readAllActiveMembersByIds( + friendships.content.map { it.relationShip.memberId } + ).associateBy { it.id } + val friendsMap = memberReader.readAllActiveMembersByIds( + friendships.content.map { it.relationShip.friendMemberId } + ).associateBy { it.id } + + return friendships .map { friendship -> FriendshipResponse.from( friendship, - friendship.relationShip.friendNickname, + membersMap.getValue(friendship.relationShip.memberId), + friendsMap.getValue(friendship.relationShip.friendMemberId), ) } } diff --git a/src/main/kotlin/com/albert/realmoneyrealtaste/application/image/dto/PresignedPutResponse.kt b/src/main/kotlin/com/albert/realmoneyrealtaste/application/image/dto/PresignedPutResponse.kt index d06c7578..eab71cc6 100644 --- a/src/main/kotlin/com/albert/realmoneyrealtaste/application/image/dto/PresignedPutResponse.kt +++ b/src/main/kotlin/com/albert/realmoneyrealtaste/application/image/dto/PresignedPutResponse.kt @@ -1,10 +1,12 @@ package com.albert.realmoneyrealtaste.application.image.dto +import com.fasterxml.jackson.annotation.JsonFormat import java.time.Instant data class PresignedPutResponse( val uploadUrl: String, val key: String, + @field:JsonFormat(shape = JsonFormat.Shape.STRING, pattern = "yyyy-MM-dd'T'HH:mm:ss", timezone = "UTC+9") val expiresAt: Instant, val metadata: Map, ) diff --git a/src/main/kotlin/com/albert/realmoneyrealtaste/application/image/service/ImageReadService.kt b/src/main/kotlin/com/albert/realmoneyrealtaste/application/image/service/ImageReadService.kt index a846b859..ca3fc319 100644 --- a/src/main/kotlin/com/albert/realmoneyrealtaste/application/image/service/ImageReadService.kt +++ b/src/main/kotlin/com/albert/realmoneyrealtaste/application/image/service/ImageReadService.kt @@ -22,8 +22,6 @@ class ImageReadService( val image = imageRepository.findByIdAndIsDeletedFalse(imageId) ?: throw ImageNotFoundException("이미지를 찾을 수 없습니다: $imageId") - require(image.canAccess(userId)) { "이미지에 접근 권한이 없습니다" } - return presignedUrlGenerator.generatePresignedGetUrl(image.fileKey.value) } diff --git a/src/main/kotlin/com/albert/realmoneyrealtaste/application/member/dto/AccountUpdateRequest.kt b/src/main/kotlin/com/albert/realmoneyrealtaste/application/member/dto/AccountUpdateRequest.kt index 768094a0..49f80d11 100644 --- a/src/main/kotlin/com/albert/realmoneyrealtaste/application/member/dto/AccountUpdateRequest.kt +++ b/src/main/kotlin/com/albert/realmoneyrealtaste/application/member/dto/AccountUpdateRequest.kt @@ -8,4 +8,6 @@ data class AccountUpdateRequest( val nickname: Nickname?, val profileAddress: ProfileAddress?, val introduction: Introduction?, + val address: String?, + val imageId: Long?, ) diff --git a/src/main/kotlin/com/albert/realmoneyrealtaste/application/member/dto/SuggestedMembersResult.kt b/src/main/kotlin/com/albert/realmoneyrealtaste/application/member/dto/SuggestedMembersResult.kt new file mode 100644 index 00000000..dca16cc9 --- /dev/null +++ b/src/main/kotlin/com/albert/realmoneyrealtaste/application/member/dto/SuggestedMembersResult.kt @@ -0,0 +1,11 @@ +package com.albert.realmoneyrealtaste.application.member.dto + +import com.albert.realmoneyrealtaste.domain.member.Member + +/** + * 추천 회원 조회 결과 + */ +data class SuggestedMembersResult( + val suggestedUsers: List, + val followingIds: List, +) diff --git a/src/main/kotlin/com/albert/realmoneyrealtaste/application/member/exception/DuplicateProfileAddressException.kt b/src/main/kotlin/com/albert/realmoneyrealtaste/application/member/exception/DuplicateProfileAddressException.kt index e59c3819..99712d58 100644 --- a/src/main/kotlin/com/albert/realmoneyrealtaste/application/member/exception/DuplicateProfileAddressException.kt +++ b/src/main/kotlin/com/albert/realmoneyrealtaste/application/member/exception/DuplicateProfileAddressException.kt @@ -3,6 +3,6 @@ package com.albert.realmoneyrealtaste.application.member.exception /** * 이미 사용 중인 프로필 주소에 대해 발생하는 예외 * - * @param message 예외 메시지 (기본값: "이미 사용 중인 프로필 주소입니다.") + * @param message 예외 메시지 */ -class DuplicateProfileAddressException(message: String = "이미 사용 중인 프로필 주소입니다.") : MemberApplicationException(message) +class DuplicateProfileAddressException(message: String) : MemberApplicationException(message) diff --git a/src/main/kotlin/com/albert/realmoneyrealtaste/application/member/exception/MemberActivateException.kt b/src/main/kotlin/com/albert/realmoneyrealtaste/application/member/exception/MemberActivateException.kt index bbbcc8d8..468698cf 100644 --- a/src/main/kotlin/com/albert/realmoneyrealtaste/application/member/exception/MemberActivateException.kt +++ b/src/main/kotlin/com/albert/realmoneyrealtaste/application/member/exception/MemberActivateException.kt @@ -4,6 +4,5 @@ package com.albert.realmoneyrealtaste.application.member.exception * 멤버 활성화 중에 발생하는 예외 * * @param message 예외 메시지 - * @param cause 예외 원인 */ -class MemberActivateException(message: String, cause: Throwable) : MemberApplicationException(message, cause) +class MemberActivateException(message: String) : MemberApplicationException(message) diff --git a/src/main/kotlin/com/albert/realmoneyrealtaste/application/member/exception/MemberApplicationException.kt b/src/main/kotlin/com/albert/realmoneyrealtaste/application/member/exception/MemberApplicationException.kt index 448bec5d..553250e6 100644 --- a/src/main/kotlin/com/albert/realmoneyrealtaste/application/member/exception/MemberApplicationException.kt +++ b/src/main/kotlin/com/albert/realmoneyrealtaste/application/member/exception/MemberApplicationException.kt @@ -4,7 +4,5 @@ package com.albert.realmoneyrealtaste.application.member.exception * 멤버 애플리케이션 관련 예외의 최상위 클래스 * * @param message 예외 메시지 - * @param cause 예외 원인 */ -sealed class MemberApplicationException(message: String, cause: Throwable? = null) : - IllegalArgumentException(message, cause) +sealed class MemberApplicationException(message: String) : IllegalArgumentException(message) diff --git a/src/main/kotlin/com/albert/realmoneyrealtaste/application/member/exception/MemberDeactivateException.kt b/src/main/kotlin/com/albert/realmoneyrealtaste/application/member/exception/MemberDeactivateException.kt index bb134404..4f505bda 100644 --- a/src/main/kotlin/com/albert/realmoneyrealtaste/application/member/exception/MemberDeactivateException.kt +++ b/src/main/kotlin/com/albert/realmoneyrealtaste/application/member/exception/MemberDeactivateException.kt @@ -4,6 +4,5 @@ package com.albert.realmoneyrealtaste.application.member.exception * 멤버 비활성화 중에 발생하는 예외 * * @param message 예외 메시지 - * @param cause 예외 원인 */ -class MemberDeactivateException(message: String, cause: Throwable) : MemberApplicationException(message, cause) +class MemberDeactivateException(message: String) : MemberApplicationException(message) diff --git a/src/main/kotlin/com/albert/realmoneyrealtaste/application/member/exception/MemberNotFoundException.kt b/src/main/kotlin/com/albert/realmoneyrealtaste/application/member/exception/MemberNotFoundException.kt index 63d33d58..38f7f984 100644 --- a/src/main/kotlin/com/albert/realmoneyrealtaste/application/member/exception/MemberNotFoundException.kt +++ b/src/main/kotlin/com/albert/realmoneyrealtaste/application/member/exception/MemberNotFoundException.kt @@ -4,6 +4,5 @@ package com.albert.realmoneyrealtaste.application.member.exception * 멤버 비활성화 중에 발생하는 예외 * * @param message 예외 메시지 - * @param cause 예외 원인 */ -class MemberNotFoundException(message: String, cause: Throwable? = null) : MemberApplicationException(message, cause) +class MemberNotFoundException(message: String) : MemberApplicationException(message) diff --git a/src/main/kotlin/com/albert/realmoneyrealtaste/application/member/exception/MemberRegisterException.kt b/src/main/kotlin/com/albert/realmoneyrealtaste/application/member/exception/MemberRegisterException.kt index d1e58eab..17b4aa42 100644 --- a/src/main/kotlin/com/albert/realmoneyrealtaste/application/member/exception/MemberRegisterException.kt +++ b/src/main/kotlin/com/albert/realmoneyrealtaste/application/member/exception/MemberRegisterException.kt @@ -1,3 +1,8 @@ package com.albert.realmoneyrealtaste.application.member.exception -class MemberRegisterException(message: String, cause: Throwable) : MemberApplicationException(message, cause) +/** + * 멤버 등록 중에 발생하는 예외 + * + * @param message 예외 메시지 + */ +class MemberRegisterException(message: String) : MemberApplicationException(message) diff --git a/src/main/kotlin/com/albert/realmoneyrealtaste/application/member/exception/MemberResendActivationEmailException.kt b/src/main/kotlin/com/albert/realmoneyrealtaste/application/member/exception/MemberResendActivationEmailException.kt index bfb8dcd1..5914fdab 100644 --- a/src/main/kotlin/com/albert/realmoneyrealtaste/application/member/exception/MemberResendActivationEmailException.kt +++ b/src/main/kotlin/com/albert/realmoneyrealtaste/application/member/exception/MemberResendActivationEmailException.kt @@ -4,7 +4,5 @@ package com.albert.realmoneyrealtaste.application.member.exception * 멤버 활성화 이메일 재전송 중에 발생하는 예외 * * @param message 예외 메시지 - * @param cause 예외 원인 */ -class MemberResendActivationEmailException(message: String, cause: Throwable?) : - MemberApplicationException(message, cause) +class MemberResendActivationEmailException(message: String) : MemberApplicationException(message) diff --git a/src/main/kotlin/com/albert/realmoneyrealtaste/application/member/exception/MemberUpdateException.kt b/src/main/kotlin/com/albert/realmoneyrealtaste/application/member/exception/MemberUpdateException.kt index e1229c87..7754c758 100644 --- a/src/main/kotlin/com/albert/realmoneyrealtaste/application/member/exception/MemberUpdateException.kt +++ b/src/main/kotlin/com/albert/realmoneyrealtaste/application/member/exception/MemberUpdateException.kt @@ -1,3 +1,8 @@ package com.albert.realmoneyrealtaste.application.member.exception -class MemberUpdateException(message: String, cause: Throwable?) : MemberApplicationException(message, cause) +/** + * 멤버 업데이트 중에 발생하는 예외 + * + * @param message 예외 메시지 + */ +class MemberUpdateException(message: String) : MemberApplicationException(message) diff --git a/src/main/kotlin/com/albert/realmoneyrealtaste/application/member/exception/MemberVerifyException.kt b/src/main/kotlin/com/albert/realmoneyrealtaste/application/member/exception/MemberVerifyException.kt index cf87ea3b..a4d9cb11 100644 --- a/src/main/kotlin/com/albert/realmoneyrealtaste/application/member/exception/MemberVerifyException.kt +++ b/src/main/kotlin/com/albert/realmoneyrealtaste/application/member/exception/MemberVerifyException.kt @@ -1,6 +1,8 @@ package com.albert.realmoneyrealtaste.application.member.exception -class MemberVerifyException( - message: String, - cause: Throwable, -) : MemberApplicationException(message, cause) +/** + * 멤버 인증 중에 발생하는 예외 + * + * @param message 예외 메시지 + */ +class MemberVerifyException(message: String) : MemberApplicationException(message) diff --git a/src/main/kotlin/com/albert/realmoneyrealtaste/application/member/exception/PassWordResetException.kt b/src/main/kotlin/com/albert/realmoneyrealtaste/application/member/exception/PassWordResetException.kt index e1616f4e..4ad26212 100644 --- a/src/main/kotlin/com/albert/realmoneyrealtaste/application/member/exception/PassWordResetException.kt +++ b/src/main/kotlin/com/albert/realmoneyrealtaste/application/member/exception/PassWordResetException.kt @@ -1,3 +1,8 @@ package com.albert.realmoneyrealtaste.application.member.exception -class PassWordResetException(message: String, cause: Throwable) : MemberApplicationException(message, cause) +/** + * 비밀번호 초기화 중에 발생하는 예외 + * + * @param message 예외 메시지 + */ +class PassWordResetException(message: String) : MemberApplicationException(message) diff --git a/src/main/kotlin/com/albert/realmoneyrealtaste/application/member/exception/PasswordChangeException.kt b/src/main/kotlin/com/albert/realmoneyrealtaste/application/member/exception/PasswordChangeException.kt index 28db19a5..5e52211e 100644 --- a/src/main/kotlin/com/albert/realmoneyrealtaste/application/member/exception/PasswordChangeException.kt +++ b/src/main/kotlin/com/albert/realmoneyrealtaste/application/member/exception/PasswordChangeException.kt @@ -1,3 +1,8 @@ package com.albert.realmoneyrealtaste.application.member.exception -class PasswordChangeException(message: String, cause: Throwable) : MemberApplicationException(message, cause) +/** + * 비밀번호 변경 중에 발생하는 예외 + * + * @param message 예외 메시지 + */ +class PasswordChangeException(message: String) : MemberApplicationException(message) diff --git a/src/main/kotlin/com/albert/realmoneyrealtaste/application/member/exception/PasswordResetTokenNotFoundException.kt b/src/main/kotlin/com/albert/realmoneyrealtaste/application/member/exception/PasswordResetTokenNotFoundException.kt index 34ff6fa1..9816f117 100644 --- a/src/main/kotlin/com/albert/realmoneyrealtaste/application/member/exception/PasswordResetTokenNotFoundException.kt +++ b/src/main/kotlin/com/albert/realmoneyrealtaste/application/member/exception/PasswordResetTokenNotFoundException.kt @@ -1,3 +1,8 @@ package com.albert.realmoneyrealtaste.application.member.exception +/** + * 비밀번호 초기화 토큰이 없을 때 발생하는 예외 + * + * @param message 예외 메시지 + */ class PasswordResetTokenNotFoundException(message: String) : MemberApplicationException(message) diff --git a/src/main/kotlin/com/albert/realmoneyrealtaste/application/member/exception/SendPasswordResetEmailException.kt b/src/main/kotlin/com/albert/realmoneyrealtaste/application/member/exception/SendPasswordResetEmailException.kt index 3999ba47..4931dc57 100644 --- a/src/main/kotlin/com/albert/realmoneyrealtaste/application/member/exception/SendPasswordResetEmailException.kt +++ b/src/main/kotlin/com/albert/realmoneyrealtaste/application/member/exception/SendPasswordResetEmailException.kt @@ -1,6 +1,8 @@ package com.albert.realmoneyrealtaste.application.member.exception -class SendPasswordResetEmailException( - message: String, - cause: Throwable, -) : MemberApplicationException(message, cause) +/** + * 비밀번호 초기화 이메일 전송 중에 발생하는 예외 + * + * @param message 예외 메시지 + */ +class SendPasswordResetEmailException(message: String) : MemberApplicationException(message) diff --git a/src/main/kotlin/com/albert/realmoneyrealtaste/application/member/provided/MemberReader.kt b/src/main/kotlin/com/albert/realmoneyrealtaste/application/member/provided/MemberReader.kt index d3ee66a8..808e2743 100644 --- a/src/main/kotlin/com/albert/realmoneyrealtaste/application/member/provided/MemberReader.kt +++ b/src/main/kotlin/com/albert/realmoneyrealtaste/application/member/provided/MemberReader.kt @@ -1,5 +1,6 @@ package com.albert.realmoneyrealtaste.application.member.provided +import com.albert.realmoneyrealtaste.application.member.dto.SuggestedMembersResult import com.albert.realmoneyrealtaste.application.member.exception.MemberNotFoundException import com.albert.realmoneyrealtaste.domain.member.Member import com.albert.realmoneyrealtaste.domain.member.value.Email @@ -86,4 +87,13 @@ interface MemberReader { * @return 추천 회원 목록 */ fun findSuggestedMembers(memberId: Long, limit: Long): List + + /** + * 주어진 회원 ID에 대한 추천 회원 목록과 팔로잉 상태를 함께 조회합니다. + * + * @param memberId 추천 회원 목록을 조회할 회원 ID + * @param limit 추천 회원 목록의 개수 + * @return 추천 회원 목록과 팔로잉 상태 + */ + fun findSuggestedMembersWithFollowStatus(memberId: Long, limit: Long): SuggestedMembersResult } diff --git a/src/main/kotlin/com/albert/realmoneyrealtaste/application/member/provided/PasswordResetter.kt b/src/main/kotlin/com/albert/realmoneyrealtaste/application/member/provided/PasswordResetter.kt index 49a273e7..5c120002 100644 --- a/src/main/kotlin/com/albert/realmoneyrealtaste/application/member/provided/PasswordResetter.kt +++ b/src/main/kotlin/com/albert/realmoneyrealtaste/application/member/provided/PasswordResetter.kt @@ -2,7 +2,6 @@ package com.albert.realmoneyrealtaste.application.member.provided import com.albert.realmoneyrealtaste.application.member.exception.PassWordResetException import com.albert.realmoneyrealtaste.application.member.exception.SendPasswordResetEmailException -import com.albert.realmoneyrealtaste.domain.member.value.Email import com.albert.realmoneyrealtaste.domain.member.value.RawPassword /** @@ -15,7 +14,7 @@ interface PasswordResetter { * @param email 대상 이메일 * @throws SendPasswordResetEmailException 이메일 전송에 실패한 경우 발생 */ - fun sendPasswordResetEmail(email: Email) + fun sendPasswordResetEmail(email: String) /** * 비밀번호 재설정 diff --git a/src/main/kotlin/com/albert/realmoneyrealtaste/application/member/service/MemberActivationService.kt b/src/main/kotlin/com/albert/realmoneyrealtaste/application/member/service/MemberActivationService.kt index 83c4f743..257ea59f 100644 --- a/src/main/kotlin/com/albert/realmoneyrealtaste/application/member/service/MemberActivationService.kt +++ b/src/main/kotlin/com/albert/realmoneyrealtaste/application/member/service/MemberActivationService.kt @@ -46,7 +46,7 @@ class MemberActivationService( return member } catch (e: IllegalArgumentException) { - throw MemberActivateException(ERROR_MEMBER_ACTIVATE_FAILED, e) + throw MemberActivateException(ERROR_MEMBER_ACTIVATE_FAILED) } } @@ -60,7 +60,7 @@ class MemberActivationService( publishResendActivationEmailEvent(member, newToken) } catch (e: IllegalArgumentException) { - throw MemberResendActivationEmailException(ERROR_ACTIVATION_TOKEN_RESEND_EMAIL_FAILED, e) + throw MemberResendActivationEmailException(ERROR_ACTIVATION_TOKEN_RESEND_EMAIL_FAILED) } } diff --git a/src/main/kotlin/com/albert/realmoneyrealtaste/application/member/service/MemberReadService.kt b/src/main/kotlin/com/albert/realmoneyrealtaste/application/member/service/MemberReadService.kt index cd76c8d4..148a9498 100644 --- a/src/main/kotlin/com/albert/realmoneyrealtaste/application/member/service/MemberReadService.kt +++ b/src/main/kotlin/com/albert/realmoneyrealtaste/application/member/service/MemberReadService.kt @@ -1,5 +1,7 @@ package com.albert.realmoneyrealtaste.application.member.service +import com.albert.realmoneyrealtaste.application.follow.provided.FollowReader +import com.albert.realmoneyrealtaste.application.member.dto.SuggestedMembersResult import com.albert.realmoneyrealtaste.application.member.exception.MemberNotFoundException import com.albert.realmoneyrealtaste.application.member.provided.MemberReader import com.albert.realmoneyrealtaste.application.member.required.MemberRepository @@ -14,6 +16,7 @@ import org.springframework.transaction.annotation.Transactional @Service class MemberReadService( private val memberRepository: MemberRepository, + private val followReader: FollowReader, ) : MemberReader { companion object { @@ -62,4 +65,15 @@ class MemberReadService( ): List { return memberRepository.findSuggestedMembers(memberId, MemberStatus.ACTIVE, limit) } + + override fun findSuggestedMembersWithFollowStatus( + memberId: Long, + limit: Long, + ): SuggestedMembersResult { + val suggestedUsers = memberRepository.findSuggestedMembers(memberId, MemberStatus.ACTIVE, limit) + val targetIds = suggestedUsers.map { it.requireId() } + val followingIds = followReader.findFollowings(memberId, targetIds) + + return SuggestedMembersResult(suggestedUsers, followingIds) + } } diff --git a/src/main/kotlin/com/albert/realmoneyrealtaste/application/member/service/MemberRegistrationService.kt b/src/main/kotlin/com/albert/realmoneyrealtaste/application/member/service/MemberRegistrationService.kt index af94bb58..fe319f86 100644 --- a/src/main/kotlin/com/albert/realmoneyrealtaste/application/member/service/MemberRegistrationService.kt +++ b/src/main/kotlin/com/albert/realmoneyrealtaste/application/member/service/MemberRegistrationService.kt @@ -44,7 +44,7 @@ class MemberRegistrationService( return savedMember } catch (e: IllegalArgumentException) { - throw MemberRegisterException(ERROR_MEMBER_REGISTRATION_FAILED, e) + throw MemberRegisterException(ERROR_MEMBER_REGISTRATION_FAILED) } } diff --git a/src/main/kotlin/com/albert/realmoneyrealtaste/application/member/service/MemberUpdateService.kt b/src/main/kotlin/com/albert/realmoneyrealtaste/application/member/service/MemberUpdateService.kt index 92d51a07..c4900bca 100644 --- a/src/main/kotlin/com/albert/realmoneyrealtaste/application/member/service/MemberUpdateService.kt +++ b/src/main/kotlin/com/albert/realmoneyrealtaste/application/member/service/MemberUpdateService.kt @@ -25,6 +25,7 @@ class MemberUpdateService( private const val ERROR_MEMBER_INFO_UPDATE = "회원 정보 업데이트 중 오류가 발생했습니다" private const val ERROR_PASSWORD_UPDATE = "비밀번호 변경 중 오류가 발생했습니다" private const val ERROR_MEMBER_DEACTIVATE = "회원 탈퇴 중 오류가 발생했습니다" + private const val ERROR_DUPLICATE_PROFILE_ADDRESS = "프로필 주소가 중복되었습니다." } override fun updateInfo( @@ -44,11 +45,13 @@ class MemberUpdateService( nickname = request.nickname, profileAddress = request.profileAddress, introduction = request.introduction, + address = request.address, + imageId = request.imageId, ) return member } catch (e: IllegalArgumentException) { - throw MemberUpdateException(ERROR_MEMBER_INFO_UPDATE, e) + throw MemberUpdateException(ERROR_MEMBER_INFO_UPDATE) } } @@ -64,20 +67,19 @@ class MemberUpdateService( return member } catch (e: IllegalArgumentException) { - throw PasswordChangeException(ERROR_PASSWORD_UPDATE, e) + throw PasswordChangeException(ERROR_PASSWORD_UPDATE) } } override fun deactivate(memberId: Long): Member { try { - val member = memberReader.readMemberById(memberId) member.deactivate() return member } catch (e: IllegalArgumentException) { - throw MemberDeactivateException(ERROR_MEMBER_DEACTIVATE, e) + throw MemberDeactivateException(ERROR_MEMBER_DEACTIVATE) } } @@ -89,7 +91,7 @@ class MemberUpdateService( private fun validateProfileAddressNotDuplicated(profileAddress: ProfileAddress) { profileAddress.let { if (memberReader.existsByDetailProfileAddress(it)) { - throw DuplicateProfileAddressException() + throw DuplicateProfileAddressException(ERROR_DUPLICATE_PROFILE_ADDRESS) } } } diff --git a/src/main/kotlin/com/albert/realmoneyrealtaste/application/member/service/MemberVerifyService.kt b/src/main/kotlin/com/albert/realmoneyrealtaste/application/member/service/MemberVerifyService.kt index 773e1a56..b27ee3d7 100644 --- a/src/main/kotlin/com/albert/realmoneyrealtaste/application/member/service/MemberVerifyService.kt +++ b/src/main/kotlin/com/albert/realmoneyrealtaste/application/member/service/MemberVerifyService.kt @@ -31,8 +31,8 @@ class MemberVerifyService( require(member.verifyPassword(password, passwordEncoder)) {} return MemberPrincipal.from(member) - } catch (e: IllegalArgumentException) { - throw MemberVerifyException(ERROR_MEMBER_VERIFY, e) + } catch (_: IllegalArgumentException) { + throw MemberVerifyException(ERROR_MEMBER_VERIFY) } } } diff --git a/src/main/kotlin/com/albert/realmoneyrealtaste/application/member/service/PasswordResetService.kt b/src/main/kotlin/com/albert/realmoneyrealtaste/application/member/service/PasswordResetService.kt index b10fbe6b..487dce67 100644 --- a/src/main/kotlin/com/albert/realmoneyrealtaste/application/member/service/PasswordResetService.kt +++ b/src/main/kotlin/com/albert/realmoneyrealtaste/application/member/service/PasswordResetService.kt @@ -38,9 +38,9 @@ class PasswordResetService( private const val ERROR_INVALID_TOKEN = "유효하지 않은 비밀번호 재설정 토큰입니다" } - override fun sendPasswordResetEmail(email: Email) { + override fun sendPasswordResetEmail(email: String) { try { - val member = memberReader.readMemberByEmail(email) + val member = memberReader.readMemberByEmail(Email(email)) deleteExistingTokenIfPresent(member.requireId()) @@ -48,7 +48,7 @@ class PasswordResetService( publishPasswordResetRequestedEvent(member, token) } catch (e: IllegalArgumentException) { - throw SendPasswordResetEmailException(ERROR_SENDING_PASSWORD_RESET_EMAIL, e) + throw SendPasswordResetEmailException(ERROR_SENDING_PASSWORD_RESET_EMAIL) } } @@ -63,7 +63,7 @@ class PasswordResetService( deleteToken(resetToken) } catch (e: IllegalArgumentException) { - throw PassWordResetException(ERROR_RESETTING_PASSWORD, e) + throw PassWordResetException(ERROR_RESETTING_PASSWORD) } } diff --git a/src/main/kotlin/com/albert/realmoneyrealtaste/domain/follow/Follow.kt b/src/main/kotlin/com/albert/realmoneyrealtaste/domain/follow/Follow.kt index d3e78bd4..d500f7ca 100644 --- a/src/main/kotlin/com/albert/realmoneyrealtaste/domain/follow/Follow.kt +++ b/src/main/kotlin/com/albert/realmoneyrealtaste/domain/follow/Follow.kt @@ -26,29 +26,20 @@ import java.time.LocalDateTime @Entity @Table( name = "follows", - uniqueConstraints = [ - UniqueConstraint(columnNames = ["follower_id", "following_id"]) - ], - indexes = [ - Index(name = "idx_follow_follower_id", columnList = "follower_id"), - Index(name = "idx_follow_following_id", columnList = "following_id"), - Index(name = "idx_follow_status", columnList = "status"), - Index(name = "idx_follow_created_at", columnList = "created_at") - ] + uniqueConstraints = [UniqueConstraint(columnNames = ["follower_id", "following_id"])], + indexes = [Index( + name = "idx_follow_follower_id", + columnList = "follower_id" + ), Index(name = "idx_follow_following_id", columnList = "following_id"), Index( + name = "idx_follow_status", + columnList = "status" + ), Index(name = "idx_follow_created_at", columnList = "created_at")] ) class Follow protected constructor( - @Embedded - val relationship: FollowRelationship, - - @Enumerated(EnumType.STRING) - @Column(length = 20, nullable = false) - var status: FollowStatus, - - @Column(nullable = false) - val createdAt: LocalDateTime, - - @Column(nullable = false) - var updatedAt: LocalDateTime, + relationship: FollowRelationship, + status: FollowStatus, + createdAt: LocalDateTime, + updatedAt: LocalDateTime, ) : BaseEntity() { companion object { @@ -61,12 +52,7 @@ class Follow protected constructor( fun create(command: FollowCreateCommand): Follow { val now = LocalDateTime.now() return Follow( - relationship = FollowRelationship( - followerId = command.followerId, - followerNickname = command.followerNickname, - followingId = command.followingId, - followingNickname = command.followingNickname, - ), + relationship = FollowRelationship.of(command), status = FollowStatus.ACTIVE, createdAt = now, updatedAt = now @@ -74,6 +60,23 @@ class Follow protected constructor( } } + @Embedded + var relationship: FollowRelationship = relationship + protected set + + @Enumerated(EnumType.STRING) + @Column(name = "status", nullable = false) + var status: FollowStatus = status + protected set + + @Column(name = "created_at", nullable = false) + var createdAt: LocalDateTime = createdAt + protected set + + @Column(name = "updated_at") + var updatedAt: LocalDateTime = updatedAt + protected set + /** * 팔로우 해제 (언팔로우) */ diff --git a/src/main/kotlin/com/albert/realmoneyrealtaste/domain/follow/command/FollowCreateCommand.kt b/src/main/kotlin/com/albert/realmoneyrealtaste/domain/follow/command/FollowCreateCommand.kt index e44a590a..b44f6ff4 100644 --- a/src/main/kotlin/com/albert/realmoneyrealtaste/domain/follow/command/FollowCreateCommand.kt +++ b/src/main/kotlin/com/albert/realmoneyrealtaste/domain/follow/command/FollowCreateCommand.kt @@ -3,8 +3,10 @@ package com.albert.realmoneyrealtaste.domain.follow.command data class FollowCreateCommand( val followerId: Long, val followerNickname: String, + val followerProfileImageId: Long, val followingId: Long, val followingNickname: String, + val followingProfileImageId: Long, ) { companion object { const val ERROR_FOLLOWER_ID_MUST_BE_POSITIVE = "팔로워 ID는 양수여야 합니다" @@ -12,6 +14,8 @@ data class FollowCreateCommand( const val ERROR_CANNOT_FOLLOW_SELF = "자기 자신을 팔로우할 수 없습니다" const val ERROR_FOLLOWER_NICKNAME_BLANK = "팔로워 닉네임은 비어있을 수 없습니다" const val ERROR_FOLLOWING_NICKNAME_BLANK = "팔로잉 대상 닉네임은 비어있을 수 없습니다" + const val ERROR_FOLLOWER_PROFILE_IMAGE_ID_MUST_BE_POSITIVE = "팔로워 프로필 이미지 ID는 양수여야 합니다" + const val ERROR_FOLLOWING_PROFILE_IMAGE_ID_MUST_BE_POSITIVE = "팔로잉 대상 프로필 이미지 ID는 양수여야 합니다" } init { @@ -24,5 +28,7 @@ data class FollowCreateCommand( require(followerId != followingId) { ERROR_CANNOT_FOLLOW_SELF } require(followerNickname.isNotBlank()) { ERROR_FOLLOWER_NICKNAME_BLANK } require(followingNickname.isNotBlank()) { ERROR_FOLLOWING_NICKNAME_BLANK } + require(followerProfileImageId > 0) { ERROR_FOLLOWER_PROFILE_IMAGE_ID_MUST_BE_POSITIVE } + require(followingProfileImageId > 0) { ERROR_FOLLOWING_PROFILE_IMAGE_ID_MUST_BE_POSITIVE } } } diff --git a/src/main/kotlin/com/albert/realmoneyrealtaste/domain/follow/value/FollowRelationship.kt b/src/main/kotlin/com/albert/realmoneyrealtaste/domain/follow/value/FollowRelationship.kt index 4a3d21e9..a3e785ce 100644 --- a/src/main/kotlin/com/albert/realmoneyrealtaste/domain/follow/value/FollowRelationship.kt +++ b/src/main/kotlin/com/albert/realmoneyrealtaste/domain/follow/value/FollowRelationship.kt @@ -1,5 +1,6 @@ package com.albert.realmoneyrealtaste.domain.follow.value +import com.albert.realmoneyrealtaste.domain.follow.command.FollowCreateCommand import jakarta.persistence.Column import jakarta.persistence.Embeddable @@ -15,18 +16,41 @@ data class FollowRelationship( @Column(name = "follower_nickname", nullable = false, length = 50) val followerNickname: String, + @Column(name = "follower_profile_image_id", nullable = false) + val followerProfileImageId: Long, + @Column(name = "following_id", nullable = false) val followingId: Long, @Column(name = "following_nickname", nullable = false, length = 50) val followingNickname: String, -) { + + @Column(name = "following_profile_image_id", nullable = false) + val followingProfileImageId: Long, + + ) { + + companion object { + fun of(command: FollowCreateCommand): FollowRelationship { + return FollowRelationship( + followerId = command.followerId, + followerNickname = command.followerNickname, + followerProfileImageId = command.followerProfileImageId, + followingId = command.followingId, + followingNickname = command.followingNickname, + followingProfileImageId = command.followingProfileImageId, + ) + } + } + init { require(followerId > 0) { "팔로워 ID는 양수여야 합니다" } require(followingId > 0) { "팔로잉 대상 ID는 양수여야 합니다" } require(followerId != followingId) { "자기 자신을 팔로우할 수 없습니다" } require(followerNickname.isNotBlank()) { "팔로워 닉네임은 비어있을 수 없습니다" } require(followingNickname.isNotBlank()) { "팔로잉 대상 닉네임은 비어있을 수 없습니다" } + require(followerProfileImageId > 0) { "팔로워 프로필 이미지 ID는 양수여야 합니다" } + require(followingProfileImageId > 0) { "팔로잉 대상 프로필 이미지 ID는 양수여야 합니다" } } /** diff --git a/src/main/kotlin/com/albert/realmoneyrealtaste/domain/friend/Friendship.kt b/src/main/kotlin/com/albert/realmoneyrealtaste/domain/friend/Friendship.kt index 09237949..a68406ea 100644 --- a/src/main/kotlin/com/albert/realmoneyrealtaste/domain/friend/Friendship.kt +++ b/src/main/kotlin/com/albert/realmoneyrealtaste/domain/friend/Friendship.kt @@ -67,11 +67,7 @@ class Friendship protected constructor( val now = LocalDateTime.now() return Friendship( - relationShip = FriendRelationship.of( - memberId = requestCommand.fromMemberId, - friendMemberId = requestCommand.toMemberId, - friendNickname = requestCommand.toMemberNickname - ), + relationShip = FriendRelationship.of(requestCommand), status = FriendshipStatus.PENDING, createdAt = now, updatedAt = now diff --git a/src/main/kotlin/com/albert/realmoneyrealtaste/domain/friend/command/FriendRequestCommand.kt b/src/main/kotlin/com/albert/realmoneyrealtaste/domain/friend/command/FriendRequestCommand.kt index 9ab23674..abec7043 100644 --- a/src/main/kotlin/com/albert/realmoneyrealtaste/domain/friend/command/FriendRequestCommand.kt +++ b/src/main/kotlin/com/albert/realmoneyrealtaste/domain/friend/command/FriendRequestCommand.kt @@ -5,6 +5,7 @@ package com.albert.realmoneyrealtaste.domain.friend.command */ data class FriendRequestCommand( val fromMemberId: Long, + val fromMemberNickName: String, val toMemberId: Long, val toMemberNickname: String, ) { @@ -13,6 +14,7 @@ data class FriendRequestCommand( const val ERROR_TO_MEMBER_ID_MUST_BE_POSITIVE = "대상 회원 ID는 양수여야 합니다" const val ERROR_CANNOT_REQUEST_FRIENDSHIP_TO_YOURSELF = "자기 자신에게는 친구 요청을 보낼 수 없습니다" const val ERROR_TO_MEMBER_NICKNAME_MUST_NOT_BE_EMPTY = "대상 회원 닉네임은 비어있을 수 없습니다" + const val ERROR_FROM_MEMBER_NICKNAME_MUST_NOT_BE_EMPTY = "요청자 회원 닉네임은 비어있을 수 없습니다" } init { @@ -23,6 +25,7 @@ data class FriendRequestCommand( require(fromMemberId > 0) { ERROR_FROM_MEMBER_ID_MUST_BE_POSITIVE } require(toMemberId > 0) { ERROR_TO_MEMBER_ID_MUST_BE_POSITIVE } require(fromMemberId != toMemberId) { ERROR_CANNOT_REQUEST_FRIENDSHIP_TO_YOURSELF } + require(fromMemberNickName.isNotBlank()) { ERROR_FROM_MEMBER_NICKNAME_MUST_NOT_BE_EMPTY } require(toMemberNickname.isNotBlank()) { ERROR_TO_MEMBER_NICKNAME_MUST_NOT_BE_EMPTY } } } diff --git a/src/main/kotlin/com/albert/realmoneyrealtaste/domain/friend/value/FriendRelationship.kt b/src/main/kotlin/com/albert/realmoneyrealtaste/domain/friend/value/FriendRelationship.kt index ee3322a1..228690e5 100644 --- a/src/main/kotlin/com/albert/realmoneyrealtaste/domain/friend/value/FriendRelationship.kt +++ b/src/main/kotlin/com/albert/realmoneyrealtaste/domain/friend/value/FriendRelationship.kt @@ -12,6 +12,9 @@ data class FriendRelationship( @Column(name = "member_id", nullable = false) val memberId: Long, + @Column(name = "member_nickname", length = 50) + val memberNickname: String, + @Column(name = "friend_member_id", nullable = false) val friendMemberId: Long, @@ -26,18 +29,11 @@ data class FriendRelationship( fun of(friendRequestCommand: FriendRequestCommand): FriendRelationship { return FriendRelationship( memberId = friendRequestCommand.fromMemberId, + memberNickname = friendRequestCommand.fromMemberNickName, friendMemberId = friendRequestCommand.toMemberId, friendNickname = friendRequestCommand.toMemberNickname, ) } - - fun of(memberId: Long, friendMemberId: Long, friendNickname: String): FriendRelationship { - return FriendRelationship( - memberId = memberId, - friendMemberId = friendMemberId, - friendNickname = friendNickname, - ) - } } init { diff --git a/src/main/kotlin/com/albert/realmoneyrealtaste/domain/member/Member.kt b/src/main/kotlin/com/albert/realmoneyrealtaste/domain/member/Member.kt index 1fd3ffc5..6af73387 100644 --- a/src/main/kotlin/com/albert/realmoneyrealtaste/domain/member/Member.kt +++ b/src/main/kotlin/com/albert/realmoneyrealtaste/domain/member/Member.kt @@ -51,6 +51,8 @@ class Member protected constructor( followersCount: Long, followingsCount: Long, + + postCount: Long, ) : BaseEntity() { @Embedded @@ -95,6 +97,22 @@ class Member protected constructor( var followingsCount: Long = followingsCount protected set + @Column(name = "post_count", nullable = false) + var postCount: Long = postCount + protected set + + val imageId: Long + get() = detail.imageId ?: 1L + + val address: String + get() = detail.address ?: "푸디마을에 살고 있어요" + + val introduction: String + get() = detail.introduction?.value ?: "아직 자기소개가 없어요!" + + val registeredAt: LocalDateTime + get() = detail.registeredAt + fun activate() { require(status == MemberStatus.PENDING) { ERROR_INVALID_STATUS_FOR_ACTIVATION } @@ -133,14 +151,14 @@ class Member protected constructor( profileAddress: ProfileAddress? = null, introduction: Introduction? = null, address: String? = null, + imageId: Long? = null, ) { - if (nickname == null && profileAddress == null && introduction == null && address == null) return - require(status == MemberStatus.ACTIVE) { ERROR_INVALID_STATUS_FOR_INFO_UPDATE } + val sameNickname = nickname == this.nickname nickname?.let { this.nickname = it } - detail.updateInfo(profileAddress, introduction, address) - updatedAt = LocalDateTime.now() + val updated = detail.updateInfo(profileAddress, introduction, address, imageId) + if (!sameNickname || updated) updatedAt = LocalDateTime.now() } fun updateTrustScore(newTrustScore: TrustScore) { @@ -182,6 +200,11 @@ class Member protected constructor( updatedAt = LocalDateTime.now() } + fun updatePostCount(count: Long) { + postCount = count + updatedAt = LocalDateTime.now() + } + companion object { const val ERROR_INVALID_STATUS_FOR_ACTIVATION = "등록 대기 상태에서만 등록 완료가 가능합니다" const val ERROR_INVALID_STATUS_FOR_DEACTIVATION = "등록 완료 상태에서만 탈퇴가 가능합니다" @@ -205,6 +228,7 @@ class Member protected constructor( roles = Roles.ofUser(), followersCount = 0L, followingsCount = 0L, + postCount = 0L, ) fun registerManager( @@ -222,6 +246,7 @@ class Member protected constructor( roles = Roles.of(Role.USER, Role.MANAGER), followersCount = 0L, followingsCount = 0L, + postCount = 0L, ) fun registerAdmin( @@ -239,6 +264,7 @@ class Member protected constructor( roles = Roles.of(Role.USER, Role.ADMIN), followersCount = 0L, followingsCount = 0L, + postCount = 0L, ) } } diff --git a/src/main/kotlin/com/albert/realmoneyrealtaste/domain/member/MemberDetail.kt b/src/main/kotlin/com/albert/realmoneyrealtaste/domain/member/MemberDetail.kt index 706f0191..e7e664ed 100644 --- a/src/main/kotlin/com/albert/realmoneyrealtaste/domain/member/MemberDetail.kt +++ b/src/main/kotlin/com/albert/realmoneyrealtaste/domain/member/MemberDetail.kt @@ -16,6 +16,7 @@ class MemberDetail protected constructor( val registeredAt: LocalDateTime, activatedAt: LocalDateTime?, deactivatedAt: LocalDateTime?, + imageId: Long?, ) : BaseEntity() { @Embedded var profileAddress: ProfileAddress? = profileAddress @@ -37,6 +38,10 @@ class MemberDetail protected constructor( var deactivatedAt: LocalDateTime? = deactivatedAt protected set + @Column + var imageId: Long? = imageId + protected set + fun activate() { activatedAt = LocalDateTime.now() } @@ -49,10 +54,20 @@ class MemberDetail protected constructor( profileAddress: ProfileAddress? = null, introduction: Introduction? = null, address: String? = null, - ) { + imageId: Long? = null, + ): Boolean { + if (profileAddress == this.profileAddress + && introduction == this.introduction + && address == this.address + && imageId == this.imageId + ) { + return false + } if (profileAddress != null) this.profileAddress = profileAddress if (introduction != null) this.introduction = introduction if (address != null) this.address = address + if (imageId != null) this.imageId = imageId + return true } companion object { @@ -67,7 +82,8 @@ class MemberDetail protected constructor( activatedAt = null, deactivatedAt = null, registeredAt = LocalDateTime.now(), - address = address + address = address, + imageId = null, ) } } diff --git a/src/main/kotlin/com/albert/realmoneyrealtaste/domain/member/value/Email.kt b/src/main/kotlin/com/albert/realmoneyrealtaste/domain/member/value/Email.kt index 6d100189..96365f1c 100644 --- a/src/main/kotlin/com/albert/realmoneyrealtaste/domain/member/value/Email.kt +++ b/src/main/kotlin/com/albert/realmoneyrealtaste/domain/member/value/Email.kt @@ -2,10 +2,14 @@ package com.albert.realmoneyrealtaste.domain.member.value import jakarta.persistence.Column import jakarta.persistence.Embeddable +import jakarta.validation.constraints.Email +import jakarta.validation.constraints.NotBlank import org.hibernate.annotations.NaturalId @Embeddable data class Email( + @field:NotBlank(message = "이메일은 필수입니다") + @field:Email(message = "올바른 이메일 형식이 아닙니다") @NaturalId @Column(name = COLUMN_NAME, nullable = false, unique = true) val address: String, diff --git a/src/main/resources/db/migration/V2__migrate251204.sql b/src/main/resources/db/migration/V2__migrate251204.sql new file mode 100644 index 00000000..0ca7b8da --- /dev/null +++ b/src/main/resources/db/migration/V2__migrate251204.sql @@ -0,0 +1,70 @@ +-- 1. 새 컬럼 추가 (NULL 허용) +ALTER TABLE follows + ADD COLUMN follower_profile_image_id BIGINT NULL; + +ALTER TABLE follows + ADD COLUMN following_profile_image_id BIGINT NULL; + +-- 기본값 설정 +UPDATE follows +SET follower_profile_image_id = 0 +WHERE follower_profile_image_id IS NULL; + +UPDATE follows +SET following_profile_image_id = 0 +WHERE following_profile_image_id IS NULL; + +-- NOT NULL 제약 추가 +ALTER TABLE follows + MODIFY COLUMN follower_profile_image_id BIGINT NOT NULL; + +ALTER TABLE follows + MODIFY COLUMN following_profile_image_id BIGINT NOT NULL; + +-- 2. member_detail에 image_id 추가 +ALTER TABLE member_detail + ADD COLUMN image_id BIGINT NULL; + +-- 3. friendships에 member_nickname 추가 +ALTER TABLE friendships + ADD COLUMN member_nickname VARCHAR(50) NULL; + +-- 4. members에 post_count 추가 +ALTER TABLE members + ADD COLUMN post_count BIGINT NOT NULL DEFAULT 0; + +-- 5. ENUM을 VARCHAR로 변경 (DROP 없이 MODIFY 사용) +ALTER TABLE images + MODIFY COLUMN image_type VARCHAR(255) NOT NULL; + +ALTER TABLE post_collections + MODIFY COLUMN privacy VARCHAR(255) NOT NULL; + +-- ✅ status는 이미 존재하므로 MODIFY만 사용 +ALTER TABLE post_collections + MODIFY COLUMN status VARCHAR(255) NOT NULL; + +ALTER TABLE member_roles + MODIFY COLUMN `role` VARCHAR(255) NULL; + +ALTER TABLE comments + MODIFY COLUMN status VARCHAR(255) NOT NULL; + +ALTER TABLE follows + MODIFY COLUMN status VARCHAR(255) NOT NULL; + +ALTER TABLE friendships + MODIFY COLUMN status VARCHAR(20) NOT NULL; + +ALTER TABLE members + MODIFY COLUMN status VARCHAR(255) NOT NULL; + +ALTER TABLE posts + MODIFY COLUMN status VARCHAR(255) NOT NULL; + +ALTER TABLE trust_score + MODIFY COLUMN trust_level VARCHAR(255) NULL; + +-- 6. updated_at NULL 허용 +ALTER TABLE follows + MODIFY COLUMN updated_at datetime NULL; diff --git a/src/main/resources/templates/collection/fragments/collections-content.html b/src/main/resources/templates/collection/fragments/collections-content.html index 3ddf559a..117b2864 100644 --- a/src/main/resources/templates/collection/fragments/collections-content.html +++ b/src/main/resources/templates/collection/fragments/collections-content.html @@ -9,7 +9,7 @@
My Collections
+ th:if="${member.id == authorId}"> diff --git a/src/main/resources/templates/member/fragments/member-profile.html b/src/main/resources/templates/member/fragments/member-profile.html new file mode 100644 index 00000000..519e4385 --- /dev/null +++ b/src/main/resources/templates/member/fragments/member-profile.html @@ -0,0 +1,90 @@ + +
+
+ member profile +
+ +
+ 회원 닉네임 +
+

+ 회원 소개글 +

+ + +
+ +
+
+ 0
+ Post +
+ +
+ +
+
+ 0
+ Followers +
+ +
+ +
+
+ 0
+ Following +
+
+ +
+ + +
+ + + + +
+ diff --git a/src/main/resources/templates/fragments/sidebar.html b/src/main/resources/templates/member/fragments/sidebar.html similarity index 79% rename from src/main/resources/templates/fragments/sidebar.html rename to src/main/resources/templates/member/fragments/sidebar.html index ea857ae1..8a220307 100644 --- a/src/main/resources/templates/fragments/sidebar.html +++ b/src/main/resources/templates/member/fragments/sidebar.html @@ -17,8 +17,9 @@
추천 푸디
- -
+ + +
@@ -28,7 +29,7 @@
추천 푸디
+ th:src="@{'/api/images/' + ${user.imageId}}">
@@ -64,7 +65,20 @@
추천 푸디
- + + + +
+

아직 추천 푸디가 없습니다.

+
+ + + + + +
diff --git a/src/main/resources/templates/member/profile.html b/src/main/resources/templates/member/profile.html index 90f31f6d..0e0ae140 100644 --- a/src/main/resources/templates/member/profile.html +++ b/src/main/resources/templates/member/profile.html @@ -2,7 +2,7 @@ - + <title th:text="${author.nickname.value ?: '사용자'} + '의 프로필'"> 사용자의 프로필 @@ -44,26 +44,27 @@
+ th:if="${author.detail.imageId != null}" + th:src="@{'/api/images/' + ${author.detail.imageId}}">

- Sam Lanson + Sam Lanson 나의 프로필 + th:if="${member.id == author.id}">나의 프로필

0 + th:text="${author.followersCount ?: 0} + ' followers'">0 followers 0 + th:text="${author.followingsCount ?: 0} + ' following'">0 following
@@ -71,7 +72,7 @@

+ th:if="${member.id == author.id}"> - +
@@ -118,11 +110,11 @@

  • - Food Reviewer at RMRT + Food Reviewer at RMRT
  • - Seoul, South Korea + Seoul, South Korea
  • @@ -177,7 +169,7 @@

  • @@ -215,7 +207,7 @@

    + th:if="${member.id == author.id}">
    계정 설정

    회원님의 계정 정보를 관리하고 수정할 수 있습니다.

- + + +
+
+ +
+ 현재 프로필 이미지 +
+ +
+
+ + +
+ +
+ +
+ +
+ + +
+ + +
+
JPG, PNG, GIF, WebP 형식 지원 (최대 5MB)
+ + + +
+
+
+
+
@@ -169,7 +226,7 @@

계정 설정

+ th:value="${member.detail.profileAddress?.address ?: null}" type="text">
@@ -178,9 +235,12 @@

계정 설정

name="introduction" placeholder="자기소개를 입력하세요" rows="4" - th:text="${member.detail.introduction?.value ?: ''}"> + th:text="${member.detail.introduction?.value ?: null}"> 최대 500자까지 입력 가능합니다
+ +
@@ -322,6 +382,305 @@
계정을 삭제하기 전에...
+ + + + + + diff --git a/src/main/resources/templates/post/modal-detail.html b/src/main/resources/templates/post/fragments/modal-detail.html similarity index 100% rename from src/main/resources/templates/post/modal-detail.html rename to src/main/resources/templates/post/fragments/modal-detail.html diff --git a/src/main/resources/templates/post/fragments.html b/src/main/resources/templates/post/fragments/posts-content.html similarity index 97% rename from src/main/resources/templates/post/fragments.html rename to src/main/resources/templates/post/fragments/posts-content.html index 72a3a89e..6f925cbc 100644 --- a/src/main/resources/templates/post/fragments.html +++ b/src/main/resources/templates/post/fragments/posts-content.html @@ -8,15 +8,13 @@
+ th:if="${member.id == authorId}">
- - - + profile
@@ -24,7 +22,7 @@
@@ -73,7 +71,7 @@
아직 포스트가 없습니다

첫 번째 포스트를 작성해보세요!

diff --git a/src/main/resources/templates/post/my-list.html b/src/main/resources/templates/post/my-list.html index 137293d2..7ecc4e37 100644 --- a/src/main/resources/templates/post/my-list.html +++ b/src/main/resources/templates/post/my-list.html @@ -40,9 +40,9 @@
- + src="#" th:src="@{'/api/images/' + ${author.imageId}}">
diff --git a/src/test/kotlin/com/albert/realmoneyrealtaste/TestcontainersConfiguration.kt b/src/test/kotlin/com/albert/realmoneyrealtaste/TestcontainersConfiguration.kt index e638b416..c13ac6e8 100644 --- a/src/test/kotlin/com/albert/realmoneyrealtaste/TestcontainersConfiguration.kt +++ b/src/test/kotlin/com/albert/realmoneyrealtaste/TestcontainersConfiguration.kt @@ -72,6 +72,7 @@ class TestcontainersConfiguration { withServices(LocalStackContainer.Service.S3) withReuse(true) withEnv("EXTRA_CORS_ALLOWED_ORIGINS", "http://localhost:8080") + withEnv("EXTRA_CORS_ALLOWED_HEADERS", "*") } } diff --git a/src/test/kotlin/com/albert/realmoneyrealtaste/adapter/infrastructure/security/MemberPrincipalTest.kt b/src/test/kotlin/com/albert/realmoneyrealtaste/adapter/infrastructure/security/MemberPrincipalTest.kt index e22ef606..e4b9ef01 100644 --- a/src/test/kotlin/com/albert/realmoneyrealtaste/adapter/infrastructure/security/MemberPrincipalTest.kt +++ b/src/test/kotlin/com/albert/realmoneyrealtaste/adapter/infrastructure/security/MemberPrincipalTest.kt @@ -31,6 +31,9 @@ class MemberPrincipalTest { address = "서울시", createdAt = LocalDateTime.now(), roles = roles, + imageId = 0L, + followersCount = 0L, + followingsCount = 0L, ) val authorities = principal.getAuthorities() @@ -50,7 +53,10 @@ class MemberPrincipalTest { introduction = "", address = "서울시", createdAt = LocalDateTime.now(), - roles = emptySet() + roles = emptySet(), + imageId = 0L, + followersCount = 0L, + followingsCount = 0L, ) val authorities = principal.getAuthorities() @@ -68,7 +74,10 @@ class MemberPrincipalTest { introduction = "", address = "서울시", createdAt = LocalDateTime.now(), - roles = setOf(Role.MANAGER) + roles = setOf(Role.MANAGER), + imageId = 0L, + followersCount = 0L, + followingsCount = 0L, ) val authorities = principal.getAuthorities() @@ -182,17 +191,7 @@ class MemberPrincipalTest { val principal = MemberPrincipal.from(member) - assertEquals("아직 주소가 없어요!", principal.address) - } - - @Test - fun `from - success - sets default profileImageUrl`() { - val member = createMemberWithId(42L) - member.activate() - - val principal = MemberPrincipal.from(member) - - assertEquals("#", principal.profileImageUrl) + assertEquals("푸디마을에 살고 있어요", principal.address) } @Test @@ -220,7 +219,10 @@ class MemberPrincipalTest { introduction = "자기소개", address = "서울시", createdAt = LocalDateTime.now(), - roles = roles + roles = roles, + imageId = 0L, + followersCount = 0L, + followingsCount = 0L, ) assertEquals(100L, principal.id) @@ -230,25 +232,6 @@ class MemberPrincipalTest { assertEquals("자기소개", principal.introduction) } - @Test - fun `constructor - success - uses default values for optional parameters`() { - val principal = MemberPrincipal( - id = 1L, - email = Email("test@example.com"), - nickname = Nickname("testUser"), - active = true, - introduction = "자기소개", - address = "서울시", - createdAt = LocalDateTime.now(), - roles = setOf(Role.USER) - // profileImageUrl, followersCount, followingsCount은 기본값 사용 - ) - - assertEquals("#", principal.profileImageUrl) // 기본값 - assertEquals(0L, principal.followersCount) // 기본값 - assertEquals(0L, principal.followingsCount) // 기본값 - } - @Test fun `constructor - success - sets custom values for optional parameters`() { val principal = MemberPrincipal( @@ -259,13 +242,12 @@ class MemberPrincipalTest { introduction = "자기소개", address = "서울시", createdAt = LocalDateTime.now(), - profileImageUrl = "https://example.com/profile.jpg", roles = setOf(Role.USER), + imageId = 0L, followersCount = 100L, followingsCount = 50L ) - assertEquals("https://example.com/profile.jpg", principal.profileImageUrl) assertEquals(100L, principal.followersCount) assertEquals(50L, principal.followingsCount) } @@ -281,7 +263,10 @@ class MemberPrincipalTest { introduction = "", address = "서울시", createdAt = LocalDateTime.now(), - roles = allRoles + roles = allRoles, + imageId = 0L, + followersCount = 0L, + followingsCount = 0L, ) val authorities = principal.getAuthorities() @@ -302,7 +287,10 @@ class MemberPrincipalTest { introduction = "", address = "서울시", createdAt = LocalDateTime.now(), - roles = setOf(Role.USER, Role.ADMIN) + roles = setOf(Role.USER, Role.ADMIN), + imageId = 0L, + followersCount = 0L, + followingsCount = 0L, ) assertTrue(principal.hasRole(Role.USER)) @@ -319,7 +307,10 @@ class MemberPrincipalTest { introduction = "", address = "서울시", createdAt = LocalDateTime.now(), - roles = setOf(Role.USER) + roles = setOf(Role.USER), + imageId = 0L, + followersCount = 0L, + followingsCount = 0L, ) assertTrue(principal.hasRole(Role.USER)) @@ -337,7 +328,10 @@ class MemberPrincipalTest { introduction = "", address = "서울시", createdAt = LocalDateTime.now(), - roles = emptySet() + roles = emptySet(), + imageId = 0L, + followersCount = 0L, + followingsCount = 0L, ) assertFalse(principal.hasRole(Role.USER)) @@ -347,16 +341,16 @@ class MemberPrincipalTest { fun createMemberWithId( id: Long, - email: Email = MemberFixture.Companion.DEFAULT_EMAIL, - nickname: Nickname = MemberFixture.Companion.DEFAULT_NICKNAME, - password: RawPassword = MemberFixture.Companion.DEFAULT_RAW_PASSWORD, + email: Email = MemberFixture.DEFAULT_EMAIL, + nickname: Nickname = MemberFixture.DEFAULT_NICKNAME, + password: RawPassword = MemberFixture.DEFAULT_RAW_PASSWORD, ): Member { - val member = Member.Companion.register( + val member = Member.register( email = email, nickname = nickname, - password = PasswordHash.Companion.of( + password = PasswordHash.of( password, - MemberFixture.Companion.TEST_ENCODER + MemberFixture.TEST_ENCODER ), ) setId(member, id) diff --git a/src/test/kotlin/com/albert/realmoneyrealtaste/adapter/webapi/friend/FriendReadApiTest.kt b/src/test/kotlin/com/albert/realmoneyrealtaste/adapter/webapi/friend/FriendReadApiTest.kt index 7707755f..53bc7766 100644 --- a/src/test/kotlin/com/albert/realmoneyrealtaste/adapter/webapi/friend/FriendReadApiTest.kt +++ b/src/test/kotlin/com/albert/realmoneyrealtaste/adapter/webapi/friend/FriendReadApiTest.kt @@ -4,7 +4,6 @@ import com.albert.realmoneyrealtaste.IntegrationTestBase import com.albert.realmoneyrealtaste.application.friend.dto.FriendResponseRequest import com.albert.realmoneyrealtaste.application.friend.provided.FriendRequestor import com.albert.realmoneyrealtaste.application.friend.provided.FriendResponder -import com.albert.realmoneyrealtaste.domain.friend.command.FriendRequestCommand import com.albert.realmoneyrealtaste.util.TestMemberHelper import com.albert.realmoneyrealtaste.util.WithMockMember import org.junit.jupiter.api.Test @@ -37,15 +36,13 @@ class FriendReadApiTest : IntegrationTestBase() { val friend2 = testMemberHelper.createActivatedMember("friend2@example.com", "friend2") // 친구 관계 설정 (targetMember가 친구 요청을 보내고 수락받음) - val friendRequest1 = friendRequestor.sendFriendRequest( - FriendRequestCommand(targetMember.requireId(), friend1.requireId(), friend1.nickname.value) - ) + val friendRequest1 = friendRequestor.sendFriendRequest(targetMember.requireId(), friend1.requireId()) friendResponder.respondToFriendRequest( FriendResponseRequest(friendRequest1.requireId(), friend1.requireId(), true) ) val friendRequest2 = friendRequestor.sendFriendRequest( - FriendRequestCommand(targetMember.requireId(), friend2.requireId(), friend2.nickname.value) + targetMember.requireId(), friend2.requireId() ) friendResponder.respondToFriendRequest( FriendResponseRequest(friendRequest2.requireId(), friend2.requireId(), true) @@ -85,7 +82,7 @@ class FriendReadApiTest : IntegrationTestBase() { repeat(5) { index -> val friend = testMemberHelper.createActivatedMember("friend$index@example.com", "friend$index") val friendRequest = friendRequestor.sendFriendRequest( - FriendRequestCommand(targetMember.requireId(), friend.requireId(), friend.nickname.value) + targetMember.requireId(), friend.requireId() ) friendResponder.respondToFriendRequest( FriendResponseRequest(friendRequest.requireId(), friend.requireId(), true) @@ -112,7 +109,7 @@ class FriendReadApiTest : IntegrationTestBase() { // 먼저 friend1과 친구 관계 설정 val friendRequest1 = friendRequestor.sendFriendRequest( - FriendRequestCommand(targetMember.requireId(), friend1.requireId(), friend1.nickname.value) + targetMember.requireId(), friend1.requireId() ) friendResponder.respondToFriendRequest( FriendResponseRequest(friendRequest1.requireId(), friend1.requireId(), true) @@ -121,7 +118,7 @@ class FriendReadApiTest : IntegrationTestBase() { // 잠시 후 friend2와 친구 관계 설정 Thread.sleep(100) // 시간 차이를 위해 val friendRequest2 = friendRequestor.sendFriendRequest( - FriendRequestCommand(targetMember.requireId(), friend2.requireId(), friend2.nickname.value) + targetMember.requireId(), friend2.requireId() ) friendResponder.respondToFriendRequest( FriendResponseRequest(friendRequest2.requireId(), friend2.requireId(), true) @@ -157,14 +154,14 @@ class FriendReadApiTest : IntegrationTestBase() { // 친구 관계 설정 val friendRequest1 = friendRequestor.sendFriendRequest( - FriendRequestCommand(targetMember.requireId(), friend1.requireId(), friend1.nickname.value) + targetMember.requireId(), friend1.requireId() ) friendResponder.respondToFriendRequest( FriendResponseRequest(friendRequest1.requireId(), friend1.requireId(), true) ) val friendRequest2 = friendRequestor.sendFriendRequest( - FriendRequestCommand(targetMember.requireId(), friend2.requireId(), friend2.nickname.value) + targetMember.requireId(), friend2.requireId(), ) friendResponder.respondToFriendRequest( FriendResponseRequest(friendRequest2.requireId(), friend2.requireId(), true) @@ -205,7 +202,7 @@ class FriendReadApiTest : IntegrationTestBase() { // friend1과는 친구 관계 완료 val friendRequest1 = friendRequestor.sendFriendRequest( - FriendRequestCommand(targetMember.requireId(), friend1.requireId(), friend1.nickname.value) + targetMember.requireId(), friend1.requireId(), ) friendResponder.respondToFriendRequest( FriendResponseRequest(friendRequest1.requireId(), friend1.requireId(), true) @@ -213,7 +210,7 @@ class FriendReadApiTest : IntegrationTestBase() { // friend2에게는 친구 요청만 보내고 수락받지 않음 friendRequestor.sendFriendRequest( - FriendRequestCommand(targetMember.requireId(), friend2.requireId(), friend2.nickname.value) + targetMember.requireId(), friend2.requireId() ) mockMvc.perform(get("/api/members/${targetMember.requireId()}/friends")) @@ -234,7 +231,7 @@ class FriendReadApiTest : IntegrationTestBase() { // friend1과는 친구 관계 완료 val friendRequest1 = friendRequestor.sendFriendRequest( - FriendRequestCommand(targetMember.requireId(), friend1.requireId(), friend1.nickname.value) + targetMember.requireId(), friend1.requireId(), ) friendResponder.respondToFriendRequest( FriendResponseRequest(friendRequest1.requireId(), friend1.requireId(), true) @@ -242,7 +239,7 @@ class FriendReadApiTest : IntegrationTestBase() { // friend2의 요청은 거절됨 val friendRequest2 = friendRequestor.sendFriendRequest( - FriendRequestCommand(targetMember.requireId(), friend2.requireId(), friend2.nickname.value) + targetMember.requireId(), friend2.requireId(), ) friendResponder.respondToFriendRequest( FriendResponseRequest(friendRequest2.requireId(), friend2.requireId(), false) diff --git a/src/test/kotlin/com/albert/realmoneyrealtaste/adapter/webapi/image/ImageApiTest.kt b/src/test/kotlin/com/albert/realmoneyrealtaste/adapter/webapi/image/ImageApiTest.kt index fab07d03..767ea94a 100644 --- a/src/test/kotlin/com/albert/realmoneyrealtaste/adapter/webapi/image/ImageApiTest.kt +++ b/src/test/kotlin/com/albert/realmoneyrealtaste/adapter/webapi/image/ImageApiTest.kt @@ -22,6 +22,7 @@ import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delet import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post import org.springframework.test.web.servlet.result.MockMvcResultMatchers.content +import org.springframework.test.web.servlet.result.MockMvcResultMatchers.header import org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath import org.springframework.test.web.servlet.result.MockMvcResultMatchers.status import kotlin.test.assertEquals @@ -470,4 +471,104 @@ class ImageApiTest : IntegrationTestBase() { .andExpect(content().contentType(MediaType.APPLICATION_JSON)) .andExpect(jsonPath("$.success").value(false)) } + + @Test + @WithMockMember(email = MemberFixture.DEFAULT_USERNAME) + fun `getImageRedirect - success - redirects to image URL`() { + // Given + val member = testMemberHelper.getDefaultMember() + val image = createTestImage(member.requireId()) + + // When & Then + mockMvc.perform( + get("/api/images/${image.requireId()}") + ) + .andExpect(status().isFound()) + .andExpect(header().exists("Location")) + } + + @Test + fun `getImageRedirect - failure - unauthorized`() { + // Given + val imageId = 1L + + // When & Then + mockMvc.perform( + get("/api/images/$imageId") + ) + .andExpect(status().isForbidden()) + } + + @Test + @WithMockMember(email = MemberFixture.DEFAULT_USERNAME) + fun `getImageRedirect - failure - image not found`() { + // Given + val imageId = 999999L + + // When & Then + mockMvc.perform( + get("/api/images/$imageId") + ) + .andExpect(status().isBadRequest()) + } + + @Test + @WithMockMember(email = "other@example.com") + fun `getImageRedirect - success - can access other user's image`() { + // Given + val owner = testMemberHelper.createActivatedMember("owner@email.com") + val image = createTestImage(owner.requireId()) + + // When & Then + mockMvc.perform( + get("/api/images/${image.requireId()}") + ) + .andExpect(status().is3xxRedirection()) + } + + @Test + fun `confirmImageUpload - failure - unauthorized`() { + // Given + val key = "images/test.jpg" + + // When & Then + mockMvc.perform( + post("/api/images/upload-confirm") + .param("key", key) + .with(csrf()) + ) + .andExpect(status().isForbidden()) + } + + @Test + fun `getMyImages - failure - unauthorized`() { + // When & Then + mockMvc.perform( + get("/api/images/my-images") + ) + .andExpect(status().isForbidden()) + } + + @Test + fun `deleteImage - failure - unauthorized`() { + // Given + val imageId = 1L + + // When & Then + mockMvc.perform( + delete("/api/images/$imageId") + .with(csrf()) + ) + .andExpect(status().isForbidden()) + } + + private fun createTestImage(memberId: Long): Image { + val command = ImageCreateCommand( + fileKey = FileKey("images/test.jpg"), + imageType = ImageType.PROFILE_IMAGE, + uploadedBy = memberId, + ) + val image = Image.create(command) + return imageRepository.save(image) + } } diff --git a/src/test/kotlin/com/albert/realmoneyrealtaste/adapter/webview/HomeViewTest.kt b/src/test/kotlin/com/albert/realmoneyrealtaste/adapter/webview/HomeViewTest.kt index c4e8d7da..b835134a 100644 --- a/src/test/kotlin/com/albert/realmoneyrealtaste/adapter/webview/HomeViewTest.kt +++ b/src/test/kotlin/com/albert/realmoneyrealtaste/adapter/webview/HomeViewTest.kt @@ -15,9 +15,6 @@ import org.springframework.test.web.servlet.result.MockMvcResultMatchers.model import org.springframework.test.web.servlet.result.MockMvcResultMatchers.status import org.springframework.test.web.servlet.result.MockMvcResultMatchers.view import kotlin.test.Test -import kotlin.test.assertEquals -import kotlin.test.assertNotNull -import kotlin.test.assertTrue class HomeViewTest : IntegrationTestBase() { @@ -50,15 +47,12 @@ class HomeViewTest : IntegrationTestBase() { images = PostFixture.createImages(2) ) ) - flushAndClear() mockMvc.perform(get("/")) .andExpect(status().isOk) .andExpect(view().name("index")) .andExpect(model().attributeExists("postCreateForm")) - .andExpect(model().attributeExists("posts")) .andExpect(model().attributeDoesNotExist("member")) - .andExpect(model().attributeDoesNotExist("hearts")) } @Test @@ -82,19 +76,12 @@ class HomeViewTest : IntegrationTestBase() { images = PostFixture.createImages(2) ) ) - flushAndClear() mockMvc.perform(get("/")) .andExpect(status().isOk) .andExpect(view().name("index")) .andExpect(model().attributeExists("postCreateForm")) - .andExpect(model().attributeExists("posts")) .andExpect(model().attributeExists("member")) - .andExpect(model().attributeExists("hearts")) - .andExpect(model().attributeExists("followStats")) - .andExpect(model().attributeExists("postCount")) - .andExpect(model().attributeExists("suggestedUsers")) - .andExpect(model().attributeExists("followings")) } @Test @@ -116,23 +103,10 @@ class HomeViewTest : IntegrationTestBase() { ) ) } - flushAndClear() - val result = mockMvc.perform(get("/")) + mockMvc.perform(get("/")) .andExpect(status().isOk) .andExpect(view().name("index")) - .andExpect(model().attributeExists("followStats")) - .andExpect(model().attributeExists("postCount")) - .andReturn() - - val modelAndView = result.modelAndView!! - // postCount 확인 - val postCount = modelAndView.model["postCount"] as Long - assertEquals(3L, postCount) - - // followStats 확인 (기본값일 수 있음) - val followStats = modelAndView.model["followStats"] - assertNotNull(followStats) } @Test @@ -168,21 +142,10 @@ class HomeViewTest : IntegrationTestBase() { // member가 post1과 post3에만 좋아요 postHeartRepository.save(PostHeart.create(memberId = member.requireId(), postId = post1.requireId())) postHeartRepository.save(PostHeart.create(memberId = member.requireId(), postId = post3.requireId())) - flushAndClear() - - mockMvc.perform(get("/")) - .andExpect(status().isOk) - .andExpect(view().name("index")) - .andExpect(model().attributeExists("hearts")) - } - @Test - fun `home - success - returns empty posts when no posts exist`() { mockMvc.perform(get("/")) .andExpect(status().isOk) .andExpect(view().name("index")) - .andExpect(model().attributeExists("postCreateForm")) - .andExpect(model().attributeExists("posts")) } @Test @@ -199,52 +162,10 @@ class HomeViewTest : IntegrationTestBase() { images = PostFixture.createImages(2) ) ) - flushAndClear() mockMvc.perform(get("/")) .andExpect(status().isOk) .andExpect(view().name("index")) - .andExpect(model().attributeExists("hearts")) - .andExpect(model().attributeExists("suggestedUsers")) - .andExpect(model().attributeExists("followings")) - } - - @Test - @WithMockMember(email = MemberFixture.DEFAULT_USERNAME) - fun `home - success - includes suggested users and following status for authenticated user`() { - testMemberHelper.getDefaultMember() - - // 추천 사용자 생성 (다른 멤버들) - val suggestedMember1 = testMemberHelper.createActivatedMember("suggested1@example.com", "suggested1") - testMemberHelper.createActivatedMember("suggested2@example.com", "suggested2") - testMemberHelper.createActivatedMember("suggested3@example.com", "suggested3") - - // 게시글 생성하여 추천 사용자들이 활성화되도록 함 - postRepository.save( - PostFixture.createPost( - authorMemberId = suggestedMember1.requireId(), - authorNickname = suggestedMember1.nickname.value, - images = PostFixture.createImages(1) - ) - ) - flushAndClear() - - val result = mockMvc.perform(get("/")) - .andExpect(status().isOk) - .andExpect(view().name("index")) - .andExpect(model().attributeExists("suggestedUsers")) - .andExpect(model().attributeExists("followings")) - .andReturn() - - // suggestedUsers 확인 - val modelAndView = result.modelAndView!! - val suggestedUsers = modelAndView.model["suggestedUsers"] as List<*> - assertTrue(suggestedUsers.isNotEmpty()) - assertTrue(suggestedUsers.size <= 5) // 최대 5명 제한 - - // followings 확인 - val followings = modelAndView.model["followings"] as List<*> - assertNotNull(followings) } @Test @@ -261,7 +182,6 @@ class HomeViewTest : IntegrationTestBase() { ) ) } - flushAndClear() mockMvc.perform( get("/") @@ -270,7 +190,6 @@ class HomeViewTest : IntegrationTestBase() { ) .andExpect(status().isOk) .andExpect(view().name("index")) - .andExpect(model().attributeExists("posts")) } @Test @@ -287,7 +206,6 @@ class HomeViewTest : IntegrationTestBase() { ) ) } - flushAndClear() mockMvc.perform( get("/") @@ -295,8 +213,6 @@ class HomeViewTest : IntegrationTestBase() { .param("size", "10") ) .andExpect(status().isOk) - .andExpect(view().name("index")) - .andExpect(model().attributeExists("posts")) } @Test @@ -323,12 +239,10 @@ class HomeViewTest : IntegrationTestBase() { // post2 삭제 post2.delete(author.requireId()) - flushAndClear() mockMvc.perform(get("/")) .andExpect(status().isOk) .andExpect(view().name("index")) - .andExpect(model().attributeExists("posts")) } @Test @@ -357,47 +271,13 @@ class HomeViewTest : IntegrationTestBase() { images = PostFixture.createImages(2) ) ) - flushAndClear() mockMvc.perform(get("/")) .andExpect(status().isOk) .andExpect(view().name("index")) - .andExpect(model().attributeExists("posts")) .andExpect(model().attributeExists("member")) } - @Test - @WithMockMember(email = MemberFixture.DEFAULT_USERNAME) - fun `home - success - handles exception in suggested members gracefully`() { - // 이 테스트는 memberReader.findSuggestedMembers()에서 예외가 발생했을 때 - // 빈 목록이 설정되는지 확인합니다 - val author = testMemberHelper.createActivatedMember( - email = "author@example.com", - nickname = "author" - ) - - postRepository.save( - PostFixture.createPost( - authorMemberId = author.requireId(), - authorNickname = author.nickname.value, - images = PostFixture.createImages(1) - ) - ) - flushAndClear() - - val result = mockMvc.perform(get("/")) - .andExpect(status().isOk) - .andExpect(view().name("index")) - .andExpect(model().attributeExists("suggestedUsers")) - .andReturn() - - // 예외 발생 시 빈 목록 확인 - val modelAndView = result.modelAndView!! - val suggestedUsers = modelAndView.model["suggestedUsers"] as List<*> - assertNotNull(suggestedUsers) - // 빈 목록 또는 실제 추천 사용자 목록 (예외가 발생하지 않은 경우) - } - @Test fun `home - success - sorts posts by createdAt descending by default`() { val author = testMemberHelper.createActivatedMember() @@ -431,18 +311,9 @@ class HomeViewTest : IntegrationTestBase() { ) ) - flushAndClear() - - val result = mockMvc.perform(get("/")) + mockMvc.perform(get("/")) .andExpect(status().isOk) .andExpect(view().name("index")) - .andExpect(model().attributeExists("posts")) - .andReturn() - - // 게시글이 createdAt 내림차순으로 정렬되었는지 확인 - val modelAndView = result.modelAndView!! - val posts = modelAndView.model["posts"] as org.springframework.data.domain.Page<*> - assertEquals(3, posts.content.size) } @Test @@ -459,7 +330,6 @@ class HomeViewTest : IntegrationTestBase() { ) ) } - flushAndClear() mockMvc.perform( get("/") @@ -468,6 +338,5 @@ class HomeViewTest : IntegrationTestBase() { ) .andExpect(status().isOk) .andExpect(view().name("index")) - .andExpect(model().attributeExists("posts")) } } diff --git a/src/test/kotlin/com/albert/realmoneyrealtaste/adapter/webview/collection/CollectionReadViewTest.kt b/src/test/kotlin/com/albert/realmoneyrealtaste/adapter/webview/collection/CollectionReadViewTest.kt index eb157e2d..6b3d49fd 100644 --- a/src/test/kotlin/com/albert/realmoneyrealtaste/adapter/webview/collection/CollectionReadViewTest.kt +++ b/src/test/kotlin/com/albert/realmoneyrealtaste/adapter/webview/collection/CollectionReadViewTest.kt @@ -55,7 +55,7 @@ class CollectionReadViewTest : IntegrationTestBase() { .andExpect(view().name(CollectionViews.MY_LIST)) .andExpect(model().attributeExists("collections")) .andExpect(model().attributeExists("member")) - .andExpect(model().attributeExists("author")) + .andExpect(model().attributeExists("authorId")) } @Test @@ -75,7 +75,7 @@ class CollectionReadViewTest : IntegrationTestBase() { .andExpect(view().name(CollectionViews.MY_LIST)) .andExpect(model().attributeExists("collections")) .andExpect(model().attributeExists("member")) - .andExpect(model().attributeExists("author")) + .andExpect(model().attributeExists("authorId")) .andReturn() // PUBLIC 필터 시 공개 컬렉션만 반환되는지 확인 @@ -101,7 +101,7 @@ class CollectionReadViewTest : IntegrationTestBase() { .andExpect(view().name(CollectionViews.MY_LIST)) .andExpect(model().attributeExists("collections")) .andExpect(model().attributeExists("member")) - .andExpect(model().attributeExists("author")) + .andExpect(model().attributeExists("authorId")) .andReturn() // ALL 필터 시 모든 컬렉션이 반환되는지 확인 @@ -139,7 +139,6 @@ class CollectionReadViewTest : IntegrationTestBase() { .andExpect(status().isOk) .andExpect(view().name(CollectionViews.DETAIL_EDIT_FRAGMENT)) .andExpect(model().attributeExists("collection")) - .andExpect(model().attributeExists("member")) } @Test @@ -192,7 +191,7 @@ class CollectionReadViewTest : IntegrationTestBase() { .andExpect(view().name(CollectionViews.MY_LIST)) .andExpect(model().attributeExists("collections")) .andExpect(model().attributeExists("member")) - .andExpect(model().attributeExists("author")) + .andExpect(model().attributeExists("authorId")) } @Test @@ -209,7 +208,7 @@ class CollectionReadViewTest : IntegrationTestBase() { .andExpect(view().name(CollectionViews.MY_LIST)) .andExpect(model().attributeExists("collections")) .andExpect(model().attributeDoesNotExist("member")) - .andExpect(model().attributeExists("author")) + .andExpect(model().attributeExists("authorId")) } @Test @@ -226,7 +225,6 @@ class CollectionReadViewTest : IntegrationTestBase() { .andExpect(view().name(CollectionViews.DETAIL_FRAGMENT)) .andExpect(model().attributeExists("collection")) .andExpect(model().attributeExists("member")) - .andExpect(model().attributeExists("author")) } @Test @@ -242,7 +240,6 @@ class CollectionReadViewTest : IntegrationTestBase() { .andExpect(view().name(CollectionViews.DETAIL_FRAGMENT)) .andExpect(model().attributeExists("collection")) .andExpect(model().attributeDoesNotExist("member")) - .andExpect(model().attributeExists("author")) } private fun createCollection( @@ -309,7 +306,6 @@ class CollectionReadViewTest : IntegrationTestBase() { .andExpect(model().attributeExists("collection")) .andExpect(model().attributeExists("posts")) .andExpect(model().attributeExists("myPosts")) - .andExpect(model().attributeExists("member")) .andReturn() // 컬렉션 게시글 확인 diff --git a/src/test/kotlin/com/albert/realmoneyrealtaste/adapter/webview/follow/FollowViewTest.kt b/src/test/kotlin/com/albert/realmoneyrealtaste/adapter/webview/follow/FollowReadViewTest.kt similarity index 62% rename from src/test/kotlin/com/albert/realmoneyrealtaste/adapter/webview/follow/FollowViewTest.kt rename to src/test/kotlin/com/albert/realmoneyrealtaste/adapter/webview/follow/FollowReadViewTest.kt index e05cfa08..fdb25cc1 100644 --- a/src/test/kotlin/com/albert/realmoneyrealtaste/adapter/webview/follow/FollowViewTest.kt +++ b/src/test/kotlin/com/albert/realmoneyrealtaste/adapter/webview/follow/FollowReadViewTest.kt @@ -3,6 +3,7 @@ package com.albert.realmoneyrealtaste.adapter.webview.follow import com.albert.realmoneyrealtaste.IntegrationTestBase import com.albert.realmoneyrealtaste.application.follow.dto.FollowCreateRequest import com.albert.realmoneyrealtaste.application.follow.provided.FollowCreator +import com.albert.realmoneyrealtaste.domain.follow.Follow import com.albert.realmoneyrealtaste.util.MemberFixture import com.albert.realmoneyrealtaste.util.TestMemberHelper import com.albert.realmoneyrealtaste.util.WithMockMember @@ -16,7 +17,7 @@ import kotlin.test.Test import kotlin.test.assertFalse import kotlin.test.assertTrue -class FollowViewTest : IntegrationTestBase() { +class FollowReadViewTest : IntegrationTestBase() { @Autowired private lateinit var mockMvc: MockMvc @@ -27,6 +28,361 @@ class FollowViewTest : IntegrationTestBase() { @Autowired private lateinit var followCreator: FollowCreator + @Test + fun `followButtonFragment - forbidden - when not authenticated`() { + val authorId = 1L + + mockMvc.perform( + get(FollowUrls.FOLLOW_BUTTON, authorId) + ) + .andExpect(status().isForbidden()) + } + + @Test + @WithMockMember(email = MemberFixture.DEFAULT_USERNAME) + fun `followButtonFragment - success - returns follow button fragment`() { + val member = testMemberHelper.getDefaultMember() + val author = testMemberHelper.createActivatedMember("author@test.com", "author") + + mockMvc.perform( + get(FollowUrls.FOLLOW_BUTTON, author.requireId()) + ) + .andExpect(status().isOk) + .andExpect(view().name(FollowViews.FOLLOW_BUTTON)) + .andExpect(model().attributeExists("authorId")) + .andExpect(model().attributeExists("isFollowing")) + .andExpect(model().attribute("authorId", author.requireId())) + .andExpect(model().attribute("isFollowing", false)) + } + + @Test + @WithMockMember(email = MemberFixture.DEFAULT_USERNAME) + fun `followButtonFragment - success - returns true when following author`() { + val member = testMemberHelper.getDefaultMember() + val author = testMemberHelper.createActivatedMember("author@test.com", "author") + + // 팔로우 생성 + createActiveFollow(member.requireId(), author.requireId()) + + mockMvc.perform( + get(FollowUrls.FOLLOW_BUTTON, author.requireId()) + ) + .andExpect(status().isOk) + .andExpect(view().name(FollowViews.FOLLOW_BUTTON)) + .andExpect(model().attribute("isFollowing", true)) + } + + @Test + @WithMockMember(email = MemberFixture.DEFAULT_USERNAME) + fun `readFollowingList - success - returns following list without keyword`() { + val member = testMemberHelper.getDefaultMember() + val following1 = testMemberHelper.createActivatedMember("following1@test.com", "following1") + val following2 = testMemberHelper.createActivatedMember("following2@test.com", "following2") + + // 팔로우 생성 + createActiveFollow(member.requireId(), following1.requireId()) + createActiveFollow(member.requireId(), following2.requireId()) + + mockMvc.perform( + get(FollowUrls.FOLLOWING_FRAGMENT) + ) + .andExpect(status().isOk) + .andExpect(view().name(FollowViews.FOLLOWING_FRAGMENT)) + .andExpect(model().attributeExists("followings")) + .andExpect(model().attributeExists("member")) + .andExpect(model().attributeExists("currentUserFollowingIds")) + } + + @Test + @WithMockMember(email = MemberFixture.DEFAULT_USERNAME) + fun `readFollowingList - success - returns filtered following list with keyword`() { + val member = testMemberHelper.getDefaultMember() + val matchingFollowing = testMemberHelper.createActivatedMember("matching@test.com", "searchTarget") + val nonMatchingFollowing = testMemberHelper.createActivatedMember("other@test.com", "other") + + // 팔로우 생성 + createActiveFollow(member.requireId(), matchingFollowing.requireId()) + createActiveFollow(member.requireId(), nonMatchingFollowing.requireId()) + + mockMvc.perform( + get(FollowUrls.FOLLOWING_FRAGMENT) + .param("keyword", "search") + ) + .andExpect(status().isOk) + .andExpect(view().name(FollowViews.FOLLOWING_FRAGMENT)) + .andExpect(model().attributeExists("followings")) + .andExpect(model().attributeExists("member")) + .andExpect(model().attributeExists("currentUserFollowingIds")) + } + + @Test + @WithMockMember(email = MemberFixture.DEFAULT_USERNAME) + fun `readFollowerList - success - returns follower list without keyword`() { + val member = testMemberHelper.getDefaultMember() + val follower1 = testMemberHelper.createActivatedMember("follower1@test.com", "follower1") + val follower2 = testMemberHelper.createActivatedMember("follower2@test.com", "follower2") + + // 팔로우 생성 + createActiveFollow(follower1.requireId(), member.requireId()) + createActiveFollow(follower2.requireId(), member.requireId()) + + mockMvc.perform( + get(FollowUrls.FOLLOWERS_FRAGMENT) + ) + .andExpect(status().isOk) + .andExpect(view().name(FollowViews.FOLLOWERS_FRAGMENT)) + .andExpect(model().attributeExists("followers")) + .andExpect(model().attributeExists("member")) + .andExpect(model().attributeExists("currentUserFollowingIds")) + } + + @Test + @WithMockMember(email = MemberFixture.DEFAULT_USERNAME) + fun `readFollowerList - success - returns filtered follower list with keyword`() { + val member = testMemberHelper.getDefaultMember() + val matchingFollower = testMemberHelper.createActivatedMember("matching@test.com", "searchTarget") + val nonMatchingFollower = testMemberHelper.createActivatedMember("other@test.com", "other") + + // 팔로우 생성 + createActiveFollow(matchingFollower.requireId(), member.requireId()) + createActiveFollow(nonMatchingFollower.requireId(), member.requireId()) + + mockMvc.perform( + get(FollowUrls.FOLLOWERS_FRAGMENT) + .param("keyword", "search") + ) + .andExpect(status().isOk) + .andExpect(view().name(FollowViews.FOLLOWERS_FRAGMENT)) + .andExpect(model().attributeExists("followers")) + .andExpect(model().attributeExists("member")) + .andExpect(model().attributeExists("currentUserFollowingIds")) + } + + @Test + fun `readUserFollowingList - success - returns user following list without authentication`() { + val targetMember = testMemberHelper.createActivatedMember("target@test.com", "target") + val following1 = testMemberHelper.createActivatedMember("following1@test.com", "following1") + val following2 = testMemberHelper.createActivatedMember("following2@test.com", "following2") + + // 팔로우 생성 + createActiveFollow(targetMember.requireId(), following1.requireId()) + createActiveFollow(targetMember.requireId(), following2.requireId()) + + mockMvc.perform( + get(FollowUrls.USER_FOLLOWING_FRAGMENT, targetMember.requireId()) + ) + .andExpect(status().isOk) + .andExpect(view().name(FollowViews.FOLLOWING_FRAGMENT)) + .andExpect(model().attributeExists("followings")) + .andExpect(model().attributeDoesNotExist("member")) + .andExpect(model().attributeDoesNotExist("currentUserFollowingIds")) + } + + @Test + @WithMockMember(email = MemberFixture.DEFAULT_USERNAME) + fun `readUserFollowingList - success - returns user following list with authentication`() { + val currentUser = testMemberHelper.getDefaultMember() + val targetMember = testMemberHelper.createActivatedMember("target@test.com", "target") + val following1 = testMemberHelper.createActivatedMember("following1@test.com", "following1") + val following2 = testMemberHelper.createActivatedMember("following2@test.com", "following2") + + // 팔로우 생성 + createActiveFollow(targetMember.requireId(), following1.requireId()) + createActiveFollow(targetMember.requireId(), following2.requireId()) + // 현재 사용자가 타겟 멤버도 팔로우 + createActiveFollow(currentUser.requireId(), targetMember.requireId()) + + mockMvc.perform( + get(FollowUrls.USER_FOLLOWING_FRAGMENT, targetMember.requireId()) + ) + .andExpect(status().isOk) + .andExpect(view().name(FollowViews.FOLLOWING_FRAGMENT)) + .andExpect(model().attributeExists("followings")) + .andExpect(model().attributeExists("member")) + .andExpect(model().attributeExists("currentUserFollowingIds")) + } + + @Test + fun `readUserFollowerList - success - returns user follower list without authentication`() { + val targetMember = testMemberHelper.createActivatedMember("target@test.com", "target") + val follower1 = testMemberHelper.createActivatedMember("follower1@test.com", "follower1") + val follower2 = testMemberHelper.createActivatedMember("follower2@test.com", "follower2") + + // 팔로우 생성 + createActiveFollow(follower1.requireId(), targetMember.requireId()) + createActiveFollow(follower2.requireId(), targetMember.requireId()) + + mockMvc.perform( + get(FollowUrls.USER_FOLLOWERS_FRAGMENT, targetMember.requireId()) + ) + .andExpect(status().isOk) + .andExpect(view().name(FollowViews.FOLLOWERS_FRAGMENT)) + .andExpect(model().attributeExists("followers")) + .andExpect(model().attributeDoesNotExist("member")) + .andExpect(model().attributeDoesNotExist("currentUserFollowingIds")) + } + + @Test + @WithMockMember(email = MemberFixture.DEFAULT_USERNAME) + fun `readUserFollowerList - success - returns user follower list with authentication`() { + val currentUser = testMemberHelper.getDefaultMember() + val targetMember = testMemberHelper.createActivatedMember("target@test.com", "target") + val follower1 = testMemberHelper.createActivatedMember("follower1@test.com", "follower1") + val follower2 = testMemberHelper.createActivatedMember("follower2@test.com", "follower2") + + // 팔로우 생성 + createActiveFollow(follower1.requireId(), targetMember.requireId()) + createActiveFollow(follower2.requireId(), targetMember.requireId()) + // 현재 사용자가 팔로워 중 하나도 팔로우 + createActiveFollow(currentUser.requireId(), follower1.requireId()) + + mockMvc.perform( + get(FollowUrls.USER_FOLLOWERS_FRAGMENT, targetMember.requireId()) + ) + .andExpect(status().isOk) + .andExpect(view().name(FollowViews.FOLLOWERS_FRAGMENT)) + .andExpect(model().attributeExists("followers")) + .andExpect(model().attributeExists("member")) + .andExpect(model().attributeExists("currentUserFollowingIds")) + } + + @Test + @WithMockMember(email = MemberFixture.DEFAULT_USERNAME) + fun `readFollowingList - success - handles pagination correctly`() { + val member = testMemberHelper.getDefaultMember() + val followings = (1..5).map { index -> + testMemberHelper.createActivatedMember("following$index@test.com", "following$index") + } + + // 팔로우 생성 + followings.forEach { createActiveFollow(member.requireId(), it.requireId()) } + + mockMvc.perform( + get(FollowUrls.FOLLOWING_FRAGMENT) + .param("page", "0") + .param("size", "3") + ) + .andExpect(status().isOk) + .andExpect(view().name(FollowViews.FOLLOWING_FRAGMENT)) + .andExpect(model().attributeExists("followings")) + } + + @Test + @WithMockMember(email = MemberFixture.DEFAULT_USERNAME) + fun `readFollowingList - success - excludes deactivated members`() { + val member = testMemberHelper.getDefaultMember() + val activeFollowing = testMemberHelper.createActivatedMember("active@test.com", "active") + val deactivatedFollowing = testMemberHelper.createActivatedMember("deactivated@test.com", "deactivated") + + // 팔로우 생성 + createActiveFollow(member.requireId(), activeFollowing.requireId()) + createActiveFollow(member.requireId(), deactivatedFollowing.requireId()) + + // 비활성화 + deactivatedFollowing.deactivate() + flushAndClear() + + mockMvc.perform( + get(FollowUrls.FOLLOWING_FRAGMENT) + ) + .andExpect(status().isOk) + .andExpect(view().name(FollowViews.FOLLOWING_FRAGMENT)) + .andExpect(model().attributeExists("followings")) + } + + @Test + @WithMockMember(email = MemberFixture.DEFAULT_USERNAME) + fun `readFollowerList - success - handles pagination correctly`() { + val member = testMemberHelper.getDefaultMember() + val followers = (1..5).map { index -> + testMemberHelper.createActivatedMember("follower$index@test.com", "follower$index") + } + + // 팔로우 생성 + followers.forEach { createActiveFollow(it.requireId(), member.requireId()) } + + mockMvc.perform( + get(FollowUrls.FOLLOWERS_FRAGMENT) + .param("page", "0") + .param("size", "3") + ) + .andExpect(status().isOk) + .andExpect(view().name(FollowViews.FOLLOWERS_FRAGMENT)) + .andExpect(model().attributeExists("followers")) + } + + @Test + @WithMockMember(email = MemberFixture.DEFAULT_USERNAME) + fun `readFollowerList - success - excludes deactivated members`() { + val member = testMemberHelper.getDefaultMember() + val activeFollower = testMemberHelper.createActivatedMember("active@test.com", "active") + val deactivatedFollower = testMemberHelper.createActivatedMember("deactivated@test.com", "deactivated") + + // 팔로우 생성 + createActiveFollow(activeFollower.requireId(), member.requireId()) + createActiveFollow(deactivatedFollower.requireId(), member.requireId()) + + // 비활성화 + deactivatedFollower.deactivate() + flushAndClear() + + mockMvc.perform( + get(FollowUrls.FOLLOWERS_FRAGMENT) + ) + .andExpect(status().isOk) + .andExpect(view().name(FollowViews.FOLLOWERS_FRAGMENT)) + .andExpect(model().attributeExists("followers")) + } + + @Test + fun `readUserFollowingList - success - excludes deactivated members`() { + val targetMember = testMemberHelper.createActivatedMember("target@test.com", "target") + val activeFollowing = testMemberHelper.createActivatedMember("active@test.com", "active") + val deactivatedFollowing = testMemberHelper.createActivatedMember("deactivated@test.com", "deactivated") + + // 팔로우 생성 + createActiveFollow(targetMember.requireId(), activeFollowing.requireId()) + createActiveFollow(targetMember.requireId(), deactivatedFollowing.requireId()) + + // 비활성화 + deactivatedFollowing.deactivate() + flushAndClear() + + mockMvc.perform( + get(FollowUrls.USER_FOLLOWING_FRAGMENT, targetMember.requireId()) + ) + .andExpect(status().isOk) + .andExpect(view().name(FollowViews.FOLLOWING_FRAGMENT)) + .andExpect(model().attributeExists("followings")) + } + + @Test + fun `readUserFollowerList - success - excludes deactivated members`() { + val targetMember = testMemberHelper.createActivatedMember("target@test.com", "target") + val activeFollower = testMemberHelper.createActivatedMember("active@test.com", "active") + val deactivatedFollower = testMemberHelper.createActivatedMember("deactivated@test.com", "deactivated") + + // 팔로우 생성 + createActiveFollow(activeFollower.requireId(), targetMember.requireId()) + createActiveFollow(deactivatedFollower.requireId(), targetMember.requireId()) + + // 비활성화 + deactivatedFollower.deactivate() + flushAndClear() + + mockMvc.perform( + get(FollowUrls.USER_FOLLOWERS_FRAGMENT, targetMember.requireId()) + ) + .andExpect(status().isOk) + .andExpect(view().name(FollowViews.FOLLOWERS_FRAGMENT)) + .andExpect(model().attributeExists("followers")) + } + + private fun createActiveFollow(followerId: Long, followingId: Long): Follow { + val request = FollowCreateRequest(followerId, followingId) + return followCreator.follow(request) + } + @Test @WithMockMember(email = MemberFixture.DEFAULT_USERNAME) fun `readFollowingList - success - returns following fragment`() { @@ -36,7 +392,6 @@ class FollowViewTest : IntegrationTestBase() { .andExpect(model().attributeExists("followings")) .andExpect(model().attributeExists("currentUserFollowingIds")) .andExpect(model().attributeExists("member")) - .andExpect(model().attributeExists("author")) .andReturn() // 모델 속성 확인 @@ -44,7 +399,6 @@ class FollowViewTest : IntegrationTestBase() { assertTrue(modelAndView.model.containsKey("followings")) assertTrue(modelAndView.model.containsKey("currentUserFollowingIds")) assertTrue(modelAndView.model.containsKey("member")) - assertTrue(modelAndView.model.containsKey("author")) } @Test @@ -94,7 +448,6 @@ class FollowViewTest : IntegrationTestBase() { .andExpect(status().isOk) .andExpect(view().name(FollowViews.FOLLOWING_FRAGMENT)) .andExpect(model().attributeExists("followings")) - .andExpect(model().attributeExists("author")) .andExpect(model().attributeExists("member")) .andExpect(model().attributeExists("currentUserFollowingIds")) .andReturn() @@ -102,7 +455,6 @@ class FollowViewTest : IntegrationTestBase() { // 모델 속성 확인 val modelAndView = result.modelAndView!! assertTrue(modelAndView.model.containsKey("followings")) - assertTrue(modelAndView.model.containsKey("author")) assertTrue(modelAndView.model.containsKey("member")) assertTrue(modelAndView.model.containsKey("currentUserFollowingIds")) } @@ -123,13 +475,11 @@ class FollowViewTest : IntegrationTestBase() { .andExpect(status().isOk) .andExpect(view().name(FollowViews.FOLLOWING_FRAGMENT)) .andExpect(model().attributeExists("followings")) - .andExpect(model().attributeExists("author")) .andReturn() // 인증 없이도 author와 followings는 포함되어야 함 val modelAndView = result.modelAndView!! assertTrue(modelAndView.model.containsKey("followings")) - assertTrue(modelAndView.model.containsKey("author")) // 인증 없이는 member와 currentUserFollowingIds가 없어야 함 assertTrue(!modelAndView.model.containsKey("member")) @@ -153,7 +503,6 @@ class FollowViewTest : IntegrationTestBase() { .andExpect(status().isOk) .andExpect(view().name(FollowViews.FOLLOWERS_FRAGMENT)) .andExpect(model().attributeExists("followers")) - .andExpect(model().attributeExists("author")) .andExpect(model().attributeExists("member")) .andExpect(model().attributeExists("currentUserFollowingIds")) .andReturn() @@ -161,7 +510,6 @@ class FollowViewTest : IntegrationTestBase() { // 모델 속성 확인 val modelAndView = result.modelAndView!! assertTrue(modelAndView.model.containsKey("followers")) - assertTrue(modelAndView.model.containsKey("author")) assertTrue(modelAndView.model.containsKey("member")) assertTrue(modelAndView.model.containsKey("currentUserFollowingIds")) } @@ -182,13 +530,11 @@ class FollowViewTest : IntegrationTestBase() { .andExpect(status().isOk) .andExpect(view().name(FollowViews.FOLLOWERS_FRAGMENT)) .andExpect(model().attributeExists("followers")) - .andExpect(model().attributeExists("author")) .andReturn() // 인증 없이도 author와 followers는 포함되어야 함 val modelAndView = result.modelAndView!! assertTrue(modelAndView.model.containsKey("followers")) - assertTrue(modelAndView.model.containsKey("author")) // 인증 없이는 member와 currentUserFollowingIds가 없어야 함 assertTrue(!modelAndView.model.containsKey("member")) @@ -343,14 +689,14 @@ class FollowViewTest : IntegrationTestBase() { @WithMockMember(email = MemberFixture.DEFAULT_USERNAME) fun `readUserFollowingList - success - handles invalid member id gracefully`() { mockMvc.perform(get(FollowUrls.USER_FOLLOWING_FRAGMENT.replace("{memberId}", "9999"))) - .andExpect(status().is4xxClientError()) + .andExpect(status().isOk()) } @Test @WithMockMember(email = MemberFixture.DEFAULT_USERNAME) fun `readUserFollowerList - success - handles invalid member id gracefully`() { mockMvc.perform(get(FollowUrls.USER_FOLLOWERS_FRAGMENT.replace("{memberId}", "9999"))) - .andExpect(status().is4xxClientError()) + .andExpect(status().isOk()) } @Test diff --git a/src/test/kotlin/com/albert/realmoneyrealtaste/adapter/webview/follow/FollowTerminateViewTest.kt b/src/test/kotlin/com/albert/realmoneyrealtaste/adapter/webview/follow/FollowTerminateViewTest.kt index dd18b889..54d8246a 100644 --- a/src/test/kotlin/com/albert/realmoneyrealtaste/adapter/webview/follow/FollowTerminateViewTest.kt +++ b/src/test/kotlin/com/albert/realmoneyrealtaste/adapter/webview/follow/FollowTerminateViewTest.kt @@ -53,7 +53,6 @@ class FollowTerminateViewTest : IntegrationTestBase() { responseContent.contains("hx-post=\"/members/${targetMember.requireId()}/follow\""), "팔로우 요청 URL이 포함되어야 함" ) - assertTrue(responseContent.contains("fa-solid fa-plus"), "팔로우 추가 아이콘이 포함되어야 함") assertTrue(responseContent.contains("follow-button-${targetMember.requireId()}"), "타겟 ID가 포함되어야 함") // 실제 팔로우 관계가 삭제되었는지 확인 diff --git a/src/test/kotlin/com/albert/realmoneyrealtaste/adapter/webview/friend/FriendReadViewTest.kt b/src/test/kotlin/com/albert/realmoneyrealtaste/adapter/webview/friend/FriendReadViewTest.kt index ff7e0d27..47c183b2 100644 --- a/src/test/kotlin/com/albert/realmoneyrealtaste/adapter/webview/friend/FriendReadViewTest.kt +++ b/src/test/kotlin/com/albert/realmoneyrealtaste/adapter/webview/friend/FriendReadViewTest.kt @@ -11,6 +11,7 @@ import org.springframework.test.web.servlet.result.MockMvcResultMatchers.model import org.springframework.test.web.servlet.result.MockMvcResultMatchers.status import org.springframework.test.web.servlet.result.MockMvcResultMatchers.view import kotlin.test.Test +import kotlin.test.assertEquals class FriendReadViewTest : IntegrationTestBase() { @@ -40,7 +41,6 @@ class FriendReadViewTest : IntegrationTestBase() { .andExpect(view().name(FriendViews.FRIEND_WIDGET)) .andExpect(model().attributeExists("recentFriends")) .andExpect(model().attributeExists("pendingRequestsCount")) - .andExpect(model().attributeExists("author")) .andExpect(model().attributeExists("member")) } @@ -55,7 +55,6 @@ class FriendReadViewTest : IntegrationTestBase() { .andExpect(view().name(FriendViews.FRIEND_WIDGET)) .andExpect(model().attributeExists("recentFriends")) .andExpect(model().attributeExists("pendingRequestsCount")) - .andExpect(model().attributeExists("author")) .andExpect(model().attributeDoesNotExist("member")) } @@ -71,7 +70,6 @@ class FriendReadViewTest : IntegrationTestBase() { .andExpect(view().name(FriendViews.FRIEND_WIDGET)) .andExpect(model().attributeExists("recentFriends")) .andExpect(model().attributeExists("pendingRequestsCount")) - .andExpect(model().attributeExists("author")) .andExpect(model().attributeExists("member")) } @@ -94,4 +92,71 @@ class FriendReadViewTest : IntegrationTestBase() { ) .andExpect(status().isForbidden()) } + + @Test + fun `readFriendButton - forbidden - when not authenticated`() { + val authorId = 1L + + mockMvc.perform( + get(FriendUrls.FRIEND_BUTTON, authorId) + ) + .andExpect(status().isForbidden()) + } + + @Test + @WithMockMember(email = MemberFixture.DEFAULT_USERNAME) + fun `readFriendButton - success - returns friend button when not friend`() { + val member = testMemberHelper.getDefaultMember() + val author = testMemberHelper.createActivatedMember("author@test.com", "author") + + mockMvc.perform( + get(FriendUrls.FRIEND_BUTTON, author.requireId()) + ) + .andExpect(status().isOk) + .andExpect(view().name(FriendViews.FRIEND_BUTTON)) + .andExpect(model().attributeExists("authorId")) + .andExpect(model().attributeExists("isFriend")) + .andExpect(model().attributeExists("hasSentFriendRequest")) + .andExpect(model().attribute("authorId", author.requireId())) + .andExpect(model().attribute("isFriend", false)) + .andExpect(model().attribute("hasSentFriendRequest", false)) + } + + @Test + @WithMockMember(email = MemberFixture.DEFAULT_USERNAME) + fun `readFriendWidgetFragment - success - pendingRequestsCount is zero for other profile`() { + val targetMember = testMemberHelper.createActivatedMember("target@example.com", "target") + + val result = mockMvc.perform( + get(FriendUrls.FRIEND_WIDGET, targetMember.requireId()) + ) + .andExpect(status().isOk) + .andExpect(view().name(FriendViews.FRIEND_WIDGET)) + .andExpect(model().attributeExists("recentFriends")) + .andExpect(model().attributeExists("pendingRequestsCount")) + .andExpect(model().attributeExists("member")) + .andReturn() + + // 다른 사용자 프로필에서는 pendingRequestsCount가 항상 0이어야 함 + val modelAndView = result.modelAndView!! + val pendingRequestsCount = modelAndView.model["pendingRequestsCount"] as Long + assertEquals(0, pendingRequestsCount, "다른 사용자의 pendingRequestsCount는 0이어야 함") + } + + @Test + @WithMockMember(email = MemberFixture.DEFAULT_USERNAME) + fun `readFriendWidgetFragment - success - handles pagination correctly`() { + val member = testMemberHelper.getDefaultMember() + + mockMvc.perform( + get(FriendUrls.FRIEND_WIDGET, member.requireId()) + .param("page", "0") + .param("size", "5") + ) + .andExpect(status().isOk) + .andExpect(view().name(FriendViews.FRIEND_WIDGET)) + .andExpect(model().attributeExists("recentFriends")) + .andExpect(model().attributeExists("pendingRequestsCount")) + .andExpect(model().attributeExists("member")) + } } diff --git a/src/test/kotlin/com/albert/realmoneyrealtaste/adapter/webview/friend/FriendWriteViewTest.kt b/src/test/kotlin/com/albert/realmoneyrealtaste/adapter/webview/friend/FriendWriteViewTest.kt index 291f3279..15578e2b 100644 --- a/src/test/kotlin/com/albert/realmoneyrealtaste/adapter/webview/friend/FriendWriteViewTest.kt +++ b/src/test/kotlin/com/albert/realmoneyrealtaste/adapter/webview/friend/FriendWriteViewTest.kt @@ -2,7 +2,6 @@ package com.albert.realmoneyrealtaste.adapter.webview.friend import com.albert.realmoneyrealtaste.IntegrationTestBase import com.albert.realmoneyrealtaste.application.friend.provided.FriendRequestor -import com.albert.realmoneyrealtaste.domain.friend.command.FriendRequestCommand import com.albert.realmoneyrealtaste.util.MemberFixture import com.albert.realmoneyrealtaste.util.TestMemberHelper import com.albert.realmoneyrealtaste.util.WithMockMember @@ -39,23 +38,18 @@ class FriendWriteViewTest : IntegrationTestBase() { @WithMockMember(email = MemberFixture.DEFAULT_USERNAME) fun `sendFriendRequest - success - sends friend request and returns friend button fragment`() { val targetMember = testMemberHelper.createActivatedMember("target@example.com", "target") - - val request = SendFriendRequest( - toMemberId = targetMember.requireId(), - toMemberNickname = targetMember.nickname.value - ) + val authorId = targetMember.requireId() val result = mockMvc.perform( post(FriendUrls.SEND_FRIEND_REQUEST) .contentType(MediaType.APPLICATION_JSON) - .content(objectMapper.writeValueAsString(request)) + .param("authorId", authorId.toString()) .with(csrf()) ) .andExpect(status().isOk) .andExpect(view().name(FriendViews.FRIEND_BUTTON)) .andExpect(model().attributeExists("isFriend")) .andExpect(model().attributeExists("hasSentFriendRequest")) - .andExpect(model().attributeExists("author")) .andReturn() // hasSentFriendRequest가 true로 설정되었는지 확인 @@ -86,16 +80,12 @@ class FriendWriteViewTest : IntegrationTestBase() { @WithMockMember(email = MemberFixture.DEFAULT_USERNAME) fun `sendFriendRequest - success - handles empty friend list initially`() { val targetMember = testMemberHelper.createActivatedMember("target@example.com", "target") - - val request = SendFriendRequest( - toMemberId = targetMember.requireId(), - toMemberNickname = targetMember.nickname.value - ) + val authorId = targetMember.requireId() val result = mockMvc.perform( post(FriendUrls.SEND_FRIEND_REQUEST) .contentType(MediaType.APPLICATION_JSON) - .content(objectMapper.writeValueAsString(request)) + .param("authorId", authorId.toString()) .with(csrf()) ) .andExpect(status().isOk) @@ -114,11 +104,8 @@ class FriendWriteViewTest : IntegrationTestBase() { val receiver = testMemberHelper.getDefaultMember() val sender = testMemberHelper.createActivatedMember("sender@example.com", "sender") val friendship = friendRequestor.sendFriendRequest( - FriendRequestCommand( - fromMemberId = sender.requireId(), - toMemberId = receiver.requireId(), - toMemberNickname = receiver.nickname.value, - ) + fromMemberId = sender.requireId(), + toMemberId = receiver.requireId(), ) val result = mockMvc.perform( @@ -130,7 +117,6 @@ class FriendWriteViewTest : IntegrationTestBase() { .andExpect(view().name(FriendViews.FRIEND_BUTTON)) .andExpect(model().attributeExists("isFriend")) .andExpect(model().attributeExists("hasSentFriendRequest")) - .andExpect(model().attributeExists("author")) .andReturn() // 친구 요청을 수락한 후 상태 확인 @@ -148,11 +134,8 @@ class FriendWriteViewTest : IntegrationTestBase() { val receiver = testMemberHelper.getDefaultMember() val sender = testMemberHelper.createActivatedMember("sender@example.com", "sender") val friendship = friendRequestor.sendFriendRequest( - FriendRequestCommand( - fromMemberId = sender.requireId(), - toMemberId = receiver.requireId(), - toMemberNickname = receiver.nickname.value, - ) + fromMemberId = sender.requireId(), + toMemberId = receiver.requireId(), ) val result = mockMvc.perform( @@ -164,7 +147,6 @@ class FriendWriteViewTest : IntegrationTestBase() { .andExpect(view().name(FriendViews.FRIEND_BUTTON)) .andExpect(model().attributeExists("isFriend")) .andExpect(model().attributeExists("hasSentFriendRequest")) - .andExpect(model().attributeExists("author")) .andReturn() // 친구 요청을 거절한 후 상태 확인 @@ -181,11 +163,8 @@ class FriendWriteViewTest : IntegrationTestBase() { val receiver = testMemberHelper.createActivatedMember() val sender = testMemberHelper.createActivatedMember("sender@example.com", "sender") val friendship = friendRequestor.sendFriendRequest( - FriendRequestCommand( - fromMemberId = sender.requireId(), - toMemberId = receiver.requireId(), - toMemberNickname = receiver.nickname.value, - ) + fromMemberId = sender.requireId(), + toMemberId = receiver.requireId(), ) mockMvc.perform( @@ -201,11 +180,8 @@ class FriendWriteViewTest : IntegrationTestBase() { val receiver = testMemberHelper.getDefaultMember() val sender = testMemberHelper.createActivatedMember("sender@example.com", "sender") val friendship = friendRequestor.sendFriendRequest( - FriendRequestCommand( - fromMemberId = sender.requireId(), - toMemberId = receiver.requireId(), - toMemberNickname = receiver.nickname.value, - ) + fromMemberId = sender.requireId(), + toMemberId = receiver.requireId(), ) val result = mockMvc.perform( @@ -216,7 +192,6 @@ class FriendWriteViewTest : IntegrationTestBase() { .andExpect(view().name(FriendViews.FRIEND_BUTTON)) .andExpect(model().attributeExists("isFriend")) .andExpect(model().attributeExists("hasSentFriendRequest")) - .andExpect(model().attributeExists("author")) .andReturn() // 친구 해제 후 상태 확인 @@ -242,16 +217,12 @@ class FriendWriteViewTest : IntegrationTestBase() { @WithMockMember(email = MemberFixture.DEFAULT_USERNAME) fun `sendFriendRequest - success - validates request data`() { val targetMember = testMemberHelper.createActivatedMember("target@example.com", "target") - - val request = SendFriendRequest( - toMemberId = targetMember.requireId(), - toMemberNickname = targetMember.nickname.value - ) + val authorId = targetMember.requireId() val result = mockMvc.perform( post(FriendUrls.SEND_FRIEND_REQUEST) .contentType(MediaType.APPLICATION_JSON) - .content(objectMapper.writeValueAsString(request)) + .param("authorId", authorId.toString()) .with(csrf()) ) .andExpect(status().isOk) @@ -261,7 +232,6 @@ class FriendWriteViewTest : IntegrationTestBase() { val modelAndView = result.modelAndView!! assertTrue(modelAndView.model.containsKey("isFriend")) assertTrue(modelAndView.model.containsKey("hasSentFriendRequest")) - assertTrue(modelAndView.model.containsKey("author")) } @Test @@ -270,11 +240,8 @@ class FriendWriteViewTest : IntegrationTestBase() { val receiver = testMemberHelper.getDefaultMember() val sender = testMemberHelper.createActivatedMember("sender@example.com", "sender") val friendship = friendRequestor.sendFriendRequest( - FriendRequestCommand( - fromMemberId = sender.requireId(), - toMemberId = receiver.requireId(), - toMemberNickname = receiver.nickname.value, - ) + fromMemberId = sender.requireId(), + toMemberId = receiver.requireId(), ) mockMvc.perform( @@ -286,7 +253,6 @@ class FriendWriteViewTest : IntegrationTestBase() { .andExpect(view().name(FriendViews.FRIEND_BUTTON)) .andExpect(model().attributeExists("isFriend")) .andExpect(model().attributeExists("hasSentFriendRequest")) - .andExpect(model().attributeExists("author")) } @Test @@ -335,11 +301,8 @@ class FriendWriteViewTest : IntegrationTestBase() { // sender가 receiver에게 친구 요청 보냄 val friendship = friendRequestor.sendFriendRequest( - FriendRequestCommand( - fromMemberId = sender.requireId(), - toMemberId = receiver.requireId(), - toMemberNickname = receiver.nickname.value, - ) + fromMemberId = sender.requireId(), + toMemberId = receiver.requireId(), ) // receiver(현재 사용자)가 요청에 응답 @@ -389,11 +352,8 @@ class FriendWriteViewTest : IntegrationTestBase() { val receiver = testMemberHelper.getDefaultMember() val sender = testMemberHelper.createActivatedMember("sender@example.com", "sender") val friendship = friendRequestor.sendFriendRequest( - FriendRequestCommand( - fromMemberId = sender.requireId(), - toMemberId = receiver.requireId(), - toMemberNickname = receiver.nickname.value, - ) + fromMemberId = sender.requireId(), + toMemberId = receiver.requireId(), ) mockMvc.perform( @@ -410,11 +370,8 @@ class FriendWriteViewTest : IntegrationTestBase() { val receiver = testMemberHelper.getDefaultMember() val sender = testMemberHelper.createActivatedMember("sender@example.com", "sender") val friendship = friendRequestor.sendFriendRequest( - FriendRequestCommand( - fromMemberId = sender.requireId(), - toMemberId = receiver.requireId(), - toMemberNickname = receiver.nickname.value, - ) + fromMemberId = sender.requireId(), + toMemberId = receiver.requireId(), ) mockMvc.perform( @@ -431,11 +388,8 @@ class FriendWriteViewTest : IntegrationTestBase() { val receiver = testMemberHelper.getDefaultMember() val sender = testMemberHelper.createActivatedMember("sender@example.com", "sender") val friendship = friendRequestor.sendFriendRequest( - FriendRequestCommand( - fromMemberId = sender.requireId(), - toMemberId = receiver.requireId(), - toMemberNickname = receiver.nickname.value, - ) + fromMemberId = sender.requireId(), + toMemberId = receiver.requireId(), ) mockMvc.perform( @@ -480,11 +434,8 @@ class FriendWriteViewTest : IntegrationTestBase() { testMemberHelper.createActivatedMember("third@example.com", "third") val friendship = friendRequestor.sendFriendRequest( - FriendRequestCommand( - fromMemberId = sender.requireId(), - toMemberId = receiver.requireId(), - toMemberNickname = receiver.nickname.value, - ) + fromMemberId = sender.requireId(), + toMemberId = receiver.requireId(), ) // thirdUser가 receiver에게 온 요청에 응답하려고 시도 @@ -502,11 +453,8 @@ class FriendWriteViewTest : IntegrationTestBase() { val receiver = testMemberHelper.getDefaultMember() val sender = testMemberHelper.createActivatedMember("sender@example.com", "sender") val friendship = friendRequestor.sendFriendRequest( - FriendRequestCommand( - fromMemberId = sender.requireId(), - toMemberId = receiver.requireId(), - toMemberNickname = receiver.nickname.value, - ) + fromMemberId = sender.requireId(), + toMemberId = receiver.requireId(), ) // 첫 번째 응답 @@ -543,11 +491,8 @@ class FriendWriteViewTest : IntegrationTestBase() { val receiver = testMemberHelper.getDefaultMember() val sender = testMemberHelper.createActivatedMember("sender@example.com", "sender") val friendship = friendRequestor.sendFriendRequest( - FriendRequestCommand( - fromMemberId = sender.requireId(), - toMemberId = receiver.requireId(), - toMemberNickname = receiver.nickname.value, - ) + fromMemberId = sender.requireId(), + toMemberId = receiver.requireId(), ) mockMvc.perform( @@ -564,11 +509,8 @@ class FriendWriteViewTest : IntegrationTestBase() { val receiver = testMemberHelper.getDefaultMember() val sender = testMemberHelper.createActivatedMember("sender@example.com", "sender") friendRequestor.sendFriendRequest( - FriendRequestCommand( - fromMemberId = sender.requireId(), - toMemberId = receiver.requireId(), - toMemberNickname = receiver.nickname.value, - ) + fromMemberId = sender.requireId(), + toMemberId = receiver.requireId(), ) // 경계값 테스트 - 유효한 최소 ID diff --git a/src/test/kotlin/com/albert/realmoneyrealtaste/adapter/webview/member/AccountUpdateFormTest.kt b/src/test/kotlin/com/albert/realmoneyrealtaste/adapter/webview/member/AccountUpdateFormTest.kt index 4f8f716d..1f87ba5b 100644 --- a/src/test/kotlin/com/albert/realmoneyrealtaste/adapter/webview/member/AccountUpdateFormTest.kt +++ b/src/test/kotlin/com/albert/realmoneyrealtaste/adapter/webview/member/AccountUpdateFormTest.kt @@ -1,5 +1,6 @@ package com.albert.realmoneyrealtaste.adapter.webview.member +import com.albert.realmoneyrealtaste.adapter.webview.member.form.AccountUpdateForm import jakarta.validation.Validation import jakarta.validation.Validator import org.junit.jupiter.api.Assertions.assertAll @@ -25,13 +26,17 @@ class AccountUpdateFormTest { val form = AccountUpdateForm( nickname = null, profileAddress = null, - introduction = null + introduction = null, + address = null, + imageId = null ) assertAll( { assertNull(form.nickname) }, { assertNull(form.profileAddress) }, - { assertNull(form.introduction) } + { assertNull(form.introduction) }, + { assertNull(form.address) }, + { assertNull(form.imageId) } ) } @@ -40,17 +45,23 @@ class AccountUpdateFormTest { val nickname = "testNickname" val profileAddress = "testAddress" val introduction = "test introduction" + val address = "test address" + val imageId = 1L val form = AccountUpdateForm( nickname = nickname, profileAddress = profileAddress, - introduction = introduction + introduction = introduction, + address = address, + imageId = imageId ) assertAll( { assertEquals(nickname, form.nickname) }, { assertEquals(profileAddress, form.profileAddress) }, - { assertEquals(introduction, form.introduction) } + { assertEquals(introduction, form.introduction) }, + { assertEquals(address, form.address) }, + { assertEquals(imageId, form.imageId) } ) } @@ -59,7 +70,9 @@ class AccountUpdateFormTest { val form = AccountUpdateForm( nickname = "", profileAddress = "address", - introduction = "intro" + introduction = "intro", + address = "address", + imageId = 1L ) val violations = validator.validate(form) @@ -74,7 +87,9 @@ class AccountUpdateFormTest { val form = AccountUpdateForm( nickname = "a", profileAddress = "address", - introduction = "intro" + introduction = "intro", + address = "address", + imageId = 1L ) val violations = validator.validate(form) @@ -89,7 +104,9 @@ class AccountUpdateFormTest { val form = AccountUpdateForm( nickname = "a".repeat(21), profileAddress = "address", - introduction = "intro" + introduction = "intro", + address = "address", + imageId = 1L ) val violations = validator.validate(form) @@ -104,7 +121,9 @@ class AccountUpdateFormTest { val form = AccountUpdateForm( nickname = "ab", profileAddress = "address", - introduction = "intro" + introduction = "intro", + address = "address", + imageId = 1L ) val violations = validator.validate(form) @@ -117,7 +136,9 @@ class AccountUpdateFormTest { val form = AccountUpdateForm( nickname = "a".repeat(20), profileAddress = "address", - introduction = "intro" + introduction = "intro", + address = "address", + imageId = 1L ) val violations = validator.validate(form) @@ -130,7 +151,9 @@ class AccountUpdateFormTest { val form = AccountUpdateForm( nickname = "nickname", profileAddress = "ab", - introduction = "intro" + introduction = "intro", + address = "address", + imageId = 1L ) val violations = validator.validate(form) @@ -145,7 +168,9 @@ class AccountUpdateFormTest { val form = AccountUpdateForm( nickname = "nickname", profileAddress = "a".repeat(16), - introduction = "intro" + introduction = "intro", + address = "address", + imageId = 1L ) val violations = validator.validate(form) @@ -160,7 +185,9 @@ class AccountUpdateFormTest { val form = AccountUpdateForm( nickname = "nickname", profileAddress = "abc", - introduction = "intro" + introduction = "intro", + address = "address", + imageId = 1L ) val violations = validator.validate(form) @@ -173,7 +200,9 @@ class AccountUpdateFormTest { val form = AccountUpdateForm( nickname = "nickname", profileAddress = "a".repeat(15), - introduction = "intro" + introduction = "intro", + address = "address", + imageId = 1L ) val violations = validator.validate(form) @@ -186,7 +215,9 @@ class AccountUpdateFormTest { val form = AccountUpdateForm( nickname = "nickname", profileAddress = "", - introduction = "intro" + introduction = "intro", + address = "address", + imageId = 1L ) val violations = validator.validate(form) @@ -201,7 +232,10 @@ class AccountUpdateFormTest { val form = AccountUpdateForm( nickname = "nickname", profileAddress = "address", - introduction = "a".repeat(501) + introduction = "a".repeat(501), + address = "address", + imageId = 1L + ) val violations = validator.validate(form) @@ -216,7 +250,10 @@ class AccountUpdateFormTest { val form = AccountUpdateForm( nickname = "nickname", profileAddress = "address", - introduction = "a".repeat(500) + introduction = "a".repeat(500), + address = "address", + imageId = 1L + ) val violations = validator.validate(form) @@ -229,7 +266,9 @@ class AccountUpdateFormTest { val form = AccountUpdateForm( nickname = "nickname", profileAddress = "address", - introduction = "" + introduction = "", + address = "address", + imageId = 1L ) val violations = validator.validate(form) @@ -242,7 +281,9 @@ class AccountUpdateFormTest { val form = AccountUpdateForm( nickname = "a", profileAddress = "ab", - introduction = "a".repeat(501) + introduction = "a".repeat(501), + address = "address", + imageId = 1L ) val violations = validator.validate(form) @@ -258,7 +299,9 @@ class AccountUpdateFormTest { val form = AccountUpdateForm( nickname = "validNickname", profileAddress = "validAddress", - introduction = "This is a valid introduction" + introduction = "This is a valid introduction", + address = "address", + imageId = 1L ) val violations = validator.validate(form) @@ -271,7 +314,9 @@ class AccountUpdateFormTest { val form = AccountUpdateForm( nickname = "nickname", profileAddress = null, - introduction = null + introduction = null, + address = null, + imageId = null ) val violations = validator.validate(form) @@ -284,7 +329,9 @@ class AccountUpdateFormTest { val form = AccountUpdateForm( nickname = null, profileAddress = null, - introduction = null + introduction = null, + address = null, + imageId = null ) val violations = validator.validate(form) @@ -299,7 +346,9 @@ class AccountUpdateFormTest { val form = AccountUpdateForm( nickname = "testNickname", profileAddress = "testAddress", - introduction = "test introduction" + introduction = "test introduction", + address = "test address", + imageId = 1L, ) val request = form.toAccountUpdateRequest() @@ -319,7 +368,9 @@ class AccountUpdateFormTest { val form = AccountUpdateForm( nickname = null, profileAddress = null, - introduction = null + introduction = null, + address = null, + imageId = null ) val request = form.toAccountUpdateRequest() @@ -336,7 +387,9 @@ class AccountUpdateFormTest { val form = AccountUpdateForm( nickname = "testNickname", profileAddress = null, - introduction = "test introduction" + introduction = "test introduction", + address = "test address", + imageId = null ) val request = form.toAccountUpdateRequest() @@ -355,7 +408,9 @@ class AccountUpdateFormTest { val form = AccountUpdateForm( nickname = "한글닉네임", profileAddress = "address", - introduction = "intro" + introduction = "intro", + address = "address", + imageId = null ) val violations = validator.validate(form) @@ -368,7 +423,9 @@ class AccountUpdateFormTest { val form = AccountUpdateForm( nickname = "nickname", profileAddress = "address", - introduction = "안녕하세요! @#$%^&*() 특수문자가 포함된 소개글입니다." + introduction = "안녕하세요! @#$%^&*() 특수문자가 포함된 소개글입니다.", + address = "address", + imageId = null ) val violations = validator.validate(form) @@ -381,7 +438,9 @@ class AccountUpdateFormTest { val form = AccountUpdateForm( nickname = "nickname", profileAddress = "user123", - introduction = "intro" + introduction = "intro", + address = "address", + imageId = null ) val violations = validator.validate(form) @@ -394,7 +453,9 @@ class AccountUpdateFormTest { val form = AccountUpdateForm( nickname = " ", profileAddress = "address", - introduction = "intro" + introduction = "intro", + address = "address", + imageId = null ) val violations = validator.validate(form) @@ -409,11 +470,188 @@ class AccountUpdateFormTest { val form = AccountUpdateForm( nickname = "nickname", profileAddress = "address", - introduction = "첫 번째 줄\n두 번째 줄\n세 번째 줄" + introduction = "첫 번째 줄\n두 번째 줄\n세 번째 줄", + address = "address", + imageId = null + ) + + val violations = validator.validate(form) + + assertTrue(violations.isEmpty()) + } + + @Test + fun `validate - success - accepts valid address`() { + val form = AccountUpdateForm( + nickname = "nickname", + profileAddress = "address", + introduction = "intro", + address = "서울시 강남구 테헤란로 123", + imageId = null + ) + + val violations = validator.validate(form) + + assertTrue(violations.isEmpty()) + } + + @Test + fun `validate - success - accepts empty address`() { + val form = AccountUpdateForm( + nickname = "nickname", + profileAddress = "address", + introduction = "intro", + address = "", + imageId = null + ) + + val violations = validator.validate(form) + + assertTrue(violations.isEmpty()) + } + + @Test + fun `validate - success - accepts null address`() { + val form = AccountUpdateForm( + nickname = "nickname", + profileAddress = "address", + introduction = "intro", + address = null, + imageId = null + ) + + val violations = validator.validate(form) + + assertTrue(violations.isEmpty()) + } + + @Test + fun `validate - success - accepts valid imageId`() { + val form = AccountUpdateForm( + nickname = "nickname", + profileAddress = "address", + introduction = "intro", + address = "address", + imageId = 123L + ) + + val violations = validator.validate(form) + + assertTrue(violations.isEmpty()) + } + + @Test + fun `validate - success - accepts null imageId`() { + val form = AccountUpdateForm( + nickname = "nickname", + profileAddress = "address", + introduction = "intro", + address = "address", + imageId = null + ) + + val violations = validator.validate(form) + + assertTrue(violations.isEmpty()) + } + + @Test + fun `validate - success - accepts zero imageId`() { + val form = AccountUpdateForm( + nickname = "nickname", + profileAddress = "address", + introduction = "intro", + address = "address", + imageId = 0L + ) + + val violations = validator.validate(form) + + assertTrue(violations.isEmpty()) + } + + @Test + fun `validate - success - accepts negative imageId`() { + val form = AccountUpdateForm( + nickname = "nickname", + profileAddress = "address", + introduction = "intro", + address = "address", + imageId = -1L ) val violations = validator.validate(form) assertTrue(violations.isEmpty()) } + + @Test + fun `toAccountUpdateRequest - success - converts form with address and imageId`() { + val form = AccountUpdateForm( + nickname = "testNickname", + profileAddress = "testAddress", + introduction = "test introduction", + address = "test address", + imageId = 123L + ) + + val request = form.toAccountUpdateRequest() + + assertAll( + { assertNotNull(request.nickname) }, + { assertEquals("testNickname", request.nickname?.value) }, + { assertNotNull(request.profileAddress) }, + { assertEquals("testAddress", request.profileAddress?.address) }, + { assertNotNull(request.introduction) }, + { assertEquals("test introduction", request.introduction?.value) }, + { assertEquals("test address", request.address) }, + { assertEquals(123L, request.imageId) } + ) + } + + @Test + fun `toAccountUpdateRequest - success - converts form with null address and imageId`() { + val form = AccountUpdateForm( + nickname = "testNickname", + profileAddress = "testAddress", + introduction = "test introduction", + address = null, + imageId = null + ) + + val request = form.toAccountUpdateRequest() + + assertAll( + { assertNotNull(request.nickname) }, + { assertEquals("testNickname", request.nickname?.value) }, + { assertNotNull(request.profileAddress) }, + { assertEquals("testAddress", request.profileAddress?.address) }, + { assertNotNull(request.introduction) }, + { assertEquals("test introduction", request.introduction?.value) }, + { assertNull(request.address) }, + { assertNull(request.imageId) } + ) + } + + @Test + fun `toAccountUpdateRequest - success - converts form with empty address`() { + val form = AccountUpdateForm( + nickname = "testNickname", + profileAddress = null, + introduction = null, + address = "", + imageId = null + ) + + val request = form.toAccountUpdateRequest() + + assertAll( + { assertNotNull(request.nickname) }, + { assertEquals("testNickname", request.nickname?.value) }, + { assertNull(request.profileAddress) }, + { assertNull(request.introduction) }, + { assertEquals("", request.address) }, + { assertNull(request.imageId) } + ) + } } diff --git a/src/test/kotlin/com/albert/realmoneyrealtaste/adapter/webview/member/MemberAuthViewTest.kt b/src/test/kotlin/com/albert/realmoneyrealtaste/adapter/webview/member/MemberAuthViewTest.kt new file mode 100644 index 00000000..566f3611 --- /dev/null +++ b/src/test/kotlin/com/albert/realmoneyrealtaste/adapter/webview/member/MemberAuthViewTest.kt @@ -0,0 +1,444 @@ +package com.albert.realmoneyrealtaste.adapter.webview.member + +import com.albert.realmoneyrealtaste.IntegrationTestBase +import com.albert.realmoneyrealtaste.application.member.required.ActivationTokenRepository +import com.albert.realmoneyrealtaste.application.member.required.PasswordResetTokenRepository +import com.albert.realmoneyrealtaste.domain.member.ActivationToken +import com.albert.realmoneyrealtaste.domain.member.PasswordResetToken +import com.albert.realmoneyrealtaste.util.MemberFixture +import com.albert.realmoneyrealtaste.util.TestMemberHelper +import com.albert.realmoneyrealtaste.util.WithMockMember +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf +import org.springframework.test.web.servlet.MockMvc +import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get +import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post +import org.springframework.test.web.servlet.result.MockMvcResultMatchers.flash +import org.springframework.test.web.servlet.result.MockMvcResultMatchers.model +import org.springframework.test.web.servlet.result.MockMvcResultMatchers.redirectedUrl +import org.springframework.test.web.servlet.result.MockMvcResultMatchers.status +import org.springframework.test.web.servlet.result.MockMvcResultMatchers.view +import java.time.LocalDateTime +import java.util.UUID +import kotlin.test.Test + +/** + * MemberAuthView 테스트 + */ +class MemberAuthViewTest : IntegrationTestBase() { + + @Autowired + private lateinit var mockMvc: MockMvc + + @Autowired + private lateinit var testMemberHelper: TestMemberHelper + + @Autowired + private lateinit var activationTokenRepository: ActivationTokenRepository + + @Autowired + private lateinit var passwordResetTokenRepository: PasswordResetTokenRepository + + @Test + fun `activate - success - returns activation view with nickname`() { + // Given + val member = testMemberHelper.createMember(email = "test@example.com") + val token = createValidActivationToken(member.requireId()) + + // When & Then + mockMvc.perform(get(MemberUrls.ACTIVATION).param("token", token.token)) + .andExpect(status().isOk) + .andExpect(view().name(MemberViews.ACTIVATE)) + .andExpect(model().attribute("nickname", member.nickname.value)) + } + + @Test + fun `activate - failure - returns error when token is invalid`() { + // When & Then + mockMvc.perform( + get(MemberUrls.ACTIVATION) + .param("token", "invalid-token") + ) + .andExpect(status().is3xxRedirection) + .andExpect(redirectedUrl("/members/activate")) + .andExpect(flash().attribute("success", false)) + .andExpect(flash().attributeExists("error")) + } + + @Test + fun `activate - failure - returns error when token is expired`() { + // Given + val member = testMemberHelper.createMember(email = "test@example.com") + val expiredToken = createExpiredActivationToken(member.requireId()) + + // When & Then + mockMvc.perform( + get(MemberUrls.ACTIVATION) + .param("token", expiredToken.token) + ) + .andExpect(status().is3xxRedirection) + .andExpect(redirectedUrl("/members/activate")) + .andExpect(flash().attribute("success", false)) + .andExpect(flash().attributeExists("error")) + } + + @Test + fun `activate - failure - returns error when token parameter is missing`() { + // When & Then + mockMvc.perform(get(MemberUrls.ACTIVATION)) + .andExpect(status().isBadRequest) + } + + @Test + @WithMockMember(email = MemberFixture.DEFAULT_USERNAME) + fun `resendActivation GET - success - returns resend activation view with email`() { + // When & Then + mockMvc.perform(get(MemberUrls.RESEND_ACTIVATION)) + .andExpect(status().isOk) + .andExpect(view().name(MemberViews.RESEND_ACTIVATION)) + .andExpect(model().attribute("email", MemberFixture.DEFAULT_EMAIL)) + } + + @Test + @WithMockMember(email = MemberFixture.DEFAULT_USERNAME, active = false) + fun `resendActivationEmail - success - resends activation email for inactive member`() { + // When & Then + mockMvc.perform(post(MemberUrls.RESEND_ACTIVATION).with(csrf())) + .andExpect(status().is3xxRedirection) + .andExpect(redirectedUrl(MemberUrls.RESEND_ACTIVATION)) + .andExpect(flash().attribute("success", true)) + .andExpect(flash().attributeExists("message")) + } + + @Test + @WithMockMember(email = MemberFixture.DEFAULT_USERNAME) + fun `resendActivationEmail - failure - returns error when member is already active`() { + // When & Then + mockMvc.perform(post(MemberUrls.RESEND_ACTIVATION).with(csrf())) + .andExpect(status().is3xxRedirection) + .andExpect(redirectedUrl(MemberUrls.RESEND_ACTIVATION)) + .andExpect(flash().attribute("success", false)) + .andExpect(flash().attributeExists("error")) + } + + @Test + fun `resendActivationEmail - failure - returns forbidden when not authenticated`() { + // When & Then + mockMvc.perform(post(MemberUrls.RESEND_ACTIVATION).with(csrf())) + .andExpect(status().isForbidden) + } + + @Test + @WithMockMember(email = MemberFixture.DEFAULT_USERNAME, active = false) + fun `resendActivationEmail - failure - requires csrf token`() { + // When & Then + mockMvc.perform(post(MemberUrls.RESEND_ACTIVATION)) + .andExpect(status().isForbidden) + } + + @Test + fun `passwordForgot GET - success - returns password forgot view`() { + // When & Then + mockMvc.perform(get(MemberUrls.PASSWORD_FORGOT)) + .andExpect(status().isOk) + .andExpect(view().name(MemberViews.PASSWORD_FORGOT)) + } + + @Test + fun `sendPasswordResetEmail - success - sends reset email for existing member`() { + // Given + testMemberHelper.createActivatedMember(email = "test@example.com") + + // When & Then + mockMvc.perform( + post(MemberUrls.PASSWORD_FORGOT) + .with(csrf()) + .param("email", "test@example.com") + ) + .andExpect(status().is3xxRedirection) + .andExpect(redirectedUrl(MemberUrls.PASSWORD_FORGOT)) + .andExpect(flash().attribute("success", true)) + .andExpect(flash().attributeExists("message")) + } + + @Test + fun `sendPasswordResetEmail - failure - validation error when email format is invalid`() { + // When & Then + mockMvc.perform( + post(MemberUrls.PASSWORD_FORGOT) + .with(csrf()) + .param("email", "invalid-email") + ) + .andExpect(status().is3xxRedirection) + .andExpect(redirectedUrl(MemberUrls.PASSWORD_FORGOT)) + .andExpect(flash().attribute("success", false)) + .andExpect(flash().attributeExists("error")) + } + + @Test + fun `sendPasswordResetEmail - failure - validation error when email is empty`() { + // When & Then + mockMvc.perform( + post(MemberUrls.PASSWORD_FORGOT) + .with(csrf()) + .param("email", "") + ) + .andExpect(status().is3xxRedirection) + .andExpect(redirectedUrl(MemberUrls.PASSWORD_FORGOT)) + .andExpect(flash().attribute("success", false)) + .andExpect(flash().attributeExists("error")) + } + + @Test + fun `sendPasswordResetEmail - failure - requires csrf token`() { + // When & Then + mockMvc.perform( + post(MemberUrls.PASSWORD_FORGOT) + .param("email", "test@example.com") + ) + .andExpect(status().isForbidden) + } + + @Test + fun `passwordReset GET - success - returns password reset view with token`() { + // Given + val token = "valid-reset-token" + + // When & Then + mockMvc.perform(get(MemberUrls.PASSWORD_RESET).param("token", token)) + .andExpect(status().isOk) + .andExpect(view().name(MemberViews.PASSWORD_RESET)) + .andExpect(model().attribute("token", token)) + } + + @Test + fun `passwordReset GET - failure - returns error when token parameter is missing`() { + // When & Then + mockMvc.perform(get(MemberUrls.PASSWORD_RESET)) + .andExpect(status().isBadRequest) + } + + @Test + fun `resetPassword - success - resets password with valid token`() { + // Given + val member = testMemberHelper.createActivatedMember(email = "test@example.com") + val token = createValidPasswordResetToken(member.requireId()) + + // When & Then + mockMvc.perform( + post(MemberUrls.PASSWORD_RESET) + .with(csrf()) + .param("token", token.token) + .param("newPassword", "NewPassword123!") + .param("newPasswordConfirm", "NewPassword123!") + ) + .andExpect(status().is3xxRedirection) + .andExpect(redirectedUrl("/")) + .andExpect(flash().attribute("success", true)) + .andExpect(flash().attributeExists("message")) + } + + @Test + fun `resetPassword - failure - validation error when passwords do not match`() { + // Given + val token = "valid-reset-token" + + // When & Then + mockMvc.perform( + post(MemberUrls.PASSWORD_RESET) + .with(csrf()) + .param("token", token) + .param("newPassword", "NewPassword123!") + .param("newPasswordConfirm", "DifferentPassword123!") + ) + .andExpect(status().is3xxRedirection) + .andExpect(redirectedUrl("${MemberUrls.PASSWORD_RESET}?token=$token")) + .andExpect(flash().attributeExists("error")) + .andExpect(flash().attribute("token", token)) + } + + @Test + fun `resetPassword - failure - validation error when password format is invalid`() { + // Given + val token = "valid-reset-token" + + // When & Then + mockMvc.perform( + post(MemberUrls.PASSWORD_RESET) + .with(csrf()) + .param("token", token) + .param("newPassword", "weak") + .param("newPasswordConfirm", "weak") + ) + .andExpect(status().is3xxRedirection) + .andExpect(redirectedUrl("${MemberUrls.PASSWORD_RESET}?token=$token")) + .andExpect(flash().attributeExists("error")) + .andExpect(flash().attribute("token", token)) + } + + @Test + fun `resetPassword - failure - validation error when password is too short`() { + // Given + val token = "valid-reset-token" + + // When & Then + mockMvc.perform( + post(MemberUrls.PASSWORD_RESET) + .with(csrf()) + .param("token", token) + .param("newPassword", "Short1!") + .param("newPasswordConfirm", "Short1!") + ) + .andExpect(status().is3xxRedirection) + .andExpect(redirectedUrl("${MemberUrls.PASSWORD_RESET}?token=$token")) + .andExpect(flash().attributeExists("error")) + .andExpect(flash().attribute("token", token)) + } + + @Test + fun `resetPassword - failure - validation error when password is too long`() { + // Given + val token = "valid-reset-token" + val longPassword = "a".repeat(21) + "1!" + + // When & Then + mockMvc.perform( + post(MemberUrls.PASSWORD_RESET) + .with(csrf()) + .param("token", token) + .param("newPassword", longPassword) + .param("newPasswordConfirm", longPassword) + ) + .andExpect(status().is3xxRedirection) + .andExpect(redirectedUrl("${MemberUrls.PASSWORD_RESET}?token=$token")) + .andExpect(flash().attributeExists("error")) + .andExpect(flash().attribute("token", token)) + } + + @Test + fun `resetPassword - failure - validation error when password confirmation is empty`() { + // Given + val token = "valid-reset-token" + + // When & Then + mockMvc.perform( + post(MemberUrls.PASSWORD_RESET) + .with(csrf()) + .param("token", token) + .param("newPassword", "NewPassword123!") + .param("newPasswordConfirm", "") + ) + .andExpect(status().is3xxRedirection) + .andExpect(redirectedUrl("${MemberUrls.PASSWORD_RESET}?token=$token")) + .andExpect(flash().attributeExists("error")) + .andExpect(flash().attribute("token", token)) + } + + @Test + fun `resetPassword - failure - returns error when token is invalid`() { + // Given + val invalidToken = "invalid-token" + + // When & Then + mockMvc.perform( + post(MemberUrls.PASSWORD_RESET) + .with(csrf()) + .param("token", invalidToken) + .param("newPassword", "NewPassword123!") + .param("newPasswordConfirm", "NewPassword123!") + ) + .andExpect(status().is3xxRedirection) + .andExpect(redirectedUrl("/")) + .andExpect(flash().attributeExists("error")) + .andExpect(flash().attribute("success", false)) + } + + @Test + fun `resetPassword - failure - returns error when token is expired`() { + // Given + val member = testMemberHelper.createActivatedMember(email = "test@example.com") + val expiredToken = createExpiredPasswordResetToken(member.requireId()) + + // When & Then + mockMvc.perform( + post(MemberUrls.PASSWORD_RESET) + .with(csrf()) + .param("token", expiredToken.token) + .param("newPassword", "NewPassword123!") + .param("newPasswordConfirm", "NewPassword123!") + ) + .andExpect(status().is3xxRedirection) + .andExpect(redirectedUrl("/")) + .andExpect(flash().attributeExists("error")) + .andExpect(flash().attribute("success", false)) + } + + @Test + fun `resetPassword - failure - requires csrf token`() { + // Given + val token = "valid-reset-token" + + // When & Then + mockMvc.perform( + post(MemberUrls.PASSWORD_RESET) + .param("token", token) + .param("newPassword", "NewPassword123!") + .param("newPasswordConfirm", "NewPassword123!") + ) + .andExpect(status().isForbidden) + } + + @Test + fun `resetPassword - failure - returns error when token parameter is missing`() { + // When & Then + mockMvc.perform( + post(MemberUrls.PASSWORD_RESET) + .with(csrf()) + .param("newPassword", "NewPassword123!") + .param("newPasswordConfirm", "NewPassword123!") + ) + .andExpect(status().isBadRequest) + } + + // ========== 헬퍼 메서드 ========== + + private fun createValidActivationToken(memberId: Long): ActivationToken { + val token = ActivationToken( + memberId = memberId, + token = UUID.randomUUID().toString(), + createdAt = LocalDateTime.now(), + expiresAt = LocalDateTime.now().plusDays(1) + ) + return activationTokenRepository.save(token) + } + + private fun createExpiredActivationToken(memberId: Long): ActivationToken { + val token = ActivationToken( + memberId = memberId, + token = UUID.randomUUID().toString(), + createdAt = LocalDateTime.now().minusDays(2), + expiresAt = LocalDateTime.now().minusDays(1) + ) + return activationTokenRepository.save(token) + } + + private fun createValidPasswordResetToken(memberId: Long): PasswordResetToken { + val token = PasswordResetToken( + memberId = memberId, + token = UUID.randomUUID().toString(), + createdAt = LocalDateTime.now(), + expiresAt = LocalDateTime.now().plusHours(1) + ) + return passwordResetTokenRepository.save(token) + } + + private fun createExpiredPasswordResetToken(memberId: Long): PasswordResetToken { + val token = PasswordResetToken( + memberId = memberId, + token = UUID.randomUUID().toString(), + createdAt = LocalDateTime.now().minusHours(2), + expiresAt = LocalDateTime.now().minusHours(1) + ) + return passwordResetTokenRepository.save(token) + } +} diff --git a/src/test/kotlin/com/albert/realmoneyrealtaste/adapter/webview/member/MemberFragmentViewTest.kt b/src/test/kotlin/com/albert/realmoneyrealtaste/adapter/webview/member/MemberFragmentViewTest.kt new file mode 100644 index 00000000..518cd749 --- /dev/null +++ b/src/test/kotlin/com/albert/realmoneyrealtaste/adapter/webview/member/MemberFragmentViewTest.kt @@ -0,0 +1,56 @@ +package com.albert.realmoneyrealtaste.adapter.webview.member + +import com.albert.realmoneyrealtaste.IntegrationTestBase +import com.albert.realmoneyrealtaste.util.MemberFixture +import com.albert.realmoneyrealtaste.util.TestMemberHelper +import com.albert.realmoneyrealtaste.util.WithMockMember +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.test.web.servlet.MockMvc +import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get +import org.springframework.test.web.servlet.result.MockMvcResultMatchers.model +import org.springframework.test.web.servlet.result.MockMvcResultMatchers.status +import org.springframework.test.web.servlet.result.MockMvcResultMatchers.view +import kotlin.test.Test + +/** + * MemberFragmentView 테스트 + */ +class MemberFragmentViewTest : IntegrationTestBase() { + + @Autowired + private lateinit var mockMvc: MockMvc + + @Autowired + private lateinit var testMemberHelper: TestMemberHelper + + @Test + @WithMockMember(email = MemberFixture.DEFAULT_USERNAME) + fun `readSidebarFragment - success - returns suggested users sidebar fragment`() { + // Given + val member = testMemberHelper.getDefaultMember() + + // When & Then + mockMvc.perform(get(MemberUrls.FRAGMENT_SUGGEST_USERS_SIDEBAR)) + .andExpect(status().isOk) + .andExpect(view().name(MemberViews.SUGGEST_USERS_SIDEBAR_CONTENT)) + .andExpect(model().attributeExists("suggestedUsers")) + .andExpect(model().attributeExists("followings")) + .andExpect(model().attributeExists("member")) + } + + @Test + @WithMockMember(email = MemberFixture.DEFAULT_USERNAME) + fun `memberProfileFragment - success - returns member profile fragment`() { + // Given + val member = testMemberHelper.getDefaultMember() + + // When & Then + mockMvc.perform(get(MemberUrls.FRAGMENT_MEMBER_PROFILE)) + .andExpect(status().isOk) + .andExpect(view().name(MemberViews.MEMBER_PROFILE_FRAGMENT)) + .andExpect(model().attributeExists("member")) + .andExpect(model().attributeExists("followersCount")) + .andExpect(model().attributeExists("followingCount")) + .andExpect(model().attributeExists("postCount")) + } +} diff --git a/src/test/kotlin/com/albert/realmoneyrealtaste/adapter/webview/member/MemberProfileViewTest.kt b/src/test/kotlin/com/albert/realmoneyrealtaste/adapter/webview/member/MemberProfileViewTest.kt new file mode 100644 index 00000000..c329e17c --- /dev/null +++ b/src/test/kotlin/com/albert/realmoneyrealtaste/adapter/webview/member/MemberProfileViewTest.kt @@ -0,0 +1,70 @@ +package com.albert.realmoneyrealtaste.adapter.webview.member + +import com.albert.realmoneyrealtaste.IntegrationTestBase +import com.albert.realmoneyrealtaste.util.MemberFixture +import com.albert.realmoneyrealtaste.util.TestMemberHelper +import com.albert.realmoneyrealtaste.util.WithMockMember +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.test.web.servlet.MockMvc +import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get +import org.springframework.test.web.servlet.result.MockMvcResultMatchers.model +import org.springframework.test.web.servlet.result.MockMvcResultMatchers.status +import org.springframework.test.web.servlet.result.MockMvcResultMatchers.view +import kotlin.test.Test + +/** + * MemberProfileView 테스트 + */ +class MemberProfileViewTest : IntegrationTestBase() { + + @Autowired + private lateinit var mockMvc: MockMvc + + @Autowired + private lateinit var testMemberHelper: TestMemberHelper + + @Test + @WithMockMember(email = MemberFixture.DEFAULT_USERNAME) + fun `readProfile - success - returns profile view with correct attributes`() { + // Given + val member = testMemberHelper.getDefaultMember() + + // When & Then + mockMvc.perform(get(MemberUrls.PROFILE, member.id)) + .andExpect(status().isOk) + .andExpect(view().name(MemberViews.PROFILE)) + .andExpect(model().attributeExists("author")) + .andExpect(model().attributeExists("member")) + .andExpect(model().attributeExists("postCreateForm")) + } + + @Test + @WithMockMember(email = MemberFixture.DEFAULT_USERNAME) + fun `readProfile - success - returns profile view when viewing other member's profile`() { + // Given + val otherMember = testMemberHelper.createActivatedMember( + email = "other@example.com", + nickname = "OtherUser" + ) + + // When & Then + mockMvc.perform(get(MemberUrls.PROFILE, otherMember.id)) + .andExpect(status().isOk) + .andExpect(view().name(MemberViews.PROFILE)) + .andExpect(model().attributeExists("author")) + .andExpect(model().attributeExists("member")) + .andExpect(model().attributeExists("postCreateForm")) + } + + @Test + @WithMockMember(email = MemberFixture.DEFAULT_USERNAME, active = false) + fun `readProfile - failure - returns error when member is not active`() { + // Given + val member = testMemberHelper.getDefaultMember() + + // When & Then + mockMvc.perform(get(MemberUrls.PROFILE, member.id)) + .andExpect(status().isBadRequest) + .andExpect(view().name("error/404")) + } +} diff --git a/src/test/kotlin/com/albert/realmoneyrealtaste/adapter/webview/member/MemberSettingsViewTest.kt b/src/test/kotlin/com/albert/realmoneyrealtaste/adapter/webview/member/MemberSettingsViewTest.kt new file mode 100644 index 00000000..6da7901b --- /dev/null +++ b/src/test/kotlin/com/albert/realmoneyrealtaste/adapter/webview/member/MemberSettingsViewTest.kt @@ -0,0 +1,235 @@ +package com.albert.realmoneyrealtaste.adapter.webview.member + +import com.albert.realmoneyrealtaste.IntegrationTestBase +import com.albert.realmoneyrealtaste.util.MemberFixture +import com.albert.realmoneyrealtaste.util.TestMemberHelper +import com.albert.realmoneyrealtaste.util.WithMockMember +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf +import org.springframework.test.web.servlet.MockMvc +import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get +import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post +import org.springframework.test.web.servlet.result.MockMvcResultMatchers.flash +import org.springframework.test.web.servlet.result.MockMvcResultMatchers.model +import org.springframework.test.web.servlet.result.MockMvcResultMatchers.redirectedUrl +import org.springframework.test.web.servlet.result.MockMvcResultMatchers.status +import org.springframework.test.web.servlet.result.MockMvcResultMatchers.view +import kotlin.test.Test + +/** + * MemberSettingsView 테스트 + */ +class MemberSettingsViewTest : IntegrationTestBase() { + + @Autowired + private lateinit var mockMvc: MockMvc + + @Autowired + private lateinit var testMemberHelper: TestMemberHelper + + @Test + @WithMockMember(email = MemberFixture.DEFAULT_USERNAME) + fun `setting - success - returns setting view with member data`() { + // Given + val member = testMemberHelper.getDefaultMember() + + // When & Then + mockMvc.perform(get(MemberUrls.SETTING)) + .andExpect(status().isOk) + .andExpect(view().name(MemberViews.SETTING)) + .andExpect(model().attributeExists("member")) + } + + @Test + @WithMockMember(email = MemberFixture.DEFAULT_USERNAME) + fun `updateAccount - success - redirects with success message`() { + // Given + val member = testMemberHelper.getDefaultMember() + + // When & Then + mockMvc.perform( + post(MemberUrls.SETTING_ACCOUNT) + .with(csrf()) + .param("nickname", "UpdatedNickname") + .param("profileAddress", "updatedProfile") + .param("introduction", "Updated introduction") + ) + .andExpect(status().is3xxRedirection) + .andExpect(redirectedUrl(MemberUrls.SETTING + "#account")) + .andExpect(flash().attribute("success", true)) + } + + @Test + @WithMockMember(email = MemberFixture.DEFAULT_USERNAME) + fun `updateAccount - failure - returns error when nickname is invalid`() { + // Given + val member = testMemberHelper.getDefaultMember() + + // When & Then + mockMvc.perform( + post(MemberUrls.SETTING_ACCOUNT) + .with(csrf()) + .param("nickname", "") + .param("profileAddress", "updated-profile") + .param("introduction", "Updated introduction") + ) + .andExpect(status().is3xxRedirection) + .andExpect(redirectedUrl(MemberUrls.SETTING + "#account")) + .andExpect(flash().attributeExists("error")) + } + + @Test + @WithMockMember(email = MemberFixture.DEFAULT_USERNAME) + fun `updateAccount - failure - returns error when nickname is too short`() { + // Given + testMemberHelper.getDefaultMember() + + // When & Then + mockMvc.perform( + post(MemberUrls.SETTING_ACCOUNT) + .with(csrf()) + .param("nickname", "a") + .param("profileAddress", "updated-profile") + .param("introduction", "Updated introduction") + ) + .andExpect(status().is3xxRedirection) + .andExpect(redirectedUrl(MemberUrls.SETTING + "#account")) + .andExpect(flash().attributeExists("error")) + } + + @Test + @WithMockMember(email = MemberFixture.DEFAULT_USERNAME, active = false) + fun `updateAccount - failure - returns error when member is not active`() { + // When & Then + mockMvc.perform( + post(MemberUrls.SETTING_ACCOUNT) + .with(csrf()) + .param("nickname", "UpdatedNickname") + .param("profileAddress", "updatedProfile") + .param("introduction", "Updated introduction") + ) + .andExpect(status().is3xxRedirection) + .andExpect(redirectedUrl(MemberUrls.SETTING + "#account")) + .andExpect(flash().attributeExists("error")) + } + + @Test + @WithMockMember(email = MemberFixture.DEFAULT_USERNAME) + fun `updatePassword - success - redirects with success message`() { + // Given + val member = testMemberHelper.getDefaultMember() + + // When & Then + mockMvc.perform( + post(MemberUrls.SETTING_PASSWORD) + .with(csrf()) + .param("currentPassword", MemberFixture.DEFAULT_PASSWORD_PLAIN) + .param("newPassword", "NewPassword123!") + .param("confirmNewPassword", "NewPassword123!") + ) + .andExpect(status().is3xxRedirection) + .andExpect(redirectedUrl("${MemberUrls.SETTING}#password")) + .andExpect(flash().attribute("success", true)) + } + + @Test + @WithMockMember(email = MemberFixture.DEFAULT_USERNAME) + fun `updatePassword - failure - returns error when password format is invalid`() { + // Given + val member = testMemberHelper.getDefaultMember() + + // When & Then + mockMvc.perform( + post(MemberUrls.SETTING_PASSWORD) + .with(csrf()) + .param("currentPassword", "CurrentPassword123!") + .param("newPassword", "weak") + .param("confirmNewPassword", "weak") + ) + .andExpect(status().is3xxRedirection) + .andExpect(redirectedUrl("${MemberUrls.SETTING}#password")) + .andExpect(flash().attributeExists("error")) + } + + @Test + @WithMockMember(email = MemberFixture.DEFAULT_USERNAME) + fun `updatePassword - failure - returns error when passwords do not match`() { + // Given + val member = testMemberHelper.getDefaultMember() + + // When & Then + mockMvc.perform( + post(MemberUrls.SETTING_PASSWORD) + .with(csrf()) + .param("currentPassword", "CurrentPassword123!") + .param("newPassword", "NewPassword123!") + .param("confirmNewPassword", "DifferentPassword123!") + ) + .andExpect(status().is3xxRedirection) + .andExpect(redirectedUrl("${MemberUrls.SETTING}#password")) + .andExpect(flash().attributeExists("error")) + } + + @Test + @WithMockMember(email = MemberFixture.DEFAULT_USERNAME) + fun `deleteAccount - success - redirects to home after account deletion`() { + // Given + val member = testMemberHelper.getDefaultMember() + + // When & Then + mockMvc.perform( + post(MemberUrls.SETTING_DELETE) + .with(csrf()) + .param("confirmed", "true") + ) + .andExpect(status().is3xxRedirection) + .andExpect(redirectedUrl("/")) + } + + @Test + @WithMockMember(email = MemberFixture.DEFAULT_USERNAME) + fun `deleteAccount - failure - returns error when not confirmed`() { + // Given + val member = testMemberHelper.getDefaultMember() + + // When & Then + mockMvc.perform( + post(MemberUrls.SETTING_DELETE) + .with(csrf()) + .param("confirmed", "false") + ) + .andExpect(status().is3xxRedirection) + .andExpect(redirectedUrl("${MemberUrls.SETTING}#delete")) + .andExpect(flash().attribute("error", "계정 삭제 확인이 필요합니다.")) + } + + @Test + @WithMockMember(email = MemberFixture.DEFAULT_USERNAME) + fun `deleteAccount - failure - returns error when confirmation is null`() { + // Given + val member = testMemberHelper.getDefaultMember() + + // When & Then + mockMvc.perform( + post(MemberUrls.SETTING_DELETE) + .with(csrf()) + ) + .andExpect(status().is3xxRedirection) + .andExpect(redirectedUrl("${MemberUrls.SETTING}#delete")) + .andExpect(flash().attribute("error", "계정 삭제 확인이 필요합니다.")) + } + + @Test + @WithMockMember(email = MemberFixture.DEFAULT_USERNAME, active = false) + fun `deleteAccount - failure - returns error when member is not active`() { + // When & Then + mockMvc.perform( + post(MemberUrls.SETTING_DELETE) + .with(csrf()) + .param("confirmed", "true") + ) + .andExpect(status().is3xxRedirection) + .andExpect(redirectedUrl("${MemberUrls.SETTING}#delete")) + .andExpect(flash().attribute("error", "회원 탈퇴에 실패했습니다. 다시 시도해주세요.")) + } +} diff --git a/src/test/kotlin/com/albert/realmoneyrealtaste/adapter/webview/member/MemberViewTest.kt b/src/test/kotlin/com/albert/realmoneyrealtaste/adapter/webview/member/MemberViewTest.kt deleted file mode 100644 index 574e9c39..00000000 --- a/src/test/kotlin/com/albert/realmoneyrealtaste/adapter/webview/member/MemberViewTest.kt +++ /dev/null @@ -1,703 +0,0 @@ -package com.albert.realmoneyrealtaste.adapter.webview.member - -import com.albert.realmoneyrealtaste.IntegrationTestBase -import com.albert.realmoneyrealtaste.application.friend.required.FriendshipRepository -import com.albert.realmoneyrealtaste.application.member.required.ActivationTokenRepository -import com.albert.realmoneyrealtaste.application.member.required.PasswordResetTokenRepository -import com.albert.realmoneyrealtaste.domain.friend.Friendship -import com.albert.realmoneyrealtaste.domain.friend.command.FriendRequestCommand -import com.albert.realmoneyrealtaste.domain.member.ActivationToken -import com.albert.realmoneyrealtaste.domain.member.Member -import com.albert.realmoneyrealtaste.domain.member.PasswordResetToken -import com.albert.realmoneyrealtaste.domain.member.value.ProfileAddress -import com.albert.realmoneyrealtaste.util.MemberFixture -import com.albert.realmoneyrealtaste.util.TestMemberHelper -import com.albert.realmoneyrealtaste.util.WithMockMember -import org.hamcrest.Matchers -import org.springframework.beans.factory.annotation.Autowired -import org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf -import org.springframework.test.web.servlet.MockMvc -import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get -import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post -import org.springframework.test.web.servlet.result.MockMvcResultMatchers.flash -import org.springframework.test.web.servlet.result.MockMvcResultMatchers.model -import org.springframework.test.web.servlet.result.MockMvcResultMatchers.redirectedUrl -import org.springframework.test.web.servlet.result.MockMvcResultMatchers.status -import org.springframework.test.web.servlet.result.MockMvcResultMatchers.view -import java.time.LocalDateTime -import java.util.UUID -import kotlin.test.Test -import kotlin.test.assertTrue - -class MemberViewTest : IntegrationTestBase() { - - @Autowired - private lateinit var mockMvc: MockMvc - - @Autowired - private lateinit var testMemberHelper: TestMemberHelper - - @Autowired - private lateinit var activationTokenRepository: ActivationTokenRepository - - @Autowired - private lateinit var passwordResetTokenRepository: PasswordResetTokenRepository - - @Autowired - private lateinit var friendshipRepository: FriendshipRepository - - @Test - @WithMockMember(email = MemberFixture.DEFAULT_USERNAME) - fun `readProfile - success - shows profile with follow and friend status when viewing other member`() { - val otherMember = testMemberHelper.createActivatedMember( - email = "other@example.com", - nickname = "other" - ) - - mockMvc.perform( - get("/members/{id}", otherMember.requireId()) - ) - .andExpect(status().isOk) - .andExpect(view().name(MemberViews.PROFILE)) - .andExpect(model().attributeExists("author")) - .andExpect(model().attributeExists("member")) - .andExpect(model().attributeExists("postCreateForm")) - .andExpect(model().attributeExists("isFollowing")) - .andExpect(model().attributeExists("isFriend")) - .andExpect(model().attribute("hasSentFriendRequest", false)) - } - - @Test - @WithMockMember(email = MemberFixture.DEFAULT_USERNAME) - fun `readProfile - success - shows profile with hasSentFriendRequest true when friend request sent`() { - val currentUser = testMemberHelper.getDefaultMember() - val otherMember = testMemberHelper.createActivatedMember( - email = "other@example.com", - nickname = "other" - ) - - // 친구 요청 생성 - val friendRequest = Friendship.request( - FriendRequestCommand( - fromMemberId = currentUser.requireId(), - toMemberId = otherMember.requireId(), - toMemberNickname = otherMember.nickname.value - ) - ) - friendshipRepository.save(friendRequest) - flushAndClear() - - mockMvc.perform( - get("/members/{id}", otherMember.requireId()) - ) - .andExpect(status().isOk) - .andExpect(view().name(MemberViews.PROFILE)) - .andExpect(model().attributeExists("author")) - .andExpect(model().attributeExists("member")) - .andExpect(model().attributeExists("postCreateForm")) - .andExpect(model().attributeExists("isFollowing")) - .andExpect(model().attributeExists("isFriend")) - .andExpect(model().attribute("hasSentFriendRequest", true)) - } - - @Test - @WithMockMember(email = MemberFixture.DEFAULT_USERNAME) - fun `readProfile - success - shows own profile without follow friend status`() { - val member = testMemberHelper.getDefaultMember() - - mockMvc.perform( - get("/members/{id}", member.requireId()) - ) - .andExpect(status().isOk) - .andExpect(view().name(MemberViews.PROFILE)) - .andExpect(model().attributeExists("author")) - .andExpect(model().attributeExists("member")) - .andExpect(model().attributeExists("postCreateForm")) - } - - @Test - fun `readProfile - success - shows profile without authentication`() { - val member = testMemberHelper.createActivatedMember( - email = "public@example.com", - nickname = "public" - ) - - mockMvc.perform( - get("/members/{id}", member.requireId()) - ) - .andExpect(status().isOk) - .andExpect(view().name(MemberViews.PROFILE)) - .andExpect(model().attributeExists("author")) - .andExpect(model().attributeExists("postCreateForm")) - } - - @Test - fun `readProfile - failure - returns error when member not found`() { - mockMvc.perform( - get("/members/{id}", 99999L) - ) - .andExpect(status().is4xxClientError) - } - - @Test - fun `activate - success - activates member and shows success page`() { - val member = testMemberHelper.createMember() - val token = createValidActivationToken(member.requireId()) - - mockMvc.perform( - get(MemberUrls.ACTIVATION) - .param("token", token.token) - ) - .andExpect(status().isOk) - .andExpect(view().name(MemberViews.ACTIVATE)) - .andExpect(model().attribute("nickname", member.nickname.value)) - .andExpect(model().attribute("success", true)) - } - - @Test - fun `activate - failure - returns error when token is invalid`() { - mockMvc.perform( - get(MemberUrls.ACTIVATION) - .param("token", "invalid-token") - ) - .andExpect(status().is3xxRedirection) - .andExpect(flash().attribute("success", false)) - .andExpect(flash().attributeExists("error")) - } - - @Test - @WithMockMember(email = MemberFixture.DEFAULT_USERNAME) - fun `resendActivationEmail GET - success - shows resend activation page`() { - mockMvc.perform(get(MemberUrls.RESEND_ACTIVATION)) - .andExpect(status().isOk) - .andExpect(view().name(MemberViews.RESEND_ACTIVATION)) - .andExpect(model().attributeExists("email")) - } - - @Test - @WithMockMember(email = MemberFixture.DEFAULT_USERNAME, active = false) - fun `resendActivationEmail POST - success - resends activation email`() { - mockMvc.perform( - post(MemberUrls.RESEND_ACTIVATION) - .with(csrf()) - ) - .andExpect(status().is3xxRedirection) - .andExpect(redirectedUrl(MemberUrls.RESEND_ACTIVATION)) - .andExpect(flash().attribute("success", true)) - .andExpect(flash().attributeExists("message")) - } - - @Test - @WithMockMember(email = MemberFixture.DEFAULT_USERNAME) - fun `resendActivationEmail - failure - returns error when member is already active`() { - mockMvc.perform( - post(MemberUrls.RESEND_ACTIVATION) - .with(csrf()) - ) - .andExpect(status().is3xxRedirection) - .andExpect(redirectedUrl(MemberUrls.RESEND_ACTIVATION)) - .andExpect(flash().attribute("success", false)) - .andExpect(flash().attributeExists("error")) - } - - @Test - fun `resendActivationEmail - failure - returns forbidden when not authenticated`() { - mockMvc.perform( - post(MemberUrls.RESEND_ACTIVATION) - .with(csrf()) - ) - .andExpect(status().isForbidden) - } - - @Test - @WithMockMember(email = MemberFixture.DEFAULT_USERNAME) - fun `setting - success - shows member setting page`() { - mockMvc.perform(get(MemberUrls.SETTING)) - .andExpect(status().isOk) - .andExpect(view().name(MemberViews.SETTING)) - .andExpect(model().attributeExists("member")) - } - - @Test - fun `setting - failure - returns forbidden when not authenticated`() { - mockMvc.perform(get(MemberUrls.SETTING)) - .andExpect(status().isForbidden) - } - - @Test - @WithMockMember(email = MemberFixture.DEFAULT_USERNAME) - fun `updateAccount - success - updates account info and redirects`() { - mockMvc.perform( - post(MemberUrls.SETTING_ACCOUNT) - .with(csrf()) - .param("nickname", "새로운닉네임") - .param("profileAddress", "newaddress") - .param("introduction", "새로운 소개") - ) - .andExpect(status().is3xxRedirection) - .andExpect(redirectedUrl(MemberUrls.SETTING + "#account")) - .andExpect(flash().attribute("tab", "account")) - .andExpect(flash().attribute("success", "계정 정보가 성공적으로 업데이트되었습니다.")) - } - - @Test - @WithMockMember(email = MemberFixture.DEFAULT_USERNAME) - fun `updateAccount - failure - validation error when nickname is too short`() { - mockMvc.perform( - post(MemberUrls.SETTING_ACCOUNT) - .with(csrf()) - .param("nickname", "a") // 너무 짧은 닉네임 - .param("profileAddress", "address") - .param("introduction", "소개") - ) - .andExpect(status().is3xxRedirection) - .andExpect(redirectedUrl(MemberUrls.SETTING + "#account")) - .andExpect(flash().attribute("tab", "account")) - .andExpect(flash().attributeExists("error")) - } - - @Test - @WithMockMember(email = MemberFixture.DEFAULT_USERNAME) - fun `updateAccount - failure - validation error when nickname is too long`() { - mockMvc.perform( - post(MemberUrls.SETTING_ACCOUNT) - .with(csrf()) - .param("nickname", "a".repeat(21)) // 너무 긴 닉네임 - .param("profileAddress", "address") - .param("introduction", "소개") - ) - .andExpect(status().is3xxRedirection) - .andExpect(redirectedUrl(MemberUrls.SETTING + "#account")) - .andExpect(flash().attribute("tab", "account")) - .andExpect(flash().attributeExists("error")) - } - - @Test - @WithMockMember(email = MemberFixture.DEFAULT_USERNAME) - fun `updateAccount - failure - profile address is duplicated`() { - val duplicateProfileAddress = "existingaddress" - val otherUser = testMemberHelper.createActivatedMember(email = "other@user.com") - otherUser.updateInfo( - profileAddress = ProfileAddress(duplicateProfileAddress), - ) - flushAndClear() - - mockMvc.perform( - post(MemberUrls.SETTING_ACCOUNT) - .with(csrf()) - .param("nickname", "새로운닉네임") - .param("profileAddress", duplicateProfileAddress) // 중복된 프로필 주소 - .param("introduction", "새로운 소개") - ) - .andExpect(status().is3xxRedirection) - .andExpect(redirectedUrl(MemberUrls.SETTING + "#account")) - .andExpect(flash().attribute("success", false)) - .andExpect(flash().attributeExists("error")) - } - - @Test - fun `updateAccount - failure - returns forbidden when not authenticated`() { - mockMvc.perform( - post(MemberUrls.SETTING_ACCOUNT) - .with(csrf()) - .param("nickname", "새로운닉네임") - .param("profileAddress", "newaddress") - .param("introduction", "새로운 소개") - ) - .andExpect(status().isForbidden) - } - - @Test - @WithMockMember(email = MemberFixture.DEFAULT_USERNAME) - fun `updatePassword - success - updates password and redirects`() { - mockMvc.perform( - post(MemberUrls.SETTING_PASSWORD) - .with(csrf()) - .param("currentPassword", MemberFixture.DEFAULT_RAW_PASSWORD.value) - .param("newPassword", "NewPassword1!") - .param("confirmNewPassword", "NewPassword1!") - ) - .andExpect(status().is3xxRedirection) - .andExpect(redirectedUrl("${MemberUrls.SETTING}#password")) - .andExpect(flash().attribute("tab", "password")) - .andExpect(flash().attribute("success", "비밀번호가 성공적으로 변경되었습니다.")) - } - - @Test - @WithMockMember(email = MemberFixture.DEFAULT_USERNAME) - fun `updatePassword - failure - validation error when password format is invalid`() { - mockMvc.perform( - post(MemberUrls.SETTING_PASSWORD) - .with(csrf()) - .param("currentPassword", "Default1!") - .param("newPassword", "weak") // 약한 비밀번호 - .param("confirmNewPassword", "weak") - ) - .andExpect(status().is3xxRedirection) - .andExpect(redirectedUrl("${MemberUrls.SETTING}#password")) - .andExpect(flash().attribute("tab", "password")) - .andExpect(flash().attributeExists("error")) - } - - @Test - @WithMockMember(email = MemberFixture.DEFAULT_USERNAME) - fun `updatePassword - failure - validation error when passwords do not match`() { - mockMvc.perform( - post(MemberUrls.SETTING_PASSWORD) - .with(csrf()) - .param("currentPassword", "Default1!") - .param("newPassword", "NewPassword1!") - .param("confirmNewPassword", "DifferentPassword1!") - ) - .andExpect(status().is3xxRedirection) - .andExpect(redirectedUrl("${MemberUrls.SETTING}#password")) - .andExpect(flash().attribute("tab", "password")) - .andExpect(flash().attributeExists("error")) - } - - @Test - fun `updatePassword - failure - returns forbidden when not authenticated`() { - mockMvc.perform( - post(MemberUrls.SETTING_PASSWORD) - .with(csrf()) - .param("currentPassword", "Default1!") - .param("newPassword", "NewPassword1!") - .param("confirmNewPassword", "NewPassword1!") - ) - .andExpect(status().isForbidden) - } - - // 계정 삭제 테스트 - @Test - @WithMockMember(email = MemberFixture.DEFAULT_USERNAME) - fun `deleteAccount - success - deactivates member and redirects to home`() { - mockMvc.perform( - post(MemberUrls.SETTING_DELETE) - .with(csrf()) - .param("confirmed", "true") - ) - .andExpect(status().is3xxRedirection) - .andExpect(redirectedUrl("/")) - } - - @Test - @WithMockMember(email = MemberFixture.DEFAULT_USERNAME) - fun `deleteAccount - failure - returns error when confirmation is missing`() { - mockMvc.perform( - post(MemberUrls.SETTING_DELETE) - .with(csrf()) - .param("confirmed", "false") - ) - .andExpect(status().is3xxRedirection) - .andExpect(redirectedUrl("${MemberUrls.SETTING}#delete")) - .andExpect(flash().attribute("tab", "delete")) - .andExpect(flash().attribute("error", "계정 삭제 확인이 필요합니다.")) - } - - @Test - @WithMockMember(email = MemberFixture.DEFAULT_USERNAME) - fun `deleteAccount - failure - returns error when confirmed is null`() { - mockMvc.perform( - post(MemberUrls.SETTING_DELETE) - .with(csrf()) - // confirmed 파라미터 없음 - ) - .andExpect(status().is3xxRedirection) - .andExpect(redirectedUrl("${MemberUrls.SETTING}#delete")) - .andExpect(flash().attribute("tab", "delete")) - .andExpect(flash().attribute("error", "계정 삭제 확인이 필요합니다.")) - } - - @Test - @WithMockMember(email = MemberFixture.DEFAULT_USERNAME, active = false) - fun `deleteAccount - failure - returns error when member is not active`() { - mockMvc.perform( - post(MemberUrls.SETTING_DELETE) - .with(csrf()) - .param("confirmed", "true") - ) - .andExpect(status().is3xxRedirection) - .andExpect(redirectedUrl("/members/setting#delete")) - .andExpect(flash().attributeExists("error")) - } - - @Test - fun `deleteAccount - failure - returns forbidden when not authenticated`() { - mockMvc.perform( - post(MemberUrls.SETTING_DELETE) - .with(csrf()) - .param("confirmed", "true") - ) - .andExpect(status().isForbidden) - } - - // 비밀번호 찾기 테스트 - @Test - fun `passwordForgot GET - success - shows password forgot page`() { - mockMvc.perform(get(MemberUrls.PASSWORD_FORGOT)) - .andExpect(status().isOk) - .andExpect(view().name(MemberViews.PASSWORD_FORGOT)) - } - - @Test - fun `sendPasswordResetEmail - success - sends reset email and redirects`() { - val member = testMemberHelper.createActivatedMember() - - mockMvc.perform( - post(MemberUrls.PASSWORD_FORGOT) - .with(csrf()) - .param("email", member.email.address) - ) - .andExpect(status().is3xxRedirection) - .andExpect(redirectedUrl(MemberUrls.PASSWORD_FORGOT)) - .andExpect(flash().attribute("success", true)) - .andExpect(flash().attributeExists("message")) - } - - @Test - fun `sendPasswordResetEmail - failure - returns error when email format is invalid`() { - mockMvc.perform( - post(MemberUrls.PASSWORD_FORGOT) - .with(csrf()) - .param("email", "invalid-email") - ) - .andExpect(status().is3xxRedirection) - .andExpect(redirectedUrl(MemberUrls.PASSWORD_FORGOT)) - .andExpect(flash().attribute("success", false)) - .andExpect(flash().attribute("error", "올바른 이메일 형식을 입력해주세요.")) - } - - @Test - fun `sendPasswordResetEmail - failure - returns error when email does not exist`() { - mockMvc.perform( - post(MemberUrls.PASSWORD_FORGOT) - .with(csrf()) - .param("email", "nonexistent@example.com") - ) - .andExpect(status().isBadRequest) - .andExpect { view().name("error/404") } - } - - @Test - fun `passwordReset GET - success - shows password reset page with token`() { - val token = "valid-reset-token" - - mockMvc.perform( - get(MemberUrls.PASSWORD_RESET) - .param("token", token) - ) - .andExpect(status().isOk) - .andExpect(view().name(MemberViews.PASSWORD_RESET)) - .andExpect(model().attribute("token", token)) - } - - @Test - fun `resetPassword - success - resets password and redirects to signin`() { - val member = testMemberHelper.createActivatedMember() - val token = createValidPasswordResetToken(member.requireId()) - - mockMvc.perform( - post(MemberUrls.PASSWORD_RESET) - .with(csrf()) - .param("token", token.token) - .param("newPassword", "NewPassword1!") - .param("newPasswordConfirm", "NewPassword1!") - ) - .andExpect(status().is3xxRedirection) - .andExpect(redirectedUrl("/")) - .andExpect(flash().attribute("success", true)) - .andExpect(flash().attributeExists("message")) - } - - @Test - fun `resetPassword - failure - validation error when password format is invalid`() { - val token = "valid-token" - - mockMvc.perform( - post(MemberUrls.PASSWORD_RESET) - .with(csrf()) - .param("token", token) - .param("newPassword", "weak") // 약한 비밀번호 - .param("newPasswordConfirm", "weak") - ) - .andExpect(status().is3xxRedirection) - .andExpect(redirectedUrl("${MemberUrls.PASSWORD_RESET}?token=$token")) - .andExpect(flash().attribute("error", "비밀번호 형식이 올바르지 않습니다.")) - .andExpect(flash().attribute("token", token)) - } - - @Test - fun `resetPassword - failure - validation error when passwords do not match`() { - val token = "valid-token" - - mockMvc.perform( - post(MemberUrls.PASSWORD_RESET) - .with(csrf()) - .param("token", token) - .param("newPassword", "NewPassword1!") - .param("newPasswordConfirm", "DifferentPassword1!") - ) - .andExpect(status().is3xxRedirection) - .andExpect(redirectedUrl("${MemberUrls.PASSWORD_RESET}?token=$token")) - .andExpect(flash().attribute("error", "새 비밀번호와 비밀번호 확인이 일치하지 않습니다.")) - .andExpect(flash().attribute("token", token)) - } - - @Test - fun `resetPassword - failure - returns error when token is invalid`() { - val invalidToken = "invalid-token" - - mockMvc.perform( - post(MemberUrls.PASSWORD_RESET) - .with(csrf()) - .param("token", invalidToken) - .param("newPassword", "NewPassword1!") - .param("newPasswordConfirm", "NewPassword1!") - ) - .andExpect(status().is3xxRedirection) - .andExpect(redirectedUrl("/")) - .andExpect(flash().attributeExists("error")) - .andExpect(flash().attribute("success", false)) - } - - @Test - @WithMockMember(email = MemberFixture.DEFAULT_USERNAME) - fun `updateAccount - failure - requires csrf token`() { - mockMvc.perform( - post(MemberUrls.SETTING_ACCOUNT) - // .with(csrf()) 제거하여 CSRF 토큰 누락 상황 테스트 - .param("nickname", "새로운닉네임") - .param("profileAddress", "newaddress") - .param("introduction", "새로운 소개") - ) - .andExpect(status().isForbidden) - } - - @Test - @WithMockMember(email = MemberFixture.DEFAULT_USERNAME) - fun `updatePassword - failure - requires csrf token`() { - mockMvc.perform( - post(MemberUrls.SETTING_PASSWORD) - // .with(csrf()) 제거 - .param("currentPassword", "Default1!") - .param("newPassword", "NewPassword1!") - .param("confirmNewPassword", "NewPassword1!") - ) - .andExpect(status().isForbidden) - } - - @Test - @WithMockMember(email = MemberFixture.DEFAULT_USERNAME) - fun `deleteAccount - failure - requires csrf token`() { - mockMvc.perform( - post(MemberUrls.SETTING_DELETE) - // .with(csrf()) 제거 - .param("confirmed", "true") - ) - .andExpect(status().isForbidden) - } - - @Test - @WithMockMember(email = MemberFixture.DEFAULT_USERNAME) - fun `readSidebarFragment - success - returns suggested users with follow status`() { - mockMvc.perform( - get(MemberUrls.FRAGMENT_SUGGEST_USERS_SIDEBAR) - ) - .andExpect(status().isOk) - .andExpect(view().name(MemberViews.SUGGEST_USERS_SIDEBAR_CONTENT)) - .andExpect(model().attributeExists("suggestedUsers")) - .andExpect(model().attributeExists("followings")) - .andExpect(model().attributeExists("member")) - } - - @Test - @WithMockMember(email = MemberFixture.DEFAULT_USERNAME) - fun `readSidebarFragment - success - returns multiple suggested users when other users exist`() { - // 여러 다른 사용자 생성 - val otherUsers = (1..10).map { index -> - testMemberHelper.createActivatedMember( - email = "user$index@example.com", - nickname = "user$index" - ) - } - - mockMvc.perform( - get(MemberUrls.FRAGMENT_SUGGEST_USERS_SIDEBAR) - ) - .andExpect(status().isOk) - .andExpect(view().name(MemberViews.SUGGEST_USERS_SIDEBAR_CONTENT)) - .andExpect(model().attributeExists("suggestedUsers")) - .andExpect(model().attributeExists("followings")) - .andExpect(model().attributeExists("member")) - .andExpect(model().attribute("member", Matchers.notNullValue())) - } - - @Test - @WithMockMember(email = MemberFixture.DEFAULT_USERNAME) - fun `readSidebarFragment - success - returns suggested users when many users exist`() { - // 더 많은 사용자 생성하여 추천 알고리즘 테스트 - val otherUsers = (1..20).map { index -> - testMemberHelper.createActivatedMember( - email = "suggestuser$index@example.com", - nickname = "suggest$index" - ) - } - flushAndClear() - - val result = mockMvc.perform( - get(MemberUrls.FRAGMENT_SUGGEST_USERS_SIDEBAR) - ) - .andExpect(status().isOk) - .andExpect(view().name(MemberViews.SUGGEST_USERS_SIDEBAR_CONTENT)) - .andExpect(model().attribute("suggestedUsers", Matchers.hasSize(5))) - .andExpect(model().attributeExists("followings")) - .andExpect(model().attributeExists("member")) - .andReturn() - } - - @Test - @WithMockMember(email = MemberFixture.DEFAULT_USERNAME) - fun `readSidebarFragment - success - returns empty suggested users when no other users exist`() { - val result = mockMvc.perform( - get(MemberUrls.FRAGMENT_SUGGEST_USERS_SIDEBAR) - ) - .andExpect(status().isOk) - .andExpect(view().name(MemberViews.SUGGEST_USERS_SIDEBAR_CONTENT)) - .andExpect(model().attributeExists("suggestedUsers")) - .andExpect(model().attributeExists("followings")) - .andExpect(model().attributeExists("member")) - .andReturn() - - // suggestedUsers가 비어있는지 확인 - val suggestedUsers = result.modelAndView!!.model["suggestedUsers"] as List<*> - assertTrue(suggestedUsers.isEmpty()) - } - - @Test - fun `readSidebarFragment - success - returns empty fragment without authentication`() { - mockMvc.perform( - get(MemberUrls.FRAGMENT_SUGGEST_USERS_SIDEBAR) - ) - .andExpect(status().isForbidden) - } - - private fun createValidActivationToken(memberId: Long): ActivationToken { - val token = ActivationToken( - memberId = memberId, - token = UUID.randomUUID().toString(), - createdAt = LocalDateTime.now(), - expiresAt = LocalDateTime.now().plusDays(1) - ) - return activationTokenRepository.save(token) - } - - private fun createValidPasswordResetToken(memberId: Long): PasswordResetToken { - val token = PasswordResetToken( - memberId = memberId, - token = UUID.randomUUID().toString(), - createdAt = LocalDateTime.now(), - expiresAt = LocalDateTime.now().plusHours(1) - ) - return passwordResetTokenRepository.save(token) - } -} diff --git a/src/test/kotlin/com/albert/realmoneyrealtaste/adapter/webview/member/PasswordResetFormValidatorTest.kt b/src/test/kotlin/com/albert/realmoneyrealtaste/adapter/webview/member/PasswordResetFormValidatorTest.kt new file mode 100644 index 00000000..8fcdfb94 --- /dev/null +++ b/src/test/kotlin/com/albert/realmoneyrealtaste/adapter/webview/member/PasswordResetFormValidatorTest.kt @@ -0,0 +1,215 @@ +package com.albert.realmoneyrealtaste.adapter.webview.member + +import com.albert.realmoneyrealtaste.adapter.webview.member.form.PasswordResetForm +import com.albert.realmoneyrealtaste.adapter.webview.member.validator.PasswordResetFormValidator +import org.springframework.validation.BeanPropertyBindingResult +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class PasswordResetFormValidatorTest { + + private val validator = PasswordResetFormValidator() + + @Test + fun `supports - success - returns true for PasswordResetForm class`() { + val result = validator.supports(PasswordResetForm::class.java) + + assertTrue(result) + } + + @Test + fun `supports - success - returns false for other classes`() { + val result = validator.supports(Any::class.java) + + assertFalse(result) + } + + @Test + fun `validate - success - no error when passwords match`() { + // Given + val form = PasswordResetForm( + newPassword = "NewPassword123!", + newPasswordConfirm = "NewPassword123!" + ) + val errors = BeanPropertyBindingResult(form, "passwordResetForm") + + // When + validator.validate(form, errors) + + // Then + assertFalse(errors.hasErrors()) + } + + @Test + fun `validate - failure - detects password mismatch and adds error`() { + // Given + val form = PasswordResetForm( + newPassword = "NewPassword123!", + newPasswordConfirm = "DifferentPassword123!" + ) + val errors = BeanPropertyBindingResult(form, "passwordResetForm") + + // When + validator.validate(form, errors) + + // Then + assertTrue(errors.hasErrors()) + assertEquals(1, errors.errorCount) + + val fieldError = errors.getFieldError("newPasswordConfirm") + assertEquals("passwordMismatch", fieldError?.code) + assertEquals("새 비밀번호와 비밀번호 확인이 일치하지 않습니다.", fieldError?.defaultMessage) + } + + @Test + fun `validate - failure - detects empty password confirmation`() { + // Given + val form = PasswordResetForm( + newPassword = "NewPassword123!", + newPasswordConfirm = "" + ) + val errors = BeanPropertyBindingResult(form, "passwordResetForm") + + // When + validator.validate(form, errors) + + // Then + assertTrue(errors.hasErrors()) + assertEquals(1, errors.errorCount) + + val fieldError = errors.getFieldError("newPasswordConfirm") + assertEquals("passwordMismatch", fieldError?.code) + assertEquals("새 비밀번호와 비밀번호 확인이 일치하지 않습니다.", fieldError?.defaultMessage) + } + + @Test + fun `validate - failure - detects null password confirmation`() { + // Given + val form = PasswordResetForm( + newPassword = "NewPassword123!", + newPasswordConfirm = "null" + ) + val errors = BeanPropertyBindingResult(form, "passwordResetForm") + + // When + validator.validate(form, errors) + + // Then + assertTrue(errors.hasErrors()) + assertEquals(1, errors.errorCount) + + val fieldError = errors.getFieldError("newPasswordConfirm") + assertEquals("passwordMismatch", fieldError?.code) + assertEquals("새 비밀번호와 비밀번호 확인이 일치하지 않습니다.", fieldError?.defaultMessage) + } + + @Test + fun `validate - success - handles complex matching passwords`() { + // Given - 복잡한 비밀번호 패턴이 일치하는 경우 + val form = PasswordResetForm( + newPassword = "Complex@Pass123", + newPasswordConfirm = "Complex@Pass123" + ) + val errors = BeanPropertyBindingResult(form, "passwordResetForm") + + // When + validator.validate(form, errors) + + // Then + assertFalse(errors.hasErrors()) + } + + @Test + fun `validate - failure - case sensitive password mismatch`() { + // Given - 대소문자가 다른 경우 + val form = PasswordResetForm( + newPassword = "NewPassword123!", + newPasswordConfirm = "newpassword123!" + ) + val errors = BeanPropertyBindingResult(form, "passwordResetForm") + + // When + validator.validate(form, errors) + + // Then + assertTrue(errors.hasErrors()) + assertEquals(1, errors.errorCount) + + val fieldError = errors.getFieldError("newPasswordConfirm") + assertEquals("passwordMismatch", fieldError?.code) + assertEquals("새 비밀번호와 비밀번호 확인이 일치하지 않습니다.", fieldError?.defaultMessage) + } + + @Test + fun `validate - failure - slight difference in passwords`() { + // Given - 미세한 차이가 있는 경우 + val form = PasswordResetForm( + newPassword = "NewPassword123!", + newPasswordConfirm = "NewPassword123" + ) + val errors = BeanPropertyBindingResult(form, "passwordResetForm") + + // When + validator.validate(form, errors) + + // Then + assertTrue(errors.hasErrors()) + assertEquals(1, errors.errorCount) + + val fieldError = errors.getFieldError("newPasswordConfirm") + assertEquals("passwordMismatch", fieldError?.code) + assertEquals("새 비밀번호와 비밀번호 확인이 일치하지 않습니다.", fieldError?.defaultMessage) + } + + @Test + fun `validate - success - handles special characters in matching passwords`() { + // Given - 특수문자가 포함된 일치하는 비밀번호 + val form = PasswordResetForm( + newPassword = "!@#\$Password123ABC", + newPasswordConfirm = "!@#\$Password123ABC" + ) + val errors = BeanPropertyBindingResult(form, "passwordResetForm") + + // When + validator.validate(form, errors) + + // Then + assertFalse(errors.hasErrors()) + } + + @Test + fun `validate - success - handles numeric passwords`() { + // Given - 숫자가 포함된 일치하는 비밀번호 + val form = PasswordResetForm( + newPassword = "Password123456!", + newPasswordConfirm = "Password123456!" + ) + val errors = BeanPropertyBindingResult(form, "passwordResetForm") + + // When + validator.validate(form, errors) + + // Then + assertFalse(errors.hasErrors()) + } + + @Test + fun `validate - integration - works with BeanPropertyBindingResult`() { + // Given - 실제 Spring BindingResult와의 통합 테스트 + val form = PasswordResetForm( + newPassword = "TestPassword123!", + newPasswordConfirm = "DifferentPassword123!" + ) + val errors = BeanPropertyBindingResult(form, "passwordResetForm") + + // When + validator.validate(form, errors) + + // Then + assertTrue(errors.hasFieldErrors("newPasswordConfirm")) + assertFalse(errors.hasFieldErrors("newPassword")) + assertEquals("passwordMismatch", errors.getFieldError("newPasswordConfirm")?.code) + } +} diff --git a/src/test/kotlin/com/albert/realmoneyrealtaste/adapter/webview/member/PasswordUpdateFormTest.kt b/src/test/kotlin/com/albert/realmoneyrealtaste/adapter/webview/member/PasswordUpdateFormTest.kt index 9148b947..013fdfe1 100644 --- a/src/test/kotlin/com/albert/realmoneyrealtaste/adapter/webview/member/PasswordUpdateFormTest.kt +++ b/src/test/kotlin/com/albert/realmoneyrealtaste/adapter/webview/member/PasswordUpdateFormTest.kt @@ -1,5 +1,6 @@ package com.albert.realmoneyrealtaste.adapter.webview.member +import com.albert.realmoneyrealtaste.adapter.webview.member.form.PasswordUpdateForm import jakarta.validation.Validation import jakarta.validation.Validator import org.junit.jupiter.api.Assertions.assertAll diff --git a/src/test/kotlin/com/albert/realmoneyrealtaste/adapter/webview/member/PasswordUpdateFormValidatorTest.kt b/src/test/kotlin/com/albert/realmoneyrealtaste/adapter/webview/member/PasswordUpdateFormValidatorTest.kt index 7427af03..c84f1cae 100644 --- a/src/test/kotlin/com/albert/realmoneyrealtaste/adapter/webview/member/PasswordUpdateFormValidatorTest.kt +++ b/src/test/kotlin/com/albert/realmoneyrealtaste/adapter/webview/member/PasswordUpdateFormValidatorTest.kt @@ -1,5 +1,7 @@ package com.albert.realmoneyrealtaste.adapter.webview.member +import com.albert.realmoneyrealtaste.adapter.webview.member.form.PasswordUpdateForm +import com.albert.realmoneyrealtaste.adapter.webview.member.validator.PasswordUpdateFormValidator import org.springframework.validation.BeanPropertyBindingResult import kotlin.test.Test import kotlin.test.assertEquals diff --git a/src/test/kotlin/com/albert/realmoneyrealtaste/adapter/webview/member/StringToEmailConverterTest.kt b/src/test/kotlin/com/albert/realmoneyrealtaste/adapter/webview/member/StringToEmailConverterTest.kt new file mode 100644 index 00000000..fda3e1c4 --- /dev/null +++ b/src/test/kotlin/com/albert/realmoneyrealtaste/adapter/webview/member/StringToEmailConverterTest.kt @@ -0,0 +1,226 @@ +package com.albert.realmoneyrealtaste.adapter.webview.member + +import com.albert.realmoneyrealtaste.adapter.webview.member.converter.StringToEmailConverter +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith + +class StringToEmailConverterTest { + + private val converter = StringToEmailConverter() + + @Test + fun `convert - success - converts valid email string to Email object`() { + // Given + val emailString = "test@example.com" + + // When + val result = converter.convert(emailString) + + // Then + assertEquals(emailString, result.address) + } + + @Test + fun `convert - success - handles email with subdomain`() { + // Given + val emailString = "user@mail.example.com" + + // When + val result = converter.convert(emailString) + + // Then + assertEquals(emailString, result.address) + } + + @Test + fun `convert - success - handles email with numbers`() { + // Given + val emailString = "user123@test456.com" + + // When + val result = converter.convert(emailString) + + // Then + assertEquals(emailString, result.address) + } + + @Test + fun `convert - success - handles email with special characters`() { + // Given + val emailString = "test.user+alias@example-domain.com" + + // When + val result = converter.convert(emailString) + + // Then + assertEquals(emailString, result.address) + } + + @Test + fun `convert - success - handles email with hyphens in domain`() { + // Given + val emailString = "user@my-domain.co.kr" + + // When + val result = converter.convert(emailString) + + // Then + assertEquals(emailString, result.address) + } + + @Test + fun `convert - success - handles simple email formats`() { + // Given + val testCases = listOf( + "a@b.co", + "ab@cd.com", + "user@domain.io", + "test@site.org" + ) + + testCases.forEach { emailString -> + // When + val result = converter.convert(emailString) + + // Then + assertEquals(emailString, result.address) + } + } + + @Test + fun `convert - failure - throws exception for empty string`() { + // Given + val emailString = "" + + // When & Then + assertFailsWith { + converter.convert(emailString) + }.let { + assertEquals("이메일은 필수입니다", it.message) + } + } + + @Test + fun `convert - failure - throws exception for blank string`() { + // Given + val emailString = " " + + // When & Then + assertFailsWith { + converter.convert(emailString) + }.let { + assertEquals("이메일은 필수입니다", it.message) + } + } + + @Test + fun `convert - failure - throws exception for email without domain`() { + // Given + val emailString = "user@" + + // When & Then + assertFailsWith { + converter.convert(emailString) + }.let { + assertEquals("유효한 이메일 형식이 아닙니다", it.message) + } + } + + @Test + fun `convert - failure - throws exception for email without local part`() { + // Given + val emailString = "@domain.com" + + // When & Then + assertFailsWith { + converter.convert(emailString) + }.let { + assertEquals("유효한 이메일 형식이 아닙니다", it.message) + } + } + + @Test + fun `convert - failure - throws exception for email without at symbol`() { + // Given + val emailString = "userdomain.com" + + // When & Then + assertFailsWith { + converter.convert(emailString) + }.let { + assertEquals("유효한 이메일 형식이 아닙니다", it.message) + } + } + + @Test + fun `convert - failure - throws exception for email with invalid domain`() { + // Given + val emailString = "user@invalid" + + // When & Then + assertFailsWith { + converter.convert(emailString) + }.let { + assertEquals("유효한 이메일 형식이 아닙니다", it.message) + } + } + + @Test + fun `convert - failure - throws exception for email with multiple at symbols`() { + // Given + val emailString = "user@domain@com" + + // When & Then + assertFailsWith { + converter.convert(emailString) + }.let { + assertEquals("유효한 이메일 형식이 아닙니다", it.message) + } + } + + @Test + fun `convert - failure - throws exception for email with invalid characters`() { + // Given + val emailString = "user!@domain.com" + + // When & Then + assertFailsWith { + converter.convert(emailString) + }.let { + assertEquals("유효한 이메일 형식이 아닙니다", it.message) + } + } + + @Test + fun `convert - integration - preserves original string value`() { + // Given + val originalEmail = "original.test@example.com" + + // When + val result = converter.convert(originalEmail) + + // Then + assertEquals(originalEmail, result.address) + assertEquals("example.com", result.getDomain()) + } + + @Test + fun `convert - integration - works with edge case valid emails`() { + // Given - 엣지 케이스지만 유효한 이메일들 + val validEmails = listOf( + "a+b@c.co", + "test_user@email-domain.org", + "user123@sub.domain.net", + "simple@simple.io" + ) + + validEmails.forEach { emailString -> + // When + val result = converter.convert(emailString) + + // Then + assertEquals(emailString, result.address) + } + } +} diff --git a/src/test/kotlin/com/albert/realmoneyrealtaste/adapter/webview/post/PostViewTest.kt b/src/test/kotlin/com/albert/realmoneyrealtaste/adapter/webview/post/PostViewTest.kt index c8b74701..c0f5fc63 100644 --- a/src/test/kotlin/com/albert/realmoneyrealtaste/adapter/webview/post/PostViewTest.kt +++ b/src/test/kotlin/com/albert/realmoneyrealtaste/adapter/webview/post/PostViewTest.kt @@ -526,7 +526,7 @@ class PostViewTest : IntegrationTestBase() { get("/posts/{postId}/modal", post.requireId()) ) .andExpect(status().isOk) - .andExpect(view().name("post/modal-detail :: post-detail-modal")) + .andExpect(view().name("post/fragments/modal-detail :: post-detail-modal")) .andExpect(model().attributeExists("post")) } @@ -629,7 +629,6 @@ class PostViewTest : IntegrationTestBase() { ) .andExpect(status().isOk) .andExpect(view().name(PostViews.POSTS_CONTENT)) - .andExpect(model().attributeExists("author")) .andExpect(model().attributeExists("posts")) .andExpect(model().attributeExists("member")) } @@ -652,17 +651,16 @@ class PostViewTest : IntegrationTestBase() { ) .andExpect(status().isOk) .andExpect(view().name(PostViews.POSTS_CONTENT)) - .andExpect(model().attributeExists("author")) .andExpect(model().attributeExists("posts")) } @Test @WithMockMember(email = MemberFixture.DEFAULT_USERNAME) - fun `readMemberPostsFragment - failure - returns error when member not found`() { + fun `readMemberPostsFragment - success - returns empty list when member not exist`() { mockMvc.perform( get("/members/{id}/posts/fragment", 99999L) ) - .andExpect(status().is4xxClientError) + .andExpect(status().isOk) } @Test diff --git a/src/test/kotlin/com/albert/realmoneyrealtaste/adapter/webview/post/PostCreateFormTest.kt b/src/test/kotlin/com/albert/realmoneyrealtaste/adapter/webview/post/form/PostCreateFormTest.kt similarity index 99% rename from src/test/kotlin/com/albert/realmoneyrealtaste/adapter/webview/post/PostCreateFormTest.kt rename to src/test/kotlin/com/albert/realmoneyrealtaste/adapter/webview/post/form/PostCreateFormTest.kt index 547832e3..bb386dd8 100644 --- a/src/test/kotlin/com/albert/realmoneyrealtaste/adapter/webview/post/PostCreateFormTest.kt +++ b/src/test/kotlin/com/albert/realmoneyrealtaste/adapter/webview/post/form/PostCreateFormTest.kt @@ -1,4 +1,4 @@ -package com.albert.realmoneyrealtaste.adapter.webview.post +package com.albert.realmoneyrealtaste.adapter.webview.post.form import jakarta.validation.ConstraintViolation import jakarta.validation.Validation diff --git a/src/test/kotlin/com/albert/realmoneyrealtaste/adapter/webview/util/BindingResultUtilsTest.kt b/src/test/kotlin/com/albert/realmoneyrealtaste/adapter/webview/util/BindingResultUtilsTest.kt new file mode 100644 index 00000000..bf8c49ad --- /dev/null +++ b/src/test/kotlin/com/albert/realmoneyrealtaste/adapter/webview/util/BindingResultUtilsTest.kt @@ -0,0 +1,237 @@ +package com.albert.realmoneyrealtaste.adapter.webview.util + +import org.springframework.validation.BeanPropertyBindingResult +import org.springframework.validation.FieldError +import org.springframework.validation.ObjectError +import kotlin.test.Test +import kotlin.test.assertEquals + +class BindingResultUtilsTest { + + @Test + fun `extractFirstErrorMessage - success - returns empty string when no errors exist`() { + // Given + val bindingResult = BeanPropertyBindingResult("test", "test") + + // When + val result = BindingResultUtils.extractFirstErrorMessage(bindingResult) + + // Then + assertEquals("", result) + } + + @Test + fun `extractFirstErrorMessage - success - returns the error message when single error exists`() { + // Given + val bindingResult = BeanPropertyBindingResult("test", "test") + bindingResult.addError( + FieldError( + "test", + "field1", + "invalid value", + false, + arrayOf("field1.error"), + null, + "field1.error" + ) + ) + + // When + val result = BindingResultUtils.extractFirstErrorMessage(bindingResult) + + // Then + assertEquals("field1.error", result) + } + + @Test + fun `extractFirstErrorMessage - success - returns the first error message when multiple errors exist`() { + // Given + val bindingResult = BeanPropertyBindingResult("test", "test") + bindingResult.addError( + FieldError( + "test", + "field1", + "invalid value", + false, + arrayOf("field1.error"), + null, + "field1.error" + ) + ) + bindingResult.addError( + FieldError( + "test", + "field2", + "invalid value", + false, + arrayOf("field2.error"), + null, + "field2.error" + ) + ) + bindingResult.addError( + FieldError( + "test", + "field3", + "invalid value", + false, + arrayOf("field3.error"), + null, + "field3.error" + ) + ) + + // When + val result = BindingResultUtils.extractFirstErrorMessage(bindingResult) + + // Then + assertEquals("field1.error", result) + } + + @Test + fun `extractFirstErrorMessage - success - skips null error messages and finds next valid message`() { + // Given + val bindingResult = BeanPropertyBindingResult("test", "test") + bindingResult.addError(FieldError("test", "field1", "invalid value", false, null, null, null)) + bindingResult.addError( + FieldError( + "test", + "field2", + "invalid value", + false, + arrayOf("field2.error"), + null, + "field2.error" + ) + ) + + // When + val result = BindingResultUtils.extractFirstErrorMessage(bindingResult) + + // Then + assertEquals("field2.error", result) + } + + @Test + fun `extractFirstErrorMessage - success - returns empty string when all error messages are null`() { + // Given + val bindingResult = BeanPropertyBindingResult("test", "test") + bindingResult.addError(FieldError("test", "field1", "invalid value", false, null, null, null)) + bindingResult.addError(FieldError("test", "field2", "invalid value", false, null, null, null)) + + // When + val result = BindingResultUtils.extractFirstErrorMessage(bindingResult) + + // Then + assertEquals("", result) + } + + @Test + fun `extractFirstErrorMessage - success - returns custom error message when provided`() { + // Given + val bindingResult = BeanPropertyBindingResult("test", "test") + bindingResult.addError( + FieldError("test", "email", "invalid-email", false, arrayOf("email.format"), null, "올바른 이메일 형식이 아닙니다") + ) + + // When + val result = BindingResultUtils.extractFirstErrorMessage(bindingResult) + + // Then + assertEquals("올바른 이메일 형식이 아닙니다", result) + } + + @Test + fun `extractFirstErrorMessage - success - extracts message from ObjectError`() { + // Given + val bindingResult = BeanPropertyBindingResult("test", "test") + bindingResult.addError(ObjectError("test", "전체 객체 에러 메시지")) + + // When + val result = BindingResultUtils.extractFirstErrorMessage(bindingResult) + + // Then + assertEquals("전체 객체 에러 메시지", result) + } + + @Test + fun `extractFirstErrorMessage - success - returns first error when FieldError and ObjectError are mixed`() { + // Given + val bindingResult = BeanPropertyBindingResult("test", "test") + bindingResult.addError( + FieldError( + "test", + "field1", + "invalid", + false, + arrayOf("field1.error"), + null, + "첫 번째 필드 에러" + ) + ) + bindingResult.addError(ObjectError("test", "객체 에러")) + + // When + val result = BindingResultUtils.extractFirstErrorMessage(bindingResult) + + // Then + assertEquals("첫 번째 필드 에러", result) + } + + @Test + fun `extractFirstErrorMessage - success - handles real email validation error scenario`() { + // Given - 실제 이메일 폼 검증 시나리오 + val bindingResult = BeanPropertyBindingResult("passwordResetEmailForm", "passwordResetEmailForm") + bindingResult.addError( + FieldError( + "passwordResetEmailForm", + "email", + "invalid-email", + false, + arrayOf("Email"), + null, + "올바른 이메일 형식이 아닙니다" + ) + ) + + // When + val result = BindingResultUtils.extractFirstErrorMessage(bindingResult) + + // Then + assertEquals("올바른 이메일 형식이 아닙니다", result) + } + + @Test + fun `extractFirstErrorMessage - success - handles real password validation error scenario`() { + // Given - 실제 비밀번호 폼 검증 시나리오 + val bindingResult = BeanPropertyBindingResult("passwordResetForm", "passwordResetForm") + bindingResult.addError( + FieldError( + "passwordResetForm", + "newPassword", + "weak", + false, + arrayOf("Size"), + arrayOf(8, 20), + "비밀번호는 최소 8자 이상이어야 합니다" + ) + ) + bindingResult.addError( + FieldError( + "passwordResetForm", + "newPasswordConfirm", + "", + false, + arrayOf("NotEmpty"), + null, + "비밀번호 확인은 필수입니다" + ) + ) + + // When + val result = BindingResultUtils.extractFirstErrorMessage(bindingResult) + + // Then + assertEquals("비밀번호는 최소 8자 이상이어야 합니다", result) + } +} diff --git a/src/test/kotlin/com/albert/realmoneyrealtaste/application/collection/provided/CollectionReaderTest.kt b/src/test/kotlin/com/albert/realmoneyrealtaste/application/collection/provided/CollectionReaderTest.kt index cadb3634..d0c78453 100644 --- a/src/test/kotlin/com/albert/realmoneyrealtaste/application/collection/provided/CollectionReaderTest.kt +++ b/src/test/kotlin/com/albert/realmoneyrealtaste/application/collection/provided/CollectionReaderTest.kt @@ -8,6 +8,7 @@ import com.albert.realmoneyrealtaste.domain.collection.CollectionStatus import com.albert.realmoneyrealtaste.domain.collection.PostCollection import com.albert.realmoneyrealtaste.domain.collection.command.CollectionCreateCommand import com.albert.realmoneyrealtaste.util.TestMemberHelper +import com.albert.realmoneyrealtaste.util.TestPostHelper import org.springframework.data.domain.PageRequest import org.springframework.data.domain.Sort import kotlin.test.Test @@ -18,6 +19,7 @@ import kotlin.test.assertTrue class CollectionReaderTest( private val collectionReader: CollectionReader, private val testMemberHelper: TestMemberHelper, + private val testPostHelper: TestPostHelper, private val collectionRepository: CollectionRepository, ) : IntegrationTestBase() { @@ -69,7 +71,7 @@ class CollectionReaderTest( @Test fun `readMyCollections - success - respects pagination`() { val member = testMemberHelper.createActivatedMember() - val collections = (1..15).map { i -> + (1..15).map { i -> createTestCollection(member.requireId(), name = "컬렉션 $i") } @@ -84,17 +86,17 @@ class CollectionReaderTest( @Test fun `readMyPublicCollections - success - returns only public collections`() { val member = testMemberHelper.createActivatedMember() - val publicCollection1 = createTestCollection( + createTestCollection( member.requireId(), name = "공개 컬렉션 1", privacy = CollectionPrivacy.PUBLIC ) - val privateCollection = createTestCollection( + createTestCollection( member.requireId(), name = "비공개 컬렉션", privacy = CollectionPrivacy.PRIVATE ) - val publicCollection2 = createTestCollection( + createTestCollection( member.requireId(), name = "공개 컬렉션 2", privacy = CollectionPrivacy.PUBLIC @@ -236,6 +238,138 @@ class CollectionReaderTest( assertTrue(result.content.all { it.privacy == CollectionPrivacy.PUBLIC }) } + @Test + fun `readDetail - success - returns collection with posts and author posts`() { + // Given + val member = testMemberHelper.createActivatedMember() + val collection = createTestCollection(member.requireId(), name = "맛집 컬렉션") + + // 컬렉션에 추가할 게시글들 생성 + val collectionPosts = (1..3).map { _ -> + testPostHelper.createPost( + authorMemberId = member.requireId(), + authorNickname = "테스트유저" + ) + } + + // 컬렉션에 게시글 추가 + collectionPosts.forEach { post -> + collection.addPost(member.requireId(), post.requireId()) + } + + // 추가적인 게시글들 생성 (myPosts에 포함될) + (1..2).map { + testPostHelper.createPost( + authorMemberId = member.requireId(), + authorNickname = "테스트유저" + ) + } + + val pageRequest = PageRequest.of(0, 10, Sort.by(Sort.Direction.DESC, "id")) + + // When + val result = collectionReader.readDetail(member.requireId(), collection.requireId(), pageRequest) + + // Then + assertEquals(collection.requireId(), result.collection.requireId()) + assertEquals("맛집 컬렉션", result.collection.info.name) + + // 컬렉션에 포함된 게시글들 확인 + assertEquals(3, result.posts.size) + val collectionPostIds = result.posts.map { it.requireId() } + assertTrue(collectionPostIds.containsAll(collectionPosts.map { it.requireId() })) + + // 작성자의 모든 게시글들 확인 (페이징) + assertEquals(5, result.myPosts.totalElements) // 총 5개 게시글 + assertEquals(5, result.myPosts.content.size) // 페이지 크기가 10이므로 모두 반환 + } + + @Test + fun `readDetail - success - handles empty collection`() { + // Given + val member = testMemberHelper.createActivatedMember() + val emptyCollection = createTestCollection(member.requireId(), name = "빈 컬렉션") + + // 작성자 게시글들만 생성 + (1..2).map { + testPostHelper.createPost( + authorMemberId = member.requireId(), + authorNickname = "테스트유저" + ) + } + + val pageRequest = PageRequest.of(0, 10) + + // When + val result = collectionReader.readDetail(member.requireId(), emptyCollection.requireId(), pageRequest) + + // Then + assertEquals(emptyCollection.requireId(), result.collection.requireId()) + assertEquals(0, result.posts.size) // 컬렉션에는 게시글이 없음 + assertEquals(2, result.myPosts.totalElements) // 작성자 게시글은 2개 + assertEquals(2, result.myPosts.content.size) + } + + @Test + fun `readDetail - failure - throws exception when collection not found`() { + // Given + val member = testMemberHelper.createActivatedMember() + val nonExistentCollectionId = 999L + val pageRequest = PageRequest.of(0, 10) + + // When & Then + assertFailsWith { + collectionReader.readDetail(member.requireId(), nonExistentCollectionId, pageRequest) + }.let { exception -> + assertEquals("컬렉션을 찾을 수 없습니다.", exception.message) + } + } + + @Test + fun `readDetail - failure - throws exception when collection is deleted`() { + // Given + val member = testMemberHelper.createActivatedMember() + val collection = createTestCollection(member.requireId()) + collection.delete(member.requireId()) + + val pageRequest = PageRequest.of(0, 10) + + // When & Then + assertFailsWith { + collectionReader.readDetail(member.requireId(), collection.requireId(), pageRequest) + }.let { exception -> + assertEquals("컬렉션을 찾을 수 없습니다.", exception.message) + } + } + + @Test + fun `readDetail - success - respects pagination for author posts`() { + // Given + val member = testMemberHelper.createActivatedMember() + val collection = createTestCollection(member.requireId()) + + // 컬렉션에 게시글 추가 + val collectionPost = testPostHelper.createPost(authorMemberId = member.requireId()) + collection.addPost(member.requireId(), collectionPost.requireId()) + + // 작성자 게시글 15개 생성 + (1..15).map { + testPostHelper.createPost(authorMemberId = member.requireId()) + } + + // 페이지 크기 5로 요청 + val pageRequest = PageRequest.of(0, 5, Sort.by(Sort.Direction.DESC, "id")) + + // When + val result = collectionReader.readDetail(member.requireId(), collection.requireId(), pageRequest) + + // Then + assertEquals(1, result.posts.size) // 컬렉션 게시글 1개 + assertEquals(16, result.myPosts.totalElements) // 총 16개 게시글 (컬렉션 포함) + assertEquals(5, result.myPosts.content.size) // 첫 페이지 5개 + assertEquals(4, result.myPosts.totalPages) // 총 4 페이지 (16/5 = 3.2 -> 4) + } + private fun createTestCollection( ownerMemberId: Long, name: String = "테스트 컬렉션", diff --git a/src/test/kotlin/com/albert/realmoneyrealtaste/application/follow/dto/FollowResponseTest.kt b/src/test/kotlin/com/albert/realmoneyrealtaste/application/follow/dto/FollowResponseTest.kt index e30be913..8095aec8 100644 --- a/src/test/kotlin/com/albert/realmoneyrealtaste/application/follow/dto/FollowResponseTest.kt +++ b/src/test/kotlin/com/albert/realmoneyrealtaste/application/follow/dto/FollowResponseTest.kt @@ -12,14 +12,18 @@ class FollowResponseTest { @Test fun `from - success - creates response with all follow information`() { val followerId = 1L - val followingId = 2L val followerNickname = "follower" + val followerProfileImageId = 1L + val followingId = 2L val followingNickname = "following" + val followingProfileImageId = 2L val command = FollowCreateCommand( followerId = followerId, followerNickname = followerNickname, + followerProfileImageId = followerProfileImageId, followingId = followingId, - followingNickname = followingNickname + followingNickname = followingNickname, + followingProfileImageId = followingProfileImageId, ) val follow = Follow.create(command) @@ -36,7 +40,9 @@ class FollowResponseTest { { assertEquals(followingNickname, result.followingNickname) }, { assertEquals(FollowStatus.ACTIVE, result.status) }, { assertEquals(follow.createdAt, result.createdAt) }, - { assertEquals(follow.updatedAt, result.updatedAt) } + { assertEquals(follow.updatedAt, result.updatedAt) }, + { assertEquals(followerProfileImageId, result.followerProfileImageId) }, + { assertEquals(followingProfileImageId, result.followingProfileImageId) }, ) } diff --git a/src/test/kotlin/com/albert/realmoneyrealtaste/application/follow/provided/FollowReaderTest.kt b/src/test/kotlin/com/albert/realmoneyrealtaste/application/follow/provided/FollowReaderTest.kt index d18a8da3..eb2f3456 100644 --- a/src/test/kotlin/com/albert/realmoneyrealtaste/application/follow/provided/FollowReaderTest.kt +++ b/src/test/kotlin/com/albert/realmoneyrealtaste/application/follow/provided/FollowReaderTest.kt @@ -460,33 +460,6 @@ class FollowReaderTest( ) } - @Test - fun `mapToFollowResponses - success - preserves nicknames from relationship`() { - val follower = testMemberHelper.createActivatedMember( - email = "known-follower@test.com", - nickname = "known" - ) - val following = testMemberHelper.createActivatedMember( - email = "following@test.com", - nickname = "following" - ) - - createActiveFollow(follower.requireId(), following.requireId()) - - // 팔로워를 비활성화해도 FollowRelationship에 저장된 닉네임은 유지됨 - follower.deactivate() - following.deactivate() - - val pageable = PageRequest.of(0, 10) - val result = followReader.findFollowingsByMemberId(follower.requireId(), pageable) - - assertAll( - { assertEquals(1, result.totalElements) }, - { assertEquals("known", result.content.first().followerNickname) }, - { assertEquals("following", result.content.first().followingNickname) } - ) - } - @Test fun `mapToFollowResponses - success - handles empty follow list`() { val pageable = PageRequest.of(0, 10) @@ -558,6 +531,367 @@ class FollowReaderTest( ) } + @Test + fun `findFollowByRelationship - success - returns follow when exists`() { + val follower = testMemberHelper.createActivatedMember( + email = "relationship-follower@test.com", + nickname = "follower" + ) + val following = testMemberHelper.createActivatedMember( + email = "relationship-following@test.com", + nickname = "following" + ) + + val follow = createActiveFollow(follower.requireId(), following.requireId()) + + val result = followReader.findFollowByRelationship(follower.requireId(), following.requireId())!! + + assertAll( + { assertEquals(follow.requireId(), result.requireId()) }, + { assertEquals(follower.requireId(), result.relationship.followerId) }, + { assertEquals(following.requireId(), result.relationship.followingId) } + ) + } + + @Test + fun `findFollowByRelationship - success - returns null when follow does not exist`() { + val member1 = testMemberHelper.createActivatedMember( + email = "no-relationship1@test.com", + nickname = "member1" + ) + val member2 = testMemberHelper.createActivatedMember( + email = "no-relationship2@test.com", + nickname = "member2" + ) + + val result = followReader.findFollowByRelationship(member1.requireId(), member2.requireId()) + + assertEquals(null, result) + } + + @Test + fun `searchFollowers - success - returns followers matching keyword`() { + val member = testMemberHelper.createActivatedMember( + email = "search-target@test.com", + nickname = "target" + ) + val matchingFollower1 = testMemberHelper.createActivatedMember( + email = "match1@test.com", + nickname = "searchable" + ) + val matchingFollower2 = testMemberHelper.createActivatedMember( + email = "match2@test.com", + nickname = "searchable2" + ) + val nonMatchingFollower = testMemberHelper.createActivatedMember( + email = "nomatch@test.com", + nickname = "other" + ) + + // 팔로우 관계 생성 + createActiveFollow(matchingFollower1.requireId(), member.requireId()) + createActiveFollow(matchingFollower2.requireId(), member.requireId()) + createActiveFollow(nonMatchingFollower.requireId(), member.requireId()) + + val pageable = PageRequest.of(0, 10) + val result = followReader.searchFollowers(member.requireId(), "search", pageable) + + assertAll( + { assertEquals(2, result.totalElements) }, + { assertTrue(result.content.all { it.followerNickname.contains("search") }) }, + { assertFalse(result.content.any { it.followerNickname == "other" }) } + ) + } + + @Test + fun `searchFollowers - success - returns empty when no followers match keyword`() { + val member = testMemberHelper.createActivatedMember( + email = "empty-search@test.com", + nickname = "member" + ) + val follower = testMemberHelper.createActivatedMember( + email = "follower@test.com", + nickname = "follower" + ) + + createActiveFollow(follower.requireId(), member.requireId()) + + val pageable = PageRequest.of(0, 10) + val result = followReader.searchFollowers(member.requireId(), "nonexistent", pageable) + + assertTrue(result.isEmpty) + assertEquals(0, result.totalElements) + } + + @Test + fun `searchFollowings - success - returns followings matching keyword`() { + val member = testMemberHelper.createActivatedMember( + email = "searching@test.com", + nickname = "searcher" + ) + val matchingFollowing1 = testMemberHelper.createActivatedMember( + email = "following1@test.com", + nickname = "searchable" + ) + val matchingFollowing2 = testMemberHelper.createActivatedMember( + email = "following2@test.com", + nickname = "searchable2" + ) + val nonMatchingFollowing = testMemberHelper.createActivatedMember( + email = "following3@test.com", + nickname = "other" + ) + + // 팔로우 관계 생성 + createActiveFollow(member.requireId(), matchingFollowing1.requireId()) + createActiveFollow(member.requireId(), matchingFollowing2.requireId()) + createActiveFollow(member.requireId(), nonMatchingFollowing.requireId()) + + val pageable = PageRequest.of(0, 10) + val result = followReader.searchFollowings(member.requireId(), "search", pageable) + + assertAll( + { assertEquals(2, result.totalElements) }, + { assertTrue(result.content.all { it.followerNickname.contains("search") }) }, + { assertFalse(result.content.any { it.followerNickname == "other" }) } + ) + } + + @Test + fun `findFollowings - success - returns only followed member IDs`() { + val follower = testMemberHelper.createActivatedMember( + email = "bulk-follower@test.com", + nickname = "follower" + ) + val members = (1..5).map { index -> + testMemberHelper.createActivatedMember( + email = "target$index@test.com", + nickname = "target$index" + ) + } + + // 일부 멤버만 팔로우 + createActiveFollow(follower.requireId(), members[0].requireId()) + createActiveFollow(follower.requireId(), members[2].requireId()) + createActiveFollow(follower.requireId(), members[4].requireId()) + + val targetIds = members.map { it.requireId() } + val result = followReader.findFollowings(follower.requireId(), targetIds) + + assertAll( + { assertEquals(3, result.size) }, + { assertTrue(result.contains(members[0].requireId())) }, + { assertTrue(result.contains(members[2].requireId())) }, + { assertTrue(result.contains(members[4].requireId())) }, + { assertFalse(result.contains(members[1].requireId())) }, + { assertFalse(result.contains(members[3].requireId())) } + ) + } + + @Test + fun `findFollowings - success - returns empty list when no follows`() { + val follower = testMemberHelper.createActivatedMember( + email = "no-follow@test.com", + nickname = "follower" + ) + val members = (1..3).map { index -> + testMemberHelper.createActivatedMember( + email = "target$index@test.com", + nickname = "target$index" + ) + } + + val targetIds = members.map { it.requireId() } + val result = followReader.findFollowings(follower.requireId(), targetIds) + + assertTrue(result.isEmpty()) + } + + @Test + fun `searchFollowings - success - returns empty when no followings match keyword`() { + val member = testMemberHelper.createActivatedMember( + email = "empty-following-search@test.com", + nickname = "member" + ) + val following = testMemberHelper.createActivatedMember( + email = "following@test.com", + nickname = "following" + ) + + createActiveFollow(member.requireId(), following.requireId()) + + val pageable = PageRequest.of(0, 10) + val result = followReader.searchFollowings(member.requireId(), "nonexistent", pageable) + + assertTrue(result.isEmpty) + assertEquals(0, result.totalElements) + } + + @Test + fun `findFollowings - success - excludes unfollowed relationships`() { + val follower = testMemberHelper.createActivatedMember( + email = "unfollow-test@test.com", + nickname = "follower" + ) + val members = (1..3).map { index -> + testMemberHelper.createActivatedMember( + email = "target$index@test.com", + nickname = "target$index" + ) + } + + // 팔로우 생성 + val follow1 = createActiveFollow(follower.requireId(), members[0].requireId()) + val follow2 = createActiveFollow(follower.requireId(), members[1].requireId()) + val follow3 = createActiveFollow(follower.requireId(), members[2].requireId()) + + // 하나 언팔로우 + follow2.unfollow() + followRepository.save(follow2) + + val targetIds = members.map { it.requireId() } + val result = followReader.findFollowings(follower.requireId(), targetIds) + + assertAll( + { assertEquals(2, result.size) }, + { assertTrue(result.contains(members[0].requireId())) }, + { assertTrue(result.contains(members[2].requireId())) }, + { assertFalse(result.contains(members[1].requireId())) } + ) + } + + @Test + fun `findFollowersByMemberId - success - excludes deactivated members`() { + val member = testMemberHelper.createActivatedMember( + email = "deactivated-follower@test.com", + nickname = "member" + ) + val activeFollower = testMemberHelper.createActivatedMember( + email = "active@test.com", + nickname = "active" + ) + val deactivatedFollower = testMemberHelper.createActivatedMember( + email = "deactivated@test.com", + nickname = "deactivated" + ) + + // 팔로우 관계 생성 + createActiveFollow(activeFollower.requireId(), member.requireId()) + createActiveFollow(deactivatedFollower.requireId(), member.requireId()) + + // 비활성화 + deactivatedFollower.deactivate() + flushAndClear() + + val pageable = PageRequest.of(0, 10) + val result = followReader.findFollowersByMemberId(member.requireId(), pageable) + + assertAll( + { assertEquals(1, result.totalElements) }, + { assertEquals(activeFollower.requireId(), result.content.first().followerId) }, + { assertFalse(result.content.any { it.followerId == deactivatedFollower.requireId() }) } + ) + } + + @Test + fun `findFollowingsByMemberId - success - excludes deactivated members`() { + val follower = testMemberHelper.createActivatedMember( + email = "deactivated-following@test.com", + nickname = "follower" + ) + val activeFollowing = testMemberHelper.createActivatedMember( + email = "active@test.com", + nickname = "active" + ) + val deactivatedFollowing = testMemberHelper.createActivatedMember( + email = "deactivated@test.com", + nickname = "deactivated" + ) + + // 팔로우 관계 생성 + createActiveFollow(follower.requireId(), activeFollowing.requireId()) + createActiveFollow(follower.requireId(), deactivatedFollowing.requireId()) + + // 비활성화 + deactivatedFollowing.deactivate() + flushAndClear() + + val pageable = PageRequest.of(0, 10) + val result = followReader.findFollowingsByMemberId(follower.requireId(), pageable) + + assertAll( + { assertEquals(1, result.totalElements) }, + { assertEquals(activeFollowing.requireId(), result.content.first().followingId) }, + { assertFalse(result.content.any { it.followingId == deactivatedFollowing.requireId() }) } + ) + } + + @Test + fun `searchFollowers - success - excludes deactivated members`() { + val member = testMemberHelper.createActivatedMember( + email = "deactivated-search@test.com", + nickname = "member" + ) + val activeFollower = testMemberHelper.createActivatedMember( + email = "active@test.com", + nickname = "activeSearch" + ) + val deactivatedFollower = testMemberHelper.createActivatedMember( + email = "deactivated@test.com", + nickname = "deactivatedSearch" + ) + + // 팔로우 관계 생성 + createActiveFollow(activeFollower.requireId(), member.requireId()) + createActiveFollow(deactivatedFollower.requireId(), member.requireId()) + + // 비활성화 + deactivatedFollower.deactivate() + flushAndClear() + + val pageable = PageRequest.of(0, 10) + val result = followReader.searchFollowers(member.requireId(), "Search", pageable) + + assertAll( + { assertEquals(1, result.totalElements) }, + { assertEquals(activeFollower.requireId(), result.content.first().followerId) }, + { assertFalse(result.content.any { it.followerId == deactivatedFollower.requireId() }) } + ) + } + + @Test + fun `searchFollowings - success - excludes deactivated members`() { + val follower = testMemberHelper.createActivatedMember( + email = "deactivated-following@test.com", + nickname = "follower" + ) + val activeFollowing = testMemberHelper.createActivatedMember( + email = "active@test.com", + nickname = "activeSearch" + ) + val deactivatedFollowing = testMemberHelper.createActivatedMember( + email = "deactivated@test.com", + nickname = "deactivatedSearch" + ) + + // 팔로우 관계 생성 + createActiveFollow(follower.requireId(), activeFollowing.requireId()) + createActiveFollow(follower.requireId(), deactivatedFollowing.requireId()) + + // 비활성화 + deactivatedFollowing.deactivate() + flushAndClear() + + val pageable = PageRequest.of(0, 10) + val result = followReader.searchFollowings(follower.requireId(), "search", pageable) + + assertAll( + { assertEquals(1, result.totalElements) }, + { assertEquals(activeFollowing.requireId(), result.content.first().followingId) }, + { assertFalse(result.content.any { it.followingId == deactivatedFollowing.requireId() }) } + ) + } + private fun createActiveFollow(followerId: Long, followingId: Long): Follow { val request = FollowCreateRequest(followerId, followingId) return followCreator.follow(request) diff --git a/src/test/kotlin/com/albert/realmoneyrealtaste/application/follow/provided/FollowTerminatorTest.kt b/src/test/kotlin/com/albert/realmoneyrealtaste/application/follow/provided/FollowTerminatorTest.kt index 5cb09bc0..3b95426a 100644 --- a/src/test/kotlin/com/albert/realmoneyrealtaste/application/follow/provided/FollowTerminatorTest.kt +++ b/src/test/kotlin/com/albert/realmoneyrealtaste/application/follow/provided/FollowTerminatorTest.kt @@ -2,7 +2,6 @@ package com.albert.realmoneyrealtaste.application.follow.provided import com.albert.realmoneyrealtaste.IntegrationTestBase import com.albert.realmoneyrealtaste.application.follow.dto.FollowCreateRequest -import com.albert.realmoneyrealtaste.application.follow.dto.UnfollowRequest import com.albert.realmoneyrealtaste.application.follow.event.UnfollowedEvent import com.albert.realmoneyrealtaste.application.follow.exception.UnfollowException import com.albert.realmoneyrealtaste.application.follow.required.FollowRepository @@ -45,11 +44,10 @@ class FollowTerminatorTest( applicationEvents.clear() // 언팔로우 - val request = UnfollowRequest( + followTerminator.unfollow( followerId = follower.requireId(), followingId = following.requireId() ) - followTerminator.unfollow(request) // 팔로우 관계 상태 확인 val updatedFollow = followRepository.findById(follow.requireId()) @@ -78,13 +76,12 @@ class FollowTerminatorTest( nickname = "existing" ) - val request = UnfollowRequest( - followerId = nonExistentFollowerId, - followingId = following.requireId() - ) assertFailsWith { - followTerminator.unfollow(request) + followTerminator.unfollow( + followerId = nonExistentFollowerId, + followingId = following.requireId() + ) }.let { assertEquals("언팔로우에 실패했습니다.", it.message) } @@ -101,13 +98,11 @@ class FollowTerminatorTest( nickname = "following" ) - val request = UnfollowRequest( - followerId = follower.requireId(), - followingId = following.requireId() - ) - assertFailsWith { - followTerminator.unfollow(request) + followTerminator.unfollow( + followerId = follower.requireId(), + followingId = following.requireId() + ) }.let { assertEquals("언팔로우에 실패했습니다.", it.message) } @@ -129,13 +124,11 @@ class FollowTerminatorTest( follow.unfollow() followRepository.save(follow) - val request = UnfollowRequest( - followerId = follower.requireId(), - followingId = following.requireId() - ) - assertFailsWith { - followTerminator.unfollow(request) + followTerminator.unfollow( + followerId = follower.requireId(), + followingId = following.requireId() + ) }.let { assertEquals("언팔로우에 실패했습니다.", it.message) } @@ -157,13 +150,11 @@ class FollowTerminatorTest( follow.block() followRepository.save(follow) - val request = UnfollowRequest( - followerId = follower.requireId(), - followingId = following.requireId() - ) - assertFailsWith { - followTerminator.unfollow(request) + followTerminator.unfollow( + followerId = follower.requireId(), + followingId = following.requireId() + ) }.let { assertEquals("언팔로우에 실패했습니다.", it.message) } @@ -179,15 +170,12 @@ class FollowTerminatorTest( email = "inactive-follower@test.com", nickname = "follower" ) - // 활성화하지 않음 - - val request = UnfollowRequest( - followerId = inactiveFollower.requireId(), - followingId = activeFollowing.requireId() - ) assertFailsWith { - followTerminator.unfollow(request) + followTerminator.unfollow( + followerId = inactiveFollower.requireId(), + followingId = activeFollowing.requireId() + ) }.let { assertEquals("언팔로우에 실패했습니다.", it.message) } @@ -207,11 +195,10 @@ class FollowTerminatorTest( val follow = createActiveFollow(follower.requireId(), following.requireId()) val originalUpdatedAt = follow.updatedAt - val request = UnfollowRequest( + followTerminator.unfollow( followerId = follower.requireId(), followingId = following.requireId() ) - followTerminator.unfollow(request) val updatedFollow = followRepository.findById(follow.requireId())!! assertAll( @@ -242,18 +229,16 @@ class FollowTerminatorTest( applicationEvents.clear() // 첫 번째 언팔로우 - val request1 = UnfollowRequest( + followTerminator.unfollow( followerId = follower.requireId(), - followingId = following1.requireId() + followingId = following1.requireId(), ) - followTerminator.unfollow(request1) // 두 번째 언팔로우 - val request2 = UnfollowRequest( + followTerminator.unfollow( followerId = follower.requireId(), - followingId = following2.requireId() + followingId = following2.requireId(), ) - followTerminator.unfollow(request2) // 두 팔로우 모두 언팔로우 상태 확인 val updatedFollow1 = followRepository.findById(follow1.requireId())!! @@ -288,11 +273,10 @@ class FollowTerminatorTest( val follow2 = createActiveFollow(follower2.requireId(), following.requireId()) // 첫 번째 팔로워만 언팔로우 - val request = UnfollowRequest( + followTerminator.unfollow( followerId = follower1.requireId(), - followingId = following.requireId() + followingId = following.requireId(), ) - followTerminator.unfollow(request) // 첫 번째는 언팔로우, 두 번째는 여전히 활성 상태 val updatedFollow1 = followRepository.findById(follow1.requireId())!! @@ -319,11 +303,10 @@ class FollowTerminatorTest( val follow2to1 = createActiveFollow(member2.requireId(), member1.requireId()) // member1이 member2를 언팔로우 - val request = UnfollowRequest( + followTerminator.unfollow( followerId = member1.requireId(), - followingId = member2.requireId() + followingId = member2.requireId(), ) - followTerminator.unfollow(request) // member1->member2는 언팔로우, member2->member1은 여전히 활성 val updatedFollow1to2 = followRepository.findById(follow1to2.requireId())!! diff --git a/src/test/kotlin/com/albert/realmoneyrealtaste/application/friend/dto/FriendshipResponseTest.kt b/src/test/kotlin/com/albert/realmoneyrealtaste/application/friend/dto/FriendshipResponseTest.kt index 7f2e0d21..502105cb 100644 --- a/src/test/kotlin/com/albert/realmoneyrealtaste/application/friend/dto/FriendshipResponseTest.kt +++ b/src/test/kotlin/com/albert/realmoneyrealtaste/application/friend/dto/FriendshipResponseTest.kt @@ -1,196 +1,108 @@ package com.albert.realmoneyrealtaste.application.friend.dto +import com.albert.realmoneyrealtaste.config.TestPasswordEncoder import com.albert.realmoneyrealtaste.domain.common.BaseEntity import com.albert.realmoneyrealtaste.domain.friend.Friendship import com.albert.realmoneyrealtaste.domain.friend.FriendshipStatus import com.albert.realmoneyrealtaste.domain.friend.command.FriendRequestCommand +import com.albert.realmoneyrealtaste.domain.member.Member +import com.albert.realmoneyrealtaste.domain.member.value.Email +import com.albert.realmoneyrealtaste.domain.member.value.Nickname +import com.albert.realmoneyrealtaste.domain.member.value.PasswordHash +import com.albert.realmoneyrealtaste.domain.member.value.RawPassword import org.junit.jupiter.api.Assertions.assertAll +import org.junit.jupiter.api.DisplayName import java.lang.reflect.Field import kotlin.test.Test import kotlin.test.assertEquals -import kotlin.test.assertNull - +import kotlin.test.assertNotNull + +/** + * FriendshipResponse DTO 매핑 테스트 + * + * 이 테스트는 단순한 필드 매핑이 아닌, null profileImageId를 0L로 변환하는 + * 중요한 비즈니스 로직을 검증하기 위해 유지됩니다. + * + * 참고: 프로젝트의 통합 테스트 우선 철학에 따라, 이 단위 테스트는 + * FriendshipReadService 통합 테스트에서 커버되지 않는 특정 비즈니스 로직만 검증합니다. + */ class FriendshipResponseTest { @Test + @DisplayName("from - success - creates response with all friendship information") fun `from - success - creates response with all friendship information`() { val fromMemberId = 1L + val member = Member.register( + nickname = Nickname("sender"), + email = Email("sender@example.com"), + password = PasswordHash.of(RawPassword("Password1!"), TestPasswordEncoder()), + ) + setMemberId(member, fromMemberId) + setMemberImageId(member, 123L) + val toMemberId = 2L - val friendNickname = "receiver" - val command = FriendRequestCommand(fromMemberId, toMemberId, friendNickname) + val friend = Member.register( + nickname = Nickname("receiver"), + email = Email("receiver@example.com"), + password = PasswordHash.of(RawPassword("Password1!"), TestPasswordEncoder()), + ) + setMemberId(friend, toMemberId) + setMemberImageId(friend, 456L) + + val command = FriendRequestCommand(fromMemberId, member.nickname.value, toMemberId, friend.nickname.value) val friendship = Friendship.request(command) // ID 설정 (실제로는 JPA가 수행) setFriendshipId(friendship, 100L) - val result = FriendshipResponse.from(friendship, friendNickname) + val result = FriendshipResponse.from(friendship, member, friend) assertAll( { assertEquals(100L, result.friendshipId) }, { assertEquals(fromMemberId, result.memberId) }, { assertEquals(toMemberId, result.friendMemberId) }, - { assertEquals(friendNickname, result.friendNickname) }, + { assertEquals("sender", result.memberNickname) }, + { assertEquals("receiver", result.friendNickname) }, + { assertEquals(123L, result.memberProfileImageId) }, + { assertEquals(456L, result.friendProfileImageId) }, { assertEquals(FriendshipStatus.PENDING, result.status) }, - { assertEquals(friendship.createdAt, result.createdAt) }, - { assertEquals(friendship.updatedAt, result.updatedAt) }, - { assertEquals(toMemberId, result.id) }, - { assertEquals(friendNickname, result.nickname) }, - { assertEquals(0, result.mutualFriendsCount) }, - { assertEquals(friendship.createdAt, result.friendSince) }, - { assertNull(result.profileImageUrl) } - ) - } - - @Test - fun `constructor - success - initializes all fields correctly`() { - val friendshipId = 200L - val memberId = 3L - val friendMemberId = 4L - val friendNickname = "bob" - val status = FriendshipStatus.ACCEPTED - val createdAt = friendshipId.let { java.time.LocalDateTime.now().minusDays(1) } - val updatedAt = friendshipId.let { java.time.LocalDateTime.now() } - - val response = FriendshipResponse( - friendshipId, - memberId, - friendMemberId, - friendNickname, - status, - createdAt, - updatedAt - ) - - assertAll( - { assertEquals(friendshipId, response.friendshipId) }, - { assertEquals(memberId, response.memberId) }, - { assertEquals(friendMemberId, response.friendMemberId) }, - { assertEquals(friendNickname, response.friendNickname) }, - { assertEquals(status, response.status) }, - { assertEquals(createdAt, response.createdAt) }, - { assertEquals(updatedAt, response.updatedAt) }, - { assertEquals(friendMemberId, response.id) }, - { assertEquals(friendNickname, response.nickname) }, - { assertEquals(0, response.mutualFriendsCount) }, - { assertEquals(createdAt, response.friendSince) }, - { assertNull(response.profileImageUrl) } + { assertNotNull(result.createdAt) }, + { assertNotNull(result.updatedAt) }, + { assertEquals(0, result.mutualFriendsCount) } ) } @Test - fun `from - success - includes optional parameters correctly`() { + @DisplayName("from - success - handles null profile image ids as zero") + fun `from - success - handles null profile image ids as zero`() { val fromMemberId = 1L - val toMemberId = 2L - val friendNickname = "receiver" - val command = FriendRequestCommand(fromMemberId, toMemberId, friendNickname) - val friendship = Friendship.request(command) - setFriendshipId(friendship, 100L) - - val mutualFriendsCount = 5 - val profileImageUrl = "https://example.com/profile.jpg" - - val result = FriendshipResponse.from( - friendship, - friendNickname, - mutualFriendsCount, - profileImageUrl + val member = Member.register( + nickname = Nickname("sender"), + email = Email("sender@example.com"), + password = PasswordHash.of(RawPassword("Password1!"), TestPasswordEncoder()), ) + setMemberId(member, fromMemberId) + // profileImageId를 설정하지 않음 (null 상태 유지) - assertAll( - { assertEquals(mutualFriendsCount, result.mutualFriendsCount) }, - { assertEquals(profileImageUrl, result.profileImageUrl) } - ) - } - - @Test - fun `from - success - works with different friendship statuses`() { - val fromMemberId = 1L val toMemberId = 2L - val friendNickname = "receiver" - - // 각 상태별로 테스트 - FriendshipStatus.values().forEach { status -> - val command = FriendRequestCommand(fromMemberId, toMemberId, friendNickname) - val friendship = Friendship.request(command) - setFriendshipId(friendship, 100L) - - // 상태 강제 설정 - setStatus(friendship, status) - - val result = FriendshipResponse.from(friendship, friendNickname) - - assertEquals(status, result.status, "Status should be $status") - } - } - - @Test - fun `constructor - success - custom template fields override defaults`() { - val friendshipId = 200L - val memberId = 3L - val friendMemberId = 4L - val friendNickname = "bob" - val status = FriendshipStatus.ACCEPTED - val createdAt = java.time.LocalDateTime.now().minusDays(1) - val updatedAt = java.time.LocalDateTime.now() - - val customId = 999L - val customNickname = "custom_nickname" - val customMutualFriendsCount = 10 - val customFriendSince = java.time.LocalDateTime.now().minusDays(2) - val customProfileImageUrl = "https://custom.example.com/image.jpg" - - val response = FriendshipResponse( - friendshipId = friendshipId, - memberId = memberId, - friendMemberId = friendMemberId, - friendNickname = friendNickname, - status = status, - createdAt = createdAt, - updatedAt = updatedAt, - id = customId, - nickname = customNickname, - mutualFriendsCount = customMutualFriendsCount, - friendSince = customFriendSince, - profileImageUrl = customProfileImageUrl + val friend = Member.register( + nickname = Nickname("receiver"), + email = Email("receiver@example.com"), + password = PasswordHash.of(RawPassword("Password1!"), TestPasswordEncoder()), ) + setMemberId(friend, toMemberId) + // profileImageId를 설정하지 않음 (null 상태 유지) - assertAll( - { assertEquals(customId, response.id) }, - { assertEquals(customNickname, response.nickname) }, - { assertEquals(customMutualFriendsCount, response.mutualFriendsCount) }, - { assertEquals(customFriendSince, response.friendSince) }, - { assertEquals(customProfileImageUrl, response.profileImageUrl) } - ) - } - - @Test - fun `from - success - handles long nicknames correctly`() { - val fromMemberId = 1L - val toMemberId = 2L - val longNickname = "a".repeat(100) // 매우 긴 닉네임 - val command = FriendRequestCommand(fromMemberId, toMemberId, longNickname) + val command = FriendRequestCommand(fromMemberId, member.nickname.value, toMemberId, friend.nickname.value) val friendship = Friendship.request(command) setFriendshipId(friendship, 100L) - val result = FriendshipResponse.from(friendship, longNickname) - - assertEquals(longNickname, result.friendNickname) - assertEquals(longNickname, result.nickname) - } + val result = FriendshipResponse.from(friendship, member, friend) - @Test - fun `from - success - handles special characters in nickname`() { - val fromMemberId = 1L - val toMemberId = 2L - val specialNickname = "특수문자_한글123!@#" - val command = FriendRequestCommand(fromMemberId, toMemberId, specialNickname) - val friendship = Friendship.request(command) - setFriendshipId(friendship, 100L) - - val result = FriendshipResponse.from(friendship, specialNickname) - - assertEquals(specialNickname, result.friendNickname) - assertEquals(specialNickname, result.nickname) + assertAll( + { assertEquals(0L, result.memberProfileImageId) }, + { assertEquals(0L, result.friendProfileImageId) } + ) } private fun setFriendshipId(friendship: Friendship, id: Long) { @@ -204,4 +116,20 @@ class FriendshipResponseTest { field.isAccessible = true field.set(friendship, status) } + + private fun setMemberId(member: Member, memberId: Long) { + val field: Field = BaseEntity::class.java.getDeclaredField("id") + field.isAccessible = true + field.set(member, memberId) + } + + private fun setMemberImageId(member: Member, imageId: Long) { + val detailField: Field = Member::class.java.getDeclaredField("detail") + detailField.isAccessible = true + val detail = detailField.get(member) + + val imageIdField: Field = detail.javaClass.getDeclaredField("imageId") + imageIdField.isAccessible = true + imageIdField.set(detail, imageId) + } } diff --git a/src/test/kotlin/com/albert/realmoneyrealtaste/application/friend/provided/FriendRequestorTest.kt b/src/test/kotlin/com/albert/realmoneyrealtaste/application/friend/provided/FriendRequestorTest.kt index b9ff4a07..c0d2ae19 100644 --- a/src/test/kotlin/com/albert/realmoneyrealtaste/application/friend/provided/FriendRequestorTest.kt +++ b/src/test/kotlin/com/albert/realmoneyrealtaste/application/friend/provided/FriendRequestorTest.kt @@ -11,6 +11,7 @@ import org.junit.jupiter.api.assertAll import org.springframework.beans.factory.annotation.Autowired import org.springframework.test.context.event.ApplicationEvents import org.springframework.test.context.event.RecordApplicationEvents +import java.time.LocalDateTime import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertFailsWith @@ -39,11 +40,10 @@ class FriendRequestorTest( email = "receiver@test.com", nickname = "receiver" ) - val command = FriendRequestCommand(fromMember.requireId(), toMember.requireId(), toMember.nickname.value) applicationEvents.clear() // when - val result = friendRequestor.sendFriendRequest(command) + val result = friendRequestor.sendFriendRequest(fromMember.requireId(), toMember.requireId()) // then assertAll( @@ -78,10 +78,9 @@ class FriendRequestorTest( email = "db-receiver@test.com", nickname = "dbreceiver" ) - val command = FriendRequestCommand(fromMember.requireId(), toMember.requireId(), toMember.nickname.value) // when - val result = friendRequestor.sendFriendRequest(command) + val result = friendRequestor.sendFriendRequest(fromMember.requireId(), toMember.requireId()) // then val saved = friendshipRepository.findById(result.requireId()) @@ -101,11 +100,10 @@ class FriendRequestorTest( email = "existing@test.com", nickname = "existing" ) - val command = FriendRequestCommand(nonExistentMemberId, toMember.requireId(), toMember.nickname.value) // when & then assertFailsWith { - friendRequestor.sendFriendRequest(command) + friendRequestor.sendFriendRequest(nonExistentMemberId, toMember.requireId()) }.let { assertEquals("친구 요청에 실패했습니다.", it.message) } @@ -119,11 +117,10 @@ class FriendRequestorTest( nickname = "existing2" ) val nonExistentMemberId = 999999L - val command = FriendRequestCommand(fromMember.requireId(), nonExistentMemberId, "unknown") // when & then assertFailsWith { - friendRequestor.sendFriendRequest(command) + friendRequestor.sendFriendRequest(fromMember.requireId(), nonExistentMemberId) }.let { assertEquals("친구 요청에 실패했습니다.", it.message) } @@ -142,16 +139,12 @@ class FriendRequestorTest( ) // 친구 요청 생성 후 수락 - val initialCommand = FriendRequestCommand(member1.requireId(), member2.requireId(), member2.nickname.value) - val friendship = friendRequestor.sendFriendRequest(initialCommand) + val friendship = friendRequestor.sendFriendRequest(member1.requireId(), member2.requireId()) friendship.accept() friendshipRepository.save(friendship) - // 이미 친구인 상태에서 다시 요청 시도 - val duplicateCommand = FriendRequestCommand(member1.requireId(), member2.requireId(), member2.nickname.value) - // when & then - val result = friendRequestor.sendFriendRequest(duplicateCommand) + val result = friendRequestor.sendFriendRequest(member1.requireId(), member2.requireId()) assertAll( { assertEquals(friendship.requireId(), result.requireId()) }, @@ -169,7 +162,7 @@ class FriendRequestorTest( ) assertFailsWith { - FriendRequestCommand(member.requireId(), member.requireId(), member.nickname.value) + FriendRequestCommand(member.requireId(), member.nickname.value, member.requireId(), member.nickname.value) } } @@ -186,14 +179,12 @@ class FriendRequestorTest( ) // 첫 번째 요청 후 거절 - val firstCommand = FriendRequestCommand(member1.requireId(), member2.requireId(), member2.nickname.value) - val firstFriendship = friendRequestor.sendFriendRequest(firstCommand) + val firstFriendship = friendRequestor.sendFriendRequest(member1.requireId(), member2.requireId()) firstFriendship.reject() friendshipRepository.save(firstFriendship) // 반대 방향 요청 (허용되어야 함) - val reverseCommand = FriendRequestCommand(member2.requireId(), member1.requireId(), member1.nickname.value) - val result = friendRequestor.sendFriendRequest(reverseCommand) + val result = friendRequestor.sendFriendRequest(member2.requireId(), member1.requireId()) assertAll( { assertNotNull(result) }, @@ -219,12 +210,9 @@ class FriendRequestorTest( nickname = "receiver2" ) - val command1 = FriendRequestCommand(sender.requireId(), receiver1.requireId(), receiver1.nickname.value) - val command2 = FriendRequestCommand(sender.requireId(), receiver2.requireId(), receiver2.nickname.value) - // when - val result1 = friendRequestor.sendFriendRequest(command1) - val result2 = friendRequestor.sendFriendRequest(command2) + val result1 = friendRequestor.sendFriendRequest(sender.requireId(), receiver1.requireId()) + val result2 = friendRequestor.sendFriendRequest(sender.requireId(), receiver2.requireId()) // then assertAll( @@ -248,12 +236,9 @@ class FriendRequestorTest( val inactiveMember = testMemberHelper.createMember() // 활성화하지 않음 (PENDING 상태) - val command = - FriendRequestCommand(activeMember.requireId(), inactiveMember.requireId(), inactiveMember.nickname.value) - // when & then assertFailsWith { - friendRequestor.sendFriendRequest(command) + friendRequestor.sendFriendRequest(activeMember.requireId(), inactiveMember.requireId()) }.let { assertEquals("친구 요청에 실패했습니다.", it.message) } @@ -270,12 +255,11 @@ class FriendRequestorTest( nickname = "tomember" ) - val command = FriendRequestCommand(fromMember.requireId(), toMember.requireId(), toMember.nickname.value) - val beforeCreation = java.time.LocalDateTime.now() + val beforeCreation = LocalDateTime.now() - val result = friendRequestor.sendFriendRequest(command) + val result = friendRequestor.sendFriendRequest(fromMember.requireId(), toMember.requireId()) - val afterCreation = java.time.LocalDateTime.now() + val afterCreation = LocalDateTime.now() assertAll( { assertNotNull(result.createdAt) }, @@ -297,8 +281,7 @@ class FriendRequestorTest( nickname = "tomember" ) - val command = FriendRequestCommand(fromMember.requireId(), toMember.requireId(), toMember.nickname.value) - val result = friendRequestor.sendFriendRequest(command) + val result = friendRequestor.sendFriendRequest(fromMember.requireId(), toMember.requireId()) flushAndClear() @@ -320,7 +303,7 @@ class FriendRequestorTest( ) assertFailsWith { - FriendRequestCommand(member.requireId(), member.requireId(), member.nickname.value) + FriendRequestCommand(member.requireId(), member.nickname.value, member.requireId(), member.nickname.value) } } @@ -336,14 +319,18 @@ class FriendRequestorTest( ) // 첫 번째 요청 후 거절 - val firstCommand = FriendRequestCommand(member1.requireId(), member2.requireId(), member2.nickname.value) - val firstFriendship = friendRequestor.sendFriendRequest(firstCommand) + val firstFriendship = friendRequestor.sendFriendRequest(member1.requireId(), member2.requireId()) firstFriendship.reject() friendshipRepository.save(firstFriendship) // 거절된 후 동일 방향으로 다시 요청 (허용되어야 함) - val secondCommand = FriendRequestCommand(member1.requireId(), member2.requireId(), member2.nickname.value) - val result = friendRequestor.sendFriendRequest(secondCommand) + val secondCommand = FriendRequestCommand( + member1.requireId(), + member1.nickname.value, + member2.requireId(), + member2.nickname.value + ) + val result = friendRequestor.sendFriendRequest(member1.requireId(), member2.requireId()) assertAll( { assertNotNull(result) }, diff --git a/src/test/kotlin/com/albert/realmoneyrealtaste/application/friend/provided/FriendResponderTest.kt b/src/test/kotlin/com/albert/realmoneyrealtaste/application/friend/provided/FriendResponderTest.kt index ac0bea65..9a972e39 100644 --- a/src/test/kotlin/com/albert/realmoneyrealtaste/application/friend/provided/FriendResponderTest.kt +++ b/src/test/kotlin/com/albert/realmoneyrealtaste/application/friend/provided/FriendResponderTest.kt @@ -5,7 +5,6 @@ import com.albert.realmoneyrealtaste.application.friend.dto.FriendResponseReques import com.albert.realmoneyrealtaste.application.friend.exception.FriendResponseException import com.albert.realmoneyrealtaste.application.friend.required.FriendshipRepository import com.albert.realmoneyrealtaste.domain.friend.FriendshipStatus -import com.albert.realmoneyrealtaste.domain.friend.command.FriendRequestCommand import com.albert.realmoneyrealtaste.domain.friend.event.FriendRequestAcceptedEvent import com.albert.realmoneyrealtaste.domain.friend.event.FriendRequestRejectedEvent import com.albert.realmoneyrealtaste.util.TestMemberHelper @@ -45,8 +44,7 @@ class FriendResponderTest( ) // 친구 요청 생성 - val command = FriendRequestCommand(sender.requireId(), receiver.requireId(), receiver.nickname.value) - val friendship = friendRequestor.sendFriendRequest(command) + val friendship = friendRequestor.sendFriendRequest(sender.requireId(), receiver.requireId()) flushAndClear() applicationEvents.clear() @@ -99,8 +97,7 @@ class FriendResponderTest( ) // 친구 요청 생성 - val command = FriendRequestCommand(sender.requireId(), receiver.requireId(), receiver.nickname.value) - val friendship = friendRequestor.sendFriendRequest(command) + val friendship = friendRequestor.sendFriendRequest(sender.requireId(), receiver.requireId()) flushAndClear() applicationEvents.clear() @@ -148,8 +145,7 @@ class FriendResponderTest( nickname = "receiver" ) - val command = FriendRequestCommand(sender.requireId(), receiver.requireId(), receiver.nickname.value) - val friendship = friendRequestor.sendFriendRequest(command) + val friendship = friendRequestor.sendFriendRequest(sender.requireId(), receiver.requireId()) val nonExistentMemberId = 999999L val request = FriendResponseRequest( @@ -201,8 +197,7 @@ class FriendResponderTest( nickname = "unauthorized" ) - val command = FriendRequestCommand(sender.requireId(), receiver.requireId(), receiver.nickname.value) - val friendship = friendRequestor.sendFriendRequest(command) + val friendship = friendRequestor.sendFriendRequest(sender.requireId(), receiver.requireId()) // 요청을 받지 않은 사람이 응답 시도 val request = FriendResponseRequest( @@ -229,8 +224,7 @@ class FriendResponderTest( nickname = "receiver" ) - val command = FriendRequestCommand(sender.requireId(), receiver.requireId(), receiver.nickname.value) - val friendship = friendRequestor.sendFriendRequest(command) + val friendship = friendRequestor.sendFriendRequest(sender.requireId(), receiver.requireId()) // 요청을 보낸 사람이 응답 시도 val request = FriendResponseRequest( @@ -257,8 +251,7 @@ class FriendResponderTest( nickname = "receiver" ) - val command = FriendRequestCommand(sender.requireId(), receiver.requireId(), receiver.nickname.value) - val friendship = friendRequestor.sendFriendRequest(command) + val friendship = friendRequestor.sendFriendRequest(sender.requireId(), receiver.requireId()) // 첫 번째 응답 (수락) val acceptRequest = FriendResponseRequest( @@ -293,8 +286,7 @@ class FriendResponderTest( nickname = "receiver" ) - val command = FriendRequestCommand(sender.requireId(), receiver.requireId(), receiver.nickname.value) - val friendship = friendRequestor.sendFriendRequest(command) + val friendship = friendRequestor.sendFriendRequest(sender.requireId(), receiver.requireId()) // 첫 번째 응답 (거절) val rejectRequest = FriendResponseRequest( @@ -329,8 +321,7 @@ class FriendResponderTest( nickname = "receiver" ) - val command = FriendRequestCommand(sender.requireId(), receiver.requireId(), receiver.nickname.value) - val friendship = friendRequestor.sendFriendRequest(command) + val friendship = friendRequestor.sendFriendRequest(sender.requireId(), receiver.requireId()) val request = FriendResponseRequest( friendshipId = friendship.requireId(), @@ -357,8 +348,7 @@ class FriendResponderTest( nickname = "receiver" ) - val command = FriendRequestCommand(sender.requireId(), receiver.requireId(), receiver.nickname.value) - val friendship = friendRequestor.sendFriendRequest(command) + val friendship = friendRequestor.sendFriendRequest(sender.requireId(), receiver.requireId()) // 수신자를 비활성화 (실제로는 불가능하지만 테스트를 위해) receiver.deactivate() @@ -388,8 +378,7 @@ class FriendResponderTest( nickname = "receiver" ) - val command = FriendRequestCommand(sender.requireId(), receiver.requireId(), receiver.nickname.value) - val friendship = friendRequestor.sendFriendRequest(command) + val friendship = friendRequestor.sendFriendRequest(sender.requireId(), receiver.requireId()) flushAndClear() val request = FriendResponseRequest( @@ -421,8 +410,7 @@ class FriendResponderTest( nickname = "receiver" ) - val command = FriendRequestCommand(sender.requireId(), receiver.requireId(), receiver.nickname.value) - val originalFriendship = friendRequestor.sendFriendRequest(command) + val originalFriendship = friendRequestor.sendFriendRequest(sender.requireId(), receiver.requireId()) val request = FriendResponseRequest( friendshipId = originalFriendship.requireId(), @@ -457,8 +445,7 @@ class FriendResponderTest( nickname = "receiver" ) - val command = FriendRequestCommand(sender.requireId(), receiver.requireId(), receiver.nickname.value) - val originalFriendship = friendRequestor.sendFriendRequest(command) + val originalFriendship = friendRequestor.sendFriendRequest(sender.requireId(), receiver.requireId()) val request = FriendResponseRequest( friendshipId = originalFriendship.requireId(), @@ -491,8 +478,7 @@ class FriendResponderTest( nickname = "receiver" ) - val command = FriendRequestCommand(sender.requireId(), receiver.requireId(), receiver.nickname.value) - val friendship = friendRequestor.sendFriendRequest(command) + val friendship = friendRequestor.sendFriendRequest(sender.requireId(), receiver.requireId()) friendship.status = FriendshipStatus.ACCEPTED // 친구 관계를 UNFRIENDED 상태로 변경 @@ -523,8 +509,7 @@ class FriendResponderTest( nickname = "receiver" ) - val command = FriendRequestCommand(sender.requireId(), receiver.requireId(), receiver.nickname.value) - val friendship = friendRequestor.sendFriendRequest(command) + val friendship = friendRequestor.sendFriendRequest(sender.requireId(), receiver.requireId()) val originalUpdatedAt = friendship.updatedAt // 잠시 대기하여 시간 차이 보장 diff --git a/src/test/kotlin/com/albert/realmoneyrealtaste/application/friend/provided/FriendshipReaderTest.kt b/src/test/kotlin/com/albert/realmoneyrealtaste/application/friend/provided/FriendshipReaderTest.kt index d8ddcf4e..0acbc50d 100644 --- a/src/test/kotlin/com/albert/realmoneyrealtaste/application/friend/provided/FriendshipReaderTest.kt +++ b/src/test/kotlin/com/albert/realmoneyrealtaste/application/friend/provided/FriendshipReaderTest.kt @@ -37,7 +37,12 @@ class FriendshipReaderTest( ) // 친구 관계 생성 - createAcceptedFriendship(member1.requireId(), member2.requireId(), member2.nickname.value) + createAcceptedFriendship( + member1.requireId(), + member1.nickname.value, + member2.requireId(), + member2.nickname.value + ) val result = friendshipReader.findActiveFriendship(member1.requireId(), member2.requireId()) @@ -61,8 +66,7 @@ class FriendshipReaderTest( ) // 친구 요청만 생성 (수락하지 않음) - val command = FriendRequestCommand(member1.requireId(), member2.requireId(), member2.nickname.value) - friendRequestor.sendFriendRequest(command) + friendRequestor.sendFriendRequest(member1.requireId(), member2.requireId()) val result = friendshipReader.findActiveFriendship(member1.requireId(), member2.requireId()) @@ -97,8 +101,7 @@ class FriendshipReaderTest( ) // 친구 요청 생성 (PENDING 상태) - val command = FriendRequestCommand(member1.requireId(), member2.requireId(), member2.nickname.value) - val friendship = friendRequestor.sendFriendRequest(command) + val friendship = friendRequestor.sendFriendRequest(member1.requireId(), member2.requireId()) val result = friendshipReader.sentedFriendRequest(member1.requireId(), member2.requireId()) @@ -121,8 +124,7 @@ class FriendshipReaderTest( ) // 친구 요청 생성 - val command = FriendRequestCommand(sender.requireId(), receiver.requireId(), receiver.nickname.value) - val friendship = friendRequestor.sendFriendRequest(command) + val friendship = friendRequestor.sendFriendRequest(sender.requireId(), receiver.requireId()) val result = friendshipReader.findPendingFriendshipReceived(receiver.requireId(), sender.requireId()) @@ -161,8 +163,7 @@ class FriendshipReaderTest( nickname = "byid2" ) - val command = FriendRequestCommand(member1.requireId(), member2.requireId(), member2.nickname.value) - val friendship = friendRequestor.sendFriendRequest(command) + val friendship = friendRequestor.sendFriendRequest(member1.requireId(), member2.requireId()) val result = friendshipReader.findFriendshipById(friendship.requireId()) @@ -216,10 +217,8 @@ class FriendshipReaderTest( ) // 두 개의 친구 요청 생성 - val command1 = FriendRequestCommand(sender1.requireId(), receiver.requireId(), receiver.nickname.value) - val command2 = FriendRequestCommand(sender2.requireId(), receiver.requireId(), receiver.nickname.value) - friendRequestor.sendFriendRequest(command1) - friendRequestor.sendFriendRequest(command2) + friendRequestor.sendFriendRequest(sender1.requireId(), receiver.requireId()) + friendRequestor.sendFriendRequest(sender2.requireId(), receiver.requireId()) val pageable = PageRequest.of(0, 10, Sort.by("createdAt").descending()) val result = friendshipReader.findPendingRequestsReceived(receiver.requireId(), pageable) @@ -248,10 +247,8 @@ class FriendshipReaderTest( ) // 두 개의 친구 요청 생성 - val command1 = FriendRequestCommand(sender.requireId(), receiver1.requireId(), receiver1.nickname.value) - val command2 = FriendRequestCommand(sender.requireId(), receiver2.requireId(), receiver2.nickname.value) - friendRequestor.sendFriendRequest(command1) - friendRequestor.sendFriendRequest(command2) + friendRequestor.sendFriendRequest(sender.requireId(), receiver1.requireId()) + friendRequestor.sendFriendRequest(sender.requireId(), receiver2.requireId()) val pageable = PageRequest.of(0, 10, Sort.by("createdAt").descending()) val result = friendshipReader.findPendingRequestsSent(sender.requireId(), pageable) @@ -274,9 +271,7 @@ class FriendshipReaderTest( nickname = "exists2" ) - val friendRequestCommand = - FriendRequestCommand(member1.requireId(), member2.requireId(), member2.nickname.value) - val friendship = friendRequestor.sendFriendRequest(friendRequestCommand) + val friendship = friendRequestor.sendFriendRequest(member1.requireId(), member2.requireId()) val friendResponseRequest = FriendResponseRequest(friendship.requireId(), member2.requireId(), true) friendResponder.respondToFriendRequest(friendResponseRequest) @@ -317,7 +312,12 @@ class FriendshipReaderTest( } friends.forEach { friend -> - createAcceptedFriendship(friend.requireId(), member.requireId(), member.nickname.value) + createAcceptedFriendship( + friend.requireId(), + friend.nickname.value, + member.requireId(), + member.nickname.value + ) } // 페이지 크기 2로 첫 번째 페이지 조회 @@ -354,9 +354,8 @@ class FriendshipReaderTest( ) // 하나는 수락, 하나는 대기 상태로 유지 - createAcceptedFriendship(friend1.requireId(), member.requireId(), member.nickname.value) - val command2 = FriendRequestCommand(friend2.requireId(), member.requireId(), member.nickname.value) - friendRequestor.sendFriendRequest(command2) + createAcceptedFriendship(friend1.requireId(), friend1.nickname.value, member.requireId(), member.nickname.value) + friendRequestor.sendFriendRequest(friend2.requireId(), member.requireId()) val pageable = PageRequest.of(0, 10) val result = friendshipReader.findFriendsByMemberId(member.requireId(), pageable) @@ -367,9 +366,14 @@ class FriendshipReaderTest( ) } - private fun createAcceptedFriendship(fromMemberId: Long, toMemberId: Long, toMemberNickname: String): Friendship { - val command = FriendRequestCommand(fromMemberId, toMemberId, toMemberNickname) - val friendship = friendRequestor.sendFriendRequest(command) + private fun createAcceptedFriendship( + fromMemberId: Long, + fromMemberNickname: String, + toMemberId: Long, + toMemberNickname: String, + ): Friendship { + FriendRequestCommand(fromMemberId, fromMemberNickname, toMemberId, toMemberNickname) + val friendship = friendRequestor.sendFriendRequest(fromMemberId, toMemberId) val response = FriendResponseRequest( friendshipId = friendship.requireId(), @@ -379,46 +383,6 @@ class FriendshipReaderTest( return friendResponder.respondToFriendRequest(response) } - @Test - fun `mapToFriendshipResponses - success - handles all unknown members gracefully`() { - val member = testMemberHelper.createActivatedMember( - email = "known-member@test.com", - nickname = "member" - ) - val activeFriend = testMemberHelper.createActivatedMember( - email = "activeFriend@test.com", - nickname = "activeFriend" - ) - val deactivateFriend1 = testMemberHelper.createActivatedMember( - email = "deactivateFriend1@test.com", - nickname = "deactivate1", - ) - val deactivateFriend2 = testMemberHelper.createActivatedMember( - email = "deactivateFriend2@test.com", - nickname = "deactivate2", - ) - - createAcceptedFriendship(activeFriend.requireId(), member.requireId(), member.nickname.value) - createAcceptedFriendship(deactivateFriend1.requireId(), member.requireId(), member.nickname.value) - createAcceptedFriendship(deactivateFriend2.requireId(), member.requireId(), member.nickname.value) - - // 친구들을 비활성화 - deactivateFriend1.deactivate() - deactivateFriend2.deactivate() - member.deactivate() - - val pageable = PageRequest.of(0, 10) - val result = friendshipReader.findFriendsByMemberId(member.requireId(), pageable) - - assertAll( - { assertEquals(3, result.totalElements) }, - { assertEquals(3, result.content.size) }, - { assertEquals(result.content.count { it.friendMemberId == activeFriend.requireId() }, 1) }, - { assertEquals(result.content.count { it.friendMemberId == deactivateFriend1.requireId() }, 1) }, - { assertEquals(result.content.count { it.friendMemberId == deactivateFriend2.requireId() }, 1) }, - ) - } - @Test fun `countFriendsByMemberId - success - returns correct friend count`() { val member = testMemberHelper.createActivatedMember( @@ -433,7 +397,12 @@ class FriendshipReaderTest( } friends.forEach { friend -> - createAcceptedFriendship(friend.requireId(), member.requireId(), member.nickname.value) + createAcceptedFriendship( + friend.requireId(), + friend.nickname.value, + member.requireId(), + member.nickname.value + ) } val result = friendshipReader.countFriendsByMemberId(member.requireId()) @@ -469,9 +438,8 @@ class FriendshipReaderTest( ) // 하나는 수락, 하나는 대기 상태 - createAcceptedFriendship(friend1.requireId(), member.requireId(), member.nickname.value) - val command2 = FriendRequestCommand(friend2.requireId(), member.requireId(), member.nickname.value) - friendRequestor.sendFriendRequest(command2) + createAcceptedFriendship(friend1.requireId(), friend1.nickname.value, member.requireId(), member.nickname.value) + friendRequestor.sendFriendRequest(friend2.requireId(), member.requireId()) val result = friendshipReader.countFriendsByMemberId(member.requireId()) @@ -497,10 +465,25 @@ class FriendshipReaderTest( nickname = "other" ) - createAcceptedFriendship(targetFriend1.requireId(), member.requireId(), member.nickname.value) - createAcceptedFriendship(targetFriend2.requireId(), member.requireId(), member.nickname.value) - createAcceptedFriendship(otherFriend.requireId(), member.requireId(), member.nickname.value) - val result1 = friendshipReader.findFriendsByMemberId(member.requireId(), PageRequest.of(0, 10)) + createAcceptedFriendship( + targetFriend1.requireId(), + targetFriend1.nickname.value, + member.requireId(), + member.nickname.value + ) + createAcceptedFriendship( + targetFriend2.requireId(), + targetFriend1.nickname.value, + member.requireId(), + member.nickname.value + ) + createAcceptedFriendship( + otherFriend.requireId(), + otherFriend.nickname.value, + member.requireId(), + member.nickname.value + ) + friendshipReader.findFriendsByMemberId(member.requireId(), PageRequest.of(0, 10)) val pageable = PageRequest.of(0, 10) val result = friendshipReader.searchFriends(member.requireId(), "search", pageable) @@ -523,7 +506,7 @@ class FriendshipReaderTest( nickname = "friend" ) - createAcceptedFriendship(friend.requireId(), member.requireId(), member.nickname.value) + createAcceptedFriendship(friend.requireId(), friend.nickname.value, member.requireId(), member.nickname.value) val pageable = PageRequest.of(0, 10) val result = friendshipReader.searchFriends(member.requireId(), "nonexistent", pageable) @@ -546,7 +529,12 @@ class FriendshipReaderTest( } friends.forEach { friend -> - createAcceptedFriendship(friend.requireId(), member.requireId(), member.nickname.value) + createAcceptedFriendship( + friend.requireId(), + friend.nickname.value, + member.requireId(), + member.nickname.value + ) } val pageable = PageRequest.of(0, 10) @@ -576,11 +564,11 @@ class FriendshipReaderTest( nickname = "recent3" ) - createAcceptedFriendship(friend1.requireId(), member.requireId(), member.nickname.value) + createAcceptedFriendship(friend1.requireId(), friend1.nickname.value, member.requireId(), member.nickname.value) Thread.sleep(100) // 시간 차이 보장 - createAcceptedFriendship(friend2.requireId(), member.requireId(), member.nickname.value) + createAcceptedFriendship(friend2.requireId(), friend2.nickname.value, member.requireId(), member.nickname.value) Thread.sleep(100) - createAcceptedFriendship(friend3.requireId(), member.requireId(), member.nickname.value) + createAcceptedFriendship(friend3.requireId(), friend3.nickname.value, member.requireId(), member.nickname.value) val pageable = PageRequest.of(0, 2, Sort.by("createdAt").descending()) val result = friendshipReader.findRecentFriends(member.requireId(), pageable) @@ -622,8 +610,7 @@ class FriendshipReaderTest( // 받은 친구 요청 생성 senders.forEach { sender -> - val command = FriendRequestCommand(sender.requireId(), member.requireId(), member.nickname.value) - friendRequestor.sendFriendRequest(command) + friendRequestor.sendFriendRequest(sender.requireId(), member.requireId()) } val result = friendshipReader.countPendingRequests(member.requireId()) @@ -659,9 +646,8 @@ class FriendshipReaderTest( ) // 하나는 대기 상태, 하나는 수락 - val command1 = FriendRequestCommand(sender1.requireId(), member.requireId(), member.nickname.value) - friendRequestor.sendFriendRequest(command1) - createAcceptedFriendship(sender2.requireId(), member.requireId(), member.nickname.value) + friendRequestor.sendFriendRequest(sender1.requireId(), member.requireId()) + createAcceptedFriendship(sender2.requireId(), sender2.nickname.value, member.requireId(), member.nickname.value) val result = friendshipReader.countPendingRequests(member.requireId()) @@ -682,8 +668,7 @@ class FriendshipReaderTest( } val friendships = senders.map { sender -> - val command = FriendRequestCommand(sender.requireId(), member.requireId(), member.nickname.value) - friendRequestor.sendFriendRequest(command) + friendRequestor.sendFriendRequest(sender.requireId(), member.requireId()) } val pageable = PageRequest.of(0, 10) @@ -695,7 +680,7 @@ class FriendshipReaderTest( { assertTrue(result.content.all { it.status == FriendshipStatus.PENDING }) }, { assertTrue(result.content.all { friendship -> - friendships.any { it.requireId() == friendship.requireId() } + friendships.any { it.requireId() == friendship.friendshipId } }) } ) @@ -716,4 +701,278 @@ class FriendshipReaderTest( assertNull(result) } + + @Test + fun `isSent - success - returns true when member sent friend request`() { + val sender = testMemberHelper.createActivatedMember( + email = "sent-sender@test.com", + nickname = "sender" + ) + val receiver = testMemberHelper.createActivatedMember( + email = "sent-receiver@test.com", + nickname = "receiver" + ) + + // 친구 요청 보내기 + friendRequestor.sendFriendRequest(sender.requireId(), receiver.requireId()) + + val result = friendshipReader.isSent(sender.requireId(), receiver.requireId()) + + assertTrue(result) + } + + @Test + fun `isSent - success - returns false when no friend request was sent`() { + val member1 = testMemberHelper.createActivatedMember( + email = "nosent1@test.com", + nickname = "nosent1" + ) + val member2 = testMemberHelper.createActivatedMember( + email = "nosent2@test.com", + nickname = "nosent2" + ) + + val result = friendshipReader.isSent(member1.requireId(), member2.requireId()) + + assertFalse(result) + } + + @Test + fun `isSent - success - returns false when member received friend request`() { + val sender = testMemberHelper.createActivatedMember( + email = "reverse-sender@test.com", + nickname = "sender" + ) + val receiver = testMemberHelper.createActivatedMember( + email = "reverse-receiver@test.com", + nickname = "receiver" + ) + + // sender가 receiver에게 요청 보냄 + friendRequestor.sendFriendRequest(sender.requireId(), receiver.requireId()) + + // receiver가 sender에게 요청을 보냈는지 확인 (false여야 함) + val result = friendshipReader.isSent(receiver.requireId(), sender.requireId()) + + assertFalse(result) + } + + @Test + fun `isSent - success - returns true when friendship is accepted`() { + val member1 = testMemberHelper.createActivatedMember( + email = "accepted-sent1@test.com", + nickname = "accepted1" + ) + val member2 = testMemberHelper.createActivatedMember( + email = "accepted-sent2@test.com", + nickname = "accepted2" + ) + + // 친구 관계 생성 및 수락 + createAcceptedFriendship( + member1.requireId(), + member1.nickname.value, + member2.requireId(), + member2.nickname.value + ) + + val result = friendshipReader.isSent(member1.requireId(), member2.requireId()) + + assertTrue(result) + } + + @Test + fun `findFriendsByMemberId - success - excludes friendships with deactivated members`() { + val activeMember = testMemberHelper.createActivatedMember( + email = "active@test.com", + nickname = "active" + ) + val deactivatedMember = testMemberHelper.createActivatedMember( + email = "deactivated@test.com", + nickname = "deactivated" + ) + val anotherActiveMember = testMemberHelper.createActivatedMember( + email = "another@test.com", + nickname = "another" + ) + + // 친구 관계 생성 + createAcceptedFriendship( + activeMember.requireId(), + activeMember.nickname.value, + deactivatedMember.requireId(), + deactivatedMember.nickname.value + ) + createAcceptedFriendship( + activeMember.requireId(), + activeMember.nickname.value, + anotherActiveMember.requireId(), + anotherActiveMember.nickname.value + ) + deactivatedMember.deactivate() + flushAndClear() + + val pageable = PageRequest.of(0, 10) + val result = friendshipReader.findFriendsByMemberId(activeMember.requireId(), pageable) + + // 비활성화된 멤버와의 친구 관계는 제외되어야 함 + assertAll( + { assertEquals(1, result.totalElements) }, + { assertEquals(1, result.content.size) }, + { assertEquals(anotherActiveMember.requireId(), result.content.first().friendMemberId) }, + { assertFalse(result.content.any { it.friendMemberId == deactivatedMember.requireId() }) } + ) + } + + @Test + fun `findPendingRequestsReceived - success - excludes requests from deactivated members`() { + val receiver = testMemberHelper.createActivatedMember( + email = "receiver@test.com", + nickname = "receiver" + ) + val deactivatedSender = testMemberHelper.createActivatedMember( + email = "deactivated@test.com", + nickname = "deactivated" + ) + val activeSender = testMemberHelper.createActivatedMember( + email = "active@test.com", + nickname = "active" + ) + + // 친구 요청 생성 + friendRequestor.sendFriendRequest(deactivatedSender.requireId(), receiver.requireId()) + friendRequestor.sendFriendRequest(activeSender.requireId(), receiver.requireId()) + deactivatedSender.deactivate() + flushAndClear() + + val pageable = PageRequest.of(0, 10) + val result = friendshipReader.findPendingRequestsReceived(receiver.requireId(), pageable) + + // 비활성화된 멤버로부터의 요청은 제외되어야 함 + assertAll( + { assertEquals(1, result.totalElements) }, + { assertEquals(1, result.content.size) }, + { assertEquals(activeSender.requireId(), result.content.first().memberId) }, + { assertFalse(result.content.any { it.memberId == deactivatedSender.requireId() }) } + ) + } + + @Test + fun `findPendingRequestsSent - success - excludes requests to deactivated members`() { + val sender = testMemberHelper.createActivatedMember( + email = "sender@test.com", + nickname = "sender" + ) + val deactivatedReceiver = testMemberHelper.createActivatedMember( + email = "deactivated@test.com", + nickname = "deactivated" + ) + val activeReceiver = testMemberHelper.createActivatedMember( + email = "active@test.com", + nickname = "active" + ) + + // 친구 요청 생성 + friendRequestor.sendFriendRequest(sender.requireId(), deactivatedReceiver.requireId()) + friendRequestor.sendFriendRequest(sender.requireId(), activeReceiver.requireId()) + deactivatedReceiver.deactivate() + flushAndClear() + + val pageable = PageRequest.of(0, 10) + val result = friendshipReader.findPendingRequestsSent(sender.requireId(), pageable) + + // 비활성화된 멤버에게 보낸 요청은 제외되어야 함 + assertAll( + { assertEquals(1, result.totalElements) }, + { assertEquals(1, result.content.size) }, + { assertEquals(activeReceiver.requireId(), result.content.first().friendMemberId) }, + { assertFalse(result.content.any { it.friendMemberId == deactivatedReceiver.requireId() }) } + ) + } + + @Test + fun `searchFriends - success - excludes deactivated friends from search results`() { + val member = testMemberHelper.createActivatedMember( + email = "searcher@test.com", + nickname = "searcher" + ) + val deactivatedFriend = testMemberHelper.createActivatedMember( + email = "deactivated@test.com", + nickname = "deactivatedfriend" + ) + val activeFriend = testMemberHelper.createActivatedMember( + email = "active@test.com", + nickname = "activefriend" + ) + + // 친구 관계 생성 + createAcceptedFriendship( + member.requireId(), + member.nickname.value, + deactivatedFriend.requireId(), + deactivatedFriend.nickname.value + ) + createAcceptedFriendship( + member.requireId(), + member.nickname.value, + activeFriend.requireId(), + activeFriend.nickname.value + ) + deactivatedFriend.deactivate() + flushAndClear() + + val pageable = PageRequest.of(0, 10) + val result = friendshipReader.searchFriends(member.requireId(), "active", pageable) + + // 비활성화된 친구는 검색 결과에서 제외되어야 함 + assertAll( + { assertEquals(1, result.totalElements) }, + { assertEquals(1, result.content.size) }, + { assertEquals(activeFriend.requireId(), result.content.first().friendMemberId) }, + { assertFalse(result.content.any { it.friendMemberId == deactivatedFriend.requireId() }) } + ) + } + + @Test + fun `findRecentFriends - success - excludes deactivated friends from recent friends`() { + val member = testMemberHelper.createActivatedMember( + email = "recent@test.com", + nickname = "recent" + ) + val deactivatedFriend = testMemberHelper.createActivatedMember( + email = "deactivated@test.com", + nickname = "deactivated" + ) + val activeFriend = testMemberHelper.createActivatedMember( + email = "active@test.com", + nickname = "active" + ) + + // 친구 관계 생성 + createAcceptedFriendship( + member.requireId(), + member.nickname.value, + deactivatedFriend.requireId(), + deactivatedFriend.nickname.value + ) + createAcceptedFriendship( + member.requireId(), + member.nickname.value, + activeFriend.requireId(), + activeFriend.nickname.value + ) + deactivatedFriend.deactivate() + flushAndClear() + + val pageable = PageRequest.of(0, 10) + val result = friendshipReader.findRecentFriends(member.requireId(), pageable) + + // 비활성화된 친구는 최근 친구 목록에서 제외되어야 함 + assertAll( + { assertEquals(1, result.totalElements) }, + { assertEquals(1, result.content.size) }, + { assertEquals(activeFriend.requireId(), result.content.first().friendMemberId) }, + { assertFalse(result.content.any { it.friendMemberId == deactivatedFriend.requireId() }) } + ) + } } diff --git a/src/test/kotlin/com/albert/realmoneyrealtaste/application/friend/provided/FriendshipTerminatorTest.kt b/src/test/kotlin/com/albert/realmoneyrealtaste/application/friend/provided/FriendshipTerminatorTest.kt index 227a0fec..906d26b3 100644 --- a/src/test/kotlin/com/albert/realmoneyrealtaste/application/friend/provided/FriendshipTerminatorTest.kt +++ b/src/test/kotlin/com/albert/realmoneyrealtaste/application/friend/provided/FriendshipTerminatorTest.kt @@ -7,7 +7,6 @@ import com.albert.realmoneyrealtaste.application.friend.exception.UnfriendExcept import com.albert.realmoneyrealtaste.application.friend.required.FriendshipRepository import com.albert.realmoneyrealtaste.domain.friend.Friendship import com.albert.realmoneyrealtaste.domain.friend.FriendshipStatus -import com.albert.realmoneyrealtaste.domain.friend.command.FriendRequestCommand import com.albert.realmoneyrealtaste.domain.friend.event.FriendshipTerminatedEvent import com.albert.realmoneyrealtaste.util.TestMemberHelper import io.mockk.every @@ -261,8 +260,7 @@ class FriendshipTerminatorTest( ) // 친구 요청만 생성 (수락하지 않음) - val command = FriendRequestCommand(sender.requireId(), receiver.requireId(), receiver.nickname.value) - friendRequestor.sendFriendRequest(command) + friendRequestor.sendFriendRequest(sender.requireId(), receiver.requireId()) // PENDING 상태의 친구 요청에 대해 해제 시도 val request = UnfriendRequest( @@ -310,8 +308,7 @@ class FriendshipTerminatorTest( ) // 친구 요청 생성 후 거절 - val command = FriendRequestCommand(sender.requireId(), receiver.requireId(), receiver.nickname.value) - val friendship = friendRequestor.sendFriendRequest(command) + val friendship = friendRequestor.sendFriendRequest(sender.requireId(), receiver.requireId()) val response = FriendResponseRequest( friendshipId = friendship.requireId(), respondentMemberId = receiver.requireId(), @@ -478,8 +475,7 @@ class FriendshipTerminatorTest( private fun createAcceptedFriendship(fromMemberId: Long, toMemberId: Long): Friendship { // 친구 요청 생성 - val command = FriendRequestCommand(fromMemberId, toMemberId, "testUser") - val friendship = friendRequestor.sendFriendRequest(command) + val friendship = friendRequestor.sendFriendRequest(fromMemberId, toMemberId) // 친구 요청 수락 val response = FriendResponseRequest( diff --git a/src/test/kotlin/com/albert/realmoneyrealtaste/application/image/dto/PresignedPutResponseTest.kt b/src/test/kotlin/com/albert/realmoneyrealtaste/application/image/dto/PresignedPutResponseTest.kt new file mode 100644 index 00000000..3685702a --- /dev/null +++ b/src/test/kotlin/com/albert/realmoneyrealtaste/application/image/dto/PresignedPutResponseTest.kt @@ -0,0 +1,415 @@ +package com.albert.realmoneyrealtaste.application.image.dto + +import com.fasterxml.jackson.core.JsonProcessingException +import com.fasterxml.jackson.databind.ObjectMapper +import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule +import com.fasterxml.jackson.module.kotlin.KotlinModule +import com.fasterxml.jackson.module.kotlin.readValue +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import java.time.Instant +import java.time.temporal.ChronoUnit +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertTrue + +class PresignedPutResponseTest { + + private lateinit var objectMapper: ObjectMapper + private lateinit var fixedInstant: Instant + + @BeforeEach + fun setUp() { + objectMapper = ObjectMapper() + .registerModules(KotlinModule.Builder().build(), JavaTimeModule()) + + // 테스트 재현성을 위해 고정된 시간 사용 + fixedInstant = Instant.parse("2024-01-15T10:30:00Z") + } + + @Test + fun `constructor - success - creates valid response`() { + // Given + val uploadUrl = "https://bucket.s3.amazonaws.com/test-key.jpg" + val key = "images/2024/01/15/uuid-test.jpg" + val metadata = mapOf( + "contentType" to "image/jpeg", + "originalName" to "test.jpg" + ) + + // When + val response = PresignedPutResponse( + uploadUrl = uploadUrl, + key = key, + expiresAt = fixedInstant, + metadata = metadata + ) + + // Then + assertEquals(uploadUrl, response.uploadUrl) + assertEquals(key, response.key) + assertEquals(fixedInstant, response.expiresAt) + assertEquals(metadata, response.metadata) + } + + @Test + fun `constructor - success - handles empty metadata`() { + // Given + val uploadUrl = "https://bucket.s3.amazonaws.com/test-key.jpg" + val key = "images/test.jpg" + val emptyMetadata = emptyMap() + + // When + val response = PresignedPutResponse( + uploadUrl = uploadUrl, + key = key, + expiresAt = fixedInstant, + metadata = emptyMetadata + ) + + // Then + assertEquals(uploadUrl, response.uploadUrl) + assertEquals(key, response.key) + assertEquals(fixedInstant, response.expiresAt) + assertEquals(emptyMetadata, response.metadata) + assertTrue(response.metadata.isEmpty()) + } + + @Test + fun `JSON serialization - success - serializes to correct format`() { + // Given + val response = PresignedPutResponse( + uploadUrl = "https://bucket.s3.amazonaws.com/test-key.jpg", + key = "images/2024/01/15/uuid-test.jpg", + expiresAt = fixedInstant, + metadata = mapOf( + "contentType" to "image/jpeg", + "originalName" to "test.jpg" + ) + ) + + // When + val json = objectMapper.writeValueAsString(response) + + // Then + assertTrue(json.contains("\"uploadUrl\":\"https://bucket.s3.amazonaws.com/test-key.jpg\"")) + assertTrue(json.contains("\"key\":\"images/2024/01/15/uuid-test.jpg\"")) + assertTrue(json.contains("\"expiresAt\":\"2024-01-15T10:30:00\"")) + assertTrue(json.contains("\"metadata\"")) + assertTrue(json.contains("\"contentType\":\"image/jpeg\"")) + assertTrue(json.contains("\"originalName\":\"test.jpg\"")) + } + + @Test + fun `JSON deserialization - success - deserializes from valid JSON`() { + // Given + val json = """ + { + "uploadUrl": "https://bucket.s3.amazonaws.com/test-key.jpg", + "key": "images/2024/01/15/uuid-test.jpg", + "expiresAt": "2024-01-15T10:30:00", + "metadata": { + "contentType": "image/jpeg", + "originalName": "test.jpg" + } + } + """.trimIndent() + + // When + val response = objectMapper.readValue(json) + + // Then + assertEquals("https://bucket.s3.amazonaws.com/test-key.jpg", response.uploadUrl) + assertEquals("images/2024/01/15/uuid-test.jpg", response.key) + assertEquals(fixedInstant, response.expiresAt) + assertEquals(2, response.metadata.size) + assertEquals("image/jpeg", response.metadata["contentType"]) + assertEquals("test.jpg", response.metadata["originalName"]) + } + + @Test + fun `JSON deserialization - success - handles empty metadata`() { + // Given + val json = """ + { + "uploadUrl": "https://bucket.s3.amazonaws.com/test-key.jpg", + "key": "images/test.jpg", + "expiresAt": "2024-01-15T10:30:00", + "metadata": {} + } + """.trimIndent() + + // When + val response = objectMapper.readValue(json) + + // Then + assertEquals("https://bucket.s3.amazonaws.com/test-key.jpg", response.uploadUrl) + assertEquals("images/test.jpg", response.key) + assertEquals(fixedInstant, response.expiresAt) + assertTrue(response.metadata.isEmpty()) + } + + @Test + fun `JSON deserialization - failure - throws exception for missing required fields`() { + // Given + val jsonWithoutUploadUrl = """ + { + "key": "images/test.jpg", + "expiresAt": "2024-01-15T10:30:00Z", + "metadata": {} + } + """.trimIndent() + + val jsonWithoutKey = """ + { + "uploadUrl": "https://bucket.s3.amazonaws.com/test-key.jpg", + "expiresAt": "2024-01-15T10:30:00Z", + "metadata": {} + } + """.trimIndent() + + val jsonWithoutExpiresAt = """ + { + "uploadUrl": "https://bucket.s3.amazonaws.com/test-key.jpg", + "key": "images/test.jpg", + "metadata": {} + } + """.trimIndent() + + // When & Then + assertFailsWith { + objectMapper.readValue(jsonWithoutUploadUrl) + } + + assertFailsWith { + objectMapper.readValue(jsonWithoutKey) + } + + assertFailsWith { + objectMapper.readValue(jsonWithoutExpiresAt) + } + } + + @Test + fun `equals and hashCode - success - works correctly`() { + // Given + val response1 = PresignedPutResponse( + uploadUrl = "https://bucket.s3.amazonaws.com/test-key.jpg", + key = "images/test.jpg", + expiresAt = fixedInstant, + metadata = mapOf("contentType" to "image/jpeg") + ) + + val response2 = PresignedPutResponse( + uploadUrl = "https://bucket.s3.amazonaws.com/test-key.jpg", + key = "images/test.jpg", + expiresAt = fixedInstant, + metadata = mapOf("contentType" to "image/jpeg") + ) + + val response3 = PresignedPutResponse( + uploadUrl = "https://bucket.s3.amazonaws.com/other-key.jpg", + key = "images/other.jpg", + expiresAt = fixedInstant, + metadata = mapOf("contentType" to "image/png") + ) + + // When & Then + assertEquals(response1, response2) + assertEquals(response1.hashCode(), response2.hashCode()) + assertEquals(response1, response1) // 자기 자신과 비교 + + assertTrue(response1 != response3) + assertTrue(response1.hashCode() != response3.hashCode()) + } + + @Test + fun `toString - success - generates readable string representation`() { + // Given + val response = PresignedPutResponse( + uploadUrl = "https://bucket.s3.amazonaws.com/test-key.jpg", + key = "images/test.jpg", + expiresAt = fixedInstant, + metadata = mapOf("contentType" to "image/jpeg") + ) + + // When + val stringRepresentation = response.toString() + + // Then + assertTrue(stringRepresentation.contains("PresignedPutResponse")) + assertTrue(stringRepresentation.contains("uploadUrl=https://bucket.s3.amazonaws.com/test-key.jpg")) + assertTrue(stringRepresentation.contains("key=images/test.jpg")) + assertTrue(stringRepresentation.contains("expiresAt=2024-01-15T10:30:00Z")) + assertTrue(stringRepresentation.contains("metadata={contentType=image/jpeg}")) + } + + @Test + fun `copy - success - creates copy with modified fields`() { + // Given + val original = PresignedPutResponse( + uploadUrl = "https://bucket.s3.amazonaws.com/test-key.jpg", + key = "images/test.jpg", + expiresAt = fixedInstant, + metadata = mapOf("contentType" to "image/jpeg") + ) + + // When + val copied = original.copy( + uploadUrl = "https://bucket.s3.amazonaws.com/new-key.jpg", + metadata = mapOf("contentType" to "image/png") + ) + + // Then + assertEquals("https://bucket.s3.amazonaws.com/new-key.jpg", copied.uploadUrl) + assertEquals("images/test.jpg", copied.key) // 변경되지 않은 필드 + assertEquals(fixedInstant, copied.expiresAt) // 변경되지 않은 필드 + assertEquals(mapOf("contentType" to "image/png"), copied.metadata) + + // 원본은 변경되지 않음 + assertEquals("https://bucket.s3.amazonaws.com/test-key.jpg", original.uploadUrl) + assertEquals(mapOf("contentType" to "image/jpeg"), original.metadata) + } + + @Test + fun `metadata - success - handles special characters and values`() { + // Given + val specialMetadata = mapOf( + "empty" to "", + "spaces" to "value with spaces", + "special" to "!@#$%^&*()_+-=[]{}|;':\",./<>?", + "unicode" to "한글 🖼️ émoji", + "number" to "12345", + "json-like" to "{\"key\": \"value\"}", + "url" to "https://example.com/path?query=value&other=test" + ) + + // When + val response = PresignedPutResponse( + uploadUrl = "https://bucket.s3.amazonaws.com/test-key.jpg", + key = "images/test.jpg", + expiresAt = fixedInstant, + metadata = specialMetadata + ) + + // Then + assertEquals(specialMetadata, response.metadata) + + // JSON 직렬화/역직렬화 테스트 + val json = objectMapper.writeValueAsString(response) + val deserialized = objectMapper.readValue(json) + assertEquals(specialMetadata, deserialized.metadata) + } + + @Test + fun `expiresAt - success - handles edge cases`() { + // Given + val pastInstant = Instant.now().minus(1, ChronoUnit.HOURS) + val futureInstant = Instant.now().plus(24, ChronoUnit.HOURS) + val epochInstant = Instant.EPOCH + + // When & Then + val pastResponse = PresignedPutResponse( + uploadUrl = "https://bucket.s3.amazonaws.com/test1.jpg", + key = "images/test1.jpg", + expiresAt = pastInstant, + metadata = emptyMap() + ) + assertEquals(pastInstant, pastResponse.expiresAt) + + val futureResponse = PresignedPutResponse( + uploadUrl = "https://bucket.s3.amazonaws.com/test2.jpg", + key = "images/test2.jpg", + expiresAt = futureInstant, + metadata = emptyMap() + ) + assertEquals(futureInstant, futureResponse.expiresAt) + + val epochResponse = PresignedPutResponse( + uploadUrl = "https://bucket.s3.amazonaws.com/test3.jpg", + key = "images/test3.jpg", + expiresAt = epochInstant, + metadata = emptyMap() + ) + assertEquals(epochInstant, epochResponse.expiresAt) + } + + @Test + fun `field validation - success - validates URL format`() { + // Given + val validUrls = listOf( + "https://bucket.s3.amazonaws.com/key.jpg", + "https://bucket.s3.region.amazonaws.com/path/to/key.jpg", + "https://custom-domain.com/images/key.jpg", + "http://localhost:9000/bucket/key.jpg" // 로컬 테스트용 + ) + + // When & Then + validUrls.forEach { url -> + val response = PresignedPutResponse( + uploadUrl = url, + key = "test.jpg", + expiresAt = fixedInstant, + metadata = emptyMap() + ) + assertEquals(url, response.uploadUrl) + } + } + + @Test + fun `field validation - success - validates key format`() { + // Given + val validKeys = listOf( + "images/test.jpg", + "images/2024/01/15/uuid.jpg", + "images/user/123/profile/image.png", + "images/very/deep/nested/path/with/uuid/file-name.webp", + "simple-key.jpg" + ) + + // When & Then + validKeys.forEach { key -> + val response = PresignedPutResponse( + uploadUrl = "https://bucket.s3.amazonaws.com/$key", + key = key, + expiresAt = fixedInstant, + metadata = emptyMap() + ) + assertEquals(key, response.key) + } + } + + @Test + fun `immutability - documents current behavior - metadata map references original`() { + // Given + val originalMetadata = mutableMapOf("contentType" to "image/jpeg") + val response = PresignedPutResponse( + uploadUrl = "https://bucket.s3.amazonaws.com/test.jpg", + key = "test.jpg", + expiresAt = fixedInstant, + metadata = originalMetadata + ) + + // When - 원본 맵 수정 + originalMetadata["newKey"] = "newValue" + + // Then - response도 변경됨 (Kotlin data class의 현재 동작) + // 이 테스트는 현재 동작을 문서화하며, 실제 사용 시에는 + // 생성자에 immutable map을 전달해야 함을 보여줌 + assertEquals(2, response.metadata.size) + assertEquals("image/jpeg", response.metadata["contentType"]) + assertEquals("newValue", response.metadata["newKey"]) + + // 권장 방법: 불변 맵을 전달 + val immutableMetadata = mapOf("contentType" to "image/jpeg") + val immutableResponse = PresignedPutResponse( + uploadUrl = "https://bucket.s3.amazonaws.com/test.jpg", + key = "test.jpg", + expiresAt = fixedInstant, + metadata = immutableMetadata + ) + + // 불변 맵은 원본이 없으므로 안전 + assertEquals(1, immutableResponse.metadata.size) + } +} diff --git a/src/test/kotlin/com/albert/realmoneyrealtaste/application/image/provided/ImageReaderTest.kt b/src/test/kotlin/com/albert/realmoneyrealtaste/application/image/provided/ImageReaderTest.kt index 697cb6f8..115fb496 100644 --- a/src/test/kotlin/com/albert/realmoneyrealtaste/application/image/provided/ImageReaderTest.kt +++ b/src/test/kotlin/com/albert/realmoneyrealtaste/application/image/provided/ImageReaderTest.kt @@ -49,20 +49,6 @@ class ImageReaderTest( } } - @Test - fun `getImageUrl - failure - throws exception when unauthorized user tries to access`() { - // Given - val ownerId = 123L - val unauthorizedUserId = 456L - val image = createTestImage(ownerId, ImageType.POST_IMAGE) - val savedImage = imageRepository.save(image) - - // When & Then - assertFailsWith { - imageReader.getImageUrl(savedImage.requireId(), unauthorizedUserId) - } - } - @Test fun `getImagesByMember - success - returns list of user's images`() { // Given @@ -319,6 +305,94 @@ class ImageReaderTest( } } + @Test + fun `readImagesByIds - success - returns images for valid IDs`() { + // Given + val userId = 123L + val imageTypes = listOf(ImageType.POST_IMAGE, ImageType.PROFILE_IMAGE, ImageType.THUMBNAIL) + val savedImages = imageTypes.map { imageType -> + val image = createTestImage(userId, imageType) + imageRepository.save(image) + } + + val imageIds = savedImages.map { it.requireId() } + + // When + val imageInfos = imageReader.readImagesByIds(imageIds) + + // Then + assertEquals(3, imageInfos.size) + + val sortedImageInfos = imageInfos.sortedBy { it.imageId } + val sortedSavedImages = savedImages.sortedBy { it.requireId() } + + sortedImageInfos.zip(sortedSavedImages) { info, image -> + assertEquals(image.requireId(), info.imageId) + assertEquals(image.fileKey.value, info.fileKey) + assertEquals(image.imageType, info.imageType) + assertNotNull(info.url) + assertTrue(info.url.contains(image.fileKey.value)) + } + } + + @Test + fun `readImagesByIds - success - returns empty list for non-existing IDs`() { + // Given + val nonExistentIds = listOf(999999L, 888888L, 777777L) + + // When + val imageInfos = imageReader.readImagesByIds(nonExistentIds) + + // Then + assertEquals(0, imageInfos.size) + } + + @Test + fun `readImagesByIds - success - excludes deleted images`() { + // Given + val userId = 123L + val activeImage = createTestImage(userId, ImageType.POST_IMAGE) + val deletedImage = createTestImage(userId, ImageType.PROFILE_IMAGE) + + val savedActiveImage = imageRepository.save(activeImage) + val savedDeletedImage = imageRepository.save(deletedImage) + + // 삭제 처리 + savedDeletedImage.markAsDeleted() + imageRepository.save(savedDeletedImage) + + val imageIds = listOf(savedActiveImage.requireId(), savedDeletedImage.requireId()) + + // When + val imageInfos = imageReader.readImagesByIds(imageIds) + + // Then + assertEquals(1, imageInfos.size) + assertEquals(savedActiveImage.requireId(), imageInfos[0].imageId) + } + + @Test + fun `readImagesByIds - success - handles mixed existing and non-existing IDs`() { + // Given + val userId = 123L + val existingImage = createTestImage(userId, ImageType.POST_IMAGE) + val savedExistingImage = imageRepository.save(existingImage) + + val mixedIds = listOf( + savedExistingImage.requireId(), + 999999L, // non-existing + 888888L // non-existing + ) + + // When + val imageInfos = imageReader.readImagesByIds(mixedIds) + + // Then + assertEquals(1, imageInfos.size) + assertEquals(savedExistingImage.requireId(), imageInfos[0].imageId) + assertEquals(savedExistingImage.fileKey.value, imageInfos[0].fileKey) + } + private fun createTestImage(userId: Long, imageType: ImageType): Image { val command = ImageCreateCommand( fileKey = FileKey("${UUID.randomUUID()}/test-image-${System.currentTimeMillis()}.jpg"), diff --git a/src/test/kotlin/com/albert/realmoneyrealtaste/application/member/provided/MemberReaderTest.kt b/src/test/kotlin/com/albert/realmoneyrealtaste/application/member/provided/MemberReaderTest.kt index ca290293..17848cc0 100644 --- a/src/test/kotlin/com/albert/realmoneyrealtaste/application/member/provided/MemberReaderTest.kt +++ b/src/test/kotlin/com/albert/realmoneyrealtaste/application/member/provided/MemberReaderTest.kt @@ -1,13 +1,17 @@ package com.albert.realmoneyrealtaste.application.member.provided import com.albert.realmoneyrealtaste.IntegrationTestBase +import com.albert.realmoneyrealtaste.application.follow.required.FollowRepository import com.albert.realmoneyrealtaste.application.member.dto.MemberRegisterRequest import com.albert.realmoneyrealtaste.application.member.exception.MemberNotFoundException +import com.albert.realmoneyrealtaste.domain.follow.Follow +import com.albert.realmoneyrealtaste.domain.follow.command.FollowCreateCommand import com.albert.realmoneyrealtaste.domain.member.Member import com.albert.realmoneyrealtaste.domain.member.MemberStatus import com.albert.realmoneyrealtaste.domain.member.value.Email import com.albert.realmoneyrealtaste.util.MemberFixture import org.junit.jupiter.api.Assertions.assertAll +import org.springframework.beans.factory.annotation.Autowired import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertFailsWith @@ -19,6 +23,9 @@ class MemberReaderTest( private val memberRegister: MemberRegister, ) : IntegrationTestBase() { + @Autowired + private lateinit var followRepository: FollowRepository + @Test fun `readMemberById - success - returns member when member exists`() { val request = MemberRegisterRequest( @@ -390,6 +397,147 @@ class MemberReaderTest( ) } + @Test + fun `findSuggestedMembersWithFollowStatus - success - returns suggested members with following status`() { + // given + val targetMember = createActiveMember(email = Email("target@example.com")) + val suggestedMember1 = createActiveMember(email = Email("suggested1@example.com")) + val suggestedMember2 = createActiveMember(email = Email("suggested2@example.com")) + val suggestedMember3 = createActiveMember(email = Email("suggested3@example.com")) + + // targetMember가 suggestedMember1과 suggestedMember3을 팔로우 + createFollowRelationship(targetMember, suggestedMember1) + createFollowRelationship(targetMember, suggestedMember3) + + val limit = 5L + + // when + val result = memberReader.findSuggestedMembersWithFollowStatus(targetMember.id!!, limit) + + // then + assertAll( + { assertTrue(result.suggestedUsers.isNotEmpty()) }, + { assertFalse(result.suggestedUsers.any { it.id == targetMember.id }) }, // 자기 자신 제외 + { result.suggestedUsers.all { it.status == MemberStatus.ACTIVE } }, // 모두 활성화된 회원 + { assertEquals(2, result.followingIds.size) }, // 2명을 팔로우 + { assertTrue(result.followingIds.contains(suggestedMember1.id!!)) }, // 팔로우한 회원 포함 + { assertTrue(result.followingIds.contains(suggestedMember3.id!!)) }, // 팔로우한 회원 포함 + { assertFalse(result.followingIds.contains(suggestedMember2.id!!)) }, // 팔로우하지 않은 회원 제외 + { assertFalse(result.followingIds.contains(targetMember.id!!)) } // 자기 자신 제외 + ) + } + + @Test + fun `findSuggestedMembersWithFollowStatus - success - returns empty following list when not following anyone`() { + // given + val targetMember = createActiveMember(email = Email("target@example.com")) + (1..3).map { i -> + createActiveMember(email = Email("suggested$i@example.com")) + } + val limit = 5L + + // when + val result = memberReader.findSuggestedMembersWithFollowStatus(targetMember.id!!, limit) + + // then + assertAll( + { assertTrue(result.suggestedUsers.isNotEmpty()) }, + { assertTrue(result.followingIds.isEmpty()) }, // 아무도 팔로우하지 않음 + { result.suggestedUsers.all { it.status == MemberStatus.ACTIVE } }, + { assertFalse(result.suggestedUsers.any { it.id == targetMember.id }) } + ) + } + + @Test + fun `findSuggestedMembersWithFollowStatus - success - respects limit parameter with following status`() { + // given + val targetMember = createActiveMember(email = Email("target@example.com")) + val suggestedMembers = (1..5).map { i -> + createActiveMember(email = Email("suggested$i@example.com")) + } + // targetMember가 첫 3명만 팔로우 + suggestedMembers.take(3).forEach { suggested -> + createFollowRelationship(targetMember, suggested) + } + val limit = 5L + + // when + val result = memberReader.findSuggestedMembersWithFollowStatus(targetMember.id!!, limit) + + // then + assertAll( + { assertEquals(limit, result.suggestedUsers.size.toLong()) }, + { assertEquals(3, result.followingIds.size) }, // 3명을 팔로우 + { result.suggestedUsers.all { it.status == MemberStatus.ACTIVE } }, + { assertFalse(result.suggestedUsers.any { it.id == targetMember.id }) } + ) + } + + @Test + fun `findSuggestedMembersWithFollowStatus - success - excludes inactive members with following status`() { + // given + val targetMember = createActiveMember(email = Email("target@example.com")) + val activeMember1 = createActiveMember(email = Email("active1@example.com")) + val inactiveMember2 = createMember(email = Email("inactive2@example.com")) // 비활성화 + val activeMember3 = createActiveMember(email = Email("active3@example.com")) + + // targetMember가 activeMember1과 inactiveMember2를 팔로우 + createFollowRelationship(targetMember, activeMember1) + createFollowRelationship(targetMember, inactiveMember2) + + val limit = 10L + + // when + val result = memberReader.findSuggestedMembersWithFollowStatus(targetMember.id!!, limit) + + // then + assertAll( + { assertTrue(result.suggestedUsers.isNotEmpty()) }, + { result.suggestedUsers.all { it.status == MemberStatus.ACTIVE } }, // 활성화된 회원만 + { assertFalse(result.suggestedUsers.any { it.id == inactiveMember2.id }) }, // 비활성화 회원 제외 + { assertEquals(1, result.followingIds.size) }, // 활성화된 회원 중 팔로우한 1명만 + { assertTrue(result.followingIds.contains(activeMember1.id!!)) }, // 활성화된 팔로우 회원 포함 + { assertFalse(result.followingIds.contains(inactiveMember2.id!!)) }, // 비활성화된 팔로우 회원 제외 + { assertFalse(result.followingIds.contains(targetMember.id!!)) } + ) + } + + @Test + fun `findSuggestedMembersWithFollowStatus - success - returns empty result when no other members exist`() { + // given + val targetMember = createActiveMember(email = Email("target@example.com")) + val limit = 5L + + // when + val result = memberReader.findSuggestedMembersWithFollowStatus(targetMember.id!!, limit) + + // then + assertAll( + { assertTrue(result.suggestedUsers.isEmpty()) }, + { assertTrue(result.followingIds.isEmpty()) } + ) + } + + @Test + fun `findSuggestedMembersWithFollowStatus - success - handles non-existent member ID with following status`() { + // given + val nonExistentMemberId = 99999L + (1..3).map { i -> + createActiveMember(email = Email("suggested$i@example.com")) + } + val limit = 5L + + // when + val result = memberReader.findSuggestedMembersWithFollowStatus(nonExistentMemberId, limit) + + // then - 비활성화 회원이 없는 한, 다른 활성화된 회원들을 반환할 수 있음 + assertAll( + { result.suggestedUsers.all { it.status == MemberStatus.ACTIVE } }, + { assertTrue(result.followingIds.isEmpty()) }, // 존재하지 않는 회원이므로 팔로우한 사람이 없음 + { assertTrue(result.suggestedUsers.size <= limit) } + ) + } + private fun createActiveMember(email: Email): Member { val registeredMember = createMember(email) registeredMember.activate() @@ -405,4 +553,19 @@ class MemberReaderTest( ) ) } + + private fun createFollowRelationship(follower: Member, following: Member) { + val command = FollowCreateCommand( + followerId = follower.id!!, + followingId = following.id!!, + followerNickname = follower.nickname.value, + followingNickname = following.nickname.value, + followerProfileImageId = follower.imageId, + followingProfileImageId = following.imageId + ) + + val follow = Follow.create(command) + + followRepository.save(follow) + } } diff --git a/src/test/kotlin/com/albert/realmoneyrealtaste/application/member/provided/MemberUpdaterTest.kt b/src/test/kotlin/com/albert/realmoneyrealtaste/application/member/provided/MemberUpdaterTest.kt index c0a30624..dced0a99 100644 --- a/src/test/kotlin/com/albert/realmoneyrealtaste/application/member/provided/MemberUpdaterTest.kt +++ b/src/test/kotlin/com/albert/realmoneyrealtaste/application/member/provided/MemberUpdaterTest.kt @@ -32,11 +32,15 @@ class MemberUpdaterTest( val newNickname = Nickname("newNickname") val newProfileAddress = ProfileAddress("newAddress123") val newIntroduction = Introduction("Hello, I'm updated!") + val newAddress = "서울시 강남구 테헤란로 123" + val newImageId = 123L val memberId = member.id!! val request = AccountUpdateRequest( nickname = newNickname, profileAddress = newProfileAddress, introduction = newIntroduction, + address = newAddress, + imageId = newImageId, ) val updatedMember = memberUpdater.updateInfo(memberId, request) @@ -44,6 +48,8 @@ class MemberUpdaterTest( assertEquals(newNickname, updatedMember.nickname) assertEquals(newProfileAddress, updatedMember.detail.profileAddress) assertEquals(newIntroduction, updatedMember.detail.introduction) + assertEquals(newAddress, updatedMember.detail.address) + assertEquals(newImageId, updatedMember.detail.imageId) } @Test @@ -55,6 +61,8 @@ class MemberUpdaterTest( nickname = null, profileAddress = oldProfileAddress, introduction = null, + address = null, + imageId = null, ) memberUpdater.updateInfo(memberId, request) val newProfileAddress = ProfileAddress("newAddress123") @@ -62,6 +70,8 @@ class MemberUpdaterTest( nickname = null, profileAddress = newProfileAddress, introduction = null, + address = null, + imageId = null, ) val updatedMember = memberUpdater.updateInfo(memberId, newRequest) @@ -74,12 +84,16 @@ class MemberUpdaterTest( val member = registerAndActivateMember() val originalProfileAddress = member.detail.profileAddress val originalIntroduction = member.detail.introduction + val originalAddress = member.detail.address + val originalImageId = member.detail.imageId val newNickname = Nickname("onlyNickname") val memberId = member.id!! val request = AccountUpdateRequest( nickname = newNickname, profileAddress = null, introduction = null, + address = null, + imageId = null, ) val updatedMember = memberUpdater.updateInfo(memberId, request) @@ -87,6 +101,8 @@ class MemberUpdaterTest( assertEquals(newNickname, updatedMember.nickname) assertEquals(originalProfileAddress, updatedMember.detail.profileAddress) assertEquals(originalIntroduction, updatedMember.detail.introduction) + assertEquals(originalAddress, updatedMember.detail.address) + assertEquals(originalImageId, updatedMember.detail.imageId) } @Test @@ -95,11 +111,15 @@ class MemberUpdaterTest( val originalNickname = member.nickname val originalProfileAddress = member.detail.profileAddress val originalIntroduction = member.detail.introduction + val originalAddress = member.detail.address + val originalImageId = member.detail.imageId val memberId = member.id!! val request = AccountUpdateRequest( nickname = null, profileAddress = null, introduction = null, + address = null, + imageId = null, ) val updatedMember = memberUpdater.updateInfo(memberId, request) @@ -107,6 +127,8 @@ class MemberUpdaterTest( assertEquals(originalNickname, updatedMember.nickname) assertEquals(originalProfileAddress, updatedMember.detail.profileAddress) assertEquals(originalIntroduction, updatedMember.detail.introduction) + assertEquals(originalAddress, updatedMember.detail.address) + assertEquals(originalImageId, updatedMember.detail.imageId) } @Test @@ -116,6 +138,8 @@ class MemberUpdaterTest( nickname = Nickname("test"), profileAddress = null, introduction = null, + address = null, + imageId = null, ) assertFailsWith { @@ -131,6 +155,8 @@ class MemberUpdaterTest( nickname = Nickname("test"), profileAddress = null, introduction = null, + address = null, + imageId = null, ) assertFailsWith { @@ -151,6 +177,8 @@ class MemberUpdaterTest( nickname = null, profileAddress = duplicateProfileAddress, introduction = null, + address = null, + imageId = null, ) val member1UP = memberUpdater.updateInfo(member1Id, request) @@ -172,6 +200,8 @@ class MemberUpdaterTest( nickname = null, profileAddress = null, introduction = null, + address = null, + imageId = null, ) val updatedMember = memberUpdater.updateInfo(memberId, request) @@ -190,6 +220,8 @@ class MemberUpdaterTest( nickname = null, profileAddress = profileAddress, introduction = null, + address = null, + imageId = null, ) memberUpdater.updateInfo(memberId, setRequest) @@ -198,6 +230,8 @@ class MemberUpdaterTest( nickname = null, profileAddress = profileAddress, introduction = null, + address = null, + imageId = null, ) val updatedMember = memberUpdater.updateInfo(memberId, sameRequest) @@ -219,6 +253,8 @@ class MemberUpdaterTest( nickname = null, profileAddress = profileAddress1, introduction = null, + address = null, + imageId = null, ) memberUpdater.updateInfo(member1Id, request1) @@ -227,6 +263,8 @@ class MemberUpdaterTest( nickname = null, profileAddress = profileAddress2, introduction = null, + address = null, + imageId = null, ) memberUpdater.updateInfo(member2Id, request2) @@ -235,6 +273,8 @@ class MemberUpdaterTest( nickname = null, profileAddress = profileAddress1, introduction = null, + address = null, + imageId = null, ) assertFailsWith { @@ -257,6 +297,8 @@ class MemberUpdaterTest( nickname = null, profileAddress = initialProfileAddress, introduction = null, + address = null, + imageId = null, ) memberUpdater.updateInfo(memberId, setRequest) @@ -265,6 +307,8 @@ class MemberUpdaterTest( nickname = newNickname, profileAddress = null, introduction = newIntroduction, + address = null, + imageId = null, ) val updatedMember = memberUpdater.updateInfo(memberId, updateRequest) @@ -364,6 +408,129 @@ class MemberUpdaterTest( } } + @Test + fun `updateInfo - success - updates address and imageId`() { + val member = registerAndActivateMember() + val memberId = member.id!! + val newAddress = "서울시 강남구 역삼동 456" + val newImageId = 456L + val request = AccountUpdateRequest( + nickname = null, + profileAddress = null, + introduction = null, + address = newAddress, + imageId = newImageId, + ) + + val updatedMember = memberUpdater.updateInfo(memberId, request) + + assertEquals(newAddress, updatedMember.detail.address) + assertEquals(newImageId, updatedMember.detail.imageId) + } + + @Test + fun `updateInfo - success - updates only address when imageId is null`() { + val member = registerAndActivateMember() + val memberId = member.id!! + val originalImageId = member.detail.imageId + val newAddress = "부산시 해운대구 광안리 789" + val request = AccountUpdateRequest( + nickname = null, + profileAddress = null, + introduction = null, + address = newAddress, + imageId = null, + ) + + val updatedMember = memberUpdater.updateInfo(memberId, request) + + assertEquals(newAddress, updatedMember.detail.address) + assertEquals(originalImageId, updatedMember.detail.imageId) + } + + @Test + fun `updateInfo - success - updates only imageId when address is null`() { + val member = registerAndActivateMember() + val memberId = member.id!! + val originalAddress = member.detail.address + val newImageId = 789L + val request = AccountUpdateRequest( + nickname = null, + profileAddress = null, + introduction = null, + address = null, + imageId = newImageId, + ) + + val updatedMember = memberUpdater.updateInfo(memberId, request) + + assertEquals(originalAddress, updatedMember.detail.address) + assertEquals(newImageId, updatedMember.detail.imageId) + } + + @Test + fun `updateInfo - success - updates address to empty string`() { + val member = registerAndActivateMember() + val memberId = member.id!! + + // 먼저 주소 설정 + val setRequest = AccountUpdateRequest( + nickname = null, + profileAddress = null, + introduction = null, + address = "초기 주소", + imageId = null, + ) + memberUpdater.updateInfo(memberId, setRequest) + + // 빈 문자열로 업데이트 + val updateRequest = AccountUpdateRequest( + nickname = null, + profileAddress = null, + introduction = null, + address = "", + imageId = null, + ) + + val updatedMember = memberUpdater.updateInfo(memberId, updateRequest) + + assertEquals("", updatedMember.detail.address) + } + + @Test + fun `updateInfo - success - updates imageId to zero`() { + val member = registerAndActivateMember() + val memberId = member.id!! + val request = AccountUpdateRequest( + nickname = null, + profileAddress = null, + introduction = null, + address = null, + imageId = 0L, + ) + + val updatedMember = memberUpdater.updateInfo(memberId, request) + + assertEquals(0L, updatedMember.detail.imageId) + } + + @Test + fun `updateInfo - success - updates imageId to negative value`() { + val member = registerAndActivateMember() + val memberId = member.id!! + val request = AccountUpdateRequest( + nickname = null, + profileAddress = null, + introduction = null, + address = null, + imageId = -1L, + ) + + val updatedMember = memberUpdater.updateInfo(memberId, request) + + assertEquals(-1L, updatedMember.detail.imageId) + } + private fun registerMember( email: Email = MemberFixture.DEFAULT_EMAIL, nickname: Nickname = MemberFixture.DEFAULT_NICKNAME, diff --git a/src/test/kotlin/com/albert/realmoneyrealtaste/application/member/provided/PasswordResetterTest.kt b/src/test/kotlin/com/albert/realmoneyrealtaste/application/member/provided/PasswordResetterTest.kt index 0039c291..e15000fb 100644 --- a/src/test/kotlin/com/albert/realmoneyrealtaste/application/member/provided/PasswordResetterTest.kt +++ b/src/test/kotlin/com/albert/realmoneyrealtaste/application/member/provided/PasswordResetterTest.kt @@ -45,7 +45,7 @@ class PasswordResetterTest( member.activate() memberRepository.save(member) - passwordResetter.sendPasswordResetEmail(member.email) + passwordResetter.sendPasswordResetEmail(member.email.address) val savedToken = passwordResetTokenReader.findByMemberId(member.requireId()) assertNotNull(savedToken) @@ -57,7 +57,7 @@ class PasswordResetterTest( applicationEvents.clear() assertFailsWith { - passwordResetter.sendPasswordResetEmail(nonExistentEmail) + passwordResetter.sendPasswordResetEmail(nonExistentEmail.address) } assertEquals(applicationEvents.stream(PasswordResetRequestedEvent::class.java).count().toInt(), 0) @@ -69,12 +69,12 @@ class PasswordResetterTest( member.activate() memberRepository.save(member) - passwordResetter.sendPasswordResetEmail(member.email) + passwordResetter.sendPasswordResetEmail(member.email.address) val firstToken = passwordResetTokenReader.findByMemberId(member.requireId()) assertNotNull(firstToken) - passwordResetter.sendPasswordResetEmail(member.email) + passwordResetter.sendPasswordResetEmail(member.email.address) val secondToken = passwordResetTokenReader.findByMemberId(member.requireId()) assertAll( @@ -89,7 +89,7 @@ class PasswordResetterTest( member.activate() memberRepository.save(member) - passwordResetter.sendPasswordResetEmail(member.email) + passwordResetter.sendPasswordResetEmail(member.email.address) val savedToken = passwordResetTokenReader.findByMemberId(member.requireId()) assertAll( @@ -109,7 +109,7 @@ class PasswordResetterTest( member.activate() memberRepository.save(member) - passwordResetter.sendPasswordResetEmail(member.email) + passwordResetter.sendPasswordResetEmail(member.email.address) val token = passwordResetTokenReader.findByMemberId(member.requireId()) val newPassword = RawPassword("newPassword123!") @@ -165,7 +165,7 @@ class PasswordResetterTest( val oldPassword = MemberFixture.DEFAULT_RAW_PASSWORD memberRepository.save(member) - passwordResetter.sendPasswordResetEmail(member.email) + passwordResetter.sendPasswordResetEmail(member.email.address) val token = passwordResetTokenReader.findByMemberId(member.requireId()) val newPassword = RawPassword("newPassword123!") @@ -185,7 +185,7 @@ class PasswordResetterTest( member.activate() memberRepository.save(member) - passwordResetter.sendPasswordResetEmail(member.email) + passwordResetter.sendPasswordResetEmail(member.email.address) val token = passwordResetTokenReader.findByMemberId(member.requireId()) val newPassword = RawPassword("newPassword123!") @@ -208,8 +208,8 @@ class PasswordResetterTest( memberRepository.save(member1) memberRepository.save(member2) - passwordResetter.sendPasswordResetEmail(member1.email) - passwordResetter.sendPasswordResetEmail(member2.email) + passwordResetter.sendPasswordResetEmail(member1.email.address) + passwordResetter.sendPasswordResetEmail(member2.email.address) val token1 = passwordResetTokenReader.findByMemberId(member1.requireId()) val token2 = passwordResetTokenReader.findByMemberId(member2.requireId()) @@ -233,7 +233,7 @@ class PasswordResetterTest( memberRepository.save(member1) memberRepository.save(member2) - passwordResetter.sendPasswordResetEmail(member1.email) + passwordResetter.sendPasswordResetEmail(member1.email.address) val token1 = passwordResetTokenReader.findByMemberId(member1.requireId()) val newPassword = RawPassword("newPassword123!") diff --git a/src/test/kotlin/com/albert/realmoneyrealtaste/domain/follow/FollowTest.kt b/src/test/kotlin/com/albert/realmoneyrealtaste/domain/follow/FollowTest.kt index 053142c2..33b0d0cf 100644 --- a/src/test/kotlin/com/albert/realmoneyrealtaste/domain/follow/FollowTest.kt +++ b/src/test/kotlin/com/albert/realmoneyrealtaste/domain/follow/FollowTest.kt @@ -1,6 +1,7 @@ package com.albert.realmoneyrealtaste.domain.follow import com.albert.realmoneyrealtaste.domain.follow.command.FollowCreateCommand +import com.albert.realmoneyrealtaste.domain.follow.value.FollowRelationship import org.junit.jupiter.api.assertAll import java.time.LocalDateTime import kotlin.test.Test @@ -15,9 +16,19 @@ class FollowTest { fun `create - success - creates active follow with valid command`() { val followerId = 1L val followerNickname = "follower" + val followerProfileImageId = 1L val followingId = 2L val followingNickname = "following" - val command = FollowCreateCommand(followerId, followerNickname, followingId, followingNickname) + val followingProfileImageId = 2L + val command = + FollowCreateCommand( + followerId, + followerNickname, + followerProfileImageId, + followingId, + followingNickname, + followingProfileImageId + ) val before = LocalDateTime.now() val follow = Follow.create(command) @@ -254,13 +265,41 @@ class FollowTest { ) } + @Test + fun `setter - success - for coverage`() { + val follow = TestFollow() + val updatedRelationship = FollowRelationship.of(FollowCreateCommand(1L, "follower", 1, 2L, "following", 2)) + val updatedCreatedAt = LocalDateTime.now() + + follow.setRelationshipForTest(updatedRelationship) + follow.setCreatedAtForTest(updatedCreatedAt) + + assertEquals(updatedRelationship, follow.relationship) + assertEquals(updatedCreatedAt, follow.createdAt) + } + private fun createActiveFollow(): Follow { - val command = FollowCreateCommand(1L, "follower", 2L, "following") + val command = FollowCreateCommand(1L, "follower", 1, 2L, "following", 2) return Follow.create(command) } private fun createFollow(followerId: Long, followingId: Long): Follow { - val command = FollowCreateCommand(followerId, "follower", followingId, "following") + val command = FollowCreateCommand(followerId, "follower", 1, followingId, "following", 2) return Follow.create(command) } + + class TestFollow : Follow( + relationship = FollowRelationship.of(FollowCreateCommand(1L, "follower", 1, 2L, "following", 2)), + status = FollowStatus.ACTIVE, + createdAt = LocalDateTime.now(), + updatedAt = LocalDateTime.now() + ) { + fun setRelationshipForTest(relationship: FollowRelationship) { + this.relationship = relationship + } + + fun setCreatedAtForTest(createdAt: LocalDateTime) { + this.createdAt = createdAt + } + } } diff --git a/src/test/kotlin/com/albert/realmoneyrealtaste/domain/follow/command/FollowCreateCommandTest.kt b/src/test/kotlin/com/albert/realmoneyrealtaste/domain/follow/command/FollowCreateCommandTest.kt index c8dffc4d..6b5fd460 100644 --- a/src/test/kotlin/com/albert/realmoneyrealtaste/domain/follow/command/FollowCreateCommandTest.kt +++ b/src/test/kotlin/com/albert/realmoneyrealtaste/domain/follow/command/FollowCreateCommandTest.kt @@ -11,16 +11,27 @@ class FollowCreateCommandTest { fun `construct - success - creates command with valid ids`() { val followerId = 1L val followerNickname = "follower" + val followerProfileImageId = 1L val followingId = 2L val followingNickname = "following" + val followingProfileImageId = 2L - val command = FollowCreateCommand(followerId, followerNickname, followingId, followingNickname) + val command = FollowCreateCommand( + followerId, + followerNickname, + followerProfileImageId, + followingId, + followingNickname, + followingProfileImageId + ) assertAll( { assertEquals(followerId, command.followerId) }, { assertEquals(followerNickname, command.followerNickname) }, + { assertEquals(followerProfileImageId, command.followerProfileImageId) }, { assertEquals(followingId, command.followingId) }, - { assertEquals(followingNickname, command.followingNickname) } + { assertEquals(followingNickname, command.followingNickname) }, + { assertEquals(followingProfileImageId, command.followingProfileImageId) } ) } @@ -28,11 +39,20 @@ class FollowCreateCommandTest { fun `construct - failure - throws exception when followerId is zero`() { val followerId = 0L val followerNickname = "follower" + val followerProfileImageId = 1L val followingId = 2L val followingNickname = "following" + val followingProfileImageId = 2L assertFailsWith { - FollowCreateCommand(followerId, followerNickname, followingId, followingNickname) + FollowCreateCommand( + followerId, + followerNickname, + followerProfileImageId, + followingId, + followingNickname, + followingProfileImageId + ) }.let { assertEquals("팔로워 ID는 양수여야 합니다", it.message) } @@ -42,11 +62,20 @@ class FollowCreateCommandTest { fun `construct - failure - throws exception when followingId is zero`() { val followerId = 1L val followerNickname = "follower" + val followerProfileImageId = 1L val followingId = 0L val followingNickname = "following" + val followingProfileImageId = 2L assertFailsWith { - FollowCreateCommand(followerId, followerNickname, followingId, followingNickname) + FollowCreateCommand( + followerId, + followerNickname, + followerProfileImageId, + followingId, + followingNickname, + followingProfileImageId + ) }.let { assertEquals("팔로잉 대상 ID는 양수여야 합니다", it.message) } @@ -56,9 +85,17 @@ class FollowCreateCommandTest { fun `construct - failure - throws exception when following self`() { val memberId = 1L val nickname = "test" + val profileImageId = 1L assertFailsWith { - FollowCreateCommand(memberId, nickname, memberId, nickname) + FollowCreateCommand( + memberId, + nickname, + profileImageId, + memberId, + nickname, + profileImageId + ) }.let { assertEquals("자기 자신을 팔로우할 수 없습니다", it.message) } @@ -68,11 +105,20 @@ class FollowCreateCommandTest { fun `construct - failure - throws exception when followerNickname is blank`() { val followerId = 1L val followerNickname = "" + val followerProfileImageId = 1L val followingId = 2L val followingNickname = "following" + val followingProfileImageId = 2L assertFailsWith { - FollowCreateCommand(followerId, followerNickname, followingId, followingNickname) + FollowCreateCommand( + followerId, + followerNickname, + followerProfileImageId, + followingId, + followingNickname, + followingProfileImageId + ) }.let { assertEquals(FollowCreateCommand.ERROR_FOLLOWER_NICKNAME_BLANK, it.message) } @@ -82,11 +128,20 @@ class FollowCreateCommandTest { fun `construct - failure - throws exception when followerNickname is only whitespace`() { val followerId = 1L val followerNickname = " " + val followerProfileImageId = 1L val followingId = 2L val followingNickname = "following" + val followingProfileImageId = 2L assertFailsWith { - FollowCreateCommand(followerId, followerNickname, followingId, followingNickname) + FollowCreateCommand( + followerId, + followerNickname, + followerProfileImageId, + followingId, + followingNickname, + followingProfileImageId + ) }.let { assertEquals(FollowCreateCommand.ERROR_FOLLOWER_NICKNAME_BLANK, it.message) } @@ -96,11 +151,20 @@ class FollowCreateCommandTest { fun `construct - failure - throws exception when followingNickname is blank`() { val followerId = 1L val followerNickname = "follower" + val followerProfileImageId = 1L val followingId = 2L val followingNickname = "" + val followingProfileImageId = 2L assertFailsWith { - FollowCreateCommand(followerId, followerNickname, followingId, followingNickname) + FollowCreateCommand( + followerId, + followerNickname, + followerProfileImageId, + followingId, + followingNickname, + followingProfileImageId + ) }.let { assertEquals(FollowCreateCommand.ERROR_FOLLOWING_NICKNAME_BLANK, it.message) } @@ -110,11 +174,20 @@ class FollowCreateCommandTest { fun `construct - failure - throws exception when followingNickname is only whitespace`() { val followerId = 1L val followerNickname = "follower" + val followerProfileImageId = 1L val followingId = 2L val followingNickname = " " + val followingProfileImageId = 2L assertFailsWith { - FollowCreateCommand(followerId, followerNickname, followingId, followingNickname) + FollowCreateCommand( + followerId, + followerNickname, + followerProfileImageId, + followingId, + followingNickname, + followingProfileImageId + ) }.let { assertEquals(FollowCreateCommand.ERROR_FOLLOWING_NICKNAME_BLANK, it.message) } @@ -124,11 +197,20 @@ class FollowCreateCommandTest { fun `construct - failure - throws exception when both nicknames are blank`() { val followerId = 1L val followerNickname = "" + val followerProfileImageId = 1L val followingId = 2L val followingNickname = "" + val followingProfileImageId = 2L assertFailsWith { - FollowCreateCommand(followerId, followerNickname, followingId, followingNickname) + FollowCreateCommand( + followerId, + followerNickname, + followerProfileImageId, + followingId, + followingNickname, + followingProfileImageId + ) }.let { // followerNickname 검증이 먼저 수행되므로 해당 에러 메시지가 나옴 assertEquals(FollowCreateCommand.ERROR_FOLLOWER_NICKNAME_BLANK, it.message) @@ -139,16 +221,27 @@ class FollowCreateCommandTest { fun `construct - success - accepts valid nicknames with special characters`() { val followerId = 1L val followerNickname = "follower_123" + val followerProfileImageId = 1L val followingId = 2L val followingNickname = "following-456" + val followingProfileImageId = 2L - val command = FollowCreateCommand(followerId, followerNickname, followingId, followingNickname) + val command = FollowCreateCommand( + followerId, + followerNickname, + followerProfileImageId, + followingId, + followingNickname, + followingProfileImageId + ) assertAll( { assertEquals(followerId, command.followerId) }, { assertEquals(followerNickname, command.followerNickname) }, + { assertEquals(followerProfileImageId, command.followerProfileImageId) }, { assertEquals(followingId, command.followingId) }, - { assertEquals(followingNickname, command.followingNickname) } + { assertEquals(followingNickname, command.followingNickname) }, + { assertEquals(followingProfileImageId, command.followingProfileImageId) } ) } @@ -156,16 +249,119 @@ class FollowCreateCommandTest { fun `construct - success - accepts valid nicknames with Korean characters`() { val followerId = 1L val followerNickname = "팔로워" + val followerProfileImageId = 1L val followingId = 2L val followingNickname = "팔로잉대상" + val followingProfileImageId = 2L - val command = FollowCreateCommand(followerId, followerNickname, followingId, followingNickname) + val command = FollowCreateCommand( + followerId, + followerNickname, + followerProfileImageId, + followingId, + followingNickname, + followingProfileImageId + ) assertAll( { assertEquals(followerId, command.followerId) }, { assertEquals(followerNickname, command.followerNickname) }, + { assertEquals(followerProfileImageId, command.followerProfileImageId) }, { assertEquals(followingId, command.followingId) }, - { assertEquals(followingNickname, command.followingNickname) } + { assertEquals(followingNickname, command.followingNickname) }, + { assertEquals(followingProfileImageId, command.followingProfileImageId) } ) } + + @Test + fun `construct - failure - throws exception when followerProfileImageId is zero`() { + val followerId = 1L + val followerNickname = "follower" + val followerProfileImageId = 0L + val followingId = 2L + val followingNickname = "following" + val followingProfileImageId = 2L + + assertFailsWith { + FollowCreateCommand( + followerId, + followerNickname, + followerProfileImageId, + followingId, + followingNickname, + followingProfileImageId + ) + }.let { + assertEquals(FollowCreateCommand.ERROR_FOLLOWER_PROFILE_IMAGE_ID_MUST_BE_POSITIVE, it.message) + } + } + + @Test + fun `construct - failure - throws exception when followerProfileImageId is negative`() { + val followerId = 1L + val followerNickname = "follower" + val followerProfileImageId = -1L + val followingId = 2L + val followingNickname = "following" + val followingProfileImageId = 2L + + assertFailsWith { + FollowCreateCommand( + followerId, + followerNickname, + followerProfileImageId, + followingId, + followingNickname, + followingProfileImageId + ) + }.let { + assertEquals(FollowCreateCommand.ERROR_FOLLOWER_PROFILE_IMAGE_ID_MUST_BE_POSITIVE, it.message) + } + } + + @Test + fun `construct - failure - throws exception when followingProfileImageId is zero`() { + val followerId = 1L + val followerNickname = "follower" + val followerProfileImageId = 1L + val followingId = 2L + val followingNickname = "following" + val followingProfileImageId = 0L + + assertFailsWith { + FollowCreateCommand( + followerId, + followerNickname, + followerProfileImageId, + followingId, + followingNickname, + followingProfileImageId + ) + }.let { + assertEquals(FollowCreateCommand.ERROR_FOLLOWING_PROFILE_IMAGE_ID_MUST_BE_POSITIVE, it.message) + } + } + + @Test + fun `construct - failure - throws exception when followingProfileImageId is negative`() { + val followerId = 1L + val followerNickname = "follower" + val followerProfileImageId = 1L + val followingId = 2L + val followingNickname = "following" + val followingProfileImageId = -1L + + assertFailsWith { + FollowCreateCommand( + followerId, + followerNickname, + followerProfileImageId, + followingId, + followingNickname, + followingProfileImageId + ) + }.let { + assertEquals(FollowCreateCommand.ERROR_FOLLOWING_PROFILE_IMAGE_ID_MUST_BE_POSITIVE, it.message) + } + } } diff --git a/src/test/kotlin/com/albert/realmoneyrealtaste/domain/follow/value/FollowRelationshipTest.kt b/src/test/kotlin/com/albert/realmoneyrealtaste/domain/follow/value/FollowRelationshipTest.kt index 973a37c9..e85547de 100644 --- a/src/test/kotlin/com/albert/realmoneyrealtaste/domain/follow/value/FollowRelationshipTest.kt +++ b/src/test/kotlin/com/albert/realmoneyrealtaste/domain/follow/value/FollowRelationshipTest.kt @@ -13,16 +13,27 @@ class FollowRelationshipTest { fun `create - success - creates relationship with valid follower and following IDs`() { val followerId = 1L val followerNickname = "follower" + val followerProfileImageId = 1L val followingId = 2L val followingNickname = "following" - - val relationship = FollowRelationship(followerId, followerNickname, followingId, followingNickname) + val followingProfileImageId = 2L + + val relationship = FollowRelationship( + followerId = followerId, + followerNickname = followerNickname, + followerProfileImageId = followerProfileImageId, + followingId = followingId, + followingNickname = followingNickname, + followingProfileImageId = followingProfileImageId + ) assertAll( { assertEquals(followerId, relationship.followerId) }, { assertEquals(followerNickname, relationship.followerNickname) }, + { assertEquals(followerProfileImageId, relationship.followerProfileImageId) }, { assertEquals(followingId, relationship.followingId) }, - { assertEquals(followingNickname, relationship.followingNickname) } + { assertEquals(followingNickname, relationship.followingNickname) }, + { assertEquals(followingProfileImageId, relationship.followingProfileImageId) } ) } @@ -30,11 +41,20 @@ class FollowRelationshipTest { fun `create - failure - throws exception when followerId is zero`() { val followerId = 0L val followerNickname = "follower" + val followerProfileImageId = 1L val followingId = 2L val followingNickname = "following" + val followingProfileImageId = 2L assertFailsWith { - FollowRelationship(followerId, followerNickname, followingId, followingNickname) + FollowRelationship( + followerId = followerId, + followerNickname = followerNickname, + followerProfileImageId = followerProfileImageId, + followingId = followingId, + followingNickname = followingNickname, + followingProfileImageId = followingProfileImageId + ) }.let { assertEquals("팔로워 ID는 양수여야 합니다", it.message) } @@ -44,11 +64,20 @@ class FollowRelationshipTest { fun `create - failure - throws exception when followerId is negative`() { val followerId = -1L val followerNickname = "follower" + val followerProfileImageId = 1L val followingId = 2L val followingNickname = "following" + val followingProfileImageId = 2L assertFailsWith { - FollowRelationship(followerId, followerNickname, followingId, followingNickname) + FollowRelationship( + followerId = followerId, + followerNickname = followerNickname, + followerProfileImageId = followerProfileImageId, + followingId = followingId, + followingNickname = followingNickname, + followingProfileImageId = followingProfileImageId + ) }.let { assertEquals("팔로워 ID는 양수여야 합니다", it.message) } @@ -58,11 +87,20 @@ class FollowRelationshipTest { fun `create - failure - throws exception when followingId is zero`() { val followerId = 1L val followerNickname = "follower" + val followerProfileImageId = 1L val followingId = 0L val followingNickname = "following" + val followingProfileImageId = 2L assertFailsWith { - FollowRelationship(followerId, followerNickname, followingId, followingNickname) + FollowRelationship( + followerId = followerId, + followerNickname = followerNickname, + followerProfileImageId = followerProfileImageId, + followingId = followingId, + followingNickname = followingNickname, + followingProfileImageId = followingProfileImageId + ) }.let { assertEquals("팔로잉 대상 ID는 양수여야 합니다", it.message) } @@ -72,11 +110,20 @@ class FollowRelationshipTest { fun `create - failure - throws exception when followingId is negative`() { val followerId = 1L val followerNickname = "follower" + val followerProfileImageId = 1L val followingId = -1L val followingNickname = "following" + val followingProfileImageId = 2L assertFailsWith { - FollowRelationship(followerId, followerNickname, followingId, followingNickname) + FollowRelationship( + followerId = followerId, + followerNickname = followerNickname, + followerProfileImageId = followerProfileImageId, + followingId = followingId, + followingNickname = followingNickname, + followingProfileImageId = followingProfileImageId + ) }.let { assertEquals("팔로잉 대상 ID는 양수여야 합니다", it.message) } @@ -86,9 +133,17 @@ class FollowRelationshipTest { fun `create - failure - throws exception when followerId and followingId are same`() { val memberId = 1L val nickname = "test" + val profileImageId = 1L assertFailsWith { - FollowRelationship(memberId, nickname, memberId, nickname) + FollowRelationship( + followerId = memberId, + followerNickname = nickname, + followerProfileImageId = profileImageId, + followingId = memberId, + followingNickname = nickname, + followingProfileImageId = profileImageId + ) }.let { assertEquals("자기 자신을 팔로우할 수 없습니다", it.message) } @@ -98,11 +153,20 @@ class FollowRelationshipTest { fun `create - failure - throws exception when both IDs are zero`() { val followerId = 0L val followerNickname = "follower" + val followerProfileImageId = 1L val followingId = 0L val followingNickname = "following" + val followingProfileImageId = 2L assertFailsWith { - FollowRelationship(followerId, followerNickname, followingId, followingNickname) + FollowRelationship( + followerId = followerId, + followerNickname = followerNickname, + followerProfileImageId = followerProfileImageId, + followingId = followingId, + followingNickname = followingNickname, + followingProfileImageId = followingProfileImageId + ) }.let { // followerId 검증이 먼저 수행되므로 해당 에러 메시지가 나옴 assertEquals("팔로워 ID는 양수여야 합니다", it.message) @@ -113,11 +177,20 @@ class FollowRelationshipTest { fun `create - failure - throws exception when both IDs are negative`() { val followerId = -1L val followerNickname = "follower" + val followerProfileImageId = 1L val followingId = -2L val followingNickname = "following" + val followingProfileImageId = 2L assertFailsWith { - FollowRelationship(followerId, followerNickname, followingId, followingNickname) + FollowRelationship( + followerId = followerId, + followerNickname = followerNickname, + followerProfileImageId = followerProfileImageId, + followingId = followingId, + followingNickname = followingNickname, + followingProfileImageId = followingProfileImageId + ) }.let { // followerId 검증이 먼저 수행되므로 해당 에러 메시지가 나옴 assertEquals("팔로워 ID는 양수여야 합니다", it.message) @@ -128,11 +201,20 @@ class FollowRelationshipTest { fun `create - failure - throws exception when followerNickname is blank`() { val followerId = 1L val followerNickname = "" + val followerProfileImageId = 1L val followingId = 2L val followingNickname = "following" + val followingProfileImageId = 2L assertFailsWith { - FollowRelationship(followerId, followerNickname, followingId, followingNickname) + FollowRelationship( + followerId = followerId, + followerNickname = followerNickname, + followerProfileImageId = followerProfileImageId, + followingId = followingId, + followingNickname = followingNickname, + followingProfileImageId = followingProfileImageId + ) }.let { assertEquals("팔로워 닉네임은 비어있을 수 없습니다", it.message) } @@ -142,11 +224,20 @@ class FollowRelationshipTest { fun `create - failure - throws exception when followerNickname is only whitespace`() { val followerId = 1L val followerNickname = " " + val followerProfileImageId = 1L val followingId = 2L val followingNickname = "following" + val followingProfileImageId = 2L assertFailsWith { - FollowRelationship(followerId, followerNickname, followingId, followingNickname) + FollowRelationship( + followerId = followerId, + followerNickname = followerNickname, + followerProfileImageId = followerProfileImageId, + followingId = followingId, + followingNickname = followingNickname, + followingProfileImageId = followingProfileImageId + ) }.let { assertEquals("팔로워 닉네임은 비어있을 수 없습니다", it.message) } @@ -156,11 +247,20 @@ class FollowRelationshipTest { fun `create - failure - throws exception when followingNickname is blank`() { val followerId = 1L val followerNickname = "follower" + val followerProfileImageId = 1L val followingId = 2L val followingNickname = "" + val followingProfileImageId = 2L assertFailsWith { - FollowRelationship(followerId, followerNickname, followingId, followingNickname) + FollowRelationship( + followerId = followerId, + followerNickname = followerNickname, + followerProfileImageId = followerProfileImageId, + followingId = followingId, + followingNickname = followingNickname, + followingProfileImageId = followingProfileImageId + ) }.let { assertEquals("팔로잉 대상 닉네임은 비어있을 수 없습니다", it.message) } @@ -170,11 +270,20 @@ class FollowRelationshipTest { fun `create - failure - throws exception when followingNickname is only whitespace`() { val followerId = 1L val followerNickname = "follower" + val followerProfileImageId = 1L val followingId = 2L val followingNickname = " " + val followingProfileImageId = 2L assertFailsWith { - FollowRelationship(followerId, followerNickname, followingId, followingNickname) + FollowRelationship( + followerId = followerId, + followerNickname = followerNickname, + followerProfileImageId = followerProfileImageId, + followingId = followingId, + followingNickname = followingNickname, + followingProfileImageId = followingProfileImageId + ) }.let { assertEquals("팔로잉 대상 닉네임은 비어있을 수 없습니다", it.message) } @@ -184,11 +293,20 @@ class FollowRelationshipTest { fun `create - failure - throws exception when both nicknames are blank`() { val followerId = 1L val followerNickname = "" + val followerProfileImageId = 1L val followingId = 2L val followingNickname = "" + val followingProfileImageId = 2L assertFailsWith { - FollowRelationship(followerId, followerNickname, followingId, followingNickname) + FollowRelationship( + followerId = followerId, + followerNickname = followerNickname, + followerProfileImageId = followerProfileImageId, + followingId = followingId, + followingNickname = followingNickname, + followingProfileImageId = followingProfileImageId + ) }.let { // followerNickname 검증이 먼저 수행되므로 해당 에러 메시지가 나옴 assertEquals("팔로워 닉네임은 비어있을 수 없습니다", it.message) @@ -199,16 +317,27 @@ class FollowRelationshipTest { fun `create - success - accepts valid nicknames with special characters`() { val followerId = 1L val followerNickname = "follower_123" + val followerProfileImageId = 1L val followingId = 2L val followingNickname = "following-456" - - val relationship = FollowRelationship(followerId, followerNickname, followingId, followingNickname) + val followingProfileImageId = 2L + + val relationship = FollowRelationship( + followerId = followerId, + followerNickname = followerNickname, + followerProfileImageId = followerProfileImageId, + followingId = followingId, + followingNickname = followingNickname, + followingProfileImageId = followingProfileImageId + ) assertAll( { assertEquals(followerId, relationship.followerId) }, { assertEquals(followerNickname, relationship.followerNickname) }, + { assertEquals(followerProfileImageId, relationship.followerProfileImageId) }, { assertEquals(followingId, relationship.followingId) }, - { assertEquals(followingNickname, relationship.followingNickname) } + { assertEquals(followingNickname, relationship.followingNickname) }, + { assertEquals(followingProfileImageId, relationship.followingProfileImageId) } ) } @@ -216,16 +345,27 @@ class FollowRelationshipTest { fun `create - success - accepts valid nicknames with Korean characters`() { val followerId = 1L val followerNickname = "팔로워" + val followerProfileImageId = 1L val followingId = 2L val followingNickname = "팔로잉대상" - - val relationship = FollowRelationship(followerId, followerNickname, followingId, followingNickname) + val followingProfileImageId = 2L + + val relationship = FollowRelationship( + followerId = followerId, + followerNickname = followerNickname, + followerProfileImageId = followerProfileImageId, + followingId = followingId, + followingNickname = followingNickname, + followingProfileImageId = followingProfileImageId + ) assertAll( { assertEquals(followerId, relationship.followerId) }, { assertEquals(followerNickname, relationship.followerNickname) }, + { assertEquals(followerProfileImageId, relationship.followerProfileImageId) }, { assertEquals(followingId, relationship.followingId) }, - { assertEquals(followingNickname, relationship.followingNickname) } + { assertEquals(followingNickname, relationship.followingNickname) }, + { assertEquals(followingProfileImageId, relationship.followingProfileImageId) } ) } @@ -233,16 +373,27 @@ class FollowRelationshipTest { fun `create - success - accepts large positive member IDs`() { val followerId = Long.MAX_VALUE - 1 val followerNickname = "follower" + val followerProfileImageId = Long.MAX_VALUE - 1 val followingId = Long.MAX_VALUE val followingNickname = "following" - - val relationship = FollowRelationship(followerId, followerNickname, followingId, followingNickname) + val followingProfileImageId = Long.MAX_VALUE + + val relationship = FollowRelationship( + followerId = followerId, + followerNickname = followerNickname, + followerProfileImageId = followerProfileImageId, + followingId = followingId, + followingNickname = followingNickname, + followingProfileImageId = followingProfileImageId + ) assertAll( { assertEquals(followerId, relationship.followerId) }, { assertEquals(followerNickname, relationship.followerNickname) }, + { assertEquals(followerProfileImageId, relationship.followerProfileImageId) }, { assertEquals(followingId, relationship.followingId) }, - { assertEquals(followingNickname, relationship.followingNickname) } + { assertEquals(followingNickname, relationship.followingNickname) }, + { assertEquals(followingProfileImageId, relationship.followingProfileImageId) } ) } @@ -250,16 +401,27 @@ class FollowRelationshipTest { fun `create - success - accepts minimum positive member IDs`() { val followerId = 1L val followerNickname = "follower" + val followerProfileImageId = 1L val followingId = 2L val followingNickname = "following" - - val relationship = FollowRelationship(followerId, followerNickname, followingId, followingNickname) + val followingProfileImageId = 2L + + val relationship = FollowRelationship( + followerId = followerId, + followerNickname = followerNickname, + followerProfileImageId = followerProfileImageId, + followingId = followingId, + followingNickname = followingNickname, + followingProfileImageId = followingProfileImageId + ) assertAll( { assertEquals(followerId, relationship.followerId) }, { assertEquals(followerNickname, relationship.followerNickname) }, + { assertEquals(followerProfileImageId, relationship.followerProfileImageId) }, { assertEquals(followingId, relationship.followingId) }, - { assertEquals(followingNickname, relationship.followingNickname) } + { assertEquals(followingNickname, relationship.followingNickname) }, + { assertEquals(followingProfileImageId, relationship.followingProfileImageId) } ) } @@ -267,9 +429,18 @@ class FollowRelationshipTest { fun `isFollower - success - returns true when member is the follower`() { val followerId = 1L val followerNickname = "follower" + val followerProfileImageId = 1L val followingId = 2L val followingNickname = "following" - val relationship = FollowRelationship(followerId, followerNickname, followingId, followingNickname) + val followingProfileImageId = 2L + val relationship = FollowRelationship( + followerId = followerId, + followerNickname = followerNickname, + followerProfileImageId = followerProfileImageId, + followingId = followingId, + followingNickname = followingNickname, + followingProfileImageId = followingProfileImageId + ) val result = relationship.isFollower(followerId) @@ -280,10 +451,19 @@ class FollowRelationshipTest { fun `isFollower - success - returns false when member is not the follower`() { val followerId = 1L val followerNickname = "follower" + val followerProfileImageId = 1L val followingId = 2L val followingNickname = "following" + val followingProfileImageId = 2L val otherId = 3L - val relationship = FollowRelationship(followerId, followerNickname, followingId, followingNickname) + val relationship = FollowRelationship( + followerId = followerId, + followerNickname = followerNickname, + followerProfileImageId = followerProfileImageId, + followingId = followingId, + followingNickname = followingNickname, + followingProfileImageId = followingProfileImageId + ) val result = relationship.isFollower(otherId) @@ -294,9 +474,18 @@ class FollowRelationshipTest { fun `isFollower - success - returns false when member is the following target`() { val followerId = 1L val followerNickname = "follower" + val followerProfileImageId = 1L val followingId = 2L val followingNickname = "following" - val relationship = FollowRelationship(followerId, followerNickname, followingId, followingNickname) + val followingProfileImageId = 2L + val relationship = FollowRelationship( + followerId = followerId, + followerNickname = followerNickname, + followerProfileImageId = followerProfileImageId, + followingId = followingId, + followingNickname = followingNickname, + followingProfileImageId = followingProfileImageId + ) val result = relationship.isFollower(followingId) @@ -307,9 +496,18 @@ class FollowRelationshipTest { fun `isFollowing - success - returns true when member is the following target`() { val followerId = 1L val followerNickname = "follower" + val followerProfileImageId = 1L val followingId = 2L val followingNickname = "following" - val relationship = FollowRelationship(followerId, followerNickname, followingId, followingNickname) + val followingProfileImageId = 2L + val relationship = FollowRelationship( + followerId = followerId, + followerNickname = followerNickname, + followerProfileImageId = followerProfileImageId, + followingId = followingId, + followingNickname = followingNickname, + followingProfileImageId = followingProfileImageId + ) val result = relationship.isFollowing(followingId) @@ -320,10 +518,19 @@ class FollowRelationshipTest { fun `isFollowing - success - returns false when member is not the following target`() { val followerId = 1L val followerNickname = "follower" + val followerProfileImageId = 1L val followingId = 2L val followingNickname = "following" + val followingProfileImageId = 2L val otherId = 3L - val relationship = FollowRelationship(followerId, followerNickname, followingId, followingNickname) + val relationship = FollowRelationship( + followerId = followerId, + followerNickname = followerNickname, + followerProfileImageId = followerProfileImageId, + followingId = followingId, + followingNickname = followingNickname, + followingProfileImageId = followingProfileImageId + ) val result = relationship.isFollowing(otherId) @@ -334,12 +541,113 @@ class FollowRelationshipTest { fun `isFollowing - success - returns false when member is the follower`() { val followerId = 1L val followerNickname = "follower" + val followerProfileImageId = 1L val followingId = 2L val followingNickname = "following" - val relationship = FollowRelationship(followerId, followerNickname, followingId, followingNickname) + val followingProfileImageId = 2L + val relationship = FollowRelationship( + followerId = followerId, + followerNickname = followerNickname, + followerProfileImageId = followerProfileImageId, + followingId = followingId, + followingNickname = followingNickname, + followingProfileImageId = followingProfileImageId + ) val result = relationship.isFollowing(followerId) assertFalse(result) } + + @Test + fun `create - failure - throws exception when followerProfileImageId is zero`() { + val followerId = 1L + val followerNickname = "follower" + val followerProfileImageId = 0L + val followingId = 2L + val followingNickname = "following" + val followingProfileImageId = 2L + + assertFailsWith { + FollowRelationship( + followerId = followerId, + followerNickname = followerNickname, + followerProfileImageId = followerProfileImageId, + followingId = followingId, + followingNickname = followingNickname, + followingProfileImageId = followingProfileImageId + ) + }.let { + assertEquals("팔로워 프로필 이미지 ID는 양수여야 합니다", it.message) + } + } + + @Test + fun `create - failure - throws exception when followerProfileImageId is negative`() { + val followerId = 1L + val followerNickname = "follower" + val followerProfileImageId = -1L + val followingId = 2L + val followingNickname = "following" + val followingProfileImageId = 2L + + assertFailsWith { + FollowRelationship( + followerId = followerId, + followerNickname = followerNickname, + followerProfileImageId = followerProfileImageId, + followingId = followingId, + followingNickname = followingNickname, + followingProfileImageId = followingProfileImageId + ) + }.let { + assertEquals("팔로워 프로필 이미지 ID는 양수여야 합니다", it.message) + } + } + + @Test + fun `create - failure - throws exception when followingProfileImageId is zero`() { + val followerId = 1L + val followerNickname = "follower" + val followerProfileImageId = 1L + val followingId = 2L + val followingNickname = "following" + val followingProfileImageId = 0L + + assertFailsWith { + FollowRelationship( + followerId = followerId, + followerNickname = followerNickname, + followerProfileImageId = followerProfileImageId, + followingId = followingId, + followingNickname = followingNickname, + followingProfileImageId = followingProfileImageId + ) + }.let { + assertEquals("팔로잉 대상 프로필 이미지 ID는 양수여야 합니다", it.message) + } + } + + @Test + fun `create - failure - throws exception when followingProfileImageId is negative`() { + val followerId = 1L + val followerNickname = "follower" + val followerProfileImageId = 1L + val followingId = 2L + val followingNickname = "following" + val followingProfileImageId = -1L + + assertFailsWith { + FollowRelationship( + followerId = followerId, + followerNickname = followerNickname, + followerProfileImageId = followerProfileImageId, + followingId = followingId, + followingNickname = followingNickname, + followingProfileImageId = followingProfileImageId + ) + }.let { + assertEquals("팔로잉 대상 프로필 이미지 ID는 양수여야 합니다", it.message) + } + } } diff --git a/src/test/kotlin/com/albert/realmoneyrealtaste/domain/friend/FriendshipTest.kt b/src/test/kotlin/com/albert/realmoneyrealtaste/domain/friend/FriendshipTest.kt index e6f58e6c..65e2d99b 100644 --- a/src/test/kotlin/com/albert/realmoneyrealtaste/domain/friend/FriendshipTest.kt +++ b/src/test/kotlin/com/albert/realmoneyrealtaste/domain/friend/FriendshipTest.kt @@ -16,9 +16,10 @@ class FriendshipTest { @Test fun `request - success - creates pending friendship with valid command`() { val fromMemberId = 1L + val fromMemberNickname = "sender" val toMemberId = 2L val toMemberNickname = "receiver" - val command = FriendRequestCommand(fromMemberId, toMemberId, toMemberNickname) + val command = FriendRequestCommand(fromMemberId, fromMemberNickname, toMemberId, toMemberNickname) val before = LocalDateTime.now() val friendship = Friendship.request(command) @@ -286,7 +287,7 @@ class FriendshipTest { @Test fun `friendship lifecycle - success - complete workflow from request to unfriend`() { - val command = FriendRequestCommand(1L, 2L, "receiver") + val command = FriendRequestCommand(1L, "sender", 2L, "receiver") // 1. 친구 요청 생성 val friendship = Friendship.request(command) @@ -303,7 +304,7 @@ class FriendshipTest { @Test fun `friendship lifecycle - success - complete workflow from request to reject`() { - val command = FriendRequestCommand(1L, 2L, "receiver") + val command = FriendRequestCommand(1L, "sender", 2L, "receiver") // 1. 친구 요청 생성 val friendship = Friendship.request(command) @@ -315,7 +316,7 @@ class FriendshipTest { } private fun createPendingFriendship(): Friendship { - val command = FriendRequestCommand(1L, 2L, "receiver") + val command = FriendRequestCommand(1L, "sender", 2L, "receiver") return Friendship.request(command) } @@ -326,7 +327,7 @@ class FriendshipTest { } private fun createFriendship(fromMemberId: Long, toMemberId: Long): Friendship { - val command = FriendRequestCommand(fromMemberId, toMemberId, "receiver") + val command = FriendRequestCommand(fromMemberId, "sender", toMemberId, "receiver") return Friendship.request(command) } } diff --git a/src/test/kotlin/com/albert/realmoneyrealtaste/domain/friend/command/FriendRequestCommandTest.kt b/src/test/kotlin/com/albert/realmoneyrealtaste/domain/friend/command/FriendRequestCommandTest.kt index 4cb29528..9643d025 100644 --- a/src/test/kotlin/com/albert/realmoneyrealtaste/domain/friend/command/FriendRequestCommandTest.kt +++ b/src/test/kotlin/com/albert/realmoneyrealtaste/domain/friend/command/FriendRequestCommandTest.kt @@ -10,10 +10,11 @@ class FriendRequestCommandTest { @Test fun `create - success - creates command with valid member IDs`() { val fromMemberId = 1L + val fromMemberNickname = "sender" val toMemberId = 2L val toMemberNickname = "receiver" - val command = FriendRequestCommand(fromMemberId, toMemberId, toMemberNickname) + val command = FriendRequestCommand(fromMemberId, fromMemberNickname, toMemberId, toMemberNickname) assertAll( { assertEquals(fromMemberId, command.fromMemberId) }, @@ -25,10 +26,12 @@ class FriendRequestCommandTest { @Test fun `create - failure - throws exception when fromMemberId is zero`() { val fromMemberId = 0L + val fromMemberNickname = "sender" val toMemberId = 2L + val toMemberNickname = "receiver" assertFailsWith { - FriendRequestCommand(fromMemberId, toMemberId, "receiver") + FriendRequestCommand(fromMemberId, fromMemberNickname, toMemberId, toMemberNickname) }.let { assertEquals(FriendRequestCommand.ERROR_FROM_MEMBER_ID_MUST_BE_POSITIVE, it.message) } @@ -37,10 +40,12 @@ class FriendRequestCommandTest { @Test fun `create - failure - throws exception when fromMemberId is negative`() { val fromMemberId = -1L + val fromMemberNickname = "sender" val toMemberId = 2L + val toMemberNickname = "receiver" assertFailsWith { - FriendRequestCommand(fromMemberId, toMemberId, "receiver") + FriendRequestCommand(fromMemberId, fromMemberNickname, toMemberId, toMemberNickname) }.let { assertEquals(FriendRequestCommand.ERROR_FROM_MEMBER_ID_MUST_BE_POSITIVE, it.message) } @@ -49,10 +54,12 @@ class FriendRequestCommandTest { @Test fun `create - failure - throws exception when toMemberId is zero`() { val fromMemberId = 1L + val fromMemberNickname = "sender" val toMemberId = 0L + val toMemberNickname = "receiver" assertFailsWith { - FriendRequestCommand(fromMemberId, toMemberId, "receiver") + FriendRequestCommand(fromMemberId, fromMemberNickname, toMemberId, toMemberNickname) }.let { assertEquals(FriendRequestCommand.ERROR_TO_MEMBER_ID_MUST_BE_POSITIVE, it.message) } @@ -61,10 +68,12 @@ class FriendRequestCommandTest { @Test fun `create - failure - throws exception when toMemberId is negative`() { val fromMemberId = 1L + val fromMemberNickname = "sender" val toMemberId = -1L + val toMemberNickname = "receiver" assertFailsWith { - FriendRequestCommand(fromMemberId, toMemberId, "receiver") + FriendRequestCommand(fromMemberId, fromMemberNickname, toMemberId, toMemberNickname) }.let { assertEquals(FriendRequestCommand.ERROR_TO_MEMBER_ID_MUST_BE_POSITIVE, it.message) } @@ -73,9 +82,11 @@ class FriendRequestCommandTest { @Test fun `create - failure - throws exception when fromMemberId and toMemberId are same`() { val memberId = 1L + val fromMemberNickname = "sender" + val toMemberNickname = "receiver" assertFailsWith { - FriendRequestCommand(memberId, memberId, "receiver") + FriendRequestCommand(memberId, fromMemberNickname, memberId, toMemberNickname) }.let { assertEquals(FriendRequestCommand.ERROR_CANNOT_REQUEST_FRIENDSHIP_TO_YOURSELF, it.message) } @@ -85,9 +96,11 @@ class FriendRequestCommandTest { fun `create - failure - throws exception when both member IDs are zero`() { val fromMemberId = 0L val toMemberId = 0L + val fromMemberNickname = "sender" + val toMemberNickname = "receiver" assertFailsWith { - FriendRequestCommand(fromMemberId, toMemberId, "receiver") + FriendRequestCommand(fromMemberId, fromMemberNickname, toMemberId, toMemberNickname) }.let { // fromMemberId 검증이 먼저 수행되므로 해당 에러 메시지가 나옴 assertEquals(FriendRequestCommand.ERROR_FROM_MEMBER_ID_MUST_BE_POSITIVE, it.message) @@ -98,9 +111,11 @@ class FriendRequestCommandTest { fun `create - failure - throws exception when both member IDs are negative`() { val fromMemberId = -1L val toMemberId = -2L + val fromMemberNickname = "sender" + val toMemberNickname = "receiver" assertFailsWith { - FriendRequestCommand(fromMemberId, toMemberId, "receiver") + FriendRequestCommand(fromMemberId, fromMemberNickname, toMemberId, toMemberNickname) }.let { // fromMemberId 검증이 먼저 수행되므로 해당 에러 메시지가 나옴 assertEquals(FriendRequestCommand.ERROR_FROM_MEMBER_ID_MUST_BE_POSITIVE, it.message) @@ -111,8 +126,10 @@ class FriendRequestCommandTest { fun `create - success - accepts large positive member IDs`() { val fromMemberId = Long.MAX_VALUE - 1 val toMemberId = Long.MAX_VALUE + val fromMemberNickname = "sender" + val toMemberNickname = "receiver" - val command = FriendRequestCommand(fromMemberId, toMemberId, "receiver") + val command = FriendRequestCommand(fromMemberId, fromMemberNickname, toMemberId, toMemberNickname) assertAll( { assertEquals(fromMemberId, command.fromMemberId) }, @@ -125,8 +142,10 @@ class FriendRequestCommandTest { fun `create - success - accepts minimum positive member IDs`() { val fromMemberId = 1L val toMemberId = 1L + 1 + val fromMemberNickname = "sender" + val toMemberNickname = "receiver" - val command = FriendRequestCommand(fromMemberId, toMemberId, "receiver") + val command = FriendRequestCommand(fromMemberId, fromMemberNickname, toMemberId, toMemberNickname) assertAll( { assertEquals(fromMemberId, command.fromMemberId) }, @@ -139,10 +158,11 @@ class FriendRequestCommandTest { fun `create - failure - throws exception when toMemberNickname is empty`() { val fromMemberId = 1L val toMemberId = 2L - val emptyNickname = "" + val fromMemberNickname = "sender" + val toMemberNickname = "" assertFailsWith { - FriendRequestCommand(fromMemberId, toMemberId, emptyNickname) + FriendRequestCommand(fromMemberId, fromMemberNickname, toMemberId, toMemberNickname) }.let { assertEquals(FriendRequestCommand.ERROR_TO_MEMBER_NICKNAME_MUST_NOT_BE_EMPTY, it.message) } @@ -152,130 +172,58 @@ class FriendRequestCommandTest { fun `create - success - accepts valid toMemberNickname`() { val fromMemberId = 1L val toMemberId = 2L - val nickname = "testUser" + val fromMemberNickname = "sender" + val toMemberNickname = "receiver" - val command = FriendRequestCommand(fromMemberId, toMemberId, nickname) + val command = FriendRequestCommand(fromMemberId, fromMemberNickname, toMemberId, toMemberNickname) - assertEquals(nickname, command.toMemberNickname) + assertAll( + { assertEquals(toMemberNickname, command.toMemberNickname) }, + { assertEquals(toMemberId, command.toMemberId) }, + { assertEquals(fromMemberNickname, command.fromMemberNickName) }, + { assertEquals(fromMemberId, command.fromMemberId) } + ) } @Test fun `create - failure - throws exception when toMemberNickname is blank`() { val fromMemberId = 1L val toMemberId = 2L - val blankNickname = " " + val fromMemberNickname = "sender" + val toMemberNickname = " " assertFailsWith { - FriendRequestCommand(fromMemberId, toMemberId, blankNickname) + FriendRequestCommand(fromMemberId, fromMemberNickname, toMemberId, toMemberNickname) }.let { assertEquals(FriendRequestCommand.ERROR_TO_MEMBER_NICKNAME_MUST_NOT_BE_EMPTY, it.message) } } - @Test - fun `create - success - accepts special characters in nickname`() { - val fromMemberId = 1L - val toMemberId = 2L - val specialNickname = "특수문자_한글123!@#" - - val command = FriendRequestCommand(fromMemberId, toMemberId, specialNickname) - - assertEquals(specialNickname, command.toMemberNickname) - } - - @Test - fun `create - success - accepts very long nickname`() { - val fromMemberId = 1L - val toMemberId = 2L - val longNickname = "a".repeat(1000) // 매우 긴 닉네임 - - val command = FriendRequestCommand(fromMemberId, toMemberId, longNickname) - - assertEquals(longNickname, command.toMemberNickname) - } - - @Test - fun `create - success - accepts unicode characters in nickname`() { - val fromMemberId = 1L - val toMemberId = 2L - val unicodeNickname = "🎉测试用户😊" - - val command = FriendRequestCommand(fromMemberId, toMemberId, unicodeNickname) - - assertEquals(unicodeNickname, command.toMemberNickname) - } - - @Test - fun `create - success - accepts nickname with spaces`() { - val fromMemberId = 1L - val toMemberId = 2L - val nicknameWithSpaces = "John Doe" - - val command = FriendRequestCommand(fromMemberId, toMemberId, nicknameWithSpaces) - - assertEquals(nicknameWithSpaces, command.toMemberNickname) - } - @Test fun `create - failure - throws exception when nickname is only whitespace`() { val fromMemberId = 1L val toMemberId = 2L + val fromMemberNickname = "sender" val whitespaceNickname = "\t\n\r " assertFailsWith { - FriendRequestCommand(fromMemberId, toMemberId, whitespaceNickname) + FriendRequestCommand(fromMemberId, fromMemberNickname, toMemberId, whitespaceNickname) }.let { assertEquals(FriendRequestCommand.ERROR_TO_MEMBER_NICKNAME_MUST_NOT_BE_EMPTY, it.message) } } @Test - fun `create - success - accepts single character nickname`() { + fun `create - failure - throws exception when from nickname is empty`() { val fromMemberId = 1L val toMemberId = 2L - val singleCharNickname = "A" - - val command = FriendRequestCommand(fromMemberId, toMemberId, singleCharNickname) - - assertEquals(singleCharNickname, command.toMemberNickname) - } - - @Test - fun `create - success - maintains order of member IDs`() { - val fromMemberId = 100L - val toMemberId = 200L - val nickname = "receiver" - - val command = FriendRequestCommand(fromMemberId, toMemberId, nickname) - - assertAll( - { assertEquals(fromMemberId, command.fromMemberId) }, - { assertEquals(toMemberId, command.toMemberId) }, - { assertEquals(nickname, command.toMemberNickname) } - ) - } - - @Test - fun `create - failure - validates fromMemberId before toMemberId`() { - val fromMemberId = 0L - val toMemberId = 0L - - assertFailsWith { - FriendRequestCommand(fromMemberId, toMemberId, "test") - }.let { - assertEquals(FriendRequestCommand.ERROR_FROM_MEMBER_ID_MUST_BE_POSITIVE, it.message) - } - } - - @Test - fun `create - failure - validates toMemberId before nickname check`() { - val fromMemberId = 1L - val toMemberId = 0L + val emptyNickname = "" + val toMemberNickname = "receiver" assertFailsWith { - FriendRequestCommand(fromMemberId, toMemberId, "") + FriendRequestCommand(fromMemberId, emptyNickname, toMemberId, toMemberNickname) }.let { - assertEquals(FriendRequestCommand.ERROR_TO_MEMBER_ID_MUST_BE_POSITIVE, it.message) + assertEquals(FriendRequestCommand.ERROR_FROM_MEMBER_NICKNAME_MUST_NOT_BE_EMPTY, it.message) } } } diff --git a/src/test/kotlin/com/albert/realmoneyrealtaste/domain/friend/value/FriendRelationshipTest.kt b/src/test/kotlin/com/albert/realmoneyrealtaste/domain/friend/value/FriendRelationshipTest.kt index d0afda54..8f4fdd01 100644 --- a/src/test/kotlin/com/albert/realmoneyrealtaste/domain/friend/value/FriendRelationshipTest.kt +++ b/src/test/kotlin/com/albert/realmoneyrealtaste/domain/friend/value/FriendRelationshipTest.kt @@ -12,9 +12,10 @@ class FriendRelationshipTest { fun `create - success - creates relationship with valid member IDs`() { val memberId = 1L val friendMemberId = 2L - val friendNickname = "friend" + val fromMemberNickname = "sender" + val friendNickname = "receiver" - val relationship = FriendRelationship(memberId, friendMemberId, friendNickname) + val relationship = FriendRelationship(memberId, fromMemberNickname, friendMemberId, friendNickname) assertAll( { assertEquals(memberId, relationship.memberId) }, @@ -26,10 +27,12 @@ class FriendRelationshipTest { @Test fun `create - failure - throws exception when memberId is zero`() { val memberId = 0L + val fromMemberNickname = "sender" + val friendNickname = "receiver" val friendMemberId = 2L assertFailsWith { - FriendRelationship(memberId, friendMemberId, "friend") + FriendRelationship(memberId, fromMemberNickname, friendMemberId, friendNickname) }.let { assertEquals(FriendRelationship.ERROR_MEMBER_ID_MUST_BE_POSITIVE, it.message) } @@ -39,9 +42,11 @@ class FriendRelationshipTest { fun `create - failure - throws exception when memberId is negative`() { val memberId = -1L val friendMemberId = 2L + val fromMemberNickname = "sender" + val friendNickname = "receiver" assertFailsWith { - FriendRelationship(memberId, friendMemberId, "friend") + FriendRelationship(memberId, fromMemberNickname, friendMemberId, friendNickname) }.let { assertEquals(FriendRelationship.ERROR_MEMBER_ID_MUST_BE_POSITIVE, it.message) } @@ -51,9 +56,11 @@ class FriendRelationshipTest { fun `create - failure - throws exception when friendMemberId is zero`() { val memberId = 1L val friendMemberId = 0L + val fromMemberNickname = "sender" + val friendNickname = "receiver" assertFailsWith { - FriendRelationship(memberId, friendMemberId, "friend") + FriendRelationship(memberId, fromMemberNickname, friendMemberId, friendNickname) }.let { assertEquals(FriendRelationship.ERROR_FRIEND_MEMBER_ID_MUST_BE_POSITIVE, it.message) } @@ -63,9 +70,11 @@ class FriendRelationshipTest { fun `create - failure - throws exception when friendMemberId is negative`() { val memberId = 1L val friendMemberId = -1L + val fromMemberNickname = "sender" + val friendNickname = "receiver" assertFailsWith { - FriendRelationship(memberId, friendMemberId, "friend") + FriendRelationship(memberId, fromMemberNickname, friendMemberId, friendNickname) }.let { assertEquals(FriendRelationship.ERROR_FRIEND_MEMBER_ID_MUST_BE_POSITIVE, it.message) } @@ -74,114 +83,31 @@ class FriendRelationshipTest { @Test fun `create - failure - throws exception when memberId and friendMemberId are same`() { val memberId = 1L + val fromMemberNickname = "sender" + val friendNickname = "receiver" assertFailsWith { - FriendRelationship(memberId, memberId, "friend") + FriendRelationship(memberId, fromMemberNickname, memberId, friendNickname) }.let { assertEquals(FriendRelationship.ERROR_CANNOT_FRIEND_YOURSELF, it.message) } } - @Test - fun `create - failure - throws exception when both member IDs are zero`() { - val memberId = 0L - val friendMemberId = 0L - - assertFailsWith { - FriendRelationship(memberId, friendMemberId, "friend") - }.let { - // memberId 검증이 먼저 수행되므로 해당 에러 메시지가 나옴 - assertEquals(FriendRelationship.ERROR_MEMBER_ID_MUST_BE_POSITIVE, it.message) - } - } - - @Test - fun `create - failure - throws exception when both member IDs are negative`() { - val memberId = -1L - val friendMemberId = -2L - - assertFailsWith { - FriendRelationship(memberId, friendMemberId, "friend") - }.let { - // memberId 검증이 먼저 수행되므로 해당 에러 메시지가 나옴 - assertEquals(FriendRelationship.ERROR_MEMBER_ID_MUST_BE_POSITIVE, it.message) - } - } - - @Test - fun `create - success - accepts large positive member IDs`() { - val memberId = Long.MAX_VALUE - 1 - val friendMemberId = Long.MAX_VALUE - - val relationship = FriendRelationship(memberId, friendMemberId, "friend") - - assertAll( - { assertEquals(memberId, relationship.memberId) }, - { assertEquals(friendMemberId, relationship.friendMemberId) }, - { assertEquals("friend", relationship.friendNickname) } - ) - } - - @Test - fun `create - success - accepts minimum positive member IDs`() { - val memberId = 1L - val friendMemberId = 2L - - val relationship = FriendRelationship(memberId, friendMemberId, "friend") - - assertAll( - { assertEquals(memberId, relationship.memberId) }, - { assertEquals(friendMemberId, relationship.friendMemberId) }, - { assertEquals("friend", relationship.friendNickname) } - ) - } - - @Test - fun `of - success - creates relationship from FriendRequestCommand`() { - val fromMemberId = 1L - val toMemberId = 2L - val toMemberNickname = "receiver" - val command = FriendRequestCommand(fromMemberId, toMemberId, toMemberNickname) - - val relationship = FriendRelationship.of(command) - - assertAll( - { assertEquals(fromMemberId, relationship.memberId) }, - { assertEquals(toMemberId, relationship.friendMemberId) }, - { assertEquals(toMemberNickname, relationship.friendNickname) } - ) - } - - @Test - fun `of - success - maps command member IDs correctly`() { - val fromMemberId = 100L - val toMemberId = 200L - val toMemberNickname = "receiver" - val command = FriendRequestCommand(fromMemberId, toMemberId, toMemberNickname) - - val relationship = FriendRelationship.of(command) - - assertAll( - { assertEquals(command.fromMemberId, relationship.memberId) }, - { assertEquals(command.toMemberId, relationship.friendMemberId) }, - { assertEquals(command.toMemberNickname, relationship.friendNickname) } - ) - } - @Test fun `of - success - creates valid relationship when command is valid`() { val fromMemberId = 50L val toMemberId = 75L + val fromMemberNickname = "sender" val toMemberNickname = "receiver" - val command = FriendRequestCommand(fromMemberId, toMemberId, toMemberNickname) + val command = FriendRequestCommand(fromMemberId, fromMemberNickname, toMemberId, toMemberNickname) val relationship = FriendRelationship.of(command) - // 생성된 관계가 유효한지 확인 (예외가 발생하지 않음) assertAll( { assertEquals(fromMemberId, relationship.memberId) }, { assertEquals(toMemberId, relationship.friendMemberId) }, - { assertEquals(toMemberNickname, relationship.friendNickname) } + { assertEquals(toMemberNickname, relationship.friendNickname) }, + { assertEquals(fromMemberNickname, relationship.memberNickname) } ) } } diff --git a/src/test/kotlin/com/albert/realmoneyrealtaste/domain/member/MemberTest.kt b/src/test/kotlin/com/albert/realmoneyrealtaste/domain/member/MemberTest.kt index 1e9c7e9c..64165933 100644 --- a/src/test/kotlin/com/albert/realmoneyrealtaste/domain/member/MemberTest.kt +++ b/src/test/kotlin/com/albert/realmoneyrealtaste/domain/member/MemberTest.kt @@ -15,6 +15,7 @@ import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertFailsWith import kotlin.test.assertFalse +import kotlin.test.assertNull import kotlin.test.assertTrue class MemberTest { @@ -157,23 +158,6 @@ class MemberTest { } } - @Test - fun `updateInfo - success - keeps existing values when no parameters provided`() { - val member = createMember() - member.activate() - val beforeNickname = member.nickname - val beforeProfileAddress = member.detail.profileAddress - val beforeIntroduction = member.detail.introduction - val beforeUpdateAt = member.updatedAt - - member.updateInfo() - - assertEquals(beforeNickname, member.nickname) - assertEquals(beforeProfileAddress, member.detail.profileAddress) - assertEquals(beforeIntroduction, member.detail.introduction) - assertEquals(beforeUpdateAt, member.updatedAt) - } - @Test fun `updateInfo - success - updates only nickname when other parameters are null`() { val member = createMember() @@ -217,7 +201,7 @@ class MemberTest { val newIntroduction = Introduction("new intro") val beforeUpdateAt = member.updatedAt - member.updateInfo(nickname = null, profileAddress = null, introduction = newIntroduction) + member.updateInfo(nickname = beforeNickname, profileAddress = null, introduction = newIntroduction) assertEquals(beforeNickname, member.nickname) assertEquals(beforeProfileAddress, member.detail.profileAddress) @@ -242,6 +226,23 @@ class MemberTest { assertTrue(beforeUpdateAt < member.updatedAt) } + @Test + fun `updateInfo - success - updates nothing when parameters are same`() { + val member = createMember() + member.activate() + val beforeNickname = member.nickname + val beforeProfileAddress = member.detail.profileAddress + val beforeIntroduction = member.detail.introduction + val beforeUpdateAt = member.updatedAt + + member.updateInfo(nickname = beforeNickname, profileAddress = null, introduction = null) + + assertEquals(beforeNickname, member.nickname) + assertEquals(beforeProfileAddress, member.detail.profileAddress) + assertEquals(beforeIntroduction, member.detail.introduction) + assertEquals(beforeUpdateAt, member.updatedAt) + } + @Test fun `updateTrustScore - success - updates trust score and timestamp`() { val member = createMember() @@ -530,6 +531,7 @@ class MemberTest { roles = Roles.ofUser(), followersCount = 0, followingsCount = 0, + postCount = 0, ) { fun setEmailForTest(email: Email) { this.email = email @@ -587,4 +589,120 @@ class MemberTest { assertEquals(0, member.followingsCount) } + + @Test + fun `imageId - success - returns 1L when detail imageId is null`() { + val member = createMember() + + assertEquals(1L, member.imageId) + } + + @Test + fun `imageId - success - returns detail imageId when it exists`() { + val member = TestMember() + val testImageId = 123L + member.detail.updateInfo(null, null, null, testImageId) + + assertEquals(testImageId, member.imageId) + } + + @Test + fun `address - success - returns default address when detail address is null`() { + val member = createMember() + + assertEquals("푸디마을에 살고 있어요", member.address) + } + + @Test + fun `address - success - returns detail address when it exists`() { + val member = TestMember() + val testAddress = "서울시 강남구" + member.detail.updateInfo(null, null, testAddress, null) + + assertEquals(testAddress, member.address) + } + + @Test + fun `introduction - success - returns default introduction when detail introduction is null`() { + val member = createMember() + + assertNull(member.detail.introduction) + assertEquals("아직 자기소개가 없어요!", member.introduction) + } + + @Test + fun `introduction - success - returns detail introduction when it exists`() { + val member = TestMember() + val testIntroduction = Introduction("안녕하세요, 테스트입니다.") + member.detail.updateInfo(null, testIntroduction, null, null) + + assertEquals(testIntroduction.value, member.introduction) + } + + @Test + fun `registeredAt - success - returns detail registeredAt`() { + val member = createMember() + val now = LocalDateTime.now() + + assertTrue(member.registeredAt >= now.minusSeconds(1)) + assertTrue(member.registeredAt <= now.plusSeconds(1)) + } + + @Test + fun `grantRole - failure - throws exception when member is deactivated`() { + val member = createMember() + member.activate() + member.deactivate() + + assertFailsWith { + member.grantRole(Role.MANAGER) + }.let { + assertEquals("등록 완료 상태에서만 권한 변경이 가능합니다", it.message) + } + } + + @Test + fun `revokeRole - failure - throws exception when member is deactivated`() { + val member = createMember() + member.activate() + member.deactivate() + + assertFailsWith { + member.revokeRole(Role.USER) + }.let { + assertEquals("등록 완료 상태에서만 권한 변경이 가능합니다", it.message) + } + } + + @Test + fun `updateInfo - success - updates address and imageId`() { + val member = createMember() + member.activate() + val newAddress = "부산시 해운대구" + val newImageId = 456L + val beforeUpdateAt = member.updatedAt + + member.updateInfo(address = newAddress, imageId = newImageId) + + assertEquals(newAddress, member.address) + assertEquals(newImageId, member.imageId) + assertTrue(beforeUpdateAt < member.updatedAt) + } + + @Test + fun `postCount - success - returns initial post count`() { + val member = createMember() + + assertEquals(0L, member.postCount) + } + + @Test + fun `updatePostCount - success - updates post count`() { + val member = createMember() + val updatedPostCount = 20L + + member.updatePostCount(updatedPostCount) + + assertEquals(updatedPostCount, member.postCount) + } } diff --git a/src/test/resources/application-devdb.yml b/src/test/resources/application-devdb.yml index 34f9eeeb..d0a6e1fc 100644 --- a/src/test/resources/application-devdb.yml +++ b/src/test/resources/application-devdb.yml @@ -1,4 +1,4 @@ spring: jpa: hibernate: - ddl-auto: create-drop + ddl-auto: update diff --git a/src/test/resources/application.yml b/src/test/resources/application.yml index 88566006..e62f10d8 100644 --- a/src/test/resources/application.yml +++ b/src/test/resources/application.yml @@ -26,3 +26,5 @@ app: expiration-hours: 24 password-reset-token: expiration-hours: 1 +password: + mismatch: 새 비밀번호와 비밀번호 확인이 일치하지 않습니다.