feat(ai-character): 팬톡 관리자 API를 추가한다
This commit is contained in:
+259
@@ -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"))
|
||||
)
|
||||
)
|
||||
}
|
||||
+263
@@ -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"))
|
||||
)
|
||||
)
|
||||
}
|
||||
+208
@@ -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", "無効なリクエストです。")
|
||||
)
|
||||
}
|
||||
}
|
||||
+146
@@ -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
|
||||
}
|
||||
}
|
||||
+224
@@ -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
|
||||
}
|
||||
}
|
||||
+261
@@ -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
|
||||
)
|
||||
}
|
||||
+205
@@ -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
|
||||
}
|
||||
}
|
||||
+260
@@ -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