test #443
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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"))
|
||||
)
|
||||
)
|
||||
}
|
||||
@@ -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"))
|
||||
)
|
||||
)
|
||||
}
|
||||
@@ -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", "無効なリクエストです。")
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user