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,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
}
}