diff --git a/README.md b/README.md index c64b6ebc..f3c2fefb 100644 --- a/README.md +++ b/README.md @@ -35,6 +35,7 @@ - 컬렉션 (게시글 모음, 공개/비공개 설정) - 프로필 페이지 (권한별 UI 분리) - 추천 사용자 (무작위 선택 기반) +- **활동 기록 (MemberEvent)**: 회원의 모든 활동 자동 추적 및 알림 ### 🏆 신뢰도 시스템 @@ -50,6 +51,15 @@ - 프로필 수정 (닉네임, 소개, 프로필 주소) - 회원 상태 관리 (PENDING → ACTIVE → DEACTIVATED) +### 🎯 도메인 이벤트 시스템 + +- **AggregateRoot 패턴**: 모든 도메인 엔티티에서 이벤트 발행 +- **이벤트 리스너**: 도메인 이벤트 기반 비동기 처리 + - MemberEvent 자동 생성 (친구 요청, 게시글 작성, 댓글 등) + - 이메일 알림 발송 (회원가입 인증, 비밀번호 재설정) +- **이벤트 타입**: 친구/게시물/댓글/프로필/시스템 (총 10가지) +- **읽음 상태 관리**: 읽은/읽지 않은 이벤트 분리 조회 + ## 🛠 기술 스택 **Backend** @@ -86,6 +96,7 @@ - `Hexagonal Architecture` (Ports & Adapters) - `DDD` (Domain-Driven Design) - `Clean Architecture` (의존성 역전) +- `Event-Driven Architecture` (도메인 이벤트 기반 비동기 처리) **Frontend** @@ -101,15 +112,15 @@ ### 📈 성과 지표 -| 항목 | 성과 | -|---------------|-----------------------------| -| **배포 자동화** | 배포 시간 83% 단축 (30분 → 5분) | -| **코드 품질** | SonarCloud 커버리지 80%+ 유지 | -| **테스트 코드** | 약 15,000줄 (단위/통합/API 테스트) | -| **아키텍처** | 헥사고날 아키텍처 + DDD 완벽 적용 | -| **이미지 시스템** | S3 Presigned URL로 서버 부하 최소화 | -| **DB 마이그레이션** | Flyway 기반 자동 스키마 버전 관리 | -| **문서화** | 2,800줄+ 기술 문서 | +| 항목 | 성과 | +|---------------|------------------------------| +| **배포 자동화** | 배포 시간 83% 단축 (30분 → 5분) | +| **코드 품질** | SonarCloud 커버리지 80%+ 유지 | +| **테스트 코드** | 약 15,000줄 (단위/통합/API 테스트) | +| **아키텍처** | 헥사고날 + DDD + Event-Driven 적용 | +| **이미지 시스템** | S3 Presigned URL로 서버 부하 최소화 | +| **DB 마이그레이션** | Flyway 기반 자동 스키마 버전 관리 | +| **문서화** | 2,800줄+ 기술 문서 | ### ☁️ 클라우드 아키텍처 diff --git a/docs/API_DOCUMENTATION.md b/docs/API_DOCUMENTATION.md index 0a06d342..46c39526 100644 --- a/docs/API_DOCUMENTATION.md +++ b/docs/API_DOCUMENTATION.md @@ -66,6 +66,13 @@ RMRT는 RESTful API와 WebView 기반의 하이브리드 구조를 제공합니 - [컬렉션 게시글 추가/제거](#컬렉션-게시글-추가제거) - [컬렉션 상세 조회](#컬렉션-상세-조회) +### 🔔 회원 이벤트 관련 API (NEW!) + +- [내 이벤트 목록 조회](#내-이벤트-목록-조회) +- [읽지 않은 이벤트 수 조회](#읽지-않은-이벤트-수-조회) +- [이벤트 읽음 처리](#이벤트-읽음-처리) +- [이벤트 일괄 읽음 처리](#이벤트-일괄-읽음-처리) + --- ## 👥 멤버 관련 API @@ -899,3 +906,225 @@ API 테스트는 다음 명령으로 실행할 수 있습니다: - 이 프로젝트는 REST API와 WebView가 혼합된 구조입니다 - JSON API는 `/api/*` 경로를 사용합니다 + +--- + +## 🔔 회원 이벤트 관련 API + +### 내 이벤트 목록 조회 + +현재 로그인한 사용자의 이벤트 목록을 조회합니다. + +```http +GET /api/events/me?isRead={isRead}&page={page}&size={size} +``` + +**Query Parameters:** + +| 파라미터 | 타입 | 필수 | 설명 | 기본값 | +|----------|---------|----|---------------------------------------------------------|-----------| +| `isRead` | Boolean | ✗ | 읽음 여부 필터 (`true`: 읽은 이벤트만, `false`: 읽지 않은 이벤트만, 생략: 전체) | null (전체) | +| `page` | Integer | ✗ | 페이지 번호 (0부터 시작) | 0 | +| `size` | Integer | ✗ | 페이지당 이벤트 수 | 20 | + +**응답 예시 (200 OK):** + +```json +{ + "events": [ + { + "id": 123, + "eventType": "FRIEND_REQUEST_RECEIVED", + "title": "새로운 친구 요청", + "message": "홍길동님이 친구 요청을 보냈습니다", + "isRead": false, + "createdAt": "2025-12-08T10:30:00", + "relatedMemberId": 456, + "relatedMemberNickname": "홍길동", + "relatedMemberProfileImageUrl": "https://example.com/profile.jpg", + "relatedPostId": null, + "relatedCommentId": null + }, + { + "id": 122, + "eventType": "POST_COMMENTED", + "title": "새로운 댓글", + "message": "내 게시물에 댓글이 달렸습니다", + "isRead": false, + "createdAt": "2025-12-08T09:15:00", + "relatedMemberId": 789, + "relatedMemberNickname": "김철수", + "relatedMemberProfileImageUrl": "https://example.com/profile2.jpg", + "relatedPostId": 100, + "relatedCommentId": 50 + } + ], + "totalElements": 42, + "totalPages": 3, + "currentPage": 0, + "size": 20, + "hasNext": true +} +``` + +**이벤트 타입 (MemberEventType):** + +| 이벤트 타입 | 설명 | +|---------------------------|----------------| +| `FRIEND_REQUEST_SENT` | 친구 요청을 보냈습니다 | +| `FRIEND_REQUEST_RECEIVED` | 친구 요청을 받았습니다 | +| `FRIEND_REQUEST_ACCEPTED` | 친구 요청이 수락되었습니다 | +| `FRIEND_REQUEST_REJECTED` | 친구 요청이 거절되었습니다 | +| `FRIENDSHIP_TERMINATED` | 친구 관계가 해제되었습니다 | +| `POST_CREATED` | 새 게시물을 작성했습니다 | +| `POST_DELETED` | 게시물을 삭제했습니다 | +| `POST_COMMENTED` | 게시물에 댓글이 달렸습니다 | +| `COMMENT_CREATED` | 댓글을 작성했습니다 | +| `COMMENT_DELETED` | 댓글을 삭제했습니다 | +| `COMMENT_REPLIED` | 대댓글이 달렸습니다 | +| `PROFILE_UPDATED` | 프로필이 업데이트되었습니다 | +| `ACCOUNT_ACTIVATED` | 계정이 활성화되었습니다 | +| `ACCOUNT_DEACTIVATED` | 계정이 비활성화되었습니다 | + +**오류 응답:** + +- `401 Unauthorized`: 인증되지 않은 사용자 + +--- + +### 읽지 않은 이벤트 수 조회 + +현재 로그인한 사용자의 읽지 않은 이벤트 수를 조회합니다. + +```http +GET /api/events/me/unread-count +``` + +**응답 예시 (200 OK):** + +```json +{ + "unreadCount": 5 +} +``` + +**오류 응답:** + +- `401 Unauthorized`: 인증되지 않은 사용자 + +--- + +### 이벤트 읽음 처리 + +특정 이벤트를 읽음으로 표시합니다. + +```http +PUT /api/events/{eventId}/mark-as-read +``` + +**Path Parameters:** + +| 파라미터 | 타입 | 설명 | +|-----------|------|--------| +| `eventId` | Long | 이벤트 ID | + +**응답 예시 (204 No Content):** + +이벤트가 성공적으로 읽음 처리되었습니다. 응답 본문 없음. + +**오류 응답:** + +- `401 Unauthorized`: 인증되지 않은 사용자 +- `403 Forbidden`: 다른 사용자의 이벤트에 접근 시도 +- `404 Not Found`: 존재하지 않는 이벤트 ID + +--- + +### 이벤트 일괄 읽음 처리 + +여러 이벤트를 한 번에 읽음으로 표시합니다. + +```http +PUT /api/events/me/mark-all-as-read +``` + +**Request Body:** + +```json +{ + "eventIds": [ + 123, + 124, + 125, + 126 + ] +} +``` + +**응답 예시 (204 No Content):** + +모든 이벤트가 성공적으로 읽음 처리되었습니다. 응답 본문 없음. + +**오류 응답:** + +- `401 Unauthorized`: 인증되지 않은 사용자 +- `403 Forbidden`: 다른 사용자의 이벤트 포함 시 + +--- + +### 이벤트 프래그먼트 조회 (WebView) + +HTMX를 사용한 동적 이벤트 목록 조회입니다. + +```http +GET /events/fragment/list?isRead={isRead}&page={page} +``` + +**Query Parameters:** + +| 파라미터 | 타입 | 필수 | 설명 | 기본값 | +|----------|---------|----|----------|-----------| +| `isRead` | Boolean | ✗ | 읽음 여부 필터 | null (전체) | +| `page` | Integer | ✗ | 페이지 번호 | 0 | + +**응답:** HTML 프래그먼트 (이벤트 목록) + +**사용 예시 (HTMX):** + +```html + +
+
+``` + +--- + +## 🔔 이벤트 사용 가이드 + +### 실시간 알림 + +이벤트는 도메인 이벤트 발생 시 자동으로 생성됩니다: + +1. **친구 요청 발송**: 발신자와 수신자 모두에게 이벤트 생성 +2. **게시물 작성**: 작성자에게 이벤트 생성 +3. **댓글 작성**: 작성자와 게시물 작성자에게 이벤트 생성 + +### 폴링 vs 웹소켓 + +현재 버전은 **폴링 방식**을 사용합니다: + +- 클라이언트가 주기적으로 `/api/events/me/unread-count` 호출 +- 권장 폴링 간격: 30초 + +**향후 개선 (WebSocket):** + +- 실시간 알림 푸시 +- 서버 부하 감소 +- 즉각적인 사용자 경험 + +### 이벤트 자동 삭제 + +- **읽은 이벤트**: 90일 후 자동 삭제 +- **읽지 않은 이벤트**: 무기한 보관 diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 4d007b09..e38cf5aa 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -6,7 +6,8 @@ - [1. 아키텍처 원칙](#1-아키텍처-원칙) - [2. 레이어별 구현 예시](#2-레이어별-구현-예시) -- [3. 프로젝트 구조](#3-프로젝트-구조) +- [3. 도메인 이벤트 시스템](#3-도메인-이벤트-시스템) +- [4. 프로젝트 구조](#4-프로젝트-구조) --- @@ -217,7 +218,272 @@ class JpaMemberRepository( } ``` -## 3. 프로젝트 구조 +--- + +## 3. 도메인 이벤트 시스템 + +### 3.1 Event-Driven Architecture + +RMRT는 **도메인 이벤트 기반 아키텍처**를 적용하여 도메인 간 결합도를 낮추고 확장성을 높였습니다. + +```mermaid +graph LR + subgraph "Domain Layer" + A[Member Aggregate] -->|이벤트 발행| B[MemberRegisteredDomainEvent] + C[Post Aggregate] -->|이벤트 발행| D[PostCreatedEvent] + E[Friendship Aggregate] -->|이벤트 발행| F[FriendRequestSentEvent] + G[Comment Aggregate] -->|이벤트 발행| H[CommentCreatedEvent] + end + + subgraph "Application Layer" + B -->|리스닝| I[MemberEventDomainEventListener] + D -->|리스닝| J[PostDomainEventListener] + F -->|리스닝| K[FriendshipDomainEventListener] + H -->|리스닝| L[CommentDomainEventListener] + I -->|MemberEvent 생성| M[(MemberEvent DB)] + J -->|MemberEvent 생성| M + K -->|MemberEvent 생성| M + L -->|MemberEvent 생성| M + B -->|리스닝| N[EmailEventListener] + N -->|이메일 발송| O[Email Service] + end +``` + +### 3.2 AggregateRoot 패턴 + +#### 개념 + +도메인 엔티티가 상태 변경 시 도메인 이벤트를 발행하여 다른 도메인 로직을 트리거하는 패턴입니다. + +```kotlin +fun interface AggregateRoot { + /** + * 애그리거트에 축적된 도메인 이벤트를 모두 가져오고 초기화합니다. + */ + fun drainDomainEvents(): List +} +``` + +#### 구현 예시 - Member Aggregate + +```kotlin +@Entity +class Member : BaseEntity(), AggregateRoot { + private val domainEvents: MutableList = mutableListOf() + + fun activate() { + if (status != MemberStatus.PENDING) { + throw InvalidMemberStatusException.NotPending("등록 대기 상태에서만 등록 완료가 가능합니다") + } + status = MemberStatus.ACTIVE + detail.activate() + updatedAt = LocalDateTime.now() + + // 도메인 이벤트 발행 + domainEvents.add( + MemberActivatedDomainEvent( + memberId = requireId(), + email = email.address, + nickname = nickname.value, + occurredOn = Instant.now() + ) + ) + } + + override fun drainDomainEvents(): List { + val events = domainEvents.toList() + domainEvents.clear() + return events + } +} +``` + +### 3.3 도메인 이벤트 계층 구조 + +```kotlin +// 최상위 도메인 이벤트 인터페이스 +interface DomainEvent { + val occurredOn: Instant // 이벤트 발생 시각 +} + +// 도메인별 마커 인터페이스 +interface MemberDomainEvent : DomainEvent +interface PostDomainEvent : DomainEvent +interface CommentDomainEvent : DomainEvent +interface FriendDomainEvent : DomainEvent + +// 구체적인 도메인 이벤트 +data class MemberRegisteredDomainEvent( + val memberId: Long, + val email: String, + val nickname: String, + override val occurredOn: Instant +) : MemberDomainEvent + +data class PostCreatedEvent( + val postId: Long, + val authorMemberId: Long, + val restaurantName: String, + val isSponsored: Boolean, + override val occurredOn: Instant +) : PostDomainEvent +``` + +### 3.4 이벤트 발행 및 처리 흐름 + +```mermaid +sequenceDiagram + participant C as Controller + participant S as Service + participant A as Aggregate + participant P as DomainEventPublisher + participant L as EventListener + participant M as MemberEventRepository + C ->> S: 비즈니스 로직 요청 + S ->> A: 도메인 메서드 호출 + A ->> A: 상태 변경 + A ->> A: 도메인 이벤트 저장 (내부 리스트) + A -->> S: 반환 + S ->> P: 트랜잭션 커밋 전 이벤트 발행 + P ->> P: drainDomainEvents() 호출 + P ->> L: 이벤트 발행 (비동기 @Async) + S -->> C: 응답 + Note over L: 별도 트랜잭션에서 처리 + L ->> M: MemberEvent 생성 및 저장 +``` + +#### 핵심 구현 + +**DomainEventPublisher (서비스)** + +```kotlin +@Service +class DomainEventPublisherService( + private val eventPublisher: ApplicationEventPublisher, +) : DomainEventPublisher { + + override fun publishDomainEvents(aggregateRoot: AggregateRoot) { + aggregateRoot.drainDomainEvents().forEach { event -> + eventPublisher.publishEvent(event) + } + } +} +``` + +**이벤트 리스너 (비동기 처리)** + +```kotlin +@Component +class MemberEventDomainEventListener( + private val memberEventCreator: MemberEventCreator, + private val memberReader: MemberReader, +) { + + @Async + @Transactional(propagation = Propagation.REQUIRES_NEW) + @EventListener + fun handleMemberRegistered(event: MemberRegisteredDomainEvent) { + memberEventCreator.create( + MemberEventCreateRequest( + memberId = event.memberId, + eventType = MemberEventType.ACCOUNT_ACTIVATED, + title = "회원 가입 완료", + message = "${event.nickname}님, 환영합니다!" + ) + ) + } +} +``` + +### 3.5 이벤트 리스너 목록 + +#### MemberEventDomainEventListener + +**처리하는 이벤트** + +- `MemberRegisteredDomainEvent`: 회원 가입 +- `MemberActivatedDomainEvent`: 계정 활성화 +- `MemberDeactivatedDomainEvent`: 계정 비활성화 +- `MemberProfileUpdatedDomainEvent`: 프로필 업데이트 +- `PasswordChangedDomainEvent`: 비밀번호 변경 + +**수행 작업**: MemberEvent 엔티티 생성 (활동 기록) + +#### EmailEventListener + +**처리하는 이벤트** + +- `EmailSendRequestedEvent`: 이메일 발송 요청 + +**수행 작업**: 이메일 발송 (회원가입 인증, 비밀번호 재설정) + +#### FriendshipDomainEventListener + +**처리하는 이벤트** + +- `FriendRequestSentEvent`: 친구 요청 발송 +- `FriendRequestAcceptedEvent`: 친구 요청 수락 +- `FriendRequestRejectedEvent`: 친구 요청 거절 +- `FriendshipTerminatedEvent`: 친구 관계 해제 + +**수행 작업**: 양방향 MemberEvent 생성 (발신자/수신자) + +#### PostDomainEventListener + +**처리하는 이벤트** + +- `PostCreatedEvent`: 게시물 작성 +- `PostDeletedEvent`: 게시물 삭제 + +**수행 작업**: MemberEvent 생성 (작성자) + +#### CommentDomainEventListener + +**처리하는 이벤트** + +- `CommentCreatedEvent`: 댓글 작성 +- `CommentDeletedEvent`: 댓글 삭제 + +**수행 작업**: MemberEvent 생성 (작성자 및 게시물 작성자) + +### 3.6 이벤트 처리 특징 + +#### 비동기 처리 (@Async) + +```kotlin +@Async +@Transactional(propagation = Propagation.REQUIRES_NEW) +@EventListener +fun handleEvent(event: DomainEvent) { + // 이벤트 처리 로직 +} +``` + +**장점** + +- 메인 비즈니스 로직과 분리 +- 실패 시 메인 로직에 영향 없음 +- 성능 향상 (비동기 처리) + +#### 트랜잭션 경계 + +``` +1. 메인 트랜잭션: 도메인 엔티티 상태 변경 + ↓ +2. 트랜잭션 커밋 전: 도메인 이벤트 발행 + ↓ +3. 리스너 트랜잭션: 각 리스너가 독립 트랜잭션 (REQUIRES_NEW) +``` + +**Eventually Consistent 모델** + +- 메인 로직은 즉시 일관성 유지 +- 이벤트 처리는 최종 일관성 보장 +- 시스템 전체의 복원력(resilience) 향상 + +--- + +## 4. 프로젝트 구조 ``` src/main/kotlin/com/albert/realmoneyrealtaste/ diff --git a/docs/DOMAIN_MODEL.md b/docs/DOMAIN_MODEL.md index 493aacba..c143547e 100644 --- a/docs/DOMAIN_MODEL.md +++ b/docs/DOMAIN_MODEL.md @@ -13,9 +13,11 @@ - [7. 팔로우 애그리거트](#7-팔로우-애그리거트) - [8. 댓글 애그리거트](#8-댓글-애그리거트) - [9. 토큰 애그리거트](#9-토큰-애그리거트) -- [10. 공통 설계 요소](#10-공통-설계-요소) -- [11. 애플리케이션 레이어](#11-애플리케이션-레이어) -- [12. 설계 특징 및 패턴](#12-설계-특징-및-패턴) +- [10. 회원 이벤트 애그리거트](#10-회원-이벤트-애그리거트) +- [11. 도메인 이벤트 시스템](#11-도메인-이벤트-시스템) +- [12. 공통 설계 요소](#12-공통-설계-요소) +- [13. 애플리케이션 레이어](#13-애플리케이션-레이어) +- [14. 설계 특징 및 패턴](#14-설계-특징-및-패턴) --- @@ -78,6 +80,11 @@ ActivationToken Aggregate PasswordResetToken Aggregate └── PasswordResetToken (Root) + +MemberEvent Aggregate +├── MemberEvent (Root) +├── MemberEventType (Enum) +└── 관련 엔티티 ID (Long) ``` --- @@ -276,12 +283,12 @@ class Roles( **생성** ```kotlin -static create( +static create ( authorMemberId: Long, - authorNickname: String, - restaurant: Restaurant, - content: PostContent, - images: PostImages +authorNickname: String, +restaurant: Restaurant, +content: PostContent, +images: PostImages ): Post ``` @@ -449,10 +456,10 @@ data class PostHeartRemovedEvent( **생성** ```kotlin -static create( +static create ( fileKey: FileKey, - uploadedBy: Long, - imageType: ImageType +uploadedBy: Long, +imageType: ImageType ): Image ``` @@ -503,6 +510,7 @@ data class FileKey( ``` **특징** + - 경로 탐색 공격 (`../`) 방지 - 절대 경로 사용 금지 - S3 객체 키 직접 매핑 @@ -820,9 +828,282 @@ class PasswordResetToken( --- -## 9. 공통 설계 요소 +## 9. 회원 이벤트 애그리거트 + +### 9.1 회원 이벤트 (MemberEvent) + +**_Aggregate Root, Entity_** + +#### 속성 + +* `id: Long` - 이벤트 식별자 (PK) +* `memberId: Long` - 이벤트 대상 회원 ID +* `eventType: MemberEventType` - 이벤트 타입 (Enum) +* `title: String` - 이벤트 제목 (최대 100자) +* `message: String` - 이벤트 상세 메시지 (최대 500자) +* `relatedMemberId: Long?` - 관련 회원 ID (선택) +* `relatedPostId: Long?` - 관련 게시물 ID (선택) +* `relatedCommentId: Long?` - 관련 댓글 ID (선택) +* `isRead: Boolean` - 읽음 여부 (기본값: false) +* `createdAt: LocalDateTime` - 생성 일시 + +#### 주요 행위 + +**생성** + +```kotlin +static create ( + memberId: Long, +eventType: MemberEventType, +title: String, +message: String, +relatedMemberId: Long? = null, +relatedPostId: Long? = null, +relatedCommentId: Long? = null +): MemberEvent +``` + +**관리** + +- `markAsRead()` - 이벤트를 읽음으로 표시 + +#### 비즈니스 규칙 + +* 초기 읽음 상태: false +* 본인의 이벤트만 조회 및 읽음 처리 가능 +* Hard Delete 방식 (90일 이상 경과한 읽은 이벤트 자동 삭제) +* 도메인 이벤트 발생 시 자동 생성 + +### 9.2 회원 이벤트 타입 (MemberEventType) + +**_Enum_** + +```kotlin +enum class MemberEventType { + // 친구 관련 + FRIEND_REQUEST_SENT, // 친구 요청을 보냈습니다 + FRIEND_REQUEST_RECEIVED, // 친구 요청을 받았습니다 + FRIEND_REQUEST_ACCEPTED, // 친구 요청이 수락되었습니다 + FRIEND_REQUEST_REJECTED, // 친구 요청이 거절되었습니다 + FRIENDSHIP_TERMINATED, // 친구 관계가 해제되었습니다 + + // 게시물 관련 + POST_CREATED, // 새 게시물을 작성했습니다 + POST_DELETED, // 게시물을 삭제했습니다 + POST_COMMENTED, // 게시물에 댓글이 달렸습니다 + + // 댓글 관련 + COMMENT_CREATED, // 댓글을 작성했습니다 + COMMENT_DELETED, // 댓글을 삭제했습니다 + COMMENT_REPLIED, // 대댓글이 달렸습니다 + + // 프로필 관련 + PROFILE_UPDATED, // 프로필이 업데이트되었습니다 + + // 시스템 관련 + ACCOUNT_ACTIVATED, // 계정이 활성화되었습니다 + ACCOUNT_DEACTIVATED, // 계정이 비활성화되었습니다 +} +``` + +**인덱스** + +* `idx_member_event_member_id`: member_id +* `idx_member_event_event_type`: event_type +* `idx_member_event_is_read`: is_read +* `idx_member_event_created_at`: created_at + +--- + +## 10. 도메인 이벤트 시스템 + +### 10.1 AggregateRoot 패턴 + +**_Interface_** + +```kotlin +fun interface AggregateRoot { + /** + * 애그리거트에 축적된 도메인 이벤트를 모두 가져오고 초기화합니다. + */ + fun drainDomainEvents(): List +} +``` + +#### 구현 엔티티 + +* **Member**: 회원 도메인 이벤트 발행 +* **Post**: 게시물 도메인 이벤트 발행 +* **Comment**: 댓글 도메인 이벤트 발행 +* **Friendship**: 친구 관계 도메인 이벤트 발행 + +### 10.2 도메인 이벤트 계층 구조 + +#### 공통 인터페이스 + +```kotlin +interface DomainEvent { + val occurredOn: Instant // 이벤트 발생 시각 +} +``` + +#### 도메인별 이벤트 마커 인터페이스 + +```kotlin +interface MemberDomainEvent : DomainEvent +interface PostDomainEvent : DomainEvent +interface CommentDomainEvent : DomainEvent +interface FriendDomainEvent : DomainEvent +``` + +### 10.3 회원 도메인 이벤트 + +```kotlin +// 회원 가입 +data class MemberRegisteredDomainEvent( + val memberId: Long, + val email: String, + val nickname: String, + override val occurredOn: Instant +) : MemberDomainEvent + +// 회원 활성화 +data class MemberActivatedDomainEvent( + val memberId: Long, + val email: String, + val nickname: String, + override val occurredOn: Instant +) : MemberDomainEvent + +// 회원 비활성화 +data class MemberDeactivatedDomainEvent( + val memberId: Long, + override val occurredOn: Instant +) : MemberDomainEvent + +// 프로필 업데이트 +data class MemberProfileUpdatedDomainEvent( + val memberId: Long, + val nickname: String, + val introduction: String?, + val profileAddress: String?, + override val occurredOn: Instant +) : MemberDomainEvent + +// 비밀번호 변경 +data class PasswordChangedDomainEvent( + val memberId: Long, + override val occurredOn: Instant +) : MemberDomainEvent +``` + +### 10.4 게시물 도메인 이벤트 + +```kotlin +// 게시물 생성 +data class PostCreatedEvent( + val postId: Long, + val authorMemberId: Long, + val restaurantName: String, + val isSponsored: Boolean, + override val occurredOn: Instant +) : PostDomainEvent + +// 게시물 삭제 +data class PostDeletedEvent( + val postId: Long, + val authorMemberId: Long, + override val occurredOn: Instant +) : PostDomainEvent +``` + +### 10.5 댓글 도메인 이벤트 + +```kotlin +// 댓글 생성 +data class CommentCreatedEvent( + val commentId: Long, + val postId: Long, + val authorMemberId: Long, + val postAuthorMemberId: Long, + val parentCommentId: Long?, + override val occurredOn: Instant +) : CommentDomainEvent + +// 댓글 삭제 +data class CommentDeletedEvent( + val commentId: Long, + val postId: Long, + val authorMemberId: Long, + override val occurredOn: Instant +) : CommentDomainEvent +``` + +### 10.6 친구 관계 도메인 이벤트 + +```kotlin +// 친구 요청 발송 +data class FriendRequestSentEvent( + val friendshipId: Long, + val requesterId: Long, + val recipientId: Long, + override val occurredOn: Instant +) : FriendDomainEvent + +// 친구 요청 수락 +data class FriendRequestAcceptedEvent( + val friendshipId: Long, + val requesterId: Long, + val recipientId: Long, + override val occurredOn: Instant +) : FriendDomainEvent + +// 친구 요청 거절 +data class FriendRequestRejectedEvent( + val friendshipId: Long, + val requesterId: Long, + val recipientId: Long, + override val occurredOn: Instant +) : FriendDomainEvent + +// 친구 관계 해제 +data class FriendshipTerminatedEvent( + val friendshipId: Long, + val initiatorId: Long, + val targetId: Long, + override val occurredOn: Instant +) : FriendDomainEvent +``` + +### 10.7 이벤트 발행 및 처리 + +#### 발행 방식 + +``` +1. 엔티티 상태 변경 메서드 호출 + ↓ +2. 도메인 이벤트 생성 및 내부 리스트에 저장 + ↓ +3. 트랜잭션 커밋 전 DomainEventPublisher가 이벤트 발행 + ↓ +4. Spring ApplicationEventPublisher를 통해 이벤트 발행 + ↓ +5. 이벤트 리스너가 비동기로 처리 (@Async) +``` + +#### 주요 리스너 + +* **MemberEventDomainEventListener**: 도메인 이벤트 → MemberEvent 생성 +* **EmailEventListener**: 이메일 발송 이벤트 처리 +* **FriendshipDomainEventListener**: 친구 관계 이벤트 → MemberEvent 생성 +* **PostDomainEventListener**: 게시물 이벤트 → MemberEvent 생성 +* **CommentDomainEventListener**: 댓글 이벤트 → MemberEvent 생성 + +--- + +## 11. 공통 설계 요소 -### 9.1 BaseEntity +### 11.1 BaseEntity ```kotlin @MappedSuperclass @@ -846,7 +1127,7 @@ abstract class BaseEntity { --- -### 9.2 예외 계층 +### 11.2 예외 계층 ``` RuntimeException @@ -882,9 +1163,9 @@ RuntimeException --- -## 10. 애플리케이션 레이어 +## 12. 애플리케이션 레이어 -### 10.1 서비스 구조 +### 12.1 서비스 구조 #### Provided 인터페이스 (Use Case) @@ -959,7 +1240,7 @@ RuntimeException --- -### 10.2 주요 서비스 흐름 +### 12.2 주요 서비스 흐름 #### 회원 등록 @@ -998,7 +1279,7 @@ RuntimeException --- -### 10.3 이벤트 기반 처리 +### 12.3 이벤트 기반 처리 #### 회원 이벤트 리스너 @@ -1049,9 +1330,9 @@ class FriendEventListener { --- -## 11. 설계 특징 및 패턴 +## 13. 설계 특징 및 패턴 -### 11.1 Aggregate 설계 원칙 +### 13.1 Aggregate 설계 원칙 #### Member Aggregate @@ -1082,7 +1363,7 @@ class FriendEventListener { --- -### 11.2 Value Object 활용 +### 13.2 Value Object 활용 #### Embedded 방식 @@ -1102,7 +1383,7 @@ class FriendEventListener { --- -### 11.3 동시성 처리 +### 13.3 동시성 처리 #### 문제 상황 @@ -1125,7 +1406,7 @@ fun incrementHeartCount(postId: Long) --- -### 11.4 Soft Delete 패턴 +### 13.4 Soft Delete 패턴 **구현** @@ -1140,7 +1421,7 @@ fun incrementHeartCount(postId: Long) --- -### 11.5 아키텍처 패턴 +### 13.5 아키텍처 패턴 #### 헥사고날 아키텍처 @@ -1172,7 +1453,7 @@ fun incrementHeartCount(postId: Long) --- -### 11.6 Kotlin 특성 활용 +### 13.6 Kotlin 특성 활용 #### data class diff --git a/docs/DOMAIN_REQUIREMENTS.md b/docs/DOMAIN_REQUIREMENTS.md index cc7b501e..b2a3bb9b 100644 --- a/docs/DOMAIN_REQUIREMENTS.md +++ b/docs/DOMAIN_REQUIREMENTS.md @@ -13,8 +13,10 @@ - [7. 팔로우 시스템](#7-팔로우-시스템) - [8. 댓글 시스템](#8-댓글-시스템) - [9. 신뢰도 시스템](#9-신뢰도-시스템) -- [10. 보안 및 인증](#10-보안-및-인증) -- [11. 비즈니스 규칙](#11-비즈니스-규칙) +- [10. 회원 활동 기록 (MemberEvent)](#10-회원-활동-기록-memberevent) +- [11. 도메인 이벤트 시스템](#11-도메인-이벤트-시스템) +- [12. 보안 및 인증](#12-보안-및-인증) +- [13. 비즈니스 규칙](#13-비즈니스-규칙) --- @@ -530,9 +532,257 @@ DIAMOND: 800-1000점 --- -## 10. 보안 및 인증 +## 10. 회원 활동 기록 (MemberEvent) -### 10.1 비밀번호 관리 +### 10.1 이벤트 생성 + +#### 자동 생성 규칙 + +``` +도메인 이벤트 발생 시 자동으로 MemberEvent 생성: +- 친구 요청 발송/수신/수락/거절/해제 +- 게시물 작성/삭제 +- 댓글 작성/삭제 +- 프로필 업데이트 +- 계정 활성화/비활성화 +``` + +#### 필수 구성 요소 + +``` +이벤트 정보: +- 회원 ID: 이벤트 대상 회원 (필수) +- 이벤트 타입: MemberEventType enum (필수) +- 제목: 이벤트 제목, 최대 100자 (필수) +- 메시지: 이벤트 상세 메시지, 최대 500자 (필수) +- 읽음 여부: 기본값 false (필수) +- 생성 시각: 자동 설정 (필수) + +관련 정보 (선택): +- 관련 회원 ID: 친구 요청 발신자 등 +- 관련 게시물 ID: 댓글이 달린 게시물 등 +- 관련 댓글 ID: 대댓글이 달린 댓글 등 +``` + +### 10.2 이벤트 타입 + +#### 친구 관련 이벤트 + +``` +FRIEND_REQUEST_SENT: 친구 요청을 보냈습니다 +FRIEND_REQUEST_RECEIVED: 친구 요청을 받았습니다 +FRIEND_REQUEST_ACCEPTED: 친구 요청이 수락되었습니다 +FRIEND_REQUEST_REJECTED: 친구 요청이 거절되었습니다 +FRIENDSHIP_TERMINATED: 친구 관계가 해제되었습니다 +``` + +#### 게시물 관련 이벤트 + +``` +POST_CREATED: 새 게시물을 작성했습니다 +POST_DELETED: 게시물을 삭제했습니다 +POST_COMMENTED: 게시물에 댓글이 달렸습니다 +``` + +#### 댓글 관련 이벤트 + +``` +COMMENT_CREATED: 댓글을 작성했습니다 +COMMENT_DELETED: 댓글을 삭제했습니다 +COMMENT_REPLIED: 대댓글이 달렸습니다 +``` + +#### 프로필 관련 이벤트 + +``` +PROFILE_UPDATED: 프로필이 업데이트되었습니다 +``` + +#### 시스템 관련 이벤트 + +``` +ACCOUNT_ACTIVATED: 계정이 활성화되었습니다 +ACCOUNT_DEACTIVATED: 계정이 비활성화되었습니다 +``` + +### 10.3 이벤트 조회 + +#### 조회 규칙 + +- 본인의 이벤트만 조회 가능 +- 읽음/읽지 않은 이벤트 분리 조회 지원 +- 페이지네이션 지원 (기본 20개) +- 최신순 정렬 (생성 시각 기준) + +#### 필터링 옵션 + +``` +읽음 상태별: +- 읽지 않은 이벤트만 +- 읽은 이벤트만 +- 전체 이벤트 + +이벤트 타입별: +- 친구 관련 +- 게시물 관련 +- 댓글 관련 +- 프로필 관련 +- 시스템 관련 +``` + +### 10.4 이벤트 관리 + +#### 읽음 처리 + +- 본인의 이벤트만 읽음 처리 가능 +- 읽음 처리 시 isRead = true로 변경 +- 일괄 읽음 처리 지원 (여러 이벤트 동시 처리) + +#### 삭제 정책 + +- Hard Delete 방식 (데이터 완전 삭제) +- 90일 이상 경과한 읽은 이벤트 자동 삭제 +- 본인의 이벤트만 수동 삭제 가능 + +--- + +## 11. 도메인 이벤트 시스템 + +### 11.1 AggregateRoot 패턴 + +#### 개념 + +``` +도메인 엔티티가 상태 변경 시 이벤트를 발행하여 +다른 도메인 로직을 트리거하는 패턴 +``` + +#### AggregateRoot 인터페이스 + +```kotlin +fun interface AggregateRoot { + fun drainDomainEvents(): List +} +``` + +#### 적용 엔티티 + +- **Member**: 회원 도메인 +- **Post**: 게시물 도메인 +- **Comment**: 댓글 도메인 +- **Friendship**: 친구 관계 도메인 + +### 11.2 도메인 이벤트 + +#### 이벤트 구조 + +``` +공통 속성: +- 발생 시각 (occurredOn): Instant +- 이벤트 타입 (eventType): String +- 애그리거트 ID: Long + +도메인별 속성: +- 도메인 특화 정보 (회원 ID, 게시물 ID 등) +``` + +#### 이벤트 발행 방식 + +``` +1. 엔티티 상태 변경 메서드 호출 +2. 도메인 이벤트 생성 및 내부 저장 +3. 트랜잭션 커밋 전 이벤트 발행 +4. 이벤트 리스너가 비동기 처리 +``` + +### 11.3 이벤트 리스너 + +#### MemberEventDomainEventListener + +``` +처리하는 이벤트: +- 회원 가입 (MemberRegisteredDomainEvent) +- 회원 활성화 (MemberActivatedDomainEvent) +- 회원 비활성화 (MemberDeactivatedDomainEvent) +- 프로필 업데이트 (MemberProfileUpdatedDomainEvent) +- 비밀번호 변경 (PasswordChangedDomainEvent) + +수행 작업: +- MemberEvent 생성 (활동 기록) +``` + +#### EmailEventListener + +``` +처리하는 이벤트: +- 이메일 발송 요청 (EmailSendRequestedEvent) + +수행 작업: +- 이메일 발송 (회원 가입 인증, 비밀번호 재설정) +``` + +#### FriendshipDomainEventListener + +``` +처리하는 이벤트: +- 친구 요청 발송 (FriendRequestSentEvent) +- 친구 요청 수락 (FriendRequestAcceptedEvent) +- 친구 요청 거절 (FriendRequestRejectedEvent) +- 친구 관계 해제 (FriendshipTerminatedEvent) + +수행 작업: +- MemberEvent 생성 (양방향: 발신자/수신자) +``` + +#### PostDomainEventListener + +``` +처리하는 이벤트: +- 게시물 작성 (PostCreatedEvent) +- 게시물 삭제 (PostDeletedEvent) + +수행 작업: +- MemberEvent 생성 (작성자) +``` + +#### CommentDomainEventListener + +``` +처리하는 이벤트: +- 댓글 작성 (CommentCreatedEvent) +- 댓글 삭제 (CommentDeletedEvent) + +수행 작업: +- MemberEvent 생성 (작성자 및 게시물 작성자) +``` + +### 11.4 이벤트 처리 규칙 + +#### 비동기 처리 + +- 모든 도메인 이벤트는 `@Async`로 비동기 처리 +- 메인 비즈니스 로직과 분리된 트랜잭션 +- 실패 시 메인 로직에 영향 없음 + +#### 트랜잭션 경계 + +``` +1. 메인 트랜잭션: 도메인 엔티티 상태 변경 +2. 이벤트 발행: 트랜잭션 커밋 직전 +3. 리스너 트랜잭션: 각 리스너가 독립 트랜잭션 +``` + +#### 이벤트 순서 보장 + +- 동일 애그리거트 내 이벤트는 발생 순서대로 처리 +- 서로 다른 애그리거트 이벤트는 순서 보장 안 함 +- Eventually Consistent 모델 적용 + +--- + +## 12. 보안 및 인증 + +### 12.1 비밀번호 관리 #### 비밀번호 정책 @@ -563,7 +813,7 @@ DIAMOND: 800-1000점 - 사용 후 즉시 삭제 ``` -### 10.2 토큰 관리 +### 12.2 토큰 관리 #### 활성화 토큰 @@ -581,9 +831,9 @@ DIAMOND: 800-1000점 --- -## 11. 비즈니스 규칙 +## 13. 비즈니스 규칙 -### 11.1 데이터 유효성 +### 13.1 데이터 유효성 #### 텍스트 길이 제한 @@ -624,7 +874,7 @@ DIAMOND: 800-1000점 - 신뢰도: 0-1000 ``` -### 11.2 상태 전이 규칙 +### 13.2 상태 전이 규칙 #### 회원 상태 @@ -678,7 +928,7 @@ DELETED → PUBLISHED: 복구 불가 기타 전이: 불가 (예외 발생) ``` -### 11.3 권한 제어 +### 13.3 권한 제어 #### 기능별 권한 diff --git a/docs/ERD.png b/docs/ERD.png index 7c95a0dd..251a2c40 100644 Binary files a/docs/ERD.png and b/docs/ERD.png differ diff --git a/docs/TODO.md b/docs/TODO.md index b03bd949..68a1a300 100644 --- a/docs/TODO.md +++ b/docs/TODO.md @@ -1,476 +1,291 @@ -# 🧪 테스트 가이드 +# RMRT 프로젝트 TODO 목록 -## 🎯 테스트 철학 +> 본 문서는 RMRT(Real Money Real Taste) 프로젝트의 향후 개발 및 개선 작업 목록을 정리합니다. -RMRT는 **실제 사용 시나리오**를 중심으로 테스트합니다. Mock을 최소화하고 실제 데이터베이스와 Spring Context를 사용하여 통합 테스트를 선호합니다. +## 📋 목차 -- **통합 테스트 우선**: 단위 테스트보다 통합 테스트 중심 -- **실제 데이터**: Testcontainers MySQL 사용 -- **인증 테스트**: `@WithMockMember`로 실제 인증 시나리오 -- **CSRF 보호**: 모든 POST/PUT/DELETE 요청에 CSRF 적용 +- [1. 인프라 및 배포](#1-인프라-및-배포) +- [2. 파일 관리 및 이미지 처리](#2-파일-관리-및-이미지-처리) +- [3. 이벤트 시스템 고도화](#3-이벤트-시스템-고도화) +- [4. 핵심 도메인 기능 현황](#4-핵심-도메인-기능-현황) +- [5. 성능 및 최적화](#5-성능-및-최적화) +- [6. 보안 강화](#6-보안-강화) +- [7. 기능 확장](#7-기능-확장) +- [8. 모니터링 및 운영](#8-모니터링-및-운영) +- [9. 문서 및 테스트](#9-문서-및-테스트) --- -## 🛠 테스트 도구 스택 +## 1. 인프라 및 배포 -### 핵심 도구 +### 🚀 배포 자동화 -- **JUnit 5**: 테스트 프레임워크 -- **MockK**: Mock 객체 생성 (Kotlin 친화적) -- **Testcontainers**: 실제 Docker MySQL 컨테이너 -- **LocalStack**: AWS S3 로컬 테스트 환경 -- **MockMvc**: 웹 계층 테스트 -- **Spring Boot Test**: 통합 테스트 지원 +| 작업 항목 | 상태 | 우선순위 | 상세 설명 | +|----------------------|----|-------|-------------------------------| +| 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 | 캐시 레이어, 세션 저장소 | -### 테스트 유틸리티 +--- + +## 2. 파일 관리 및 이미지 처리 -- **IntegrationTestBase**: 모든 통합 테스트의 기본 클래스 -- **TestMemberHelper**: 멤버 생성 유틸리티 -- **TestPostHelper**: 포스트 생성 유틸리티 -- **@WithMockMember**: 인증된 사용자 시뮬레이션 +### 📸 이미지 업로드 시스템 + +| 작업 항목 | 상태 | 우선순위 | 상세 설명 | +|-------------------|----|-------|---------------------------------| +| AWS S3 버킷 구성 | ✅ | 🔴 P0 | 버킷 정책, CORS, 접근 제어 | +| 이미지 업로드 API 개발 | ✅ | 🔴 P0 | Multipart 파일 업로드, Presigned URL | +| 이미지 최적화 | ☐ | 🟡 P1 | 리사이징, WebP 변환, 압축 | +| CloudFront CDN 연동 | ☐ | 🟡 P1 | 이미지 캐싱, 글로벌 배포 | +| 파일 메타데이터 관리 | ☐ | 🟢 P2 | 업로드 이력, 용량 모니터링 | +| 파일 보안 강화 | ☐ | 🟢 P2 | 타입 검증, 바이러스 스캔 | --- -## 🏗 테스트 구조 - -### 기본 클래스 설정 - -```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()) - } - } -} -``` +## 3. 이벤트 시스템 고도화 + +### 🔄 이벤트 기반 아키텍처 + +| 작업 항목 | 상태 | 우선순위 | 상세 설명 | +|-------------------------------|----|-------|------------------------------------| +| AggregateRoot 패턴 구현 | ✅ | 🔴 P0 | Member/Post/Comment/Friendship에 적용 | +| 도메인 이벤트 계층 구조 | ✅ | 🔴 P0 | DomainEvent 인터페이스, 마커 인터페이스 | +| MemberEvent 엔티티 구현 | ✅ | 🔴 P0 | 회원 활동 기록, 10가지 이벤트 타입 | +| 도메인 이벤트 리스너 구현 | ✅ | 🔴 P0 | 5개 리스너, 비동기 처리 (@Async) | +| Spring Events → Message Queue | ☐ | 🟡 P1 | RabbitMQ/Kafka 연동, 이벤트 직렬화 | +| 이벤트 저장소 구현 | ☐ | 🟡 P1 | Event Sourcing, 스냅샷 전략 | +| 도메인 이벤트 추가 | ☐ | 🟢 P2 | CollectionUpdatedEvent 등 | +| 이벤트 핸들러 고도화 | ☐ | 🟢 P2 | 실패 재시도, 데드 레터 큐 | + +### ✅ 현재 구현된 이벤트 시스템 + +| 이벤트 리스너 | 구현 상태 | 설명 | +|--------------------------------|-------|-------------------------------------------| +| PostDomainEventListener | ✅ | 게시물 생성/삭제 → MemberEvent 생성 | +| CommentDomainEventListener | ✅ | 댓글 생성/삭제 → MemberEvent 생성 (작성자 및 게시물 작성자) | +| FriendshipDomainEventListener | ✅ | 친구 요청/수락/거절/해제 → MemberEvent 생성 (양방향) | +| MemberEventDomainEventListener | ✅ | 회원 가입/활성화/프로필 업데이트 → MemberEvent 생성 | +| EmailEventListener | ✅ | 회원가입 인증/비밀번호 재설정 이메일 발송 | --- -## 🔐 인증 테스트 +## 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 상태, 이벤트 발행 | +| 친구 이벤트 알림 | ✅ | - | 도메인 이벤트 기반 MemberEvent 자동 생성 | +| 친구 추천 시스템 | ☐ | 🟢 P2 | 상호 친구, 관심사 기반 추천 | +| 친구 그룹/리스트 관리 | ☐ | 🟢 P2 | 친구 분류, 그룹별 공유 기능 | +| 친구 활동 피드 | ☐ | 🟡 P1 | 친구들의 최신 활동 표시 | + +### 🔔 팔로우 시스템 + +| 작업 항목 | 상태 | 우선순위 | 상세 설명 | +|---------------|----|-------|------------------------------| +| 팔로우/언팔로우 기능 | ✅ | - | 단방향 관계, ACTIVE/UNFOLLOWED 상태 | +| 팔로워/팔로잉 목록 조회 | ✅ | - | 페이징, 정렬, 상세 정보 조회 | +| 팔로우 상태 확인 | ✅ | - | 특정 사용자와의 팔로우 관계 확인 | +| 실시간 팔로우 알림 | ☐ | 🟡 P1 | WebSocket, 푸시 알림 | +| 팔로우 추천 | ☐ | 🟢 P2 | 관심사 기반, 맞팔 추천 | +| 팔로우 차단 기능 | ☐ | 🟢 P2 | BLOCKED 상태, 특정 사용자 차단 | + +### 💬 댓글 시스템 + +| 작업 항목 | 상태 | 우선순위 | 상세 설명 | +|-------------|----|-------|---------------------------| +| 댓글 생성/수정/삭제 | ✅ | - | Soft Delete, 댓글 수 계산 | +| 대댓글 기능 | ✅ | - | 계층적 댓글 구조, 부모-자식 관계 | +| 댓글 조회 및 페이징 | ✅ | - | 게시글별 댓글, 정렬 기능 | +| 댓글 알림 | ✅ | - | 도메인 이벤트 기반 MemberEvent 생성 | +| 댓글 신고 기능 | ☐ | 🟡 P1 | 부적절한 댓글 신고, 관리자 검토 | +| 댓글 좋아요 기능 | ☐ | 🟢 P2 | 댓글별 좋아요, 비동기 처리 | +| 댓글 필터링 및 정렬 | ☐ | 🟢 P2 | 최신순, 인기순, 작성자 필터 | + +### 🔔 회원 이벤트 시스템 (MemberEvent) + +| 작업 항목 | 상태 | 우선순위 | 상세 설명 | +|---------------------|----|-------|-------------------------| +| MemberEvent 엔티티 구현 | ✅ | - | 회원 활동 기록, 10가지 이벤트 타입 | +| 이벤트 자동 생성 | ✅ | - | 도메인 이벤트 발생 시 자동 생성 | +| 이벤트 목록 조회 API | ✅ | - | 페이징, 필터링 (읽음/타입별) | +| 읽지 않은 이벤트 수 조회 API | ✅ | - | 실시간 알림 카운트 | +| 이벤트 읽음 처리 API | ✅ | - | 개별/일괄 읽음 처리 | +| 이벤트 프래그먼트 조회 (HTMX) | ✅ | - | 동적 UI 업데이트 | +| WebSocket 실시간 알림 | ☐ | 🟡 P1 | 폴링 방식 → WebSocket 푸시 알림 | +| 이벤트 자동 삭제 스케줄러 | ☐ | 🟢 P2 | 90일 경과 읽은 이벤트 삭제 | +| 이벤트 필터링 고도화 | ☐ | 🟢 P2 | 날짜 범위, 다중 타입 필터 | +| 이벤트 알림 설정 | ☐ | 🟢 P2 | 사용자별 알림 on/off, 타입별 설정 | -### @WithMockMember 사용 +--- -```kotlin -@WithMockMember(email = "test@example.com", nickname = "테스트") -@Test -fun `authenticated request test`() { - // 인증된 상태로 테스트 실행 -} -``` +## 5. 성능 및 최적화 -### 인증 실패 테스트 +### ⚡ 데이터베이스 최적화 -```kotlin -@Test -fun `deleteCollection - forbidden - when not authenticated`() { - mockMvc.perform( - delete("/api/collections/1") - .with(csrf()) - ) - .andExpect(status().isForbidden()) -} -``` +| 작업 항목 | 상태 | 우선순위 | 상세 설명 | +|----------------|----|-------|----------------------------| +| 인덱스 전략 개선 | ☐ | 🟡 P1 | 복합 인덱스 추가, 쿼리 실행 계획 분석 | +| N+1 문제 해결 | ☐ | 🟡 P1 | Fetch Join, DTO Projection | +| Redis 캐시 구현 | ☐ | 🟡 P1 | 조회 성능 개선, 캐시 무효화 | +| Caffeine 로컬 캐시 | ☐ | 🟢 P2 | 애플리케이션 레벨 캐싱 | +| JVM 튜닝 | ☐ | 🟢 P2 | G1GC, 메모리 할당 최적화 | +| 프로파일링 도구 연동 | ☐ | 🟢 P2 | Micrometer, APM 도구 | --- -## 📝 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) -``` +## 6. 보안 강화 + +### 🔐 인증 및 인가 + +| 작업 항목 | 상태 | 우선순위 | 상세 설명 | +|------------------|----|-------|--------------------------------| +| 소셜 로그인 연동 | ☐ | 🟡 P1 | Google, Kakao, Naver OAuth 2.0 | +| 2FA (2단계 인증) | ☐ | 🟡 P1 | SMS, Google Authenticator | +| Rate Limiting 구현 | ☐ | 🔴 P0 | API 호출 제한, IP 차단 | +| 데이터 암호화 | ☐ | 🟢 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 - ) -) -``` +## 7. 기능 확장 + +### 🎯 핵심 기능 고도화 + +| 작업 항목 | 상태 | 우선순위 | 상세 설명 | +|--------------------|----|-------|------------------------------| +| 컬렉션 공유 기능 | ☐ | 🟢 P2 | 링크 공유, 소셜 미디어 연동 | +| 컬렉션 추천 시스템 | ☐ | ⚪ P3 | AI 기반 추천, 인기 순위 | +| 실시간 알림 (WebSocket) | ☐ | 🟡 P1 | MemberEvent 실시간 푸시, 폴링 방식 대체 | +| 커뮤니티 기능 | ☐ | ⚪ P3 | 그룹 채팅, 게시판, 해시태그 | + +### 📊 분석 및 리포팅 + +| 작업 항목 | 상태 | 우선순위 | 상세 설명 | +|---------------------|----|-------|--------------------| +| Google Analytics 연동 | ☐ | 🟢 P2 | 사용자 행동 분석, 커스텀 이벤트 | +| 비즈니스 대시보드 | ☐ | 🟢 P2 | 리포트 자동화, 예측 분석 | --- -## 🎯 테스트 시나리오 예제 - -### 성공 시나리오 - -```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) -} -``` +## 8. 모니터링 및 운영 + +### 📈 모니터링 시스템 + +| 작업 항목 | 상태 | 우선순위 | 상세 설명 | +|--------------|----|-------|--------------------------| +| ELK Stack 구축 | ☐ | 🟡 P1 | 중앙 로그 수집, 분석 | +| 애플리케이션 헬스체크 | ☐ | 🟡 P1 | Health Indicator, 의존성 상태 | +| 알림 시스템 연동 | ☐ | 🟢 P2 | Slack, SMS, 이메일 알림 | --- -## 🔄 테스트 실행 방법 +## 9. 문서 및 테스트 -### 전체 테스트 실행 +### 📚 문서화 -```bash -./gradlew test -``` +| 작업 항목 | 상태 | 우선순위 | 상세 설명 | +|----------------|----|-------|-----------------------| +| OpenAPI 3.0 명세 | ☐ | 🟢 P2 | Swagger UI, API 예제 코드 | +| 기술 문서 완성 | ☐ | 🟢 P2 | 아키텍처 다이어그램, 배포 가이드 | -### 특정 테스트만 실행 +### 🧪 테스트 자동화 -```bash -# API 테스트만 -./gradlew test --tests "*ApiTest*" +| 작업 항목 | 상태 | 우선순위 | 상세 설명 | +|-------------------|----|-------|----------------------| +| 통합 테스트 확장 | ☐ | 🟡 P1 | API 테스트, E2E 테스트 | +| TestContainers 활용 | ☐ | 🟢 P2 | 테스트 데이터 관리, Mock 데이터 | -# 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 -``` +- ✅ **완료**: 이미 구현된 기능 +- ☐ **미구현**: 구현이 필요한 기능 +- 🚧 **진행중**: 현재 개발 중인 기능 +- ⚠️ **이슈**: 문제가 있는 기능 +- 🔍 **검토**: 기술 검토가 필요한 기능 --- -## 🖼 이미지 관리 시스템 테스트 - -### 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") - } -} -``` +## 📝 사용 가이드 + +### 체크박스 사용법 + +| 작업 항목 | 상태 | 우선순위 | 상세 설명 | +|--------|----|-------|-------| +| 새로운 작업 | ☐ | 🔴 P0 | 작업 설명 | + +### 상태 업데이트 + +- 작업 완료 시: `☐` → `✅` +- 진행 중 시: `☐` → `🚧` +- 이슈 발생 시: `☐` → `⚠️` +- 검토 필요 시: `☐` → `🔍` + +### 새로운 항목 추가 + +1. 해당 섹션의 테이블에 새 행 추가 +2. 적절한 우선순위 지정 +3. 상세 설명 작성 +4. 상태는 `☐`으로 시작 --- + +*본 TODO 목록은 프로젝트 진행 상황에 따라 지속적으로 업데이트됩니다.* diff --git a/http/auth.http b/http/auth.http deleted file mode 100644 index e69de29b..00000000 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 bb1051e5..7a76f477 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 @@ -42,7 +42,7 @@ data class MemberPrincipal( introduction = member.introduction, address = member.address, createdAt = member.registeredAt, - imageId = member.imageId, + imageId = member.profileImageId, followersCount = member.followersCount, followingsCount = member.followingsCount, ) diff --git a/src/main/kotlin/com/albert/realmoneyrealtaste/adapter/webapi/event/MemberEventApi.kt b/src/main/kotlin/com/albert/realmoneyrealtaste/adapter/webapi/event/MemberEventApi.kt new file mode 100644 index 00000000..7d911f49 --- /dev/null +++ b/src/main/kotlin/com/albert/realmoneyrealtaste/adapter/webapi/event/MemberEventApi.kt @@ -0,0 +1,99 @@ +package com.albert.realmoneyrealtaste.adapter.webapi.event + +import com.albert.realmoneyrealtaste.adapter.infrastructure.security.MemberPrincipal +import com.albert.realmoneyrealtaste.application.event.dto.MemberEventResponse +import com.albert.realmoneyrealtaste.application.event.provided.MemberEventReader +import com.albert.realmoneyrealtaste.application.event.provided.MemberEventUpdater +import com.albert.realmoneyrealtaste.domain.event.MemberEventType +import org.springframework.data.domain.Page +import org.springframework.data.domain.PageRequest +import org.springframework.data.domain.Pageable +import org.springframework.data.domain.Sort +import org.springframework.http.ResponseEntity +import org.springframework.security.core.annotation.AuthenticationPrincipal +import org.springframework.web.bind.annotation.GetMapping +import org.springframework.web.bind.annotation.PathVariable +import org.springframework.web.bind.annotation.PostMapping +import org.springframework.web.bind.annotation.RequestMapping +import org.springframework.web.bind.annotation.RequestParam +import org.springframework.web.bind.annotation.RestController + +/** + * 회원 이벤트 API 컨트롤러 + */ +@RestController +@RequestMapping("/api/v1/events") +class MemberEventApi( + private val memberEventReader: MemberEventReader, + private val memberEventUpdater: MemberEventUpdater, +) { + + /** + * 회원의 이벤트 목록을 조회합니다. + */ + @GetMapping + fun getMemberEvents( + @AuthenticationPrincipal member: MemberPrincipal, + @RequestParam(defaultValue = "0") page: Int, + @RequestParam(defaultValue = "20") size: Int, + ): ResponseEntity> { + val memberId = member.id + val pageable: Pageable = PageRequest.of(page, size, Sort.by(Sort.Direction.DESC, "createdAt")) + val events = memberEventReader.readMemberEvents(memberId, pageable) + + return ResponseEntity.ok(events) + } + + /** + * 특정 타입의 회원 이벤트 목록을 조회합니다. + */ + @GetMapping("/type/{eventType}") + fun getMemberEventsByType( + @AuthenticationPrincipal member: MemberPrincipal, + @PathVariable eventType: MemberEventType, + @RequestParam(defaultValue = "0") page: Int, + @RequestParam(defaultValue = "20") size: Int, + ): ResponseEntity> { + val pageable: Pageable = PageRequest.of(page, size, Sort.by(Sort.Direction.DESC, "createdAt")) + val events = memberEventReader.readMemberEventsByType(member.id, eventType, pageable) + + return ResponseEntity.ok(events) + } + + /** + * 읽지 않은 이벤트 수를 조회합니다. + */ + @GetMapping("/unread-count") + fun getUnreadEventCount( + @AuthenticationPrincipal member: MemberPrincipal, + ): ResponseEntity { + val count = memberEventReader.readUnreadEventCount(member.id) + + return ResponseEntity.ok(count) + } + + /** + * 모든 이벤트를 읽음으로 표시합니다. + */ + @PostMapping("/mark-all-read") + fun markAllAsRead( + @AuthenticationPrincipal member: MemberPrincipal, + ): ResponseEntity { + val count = memberEventUpdater.markAllAsRead(member.id) + + return ResponseEntity.ok(count) + } + + /** + * 특정 이벤트를 읽음으로 표시합니다. + */ + @PostMapping("/{eventId}/read") + fun markAsRead( + @AuthenticationPrincipal member: MemberPrincipal, + @PathVariable eventId: Long, + ): ResponseEntity { + val event = memberEventUpdater.markAsRead(eventId, member.id) + + return ResponseEntity.ok(MemberEventResponse.from(event)) + } +} diff --git a/src/main/kotlin/com/albert/realmoneyrealtaste/adapter/webapi/event/MemberEventRestExceptionHandler.kt b/src/main/kotlin/com/albert/realmoneyrealtaste/adapter/webapi/event/MemberEventRestExceptionHandler.kt new file mode 100644 index 00000000..f0144a61 --- /dev/null +++ b/src/main/kotlin/com/albert/realmoneyrealtaste/adapter/webapi/event/MemberEventRestExceptionHandler.kt @@ -0,0 +1,17 @@ +package com.albert.realmoneyrealtaste.adapter.webapi.event + +import com.albert.realmoneyrealtaste.application.event.exception.MemberEventNotFoundException +import org.springframework.http.HttpStatus +import org.springframework.http.ResponseEntity +import org.springframework.web.bind.annotation.ExceptionHandler +import org.springframework.web.bind.annotation.RestController +import org.springframework.web.bind.annotation.RestControllerAdvice + +@RestControllerAdvice(annotations = [RestController::class]) +class MemberEventRestExceptionHandler { + + @ExceptionHandler + fun handleMemberEventNotFoundException(e: MemberEventNotFoundException): ResponseEntity { + return ResponseEntity.status(HttpStatus.NOT_FOUND).body(e.message) + } +} 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 afa00a66..996d46fd 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 @@ -39,7 +39,7 @@ class ImageApi( @RequestBody @Valid request: ImageUploadRequest, @AuthenticationPrincipal member: MemberPrincipal, ): ResponseEntity { - val response = imageUploadRequester.generatePresignedPostUrl(request, member.id) + val response = imageUploadRequester.generatePresignedUploadUrl(request, member.id) return ResponseEntity.ok(response) } diff --git a/src/main/kotlin/com/albert/realmoneyrealtaste/adapter/webview/event/MemberEventView.kt b/src/main/kotlin/com/albert/realmoneyrealtaste/adapter/webview/event/MemberEventView.kt new file mode 100644 index 00000000..09d4266d --- /dev/null +++ b/src/main/kotlin/com/albert/realmoneyrealtaste/adapter/webview/event/MemberEventView.kt @@ -0,0 +1,67 @@ +package com.albert.realmoneyrealtaste.adapter.webview.event + +import com.albert.realmoneyrealtaste.adapter.infrastructure.security.MemberPrincipal +import com.albert.realmoneyrealtaste.application.event.dto.MemberEventResponse +import com.albert.realmoneyrealtaste.application.event.provided.MemberEventReader +import com.albert.realmoneyrealtaste.application.event.provided.MemberEventUpdater +import com.albert.realmoneyrealtaste.domain.event.MemberEventType +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 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 +import org.springframework.web.bind.annotation.RequestParam + +/** + * 회원 이벤트 뷰 컨트롤러 + * 회원의 이벤트 목록을 조회하는 웹 페이지를 제공합니다. + */ +@Controller +class MemberEventView( + private val memberEventReader: MemberEventReader, + private val memberEventUpdater: MemberEventUpdater, +) { + + /** + * 이벤트 목록 프래그먼트 + */ + @GetMapping("/members/{memberId}/events/fragment") + fun eventsFragment( + @PathVariable memberId: Long, + @AuthenticationPrincipal principal: MemberPrincipal, + @PageableDefault(sort = ["createdAt"], direction = Sort.Direction.DESC, size = 20) pageable: Pageable, + @RequestParam(required = false) eventType: String?, + model: Model, + ): String { + // 본인의 이벤트만 볼 수 있도록 체크 + if (principal.id != memberId) { + return "redirect:/members/" + principal.id + "/events/fragment" + } + + val events: Page = if (eventType.isNullOrBlank()) { + memberEventReader.readMemberEvents(memberId, pageable) + } else { + try { + memberEventReader.readMemberEventsByType( + memberId, + MemberEventType.valueOf(eventType.uppercase()), + pageable + ) + } catch (e: IllegalArgumentException) { + memberEventReader.readMemberEvents(memberId, pageable) + } + } + + model.addAttribute("member", principal) + model.addAttribute("author", principal) + model.addAttribute("events", events) + model.addAttribute("page", events) + model.addAttribute("eventType", eventType) + + return "event/fragments/events :: events" + } +} diff --git a/src/main/kotlin/com/albert/realmoneyrealtaste/adapter/webview/image/ImageView.kt b/src/main/kotlin/com/albert/realmoneyrealtaste/adapter/webview/image/ImageView.kt index a15e6854..b0dc6e14 100644 --- a/src/main/kotlin/com/albert/realmoneyrealtaste/adapter/webview/image/ImageView.kt +++ b/src/main/kotlin/com/albert/realmoneyrealtaste/adapter/webview/image/ImageView.kt @@ -1,8 +1,6 @@ package com.albert.realmoneyrealtaste.adapter.webview.image -import com.albert.realmoneyrealtaste.adapter.infrastructure.security.MemberPrincipal import com.albert.realmoneyrealtaste.application.image.provided.ImageReader -import org.springframework.security.core.annotation.AuthenticationPrincipal import org.springframework.stereotype.Controller import org.springframework.ui.Model import org.springframework.web.bind.annotation.GetMapping @@ -20,7 +18,6 @@ class ImageView( @GetMapping(ImageUrls.READ_CAROUSEL) fun getImageCarousel( @RequestParam imageIds: List, - @AuthenticationPrincipal principal: MemberPrincipal?, model: Model, ): String { val images = imageReader.readImagesByIds(imageIds) @@ -38,7 +35,6 @@ class ImageView( @GetMapping(ImageUrls.READ_EDIT_IMAGES) fun getImageEdit( @RequestParam imageIds: List, - @AuthenticationPrincipal principal: MemberPrincipal?, model: Model, ): String { val images = imageReader.readImagesByIds(imageIds) 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 index 68ed2e48..98835d47 100644 --- a/src/main/kotlin/com/albert/realmoneyrealtaste/adapter/webview/member/MemberFragmentView.kt +++ b/src/main/kotlin/com/albert/realmoneyrealtaste/adapter/webview/member/MemberFragmentView.kt @@ -41,9 +41,6 @@ class MemberFragmentView( 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/post/PostReadView.kt b/src/main/kotlin/com/albert/realmoneyrealtaste/adapter/webview/post/PostReadView.kt index 1b3c5c85..4abbefd4 100644 --- a/src/main/kotlin/com/albert/realmoneyrealtaste/adapter/webview/post/PostReadView.kt +++ b/src/main/kotlin/com/albert/realmoneyrealtaste/adapter/webview/post/PostReadView.kt @@ -1,7 +1,6 @@ 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.PostReader import org.springframework.data.domain.Pageable import org.springframework.data.domain.Sort @@ -22,33 +21,6 @@ class PostReadView( private val postReader: PostReader, ) { - /** - * 현재 로그인한 사용자의 게시글 목록 페이지를 조회합니다. - * - * @param memberPrincipal 현재 인증된 사용자 정보 - * @param pageable 페이징 정보 (기본: 생성일 내림차순, 10개씩) - * @param model 뷰에 전달할 데이터 모델 - * @return 내 게시글 목록 뷰 - */ - @GetMapping(PostUrls.READ_MY_LIST) - fun readMyPosts( - @AuthenticationPrincipal memberPrincipal: MemberPrincipal, - @PageableDefault(size = 10, sort = ["createdAt"], direction = Sort.Direction.DESC) pageable: Pageable, - model: Model, - ): String { - val postsPage = postReader.readPostsByAuthor( - authorId = memberPrincipal.id, - pageable = pageable, - ) - model.addAttribute("postCreateForm", PostCreateForm()) - model.addAttribute("posts", postsPage) - // author: 프로필 페이지의 주인 (게시물 작성자) - model.addAttribute("author", memberPrincipal) - // member: 현재 로그인한 사용자 (뷰에서 권한 확인용) - model.addAttribute("member", memberPrincipal) - return PostViews.MY_LIST - } - /** * 특정 멤버의 게시글 목록 프래그먼트를 조회합니다. * 비동기 AJAX 요청에 사용됩니다. @@ -75,31 +47,6 @@ class PostReadView( return PostViews.POSTS_CONTENT } - /** - * 현재 로그인한 사용자의 게시글 목록 프래그먼트를 조회합니다. - * 비동기 AJAX 요청에 사용됩니다. - * - * @param memberPrincipal 현재 인증된 사용자 정보 - * @param pageable 페이징 정보 (기본: 생성일 내림차순, 10개씩) - * @param model 뷰에 전달할 데이터 모델 - * @return 게시글 목록 프래그먼트 뷰 - */ - @GetMapping(PostUrls.READ_MY_LIST_FRAGMENT) - fun readMyPostsFragment( - @AuthenticationPrincipal memberPrincipal: MemberPrincipal, - @PageableDefault(size = 10, sort = ["createdAt"], direction = Sort.Direction.DESC) pageable: Pageable, - model: Model, - ): String { - val postsPage = postReader.readPostsByAuthor( - authorId = memberPrincipal.id, - pageable = pageable, - ) - model.addAttribute("posts", postsPage) - model.addAttribute("member", memberPrincipal) - model.addAttribute("author", memberPrincipal) - return PostViews.POSTS_CONTENT - } - /** * 전체 게시글 목록 프래그먼트를 조회합니다. * 인증된 사용자와 비인증 사용자 모두 접근 가능합니다. @@ -122,7 +69,7 @@ class PostReadView( } model.addAttribute("posts", postsPage) model.addAttribute("member", memberPrincipal) - model.addAttribute("author", memberPrincipal) + model.addAttribute("authorId", memberPrincipal.id) return PostViews.POSTS_CONTENT } diff --git a/src/main/kotlin/com/albert/realmoneyrealtaste/application/comment/listener/CommentDomainEventListener.kt b/src/main/kotlin/com/albert/realmoneyrealtaste/application/comment/listener/CommentDomainEventListener.kt new file mode 100644 index 00000000..6023a400 --- /dev/null +++ b/src/main/kotlin/com/albert/realmoneyrealtaste/application/comment/listener/CommentDomainEventListener.kt @@ -0,0 +1,66 @@ +package com.albert.realmoneyrealtaste.application.comment.listener + +import com.albert.realmoneyrealtaste.application.comment.required.CommentRepository +import com.albert.realmoneyrealtaste.domain.comment.event.CommentCreatedEvent +import com.albert.realmoneyrealtaste.domain.comment.event.CommentDeletedEvent +import com.albert.realmoneyrealtaste.domain.member.event.MemberProfileUpdatedDomainEvent +import org.springframework.scheduling.annotation.Async +import org.springframework.stereotype.Component +import org.springframework.transaction.annotation.Propagation +import org.springframework.transaction.annotation.Transactional +import org.springframework.transaction.event.TransactionPhase +import org.springframework.transaction.event.TransactionalEventListener + +/** + * Comment 도메인 이벤트를 처리하는 리스너 + */ +@Component +class CommentDomainEventListener( + private val commentRepository: CommentRepository, +) { + + /** + * 회원 프로필 업데이트 도메인 이벤트 처리 (크로스 도메인) + * - Comment의 작성자 닉네임 동기화 + */ + @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) + @Async + @Transactional(propagation = Propagation.REQUIRES_NEW) + fun handleMemberProfileUpdated(event: MemberProfileUpdatedDomainEvent) { + // Comment의 작성자 닉네임 업데이트 + event.nickname?.let { nickname -> + commentRepository.updateAuthorNickname( + authorMemberId = event.memberId, + nickname = nickname + ) + } + } + + /** + * 댓글 생성 도메인 이벤트 처리 + * - 대댓글인 경우 부모 댓글의 대댓글 수 증가 + */ + @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) + @Async + @Transactional(propagation = Propagation.REQUIRES_NEW) + fun handleCommentCreated(event: CommentCreatedEvent) { + // 대댓글인 경우 부모 댓글의 대댓글 수 증가 + event.parentCommentId?.let { parentCommentId -> + commentRepository.incrementRepliesCount(parentCommentId) + } + } + + /** + * 댓글 삭제 도메인 이벤트 처리 + * - 대댓글인 경우 부모 댓글의 대댓글 수 감소 + */ + @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) + @Async + @Transactional(propagation = Propagation.REQUIRES_NEW) + fun handleCommentDeleted(event: CommentDeletedEvent) { + // 대댓글인 경우 부모 댓글의 대댓글 수 감소 + event.parentCommentId?.let { parentCommentId -> + commentRepository.decrementRepliesCount(parentCommentId) + } + } +} diff --git a/src/main/kotlin/com/albert/realmoneyrealtaste/application/comment/required/CommentRepository.kt b/src/main/kotlin/com/albert/realmoneyrealtaste/application/comment/required/CommentRepository.kt index e4bf26b0..ab77eee4 100644 --- a/src/main/kotlin/com/albert/realmoneyrealtaste/application/comment/required/CommentRepository.kt +++ b/src/main/kotlin/com/albert/realmoneyrealtaste/application/comment/required/CommentRepository.kt @@ -4,6 +4,8 @@ import com.albert.realmoneyrealtaste.domain.comment.Comment import com.albert.realmoneyrealtaste.domain.comment.CommentStatus import org.springframework.data.domain.Page import org.springframework.data.domain.Pageable +import org.springframework.data.jpa.repository.Modifying +import org.springframework.data.jpa.repository.Query import org.springframework.data.repository.Repository import java.util.Optional @@ -88,4 +90,35 @@ interface CommentRepository : Repository { * @param comment 삭제할 댓글 */ fun delete(comment: Comment) + + /** + * 대댓글 수를 증가시킵니다. + * 동시성 문제를 방지하기 위해 DB 레벨에서 직접 업데이트합니다. + * + * @param parentCommentId 부모 댓글 ID + */ + @Modifying + @Query("UPDATE Comment c SET c.repliesCount = c.repliesCount + 1 WHERE c.id = :parentCommentId") + fun incrementRepliesCount(parentCommentId: Long) + + /** + * 대댓글 수를 감소시킵니다. + * 동시성 문제를 방지하기 위해 DB 레벨에서 직접 업데이트합니다. + * + * @param parentCommentId 부모 댓글 ID + */ + @Modifying + @Query("UPDATE Comment c SET c.repliesCount = c.repliesCount - 1 WHERE c.id = :parentCommentId AND c.repliesCount > 0") + fun decrementRepliesCount(parentCommentId: Long) + + /** + * 작성자 닉네임을 업데이트합니다. + * 동시성 문제를 방지하기 위해 DB 레벨에서 직접 업데이트합니다. + * + * @param authorMemberId 작성자 회원 ID + * @param nickname 새 닉네임 + */ + @Modifying + @Query("UPDATE Comment c SET c.author.nickname = :nickname WHERE c.author.memberId = :authorMemberId") + fun updateAuthorNickname(authorMemberId: Long, nickname: String) } diff --git a/src/main/kotlin/com/albert/realmoneyrealtaste/application/comment/service/CommentCreationService.kt b/src/main/kotlin/com/albert/realmoneyrealtaste/application/comment/service/CommentCreationService.kt index 2369447a..6995aff4 100644 --- a/src/main/kotlin/com/albert/realmoneyrealtaste/application/comment/service/CommentCreationService.kt +++ b/src/main/kotlin/com/albert/realmoneyrealtaste/application/comment/service/CommentCreationService.kt @@ -6,13 +6,12 @@ import com.albert.realmoneyrealtaste.application.comment.exception.CommentCreati import com.albert.realmoneyrealtaste.application.comment.provided.CommentCreator import com.albert.realmoneyrealtaste.application.comment.provided.CommentReader import com.albert.realmoneyrealtaste.application.comment.required.CommentRepository +import com.albert.realmoneyrealtaste.application.common.provided.DomainEventPublisher import com.albert.realmoneyrealtaste.application.member.provided.MemberReader import com.albert.realmoneyrealtaste.application.post.provided.PostReader import com.albert.realmoneyrealtaste.domain.comment.Comment import com.albert.realmoneyrealtaste.domain.comment.CommentStatus -import com.albert.realmoneyrealtaste.domain.comment.event.CommentCreatedEvent import com.albert.realmoneyrealtaste.domain.comment.value.CommentContent -import org.springframework.context.ApplicationEventPublisher import org.springframework.stereotype.Service import org.springframework.transaction.annotation.Transactional @@ -21,7 +20,7 @@ class CommentCreationService( private val commentRepository: CommentRepository, private val memberReader: MemberReader, private val postReader: PostReader, - private val eventPublisher: ApplicationEventPublisher, + private val domainEventPublisher: DomainEventPublisher, private val commentReader: CommentReader, ) : CommentCreator { @@ -50,16 +49,8 @@ class CommentCreationService( // 저장 val savedComment = commentRepository.save(comment) - // 이벤트 발행 - eventPublisher.publishEvent( - CommentCreatedEvent( - commentId = savedComment.requireId(), - postId = savedComment.postId, - authorMemberId = savedComment.author.memberId, - parentCommentId = null, - createdAt = savedComment.createdAt - ) - ) + // 도메인 이벤트 발행 + domainEventPublisher.publishFrom(savedComment) return savedComment } catch (e: IllegalArgumentException) { @@ -75,7 +66,7 @@ class CommentCreationService( val nickname = memberReader.getNicknameById(request.memberId) // 부모 댓글 검증 - validateParentComment(request) + val parentComment = validateParentComment(request) // 대댓글 생성 val reply = Comment.create( @@ -83,22 +74,15 @@ class CommentCreationService( authorMemberId = request.memberId, authorNickname = nickname, content = CommentContent(request.content), - parentCommentId = request.parentCommentId + parentCommentId = request.parentCommentId, + parentCommentAuthorId = parentComment.author.memberId ) // 저장 val savedReply = commentRepository.save(reply) - // 이벤트 발행 - eventPublisher.publishEvent( - CommentCreatedEvent( - commentId = savedReply.requireId(), - postId = savedReply.postId, - authorMemberId = savedReply.author.memberId, - parentCommentId = savedReply.parentCommentId, - createdAt = savedReply.createdAt - ) - ) + // 도메인 이벤트 발행 + domainEventPublisher.publishFrom(savedReply) return savedReply } catch (e: IllegalArgumentException) { @@ -124,10 +108,11 @@ class CommentCreationService( /** * 부모 댓글 검증 로직 * @param request 대댓글 작성 요청 DTO + * @return 검증된 부모 댓글 * * @throws IllegalArgumentException 부모 댓글이 유효하지 않은 경우, 대댓글 작성이 불가능한 경우 발생 */ - private fun validateParentComment(request: ReplyCreateRequest) { + private fun validateParentComment(request: ReplyCreateRequest): Comment { val parentComment = commentReader.findById(request.parentCommentId) require(parentComment.status == CommentStatus.PUBLISHED) { ERROR_PARENT_COMMENT_NOT_FOUND } @@ -135,5 +120,7 @@ class CommentCreationService( require(parentComment.postId == request.postId) { ERROR_PARENT_COMMENT_POST_MISMATCH } require(!parentComment.isReply()) { ERROR_CANNOT_REPLY_TO_REPLY } + + return parentComment } } diff --git a/src/main/kotlin/com/albert/realmoneyrealtaste/application/comment/service/CommentUpdateService.kt b/src/main/kotlin/com/albert/realmoneyrealtaste/application/comment/service/CommentUpdateService.kt index f04647ad..d9265f88 100644 --- a/src/main/kotlin/com/albert/realmoneyrealtaste/application/comment/service/CommentUpdateService.kt +++ b/src/main/kotlin/com/albert/realmoneyrealtaste/application/comment/service/CommentUpdateService.kt @@ -5,6 +5,7 @@ import com.albert.realmoneyrealtaste.application.comment.exception.CommentNotFou import com.albert.realmoneyrealtaste.application.comment.exception.CommentUpdateException import com.albert.realmoneyrealtaste.application.comment.provided.CommentUpdater import com.albert.realmoneyrealtaste.application.comment.required.CommentRepository +import com.albert.realmoneyrealtaste.application.common.provided.DomainEventPublisher import com.albert.realmoneyrealtaste.application.member.provided.MemberReader import com.albert.realmoneyrealtaste.domain.comment.Comment import com.albert.realmoneyrealtaste.domain.comment.value.CommentContent @@ -16,6 +17,7 @@ import org.springframework.stereotype.Service class CommentUpdateService( private val commentRepository: CommentRepository, private val memberReader: MemberReader, + private val domainEventPublisher: DomainEventPublisher, ) : CommentUpdater { override fun updateComment(request: CommentUpdateRequest): Comment { @@ -37,7 +39,12 @@ class CommentUpdateService( content = CommentContent(request.content) ) - return commentRepository.save(comment) + val savedComment = commentRepository.save(comment) + + // 도메인 이벤트 발행 + domainEventPublisher.publishFrom(savedComment) + + return savedComment } catch (e: IllegalArgumentException) { throw CommentUpdateException("댓글 수정 중 오류가 발생했습니다.", e) } diff --git a/src/main/kotlin/com/albert/realmoneyrealtaste/application/common/provided/DomainEventPublisher.kt b/src/main/kotlin/com/albert/realmoneyrealtaste/application/common/provided/DomainEventPublisher.kt new file mode 100644 index 00000000..e64a8061 --- /dev/null +++ b/src/main/kotlin/com/albert/realmoneyrealtaste/application/common/provided/DomainEventPublisher.kt @@ -0,0 +1,15 @@ +package com.albert.realmoneyrealtaste.application.common.provided + +import com.albert.realmoneyrealtaste.domain.common.AggregateRoot + +/** + * 도메인 이벤트 발행 포트 + */ +fun interface DomainEventPublisher { + /** + * 애그리거트의 도메인 이벤트를 발행합니다. + * + * @param aggregate 도메인 이벤트를 가진 애그리거트 + */ + fun publishFrom(aggregate: AggregateRoot) +} diff --git a/src/main/kotlin/com/albert/realmoneyrealtaste/application/common/provided/DomainEventPublisherService.kt b/src/main/kotlin/com/albert/realmoneyrealtaste/application/common/provided/DomainEventPublisherService.kt new file mode 100644 index 00000000..326c5e36 --- /dev/null +++ b/src/main/kotlin/com/albert/realmoneyrealtaste/application/common/provided/DomainEventPublisherService.kt @@ -0,0 +1,19 @@ +package com.albert.realmoneyrealtaste.application.common.provided + +import com.albert.realmoneyrealtaste.domain.common.AggregateRoot +import org.springframework.context.ApplicationEventPublisher +import org.springframework.stereotype.Component + +/** + * 도메인 이벤트 발행 서비스 구현체 + */ +@Component +class DomainEventPublisherService( + private val eventPublisher: ApplicationEventPublisher, +) : DomainEventPublisher { + + override fun publishFrom(aggregate: AggregateRoot) { + val events = aggregate.drainDomainEvents() + events.forEach { eventPublisher.publishEvent(it) } + } +} diff --git a/src/main/kotlin/com/albert/realmoneyrealtaste/application/event/MemberEventCreationService.kt b/src/main/kotlin/com/albert/realmoneyrealtaste/application/event/MemberEventCreationService.kt new file mode 100644 index 00000000..974d852c --- /dev/null +++ b/src/main/kotlin/com/albert/realmoneyrealtaste/application/event/MemberEventCreationService.kt @@ -0,0 +1,40 @@ +package com.albert.realmoneyrealtaste.application.event + +import com.albert.realmoneyrealtaste.application.event.provided.MemberEventCreator +import com.albert.realmoneyrealtaste.application.event.required.MemberEventRepository +import com.albert.realmoneyrealtaste.domain.event.MemberEvent +import com.albert.realmoneyrealtaste.domain.event.MemberEventType +import org.springframework.stereotype.Service +import org.springframework.transaction.annotation.Transactional + +/** + * 회원 이벤트 생성 서비스 + */ +@Service +@Transactional +class MemberEventCreationService( + private val memberEventRepository: MemberEventRepository, +) : MemberEventCreator { + + override fun createEvent( + memberId: Long, + eventType: MemberEventType, + title: String, + message: String, + relatedMemberId: Long?, + relatedPostId: Long?, + relatedCommentId: Long?, + ): MemberEvent { + val event = MemberEvent.create( + memberId = memberId, + eventType = eventType, + title = title, + message = message, + relatedMemberId = relatedMemberId, + relatedPostId = relatedPostId, + relatedCommentId = relatedCommentId + ) + + return memberEventRepository.save(event) + } +} diff --git a/src/main/kotlin/com/albert/realmoneyrealtaste/application/event/MemberEventQueryService.kt b/src/main/kotlin/com/albert/realmoneyrealtaste/application/event/MemberEventQueryService.kt new file mode 100644 index 00000000..0238fbd0 --- /dev/null +++ b/src/main/kotlin/com/albert/realmoneyrealtaste/application/event/MemberEventQueryService.kt @@ -0,0 +1,57 @@ +package com.albert.realmoneyrealtaste.application.event + +import com.albert.realmoneyrealtaste.application.event.dto.MemberEventResponse +import com.albert.realmoneyrealtaste.application.event.exception.MemberEventNotFoundException +import com.albert.realmoneyrealtaste.application.event.provided.MemberEventReader +import com.albert.realmoneyrealtaste.application.event.required.MemberEventRepository +import com.albert.realmoneyrealtaste.domain.event.MemberEvent +import com.albert.realmoneyrealtaste.domain.event.MemberEventType +import org.springframework.data.domain.Page +import org.springframework.data.domain.Pageable +import org.springframework.stereotype.Service +import org.springframework.transaction.annotation.Transactional + +/** + * 회원 이벤트 조회 서비스 + */ +@Service +@Transactional(readOnly = true) +class MemberEventQueryService( + private val memberEventRepository: MemberEventRepository, +) : MemberEventReader { + + override fun readMemberEvents( + memberId: Long, + pageable: Pageable, + ): Page { + val events = memberEventRepository.findByMemberIdOrderByCreatedAtDesc(memberId, pageable) + return events.map { MemberEventResponse.from(it) } + } + + override fun readMemberEventsByType( + memberId: Long, + eventType: MemberEventType, + pageable: Pageable, + ): Page { + val events = memberEventRepository.findByMemberIdAndEventTypeOrderByCreatedAtDesc( + memberId, eventType, pageable + ) + return events.map { MemberEventResponse.from(it) } + } + + override fun readUnreadEventCount(memberId: Long): Long { + return memberEventRepository.countByMemberIdAndIsReadFalse(memberId) + } + + override fun findByIdAndMemberId( + eventId: Long, + memberId: Long, + ): MemberEvent { + try { + return memberEventRepository.findByIdAndMemberId(eventId, memberId) + ?: throw IllegalArgumentException("이벤트를 찾을 수 없거나 접근 권한이 없습니다: $eventId") + } catch (e: IllegalArgumentException) { + throw MemberEventNotFoundException("이벤트를 찾을 수 없거나 접근 권한이 없습니다: $eventId") + } + } +} diff --git a/src/main/kotlin/com/albert/realmoneyrealtaste/application/event/MemberEventUpdateService.kt b/src/main/kotlin/com/albert/realmoneyrealtaste/application/event/MemberEventUpdateService.kt new file mode 100644 index 00000000..11ae8d0f --- /dev/null +++ b/src/main/kotlin/com/albert/realmoneyrealtaste/application/event/MemberEventUpdateService.kt @@ -0,0 +1,35 @@ +package com.albert.realmoneyrealtaste.application.event + +import com.albert.realmoneyrealtaste.application.event.provided.MemberEventReader +import com.albert.realmoneyrealtaste.application.event.provided.MemberEventUpdater +import com.albert.realmoneyrealtaste.application.event.required.MemberEventRepository +import com.albert.realmoneyrealtaste.domain.event.MemberEvent +import org.springframework.stereotype.Service +import org.springframework.transaction.annotation.Transactional +import java.time.LocalDateTime + +/** + * 회원 이벤트 수정 서비스 + */ +@Service +@Transactional +class MemberEventUpdateService( + private val memberEventRepository: MemberEventRepository, + private val memberEventReader: MemberEventReader, +) : MemberEventUpdater { + + override fun markAllAsRead(memberId: Long): Int { + return memberEventRepository.markAllAsRead(memberId) + } + + override fun markAsRead(eventId: Long, memberId: Long): MemberEvent { + val event = memberEventReader.findByIdAndMemberId(eventId, memberId) + + event.markAsRead() + return memberEventRepository.save(event) + } + + override fun deleteOldEvents(memberId: Long, beforeDate: LocalDateTime): Int { + return memberEventRepository.deleteOldEvents(memberId, beforeDate) + } +} diff --git a/src/main/kotlin/com/albert/realmoneyrealtaste/application/event/dto/MemberEventResponse.kt b/src/main/kotlin/com/albert/realmoneyrealtaste/application/event/dto/MemberEventResponse.kt new file mode 100644 index 00000000..5616aae8 --- /dev/null +++ b/src/main/kotlin/com/albert/realmoneyrealtaste/application/event/dto/MemberEventResponse.kt @@ -0,0 +1,38 @@ +package com.albert.realmoneyrealtaste.application.event.dto + +import com.albert.realmoneyrealtaste.domain.event.MemberEventType +import java.time.LocalDateTime + +/** + * 회원 이벤트 응답 DTO + */ +data class MemberEventResponse( + val id: Long, + val eventType: MemberEventType, + val title: String, + val message: String, + val relatedMemberId: Long?, + val relatedPostId: Long?, + val relatedCommentId: Long?, + val isRead: Boolean, + val createdAt: LocalDateTime, +) { + companion object { + /** + * MemberEvent 엔티티로부터 응답 DTO 생성 + */ + fun from(event: com.albert.realmoneyrealtaste.domain.event.MemberEvent): MemberEventResponse { + return MemberEventResponse( + id = event.requireId(), + eventType = event.eventType, + title = event.title, + message = event.message, + relatedMemberId = event.relatedMemberId, + relatedPostId = event.relatedPostId, + relatedCommentId = event.relatedCommentId, + isRead = event.isRead, + createdAt = event.createdAt + ) + } + } +} diff --git a/src/main/kotlin/com/albert/realmoneyrealtaste/application/event/exception/MemberEventApplicationException.kt b/src/main/kotlin/com/albert/realmoneyrealtaste/application/event/exception/MemberEventApplicationException.kt new file mode 100644 index 00000000..e866c0f8 --- /dev/null +++ b/src/main/kotlin/com/albert/realmoneyrealtaste/application/event/exception/MemberEventApplicationException.kt @@ -0,0 +1,4 @@ +package com.albert.realmoneyrealtaste.application.event.exception + +sealed class MemberEventApplicationException(message: String, cause: Throwable? = null) : + IllegalArgumentException(message, cause) diff --git a/src/main/kotlin/com/albert/realmoneyrealtaste/application/event/exception/MemberEventNotFoundException.kt b/src/main/kotlin/com/albert/realmoneyrealtaste/application/event/exception/MemberEventNotFoundException.kt new file mode 100644 index 00000000..8d3729ac --- /dev/null +++ b/src/main/kotlin/com/albert/realmoneyrealtaste/application/event/exception/MemberEventNotFoundException.kt @@ -0,0 +1,3 @@ +package com.albert.realmoneyrealtaste.application.event.exception + +class MemberEventNotFoundException(message: String) : MemberEventApplicationException(message) diff --git a/src/main/kotlin/com/albert/realmoneyrealtaste/application/event/listener/MemberEventDomainEventListener.kt b/src/main/kotlin/com/albert/realmoneyrealtaste/application/event/listener/MemberEventDomainEventListener.kt new file mode 100644 index 00000000..1787ece7 --- /dev/null +++ b/src/main/kotlin/com/albert/realmoneyrealtaste/application/event/listener/MemberEventDomainEventListener.kt @@ -0,0 +1,215 @@ +package com.albert.realmoneyrealtaste.application.event.listener + +import com.albert.realmoneyrealtaste.application.event.MemberEventCreationService +import com.albert.realmoneyrealtaste.domain.comment.event.CommentCreatedEvent +import com.albert.realmoneyrealtaste.domain.comment.event.CommentDeletedEvent +import com.albert.realmoneyrealtaste.domain.event.MemberEventType +import com.albert.realmoneyrealtaste.domain.friend.event.FriendRequestAcceptedEvent +import com.albert.realmoneyrealtaste.domain.friend.event.FriendRequestRejectedEvent +import com.albert.realmoneyrealtaste.domain.friend.event.FriendRequestSentEvent +import com.albert.realmoneyrealtaste.domain.friend.event.FriendshipTerminatedEvent +import com.albert.realmoneyrealtaste.domain.member.event.MemberActivatedDomainEvent +import com.albert.realmoneyrealtaste.domain.member.event.MemberDeactivatedDomainEvent +import com.albert.realmoneyrealtaste.domain.member.event.MemberProfileUpdatedDomainEvent +import com.albert.realmoneyrealtaste.domain.post.event.PostCreatedEvent +import com.albert.realmoneyrealtaste.domain.post.event.PostDeletedEvent +import org.springframework.scheduling.annotation.Async +import org.springframework.stereotype.Component +import org.springframework.transaction.annotation.Propagation +import org.springframework.transaction.annotation.Transactional +import org.springframework.transaction.event.TransactionPhase +import org.springframework.transaction.event.TransactionalEventListener + +/** + * 모든 도메인 이벤트를 수신하여 회원 이벤트를 저장하는 전용 리스너 + */ +@Component +class MemberEventDomainEventListener( + private val memberEventCreationService: MemberEventCreationService, +) { + + // ===== 친구 관련 이벤트 ===== + + @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) + @Async + @Transactional(propagation = Propagation.REQUIRES_NEW) + fun handleFriendRequestSent(event: FriendRequestSentEvent) { + // 요청자에게 이벤트 저장 + memberEventCreationService.createEvent( + memberId = event.fromMemberId, + eventType = MemberEventType.FRIEND_REQUEST_SENT, + title = "친구 요청을 보냈습니다", + message = "${event.toMemberId}님에게 친구 요청을 보냈습니다.", + relatedMemberId = event.toMemberId + ) + + // 수신자에게 이벤트 저장 + memberEventCreationService.createEvent( + memberId = event.toMemberId, + eventType = MemberEventType.FRIEND_REQUEST_RECEIVED, + title = "친구 요청을 받았습니다", + message = "${event.fromMemberId}님이 친구 요청을 보냈습니다.", + relatedMemberId = event.fromMemberId + ) + } + + @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) + @Async + @Transactional(propagation = Propagation.REQUIRES_NEW) + fun handleFriendRequestAccepted(event: FriendRequestAcceptedEvent) { + // 요청자에게 이벤트 저장 + memberEventCreationService.createEvent( + memberId = event.fromMemberId, + eventType = MemberEventType.FRIEND_REQUEST_ACCEPTED, + title = "친구 요청이 수락되었습니다", + message = "${event.toMemberId}님이 친구 요청을 수락했습니다.", + relatedMemberId = event.toMemberId + ) + + // 수락자에게 이벤트 저장 + memberEventCreationService.createEvent( + memberId = event.toMemberId, + eventType = MemberEventType.FRIEND_REQUEST_ACCEPTED, + title = "친구 요청을 수락했습니다", + message = "${event.fromMemberId}님의 친구 요청을 수락했습니다.", + relatedMemberId = event.fromMemberId + ) + } + + @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) + @Async + @Transactional(propagation = Propagation.REQUIRES_NEW) + fun handleFriendRequestRejected(event: FriendRequestRejectedEvent) { + memberEventCreationService.createEvent( + memberId = event.fromMemberId, + eventType = MemberEventType.FRIEND_REQUEST_REJECTED, + title = "친구 요청이 거절되었습니다", + message = "${event.toMemberId}님이 친구 요청을 거절했습니다.", + relatedMemberId = event.toMemberId + ) + } + + @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) + @Async + @Transactional(propagation = Propagation.REQUIRES_NEW) + fun handleFriendshipTerminated(event: FriendshipTerminatedEvent) { + memberEventCreationService.createEvent( + memberId = event.memberId, + eventType = MemberEventType.FRIENDSHIP_TERMINATED, + title = "친구 관계가 해제되었습니다", + message = "${event.friendMemberId}님과의 친구 관계가 해제되었습니다.", + relatedMemberId = event.friendMemberId + ) + } + + // ===== 게시물 관련 이벤트 ===== + + @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) + @Async + @Transactional(propagation = Propagation.REQUIRES_NEW) + fun handlePostCreated(event: PostCreatedEvent) { + memberEventCreationService.createEvent( + memberId = event.authorMemberId, + eventType = MemberEventType.POST_CREATED, + title = "새 게시물을 작성했습니다", + message = "새로운 맛집 게시물을 작성했습니다.", + relatedPostId = event.postId + ) + } + + @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) + @Async + @Transactional(propagation = Propagation.REQUIRES_NEW) + fun handlePostDeleted(event: PostDeletedEvent) { + memberEventCreationService.createEvent( + memberId = event.authorMemberId, + eventType = MemberEventType.POST_DELETED, + title = "게시물을 삭제했습니다", + message = "게시물을 삭제했습니다.", + relatedPostId = event.postId + ) + } + + // ===== 댓글 관련 이벤트 ===== + + @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) + @Async + @Transactional(propagation = Propagation.REQUIRES_NEW) + fun handleCommentCreated(event: CommentCreatedEvent) { + // 댓글 작성자에게 이벤트 저장 + memberEventCreationService.createEvent( + memberId = event.authorMemberId, + eventType = MemberEventType.COMMENT_CREATED, + title = "댓글을 작성했습니다", + message = "댓글을 작성했습니다.", + relatedPostId = event.postId, + relatedCommentId = event.commentId + ) + + // 대댓글인 경우 부모 댓글 작성자에게 알림 + event.parentCommentAuthorId?.let { parentAuthorId -> + if (parentAuthorId != event.authorMemberId) { + memberEventCreationService.createEvent( + memberId = parentAuthorId, + eventType = MemberEventType.COMMENT_REPLIED, + title = "대댓글이 달렸습니다", + message = "댓글에 대댓글이 달렸습니다.", + relatedPostId = event.postId, + relatedCommentId = event.parentCommentId + ) + } + } + } + + @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) + @Async + @Transactional(propagation = Propagation.REQUIRES_NEW) + fun handleCommentDeleted(event: CommentDeletedEvent) { + memberEventCreationService.createEvent( + memberId = event.authorMemberId, + eventType = MemberEventType.COMMENT_DELETED, + title = "댓글을 삭제했습니다", + message = "댓글을 삭제했습니다.", + relatedPostId = event.postId, + relatedCommentId = event.commentId + ) + } + + // ===== 회원 관련 이벤트 ===== + + @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) + @Async + @Transactional(propagation = Propagation.REQUIRES_NEW) + fun handleMemberActivated(event: MemberActivatedDomainEvent) { + memberEventCreationService.createEvent( + memberId = event.memberId, + eventType = MemberEventType.ACCOUNT_ACTIVATED, + title = "계정이 활성화되었습니다", + message = "회원님의 계정이 성공적으로 활성화되었습니다." + ) + } + + @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) + @Async + @Transactional(propagation = Propagation.REQUIRES_NEW) + fun handleMemberDeactivated(event: MemberDeactivatedDomainEvent) { + memberEventCreationService.createEvent( + memberId = event.memberId, + eventType = MemberEventType.ACCOUNT_DEACTIVATED, + title = "계정이 비활성화되었습니다", + message = "회원님의 계정이 비활성화되었습니다." + ) + } + + @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) + @Async + @Transactional(propagation = Propagation.REQUIRES_NEW) + fun handleMemberProfileUpdated(event: MemberProfileUpdatedDomainEvent) { + memberEventCreationService.createEvent( + memberId = event.memberId, + eventType = MemberEventType.PROFILE_UPDATED, + title = "프로필이 업데이트되었습니다", + message = "회원님의 프로필 정보가 업데이트되었습니다." + ) + } +} diff --git a/src/main/kotlin/com/albert/realmoneyrealtaste/application/event/provided/MemberEventCreator.kt b/src/main/kotlin/com/albert/realmoneyrealtaste/application/event/provided/MemberEventCreator.kt new file mode 100644 index 00000000..e66a48fc --- /dev/null +++ b/src/main/kotlin/com/albert/realmoneyrealtaste/application/event/provided/MemberEventCreator.kt @@ -0,0 +1,31 @@ +package com.albert.realmoneyrealtaste.application.event.provided + +import com.albert.realmoneyrealtaste.domain.event.MemberEvent + +/** + * 회원 이벤트 생성 기능을 제공하는 인터페이스 + */ +interface MemberEventCreator { + + /** + * 새로운 회원 이벤트를 생성합니다. + * + * @param memberId 회원 ID + * @param eventType 이벤트 타입 + * @param title 이벤트 제목 + * @param message 이벤트 메시지 + * @param relatedMemberId 관련 회원 ID (선택사항) + * @param relatedPostId 관련 게시물 ID (선택사항) + * @param relatedCommentId 관련 댓글 ID (선택사항) + * @return 생성된 회원 이벤트 + */ + fun createEvent( + memberId: Long, + eventType: com.albert.realmoneyrealtaste.domain.event.MemberEventType, + title: String, + message: String, + relatedMemberId: Long? = null, + relatedPostId: Long? = null, + relatedCommentId: Long? = null, + ): MemberEvent +} diff --git a/src/main/kotlin/com/albert/realmoneyrealtaste/application/event/provided/MemberEventReader.kt b/src/main/kotlin/com/albert/realmoneyrealtaste/application/event/provided/MemberEventReader.kt new file mode 100644 index 00000000..c06cc747 --- /dev/null +++ b/src/main/kotlin/com/albert/realmoneyrealtaste/application/event/provided/MemberEventReader.kt @@ -0,0 +1,49 @@ +package com.albert.realmoneyrealtaste.application.event.provided + +import com.albert.realmoneyrealtaste.application.event.dto.MemberEventResponse +import com.albert.realmoneyrealtaste.domain.event.MemberEvent +import com.albert.realmoneyrealtaste.domain.event.MemberEventType +import org.springframework.data.domain.Page +import org.springframework.data.domain.Pageable + +/** + * 회원 이벤트 조회 기능을 제공하는 인터페이스 + */ +interface MemberEventReader { + + /** + * 회원의 이벤트 목록을 조회합니다. + * + * @param memberId 회원 ID + * @param pageable 페이징 정보 + * @return 페이징된 이벤트 목록 + */ + fun readMemberEvents( + memberId: Long, + pageable: Pageable, + ): Page + + /** + * 회원의 특정 타입 이벤트 목록을 조회합니다. + * + * @param memberId 회원 ID + * @param eventType 이벤트 타입 + * @param pageable 페이징 정보 + * @return 페이징된 이벤트 목록 + */ + fun readMemberEventsByType( + memberId: Long, + eventType: MemberEventType, + pageable: Pageable, + ): Page + + /** + * 읽지 않은 이벤트 수를 조회합니다. + * + * @param memberId 회원 ID + * @return 읽지 않은 이벤트 수 + */ + fun readUnreadEventCount(memberId: Long): Long + + fun findByIdAndMemberId(eventId: Long, memberId: Long): MemberEvent +} diff --git a/src/main/kotlin/com/albert/realmoneyrealtaste/application/event/provided/MemberEventUpdater.kt b/src/main/kotlin/com/albert/realmoneyrealtaste/application/event/provided/MemberEventUpdater.kt new file mode 100644 index 00000000..474917cd --- /dev/null +++ b/src/main/kotlin/com/albert/realmoneyrealtaste/application/event/provided/MemberEventUpdater.kt @@ -0,0 +1,37 @@ +package com.albert.realmoneyrealtaste.application.event.provided + +import com.albert.realmoneyrealtaste.domain.event.MemberEvent +import java.time.LocalDateTime + +/** + * 회원 이벤트 수정 기능을 제공하는 인터페이스 + */ +interface MemberEventUpdater { + + /** + * 특정 회원의 모든 이벤트를 읽음으로 표시합니다. + * + * @param memberId 회원 ID + * @return 읽음으로 표시된 이벤트 수 + */ + fun markAllAsRead(memberId: Long): Int + + /** + * 특정 이벤트를 읽음으로 표시합니다. + * + * @param eventId 이벤트 ID + * @param memberId 회원 ID (소유권 검증용) + * @return 읽음으로 표시된 이벤트 + * @throws IllegalArgumentException 이벤트를 찾을 수 없거나 접근 권한이 없는 경우 + */ + fun markAsRead(eventId: Long, memberId: Long): MemberEvent + + /** + * 오래된 이벤트를 삭제합니다. + * + * @param memberId 회원 ID + * @param beforeDate 이 날짜보다 이전의 이벤트만 삭제 + * @return 삭제된 이벤트 수 + */ + fun deleteOldEvents(memberId: Long, beforeDate: LocalDateTime): Int +} diff --git a/src/main/kotlin/com/albert/realmoneyrealtaste/application/event/required/MemberEventRepository.kt b/src/main/kotlin/com/albert/realmoneyrealtaste/application/event/required/MemberEventRepository.kt new file mode 100644 index 00000000..bef3ac41 --- /dev/null +++ b/src/main/kotlin/com/albert/realmoneyrealtaste/application/event/required/MemberEventRepository.kt @@ -0,0 +1,87 @@ +package com.albert.realmoneyrealtaste.application.event.required + +import com.albert.realmoneyrealtaste.domain.event.MemberEvent +import com.albert.realmoneyrealtaste.domain.event.MemberEventType +import org.springframework.data.domain.Page +import org.springframework.data.domain.Pageable +import org.springframework.data.jpa.repository.JpaRepository +import org.springframework.data.jpa.repository.Modifying +import org.springframework.data.jpa.repository.Query +import org.springframework.data.repository.query.Param +import org.springframework.stereotype.Repository + +/** + * 회원 이벤트 저장소 + */ +@Repository +interface MemberEventRepository : JpaRepository { + + /** + * 특정 회원의 이벤트를 페이징하여 조회 (최신순) + */ + fun findByMemberIdOrderByCreatedAtDesc( + memberId: Long, + pageable: Pageable, + ): Page + + /** + * 특정 회원의 읽지 않은 이벤트 수를 조회 + */ + fun countByMemberIdAndIsReadFalse(memberId: Long): Long + + /** + * 특정 회원의 특정 타입 이벤트를 페이징하여 조회 + */ + fun findByMemberIdAndEventTypeOrderByCreatedAtDesc( + memberId: Long, + eventType: MemberEventType, + pageable: Pageable, + ): Page + + /** + * 특정 회원의 모든 이벤트를 읽음으로 표시 + */ + @Modifying + @Query("UPDATE MemberEvent e SET e.isRead = true WHERE e.memberId = :memberId AND e.isRead = false") + fun markAllAsRead(@Param("memberId") memberId: Long): Int + + /** + * 회원 ID와 이벤트 ID로 이벤트 조회 (보안용) + */ + fun findByIdAndMemberId(eventId: Long, memberId: Long): MemberEvent? + + /** + * 특정 회원의 오래된 이벤트 삭제 (보관 기간 정책용) + */ + @Modifying + @Query("DELETE FROM MemberEvent e WHERE e.memberId = :memberId AND e.createdAt < :beforeDate") + fun deleteOldEvents( + @Param("memberId") memberId: Long, + @Param("beforeDate") beforeDate: java.time.LocalDateTime, + ): Int + + /** + * 특정 회원의 특정 관련 게시물 이벤트 삭제 (게시물 삭제 시 연관 이벤트 정리) + */ + @Modifying + @Query("DELETE FROM MemberEvent e WHERE e.memberId = :memberId AND e.relatedPostId = :postId") + fun deleteByMemberIdAndRelatedPostId( + @Param("memberId") memberId: Long, + @Param("postId") postId: Long, + ): Int + + /** + * 특정 회원의 특정 관련 댓글 이벤트 삭제 (댓글 삭제 시 연관 이벤트 정리) + */ + @Modifying + @Query("DELETE FROM MemberEvent e WHERE e.memberId = :memberId AND e.relatedCommentId = :commentId") + fun deleteByMemberIdAndRelatedCommentId( + @Param("memberId") memberId: Long, + @Param("commentId") commentId: Long, + ): Int + + /** + * 특정 회원의 모든 이벤트 조회 + */ + fun findByMemberId(memberId: Long): List +} 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 fbe41721..6a14a6b4 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 @@ -40,6 +40,13 @@ class FollowCreationService( return existingFollow } else if (existingFollow != null) { existingFollow.reactivate() + eventPublisher.publishEvent( + FollowStartedEvent( + followId = existingFollow.requireId(), + followerId = existingFollow.relationship.followerId, + followingId = existingFollow.relationship.followingId, + ) + ) return existingFollow } @@ -47,10 +54,10 @@ class FollowCreationService( val command = FollowCreateCommand( followerId = follower.requireId(), followerNickname = follower.nickname.value, - followerProfileImageId = follower.imageId, + followerProfileImageId = follower.profileImageId, followingId = following.requireId(), followingNickname = following.nickname.value, - followingProfileImageId = following.imageId, + followingProfileImageId = following.profileImageId, ) val follow = Follow.create(command) val savedFollow = followRepository.save(follow) diff --git a/src/main/kotlin/com/albert/realmoneyrealtaste/application/friend/listener/FriendshipDomainEventListener.kt b/src/main/kotlin/com/albert/realmoneyrealtaste/application/friend/listener/FriendshipDomainEventListener.kt new file mode 100644 index 00000000..6db32580 --- /dev/null +++ b/src/main/kotlin/com/albert/realmoneyrealtaste/application/friend/listener/FriendshipDomainEventListener.kt @@ -0,0 +1,40 @@ +package com.albert.realmoneyrealtaste.application.friend.listener + +import com.albert.realmoneyrealtaste.application.friend.required.FriendshipRepository +import com.albert.realmoneyrealtaste.domain.member.event.MemberProfileUpdatedDomainEvent +import org.springframework.scheduling.annotation.Async +import org.springframework.stereotype.Component +import org.springframework.transaction.annotation.Propagation +import org.springframework.transaction.annotation.Transactional +import org.springframework.transaction.event.TransactionPhase +import org.springframework.transaction.event.TransactionalEventListener + +/** + * Friendship 도메인 이벤트를 처리하는 리스너 + */ +@Component +class FriendshipDomainEventListener( + private val friendshipRepository: FriendshipRepository, +) { + + /** + * 회원 프로필 업데이트 도메인 이벤트 처리 (크로스 도메인) + * - FriendRelationship의 nickname과 imageId 동기화 + */ + @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) + @Async + @Transactional(propagation = Propagation.REQUIRES_NEW) + fun handleMemberProfileUpdated(event: MemberProfileUpdatedDomainEvent) { + // 해당 회원과 관련된 모든 활성 친구 관계 업데이트 + val friendships = friendshipRepository.findAllActiveByMemberId(event.memberId) + + friendships.forEach { friendship -> + friendship.updateMemberInfo( + memberId = event.memberId, + nickname = event.nickname, + imageId = event.imageId + ) + friendshipRepository.save(friendship) + } + } +} 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 8071b359..af6daab0 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 @@ -179,4 +179,17 @@ interface FriendshipRepository : Repository { friendMemberId: Long, statusList: List, ): Boolean + + /** + * 특정 회원과 관련된 모든 활성 친구 관계를 조회합니다. + * (회원이 요청자이거나 수신자인 모든 관계) + */ + @Query( + """ + SELECT f FROM Friendship f + WHERE (f.relationShip.memberId = :memberId OR f.relationShip.friendMemberId = :memberId) + AND f.status = 'ACCEPTED' + """ + ) + fun findAllActiveByMemberId(memberId: Long): List } 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 971f841e..721ce068 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 @@ -1,16 +1,14 @@ package com.albert.realmoneyrealtaste.application.friend.service +import com.albert.realmoneyrealtaste.application.common.provided.DomainEventPublisher import com.albert.realmoneyrealtaste.application.friend.exception.FriendRequestException import com.albert.realmoneyrealtaste.application.friend.provided.FriendRequestor 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 com.albert.realmoneyrealtaste.domain.friend.command.FriendRequestCommand -import com.albert.realmoneyrealtaste.domain.friend.event.FriendRequestSentEvent import jakarta.transaction.Transactional -import org.springframework.context.ApplicationEventPublisher import org.springframework.stereotype.Service @Service @@ -18,7 +16,7 @@ import org.springframework.stereotype.Service class FriendRequestService( private val friendshipReader: FriendshipReader, private val memberReader: MemberReader, - private val eventPublisher: ApplicationEventPublisher, + private val domainEventPublisher: DomainEventPublisher, private val friendshipRepository: FriendshipRepository, ) : FriendRequestor { @@ -33,50 +31,33 @@ class FriendRequestService( val command = FriendRequestCommand( fromMemberId = fromMemberId, - fromMemberNickName = fromMember.nickname.value, + fromMemberNickname = fromMember.nickname.value, + fromMemberProfileImageId = fromMember.profileImageId, toMemberId = toMemberId, - toMemberNickname = toMember.nickname.value + toMemberNickname = toMember.nickname.value, + toMemberProfileImageId = toMember.profileImageId, ) - // 요청자와 대상자가 모두 활성 회원인지 확인 - validateMembersExist(command) // 기존 친구 관계나 요청이 있는지 확인 val existingFriendship = friendshipReader.findByMembersId(command.fromMemberId, command.toMemberId) if (existingFriendship != null) { - existingFriendship.status = FriendshipStatus.PENDING - publishEvent(existingFriendship, command) + existingFriendship.rePending() + // 도메인 이벤트 발행 + domainEventPublisher.publishFrom(existingFriendship) return existingFriendship } // 친구 요청 생성 val friendship = Friendship.request(command) - friendshipRepository.save(friendship) + val savedFriendship = friendshipRepository.save(friendship) - // 이벤트 발행 (알림 등을 위해) - publishEvent(friendship, command) + // 도메인 이벤트 발행 + domainEventPublisher.publishFrom(savedFriendship) - return friendship + return savedFriendship } catch (e: IllegalArgumentException) { throw FriendRequestException(ERROR_FRIEND_REQUEST_FAILED, e) } } - - private fun validateMembersExist(command: FriendRequestCommand) { - memberReader.readActiveMemberById(command.fromMemberId) - memberReader.readActiveMemberById(command.toMemberId) - } - - private fun publishEvent( - friendship: Friendship, - command: FriendRequestCommand, - ) { - eventPublisher.publishEvent( - FriendRequestSentEvent( - friendshipId = friendship.requireId(), - fromMemberId = command.fromMemberId, - toMemberId = command.toMemberId - ) - ) - } } 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 b68a307f..bf222391 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 @@ -1,5 +1,6 @@ package com.albert.realmoneyrealtaste.application.friend.service +import com.albert.realmoneyrealtaste.application.common.provided.DomainEventPublisher import com.albert.realmoneyrealtaste.application.friend.dto.FriendResponseRequest import com.albert.realmoneyrealtaste.application.friend.exception.FriendResponseException import com.albert.realmoneyrealtaste.application.friend.provided.FriendRequestor @@ -7,11 +8,8 @@ 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.event.FriendRequestAcceptedEvent -import com.albert.realmoneyrealtaste.domain.friend.event.FriendRequestRejectedEvent import com.albert.realmoneyrealtaste.domain.member.Member import jakarta.transaction.Transactional -import org.springframework.context.ApplicationEventPublisher import org.springframework.stereotype.Service @Service @@ -19,7 +17,7 @@ import org.springframework.stereotype.Service class FriendResponseService( private val memberReader: MemberReader, private val friendshipReader: FriendshipReader, - private val eventPublisher: ApplicationEventPublisher, + private val domainEventPublisher: DomainEventPublisher, private val friendRequestor: FriendRequestor, ) : FriendResponder { @@ -50,8 +48,8 @@ class FriendResponseService( friendship.reject() } - // 이벤트 발행 - publishEvent(friendship, request.accept) + // 도메인 이벤트 발행 + domainEventPublisher.publishFrom(friendship) return friendship } catch (e: IllegalArgumentException) { @@ -59,29 +57,12 @@ class FriendResponseService( } } - private fun publishEvent( - friendship: Friendship, - accept: Boolean, - ) { - val event = if (accept) { - FriendRequestAcceptedEvent( - friendshipId = friendship.requireId(), - fromMemberId = friendship.relationShip.memberId, - toMemberId = friendship.relationShip.friendMemberId - ) - } else { - FriendRequestRejectedEvent( - friendshipId = friendship.requireId(), - fromMemberId = friendship.relationShip.memberId, - toMemberId = friendship.relationShip.friendMemberId - ) - } - eventPublisher.publishEvent(event) - } - private fun createReverseFriendship(fromMemberId: Long, toMember: Member) { val reverseFriendship = friendRequestor.sendFriendRequest(fromMemberId = fromMemberId, toMemberId = toMember.requireId()) reverseFriendship.accept() // 즉시 수락 상태로 설정 + + // 역방향 친구 관계의 도메인 이벤트도 발행 + domainEventPublisher.publishFrom(reverseFriendship) } } diff --git a/src/main/kotlin/com/albert/realmoneyrealtaste/application/friend/service/FriendshipTerminationService.kt b/src/main/kotlin/com/albert/realmoneyrealtaste/application/friend/service/FriendshipTerminationService.kt index 6c228c12..5aeaca6f 100644 --- a/src/main/kotlin/com/albert/realmoneyrealtaste/application/friend/service/FriendshipTerminationService.kt +++ b/src/main/kotlin/com/albert/realmoneyrealtaste/application/friend/service/FriendshipTerminationService.kt @@ -1,20 +1,20 @@ package com.albert.realmoneyrealtaste.application.friend.service +import com.albert.realmoneyrealtaste.application.common.provided.DomainEventPublisher import com.albert.realmoneyrealtaste.application.friend.dto.UnfriendRequest import com.albert.realmoneyrealtaste.application.friend.exception.UnfriendException 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.event.FriendshipTerminatedEvent +import com.albert.realmoneyrealtaste.domain.friend.Friendship import jakarta.transaction.Transactional -import org.springframework.context.ApplicationEventPublisher import org.springframework.stereotype.Service @Service @Transactional class FriendshipTerminationService( private val memberReader: MemberReader, - private val eventPublisher: ApplicationEventPublisher, + private val domainEventPublisher: DomainEventPublisher, private val friendshipReader: FriendshipReader, ) : FriendshipTerminator { @@ -27,27 +27,27 @@ class FriendshipTerminationService( // 요청자가 활성 회원인지 확인 memberReader.readActiveMemberById(request.memberId) - // 친구 관계 조회 + // 양방향 친구 관계 모두 해제 + val friendshipsToTerminate = mutableListOf() + friendshipReader.findActiveFriendship(request.memberId, request.friendMemberId) - ?.unfriend() + ?.let { friendship -> + friendship.unfriend() + friendshipsToTerminate.add(friendship) + } - // 양방향 친구 관계 모두 해제 friendshipReader.findActiveFriendship(request.friendMemberId, request.memberId) - ?.unfriend() - - // 이벤트 발행 - publishEvent(request) + ?.let { friendship -> + friendship.unfriend() + friendshipsToTerminate.add(friendship) + } + + // 도메인 이벤트 발행 + friendshipsToTerminate.forEach { friendship -> + domainEventPublisher.publishFrom(friendship) + } } catch (e: IllegalArgumentException) { throw UnfriendException(ERROR_UNFRIEND_FAILED, e) } } - - private fun publishEvent(request: UnfriendRequest) { - eventPublisher.publishEvent( - FriendshipTerminatedEvent( - memberId = request.memberId, - friendMemberId = request.friendMemberId - ) - ) - } } diff --git a/src/main/kotlin/com/albert/realmoneyrealtaste/application/image/provided/ImageUploadRequester.kt b/src/main/kotlin/com/albert/realmoneyrealtaste/application/image/provided/ImageUploadRequester.kt index 9e4039f8..08859f72 100644 --- a/src/main/kotlin/com/albert/realmoneyrealtaste/application/image/provided/ImageUploadRequester.kt +++ b/src/main/kotlin/com/albert/realmoneyrealtaste/application/image/provided/ImageUploadRequester.kt @@ -4,5 +4,5 @@ import com.albert.realmoneyrealtaste.application.image.dto.ImageUploadRequest import com.albert.realmoneyrealtaste.application.image.dto.PresignedPutResponse fun interface ImageUploadRequester { - fun generatePresignedPostUrl(request: ImageUploadRequest, userId: Long): PresignedPutResponse + fun generatePresignedUploadUrl(request: ImageUploadRequest, userId: Long): PresignedPutResponse } diff --git a/src/main/kotlin/com/albert/realmoneyrealtaste/application/image/service/ImageUploadService.kt b/src/main/kotlin/com/albert/realmoneyrealtaste/application/image/service/ImageUploadService.kt index 45770ad9..e3399a5b 100644 --- a/src/main/kotlin/com/albert/realmoneyrealtaste/application/image/service/ImageUploadService.kt +++ b/src/main/kotlin/com/albert/realmoneyrealtaste/application/image/service/ImageUploadService.kt @@ -32,7 +32,7 @@ class ImageUploadService( private val logger = LoggerFactory.getLogger(ImageUploadService::class.java) - override fun generatePresignedPostUrl(request: ImageUploadRequest, userId: Long): PresignedPutResponse { + override fun generatePresignedUploadUrl(request: ImageUploadRequest, userId: Long): PresignedPutResponse { try { // 1. 사용자 검증 val todayUploadCount = imageReader.getTodayUploadCount(userId) diff --git a/src/main/kotlin/com/albert/realmoneyrealtaste/application/image/service/ImageUploadValidateService.kt b/src/main/kotlin/com/albert/realmoneyrealtaste/application/image/service/ImageUploadValidateService.kt index cc2d32e8..89c88b18 100644 --- a/src/main/kotlin/com/albert/realmoneyrealtaste/application/image/service/ImageUploadValidateService.kt +++ b/src/main/kotlin/com/albert/realmoneyrealtaste/application/image/service/ImageUploadValidateService.kt @@ -96,10 +96,5 @@ class ImageUploadValidateService( require(extension in ALLOWED_EXTENSIONS) { "허용되지 않는 파일 확장자: $extension" } - - // 파일명 형식 검증 (영문, 숫자, 언더스코어, 하이픈, 점) - require(fileName.matches(Regex("^[a-zA-Z0-9._-]+\\.[a-zA-Z0-9]+$"))) { - "파일명은 영문, 숫자, 언더스코어(_), 하이픈(-), 점(.)만 사용할 수 있습니다" - } } } diff --git a/src/main/kotlin/com/albert/realmoneyrealtaste/application/member/event/EmailSendRequestedEvent.kt b/src/main/kotlin/com/albert/realmoneyrealtaste/application/member/event/EmailSendRequestedEvent.kt new file mode 100644 index 00000000..e7566cfb --- /dev/null +++ b/src/main/kotlin/com/albert/realmoneyrealtaste/application/member/event/EmailSendRequestedEvent.kt @@ -0,0 +1,23 @@ +package com.albert.realmoneyrealtaste.application.member.event + +import com.albert.realmoneyrealtaste.domain.member.ActivationToken +import com.albert.realmoneyrealtaste.domain.member.PasswordResetToken +import com.albert.realmoneyrealtaste.domain.member.value.Email +import com.albert.realmoneyrealtaste.domain.member.value.Nickname + +/** + * 이메일 발송 요청 애플리케이션 이벤트 + */ +sealed class EmailSendRequestedEvent { + data class ActivationEmail( + val email: Email, + val nickname: Nickname, + val activationToken: ActivationToken, + ) : EmailSendRequestedEvent() + + data class PasswordResetEmail( + val email: Email, + val nickname: Nickname, + val passwordResetToken: PasswordResetToken, + ) : EmailSendRequestedEvent() +} diff --git a/src/main/kotlin/com/albert/realmoneyrealtaste/application/member/event/MemberRegisteredEvent.kt b/src/main/kotlin/com/albert/realmoneyrealtaste/application/member/event/MemberRegisteredEvent.kt deleted file mode 100644 index 6362ff2d..00000000 --- a/src/main/kotlin/com/albert/realmoneyrealtaste/application/member/event/MemberRegisteredEvent.kt +++ /dev/null @@ -1,11 +0,0 @@ -package com.albert.realmoneyrealtaste.application.member.event - -import com.albert.realmoneyrealtaste.domain.member.ActivationToken -import com.albert.realmoneyrealtaste.domain.member.value.Email -import com.albert.realmoneyrealtaste.domain.member.value.Nickname - -data class MemberRegisteredEvent( - val email: Email, - val nickname: Nickname, - val activationToken: ActivationToken, -) diff --git a/src/main/kotlin/com/albert/realmoneyrealtaste/application/member/event/PasswordResetRequestedEvent.kt b/src/main/kotlin/com/albert/realmoneyrealtaste/application/member/event/PasswordResetRequestedEvent.kt deleted file mode 100644 index 82a459ae..00000000 --- a/src/main/kotlin/com/albert/realmoneyrealtaste/application/member/event/PasswordResetRequestedEvent.kt +++ /dev/null @@ -1,18 +0,0 @@ -package com.albert.realmoneyrealtaste.application.member.event - -import com.albert.realmoneyrealtaste.domain.member.PasswordResetToken -import com.albert.realmoneyrealtaste.domain.member.value.Email -import com.albert.realmoneyrealtaste.domain.member.value.Nickname - -/** - * 비밀번호 재설정이 요청된 이벤트 - * - * @property email 회원 이메일 - * @property nickname 회원 닉네임 - * @property token 비밀번호 재설정 토큰 - */ -data class PasswordResetRequestedEvent( - val email: Email, - val nickname: Nickname, - val token: PasswordResetToken, -) diff --git a/src/main/kotlin/com/albert/realmoneyrealtaste/application/member/event/ResendActivationEmailEvent.kt b/src/main/kotlin/com/albert/realmoneyrealtaste/application/member/event/ResendActivationEmailEvent.kt deleted file mode 100644 index 8faf8870..00000000 --- a/src/main/kotlin/com/albert/realmoneyrealtaste/application/member/event/ResendActivationEmailEvent.kt +++ /dev/null @@ -1,11 +0,0 @@ -package com.albert.realmoneyrealtaste.application.member.event - -import com.albert.realmoneyrealtaste.domain.member.ActivationToken -import com.albert.realmoneyrealtaste.domain.member.value.Email -import com.albert.realmoneyrealtaste.domain.member.value.Nickname - -data class ResendActivationEmailEvent( - val email: Email, - val nickname: Nickname, - val activationToken: ActivationToken, -) diff --git a/src/main/kotlin/com/albert/realmoneyrealtaste/application/member/listener/EmailEventListener.kt b/src/main/kotlin/com/albert/realmoneyrealtaste/application/member/listener/EmailEventListener.kt new file mode 100644 index 00000000..e7f584d5 --- /dev/null +++ b/src/main/kotlin/com/albert/realmoneyrealtaste/application/member/listener/EmailEventListener.kt @@ -0,0 +1,48 @@ +package com.albert.realmoneyrealtaste.application.member.listener + +import com.albert.realmoneyrealtaste.application.member.event.EmailSendRequestedEvent +import com.albert.realmoneyrealtaste.application.member.provided.MemberActivationEmailSender +import com.albert.realmoneyrealtaste.application.member.provided.MemberPasswordResetEmailSender +import org.springframework.context.event.EventListener +import org.springframework.scheduling.annotation.Async +import org.springframework.stereotype.Component + +/** + * 이메일 발송 요청 이벤트를 처리하는 리스너 + */ +@Component +class EmailEventListener( + private val memberActivationEmailSender: MemberActivationEmailSender, + private val passwordResetEmailSender: MemberPasswordResetEmailSender, +) { + + /** + * 활성화 이메일 발송 요청 이벤트 처리 + * + * @param event 활성화 이메일 발송 요청 이벤트 + */ + @Async + @EventListener + fun handleActivationEmailRequest(event: EmailSendRequestedEvent.ActivationEmail) { + memberActivationEmailSender.sendActivationEmail( + email = event.email, + nickname = event.nickname, + activationToken = event.activationToken, + ) + } + + /** + * 비밀번호 재설정 이메일 발송 요청 이벤트 처리 + * + * @param event 비밀번호 재설정 이메일 발송 요청 이벤트 + */ + @Async + @EventListener + fun handlePasswordResetEmailRequest(event: EmailSendRequestedEvent.PasswordResetEmail) { + passwordResetEmailSender.sendResetEmail( + email = event.email, + nickname = event.nickname, + passwordResetToken = event.passwordResetToken, + ) + } +} diff --git a/src/main/kotlin/com/albert/realmoneyrealtaste/application/member/listener/MemberDomainEventListener.kt b/src/main/kotlin/com/albert/realmoneyrealtaste/application/member/listener/MemberDomainEventListener.kt new file mode 100644 index 00000000..38ba06f0 --- /dev/null +++ b/src/main/kotlin/com/albert/realmoneyrealtaste/application/member/listener/MemberDomainEventListener.kt @@ -0,0 +1,120 @@ +package com.albert.realmoneyrealtaste.application.member.listener + +import com.albert.realmoneyrealtaste.application.friend.required.FriendshipRepository +import com.albert.realmoneyrealtaste.application.member.event.EmailSendRequestedEvent +import com.albert.realmoneyrealtaste.application.member.provided.ActivationTokenGenerator +import com.albert.realmoneyrealtaste.application.member.required.MemberRepository +import com.albert.realmoneyrealtaste.domain.friend.FriendshipStatus +import com.albert.realmoneyrealtaste.domain.friend.event.FriendRequestAcceptedEvent +import com.albert.realmoneyrealtaste.domain.friend.event.FriendshipTerminatedEvent +import com.albert.realmoneyrealtaste.domain.member.event.MemberRegisteredDomainEvent +import com.albert.realmoneyrealtaste.domain.member.value.Email +import com.albert.realmoneyrealtaste.domain.member.value.Nickname +import com.albert.realmoneyrealtaste.domain.post.event.PostCreatedEvent +import com.albert.realmoneyrealtaste.domain.post.event.PostDeletedEvent +import org.springframework.context.ApplicationEventPublisher +import org.springframework.scheduling.annotation.Async +import org.springframework.stereotype.Component +import org.springframework.transaction.annotation.Propagation +import org.springframework.transaction.annotation.Transactional +import org.springframework.transaction.event.TransactionPhase +import org.springframework.transaction.event.TransactionalEventListener + +/** + * 도메인 이벤트를 처리하여 애플리케이션 이벤트를 발행하는 리스너 + */ +@Component +class MemberDomainEventListener( + private val activationTokenGenerator: ActivationTokenGenerator, + private val eventPublisher: ApplicationEventPublisher, + private val memberRepository: MemberRepository, + private val friendshipRepository: FriendshipRepository, +) { + + /** + * 포스트 생성 도메인 이벤트 처리 (크로스 도메인) + * - 작성자의 게시글 수 증가 + */ + @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) + @Async + @Transactional(propagation = Propagation.REQUIRES_NEW) + fun handlePostCreated(event: PostCreatedEvent) { + memberRepository.incrementPostCount(event.authorMemberId) + } + + /** + * 포스트 삭제 도메인 이벤트 처리 (크로스 도메인) + * - 작성자의 게시글 수 감소 + */ + @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) + @Async + @Transactional(propagation = Propagation.REQUIRES_NEW) + fun handlePostDeleted(event: PostDeletedEvent) { + memberRepository.decrementPostCount(event.authorMemberId) + } + + /** + * 친구 요청 수락 도메인 이벤트 처리 (크로스 도메인) + * - 양방향 회원의 팔로워/팔로잉 수 업데이트 + */ + @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) + @Async + @Transactional(propagation = Propagation.REQUIRES_NEW) + fun handleFriendRequestAccepted(event: FriendRequestAcceptedEvent) { + // 요청자의 팔로잉 수 업데이트 + val fromMemberCount = friendshipRepository.countFriends( + event.fromMemberId, + FriendshipStatus.ACCEPTED + ) + memberRepository.updateFollowingsCount(event.fromMemberId, fromMemberCount) + + // 수신자의 팔로워 수 업데이트 + val toMemberCount = friendshipRepository.countFriends( + event.toMemberId, + FriendshipStatus.ACCEPTED + ) + memberRepository.updateFollowersCount(event.toMemberId, toMemberCount) + } + + /** + * 친구 관계 해제 도메인 이벤트 처리 (크로스 도메인) + * - 양방향 회원의 팔로워/팔로잉 수 감소 + */ + @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) + @Async + @Transactional(propagation = Propagation.REQUIRES_NEW) + fun handleFriendshipTerminated(event: FriendshipTerminatedEvent) { + // 회원의 팔로잉 수 업데이트 + val memberCount = friendshipRepository.countFriends( + event.memberId, + FriendshipStatus.ACCEPTED + ) + memberRepository.updateFollowingsCount(event.memberId, memberCount) + + // 친구 회원의 팔로워 수 업데이트 + val friendCount = friendshipRepository.countFriends( + event.friendMemberId, + FriendshipStatus.ACCEPTED + ) + memberRepository.updateFollowersCount(event.friendMemberId, friendCount) + } + + /** + * 회원 등록 도메인 이벤트 처리 + * - 활성화 이메일 발송 요청 이벤트 발행 + */ + @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) + @Async + @Transactional(propagation = Propagation.REQUIRES_NEW) + fun handleMemberRegistered(event: MemberRegisteredDomainEvent) { + val activationToken = activationTokenGenerator.generate(event.memberId) + + eventPublisher.publishEvent( + EmailSendRequestedEvent.ActivationEmail( + email = Email(event.email), + nickname = Nickname(event.nickname), + activationToken = activationToken + ) + ) + } +} diff --git a/src/main/kotlin/com/albert/realmoneyrealtaste/application/member/listener/MemberEventListener.kt b/src/main/kotlin/com/albert/realmoneyrealtaste/application/member/listener/MemberEventListener.kt deleted file mode 100644 index 2a9b8e7d..00000000 --- a/src/main/kotlin/com/albert/realmoneyrealtaste/application/member/listener/MemberEventListener.kt +++ /dev/null @@ -1,64 +0,0 @@ -package com.albert.realmoneyrealtaste.application.member.listener - -import com.albert.realmoneyrealtaste.application.member.event.MemberRegisteredEvent -import com.albert.realmoneyrealtaste.application.member.event.PasswordResetRequestedEvent -import com.albert.realmoneyrealtaste.application.member.event.ResendActivationEmailEvent -import com.albert.realmoneyrealtaste.application.member.provided.MemberActivationEmailSender -import com.albert.realmoneyrealtaste.application.member.provided.MemberPasswordResetEmailSender -import org.springframework.context.event.EventListener -import org.springframework.scheduling.annotation.Async -import org.springframework.stereotype.Component - -@Component -class MemberEventListener( - private val memberActivationEmailSender: MemberActivationEmailSender, - private val passwordResetEmailSender: MemberPasswordResetEmailSender, -) { - /** - * 회원 가입 이벤트 처리 - * - 회원 가입 시 인증 이메일 발송 - * - * @param event 회원 가입 이벤트 - */ - @Async - @EventListener - fun handleMemberRegistered(event: MemberRegisteredEvent) { - memberActivationEmailSender.sendActivationEmail( - email = event.email, - nickname = event.nickname, - activationToken = event.activationToken, - ) - } - - /** - * 인증 이메일 재전송 이벤트 처리 - * - 인증 이메일 재전송 시 인증 이메일 발송 - * - * @param event 인증 이메일 재전송 이벤트 - */ - @Async - @EventListener - fun handleResendActivationEmail(event: ResendActivationEmailEvent) { - memberActivationEmailSender.sendActivationEmail( - email = event.email, - nickname = event.nickname, - activationToken = event.activationToken, - ) - } - - /** - * 비밀번호 재설정 요청 이벤트 처리 - * - 비밀번호 재설정 요청 시 비밀번호 재설정 이메일 발송 - * - * @param passwordResetToken 비밀번호 재설정 요청 이벤트 - */ - @Async - @EventListener - fun handlePasswordResetRequested(passwordResetToken: PasswordResetRequestedEvent) { - passwordResetEmailSender.sendResetEmail( - email = passwordResetToken.email, - nickname = passwordResetToken.nickname, - passwordResetToken = passwordResetToken.token, - ) - } -} diff --git a/src/main/kotlin/com/albert/realmoneyrealtaste/application/member/required/MemberRepository.kt b/src/main/kotlin/com/albert/realmoneyrealtaste/application/member/required/MemberRepository.kt index cd3d15fc..3592f003 100644 --- a/src/main/kotlin/com/albert/realmoneyrealtaste/application/member/required/MemberRepository.kt +++ b/src/main/kotlin/com/albert/realmoneyrealtaste/application/member/required/MemberRepository.kt @@ -4,6 +4,7 @@ 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.domain.member.value.ProfileAddress +import org.springframework.data.jpa.repository.Modifying import org.springframework.data.jpa.repository.Query import org.springframework.data.repository.Repository @@ -89,4 +90,46 @@ interface MemberRepository : Repository { """ ) fun findSuggestedMembers(memberId: Long, status: MemberStatus, limit: Long): List + + /** + * 회원의 게시글 수를 증가시킵니다. + * 동시성 문제를 방지하기 위해 DB 레벨에서 직접 업데이트합니다. + * + * @param memberId 회원 ID + */ + @Modifying + @Query("UPDATE Member m SET m.postCount = m.postCount + 1 WHERE m.id = :memberId") + fun incrementPostCount(memberId: Long) + + /** + * 회원의 게시글 수를 감소시킵니다. + * 동시성 문제를 방지하기 위해 DB 레벨에서 직접 업데이트합니다. + * + * @param memberId 회원 ID + */ + @Modifying + @Query("UPDATE Member m SET m.postCount = m.postCount - 1 WHERE m.id = :memberId AND m.postCount > 0") + fun decrementPostCount(memberId: Long) + + /** + * 회원의 팔로워 수를 업데이트합니다. + * 동시성 문제를 방지하기 위해 DB 레벨에서 직접 업데이트합니다. + * + * @param memberId 회원 ID + * @param count 새 팔로워 수 + */ + @Modifying + @Query("UPDATE Member m SET m.followersCount = :count WHERE m.id = :memberId") + fun updateFollowersCount(memberId: Long, count: Long) + + /** + * 회원의 팔로잉 수를 업데이트합니다. + * 동시성 문제를 방지하기 위해 DB 레벨에서 직접 업데이트합니다. + * + * @param memberId 회원 ID + * @param count 새 팔로잉 수 + */ + @Modifying + @Query("UPDATE Member m SET m.followingsCount = :count WHERE m.id = :memberId") + fun updateFollowingsCount(memberId: Long, count: Long) } 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 257ea59f..687479ce 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 @@ -1,6 +1,7 @@ package com.albert.realmoneyrealtaste.application.member.service -import com.albert.realmoneyrealtaste.application.member.event.ResendActivationEmailEvent +import com.albert.realmoneyrealtaste.application.common.provided.DomainEventPublisher +import com.albert.realmoneyrealtaste.application.member.event.EmailSendRequestedEvent.ActivationEmail import com.albert.realmoneyrealtaste.application.member.exception.MemberActivateException import com.albert.realmoneyrealtaste.application.member.exception.MemberResendActivationEmailException import com.albert.realmoneyrealtaste.application.member.provided.ActivationTokenDeleter @@ -21,6 +22,7 @@ class MemberActivationService( private val activationTokenReader: ActivationTokenReader, private val activationTokenDeleter: ActivationTokenDeleter, private val memberReader: MemberReader, + private val domainEventPublisher: DomainEventPublisher, private val eventPublisher: ApplicationEventPublisher, private val activationTokenGenerator: ActivationTokenGenerator, ) : MemberActivate { @@ -44,6 +46,9 @@ class MemberActivationService( activationTokenDeleter.delete(activationToken) + // 도메인 이벤트 발행 + domainEventPublisher.publishFrom(member) + return member } catch (e: IllegalArgumentException) { throw MemberActivateException(ERROR_MEMBER_ACTIVATE_FAILED) @@ -58,24 +63,25 @@ class MemberActivationService( val newToken = activationTokenGenerator.generate(member.requireId()) - publishResendActivationEmailEvent(member, newToken) + // 애플리케이션 이벤트 직접 발행 (도메인 이벤트 없이) + publishActivationEmailEvent(member, newToken) } catch (e: IllegalArgumentException) { throw MemberResendActivationEmailException(ERROR_ACTIVATION_TOKEN_RESEND_EMAIL_FAILED) } } /** - * 인증 이메일 재전송 이벤트 발행 + * 활성화 이메일 발송 이벤트 발행 * * @param member 회원 * @param newToken 새로 생성된 활성화 토큰 */ - private fun publishResendActivationEmailEvent( + private fun publishActivationEmailEvent( member: Member, newToken: ActivationToken, ) { eventPublisher.publishEvent( - ResendActivationEmailEvent( + ActivationEmail( email = member.email, nickname = member.nickname, activationToken = newToken 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 fe319f86..77d6d627 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 @@ -1,17 +1,15 @@ package com.albert.realmoneyrealtaste.application.member.service +import com.albert.realmoneyrealtaste.application.common.provided.DomainEventPublisher import com.albert.realmoneyrealtaste.application.member.dto.MemberRegisterRequest -import com.albert.realmoneyrealtaste.application.member.event.MemberRegisteredEvent import com.albert.realmoneyrealtaste.application.member.exception.DuplicateEmailException import com.albert.realmoneyrealtaste.application.member.exception.MemberRegisterException -import com.albert.realmoneyrealtaste.application.member.provided.ActivationTokenGenerator import com.albert.realmoneyrealtaste.application.member.provided.MemberReader import com.albert.realmoneyrealtaste.application.member.provided.MemberRegister import com.albert.realmoneyrealtaste.application.member.required.MemberRepository import com.albert.realmoneyrealtaste.domain.member.Member import com.albert.realmoneyrealtaste.domain.member.service.PasswordEncoder import com.albert.realmoneyrealtaste.domain.member.value.PasswordHash -import org.springframework.context.ApplicationEventPublisher import org.springframework.stereotype.Service import org.springframework.transaction.annotation.Transactional @@ -20,8 +18,7 @@ import org.springframework.transaction.annotation.Transactional class MemberRegistrationService( private val passwordEncoder: PasswordEncoder, private val memberRepository: MemberRepository, - private val eventPublisher: ApplicationEventPublisher, - private val activationTokenGenerator: ActivationTokenGenerator, + private val domainEventPublisher: DomainEventPublisher, private val memberReader: MemberReader, ) : MemberRegister { @@ -40,7 +37,8 @@ class MemberRegistrationService( val savedMember = memberRepository.save(member) - publishMemberRegisteredEvent(savedMember) + // 도메인 이벤트 발행 + domainEventPublisher.publishFrom(savedMember) return savedMember } catch (e: IllegalArgumentException) { @@ -61,20 +59,4 @@ class MemberRegistrationService( } } } - - /** - * 회원 등록 이벤트를 발행합니다. - * - * @param member 등록된 회원 - */ - private fun publishMemberRegisteredEvent(member: Member) { - val activationToken = activationTokenGenerator.generate(member.requireId()) - eventPublisher.publishEvent( - MemberRegisteredEvent( - email = member.email, - nickname = member.nickname, - activationToken = activationToken, - ) - ) - } } 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 c4900bca..2269aa9d 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 @@ -1,5 +1,6 @@ package com.albert.realmoneyrealtaste.application.member.service +import com.albert.realmoneyrealtaste.application.common.provided.DomainEventPublisher import com.albert.realmoneyrealtaste.application.member.dto.AccountUpdateRequest import com.albert.realmoneyrealtaste.application.member.exception.DuplicateProfileAddressException import com.albert.realmoneyrealtaste.application.member.exception.MemberDeactivateException @@ -19,6 +20,7 @@ import org.springframework.stereotype.Service class MemberUpdateService( private val memberReader: MemberReader, private val passwordEncoder: PasswordEncoder, + private val domainEventPublisher: DomainEventPublisher, ) : MemberUpdater { companion object { @@ -49,6 +51,9 @@ class MemberUpdateService( imageId = request.imageId, ) + // 도메인 이벤트 발행 + domainEventPublisher.publishFrom(member) + return member } catch (e: IllegalArgumentException) { throw MemberUpdateException(ERROR_MEMBER_INFO_UPDATE) @@ -65,6 +70,9 @@ class MemberUpdateService( member.changePassword(currentPassword, newPassword, passwordEncoder) + // 도메인 이벤트 발행 + domainEventPublisher.publishFrom(member) + return member } catch (e: IllegalArgumentException) { throw PasswordChangeException(ERROR_PASSWORD_UPDATE) @@ -77,6 +85,9 @@ class MemberUpdateService( member.deactivate() + // 도메인 이벤트 발행 + domainEventPublisher.publishFrom(member) + return member } catch (e: IllegalArgumentException) { throw MemberDeactivateException(ERROR_MEMBER_DEACTIVATE) 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 487dce67..fd7767cf 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 @@ -1,6 +1,7 @@ package com.albert.realmoneyrealtaste.application.member.service -import com.albert.realmoneyrealtaste.application.member.event.PasswordResetRequestedEvent +import com.albert.realmoneyrealtaste.application.common.provided.DomainEventPublisher +import com.albert.realmoneyrealtaste.application.member.event.EmailSendRequestedEvent.PasswordResetEmail import com.albert.realmoneyrealtaste.application.member.exception.ExpiredPasswordResetTokenException import com.albert.realmoneyrealtaste.application.member.exception.PassWordResetException import com.albert.realmoneyrealtaste.application.member.exception.SendPasswordResetEmailException @@ -29,6 +30,7 @@ class PasswordResetService( private val passwordRestTokenDeleter: PasswordResetTokenDeleter, private val passwordEncoder: PasswordEncoder, private val entityManager: EntityManager, + private val domainEventPublisher: DomainEventPublisher, private val eventPublisher: ApplicationEventPublisher, ) : PasswordResetter { @@ -46,7 +48,8 @@ class PasswordResetService( val token = passwordRestTokenGenerator.generate(member.requireId()) - publishPasswordResetRequestedEvent(member, token) + // 애플리케이션 이벤트 직접 발행 (도메인 이벤트 없이) + publishPasswordResetEmailEvent(member, token) } catch (e: IllegalArgumentException) { throw SendPasswordResetEmailException(ERROR_SENDING_PASSWORD_RESET_EMAIL) } @@ -61,6 +64,9 @@ class PasswordResetService( member.changePassword(PasswordHash.of(newPassword, passwordEncoder)) + // 도메인 이벤트 발행 + domainEventPublisher.publishFrom(member) + deleteToken(resetToken) } catch (e: IllegalArgumentException) { throw PassWordResetException(ERROR_RESETTING_PASSWORD) @@ -80,17 +86,17 @@ class PasswordResetService( } /** - * 비밀번호 재설정 요청 이벤트를 발행합니다. + * 비밀번호 재설정 이메일 발송 이벤트를 발행합니다. * * @param member 회원 * @param token 비밀번호 재설정 토큰 */ - private fun publishPasswordResetRequestedEvent(member: Member, token: PasswordResetToken) { + private fun publishPasswordResetEmailEvent(member: Member, token: PasswordResetToken) { eventPublisher.publishEvent( - PasswordResetRequestedEvent( + PasswordResetEmail( email = member.email, nickname = member.nickname, - token = token, + passwordResetToken = token ) ) } diff --git a/src/main/kotlin/com/albert/realmoneyrealtaste/application/post/listener/PostDomainEventListener.kt b/src/main/kotlin/com/albert/realmoneyrealtaste/application/post/listener/PostDomainEventListener.kt new file mode 100644 index 00000000..b6e713e3 --- /dev/null +++ b/src/main/kotlin/com/albert/realmoneyrealtaste/application/post/listener/PostDomainEventListener.kt @@ -0,0 +1,62 @@ +package com.albert.realmoneyrealtaste.application.post.listener + +import com.albert.realmoneyrealtaste.application.post.required.PostRepository +import com.albert.realmoneyrealtaste.domain.comment.event.CommentCreatedEvent +import com.albert.realmoneyrealtaste.domain.comment.event.CommentDeletedEvent +import com.albert.realmoneyrealtaste.domain.member.event.MemberProfileUpdatedDomainEvent +import org.springframework.scheduling.annotation.Async +import org.springframework.stereotype.Component +import org.springframework.transaction.annotation.Propagation +import org.springframework.transaction.annotation.Transactional +import org.springframework.transaction.event.TransactionPhase +import org.springframework.transaction.event.TransactionalEventListener + +/** + * Post 도메인 이벤트를 처리하는 리스너 + */ +@Component +class PostDomainEventListener( + private val postRepository: PostRepository, +) { + + /** + * 회원 프로필 업데이트 도메인 이벤트 처리 (크로스 도메인) + * - Post의 작성자 정보 동기화 + */ + @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) + @Async + @Transactional(propagation = Propagation.REQUIRES_NEW) + fun handleMemberProfileUpdated(event: MemberProfileUpdatedDomainEvent) { + // Post의 작성자 정보 업데이트 + postRepository.updateAuthorInfo( + authorMemberId = event.memberId, + nickname = event.nickname, + introduction = event.introduction, + imageId = event.imageId, + ) + } + + /** + * 댓글 생성 도메인 이벤트 처리 (크로스 도메인) + * - 포스트의 댓글 수 증가 + */ + @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) + @Async + @Transactional(propagation = Propagation.REQUIRES_NEW) + fun handleCommentCreated(event: CommentCreatedEvent) { + // 포스트의 댓글 수 증가 + postRepository.incrementCommentCount(event.postId) + } + + /** + * 댓글 삭제 도메인 이벤트 처리 (크로스 도메인) + * - 포스트의 댓글 수 감소 + */ + @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) + @Async + @Transactional(propagation = Propagation.REQUIRES_NEW) + fun handleCommentDeleted(event: CommentDeletedEvent) { + // 포스트의 댓글 수 감소 + postRepository.decrementCommentCount(event.postId) + } +} diff --git a/src/main/kotlin/com/albert/realmoneyrealtaste/application/post/required/PostRepository.kt b/src/main/kotlin/com/albert/realmoneyrealtaste/application/post/required/PostRepository.kt index 2ee32907..f35307b2 100644 --- a/src/main/kotlin/com/albert/realmoneyrealtaste/application/post/required/PostRepository.kt +++ b/src/main/kotlin/com/albert/realmoneyrealtaste/application/post/required/PostRepository.kt @@ -147,4 +147,50 @@ interface PostRepository : Repository { * @return 조회된 게시글 목록 */ fun findAllByStatusAndIdIn(status: PostStatus, ids: List): List + + /** + * 댓글 수를 증가시킵니다. + * 동시성 문제를 방지하기 위해 DB 레벨에서 직접 업데이트합니다. + * + * @param postId 게시글 ID + */ + @Modifying + @Query("UPDATE Post p SET p.commentCount = p.commentCount + 1 WHERE p.id = :postId") + fun incrementCommentCount(postId: Long) + + /** + * 댓글 수를 감소시킵니다. + * 동시성 문제를 방지하기 위해 DB 레벨에서 직접 업데이트합니다. + * + * @param postId 게시글 ID + */ + @Modifying + @Query("UPDATE Post p SET p.commentCount = p.commentCount - 1 WHERE p.id = :postId AND p.commentCount > 0") + fun decrementCommentCount(postId: Long) + + /** + * 작성자 정보를 업데이트합니다. + * 동시성 문제를 방지하기 위해 DB 레벨에서 직접 업데이트합니다. + * + * @param authorMemberId 작성자 회원 ID + * @param nickname 새 닉네임 (null이면 업데이트하지 않음) + * @param introduction 새 자기소개 (null이면 업데이트하지 않음) + * @param imageId 새 이미지 ID (null이면 업데이트하지 않음) + */ + @Modifying + @Query( + """ + UPDATE Post p SET + p.author.nickname = COALESCE(:nickname, p.author.nickname), + p.author.introduction = COALESCE(:introduction, p.author.introduction), + p.author.imageId = COALESCE(:imageId, p.author.imageId) + WHERE p.author.memberId = :authorMemberId + """ + ) + fun updateAuthorInfo( + authorMemberId: Long, + nickname: String?, + introduction: String?, + imageId: Long?, + ) } diff --git a/src/main/kotlin/com/albert/realmoneyrealtaste/application/post/service/PostCreationService.kt b/src/main/kotlin/com/albert/realmoneyrealtaste/application/post/service/PostCreationService.kt index f4a41e3c..c122f276 100644 --- a/src/main/kotlin/com/albert/realmoneyrealtaste/application/post/service/PostCreationService.kt +++ b/src/main/kotlin/com/albert/realmoneyrealtaste/application/post/service/PostCreationService.kt @@ -1,13 +1,12 @@ package com.albert.realmoneyrealtaste.application.post.service +import com.albert.realmoneyrealtaste.application.common.provided.DomainEventPublisher import com.albert.realmoneyrealtaste.application.member.provided.MemberReader import com.albert.realmoneyrealtaste.application.post.dto.PostCreateRequest import com.albert.realmoneyrealtaste.application.post.exception.PostCreateException import com.albert.realmoneyrealtaste.application.post.provided.PostCreator import com.albert.realmoneyrealtaste.application.post.required.PostRepository import com.albert.realmoneyrealtaste.domain.post.Post -import com.albert.realmoneyrealtaste.domain.post.event.PostCreatedEvent -import org.springframework.context.ApplicationEventPublisher import org.springframework.stereotype.Service import org.springframework.transaction.annotation.Transactional @@ -16,7 +15,7 @@ import org.springframework.transaction.annotation.Transactional class PostCreationService( private val postRepository: PostRepository, private val memberReader: MemberReader, - private val eventPublisher: ApplicationEventPublisher, + private val domainEventPublisher: DomainEventPublisher, ) : PostCreator { companion object { @@ -33,25 +32,18 @@ class PostCreationService( request.restaurant, request.content, request.images, - member.detail.introduction?.value ?: "", + member.introduction, + member.profileImageId ) val savedPost = postRepository.save(post) - publishPostCreatedEvent(savedPost) + // 도메인 이벤트 발행 + domainEventPublisher.publishFrom(savedPost) return savedPost } catch (e: IllegalArgumentException) { throw PostCreateException(ERROR_POST_CREATE, e) } } - - /** - * 게시글 생성 이벤트를 발행합니다. - * - * @param post 생성된 게시글 - */ - private fun publishPostCreatedEvent(post: Post) { - eventPublisher.publishEvent(PostCreatedEvent(post.requireId(), post.author.memberId, post.restaurant.name)) - } } diff --git a/src/main/kotlin/com/albert/realmoneyrealtaste/application/post/service/PostUpdateService.kt b/src/main/kotlin/com/albert/realmoneyrealtaste/application/post/service/PostUpdateService.kt index a16f6bfc..877b58e7 100644 --- a/src/main/kotlin/com/albert/realmoneyrealtaste/application/post/service/PostUpdateService.kt +++ b/src/main/kotlin/com/albert/realmoneyrealtaste/application/post/service/PostUpdateService.kt @@ -1,5 +1,6 @@ package com.albert.realmoneyrealtaste.application.post.service +import com.albert.realmoneyrealtaste.application.common.provided.DomainEventPublisher import com.albert.realmoneyrealtaste.application.post.dto.PostUpdateRequest import com.albert.realmoneyrealtaste.application.post.exception.PostDeleteException import com.albert.realmoneyrealtaste.application.post.exception.PostUpdateException @@ -7,8 +8,6 @@ import com.albert.realmoneyrealtaste.application.post.provided.PostReader import com.albert.realmoneyrealtaste.application.post.provided.PostUpdater import com.albert.realmoneyrealtaste.application.post.required.PostRepository import com.albert.realmoneyrealtaste.domain.post.Post -import com.albert.realmoneyrealtaste.domain.post.event.PostDeletedEvent -import org.springframework.context.ApplicationEventPublisher import org.springframework.stereotype.Service import org.springframework.transaction.annotation.Transactional @@ -16,7 +15,7 @@ import org.springframework.transaction.annotation.Transactional @Service class PostUpdateService( private val postRepository: PostRepository, - private val eventPublisher: ApplicationEventPublisher, + private val domainEventPublisher: DomainEventPublisher, private val postReader: PostReader, ) : PostUpdater { @@ -43,7 +42,8 @@ class PostUpdateService( post.delete(memberId) - publishPostDeletedEvent(postId, memberId) + // 도메인 이벤트 발행 + domainEventPublisher.publishFrom(post) } catch (e: IllegalArgumentException) { throw PostDeleteException(ERROR_POST_DELETE.format(postId, memberId), e) } @@ -60,19 +60,4 @@ class PostUpdateService( override fun incrementViewCount(postId: Long) { postRepository.incrementViewCount(postId) } - - /** - * 게시글 삭제 이벤트를 발행합니다. - * - * @param postId 게시글 ID - * @param memberId 회원 ID - */ - private fun publishPostDeletedEvent(postId: Long, memberId: Long) { - eventPublisher.publishEvent( - PostDeletedEvent( - postId = postId, - authorMemberId = memberId - ) - ) - } } diff --git a/src/main/kotlin/com/albert/realmoneyrealtaste/domain/comment/Comment.kt b/src/main/kotlin/com/albert/realmoneyrealtaste/domain/comment/Comment.kt index 1f279d42..45886c40 100644 --- a/src/main/kotlin/com/albert/realmoneyrealtaste/domain/comment/Comment.kt +++ b/src/main/kotlin/com/albert/realmoneyrealtaste/domain/comment/Comment.kt @@ -1,7 +1,12 @@ package com.albert.realmoneyrealtaste.domain.comment +import com.albert.realmoneyrealtaste.domain.comment.event.CommentCreatedEvent +import com.albert.realmoneyrealtaste.domain.comment.event.CommentDeletedEvent +import com.albert.realmoneyrealtaste.domain.comment.event.CommentDomainEvent +import com.albert.realmoneyrealtaste.domain.comment.event.CommentUpdatedEvent import com.albert.realmoneyrealtaste.domain.comment.value.CommentAuthor import com.albert.realmoneyrealtaste.domain.comment.value.CommentContent +import com.albert.realmoneyrealtaste.domain.common.AggregateRoot import com.albert.realmoneyrealtaste.domain.common.BaseEntity import jakarta.persistence.Column import jakarta.persistence.Embedded @@ -10,6 +15,7 @@ import jakarta.persistence.EnumType import jakarta.persistence.Enumerated import jakarta.persistence.Index import jakarta.persistence.Table +import jakarta.persistence.Transient import java.time.LocalDateTime @Entity @@ -32,7 +38,7 @@ class Comment protected constructor( repliesCount: Long, createdAt: LocalDateTime, updatedAt: LocalDateTime, -) : BaseEntity() { +) : BaseEntity(), AggregateRoot { companion object { const val ERROR_POST_ID_MUST_BE_POSITIVE = "게시글 ID는 양수여야 합니다: %d" @@ -48,6 +54,7 @@ class Comment protected constructor( * @param authorNickname 작성자 닉네임 * @param content 댓글 내용 * @param parentCommentId 부모 댓글 ID (대댓글인 경우) + * @param parentCommentAuthorId 부모 댓글 작성자 ID (대댓글인 경우) * @return 생성된 댓글 */ fun create( @@ -56,6 +63,7 @@ class Comment protected constructor( authorNickname: String, content: CommentContent, parentCommentId: Long? = null, + parentCommentAuthorId: Long? = null, ): Comment { require(postId > 0) { ERROR_POST_ID_MUST_BE_POSITIVE.format(postId) } @@ -65,7 +73,7 @@ class Comment protected constructor( ) } - return Comment( + val comment = Comment( postId = postId, author = CommentAuthor(authorMemberId, authorNickname), content = content, @@ -75,6 +83,20 @@ class Comment protected constructor( updatedAt = LocalDateTime.now(), repliesCount = 0, ) + + // 도메인 이벤트 발행 + comment.addDomainEvent( + CommentCreatedEvent( + commentId = 0L, // drainDomainEvents에서 실제 ID로 설정 + postId = postId, + authorMemberId = authorMemberId, + parentCommentId = parentCommentId, + parentCommentAuthorId = parentCommentAuthorId, + createdAt = comment.createdAt + ) + ) + + return comment } } @@ -111,6 +133,9 @@ class Comment protected constructor( var updatedAt: LocalDateTime = updatedAt protected set + @Transient + private var domainEvents: MutableList = mutableListOf() + /** * 댓글 내용을 수정합니다. * @@ -123,6 +148,16 @@ class Comment protected constructor( ensurePublished() this.content = content this.updatedAt = LocalDateTime.now() + + // 도메인 이벤트 발행 + addDomainEvent( + CommentUpdatedEvent( + commentId = requireId(), + postId = postId, + authorMemberId = author.memberId, + updatedAt = updatedAt, + ) + ) } /** @@ -136,6 +171,17 @@ class Comment protected constructor( ensurePublished() this.status = CommentStatus.DELETED this.updatedAt = LocalDateTime.now() + + // 도메인 이벤트 발행 + addDomainEvent( + CommentDeletedEvent( + commentId = requireId(), + parentCommentId = parentCommentId, + postId = postId, + authorMemberId = author.memberId, + deletedAt = updatedAt, + ) + ) } /** @@ -176,4 +222,22 @@ class Comment protected constructor( * 삭제된 댓글인지 확인합니다. */ fun isDeleted(): Boolean = status == CommentStatus.DELETED + + /** + * 도메인 이벤트 추가 + */ + private fun addDomainEvent(event: CommentDomainEvent) { + domainEvents.add(event) + } + + /** + * 도메인 이벤트를 조회 및 초기화하고 ID를 설정합니다. + */ + override fun drainDomainEvents(): List { + val events = domainEvents.toList() + domainEvents.clear() + + // 이벤트의 commentId를 실제 ID로 설정 + return events.map { it.withCommentId(this.requireId()) } + } } diff --git a/src/main/kotlin/com/albert/realmoneyrealtaste/domain/comment/event/CommentCreatedEvent.kt b/src/main/kotlin/com/albert/realmoneyrealtaste/domain/comment/event/CommentCreatedEvent.kt index 623c4c51..07ad37df 100644 --- a/src/main/kotlin/com/albert/realmoneyrealtaste/domain/comment/event/CommentCreatedEvent.kt +++ b/src/main/kotlin/com/albert/realmoneyrealtaste/domain/comment/event/CommentCreatedEvent.kt @@ -6,9 +6,12 @@ import java.time.LocalDateTime * 댓글 생성 이벤트 */ data class CommentCreatedEvent( - val commentId: Long, + override val commentId: Long, val postId: Long, val authorMemberId: Long, val parentCommentId: Long?, + val parentCommentAuthorId: Long?, val createdAt: LocalDateTime, -) +) : CommentDomainEvent { + override fun withCommentId(commentId: Long): CommentCreatedEvent = copy(commentId = commentId) +} diff --git a/src/main/kotlin/com/albert/realmoneyrealtaste/domain/comment/event/CommentDeletedEvent.kt b/src/main/kotlin/com/albert/realmoneyrealtaste/domain/comment/event/CommentDeletedEvent.kt index 0056ed78..db641d76 100644 --- a/src/main/kotlin/com/albert/realmoneyrealtaste/domain/comment/event/CommentDeletedEvent.kt +++ b/src/main/kotlin/com/albert/realmoneyrealtaste/domain/comment/event/CommentDeletedEvent.kt @@ -6,8 +6,11 @@ import java.time.LocalDateTime * 댓글 삭제 이벤트 */ data class CommentDeletedEvent( - val commentId: Long, + override val commentId: Long, + val parentCommentId: Long?, val postId: Long, val authorMemberId: Long, val deletedAt: LocalDateTime, -) +) : CommentDomainEvent { + override fun withCommentId(commentId: Long): CommentDeletedEvent = copy(commentId = commentId) +} diff --git a/src/main/kotlin/com/albert/realmoneyrealtaste/domain/comment/event/CommentDomainEvent.kt b/src/main/kotlin/com/albert/realmoneyrealtaste/domain/comment/event/CommentDomainEvent.kt new file mode 100644 index 00000000..6b211101 --- /dev/null +++ b/src/main/kotlin/com/albert/realmoneyrealtaste/domain/comment/event/CommentDomainEvent.kt @@ -0,0 +1,9 @@ +package com.albert.realmoneyrealtaste.domain.comment.event + +import com.albert.realmoneyrealtaste.domain.common.DomainEvent + +interface CommentDomainEvent : DomainEvent { + val commentId: Long + + fun withCommentId(commentId: Long): CommentDomainEvent +} diff --git a/src/main/kotlin/com/albert/realmoneyrealtaste/domain/comment/event/CommentUpdatedEvent.kt b/src/main/kotlin/com/albert/realmoneyrealtaste/domain/comment/event/CommentUpdatedEvent.kt index 23cbf040..53bb0e47 100644 --- a/src/main/kotlin/com/albert/realmoneyrealtaste/domain/comment/event/CommentUpdatedEvent.kt +++ b/src/main/kotlin/com/albert/realmoneyrealtaste/domain/comment/event/CommentUpdatedEvent.kt @@ -6,8 +6,10 @@ import java.time.LocalDateTime * 댓글 수정 이벤트 */ data class CommentUpdatedEvent( - val commentId: Long, + override val commentId: Long, val postId: Long, val authorMemberId: Long, val updatedAt: LocalDateTime, -) +) : CommentDomainEvent { + override fun withCommentId(commentId: Long): CommentUpdatedEvent = copy(commentId = commentId) +} diff --git a/src/main/kotlin/com/albert/realmoneyrealtaste/domain/common/AggregateRoot.kt b/src/main/kotlin/com/albert/realmoneyrealtaste/domain/common/AggregateRoot.kt new file mode 100644 index 00000000..e2e08844 --- /dev/null +++ b/src/main/kotlin/com/albert/realmoneyrealtaste/domain/common/AggregateRoot.kt @@ -0,0 +1,14 @@ +package com.albert.realmoneyrealtaste.domain.common + +/** + * 도메인 애그리거트의 공통 인터페이스 + * 모든 애그리거트는 도메인 이벤트를 가져올 수 있어야 합니다. + */ +fun interface AggregateRoot { + /** + * 애그리거트에 축적된 도메인 이벤트를 모두 가져오고 초기화합니다. + * + * @return 도메인 이벤트 리스트 + */ + fun drainDomainEvents(): List +} diff --git a/src/main/kotlin/com/albert/realmoneyrealtaste/domain/common/DomainEvent.kt b/src/main/kotlin/com/albert/realmoneyrealtaste/domain/common/DomainEvent.kt new file mode 100644 index 00000000..ce9c548d --- /dev/null +++ b/src/main/kotlin/com/albert/realmoneyrealtaste/domain/common/DomainEvent.kt @@ -0,0 +1,3 @@ +package com.albert.realmoneyrealtaste.domain.common + +interface DomainEvent diff --git a/src/main/kotlin/com/albert/realmoneyrealtaste/domain/event/MemberEvent.kt b/src/main/kotlin/com/albert/realmoneyrealtaste/domain/event/MemberEvent.kt new file mode 100644 index 00000000..e9bc8bce --- /dev/null +++ b/src/main/kotlin/com/albert/realmoneyrealtaste/domain/event/MemberEvent.kt @@ -0,0 +1,130 @@ +package com.albert.realmoneyrealtaste.domain.event + +import com.albert.realmoneyrealtaste.domain.common.BaseEntity +import jakarta.persistence.Column +import jakarta.persistence.Entity +import jakarta.persistence.EnumType +import jakarta.persistence.Enumerated +import jakarta.persistence.Index +import jakarta.persistence.Table +import java.time.LocalDateTime + +/** + * 회원 이벤트 엔티티 + * 회원의 활동 기록을 저장하는 엔티티 + */ +@Entity +@Table( + name = "member_event", + indexes = [ + Index(name = "idx_member_event_member_id", columnList = "member_id"), + Index(name = "idx_member_event_event_type", columnList = "event_type"), + Index(name = "idx_member_event_is_read", columnList = "is_read"), + Index(name = "idx_member_event_created_at", columnList = "created_at") + ] +) +class MemberEvent protected constructor( + memberId: Long, + + eventType: MemberEventType, + + title: String, + + message: String, + + relatedMemberId: Long?, + + relatedPostId: Long?, + + relatedCommentId: Long?, + + isRead: Boolean, + + createdAt: LocalDateTime, +) : BaseEntity() { + + @Column(name = "member_id", nullable = false) + var memberId: Long = memberId + protected set + + @Enumerated(EnumType.STRING) + @Column(name = "event_type", nullable = false, length = 30) + var eventType: MemberEventType = eventType + protected set + + @Column(name = "title", nullable = false, length = 100) + var title: String = title + protected set + + @Column(name = "message", nullable = false, length = 500) + var message: String = message + protected set + + @Column(name = "related_member_id") + var relatedMemberId: Long? = relatedMemberId + protected set + + @Column(name = "related_post_id") + var relatedPostId: Long? = relatedPostId + protected set + + @Column(name = "related_comment_id") + var relatedCommentId: Long? = relatedCommentId + protected set + + @Column(name = "is_read", nullable = false) + var isRead: Boolean = isRead + protected set + + @Column(name = "created_at", nullable = false) + var createdAt: LocalDateTime = createdAt + protected set + + init { + validate() + } + + private fun validate() { + require(memberId > 0) { ERROR_MEMBER_ID_MUST_BE_POSITIVE } + require(title.isNotBlank()) { ERROR_TITLE_MUST_NOT_BE_EMPTY } + require(message.isNotBlank()) { ERROR_MESSAGE_MUST_NOT_BE_EMPTY } + } + + /** + * 이벤트를 읽음으로 표시 + */ + fun markAsRead() { + isRead = true + } + + companion object { + const val ERROR_MEMBER_ID_MUST_BE_POSITIVE = "회원 ID는 양수여야 합니다" + const val ERROR_TITLE_MUST_NOT_BE_EMPTY = "제목은 비어있을 수 없습니다" + const val ERROR_MESSAGE_MUST_NOT_BE_EMPTY = "메시지는 비어있을 수 없습니다" + + /** + * 회원 이벤트 생성 + */ + fun create( + memberId: Long, + eventType: MemberEventType, + title: String, + message: String, + relatedMemberId: Long? = null, + relatedPostId: Long? = null, + relatedCommentId: Long? = null, + ): MemberEvent { + return MemberEvent( + memberId = memberId, + eventType = eventType, + title = title, + message = message, + relatedMemberId = relatedMemberId, + relatedPostId = relatedPostId, + relatedCommentId = relatedCommentId, + isRead = false, + createdAt = LocalDateTime.now(), + ) + } + } +} diff --git a/src/main/kotlin/com/albert/realmoneyrealtaste/domain/event/MemberEventType.kt b/src/main/kotlin/com/albert/realmoneyrealtaste/domain/event/MemberEventType.kt new file mode 100644 index 00000000..2b1bf6f3 --- /dev/null +++ b/src/main/kotlin/com/albert/realmoneyrealtaste/domain/event/MemberEventType.kt @@ -0,0 +1,30 @@ +package com.albert.realmoneyrealtaste.domain.event + +/** + * 회원 이벤트 타입 enum + */ +enum class MemberEventType { + // 친구 관련 + FRIEND_REQUEST_SENT, // 친구 요청을 보냈습니다 + FRIEND_REQUEST_RECEIVED, // 친구 요청을 받았습니다 + FRIEND_REQUEST_ACCEPTED, // 친구 요청이 수락되었습니다 + FRIEND_REQUEST_REJECTED, // 친구 요청이 거절되었습니다 + FRIENDSHIP_TERMINATED, // 친구 관계가 해제되었습니다 + + // 게시물 관련 + POST_CREATED, // 새 게시물을 작성했습니다 + POST_DELETED, // 게시물을 삭제했습니다 + POST_COMMENTED, // 게시물에 댓글이 달렸습니다 + + // 댓글 관련 + COMMENT_CREATED, // 댓글을 작성했습니다 + COMMENT_DELETED, // 댓글을 삭제했습니다 + COMMENT_REPLIED, // 대댓글이 달렸습니다 + + // 프로필 관련 + PROFILE_UPDATED, // 프로필이 업데이트되었습니다 + + // 시스템 관련 + ACCOUNT_ACTIVATED, // 계정이 활성화되었습니다 + ACCOUNT_DEACTIVATED, // 계정이 비활성화되었습니다 +} 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 a68406ea..16f18553 100644 --- a/src/main/kotlin/com/albert/realmoneyrealtaste/domain/friend/Friendship.kt +++ b/src/main/kotlin/com/albert/realmoneyrealtaste/domain/friend/Friendship.kt @@ -1,7 +1,13 @@ package com.albert.realmoneyrealtaste.domain.friend +import com.albert.realmoneyrealtaste.domain.common.AggregateRoot import com.albert.realmoneyrealtaste.domain.common.BaseEntity import com.albert.realmoneyrealtaste.domain.friend.command.FriendRequestCommand +import com.albert.realmoneyrealtaste.domain.friend.event.FriendDomainEvent +import com.albert.realmoneyrealtaste.domain.friend.event.FriendRequestAcceptedEvent +import com.albert.realmoneyrealtaste.domain.friend.event.FriendRequestRejectedEvent +import com.albert.realmoneyrealtaste.domain.friend.event.FriendRequestSentEvent +import com.albert.realmoneyrealtaste.domain.friend.event.FriendshipTerminatedEvent import com.albert.realmoneyrealtaste.domain.friend.value.FriendRelationship import jakarta.persistence.Column import jakarta.persistence.Embedded @@ -10,6 +16,7 @@ import jakarta.persistence.EnumType import jakarta.persistence.Enumerated import jakarta.persistence.Index import jakarta.persistence.Table +import jakarta.persistence.Transient import jakarta.persistence.UniqueConstraint import java.time.LocalDateTime @@ -37,19 +44,14 @@ import java.time.LocalDateTime ] ) class Friendship protected constructor( - @Embedded - val relationShip: FriendRelationship, + relationShip: FriendRelationship, - @Enumerated(EnumType.STRING) - @Column(length = 20, nullable = false) - var status: FriendshipStatus, + status: FriendshipStatus, - @Column(nullable = false) - val createdAt: LocalDateTime, + createdAt: LocalDateTime, - @Column(nullable = false) - var updatedAt: LocalDateTime, -) : BaseEntity() { + updatedAt: LocalDateTime, +) : BaseEntity(), AggregateRoot { companion object { const val ERROR_ONLY_PENDING_REQUESTS_CAN_BE_ACCEPTED = "대기 중인 친구 요청만 수락할 수 있습니다" @@ -66,15 +68,70 @@ class Friendship protected constructor( require(requestCommand.fromMemberId != requestCommand.toMemberId) { ERROR_CANNOT_SEND_REQUEST_TO_SELF } val now = LocalDateTime.now() - return Friendship( + val friendship = Friendship( relationShip = FriendRelationship.of(requestCommand), status = FriendshipStatus.PENDING, createdAt = now, updatedAt = now ) + + // 도메인 이벤트 발행 + friendship.addDomainEvent( + FriendRequestSentEvent( + friendshipId = 0L, // drainDomainEvents에서 실제 ID로 설정 + fromMemberId = requestCommand.fromMemberId, + toMemberId = requestCommand.toMemberId, + occurredAt = now, + ) + ) + + return friendship } } + @Embedded + var relationShip: FriendRelationship = relationShip + protected set + + @Enumerated(EnumType.STRING) + @Column(length = 20, nullable = false) + var status: FriendshipStatus = status + protected set + + @Column(nullable = false) + var createdAt: LocalDateTime = createdAt + protected set + + @Column(nullable = false) + var updatedAt: LocalDateTime = updatedAt + protected set + + @Transient + private var domainEvents: MutableList = mutableListOf() + + /** + * 다시 친구 요청 + * + * @throws IllegalArgumentException 친구 요청을 다시 보낼 수 없는 상태인 경우 + */ + fun rePending() { + require(status == FriendshipStatus.UNFRIENDED || status == FriendshipStatus.REJECTED) { + "친구 요청을 다시 보낼 수 없는 상태입니다. 현재 상태: $status" + } + status = FriendshipStatus.PENDING + updatedAt = LocalDateTime.now() + + // 도메인 이벤트 발행 + addDomainEvent( + FriendRequestSentEvent( + friendshipId = requireId(), + fromMemberId = relationShip.memberId, + toMemberId = relationShip.friendMemberId, + occurredAt = updatedAt, + ) + ) + } + /** * 친구 요청 수락 * @@ -84,6 +141,16 @@ class Friendship protected constructor( require(status == FriendshipStatus.PENDING) { ERROR_ONLY_PENDING_REQUESTS_CAN_BE_ACCEPTED } status = FriendshipStatus.ACCEPTED updatedAt = LocalDateTime.now() + + // 도메인 이벤트 발행 + addDomainEvent( + FriendRequestAcceptedEvent( + friendshipId = requireId(), + fromMemberId = relationShip.memberId, + toMemberId = relationShip.friendMemberId, + occurredAt = updatedAt + ) + ) } /** @@ -95,6 +162,15 @@ class Friendship protected constructor( require(status == FriendshipStatus.PENDING) { ERROR_ONLY_PENDING_REQUESTS_CAN_BE_REJECTED } status = FriendshipStatus.REJECTED updatedAt = LocalDateTime.now() + + // 도메인 이벤트 발행 + addDomainEvent( + FriendRequestRejectedEvent( + friendshipId = requireId(), + fromMemberId = relationShip.memberId, + toMemberId = relationShip.friendMemberId + ) + ) } /** @@ -106,6 +182,16 @@ class Friendship protected constructor( require(status == FriendshipStatus.ACCEPTED) { ERROR_ONLY_FRIENDS_CAN_UNFRIEND } status = FriendshipStatus.UNFRIENDED updatedAt = LocalDateTime.now() + + // 도메인 이벤트 발행 + addDomainEvent( + FriendshipTerminatedEvent( + friendshipId = requireId(), + memberId = relationShip.memberId, + friendMemberId = relationShip.friendMemberId, + occurredAt = updatedAt + ) + ) } /** @@ -124,4 +210,47 @@ class Friendship protected constructor( fun isRelatedTo(memberId: Long): Boolean { return relationShip.memberId == memberId || relationShip.friendMemberId == memberId } + + /** + * 회원 정보를 업데이트합니다 (크로스 도메인 이벤트 처리용) + * + * @param memberId 업데이트할 회원 ID + * @param nickname 새 닉네임 (null이면 업데이트하지 않음) + * @param imageId 새 이미지 ID (null이면 업데이트하지 않음) + */ + fun updateMemberInfo(memberId: Long, nickname: String?, imageId: Long?) { + if (relationShip.memberId == memberId) { + // 요청자 정보 업데이트 + nickname?.let { relationShip = relationShip.copy(memberNickname = it) } + imageId?.let { relationShip = relationShip.copy(memberProfileImageId = it) } + } else if (relationShip.friendMemberId == memberId) { + // 친구 정보 업데이트 + nickname?.let { relationShip = relationShip.copy(friendNickname = it) } + imageId?.let { relationShip = relationShip.copy(friendProfileImageId = it) } + } else { + return + } + + if (nickname != null || imageId != null) { + updatedAt = LocalDateTime.now() + } + } + + /** + * 도메인 이벤트 추가 + */ + private fun addDomainEvent(event: FriendDomainEvent) { + domainEvents.add(event) + } + + /** + * 도메인 이벤트를 조회 및 초기화하고 ID를 설정합니다. + */ + override fun drainDomainEvents(): List { + val events = domainEvents.toList() + domainEvents.clear() + + // 이벤트의 friendshipId를 실제 ID로 설정 + return events.map { event -> event.withFriendshipId(requireId()) } + } } 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 abec7043..1e607a62 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,9 +5,11 @@ package com.albert.realmoneyrealtaste.domain.friend.command */ data class FriendRequestCommand( val fromMemberId: Long, - val fromMemberNickName: String, + val fromMemberNickname: String, + val fromMemberProfileImageId: Long, val toMemberId: Long, val toMemberNickname: String, + val toMemberProfileImageId: Long, ) { companion object { const val ERROR_FROM_MEMBER_ID_MUST_BE_POSITIVE = "요청자 회원 ID는 양수여야 합니다" @@ -15,6 +17,8 @@ data class FriendRequestCommand( 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 = "요청자 회원 닉네임은 비어있을 수 없습니다" + const val ERROR_FROM_MEMBER_IMAGE_ID_MUST_BE_POSITIVE = "요청자 회원 이미지 ID는 양수여야 합니다" + const val ERROR_TO_MEMBER_IMAGE_ID_MUST_BE_POSITIVE = "대상 회원 이미지 ID는 양수여야 합니다" } init { @@ -25,7 +29,9 @@ 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(fromMemberNickname.isNotBlank()) { ERROR_FROM_MEMBER_NICKNAME_MUST_NOT_BE_EMPTY } require(toMemberNickname.isNotBlank()) { ERROR_TO_MEMBER_NICKNAME_MUST_NOT_BE_EMPTY } + require(fromMemberProfileImageId > 0) { ERROR_FROM_MEMBER_IMAGE_ID_MUST_BE_POSITIVE } + require(toMemberProfileImageId > 0) { ERROR_TO_MEMBER_IMAGE_ID_MUST_BE_POSITIVE } } } diff --git a/src/main/kotlin/com/albert/realmoneyrealtaste/domain/friend/event/FriendDomainEvent.kt b/src/main/kotlin/com/albert/realmoneyrealtaste/domain/friend/event/FriendDomainEvent.kt new file mode 100644 index 00000000..72bca9c8 --- /dev/null +++ b/src/main/kotlin/com/albert/realmoneyrealtaste/domain/friend/event/FriendDomainEvent.kt @@ -0,0 +1,9 @@ +package com.albert.realmoneyrealtaste.domain.friend.event + +import com.albert.realmoneyrealtaste.domain.common.DomainEvent + +interface FriendDomainEvent : DomainEvent { + val friendshipId: Long + + fun withFriendshipId(friendshipId: Long): FriendDomainEvent +} diff --git a/src/main/kotlin/com/albert/realmoneyrealtaste/domain/friend/event/FriendRequestAcceptedEvent.kt b/src/main/kotlin/com/albert/realmoneyrealtaste/domain/friend/event/FriendRequestAcceptedEvent.kt index 5deb7e76..e5c1af50 100644 --- a/src/main/kotlin/com/albert/realmoneyrealtaste/domain/friend/event/FriendRequestAcceptedEvent.kt +++ b/src/main/kotlin/com/albert/realmoneyrealtaste/domain/friend/event/FriendRequestAcceptedEvent.kt @@ -1,10 +1,15 @@ package com.albert.realmoneyrealtaste.domain.friend.event +import java.time.LocalDateTime + /** * 친구 요청 수락 이벤트 */ data class FriendRequestAcceptedEvent( - val friendshipId: Long, + override val friendshipId: Long, val fromMemberId: Long, val toMemberId: Long, -) + val occurredAt: LocalDateTime, +) : FriendDomainEvent { + override fun withFriendshipId(friendshipId: Long): FriendDomainEvent = copy(friendshipId = friendshipId) +} diff --git a/src/main/kotlin/com/albert/realmoneyrealtaste/domain/friend/event/FriendRequestRejectedEvent.kt b/src/main/kotlin/com/albert/realmoneyrealtaste/domain/friend/event/FriendRequestRejectedEvent.kt index 7e99a265..0fdf81c5 100644 --- a/src/main/kotlin/com/albert/realmoneyrealtaste/domain/friend/event/FriendRequestRejectedEvent.kt +++ b/src/main/kotlin/com/albert/realmoneyrealtaste/domain/friend/event/FriendRequestRejectedEvent.kt @@ -4,7 +4,9 @@ package com.albert.realmoneyrealtaste.domain.friend.event * 친구 요청 거절 이벤트 */ data class FriendRequestRejectedEvent( - val friendshipId: Long, + override val friendshipId: Long, val fromMemberId: Long, val toMemberId: Long, -) +) : FriendDomainEvent { + override fun withFriendshipId(friendshipId: Long): FriendDomainEvent = copy(friendshipId = friendshipId) +} diff --git a/src/main/kotlin/com/albert/realmoneyrealtaste/domain/friend/event/FriendRequestSentEvent.kt b/src/main/kotlin/com/albert/realmoneyrealtaste/domain/friend/event/FriendRequestSentEvent.kt index 2ce19c17..e737c988 100644 --- a/src/main/kotlin/com/albert/realmoneyrealtaste/domain/friend/event/FriendRequestSentEvent.kt +++ b/src/main/kotlin/com/albert/realmoneyrealtaste/domain/friend/event/FriendRequestSentEvent.kt @@ -1,10 +1,16 @@ package com.albert.realmoneyrealtaste.domain.friend.event +import java.time.LocalDateTime + /** * 친구 요청 전송 이벤트 */ data class FriendRequestSentEvent( - val friendshipId: Long, + override val friendshipId: Long, val fromMemberId: Long, val toMemberId: Long, -) + val occurredAt: LocalDateTime, +) : FriendDomainEvent { + override fun withFriendshipId(friendshipId: Long): FriendDomainEvent = copy(friendshipId = friendshipId) +} + diff --git a/src/main/kotlin/com/albert/realmoneyrealtaste/domain/friend/event/FriendshipTerminatedEvent.kt b/src/main/kotlin/com/albert/realmoneyrealtaste/domain/friend/event/FriendshipTerminatedEvent.kt index d7df8c27..77632709 100644 --- a/src/main/kotlin/com/albert/realmoneyrealtaste/domain/friend/event/FriendshipTerminatedEvent.kt +++ b/src/main/kotlin/com/albert/realmoneyrealtaste/domain/friend/event/FriendshipTerminatedEvent.kt @@ -1,9 +1,16 @@ package com.albert.realmoneyrealtaste.domain.friend.event +import java.time.LocalDateTime + /** * 친구 관계 해제 이벤트 */ data class FriendshipTerminatedEvent( + override val friendshipId: Long, val memberId: Long, val friendMemberId: Long, -) + val occurredAt: LocalDateTime, +) : FriendDomainEvent { + override fun withFriendshipId(friendshipId: Long): FriendDomainEvent = copy(friendshipId = friendshipId) +} + 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 228690e5..fca71da6 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 @@ -15,23 +15,33 @@ data class FriendRelationship( @Column(name = "member_nickname", length = 50) val memberNickname: String, + @Column(name = "image_id", nullable = false) + val memberProfileImageId: Long, + @Column(name = "friend_member_id", nullable = false) val friendMemberId: Long, @Column(name = "friend_nickname", length = 50) val friendNickname: String, + + @Column(name = "friend_image_id", nullable = false) + val friendProfileImageId: Long, ) { companion object { const val ERROR_MEMBER_ID_MUST_BE_POSITIVE = "회원 ID는 양수여야 합니다" const val ERROR_FRIEND_MEMBER_ID_MUST_BE_POSITIVE = "친구 회원 ID는 양수여야 합니다" const val ERROR_CANNOT_FRIEND_YOURSELF = "자기 자신과는 친구가 될 수 없습니다" + const val ERROR_MEMBER_PROFILE_IMAGE_ID_MUST_BE_POSITIVE = "회원 프로필 이미지 ID는 양수여야 합니다" + const val ERROR_FRIEND_PROFILE_IMAGE_ID_MUST_BE_POSITIVE = "친구 프로필 이미지 ID는 양수여야 합니다" fun of(friendRequestCommand: FriendRequestCommand): FriendRelationship { return FriendRelationship( memberId = friendRequestCommand.fromMemberId, - memberNickname = friendRequestCommand.fromMemberNickName, + memberNickname = friendRequestCommand.fromMemberNickname, + memberProfileImageId = friendRequestCommand.fromMemberProfileImageId, friendMemberId = friendRequestCommand.toMemberId, friendNickname = friendRequestCommand.toMemberNickname, + friendProfileImageId = friendRequestCommand.toMemberProfileImageId, ) } } @@ -44,5 +54,7 @@ data class FriendRelationship( require(memberId > 0) { ERROR_MEMBER_ID_MUST_BE_POSITIVE } require(friendMemberId > 0) { ERROR_FRIEND_MEMBER_ID_MUST_BE_POSITIVE } require(memberId != friendMemberId) { ERROR_CANNOT_FRIEND_YOURSELF } + require(memberProfileImageId > 0) { ERROR_MEMBER_PROFILE_IMAGE_ID_MUST_BE_POSITIVE } + require(friendProfileImageId > 0) { ERROR_FRIEND_PROFILE_IMAGE_ID_MUST_BE_POSITIVE } } } 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 6af73387..cf16b68f 100644 --- a/src/main/kotlin/com/albert/realmoneyrealtaste/domain/member/Member.kt +++ b/src/main/kotlin/com/albert/realmoneyrealtaste/domain/member/Member.kt @@ -1,6 +1,14 @@ package com.albert.realmoneyrealtaste.domain.member +import com.albert.realmoneyrealtaste.domain.common.AggregateRoot import com.albert.realmoneyrealtaste.domain.common.BaseEntity +import com.albert.realmoneyrealtaste.domain.common.DomainEvent +import com.albert.realmoneyrealtaste.domain.member.event.MemberActivatedDomainEvent +import com.albert.realmoneyrealtaste.domain.member.event.MemberDeactivatedDomainEvent +import com.albert.realmoneyrealtaste.domain.member.event.MemberDomainEvent +import com.albert.realmoneyrealtaste.domain.member.event.MemberProfileUpdatedDomainEvent +import com.albert.realmoneyrealtaste.domain.member.event.MemberRegisteredDomainEvent +import com.albert.realmoneyrealtaste.domain.member.event.PasswordChangedDomainEvent import com.albert.realmoneyrealtaste.domain.member.service.PasswordEncoder import com.albert.realmoneyrealtaste.domain.member.value.Email import com.albert.realmoneyrealtaste.domain.member.value.Introduction @@ -53,7 +61,10 @@ class Member protected constructor( followingsCount: Long, postCount: Long, -) : BaseEntity() { +) : BaseEntity(), AggregateRoot { + + @Transient + private var domainEvents: MutableList = mutableListOf() @Embedded var email: Email = email @@ -101,7 +112,7 @@ class Member protected constructor( var postCount: Long = postCount protected set - val imageId: Long + val profileImageId: Long get() = detail.imageId ?: 1L val address: String @@ -119,6 +130,15 @@ class Member protected constructor( status = MemberStatus.ACTIVE detail.activate() updatedAt = LocalDateTime.now() + + // 도메인 이벤트 발행 + addDomainEvent( + MemberActivatedDomainEvent( + memberId = requireId(), + email = email.address, + nickname = nickname.value + ) + ) } fun deactivate() { @@ -127,6 +147,14 @@ class Member protected constructor( status = MemberStatus.DEACTIVATED detail.deactivate() updatedAt = LocalDateTime.now() + + // 도메인 이벤트 발행 + addDomainEvent( + MemberDeactivatedDomainEvent( + memberId = requireId(), + occurredAt = LocalDateTime.now(), + ) + ) } fun verifyPassword(rawPassword: RawPassword, encoder: PasswordEncoder): Boolean = @@ -135,6 +163,14 @@ class Member protected constructor( fun changePassword(newPassword: PasswordHash) { passwordHash = newPassword updatedAt = LocalDateTime.now() + + // 도메인 이벤트 발행 + addDomainEvent( + PasswordChangedDomainEvent( + memberId = requireId(), + email = email.address + ) + ) } fun changePassword(currentPassword: RawPassword, newPassword: RawPassword, encoder: PasswordEncoder) { @@ -144,6 +180,14 @@ class Member protected constructor( passwordHash = PasswordHash.of(newPassword, encoder) updatedAt = LocalDateTime.now() + + // 도메인 이벤트 발행 + addDomainEvent( + PasswordChangedDomainEvent( + memberId = requireId(), + email = email.address + ) + ) } fun updateInfo( @@ -155,10 +199,36 @@ class Member protected constructor( ) { require(status == MemberStatus.ACTIVE) { ERROR_INVALID_STATUS_FOR_INFO_UPDATE } - val sameNickname = nickname == this.nickname - nickname?.let { this.nickname = it } - val updated = detail.updateInfo(profileAddress, introduction, address, imageId) - if (!sameNickname || updated) updatedAt = LocalDateTime.now() + val updatedFields = mutableListOf() + + nickname?.let { + if (nickname != this.nickname) { + this.nickname = it + updatedFields.add("nickname") + } + } + + if (detail.updateInfo(profileAddress, introduction, address, imageId)) { + profileAddress?.let { updatedFields.add("profileAddress") } + introduction?.let { updatedFields.add("introduction") } + address?.let { updatedFields.add("address") } + imageId?.let { updatedFields.add("imageId") } + } + + // 도메인 이벤트 발행 + if (updatedFields.isNotEmpty()) { + updatedAt = LocalDateTime.now() + + addDomainEvent( + MemberProfileUpdatedDomainEvent( + memberId = requireId(), + email = email.address, + updatedFields = updatedFields, + nickname = if (updatedFields.contains("nickname")) this.nickname.value else null, + imageId = if (updatedFields.contains("imageId")) detail.imageId else null + ) + ) + } } fun updateTrustScore(newTrustScore: TrustScore) { @@ -205,6 +275,24 @@ class Member protected constructor( updatedAt = LocalDateTime.now() } + /** + * 도메인 이벤트 추가 + */ + private fun addDomainEvent(event: MemberDomainEvent) { + domainEvents.add(event) + } + + /** + * 도메인 이벤트를 조회 및 초기화하고 ID를 설정합니다. + */ + override fun drainDomainEvents(): List { + val events = domainEvents.toList() + domainEvents.clear() + + // 이벤트의 memberId를 실제 ID로 설정 + return events.map { it.withMemberId(requireId()) } + } + companion object { const val ERROR_INVALID_STATUS_FOR_ACTIVATION = "등록 대기 상태에서만 등록 완료가 가능합니다" const val ERROR_INVALID_STATUS_FOR_DEACTIVATION = "등록 완료 상태에서만 탈퇴가 가능합니다" @@ -217,54 +305,93 @@ class Member protected constructor( email: Email, nickname: Nickname, password: PasswordHash, - ): Member = Member( - email = email, - nickname = nickname, - passwordHash = password, - detail = MemberDetail.register(), - trustScore = TrustScore.create(), - status = MemberStatus.PENDING, - updatedAt = LocalDateTime.now(), - roles = Roles.ofUser(), - followersCount = 0L, - followingsCount = 0L, - postCount = 0L, - ) + ): Member { + val member = Member( + email = email, + nickname = nickname, + passwordHash = password, + detail = MemberDetail.register(), + trustScore = TrustScore.create(), + status = MemberStatus.PENDING, + updatedAt = LocalDateTime.now(), + roles = Roles.ofUser(), + followersCount = 0L, + followingsCount = 0L, + postCount = 0L, + ) + + // 도메인 이벤트 발행 (ID는 나중에 설정) + member.addDomainEvent( + MemberRegisteredDomainEvent( + memberId = 0L, // 임시값, 이벤트 발행 시점에 실제 ID로 설정 + email = email.address, + nickname = nickname.value + ) + ) + + return member + } fun registerManager( email: Email, nickname: Nickname, password: PasswordHash, - ): Member = Member( - email = email, - nickname = nickname, - passwordHash = password, - detail = MemberDetail.register(), - trustScore = TrustScore.create(), - status = MemberStatus.PENDING, - updatedAt = LocalDateTime.now(), - roles = Roles.of(Role.USER, Role.MANAGER), - followersCount = 0L, - followingsCount = 0L, - postCount = 0L, - ) + ): Member { + val member = Member( + email = email, + nickname = nickname, + passwordHash = password, + detail = MemberDetail.register(), + trustScore = TrustScore.create(), + status = MemberStatus.PENDING, + updatedAt = LocalDateTime.now(), + roles = Roles.of(Role.USER, Role.MANAGER), + followersCount = 0L, + followingsCount = 0L, + postCount = 0L, + ) + + // 도메인 이벤트 발행 (ID는 나중에 설정) + member.addDomainEvent( + MemberRegisteredDomainEvent( + memberId = 0L, // 임시값, 이벤트 발행 시점에 실제 ID로 설정 + email = email.address, + nickname = nickname.value + ) + ) + + return member + } fun registerAdmin( email: Email, nickname: Nickname, password: PasswordHash, - ): Member = Member( - email = email, - nickname = nickname, - passwordHash = password, - detail = MemberDetail.register(), - trustScore = TrustScore.create(), - status = MemberStatus.PENDING, - updatedAt = LocalDateTime.now(), - roles = Roles.of(Role.USER, Role.ADMIN), - followersCount = 0L, - followingsCount = 0L, - postCount = 0L, - ) + ): Member { + val member = Member( + email = email, + nickname = nickname, + passwordHash = password, + detail = MemberDetail.register(), + trustScore = TrustScore.create(), + status = MemberStatus.PENDING, + updatedAt = LocalDateTime.now(), + roles = Roles.of(Role.USER, Role.ADMIN), + followersCount = 0L, + followingsCount = 0L, + postCount = 0L, + ) + + // 도메인 이벤트 발행 (ID는 나중에 설정) + member.addDomainEvent( + MemberRegisteredDomainEvent( + memberId = 0L, // 임시값, 이벤트 발행 시점에 실제 ID로 설정 + email = email.address, + nickname = nickname.value + ) + ) + + return member + } } } diff --git a/src/main/kotlin/com/albert/realmoneyrealtaste/domain/member/event/MemberActivatedDomainEvent.kt b/src/main/kotlin/com/albert/realmoneyrealtaste/domain/member/event/MemberActivatedDomainEvent.kt new file mode 100644 index 00000000..16ef874d --- /dev/null +++ b/src/main/kotlin/com/albert/realmoneyrealtaste/domain/member/event/MemberActivatedDomainEvent.kt @@ -0,0 +1,15 @@ +package com.albert.realmoneyrealtaste.domain.member.event + +import java.time.LocalDateTime + +/** + * 회원이 활성화된 도메인 이벤트 + */ +data class MemberActivatedDomainEvent( + override val memberId: Long, + val email: String, + val nickname: String, + val occurredAt: LocalDateTime = LocalDateTime.now(), +) : MemberDomainEvent { + override fun withMemberId(memberId: Long): MemberActivatedDomainEvent = this.copy(memberId = memberId) +} diff --git a/src/main/kotlin/com/albert/realmoneyrealtaste/domain/member/event/MemberDeactivatedDomainEvent.kt b/src/main/kotlin/com/albert/realmoneyrealtaste/domain/member/event/MemberDeactivatedDomainEvent.kt new file mode 100644 index 00000000..07c1bcec --- /dev/null +++ b/src/main/kotlin/com/albert/realmoneyrealtaste/domain/member/event/MemberDeactivatedDomainEvent.kt @@ -0,0 +1,13 @@ +package com.albert.realmoneyrealtaste.domain.member.event + +import java.time.LocalDateTime + +/** + * 회원 비활성화 도메인 이벤트 + */ +data class MemberDeactivatedDomainEvent( + override val memberId: Long, + val occurredAt: LocalDateTime, +) : MemberDomainEvent { + override fun withMemberId(memberId: Long): MemberDeactivatedDomainEvent = this.copy(memberId = memberId) +} diff --git a/src/main/kotlin/com/albert/realmoneyrealtaste/domain/member/event/MemberDomainEvent.kt b/src/main/kotlin/com/albert/realmoneyrealtaste/domain/member/event/MemberDomainEvent.kt new file mode 100644 index 00000000..0b37ce3b --- /dev/null +++ b/src/main/kotlin/com/albert/realmoneyrealtaste/domain/member/event/MemberDomainEvent.kt @@ -0,0 +1,9 @@ +package com.albert.realmoneyrealtaste.domain.member.event + +import com.albert.realmoneyrealtaste.domain.common.DomainEvent + +interface MemberDomainEvent : DomainEvent { + val memberId: Long + + fun withMemberId(memberId: Long): MemberDomainEvent +} diff --git a/src/main/kotlin/com/albert/realmoneyrealtaste/domain/member/event/MemberProfileUpdatedDomainEvent.kt b/src/main/kotlin/com/albert/realmoneyrealtaste/domain/member/event/MemberProfileUpdatedDomainEvent.kt new file mode 100644 index 00000000..e5ebbec7 --- /dev/null +++ b/src/main/kotlin/com/albert/realmoneyrealtaste/domain/member/event/MemberProfileUpdatedDomainEvent.kt @@ -0,0 +1,18 @@ +package com.albert.realmoneyrealtaste.domain.member.event + +import java.time.LocalDateTime + +/** + * 회원 프로필이 업데이트된 도메인 이벤트 + */ +data class MemberProfileUpdatedDomainEvent( + override val memberId: Long, + val email: String, + val updatedFields: List, + val nickname: String? = null, + val introduction: String? = null, + val imageId: Long? = null, + val occurredAt: LocalDateTime = LocalDateTime.now(), +) : MemberDomainEvent { + override fun withMemberId(memberId: Long): MemberProfileUpdatedDomainEvent = this.copy(memberId = memberId) +} diff --git a/src/main/kotlin/com/albert/realmoneyrealtaste/domain/member/event/MemberRegisteredDomainEvent.kt b/src/main/kotlin/com/albert/realmoneyrealtaste/domain/member/event/MemberRegisteredDomainEvent.kt new file mode 100644 index 00000000..4a6f79f5 --- /dev/null +++ b/src/main/kotlin/com/albert/realmoneyrealtaste/domain/member/event/MemberRegisteredDomainEvent.kt @@ -0,0 +1,16 @@ +package com.albert.realmoneyrealtaste.domain.member.event + +import java.time.LocalDateTime + +/** + * 회원이 등록된 도메인 이벤트 + * 순수 비즈니스 사실만 포함 + */ +data class MemberRegisteredDomainEvent( + override val memberId: Long, + val email: String, + val nickname: String, + val occurredAt: LocalDateTime = LocalDateTime.now(), +) : MemberDomainEvent { + override fun withMemberId(memberId: Long): MemberRegisteredDomainEvent = this.copy(memberId = memberId) +} diff --git a/src/main/kotlin/com/albert/realmoneyrealtaste/domain/member/event/PasswordChangedDomainEvent.kt b/src/main/kotlin/com/albert/realmoneyrealtaste/domain/member/event/PasswordChangedDomainEvent.kt new file mode 100644 index 00000000..46b9850e --- /dev/null +++ b/src/main/kotlin/com/albert/realmoneyrealtaste/domain/member/event/PasswordChangedDomainEvent.kt @@ -0,0 +1,14 @@ +package com.albert.realmoneyrealtaste.domain.member.event + +import java.time.LocalDateTime + +/** + * 비밀번호가 변경된 도메인 이벤트 + */ +data class PasswordChangedDomainEvent( + override val memberId: Long, + val email: String, + val occurredAt: LocalDateTime = LocalDateTime.now(), +) : MemberDomainEvent { + override fun withMemberId(memberId: Long): PasswordChangedDomainEvent = this.copy(memberId = memberId) +} diff --git a/src/main/kotlin/com/albert/realmoneyrealtaste/domain/post/Post.kt b/src/main/kotlin/com/albert/realmoneyrealtaste/domain/post/Post.kt index 09b584de..b3b4304c 100644 --- a/src/main/kotlin/com/albert/realmoneyrealtaste/domain/post/Post.kt +++ b/src/main/kotlin/com/albert/realmoneyrealtaste/domain/post/Post.kt @@ -1,6 +1,10 @@ package com.albert.realmoneyrealtaste.domain.post +import com.albert.realmoneyrealtaste.domain.common.AggregateRoot import com.albert.realmoneyrealtaste.domain.common.BaseEntity +import com.albert.realmoneyrealtaste.domain.post.event.PostCreatedEvent +import com.albert.realmoneyrealtaste.domain.post.event.PostDeletedEvent +import com.albert.realmoneyrealtaste.domain.post.event.PostDomainEvent import com.albert.realmoneyrealtaste.domain.post.value.Author import com.albert.realmoneyrealtaste.domain.post.value.PostContent import com.albert.realmoneyrealtaste.domain.post.value.PostImages @@ -12,6 +16,7 @@ import jakarta.persistence.EnumType import jakarta.persistence.Enumerated import jakarta.persistence.Index import jakarta.persistence.Table +import jakarta.persistence.Transient import java.time.LocalDateTime @Entity @@ -44,7 +49,7 @@ class Post protected constructor( createdAt: LocalDateTime, updatedAt: LocalDateTime, -) : BaseEntity() { +) : BaseEntity(), AggregateRoot { companion object { const val ERROR_NO_EDIT_PERMISSION = "게시글 수정 권한이 없습니다." @@ -57,9 +62,11 @@ class Post protected constructor( content: PostContent, images: PostImages, authorIntroduction: String, + authorImageId: Long, ): Post { - return Post( - author = Author(authorMemberId, authorNickname, authorIntroduction), + val createdAt = LocalDateTime.now() + val post = Post( + author = Author(authorMemberId, authorNickname, authorIntroduction, authorImageId), restaurant = restaurant, content = content, images = images, @@ -67,9 +74,21 @@ class Post protected constructor( commentCount = 0, heartCount = 0, viewCount = 0, - createdAt = LocalDateTime.now(), - updatedAt = LocalDateTime.now() + createdAt = createdAt, + updatedAt = createdAt, ) + + // 도메인 이벤트 발행 + post.addDomainEvent( + PostCreatedEvent( + postId = 0L, // drainDomainEvents에서 실제 ID로 설정 + authorMemberId = authorMemberId, + restaurantName = restaurant.name, + occurredAt = createdAt, + ) + ) + + return post } } @@ -138,6 +157,15 @@ class Post protected constructor( ensurePublished() this.status = PostStatus.DELETED this.updatedAt = LocalDateTime.now() + + // 도메인 이벤트 발행 + addDomainEvent( + PostDeletedEvent( + postId = requireId(), + authorMemberId = author.memberId, + occurredAt = this.updatedAt, + ) + ) } /** @@ -172,4 +200,25 @@ class Post protected constructor( fun isAuthor(memberId: Long): Boolean = author.memberId == memberId fun isDeleted(): Boolean = status == PostStatus.DELETED + + @Transient + private var domainEvents: MutableList = mutableListOf() + + /** + * 도메인 이벤트 추가 + */ + private fun addDomainEvent(event: PostDomainEvent) { + domainEvents.add(event) + } + + /** + * 도메인 이벤트를 조회 및 초기화하고 ID를 설정합니다. + */ + override fun drainDomainEvents(): List { + val events = domainEvents.toList() + domainEvents.clear() + + // 이벤트의 postId를 실제 ID로 설정 + return events.map { it.withPostId(requireId()) } + } } diff --git a/src/main/kotlin/com/albert/realmoneyrealtaste/domain/post/event/PostCreatedEvent.kt b/src/main/kotlin/com/albert/realmoneyrealtaste/domain/post/event/PostCreatedEvent.kt index d6f482d2..3d34a90d 100644 --- a/src/main/kotlin/com/albert/realmoneyrealtaste/domain/post/event/PostCreatedEvent.kt +++ b/src/main/kotlin/com/albert/realmoneyrealtaste/domain/post/event/PostCreatedEvent.kt @@ -1,7 +1,12 @@ package com.albert.realmoneyrealtaste.domain.post.event +import java.time.LocalDateTime + data class PostCreatedEvent( - val postId: Long, + override val postId: Long, val authorMemberId: Long, val restaurantName: String, -) + val occurredAt: LocalDateTime, +) : PostDomainEvent { + override fun withPostId(postId: Long): PostCreatedEvent = this.copy(postId = postId) +} diff --git a/src/main/kotlin/com/albert/realmoneyrealtaste/domain/post/event/PostDeletedEvent.kt b/src/main/kotlin/com/albert/realmoneyrealtaste/domain/post/event/PostDeletedEvent.kt index 6bb51a92..2786b420 100644 --- a/src/main/kotlin/com/albert/realmoneyrealtaste/domain/post/event/PostDeletedEvent.kt +++ b/src/main/kotlin/com/albert/realmoneyrealtaste/domain/post/event/PostDeletedEvent.kt @@ -1,6 +1,11 @@ package com.albert.realmoneyrealtaste.domain.post.event +import java.time.LocalDateTime + data class PostDeletedEvent( - val postId: Long, + override val postId: Long, val authorMemberId: Long, -) + val occurredAt: LocalDateTime, +) : PostDomainEvent { + override fun withPostId(postId: Long): PostDeletedEvent = this.copy(postId = postId) +} diff --git a/src/main/kotlin/com/albert/realmoneyrealtaste/domain/post/event/PostDomainEvent.kt b/src/main/kotlin/com/albert/realmoneyrealtaste/domain/post/event/PostDomainEvent.kt new file mode 100644 index 00000000..ae9a43c4 --- /dev/null +++ b/src/main/kotlin/com/albert/realmoneyrealtaste/domain/post/event/PostDomainEvent.kt @@ -0,0 +1,9 @@ +package com.albert.realmoneyrealtaste.domain.post.event + +import com.albert.realmoneyrealtaste.domain.common.DomainEvent + +interface PostDomainEvent : DomainEvent { + val postId: Long + + fun withPostId(postId: Long): PostDomainEvent +} diff --git a/src/main/kotlin/com/albert/realmoneyrealtaste/domain/post/event/PostHeartAddedEvent.kt b/src/main/kotlin/com/albert/realmoneyrealtaste/domain/post/event/PostHeartAddedEvent.kt index f8bf30cc..524a6547 100644 --- a/src/main/kotlin/com/albert/realmoneyrealtaste/domain/post/event/PostHeartAddedEvent.kt +++ b/src/main/kotlin/com/albert/realmoneyrealtaste/domain/post/event/PostHeartAddedEvent.kt @@ -1,3 +1,8 @@ package com.albert.realmoneyrealtaste.domain.post.event -data class PostHeartAddedEvent(val postId: Long, val memberId: Long) +data class PostHeartAddedEvent( + override val postId: Long, + val memberId: Long, +) : PostDomainEvent { + override fun withPostId(postId: Long): PostHeartAddedEvent = this.copy(postId = postId) +} diff --git a/src/main/kotlin/com/albert/realmoneyrealtaste/domain/post/event/PostHeartRemovedEvent.kt b/src/main/kotlin/com/albert/realmoneyrealtaste/domain/post/event/PostHeartRemovedEvent.kt index 2fa9ebb4..83f574e4 100644 --- a/src/main/kotlin/com/albert/realmoneyrealtaste/domain/post/event/PostHeartRemovedEvent.kt +++ b/src/main/kotlin/com/albert/realmoneyrealtaste/domain/post/event/PostHeartRemovedEvent.kt @@ -1,3 +1,8 @@ package com.albert.realmoneyrealtaste.domain.post.event -data class PostHeartRemovedEvent(val postId: Long, val memberId: Long) +data class PostHeartRemovedEvent( + override val postId: Long, + val memberId: Long, +) : PostDomainEvent { + override fun withPostId(postId: Long): PostHeartRemovedEvent = this.copy(postId = postId) +} diff --git a/src/main/kotlin/com/albert/realmoneyrealtaste/domain/post/event/PostViewedEvent.kt b/src/main/kotlin/com/albert/realmoneyrealtaste/domain/post/event/PostViewedEvent.kt index f461585e..940dc6d9 100644 --- a/src/main/kotlin/com/albert/realmoneyrealtaste/domain/post/event/PostViewedEvent.kt +++ b/src/main/kotlin/com/albert/realmoneyrealtaste/domain/post/event/PostViewedEvent.kt @@ -1,3 +1,9 @@ package com.albert.realmoneyrealtaste.domain.post.event -data class PostViewedEvent(val postId: Long, val viewerMemberId: Long, val authorMemberId: Long) +data class PostViewedEvent( + override val postId: Long, + val viewerMemberId: Long, + val authorMemberId: Long, +) : PostDomainEvent { + override fun withPostId(postId: Long): PostViewedEvent = this.copy(postId = postId) +} diff --git a/src/main/kotlin/com/albert/realmoneyrealtaste/domain/post/value/Author.kt b/src/main/kotlin/com/albert/realmoneyrealtaste/domain/post/value/Author.kt index 2df222ac..78751626 100644 --- a/src/main/kotlin/com/albert/realmoneyrealtaste/domain/post/value/Author.kt +++ b/src/main/kotlin/com/albert/realmoneyrealtaste/domain/post/value/Author.kt @@ -16,6 +16,9 @@ data class Author( @Column(name = "author_introduction", nullable = false, length = MAX_INTRODUCTION_LENGTH) val introduction: String, + + @Column(name = "author_image_id", nullable = false) + val imageId: Long, ) { companion object { @@ -24,10 +27,13 @@ data class Author( const val ERROR_NICKNAME_BLANK = "작성자 닉네임은 필수입니다." const val ERROR_NICKNAME_LENGTH = "닉네임은 20자 이내여야 합니다." + + const val ERROR_IMAGE_ID_POSITIVE = "작성자 이미지 ID는 0보다 커야 합니다." } init { require(nickname.isNotBlank()) { ERROR_NICKNAME_BLANK } require(nickname.length <= MAX_NICKNAME_LENGTH) { ERROR_NICKNAME_LENGTH } + require(imageId > 0) { ERROR_IMAGE_ID_POSITIVE } } } diff --git a/src/main/resources/application-prod.yml b/src/main/resources/application-prod.yml index a1dcb7d0..400c42ca 100644 --- a/src/main/resources/application-prod.yml +++ b/src/main/resources/application-prod.yml @@ -84,12 +84,6 @@ logging: org.springframework.web: WARN org.hibernate.SQL: WARN root: WARN - file: - name: /app/logs/application.log - logback: - rollingpolicy: - max-file-size: 50MB - max-history: 30 # 애플리케이션 설정 app: diff --git a/src/main/resources/db/migration/V3__memberevent.sql b/src/main/resources/db/migration/V3__memberevent.sql new file mode 100644 index 00000000..ebf6c276 --- /dev/null +++ b/src/main/resources/db/migration/V3__memberevent.sql @@ -0,0 +1,50 @@ +-- v3__memberevent.sql + +-- 1. member_event 테이블 생성 +CREATE TABLE member_event +( + id BIGINT AUTO_INCREMENT NOT NULL, + member_id BIGINT NOT NULL, + event_type VARCHAR(30) NOT NULL, + title VARCHAR(100) NOT NULL, + message VARCHAR(500) NOT NULL, + related_member_id BIGINT NULL, + related_post_id BIGINT NULL, + related_comment_id BIGINT NULL, + is_read BIT(1) NOT NULL, + created_at datetime NOT NULL, + CONSTRAINT pk_member_event PRIMARY KEY (id) +); + +CREATE INDEX idx_member_event_member_id ON member_event (member_id); +CREATE INDEX idx_member_event_created_at ON member_event (created_at); +CREATE INDEX idx_member_event_event_type ON member_event (event_type); +CREATE INDEX idx_member_event_is_read ON member_event (is_read); +CREATE INDEX idx_member_event_member_unread ON member_event (member_id, is_read, created_at); + +-- 2. posts에 author_image_id 추가 +ALTER TABLE posts + ADD author_image_id BIGINT NULL; +UPDATE posts +SET author_image_id = 0 +WHERE author_image_id IS NULL; +ALTER TABLE posts + MODIFY author_image_id BIGINT NOT NULL; + +-- 3. friendships에 이미지 ID 추가 +ALTER TABLE friendships + ADD friend_image_id BIGINT NULL; +ALTER TABLE friendships + ADD image_id BIGINT NULL; + +UPDATE friendships +SET friend_image_id = 0 +WHERE friend_image_id IS NULL; +UPDATE friendships +SET image_id = 0 +WHERE image_id IS NULL; + +ALTER TABLE friendships + MODIFY friend_image_id BIGINT NOT NULL; +ALTER TABLE friendships + MODIFY image_id BIGINT NOT NULL; diff --git a/src/main/resources/templates/event/fragments/events.html b/src/main/resources/templates/event/fragments/events.html new file mode 100644 index 00000000..ffd9485b --- /dev/null +++ b/src/main/resources/templates/event/fragments/events.html @@ -0,0 +1,135 @@ + +
+ +
+
+
알림
+ 0 +
+ +
+ + + +
+
+ + +
    + +
  • +
    +
    + +
    +
    + +
    +
    + + +
    +
    +
    +

    이벤트 내용

    + 시간 +
    + + +
    + 새 알림 +
    +
    + + + +
    + + +
    +
    + +
    +
    +
    +
    +
  • +
    + + + +
    + +

    알림이 없습니다

    +
    +
    +
+ + +
+ +
+
+ diff --git a/src/main/resources/templates/index.html b/src/main/resources/templates/index.html index 96bd1d59..5063bf68 100644 --- a/src/main/resources/templates/index.html +++ b/src/main/resources/templates/index.html @@ -1,5 +1,6 @@ - + RMRT - 진짜 내돈내산 푸디 소셜 플랫폼 @@ -58,7 +59,8 @@
-
diff --git a/src/main/resources/templates/member/fragments/member-profile.html b/src/main/resources/templates/member/fragments/member-profile.html index 519e4385..e846f795 100644 --- a/src/main/resources/templates/member/fragments/member-profile.html +++ b/src/main/resources/templates/member/fragments/member-profile.html @@ -1,6 +1,6 @@
-
+
member profile @@ -39,7 +39,7 @@
추천 푸디
th:href="@{/members/{id}(id=${user.id})}" th:text="${user.nickname.value}">User Name

+ th:text="${user.introduction != null ? user.introduction : ''}"> User description

diff --git a/src/main/resources/templates/member/profile.html b/src/main/resources/templates/member/profile.html index 0e0ae140..666a69e8 100644 --- a/src/main/resources/templates/member/profile.html +++ b/src/main/resources/templates/member/profile.html @@ -110,7 +110,7 @@

  • - Food Reviewer at RMRT + Food Reviewer at RMRT
  • @@ -181,8 +181,15 @@

diff --git a/src/main/resources/templates/member/setting.html b/src/main/resources/templates/member/setting.html index 157edbf5..c42e473a 100644 --- a/src/main/resources/templates/member/setting.html +++ b/src/main/resources/templates/member/setting.html @@ -24,7 +24,7 @@
-
+
diff --git a/src/main/resources/templates/post/fragments/posts-content.html b/src/main/resources/templates/post/fragments/posts-content.html index 6f925cbc..28d9f47f 100644 --- a/src/main/resources/templates/post/fragments/posts-content.html +++ b/src/main/resources/templates/post/fragments/posts-content.html @@ -18,6 +18,7 @@
+ 아직 포스트가 없습니다 + th:src="@{'/api/images/' + ${post.author.imageId}}" + >
diff --git a/src/main/resources/templates/post/my-list.html b/src/main/resources/templates/post/my-list.html deleted file mode 100644 index 7ecc4e37..00000000 --- a/src/main/resources/templates/post/my-list.html +++ /dev/null @@ -1,336 +0,0 @@ - - - - RMRT - My Posts - - - - - - - - - - - - - -
- - - -
- - -
-
- - -
- -
- -
- - -
-
-
- -
- profile -
-
-
- -

- Sam Lanson - - - 나의 프로필 -

- - -
- 0 - followers - 0 - following -
-
- -
- -
- -
- - -
- - -
- - -
- -
- -
-
- - -
    -
  • - Food Reviewer at RMRT -
  • -
  • - - Seoul, South Korea -
  • -
  • - - Joined on Jan - 2020 -
  • -
-
- - - -
- - - -
- -
-
-

불러오는 중...

-
- - -
-
-
- -
- - - -
-
- -
-
- -
-
- - -
-
- -
- - - - - - - - - - - - - - - - - - - - diff --git a/src/test/kotlin/com/albert/realmoneyrealtaste/adapter/webapi/event/MemberEventApiTest.kt b/src/test/kotlin/com/albert/realmoneyrealtaste/adapter/webapi/event/MemberEventApiTest.kt new file mode 100644 index 00000000..7090a6f5 --- /dev/null +++ b/src/test/kotlin/com/albert/realmoneyrealtaste/adapter/webapi/event/MemberEventApiTest.kt @@ -0,0 +1,439 @@ +package com.albert.realmoneyrealtaste.adapter.webapi.event + +import com.albert.realmoneyrealtaste.IntegrationTestBase +import com.albert.realmoneyrealtaste.application.event.provided.MemberEventReader +import com.albert.realmoneyrealtaste.domain.event.MemberEventType +import com.albert.realmoneyrealtaste.util.MemberFixture +import com.albert.realmoneyrealtaste.util.TestEventHelper +import com.albert.realmoneyrealtaste.util.TestMemberHelper +import com.albert.realmoneyrealtaste.util.WithMockMember +import com.fasterxml.jackson.databind.ObjectMapper +import com.fasterxml.jackson.module.kotlin.readValue +import org.junit.jupiter.api.Test +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.http.MediaType +import org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf +import org.springframework.test.web.servlet.MockMvc +import org.springframework.test.web.servlet.get +import org.springframework.test.web.servlet.post +import kotlin.test.assertEquals + +class MemberEventApiTest : IntegrationTestBase() { + + @Autowired + private lateinit var memberEventReader: MemberEventReader + + @Autowired + private lateinit var mockMvc: MockMvc + + @Autowired + private lateinit var testEventHelper: TestEventHelper + + @Autowired + private lateinit var testMemberHelper: TestMemberHelper + + @WithMockMember(email = MemberFixture.DEFAULT_USERNAME) + @Test + fun `getMemberEvents - success - returns paginated events`() { + // Given + val member = testMemberHelper.getDefaultMember() + + // 테스트 이벤트 생성 + testEventHelper.createEvent( + memberId = member.requireId(), + eventType = MemberEventType.POST_CREATED, + title = "게시물 생성", + message = "새 게시물을 작성했습니다" + ) + testEventHelper.createEvent( + memberId = member.requireId(), + eventType = MemberEventType.COMMENT_CREATED, + title = "댓글 생성", + message = "댓글을 작성했습니다" + ) + + // When & Then + mockMvc.get("/api/v1/events") { + with(csrf()) + }.andExpect { + status { isOk() } + jsonPath("$.content") { exists() } + jsonPath("$.totalElements") { value(4) } + } + } + + @WithMockMember(email = MemberFixture.DEFAULT_USERNAME) + @Test + fun `getMemberEvents - success - respects pagination`() { + // Given + val member = testMemberHelper.getDefaultMember() + + // 5개 이벤트 생성 + repeat(5) { i -> + testEventHelper.createEvent( + memberId = member.requireId(), + eventType = MemberEventType.POST_CREATED, + title = "게시물 생성 $i", + message = "메시지 $i" + ) + } + + // When & Then - 첫 페이지 + mockMvc.get("/api/v1/events?page=0&size=2") { + with(csrf()) + }.andExpect { + status { isOk() } + jsonPath("$.content") { isArray() } + jsonPath("$.content.size()") { value(2) } + jsonPath("$.totalElements") { value(10) } + jsonPath("$.totalPages") { value(5) } + jsonPath("$.first") { value(true) } + jsonPath("$.last") { value(false) } + } + + // When & Then - 두 번째 페이지 + mockMvc.get("/api/v1/events?page=1&size=2") { + with(csrf()) + }.andExpect { + status { isOk() } + jsonPath("$.content.size()") { value(2) } + jsonPath("$.first") { value(false) } + jsonPath("$.last") { value(false) } + } + + // When & Then - 세 번째 페이지 + mockMvc.get("/api/v1/events?page=2&size=2") { + with(csrf()) + }.andExpect { + status { isOk() } + jsonPath("$.content.size()") { value(2) } + jsonPath("$.last") { value(false) } + } + + // When & Then - 마지막 페이지 + mockMvc.get("/api/v1/events?page=4&size=2") { + with(csrf()) + }.andExpect { + status { isOk() } + jsonPath("$.content.size()") { value(2) } + jsonPath("$.last") { value(true) } + } + } + + @WithMockMember(email = MemberFixture.DEFAULT_USERNAME) + @Test + fun `getMemberEventsByType - success - returns filtered events`() { + // Given + val member = testMemberHelper.getDefaultMember() + + // 다양한 타입의 이벤트 생성 + testEventHelper.createEvent( + memberId = member.requireId(), + eventType = MemberEventType.POST_CREATED, + title = "게시물 생성", + message = "게시물 생성" + ) + testEventHelper.createEvent( + memberId = member.requireId(), + eventType = MemberEventType.POST_CREATED, + title = "게시물 생성2", + message = "게시물 생성2" + ) + testEventHelper.createEvent( + memberId = member.requireId(), + eventType = MemberEventType.COMMENT_CREATED, + title = "댓글 생성", + message = "댓글 생성" + ) + + // When & Then - POST_CREATED 타입만 필터링 + mockMvc.get("/api/v1/events/type/POST_CREATED") { + with(csrf()) + }.andExpect { + status { isOk() } + jsonPath("$.content") { isArray() } + jsonPath("$.content.size()") { value(4) } + jsonPath("$.content[0].eventType") { value("POST_CREATED") } + jsonPath("$.content[1].eventType") { value("POST_CREATED") } + jsonPath("$.totalElements") { value(4) } + } + } + + @WithMockMember(email = MemberFixture.DEFAULT_USERNAME) + @Test + fun `getMemberEventsByType - success - works with different event types`() { + // Given + val member = testMemberHelper.getDefaultMember() + + testEventHelper.createEvent( + memberId = member.requireId(), + eventType = MemberEventType.FRIEND_REQUEST_SENT, + title = "친구 요청", + message = "친구 요청을 보냈습니다" + ) + + // When & Then - FRIEND_REQUEST_SENT 타입 + mockMvc.get("/api/v1/events/type/FRIEND_REQUEST_SENT") { + with(csrf()) + }.andExpect { + status { isOk() } + jsonPath("$.content.size()") { value(2) } + jsonPath("$.content[0].eventType") { value("FRIEND_REQUEST_SENT") } + } + } + + @WithMockMember(email = MemberFixture.DEFAULT_USERNAME) + @Test + fun `getUnreadEventCount - success - returns correct count`() { + // Given + val member = testMemberHelper.getDefaultMember() + + // 읽지 않은 이벤트 3개 생성 + repeat(3) { + testEventHelper.createEvent( + memberId = member.requireId(), + eventType = MemberEventType.POST_CREATED, + title = "게시물 생성", + message = "메시지", + ) + } + + // 읽은 이벤트 2개 생성 + repeat(2) { + testEventHelper.createEvent( + memberId = member.requireId(), + eventType = MemberEventType.COMMENT_CREATED, + title = "댓글 생성", + message = "메시지", + isRead = true, + ) + } + + // When & Then + val result = mockMvc.get("/api/v1/events/unread-count") { + with(csrf()) + }.andExpect { + status { isOk() } + }.andReturn().response.contentAsString + + val count = ObjectMapper().readValue(result) + assertEquals(10L, count) + } + + @WithMockMember(email = MemberFixture.DEFAULT_USERNAME) + @Test + fun `getUnreadEventCount - success - returns zero when no unread events`() { + // Given + val member = testMemberHelper.getDefaultMember() + + // 모두 읽은 상태의 이벤트 생성 + repeat(2) { + testEventHelper.createEvent( + memberId = member.requireId(), + eventType = MemberEventType.POST_CREATED, + title = "게시물 생성", + message = "메시지", + isRead = true, + ) + } + + // When & Then + val result = mockMvc.get("/api/v1/events/unread-count") { + with(csrf()) + }.andExpect { + status { isOk() } + }.andReturn().response.contentAsString + + val count = ObjectMapper().readValue(result) + assertEquals(4L, count) + } + + @WithMockMember(email = MemberFixture.DEFAULT_USERNAME) + @Test + fun `markAllAsRead - success - marks all events as read and returns count`() { + // Given + val member = testMemberHelper.getDefaultMember() + + // 읽지 않은 이벤트 5개 생성 + val events = repeat(5) { + testEventHelper.createEvent( + memberId = member.requireId(), + eventType = MemberEventType.POST_CREATED, + title = "게시물 생성", + message = "메시지", + ) + } + + // When & Then + val result = mockMvc.post("/api/v1/events/mark-all-read") { + with(csrf()) + contentType = MediaType.APPLICATION_JSON + }.andExpect { + status { isOk() } + }.andReturn().response.contentAsString + + val count = ObjectMapper().readValue(result) + assertEquals(10, count) + + // DB에서 모두 읽음 상태인지 확인 + val unreadCount = memberEventReader.readUnreadEventCount(member.requireId()) + assertEquals(0L, unreadCount) + } + + @WithMockMember(email = MemberFixture.DEFAULT_USERNAME) + @Test + fun `markAllAsRead - success - returns zero when no events to mark`() { + // When & Then + val result = mockMvc.post("/api/v1/events/mark-all-read") { + with(csrf()) + contentType = MediaType.APPLICATION_JSON + }.andExpect { + status { isOk() } + }.andReturn().response.contentAsString + + val count = ObjectMapper().readValue(result) + assertEquals(0, count) + } + + @WithMockMember(email = MemberFixture.DEFAULT_USERNAME) + @Test + fun `markAsRead - success - marks specific event as read`() { + // Given + val member = testMemberHelper.getDefaultMember() + + val event = testEventHelper.createEvent( + memberId = member.requireId(), + eventType = MemberEventType.POST_CREATED, + title = "게시물 생성", + message = "메시지", + ) + + // When & Then + val result = mockMvc.post("/api/v1/events/${event.requireId()}/read") { + with(csrf()) + contentType = MediaType.APPLICATION_JSON + }.andExpect { + status { isOk() } + jsonPath("$.id") { value(event.requireId()) } + jsonPath("$.isRead") { value(true) } + } + } + + @WithMockMember(email = MemberFixture.DEFAULT_USERNAME) + @Test + fun `markAsRead - error - returns 404 when event not found`() { + // When & Then + mockMvc.post("/api/v1/events/99999/read") { + with(csrf()) + contentType = MediaType.APPLICATION_JSON + }.andExpect { + status { isNotFound() } + } + } + + @WithMockMember(email = MemberFixture.DEFAULT_USERNAME) + @Test + fun `markAsRead - error - returns 403 when trying to mark other member's event`() { + // Given + val member1 = testMemberHelper.createActivatedMember("other@email.com") + + val event = testEventHelper.createEvent( + memberId = member1.requireId(), + eventType = MemberEventType.POST_CREATED, + title = "게시물 생성", + message = "메시지", + ) + + // When & Then - member2가 member1의 이벤트를 읽음으로 표시하려고 시도 + mockMvc.post("/api/v1/events/${event.requireId()}/read") { + with(csrf()) + contentType = MediaType.APPLICATION_JSON + }.andExpect { + status { isNotFound() } + } + } + + @Test + fun `all endpoints - error - require authentication`() { + // When & Then - 인증 없이 요청 + mockMvc.get("/api/v1/events").andExpect { + status { isForbidden() } + } + + mockMvc.get("/api/v1/events/type/POST_CREATED").andExpect { + status { isForbidden() } + } + + mockMvc.get("/api/v1/events/unread-count").andExpect { + status { isForbidden() } + } + + mockMvc.post("/api/v1/events/mark-all-read") { + with(csrf()) + }.andExpect { + status { isForbidden() } + } + + mockMvc.post("/api/v1/events/1/read") { + with(csrf()) + }.andExpect { + status { isForbidden() } + } + } + + @WithMockMember(email = MemberFixture.DEFAULT_USERNAME) + @Test + fun `POST endpoints - error - require CSRF protection`() { + // When & Then - CSRF 없이 POST 요청 + mockMvc.post("/api/v1/events/mark-all-read") { + contentType = MediaType.APPLICATION_JSON + }.andExpect { + status { isForbidden() } + } + + mockMvc.post("/api/v1/events/1/read") { + contentType = MediaType.APPLICATION_JSON + }.andExpect { + status { isForbidden() } + } + } + + @WithMockMember(email = MemberFixture.DEFAULT_USERNAME) + @Test + fun `getMemberEvents - success - returns empty page when no events`() { + // When & Then + mockMvc.get("/api/v1/events") { + with(csrf()) + }.andExpect { + status { isOk() } + jsonPath("$.content") { isArray() } + jsonPath("$.content.size()") { value(0) } + jsonPath("$.totalElements") { value(0) } + jsonPath("$.empty") { value(true) } + } + } + + @WithMockMember(email = MemberFixture.DEFAULT_USERNAME) + @Test + fun `getMemberEventsByType - success - returns empty page when no events of type`() { + // Given + val member = testMemberHelper.getDefaultMember() + + // 다른 타입의 이벤트만 생성 + testEventHelper.createEvent( + memberId = member.requireId(), + eventType = MemberEventType.POST_CREATED, + title = "게시물 생성", + message = "메시지" + ) + + // When & Then - COMMENT_CREATED 타입으로 조회 (없는 타입) + mockMvc.get("/api/v1/events/type/COMMENT_CREATED") { + with(csrf()) + }.andExpect { + status { isOk() } + jsonPath("$.content.size()") { value(0) } + jsonPath("$.totalElements") { value(0) } + } + } +} 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 6b3d49fd..66599b60 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 @@ -8,6 +8,7 @@ import com.albert.realmoneyrealtaste.domain.collection.PostCollection import com.albert.realmoneyrealtaste.domain.collection.command.CollectionCreateCommand import com.albert.realmoneyrealtaste.domain.collection.value.CollectionFilter import com.albert.realmoneyrealtaste.domain.member.Member +import com.albert.realmoneyrealtaste.domain.post.Post import com.albert.realmoneyrealtaste.util.MemberFixture import com.albert.realmoneyrealtaste.util.TestMemberHelper import com.albert.realmoneyrealtaste.util.TestPostHelper @@ -519,7 +520,7 @@ class CollectionReadViewTest : IntegrationTestBase() { private fun createPost( author: Member, - ): com.albert.realmoneyrealtaste.domain.post.Post { + ): Post { return testPostHelper.createPost(authorMemberId = author.requireId()) } } diff --git a/src/test/kotlin/com/albert/realmoneyrealtaste/adapter/webview/comment/CommentWriteViewTest.kt b/src/test/kotlin/com/albert/realmoneyrealtaste/adapter/webview/comment/CommentWriteViewTest.kt index 9faba5f6..c9f3e26e 100644 --- a/src/test/kotlin/com/albert/realmoneyrealtaste/adapter/webview/comment/CommentWriteViewTest.kt +++ b/src/test/kotlin/com/albert/realmoneyrealtaste/adapter/webview/comment/CommentWriteViewTest.kt @@ -209,7 +209,6 @@ class CommentWriteViewTest : IntegrationTestBase() { ) ) val comment = createAndSaveComment(post.requireId(), "원본 댓글 내용", member.requireId()) - flushAndClear() mockMvc.perform( post("/comments/{commentId}", comment.id) @@ -233,7 +232,6 @@ class CommentWriteViewTest : IntegrationTestBase() { ) val parentComment = createAndSaveComment(post.requireId(), "부모 댓글", member.requireId()) val replyComment = createAndSaveReply(post.requireId(), parentComment.id!!, "원본 대댓글", member.requireId()) - flushAndClear() mockMvc.perform( post("/comments/{commentId}", replyComment.id) @@ -255,8 +253,6 @@ class CommentWriteViewTest : IntegrationTestBase() { ) ) val comment = createAndSaveComment(post.requireId(), "댓글 내용", member.requireId()) - flushAndClear() - mockMvc.perform( post("/comments/{commentId}", comment.id) .with(csrf()) diff --git a/src/test/kotlin/com/albert/realmoneyrealtaste/adapter/webview/event/MemberEventViewTest.kt b/src/test/kotlin/com/albert/realmoneyrealtaste/adapter/webview/event/MemberEventViewTest.kt new file mode 100644 index 00000000..19a572a7 --- /dev/null +++ b/src/test/kotlin/com/albert/realmoneyrealtaste/adapter/webview/event/MemberEventViewTest.kt @@ -0,0 +1,281 @@ +package com.albert.realmoneyrealtaste.adapter.webview.event + +import com.albert.realmoneyrealtaste.IntegrationTestBase +import com.albert.realmoneyrealtaste.domain.event.MemberEventType +import com.albert.realmoneyrealtaste.util.MemberFixture +import com.albert.realmoneyrealtaste.util.TestEventHelper +import com.albert.realmoneyrealtaste.util.TestMemberHelper +import com.albert.realmoneyrealtaste.util.WithMockMember +import org.hamcrest.Matchers.equalTo +import org.hamcrest.Matchers.hasProperty +import org.junit.jupiter.api.Test +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.get +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +class MemberEventViewTest : IntegrationTestBase() { + + @Autowired + private lateinit var mockMvc: MockMvc + + @Autowired + private lateinit var testMemberHelper: TestMemberHelper + + @Autowired + private lateinit var testEventHelper: TestEventHelper + + @WithMockMember(email = MemberFixture.DEFAULT_USERNAME) + @Test + fun `eventsFragment - success - returns events fragment for own events`() { + // Given + val member = testMemberHelper.getDefaultMember() + + // 테스트 이벤트 생성 + testEventHelper.createEvent( + memberId = member.requireId(), + eventType = MemberEventType.POST_CREATED, + title = "게시물 생성", + message = "새 게시물을 작성했습니다" + ) + + // When & Then + mockMvc.get("/members/${member.requireId()}/events/fragment") { + with(csrf()) + param("eventType", "POST_CREATED") + }.andExpect { + status { isOk() } + view { name("event/fragments/events :: events") } + model { attributeExists("member") } + model { attributeExists("author") } + model { attributeExists("events") } + model { attributeExists("page") } + model { attributeExists("eventType") } + model { + attribute("member", hasProperty("id", equalTo(member.requireId()))) + } + model { + attribute("author", hasProperty("id", equalTo(member.requireId()))) + } + } + } + + @WithMockMember(email = MemberFixture.DEFAULT_USERNAME) + @Test + fun `eventsFragment - success - redirects when trying to view other member's events`() { + // Given + val member1 = testMemberHelper.getDefaultMember() + val member2 = testMemberHelper.createActivatedMember("other@email.com") + + // When & Then - member1이 member2의 이벤트를 보려고 시도 + mockMvc.get("/members/${member2.requireId()}/events/fragment") { + with(csrf()) + }.andExpect { + status { is3xxRedirection() } + redirectedUrl("/members/${member1.requireId()}/events/fragment") + } + } + + @WithMockMember(email = MemberFixture.DEFAULT_USERNAME) + @Test + fun `eventsFragment - success - filters events by valid type`() { + // Given + val member = testMemberHelper.getDefaultMember() + + // 다양한 타입의 이벤트 생성 + testEventHelper.createEvent( + memberId = member.requireId(), + eventType = MemberEventType.POST_CREATED, + title = "게시물 생성", + message = "게시물 생성" + ) + testEventHelper.createEvent( + memberId = member.requireId(), + eventType = MemberEventType.POST_CREATED, + title = "게시물 생성2", + message = "게시물 생성2" + ) + testEventHelper.createEvent( + memberId = member.requireId(), + eventType = MemberEventType.COMMENT_CREATED, + title = "댓글 생성", + message = "댓글 생성" + ) + + // When & Then - POST_CREATED 타입으로 필터링 + mockMvc.get("/members/${member.requireId()}/events/fragment?eventType=POST_CREATED") { + with(csrf()) + }.andExpect { + status { isOk() } + view { name("event/fragments/events :: events") } + model { attribute("eventType", "POST_CREATED") } + model { attributeExists("events") } + } + } + + @WithMockMember(email = MemberFixture.DEFAULT_USERNAME) + @Test + fun `eventsFragment - success - handles invalid eventType gracefully`() { + // Given + val member = testMemberHelper.getDefaultMember() + + testEventHelper.createEvent( + memberId = member.requireId(), + eventType = MemberEventType.POST_CREATED, + title = "게시물 생성", + message = "게시물 생성" + ) + + // When & Then - 잘못된 eventType으로 요청 + mockMvc.get("/members/${member.requireId()}/events/fragment?eventType=INVALID_TYPE") { + with(csrf()) + }.andExpect { + status { isOk() } + view { name("event/fragments/events :: events") } + model { attribute("eventType", "INVALID_TYPE") } + model { attributeExists("events") } + // 모든 이벤트가 반환되어야 함 (fallback 동작) + } + } + + @WithMockMember(email = MemberFixture.DEFAULT_USERNAME) + @Test + fun `eventsFragment - success - respects pagination parameters`() { + // Given + val member = testMemberHelper.getDefaultMember() + + // 5개 이벤트 생성 + repeat(5) { i -> + testEventHelper.createEvent( + memberId = member.requireId(), + eventType = MemberEventType.POST_CREATED, + title = "게시물 생성 $i", + message = "메시지 $i" + ) + } + + // When & Then - 페이지네이션 파라미터 적용 + mockMvc.get("/members/${member.requireId()}/events/fragment?page=0&size=2") { + with(csrf()) + }.andExpect { + status { isOk() } + view { name("event/fragments/events :: events") } + model { attributeExists("page") } + } + } + + @WithMockMember(email = MemberFixture.DEFAULT_USERNAME) + @Test + fun `eventsFragment - success - returns empty fragment when no events`() { + // Given + val member = testMemberHelper.getDefaultMember() + + // When & Then + mockMvc.get("/members/${member.requireId()}/events/fragment") { + with(csrf()) + }.andExpect { + status { isOk() } + view { name("event/fragments/events :: events") } + model { attributeExists("events") } + model { attribute("eventType", null) } + } + } + + @WithMockMember(email = MemberFixture.DEFAULT_USERNAME) + @Test + fun `eventsFragment - success - works with different event types`() { + // Given + val member = testMemberHelper.getDefaultMember() + + testEventHelper.createEvent( + memberId = member.requireId(), + eventType = MemberEventType.FRIEND_REQUEST_SENT, + title = "친구 요청", + message = "친구 요청을 보냈습니다" + ) + + // When & Then - FRIEND_REQUEST_SENT 타입 (소문자로 전달) + mockMvc.get("/members/${member.requireId()}/events/fragment?eventType=friend_request_sent") { + with(csrf()) + }.andExpect { + status { isOk() } + view { name("event/fragments/events :: events") } + model { attribute("eventType", "friend_request_sent") } + model { attributeExists("events") } + } + } + + @Test + fun `eventsFragment - failure - requires authentication`() { + // Given + val memberId = 1L + + // When & Then - 인증 없이 요청 + mockMvc.get("/members/$memberId/events/fragment") { + with(csrf()) + }.andExpect { + status { isForbidden() } + } + } + + @WithMockMember(email = MemberFixture.DEFAULT_USERNAME) + @Test + fun `eventsFragment - success - handles case insensitive eventType`() { + // Given + val member = testMemberHelper.getDefaultMember() + + testEventHelper.createEvent( + memberId = member.requireId(), + eventType = MemberEventType.POST_CREATED, + title = "게시물 생성", + message = "게시물 생성" + ) + + // When & Then - 소문자로 eventType 전달 + mockMvc.get("/members/${member.requireId()}/events/fragment?eventType=post_created") { + with(csrf()) + }.andExpect { + status { isOk() } + view { name("event/fragments/events :: events") } + model { attribute("eventType", "post_created") } + model { attributeExists("events") } + } + } + + @WithMockMember(email = MemberFixture.DEFAULT_USERNAME) + @Test + fun `eventsFragment - success - returns correct model attributes structure`() { + // Given + val member = testMemberHelper.getDefaultMember() + + testEventHelper.createEvent( + memberId = member.requireId(), + eventType = MemberEventType.POST_CREATED, + title = "게시물 생성", + message = "게시물 생성" + ) + + // When & Then + val result = mockMvc.get("/members/${member.requireId()}/events/fragment") { + with(csrf()) + }.andExpect { + status { isOk() } + view { name("event/fragments/events :: events") } + }.andReturn() + + val modelAndView = result.modelAndView + val model = modelAndView!!.modelMap + + // 모든 필수 속성이 있는지 확인 + assertTrue(model.containsAttribute("member")) + assertTrue(model.containsAttribute("author")) + assertTrue(model.containsAttribute("events")) + assertTrue(model.containsAttribute("page")) + assertTrue(model.containsAttribute("eventType")) + + // member와 author가 동일한 객체인지 확인 + assertEquals(model["member"], model["author"]) + } +} 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 index 518cd749..4014ec69 100644 --- a/src/test/kotlin/com/albert/realmoneyrealtaste/adapter/webview/member/MemberFragmentViewTest.kt +++ b/src/test/kotlin/com/albert/realmoneyrealtaste/adapter/webview/member/MemberFragmentViewTest.kt @@ -49,8 +49,5 @@ class MemberFragmentViewTest : IntegrationTestBase() { .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/post/PostViewTest.kt b/src/test/kotlin/com/albert/realmoneyrealtaste/adapter/webview/post/PostViewTest.kt index c0f5fc63..9d1b2dfe 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 @@ -568,34 +568,6 @@ class PostViewTest : IntegrationTestBase() { .andExpect(status().is4xxClientError) } - @Test - @WithMockMember(email = MemberFixture.DEFAULT_USERNAME) - fun `readMyPosts - success - returns my posts list view with posts and member info`() { - val member = testMemberHelper.getDefaultMember() - postRepository.save( - PostFixture.createPost( - authorMemberId = member.requireId(), - authorNickname = member.nickname.value - ) - ) - postRepository.save( - PostFixture.createPost( - authorMemberId = member.requireId(), - authorNickname = member.nickname.value - ) - ) - - mockMvc.perform( - get("/posts/mine") - ) - .andExpect(status().isOk) - .andExpect(view().name(PostViews.MY_LIST)) - .andExpect(model().attributeExists("posts")) - .andExpect(model().attributeExists("author")) - .andExpect(model().attributeExists("member")) - .andExpect(model().attributeExists("postCreateForm")) - } - @Test fun `readMyPosts - failure - returns forbidden when not authenticated`() { mockMvc.perform( @@ -663,40 +635,6 @@ class PostViewTest : IntegrationTestBase() { .andExpect(status().isOk) } - @Test - @WithMockMember(email = MemberFixture.DEFAULT_USERNAME) - fun `readMyPostsFragment - success - returns my posts fragment with posts and member info`() { - val member = testMemberHelper.getDefaultMember() - postRepository.save( - PostFixture.createPost( - authorMemberId = member.requireId(), - authorNickname = member.nickname.value - ) - ) - postRepository.save( - PostFixture.createPost( - authorMemberId = member.requireId(), - authorNickname = member.nickname.value - ) - ) - - mockMvc.perform( - get("/posts/mine/fragment") - ) - .andExpect(status().isOk) - .andExpect(view().name(PostViews.POSTS_CONTENT)) - .andExpect(model().attributeExists("posts")) - .andExpect(model().attributeExists("member")) - } - - @Test - fun `readMyPostsFragment - failure - returns forbidden when not authenticated`() { - mockMvc.perform( - get("/posts/mine/fragment") - ) - .andExpect(status().isForbidden) - } - @Test @WithMockMember(email = MemberFixture.DEFAULT_USERNAME) fun `readPosts - success - returns posts fragment with posts and member info`() { diff --git a/src/test/kotlin/com/albert/realmoneyrealtaste/application/comment/listener/CommentDomainEventListenerTest.kt b/src/test/kotlin/com/albert/realmoneyrealtaste/application/comment/listener/CommentDomainEventListenerTest.kt new file mode 100644 index 00000000..6f2934b6 --- /dev/null +++ b/src/test/kotlin/com/albert/realmoneyrealtaste/application/comment/listener/CommentDomainEventListenerTest.kt @@ -0,0 +1,272 @@ +package com.albert.realmoneyrealtaste.application.comment.listener + +import com.albert.realmoneyrealtaste.application.comment.required.CommentRepository +import com.albert.realmoneyrealtaste.domain.comment.event.CommentCreatedEvent +import com.albert.realmoneyrealtaste.domain.comment.event.CommentDeletedEvent +import com.albert.realmoneyrealtaste.domain.member.event.MemberProfileUpdatedDomainEvent +import io.mockk.impl.annotations.InjectMockKs +import io.mockk.impl.annotations.MockK +import io.mockk.junit5.MockKExtension +import io.mockk.verify +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.extension.ExtendWith +import java.time.LocalDateTime + +@ExtendWith(MockKExtension::class) +class CommentDomainEventListenerTest { + + @MockK(relaxed = true) + private lateinit var commentRepository: CommentRepository + + @InjectMockKs + private lateinit var commentDomainEventListener: CommentDomainEventListener + + @Test + fun `handleMemberProfileUpdated - success - updates author nickname`() { + // Given + val memberId = 1L + val nickname = "새로운닉네임" + val imageId = 999L + + val event = MemberProfileUpdatedDomainEvent( + memberId = memberId, + email = "test@example.com", + updatedFields = listOf("nickname"), + nickname = nickname, + imageId = imageId + ) + + // When + commentDomainEventListener.handleMemberProfileUpdated(event) + + // Then + verify(exactly = 1) { + commentRepository.updateAuthorNickname( + authorMemberId = memberId, + nickname = nickname + ) + } + } + + @Test + fun `handleMemberProfileUpdated - success - does not update when nickname is null`() { + // Given + val memberId = 1L + + val event = MemberProfileUpdatedDomainEvent( + memberId = memberId, + email = "test@example.com", + updatedFields = listOf("imageId"), + nickname = null, + imageId = 999L + ) + + // When + commentDomainEventListener.handleMemberProfileUpdated(event) + + // Then + verify(exactly = 0) { + commentRepository.updateAuthorNickname( + authorMemberId = any(), + nickname = any() + ) + } + } + + @Test + fun `handleCommentCreated - success - increments parent comment replies count`() { + // Given + val commentId = 1L + val postId = 2L + val authorMemberId = 3L + val parentCommentId = 10L + val parentCommentAuthorId = 4L + val createdAt = LocalDateTime.now() + + val event = CommentCreatedEvent( + commentId = commentId, + postId = postId, + authorMemberId = authorMemberId, + parentCommentId = parentCommentId, + parentCommentAuthorId = parentCommentAuthorId, + createdAt = createdAt + ) + + // When + commentDomainEventListener.handleCommentCreated(event) + + // Then + verify(exactly = 1) { + commentRepository.incrementRepliesCount(parentCommentId) + } + } + + @Test + fun `handleCommentCreated - success - does not increment for top level comment`() { + // Given + val commentId = 1L + val postId = 2L + val authorMemberId = 3L + val createdAt = LocalDateTime.now() + + val event = CommentCreatedEvent( + commentId = commentId, + postId = postId, + authorMemberId = authorMemberId, + parentCommentId = null, + parentCommentAuthorId = null, + createdAt = createdAt + ) + + // When + commentDomainEventListener.handleCommentCreated(event) + + // Then + verify(exactly = 0) { + commentRepository.incrementRepliesCount(any()) + } + } + + @Test + fun `handleCommentDeleted - success - decrements parent comment replies count`() { + // Given + val commentId = 1L + val postId = 2L + val authorMemberId = 3L + val parentCommentId = 10L + val deletedAt = LocalDateTime.now() + + val event = CommentDeletedEvent( + commentId = commentId, + postId = postId, + parentCommentId = parentCommentId, + authorMemberId = authorMemberId, + deletedAt = deletedAt + ) + + // When + commentDomainEventListener.handleCommentDeleted(event) + + // Then + verify(exactly = 1) { + commentRepository.decrementRepliesCount(parentCommentId) + } + } + + @Test + fun `handleCommentDeleted - success - does not decrement for top level comment`() { + // Given + val commentId = 1L + val postId = 2L + val authorMemberId = 3L + val deletedAt = LocalDateTime.now() + + val event = CommentDeletedEvent( + commentId = commentId, + postId = postId, + parentCommentId = null, + authorMemberId = authorMemberId, + deletedAt = deletedAt + ) + + // When + commentDomainEventListener.handleCommentDeleted(event) + + // Then + verify(exactly = 0) { + commentRepository.decrementRepliesCount(any()) + } + } + + @Test + fun `integration - all event handlers work correctly`() { + // Given + val memberId = 1L + val parentCommentId = 10L + + val profileEvent = MemberProfileUpdatedDomainEvent( + memberId = memberId, + email = "test@example.com", + updatedFields = listOf("nickname"), + nickname = "통합테스트닉네임", + imageId = null + ) + + val commentCreatedEvent = CommentCreatedEvent( + commentId = 1L, + postId = 2L, + authorMemberId = 3L, + parentCommentId = parentCommentId, + parentCommentAuthorId = 4L, + createdAt = LocalDateTime.now() + ) + + val commentDeletedEvent = CommentDeletedEvent( + commentId = 2L, + postId = 2L, + parentCommentId = parentCommentId, + authorMemberId = 5L, + deletedAt = LocalDateTime.now() + ) + + val nullNicknameEvent = MemberProfileUpdatedDomainEvent( + memberId = 2L, + email = "test2@example.com", + updatedFields = listOf("imageId"), + nickname = null, + imageId = 999L + ) + + // When + commentDomainEventListener.handleMemberProfileUpdated(profileEvent) + commentDomainEventListener.handleCommentCreated(commentCreatedEvent) + commentDomainEventListener.handleCommentDeleted(commentDeletedEvent) + commentDomainEventListener.handleMemberProfileUpdated(nullNicknameEvent) + + // Then + verify(exactly = 1) { + commentRepository.updateAuthorNickname( + authorMemberId = memberId, + nickname = "통합테스트닉네임" + ) + } + + verify(exactly = 1) { + commentRepository.incrementRepliesCount(parentCommentId) + } + + verify(exactly = 1) { + commentRepository.decrementRepliesCount(parentCommentId) + } + + // null 닉네임 이벤트는 updateAuthorNickname 호출하지 않음 + verify(exactly = 0) { + commentRepository.updateAuthorNickname( + authorMemberId = 2L, + nickname = any() + ) + } + } + + @Test + fun `integration - top level comments do not affect replies count`() { + // Given + val topLevelCommentEvent = CommentCreatedEvent( + commentId = 3L, + postId = 3L, + authorMemberId = 6L, + parentCommentId = null, + parentCommentAuthorId = null, + createdAt = LocalDateTime.now() + ) + + // When + commentDomainEventListener.handleCommentCreated(topLevelCommentEvent) + + // Then + // 최상위 댓글 이벤트는 incrementRepliesCount 호출하지 않음 + verify(exactly = 0) { + commentRepository.incrementRepliesCount(any()) + } + } +} diff --git a/src/test/kotlin/com/albert/realmoneyrealtaste/application/event/listener/MemberEventDomainEventListenerTest.kt b/src/test/kotlin/com/albert/realmoneyrealtaste/application/event/listener/MemberEventDomainEventListenerTest.kt new file mode 100644 index 00000000..38d5392d --- /dev/null +++ b/src/test/kotlin/com/albert/realmoneyrealtaste/application/event/listener/MemberEventDomainEventListenerTest.kt @@ -0,0 +1,587 @@ +package com.albert.realmoneyrealtaste.application.event.listener + +import com.albert.realmoneyrealtaste.application.event.MemberEventCreationService +import com.albert.realmoneyrealtaste.domain.comment.event.CommentCreatedEvent +import com.albert.realmoneyrealtaste.domain.comment.event.CommentDeletedEvent +import com.albert.realmoneyrealtaste.domain.event.MemberEventType +import com.albert.realmoneyrealtaste.domain.friend.event.FriendRequestAcceptedEvent +import com.albert.realmoneyrealtaste.domain.friend.event.FriendRequestRejectedEvent +import com.albert.realmoneyrealtaste.domain.friend.event.FriendRequestSentEvent +import com.albert.realmoneyrealtaste.domain.friend.event.FriendshipTerminatedEvent +import com.albert.realmoneyrealtaste.domain.member.event.MemberActivatedDomainEvent +import com.albert.realmoneyrealtaste.domain.member.event.MemberDeactivatedDomainEvent +import com.albert.realmoneyrealtaste.domain.member.event.MemberProfileUpdatedDomainEvent +import com.albert.realmoneyrealtaste.domain.post.event.PostCreatedEvent +import com.albert.realmoneyrealtaste.domain.post.event.PostDeletedEvent +import io.mockk.impl.annotations.InjectMockKs +import io.mockk.impl.annotations.MockK +import io.mockk.junit5.MockKExtension +import io.mockk.verify +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.extension.ExtendWith +import java.time.LocalDateTime + +@ExtendWith(MockKExtension::class) +class MemberEventDomainEventListenerTest { + + @MockK(relaxed = true) + private lateinit var memberEventCreationService: MemberEventCreationService + + @InjectMockKs + private lateinit var memberEventDomainEventListener: MemberEventDomainEventListener + + // ===== 친구 관련 이벤트 테스트 ===== + + @Test + fun `handleFriendRequestSent - success - creates events for both members`() { + // Given + val fromMemberId = 1L + val toMemberId = 2L + + val event = FriendRequestSentEvent( + friendshipId = 10L, + fromMemberId = fromMemberId, + toMemberId = toMemberId, + occurredAt = LocalDateTime.now(), + ) + + // When + memberEventDomainEventListener.handleFriendRequestSent(event) + + // Then + verify(exactly = 1) { + memberEventCreationService.createEvent( + memberId = fromMemberId, + eventType = MemberEventType.FRIEND_REQUEST_SENT, + title = "친구 요청을 보냈습니다", + message = "${toMemberId}님에게 친구 요청을 보냈습니다.", + relatedMemberId = toMemberId + ) + } + + verify(exactly = 1) { + memberEventCreationService.createEvent( + memberId = toMemberId, + eventType = MemberEventType.FRIEND_REQUEST_RECEIVED, + title = "친구 요청을 받았습니다", + message = "${fromMemberId}님이 친구 요청을 보냈습니다.", + relatedMemberId = fromMemberId + ) + } + } + + @Test + fun `handleFriendRequestAccepted - success - creates events for both members`() { + // Given + val fromMemberId = 1L + val toMemberId = 2L + + val event = FriendRequestAcceptedEvent( + friendshipId = 10L, + fromMemberId = fromMemberId, + toMemberId = toMemberId, + occurredAt = LocalDateTime.now() + ) + + // When + memberEventDomainEventListener.handleFriendRequestAccepted(event) + + // Then + verify(exactly = 1) { + memberEventCreationService.createEvent( + memberId = fromMemberId, + eventType = MemberEventType.FRIEND_REQUEST_ACCEPTED, + title = "친구 요청이 수락되었습니다", + message = "${toMemberId}님이 친구 요청을 수락했습니다.", + relatedMemberId = toMemberId + ) + } + + verify(exactly = 1) { + memberEventCreationService.createEvent( + memberId = toMemberId, + eventType = MemberEventType.FRIEND_REQUEST_ACCEPTED, + title = "친구 요청을 수락했습니다", + message = "${fromMemberId}님의 친구 요청을 수락했습니다.", + relatedMemberId = fromMemberId + ) + } + } + + @Test + fun `handleFriendRequestRejected - success - creates event for requester`() { + // Given + val fromMemberId = 1L + val toMemberId = 2L + + val event = FriendRequestRejectedEvent( + friendshipId = 10L, + fromMemberId = fromMemberId, + toMemberId = toMemberId, + ) + + // When + memberEventDomainEventListener.handleFriendRequestRejected(event) + + // Then + verify(exactly = 1) { + memberEventCreationService.createEvent( + memberId = fromMemberId, + eventType = MemberEventType.FRIEND_REQUEST_REJECTED, + title = "친구 요청이 거절되었습니다", + message = "${toMemberId}님이 친구 요청을 거절했습니다.", + relatedMemberId = toMemberId + ) + } + } + + @Test + fun `handleFriendshipTerminated - success - creates event for member`() { + // Given + val memberId = 1L + val friendMemberId = 2L + + val event = FriendshipTerminatedEvent( + friendshipId = 10L, + memberId = memberId, + friendMemberId = friendMemberId, + occurredAt = LocalDateTime.now() + ) + + // When + memberEventDomainEventListener.handleFriendshipTerminated(event) + + // Then + verify(exactly = 1) { + memberEventCreationService.createEvent( + memberId = memberId, + eventType = MemberEventType.FRIENDSHIP_TERMINATED, + title = "친구 관계가 해제되었습니다", + message = "${friendMemberId}님과의 친구 관계가 해제되었습니다.", + relatedMemberId = friendMemberId + ) + } + } + + // ===== 게시물 관련 이벤트 테스트 ===== + + @Test + fun `handlePostCreated - success - creates event for author`() { + // Given + val postId = 1L + val authorMemberId = 2L + + val event = PostCreatedEvent( + postId = postId, + authorMemberId = authorMemberId, + restaurantName = "맛있는집", + occurredAt = LocalDateTime.now() + ) + + // When + memberEventDomainEventListener.handlePostCreated(event) + + // Then + verify(exactly = 1) { + memberEventCreationService.createEvent( + memberId = authorMemberId, + eventType = MemberEventType.POST_CREATED, + title = "새 게시물을 작성했습니다", + message = "새로운 맛집 게시물을 작성했습니다.", + relatedPostId = postId + ) + } + } + + @Test + fun `handlePostDeleted - success - creates event for author`() { + // Given + val postId = 1L + val authorMemberId = 2L + + val event = PostDeletedEvent( + postId = postId, + authorMemberId = authorMemberId, + occurredAt = LocalDateTime.now() + ) + + // When + memberEventDomainEventListener.handlePostDeleted(event) + + // Then + verify(exactly = 1) { + memberEventCreationService.createEvent( + memberId = authorMemberId, + eventType = MemberEventType.POST_DELETED, + title = "게시물을 삭제했습니다", + message = "게시물을 삭제했습니다.", + relatedPostId = postId + ) + } + } + + // ===== 댓글 관련 이벤트 테스트 ===== + + @Test + fun `handleCommentCreated - success - creates event for top level comment`() { + // Given + val commentId = 1L + val postId = 2L + val authorMemberId = 3L + + val event = CommentCreatedEvent( + commentId = commentId, + postId = postId, + authorMemberId = authorMemberId, + parentCommentId = null, + parentCommentAuthorId = null, + createdAt = LocalDateTime.now() + ) + + // When + memberEventDomainEventListener.handleCommentCreated(event) + + // Then + verify(exactly = 1) { + memberEventCreationService.createEvent( + memberId = authorMemberId, + eventType = MemberEventType.COMMENT_CREATED, + title = "댓글을 작성했습니다", + message = "댓글을 작성했습니다.", + relatedPostId = postId, + relatedCommentId = commentId + ) + } + + // 부모 댓글 작성자에게는 이벤트 생성되지 않아야 함 + verify(exactly = 0) { + memberEventCreationService.createEvent( + memberId = any(), + eventType = MemberEventType.COMMENT_REPLIED, + title = any(), + message = any(), + relatedPostId = any(), + relatedCommentId = any() + ) + } + } + + @Test + fun `handleCommentCreated - success - creates events for reply to different author`() { + // Given + val commentId = 1L + val postId = 2L + val authorMemberId = 3L + val parentCommentId = 10L + val parentCommentAuthorId = 4L + + val event = CommentCreatedEvent( + commentId = commentId, + postId = postId, + authorMemberId = authorMemberId, + parentCommentId = parentCommentId, + parentCommentAuthorId = parentCommentAuthorId, + createdAt = LocalDateTime.now() + ) + + // When + memberEventDomainEventListener.handleCommentCreated(event) + + // Then + // 댓글 작성자 이벤트 + verify(exactly = 1) { + memberEventCreationService.createEvent( + memberId = authorMemberId, + eventType = MemberEventType.COMMENT_CREATED, + title = "댓글을 작성했습니다", + message = "댓글을 작성했습니다.", + relatedPostId = postId, + relatedCommentId = commentId + ) + } + + // 부모 댓글 작성자에게 알림 이벤트 + verify(exactly = 1) { + memberEventCreationService.createEvent( + memberId = parentCommentAuthorId, + eventType = MemberEventType.COMMENT_REPLIED, + title = "대댓글이 달렸습니다", + message = "댓글에 대댓글이 달렸습니다.", + relatedPostId = postId, + relatedCommentId = parentCommentId + ) + } + } + + @Test + fun `handleCommentCreated - success - creates single event for reply to self`() { + // Given + val commentId = 1L + val postId = 2L + val authorMemberId = 3L + val parentCommentId = 10L + + val event = CommentCreatedEvent( + commentId = commentId, + postId = postId, + authorMemberId = authorMemberId, + parentCommentId = parentCommentId, + parentCommentAuthorId = authorMemberId, // 자신의 댓글에 답글 + createdAt = LocalDateTime.now() + ) + + // When + memberEventDomainEventListener.handleCommentCreated(event) + + // Then + // 댓글 작성자 이벤트만 생성 + verify(exactly = 1) { + memberEventCreationService.createEvent( + memberId = authorMemberId, + eventType = MemberEventType.COMMENT_CREATED, + title = "댓글을 작성했습니다", + message = "댓글을 작성했습니다.", + relatedPostId = postId, + relatedCommentId = commentId + ) + } + + // 자신에게는 알림 이벤트 생성되지 않아야 함 + verify(exactly = 0) { + memberEventCreationService.createEvent( + memberId = authorMemberId, + eventType = MemberEventType.COMMENT_REPLIED, + title = any(), + message = any(), + relatedPostId = any(), + relatedCommentId = any() + ) + } + } + + @Test + fun `handleCommentDeleted - success - creates event for author`() { + // Given + val commentId = 1L + val postId = 2L + val authorMemberId = 3L + + val event = CommentDeletedEvent( + commentId = commentId, + postId = postId, + parentCommentId = null, + authorMemberId = authorMemberId, + deletedAt = LocalDateTime.now() + ) + + // When + memberEventDomainEventListener.handleCommentDeleted(event) + + // Then + verify(exactly = 1) { + memberEventCreationService.createEvent( + memberId = authorMemberId, + eventType = MemberEventType.COMMENT_DELETED, + title = "댓글을 삭제했습니다", + message = "댓글을 삭제했습니다.", + relatedPostId = postId, + relatedCommentId = commentId + ) + } + } + + // ===== 회원 관련 이벤트 테스트 ===== + + @Test + fun `handleMemberActivated - success - creates activation event`() { + // Given + val memberId = 1L + val email = "example@email.com" + + val event = MemberActivatedDomainEvent( + memberId = memberId, + email = email, + nickname = "example", + ) + + // When + memberEventDomainEventListener.handleMemberActivated(event) + + // Then + verify(exactly = 1) { + memberEventCreationService.createEvent( + memberId = memberId, + eventType = MemberEventType.ACCOUNT_ACTIVATED, + title = "계정이 활성화되었습니다", + message = "회원님의 계정이 성공적으로 활성화되었습니다." + ) + } + } + + @Test + fun `handleMemberDeactivated - success - creates deactivation event`() { + // Given + val memberId = 1L + + val event = MemberDeactivatedDomainEvent( + memberId = memberId, + occurredAt = LocalDateTime.now(), + ) + + // When + memberEventDomainEventListener.handleMemberDeactivated(event) + + // Then + verify(exactly = 1) { + memberEventCreationService.createEvent( + memberId = memberId, + eventType = MemberEventType.ACCOUNT_DEACTIVATED, + title = "계정이 비활성화되었습니다", + message = "회원님의 계정이 비활성화되었습니다." + ) + } + } + + @Test + fun `handleMemberProfileUpdated - success - creates profile update event`() { + // Given + val memberId = 1L + + val event = MemberProfileUpdatedDomainEvent( + memberId = memberId, + email = "test@example.com", + updatedFields = listOf("nickname"), + nickname = "새닉네임", + imageId = null + ) + + // When + memberEventDomainEventListener.handleMemberProfileUpdated(event) + + // Then + verify(exactly = 1) { + memberEventCreationService.createEvent( + memberId = memberId, + eventType = MemberEventType.PROFILE_UPDATED, + title = "프로필이 업데이트되었습니다", + message = "회원님의 프로필 정보가 업데이트되었습니다." + ) + } + } + + // ===== 통합 테스트 ===== + + @Test + fun `integration - multiple event handlers work correctly`() { + // Given + val memberId = 1L + val friendId = 2L + val postId = 3L + val commentId = 4L + val parentCommentId = 5L + val email = "example@email.com" + val nickname = "example" + + val friendRequestEvent = FriendRequestSentEvent( + friendshipId = 10L, + fromMemberId = memberId, + toMemberId = friendId, + occurredAt = LocalDateTime.now() + ) + + val postCreatedEvent = PostCreatedEvent( + postId = postId, + authorMemberId = memberId, + restaurantName = "통합테스트", + occurredAt = LocalDateTime.now() + ) + + val commentCreatedEvent = CommentCreatedEvent( + commentId = commentId, + postId = postId, + authorMemberId = memberId, + parentCommentId = parentCommentId, + parentCommentAuthorId = friendId, + createdAt = LocalDateTime.now() + ) + + val memberActivatedEvent = MemberActivatedDomainEvent( + memberId = memberId, + email = email, + nickname = nickname, + occurredAt = LocalDateTime.now() + ) + + // When + memberEventDomainEventListener.handleFriendRequestSent(friendRequestEvent) + memberEventDomainEventListener.handlePostCreated(postCreatedEvent) + memberEventDomainEventListener.handleCommentCreated(commentCreatedEvent) + memberEventDomainEventListener.handleMemberActivated(memberActivatedEvent) + + // Then + // 친구 요청 이벤트 2개 + verify(exactly = 1) { + memberEventCreationService.createEvent( + memberId = memberId, + eventType = MemberEventType.FRIEND_REQUEST_SENT, + title = "친구 요청을 보냈습니다", + message = "${friendId}님에게 친구 요청을 보냈습니다.", + relatedMemberId = friendId + ) + } + + verify(exactly = 1) { + memberEventCreationService.createEvent( + memberId = friendId, + eventType = MemberEventType.FRIEND_REQUEST_RECEIVED, + title = "친구 요청을 받았습니다", + message = "${memberId}님이 친구 요청을 보냈습니다.", + relatedMemberId = memberId + ) + } + + // 게시물 생성 이벤트 1개 + verify(exactly = 1) { + memberEventCreationService.createEvent( + memberId = memberId, + eventType = MemberEventType.POST_CREATED, + title = "새 게시물을 작성했습니다", + message = "새로운 맛집 게시물을 작성했습니다.", + relatedPostId = postId + ) + } + + // 댓글 관련 이벤트 2개 (작성자 + 부모 댓글 작성자) + verify(exactly = 1) { + memberEventCreationService.createEvent( + memberId = memberId, + eventType = MemberEventType.COMMENT_CREATED, + title = "댓글을 작성했습니다", + message = "댓글을 작성했습니다.", + relatedPostId = postId, + relatedCommentId = commentId + ) + } + + verify(exactly = 1) { + memberEventCreationService.createEvent( + memberId = friendId, + eventType = MemberEventType.COMMENT_REPLIED, + title = "대댓글이 달렸습니다", + message = "댓글에 대댓글이 달렸습니다.", + relatedPostId = postId, + relatedCommentId = parentCommentId + ) + } + + // 회원 활성화 이벤트 1개 + verify(exactly = 1) { + memberEventCreationService.createEvent( + memberId = memberId, + eventType = MemberEventType.ACCOUNT_ACTIVATED, + title = "계정이 활성화되었습니다", + message = "회원님의 계정이 성공적으로 활성화되었습니다." + ) + } + } +} diff --git a/src/test/kotlin/com/albert/realmoneyrealtaste/application/event/provided/MemberEventCreatorTest.kt b/src/test/kotlin/com/albert/realmoneyrealtaste/application/event/provided/MemberEventCreatorTest.kt new file mode 100644 index 00000000..7e26abdc --- /dev/null +++ b/src/test/kotlin/com/albert/realmoneyrealtaste/application/event/provided/MemberEventCreatorTest.kt @@ -0,0 +1,298 @@ +package com.albert.realmoneyrealtaste.application.event.provided + +import com.albert.realmoneyrealtaste.IntegrationTestBase +import com.albert.realmoneyrealtaste.domain.event.MemberEventType +import java.time.LocalDateTime +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertNotNull +import kotlin.test.assertTrue + +class MemberEventCreatorTest( + val memberEventCreator: MemberEventCreator, +) : IntegrationTestBase() { + + @Test + fun `createEvent - success - creates friend request sent event`() { + val memberId = 1L + val eventType = MemberEventType.FRIEND_REQUEST_SENT + val title = "친구 요청 알림" + val message = "홍길동님이 친구 요청을 보냈습니다" + val relatedMemberId = 2L + + val event = memberEventCreator.createEvent( + memberId = memberId, + eventType = eventType, + title = title, + message = message, + relatedMemberId = relatedMemberId + ) + + assertNotNull(event) + assertEquals(memberId, event.memberId) + assertEquals(eventType, event.eventType) + assertEquals(title, event.title) + assertEquals(message, event.message) + assertEquals(relatedMemberId, event.relatedMemberId) + assertTrue(event.requireId() > 0) // 저장된 경우 ID가 할당됨 + } + + @Test + fun `createEvent - success - creates post created event with all related IDs`() { + val memberId = 1L + val eventType = MemberEventType.POST_COMMENTED + val title = "댓글 알림" + val message = "게시물에 댓글이 달렸습니다" + val relatedMemberId = 2L + val relatedPostId = 100L + val relatedCommentId = 200L + + val event = memberEventCreator.createEvent( + memberId = memberId, + eventType = eventType, + title = title, + message = message, + relatedMemberId = relatedMemberId, + relatedPostId = relatedPostId, + relatedCommentId = relatedCommentId + ) + + assertNotNull(event) + assertEquals(memberId, event.memberId) + assertEquals(eventType, event.eventType) + assertEquals(title, event.title) + assertEquals(message, event.message) + assertEquals(relatedMemberId, event.relatedMemberId) + assertEquals(relatedPostId, event.relatedPostId) + assertEquals(relatedCommentId, event.relatedCommentId) + assertTrue(event.requireId() > 0) + } + + @Test + fun `createEvent - success - creates account activated event without related IDs`() { + val memberId = 1L + val eventType = MemberEventType.ACCOUNT_ACTIVATED + val title = "계정 활성화" + val message = "계정이 활성화되었습니다" + + val event = memberEventCreator.createEvent( + memberId = memberId, + eventType = eventType, + title = title, + message = message + ) + + assertNotNull(event) + assertEquals(memberId, event.memberId) + assertEquals(eventType, event.eventType) + assertEquals(title, event.title) + assertEquals(message, event.message) + assertEquals(null, event.relatedMemberId) + assertEquals(null, event.relatedPostId) + assertEquals(null, event.relatedCommentId) + assertTrue(event.requireId() > 0) + } + + @Test + fun `createEvent - success - handles Korean characters in title and message`() { + val memberId = 1L + val eventType = MemberEventType.PROFILE_UPDATED + val title = "프로필 업데이트 알림" + val message = "김철수님이 프로필을 업데이트했습니다" + + val event = memberEventCreator.createEvent( + memberId = memberId, + eventType = eventType, + title = title, + message = message + ) + + assertNotNull(event) + assertEquals(title, event.title) + assertEquals(message, event.message) + assertTrue(event.requireId() > 0) + } + + @Test + fun `createEvent - success - handles special characters in title and message`() { + val memberId = 1L + val eventType = MemberEventType.POST_CREATED + val title = "New Post! @#$%^&*()" + val message = "Special chars: 안녕~ Hello! @#$% &*()" + + val event = memberEventCreator.createEvent( + memberId = memberId, + eventType = eventType, + title = title, + message = message + ) + + assertNotNull(event) + assertEquals(title, event.title) + assertEquals(message, event.message) + assertTrue(event.requireId() > 0) + } + + @Test + fun `createEvent - success - creates multiple events for same member`() { + val memberId = 1L + + // 첫 번째 이벤트 + val event1 = memberEventCreator.createEvent( + memberId = memberId, + eventType = MemberEventType.FRIEND_REQUEST_SENT, + title = "첫 번째 알림", + message = "첫 번째 메시지" + ) + + // 두 번째 이벤트 + val event2 = memberEventCreator.createEvent( + memberId = memberId, + eventType = MemberEventType.FRIEND_REQUEST_RECEIVED, + title = "두 번째 알림", + message = "두 번째 메시지" + ) + + assertNotNull(event1) + assertNotNull(event2) + assertEquals(memberId, event1.memberId) + assertEquals(memberId, event2.memberId) + assertTrue(event1.requireId() > 0) + assertTrue(event2.requireId() > 0) + assertTrue(event1.requireId() != event2.requireId()) // 다른 ID를 가져야 함 + } + + @Test + fun `createEvent - success - handles all event types`() { + val memberId = 1L + + MemberEventType.entries.forEach { eventType -> + val event = memberEventCreator.createEvent( + memberId = memberId, + eventType = eventType, + title = "테스트 제목", + message = "테스트 메시지" + ) + + assertNotNull(event) + assertEquals(eventType, event.eventType) + assertTrue(event.requireId() > 0) + } + } + + @Test + fun `createEvent - success - isRead is false by default`() { + val event = memberEventCreator.createEvent( + memberId = 1L, + eventType = MemberEventType.FRIEND_REQUEST_SENT, + title = "제목", + message = "메시지" + ) + + assertNotNull(event) + assertEquals(false, event.isRead) + } + + @Test + fun `createEvent - success - createdAt is set automatically`() { + val before = LocalDateTime.now() + + val event = memberEventCreator.createEvent( + memberId = 1L, + eventType = MemberEventType.FRIEND_REQUEST_SENT, + title = "제목", + message = "메시지" + ) + + val after = LocalDateTime.now() + + assertNotNull(event) + assertTrue(event.createdAt >= before) + assertTrue(event.createdAt <= after) + } + + @Test + fun `createEvent - failure - throws exception when memberId is zero`() { + assertFailsWith { + memberEventCreator.createEvent( + memberId = 0L, + eventType = MemberEventType.FRIEND_REQUEST_SENT, + title = "제목", + message = "메시지" + ) + }.let { + assertEquals("회원 ID는 양수여야 합니다", it.message) + } + } + + @Test + fun `createEvent - failure - throws exception when memberId is negative`() { + assertFailsWith { + memberEventCreator.createEvent( + memberId = -1L, + eventType = MemberEventType.FRIEND_REQUEST_SENT, + title = "제목", + message = "메시지" + ) + }.let { + assertEquals("회원 ID는 양수여야 합니다", it.message) + } + } + + @Test + fun `createEvent - failure - throws exception when title is empty`() { + assertFailsWith { + memberEventCreator.createEvent( + memberId = 1L, + eventType = MemberEventType.FRIEND_REQUEST_SENT, + title = "", + message = "메시지" + ) + }.let { + assertEquals("제목은 비어있을 수 없습니다", it.message) + } + } + + @Test + fun `createEvent - failure - throws exception when title is blank`() { + assertFailsWith { + memberEventCreator.createEvent( + memberId = 1L, + eventType = MemberEventType.FRIEND_REQUEST_SENT, + title = " ", + message = "메시지" + ) + }.let { + assertEquals("제목은 비어있을 수 없습니다", it.message) + } + } + + @Test + fun `createEvent - failure - throws exception when message is empty`() { + assertFailsWith { + memberEventCreator.createEvent( + memberId = 1L, + eventType = MemberEventType.FRIEND_REQUEST_SENT, + title = "제목", + message = "" + ) + }.let { + assertEquals("메시지는 비어있을 수 없습니다", it.message) + } + } + + @Test + fun `createEvent - failure - throws exception when message is blank`() { + assertFailsWith { + memberEventCreator.createEvent( + memberId = 1L, + eventType = MemberEventType.FRIEND_REQUEST_SENT, + title = "제목", + message = "\t\n\r " + ) + }.let { + assertEquals("메시지는 비어있을 수 없습니다", it.message) + } + } +} diff --git a/src/test/kotlin/com/albert/realmoneyrealtaste/application/event/provided/MemberEventReaderTest.kt b/src/test/kotlin/com/albert/realmoneyrealtaste/application/event/provided/MemberEventReaderTest.kt new file mode 100644 index 00000000..70fcee95 --- /dev/null +++ b/src/test/kotlin/com/albert/realmoneyrealtaste/application/event/provided/MemberEventReaderTest.kt @@ -0,0 +1,360 @@ +package com.albert.realmoneyrealtaste.application.event.provided + +import com.albert.realmoneyrealtaste.IntegrationTestBase +import com.albert.realmoneyrealtaste.domain.event.MemberEventType +import org.springframework.data.domain.PageRequest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNotNull +import kotlin.test.assertTrue + +class MemberEventReaderTest( + val memberEventCreator: MemberEventCreator, + val memberEventReader: MemberEventReader, + val memberEventUpdater: MemberEventUpdater, +) : IntegrationTestBase() { + + @Test + fun `readMemberEvents - success - returns paginated events ordered by createdAt desc`() { + val memberId = 1L + + // 여러 이벤트 생성 + val event1 = memberEventCreator.createEvent( + memberId = memberId, + eventType = MemberEventType.FRIEND_REQUEST_SENT, + title = "첫 번째 알림", + message = "첫 번째 메시지" + ) + + val event2 = memberEventCreator.createEvent( + memberId = memberId, + eventType = MemberEventType.POST_CREATED, + title = "두 번째 알림", + message = "두 번째 메시지" + ) + + val event3 = memberEventCreator.createEvent( + memberId = memberId, + eventType = MemberEventType.COMMENT_CREATED, + title = "세 번째 알림", + message = "세 번째 메시지" + ) + + // 페이지 크기 2로 조회 + val pageable = PageRequest.of(0, 2) + val result = memberEventReader.readMemberEvents(memberId, pageable) + + // 페이지 정보 확인 + assertEquals(2, result.size) + assertEquals(3, result.totalElements) + assertEquals(2, result.totalPages) + assertEquals(0, result.number) + assertTrue(result.hasNext()) + assertFalse(result.hasPrevious()) + + // 최신순 정렬 확인 (event3, event2) + val events = result.content + assertEquals(event3.requireId(), events[0].id) + assertEquals(event2.requireId(), events[1].id) + + // DTO 매핑 확인 + assertEquals(MemberEventType.COMMENT_CREATED, events[0].eventType) + assertEquals("세 번째 알림", events[0].title) + assertEquals("세 번째 메시지", events[0].message) + assertFalse(events[0].isRead) + assertNotNull(events[0].createdAt) + assertEquals(null, events[0].relatedMemberId) + assertEquals(null, events[0].relatedPostId) + assertEquals(null, events[0].relatedCommentId) + } + + @Test + fun `readMemberEvents - success - returns empty page when member has no events`() { + val memberId = 999L + val pageable = PageRequest.of(0, 10) + + val result = memberEventReader.readMemberEvents(memberId, pageable) + + assertTrue(result.isEmpty) + assertEquals(10, result.size) + assertEquals(0, result.totalElements) + assertEquals(0, result.totalPages) + assertEquals(0, result.number) + assertFalse(result.hasNext()) + assertFalse(result.hasPrevious()) + } + + @Test + fun `readMemberEvents - success - handles pagination correctly`() { + val memberId = 1L + + // 5개 이벤트 생성 + repeat(5) { index -> + memberEventCreator.createEvent( + memberId = memberId, + eventType = MemberEventType.FRIEND_REQUEST_SENT, + title = "알림 $index", + message = "메시지 $index" + ) + } + + // 첫 페이지 (크기 2) + val firstPage = memberEventReader.readMemberEvents(memberId, PageRequest.of(0, 2)) + assertEquals(2, firstPage.size) + assertEquals(5, firstPage.totalElements) + assertEquals(3, firstPage.totalPages) + + // 두 번째 페이지 + val secondPage = memberEventReader.readMemberEvents(memberId, PageRequest.of(1, 2)) + assertEquals(2, secondPage.size) + assertEquals(5, secondPage.totalElements) + assertEquals(3, secondPage.totalPages) + + // 세 번째 페이지 (마지막) + val thirdPage = memberEventReader.readMemberEvents(memberId, PageRequest.of(2, 2)) + assertEquals(2, thirdPage.size) + assertEquals(5, thirdPage.totalElements) + assertEquals(3, thirdPage.totalPages) + + // 네 번째 페이지 (범위 초과) + val fourthPage = memberEventReader.readMemberEvents(memberId, PageRequest.of(3, 2)) + assertTrue(fourthPage.isEmpty) + } + + @Test + fun `readMemberEventsByType - success - filters events by type`() { + val memberId = 1L + + // 다양한 타입의 이벤트 생성 + val friendEvent = memberEventCreator.createEvent( + memberId = memberId, + eventType = MemberEventType.FRIEND_REQUEST_SENT, + title = "친구 알림", + message = "친구 메시지" + ) + + val postEvent = memberEventCreator.createEvent( + memberId = memberId, + eventType = MemberEventType.POST_CREATED, + title = "게시물 알림", + message = "게시물 메시지" + ) + + val anotherFriendEvent = memberEventCreator.createEvent( + memberId = memberId, + eventType = MemberEventType.FRIEND_REQUEST_RECEIVED, + title = "친구 알림 2", + message = "친구 메시지 2" + ) + + // 친구 관련 이벤트만 필터링 + val friendEvents = memberEventReader.readMemberEventsByType( + memberId = memberId, + eventType = MemberEventType.FRIEND_REQUEST_SENT, + PageRequest.of(0, 10) + ) + + assertEquals(10, friendEvents.size) + assertEquals(1, friendEvents.totalElements) + assertEquals(friendEvent.requireId(), friendEvents.content[0].id) + assertEquals(MemberEventType.FRIEND_REQUEST_SENT, friendEvents.content[0].eventType) + + // 게시물 관련 이벤트만 필터링 + val postEvents = memberEventReader.readMemberEventsByType( + memberId = memberId, + eventType = MemberEventType.POST_CREATED, + PageRequest.of(0, 10) + ) + + assertEquals(1, postEvents.totalElements) + assertEquals(postEvent.requireId(), postEvents.content[0].id) + } + + @Test + fun `readMemberEventsByType - success - returns empty when no events of specified type`() { + val memberId = 1L + + // 친구 이벤트만 생성 + memberEventCreator.createEvent( + memberId = memberId, + eventType = MemberEventType.FRIEND_REQUEST_SENT, + title = "친구 알림", + message = "친구 메시지" + ) + + // 게시물 이벤트 조회 (없음) + val postEvents = memberEventReader.readMemberEventsByType( + memberId = memberId, + eventType = MemberEventType.POST_CREATED, + PageRequest.of(0, 10) + ) + + assertTrue(postEvents.isEmpty) + assertEquals(0, postEvents.totalElements) + } + + @Test + fun `readMemberEventsByType - success - maintains ordering by createdAt desc`() { + val memberId = 1L + + // 같은 타입의 여러 이벤트 생성 + val event1 = memberEventCreator.createEvent( + memberId = memberId, + eventType = MemberEventType.FRIEND_REQUEST_SENT, + title = "첫 번째", + message = "메시지 1" + ) + + val event2 = memberEventCreator.createEvent( + memberId = memberId, + eventType = MemberEventType.FRIEND_REQUEST_SENT, + title = "두 번째", + message = "메시지 2" + ) + + val event3 = memberEventCreator.createEvent( + memberId = memberId, + eventType = MemberEventType.FRIEND_REQUEST_SENT, + title = "세 번째", + message = "메시지 3" + ) + + // 조회 + val events = memberEventReader.readMemberEventsByType( + memberId = memberId, + eventType = MemberEventType.FRIEND_REQUEST_SENT, + PageRequest.of(0, 10) + ) + + assertEquals(3, events.totalElements) + // 최신순 정렬 확인 + assertEquals(event3.requireId(), events.content[0].id) + assertEquals(event2.requireId(), events.content[1].id) + assertEquals(event1.requireId(), events.content[2].id) + } + + @Test + fun `readUnreadEventCount - success - returns count of unread events`() { + val memberId = 1L + + // 5개 이벤트 생성 + repeat(5) { index -> + memberEventCreator.createEvent( + memberId = memberId, + eventType = MemberEventType.FRIEND_REQUEST_SENT, + title = "알림 $index", + message = "메시지 $index" + ) + } + + // 모두 안 읽음 상태 + val unreadCount = memberEventReader.readUnreadEventCount(memberId) + assertEquals(5, unreadCount) + + // 2개 읽음으로 표시 + val events = memberEventReader.readMemberEvents(memberId, PageRequest.of(0, 5)) + memberEventUpdater.markAsRead(events.content[0].id, memberId) + memberEventUpdater.markAsRead(events.content[1].id, memberId) + + // 3개 안 읽음 + val unreadCountAfter = memberEventReader.readUnreadEventCount(memberId) + assertEquals(3, unreadCountAfter) + } + + @Test + fun `readUnreadEventCount - success - returns 0 when all events are read`() { + val memberId = 1L + + // 이벤트 생성 + val event = memberEventCreator.createEvent( + memberId = memberId, + eventType = MemberEventType.FRIEND_REQUEST_SENT, + title = "알림", + message = "메시지" + ) + + // 읽음으로 표시 + memberEventUpdater.markAsRead(event.requireId(), memberId) + + // 안 읽은 이벤트 수 확인 + val unreadCount = memberEventReader.readUnreadEventCount(memberId) + assertEquals(0, unreadCount) + } + + @Test + fun `readUnreadEventCount - success - returns 0 when member has no events`() { + val memberId = 999L + + val unreadCount = memberEventReader.readUnreadEventCount(memberId) + assertEquals(0, unreadCount) + } + + @Test + fun `readUnreadEventCount - success - counts only specific member's events`() { + val member1Id = 1L + val member2Id = 2L + + // member1의 이벤트 + memberEventCreator.createEvent( + memberId = member1Id, + eventType = MemberEventType.FRIEND_REQUEST_SENT, + title = "member1 알림", + message = "member1 메시지" + ) + + // member2의 이벤트 + memberEventCreator.createEvent( + memberId = member2Id, + eventType = MemberEventType.FRIEND_REQUEST_SENT, + title = "member2 알림", + message = "member2 메시지" + ) + + // 각 회원의 안 읽은 이벤트 수 확인 + assertEquals(1, memberEventReader.readUnreadEventCount(member1Id)) + assertEquals(1, memberEventReader.readUnreadEventCount(member2Id)) + } + + @Test + fun `integration - all methods work together correctly`() { + val memberId = 1L + + // 다양한 타입의 이벤트 생성 + val friendEvent = memberEventCreator.createEvent( + memberId = memberId, + eventType = MemberEventType.FRIEND_REQUEST_SENT, + title = "친구 알림", + message = "친구 메시지" + ) + + val postEvent = memberEventCreator.createEvent( + memberId = memberId, + eventType = MemberEventType.POST_CREATED, + title = "게시물 알림", + message = "게시물 메시지" + ) + + // 전체 이벤트 조회 + val allEvents = memberEventReader.readMemberEvents(memberId, PageRequest.of(0, 10)) + assertEquals(10, allEvents.size) + assertEquals(2, allEvents.totalElements) + + // 타입별 조회 + val friendEvents = memberEventReader.readMemberEventsByType( + memberId = memberId, + eventType = MemberEventType.FRIEND_REQUEST_SENT, + PageRequest.of(0, 10) + ) + assertEquals(1, friendEvents.totalElements) + + // 안 읽은 이벤트 수 확인 + assertEquals(2, memberEventReader.readUnreadEventCount(memberId)) + + // 하나 읽음으로 표시 + memberEventUpdater.markAsRead(friendEvent.requireId(), memberId) + + // 안 읽은 이벤트 수 재확인 + assertEquals(1, memberEventReader.readUnreadEventCount(memberId)) + } +} diff --git a/src/test/kotlin/com/albert/realmoneyrealtaste/application/event/provided/MemberEventUpdaterTest.kt b/src/test/kotlin/com/albert/realmoneyrealtaste/application/event/provided/MemberEventUpdaterTest.kt new file mode 100644 index 00000000..af1275da --- /dev/null +++ b/src/test/kotlin/com/albert/realmoneyrealtaste/application/event/provided/MemberEventUpdaterTest.kt @@ -0,0 +1,322 @@ +package com.albert.realmoneyrealtaste.application.event.provided + +import com.albert.realmoneyrealtaste.IntegrationTestBase +import com.albert.realmoneyrealtaste.application.event.required.MemberEventRepository +import com.albert.realmoneyrealtaste.domain.event.MemberEvent +import com.albert.realmoneyrealtaste.domain.event.MemberEventType +import java.time.LocalDateTime +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertNotNull +import kotlin.test.assertTrue + +class MemberEventUpdaterTest( + val memberEventCreator: MemberEventCreator, + val memberEventUpdater: MemberEventUpdater, + val memberEventRepository: MemberEventRepository, +) : IntegrationTestBase() { + + @Test + fun `markAllAsRead - success - marks all unread events as read`() { + val memberId = 1L + + // 여러 개의 안 읽은 이벤트 생성 + repeat(5) { index -> + memberEventCreator.createEvent( + memberId = memberId, + eventType = MemberEventType.FRIEND_REQUEST_SENT, + title = "알림 $index", + message = "메시지 $index" + ) + } + + // 이미 읽은 이벤트 생성 + val readEvent = memberEventCreator.createEvent( + memberId = memberId, + eventType = MemberEventType.POST_CREATED, + title = "이미 읽은 알림", + message = "이미 읽은 메시지" + ) + memberEventUpdater.markAsRead(readEvent.requireId(), memberId) + + // 모든 이벤트를 읽음으로 표시 + val markedCount = memberEventUpdater.markAllAsRead(memberId) + + // 5개의 안 읽은 이벤트만 읽음으로 표시되어야 함 + assertEquals(5, markedCount) + } + + @Test + fun `markAllAsRead - success - returns 0 when no unread events exist`() { + val memberId = 1L + + // 이미 모두 읽은 이벤트들 생성 + repeat(3) { index -> + val event = memberEventCreator.createEvent( + memberId = memberId, + eventType = MemberEventType.FRIEND_REQUEST_SENT, + title = "알림 $index", + message = "메시지 $index" + ) + memberEventUpdater.markAsRead(event.requireId(), memberId) + } + + // 모두 읽음으로 표시 시도 + val markedCount = memberEventUpdater.markAllAsRead(memberId) + + assertEquals(0, markedCount) + } + + @Test + fun `markAllAsRead - success - returns 0 when member has no events`() { + val memberId = 999L + + val markedCount = memberEventUpdater.markAllAsRead(memberId) + + assertEquals(0, markedCount) + } + + @Test + fun `markAsRead - success - marks specific event as read`() { + val memberId = 1L + + // 여러 이벤트 생성 + val event1 = memberEventCreator.createEvent( + memberId = memberId, + eventType = MemberEventType.FRIEND_REQUEST_SENT, + title = "첫 번째 알림", + message = "첫 번째 메시지" + ) + val event2 = memberEventCreator.createEvent( + memberId = memberId, + eventType = MemberEventType.POST_CREATED, + title = "두 번째 알림", + message = "두 번째 메시지" + ) + + // 특정 이벤트만 읽음으로 표시 + val updatedEvent = memberEventUpdater.markAsRead(event1.requireId(), memberId) + + assertNotNull(updatedEvent) + assertEquals(event1.requireId(), updatedEvent.requireId()) + assertTrue(updatedEvent.isRead) + + // 다른 이벤트는 여전히 안 읽음 상태 + val retrievedEvent2 = memberEventUpdater.markAsRead(event2.requireId(), memberId) + assertTrue(retrievedEvent2.isRead) + } + + @Test + fun `markAsRead - success - can mark already read event`() { + val memberId = 1L + + val event = memberEventCreator.createEvent( + memberId = memberId, + eventType = MemberEventType.FRIEND_REQUEST_SENT, + title = "알림", + message = "메시지" + ) + + // 첫 번째 읽음 표시 + val updatedEvent1 = memberEventUpdater.markAsRead(event.requireId(), memberId) + assertTrue(updatedEvent1.isRead) + + // 두 번째 읽음 표시 (이미 읽은 상태) + val updatedEvent2 = memberEventUpdater.markAsRead(event.requireId(), memberId) + assertTrue(updatedEvent2.isRead) + assertEquals(updatedEvent1.requireId(), updatedEvent2.requireId()) + } + + @Test + fun `markAsRead - failure - throws exception when event does not exist`() { + val memberId = 1L + val nonExistentEventId = 99999L + + assertFailsWith { + memberEventUpdater.markAsRead(nonExistentEventId, memberId) + }.let { + assertEquals("이벤트를 찾을 수 없거나 접근 권한이 없습니다: $nonExistentEventId", it.message) + } + } + + @Test + fun `markAsRead - failure - throws exception when trying to access another member's event`() { + val member1Id = 1L + val member2Id = 2L + + // member1의 이벤트 생성 + val event = memberEventCreator.createEvent( + memberId = member1Id, + eventType = MemberEventType.FRIEND_REQUEST_SENT, + title = "member1의 알림", + message = "member1의 메시지" + ) + + // member2가 member1의 이벤트에 접근 시도 + assertFailsWith { + memberEventUpdater.markAsRead(event.requireId(), member2Id) + }.let { + assertEquals("이벤트를 찾을 수 없거나 접근 권한이 없습니다: ${event.requireId()}", it.message) + } + } + + @Test + fun `deleteOldEvents - success - deletes events before specified date`() { + val memberId = 1L + val now = LocalDateTime.now() + val oneWeekAgo = now.minusWeeks(1) + val twoWeeksAgo = now.minusWeeks(2) + val threeWeeksAgo = now.minusWeeks(3) + + // 특정 시간에 이벤트 생성 + val oldEvent1 = createEventWithCreatedAt(memberId, threeWeeksAgo, "오래된 알림 1") + val oldEvent2 = createEventWithCreatedAt(memberId, twoWeeksAgo, "오래된 알림 2") + val recentEvent = createEventWithCreatedAt(memberId, oneWeekAgo, "최신 알림") + + // oneWeekAgo보다 이전의 이벤트들 삭제 + val deletedCount = memberEventUpdater.deleteOldEvents(memberId, oneWeekAgo) + + // 2개의 이벤트가 삭제되어야 함 + assertEquals(2, deletedCount) + + // 최신 이벤트는 남아있어야 함 + val remainingEvents = memberEventRepository.findByMemberId(memberId) + assertEquals(1, remainingEvents.size) + assertEquals(recentEvent.requireId(), remainingEvents[0].requireId()) + } + + @Test + fun `deleteOldEvents - success - returns 0 when no events to delete`() { + val memberId = 1L + val beforeDate = LocalDateTime.now().minusDays(1) + + // 현재 시간에 이벤트 생성 + memberEventCreator.createEvent( + memberId = memberId, + eventType = MemberEventType.FRIEND_REQUEST_SENT, + title = "알림", + message = "메시지" + ) + + // 이전 날짜로 삭제 시도 (삭제될 이벤트 없음) + val deletedCount = memberEventUpdater.deleteOldEvents(memberId, beforeDate) + + assertEquals(0, deletedCount) + } + + @Test + fun `deleteOldEvents - success - returns 0 when member has no events`() { + val memberId = 999L + val pastDate = LocalDateTime.now().minusYears(1) + + val deletedCount = memberEventUpdater.deleteOldEvents(memberId, pastDate) + + assertEquals(0, deletedCount) + } + + @Test + fun `deleteOldEvents - success - deletes all events when date is far in future`() { + val memberId = 1L + val futureDate = LocalDateTime.now().plusYears(1) + + // 여러 이벤트 생성 + repeat(5) { index -> + memberEventCreator.createEvent( + memberId = memberId, + eventType = MemberEventType.FRIEND_REQUEST_SENT, + title = "알림 $index", + message = "메시지 $index" + ) + } + + // 미래 날짜로 모든 이벤트 삭제 + val deletedCount = memberEventUpdater.deleteOldEvents(memberId, futureDate) + + assertEquals(5, deletedCount) + } + + @Test + fun `integration - all methods work together correctly`() { + val memberId = 1L + val now = LocalDateTime.now() + val yesterday = now.minusDays(1) + val twoDaysAgo = now.minusDays(2) + + // 이벤트 생성 + val event1 = createEventWithCreatedAt(memberId, twoDaysAgo, "알림 1") + val event2 = createEventWithCreatedAt(memberId, yesterday, "알림 2") + + // 하나만 읽음으로 표시 + memberEventUpdater.markAsRead(event1.requireId(), memberId) + + // 나머지 모두 읽음으로 표시 + val markedCount = memberEventUpdater.markAllAsRead(memberId) + assertEquals(1, markedCount) // event2만 읽음으로 표시됨 + + // 어제 이전의 이벤트 삭제 + val deletedCount = memberEventUpdater.deleteOldEvents(memberId, yesterday) + assertEquals(1, deletedCount) // event1만 삭제됨 + } + + @Test + fun `deleteOldEvents - success - deletes only specified member's events`() { + val member1Id = 1L + val member2Id = 2L + val now = LocalDateTime.now() + val oneWeekAgo = now.minusWeeks(1) + val twoWeeksAgo = now.minusWeeks(2) + + // member1의 오래된 이벤트 생성 + val member1OldEvent = createEventWithCreatedAt(member1Id, twoWeeksAgo, "member1의 오래된 알림") + val member1RecentEvent = createEventWithCreatedAt(member1Id, oneWeekAgo, "member1의 최신 알림") + + // member2의 오래된 이벤트 생성 + val member2OldEvent = createEventWithCreatedAt(member2Id, twoWeeksAgo, "member2의 오래된 알림") + + // member1의 오래된 이벤트만 삭제 + val deletedCount = memberEventUpdater.deleteOldEvents(member1Id, oneWeekAgo) + + // member1의 오래된 이벤트 1개만 삭제됨 + assertEquals(1, deletedCount) + + // member2의 이벤트은 그대로 있어야 함 + val member2Events = memberEventRepository.findByMemberId(member2Id) + assertEquals(1, member2Events.size) + assertEquals(member2OldEvent.requireId(), member2Events[0].requireId()) + + // member1의 최신 이벤트은 남아있어야 함 + val member1Events = memberEventRepository.findByMemberId(member1Id) + assertEquals(1, member1Events.size) + assertEquals(member1RecentEvent.requireId(), member1Events[0].requireId()) + } + + // Helper method to create events with specific createdAt timestamp + private fun createEventWithCreatedAt( + memberId: Long, + createdAt: LocalDateTime, + title: String, + relatedMemberId: Long? = null, + relatedPostId: Long? = null, + relatedCommentId: Long? = null, + ): MemberEvent { + // Test helper class that allows setting createdAt directly + val event = MemberEvent.create( + memberId = memberId, + eventType = MemberEventType.FRIEND_REQUEST_SENT, + title = title, + message = "메시지", + relatedMemberId = relatedMemberId, + relatedPostId = relatedPostId, + relatedCommentId = relatedCommentId, + ) + event.setCreatedAt(createdAt) + return memberEventRepository.save(event) + } + + private fun MemberEvent.setCreatedAt(createdAt: LocalDateTime) { + val fieldCreatedAt = MemberEvent::class.java.getDeclaredField("createdAt") + fieldCreatedAt.isAccessible = true + fieldCreatedAt.set(this, createdAt) + } +} 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 502105cb..8b957b89 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 @@ -49,7 +49,14 @@ class FriendshipResponseTest { setMemberId(friend, toMemberId) setMemberImageId(friend, 456L) - val command = FriendRequestCommand(fromMemberId, member.nickname.value, toMemberId, friend.nickname.value) + val command = FriendRequestCommand( + fromMemberId, + member.nickname.value, + member.profileImageId, + toMemberId, + friend.nickname.value, + friend.profileImageId, + ) val friendship = Friendship.request(command) // ID 설정 (실제로는 JPA가 수행) @@ -93,7 +100,14 @@ class FriendshipResponseTest { setMemberId(friend, toMemberId) // profileImageId를 설정하지 않음 (null 상태 유지) - val command = FriendRequestCommand(fromMemberId, member.nickname.value, toMemberId, friend.nickname.value) + val command = FriendRequestCommand( + fromMemberId, + member.nickname.value, + member.profileImageId, + toMemberId, + friend.nickname.value, + member.profileImageId, + ) val friendship = Friendship.request(command) setFriendshipId(friendship, 100L) @@ -111,12 +125,6 @@ class FriendshipResponseTest { field.set(friendship, id) } - private fun setStatus(friendship: Friendship, status: FriendshipStatus) { - val field: Field = Friendship::class.java.getDeclaredField("status") - 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 diff --git a/src/test/kotlin/com/albert/realmoneyrealtaste/application/friend/listener/FriendshipDomainEventListenerTest.kt b/src/test/kotlin/com/albert/realmoneyrealtaste/application/friend/listener/FriendshipDomainEventListenerTest.kt new file mode 100644 index 00000000..5f0a297c --- /dev/null +++ b/src/test/kotlin/com/albert/realmoneyrealtaste/application/friend/listener/FriendshipDomainEventListenerTest.kt @@ -0,0 +1,244 @@ +package com.albert.realmoneyrealtaste.application.friend.listener + +import com.albert.realmoneyrealtaste.application.friend.required.FriendshipRepository +import com.albert.realmoneyrealtaste.domain.friend.Friendship +import com.albert.realmoneyrealtaste.domain.member.event.MemberProfileUpdatedDomainEvent +import io.mockk.every +import io.mockk.impl.annotations.InjectMockKs +import io.mockk.impl.annotations.MockK +import io.mockk.junit5.MockKExtension +import io.mockk.mockk +import io.mockk.verify +import io.mockk.verifyOrder +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.extension.ExtendWith + +@ExtendWith(MockKExtension::class) +class FriendshipDomainEventListenerTest { + + @MockK(relaxed = true) + private lateinit var friendshipRepository: FriendshipRepository + + @InjectMockKs + private lateinit var friendshipDomainEventListener: FriendshipDomainEventListener + + @Test + fun `handleMemberProfileUpdated - success - updates single friendship`() { + // Given + val memberId = 1L + val nickname = "새로운닉네임" + val imageId = 999L + + val friendship = mockk(relaxed = true) + + val event = MemberProfileUpdatedDomainEvent( + memberId = memberId, + email = "test@example.com", + updatedFields = listOf("nickname", "imageId"), + nickname = nickname, + imageId = imageId + ) + + every { friendshipRepository.findAllActiveByMemberId(memberId) } returns listOf(friendship) + + // When + friendshipDomainEventListener.handleMemberProfileUpdated(event) + + // Then + verify(exactly = 1) { friendshipRepository.findAllActiveByMemberId(memberId) } + verify(exactly = 1) { + friendship.updateMemberInfo( + memberId = memberId, + nickname = nickname, + imageId = imageId + ) + } + verify(exactly = 1) { friendshipRepository.save(friendship) } + + verifyOrder { + friendship.updateMemberInfo(memberId, nickname, imageId) + friendshipRepository.save(friendship) + } + } + + @Test + fun `handleMemberProfileUpdated - success - updates multiple friendships`() { + // Given + val memberId = 1L + val nickname = "업데이트닉네임" + val imageId = 888L + + val friendship1 = mockk(relaxed = true) + val friendship2 = mockk(relaxed = true) + val friendship3 = mockk(relaxed = true) + + val event = MemberProfileUpdatedDomainEvent( + memberId = memberId, + email = "test@example.com", + updatedFields = listOf("nickname"), + nickname = nickname, + imageId = imageId + ) + + every { friendshipRepository.findAllActiveByMemberId(memberId) } returns listOf( + friendship1, friendship2, friendship3 + ) + + // When + friendshipDomainEventListener.handleMemberProfileUpdated(event) + + // Then + verify(exactly = 1) { friendshipRepository.findAllActiveByMemberId(memberId) } + + // 각 친구 관계에 대해 updateMemberInfo 호출 확인 + verify(exactly = 1) { + friendship1.updateMemberInfo( + memberId = memberId, + nickname = nickname, + imageId = imageId + ) + } + verify(exactly = 1) { + friendship2.updateMemberInfo( + memberId = memberId, + nickname = nickname, + imageId = imageId + ) + } + verify(exactly = 1) { + friendship3.updateMemberInfo( + memberId = memberId, + nickname = nickname, + imageId = imageId + ) + } + + // 각 친구 관계 저장 확인 + verify(exactly = 1) { friendshipRepository.save(friendship1) } + verify(exactly = 1) { friendshipRepository.save(friendship2) } + verify(exactly = 1) { friendshipRepository.save(friendship3) } + } + + @Test + fun `handleMemberProfileUpdated - success - updates only imageId`() { + // Given + val memberId = 1L + val imageId = 777L + + val friendship = mockk(relaxed = true) + + val event = MemberProfileUpdatedDomainEvent( + memberId = memberId, + email = "test@example.com", + updatedFields = listOf("imageId"), + nickname = null, + imageId = imageId + ) + + every { friendshipRepository.findAllActiveByMemberId(memberId) } returns listOf(friendship) + + // When + friendshipDomainEventListener.handleMemberProfileUpdated(event) + + // Then + verify(exactly = 1) { + friendship.updateMemberInfo( + memberId = memberId, + nickname = null, + imageId = imageId + ) + } + verify(exactly = 1) { friendshipRepository.save(friendship) } + } + + @Test + fun `handleMemberProfileUpdated - success - handles no active friendships`() { + // Given + val memberId = 1L + + val event = MemberProfileUpdatedDomainEvent( + memberId = memberId, + email = "test@example.com", + updatedFields = listOf("nickname"), + nickname = "새닉네임", + imageId = null + ) + + every { friendshipRepository.findAllActiveByMemberId(memberId) } returns emptyList() + + // When + friendshipDomainEventListener.handleMemberProfileUpdated(event) + + // Then + verify(exactly = 1) { friendshipRepository.findAllActiveByMemberId(memberId) } + // save가 호출되지 않아야 함 + verify(exactly = 0) { friendshipRepository.save(any()) } + } + + @Test + fun `handleMemberProfileUpdated - success - handles empty update fields`() { + // Given + val memberId = 1L + + val friendship = mockk(relaxed = true) + + val event = MemberProfileUpdatedDomainEvent( + memberId = memberId, + email = "test@example.com", + updatedFields = emptyList(), + nickname = null, + imageId = null + ) + + every { friendshipRepository.findAllActiveByMemberId(memberId) } returns listOf(friendship) + + // When + friendshipDomainEventListener.handleMemberProfileUpdated(event) + + // Then + verify(exactly = 1) { + friendship.updateMemberInfo( + memberId = memberId, + nickname = null, + imageId = null + ) + } + verify(exactly = 1) { friendshipRepository.save(friendship) } + } + + @Test + fun `handleMemberProfileUpdated - success - preserves order of operations`() { + // Given + val memberId = 1L + val nickname = "순서테스트" + + val friendship1 = mockk(relaxed = true) + val friendship2 = mockk(relaxed = true) + + val event = MemberProfileUpdatedDomainEvent( + memberId = memberId, + email = "test@example.com", + updatedFields = listOf("nickname"), + nickname = nickname, + imageId = null + ) + + every { friendshipRepository.findAllActiveByMemberId(memberId) } returns listOf( + friendship1, friendship2 + ) + + // When + friendshipDomainEventListener.handleMemberProfileUpdated(event) + + // Then + verifyOrder { + friendshipRepository.findAllActiveByMemberId(memberId) + + friendship1.updateMemberInfo(memberId, nickname, null) + friendshipRepository.save(friendship1) + + friendship2.updateMemberInfo(memberId, nickname, null) + friendshipRepository.save(friendship2) + } + } +} 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 c0d2ae19..68b93365 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 @@ -138,10 +138,9 @@ class FriendRequestorTest( nickname = "friend2" ) - // 친구 요청 생성 후 수락 + // 친구 요청 생성 후 reject val friendship = friendRequestor.sendFriendRequest(member1.requireId(), member2.requireId()) - friendship.accept() - friendshipRepository.save(friendship) + friendship.reject() // when & then val result = friendRequestor.sendFriendRequest(member1.requireId(), member2.requireId()) @@ -162,7 +161,14 @@ class FriendRequestorTest( ) assertFailsWith { - FriendRequestCommand(member.requireId(), member.nickname.value, member.requireId(), member.nickname.value) + FriendRequestCommand( + member.requireId(), + member.nickname.value, + member.profileImageId, + member.requireId(), + member.nickname.value, + member.profileImageId, + ) } } @@ -303,7 +309,14 @@ class FriendRequestorTest( ) assertFailsWith { - FriendRequestCommand(member.requireId(), member.nickname.value, member.requireId(), member.nickname.value) + FriendRequestCommand( + member.requireId(), + member.nickname.value, + member.profileImageId, + member.requireId(), + member.nickname.value, + member.profileImageId, + ) } } @@ -327,8 +340,10 @@ class FriendRequestorTest( val secondCommand = FriendRequestCommand( member1.requireId(), member1.nickname.value, + member1.profileImageId, member2.requireId(), - member2.nickname.value + member2.nickname.value, + member2.profileImageId, ) val result = friendRequestor.sendFriendRequest(member1.requireId(), member2.requireId()) 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 9a972e39..5dbbf62a 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 @@ -45,7 +45,6 @@ class FriendResponderTest( // 친구 요청 생성 val friendship = friendRequestor.sendFriendRequest(sender.requireId(), receiver.requireId()) - flushAndClear() applicationEvents.clear() // 친구 요청 수락 @@ -76,13 +75,8 @@ class FriendResponderTest( // 이벤트 발행 확인 val events = applicationEvents.stream(FriendRequestAcceptedEvent::class.java).toList() - assertEquals(1, events.size) + assertEquals(2, events.size) val event = events.first() - assertAll( - { assertEquals(friendship.requireId(), event.friendshipId) }, - { assertEquals(sender.requireId(), event.fromMemberId) }, - { assertEquals(receiver.requireId(), event.toMemberId) } - ) } @Test @@ -98,7 +92,6 @@ class FriendResponderTest( // 친구 요청 생성 val friendship = friendRequestor.sendFriendRequest(sender.requireId(), receiver.requireId()) - flushAndClear() applicationEvents.clear() // 친구 요청 거절 @@ -352,7 +345,6 @@ class FriendResponderTest( // 수신자를 비활성화 (실제로는 불가능하지만 테스트를 위해) receiver.deactivate() - flushAndClear() val request = FriendResponseRequest( friendshipId = friendship.requireId(), @@ -379,7 +371,6 @@ class FriendResponderTest( ) val friendship = friendRequestor.sendFriendRequest(sender.requireId(), receiver.requireId()) - flushAndClear() val request = FriendResponseRequest( friendshipId = friendship.requireId(), @@ -388,8 +379,6 @@ class FriendResponderTest( ) val result = friendResponder.respondToFriendRequest(request) - flushAndClear() - val persisted = friendshipReader.findFriendshipById(result.requireId()) assertAll( { assertNotNull(persisted) }, @@ -419,8 +408,6 @@ class FriendResponderTest( ) friendResponder.respondToFriendRequest(request) - flushAndClear() - // 양방향 친구 관계 확인 val forwardFriendship = friendshipReader.findActiveFriendship(sender.requireId(), receiver.requireId())!! val reverseFriendship = friendshipReader.findActiveFriendship(receiver.requireId(), sender.requireId())!! @@ -454,8 +441,6 @@ class FriendResponderTest( ) friendResponder.respondToFriendRequest(request) - flushAndClear() - // 원래 관계는 거절 상태, 역방향 관계는 없음 val rejectedFriendship = friendshipReader.findFriendshipById(originalFriendship.requireId()) val reverseFriendship = friendshipReader.findActiveFriendship(receiver.requireId(), sender.requireId()) @@ -479,7 +464,7 @@ class FriendResponderTest( ) val friendship = friendRequestor.sendFriendRequest(sender.requireId(), receiver.requireId()) - friendship.status = FriendshipStatus.ACCEPTED + friendship.accept() // 친구 관계를 UNFRIENDED 상태로 변경 friendship.unfriend() 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 0acbc50d..472ffd9f 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 @@ -6,6 +6,7 @@ import com.albert.realmoneyrealtaste.application.friend.exception.FriendshipNotF 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.util.TestMemberHelper import org.junit.jupiter.api.assertAll import org.springframework.data.domain.PageRequest @@ -37,12 +38,7 @@ class FriendshipReaderTest( ) // 친구 관계 생성 - createAcceptedFriendship( - member1.requireId(), - member1.nickname.value, - member2.requireId(), - member2.nickname.value - ) + createAcceptedFriendship(member1, member2) val result = friendshipReader.findActiveFriendship(member1.requireId(), member2.requireId()) @@ -311,14 +307,7 @@ class FriendshipReaderTest( ) } - friends.forEach { friend -> - createAcceptedFriendship( - friend.requireId(), - friend.nickname.value, - member.requireId(), - member.nickname.value - ) - } + friends.forEach { friend -> createAcceptedFriendship(friend, member) } // 페이지 크기 2로 첫 번째 페이지 조회 val firstPage = PageRequest.of(0, 2, Sort.by("createdAt").ascending()) @@ -354,7 +343,7 @@ class FriendshipReaderTest( ) // 하나는 수락, 하나는 대기 상태로 유지 - createAcceptedFriendship(friend1.requireId(), friend1.nickname.value, member.requireId(), member.nickname.value) + createAcceptedFriendship(friend1, member) friendRequestor.sendFriendRequest(friend2.requireId(), member.requireId()) val pageable = PageRequest.of(0, 10) @@ -367,12 +356,19 @@ class FriendshipReaderTest( } private fun createAcceptedFriendship( - fromMemberId: Long, - fromMemberNickname: String, - toMemberId: Long, - toMemberNickname: String, + fromMember: Member, + toMember: Member, ): Friendship { - FriendRequestCommand(fromMemberId, fromMemberNickname, toMemberId, toMemberNickname) + val fromMemberId = fromMember.requireId() + val toMemberId = toMember.requireId() + FriendRequestCommand( + fromMemberId = fromMemberId, + fromMemberNickname = fromMember.nickname.value, + fromMemberProfileImageId = fromMember.profileImageId, + toMemberId = toMemberId, + toMemberNickname = toMember.nickname.value, + toMemberProfileImageId = toMember.profileImageId, + ) val friendship = friendRequestor.sendFriendRequest(fromMemberId, toMemberId) val response = FriendResponseRequest( @@ -396,14 +392,7 @@ class FriendshipReaderTest( ) } - friends.forEach { friend -> - createAcceptedFriendship( - friend.requireId(), - friend.nickname.value, - member.requireId(), - member.nickname.value - ) - } + friends.forEach { friend -> createAcceptedFriendship(friend, member) } val result = friendshipReader.countFriendsByMemberId(member.requireId()) @@ -438,7 +427,7 @@ class FriendshipReaderTest( ) // 하나는 수락, 하나는 대기 상태 - createAcceptedFriendship(friend1.requireId(), friend1.nickname.value, member.requireId(), member.nickname.value) + createAcceptedFriendship(friend1, member) friendRequestor.sendFriendRequest(friend2.requireId(), member.requireId()) val result = friendshipReader.countFriendsByMemberId(member.requireId()) @@ -465,24 +454,9 @@ class FriendshipReaderTest( nickname = "other" ) - 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 - ) + createAcceptedFriendship(targetFriend1, member) + createAcceptedFriendship(targetFriend2, member) + createAcceptedFriendship(otherFriend, member) friendshipReader.findFriendsByMemberId(member.requireId(), PageRequest.of(0, 10)) val pageable = PageRequest.of(0, 10) @@ -506,7 +480,7 @@ class FriendshipReaderTest( nickname = "friend" ) - createAcceptedFriendship(friend.requireId(), friend.nickname.value, member.requireId(), member.nickname.value) + createAcceptedFriendship(friend, member) val pageable = PageRequest.of(0, 10) val result = friendshipReader.searchFriends(member.requireId(), "nonexistent", pageable) @@ -528,14 +502,7 @@ class FriendshipReaderTest( ) } - friends.forEach { friend -> - createAcceptedFriendship( - friend.requireId(), - friend.nickname.value, - member.requireId(), - member.nickname.value - ) - } + friends.forEach { friend -> createAcceptedFriendship(friend, member) } val pageable = PageRequest.of(0, 10) val result = friendshipReader.searchFriends(member.requireId(), "", pageable) @@ -564,11 +531,11 @@ class FriendshipReaderTest( nickname = "recent3" ) - createAcceptedFriendship(friend1.requireId(), friend1.nickname.value, member.requireId(), member.nickname.value) + createAcceptedFriendship(friend1, member) Thread.sleep(100) // 시간 차이 보장 - createAcceptedFriendship(friend2.requireId(), friend2.nickname.value, member.requireId(), member.nickname.value) + createAcceptedFriendship(friend2, member) Thread.sleep(100) - createAcceptedFriendship(friend3.requireId(), friend3.nickname.value, member.requireId(), member.nickname.value) + createAcceptedFriendship(friend3, member) val pageable = PageRequest.of(0, 2, Sort.by("createdAt").descending()) val result = friendshipReader.findRecentFriends(member.requireId(), pageable) @@ -647,7 +614,7 @@ class FriendshipReaderTest( // 하나는 대기 상태, 하나는 수락 friendRequestor.sendFriendRequest(sender1.requireId(), member.requireId()) - createAcceptedFriendship(sender2.requireId(), sender2.nickname.value, member.requireId(), member.nickname.value) + createAcceptedFriendship(sender2, member) val result = friendshipReader.countPendingRequests(member.requireId()) @@ -769,12 +736,7 @@ class FriendshipReaderTest( ) // 친구 관계 생성 및 수락 - createAcceptedFriendship( - member1.requireId(), - member1.nickname.value, - member2.requireId(), - member2.nickname.value - ) + createAcceptedFriendship(member1, member2) val result = friendshipReader.isSent(member1.requireId(), member2.requireId()) @@ -797,18 +759,8 @@ class FriendshipReaderTest( ) // 친구 관계 생성 - createAcceptedFriendship( - activeMember.requireId(), - activeMember.nickname.value, - deactivatedMember.requireId(), - deactivatedMember.nickname.value - ) - createAcceptedFriendship( - activeMember.requireId(), - activeMember.nickname.value, - anotherActiveMember.requireId(), - anotherActiveMember.nickname.value - ) + createAcceptedFriendship(activeMember, deactivatedMember) + createAcceptedFriendship(activeMember, anotherActiveMember) deactivatedMember.deactivate() flushAndClear() @@ -906,18 +858,8 @@ class FriendshipReaderTest( ) // 친구 관계 생성 - createAcceptedFriendship( - member.requireId(), - member.nickname.value, - deactivatedFriend.requireId(), - deactivatedFriend.nickname.value - ) - createAcceptedFriendship( - member.requireId(), - member.nickname.value, - activeFriend.requireId(), - activeFriend.nickname.value - ) + createAcceptedFriendship(member, deactivatedFriend) + createAcceptedFriendship(member, activeFriend) deactivatedFriend.deactivate() flushAndClear() @@ -949,18 +891,8 @@ class FriendshipReaderTest( ) // 친구 관계 생성 - createAcceptedFriendship( - member.requireId(), - member.nickname.value, - deactivatedFriend.requireId(), - deactivatedFriend.nickname.value - ) - createAcceptedFriendship( - member.requireId(), - member.nickname.value, - activeFriend.requireId(), - activeFriend.nickname.value - ) + createAcceptedFriendship(member, deactivatedFriend) + createAcceptedFriendship(member, activeFriend) deactivatedFriend.deactivate() flushAndClear() 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 906d26b3..dd824d25 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 @@ -70,12 +70,8 @@ class FriendshipTerminatorTest( // 이벤트 발행 확인 val events = applicationEvents.stream(FriendshipTerminatedEvent::class.java).toList() - assertEquals(1, events.size) + assertEquals(2, events.size) val event = events.first() - assertAll( - { assertEquals(member1.requireId(), event.memberId) }, - { assertEquals(member2.requireId(), event.friendMemberId) } - ) } @Test @@ -154,9 +150,9 @@ class FriendshipTerminatorTest( // 예외 발생하지 않고 정상 처리 (null 체크로 인해) friendshipTerminator.unfriend(request) - // 이벤트는 여전히 발행됨 + // 이벤트는 발행하지 않음 val events = applicationEvents.stream(FriendshipTerminatedEvent::class.java).toList() - assertEquals(1, events.size) + assertEquals(0, events.size) } @Test @@ -272,9 +268,9 @@ class FriendshipTerminatorTest( // 정상적으로 처리됨 (null 체크) friendshipTerminator.unfriend(request) - // 이벤트는 발행됨 + // 이벤트는 발행하지 않음 val events = applicationEvents.stream(FriendshipTerminatedEvent::class.java).toList() - assertEquals(1, events.size) + assertEquals(0, events.size) } @Test @@ -326,9 +322,9 @@ class FriendshipTerminatorTest( // 정상적으로 처리됨 (null 체크) friendshipTerminator.unfriend(request) - // 이벤트는 발행됨 + // 이벤트는 발행 않함 val events = applicationEvents.stream(FriendshipTerminatedEvent::class.java).toList() - assertEquals(1, events.size) + assertEquals(0, events.size) } @Test @@ -385,7 +381,6 @@ class FriendshipTerminatorTest( // 친구 관계 생성 createAcceptedFriendship(member1.requireId(), member2.requireId()) - flushAndClear() // 친구 관계 해제 val request = UnfriendRequest( @@ -394,8 +389,6 @@ class FriendshipTerminatorTest( ) friendshipTerminator.unfriend(request) - flushAndClear() - // 데이터베이스에서 상태 확인 val friendship1 = friendshipRepository.findByRelationShipMemberIdAndRelationShipFriendMemberId( member1.requireId(), diff --git a/src/test/kotlin/com/albert/realmoneyrealtaste/application/image/provided/ImageUploadRequesterTest.kt b/src/test/kotlin/com/albert/realmoneyrealtaste/application/image/provided/ImageUploadRequesterTest.kt index fdcd4153..3b17f34d 100644 --- a/src/test/kotlin/com/albert/realmoneyrealtaste/application/image/provided/ImageUploadRequesterTest.kt +++ b/src/test/kotlin/com/albert/realmoneyrealtaste/application/image/provided/ImageUploadRequesterTest.kt @@ -32,7 +32,7 @@ class ImageUploadRequesterTest : IntegrationTestBase() { ) // When - val response = imageUploadRequester.generatePresignedPostUrl(request, userId) + val response = imageUploadRequester.generatePresignedUploadUrl(request, userId) // Then assertNotNull(response.uploadUrl) @@ -65,8 +65,8 @@ class ImageUploadRequesterTest : IntegrationTestBase() { ) // When - val response1 = imageUploadRequester.generatePresignedPostUrl(request, userId) - val response2 = imageUploadRequester.generatePresignedPostUrl(request, userId) + val response1 = imageUploadRequester.generatePresignedUploadUrl(request, userId) + val response2 = imageUploadRequester.generatePresignedUploadUrl(request, userId) // Then // URL은 다르지만 키 형식은 유사해야 함 @@ -99,7 +99,7 @@ class ImageUploadRequesterTest : IntegrationTestBase() { imageType = imageType ) - val response = imageUploadRequester.generatePresignedPostUrl(request, userId) + val response = imageUploadRequester.generatePresignedUploadUrl(request, userId) assertNotNull(response.uploadUrl) assertNotNull(response.key) @@ -128,7 +128,7 @@ class ImageUploadRequesterTest : IntegrationTestBase() { imageType = ImageType.POST_IMAGE ) - val response = imageUploadRequester.generatePresignedPostUrl(request, userId) + val response = imageUploadRequester.generatePresignedUploadUrl(request, userId) assertNotNull(response.uploadUrl) assertTrue(response.key.endsWith(fileName.substringAfterLast("."))) @@ -151,7 +151,7 @@ class ImageUploadRequesterTest : IntegrationTestBase() { ) // When - val minResponse = imageUploadRequester.generatePresignedPostUrl(minRequest, userId) + val minResponse = imageUploadRequester.generatePresignedUploadUrl(minRequest, userId) // Then assertNotNull(minResponse.uploadUrl) @@ -169,7 +169,7 @@ class ImageUploadRequesterTest : IntegrationTestBase() { ) // When - val maxResponse = imageUploadRequester.generatePresignedPostUrl(maxRequest, userId) + val maxResponse = imageUploadRequester.generatePresignedUploadUrl(maxRequest, userId) // Then assertNotNull(maxResponse.uploadUrl) @@ -191,7 +191,7 @@ class ImageUploadRequesterTest : IntegrationTestBase() { ) // When - val response = imageUploadRequester.generatePresignedPostUrl(request, userId) + val response = imageUploadRequester.generatePresignedUploadUrl(request, userId) // Then // 키 형식: images/yyyy/MM/dd/uuid.extension @@ -225,7 +225,7 @@ class ImageUploadRequesterTest : IntegrationTestBase() { ) // When - val minResponse = imageUploadRequester.generatePresignedPostUrl(request, minUserId) + val minResponse = imageUploadRequester.generatePresignedUploadUrl(request, minUserId) // Then assertNotNull(minResponse.uploadUrl) @@ -233,7 +233,7 @@ class ImageUploadRequesterTest : IntegrationTestBase() { // Given - 최대 사용자 ID val maxUserId = Long.MAX_VALUE - val maxResponse = imageUploadRequester.generatePresignedPostUrl(request, maxUserId) + val maxResponse = imageUploadRequester.generatePresignedUploadUrl(request, maxUserId) // Then assertNotNull(maxResponse.uploadUrl) @@ -254,7 +254,7 @@ class ImageUploadRequesterTest : IntegrationTestBase() { ) // When - val response = imageUploadRequester.generatePresignedPostUrl(request, userId) + val response = imageUploadRequester.generatePresignedUploadUrl(request, userId) // Then - 필수 필드 검증 assertNotNull(response.uploadUrl) @@ -282,7 +282,7 @@ class ImageUploadRequesterTest : IntegrationTestBase() { // When & Then val exception = assertFailsWith { - imageUploadRequester.generatePresignedPostUrl(invalidRequest, userId) + imageUploadRequester.generatePresignedUploadUrl(invalidRequest, userId) } assertEquals("이미지 업로드 실패", exception.message) assertNotNull(exception.cause) @@ -303,7 +303,7 @@ class ImageUploadRequesterTest : IntegrationTestBase() { // When & Then val exception = assertFailsWith { - imageUploadRequester.generatePresignedPostUrl(invalidRequest, userId) + imageUploadRequester.generatePresignedUploadUrl(invalidRequest, userId) } assertEquals("이미지 업로드 실패", exception.message) assertNotNull(exception.cause) @@ -324,7 +324,7 @@ class ImageUploadRequesterTest : IntegrationTestBase() { // When & Then val exception = assertFailsWith { - imageUploadRequester.generatePresignedPostUrl(invalidRequest, userId) + imageUploadRequester.generatePresignedUploadUrl(invalidRequest, userId) } assertEquals("이미지 업로드 실패", exception.message) assertNotNull(exception.cause) @@ -345,7 +345,7 @@ class ImageUploadRequesterTest : IntegrationTestBase() { // When & Then val exception = assertFailsWith { - imageUploadRequester.generatePresignedPostUrl(invalidRequest, userId) + imageUploadRequester.generatePresignedUploadUrl(invalidRequest, userId) } assertEquals("이미지 업로드 실패", exception.message) assertNotNull(exception.cause) @@ -366,7 +366,7 @@ class ImageUploadRequesterTest : IntegrationTestBase() { // When & Then val exception = assertFailsWith { - imageUploadRequester.generatePresignedPostUrl(invalidRequest, userId) + imageUploadRequester.generatePresignedUploadUrl(invalidRequest, userId) } assertEquals("이미지 업로드 실패", exception.message) assertNotNull(exception.cause) @@ -387,7 +387,7 @@ class ImageUploadRequesterTest : IntegrationTestBase() { // When & Then val exception = assertFailsWith { - imageUploadRequester.generatePresignedPostUrl(invalidRequest, userId) + imageUploadRequester.generatePresignedUploadUrl(invalidRequest, userId) } assertEquals("이미지 업로드 실패", exception.message) assertNotNull(exception.cause) @@ -408,7 +408,7 @@ class ImageUploadRequesterTest : IntegrationTestBase() { // When & Then val exception = assertFailsWith { - imageUploadRequester.generatePresignedPostUrl(invalidRequest, userId) + imageUploadRequester.generatePresignedUploadUrl(invalidRequest, userId) } assertEquals("이미지 업로드 실패", exception.message) assertNotNull(exception.cause) @@ -429,28 +429,7 @@ class ImageUploadRequesterTest : IntegrationTestBase() { // When & Then val exception = assertFailsWith { - imageUploadRequester.generatePresignedPostUrl(invalidRequest, userId) - } - assertEquals("이미지 업로드 실패", exception.message) - assertNotNull(exception.cause) - } - - @Test - fun `generatePresignedPostUrl - failure - throws ImageGenerateException for special characters in file name`() { - // Given - val userId = 123L - val invalidRequest = ImageUploadRequest( - fileName = "test@image.jpg", // 특수문자 포함 - fileSize = 1024L, - contentType = "image/jpeg", - width = 100, - height = 100, - imageType = ImageType.POST_IMAGE - ) - - // When & Then - val exception = assertFailsWith { - imageUploadRequester.generatePresignedPostUrl(invalidRequest, userId) + imageUploadRequester.generatePresignedUploadUrl(invalidRequest, userId) } assertEquals("이미지 업로드 실패", exception.message) assertNotNull(exception.cause) @@ -471,28 +450,7 @@ class ImageUploadRequesterTest : IntegrationTestBase() { // When & Then val exception = assertFailsWith { - imageUploadRequester.generatePresignedPostUrl(invalidRequest, userId) - } - assertEquals("이미지 업로드 실패", exception.message) - assertNotNull(exception.cause) - } - - @Test - fun `generatePresignedPostUrl - failure - throws ImageGenerateException for wrong name`() { - // Given - val userId = 1L - val request = ImageUploadRequest( - fileName = "test^.jpg", - fileSize = 1024L, - contentType = "image/jpeg", - width = 100, - height = 100, - imageType = ImageType.POST_IMAGE - ) - - // When & Then - val exception = assertFailsWith { - imageUploadRequester.generatePresignedPostUrl(request, userId) + imageUploadRequester.generatePresignedUploadUrl(invalidRequest, userId) } assertEquals("이미지 업로드 실패", exception.message) assertNotNull(exception.cause) diff --git a/src/test/kotlin/com/albert/realmoneyrealtaste/application/image/provided/ImageUploadValidatorTest.kt b/src/test/kotlin/com/albert/realmoneyrealtaste/application/image/provided/ImageUploadValidatorTest.kt index 275a9b16..f8ebdc90 100644 --- a/src/test/kotlin/com/albert/realmoneyrealtaste/application/image/provided/ImageUploadValidatorTest.kt +++ b/src/test/kotlin/com/albert/realmoneyrealtaste/application/image/provided/ImageUploadValidatorTest.kt @@ -50,6 +50,15 @@ class ImageUploadValidatorTest( } } + @Test + fun `validateUserUploadLimit - success - allows upload when count is zero`() { + // Given + val todayUploadCount = 0 + + // When & Then - 예외 발생하지 않음 + imageUploadValidator.validateUserUploadLimit(todayUploadCount) + } + @Test fun `validateImageRequest - success - validates valid post image request`() { // Given @@ -198,7 +207,9 @@ class ImageUploadValidatorTest( "123.jpg", // 숫자로 시작 "file123.jpg", // 숫자 포함 "ALL_CAPS.JPG", // 대문자 - "mixed_Case-Name.jpg" // 대소문자 혼합 + "mixed_Case-Name.jpg", // 대소문자 혼합 + "test.image.name.jpg", // 여러 개의 점이 있지만 ".."가 아닌 경우 + "my.photo.album.png" // 여러 개의 점이 있지만 ".."가 아닌 경우 ) // When & Then @@ -217,6 +228,21 @@ class ImageUploadValidatorTest( @Test fun `validateImageRequest - failure - throws exception for invalid file size`() { + // Given - 파일 크기 음수 + val negativeSizeRequest = ImageUploadRequest( + fileName = "test.jpg", + fileSize = -1L, + contentType = "image/jpeg", + width = 200, + height = 200, + imageType = ImageType.POST_IMAGE + ) + + // When & Then + assertFailsWith { + imageUploadValidator.validateImageRequest(negativeSizeRequest) + } + // Given - 파일 크기 0 val zeroSizeRequest = ImageUploadRequest( fileName = "test.jpg", @@ -358,68 +384,101 @@ class ImageUploadValidatorTest( } @Test - fun `validateImageRequest - failure - throws exception for invalid file name`() { - // Given - val invalidFileNameRequests = listOf( - "", // 빈 파일명 - " ", // 공백만 - "no-extension", // 확장자 없음 - "file.", // 확장자만 점 - ".hidden", // 점으로 시작 - "file@name.jpg", // 특수문자 @ - "file#name.jpg", // 특수문자 # - "file name.jpg", // 공백 - "file(name).jpg", // 괄호 - "file+name.jpg", // 플러스 - "file=name.jpg", // 등호 - "file&name.jpg", // 앰퍼샌드 - "file%name.jpg", // 퍼센트 - "file?name.jpg", // 물음표 - "file|name.jpg", // 파이프 - "file.jpg", // 꺽쇠 - "file>name>.jpg", // 꺽쇠 - "file*name.jpg", // 별표 - "file\"name.jpg", // 따옴표 - "file'name.jpg", // 작은따옴표 - "file`name.jpg", // 백틱 - "file~name.jpg", // 틸드 - "file!name.jpg", // 느낌표 - "file^name.jpg", // 캐럿 - "file{name}.jpg", // 중괄호 - "file}name.jpg", // 중괄호 - "file[name].jpg", // 대괄호 - "file]name.jpg", // 대괄호 - "file;name.jpg", // 세미콜론 - "file:name.jpg", // 콜론 - "file,name.jpg", // 쉼표 - "file\\name.jpg", // 백슬래시 - "file/name.jpg", // 슬래시 (허용되지 않음) - "file..name.jpg", // 연속 점 - "file.txt", // 허용되지 않는 확장자 - "file.exe", // 실행 파일 - "file.php", // 스크립트 파일 - "a".repeat(256) + ".jpg" // 파일명 너무 김 + fun `validateImageRequest - failure - throws exception for invalid file names`() { + // Given - 빈 파일명 + val emptyFileNameRequest = ImageUploadRequest( + fileName = "", + fileSize = 1024L, + contentType = "image/jpeg", + width = 200, + height = 200, + imageType = ImageType.POST_IMAGE ) // When & Then - invalidFileNameRequests.forEach { fileName -> - val request = ImageUploadRequest( - fileName = fileName, - fileSize = 1024L, - contentType = "image/jpeg", - width = 200, - height = 200, - imageType = ImageType.POST_IMAGE - ) - assertFailsWith { - imageUploadValidator.validateImageRequest(request) - } + assertFailsWith { + imageUploadValidator.validateImageRequest(emptyFileNameRequest) + } + + // Given - 공백만 있는 파일명 + val blankFileNameRequest = ImageUploadRequest( + fileName = " ", + fileSize = 1024L, + contentType = "image/jpeg", + width = 200, + height = 200, + imageType = ImageType.POST_IMAGE + ) + + // When & Then + assertFailsWith { + imageUploadValidator.validateImageRequest(blankFileNameRequest) + } + + // Given - 최대 길이를 초과하는 파일명 + val tooLongFileNameRequest = ImageUploadRequest( + fileName = "a".repeat(256) + ".jpg", + fileSize = 1024L, + contentType = "image/jpeg", + width = 200, + height = 200, + imageType = ImageType.POST_IMAGE + ) + + // When & Then + assertFailsWith { + imageUploadValidator.validateImageRequest(tooLongFileNameRequest) + } + + // Given - .. 문자열을 포함하는 파일명 + val doubleDotFileNameRequest = ImageUploadRequest( + fileName = "test..image.jpg", + fileSize = 1024L, + contentType = "image/jpeg", + width = 200, + height = 200, + imageType = ImageType.POST_IMAGE + ) + + // When & Then + assertFailsWith { + imageUploadValidator.validateImageRequest(doubleDotFileNameRequest) + } + + // Given - path traversal 공격 시도 + val pathTraversalRequest = ImageUploadRequest( + fileName = "../test.jpg", + fileSize = 1024L, + contentType = "image/jpeg", + width = 200, + height = 200, + imageType = ImageType.POST_IMAGE + ) + + // When & Then + assertFailsWith { + imageUploadValidator.validateImageRequest(pathTraversalRequest) } } @Test fun `validateImageRequest - failure - throws exception for invalid file extensions`() { - // Given + // Given - 확장자 없는 파일명 + val noExtensionRequest = ImageUploadRequest( + fileName = "testimage", + fileSize = 1024L, + contentType = "image/jpeg", + width = 200, + height = 200, + imageType = ImageType.POST_IMAGE + ) + + // When & Then + assertFailsWith { + imageUploadValidator.validateImageRequest(noExtensionRequest) + } + + // Given - 허용되지 않는 확장자들 val invalidExtensionRequests = listOf( "file.txt", "file.pdf", diff --git a/src/test/kotlin/com/albert/realmoneyrealtaste/application/member/listener/MemberDomainEventListenerTest.kt b/src/test/kotlin/com/albert/realmoneyrealtaste/application/member/listener/MemberDomainEventListenerTest.kt new file mode 100644 index 00000000..02f03b65 --- /dev/null +++ b/src/test/kotlin/com/albert/realmoneyrealtaste/application/member/listener/MemberDomainEventListenerTest.kt @@ -0,0 +1,377 @@ +package com.albert.realmoneyrealtaste.application.member.listener + +import com.albert.realmoneyrealtaste.application.friend.required.FriendshipRepository +import com.albert.realmoneyrealtaste.application.member.event.EmailSendRequestedEvent +import com.albert.realmoneyrealtaste.application.member.provided.ActivationTokenGenerator +import com.albert.realmoneyrealtaste.application.member.required.MemberRepository +import com.albert.realmoneyrealtaste.domain.friend.FriendshipStatus +import com.albert.realmoneyrealtaste.domain.friend.event.FriendRequestAcceptedEvent +import com.albert.realmoneyrealtaste.domain.friend.event.FriendshipTerminatedEvent +import com.albert.realmoneyrealtaste.domain.member.ActivationToken +import com.albert.realmoneyrealtaste.domain.member.event.MemberRegisteredDomainEvent +import com.albert.realmoneyrealtaste.domain.post.event.PostCreatedEvent +import com.albert.realmoneyrealtaste.domain.post.event.PostDeletedEvent +import io.mockk.every +import io.mockk.impl.annotations.InjectMockKs +import io.mockk.impl.annotations.MockK +import io.mockk.junit5.MockKExtension +import io.mockk.slot +import io.mockk.verify +import io.mockk.verifyOrder +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.extension.ExtendWith +import org.springframework.context.ApplicationEventPublisher +import java.time.LocalDateTime +import kotlin.test.assertEquals + +@ExtendWith(MockKExtension::class) +class MemberDomainEventListenerTest { + + @MockK(relaxed = true) + private lateinit var activationTokenGenerator: ActivationTokenGenerator + + @MockK(relaxed = true) + private lateinit var eventPublisher: ApplicationEventPublisher + + @MockK(relaxed = true) + private lateinit var memberRepository: MemberRepository + + @MockK(relaxed = true) + private lateinit var friendshipRepository: FriendshipRepository + + @InjectMockKs + private lateinit var memberDomainEventListener: MemberDomainEventListener + + @Test + fun `handlePostCreated - success - increments member post count`() { + // Given + val authorMemberId = 1L + val postId = 2L + val restaurantName = "맛있는 식당" + val occurredAt = LocalDateTime.now() + + val event = PostCreatedEvent( + postId = postId, + authorMemberId = authorMemberId, + restaurantName = restaurantName, + occurredAt = occurredAt, + ) + + // When + memberDomainEventListener.handlePostCreated(event) + + // Then + verify(exactly = 1) { + memberRepository.incrementPostCount(authorMemberId) + } + } + + @Test + fun `handlePostDeleted - success - decrements member post count`() { + // Given + val authorMemberId = 1L + val postId = 2L + val occurredAt = LocalDateTime.now() + + val event = PostDeletedEvent( + postId = postId, + authorMemberId = authorMemberId, + occurredAt = occurredAt + ) + + // When + memberDomainEventListener.handlePostDeleted(event) + + // Then + verify(exactly = 1) { + memberRepository.decrementPostCount(authorMemberId) + } + } + + @Test + fun `handleFriendRequestAccepted - success - updates follower and following counts`() { + // Given + val friendshipId = 4L + val fromMemberId = 1L + val toMemberId = 2L + val fromMemberFollowingsCount = 5L + val toMemberFollowersCount = 3L + + val event = FriendRequestAcceptedEvent( + fromMemberId = fromMemberId, + toMemberId = toMemberId, + friendshipId = friendshipId, + occurredAt = LocalDateTime.now() + ) + + every { + friendshipRepository.countFriends(fromMemberId, FriendshipStatus.ACCEPTED) + } returns fromMemberFollowingsCount + + every { + friendshipRepository.countFriends(toMemberId, FriendshipStatus.ACCEPTED) + } returns toMemberFollowersCount + + // When + memberDomainEventListener.handleFriendRequestAccepted(event) + + // Then + verifyOrder { + friendshipRepository.countFriends(fromMemberId, FriendshipStatus.ACCEPTED) + memberRepository.updateFollowingsCount(fromMemberId, fromMemberFollowingsCount) + + friendshipRepository.countFriends(toMemberId, FriendshipStatus.ACCEPTED) + memberRepository.updateFollowersCount(toMemberId, toMemberFollowersCount) + } + } + + @Test + fun `handleFriendRequestAccepted - success - handles zero counts`() { + // Given + val fromMemberId = 1L + val toMemberId = 2L + val friendshipId = 3L + + val event = FriendRequestAcceptedEvent( + fromMemberId = fromMemberId, + toMemberId = toMemberId, + friendshipId = friendshipId, + occurredAt = LocalDateTime.now(), + ) + + every { + friendshipRepository.countFriends(fromMemberId, FriendshipStatus.ACCEPTED) + } returns 0 + + every { + friendshipRepository.countFriends(toMemberId, FriendshipStatus.ACCEPTED) + } returns 0 + + // When + memberDomainEventListener.handleFriendRequestAccepted(event) + + // Then + verify(exactly = 1) { + memberRepository.updateFollowingsCount(fromMemberId, 0) + } + verify(exactly = 1) { + memberRepository.updateFollowersCount(toMemberId, 0) + } + } + + @Test + fun `handleFriendshipTerminated - success - updates follower and following counts`() { + // Given + val memberId = 1L + val friendMemberId = 2L + val memberFollowingsCount = 4L + val friendFollowersCount = 2L + val friendshipId = 3L + + val event = FriendshipTerminatedEvent( + friendshipId = friendshipId, + memberId = memberId, + friendMemberId = friendMemberId, + occurredAt = LocalDateTime.now() + ) + + every { + friendshipRepository.countFriends(memberId, FriendshipStatus.ACCEPTED) + } returns memberFollowingsCount + + every { + friendshipRepository.countFriends(friendMemberId, FriendshipStatus.ACCEPTED) + } returns friendFollowersCount + + // When + memberDomainEventListener.handleFriendshipTerminated(event) + + // Then + verifyOrder { + friendshipRepository.countFriends(memberId, FriendshipStatus.ACCEPTED) + memberRepository.updateFollowingsCount(memberId, memberFollowingsCount) + + friendshipRepository.countFriends(friendMemberId, FriendshipStatus.ACCEPTED) + memberRepository.updateFollowersCount(friendMemberId, friendFollowersCount) + } + } + + @Test + fun `handleFriendshipTerminated - success - handles single friend remaining`() { + // Given + val memberId = 1L + val friendMemberId = 2L + val friendshipId = 3L + + val event = FriendshipTerminatedEvent( + memberId = memberId, + friendMemberId = friendMemberId, + occurredAt = LocalDateTime.now(), + friendshipId = friendshipId + ) + + every { + friendshipRepository.countFriends(memberId, FriendshipStatus.ACCEPTED) + } returns 1 + + every { + friendshipRepository.countFriends(friendMemberId, FriendshipStatus.ACCEPTED) + } returns 1 + + // When + memberDomainEventListener.handleFriendshipTerminated(event) + + // Then + verify(exactly = 1) { + memberRepository.updateFollowingsCount(memberId, 1) + } + verify(exactly = 1) { + memberRepository.updateFollowersCount(friendMemberId, 1) + } + } + + @Test + fun `handleMemberRegistered - success - generates token and publishes activation email event`() { + // Given + val memberId = 1L + val email = "test@example.com" + val nickname = "테스트유저" + val activationToken = ActivationToken( + memberId = memberId, + token = "test-activation-token-123", + createdAt = LocalDateTime.now(), + expiresAt = LocalDateTime.now().plusHours(24) + ) + + val event = MemberRegisteredDomainEvent( + memberId = memberId, + email = email, + nickname = nickname, + occurredAt = LocalDateTime.now(), + ) + + every { activationTokenGenerator.generate(memberId) } returns activationToken + + val eventSlot = slot() + + // When + memberDomainEventListener.handleMemberRegistered(event) + + // Then + verify(exactly = 1) { activationTokenGenerator.generate(memberId) } + verify(exactly = 1) { + eventPublisher.publishEvent(capture(eventSlot)) + } + + val publishedEvent = eventSlot.captured + assertEquals(email, publishedEvent.email.address) + assertEquals(nickname, publishedEvent.nickname.value) + assertEquals(activationToken, publishedEvent.activationToken) + } + + @Test + fun `handleMemberRegistered - success - handles different member data`() { + // Given + val memberId = 999L + val email = "different@example.com" + val nickname = "다른유저" + val activationToken = ActivationToken( + memberId = memberId, + token = "different-token-456", + createdAt = LocalDateTime.now(), + expiresAt = LocalDateTime.now().plusHours(24) + ) + + val event = MemberRegisteredDomainEvent( + memberId = memberId, + email = email, + nickname = nickname, + ) + + every { activationTokenGenerator.generate(memberId) } returns activationToken + + val eventSlot = slot() + + // When + memberDomainEventListener.handleMemberRegistered(event) + + // Then + verify(exactly = 1) { activationTokenGenerator.generate(memberId) } + verify(exactly = 1) { + eventPublisher.publishEvent(capture(eventSlot)) + } + + val publishedEvent = eventSlot.captured + assertEquals(email, publishedEvent.email.address) + assertEquals(nickname, publishedEvent.nickname.value) + assertEquals(activationToken, publishedEvent.activationToken) + } + + @Test + fun `integration - all event handlers work correctly`() { + // Given + val memberId = 1L + val postId = 2L + val fromMemberId = 3L + val toMemberId = 4L + val friendshipId = 5L + val activationToken = ActivationToken( + memberId = memberId, + token = "integration-token-123", + createdAt = LocalDateTime.now(), + expiresAt = LocalDateTime.now().plusHours(24) + ) + + val postCreatedEvent = PostCreatedEvent( + postId = postId, + authorMemberId = memberId, + restaurantName = "통합테스트", + occurredAt = LocalDateTime.now() + ) + + val postDeletedEvent = PostDeletedEvent( + postId = postId, + authorMemberId = memberId, + occurredAt = LocalDateTime.now(), + ) + + val friendRequestEvent = FriendRequestAcceptedEvent( + fromMemberId = fromMemberId, + toMemberId = toMemberId, + friendshipId = friendshipId, + occurredAt = LocalDateTime.now(), + ) + + val memberRegisteredEvent = MemberRegisteredDomainEvent( + memberId = memberId, + email = "integration@example.com", + nickname = "통합테스트", + ) + + every { + friendshipRepository.countFriends(fromMemberId, FriendshipStatus.ACCEPTED) + } returns 10 + + every { + friendshipRepository.countFriends(toMemberId, FriendshipStatus.ACCEPTED) + } returns 5 + + every { activationTokenGenerator.generate(memberId) } returns activationToken + + // When + memberDomainEventListener.handlePostCreated(postCreatedEvent) + memberDomainEventListener.handlePostDeleted(postDeletedEvent) + memberDomainEventListener.handleFriendRequestAccepted(friendRequestEvent) + memberDomainEventListener.handleMemberRegistered(memberRegisteredEvent) + + // Then + verify(exactly = 1) { memberRepository.incrementPostCount(memberId) } + verify(exactly = 1) { memberRepository.decrementPostCount(memberId) } + verify(exactly = 1) { memberRepository.updateFollowingsCount(fromMemberId, 10) } + verify(exactly = 1) { memberRepository.updateFollowersCount(toMemberId, 5) } + verify(exactly = 1) { activationTokenGenerator.generate(memberId) } + verify(exactly = 1) { + eventPublisher.publishEvent(any()) + } + } +} diff --git a/src/test/kotlin/com/albert/realmoneyrealtaste/application/member/listener/MemberEventListenerTest.kt b/src/test/kotlin/com/albert/realmoneyrealtaste/application/member/listener/MemberEventListenerTest.kt deleted file mode 100644 index 254be66b..00000000 --- a/src/test/kotlin/com/albert/realmoneyrealtaste/application/member/listener/MemberEventListenerTest.kt +++ /dev/null @@ -1,54 +0,0 @@ -package com.albert.realmoneyrealtaste.application.member.listener - -import com.albert.realmoneyrealtaste.IntegrationTestBase -import com.albert.realmoneyrealtaste.application.member.event.MemberRegisteredEvent -import com.albert.realmoneyrealtaste.application.member.event.ResendActivationEmailEvent -import com.albert.realmoneyrealtaste.application.member.required.EmailSender -import com.albert.realmoneyrealtaste.config.TestEmailSender -import com.albert.realmoneyrealtaste.util.MemberFixture -import kotlin.test.Test -import kotlin.test.assertEquals - -class MemberEventListenerTest( - val memberEventListener: MemberEventListener, - emailSender: EmailSender, -) : IntegrationTestBase() { - - var testEmailSender: TestEmailSender = emailSender as TestEmailSender - - @Test - fun `handleMemberRegistered - success - sends activation email`() { - val memberId = 1L - val email = MemberFixture.DEFAULT_EMAIL - val nickname = MemberFixture.DEFAULT_NICKNAME - val activationToken = MemberFixture.createActivationToken(memberId) - val event = MemberRegisteredEvent( - email = email, - nickname = nickname, - activationToken = activationToken - ) - - testEmailSender.clear() - memberEventListener.handleMemberRegistered(event) - - assertEquals(1, testEmailSender.count()) - } - - @Test - fun `handleResendActivationEmail - success - sends activation email`() { - val memberId = 2L - val email = MemberFixture.DEFAULT_EMAIL - val nickname = MemberFixture.DEFAULT_NICKNAME - val activationToken = MemberFixture.createActivationToken(memberId) - val event = ResendActivationEmailEvent( - email = email, - nickname = nickname, - activationToken = activationToken - ) - - testEmailSender.clear() - memberEventListener.handleResendActivationEmail(event) - - assertEquals(1, testEmailSender.count()) - } -} diff --git a/src/test/kotlin/com/albert/realmoneyrealtaste/application/member/provided/MemberActivateTest.kt b/src/test/kotlin/com/albert/realmoneyrealtaste/application/member/provided/MemberActivateTest.kt index 5506abf2..16d67336 100644 --- a/src/test/kotlin/com/albert/realmoneyrealtaste/application/member/provided/MemberActivateTest.kt +++ b/src/test/kotlin/com/albert/realmoneyrealtaste/application/member/provided/MemberActivateTest.kt @@ -32,8 +32,7 @@ class MemberActivateTest( nickname = MemberFixture.DEFAULT_NICKNAME ) val member = memberRegister.register(request) - val token = activationTokenRepository.findByMemberId(member.id!!) - ?: throw IllegalStateException("Activation token not found for member id: ${member.id}") + val token = activationTokenGenerator.generate(member.id!!) val activatedMember = memberActivate.activate(token.token) @@ -62,8 +61,7 @@ class MemberActivateTest( nickname = MemberFixture.DEFAULT_NICKNAME ) val member = memberRegister.register(request) - val token = activationTokenRepository.findByMemberId(member.id!!) - ?: throw IllegalStateException("Activation token not found for member id: ${member.id}") + val token = activationTokenGenerator.generate(member.id!!) memberActivate.activate(token.token) @@ -122,8 +120,8 @@ class MemberActivateTest( ) val member = memberRegister.register(request) member.activate() - val token = activationTokenRepository.findByMemberId(member.requireId()) - ?: throw IllegalStateException("Activation token not found for member id: ${member.id}") + + val token = activationTokenGenerator.generate(member.requireId()) assertFailsWith { memberActivate.activate(token.token) @@ -163,8 +161,7 @@ class MemberActivateTest( nickname = MemberFixture.DEFAULT_NICKNAME ) val member = memberRegister.register(request) - val token = activationTokenRepository.findByMemberId(member.id!!) - ?: throw IllegalStateException("활성 토큰을 찾을 수 없습니다. 회원 ID: ${member.id}") + val token = activationTokenGenerator.generate(member.id!!) memberActivate.activate(token.token) 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 17848cc0..f4dd47d0 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 @@ -479,7 +479,7 @@ class MemberReaderTest( 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")) + createActiveMember(email = Email("active3@example.com")) // targetMember가 activeMember1과 inactiveMember2를 팔로우 createFollowRelationship(targetMember, activeMember1) @@ -560,8 +560,8 @@ class MemberReaderTest( followingId = following.id!!, followerNickname = follower.nickname.value, followingNickname = following.nickname.value, - followerProfileImageId = follower.imageId, - followingProfileImageId = following.imageId + followerProfileImageId = follower.profileImageId, + followingProfileImageId = following.profileImageId ) val follow = Follow.create(command) 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 e15000fb..23617c13 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 @@ -1,7 +1,6 @@ package com.albert.realmoneyrealtaste.application.member.provided import com.albert.realmoneyrealtaste.IntegrationTestBase -import com.albert.realmoneyrealtaste.application.member.event.PasswordResetRequestedEvent import com.albert.realmoneyrealtaste.application.member.exception.PassWordResetException import com.albert.realmoneyrealtaste.application.member.exception.PasswordResetTokenNotFoundException import com.albert.realmoneyrealtaste.application.member.exception.SendPasswordResetEmailException @@ -42,12 +41,12 @@ class PasswordResetterTest( @Test fun `sendPasswordResetEmail - success - generates token and publishes event`() { val member = createMember() - member.activate() - memberRepository.save(member) + val savedMember = memberRepository.save(member) + savedMember.activate() - passwordResetter.sendPasswordResetEmail(member.email.address) + passwordResetter.sendPasswordResetEmail(savedMember.email.address) - val savedToken = passwordResetTokenReader.findByMemberId(member.requireId()) + val savedToken = passwordResetTokenReader.findByMemberId(savedMember.requireId()) assertNotNull(savedToken) } @@ -60,23 +59,22 @@ class PasswordResetterTest( passwordResetter.sendPasswordResetEmail(nonExistentEmail.address) } - assertEquals(applicationEvents.stream(PasswordResetRequestedEvent::class.java).count().toInt(), 0) } @Test fun `sendPasswordResetEmail - success - deletes existing token before generating new one`() { val member = createMember() - member.activate() - memberRepository.save(member) + val savedMember = memberRepository.save(member) + savedMember.activate() - passwordResetter.sendPasswordResetEmail(member.email.address) + passwordResetter.sendPasswordResetEmail(savedMember.email.address) - val firstToken = passwordResetTokenReader.findByMemberId(member.requireId()) + val firstToken = passwordResetTokenReader.findByMemberId(savedMember.requireId()) assertNotNull(firstToken) - passwordResetter.sendPasswordResetEmail(member.email.address) + passwordResetter.sendPasswordResetEmail(savedMember.email.address) - val secondToken = passwordResetTokenReader.findByMemberId(member.requireId()) + val secondToken = passwordResetTokenReader.findByMemberId(savedMember.requireId()) assertAll( { assertNotNull(secondToken) }, { assertTrue(firstToken.token != secondToken.token) } @@ -86,15 +84,15 @@ class PasswordResetterTest( @Test fun `sendPasswordResetEmail - success - creates valid token`() { val member = createMember() - member.activate() - memberRepository.save(member) + val savedMember = memberRepository.save(member) + savedMember.activate() - passwordResetter.sendPasswordResetEmail(member.email.address) + passwordResetter.sendPasswordResetEmail(savedMember.email.address) - val savedToken = passwordResetTokenReader.findByMemberId(member.requireId()) + val savedToken = passwordResetTokenReader.findByMemberId(savedMember.requireId()) assertAll( { assertNotNull(savedToken) }, - { assertEquals(member.requireId(), savedToken.memberId) }, + { assertEquals(savedMember.requireId(), savedToken.memberId) }, { assertNotNull(savedToken.token) }, { assertTrue(savedToken.token.isNotEmpty()) }, { assertNotNull(savedToken.createdAt) }, @@ -106,17 +104,17 @@ class PasswordResetterTest( @Test fun `resetPassword - success - changes password and deletes token`() { val member = createMember() - member.activate() - memberRepository.save(member) + val savedMember = memberRepository.save(member) + savedMember.activate() - passwordResetter.sendPasswordResetEmail(member.email.address) + passwordResetter.sendPasswordResetEmail(savedMember.email.address) - val token = passwordResetTokenReader.findByMemberId(member.requireId()) + val token = passwordResetTokenReader.findByMemberId(savedMember.requireId()) val newPassword = RawPassword("newPassword123!") passwordResetter.resetPassword(token.token, newPassword) - val updatedMember = memberReader.readMemberById(member.requireId()) + val updatedMember = memberReader.readMemberById(savedMember.requireId()) assertAll( { assertTrue(updatedMember.verifyPassword(newPassword, passwordEncoder)) }, { assertFailsWith { passwordResetTokenReader.findByMemberId(member.requireId()) } } @@ -136,8 +134,8 @@ class PasswordResetterTest( @Test fun `resetPassword - failure - throws exception when token is expired`() { val member = createMember() - member.activate() memberRepository.save(member) + member.activate() val expiredToken = PasswordResetToken( memberId = member.requireId(), @@ -161,18 +159,18 @@ class PasswordResetterTest( @Test fun `resetPassword - success - old password no longer works after reset`() { val member = createMember() - member.activate() val oldPassword = MemberFixture.DEFAULT_RAW_PASSWORD - memberRepository.save(member) + val savedMember = memberRepository.save(member) + savedMember.activate() - passwordResetter.sendPasswordResetEmail(member.email.address) + passwordResetter.sendPasswordResetEmail(savedMember.email.address) - val token = passwordResetTokenReader.findByMemberId(member.requireId()) + val token = passwordResetTokenReader.findByMemberId(savedMember.requireId()) val newPassword = RawPassword("newPassword123!") passwordResetter.resetPassword(token.token, newPassword) - val updatedMember = memberReader.readMemberById(member.requireId()) + val updatedMember = memberReader.readMemberById(savedMember.requireId()) assertAll( { assertTrue(updatedMember.verifyPassword(newPassword, passwordEncoder)) }, { assertTrue(!updatedMember.verifyPassword(oldPassword, passwordEncoder)) } @@ -182,12 +180,12 @@ class PasswordResetterTest( @Test fun `resetPassword - failure - cannot reuse token after successful reset`() { val member = createMember() - member.activate() - memberRepository.save(member) + val savedMember = memberRepository.save(member) + savedMember.activate() - passwordResetter.sendPasswordResetEmail(member.email.address) + passwordResetter.sendPasswordResetEmail(savedMember.email.address) - val token = passwordResetTokenReader.findByMemberId(member.requireId()) + val token = passwordResetTokenReader.findByMemberId(savedMember.requireId()) val newPassword = RawPassword("newPassword123!") passwordResetter.resetPassword(token.token, newPassword) @@ -203,23 +201,23 @@ class PasswordResetterTest( fun `sendPasswordResetEmail - success - multiple members can have tokens simultaneously`() { val member1 = createMember(email = Email("member1@example.com")) val member2 = createMember(email = Email("member2@example.com")) - member1.activate() - member2.activate() - memberRepository.save(member1) - memberRepository.save(member2) + val savedMember1 = memberRepository.save(member1) + val savedMember2 = memberRepository.save(member2) + savedMember1.activate() + savedMember2.activate() - passwordResetter.sendPasswordResetEmail(member1.email.address) - passwordResetter.sendPasswordResetEmail(member2.email.address) + passwordResetter.sendPasswordResetEmail(savedMember1.email.address) + passwordResetter.sendPasswordResetEmail(savedMember2.email.address) - val token1 = passwordResetTokenReader.findByMemberId(member1.requireId()) - val token2 = passwordResetTokenReader.findByMemberId(member2.requireId()) + val token1 = passwordResetTokenReader.findByMemberId(savedMember1.requireId()) + val token2 = passwordResetTokenReader.findByMemberId(savedMember2.requireId()) assertAll( { assertNotNull(token1) }, { assertNotNull(token2) }, { assertTrue(token1.token != token2.token) }, - { assertEquals(member1.requireId(), token1.memberId) }, - { assertEquals(member2.requireId(), token2.memberId) } + { assertEquals(savedMember1.requireId(), token1.memberId) }, + { assertEquals(savedMember2.requireId(), token2.memberId) } ) } @@ -228,20 +226,22 @@ class PasswordResetterTest( val password = MemberFixture.DEFAULT_RAW_PASSWORD val member1 = createMember(email = Email("member1@example.com"), password = password) val member2 = createMember(email = Email("member2@example.com"), password = password) - member1.activate() - member2.activate() - memberRepository.save(member1) - memberRepository.save(member2) - passwordResetter.sendPasswordResetEmail(member1.email.address) + val savedMember1 = memberRepository.save(member1) + val savedMember2 = memberRepository.save(member2) + + savedMember1.activate() + savedMember2.activate() + + passwordResetter.sendPasswordResetEmail(savedMember1.email.address) - val token1 = passwordResetTokenReader.findByMemberId(member1.requireId()) + val token1 = passwordResetTokenReader.findByMemberId(savedMember1.requireId()) val newPassword = RawPassword("newPassword123!") passwordResetter.resetPassword(token1.token, newPassword) - val updatedMember1 = memberReader.readMemberById(member1.requireId()) - val updatedMember2 = memberReader.readMemberById(member2.requireId()) + val updatedMember1 = memberReader.readMemberById(savedMember1.requireId()) + val updatedMember2 = memberReader.readMemberById(savedMember2.requireId()) assertAll( { assertTrue(updatedMember1.verifyPassword(newPassword, passwordEncoder)) }, diff --git a/src/test/kotlin/com/albert/realmoneyrealtaste/application/post/listener/PostDomainEventListenerTest.kt b/src/test/kotlin/com/albert/realmoneyrealtaste/application/post/listener/PostDomainEventListenerTest.kt new file mode 100644 index 00000000..52519306 --- /dev/null +++ b/src/test/kotlin/com/albert/realmoneyrealtaste/application/post/listener/PostDomainEventListenerTest.kt @@ -0,0 +1,265 @@ +package com.albert.realmoneyrealtaste.application.post.listener + +import com.albert.realmoneyrealtaste.application.post.required.PostRepository +import com.albert.realmoneyrealtaste.domain.comment.event.CommentCreatedEvent +import com.albert.realmoneyrealtaste.domain.comment.event.CommentDeletedEvent +import com.albert.realmoneyrealtaste.domain.member.event.MemberProfileUpdatedDomainEvent +import io.mockk.impl.annotations.InjectMockKs +import io.mockk.impl.annotations.MockK +import io.mockk.junit5.MockKExtension +import io.mockk.verify +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.extension.ExtendWith +import java.time.LocalDateTime + +@ExtendWith(MockKExtension::class) +class PostDomainEventListenerTest { + + @MockK(relaxed = true) + private lateinit var postRepository: PostRepository + + @InjectMockKs + private lateinit var postDomainEventListener: PostDomainEventListener + + @Test + fun `handleMemberProfileUpdated - success - updates author info with nickname and imageId`() { + // Given + val memberId = 1L + val email = "test@example.com" + val nickname = "새로운닉네임" + val imageId = 999L + val event = MemberProfileUpdatedDomainEvent( + memberId = memberId, + email = email, + updatedFields = listOf("nickname", "imageId"), + nickname = nickname, + imageId = imageId + ) + + // When + postDomainEventListener.handleMemberProfileUpdated(event) + + // Then + verify(exactly = 1) { + postRepository.updateAuthorInfo( + authorMemberId = memberId, + nickname = nickname, + introduction = null, + imageId = imageId + ) + } + } + + @Test + fun `handleMemberProfileUpdated - success - updates author info with only nickname`() { + // Given + val memberId = 1L + val email = "test@example.com" + val nickname = "바뀐닉네임" + val event = MemberProfileUpdatedDomainEvent( + memberId = memberId, + email = email, + updatedFields = listOf("nickname"), + nickname = nickname, + imageId = null + ) + + // When + postDomainEventListener.handleMemberProfileUpdated(event) + + // Then + verify(exactly = 1) { + postRepository.updateAuthorInfo( + authorMemberId = memberId, + nickname = nickname, + introduction = null, + imageId = null + ) + } + } + + @Test + fun `handleMemberProfileUpdated - success - updates author info with only imageId`() { + // Given + val memberId = 1L + val email = "test@example.com" + val imageId = 888L + val event = MemberProfileUpdatedDomainEvent( + memberId = memberId, + email = email, + updatedFields = listOf("imageId"), + nickname = null, + imageId = imageId + ) + + // When + postDomainEventListener.handleMemberProfileUpdated(event) + + // Then + verify(exactly = 1) { + postRepository.updateAuthorInfo( + authorMemberId = memberId, + nickname = null, + introduction = null, + imageId = imageId + ) + } + } + + @Test + fun `handleCommentCreated - success - increments post comment count`() { + // Given + val commentId = 1L + val postId = 2L + val authorMemberId = 3L + val createdAt = LocalDateTime.now() + val event = CommentCreatedEvent( + commentId = commentId, + postId = postId, + authorMemberId = authorMemberId, + parentCommentId = null, + parentCommentAuthorId = null, + createdAt = createdAt + ) + + // When + postDomainEventListener.handleCommentCreated(event) + + // Then + verify(exactly = 1) { + postRepository.incrementCommentCount(postId) + } + } + + @Test + fun `handleCommentCreated - success - increments comment count for reply`() { + // Given + val commentId = 1L + val postId = 2L + val authorMemberId = 3L + val parentCommentId = 10L + val parentCommentAuthorId = 4L + val createdAt = LocalDateTime.now() + val event = CommentCreatedEvent( + commentId = commentId, + postId = postId, + authorMemberId = authorMemberId, + parentCommentId = parentCommentId, + parentCommentAuthorId = parentCommentAuthorId, + createdAt = createdAt + ) + + // When + postDomainEventListener.handleCommentCreated(event) + + // Then + verify(exactly = 1) { + postRepository.incrementCommentCount(postId) + } + } + + @Test + fun `handleCommentDeleted - success - decrements post comment count`() { + // Given + val commentId = 1L + val postId = 2L + val deletedAt = LocalDateTime.now() + val authorMemberId = 3L + val event = CommentDeletedEvent( + commentId = commentId, + postId = postId, + deletedAt = deletedAt, + parentCommentId = null, + authorMemberId = authorMemberId, + ) + + // When + postDomainEventListener.handleCommentDeleted(event) + + // Then + verify(exactly = 1) { + postRepository.decrementCommentCount(postId) + } + } + + @Test + fun `handleCommentDeleted - success - decrements comment count for deleted reply`() { + // Given + val commentId = 1L + val postId = 2L + val parentCommentId = 10L + val deletedAt = LocalDateTime.now() + val authorMemberId = 3L + val event = CommentDeletedEvent( + commentId = commentId, + postId = postId, + parentCommentId = parentCommentId, + deletedAt = deletedAt, + authorMemberId = authorMemberId, + ) + + // When + postDomainEventListener.handleCommentDeleted(event) + + // Then + verify(exactly = 1) { + postRepository.decrementCommentCount(postId) + } + } + + @Test + fun `integration - all event handlers work correctly`() { + // Given + val memberId = 1L + val postId = 2L + val authorMemberId = 3L + + val profileEvent = MemberProfileUpdatedDomainEvent( + memberId = memberId, + email = "test@example.com", + updatedFields = listOf("nickname"), + nickname = "통합테스트닉네임", + imageId = null + ) + + val commentCreatedEvent = CommentCreatedEvent( + commentId = 1L, + postId = postId, + authorMemberId = 3L, + parentCommentId = null, + parentCommentAuthorId = null, + createdAt = LocalDateTime.now() + ) + + val commentDeletedEvent = CommentDeletedEvent( + commentId = 2L, + postId = postId, + parentCommentId = null, + deletedAt = LocalDateTime.now(), + authorMemberId = authorMemberId, + ) + + // When + postDomainEventListener.handleMemberProfileUpdated(profileEvent) + postDomainEventListener.handleCommentCreated(commentCreatedEvent) + postDomainEventListener.handleCommentDeleted(commentDeletedEvent) + + // Then + verify(exactly = 1) { + postRepository.updateAuthorInfo( + authorMemberId = memberId, + nickname = "통합테스트닉네임", + introduction = null, + imageId = null + ) + } + + verify(exactly = 1) { + postRepository.incrementCommentCount(postId) + } + + verify(exactly = 1) { + postRepository.decrementCommentCount(postId) + } + } +} diff --git a/src/test/kotlin/com/albert/realmoneyrealtaste/application/post/provided/PostCreatorTest.kt b/src/test/kotlin/com/albert/realmoneyrealtaste/application/post/provided/PostCreatorTest.kt index 8fe0f41f..7416e215 100644 --- a/src/test/kotlin/com/albert/realmoneyrealtaste/application/post/provided/PostCreatorTest.kt +++ b/src/test/kotlin/com/albert/realmoneyrealtaste/application/post/provided/PostCreatorTest.kt @@ -171,7 +171,7 @@ class PostCreatorTest( } @Test - fun `createPost - success - uses empty introduction when member has no introduction`() { + fun `createPost - success - uses default introduction when member has no introduction`() { val member = testMemberHelper.createActivatedMember() val request = createPostRequest() @@ -180,7 +180,7 @@ class PostCreatorTest( assertNotNull(result.id) assertEquals(member.id, result.author.memberId) assertEquals(member.nickname.value, result.author.nickname) - assertEquals("", result.author.introduction) // introduction이 없으면 빈 문자열 + assertEquals("아직 자기소개가 없어요!", result.author.introduction) } private fun createPostRequest( diff --git a/src/test/kotlin/com/albert/realmoneyrealtaste/application/post/required/PostRepositoryTest.kt b/src/test/kotlin/com/albert/realmoneyrealtaste/application/post/required/PostRepositoryTest.kt new file mode 100644 index 00000000..215da10c --- /dev/null +++ b/src/test/kotlin/com/albert/realmoneyrealtaste/application/post/required/PostRepositoryTest.kt @@ -0,0 +1,681 @@ +package com.albert.realmoneyrealtaste.application.post.required + +import com.albert.realmoneyrealtaste.IntegrationTestBase +import com.albert.realmoneyrealtaste.application.post.dto.PostSearchCondition +import com.albert.realmoneyrealtaste.domain.post.PostStatus +import com.albert.realmoneyrealtaste.domain.post.value.PostContent +import com.albert.realmoneyrealtaste.domain.post.value.Restaurant +import com.albert.realmoneyrealtaste.util.PostFixture +import com.albert.realmoneyrealtaste.util.TestPostHelper +import org.springframework.data.domain.PageRequest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNotNull +import kotlin.test.assertNull +import kotlin.test.assertTrue + +class PostRepositoryTest( + val postRepository: PostRepository, + val testPostHelper: TestPostHelper, +) : IntegrationTestBase() { + + @Test + fun `save - success - saves and returns post with ID`() { + val post = PostFixture.createPost( + authorMemberId = 1L, + authorNickname = "작성자", + restaurant = Restaurant( + name = "새로운 맛집", + address = "서울시 강남구", + latitude = 37.5, + longitude = 127.0 + ), + content = PostContent( + text = "새로운 게시글", + rating = 4 + ) + ) + + val savedPost = postRepository.save(post) + + assertNotNull(savedPost.id) + assertTrue(savedPost.requireId() > 0) + assertEquals("작성자", savedPost.author.nickname) + assertEquals("새로운 맛집", savedPost.restaurant.name) + assertEquals("새로운 게시글", savedPost.content.text) + } + + @Test + fun `findById - success - returns post when exists`() { + val createdPost = testPostHelper.createPost(authorMemberId = 1L) + + val foundPost = postRepository.findById(createdPost.requireId()) + + assertNotNull(foundPost) + assertEquals(createdPost.id, foundPost.id) + assertEquals(createdPost.author.nickname, foundPost.author.nickname) + } + + @Test + fun `findById - success - returns null when not exists`() { + val foundPost = postRepository.findById(99999L) + + assertNull(foundPost) + } + + @Test + fun `findByAuthorMemberIdAndStatusNot - success - returns author's posts excluding deleted`() { + val authorId = 1L + + // 작성자의 게시글 3개 생성 + val post1 = testPostHelper.createPost(authorMemberId = authorId) + val post2 = testPostHelper.createPost(authorMemberId = authorId) + val post3 = testPostHelper.createPost(authorMemberId = authorId) + + // 다른 작성자의 게시글 + testPostHelper.createPost(authorMemberId = 2L) + + // 삭제 상태의 게시글 + val deletedPost = testPostHelper.createPost(authorMemberId = authorId) + deletedPost.delete(authorId) + postRepository.save(deletedPost) + + // 조회 + val result = postRepository.findByAuthorMemberIdAndStatusNot( + memberId = authorId, + pageable = PageRequest.of(0, 10) + ) + + assertEquals(10, result.size) + assertEquals(3, result.totalElements) + assertEquals(1, result.totalPages) + + val postIds = result.content.map { it.id } + assertTrue(postIds.contains(post1.id)) + assertTrue(postIds.contains(post2.id)) + assertTrue(postIds.contains(post3.id)) + assertFalse(postIds.contains(deletedPost.id)) + } + + @Test + fun `searchByRestaurantNameContainingAndStatusNot - success - finds posts by restaurant name`() { + // 맛집 이름으로 게시글 생성 + val post1 = testPostHelper.createPost( + authorMemberId = 1L, + restaurant = Restaurant( + name = "김치찌집", + address = "서울시", + latitude = 37.5, + longitude = 127.0 + ) + ) + + val post2 = testPostHelper.createPost( + authorMemberId = 2L, + restaurant = Restaurant( + name = "김치나라", + address = "부산시", + latitude = 35.0, + longitude = 129.0 + ) + ) + + // 다른 이름의 게시글 + testPostHelper.createPost( + authorMemberId = 3L, + restaurant = Restaurant( + name = "된장찌개", + address = "대구시", + latitude = 35.8, + longitude = 128.5 + ) + ) + + // "김치"로 검색 + val result = postRepository.searchByRestaurantNameContainingAndStatusNot( + name = "김치", + pageable = PageRequest.of(0, 10) + ) + + assertEquals(10, result.size) + assertEquals(2, result.totalElements) + val postIds = result.content.map { it.id } + assertTrue(postIds.contains(post1.id)) + assertTrue(postIds.contains(post2.id)) + } + + @Test + fun `searchByCondition - success - searches with all conditions`() { + // 데이터 생성 + val post1 = testPostHelper.createPost( + authorMemberId = 1L, + authorNickname = "맛집탐험가", + restaurant = Restaurant( + name = "맛있는김치찌개", + address = "서울시", + latitude = 37.5, + longitude = 127.0 + ), + content = PostContent(text = "최고!", rating = 5) + ) + + val post2 = testPostHelper.createPost( + authorMemberId = 2L, + authorNickname = "맛집탐험가", + restaurant = Restaurant( + name = "맛있는된장찌개", + address = "부산시", + latitude = 35.0, + longitude = 129.0 + ), + content = PostContent(text = "좋아요", rating = 3) + ) + + // 조건 검색 + val condition = PostSearchCondition( + restaurantName = "맛있는", + authorNickname = "맛집탐험가", + minRating = 3, + maxRating = 5 + ) + + val result = postRepository.searchByCondition(condition, PageRequest.of(0, 10)) + + assertEquals(10, result.size) + assertEquals(2, result.totalElements) + val postIds = result.content.map { it.id } + assertTrue(postIds.contains(post1.id)) + assertTrue(postIds.contains(post2.id)) + } + + @Test + fun `searchByCondition - success - searches with partial null conditions`() { + val post1 = testPostHelper.createPost( + authorMemberId = 1L, + authorNickname = "작성자A", + content = PostContent(text = "별로", rating = 2) + ) + + val post2 = testPostHelper.createPost( + authorMemberId = 2L, + authorNickname = "작성자B", + content = PostContent(text = "최고", rating = 5) + ) + + // 최소 평점만 지정 + val condition = PostSearchCondition( + restaurantName = null, + authorNickname = null, + minRating = 4, + maxRating = null + ) + + val result = postRepository.searchByCondition(condition, PageRequest.of(0, 10)) + + assertEquals(10, result.size) + assertEquals(1, result.totalElements) + assertEquals(post2.id, result.content[0].id) + } + + @Test + fun `existsByIdAndStatusNot - success - returns true for active post`() { + val post = testPostHelper.createPost(authorMemberId = 1L) + + val exists = postRepository.existsByIdAndStatusNot(post.requireId(), PostStatus.DELETED) + + assertTrue(exists) + } + + @Test + fun `existsByIdAndStatusNot - success - returns false for deleted post`() { + val post = testPostHelper.createPost(authorMemberId = 1L) + post.delete(1L) + val savedPost = postRepository.save(post) + + val exists = postRepository.existsByIdAndStatusNot(savedPost.requireId(), PostStatus.DELETED) + + assertFalse(exists) + } + + @Test + fun `incrementHeartCount - success - increases heart count`() { + val post = testPostHelper.createPost(authorMemberId = 1L) + val initialCount = post.heartCount + + postRepository.incrementHeartCount(post.requireId()) + entityManager.flush() + entityManager.clear() + + val updatedPost = postRepository.findById(post.requireId()) + assertNotNull(updatedPost) + assertEquals(initialCount + 1, updatedPost.heartCount) + } + + @Test + fun `decrementHeartCount - success - decreases heart count`() { + val post = testPostHelper.createPost(authorMemberId = 1L) + // 먼저 증가시킴 + postRepository.incrementHeartCount(post.requireId()) + entityManager.flush() + entityManager.clear() + + val postWithHeart = postRepository.findById(post.requireId()) + assertNotNull(postWithHeart) + val initialCount = postWithHeart.heartCount + + // 감소시킴 + postRepository.decrementHeartCount(post.requireId()) + entityManager.flush() + entityManager.clear() + + val updatedPost = postRepository.findById(post.requireId()) + assertNotNull(updatedPost) + assertEquals(initialCount - 1, updatedPost.heartCount) + } + + @Test + fun `decrementHeartCount - success - does not go below zero`() { + val post = testPostHelper.createPost(authorMemberId = 1L) + + // 0일 때 감소 시도 + postRepository.decrementHeartCount(post.requireId()) + entityManager.flush() + entityManager.clear() + + val updatedPost = postRepository.findById(post.requireId()) + assertNotNull(updatedPost) + assertEquals(0, updatedPost.heartCount) + } + + @Test + fun `incrementViewCount - success - increases view count`() { + val post = testPostHelper.createPost(authorMemberId = 1L) + val initialCount = post.viewCount + + postRepository.incrementViewCount(post.requireId()) + entityManager.flush() + entityManager.clear() + + val updatedPost = postRepository.findById(post.requireId()) + assertNotNull(updatedPost) + assertEquals(initialCount + 1, updatedPost.viewCount) + } + + @Test + fun `findAllByStatusNot - success - returns all posts excluding deleted`() { + val post1 = testPostHelper.createPost(authorMemberId = 1L) + val post2 = testPostHelper.createPost(authorMemberId = 2L) + + val deletedPost = testPostHelper.createPost(authorMemberId = 3L) + deletedPost.delete(3L) + postRepository.save(deletedPost) + + val result = postRepository.findAllByStatusNot( + status = PostStatus.DELETED, + pageable = PageRequest.of(0, 10) + ) + + assertEquals(10, result.size) + assertEquals(2, result.totalElements) + val postIds = result.content.map { it.id } + assertTrue(postIds.contains(post1.id)) + assertTrue(postIds.contains(post2.id)) + assertFalse(postIds.contains(deletedPost.id)) + } + + @Test + fun `existsByIdAndStatus - success - checks specific status`() { + val activePost = testPostHelper.createPost(authorMemberId = 1L) + val deletedPost = testPostHelper.createPost(authorMemberId = 2L) + deletedPost.delete(2L) + val savedDeletedPost = postRepository.save(deletedPost) + + assertTrue(postRepository.existsByIdAndStatus(activePost.requireId(), PostStatus.PUBLISHED)) + assertFalse(postRepository.existsByIdAndStatus(activePost.requireId(), PostStatus.DELETED)) + assertTrue(postRepository.existsByIdAndStatus(savedDeletedPost.requireId(), PostStatus.DELETED)) + assertFalse(postRepository.existsByIdAndStatus(savedDeletedPost.requireId(), PostStatus.PUBLISHED)) + } + + @Test + fun `countByAuthorMemberIdAndStatusNot - success - counts author's posts`() { + val authorId = 1L + + testPostHelper.createPost(authorMemberId = authorId) + testPostHelper.createPost(authorMemberId = authorId) + testPostHelper.createPost(authorMemberId = authorId) + + testPostHelper.createPost(authorMemberId = 2L) + + val deletedPost = testPostHelper.createPost(authorMemberId = authorId) + deletedPost.delete(authorId) + postRepository.save(deletedPost) + + val count = postRepository.countByAuthorMemberIdAndStatusNot(authorId, PostStatus.DELETED) + + assertEquals(3, count) + } + + @Test + fun `findAllByStatusAndIdIn - success - finds posts by IDs`() { + val post1 = testPostHelper.createPost(authorMemberId = 1L) + val post2 = testPostHelper.createPost(authorMemberId = 2L) + val post3 = testPostHelper.createPost(authorMemberId = 3L) + + val deletedPost = testPostHelper.createPost(authorMemberId = 4L) + deletedPost.delete(4L) + postRepository.save(deletedPost) + + val posts = postRepository.findAllByStatusAndIdIn( + status = PostStatus.PUBLISHED, + ids = listOf(post1.requireId(), post2.requireId(), post3.requireId(), deletedPost.requireId()) + ) + + assertEquals(3, posts.size) + val postIds = posts.map { it.id } + assertTrue(postIds.contains(post1.id)) + assertTrue(postIds.contains(post2.id)) + assertTrue(postIds.contains(post3.id)) + assertFalse(postIds.contains(deletedPost.id)) + } + + @Test + fun `findAllByStatusAndIdIn - success - returns empty list for empty IDs`() { + val posts = postRepository.findAllByStatusAndIdIn( + status = PostStatus.PUBLISHED, + ids = emptyList() + ) + + assertTrue(posts.isEmpty()) + assertEquals(0, posts.size) + } + + @Test + fun `incrementCommentCount - success - increases comment count`() { + val post = testPostHelper.createPost(authorMemberId = 1L) + val initialCount = post.commentCount + + postRepository.incrementCommentCount(post.requireId()) + entityManager.flush() + entityManager.clear() + + val updatedPost = postRepository.findById(post.requireId()) + assertNotNull(updatedPost) + assertEquals(initialCount + 1, updatedPost.commentCount) + } + + @Test + fun `decrementCommentCount - success - decreases comment count`() { + val post = testPostHelper.createPost(authorMemberId = 1L) + // 먼저 증가시킴 + postRepository.incrementCommentCount(post.requireId()) + entityManager.flush() + entityManager.clear() + + val postWithComment = postRepository.findById(post.requireId()) + assertNotNull(postWithComment) + val initialCount = postWithComment.commentCount + + // 감소시킴 + postRepository.decrementCommentCount(post.requireId()) + entityManager.flush() + entityManager.clear() + + val updatedPost = postRepository.findById(post.requireId()) + assertNotNull(updatedPost) + assertEquals(initialCount - 1, updatedPost.commentCount) + } + + @Test + fun `decrementCommentCount - success - does not go below zero`() { + val post = testPostHelper.createPost(authorMemberId = 1L) + + // 0일 때 감소 시도 + postRepository.decrementCommentCount(post.requireId()) + entityManager.flush() + entityManager.clear() + + val updatedPost = postRepository.findById(post.requireId()) + assertNotNull(updatedPost) + assertEquals(0, updatedPost.commentCount) + } + + @Test + fun `updateAuthorInfo - success - updates all author fields`() { + val authorId = 1L + val post1 = testPostHelper.createPost( + authorMemberId = authorId, + authorNickname = "기존닉네임" + ) + val post2 = testPostHelper.createPost( + authorMemberId = authorId, + authorNickname = "기존닉네임" + ) + + // 작성자 정보 업데이트 + postRepository.updateAuthorInfo( + authorMemberId = authorId, + nickname = "새로운닉네임", + introduction = "새로운소개", + imageId = 999L + ) + entityManager.flush() + entityManager.clear() + + // 확인 + val updatedPost1 = postRepository.findById(post1.requireId()) + val updatedPost2 = postRepository.findById(post2.requireId()) + + assertNotNull(updatedPost1) + assertNotNull(updatedPost2) + + assertEquals("새로운닉네임", updatedPost1.author.nickname) + assertEquals("새로운소개", updatedPost1.author.introduction) + assertEquals(999L, updatedPost1.author.imageId) + + assertEquals("새로운닉네임", updatedPost2.author.nickname) + assertEquals("새로운소개", updatedPost2.author.introduction) + assertEquals(999L, updatedPost2.author.imageId) + } + + @Test + fun `updateAuthorInfo - success - updates only nickname`() { + val authorId = 1L + val post = testPostHelper.createPost( + authorMemberId = authorId, + authorNickname = "기존닉네임" + ) + + postRepository.updateAuthorInfo( + authorMemberId = authorId, + nickname = "바뀐닉네임만", + introduction = null, + imageId = null + ) + entityManager.flush() + entityManager.clear() + + val updatedPost = postRepository.findById(post.requireId()) + assertNotNull(updatedPost) + + assertEquals("바뀐닉네임만", updatedPost.author.nickname) + assertEquals(PostFixture.DEFAULT_AUTHOR_INTRODUCTION, updatedPost.author.introduction) + assertEquals(1L, updatedPost.author.imageId) + } + + @Test + fun `updateAuthorInfo - success - does not affect other authors' posts`() { + val author1Id = 1L + val author2Id = 2L + + val post1 = testPostHelper.createPost( + authorMemberId = author1Id, + authorNickname = "작성자1" + ) + val post2 = testPostHelper.createPost( + authorMemberId = author2Id, + authorNickname = "작성자2" + ) + + // author1만 업데이트 + postRepository.updateAuthorInfo( + authorMemberId = author1Id, + nickname = "수정된작성자1", + introduction = null, + imageId = null + ) + entityManager.flush() + entityManager.clear() + + val updatedPost1 = postRepository.findById(post1.requireId()) + val updatedPost2 = postRepository.findById(post2.requireId()) + + assertNotNull(updatedPost1) + assertNotNull(updatedPost2) + + assertEquals("수정된작성자1", updatedPost1.author.nickname) + assertEquals("작성자2", updatedPost2.author.nickname) + } + + @Test + fun `updateAuthorInfo - success - updates only imageId`() { + val authorId = 1L + val post = testPostHelper.createPost( + authorMemberId = authorId, + authorNickname = "기존닉네임", + ) + + postRepository.updateAuthorInfo( + authorMemberId = authorId, + nickname = null, + introduction = null, + imageId = 999L + ) + entityManager.flush() + entityManager.clear() + + val updatedPost = postRepository.findById(post.requireId()) + assertNotNull(updatedPost) + assertEquals("기존닉네임", updatedPost.author.nickname) // 닉네임 보존 + assertEquals(999L, updatedPost.author.imageId) // 이미지만 업데이트 + } + + @Test + fun `updateAuthorInfo - success - updates only introduction`() { + val authorId = 1L + val post = testPostHelper.createPost( + authorMemberId = authorId, + authorNickname = "기존닉네임", + ) + + postRepository.updateAuthorInfo( + authorMemberId = authorId, + nickname = null, + introduction = "새로운소개글", + imageId = null + ) + entityManager.flush() + entityManager.clear() + + val updatedPost = postRepository.findById(post.requireId()) + assertNotNull(updatedPost) + assertEquals("기존닉네임", updatedPost.author.nickname) // 닉네임 보존 + assertEquals("새로운소개글", updatedPost.author.introduction) // 소개글만 업데이트 + assertEquals(post.author.imageId, updatedPost.author.imageId) // 이미지 ID 보존 + } + + @Test + fun `updateAuthorInfo - success - all null preserves existing values`() { + val authorId = 1L + val originalNickname = "원본닉네임" + + val post = testPostHelper.createPost( + authorMemberId = authorId, + authorNickname = originalNickname, + ) + + postRepository.updateAuthorInfo( + authorMemberId = authorId, + nickname = null, + introduction = null, + imageId = null + ) + entityManager.flush() + entityManager.clear() + + val updatedPost = postRepository.findById(post.requireId()) + assertNotNull(updatedPost) + // 모든 값이 원본 그대로 보존되어야 함 + assertEquals(originalNickname, updatedPost.author.nickname) + assertEquals(post.author.introduction, updatedPost.author.introduction) + assertEquals(post.author.imageId, updatedPost.author.imageId) + } + + @Test + fun `updateAuthorInfo - success - updates nickname and imageId with null introduction`() { + val authorId = 1L + val originalIntroduction = "원본소개" + + val post = testPostHelper.createPost( + authorMemberId = authorId, + authorNickname = "기존닉네임", + ) + + postRepository.updateAuthorInfo( + authorMemberId = authorId, + nickname = "새닉네임", + introduction = null, + imageId = 777L + ) + entityManager.flush() + entityManager.clear() + + val updatedPost = postRepository.findById(post.requireId()) + assertNotNull(updatedPost) + assertEquals("새닉네임", updatedPost.author.nickname) // 닉네임 업데이트 + assertEquals(post.author.introduction, updatedPost.author.introduction) // 소개는 보존 + assertEquals(777L, updatedPost.author.imageId) // 이미지 ID 업데이트 + } + + @Test + fun `updateAuthorInfo - success - updates multiple posts of same author`() { + val authorId = 1L + + val post1 = testPostHelper.createPost( + authorMemberId = authorId, + authorNickname = "기존닉네임", + ) + val post2 = testPostHelper.createPost( + authorMemberId = authorId, + authorNickname = "기존닉네임", + ) + val post3 = testPostHelper.createPost( + authorMemberId = authorId, + authorNickname = "기존닉네임", + ) + + postRepository.updateAuthorInfo( + authorMemberId = authorId, + nickname = "통일닉네임", + introduction = null, + imageId = 999L + ) + entityManager.flush() + entityManager.clear() + + // 모든 포스트가 업데이트되어야 함 + val updatedPost1 = postRepository.findById(post1.requireId()) + val updatedPost2 = postRepository.findById(post2.requireId()) + val updatedPost3 = postRepository.findById(post3.requireId()) + + assertNotNull(updatedPost1) + assertNotNull(updatedPost2) + assertNotNull(updatedPost3) + + // 모든 포스트에서 동일한 값 확인 + listOf(updatedPost1, updatedPost2, updatedPost3).forEach { post -> + assertEquals("통일닉네임", post.author.nickname) + assertEquals(999L, post.author.imageId) + } + } +} diff --git a/src/test/kotlin/com/albert/realmoneyrealtaste/domain/comment/CommentTest.kt b/src/test/kotlin/com/albert/realmoneyrealtaste/domain/comment/CommentTest.kt index 1a49d5c5..dea3d678 100644 --- a/src/test/kotlin/com/albert/realmoneyrealtaste/domain/comment/CommentTest.kt +++ b/src/test/kotlin/com/albert/realmoneyrealtaste/domain/comment/CommentTest.kt @@ -1,7 +1,11 @@ package com.albert.realmoneyrealtaste.domain.comment +import com.albert.realmoneyrealtaste.domain.comment.event.CommentCreatedEvent +import com.albert.realmoneyrealtaste.domain.comment.event.CommentDeletedEvent +import com.albert.realmoneyrealtaste.domain.comment.event.CommentUpdatedEvent import com.albert.realmoneyrealtaste.domain.comment.value.CommentAuthor import com.albert.realmoneyrealtaste.domain.comment.value.CommentContent +import com.albert.realmoneyrealtaste.util.setId import java.time.LocalDateTime import kotlin.test.Test import kotlin.test.assertEquals @@ -108,11 +112,12 @@ class CommentTest { @Test fun `update - success - updates comment content when author matches`() { - val comment = Comment.create( + val commentContent = CommentContent("원본 댓글") + val comment = createComment( postId = 1L, authorMemberId = 100L, authorNickname = "맛집탐험가", - content = CommentContent("원본 댓글") + content = commentContent ) val originalUpdatedAt = comment.updatedAt @@ -144,12 +149,7 @@ class CommentTest { @Test fun `update - failure - throws exception when comment is deleted`() { - val comment = Comment.create( - postId = 1L, - authorMemberId = 100L, - authorNickname = "맛집탐험가", - content = CommentContent("댓글 내용") - ) + val comment = createComment(1L, 100L, "맛집탐험가", CommentContent("댓글 내용")) // 댓글을 먼저 삭제 comment.delete(100L) @@ -164,7 +164,7 @@ class CommentTest { @Test fun `delete - success - deletes comment when author matches`() { - val comment = Comment.create( + val comment = createComment( postId = 1L, authorMemberId = 100L, authorNickname = "맛집탐험가", @@ -184,12 +184,7 @@ class CommentTest { @Test fun `delete - failure - throws exception when member is not author`() { - val comment = Comment.create( - postId = 1L, - authorMemberId = 100L, - authorNickname = "맛집탐험가", - content = CommentContent("댓글 내용") - ) + val comment = createComment(1L, 100L, "맛집탐험가", CommentContent("댓글 내용")) assertFailsWith { comment.delete(999L) @@ -200,12 +195,7 @@ class CommentTest { @Test fun `delete - failure - throws exception when comment is already deleted`() { - val comment = Comment.create( - postId = 1L, - authorMemberId = 100L, - authorNickname = "맛집탐험가", - content = CommentContent("댓글 내용") - ) + val comment = createComment(1L, 100L, "맛집탐험가", CommentContent("댓글 내용")) // 댓글을 먼저 삭제 comment.delete(100L) @@ -220,36 +210,21 @@ class CommentTest { @Test fun `canEditBy - success - returns true when member is author`() { - val comment = Comment.create( - postId = 1L, - authorMemberId = 100L, - authorNickname = "맛집탐험가", - content = CommentContent("댓글 내용") - ) + val comment = createComment(1L, 100L, "맛집탐험가", CommentContent("댓글 내용")) assertTrue(comment.canEditBy(100L)) } @Test fun `canEditBy - success - returns false when member is not author`() { - val comment = Comment.create( - postId = 1L, - authorMemberId = 100L, - authorNickname = "맛집탐험가", - content = CommentContent("댓글 내용") - ) + val comment = createComment(1L, 100L, "맛집탐험가", CommentContent("댓글 내용")) assertFalse(comment.canEditBy(999L)) } @Test fun `ensureCanEditBy - success - does not throw when member is author`() { - val comment = Comment.create( - postId = 1L, - authorMemberId = 100L, - authorNickname = "맛집탐험가", - content = CommentContent("댓글 내용") - ) + val comment = createComment(1L, 100L, "맛집탐험가", CommentContent("댓글 내용")) // 예외가 발생하지 않아야 함 comment.ensureCanEditBy(100L) @@ -257,12 +232,7 @@ class CommentTest { @Test fun `ensureCanEditBy - failure - throws exception when member is not author`() { - val comment = Comment.create( - postId = 1L, - authorMemberId = 100L, - authorNickname = "맛집탐험가", - content = CommentContent("댓글 내용") - ) + val comment = createComment(1L, 100L, "맛집탐험가", CommentContent("댓글 내용")) assertFailsWith { comment.ensureCanEditBy(999L) @@ -273,12 +243,7 @@ class CommentTest { @Test fun `ensurePublished - success - does not throw when comment is published`() { - val comment = Comment.create( - postId = 1L, - authorMemberId = 100L, - authorNickname = "맛집탐험가", - content = CommentContent("댓글 내용") - ) + val comment = createComment(1L, 100L, "맛집탐험가", CommentContent("댓글 내용")) // 예외가 발생하지 않아야 함 comment.ensurePublished() @@ -286,12 +251,7 @@ class CommentTest { @Test fun `ensurePublished - failure - throws exception when comment is deleted`() { - val comment = Comment.create( - postId = 1L, - authorMemberId = 100L, - authorNickname = "맛집탐험가", - content = CommentContent("댓글 내용") - ) + val comment = createComment(1L, 100L, "맛집탐험가", CommentContent("댓글 내용")) comment.delete(100L) @@ -329,24 +289,14 @@ class CommentTest { @Test fun `isDeleted - success - returns false when comment is published`() { - val comment = Comment.create( - postId = 1L, - authorMemberId = 100L, - authorNickname = "맛집탐험가", - content = CommentContent("댓글 내용") - ) + val comment = createComment(1L, 100L, "맛집탐험가", CommentContent("댓글 내용")) assertFalse(comment.isDeleted()) } @Test fun `isDeleted - success - returns true when comment is deleted`() { - val comment = Comment.create( - postId = 1L, - authorMemberId = 100L, - authorNickname = "맛집탐험가", - content = CommentContent("댓글 내용") - ) + val comment = createComment(1L, 100L, "맛집탐험가", CommentContent("댓글 내용")) comment.delete(100L) @@ -389,6 +339,152 @@ class CommentTest { assertEquals(5L, testComment.repliesCount) } + @Test + fun `drainDomainEvents - success - returns CommentCreatedEvent with actual ID when comment is created`() { + val comment = Comment.create( + postId = 1L, + authorMemberId = 100L, + authorNickname = "맛집탐험가", + content = CommentContent("새 댓글"), + parentCommentId = 50L, + parentCommentAuthorId = 200L + ) + comment.setId(123L) + + val events = comment.drainDomainEvents() + + assertEquals(1, events.size) + assertTrue(events[0] is CommentCreatedEvent) + + val event = events[0] as CommentCreatedEvent + assertEquals(123L, event.commentId) + assertEquals(1L, event.postId) + assertEquals(100L, event.authorMemberId) + assertEquals(50L, event.parentCommentId) + assertEquals(200L, event.parentCommentAuthorId) + assertNotNull(event.createdAt) + } + + @Test + fun `drainDomainEvents - success - returns empty list when called twice`() { + val comment = Comment.create( + postId = 1L, + authorMemberId = 100L, + authorNickname = "맛집탐험가", + content = CommentContent("새 댓글") + ) + comment.setId(123L) + + // 첫 번째 호출 + val firstEvents = comment.drainDomainEvents() + assertEquals(1, firstEvents.size) + + // 두 번째 호출은 빈 리스트 반환 + val secondEvents = comment.drainDomainEvents() + assertEquals(0, secondEvents.size) + } + + @Test + fun `drainDomainEvents - success - returns CommentUpdatedEvent with actual ID when comment is updated`() { + val comment = createComment( + postId = 1L, + authorMemberId = 100L, + authorNickname = "맛집탐험가", + content = CommentContent("원본 댓글") + ) + comment.setId(123L) + + // 댓글 수정 + comment.update(100L, CommentContent("수정된 댓글")) + + val events = comment.drainDomainEvents() + + assertEquals(2, events.size) // 생성 + 수정 이벤트 + assertTrue(events[1] is CommentUpdatedEvent) + + val updateEvent = events[1] as CommentUpdatedEvent + assertEquals(123L, updateEvent.commentId) + assertEquals(1L, updateEvent.postId) + assertEquals(100L, updateEvent.authorMemberId) + assertNotNull(updateEvent.updatedAt) + } + + @Test + fun `drainDomainEvents - success - returns CommentDeletedEvent with actual ID when comment is deleted`() { + val comment = createComment( + postId = 1L, + authorMemberId = 100L, + authorNickname = "맛집탐험가", + content = CommentContent("삭제할 댓글"), + parentCommentId = 50L + ) + comment.setId(123L) + + // 댓글 삭제 + comment.delete(100L) + + val events = comment.drainDomainEvents() + + assertEquals(2, events.size) // 생성 + 삭제 이벤트 + assertTrue(events[1] is CommentDeletedEvent) + + val deleteEvent = events[1] as CommentDeletedEvent + assertEquals(123L, deleteEvent.commentId) + assertEquals(50L, deleteEvent.parentCommentId) + assertEquals(1L, deleteEvent.postId) + assertEquals(100L, deleteEvent.authorMemberId) + assertNotNull(deleteEvent.deletedAt) + } + + @Test + fun `drainDomainEvents - success - handles multiple events in correct order`() { + val comment = createComment( + postId = 1L, + authorMemberId = 100L, + authorNickname = "맛집탐험가", + content = CommentContent("원본 댓글") + ) + comment.setId(123L) + + // 수정 후 삭제 + comment.update(100L, CommentContent("수정된 댓글")) + comment.delete(100L) + + val events = comment.drainDomainEvents() + + assertEquals(3, events.size) + assertTrue(events[0] is CommentCreatedEvent) + assertTrue(events[1] is CommentUpdatedEvent) + assertTrue(events[2] is CommentDeletedEvent) + + // 모든 이벤트의 commentId가 실제 ID로 설정되었는지 확인 + events.forEach { event -> + when (event) { + is CommentCreatedEvent -> assertEquals(123L, event.commentId) + is CommentUpdatedEvent -> assertEquals(123L, event.commentId) + is CommentDeletedEvent -> assertEquals(123L, event.commentId) + } + } + } + + private fun createComment( + postId: Long, + authorMemberId: Long, + authorNickname: String, + content: CommentContent, + parentCommentId: Long? = null, + ): Comment { + val comment = Comment.create( + postId = postId, + authorMemberId = authorMemberId, + authorNickname = authorNickname, + content = content, + parentCommentId + ) + comment.setId() + return comment + } + private class TestComment : Comment( postId = 1L, author = CommentAuthor( diff --git a/src/test/kotlin/com/albert/realmoneyrealtaste/domain/comment/event/CommentCreatedEventTest.kt b/src/test/kotlin/com/albert/realmoneyrealtaste/domain/comment/event/CommentCreatedEventTest.kt index 47476478..21aac098 100644 --- a/src/test/kotlin/com/albert/realmoneyrealtaste/domain/comment/event/CommentCreatedEventTest.kt +++ b/src/test/kotlin/com/albert/realmoneyrealtaste/domain/comment/event/CommentCreatedEventTest.kt @@ -1,5 +1,6 @@ package com.albert.realmoneyrealtaste.domain.comment.event +import java.time.LocalDateTime import kotlin.test.Test class CommentCreatedEventTest { @@ -10,13 +11,14 @@ class CommentCreatedEventTest { val postId = 10L val authorMemberId = 100L val parentCommentId = 5L - val createdAt = java.time.LocalDateTime.now() + val createdAt = LocalDateTime.now() val event = CommentCreatedEvent( commentId = commentId, postId = postId, authorMemberId = authorMemberId, parentCommentId = parentCommentId, + parentCommentAuthorId = null, createdAt = createdAt ) diff --git a/src/test/kotlin/com/albert/realmoneyrealtaste/domain/comment/event/CommentDeletedEventTest.kt b/src/test/kotlin/com/albert/realmoneyrealtaste/domain/comment/event/CommentDeletedEventTest.kt index 0cde7c41..acf71ac0 100644 --- a/src/test/kotlin/com/albert/realmoneyrealtaste/domain/comment/event/CommentDeletedEventTest.kt +++ b/src/test/kotlin/com/albert/realmoneyrealtaste/domain/comment/event/CommentDeletedEventTest.kt @@ -1,5 +1,6 @@ package com.albert.realmoneyrealtaste.domain.comment.event +import java.time.LocalDateTime import kotlin.test.Test import kotlin.test.assertEquals @@ -9,17 +10,42 @@ class CommentDeletedEventTest { fun `construct - success - creates CommentDeletedEvent with valid parameters`() { val commentId = 1L val postId = 10L + val parentCommentId = 123L val authorMemberId = 100L - val deletedAt = java.time.LocalDateTime.now() + val deletedAt = LocalDateTime.now() val event = CommentDeletedEvent( commentId = commentId, + parentCommentId = parentCommentId, postId = postId, authorMemberId = authorMemberId, deletedAt = deletedAt ) assertEquals(commentId, event.commentId) + assertEquals(parentCommentId, event.parentCommentId) + assertEquals(postId, event.postId) + assertEquals(authorMemberId, event.authorMemberId) + assertEquals(deletedAt, event.deletedAt) + } + + @Test + fun `construct - success - creates CommentDeletedEvent with null parentCommentId when parentCommentId is not provided`() { + val commentId = 1L + val postId = 10L + val authorMemberId = 100L + val deletedAt = LocalDateTime.now() + + val event = CommentDeletedEvent( + commentId = commentId, + postId = postId, + authorMemberId = authorMemberId, + deletedAt = deletedAt, + parentCommentId = null + ) + + assertEquals(commentId, event.commentId) + assertEquals(null, event.parentCommentId) assertEquals(postId, event.postId) assertEquals(authorMemberId, event.authorMemberId) assertEquals(deletedAt, event.deletedAt) diff --git a/src/test/kotlin/com/albert/realmoneyrealtaste/domain/event/MemberEventTest.kt b/src/test/kotlin/com/albert/realmoneyrealtaste/domain/event/MemberEventTest.kt new file mode 100644 index 00000000..cede92b9 --- /dev/null +++ b/src/test/kotlin/com/albert/realmoneyrealtaste/domain/event/MemberEventTest.kt @@ -0,0 +1,439 @@ +package com.albert.realmoneyrealtaste.domain.event + +import org.junit.jupiter.api.assertAll +import java.time.LocalDateTime +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 MemberEventTest { + + @Test + fun `create - success - creates event with all required properties`() { + val memberId = 1L + val eventType = MemberEventType.FRIEND_REQUEST_SENT + val title = "친구 요청 알림" + val message = "홍길동님이 친구 요청을 보냈습니다" + val relatedMemberId = 2L + val relatedPostId = null + val relatedCommentId = null + + val event = MemberEvent.create( + memberId = memberId, + eventType = eventType, + title = title, + message = message, + relatedMemberId = relatedMemberId, + relatedPostId = relatedPostId, + relatedCommentId = relatedCommentId + ) + + assertAll( + { assertEquals(memberId, event.memberId) }, + { assertEquals(eventType, event.eventType) }, + { assertEquals(title, event.title) }, + { assertEquals(message, event.message) }, + { assertEquals(relatedMemberId, event.relatedMemberId) }, + { assertEquals(relatedPostId, event.relatedPostId) }, + { assertEquals(relatedCommentId, event.relatedCommentId) }, + { assertFalse(event.isRead) }, + { assertTrue(event.createdAt <= LocalDateTime.now()) }, + { assertTrue(event.createdAt > LocalDateTime.now().minusSeconds(5)) } + ) + } + + @Test + fun `create - success - creates event with all related IDs`() { + val memberId = 1L + val eventType = MemberEventType.POST_COMMENTED + val title = "댓글 알림" + val message = "게시물에 댓글이 달렸습니다" + val relatedMemberId = 2L + val relatedPostId = 100L + val relatedCommentId = 200L + + val event = MemberEvent.create( + memberId = memberId, + eventType = eventType, + title = title, + message = message, + relatedMemberId = relatedMemberId, + relatedPostId = relatedPostId, + relatedCommentId = relatedCommentId + ) + + assertAll( + { assertEquals(memberId, event.memberId) }, + { assertEquals(eventType, event.eventType) }, + { assertEquals(title, event.title) }, + { assertEquals(message, event.message) }, + { assertEquals(relatedMemberId, event.relatedMemberId) }, + { assertEquals(relatedPostId, event.relatedPostId) }, + { assertEquals(relatedCommentId, event.relatedCommentId) } + ) + } + + @Test + fun `create - failure - throws exception when memberId is zero`() { + assertFailsWith { + MemberEvent.create( + memberId = 0L, + eventType = MemberEventType.FRIEND_REQUEST_SENT, + title = "제목", + message = "메시지" + ) + }.let { + assertEquals(MemberEvent.ERROR_MEMBER_ID_MUST_BE_POSITIVE, it.message) + } + } + + @Test + fun `create - failure - throws exception when memberId is negative`() { + assertFailsWith { + MemberEvent.create( + memberId = -1L, + eventType = MemberEventType.FRIEND_REQUEST_SENT, + title = "제목", + message = "메시지" + ) + }.let { + assertEquals(MemberEvent.ERROR_MEMBER_ID_MUST_BE_POSITIVE, it.message) + } + } + + @Test + fun `create - failure - throws exception when title is empty`() { + assertFailsWith { + MemberEvent.create( + memberId = 1L, + eventType = MemberEventType.FRIEND_REQUEST_SENT, + title = "", + message = "메시지" + ) + }.let { + assertEquals(MemberEvent.ERROR_TITLE_MUST_NOT_BE_EMPTY, it.message) + } + } + + @Test + fun `create - failure - throws exception when title is blank`() { + assertFailsWith { + MemberEvent.create( + memberId = 1L, + eventType = MemberEventType.FRIEND_REQUEST_SENT, + title = " ", + message = "메시지" + ) + }.let { + assertEquals(MemberEvent.ERROR_TITLE_MUST_NOT_BE_EMPTY, it.message) + } + } + + @Test + fun `create - failure - throws exception when message is empty`() { + assertFailsWith { + MemberEvent.create( + memberId = 1L, + eventType = MemberEventType.FRIEND_REQUEST_SENT, + title = "제목", + message = "" + ) + }.let { + assertEquals(MemberEvent.ERROR_MESSAGE_MUST_NOT_BE_EMPTY, it.message) + } + } + + @Test + fun `create - failure - throws exception when message is blank`() { + assertFailsWith { + MemberEvent.create( + memberId = 1L, + eventType = MemberEventType.FRIEND_REQUEST_SENT, + title = "제목", + message = "\t\n\r " + ) + }.let { + assertEquals(MemberEvent.ERROR_MESSAGE_MUST_NOT_BE_EMPTY, it.message) + } + } + + @Test + fun `create - failure - throws exception when both title and message are blank`() { + assertFailsWith { + MemberEvent.create( + memberId = 1L, + eventType = MemberEventType.FRIEND_REQUEST_SENT, + title = "", + message = "" + ) + }.let { + // title 검증이 먼저 수행되므로 해당 에러 메시지가 나옴 + assertEquals(MemberEvent.ERROR_TITLE_MUST_NOT_BE_EMPTY, it.message) + } + } + + @Test + fun `markAsRead - success - marks event as read`() { + val event = MemberEvent.create( + memberId = 1L, + eventType = MemberEventType.FRIEND_REQUEST_SENT, + title = "제목", + message = "메시지" + ) + + // 초기 상태 확인 + assertFalse(event.isRead) + + // 읽음으로 표시 + event.markAsRead() + + // 변경 확인 + assertTrue(event.isRead) + } + + @Test + fun `markAsRead - success - can be called multiple times`() { + val event = MemberEvent.create( + memberId = 1L, + eventType = MemberEventType.FRIEND_REQUEST_SENT, + title = "제목", + message = "메시지" + ) + + event.markAsRead() + assertTrue(event.isRead) + + // 다시 호출해도 상태는 유지됨 + event.markAsRead() + assertTrue(event.isRead) + } + + @Test + fun `create - success - handles edge case values`() { + val event = MemberEvent.create( + memberId = Long.MAX_VALUE, + eventType = MemberEventType.ACCOUNT_ACTIVATED, + title = "a", + message = "b", + relatedMemberId = Long.MAX_VALUE, + relatedPostId = Long.MAX_VALUE, + relatedCommentId = Long.MAX_VALUE + ) + + assertAll( + { assertEquals(Long.MAX_VALUE, event.memberId) }, + { assertEquals(MemberEventType.ACCOUNT_ACTIVATED, event.eventType) }, + { assertEquals("a", event.title) }, + { assertEquals("b", event.message) }, + { assertEquals(Long.MAX_VALUE, event.relatedMemberId) }, + { assertEquals(Long.MAX_VALUE, event.relatedPostId) }, + { assertEquals(Long.MAX_VALUE, event.relatedCommentId) } + ) + } + + @Test + fun `create - success - handles Korean characters`() { + val event = createMemberEvent( + memberId = 1L, + eventType = MemberEventType.FRIEND_REQUEST_SENT, + title = "친구 요청 알림", + message = "홍길동님이 친구 요청을 보냈습니다" + ) + + assertAll( + { assertEquals("친구 요청 알림", event.title) }, + { assertEquals("홍길동님이 친구 요청을 보냈습니다", event.message) } + ) + } + + @Test + fun `create - success - handles special characters in title and message`() { + val event = createMemberEvent( + memberId = 1L, + eventType = MemberEventType.POST_CREATED, + title = "Special Event! @#$%^&*()", + message = "Message with special chars: 안녕~ Hello! @#$% &*()" + ) + + assertAll( + { assertEquals("Special Event! @#$%^&*()", event.title) }, + { assertEquals("Message with special chars: 안녕~ Hello! @#$% &*()", event.message) } + ) + } + + @Test + fun `create - success - handles minimum valid memberId`() { + val event = MemberEvent.create( + memberId = 1L, + eventType = MemberEventType.POST_CREATED, + title = "게시물 작성", + message = "새 게시물이 작성되었습니다" + ) + + assertEquals(1L, event.memberId) + } + + @Test + fun `create - success - all event types are supported`() { + MemberEventType.values().forEach { eventType -> + val event = MemberEvent.create( + memberId = 1L, + eventType = eventType, + title = "제목", + message = "메시지" + ) + assertEquals(eventType, event.eventType) + } + } + + @Test + fun `create - success - default values are correctly set`() { + val before = LocalDateTime.now() + + val event = MemberEvent.create( + memberId = 1L, + eventType = MemberEventType.FRIEND_REQUEST_SENT, + title = "제목", + message = "메시지" + ) + + val after = LocalDateTime.now() + + assertAll( + { assertFalse(event.isRead) }, + { assertTrue(event.createdAt >= before) }, + { assertTrue(event.createdAt <= after) } + ) + } + + @Test + fun `create - success - creates event without related IDs`() { + val event = MemberEvent.create( + memberId = 1L, + eventType = MemberEventType.ACCOUNT_ACTIVATED, + title = "계정 활성화", + message = "계정이 활성화되었습니다" + ) + + assertAll( + { assertNull(event.relatedMemberId) }, + { assertNull(event.relatedPostId) }, + { assertNull(event.relatedCommentId) } + ) + } + + @Test + fun `constants - success - has correct error messages`() { + assertAll( + { assertEquals("회원 ID는 양수여야 합니다", MemberEvent.ERROR_MEMBER_ID_MUST_BE_POSITIVE) }, + { assertEquals("제목은 비어있을 수 없습니다", MemberEvent.ERROR_TITLE_MUST_NOT_BE_EMPTY) }, + { assertEquals("메시지는 비어있을 수 없습니다", MemberEvent.ERROR_MESSAGE_MUST_NOT_BE_EMPTY) } + ) + } + + @Test + fun `setters - success - update properties for code coverage`() { + val event = TestMemberEvent() + val newMemberId = 20L + val newEventType = MemberEventType.FRIEND_REQUEST_ACCEPTED + val newRelatedMemberId = 30L + val newRelatedPostId = 40L + val newRelatedCommentId = 25L + val newCreatedAt = LocalDateTime.now() + val newTitle = "새로운 제목" + val newMessage = "새로운 메시지" + val newIsRead = true + + event.setMemberIdForTest(newMemberId) + event.setEventTypeForTest(newEventType) + event.setTitleForTest(newTitle) + event.setMessageForTest(newMessage) + event.setRelatedMemberIdForTest(newRelatedMemberId) + event.setRelatedPostIdForTest(newRelatedPostId) + event.setRelatedCommentIdForTest(newRelatedCommentId) + event.setIsReadForTest(newIsRead) + event.setCreatedAtForTest(newCreatedAt) + + assertEquals(newMemberId, event.memberId) + assertEquals(newEventType, event.eventType) + assertEquals(newTitle, event.title) + assertEquals(newMessage, event.message) + assertEquals(newRelatedMemberId, event.relatedMemberId) + assertEquals(newRelatedPostId, event.relatedPostId) + assertEquals(newRelatedCommentId, event.relatedCommentId) + assertEquals(newIsRead, event.isRead) + assertEquals(newCreatedAt, event.createdAt) + } + + // Helper method to reduce test boilerplate + private fun createMemberEvent( + memberId: Long = 1L, + eventType: MemberEventType = MemberEventType.FRIEND_REQUEST_SENT, + title: String = "제목", + message: String = "메시지", + relatedMemberId: Long? = null, + relatedPostId: Long? = null, + relatedCommentId: Long? = null, + ): MemberEvent { + return MemberEvent.create( + memberId = memberId, + eventType = eventType, + title = title, + message = message, + relatedMemberId = relatedMemberId, + relatedPostId = relatedPostId, + relatedCommentId = relatedCommentId + ) + } + + private class TestMemberEvent : MemberEvent( + memberId = 1L, + eventType = MemberEventType.FRIEND_REQUEST_SENT, + title = "제목", + message = "메시지", + relatedMemberId = null, + relatedPostId = null, + relatedCommentId = null, + isRead = false, + createdAt = LocalDateTime.now() + ) { + fun setMemberIdForTest(memberId: Long) { + this.memberId = memberId + } + + fun setEventTypeForTest(eventType: MemberEventType) { + this.eventType = eventType + } + + fun setTitleForTest(title: String) { + this.title = title + } + + fun setMessageForTest(message: String) { + this.message = message + } + + fun setRelatedMemberIdForTest(relatedMemberId: Long?) { + this.relatedMemberId = relatedMemberId + } + + fun setRelatedPostIdForTest(relatedPostId: Long?) { + this.relatedPostId = relatedPostId + } + + fun setRelatedCommentIdForTest(relatedCommentId: Long?) { + this.relatedCommentId = relatedCommentId + } + + fun setIsReadForTest(isRead: Boolean) { + this.isRead = isRead + } + + fun setCreatedAtForTest(createdAt: LocalDateTime) { + this.createdAt = createdAt + } + } +} diff --git a/src/test/kotlin/com/albert/realmoneyrealtaste/domain/event/MemberEventTypeTest.kt b/src/test/kotlin/com/albert/realmoneyrealtaste/domain/event/MemberEventTypeTest.kt new file mode 100644 index 00000000..ae7a7e11 --- /dev/null +++ b/src/test/kotlin/com/albert/realmoneyrealtaste/domain/event/MemberEventTypeTest.kt @@ -0,0 +1,138 @@ +package com.albert.realmoneyrealtaste.domain.event + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +class MemberEventTypeTest { + + @Test + fun `enum values - success - contains all expected event types`() { + val expectedTypes = listOf( + // 친구 관련 + "FRIEND_REQUEST_SENT", + "FRIEND_REQUEST_RECEIVED", + "FRIEND_REQUEST_ACCEPTED", + "FRIEND_REQUEST_REJECTED", + "FRIENDSHIP_TERMINATED", + + // 게시물 관련 + "POST_CREATED", + "POST_DELETED", + "POST_COMMENTED", + + // 댓글 관련 + "COMMENT_CREATED", + "COMMENT_DELETED", + "COMMENT_REPLIED", + + // 프로필 관련 + "PROFILE_UPDATED", + + // 시스템 관련 + "ACCOUNT_ACTIVATED", + "ACCOUNT_DEACTIVATED" + ) + + val actualTypes = MemberEventType.values().map { it.name } + + expectedTypes.forEach { expected -> + assertTrue(actualTypes.contains(expected), "Missing enum value: $expected") + } + } + + @Test + fun `enum values - success - friend related events are grouped together`() { + val friendEvents = listOf( + MemberEventType.FRIEND_REQUEST_SENT, + MemberEventType.FRIEND_REQUEST_RECEIVED, + MemberEventType.FRIEND_REQUEST_ACCEPTED, + MemberEventType.FRIEND_REQUEST_REJECTED, + MemberEventType.FRIENDSHIP_TERMINATED + ) + + friendEvents.forEach { eventType -> + assertTrue(eventType.name.startsWith("FRIEND") || eventType.name.startsWith("FRIENDSHIP")) + } + } + + @Test + fun `enum values - success - post related events are grouped together`() { + val postEvents = listOf( + MemberEventType.POST_CREATED, + MemberEventType.POST_DELETED, + MemberEventType.POST_COMMENTED + ) + + postEvents.forEach { eventType -> + assertTrue(eventType.name.startsWith("POST")) + } + } + + @Test + fun `enum values - success - comment related events are grouped together`() { + val commentEvents = listOf( + MemberEventType.COMMENT_CREATED, + MemberEventType.COMMENT_DELETED, + MemberEventType.COMMENT_REPLIED + ) + + commentEvents.forEach { eventType -> + assertTrue(eventType.name.startsWith("COMMENT")) + } + } + + @Test + fun `enum values - success - system related events are grouped together`() { + val systemEvents = listOf( + MemberEventType.ACCOUNT_ACTIVATED, + MemberEventType.ACCOUNT_DEACTIVATED + ) + + systemEvents.forEach { eventType -> + assertTrue(eventType.name.startsWith("ACCOUNT")) + } + } + + @Test + fun `valueOf - success - returns correct enum for valid names`() { + assertEquals(MemberEventType.FRIEND_REQUEST_SENT, MemberEventType.valueOf("FRIEND_REQUEST_SENT")) + assertEquals(MemberEventType.POST_CREATED, MemberEventType.valueOf("POST_CREATED")) + assertEquals(MemberEventType.COMMENT_CREATED, MemberEventType.valueOf("COMMENT_CREATED")) + assertEquals(MemberEventType.PROFILE_UPDATED, MemberEventType.valueOf("PROFILE_UPDATED")) + assertEquals(MemberEventType.ACCOUNT_ACTIVATED, MemberEventType.valueOf("ACCOUNT_ACTIVATED")) + } + + @Test + fun `enum constants - success - all constants are accessible`() { + // 친구 관련 + assertEquals(MemberEventType.FRIEND_REQUEST_SENT.name, "FRIEND_REQUEST_SENT") + assertEquals(MemberEventType.FRIEND_REQUEST_RECEIVED.name, "FRIEND_REQUEST_RECEIVED") + assertEquals(MemberEventType.FRIEND_REQUEST_ACCEPTED.name, "FRIEND_REQUEST_ACCEPTED") + assertEquals(MemberEventType.FRIEND_REQUEST_REJECTED.name, "FRIEND_REQUEST_REJECTED") + assertEquals(MemberEventType.FRIENDSHIP_TERMINATED.name, "FRIENDSHIP_TERMINATED") + + // 게시물 관련 + assertEquals(MemberEventType.POST_CREATED.name, "POST_CREATED") + assertEquals(MemberEventType.POST_DELETED.name, "POST_DELETED") + assertEquals(MemberEventType.POST_COMMENTED.name, "POST_COMMENTED") + + // 댓글 관련 + assertEquals(MemberEventType.COMMENT_CREATED.name, "COMMENT_CREATED") + assertEquals(MemberEventType.COMMENT_DELETED.name, "COMMENT_DELETED") + assertEquals(MemberEventType.COMMENT_REPLIED.name, "COMMENT_REPLIED") + + // 프로필 관련 + assertEquals(MemberEventType.PROFILE_UPDATED.name, "PROFILE_UPDATED") + + // 시스템 관련 + assertEquals(MemberEventType.ACCOUNT_ACTIVATED.name, "ACCOUNT_ACTIVATED") + assertEquals(MemberEventType.ACCOUNT_DEACTIVATED.name, "ACCOUNT_DEACTIVATED") + } + + @Test + fun `enum count - success - has correct number of values`() { + val totalExpected = 14 // 5 friend + 3 post + 3 comment + 1 profile + 2 system + assertEquals(totalExpected, MemberEventType.values().size) + } +} 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 65e2d99b..9076abd3 100644 --- a/src/test/kotlin/com/albert/realmoneyrealtaste/domain/friend/FriendshipTest.kt +++ b/src/test/kotlin/com/albert/realmoneyrealtaste/domain/friend/FriendshipTest.kt @@ -1,6 +1,12 @@ package com.albert.realmoneyrealtaste.domain.friend 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.friend.event.FriendRequestSentEvent +import com.albert.realmoneyrealtaste.domain.friend.event.FriendshipTerminatedEvent +import com.albert.realmoneyrealtaste.domain.friend.value.FriendRelationship +import com.albert.realmoneyrealtaste.util.setId import io.mockk.every import io.mockk.mockk import org.junit.jupiter.api.assertAll @@ -17,9 +23,18 @@ class FriendshipTest { fun `request - success - creates pending friendship with valid command`() { val fromMemberId = 1L val fromMemberNickname = "sender" + val fromMemberProfileImageId = 1L val toMemberId = 2L val toMemberNickname = "receiver" - val command = FriendRequestCommand(fromMemberId, fromMemberNickname, toMemberId, toMemberNickname) + val toMemberProfileImageId = 2L + val command = FriendRequestCommand( + fromMemberId, + fromMemberNickname, + fromMemberProfileImageId, + toMemberId, + toMemberNickname, + toMemberProfileImageId, + ) val before = LocalDateTime.now() val friendship = Friendship.request(command) @@ -287,10 +302,10 @@ class FriendshipTest { @Test fun `friendship lifecycle - success - complete workflow from request to unfriend`() { - val command = FriendRequestCommand(1L, "sender", 2L, "receiver") + val command = createFriendRequestCommand(1L, "sender") // 1. 친구 요청 생성 - val friendship = Friendship.request(command) + val friendship = Friendship.request(command).also { it.setId() } assertEquals(FriendshipStatus.PENDING, friendship.status) // 2. 친구 요청 수락 @@ -304,10 +319,10 @@ class FriendshipTest { @Test fun `friendship lifecycle - success - complete workflow from request to reject`() { - val command = FriendRequestCommand(1L, "sender", 2L, "receiver") + val command = createFriendRequestCommand() // 1. 친구 요청 생성 - val friendship = Friendship.request(command) + val friendship = Friendship.request(command).also { it.setId() } assertEquals(FriendshipStatus.PENDING, friendship.status) // 2. 친구 요청 거절 @@ -315,9 +330,526 @@ class FriendshipTest { assertEquals(FriendshipStatus.REJECTED, friendship.status) } + @Test + fun `rePending - success - changes status to pending when unfriended`() { + val friendship = createAcceptedFriendship() + friendship.unfriend() // UNFRIENDED 상태로 변경 + val beforeUpdate = friendship.updatedAt + + friendship.rePending() + + assertAll( + { assertEquals(FriendshipStatus.PENDING, friendship.status) }, + { assertTrue(friendship.updatedAt > beforeUpdate) } + ) + } + + @Test + fun `rePending - success - changes status to pending when rejected`() { + val friendship = createPendingFriendship() + friendship.reject() // REJECTED 상태로 변경 + val beforeUpdate = friendship.updatedAt + + friendship.rePending() + + assertAll( + { assertEquals(FriendshipStatus.PENDING, friendship.status) }, + { assertTrue(friendship.updatedAt > beforeUpdate) } + ) + } + + @Test + fun `rePending - failure - throws exception when status is pending`() { + val friendship = createPendingFriendship() + + assertFailsWith { + friendship.rePending() + }.let { + assertEquals("친구 요청을 다시 보낼 수 없는 상태입니다. 현재 상태: PENDING", it.message) + } + } + + @Test + fun `rePending - failure - throws exception when status is accepted`() { + val friendship = createAcceptedFriendship() + + assertFailsWith { + friendship.rePending() + }.let { + assertEquals("친구 요청을 다시 보낼 수 없는 상태입니다. 현재 상태: ACCEPTED", it.message) + } + } + + @Test + fun `updateMemberInfo - success - updates sender nickname and image ID`() { + val friendship = createPendingFriendship() + val beforeUpdate = friendship.updatedAt + val newNickname = "새로운닉네임" + val newImageId = 999L + + friendship.updateMemberInfo( + memberId = friendship.relationShip.memberId, + nickname = newNickname, + imageId = newImageId + ) + + assertAll( + { assertEquals(newNickname, friendship.relationShip.memberNickname) }, + { assertEquals(newImageId, friendship.relationShip.memberProfileImageId) }, + { assertTrue(friendship.updatedAt > beforeUpdate) } + ) + } + + @Test + fun `updateMemberInfo - success - updates friend nickname and image ID`() { + val friendship = createPendingFriendship() + val beforeUpdate = friendship.updatedAt + val newNickname = "친구새닉네임" + val newImageId = 888L + + friendship.updateMemberInfo( + memberId = friendship.relationShip.friendMemberId, + nickname = newNickname, + imageId = newImageId + ) + + assertAll( + { assertEquals(newNickname, friendship.relationShip.friendNickname) }, + { assertEquals(newImageId, friendship.relationShip.friendProfileImageId) }, + { assertTrue(friendship.updatedAt > beforeUpdate) } + ) + } + + @Test + fun `updateMemberInfo - success - updates only nickname when image ID is null`() { + val friendship = createPendingFriendship() + val originalImageId = friendship.relationShip.memberProfileImageId + val newNickname = "닉네임만변경" + + friendship.updateMemberInfo( + memberId = friendship.relationShip.memberId, + nickname = newNickname, + imageId = null + ) + + assertAll( + { assertEquals(newNickname, friendship.relationShip.memberNickname) }, + { assertEquals(originalImageId, friendship.relationShip.memberProfileImageId) } + ) + } + + @Test + fun `updateMemberInfo - success - updates only image ID when nickname is null`() { + val friendship = createPendingFriendship() + val originalNickname = friendship.relationShip.memberNickname + val newImageId = 777L + + friendship.updateMemberInfo( + memberId = friendship.relationShip.memberId, + nickname = null, + imageId = newImageId + ) + + assertAll( + { assertEquals(originalNickname, friendship.relationShip.memberNickname) }, + { assertEquals(newImageId, friendship.relationShip.memberProfileImageId) } + ) + } + + @Test + fun `updateMemberInfo - success - does nothing when member is not related`() { + val friendship = createPendingFriendship() + val beforeUpdate = friendship.updatedAt + val originalNickname = friendship.relationShip.memberNickname + val originalImageId = friendship.relationShip.memberProfileImageId + + friendship.updateMemberInfo( + memberId = 999L, // 관련 없는 회원 ID + nickname = "변경안됨", + imageId = 555L + ) + + assertAll( + { assertEquals(originalNickname, friendship.relationShip.memberNickname) }, + { assertEquals(originalImageId, friendship.relationShip.memberProfileImageId) }, + { assertTrue(beforeUpdate.isEqual(friendship.updatedAt)) } // updatedAt 변경 없음 + ) + } + + @Test + fun `updateMemberInfo - success - updates only friend nickname when image ID is null`() { + val friendship = createPendingFriendship() + val originalImageId = friendship.relationShip.friendProfileImageId + val newNickname = "친구닉네임만변경" + + friendship.updateMemberInfo( + memberId = friendship.relationShip.friendMemberId, + nickname = newNickname, + imageId = null + ) + + assertAll( + { assertEquals(newNickname, friendship.relationShip.friendNickname) }, + { assertEquals(originalImageId, friendship.relationShip.friendProfileImageId) }, + { assertTrue(friendship.updatedAt > friendship.createdAt) } + ) + } + + @Test + fun `updateMemberInfo - success - updates only friend image ID when nickname is null`() { + val friendship = createPendingFriendship() + val originalNickname = friendship.relationShip.friendNickname + val newImageId = 666L + + friendship.updateMemberInfo( + memberId = friendship.relationShip.friendMemberId, + nickname = null, + imageId = newImageId + ) + + assertAll( + { assertEquals(originalNickname, friendship.relationShip.friendNickname) }, + { assertEquals(newImageId, friendship.relationShip.friendProfileImageId) }, + { assertTrue(friendship.updatedAt > friendship.createdAt) } + ) + } + + @Test + fun `updateMemberInfo - success - updates friend info multiple times`() { + val friendship = createPendingFriendship() + + // 첫 번째 업데이트 + friendship.updateMemberInfo( + memberId = friendship.relationShip.friendMemberId, + nickname = "첫번째닉네임", + imageId = 111L + ) + + val firstUpdate = friendship.updatedAt + + // 잠시 대기 + Thread.sleep(1) + + // 두 번째 업데이트 + friendship.updateMemberInfo( + memberId = friendship.relationShip.friendMemberId, + nickname = "두번째닉네임", + imageId = 222L + ) + + assertAll( + { assertEquals("두번째닉네임", friendship.relationShip.friendNickname) }, + { assertEquals(222L, friendship.relationShip.friendProfileImageId) }, + { assertTrue(friendship.updatedAt > firstUpdate) } + ) + } + + @Test + fun `updateMemberInfo - success - updates friend info after unfriend`() { + val friendship = createAcceptedFriendship() + friendship.unfriend() // UNFRIENDED 상태로 변경 + + val beforeUpdate = friendship.updatedAt + + friendship.updateMemberInfo( + memberId = friendship.relationShip.friendMemberId, + nickname = "해제후닉네임", + imageId = 555L + ) + + assertAll( + { assertEquals("해제후닉네임", friendship.relationShip.friendNickname) }, + { assertEquals(555L, friendship.relationShip.friendProfileImageId) }, + { assertTrue(friendship.updatedAt > beforeUpdate) } + ) + } + + @Test + fun `updateMemberInfo - success - updates friend info when sender info is unchanged`() { + val friendship = createPendingFriendship() + val originalSenderNickname = friendship.relationShip.memberNickname + val originalSenderImageId = friendship.relationShip.memberProfileImageId + + val newFriendNickname = "친구만변경" + val newFriendImageId = 999L + + friendship.updateMemberInfo( + memberId = friendship.relationShip.friendMemberId, + nickname = newFriendNickname, + imageId = newFriendImageId + ) + + assertAll( + { assertEquals(originalSenderNickname, friendship.relationShip.memberNickname) }, + { assertEquals(originalSenderImageId, friendship.relationShip.memberProfileImageId) }, + { assertEquals(newFriendNickname, friendship.relationShip.friendNickname) }, + { assertEquals(newFriendImageId, friendship.relationShip.friendProfileImageId) } + ) + } + + @Test + fun `updateMemberInfo - success - preserves friend info when updating sender`() { + val friendship = createPendingFriendship() + val originalFriendNickname = friendship.relationShip.friendNickname + val originalFriendImageId = friendship.relationShip.friendProfileImageId + + friendship.updateMemberInfo( + memberId = friendship.relationShip.memberId, + nickname = "보내는사람변경", + imageId = 123L + ) + + assertAll( + { assertEquals("보내는사람변경", friendship.relationShip.memberNickname) }, + { assertEquals(123L, friendship.relationShip.memberProfileImageId) }, + { assertEquals(originalFriendNickname, friendship.relationShip.friendNickname) }, + { assertEquals(originalFriendImageId, friendship.relationShip.friendProfileImageId) } + ) + } + + @Test + fun `updateMemberInfo - success - does not update updatedAt when both parameters are null`() { + val friendship = createPendingFriendship() + val beforeUpdate = friendship.updatedAt + + friendship.updateMemberInfo( + memberId = friendship.relationShip.memberId, + nickname = null, + imageId = null + ) + + assertTrue(beforeUpdate.isEqual(friendship.updatedAt)) // updatedAt 변경 없음 + } + + @Test + fun `updateMemberInfo - success - does not update updatedAt when both parameters are null for friend`() { + val friendship = createPendingFriendship() + val beforeUpdate = friendship.updatedAt + + friendship.updateMemberInfo( + memberId = friendship.relationShip.friendMemberId, + nickname = null, + imageId = null + ) + + assertTrue(beforeUpdate.isEqual(friendship.updatedAt)) // updatedAt 변경 없음 + } + + @Test + fun `updateMemberInfo - success - updates updatedAt when only nickname is provided`() { + val friendship = createPendingFriendship() + val beforeUpdate = friendship.updatedAt + + Thread.sleep(1) + + friendship.updateMemberInfo( + memberId = friendship.relationShip.memberId, + nickname = "새닉네임", + imageId = null + ) + + assertTrue(friendship.updatedAt.isAfter(beforeUpdate)) + } + + @Test + fun `updateMemberInfo - success - updates updatedAt when only imageId is provided`() { + val friendship = createPendingFriendship() + val beforeUpdate = friendship.updatedAt + + Thread.sleep(1) + + friendship.updateMemberInfo( + memberId = friendship.relationShip.memberId, + nickname = null, + imageId = 123L + ) + + assertTrue(friendship.updatedAt.isAfter(beforeUpdate)) + } + + @Test + fun `updateMemberInfo - boundary - empty string nickname still updates updatedAt`() { + val friendship = createPendingFriendship() + val beforeUpdate = friendship.updatedAt + + Thread.sleep(1) + + friendship.updateMemberInfo( + memberId = friendship.relationShip.memberId, + nickname = "", // 빈 문자열 + imageId = null + ) + + assertTrue(friendship.updatedAt.isAfter(beforeUpdate)) + assertEquals("", friendship.relationShip.memberNickname) + } + + @Test + fun `drainDomainEvents - success - returns FriendRequestSentEvent with actual ID when friendship is created`() { + val friendship = Friendship.request(createFriendRequestCommand()) + friendship.setId(123L) + + val events = friendship.drainDomainEvents() + + assertEquals(1, events.size) + assertTrue(events[0] is FriendRequestSentEvent) + + val event = events[0] as FriendRequestSentEvent + assertEquals(123L, event.friendshipId) + assertEquals(1L, event.fromMemberId) + assertEquals(2L, event.toMemberId) + } + + @Test + fun `drainDomainEvents - success - returns empty list when called twice`() { + val friendship = Friendship.request(createFriendRequestCommand()) + friendship.setId(123L) + + // 첫 번째 호출 + val firstEvents = friendship.drainDomainEvents() + assertEquals(1, firstEvents.size) + + // 두 번째 호출은 빈 리스트 반환 + val secondEvents = friendship.drainDomainEvents() + assertEquals(0, secondEvents.size) + } + + @Test + fun `drainDomainEvents - success - returns FriendRequestAcceptedEvent with actual ID when accepted`() { + val friendship = createPendingFriendship() + friendship.setId(456L) + friendship.accept() + + val events = friendship.drainDomainEvents() + + assertEquals(2, events.size) // 생성 + 수락 이벤트 + assertTrue(events[1] is FriendRequestAcceptedEvent) + + val acceptEvent = events[1] as FriendRequestAcceptedEvent + assertEquals(456L, acceptEvent.friendshipId) + assertEquals(1L, acceptEvent.fromMemberId) + assertEquals(2L, acceptEvent.toMemberId) + } + + @Test + fun `drainDomainEvents - success - returns FriendRequestRejectedEvent with actual ID when rejected`() { + val friendship = createPendingFriendship() + friendship.setId(789L) + friendship.reject() + + val events = friendship.drainDomainEvents() + + assertEquals(2, events.size) // 생성 + 거절 이벤트 + assertTrue(events[1] is FriendRequestRejectedEvent) + + val rejectEvent = events[1] as FriendRequestRejectedEvent + assertEquals(789L, rejectEvent.friendshipId) + assertEquals(1L, rejectEvent.fromMemberId) + assertEquals(2L, rejectEvent.toMemberId) + } + + @Test + fun `drainDomainEvents - success - returns FriendshipTerminatedEvent with actual ID when unfriended`() { + val friendship = createAcceptedFriendship() + friendship.setId(999L) + friendship.unfriend() + + val events = friendship.drainDomainEvents() + + assertEquals(3, events.size) // 생성 + 수락 + 해제 이벤트 + assertTrue(events[2] is FriendshipTerminatedEvent) + + val terminateEvent = events[2] as FriendshipTerminatedEvent + assertEquals(999L, terminateEvent.friendshipId) + assertEquals(1L, terminateEvent.memberId) + assertEquals(2L, terminateEvent.friendMemberId) + } + + @Test + fun `drainDomainEvents - success - handles multiple events in correct order`() { + val friendship = createPendingFriendship() + friendship.setId(555L) + + // 수락 후 해제 + friendship.accept() + friendship.unfriend() + + val events = friendship.drainDomainEvents() + + assertEquals(3, events.size) + assertTrue(events[0] is FriendRequestSentEvent) + assertTrue(events[1] is FriendRequestAcceptedEvent) + assertTrue(events[2] is FriendshipTerminatedEvent) + + // 모든 이벤트의 friendshipId가 실제 ID로 설정되었는지 확인 + events.forEach { event -> + when (event) { + is FriendRequestSentEvent -> assertEquals(555L, event.friendshipId) + is FriendRequestAcceptedEvent -> assertEquals(555L, event.friendshipId) + is FriendshipTerminatedEvent -> assertEquals(555L, event.friendshipId) + } + } + } + + @Test + fun `drainDomainEvents - success - includes events from rePending`() { + val friendship = createAcceptedFriendship() + friendship.setId(666L) + friendship.unfriend() // UNFRIENDED 상태 + friendship.rePending() // 다시 PENDING 상태 + + val events = friendship.drainDomainEvents() + + assertEquals(4, events.size) // 생성 + 해제 + 재요청 이벤트 + assertTrue(events[3] is FriendRequestSentEvent) + + val rePendingEvent = events[3] as FriendRequestSentEvent + assertEquals(666L, rePendingEvent.friendshipId) + assertEquals(1L, rePendingEvent.fromMemberId) + assertEquals(2L, rePendingEvent.toMemberId) + } + + @Test + fun `setter - success - for coverage`() { + val friendship = TestFriendship( + relationShip = FriendRelationship.of( + FriendRequestCommand( + fromMemberId = 1L, + fromMemberNickname = "홍길동", + fromMemberProfileImageId = 3L, + toMemberId = 2L, + toMemberNickname = "김철수", + toMemberProfileImageId = 4L, + ) + ) + ) + val newCreatedAt = LocalDateTime.now() + + friendship.setCreatedAtForTest(newCreatedAt) + + assertTrue { newCreatedAt.isEqual(friendship.createdAt) } + } + + private class TestFriendship( + relationShip: FriendRelationship, + status: FriendshipStatus = FriendshipStatus.PENDING, + createdAt: LocalDateTime = LocalDateTime.now(), + updatedAt: LocalDateTime = LocalDateTime.now(), + ) : Friendship( + relationShip = relationShip, + status = status, + createdAt = createdAt, + updatedAt = updatedAt, + ) { + fun setCreatedAtForTest(createdAt: LocalDateTime) { + this.createdAt = createdAt + } + } + private fun createPendingFriendship(): Friendship { - val command = FriendRequestCommand(1L, "sender", 2L, "receiver") + val command = createFriendRequestCommand() return Friendship.request(command) + .also { it.setId() } } private fun createAcceptedFriendship(): Friendship { @@ -327,7 +859,29 @@ class FriendshipTest { } private fun createFriendship(fromMemberId: Long, toMemberId: Long): Friendship { - val command = FriendRequestCommand(fromMemberId, "sender", toMemberId, "receiver") + val command = createFriendRequestCommand( + fromMemberId = fromMemberId, + toMemberId = toMemberId + ) return Friendship.request(command) + .also { it.setId() } + } + + private fun createFriendRequestCommand( + fromMemberId: Long = 1L, + fromMemberNickName: String = "sender", + fromMemberProfileImageId: Long = 3L, + toMemberId: Long = 2L, + toMemberNickname: String = "receiver", + toMemberProfileImageId: Long = 4L, + ): FriendRequestCommand { + return FriendRequestCommand( + fromMemberId, + fromMemberNickName, + fromMemberProfileImageId, + toMemberId, + toMemberNickname, + toMemberProfileImageId + ) } } 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 9643d025..1a5cc8ec 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 @@ -13,8 +13,17 @@ class FriendRequestCommandTest { val fromMemberNickname = "sender" val toMemberId = 2L val toMemberNickname = "receiver" - - val command = FriendRequestCommand(fromMemberId, fromMemberNickname, toMemberId, toMemberNickname) + val fromMemberProfileImageId = 3L + val toMemberProfileImageId = 4L + + val command = FriendRequestCommand( + fromMemberId, + fromMemberNickname, + fromMemberProfileImageId, + toMemberId, + toMemberNickname, + toMemberProfileImageId + ) assertAll( { assertEquals(fromMemberId, command.fromMemberId) }, @@ -29,9 +38,18 @@ class FriendRequestCommandTest { val fromMemberNickname = "sender" val toMemberId = 2L val toMemberNickname = "receiver" + val fromMemberProfileImageId = 3L + val toMemberProfileImageId = 4L assertFailsWith { - FriendRequestCommand(fromMemberId, fromMemberNickname, toMemberId, toMemberNickname) + FriendRequestCommand( + fromMemberId, + fromMemberNickname, + fromMemberProfileImageId, + toMemberId, + toMemberNickname, + toMemberProfileImageId + ) }.let { assertEquals(FriendRequestCommand.ERROR_FROM_MEMBER_ID_MUST_BE_POSITIVE, it.message) } @@ -43,9 +61,18 @@ class FriendRequestCommandTest { val fromMemberNickname = "sender" val toMemberId = 2L val toMemberNickname = "receiver" + val fromMemberProfileImageId = 3L + val toMemberProfileImageId = 4L assertFailsWith { - FriendRequestCommand(fromMemberId, fromMemberNickname, toMemberId, toMemberNickname) + FriendRequestCommand( + fromMemberId, + fromMemberNickname, + fromMemberProfileImageId, + toMemberId, + toMemberNickname, + toMemberProfileImageId + ) }.let { assertEquals(FriendRequestCommand.ERROR_FROM_MEMBER_ID_MUST_BE_POSITIVE, it.message) } @@ -57,9 +84,18 @@ class FriendRequestCommandTest { val fromMemberNickname = "sender" val toMemberId = 0L val toMemberNickname = "receiver" + val fromMemberProfileImageId = 3L + val toMemberProfileImageId = 4L assertFailsWith { - FriendRequestCommand(fromMemberId, fromMemberNickname, toMemberId, toMemberNickname) + FriendRequestCommand( + fromMemberId, + fromMemberNickname, + fromMemberProfileImageId, + toMemberId, + toMemberNickname, + toMemberProfileImageId, + ) }.let { assertEquals(FriendRequestCommand.ERROR_TO_MEMBER_ID_MUST_BE_POSITIVE, it.message) } @@ -71,9 +107,18 @@ class FriendRequestCommandTest { val fromMemberNickname = "sender" val toMemberId = -1L val toMemberNickname = "receiver" + val fromMemberProfileImageId = 3L + val toMemberProfileImageId = 4L assertFailsWith { - FriendRequestCommand(fromMemberId, fromMemberNickname, toMemberId, toMemberNickname) + FriendRequestCommand( + fromMemberId, + fromMemberNickname, + fromMemberProfileImageId, + toMemberId, + toMemberNickname, + toMemberProfileImageId, + ) }.let { assertEquals(FriendRequestCommand.ERROR_TO_MEMBER_ID_MUST_BE_POSITIVE, it.message) } @@ -84,9 +129,18 @@ class FriendRequestCommandTest { val memberId = 1L val fromMemberNickname = "sender" val toMemberNickname = "receiver" + val fromMemberProfileImageId = 3L + val toMemberProfileImageId = 4L assertFailsWith { - FriendRequestCommand(memberId, fromMemberNickname, memberId, toMemberNickname) + FriendRequestCommand( + memberId, + fromMemberNickname, + fromMemberProfileImageId, + memberId, + toMemberNickname, + toMemberProfileImageId, + ) }.let { assertEquals(FriendRequestCommand.ERROR_CANNOT_REQUEST_FRIENDSHIP_TO_YOURSELF, it.message) } @@ -98,9 +152,18 @@ class FriendRequestCommandTest { val toMemberId = 0L val fromMemberNickname = "sender" val toMemberNickname = "receiver" + val fromMemberProfileImageId = 3L + val toMemberProfileImageId = 4L assertFailsWith { - FriendRequestCommand(fromMemberId, fromMemberNickname, toMemberId, toMemberNickname) + FriendRequestCommand( + fromMemberId, + fromMemberNickname, + fromMemberProfileImageId, + toMemberId, + toMemberNickname, + toMemberProfileImageId, + ) }.let { // fromMemberId 검증이 먼저 수행되므로 해당 에러 메시지가 나옴 assertEquals(FriendRequestCommand.ERROR_FROM_MEMBER_ID_MUST_BE_POSITIVE, it.message) @@ -113,9 +176,18 @@ class FriendRequestCommandTest { val toMemberId = -2L val fromMemberNickname = "sender" val toMemberNickname = "receiver" + val fromMemberProfileImageId = 3L + val toMemberProfileImageId = 4L assertFailsWith { - FriendRequestCommand(fromMemberId, fromMemberNickname, toMemberId, toMemberNickname) + FriendRequestCommand( + fromMemberId, + fromMemberNickname, + fromMemberProfileImageId, + toMemberId, + toMemberNickname, + toMemberProfileImageId, + ) }.let { // fromMemberId 검증이 먼저 수행되므로 해당 에러 메시지가 나옴 assertEquals(FriendRequestCommand.ERROR_FROM_MEMBER_ID_MUST_BE_POSITIVE, it.message) @@ -128,8 +200,17 @@ class FriendRequestCommandTest { val toMemberId = Long.MAX_VALUE val fromMemberNickname = "sender" val toMemberNickname = "receiver" - - val command = FriendRequestCommand(fromMemberId, fromMemberNickname, toMemberId, toMemberNickname) + val fromMemberProfileImageId = 3L + val toMemberProfileImageId = 4L + + val command = FriendRequestCommand( + fromMemberId, + fromMemberNickname, + fromMemberProfileImageId, + toMemberId, + toMemberNickname, + toMemberProfileImageId, + ) assertAll( { assertEquals(fromMemberId, command.fromMemberId) }, @@ -144,8 +225,17 @@ class FriendRequestCommandTest { val toMemberId = 1L + 1 val fromMemberNickname = "sender" val toMemberNickname = "receiver" - - val command = FriendRequestCommand(fromMemberId, fromMemberNickname, toMemberId, toMemberNickname) + val fromMemberProfileImageId = 3L + val toMemberProfileImageId = 4L + + val command = FriendRequestCommand( + fromMemberId, + fromMemberNickname, + fromMemberProfileImageId, + toMemberId, + toMemberNickname, + toMemberProfileImageId, + ) assertAll( { assertEquals(fromMemberId, command.fromMemberId) }, @@ -160,9 +250,18 @@ class FriendRequestCommandTest { val toMemberId = 2L val fromMemberNickname = "sender" val toMemberNickname = "" + val fromMemberProfileImageId = 3L + val toMemberProfileImageId = 4L assertFailsWith { - FriendRequestCommand(fromMemberId, fromMemberNickname, toMemberId, toMemberNickname) + FriendRequestCommand( + fromMemberId, + fromMemberNickname, + fromMemberProfileImageId, + toMemberId, + toMemberNickname, + toMemberProfileImageId, + ) }.let { assertEquals(FriendRequestCommand.ERROR_TO_MEMBER_NICKNAME_MUST_NOT_BE_EMPTY, it.message) } @@ -174,13 +273,22 @@ class FriendRequestCommandTest { val toMemberId = 2L val fromMemberNickname = "sender" val toMemberNickname = "receiver" - - val command = FriendRequestCommand(fromMemberId, fromMemberNickname, toMemberId, toMemberNickname) + val fromMemberProfileImageId = 3L + val toMemberProfileImageId = 4L + + val command = FriendRequestCommand( + fromMemberId, + fromMemberNickname, + fromMemberProfileImageId, + toMemberId, + toMemberNickname, + toMemberProfileImageId, + ) assertAll( { assertEquals(toMemberNickname, command.toMemberNickname) }, { assertEquals(toMemberId, command.toMemberId) }, - { assertEquals(fromMemberNickname, command.fromMemberNickName) }, + { assertEquals(fromMemberNickname, command.fromMemberNickname) }, { assertEquals(fromMemberId, command.fromMemberId) } ) } @@ -191,9 +299,18 @@ class FriendRequestCommandTest { val toMemberId = 2L val fromMemberNickname = "sender" val toMemberNickname = " " + val fromMemberProfileImageId = 3L + val toMemberProfileImageId = 4L assertFailsWith { - FriendRequestCommand(fromMemberId, fromMemberNickname, toMemberId, toMemberNickname) + FriendRequestCommand( + fromMemberId, + fromMemberNickname, + fromMemberProfileImageId, + toMemberId, + toMemberNickname, + toMemberProfileImageId, + ) }.let { assertEquals(FriendRequestCommand.ERROR_TO_MEMBER_NICKNAME_MUST_NOT_BE_EMPTY, it.message) } @@ -205,9 +322,18 @@ class FriendRequestCommandTest { val toMemberId = 2L val fromMemberNickname = "sender" val whitespaceNickname = "\t\n\r " + val fromMemberProfileImageId = 3L + val toMemberProfileImageId = 4L assertFailsWith { - FriendRequestCommand(fromMemberId, fromMemberNickname, toMemberId, whitespaceNickname) + FriendRequestCommand( + fromMemberId, + fromMemberNickname, + fromMemberProfileImageId, + toMemberId, + whitespaceNickname, + toMemberProfileImageId, + ) }.let { assertEquals(FriendRequestCommand.ERROR_TO_MEMBER_NICKNAME_MUST_NOT_BE_EMPTY, it.message) } @@ -219,11 +345,356 @@ class FriendRequestCommandTest { val toMemberId = 2L val emptyNickname = "" val toMemberNickname = "receiver" + val fromMemberProfileImageId = 3L + val toMemberProfileImageId = 4L + + assertFailsWith { + FriendRequestCommand( + fromMemberId, + emptyNickname, + fromMemberProfileImageId, + toMemberId, + toMemberNickname, + toMemberProfileImageId, + ) + }.let { + assertEquals(FriendRequestCommand.ERROR_FROM_MEMBER_NICKNAME_MUST_NOT_BE_EMPTY, it.message) + } + } + + @Test + fun `create - failure - throws exception when from nickname is blank`() { + val fromMemberId = 1L + val toMemberId = 2L + val fromMemberNickname = " " + val toMemberNickname = "receiver" + val fromMemberProfileImageId = 3L + val toMemberProfileImageId = 4L assertFailsWith { - FriendRequestCommand(fromMemberId, emptyNickname, toMemberId, toMemberNickname) + FriendRequestCommand( + fromMemberId, + fromMemberNickname, + fromMemberProfileImageId, + toMemberId, + toMemberNickname, + toMemberProfileImageId, + ) }.let { assertEquals(FriendRequestCommand.ERROR_FROM_MEMBER_NICKNAME_MUST_NOT_BE_EMPTY, it.message) } } + + @Test + fun `create - failure - throws exception when from nickname is only whitespace`() { + val fromMemberId = 1L + val toMemberId = 2L + val fromMemberNickname = "\t\n\r " + val toMemberNickname = "receiver" + val fromMemberProfileImageId = 3L + val toMemberProfileImageId = 4L + + assertFailsWith { + FriendRequestCommand( + fromMemberId, + fromMemberNickname, + fromMemberProfileImageId, + toMemberId, + toMemberNickname, + toMemberProfileImageId, + ) + }.let { + assertEquals(FriendRequestCommand.ERROR_FROM_MEMBER_NICKNAME_MUST_NOT_BE_EMPTY, it.message) + } + } + + @Test + fun `create - failure - throws exception when fromMemberProfileImageId is zero`() { + val fromMemberId = 1L + val fromMemberNickname = "sender" + val toMemberId = 2L + val toMemberNickname = "receiver" + val fromMemberProfileImageId = 0L + val toMemberProfileImageId = 4L + + assertFailsWith { + FriendRequestCommand( + fromMemberId, + fromMemberNickname, + fromMemberProfileImageId, + toMemberId, + toMemberNickname, + toMemberProfileImageId, + ) + }.let { + assertEquals(FriendRequestCommand.ERROR_FROM_MEMBER_IMAGE_ID_MUST_BE_POSITIVE, it.message) + } + } + + @Test + fun `create - failure - throws exception when fromMemberProfileImageId is negative`() { + val fromMemberId = 1L + val fromMemberNickname = "sender" + val toMemberId = 2L + val toMemberNickname = "receiver" + val fromMemberProfileImageId = -1L + val toMemberProfileImageId = 4L + + assertFailsWith { + FriendRequestCommand( + fromMemberId, + fromMemberNickname, + fromMemberProfileImageId, + toMemberId, + toMemberNickname, + toMemberProfileImageId, + ) + }.let { + assertEquals(FriendRequestCommand.ERROR_FROM_MEMBER_IMAGE_ID_MUST_BE_POSITIVE, it.message) + } + } + + @Test + fun `create - failure - throws exception when toMemberProfileImageId is zero`() { + val fromMemberId = 1L + val fromMemberNickname = "sender" + val toMemberId = 2L + val toMemberNickname = "receiver" + val fromMemberProfileImageId = 3L + val toMemberProfileImageId = 0L + + assertFailsWith { + FriendRequestCommand( + fromMemberId, + fromMemberNickname, + fromMemberProfileImageId, + toMemberId, + toMemberNickname, + toMemberProfileImageId, + ) + }.let { + assertEquals(FriendRequestCommand.ERROR_TO_MEMBER_IMAGE_ID_MUST_BE_POSITIVE, it.message) + } + } + + @Test + fun `create - failure - throws exception when toMemberProfileImageId is negative`() { + val fromMemberId = 1L + val fromMemberNickname = "sender" + val toMemberId = 2L + val toMemberNickname = "receiver" + val fromMemberProfileImageId = 3L + val toMemberProfileImageId = -1L + + assertFailsWith { + FriendRequestCommand( + fromMemberId, + fromMemberNickname, + fromMemberProfileImageId, + toMemberId, + toMemberNickname, + toMemberProfileImageId, + ) + }.let { + assertEquals(FriendRequestCommand.ERROR_TO_MEMBER_IMAGE_ID_MUST_BE_POSITIVE, it.message) + } + } + + @Test + fun `create - failure - throws exception when both profile image IDs are zero`() { + val fromMemberId = 1L + val fromMemberNickname = "sender" + val toMemberId = 2L + val toMemberNickname = "receiver" + val fromMemberProfileImageId = 0L + val toMemberProfileImageId = 0L + + assertFailsWith { + FriendRequestCommand( + fromMemberId, + fromMemberNickname, + fromMemberProfileImageId, + toMemberId, + toMemberNickname, + toMemberProfileImageId, + ) + }.let { + assertEquals(FriendRequestCommand.ERROR_FROM_MEMBER_IMAGE_ID_MUST_BE_POSITIVE, it.message) + } + } + + @Test + fun `create - failure - throws exception when both profile image IDs are negative`() { + val fromMemberId = 1L + val fromMemberNickname = "sender" + val toMemberId = 2L + val toMemberNickname = "receiver" + val fromMemberProfileImageId = -1L + val toMemberProfileImageId = -2L + + assertFailsWith { + FriendRequestCommand( + fromMemberId, + fromMemberNickname, + fromMemberProfileImageId, + toMemberId, + toMemberNickname, + toMemberProfileImageId, + ) + }.let { + assertEquals(FriendRequestCommand.ERROR_FROM_MEMBER_IMAGE_ID_MUST_BE_POSITIVE, it.message) + } + } + + @Test + fun `create - success - accepts large positive profile image IDs`() { + val fromMemberId = 1L + val fromMemberNickname = "sender" + val toMemberId = 2L + val toMemberNickname = "receiver" + val fromMemberProfileImageId = Long.MAX_VALUE - 1 + val toMemberProfileImageId = Long.MAX_VALUE + + val command = FriendRequestCommand( + fromMemberId, + fromMemberNickname, + fromMemberProfileImageId, + toMemberId, + toMemberNickname, + toMemberProfileImageId, + ) + + assertAll( + { assertEquals(fromMemberProfileImageId, command.fromMemberProfileImageId) }, + { assertEquals(toMemberProfileImageId, command.toMemberProfileImageId) } + ) + } + + @Test + fun `create - success - accepts minimum positive profile image IDs`() { + val fromMemberId = 1L + val fromMemberNickname = "sender" + val toMemberId = 2L + val toMemberNickname = "receiver" + val fromMemberProfileImageId = 1L + val toMemberProfileImageId = 1L + + val command = FriendRequestCommand( + fromMemberId, + fromMemberNickname, + fromMemberProfileImageId, + toMemberId, + toMemberNickname, + toMemberProfileImageId, + ) + + assertAll( + { assertEquals(fromMemberProfileImageId, command.fromMemberProfileImageId) }, + { assertEquals(toMemberProfileImageId, command.toMemberProfileImageId) } + ) + } + + @Test + fun `create - success - accepts valid nicknames with special characters`() { + val fromMemberId = 1L + val fromMemberNickname = "sender_123" + val toMemberId = 2L + val toMemberNickname = "receiver-abc" + val fromMemberProfileImageId = 3L + val toMemberProfileImageId = 4L + + val command = FriendRequestCommand( + fromMemberId, + fromMemberNickname, + fromMemberProfileImageId, + toMemberId, + toMemberNickname, + toMemberProfileImageId, + ) + + assertAll( + { assertEquals(fromMemberNickname, command.fromMemberNickname) }, + { assertEquals(toMemberNickname, command.toMemberNickname) } + ) + } + + @Test + fun `create - success - accepts valid nicknames with Korean characters`() { + val fromMemberId = 1L + val fromMemberNickname = "홍길동" + val toMemberId = 2L + val toMemberNickname = "김철수" + val fromMemberProfileImageId = 3L + val toMemberProfileImageId = 4L + + val command = FriendRequestCommand( + fromMemberId, + fromMemberNickname, + fromMemberProfileImageId, + toMemberId, + toMemberNickname, + toMemberProfileImageId, + ) + + assertAll( + { assertEquals(fromMemberNickname, command.fromMemberNickname) }, + { assertEquals(toMemberNickname, command.toMemberNickname) } + ) + } + + @Test + fun `constants - success - has correct error messages`() { + assertAll( + { assertEquals("요청자 회원 ID는 양수여야 합니다", FriendRequestCommand.ERROR_FROM_MEMBER_ID_MUST_BE_POSITIVE) }, + { assertEquals("대상 회원 ID는 양수여야 합니다", FriendRequestCommand.ERROR_TO_MEMBER_ID_MUST_BE_POSITIVE) }, + { + assertEquals( + "자기 자신에게는 친구 요청을 보낼 수 없습니다", + FriendRequestCommand.ERROR_CANNOT_REQUEST_FRIENDSHIP_TO_YOURSELF + ) + }, + { assertEquals("대상 회원 닉네임은 비어있을 수 없습니다", FriendRequestCommand.ERROR_TO_MEMBER_NICKNAME_MUST_NOT_BE_EMPTY) }, + { + assertEquals( + "요청자 회원 닉네임은 비어있을 수 없습니다", + FriendRequestCommand.ERROR_FROM_MEMBER_NICKNAME_MUST_NOT_BE_EMPTY + ) + }, + { + assertEquals( + "요청자 회원 이미지 ID는 양수여야 합니다", + FriendRequestCommand.ERROR_FROM_MEMBER_IMAGE_ID_MUST_BE_POSITIVE + ) + }, + { assertEquals("대상 회원 이미지 ID는 양수여야 합니다", FriendRequestCommand.ERROR_TO_MEMBER_IMAGE_ID_MUST_BE_POSITIVE) } + ) + } + + @Test + fun `create - success - verifies all properties are set correctly`() { + val fromMemberId = 10L + val fromMemberNickname = "요청자" + val fromMemberProfileImageId = 100L + val toMemberId = 20L + val toMemberNickname = "수신자" + val toMemberProfileImageId = 200L + + val command = FriendRequestCommand( + fromMemberId, + fromMemberNickname, + fromMemberProfileImageId, + toMemberId, + toMemberNickname, + toMemberProfileImageId, + ) + + assertAll( + { assertEquals(fromMemberId, command.fromMemberId) }, + { assertEquals(fromMemberNickname, command.fromMemberNickname) }, + { assertEquals(fromMemberProfileImageId, command.fromMemberProfileImageId) }, + { assertEquals(toMemberId, command.toMemberId) }, + { assertEquals(toMemberNickname, command.toMemberNickname) }, + { assertEquals(toMemberProfileImageId, command.toMemberProfileImageId) } + ) + } } diff --git a/src/test/kotlin/com/albert/realmoneyrealtaste/domain/friend/event/FriendRequestAcceptedEventTest.kt b/src/test/kotlin/com/albert/realmoneyrealtaste/domain/friend/event/FriendRequestAcceptedEventTest.kt index fbb3a14e..2fe6812f 100644 --- a/src/test/kotlin/com/albert/realmoneyrealtaste/domain/friend/event/FriendRequestAcceptedEventTest.kt +++ b/src/test/kotlin/com/albert/realmoneyrealtaste/domain/friend/event/FriendRequestAcceptedEventTest.kt @@ -1,7 +1,9 @@ package com.albert.realmoneyrealtaste.domain.friend.event +import java.time.LocalDateTime import kotlin.test.Test import kotlin.test.assertEquals +import kotlin.test.assertTrue class FriendRequestAcceptedEventTest { @@ -14,11 +16,13 @@ class FriendRequestAcceptedEventTest { val event = FriendRequestAcceptedEvent( friendshipId = friendshipId, fromMemberId = fromMemberId, - toMemberId = toMemberId + toMemberId = toMemberId, + occurredAt = LocalDateTime.now(), ) assertEquals(friendshipId, event.friendshipId) assertEquals(fromMemberId, event.fromMemberId) assertEquals(toMemberId, event.toMemberId) + assertTrue(event.occurredAt.isBefore(LocalDateTime.now())) } } diff --git a/src/test/kotlin/com/albert/realmoneyrealtaste/domain/friend/event/FriendRequestSentEventTest.kt b/src/test/kotlin/com/albert/realmoneyrealtaste/domain/friend/event/FriendRequestSentEventTest.kt index b3304791..9a44d267 100644 --- a/src/test/kotlin/com/albert/realmoneyrealtaste/domain/friend/event/FriendRequestSentEventTest.kt +++ b/src/test/kotlin/com/albert/realmoneyrealtaste/domain/friend/event/FriendRequestSentEventTest.kt @@ -1,7 +1,9 @@ package com.albert.realmoneyrealtaste.domain.friend.event +import java.time.LocalDateTime import kotlin.test.Test import kotlin.test.assertEquals +import kotlin.test.assertTrue class FriendRequestSentEventTest { @@ -14,11 +16,13 @@ class FriendRequestSentEventTest { val event = FriendRequestSentEvent( friendshipId = friendshipId, fromMemberId = fromMemberId, - toMemberId = toMemberId + toMemberId = toMemberId, + occurredAt = LocalDateTime.now(), ) assertEquals(friendshipId, event.friendshipId) assertEquals(fromMemberId, event.fromMemberId) assertEquals(toMemberId, event.toMemberId) + assertTrue(event.occurredAt.isBefore(LocalDateTime.now())) } } diff --git a/src/test/kotlin/com/albert/realmoneyrealtaste/domain/friend/event/FriendshipTerminatedEventTest.kt b/src/test/kotlin/com/albert/realmoneyrealtaste/domain/friend/event/FriendshipTerminatedEventTest.kt index e3baf461..7ee90f83 100644 --- a/src/test/kotlin/com/albert/realmoneyrealtaste/domain/friend/event/FriendshipTerminatedEventTest.kt +++ b/src/test/kotlin/com/albert/realmoneyrealtaste/domain/friend/event/FriendshipTerminatedEventTest.kt @@ -1,7 +1,9 @@ package com.albert.realmoneyrealtaste.domain.friend.event +import java.time.LocalDateTime import kotlin.test.Test import kotlin.test.assertEquals +import kotlin.test.assertTrue class FriendshipTerminatedEventTest { @@ -12,10 +14,14 @@ class FriendshipTerminatedEventTest { val event = FriendshipTerminatedEvent( memberId = memberId, - friendMemberId = friendMemberId + friendMemberId = friendMemberId, + friendshipId = 1L, + occurredAt = LocalDateTime.now(), ) assertEquals(memberId, event.memberId) assertEquals(friendMemberId, event.friendMemberId) + assertEquals(1L, event.friendshipId) + assertTrue(event.occurredAt.isBefore(LocalDateTime.now())) } } 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 8f4fdd01..05d310c9 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 @@ -14,8 +14,17 @@ class FriendRelationshipTest { val friendMemberId = 2L val fromMemberNickname = "sender" val friendNickname = "receiver" + val memberProfileImageId = 3L + val friendProfileImageId = 4L - val relationship = FriendRelationship(memberId, fromMemberNickname, friendMemberId, friendNickname) + val relationship = FriendRelationship( + memberId, + fromMemberNickname, + memberProfileImageId, + friendMemberId, + friendNickname, + friendProfileImageId + ) assertAll( { assertEquals(memberId, relationship.memberId) }, @@ -30,9 +39,18 @@ class FriendRelationshipTest { val fromMemberNickname = "sender" val friendNickname = "receiver" val friendMemberId = 2L + val memberProfileImageId = 3L + val friendProfileImageId = 4L assertFailsWith { - FriendRelationship(memberId, fromMemberNickname, friendMemberId, friendNickname) + FriendRelationship( + memberId, + fromMemberNickname, + memberProfileImageId, + friendMemberId, + friendNickname, + friendProfileImageId + ) }.let { assertEquals(FriendRelationship.ERROR_MEMBER_ID_MUST_BE_POSITIVE, it.message) } @@ -44,9 +62,18 @@ class FriendRelationshipTest { val friendMemberId = 2L val fromMemberNickname = "sender" val friendNickname = "receiver" + val memberProfileImageId = 3L + val friendProfileImageId = 4L assertFailsWith { - FriendRelationship(memberId, fromMemberNickname, friendMemberId, friendNickname) + FriendRelationship( + memberId, + fromMemberNickname, + memberProfileImageId, + friendMemberId, + friendNickname, + friendProfileImageId + ) }.let { assertEquals(FriendRelationship.ERROR_MEMBER_ID_MUST_BE_POSITIVE, it.message) } @@ -58,9 +85,18 @@ class FriendRelationshipTest { val friendMemberId = 0L val fromMemberNickname = "sender" val friendNickname = "receiver" + val memberProfileImageId = 3L + val friendProfileImageId = 4L assertFailsWith { - FriendRelationship(memberId, fromMemberNickname, friendMemberId, friendNickname) + FriendRelationship( + memberId, + fromMemberNickname, + memberProfileImageId, + friendMemberId, + friendNickname, + friendProfileImageId + ) }.let { assertEquals(FriendRelationship.ERROR_FRIEND_MEMBER_ID_MUST_BE_POSITIVE, it.message) } @@ -72,9 +108,18 @@ class FriendRelationshipTest { val friendMemberId = -1L val fromMemberNickname = "sender" val friendNickname = "receiver" + val memberProfileImageId = 3L + val friendProfileImageId = 4L assertFailsWith { - FriendRelationship(memberId, fromMemberNickname, friendMemberId, friendNickname) + FriendRelationship( + memberId, + fromMemberNickname, + memberProfileImageId, + friendMemberId, + friendNickname, + friendProfileImageId + ) }.let { assertEquals(FriendRelationship.ERROR_FRIEND_MEMBER_ID_MUST_BE_POSITIVE, it.message) } @@ -85,21 +130,131 @@ class FriendRelationshipTest { val memberId = 1L val fromMemberNickname = "sender" val friendNickname = "receiver" + val memberProfileImageId = 3L + val friendProfileImageId = 4L assertFailsWith { - FriendRelationship(memberId, fromMemberNickname, memberId, friendNickname) + FriendRelationship( + memberId, + fromMemberNickname, + memberProfileImageId, + memberId, + friendNickname, + friendProfileImageId + ) }.let { assertEquals(FriendRelationship.ERROR_CANNOT_FRIEND_YOURSELF, it.message) } } + @Test + fun `create - failure - throws exception when memberProfileImageId is zero`() { + val memberId = 1L + val friendMemberId = 2L + val fromMemberNickname = "sender" + val friendNickname = "receiver" + val memberProfileImageId = 0L + val friendProfileImageId = 4L + + assertFailsWith { + FriendRelationship( + memberId, + fromMemberNickname, + memberProfileImageId, + friendMemberId, + friendNickname, + friendProfileImageId + ) + }.let { + assertEquals(FriendRelationship.ERROR_MEMBER_PROFILE_IMAGE_ID_MUST_BE_POSITIVE, it.message) + } + } + + @Test + fun `create - failure - throws exception when memberProfileImageId is negative`() { + val memberId = 1L + val friendMemberId = 2L + val fromMemberNickname = "sender" + val friendNickname = "receiver" + val memberProfileImageId = -1L + val friendProfileImageId = 4L + + assertFailsWith { + FriendRelationship( + memberId, + fromMemberNickname, + memberProfileImageId, + friendMemberId, + friendNickname, + friendProfileImageId + ) + }.let { + assertEquals(FriendRelationship.ERROR_MEMBER_PROFILE_IMAGE_ID_MUST_BE_POSITIVE, it.message) + } + } + + @Test + fun `create - failure - throws exception when friendProfileImageId is zero`() { + val memberId = 1L + val friendMemberId = 2L + val fromMemberNickname = "sender" + val friendNickname = "receiver" + val memberProfileImageId = 3L + val friendProfileImageId = 0L + + assertFailsWith { + FriendRelationship( + memberId, + fromMemberNickname, + memberProfileImageId, + friendMemberId, + friendNickname, + friendProfileImageId + ) + }.let { + assertEquals(FriendRelationship.ERROR_FRIEND_PROFILE_IMAGE_ID_MUST_BE_POSITIVE, it.message) + } + } + + @Test + fun `create - failure - throws exception when friendProfileImageId is negative`() { + val memberId = 1L + val friendMemberId = 2L + val fromMemberNickname = "sender" + val friendNickname = "receiver" + val memberProfileImageId = 3L + val friendProfileImageId = -1L + + assertFailsWith { + FriendRelationship( + memberId, + fromMemberNickname, + memberProfileImageId, + friendMemberId, + friendNickname, + friendProfileImageId + ) + }.let { + assertEquals(FriendRelationship.ERROR_FRIEND_PROFILE_IMAGE_ID_MUST_BE_POSITIVE, it.message) + } + } + @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, fromMemberNickname, toMemberId, toMemberNickname) + val memberProfileImageId = 3L + val friendProfileImageId = 4L + val command = FriendRequestCommand( + fromMemberId, + fromMemberNickname, + memberProfileImageId, + toMemberId, + toMemberNickname, + friendProfileImageId + ) val relationship = FriendRelationship.of(command) 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 64165933..29258303 100644 --- a/src/test/kotlin/com/albert/realmoneyrealtaste/domain/member/MemberTest.kt +++ b/src/test/kotlin/com/albert/realmoneyrealtaste/domain/member/MemberTest.kt @@ -1,5 +1,10 @@ package com.albert.realmoneyrealtaste.domain.member +import com.albert.realmoneyrealtaste.domain.member.event.MemberActivatedDomainEvent +import com.albert.realmoneyrealtaste.domain.member.event.MemberDeactivatedDomainEvent +import com.albert.realmoneyrealtaste.domain.member.event.MemberProfileUpdatedDomainEvent +import com.albert.realmoneyrealtaste.domain.member.event.MemberRegisteredDomainEvent +import com.albert.realmoneyrealtaste.domain.member.event.PasswordChangedDomainEvent import com.albert.realmoneyrealtaste.domain.member.value.Email import com.albert.realmoneyrealtaste.domain.member.value.Introduction import com.albert.realmoneyrealtaste.domain.member.value.Nickname @@ -10,6 +15,7 @@ import com.albert.realmoneyrealtaste.domain.member.value.Role import com.albert.realmoneyrealtaste.domain.member.value.Roles import com.albert.realmoneyrealtaste.domain.member.value.TrustLevel import com.albert.realmoneyrealtaste.util.MemberFixture +import com.albert.realmoneyrealtaste.util.setId import java.time.LocalDateTime import kotlin.test.Test import kotlin.test.assertEquals @@ -514,7 +520,7 @@ class MemberTest { email = email, nickname = nickname, password = PasswordHash.of(password, MemberFixture.TEST_ENCODER) - ) + ).also { it.setId() } } private class TestMember : Member( @@ -594,7 +600,7 @@ class MemberTest { fun `imageId - success - returns 1L when detail imageId is null`() { val member = createMember() - assertEquals(1L, member.imageId) + assertEquals(1L, member.profileImageId) } @Test @@ -603,7 +609,7 @@ class MemberTest { val testImageId = 123L member.detail.updateInfo(null, null, null, testImageId) - assertEquals(testImageId, member.imageId) + assertEquals(testImageId, member.profileImageId) } @Test @@ -685,7 +691,7 @@ class MemberTest { member.updateInfo(address = newAddress, imageId = newImageId) assertEquals(newAddress, member.address) - assertEquals(newImageId, member.imageId) + assertEquals(newImageId, member.profileImageId) assertTrue(beforeUpdateAt < member.updatedAt) } @@ -705,4 +711,267 @@ class MemberTest { assertEquals(updatedPostCount, member.postCount) } + + @Test + fun `drainDomainEvents - success - returns MemberRegisteredDomainEvent when member is registered`() { + val member = createMember() + val events = member.drainDomainEvents() + + assertEquals(1, events.size) + assertTrue(events[0] is MemberRegisteredDomainEvent) + val event = events[0] as MemberRegisteredDomainEvent + assertEquals(member.id, event.memberId) + assertEquals(member.email.address, event.email) + assertEquals(member.nickname.value, event.nickname) + } + + @Test + fun `drainDomainEvents - success - returns MemberActivatedDomainEvent when member is activated`() { + val member = createMember() + member.activate() + val events = member.drainDomainEvents() + + assertEquals(2, events.size) + assertTrue(events[0] is MemberRegisteredDomainEvent) + assertTrue(events[1] is MemberActivatedDomainEvent) + val activatedEvent = events[1] as MemberActivatedDomainEvent + assertEquals(member.id, activatedEvent.memberId) + assertEquals(member.email.address, activatedEvent.email) + assertEquals(member.nickname.value, activatedEvent.nickname) + } + + @Test + fun `drainDomainEvents - success - returns MemberDeactivatedDomainEvent when member is deactivated`() { + val member = createMember() + member.activate() + member.drainDomainEvents() // Clear previous events + member.deactivate() + val events = member.drainDomainEvents() + + assertEquals(1, events.size) + assertTrue(events[0] is MemberDeactivatedDomainEvent) + val event = events[0] as MemberDeactivatedDomainEvent + assertEquals(member.id, event.memberId) + } + + @Test + fun `drainDomainEvents - success - returns PasswordChangedDomainEvent when password is changed`() { + val member = createMember() + member.drainDomainEvents() // Clear registration event + val newPassword = PasswordHash.of(MemberFixture.NEW_RAW_PASSWORD, MemberFixture.TEST_ENCODER) + member.changePassword(newPassword) + val events = member.drainDomainEvents() + + assertEquals(1, events.size) + assertTrue(events[0] is PasswordChangedDomainEvent) + val event = events[0] as PasswordChangedDomainEvent + assertEquals(member.id, event.memberId) + assertEquals(member.email.address, event.email) + } + + @Test + fun `drainDomainEvents - success - returns PasswordChangedDomainEvent when password is changed with current password`() { + val member = createMember() + member.activate() + member.drainDomainEvents() // Clear previous events + member.changePassword( + MemberFixture.DEFAULT_RAW_PASSWORD, + MemberFixture.NEW_RAW_PASSWORD, + MemberFixture.TEST_ENCODER + ) + val events = member.drainDomainEvents() + + assertEquals(1, events.size) + assertTrue(events[0] is PasswordChangedDomainEvent) + val event = events[0] as PasswordChangedDomainEvent + assertEquals(member.id, event.memberId) + assertEquals(member.email.address, event.email) + } + + @Test + fun `drainDomainEvents - success - returns MemberProfileUpdatedDomainEvent when profile is updated`() { + val member = createMember() + member.activate() + member.drainDomainEvents() // Clear previous events + val newNickname = Nickname("newNickname") + val newProfileAddress = ProfileAddress("newAddress") + val newIntroduction = Introduction("newIntroduction") + member.updateInfo(newNickname, newProfileAddress, newIntroduction) + val events = member.drainDomainEvents() + + assertEquals(1, events.size) + assertTrue(events[0] is MemberProfileUpdatedDomainEvent) + val event = events[0] as MemberProfileUpdatedDomainEvent + assertEquals(member.id, event.memberId) + assertEquals(member.email.address, event.email) + assertEquals(listOf("nickname", "profileAddress", "introduction"), event.updatedFields) + assertEquals(newNickname.value, event.nickname) + assertNull(event.imageId) + } + + @Test + fun `drainDomainEvents - success - returns MemberProfileUpdatedDomainEvent with imageId when image is updated`() { + val member = createMember() + member.activate() + member.drainDomainEvents() // Clear previous events + val newImageId = 123L + member.updateInfo(imageId = newImageId) + val events = member.drainDomainEvents() + + assertEquals(1, events.size) + assertTrue(events[0] is MemberProfileUpdatedDomainEvent) + val event = events[0] as MemberProfileUpdatedDomainEvent + assertEquals(member.id, event.memberId) + assertEquals(member.email.address, event.email) + assertEquals(listOf("imageId"), event.updatedFields) + assertNull(event.nickname) + assertEquals(newImageId, event.imageId) + } + + @Test + fun `drainDomainEvents - success - clears events after draining`() { + val member = createMember() + member.activate() + member.drainDomainEvents() // First drain + val eventsAfterFirstDrain = member.drainDomainEvents() // Second drain + + assertEquals(0, eventsAfterFirstDrain.size) + } + + @Test + fun `drainDomainEvents - success - returns empty list when no events exist`() { + val member = createMember() + member.drainDomainEvents() // Clear all events + val events = member.drainDomainEvents() + + assertEquals(0, events.size) + } + + @Test + fun `registerManager - success - publishes MemberRegisteredDomainEvent`() { + val email = MemberFixture.DEFAULT_EMAIL + val nickname = MemberFixture.DEFAULT_NICKNAME + val password = PasswordHash.of(MemberFixture.DEFAULT_RAW_PASSWORD, MemberFixture.TEST_ENCODER) + + val member = Member.registerManager(email, nickname, password).also { it.setId() } + val events = member.drainDomainEvents() + + assertEquals(1, events.size) + assertTrue(events[0] is MemberRegisteredDomainEvent) + val event = events[0] as MemberRegisteredDomainEvent + assertEquals(member.id, event.memberId) + assertEquals(email.address, event.email) + assertEquals(nickname.value, event.nickname) + } + + @Test + fun `registerAdmin - success - publishes MemberRegisteredDomainEvent`() { + val email = MemberFixture.DEFAULT_EMAIL + val nickname = MemberFixture.DEFAULT_NICKNAME + val password = PasswordHash.of(MemberFixture.DEFAULT_RAW_PASSWORD, MemberFixture.TEST_ENCODER) + + val member = Member.registerAdmin(email, nickname, password).also { it.setId() } + val events = member.drainDomainEvents() + + assertEquals(1, events.size) + assertTrue(events[0] is MemberRegisteredDomainEvent) + val event = events[0] as MemberRegisteredDomainEvent + assertEquals(member.id, event.memberId) + assertEquals(email.address, event.email) + assertEquals(nickname.value, event.nickname) + } + + @Test + fun `updateInfo - success - does not publish event when no actual changes`() { + val member = createMember() + member.activate() + member.drainDomainEvents() // Clear previous events + + member.updateInfo(nickname = member.nickname) // Same nickname + val events = member.drainDomainEvents() + + assertEquals(0, events.size) + } + + @Test + fun `updateInfo - success - does not publish event when updatedFields is empty`() { + val member = createMember() + member.activate() + member.drainDomainEvents() // Clear previous events + + // Update with same values - should not generate event + member.updateInfo( + nickname = member.nickname, + profileAddress = member.detail.profileAddress, + introduction = member.detail.introduction, + address = member.detail.address, + imageId = member.detail.imageId + ) + val events = member.drainDomainEvents() + + assertEquals(0, events.size) + assertEquals(member.updatedAt, member.updatedAt) // updatedAt should not change + } + + @Test + fun `drainDomainEvents - success - handles MemberDeactivatedDomainEvent correctly`() { + val member = createMember() + member.activate() + member.drainDomainEvents() // Clear registration event + + // Deactivate member + member.deactivate() + val events = member.drainDomainEvents() + + assertEquals(1, events.size) + assertTrue(events[0] is MemberDeactivatedDomainEvent) + val event = events[0] as MemberDeactivatedDomainEvent + assertEquals(member.id, event.memberId) + assertEquals(MemberStatus.DEACTIVATED, member.status) + } + + @Test + fun `drainDomainEvents - success - handles multiple event types correctly`() { + val member = createMember() + member.activate() + member.drainDomainEvents() // Clear registration and activation events + + // Trigger multiple events + member.updateInfo(nickname = Nickname("newNick")) + member.changePassword(PasswordHash.of(MemberFixture.NEW_RAW_PASSWORD, MemberFixture.TEST_ENCODER)) + member.grantRole(Role.MANAGER) + + val events = member.drainDomainEvents() + + assertEquals(2, events.size) + assertTrue(events[0] is MemberProfileUpdatedDomainEvent) + assertTrue(events[1] is PasswordChangedDomainEvent) + + // Verify event data + val profileEvent = events[0] as MemberProfileUpdatedDomainEvent + assertEquals(listOf("nickname"), profileEvent.updatedFields) + assertEquals("newNick", profileEvent.nickname) + + val passwordEvent = events[1] as PasswordChangedDomainEvent + assertEquals(member.id, passwordEvent.memberId) + assertEquals(member.email.address, passwordEvent.email) + } + + @Test + fun `drainDomainEvents - success - maintains event order`() { + val member = createMember() + val originalEvents = mutableListOf() + + // Collect events in order + originalEvents.addAll(member.drainDomainEvents()) // Registration event + member.activate() + originalEvents.addAll(member.drainDomainEvents()) // Activation event + member.updateInfo(nickname = Nickname("changed")) + originalEvents.addAll(member.drainDomainEvents()) // Profile update event + + // Verify order + assertTrue(originalEvents[0] is MemberRegisteredDomainEvent) + assertTrue(originalEvents[1] is MemberActivatedDomainEvent) + assertTrue(originalEvents[2] is MemberProfileUpdatedDomainEvent) + } } diff --git a/src/test/kotlin/com/albert/realmoneyrealtaste/domain/member/event/MemberActivatedDomainEventTest.kt b/src/test/kotlin/com/albert/realmoneyrealtaste/domain/member/event/MemberActivatedDomainEventTest.kt new file mode 100644 index 00000000..9f67778c --- /dev/null +++ b/src/test/kotlin/com/albert/realmoneyrealtaste/domain/member/event/MemberActivatedDomainEventTest.kt @@ -0,0 +1,171 @@ +package com.albert.realmoneyrealtaste.domain.member.event + +import org.junit.jupiter.api.assertAll +import java.time.LocalDateTime +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotEquals +import kotlin.test.assertTrue + +class MemberActivatedDomainEventTest { + + @Test + fun `create - success - creates event with all properties`() { + val memberId = 1L + val email = "test@example.com" + val nickname = "testUser" + val occurredAt = LocalDateTime.of(2023, 1, 1, 12, 0, 0) + + val event = MemberActivatedDomainEvent( + memberId = memberId, + email = email, + nickname = nickname, + occurredAt = occurredAt + ) + + assertAll( + { assertEquals(memberId, event.memberId) }, + { assertEquals(email, event.email) }, + { assertEquals(nickname, event.nickname) }, + { assertEquals(occurredAt, event.occurredAt) } + ) + } + + @Test + fun `create - success - uses default occurredAt when not provided`() { + val before = LocalDateTime.now() + + val event = MemberActivatedDomainEvent( + memberId = 1L, + email = "test@example.com", + nickname = "testUser" + ) + + val after = LocalDateTime.now() + + assertTrue(event.occurredAt >= before) + assertTrue(event.occurredAt <= after) + } + + @Test + fun `withMemberId - success - returns new event with updated memberId`() { + val originalMemberId = 1L + val newMemberId = 999L + val email = "test@example.com" + val nickname = "testUser" + val occurredAt = LocalDateTime.of(2023, 1, 1, 12, 0, 0) + + val originalEvent = MemberActivatedDomainEvent( + memberId = originalMemberId, + email = email, + nickname = nickname, + occurredAt = occurredAt + ) + + val updatedEvent = originalEvent.withMemberId(newMemberId) + + assertAll( + { assertEquals(newMemberId, updatedEvent.memberId) }, + { assertEquals(email, updatedEvent.email) }, + { assertEquals(nickname, updatedEvent.nickname) }, + { assertEquals(occurredAt, updatedEvent.occurredAt) }, + { assertNotEquals(originalEvent, updatedEvent) } + ) + } + + @Test + fun `withMemberId - success - preserves immutability of original event`() { + val originalEvent = MemberActivatedDomainEvent( + memberId = 1L, + email = "test@example.com", + nickname = "testUser" + ) + + originalEvent.withMemberId(999L) + + // 원본 이벤트는 변경되지 않아야 함 + assertEquals(1L, originalEvent.memberId) + } + + @Test + fun `create - success - handles edge case values`() { + val event = MemberActivatedDomainEvent( + memberId = Long.MAX_VALUE, + email = "a@b.c", + nickname = "a", + occurredAt = LocalDateTime.MAX + ) + + assertAll( + { assertEquals(Long.MAX_VALUE, event.memberId) }, + { assertEquals("a@b.c", event.email) }, + { assertEquals("a", event.nickname) }, + { assertEquals(LocalDateTime.MAX, event.occurredAt) } + ) + } + + @Test + fun `equals and hashCode - success - works correctly`() { + val event1 = MemberActivatedDomainEvent( + memberId = 1L, + email = "test@example.com", + nickname = "testUser", + occurredAt = LocalDateTime.of(2023, 1, 1, 12, 0, 0) + ) + + val event2 = MemberActivatedDomainEvent( + memberId = 1L, + email = "test@example.com", + nickname = "testUser", + occurredAt = LocalDateTime.of(2023, 1, 1, 12, 0, 0) + ) + + val event3 = MemberActivatedDomainEvent( + memberId = 2L, + email = "test@example.com", + nickname = "testUser", + occurredAt = LocalDateTime.of(2023, 1, 1, 12, 0, 0) + ) + + assertAll( + { assertEquals(event1, event2) }, + { assertEquals(event1.hashCode(), event2.hashCode()) }, + { assertNotEquals(event1, event3) }, + { assertNotEquals(event1.hashCode(), event3.hashCode()) } + ) + } + + @Test + fun `toString - success - contains all properties`() { + val event = MemberActivatedDomainEvent( + memberId = 1L, + email = "test@example.com", + nickname = "testUser", + occurredAt = LocalDateTime.of(2023, 1, 1, 12, 0, 0) + ) + + val toString = event.toString() + + assertAll( + { assertTrue(toString.contains("1")) }, + { assertTrue(toString.contains("test@example.com")) }, + { assertTrue(toString.contains("testUser")) }, + { assertTrue(toString.contains("2023-01-01T12:00")) } + ) + } + + @Test + fun `create - success - handles Korean characters`() { + val event = MemberActivatedDomainEvent( + memberId = 1L, + email = "한글@example.com", + nickname = "홍길동" + ) + + assertAll( + { assertEquals(1L, event.memberId) }, + { assertEquals("한글@example.com", event.email) }, + { assertEquals("홍길동", event.nickname) } + ) + } +} diff --git a/src/test/kotlin/com/albert/realmoneyrealtaste/domain/member/event/MemberDeactivatedDomainEventTest.kt b/src/test/kotlin/com/albert/realmoneyrealtaste/domain/member/event/MemberDeactivatedDomainEventTest.kt new file mode 100644 index 00000000..b9d3249c --- /dev/null +++ b/src/test/kotlin/com/albert/realmoneyrealtaste/domain/member/event/MemberDeactivatedDomainEventTest.kt @@ -0,0 +1,96 @@ +package com.albert.realmoneyrealtaste.domain.member.event + +import org.junit.jupiter.api.assertAll +import java.time.LocalDateTime +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotEquals +import kotlin.test.assertTrue + +class MemberDeactivatedDomainEventTest { + + @Test + fun `create - success - creates event with memberId`() { + val memberId = 1L + + val event = MemberDeactivatedDomainEvent( + memberId = memberId, + occurredAt = LocalDateTime.now(), + ) + + assertEquals(memberId, event.memberId) + assertTrue(event.occurredAt.isBefore(LocalDateTime.now())) + } + + @Test + fun `withMemberId - success - returns new event with updated memberId`() { + val originalMemberId = 1L + val newMemberId = 999L + + val originalEvent = MemberDeactivatedDomainEvent( + memberId = originalMemberId, + occurredAt = LocalDateTime.now(), + ) + + val updatedEvent = originalEvent.withMemberId(newMemberId) + + assertAll( + { assertEquals(newMemberId, updatedEvent.memberId) }, + { assertNotEquals(originalEvent, updatedEvent) } + ) + } + + @Test + fun `withMemberId - success - preserves immutability of original event`() { + val originalEvent = MemberDeactivatedDomainEvent( + memberId = 1L, + occurredAt = LocalDateTime.now(), + ) + + originalEvent.withMemberId(999L) + + // 원본 이벤트는 변경되지 않아야 함 + assertEquals(1L, originalEvent.memberId) + } + + @Test + fun `create - success - handles edge case values`() { + val event = MemberDeactivatedDomainEvent( + memberId = Long.MAX_VALUE, + occurredAt = LocalDateTime.now(), + ) + + assertEquals(Long.MAX_VALUE, event.memberId) + } + + @Test + fun `equals and hashCode - success - works correctly`() { + val occurredAt = LocalDateTime.now() + val event1 = MemberDeactivatedDomainEvent(memberId = 1L, occurredAt) + val event2 = MemberDeactivatedDomainEvent(memberId = 1L, occurredAt) + val event3 = MemberDeactivatedDomainEvent(memberId = 2L, occurredAt) + + assertAll( + { assertEquals(event1, event2) }, + { assertEquals(event1.hashCode(), event2.hashCode()) }, + { assertNotEquals(event1, event3) }, + { assertNotEquals(event1.hashCode(), event3.hashCode()) } + ) + } + + @Test + fun `toString - success - contains memberId`() { + val event = MemberDeactivatedDomainEvent(memberId = 123L, occurredAt = LocalDateTime.now()) + + val toString = event.toString() + + assertTrue(toString.contains("123")) + } + + @Test + fun `create - success - handles minimum memberId`() { + val event = MemberDeactivatedDomainEvent(memberId = 1L, occurredAt = LocalDateTime.now()) + + assertEquals(1L, event.memberId) + } +} diff --git a/src/test/kotlin/com/albert/realmoneyrealtaste/domain/member/event/MemberProfileUpdatedDomainEventTest.kt b/src/test/kotlin/com/albert/realmoneyrealtaste/domain/member/event/MemberProfileUpdatedDomainEventTest.kt new file mode 100644 index 00000000..0eaedbd8 --- /dev/null +++ b/src/test/kotlin/com/albert/realmoneyrealtaste/domain/member/event/MemberProfileUpdatedDomainEventTest.kt @@ -0,0 +1,259 @@ +package com.albert.realmoneyrealtaste.domain.member.event + +import org.junit.jupiter.api.assertAll +import java.time.LocalDateTime +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotEquals +import kotlin.test.assertNull +import kotlin.test.assertTrue + +class MemberProfileUpdatedDomainEventTest { + + @Test + fun `create - success - creates event with all properties`() { + val memberId = 1L + val email = "test@example.com" + val updatedFields = listOf("nickname", "imageId") + val nickname = "newNickname" + val imageId = 123L + val occurredAt = LocalDateTime.of(2023, 1, 1, 12, 0, 0) + + val event = MemberProfileUpdatedDomainEvent( + memberId = memberId, + email = email, + updatedFields = updatedFields, + nickname = nickname, + imageId = imageId, + occurredAt = occurredAt + ) + + assertAll( + { assertEquals(memberId, event.memberId) }, + { assertEquals(email, event.email) }, + { assertEquals(updatedFields, event.updatedFields) }, + { assertEquals(nickname, event.nickname) }, + { assertEquals(imageId, event.imageId) }, + { assertEquals(occurredAt, event.occurredAt) } + ) + } + + @Test + fun `create - success - uses default occurredAt when not provided`() { + val before = LocalDateTime.now() + + val event = MemberProfileUpdatedDomainEvent( + memberId = 1L, + email = "test@example.com", + updatedFields = listOf("nickname") + ) + + val after = LocalDateTime.now() + + assertTrue(event.occurredAt >= before) + assertTrue(event.occurredAt <= after) + } + + @Test + fun `create - success - with only nickname updated`() { + val event = MemberProfileUpdatedDomainEvent( + memberId = 1L, + email = "test@example.com", + updatedFields = listOf("nickname"), + nickname = "newNickname" + ) + + assertAll( + { assertEquals(1L, event.memberId) }, + { assertEquals("test@example.com", event.email) }, + { assertEquals(listOf("nickname"), event.updatedFields) }, + { assertEquals("newNickname", event.nickname) }, + { assertNull(event.imageId) } + ) + } + + @Test + fun `create - success - with only imageId updated`() { + val event = MemberProfileUpdatedDomainEvent( + memberId = 1L, + email = "test@example.com", + updatedFields = listOf("imageId"), + imageId = 123L + ) + + assertAll( + { assertEquals(1L, event.memberId) }, + { assertEquals("test@example.com", event.email) }, + { assertEquals(listOf("imageId"), event.updatedFields) }, + { assertNull(event.nickname) }, + { assertEquals(123L, event.imageId) } + ) + } + + @Test + fun `create - success - with both nickname and imageId updated`() { + val event = MemberProfileUpdatedDomainEvent( + memberId = 1L, + email = "test@example.com", + updatedFields = listOf("nickname", "imageId"), + nickname = "newNickname", + imageId = 123L + ) + + assertAll( + { assertEquals(1L, event.memberId) }, + { assertEquals("test@example.com", event.email) }, + { assertEquals(listOf("nickname", "imageId"), event.updatedFields) }, + { assertEquals("newNickname", event.nickname) }, + { assertEquals(123L, event.imageId) } + ) + } + + @Test + fun `withMemberId - success - returns new event with updated memberId`() { + val originalMemberId = 1L + val newMemberId = 999L + val email = "test@example.com" + val updatedFields = listOf("nickname") + val nickname = "newNickname" + + val originalEvent = MemberProfileUpdatedDomainEvent( + memberId = originalMemberId, + email = email, + updatedFields = updatedFields, + nickname = nickname + ) + + val updatedEvent = originalEvent.withMemberId(newMemberId) + + assertAll( + { assertEquals(newMemberId, updatedEvent.memberId) }, + { assertEquals(email, updatedEvent.email) }, + { assertEquals(updatedFields, updatedEvent.updatedFields) }, + { assertEquals(nickname, updatedEvent.nickname) }, + { assertNull(updatedEvent.imageId) }, + { assertNotEquals(originalEvent, updatedEvent) } + ) + } + + @Test + fun `withMemberId - success - preserves immutability of original event`() { + val originalEvent = MemberProfileUpdatedDomainEvent( + memberId = 1L, + email = "test@example.com", + updatedFields = listOf("nickname"), + nickname = "newNickname" + ) + + originalEvent.withMemberId(999L) + + // 원본 이벤트는 변경되지 않아야 함 + assertEquals(1L, originalEvent.memberId) + } + + @Test + fun `create - success - handles edge case values`() { + val event = MemberProfileUpdatedDomainEvent( + memberId = Long.MAX_VALUE, + email = "a@b.c", + updatedFields = listOf("a"), + nickname = "", + imageId = 0L, + occurredAt = LocalDateTime.MAX + ) + + assertAll( + { assertEquals(Long.MAX_VALUE, event.memberId) }, + { assertEquals("a@b.c", event.email) }, + { assertEquals(listOf("a"), event.updatedFields) }, + { assertEquals("", event.nickname) }, + { assertEquals(0L, event.imageId) }, + { assertEquals(LocalDateTime.MAX, event.occurredAt) } + ) + } + + @Test + fun `equals and hashCode - success - works correctly`() { + val event1 = MemberProfileUpdatedDomainEvent( + memberId = 1L, + email = "test@example.com", + updatedFields = listOf("nickname"), + nickname = "newNickname", + occurredAt = LocalDateTime.of(2023, 1, 1, 12, 0, 0) + ) + + val event2 = MemberProfileUpdatedDomainEvent( + memberId = 1L, + email = "test@example.com", + updatedFields = listOf("nickname"), + nickname = "newNickname", + occurredAt = LocalDateTime.of(2023, 1, 1, 12, 0, 0) + ) + + val event3 = MemberProfileUpdatedDomainEvent( + memberId = 2L, + email = "test@example.com", + updatedFields = listOf("nickname"), + nickname = "newNickname", + occurredAt = LocalDateTime.of(2023, 1, 1, 12, 0, 0) + ) + + assertAll( + { assertEquals(event1, event2) }, + { assertEquals(event1.hashCode(), event2.hashCode()) }, + { assertNotEquals(event1, event3) }, + { assertNotEquals(event1.hashCode(), event3.hashCode()) } + ) + } + + @Test + fun `toString - success - contains all properties`() { + val event = MemberProfileUpdatedDomainEvent( + memberId = 1L, + email = "test@example.com", + updatedFields = listOf("nickname", "imageId"), + nickname = "newNickname", + imageId = 123L, + occurredAt = LocalDateTime.of(2023, 1, 1, 12, 0, 0) + ) + + val toString = event.toString() + + assertAll( + { assertTrue(toString.contains("1")) }, + { assertTrue(toString.contains("test@example.com")) }, + { assertTrue(toString.contains("[nickname, imageId]")) }, + { assertTrue(toString.contains("newNickname")) }, + { assertTrue(toString.contains("123")) }, + { assertTrue(toString.contains("2023-01-01T12:00")) } + ) + } + + @Test + fun `create - success - handles Korean characters`() { + val event = MemberProfileUpdatedDomainEvent( + memberId = 1L, + email = "한글@example.com", + updatedFields = listOf("닉네임"), + nickname = "홍길동" + ) + + assertAll( + { assertEquals(1L, event.memberId) }, + { assertEquals("한글@example.com", event.email) }, + { assertEquals(listOf("닉네임"), event.updatedFields) }, + { assertEquals("홍길동", event.nickname) } + ) + } + + @Test + fun `create - success - with empty updatedFields list`() { + val event = MemberProfileUpdatedDomainEvent( + memberId = 1L, + email = "test@example.com", + updatedFields = emptyList() + ) + + assertTrue(event.updatedFields.isEmpty()) + } +} diff --git a/src/test/kotlin/com/albert/realmoneyrealtaste/domain/member/event/MemberRegisteredDomainEventTest.kt b/src/test/kotlin/com/albert/realmoneyrealtaste/domain/member/event/MemberRegisteredDomainEventTest.kt new file mode 100644 index 00000000..3638f8bc --- /dev/null +++ b/src/test/kotlin/com/albert/realmoneyrealtaste/domain/member/event/MemberRegisteredDomainEventTest.kt @@ -0,0 +1,156 @@ +package com.albert.realmoneyrealtaste.domain.member.event + +import org.junit.jupiter.api.assertAll +import java.time.LocalDateTime +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotEquals +import kotlin.test.assertTrue + +class MemberRegisteredDomainEventTest { + + @Test + fun `create - success - creates event with all properties`() { + val memberId = 1L + val email = "test@example.com" + val nickname = "testUser" + val occurredAt = LocalDateTime.of(2023, 1, 1, 12, 0, 0) + + val event = MemberRegisteredDomainEvent( + memberId = memberId, + email = email, + nickname = nickname, + occurredAt = occurredAt + ) + + assertAll( + { assertEquals(memberId, event.memberId) }, + { assertEquals(email, event.email) }, + { assertEquals(nickname, event.nickname) }, + { assertEquals(occurredAt, event.occurredAt) } + ) + } + + @Test + fun `create - success - uses default occurredAt when not provided`() { + val before = LocalDateTime.now() + + val event = MemberRegisteredDomainEvent( + memberId = 1L, + email = "test@example.com", + nickname = "testUser" + ) + + val after = LocalDateTime.now() + + assertTrue(event.occurredAt >= before) + assertTrue(event.occurredAt <= after) + } + + @Test + fun `withMemberId - success - returns new event with updated memberId`() { + val originalMemberId = 1L + val newMemberId = 999L + val email = "test@example.com" + val nickname = "testUser" + val occurredAt = LocalDateTime.of(2023, 1, 1, 12, 0, 0) + + val originalEvent = MemberRegisteredDomainEvent( + memberId = originalMemberId, + email = email, + nickname = nickname, + occurredAt = occurredAt + ) + + val updatedEvent = originalEvent.withMemberId(newMemberId) + + assertAll( + { assertEquals(newMemberId, updatedEvent.memberId) }, + { assertEquals(email, updatedEvent.email) }, + { assertEquals(nickname, updatedEvent.nickname) }, + { assertEquals(occurredAt, updatedEvent.occurredAt) }, + { assertNotEquals(originalEvent, updatedEvent) } + ) + } + + @Test + fun `withMemberId - success - preserves immutability of original event`() { + val originalEvent = MemberRegisteredDomainEvent( + memberId = 1L, + email = "test@example.com", + nickname = "testUser" + ) + + originalEvent.withMemberId(999L) + + // 원본 이벤트는 변경되지 않아야 함 + assertEquals(1L, originalEvent.memberId) + } + + @Test + fun `create - success - handles edge case values`() { + val event = MemberRegisteredDomainEvent( + memberId = Long.MAX_VALUE, + email = "a@b.c", + nickname = "a", + occurredAt = LocalDateTime.MAX + ) + + assertAll( + { assertEquals(Long.MAX_VALUE, event.memberId) }, + { assertEquals("a@b.c", event.email) }, + { assertEquals("a", event.nickname) }, + { assertEquals(LocalDateTime.MAX, event.occurredAt) } + ) + } + + @Test + fun `equals and hashCode - success - works correctly`() { + val event1 = MemberRegisteredDomainEvent( + memberId = 1L, + email = "test@example.com", + nickname = "testUser", + occurredAt = LocalDateTime.of(2023, 1, 1, 12, 0, 0) + ) + + val event2 = MemberRegisteredDomainEvent( + memberId = 1L, + email = "test@example.com", + nickname = "testUser", + occurredAt = LocalDateTime.of(2023, 1, 1, 12, 0, 0) + ) + + val event3 = MemberRegisteredDomainEvent( + memberId = 2L, + email = "test@example.com", + nickname = "testUser", + occurredAt = LocalDateTime.of(2023, 1, 1, 12, 0, 0) + ) + + assertAll( + { assertEquals(event1, event2) }, + { assertEquals(event1.hashCode(), event2.hashCode()) }, + { assertNotEquals(event1, event3) }, + { assertNotEquals(event1.hashCode(), event3.hashCode()) } + ) + } + + @Test + fun `toString - success - contains all properties`() { + val event = MemberRegisteredDomainEvent( + memberId = 1L, + email = "test@example.com", + nickname = "testUser", + occurredAt = LocalDateTime.of(2023, 1, 1, 12, 0, 0) + ) + + val toString = event.toString() + + assertAll( + { assertTrue(toString.contains("1")) }, + { assertTrue(toString.contains("test@example.com")) }, + { assertTrue(toString.contains("testUser")) }, + { assertTrue(toString.contains("2023-01-01T12:00")) } + ) + } +} diff --git a/src/test/kotlin/com/albert/realmoneyrealtaste/domain/member/event/PasswordChangedDomainEventTest.kt b/src/test/kotlin/com/albert/realmoneyrealtaste/domain/member/event/PasswordChangedDomainEventTest.kt new file mode 100644 index 00000000..d88b0c33 --- /dev/null +++ b/src/test/kotlin/com/albert/realmoneyrealtaste/domain/member/event/PasswordChangedDomainEventTest.kt @@ -0,0 +1,174 @@ +package com.albert.realmoneyrealtaste.domain.member.event + +import org.junit.jupiter.api.assertAll +import java.time.LocalDateTime +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotEquals +import kotlin.test.assertTrue + +class PasswordChangedDomainEventTest { + + @Test + fun `create - success - creates event with all properties`() { + val memberId = 1L + val email = "test@example.com" + val occurredAt = LocalDateTime.of(2023, 1, 1, 12, 0, 0) + + val event = PasswordChangedDomainEvent( + memberId = memberId, + email = email, + occurredAt = occurredAt + ) + + assertAll( + { assertEquals(memberId, event.memberId) }, + { assertEquals(email, event.email) }, + { assertEquals(occurredAt, event.occurredAt) } + ) + } + + @Test + fun `create - success - uses default occurredAt when not provided`() { + val before = LocalDateTime.now() + + val event = PasswordChangedDomainEvent( + memberId = 1L, + email = "test@example.com" + ) + + val after = LocalDateTime.now() + + assertTrue(event.occurredAt >= before) + assertTrue(event.occurredAt <= after) + } + + @Test + fun `withMemberId - success - returns new event with updated memberId`() { + val originalMemberId = 1L + val newMemberId = 999L + val email = "test@example.com" + val occurredAt = LocalDateTime.of(2023, 1, 1, 12, 0, 0) + + val originalEvent = PasswordChangedDomainEvent( + memberId = originalMemberId, + email = email, + occurredAt = occurredAt + ) + + val updatedEvent = originalEvent.withMemberId(newMemberId) + + assertAll( + { assertEquals(newMemberId, updatedEvent.memberId) }, + { assertEquals(email, updatedEvent.email) }, + { assertEquals(occurredAt, updatedEvent.occurredAt) }, + { assertNotEquals(originalEvent, updatedEvent) } + ) + } + + @Test + fun `withMemberId - success - preserves immutability of original event`() { + val originalEvent = PasswordChangedDomainEvent( + memberId = 1L, + email = "test@example.com" + ) + + originalEvent.withMemberId(999L) + + // 원본 이벤트는 변경되지 않아야 함 + assertEquals(1L, originalEvent.memberId) + } + + @Test + fun `create - success - handles edge case values`() { + val event = PasswordChangedDomainEvent( + memberId = Long.MAX_VALUE, + email = "a@b.c", + occurredAt = LocalDateTime.MAX + ) + + assertAll( + { assertEquals(Long.MAX_VALUE, event.memberId) }, + { assertEquals("a@b.c", event.email) }, + { assertEquals(LocalDateTime.MAX, event.occurredAt) } + ) + } + + @Test + fun `equals and hashCode - success - works correctly`() { + val event1 = PasswordChangedDomainEvent( + memberId = 1L, + email = "test@example.com", + occurredAt = LocalDateTime.of(2023, 1, 1, 12, 0, 0) + ) + + val event2 = PasswordChangedDomainEvent( + memberId = 1L, + email = "test@example.com", + occurredAt = LocalDateTime.of(2023, 1, 1, 12, 0, 0) + ) + + val event3 = PasswordChangedDomainEvent( + memberId = 2L, + email = "test@example.com", + occurredAt = LocalDateTime.of(2023, 1, 1, 12, 0, 0) + ) + + assertAll( + { assertEquals(event1, event2) }, + { assertEquals(event1.hashCode(), event2.hashCode()) }, + { assertNotEquals(event1, event3) }, + { assertNotEquals(event1.hashCode(), event3.hashCode()) } + ) + } + + @Test + fun `toString - success - contains all properties`() { + val event = PasswordChangedDomainEvent( + memberId = 1L, + email = "test@example.com", + occurredAt = LocalDateTime.of(2023, 1, 1, 12, 0, 0) + ) + + val toString = event.toString() + + assertAll( + { assertTrue(toString.contains("1")) }, + { assertTrue(toString.contains("test@example.com")) }, + { assertTrue(toString.contains("2023-01-01T12:00")) } + ) + } + + @Test + fun `create - success - handles Korean characters in email`() { + val event = PasswordChangedDomainEvent( + memberId = 1L, + email = "한글@example.com" + ) + + assertAll( + { assertEquals(1L, event.memberId) }, + { assertEquals("한글@example.com", event.email) } + ) + } + + @Test + fun `create - success - handles minimum memberId`() { + val event = PasswordChangedDomainEvent( + memberId = 1L, + email = "test@example.com" + ) + + assertEquals(1L, event.memberId) + } + + @Test + fun `create - success - handles special characters in email`() { + val event = PasswordChangedDomainEvent( + memberId = 1L, + email = "test.user+tag@example-domain.co.uk" + ) + + assertEquals("test.user+tag@example-domain.co.uk", event.email) + } +} diff --git a/src/test/kotlin/com/albert/realmoneyrealtaste/domain/post/PostTest.kt b/src/test/kotlin/com/albert/realmoneyrealtaste/domain/post/PostTest.kt index 4e843f92..3610866d 100644 --- a/src/test/kotlin/com/albert/realmoneyrealtaste/domain/post/PostTest.kt +++ b/src/test/kotlin/com/albert/realmoneyrealtaste/domain/post/PostTest.kt @@ -1,10 +1,13 @@ package com.albert.realmoneyrealtaste.domain.post +import com.albert.realmoneyrealtaste.domain.post.event.PostCreatedEvent +import com.albert.realmoneyrealtaste.domain.post.event.PostDeletedEvent import com.albert.realmoneyrealtaste.domain.post.value.Author import com.albert.realmoneyrealtaste.domain.post.value.PostContent import com.albert.realmoneyrealtaste.domain.post.value.PostImages import com.albert.realmoneyrealtaste.domain.post.value.Restaurant import com.albert.realmoneyrealtaste.util.PostFixture +import com.albert.realmoneyrealtaste.util.setId import java.time.LocalDateTime import kotlin.test.Test import kotlin.test.assertEquals @@ -40,7 +43,7 @@ class PostTest { @Test fun `create - success - creates post with images`() { - val post = PostFixture.createPost() + val post = PostFixture.createPostWithId() assertTrue(post.images.isNotEmpty()) assertEquals(3, post.images.size()) @@ -48,7 +51,7 @@ class PostTest { @Test fun `update - success - updates content and images`() { - val post = PostFixture.createPost() + val post = PostFixture.createPostWithId() val newContent = PostContent("새로운 내용입니다!", 4) val newImages = PostImages(listOf(1)) val newRestaurant = Restaurant( @@ -71,7 +74,7 @@ class PostTest { @Test fun `update - failure - throws exception when post is deleted`() { - val post = PostFixture.createPost() + val post = PostFixture.createPostWithId() post.delete(PostFixture.DEFAULT_AUTHOR_MEMBER_ID) val newContent = PostContent("새로운 내용", 4) val newImages = PostImages.empty() @@ -85,7 +88,7 @@ class PostTest { @Test fun `update - failure - throws exception when member is not author`() { - val post = PostFixture.createPost() + val post = PostFixture.createPostWithId() val newContent = PostContent("새로운 내용", 4) val newImages = PostImages.empty() @@ -98,7 +101,7 @@ class PostTest { @Test fun `delete - success - changes status to deleted`() { - val post = PostFixture.createPost() + val post = PostFixture.createPostWithId() val beforeUpdateAt = post.updatedAt Thread.sleep(10) @@ -110,7 +113,7 @@ class PostTest { @Test fun `delete - failure - throws exception when already deleted`() { - val post = PostFixture.createPost() + val post = PostFixture.createPostWithId() post.delete(PostFixture.DEFAULT_AUTHOR_MEMBER_ID) assertFailsWith { @@ -122,7 +125,7 @@ class PostTest { @Test fun `delete - failure - throws exception when member is not author`() { - val post = PostFixture.createPost() + val post = PostFixture.createPostWithId() assertFailsWith { post.delete(999L) @@ -133,28 +136,28 @@ class PostTest { @Test fun `canEditBy - success - returns true for post author`() { - val post = PostFixture.createPost() + val post = PostFixture.createPostWithId() assertTrue(post.canEditBy(PostFixture.DEFAULT_AUTHOR_MEMBER_ID)) } @Test fun `canEditBy - success - returns false for different member`() { - val post = PostFixture.createPost() + val post = PostFixture.createPostWithId() assertFalse(post.canEditBy(999L)) } @Test fun `ensureCanEditBy - success - does not throw for post author`() { - val post = PostFixture.createPost() + val post = PostFixture.createPostWithId() post.ensureCanEditBy(PostFixture.DEFAULT_AUTHOR_MEMBER_ID) } @Test fun `ensureCanEditBy - failure - throws exception for different member`() { - val post = PostFixture.createPost() + val post = PostFixture.createPostWithId() assertFailsWith { post.ensureCanEditBy(999L) @@ -165,28 +168,28 @@ class PostTest { @Test fun `heartCount - success - starts at zero`() { - val post = PostFixture.createPost() + val post = PostFixture.createPostWithId() assertEquals(0, post.heartCount) } @Test fun `viewCount - success - starts at zero`() { - val post = PostFixture.createPost() + val post = PostFixture.createPostWithId() assertEquals(0, post.viewCount) } @Test fun `isDeleted - success - returns false for published post`() { - val post = PostFixture.createPost() + val post = PostFixture.createPostWithId() assertFalse(post.isDeleted()) } @Test fun `isDeleted - success - returns true for deleted post`() { - val post = PostFixture.createPost() + val post = PostFixture.createPostWithId() post.delete(PostFixture.DEFAULT_AUTHOR_MEMBER_ID) assertTrue(post.isDeleted()) @@ -194,28 +197,28 @@ class PostTest { @Test fun `isAuthor - success - returns true for post author`() { - val post = PostFixture.createPost() + val post = PostFixture.createPostWithId() assertTrue(post.isAuthor(PostFixture.DEFAULT_AUTHOR_MEMBER_ID)) } @Test fun `isAuthor - success - returns false for different member`() { - val post = PostFixture.createPost() + val post = PostFixture.createPostWithId() assertFalse(post.isAuthor(999L)) } @Test fun `ensurePublished - success - does not throw for published post`() { - val post = PostFixture.createPost() + val post = PostFixture.createPostWithId() post.ensurePublished() // Should not throw } @Test fun `ensurePublished - failure - throws exception for deleted post`() { - val post = PostFixture.createPost() + val post = PostFixture.createPostWithId() post.delete(PostFixture.DEFAULT_AUTHOR_MEMBER_ID) assertFailsWith { @@ -237,7 +240,7 @@ class PostTest { fun `setter - success - for covering frameworks`() { val post = TestPost() - val newAuthor = Author(999L, "새로운닉네임", "새로운소개") + val newAuthor = Author(999L, "새로운닉네임", "새로운소개", 1L) post.setAuthorForTest(newAuthor) assertEquals(999L, post.author.memberId) @@ -252,6 +255,91 @@ class PostTest { assertEquals(100, post.viewCount) } + @Test + fun `drainDomainEvents - success - returns PostCreatedEvent with actual ID when post is created`() { + val post = PostFixture.createPost() + post.setId(123L) + + val events = post.drainDomainEvents() + + assertEquals(1, events.size) + assertTrue(events[0] is PostCreatedEvent) + + val event = events[0] as PostCreatedEvent + assertEquals(123L, event.postId) + assertEquals(PostFixture.DEFAULT_AUTHOR_MEMBER_ID, event.authorMemberId) + assertEquals(PostFixture.DEFAULT_RESTAURANT_NAME, event.restaurantName) + } + + @Test + fun `drainDomainEvents - success - returns empty list when called twice`() { + val post = PostFixture.createPost() + post.setId(123L) + + // 첫 번째 호출 + val firstEvents = post.drainDomainEvents() + assertEquals(1, firstEvents.size) + + // 두 번째 호출은 빈 리스트 반환 + val secondEvents = post.drainDomainEvents() + assertEquals(0, secondEvents.size) + } + + @Test + fun `drainDomainEvents - success - returns PostDeletedEvent with actual ID when post is deleted`() { + val post = PostFixture.createPost() + post.setId(456L) + + // 게시글 삭제 + post.delete(PostFixture.DEFAULT_AUTHOR_MEMBER_ID) + + val events = post.drainDomainEvents() + + assertEquals(2, events.size) // 생성 + 삭제 이벤트 + assertTrue(events[1] is PostDeletedEvent) + + val deleteEvent = events[1] as PostDeletedEvent + assertEquals(456L, deleteEvent.postId) + assertEquals(PostFixture.DEFAULT_AUTHOR_MEMBER_ID, deleteEvent.authorMemberId) + } + + @Test + fun `drainDomainEvents - success - handles multiple events in correct order`() { + val post = PostFixture.createPost() + post.setId(789L) + + // 게시글 삭제 + post.delete(PostFixture.DEFAULT_AUTHOR_MEMBER_ID) + + val events = post.drainDomainEvents() + + assertEquals(2, events.size) + assertTrue(events[0] is PostCreatedEvent) + assertTrue(events[1] is PostDeletedEvent) + + // 모든 이벤트의 postId가 실제 ID로 설정되었는지 확인 + events.forEach { event -> + when (event) { + is PostCreatedEvent -> assertEquals(789L, event.postId) + is PostDeletedEvent -> assertEquals(789L, event.postId) + } + } + } + + @Test + fun `drainDomainEvents - success - all events implement PostDomainEvent interface`() { + val post = PostFixture.createPost() + post.setId(123L) + + post.delete(PostFixture.DEFAULT_AUTHOR_MEMBER_ID) + + val events = post.drainDomainEvents() + + events.forEach { event -> + assertEquals(123L, event.postId) + } + } + private class TestPost : Post( author = PostFixture.DEFAULT_AUTHOR, restaurant = PostFixture.DEFAULT_RESTAURANT, diff --git a/src/test/kotlin/com/albert/realmoneyrealtaste/domain/post/event/PostCreatedEventTest.kt b/src/test/kotlin/com/albert/realmoneyrealtaste/domain/post/event/PostCreatedEventTest.kt index d995db6a..5868c71e 100644 --- a/src/test/kotlin/com/albert/realmoneyrealtaste/domain/post/event/PostCreatedEventTest.kt +++ b/src/test/kotlin/com/albert/realmoneyrealtaste/domain/post/event/PostCreatedEventTest.kt @@ -1,7 +1,11 @@ package com.albert.realmoneyrealtaste.domain.post.event +import org.junit.jupiter.api.assertAll +import java.time.LocalDateTime import kotlin.test.Test import kotlin.test.assertEquals +import kotlin.test.assertNotEquals +import kotlin.test.assertTrue class PostCreatedEventTest { @@ -10,11 +14,158 @@ class PostCreatedEventTest { val event = PostCreatedEvent( postId = 1L, authorMemberId = 42L, - restaurantName = "Delicious Place" + restaurantName = "Delicious Place", + occurredAt = LocalDateTime.now(), ) assertEquals(1L, event.postId) assertEquals(42L, event.authorMemberId) assertEquals("Delicious Place", event.restaurantName) + assertTrue(event.occurredAt.isBefore(LocalDateTime.now())) + } + + @Test + fun `withPostId - success - returns new event with updated postId`() { + val originalPostId = 1L + val newPostId = 999L + val authorMemberId = 42L + val restaurantName = "Delicious Place" + + val originalEvent = PostCreatedEvent( + postId = originalPostId, + authorMemberId = authorMemberId, + restaurantName = restaurantName, + occurredAt = LocalDateTime.now(), + ) + + val updatedEvent = originalEvent.withPostId(newPostId) + + assertAll( + { assertEquals(newPostId, updatedEvent.postId) }, + { assertEquals(authorMemberId, updatedEvent.authorMemberId) }, + { assertEquals(restaurantName, updatedEvent.restaurantName) }, + { assertNotEquals(originalEvent, updatedEvent) } + ) + } + + @Test + fun `withPostId - success - preserves immutability of original event`() { + val originalEvent = PostCreatedEvent( + postId = 1L, + authorMemberId = 42L, + restaurantName = "Delicious Place", + occurredAt = LocalDateTime.now(), + ) + + originalEvent.withPostId(999L) + + // 원본 이벤트는 변경되지 않아야 함 + assertEquals(1L, originalEvent.postId) + } + + @Test + fun `construction - success - handles edge case values`() { + val event = PostCreatedEvent( + postId = Long.MAX_VALUE, + authorMemberId = Long.MAX_VALUE, + restaurantName = "", + occurredAt = LocalDateTime.now(), + ) + + assertAll( + { assertEquals(Long.MAX_VALUE, event.postId) }, + { assertEquals(Long.MAX_VALUE, event.authorMemberId) }, + { assertEquals("", event.restaurantName) } + ) + } + + @Test + fun `construction - success - handles Korean characters`() { + val event = PostCreatedEvent( + postId = 1L, + authorMemberId = 42L, + restaurantName = "맛있는 식당", + occurredAt = LocalDateTime.now(), + ) + + assertEquals("맛있는 식당", event.restaurantName) + } + + @Test + fun `equals and hashCode - success - works correctly`() { + val occurredAt = LocalDateTime.now() + val event1 = PostCreatedEvent( + postId = 1L, + authorMemberId = 42L, + restaurantName = "Delicious Place", + occurredAt = occurredAt + ) + + val event2 = PostCreatedEvent( + postId = 1L, + authorMemberId = 42L, + restaurantName = "Delicious Place", + occurredAt = occurredAt + ) + + val event3 = PostCreatedEvent( + postId = 2L, + authorMemberId = 42L, + restaurantName = "Delicious Place", + occurredAt = occurredAt + ) + + assertAll( + { assertEquals(event1, event2) }, + { assertEquals(event1.hashCode(), event2.hashCode()) }, + { assertNotEquals(event1, event3) }, + { assertNotEquals(event1.hashCode(), event3.hashCode()) } + ) + } + + @Test + fun `toString - success - contains all properties`() { + val event = PostCreatedEvent( + postId = 123L, + authorMemberId = 456L, + restaurantName = "Test Restaurant", + occurredAt = LocalDateTime.now(), + ) + + val toString = event.toString() + + assertAll( + { assertTrue(toString.contains("123")) }, + { assertTrue(toString.contains("456")) }, + { assertTrue(toString.contains("Test Restaurant")) } + ) + } + + @Test + fun `construction - success - handles special characters in restaurant name`() { + val event = PostCreatedEvent( + postId = 1L, + authorMemberId = 42L, + restaurantName = "Special & Unique's Place! @#$%", + occurredAt = LocalDateTime.now(), + ) + + assertEquals("Special & Unique's Place! @#$%", event.restaurantName) + } + + @Test + fun `construction - success - handles minimum postId`() { + val event = PostCreatedEvent( + postId = 1L, + authorMemberId = 1L, + restaurantName = "A", + occurredAt = LocalDateTime.now(), + ) + + assertAll( + { assertEquals(1L, event.postId) }, + { assertEquals(1L, event.authorMemberId) }, + { assertEquals("A", event.restaurantName) } + ) } } diff --git a/src/test/kotlin/com/albert/realmoneyrealtaste/domain/post/event/PostDeletedEventTest.kt b/src/test/kotlin/com/albert/realmoneyrealtaste/domain/post/event/PostDeletedEventTest.kt index 9826c6de..102630f0 100644 --- a/src/test/kotlin/com/albert/realmoneyrealtaste/domain/post/event/PostDeletedEventTest.kt +++ b/src/test/kotlin/com/albert/realmoneyrealtaste/domain/post/event/PostDeletedEventTest.kt @@ -1,6 +1,11 @@ package com.albert.realmoneyrealtaste.domain.post.event +import org.junit.jupiter.api.assertAll +import java.time.LocalDateTime import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotEquals +import kotlin.test.assertTrue class PostDeletedEventTest { @@ -9,9 +14,136 @@ class PostDeletedEventTest { val event = PostDeletedEvent( postId = 1L, authorMemberId = 42L, + occurredAt = LocalDateTime.now(), ) - assert(event.postId == 1L) - assert(event.authorMemberId == 42L) + assertEquals(1L, event.postId) + assertEquals(42L, event.authorMemberId) + assertTrue(event.occurredAt.isBefore(LocalDateTime.now())) + } + + @Test + fun `withPostId - success - returns new event with updated postId`() { + val originalPostId = 1L + val newPostId = 999L + val authorMemberId = 42L + + val originalEvent = PostDeletedEvent( + postId = originalPostId, + authorMemberId = authorMemberId, + occurredAt = LocalDateTime.now(), + ) + + val updatedEvent = originalEvent.withPostId(newPostId) + + assertAll( + { assertEquals(newPostId, updatedEvent.postId) }, + { assertEquals(authorMemberId, updatedEvent.authorMemberId) }, + { assertNotEquals(originalEvent, updatedEvent) } + ) + } + + @Test + fun `withPostId - success - preserves immutability of original event`() { + val originalEvent = PostDeletedEvent( + postId = 1L, + authorMemberId = 42L, + occurredAt = LocalDateTime.now(), + ) + + originalEvent.withPostId(999L) + + // 원본 이벤트는 변경되지 않아야 함 + assertEquals(1L, originalEvent.postId) + } + + @Test + fun `construction - success - handles edge case values`() { + val event = PostDeletedEvent( + postId = Long.MAX_VALUE, + authorMemberId = Long.MAX_VALUE, + occurredAt = LocalDateTime.now(), + ) + + assertAll( + { assertEquals(Long.MAX_VALUE, event.postId) }, + { assertEquals(Long.MAX_VALUE, event.authorMemberId) } + ) + } + + @Test + fun `construction - success - handles minimum values`() { + val event = PostDeletedEvent( + postId = 1L, + authorMemberId = 1L, + occurredAt = LocalDateTime.now(), + ) + + assertAll( + { assertEquals(1L, event.postId) }, + { assertEquals(1L, event.authorMemberId) } + ) + } + + @Test + fun `equals and hashCode - success - works correctly`() { + val occurredAt = LocalDateTime.now() + val event1 = PostDeletedEvent( + postId = 1L, + authorMemberId = 42L, + occurredAt, + ) + + val event2 = PostDeletedEvent( + postId = 1L, + authorMemberId = 42L, + occurredAt, + ) + + val event3 = PostDeletedEvent( + postId = 2L, + authorMemberId = 42L, + occurredAt, + ) + + assertAll( + { assertEquals(event1, event2) }, + { assertEquals(event1.hashCode(), event2.hashCode()) }, + { assertNotEquals(event1, event3) }, + { assertNotEquals(event1.hashCode(), event3.hashCode()) } + ) + } + + @Test + fun `toString - success - contains all properties`() { + val event = PostDeletedEvent( + postId = 123L, + authorMemberId = 456L, + occurredAt = LocalDateTime.now(), + ) + + val toString = event.toString() + + assertAll( + { assertTrue(toString.contains("123")) }, + { assertTrue(toString.contains("456")) } + ) + } + + @Test + fun `equals - success - different authorMemberId produces different event`() { + val event1 = PostDeletedEvent( + postId = 1L, + authorMemberId = 42L, + occurredAt = LocalDateTime.now(), + ) + + val event2 = PostDeletedEvent( + postId = 1L, + authorMemberId = 43L, + occurredAt = LocalDateTime.now(), + ) + + assertNotEquals(event1, event2) } } diff --git a/src/test/kotlin/com/albert/realmoneyrealtaste/domain/post/event/PostHeartAddedEventTest.kt b/src/test/kotlin/com/albert/realmoneyrealtaste/domain/post/event/PostHeartAddedEventTest.kt index f2bd4d0d..8f2d3f2c 100644 --- a/src/test/kotlin/com/albert/realmoneyrealtaste/domain/post/event/PostHeartAddedEventTest.kt +++ b/src/test/kotlin/com/albert/realmoneyrealtaste/domain/post/event/PostHeartAddedEventTest.kt @@ -1,6 +1,10 @@ package com.albert.realmoneyrealtaste.domain.post.event +import org.junit.jupiter.api.assertAll import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotEquals +import kotlin.test.assertTrue class PostHeartAddedEventTest { @@ -8,10 +12,137 @@ class PostHeartAddedEventTest { fun `construction - success - creates event with valid parameters`() { val event = PostHeartAddedEvent( postId = 1L, - memberId = 42L, + memberId = 42L ) - assert(event.postId == 1L) - assert(event.memberId == 42L) + assertEquals(1L, event.postId) + assertEquals(42L, event.memberId) + } + + @Test + fun `withPostId - success - returns new event with updated postId`() { + val originalPostId = 1L + val newPostId = 999L + val memberId = 42L + + val originalEvent = PostHeartAddedEvent( + postId = originalPostId, + memberId = memberId + ) + + val updatedEvent = originalEvent.withPostId(newPostId) + + assertAll( + { assertEquals(newPostId, updatedEvent.postId) }, + { assertEquals(memberId, updatedEvent.memberId) }, + { assertNotEquals(originalEvent, updatedEvent) } + ) + } + + @Test + fun `withPostId - success - preserves immutability of original event`() { + val originalEvent = PostHeartAddedEvent( + postId = 1L, + memberId = 42L + ) + + originalEvent.withPostId(999L) + + // 원본 이벤트는 변경되지 않아야 함 + assertEquals(1L, originalEvent.postId) + } + + @Test + fun `construction - success - handles edge case values`() { + val event = PostHeartAddedEvent( + postId = Long.MAX_VALUE, + memberId = Long.MAX_VALUE + ) + + assertAll( + { assertEquals(Long.MAX_VALUE, event.postId) }, + { assertEquals(Long.MAX_VALUE, event.memberId) } + ) + } + + @Test + fun `construction - success - handles minimum values`() { + val event = PostHeartAddedEvent( + postId = 1L, + memberId = 1L + ) + + assertAll( + { assertEquals(1L, event.postId) }, + { assertEquals(1L, event.memberId) } + ) + } + + @Test + fun `equals and hashCode - success - works correctly`() { + val event1 = PostHeartAddedEvent( + postId = 1L, + memberId = 42L + ) + + val event2 = PostHeartAddedEvent( + postId = 1L, + memberId = 42L + ) + + val event3 = PostHeartAddedEvent( + postId = 2L, + memberId = 42L + ) + + assertAll( + { assertEquals(event1, event2) }, + { assertEquals(event1.hashCode(), event2.hashCode()) }, + { assertNotEquals(event1, event3) }, + { assertNotEquals(event1.hashCode(), event3.hashCode()) } + ) + } + + @Test + fun `toString - success - contains all properties`() { + val event = PostHeartAddedEvent( + postId = 123L, + memberId = 456L + ) + + val toString = event.toString() + + assertAll( + { assertTrue(toString.contains("123")) }, + { assertTrue(toString.contains("456")) } + ) + } + + @Test + fun `equals - success - different memberId produces different event`() { + val event1 = PostHeartAddedEvent( + postId = 1L, + memberId = 42L + ) + + val event2 = PostHeartAddedEvent( + postId = 1L, + memberId = 43L + ) + + assertNotEquals(event1, event2) + } + + @Test + fun `construction - success - postId and memberId can be different`() { + val event = PostHeartAddedEvent( + postId = 100L, + memberId = 200L + ) + + assertAll( + { assertEquals(100L, event.postId) }, + { assertEquals(200L, event.memberId) } + ) } } diff --git a/src/test/kotlin/com/albert/realmoneyrealtaste/domain/post/event/PostHeartRemovedEventTest.kt b/src/test/kotlin/com/albert/realmoneyrealtaste/domain/post/event/PostHeartRemovedEventTest.kt index e412a9f1..f9f892c8 100644 --- a/src/test/kotlin/com/albert/realmoneyrealtaste/domain/post/event/PostHeartRemovedEventTest.kt +++ b/src/test/kotlin/com/albert/realmoneyrealtaste/domain/post/event/PostHeartRemovedEventTest.kt @@ -1,6 +1,10 @@ package com.albert.realmoneyrealtaste.domain.post.event +import org.junit.jupiter.api.assertAll import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotEquals +import kotlin.test.assertTrue class PostHeartRemovedEventTest { @@ -8,10 +12,137 @@ class PostHeartRemovedEventTest { fun `construction - success - creates event with valid parameters`() { val event = PostHeartRemovedEvent( postId = 1L, - memberId = 42L, + memberId = 42L ) - assert(event.postId == 1L) - assert(event.memberId == 42L) + assertEquals(1L, event.postId) + assertEquals(42L, event.memberId) + } + + @Test + fun `withPostId - success - returns new event with updated postId`() { + val originalPostId = 1L + val newPostId = 999L + val memberId = 42L + + val originalEvent = PostHeartRemovedEvent( + postId = originalPostId, + memberId = memberId + ) + + val updatedEvent = originalEvent.withPostId(newPostId) + + assertAll( + { assertEquals(newPostId, updatedEvent.postId) }, + { assertEquals(memberId, updatedEvent.memberId) }, + { assertNotEquals(originalEvent, updatedEvent) } + ) + } + + @Test + fun `withPostId - success - preserves immutability of original event`() { + val originalEvent = PostHeartRemovedEvent( + postId = 1L, + memberId = 42L + ) + + originalEvent.withPostId(999L) + + // 원본 이벤트는 변경되지 않아야 함 + assertEquals(1L, originalEvent.postId) + } + + @Test + fun `construction - success - handles edge case values`() { + val event = PostHeartRemovedEvent( + postId = Long.MAX_VALUE, + memberId = Long.MAX_VALUE + ) + + assertAll( + { assertEquals(Long.MAX_VALUE, event.postId) }, + { assertEquals(Long.MAX_VALUE, event.memberId) } + ) + } + + @Test + fun `construction - success - handles minimum values`() { + val event = PostHeartRemovedEvent( + postId = 1L, + memberId = 1L + ) + + assertAll( + { assertEquals(1L, event.postId) }, + { assertEquals(1L, event.memberId) } + ) + } + + @Test + fun `equals and hashCode - success - works correctly`() { + val event1 = PostHeartRemovedEvent( + postId = 1L, + memberId = 42L + ) + + val event2 = PostHeartRemovedEvent( + postId = 1L, + memberId = 42L + ) + + val event3 = PostHeartRemovedEvent( + postId = 2L, + memberId = 42L + ) + + assertAll( + { assertEquals(event1, event2) }, + { assertEquals(event1.hashCode(), event2.hashCode()) }, + { assertNotEquals(event1, event3) }, + { assertNotEquals(event1.hashCode(), event3.hashCode()) } + ) + } + + @Test + fun `toString - success - contains all properties`() { + val event = PostHeartRemovedEvent( + postId = 123L, + memberId = 456L + ) + + val toString = event.toString() + + assertAll( + { assertTrue(toString.contains("123")) }, + { assertTrue(toString.contains("456")) } + ) + } + + @Test + fun `equals - success - different memberId produces different event`() { + val event1 = PostHeartRemovedEvent( + postId = 1L, + memberId = 42L + ) + + val event2 = PostHeartRemovedEvent( + postId = 1L, + memberId = 43L + ) + + assertNotEquals(event1, event2) + } + + @Test + fun `construction - success - postId and memberId can be different`() { + val event = PostHeartRemovedEvent( + postId = 100L, + memberId = 200L + ) + + assertAll( + { assertEquals(100L, event.postId) }, + { assertEquals(200L, event.memberId) } + ) } } diff --git a/src/test/kotlin/com/albert/realmoneyrealtaste/domain/post/event/PostViewedEventTest.kt b/src/test/kotlin/com/albert/realmoneyrealtaste/domain/post/event/PostViewedEventTest.kt index ca0e712e..f5742665 100644 --- a/src/test/kotlin/com/albert/realmoneyrealtaste/domain/post/event/PostViewedEventTest.kt +++ b/src/test/kotlin/com/albert/realmoneyrealtaste/domain/post/event/PostViewedEventTest.kt @@ -1,7 +1,10 @@ package com.albert.realmoneyrealtaste.domain.post.event -import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.assertAll import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotEquals +import kotlin.test.assertTrue class PostViewedEventTest { @@ -10,11 +13,174 @@ class PostViewedEventTest { val event = PostViewedEvent( postId = 1L, viewerMemberId = 42L, - authorMemberId = 99L, + authorMemberId = 100L ) assertEquals(1L, event.postId) assertEquals(42L, event.viewerMemberId) - assertEquals(99L, event.authorMemberId) + assertEquals(100L, event.authorMemberId) + } + + @Test + fun `withPostId - success - returns new event with updated postId`() { + val originalPostId = 1L + val newPostId = 999L + val viewerMemberId = 42L + val authorMemberId = 100L + + val originalEvent = PostViewedEvent( + postId = originalPostId, + viewerMemberId = viewerMemberId, + authorMemberId = authorMemberId + ) + + val updatedEvent = originalEvent.withPostId(newPostId) + + assertAll( + { assertEquals(newPostId, updatedEvent.postId) }, + { assertEquals(viewerMemberId, updatedEvent.viewerMemberId) }, + { assertEquals(authorMemberId, updatedEvent.authorMemberId) }, + { assertNotEquals(originalEvent, updatedEvent) } + ) + } + + @Test + fun `withPostId - success - preserves immutability of original event`() { + val originalEvent = PostViewedEvent( + postId = 1L, + viewerMemberId = 42L, + authorMemberId = 100L + ) + + originalEvent.withPostId(999L) + + // 원본 이벤트는 변경되지 않아야 함 + assertEquals(1L, originalEvent.postId) + } + + @Test + fun `construction - success - handles edge case values`() { + val event = PostViewedEvent( + postId = Long.MAX_VALUE, + viewerMemberId = Long.MAX_VALUE, + authorMemberId = Long.MAX_VALUE + ) + + assertAll( + { assertEquals(Long.MAX_VALUE, event.postId) }, + { assertEquals(Long.MAX_VALUE, event.viewerMemberId) }, + { assertEquals(Long.MAX_VALUE, event.authorMemberId) } + ) + } + + @Test + fun `construction - success - handles minimum values`() { + val event = PostViewedEvent( + postId = 1L, + viewerMemberId = 1L, + authorMemberId = 1L + ) + + assertAll( + { assertEquals(1L, event.postId) }, + { assertEquals(1L, event.viewerMemberId) }, + { assertEquals(1L, event.authorMemberId) } + ) + } + + @Test + fun `construction - success - viewer and author can be same member`() { + val memberId = 42L + + val event = PostViewedEvent( + postId = 1L, + viewerMemberId = memberId, + authorMemberId = memberId + ) + + assertAll( + { assertEquals(1L, event.postId) }, + { assertEquals(memberId, event.viewerMemberId) }, + { assertEquals(memberId, event.authorMemberId) } + ) + } + + @Test + fun `equals and hashCode - success - works correctly`() { + val event1 = PostViewedEvent( + postId = 1L, + viewerMemberId = 42L, + authorMemberId = 100L + ) + + val event2 = PostViewedEvent( + postId = 1L, + viewerMemberId = 42L, + authorMemberId = 100L + ) + + val event3 = PostViewedEvent( + postId = 2L, + viewerMemberId = 42L, + authorMemberId = 100L + ) + + assertAll( + { assertEquals(event1, event2) }, + { assertEquals(event1.hashCode(), event2.hashCode()) }, + { assertNotEquals(event1, event3) }, + { assertNotEquals(event1.hashCode(), event3.hashCode()) } + ) + } + + @Test + fun `toString - success - contains all properties`() { + val event = PostViewedEvent( + postId = 123L, + viewerMemberId = 456L, + authorMemberId = 789L + ) + + val toString = event.toString() + + assertAll( + { assertTrue(toString.contains("123")) }, + { assertTrue(toString.contains("456")) }, + { assertTrue(toString.contains("789")) } + ) + } + + @Test + fun `equals - success - different viewerMemberId produces different event`() { + val event1 = PostViewedEvent( + postId = 1L, + viewerMemberId = 42L, + authorMemberId = 100L + ) + + val event2 = PostViewedEvent( + postId = 1L, + viewerMemberId = 43L, + authorMemberId = 100L + ) + + assertNotEquals(event1, event2) + } + + @Test + fun `equals - success - different authorMemberId produces different event`() { + val event1 = PostViewedEvent( + postId = 1L, + viewerMemberId = 42L, + authorMemberId = 100L + ) + + val event2 = PostViewedEvent( + postId = 1L, + viewerMemberId = 42L, + authorMemberId = 101L + ) + + assertNotEquals(event1, event2) } } diff --git a/src/test/kotlin/com/albert/realmoneyrealtaste/domain/post/value/AuthorTest.kt b/src/test/kotlin/com/albert/realmoneyrealtaste/domain/post/value/AuthorTest.kt index d45f44af..f1f9407f 100644 --- a/src/test/kotlin/com/albert/realmoneyrealtaste/domain/post/value/AuthorTest.kt +++ b/src/test/kotlin/com/albert/realmoneyrealtaste/domain/post/value/AuthorTest.kt @@ -9,7 +9,7 @@ class AuthorTest { @Test fun `create - success - creates author with valid parameters`() { - val author = Author(1L, "작성자", TEST_INTRODUCTION) + val author = Author(1L, "작성자", TEST_INTRODUCTION, 1L) assertEquals(1L, author.memberId) assertEquals("작성자", author.nickname) @@ -19,7 +19,7 @@ class AuthorTest { @Test fun `create - failure - throws exception when nickname is blank`() { assertFailsWith { - Author(1L, "", TEST_INTRODUCTION) + Author(1L, "", TEST_INTRODUCTION, 1L) }.let { assertEquals("작성자 닉네임은 필수입니다.", it.message) } @@ -30,9 +30,43 @@ class AuthorTest { val longNickname = "a".repeat(21) assertFailsWith { - Author(1L, longNickname, TEST_INTRODUCTION) + Author(1L, longNickname, TEST_INTRODUCTION, 1L) }.let { assertEquals("닉네임은 20자 이내여야 합니다.", it.message) } } + + @Test + fun `create - failure - throws exception when imageId is zero`() { + assertFailsWith { + Author(1L, "작성자", TEST_INTRODUCTION, 0L) + }.let { + assertEquals("작성자 이미지 ID는 0보다 커야 합니다.", it.message) + } + } + + @Test + fun `create - failure - throws exception when imageId is negative`() { + assertFailsWith { + Author(1L, "작성자", TEST_INTRODUCTION, -1L) + }.let { + assertEquals("작성자 이미지 ID는 0보다 커야 합니다.", it.message) + } + } + + @Test + fun `create - success - creates author when nickname is exactly max length`() { + val maxLengthNickname = "a".repeat(Author.MAX_NICKNAME_LENGTH) + val author = Author(1L, maxLengthNickname, TEST_INTRODUCTION, 1L) + + assertEquals(maxLengthNickname, author.nickname) + } + + @Test + fun `create - success - creates author when introduction is exactly max length`() { + val maxLengthIntroduction = "a".repeat(Author.MAX_INTRODUCTION_LENGTH) + val author = Author(1L, "작성자", maxLengthIntroduction, 1L) + + assertEquals(maxLengthIntroduction, author.introduction) + } } diff --git a/src/test/kotlin/com/albert/realmoneyrealtaste/util/PostFixture.kt b/src/test/kotlin/com/albert/realmoneyrealtaste/util/PostFixture.kt index dd910f69..f8e6b9fa 100644 --- a/src/test/kotlin/com/albert/realmoneyrealtaste/util/PostFixture.kt +++ b/src/test/kotlin/com/albert/realmoneyrealtaste/util/PostFixture.kt @@ -5,7 +5,6 @@ import com.albert.realmoneyrealtaste.domain.post.value.Author import com.albert.realmoneyrealtaste.domain.post.value.PostContent import com.albert.realmoneyrealtaste.domain.post.value.PostImages import com.albert.realmoneyrealtaste.domain.post.value.Restaurant -import java.lang.reflect.Field class PostFixture { @@ -26,7 +25,8 @@ class PostFixture { val DEFAULT_AUTHOR = Author( memberId = DEFAULT_AUTHOR_MEMBER_ID, nickname = DEFAULT_AUTHOR_NICKNAME, - introduction = DEFAULT_AUTHOR_INTRODUCTION + introduction = DEFAULT_AUTHOR_INTRODUCTION, + imageId = 1L ) val DEFAULT_RESTAURANT = Restaurant( @@ -52,6 +52,24 @@ class PostFixture { /** * 기본 Post 생성 */ + fun createPostWithId( + authorMemberId: Long = DEFAULT_AUTHOR_MEMBER_ID, + authorNickname: String = DEFAULT_AUTHOR_NICKNAME, + restaurant: Restaurant = DEFAULT_RESTAURANT, + content: PostContent = DEFAULT_CONTENT, + images: PostImages = createImages(3), + ): Post { + return Post.create( + authorMemberId = authorMemberId, + authorNickname = authorNickname, + authorIntroduction = DEFAULT_AUTHOR_INTRODUCTION, + restaurant = restaurant, + content = content, + images = images, + authorImageId = 1L + ).also { it.setId() } + } + fun createPost( authorMemberId: Long = DEFAULT_AUTHOR_MEMBER_ID, authorNickname: String = DEFAULT_AUTHOR_NICKNAME, @@ -65,7 +83,8 @@ class PostFixture { authorIntroduction = DEFAULT_AUTHOR_INTRODUCTION, restaurant = restaurant, content = content, - images = images + images = images, + authorImageId = 1L ) } @@ -82,22 +101,5 @@ class PostFixture { fun createPostWithoutImages(): Post { return createPost(images = PostImages.empty()) } - - /** - * 특정 평점의 Post 생성 - */ - fun createPostWithRating(rating: Int): Post { - val content = PostContent(DEFAULT_CONTENT_TEXT, rating) - return createPost(content = content) - } - - /** - * ID를 설정하는 헬퍼 메서드 (테스트용) - */ - fun setId(post: Post, id: Long) { - val idField: Field = post.javaClass.superclass.getDeclaredField("id") - idField.isAccessible = true - idField.set(post, id) - } } } diff --git a/src/test/kotlin/com/albert/realmoneyrealtaste/util/TestEntityExtensions.kt b/src/test/kotlin/com/albert/realmoneyrealtaste/util/TestEntityExtensions.kt new file mode 100644 index 00000000..832fc082 --- /dev/null +++ b/src/test/kotlin/com/albert/realmoneyrealtaste/util/TestEntityExtensions.kt @@ -0,0 +1,21 @@ +package com.albert.realmoneyrealtaste.util + +import com.albert.realmoneyrealtaste.domain.common.BaseEntity +import kotlin.random.Random + +/** + * 테스트용 BaseEntity ID 설정 확장 함수 + */ +fun BaseEntity.setId(id: Long = Random.nextLong()) { + val idField = BaseEntity::class.java.getDeclaredField("id") + idField.isAccessible = true + idField.set(this, id) +} + +/** + * 체이닝을 위한 확장 함수 + */ +fun T.withId(id: Long): T { + this.setId(id) + return this +} diff --git a/src/test/kotlin/com/albert/realmoneyrealtaste/util/TestEventHelper.kt b/src/test/kotlin/com/albert/realmoneyrealtaste/util/TestEventHelper.kt new file mode 100644 index 00000000..6ddff4c6 --- /dev/null +++ b/src/test/kotlin/com/albert/realmoneyrealtaste/util/TestEventHelper.kt @@ -0,0 +1,46 @@ +package com.albert.realmoneyrealtaste.util + +import com.albert.realmoneyrealtaste.application.event.MemberEventCreationService +import com.albert.realmoneyrealtaste.domain.event.MemberEvent +import com.albert.realmoneyrealtaste.domain.event.MemberEventType +import jakarta.persistence.EntityManager +import org.springframework.stereotype.Component +import org.springframework.transaction.annotation.Transactional + +@Component +class TestEventHelper( + private val memberEventCreationService: MemberEventCreationService, + private val entityManager: EntityManager, +) { + + @Transactional + fun createEvent( + memberId: Long, + eventType: MemberEventType, + title: String, + message: String, + isRead: Boolean = false, + relatedMemberId: Long? = null, + relatedPostId: Long? = null, + relatedCommentId: Long? = null, + ): MemberEvent { + val event = memberEventCreationService.createEvent( + memberId = memberId, + eventType = eventType, + title = title, + message = message, + relatedMemberId = relatedMemberId, + relatedPostId = relatedPostId, + relatedCommentId = relatedCommentId + ) + return memberEventCreationService.createEvent( + memberId = memberId, + eventType = eventType, + title = title, + message = message, + relatedMemberId = relatedMemberId, + relatedPostId = relatedPostId, + relatedCommentId = relatedCommentId + ) + } +} diff --git a/src/test/kotlin/com/albert/realmoneyrealtaste/util/TestMemberHelper.kt b/src/test/kotlin/com/albert/realmoneyrealtaste/util/TestMemberHelper.kt index 3d662154..b21bb2d4 100644 --- a/src/test/kotlin/com/albert/realmoneyrealtaste/util/TestMemberHelper.kt +++ b/src/test/kotlin/com/albert/realmoneyrealtaste/util/TestMemberHelper.kt @@ -23,8 +23,9 @@ class TestMemberHelper( ): Member { val passwordHash = PasswordHash.of(password, passwordEncoder) val member = Member.register(email, nickname, passwordHash) + memberRepository.save(member) member.activate() - return memberRepository.save(member) + return member } @Transactional diff --git a/src/test/resources/application-devdb.yml b/src/test/resources/application-devdb.yml index d0a6e1fc..bc275b92 100644 --- a/src/test/resources/application-devdb.yml +++ b/src/test/resources/application-devdb.yml @@ -1,4 +1,4 @@ spring: jpa: hibernate: - ddl-auto: update + ddl-auto: validate