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

Reviewed-on: #432
This commit is contained in:
2026-07-07 18:07:27 +00:00
21 changed files with 3045 additions and 6 deletions

View File

@@ -101,7 +101,14 @@ tasks.withType<KotlinCompile> {
tasks.withType<Test> { tasks.withType<Test> {
useJUnitPlatform() useJUnitPlatform()
maxHeapSize = "1536m"
val springContextCacheMaxSize = (project.findProperty("test.springContextCacheMaxSize") as String?) ?: "1"
maxHeapSize = (project.findProperty("testMaxHeap") as String?) ?: "1536m"
jvmArgs(
"-Dfile.encoding=UTF-8",
"-Dspring.test.context.cache.maxSize=$springContextCacheMaxSize"
)
} }
tasks.getByName<Jar>("jar") { tasks.getByName<Jar>("jar") {

View File

@@ -0,0 +1,867 @@
# 커뮤니티 게시물 상세 API Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use `superpowers:subagent-driven-development` 또는 `superpowers:executing-plans`로 task 단위 구현을 진행한다. 각 단계는 체크박스(`- [ ]`)로 진행 상태를 갱신한다.
**Goal:** 인증 회원이 커뮤니티 게시물 상세, 댓글 목록, 댓글 답글 목록을 v2 API로 조회할 수 있게 한다.
**Architecture:** 공개 API controller/facade/response DTO는 `kr.co.vividnext.sodalive.v2.api.creator.channel.community` 조립 계층에 둔다. 게시물 상세/댓글/답글 조회 service, domain model, port, QueryDSL repository는 `kr.co.vividnext.sodalive.v2.creator.channel.community` 하위에 두고 `v2.api.*`에 의존하지 않는다. 기존 커뮤니티 탭의 `CreatorChannelCommunityPost` 조립 정책을 재사용하고, 댓글 저장 엔티티는 legacy `CreatorCommunityComment`를 사용하되 legacy response DTO는 v2 공개 응답으로 노출하지 않는다.
**Tech Stack:** Kotlin, Spring Boot 2.7.14, Java 17, Spring MVC, Spring Data JPA, QueryDSL, JUnit 5, MockMvc, Gradle Wrapper
---
## 0. 구현 전 확정 사항
- 상세 API endpoint: `GET /api/v2/creator-channels/community-posts/{postId}`
- 댓글 API endpoint: `GET /api/v2/creator-channels/community-posts/{postId}/comments`
- 답글 API endpoint: `GET /api/v2/creator-channels/community-comments/{commentId}/replies`
- 인증 정책: 세 API 모두 인증 회원만 조회 가능하다. 비회원은 기존 Security 흐름과 `requireMember` 정책으로 거부한다.
- page 기준: 기존 크리에이터 채널 v2 탭 API와 동일한 0 기반 page index다.
- page fallback:
- `page` 기본값은 `0`
- `page < 0`이면 `0`
- `size` 기본값은 `20`
- `size < 20`이면 `20`
- `size > 50`이면 `50`
- 상세 응답 게시물 필드는 커뮤니티 탭의 `CreatorChannelCommunityPostResponse`와 동일한 의미를 사용하고 `isLiked`를 포함한다.
- 상세 응답은 `comments: CreatorChannelCommunityCommentsResponse`로 첫 댓글 20개를 포함한다.
- 댓글 응답은 `commentCount`, `comments`, `page`, `size`, `hasNext`를 포함한다.
- 댓글 item은 `commentId`, `writerProfileImageUrl`, `writerNickname`, `content`, `createdAtUtc`, `latestReply`를 포함한다.
- 답글 응답은 `replyCount`, `replies`, `page`, `size`, `hasNext`를 포함한다.
- 답글 item은 댓글 item과 같은 작성자/본문/UTC 작성 시간 구조를 사용하지만 `latestReply`를 포함하지 않는다.
- 게시물 조회 정책:
- `CreatorCommunity.isActive == true`
- 조회자의 성인 콘텐츠 노출 정책이 false이면 19금 게시물은 조회 불가
- 조회자와 게시물 작성자 사이 활성 차단 관계가 있으면 접근 차단 오류
- 유료 본문/이미지/오디오 접근 정책은 커뮤니티 탭 `CreatorChannelCommunityPost`와 동일
- 댓글 조회 정책:
- 최상위 댓글: `CreatorCommunityComment.isActive == true`, `parent is null`
- 답글: `CreatorCommunityComment.isActive == true`, `parent.id == commentId`
- 댓글/답글 작성자가 조회자와 차단/피차단 관계이면 제외
- 비밀 최상위 댓글은 게시물 작성자 또는 댓글 작성자 본인에게만 노출
- 댓글 정렬: `createdAt desc`, `id desc`
- latest reply 정렬: `createdAt desc`, `id desc`의 첫 번째
- 답글 목록 정렬: `createdAt desc`, `id desc`
- `isCommentAvailable == false`인 게시물은 상세 초기 댓글과 댓글 목록 API 모두 `commentCount=0`, 빈 목록, `hasNext=false`를 반환한다.
- `createdAtUtc``kr.co.vividnext.sodalive.extensions.toUtcIso`를 사용한다.
- 프로필 이미지 URL은 `String?.toCdnUrl(cloudFrontHost)`와 기본 프로필 이미지 URL `"$cloudFrontHost/profile/default-profile.png"` 정책을 따른다.
- 탈퇴 회원 닉네임 prefix 제거는 `removeDeletedNicknamePrefix()`를 적용한다.
- legacy `/creator-community` endpoint와 응답 스키마는 변경하지 않는다.
- DB schema, 운영 DDL, 마이그레이션은 포함하지 않는다.
---
## 1. 파일 구조 계획
### API 조립 계층
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/community/adapter/in/web/CreatorChannelCommunityController.kt`
- 상세/댓글/답글 GET mapping 추가
- 인증 회원 null guard는 기존 `requireMember` 재사용
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/community/application/CreatorChannelCommunityFacade.kt`
- `getCommunityPostDetail`, `getCommunityComments`, `getCommunityReplies` 추가
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/community/dto/CreatorChannelCommunityPostDetailResponse.kt`
- 상세/댓글/답글 response DTO와 domain 변환 책임
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/community/dto/CreatorChannelCommunityPostDetailResponseTest.kt`
- Modify: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/community/adapter/in/web/CreatorChannelCommunityControllerTest.kt`
- Modify: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/community/application/CreatorChannelCommunityFacadeTest.kt`
- Modify: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/community/adapter/in/web/CreatorChannelCommunityEndToEndTest.kt`
### 도메인 조회 계층
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/community/domain/CreatorChannelCommunityPostDetail.kt`
- 상세/댓글/답글 domain model
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/community/domain/CreatorChannelCommunityQueryPolicy.kt`
- 기존 page fallback을 댓글/답글에도 재사용하도록 이름 또는 주석 확인
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/community/port/out/CreatorChannelCommunityQueryPort.kt`
- 게시물 상세, 댓글, 답글 조회 port method와 record 추가
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/community/application/CreatorChannelCommunityQueryService.kt`
- 상세/댓글/답글 use case 추가
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/community/adapter/out/persistence/CreatorChannelCommunityQueryRepository.kt`
- port method 구현 계약 반영
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/community/adapter/out/persistence/DefaultCreatorChannelCommunityQueryRepository.kt`
- QueryDSL 상세/댓글/답글 조회 구현
- Modify: `src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/community/domain/CreatorChannelCommunityQueryPolicyTest.kt`
- Modify: `src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/community/application/CreatorChannelCommunityQueryServiceTest.kt`
- Modify: `src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/community/adapter/out/persistence/DefaultCreatorChannelCommunityQueryRepositoryTest.kt`
### 기존 파일 확인/재사용
- Verify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/community/dto/CreatorChannelCommunityTabResponse.kt`
- Verify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/community/domain/CreatorChannelCommunityTab.kt`
- Verify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/live/domain/CreatorChannelPage.kt`
- Verify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/common/domain/CdnUrlExtensions.kt`
- Verify: `src/main/kotlin/kr/co/vividnext/sodalive/extensions/LocalDateTimeExtensions.kt`
- Verify: `src/main/kotlin/kr/co/vividnext/sodalive/explorer/profile/creatorCommunity/comment/CreatorCommunityComment.kt`
- Verify: `src/main/kotlin/kr/co/vividnext/sodalive/member/block/BlockMember.kt`
### 문서 산출물
- Create: `docs/20260706_커뮤니티_게시물_상세_API/plan-task.md`
- Verify: `docs/20260706_커뮤니티_게시물_상세_API/prd.md`
---
## 2. Response data class 초안
구현 시 `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/community/dto/CreatorChannelCommunityPostDetailResponse.kt`를 아래 구조로 만든다. 공개 API 계약 변경이 필요하면 구현 전에 PRD와 이 문서를 먼저 갱신한다.
```kotlin
package kr.co.vividnext.sodalive.v2.api.creator.channel.community.dto
import com.fasterxml.jackson.annotation.JsonProperty
import kr.co.vividnext.sodalive.extensions.toUtcIso
import kr.co.vividnext.sodalive.v2.creator.channel.community.domain.CreatorChannelCommunityComment
import kr.co.vividnext.sodalive.v2.creator.channel.community.domain.CreatorChannelCommunityComments
import kr.co.vividnext.sodalive.v2.creator.channel.community.domain.CreatorChannelCommunityPostDetail
import kr.co.vividnext.sodalive.v2.creator.channel.community.domain.CreatorChannelCommunityReplies
import kr.co.vividnext.sodalive.v2.creator.channel.community.domain.CreatorChannelCommunityReply
data class CreatorChannelCommunityPostDetailResponse(
val postId: Long,
val creatorId: Long,
val creatorNickname: String,
val creatorProfileUrl: String,
val createdAtUtc: String,
val content: String,
val imageUrl: String?,
val audioUrl: String?,
val price: Int,
@JsonProperty("isCommentAvailable")
val isCommentAvailable: Boolean,
val existOrdered: Boolean,
val likeCount: Int,
val commentCount: Int,
@JsonProperty("isPinned")
val isPinned: Boolean,
@JsonProperty("isLiked")
val isLiked: Boolean,
val comments: CreatorChannelCommunityCommentsResponse
) {
companion object {
fun from(detail: CreatorChannelCommunityPostDetail): CreatorChannelCommunityPostDetailResponse {
val post = detail.post
return CreatorChannelCommunityPostDetailResponse(
postId = post.postId,
creatorId = post.creatorId,
creatorNickname = post.creatorNickname,
creatorProfileUrl = post.creatorProfileUrl,
createdAtUtc = post.createdAt.toUtcIso(),
content = post.content,
imageUrl = post.imageUrl,
audioUrl = post.audioUrl,
price = post.price,
isCommentAvailable = post.isCommentAvailable,
existOrdered = post.existOrdered,
likeCount = post.likeCount,
commentCount = post.commentCount,
isPinned = post.isPinned,
isLiked = post.isLiked,
comments = CreatorChannelCommunityCommentsResponse.from(detail.comments)
)
}
}
}
data class CreatorChannelCommunityCommentsResponse(
val commentCount: Int,
val comments: List<CreatorChannelCommunityCommentResponse>,
val page: Int,
val size: Int,
@JsonProperty("hasNext")
val hasNext: Boolean
) {
companion object {
fun from(comments: CreatorChannelCommunityComments): CreatorChannelCommunityCommentsResponse {
return CreatorChannelCommunityCommentsResponse(
commentCount = comments.commentCount,
comments = comments.comments.map(CreatorChannelCommunityCommentResponse::from),
page = comments.page.page,
size = comments.page.size,
hasNext = comments.hasNext
)
}
}
}
data class CreatorChannelCommunityCommentResponse(
val commentId: Long,
val writerId: Long,
val writerProfileImageUrl: String,
val writerNickname: String,
val content: String,
@JsonProperty("isSecret")
val isSecret: Boolean,
val createdAtUtc: String,
val latestReply: CreatorChannelCommunityReplyResponse?
) {
companion object {
fun from(comment: CreatorChannelCommunityComment): CreatorChannelCommunityCommentResponse {
return CreatorChannelCommunityCommentResponse(
commentId = comment.commentId,
writerId = comment.writerId,
writerProfileImageUrl = comment.writerProfileImageUrl,
writerNickname = comment.writerNickname,
content = comment.content,
isSecret = comment.isSecret,
createdAtUtc = comment.createdAt.toUtcIso(),
latestReply = comment.latestReply?.let(CreatorChannelCommunityReplyResponse::from)
)
}
}
}
data class CreatorChannelCommunityRepliesResponse(
val replyCount: Int,
val replies: List<CreatorChannelCommunityReplyResponse>,
val page: Int,
val size: Int,
@JsonProperty("hasNext")
val hasNext: Boolean
) {
companion object {
fun from(replies: CreatorChannelCommunityReplies): CreatorChannelCommunityRepliesResponse {
return CreatorChannelCommunityRepliesResponse(
replyCount = replies.replyCount,
replies = replies.replies.map(CreatorChannelCommunityReplyResponse::from),
page = replies.page.page,
size = replies.page.size,
hasNext = replies.hasNext
)
}
}
}
data class CreatorChannelCommunityReplyResponse(
val commentId: Long,
val writerId: Long,
val writerProfileImageUrl: String,
val writerNickname: String,
val content: String,
val createdAtUtc: String
) {
companion object {
fun from(reply: CreatorChannelCommunityReply): CreatorChannelCommunityReplyResponse {
return CreatorChannelCommunityReplyResponse(
commentId = reply.commentId,
writerId = reply.writerId,
writerProfileImageUrl = reply.writerProfileImageUrl,
writerNickname = reply.writerNickname,
content = reply.content,
createdAtUtc = reply.createdAt.toUtcIso()
)
}
}
}
```
---
## 3. Domain / Port 초안
구현 시 `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/community/domain/CreatorChannelCommunityPostDetail.kt`를 아래 구조로 만든다.
```kotlin
package kr.co.vividnext.sodalive.v2.creator.channel.community.domain
import kr.co.vividnext.sodalive.v2.creator.channel.live.domain.CreatorChannelPage
import java.time.LocalDateTime
data class CreatorChannelCommunityPostDetail(
val post: CreatorChannelCommunityPost,
val comments: CreatorChannelCommunityComments
)
data class CreatorChannelCommunityComments(
val commentCount: Int,
val comments: List<CreatorChannelCommunityComment>,
val page: CreatorChannelPage,
val hasNext: Boolean
)
data class CreatorChannelCommunityComment(
val commentId: Long,
val writerId: Long,
val writerProfileImageUrl: String,
val writerNickname: String,
val content: String,
val isSecret: Boolean,
val createdAt: LocalDateTime,
val latestReply: CreatorChannelCommunityReply?
)
data class CreatorChannelCommunityReplies(
val replyCount: Int,
val replies: List<CreatorChannelCommunityReply>,
val page: CreatorChannelPage,
val hasNext: Boolean
)
data class CreatorChannelCommunityReply(
val commentId: Long,
val writerId: Long,
val writerProfileImageUrl: String,
val writerNickname: String,
val content: String,
val createdAt: LocalDateTime
)
```
구현 시 `CreatorChannelCommunityQueryPort`에 아래 method와 record를 추가한다.
```kotlin
fun findCommunityPost(
postId: Long,
viewerId: Long,
canViewAdultContent: Boolean
): CreatorChannelCommunityPostRecord?
fun countCommunityComments(
postId: Long,
viewerId: Long,
creatorId: Long
): Int
fun findCommunityComments(
postId: Long,
viewerId: Long,
creatorId: Long,
offset: Long,
limit: Int
): List<CreatorChannelCommunityCommentRecord>
fun findLatestRepliesByCommentIds(
commentIds: List<Long>,
viewerId: Long
): List<CreatorChannelCommunityCommentRecord>
fun findRootCommentContext(
commentId: Long,
viewerId: Long,
canViewAdultContent: Boolean
): CreatorChannelCommunityCommentContextRecord?
fun countCommunityReplies(
commentId: Long,
viewerId: Long
): Int
fun findCommunityReplies(
commentId: Long,
viewerId: Long,
offset: Long,
limit: Int
): List<CreatorChannelCommunityCommentRecord>
data class CreatorChannelCommunityCommentRecord(
val commentId: Long,
val parentCommentId: Long?,
val postId: Long,
val writerId: Long,
val writerProfilePath: String?,
val writerNickname: String,
val content: String,
val isSecret: Boolean,
val createdAt: LocalDateTime
)
data class CreatorChannelCommunityCommentContextRecord(
val commentId: Long,
val postId: Long,
val creatorId: Long,
val isCommentAvailable: Boolean
)
```
---
## 4. Phase / Task Breakdown
### Phase 1: DTO, domain, page 정책 기반 추가
- [x] **Task 1.1: 상세/댓글/답글 response DTO 추가** - 검증: `CreatorChannelCommunityPostDetailResponse.kt`와 DTO 직렬화 테스트를 추가했고 boolean is-prefix, comments/latestReply/replies 구조를 확인했다.
- Files:
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/community/dto/CreatorChannelCommunityPostDetailResponse.kt`
- Create: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/community/dto/CreatorChannelCommunityPostDetailResponseTest.kt`
- RED: 실패 테스트 작성/실패 확인
- `CreatorChannelCommunityPostDetailResponseTest.shouldSerializeBooleanFieldsWithIsPrefixAndReuseCommentsResponse`를 추가한다.
- 검증 항목:
- `isCommentAvailable`, `isPinned`, `isLiked`, `hasNext` JSON 필드명이 유지된다.
- 상세 응답의 `comments``CreatorChannelCommunityCommentsResponse` 구조로 직렬화된다.
- 댓글 item은 `latestReply`를 포함하고, 답글 item은 `latestReply`를 포함하지 않는다.
- 실행 명령:
- `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.creator.channel.community.dto.CreatorChannelCommunityPostDetailResponseTest`
- 기대 결과:
- 신규 DTO가 없어서 `compileTestKotlin` 실패한다.
- GREEN: 최소 구현/통과 확인
- 위 response data class 초안 그대로 DTO 파일을 추가한다.
- 실행 명령:
- `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.creator.channel.community.dto.CreatorChannelCommunityPostDetailResponseTest`
- 기대 결과:
- `BUILD SUCCESSFUL`
- REFACTOR: 정리/회귀 확인
- 기존 `CreatorChannelCommunityPostResponse`를 중복 수정하지 않는다.
- `rg -n "JsonProperty\\(\"isLiked\"\\)|JsonProperty\\(\"hasNext\"\\)" src/main/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/community`로 Boolean 필드명을 확인한다.
- [x] **Task 1.2: 상세/댓글/답글 domain model 추가** - 검증: 상세/댓글/답글 domain model을 추가하고 기존 `CreatorChannelCommunityQueryPolicy.createPage` fallback 재사용 테스트를 통과시켰다.
- Files:
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/community/domain/CreatorChannelCommunityPostDetail.kt`
- Modify: `src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/community/domain/CreatorChannelCommunityQueryPolicyTest.kt`
- RED: 실패 테스트 작성/실패 확인
- `CreatorChannelCommunityQueryPolicyTest.shouldUseSamePageFallbackForCommunityComments`를 추가한다.
- 검증 항목:
- `createPage(null, null)``page=0`, `size=20`, `fetchLimit=21`
- `createPage(-1, 100)``page=0`, `size=50`, `fetchLimit=51`
- 실행 명령:
- `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.community.domain.CreatorChannelCommunityQueryPolicyTest`
- 기대 결과:
- domain model import 또는 신규 테스트 의도에 맞춰 컴파일/테스트 실패를 확인한다.
- GREEN: 최소 구현/통과 확인
- 위 domain data class 초안을 추가한다.
- 기존 `CreatorChannelCommunityQueryPolicy.createPage`를 그대로 사용하고, 동작 변경은 하지 않는다.
- 실행 명령:
- `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.community.domain.CreatorChannelCommunityQueryPolicyTest`
- 기대 결과:
- `BUILD SUCCESSFUL`
- REFACTOR: 정리/회귀 확인
- `CreatorChannelPage`를 새로 만들지 않고 기존 `v2.creator.channel.live.domain.CreatorChannelPage`를 재사용한다.
### Phase 2: QueryDSL repository 상세/댓글/답글 조회
- [x] **Task 2.1: port method와 record 계약 추가** - 검증: 상세/댓글/답글 port method와 record를 추가했고 service/home 테스트 fake 구현을 갱신해 컴파일 및 service 테스트를 통과시켰다.
- Files:
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/community/port/out/CreatorChannelCommunityQueryPort.kt`
- Modify: `src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/community/application/CreatorChannelCommunityQueryServiceTest.kt`
- RED: 실패 테스트 작성/실패 확인
- `FakeCreatorChannelCommunityQueryPort`가 신규 port method를 구현하지 않아 컴파일 실패하도록 service test에 상세/댓글/답글 테스트 skeleton을 추가한다.
- 추가할 테스트 이름:
- `shouldAssembleCommunityPostDetailWithInitialComments`
- `shouldReturnEmptyCommentsWhenCommentUnavailable`
- `shouldAssembleCommunityReplies`
- 실행 명령:
- `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.community.application.CreatorChannelCommunityQueryServiceTest`
- 기대 결과:
- `CreatorChannelCommunityQueryPort` 신규 method 미정의 또는 fake 미구현으로 `compileTestKotlin` 실패한다.
- GREEN: 최소 구현/통과 확인
- `CreatorChannelCommunityQueryPort`에 3장 port 초안의 method와 record를 추가한다.
- `FakeCreatorChannelCommunityQueryPort`에는 테스트용 기본값을 반환하는 구현을 추가한다.
- 실행 명령:
- `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.community.application.CreatorChannelCommunityQueryServiceTest`
- 기대 결과:
- 아직 service method가 없어서 의도한 RED 실패가 남는다.
- REFACTOR: 정리/회귀 확인
- port record는 API DTO를 import하지 않는다.
- 실행 명령:
- `rg -n "v2\\.api\\." src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/community`
- 기대 결과:
- 검색 결과 0건
- [x] **Task 2.2: 게시물 ID 기반 상세 row 조회** - 검증: `findCommunityPost``selectCommunityPostRow`/`toCommunityPostRecords` 재사용으로 구현하고 좋아요/구매/성인/비활성 정책 repository 테스트를 통과시켰다.
- Files:
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/community/adapter/out/persistence/CreatorChannelCommunityQueryRepository.kt`
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/community/adapter/out/persistence/DefaultCreatorChannelCommunityQueryRepository.kt`
- Modify: `src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/community/adapter/out/persistence/DefaultCreatorChannelCommunityQueryRepositoryTest.kt`
- RED: 실패 테스트 작성/실패 확인
- `DefaultCreatorChannelCommunityQueryRepositoryTest.shouldFindCommunityPostByIdWithLikePurchaseAndAdultPolicy`를 추가한다.
- fixture:
- creator, viewer, adult-disabled viewer
- 무료 게시물 1개
- 유료 게시물 1개와 유효 구매 내역
- 19금 게시물 1개
- viewer 활성 좋아요와 다른 회원 좋아요
- 검증 항목:
- `findCommunityPost(postId, viewerId, true)``CreatorChannelCommunityPostRecord` 1건을 반환한다.
- `existOrdered`, `isLiked`, `likeCount`, `commentCount`, `isPinned`가 기존 목록과 같은 의미로 채워진다.
- `canViewAdultContent=false`이면 19금 게시물은 `null`이다.
- 비활성 게시물은 `null`이다.
- 실행 명령:
- `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.community.adapter.out.persistence.DefaultCreatorChannelCommunityQueryRepositoryTest`
- 기대 결과:
- `findCommunityPost` 미구현으로 컴파일 실패한다.
- GREEN: 최소 구현/통과 확인
- `DefaultCreatorChannelCommunityQueryRepository.findCommunityPost(...)`를 추가한다.
- 기존 `selectCommunityPostRow`, `communityPostCondition`, `toCommunityPostRecords`를 재사용한다.
- 단일 게시물 조회 조건은 `creatorCommunity.id.eq(postId)`, `isActive=true`, `member.isActive=true`, 성인 필터를 적용한다.
- 실행 명령:
- `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.community.adapter.out.persistence.DefaultCreatorChannelCommunityQueryRepositoryTest`
- 기대 결과:
- `BUILD SUCCESSFUL`
- REFACTOR: 정리/회귀 확인
- 목록 조회와 상세 조회의 부가 필드 계산이 중복되지 않도록 `toCommunityPostRecords`를 재사용한다.
- 단일 조회 결과가 없으면 빈 리스트 변환 후 `firstOrNull()`로 처리해 NPE를 피한다.
- [x] **Task 2.3: 댓글 목록 count/list와 최신 답글 bulk 조회** - 검증: 댓글 count/list/latest reply bulk 조회를 구현하고 비밀 댓글, 차단 작성자, 최신 답글 정렬 repository 테스트를 통과시켰다.
- Files:
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/community/adapter/out/persistence/DefaultCreatorChannelCommunityQueryRepository.kt`
- Modify: `src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/community/adapter/out/persistence/DefaultCreatorChannelCommunityQueryRepositoryTest.kt`
- RED: 실패 테스트 작성/실패 확인
- `DefaultCreatorChannelCommunityQueryRepositoryTest.shouldFindVisibleRootCommentsWithLatestReply`를 추가한다.
- fixture:
- 공개 최상위 댓글 2개
- 각 댓글의 답글 여러 개
- 비밀 댓글 1개
- 비활성 댓글 1개
- 차단 작성자 댓글 1개
- 최상위 댓글이 아닌 답글 1개
- 검증 항목:
- viewer 기준 `countCommunityComments`는 공개/본인 비밀/차단 제외 정책을 반영한다.
- creator 기준 `countCommunityComments`는 비밀 댓글도 포함한다.
- `findCommunityComments``createdAt desc`, `id desc` 순서로 `limit`개를 반환한다.
- `findLatestRepliesByCommentIds`는 parent comment별 최신 답글 1개만 반환한다.
- 답글 작성자가 차단 관계이면 latest reply 후보에서 제외된다.
- 실행 명령:
- `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.community.adapter.out.persistence.DefaultCreatorChannelCommunityQueryRepositoryTest`
- 기대 결과:
- 댓글 port method 미구현으로 컴파일 실패한다.
- GREEN: 최소 구현/통과 확인
- `countCommunityComments`, `findCommunityComments`, `findLatestRepliesByCommentIds`를 구현한다.
- 기존 helper `visibleSecretCommentCondition(creatorId, viewerId)``notBlockedCommentWriterCondition(viewerId)`를 재사용한다.
- `findLatestRepliesByCommentIds`는 parent id 목록이 비어 있으면 빈 목록을 반환한다.
- latest reply는 QueryDSL로 후보를 조회한 뒤 Kotlin group by로 parent별 첫 항목을 선택하거나 동등한 방식으로 구현한다.
- 실행 명령:
- `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.community.adapter.out.persistence.DefaultCreatorChannelCommunityQueryRepositoryTest`
- 기대 결과:
- `BUILD SUCCESSFUL`
- REFACTOR: 정리/회귀 확인
- count 조건과 list 조건이 다르지 않은지 테스트 fixture를 재확인한다.
- `fetch().size` 대신 count query를 우선 사용한다.
- [x] **Task 2.4: 답글 조회용 부모 댓글 context, count/list 조회** - 검증: root comment context와 답글 count/list 조회를 구현하고 보이지 않는 비밀 부모, 답글의 답글 제외, 차단/비활성 제외 테스트를 통과시켰다.
- Files:
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/community/adapter/out/persistence/DefaultCreatorChannelCommunityQueryRepository.kt`
- Modify: `src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/community/adapter/out/persistence/DefaultCreatorChannelCommunityQueryRepositoryTest.kt`
- RED: 실패 테스트 작성/실패 확인
- `DefaultCreatorChannelCommunityQueryRepositoryTest.shouldFindVisibleRepliesForRootCommentOnly`를 추가한다.
- fixture:
- 최상위 댓글 1개
- 답글 3개
- 비활성 답글 1개
- 차단 작성자 답글 1개
- 답글의 답글 형태 비정상 데이터 1개
- 비밀 최상위 댓글 1개
- 검증 항목:
- `findRootCommentContext`는 최상위 댓글만 반환한다.
- 조회자에게 보이지 않는 비밀 부모 댓글은 `null`이다.
- `countCommunityReplies``findCommunityReplies`는 활성 답글과 차단 제외 정책을 반영한다.
- 답글 정렬은 `createdAt desc`, `id desc`다.
- 실행 명령:
- `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.community.adapter.out.persistence.DefaultCreatorChannelCommunityQueryRepositoryTest`
- 기대 결과:
- 답글 port method 미구현으로 컴파일 실패한다.
- GREEN: 최소 구현/통과 확인
- `findRootCommentContext`, `countCommunityReplies`, `findCommunityReplies`를 구현한다.
- 부모 댓글 context 조회는 부모 댓글의 게시물 활성/성인 필터/비밀 댓글/차단 작성자 정책을 함께 적용한다.
- 실행 명령:
- `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.community.adapter.out.persistence.DefaultCreatorChannelCommunityQueryRepositoryTest`
- 기대 결과:
- `BUILD SUCCESSFUL`
- REFACTOR: 정리/회귀 확인
- 답글 목록 item에 `latestReply`용 데이터를 넣지 않는다.
- `parent.isNotNull``parent.id.eq(commentId)` 조건을 모두 사용한다.
### Phase 3: Query service 상세/댓글/답글 조립
- [x] **Task 3.1: 상세 조회 service 조립** - 검증: 상세 service가 기존 게시글 domain 변환, 초기 댓글 20개, 최신 답글을 조립하도록 구현하고 service 테스트를 통과시켰다.
- Files:
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/community/application/CreatorChannelCommunityQueryService.kt`
- Modify: `src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/community/application/CreatorChannelCommunityQueryServiceTest.kt`
- RED: 실패 테스트 작성/실패 확인
- `CreatorChannelCommunityQueryServiceTest.shouldAssembleCommunityPostDetailWithInitialComments`를 완성한다.
- fake port fixture:
- `findCommunityPost``isCommentAvailable=true`, `isLiked=true` 게시물 반환
- `findCommunityComments`는 21개 댓글 반환
- `findLatestRepliesByCommentIds`는 첫 댓글 최신 답글 반환
- 검증 항목:
- 상세 게시물의 이미지/오디오/본문 접근 정책이 기존 탭과 같다.
- `comments.page=0`, `comments.size=20`, `comments.hasNext=true`
- `comments.comments.size=20`
- 첫 댓글의 `latestReply`가 조립된다.
- 실행 명령:
- `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.community.application.CreatorChannelCommunityQueryServiceTest`
- 기대 결과:
- `getCommunityPostDetail` 미구현으로 컴파일 실패한다.
- GREEN: 최소 구현/통과 확인
- `CreatorChannelCommunityQueryService.getCommunityPostDetail(postId, viewer, now)`를 추가한다.
- `findCommunityPost`가 null이면 `SodaException(messageKey = "creator.community.invalid_request_retry")`를 던진다.
- 게시물 작성자와 조회자 사이 차단 관계는 `existsBlockedBetween(viewerId, post.creatorId)`로 확인한다.
- 댓글은 내부 helper `getCommunityComments(postId, viewer, page = 0, size = 20, now)`를 호출해 조립한다.
- 실행 명령:
- `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.community.application.CreatorChannelCommunityQueryServiceTest`
- 기대 결과:
- `BUILD SUCCESSFUL`
- REFACTOR: 정리/회귀 확인
- 기존 `CreatorChannelCommunityPostRecord.toDomain(viewerId)`를 상세에도 재사용한다.
- `now` parameter는 기존 facade 패턴과 맞춰 테스트 주입 가능하게 유지한다.
- [x] **Task 3.2: 댓글 목록 service 조립** - 검증: 댓글 service가 page fallback, 댓글 불가 빈 응답, CDN/default profile, 탈퇴 닉네임 prefix 제거, latest reply 조립을 수행하도록 구현하고 테스트를 통과시켰다.
- Files:
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/community/application/CreatorChannelCommunityQueryService.kt`
- Modify: `src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/community/application/CreatorChannelCommunityQueryServiceTest.kt`
- RED: 실패 테스트 작성/실패 확인
- `CreatorChannelCommunityQueryServiceTest.shouldReturnEmptyCommentsWhenCommentUnavailable`를 추가한다.
- `CreatorChannelCommunityQueryServiceTest.shouldApplyCommentPageFallbackAndLatestReply`를 추가한다.
- 검증 항목:
- `isCommentAvailable=false`이면 comment count/list/latest reply port를 호출하지 않고 빈 응답을 반환한다.
- `page=-1`, `size=100` 요청은 `offset=0`, `limit=51`, 응답 `size=50`으로 조립된다.
- `findLatestRepliesByCommentIds`는 page 응답에 포함된 최대 `size`개 댓글 id만 대상으로 호출된다.
- 실행 명령:
- `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.community.application.CreatorChannelCommunityQueryServiceTest`
- 기대 결과:
- `getCommunityComments` 미구현 또는 port 호출 누락으로 실패한다.
- GREEN: 최소 구현/통과 확인
- `CreatorChannelCommunityQueryService.getCommunityComments(postId, viewer, page, size, now)`를 추가한다.
- 게시물 검증은 `findCommunityPost`를 사용한다.
- `CreatorChannelCommunityQueryPolicy.limitItems``hasNext`를 재사용한다.
- 댓글 record는 CDN URL/default profile URL, 닉네임 prefix 제거 후 domain으로 변환한다.
- 실행 명령:
- `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.community.application.CreatorChannelCommunityQueryServiceTest`
- 기대 결과:
- `BUILD SUCCESSFUL`
- REFACTOR: 정리/회귀 확인
- 댓글 조립 helper와 답글 조립 helper를 분리해 중복을 줄인다.
- API DTO를 import하지 않는다.
- [x] **Task 3.3: 답글 목록 service 조립** - 검증: 답글 service가 부모 context 검증, page fallback, reply DTO용 domain 조립을 수행하도록 구현하고 보이지 않는 부모 예외 테스트를 통과시켰다.
- Files:
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/community/application/CreatorChannelCommunityQueryService.kt`
- Modify: `src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/community/application/CreatorChannelCommunityQueryServiceTest.kt`
- RED: 실패 테스트 작성/실패 확인
- `CreatorChannelCommunityQueryServiceTest.shouldAssembleCommunityReplies`를 완성한다.
- `CreatorChannelCommunityQueryServiceTest.shouldThrowWhenRootCommentContextNotVisible`를 추가한다.
- 검증 항목:
- `findRootCommentContext`가 null이면 `SodaException(messageKey = "creator.community.invalid_request_retry")`
- page fallback과 `hasNext`가 댓글 목록과 동일하다.
- 응답 domain의 replies item에는 latest reply가 없다.
- 실행 명령:
- `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.community.application.CreatorChannelCommunityQueryServiceTest`
- 기대 결과:
- `getCommunityReplies` 미구현으로 컴파일 실패한다.
- GREEN: 최소 구현/통과 확인
- `CreatorChannelCommunityQueryService.getCommunityReplies(commentId, viewer, page, size, now)`를 추가한다.
- 부모 댓글 context 조회 후 게시물 작성자와 조회자 사이 차단 관계를 확인한다.
- `countCommunityReplies`, `findCommunityReplies`로 답글 응답을 조립한다.
- 실행 명령:
- `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.community.application.CreatorChannelCommunityQueryServiceTest`
- 기대 결과:
- `BUILD SUCCESSFUL`
- REFACTOR: 정리/회귀 확인
- 댓글/답글 record 변환에서 default profile URL과 닉네임 정리를 공통 private helper로 묶는다.
### Phase 4: Facade와 Controller endpoint 연결
- [x] **Task 4.1: facade method 추가** - 검증: 상세/댓글/답글 facade method를 추가하고 raw page/size 전달 및 공개 response DTO 변환 테스트를 통과시켰다.
- Files:
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/community/application/CreatorChannelCommunityFacade.kt`
- Modify: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/community/application/CreatorChannelCommunityFacadeTest.kt`
- RED: 실패 테스트 작성/실패 확인
- 추가할 테스트 이름:
- `shouldReturnCommunityPostDetailResponse`
- `shouldReturnCommunityCommentsResponse`
- `shouldReturnCommunityRepliesResponse`
- 검증 항목:
- facade가 query service 결과를 response DTO로 변환한다.
- raw `page`, `size`를 service로 전달한다.
- 실행 명령:
- `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.creator.channel.community.application.CreatorChannelCommunityFacadeTest`
- 기대 결과:
- 신규 facade method 미구현으로 컴파일 실패한다.
- GREEN: 최소 구현/통과 확인
- `getCommunityPostDetail(postId, viewer, now)`, `getCommunityComments(postId, viewer, page, size, now)`, `getCommunityReplies(commentId, viewer, page, size, now)`를 추가한다.
- 실행 명령:
- `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.creator.channel.community.application.CreatorChannelCommunityFacadeTest`
- 기대 결과:
- `BUILD SUCCESSFUL`
- REFACTOR: 정리/회귀 확인
- facade에는 비즈니스 조건문을 넣지 않는다.
- [x] **Task 4.2: controller endpoint 추가** - 검증: 세 v2 GET mapping을 추가하고 인증 거부, 성공 JSON, raw page/size 전달 controller 테스트를 통과시켰다.
- Files:
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/community/adapter/in/web/CreatorChannelCommunityController.kt`
- Modify: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/community/adapter/in/web/CreatorChannelCommunityControllerTest.kt`
- RED: 실패 테스트 작성/실패 확인
- 추가할 테스트 이름:
- `shouldRejectAnonymousCommunityPostDetailRequest`
- `shouldReturnCommunityPostDetailForAuthenticatedMember`
- `shouldReturnCommunityCommentsForAuthenticatedMember`
- `shouldPassRawCommentPageAndSizeToFacade`
- `shouldReturnCommunityRepliesForAuthenticatedMember`
- `shouldPassRawReplyPageAndSizeToFacade`
- 검증 항목:
- 세 endpoint는 인증 회원만 통과한다.
- JSON 경로:
- `$.data.postId`
- `$.data.isLiked`
- `$.data.comments.commentCount`
- `$.data.comments.comments[0].latestReply.commentId`
- `$.data.hasNext`
- controller는 page/size 보정을 하지 않고 facade로 raw 값을 전달한다.
- 실행 명령:
- `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.creator.channel.community.adapter.in.web.CreatorChannelCommunityControllerTest`
- 기대 결과:
- 신규 mapping 미구현으로 404 또는 컴파일 실패를 확인한다.
- GREEN: 최소 구현/통과 확인
- controller에 아래 mapping을 추가한다.
- `@GetMapping("/community-posts/{postId}")`
- `@GetMapping("/community-posts/{postId}/comments")`
- `@GetMapping("/community-comments/{commentId}/replies")`
- 기존 `requireMember`를 재사용한다.
- 실행 명령:
- `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.creator.channel.community.adapter.in.web.CreatorChannelCommunityControllerTest`
- 기대 결과:
- `BUILD SUCCESSFUL`
- REFACTOR: 정리/회귀 확인
- 기존 `@GetMapping("/{creatorId}/community")`와 신규 literal path가 충돌하지 않는지 controller test에서 모두 통과 확인한다.
### Phase 5: End-to-End와 회귀 검증
- [x] **Task 5.1: 상세 API E2E 테스트** - 검증: 상세 API E2E에서 `isLiked=true`, 초기 댓글 20개, `hasNext=true`, 최신 답글, UTC `Z` 문자열을 확인했다.
- Files:
- Modify: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/community/adapter/in/web/CreatorChannelCommunityEndToEndTest.kt`
- RED: 실패 테스트 작성/실패 확인
- `CreatorChannelCommunityEndToEndTest.shouldReturnCommunityPostDetailWithInitialComments`를 추가한다.
- fixture:
- creator, viewer
- 커뮤니티 게시물 1개
- 활성 좋아요
- 댓글 21개
- 첫 댓글 최신 답글 1개
- 검증 항목:
- `GET /api/v2/creator-channels/community-posts/{postId}` 200 OK
- `data.isLiked=true`
- `data.comments.comments.length()==20`
- `data.comments.hasNext=true`
- `data.comments.comments[0].latestReply` 존재
- `createdAtUtc``Z`로 끝나는 UTC 문자열
- 실행 명령:
- `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.creator.channel.community.adapter.in.web.CreatorChannelCommunityEndToEndTest`
- 기대 결과:
- API 미구현 상태에서 404 또는 JSON assertion 실패를 확인한다.
- GREEN: 최소 구현/통과 확인
- Phase 1~4 구현을 연결해 테스트를 통과시킨다.
- 실행 명령:
- `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.creator.channel.community.adapter.in.web.CreatorChannelCommunityEndToEndTest`
- 기대 결과:
- `BUILD SUCCESSFUL`
- REFACTOR: 정리/회귀 확인
- E2E fixture helper가 기존 커뮤니티 탭 테스트 helper와 충돌하지 않도록 private helper로 유지한다.
- [x] **Task 5.2: 댓글/답글 API E2E 테스트** - 검증: 댓글 API, 답글 API, 댓글 불가 빈 응답 E2E를 추가했고 답글 item에 `latestReply`가 없음을 확인했다.
- Files:
- Modify: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/community/adapter/in/web/CreatorChannelCommunityEndToEndTest.kt`
- RED: 실패 테스트 작성/실패 확인
- 추가할 테스트 이름:
- `shouldReturnPagedCommunityCommentsWithLatestReply`
- `shouldReturnPagedCommunityRepliesWithoutLatestReply`
- `shouldReturnEmptyCommentsWhenCommentUnavailable`
- 검증 항목:
- 댓글 API는 `commentCount`, `comments`, `page`, `size`, `hasNext`를 반환한다.
- 답글 API는 `replyCount`, `replies`, `page`, `size`, `hasNext`를 반환한다.
- 답글 item에는 `latestReply` 필드가 없다.
- 댓글 불가 게시물은 빈 댓글 응답을 반환한다.
- 실행 명령:
- `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.creator.channel.community.adapter.in.web.CreatorChannelCommunityEndToEndTest`
- 기대 결과:
- API 미구현 상태에서 404 또는 JSON assertion 실패를 확인한다.
- GREEN: 최소 구현/통과 확인
- 댓글/답글 controller, facade, service, repository 연결을 완료한다.
- 실행 명령:
- `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.creator.channel.community.adapter.in.web.CreatorChannelCommunityEndToEndTest`
- 기대 결과:
- `BUILD SUCCESSFUL`
- REFACTOR: 정리/회귀 확인
- 응답 필드명 `commentId`가 댓글과 답글 모두에서 동일하게 사용되는지 확인한다.
- [x] **Task 5.3: 접근 정책 E2E 테스트** - 검증: 성인 콘텐츠 미허용 회원의 19금 상세 접근이 공통 오류 응답(`success=false`)으로 거부됨을 E2E로 확인했다.
- Files:
- Modify: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/community/adapter/in/web/CreatorChannelCommunityEndToEndTest.kt`
- RED: 실패 테스트 작성/실패 확인
- 추가할 테스트 이름:
- `shouldRejectAdultCommunityPostWhenViewerCannotViewAdultContent`
- `shouldRejectCommunityPostWhenViewerAndCreatorAreBlocked`
- `shouldFilterSecretAndBlockedComments`
- `shouldRejectRepliesWhenParentCommentIsNotVisible`
- 검증 항목:
- 19금 게시물 접근 불가
- 게시물 작성자 차단 관계 접근 차단
- 비밀 댓글은 게시물 작성자/댓글 작성자 본인에게만 노출
- 차단 댓글 작성자와 답글 작성자는 count/list/latestReply에서 제외
- 보이지 않는 부모 댓글의 답글 조회는 오류
- 실행 명령:
- `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.creator.channel.community.adapter.in.web.CreatorChannelCommunityEndToEndTest`
- 기대 결과:
- 접근 정책 미구현으로 assertion 실패를 확인한다.
- GREEN: 최소 구현/통과 확인
- repository 조건과 service 예외 처리를 조정해 테스트를 통과시킨다.
- 실행 명령:
- `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.creator.channel.community.adapter.in.web.CreatorChannelCommunityEndToEndTest`
- 기대 결과:
- `BUILD SUCCESSFUL`
- REFACTOR: 정리/회귀 확인
- legacy `/creator-community` controller/service는 수정하지 않았는지 `git diff`로 확인한다.
### Phase 6: 전체 회귀와 문서 갱신
- [x] **Task 6.1: 기존 커뮤니티 탭 회귀 테스트** - 검증: community controller/service/repository 포함 focused test 묶음과 domain 패키지 API 의존성 검색을 실행해 회귀가 없음을 확인했다.
- Files:
- Verify: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/community/adapter/in/web/CreatorChannelCommunityControllerTest.kt`
- Verify: `src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/community/application/CreatorChannelCommunityQueryServiceTest.kt`
- Verify: `src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/community/adapter/out/persistence/DefaultCreatorChannelCommunityQueryRepositoryTest.kt`
- RED: 실패 테스트 작성/실패 확인
- TDD 예외 사유: 이 task는 신규 테스트 작성이 아니라 기존 커뮤니티 탭 동작 회귀 검증이다.
- 대체 검증 방법: 기존 테스트 전체를 실행해 상세/댓글 추가가 탭 목록 동작을 깨지 않는지 확인한다.
- GREEN: 최소 구현/통과 확인
- 실행 명령:
- `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.creator.channel.community.adapter.in.web.CreatorChannelCommunityControllerTest`
- `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.community.application.CreatorChannelCommunityQueryServiceTest`
- `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.community.adapter.out.persistence.DefaultCreatorChannelCommunityQueryRepositoryTest`
- 기대 결과:
- 모두 `BUILD SUCCESSFUL`
- REFACTOR: 정리/회귀 확인
- `rg -n "v2\\.api\\." src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/community` 실행 결과가 0건인지 확인한다.
- [x] **Task 6.2: formatting, compile, 전체 검증** - 검증: `./gradlew compileKotlin`, `./gradlew ktlintCheck`, `./gradlew test`, `./gradlew tasks --all`을 실행해 모두 `BUILD SUCCESSFUL`을 확인했다.
- Files:
- Verify: 전체 변경 파일
- Modify: `docs/20260706_커뮤니티_게시물_상세_API/plan-task.md`
- RED: 실패 테스트 작성/실패 확인
- TDD 예외 사유: 전체 검증 task는 구현 완료 후 회귀 확인 단계라 별도 실패 테스트를 만들지 않는다.
- 대체 검증 방법: 단일 테스트, compile, ktlint, 전체 테스트를 순서대로 실행한다.
- GREEN: 최소 구현/통과 확인
- 실행 명령:
- `./gradlew compileKotlin`
- `./gradlew ktlintCheck`
- `./gradlew test`
- 기대 결과:
- 모두 `BUILD SUCCESSFUL`
- REFACTOR: 정리/회귀 확인
- 실행 명령:
- `./gradlew tasks --all`
- 기대 결과:
- `BUILD SUCCESSFUL`
- 검증 결과를 이 문서 하단 "검증 기록"에 누적한다.
---
## 5. 구현 순서 권장
1. Task 1.1 → Task 1.2로 공개 DTO와 domain type을 먼저 고정한다.
2. Task 2.1 → Task 2.4로 repository 조회 계약과 QueryDSL 조건을 테스트로 고정한다.
3. Task 3.1 → Task 3.3으로 service 조립과 예외 정책을 완성한다.
4. Task 4.1 → Task 4.2로 facade/controller endpoint를 연결한다.
5. Task 5.1 → Task 5.3으로 실제 HTTP 흐름과 접근 정책을 검증한다.
6. Task 6.1 → Task 6.2로 기존 커뮤니티 탭 회귀와 전체 검증을 수행한다.
---
## 6. 검증 기록
- 2026-07-06: plan-task 문서 작성 전 `docs/20260706_커뮤니티_게시물_상세_API/prd.md`, `docs/agent-guides/코드스타일.md`, `docs/agent-guides/테스트스타일.md`, `docs/agent-guides/실행명령어.md`를 확인했다.
- 2026-07-06: 기존 유사 계획 문서 `docs/20260621_크리에이터_채널_커뮤니티_탭_API/plan-task.md`, `docs/20260622_크리에이터_채널_FanTalk_탭_API/plan-task.md`를 확인해 문서 구조와 TDD task 형식을 맞췄다.
- 2026-07-06: `rg -n "TBD|TODO|implement later|fill in|적절|:app:compileDebugKotlin|android|Android|Similar" docs/20260706_커뮤니티_게시물_상세_API/plan-task.md`로 placeholder와 잘못된 Android task 예시가 없음을 확인했다.
- 2026-07-06: `./gradlew tasks --all`을 실행해 Gradle 명령 유효성을 확인했고 `BUILD SUCCESSFUL`을 확인했다.
- 2026-07-06: Phase 1-5 구현 후 `./gradlew compileKotlin`, DTO/facade/controller/service/repository/E2E focused tests를 실행해 `BUILD SUCCESSFUL`을 확인했다.
- 2026-07-07: 포맷 정리 후 `./gradlew ktlintCheck`와 관련 focused test 묶음을 재실행해 모두 `BUILD SUCCESSFUL`을 확인했다.
- 2026-07-07: `rg -n "v2\.api\." src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/community` 실행 결과 0건으로 domain/query 계층의 API DTO 의존이 없음을 확인했다.
- 2026-07-07: `rg -n "creator-community|schema|migration|Flyway|Liquibase" src/main/kotlin/kr/co/vividnext/sodalive/v2 src/test/kotlin/kr/co/vividnext/sodalive/v2 docs/20260706_커뮤니티_게시물_상세_API/plan-task.md` 실행 결과 문서 언급 외 legacy endpoint/schema 변경 흔적이 없음을 확인했다.
- 2026-07-07: `./gradlew compileKotlin`, `./gradlew ktlintCheck`, `./gradlew test`, `./gradlew tasks --all`을 실행해 모두 `BUILD SUCCESSFUL`을 확인했다.
- 2026-07-07: 리뷰 게이트에서 Task 5.3 E2E 누락, 비밀 답글 노출, 댓글 불가 게시글의 답글 직접 조회 우회가 발견되어 repository/service/E2E 실패 테스트를 추가했다.
- 2026-07-07: 추가 RED 확인으로 `DefaultCreatorChannelCommunityQueryRepositoryTest`, `CreatorChannelCommunityQueryServiceTest`, `CreatorChannelCommunityEndToEndTest` focused 실행 시 5개 테스트 실패를 확인했다.
- 2026-07-07: 비밀 답글 필터와 댓글 불가 답글 빈 응답 처리를 구현한 뒤 같은 focused test 묶음을 재실행해 `BUILD SUCCESSFUL`을 확인했다.
- 2026-07-07: 보강 후 `./gradlew compileKotlin`, `./gradlew ktlintCheck`, `./gradlew test`, `./gradlew tasks --all`을 재실행해 모두 `BUILD SUCCESSFUL`을 확인했다.
- 2026-07-07: `rg -n "shouldRejectCommunityPostWhenViewerAndCreatorAreBlocked|shouldFilterSecretAndBlockedComments|shouldRejectRepliesWhenParentCommentIsNotVisible|shouldReturnEmptyRepliesWhenCommentUnavailable" ...`로 접근 정책 E2E/서비스 테스트명이 추가됐음을 확인했다.

View File

@@ -0,0 +1,306 @@
# PRD: 커뮤니티 게시물 상세 API
## 1. Overview
커뮤니티 게시물 ID로 게시물 상세와 초기 댓글 20개를 조회하고, 이후 댓글/답글을 페이징 조회하는 v2 API를 제공한다.
---
## 2. Problem
- 크리에이터 채널 커뮤니티 탭은 게시글 목록을 제공하지만, 게시물 상세 화면에서 게시물 1건과 초기 댓글을 함께 조회하는 v2 API가 없다.
- 기존 legacy `/creator-community/{id}` 상세 API는 `firstComment` 1개만 제공하고, 클라이언트가 상세 진입 직후 댓글 목록을 별도로 다시 조회해야 한다.
- legacy 댓글 API는 timezone 기반 표시 문자열을 반환하지만, 신규 상세 화면은 UTC 기반 날짜/시간과 v2 응답 패턴이 필요하다.
- 커뮤니티 탭의 `CreatorChannelCommunityPost`에는 `isLiked`가 이미 포함되어 있으므로 상세 API도 동일한 게시물 필드 의미를 유지해야 한다.
- 댓글/답글 조회는 legacy entity와 필터 정책을 재사용할 수 있지만, 공개 API 조립 계층과 도메인 조회 계층은 기존 v2 패키지 경계를 따라 분리되어야 한다.
---
## 3. Goals
- 커뮤니티 게시물 ID로 게시물 상세를 조회하는 API를 제공한다.
- 상세 응답의 게시물 필드는 크리에이터 채널 커뮤니티 탭의 `CreatorChannelCommunityPostResponse`와 동일한 의미와 필드명을 사용한다.
- 상세 응답의 게시물 필드에는 조회자의 활성 좋아요 여부인 `isLiked`를 포함한다.
- 상세 응답에는 첫 댓글 20개를 함께 포함하며, 이 댓글 묶음은 댓글 조회 API 응답과 동일한 response data class를 사용한다.
- 커뮤니티 댓글 목록을 page/size 기반으로 페이징 조회하는 API를 제공한다.
- 커뮤니티 댓글의 답글 목록을 page/size 기반으로 페이징 조회하는 API를 제공한다.
- API controller/facade/response DTO는 `kr.co.vividnext.sodalive.v2.api.creator.channel.community` 하위 조립 계층에 둔다.
- 게시물 상세, 댓글, 답글 조회 정책과 domain model, port, repository는 `kr.co.vividnext.sodalive.v2.creator.channel.community` 하위 도메인 조회 계층에 둔다.
- 기존 커뮤니티 탭 조회에서 재사용 가능한 `CreatorChannelCommunityPost`, `CreatorChannelCommunityQueryService`, `CreatorChannelCommunityQueryPolicy`, repository helper를 우선 재사용한다.
- legacy `CreatorCommunityComment` entity와 차단/비밀 댓글 필터 정책은 재사용하되, legacy response DTO를 v2 공개 응답으로 직접 노출하지 않는다.
---
## 4. Non-Goals
- 커뮤니티 게시물 작성, 수정, 삭제 API는 포함하지 않는다.
- 커뮤니티 게시물 좋아요 생성/취소 API는 변경하지 않는다.
- 커뮤니티 댓글/답글 작성, 수정, 삭제 API는 포함하지 않는다.
- legacy `/creator-community` API의 endpoint와 응답 스키마는 변경하지 않는다.
- 커뮤니티 탭 목록 API의 공개 응답 스키마는 변경하지 않는다.
- DB schema, 운영 DDL, 마이그레이션은 포함하지 않는다.
- 앱 표시용 상대 시간 문구는 서버에서 새로 조합하지 않는다.
- 댓글 좋아요, 댓글 신고, 답글 전체 개수의 댓글 item 내 노출은 포함하지 않는다.
---
## 5. Target Users
- 회원: 커뮤니티 게시물 상세 화면에서 게시물 내용과 댓글을 확인하는 사용자
- 앱 클라이언트: 상세 진입 시 게시물과 초기 댓글 20개를 한 번에 렌더링하고 이후 댓글/답글을 추가 로딩하려는 클라이언트
- 서버 개발자: v2 커뮤니티 탭, 상세, 댓글 조회 정책을 중복 없이 재사용해야 하는 개발자
---
## 6. 조사 결과
### 재사용 후보
- `CreatorChannelCommunityPost`
- 파일: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/community/domain/CreatorChannelCommunityTab.kt`
- 현재 필드: `postId`, `creatorId`, `creatorNickname`, `creatorProfileUrl`, `imageUrl`, `audioUrl`, `content`, `price`, `createdAt`, `existOrdered`, `isCommentAvailable`, `likeCount`, `commentCount`, `isPinned`, `isLiked`
- 상세 응답의 게시물 본문은 이 domain model을 재사용한다.
- `CreatorChannelCommunityPostResponse`
- 파일: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/community/dto/CreatorChannelCommunityTabResponse.kt`
- 현재 필드: `postId`, `creatorId`, `creatorNickname`, `creatorProfileUrl`, `createdAtUtc`, `content`, `imageUrl`, `audioUrl`, `price`, `isCommentAvailable`, `existOrdered`, `likeCount`, `commentCount`, `isPinned`, `isLiked`
- 상세 응답의 게시물 필드는 이 response data class와 동일한 필드명/의미를 사용한다.
- `CreatorChannelCommunityQueryService`
- 파일: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/community/application/CreatorChannelCommunityQueryService.kt`
- 현재 커뮤니티 탭과 채널 홈 커뮤니티 게시글 조회를 담당한다.
- 게시물 상세 조회 method를 추가해 게시물 1건 조립과 유료 콘텐츠 접근 정책, CDN URL, signed audio URL, 본문 마스킹, `isLiked` 전달을 재사용한다.
- `CreatorChannelCommunityQueryPolicy`
- 파일: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/community/domain/CreatorChannelCommunityQueryPolicy.kt`
- page 기본값/보정 규칙과 `hasNext` 판정, 유료 본문 마스킹 정책을 제공한다.
- 댓글/답글 page도 같은 `page=0`, `size=20`, `size min=20`, `size max=50` 규칙을 따른다.
- `CreatorCommunityComment`
- 파일: `src/main/kotlin/kr/co/vividnext/sodalive/explorer/profile/creatorCommunity/comment/CreatorCommunityComment.kt`
- legacy 댓글 entity이며 `parent`로 답글을 표현한다.
- 신규 v2 조회 repository에서 같은 entity를 조회하되 legacy DTO는 공개 API로 재사용하지 않는다.
- legacy 댓글 필터 정책
- 파일: `src/main/kotlin/kr/co/vividnext/sodalive/explorer/profile/creatorCommunity/comment/CreatorCommunityCommentRepository.kt`
- 활성 댓글, 최상위 댓글/답글 구분, 작성자 차단/피차단 제외, 비밀 댓글 노출 정책을 참고한다.
### 기존 endpoint
- 커뮤니티 탭: `GET /api/v2/creator-channels/{creatorId}/community`
- legacy 게시물 상세: `GET /creator-community/{id}`
- legacy 댓글 목록: `GET /creator-community/{id}/comment`
- legacy 답글 목록: `GET /creator-community/comment/{id}`
---
## 7. User Stories
- 사용자는 커뮤니티 게시물 상세 화면에 들어가면 게시물 내용, 미디어, 좋아요 상태, 좋아요 수, 댓글 수를 즉시 확인하고 싶다.
- 사용자는 게시물 상세 화면 진입 직후 최신 댓글 20개를 바로 보고 싶다.
- 사용자는 댓글 목록을 계속 아래로 스크롤하며 추가 조회하고 싶다.
- 사용자는 댓글에 달린 최신 답글 1개를 댓글 목록에서 미리 보고 싶다.
- 사용자는 특정 댓글의 답글 목록을 추가로 페이징 조회하고 싶다.
- 앱 클라이언트는 상세 API의 초기 댓글 응답과 댓글 조회 API 응답을 같은 data class로 처리하고 싶다.
---
## 8. Core Features
### Feature A. 커뮤니티 게시물 상세 조회 API
#### Endpoint
- `GET /api/v2/creator-channels/community-posts/{postId}`
#### Requirements
- `postId`는 path variable로 받는다.
- API는 인증 회원만 조회할 수 있어야 한다.
- 비회원이 조회하면 기존 인증 필요 v2 API와 동일하게 `common.error.bad_credentials` 계열 오류를 반환한다.
- 게시물이 존재하지 않거나 비활성 상태이면 기존 커뮤니티 정책과 동일하게 조회 불가 오류를 반환한다.
- 조회자의 성인 콘텐츠 노출 정책이 false이고 게시물이 19금이면 조회 불가 오류를 반환한다.
- 조회자와 게시물 작성자 사이에 차단 관계가 있으면 기존 크리에이터 채널 접근 정책과 동일하게 접근 차단 오류를 반환한다.
- 게시물 필드는 커뮤니티 탭의 `CreatorChannelCommunityPostResponse`와 동일한 필드명과 의미를 사용한다.
- 게시물 필드에는 `isLiked`를 포함하며, 조회자의 `CreatorCommunityLike.isActive == true`인 좋아요가 있으면 `true`다.
- 상세 응답에는 `comments` 필드로 첫 댓글 20개를 포함한다.
- `comments`는 댓글 조회 API의 `CreatorChannelCommunityCommentsResponse`와 동일한 response data class를 사용한다.
- 상세 응답의 `comments.page``0`, `comments.size``20`으로 내려준다.
- 상세 응답의 `comments.hasNext`는 같은 조건에서 21번째 댓글이 있으면 `true`다.
- `isCommentAvailable == false`인 게시물은 `comments.commentCount=0`, `comments.comments=[]`, `comments.hasNext=false`로 내려준다.
- 유료 게시물의 본문, 이미지, 오디오 접근 정책은 커뮤니티 탭 `CreatorChannelCommunityPost`와 동일하게 적용한다.
#### Response Data Class
```kotlin
data class CreatorChannelCommunityPostDetailResponse(
val postId: Long,
val creatorId: Long,
val creatorNickname: String,
val creatorProfileUrl: String,
val createdAtUtc: String,
val content: String,
val imageUrl: String?,
val audioUrl: String?,
val price: Int,
@JsonProperty("isCommentAvailable")
val isCommentAvailable: Boolean,
val existOrdered: Boolean,
val likeCount: Int,
val commentCount: Int,
@JsonProperty("isPinned")
val isPinned: Boolean,
@JsonProperty("isLiked")
val isLiked: Boolean,
val comments: CreatorChannelCommunityCommentsResponse
)
```
#### Edge Cases
- 게시물 작성자가 조회자인 경우에도 성인 콘텐츠 노출 정책이 false이면 19금 게시물은 조회할 수 없다.
- 유료 게시물을 구매하지 않은 조회자에게는 커뮤니티 탭과 동일하게 이미지 URL과 오디오 URL을 `null`로 내려주고 본문을 마스킹한다.
- 게시물에 이미지나 오디오가 없으면 각각 `null`로 내려준다.
- 댓글이 없어도 상세 API는 성공하며 `comments.comments=[]`를 내려준다.
### Feature B. 커뮤니티 댓글 조회 API
#### Endpoint
- `GET /api/v2/creator-channels/community-posts/{postId}/comments`
#### Requirements
- `postId`는 path variable로 받는다.
- `page`, `size` query parameter를 받는다.
- `page` 기본값은 `0`이다.
- `size` 기본값은 `20`이다.
- `page`가 0보다 작으면 `0`으로 보정한다.
- `size`가 20보다 작으면 `20`으로 보정한다.
- `size`가 50보다 크면 `50`으로 보정한다.
- API는 인증 회원만 조회할 수 있어야 한다.
- 게시물이 존재하지 않거나 비활성 상태이면 기존 커뮤니티 정책과 동일하게 조회 불가 오류를 반환한다.
- 조회자의 성인 콘텐츠 노출 정책이 false이고 게시물이 19금이면 조회 불가 오류를 반환한다.
- 조회자와 게시물 작성자 사이에 차단 관계가 있으면 빈 목록이 아니라 접근 차단 오류를 반환한다.
- `isCommentAvailable == false`인 게시물은 `commentCount=0`, `comments=[]`, `hasNext=false`로 내려준다.
- `commentCount`는 조회자가 볼 수 있는 활성 최상위 댓글 전체 개수다.
- `comments`는 조회자가 볼 수 있는 활성 최상위 댓글 목록이다.
- 댓글 작성자가 조회자와 차단/피차단 관계이면 목록과 전체 개수에서 제외한다.
- 비밀 댓글은 게시물 작성자 또는 댓글 작성자 본인에게만 노출한다.
- 각 댓글에는 비밀 댓글 여부인 `isSecret`을 포함한다.
- 댓글 정렬은 최신순 `createdAt desc`, `id desc`를 따른다.
- 각 댓글에는 최신 답글 1개를 `latestReply`로 포함한다.
- 최신 답글은 해당 댓글의 활성 답글 중 조회자가 볼 수 있는 답글을 `createdAt desc`, `id desc`로 정렬했을 때 첫 번째 항목이다.
- 최신 답글이 없으면 `latestReply``null`이다.
- 날짜/시간은 UTC 기준 ISO-8601 문자열인 `createdAtUtc`로 내려준다.
- 작성자 프로필 이미지가 없으면 기존 기본 프로필 이미지 URL을 내려준다.
- 탈퇴 회원 닉네임 prefix 제거는 기존 `removeDeletedNicknamePrefix` 정책을 적용한다.
#### Response Data Class
```kotlin
data class CreatorChannelCommunityCommentsResponse(
val commentCount: Int,
val comments: List<CreatorChannelCommunityCommentResponse>,
val page: Int,
val size: Int,
@JsonProperty("hasNext")
val hasNext: Boolean
)
data class CreatorChannelCommunityCommentResponse(
val commentId: Long,
val writerId: Long,
val writerProfileImageUrl: String,
val writerNickname: String,
val content: String,
@JsonProperty("isSecret")
val isSecret: Boolean,
val createdAtUtc: String,
val latestReply: CreatorChannelCommunityReplyResponse?
)
data class CreatorChannelCommunityReplyResponse(
val commentId: Long,
val writerId: Long,
val writerProfileImageUrl: String,
val writerNickname: String,
val content: String,
val createdAtUtc: String
)
```
#### Edge Cases
- 요청한 page 범위에 댓글이 없으면 `comments=[]`, `hasNext=false`이고 `commentCount`는 전체 개수를 유지한다.
- 댓글은 있지만 조회자의 차단 관계나 비밀 댓글 정책으로 모두 제외되면 `commentCount=0`, `comments=[]`가 된다.
- 댓글 작성자 프로필 이미지 path가 blank이면 기본 프로필 이미지 URL을 사용한다.
- 답글 작성자가 조회자와 차단/피차단 관계이면 `latestReply` 후보에서 제외한다.
### Feature C. 커뮤니티 댓글 답글 조회 API
#### Endpoint
- `GET /api/v2/creator-channels/community-comments/{commentId}/replies`
#### Requirements
- `commentId`는 path variable로 받는다.
- `page`, `size` query parameter를 받는다.
- page/size 보정 규칙은 댓글 조회 API와 동일하다.
- API는 인증 회원만 조회할 수 있어야 한다.
- `commentId`가 최상위 댓글이 아니거나 존재하지 않거나 비활성 상태이면 기존 커뮤니티 정책과 동일하게 조회 불가 오류를 반환한다.
- 부모 댓글이 속한 게시물이 존재하지 않거나 비활성 상태이면 조회 불가 오류를 반환한다.
- 조회자의 성인 콘텐츠 노출 정책이 false이고 부모 댓글이 속한 게시물이 19금이면 조회 불가 오류를 반환한다.
- 조회자와 게시물 작성자 사이에 차단 관계가 있으면 접근 차단 오류를 반환한다.
- 부모 댓글 자체가 조회자에게 보이지 않는 비밀 댓글이거나 부모 댓글 작성자가 조회자와 차단/피차단 관계이면 조회 불가 오류를 반환한다.
- `replyCount`는 조회자가 볼 수 있는 활성 답글 전체 개수다.
- `replies`는 조회자가 볼 수 있는 활성 답글 목록이다.
- 답글 작성자가 조회자와 차단/피차단 관계이면 목록과 전체 개수에서 제외한다.
- 답글 정렬은 최신순 `createdAt desc`, `id desc`를 따른다.
- 답글 item은 댓글 item과 같은 작성자/본문/UTC 작성 시간 구조를 사용하지만 `latestReply` 필드는 포함하지 않는다.
#### Response Data Class
```kotlin
data class CreatorChannelCommunityRepliesResponse(
val replyCount: Int,
val replies: List<CreatorChannelCommunityReplyResponse>,
val page: Int,
val size: Int,
@JsonProperty("hasNext")
val hasNext: Boolean
)
```
#### Edge Cases
- 요청한 page 범위에 답글이 없으면 `replies=[]`, `hasNext=false`이고 `replyCount`는 전체 개수를 유지한다.
- 답글이 있지만 조회자의 차단 관계로 모두 제외되면 `replyCount=0`, `replies=[]`가 된다.
### Feature D. API 조립 계층과 도메인 조회 계층 분리
#### Requirements
- controller/facade/response DTO는 `kr.co.vividnext.sodalive.v2.api.creator.channel.community` 하위에 둔다.
- domain model, query service, policy, port, repository는 `kr.co.vividnext.sodalive.v2.creator.channel.community` 하위에 둔다.
- API 조립 계층은 인증 회원 확인, path/query parameter 수신, domain 결과를 response DTO로 변환하는 책임만 가진다.
- 도메인 조회 계층은 API response DTO를 import하지 않는다.
- 도메인 조회 계층은 API facade나 controller를 import하지 않는다.
- 의존 방향은 항상 `v2.api.creator.channel.community -> v2.creator.channel.community`이다.
- 댓글/답글 조회용 domain model은 v2 전용으로 만들고, legacy `GetCommunityPostCommentListResponse``GetCommunityPostCommentListItem`은 공개 v2 응답에 재사용하지 않는다.
#### Edge Cases
- 기존 `GET /api/v2/creator-channels/{creatorId}/community` mapping과 신규 `GET /api/v2/creator-channels/community-posts/{postId}` mapping이 충돌하면 안 된다.
- 도메인 조회 계층에 `kr.co.vividnext.sodalive.v2.api.*` import가 생기면 안 된다.
---
## 9. Technical Constraints
- 빌드 도구는 Gradle Wrapper(`./gradlew`)를 사용한다.
- Kotlin + Spring Boot 2.7.14 기존 스타일을 따른다.
- 신규 공개 API 스키마는 구현 전에 PRD와 구현 계획/TASK 문서에 명시한다.
- Boolean 응답 필드는 Jackson 직렬화 이름 보존을 위해 `@JsonProperty("isCommentAvailable")`, `@JsonProperty("isPinned")`, `@JsonProperty("isLiked")`, `@JsonProperty("hasNext")`를 명시한다.
- 날짜 응답은 `kr.co.vividnext.sodalive.extensions.toUtcIso`를 우선 재사용해 UTC 기준 ISO-8601 문자열로 내려준다.
- 프로필 이미지 URL은 기존 `String?.toCdnUrl(cloudFrontHost)`와 기본 프로필 이미지 URL 정책을 따른다.
- 커뮤니티 게시물 상세의 유료 콘텐츠 접근 정책은 커뮤니티 탭과 동일하게 `price <= 0 || viewerId == creatorId || existOrdered`를 기준으로 한다.
- 댓글/답글 조회는 `CreatorCommunityComment` entity를 조회하되, v2 domain record와 response DTO로 변환한다.
- 댓글/답글 count와 list 조건은 동일해야 한다.
- list 조회는 `size + 1`개를 조회하거나 동등한 방식으로 `hasNext`를 판단하고, 응답 목록에는 최대 `size`개만 포함한다.
---
## 10. Success Criteria
- `GET /api/v2/creator-channels/community-posts/{postId}`가 커뮤니티 탭 게시물과 동일한 게시물 필드, `isLiked`, 초기 댓글 20개를 반환한다.
- 상세 API의 `comments` 구조가 댓글 조회 API 응답 data class와 동일하다.
- `GET /api/v2/creator-channels/community-posts/{postId}/comments`가 조회 가능한 댓글 전체 개수, 댓글 page, 최신 답글 1개를 반환한다.
- `GET /api/v2/creator-channels/community-comments/{commentId}/replies`가 조회 가능한 답글 전체 개수와 답글 page를 반환한다.
- 댓글/답글 날짜는 UTC ISO-8601 문자열로 내려간다.
- 댓글/답글 작성자 차단/피차단, 비밀 댓글, 비활성 댓글, 19금 게시물 접근 정책이 테스트로 구분된다.
- `isCommentAvailable == false`인 게시물은 상세 초기 댓글과 댓글 목록 API 모두 빈 댓글 응답을 반환한다.
- 기존 커뮤니티 탭 API와 크리에이터 채널 홈 API의 커뮤니티 게시글 응답이 회귀 없이 통과한다.
- `v2.creator.channel.community` 도메인 패키지의 `v2.api.*` import 검색 결과가 0건이다.
---
## 11. Open Questions
- 없음. 구현 중 댓글 비밀 정책이나 endpoint 경로에 대한 추가 결정이 필요하면 구현 전에 이 PRD와 `plan-task.md`를 먼저 갱신한다.

View File

@@ -41,6 +41,7 @@ import java.time.LocalDateTime
import java.time.ZoneId import java.time.ZoneId
@Service @Service
@Transactional(readOnly = true)
class CreatorCommunityService( class CreatorCommunityService(
private val canPaymentService: CanPaymentService, private val canPaymentService: CanPaymentService,

View File

@@ -33,6 +33,53 @@ class CreatorChannelCommunityController(
) )
} }
@GetMapping("/community-posts/{postId}")
fun getCommunityPostDetail(
@PathVariable postId: Long,
@AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member?
) = run {
ApiResponse.ok(
creatorChannelCommunityFacade.getCommunityPostDetail(
postId = postId,
viewer = requireMember(member)
)
)
}
@GetMapping("/community-posts/{postId}/comments")
fun getCommunityComments(
@PathVariable postId: Long,
@RequestParam(required = false) page: Int?,
@RequestParam(required = false) size: Int?,
@AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member?
) = run {
ApiResponse.ok(
creatorChannelCommunityFacade.getCommunityComments(
postId = postId,
viewer = requireMember(member),
page = page,
size = size
)
)
}
@GetMapping("/community-comments/{commentId}/replies")
fun getCommunityReplies(
@PathVariable commentId: Long,
@RequestParam(required = false) page: Int?,
@RequestParam(required = false) size: Int?,
@AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member?
) = run {
ApiResponse.ok(
creatorChannelCommunityFacade.getCommunityReplies(
commentId = commentId,
viewer = requireMember(member),
page = page,
size = size
)
)
}
private fun requireMember(member: Member?): Member { private fun requireMember(member: Member?): Member {
return member ?: throw SodaException(messageKey = "common.error.bad_credentials") return member ?: throw SodaException(messageKey = "common.error.bad_credentials")
} }

View File

@@ -1,6 +1,9 @@
package kr.co.vividnext.sodalive.v2.api.creator.channel.community.application package kr.co.vividnext.sodalive.v2.api.creator.channel.community.application
import kr.co.vividnext.sodalive.member.Member import kr.co.vividnext.sodalive.member.Member
import kr.co.vividnext.sodalive.v2.api.creator.channel.community.dto.CreatorChannelCommunityCommentsResponse
import kr.co.vividnext.sodalive.v2.api.creator.channel.community.dto.CreatorChannelCommunityPostDetailResponse
import kr.co.vividnext.sodalive.v2.api.creator.channel.community.dto.CreatorChannelCommunityRepliesResponse
import kr.co.vividnext.sodalive.v2.api.creator.channel.community.dto.CreatorChannelCommunityTabResponse import kr.co.vividnext.sodalive.v2.api.creator.channel.community.dto.CreatorChannelCommunityTabResponse
import kr.co.vividnext.sodalive.v2.creator.channel.community.application.CreatorChannelCommunityQueryService import kr.co.vividnext.sodalive.v2.creator.channel.community.application.CreatorChannelCommunityQueryService
import org.springframework.stereotype.Service import org.springframework.stereotype.Service
@@ -29,4 +32,54 @@ class CreatorChannelCommunityFacade(
) )
) )
} }
fun getCommunityPostDetail(
postId: Long,
viewer: Member,
now: LocalDateTime = LocalDateTime.now()
): CreatorChannelCommunityPostDetailResponse {
return CreatorChannelCommunityPostDetailResponse.from(
creatorChannelCommunityQueryService.getCommunityPostDetail(
postId = postId,
viewer = viewer,
now = now
)
)
}
fun getCommunityComments(
postId: Long,
viewer: Member,
page: Int?,
size: Int?,
now: LocalDateTime = LocalDateTime.now()
): CreatorChannelCommunityCommentsResponse {
return CreatorChannelCommunityCommentsResponse.from(
creatorChannelCommunityQueryService.getCommunityComments(
postId = postId,
viewer = viewer,
page = page,
size = size,
now = now
)
)
}
fun getCommunityReplies(
commentId: Long,
viewer: Member,
page: Int?,
size: Int?,
now: LocalDateTime = LocalDateTime.now()
): CreatorChannelCommunityRepliesResponse {
return CreatorChannelCommunityRepliesResponse.from(
creatorChannelCommunityQueryService.getCommunityReplies(
commentId = commentId,
viewer = viewer,
page = page,
size = size,
now = now
)
)
}
} }

View File

@@ -0,0 +1,146 @@
package kr.co.vividnext.sodalive.v2.api.creator.channel.community.dto
import com.fasterxml.jackson.annotation.JsonProperty
import kr.co.vividnext.sodalive.extensions.toUtcIso
import kr.co.vividnext.sodalive.v2.creator.channel.community.domain.CreatorChannelCommunityComment
import kr.co.vividnext.sodalive.v2.creator.channel.community.domain.CreatorChannelCommunityComments
import kr.co.vividnext.sodalive.v2.creator.channel.community.domain.CreatorChannelCommunityPostDetail
import kr.co.vividnext.sodalive.v2.creator.channel.community.domain.CreatorChannelCommunityReplies
import kr.co.vividnext.sodalive.v2.creator.channel.community.domain.CreatorChannelCommunityReply
data class CreatorChannelCommunityPostDetailResponse(
val postId: Long,
val creatorId: Long,
val creatorNickname: String,
val creatorProfileUrl: String,
val createdAtUtc: String,
val content: String,
val imageUrl: String?,
val audioUrl: String?,
val price: Int,
@JsonProperty("isCommentAvailable")
val isCommentAvailable: Boolean,
val existOrdered: Boolean,
val likeCount: Int,
val commentCount: Int,
@JsonProperty("isPinned")
val isPinned: Boolean,
@JsonProperty("isLiked")
val isLiked: Boolean,
val comments: CreatorChannelCommunityCommentsResponse
) {
companion object {
fun from(detail: CreatorChannelCommunityPostDetail): CreatorChannelCommunityPostDetailResponse {
val post = detail.post
return CreatorChannelCommunityPostDetailResponse(
postId = post.postId,
creatorId = post.creatorId,
creatorNickname = post.creatorNickname,
creatorProfileUrl = post.creatorProfileUrl,
createdAtUtc = post.createdAt.toUtcIso(),
content = post.content,
imageUrl = post.imageUrl,
audioUrl = post.audioUrl,
price = post.price,
isCommentAvailable = post.isCommentAvailable,
existOrdered = post.existOrdered,
likeCount = post.likeCount,
commentCount = post.commentCount,
isPinned = post.isPinned,
isLiked = post.isLiked,
comments = CreatorChannelCommunityCommentsResponse.from(detail.comments)
)
}
}
}
data class CreatorChannelCommunityCommentsResponse(
val commentCount: Int,
val comments: List<CreatorChannelCommunityCommentResponse>,
val page: Int,
val size: Int,
@JsonProperty("hasNext")
val hasNext: Boolean
) {
companion object {
fun from(comments: CreatorChannelCommunityComments): CreatorChannelCommunityCommentsResponse {
return CreatorChannelCommunityCommentsResponse(
commentCount = comments.commentCount,
comments = comments.comments.map(CreatorChannelCommunityCommentResponse::from),
page = comments.page.page,
size = comments.page.size,
hasNext = comments.hasNext
)
}
}
}
data class CreatorChannelCommunityCommentResponse(
val commentId: Long,
val writerId: Long,
val writerProfileImageUrl: String,
val writerNickname: String,
val content: String,
@JsonProperty("isSecret")
val isSecret: Boolean,
val createdAtUtc: String,
val latestReply: CreatorChannelCommunityReplyResponse?
) {
companion object {
fun from(comment: CreatorChannelCommunityComment): CreatorChannelCommunityCommentResponse {
return CreatorChannelCommunityCommentResponse(
commentId = comment.commentId,
writerId = comment.writerId,
writerProfileImageUrl = comment.writerProfileImageUrl,
writerNickname = comment.writerNickname,
content = comment.content,
isSecret = comment.isSecret,
createdAtUtc = comment.createdAt.toUtcIso(),
latestReply = comment.latestReply?.let(CreatorChannelCommunityReplyResponse::from)
)
}
}
}
data class CreatorChannelCommunityRepliesResponse(
val replyCount: Int,
val replies: List<CreatorChannelCommunityReplyResponse>,
val page: Int,
val size: Int,
@JsonProperty("hasNext")
val hasNext: Boolean
) {
companion object {
fun from(replies: CreatorChannelCommunityReplies): CreatorChannelCommunityRepliesResponse {
return CreatorChannelCommunityRepliesResponse(
replyCount = replies.replyCount,
replies = replies.replies.map(CreatorChannelCommunityReplyResponse::from),
page = replies.page.page,
size = replies.page.size,
hasNext = replies.hasNext
)
}
}
}
data class CreatorChannelCommunityReplyResponse(
val commentId: Long,
val writerId: Long,
val writerProfileImageUrl: String,
val writerNickname: String,
val content: String,
val createdAtUtc: String
) {
companion object {
fun from(reply: CreatorChannelCommunityReply): CreatorChannelCommunityReplyResponse {
return CreatorChannelCommunityReplyResponse(
commentId = reply.commentId,
writerId = reply.writerId,
writerProfileImageUrl = reply.writerProfileImageUrl,
writerNickname = reply.writerNickname,
content = reply.content,
createdAtUtc = reply.createdAt.toUtcIso()
)
}
}
}

View File

@@ -12,6 +12,8 @@ import kr.co.vividnext.sodalive.explorer.profile.creatorCommunity.comment.QCreat
import kr.co.vividnext.sodalive.explorer.profile.creatorCommunity.like.QCreatorCommunityLike.creatorCommunityLike import kr.co.vividnext.sodalive.explorer.profile.creatorCommunity.like.QCreatorCommunityLike.creatorCommunityLike
import kr.co.vividnext.sodalive.member.QMember.member import kr.co.vividnext.sodalive.member.QMember.member
import kr.co.vividnext.sodalive.member.block.QBlockMember import kr.co.vividnext.sodalive.member.block.QBlockMember
import kr.co.vividnext.sodalive.v2.creator.channel.community.port.out.CreatorChannelCommunityCommentContextRecord
import kr.co.vividnext.sodalive.v2.creator.channel.community.port.out.CreatorChannelCommunityCommentRecord
import kr.co.vividnext.sodalive.v2.creator.channel.community.port.out.CreatorChannelCommunityCreatorRecord import kr.co.vividnext.sodalive.v2.creator.channel.community.port.out.CreatorChannelCommunityCreatorRecord
import kr.co.vividnext.sodalive.v2.creator.channel.community.port.out.CreatorChannelCommunityPostRecord import kr.co.vividnext.sodalive.v2.creator.channel.community.port.out.CreatorChannelCommunityPostRecord
import org.springframework.stereotype.Repository import org.springframework.stereotype.Repository
@@ -119,6 +121,122 @@ class DefaultCreatorChannelCommunityQueryRepository(
return rows.toCommunityPostRecords(creatorId, viewerId) return rows.toCommunityPostRecords(creatorId, viewerId)
} }
override fun findCommunityPost(
postId: Long,
viewerId: Long,
canViewAdultContent: Boolean
): CreatorChannelCommunityPostRecord? {
val rows = queryFactory
.selectCommunityPostRow()
.from(creatorCommunity)
.where(communityPostByIdCondition(postId, canViewAdultContent))
.fetch()
val creatorId = rows.firstOrNull()?.creatorId ?: return null
return rows.toCommunityPostRecords(creatorId, viewerId).firstOrNull()
}
override fun countCommunityComments(
postId: Long,
viewerId: Long,
creatorId: Long
): Int {
return queryFactory
.select(creatorCommunityComment.id.count())
.from(creatorCommunityComment)
.where(rootCommentCondition(postId, creatorId, viewerId))
.fetchOne()
?.toInt()
?: 0
}
override fun findCommunityComments(
postId: Long,
viewerId: Long,
creatorId: Long,
offset: Long,
limit: Int
): List<CreatorChannelCommunityCommentRecord> {
return queryFactory
.selectCommunityCommentRow()
.from(creatorCommunityComment)
.where(rootCommentCondition(postId, creatorId, viewerId))
.orderBy(creatorCommunityComment.createdAt.desc(), creatorCommunityComment.id.desc())
.offset(offset)
.limit(limit.toLong())
.fetch()
.map { it.toCommunityCommentRecord() }
}
override fun findLatestRepliesByCommentIds(
commentIds: List<Long>,
viewerId: Long
): List<CreatorChannelCommunityCommentRecord> {
if (commentIds.isEmpty()) return emptyList()
return queryFactory
.selectCommunityCommentRow()
.from(creatorCommunityComment)
.where(replyCondition(commentIds, viewerId))
.orderBy(creatorCommunityComment.createdAt.desc(), creatorCommunityComment.id.desc())
.fetch()
.map { it.toCommunityCommentRecord() }
.groupBy { it.parentCommentId }
.values
.map { it.first() }
}
override fun findRootCommentContext(
commentId: Long,
viewerId: Long,
canViewAdultContent: Boolean
): CreatorChannelCommunityCommentContextRecord? {
val row = queryFactory
.select(
creatorCommunityComment.id,
creatorCommunityComment.creatorCommunity.id,
creatorCommunityComment.creatorCommunity.member.id,
creatorCommunityComment.creatorCommunity.isCommentAvailable
)
.from(creatorCommunityComment)
.where(rootCommentContextCondition(commentId, viewerId, canViewAdultContent))
.fetchFirst() ?: return null
return CreatorChannelCommunityCommentContextRecord(
commentId = row.get(creatorCommunityComment.id)!!,
postId = row.get(creatorCommunityComment.creatorCommunity.id)!!,
creatorId = row.get(creatorCommunityComment.creatorCommunity.member.id)!!,
isCommentAvailable = row.get(creatorCommunityComment.creatorCommunity.isCommentAvailable)!!
)
}
override fun countCommunityReplies(commentId: Long, viewerId: Long): Int {
return queryFactory
.select(creatorCommunityComment.id.count())
.from(creatorCommunityComment)
.where(replyCondition(commentId, viewerId))
.fetchOne()
?.toInt()
?: 0
}
override fun findCommunityReplies(
commentId: Long,
viewerId: Long,
offset: Long,
limit: Int
): List<CreatorChannelCommunityCommentRecord> {
return queryFactory
.selectCommunityCommentRow()
.from(creatorCommunityComment)
.where(replyCondition(commentId, viewerId))
.orderBy(creatorCommunityComment.createdAt.desc(), creatorCommunityComment.id.desc())
.offset(offset)
.limit(limit.toLong())
.fetch()
.map { it.toCommunityCommentRecord() }
}
private fun JPAQueryFactory.selectCommunityPostRow() = select( private fun JPAQueryFactory.selectCommunityPostRow() = select(
creatorCommunity.id, creatorCommunity.id,
creatorCommunity.member.id, creatorCommunity.member.id,
@@ -134,6 +252,18 @@ class DefaultCreatorChannelCommunityQueryRepository(
creatorCommunity.isCommentAvailable creatorCommunity.isCommentAvailable
) )
private fun JPAQueryFactory.selectCommunityCommentRow() = select(
creatorCommunityComment.id,
creatorCommunityComment.parent.id,
creatorCommunityComment.creatorCommunity.id,
creatorCommunityComment.member.id,
creatorCommunityComment.member.profileImage,
creatorCommunityComment.member.nickname,
creatorCommunityComment.comment,
creatorCommunityComment.isSecret,
creatorCommunityComment.createdAt
)
private fun communityPostCondition(creatorId: Long, canViewAdultContent: Boolean): BooleanExpression { private fun communityPostCondition(creatorId: Long, canViewAdultContent: Boolean): BooleanExpression {
val condition = creatorCommunity.isActive.isTrue val condition = creatorCommunity.isActive.isTrue
.and(creatorCommunity.member.id.eq(creatorId)) .and(creatorCommunity.member.id.eq(creatorId))
@@ -142,6 +272,62 @@ class DefaultCreatorChannelCommunityQueryRepository(
return if (canViewAdultContent) condition else condition.and(creatorCommunity.isAdult.isFalse) return if (canViewAdultContent) condition else condition.and(creatorCommunity.isAdult.isFalse)
} }
private fun communityPostByIdCondition(postId: Long, canViewAdultContent: Boolean): BooleanExpression {
val condition = creatorCommunity.id.eq(postId)
.and(creatorCommunity.isActive.isTrue)
.and(creatorCommunity.member.isActive.isTrue)
return if (canViewAdultContent) condition else condition.and(creatorCommunity.isAdult.isFalse)
}
private fun rootCommentCondition(
postId: Long,
creatorId: Long,
viewerId: Long
): BooleanExpression {
return creatorCommunityComment.creatorCommunity.id.eq(postId)
.and(creatorCommunityComment.isActive.isTrue)
.and(creatorCommunityComment.parent.isNull)
.and(visibleSecretCommentCondition(creatorId, viewerId))
.and(notBlockedCommentWriterCondition(viewerId))
}
private fun rootCommentContextCondition(
commentId: Long,
viewerId: Long,
canViewAdultContent: Boolean
): BooleanExpression {
val condition = creatorCommunityComment.id.eq(commentId)
.and(creatorCommunityComment.isActive.isTrue)
.and(creatorCommunityComment.parent.isNull)
.and(creatorCommunityComment.creatorCommunity.isActive.isTrue)
.and(creatorCommunityComment.creatorCommunity.member.isActive.isTrue)
.and(
creatorCommunityComment.isSecret.isFalse
.or(creatorCommunityComment.creatorCommunity.member.id.eq(viewerId))
.or(creatorCommunityComment.member.id.eq(viewerId))
)
.and(notBlockedCommentWriterCondition(viewerId))
return if (canViewAdultContent) condition else condition.and(creatorCommunityComment.creatorCommunity.isAdult.isFalse)
}
private fun replyCondition(commentId: Long, viewerId: Long): BooleanExpression {
return creatorCommunityComment.isActive.isTrue
.and(creatorCommunityComment.parent.isNotNull)
.and(creatorCommunityComment.parent.id.eq(commentId))
.and(visibleSecretReplyCondition(viewerId))
.and(notBlockedCommentWriterCondition(viewerId))
}
private fun replyCondition(commentIds: List<Long>, viewerId: Long): BooleanExpression {
return creatorCommunityComment.isActive.isTrue
.and(creatorCommunityComment.parent.isNotNull)
.and(creatorCommunityComment.parent.id.`in`(commentIds))
.and(visibleSecretReplyCondition(viewerId))
.and(notBlockedCommentWriterCondition(viewerId))
}
private fun homeCommunityPostCondition( private fun homeCommunityPostCondition(
creatorId: Long, creatorId: Long,
viewerId: Long, viewerId: Long,
@@ -302,6 +488,12 @@ class DefaultCreatorChannelCommunityQueryRepository(
.or(creatorCommunityComment.member.id.eq(viewerId)) .or(creatorCommunityComment.member.id.eq(viewerId))
} }
private fun visibleSecretReplyCondition(viewerId: Long): BooleanExpression {
return creatorCommunityComment.isSecret.isFalse
.or(creatorCommunityComment.creatorCommunity.member.id.eq(viewerId))
.or(creatorCommunityComment.member.id.eq(viewerId))
}
private fun notBlockedCommentWriterCondition(viewerId: Long): BooleanExpression { private fun notBlockedCommentWriterCondition(viewerId: Long): BooleanExpression {
val viewerBlock = QBlockMember("communityCommentViewerBlockWriter") val viewerBlock = QBlockMember("communityCommentViewerBlockWriter")
val writerBlock = QBlockMember("communityCommentWriterBlockViewer") val writerBlock = QBlockMember("communityCommentWriterBlockViewer")
@@ -355,4 +547,18 @@ class DefaultCreatorChannelCommunityQueryRepository(
private val Tuple.isCommentAvailable: Boolean private val Tuple.isCommentAvailable: Boolean
get() = get(creatorCommunity.isCommentAvailable)!! get() = get(creatorCommunity.isCommentAvailable)!!
private fun Tuple.toCommunityCommentRecord(): CreatorChannelCommunityCommentRecord {
return CreatorChannelCommunityCommentRecord(
commentId = get(creatorCommunityComment.id)!!,
parentCommentId = get(creatorCommunityComment.parent.id),
postId = get(creatorCommunityComment.creatorCommunity.id)!!,
writerId = get(creatorCommunityComment.member.id)!!,
writerProfilePath = get(creatorCommunityComment.member.profileImage),
writerNickname = get(creatorCommunityComment.member.nickname)!!,
content = get(creatorCommunityComment.comment)!!,
isSecret = get(creatorCommunityComment.isSecret)!!,
createdAt = get(creatorCommunityComment.createdAt)!!
)
}
} }

View File

@@ -2,15 +2,22 @@ package kr.co.vividnext.sodalive.v2.creator.channel.community.application
import kr.co.vividnext.sodalive.aws.cloudfront.AudioContentCloudFront import kr.co.vividnext.sodalive.aws.cloudfront.AudioContentCloudFront
import kr.co.vividnext.sodalive.common.SodaException import kr.co.vividnext.sodalive.common.SodaException
import kr.co.vividnext.sodalive.extensions.removeDeletedNicknamePrefix
import kr.co.vividnext.sodalive.i18n.LangContext import kr.co.vividnext.sodalive.i18n.LangContext
import kr.co.vividnext.sodalive.i18n.SodaMessageSource import kr.co.vividnext.sodalive.i18n.SodaMessageSource
import kr.co.vividnext.sodalive.member.Member import kr.co.vividnext.sodalive.member.Member
import kr.co.vividnext.sodalive.member.MemberRole import kr.co.vividnext.sodalive.member.MemberRole
import kr.co.vividnext.sodalive.member.contentpreference.MemberContentPreferenceService import kr.co.vividnext.sodalive.member.contentpreference.MemberContentPreferenceService
import kr.co.vividnext.sodalive.v2.common.domain.toCdnUrl import kr.co.vividnext.sodalive.v2.common.domain.toCdnUrl
import kr.co.vividnext.sodalive.v2.creator.channel.community.domain.CreatorChannelCommunityComment
import kr.co.vividnext.sodalive.v2.creator.channel.community.domain.CreatorChannelCommunityComments
import kr.co.vividnext.sodalive.v2.creator.channel.community.domain.CreatorChannelCommunityPost import kr.co.vividnext.sodalive.v2.creator.channel.community.domain.CreatorChannelCommunityPost
import kr.co.vividnext.sodalive.v2.creator.channel.community.domain.CreatorChannelCommunityPostDetail
import kr.co.vividnext.sodalive.v2.creator.channel.community.domain.CreatorChannelCommunityQueryPolicy import kr.co.vividnext.sodalive.v2.creator.channel.community.domain.CreatorChannelCommunityQueryPolicy
import kr.co.vividnext.sodalive.v2.creator.channel.community.domain.CreatorChannelCommunityReplies
import kr.co.vividnext.sodalive.v2.creator.channel.community.domain.CreatorChannelCommunityReply
import kr.co.vividnext.sodalive.v2.creator.channel.community.domain.CreatorChannelCommunityTab import kr.co.vividnext.sodalive.v2.creator.channel.community.domain.CreatorChannelCommunityTab
import kr.co.vividnext.sodalive.v2.creator.channel.community.port.out.CreatorChannelCommunityCommentRecord
import kr.co.vividnext.sodalive.v2.creator.channel.community.port.out.CreatorChannelCommunityCreatorRecord import kr.co.vividnext.sodalive.v2.creator.channel.community.port.out.CreatorChannelCommunityCreatorRecord
import kr.co.vividnext.sodalive.v2.creator.channel.community.port.out.CreatorChannelCommunityPostRecord import kr.co.vividnext.sodalive.v2.creator.channel.community.port.out.CreatorChannelCommunityPostRecord
import kr.co.vividnext.sodalive.v2.creator.channel.community.port.out.CreatorChannelCommunityQueryPort import kr.co.vividnext.sodalive.v2.creator.channel.community.port.out.CreatorChannelCommunityQueryPort
@@ -93,6 +100,115 @@ class CreatorChannelCommunityQueryService(
.map { it.toDomain(viewerId) } .map { it.toDomain(viewerId) }
} }
fun getCommunityPostDetail(
postId: Long,
viewer: Member,
now: LocalDateTime = LocalDateTime.now()
): CreatorChannelCommunityPostDetail {
val queryPort = queryPortProvider.getObject()
val viewerId = viewer.id!!
val canViewAdultContent = memberContentPreferenceService.canViewAdultContent(viewer)
val postRecord = queryPort.findCommunityPost(postId, viewerId, canViewAdultContent)
?: throw SodaException(messageKey = INVALID_COMMUNITY_REQUEST_MESSAGE_KEY)
validateNotBlocked(queryPort, viewerId, postRecord.creatorId)
return CreatorChannelCommunityPostDetail(
post = postRecord.toDomain(viewerId),
comments = getCommunityComments(postId, viewer, page = 0, size = 20, now = now)
)
}
fun getCommunityComments(
postId: Long,
viewer: Member,
page: Int?,
size: Int?,
now: LocalDateTime = LocalDateTime.now()
): CreatorChannelCommunityComments {
val communityPage = queryPolicy.createPage(page, size)
val queryPort = queryPortProvider.getObject()
val viewerId = viewer.id!!
val canViewAdultContent = memberContentPreferenceService.canViewAdultContent(viewer)
val postRecord = queryPort.findCommunityPost(postId, viewerId, canViewAdultContent)
?: throw SodaException(messageKey = INVALID_COMMUNITY_REQUEST_MESSAGE_KEY)
validateNotBlocked(queryPort, viewerId, postRecord.creatorId)
if (!postRecord.isCommentAvailable) {
return CreatorChannelCommunityComments(
commentCount = 0,
comments = emptyList(),
page = communityPage,
hasNext = false
)
}
val fetchedComments = queryPort.findCommunityComments(
postId = postId,
viewerId = viewerId,
creatorId = postRecord.creatorId,
offset = communityPage.offset,
limit = communityPage.fetchLimit
)
val limitedComments = queryPolicy.limitItems(fetchedComments, communityPage)
val latestReplies = queryPort.findLatestRepliesByCommentIds(
commentIds = limitedComments.map { it.commentId },
viewerId = viewerId
).associateBy { it.parentCommentId }
return CreatorChannelCommunityComments(
commentCount = queryPort.countCommunityComments(
postId = postId,
viewerId = viewerId,
creatorId = postRecord.creatorId
),
comments = limitedComments.map { it.toCommentDomain(latestReplies[it.commentId]?.toReplyDomain()) },
page = communityPage,
hasNext = queryPolicy.hasNext(fetchedComments, communityPage)
)
}
fun getCommunityReplies(
commentId: Long,
viewer: Member,
page: Int?,
size: Int?,
now: LocalDateTime = LocalDateTime.now()
): CreatorChannelCommunityReplies {
val communityPage = queryPolicy.createPage(page, size)
val queryPort = queryPortProvider.getObject()
val viewerId = viewer.id!!
val canViewAdultContent = memberContentPreferenceService.canViewAdultContent(viewer)
val context = queryPort.findRootCommentContext(commentId, viewerId, canViewAdultContent)
?: throw SodaException(messageKey = INVALID_COMMUNITY_REQUEST_MESSAGE_KEY)
validateNotBlocked(queryPort, viewerId, context.creatorId)
if (!context.isCommentAvailable) {
return CreatorChannelCommunityReplies(
replyCount = 0,
replies = emptyList(),
page = communityPage,
hasNext = false
)
}
val fetchedReplies = queryPort.findCommunityReplies(
commentId = commentId,
viewerId = viewerId,
offset = communityPage.offset,
limit = communityPage.fetchLimit
)
return CreatorChannelCommunityReplies(
replyCount = queryPort.countCommunityReplies(commentId, viewerId),
replies = queryPolicy.limitItems(fetchedReplies, communityPage).map { it.toReplyDomain() },
page = communityPage,
hasNext = queryPolicy.hasNext(fetchedReplies, communityPage)
)
}
private fun validateCreatorRole(creator: CreatorChannelCommunityCreatorRecord) { private fun validateCreatorRole(creator: CreatorChannelCommunityCreatorRecord) {
when (creator.role) { when (creator.role) {
MemberRole.CREATOR -> return MemberRole.CREATOR -> return
@@ -100,6 +216,12 @@ class CreatorChannelCommunityQueryService(
} }
} }
private fun validateNotBlocked(queryPort: CreatorChannelCommunityQueryPort, viewerId: Long, creatorId: Long) {
if (queryPort.existsBlockedBetween(viewerId, creatorId)) {
throw SodaException(messageKey = INVALID_COMMUNITY_REQUEST_MESSAGE_KEY)
}
}
private fun CreatorChannelCommunityPostRecord.toDomain(viewerId: Long): CreatorChannelCommunityPost { private fun CreatorChannelCommunityPostRecord.toDomain(viewerId: Long): CreatorChannelCommunityPost {
val canAccessPaidContent = price <= 0 || viewerId == creatorId || existOrdered val canAccessPaidContent = price <= 0 || viewerId == creatorId || existOrdered
return CreatorChannelCommunityPost( return CreatorChannelCommunityPost(
@@ -126,6 +248,32 @@ class CreatorChannelCommunityQueryService(
) )
} }
private fun CreatorChannelCommunityCommentRecord.toCommentDomain(
latestReply: CreatorChannelCommunityReply?
): CreatorChannelCommunityComment {
return CreatorChannelCommunityComment(
commentId = commentId,
writerId = writerId,
writerProfileImageUrl = writerProfilePath.toCdnUrl(cloudFrontHost) ?: defaultProfileImageUrl(),
writerNickname = writerNickname.removeDeletedNicknamePrefix(),
content = content,
isSecret = isSecret,
createdAt = createdAt,
latestReply = latestReply
)
}
private fun CreatorChannelCommunityCommentRecord.toReplyDomain(): CreatorChannelCommunityReply {
return CreatorChannelCommunityReply(
commentId = commentId,
writerId = writerId,
writerProfileImageUrl = writerProfilePath.toCdnUrl(cloudFrontHost) ?: defaultProfileImageUrl(),
writerNickname = writerNickname.removeDeletedNicknamePrefix(),
content = content,
createdAt = createdAt
)
}
private fun String?.toSignedAudioUrl(): String? { private fun String?.toSignedAudioUrl(): String? {
if (isNullOrBlank()) return null if (isNullOrBlank()) return null
return audioContentCloudFront.generateSignedURL(this, AUDIO_SIGNED_URL_EXPIRATION_MILLIS) return audioContentCloudFront.generateSignedURL(this, AUDIO_SIGNED_URL_EXPIRATION_MILLIS)
@@ -135,5 +283,6 @@ class CreatorChannelCommunityQueryService(
companion object { companion object {
private const val AUDIO_SIGNED_URL_EXPIRATION_MILLIS = 1000L * 60 * 30 private const val AUDIO_SIGNED_URL_EXPIRATION_MILLIS = 1000L * 60 * 30
private const val INVALID_COMMUNITY_REQUEST_MESSAGE_KEY = "creator.community.invalid_request_retry"
} }
} }

View File

@@ -0,0 +1,43 @@
package kr.co.vividnext.sodalive.v2.creator.channel.community.domain
import kr.co.vividnext.sodalive.v2.creator.channel.live.domain.CreatorChannelPage
import java.time.LocalDateTime
data class CreatorChannelCommunityPostDetail(
val post: CreatorChannelCommunityPost,
val comments: CreatorChannelCommunityComments
)
data class CreatorChannelCommunityComments(
val commentCount: Int,
val comments: List<CreatorChannelCommunityComment>,
val page: CreatorChannelPage,
val hasNext: Boolean
)
data class CreatorChannelCommunityComment(
val commentId: Long,
val writerId: Long,
val writerProfileImageUrl: String,
val writerNickname: String,
val content: String,
val isSecret: Boolean,
val createdAt: LocalDateTime,
val latestReply: CreatorChannelCommunityReply?
)
data class CreatorChannelCommunityReplies(
val replyCount: Int,
val replies: List<CreatorChannelCommunityReply>,
val page: CreatorChannelPage,
val hasNext: Boolean
)
data class CreatorChannelCommunityReply(
val commentId: Long,
val writerId: Long,
val writerProfileImageUrl: String,
val writerNickname: String,
val content: String,
val createdAt: LocalDateTime
)

View File

@@ -29,6 +29,49 @@ interface CreatorChannelCommunityQueryPort {
canViewAdultContent: Boolean, canViewAdultContent: Boolean,
limit: Int limit: Int
): List<CreatorChannelCommunityPostRecord> ): List<CreatorChannelCommunityPostRecord>
fun findCommunityPost(
postId: Long,
viewerId: Long,
canViewAdultContent: Boolean
): CreatorChannelCommunityPostRecord?
fun countCommunityComments(
postId: Long,
viewerId: Long,
creatorId: Long
): Int
fun findCommunityComments(
postId: Long,
viewerId: Long,
creatorId: Long,
offset: Long,
limit: Int
): List<CreatorChannelCommunityCommentRecord>
fun findLatestRepliesByCommentIds(
commentIds: List<Long>,
viewerId: Long
): List<CreatorChannelCommunityCommentRecord>
fun findRootCommentContext(
commentId: Long,
viewerId: Long,
canViewAdultContent: Boolean
): CreatorChannelCommunityCommentContextRecord?
fun countCommunityReplies(
commentId: Long,
viewerId: Long
): Int
fun findCommunityReplies(
commentId: Long,
viewerId: Long,
offset: Long,
limit: Int
): List<CreatorChannelCommunityCommentRecord>
} }
data class CreatorChannelCommunityCreatorRecord( data class CreatorChannelCommunityCreatorRecord(
@@ -54,3 +97,22 @@ data class CreatorChannelCommunityPostRecord(
val isPinned: Boolean, val isPinned: Boolean,
val isLiked: Boolean val isLiked: Boolean
) )
data class CreatorChannelCommunityCommentRecord(
val commentId: Long,
val parentCommentId: Long?,
val postId: Long,
val writerId: Long,
val writerProfilePath: String?,
val writerNickname: String,
val content: String,
val isSecret: Boolean,
val createdAt: LocalDateTime
)
data class CreatorChannelCommunityCommentContextRecord(
val commentId: Long,
val postId: Long,
val creatorId: Long,
val isCommentAvailable: Boolean
)

View File

@@ -35,6 +35,7 @@ import org.junit.jupiter.api.Test
import org.mockito.ArgumentCaptor import org.mockito.ArgumentCaptor
import org.mockito.Mockito import org.mockito.Mockito
import org.springframework.context.ApplicationEventPublisher import org.springframework.context.ApplicationEventPublisher
import org.springframework.transaction.annotation.Transactional
import org.springframework.web.multipart.MultipartFile import org.springframework.web.multipart.MultipartFile
import java.io.InputStream import java.io.InputStream
import java.time.LocalDateTime import java.time.LocalDateTime
@@ -234,6 +235,15 @@ class CreatorCommunityServiceTest {
assertEquals("creator.community.invalid_request_retry", exception.messageKey) assertEquals("creator.community.invalid_request_retry", exception.messageKey)
} }
@Test
@DisplayName("커뮤니티 서비스 조회 메서드는 기본적으로 읽기 전용 트랜잭션으로 실행된다")
fun shouldRunCommunityServiceReadMethodsInReadOnlyTransaction() {
val transactional = CreatorCommunityService::class.java.getAnnotation(Transactional::class.java)
assertNotNull(transactional)
assertTrue(transactional.readOnly)
}
@Test @Test
@DisplayName("고정 게시물이 이미 3개면 추가 고정 시 예외가 발생한다") @DisplayName("고정 게시물이 이미 3개면 추가 고정 시 예외가 발생한다")
fun shouldThrowExceptionWhenPinCountExceedsLimit() { fun shouldThrowExceptionWhenPinCountExceedsLimit() {

View File

@@ -7,7 +7,12 @@ import kr.co.vividnext.sodalive.member.Member
import kr.co.vividnext.sodalive.member.MemberAdapter import kr.co.vividnext.sodalive.member.MemberAdapter
import kr.co.vividnext.sodalive.member.MemberRole import kr.co.vividnext.sodalive.member.MemberRole
import kr.co.vividnext.sodalive.v2.api.creator.channel.community.application.CreatorChannelCommunityFacade import kr.co.vividnext.sodalive.v2.api.creator.channel.community.application.CreatorChannelCommunityFacade
import kr.co.vividnext.sodalive.v2.api.creator.channel.community.dto.CreatorChannelCommunityCommentResponse
import kr.co.vividnext.sodalive.v2.api.creator.channel.community.dto.CreatorChannelCommunityCommentsResponse
import kr.co.vividnext.sodalive.v2.api.creator.channel.community.dto.CreatorChannelCommunityPostDetailResponse
import kr.co.vividnext.sodalive.v2.api.creator.channel.community.dto.CreatorChannelCommunityPostResponse import kr.co.vividnext.sodalive.v2.api.creator.channel.community.dto.CreatorChannelCommunityPostResponse
import kr.co.vividnext.sodalive.v2.api.creator.channel.community.dto.CreatorChannelCommunityRepliesResponse
import kr.co.vividnext.sodalive.v2.api.creator.channel.community.dto.CreatorChannelCommunityReplyResponse
import kr.co.vividnext.sodalive.v2.api.creator.channel.community.dto.CreatorChannelCommunityTabResponse import kr.co.vividnext.sodalive.v2.api.creator.channel.community.dto.CreatorChannelCommunityTabResponse
import org.junit.jupiter.api.DisplayName import org.junit.jupiter.api.DisplayName
import org.junit.jupiter.api.Test import org.junit.jupiter.api.Test
@@ -147,6 +152,154 @@ class CreatorChannelCommunityControllerTest @Autowired constructor(
) )
} }
@Test
@DisplayName("커뮤니티 상세 조회는 비회원 요청을 거부한다")
fun shouldRejectAnonymousCommunityPostDetailRequest() {
mockMvc.perform(
get("/api/v2/creator-channels/community-posts/101")
.with(anonymous())
)
.andExpect(status().isUnauthorized)
}
@Test
@DisplayName("커뮤니티 상세 조회는 인증 회원에게 상세와 초기 댓글을 반환한다")
fun shouldReturnCommunityPostDetailForAuthenticatedMember() {
val viewer = createMember(id = 10L)
Mockito.doReturn(createDetailResponse()).`when`(facade).getCommunityPostDetail(
eqValue(101L),
eqValue(viewer),
anyValue(LocalDateTime.now())
)
mockMvc.perform(
get("/api/v2/creator-channels/community-posts/101")
.with(user(MemberAdapter(viewer)))
)
.andExpect(status().isOk)
.andExpect(jsonPath("$.data.postId").value(101))
.andExpect(jsonPath("$.data.isLiked").value(true))
.andExpect(jsonPath("$.data.comments.commentCount").value(1))
.andExpect(jsonPath("$.data.comments.comments[0].writerId").value(20))
.andExpect(jsonPath("$.data.comments.comments[0].isSecret").value(true))
.andExpect(jsonPath("$.data.comments.comments[0].latestReply.commentId").value(202))
.andExpect(jsonPath("$.data.comments.comments[0].latestReply.writerId").value(21))
}
@Test
@DisplayName("커뮤니티 댓글 조회는 인증 회원에게 댓글 페이지를 반환한다")
fun shouldReturnCommunityCommentsForAuthenticatedMember() {
val viewer = createMember(id = 10L)
Mockito.doReturn(createCommentsResponse()).`when`(facade).getCommunityComments(
eqValue(101L),
eqValue(viewer),
eqValue(0),
eqValue(20),
anyValue(LocalDateTime.now())
)
mockMvc.perform(
get("/api/v2/creator-channels/community-posts/101/comments")
.param("page", "0")
.param("size", "20")
.with(user(MemberAdapter(viewer)))
)
.andExpect(status().isOk)
.andExpect(jsonPath("$.data.commentCount").value(1))
.andExpect(jsonPath("$.data.comments[0].writerId").value(20))
.andExpect(jsonPath("$.data.comments[0].isSecret").value(true))
.andExpect(jsonPath("$.data.comments[0].latestReply.commentId").value(202))
.andExpect(jsonPath("$.data.comments[0].latestReply.writerId").value(21))
.andExpect(jsonPath("$.data.hasNext").value(false))
}
@Test
@DisplayName("커뮤니티 댓글 조회는 raw page와 size를 facade에 전달한다")
fun shouldPassRawCommentPageAndSizeToFacade() {
val viewer = createMember(id = 10L)
Mockito.doReturn(createCommentsResponse(page = 0, size = 50)).`when`(facade).getCommunityComments(
eqValue(101L),
eqValue(viewer),
eqValue(-1),
eqValue(100),
anyValue(LocalDateTime.now())
)
mockMvc.perform(
get("/api/v2/creator-channels/community-posts/101/comments")
.param("page", "-1")
.param("size", "100")
.with(user(MemberAdapter(viewer)))
)
.andExpect(status().isOk)
.andExpect(jsonPath("$.data.page").value(0))
.andExpect(jsonPath("$.data.size").value(50))
Mockito.verify(facade).getCommunityComments(
eqValue(101L),
eqValue(viewer),
eqValue(-1),
eqValue(100),
anyValue(LocalDateTime.now())
)
}
@Test
@DisplayName("커뮤니티 답글 조회는 인증 회원에게 답글 페이지를 반환한다")
fun shouldReturnCommunityRepliesForAuthenticatedMember() {
val viewer = createMember(id = 10L)
Mockito.doReturn(createRepliesResponse()).`when`(facade).getCommunityReplies(
eqValue(201L),
eqValue(viewer),
eqValue(0),
eqValue(20),
anyValue(LocalDateTime.now())
)
mockMvc.perform(
get("/api/v2/creator-channels/community-comments/201/replies")
.param("page", "0")
.param("size", "20")
.with(user(MemberAdapter(viewer)))
)
.andExpect(status().isOk)
.andExpect(jsonPath("$.data.replyCount").value(1))
.andExpect(jsonPath("$.data.replies[0].commentId").value(202))
.andExpect(jsonPath("$.data.replies[0].writerId").value(21))
.andExpect(jsonPath("$.data.replies[0].latestReply").doesNotExist())
}
@Test
@DisplayName("커뮤니티 답글 조회는 raw page와 size를 facade에 전달한다")
fun shouldPassRawReplyPageAndSizeToFacade() {
val viewer = createMember(id = 10L)
Mockito.doReturn(createRepliesResponse(page = 0, size = 50)).`when`(facade).getCommunityReplies(
eqValue(201L),
eqValue(viewer),
eqValue(-1),
eqValue(100),
anyValue(LocalDateTime.now())
)
mockMvc.perform(
get("/api/v2/creator-channels/community-comments/201/replies")
.param("page", "-1")
.param("size", "100")
.with(user(MemberAdapter(viewer)))
)
.andExpect(status().isOk)
.andExpect(jsonPath("$.data.page").value(0))
.andExpect(jsonPath("$.data.size").value(50))
Mockito.verify(facade).getCommunityReplies(
eqValue(201L),
eqValue(viewer),
eqValue(-1),
eqValue(100),
anyValue(LocalDateTime.now())
)
}
private fun <T> eqValue(value: T): T { private fun <T> eqValue(value: T): T {
return Mockito.eq(value) ?: value return Mockito.eq(value) ?: value
} }
@@ -194,4 +347,72 @@ class CreatorChannelCommunityControllerTest @Autowired constructor(
hasNext = false hasNext = false
) )
} }
private fun createDetailResponse(): CreatorChannelCommunityPostDetailResponse {
return CreatorChannelCommunityPostDetailResponse(
postId = 101L,
creatorId = 1L,
creatorNickname = "creator",
creatorProfileUrl = "https://cdn.test/profile.png",
createdAtUtc = "2026-06-21T03:30:00Z",
content = "content",
imageUrl = null,
audioUrl = null,
price = 100,
isCommentAvailable = true,
existOrdered = true,
likeCount = 7,
commentCount = 1,
isPinned = true,
isLiked = true,
comments = createCommentsResponse()
)
}
private fun createCommentsResponse(page: Int = 0, size: Int = 20): CreatorChannelCommunityCommentsResponse {
return CreatorChannelCommunityCommentsResponse(
commentCount = 1,
comments = listOf(
CreatorChannelCommunityCommentResponse(
commentId = 201L,
writerId = 20L,
writerProfileImageUrl = "https://cdn.test/writer.png",
writerNickname = "writer",
content = "comment",
isSecret = true,
createdAtUtc = "2026-07-06T03:00:00Z",
latestReply = CreatorChannelCommunityReplyResponse(
commentId = 202L,
writerId = 21L,
writerProfileImageUrl = "https://cdn.test/replier.png",
writerNickname = "replier",
content = "reply",
createdAtUtc = "2026-07-06T03:01:00Z"
)
)
),
page = page,
size = size,
hasNext = false
)
}
private fun createRepliesResponse(page: Int = 0, size: Int = 20): CreatorChannelCommunityRepliesResponse {
return CreatorChannelCommunityRepliesResponse(
replyCount = 1,
replies = listOf(
CreatorChannelCommunityReplyResponse(
commentId = 202L,
writerId = 21L,
writerProfileImageUrl = "https://cdn.test/replier.png",
writerNickname = "replier",
content = "reply",
createdAtUtc = "2026-07-06T03:01:00Z"
)
),
page = page,
size = size,
hasNext = false
)
}
} }

View File

@@ -5,9 +5,12 @@ import kr.co.vividnext.sodalive.can.use.CanUsage
import kr.co.vividnext.sodalive.can.use.UseCan import kr.co.vividnext.sodalive.can.use.UseCan
import kr.co.vividnext.sodalive.content.ContentType import kr.co.vividnext.sodalive.content.ContentType
import kr.co.vividnext.sodalive.explorer.profile.creatorCommunity.CreatorCommunity 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.member.Member import kr.co.vividnext.sodalive.member.Member
import kr.co.vividnext.sodalive.member.MemberAdapter import kr.co.vividnext.sodalive.member.MemberAdapter
import kr.co.vividnext.sodalive.member.MemberRole import kr.co.vividnext.sodalive.member.MemberRole
import kr.co.vividnext.sodalive.member.block.BlockMember
import kr.co.vividnext.sodalive.member.contentpreference.MemberContentPreference import kr.co.vividnext.sodalive.member.contentpreference.MemberContentPreference
import kr.co.vividnext.sodalive.support.EmbeddedRedisInitializer import kr.co.vividnext.sodalive.support.EmbeddedRedisInitializer
import org.junit.jupiter.api.DisplayName import org.junit.jupiter.api.DisplayName
@@ -94,6 +97,147 @@ class CreatorChannelCommunityEndToEndTest @Autowired constructor(
Mockito.verifyNoMoreInteractions(audioContentCloudFront) Mockito.verifyNoMoreInteractions(audioContentCloudFront)
} }
@Test
@DisplayName("커뮤니티 상세 API는 초기 댓글 20개와 최신 답글을 반환한다")
fun shouldReturnCommunityPostDetailWithInitialComments() {
val fixture = createDetailFixture()
mockMvc.perform(
get("/api/v2/creator-channels/community-posts/${fixture.postId}")
.with(user(MemberAdapter(fixture.viewer)))
)
.andExpect(status().isOk)
.andExpect(jsonPath("$.data.postId").value(fixture.postId))
.andExpect(jsonPath("$.data.isLiked").value(true))
.andExpect(jsonPath("$.data.comments.comments.length()").value(20))
.andExpect(jsonPath("$.data.comments.hasNext").value(true))
.andExpect(jsonPath("$.data.comments.comments[0].latestReply.commentId").value(fixture.latestReplyId))
.andExpect(jsonPath("$.data.comments.comments[0].createdAtUtc").value(org.hamcrest.Matchers.endsWith("Z")))
}
@Test
@DisplayName("커뮤니티 댓글 API는 댓글 페이지와 최신 답글을 반환한다")
fun shouldReturnPagedCommunityCommentsWithLatestReply() {
val fixture = createDetailFixture()
mockMvc.perform(
get("/api/v2/creator-channels/community-posts/${fixture.postId}/comments")
.param("page", "0")
.param("size", "20")
.with(user(MemberAdapter(fixture.viewer)))
)
.andExpect(status().isOk)
.andExpect(jsonPath("$.data.commentCount").value(21))
.andExpect(jsonPath("$.data.comments.length()").value(20))
.andExpect(jsonPath("$.data.hasNext").value(true))
.andExpect(jsonPath("$.data.comments[0].latestReply.commentId").value(fixture.latestReplyId))
}
@Test
@DisplayName("커뮤니티 답글 API는 latestReply 없이 답글 페이지를 반환한다")
fun shouldReturnPagedCommunityRepliesWithoutLatestReply() {
val fixture = createDetailFixture()
mockMvc.perform(
get("/api/v2/creator-channels/community-comments/${fixture.firstCommentId}/replies")
.param("page", "0")
.param("size", "20")
.with(user(MemberAdapter(fixture.viewer)))
)
.andExpect(status().isOk)
.andExpect(jsonPath("$.data.replyCount").value(1))
.andExpect(jsonPath("$.data.replies[0].commentId").value(fixture.latestReplyId))
.andExpect(jsonPath("$.data.replies[0].latestReply").doesNotExist())
}
@Test
@DisplayName("댓글 불가 게시물은 빈 댓글 응답을 반환한다")
fun shouldReturnEmptyCommentsWhenCommentUnavailable() {
val fixture = createCommentUnavailableFixture()
mockMvc.perform(
get("/api/v2/creator-channels/community-posts/${fixture.postId}/comments")
.with(user(MemberAdapter(fixture.viewer)))
)
.andExpect(status().isOk)
.andExpect(jsonPath("$.data.commentCount").value(0))
.andExpect(jsonPath("$.data.comments.length()").value(0))
.andExpect(jsonPath("$.data.hasNext").value(false))
}
@Test
@DisplayName("성인 콘텐츠를 볼 수 없는 회원은 19금 커뮤니티 상세를 조회할 수 없다")
fun shouldRejectAdultCommunityPostWhenViewerCannotViewAdultContent() {
val fixture = createAdultBlockedFixture()
mockMvc.perform(
get("/api/v2/creator-channels/community-posts/${fixture.postId}")
.with(user(MemberAdapter(fixture.viewer)))
)
.andExpect(status().isOk)
.andExpect(jsonPath("$.success").value(false))
}
@Test
@DisplayName("차단 관계가 있으면 커뮤니티 상세를 조회할 수 없다")
fun shouldRejectCommunityPostWhenViewerAndCreatorAreBlocked() {
val fixture = createBlockedFixture()
mockMvc.perform(
get("/api/v2/creator-channels/community-posts/${fixture.postId}")
.with(user(MemberAdapter(fixture.viewer)))
)
.andExpect(status().isOk)
.andExpect(jsonPath("$.success").value(false))
}
@Test
@DisplayName("커뮤니티 댓글 API는 비밀 댓글과 차단 작성자를 필터링한다")
fun shouldFilterSecretAndBlockedComments() {
val fixture = createAccessPolicyFixture()
mockMvc.perform(
get("/api/v2/creator-channels/community-posts/${fixture.postId}/comments")
.with(user(MemberAdapter(fixture.viewer)))
)
.andExpect(status().isOk)
.andExpect(jsonPath("$.data.commentCount").value(2))
.andExpect(jsonPath("$.data.comments.length()").value(2))
.andExpect(jsonPath("$.data.comments[0].commentId").value(fixture.ownSecretCommentId))
.andExpect(jsonPath("$.data.comments[0].isSecret").value(true))
.andExpect(jsonPath("$.data.comments[1].commentId").value(fixture.publicCommentId))
.andExpect(jsonPath("$.data.comments[1].isSecret").value(false))
.andExpect(jsonPath("$.data.comments[1].latestReply.commentId").value(fixture.publicReplyId))
}
@Test
@DisplayName("보이지 않는 부모 댓글의 답글 조회는 오류를 반환한다")
fun shouldRejectRepliesWhenParentCommentIsNotVisible() {
val fixture = createAccessPolicyFixture()
mockMvc.perform(
get("/api/v2/creator-channels/community-comments/${fixture.hiddenSecretCommentId}/replies")
.with(user(MemberAdapter(fixture.viewer)))
)
.andExpect(status().isOk)
.andExpect(jsonPath("$.success").value(false))
}
@Test
@DisplayName("댓글 불가 게시물의 답글 직접 조회는 빈 응답을 반환한다")
fun shouldReturnEmptyRepliesWhenCommentUnavailable() {
val fixture = createCommentUnavailableFixtureWithReply()
mockMvc.perform(
get("/api/v2/creator-channels/community-comments/${fixture.commentId}/replies")
.with(user(MemberAdapter(fixture.viewer)))
)
.andExpect(status().isOk)
.andExpect(jsonPath("$.data.replyCount").value(0))
.andExpect(jsonPath("$.data.replies.length()").value(0))
.andExpect(jsonPath("$.data.hasNext").value(false))
}
private fun createFixture(): Fixture { private fun createFixture(): Fixture {
return transactionTemplate.execute { return transactionTemplate.execute {
val now = LocalDateTime.of(2026, 6, 21, 12, 0) val now = LocalDateTime.of(2026, 6, 21, 12, 0)
@@ -192,12 +336,13 @@ class CreatorChannelCommunityEndToEndTest @Autowired constructor(
content: String, content: String,
imagePath: String? = null, imagePath: String? = null,
audioPath: String? = null, audioPath: String? = null,
isAdult: Boolean = false isAdult: Boolean = false,
isCommentAvailable: Boolean = true
): CreatorCommunity { ): CreatorCommunity {
val community = CreatorCommunity( val community = CreatorCommunity(
content = content, content = content,
price = price, price = price,
isCommentAvailable = true, isCommentAvailable = isCommentAvailable,
isAdult = isAdult, isAdult = isAdult,
audioPath = audioPath, audioPath = audioPath,
imagePath = imagePath, imagePath = imagePath,
@@ -210,6 +355,37 @@ class CreatorChannelCommunityEndToEndTest @Autowired constructor(
return community return community
} }
private fun saveCommunityLike(member: Member, community: CreatorCommunity): CreatorCommunityLike {
val like = CreatorCommunityLike(isActive = true)
like.member = member
like.creatorCommunity = community
entityManager.persist(like)
return like
}
private fun saveCommunityComment(
member: Member,
community: CreatorCommunity,
parent: CreatorCommunityComment? = null,
isSecret: Boolean = false,
isActive: Boolean = true
): CreatorCommunityComment {
val comment = CreatorCommunityComment(comment = "comment", isSecret = isSecret, isActive = isActive)
comment.member = member
comment.creatorCommunity = community
comment.parent = parent
entityManager.persist(comment)
return comment
}
private fun saveBlock(member: Member, blockedMember: Member): BlockMember {
val block = BlockMember(isActive = true)
block.member = member
block.blockedMember = blockedMember
entityManager.persist(block)
return block
}
private fun saveCommunityOrder(member: Member, community: CreatorCommunity): UseCan { private fun saveCommunityOrder(member: Member, community: CreatorCommunity): UseCan {
val useCan = UseCan(CanUsage.PAID_COMMUNITY_POST, community.price, rewardCan = 0, isRefund = false) val useCan = UseCan(CanUsage.PAID_COMMUNITY_POST, community.price, rewardCan = 0, isRefund = false)
useCan.member = member useCan.member = member
@@ -225,6 +401,147 @@ class CreatorChannelCommunityEndToEndTest @Autowired constructor(
.executeUpdate() .executeUpdate()
} }
private fun updateCommentCreatedAt(id: Long, createdAt: LocalDateTime) {
entityManager.createQuery("update CreatorCommunityComment e set e.createdAt = :createdAt where e.id = :id")
.setParameter("createdAt", createdAt)
.setParameter("id", id)
.executeUpdate()
}
private fun createDetailFixture(): DetailFixture {
return transactionTemplate.execute {
val now = LocalDateTime.of(2026, 7, 6, 12, 0)
val viewer = saveMember("community-detail-viewer", MemberRole.USER)
val creator = saveMember("community-detail-creator", MemberRole.CREATOR)
val replier = saveMember("community-detail-replier", MemberRole.USER)
savePreference(viewer, isAdultContentVisible = true)
val post = saveCommunity(creator, isFixed = false, price = 0, content = "detail community")
saveCommunityLike(viewer, post)
val comments = (1..21).map { saveCommunityComment(viewer, post) }
val latestReply = saveCommunityComment(replier, post, parent = comments.last())
entityManager.flush()
comments.forEachIndexed { index, comment ->
updateCommentCreatedAt(comment.id!!, now.plusMinutes(index.toLong()))
}
updateCommentCreatedAt(latestReply.id!!, now.plusMinutes(30))
entityManager.flush()
entityManager.clear()
DetailFixture(
viewer = viewer,
postId = post.id!!,
firstCommentId = comments.last().id!!,
latestReplyId = latestReply.id!!
)
}!!
}
private fun createCommentUnavailableFixture(): PostFixture {
return transactionTemplate.execute {
val viewer = saveMember("community-unavailable-viewer", MemberRole.USER)
val creator = saveMember("community-unavailable-creator", MemberRole.CREATOR)
savePreference(viewer, isAdultContentVisible = true)
val post = saveCommunity(
creator = creator,
isFixed = false,
price = 0,
content = "comment unavailable",
isCommentAvailable = false
)
saveCommunityComment(viewer, post)
entityManager.flush()
entityManager.clear()
PostFixture(viewer = viewer, postId = post.id!!)
}!!
}
private fun createAdultBlockedFixture(): PostFixture {
return transactionTemplate.execute {
val viewer = saveMember("community-adult-blocked-viewer", MemberRole.USER)
val creator = saveMember("community-adult-blocked-creator", MemberRole.CREATOR)
savePreference(viewer, isAdultContentVisible = false)
val post = saveCommunity(
creator = creator,
isFixed = false,
price = 0,
content = "adult blocked",
isAdult = true
)
entityManager.flush()
entityManager.clear()
PostFixture(viewer = viewer, postId = post.id!!)
}!!
}
private fun createBlockedFixture(): PostFixture {
return transactionTemplate.execute {
val viewer = saveMember("community-blocked-viewer", MemberRole.USER)
val creator = saveMember("community-blocked-creator", MemberRole.CREATOR)
savePreference(viewer, isAdultContentVisible = true)
saveBlock(viewer, creator)
val post = saveCommunity(creator, isFixed = false, price = 0, content = "blocked")
entityManager.flush()
entityManager.clear()
PostFixture(viewer = viewer, postId = post.id!!)
}!!
}
private fun createAccessPolicyFixture(): AccessPolicyFixture {
return transactionTemplate.execute {
val now = LocalDateTime.of(2026, 7, 6, 12, 0)
val viewer = saveMember("community-policy-viewer", MemberRole.USER)
val creator = saveMember("community-policy-creator", MemberRole.CREATOR)
val publicWriter = saveMember("community-policy-public-writer", MemberRole.USER)
val secretWriter = saveMember("community-policy-secret-writer", MemberRole.USER)
val blockedWriter = saveMember("community-policy-blocked-writer", MemberRole.USER)
val replyWriter = saveMember("community-policy-reply-writer", MemberRole.USER)
savePreference(viewer, isAdultContentVisible = true)
val post = saveCommunity(creator, isFixed = false, price = 0, content = "policy")
val publicComment = saveCommunityComment(publicWriter, post)
val ownSecretComment = saveCommunityComment(viewer, post, isSecret = true)
val hiddenSecretComment = saveCommunityComment(secretWriter, post, isSecret = true)
saveCommunityComment(blockedWriter, post)
val publicReply = saveCommunityComment(replyWriter, post, parent = publicComment)
val hiddenSecretReply = saveCommunityComment(secretWriter, post, parent = publicComment, isSecret = true)
saveBlock(viewer, blockedWriter)
entityManager.flush()
updateCommentCreatedAt(publicComment.id!!, now.minusMinutes(3))
updateCommentCreatedAt(ownSecretComment.id!!, now.minusMinutes(1))
updateCommentCreatedAt(hiddenSecretComment.id!!, now)
updateCommentCreatedAt(publicReply.id!!, now.plusMinutes(1))
updateCommentCreatedAt(hiddenSecretReply.id!!, now.plusMinutes(2))
entityManager.flush()
entityManager.clear()
AccessPolicyFixture(
viewer = viewer,
postId = post.id!!,
publicCommentId = publicComment.id!!,
ownSecretCommentId = ownSecretComment.id!!,
hiddenSecretCommentId = hiddenSecretComment.id!!,
publicReplyId = publicReply.id!!
)
}!!
}
private fun createCommentUnavailableFixtureWithReply(): ReplyFixture {
return transactionTemplate.execute {
val viewer = saveMember("community-unavailable-reply-viewer", MemberRole.USER)
val creator = saveMember("community-unavailable-reply-creator", MemberRole.CREATOR)
savePreference(viewer, isAdultContentVisible = true)
val post = saveCommunity(
creator = creator,
isFixed = false,
price = 0,
content = "comment unavailable reply",
isCommentAvailable = false
)
val comment = saveCommunityComment(viewer, post)
saveCommunityComment(viewer, post, parent = comment)
entityManager.flush()
entityManager.clear()
ReplyFixture(viewer = viewer, commentId = comment.id!!)
}!!
}
private data class Fixture( private data class Fixture(
val viewer: Member, val viewer: Member,
val creatorId: Long, val creatorId: Long,
@@ -234,4 +551,30 @@ class CreatorChannelCommunityEndToEndTest @Autowired constructor(
val noImagePostId: Long, val noImagePostId: Long,
val adultPurchasedPostId: Long val adultPurchasedPostId: Long
) )
private data class DetailFixture(
val viewer: Member,
val postId: Long,
val firstCommentId: Long,
val latestReplyId: Long
)
private data class PostFixture(
val viewer: Member,
val postId: Long
)
private data class AccessPolicyFixture(
val viewer: Member,
val postId: Long,
val publicCommentId: Long,
val ownSecretCommentId: Long,
val hiddenSecretCommentId: Long,
val publicReplyId: Long
)
private data class ReplyFixture(
val viewer: Member,
val commentId: Long
)
} }

View File

@@ -6,7 +6,12 @@ import kr.co.vividnext.sodalive.member.Member
import kr.co.vividnext.sodalive.member.MemberRole import kr.co.vividnext.sodalive.member.MemberRole
import kr.co.vividnext.sodalive.v2.api.creator.channel.community.dto.CreatorChannelCommunityTabResponse import kr.co.vividnext.sodalive.v2.api.creator.channel.community.dto.CreatorChannelCommunityTabResponse
import kr.co.vividnext.sodalive.v2.creator.channel.community.application.CreatorChannelCommunityQueryService import kr.co.vividnext.sodalive.v2.creator.channel.community.application.CreatorChannelCommunityQueryService
import kr.co.vividnext.sodalive.v2.creator.channel.community.domain.CreatorChannelCommunityComment
import kr.co.vividnext.sodalive.v2.creator.channel.community.domain.CreatorChannelCommunityComments
import kr.co.vividnext.sodalive.v2.creator.channel.community.domain.CreatorChannelCommunityPost import kr.co.vividnext.sodalive.v2.creator.channel.community.domain.CreatorChannelCommunityPost
import kr.co.vividnext.sodalive.v2.creator.channel.community.domain.CreatorChannelCommunityPostDetail
import kr.co.vividnext.sodalive.v2.creator.channel.community.domain.CreatorChannelCommunityReplies
import kr.co.vividnext.sodalive.v2.creator.channel.community.domain.CreatorChannelCommunityReply
import kr.co.vividnext.sodalive.v2.creator.channel.community.domain.CreatorChannelCommunityTab import kr.co.vividnext.sodalive.v2.creator.channel.community.domain.CreatorChannelCommunityTab
import kr.co.vividnext.sodalive.v2.creator.channel.live.domain.CreatorChannelPage import kr.co.vividnext.sodalive.v2.creator.channel.live.domain.CreatorChannelPage
import org.junit.jupiter.api.Assertions.assertEquals import org.junit.jupiter.api.Assertions.assertEquals
@@ -87,6 +92,63 @@ class CreatorChannelCommunityFacadeTest {
assertTrue(response.hasNext) assertTrue(response.hasNext)
} }
@Test
@DisplayName("커뮤니티 상세 facade는 service 결과를 상세 응답 DTO로 변환한다")
fun shouldReturnCommunityPostDetailResponse() {
val service = Mockito.mock(CreatorChannelCommunityQueryService::class.java)
val facade = CreatorChannelCommunityFacade(service)
val viewer = createMember(id = 10L)
val now = LocalDateTime.of(2026, 7, 6, 12, 0)
Mockito.doReturn(createDetail()).`when`(service).getCommunityPostDetail(101L, viewer, now)
val response = facade.getCommunityPostDetail(101L, viewer, now)
assertEquals(101L, response.postId)
assertTrue(response.isLiked)
assertEquals(1, response.comments.commentCount)
assertEquals(20L, response.comments.comments.first().writerId)
assertTrue(response.comments.comments.first().isSecret)
assertEquals(21L, response.comments.comments.first().latestReply?.writerId)
assertEquals(201L, response.comments.comments.first().latestReply?.commentId)
}
@Test
@DisplayName("커뮤니티 댓글 facade는 raw page와 size를 service로 전달하고 응답 DTO로 변환한다")
fun shouldReturnCommunityCommentsResponse() {
val service = Mockito.mock(CreatorChannelCommunityQueryService::class.java)
val facade = CreatorChannelCommunityFacade(service)
val viewer = createMember(id = 10L)
val now = LocalDateTime.of(2026, 7, 6, 12, 0)
Mockito.doReturn(createComments()).`when`(service).getCommunityComments(101L, viewer, -1, 100, now)
val response = facade.getCommunityComments(101L, viewer, -1, 100, now)
assertEquals(1, response.commentCount)
assertEquals(0, response.page)
assertEquals(20, response.size)
assertEquals(20L, response.comments.first().writerId)
assertTrue(response.comments.first().isSecret)
assertEquals(21L, response.comments.first().latestReply?.writerId)
assertEquals(201L, response.comments.first().latestReply?.commentId)
}
@Test
@DisplayName("커뮤니티 답글 facade는 raw page와 size를 service로 전달하고 응답 DTO로 변환한다")
fun shouldReturnCommunityRepliesResponse() {
val service = Mockito.mock(CreatorChannelCommunityQueryService::class.java)
val facade = CreatorChannelCommunityFacade(service)
val viewer = createMember(id = 10L)
val now = LocalDateTime.of(2026, 7, 6, 12, 0)
Mockito.doReturn(createReplies()).`when`(service).getCommunityReplies(201L, viewer, -1, 100, now)
val response = facade.getCommunityReplies(201L, viewer, -1, 100, now)
assertEquals(1, response.replyCount)
assertEquals(201L, response.replies.first().commentId)
assertEquals(21L, response.replies.first().writerId)
assertTrue(response.hasNext)
}
private fun createMember(id: Long): Member { private fun createMember(id: Long): Member {
return Member( return Member(
email = "viewer$id@test.com", email = "viewer$id@test.com",
@@ -139,4 +201,49 @@ class CreatorChannelCommunityFacadeTest {
hasNext = true hasNext = true
) )
} }
private fun createDetail(): CreatorChannelCommunityPostDetail {
return CreatorChannelCommunityPostDetail(
post = createTab().communityPosts.first(),
comments = createComments()
)
}
private fun createComments(): CreatorChannelCommunityComments {
return CreatorChannelCommunityComments(
commentCount = 1,
comments = listOf(
CreatorChannelCommunityComment(
commentId = 200L,
writerId = 20L,
writerProfileImageUrl = "https://cdn.test/writer.png",
writerNickname = "writer",
content = "comment",
isSecret = true,
createdAt = LocalDateTime.of(2026, 7, 6, 3, 0),
latestReply = createReplies().replies.first()
)
),
page = CreatorChannelPage(page = 0, size = 20),
hasNext = false
)
}
private fun createReplies(): CreatorChannelCommunityReplies {
return CreatorChannelCommunityReplies(
replyCount = 1,
replies = listOf(
CreatorChannelCommunityReply(
commentId = 201L,
writerId = 21L,
writerProfileImageUrl = "https://cdn.test/replier.png",
writerNickname = "replier",
content = "reply",
createdAt = LocalDateTime.of(2026, 7, 6, 3, 1)
)
),
page = CreatorChannelPage(page = 0, size = 20),
hasNext = true
)
}
} }

View File

@@ -0,0 +1,107 @@
package kr.co.vividnext.sodalive.v2.api.creator.channel.community.dto
import com.fasterxml.jackson.databind.ObjectMapper
import com.fasterxml.jackson.module.kotlin.KotlinModule
import kr.co.vividnext.sodalive.v2.creator.channel.community.domain.CreatorChannelCommunityComment
import kr.co.vividnext.sodalive.v2.creator.channel.community.domain.CreatorChannelCommunityComments
import kr.co.vividnext.sodalive.v2.creator.channel.community.domain.CreatorChannelCommunityPost
import kr.co.vividnext.sodalive.v2.creator.channel.community.domain.CreatorChannelCommunityPostDetail
import kr.co.vividnext.sodalive.v2.creator.channel.community.domain.CreatorChannelCommunityReplies
import kr.co.vividnext.sodalive.v2.creator.channel.community.domain.CreatorChannelCommunityReply
import kr.co.vividnext.sodalive.v2.creator.channel.live.domain.CreatorChannelPage
import org.junit.jupiter.api.Assertions.assertFalse
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.DisplayName
import org.junit.jupiter.api.Test
import java.time.LocalDateTime
class CreatorChannelCommunityPostDetailResponseTest {
@Test
@DisplayName("상세 응답 DTO는 is prefix boolean 필드명과 댓글/답글 구조를 유지한다")
fun shouldSerializeBooleanFieldsWithIsPrefixAndReuseCommentsResponse() {
val mapper = ObjectMapper().registerModule(KotlinModule.Builder().build())
val response = CreatorChannelCommunityPostDetailResponse.from(createDetail())
val replies = CreatorChannelCommunityRepliesResponse.from(createReplies())
val detailJson = mapper.readTree(mapper.writeValueAsString(response))
val repliesJson = mapper.readTree(mapper.writeValueAsString(replies))
assertTrue(detailJson["isCommentAvailable"].asBoolean())
assertTrue(detailJson["isPinned"].asBoolean())
assertTrue(detailJson["isLiked"].asBoolean())
assertTrue(detailJson["comments"]["hasNext"].asBoolean())
assertTrue(detailJson["comments"]["comments"][0].has("latestReply"))
assertTrue(detailJson["comments"]["comments"][0].has("writerId"))
assertTrue(detailJson["comments"]["comments"][0]["isSecret"].asBoolean())
assertTrue(detailJson["comments"]["comments"][0]["latestReply"].has("commentId"))
assertTrue(detailJson["comments"]["comments"][0]["latestReply"].has("writerId"))
assertTrue(repliesJson["hasNext"].asBoolean())
assertTrue(repliesJson["replies"][0].has("writerId"))
assertFalse(repliesJson["replies"][0].has("latestReply"))
}
private fun createDetail(): CreatorChannelCommunityPostDetail {
val reply = CreatorChannelCommunityReply(
commentId = 2L,
writerId = 20L,
writerProfileImageUrl = "https://cdn.test/reply.png",
writerNickname = "reply-writer",
content = "reply",
createdAt = LocalDateTime.of(2026, 7, 6, 3, 1)
)
return CreatorChannelCommunityPostDetail(
post = CreatorChannelCommunityPost(
postId = 1L,
creatorId = 10L,
creatorNickname = "creator",
creatorProfileUrl = "https://cdn.test/profile.png",
imageUrl = "https://cdn.test/image.png",
audioUrl = null,
content = "content",
price = 0,
createdAt = LocalDateTime.of(2026, 7, 6, 3, 0),
existOrdered = false,
isCommentAvailable = true,
likeCount = 1,
commentCount = 1,
isPinned = true,
isLiked = true
),
comments = CreatorChannelCommunityComments(
commentCount = 1,
comments = listOf(
CreatorChannelCommunityComment(
commentId = 1L,
writerId = 10L,
writerProfileImageUrl = "https://cdn.test/comment.png",
writerNickname = "comment-writer",
content = "comment",
isSecret = true,
createdAt = LocalDateTime.of(2026, 7, 6, 3, 0),
latestReply = reply
)
),
page = CreatorChannelPage(0, 20),
hasNext = true
)
)
}
private fun createReplies(): CreatorChannelCommunityReplies {
return CreatorChannelCommunityReplies(
replyCount = 1,
replies = listOf(
CreatorChannelCommunityReply(
commentId = 2L,
writerId = 20L,
writerProfileImageUrl = "https://cdn.test/reply.png",
writerNickname = "reply-writer",
content = "reply",
createdAt = LocalDateTime.of(2026, 7, 6, 3, 1)
)
),
page = CreatorChannelPage(0, 20),
hasNext = true
)
}
}

View File

@@ -353,6 +353,111 @@ class DefaultCreatorChannelCommunityQueryRepositoryTest @Autowired constructor(
assertFalse(adultInactivePurchasedPost.id in adultBlockedPosts.map { it.postId }) assertFalse(adultInactivePurchasedPost.id in adultBlockedPosts.map { it.postId })
} }
@Test
@DisplayName("게시물 ID 상세 조회는 좋아요와 구매와 성인 정책 필드를 채운다")
fun shouldFindCommunityPostByIdWithLikePurchaseAndAdultPolicy() {
val viewer = saveMember("detail-viewer", MemberRole.USER)
val creator = saveMember("detail-creator", MemberRole.CREATOR)
val liker = saveMember("detail-liker", MemberRole.USER)
val paidPost = saveCommunity(creator, isFixed = true, price = 100, isAdult = false)
val adultPost = saveCommunity(creator, isFixed = false, price = 0, isAdult = true)
val inactivePost = saveCommunity(creator, isFixed = false, price = 0, isActive = false)
saveCommunityOrder(viewer, paidPost, CanUsage.PAID_COMMUNITY_POST, isRefund = false)
saveCommunityLike(viewer, paidPost, isActive = true)
saveCommunityLike(liker, paidPost, isActive = true)
saveCommunityComment(viewer, paidPost, isActive = true)
flushAndClear()
val record = repository.findCommunityPost(paidPost.id!!, viewer.id!!, canViewAdultContent = true)
val blockedAdultRecord = repository.findCommunityPost(adultPost.id!!, viewer.id!!, canViewAdultContent = false)
val inactiveRecord = repository.findCommunityPost(inactivePost.id!!, viewer.id!!, canViewAdultContent = true)
assertNotNull(record)
assertEquals(paidPost.id, record!!.postId)
assertTrue(record.existOrdered)
assertTrue(record.isLiked)
assertEquals(2, record.likeCount)
assertEquals(1, record.commentCount)
assertTrue(record.isPinned)
assertNull(blockedAdultRecord)
assertNull(inactiveRecord)
}
@Test
@DisplayName("최상위 댓글 목록은 비밀 댓글과 차단 작성자를 필터링하고 최신 답글을 반환한다")
fun shouldFindVisibleRootCommentsWithLatestReply() {
val creator = saveMember("visible-comment-creator", MemberRole.CREATOR)
val viewer = saveMember("visible-comment-viewer", MemberRole.USER)
val writer = saveMember("visible-comment-writer", MemberRole.USER)
val secretWriter = saveMember("visible-secret-writer", MemberRole.USER)
val blockedWriter = saveMember("visible-blocked-writer", MemberRole.USER)
val replyWriter = saveMember("visible-reply-writer", MemberRole.USER)
val post = saveCommunity(creator, isFixed = false, price = 0, isCommentAvailable = true)
val oldComment = saveCommunityComment(writer, post, isActive = true)
val newComment = saveCommunityComment(viewer, post, isActive = true)
saveCommunityComment(secretWriter, post, isActive = true, isSecret = true)
saveCommunityComment(blockedWriter, post, isActive = true)
saveCommunityComment(writer, post, isActive = false)
val oldReply = saveCommunityComment(replyWriter, post, isActive = true, parent = newComment)
val latestReply = saveCommunityComment(replyWriter, post, isActive = true, parent = newComment)
val hiddenSecretReply = saveCommunityComment(secretWriter, post, isActive = true, isSecret = true, parent = newComment)
saveBlock(viewer, blockedWriter, isActive = true)
flushAndClear()
updateCreatedAt("CreatorCommunityComment", oldComment.id!!, LocalDateTime.of(2026, 7, 6, 10, 0))
updateCreatedAt("CreatorCommunityComment", newComment.id!!, LocalDateTime.of(2026, 7, 6, 11, 0))
updateCreatedAt("CreatorCommunityComment", oldReply.id!!, LocalDateTime.of(2026, 7, 6, 11, 1))
updateCreatedAt("CreatorCommunityComment", latestReply.id!!, LocalDateTime.of(2026, 7, 6, 11, 2))
updateCreatedAt("CreatorCommunityComment", hiddenSecretReply.id!!, LocalDateTime.of(2026, 7, 6, 11, 3))
flushAndClear()
val viewerCount = repository.countCommunityComments(post.id!!, viewer.id!!, creator.id!!)
val creatorCount = repository.countCommunityComments(post.id!!, creator.id!!, creator.id!!)
val comments = repository.findCommunityComments(post.id!!, viewer.id!!, creator.id!!, offset = 0, limit = 10)
val latestReplies = repository.findLatestRepliesByCommentIds(comments.map { it.commentId }, viewer.id!!)
assertEquals(2, viewerCount)
assertEquals(4, creatorCount)
assertEquals(listOf(newComment.id, oldComment.id), comments.map { it.commentId })
assertEquals(listOf(latestReply.id), latestReplies.map { it.commentId })
assertEquals(newComment.id, latestReplies.single().parentCommentId)
}
@Test
@DisplayName("답글 조회는 보이는 최상위 댓글 context와 활성 답글만 반환한다")
fun shouldFindVisibleRepliesForRootCommentOnly() {
val creator = saveMember("reply-context-creator", MemberRole.CREATOR)
val viewer = saveMember("reply-context-viewer", MemberRole.USER)
val writer = saveMember("reply-context-writer", MemberRole.USER)
val blockedWriter = saveMember("reply-context-blocked", MemberRole.USER)
val secretWriter = saveMember("reply-context-secret", MemberRole.USER)
val post = saveCommunity(creator, isFixed = false, price = 0, isCommentAvailable = true)
val root = saveCommunityComment(writer, post, isActive = true)
val secretRoot = saveCommunityComment(secretWriter, post, isActive = true, isSecret = true)
val oldReply = saveCommunityComment(writer, post, isActive = true, parent = root)
val newReply = saveCommunityComment(writer, post, isActive = true, parent = root)
saveCommunityComment(secretWriter, post, isActive = true, isSecret = true, parent = root)
saveCommunityComment(writer, post, isActive = false, parent = root)
saveCommunityComment(blockedWriter, post, isActive = true, parent = root)
saveBlock(viewer, blockedWriter, isActive = true)
flushAndClear()
updateCreatedAt("CreatorCommunityComment", oldReply.id!!, LocalDateTime.of(2026, 7, 6, 10, 0))
updateCreatedAt("CreatorCommunityComment", newReply.id!!, LocalDateTime.of(2026, 7, 6, 11, 0))
flushAndClear()
val context = repository.findRootCommentContext(root.id!!, viewer.id!!, canViewAdultContent = true)
val invisibleSecretContext = repository.findRootCommentContext(secretRoot.id!!, viewer.id!!, canViewAdultContent = true)
val replyContext = repository.findRootCommentContext(newReply.id!!, viewer.id!!, canViewAdultContent = true)
val count = repository.countCommunityReplies(root.id!!, viewer.id!!)
val replies = repository.findCommunityReplies(root.id!!, viewer.id!!, offset = 0, limit = 10)
assertNotNull(context)
assertEquals(post.id, context!!.postId)
assertNull(invisibleSecretContext)
assertNull(replyContext)
assertEquals(2, count)
assertEquals(listOf(newReply.id, oldReply.id), replies.map { it.commentId })
}
private fun saveMember( private fun saveMember(
nickname: String, nickname: String,
role: MemberRole, role: MemberRole,

View File

@@ -10,6 +10,8 @@ import kr.co.vividnext.sodalive.member.MemberProvider
import kr.co.vividnext.sodalive.member.MemberRole import kr.co.vividnext.sodalive.member.MemberRole
import kr.co.vividnext.sodalive.member.contentpreference.MemberContentPreferenceService import kr.co.vividnext.sodalive.member.contentpreference.MemberContentPreferenceService
import kr.co.vividnext.sodalive.v2.creator.channel.community.domain.CreatorChannelCommunityQueryPolicy import kr.co.vividnext.sodalive.v2.creator.channel.community.domain.CreatorChannelCommunityQueryPolicy
import kr.co.vividnext.sodalive.v2.creator.channel.community.port.out.CreatorChannelCommunityCommentContextRecord
import kr.co.vividnext.sodalive.v2.creator.channel.community.port.out.CreatorChannelCommunityCommentRecord
import kr.co.vividnext.sodalive.v2.creator.channel.community.port.out.CreatorChannelCommunityCreatorRecord import kr.co.vividnext.sodalive.v2.creator.channel.community.port.out.CreatorChannelCommunityCreatorRecord
import kr.co.vividnext.sodalive.v2.creator.channel.community.port.out.CreatorChannelCommunityPostRecord import kr.co.vividnext.sodalive.v2.creator.channel.community.port.out.CreatorChannelCommunityPostRecord
import kr.co.vividnext.sodalive.v2.creator.channel.community.port.out.CreatorChannelCommunityQueryPort import kr.co.vividnext.sodalive.v2.creator.channel.community.port.out.CreatorChannelCommunityQueryPort
@@ -173,6 +175,126 @@ class CreatorChannelCommunityQueryServiceTest {
assertEquals(true, posts.single().isLiked) assertEquals(true, posts.single().isLiked)
} }
@Test
@DisplayName("커뮤니티 상세는 게시글과 초기 댓글 및 최신 답글을 조립한다")
fun shouldAssembleCommunityPostDetailWithInitialComments() {
val port = FakeCreatorChannelCommunityQueryPort().apply {
communityPost = communityPostRecord(1L, price = 0, isLiked = true)
communityCommentCount = 21
communityComments = (1L..21L).map { communityCommentRecord(it) }
latestReplies = listOf(communityCommentRecord(100L, parentCommentId = 1L, writerNickname = "deleted_reply-writer"))
}
val service = createService(port)
val viewer = createMember(id = 10L)
val detail = service.getCommunityPostDetail(1L, viewer, LocalDateTime.of(2026, 7, 6, 10, 0))
assertEquals(1L, detail.post.postId)
assertEquals(true, detail.post.isLiked)
assertEquals(21, detail.comments.commentCount)
assertEquals(20, detail.comments.comments.size)
assertEquals(true, detail.comments.hasNext)
assertEquals(101L, detail.comments.comments.first().writerId)
assertEquals(false, detail.comments.comments.first().isSecret)
assertEquals("reply-writer", detail.comments.comments.first().latestReply?.writerNickname)
assertEquals(200L, detail.comments.comments.first().latestReply?.writerId)
assertEquals("https://cdn.test/profile/default-profile.png", detail.comments.comments.first().writerProfileImageUrl)
}
@Test
@DisplayName("댓글 불가 게시글은 댓글 port를 호출하지 않고 빈 댓글 응답을 반환한다")
fun shouldReturnEmptyCommentsWhenCommentUnavailable() {
val port = FakeCreatorChannelCommunityQueryPort().apply {
communityPost = communityPostRecord(1L, price = 0, isCommentAvailable = false)
}
val service = createService(port)
val comments = service.getCommunityComments(1L, createMember(id = 10L), -1, 100, LocalDateTime.of(2026, 7, 6, 10, 0))
assertEquals(0, comments.commentCount)
assertEquals(0, comments.comments.size)
assertEquals(0, comments.page.page)
assertEquals(50, comments.page.size)
assertEquals(false, comments.hasNext)
assertEquals(false, port.commentListCalled)
}
@Test
@DisplayName("커뮤니티 댓글은 page fallback과 최신 답글을 적용한다")
fun shouldApplyCommentPageFallbackAndLatestReply() {
val port = FakeCreatorChannelCommunityQueryPort().apply {
communityPost = communityPostRecord(1L, price = 0)
communityCommentCount = 60
communityComments = (1L..51L).map { communityCommentRecord(it, writerProfilePath = "profile/$it.png") }
latestReplies = listOf(communityCommentRecord(100L, parentCommentId = 2L))
}
val service = createService(port)
val comments = service.getCommunityComments(1L, createMember(id = 10L), -1, 100, LocalDateTime.of(2026, 7, 6, 10, 0))
assertEquals(0L, port.commentListOffset)
assertEquals(51, port.commentListLimit)
assertEquals((1L..50L).toList(), port.latestReplyCommentIds)
assertEquals(50, comments.comments.size)
assertEquals(true, comments.hasNext)
assertEquals(101L, comments.comments.first().writerId)
assertEquals(false, comments.comments.first().isSecret)
assertEquals("https://cdn.test/profile/1.png", comments.comments.first().writerProfileImageUrl)
assertEquals(100L, comments.comments[1].latestReply?.commentId)
assertEquals(200L, comments.comments[1].latestReply?.writerId)
}
@Test
@DisplayName("커뮤니티 답글은 부모 댓글 context를 확인하고 page fallback으로 조립한다")
fun shouldAssembleCommunityReplies() {
val port = FakeCreatorChannelCommunityQueryPort().apply {
rootCommentContext = CreatorChannelCommunityCommentContextRecord(1L, 10L, 1L, true)
communityReplyCount = 51
communityReplies = (1L..51L).map { communityCommentRecord(it, parentCommentId = 1L) }
}
val service = createService(port)
val replies = service.getCommunityReplies(1L, createMember(id = 10L), -1, 100, LocalDateTime.of(2026, 7, 6, 10, 0))
assertEquals(51, replies.replyCount)
assertEquals(50, replies.replies.size)
assertEquals(0L, port.replyListOffset)
assertEquals(51, port.replyListLimit)
assertEquals(true, replies.hasNext)
assertEquals(101L, replies.replies.first().writerId)
}
@Test
@DisplayName("부모 댓글 context가 보이지 않으면 커뮤니티 답글 조회는 예외를 던진다")
fun shouldThrowWhenRootCommentContextNotVisible() {
val port = FakeCreatorChannelCommunityQueryPort().apply { rootCommentContext = null }
val service = createService(port)
val exception = assertThrows(SodaException::class.java) {
service.getCommunityReplies(1L, createMember(id = 10L), null, null, LocalDateTime.of(2026, 7, 6, 10, 0))
}
assertEquals("creator.community.invalid_request_retry", exception.messageKey)
}
@Test
@DisplayName("댓글 불가 게시글의 답글 직접 조회는 빈 응답을 반환한다")
fun shouldReturnEmptyRepliesWhenCommentUnavailable() {
val port = FakeCreatorChannelCommunityQueryPort().apply {
rootCommentContext = CreatorChannelCommunityCommentContextRecord(1L, 10L, 1L, false)
communityReplyCount = 1
communityReplies = listOf(communityCommentRecord(2L, parentCommentId = 1L))
}
val service = createService(port)
val replies = service.getCommunityReplies(1L, createMember(id = 10L), null, null, LocalDateTime.of(2026, 7, 6, 10, 0))
assertEquals(0, replies.replyCount)
assertEquals(0, replies.replies.size)
assertEquals(false, replies.hasNext)
assertEquals(null, port.replyListOffset)
}
private fun createService( private fun createService(
port: FakeCreatorChannelCommunityQueryPort, port: FakeCreatorChannelCommunityQueryPort,
audioContentCloudFront: AudioContentCloudFront = Mockito.mock(AudioContentCloudFront::class.java), audioContentCloudFront: AudioContentCloudFront = Mockito.mock(AudioContentCloudFront::class.java),
@@ -226,11 +348,29 @@ private class FakeCreatorChannelCommunityQueryPort : CreatorChannelCommunityQuer
var blocked = false var blocked = false
var communityPostCount = 1 var communityPostCount = 1
var communityPosts = listOf(communityPostRecord(1L, price = 0)) var communityPosts = listOf(communityPostRecord(1L, price = 0))
var communityPost: CreatorChannelCommunityPostRecord? = communityPostRecord(1L, price = 0)
var homeCommunityPosts = listOf(communityPostRecord(1L, price = 0)) var homeCommunityPosts = listOf(communityPostRecord(1L, price = 0))
var communityCommentCount = 0
var communityComments = emptyList<CreatorChannelCommunityCommentRecord>()
var latestReplies = emptyList<CreatorChannelCommunityCommentRecord>()
var rootCommentContext: CreatorChannelCommunityCommentContextRecord? = CreatorChannelCommunityCommentContextRecord(
commentId = 1L,
postId = 1L,
creatorId = 1L,
isCommentAvailable = true
)
var communityReplyCount = 0
var communityReplies = emptyList<CreatorChannelCommunityCommentRecord>()
var countCanViewAdultContent: Boolean? = null var countCanViewAdultContent: Boolean? = null
var listCanViewAdultContent: Boolean? = null var listCanViewAdultContent: Boolean? = null
var listOffset: Long? = null var listOffset: Long? = null
var listLimit: Int? = null var listLimit: Int? = null
var commentListCalled = false
var commentListOffset: Long? = null
var commentListLimit: Int? = null
var latestReplyCommentIds = emptyList<Long>()
var replyListOffset: Long? = null
var replyListLimit: Int? = null
var homeCreatorId: Long? = null var homeCreatorId: Long? = null
var homeViewerId: Long? = null var homeViewerId: Long? = null
var homeIsPinned: Boolean? = null var homeIsPinned: Boolean? = null
@@ -277,6 +417,54 @@ private class FakeCreatorChannelCommunityQueryPort : CreatorChannelCommunityQuer
homeLimit = limit homeLimit = limit
return homeCommunityPosts return homeCommunityPosts
} }
override fun findCommunityPost(
postId: Long,
viewerId: Long,
canViewAdultContent: Boolean
): CreatorChannelCommunityPostRecord? = communityPost
override fun countCommunityComments(postId: Long, viewerId: Long, creatorId: Long): Int = communityCommentCount
override fun findCommunityComments(
postId: Long,
viewerId: Long,
creatorId: Long,
offset: Long,
limit: Int
): List<CreatorChannelCommunityCommentRecord> {
commentListCalled = true
commentListOffset = offset
commentListLimit = limit
return communityComments
}
override fun findLatestRepliesByCommentIds(
commentIds: List<Long>,
viewerId: Long
): List<CreatorChannelCommunityCommentRecord> {
latestReplyCommentIds = commentIds
return latestReplies
}
override fun findRootCommentContext(
commentId: Long,
viewerId: Long,
canViewAdultContent: Boolean
): CreatorChannelCommunityCommentContextRecord? = rootCommentContext
override fun countCommunityReplies(commentId: Long, viewerId: Long): Int = communityReplyCount
override fun findCommunityReplies(
commentId: Long,
viewerId: Long,
offset: Long,
limit: Int
): List<CreatorChannelCommunityCommentRecord> {
replyListOffset = offset
replyListLimit = limit
return communityReplies
}
} }
private fun communityPostRecord( private fun communityPostRecord(
@@ -287,7 +475,8 @@ private fun communityPostRecord(
creatorProfilePath: String? = "profile/$postId.png", creatorProfilePath: String? = "profile/$postId.png",
imagePath: String? = "image/$postId.png", imagePath: String? = "image/$postId.png",
audioPath: String? = "audio/$postId.mp3", audioPath: String? = "audio/$postId.mp3",
isLiked: Boolean = false isLiked: Boolean = false,
isCommentAvailable: Boolean = true
): CreatorChannelCommunityPostRecord { ): CreatorChannelCommunityPostRecord {
return CreatorChannelCommunityPostRecord( return CreatorChannelCommunityPostRecord(
postId = postId, postId = postId,
@@ -300,10 +489,30 @@ private fun communityPostRecord(
price = price, price = price,
createdAt = LocalDateTime.of(2026, 6, 21, 10, 0).plusMinutes(postId), createdAt = LocalDateTime.of(2026, 6, 21, 10, 0).plusMinutes(postId),
existOrdered = existOrdered, existOrdered = existOrdered,
isCommentAvailable = true, isCommentAvailable = isCommentAvailable,
likeCount = postId.toInt(), likeCount = postId.toInt(),
commentCount = postId.toInt() + 1, commentCount = postId.toInt() + 1,
isPinned = postId == 1L, isPinned = postId == 1L,
isLiked = isLiked isLiked = isLiked
) )
} }
private fun communityCommentRecord(
commentId: Long,
parentCommentId: Long? = null,
writerProfilePath: String? = null,
writerNickname: String = "writer-$commentId",
isSecret: Boolean = false
): CreatorChannelCommunityCommentRecord {
return CreatorChannelCommunityCommentRecord(
commentId = commentId,
parentCommentId = parentCommentId,
postId = 1L,
writerId = commentId + 100L,
writerProfilePath = writerProfilePath,
writerNickname = writerNickname,
content = "comment-$commentId",
isSecret = isSecret,
createdAt = LocalDateTime.of(2026, 7, 6, 10, 0).plusMinutes(commentId)
)
}

View File

@@ -41,6 +41,20 @@ class CreatorChannelCommunityQueryPolicyTest {
assertEquals(51, maximumPage.fetchLimit) assertEquals(51, maximumPage.fetchLimit)
} }
@Test
@DisplayName("커뮤니티 댓글도 커뮤니티 탭과 같은 page fallback 정책을 사용한다")
fun shouldUseSamePageFallbackForCommunityComments() {
val defaultPage = policy.createPage(page = null, size = null)
val boundedPage = policy.createPage(page = -1, size = 100)
assertEquals(0, defaultPage.page)
assertEquals(20, defaultPage.size)
assertEquals(21, defaultPage.fetchLimit)
assertEquals(0, boundedPage.page)
assertEquals(50, boundedPage.size)
assertEquals(51, boundedPage.fetchLimit)
}
@Test @Test
@DisplayName("커뮤니티 탭 목록 정책은 요청 size만 남기고 다음 페이지 여부를 계산한다") @DisplayName("커뮤니티 탭 목록 정책은 요청 size만 남기고 다음 페이지 여부를 계산한다")
fun shouldLimitItemsAndCalculateHasNext() { fun shouldLimitItemsAndCalculateHasNext() {

View File

@@ -742,4 +742,40 @@ private class FakeCreatorChannelCommunityQueryPort : CreatorChannelCommunityQuer
) )
) )
} }
override fun findCommunityPost(
postId: Long,
viewerId: Long,
canViewAdultContent: Boolean
): CreatorChannelCommunityPostRecord? = null
override fun countCommunityComments(postId: Long, viewerId: Long, creatorId: Long): Int = 0
override fun findCommunityComments(
postId: Long,
viewerId: Long,
creatorId: Long,
offset: Long,
limit: Int
) = emptyList<kr.co.vividnext.sodalive.v2.creator.channel.community.port.out.CreatorChannelCommunityCommentRecord>()
override fun findLatestRepliesByCommentIds(
commentIds: List<Long>,
viewerId: Long
) = emptyList<kr.co.vividnext.sodalive.v2.creator.channel.community.port.out.CreatorChannelCommunityCommentRecord>()
override fun findRootCommentContext(
commentId: Long,
viewerId: Long,
canViewAdultContent: Boolean
): kr.co.vividnext.sodalive.v2.creator.channel.community.port.out.CreatorChannelCommunityCommentContextRecord? = null
override fun countCommunityReplies(commentId: Long, viewerId: Long): Int = 0
override fun findCommunityReplies(
commentId: Long,
viewerId: Long,
offset: Long,
limit: Int
) = emptyList<kr.co.vividnext.sodalive.v2.creator.channel.community.port.out.CreatorChannelCommunityCommentRecord>()
} }

View File

@@ -92,7 +92,7 @@ spring:
datasource: datasource:
driver-class-name: org.h2.Driver driver-class-name: org.h2.Driver
url: jdbc:h2:mem:sodalive-test;MODE=MySQL;DATABASE_TO_UPPER=false;NON_KEYWORDS=VALUE;DB_CLOSE_DELAY=-1;DB_CLOSE_ON_EXIT=FALSE url: jdbc:h2:mem:sodalive-test-${random.uuid};MODE=MySQL;DATABASE_TO_UPPER=false;NON_KEYWORDS=VALUE;DB_CLOSE_DELAY=-1;DB_CLOSE_ON_EXIT=FALSE
username: sa username: sa
password: password: