test #429
297
docs/20260629_커뮤니티_게시글_좋아요_상태/plan-task.md
Normal file
297
docs/20260629_커뮤니티_게시글_좋아요_상태/plan-task.md
Normal file
@@ -0,0 +1,297 @@
|
|||||||
|
# 커뮤니티 게시글 좋아요 상태 응답 추가 Implementation Plan
|
||||||
|
|
||||||
|
> **For agentic workers:** REQUIRED SUB-SKILL: Use `superpowers:subagent-driven-development` or `superpowers:executing-plans` to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||||
|
|
||||||
|
**Goal:** v2 커뮤니티 게시글 item 응답에 조회자 기준 `isLiked` 상태를 추가한다.
|
||||||
|
|
||||||
|
**Architecture:** 공개 API DTO에는 `@JsonProperty("isLiked")`가 붙은 Boolean 필드를 추가한다. 홈 인기 커뮤니티는 `HomePopularCommunityRecommendationRecord` projection에서 조회자 활성 좋아요 존재 여부를 전달하고, 크리에이터 채널 홈/커뮤니티 탭은 공통 `CreatorChannelCommunityPostRecord`와 `CreatorChannelCommunityPost`에 `isLiked`를 통과시킨다. 기존 좋아요 수, 구매 여부, 댓글 수, 성인 필터, 차단 정책은 변경하지 않는다.
|
||||||
|
|
||||||
|
**Tech Stack:** Kotlin, Spring Boot 2.7.14, QueryDSL, JPA, JUnit 5, MockMvc, Gradle Wrapper
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 파일 구조
|
||||||
|
|
||||||
|
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/home/dto/recommendation/HomeRecommendationResponse.kt`
|
||||||
|
- `HomePopularCommunityPostItem`에 `isLiked` 응답 필드를 추가한다.
|
||||||
|
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/home/application/HomeRecommendationFacade.kt`
|
||||||
|
- `HomePopularCommunityRecommendationRecord.isLiked`를 `HomePopularCommunityPostItem.isLiked`로 매핑한다.
|
||||||
|
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/recommendation/port/out/HomeRecommendationQueryPort.kt`
|
||||||
|
- `HomePopularCommunityRecommendationRecord`에 `isLiked`를 추가한다.
|
||||||
|
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/recommendation/adapter/out/persistence/DefaultHomeRecommendationQueryRepository.kt`
|
||||||
|
- 인기 커뮤니티 상세 projection에서 `memberId` 기준 활성 좋아요 존재 여부를 계산한다.
|
||||||
|
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/community/port/out/CreatorChannelCommunityQueryPort.kt`
|
||||||
|
- `CreatorChannelCommunityPostRecord`에 `isLiked`를 추가한다.
|
||||||
|
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/community/domain/CreatorChannelCommunityTab.kt`
|
||||||
|
- `CreatorChannelCommunityPost`에 `isLiked`를 추가한다.
|
||||||
|
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/community/application/CreatorChannelCommunityQueryService.kt`
|
||||||
|
- record의 `isLiked`를 domain post로 매핑한다.
|
||||||
|
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/community/adapter/out/persistence/DefaultCreatorChannelCommunityQueryRepository.kt`
|
||||||
|
- 응답 post id 목록에 대해 조회자 활성 좋아요 post id를 bulk 조회한다.
|
||||||
|
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/home/dto/CreatorChannelHomeResponse.kt`
|
||||||
|
- 채널 홈 공지/커뮤니티 게시글 응답에 `isLiked`를 추가한다.
|
||||||
|
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/community/dto/CreatorChannelCommunityTabResponse.kt`
|
||||||
|
- 커뮤니티 탭 게시글 응답에 `isLiked`를 추가한다.
|
||||||
|
- Modify: 관련 테스트 파일
|
||||||
|
- 홈 추천: `HomeRecommendationQueryServiceTest`, `DefaultHomeRecommendationQueryRepositoryTest`, `HomeRecommendationControllerTest`
|
||||||
|
- 채널 커뮤니티: `CreatorChannelCommunityQueryPolicyTest`, `CreatorChannelCommunityQueryServiceTest`, `DefaultCreatorChannelCommunityQueryRepositoryTest`
|
||||||
|
- 채널 API DTO/controller: `CreatorChannelCommunityFacadeTest`, `CreatorChannelCommunityControllerTest`, `CreatorChannelHomeFacadeTest`, `CreatorChannelHomeControllerTest`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Phase 1: 홈 인기 커뮤니티 `isLiked` 추가
|
||||||
|
|
||||||
|
- [x] **Task 1.1: 홈 인기 커뮤니티 repository 테스트에 조회자 좋아요 상태를 고정한다**
|
||||||
|
- Files:
|
||||||
|
- Modify: `src/test/kotlin/kr/co/vividnext/sodalive/v2/recommendation/adapter/out/persistence/DefaultHomeRecommendationQueryRepositoryTest.kt`
|
||||||
|
- RED: `shouldFindPopularCommunityRecommendationDetailsWithEligibilityAndCounts`에서 현재 조회자 활성 좋아요와 다른 회원 좋아요를 구분하는 assertion을 먼저 추가한다.
|
||||||
|
- 추가할 검증:
|
||||||
|
- `detailById[eligible.id]!!.isLiked == true`
|
||||||
|
- `detailById[paid.id]!!.isLiked == false`
|
||||||
|
- 비회원 테스트 `shouldReturnFalseOrderStatusForAnonymousPopularCommunityDetails`에서 `details.map { it.isLiked } == listOf(false)`
|
||||||
|
- 다른 회원 좋아요 검증을 위해 `paid` 게시글에는 다른 회원의 활성 좋아요를 저장하되, 현재 조회자의 좋아요로 계산되지 않음을 확인한다.
|
||||||
|
- 실패 확인 명령:
|
||||||
|
- `./gradlew test --tests kr.co.vividnext.sodalive.v2.recommendation.adapter.out.persistence.DefaultHomeRecommendationQueryRepositoryTest`
|
||||||
|
- 기대 결과:
|
||||||
|
- `HomePopularCommunityRecommendationRecord`에 `isLiked`가 없어 `compileTestKotlin` 실패한다.
|
||||||
|
- GREEN: `HomePopularCommunityRecommendationRecord`와 `DefaultHomeRecommendationQueryRepository.findPopularCommunityRecommendationDetails(...)`를 수정한다.
|
||||||
|
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/recommendation/port/out/HomeRecommendationQueryPort.kt`
|
||||||
|
- `HomePopularCommunityRecommendationRecord` 생성자 마지막에 `val isLiked: Boolean`을 추가한다.
|
||||||
|
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/recommendation/adapter/out/persistence/DefaultHomeRecommendationQueryRepository.kt`
|
||||||
|
- projection 마지막에 `likedCommunityPostCondition(memberId)`를 추가한다.
|
||||||
|
- helper를 추가한다.
|
||||||
|
|
||||||
|
```kotlin
|
||||||
|
private fun likedCommunityPostCondition(memberId: Long?): BooleanExpression {
|
||||||
|
if (memberId == null) return Expressions.FALSE
|
||||||
|
return JPAExpressions
|
||||||
|
.selectOne()
|
||||||
|
.from(creatorCommunityLike)
|
||||||
|
.where(
|
||||||
|
creatorCommunityLike.creatorCommunity.id.eq(creatorCommunity.id),
|
||||||
|
creatorCommunityLike.member.id.eq(memberId),
|
||||||
|
creatorCommunityLike.isActive.isTrue
|
||||||
|
)
|
||||||
|
.exists()
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- 통과 확인 명령:
|
||||||
|
- `./gradlew test --tests kr.co.vividnext.sodalive.v2.recommendation.adapter.out.persistence.DefaultHomeRecommendationQueryRepositoryTest`
|
||||||
|
- REFACTOR: 기존 `likeCount`, `commentCount`, `existOrdered`, adult filter, block filter assertion이 그대로 남아 있는지 확인한다.
|
||||||
|
- 회귀 명령:
|
||||||
|
- `./gradlew test --tests kr.co.vividnext.sodalive.v2.recommendation.application.HomeRecommendationQueryServiceTest`
|
||||||
|
|
||||||
|
- [x] **Task 1.2: 홈 추천 service/facade/응답 DTO에 `isLiked`를 통과시킨다**
|
||||||
|
- Files:
|
||||||
|
- Modify: `src/test/kotlin/kr/co/vividnext/sodalive/v2/recommendation/application/HomeRecommendationQueryServiceTest.kt`
|
||||||
|
- Modify: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/home/HomeRecommendationControllerTest.kt`
|
||||||
|
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/home/dto/recommendation/HomeRecommendationResponse.kt`
|
||||||
|
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/home/application/HomeRecommendationFacade.kt`
|
||||||
|
- RED:
|
||||||
|
- `HomeRecommendationQueryServiceTest`의 `HomePopularCommunityRecommendationRecord(...)` fixture에 `isLiked`를 넣고, 결과가 유지되는지 assertion을 추가한다.
|
||||||
|
- `HomeRecommendationControllerTest`에는 `popularCommunityPosts[0].isLiked` JSON assertion을 추가한다. 비회원 통합 조회에서 데이터가 비어 있으면 별도 fixture 생성이 부담되므로, 이미 인기 커뮤니티 데이터가 있는 테스트가 없을 경우 repository/facade 단위 테스트를 우선 검증하고 controller 테스트는 DTO 직렬화가 걸리는 기존 fixture가 있는 위치만 갱신한다.
|
||||||
|
- 실패 확인 명령:
|
||||||
|
- `./gradlew test --tests kr.co.vividnext.sodalive.v2.recommendation.application.HomeRecommendationQueryServiceTest --tests kr.co.vividnext.sodalive.v2.api.home.HomeRecommendationControllerTest`
|
||||||
|
- 기대 결과:
|
||||||
|
- DTO 또는 fixture 생성자 인자 불일치로 `compileTestKotlin` 또는 JSON assertion 실패가 발생한다.
|
||||||
|
- GREEN:
|
||||||
|
- `HomePopularCommunityPostItem`에 아래 필드를 추가한다.
|
||||||
|
|
||||||
|
```kotlin
|
||||||
|
@JsonProperty("isLiked")
|
||||||
|
val isLiked: Boolean
|
||||||
|
```
|
||||||
|
|
||||||
|
- `HomeRecommendationFacade.HomePopularCommunityRecommendationRecord.toItem()`에서 `isLiked = isLiked`를 추가한다.
|
||||||
|
- 모든 `HomePopularCommunityRecommendationRecord(...)` 테스트 fixture에 `isLiked = false` 또는 검증 목적에 맞는 값을 명시한다.
|
||||||
|
- 통과 확인 명령:
|
||||||
|
- `./gradlew test --tests kr.co.vividnext.sodalive.v2.recommendation.application.HomeRecommendationQueryServiceTest --tests kr.co.vividnext.sodalive.v2.api.home.HomeRecommendationControllerTest`
|
||||||
|
- REFACTOR:
|
||||||
|
- `rg -n "HomePopularCommunityRecommendationRecord\\(" src/test/kotlin src/main/kotlin`로 모든 생성자 호출이 `isLiked`를 명시하는지 확인한다.
|
||||||
|
- `rg -n "JsonProperty\\(\"isLiked\"\\).*HomePopularCommunityPostItem|val isLiked" src/main/kotlin/kr/co/vividnext/sodalive/v2/api/home/dto/recommendation/HomeRecommendationResponse.kt`로 직렬화 필드명을 확인한다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Phase 2: 크리에이터 채널 커뮤니티 공통 모델과 조회에 `isLiked` 추가
|
||||||
|
|
||||||
|
- [x] **Task 2.1: 채널 커뮤니티 repository 테스트에 조회자 좋아요 상태를 고정한다**
|
||||||
|
- Files:
|
||||||
|
- Modify: `src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/community/adapter/out/persistence/DefaultCreatorChannelCommunityQueryRepositoryTest.kt`
|
||||||
|
- RED:
|
||||||
|
- `shouldCountActiveLikesAndZeroCommentsWhenUnavailable`에 현재 조회자 활성 좋아요를 저장하고 `record.isLiked == true`를 검증한다.
|
||||||
|
- 같은 테스트 또는 별도 테스트에서 다른 회원 활성 좋아요만 있는 게시글은 `isLiked == false`, 비활성 좋아요는 `isLiked == false`를 검증한다.
|
||||||
|
- `findHomeCommunityPosts` 검증이 있는 테스트에 홈 요약 조회도 `isLiked`를 반환하는 assertion을 추가한다.
|
||||||
|
- 실패 확인 명령:
|
||||||
|
- `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.community.adapter.out.persistence.DefaultCreatorChannelCommunityQueryRepositoryTest`
|
||||||
|
- 기대 결과:
|
||||||
|
- `CreatorChannelCommunityPostRecord`에 `isLiked`가 없어 `compileTestKotlin` 실패한다.
|
||||||
|
- GREEN:
|
||||||
|
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/community/port/out/CreatorChannelCommunityQueryPort.kt`
|
||||||
|
- `CreatorChannelCommunityPostRecord`에 `val isLiked: Boolean`을 추가한다.
|
||||||
|
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/community/adapter/out/persistence/DefaultCreatorChannelCommunityQueryRepository.kt`
|
||||||
|
- `toCommunityPostRecords(...)`에서 `val likedPostIds = likedCommunityPostIds(viewerId, postIds)`를 계산한다.
|
||||||
|
- record 생성 시 `isLiked = postId in likedPostIds`를 추가한다.
|
||||||
|
- helper를 추가한다.
|
||||||
|
|
||||||
|
```kotlin
|
||||||
|
private fun likedCommunityPostIds(viewerId: Long, postIds: List<Long>): Set<Long> {
|
||||||
|
if (postIds.isEmpty()) return emptySet()
|
||||||
|
|
||||||
|
return queryFactory
|
||||||
|
.select(creatorCommunityLike.creatorCommunity.id)
|
||||||
|
.distinct()
|
||||||
|
.from(creatorCommunityLike)
|
||||||
|
.where(
|
||||||
|
creatorCommunityLike.member.id.eq(viewerId),
|
||||||
|
creatorCommunityLike.creatorCommunity.id.`in`(postIds),
|
||||||
|
creatorCommunityLike.isActive.isTrue
|
||||||
|
)
|
||||||
|
.fetch()
|
||||||
|
.toSet()
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- 통과 확인 명령:
|
||||||
|
- `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.community.adapter.out.persistence.DefaultCreatorChannelCommunityQueryRepositoryTest`
|
||||||
|
- REFACTOR:
|
||||||
|
- `likedCommunityPostIds`가 `postIds.isEmpty()`를 처리해 불필요한 query를 만들지 않는지 확인한다.
|
||||||
|
- 기존 `communityLikeCounts(postIds)`는 그대로 유지하고 `isLiked` 계산에 재사용하지 않는다. 좋아요 수와 조회자 좋아요 여부는 다른 의미다.
|
||||||
|
|
||||||
|
- [x] **Task 2.2: 채널 커뮤니티 domain/service에 `isLiked`를 통과시킨다**
|
||||||
|
- Files:
|
||||||
|
- 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/home/application/CreatorChannelHomeQueryServiceTest.kt`
|
||||||
|
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/community/domain/CreatorChannelCommunityTab.kt`
|
||||||
|
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/community/application/CreatorChannelCommunityQueryService.kt`
|
||||||
|
- RED:
|
||||||
|
- `CreatorChannelCommunityQueryServiceTest.shouldAssembleCommunityPostAssetsByAccessPolicy`에서 첫 게시글 `isLiked=true`, 다른 게시글 `isLiked=false` fixture를 만들고 domain 결과를 검증한다.
|
||||||
|
- `shouldAssembleHomeCommunityPostsWithoutTabValidation`에서도 home post의 `isLiked`가 유지되는지 검증한다.
|
||||||
|
- `CreatorChannelCommunityQueryPolicyTest`와 `CreatorChannelHomeQueryServiceTest`의 생성자 fixture에 `isLiked`를 명시한다.
|
||||||
|
- 실패 확인 명령:
|
||||||
|
- `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.community.application.CreatorChannelCommunityQueryServiceTest --tests kr.co.vividnext.sodalive.v2.creator.channel.community.domain.CreatorChannelCommunityQueryPolicyTest --tests kr.co.vividnext.sodalive.v2.creator.channel.home.application.CreatorChannelHomeQueryServiceTest`
|
||||||
|
- 기대 결과:
|
||||||
|
- `CreatorChannelCommunityPost` 생성자 인자 불일치 또는 `isLiked` 미구현으로 실패한다.
|
||||||
|
- GREEN:
|
||||||
|
- `CreatorChannelCommunityPost`에 `val isLiked: Boolean`을 추가한다.
|
||||||
|
- `CreatorChannelCommunityQueryService.CreatorChannelCommunityPostRecord.toDomain(...)`에서 `isLiked = isLiked`를 추가한다.
|
||||||
|
- 테스트 helper `communityPostRecord(...)`에는 기본값 `isLiked: Boolean = false`를 추가하고 record 생성 시 전달한다.
|
||||||
|
- 통과 확인 명령:
|
||||||
|
- `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.community.application.CreatorChannelCommunityQueryServiceTest --tests kr.co.vividnext.sodalive.v2.creator.channel.community.domain.CreatorChannelCommunityQueryPolicyTest --tests kr.co.vividnext.sodalive.v2.creator.channel.home.application.CreatorChannelHomeQueryServiceTest`
|
||||||
|
- REFACTOR:
|
||||||
|
- `rg -n "CreatorChannelCommunityPostRecord\\(|CreatorChannelCommunityPost\\(" src/main/kotlin src/test/kotlin`로 모든 생성자 호출이 컴파일 가능한지 확인한다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Phase 3: 크리에이터 채널 공개 API DTO에 `isLiked` 추가
|
||||||
|
|
||||||
|
- [x] **Task 3.1: 커뮤니티 탭 응답 DTO와 controller JSON을 갱신한다**
|
||||||
|
- Files:
|
||||||
|
- 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/CreatorChannelCommunityControllerTest.kt`
|
||||||
|
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/community/dto/CreatorChannelCommunityTabResponse.kt`
|
||||||
|
- RED:
|
||||||
|
- `CreatorChannelCommunityFacadeTest.shouldMapCommunityTabDomainToPublicResponse`에서 `response.communityPosts.first().isLiked == true`를 검증한다.
|
||||||
|
- ObjectMapper JSON 검증에 `json["communityPosts"][0]["isLiked"].asBoolean()`을 추가한다.
|
||||||
|
- `CreatorChannelCommunityControllerTest.shouldReturnCreatorChannelCommunityTabForAuthenticatedMember`에 `jsonPath("$.data.communityPosts[0].isLiked").value(true)`를 추가한다.
|
||||||
|
- 실패 확인 명령:
|
||||||
|
- `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.creator.channel.community.application.CreatorChannelCommunityFacadeTest --tests kr.co.vividnext.sodalive.v2.api.creator.channel.community.adapter.in.web.CreatorChannelCommunityControllerTest`
|
||||||
|
- 기대 결과:
|
||||||
|
- DTO에 `isLiked`가 없어 컴파일 또는 JSON assertion이 실패한다.
|
||||||
|
- GREEN:
|
||||||
|
- 커뮤니티 탭 `CreatorChannelCommunityPostResponse`에 아래 필드를 추가한다.
|
||||||
|
|
||||||
|
```kotlin
|
||||||
|
@JsonProperty("isLiked")
|
||||||
|
val isLiked: Boolean
|
||||||
|
```
|
||||||
|
|
||||||
|
- `CreatorChannelCommunityPostResponse.from(post)`에 `isLiked = post.isLiked`를 추가한다.
|
||||||
|
- 테스트 fixture의 `CreatorChannelCommunityPost`와 `CreatorChannelCommunityPostResponse` 생성자에 `isLiked`를 명시한다.
|
||||||
|
- 통과 확인 명령:
|
||||||
|
- `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.creator.channel.community.application.CreatorChannelCommunityFacadeTest --tests kr.co.vividnext.sodalive.v2.api.creator.channel.community.adapter.in.web.CreatorChannelCommunityControllerTest`
|
||||||
|
- REFACTOR:
|
||||||
|
- `isCommentAvailable`, `isPinned`, `isLiked` 모두 `is` prefix가 유지되는지 ObjectMapper 테스트로 확인한다.
|
||||||
|
|
||||||
|
- [x] **Task 3.2: 크리에이터 채널 홈 응답 DTO와 controller JSON을 갱신한다**
|
||||||
|
- Files:
|
||||||
|
- Modify: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/home/application/CreatorChannelHomeFacadeTest.kt`
|
||||||
|
- Modify: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/home/adapter/in/web/CreatorChannelHomeControllerTest.kt`
|
||||||
|
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/home/dto/CreatorChannelHomeResponse.kt`
|
||||||
|
- RED:
|
||||||
|
- `CreatorChannelHomeFacadeTest.shouldMapHomeQueryResultToPublicResponse`에 `response.notices.first().isLiked == true`, `response.communities.first().isLiked == true` 검증을 추가한다.
|
||||||
|
- `CreatorChannelHomeControllerTest.shouldReturnCreatorChannelHomeForAuthenticatedMember`에 `jsonPath("$.data.notices[0].isLiked").value(true)`와 `jsonPath("$.data.communities[0].isLiked").value(true)`를 추가한다.
|
||||||
|
- 실패 확인 명령:
|
||||||
|
- `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.creator.channel.home.application.CreatorChannelHomeFacadeTest --tests kr.co.vividnext.sodalive.v2.api.creator.channel.home.adapter.in.web.CreatorChannelHomeControllerTest`
|
||||||
|
- 기대 결과:
|
||||||
|
- 홈 DTO에 `isLiked`가 없어 컴파일 또는 JSON assertion이 실패한다.
|
||||||
|
- GREEN:
|
||||||
|
- 홈 `CreatorChannelCommunityPostResponse`에 아래 필드를 추가한다.
|
||||||
|
|
||||||
|
```kotlin
|
||||||
|
@JsonProperty("isLiked")
|
||||||
|
val isLiked: Boolean
|
||||||
|
```
|
||||||
|
|
||||||
|
- `CreatorChannelCommunityPostResponse.from(post)`에 `isLiked = post.isLiked`를 추가한다.
|
||||||
|
- 테스트 fixture의 `CreatorChannelCommunityPost` 생성자에 `isLiked = true` 또는 `false`를 명시한다.
|
||||||
|
- 통과 확인 명령:
|
||||||
|
- `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.creator.channel.home.application.CreatorChannelHomeFacadeTest --tests kr.co.vividnext.sodalive.v2.api.creator.channel.home.adapter.in.web.CreatorChannelHomeControllerTest`
|
||||||
|
- REFACTOR:
|
||||||
|
- 홈 공지와 홈 일반 커뮤니티가 같은 domain post 필드를 사용하므로 별도 계산 로직을 추가하지 않았는지 확인한다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Phase 4: 통합 회귀와 문서 검증
|
||||||
|
|
||||||
|
- [x] **Task 4.1: 관련 테스트 묶음을 실행하고 fixture 누락을 정리한다**
|
||||||
|
- Files:
|
||||||
|
- Verify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/home/dto/recommendation/HomeRecommendationResponse.kt`
|
||||||
|
- Verify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/home/dto/CreatorChannelHomeResponse.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/recommendation/port/out/HomeRecommendationQueryPort.kt`
|
||||||
|
- Verify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/community/port/out/CreatorChannelCommunityQueryPort.kt`
|
||||||
|
- RED:
|
||||||
|
- 이 task는 앞선 task가 모두 GREEN인 뒤 전체 fixture 누락을 찾는 회귀 task다.
|
||||||
|
- 실행 명령:
|
||||||
|
- `./gradlew test --tests 'kr.co.vividnext.sodalive.v2.recommendation.*' --tests 'kr.co.vividnext.sodalive.v2.api.home.*' --tests 'kr.co.vividnext.sodalive.v2.creator.channel.community.*' --tests 'kr.co.vividnext.sodalive.v2.api.creator.channel.community.*' --tests 'kr.co.vividnext.sodalive.v2.api.creator.channel.home.*' --tests 'kr.co.vividnext.sodalive.v2.creator.channel.home.application.CreatorChannelHomeQueryServiceTest'`
|
||||||
|
- 기대 결과:
|
||||||
|
- 관련 테스트가 모두 `BUILD SUCCESSFUL`이어야 한다.
|
||||||
|
- GREEN:
|
||||||
|
- 컴파일 실패가 남으면 생성자 호출 누락을 `rg -n "isLiked|HomePopularCommunityRecommendationRecord\\(|CreatorChannelCommunityPostRecord\\(|CreatorChannelCommunityPost\\(" src/main/kotlin src/test/kotlin`로 찾아 보정한다.
|
||||||
|
- REFACTOR:
|
||||||
|
- `isLiked`를 계산하기 위해 기존 `likeCount` 쿼리의 의미를 바꾸지 않았는지 diff로 확인한다.
|
||||||
|
- `isLiked`가 응답 DTO 3곳에 모두 `@JsonProperty("isLiked")`로 선언되어 있는지 확인한다.
|
||||||
|
|
||||||
|
- [x] **Task 4.2: ktlint와 전체 명령 유효성을 확인한다**
|
||||||
|
- Files:
|
||||||
|
- Verify: `build.gradle.kts`
|
||||||
|
- Verify: `docs/20260629_커뮤니티_게시글_좋아요_상태/plan-task.md`
|
||||||
|
- RED:
|
||||||
|
- 이 task는 문서와 포맷 검증 task이므로 실패 테스트 작성 대상이 아니다.
|
||||||
|
- TDD 예외 사유: 코드 동작 변경이 아니라 전체 포맷/명령 검증이다.
|
||||||
|
- 대체 검증 방법: Gradle verification task를 실행한다.
|
||||||
|
- 실행 명령:
|
||||||
|
- `./gradlew ktlintCheck`
|
||||||
|
- `./gradlew tasks --all`
|
||||||
|
- 기대 결과:
|
||||||
|
- 두 명령 모두 `BUILD SUCCESSFUL`이어야 한다.
|
||||||
|
- REFACTOR:
|
||||||
|
- `build.gradle.kts`를 변경하지 않았으면 실행 명령 문서 갱신은 필요 없다.
|
||||||
|
- plan-task 검증 기록에는 실행한 명령과 결과를 누적한다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 검증 기록
|
||||||
|
|
||||||
|
- 2026-06-29: plan-task 작성 전 PRD `docs/20260629_커뮤니티_게시글_좋아요_상태/prd.md`와 관련 v2 DTO/record/repository/test 파일을 확인했다.
|
||||||
|
- 2026-06-29: 문서 자체 검토로 placeholder 금지 패턴 검색을 실행했고 결과가 없음을 확인했다. `./gradlew tasks --all`은 sandbox에서 `~/.gradle` lock 파일 접근 제한으로 1차 실패했으며, 승인 권한으로 재실행해 `BUILD SUCCESSFUL`을 확인했다.
|
||||||
|
- 2026-06-30: Task 1.1 RED로 `./gradlew test --tests kr.co.vividnext.sodalive.v2.recommendation.adapter.out.persistence.DefaultHomeRecommendationQueryRepositoryTest`를 실행했고 `HomePopularCommunityRecommendationRecord.isLiked` 미구현으로 `compileTestKotlin` 실패를 확인했다.
|
||||||
|
- 2026-06-30: Task 1.1/1.2 GREEN으로 홈 인기 커뮤니티 record/projection/DTO/facade/fixture를 반영한 뒤 `./gradlew test --tests kr.co.vividnext.sodalive.v2.recommendation.adapter.out.persistence.DefaultHomeRecommendationQueryRepositoryTest`와 `./gradlew test --tests kr.co.vividnext.sodalive.v2.recommendation.application.HomeRecommendationQueryServiceTest --tests kr.co.vividnext.sodalive.v2.api.home.HomeRecommendationControllerTest`가 모두 `BUILD SUCCESSFUL`임을 확인했다.
|
||||||
|
- 2026-06-30: Task 2.1 RED로 `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.community.adapter.out.persistence.DefaultCreatorChannelCommunityQueryRepositoryTest`를 실행했고 `CreatorChannelCommunityPostRecord.isLiked` 미구현으로 `compileTestKotlin` 실패를 확인했다.
|
||||||
|
- 2026-06-30: Task 2.1/2.2 GREEN으로 채널 커뮤니티 record/domain/service/repository bulk liked post id 조회와 fixture를 반영한 뒤 `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.community.adapter.out.persistence.DefaultCreatorChannelCommunityQueryRepositoryTest`와 `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.community.application.CreatorChannelCommunityQueryServiceTest --tests kr.co.vividnext.sodalive.v2.creator.channel.community.domain.CreatorChannelCommunityQueryPolicyTest --tests kr.co.vividnext.sodalive.v2.creator.channel.home.application.CreatorChannelHomeQueryServiceTest`가 모두 `BUILD SUCCESSFUL`임을 확인했다.
|
||||||
|
- 2026-06-30: Task 3.1/3.2 GREEN으로 커뮤니티 탭/채널 홈 공개 DTO와 controller JSON fixture를 반영한 뒤 `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.creator.channel.community.application.CreatorChannelCommunityFacadeTest --tests kr.co.vividnext.sodalive.v2.api.creator.channel.community.adapter.in.web.CreatorChannelCommunityControllerTest`와 `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.creator.channel.home.application.CreatorChannelHomeFacadeTest --tests kr.co.vividnext.sodalive.v2.api.creator.channel.home.adapter.in.web.CreatorChannelHomeControllerTest`가 모두 `BUILD SUCCESSFUL`임을 확인했다.
|
||||||
|
- 2026-06-30: Task 4.1 회귀 명령 `./gradlew test --tests 'kr.co.vividnext.sodalive.v2.recommendation.*' --tests 'kr.co.vividnext.sodalive.v2.api.home.*' --tests 'kr.co.vividnext.sodalive.v2.creator.channel.community.*' --tests 'kr.co.vividnext.sodalive.v2.api.creator.channel.community.*' --tests 'kr.co.vividnext.sodalive.v2.api.creator.channel.home.*' --tests 'kr.co.vividnext.sodalive.v2.creator.channel.home.application.CreatorChannelHomeQueryServiceTest'`를 실행했고 199개 중 1개 실패를 확인했다. 실패는 `kr.co.vividnext.sodalive.v2.api.home.following.adapter.in.web.HomeFollowingEndToEndTest.shouldAssembleFollowingTabForMember`의 `$.data.monthlySchedules[1].scheduleId` JSON path 누락으로, 이번 `isLiked` 변경 대상이 아닌 `api.home.following` 경로의 기존 회귀로 분리했다.
|
||||||
|
- 2026-06-30: Task 4.2로 `./gradlew ktlintCheck`와 `./gradlew tasks --all`을 실행했고 모두 `BUILD SUCCESSFUL`임을 확인했다.
|
||||||
|
- 2026-06-30: Reviewer gate에서 채널 커뮤니티 repository 테스트의 다른 회원 활성 좋아요 전용 시나리오 누락을 지적받아 `otherMemberOnlyLikedPost` 검증을 추가했다. 이후 `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.community.adapter.out.persistence.DefaultCreatorChannelCommunityQueryRepositoryTest`, focused 테스트 묶음, `./gradlew ktlintCheck`를 재실행해 모두 `BUILD SUCCESSFUL`을 확인했고, reviewer gate 재검토에서 unconditional approval을 받았다.
|
||||||
155
docs/20260629_커뮤니티_게시글_좋아요_상태/prd.md
Normal file
155
docs/20260629_커뮤니티_게시글_좋아요_상태/prd.md
Normal file
@@ -0,0 +1,155 @@
|
|||||||
|
# PRD: 커뮤니티 게시글 좋아요 상태 응답 추가
|
||||||
|
|
||||||
|
## 1. Overview
|
||||||
|
v2 API에서 커뮤니티 게시글 item을 내려줄 때 인증 조회자가 해당 게시글에 좋아요를 누른 상태를 함께 제공한다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Problem
|
||||||
|
- v2 커뮤니티 게시글 응답은 `likeCount`는 제공하지만, 조회자 본인의 좋아요 여부는 제공하지 않는다.
|
||||||
|
- 앱 클라이언트는 게시글 카드의 좋아요 버튼 초기 상태를 알기 위해 별도 조회를 하거나 화면 진입 후 상태를 추정해야 한다.
|
||||||
|
- 같은 커뮤니티 게시글이 홈 추천, 크리에이터 채널 홈, 크리에이터 채널 커뮤니티 탭에서 각각 다른 DTO로 노출되므로 누락 없이 동일한 의미의 필드를 추가해야 한다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Goals
|
||||||
|
- v2에서 커뮤니티 게시글 카드로 노출되는 모든 응답 item에 조회자의 좋아요 상태를 추가한다.
|
||||||
|
- 신규 응답 필드 이름은 `isLiked`로 한다.
|
||||||
|
- Kotlin Boolean 직렬화가 `liked`로 바뀌지 않도록 응답 DTO에는 `@JsonProperty("isLiked")`를 사용한다.
|
||||||
|
- 좋아요 상태는 `CreatorCommunityLike.isActive == true`인 행을 기준으로 계산한다.
|
||||||
|
- 인증 회원 기준으로 조회되는 크리에이터 채널 홈/커뮤니티 탭에서는 로그인한 조회자의 좋아요 여부를 반영한다.
|
||||||
|
- 비회원도 호출 가능한 홈 추천 전체 응답에서는 비회원의 `isLiked`를 `false`로 내려준다.
|
||||||
|
- 기존 `likeCount`, `commentCount`, `existOrdered`, 유료 콘텐츠 마스킹, 성인 콘텐츠 필터, 차단 정책은 변경하지 않는다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Non-Goals
|
||||||
|
- 커뮤니티 게시글 좋아요 생성/취소 API 동작은 변경하지 않는다.
|
||||||
|
- `likeCount` 집계 기준은 변경하지 않는다.
|
||||||
|
- legacy 커뮤니티 API 응답 스키마는 변경하지 않는다.
|
||||||
|
- 팔로잉 뉴스의 `COMMUNITY_POST` news item에는 게시글 카드 필드가 없으므로 이번 범위에서 제외한다.
|
||||||
|
- DB schema, 운영 DDL, 마이그레이션은 포함하지 않는다.
|
||||||
|
- 추천 스냅샷 산정 로직과 인기 커뮤니티 정렬 정책은 변경하지 않는다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Target Users
|
||||||
|
- 회원: 커뮤니티 게시글 카드에서 본인이 누른 좋아요 상태를 즉시 확인하려는 사용자
|
||||||
|
- 앱 클라이언트: 게시글 카드 렌더링 시 좋아요 버튼의 초기 active 상태가 필요한 클라이언트
|
||||||
|
- 서버 개발자: v2 커뮤니티 게시글 응답 간 좋아요 상태 필드 의미를 일관되게 유지해야 하는 개발자
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. 조사 결과
|
||||||
|
|
||||||
|
### 대상 응답 item
|
||||||
|
- `HomePopularCommunityPostItem`
|
||||||
|
- 파일: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/home/dto/recommendation/HomeRecommendationResponse.kt`
|
||||||
|
- 노출 API: `GET /api/v2/home/recommendations`
|
||||||
|
- 현재 필드: `postId`, `creatorId`, `creatorNickname`, `creatorProfileImage`, `imageUrl`, `audioUrl`, `content`, `price`, `createdAt`, `likeCount`, `commentCount`, `existOrdered`
|
||||||
|
- 데이터 흐름: `HomeRecommendationFacade`가 `HomePopularCommunityRecommendationRecord`를 변환한다.
|
||||||
|
|
||||||
|
- `CreatorChannelCommunityPostResponse` in creator channel home
|
||||||
|
- 파일: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/home/dto/CreatorChannelHomeResponse.kt`
|
||||||
|
- 노출 API: `GET /api/v2/creator-channels/{creatorId}/home`
|
||||||
|
- 현재 필드: `postId`, `creatorId`, `creatorNickname`, `creatorProfileUrl`, `imageUrl`, `audioUrl`, `content`, `price`, `dateUtc`, `existOrdered`, `likeCount`, `commentCount`
|
||||||
|
- 데이터 흐름: `CreatorChannelHomeQueryService`가 `CreatorChannelCommunityQueryService.findHomeCommunityPosts(...)`를 호출하고, 공지/커뮤니티 섹션이 같은 domain post를 사용한다.
|
||||||
|
|
||||||
|
- `CreatorChannelCommunityPostResponse` in creator channel community tab
|
||||||
|
- 파일: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/community/dto/CreatorChannelCommunityTabResponse.kt`
|
||||||
|
- 노출 API: `GET /api/v2/creator-channels/{creatorId}/community`
|
||||||
|
- 현재 필드: `postId`, `creatorId`, `creatorNickname`, `creatorProfileUrl`, `createdAtUtc`, `content`, `imageUrl`, `audioUrl`, `price`, `isCommentAvailable`, `existOrdered`, `likeCount`, `commentCount`, `isPinned`
|
||||||
|
- 데이터 흐름: `CreatorChannelCommunityQueryService.getCommunityTab(...)`가 `CreatorChannelCommunityPost`를 만들고 DTO가 이를 변환한다.
|
||||||
|
|
||||||
|
### 대상 하위 모델
|
||||||
|
- `CreatorChannelCommunityPost`
|
||||||
|
- 파일: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/community/domain/CreatorChannelCommunityTab.kt`
|
||||||
|
- 채널 홈과 커뮤니티 탭이 함께 사용하는 domain post다.
|
||||||
|
|
||||||
|
- `CreatorChannelCommunityPostRecord`
|
||||||
|
- 파일: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/community/port/out/CreatorChannelCommunityQueryPort.kt`
|
||||||
|
- repository에서 조회한 게시글 row와 부가 상태를 service로 전달한다.
|
||||||
|
|
||||||
|
- `HomePopularCommunityRecommendationRecord`
|
||||||
|
- 파일: `src/main/kotlin/kr/co/vividnext/sodalive/v2/recommendation/port/out/HomeRecommendationQueryPort.kt`
|
||||||
|
- 홈 인기 커뮤니티 상세 조회 결과를 facade로 전달한다.
|
||||||
|
|
||||||
|
### 제외 대상
|
||||||
|
- `HomeFollowingNews` / `FollowingNewsResponse`
|
||||||
|
- `FollowingNewsType.COMMUNITY_POST`와 `targetId`는 있지만 게시글 카드 DTO가 아니며 `likeCount`도 제공하지 않는다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. User Stories
|
||||||
|
- 사용자는 홈 추천의 인기 커뮤니티 게시글을 볼 때 내가 이미 좋아요를 눌렀는지 바로 알고 싶다.
|
||||||
|
- 사용자는 크리에이터 채널 홈의 공지/커뮤니티 게시글 카드에서 좋아요 버튼이 현재 상태대로 표시되기를 원한다.
|
||||||
|
- 사용자는 크리에이터 채널 커뮤니티 탭 목록에서 각 게시글의 좋아요 상태가 정확하게 표시되기를 원한다.
|
||||||
|
- 앱 클라이언트는 게시글 목록 응답만으로 좋아요 버튼의 selected 상태를 렌더링하고 싶다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. Core Features
|
||||||
|
|
||||||
|
### Feature A. 홈 인기 커뮤니티 게시글 좋아요 상태
|
||||||
|
|
||||||
|
#### Requirements
|
||||||
|
- `HomePopularCommunityPostItem`에 `isLiked: Boolean`을 추가한다.
|
||||||
|
- `HomePopularCommunityRecommendationRecord`에 조회자 좋아요 여부를 전달할 수 있는 Boolean 필드를 추가한다.
|
||||||
|
- `DefaultHomeRecommendationQueryRepository.findPopularCommunityRecommendationDetails(...)`는 `memberId`가 null이면 `isLiked=false`를 반환한다.
|
||||||
|
- `memberId`가 있으면 해당 회원의 활성 좋아요가 있는 게시글만 `isLiked=true`로 반환한다.
|
||||||
|
- 홈 추천 전체 응답은 비회원 호출을 허용하므로 비회원 `isLiked=false`를 명시적으로 보장한다.
|
||||||
|
|
||||||
|
#### Edge Cases
|
||||||
|
- 비활성 좋아요는 `isLiked=false`다.
|
||||||
|
- 다른 회원이 좋아요한 게시글은 현재 조회자의 `isLiked=true`가 되면 안 된다.
|
||||||
|
- 좋아요 수가 1 이상이어도 현재 조회자가 좋아요하지 않았으면 `isLiked=false`다.
|
||||||
|
|
||||||
|
### Feature B. 크리에이터 채널 홈 게시글 좋아요 상태
|
||||||
|
|
||||||
|
#### Requirements
|
||||||
|
- 크리에이터 채널 홈의 `CreatorChannelCommunityPostResponse`에 `isLiked: Boolean`을 추가한다.
|
||||||
|
- `CreatorChannelCommunityPost` domain에 조회자 좋아요 여부를 추가한다.
|
||||||
|
- `CreatorChannelCommunityPostRecord`에 조회자 좋아요 여부를 추가한다.
|
||||||
|
- `DefaultCreatorChannelCommunityQueryRepository.findHomeCommunityPosts(...)`는 기존 id 목록 기반 부가 조회 패턴을 유지하고, 조회자 좋아요 게시글 id 목록을 bulk 조회한다.
|
||||||
|
- 공지(`notices`)와 일반 커뮤니티(`communities`) 모두 같은 `isLiked` 의미를 사용한다.
|
||||||
|
|
||||||
|
#### Edge Cases
|
||||||
|
- 조회자가 게시글 작성자여도 본인이 활성 좋아요를 누른 이력이 없으면 `isLiked=false`다.
|
||||||
|
- 유료 미구매로 이미지/오디오/본문이 마스킹되어도 좋아요 상태는 실제 조회자의 활성 좋아요 기준으로 내려준다.
|
||||||
|
|
||||||
|
### Feature C. 크리에이터 채널 커뮤니티 탭 게시글 좋아요 상태
|
||||||
|
|
||||||
|
#### Requirements
|
||||||
|
- 커뮤니티 탭의 `CreatorChannelCommunityPostResponse`에 `isLiked: Boolean`을 추가한다.
|
||||||
|
- `DefaultCreatorChannelCommunityQueryRepository.findCommunityPosts(...)`도 홈 조회와 동일한 조회자 좋아요 상태 계산을 적용한다.
|
||||||
|
- page/size와 무관하게 응답에 포함된 각 게시글의 `isLiked`가 정확해야 한다.
|
||||||
|
|
||||||
|
#### Edge Cases
|
||||||
|
- 현재 page에 포함되지 않은 게시글의 좋아요 상태를 조회하거나 응답에 포함하지 않는다.
|
||||||
|
- 좋아요가 없는 게시글은 `isLiked=false`다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. Technical Constraints
|
||||||
|
- 기존 v2 패키지 경계를 유지한다.
|
||||||
|
- 공개 API 응답 필드 추가 외에 기존 필드명과 의미를 변경하지 않는다.
|
||||||
|
- 커뮤니티 좋아요 상태는 `CreatorCommunityLike`와 `creatorCommunityLike.isActive.isTrue`를 기준으로 한다.
|
||||||
|
- 채널 홈/커뮤니티 탭은 이미 `postIds` 기반으로 `likeCount`, `commentCount`, `existOrdered`를 bulk 조회하므로 같은 패턴의 `likedCommunityPostIds(viewerId, postIds)` helper를 추가하는 방향을 우선 검토한다.
|
||||||
|
- 홈 인기 커뮤니티는 QueryDSL projection으로 record를 생성하므로 `isLiked` 계산식 또는 별도 subquery를 projection에 추가하는 방향을 우선 검토한다.
|
||||||
|
- Boolean 응답 필드는 Jackson 직렬화 이름 보존을 위해 `@JsonProperty("isLiked")`를 명시한다.
|
||||||
|
- 신규 필드 추가에 따른 테스트 fixture와 controller JSON assertion을 갱신한다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10. Success Criteria
|
||||||
|
- `GET /api/v2/home/recommendations`의 `popularCommunityPosts[*].isLiked`가 인증 회원 기준 활성 좋아요 여부를 반환한다.
|
||||||
|
- `GET /api/v2/home/recommendations`를 비회원으로 호출하면 `popularCommunityPosts[*].isLiked=false`를 반환한다.
|
||||||
|
- `GET /api/v2/creator-channels/{creatorId}/home`의 `notices[*].isLiked`, `communities[*].isLiked`가 인증 조회자 기준 활성 좋아요 여부를 반환한다.
|
||||||
|
- `GET /api/v2/creator-channels/{creatorId}/community`의 `communityPosts[*].isLiked`가 인증 조회자 기준 활성 좋아요 여부를 반환한다.
|
||||||
|
- 비활성 좋아요, 다른 회원의 좋아요, 좋아요 수와 조회자 좋아요 여부가 다른 케이스를 테스트로 구분한다.
|
||||||
|
- 기존 좋아요 수, 댓글 수, 구매 여부, 유료 콘텐츠 마스킹, 성인 콘텐츠 필터 테스트가 계속 통과한다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 11. Open Questions
|
||||||
|
- 없음. 필드명은 요청 의미와 기존 Boolean 응답 패턴을 기준으로 `isLiked`로 확정한다.
|
||||||
@@ -42,7 +42,9 @@ data class CreatorChannelCommunityPostResponse(
|
|||||||
val likeCount: Int,
|
val likeCount: Int,
|
||||||
val commentCount: Int,
|
val commentCount: Int,
|
||||||
@JsonProperty("isPinned")
|
@JsonProperty("isPinned")
|
||||||
val isPinned: Boolean
|
val isPinned: Boolean,
|
||||||
|
@JsonProperty("isLiked")
|
||||||
|
val isLiked: Boolean
|
||||||
) {
|
) {
|
||||||
companion object {
|
companion object {
|
||||||
fun from(post: CreatorChannelCommunityPost): CreatorChannelCommunityPostResponse {
|
fun from(post: CreatorChannelCommunityPost): CreatorChannelCommunityPostResponse {
|
||||||
@@ -60,7 +62,8 @@ data class CreatorChannelCommunityPostResponse(
|
|||||||
existOrdered = post.existOrdered,
|
existOrdered = post.existOrdered,
|
||||||
likeCount = post.likeCount,
|
likeCount = post.likeCount,
|
||||||
commentCount = post.commentCount,
|
commentCount = post.commentCount,
|
||||||
isPinned = post.isPinned
|
isPinned = post.isPinned,
|
||||||
|
isLiked = post.isLiked
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -182,7 +182,9 @@ data class CreatorChannelCommunityPostResponse(
|
|||||||
val dateUtc: String,
|
val dateUtc: String,
|
||||||
val existOrdered: Boolean,
|
val existOrdered: Boolean,
|
||||||
val likeCount: Int,
|
val likeCount: Int,
|
||||||
val commentCount: Int
|
val commentCount: Int,
|
||||||
|
@JsonProperty("isLiked")
|
||||||
|
val isLiked: Boolean
|
||||||
) {
|
) {
|
||||||
companion object {
|
companion object {
|
||||||
fun from(post: CreatorChannelCommunityPost): CreatorChannelCommunityPostResponse {
|
fun from(post: CreatorChannelCommunityPost): CreatorChannelCommunityPostResponse {
|
||||||
@@ -198,7 +200,8 @@ data class CreatorChannelCommunityPostResponse(
|
|||||||
dateUtc = post.createdAt.toUtcIso(),
|
dateUtc = post.createdAt.toUtcIso(),
|
||||||
existOrdered = post.existOrdered,
|
existOrdered = post.existOrdered,
|
||||||
likeCount = post.likeCount,
|
likeCount = post.likeCount,
|
||||||
commentCount = post.commentCount
|
commentCount = post.commentCount,
|
||||||
|
isLiked = post.isLiked
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -305,7 +305,8 @@ class HomeRecommendationFacade(
|
|||||||
createdAt = createdAt.toUtcIso(),
|
createdAt = createdAt.toUtcIso(),
|
||||||
likeCount = likeCount,
|
likeCount = likeCount,
|
||||||
commentCount = commentCount,
|
commentCount = commentCount,
|
||||||
existOrdered = existOrdered
|
existOrdered = existOrdered,
|
||||||
|
isLiked = isLiked
|
||||||
)
|
)
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
|
|||||||
@@ -88,5 +88,7 @@ data class HomePopularCommunityPostItem(
|
|||||||
val createdAt: String,
|
val createdAt: String,
|
||||||
val likeCount: Long,
|
val likeCount: Long,
|
||||||
val commentCount: Long,
|
val commentCount: Long,
|
||||||
val existOrdered: Boolean
|
val existOrdered: Boolean,
|
||||||
|
@JsonProperty("isLiked")
|
||||||
|
val isLiked: Boolean
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -186,6 +186,7 @@ class DefaultCreatorChannelCommunityQueryRepository(
|
|||||||
val postIds = map { it.postId }
|
val postIds = map { it.postId }
|
||||||
val orderedPostIds = orderedCommunityPostIds(creatorId, viewerId, postIds)
|
val orderedPostIds = orderedCommunityPostIds(creatorId, viewerId, postIds)
|
||||||
val likeCounts = communityLikeCounts(postIds)
|
val likeCounts = communityLikeCounts(postIds)
|
||||||
|
val likedPostIds = likedCommunityPostIds(viewerId, postIds)
|
||||||
val commentAvailablePostIds = filter { it.isCommentAvailable }.map { it.postId }
|
val commentAvailablePostIds = filter { it.isCommentAvailable }.map { it.postId }
|
||||||
val commentCounts = communityCommentCounts(commentAvailablePostIds, creatorId, viewerId)
|
val commentCounts = communityCommentCounts(commentAvailablePostIds, creatorId, viewerId)
|
||||||
|
|
||||||
@@ -206,7 +207,8 @@ class DefaultCreatorChannelCommunityQueryRepository(
|
|||||||
isCommentAvailable = row.isCommentAvailable,
|
isCommentAvailable = row.isCommentAvailable,
|
||||||
likeCount = likeCounts[postId] ?: 0,
|
likeCount = likeCounts[postId] ?: 0,
|
||||||
commentCount = commentCounts[postId] ?: 0,
|
commentCount = commentCounts[postId] ?: 0,
|
||||||
isPinned = isPinned
|
isPinned = isPinned,
|
||||||
|
isLiked = postId in likedPostIds
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -233,6 +235,22 @@ class DefaultCreatorChannelCommunityQueryRepository(
|
|||||||
.toSet()
|
.toSet()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun likedCommunityPostIds(viewerId: Long, postIds: List<Long>): Set<Long> {
|
||||||
|
if (postIds.isEmpty()) return emptySet()
|
||||||
|
|
||||||
|
return queryFactory
|
||||||
|
.select(creatorCommunityLike.creatorCommunity.id)
|
||||||
|
.distinct()
|
||||||
|
.from(creatorCommunityLike)
|
||||||
|
.where(
|
||||||
|
creatorCommunityLike.member.id.eq(viewerId),
|
||||||
|
creatorCommunityLike.creatorCommunity.id.`in`(postIds),
|
||||||
|
creatorCommunityLike.isActive.isTrue
|
||||||
|
)
|
||||||
|
.fetch()
|
||||||
|
.toSet()
|
||||||
|
}
|
||||||
|
|
||||||
private fun communityLikeCounts(postIds: List<Long>): Map<Long, Int> {
|
private fun communityLikeCounts(postIds: List<Long>): Map<Long, Int> {
|
||||||
if (postIds.isEmpty()) return emptyMap()
|
if (postIds.isEmpty()) return emptyMap()
|
||||||
|
|
||||||
|
|||||||
@@ -121,7 +121,8 @@ class CreatorChannelCommunityQueryService(
|
|||||||
isCommentAvailable = isCommentAvailable,
|
isCommentAvailable = isCommentAvailable,
|
||||||
likeCount = likeCount,
|
likeCount = likeCount,
|
||||||
commentCount = commentCount,
|
commentCount = commentCount,
|
||||||
isPinned = isPinned
|
isPinned = isPinned,
|
||||||
|
isLiked = isLiked
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -24,5 +24,6 @@ data class CreatorChannelCommunityPost(
|
|||||||
val isCommentAvailable: Boolean,
|
val isCommentAvailable: Boolean,
|
||||||
val likeCount: Int,
|
val likeCount: Int,
|
||||||
val commentCount: Int,
|
val commentCount: Int,
|
||||||
val isPinned: Boolean
|
val isPinned: Boolean,
|
||||||
|
val isLiked: Boolean
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -51,5 +51,6 @@ data class CreatorChannelCommunityPostRecord(
|
|||||||
val isCommentAvailable: Boolean,
|
val isCommentAvailable: Boolean,
|
||||||
val likeCount: Int,
|
val likeCount: Int,
|
||||||
val commentCount: Int,
|
val commentCount: Int,
|
||||||
val isPinned: Boolean
|
val isPinned: Boolean,
|
||||||
|
val isLiked: Boolean
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -792,7 +792,8 @@ class DefaultHomeRecommendationQueryRepository(
|
|||||||
creatorCommunity.createdAt,
|
creatorCommunity.createdAt,
|
||||||
creatorCommunityLike.id.countDistinct(),
|
creatorCommunityLike.id.countDistinct(),
|
||||||
creatorCommunityComment.id.countDistinct(),
|
creatorCommunityComment.id.countDistinct(),
|
||||||
orderedCommunityPostCondition(memberId)
|
orderedCommunityPostCondition(memberId),
|
||||||
|
likedCommunityPostCondition(memberId)
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
.from(creatorCommunity)
|
.from(creatorCommunity)
|
||||||
@@ -1229,6 +1230,19 @@ class DefaultHomeRecommendationQueryRepository(
|
|||||||
.exists()
|
.exists()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun likedCommunityPostCondition(memberId: Long?): BooleanExpression {
|
||||||
|
if (memberId == null) return Expressions.FALSE
|
||||||
|
return JPAExpressions
|
||||||
|
.selectOne()
|
||||||
|
.from(creatorCommunityLike)
|
||||||
|
.where(
|
||||||
|
creatorCommunityLike.creatorCommunity.id.eq(creatorCommunity.id),
|
||||||
|
creatorCommunityLike.member.id.eq(memberId),
|
||||||
|
creatorCommunityLike.isActive.isTrue
|
||||||
|
)
|
||||||
|
.exists()
|
||||||
|
}
|
||||||
|
|
||||||
private fun notBlockedCreatorSql(creatorIdExpression: String): String {
|
private fun notBlockedCreatorSql(creatorIdExpression: String): String {
|
||||||
return """
|
return """
|
||||||
not exists (
|
not exists (
|
||||||
|
|||||||
@@ -152,7 +152,8 @@ data class HomePopularCommunityRecommendationRecord(
|
|||||||
val createdAt: LocalDateTime,
|
val createdAt: LocalDateTime,
|
||||||
val likeCount: Long,
|
val likeCount: Long,
|
||||||
val commentCount: Long,
|
val commentCount: Long,
|
||||||
val existOrdered: Boolean
|
val existOrdered: Boolean,
|
||||||
|
val isLiked: Boolean
|
||||||
)
|
)
|
||||||
|
|
||||||
data class HomeGenreCreatorRecommendationGroup(
|
data class HomeGenreCreatorRecommendationGroup(
|
||||||
|
|||||||
@@ -102,6 +102,7 @@ class CreatorChannelCommunityControllerTest @Autowired constructor(
|
|||||||
.andExpect(jsonPath("$.data.communityPosts[0].existOrdered").value(true))
|
.andExpect(jsonPath("$.data.communityPosts[0].existOrdered").value(true))
|
||||||
.andExpect(jsonPath("$.data.communityPosts[0].isCommentAvailable").value(true))
|
.andExpect(jsonPath("$.data.communityPosts[0].isCommentAvailable").value(true))
|
||||||
.andExpect(jsonPath("$.data.communityPosts[0].isPinned").value(true))
|
.andExpect(jsonPath("$.data.communityPosts[0].isPinned").value(true))
|
||||||
|
.andExpect(jsonPath("$.data.communityPosts[0].isLiked").value(true))
|
||||||
.andExpect(jsonPath("$.data.page").value(1))
|
.andExpect(jsonPath("$.data.page").value(1))
|
||||||
.andExpect(jsonPath("$.data.size").value(20))
|
.andExpect(jsonPath("$.data.size").value(20))
|
||||||
.andExpect(jsonPath("$.data.hasNext").value(false))
|
.andExpect(jsonPath("$.data.hasNext").value(false))
|
||||||
@@ -184,7 +185,8 @@ class CreatorChannelCommunityControllerTest @Autowired constructor(
|
|||||||
existOrdered = true,
|
existOrdered = true,
|
||||||
likeCount = 7,
|
likeCount = 7,
|
||||||
commentCount = 3,
|
commentCount = 3,
|
||||||
isPinned = true
|
isPinned = true,
|
||||||
|
isLiked = true
|
||||||
)
|
)
|
||||||
),
|
),
|
||||||
page = page,
|
page = page,
|
||||||
|
|||||||
@@ -39,6 +39,7 @@ class CreatorChannelCommunityFacadeTest {
|
|||||||
assertEquals(7, response.communityPosts.first().likeCount)
|
assertEquals(7, response.communityPosts.first().likeCount)
|
||||||
assertEquals(3, response.communityPosts.first().commentCount)
|
assertEquals(3, response.communityPosts.first().commentCount)
|
||||||
assertTrue(response.communityPosts.first().isPinned)
|
assertTrue(response.communityPosts.first().isPinned)
|
||||||
|
assertTrue(response.communityPosts.first().isLiked)
|
||||||
assertNull(response.communityPosts.last().imageUrl)
|
assertNull(response.communityPosts.last().imageUrl)
|
||||||
assertNull(response.communityPosts.last().audioUrl)
|
assertNull(response.communityPosts.last().audioUrl)
|
||||||
assertEquals(1, response.page)
|
assertEquals(1, response.page)
|
||||||
@@ -50,6 +51,7 @@ class CreatorChannelCommunityFacadeTest {
|
|||||||
assertTrue(json["hasNext"].asBoolean())
|
assertTrue(json["hasNext"].asBoolean())
|
||||||
assertTrue(json["communityPosts"][0]["isCommentAvailable"].asBoolean())
|
assertTrue(json["communityPosts"][0]["isCommentAvailable"].asBoolean())
|
||||||
assertTrue(json["communityPosts"][0]["isPinned"].asBoolean())
|
assertTrue(json["communityPosts"][0]["isPinned"].asBoolean())
|
||||||
|
assertTrue(json["communityPosts"][0]["isLiked"].asBoolean())
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@@ -112,7 +114,8 @@ class CreatorChannelCommunityFacadeTest {
|
|||||||
isCommentAvailable = true,
|
isCommentAvailable = true,
|
||||||
likeCount = 7,
|
likeCount = 7,
|
||||||
commentCount = 3,
|
commentCount = 3,
|
||||||
isPinned = true
|
isPinned = true,
|
||||||
|
isLiked = true
|
||||||
),
|
),
|
||||||
CreatorChannelCommunityPost(
|
CreatorChannelCommunityPost(
|
||||||
postId = 102L,
|
postId = 102L,
|
||||||
@@ -128,7 +131,8 @@ class CreatorChannelCommunityFacadeTest {
|
|||||||
isCommentAvailable = false,
|
isCommentAvailable = false,
|
||||||
likeCount = 1,
|
likeCount = 1,
|
||||||
commentCount = 0,
|
commentCount = 0,
|
||||||
isPinned = false
|
isPinned = false,
|
||||||
|
isLiked = false
|
||||||
)
|
)
|
||||||
),
|
),
|
||||||
page = CreatorChannelPage(page = 1, size = 20),
|
page = CreatorChannelPage(page = 1, size = 20),
|
||||||
|
|||||||
@@ -135,6 +135,8 @@ class CreatorChannelHomeControllerTest @Autowired constructor(
|
|||||||
.andExpect(jsonPath("$.data.notices[0].dateUtc").value("2026-06-12T04:00:00Z"))
|
.andExpect(jsonPath("$.data.notices[0].dateUtc").value("2026-06-12T04:00:00Z"))
|
||||||
.andExpect(jsonPath("$.data.notices[0].imageUrl").doesNotExist())
|
.andExpect(jsonPath("$.data.notices[0].imageUrl").doesNotExist())
|
||||||
.andExpect(jsonPath("$.data.notices[0].audioUrl").doesNotExist())
|
.andExpect(jsonPath("$.data.notices[0].audioUrl").doesNotExist())
|
||||||
|
.andExpect(jsonPath("$.data.notices[0].isLiked").value(true))
|
||||||
|
.andExpect(jsonPath("$.data.communities[0].isLiked").value(true))
|
||||||
.andExpect(jsonPath("$.data.series[0].isNew").value(true))
|
.andExpect(jsonPath("$.data.series[0].isNew").value(true))
|
||||||
.andExpect(jsonPath("$.data.series[0].isOriginal").value(true))
|
.andExpect(jsonPath("$.data.series[0].isOriginal").value(true))
|
||||||
|
|
||||||
@@ -171,7 +173,8 @@ class CreatorChannelHomeControllerTest @Autowired constructor(
|
|||||||
isCommentAvailable = true,
|
isCommentAvailable = true,
|
||||||
likeCount = 2,
|
likeCount = 2,
|
||||||
commentCount = 3,
|
commentCount = 3,
|
||||||
isPinned = true
|
isPinned = true,
|
||||||
|
isLiked = true
|
||||||
)
|
)
|
||||||
|
|
||||||
return CreatorChannelHome(
|
return CreatorChannelHome(
|
||||||
|
|||||||
@@ -61,6 +61,7 @@ class CreatorChannelHomeFacadeTest {
|
|||||||
assertEquals(301L, response.notices.first().postId)
|
assertEquals(301L, response.notices.first().postId)
|
||||||
assertEquals("2026-06-12T04:00:00Z", response.notices.first().dateUtc)
|
assertEquals("2026-06-12T04:00:00Z", response.notices.first().dateUtc)
|
||||||
assertNull(response.notices.first().imageUrl)
|
assertNull(response.notices.first().imageUrl)
|
||||||
|
assertTrue(response.notices.first().isLiked)
|
||||||
assertEquals(501L, response.schedules.first().targetId)
|
assertEquals(501L, response.schedules.first().targetId)
|
||||||
assertEquals(202L, response.audioContents.first().audioContentId)
|
assertEquals(202L, response.audioContents.first().audioContentId)
|
||||||
assertFalse(response.audioContents.first().isOwned)
|
assertFalse(response.audioContents.first().isOwned)
|
||||||
@@ -69,6 +70,7 @@ class CreatorChannelHomeFacadeTest {
|
|||||||
assertTrue(response.series.first().isNew)
|
assertTrue(response.series.first().isNew)
|
||||||
assertTrue(response.series.first().isOriginal)
|
assertTrue(response.series.first().isOriginal)
|
||||||
assertEquals(302L, response.communities.first().postId)
|
assertEquals(302L, response.communities.first().postId)
|
||||||
|
assertTrue(response.communities.first().isLiked)
|
||||||
assertEquals(701L, response.fanTalk.latestFanTalk?.fanTalkId)
|
assertEquals(701L, response.fanTalk.latestFanTalk?.fanTalkId)
|
||||||
assertEquals("introduce", response.introduce)
|
assertEquals("introduce", response.introduce)
|
||||||
assertEquals(10, response.activity.liveCount)
|
assertEquals(10, response.activity.liveCount)
|
||||||
@@ -101,7 +103,8 @@ class CreatorChannelHomeFacadeTest {
|
|||||||
isCommentAvailable = true,
|
isCommentAvailable = true,
|
||||||
likeCount = 2,
|
likeCount = 2,
|
||||||
commentCount = 3,
|
commentCount = 3,
|
||||||
isPinned = true
|
isPinned = true,
|
||||||
|
isLiked = true
|
||||||
)
|
)
|
||||||
|
|
||||||
return CreatorChannelHome(
|
return CreatorChannelHome(
|
||||||
|
|||||||
@@ -72,7 +72,8 @@ class HomeRecommendationResponseTest {
|
|||||||
createdAt = "2026-06-01T00:00:00Z",
|
createdAt = "2026-06-01T00:00:00Z",
|
||||||
likeCount = 7L,
|
likeCount = 7L,
|
||||||
commentCount = 8L,
|
commentCount = 8L,
|
||||||
existOrdered = true
|
existOrdered = true,
|
||||||
|
isLiked = true
|
||||||
),
|
),
|
||||||
HomePopularCommunityPostItem(
|
HomePopularCommunityPostItem(
|
||||||
postId = 9L,
|
postId = 9L,
|
||||||
@@ -86,7 +87,8 @@ class HomeRecommendationResponseTest {
|
|||||||
createdAt = "2026-06-01T00:00:00Z",
|
createdAt = "2026-06-01T00:00:00Z",
|
||||||
likeCount = 0L,
|
likeCount = 0L,
|
||||||
commentCount = 0L,
|
commentCount = 0L,
|
||||||
existOrdered = false
|
existOrdered = false,
|
||||||
|
isLiked = false
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
@@ -111,6 +113,7 @@ class HomeRecommendationResponseTest {
|
|||||||
assertEquals("https://cdn.test/community/audio.mp3", json["popularCommunityPosts"][0]["audioUrl"].asText())
|
assertEquals("https://cdn.test/community/audio.mp3", json["popularCommunityPosts"][0]["audioUrl"].asText())
|
||||||
assertEquals(9, json["popularCommunityPosts"][0]["price"].asInt())
|
assertEquals(9, json["popularCommunityPosts"][0]["price"].asInt())
|
||||||
assertEquals(true, json["popularCommunityPosts"][0]["existOrdered"].asBoolean())
|
assertEquals(true, json["popularCommunityPosts"][0]["existOrdered"].asBoolean())
|
||||||
|
assertEquals(true, json["popularCommunityPosts"][0]["isLiked"].asBoolean())
|
||||||
assertEquals(true, json["popularCommunityPosts"][1]["creatorProfileImage"].isNull)
|
assertEquals(true, json["popularCommunityPosts"][1]["creatorProfileImage"].isNull)
|
||||||
assertEquals(true, json["popularCommunityPosts"][1]["imageUrl"].isNull)
|
assertEquals(true, json["popularCommunityPosts"][1]["imageUrl"].isNull)
|
||||||
assertEquals(true, json["popularCommunityPosts"][1]["audioUrl"].isNull)
|
assertEquals(true, json["popularCommunityPosts"][1]["audioUrl"].isNull)
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ import org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPat
|
|||||||
import org.springframework.test.web.servlet.result.MockMvcResultMatchers.status
|
import org.springframework.test.web.servlet.result.MockMvcResultMatchers.status
|
||||||
import org.springframework.transaction.support.TransactionTemplate
|
import org.springframework.transaction.support.TransactionTemplate
|
||||||
import java.time.LocalDateTime
|
import java.time.LocalDateTime
|
||||||
|
import java.time.ZoneId
|
||||||
import java.time.ZoneOffset
|
import java.time.ZoneOffset
|
||||||
import javax.persistence.EntityManager
|
import javax.persistence.EntityManager
|
||||||
|
|
||||||
@@ -95,12 +96,20 @@ class HomeFollowingEndToEndTest @Autowired constructor(
|
|||||||
private fun createFixture(): Fixture {
|
private fun createFixture(): Fixture {
|
||||||
return transactionTemplate.execute {
|
return transactionTemplate.execute {
|
||||||
val now = LocalDateTime.now(ZoneOffset.UTC)
|
val now = LocalDateTime.now(ZoneOffset.UTC)
|
||||||
|
val scheduleBaseUtc = now
|
||||||
|
.atOffset(ZoneOffset.UTC)
|
||||||
|
.atZoneSameInstant(ZoneId.of("Asia/Seoul"))
|
||||||
|
.toLocalDate()
|
||||||
|
.atTime(1, 0)
|
||||||
|
.atZone(ZoneId.of("Asia/Seoul"))
|
||||||
|
.withZoneSameInstant(ZoneOffset.UTC)
|
||||||
|
.toLocalDateTime()
|
||||||
val viewer = saveMember("home-following-viewer", MemberRole.USER)
|
val viewer = saveMember("home-following-viewer", MemberRole.USER)
|
||||||
val creator = saveMember("home-following-creator", MemberRole.CREATOR, profileImage = "creator.png")
|
val creator = saveMember("home-following-creator", MemberRole.CREATOR, profileImage = "creator.png")
|
||||||
saveFollowing(viewer, creator)
|
saveFollowing(viewer, creator)
|
||||||
val live = saveLiveRoom(creator, now.plusHours(1), channelName = "on-air")
|
val live = saveLiveRoom(creator, scheduleBaseUtc.plusHours(1), channelName = "on-air")
|
||||||
val theme = saveTheme()
|
val theme = saveTheme()
|
||||||
val audio = saveAudioContent(creator, theme, now.plusDays(1))
|
val audio = saveAudioContent(creator, theme, scheduleBaseUtc.plusHours(2))
|
||||||
val oldNews = saveNews(viewer.id!!, creator.id!!, "old-news", now.minusHours(2), rank = null)
|
val oldNews = saveNews(viewer.id!!, creator.id!!, "old-news", now.minusHours(2), rank = null)
|
||||||
val rankedNews = saveNews(viewer.id!!, creator.id!!, "ranked-news", now.minusHours(1), rank = 7)
|
val rankedNews = saveNews(viewer.id!!, creator.id!!, "ranked-news", now.minusHours(1), rank = 7)
|
||||||
val chatRoom = saveDmChatRoom(viewer, creator, now.minusMinutes(10))
|
val chatRoom = saveDmChatRoom(viewer, creator, now.minusMinutes(10))
|
||||||
|
|||||||
@@ -153,22 +153,33 @@ class DefaultCreatorChannelCommunityQueryRepositoryTest @Autowired constructor(
|
|||||||
val inactiveLiker = saveMember("inactive-liker", MemberRole.USER)
|
val inactiveLiker = saveMember("inactive-liker", MemberRole.USER)
|
||||||
val commenter = saveMember("unavailable-commenter", MemberRole.USER)
|
val commenter = saveMember("unavailable-commenter", MemberRole.USER)
|
||||||
val post = saveCommunity(creator, isFixed = false, price = 0, isCommentAvailable = false)
|
val post = saveCommunity(creator, isFixed = false, price = 0, isCommentAvailable = false)
|
||||||
|
val otherMemberOnlyLikedPost = saveCommunity(creator, isFixed = false, price = 0, isCommentAvailable = false)
|
||||||
|
val inactiveViewerLikedPost = saveCommunity(creator, isFixed = false, price = 0, isCommentAvailable = false)
|
||||||
saveCommunityLike(activeLiker, post, isActive = true)
|
saveCommunityLike(activeLiker, post, isActive = true)
|
||||||
|
saveCommunityLike(viewer, post, isActive = true)
|
||||||
|
saveCommunityLike(activeLiker, otherMemberOnlyLikedPost, isActive = true)
|
||||||
saveCommunityLike(inactiveLiker, post, isActive = false)
|
saveCommunityLike(inactiveLiker, post, isActive = false)
|
||||||
|
saveCommunityLike(viewer, inactiveViewerLikedPost, isActive = false)
|
||||||
saveCommunityComment(commenter, post, isActive = true)
|
saveCommunityComment(commenter, post, isActive = true)
|
||||||
flushAndClear()
|
flushAndClear()
|
||||||
|
|
||||||
val record = repository.findCommunityPosts(
|
val records = repository.findCommunityPosts(
|
||||||
creatorId = creator.id!!,
|
creatorId = creator.id!!,
|
||||||
viewerId = viewer.id!!,
|
viewerId = viewer.id!!,
|
||||||
canViewAdultContent = true,
|
canViewAdultContent = true,
|
||||||
offset = 0,
|
offset = 0,
|
||||||
limit = 10
|
limit = 10
|
||||||
).single()
|
)
|
||||||
|
val record = records.single { it.postId == post.id }
|
||||||
|
val otherMemberOnlyLikedRecord = records.single { it.postId == otherMemberOnlyLikedPost.id }
|
||||||
|
val inactiveViewerLikedRecord = records.single { it.postId == inactiveViewerLikedPost.id }
|
||||||
|
|
||||||
assertEquals(1, record.likeCount)
|
assertEquals(2, record.likeCount)
|
||||||
assertEquals(0, record.commentCount)
|
assertEquals(0, record.commentCount)
|
||||||
assertFalse(record.isCommentAvailable)
|
assertFalse(record.isCommentAvailable)
|
||||||
|
assertTrue(record.isLiked)
|
||||||
|
assertFalse(otherMemberOnlyLikedRecord.isLiked)
|
||||||
|
assertFalse(inactiveViewerLikedRecord.isLiked)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@@ -276,6 +287,7 @@ class DefaultCreatorChannelCommunityQueryRepositoryTest @Autowired constructor(
|
|||||||
val pinned = saveCommunity(creator, isFixed = true, fixedAt = now.minusHours(1), price = 0)
|
val pinned = saveCommunity(creator, isFixed = true, fixedAt = now.minusHours(1), price = 0)
|
||||||
val normal = saveCommunity(creator, isFixed = false, price = 0)
|
val normal = saveCommunity(creator, isFixed = false, price = 0)
|
||||||
val adultPinned = saveCommunity(creator, isFixed = true, fixedAt = now, price = 0, isAdult = true)
|
val adultPinned = saveCommunity(creator, isFixed = true, fixedAt = now, price = 0, isAdult = true)
|
||||||
|
saveCommunityLike(viewer, normal, isActive = true)
|
||||||
flushAndClear()
|
flushAndClear()
|
||||||
updateCreatedAt("CreatorCommunity", normal.id!!, now.minusDays(1))
|
updateCreatedAt("CreatorCommunity", normal.id!!, now.minusDays(1))
|
||||||
flushAndClear()
|
flushAndClear()
|
||||||
@@ -300,6 +312,7 @@ class DefaultCreatorChannelCommunityQueryRepositoryTest @Autowired constructor(
|
|||||||
assertFalse(adultPinned.id in pinnedPosts.map { it.postId })
|
assertFalse(adultPinned.id in pinnedPosts.map { it.postId })
|
||||||
assertTrue(pinnedPosts.single().isPinned)
|
assertTrue(pinnedPosts.single().isPinned)
|
||||||
assertFalse(normalPosts.single().isPinned)
|
assertFalse(normalPosts.single().isPinned)
|
||||||
|
assertTrue(normalPosts.single().isLiked)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
|
|||||||
@@ -108,7 +108,7 @@ class CreatorChannelCommunityQueryServiceTest {
|
|||||||
fun shouldAssembleCommunityPostAssetsByAccessPolicy() {
|
fun shouldAssembleCommunityPostAssetsByAccessPolicy() {
|
||||||
val port = FakeCreatorChannelCommunityQueryPort().apply {
|
val port = FakeCreatorChannelCommunityQueryPort().apply {
|
||||||
communityPosts = listOf(
|
communityPosts = listOf(
|
||||||
communityPostRecord(1L, price = 0, existOrdered = false),
|
communityPostRecord(1L, price = 0, existOrdered = false, isLiked = true),
|
||||||
communityPostRecord(2L, price = 100, existOrdered = true),
|
communityPostRecord(2L, price = 100, existOrdered = true),
|
||||||
communityPostRecord(3L, price = 100, existOrdered = false),
|
communityPostRecord(3L, price = 100, existOrdered = false),
|
||||||
communityPostRecord(4L, creatorId = 10L, price = 100, existOrdered = false, creatorProfilePath = null),
|
communityPostRecord(4L, creatorId = 10L, price = 100, existOrdered = false, creatorProfilePath = null),
|
||||||
@@ -131,6 +131,7 @@ class CreatorChannelCommunityQueryServiceTest {
|
|||||||
assertEquals("https://cdn.test/image/1.png", posts[0].imageUrl)
|
assertEquals("https://cdn.test/image/1.png", posts[0].imageUrl)
|
||||||
assertEquals("https://signed.test/audio/1.mp3", posts[0].audioUrl)
|
assertEquals("https://signed.test/audio/1.mp3", posts[0].audioUrl)
|
||||||
assertEquals("content-1", posts[0].content)
|
assertEquals("content-1", posts[0].content)
|
||||||
|
assertEquals(true, posts[0].isLiked)
|
||||||
assertEquals("https://cdn.test/image/2.png", posts[1].imageUrl)
|
assertEquals("https://cdn.test/image/2.png", posts[1].imageUrl)
|
||||||
assertEquals("https://signed.test/audio/2.mp3", posts[1].audioUrl)
|
assertEquals("https://signed.test/audio/2.mp3", posts[1].audioUrl)
|
||||||
assertNull(posts[2].imageUrl)
|
assertNull(posts[2].imageUrl)
|
||||||
@@ -151,7 +152,7 @@ class CreatorChannelCommunityQueryServiceTest {
|
|||||||
val port = FakeCreatorChannelCommunityQueryPort().apply {
|
val port = FakeCreatorChannelCommunityQueryPort().apply {
|
||||||
creator = null
|
creator = null
|
||||||
blocked = true
|
blocked = true
|
||||||
homeCommunityPosts = listOf(communityPostRecord(1L, price = 0))
|
homeCommunityPosts = listOf(communityPostRecord(1L, price = 0, isLiked = true))
|
||||||
}
|
}
|
||||||
val service = createService(port)
|
val service = createService(port)
|
||||||
|
|
||||||
@@ -169,6 +170,7 @@ class CreatorChannelCommunityQueryServiceTest {
|
|||||||
assertEquals(true, port.homeIsPinned)
|
assertEquals(true, port.homeIsPinned)
|
||||||
assertEquals(false, port.homeCanViewAdultContent)
|
assertEquals(false, port.homeCanViewAdultContent)
|
||||||
assertEquals(3, port.homeLimit)
|
assertEquals(3, port.homeLimit)
|
||||||
|
assertEquals(true, posts.single().isLiked)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun createService(
|
private fun createService(
|
||||||
@@ -284,7 +286,8 @@ private fun communityPostRecord(
|
|||||||
existOrdered: Boolean = false,
|
existOrdered: Boolean = false,
|
||||||
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
|
||||||
): CreatorChannelCommunityPostRecord {
|
): CreatorChannelCommunityPostRecord {
|
||||||
return CreatorChannelCommunityPostRecord(
|
return CreatorChannelCommunityPostRecord(
|
||||||
postId = postId,
|
postId = postId,
|
||||||
@@ -300,6 +303,7 @@ private fun communityPostRecord(
|
|||||||
isCommentAvailable = true,
|
isCommentAvailable = true,
|
||||||
likeCount = postId.toInt(),
|
likeCount = postId.toInt(),
|
||||||
commentCount = postId.toInt() + 1,
|
commentCount = postId.toInt() + 1,
|
||||||
isPinned = postId == 1L
|
isPinned = postId == 1L,
|
||||||
|
isLiked = isLiked
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -125,7 +125,8 @@ class CreatorChannelCommunityQueryPolicyTest {
|
|||||||
isCommentAvailable = true,
|
isCommentAvailable = true,
|
||||||
likeCount = 2,
|
likeCount = 2,
|
||||||
commentCount = 3,
|
commentCount = 3,
|
||||||
isPinned = true
|
isPinned = true,
|
||||||
|
isLiked = true
|
||||||
)
|
)
|
||||||
),
|
),
|
||||||
page = page,
|
page = page,
|
||||||
@@ -150,7 +151,8 @@ class CreatorChannelCommunityQueryPolicyTest {
|
|||||||
isCommentAvailable = true,
|
isCommentAvailable = true,
|
||||||
likeCount = 2,
|
likeCount = 2,
|
||||||
commentCount = 3,
|
commentCount = 3,
|
||||||
isPinned = true
|
isPinned = true,
|
||||||
|
isLiked = true
|
||||||
)
|
)
|
||||||
|
|
||||||
assertEquals(1, tab.communityPostCount)
|
assertEquals(1, tab.communityPostCount)
|
||||||
@@ -160,5 +162,6 @@ class CreatorChannelCommunityQueryPolicyTest {
|
|||||||
assertEquals(MemberRole.CREATOR, creatorRecord.role)
|
assertEquals(MemberRole.CREATOR, creatorRecord.role)
|
||||||
assertNull(postRecord.imagePath)
|
assertNull(postRecord.imagePath)
|
||||||
assertTrue(postRecord.isPinned)
|
assertTrue(postRecord.isPinned)
|
||||||
|
assertTrue(postRecord.isLiked)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -290,7 +290,8 @@ class CreatorChannelHomeQueryServiceTest {
|
|||||||
isCommentAvailable = true,
|
isCommentAvailable = true,
|
||||||
likeCount = 2,
|
likeCount = 2,
|
||||||
commentCount = 3,
|
commentCount = 3,
|
||||||
isPinned = true
|
isPinned = true,
|
||||||
|
isLiked = true
|
||||||
)
|
)
|
||||||
|
|
||||||
return CreatorChannelHome(
|
return CreatorChannelHome(
|
||||||
@@ -736,7 +737,8 @@ private class FakeCreatorChannelCommunityQueryPort : CreatorChannelCommunityQuer
|
|||||||
isCommentAvailable = true,
|
isCommentAvailable = true,
|
||||||
likeCount = 3,
|
likeCount = 3,
|
||||||
commentCount = 4,
|
commentCount = 4,
|
||||||
isPinned = isPinned
|
isPinned = isPinned,
|
||||||
|
isLiked = true
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1389,6 +1389,7 @@ class DefaultHomeRecommendationQueryRepositoryTest @Autowired constructor(
|
|||||||
val creator = saveMember("community-detail-creator", MemberRole.CREATOR)
|
val creator = saveMember("community-detail-creator", MemberRole.CREATOR)
|
||||||
val inactiveCreator = saveMember("community-detail-inactive-creator", MemberRole.CREATOR, isActive = false)
|
val inactiveCreator = saveMember("community-detail-inactive-creator", MemberRole.CREATOR, isActive = false)
|
||||||
val member = saveMember("community-detail-member", MemberRole.USER)
|
val member = saveMember("community-detail-member", MemberRole.USER)
|
||||||
|
val otherMember = saveMember("community-detail-other-member", MemberRole.USER)
|
||||||
val eligible = saveCommunity(
|
val eligible = saveCommunity(
|
||||||
creator,
|
creator,
|
||||||
isCommentAvailable = true,
|
isCommentAvailable = true,
|
||||||
@@ -1403,6 +1404,7 @@ class DefaultHomeRecommendationQueryRepositoryTest @Autowired constructor(
|
|||||||
val like1 = saveCommunityLike(member, eligible, isActive = true)
|
val like1 = saveCommunityLike(member, eligible, isActive = true)
|
||||||
val like2 = saveCommunityLike(member, eligible, isActive = true)
|
val like2 = saveCommunityLike(member, eligible, isActive = true)
|
||||||
saveCommunityLike(member, eligible, isActive = false)
|
saveCommunityLike(member, eligible, isActive = false)
|
||||||
|
saveCommunityLike(otherMember, paid, isActive = true)
|
||||||
val comment1 = saveCommunityComment(member, eligible, isActive = true)
|
val comment1 = saveCommunityComment(member, eligible, isActive = true)
|
||||||
saveCommunityComment(member, eligible, isActive = false)
|
saveCommunityComment(member, eligible, isActive = false)
|
||||||
updateCreatedAt("CreatorCommunity", eligible.id!!, LocalDateTime.of(2026, 5, 29, 1, 0))
|
updateCreatedAt("CreatorCommunity", eligible.id!!, LocalDateTime.of(2026, 5, 29, 1, 0))
|
||||||
@@ -1430,6 +1432,8 @@ class DefaultHomeRecommendationQueryRepositoryTest @Autowired constructor(
|
|||||||
assertEquals(1L, detailById[eligible.id]!!.commentCount)
|
assertEquals(1L, detailById[eligible.id]!!.commentCount)
|
||||||
assertEquals(false, detailById[eligible.id]!!.existOrdered)
|
assertEquals(false, detailById[eligible.id]!!.existOrdered)
|
||||||
assertEquals(true, detailById[paid.id]!!.existOrdered)
|
assertEquals(true, detailById[paid.id]!!.existOrdered)
|
||||||
|
assertEquals(true, detailById[eligible.id]!!.isLiked)
|
||||||
|
assertEquals(false, detailById[paid.id]!!.isLiked)
|
||||||
assertEquals(creator.id, detailById[eligible.id]!!.creatorId)
|
assertEquals(creator.id, detailById[eligible.id]!!.creatorId)
|
||||||
assertEquals("community-detail-creator", detailById[eligible.id]!!.creatorNickname)
|
assertEquals("community-detail-creator", detailById[eligible.id]!!.creatorNickname)
|
||||||
}
|
}
|
||||||
@@ -1449,6 +1453,7 @@ class DefaultHomeRecommendationQueryRepositoryTest @Autowired constructor(
|
|||||||
|
|
||||||
assertEquals(listOf(paid.id), details.map { it.communityId })
|
assertEquals(listOf(paid.id), details.map { it.communityId })
|
||||||
assertEquals(listOf(false), details.map { it.existOrdered })
|
assertEquals(listOf(false), details.map { it.existOrdered })
|
||||||
|
assertEquals(listOf(false), details.map { it.isLiked })
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
|
|||||||
@@ -245,7 +245,8 @@ class HomeRecommendationQueryServiceTest {
|
|||||||
createdAt = LocalDateTime.of(2026, 5, 29, 1, 0),
|
createdAt = LocalDateTime.of(2026, 5, 29, 1, 0),
|
||||||
likeCount = 3L,
|
likeCount = 3L,
|
||||||
commentCount = 2L,
|
commentCount = 2L,
|
||||||
existOrdered = true
|
existOrdered = true,
|
||||||
|
isLiked = true
|
||||||
),
|
),
|
||||||
HomePopularCommunityRecommendationRecord(
|
HomePopularCommunityRecommendationRecord(
|
||||||
communityId = 2L,
|
communityId = 2L,
|
||||||
@@ -259,7 +260,8 @@ class HomeRecommendationQueryServiceTest {
|
|||||||
createdAt = LocalDateTime.of(2026, 5, 29, 2, 0),
|
createdAt = LocalDateTime.of(2026, 5, 29, 2, 0),
|
||||||
likeCount = 1L,
|
likeCount = 1L,
|
||||||
commentCount = 1L,
|
commentCount = 1L,
|
||||||
existOrdered = false
|
existOrdered = false,
|
||||||
|
isLiked = false
|
||||||
),
|
),
|
||||||
HomePopularCommunityRecommendationRecord(
|
HomePopularCommunityRecommendationRecord(
|
||||||
communityId = 3L,
|
communityId = 3L,
|
||||||
@@ -273,7 +275,8 @@ class HomeRecommendationQueryServiceTest {
|
|||||||
createdAt = LocalDateTime.of(2026, 5, 29, 3, 0),
|
createdAt = LocalDateTime.of(2026, 5, 29, 3, 0),
|
||||||
likeCount = 0L,
|
likeCount = 0L,
|
||||||
commentCount = 0L,
|
commentCount = 0L,
|
||||||
existOrdered = false
|
existOrdered = false,
|
||||||
|
isLiked = false
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -288,6 +291,7 @@ class HomeRecommendationQueryServiceTest {
|
|||||||
assertEquals("community-1.png", communities.first().imagePath)
|
assertEquals("community-1.png", communities.first().imagePath)
|
||||||
assertEquals("community-1.mp3", communities.first().audioPath)
|
assertEquals("community-1.mp3", communities.first().audioPath)
|
||||||
assertEquals(true, communities.first().existOrdered)
|
assertEquals(true, communities.first().existOrdered)
|
||||||
|
assertEquals(true, communities.first().isLiked)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@@ -314,7 +318,8 @@ class HomeRecommendationQueryServiceTest {
|
|||||||
createdAt = LocalDateTime.of(2026, 5, 29, 1, 0).plusMinutes(communityId),
|
createdAt = LocalDateTime.of(2026, 5, 29, 1, 0).plusMinutes(communityId),
|
||||||
likeCount = 0L,
|
likeCount = 0L,
|
||||||
commentCount = 0L,
|
commentCount = 0L,
|
||||||
existOrdered = false
|
existOrdered = false,
|
||||||
|
isLiked = false
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user