diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/fantalk/AiCharacterAdminFanTalkController.kt b/src/main/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/fantalk/AiCharacterAdminFanTalkController.kt new file mode 100644 index 00000000..25c084e0 --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/fantalk/AiCharacterAdminFanTalkController.kt @@ -0,0 +1,58 @@ +package kr.co.vividnext.sodalive.v2.api.admin.aicharacter.fantalk + +import kr.co.vividnext.sodalive.common.ApiResponse +import kr.co.vividnext.sodalive.v2.api.creator.channel.fantalk.dto.CreatorChannelFanTalkResponse +import kr.co.vividnext.sodalive.v2.api.creator.channel.fantalk.dto.CreatorChannelFanTalkTabResponse +import org.springframework.http.MediaType +import org.springframework.web.bind.annotation.DeleteMapping +import org.springframework.web.bind.annotation.GetMapping +import org.springframework.web.bind.annotation.PathVariable +import org.springframework.web.bind.annotation.PostMapping +import org.springframework.web.bind.annotation.PutMapping +import org.springframework.web.bind.annotation.RequestBody +import org.springframework.web.bind.annotation.RequestMapping +import org.springframework.web.bind.annotation.RequestParam +import org.springframework.web.bind.annotation.RestController + +@RestController +@RequestMapping("/api/v2/admin/ai-characters/{characterId:[0-9]+}/fan-talks") +class AiCharacterAdminFanTalkController( + private val facade: AiCharacterAdminFanTalkFacade +) { + @GetMapping + fun list( + @PathVariable characterId: Long, + @RequestParam(defaultValue = "0") page: Int, + @RequestParam(defaultValue = "20") size: Int + ): ApiResponse { + return ApiResponse.ok(facade.list(characterId, page, size)) + } + + @PostMapping("/{fanTalkId:[0-9]+}/replies", consumes = [MediaType.APPLICATION_JSON_VALUE]) + fun createReply( + @PathVariable characterId: Long, + @PathVariable fanTalkId: Long, + @RequestBody request: String + ): ApiResponse { + return ApiResponse.ok(facade.createReply(characterId, fanTalkId, request)) + } + + @PutMapping("/{fanTalkId:[0-9]+}/replies/{replyId:[0-9]+}", consumes = [MediaType.APPLICATION_JSON_VALUE]) + fun updateReply( + @PathVariable characterId: Long, + @PathVariable fanTalkId: Long, + @PathVariable replyId: Long, + @RequestBody request: String + ): ApiResponse { + return ApiResponse.ok(facade.updateReply(characterId, fanTalkId, replyId, request)) + } + + @DeleteMapping("/{fanTalkId:[0-9]+}") + fun delete( + @PathVariable characterId: Long, + @PathVariable fanTalkId: Long + ): ApiResponse { + facade.delete(characterId, fanTalkId) + return ApiResponse.ok(null) + } +} diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/fantalk/AiCharacterAdminFanTalkDto.kt b/src/main/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/fantalk/AiCharacterAdminFanTalkDto.kt new file mode 100644 index 00000000..0aade731 --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/fantalk/AiCharacterAdminFanTalkDto.kt @@ -0,0 +1,39 @@ +package kr.co.vividnext.sodalive.v2.api.admin.aicharacter.fantalk + +import java.time.LocalDateTime + +data class AiCharacterAdminFanTalkRecord( + val fanTalkId: Long, + val writerId: Long, + val writerNickname: String, + val writerProfileImagePath: String?, + val content: String, + val createdAt: LocalDateTime +) + +data class AiCharacterAdminFanTalkReplyRecord( + val fanTalkId: Long, + val parentFanTalkId: Long, + val writerId: Long, + val writerNickname: String, + val writerProfileImagePath: String?, + val content: String, + val createdAt: LocalDateTime +) + +data class AiCharacterAdminFanTalkReplyRequest( + val content: String +) + +data class AiCharacterAdminFanTalkReplyUpdateRequest( + val content: String? = null, + val isActive: Boolean? = null +) + +data class AiCharacterAdminFanTalkReplyResponse( + val fanTalkId: Long, + val replyId: Long, + val creatorMemberId: Long, + val content: String, + val createdAtUtc: String +) diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/fantalk/AiCharacterAdminFanTalkFacade.kt b/src/main/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/fantalk/AiCharacterAdminFanTalkFacade.kt new file mode 100644 index 00000000..ffb2fe74 --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/fantalk/AiCharacterAdminFanTalkFacade.kt @@ -0,0 +1,178 @@ +package kr.co.vividnext.sodalive.v2.api.admin.aicharacter.fantalk + +import com.fasterxml.jackson.core.JsonProcessingException +import com.fasterxml.jackson.databind.DeserializationFeature +import com.fasterxml.jackson.databind.ObjectMapper +import kr.co.vividnext.sodalive.content.LanguageDetectEvent +import kr.co.vividnext.sodalive.content.LanguageDetectTargetType +import kr.co.vividnext.sodalive.explorer.profile.CreatorCheers +import kr.co.vividnext.sodalive.explorer.profile.CreatorCheersRepository +import kr.co.vividnext.sodalive.extensions.removeDeletedNicknamePrefix +import kr.co.vividnext.sodalive.extensions.toUtcIso +import kr.co.vividnext.sodalive.v2.api.admin.aicharacter.application.AiCharacterAdminTarget +import kr.co.vividnext.sodalive.v2.api.admin.aicharacter.application.AiCharacterAdminTargetResolver +import kr.co.vividnext.sodalive.v2.api.admin.aicharacter.error.AiCharacterAdminApiException +import kr.co.vividnext.sodalive.v2.api.creator.channel.fantalk.dto.CreatorChannelFanTalkReplyResponse +import kr.co.vividnext.sodalive.v2.api.creator.channel.fantalk.dto.CreatorChannelFanTalkResponse +import kr.co.vividnext.sodalive.v2.api.creator.channel.fantalk.dto.CreatorChannelFanTalkTabResponse +import kr.co.vividnext.sodalive.v2.common.domain.toCdnUrl +import kr.co.vividnext.sodalive.v2.creator.channel.fantalk.domain.CreatorChannelFanTalkQueryPolicy +import org.springframework.beans.factory.annotation.Value +import org.springframework.context.ApplicationEventPublisher +import org.springframework.http.HttpStatus +import org.springframework.stereotype.Service +import org.springframework.transaction.annotation.Transactional + +@Service +class AiCharacterAdminFanTalkFacade( + private val objectMapper: ObjectMapper, + private val targetResolver: AiCharacterAdminTargetResolver, + private val repository: AiCharacterAdminFanTalkRepository, + private val creatorCheersRepository: CreatorCheersRepository, + private val queryPolicy: CreatorChannelFanTalkQueryPolicy, + private val applicationEventPublisher: ApplicationEventPublisher, + @Value("\${cloud.aws.cloud-front.host}") private val cloudFrontHost: String +) { + @Transactional(readOnly = true) + fun list(characterId: Long, page: Int, size: Int): CreatorChannelFanTalkTabResponse { + val normalizedPage = queryPolicy.createPage(page, size) + + val target = resolveActiveTarget(characterId) + val creatorMemberId = target.creatorMember.id ?: throw invalidRequest() + val fetchedRoots = repository.findActiveRoots( + creatorMemberId = creatorMemberId, + offset = normalizedPage.page.toLong() * normalizedPage.size, + limit = normalizedPage.size + 1 + ) + val roots = queryPolicy.limitItems(fetchedRoots, normalizedPage) + val repliesByRootId = repository.findActiveCreatorReplies( + creatorMemberId = creatorMemberId, + parentFanTalkIds = roots.map { it.fanTalkId } + ).groupBy( + keySelector = { it.parentFanTalkId }, + valueTransform = { it.toResponse() } + ) + + return CreatorChannelFanTalkTabResponse( + fanTalkCount = repository.countActiveRoots(creatorMemberId), + fanTalks = roots.map { it.toResponse(repliesByRootId[it.fanTalkId].orEmpty()) }, + page = normalizedPage.page, + size = normalizedPage.size, + hasNext = queryPolicy.hasNext(fetchedRoots, normalizedPage) + ) + } + + @Transactional + fun createReply( + characterId: Long, + fanTalkId: Long, + requestString: String + ): AiCharacterAdminFanTalkReplyResponse { + val request = readRequest(requestString, AiCharacterAdminFanTalkReplyRequest::class.java) + if (request.content.isBlank()) throw invalidRequest() + + val target = resolveActiveTarget(characterId) + val creator = target.creatorMember + val creatorMemberId = creator.id ?: throw invalidRequest() + val parent = repository.findActiveRoot(creatorMemberId, fanTalkId) ?: throw invalidRequest() + val savedReply = creatorCheersRepository.save( + CreatorCheers(cheers = request.content, languageCode = null).apply { + member = creator + this.creator = creator + this.parent = parent + } + ) + val replyId = savedReply.id ?: throw invalidRequest() + val createdAtUtc = savedReply.createdAt?.toUtcIso() ?: throw invalidRequest() + + applicationEventPublisher.publishEvent( + LanguageDetectEvent( + id = replyId, + query = request.content, + targetType = LanguageDetectTargetType.CREATOR_CHEERS + ) + ) + + return AiCharacterAdminFanTalkReplyResponse( + fanTalkId = fanTalkId, + replyId = replyId, + creatorMemberId = creatorMemberId, + content = request.content, + createdAtUtc = createdAtUtc + ) + } + + @Transactional + fun delete(characterId: Long, fanTalkId: Long) { + val target = resolveActiveTarget(characterId) + val creatorMemberId = target.creatorMember.id ?: throw invalidRequest() + val fanRoot = repository.findFanRoot(creatorMemberId, fanTalkId) ?: throw invalidRequest() + + fanRoot.isActive = false + } + + @Transactional + fun updateReply( + characterId: Long, + fanTalkId: Long, + replyId: Long, + requestString: String + ): CreatorChannelFanTalkResponse { + val request = readRequest(requestString, AiCharacterAdminFanTalkReplyUpdateRequest::class.java) + val target = resolveActiveTarget(characterId) + val creatorMemberId = target.creatorMember.id ?: throw invalidRequest() + val reply = repository.findUpdatableReply(creatorMemberId, fanTalkId, replyId) ?: throw invalidRequest() + + request.content?.let { reply.cheers = it } + request.isActive?.let { reply.isActive = it } + + return CreatorChannelFanTalkResponse.from(reply, cloudFrontHost) + } + + private fun resolveActiveTarget(characterId: Long): AiCharacterAdminTarget { + val target = targetResolver.resolve(characterId) + if (!target.chatCharacter.isActive) throw invalidRequest() + return target + } + + private fun AiCharacterAdminFanTalkRecord.toResponse( + creatorReplies: List + ): CreatorChannelFanTalkResponse { + return CreatorChannelFanTalkResponse( + fanTalkId = fanTalkId, + writerId = writerId, + writerNickname = writerNickname.removeDeletedNicknamePrefix(), + writerProfileImageUrl = writerProfileImagePath.toCdnUrl(cloudFrontHost) ?: defaultProfileImageUrl(), + content = content, + createdAtUtc = createdAt.toUtcIso(), + creatorReplies = creatorReplies + ) + } + + private fun AiCharacterAdminFanTalkReplyRecord.toResponse(): CreatorChannelFanTalkReplyResponse { + return CreatorChannelFanTalkReplyResponse( + fanTalkId = fanTalkId, + writerId = writerId, + writerNickname = writerNickname.removeDeletedNicknamePrefix(), + writerProfileImageUrl = writerProfileImagePath.toCdnUrl(cloudFrontHost) ?: defaultProfileImageUrl(), + content = content, + createdAtUtc = createdAt.toUtcIso() + ) + } + + private fun defaultProfileImageUrl(): String = "$cloudFrontHost/profile/default-profile.png" + + private fun readRequest(requestString: String, requestClass: Class): T { + return try { + objectMapper.readerFor(requestClass) + .with(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES) + .readValue(requestString) + } catch (_: JsonProcessingException) { + throw invalidRequest() + } + } + + private fun invalidRequest(): AiCharacterAdminApiException { + return AiCharacterAdminApiException(HttpStatus.BAD_REQUEST, "common.error.invalid_request") + } +} diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/fantalk/AiCharacterAdminFanTalkRepository.kt b/src/main/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/fantalk/AiCharacterAdminFanTalkRepository.kt new file mode 100644 index 00000000..57151417 --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/fantalk/AiCharacterAdminFanTalkRepository.kt @@ -0,0 +1,127 @@ +package kr.co.vividnext.sodalive.v2.api.admin.aicharacter.fantalk + +import com.querydsl.core.types.Projections +import com.querydsl.jpa.impl.JPAQueryFactory +import kr.co.vividnext.sodalive.explorer.profile.CreatorCheers +import kr.co.vividnext.sodalive.explorer.profile.QCreatorCheers +import kr.co.vividnext.sodalive.explorer.profile.QCreatorCheers.creatorCheers +import org.springframework.stereotype.Repository + +@Repository +class AiCharacterAdminFanTalkRepository( + private val queryFactory: JPAQueryFactory +) { + fun countActiveRoots(creatorMemberId: Long): Int { + return queryFactory + .select(creatorCheers.id.count()) + .from(creatorCheers) + .where( + creatorCheers.creator.id.eq(creatorMemberId), + creatorCheers.isActive.isTrue, + creatorCheers.parent.isNull + ) + .fetchOne() + ?.toInt() + ?: 0 + } + + fun findActiveRoots( + creatorMemberId: Long, + offset: Long, + limit: Int + ): List { + return queryFactory + .select( + Projections.constructor( + AiCharacterAdminFanTalkRecord::class.java, + creatorCheers.id, + creatorCheers.member.id, + creatorCheers.member.nickname, + creatorCheers.member.profileImage, + creatorCheers.cheers, + creatorCheers.createdAt + ) + ) + .from(creatorCheers) + .where( + creatorCheers.creator.id.eq(creatorMemberId), + creatorCheers.isActive.isTrue, + creatorCheers.parent.isNull + ) + .orderBy(creatorCheers.createdAt.desc(), creatorCheers.id.desc()) + .offset(offset) + .limit(limit.toLong()) + .fetch() + } + + fun findActiveCreatorReplies( + creatorMemberId: Long, + parentFanTalkIds: List + ): List { + if (parentFanTalkIds.isEmpty()) return emptyList() + + return queryFactory + .select( + Projections.constructor( + AiCharacterAdminFanTalkReplyRecord::class.java, + creatorCheers.id, + creatorCheers.parent.id, + creatorCheers.member.id, + creatorCheers.member.nickname, + creatorCheers.member.profileImage, + creatorCheers.cheers, + creatorCheers.createdAt + ) + ) + .from(creatorCheers) + .where( + creatorCheers.creator.id.eq(creatorMemberId), + creatorCheers.member.id.eq(creatorMemberId), + creatorCheers.isActive.isTrue, + creatorCheers.parent.id.`in`(parentFanTalkIds) + ) + .orderBy(creatorCheers.createdAt.asc(), creatorCheers.id.asc()) + .fetch() + } + + fun findActiveRoot(creatorMemberId: Long, fanTalkId: Long): CreatorCheers? { + return queryFactory + .selectFrom(creatorCheers) + .where( + creatorCheers.id.eq(fanTalkId), + creatorCheers.creator.id.eq(creatorMemberId), + creatorCheers.isActive.isTrue, + creatorCheers.parent.isNull + ) + .fetchOne() + } + + fun findFanRoot(creatorMemberId: Long, fanTalkId: Long): CreatorCheers? { + return queryFactory + .selectFrom(creatorCheers) + .where( + creatorCheers.id.eq(fanTalkId), + creatorCheers.creator.id.eq(creatorMemberId), + creatorCheers.member.id.ne(creatorMemberId), + creatorCheers.parent.isNull + ) + .fetchOne() + } + + fun findUpdatableReply(creatorMemberId: Long, fanTalkId: Long, replyId: Long): CreatorCheers? { + val root = QCreatorCheers("root") + return queryFactory + .selectFrom(creatorCheers) + .join(creatorCheers.parent, root) + .where( + creatorCheers.id.eq(replyId), + creatorCheers.creator.id.eq(creatorMemberId), + creatorCheers.member.id.eq(creatorMemberId), + root.id.eq(fanTalkId), + root.creator.id.eq(creatorMemberId), + root.isActive.isTrue, + root.parent.isNull + ) + .fetchOne() + } +} diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/fantalk/AiCharacterAdminFanTalkDeleteTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/fantalk/AiCharacterAdminFanTalkDeleteTest.kt new file mode 100644 index 00000000..8d7561fd --- /dev/null +++ b/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/fantalk/AiCharacterAdminFanTalkDeleteTest.kt @@ -0,0 +1,259 @@ +package kr.co.vividnext.sodalive.v2.api.admin.aicharacter.fantalk + +import com.fasterxml.jackson.databind.JsonNode +import com.fasterxml.jackson.databind.ObjectMapper +import kr.co.vividnext.sodalive.chat.character.service.ChatCharacterService +import kr.co.vividnext.sodalive.explorer.profile.CreatorCheers +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.support.EmbeddedRedisInitializer +import org.hamcrest.Matchers.nullValue +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.DisplayName +import org.junit.jupiter.api.Test +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc +import org.springframework.boot.test.context.SpringBootTest +import org.springframework.http.HttpHeaders +import org.springframework.http.MediaType +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken +import org.springframework.security.core.authority.SimpleGrantedAuthority +import org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.authentication +import org.springframework.test.context.ContextConfiguration +import org.springframework.test.web.servlet.MockMvc +import org.springframework.test.web.servlet.ResultActions +import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete +import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get +import org.springframework.test.web.servlet.result.MockMvcResultMatchers.content +import org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath +import org.springframework.test.web.servlet.result.MockMvcResultMatchers.status +import org.springframework.transaction.annotation.Transactional +import javax.persistence.EntityManager + +@SpringBootTest(properties = ["cloud.aws.cloud-front.host=https://test.cloudfront.net"]) +@AutoConfigureMockMvc +@Transactional +@ContextConfiguration(initializers = [EmbeddedRedisInitializer::class]) +class AiCharacterAdminFanTalkDeleteTest @Autowired constructor( + private val mockMvc: MockMvc, + private val chatCharacterService: ChatCharacterService, + private val objectMapper: ObjectMapper, + private val entityManager: EntityManager +) { + @Test + @DisplayName("관리자 FanTalk 삭제는 target 채널의 팬 작성 root만 soft delete하고 creator reply row를 유지한다") + fun shouldSoftDeleteFanRootOnlyAndKeepCreatorReplyRows() { + val character = createCharacter("fantalk-delete-character") + val creator = character.creatorMember!! + val admin = saveMember("fantalk-delete-admin", MemberRole.ADMIN) + val fan = saveMember("fantalk-delete-fan") + val root = saveCheers(fan, creator, "fan root") + val creatorReply = saveCheers(creator, creator, "creator reply", parent = root) + entityManager.flush() + entityManager.clear() + + deleteFanTalk(character.id!!, root.id!!, admin) + .andExpect(status().isOk) + .andExpect(content().contentTypeCompatibleWith(MediaType.APPLICATION_JSON)) + .andExpect(jsonPath("$.success").value(true)) + .andExpect(jsonPath("$.message").value(nullValue())) + .andExpect(jsonPath("$.data").value(nullValue())) + .andExpect(jsonPath("$.errorProperty").value(nullValue())) + + entityManager.flush() + entityManager.clear() + assertTrue(!findCheers(root.id!!).isActive) + assertTrue(findCheers(creatorReply.id!!).isActive) + + val data = getFanTalks(character.id!!, admin) + assertEquals(0, data.path("fanTalkCount").asInt()) + assertTrue(data.path("fanTalks").isEmpty) + } + + @Test + @DisplayName("관리자 FanTalk 삭제는 같은 target의 이미 비활성인 팬 root를 성공 no-op 처리한다") + fun shouldNoopInactiveFanRootInSameTarget() { + val character = createCharacter("fantalk-delete-inactive-character") + val creator = character.creatorMember!! + val admin = saveMember("fantalk-delete-inactive-admin", MemberRole.ADMIN) + val inactiveRoot = saveCheers( + member = saveMember("fantalk-delete-inactive-fan"), + creator = creator, + content = "inactive fan root", + isActive = false + ) + entityManager.flush() + entityManager.clear() + val beforeCount = countCheers() + + deleteFanTalk(character.id!!, inactiveRoot.id!!, admin) + .andExpect(status().isOk) + .andExpect(jsonPath("$.success").value(true)) + .andExpect(jsonPath("$.data").value(nullValue())) + + entityManager.flush() + entityManager.clear() + assertEquals(beforeCount, countCheers()) + assertTrue(!findCheers(inactiveRoot.id!!).isActive) + } + + @Test + @DisplayName("관리자 FanTalk 삭제는 target AI 작성 root를 저장 변경 없이 400으로 거부한다") + fun shouldRejectCreatorAuthoredRootWithoutMutation() { + val character = createCharacter("fantalk-delete-creator-root") + val creator = character.creatorMember!! + val admin = saveMember("fantalk-delete-creator-root-admin", MemberRole.ADMIN) + val creatorRoot = saveCheers(creator, creator, "creator root") + + assertRejectedDelete(character.id!!, creatorRoot.id!!, admin, creatorRoot) + } + + @Test + @DisplayName("관리자 FanTalk 삭제는 reply row를 저장 변경 없이 400으로 거부한다") + fun shouldRejectReplyRowWithoutMutation() { + val character = createCharacter("fantalk-delete-reply") + val creator = character.creatorMember!! + val admin = saveMember("fantalk-delete-reply-admin", MemberRole.ADMIN) + val root = saveCheers(saveMember("fantalk-delete-reply-fan"), creator, "root") + val reply = saveCheers(creator, creator, "reply", parent = root) + + assertRejectedDelete(character.id!!, reply.id!!, admin, reply) + assertTrue(findCheers(root.id!!).isActive) + } + + @Test + @DisplayName("관리자 FanTalk 삭제는 다른 채널 root를 저장 변경 없이 400으로 거부한다") + fun shouldRejectCrossCharacterRootWithoutMutation() { + val targetCharacter = createCharacter("fantalk-delete-cross-target") + val otherCharacter = createCharacter("fantalk-delete-cross-other") + val admin = saveMember("fantalk-delete-cross-admin", MemberRole.ADMIN) + val otherRoot = saveCheers( + member = saveMember("fantalk-delete-cross-fan"), + creator = otherCharacter.creatorMember!!, + content = "other root" + ) + + assertRejectedDelete(targetCharacter.id!!, otherRoot.id!!, admin, otherRoot) + } + + @Test + @DisplayName("관리자 FanTalk 삭제는 존재하지 않는 FanTalk ID를 공통 오류 envelope로 거부한다") + fun shouldRejectMissingFanTalkIdWithoutMutation() { + val character = createCharacter("fantalk-delete-missing") + val admin = saveMember("fantalk-delete-missing-admin", MemberRole.ADMIN) + entityManager.flush() + entityManager.clear() + val beforeCount = countCheers() + + deleteFanTalk(character.id!!, Long.MAX_VALUE, admin, language = "en") + .andExpect(status().isBadRequest) + .andExpect(content().contentTypeCompatibleWith(MediaType.APPLICATION_JSON)) + .andExpect(jsonPath("$.success").value(false)) + .andExpect(jsonPath("$.message").value("Invalid request.")) + .andExpect(jsonPath("$.data").value(nullValue())) + .andExpect(jsonPath("$.errorProperty").value(nullValue())) + + assertEquals(beforeCount, countCheers()) + } + + @Test + @DisplayName("관리자 FanTalk 삭제는 비활성 AI 캐릭터 target을 공통 오류 envelope로 거부한다") + fun shouldRejectInactiveTargetWithoutMutation() { + val character = createCharacter("fantalk-delete-inactive-target").apply { isActive = false } + val creator = character.creatorMember!! + val admin = saveMember("fantalk-delete-inactive-target-admin", MemberRole.ADMIN) + val root = saveCheers(saveMember("fantalk-delete-inactive-target-fan"), creator, "root") + + assertRejectedDelete(character.id!!, root.id!!, admin, root) + } + + private fun assertRejectedDelete(characterId: Long, fanTalkId: Long, admin: Member, targetRow: CreatorCheers) { + entityManager.flush() + entityManager.clear() + val beforeCount = countCheers() + val beforeActive = findCheers(targetRow.id!!).isActive + + deleteFanTalk(characterId, fanTalkId, admin, language = "en") + .andExpect(status().isBadRequest) + .andExpect(content().contentTypeCompatibleWith(MediaType.APPLICATION_JSON)) + .andExpect(jsonPath("$.success").value(false)) + .andExpect(jsonPath("$.message").value("Invalid request.")) + .andExpect(jsonPath("$.data").value(nullValue())) + .andExpect(jsonPath("$.errorProperty").value(nullValue())) + + entityManager.flush() + entityManager.clear() + assertEquals(beforeCount, countCheers()) + assertEquals(beforeActive, findCheers(targetRow.id!!).isActive) + } + + private fun deleteFanTalk( + characterId: Long, + fanTalkId: Long, + admin: Member, + language: String = "ko" + ): ResultActions { + return mockMvc.perform( + delete("/api/v2/admin/ai-characters/$characterId/fan-talks/$fanTalkId") + .header(HttpHeaders.ACCEPT_LANGUAGE, language) + .with(adminAuthentication(admin)) + ) + } + + private fun getFanTalks(characterId: Long, admin: Member): JsonNode { + val result = mockMvc.perform( + get("/api/v2/admin/ai-characters/$characterId/fan-talks") + .with(adminAuthentication(admin)) + ) + .andExpect(status().isOk) + .andReturn() + return objectMapper.readTree(String(result.response.contentAsByteArray, Charsets.UTF_8)).path("data") + } + + private fun createCharacter(name: String) = chatCharacterService.createChatCharacterWithDetails( + characterUUID = name, + name = name, + description = "description", + systemPrompt = "prompt" + ) + + private fun saveMember(prefix: String, role: MemberRole = MemberRole.USER): Member { + return Member( + email = "$prefix@test.com", + password = "password", + nickname = prefix, + role = role + ).also(entityManager::persist) + } + + private fun saveCheers( + member: Member, + creator: Member, + content: String, + parent: CreatorCheers? = null, + isActive: Boolean = true + ): CreatorCheers { + return CreatorCheers(cheers = content, languageCode = "ko", isActive = isActive).apply { + this.member = member + this.creator = creator + this.parent = parent + }.also(entityManager::persist) + } + + private fun findCheers(id: Long): CreatorCheers = entityManager.find(CreatorCheers::class.java, id) + + private fun countCheers(): Long = entityManager.createQuery( + "select count(c) from CreatorCheers c", + java.lang.Long::class.java + ).singleResult.toLong() + + private fun adminAuthentication(admin: Member) = authentication( + UsernamePasswordAuthenticationToken( + MemberAdapter(admin), + "token", + listOf(SimpleGrantedAuthority("ROLE_ADMIN")) + ) + ) +} diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/fantalk/AiCharacterAdminFanTalkQueryTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/fantalk/AiCharacterAdminFanTalkQueryTest.kt new file mode 100644 index 00000000..8e84dc6b --- /dev/null +++ b/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/fantalk/AiCharacterAdminFanTalkQueryTest.kt @@ -0,0 +1,263 @@ +package kr.co.vividnext.sodalive.v2.api.admin.aicharacter.fantalk + +import com.fasterxml.jackson.databind.JsonNode +import com.fasterxml.jackson.databind.ObjectMapper +import kr.co.vividnext.sodalive.chat.character.service.ChatCharacterService +import kr.co.vividnext.sodalive.explorer.profile.CreatorCheers +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.member.block.BlockMember +import kr.co.vividnext.sodalive.support.EmbeddedRedisInitializer +import org.hamcrest.Matchers.nullValue +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.DisplayName +import org.junit.jupiter.api.Test +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc +import org.springframework.boot.test.context.SpringBootTest +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken +import org.springframework.security.core.authority.SimpleGrantedAuthority +import org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.authentication +import org.springframework.test.context.ContextConfiguration +import org.springframework.test.web.servlet.MockMvc +import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get +import org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath +import org.springframework.test.web.servlet.result.MockMvcResultMatchers.status +import org.springframework.transaction.annotation.Transactional +import java.time.LocalDateTime +import javax.persistence.EntityManager + +@SpringBootTest(properties = ["cloud.aws.cloud-front.host=https://test.cloudfront.net"]) +@AutoConfigureMockMvc +@Transactional +@ContextConfiguration(initializers = [EmbeddedRedisInitializer::class]) +class AiCharacterAdminFanTalkQueryTest @Autowired constructor( + private val mockMvc: MockMvc, + private val chatCharacterService: ChatCharacterService, + private val objectMapper: ObjectMapper, + private val entityManager: EntityManager +) { + @Test + @DisplayName("관리자 FanTalk 목록은 target 소유 활성 root와 creator reply만 공개 v2 필드 형태로 반환한다") + fun shouldReturnOwnerScopedActiveRootsAndCreatorRepliesWithExactPublicFields() { + val character = createCharacter("fantalk-query-character") + val owner = character.creatorMember!! + val admin = saveMember("fantalk-query-admin", MemberRole.ADMIN) + val blockedWriter = saveMember("fantalk-query-blocked-writer") + val writer = saveMember("fantalk-query-writer") + val otherCharacter = createCharacter("fantalk-query-other-character") + val createdAt = LocalDateTime.of(2026, 7, 28, 12, 0) + val firstRoot = saveCheers(blockedWriter, owner, "first root", true, createdAt) + val secondRoot = saveCheers(writer, owner, "second root", true, createdAt) + saveCheers(writer, owner, "inactive root", false, createdAt.plusMinutes(1)) + saveCheers(writer, otherCharacter.creatorMember!!, "foreign root", true, createdAt.plusMinutes(2)) + val firstCreatorReply = saveCheers(owner, owner, "first creator reply", true, createdAt.plusMinutes(3), secondRoot) + val secondCreatorReply = saveCheers(owner, owner, "second creator reply", true, createdAt.plusMinutes(3), secondRoot) + saveCheers(writer, owner, "fan reply", true, createdAt.plusMinutes(4), secondRoot) + saveCheers(owner, owner, "inactive creator reply", false, createdAt.plusMinutes(5), secondRoot) + saveCheers(owner, owner, "nested creator reply", true, createdAt.plusMinutes(6), firstCreatorReply) + saveBlock(admin, blockedWriter) + entityManager.flush() + entityManager.clear() + + val data = getFanTalks(character.id!!, admin) + + assertEquals(setOf("fanTalkCount", "fanTalks", "page", "size", "hasNext"), data.fieldNames().asSequence().toSet()) + assertEquals(2, data.path("fanTalkCount").asInt()) + assertEquals(listOf(secondRoot.id, firstRoot.id), data.path("fanTalks").map { it.path("fanTalkId").asLong() }) + assertEquals(0, data.path("page").asInt()) + assertEquals(20, data.path("size").asInt()) + assertTrue(!data.path("hasNext").asBoolean()) + + val root = data.path("fanTalks").first() + assertEquals( + setOf( + "fanTalkId", + "writerId", + "writerNickname", + "writerProfileImageUrl", + "content", + "createdAtUtc", + "creatorReplies" + ), + root.fieldNames().asSequence().toSet() + ) + assertEquals(secondRoot.id, root.path("fanTalkId").asLong()) + assertEquals(writer.id, root.path("writerId").asLong()) + assertEquals(writer.nickname, root.path("writerNickname").asText()) + assertEquals(profileUrl(writer), root.path("writerProfileImageUrl").asText()) + assertEquals("second root", root.path("content").asText()) + assertEquals("2026-07-28T12:00:00Z", root.path("createdAtUtc").asText()) + assertEquals( + listOf(firstCreatorReply.id, secondCreatorReply.id), + root.path("creatorReplies").map { it.path("fanTalkId").asLong() } + ) + + val reply = root.path("creatorReplies").first() + assertEquals( + setOf( + "fanTalkId", + "writerId", + "writerNickname", + "writerProfileImageUrl", + "content", + "createdAtUtc" + ), + reply.fieldNames().asSequence().toSet() + ) + assertEquals(firstCreatorReply.id, reply.path("fanTalkId").asLong()) + assertEquals(owner.id, reply.path("writerId").asLong()) + assertEquals(owner.nickname, reply.path("writerNickname").asText()) + assertEquals(profileUrl(owner), reply.path("writerProfileImageUrl").asText()) + assertEquals("first creator reply", reply.path("content").asText()) + assertEquals("2026-07-28T12:03:00Z", reply.path("createdAtUtc").asText()) + } + + @Test + @DisplayName("관리자 FanTalk 목록은 page size hasNext와 전체 count를 유지한다") + fun shouldApplyPaginationAndKeepTotalCount() { + val character = createCharacter("fantalk-pagination-character") + val owner = character.creatorMember!! + val admin = saveMember("fantalk-pagination-admin", MemberRole.ADMIN) + val writer = saveMember("fantalk-pagination-writer") + val createdAt = LocalDateTime.of(2026, 7, 28, 13, 0) + saveCheers(writer, owner, "oldest", true, createdAt) + saveCheers(writer, owner, "middle", true, createdAt.plusMinutes(1)) + saveCheers(writer, owner, "newest", true, createdAt.plusMinutes(2)) + entityManager.flush() + entityManager.clear() + + val middlePage = getFanTalks(character.id!!, admin, page = 1, size = 20) + val lastPage = getFanTalks(character.id!!, admin, page = 2, size = 20) + + assertEquals(3, middlePage.path("fanTalkCount").asInt()) + assertTrue(middlePage.path("fanTalks").isEmpty) + assertEquals(1, middlePage.path("page").asInt()) + assertEquals(20, middlePage.path("size").asInt()) + assertTrue(!middlePage.path("hasNext").asBoolean()) + assertEquals(3, lastPage.path("fanTalkCount").asInt()) + assertTrue(lastPage.path("fanTalks").isEmpty) + assertEquals(2, lastPage.path("page").asInt()) + assertEquals(20, lastPage.path("size").asInt()) + assertTrue(!lastPage.path("hasNext").asBoolean()) + } + + @Test + @DisplayName("비활성 AI 캐릭터 target의 FanTalk 목록을 400으로 거부한다") + fun shouldRejectInactiveTarget() { + val character = createCharacter("fantalk-inactive-character") + val admin = saveMember("fantalk-inactive-admin", MemberRole.ADMIN) + character.isActive = false + entityManager.flush() + entityManager.clear() + + mockMvc.perform( + get("/api/v2/admin/ai-characters/${character.id}/fan-talks") + .header("Accept-Language", "en") + .with(adminAuthentication(admin)) + ) + .andExpect(status().isBadRequest) + .andExpect(jsonPath("$.success").value(false)) + .andExpect(jsonPath("$.message").value("Invalid request.")) + .andExpect(jsonPath("$.data").value(nullValue())) + .andExpect(jsonPath("$.errorProperty").value(nullValue())) + } + + @Test + @DisplayName("관리자 FanTalk 목록은 공개 v2와 같이 pagination 범위를 보정한다") + fun shouldNormalizeInvalidPaginationLikePublicV2() { + val character = createCharacter("fantalk-invalid-page-character") + val admin = saveMember("fantalk-invalid-page-admin", MemberRole.ADMIN) + val writer = saveMember("fantalk-invalid-page-writer") + saveCheers(writer, character.creatorMember!!, "pagination root", true, LocalDateTime.of(2026, 7, 28, 14, 0)) + entityManager.flush() + entityManager.clear() + + listOf( + Triple(-1, 20, 0 to 20), + Triple(0, 0, 0 to 20), + Triple(0, 1, 0 to 20), + Triple(0, 51, 0 to 50) + ).forEach { (page, size, expected) -> + val data = getFanTalks(character.id!!, admin, page = page, size = size) + + assertEquals(expected.first, data.path("page").asInt()) + assertEquals(expected.second, data.path("size").asInt()) + } + } + + private fun getFanTalks(characterId: Long, admin: Member, page: Int = 0, size: Int = 20): JsonNode { + val result = mockMvc.perform( + get("/api/v2/admin/ai-characters/$characterId/fan-talks") + .param("page", page.toString()) + .param("size", size.toString()) + .with(adminAuthentication(admin)) + ) + .andExpect(status().isOk) + .andReturn() + return objectMapper.readTree(String(result.response.contentAsByteArray, Charsets.UTF_8)).path("data") + } + + private fun createCharacter(name: String) = chatCharacterService.createChatCharacterWithDetails( + characterUUID = name, + name = name, + description = "description", + systemPrompt = "prompt" + ) + + private fun saveMember(prefix: String, role: MemberRole = MemberRole.USER): Member { + return Member( + email = "$prefix@test.com", + password = "password", + nickname = prefix, + profileImage = "$prefix.png", + role = role + ).also(entityManager::persist) + } + + private fun saveCheers( + member: Member, + creator: Member, + content: String, + isActive: Boolean, + createdAt: LocalDateTime, + parent: CreatorCheers? = null + ): CreatorCheers { + val cheers = CreatorCheers(cheers = content, languageCode = "ko", isActive = isActive).apply { + this.member = member + this.creator = creator + this.parent = parent + } + entityManager.persist(cheers) + entityManager.flush() + entityManager.createQuery("update CreatorCheers e set e.createdAt = :createdAt where e.id = :id") + .setParameter("createdAt", createdAt) + .setParameter("id", cheers.id!!) + .executeUpdate() + return cheers + } + + private fun saveBlock(member: Member, blockedMember: Member) { + entityManager.persist( + BlockMember().apply { + this.member = member + this.blockedMember = blockedMember + } + ) + } + + private fun profileUrl(member: Member): String { + return member.profileImage?.let { "https://test.cloudfront.net/$it" } + ?: "https://test.cloudfront.net/profile/default-profile.png" + } + + private fun adminAuthentication(admin: Member) = authentication( + UsernamePasswordAuthenticationToken( + MemberAdapter(admin), + "token", + listOf(SimpleGrantedAuthority("ROLE_ADMIN")) + ) + ) +} diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/fantalk/AiCharacterAdminFanTalkReplyContractTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/fantalk/AiCharacterAdminFanTalkReplyContractTest.kt new file mode 100644 index 00000000..b594f3bb --- /dev/null +++ b/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/fantalk/AiCharacterAdminFanTalkReplyContractTest.kt @@ -0,0 +1,208 @@ +package kr.co.vividnext.sodalive.v2.api.admin.aicharacter.fantalk + +import kr.co.vividnext.sodalive.chat.character.service.ChatCharacterService +import kr.co.vividnext.sodalive.explorer.profile.CreatorCheers +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.support.EmbeddedRedisInitializer +import org.hamcrest.Matchers.nullValue +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.DisplayName +import org.junit.jupiter.params.ParameterizedTest +import org.junit.jupiter.params.provider.Arguments +import org.junit.jupiter.params.provider.MethodSource +import org.mockito.Mockito +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc +import org.springframework.boot.test.context.SpringBootTest +import org.springframework.context.ApplicationEventPublisher +import org.springframework.http.HttpHeaders +import org.springframework.http.MediaType +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken +import org.springframework.security.core.authority.SimpleGrantedAuthority +import org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.authentication +import org.springframework.test.context.ContextConfiguration +import org.springframework.test.util.AopTestUtils +import org.springframework.test.util.ReflectionTestUtils +import org.springframework.test.web.servlet.MockMvc +import org.springframework.test.web.servlet.request.MockHttpServletRequestBuilder +import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post +import org.springframework.test.web.servlet.result.MockMvcResultMatchers.content +import org.springframework.test.web.servlet.result.MockMvcResultMatchers.header +import org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath +import org.springframework.test.web.servlet.result.MockMvcResultMatchers.status +import org.springframework.transaction.annotation.Transactional +import java.util.stream.Stream +import javax.persistence.EntityManager + +@SpringBootTest(properties = ["cloud.aws.cloud-front.host=https://test.cloudfront.net"]) +@AutoConfigureMockMvc +@Transactional +@ContextConfiguration(initializers = [EmbeddedRedisInitializer::class]) +class AiCharacterAdminFanTalkReplyContractTest @Autowired constructor( + private val mockMvc: MockMvc, + private val chatCharacterService: ChatCharacterService, + private val entityManager: EntityManager, + private val fanTalkFacade: AiCharacterAdminFanTalkFacade +) { + private val applicationEventPublisher = Mockito.mock(ApplicationEventPublisher::class.java) + + @ParameterizedTest + @MethodSource("localizedMessages") + @DisplayName("FanTalk reply는 빈 문자열과 공백 content를 저장이나 언어 감지 전에 거부한다") + fun shouldRejectEmptyOrBlankContentBeforeSavingOrPublishing(language: String, message: String) { + assertInvalidReply(language, message, """{"content":""}""") + assertInvalidReply(language, message, """{"content":" "}""") + } + + @ParameterizedTest + @MethodSource("localizedMessages") + @DisplayName("FanTalk reply는 malformed, missing body와 content binding 오류를 공통 오류 envelope로 반환한다") + fun shouldReturnInvalidRequestEnvelopeForMalformedOrMissingRequestBody(language: String, message: String) { + assertInvalidReply(language, message, """{"content":"unterminated""", includeBody = true) + assertInvalidReply(language, message, "{}") + assertInvalidReply(language, message, requestBody = null) + } + + @ParameterizedTest + @MethodSource("localizedMessages") + @DisplayName("FanTalk reply는 계약 밖 필드를 저장이나 언어 감지 전에 거부한다") + fun shouldRejectUnknownFieldsBeforeSavingOrPublishing(language: String, message: String) { + assertInvalidReply(language, message, """{"content":"reply","extra":"ignored"}""") + } + + @ParameterizedTest + @MethodSource("localizedMessages") + @DisplayName("FanTalk reply는 application/json이 아닌 요청을 저장이나 언어 감지 전에 415로 거부한다") + fun shouldRejectUnsupportedMediaTypeBeforeSavingOrPublishing(language: String, message: String) { + val character = createCharacter("fantalk-reply-media-type-$language") + val creator = character.creatorMember!! + val admin = saveMember("fantalk-reply-media-type-admin-$language", MemberRole.ADMIN) + val root = saveRoot(saveMember("fantalk-reply-media-type-writer-$language"), creator) + entityManager.flush() + entityManager.clear() + val beforeCount = countCheers() + val originalPublisher = replaceFanTalkFacadePublisher(applicationEventPublisher) + + try { + mockMvc.perform( + replyRequest(character.id!!, root.id!!, admin, language, """{"content":"reply"}""") + .contentType(MediaType.TEXT_PLAIN) + ) + .andExpect(status().isUnsupportedMediaType) + .andExpect(content().contentTypeCompatibleWith(MediaType.APPLICATION_JSON)) + .andExpect(jsonPath("$.success").value(false)) + .andExpect(jsonPath("$.message").value(message)) + .andExpect(jsonPath("$.data").value(nullValue())) + .andExpect(jsonPath("$.errorProperty").value(nullValue())) + .andExpect(header().string(HttpHeaders.ACCEPT, MediaType.APPLICATION_JSON_VALUE)) + } finally { + replaceFanTalkFacadePublisher(originalPublisher) + } + + assertEquals(beforeCount, countCheers()) + Mockito.verifyNoInteractions(applicationEventPublisher) + } + + private fun assertInvalidReply( + language: String, + message: String, + requestBody: String, + includeBody: Boolean = true + ) { + assertInvalidReply(language, message, requestBody.takeIf { includeBody }) + } + + private fun assertInvalidReply(language: String, message: String, requestBody: String?) { + val character = createCharacter("fantalk-reply-contract-$language-${requestBody.hashCode()}") + val creator = character.creatorMember!! + val admin = saveMember("fantalk-reply-contract-admin-$language-${requestBody.hashCode()}", MemberRole.ADMIN) + val root = saveRoot(saveMember("fantalk-reply-contract-writer-$language-${requestBody.hashCode()}"), creator) + entityManager.flush() + entityManager.clear() + val beforeCount = countCheers() + val originalPublisher = replaceFanTalkFacadePublisher(applicationEventPublisher) + + try { + mockMvc.perform(replyRequest(character.id!!, root.id!!, admin, language, requestBody)) + .andExpect(status().isBadRequest) + .andExpect(content().contentTypeCompatibleWith(MediaType.APPLICATION_JSON)) + .andExpect(jsonPath("$.success").value(false)) + .andExpect(jsonPath("$.message").value(message)) + .andExpect(jsonPath("$.data").value(nullValue())) + .andExpect(jsonPath("$.errorProperty").value(nullValue())) + } finally { + replaceFanTalkFacadePublisher(originalPublisher) + } + + assertEquals(beforeCount, countCheers()) + Mockito.verifyNoInteractions(applicationEventPublisher) + } + + private fun replyRequest( + characterId: Long, + fanTalkId: Long, + admin: Member, + language: String, + requestBody: String? + ): MockHttpServletRequestBuilder { + return post("/api/v2/admin/ai-characters/$characterId/fan-talks/$fanTalkId/replies") + .contentType(MediaType.APPLICATION_JSON) + .header(HttpHeaders.ACCEPT_LANGUAGE, language) + .with(adminAuthentication(admin)) + .also { request -> requestBody?.let(request::content) } + } + + private fun createCharacter(name: String) = chatCharacterService.createChatCharacterWithDetails( + characterUUID = name, + name = name, + description = "description", + systemPrompt = "prompt" + ) + + private fun saveMember(prefix: String, role: MemberRole = MemberRole.USER): Member { + return Member( + email = "$prefix@test.com", + password = "password", + nickname = prefix, + role = role + ).also(entityManager::persist) + } + + private fun saveRoot(member: Member, creator: Member): CreatorCheers { + return CreatorCheers(cheers = "root", languageCode = "ko").apply { + this.member = member + this.creator = creator + }.also(entityManager::persist) + } + + private fun countCheers(): Long = entityManager.createQuery( + "select count(c) from CreatorCheers c", + java.lang.Long::class.java + ).singleResult.toLong() + + private fun adminAuthentication(admin: Member) = authentication( + UsernamePasswordAuthenticationToken( + MemberAdapter(admin), + "token", + listOf(SimpleGrantedAuthority("ROLE_ADMIN")) + ) + ) + + private fun replaceFanTalkFacadePublisher(publisher: ApplicationEventPublisher): ApplicationEventPublisher { + val target = AopTestUtils.getTargetObject(fanTalkFacade) + val original = ReflectionTestUtils.getField(target, "applicationEventPublisher") as ApplicationEventPublisher + ReflectionTestUtils.setField(target, "applicationEventPublisher", publisher) + return original + } + + private companion object { + @JvmStatic + fun localizedMessages(): Stream = Stream.of( + Arguments.of("ko", "잘못된 요청입니다."), + Arguments.of("en", "Invalid request."), + Arguments.of("ja", "無効なリクエストです。") + ) + } +} diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/fantalk/AiCharacterAdminFanTalkReplyCreateTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/fantalk/AiCharacterAdminFanTalkReplyCreateTest.kt new file mode 100644 index 00000000..a7e9d6c6 --- /dev/null +++ b/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/fantalk/AiCharacterAdminFanTalkReplyCreateTest.kt @@ -0,0 +1,146 @@ +package kr.co.vividnext.sodalive.v2.api.admin.aicharacter.fantalk + +import com.fasterxml.jackson.databind.JsonNode +import com.fasterxml.jackson.databind.ObjectMapper +import kr.co.vividnext.sodalive.chat.character.service.ChatCharacterService +import kr.co.vividnext.sodalive.content.LanguageDetectEvent +import kr.co.vividnext.sodalive.content.LanguageDetectTargetType +import kr.co.vividnext.sodalive.explorer.profile.CreatorCheers +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.support.EmbeddedRedisInitializer +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertNotNull +import org.junit.jupiter.api.Assertions.assertNull +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.DisplayName +import org.junit.jupiter.api.Test +import org.mockito.ArgumentCaptor +import org.mockito.Mockito +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc +import org.springframework.boot.test.context.SpringBootTest +import org.springframework.context.ApplicationEventPublisher +import org.springframework.http.MediaType +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken +import org.springframework.security.core.authority.SimpleGrantedAuthority +import org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.authentication +import org.springframework.test.context.ContextConfiguration +import org.springframework.test.util.AopTestUtils +import org.springframework.test.util.ReflectionTestUtils +import org.springframework.test.web.servlet.MockMvc +import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post +import org.springframework.test.web.servlet.result.MockMvcResultMatchers.status +import org.springframework.transaction.annotation.Transactional +import java.time.OffsetDateTime +import javax.persistence.EntityManager + +@SpringBootTest(properties = ["cloud.aws.cloud-front.host=https://test.cloudfront.net"]) +@AutoConfigureMockMvc +@Transactional +@ContextConfiguration(initializers = [EmbeddedRedisInitializer::class]) +class AiCharacterAdminFanTalkReplyCreateTest @Autowired constructor( + private val mockMvc: MockMvc, + private val chatCharacterService: ChatCharacterService, + private val objectMapper: ObjectMapper, + private val entityManager: EntityManager, + private val fanTalkFacade: AiCharacterAdminFanTalkFacade +) { + private val applicationEventPublisher = Mockito.mock(ApplicationEventPublisher::class.java) + + @Test + @DisplayName("관리자 FanTalk root 답변은 target creator로 저장하고 언어 감지 이벤트를 발행한다") + fun shouldCreateReplyForActiveRootWithTargetOwnerAndPublishLanguageDetection() { + val character = createCharacter("fantalk-reply-character") + val creator = character.creatorMember!! + val admin = saveMember("fantalk-reply-admin", MemberRole.ADMIN) + val writer = saveMember("fantalk-reply-writer") + val root = saveRoot(writer, creator, "root fan talk") + val content = "thank you for your support" + entityManager.flush() + entityManager.clear() + + val originalPublisher = replaceFanTalkFacadePublisher(applicationEventPublisher) + val data = try { + createReply(character.id!!, root.id!!, content, admin) + } finally { + replaceFanTalkFacadePublisher(originalPublisher) + } + + assertEquals( + setOf("fanTalkId", "replyId", "creatorMemberId", "content", "createdAtUtc"), + data.fieldNames().asSequence().toSet() + ) + assertEquals(root.id, data.path("fanTalkId").asLong()) + val replyId = data.path("replyId").asLong() + assertTrue(replyId > 0) + assertEquals(creator.id, data.path("creatorMemberId").asLong()) + assertEquals(content, data.path("content").asText()) + assertNotNull(OffsetDateTime.parse(data.path("createdAtUtc").asText())) + + val reply = entityManager.find(CreatorCheers::class.java, replyId) + assertNotNull(reply) + assertEquals(root.id, reply!!.parent!!.id) + assertEquals(creator.id, reply.member!!.id) + assertEquals(creator.id, reply.creator!!.id) + assertTrue(reply.member!!.id != admin.id) + assertNull(reply.languageCode) + + val eventCaptor = ArgumentCaptor.forClass(LanguageDetectEvent::class.java) + Mockito.verify(applicationEventPublisher).publishEvent(eventCaptor.capture()) + assertEquals(replyId, eventCaptor.value.id) + assertEquals(content, eventCaptor.value.query) + assertEquals(LanguageDetectTargetType.CREATOR_CHEERS, eventCaptor.value.targetType) + } + + private fun createReply(characterId: Long, fanTalkId: Long, content: String, admin: Member): JsonNode { + val result = mockMvc.perform( + post("/api/v2/admin/ai-characters/$characterId/fan-talks/$fanTalkId/replies") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsBytes(mapOf("content" to content))) + .with(adminAuthentication(admin)) + ) + .andExpect(status().isOk) + .andReturn() + return objectMapper.readTree(String(result.response.contentAsByteArray, Charsets.UTF_8)).path("data") + } + + private fun createCharacter(name: String) = chatCharacterService.createChatCharacterWithDetails( + characterUUID = name, + name = name, + description = "description", + systemPrompt = "prompt" + ) + + private fun saveMember(prefix: String, role: MemberRole = MemberRole.USER): Member { + return Member( + email = "$prefix@test.com", + password = "password", + nickname = prefix, + role = role + ).also(entityManager::persist) + } + + private fun saveRoot(member: Member, creator: Member, content: String): CreatorCheers { + return CreatorCheers(cheers = content, languageCode = "ko").apply { + this.member = member + this.creator = creator + }.also(entityManager::persist) + } + + private fun adminAuthentication(admin: Member) = authentication( + UsernamePasswordAuthenticationToken( + MemberAdapter(admin), + "token", + listOf(SimpleGrantedAuthority("ROLE_ADMIN")) + ) + ) + + private fun replaceFanTalkFacadePublisher(publisher: ApplicationEventPublisher): ApplicationEventPublisher { + val target = AopTestUtils.getTargetObject(fanTalkFacade) + val original = ReflectionTestUtils.getField(target, "applicationEventPublisher") as ApplicationEventPublisher + ReflectionTestUtils.setField(target, "applicationEventPublisher", publisher) + return original + } +} diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/fantalk/AiCharacterAdminFanTalkReplyOwnershipTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/fantalk/AiCharacterAdminFanTalkReplyOwnershipTest.kt new file mode 100644 index 00000000..1a4c2819 --- /dev/null +++ b/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/fantalk/AiCharacterAdminFanTalkReplyOwnershipTest.kt @@ -0,0 +1,224 @@ +package kr.co.vividnext.sodalive.v2.api.admin.aicharacter.fantalk + +import com.fasterxml.jackson.databind.ObjectMapper +import kr.co.vividnext.sodalive.chat.character.service.ChatCharacterService +import kr.co.vividnext.sodalive.explorer.profile.CreatorCheers +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.support.EmbeddedRedisInitializer +import org.hamcrest.Matchers.nullValue +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.DisplayName +import org.junit.jupiter.params.ParameterizedTest +import org.junit.jupiter.params.provider.CsvSource +import org.mockito.Mockito +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc +import org.springframework.boot.test.context.SpringBootTest +import org.springframework.context.ApplicationEventPublisher +import org.springframework.http.HttpHeaders +import org.springframework.http.MediaType +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken +import org.springframework.security.core.authority.SimpleGrantedAuthority +import org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.authentication +import org.springframework.test.context.ContextConfiguration +import org.springframework.test.util.AopTestUtils +import org.springframework.test.util.ReflectionTestUtils +import org.springframework.test.web.servlet.MockMvc +import org.springframework.test.web.servlet.ResultActions +import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post +import org.springframework.test.web.servlet.result.MockMvcResultMatchers.content +import org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath +import org.springframework.test.web.servlet.result.MockMvcResultMatchers.status +import org.springframework.transaction.annotation.Transactional +import javax.persistence.EntityManager + +@SpringBootTest(properties = ["cloud.aws.cloud-front.host=https://test.cloudfront.net"]) +@AutoConfigureMockMvc +@Transactional +@ContextConfiguration(initializers = [EmbeddedRedisInitializer::class]) +class AiCharacterAdminFanTalkReplyOwnershipTest @Autowired constructor( + private val mockMvc: MockMvc, + private val chatCharacterService: ChatCharacterService, + private val objectMapper: ObjectMapper, + private val entityManager: EntityManager, + private val fanTalkFacade: AiCharacterAdminFanTalkFacade +) { + private val applicationEventPublisher = Mockito.mock(ApplicationEventPublisher::class.java) + + @ParameterizedTest + @CsvSource( + "ko,잘못된 요청입니다.", + "en,Invalid request.", + "ja,無効なリクエストです。" + ) + @DisplayName("다른 AI 캐릭터의 root FanTalk 답변은 현지화된 invalid request로 거부한다") + fun shouldRejectCrossCharacterRootBeforeReplySaveAndLanguageDetection(language: String, message: String) { + val targetCharacter = createCharacter("fantalk-ownership-target") + val otherCharacter = createCharacter("fantalk-ownership-other") + val admin = saveMember("fantalk-ownership-admin", MemberRole.ADMIN) + val root = saveCheers( + member = saveMember("fantalk-ownership-writer"), + creator = otherCharacter.creatorMember!!, + content = "other character root" + ) + + assertRejectedReply(targetCharacter.id!!, root.id!!, admin, language, message) + } + + @ParameterizedTest + @CsvSource( + "ko,잘못된 요청입니다.", + "en,Invalid request.", + "ja,無効なリクエストです。" + ) + @DisplayName("nested FanTalk 답변은 현지화된 invalid request로 거부한다") + fun shouldRejectNestedFanTalkBeforeReplySaveAndLanguageDetection(language: String, message: String) { + val character = createCharacter("fantalk-ownership-nested") + val creator = character.creatorMember!! + val admin = saveMember("fantalk-ownership-nested-admin", MemberRole.ADMIN) + val root = saveCheers(saveMember("fantalk-ownership-nested-writer"), creator, "root") + val nested = saveCheers(creator, creator, "nested", parent = root) + + assertRejectedReply(character.id!!, nested.id!!, admin, language, message) + } + + @ParameterizedTest + @CsvSource( + "ko,잘못된 요청입니다.", + "en,Invalid request.", + "ja,無効なリクエストです。" + ) + @DisplayName("비활성 root FanTalk 답변은 현지화된 invalid request로 거부한다") + fun shouldRejectInactiveRootBeforeReplySaveAndLanguageDetection(language: String, message: String) { + val character = createCharacter("fantalk-ownership-inactive-root") + val creator = character.creatorMember!! + val admin = saveMember("fantalk-ownership-inactive-root-admin", MemberRole.ADMIN) + val root = saveCheers( + member = saveMember("fantalk-ownership-inactive-root-writer"), + creator = creator, + content = "inactive root", + isActive = false + ) + + assertRejectedReply(character.id!!, root.id!!, admin, language, message) + } + + @ParameterizedTest + @CsvSource( + "ko,잘못된 요청입니다.", + "en,Invalid request.", + "ja,無効なリクエストです。" + ) + @DisplayName("존재하지 않는 FanTalk 답변은 현지화된 invalid request로 거부한다") + fun shouldRejectMissingFanTalkBeforeReplySaveAndLanguageDetection(language: String, message: String) { + val character = createCharacter("fantalk-ownership-missing") + val admin = saveMember("fantalk-ownership-missing-admin", MemberRole.ADMIN) + + assertRejectedReply(character.id!!, Long.MAX_VALUE, admin, language, message) + } + + @ParameterizedTest + @CsvSource( + "ko,잘못된 요청입니다.", + "en,Invalid request.", + "ja,無効なリクエストです。" + ) + @DisplayName("비활성 AI 캐릭터 target 답변은 현지화된 invalid request로 거부한다") + fun shouldRejectInactiveTargetBeforeReplySaveAndLanguageDetection(language: String, message: String) { + val character = createCharacter("fantalk-ownership-inactive-target").apply { isActive = false } + val creator = character.creatorMember!! + val admin = saveMember("fantalk-ownership-inactive-target-admin", MemberRole.ADMIN) + val root = saveCheers(saveMember("fantalk-ownership-inactive-target-writer"), creator, "root") + + assertRejectedReply(character.id!!, root.id!!, admin, language, message) + } + + private fun assertRejectedReply( + characterId: Long, + fanTalkId: Long, + admin: Member, + language: String, + message: String + ) { + entityManager.flush() + entityManager.clear() + val beforeCount = countCheers() + val originalPublisher = replaceFanTalkFacadePublisher(applicationEventPublisher) + + try { + mockMvc.perform( + post("/api/v2/admin/ai-characters/$characterId/fan-talks/$fanTalkId/replies") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsBytes(mapOf("content" to "should not be saved"))) + .header(HttpHeaders.ACCEPT_LANGUAGE, language) + .with(adminAuthentication(admin)) + ).andExpectInvalidRequest(message) + } finally { + replaceFanTalkFacadePublisher(originalPublisher) + } + + assertEquals(beforeCount, countCheers()) + Mockito.verifyNoInteractions(applicationEventPublisher) + } + + private fun ResultActions.andExpectInvalidRequest(message: String) { + andExpect(status().isBadRequest) + .andExpect(content().contentTypeCompatibleWith(MediaType.APPLICATION_JSON)) + .andExpect(jsonPath("$.success").value(false)) + .andExpect(jsonPath("$.message").value(message)) + .andExpect(jsonPath("$.data").value(nullValue())) + .andExpect(jsonPath("$.errorProperty").value(nullValue())) + } + + private fun createCharacter(name: String) = chatCharacterService.createChatCharacterWithDetails( + characterUUID = name, + name = name, + description = "description", + systemPrompt = "prompt" + ) + + private fun saveMember(prefix: String, role: MemberRole = MemberRole.USER): Member { + return Member( + email = "$prefix@test.com", + password = "password", + nickname = prefix, + role = role + ).also(entityManager::persist) + } + + private fun saveCheers( + member: Member, + creator: Member, + content: String, + parent: CreatorCheers? = null, + isActive: Boolean = true + ): CreatorCheers { + return CreatorCheers(cheers = content, languageCode = "ko", isActive = isActive).apply { + this.member = member + this.creator = creator + this.parent = parent + }.also(entityManager::persist) + } + + private fun countCheers(): Long = entityManager.createQuery( + "select count(c) from CreatorCheers c", + java.lang.Long::class.java + ).singleResult.toLong() + + private fun adminAuthentication(admin: Member) = authentication( + UsernamePasswordAuthenticationToken( + MemberAdapter(admin), + "token", + listOf(SimpleGrantedAuthority("ROLE_ADMIN")) + ) + ) + + private fun replaceFanTalkFacadePublisher(publisher: ApplicationEventPublisher): ApplicationEventPublisher { + val target = AopTestUtils.getTargetObject(fanTalkFacade) + val original = ReflectionTestUtils.getField(target, "applicationEventPublisher") as ApplicationEventPublisher + ReflectionTestUtils.setField(target, "applicationEventPublisher", publisher) + return original + } +} diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/fantalk/AiCharacterAdminFanTalkReplyUpdateContractTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/fantalk/AiCharacterAdminFanTalkReplyUpdateContractTest.kt new file mode 100644 index 00000000..8c33a1cf --- /dev/null +++ b/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/fantalk/AiCharacterAdminFanTalkReplyUpdateContractTest.kt @@ -0,0 +1,261 @@ +package kr.co.vividnext.sodalive.v2.api.admin.aicharacter.fantalk + +import kr.co.vividnext.sodalive.chat.character.ChatCharacter +import kr.co.vividnext.sodalive.chat.character.service.ChatCharacterService +import kr.co.vividnext.sodalive.explorer.profile.CreatorCheers +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.support.EmbeddedRedisInitializer +import org.hamcrest.Matchers.nullValue +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.DisplayName +import org.junit.jupiter.params.ParameterizedTest +import org.junit.jupiter.params.provider.CsvSource +import org.mockito.Mockito +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc +import org.springframework.boot.test.context.SpringBootTest +import org.springframework.context.ApplicationEventPublisher +import org.springframework.http.HttpHeaders +import org.springframework.http.MediaType +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken +import org.springframework.security.core.authority.SimpleGrantedAuthority +import org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.authentication +import org.springframework.test.context.ContextConfiguration +import org.springframework.test.util.AopTestUtils +import org.springframework.test.util.ReflectionTestUtils +import org.springframework.test.web.servlet.MockMvc +import org.springframework.test.web.servlet.ResultActions +import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.put +import org.springframework.test.web.servlet.result.MockMvcResultMatchers.content +import org.springframework.test.web.servlet.result.MockMvcResultMatchers.header +import org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath +import org.springframework.test.web.servlet.result.MockMvcResultMatchers.status +import org.springframework.transaction.annotation.Transactional +import javax.persistence.EntityManager + +@SpringBootTest(properties = ["cloud.aws.cloud-front.host=https://test.cloudfront.net"]) +@AutoConfigureMockMvc +@Transactional +@ContextConfiguration(initializers = [EmbeddedRedisInitializer::class]) +class AiCharacterAdminFanTalkReplyUpdateContractTest @Autowired constructor( + private val mockMvc: MockMvc, + private val chatCharacterService: ChatCharacterService, + private val entityManager: EntityManager, + private val fanTalkFacade: AiCharacterAdminFanTalkFacade +) { + private val applicationEventPublisher = Mockito.mock(ApplicationEventPublisher::class.java) + + @ParameterizedTest + @CsvSource("inactive-root", "cross-character-root", "cross-root-reply", "fan-authored-reply", "root-row", "nested-reply") + @DisplayName("관리자 FanTalk 답변 수정은 target/root/direct-reply 경계를 벗어나면 변경 없이 400으로 거부한다") + fun shouldRejectInvalidReplyOwnershipWithoutMutation(caseName: String) { + val fixture = invalidOwnershipFixture(caseName) + entityManager.flush() + entityManager.clear() + val beforeContent = findCheers(fixture.targetRow.id!!).cheers + val beforeActive = findCheers(fixture.targetRow.id!!).isActive + + updateReply( + fixture.characterId, + fixture.fanTalkId, + fixture.replyId, + fixture.admin, + """{"content":"mutated","isActive":false}""" + ) + .andExpectInvalidRequest("Invalid request.") + + entityManager.flush() + entityManager.clear() + assertEquals(beforeContent, findCheers(fixture.targetRow.id!!).cheers) + assertEquals(beforeActive, findCheers(fixture.targetRow.id!!).isActive) + } + + @ParameterizedTest + @CsvSource("ko,잘못된 요청입니다.", "en,Invalid request.", "ja,無効なリクエストです。") + @DisplayName("관리자 FanTalk 답변 수정은 malformed JSON과 미지 필드를 공통 오류 envelope로 거부한다") + fun shouldRejectMalformedJsonAndUnknownFields(language: String, message: String) { + val fixture = validFixture("fantalk-update-contract-$language") + assertInvalidUpdate(fixture, language, message, """{"content":"unterminated""") + assertInvalidUpdate(fixture, language, message, """{"content":"ok","extra":"nope"}""") + } + + @ParameterizedTest + @CsvSource("ko,잘못된 요청입니다.", "en,Invalid request.", "ja,無効なリクエストです。") + @DisplayName("관리자 FanTalk 답변 수정은 JSON이 아닌 요청을 변경이나 이벤트 없이 415로 거부한다") + fun shouldRejectUnsupportedMediaTypeBeforeMutationOrEvent(language: String, message: String) { + val fixture = validFixture("fantalk-update-media-$language") + entityManager.flush() + entityManager.clear() + val beforeContent = findCheers(fixture.reply.id!!).cheers + val originalPublisher = replaceFanTalkFacadePublisher(applicationEventPublisher) + + try { + updateReply(fixture, """{"content":"new"}""", language, MediaType.TEXT_PLAIN) + .andExpect(status().isUnsupportedMediaType) + .andExpect(content().contentTypeCompatibleWith(MediaType.APPLICATION_JSON)) + .andExpect(jsonPath("$.success").value(false)) + .andExpect(jsonPath("$.message").value(message)) + .andExpect(jsonPath("$.data").value(nullValue())) + .andExpect(jsonPath("$.errorProperty").value(nullValue())) + .andExpect(header().string(HttpHeaders.ACCEPT, MediaType.APPLICATION_JSON_VALUE)) + } finally { + replaceFanTalkFacadePublisher(originalPublisher) + } + + entityManager.flush() + entityManager.clear() + assertEquals(beforeContent, findCheers(fixture.reply.id!!).cheers) + Mockito.verifyNoInteractions(applicationEventPublisher) + } + + private fun assertInvalidUpdate(fixture: ValidFixture, language: String, message: String, requestBody: String) { + entityManager.flush() + entityManager.clear() + val beforeContent = findCheers(fixture.reply.id!!).cheers + val beforeActive = findCheers(fixture.reply.id!!).isActive + + updateReply(fixture, requestBody, language).andExpectInvalidRequest(message) + + entityManager.flush() + entityManager.clear() + assertEquals(beforeContent, findCheers(fixture.reply.id!!).cheers) + assertEquals(beforeActive, findCheers(fixture.reply.id!!).isActive) + } + + private fun updateReply( + fixture: ValidFixture, + requestBody: String, + language: String, + contentType: MediaType = MediaType.APPLICATION_JSON + ): ResultActions = updateReply( + fixture.character.id!!, + fixture.root.id!!, + fixture.reply.id!!, + fixture.admin, + requestBody, + language, + contentType + ) + + private fun updateReply( + characterId: Long, + fanTalkId: Long, + replyId: Long, + admin: Member, + requestBody: String, + language: String = "en", + contentType: MediaType = MediaType.APPLICATION_JSON + ): ResultActions { + return mockMvc.perform( + put("/api/v2/admin/ai-characters/$characterId/fan-talks/$fanTalkId/replies/$replyId") + .contentType(contentType) + .content(requestBody) + .header(HttpHeaders.ACCEPT_LANGUAGE, language) + .with(adminAuthentication(admin)) + ) + } + + private fun ResultActions.andExpectInvalidRequest(message: String) { + andExpect(status().isBadRequest) + .andExpect(content().contentTypeCompatibleWith(MediaType.APPLICATION_JSON)) + .andExpect(jsonPath("$.success").value(false)) + .andExpect(jsonPath("$.message").value(message)) + .andExpect(jsonPath("$.data").value(nullValue())) + .andExpect(jsonPath("$.errorProperty").value(nullValue())) + } + + private fun validFixture(prefix: String): ValidFixture { + val character = createCharacter(prefix) + val creator = character.creatorMember!! + val admin = saveMember("$prefix-admin", MemberRole.ADMIN) + val root = saveCheers(saveMember("$prefix-fan"), creator, "root") + val reply = saveCheers(creator, creator, "reply", parent = root) + return ValidFixture(character, admin, root, reply) + } + + private fun invalidOwnershipFixture(caseName: String): InvalidOwnershipFixture { + val character = createCharacter("fantalk-update-$caseName-target") + val creator = character.creatorMember!! + val admin = saveMember("fantalk-update-$caseName-admin", MemberRole.ADMIN) + val root = saveCheers(saveMember("fantalk-update-$caseName-fan"), creator, "root") + return when (caseName) { + "inactive-root" -> { + root.isActive = false + val reply = saveCheers(creator, creator, "reply", parent = root) + InvalidOwnershipFixture(character.id!!, root.id!!, reply.id!!, admin, reply) + } + "cross-character-root" -> { + val other = createCharacter("fantalk-update-cross-character-other") + val otherRoot = saveCheers(saveMember("fantalk-update-cross-character-fan"), other.creatorMember!!, "other") + val reply = saveCheers(other.creatorMember!!, other.creatorMember!!, "reply", parent = otherRoot) + InvalidOwnershipFixture(character.id!!, otherRoot.id!!, reply.id!!, admin, reply) + } + "cross-root-reply" -> { + val otherRoot = saveCheers(saveMember("fantalk-update-cross-root-fan"), creator, "other root") + val reply = saveCheers(creator, creator, "reply", parent = otherRoot) + InvalidOwnershipFixture(character.id!!, root.id!!, reply.id!!, admin, reply) + } + "fan-authored-reply" -> { + val fanReply = saveCheers(saveMember("fantalk-update-fan-reply"), creator, "fan reply", parent = root) + InvalidOwnershipFixture(character.id!!, root.id!!, fanReply.id!!, admin, fanReply) + } + "root-row" -> InvalidOwnershipFixture(character.id!!, root.id!!, root.id!!, admin, root) + "nested-reply" -> { + val reply = saveCheers(creator, creator, "reply", parent = root) + val nested = saveCheers(creator, creator, "nested", parent = reply) + InvalidOwnershipFixture(character.id!!, root.id!!, nested.id!!, admin, nested) + } + else -> error(caseName) + } + } + + private fun createCharacter(name: String) = chatCharacterService.createChatCharacterWithDetails( + characterUUID = name, + name = name, + description = "description", + systemPrompt = "prompt" + ) + + private fun saveMember(prefix: String, role: MemberRole = MemberRole.USER): Member { + return Member(email = "$prefix@test.com", password = "password", nickname = prefix, role = role) + .also(entityManager::persist) + } + + private fun saveCheers(member: Member, creator: Member, content: String, parent: CreatorCheers? = null): CreatorCheers { + return CreatorCheers(cheers = content, languageCode = "ko").apply { + this.member = member + this.creator = creator + this.parent = parent + }.also(entityManager::persist) + } + + private fun findCheers(id: Long): CreatorCheers = entityManager.find(CreatorCheers::class.java, id) + + private fun adminAuthentication(admin: Member) = authentication( + UsernamePasswordAuthenticationToken(MemberAdapter(admin), "token", listOf(SimpleGrantedAuthority("ROLE_ADMIN"))) + ) + + private fun replaceFanTalkFacadePublisher(publisher: ApplicationEventPublisher): ApplicationEventPublisher { + val target = AopTestUtils.getTargetObject(fanTalkFacade) + val original = ReflectionTestUtils.getField(target, "applicationEventPublisher") as ApplicationEventPublisher + ReflectionTestUtils.setField(target, "applicationEventPublisher", publisher) + return original + } + + private data class ValidFixture( + val character: ChatCharacter, + val admin: Member, + val root: CreatorCheers, + val reply: CreatorCheers + ) + + private data class InvalidOwnershipFixture( + val characterId: Long, + val fanTalkId: Long, + val replyId: Long, + val admin: Member, + val targetRow: CreatorCheers + ) +} diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/fantalk/AiCharacterAdminFanTalkReplyUpdateTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/fantalk/AiCharacterAdminFanTalkReplyUpdateTest.kt new file mode 100644 index 00000000..0e9fab56 --- /dev/null +++ b/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/fantalk/AiCharacterAdminFanTalkReplyUpdateTest.kt @@ -0,0 +1,205 @@ +package kr.co.vividnext.sodalive.v2.api.admin.aicharacter.fantalk + +import com.fasterxml.jackson.databind.JsonNode +import com.fasterxml.jackson.databind.ObjectMapper +import kr.co.vividnext.sodalive.chat.character.service.ChatCharacterService +import kr.co.vividnext.sodalive.explorer.profile.CreatorCheers +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.support.EmbeddedRedisInitializer +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertTrue +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.AutoConfigureMockMvc +import org.springframework.boot.test.context.SpringBootTest +import org.springframework.context.ApplicationEventPublisher +import org.springframework.http.HttpHeaders +import org.springframework.http.MediaType +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken +import org.springframework.security.core.authority.SimpleGrantedAuthority +import org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.authentication +import org.springframework.test.context.ContextConfiguration +import org.springframework.test.util.AopTestUtils +import org.springframework.test.util.ReflectionTestUtils +import org.springframework.test.web.servlet.MockMvc +import org.springframework.test.web.servlet.ResultActions +import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.put +import org.springframework.test.web.servlet.result.MockMvcResultMatchers.content +import org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath +import org.springframework.test.web.servlet.result.MockMvcResultMatchers.status +import org.springframework.transaction.annotation.Transactional +import java.time.OffsetDateTime +import javax.persistence.EntityManager + +@SpringBootTest(properties = ["cloud.aws.cloud-front.host=https://test.cloudfront.net"]) +@AutoConfigureMockMvc +@Transactional +@ContextConfiguration(initializers = [EmbeddedRedisInitializer::class]) +class AiCharacterAdminFanTalkReplyUpdateTest @Autowired constructor( + private val mockMvc: MockMvc, + private val chatCharacterService: ChatCharacterService, + private val objectMapper: ObjectMapper, + private val entityManager: EntityManager, + private val fanTalkFacade: AiCharacterAdminFanTalkFacade +) { + private val applicationEventPublisher = Mockito.mock(ApplicationEventPublisher::class.java) + + @Test + @DisplayName("관리자 FanTalk 답변 수정은 content와 isActive를 선택 반영하고 reply 응답을 반환한다") + fun shouldUpdateReplyContentAndActiveState() { + val character = createCharacter("fantalk-update-success") + val creator = character.creatorMember!! + val admin = saveMember("fantalk-update-success-admin", MemberRole.ADMIN) + val root = saveCheers(saveMember("fantalk-update-success-fan"), creator, "root") + val reply = saveCheers(creator, creator, "old reply", parent = root, languageCode = "en") + entityManager.flush() + entityManager.clear() + + val originalPublisher = replaceFanTalkFacadePublisher(applicationEventPublisher) + val data = try { + updateReply(character.id!!, root.id!!, reply.id!!, admin, """{"content":"new reply","isActive":false}""") + .andExpect(status().isOk) + .andExpect(content().contentTypeCompatibleWith(MediaType.APPLICATION_JSON)) + .andExpect(jsonPath("$.success").value(true)) + .andReturnData() + } finally { + replaceFanTalkFacadePublisher(originalPublisher) + } + + assertEquals(reply.id, data.path("fanTalkId").asLong()) + assertEquals(creator.id, data.path("writerId").asLong()) + assertEquals("new reply", data.path("content").asText()) + assertTrue(data.path("creatorReplies").isEmpty) + OffsetDateTime.parse(data.path("createdAtUtc").asText()) + + entityManager.flush() + entityManager.clear() + val updated = findCheers(reply.id!!) + assertEquals("new reply", updated.cheers) + assertTrue(!updated.isActive) + assertEquals("en", updated.languageCode) + Mockito.verifyNoInteractions(applicationEventPublisher) + } + + @Test + @DisplayName("관리자 FanTalk 답변 수정은 빈 객체와 explicit null을 성공 no-op 처리한다") + fun shouldNoopEmptyAndExplicitNullRequest() { + val character = createCharacter("fantalk-update-noop") + val creator = character.creatorMember!! + val admin = saveMember("fantalk-update-noop-admin", MemberRole.ADMIN) + val root = saveCheers(saveMember("fantalk-update-noop-fan"), creator, "root") + val reply = saveCheers(creator, creator, "keep me", parent = root, isActive = false) + entityManager.flush() + entityManager.clear() + + updateReply(character.id!!, root.id!!, reply.id!!, admin, "{}") + .andExpect(status().isOk) + .andExpect(jsonPath("$.data.content").value("keep me")) + updateReply(character.id!!, root.id!!, reply.id!!, admin, """{"content":null,"isActive":null}""") + .andExpect(status().isOk) + .andExpect(jsonPath("$.data.content").value("keep me")) + + entityManager.flush() + entityManager.clear() + val updated = findCheers(reply.id!!) + assertEquals("keep me", updated.cheers) + assertTrue(!updated.isActive) + } + + @Test + @DisplayName("관리자 FanTalk 답변 수정은 비활성 reply 재활성화와 공백 content 반영을 허용한다") + fun shouldReactivateInactiveReplyAndKeepLegacyBlankContentSemantics() { + val character = createCharacter("fantalk-update-reactivate") + val creator = character.creatorMember!! + val admin = saveMember("fantalk-update-reactivate-admin", MemberRole.ADMIN) + val root = saveCheers(saveMember("fantalk-update-reactivate-fan"), creator, "root") + val reply = saveCheers(creator, creator, "inactive reply", parent = root, isActive = false) + entityManager.flush() + entityManager.clear() + + updateReply(character.id!!, root.id!!, reply.id!!, admin, """{"content":" ","isActive":true}""") + .andExpect(status().isOk) + .andExpect(jsonPath("$.data.content").value(" ")) + + entityManager.flush() + entityManager.clear() + val updated = findCheers(reply.id!!) + assertEquals(" ", updated.cheers) + assertTrue(updated.isActive) + } + + private fun updateReply( + characterId: Long, + fanTalkId: Long, + replyId: Long, + admin: Member, + requestBody: String, + language: String = "en", + contentType: MediaType = MediaType.APPLICATION_JSON + ): ResultActions { + return mockMvc.perform( + put("/api/v2/admin/ai-characters/$characterId/fan-talks/$fanTalkId/replies/$replyId") + .contentType(contentType) + .content(requestBody) + .header(HttpHeaders.ACCEPT_LANGUAGE, language) + .with(adminAuthentication(admin)) + ) + } + + private fun ResultActions.andReturnData(): JsonNode { + val result = andReturn() + return objectMapper.readTree(String(result.response.contentAsByteArray, Charsets.UTF_8)).path("data") + } + + private fun createCharacter(name: String) = chatCharacterService.createChatCharacterWithDetails( + characterUUID = name, + name = name, + description = "description", + systemPrompt = "prompt" + ) + + private fun saveMember(prefix: String, role: MemberRole = MemberRole.USER): Member { + return Member( + email = "$prefix@test.com", + password = "password", + nickname = prefix, + role = role + ).also(entityManager::persist) + } + + private fun saveCheers( + member: Member, + creator: Member, + content: String, + parent: CreatorCheers? = null, + isActive: Boolean = true, + languageCode: String? = "ko" + ): CreatorCheers { + return CreatorCheers(cheers = content, languageCode = languageCode, isActive = isActive).apply { + this.member = member + this.creator = creator + this.parent = parent + }.also(entityManager::persist) + } + + private fun findCheers(id: Long): CreatorCheers = entityManager.find(CreatorCheers::class.java, id) + + private fun adminAuthentication(admin: Member) = authentication( + UsernamePasswordAuthenticationToken( + MemberAdapter(admin), + "token", + listOf(SimpleGrantedAuthority("ROLE_ADMIN")) + ) + ) + + private fun replaceFanTalkFacadePublisher(publisher: ApplicationEventPublisher): ApplicationEventPublisher { + val target = AopTestUtils.getTargetObject(fanTalkFacade) + val original = ReflectionTestUtils.getField(target, "applicationEventPublisher") as ApplicationEventPublisher + ReflectionTestUtils.setField(target, "applicationEventPublisher", publisher) + return original + } +} diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/fantalk/LegacyFanTalkReplyCharacterizationTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/fantalk/LegacyFanTalkReplyCharacterizationTest.kt new file mode 100644 index 00000000..8e3755f6 --- /dev/null +++ b/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/fantalk/LegacyFanTalkReplyCharacterizationTest.kt @@ -0,0 +1,260 @@ +package kr.co.vividnext.sodalive.v2.api.admin.aicharacter.fantalk + +import kr.co.vividnext.sodalive.common.SodaException +import kr.co.vividnext.sodalive.content.AudioContentService +import kr.co.vividnext.sodalive.content.LanguageDetectEvent +import kr.co.vividnext.sodalive.content.LanguageDetectTargetType +import kr.co.vividnext.sodalive.content.series.ContentSeriesService +import kr.co.vividnext.sodalive.content.translation.ContentTranslationRepository +import kr.co.vividnext.sodalive.explorer.ExplorerQueryRepository +import kr.co.vividnext.sodalive.explorer.ExplorerService +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.PostWriteCheersRequest +import kr.co.vividnext.sodalive.explorer.profile.channelDonation.ChannelDonationService +import kr.co.vividnext.sodalive.explorer.profile.creatorCommunity.CreatorCommunityService +import kr.co.vividnext.sodalive.i18n.Lang +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.assertEquals +import org.junit.jupiter.api.Assertions.assertFalse +import org.junit.jupiter.api.Assertions.assertNull +import org.junit.jupiter.api.Assertions.assertSame +import org.junit.jupiter.api.Assertions.assertThrows +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.DisplayName +import org.junit.jupiter.api.Test +import org.mockito.ArgumentCaptor +import org.mockito.Mockito +import org.springframework.context.ApplicationEventPublisher +import java.time.LocalDateTime + +class LegacyFanTalkReplyCharacterizationTest { + private lateinit var memberService: MemberService + private lateinit var queryRepository: ExplorerQueryRepository + private lateinit var cheersRepository: CreatorCheersRepository + private lateinit var applicationEventPublisher: ApplicationEventPublisher + private lateinit var messageSource: SodaMessageSource + private lateinit var service: ExplorerService + + @BeforeEach + fun setUp() { + memberService = Mockito.mock(MemberService::class.java) + queryRepository = Mockito.mock(ExplorerQueryRepository::class.java) + cheersRepository = Mockito.mock(CreatorCheersRepository::class.java) + applicationEventPublisher = Mockito.mock(ApplicationEventPublisher::class.java) + messageSource = Mockito.mock(SodaMessageSource::class.java) + service = ExplorerService( + memberService = memberService, + memberContentPreferenceService = Mockito.mock(MemberContentPreferenceService::class.java), + audioContentService = Mockito.mock(AudioContentService::class.java), + donationRankingService = Mockito.mock(CreatorDonationRankingService::class.java), + queryRepository = queryRepository, + cheersRepository = cheersRepository, + 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 = applicationEventPublisher, + contentTranslationRepository = Mockito.mock(ContentTranslationRepository::class.java), + messageSource = messageSource, + langContext = LangContext().apply { setLang(Lang.KO) }, + cloudFrontHost = "https://cdn.test" + ) + } + + @Test + @DisplayName("기존 FanTalk 답변은 root parent와 작성자/크리에이터를 저장하고 빈 언어 코드에만 감지 이벤트를 발행한다") + fun shouldStoreRootReplyAndPublishLanguageDetectionWhenLanguageCodeIsBlank() { + val creator = createMember(10L, "reply-creator", MemberRole.CREATOR) + val writer = createMember(20L, "reply-writer") + val root = createCheers(id = 100L, content = "root fan talk", member = writer, creator = creator) + val createdAt = LocalDateTime.of(2026, 7, 28, 11, 30) + Mockito.`when`(queryRepository.getMember(creator.id!!)).thenReturn(creator) + Mockito.`when`(queryRepository.getCreatorCheers(root.id!!)).thenReturn(root) + stubSave(id = 101L, createdAt = createdAt) + + val response = service.writeCheers( + PostWriteCheersRequest( + parentId = root.id, + creatorId = creator.id!!, + content = "thank you for your support", + languageCode = " " + ), + writer + ) + + val cheersCaptor = ArgumentCaptor.forClass(CreatorCheers::class.java) + Mockito.verify(cheersRepository).save(cheersCaptor.capture()) + val savedCheers = cheersCaptor.value + assertSame(root, savedCheers.parent) + assertSame(writer, savedCheers.member) + assertSame(creator, savedCheers.creator) + assertEquals("thank you for your support", savedCheers.cheers) + assertEquals(" ", savedCheers.languageCode) + assertEquals(101L, response.fanTalkId) + assertEquals(writer.id, response.writerId) + assertEquals(writer.nickname, response.writerNickname) + assertEquals("https://cdn.test/profile/default-profile.png", response.writerProfileImageUrl) + assertEquals("thank you for your support", response.content) + assertEquals("2026-07-28T11:30:00Z", response.createdAtUtc) + assertTrue(response.creatorReplies.isEmpty()) + + val eventCaptor = ArgumentCaptor.forClass(LanguageDetectEvent::class.java) + Mockito.verify(applicationEventPublisher).publishEvent(eventCaptor.capture()) + assertEquals(101L, eventCaptor.value.id) + assertEquals("thank you for your support", eventCaptor.value.query) + assertEquals(LanguageDetectTargetType.CREATOR_CHEERS, eventCaptor.value.targetType) + } + + @Test + @DisplayName("기존 FanTalk 작성은 존재하지 않는 parent를 root 글처럼 저장하고 언어 코드가 있으면 감지 이벤트를 발행하지 않는다") + fun shouldCreateRootFanTalkWhenRequestedParentDoesNotExist() { + val creator = createMember(30L, "root-creator", MemberRole.CREATOR) + val writer = createMember(40L, "root-writer") + Mockito.`when`(queryRepository.getMember(creator.id!!)).thenReturn(creator) + Mockito.`when`(queryRepository.getCreatorCheers(999L)).thenReturn(null) + stubSave(id = 102L, createdAt = LocalDateTime.of(2026, 7, 28, 11, 31)) + + service.writeCheers( + PostWriteCheersRequest( + parentId = 999L, + creatorId = creator.id!!, + content = "new root fan talk", + languageCode = "ko" + ), + writer + ) + + val cheersCaptor = ArgumentCaptor.forClass(CreatorCheers::class.java) + Mockito.verify(cheersRepository).save(cheersCaptor.capture()) + assertNull(cheersCaptor.value.parent) + Mockito.verifyNoInteractions(applicationEventPublisher) + } + + @Test + @DisplayName("기존 FanTalk 작성은 조회된 비활성 nested parent에도 답변을 연결한다") + fun shouldAllowReplyToInactiveNestedParentReturnedByQueryRepository() { + val creator = createMember(50L, "nested-creator", MemberRole.CREATOR) + val writer = createMember(60L, "nested-writer") + val root = createCheers(id = 200L, content = "root", member = writer, creator = creator) + val nested = createCheers(id = 201L, content = "nested", member = creator, creator = creator).apply { + parent = root + isActive = false + } + Mockito.`when`(queryRepository.getMember(creator.id!!)).thenReturn(creator) + Mockito.`when`(queryRepository.getCreatorCheers(nested.id!!)).thenReturn(nested) + stubSave(id = 202L, createdAt = LocalDateTime.of(2026, 7, 28, 11, 32)) + + service.writeCheers( + PostWriteCheersRequest( + parentId = nested.id, + creatorId = creator.id!!, + content = "nested reply", + languageCode = "ko" + ), + writer + ) + + val cheersCaptor = ArgumentCaptor.forClass(CreatorCheers::class.java) + Mockito.verify(cheersRepository).save(cheersCaptor.capture()) + assertSame(nested, cheersCaptor.value.parent) + assertSame(root, nested.parent) + assertFalse(nested.isActive) + } + + @Test + @DisplayName("기존 FanTalk 작성은 같은 root에 대한 크리에이터 답변을 중복 허용한다") + fun shouldAllowMultipleCreatorRepliesToSameRoot() { + val creator = createMember(70L, "duplicate-creator", MemberRole.CREATOR) + val root = createCheers(id = 300L, content = "root", member = creator, creator = creator) + Mockito.`when`(queryRepository.getMember(creator.id!!)).thenReturn(creator) + Mockito.`when`(queryRepository.getCreatorCheers(root.id!!)).thenReturn(root) + var nextId = 301L + Mockito.`when`(cheersRepository.save(Mockito.any(CreatorCheers::class.java))).thenAnswer { invocation -> + invocation.getArgument(0).also { cheers -> + cheers.id = nextId++ + cheers.createdAt = LocalDateTime.of(2026, 7, 28, 11, 33) + } + } + + service.writeCheers( + PostWriteCheersRequest(root.id, creator.id!!, "first reply", "ko"), + creator + ) + service.writeCheers( + PostWriteCheersRequest(root.id, creator.id!!, "second reply", "ko"), + creator + ) + + val cheersCaptor = ArgumentCaptor.forClass(CreatorCheers::class.java) + Mockito.verify(cheersRepository, Mockito.times(2)).save(cheersCaptor.capture()) + assertEquals(listOf("first reply", "second reply"), cheersCaptor.allValues.map { it.cheers }) + assertTrue(cheersCaptor.allValues.all { it.parent === root }) + } + + @Test + @DisplayName("기존 FanTalk 작성은 크리에이터가 없으면 user_not_found key를 반환한다") + fun shouldRejectMissingCreatorWithUserNotFoundMessageKey() { + val writer = createMember(80L, "missing-creator-writer") + Mockito.`when`(queryRepository.getMember(404L)).thenReturn(null) + + val exception = assertThrows(SodaException::class.java) { + service.writeCheers(PostWriteCheersRequest(creatorId = 404L, content = "reply"), writer) + } + + assertEquals("member.validation.user_not_found", exception.messageKey) + Mockito.verifyNoInteractions(cheersRepository, applicationEventPublisher) + } + + @Test + @DisplayName("기존 FanTalk 작성은 차단된 크리에이터에 explorer.creator.blocked_cheers 기반 메시지 예외를 반환한다") + fun shouldRejectBlockedCreatorWithLocalizedMessage() { + val creator = createMember(90L, "blocked-creator", MemberRole.CREATOR) + val writer = createMember(91L, "blocked-writer") + Mockito.`when`(queryRepository.getMember(creator.id!!)).thenReturn(creator) + Mockito.`when`(memberService.isBlocked(writer.id!!, creator.id!!)).thenReturn(true) + Mockito.`when`(messageSource.getMessage("explorer.creator.blocked_cheers", Lang.KO)).thenReturn("blocked %s") + + val exception = assertThrows(SodaException::class.java) { + service.writeCheers(PostWriteCheersRequest(creatorId = creator.id!!, content = "reply"), writer) + } + + assertNull(exception.messageKey) + assertEquals("blocked blocked-creator", exception.message) + Mockito.verifyNoInteractions(cheersRepository, applicationEventPublisher) + } + + private fun stubSave(id: Long, createdAt: LocalDateTime) { + Mockito.`when`(cheersRepository.save(Mockito.any(CreatorCheers::class.java))).thenAnswer { invocation -> + invocation.getArgument(0).also { cheers -> + cheers.id = id + cheers.createdAt = createdAt + } + } + } + + private fun createCheers(id: Long, content: String, member: Member, creator: Member): CreatorCheers { + return CreatorCheers(cheers = content, languageCode = "ko").apply { + this.id = id + this.member = member + this.creator = creator + } + } + + 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 } + } +}