revert(ai-character): 기존 관리자 기능 기반을 제거한다

This commit is contained in:
2026-07-23 16:47:00 +09:00
parent a0bf71be47
commit d3564f8c0d
40 changed files with 83 additions and 11634 deletions
@@ -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)
}
}
@@ -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) }
)
}
}
@@ -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")
}
}
@@ -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")
}
}
@@ -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)
}
}
@@ -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))
}
}