feat(ai-character): 팬톡 관리자 API를 추가한다

This commit is contained in:
2026-07-30 00:33:46 +09:00
parent c45ad98db8
commit 1bfb387e78
12 changed files with 2228 additions and 0 deletions

View File

@@ -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<CreatorChannelFanTalkTabResponse> {
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<AiCharacterAdminFanTalkReplyResponse> {
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<CreatorChannelFanTalkResponse> {
return ApiResponse.ok(facade.updateReply(characterId, fanTalkId, replyId, request))
}
@DeleteMapping("/{fanTalkId:[0-9]+}")
fun delete(
@PathVariable characterId: Long,
@PathVariable fanTalkId: Long
): ApiResponse<Unit> {
facade.delete(characterId, fanTalkId)
return ApiResponse.ok(null)
}
}

View File

@@ -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
)

View File

@@ -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<CreatorChannelFanTalkReplyResponse>
): 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 <T> readRequest(requestString: String, requestClass: Class<T>): 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")
}
}

View File

@@ -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<AiCharacterAdminFanTalkRecord> {
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<Long>
): List<AiCharacterAdminFanTalkReplyRecord> {
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()
}
}

View File

@@ -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"))
)
)
}

View File

@@ -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"))
)
)
}

View File

@@ -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<AiCharacterAdminFanTalkFacade>(fanTalkFacade)
val original = ReflectionTestUtils.getField(target, "applicationEventPublisher") as ApplicationEventPublisher
ReflectionTestUtils.setField(target, "applicationEventPublisher", publisher)
return original
}
private companion object {
@JvmStatic
fun localizedMessages(): Stream<Arguments> = Stream.of(
Arguments.of("ko", "잘못된 요청입니다."),
Arguments.of("en", "Invalid request."),
Arguments.of("ja", "無効なリクエストです。")
)
}
}

View File

@@ -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<AiCharacterAdminFanTalkFacade>(fanTalkFacade)
val original = ReflectionTestUtils.getField(target, "applicationEventPublisher") as ApplicationEventPublisher
ReflectionTestUtils.setField(target, "applicationEventPublisher", publisher)
return original
}
}

View File

@@ -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<AiCharacterAdminFanTalkFacade>(fanTalkFacade)
val original = ReflectionTestUtils.getField(target, "applicationEventPublisher") as ApplicationEventPublisher
ReflectionTestUtils.setField(target, "applicationEventPublisher", publisher)
return original
}
}

View File

@@ -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<AiCharacterAdminFanTalkFacade>(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
)
}

View File

@@ -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<AiCharacterAdminFanTalkFacade>(fanTalkFacade)
val original = ReflectionTestUtils.getField(target, "applicationEventPublisher") as ApplicationEventPublisher
ReflectionTestUtils.setField(target, "applicationEventPublisher", publisher)
return original
}
}

View File

@@ -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<CreatorCheers>(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<CreatorCheers>(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 }
}
}