revert(ai-character): 기존 관리자 기능 기반을 제거한다
This commit is contained in:
-696
@@ -1,54 +1,14 @@
|
||||
package kr.co.vividnext.sodalive.admin.chat.character
|
||||
|
||||
import com.amazonaws.services.s3.AmazonS3Client
|
||||
import com.fasterxml.jackson.databind.ObjectMapper
|
||||
import com.sun.net.httpserver.HttpServer
|
||||
import kr.co.vividnext.sodalive.admin.chat.character.dto.BackgroundResponse
|
||||
import kr.co.vividnext.sodalive.admin.chat.character.dto.ChatCharacterBackgroundRequest
|
||||
import kr.co.vividnext.sodalive.admin.chat.character.dto.ChatCharacterDetailResponse
|
||||
import kr.co.vividnext.sodalive.admin.chat.character.dto.ChatCharacterListPageResponse
|
||||
import kr.co.vividnext.sodalive.admin.chat.character.dto.ChatCharacterListResponse
|
||||
import kr.co.vividnext.sodalive.admin.chat.character.dto.ChatCharacterMemoryRequest
|
||||
import kr.co.vividnext.sodalive.admin.chat.character.dto.ChatCharacterPersonalityRequest
|
||||
import kr.co.vividnext.sodalive.admin.chat.character.dto.ChatCharacterRelationshipRequest
|
||||
import kr.co.vividnext.sodalive.admin.chat.character.dto.ChatCharacterUpdateRequest
|
||||
import kr.co.vividnext.sodalive.admin.chat.character.dto.MemoryResponse
|
||||
import kr.co.vividnext.sodalive.admin.chat.character.dto.OriginalWorkBriefResponse
|
||||
import kr.co.vividnext.sodalive.admin.chat.character.dto.PersonalityResponse
|
||||
import kr.co.vividnext.sodalive.admin.chat.character.dto.RelationshipResponse
|
||||
import kr.co.vividnext.sodalive.admin.chat.character.service.AdminChatCharacterService
|
||||
import kr.co.vividnext.sodalive.admin.chat.original.service.AdminOriginalWorkService
|
||||
import kr.co.vividnext.sodalive.aws.s3.S3Uploader
|
||||
import kr.co.vividnext.sodalive.chat.character.CharacterType
|
||||
import kr.co.vividnext.sodalive.chat.character.ChatCharacter
|
||||
import kr.co.vividnext.sodalive.chat.character.service.ChatCharacterCreatorMemberService
|
||||
import kr.co.vividnext.sodalive.chat.character.service.ChatCharacterService
|
||||
import org.junit.jupiter.api.Assertions.assertEquals
|
||||
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 org.springframework.data.domain.PageImpl
|
||||
import org.springframework.data.domain.PageRequest
|
||||
import org.springframework.http.MediaType
|
||||
import org.springframework.mock.web.MockMultipartFile
|
||||
import org.springframework.security.access.prepost.PreAuthorize
|
||||
import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get
|
||||
import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.multipart
|
||||
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.test.web.servlet.setup.MockMvcBuilders
|
||||
import org.springframework.web.bind.annotation.GetMapping
|
||||
import org.springframework.web.bind.annotation.PostMapping
|
||||
import org.springframework.web.bind.annotation.PutMapping
|
||||
import org.springframework.web.bind.annotation.RequestMapping
|
||||
import org.springframework.web.bind.annotation.RequestPart
|
||||
import org.springframework.web.multipart.MultipartFile
|
||||
import java.net.InetSocketAddress
|
||||
import java.net.URL
|
||||
import java.util.concurrent.CopyOnWriteArrayList
|
||||
|
||||
class AdminChatCharacterControllerTest {
|
||||
private val controller = AdminChatCharacterController(
|
||||
@@ -75,523 +35,6 @@ class AdminChatCharacterControllerTest {
|
||||
return method.invoke(controller, region, gender) as String
|
||||
}
|
||||
|
||||
@Test
|
||||
fun shouldKeepLegacyAdminCharacterBaseContract() {
|
||||
val classMapping = AdminChatCharacterController::class.java.getAnnotation(RequestMapping::class.java)
|
||||
val preAuthorize = AdminChatCharacterController::class.java.getAnnotation(PreAuthorize::class.java)
|
||||
|
||||
assertEquals("/admin/chat/character", classMapping.value.single())
|
||||
assertEquals("hasRole('ADMIN')", preAuthorize.value)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun shouldKeepLegacyAdminCharacterRoutes() {
|
||||
assertEquals(
|
||||
"/list",
|
||||
method("getCharacterList", Int::class.java, Int::class.java).getAnnotation(GetMapping::class.java).value.single()
|
||||
)
|
||||
assertEquals(
|
||||
"/search",
|
||||
method("searchCharacters", String::class.java, Int::class.java, Int::class.java)
|
||||
.getAnnotation(GetMapping::class.java).value.single()
|
||||
)
|
||||
assertEquals(
|
||||
"/{characterId}",
|
||||
method("getCharacterDetail", Long::class.java).getAnnotation(GetMapping::class.java).value.single()
|
||||
)
|
||||
assertEquals(
|
||||
"/register",
|
||||
method("registerCharacter", MultipartFile::class.java, String::class.java)
|
||||
.getAnnotation(PostMapping::class.java).value.single()
|
||||
)
|
||||
assertEquals(
|
||||
"/update",
|
||||
method("updateCharacter", MultipartFile::class.java, String::class.java)
|
||||
.getAnnotation(PutMapping::class.java).value.single()
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun shouldKeepLegacyAdminCharacterMultipartRequestParts() {
|
||||
val register = method("registerCharacter", MultipartFile::class.java, String::class.java)
|
||||
val update = method("updateCharacter", MultipartFile::class.java, String::class.java)
|
||||
|
||||
assertEquals("image", register.parameters[0].getAnnotation(RequestPart::class.java).value)
|
||||
assertEquals("request", register.parameters[1].getAnnotation(RequestPart::class.java).value)
|
||||
assertEquals("image", update.parameters[0].getAnnotation(RequestPart::class.java).value)
|
||||
assertEquals(false, update.parameters[0].getAnnotation(RequestPart::class.java).required)
|
||||
assertEquals("request", update.parameters[1].getAnnotation(RequestPart::class.java).value)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun shouldKeepLegacyAdminCharacterUpdateRequestFields() {
|
||||
val request = ChatCharacterUpdateRequest(
|
||||
id = 1L,
|
||||
name = "character",
|
||||
systemPrompt = "prompt",
|
||||
description = "description",
|
||||
age = null,
|
||||
gender = null,
|
||||
mbti = null,
|
||||
speechPattern = null,
|
||||
speechStyle = null,
|
||||
appearance = null,
|
||||
originalTitle = "title",
|
||||
originalLink = null,
|
||||
originalWorkId = null,
|
||||
characterType = null,
|
||||
isActive = null,
|
||||
tags = emptyList(),
|
||||
hobbies = emptyList(),
|
||||
values = emptyList(),
|
||||
goals = emptyList(),
|
||||
relationships = emptyList(),
|
||||
personalities = emptyList(),
|
||||
backgrounds = emptyList(),
|
||||
memories = emptyList()
|
||||
)
|
||||
|
||||
assertEquals(1L, request.id)
|
||||
assertEquals("character", request.name)
|
||||
assertEquals("prompt", request.systemPrompt)
|
||||
assertEquals("description", request.description)
|
||||
assertEquals("title", request.originalTitle)
|
||||
assertEquals(emptyList<String>(), request.tags)
|
||||
assertEquals(emptyList<String>(), request.memories)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun shouldKeepLegacyAdminCharacterListResponseSurface() {
|
||||
val adminService = Mockito.mock(AdminChatCharacterService::class.java)
|
||||
Mockito.`when`(adminService.createDefaultPageRequest(0, 20)).thenReturn(PageRequest.of(0, 20))
|
||||
Mockito.`when`(adminService.getActiveChatCharacters(PageRequest.of(0, 20), "https://cdn.example.com"))
|
||||
.thenReturn(
|
||||
ChatCharacterListPageResponse(
|
||||
totalCount = 1,
|
||||
content = listOf(characterListResponse())
|
||||
)
|
||||
)
|
||||
val mockMvc = MockMvcBuilders.standaloneSetup(controller(adminService = adminService)).build()
|
||||
|
||||
mockMvc.perform(get("/admin/chat/character/list").param("page", "0").param("size", "20"))
|
||||
.andExpect(status().isOk)
|
||||
.andExpect(jsonPath("$.success").value(true))
|
||||
.andExpect(jsonPath("$.data.totalCount").value(1))
|
||||
.andExpect(jsonPath("$.data.content[0].id").value(1))
|
||||
.andExpect(jsonPath("$.data.content[0].name").value("character"))
|
||||
.andExpect(jsonPath("$.data.content[0].imageUrl").value("https://cdn.example.com/characters/1.png"))
|
||||
.andExpect(jsonPath("$.data.content[0].description").value("description"))
|
||||
.andExpect(jsonPath("$.data.content[0].gender").value("여성"))
|
||||
.andExpect(jsonPath("$.data.content[0].age").value(20))
|
||||
.andExpect(jsonPath("$.data.content[0].mbti").value("INTJ"))
|
||||
.andExpect(jsonPath("$.data.content[0].speechStyle").value("calm"))
|
||||
.andExpect(jsonPath("$.data.content[0].speechPattern").value("polite"))
|
||||
.andExpect(jsonPath("$.data.content[0].region").value("KR"))
|
||||
.andExpect(jsonPath("$.data.content[0].tags[0]").value("tag"))
|
||||
.andExpect(jsonPath("$.data.content[0].createdAt").value("2026-07-21 12:00:00"))
|
||||
.andExpect(jsonPath("$.data.content[0].updatedAt").value("2026-07-21 12:00:01"))
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("legacy 캐릭터 검색은 query와 비기본 pagination을 service에 전달하고 기존 응답을 유지한다")
|
||||
fun shouldKeepLegacyAdminCharacterSearchRequestAndResponseSurface() {
|
||||
val adminService = Mockito.mock(AdminChatCharacterService::class.java)
|
||||
val pageable = PageRequest.of(2, 7)
|
||||
Mockito.`when`(adminService.createDefaultPageRequest(2, 7)).thenReturn(pageable)
|
||||
Mockito.`when`(adminService.searchCharacters("character", pageable, "https://cdn.example.com"))
|
||||
.thenReturn(PageImpl(listOf(characterListResponse()), pageable, 15))
|
||||
val mockMvc = MockMvcBuilders.standaloneSetup(controller(adminService = adminService)).build()
|
||||
|
||||
mockMvc.perform(
|
||||
get("/admin/chat/character/search")
|
||||
.param("searchTerm", "character")
|
||||
.param("page", "2")
|
||||
.param("size", "7")
|
||||
)
|
||||
.andExpect(status().isOk)
|
||||
.andExpect(jsonPath("$.success").value(true))
|
||||
.andExpect(jsonPath("$.data.totalCount").value(15))
|
||||
.andExpect(jsonPath("$.data.content").isArray)
|
||||
.andExpect(jsonPath("$.data.content[0].id").value(1))
|
||||
.andExpect(jsonPath("$.data.content[0].name").value("character"))
|
||||
.andExpect(jsonPath("$.data.content[0].imageUrl").value("https://cdn.example.com/characters/1.png"))
|
||||
.andExpect(jsonPath("$.data.content[0].description").value("description"))
|
||||
.andExpect(jsonPath("$.data.content[0].gender").value("여성"))
|
||||
.andExpect(jsonPath("$.data.content[0].age").value(20))
|
||||
.andExpect(jsonPath("$.data.content[0].mbti").value("INTJ"))
|
||||
.andExpect(jsonPath("$.data.content[0].speechStyle").value("calm"))
|
||||
.andExpect(jsonPath("$.data.content[0].speechPattern").value("polite"))
|
||||
.andExpect(jsonPath("$.data.content[0].region").value("KR"))
|
||||
.andExpect(jsonPath("$.data.content[0].tags[0]").value("tag"))
|
||||
.andExpect(jsonPath("$.data.content[0].createdAt").value("2026-07-21 12:00:00"))
|
||||
.andExpect(jsonPath("$.data.content[0].updatedAt").value("2026-07-21 12:00:01"))
|
||||
|
||||
Mockito.verify(adminService).createDefaultPageRequest(2, 7)
|
||||
Mockito.verify(adminService).searchCharacters("character", pageable, "https://cdn.example.com")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun shouldKeepLegacyAdminCharacterDetailResponseSurface() {
|
||||
val adminService = Mockito.mock(AdminChatCharacterService::class.java)
|
||||
Mockito.`when`(adminService.getChatCharacterDetail(1L, "https://cdn.example.com"))
|
||||
.thenReturn(
|
||||
ChatCharacterDetailResponse(
|
||||
id = 1L,
|
||||
characterUUID = "uuid",
|
||||
name = "character",
|
||||
imageUrl = "https://cdn.example.com/characters/1.png",
|
||||
description = "description",
|
||||
systemPrompt = "prompt",
|
||||
characterType = "Character",
|
||||
age = 20,
|
||||
gender = "여성",
|
||||
mbti = "INTJ",
|
||||
speechPattern = "polite",
|
||||
speechStyle = "calm",
|
||||
appearance = "appearance",
|
||||
region = "KR",
|
||||
isActive = true,
|
||||
tags = listOf("tag"),
|
||||
hobbies = listOf("hobby"),
|
||||
values = listOf("value"),
|
||||
goals = listOf("goal"),
|
||||
relationships = listOf(
|
||||
RelationshipResponse(
|
||||
personName = "person",
|
||||
relationshipName = "friend",
|
||||
description = "relationship description",
|
||||
importance = 5,
|
||||
relationshipType = "ally",
|
||||
currentStatus = "active"
|
||||
)
|
||||
),
|
||||
personalities = listOf(PersonalityResponse(trait = "kind", description = "personality description")),
|
||||
backgrounds = listOf(BackgroundResponse(topic = "past", description = "background description")),
|
||||
memories = listOf(MemoryResponse(title = "memory", content = "memory content", emotion = "happy")),
|
||||
originalWork = OriginalWorkBriefResponse(
|
||||
id = 10L,
|
||||
imageUrl = "https://cdn.example.com/originals/10.png",
|
||||
title = "original title"
|
||||
)
|
||||
)
|
||||
)
|
||||
val mockMvc = MockMvcBuilders.standaloneSetup(controller(adminService = adminService)).build()
|
||||
|
||||
mockMvc.perform(get("/admin/chat/character/1"))
|
||||
.andExpect(status().isOk)
|
||||
.andExpect(jsonPath("$.success").value(true))
|
||||
.andExpect(jsonPath("$.data.id").value(1))
|
||||
.andExpect(jsonPath("$.data.characterUUID").value("uuid"))
|
||||
.andExpect(jsonPath("$.data.name").value("character"))
|
||||
.andExpect(jsonPath("$.data.imageUrl").value("https://cdn.example.com/characters/1.png"))
|
||||
.andExpect(jsonPath("$.data.description").value("description"))
|
||||
.andExpect(jsonPath("$.data.systemPrompt").value("prompt"))
|
||||
.andExpect(jsonPath("$.data.characterType").value("Character"))
|
||||
.andExpect(jsonPath("$.data.age").value(20))
|
||||
.andExpect(jsonPath("$.data.gender").value("여성"))
|
||||
.andExpect(jsonPath("$.data.mbti").value("INTJ"))
|
||||
.andExpect(jsonPath("$.data.speechPattern").value("polite"))
|
||||
.andExpect(jsonPath("$.data.speechStyle").value("calm"))
|
||||
.andExpect(jsonPath("$.data.appearance").value("appearance"))
|
||||
.andExpect(jsonPath("$.data.region").value("KR"))
|
||||
.andExpect(jsonPath("$.data.isActive").value(true))
|
||||
.andExpect(jsonPath("$.data.tags[0]").value("tag"))
|
||||
.andExpect(jsonPath("$.data.hobbies[0]").value("hobby"))
|
||||
.andExpect(jsonPath("$.data.values[0]").value("value"))
|
||||
.andExpect(jsonPath("$.data.goals[0]").value("goal"))
|
||||
.andExpect(jsonPath("$.data.relationships[0].personName").value("person"))
|
||||
.andExpect(jsonPath("$.data.relationships[0].relationshipName").value("friend"))
|
||||
.andExpect(jsonPath("$.data.relationships[0].description").value("relationship description"))
|
||||
.andExpect(jsonPath("$.data.relationships[0].importance").value(5))
|
||||
.andExpect(jsonPath("$.data.relationships[0].relationshipType").value("ally"))
|
||||
.andExpect(jsonPath("$.data.relationships[0].currentStatus").value("active"))
|
||||
.andExpect(jsonPath("$.data.personalities[0].trait").value("kind"))
|
||||
.andExpect(jsonPath("$.data.personalities[0].description").value("personality description"))
|
||||
.andExpect(jsonPath("$.data.backgrounds[0].topic").value("past"))
|
||||
.andExpect(jsonPath("$.data.backgrounds[0].description").value("background description"))
|
||||
.andExpect(jsonPath("$.data.memories[0].title").value("memory"))
|
||||
.andExpect(jsonPath("$.data.memories[0].content").value("memory content"))
|
||||
.andExpect(jsonPath("$.data.memories[0].emotion").value("happy"))
|
||||
.andExpect(jsonPath("$.data.originalWork.id").value(10))
|
||||
.andExpect(jsonPath("$.data.originalWork.imageUrl").value("https://cdn.example.com/originals/10.png"))
|
||||
.andExpect(jsonPath("$.data.originalWork.title").value("original title"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun shouldKeepLegacyAdminCharacterRegisterResponseSurface() {
|
||||
val outboundRequests = CopyOnWriteArrayList<RecordedHttpRequest>()
|
||||
val server = externalCharacterApiServer(outboundRequests)
|
||||
try {
|
||||
val service = chatCharacterServiceFake()
|
||||
val originalWorkService = Mockito.mock(AdminOriginalWorkService::class.java)
|
||||
val mockMvc = MockMvcBuilders.standaloneSetup(
|
||||
controllerForMutation(server, service, originalWorkService)
|
||||
).build()
|
||||
val request = """
|
||||
{
|
||||
"name":"character",
|
||||
"systemPrompt":"prompt",
|
||||
"description":"description",
|
||||
"age":"20",
|
||||
"gender":"여성",
|
||||
"mbti":"INTJ",
|
||||
"speechPattern":"polite",
|
||||
"speechStyle":"calm",
|
||||
"appearance":"appearance",
|
||||
"region":"KR",
|
||||
"originalTitle":"original title",
|
||||
"originalLink":"https://original.test",
|
||||
"originalWorkId":10,
|
||||
"characterType":"Character",
|
||||
"tags":["tag"],
|
||||
"hobbies":["hobby"],
|
||||
"values":["value"],
|
||||
"goals":["goal"],
|
||||
"relationships":[{
|
||||
"personName":"person",
|
||||
"relationshipName":"friend",
|
||||
"description":"relationship description",
|
||||
"importance":5,
|
||||
"relationshipType":"ally",
|
||||
"currentStatus":"active"
|
||||
}],
|
||||
"personalities":[{"trait":"kind","description":"personality description"}],
|
||||
"backgrounds":[{"topic":"past","description":"background description"}],
|
||||
"memories":[{"title":"memory","content":"memory content","emotion":"happy"}]
|
||||
}
|
||||
""".trimIndent()
|
||||
|
||||
mockMvc.perform(
|
||||
multipart("/admin/chat/character/register")
|
||||
.file(imageFile())
|
||||
.file(jsonPart(request))
|
||||
)
|
||||
.andExpect(status().isOk)
|
||||
.andExpect(jsonPath("$.success").value(true))
|
||||
.andExpect(content().json(LEGACY_EMPTY_SUCCESS_RESPONSE, true))
|
||||
|
||||
val arguments = Mockito.mockingDetails(service).invocations
|
||||
.single { it.method.name == "createChatCharacterWithDetails" }
|
||||
.arguments
|
||||
assertEquals("remote-id", arguments[0])
|
||||
assertEquals("character", arguments[1])
|
||||
assertEquals("description", arguments[2])
|
||||
assertEquals("prompt", arguments[3])
|
||||
assertEquals(20, arguments[4])
|
||||
assertEquals("여성", arguments[5])
|
||||
assertEquals("INTJ", arguments[6])
|
||||
assertEquals("polite", arguments[7])
|
||||
assertEquals("calm", arguments[8])
|
||||
assertEquals("appearance", arguments[9])
|
||||
assertEquals("original title", arguments[10])
|
||||
assertEquals("https://original.test", arguments[11])
|
||||
assertEquals(CharacterType.Character, arguments[12])
|
||||
assertEquals("KR", arguments[13])
|
||||
assertEquals(listOf("tag"), arguments[14])
|
||||
assertEquals(listOf("value"), arguments[15])
|
||||
assertEquals(listOf("hobby"), arguments[16])
|
||||
assertEquals(listOf("goal"), arguments[17])
|
||||
assertEquals(listOf(Triple("memory", "memory content", "happy")), arguments[18])
|
||||
assertEquals(listOf(Pair("kind", "personality description")), arguments[19])
|
||||
assertEquals(listOf(Pair("past", "background description")), arguments[20])
|
||||
assertEquals(
|
||||
listOf(
|
||||
ChatCharacterRelationshipRequest(
|
||||
personName = "person",
|
||||
relationshipName = "friend",
|
||||
description = "relationship description",
|
||||
importance = 5,
|
||||
relationshipType = "ally",
|
||||
currentStatus = "active"
|
||||
)
|
||||
),
|
||||
arguments[21]
|
||||
)
|
||||
Mockito.verify(originalWorkService).assignOneCharacter(10L, 1L)
|
||||
assertRecordedRequest(
|
||||
request = outboundRequests.single(),
|
||||
method = "POST",
|
||||
path = "/api/characters",
|
||||
expectedBody = """
|
||||
{
|
||||
"name":"character",
|
||||
"systemPrompt":"prompt",
|
||||
"description":"description",
|
||||
"region":"KR",
|
||||
"age":"20",
|
||||
"gender":"여성",
|
||||
"mbti":"INTJ",
|
||||
"speechPattern":"polite",
|
||||
"speechStyle":"calm",
|
||||
"appearance":"appearance",
|
||||
"tags":["tag"],
|
||||
"hobbies":["hobby"],
|
||||
"values":["value"],
|
||||
"goals":["goal"],
|
||||
"relationships":[{
|
||||
"personName":"person",
|
||||
"relationshipName":"friend",
|
||||
"description":"relationship description",
|
||||
"importance":5,
|
||||
"relationshipType":"ally",
|
||||
"currentStatus":"active"
|
||||
}],
|
||||
"personalities":[{"trait":"kind","description":"personality description"}],
|
||||
"backgrounds":[{"topic":"past","description":"background description"}],
|
||||
"memories":[{"title":"memory","content":"memory content","emotion":"happy"}]
|
||||
}
|
||||
""".trimIndent()
|
||||
)
|
||||
} finally {
|
||||
server.stop(0)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun shouldKeepLegacyAdminCharacterUpdateResponseSurface() {
|
||||
val outboundRequests = CopyOnWriteArrayList<RecordedHttpRequest>()
|
||||
val server = externalCharacterApiServer(outboundRequests)
|
||||
try {
|
||||
val service = chatCharacterServiceFake()
|
||||
val originalWorkService = Mockito.mock(AdminOriginalWorkService::class.java)
|
||||
val mockMvc = MockMvcBuilders.standaloneSetup(
|
||||
controllerForMutation(server, service, originalWorkService)
|
||||
).build()
|
||||
val request = """
|
||||
{
|
||||
"id":1,
|
||||
"name":"updated character",
|
||||
"systemPrompt":"updated prompt",
|
||||
"description":"updated description",
|
||||
"age":"21",
|
||||
"gender":"남성",
|
||||
"mbti":"ENTP",
|
||||
"speechPattern":"casual",
|
||||
"speechStyle":"bright",
|
||||
"appearance":"updated appearance",
|
||||
"originalTitle":"updated original title",
|
||||
"originalLink":"https://updated-original.test",
|
||||
"originalWorkId":11,
|
||||
"characterType":"Character",
|
||||
"isActive":true,
|
||||
"tags":["updated tag"],
|
||||
"hobbies":["updated hobby"],
|
||||
"values":["updated value"],
|
||||
"goals":["updated goal"],
|
||||
"relationships":[{
|
||||
"personName":"updated person",
|
||||
"relationshipName":"rival",
|
||||
"description":"updated relationship description",
|
||||
"importance":4,
|
||||
"relationshipType":"opponent",
|
||||
"currentStatus":"tense"
|
||||
}],
|
||||
"personalities":[{"trait":"bold","description":"updated personality description"}],
|
||||
"backgrounds":[{"topic":"future","description":"updated background description"}],
|
||||
"memories":[{"title":"updated memory","content":"updated memory content","emotion":"hopeful"}]
|
||||
}
|
||||
""".trimIndent()
|
||||
|
||||
mockMvc.perform(
|
||||
multipart("/admin/chat/character/update")
|
||||
.file(jsonPart(request))
|
||||
.with { requestBuilder ->
|
||||
requestBuilder.method = "PUT"
|
||||
requestBuilder
|
||||
}
|
||||
)
|
||||
.andExpect(status().isOk)
|
||||
.andExpect(jsonPath("$.success").value(true))
|
||||
.andExpect(content().json(LEGACY_EMPTY_SUCCESS_RESPONSE, true))
|
||||
|
||||
val requestCaptor = ArgumentCaptor.forClass(ChatCharacterUpdateRequest::class.java)
|
||||
Mockito.verify(service).updateChatCharacterWithDetails(Mockito.isNull(), captureRequest(requestCaptor))
|
||||
assertEquals(
|
||||
ChatCharacterUpdateRequest(
|
||||
id = 1L,
|
||||
name = "updated character",
|
||||
systemPrompt = "updated prompt",
|
||||
description = "updated description",
|
||||
age = "21",
|
||||
gender = "남성",
|
||||
mbti = "ENTP",
|
||||
speechPattern = "casual",
|
||||
speechStyle = "bright",
|
||||
appearance = "updated appearance",
|
||||
originalTitle = "updated original title",
|
||||
originalLink = "https://updated-original.test",
|
||||
originalWorkId = 11L,
|
||||
characterType = "Character",
|
||||
isActive = true,
|
||||
tags = listOf("updated tag"),
|
||||
hobbies = listOf("updated hobby"),
|
||||
values = listOf("updated value"),
|
||||
goals = listOf("updated goal"),
|
||||
relationships = listOf(
|
||||
ChatCharacterRelationshipRequest(
|
||||
personName = "updated person",
|
||||
relationshipName = "rival",
|
||||
description = "updated relationship description",
|
||||
importance = 4,
|
||||
relationshipType = "opponent",
|
||||
currentStatus = "tense"
|
||||
)
|
||||
),
|
||||
personalities = listOf(
|
||||
ChatCharacterPersonalityRequest("bold", "updated personality description")
|
||||
),
|
||||
backgrounds = listOf(
|
||||
ChatCharacterBackgroundRequest("future", "updated background description")
|
||||
),
|
||||
memories = listOf(
|
||||
ChatCharacterMemoryRequest("updated memory", "updated memory content", "hopeful")
|
||||
)
|
||||
),
|
||||
requestCaptor.value
|
||||
)
|
||||
Mockito.verify(originalWorkService).assignOneCharacter(11L, 1L)
|
||||
assertRecordedRequest(
|
||||
request = outboundRequests.single(),
|
||||
method = "PUT",
|
||||
path = "/api/characters/remote-id",
|
||||
expectedBody = """
|
||||
{
|
||||
"name":"updated character",
|
||||
"systemPrompt":"updated prompt",
|
||||
"description":"updated description",
|
||||
"age":"21",
|
||||
"gender":"남성",
|
||||
"mbti":"ENTP",
|
||||
"speechPattern":"casual",
|
||||
"speechStyle":"bright",
|
||||
"appearance":"updated appearance",
|
||||
"tags":["updated tag"],
|
||||
"hobbies":["updated hobby"],
|
||||
"values":["updated value"],
|
||||
"goals":["updated goal"],
|
||||
"relationships":[{
|
||||
"personName":"updated person",
|
||||
"relationshipName":"rival",
|
||||
"description":"updated relationship description",
|
||||
"importance":4,
|
||||
"relationshipType":"opponent",
|
||||
"currentStatus":"tense"
|
||||
}],
|
||||
"personalities":[{"trait":"bold","description":"updated personality description"}],
|
||||
"backgrounds":[{"topic":"future","description":"updated background description"}],
|
||||
"memories":[{
|
||||
"title":"updated memory",
|
||||
"content":"updated memory content",
|
||||
"emotion":"hopeful"
|
||||
}]
|
||||
}
|
||||
""".trimIndent()
|
||||
)
|
||||
} finally {
|
||||
server.stop(0)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun shouldMapFemaleToJapaneseWhenRegionIsJp() {
|
||||
val mappedGender = mapGender(region = "JP", gender = "여성")
|
||||
@@ -619,143 +62,4 @@ class AdminChatCharacterControllerTest {
|
||||
|
||||
assertEquals("여성", mappedGender)
|
||||
}
|
||||
|
||||
private fun method(name: String, vararg parameterTypes: Class<*>): java.lang.reflect.Method {
|
||||
return AdminChatCharacterController::class.java.getDeclaredMethod(name, *parameterTypes)
|
||||
}
|
||||
|
||||
private fun controller(
|
||||
adminService: AdminChatCharacterService = Mockito.mock(AdminChatCharacterService::class.java)
|
||||
): AdminChatCharacterController {
|
||||
return AdminChatCharacterController(
|
||||
service = Mockito.mock(ChatCharacterService::class.java),
|
||||
adminService = adminService,
|
||||
s3Uploader = Mockito.mock(S3Uploader::class.java),
|
||||
originalWorkService = Mockito.mock(AdminOriginalWorkService::class.java),
|
||||
creatorMemberService = Mockito.mock(ChatCharacterCreatorMemberService::class.java),
|
||||
applicationEventPublisher = Mockito.mock(ApplicationEventPublisher::class.java),
|
||||
apiKey = "test-api-key",
|
||||
apiUrl = "https://example.com",
|
||||
s3Bucket = "test-bucket",
|
||||
imageHost = "https://cdn.example.com"
|
||||
)
|
||||
}
|
||||
|
||||
private fun controllerForMutation(
|
||||
server: HttpServer? = null,
|
||||
service: ChatCharacterService = chatCharacterServiceFake(),
|
||||
originalWorkService: AdminOriginalWorkService = Mockito.mock(AdminOriginalWorkService::class.java)
|
||||
): AdminChatCharacterController {
|
||||
val amazonS3Client = Mockito.mock(AmazonS3Client::class.java) { invocation ->
|
||||
if (invocation.method.name == "getUrl") {
|
||||
URL("https://cdn.example.com/characters/1/character.png")
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}
|
||||
return AdminChatCharacterController(
|
||||
service = service,
|
||||
adminService = Mockito.mock(AdminChatCharacterService::class.java),
|
||||
s3Uploader = S3Uploader(amazonS3Client),
|
||||
originalWorkService = originalWorkService,
|
||||
creatorMemberService = Mockito.mock(ChatCharacterCreatorMemberService::class.java),
|
||||
applicationEventPublisher = Mockito.mock(ApplicationEventPublisher::class.java),
|
||||
apiKey = "test-api-key",
|
||||
apiUrl = server?.let { "http://127.0.0.1:${it.address.port}" } ?: "https://example.com",
|
||||
s3Bucket = "test-bucket",
|
||||
imageHost = "https://cdn.example.com"
|
||||
)
|
||||
}
|
||||
|
||||
private fun chatCharacterServiceFake(): ChatCharacterService {
|
||||
val character = chatCharacter(id = 1L, uuid = "remote-id")
|
||||
return Mockito.mock(ChatCharacterService::class.java) { invocation ->
|
||||
when (invocation.method.name) {
|
||||
"findByName" -> null
|
||||
"findById" -> character
|
||||
"createChatCharacterWithDetails" -> character
|
||||
"saveChatCharacter" -> invocation.arguments[0]
|
||||
"updateChatCharacterWithDetails" -> character
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun chatCharacter(id: Long, uuid: String): ChatCharacter {
|
||||
return ChatCharacter(
|
||||
characterUUID = uuid,
|
||||
name = "character",
|
||||
description = "description",
|
||||
systemPrompt = "prompt",
|
||||
characterType = CharacterType.Character
|
||||
).apply { this.id = id }
|
||||
}
|
||||
|
||||
private fun characterListResponse(): ChatCharacterListResponse {
|
||||
return ChatCharacterListResponse(
|
||||
id = 1L,
|
||||
name = "character",
|
||||
imageUrl = "https://cdn.example.com/characters/1.png",
|
||||
description = "description",
|
||||
gender = "여성",
|
||||
age = 20,
|
||||
mbti = "INTJ",
|
||||
speechStyle = "calm",
|
||||
speechPattern = "polite",
|
||||
region = "KR",
|
||||
tags = listOf("tag"),
|
||||
createdAt = "2026-07-21 12:00:00",
|
||||
updatedAt = "2026-07-21 12:00:01"
|
||||
)
|
||||
}
|
||||
|
||||
private fun captureRequest(captor: ArgumentCaptor<ChatCharacterUpdateRequest>): ChatCharacterUpdateRequest {
|
||||
return captor.capture() ?: ChatCharacterUpdateRequest(id = 0L)
|
||||
}
|
||||
|
||||
private fun externalCharacterApiServer(requests: MutableList<RecordedHttpRequest>): HttpServer {
|
||||
val server = HttpServer.create(InetSocketAddress("127.0.0.1", 0), 0)
|
||||
server.createContext("/api/characters") { exchange ->
|
||||
requests += RecordedHttpRequest(
|
||||
method = exchange.requestMethod,
|
||||
path = exchange.requestURI.path,
|
||||
body = exchange.requestBody.bufferedReader().use { it.readText() }
|
||||
)
|
||||
val response = """{"success":true,"data":{"id":"remote-id"}}""".toByteArray()
|
||||
exchange.sendResponseHeaders(200, response.size.toLong())
|
||||
exchange.responseBody.use { it.write(response) }
|
||||
}
|
||||
server.start()
|
||||
return server
|
||||
}
|
||||
|
||||
private fun assertRecordedRequest(
|
||||
request: RecordedHttpRequest,
|
||||
method: String,
|
||||
path: String,
|
||||
expectedBody: String
|
||||
) {
|
||||
assertEquals(method, request.method)
|
||||
assertEquals(path, request.path)
|
||||
assertEquals(ObjectMapper().readTree(expectedBody), ObjectMapper().readTree(request.body))
|
||||
}
|
||||
|
||||
private fun imageFile(): MockMultipartFile {
|
||||
return MockMultipartFile("image", "character.png", MediaType.IMAGE_PNG_VALUE, byteArrayOf(1, 2, 3))
|
||||
}
|
||||
|
||||
private fun jsonPart(json: String): MockMultipartFile {
|
||||
return MockMultipartFile("request", "", MediaType.APPLICATION_JSON_VALUE, json.toByteArray())
|
||||
}
|
||||
|
||||
private data class RecordedHttpRequest(
|
||||
val method: String,
|
||||
val path: String,
|
||||
val body: String
|
||||
)
|
||||
|
||||
companion object {
|
||||
private const val LEGACY_EMPTY_SUCCESS_RESPONSE =
|
||||
"""{"success":true,"message":null,"data":null,"errorProperty":null}"""
|
||||
}
|
||||
}
|
||||
|
||||
-469
@@ -1,469 +0,0 @@
|
||||
package kr.co.vividnext.sodalive.admin.chat.original
|
||||
|
||||
import com.amazonaws.services.s3.AmazonS3Client
|
||||
import kr.co.vividnext.sodalive.admin.chat.original.dto.OriginalWorkAssignCharactersRequest
|
||||
import kr.co.vividnext.sodalive.admin.chat.original.dto.OriginalWorkRegisterRequest
|
||||
import kr.co.vividnext.sodalive.admin.chat.original.dto.OriginalWorkUpdateRequest
|
||||
import kr.co.vividnext.sodalive.admin.chat.original.service.AdminOriginalWorkService
|
||||
import kr.co.vividnext.sodalive.aws.s3.S3Uploader
|
||||
import kr.co.vividnext.sodalive.chat.character.ChatCharacter
|
||||
import kr.co.vividnext.sodalive.chat.original.OriginalWork
|
||||
import kr.co.vividnext.sodalive.chat.original.OriginalWorkLink
|
||||
import kr.co.vividnext.sodalive.chat.original.OriginalWorkTag
|
||||
import kr.co.vividnext.sodalive.chat.original.OriginalWorkTagMapping
|
||||
import org.junit.jupiter.api.Assertions.assertEquals
|
||||
import org.junit.jupiter.api.DisplayName
|
||||
import org.junit.jupiter.api.Test
|
||||
import org.mockito.ArgumentCaptor
|
||||
import org.mockito.Mockito
|
||||
import org.springframework.data.domain.PageImpl
|
||||
import org.springframework.data.domain.PageRequest
|
||||
import org.springframework.http.MediaType
|
||||
import org.springframework.mock.web.MockMultipartFile
|
||||
import org.springframework.security.access.prepost.PreAuthorize
|
||||
import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete
|
||||
import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get
|
||||
import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.multipart
|
||||
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.test.web.servlet.setup.MockMvcBuilders
|
||||
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.RequestPart
|
||||
import org.springframework.web.multipart.MultipartFile
|
||||
import java.net.URL
|
||||
|
||||
class AdminOriginalWorkControllerContractTest {
|
||||
@Test
|
||||
@DisplayName("legacy 원작 관리자 API는 기존 base path와 ADMIN 권한 계약을 유지한다")
|
||||
fun shouldKeepLegacyAdminOriginalWorkBaseContract() {
|
||||
val classMapping = AdminOriginalWorkController::class.java.getAnnotation(RequestMapping::class.java)
|
||||
val preAuthorize = AdminOriginalWorkController::class.java.getAnnotation(PreAuthorize::class.java)
|
||||
|
||||
assertEquals("/admin/chat/original", classMapping.value.single())
|
||||
assertEquals("hasRole('ADMIN')", preAuthorize.value)
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("legacy 원작 관리자 mutation 경로와 method를 유지한다")
|
||||
fun shouldKeepLegacyAdminOriginalWorkMutationRoutes() {
|
||||
assertEquals(
|
||||
"/register",
|
||||
method("register", MultipartFile::class.java, String::class.java)
|
||||
.getAnnotation(PostMapping::class.java).value.single()
|
||||
)
|
||||
assertEquals(
|
||||
"/update",
|
||||
method("update", MultipartFile::class.java, String::class.java)
|
||||
.getAnnotation(PutMapping::class.java).value.single()
|
||||
)
|
||||
assertEquals(
|
||||
"/{id}",
|
||||
method("delete", Long::class.java).getAnnotation(DeleteMapping::class.java).value.single()
|
||||
)
|
||||
assertEquals(
|
||||
"/{id}/assign-characters",
|
||||
method("assignCharacters", Long::class.java, OriginalWorkAssignCharactersRequest::class.java)
|
||||
.getAnnotation(PostMapping::class.java).value.single()
|
||||
)
|
||||
assertEquals(
|
||||
"/{id}/unassign-characters",
|
||||
method("unassignCharacters", Long::class.java, OriginalWorkAssignCharactersRequest::class.java)
|
||||
.getAnnotation(PostMapping::class.java).value.single()
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("legacy 원작 관리자 mutation request annotation을 유지한다")
|
||||
fun shouldKeepLegacyAdminOriginalWorkMutationRequestAnnotations() {
|
||||
val register = method("register", MultipartFile::class.java, String::class.java)
|
||||
val update = method("update", MultipartFile::class.java, String::class.java)
|
||||
val delete = method("delete", Long::class.java)
|
||||
val assign = method("assignCharacters", Long::class.java, OriginalWorkAssignCharactersRequest::class.java)
|
||||
val unassign = method("unassignCharacters", Long::class.java, OriginalWorkAssignCharactersRequest::class.java)
|
||||
|
||||
assertEquals("image", register.parameters[0].getAnnotation(RequestPart::class.java).value)
|
||||
assertEquals("request", register.parameters[1].getAnnotation(RequestPart::class.java).value)
|
||||
assertEquals("image", update.parameters[0].getAnnotation(RequestPart::class.java).value)
|
||||
assertEquals(false, update.parameters[0].getAnnotation(RequestPart::class.java).required)
|
||||
assertEquals("request", update.parameters[1].getAnnotation(RequestPart::class.java).value)
|
||||
assertEquals(true, delete.parameters[0].isAnnotationPresent(PathVariable::class.java))
|
||||
assertEquals(true, assign.parameters[0].isAnnotationPresent(PathVariable::class.java))
|
||||
assertEquals(true, assign.parameters[1].isAnnotationPresent(RequestBody::class.java))
|
||||
assertEquals(true, unassign.parameters[0].isAnnotationPresent(PathVariable::class.java))
|
||||
assertEquals(true, unassign.parameters[1].isAnnotationPresent(RequestBody::class.java))
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("legacy 원작 관리자 조회 경로와 request DTO 기본 field를 유지한다")
|
||||
fun shouldKeepLegacyAdminOriginalWorkReadRoutesAndDtos() {
|
||||
assertEquals(
|
||||
"/list",
|
||||
method("list", Int::class.java, Int::class.java).getAnnotation(GetMapping::class.java).value.single()
|
||||
)
|
||||
assertEquals(
|
||||
"/search",
|
||||
method("search", String::class.java).getAnnotation(GetMapping::class.java).value.single()
|
||||
)
|
||||
assertEquals(
|
||||
"/{id}",
|
||||
method("detail", Long::class.java).getAnnotation(GetMapping::class.java).value.single()
|
||||
)
|
||||
assertEquals(
|
||||
"/{id}/characters",
|
||||
method("listCharactersOfOriginal", Long::class.java, Int::class.java, Int::class.java)
|
||||
.getAnnotation(GetMapping::class.java).value.single()
|
||||
)
|
||||
|
||||
val register = OriginalWorkRegisterRequest(title = "title", contentType = "type", category = "category")
|
||||
val registerWithAllFields = OriginalWorkRegisterRequest(
|
||||
title = "title",
|
||||
contentType = "type",
|
||||
category = "category",
|
||||
isAdult = true,
|
||||
description = "description",
|
||||
originalWork = "source",
|
||||
originalLink = "https://source.test",
|
||||
writer = "writer",
|
||||
studio = "studio",
|
||||
originalLinks = listOf("https://link.test"),
|
||||
tags = listOf("tag")
|
||||
)
|
||||
val update = OriginalWorkUpdateRequest(
|
||||
id = 1L,
|
||||
title = "title",
|
||||
contentType = "type",
|
||||
category = "category",
|
||||
isAdult = null,
|
||||
description = "description",
|
||||
originalWork = "source",
|
||||
originalLink = "https://source.test",
|
||||
writer = "writer",
|
||||
studio = "studio",
|
||||
originalLinks = listOf("https://link.test"),
|
||||
tags = listOf("tag")
|
||||
)
|
||||
|
||||
assertEquals("title", register.title)
|
||||
assertEquals(false, register.isAdult)
|
||||
assertEquals(true, registerWithAllFields.isAdult)
|
||||
assertEquals("source", registerWithAllFields.originalWork)
|
||||
assertEquals("https://source.test", registerWithAllFields.originalLink)
|
||||
assertEquals("writer", registerWithAllFields.writer)
|
||||
assertEquals("studio", registerWithAllFields.studio)
|
||||
assertEquals(listOf("https://link.test"), registerWithAllFields.originalLinks)
|
||||
assertEquals(listOf("tag"), registerWithAllFields.tags)
|
||||
assertEquals(1L, update.id)
|
||||
assertEquals("type", update.contentType)
|
||||
assertEquals("category", update.category)
|
||||
assertEquals(null, update.isAdult)
|
||||
assertEquals("description", update.description)
|
||||
assertEquals("source", update.originalWork)
|
||||
assertEquals("https://source.test", update.originalLink)
|
||||
assertEquals("writer", update.writer)
|
||||
assertEquals("studio", update.studio)
|
||||
assertEquals(listOf("https://link.test"), update.originalLinks)
|
||||
assertEquals(listOf("tag"), update.tags)
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("legacy 원작 관리자 목록은 기존 성공 응답 surface를 유지한다")
|
||||
fun shouldKeepLegacyAdminOriginalWorkListResponseSurface() {
|
||||
val service = Mockito.mock(AdminOriginalWorkService::class.java)
|
||||
Mockito.`when`(service.getOriginalWorkPage(0, 20))
|
||||
.thenReturn(PageImpl(listOf(originalWork()), PageRequest.of(0, 20), 1))
|
||||
val controller = AdminOriginalWorkController(
|
||||
originalWorkService = service,
|
||||
s3Uploader = Mockito.mock(S3Uploader::class.java),
|
||||
s3Bucket = "test-bucket",
|
||||
imageHost = "https://cdn.test"
|
||||
)
|
||||
val mockMvc = MockMvcBuilders.standaloneSetup(controller).build()
|
||||
|
||||
mockMvc.perform(get("/admin/chat/original/list").param("page", "0").param("size", "20"))
|
||||
.andExpect(status().isOk)
|
||||
.andExpect(jsonPath("$.success").value(true))
|
||||
.andExpect(jsonPath("$.data.totalCount").value(1))
|
||||
.andExpect(jsonPath("$.data.content").isArray)
|
||||
.andExpect(jsonPath("$.data.content[0].id").value(1))
|
||||
.andExpect(jsonPath("$.data.content[0].title").value("title"))
|
||||
.andExpect(jsonPath("$.data.content[0].contentType").value("webtoon"))
|
||||
.andExpect(jsonPath("$.data.content[0].category").value("romance"))
|
||||
.andExpect(jsonPath("$.data.content[0].isAdult").value(false))
|
||||
.andExpect(jsonPath("$.data.content[0].description").value("description"))
|
||||
.andExpect(jsonPath("$.data.content[0].originalWork").value("source"))
|
||||
.andExpect(jsonPath("$.data.content[0].originalLink").value("https://source.test"))
|
||||
.andExpect(jsonPath("$.data.content[0].writer").value("writer"))
|
||||
.andExpect(jsonPath("$.data.content[0].studio").value("studio"))
|
||||
.andExpect(jsonPath("$.data.content[0].originalLinks[0]").value("https://link.test"))
|
||||
.andExpect(jsonPath("$.data.content[0].tags[0]").value("tag"))
|
||||
.andExpect(jsonPath("$.data.content[0].imageUrl").value("https://cdn.test/originals/1.png"))
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("legacy 원작 검색은 searchTerm을 service에 전달하고 기존 성공 응답을 유지한다")
|
||||
fun shouldKeepLegacyAdminOriginalWorkSearchRequestAndResponseSurface() {
|
||||
val service = Mockito.mock(AdminOriginalWorkService::class.java)
|
||||
Mockito.`when`(service.searchOriginalWorksAll("title")).thenReturn(listOf(originalWork()))
|
||||
val mockMvc = MockMvcBuilders.standaloneSetup(controller(service = service)).build()
|
||||
|
||||
mockMvc.perform(get("/admin/chat/original/search").param("searchTerm", "title"))
|
||||
.andExpect(status().isOk)
|
||||
.andExpect(jsonPath("$.success").value(true))
|
||||
.andExpect(jsonPath("$.data").isArray)
|
||||
.andExpect(jsonPath("$.data[0].id").value(1))
|
||||
.andExpect(jsonPath("$.data[0].title").value("title"))
|
||||
.andExpect(jsonPath("$.data[0].contentType").value("webtoon"))
|
||||
.andExpect(jsonPath("$.data[0].category").value("romance"))
|
||||
.andExpect(jsonPath("$.data[0].isAdult").value(false))
|
||||
.andExpect(jsonPath("$.data[0].description").value("description"))
|
||||
.andExpect(jsonPath("$.data[0].originalWork").value("source"))
|
||||
.andExpect(jsonPath("$.data[0].originalLink").value("https://source.test"))
|
||||
.andExpect(jsonPath("$.data[0].writer").value("writer"))
|
||||
.andExpect(jsonPath("$.data[0].studio").value("studio"))
|
||||
.andExpect(jsonPath("$.data[0].originalLinks[0]").value("https://link.test"))
|
||||
.andExpect(jsonPath("$.data[0].tags[0]").value("tag"))
|
||||
.andExpect(jsonPath("$.data[0].imageUrl").value("https://cdn.test/originals/1.png"))
|
||||
|
||||
Mockito.verify(service).searchOriginalWorksAll("title")
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("legacy 원작 관리자 상세은 기존 성공 응답 surface를 유지한다")
|
||||
fun shouldKeepLegacyAdminOriginalWorkDetailResponseSurface() {
|
||||
val service = Mockito.mock(AdminOriginalWorkService::class.java)
|
||||
Mockito.`when`(service.getOriginalWork(1L)).thenReturn(originalWork())
|
||||
val mockMvc = MockMvcBuilders.standaloneSetup(controller(service = service)).build()
|
||||
|
||||
mockMvc.perform(get("/admin/chat/original/1"))
|
||||
.andExpect(status().isOk)
|
||||
.andExpect(jsonPath("$.success").value(true))
|
||||
.andExpect(jsonPath("$.data.id").value(1))
|
||||
.andExpect(jsonPath("$.data.title").value("title"))
|
||||
.andExpect(jsonPath("$.data.contentType").value("webtoon"))
|
||||
.andExpect(jsonPath("$.data.category").value("romance"))
|
||||
.andExpect(jsonPath("$.data.isAdult").value(false))
|
||||
.andExpect(jsonPath("$.data.description").value("description"))
|
||||
.andExpect(jsonPath("$.data.originalWork").value("source"))
|
||||
.andExpect(jsonPath("$.data.originalLink").value("https://source.test"))
|
||||
.andExpect(jsonPath("$.data.writer").value("writer"))
|
||||
.andExpect(jsonPath("$.data.studio").value("studio"))
|
||||
.andExpect(jsonPath("$.data.originalLinks[0]").value("https://link.test"))
|
||||
.andExpect(jsonPath("$.data.tags[0]").value("tag"))
|
||||
.andExpect(jsonPath("$.data.imageUrl").value("https://cdn.test/originals/1.png"))
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("legacy 원작 연결 캐릭터 목록은 기존 성공 응답 surface를 유지한다")
|
||||
fun shouldKeepLegacyAdminOriginalWorkCharactersResponseSurface() {
|
||||
val service = Mockito.mock(AdminOriginalWorkService::class.java)
|
||||
val character = ChatCharacter(
|
||||
characterUUID = "uuid",
|
||||
name = "character",
|
||||
description = "description",
|
||||
systemPrompt = "prompt"
|
||||
).apply {
|
||||
id = 10L
|
||||
imagePath = "characters/10.png"
|
||||
}
|
||||
Mockito.`when`(service.getCharactersOfOriginalWorkPage(1L, 0, 20))
|
||||
.thenReturn(PageImpl(listOf(character), PageRequest.of(0, 20), 1))
|
||||
val mockMvc = MockMvcBuilders.standaloneSetup(controller(service = service)).build()
|
||||
|
||||
mockMvc.perform(get("/admin/chat/original/1/characters").param("page", "0").param("size", "20"))
|
||||
.andExpect(status().isOk)
|
||||
.andExpect(jsonPath("$.success").value(true))
|
||||
.andExpect(jsonPath("$.data.totalCount").value(1))
|
||||
.andExpect(jsonPath("$.data.content").isArray)
|
||||
.andExpect(jsonPath("$.data.content[0].id").value(10))
|
||||
.andExpect(jsonPath("$.data.content[0].name").value("character"))
|
||||
.andExpect(jsonPath("$.data.content[0].imagePath").value("https://cdn.test/characters/10.png"))
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("legacy 원작 관리자 mutation은 기존 성공 응답 surface를 유지한다")
|
||||
fun shouldKeepLegacyAdminOriginalWorkMutationResponseSurface() {
|
||||
val service = Mockito.mock(AdminOriginalWorkService::class.java)
|
||||
val amazonS3Client = Mockito.mock(AmazonS3Client::class.java) { invocation ->
|
||||
if (invocation.method.name == "getUrl") {
|
||||
URL("https://cdn.test/originals/1/original.png")
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}
|
||||
val s3Uploader = S3Uploader(amazonS3Client)
|
||||
val saved = OriginalWork(title = "title", contentType = "type", category = "category").apply { id = 1L }
|
||||
val createRequest = OriginalWorkRegisterRequest(
|
||||
title = "title",
|
||||
contentType = "type",
|
||||
category = "category",
|
||||
isAdult = false,
|
||||
description = "",
|
||||
originalWork = "source",
|
||||
originalLink = "https://source.test",
|
||||
writer = "writer",
|
||||
studio = "studio",
|
||||
originalLinks = listOf("https://link.test"),
|
||||
tags = listOf("tag")
|
||||
)
|
||||
Mockito.`when`(service.createOriginalWork(createRequest)).thenReturn(saved)
|
||||
val mockMvc = MockMvcBuilders.standaloneSetup(controller(service = service, s3Uploader = s3Uploader)).build()
|
||||
|
||||
val registerRequest = """
|
||||
{
|
||||
"title":"title",
|
||||
"contentType":"type",
|
||||
"category":"category",
|
||||
"isAdult":false,
|
||||
"description":"",
|
||||
"originalWork":"source",
|
||||
"originalLink":"https://source.test",
|
||||
"writer":"writer",
|
||||
"studio":"studio",
|
||||
"originalLinks":["https://link.test"],
|
||||
"tags":["tag"]
|
||||
}
|
||||
""".trimIndent()
|
||||
mockMvc.perform(multipart("/admin/chat/original/register").file(imageFile()).file(jsonPart("request", registerRequest)))
|
||||
.andExpect(status().isOk)
|
||||
.andExpect(jsonPath("$.success").value(true))
|
||||
.andExpect(content().json(LEGACY_EMPTY_SUCCESS_RESPONSE, true))
|
||||
|
||||
val updateRequest = """
|
||||
{
|
||||
"id":1,
|
||||
"title":"title",
|
||||
"contentType":"type",
|
||||
"category":"category",
|
||||
"isAdult":null,
|
||||
"description":"description",
|
||||
"originalWork":"source",
|
||||
"originalLink":"https://source.test",
|
||||
"writer":"writer",
|
||||
"studio":"studio",
|
||||
"originalLinks":["https://link.test"],
|
||||
"tags":["tag"]
|
||||
}
|
||||
""".trimIndent()
|
||||
mockMvc.perform(
|
||||
multipart("/admin/chat/original/update")
|
||||
.file(jsonPart("request", updateRequest))
|
||||
.with { request ->
|
||||
request.method = "PUT"
|
||||
request
|
||||
}
|
||||
)
|
||||
.andExpect(status().isOk)
|
||||
.andExpect(jsonPath("$.success").value(true))
|
||||
.andExpect(content().json(LEGACY_EMPTY_SUCCESS_RESPONSE, true))
|
||||
|
||||
val updateRequestCaptor = ArgumentCaptor.forClass(OriginalWorkUpdateRequest::class.java)
|
||||
Mockito.verify(service).updateOriginalWork(captureUpdateRequest(updateRequestCaptor), Mockito.isNull())
|
||||
assertEquals(
|
||||
OriginalWorkUpdateRequest(
|
||||
id = 1L,
|
||||
title = "title",
|
||||
contentType = "type",
|
||||
category = "category",
|
||||
isAdult = null,
|
||||
description = "description",
|
||||
originalWork = "source",
|
||||
originalLink = "https://source.test",
|
||||
writer = "writer",
|
||||
studio = "studio",
|
||||
originalLinks = listOf("https://link.test"),
|
||||
tags = listOf("tag")
|
||||
),
|
||||
updateRequestCaptor.value
|
||||
)
|
||||
|
||||
mockMvc.perform(delete("/admin/chat/original/1"))
|
||||
.andExpect(status().isOk)
|
||||
.andExpect(jsonPath("$.success").value(true))
|
||||
.andExpect(content().json(LEGACY_EMPTY_SUCCESS_RESPONSE, true))
|
||||
Mockito.verify(service).deleteOriginalWork(1L)
|
||||
|
||||
val assignBody = """{"characterIds":[1]}"""
|
||||
mockMvc.perform(
|
||||
post("/admin/chat/original/1/assign-characters")
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content(assignBody)
|
||||
)
|
||||
.andExpect(status().isOk)
|
||||
.andExpect(jsonPath("$.success").value(true))
|
||||
.andExpect(content().json(LEGACY_EMPTY_SUCCESS_RESPONSE, true))
|
||||
Mockito.verify(service).assignCharacters(1L, listOf(1L))
|
||||
|
||||
mockMvc.perform(
|
||||
post("/admin/chat/original/1/unassign-characters")
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content(assignBody)
|
||||
)
|
||||
.andExpect(status().isOk)
|
||||
.andExpect(jsonPath("$.success").value(true))
|
||||
.andExpect(content().json(LEGACY_EMPTY_SUCCESS_RESPONSE, true))
|
||||
Mockito.verify(service).unassignCharacters(1L, listOf(1L))
|
||||
}
|
||||
|
||||
private fun controller(
|
||||
service: AdminOriginalWorkService = Mockito.mock(AdminOriginalWorkService::class.java),
|
||||
s3Uploader: S3Uploader = Mockito.mock(S3Uploader::class.java)
|
||||
): AdminOriginalWorkController {
|
||||
return AdminOriginalWorkController(
|
||||
originalWorkService = service,
|
||||
s3Uploader = s3Uploader,
|
||||
s3Bucket = "test-bucket",
|
||||
imageHost = "https://cdn.test"
|
||||
)
|
||||
}
|
||||
|
||||
private fun imageFile(): MockMultipartFile {
|
||||
return MockMultipartFile("image", "original.png", MediaType.IMAGE_PNG_VALUE, byteArrayOf(1, 2, 3))
|
||||
}
|
||||
|
||||
private fun originalWork(): OriginalWork {
|
||||
val originalWork = OriginalWork(
|
||||
title = "title",
|
||||
contentType = "webtoon",
|
||||
category = "romance",
|
||||
isAdult = false,
|
||||
description = "description",
|
||||
originalWork = "source",
|
||||
originalLink = "https://source.test",
|
||||
writer = "writer",
|
||||
studio = "studio"
|
||||
).apply {
|
||||
id = 1L
|
||||
imagePath = "originals/1.png"
|
||||
}
|
||||
originalWork.originalLinks += OriginalWorkLink("https://link.test", originalWork)
|
||||
originalWork.tagMappings += OriginalWorkTagMapping(originalWork, OriginalWorkTag("tag"))
|
||||
return originalWork
|
||||
}
|
||||
|
||||
private fun jsonPart(name: String, json: String): MockMultipartFile {
|
||||
return MockMultipartFile(name, "", MediaType.APPLICATION_JSON_VALUE, json.toByteArray())
|
||||
}
|
||||
|
||||
private fun method(name: String, vararg parameterTypes: Class<*>): java.lang.reflect.Method {
|
||||
return AdminOriginalWorkController::class.java.getDeclaredMethod(name, *parameterTypes)
|
||||
}
|
||||
|
||||
private fun captureUpdateRequest(
|
||||
captor: ArgumentCaptor<OriginalWorkUpdateRequest>
|
||||
): OriginalWorkUpdateRequest {
|
||||
return captor.capture() ?: OriginalWorkUpdateRequest(id = 0L)
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val LEGACY_EMPTY_SUCCESS_RESPONSE =
|
||||
"""{"success":true,"message":null,"data":null,"errorProperty":null}"""
|
||||
}
|
||||
}
|
||||
+2
-27
@@ -6,7 +6,6 @@ import kr.co.vividnext.sodalive.member.Member
|
||||
import kr.co.vividnext.sodalive.member.MemberRepository
|
||||
import kr.co.vividnext.sodalive.member.MemberRole
|
||||
import kr.co.vividnext.sodalive.member.login.LoginRequest
|
||||
import kr.co.vividnext.sodalive.member.token.MemberToken
|
||||
import kr.co.vividnext.sodalive.member.token.MemberTokenRepository
|
||||
import org.junit.jupiter.api.Assertions.assertEquals
|
||||
import org.junit.jupiter.api.Assertions.assertThrows
|
||||
@@ -19,22 +18,19 @@ import org.springframework.security.crypto.password.PasswordEncoder
|
||||
|
||||
class AdminMemberLoginServiceTest {
|
||||
private lateinit var repository: AdminMemberRepository
|
||||
private lateinit var memberRepository: MemberRepository
|
||||
private lateinit var passwordEncoder: PasswordEncoder
|
||||
private lateinit var tokenRepository: MemberTokenRepository
|
||||
private lateinit var tokenProvider: TokenProvider
|
||||
private lateinit var service: AdminMemberLoginService
|
||||
|
||||
@BeforeEach
|
||||
fun setup() {
|
||||
repository = mock()
|
||||
memberRepository = mock()
|
||||
passwordEncoder = mock()
|
||||
tokenRepository = mock()
|
||||
tokenProvider = TokenProvider(
|
||||
val tokenProvider = TokenProvider(
|
||||
secret = "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA==",
|
||||
tokenValidityInSeconds = 3600,
|
||||
repository = memberRepository,
|
||||
repository = mock<MemberRepository>(),
|
||||
tokenRepository = tokenRepository
|
||||
)
|
||||
tokenProvider.afterPropertiesSet()
|
||||
@@ -58,27 +54,6 @@ class AdminMemberLoginServiceTest {
|
||||
assertEquals(MemberRole.ADMIN, response.role)
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("관리자 로그인 token은 기존 TokenProvider로 검증하고 인증 정보를 복원할 수 있다")
|
||||
fun shouldCreateUsableAdminToken() {
|
||||
val member = createMember(id = 1L, role = MemberRole.ADMIN)
|
||||
var savedToken: MemberToken? = null
|
||||
Mockito.`when`(repository.findByEmail("admin@test.com")).thenReturn(member)
|
||||
Mockito.`when`(memberRepository.findById(Mockito.eq(1L))).thenReturn(java.util.Optional.of(member))
|
||||
Mockito.`when`(tokenRepository.findById(Mockito.eq(1L))).thenAnswer {
|
||||
java.util.Optional.ofNullable(savedToken)
|
||||
}
|
||||
Mockito.`when`(tokenRepository.save(Mockito.any(MemberToken::class.java))).thenAnswer { invocation ->
|
||||
(invocation.arguments[0] as MemberToken).also { savedToken = it }
|
||||
}
|
||||
Mockito.`when`(passwordEncoder.matches("password", "encoded-password")).thenReturn(true)
|
||||
|
||||
val response = service.login(LoginRequest(email = "admin@test.com", password = "password"))
|
||||
|
||||
assertTrue(tokenProvider.validateToken(response.token))
|
||||
assertEquals(member.email, tokenProvider.getAuthentication(response.token).name)
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("콘텐츠 관리자는 관리자 로그인 API로 token과 role을 받는다")
|
||||
fun shouldLoginContentManager() {
|
||||
|
||||
-276
@@ -1,276 +0,0 @@
|
||||
package kr.co.vividnext.sodalive.chat.original.controller
|
||||
|
||||
import kr.co.vividnext.sodalive.chat.character.ChatCharacter
|
||||
import kr.co.vividnext.sodalive.chat.character.image.CharacterImageRepository
|
||||
import kr.co.vividnext.sodalive.chat.character.translate.AiCharacterTranslationRepository
|
||||
import kr.co.vividnext.sodalive.chat.original.OriginalWork
|
||||
import kr.co.vividnext.sodalive.chat.original.OriginalWorkLink
|
||||
import kr.co.vividnext.sodalive.chat.original.OriginalWorkTag
|
||||
import kr.co.vividnext.sodalive.chat.original.OriginalWorkTagMapping
|
||||
import kr.co.vividnext.sodalive.chat.original.service.OriginalWorkQueryService
|
||||
import kr.co.vividnext.sodalive.chat.original.service.OriginalWorkTranslationService
|
||||
import kr.co.vividnext.sodalive.chat.original.translation.OriginalWorkTranslationRepository
|
||||
import kr.co.vividnext.sodalive.common.CountryContext
|
||||
import kr.co.vividnext.sodalive.configs.SecurityConfig
|
||||
import kr.co.vividnext.sodalive.content.ContentType
|
||||
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.jwt.JwtAccessDeniedHandler
|
||||
import kr.co.vividnext.sodalive.jwt.JwtAuthenticationEntryPoint
|
||||
import kr.co.vividnext.sodalive.jwt.TokenProvider
|
||||
import kr.co.vividnext.sodalive.member.Member
|
||||
import kr.co.vividnext.sodalive.member.contentpreference.MemberContentPreferenceService
|
||||
import kr.co.vividnext.sodalive.member.contentpreference.ViewerContentPreference
|
||||
import org.junit.jupiter.api.Assertions.assertEquals
|
||||
import org.junit.jupiter.api.Assertions.assertNull
|
||||
import org.junit.jupiter.api.DisplayName
|
||||
import org.junit.jupiter.api.Test
|
||||
import org.mockito.Mockito
|
||||
import org.springframework.beans.factory.annotation.Autowired
|
||||
import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest
|
||||
import org.springframework.boot.test.mock.mockito.MockBean
|
||||
import org.springframework.context.annotation.Import
|
||||
import org.springframework.core.MethodParameter
|
||||
import org.springframework.data.domain.PageImpl
|
||||
import org.springframework.security.access.prepost.PreAuthorize
|
||||
import org.springframework.security.core.annotation.AuthenticationPrincipal
|
||||
import org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.anonymous
|
||||
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.test.web.servlet.setup.MockMvcBuilders
|
||||
import org.springframework.web.bind.annotation.GetMapping
|
||||
import org.springframework.web.bind.annotation.RequestMapping
|
||||
import org.springframework.web.bind.support.WebDataBinderFactory
|
||||
import org.springframework.web.context.request.NativeWebRequest
|
||||
import org.springframework.web.method.support.HandlerMethodArgumentResolver
|
||||
import org.springframework.web.method.support.ModelAndViewContainer
|
||||
import java.time.LocalDateTime
|
||||
|
||||
@WebMvcTest(OriginalWorkController::class)
|
||||
@Import(SecurityConfig::class, JwtAuthenticationEntryPoint::class, JwtAccessDeniedHandler::class)
|
||||
class OriginalWorkControllerContractTest @Autowired constructor(
|
||||
private val securityMockMvc: MockMvc
|
||||
) {
|
||||
@MockBean
|
||||
private lateinit var securedQueryService: OriginalWorkQueryService
|
||||
|
||||
@MockBean
|
||||
private lateinit var securedCharacterImageRepository: CharacterImageRepository
|
||||
|
||||
@MockBean
|
||||
private lateinit var securedMemberContentPreferenceService: MemberContentPreferenceService
|
||||
|
||||
@MockBean
|
||||
private lateinit var securedLangContext: LangContext
|
||||
|
||||
@MockBean
|
||||
private lateinit var securedOriginalWorkTranslationService: OriginalWorkTranslationService
|
||||
|
||||
@MockBean
|
||||
private lateinit var securedOriginalWorkTranslationRepository: OriginalWorkTranslationRepository
|
||||
|
||||
@MockBean
|
||||
private lateinit var securedAiCharacterTranslationRepository: AiCharacterTranslationRepository
|
||||
|
||||
@MockBean
|
||||
private lateinit var tokenProvider: TokenProvider
|
||||
|
||||
@MockBean
|
||||
private lateinit var countryContext: CountryContext
|
||||
|
||||
@MockBean
|
||||
private lateinit var sodaMessageSource: SodaMessageSource
|
||||
|
||||
@Test
|
||||
@DisplayName("일반 사용자 원작 API는 기존 base path와 공개 목록 경로를 유지한다")
|
||||
fun shouldKeepConsumerOriginalWorkListRoute() {
|
||||
val classMapping = OriginalWorkController::class.java.getAnnotation(RequestMapping::class.java)
|
||||
val list = OriginalWorkController::class.java.getDeclaredMethod(
|
||||
"list",
|
||||
Int::class.java,
|
||||
Int::class.java,
|
||||
Member::class.java
|
||||
)
|
||||
|
||||
assertEquals("/api/chat/original", classMapping.value.single())
|
||||
assertEquals("/list", list.getAnnotation(GetMapping::class.java).value.single())
|
||||
assertNull(list.getAnnotation(PreAuthorize::class.java))
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("일반 사용자 원작 상세 API는 기존 경로를 유지한다")
|
||||
fun shouldKeepConsumerOriginalWorkDetailRoute() {
|
||||
val detail = OriginalWorkController::class.java.getDeclaredMethod("detail", Long::class.java, Member::class.java)
|
||||
|
||||
assertEquals("/{id}", detail.getAnnotation(GetMapping::class.java).value.single())
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("production security matcher는 익명 원작 목록은 허용하고 상세는 거부한다")
|
||||
fun shouldApplyProductionSecurityMatcherToAnonymousOriginalWorkRequests() {
|
||||
Mockito.`when`(securedQueryService.listForAppPage(false, 0, 20)).thenReturn(PageImpl(emptyList()))
|
||||
|
||||
securityMockMvc.perform(get("/api/chat/original/list").with(anonymous()))
|
||||
.andExpect(status().isOk)
|
||||
|
||||
securityMockMvc.perform(get("/api/chat/original/1").with(anonymous()))
|
||||
.andExpect(status().isUnauthorized)
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("일반 사용자 원작 목록은 기존 성공 응답 surface를 유지한다")
|
||||
fun shouldKeepConsumerOriginalWorkListResponseSurface() {
|
||||
val queryService = Mockito.mock(OriginalWorkQueryService::class.java)
|
||||
val originalWork = originalWork()
|
||||
Mockito.`when`(queryService.listForAppPage(false, 0, 20)).thenReturn(PageImpl(listOf(originalWork)))
|
||||
val langContext = Mockito.mock(LangContext::class.java)
|
||||
Mockito.`when`(langContext.lang).thenReturn(Lang.KO)
|
||||
val originalWorkTranslationRepository = Mockito.mock(OriginalWorkTranslationRepository::class.java)
|
||||
Mockito.`when`(originalWorkTranslationRepository.findByOriginalWorkIdInAndLocale(setOf(1L), "ko"))
|
||||
.thenReturn(emptyList())
|
||||
val controller = OriginalWorkController(
|
||||
queryService = queryService,
|
||||
characterImageRepository = Mockito.mock(CharacterImageRepository::class.java),
|
||||
memberContentPreferenceService = Mockito.mock(MemberContentPreferenceService::class.java),
|
||||
langContext = langContext,
|
||||
originalWorkTranslationService = Mockito.mock(OriginalWorkTranslationService::class.java),
|
||||
originalWorkTranslationRepository = originalWorkTranslationRepository,
|
||||
aiCharacterTranslationRepository = Mockito.mock(AiCharacterTranslationRepository::class.java),
|
||||
imageHost = "https://cdn.test"
|
||||
)
|
||||
val mockMvc = MockMvcBuilders.standaloneSetup(controller)
|
||||
.setCustomArgumentResolvers(AnonymousMemberArgumentResolver())
|
||||
.build()
|
||||
|
||||
mockMvc.perform(get("/api/chat/original/list").param("page", "0").param("size", "20"))
|
||||
.andExpect(status().isOk)
|
||||
.andExpect(jsonPath("$.success").value(true))
|
||||
.andExpect(jsonPath("$.data.totalCount").value(1))
|
||||
.andExpect(jsonPath("$.data.content").isArray)
|
||||
.andExpect(jsonPath("$.data.content[0].id").value(1))
|
||||
.andExpect(jsonPath("$.data.content[0].imageUrl").value("https://cdn.test/originals/1.png"))
|
||||
.andExpect(jsonPath("$.data.content[0].title").value("title"))
|
||||
.andExpect(jsonPath("$.data.content[0].contentType").value("webtoon"))
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("일반 사용자 원작 상세는 기존 성공 응답 field를 유지한다")
|
||||
fun shouldKeepConsumerOriginalWorkDetailResponseSurface() {
|
||||
val queryService = Mockito.mock(OriginalWorkQueryService::class.java)
|
||||
val originalWork = originalWork()
|
||||
val character = ChatCharacter(
|
||||
characterUUID = "character-uuid",
|
||||
name = "character",
|
||||
description = "character description",
|
||||
systemPrompt = "prompt"
|
||||
).apply {
|
||||
id = 10L
|
||||
imagePath = "characters/10.png"
|
||||
}
|
||||
Mockito.`when`(queryService.getOriginalWork(1L)).thenReturn(originalWork)
|
||||
Mockito.`when`(queryService.getActiveCharactersPage(1L, 0, 20)).thenReturn(PageImpl(listOf(character)))
|
||||
val characterImageRepository = Mockito.mock(CharacterImageRepository::class.java)
|
||||
Mockito.`when`(
|
||||
characterImageRepository.findCharacterIdsWithRecentImages(
|
||||
Mockito.eq(listOf(10L)) ?: listOf(10L),
|
||||
Mockito.any(LocalDateTime::class.java) ?: LocalDateTime.now()
|
||||
)
|
||||
)
|
||||
.thenReturn(listOf(10L))
|
||||
val memberContentPreferenceService = Mockito.mock(MemberContentPreferenceService::class.java)
|
||||
val member = Member(email = "user@test.com", password = "password", nickname = "user").apply { id = 20L }
|
||||
Mockito.`when`(memberContentPreferenceService.getStoredPreference(member))
|
||||
.thenReturn(ViewerContentPreference("KR", true, ContentType.ALL, true))
|
||||
val langContext = Mockito.mock(LangContext::class.java)
|
||||
Mockito.`when`(langContext.lang).thenReturn(Lang.KO)
|
||||
val originalWorkTranslationService = Mockito.mock(OriginalWorkTranslationService::class.java)
|
||||
Mockito.`when`(originalWorkTranslationService.ensureTranslated(originalWork, "ko")).thenReturn(null)
|
||||
val aiCharacterTranslationRepository = Mockito.mock(AiCharacterTranslationRepository::class.java)
|
||||
Mockito.`when`(aiCharacterTranslationRepository.findByCharacterIdInAndLocale(listOf(10L), "ko"))
|
||||
.thenReturn(emptyList())
|
||||
val controller = OriginalWorkController(
|
||||
queryService = queryService,
|
||||
characterImageRepository = characterImageRepository,
|
||||
memberContentPreferenceService = memberContentPreferenceService,
|
||||
langContext = langContext,
|
||||
originalWorkTranslationService = originalWorkTranslationService,
|
||||
originalWorkTranslationRepository = Mockito.mock(OriginalWorkTranslationRepository::class.java),
|
||||
aiCharacterTranslationRepository = aiCharacterTranslationRepository,
|
||||
imageHost = "https://cdn.test"
|
||||
)
|
||||
val mockMvc = MockMvcBuilders.standaloneSetup(controller)
|
||||
.setCustomArgumentResolvers(MemberArgumentResolver(member))
|
||||
.build()
|
||||
|
||||
mockMvc.perform(get("/api/chat/original/1"))
|
||||
.andExpect(status().isOk)
|
||||
.andExpect(jsonPath("$.success").value(true))
|
||||
.andExpect(jsonPath("$.data.imageUrl").value("https://cdn.test/originals/1.png"))
|
||||
.andExpect(jsonPath("$.data.title").value("title"))
|
||||
.andExpect(jsonPath("$.data.contentType").value("webtoon"))
|
||||
.andExpect(jsonPath("$.data.category").value("romance"))
|
||||
.andExpect(jsonPath("$.data.isAdult").value(false))
|
||||
.andExpect(jsonPath("$.data.description").value("description"))
|
||||
.andExpect(jsonPath("$.data.originalWork").value("source"))
|
||||
.andExpect(jsonPath("$.data.originalLink").value("https://source.test"))
|
||||
.andExpect(jsonPath("$.data.writer").value("writer"))
|
||||
.andExpect(jsonPath("$.data.studio").value("studio"))
|
||||
.andExpect(jsonPath("$.data.originalLinks[0]").value("https://link.test"))
|
||||
.andExpect(jsonPath("$.data.tags[0]").value("tag"))
|
||||
.andExpect(jsonPath("$.data.characters[0].characterId").value(10))
|
||||
.andExpect(jsonPath("$.data.characters[0].name").value("character"))
|
||||
.andExpect(jsonPath("$.data.characters[0].description").value("character description"))
|
||||
.andExpect(jsonPath("$.data.characters[0].imageUrl").value("https://cdn.test/characters/10.png"))
|
||||
.andExpect(jsonPath("$.data.characters[0].isNew").value(true))
|
||||
.andExpect(jsonPath("$.data.translated").doesNotExist())
|
||||
}
|
||||
|
||||
private class AnonymousMemberArgumentResolver : HandlerMethodArgumentResolver {
|
||||
override fun supportsParameter(parameter: MethodParameter): Boolean {
|
||||
return parameter.hasParameterAnnotation(AuthenticationPrincipal::class.java)
|
||||
}
|
||||
|
||||
override fun resolveArgument(
|
||||
parameter: MethodParameter,
|
||||
mavContainer: ModelAndViewContainer?,
|
||||
webRequest: NativeWebRequest,
|
||||
binderFactory: WebDataBinderFactory?
|
||||
): Any? = null
|
||||
}
|
||||
|
||||
private class MemberArgumentResolver(private val member: Member) : HandlerMethodArgumentResolver {
|
||||
override fun supportsParameter(parameter: MethodParameter): Boolean {
|
||||
return parameter.hasParameterAnnotation(AuthenticationPrincipal::class.java)
|
||||
}
|
||||
|
||||
override fun resolveArgument(
|
||||
parameter: MethodParameter,
|
||||
mavContainer: ModelAndViewContainer?,
|
||||
webRequest: NativeWebRequest,
|
||||
binderFactory: WebDataBinderFactory?
|
||||
): Any = member
|
||||
}
|
||||
|
||||
private fun originalWork(): OriginalWork {
|
||||
val originalWork = OriginalWork(
|
||||
title = "title",
|
||||
contentType = "webtoon",
|
||||
category = "romance",
|
||||
isAdult = false,
|
||||
description = "description",
|
||||
originalWork = "source",
|
||||
originalLink = "https://source.test",
|
||||
writer = "writer",
|
||||
studio = "studio"
|
||||
).apply {
|
||||
id = 1L
|
||||
imagePath = "originals/1.png"
|
||||
}
|
||||
originalWork.originalLinks += OriginalWorkLink("https://link.test", originalWork)
|
||||
originalWork.tagMappings += OriginalWorkTagMapping(originalWork, OriginalWorkTag("tag"))
|
||||
return originalWork
|
||||
}
|
||||
}
|
||||
-162
@@ -1,162 +0,0 @@
|
||||
package kr.co.vividnext.sodalive.content
|
||||
|
||||
import kr.co.vividnext.sodalive.common.CountryContext
|
||||
import kr.co.vividnext.sodalive.configs.SecurityConfig
|
||||
import kr.co.vividnext.sodalive.i18n.LangContext
|
||||
import kr.co.vividnext.sodalive.i18n.SodaMessageSource
|
||||
import kr.co.vividnext.sodalive.jwt.JwtAccessDeniedHandler
|
||||
import kr.co.vividnext.sodalive.jwt.JwtAuthenticationEntryPoint
|
||||
import kr.co.vividnext.sodalive.jwt.TokenProvider
|
||||
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.contentpreference.MemberContentPreferenceService
|
||||
import org.hamcrest.Matchers.anEmptyMap
|
||||
import org.junit.jupiter.api.Assertions.assertEquals
|
||||
import org.junit.jupiter.api.DisplayName
|
||||
import org.junit.jupiter.api.Test
|
||||
import org.mockito.Mockito
|
||||
import org.mockito.Mockito.verify
|
||||
import org.springframework.beans.factory.annotation.Autowired
|
||||
import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest
|
||||
import org.springframework.boot.test.mock.mockito.MockBean
|
||||
import org.springframework.context.annotation.Import
|
||||
import org.springframework.http.MediaType
|
||||
import org.springframework.security.access.prepost.PreAuthorize
|
||||
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken
|
||||
import org.springframework.test.web.servlet.MockMvc
|
||||
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.web.bind.annotation.PutMapping
|
||||
import org.springframework.web.bind.annotation.RequestMapping
|
||||
|
||||
@WebMvcTest(AudioContentController::class)
|
||||
@Import(SecurityConfig::class, JwtAuthenticationEntryPoint::class, JwtAccessDeniedHandler::class)
|
||||
class AudioContentUploadCompletionContractTest @Autowired constructor(
|
||||
private val mockMvc: MockMvc
|
||||
) {
|
||||
@MockBean
|
||||
private lateinit var service: AudioContentService
|
||||
|
||||
@MockBean
|
||||
private lateinit var memberContentPreferenceService: MemberContentPreferenceService
|
||||
|
||||
@MockBean
|
||||
private lateinit var tokenProvider: TokenProvider
|
||||
|
||||
@MockBean
|
||||
private lateinit var countryContext: CountryContext
|
||||
|
||||
@MockBean
|
||||
private lateinit var langContext: LangContext
|
||||
|
||||
@MockBean
|
||||
private lateinit var sodaMessageSource: SodaMessageSource
|
||||
|
||||
@Test
|
||||
@DisplayName("upload-complete callback은 기존 PUT 경로와 ADMIN/BOT 권한 계약을 유지한다")
|
||||
fun shouldKeepUploadCompleteRouteAndRoles() {
|
||||
val classMapping = AudioContentController::class.java.getAnnotation(RequestMapping::class.java)
|
||||
val method = AudioContentController::class.java.getDeclaredMethod(
|
||||
"uploadComplete",
|
||||
UploadCompleteRequest::class.java,
|
||||
kr.co.vividnext.sodalive.member.Member::class.java
|
||||
)
|
||||
val putMapping = method.getAnnotation(PutMapping::class.java)
|
||||
val preAuthorize = method.getAnnotation(PreAuthorize::class.java)
|
||||
|
||||
assertEquals("/audio-content", classMapping.value.single())
|
||||
assertEquals("/upload-complete", putMapping.value.single())
|
||||
assertEquals("hasAnyRole('ADMIN', 'BOT')", preAuthorize.value)
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("upload-complete request는 기존 contentId/contentPath/duration field를 유지한다")
|
||||
fun shouldKeepUploadCompleteRequestFields() {
|
||||
val request = UploadCompleteRequest(contentId = 1L, contentPath = "1/output.mp3", duration = "00:01:00")
|
||||
|
||||
assertEquals(1L, request.contentId)
|
||||
assertEquals("1/output.mp3", request.contentPath)
|
||||
assertEquals("00:01:00", request.duration)
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("upload-complete callback은 기존 성공 응답 surface를 유지한다")
|
||||
fun shouldKeepUploadCompleteSuccessResponseSurface() {
|
||||
givenToken("bot-token", MemberRole.BOT)
|
||||
|
||||
mockMvc.perform(
|
||||
put("/audio-content/upload-complete")
|
||||
.header("Authorization", "Bearer bot-token")
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("""{"contentId":1,"contentPath":"1/output.mp3","duration":"00:01:00"}""")
|
||||
)
|
||||
.andExpect(status().isOk)
|
||||
.andExpect(jsonPath("$.success").value(true))
|
||||
.andExpect(jsonPath("$.data").value(anEmptyMap<Any, Any>()))
|
||||
.andExpect(content().json(LEGACY_UPLOAD_COMPLETE_SUCCESS_RESPONSE, true))
|
||||
|
||||
verify(service).uploadComplete(1L, "1/output.mp3", "00:01:00")
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("upload-complete callback은 ADMIN/BOT JWT만 허용하고 USER/invalid JWT/익명 요청은 거부한다")
|
||||
fun shouldAuthorizeUploadCompleteWithProductionSecurityChain() {
|
||||
givenToken("admin-token", MemberRole.ADMIN)
|
||||
givenToken("bot-token", MemberRole.BOT)
|
||||
givenToken("user-token", MemberRole.USER)
|
||||
Mockito.`when`(tokenProvider.validateToken("invalid-token")).thenReturn(false)
|
||||
|
||||
mockMvc.perform(uploadCompleteRequest("user-token"))
|
||||
.andExpect(status().isForbidden)
|
||||
|
||||
mockMvc.perform(uploadCompleteRequest("invalid-token"))
|
||||
.andExpect(status().isUnauthorized)
|
||||
|
||||
mockMvc.perform(uploadCompleteRequest("admin-token"))
|
||||
.andExpect(status().isOk)
|
||||
.andExpect(jsonPath("$.success").value(true))
|
||||
|
||||
mockMvc.perform(uploadCompleteRequest("bot-token"))
|
||||
.andExpect(status().isOk)
|
||||
.andExpect(jsonPath("$.success").value(true))
|
||||
|
||||
mockMvc.perform(
|
||||
put("/audio-content/upload-complete")
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content(UPLOAD_COMPLETE_REQUEST)
|
||||
)
|
||||
.andExpect(status().isUnauthorized)
|
||||
}
|
||||
|
||||
private fun uploadCompleteRequest(token: String) = put("/audio-content/upload-complete")
|
||||
.header("Authorization", "Bearer $token")
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content(UPLOAD_COMPLETE_REQUEST)
|
||||
|
||||
private fun givenToken(token: String, role: MemberRole) {
|
||||
val member = Member(
|
||||
email = "${role.name.lowercase()}@test.com",
|
||||
password = "password",
|
||||
nickname = role.name.lowercase(),
|
||||
role = role
|
||||
).apply { id = role.ordinal.toLong() + 1 }
|
||||
val authentication = UsernamePasswordAuthenticationToken(
|
||||
MemberAdapter(member),
|
||||
token,
|
||||
MemberAdapter(member).authorities
|
||||
)
|
||||
Mockito.`when`(tokenProvider.validateToken(token)).thenReturn(true)
|
||||
Mockito.`when`(tokenProvider.getAuthentication(token)).thenReturn(authentication)
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val UPLOAD_COMPLETE_REQUEST =
|
||||
"""{"contentId":1,"contentPath":"1/output.mp3","duration":"00:01:00"}"""
|
||||
|
||||
private const val LEGACY_UPLOAD_COMPLETE_SUCCESS_RESPONSE =
|
||||
"""{"success":true,"message":null,"data":{},"errorProperty":null}"""
|
||||
}
|
||||
}
|
||||
-210
@@ -1,210 +0,0 @@
|
||||
package kr.co.vividnext.sodalive.legacy
|
||||
|
||||
import kr.co.vividnext.sodalive.admin.chat.character.service.AdminChatCharacterService
|
||||
import kr.co.vividnext.sodalive.admin.chat.original.service.AdminOriginalWorkService
|
||||
import kr.co.vividnext.sodalive.chat.character.ChatCharacter
|
||||
import kr.co.vividnext.sodalive.chat.character.ChatCharacterTag
|
||||
import kr.co.vividnext.sodalive.chat.character.repository.ChatCharacterRepository
|
||||
import kr.co.vividnext.sodalive.chat.original.OriginalWork
|
||||
import kr.co.vividnext.sodalive.chat.original.OriginalWorkRepository
|
||||
import kr.co.vividnext.sodalive.chat.original.repository.OriginalWorkTagRepository
|
||||
import kr.co.vividnext.sodalive.configs.QueryDslConfig
|
||||
import kr.co.vividnext.sodalive.member.Member
|
||||
import org.junit.jupiter.api.Assertions.assertEquals
|
||||
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.jdbc.AutoConfigureTestDatabase
|
||||
import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest
|
||||
import org.springframework.context.ApplicationEventPublisher
|
||||
import org.springframework.context.annotation.Import
|
||||
import java.time.LocalDateTime
|
||||
import javax.persistence.EntityManager
|
||||
|
||||
@DataJpaTest(
|
||||
properties = [
|
||||
"spring.cache.type=none",
|
||||
"spring.datasource.url=jdbc:h2:mem:legacy-admin-search-contract;MODE=MySQL;NON_KEYWORDS=VALUE;DB_CLOSE_ON_EXIT=FALSE"
|
||||
]
|
||||
)
|
||||
@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE)
|
||||
@Import(QueryDslConfig::class)
|
||||
class LegacyAdminSearchQueryContractTest @Autowired constructor(
|
||||
private val chatCharacterRepository: ChatCharacterRepository,
|
||||
private val originalWorkRepository: OriginalWorkRepository,
|
||||
private val entityManager: EntityManager
|
||||
) {
|
||||
private val characterService = AdminChatCharacterService(chatCharacterRepository)
|
||||
private val originalWorkService = AdminOriginalWorkService(
|
||||
originalWorkRepository = originalWorkRepository,
|
||||
chatCharacterRepository = chatCharacterRepository,
|
||||
originalWorkTagRepository = Mockito.mock(OriginalWorkTagRepository::class.java),
|
||||
applicationEventPublisher = Mockito.mock(ApplicationEventPublisher::class.java)
|
||||
)
|
||||
|
||||
@Test
|
||||
@DisplayName("legacy 캐릭터 검색은 이름, 설명, MBTI, 태그를 검색하고 비활성 캐릭터를 제외한다")
|
||||
fun shouldSearchActiveLegacyCharactersByEverySupportedField() {
|
||||
val nameId = saveCharacter(name = "NameNeedle", description = "plain-name-description").id!!
|
||||
val descriptionId = saveCharacter(name = "description-character", description = "DescriptionNeedle").id!!
|
||||
val mbtiId = saveCharacter(
|
||||
name = "mbti-character",
|
||||
description = "plain-mbti-description",
|
||||
mbti = "MbtiNeedle"
|
||||
).id!!
|
||||
val tagId = saveCharacter(name = "tag-character", description = "plain-tag-description", tag = "TagNeedle").id!!
|
||||
saveCharacter(name = "InactiveNeedle", description = "inactive-description", isActive = false)
|
||||
entityManager.clear()
|
||||
|
||||
assertEquals(listOf(nameId), searchCharacterIds("nameneedle"))
|
||||
assertEquals(listOf(descriptionId), searchCharacterIds("descriptionneedle"))
|
||||
assertEquals(listOf(mbtiId), searchCharacterIds("mbtineedle"))
|
||||
assertEquals(listOf(tagId), searchCharacterIds("tagneedle"))
|
||||
assertEquals(emptyList<Long>(), searchCharacterIds("inactiveneedle"))
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("legacy 캐릭터 검색은 최신순과 비기본 page, size, totalCount 계약을 유지한다")
|
||||
fun shouldPageLegacyCharacterSearchByCreatedAtDescending() {
|
||||
val baseTime = LocalDateTime.of(2026, 7, 21, 0, 0)
|
||||
repeat(5) { index ->
|
||||
saveCharacter(
|
||||
name = "paging-needle-$index",
|
||||
description = "paging-description-$index",
|
||||
createdAt = baseTime.plusMinutes(index.toLong())
|
||||
)
|
||||
}
|
||||
saveCharacter(
|
||||
name = "paging-needle-inactive",
|
||||
description = "paging-inactive-description",
|
||||
isActive = false,
|
||||
createdAt = baseTime.plusMinutes(10)
|
||||
)
|
||||
entityManager.clear()
|
||||
|
||||
val pageable = characterService.createDefaultPageRequest(page = 1, size = 2)
|
||||
val result = characterService.searchCharacters("paging-needle", pageable)
|
||||
|
||||
assertEquals(5L, result.totalElements)
|
||||
assertEquals(1, result.number)
|
||||
assertEquals(2, result.size)
|
||||
assertEquals(listOf("paging-needle-2", "paging-needle-1"), result.content.map { it.name })
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("legacy 원작 검색은 제목, 콘텐츠 타입, 카테고리를 검색하고 삭제 원작을 제외한다")
|
||||
fun shouldSearchNonDeletedLegacyOriginalWorksByEverySupportedField() {
|
||||
val titleId = saveOriginalWork(
|
||||
title = "TitleNeedle",
|
||||
contentType = "plain-title-type",
|
||||
category = "plain-title-category"
|
||||
).id!!
|
||||
val contentTypeId = saveOriginalWork(
|
||||
title = "content-type-work",
|
||||
contentType = "ContentTypeNeedle",
|
||||
category = "plain-content-type-category"
|
||||
).id!!
|
||||
val categoryId = saveOriginalWork(
|
||||
title = "category-work",
|
||||
contentType = "plain-category-type",
|
||||
category = "CategoryNeedle"
|
||||
).id!!
|
||||
saveOriginalWork(
|
||||
title = "DeletedNeedle",
|
||||
contentType = "deleted-type",
|
||||
category = "deleted-category",
|
||||
isDeleted = true
|
||||
)
|
||||
entityManager.clear()
|
||||
|
||||
assertEquals(listOf(titleId), searchOriginalWorkIds("titleneedle"))
|
||||
assertEquals(listOf(contentTypeId), searchOriginalWorkIds("contenttypeneedle"))
|
||||
assertEquals(listOf(categoryId), searchOriginalWorkIds("categoryneedle"))
|
||||
assertEquals(emptyList<Long>(), searchOriginalWorkIds("deletedneedle"))
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("legacy 원작 검색은 최신순 무페이징 목록 계약을 유지한다")
|
||||
fun shouldReturnAllLegacyOriginalWorkSearchResultsByCreatedAtDescending() {
|
||||
val baseTime = LocalDateTime.of(2026, 7, 21, 0, 0)
|
||||
repeat(3) { index ->
|
||||
saveOriginalWork(
|
||||
title = "ordering-needle-$index",
|
||||
contentType = "ordering-type-$index",
|
||||
category = "ordering-category-$index",
|
||||
createdAt = baseTime.plusMinutes(index.toLong())
|
||||
)
|
||||
}
|
||||
entityManager.clear()
|
||||
|
||||
val result = originalWorkService.searchOriginalWorksAll("ordering-needle")
|
||||
|
||||
assertEquals(
|
||||
listOf("ordering-needle-2", "ordering-needle-1", "ordering-needle-0"),
|
||||
result.map { it.title }
|
||||
)
|
||||
}
|
||||
|
||||
private fun searchCharacterIds(searchTerm: String): List<Long> {
|
||||
val pageable = characterService.createDefaultPageRequest(page = 0, size = 20)
|
||||
return characterService.searchCharacters(searchTerm, pageable).content.map { it.id }
|
||||
}
|
||||
|
||||
private fun searchOriginalWorkIds(searchTerm: String): List<Long> {
|
||||
return originalWorkService.searchOriginalWorksAll(searchTerm).map { it.id!! }
|
||||
}
|
||||
|
||||
private fun saveCharacter(
|
||||
name: String,
|
||||
description: String,
|
||||
mbti: String? = null,
|
||||
tag: String? = null,
|
||||
isActive: Boolean = true,
|
||||
createdAt: LocalDateTime? = null
|
||||
): ChatCharacter {
|
||||
val character = ChatCharacter(
|
||||
characterUUID = "$name-uuid",
|
||||
name = name,
|
||||
description = description,
|
||||
systemPrompt = "$name-system-prompt",
|
||||
mbti = mbti,
|
||||
isActive = isActive
|
||||
)
|
||||
character.creatorMember = Member(
|
||||
email = "$name@test.com",
|
||||
password = "password",
|
||||
nickname = "$name-creator"
|
||||
).also(entityManager::persist)
|
||||
tag?.let { tagName ->
|
||||
val tagEntity = ChatCharacterTag(tagName).also(entityManager::persist)
|
||||
character.addTag(tagEntity)
|
||||
}
|
||||
chatCharacterRepository.saveAndFlush(character)
|
||||
if (createdAt != null) {
|
||||
character.createdAt = createdAt
|
||||
entityManager.flush()
|
||||
}
|
||||
return character
|
||||
}
|
||||
|
||||
private fun saveOriginalWork(
|
||||
title: String,
|
||||
contentType: String,
|
||||
category: String,
|
||||
isDeleted: Boolean = false,
|
||||
createdAt: LocalDateTime? = null
|
||||
): OriginalWork {
|
||||
val originalWork = OriginalWork(
|
||||
title = title,
|
||||
contentType = contentType,
|
||||
category = category
|
||||
).apply { this.isDeleted = isDeleted }
|
||||
originalWorkRepository.saveAndFlush(originalWork)
|
||||
if (createdAt != null) {
|
||||
originalWork.createdAt = createdAt
|
||||
entityManager.flush()
|
||||
}
|
||||
return originalWork
|
||||
}
|
||||
}
|
||||
-119
@@ -1,119 +0,0 @@
|
||||
package kr.co.vividnext.sodalive.legacy
|
||||
|
||||
import kr.co.vividnext.sodalive.common.ApiResponse
|
||||
import kr.co.vividnext.sodalive.common.SodaException
|
||||
import kr.co.vividnext.sodalive.support.EmbeddedRedisInitializer
|
||||
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.boot.test.mock.mockito.MockBean
|
||||
import org.springframework.context.annotation.Import
|
||||
import org.springframework.http.HttpStatus
|
||||
import org.springframework.http.MediaType
|
||||
import org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.user
|
||||
import org.springframework.test.annotation.DirtiesContext
|
||||
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.request.MockMvcRequestBuilders.post
|
||||
import org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath
|
||||
import org.springframework.test.web.servlet.result.MockMvcResultMatchers.status
|
||||
import org.springframework.web.bind.annotation.GetMapping
|
||||
import org.springframework.web.bind.annotation.RequestMapping
|
||||
import org.springframework.web.bind.annotation.RestController
|
||||
import org.springframework.web.multipart.MaxUploadSizeExceededException
|
||||
import org.springframework.web.multipart.MultipartException
|
||||
import org.springframework.web.multipart.MultipartResolver
|
||||
import org.springframework.web.servlet.DispatcherServlet
|
||||
import javax.servlet.http.HttpServletRequest
|
||||
|
||||
@SpringBootTest(
|
||||
properties = [
|
||||
"cloud.aws.cloud-front.host=https://cdn.test",
|
||||
"spring.cache.type=none",
|
||||
"spring.datasource.url=jdbc:h2:mem:legacy-soda-exception-http-contract;" +
|
||||
"MODE=MySQL;DATABASE_TO_UPPER=false;NON_KEYWORDS=VALUE;DB_CLOSE_DELAY=-1;DB_CLOSE_ON_EXIT=FALSE"
|
||||
]
|
||||
)
|
||||
@AutoConfigureMockMvc
|
||||
@ContextConfiguration(initializers = [EmbeddedRedisInitializer::class])
|
||||
@DirtiesContext(classMode = DirtiesContext.ClassMode.AFTER_CLASS)
|
||||
@Import(LegacySodaExceptionHttpStatusContractTest.TestLegacyController::class)
|
||||
class LegacySodaExceptionHttpStatusContractTest @Autowired constructor(
|
||||
private val mockMvc: MockMvc
|
||||
) {
|
||||
@MockBean(name = DispatcherServlet.MULTIPART_RESOLVER_BEAN_NAME)
|
||||
private lateinit var multipartResolver: MultipartResolver
|
||||
|
||||
@Test
|
||||
@DisplayName("legacy route의 SodaException은 기존 HTTP 200 오류 envelope를 유지한다")
|
||||
fun shouldPreserveLegacySodaExceptionHttp200() {
|
||||
mockMvc.perform(get("/legacy-ai-character-admin-test").with(user("admin").roles("ADMIN")))
|
||||
.andExpect(status().isOk)
|
||||
.andExpect(jsonPath("$.success").value(false))
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("legacy route는 httpStatus가 있는 SodaException도 HTTP 200 오류 envelope를 유지한다")
|
||||
fun shouldPreserveLegacySodaExceptionHttp200WhenExceptionHasHttpStatus() {
|
||||
mockMvc.perform(get("/legacy-ai-character-admin-test/conflict").with(user("admin").roles("ADMIN")))
|
||||
.andExpect(status().isOk)
|
||||
.andExpect(jsonPath("$.success").value(false))
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("admin ai-character multipart 크기 오류는 handler 선택 전에도 HTTP 400을 반환한다")
|
||||
fun shouldReturnBadRequestForAiCharacterAdminMaxUploadSizeExceeded() {
|
||||
givenMultipartResolutionFailure(MaxUploadSizeExceededException(1L))
|
||||
|
||||
mockMvc.perform(
|
||||
post("/admin/ai-characters/upload-size-test")
|
||||
.with(user("admin").roles("ADMIN"))
|
||||
.contentType(MediaType.MULTIPART_FORM_DATA)
|
||||
)
|
||||
.andExpect(status().isBadRequest)
|
||||
.andExpect(jsonPath("$.success").value(false))
|
||||
.andExpect(jsonPath("$.message").value("파일용량은 최대 1024MB까지 저장할 수 있습니다."))
|
||||
|
||||
Mockito.verify(multipartResolver).resolveMultipart(Mockito.any(HttpServletRequest::class.java))
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("legacy multipart 형식 오류는 handler 선택 전에도 기존 HTTP 200 unknown 오류를 유지한다")
|
||||
fun shouldPreserveLegacyUnknownMessageForMalformedMultipartBeforeHandlerSelection() {
|
||||
givenMultipartResolutionFailure(MultipartException("malformed multipart"))
|
||||
|
||||
mockMvc.perform(
|
||||
post("/legacy-ai-character-admin-test/upload")
|
||||
.with(user("admin").roles("ADMIN"))
|
||||
.contentType(MediaType.MULTIPART_FORM_DATA)
|
||||
)
|
||||
.andExpect(status().isOk)
|
||||
.andExpect(jsonPath("$.success").value(false))
|
||||
.andExpect(jsonPath("$.message").value("알 수 없는 오류가 발생했습니다. 다시 시도해 주세요."))
|
||||
|
||||
Mockito.verify(multipartResolver).resolveMultipart(Mockito.any(HttpServletRequest::class.java))
|
||||
}
|
||||
|
||||
private fun givenMultipartResolutionFailure(exception: MultipartException) {
|
||||
Mockito.`when`(multipartResolver.isMultipart(Mockito.any(HttpServletRequest::class.java))).thenReturn(true)
|
||||
Mockito.`when`(multipartResolver.resolveMultipart(Mockito.any(HttpServletRequest::class.java))).thenThrow(exception)
|
||||
}
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/legacy-ai-character-admin-test")
|
||||
class TestLegacyController {
|
||||
@GetMapping
|
||||
fun legacy(): ApiResponse<String> {
|
||||
throw SodaException(messageKey = "common.error.invalid_request")
|
||||
}
|
||||
|
||||
@GetMapping("/conflict")
|
||||
fun legacyWithHttpStatus(): ApiResponse<String> {
|
||||
throw SodaException(messageKey = "common.error.invalid_request", httpStatus = HttpStatus.CONFLICT)
|
||||
}
|
||||
}
|
||||
}
|
||||
-1054
File diff suppressed because it is too large
Load Diff
-93
@@ -1,93 +0,0 @@
|
||||
package kr.co.vividnext.sodalive.v2.admin.aicharacter.adapter.`in`.web
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper
|
||||
import kr.co.vividnext.sodalive.common.SodaException
|
||||
import org.junit.jupiter.api.Assertions.assertEquals
|
||||
import org.junit.jupiter.api.Assertions.assertThrows
|
||||
import org.junit.jupiter.api.Assertions.assertTrue
|
||||
import org.junit.jupiter.api.DisplayName
|
||||
import org.junit.jupiter.api.Test
|
||||
|
||||
class AdminJsonRequestParserTest {
|
||||
private val parser = AdminJsonRequestParser(ObjectMapper())
|
||||
|
||||
@Test
|
||||
@DisplayName("multipart request JSON은 명시적 null key를 누락으로 보지 않는다")
|
||||
fun shouldKeepExplicitNullRequiredKey() {
|
||||
val node = parser.parseRequiredObject(
|
||||
rawJson = """{"name":null,"tags":[]}""",
|
||||
requiredKeys = setOf("name", "tags")
|
||||
)
|
||||
|
||||
assertTrue(node.has("name"))
|
||||
assertTrue(node.get("name").isNull)
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("multipart request JSON은 필수 key 누락을 errorProperty로 반환한다")
|
||||
fun shouldRejectMissingRequiredKey() {
|
||||
val exception = assertThrows(SodaException::class.java) {
|
||||
parser.parseRequiredObject(rawJson = """{"name":"Soda"}""", requiredKeys = setOf("name", "tags"))
|
||||
}
|
||||
|
||||
assertEquals("tags", exception.errorProperty)
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("multipart request JSON 뒤에 다른 root가 이어지면 거부한다")
|
||||
fun shouldRejectTrailingJsonRoot() {
|
||||
val exception = assertThrows(SodaException::class.java) {
|
||||
parser.parseRequiredObject(
|
||||
rawJson = """{"name":"Soda"}{"name":"Pop"}""",
|
||||
requiredKeys = setOf("name")
|
||||
)
|
||||
}
|
||||
|
||||
assertEquals("request", exception.errorProperty)
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("multipart request JSON 뒤에 garbage가 이어지면 거부한다")
|
||||
fun shouldRejectTrailingGarbage() {
|
||||
val exception = assertThrows(SodaException::class.java) {
|
||||
parser.parseRequiredObject(
|
||||
rawJson = """{"name":"Soda"} trailing""",
|
||||
requiredKeys = setOf("name")
|
||||
)
|
||||
}
|
||||
|
||||
assertEquals("request", exception.errorProperty)
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("빈 multipart request JSON은 잘못된 요청으로 거부한다")
|
||||
fun shouldRejectBlankJson() {
|
||||
val exception = assertThrows(SodaException::class.java) {
|
||||
parser.parseRequiredObject(rawJson = " ", requiredKeys = setOf("name"))
|
||||
}
|
||||
|
||||
assertEquals("request", exception.errorProperty)
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("일반 JsonNode body도 같은 필수 key 계약을 사용한다")
|
||||
fun shouldParseJsonNodeWithSameRequiredKeyPolicy() {
|
||||
val node = ObjectMapper().readTree("""{"comment":null}""")
|
||||
|
||||
val parsed = parser.parseRequiredObject(node = node, requiredKeys = setOf("comment"))
|
||||
|
||||
assertTrue(parsed.get("comment").isNull)
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("일반 JsonNode body도 필수 key가 없으면 errorProperty로 반환한다")
|
||||
fun shouldRejectJsonNodeMissingRequiredKey() {
|
||||
val node = ObjectMapper().readTree("""{"comment":"hello"}""")
|
||||
|
||||
val exception = assertThrows(SodaException::class.java) {
|
||||
parser.parseRequiredObject(node = node, requiredKeys = setOf("comment", "author"))
|
||||
}
|
||||
|
||||
assertEquals("author", exception.errorProperty)
|
||||
}
|
||||
}
|
||||
-126
@@ -1,126 +0,0 @@
|
||||
package kr.co.vividnext.sodalive.v2.admin.aicharacter.adapter.`in`.web
|
||||
|
||||
import com.fasterxml.jackson.core.JsonParser
|
||||
import com.fasterxml.jackson.databind.JsonMappingException
|
||||
import kr.co.vividnext.sodalive.common.SodaException
|
||||
import kr.co.vividnext.sodalive.i18n.LangContext
|
||||
import kr.co.vividnext.sodalive.i18n.SodaMessageSource
|
||||
import kr.co.vividnext.sodalive.v2.admin.aicharacter.dto.AdminCommentUpdateRequest
|
||||
import org.junit.jupiter.api.Assertions.assertAll
|
||||
import org.junit.jupiter.api.Assertions.assertEquals
|
||||
import org.junit.jupiter.api.DisplayName
|
||||
import org.junit.jupiter.api.Test
|
||||
import org.springframework.http.HttpStatus
|
||||
import org.springframework.http.converter.HttpMessageNotReadableException
|
||||
import org.springframework.mock.http.MockHttpInputMessage
|
||||
import org.springframework.web.bind.MissingServletRequestParameterException
|
||||
import org.springframework.web.multipart.support.MissingServletRequestPartException
|
||||
|
||||
class AiCharacterAdminExceptionHandlerTest {
|
||||
private val handler = AiCharacterAdminExceptionHandler(
|
||||
langContext = LangContext(),
|
||||
messageSource = SodaMessageSource()
|
||||
)
|
||||
|
||||
@Test
|
||||
@DisplayName("SodaException은 지정 HTTP status와 errorProperty를 ApiResponse로 반환한다")
|
||||
fun shouldReturnConfiguredStatusAndErrorProperty() {
|
||||
listOf(
|
||||
HttpStatus.BAD_REQUEST,
|
||||
HttpStatus.NOT_FOUND,
|
||||
HttpStatus.CONFLICT,
|
||||
HttpStatus.INTERNAL_SERVER_ERROR,
|
||||
HttpStatus.BAD_GATEWAY
|
||||
).forEach { status ->
|
||||
val response = handler.handleSodaException(
|
||||
SodaException(
|
||||
messageKey = "common.error.invalid_request",
|
||||
errorProperty = "characterId",
|
||||
httpStatus = status
|
||||
)
|
||||
)
|
||||
|
||||
assertAll(
|
||||
{ assertEquals(status, response.statusCode) },
|
||||
{ assertEquals(false, response.body?.success) },
|
||||
{ assertEquals("characterId", response.body?.errorProperty) }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("HTTP status가 없는 SodaException은 관리자 경계에서 400으로 응답한다")
|
||||
fun shouldDefaultSodaExceptionToBadRequest() {
|
||||
val response = handler.handleSodaException(SodaException(messageKey = "common.error.invalid_request"))
|
||||
|
||||
assertAll(
|
||||
{ assertEquals(HttpStatus.BAD_REQUEST, response.statusCode) },
|
||||
{ assertEquals(false, response.body?.success) }
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("알 수 없는 예외는 500 ApiResponse로 응답한다")
|
||||
fun shouldReturnInternalServerErrorForUnknownException() {
|
||||
val response = handler.handleException(IllegalStateException("sensitive-body"))
|
||||
|
||||
assertAll(
|
||||
{ assertEquals(HttpStatus.INTERNAL_SERVER_ERROR, response.statusCode) },
|
||||
{ assertEquals(false, response.body?.success) }
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("잘못된 request parameter는 400 errorProperty를 반환한다")
|
||||
fun shouldReturnErrorPropertyForMissingRequestParameter() {
|
||||
val response = handler.handleBadRequestException(MissingServletRequestParameterException("page", "Int"))
|
||||
|
||||
assertAll(
|
||||
{ assertEquals(HttpStatus.BAD_REQUEST, response.statusCode) },
|
||||
{ assertEquals(false, response.body?.success) },
|
||||
{ assertEquals("page", response.body?.errorProperty) }
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("누락된 multipart part는 400 errorProperty를 반환한다")
|
||||
fun shouldReturnErrorPropertyForMissingRequestPart() {
|
||||
val response = handler.handleBadRequestException(MissingServletRequestPartException("image"))
|
||||
|
||||
assertAll(
|
||||
{ assertEquals(HttpStatus.BAD_REQUEST, response.statusCode) },
|
||||
{ assertEquals(false, response.body?.success) },
|
||||
{ assertEquals("image", response.body?.errorProperty) }
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("읽을 수 없는 JSON body는 Jackson path의 field를 errorProperty로 반환한다")
|
||||
fun shouldReturnJsonBodyFieldNameForUnreadableBody() {
|
||||
val cause = JsonMappingException.from(null as JsonParser?, "missing content")
|
||||
.also { it.prependPath(AdminCommentUpdateRequest::class.java, "content") }
|
||||
val exception = HttpMessageNotReadableException("bad request", cause, MockHttpInputMessage(ByteArray(0)))
|
||||
|
||||
val response = handler.handleBadRequestException(exception)
|
||||
|
||||
assertAll(
|
||||
{ assertEquals(HttpStatus.BAD_REQUEST, response.statusCode) },
|
||||
{ assertEquals(false, response.body?.success) },
|
||||
{ assertEquals("content", response.body?.errorProperty) }
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("읽을 수 없는 JSON body에 Jackson field path가 없으면 request를 errorProperty로 반환한다")
|
||||
fun shouldFallbackToRequestForUnreadableBodyWithoutJsonPath() {
|
||||
val exception = HttpMessageNotReadableException("bad request", MockHttpInputMessage(ByteArray(0)))
|
||||
|
||||
val response = handler.handleBadRequestException(exception)
|
||||
|
||||
assertAll(
|
||||
{ assertEquals(HttpStatus.BAD_REQUEST, response.statusCode) },
|
||||
{ assertEquals(false, response.body?.success) },
|
||||
{ assertEquals("request", response.body?.errorProperty) }
|
||||
)
|
||||
}
|
||||
}
|
||||
-165
@@ -1,165 +0,0 @@
|
||||
package kr.co.vividnext.sodalive.v2.admin.aicharacter.adapter.`in`.web
|
||||
|
||||
import kr.co.vividnext.sodalive.admin.member.AdminMemberLoginController
|
||||
import kr.co.vividnext.sodalive.admin.member.AdminMemberLoginService
|
||||
import kr.co.vividnext.sodalive.admin.member.AdminMemberRepository
|
||||
import kr.co.vividnext.sodalive.common.ApiResponse
|
||||
import kr.co.vividnext.sodalive.common.CountryContext
|
||||
import kr.co.vividnext.sodalive.configs.SecurityConfig
|
||||
import kr.co.vividnext.sodalive.i18n.LangContext
|
||||
import kr.co.vividnext.sodalive.i18n.SodaMessageSource
|
||||
import kr.co.vividnext.sodalive.jwt.JwtAccessDeniedHandler
|
||||
import kr.co.vividnext.sodalive.jwt.JwtAuthenticationEntryPoint
|
||||
import kr.co.vividnext.sodalive.jwt.TokenProvider
|
||||
import kr.co.vividnext.sodalive.member.Member
|
||||
import kr.co.vividnext.sodalive.member.MemberRepository
|
||||
import kr.co.vividnext.sodalive.member.MemberRole
|
||||
import kr.co.vividnext.sodalive.member.token.MemberToken
|
||||
import kr.co.vividnext.sodalive.member.token.MemberTokenRepository
|
||||
import kr.co.vividnext.sodalive.v2.admin.aicharacter.adapter.`in`.security.AiCharacterAdminAccessDeniedHandler
|
||||
import kr.co.vividnext.sodalive.v2.admin.aicharacter.adapter.`in`.security.AiCharacterAdminAuthenticationEntryPoint
|
||||
import org.junit.jupiter.api.DisplayName
|
||||
import org.junit.jupiter.api.Test
|
||||
import org.mockito.Mockito
|
||||
import org.springframework.beans.factory.annotation.Autowired
|
||||
import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest
|
||||
import org.springframework.boot.test.mock.mockito.MockBean
|
||||
import org.springframework.context.annotation.Import
|
||||
import org.springframework.http.MediaType
|
||||
import org.springframework.security.access.prepost.PreAuthorize
|
||||
import org.springframework.security.crypto.password.PasswordEncoder
|
||||
import org.springframework.test.context.TestPropertySource
|
||||
import org.springframework.test.web.servlet.MockMvc
|
||||
import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get
|
||||
import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post
|
||||
import org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath
|
||||
import org.springframework.test.web.servlet.result.MockMvcResultMatchers.status
|
||||
import org.springframework.web.bind.annotation.GetMapping
|
||||
import org.springframework.web.bind.annotation.RequestMapping
|
||||
import org.springframework.web.bind.annotation.RestController
|
||||
import java.util.Optional
|
||||
|
||||
@WebMvcTest(
|
||||
controllers = [
|
||||
AdminMemberLoginController::class,
|
||||
AiCharacterAdminLoginJwtIntegrationTest.TestAiCharacterAdminController::class
|
||||
]
|
||||
)
|
||||
@Import(
|
||||
SecurityConfig::class,
|
||||
JwtAuthenticationEntryPoint::class,
|
||||
JwtAccessDeniedHandler::class,
|
||||
AiCharacterAdminAuthenticationEntryPoint::class,
|
||||
AiCharacterAdminAccessDeniedHandler::class,
|
||||
AiCharacterAdminExceptionHandler::class,
|
||||
AiCharacterAdminLoginJwtIntegrationTest.TestAiCharacterAdminController::class,
|
||||
AdminMemberLoginService::class,
|
||||
TokenProvider::class,
|
||||
LangContext::class,
|
||||
SodaMessageSource::class
|
||||
)
|
||||
@TestPropertySource(
|
||||
properties = [
|
||||
"jwt.secret=AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA==",
|
||||
"jwt.token-validity-in-seconds=3600"
|
||||
]
|
||||
)
|
||||
class AiCharacterAdminLoginJwtIntegrationTest @Autowired constructor(
|
||||
private val mockMvc: MockMvc
|
||||
) {
|
||||
@MockBean
|
||||
private lateinit var adminMemberRepository: AdminMemberRepository
|
||||
|
||||
@MockBean
|
||||
private lateinit var memberRepository: MemberRepository
|
||||
|
||||
@MockBean
|
||||
private lateinit var memberTokenRepository: MemberTokenRepository
|
||||
|
||||
@MockBean
|
||||
private lateinit var passwordEncoder: PasswordEncoder
|
||||
|
||||
@MockBean
|
||||
private lateinit var countryContext: CountryContext
|
||||
|
||||
@Test
|
||||
@DisplayName("관리자 로그인으로 받은 실제 JWT는 신규 관리자 API 인증에 사용된다")
|
||||
fun shouldCallAiCharacterAdminApiWithTokenFromAdminLogin() {
|
||||
val member = Member(email = "admin@test.com", password = "encoded-password", nickname = "admin", role = MemberRole.ADMIN)
|
||||
.apply { id = 1L }
|
||||
var savedToken: MemberToken? = null
|
||||
Mockito.`when`(adminMemberRepository.findByEmail("admin@test.com")).thenReturn(member)
|
||||
Mockito.`when`(memberRepository.findById(Mockito.eq(1L))).thenReturn(Optional.of(member))
|
||||
Mockito.`when`(memberTokenRepository.findById(Mockito.eq(1L))).thenAnswer { Optional.ofNullable(savedToken) }
|
||||
Mockito.`when`(memberTokenRepository.save(Mockito.any(MemberToken::class.java))).thenAnswer { invocation ->
|
||||
(invocation.arguments[0] as MemberToken).also { savedToken = it }
|
||||
}
|
||||
Mockito.`when`(passwordEncoder.matches("password", "encoded-password")).thenReturn(true)
|
||||
|
||||
val loginResult = mockMvc.perform(
|
||||
post("/admin/member/login")
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("""{"email":"admin@test.com","password":"password"}""")
|
||||
)
|
||||
.andExpect(status().isOk)
|
||||
.andExpect(jsonPath("$.success").value(true))
|
||||
.andExpect(jsonPath("$.data.role").value("ADMIN"))
|
||||
.andReturn()
|
||||
|
||||
val token = Regex(""""token":"([^"]+)"""")
|
||||
.find(loginResult.response.contentAsString)
|
||||
?.groupValues
|
||||
?.get(1)
|
||||
|
||||
mockMvc.perform(get("/admin/ai-characters/login-jwt-test").header("Authorization", "Bearer $token"))
|
||||
.andExpect(status().isOk)
|
||||
.andExpect(jsonPath("$.success").value(true))
|
||||
.andExpect(jsonPath("$.data").value("ok"))
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("CONTENT_MANAGER 로그인으로 받은 실제 JWT는 신규 관리자 API에서 403으로 거부된다")
|
||||
fun shouldRejectContentManagerTokenFromAdminLoginForAiCharacterAdminApi() {
|
||||
val member = Member(
|
||||
email = "content@test.com",
|
||||
password = "encoded-password",
|
||||
nickname = "content-manager",
|
||||
role = MemberRole.CONTENT_MANAGER
|
||||
).apply { id = 2L }
|
||||
var savedToken: MemberToken? = null
|
||||
Mockito.`when`(adminMemberRepository.findByEmail("content@test.com")).thenReturn(member)
|
||||
Mockito.`when`(memberRepository.findById(Mockito.eq(2L))).thenReturn(Optional.of(member))
|
||||
Mockito.`when`(memberTokenRepository.findById(Mockito.eq(2L))).thenAnswer { Optional.ofNullable(savedToken) }
|
||||
Mockito.`when`(memberTokenRepository.save(Mockito.any(MemberToken::class.java))).thenAnswer { invocation ->
|
||||
(invocation.arguments[0] as MemberToken).also { savedToken = it }
|
||||
}
|
||||
Mockito.`when`(passwordEncoder.matches("password", "encoded-password")).thenReturn(true)
|
||||
|
||||
val loginResult = mockMvc.perform(
|
||||
post("/admin/member/login")
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("""{"email":"content@test.com","password":"password"}""")
|
||||
)
|
||||
.andExpect(status().isOk)
|
||||
.andExpect(jsonPath("$.success").value(true))
|
||||
.andExpect(jsonPath("$.data.role").value("CONTENT_MANAGER"))
|
||||
.andReturn()
|
||||
|
||||
val token = Regex(""""token":"([^"]+)"""")
|
||||
.find(loginResult.response.contentAsString)
|
||||
?.groupValues
|
||||
?.get(1)
|
||||
|
||||
mockMvc.perform(get("/admin/ai-characters/login-jwt-test").header("Authorization", "Bearer $token"))
|
||||
.andExpect(status().isForbidden)
|
||||
.andExpect(jsonPath("$.success").value(false))
|
||||
}
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/admin/ai-characters/login-jwt-test")
|
||||
@PreAuthorize("hasRole('ADMIN')")
|
||||
class TestAiCharacterAdminController {
|
||||
@GetMapping
|
||||
fun ok(): ApiResponse<String> = ApiResponse.ok("ok")
|
||||
}
|
||||
}
|
||||
-215
@@ -1,215 +0,0 @@
|
||||
package kr.co.vividnext.sodalive.v2.admin.aicharacter.adapter.`in`.web
|
||||
|
||||
import kr.co.vividnext.sodalive.common.ApiResponse
|
||||
import kr.co.vividnext.sodalive.common.CountryContext
|
||||
import kr.co.vividnext.sodalive.common.SodaException
|
||||
import kr.co.vividnext.sodalive.configs.SecurityConfig
|
||||
import kr.co.vividnext.sodalive.i18n.LangContext
|
||||
import kr.co.vividnext.sodalive.i18n.SodaMessageSource
|
||||
import kr.co.vividnext.sodalive.jwt.JwtAccessDeniedHandler
|
||||
import kr.co.vividnext.sodalive.jwt.JwtAuthenticationEntryPoint
|
||||
import kr.co.vividnext.sodalive.jwt.TokenProvider
|
||||
import kr.co.vividnext.sodalive.member.Member
|
||||
import kr.co.vividnext.sodalive.member.MemberAdapter
|
||||
import kr.co.vividnext.sodalive.member.MemberRole
|
||||
import kr.co.vividnext.sodalive.v2.admin.aicharacter.adapter.`in`.security.AiCharacterAdminAccessDeniedHandler
|
||||
import kr.co.vividnext.sodalive.v2.admin.aicharacter.adapter.`in`.security.AiCharacterAdminAuthenticationEntryPoint
|
||||
import kr.co.vividnext.sodalive.v2.admin.aicharacter.dto.AdminCommentUpdateRequest
|
||||
import org.hamcrest.Matchers.containsString
|
||||
import org.hamcrest.Matchers.not
|
||||
import org.junit.jupiter.api.DisplayName
|
||||
import org.junit.jupiter.api.Test
|
||||
import org.mockito.Mockito
|
||||
import org.springframework.beans.factory.annotation.Autowired
|
||||
import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest
|
||||
import org.springframework.boot.test.mock.mockito.MockBean
|
||||
import org.springframework.context.annotation.Import
|
||||
import org.springframework.http.HttpStatus
|
||||
import org.springframework.security.access.prepost.PreAuthorize
|
||||
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken
|
||||
import org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.anonymous
|
||||
import org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.user
|
||||
import org.springframework.test.web.servlet.MockMvc
|
||||
import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get
|
||||
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.web.bind.annotation.GetMapping
|
||||
import org.springframework.web.bind.annotation.PostMapping
|
||||
import org.springframework.web.bind.annotation.RequestBody
|
||||
import org.springframework.web.bind.annotation.RequestMapping
|
||||
import org.springframework.web.bind.annotation.RestController
|
||||
import java.util.stream.Stream
|
||||
|
||||
@WebMvcTest(
|
||||
controllers = [
|
||||
AiCharacterAdminSecurityIntegrationTest.TestAiCharacterAdminController::class,
|
||||
AiCharacterAdminSecurityIntegrationTest.TestAdjacentAdminController::class
|
||||
]
|
||||
)
|
||||
@Import(
|
||||
SecurityConfig::class,
|
||||
JwtAuthenticationEntryPoint::class,
|
||||
JwtAccessDeniedHandler::class,
|
||||
AiCharacterAdminAuthenticationEntryPoint::class,
|
||||
AiCharacterAdminAccessDeniedHandler::class,
|
||||
AiCharacterAdminExceptionHandler::class,
|
||||
AiCharacterAdminSecurityIntegrationTest.TestAiCharacterAdminController::class,
|
||||
AiCharacterAdminSecurityIntegrationTest.TestAdjacentAdminController::class,
|
||||
LangContext::class,
|
||||
SodaMessageSource::class
|
||||
)
|
||||
class AiCharacterAdminSecurityIntegrationTest @Autowired constructor(
|
||||
private val mockMvc: MockMvc
|
||||
) {
|
||||
@MockBean
|
||||
private lateinit var tokenProvider: TokenProvider
|
||||
|
||||
@MockBean
|
||||
private lateinit var countryContext: CountryContext
|
||||
|
||||
@Test
|
||||
@DisplayName("/admin/ai-characters 전용 API는 ADMIN 권한이면 통과한다")
|
||||
fun shouldAllowAdminRole() {
|
||||
mockMvc.perform(get("/admin/ai-characters/test").with(user("admin").roles("ADMIN")))
|
||||
.andExpect(status().isOk)
|
||||
.andExpect(jsonPath("$.success").value(true))
|
||||
.andExpect(jsonPath("$.data").value("ok"))
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("기존 JWT 인증 결과가 ADMIN이면 신규 관리자 API를 호출할 수 있다")
|
||||
fun shouldAllowAdminJwtAuthentication() {
|
||||
val admin = Member(email = "admin@test.com", password = "password", nickname = "admin", role = MemberRole.ADMIN)
|
||||
.apply { id = 1L }
|
||||
val authentication = UsernamePasswordAuthenticationToken(
|
||||
MemberAdapter(admin),
|
||||
"admin-token",
|
||||
MemberAdapter(admin).authorities
|
||||
)
|
||||
Mockito.`when`(tokenProvider.validateToken("admin-token")).thenReturn(true)
|
||||
Mockito.`when`(tokenProvider.getAuthentication("admin-token")).thenReturn(authentication)
|
||||
|
||||
mockMvc.perform(get("/admin/ai-characters/test").header("Authorization", "Bearer admin-token"))
|
||||
.andExpect(status().isOk)
|
||||
.andExpect(jsonPath("$.success").value(true))
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("/admin/ai-characters 전용 API는 비로그인을 401 ApiResponse로 거부한다")
|
||||
fun shouldRejectAnonymousWithJson401() {
|
||||
mockMvc.perform(get("/admin/ai-characters/test").with(anonymous()))
|
||||
.andExpect(status().isUnauthorized)
|
||||
.andExpect(jsonPath("$.success").value(false))
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("/admin/ai-characters 전용 API는 ADMIN 외 권한을 403 ApiResponse로 거부한다")
|
||||
fun shouldRejectNonAdminWithJson403() {
|
||||
Stream.of("USER", "CREATOR", "AGENT", "CONTENT_MANAGER").forEach { role ->
|
||||
mockMvc.perform(get("/admin/ai-characters/test").with(user(role.lowercase()).roles(role)))
|
||||
.andExpect(status().isForbidden)
|
||||
.andExpect(jsonPath("$.success").value(false))
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("ADMIN 외 권한은 request body 역직렬화 전에 403으로 거부된다")
|
||||
fun shouldRejectNonAdminBeforeRequestBodyBinding() {
|
||||
mockMvc.perform(
|
||||
post("/admin/ai-characters/test/body")
|
||||
.with(user("user").roles("USER"))
|
||||
.contentType("application/json")
|
||||
.content("{")
|
||||
)
|
||||
.andExpect(status().isForbidden)
|
||||
.andExpect(jsonPath("$.success").value(false))
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("잘못된 JWT는 401 ApiResponse로 거부한다")
|
||||
fun shouldRejectInvalidJwtWithJson401() {
|
||||
Mockito.`when`(tokenProvider.validateToken("invalid-token")).thenReturn(false)
|
||||
|
||||
mockMvc.perform(get("/admin/ai-characters/test").header("Authorization", "Bearer invalid-token"))
|
||||
.andExpect(status().isUnauthorized)
|
||||
.andExpect(jsonPath("$.success").value(false))
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("/admin/ai-characters 인접 prefix는 신규 관리자 오류 handler 대상이 아니다")
|
||||
fun shouldNotUseAdminHandlerForAdjacentPrefix() {
|
||||
mockMvc.perform(get("/admin/ai-characters-shadow/test").with(anonymous()))
|
||||
.andExpect(status().isUnauthorized)
|
||||
.andExpect(content().string(not(containsString("success"))))
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("/admin/ai-characters 전용 예외는 지정 HTTP status와 errorProperty를 응답한다")
|
||||
fun shouldUseAdminExceptionBoundary() {
|
||||
mockMvc.perform(get("/admin/ai-characters/test/conflict").with(user("admin").roles("ADMIN")))
|
||||
.andExpect(status().isConflict)
|
||||
.andExpect(jsonPath("$.success").value(false))
|
||||
.andExpect(jsonPath("$.errorProperty").value("characterId"))
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("/admin/ai-characters 전용 예외는 400/404/500/502 status를 보존한다")
|
||||
fun shouldUseAdminExceptionBoundaryForCommonStatuses() {
|
||||
mapOf(
|
||||
"bad-request" to HttpStatus.BAD_REQUEST,
|
||||
"not-found" to HttpStatus.NOT_FOUND,
|
||||
"server-error" to HttpStatus.INTERNAL_SERVER_ERROR,
|
||||
"bad-gateway" to HttpStatus.BAD_GATEWAY
|
||||
).forEach { (path, status) ->
|
||||
mockMvc.perform(get("/admin/ai-characters/test/$path").with(user("admin").roles("ADMIN")))
|
||||
.andExpect(status().`is`(status.value()))
|
||||
.andExpect(jsonPath("$.success").value(false))
|
||||
}
|
||||
}
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/admin/ai-characters/test")
|
||||
@PreAuthorize("hasRole('ADMIN')")
|
||||
class TestAiCharacterAdminController {
|
||||
@GetMapping
|
||||
fun ok(): ApiResponse<String> = ApiResponse.ok("ok")
|
||||
|
||||
@PostMapping("/body")
|
||||
fun body(@RequestBody request: AdminCommentUpdateRequest): ApiResponse<String> = ApiResponse.ok(request.content)
|
||||
|
||||
@GetMapping("/conflict")
|
||||
fun conflict(): ApiResponse<String> {
|
||||
throw SodaException(
|
||||
messageKey = "common.error.invalid_request",
|
||||
errorProperty = "characterId",
|
||||
httpStatus = HttpStatus.CONFLICT
|
||||
)
|
||||
}
|
||||
|
||||
@GetMapping("/bad-request")
|
||||
fun badRequest(): ApiResponse<String> = throwStatus(HttpStatus.BAD_REQUEST)
|
||||
|
||||
@GetMapping("/not-found")
|
||||
fun notFound(): ApiResponse<String> = throwStatus(HttpStatus.NOT_FOUND)
|
||||
|
||||
@GetMapping("/server-error")
|
||||
fun serverError(): ApiResponse<String> = throwStatus(HttpStatus.INTERNAL_SERVER_ERROR)
|
||||
|
||||
@GetMapping("/bad-gateway")
|
||||
fun badGateway(): ApiResponse<String> = throwStatus(HttpStatus.BAD_GATEWAY)
|
||||
|
||||
private fun throwStatus(status: HttpStatus): ApiResponse<String> {
|
||||
throw SodaException(messageKey = "common.error.invalid_request", httpStatus = status)
|
||||
}
|
||||
}
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/admin/ai-characters-shadow/test")
|
||||
@PreAuthorize("hasRole('ADMIN')")
|
||||
class TestAdjacentAdminController {
|
||||
@GetMapping
|
||||
fun ok(): ApiResponse<String> = ApiResponse.ok("shadow")
|
||||
}
|
||||
}
|
||||
-85
@@ -1,85 +0,0 @@
|
||||
package kr.co.vividnext.sodalive.v2.admin.aicharacter.application
|
||||
|
||||
import kr.co.vividnext.sodalive.v2.admin.aicharacter.dto.AdminCommentUpdateRequest
|
||||
import kr.co.vividnext.sodalive.v2.admin.aicharacter.dto.AdminMutationResponse
|
||||
import kr.co.vividnext.sodalive.v2.admin.aicharacter.dto.AdminPageResponse
|
||||
import org.junit.jupiter.api.Assertions.assertEquals
|
||||
import org.junit.jupiter.api.Assertions.assertFalse
|
||||
import org.junit.jupiter.api.Assertions.assertTrue
|
||||
import org.junit.jupiter.api.DisplayName
|
||||
import org.junit.jupiter.api.Test
|
||||
|
||||
class AdminPagePolicyTest {
|
||||
@Test
|
||||
@DisplayName("관리자 목록 page와 size 기본값을 정규화한다")
|
||||
fun shouldNormalizeDefaultPageRequest() {
|
||||
val request = AdminPagePolicy.normalize(page = null, size = null)
|
||||
|
||||
assertEquals(0, request.page)
|
||||
assertEquals(20, request.size)
|
||||
assertEquals(0, request.offset)
|
||||
assertEquals(20, request.limit)
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("관리자 목록 page와 size 경계값을 정규화한다")
|
||||
fun shouldNormalizePageRequestBoundaries() {
|
||||
val lowerBound = AdminPagePolicy.normalize(page = -1, size = 0)
|
||||
val upperBound = AdminPagePolicy.normalize(page = 2, size = 51)
|
||||
|
||||
assertEquals(0, lowerBound.page)
|
||||
assertEquals(20, lowerBound.size)
|
||||
assertEquals(2, upperBound.page)
|
||||
assertEquals(50, upperBound.size)
|
||||
assertEquals(100, upperBound.offset)
|
||||
assertEquals(50, upperBound.limit)
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("관리자 목록 page 0과 size 1/20/50 유효 경계값을 유지한다")
|
||||
fun shouldKeepValidPageRequestBoundaries() {
|
||||
listOf(1, 20, 50).forEach { size ->
|
||||
val request = AdminPagePolicy.normalize(page = 0, size = size)
|
||||
|
||||
assertEquals(0, request.page)
|
||||
assertEquals(size, request.size)
|
||||
assertEquals(0, request.offset)
|
||||
assertEquals(size.toLong(), request.limit)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("관리자 page 응답은 hasNext를 계산한다")
|
||||
fun shouldCalculateHasNextForPageResponse() {
|
||||
val firstPage = AdminPageResponse.of(totalCount = 3, content = listOf(1, 2), page = 0, size = 2)
|
||||
val lastPage = AdminPageResponse.of(totalCount = 3, content = listOf(3), page = 1, size = 2)
|
||||
|
||||
assertTrue(firstPage.hasNext)
|
||||
assertFalse(lastPage.hasNext)
|
||||
assertEquals(listOf(1, 2), firstPage.items)
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("관리자 page 응답은 Int overflow 없이 hasNext를 계산한다")
|
||||
fun shouldCalculateHasNextWithoutIntOverflow() {
|
||||
val response = AdminPageResponse.of(
|
||||
totalCount = Long.MAX_VALUE,
|
||||
content = emptyList<Int>(),
|
||||
page = Int.MAX_VALUE,
|
||||
size = Int.MAX_VALUE
|
||||
)
|
||||
|
||||
assertTrue(response.hasNext)
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("관리자 공통 mutation 응답과 댓글 수정 요청 DTO 계약을 유지한다")
|
||||
fun shouldKeepAdminMutationDtoContract() {
|
||||
val mutationResponse = AdminMutationResponse(id = 1L, isActive = true)
|
||||
val commentUpdateRequest = AdminCommentUpdateRequest(content = "updated")
|
||||
|
||||
assertEquals(1L, mutationResponse.id)
|
||||
assertTrue(mutationResponse.isActive)
|
||||
assertEquals("updated", commentUpdateRequest.content)
|
||||
}
|
||||
}
|
||||
-199
@@ -1,199 +0,0 @@
|
||||
package kr.co.vividnext.sodalive.v2.admin.aicharacter.application
|
||||
|
||||
import org.junit.jupiter.api.Assertions.assertDoesNotThrow
|
||||
import org.junit.jupiter.api.Assertions.assertFalse
|
||||
import org.junit.jupiter.api.Assertions.assertThrows
|
||||
import org.junit.jupiter.api.Assertions.assertTrue
|
||||
import org.junit.jupiter.api.DisplayName
|
||||
import org.junit.jupiter.api.Test
|
||||
import org.junit.jupiter.api.extension.ExtendWith
|
||||
import org.springframework.boot.test.system.CapturedOutput
|
||||
import org.springframework.boot.test.system.OutputCaptureExtension
|
||||
|
||||
@ExtendWith(OutputCaptureExtension::class)
|
||||
class AiCharacterAdminAuditLoggerTest {
|
||||
private val auditLogger = AiCharacterAdminAuditLogger()
|
||||
|
||||
@Test
|
||||
@DisplayName("global 원작 mutation audit은 character field null을 허용한다")
|
||||
fun shouldAllowGlobalOriginalWorkAuditContext(output: CapturedOutput) {
|
||||
auditLogger.logSuccess(
|
||||
context = AiCharacterAdminAuditContext.globalOriginalWork(
|
||||
adminMemberId = 1L,
|
||||
action = AiCharacterAdminAuditAction.CREATE,
|
||||
resourceType = AiCharacterAdminAuditResourceType.ORIGINAL_WORK,
|
||||
resourceId = 10L
|
||||
)
|
||||
)
|
||||
|
||||
assertTrue(output.out.contains("aiCharacterAdminAudit result=SUCCESS adminMemberId=1"))
|
||||
assertTrue(output.out.contains("characterId=null"))
|
||||
assertTrue(output.out.contains("creatorMemberId=null"))
|
||||
assertTrue(output.out.contains("action=CREATE"))
|
||||
assertTrue(output.out.contains("resourceType=ORIGINAL_WORK"))
|
||||
assertTrue(output.out.contains("resourceId=10"))
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("character-scoped mutation audit은 character와 creator field를 명시한다")
|
||||
fun shouldAllowCharacterScopedAuditContext(output: CapturedOutput) {
|
||||
auditLogger.logFailure(
|
||||
context = AiCharacterAdminAuditContext.characterScoped(
|
||||
adminMemberId = 1L,
|
||||
characterId = 2L,
|
||||
creatorMemberId = 3L,
|
||||
action = AiCharacterAdminAuditAction.UPDATE,
|
||||
resourceType = AiCharacterAdminAuditResourceType.CHARACTER,
|
||||
resourceId = 2L
|
||||
),
|
||||
exception = IllegalStateException("failed-sensitive-body")
|
||||
)
|
||||
|
||||
assertTrue(output.out.contains("result=FAILURE"))
|
||||
assertTrue(output.out.contains("characterId=2"))
|
||||
assertTrue(output.out.contains("creatorMemberId=3"))
|
||||
assertTrue(output.out.contains("action=UPDATE"))
|
||||
assertTrue(output.out.contains("resourceType=CHARACTER"))
|
||||
assertTrue(output.out.contains("resourceId=2"))
|
||||
assertTrue(output.out.contains("error=IllegalStateException"))
|
||||
assertFalse(output.out.contains("failed-sensitive-body"))
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("원작 배정 audit은 global context로 만들 수 없다")
|
||||
fun shouldRejectAssignmentAuditInGlobalOriginalWorkContext() {
|
||||
assertThrows(IllegalArgumentException::class.java) {
|
||||
AiCharacterAdminAuditContext.globalOriginalWork(
|
||||
adminMemberId = 1L,
|
||||
action = AiCharacterAdminAuditAction.ASSIGN,
|
||||
resourceType = AiCharacterAdminAuditResourceType.ORIGINAL_WORK_CHARACTER,
|
||||
resourceId = 10L
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("global 원작 audit은 create/update/delete action만 허용한다")
|
||||
fun shouldRejectNonMutationActionInGlobalOriginalWorkContext() {
|
||||
assertThrows(IllegalArgumentException::class.java) {
|
||||
AiCharacterAdminAuditContext.globalOriginalWork(
|
||||
adminMemberId = 1L,
|
||||
action = AiCharacterAdminAuditAction.READ,
|
||||
resourceType = AiCharacterAdminAuditResourceType.ORIGINAL_WORK,
|
||||
resourceId = 10L
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("원작 배정 audit은 캐릭터별 ORIGINAL_WORK_CHARACTER context만 허용한다")
|
||||
fun shouldRejectAssignmentAuditWithoutOriginalWorkCharacterResourceType() {
|
||||
assertThrows(IllegalArgumentException::class.java) {
|
||||
AiCharacterAdminAuditContext.characterScoped(
|
||||
adminMemberId = 1L,
|
||||
characterId = 2L,
|
||||
creatorMemberId = 3L,
|
||||
action = AiCharacterAdminAuditAction.ASSIGN,
|
||||
resourceType = AiCharacterAdminAuditResourceType.ORIGINAL_WORK,
|
||||
resourceId = 10L
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("ORIGINAL_WORK_CHARACTER resource는 assign/unassign action만 허용한다")
|
||||
fun shouldRejectOriginalWorkCharacterResourceForNonAssignmentAction() {
|
||||
assertThrows(IllegalArgumentException::class.java) {
|
||||
AiCharacterAdminAuditContext.characterScoped(
|
||||
adminMemberId = 1L,
|
||||
characterId = 2L,
|
||||
creatorMemberId = 3L,
|
||||
action = AiCharacterAdminAuditAction.UPDATE,
|
||||
resourceType = AiCharacterAdminAuditResourceType.ORIGINAL_WORK_CHARACTER,
|
||||
resourceId = 10L
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("원작 배정 audit은 creator member id를 필수로 요구한다")
|
||||
fun shouldRejectAssignmentWithoutCreatorMemberId() {
|
||||
assertThrows(IllegalArgumentException::class.java) {
|
||||
AiCharacterAdminAuditContext.characterScoped(
|
||||
adminMemberId = 1L,
|
||||
characterId = 2L,
|
||||
creatorMemberId = null,
|
||||
action = AiCharacterAdminAuditAction.ASSIGN,
|
||||
resourceType = AiCharacterAdminAuditResourceType.ORIGINAL_WORK_CHARACTER,
|
||||
resourceId = 10L
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("global 원작 resource는 character-scoped context로 만들 수 없다")
|
||||
fun shouldRejectGlobalOriginalWorkResourceInCharacterScopedContext() {
|
||||
assertThrows(IllegalArgumentException::class.java) {
|
||||
AiCharacterAdminAuditContext.characterScoped(
|
||||
adminMemberId = 1L,
|
||||
characterId = 2L,
|
||||
creatorMemberId = 3L,
|
||||
action = AiCharacterAdminAuditAction.UPDATE,
|
||||
resourceType = AiCharacterAdminAuditResourceType.ORIGINAL_WORK,
|
||||
resourceId = 10L
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("원작 해제가 아닌 character-scoped audit은 creator member id가 필요하다")
|
||||
fun shouldRejectMissingCreatorMemberIdOutsideUnassignment() {
|
||||
assertThrows(IllegalArgumentException::class.java) {
|
||||
AiCharacterAdminAuditContext.characterScoped(
|
||||
adminMemberId = 1L,
|
||||
characterId = 2L,
|
||||
creatorMemberId = null,
|
||||
action = AiCharacterAdminAuditAction.UPDATE,
|
||||
resourceType = AiCharacterAdminAuditResourceType.CHARACTER,
|
||||
resourceId = 2L
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("원작 해제 audit은 creator member id null을 허용한다")
|
||||
fun shouldAllowMissingCreatorMemberIdForUnassignment() {
|
||||
assertDoesNotThrow {
|
||||
AiCharacterAdminAuditContext.characterScoped(
|
||||
adminMemberId = 1L,
|
||||
characterId = 2L,
|
||||
creatorMemberId = null,
|
||||
action = AiCharacterAdminAuditAction.UNASSIGN,
|
||||
resourceType = AiCharacterAdminAuditResourceType.ORIGINAL_WORK_CHARACTER,
|
||||
resourceId = 10L
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("audit context는 factory 검증을 우회하는 public copy를 노출하지 않는다")
|
||||
fun shouldNotExposePublicCopyMethod() {
|
||||
assertFalse(AiCharacterAdminAuditContext::class.java.methods.any { method -> method.name == "copy" })
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("audit resource type은 원작 배정과 댓글/카테고리/공지/FanTalk 답글을 표현한다")
|
||||
fun shouldExposeRequiredAuditResourceTypes() {
|
||||
assertTrue(AiCharacterAdminAuditResourceType.values().contains(AiCharacterAdminAuditResourceType.ORIGINAL_WORK_CHARACTER))
|
||||
assertTrue(AiCharacterAdminAuditResourceType.values().contains(AiCharacterAdminAuditResourceType.CONTENT_COMMENT))
|
||||
assertTrue(AiCharacterAdminAuditResourceType.values().contains(AiCharacterAdminAuditResourceType.CONTENT_CATEGORY))
|
||||
assertTrue(AiCharacterAdminAuditResourceType.values().contains(AiCharacterAdminAuditResourceType.FAN_TALK_REPLY))
|
||||
assertTrue(AiCharacterAdminAuditResourceType.values().contains(AiCharacterAdminAuditResourceType.CHANNEL_NOTICE))
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("audit action은 콘텐츠 고정까지 표현한다")
|
||||
fun shouldExposePinAuditAction() {
|
||||
assertTrue(AiCharacterAdminAuditAction.values().contains(AiCharacterAdminAuditAction.PIN))
|
||||
}
|
||||
}
|
||||
-121
@@ -1,121 +0,0 @@
|
||||
package kr.co.vividnext.sodalive.v2.aicharacter.adapter.out.persistence
|
||||
|
||||
import kr.co.vividnext.sodalive.chat.character.CharacterType
|
||||
import kr.co.vividnext.sodalive.chat.character.ChatCharacter
|
||||
import kr.co.vividnext.sodalive.configs.QueryDslConfig
|
||||
import kr.co.vividnext.sodalive.member.Member
|
||||
import kr.co.vividnext.sodalive.member.MemberKind
|
||||
import kr.co.vividnext.sodalive.member.MemberRole
|
||||
import org.junit.jupiter.api.Assertions.assertEquals
|
||||
import org.junit.jupiter.api.Assertions.assertNull
|
||||
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.orm.jpa.DataJpaTest
|
||||
import org.springframework.boot.test.autoconfigure.orm.jpa.TestEntityManager
|
||||
import org.springframework.context.annotation.Import
|
||||
import javax.persistence.EntityManager
|
||||
import javax.persistence.TypedQuery
|
||||
|
||||
@DataJpaTest(properties = ["spring.cache.type=none"])
|
||||
@Import(QueryDslConfig::class)
|
||||
class DefaultAiCharacterPersistenceAdapterTest @Autowired constructor(
|
||||
private val entityManager: TestEntityManager,
|
||||
jpaEntityManager: EntityManager
|
||||
) {
|
||||
private val adapter = DefaultAiCharacterPersistenceAdapter(jpaEntityManager)
|
||||
|
||||
@Test
|
||||
@DisplayName("AI 캐릭터 관리자 대상은 ChatCharacter와 AI creator Member를 함께 반환한다")
|
||||
fun shouldFindAiCharacterAdminTarget() {
|
||||
val creator = persistMember(role = MemberRole.CREATOR, memberKind = MemberKind.AI_CHARACTER, isActive = true)
|
||||
val character = persistCharacter(creator = creator, isActive = true)
|
||||
|
||||
val target = adapter.findAdminTarget(character.id!!)
|
||||
|
||||
assertEquals(character.id, target?.characterId)
|
||||
assertEquals(creator.id, target?.creatorMemberId)
|
||||
assertEquals(true, target?.characterIsActive)
|
||||
assertEquals(true, target?.creatorMemberIsActive)
|
||||
assertEquals(MemberRole.CREATOR, target?.creatorRole)
|
||||
assertEquals(MemberKind.AI_CHARACTER, target?.memberKind)
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("존재하지 않는 캐릭터는 관리자 대상이 아니다")
|
||||
fun shouldReturnNullForMissingCharacter() {
|
||||
assertNull(adapter.findAdminTarget(-1L))
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("creator Member가 없으면 관리자 대상이 아니다")
|
||||
fun shouldReturnNullWhenCreatorMemberIsMissing() {
|
||||
val entityManager = Mockito.mock(EntityManager::class.java)
|
||||
val query = Mockito.mock(TypedQuery::class.java) as TypedQuery<ChatCharacter>
|
||||
Mockito.`when`(entityManager.createQuery(Mockito.anyString(), Mockito.eq(ChatCharacter::class.java)))
|
||||
.thenReturn(query)
|
||||
Mockito.`when`(query.setParameter("characterId", 1L)).thenReturn(query)
|
||||
Mockito.`when`(query.singleResult).thenReturn(characterWithoutCreator())
|
||||
|
||||
assertNull(DefaultAiCharacterPersistenceAdapter(entityManager).findAdminTarget(1L))
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("creator Member가 AI 캐릭터가 아니면 관리자 대상이 아니다")
|
||||
fun shouldIgnoreHumanCreatorMember() {
|
||||
val creator = persistMember(role = MemberRole.CREATOR, memberKind = MemberKind.HUMAN, isActive = true)
|
||||
val character = persistCharacter(creator = creator, isActive = true)
|
||||
|
||||
assertNull(adapter.findAdminTarget(character.id!!))
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("비활성 캐릭터와 creator는 조회 대상에 포함하되 상태를 반환한다")
|
||||
fun shouldReturnInactiveStateForExistingTarget() {
|
||||
val creator = persistMember(role = MemberRole.CREATOR, memberKind = MemberKind.AI_CHARACTER, isActive = false)
|
||||
val character = persistCharacter(creator = creator, isActive = false)
|
||||
|
||||
val target = adapter.findAdminTarget(character.id!!)
|
||||
|
||||
assertEquals(false, target?.characterIsActive)
|
||||
assertEquals(false, target?.creatorMemberIsActive)
|
||||
}
|
||||
|
||||
private fun persistMember(role: MemberRole, memberKind: MemberKind, isActive: Boolean): Member {
|
||||
return entityManager.persistAndFlush(
|
||||
Member(
|
||||
email = null,
|
||||
password = "",
|
||||
nickname = "creator-${System.nanoTime()}",
|
||||
role = role,
|
||||
memberKind = memberKind,
|
||||
isActive = isActive
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
private fun persistCharacter(creator: Member?, isActive: Boolean): ChatCharacter {
|
||||
val character = ChatCharacter(
|
||||
characterUUID = "uuid-${System.nanoTime()}",
|
||||
name = "Soda",
|
||||
description = "desc",
|
||||
systemPrompt = "prompt",
|
||||
characterType = CharacterType.Character,
|
||||
isActive = isActive
|
||||
)
|
||||
character.creatorMember = creator
|
||||
return entityManager.persistAndFlush(character)
|
||||
}
|
||||
|
||||
private fun characterWithoutCreator(): ChatCharacter {
|
||||
return ChatCharacter(
|
||||
characterUUID = "uuid-${System.nanoTime()}",
|
||||
name = "Soda",
|
||||
description = "desc",
|
||||
systemPrompt = "prompt",
|
||||
characterType = CharacterType.Character,
|
||||
isActive = true
|
||||
)
|
||||
}
|
||||
}
|
||||
-124
@@ -1,124 +0,0 @@
|
||||
package kr.co.vividnext.sodalive.v2.aicharacter.application
|
||||
|
||||
import kr.co.vividnext.sodalive.common.SodaException
|
||||
import kr.co.vividnext.sodalive.member.MemberKind
|
||||
import kr.co.vividnext.sodalive.member.MemberRole
|
||||
import kr.co.vividnext.sodalive.v2.aicharacter.domain.AiCharacterAdminTarget
|
||||
import kr.co.vividnext.sodalive.v2.aicharacter.port.out.AiCharacterPersistencePort
|
||||
import org.junit.jupiter.api.Assertions.assertAll
|
||||
import org.junit.jupiter.api.Assertions.assertEquals
|
||||
import org.junit.jupiter.api.Assertions.assertThrows
|
||||
import org.junit.jupiter.api.DisplayName
|
||||
import org.junit.jupiter.api.Test
|
||||
import org.springframework.http.HttpStatus
|
||||
|
||||
class AiCharacterAdminTargetResolverTest {
|
||||
@Test
|
||||
@DisplayName("존재하지 않는 관리자 대상 캐릭터는 404로 거부한다")
|
||||
fun shouldRejectMissingTarget() {
|
||||
val resolver = AiCharacterAdminTargetResolver(FakeAiCharacterPersistencePort(target = null))
|
||||
|
||||
val exception = assertThrows(SodaException::class.java) {
|
||||
resolver.resolveActiveTarget(characterId = 1L)
|
||||
}
|
||||
|
||||
assertAll(
|
||||
{ assertEquals("characterId", exception.errorProperty) },
|
||||
{ assertEquals(HttpStatus.NOT_FOUND, exception.httpStatus) }
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("AI 캐릭터 creator가 아니면 404로 거부한다")
|
||||
fun shouldRejectNonAiCreatorTargetAsMissing() {
|
||||
listOf(
|
||||
target(creatorRole = MemberRole.USER),
|
||||
target(memberKind = MemberKind.HUMAN)
|
||||
).forEach { target ->
|
||||
val resolver = AiCharacterAdminTargetResolver(FakeAiCharacterPersistencePort(target = target))
|
||||
|
||||
val exception = assertThrows(SodaException::class.java) {
|
||||
resolver.resolveActiveTarget(characterId = 1L)
|
||||
}
|
||||
|
||||
assertAll(
|
||||
{ assertEquals("characterId", exception.errorProperty) },
|
||||
{ assertEquals(HttpStatus.NOT_FOUND, exception.httpStatus) }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("생성/수정 대상 해석은 비활성 캐릭터 또는 creator를 409로 거부한다")
|
||||
fun shouldRejectInactiveTargetForMutation() {
|
||||
listOf(
|
||||
target(isCharacterActive = false, isCreatorActive = true),
|
||||
target(isCharacterActive = true, isCreatorActive = false)
|
||||
).forEach { target ->
|
||||
val resolver = AiCharacterAdminTargetResolver(FakeAiCharacterPersistencePort(target = target))
|
||||
|
||||
val exception = assertThrows(SodaException::class.java) {
|
||||
resolver.resolveActiveTarget(characterId = 1L)
|
||||
}
|
||||
|
||||
assertAll(
|
||||
{ assertEquals("characterId", exception.errorProperty) },
|
||||
{ assertEquals(HttpStatus.CONFLICT, exception.httpStatus) }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("삭제와 상태 조회는 비활성 관리자 대상을 반환한다")
|
||||
fun shouldReturnInactiveTargetForStatusOrDelete() {
|
||||
val target = target(isCharacterActive = false, isCreatorActive = false)
|
||||
val resolver = AiCharacterAdminTargetResolver(FakeAiCharacterPersistencePort(target = target))
|
||||
|
||||
val resolved = resolver.resolveExistingTarget(characterId = 1L)
|
||||
|
||||
assertEquals(target, resolved)
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("대상 해석은 요청 characterId를 persistence port에 그대로 전달한다")
|
||||
fun shouldPassRequestedCharacterIdToPersistencePort() {
|
||||
val port = FakeAiCharacterPersistencePort(target = target(characterId = 99L))
|
||||
val resolver = AiCharacterAdminTargetResolver(port)
|
||||
|
||||
val resolved = resolver.resolveActiveTarget(characterId = 99L)
|
||||
|
||||
assertAll(
|
||||
{ assertEquals(99L, port.requestedCharacterId) },
|
||||
{ assertEquals(99L, resolved.characterId) }
|
||||
)
|
||||
}
|
||||
|
||||
private fun target(
|
||||
characterId: Long = 1L,
|
||||
isCharacterActive: Boolean = true,
|
||||
isCreatorActive: Boolean = true,
|
||||
creatorRole: MemberRole = MemberRole.CREATOR,
|
||||
memberKind: MemberKind = MemberKind.AI_CHARACTER
|
||||
): AiCharacterAdminTarget {
|
||||
return AiCharacterAdminTarget(
|
||||
characterId = characterId,
|
||||
creatorMemberId = 10L,
|
||||
characterIsActive = isCharacterActive,
|
||||
creatorMemberIsActive = isCreatorActive,
|
||||
creatorRole = creatorRole,
|
||||
memberKind = memberKind
|
||||
)
|
||||
}
|
||||
|
||||
private class FakeAiCharacterPersistencePort(
|
||||
private val target: AiCharacterAdminTarget?
|
||||
) : AiCharacterPersistencePort {
|
||||
var requestedCharacterId: Long? = null
|
||||
private set
|
||||
|
||||
override fun findAdminTarget(characterId: Long): AiCharacterAdminTarget? {
|
||||
requestedCharacterId = characterId
|
||||
return target
|
||||
}
|
||||
}
|
||||
}
|
||||
+24
-7
@@ -1,12 +1,8 @@
|
||||
package kr.co.vividnext.sodalive.v2.api.creator.channel.community.adapter.`in`.web
|
||||
|
||||
import kr.co.vividnext.sodalive.common.CountryContext
|
||||
import kr.co.vividnext.sodalive.configs.SecurityConfig
|
||||
import kr.co.vividnext.sodalive.i18n.LangContext
|
||||
import kr.co.vividnext.sodalive.i18n.SodaMessageSource
|
||||
import kr.co.vividnext.sodalive.jwt.JwtAccessDeniedHandler
|
||||
import kr.co.vividnext.sodalive.jwt.JwtAuthenticationEntryPoint
|
||||
import kr.co.vividnext.sodalive.jwt.TokenProvider
|
||||
import kr.co.vividnext.sodalive.member.Member
|
||||
import kr.co.vividnext.sodalive.member.MemberAdapter
|
||||
import kr.co.vividnext.sodalive.member.MemberRole
|
||||
@@ -23,18 +19,25 @@ import org.junit.jupiter.api.Test
|
||||
import org.mockito.Mockito
|
||||
import org.springframework.beans.factory.annotation.Autowired
|
||||
import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest
|
||||
import org.springframework.boot.test.context.TestConfiguration
|
||||
import org.springframework.boot.test.mock.mockito.MockBean
|
||||
import org.springframework.context.annotation.Bean
|
||||
import org.springframework.context.annotation.Import
|
||||
import org.springframework.http.HttpStatus
|
||||
import org.springframework.security.config.annotation.web.builders.HttpSecurity
|
||||
import org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.anonymous
|
||||
import org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.user
|
||||
import org.springframework.security.web.SecurityFilterChain
|
||||
import org.springframework.security.web.authentication.HttpStatusEntryPoint
|
||||
import org.springframework.test.web.servlet.MockMvc
|
||||
import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get
|
||||
import org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath
|
||||
import org.springframework.test.web.servlet.result.MockMvcResultMatchers.status
|
||||
import java.time.LocalDateTime
|
||||
import javax.servlet.http.HttpServletResponse
|
||||
|
||||
@WebMvcTest(CreatorChannelCommunityController::class)
|
||||
@Import(SecurityConfig::class, JwtAuthenticationEntryPoint::class, JwtAccessDeniedHandler::class)
|
||||
@Import(CreatorChannelCommunityControllerTest.TestSecurityConfig::class)
|
||||
class CreatorChannelCommunityControllerTest @Autowired constructor(
|
||||
private val mockMvc: MockMvc
|
||||
) {
|
||||
@@ -50,8 +53,22 @@ class CreatorChannelCommunityControllerTest @Autowired constructor(
|
||||
@MockBean
|
||||
private lateinit var sodaMessageSource: SodaMessageSource
|
||||
|
||||
@MockBean
|
||||
private lateinit var tokenProvider: TokenProvider
|
||||
@TestConfiguration
|
||||
class TestSecurityConfig {
|
||||
@Bean
|
||||
fun securityFilterChain(http: HttpSecurity): SecurityFilterChain {
|
||||
return http
|
||||
.csrf().disable()
|
||||
.authorizeRequests()
|
||||
.anyRequest().authenticated()
|
||||
.and()
|
||||
.exceptionHandling()
|
||||
.authenticationEntryPoint(HttpStatusEntryPoint(HttpStatus.UNAUTHORIZED))
|
||||
.accessDeniedHandler { _, response, _ -> response.sendError(HttpServletResponse.SC_FORBIDDEN) }
|
||||
.and()
|
||||
.build()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("크리에이터 채널 커뮤니티 탭 조회는 비회원 요청을 거부한다")
|
||||
|
||||
+24
-7
@@ -1,12 +1,8 @@
|
||||
package kr.co.vividnext.sodalive.v2.api.creator.channel.fantalk.adapter.`in`.web
|
||||
|
||||
import kr.co.vividnext.sodalive.common.CountryContext
|
||||
import kr.co.vividnext.sodalive.configs.SecurityConfig
|
||||
import kr.co.vividnext.sodalive.i18n.LangContext
|
||||
import kr.co.vividnext.sodalive.i18n.SodaMessageSource
|
||||
import kr.co.vividnext.sodalive.jwt.JwtAccessDeniedHandler
|
||||
import kr.co.vividnext.sodalive.jwt.JwtAuthenticationEntryPoint
|
||||
import kr.co.vividnext.sodalive.jwt.TokenProvider
|
||||
import kr.co.vividnext.sodalive.member.Member
|
||||
import kr.co.vividnext.sodalive.member.MemberAdapter
|
||||
import kr.co.vividnext.sodalive.member.MemberRole
|
||||
@@ -19,18 +15,25 @@ import org.junit.jupiter.api.Test
|
||||
import org.mockito.Mockito
|
||||
import org.springframework.beans.factory.annotation.Autowired
|
||||
import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest
|
||||
import org.springframework.boot.test.context.TestConfiguration
|
||||
import org.springframework.boot.test.mock.mockito.MockBean
|
||||
import org.springframework.context.annotation.Bean
|
||||
import org.springframework.context.annotation.Import
|
||||
import org.springframework.http.HttpStatus
|
||||
import org.springframework.security.config.annotation.web.builders.HttpSecurity
|
||||
import org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.anonymous
|
||||
import org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.user
|
||||
import org.springframework.security.web.SecurityFilterChain
|
||||
import org.springframework.security.web.authentication.HttpStatusEntryPoint
|
||||
import org.springframework.test.web.servlet.MockMvc
|
||||
import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get
|
||||
import org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath
|
||||
import org.springframework.test.web.servlet.result.MockMvcResultMatchers.status
|
||||
import java.time.LocalDateTime
|
||||
import javax.servlet.http.HttpServletResponse
|
||||
|
||||
@WebMvcTest(CreatorChannelFanTalkController::class)
|
||||
@Import(SecurityConfig::class, JwtAuthenticationEntryPoint::class, JwtAccessDeniedHandler::class)
|
||||
@Import(CreatorChannelFanTalkControllerTest.TestSecurityConfig::class)
|
||||
class CreatorChannelFanTalkControllerTest @Autowired constructor(
|
||||
private val mockMvc: MockMvc
|
||||
) {
|
||||
@@ -46,8 +49,22 @@ class CreatorChannelFanTalkControllerTest @Autowired constructor(
|
||||
@MockBean
|
||||
private lateinit var sodaMessageSource: SodaMessageSource
|
||||
|
||||
@MockBean
|
||||
private lateinit var tokenProvider: TokenProvider
|
||||
@TestConfiguration
|
||||
class TestSecurityConfig {
|
||||
@Bean
|
||||
fun securityFilterChain(http: HttpSecurity): SecurityFilterChain {
|
||||
return http
|
||||
.csrf().disable()
|
||||
.authorizeRequests()
|
||||
.anyRequest().authenticated()
|
||||
.and()
|
||||
.exceptionHandling()
|
||||
.authenticationEntryPoint(HttpStatusEntryPoint(HttpStatus.UNAUTHORIZED))
|
||||
.accessDeniedHandler { _, response, _ -> response.sendError(HttpServletResponse.SC_FORBIDDEN) }
|
||||
.and()
|
||||
.build()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("크리에이터 채널 FanTalk 탭 조회는 비회원 요청을 거부한다")
|
||||
|
||||
+24
-7
@@ -1,12 +1,8 @@
|
||||
package kr.co.vividnext.sodalive.v2.api.creator.channel.series.adapter.`in`.web
|
||||
|
||||
import kr.co.vividnext.sodalive.common.CountryContext
|
||||
import kr.co.vividnext.sodalive.configs.SecurityConfig
|
||||
import kr.co.vividnext.sodalive.i18n.LangContext
|
||||
import kr.co.vividnext.sodalive.i18n.SodaMessageSource
|
||||
import kr.co.vividnext.sodalive.jwt.JwtAccessDeniedHandler
|
||||
import kr.co.vividnext.sodalive.jwt.JwtAuthenticationEntryPoint
|
||||
import kr.co.vividnext.sodalive.jwt.TokenProvider
|
||||
import kr.co.vividnext.sodalive.member.Member
|
||||
import kr.co.vividnext.sodalive.member.MemberAdapter
|
||||
import kr.co.vividnext.sodalive.member.MemberRole
|
||||
@@ -19,18 +15,25 @@ import org.junit.jupiter.api.Test
|
||||
import org.mockito.Mockito
|
||||
import org.springframework.beans.factory.annotation.Autowired
|
||||
import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest
|
||||
import org.springframework.boot.test.context.TestConfiguration
|
||||
import org.springframework.boot.test.mock.mockito.MockBean
|
||||
import org.springframework.context.annotation.Bean
|
||||
import org.springframework.context.annotation.Import
|
||||
import org.springframework.http.HttpStatus
|
||||
import org.springframework.security.config.annotation.web.builders.HttpSecurity
|
||||
import org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.anonymous
|
||||
import org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.user
|
||||
import org.springframework.security.web.SecurityFilterChain
|
||||
import org.springframework.security.web.authentication.HttpStatusEntryPoint
|
||||
import org.springframework.test.web.servlet.MockMvc
|
||||
import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get
|
||||
import org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath
|
||||
import org.springframework.test.web.servlet.result.MockMvcResultMatchers.status
|
||||
import java.time.LocalDateTime
|
||||
import javax.servlet.http.HttpServletResponse
|
||||
|
||||
@WebMvcTest(CreatorChannelSeriesController::class)
|
||||
@Import(SecurityConfig::class, JwtAuthenticationEntryPoint::class, JwtAccessDeniedHandler::class)
|
||||
@Import(CreatorChannelSeriesControllerTest.TestSecurityConfig::class)
|
||||
class CreatorChannelSeriesControllerTest @Autowired constructor(
|
||||
private val mockMvc: MockMvc
|
||||
) {
|
||||
@@ -46,8 +49,22 @@ class CreatorChannelSeriesControllerTest @Autowired constructor(
|
||||
@MockBean
|
||||
private lateinit var sodaMessageSource: SodaMessageSource
|
||||
|
||||
@MockBean
|
||||
private lateinit var tokenProvider: TokenProvider
|
||||
@TestConfiguration
|
||||
class TestSecurityConfig {
|
||||
@Bean
|
||||
fun securityFilterChain(http: HttpSecurity): SecurityFilterChain {
|
||||
return http
|
||||
.csrf().disable()
|
||||
.authorizeRequests()
|
||||
.anyRequest().authenticated()
|
||||
.and()
|
||||
.exceptionHandling()
|
||||
.authenticationEntryPoint(HttpStatusEntryPoint(HttpStatus.UNAUTHORIZED))
|
||||
.accessDeniedHandler { _, response, _ -> response.sendError(HttpServletResponse.SC_FORBIDDEN) }
|
||||
.and()
|
||||
.build()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("크리에이터 채널 시리즈 탭 조회는 비회원 요청을 거부한다")
|
||||
|
||||
-333
@@ -1,333 +0,0 @@
|
||||
package kr.co.vividnext.sodalive.v2.common.application
|
||||
|
||||
import kr.co.vividnext.sodalive.chat.character.repository.ChatCharacterRepository
|
||||
import kr.co.vividnext.sodalive.chat.original.OriginalWorkRepository
|
||||
import kr.co.vividnext.sodalive.configs.QueryDslConfig
|
||||
import kr.co.vividnext.sodalive.content.AudioContentRepository
|
||||
import kr.co.vividnext.sodalive.content.LanguageDetectEvent
|
||||
import kr.co.vividnext.sodalive.content.LanguageDetectListener
|
||||
import kr.co.vividnext.sodalive.content.LanguageDetectTargetType
|
||||
import kr.co.vividnext.sodalive.content.LanguageDetectionCacheService
|
||||
import kr.co.vividnext.sodalive.content.category.CategoryRepository
|
||||
import kr.co.vividnext.sodalive.content.comment.AudioContentCommentRepository
|
||||
import kr.co.vividnext.sodalive.content.series.ContentSeriesRepository
|
||||
import kr.co.vividnext.sodalive.explorer.profile.CreatorCheersRepository
|
||||
import kr.co.vividnext.sodalive.fcm.FcmEvent
|
||||
import kr.co.vividnext.sodalive.fcm.FcmEventType
|
||||
import kr.co.vividnext.sodalive.fcm.FcmSendListener
|
||||
import kr.co.vividnext.sodalive.fcm.FcmService
|
||||
import kr.co.vividnext.sodalive.fcm.PushTokenInfo
|
||||
import kr.co.vividnext.sodalive.fcm.notification.PushNotificationService
|
||||
import kr.co.vividnext.sodalive.i18n.SodaMessageSource
|
||||
import kr.co.vividnext.sodalive.i18n.translation.LanguageTranslationEvent
|
||||
import kr.co.vividnext.sodalive.i18n.translation.LanguageTranslationListener
|
||||
import kr.co.vividnext.sodalive.i18n.translation.LanguageTranslationTargetType
|
||||
import kr.co.vividnext.sodalive.i18n.translation.ResourceTranslationJobScheduler
|
||||
import kr.co.vividnext.sodalive.member.MemberRepository
|
||||
import org.junit.jupiter.api.Assertions.assertEquals
|
||||
import org.junit.jupiter.api.Assertions.assertThrows
|
||||
import org.junit.jupiter.api.DisplayName
|
||||
import org.junit.jupiter.api.Test
|
||||
import org.mockito.Mockito
|
||||
import org.mockito.Mockito.verify
|
||||
import org.springframework.beans.factory.annotation.Autowired
|
||||
import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest
|
||||
import org.springframework.boot.test.mock.mockito.MockBean
|
||||
import org.springframework.context.ApplicationEventPublisher
|
||||
import org.springframework.context.annotation.Import
|
||||
import org.springframework.transaction.PlatformTransactionManager
|
||||
import org.springframework.transaction.annotation.Propagation
|
||||
import org.springframework.transaction.annotation.Transactional
|
||||
import org.springframework.transaction.event.TransactionPhase
|
||||
import org.springframework.transaction.event.TransactionalEventListener
|
||||
import org.springframework.transaction.support.TransactionTemplate
|
||||
|
||||
@DataJpaTest(
|
||||
properties = [
|
||||
"spring.cache.type=none",
|
||||
"cloud.naver.papago-client-id=test-client-id",
|
||||
"cloud.naver.papago-client-secret=test-client-secret"
|
||||
]
|
||||
)
|
||||
@Import(
|
||||
AfterCommitExecutor::class,
|
||||
QueryDslConfig::class,
|
||||
AfterCommitEventBoundaryIntegrationTest.TestEventListener::class,
|
||||
FcmSendListener::class,
|
||||
LanguageDetectListener::class,
|
||||
LanguageTranslationListener::class
|
||||
)
|
||||
@Transactional(propagation = Propagation.NOT_SUPPORTED)
|
||||
class AfterCommitEventBoundaryIntegrationTest @Autowired constructor(
|
||||
private val executor: AfterCommitExecutor,
|
||||
private val eventPublisher: ApplicationEventPublisher,
|
||||
private val testEventListener: TestEventListener,
|
||||
transactionManager: PlatformTransactionManager
|
||||
) {
|
||||
private val transactionTemplate = TransactionTemplate(transactionManager)
|
||||
|
||||
@MockBean
|
||||
private lateinit var fcmService: FcmService
|
||||
|
||||
@MockBean
|
||||
private lateinit var memberRepository: MemberRepository
|
||||
|
||||
@MockBean
|
||||
private lateinit var contentCommentRepository: AudioContentCommentRepository
|
||||
|
||||
@MockBean
|
||||
private lateinit var sodaMessageSource: SodaMessageSource
|
||||
|
||||
@MockBean
|
||||
private lateinit var pushNotificationService: PushNotificationService
|
||||
|
||||
@MockBean
|
||||
private lateinit var audioContentRepository: AudioContentRepository
|
||||
|
||||
@MockBean
|
||||
private lateinit var chatCharacterRepository: ChatCharacterRepository
|
||||
|
||||
@MockBean
|
||||
private lateinit var characterCommentRepository: kr.co.vividnext.sodalive.chat.character.comment.CharacterCommentRepository
|
||||
|
||||
@MockBean
|
||||
private lateinit var creatorCheersRepository: CreatorCheersRepository
|
||||
|
||||
@MockBean
|
||||
private lateinit var seriesRepository: ContentSeriesRepository
|
||||
|
||||
@MockBean
|
||||
private lateinit var originalWorkRepository: OriginalWorkRepository
|
||||
|
||||
@MockBean
|
||||
private lateinit var categoryRepository: CategoryRepository
|
||||
|
||||
@MockBean
|
||||
private lateinit var languageDetectionCacheService: LanguageDetectionCacheService
|
||||
|
||||
@MockBean
|
||||
private lateinit var resourceTranslationJobScheduler: ResourceTranslationJobScheduler
|
||||
|
||||
@Test
|
||||
@DisplayName("direct callback은 실제 transaction commit 후 1회 실행된다")
|
||||
fun shouldRunCallbackAfterRealTransactionCommit() {
|
||||
var count = 0
|
||||
|
||||
transactionTemplate.executeWithoutResult {
|
||||
executor.executeAfterCommit { count += 1 }
|
||||
assertEquals(0, count)
|
||||
}
|
||||
|
||||
assertEquals(1, count)
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("direct callback은 실제 transaction rollback 후 실행되지 않는다")
|
||||
fun shouldNotRunCallbackAfterRealTransactionRollback() {
|
||||
var count = 0
|
||||
|
||||
try {
|
||||
transactionTemplate.executeWithoutResult {
|
||||
executor.executeAfterCommit { count += 1 }
|
||||
throw IllegalStateException("rollback")
|
||||
}
|
||||
} catch (e: IllegalStateException) {
|
||||
assertEquals("rollback", e.message)
|
||||
}
|
||||
|
||||
assertEquals(0, count)
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("첫 attempt rollback 후 동일 command 재시도 commit 시 callback은 총 1회 실행된다")
|
||||
fun shouldRunCallbackOnceWhenSameCommandCommitsOnRetryAfterRollback() {
|
||||
var attemptCount = 0
|
||||
var callbackCount = 0
|
||||
val command = {
|
||||
transactionTemplate.executeWithoutResult {
|
||||
attemptCount += 1
|
||||
executor.executeAfterCommit { callbackCount += 1 }
|
||||
if (attemptCount == 1) {
|
||||
throw IllegalStateException("rollback")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
assertThrows(IllegalStateException::class.java) { command() }
|
||||
assertEquals(0, callbackCount)
|
||||
|
||||
command()
|
||||
|
||||
assertEquals(2, attemptCount)
|
||||
assertEquals(1, callbackCount)
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("기존 FCM과 언어 event listener는 AFTER_COMMIT 경계를 유지한다")
|
||||
fun shouldKeepExistingEventListenersAfterCommit() {
|
||||
assertEquals(
|
||||
TransactionPhase.AFTER_COMMIT,
|
||||
transactionalEventListener(FcmSendListener::class.java, "send", FcmEvent::class.java).phase
|
||||
)
|
||||
assertEquals(
|
||||
TransactionPhase.AFTER_COMMIT,
|
||||
transactionalEventListener(
|
||||
LanguageDetectListener::class.java,
|
||||
"detectLanguage",
|
||||
LanguageDetectEvent::class.java
|
||||
).phase
|
||||
)
|
||||
assertEquals(
|
||||
TransactionPhase.AFTER_COMMIT,
|
||||
transactionalEventListener(
|
||||
LanguageTranslationListener::class.java,
|
||||
"translationAfterCommit",
|
||||
LanguageTranslationEvent::class.java
|
||||
).phase
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("일반 event publish는 rollback되면 transactional listener를 실행하지 않는다")
|
||||
fun shouldNotRunTransactionalEventListenerAfterRollback() {
|
||||
testEventListener.messages.clear()
|
||||
|
||||
try {
|
||||
transactionTemplate.executeWithoutResult {
|
||||
eventPublisher.publishEvent(TestEvent("rollback"))
|
||||
throw IllegalStateException("rollback")
|
||||
}
|
||||
} catch (e: IllegalStateException) {
|
||||
assertEquals("rollback", e.message)
|
||||
}
|
||||
|
||||
assertEquals(emptyList<String>(), testEventListener.messages)
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("일반 event publish는 commit 이후 transactional listener를 실행한다")
|
||||
fun shouldRunTransactionalEventListenerAfterCommit() {
|
||||
testEventListener.messages.clear()
|
||||
|
||||
transactionTemplate.executeWithoutResult {
|
||||
eventPublisher.publishEvent(TestEvent("commit"))
|
||||
assertEquals(emptyList<String>(), testEventListener.messages)
|
||||
}
|
||||
|
||||
assertEquals(listOf("commit"), testEventListener.messages)
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("실제 FCM event listener는 transaction commit 이후 실행된다")
|
||||
fun shouldRunRealFcmEventListenerAfterCommit() {
|
||||
val event = FcmEvent(
|
||||
type = FcmEventType.CANCEL_LIVE,
|
||||
title = "title",
|
||||
message = "message",
|
||||
pushTokens = listOf(PushTokenInfo(token = "token", deviceType = "ios", languageCode = "ko"))
|
||||
)
|
||||
|
||||
transactionTemplate.executeWithoutResult {
|
||||
eventPublisher.publishEvent(event)
|
||||
Mockito.verifyNoInteractions(fcmService)
|
||||
}
|
||||
|
||||
Mockito.verify(fcmService, Mockito.timeout(1000)).send(
|
||||
tokens = listOf("token"),
|
||||
title = "title",
|
||||
message = "message",
|
||||
container = "ios",
|
||||
roomId = null,
|
||||
messageId = null,
|
||||
contentId = null,
|
||||
creatorId = null,
|
||||
auditionId = null,
|
||||
deepLinkValue = null,
|
||||
deepLinkId = null,
|
||||
deepLinkCommentPostId = null,
|
||||
chatType = null
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("실제 언어 감지 event listener는 rollback되면 실행되지 않는다")
|
||||
fun shouldNotRunRealLanguageDetectListenerAfterRollback() {
|
||||
try {
|
||||
transactionTemplate.executeWithoutResult {
|
||||
eventPublisher.publishEvent(
|
||||
LanguageDetectEvent(id = 9L, query = "hello", targetType = LanguageDetectTargetType.CHARACTER)
|
||||
)
|
||||
throw IllegalStateException("rollback")
|
||||
}
|
||||
} catch (e: IllegalStateException) {
|
||||
assertEquals("rollback", e.message)
|
||||
}
|
||||
|
||||
Mockito.verifyNoInteractions(chatCharacterRepository)
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("실제 언어 감지 event listener는 commit 이후 실행된다")
|
||||
fun shouldRunRealLanguageDetectListenerAfterCommit() {
|
||||
Mockito.doReturn("ko").`when`(languageDetectionCacheService)
|
||||
.detectWithCache(eqValue("hello"), eqValue("papago"), anyValue())
|
||||
|
||||
transactionTemplate.executeWithoutResult {
|
||||
eventPublisher.publishEvent(
|
||||
LanguageDetectEvent(
|
||||
id = 9L,
|
||||
query = "hello",
|
||||
targetType = LanguageDetectTargetType.CHARACTER
|
||||
)
|
||||
)
|
||||
Mockito.verifyNoInteractions(chatCharacterRepository)
|
||||
}
|
||||
|
||||
verify(chatCharacterRepository, Mockito.timeout(1000)).findById(eqValue(9L))
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("waitTransactionCommit=true 언어 번역 event는 commit 이후 scheduler를 호출한다")
|
||||
fun shouldRunRealLanguageTranslationListenerAfterCommitWhenWaitingTransactionCommit() {
|
||||
transactionTemplate.executeWithoutResult {
|
||||
eventPublisher.publishEvent(
|
||||
LanguageTranslationEvent(
|
||||
id = 11L,
|
||||
targetType = LanguageTranslationTargetType.CHARACTER,
|
||||
waitTransactionCommit = true
|
||||
)
|
||||
)
|
||||
Mockito.verifyNoInteractions(resourceTranslationJobScheduler)
|
||||
}
|
||||
|
||||
Mockito.verify(resourceTranslationJobScheduler, Mockito.timeout(1000))
|
||||
.scheduleResourceTranslations(LanguageTranslationTargetType.CHARACTER, 11L)
|
||||
}
|
||||
|
||||
private fun transactionalEventListener(
|
||||
type: Class<*>,
|
||||
methodName: String,
|
||||
eventType: Class<*>
|
||||
): TransactionalEventListener {
|
||||
return type.getDeclaredMethod(methodName, eventType).getAnnotation(TransactionalEventListener::class.java)
|
||||
}
|
||||
|
||||
private fun <T> eqValue(value: T): T {
|
||||
return Mockito.eq(value) ?: value
|
||||
}
|
||||
|
||||
private fun <T> anyValue(): T {
|
||||
return Mockito.any<T>()
|
||||
}
|
||||
|
||||
data class TestEvent(val message: String)
|
||||
|
||||
class TestEventListener {
|
||||
val messages = mutableListOf<String>()
|
||||
|
||||
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
|
||||
fun handle(event: TestEvent) {
|
||||
messages += event.message
|
||||
}
|
||||
}
|
||||
}
|
||||
-88
@@ -1,88 +0,0 @@
|
||||
package kr.co.vividnext.sodalive.v2.common.application
|
||||
|
||||
import org.junit.jupiter.api.Assertions.assertDoesNotThrow
|
||||
import org.junit.jupiter.api.Assertions.assertEquals
|
||||
import org.junit.jupiter.api.DisplayName
|
||||
import org.junit.jupiter.api.Test
|
||||
import org.springframework.transaction.support.TransactionSynchronizationManager
|
||||
|
||||
class AfterCommitExecutorTest {
|
||||
@Test
|
||||
@DisplayName("트랜잭션이 없으면 direct callback을 즉시 1회 실행한다")
|
||||
fun shouldRunCallbackImmediatelyWithoutTransaction() {
|
||||
val executor = AfterCommitExecutor()
|
||||
var count = 0
|
||||
|
||||
executor.executeAfterCommit { count += 1 }
|
||||
|
||||
assertEquals(1, count)
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("트랜잭션이 없으면 direct callback 예외를 호출자에게 전파하지 않는다")
|
||||
fun shouldNotPropagateCallbackExceptionWithoutTransaction() {
|
||||
val executor = AfterCommitExecutor()
|
||||
|
||||
assertDoesNotThrow {
|
||||
executor.executeAfterCommit { throw IllegalStateException("sensitive-body") }
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("트랜잭션 commit 이후 direct callback을 1회 실행한다")
|
||||
fun shouldRunCallbackOnceAfterCommit() {
|
||||
val executor = AfterCommitExecutor()
|
||||
var count = 0
|
||||
TransactionSynchronizationManager.initSynchronization()
|
||||
|
||||
try {
|
||||
executor.executeAfterCommit { count += 1 }
|
||||
TransactionSynchronizationManager.getSynchronizations().forEach { synchronization ->
|
||||
synchronization.afterCommit()
|
||||
}
|
||||
} finally {
|
||||
TransactionSynchronizationManager.clearSynchronization()
|
||||
}
|
||||
|
||||
assertEquals(1, count)
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("afterCommit callback 예외는 격리되어 이후 callback 실행을 막지 않는다")
|
||||
fun shouldIsolateCallbackExceptionAfterCommit() {
|
||||
val executor = AfterCommitExecutor()
|
||||
var count = 0
|
||||
TransactionSynchronizationManager.initSynchronization()
|
||||
|
||||
try {
|
||||
executor.executeAfterCommit { throw IllegalStateException("sensitive-body") }
|
||||
executor.executeAfterCommit { count += 1 }
|
||||
|
||||
assertDoesNotThrow {
|
||||
TransactionSynchronizationManager.getSynchronizations().forEach { synchronization ->
|
||||
synchronization.afterCommit()
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
TransactionSynchronizationManager.clearSynchronization()
|
||||
}
|
||||
|
||||
assertEquals(1, count)
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("트랜잭션 rollback이면 direct callback을 실행하지 않는다")
|
||||
fun shouldNotRunCallbackBeforeCommit() {
|
||||
val executor = AfterCommitExecutor()
|
||||
var count = 0
|
||||
TransactionSynchronizationManager.initSynchronization()
|
||||
|
||||
try {
|
||||
executor.executeAfterCommit { count += 1 }
|
||||
} finally {
|
||||
TransactionSynchronizationManager.clearSynchronization()
|
||||
}
|
||||
|
||||
assertEquals(0, count)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user