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 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
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 @@
-
+
-
+
0
Post
@@ -28,7 +28,7 @@
@@ -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 @@
-
-
- Events
+
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 @@
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
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-

-
-
-
-
-
- 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