From b5b682e340651bb5834bec83efb7644b2609be39 Mon Sep 17 00:00:00 2001 From: Klaus Date: Thu, 9 Jul 2026 06:00:35 +0900 Subject: [PATCH] =?UTF-8?q?feat(fantalk):=20=ED=8C=AC=ED=86=A1=20=EC=9E=91?= =?UTF-8?q?=EC=84=B1=20=EC=88=98=EC=A0=95=20=EC=9D=91=EB=8B=B5=EC=9D=84=20?= =?UTF-8?q?=EB=B0=98=ED=99=98=ED=95=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../sodalive/explorer/ExplorerService.kt | 26 ++-- .../dto/CreatorChannelFanTalkTabResponse.kt | 17 +++ .../explorer/ExplorerControllerTest.kt | 141 ++++++++++++++++++ .../sodalive/explorer/ExplorerServiceTest.kt | 86 +++++++++++ 4 files changed, 261 insertions(+), 9 deletions(-) create mode 100644 src/test/kotlin/kr/co/vividnext/sodalive/explorer/ExplorerControllerTest.kt create mode 100644 src/test/kotlin/kr/co/vividnext/sodalive/explorer/ExplorerServiceTest.kt diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/explorer/ExplorerService.kt b/src/main/kotlin/kr/co/vividnext/sodalive/explorer/ExplorerService.kt index 3d228843..f632cfe1 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/explorer/ExplorerService.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/explorer/ExplorerService.kt @@ -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 diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/fantalk/dto/CreatorChannelFanTalkTabResponse.kt b/src/main/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/fantalk/dto/CreatorChannelFanTalkTabResponse.kt index b8ef31d0..a8f4dca6 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/fantalk/dto/CreatorChannelFanTalkTabResponse.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/fantalk/dto/CreatorChannelFanTalkTabResponse.kt @@ -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() + ) + } } } diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/explorer/ExplorerControllerTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/explorer/ExplorerControllerTest.kt new file mode 100644 index 00000000..b48c813b --- /dev/null +++ b/src/test/kotlin/kr/co/vividnext/sodalive/explorer/ExplorerControllerTest.kt @@ -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 eqValue(value: T): T { + return Mockito.eq(value) ?: value + } +} diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/explorer/ExplorerServiceTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/explorer/ExplorerServiceTest.kt new file mode 100644 index 00000000..e365ce82 --- /dev/null +++ b/src/test/kotlin/kr/co/vividnext/sodalive/explorer/ExplorerServiceTest.kt @@ -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 } + } +}