Compare commits
2 Commits
9bb14aacdf
...
b5b682e340
| Author | SHA1 | Date | |
|---|---|---|---|
| b5b682e340 | |||
| ae380e8922 |
50
docs/20260709_팬톡_작성수정_응답보강/plan-task.md
Normal file
50
docs/20260709_팬톡_작성수정_응답보강/plan-task.md
Normal file
@@ -0,0 +1,50 @@
|
||||
# 팬톡 작성/수정 응답 보강 Plan/Task
|
||||
|
||||
## 시나리오 계약
|
||||
- Happy path: 인증 사용자가 `POST /explorer/profile/cheers`를 호출하면 HTTP 200과 함께 `$.data.fanTalkId`, `$.data.writerId`, `$.data.content`, `$.data.creatorReplies=[]`가 반환된다. Real surface: MockMvc controller test.
|
||||
- Edge: 인증 사용자가 `PUT /explorer/profile/cheers`로 내용을 수정하면 HTTP 200과 함께 수정 후 content가 `$.data.content`에 반환된다. Real surface: MockMvc controller test.
|
||||
- Adjacent regression: 기존 V2 FanTalk 탭 `CreatorChannelFanTalkResponse.from(domain)` 매핑과 조회 controller 응답은 유지된다. Real surface: 기존 `CreatorChannelFanTalkControllerTest`, DTO/facade unit test.
|
||||
|
||||
### Phase 1: 문서와 RED 테스트
|
||||
- [x] **Task 1.1: PRD/plan-task 작성**
|
||||
- 파일 경로: `docs/20260709_팬톡_작성수정_응답보강/prd.md`, `docs/20260709_팬톡_작성수정_응답보강/plan-task.md`
|
||||
- RED: 문서 작업은 TDD 예외. TDD 예외 사유: 코드 동작 변경 전 요구사항 고정 문서다.
|
||||
- GREEN: 요구사항, non-goal, 시나리오, 검증 명령을 문서화한다.
|
||||
- REFACTOR: 문서 경로와 명령 유효성을 확인한다.
|
||||
- 검증 기록: `docs/20260709_팬톡_작성수정_응답보강/prd.md`, `docs/20260709_팬톡_작성수정_응답보강/plan-task.md`를 생성해 endpoint 유지, legacy 직접 수정, `CreatorChannelFanTalkResponse` 반환 범위를 문서화했다.
|
||||
- [x] **Task 1.2: 작성/수정 응답 RED 테스트 추가**
|
||||
- 파일 경로: `src/test/kotlin/kr/co/vividnext/sodalive/explorer/ExplorerControllerTest.kt`, `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/fantalk/application/CreatorChannelFanTalkFacadeTest.kt`
|
||||
- RED: `./gradlew test --tests kr.co.vividnext.sodalive.explorer.ExplorerControllerTest --tests kr.co.vividnext.sodalive.v2.api.creator.channel.fantalk.application.CreatorChannelFanTalkFacadeTest --no-daemon` 실행 시 신규 기대 동작이 미구현이라 실패해야 한다.
|
||||
- GREEN: 구현 후 같은 명령이 통과해야 한다.
|
||||
- REFACTOR: 기존 FanTalk 탭 controller 테스트를 함께 실행한다.
|
||||
- RED 검증 기록: 신규 controller 테스트 추가 직후 같은 명령을 실행했고, `ExplorerService.writeCheers/modifyCheers`가 `Unit` 반환이라 `CannotStubVoidMethodWithReturnValue`로 2개 테스트 실패를 확인했다.
|
||||
|
||||
### Phase 2: 최소 구현
|
||||
- [x] **Task 2.1: legacy cheers 응답 매핑 구현**
|
||||
- 파일 경로: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/fantalk/dto/CreatorChannelFanTalkTabResponse.kt`
|
||||
- RED: legacy `CreatorCheers`를 `CreatorChannelFanTalkResponse`로 만들 수 없어 테스트가 실패한다.
|
||||
- GREEN: `CreatorChannelFanTalkResponse.from(CreatorCheers, cloudFrontHost)`를 추가한다.
|
||||
- REFACTOR: 기존 domain 기반 `from` 매핑을 변경하지 않는다.
|
||||
- GREEN 검증 기록: `CreatorChannelFanTalkResponse.from(CreatorCheers, cloudFrontHost)`를 추가해 작성자 ID/닉네임/프로필 URL/content/UTC 생성 시각/빈 creatorReplies를 매핑했다.
|
||||
- [x] **Task 2.2: 작성/수정 서비스 반환값 변경**
|
||||
- 파일 경로: `src/main/kotlin/kr/co/vividnext/sodalive/explorer/ExplorerService.kt`, `src/main/kotlin/kr/co/vividnext/sodalive/explorer/ExplorerController.kt`
|
||||
- RED: controller/service가 `Unit`을 반환해 `data`가 비어 있다.
|
||||
- GREEN: 저장/수정 후 `CreatorChannelFanTalkResponse`를 반환한다.
|
||||
- REFACTOR: 메시지와 기존 검증 흐름을 유지한다.
|
||||
- GREEN 검증 기록: `ExplorerService.writeCheers`, `ExplorerService.modifyCheers`가 저장/수정된 `CreatorCheers`를 `CreatorChannelFanTalkResponse`로 반환하도록 변경했고, `ExplorerController`의 기존 `ApiResponse.ok(service..., message)` 흐름은 유지했다.
|
||||
|
||||
## 전체 검증 기록
|
||||
- 작업 시작 전 확인: `rg -n "explorer/profile/cheers|CreatorChannelFanTalkResponse|writeCheers|modifyCheers"`로 대상 controller/service/DTO 위치를 확인했다.
|
||||
- RED: `./gradlew test --tests kr.co.vividnext.sodalive.explorer.ExplorerControllerTest --tests kr.co.vividnext.sodalive.v2.api.creator.channel.fantalk.application.CreatorChannelFanTalkFacadeTest --no-daemon` 실행 결과 신규 테스트 2개가 `CannotStubVoidMethodWithReturnValue`로 실패해 기존 서비스 반환 타입이 `Unit`임을 확인했다.
|
||||
- GREEN/인접 회귀: `./gradlew test --tests kr.co.vividnext.sodalive.explorer.ExplorerControllerTest --tests kr.co.vividnext.sodalive.v2.api.creator.channel.fantalk.application.CreatorChannelFanTalkFacadeTest --tests kr.co.vividnext.sodalive.v2.api.creator.channel.fantalk.adapter.in.web.CreatorChannelFanTalkControllerTest --no-daemon` 실행 결과 `BUILD SUCCESSFUL in 1m`을 확인했다.
|
||||
- Diff 검증: `git diff --check` 실행 결과 출력 없이 통과했다.
|
||||
- 포맷 검증: `./gradlew ktlintCheck --no-daemon` 실행 결과 `BUILD SUCCESSFUL in 1m 21s`를 확인했다.
|
||||
- 문서 명령 검증: `./gradlew tasks --all --no-daemon` 실행 결과 `BUILD SUCCESSFUL in 20s`를 확인했다.
|
||||
- 전체 회귀 검증: `./gradlew test --no-daemon` 실행 결과 `BUILD SUCCESSFUL in 8m 56s`를 확인했다.
|
||||
- 리뷰 게이트 1차 결과: 목표 검증/QA/컨텍스트 마이닝은 PASS였지만, 보안 리뷰와 코드 품질 리뷰에서 `PUT /explorer/profile/cheers`가 작성자/크리에이터가 아닌 인증 사용자에게도 `cheersId` 기반 응답 데이터를 반환할 수 있다는 IDOR성 정보 노출 지적이 나왔다.
|
||||
- 보안 RED: `ExplorerServiceTest.shouldRejectModifyCheersForNonWriterAndNonCreator` 추가 직후 `./gradlew test --tests kr.co.vividnext.sodalive.explorer.ExplorerServiceTest --no-daemon` 실행 결과 `AssertionFailedError`로 실패해 비작성자/비크리에이터 요청이 예외 없이 데이터를 받을 수 있음을 확인했다.
|
||||
- 보안 GREEN: `ExplorerService.modifyCheers`에서 `isWriter`, `isCreator`를 계산하고 둘 다 아니면 `SodaException(messageKey = "common.error.invalid_request")`를 던지도록 수정했다. 이후 `./gradlew test --tests kr.co.vividnext.sodalive.explorer.ExplorerServiceTest --tests kr.co.vividnext.sodalive.explorer.ExplorerControllerTest --tests kr.co.vividnext.sodalive.v2.api.creator.channel.fantalk.application.CreatorChannelFanTalkFacadeTest --tests kr.co.vividnext.sodalive.v2.api.creator.channel.fantalk.adapter.in.web.CreatorChannelFanTalkControllerTest --no-daemon` 실행 결과 `BUILD SUCCESSFUL in 1m 12s`를 확인했다.
|
||||
- 최종 포맷 검증: 보안 수정 후 `./gradlew ktlintCheck --no-daemon` 실행 결과 `BUILD SUCCESSFUL in 1m 47s`를 확인했다.
|
||||
- 최종 문서 명령 검증: 보안 수정 후 `./gradlew tasks --all --no-daemon` 실행 결과 `BUILD SUCCESSFUL in 30s`를 확인했다.
|
||||
- 최종 diff 검증: 보안 수정 후 `git diff --check` 실행 결과 출력 없이 통과했다.
|
||||
- 최종 전체 회귀 검증: 보안 수정 후 `./gradlew test --no-daemon` 실행 결과 `BUILD SUCCESSFUL in 14m 53s`를 확인했다.
|
||||
39
docs/20260709_팬톡_작성수정_응답보강/prd.md
Normal file
39
docs/20260709_팬톡_작성수정_응답보강/prd.md
Normal file
@@ -0,0 +1,39 @@
|
||||
# PRD: 팬톡 작성/수정 응답 보강
|
||||
|
||||
## 1. Overview
|
||||
`POST /explorer/profile/cheers`, `PUT /explorer/profile/cheers` 성공 응답의 `data`에 실제 저장/수정된 팬톡 데이터를 `CreatorChannelFanTalkResponse` 스키마로 내려준다.
|
||||
|
||||
## 2. Problem
|
||||
- 현재 두 API는 성공 여부와 메시지만 확인할 수 있고, 클라이언트가 방금 작성/수정된 팬톡 데이터를 응답에서 바로 사용할 수 없다.
|
||||
- 작성/수정 후 화면 반영을 위해 별도 조회가 필요해질 수 있다.
|
||||
|
||||
## 3. Goals
|
||||
- 작성 API 성공 시 저장된 `CreatorCheers`를 `CreatorChannelFanTalkResponse`로 변환해 `ApiResponse.data`에 포함한다.
|
||||
- 수정 API 성공 시 수정 후 `CreatorCheers` 상태를 `CreatorChannelFanTalkResponse`로 변환해 `ApiResponse.data`에 포함한다.
|
||||
- 기존 성공 메시지와 인증/차단/검증 흐름은 유지한다.
|
||||
|
||||
## 4. Non-Goals
|
||||
- API endpoint 경로 변경 또는 신규 endpoint 추가는 하지 않는다.
|
||||
- legacy `CreatorCheers` 저장 구조는 변경하지 않는다.
|
||||
- FanTalk 탭 조회 API 응답 스키마는 변경하지 않는다.
|
||||
|
||||
## 5. Core Features
|
||||
|
||||
### Feature A: 작성/수정 응답 data 보강
|
||||
|
||||
#### Requirements
|
||||
- `POST /explorer/profile/cheers` 반환 타입은 `ApiResponse<CreatorChannelFanTalkResponse>`가 되어야 한다.
|
||||
- `PUT /explorer/profile/cheers` 반환 타입은 `ApiResponse<CreatorChannelFanTalkResponse>`가 되어야 한다.
|
||||
- 응답 필드는 기존 V2 FanTalk 탭의 `CreatorChannelFanTalkResponse` 필드와 동일해야 한다.
|
||||
- legacy `CreatorCheers`에서 응답을 만들 때 작성자 ID, 닉네임, 프로필 이미지 URL, 내용, UTC 생성 시각을 매핑한다.
|
||||
- 작성/수정 직후 단일 팬톡 응답의 `creatorReplies`는 빈 목록으로 내려준다.
|
||||
|
||||
#### Edge Cases
|
||||
- 작성자가 기본 프로필인 경우 기존 기본 프로필 URL 정책을 유지한다.
|
||||
- 탈퇴 회원 prefix가 붙은 닉네임은 기존 제거 정책을 적용한다.
|
||||
- 수정 요청에서 작성자 또는 크리에이터가 비활성 처리한 경우에도 수정 후 현재 row 상태를 응답한다.
|
||||
|
||||
## 6. Technical Constraints
|
||||
- 기존 legacy controller/service를 직접 수정한다.
|
||||
- 공개 응답 DTO는 기존 `CreatorChannelFanTalkResponse`를 재사용한다.
|
||||
- 변경은 문서, 테스트, 최소 구현 범위로 제한한다.
|
||||
@@ -31,6 +31,7 @@ import kr.co.vividnext.sodalive.member.Member
|
||||
import kr.co.vividnext.sodalive.member.MemberRole
|
||||
import kr.co.vividnext.sodalive.member.MemberService
|
||||
import kr.co.vividnext.sodalive.member.contentpreference.MemberContentPreferenceService
|
||||
import kr.co.vividnext.sodalive.v2.api.creator.channel.fantalk.dto.CreatorChannelFanTalkResponse
|
||||
import org.springframework.beans.factory.annotation.Value
|
||||
import org.springframework.context.ApplicationEventPublisher
|
||||
import org.springframework.data.domain.Pageable
|
||||
@@ -579,7 +580,7 @@ class ExplorerService(
|
||||
}
|
||||
|
||||
@Transactional
|
||||
fun writeCheers(request: PostWriteCheersRequest, member: Member) {
|
||||
fun writeCheers(request: PostWriteCheersRequest, member: Member): CreatorChannelFanTalkResponse {
|
||||
val creator = queryRepository.getMember(request.creatorId)
|
||||
?: throw SodaException(messageKey = "member.validation.user_not_found")
|
||||
|
||||
@@ -605,18 +606,20 @@ class ExplorerService(
|
||||
cheers.parent = parent
|
||||
}
|
||||
|
||||
cheersRepository.save(cheers)
|
||||
val savedCheers = cheersRepository.save(cheers)
|
||||
|
||||
// 언어 코드가 지정되지 않은 경우, 파파고 언어 감지 API를 통해 비동기로 언어를 식별한다.
|
||||
if (request.languageCode.isNullOrBlank()) {
|
||||
applicationEventPublisher.publishEvent(
|
||||
LanguageDetectEvent(
|
||||
id = cheers.id!!,
|
||||
id = savedCheers.id!!,
|
||||
query = request.content,
|
||||
targetType = LanguageDetectTargetType.CREATOR_CHEERS
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
return CreatorChannelFanTalkResponse.from(savedCheers, cloudFrontHost)
|
||||
}
|
||||
|
||||
fun getCreatorProfileCheers(
|
||||
@@ -639,24 +642,29 @@ class ExplorerService(
|
||||
}
|
||||
|
||||
@Transactional
|
||||
fun modifyCheers(request: PutWriteCheersRequest, member: Member) {
|
||||
fun modifyCheers(request: PutWriteCheersRequest, member: Member): CreatorChannelFanTalkResponse {
|
||||
val cheers = queryRepository.getCheers(request.cheersId)
|
||||
?: throw SodaException(messageKey = "common.error.invalid_request")
|
||||
val isWriter = cheers.member!!.id!! == member.id!!
|
||||
val isCreator = cheers.creator!!.id!! == member.id!!
|
||||
|
||||
if (cheers.member!!.id!! == member.id!!) {
|
||||
if (!isWriter && !isCreator) {
|
||||
throw SodaException(messageKey = "common.error.invalid_request")
|
||||
}
|
||||
|
||||
if (isWriter) {
|
||||
if (request.content != null) {
|
||||
cheers.cheers = request.content
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
cheers.creator!!.id!! == member.id!! ||
|
||||
cheers.member!!.id!! == member.id!!
|
||||
) {
|
||||
if (isCreator || isWriter) {
|
||||
if (request.isActive != null) {
|
||||
cheers.isActive = request.isActive
|
||||
}
|
||||
}
|
||||
|
||||
return CreatorChannelFanTalkResponse.from(cheers, cloudFrontHost)
|
||||
}
|
||||
|
||||
@Transactional
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
package kr.co.vividnext.sodalive.v2.api.creator.channel.fantalk.dto
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonProperty
|
||||
import kr.co.vividnext.sodalive.explorer.profile.CreatorCheers
|
||||
import kr.co.vividnext.sodalive.extensions.removeDeletedNicknamePrefix
|
||||
import kr.co.vividnext.sodalive.extensions.toUtcIso
|
||||
import kr.co.vividnext.sodalive.v2.common.domain.toCdnUrl
|
||||
import kr.co.vividnext.sodalive.v2.creator.channel.fantalk.domain.CreatorChannelFanTalk
|
||||
import kr.co.vividnext.sodalive.v2.creator.channel.fantalk.domain.CreatorChannelFanTalkReply
|
||||
import kr.co.vividnext.sodalive.v2.creator.channel.fantalk.domain.CreatorChannelFanTalkTab
|
||||
@@ -48,6 +51,20 @@ data class CreatorChannelFanTalkResponse(
|
||||
creatorReplies = fanTalk.creatorReplies.map(CreatorChannelFanTalkReplyResponse::from)
|
||||
)
|
||||
}
|
||||
|
||||
fun from(fanTalk: CreatorCheers, cloudFrontHost: String): CreatorChannelFanTalkResponse {
|
||||
val writer = fanTalk.member!!
|
||||
return CreatorChannelFanTalkResponse(
|
||||
fanTalkId = fanTalk.id!!,
|
||||
writerId = writer.id!!,
|
||||
writerNickname = writer.nickname.removeDeletedNicknamePrefix(),
|
||||
writerProfileImageUrl = writer.profileImage.toCdnUrl(cloudFrontHost)
|
||||
?: "$cloudFrontHost/profile/default-profile.png",
|
||||
content = fanTalk.cheers,
|
||||
createdAtUtc = fanTalk.createdAt!!.toUtcIso(),
|
||||
creatorReplies = emptyList()
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,141 @@
|
||||
package kr.co.vividnext.sodalive.explorer
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper
|
||||
import kr.co.vividnext.sodalive.common.CountryContext
|
||||
import kr.co.vividnext.sodalive.explorer.profile.PostWriteCheersRequest
|
||||
import kr.co.vividnext.sodalive.explorer.profile.PutWriteCheersRequest
|
||||
import kr.co.vividnext.sodalive.i18n.LangContext
|
||||
import kr.co.vividnext.sodalive.i18n.SodaMessageSource
|
||||
import kr.co.vividnext.sodalive.member.Member
|
||||
import kr.co.vividnext.sodalive.member.MemberAdapter
|
||||
import kr.co.vividnext.sodalive.member.MemberRole
|
||||
import kr.co.vividnext.sodalive.v2.api.creator.channel.fantalk.dto.CreatorChannelFanTalkResponse
|
||||
import org.junit.jupiter.api.DisplayName
|
||||
import org.junit.jupiter.api.Test
|
||||
import org.mockito.Mockito
|
||||
import org.springframework.beans.factory.annotation.Autowired
|
||||
import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest
|
||||
import org.springframework.boot.test.context.TestConfiguration
|
||||
import org.springframework.boot.test.mock.mockito.MockBean
|
||||
import org.springframework.context.annotation.Bean
|
||||
import org.springframework.context.annotation.Import
|
||||
import org.springframework.http.HttpStatus
|
||||
import org.springframework.http.MediaType
|
||||
import org.springframework.security.config.annotation.web.builders.HttpSecurity
|
||||
import org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.user
|
||||
import org.springframework.security.web.SecurityFilterChain
|
||||
import org.springframework.security.web.authentication.HttpStatusEntryPoint
|
||||
import org.springframework.test.web.servlet.MockMvc
|
||||
import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post
|
||||
import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.put
|
||||
import org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath
|
||||
import org.springframework.test.web.servlet.result.MockMvcResultMatchers.status
|
||||
import javax.servlet.http.HttpServletResponse
|
||||
|
||||
@WebMvcTest(ExplorerController::class)
|
||||
@Import(ExplorerControllerTest.TestSecurityConfig::class)
|
||||
class ExplorerControllerTest @Autowired constructor(
|
||||
private val mockMvc: MockMvc,
|
||||
private val objectMapper: ObjectMapper
|
||||
) {
|
||||
@MockBean
|
||||
private lateinit var service: ExplorerService
|
||||
|
||||
@MockBean
|
||||
private lateinit var messageSource: SodaMessageSource
|
||||
|
||||
@MockBean
|
||||
private lateinit var langContext: LangContext
|
||||
|
||||
@MockBean
|
||||
private lateinit var countryContext: CountryContext
|
||||
|
||||
@TestConfiguration
|
||||
class TestSecurityConfig {
|
||||
@Bean
|
||||
fun securityFilterChain(http: HttpSecurity): SecurityFilterChain {
|
||||
return http
|
||||
.csrf().disable()
|
||||
.authorizeRequests()
|
||||
.anyRequest().authenticated()
|
||||
.and()
|
||||
.exceptionHandling()
|
||||
.authenticationEntryPoint(HttpStatusEntryPoint(HttpStatus.UNAUTHORIZED))
|
||||
.accessDeniedHandler { _, response, _ -> response.sendError(HttpServletResponse.SC_FORBIDDEN) }
|
||||
.and()
|
||||
.build()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("팬톡 작성 API는 저장된 팬톡 데이터를 data에 반환한다")
|
||||
fun shouldReturnCreatedFanTalkData() {
|
||||
val member = createMember(id = 10L)
|
||||
val request = PostWriteCheersRequest(creatorId = 1L, content = "created fan talk", languageCode = "ko")
|
||||
Mockito.doReturn(createFanTalkResponse(content = "created fan talk"))
|
||||
.`when`(service).writeCheers(eqValue(request), eqValue(member))
|
||||
|
||||
mockMvc.perform(
|
||||
post("/explorer/profile/cheers")
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content(objectMapper.writeValueAsString(request))
|
||||
.with(user(MemberAdapter(member)))
|
||||
)
|
||||
.andExpect(status().isOk)
|
||||
.andExpect(jsonPath("$.success").value(true))
|
||||
.andExpect(jsonPath("$.data.fanTalkId").value(101))
|
||||
.andExpect(jsonPath("$.data.writerId").value(10))
|
||||
.andExpect(jsonPath("$.data.writerNickname").value("fan"))
|
||||
.andExpect(jsonPath("$.data.writerProfileImageUrl").value("https://cdn.test/profile/fan.png"))
|
||||
.andExpect(jsonPath("$.data.content").value("created fan talk"))
|
||||
.andExpect(jsonPath("$.data.createdAtUtc").value("2026-07-09T01:00:00Z"))
|
||||
.andExpect(jsonPath("$.data.creatorReplies").isArray)
|
||||
.andExpect(jsonPath("$.data.creatorReplies.length()").value(0))
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("팬톡 수정 API는 수정된 팬톡 데이터를 data에 반환한다")
|
||||
fun shouldReturnModifiedFanTalkData() {
|
||||
val member = createMember(id = 10L)
|
||||
val request = PutWriteCheersRequest(cheersId = 101L, content = "modified fan talk")
|
||||
Mockito.doReturn(createFanTalkResponse(content = "modified fan talk"))
|
||||
.`when`(service).modifyCheers(eqValue(request), eqValue(member))
|
||||
|
||||
mockMvc.perform(
|
||||
put("/explorer/profile/cheers")
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content(objectMapper.writeValueAsString(request))
|
||||
.with(user(MemberAdapter(member)))
|
||||
)
|
||||
.andExpect(status().isOk)
|
||||
.andExpect(jsonPath("$.success").value(true))
|
||||
.andExpect(jsonPath("$.data.fanTalkId").value(101))
|
||||
.andExpect(jsonPath("$.data.content").value("modified fan talk"))
|
||||
.andExpect(jsonPath("$.data.creatorReplies.length()").value(0))
|
||||
}
|
||||
|
||||
private fun createMember(id: Long): Member {
|
||||
return Member(
|
||||
email = "user$id@test.com",
|
||||
password = "password",
|
||||
nickname = "user$id",
|
||||
role = MemberRole.USER
|
||||
).apply { this.id = id }
|
||||
}
|
||||
|
||||
private fun createFanTalkResponse(content: String): CreatorChannelFanTalkResponse {
|
||||
return CreatorChannelFanTalkResponse(
|
||||
fanTalkId = 101L,
|
||||
writerId = 10L,
|
||||
writerNickname = "fan",
|
||||
writerProfileImageUrl = "https://cdn.test/profile/fan.png",
|
||||
content = content,
|
||||
createdAtUtc = "2026-07-09T01:00:00Z",
|
||||
creatorReplies = emptyList()
|
||||
)
|
||||
}
|
||||
|
||||
private fun <T> eqValue(value: T): T {
|
||||
return Mockito.eq(value) ?: value
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
package kr.co.vividnext.sodalive.explorer
|
||||
|
||||
import kr.co.vividnext.sodalive.common.SodaException
|
||||
import kr.co.vividnext.sodalive.content.AudioContentService
|
||||
import kr.co.vividnext.sodalive.content.series.ContentSeriesService
|
||||
import kr.co.vividnext.sodalive.content.translation.ContentTranslationRepository
|
||||
import kr.co.vividnext.sodalive.explorer.profile.ChannelNoticeRepository
|
||||
import kr.co.vividnext.sodalive.explorer.profile.CreatorCheers
|
||||
import kr.co.vividnext.sodalive.explorer.profile.CreatorCheersRepository
|
||||
import kr.co.vividnext.sodalive.explorer.profile.CreatorDonationRankingService
|
||||
import kr.co.vividnext.sodalive.explorer.profile.PutWriteCheersRequest
|
||||
import kr.co.vividnext.sodalive.explorer.profile.channelDonation.ChannelDonationService
|
||||
import kr.co.vividnext.sodalive.explorer.profile.creatorCommunity.CreatorCommunityService
|
||||
import kr.co.vividnext.sodalive.i18n.LangContext
|
||||
import kr.co.vividnext.sodalive.i18n.SodaMessageSource
|
||||
import kr.co.vividnext.sodalive.member.Member
|
||||
import kr.co.vividnext.sodalive.member.MemberRole
|
||||
import kr.co.vividnext.sodalive.member.MemberService
|
||||
import kr.co.vividnext.sodalive.member.contentpreference.MemberContentPreferenceService
|
||||
import org.junit.jupiter.api.Assertions.assertThrows
|
||||
import org.junit.jupiter.api.DisplayName
|
||||
import org.junit.jupiter.api.Test
|
||||
import org.mockito.Mockito
|
||||
import org.springframework.context.ApplicationEventPublisher
|
||||
import java.time.LocalDateTime
|
||||
|
||||
class ExplorerServiceTest {
|
||||
@Test
|
||||
@DisplayName("팬톡 수정은 작성자나 크리에이터가 아닌 요청자에게 데이터를 반환하지 않는다")
|
||||
fun shouldRejectModifyCheersForNonWriterAndNonCreator() {
|
||||
val queryRepository = Mockito.mock(ExplorerQueryRepository::class.java)
|
||||
val service = createService(queryRepository)
|
||||
val cheers = createCheers(
|
||||
writer = createMember(id = 10L, nickname = "writer"),
|
||||
creator = createMember(id = 20L, nickname = "creator", role = MemberRole.CREATOR)
|
||||
)
|
||||
val requester = createMember(id = 30L, nickname = "requester")
|
||||
Mockito.doReturn(cheers).`when`(queryRepository).getCheers(101L)
|
||||
|
||||
assertThrows(SodaException::class.java) {
|
||||
service.modifyCheers(PutWriteCheersRequest(cheersId = 101L, content = "stolen"), requester)
|
||||
}
|
||||
}
|
||||
|
||||
private fun createService(queryRepository: ExplorerQueryRepository): ExplorerService {
|
||||
return ExplorerService(
|
||||
memberService = Mockito.mock(MemberService::class.java),
|
||||
memberContentPreferenceService = Mockito.mock(MemberContentPreferenceService::class.java),
|
||||
audioContentService = Mockito.mock(AudioContentService::class.java),
|
||||
donationRankingService = Mockito.mock(CreatorDonationRankingService::class.java),
|
||||
queryRepository = queryRepository,
|
||||
cheersRepository = Mockito.mock(CreatorCheersRepository::class.java),
|
||||
noticeRepository = Mockito.mock(ChannelNoticeRepository::class.java),
|
||||
channelDonationService = Mockito.mock(ChannelDonationService::class.java),
|
||||
communityService = Mockito.mock(CreatorCommunityService::class.java),
|
||||
seriesService = Mockito.mock(ContentSeriesService::class.java),
|
||||
applicationEventPublisher = Mockito.mock(ApplicationEventPublisher::class.java),
|
||||
contentTranslationRepository = Mockito.mock(ContentTranslationRepository::class.java),
|
||||
messageSource = Mockito.mock(SodaMessageSource::class.java),
|
||||
langContext = Mockito.mock(LangContext::class.java),
|
||||
cloudFrontHost = "https://cdn.test"
|
||||
)
|
||||
}
|
||||
|
||||
private fun createCheers(writer: Member, creator: Member): CreatorCheers {
|
||||
return CreatorCheers(cheers = "fan talk", languageCode = "ko").apply {
|
||||
id = 101L
|
||||
member = writer
|
||||
this.creator = creator
|
||||
createdAt = LocalDateTime.of(2026, 7, 9, 1, 0)
|
||||
}
|
||||
}
|
||||
|
||||
private fun createMember(
|
||||
id: Long,
|
||||
nickname: String,
|
||||
role: MemberRole = MemberRole.USER
|
||||
): Member {
|
||||
return Member(
|
||||
email = "$nickname@test.com",
|
||||
password = "password",
|
||||
nickname = nickname,
|
||||
role = role
|
||||
).apply { this.id = id }
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user