Merge pull request 'test' (#430) from test into main

Reviewed-on: #430
This commit is contained in:
2026-06-30 12:54:07 +00:00
13 changed files with 1042 additions and 172 deletions

View File

@@ -26,8 +26,13 @@
- 최근 대화는 기존 `ChatRoomListService.getRooms(member, filter = "ALL", cursor = null, limit = 10)``ChatRoomListItemResponse`를 재사용한다.
- 최근 소식 타입은 `CREATOR_RANKING`, `CONTENT_RANKING`, `COMMUNITY_POST`, `AUDIO_CONTENT`, `PHOTO_CONTENT`를 정의한다.
- 이번 범위에서 생성하는 랭킹 소식은 `CREATOR_RANKING`만이다. `CONTENT_RANKING`은 향후 확장용으로 enum/table 값만 예약한다.
- 최근 소식 응답에는 별도 `creatorId`를 내려주지 않는다. 크리에이터 채널 이동이 필요한 `CREATOR_RANKING` `targetId`가 크리에이터 회원 id다.
- 최근 소식 랭킹 값은 `rank: Int?`만 사용한다. `rankChange`, `isNew`, nested `ranking` object는 사용하지 않는다.
- 최근 소식 응답 최상위에는 `newsId`, `type`, `visibleFromAtUtc`만 공통으로 내려준다.
- 최근 소식 상세 값은 타입별 nullable nested DTO로 내려준다. `type`과 일치하는 nested DTO만 non-null이고 나머지는 `null`다.
- `CREATOR_RANKING``creatorRanking.rank`, `creatorRanking.creatorId`, `creatorRanking.nickname`, `creatorRanking.profileImageUrl`을 사용한다. `rankChange`, `isNew`는 사용하지 않는다.
- `CONTENT_RANKING``contentRanking.rank`, `contentRanking.contentId`, `contentRanking.contentImageUrl`, `contentRanking.title`을 사용한다.
- `AUDIO_CONTENT`, `PHOTO_CONTENT`는 각각 `audioContent`/`photoContent``contentId`, `contentImageUrl`, `title`, `creatorProfileImageUrl`, `creatorNickname`을 담고, 공개 시각은 최상위 `visibleFromAtUtc`를 사용한다.
- `COMMUNITY_POST``communityPost``postId`, `creatorProfileImage`, `creatorNickname`, nullable `imageUrl`, `content`, UTC `createdAt`, `likeCount`, `commentCount`를 담는다.
- `COMMUNITY_POST` 최근 소식은 무료 커뮤니티 게시글만 발행한다. 유료 커뮤니티 게시글은 inbox row를 생성하지 않는다.
- 최근 소식 inbox table DDL은 `docs/20260625_메인_홈_팔로잉_탭_API/create-home-following-news-inbox-table.sql`을 기준으로 한다.
- inbox 중복 방지는 `memberId`, `newsType`, `sourceKey` 기준 unique 정책으로 보장한다.
- 언팔로우 시 해당 회원과 크리에이터의 활성 inbox row를 비활성화한다. 재팔로우 시 기존 비활성 row는 복구하지 않는다.
@@ -206,34 +211,94 @@ data class FollowingScheduleResponse(
data class FollowingNewsResponse(
val newsId: String,
val type: FollowingNewsType,
val creatorProfileImageUrl: String,
val creatorNickname: String,
val title: String,
val body: String,
val thumbnailImageUrl: String?,
val targetId: Long,
val occurredAtUtc: String,
val visibleFromAtUtc: String,
val rank: Int?
val creatorRanking: FollowingCreatorRankingNewsResponse?,
val audioContent: FollowingContentNewsResponse?,
val photoContent: FollowingContentNewsResponse?,
val contentRanking: FollowingContentRankingNewsResponse?,
val communityPost: FollowingCommunityPostNewsResponse?
) {
companion object {
fun from(news: HomeFollowingNews): FollowingNewsResponse {
return FollowingNewsResponse(
newsId = news.newsId,
type = news.type,
creatorProfileImageUrl = news.creatorProfileImageUrl,
creatorNickname = news.creatorNickname,
title = news.title,
body = news.body,
thumbnailImageUrl = news.thumbnailImageUrl,
targetId = news.targetId,
occurredAtUtc = news.occurredAtUtc,
visibleFromAtUtc = news.visibleFromAtUtc,
rank = news.rank
creatorRanking = news.creatorRanking?.toResponse(),
audioContent = news.audioContent?.toContentResponse(),
photoContent = news.photoContent?.toContentResponse(),
contentRanking = news.contentRanking?.toResponse(),
communityPost = news.communityPost?.toResponse()
)
}
}
}
data class FollowingCreatorRankingNewsResponse(
val rank: Int,
val creatorId: Long,
val nickname: String,
val profileImageUrl: String
)
data class FollowingContentNewsResponse(
val contentId: Long,
val contentImageUrl: String?,
val title: String,
val creatorProfileImageUrl: String,
val creatorNickname: String
)
data class FollowingContentRankingNewsResponse(
val rank: Int,
val contentId: Long,
val contentImageUrl: String?,
val title: String
)
data class FollowingCommunityPostNewsResponse(
val postId: Long,
val creatorProfileImage: String,
val creatorNickname: String,
val imageUrl: String?,
val content: String,
val createdAt: String,
val likeCount: Int,
val commentCount: Int
)
private fun HomeFollowingCreatorRankingNews.toResponse() = FollowingCreatorRankingNewsResponse(
rank = rank,
creatorId = creatorId,
nickname = nickname,
profileImageUrl = profileImageUrl
)
private fun HomeFollowingContentNews.toContentResponse() = FollowingContentNewsResponse(
contentId = contentId,
contentImageUrl = contentImageUrl,
title = title,
creatorProfileImageUrl = creatorProfileImageUrl,
creatorNickname = creatorNickname
)
private fun HomeFollowingContentRankingNews.toResponse() = FollowingContentRankingNewsResponse(
rank = rank,
contentId = contentId,
contentImageUrl = contentImageUrl,
title = title
)
private fun HomeFollowingCommunityPostNews.toResponse() = FollowingCommunityPostNewsResponse(
postId = postId,
creatorProfileImage = creatorProfileImage,
creatorNickname = creatorNickname,
imageUrl = imageUrl,
content = content,
createdAt = createdAt,
likeCount = likeCount,
commentCount = commentCount
)
```
---
@@ -283,15 +348,45 @@ data class HomeFollowingSchedule(
data class HomeFollowingNews(
val newsId: String,
val type: FollowingNewsType,
val creatorProfileImageUrl: String,
val creatorNickname: String,
val title: String,
val body: String,
val thumbnailImageUrl: String?,
val targetId: Long,
val occurredAtUtc: String,
val visibleFromAtUtc: String,
val rank: Int?
val creatorRanking: HomeFollowingCreatorRankingNews? = null,
val audioContent: HomeFollowingContentNews? = null,
val photoContent: HomeFollowingContentNews? = null,
val contentRanking: HomeFollowingContentRankingNews? = null,
val communityPost: HomeFollowingCommunityPostNews? = null
)
data class HomeFollowingCreatorRankingNews(
val rank: Int,
val creatorId: Long,
val nickname: String,
val profileImageUrl: String
)
data class HomeFollowingContentNews(
val contentId: Long,
val contentImageUrl: String?,
val title: String,
val creatorProfileImageUrl: String,
val creatorNickname: String
)
data class HomeFollowingContentRankingNews(
val rank: Int,
val contentId: Long,
val contentImageUrl: String?,
val title: String
)
data class HomeFollowingCommunityPostNews(
val postId: Long,
val creatorProfileImage: String,
val creatorNickname: String,
val imageUrl: String?,
val content: String,
val createdAt: String,
val likeCount: Int,
val commentCount: Int
)
enum class FollowingNewsType {
@@ -356,7 +451,7 @@ data class HomeFollowingNewsInboxRecord(
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/home/following/domain/FollowingNewsType.kt`
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/home/following/dto/HomeFollowingTabResponseTest.kt`
- RED: `HomeFollowingTabResponse.loginRequired()``isLoginRequired=true`와 빈 배열을 반환하는 테스트를 작성한다.
- RED: `FollowingNewsResponse` 변환 결과`creatorId` 없이 `rank: Int?`만 포함하는 테스트를 작성한다.
- RED: `FollowingNewsResponse` 변환 결과의 최상위 필드가 `newsId`, `type`, `visibleFromAtUtc`와 타입별 nullable nested DTO만 포함하는 테스트를 작성한다. `CREATOR_RANKING``creatorRanking`만 non-null이고 다른 nested DTO는 null이어야 한다.
- 실패 확인: `./gradlew --no-daemon test --tests "kr.co.vividnext.sodalive.v2.api.home.following.dto.HomeFollowingTabResponseTest"` 실행, DTO 미구현으로 실패 확인.
- GREEN: DTO/domain enum/model을 최소 구현한다.
- 통과 확인: 같은 단일 테스트 명령 실행, PASS 확인.
@@ -444,7 +539,7 @@ data class HomeFollowingNewsInboxRecord(
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/home/following/adapter/out/persistence/DefaultHomeFollowingQueryRepository.kt`
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/home/following/adapter/out/persistence/DefaultHomeFollowingQueryRepositoryTest.kt`
- RED: `memberId`, `isActive=true`, `visibleFromAtUtc <= nowUtc` 조건으로 `visibleFromAtUtc desc, id desc` 30개를 조회하는 테스트를 작성한다.
- RED: `creatorId`가 응답 domain에 노출되지 않고 `rank`만 nullable로 내려가는 테스트를 작성한다.
- RED: 최근 소식 domain이 타입별 nested DTO를 채우고, `COMMUNITY_POST`/`AUDIO_CONTENT` target 비활성 필터를 유지하는 테스트를 작성한다.
- 실패 확인: repository 단일 테스트 명령 실행, recent news 미구현 실패 확인.
- GREEN: inbox table 조회와 `HomeFollowingNews` 변환을 최소 구현한다.
- 통과 확인: 같은 단일 테스트 명령 실행, PASS 확인.
@@ -487,7 +582,7 @@ data class HomeFollowingNewsInboxRecord(
- Files:
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/home/following/application/HomeFollowingNewsPublishService.kt`
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/home/following/application/HomeFollowingNewsPublishServiceTest.kt`
- RED: `publishCommunityPostCreated(...)`가 현재 active follower에게 inbox record를 생성하는 테스트를 작성한다.
- RED: `publishFreeCommunityPostCreated(...)` 무료 커뮤니티 게시글에 대해서만 현재 active follower에게 inbox record를 생성하고, 유료 커뮤니티 게시글 생성 흐름은 publish service를 호출하지 않는 테스트를 작성한다.
- RED: `publishContentUploaded(...)``visibleFromAtUtc`를 콘텐츠 공개 시각으로 저장하는 테스트를 작성한다.
- RED: `publishCreatorRankingVisible(...)``rank`와 랭킹 스냅샷 `visibleFromAtUtc`를 저장하는 테스트를 작성한다.
- 실패 확인: `./gradlew --no-daemon test --tests "kr.co.vividnext.sodalive.v2.home.following.application.HomeFollowingNewsPublishServiceTest"` 실행, service 미구현 실패 확인.
@@ -524,7 +619,7 @@ data class HomeFollowingNewsInboxRecord(
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/explorer/profile/creatorCommunity/CreatorCommunityService.kt`
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/content/AudioContentServiceTest.kt`
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/explorer/profile/creatorCommunity/CreatorCommunityServiceTest.kt`
- RED: `CreatorCommunityService.createCommunityPost(...)` 성공 후 `publishCommunityPostCreated(...)`가 post id, creator id, 본문 요약, 생성 시각으로 호출되는 테스트를 작성한다.
- RED: `CreatorCommunityService.createCommunityPost(...)` 성공 후 무료 게시글이면 `publishFreeCommunityPostCreated(...)`가 post id, creator id, 본문, 생성 시각으로 호출되고, 유료 게시글이면 호출되지 않는 테스트를 작성한다.
- RED: `AudioContentService.createAudioContent(...)`에서 `releaseDate <= now`인 즉시 공개 콘텐츠 저장 성공 후 `publishContentUploaded(...)`가 호출되는 테스트를 작성한다.
- RED: `AudioContentService.createAudioContent(...)`에서 `releaseDate > now`인 예약 공개 콘텐츠 생성 시점에는 `publishContentUploaded(...)`가 호출되지 않는 테스트를 작성한다.
- RED: `AudioContentService.releaseContent()`가 예약 콘텐츠를 active로 바꾸는 시점에 `publishContentUploaded(...)`를 호출하는 테스트를 작성한다.
@@ -554,12 +649,107 @@ data class HomeFollowingNewsInboxRecord(
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/home/following/adapter/in/web/HomeFollowingEndToEndTest.kt`
- RED: 비로그인 호출이 200, `isLoginRequired=true`, 모든 배열 빈 값인지 검증하는 통합 테스트를 작성한다.
- RED: 로그인 회원 호출이 팔로잉 크리에이터/On Air/최근 대화/스케줄/최근 소식을 모두 조립하는 통합 테스트를 작성한다.
- RED: `FollowingNewsResponse``creatorId`와 nested `ranking`이 없고 `rank`만 있는지 JSON path 테스트를 작성한다.
- RED: `FollowingNewsResponse` 최상위`creatorProfileImageUrl`, `creatorNickname`, `title`, `body`, `thumbnailImageUrl`, `targetId`, `occurredAtUtc`, `rank`가 없고, 타입과 일치하는 nested DTO만 non-null인지 JSON path 테스트를 작성한다.
- 실패 확인: `./gradlew --no-daemon test --tests "kr.co.vividnext.sodalive.v2.api.home.following.adapter.in.web.HomeFollowingEndToEndTest"` 실행, 통합 미구현 실패 확인.
- GREEN: 누락된 wiring, bean 등록, security 설정을 최소 수정한다.
- 통과 확인: 같은 단일 테스트 명령 실행, PASS 확인.
- REFACTOR: 테스트 데이터 builder가 과하게 커지면 테스트 내부 private helper로만 분리한다.
### Phase 5.5: 최근 소식 응답 계약 nested DTO 변경 후속 구현
> 이 Phase는 Phase 1-5 구현 완료 후 변경된 공개 응답 계약을 실제 코드에 반영하기 위한 후속 작업이다. 기존 flat `FollowingNewsResponse`를 제거하고, 타입별 nullable nested DTO 계약과 무료 커뮤니티 게시글 전용 발행 계약을 구현한다.
#### 작업 의존성
1. `Task 5.5.1` DTO/domain 계약 변경과 `Task 5.5.3` 무료 커뮤니티 발행 제한은 서로 독립적으로 시작할 수 있다.
2. `Task 5.5.2` repository enrichment는 `Task 5.5.1`의 domain model 변경 이후 진행한다.
3. `Task 5.5.4` E2E 검증은 `Task 5.5.1`, `Task 5.5.2`, `Task 5.5.3` 완료 후 진행한다.
4. `Task 5.5.5` 회귀 검증은 모든 구현 task 완료 후 진행한다.
- [x] **Task 5.5.1: FollowingNewsResponse / HomeFollowingNews nested DTO 모델 전환**
- Files:
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/home/following/dto/HomeFollowingTabResponse.kt`
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/home/following/domain/HomeFollowing.kt`
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/home/following/dto/HomeFollowingTabResponseTest.kt`
- RED: `FollowingNewsResponse` 최상위 JSON에 `newsId`, `type`, `visibleFromAtUtc`, `creatorRanking`, `contentRanking`, `audioContent`, `photoContent`, `communityPost`만 존재하는 테스트를 작성한다.
- RED: 최상위 JSON에 기존 flat 필드인 `creatorProfileImageUrl`, `creatorNickname`, `title`, `body`, `thumbnailImageUrl`, `targetId`, `occurredAtUtc`, `rank`가 존재하지 않는 테스트를 작성한다.
- RED: `CREATOR_RANKING`이면 `creatorRanking`만 non-null이고 `rank`, `creatorId`, `nickname`, `profileImageUrl`을 포함하는 테스트를 작성한다.
- RED: `CONTENT_RANKING`이면 `contentRanking`만 non-null이고 `rank`, `contentId`, `contentImageUrl`, `title`을 포함하는 테스트를 작성한다.
- RED: `AUDIO_CONTENT`이면 `audioContent`만 non-null이고 `contentId`, `contentImageUrl`, `title`, `creatorProfileImageUrl`, `creatorNickname`을 포함하며 `releaseDate`가 없음을 검증하는 테스트를 작성한다.
- RED: `PHOTO_CONTENT`이면 `photoContent`만 non-null이고 `contentId`, `contentImageUrl`, `title`, `creatorProfileImageUrl`, `creatorNickname`을 포함하며 `releaseDate`가 없음을 검증하는 테스트를 작성한다.
- RED: `COMMUNITY_POST`이면 `communityPost`만 non-null이고 `postId`, `creatorProfileImage`, `creatorNickname`, nullable `imageUrl`, `content`, UTC `createdAt`, `likeCount`, `commentCount`를 포함하는 테스트를 작성한다.
- 실패 확인: `./gradlew --no-daemon test --tests "kr.co.vividnext.sodalive.v2.api.home.following.dto.HomeFollowingTabResponseTest"` 실행, 기존 flat DTO/domain 구조로 실패 확인.
- GREEN: `HomeFollowingNews`에 공통 필드 `newsId`, `type`, `visibleFromAtUtc`와 타입별 nullable domain payload를 추가한다.
- GREEN: `FollowingNewsResponse`에 공통 필드와 타입별 nullable response payload를 추가하고, domain payload를 response payload로 1:1 변환한다.
- REFACTOR: 타입별 DTO 변환 helper는 `HomeFollowingTabResponse.kt` 내부 private function 또는 companion object로만 유지하고, sealed class/상속 구조는 도입하지 않는다.
- 통과 확인: 위 단일 테스트 명령 재실행, PASS 확인.
- [x] **Task 5.5.2: 최근 소식 조회 repository 타입별 nested payload enrichment 구현**
- Files:
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/home/following/adapter/out/persistence/DefaultHomeFollowingQueryRepository.kt`
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/home/following/adapter/out/persistence/DefaultHomeFollowingQueryRepositoryTest.kt`
- RED: `CREATOR_RANKING` row가 `creatorRanking.rank`, `creatorRanking.creatorId`, `creatorRanking.nickname`, `creatorRanking.profileImageUrl`을 채우고 다른 nested payload는 null인 테스트를 작성한다.
- RED: `AUDIO_CONTENT` row가 `AudioContent` 원천에서 `contentId`, `contentImageUrl`, `title`, creator profile/nickname을 채우고 공개 시각은 최상위 `visibleFromAtUtc`를 사용하는 테스트를 작성한다.
- RED: `COMMUNITY_POST` row가 무료 `CreatorCommunity` 원천에서 `postId`, `creatorProfileImage`, `creatorNickname`, CDN `imageUrl`, 전체 `content`, UTC `createdAt`, active `likeCount`, active top-level `commentCount`를 채우는 테스트를 작성한다.
- RED: `COMMUNITY_POST` 원천 게시글이 `price > 0`이면 inbox row가 있더라도 최근 소식에서 제외되는 테스트를 작성한다.
- RED: 기존 `AUDIO_CONTENT`, `COMMUNITY_POST` 원천 target 비활성 제외 테스트가 nested domain 구조에서도 유지되도록 수정한다.
- 실패 확인: `./gradlew --no-daemon test --tests "kr.co.vividnext.sodalive.v2.home.following.adapter.out.persistence.DefaultHomeFollowingQueryRepositoryTest"` 실행, flat `HomeFollowingNews` 매핑과 enrichment 미구현으로 실패 확인.
- GREEN: 기존 inbox 후보 조회는 유지하되, 조회된 `targetId`를 타입별로 모아 batch 조회한다.
- GREEN: `AUDIO_CONTENT``audio_content` 원천에서 active row만 조회하고 이미지 URL을 변환해 `audioContent` payload를 만든다.
- GREEN: `COMMUNITY_POST`는 active이고 `price <= 0`인 원천 게시글만 조회하며, active like count와 active top-level comment count를 postIds 기준으로 batch 집계해 `communityPost` payload를 만든다.
- GREEN: `CREATOR_RANKING`은 inbox/creator 정보와 `rank_no``creatorRanking` payload를 만든다.
- GREEN: 아직 생성하지 않는 `CONTENT_RANKING`, `PHOTO_CONTENT`는 domain/DTO 타입만 유지하고, 원천 조회 경로가 없으면 API에 노출하지 않는다.
- REFACTOR: enrichment helper는 `DefaultHomeFollowingQueryRepository.kt` private method로 둔다. 새 port/repository interface는 이 task에서 만들지 않는다.
- 통과 확인: 위 단일 테스트 명령 재실행, PASS 확인.
- [x] **Task 5.5.3: 무료 커뮤니티 게시글만 COMMUNITY_POST 최근 소식 발행**
- Files:
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/explorer/profile/creatorCommunity/CreatorCommunityService.kt`
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/explorer/profile/creatorCommunity/CreatorCommunityServiceTest.kt`
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/home/following/application/HomeFollowingNewsPublishServiceTest.kt`
- RED: 무료 커뮤니티 게시글 생성 성공 후 `HomeFollowingNewsPublishService`가 post id, creator id, creator nickname/profile, 본문, 이미지 path, 생성 시각으로 호출되는 테스트를 작성한다.
- RED: 유료 커뮤니티 게시글 생성 성공 후 `HomeFollowingNewsPublishService`가 호출되지 않는 테스트를 작성한다.
- RED: 무료 커뮤니티 게시글 최근 소식 발행 실패가 원 게시글 생성 성공을 실패로 전파하지 않는 기존 after-commit 격리 테스트를 유지한다.
- RED: `HomeFollowingNewsPublishServiceTest`의 커뮤니티 발행 테스트명/설명을 무료 게시글 발행 계약에 맞게 갱신한다.
- 실패 확인: `./gradlew --no-daemon test --tests "kr.co.vividnext.sodalive.explorer.profile.creatorCommunity.CreatorCommunityServiceTest" --tests "kr.co.vividnext.sodalive.v2.home.following.application.HomeFollowingNewsPublishServiceTest"` 실행, 유료 게시글도 발행하는 기존 구현으로 실패 확인.
- GREEN: `CreatorCommunityService`에서 `post.price <= 0`인 경우에만 최근 소식 publish를 예약한다.
- GREEN: publish service 메서드명은 구현 시 선택한다. 이름을 `publishFreeCommunityPostCreated(...)`로 바꾸면 모든 호출부/테스트를 함께 갱신하고, 기존 이름을 유지하면 호출 조건으로 무료 게시글 전용 계약을 보장한다.
- REFACTOR: 유료 게시글 미리보기 마스킹 로직이 최근 소식 발행에서 더 이상 쓰이지 않으면 제거하되, 커뮤니티 탭/상세의 유료 콘텐츠 정책은 건드리지 않는다.
- 통과 확인: 위 테스트 명령 재실행, PASS 확인.
- [x] **Task 5.5.4: 팔로잉 탭 API E2E nested response 계약 검증**
- Files:
- Modify: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/home/following/adapter/in/web/HomeFollowingEndToEndTest.kt`
- RED: 로그인 회원 API 응답에 `CREATOR_RANKING`, `AUDIO_CONTENT`, 무료 `COMMUNITY_POST` 최근 소식 fixture를 포함한다.
- RED: 각 recentNews item의 최상위 flat 필드 `creatorProfileImageUrl`, `creatorNickname`, `title`, `body`, `thumbnailImageUrl`, `targetId`, `occurredAtUtc`, `rank`가 존재하지 않는 JSON path 테스트를 작성한다.
- RED: `CREATOR_RANKING` item은 `creatorRanking`만 non-null이고 `rank`, `creatorId`, `nickname`, `profileImageUrl`을 포함하는지 검증한다.
- RED: `AUDIO_CONTENT` item은 `audioContent`만 non-null이고 `releaseDate`가 없으며 최상위 `visibleFromAtUtc`가 존재하는지 검증한다.
- RED: `COMMUNITY_POST` item은 `communityPost`만 non-null이고 `postId`, `creatorProfileImage`, `creatorNickname`, `imageUrl`, `content`, UTC `createdAt`, `likeCount`, `commentCount`를 포함하는지 검증한다.
- RED: 유료 커뮤니티 게시글은 최근 소식 응답에 노출되지 않는지 검증한다.
- 실패 확인: `./gradlew --no-daemon test --tests "kr.co.vividnext.sodalive.v2.api.home.following.adapter.in.web.HomeFollowingEndToEndTest"` 실행, 기존 flat API 응답으로 실패 확인.
- GREEN: Task 5.5.1-5.5.3 구현으로 대부분 통과해야 한다. wiring 누락이 있으면 관련 production file만 최소 수정한다.
- REFACTOR: E2E fixture helper는 테스트 파일 내부 private helper로만 분리한다.
- 통과 확인: 위 단일 테스트 명령 재실행, PASS 확인.
- [x] **Task 5.5.5: Phase 5.5 문서/회귀 검증 기록**
- Files:
- Verify: `docs/20260625_메인_홈_팔로잉_탭_API/prd.md`
- Modify: `docs/20260625_메인_홈_팔로잉_탭_API/plan-task.md`
- Verify: `docs/20260629_커뮤니티_게시글_좋아요_상태/prd.md`
- Verify: Phase 5.5에서 변경한 Kotlin source/test
- TDD 예외 사유: 문서/회귀 검증 작업이며 신규 production behavior를 만들지 않는다.
- 대체 검증 방법:
- `./gradlew --no-daemon test --tests "kr.co.vividnext.sodalive.v2.api.home.following.dto.HomeFollowingTabResponseTest"`
- `./gradlew --no-daemon test --tests "kr.co.vividnext.sodalive.v2.home.following.adapter.out.persistence.DefaultHomeFollowingQueryRepositoryTest"`
- `./gradlew --no-daemon test --tests "kr.co.vividnext.sodalive.explorer.profile.creatorCommunity.CreatorCommunityServiceTest" --tests "kr.co.vividnext.sodalive.v2.home.following.application.HomeFollowingNewsPublishServiceTest"`
- `./gradlew --no-daemon test --tests "kr.co.vividnext.sodalive.v2.api.home.following.adapter.in.web.HomeFollowingEndToEndTest"`
- `./gradlew --no-daemon test`
- `./gradlew --no-daemon ktlintCheck`
- 문서 검증 grep:
- `rg -n "creatorProfileImageUrl|creatorNickname|thumbnailImageUrl|targetId|occurredAtUtc|rank: Int\?|publishCommunityPostCreated|유료 커뮤니티 최근 소식 미리보기" docs/20260625_메인_홈_팔로잉_탭_API`
- 검색 결과가 팔로잉 크리에이터/스케줄/타입별 nested DTO/내부 inbox 컬럼/과거 검증 기록 맥락인지 확인하고, stale flat `FollowingNewsResponse` 공개 계약이면 수정한다.
- 검증 결과 기록: Phase 5.5 완료 시 실행 명령, 결과, 실패 시 원인과 후속 조치를 이 문서의 `## 6. 검증 기록`에 한국어로 누적 기록한다.
### Phase 6: 문서/회귀 검증
- [x] **Task 6.1: 문서 동기화 확인**
@@ -568,7 +758,7 @@ data class HomeFollowingNewsInboxRecord(
- Verify: `docs/20260625_메인_홈_팔로잉_탭_API/create-home-following-news-inbox-table.sql`
- Modify: `docs/20260625_메인_홈_팔로잉_탭_API/plan-task.md`
- TDD 예외 사유: 문서 검증 작업이며 실행 코드가 없다.
- 대체 검증 방법: `rg -n "FollowingNewsRankingResponse|ranking\\?|rankChange|isNew|creatorId" docs/20260625_메인_홈_팔로잉_탭_API`로 삭제된 공개 응답 필드가 남아 있는지 확인한다. 단, 팔로잉 크리에이터/스케줄의 `creatorId`와 DDL 내부 컬럼 `creator_id` 허용한다.
- 대체 검증 방법: `rg -n "creatorProfileImageUrl|creatorNickname|thumbnailImageUrl|targetId|occurredAtUtc|rank: Int\\?|publishCommunityPostCreated|유료 커뮤니티 최근 소식 미리보기" docs/20260625_메인_홈_팔로잉_탭_API`로 최근 소식 flat 공개 응답 필드와 유료 커뮤니티 발행 계약이 남아 있는지 확인한다. 단, 팔로잉 크리에이터/스케줄/타입별 nested DTO/내부 inbox 컬럼 맥락은 허용한다.
- 실행 명령: `./gradlew tasks --all`
- 기대 결과: `BUILD SUCCESSFUL`
@@ -591,7 +781,8 @@ data class HomeFollowingNewsInboxRecord(
3. 팔로잉 크리에이터, On Air, 스케줄, 최근 소식 조회 repository를 만든다.
4. query service와 facade에서 섹션을 조립한다.
5. publish service를 만들고 언팔로우/랭킹/콘텐츠/커뮤니티 이벤트에 연결한다.
6. End-to-End 테스트와 전체 회귀 검증을 수행한다.
6. `FollowingNewsResponse`를 타입별 nested DTO 계약으로 전환하고 무료 커뮤니티 게시글만 `COMMUNITY_POST` 최근 소식을 발행하도록 보강한다.
7. End-to-End 테스트와 전체 회귀 검증을 수행한다.
---
@@ -622,7 +813,7 @@ data class HomeFollowingNewsInboxRecord(
- `./gradlew --no-daemon test --tests "kr.co.vividnext.sodalive.v2.api.home.following.application.HomeFollowingFacadeTest"` 실행 결과 `BUILD SUCCESSFUL`.
- `./gradlew --no-daemon test --tests "kr.co.vividnext.sodalive.v2.api.home.following.adapter.in.web.HomeFollowingEndToEndTest"` 실행 결과 `BUILD SUCCESSFUL`.
- 2026-06-26 Phase 3-5 리뷰 보완 검증:
- 리뷰 지적 사항에 따라 팔로잉 탭 조회의 크리에이터 role 필터, 오디오 공개 시각 판정, 유료 커뮤니티 최근 소식 미리보기 마스킹, 최근 소식 발행 `REQUIRES_NEW` 트랜잭션, inbox `title/body` 길이 정규화를 보강했다.
- 리뷰 지적 사항에 따라 팔로잉 탭 조회의 크리에이터 role 필터, 오디오 공개 시각 판정, 커뮤니티 최근 소식 미리보기 마스킹, 최근 소식 발행 `REQUIRES_NEW` 트랜잭션, inbox `title/body` 길이 정규화를 보강했다. 이후 계약 변경으로 `COMMUNITY_POST` 최근 소식은 무료 게시글만 발행하도록 재정의했다.
- RED 확인: `./gradlew --no-daemon test --tests "kr.co.vividnext.sodalive.v2.home.following.adapter.out.persistence.DefaultHomeFollowingQueryRepositoryTest" --tests "kr.co.vividnext.sodalive.v2.home.following.application.HomeFollowingNewsPublishServiceTest" --tests "kr.co.vividnext.sodalive.content.AudioContentServiceTest" --tests "kr.co.vividnext.sodalive.explorer.profile.creatorCommunity.CreatorCommunityServiceTest"` 실행 결과 reviewer 보완 전 7개 regression 테스트 실패를 확인했다.
- 같은 regression 테스트 명령 재실행 결과 `BUILD SUCCESSFUL`.
- Phase 3-5 전체 대상 테스트 명령 재실행 결과 `BUILD SUCCESSFUL`.
@@ -650,3 +841,21 @@ data class HomeFollowingNewsInboxRecord(
- `./gradlew tasks --all` 실행 결과 `BUILD SUCCESSFUL`.
- `./gradlew --no-daemon test` 실행 결과 `BUILD SUCCESSFUL`.
- `./gradlew --no-daemon ktlintCheck` 실행 결과 `BUILD SUCCESSFUL`.
- 2026-06-30 Phase 5.5 구현 검증:
- RED 확인: `./gradlew --no-daemon test --tests "kr.co.vividnext.sodalive.v2.api.home.following.dto.HomeFollowingTabResponseTest"` 실행 결과 nested domain/DTO 미구현 컴파일 오류로 `BUILD FAILED`.
- RED 확인: `./gradlew --no-daemon test --tests "kr.co.vividnext.sodalive.v2.home.following.adapter.out.persistence.DefaultHomeFollowingQueryRepositoryTest.shouldPopulateRecentNewsNestedPayloadsFromSourceTargets" --tests "kr.co.vividnext.sodalive.v2.home.following.adapter.out.persistence.DefaultHomeFollowingQueryRepositoryTest.shouldExcludePaidCommunityPostRecentNews"` 실행 결과 원천 payload enrichment와 유료 커뮤니티 제외 미구현으로 `BUILD FAILED`.
- RED 확인: `./gradlew --no-daemon test --tests "kr.co.vividnext.sodalive.explorer.profile.creatorCommunity.CreatorCommunityServiceTest.shouldNotPublishNewsAfterPaidCommunityPostCreated"` 실행 결과 유료 커뮤니티 게시글도 최근 소식을 발행해 `BUILD FAILED`.
- `./gradlew --no-daemon test --tests "kr.co.vividnext.sodalive.v2.api.home.following.dto.HomeFollowingTabResponseTest"` 실행 결과 `BUILD SUCCESSFUL`.
- `./gradlew --no-daemon test --tests "kr.co.vividnext.sodalive.v2.home.following.adapter.out.persistence.DefaultHomeFollowingQueryRepositoryTest.shouldPopulateRecentNewsNestedPayloadsFromSourceTargets" --tests "kr.co.vividnext.sodalive.v2.home.following.adapter.out.persistence.DefaultHomeFollowingQueryRepositoryTest.shouldExcludePaidCommunityPostRecentNews"` 실행 결과 `BUILD SUCCESSFUL`.
- `./gradlew --no-daemon test --tests "kr.co.vividnext.sodalive.explorer.profile.creatorCommunity.CreatorCommunityServiceTest.shouldPublishNewsAfterCommunityPostCreated" --tests "kr.co.vividnext.sodalive.explorer.profile.creatorCommunity.CreatorCommunityServiceTest.shouldNotPublishNewsAfterPaidCommunityPostCreated"` 실행 결과 `BUILD SUCCESSFUL`.
- `./gradlew --no-daemon test --tests "kr.co.vividnext.sodalive.v2.api.home.following.adapter.in.web.HomeFollowingEndToEndTest.shouldAssembleFollowingTabForMember"` 실행 결과 `BUILD SUCCESSFUL`.
- Phase 5.5 집중 회귀 명령 `./gradlew --no-daemon test --tests "kr.co.vividnext.sodalive.v2.api.home.following.dto.HomeFollowingTabResponseTest" --tests "kr.co.vividnext.sodalive.v2.home.following.adapter.out.persistence.DefaultHomeFollowingQueryRepositoryTest" --tests "kr.co.vividnext.sodalive.explorer.profile.creatorCommunity.CreatorCommunityServiceTest" --tests "kr.co.vividnext.sodalive.v2.api.home.following.adapter.in.web.HomeFollowingEndToEndTest"` 실행 결과 `BUILD SUCCESSFUL`.
- `./gradlew --no-daemon ktlintCheck` 실행 결과 import 정렬 위반 2건을 수정한 뒤 재실행 결과 `BUILD SUCCESSFUL`.
- 리뷰 보완: `CREATOR_RANKING``rank`가 있는 row만 노출하도록 수정했고, `AUDIO_CONTENT`/`COMMUNITY_POST` 최근 소식은 현재 원천 target의 성인 상태를 함께 반영하도록 보강했다.
- 리뷰 보완: `HomeFollowingEndToEndTest``CREATOR_RANKING`, `AUDIO_CONTENT`, 무료 `COMMUNITY_POST`, 유료 커뮤니티 미노출 JSON surface를 모두 검증하도록 fixture/assertion을 확장했다.
- 리뷰 보완 후 `./gradlew --no-daemon test --tests "kr.co.vividnext.sodalive.v2.home.following.adapter.out.persistence.DefaultHomeFollowingQueryRepositoryTest"` 실행 결과 `BUILD SUCCESSFUL`.
- 리뷰 보완 후 `./gradlew --no-daemon test --tests "kr.co.vividnext.sodalive.v2.api.home.following.adapter.in.web.HomeFollowingEndToEndTest"` 실행 결과 `BUILD SUCCESSFUL`.
- 리뷰 보완 후 Phase 5.5 집중 회귀 명령 `./gradlew --no-daemon test --tests "kr.co.vividnext.sodalive.v2.api.home.following.dto.HomeFollowingTabResponseTest" --tests "kr.co.vividnext.sodalive.v2.home.following.adapter.out.persistence.DefaultHomeFollowingQueryRepositoryTest" --tests "kr.co.vividnext.sodalive.explorer.profile.creatorCommunity.CreatorCommunityServiceTest" --tests "kr.co.vividnext.sodalive.v2.home.following.application.HomeFollowingNewsPublishServiceTest" --tests "kr.co.vividnext.sodalive.v2.api.home.following.adapter.in.web.HomeFollowingEndToEndTest"` 실행 결과 `BUILD SUCCESSFUL`.
- 리뷰 보완 후 `./gradlew --no-daemon ktlintCheck` 실행 결과 `BUILD SUCCESSFUL`.
- 응답 단순화: `HomeFollowingContentNews`/`FollowingContentNewsResponse``releaseDate`를 제거하고 콘텐츠 공개 시각은 최상위 `visibleFromAtUtc`를 사용하도록 계약과 테스트를 갱신했다.

View File

@@ -175,22 +175,22 @@
- API 조회는 `memberId = 요청 회원 id`, `isActive = true`, `visibleFromAtUtc <= nowUtc`인 inbox row를 최신순으로 조회한다.
- 조회 정렬은 `visibleFromAtUtc desc`, `newsId desc`를 기본으로 한다.
- 조회 시 원천 target의 비활성/삭제 여부, 차단 관계, 성인 노출 가능 여부를 최종 확인한다.
- 응답 필드는 `newsId`, `type`, `creatorProfileImageUrl`, `creatorNickname`, `title`, `body`, `thumbnailImageUrl`, `targetId`, `occurredAtUtc`, `visibleFromAtUtc`, `rank` 포함한다.
- 응답에는 `creatorId`를 별도 필드로 내려주지 않는다.
- `CREATOR_RANKING` 터치 액션은 해당 크리에이터 채널 이동이므로 `targetId`는 크리에이터 회원 id다.
- `CONTENT_RANKING` 터치 액션은 향후 콘텐츠 상세 이동이므로 `targetId`는 콘텐츠 id로 정의한다.
- `COMMUNITY_POST` 터치 액션은 게시글 상세 이동이므로 `targetId`는 커뮤니티 게시글 id다.
- `AUDIO_CONTENT` 터치 액션은 오디오 상세 이동이므로 `targetId`는 오디오 콘텐츠 id다.
- `PHOTO_CONTENT` 터치 액션은 향후 화보 상세 이동이므로 `targetId`는 화보 콘텐츠 id로 정의한다.
- 화면의 상대 시간 표시는 `visibleFromAtUtc` 기준을 기본으로 한다.
- 커뮤니티 게시글 업로드 소식의 `occurredAtUtc` `visibleFromAtUtc`는 게시글 생성 시각을 기본값으로 한다.
- 오디오 콘텐츠 업로드 소식의 `occurredAtUtc`는 콘텐츠 업로드 또는 공개 예약 생성 시각, `visibleFromAtUtc`는 콘텐츠 공개 시각을 기본으로 한다.
- 즉시 공개 콘텐츠는 `visibleFromAtUtc = occurredAtUtc`로 저장할 수 있다.
- `FollowingNewsResponse` 최상위 응답 필드는 `newsId`, `type`, `visibleFromAtUtc`만 공통으로 포함한다.
- 타입별 세부 값은 nullable nested DTO로 내려주며, `type`과 일치하는 nested DTO만 non-null이고 나머지는 `null`다.
- `CREATOR_RANKING``creatorRanking``rank`, `creatorId`, `nickname`, `profileImageUrl`을 포함한다.
- `CONTENT_RANKING``contentRanking``rank`, `contentId`, `contentImageUrl`, `title`을 포함한다.
- `AUDIO_CONTENT``audioContent``contentId`, `contentImageUrl`, `title`, `creatorProfileImageUrl`, `creatorNickname`을 포함한다. 콘텐츠 공개 시각은 최상위 `visibleFromAtUtc`를 사용한다.
- `PHOTO_CONTENT``photoContent``contentId`, `contentImageUrl`, `title`, `creatorProfileImageUrl`, `creatorNickname`을 포함한다. 콘텐츠 공개 시각은 최상위 `visibleFromAtUtc`를 사용한다.
- `COMMUNITY_POST``communityPost``postId`, `creatorProfileImage`, `creatorNickname`, `imageUrl`, `content`, `createdAt`, `likeCount`, `commentCount`를 포함한다. `imageUrl`은 nullable이고 `createdAt` timezone은 UTC다.
- `COMMUNITY_POST` 최근 소식은 무료 커뮤니티 게시글만 발행한다. 유료 커뮤니티 게시글은 팔로잉 최근 소식 inbox row를 생성하지 않는다.
- 타입별 터치 액션 target은 각 nested DTO의 id를 사용한다. `creatorRanking.creatorId`, `contentRanking.contentId`, `audioContent.contentId`, `photoContent.contentId`, `communityPost.postId`가 이동 대상 id다.
- 화면의 상대 시간 표시는 최상위 `visibleFromAtUtc` 기준을 기본으로 한다.
- 커뮤니티 게시글 업로드 소식의 `visibleFromAtUtc``communityPost.createdAt`은 게시글 생성 시각을 기본값으로 한다.
- 오디오/화보 콘텐츠 업로드 소식의 `visibleFromAtUtc`는 콘텐츠 공개 시각을 기본값으로 한다.
- 즉시 공개 콘텐츠는 `visibleFromAtUtc = releaseDate`로 저장할 수 있다.
- 크리에이터 랭킹 소식은 크리에이터 랭킹 스냅샷 생성 시 inbox row를 생성할 수 있으나, `visibleFromAtUtc`는 랭킹 스냅샷의 `visibleFromAtUtc`를 그대로 사용한다.
- 크리에이터 랭킹 스냅샷이 월요일 01:00 KST에 생성되고 월요일 09:00 KST에 화면 반영되는 경우, `CREATOR_RANKING` inbox row도 월요일 09:00 KST 전에는 API에 노출되지 않아야 한다.
- 최근 소식에서 순위 변화와 신규 진입 여부는 사용하지 않는다.
- 랭킹 소식은 이번에 몇 위에 올랐는지를 나타내는 `rank`를 내려준다.
- `COMMUNITY_POST`, `AUDIO_CONTENT`, `PHOTO_CONTENT``rank``null`로 내려준다.
- 최근 소식에서 순위 변화와 신규 진입 여부는 사용하지 않는다. 랭킹 타입은 nested DTO의 `rank`만 내려준다.
#### Edge Cases
- inbox row가 없거나 필터링 후 결과가 없으면 빈 배열을 내려준다.
@@ -198,7 +198,8 @@
- 랭킹 소식의 순위 값이 없거나 오래된 경우 해당 item은 생성하지 않는다.
- 같은 회원, 같은 소식 타입, 같은 `sourceKey`에 대해 중복 inbox row를 생성하지 않는다.
- 언팔로우와 inbox 적재가 동시에 발생하면, 최종적으로 언팔로우 상태인 크리에이터의 새 소식은 노출하지 않는다.
- 콘텐츠 썸네일이 없으면 `thumbnailImageUrl` `null`로 내려준다.
- 타입별 이미지가 없으면 해당 nested DTO의 이미지 URL 필드는 `null`로 내려준다.
- 무료 커뮤니티 게시글이 생성되더라도 조회 시 원천 게시글이 비활성화되었거나 성인/차단 정책에 의해 제외되면 `COMMUNITY_POST` 최근 소식은 노출하지 않는다.
### Feature G. Response 재사용 정책
@@ -275,15 +276,45 @@ data class FollowingScheduleResponse(
data class FollowingNewsResponse(
val newsId: String,
val type: FollowingNewsType,
val creatorProfileImageUrl: String,
val creatorNickname: String,
val title: String,
val body: String,
val thumbnailImageUrl: String?,
val targetId: Long,
val occurredAtUtc: String,
val visibleFromAtUtc: String,
val rank: Int?
val creatorRanking: FollowingCreatorRankingNewsResponse?,
val audioContent: FollowingContentNewsResponse?,
val photoContent: FollowingContentNewsResponse?,
val contentRanking: FollowingContentRankingNewsResponse?,
val communityPost: FollowingCommunityPostNewsResponse?
)
data class FollowingCreatorRankingNewsResponse(
val rank: Int,
val creatorId: Long,
val nickname: String,
val profileImageUrl: String
)
data class FollowingContentNewsResponse(
val contentId: Long,
val contentImageUrl: String?,
val title: String,
val creatorProfileImageUrl: String,
val creatorNickname: String
)
data class FollowingContentRankingNewsResponse(
val rank: Int,
val contentId: Long,
val contentImageUrl: String?,
val title: String
)
data class FollowingCommunityPostNewsResponse(
val postId: Long,
val creatorProfileImage: String,
val creatorNickname: String,
val imageUrl: String?,
val content: String,
val createdAt: String,
val likeCount: Int,
val commentCount: Int
)
enum class FollowingNewsType {
@@ -297,7 +328,7 @@ enum class FollowingNewsType {
```
- `ChatRoomListItemResponse`는 기존 `v2.chat.dto` 응답 DTO를 직접 재사용한다.
- `scheduleId``newsId`는 서로 다른 원천 타입의 id 충돌을 피하기 위해 `{TYPE}:{targetId}` 형식의 문자열을 기본안으로 한다.
- `scheduleId``newsId`는 서로 다른 원천 타입의 id 충돌을 피하기 위해 `{TYPE}:{targetId}` 형식의 문자열을 기본안으로 한다. 최근 소식의 이동 대상 id는 타입별 nested DTO 안의 id 필드를 사용한다.
---
@@ -332,11 +363,11 @@ enum class FollowingNewsType {
- MySQL DDL은 `docs/20260625_메인_홈_팔로잉_탭_API/create-home-following-news-inbox-table.sql`에 기록한다.
- inbox는 사용자별 소식 저장소다.
- inbox table의 `creator_id`는 언팔로우 비활성화, 차단 관계 확인, 운영 조회를 위한 내부 컬럼이며 공개 응답의 별도 `creatorId` 필드로 내려주지 않는다.
- 커뮤니티/콘텐츠 업로드 소식은 업로드 또는 공개 이벤트에서 현재 follower 회원별로 적재한다.
- 콘텐츠 업로드 소식은 업로드 또는 공개 이벤트에서 현재 follower 회원별로 적재한다. 커뮤니티 업로드 소식은 무료 커뮤니티 게시글 생성 이벤트에서만 현재 follower 회원별로 적재한다.
- 크리에이터 랭킹 소식은 크리에이터 랭킹 스냅샷 생성 시점에 현재 follower 회원별로 적재하되, `visibleFromAtUtc`는 랭킹 스냅샷의 공개 시각을 사용한다.
- 이번 구현은 외부 MQ, outbox table, 별도 worker 없이 내부 publish service에서 follower 조회와 inbox bulk insert를 수행하는 최소 구조로 한다.
- 콘텐츠/커뮤니티/랭킹 생성 로직은 inbox 저장소를 직접 호출하지 않고 publish service만 호출한다.
- publish service는 `publishContentUploaded(...)`, `publishCommunityPostCreated(...)`, `publishCreatorRankingVisible(...)`처럼 이벤트별 명시적 메서드를 제공한다.
- publish service는 `publishContentUploaded(...)`, `publishFreeCommunityPostCreated(...)`, `publishCreatorRankingVisible(...)`처럼 이벤트별 명시적 메서드를 제공한다. 유료 커뮤니티 게시글은 publish service 호출 대상이 아니다.
- 운영 규모가 커지면 publish service 내부에서 outbox row 저장 또는 비동기 worker 위임으로 전환할 수 있도록 호출부 계약을 작게 유지한다.
- `CREATOR_RANKING` 타입은 크리에이터 랭킹 소식만 포함한다.
- `CONTENT_RANKING` 타입은 향후 콘텐츠 랭킹 소식용으로 enum과 table 값만 예약하고, 이번 범위에서는 생성하지 않는다.

View File

@@ -27,7 +27,7 @@ v2 API에서 커뮤니티 게시글 item을 내려줄 때 인증 조회자가
- 커뮤니티 게시글 좋아요 생성/취소 API 동작은 변경하지 않는다.
- `likeCount` 집계 기준은 변경하지 않는다.
- legacy 커뮤니티 API 응답 스키마는 변경하지 않는다.
- 팔로잉 뉴스의 `COMMUNITY_POST` news item에는 게시글 카드 필드가 없으므로 이번 범위에서 제외다.
- 팔로잉 뉴스의 `COMMUNITY_POST` news item은 이 문서 작성 당시 게시글 카드 필드가 없 이번 범위에서 제외했다. 이후 `FollowingNewsResponse.communityPost` nested DTO 계약으로 변경하며 무료 커뮤니티 게시글에 한해 `likeCount`, `commentCount`를 포함하도록 별도 후속 범위에서 다룬다.
- DB schema, 운영 DDL, 마이그레이션은 포함하지 않는다.
- 추천 스냅샷 산정 로직과 인기 커뮤니티 정렬 정책은 변경하지 않는다.
@@ -76,7 +76,7 @@ v2 API에서 커뮤니티 게시글 item을 내려줄 때 인증 조회자가
### 제외 대상
- `HomeFollowingNews` / `FollowingNewsResponse`
- `FollowingNewsType.COMMUNITY_POST``targetId`지만 게시글 카드 DTO가 아니`likeCount`도 제공하지 않는다.
- 이 문서 작성 당시에는 `FollowingNewsType.COMMUNITY_POST``targetId`었고 게시글 카드 DTO가 아니어서 제외했다. 이후 팔로잉 뉴스 응답 계약 변경으로 `communityPost` nested DTO가 무료 커뮤니티 게시글의 `postId`, `creatorProfileImage`, `creatorNickname`, `imageUrl`, `content`, `createdAt`, `likeCount`, `commentCount`를 제공하도록 별도 후속 작업에서 갱신한다.
---

View File

@@ -145,6 +145,8 @@ class CreatorCommunityService(
}
private fun publishCommunityPostCreatedAfterCommit(post: CreatorCommunity, member: Member) {
if (post.price > 0) return
val occurredAtUtc = post.createdAt ?: LocalDateTime.now()
val newsContent = post.newsContentPreview()
afterCommit {

View File

@@ -5,7 +5,11 @@ import kr.co.vividnext.sodalive.v2.chat.dto.ChatRoomListItemResponse
import kr.co.vividnext.sodalive.v2.common.domain.CreatorActivityType
import kr.co.vividnext.sodalive.v2.home.following.domain.FollowingNewsType
import kr.co.vividnext.sodalive.v2.home.following.domain.HomeFollowing
import kr.co.vividnext.sodalive.v2.home.following.domain.HomeFollowingCommunityPostNews
import kr.co.vividnext.sodalive.v2.home.following.domain.HomeFollowingContentNews
import kr.co.vividnext.sodalive.v2.home.following.domain.HomeFollowingContentRankingNews
import kr.co.vividnext.sodalive.v2.home.following.domain.HomeFollowingCreator
import kr.co.vividnext.sodalive.v2.home.following.domain.HomeFollowingCreatorRankingNews
import kr.co.vividnext.sodalive.v2.home.following.domain.HomeFollowingLive
import kr.co.vividnext.sodalive.v2.home.following.domain.HomeFollowingNews
import kr.co.vividnext.sodalive.v2.home.following.domain.HomeFollowingSchedule
@@ -112,31 +116,91 @@ data class FollowingScheduleResponse(
data class FollowingNewsResponse(
val newsId: String,
val type: FollowingNewsType,
val creatorProfileImageUrl: String,
val creatorNickname: String,
val title: String,
val body: String,
val thumbnailImageUrl: String?,
val targetId: Long,
val occurredAtUtc: String,
val visibleFromAtUtc: String,
val rank: Int?
val creatorRanking: FollowingCreatorRankingNewsResponse?,
val audioContent: FollowingContentNewsResponse?,
val photoContent: FollowingContentNewsResponse?,
val contentRanking: FollowingContentRankingNewsResponse?,
val communityPost: FollowingCommunityPostNewsResponse?
) {
companion object {
fun from(news: HomeFollowingNews): FollowingNewsResponse {
return FollowingNewsResponse(
newsId = news.newsId,
type = news.type,
creatorProfileImageUrl = news.creatorProfileImageUrl,
creatorNickname = news.creatorNickname,
title = news.title,
body = news.body,
thumbnailImageUrl = news.thumbnailImageUrl,
targetId = news.targetId,
occurredAtUtc = news.occurredAtUtc,
visibleFromAtUtc = news.visibleFromAtUtc,
rank = news.rank
creatorRanking = news.creatorRanking?.toResponse(),
audioContent = news.audioContent?.toContentResponse(),
photoContent = news.photoContent?.toContentResponse(),
contentRanking = news.contentRanking?.toResponse(),
communityPost = news.communityPost?.toResponse()
)
}
}
}
data class FollowingCreatorRankingNewsResponse(
val rank: Int,
val creatorId: Long,
val nickname: String,
val profileImageUrl: String
)
data class FollowingContentNewsResponse(
val contentId: Long,
val contentImageUrl: String?,
val title: String,
val creatorProfileImageUrl: String,
val creatorNickname: String
)
data class FollowingContentRankingNewsResponse(
val rank: Int,
val contentId: Long,
val contentImageUrl: String?,
val title: String
)
data class FollowingCommunityPostNewsResponse(
val postId: Long,
val creatorProfileImage: String,
val creatorNickname: String,
val imageUrl: String?,
val content: String,
val createdAt: String,
val likeCount: Int,
val commentCount: Int
)
private fun HomeFollowingCreatorRankingNews.toResponse() = FollowingCreatorRankingNewsResponse(
rank = rank,
creatorId = creatorId,
nickname = nickname,
profileImageUrl = profileImageUrl
)
private fun HomeFollowingContentNews.toContentResponse() = FollowingContentNewsResponse(
contentId = contentId,
contentImageUrl = contentImageUrl,
title = title,
creatorProfileImageUrl = creatorProfileImageUrl,
creatorNickname = creatorNickname
)
private fun HomeFollowingContentRankingNews.toResponse() = FollowingContentRankingNewsResponse(
rank = rank,
contentId = contentId,
contentImageUrl = contentImageUrl,
title = title
)
private fun HomeFollowingCommunityPostNews.toResponse() = FollowingCommunityPostNewsResponse(
postId = postId,
creatorProfileImage = creatorProfileImage,
creatorNickname = creatorNickname,
imageUrl = imageUrl,
content = content,
createdAt = createdAt,
likeCount = likeCount,
commentCount = commentCount
)

View File

@@ -8,6 +8,8 @@ import com.querydsl.jpa.impl.JPAQueryFactory
import kr.co.vividnext.sodalive.content.QAudioContent
import kr.co.vividnext.sodalive.content.QAudioContent.audioContent
import kr.co.vividnext.sodalive.explorer.profile.creatorCommunity.QCreatorCommunity
import kr.co.vividnext.sodalive.explorer.profile.creatorCommunity.comment.QCreatorCommunityComment.creatorCommunityComment
import kr.co.vividnext.sodalive.explorer.profile.creatorCommunity.like.QCreatorCommunityLike.creatorCommunityLike
import kr.co.vividnext.sodalive.extensions.toUtcIso
import kr.co.vividnext.sodalive.live.room.QLiveRoom.liveRoom
import kr.co.vividnext.sodalive.member.MemberRole
@@ -18,7 +20,10 @@ import kr.co.vividnext.sodalive.v2.common.domain.CreatorActivityType
import kr.co.vividnext.sodalive.v2.common.domain.toCdnUrl
import kr.co.vividnext.sodalive.v2.home.following.adapter.out.persistence.QHomeFollowingNewsInbox.homeFollowingNewsInbox
import kr.co.vividnext.sodalive.v2.home.following.domain.FollowingNewsType
import kr.co.vividnext.sodalive.v2.home.following.domain.HomeFollowingCommunityPostNews
import kr.co.vividnext.sodalive.v2.home.following.domain.HomeFollowingContentNews
import kr.co.vividnext.sodalive.v2.home.following.domain.HomeFollowingCreator
import kr.co.vividnext.sodalive.v2.home.following.domain.HomeFollowingCreatorRankingNews
import kr.co.vividnext.sodalive.v2.home.following.domain.HomeFollowingLive
import kr.co.vividnext.sodalive.v2.home.following.domain.HomeFollowingNews
import kr.co.vividnext.sodalive.v2.home.following.domain.HomeFollowingSchedule
@@ -116,50 +121,109 @@ class DefaultHomeFollowingQueryRepository(
limit: Int
): List<HomeFollowingNews> {
val creator = QMember("newsCreator")
return queryFactory
val newsAudioContent = QAudioContent("recentNewsAudioContent")
val newsCommunity = QCreatorCommunity("recentNewsCommunity")
val rows = queryFactory
.select(
homeFollowingNewsInbox.id,
homeFollowingNewsInbox.newsType,
homeFollowingNewsInbox.creatorProfileImagePath,
homeFollowingNewsInbox.creatorNickname,
homeFollowingNewsInbox.title,
homeFollowingNewsInbox.body,
homeFollowingNewsInbox.thumbnailImagePath,
creator.profileImage,
creator.nickname,
homeFollowingNewsInbox.targetId,
homeFollowingNewsInbox.occurredAtUtc,
homeFollowingNewsInbox.visibleFromAtUtc,
homeFollowingNewsInbox.rank
homeFollowingNewsInbox.rank,
newsAudioContent.title,
newsAudioContent.coverImage,
newsCommunity.content,
newsCommunity.imagePath,
newsCommunity.createdAt
)
.from(homeFollowingNewsInbox)
.join(creator).on(creator.id.eq(homeFollowingNewsInbox.creatorId))
.leftJoin(newsAudioContent).on(
homeFollowingNewsInbox.newsType.eq(FollowingNewsType.AUDIO_CONTENT),
newsAudioContent.id.eq(homeFollowingNewsInbox.targetId)
)
.leftJoin(newsCommunity).on(
homeFollowingNewsInbox.newsType.eq(FollowingNewsType.COMMUNITY_POST),
newsCommunity.id.eq(homeFollowingNewsInbox.targetId)
)
.where(
homeFollowingNewsInbox.memberId.eq(memberId),
homeFollowingNewsInbox.isActive.isTrue,
homeFollowingNewsInbox.visibleFromAtUtc.loe(nowUtc),
creator.isActive.isTrue,
creator.role.eq(MemberRole.CREATOR),
activeFollowingCondition(memberId, homeFollowingNewsInbox.creatorId),
adultNewsCondition(canViewAdultContent),
notBlockedCreatorCondition(memberId, homeFollowingNewsInbox.creatorId),
activeNewsTargetCondition()
activeNewsTargetCondition(canViewAdultContent)
)
.orderBy(homeFollowingNewsInbox.visibleFromAtUtc.desc(), homeFollowingNewsInbox.id.desc())
.limit(limit.toLong())
.fetch()
.map { row ->
HomeFollowingNews(
newsId = row.get(homeFollowingNewsInbox.id)!!.toString(),
type = row.get(homeFollowingNewsInbox.newsType)!!,
creatorProfileImageUrl = profileImageUrl(row.get(homeFollowingNewsInbox.creatorProfileImagePath)),
creatorNickname = row.get(homeFollowingNewsInbox.creatorNickname)!!,
title = row.get(homeFollowingNewsInbox.title)!!,
body = row.get(homeFollowingNewsInbox.body)!!,
thumbnailImageUrl = row.get(homeFollowingNewsInbox.thumbnailImagePath).toCdnUrl(cloudFrontHost),
targetId = row.get(homeFollowingNewsInbox.targetId)!!,
occurredAtUtc = row.get(homeFollowingNewsInbox.occurredAtUtc)!!.toUtcIso(),
visibleFromAtUtc = row.get(homeFollowingNewsInbox.visibleFromAtUtc)!!.toUtcIso(),
rank = row.get(homeFollowingNewsInbox.rank)
val communityPostIds = rows
.filter { it.get(homeFollowingNewsInbox.newsType) == FollowingNewsType.COMMUNITY_POST }
.map { it.get(homeFollowingNewsInbox.targetId)!! }
val likeCounts = communityLikeCounts(communityPostIds)
val commentCounts = communityCommentCounts(communityPostIds)
return rows.map { row -> row.toHomeFollowingNews(creator, newsAudioContent, newsCommunity, likeCounts, commentCounts) }
}
private fun Tuple.toHomeFollowingNews(
newsCreator: QMember,
newsAudioContent: QAudioContent,
newsCommunity: QCreatorCommunity,
likeCounts: Map<Long, Int>,
commentCounts: Map<Long, Int>
): HomeFollowingNews {
val type = get(homeFollowingNewsInbox.newsType)!!
val targetId = get(homeFollowingNewsInbox.targetId)!!
val creatorProfileImageUrl = profileImageUrl(get(newsCreator.profileImage))
val creatorNickname = get(newsCreator.nickname)!!
val visibleFromAtUtc = get(homeFollowingNewsInbox.visibleFromAtUtc)!!.toUtcIso()
val rank = get(homeFollowingNewsInbox.rank)
return HomeFollowingNews(
newsId = get(homeFollowingNewsInbox.id)!!.toString(),
type = type,
visibleFromAtUtc = visibleFromAtUtc,
creatorRanking = if (type == FollowingNewsType.CREATOR_RANKING && rank != null) {
HomeFollowingCreatorRankingNews(
rank = rank,
creatorId = targetId,
nickname = creatorNickname,
profileImageUrl = creatorProfileImageUrl
)
} else {
null
},
audioContent = if (type == FollowingNewsType.AUDIO_CONTENT) {
HomeFollowingContentNews(
contentId = targetId,
contentImageUrl = get(newsAudioContent.coverImage).toCdnUrl(cloudFrontHost),
title = get(newsAudioContent.title)!!,
creatorProfileImageUrl = creatorProfileImageUrl,
creatorNickname = creatorNickname
)
} else {
null
},
communityPost = if (type == FollowingNewsType.COMMUNITY_POST) {
HomeFollowingCommunityPostNews(
postId = targetId,
creatorProfileImage = creatorProfileImageUrl,
creatorNickname = creatorNickname,
imageUrl = get(newsCommunity.imagePath).toCdnUrl(cloudFrontHost),
content = get(newsCommunity.content)!!,
createdAt = get(newsCommunity.createdAt)!!.toUtcIso(),
likeCount = likeCounts[targetId] ?: 0,
commentCount = commentCounts[targetId] ?: 0
)
} else {
null
}
)
}
private fun findLiveSchedules(
@@ -284,7 +348,44 @@ class DefaultHomeFollowingQueryRepository(
return if (canViewAdultContent) null else homeFollowingNewsInbox.isAdult.isFalse
}
private fun activeNewsTargetCondition(): BooleanExpression {
private fun communityLikeCounts(postIds: List<Long>): Map<Long, Int> {
if (postIds.isEmpty()) return emptyMap()
return queryFactory
.select(creatorCommunityLike.creatorCommunity.id, creatorCommunityLike.id.count())
.from(creatorCommunityLike)
.where(
creatorCommunityLike.creatorCommunity.id.`in`(postIds),
creatorCommunityLike.isActive.isTrue
)
.groupBy(creatorCommunityLike.creatorCommunity.id)
.fetch()
.associate {
it.get(creatorCommunityLike.creatorCommunity.id)!! to
(it.get(creatorCommunityLike.id.count())?.toInt() ?: 0)
}
}
private fun communityCommentCounts(postIds: List<Long>): Map<Long, Int> {
if (postIds.isEmpty()) return emptyMap()
return queryFactory
.select(creatorCommunityComment.creatorCommunity.id, creatorCommunityComment.id.count())
.from(creatorCommunityComment)
.where(
creatorCommunityComment.creatorCommunity.id.`in`(postIds),
creatorCommunityComment.isActive.isTrue,
creatorCommunityComment.parent.isNull
)
.groupBy(creatorCommunityComment.creatorCommunity.id)
.fetch()
.associate {
it.get(creatorCommunityComment.creatorCommunity.id)!! to
(it.get(creatorCommunityComment.id.count())?.toInt() ?: 0)
}
}
private fun activeNewsTargetCondition(canViewAdultContent: Boolean): BooleanExpression {
val newsAudioContent = QAudioContent("newsAudioContent")
val newsCommunity = QCreatorCommunity("newsCommunity")
val activeAudioExists = JPAExpressions
@@ -292,7 +393,8 @@ class DefaultHomeFollowingQueryRepository(
.from(newsAudioContent)
.where(
newsAudioContent.id.eq(homeFollowingNewsInbox.targetId),
newsAudioContent.isActive.isTrue
newsAudioContent.isActive.isTrue,
adultAudioNewsTargetCondition(canViewAdultContent, newsAudioContent)
)
.exists()
val activeCommunityExists = JPAExpressions
@@ -300,15 +402,44 @@ class DefaultHomeFollowingQueryRepository(
.from(newsCommunity)
.where(
newsCommunity.id.eq(homeFollowingNewsInbox.targetId),
newsCommunity.isActive.isTrue
newsCommunity.isActive.isTrue,
newsCommunity.price.loe(0),
adultCommunityNewsTargetCondition(canViewAdultContent, newsCommunity)
)
.exists()
return homeFollowingNewsInbox.newsType.eq(FollowingNewsType.CREATOR_RANKING)
.and(homeFollowingNewsInbox.rank.isNotNull)
.or(homeFollowingNewsInbox.newsType.eq(FollowingNewsType.AUDIO_CONTENT).and(activeAudioExists))
.or(homeFollowingNewsInbox.newsType.eq(FollowingNewsType.COMMUNITY_POST).and(activeCommunityExists))
}
private fun adultAudioNewsTargetCondition(
canViewAdultContent: Boolean,
newsAudioContent: QAudioContent
): BooleanExpression? {
return if (canViewAdultContent) null else newsAudioContent.isAdult.isFalse
}
private fun adultCommunityNewsTargetCondition(
canViewAdultContent: Boolean,
newsCommunity: QCreatorCommunity
): BooleanExpression? {
return if (canViewAdultContent) null else newsCommunity.isAdult.isFalse
}
private fun activeFollowingCondition(memberId: Long, creatorIdPath: Expression<Long>): BooleanExpression {
return JPAExpressions
.selectOne()
.from(creatorFollowing)
.where(
creatorFollowing.member.id.eq(memberId),
creatorFollowing.creator.id.eq(creatorIdPath),
creatorFollowing.isActive.isTrue
)
.exists()
}
private fun notBlockedCreatorCondition(memberId: Long, creatorIdPath: Expression<Long>): BooleanExpression {
val blockMember = QBlockMember("homeFollowingBlockMember")
return JPAExpressions

View File

@@ -40,13 +40,43 @@ data class HomeFollowingSchedule(
data class HomeFollowingNews(
val newsId: String,
val type: FollowingNewsType,
val creatorProfileImageUrl: String,
val creatorNickname: String,
val title: String,
val body: String,
val thumbnailImageUrl: String?,
val targetId: Long,
val occurredAtUtc: String,
val visibleFromAtUtc: String,
val rank: Int?
val creatorRanking: HomeFollowingCreatorRankingNews? = null,
val audioContent: HomeFollowingContentNews? = null,
val photoContent: HomeFollowingContentNews? = null,
val contentRanking: HomeFollowingContentRankingNews? = null,
val communityPost: HomeFollowingCommunityPostNews? = null
)
data class HomeFollowingCreatorRankingNews(
val rank: Int,
val creatorId: Long,
val nickname: String,
val profileImageUrl: String
)
data class HomeFollowingContentNews(
val contentId: Long,
val contentImageUrl: String?,
val title: String,
val creatorProfileImageUrl: String,
val creatorNickname: String
)
data class HomeFollowingContentRankingNews(
val rank: Int,
val contentId: Long,
val contentImageUrl: String?,
val title: String
)
data class HomeFollowingCommunityPostNews(
val postId: Long,
val creatorProfileImage: String,
val creatorNickname: String,
val imageUrl: String?,
val content: String,
val createdAt: String,
val likeCount: Int,
val commentCount: Int
)

View File

@@ -330,15 +330,14 @@ class CreatorCommunityServiceTest {
}
@Test
@DisplayName("유료 커뮤니티 게시글 최근 소식은 전체 본문을 노출하지 않고 미리보기만 발행한")
fun shouldPublishPaidCommunityPostNewsWithMaskedContent() {
@DisplayName("유료 커뮤니티 게시글 생성 성공 후 최근 소식을 발행하지 않는")
fun shouldNotPublishNewsAfterPaidCommunityPostCreated() {
val creator = createMember(id = 910L, role = MemberRole.CREATOR, nickname = "paid-community-creator")
val fullContent = "유료 커뮤니티 게시글 전체 본문은 최근 소식에서 노출되면 안 됩니다"
val createdAt = LocalDateTime.of(2026, 6, 25, 11, 0)
Mockito.`when`(repository.save(Mockito.any(CreatorCommunity::class.java))).thenAnswer { invocation ->
val post = invocation.getArgument<CreatorCommunity>(0)
post.id = 911L
post.createdAt = createdAt
post.createdAt = LocalDateTime.of(2026, 6, 25, 11, 0)
post
}
Mockito.`when`(
@@ -357,17 +356,7 @@ class CreatorCommunityServiceTest {
member = creator
)
Mockito.verify(homeFollowingNewsPublishService).publishCommunityPostCreated(
postId = 911L,
creatorId = creator.id!!,
creatorNickname = creator.nickname!!,
creatorProfileImagePath = creator.profileImage,
title = "유료 커뮤니티 게시글 전체 ...",
body = "유료 커뮤니티 게시글 전체 ...",
thumbnailImagePath = "creator_community/911/911-image.png",
occurredAtUtc = createdAt,
isAdult = false
)
Mockito.verifyNoInteractions(homeFollowingNewsPublishService)
}
@Test

View File

@@ -3,6 +3,9 @@ package kr.co.vividnext.sodalive.v2.api.home.following.adapter.`in`.web
import kr.co.vividnext.sodalive.common.CountryContext
import kr.co.vividnext.sodalive.content.AudioContent
import kr.co.vividnext.sodalive.content.theme.AudioContentTheme
import kr.co.vividnext.sodalive.explorer.profile.creatorCommunity.CreatorCommunity
import kr.co.vividnext.sodalive.explorer.profile.creatorCommunity.comment.CreatorCommunityComment
import kr.co.vividnext.sodalive.explorer.profile.creatorCommunity.like.CreatorCommunityLike
import kr.co.vividnext.sodalive.live.room.LiveRoom
import kr.co.vividnext.sodalive.member.Member
import kr.co.vividnext.sodalive.member.MemberAdapter
@@ -87,10 +90,34 @@ class HomeFollowingEndToEndTest @Autowired constructor(
.andExpect(jsonPath("$.data.monthlySchedules[0].scheduleId").value("LIVE:${fixture.liveId}"))
.andExpect(jsonPath("$.data.monthlySchedules[1].scheduleId").value("AUDIO:${fixture.audioId}"))
.andExpect(jsonPath("$.data.recentNews[0].newsId").value(fixture.rankedNewsId.toString()))
.andExpect(jsonPath("$.data.recentNews[0].type").value("CREATOR_RANKING"))
.andExpect(jsonPath("$.data.recentNews[0].visibleFromAtUtc").exists())
.andExpect(jsonPath("$.data.recentNews[0].creatorId").doesNotExist())
.andExpect(jsonPath("$.data.recentNews[0].ranking").doesNotExist())
.andExpect(jsonPath("$.data.recentNews[0].rank").value(7))
.andExpect(jsonPath("$.data.recentNews[1].rank").value(nullValue()))
.andExpect(jsonPath("$.data.recentNews[0].rank").doesNotExist())
.andExpect(jsonPath("$.data.recentNews[0].creatorRanking.rank").value(7))
.andExpect(jsonPath("$.data.recentNews[0].creatorRanking.creatorId").value(fixture.creatorId))
.andExpect(jsonPath("$.data.recentNews[0].audioContent").value(nullValue()))
.andExpect(jsonPath("$.data.recentNews[0].communityPost").value(nullValue()))
.andExpect(jsonPath("$.data.recentNews[1].type").value("COMMUNITY_POST"))
.andExpect(jsonPath("$.data.recentNews[1].communityPost.postId").value(fixture.freePostId))
.andExpect(jsonPath("$.data.recentNews[1].communityPost.creatorProfileImage").value("https://cdn.test/creator.png"))
.andExpect(jsonPath("$.data.recentNews[1].communityPost.creatorNickname").value("home-following-creator"))
.andExpect(jsonPath("$.data.recentNews[1].communityPost.imageUrl").value("https://cdn.test/community/free.png"))
.andExpect(jsonPath("$.data.recentNews[1].communityPost.content").value("free community body"))
.andExpect(jsonPath("$.data.recentNews[1].communityPost.createdAt").exists())
.andExpect(jsonPath("$.data.recentNews[1].communityPost.likeCount").value(1))
.andExpect(jsonPath("$.data.recentNews[1].communityPost.commentCount").value(1))
.andExpect(jsonPath("$.data.recentNews[1].creatorRanking").value(nullValue()))
.andExpect(jsonPath("$.data.recentNews[2].type").value("AUDIO_CONTENT"))
.andExpect(jsonPath("$.data.recentNews[2].audioContent.contentId").value(fixture.audioId))
.andExpect(jsonPath("$.data.recentNews[2].audioContent.title").value("home-following-audio"))
.andExpect(
jsonPath("$.data.recentNews[2].audioContent.contentImageUrl")
.value("https://cdn.test/audio/home-following.png")
)
.andExpect(jsonPath("$.data.recentNews[2].audioContent.releaseDate").doesNotExist())
.andExpect(jsonPath("$.data.recentNews[2].communityPost").value(nullValue()))
.andExpect(jsonPath("$.data.recentNews[?(@.communityPost.postId == ${fixture.paidPostId})]").isEmpty)
}
private fun createFixture(): Fixture {
@@ -110,7 +137,36 @@ class HomeFollowingEndToEndTest @Autowired constructor(
val live = saveLiveRoom(creator, scheduleBaseUtc.plusHours(1), channelName = "on-air")
val theme = saveTheme()
val audio = saveAudioContent(creator, theme, scheduleBaseUtc.plusHours(2))
val oldNews = saveNews(viewer.id!!, creator.id!!, "old-news", now.minusHours(2), rank = null)
audio.coverImage = "audio/home-following.png"
val freePost = saveCommunityPost(creator, "free community body", price = 0, imagePath = "community/free.png")
val paidPost = saveCommunityPost(creator, "paid community body", price = 10, imagePath = "community/paid.png")
saveCommunityLike(viewer, freePost, isActive = true)
saveCommunityComment(viewer, freePost, isActive = true)
saveNews(viewer.id!!, creator.id!!, "old-news", now.minusHours(2), rank = null)
val audioNews = saveNews(
memberId = viewer.id!!,
creatorId = creator.id!!,
sourceKey = "audio-news",
visibleFromAtUtc = now.minusHours(3),
newsType = FollowingNewsType.AUDIO_CONTENT,
targetId = audio.id!!
)
val communityNews = saveNews(
memberId = viewer.id!!,
creatorId = creator.id!!,
sourceKey = "free-community-news",
visibleFromAtUtc = now.minusHours(2),
newsType = FollowingNewsType.COMMUNITY_POST,
targetId = freePost.id!!
)
saveNews(
memberId = viewer.id!!,
creatorId = creator.id!!,
sourceKey = "paid-community-news",
visibleFromAtUtc = now.minusMinutes(30),
newsType = FollowingNewsType.COMMUNITY_POST,
targetId = paidPost.id!!
)
val rankedNews = saveNews(viewer.id!!, creator.id!!, "ranked-news", now.minusHours(1), rank = 7)
val chatRoom = saveDmChatRoom(viewer, creator, now.minusMinutes(10))
entityManager.flush()
@@ -123,7 +179,10 @@ class HomeFollowingEndToEndTest @Autowired constructor(
audioId = audio.id!!,
chatRoomId = chatRoom.id!!,
rankedNewsId = rankedNews.id!!,
oldNewsId = oldNews.id!!
audioNewsId = audioNews.id!!,
communityNewsId = communityNews.id!!,
freePostId = freePost.id!!,
paidPostId = paidPost.id!!
)
}!!
}
@@ -192,14 +251,16 @@ class HomeFollowingEndToEndTest @Autowired constructor(
creatorId: Long,
sourceKey: String,
visibleFromAtUtc: LocalDateTime,
rank: Int?
rank: Int? = null,
newsType: FollowingNewsType = FollowingNewsType.CREATOR_RANKING,
targetId: Long = creatorId
): HomeFollowingNewsInbox {
val news = HomeFollowingNewsInbox(
memberId = memberId,
creatorId = creatorId,
newsType = FollowingNewsType.CREATOR_RANKING,
newsType = newsType,
sourceKey = sourceKey,
targetId = creatorId,
targetId = targetId,
occurredAtUtc = visibleFromAtUtc.minusMinutes(30),
visibleFromAtUtc = visibleFromAtUtc,
creatorNickname = "home-following-creator",
@@ -214,6 +275,39 @@ class HomeFollowingEndToEndTest @Autowired constructor(
return news
}
private fun saveCommunityPost(creator: Member, content: String, price: Int, imagePath: String): CreatorCommunity {
val post = CreatorCommunity(
content = content,
price = price,
isCommentAvailable = true,
isAdult = false,
imagePath = imagePath,
isActive = true
).apply {
member = creator
}
entityManager.persist(post)
return post
}
private fun saveCommunityLike(member: Member, post: CreatorCommunity, isActive: Boolean): CreatorCommunityLike {
val like = CreatorCommunityLike(isActive = isActive).apply {
this.member = member
creatorCommunity = post
}
entityManager.persist(like)
return like
}
private fun saveCommunityComment(member: Member, post: CreatorCommunity, isActive: Boolean): CreatorCommunityComment {
val comment = CreatorCommunityComment(comment = "comment", isActive = isActive).apply {
this.member = member
creatorCommunity = post
}
entityManager.persist(comment)
return comment
}
private fun saveDmChatRoom(viewer: Member, creator: Member, messageCreatedAt: LocalDateTime): UserCreatorChatRoom {
val room = UserCreatorChatRoom()
entityManager.persist(room)
@@ -241,6 +335,9 @@ class HomeFollowingEndToEndTest @Autowired constructor(
val audioId: Long,
val chatRoomId: Long,
val rankedNewsId: Long,
val oldNewsId: Long
val audioNewsId: Long,
val communityNewsId: Long,
val freePostId: Long,
val paidPostId: Long
)
}

View File

@@ -10,6 +10,7 @@ import kr.co.vividnext.sodalive.v2.home.following.application.HomeFollowingQuery
import kr.co.vividnext.sodalive.v2.home.following.domain.FollowingNewsType
import kr.co.vividnext.sodalive.v2.home.following.domain.HomeFollowing
import kr.co.vividnext.sodalive.v2.home.following.domain.HomeFollowingCreator
import kr.co.vividnext.sodalive.v2.home.following.domain.HomeFollowingCreatorRankingNews
import kr.co.vividnext.sodalive.v2.home.following.domain.HomeFollowingLive
import kr.co.vividnext.sodalive.v2.home.following.domain.HomeFollowingNews
import kr.co.vividnext.sodalive.v2.home.following.domain.HomeFollowingSchedule
@@ -109,15 +110,13 @@ class HomeFollowingFacadeTest {
HomeFollowingNews(
newsId = "news-5",
type = FollowingNewsType.CREATOR_RANKING,
creatorProfileImageUrl = "https://cdn.test/news.png",
creatorNickname = "creator",
title = "news",
body = "body",
thumbnailImageUrl = null,
targetId = 1L,
occurredAtUtc = "2026-06-25T03:00:00Z",
visibleFromAtUtc = "2026-06-25T04:00:00Z",
rank = 7
creatorRanking = HomeFollowingCreatorRankingNews(
rank = 7,
creatorId = 1L,
nickname = "creator",
profileImageUrl = "https://cdn.test/news.png"
)
)
)
)

View File

@@ -5,7 +5,11 @@ import kr.co.vividnext.sodalive.v2.chat.dto.ChatRoomListItemResponse
import kr.co.vividnext.sodalive.v2.common.domain.CreatorActivityType
import kr.co.vividnext.sodalive.v2.home.following.domain.FollowingNewsType
import kr.co.vividnext.sodalive.v2.home.following.domain.HomeFollowing
import kr.co.vividnext.sodalive.v2.home.following.domain.HomeFollowingCommunityPostNews
import kr.co.vividnext.sodalive.v2.home.following.domain.HomeFollowingContentNews
import kr.co.vividnext.sodalive.v2.home.following.domain.HomeFollowingContentRankingNews
import kr.co.vividnext.sodalive.v2.home.following.domain.HomeFollowingCreator
import kr.co.vividnext.sodalive.v2.home.following.domain.HomeFollowingCreatorRankingNews
import kr.co.vividnext.sodalive.v2.home.following.domain.HomeFollowingLive
import kr.co.vividnext.sodalive.v2.home.following.domain.HomeFollowingNews
import kr.co.vividnext.sodalive.v2.home.following.domain.HomeFollowingSchedule
@@ -39,24 +43,71 @@ class HomeFollowingTabResponseTest {
}
@Test
@DisplayName("팔로잉 탭 도메인은 creatorId 없는 최근 소식과 nullable rank 응답으로 변환한다")
fun shouldMapDomainToResponseWithoutCreatorIdInRecentNews() {
@DisplayName("팔로잉 탭 도메인은 타입별 nested 최근 소식 응답으로 변환한다")
fun shouldMapDomainToNestedRecentNewsResponseByType() {
val response = HomeFollowingTabResponse.from(createHomeFollowing())
val json = objectMapper.readTree(objectMapper.writeValueAsString(response))
val recentNews = json["recentNews"]
assertFalse(response.isLoginRequired)
assertEquals(1L, response.followingCreators.first().creatorId)
assertEquals(10L, response.onAirLives.first().liveId)
assertEquals(100L, response.recentChats.first().roomId)
assertEquals("LIVE:20", response.monthlySchedules.first().scheduleId)
assertEquals(3, response.recentNews.first().rank)
assertEquals(false, json["isLoginRequired"].asBoolean())
assertFalse(json["recentNews"][0].has("creatorId"))
assertFalse(json["recentNews"][0].has("ranking"))
assertFalse(json["recentNews"][0].has("rankChange"))
assertFalse(json["recentNews"][0].has("isNew"))
assertEquals(3, json["recentNews"][0]["rank"].asInt())
assertEquals(true, json["monthlySchedules"][0]["isOnAir"].asBoolean())
val removedTopLevelFields = listOf(
"creatorProfileImageUrl",
"creatorNickname",
"title",
"body",
"thumbnailImageUrl",
"targetId",
"occurredAtUtc",
"rank"
)
removedTopLevelFields.forEach { field ->
assertFalse(recentNews[0].has(field), "recentNews top-level must not expose $field")
}
assertEquals("30", recentNews[0]["newsId"].asText())
assertEquals("CREATOR_RANKING", recentNews[0]["type"].asText())
assertEquals("2026-06-25T09:00:00Z", recentNews[0]["visibleFromAtUtc"].asText())
assertEquals(3, recentNews[0]["creatorRanking"]["rank"].asInt())
assertEquals(1L, recentNews[0]["creatorRanking"]["creatorId"].asLong())
assertEquals("news-creator", recentNews[0]["creatorRanking"]["nickname"].asText())
assertTrue(recentNews[0]["audioContent"].isNull)
assertTrue(recentNews[0]["photoContent"].isNull)
assertTrue(recentNews[0]["contentRanking"].isNull)
assertTrue(recentNews[0]["communityPost"].isNull)
assertEquals("AUDIO_CONTENT", recentNews[1]["type"].asText())
assertEquals(200L, recentNews[1]["audioContent"]["contentId"].asLong())
assertEquals("audio title", recentNews[1]["audioContent"]["title"].asText())
assertFalse(recentNews[1]["audioContent"].has("releaseDate"))
assertTrue(recentNews[1]["creatorRanking"].isNull)
assertTrue(recentNews[1]["communityPost"].isNull)
assertEquals("PHOTO_CONTENT", recentNews[2]["type"].asText())
assertEquals(300L, recentNews[2]["photoContent"]["contentId"].asLong())
assertEquals("photo title", recentNews[2]["photoContent"]["title"].asText())
assertTrue(recentNews[2]["audioContent"].isNull)
assertEquals("CONTENT_RANKING", recentNews[3]["type"].asText())
assertEquals(5, recentNews[3]["contentRanking"]["rank"].asInt())
assertEquals(400L, recentNews[3]["contentRanking"]["contentId"].asLong())
assertTrue(recentNews[3]["creatorRanking"].isNull)
assertEquals("COMMUNITY_POST", recentNews[4]["type"].asText())
assertEquals(500L, recentNews[4]["communityPost"]["postId"].asLong())
assertEquals("https://cdn/community-profile.jpg", recentNews[4]["communityPost"]["creatorProfileImage"].asText())
assertEquals("community creator", recentNews[4]["communityPost"]["creatorNickname"].asText())
assertTrue(recentNews[4]["communityPost"]["imageUrl"].isNull)
assertEquals("community body", recentNews[4]["communityPost"]["content"].asText())
assertEquals("2026-06-25T03:00:00Z", recentNews[4]["communityPost"]["createdAt"].asText())
assertEquals(11, recentNews[4]["communityPost"]["likeCount"].asInt())
assertEquals(2, recentNews[4]["communityPost"]["commentCount"].asInt())
}
private fun createHomeFollowing(): HomeFollowing {
@@ -98,15 +149,63 @@ class HomeFollowingTabResponseTest {
HomeFollowingNews(
newsId = "30",
type = FollowingNewsType.CREATOR_RANKING,
creatorProfileImageUrl = "https://cdn/news-profile.jpg",
creatorNickname = "news-creator",
title = "ranking",
body = "ranked",
thumbnailImageUrl = null,
targetId = 1L,
occurredAtUtc = "2026-06-25T00:00:00Z",
visibleFromAtUtc = "2026-06-25T09:00:00Z",
rank = 3
creatorRanking = HomeFollowingCreatorRankingNews(
rank = 3,
creatorId = 1L,
nickname = "news-creator",
profileImageUrl = "https://cdn/news-profile.jpg"
)
),
HomeFollowingNews(
newsId = "31",
type = FollowingNewsType.AUDIO_CONTENT,
visibleFromAtUtc = "2026-06-26T00:00:00Z",
audioContent = HomeFollowingContentNews(
contentId = 200L,
contentImageUrl = "https://cdn/audio.jpg",
title = "audio title",
creatorProfileImageUrl = "https://cdn/audio-profile.jpg",
creatorNickname = "audio creator"
)
),
HomeFollowingNews(
newsId = "32",
type = FollowingNewsType.PHOTO_CONTENT,
visibleFromAtUtc = "2026-06-27T00:00:00Z",
photoContent = HomeFollowingContentNews(
contentId = 300L,
contentImageUrl = "https://cdn/photo.jpg",
title = "photo title",
creatorProfileImageUrl = "https://cdn/photo-profile.jpg",
creatorNickname = "photo creator"
)
),
HomeFollowingNews(
newsId = "33",
type = FollowingNewsType.CONTENT_RANKING,
visibleFromAtUtc = "2026-06-28T00:00:00Z",
contentRanking = HomeFollowingContentRankingNews(
rank = 5,
contentId = 400L,
contentImageUrl = "https://cdn/ranking.jpg",
title = "content ranking"
)
),
HomeFollowingNews(
newsId = "34",
type = FollowingNewsType.COMMUNITY_POST,
visibleFromAtUtc = "2026-06-25T03:00:00Z",
communityPost = HomeFollowingCommunityPostNews(
postId = 500L,
creatorProfileImage = "https://cdn/community-profile.jpg",
creatorNickname = "community creator",
imageUrl = null,
content = "community body",
createdAt = "2026-06-25T03:00:00Z",
likeCount = 11,
commentCount = 2
)
)
)
)

View File

@@ -5,6 +5,8 @@ import kr.co.vividnext.sodalive.configs.QueryDslConfig
import kr.co.vividnext.sodalive.content.AudioContent
import kr.co.vividnext.sodalive.content.theme.AudioContentTheme
import kr.co.vividnext.sodalive.explorer.profile.creatorCommunity.CreatorCommunity
import kr.co.vividnext.sodalive.explorer.profile.creatorCommunity.comment.CreatorCommunityComment
import kr.co.vividnext.sodalive.explorer.profile.creatorCommunity.like.CreatorCommunityLike
import kr.co.vividnext.sodalive.live.room.LiveRoom
import kr.co.vividnext.sodalive.member.Member
import kr.co.vividnext.sodalive.member.MemberRole
@@ -219,8 +221,8 @@ class DefaultHomeFollowingQueryRepositoryTest @Autowired constructor(
}
@Test
@DisplayName("최근 소식은 활성 노출 가능 inbox를 최신순으로 조회하고 creatorId 없이 nullable rank만 반환한다")
fun shouldFindRecentNewsWithoutCreatorIdAndWithNullableRank() {
@DisplayName("최근 소식은 노출 가능한 랭킹 inbox 중 rank가 있는 row만 최신순으로 조회한다")
fun shouldFindRecentNewsWithRankedCreatorRankingPayloadOnly() {
val viewer = saveMember("news-viewer", MemberRole.USER)
val creator = saveMember("news-creator", MemberRole.CREATOR)
val blockedCreator = saveMember("news-blocked", MemberRole.CREATOR)
@@ -228,7 +230,7 @@ class DefaultHomeFollowingQueryRepositoryTest @Autowired constructor(
saveFollowing(viewer, creator)
saveFollowing(viewer, blockedCreator)
saveFollowing(viewer, nonCreator)
val oldVisible = saveNews(viewer.id!!, creator.id!!, "old", LocalDateTime.of(2026, 6, 25, 8, 0), rank = null)
saveNews(viewer.id!!, creator.id!!, "old-without-rank", LocalDateTime.of(2026, 6, 25, 8, 0), rank = null)
val latestVisible = saveNews(viewer.id!!, creator.id!!, "latest", LocalDateTime.of(2026, 6, 25, 9, 0), rank = 3)
saveNews(viewer.id!!, creator.id!!, "future", LocalDateTime.of(2026, 6, 25, 10, 0), rank = 1)
saveNews(viewer.id!!, creator.id!!, "adult", LocalDateTime.of(2026, 6, 25, 9, 30), isAdult = true)
@@ -244,8 +246,8 @@ class DefaultHomeFollowingQueryRepositoryTest @Autowired constructor(
limit = 30
)
assertEquals(listOf(latestVisible.id!!.toString(), oldVisible.id!!.toString()), news.map { it.newsId })
assertEquals(listOf(3, null), news.map { it.rank })
assertEquals(listOf(latestVisible.id!!.toString()), news.map { it.newsId })
assertEquals(listOf(3), news.map { it.creatorRanking?.rank })
}
@Test
@@ -254,8 +256,8 @@ class DefaultHomeFollowingQueryRepositoryTest @Autowired constructor(
val viewer = saveMember("news-utc-viewer", MemberRole.USER)
val creator = saveMember("news-utc-creator", MemberRole.CREATOR)
saveFollowing(viewer, creator)
val visibleNow = saveNews(viewer.id!!, creator.id!!, "visible-now", LocalDateTime.of(2026, 6, 25, 14, 30))
saveNews(viewer.id!!, creator.id!!, "future-utc", LocalDateTime.of(2026, 6, 25, 14, 31))
val visibleNow = saveNews(viewer.id!!, creator.id!!, "visible-now", LocalDateTime.of(2026, 6, 25, 14, 30), rank = 1)
saveNews(viewer.id!!, creator.id!!, "future-utc", LocalDateTime.of(2026, 6, 25, 14, 31), rank = 2)
flushAndClear()
val news = repository.findRecentNews(
@@ -268,6 +270,198 @@ class DefaultHomeFollowingQueryRepositoryTest @Autowired constructor(
assertEquals(listOf(visibleNow.id!!.toString()), news.map { it.newsId })
}
@Test
@DisplayName("최근 소식은 타입별 원천 데이터를 nested payload로 채운다")
fun shouldPopulateRecentNewsNestedPayloadsFromSourceTargets() {
val viewer = saveMember("news-payload-viewer", MemberRole.USER)
val creator = saveMember("news-payload-creator", MemberRole.CREATOR, profileImage = "payload-profile.png")
val otherMember = saveMember("news-payload-other", MemberRole.USER)
val theme = saveTheme("news-payload-theme")
saveFollowing(viewer, creator)
val audio = saveAudioContent(creator, theme, LocalDateTime.of(2026, 6, 25, 7, 0)).apply {
title = "source audio title"
coverImage = "audio/source-cover.png"
}
val post = saveCommunityPost(creator, "source community content", isActive = true).apply {
imagePath = "community/source-image.png"
}
saveCommunityLike(viewer, post, isActive = true)
saveCommunityLike(otherMember, post, isActive = true)
saveCommunityLike(otherMember, post, isActive = false)
saveCommunityComment(viewer, post, isActive = true)
val inactiveComment = saveCommunityComment(otherMember, post, isActive = false)
val childComment = saveCommunityComment(otherMember, post, isActive = true)
childComment.parent = inactiveComment
post.createdAt = LocalDateTime.of(2026, 6, 25, 6, 30)
saveNews(
memberId = viewer.id!!,
creatorId = creator.id!!,
sourceKey = "payload-audio",
visibleFromAtUtc = LocalDateTime.of(2026, 6, 25, 9, 0),
newsType = FollowingNewsType.AUDIO_CONTENT,
targetId = audio.id!!
)
saveNews(
memberId = viewer.id!!,
creatorId = creator.id!!,
sourceKey = "payload-post",
visibleFromAtUtc = LocalDateTime.of(2026, 6, 25, 9, 1),
newsType = FollowingNewsType.COMMUNITY_POST,
targetId = post.id!!
)
flushAndClear()
val news = repository.findRecentNews(
memberId = viewer.id!!,
canViewAdultContent = true,
nowUtc = LocalDateTime.of(2026, 6, 25, 10, 0),
limit = 30
)
val communityNews = news.first { it.type == FollowingNewsType.COMMUNITY_POST }.communityPost!!
assertEquals(post.id!!, communityNews.postId)
assertEquals("https://cdn.test/payload-profile.png", communityNews.creatorProfileImage)
assertEquals("news-payload-creator", communityNews.creatorNickname)
assertEquals("https://cdn.test/community/source-image.png", communityNews.imageUrl)
assertEquals("source community content", communityNews.content)
assertEquals("2026-06-25T06:30:00Z", communityNews.createdAt)
assertEquals(2, communityNews.likeCount)
assertEquals(1, communityNews.commentCount)
val audioNews = news.first { it.type == FollowingNewsType.AUDIO_CONTENT }.audioContent!!
assertEquals(audio.id!!, audioNews.contentId)
assertEquals("https://cdn.test/audio/source-cover.png", audioNews.contentImageUrl)
assertEquals("source audio title", audioNews.title)
assertEquals("https://cdn.test/payload-profile.png", audioNews.creatorProfileImageUrl)
assertEquals("news-payload-creator", audioNews.creatorNickname)
}
@Test
@DisplayName("최근 소식은 현재 활성 팔로우가 아닌 크리에이터 inbox를 제외한다")
fun shouldExcludeRecentNewsWhenFollowingIsInactive() {
val viewer = saveMember("news-inactive-following-viewer", MemberRole.USER)
val creator = saveMember("news-inactive-following-creator", MemberRole.CREATOR)
saveFollowing(viewer, creator, isActive = false)
saveNews(
memberId = viewer.id!!,
creatorId = creator.id!!,
sourceKey = "inactive-following-news",
visibleFromAtUtc = LocalDateTime.of(2026, 6, 25, 9, 0),
rank = 1
)
flushAndClear()
val news = repository.findRecentNews(
memberId = viewer.id!!,
canViewAdultContent = true,
nowUtc = LocalDateTime.of(2026, 6, 25, 10, 0),
limit = 30
)
assertTrue(news.isEmpty())
}
@Test
@DisplayName("최근 소식은 유료 커뮤니티 게시글을 제외한다")
fun shouldExcludePaidCommunityPostRecentNews() {
val viewer = saveMember("news-paid-viewer", MemberRole.USER)
val creator = saveMember("news-paid-creator", MemberRole.CREATOR)
saveFollowing(viewer, creator)
val freePost = saveCommunityPost(creator, "free-post", isActive = true)
val negativeFreePost = saveCommunityPost(creator, "negative-free-post", isActive = true).apply { price = -1 }
val paidPost = saveCommunityPost(creator, "paid-post", isActive = true).apply { price = 10 }
saveNews(
memberId = viewer.id!!,
creatorId = creator.id!!,
sourceKey = "free-post",
visibleFromAtUtc = LocalDateTime.of(2026, 6, 25, 9, 0),
newsType = FollowingNewsType.COMMUNITY_POST,
targetId = freePost.id!!
)
saveNews(
memberId = viewer.id!!,
creatorId = creator.id!!,
sourceKey = "negative-free-post",
visibleFromAtUtc = LocalDateTime.of(2026, 6, 25, 9, 1),
newsType = FollowingNewsType.COMMUNITY_POST,
targetId = negativeFreePost.id!!
)
saveNews(
memberId = viewer.id!!,
creatorId = creator.id!!,
sourceKey = "paid-post",
visibleFromAtUtc = LocalDateTime.of(2026, 6, 25, 9, 2),
newsType = FollowingNewsType.COMMUNITY_POST,
targetId = paidPost.id!!
)
flushAndClear()
val news = repository.findRecentNews(
memberId = viewer.id!!,
canViewAdultContent = true,
nowUtc = LocalDateTime.of(2026, 6, 25, 10, 0),
limit = 30
)
assertEquals(listOf(negativeFreePost.id!!, freePost.id!!), news.map { it.communityPost?.postId })
assertTrue(news.all { it.audioContent == null })
}
@Test
@DisplayName("최근 소식은 비성인 사용자의 오디오와 커뮤니티 원천 target 현재 성인 상태를 반영해 제외한다")
fun shouldExcludeAdultSourceTargetsForNonAdultViewer() {
val viewer = saveMember("news-adult-source-viewer", MemberRole.USER)
val creator = saveMember("news-adult-source-creator", MemberRole.CREATOR)
val theme = saveTheme("news-adult-source-theme")
saveFollowing(viewer, creator)
val adultAudio = saveAudioContent(
creator,
theme,
LocalDateTime.of(2026, 6, 25, 8, 0),
isActive = true,
isAdult = true
)
val adultPost = saveCommunityPost(creator, "adult-post", isActive = true, isAdult = true)
saveNews(
memberId = viewer.id!!,
creatorId = creator.id!!,
sourceKey = "adult-audio",
visibleFromAtUtc = LocalDateTime.of(2026, 6, 25, 9, 0),
isAdult = false,
newsType = FollowingNewsType.AUDIO_CONTENT,
targetId = adultAudio.id!!
)
saveNews(
memberId = viewer.id!!,
creatorId = creator.id!!,
sourceKey = "adult-post",
visibleFromAtUtc = LocalDateTime.of(2026, 6, 25, 9, 1),
isAdult = false,
newsType = FollowingNewsType.COMMUNITY_POST,
targetId = adultPost.id!!
)
flushAndClear()
val hiddenNews = repository.findRecentNews(
memberId = viewer.id!!,
canViewAdultContent = false,
nowUtc = LocalDateTime.of(2026, 6, 25, 10, 0),
limit = 30
)
val visibleNews = repository.findRecentNews(
memberId = viewer.id!!,
canViewAdultContent = true,
nowUtc = LocalDateTime.of(2026, 6, 25, 10, 0),
limit = 30
)
assertEquals(emptyList<Long>(), hiddenNews.map { it.communityPost?.postId ?: it.audioContent?.contentId })
assertEquals(
listOf(adultPost.id!!, adultAudio.id!!),
visibleNews.map { it.communityPost?.postId ?: it.audioContent?.contentId }
)
}
@Test
@DisplayName("최근 소식은 오디오와 커뮤니티 원천 target이 비활성화되면 제외한다")
fun shouldExcludeRecentNewsWhenSourceTargetIsInactive() {
@@ -322,7 +516,7 @@ class DefaultHomeFollowingQueryRepositoryTest @Autowired constructor(
assertEquals(
listOf(activePost.id!!, activeAudio.id!!),
news.map { it.targetId }
news.map { it.communityPost?.postId ?: it.audioContent?.contentId }
)
}
@@ -387,7 +581,8 @@ class DefaultHomeFollowingQueryRepositoryTest @Autowired constructor(
creator: Member,
theme: AudioContentTheme,
releaseDate: LocalDateTime,
isActive: Boolean = true
isActive: Boolean = true,
isAdult: Boolean = false
): AudioContent {
val content = AudioContent(
title = "audio-$releaseDate",
@@ -399,17 +594,23 @@ class DefaultHomeFollowingQueryRepositoryTest @Autowired constructor(
this.theme = theme
duration = "00:10:00"
this.isActive = isActive
this.isAdult = isAdult
}
entityManager.persist(content)
return content
}
private fun saveCommunityPost(creator: Member, content: String, isActive: Boolean): CreatorCommunity {
private fun saveCommunityPost(
creator: Member,
content: String,
isActive: Boolean,
isAdult: Boolean = false
): CreatorCommunity {
val post = CreatorCommunity(
content = content,
price = 0,
isCommentAvailable = true,
isAdult = false,
isAdult = isAdult,
isActive = isActive
).apply {
member = creator
@@ -418,6 +619,24 @@ class DefaultHomeFollowingQueryRepositoryTest @Autowired constructor(
return post
}
private fun saveCommunityLike(member: Member, post: CreatorCommunity, isActive: Boolean): CreatorCommunityLike {
val like = CreatorCommunityLike(isActive = isActive).apply {
this.member = member
creatorCommunity = post
}
entityManager.persist(like)
return like
}
private fun saveCommunityComment(member: Member, post: CreatorCommunity, isActive: Boolean): CreatorCommunityComment {
val comment = CreatorCommunityComment(comment = "comment", isActive = isActive).apply {
this.member = member
creatorCommunity = post
}
entityManager.persist(comment)
return comment
}
private fun saveNews(
memberId: Long,
creatorId: Long,

View File

@@ -10,8 +10,8 @@ import java.time.LocalDateTime
class HomeFollowingNewsPublishServiceTest {
@Test
@DisplayName("커뮤니티 게시글 발행은 현재 활성 팔로워에게만 inbox record를 생성한다")
fun shouldPublishCommunityPostCreatedToActiveFollowers() {
@DisplayName("무료 커뮤니티 게시글 발행은 현재 활성 팔로워에게만 inbox record를 생성한다")
fun shouldPublishFreeCommunityPostCreatedToActiveFollowers() {
val inboxPort = FakeHomeFollowingNewsInboxPort(activeFollowerIds = listOf(1L, 2L))
val service = HomeFollowingNewsPublishService(inboxPort)
val occurredAtUtc = LocalDateTime.of(2026, 6, 25, 1, 2, 3)