feat(creatorname): 크리에이터 언어별 이름 관리와 공통 조회를 추가한다

This commit is contained in:
2026-10-16 14:03:21 +09:00
parent 9da27d3feb
commit 698cdc1fe3
19 changed files with 1443 additions and 3 deletions
@@ -6,6 +6,7 @@ import kr.co.vividnext.sodalive.i18n.SodaMessageSource
import kr.co.vividnext.sodalive.member.Member
import kr.co.vividnext.sodalive.member.MemberProvider
import kr.co.vividnext.sodalive.member.MemberRole
import kr.co.vividnext.sodalive.v2.creatorname.application.CreatorLocalizedNameService
import org.springframework.beans.factory.annotation.Value
import org.springframework.data.domain.Pageable
import org.springframework.data.repository.findByIdOrNull
@@ -22,6 +23,7 @@ class AdminMemberService(
private val passwordEncoder: PasswordEncoder,
private val messageSource: SodaMessageSource,
private val langContext: LangContext,
private val creatorLocalizedNameService: CreatorLocalizedNameService,
@Value("\${cloud.aws.cloud-front.host}")
private val cloudFrontHost: String
@@ -93,6 +95,8 @@ class AdminMemberService(
private fun processMemberListToGetAdminMemberListResponseItemList(
memberList: List<Member>
): List<GetAdminMemberListResponseItem> {
val creatorIds = memberList.filter { it.role == MemberRole.CREATOR }.map { requireNotNull(it.id) }
val localizedNames = creatorLocalizedNameService.findAllLocalizedNames(creatorIds)
return memberList
.asSequence()
.map {
@@ -145,7 +149,8 @@ class AdminMemberService(
auth = it.auth != null,
signUpDate = signUpDate,
signOutDate = signOutDate,
isActive = it.isActive
isActive = it.isActive,
localizedNames = localizedNames[it.id].orEmpty()
)
}
.toList()
@@ -16,5 +16,6 @@ data class GetAdminMemberListResponseItem(
val auth: Boolean,
val signUpDate: String,
val signOutDate: String,
val isActive: Boolean
val isActive: Boolean,
val localizedNames: Map<String, String> = emptyMap()
)
@@ -0,0 +1,22 @@
package kr.co.vividnext.sodalive.v2.creatorname.adapter.`in`.web
import kr.co.vividnext.sodalive.common.ApiResponse
import kr.co.vividnext.sodalive.i18n.Lang
import kr.co.vividnext.sodalive.v2.creatorname.application.CreatorLocalizedNameService
import kr.co.vividnext.sodalive.v2.creatorname.dto.CreatorLocalizedNameRequest
import org.springframework.security.access.prepost.PreAuthorize
import org.springframework.web.bind.annotation.PathVariable
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.RestController
@RestController
@RequestMapping("/api/v2/admin/creators/{creatorId}/localized-names/ja")
@PreAuthorize("hasRole('ADMIN')")
class AdminCreatorLocalizedNameController(private val service: CreatorLocalizedNameService) {
@PutMapping
fun saveLocalizedName(@PathVariable creatorId: Long, @RequestBody request: CreatorLocalizedNameRequest) = ApiResponse.ok(
service.saveLocalizedName(creatorId, Lang.fromCode("ja"), request.displayName)
)
}
@@ -0,0 +1,48 @@
package kr.co.vividnext.sodalive.v2.creatorname.adapter.out.persistence
import kr.co.vividnext.sodalive.common.BaseEntity
import kr.co.vividnext.sodalive.i18n.Lang
import kr.co.vividnext.sodalive.member.Member
import kr.co.vividnext.sodalive.v2.creatorname.domain.CreatorLocalizedName
import javax.persistence.Column
import javax.persistence.Entity
import javax.persistence.EnumType
import javax.persistence.Enumerated
import javax.persistence.FetchType
import javax.persistence.ForeignKey
import javax.persistence.JoinColumn
import javax.persistence.ManyToOne
import javax.persistence.Table
import javax.persistence.UniqueConstraint
@Entity
@Table(
name = "creator_localized_name",
uniqueConstraints = [UniqueConstraint(name = "uk_creator_localized_name_member_lang", columnNames = ["member_id", "lang"])]
)
class CreatorLocalizedNameEntity(
@Column(name = "member_id", nullable = false, updatable = false)
val memberId: Long,
@Enumerated(EnumType.STRING)
@Column(name = "lang", nullable = false, length = 10, updatable = false)
val lang: Lang,
@Column(name = "display_name", nullable = false, length = 255)
var displayName: String,
@Column(name = "is_active", nullable = false)
var isActive: Boolean = true
) : BaseEntity() {
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(
name = "member_id",
nullable = false,
insertable = false,
updatable = false,
foreignKey = ForeignKey(name = "fk_creator_localized_name_member")
)
private var member: Member? = null
fun toDomain(): CreatorLocalizedName = CreatorLocalizedName(memberId, lang, displayName, isActive, id)
}
@@ -0,0 +1,16 @@
package kr.co.vividnext.sodalive.v2.creatorname.adapter.out.persistence
import com.querydsl.core.types.dsl.BooleanExpression
import com.querydsl.core.types.dsl.StringExpression
import kr.co.vividnext.sodalive.i18n.Lang
import kr.co.vividnext.sodalive.member.MemberRole
import kr.co.vividnext.sodalive.member.QMember
class CreatorNameJoin(owner: QMember, lang: Lang, alias: String) {
val name: QCreatorLocalizedNameEntity = QCreatorLocalizedNameEntity(alias)
val condition: BooleanExpression = name.memberId.eq(owner.id)
.and(name.lang.eq(lang))
.and(name.isActive.isTrue)
.and(owner.role.eq(MemberRole.CREATOR))
val displayName: StringExpression = name.displayName.coalesce(owner.nickname)
}
@@ -0,0 +1,22 @@
package kr.co.vividnext.sodalive.v2.creatorname.adapter.out.persistence
import com.querydsl.core.types.dsl.BooleanExpression
import com.querydsl.jpa.JPAExpressions
import kr.co.vividnext.sodalive.i18n.Lang
import kr.co.vividnext.sodalive.member.MemberRole
import kr.co.vividnext.sodalive.member.QMember
import kr.co.vividnext.sodalive.v2.creatorname.adapter.out.persistence.QCreatorLocalizedNameEntity.creatorLocalizedNameEntity
fun creatorNameContains(owner: QMember, keyword: String): BooleanExpression {
val alias = creatorLocalizedNameEntity
return owner.nickname.containsIgnoreCase(keyword).or(
owner.role.eq(MemberRole.CREATOR).and(
JPAExpressions.selectOne().from(alias).where(
alias.memberId.eq(owner.id),
alias.lang.eq(Lang.JA),
alias.isActive.isTrue,
alias.displayName.containsIgnoreCase(keyword)
).exists()
)
)
}
@@ -0,0 +1,88 @@
package kr.co.vividnext.sodalive.v2.creatorname.adapter.out.persistence
import com.querydsl.jpa.impl.JPAQueryFactory
import kr.co.vividnext.sodalive.i18n.Lang
import kr.co.vividnext.sodalive.member.MemberRole
import kr.co.vividnext.sodalive.member.QMember.member
import kr.co.vividnext.sodalive.v2.creatorname.adapter.out.persistence.QCreatorLocalizedNameEntity.creatorLocalizedNameEntity
import kr.co.vividnext.sodalive.v2.creatorname.domain.CreatorLocalizedName
import kr.co.vividnext.sodalive.v2.creatorname.port.out.CreatorLocalizedNameRepository
import org.springframework.stereotype.Repository
import javax.persistence.EntityManager
@Repository
class DefaultCreatorLocalizedNameRepository(
private val entityManager: EntityManager,
private val queryFactory: JPAQueryFactory
) : CreatorLocalizedNameRepository {
override fun findByMemberIdAndLang(memberId: Long, lang: Lang): CreatorLocalizedName? = queryFactory
.selectFrom(creatorLocalizedNameEntity)
.where(creatorLocalizedNameEntity.memberId.eq(memberId), creatorLocalizedNameEntity.lang.eq(lang))
.fetchOne()
?.toDomain()
override fun findActiveLocalizedNames(memberIds: Collection<Long>, lang: Lang): Map<Long, String> {
if (memberIds.isEmpty()) return emptyMap()
return queryFactory
.select(creatorLocalizedNameEntity.memberId, creatorLocalizedNameEntity.displayName)
.from(creatorLocalizedNameEntity)
.innerJoin(member).on(member.id.eq(creatorLocalizedNameEntity.memberId))
.where(
creatorLocalizedNameEntity.memberId.`in`(memberIds),
creatorLocalizedNameEntity.lang.eq(lang),
creatorLocalizedNameEntity.isActive.isTrue,
member.role.eq(MemberRole.CREATOR)
)
.fetch()
.associate { row ->
requireNotNull(row[creatorLocalizedNameEntity.memberId]) to
requireNotNull(row[creatorLocalizedNameEntity.displayName])
}
}
override fun findAllActiveLocalizedNames(memberIds: Collection<Long>): Map<Long, Map<String, String>> {
if (memberIds.isEmpty()) return emptyMap()
return queryFactory
.select(creatorLocalizedNameEntity.memberId, creatorLocalizedNameEntity.lang, creatorLocalizedNameEntity.displayName)
.from(creatorLocalizedNameEntity)
.innerJoin(member).on(member.id.eq(creatorLocalizedNameEntity.memberId))
.where(
creatorLocalizedNameEntity.memberId.`in`(memberIds),
creatorLocalizedNameEntity.isActive.isTrue,
member.role.eq(MemberRole.CREATOR)
)
.fetch()
.groupBy { row -> requireNotNull(row[creatorLocalizedNameEntity.memberId]) }
.mapValues { (_, rows) ->
rows.associate { row ->
requireNotNull(row[creatorLocalizedNameEntity.lang]).code to
requireNotNull(row[creatorLocalizedNameEntity.displayName])
}
}
}
override fun findCurrentNames(memberIds: Collection<Long>, lang: Lang): Map<Long, String> {
if (memberIds.isEmpty()) return emptyMap()
val join = CreatorNameJoin(member, lang, "currentCreatorName")
return queryFactory
.select(member.id, join.displayName)
.from(member)
.leftJoin(join.name).on(join.condition)
.where(member.id.`in`(memberIds.distinct()))
.fetch()
.associate { row -> requireNotNull(row[member.id]) to requireNotNull(row[join.displayName]) }
}
override fun save(name: CreatorLocalizedName): CreatorLocalizedName {
val entity = if (name.id == null) {
CreatorLocalizedNameEntity(name.memberId, name.lang, name.displayName, name.isActive)
.also { entityManager.persist(it) }
} else {
entityManager.find(CreatorLocalizedNameEntity::class.java, name.id).apply {
displayName = name.displayName
isActive = name.isActive
}
}
return entity.toDomain()
}
}
@@ -0,0 +1,61 @@
package kr.co.vividnext.sodalive.v2.creatorname.application
import kr.co.vividnext.sodalive.common.SodaException
import kr.co.vividnext.sodalive.i18n.Lang
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.v2.creatorname.domain.CreatorLocalizedName
import kr.co.vividnext.sodalive.v2.creatorname.dto.CreatorLocalizedNameResponse
import kr.co.vividnext.sodalive.v2.creatorname.port.out.CreatorLocalizedNameRepository
import org.springframework.stereotype.Service
import org.springframework.transaction.annotation.Transactional
@Service
@Transactional(readOnly = true)
class CreatorLocalizedNameService(
private val memberRepository: MemberRepository,
private val repository: CreatorLocalizedNameRepository
) {
fun findLocalizedNames(memberIds: Collection<Long>, lang: Lang): Map<Long, String> {
if (memberIds.isEmpty()) return emptyMap()
return repository.findActiveLocalizedNames(memberIds, lang)
}
fun findCurrentNames(memberIds: Collection<Long>, lang: Lang): Map<Long, String> {
if (memberIds.isEmpty()) return emptyMap()
return repository.findCurrentNames(memberIds, lang)
}
fun findAllLocalizedNames(memberIds: Collection<Long>): Map<Long, Map<String, String>> {
if (memberIds.isEmpty()) return emptyMap()
return repository.findAllActiveLocalizedNames(memberIds)
}
@Transactional
fun saveLocalizedName(creatorId: Long, lang: Lang, displayName: String): CreatorLocalizedNameResponse {
requireJapanese(lang)
if (displayName.codePointCount(0, displayName.length) > 255) {
throw SodaException(messageKey = "common.error.invalid_request", errorProperty = "displayName")
}
val creator = requireCreator(memberRepository.findByIdForUpdate(creatorId))
val existing = repository.findByMemberIdAndLang(creatorId, lang)
val name = if (displayName.isBlank()) {
existing?.copy(isActive = false)
} else {
existing?.copy(displayName = displayName, isActive = true) ?: CreatorLocalizedName(creatorId, lang, displayName)
}
name?.let { repository.save(it) }
return CreatorLocalizedNameResponse(creatorId, lang.code, creator.nickname, name?.takeIf { it.isActive }?.displayName)
}
private fun requireJapanese(lang: Lang) {
if (lang != Lang.JA) throw SodaException(messageKey = "common.error.invalid_request")
}
private fun requireCreator(member: Member?): Member {
val creator = member ?: throw SodaException(messageKey = "admin.member.not_found")
if (creator.role != MemberRole.CREATOR) throw SodaException(messageKey = "admin.live.creator_required")
return creator
}
}
@@ -0,0 +1,11 @@
package kr.co.vividnext.sodalive.v2.creatorname.domain
import kr.co.vividnext.sodalive.i18n.Lang
data class CreatorLocalizedName(
val memberId: Long,
val lang: Lang,
val displayName: String,
val isActive: Boolean = true,
val id: Long? = null
)
@@ -0,0 +1,3 @@
package kr.co.vividnext.sodalive.v2.creatorname.dto
data class CreatorLocalizedNameRequest(val displayName: String)
@@ -0,0 +1,8 @@
package kr.co.vividnext.sodalive.v2.creatorname.dto
data class CreatorLocalizedNameResponse(
val creatorId: Long,
val locale: String,
val originalNickname: String,
val displayName: String?
)
@@ -0,0 +1,12 @@
package kr.co.vividnext.sodalive.v2.creatorname.port.out
import kr.co.vividnext.sodalive.i18n.Lang
import kr.co.vividnext.sodalive.v2.creatorname.domain.CreatorLocalizedName
interface CreatorLocalizedNameRepository {
fun findByMemberIdAndLang(memberId: Long, lang: Lang): CreatorLocalizedName?
fun findActiveLocalizedNames(memberIds: Collection<Long>, lang: Lang): Map<Long, String>
fun findAllActiveLocalizedNames(memberIds: Collection<Long>): Map<Long, Map<String, String>>
fun findCurrentNames(memberIds: Collection<Long>, lang: Lang): Map<Long, String>
fun save(name: CreatorLocalizedName): CreatorLocalizedName
}
@@ -1,6 +1,10 @@
package kr.co.vividnext.sodalive.admin.member
import com.fasterxml.jackson.databind.JsonNode
import com.fasterxml.jackson.databind.ObjectMapper
import kr.co.vividnext.sodalive.i18n.Lang
import kr.co.vividnext.sodalive.member.Member
import kr.co.vividnext.sodalive.member.MemberKind
import kr.co.vividnext.sodalive.member.MemberRole
import kr.co.vividnext.sodalive.member.SignOut
import kr.co.vividnext.sodalive.member.SignOutRepository
@@ -10,24 +14,140 @@ import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.DisplayName
import org.junit.jupiter.api.Test
import org.junit.jupiter.params.ParameterizedTest
import org.junit.jupiter.params.provider.ValueSource
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.data.domain.PageRequest
import org.springframework.http.HttpHeaders
import org.springframework.jdbc.core.JdbcTemplate
import org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.user
import org.springframework.test.context.ContextConfiguration
import org.springframework.test.web.servlet.MockMvc
import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get
import org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath
import org.springframework.test.web.servlet.result.MockMvcResultMatchers.status
import org.springframework.web.HttpRequestMethodNotSupportedException
@SpringBootTest(properties = ["cloud.aws.cloud-front.host=https://cdn.test"])
@AutoConfigureMockMvc
@ContextConfiguration(initializers = [EmbeddedRedisInitializer::class])
class AdminMemberServiceTest @Autowired constructor(
private val service: AdminMemberService,
private val adminMemberRepository: AdminMemberRepository,
private val signOutRepository: SignOutRepository
private val signOutRepository: SignOutRepository,
private val jdbcTemplate: JdbcTemplate,
private val mockMvc: MockMvc,
private val objectMapper: ObjectMapper
) {
@AfterEach
fun tearDown() {
signOutRepository.deleteAll()
jdbcTemplate.update("delete from creator_localized_name")
adminMemberRepository.deleteAll()
}
@ParameterizedTest
@ValueSource(strings = ["/list", "/search", "/creator/list", "/creator/search"])
@DisplayName("관리자 목록·검색 4개 API는 페이지와 원본을 유지하며 헤더와 무관하게 모든 활성 언어 이름을 제공한다")
fun shouldEmbedAllLanguagesWithoutChangingMemberPagesOrOriginalNames(path: String) {
val first = saveListMember("first")
val second = saveListMember("second", kind = MemberKind.AI_CHARACTER, isActive = false)
val missing = saveListMember("missing")
val cleared = saveListMember("cleared")
val regularUser = saveListMember("user", MemberRole.USER)
val aiUser = saveListMember("ai-user", MemberRole.USER, MemberKind.AI_CHARACTER)
saveAlias(first, Lang.JA, " 日本 Name 😀 ")
saveAlias(first, Lang.EN, "English")
saveAlias(first, Lang.KO, "해제됨", isActive = false)
saveAlias(second, Lang.JA, "AI 日本")
saveAlias(second, Lang.KO, "AI 한국어")
saveAlias(cleared, Lang.JA, "inactive", isActive = false)
saveAlias(regularUser, Lang.JA, "must not expose USER")
saveAlias(aiUser, Lang.EN, "must not expose AI USER")
val members = listOf(first, second, missing, cleared, regularUser, aiUser)
val expectedIds = if (path.startsWith("/creator/")) {
listOf(cleared.id, missing.id, second.id, first.id)
} else {
listOf(aiUser.id, regularUser.id, cleared.id, missing.id, second.id, first.id)
}
val expectedNames = mapOf(
first.id to mapOf("ja" to " 日本 Name 😀 ", "en" to "English"),
second.id to mapOf("ja" to "AI 日本", "ko" to "AI 한국어")
)
listOf(null, "ko", "ja", "en").forEach { language ->
for (page in 0..expectedIds.size / 2) {
val request = get("/admin/member$path")
.with(user("admin").roles("ADMIN"))
.param("page", page.toString())
.param("size", "2")
.param("search_word", "cor014-original")
language?.let { request.header(HttpHeaders.ACCEPT_LANGUAGE, it) }
val result = mockMvc.perform(request)
.andExpect(status().isOk)
.andExpect(jsonPath("$.success").value(true))
.andExpect(jsonPath("$.data.totalCount").value(expectedIds.size))
.andReturn()
val items = objectMapper.readTree(result.response.contentAsByteArray).path("data").path("items")
assertEquals(expectedIds.drop(page * 2).take(2), items.map { it.path("id").asLong() })
items.forEach { item ->
val member = members.single { it.id == item.path("id").asLong() }
assertEquals(member.nickname, item.path("nickname").asText())
assertEquals(member.email, item.path("email").asText())
assertEquals("https://cdn.test/profile/default-profile.png", item.path("profileUrl").asText())
assertEquals(member.isActive, item.path("isActive").asBoolean())
assertEquals(
objectMapper.valueToTree<JsonNode>(expectedNames[member.id] ?: emptyMap<String, String>()),
item.path("localizedNames")
)
}
}
}
}
@Test
@DisplayName("이름 전용 GET은 제거되어 기존 공통 예외 응답을 반환하고 이름 데이터를 노출하지 않는다")
fun shouldRejectDedicatedLocalizedNameGet() {
val result = mockMvc.perform(
get("/api/v2/admin/creators/1/localized-names/ja")
.with(user("admin").roles("ADMIN"))
.header(HttpHeaders.ACCEPT_LANGUAGE, "en")
)
.andExpect(status().isOk)
.andExpect(jsonPath("$.success").value(false))
.andExpect(jsonPath("$.data").doesNotExist())
.andReturn()
assertTrue(result.resolvedException is HttpRequestMethodNotSupportedException)
}
private fun saveListMember(
suffix: String,
role: MemberRole = MemberRole.CREATOR,
kind: MemberKind = MemberKind.HUMAN,
isActive: Boolean = true
): Member = adminMemberRepository.saveAndFlush(
Member(
email = "cor014-$suffix@test.com",
password = "password",
nickname = "cor014-original-$suffix",
role = role,
memberKind = kind,
isActive = isActive
)
)
private fun saveAlias(member: Member, lang: Lang, displayName: String, isActive: Boolean = true) {
jdbcTemplate.update(
"insert into creator_localized_name (member_id, lang, display_name, is_active, created_at, updated_at) " +
"values (?, ?, ?, ?, current_timestamp, current_timestamp)",
requireNotNull(member.id),
lang.name,
displayName,
isActive
)
}
@Test
@DisplayName("OSIV off 환경에서 탈퇴 이력이 있는 회원 리스트를 조회해도 lazy 초기화 예외가 발생하지 않는다")
fun shouldGetMemberListWithSignOutReasonsWhenOpenInViewIsDisabled() {
@@ -0,0 +1,17 @@
package kr.co.vividnext.sodalive.support
import kr.co.vividnext.sodalive.v2.creatorname.adapter.out.persistence.DefaultCreatorLocalizedNameRepository
import kr.co.vividnext.sodalive.v2.creatorname.application.CreatorLocalizedNameService
import org.mockito.Mockito
import org.springframework.boot.test.context.TestConfiguration
import org.springframework.context.annotation.Import
@TestConfiguration
@Import(
CreatorLocalizedNameService::class,
DefaultCreatorLocalizedNameRepository::class
)
class LegacyCreatorNameTestConfiguration
fun originalLocalizedNameService(): CreatorLocalizedNameService =
Mockito.mock(CreatorLocalizedNameService::class.java)
@@ -0,0 +1,283 @@
package kr.co.vividnext.sodalive.v2.creatorname.adapter.`in`.web
import com.fasterxml.jackson.databind.JsonNode
import com.fasterxml.jackson.databind.ObjectMapper
import kr.co.vividnext.sodalive.jwt.TokenProvider
import kr.co.vividnext.sodalive.member.Member
import kr.co.vividnext.sodalive.member.MemberKind
import kr.co.vividnext.sodalive.member.MemberRepository
import kr.co.vividnext.sodalive.member.MemberRole
import kr.co.vividnext.sodalive.member.token.MemberTokenRepository
import kr.co.vividnext.sodalive.support.EmbeddedRedisInitializer
import kr.co.vividnext.sodalive.v2.creatorname.support.CreatorLocalizedNameH2Schema
import org.junit.jupiter.api.AfterEach
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.BeforeEach
import org.junit.jupiter.api.DisplayName
import org.junit.jupiter.api.Test
import org.springframework.beans.factory.annotation.Autowired
import org.springframework.boot.test.context.SpringBootTest
import org.springframework.boot.test.web.client.TestRestTemplate
import org.springframework.boot.test.web.server.LocalServerPort
import org.springframework.http.HttpEntity
import org.springframework.http.HttpHeaders
import org.springframework.http.HttpMethod
import org.springframework.http.HttpStatus
import org.springframework.http.MediaType
import org.springframework.jdbc.core.JdbcTemplate
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken
import org.springframework.security.core.authority.SimpleGrantedAuthority
import org.springframework.test.annotation.DirtiesContext
import org.springframework.test.context.ContextConfiguration
import java.util.UUID
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
@ContextConfiguration(initializers = [EmbeddedRedisInitializer::class])
@DirtiesContext(classMode = DirtiesContext.ClassMode.AFTER_CLASS)
class AdminCreatorLocalizedNameControllerTest @Autowired constructor(
private val restTemplate: TestRestTemplate,
private val objectMapper: ObjectMapper,
private val memberRepository: MemberRepository,
private val tokenProvider: TokenProvider,
private val tokenRepository: MemberTokenRepository,
private val jdbcTemplate: JdbcTemplate
) {
@LocalServerPort
private var port: Int = 0
private val memberIds = mutableListOf<Long>()
@BeforeEach
fun prepareCodePointStorage() {
CreatorLocalizedNameH2Schema.prepare(jdbcTemplate)
}
@AfterEach
fun cleanUp() {
tokenRepository.deleteAll()
jdbcTemplate.update("delete from creator_localized_name")
memberRepository.deleteAllById(memberIds)
}
@Test
@DisplayName("실제 HTTP에서 ADMIN은 PUT으로 저장·해제·재활성화하고 회원 목록에서 모든 언어를 헤더와 무관하게 조회한다")
fun shouldReturnOriginalAndLocalizedNameForAdmin() {
val creator = saveMember(MemberRole.CREATOR)
val token = tokenFor(saveMember(MemberRole.ADMIN))
val creatorId = requireNotNull(creator.id)
assertListNames(creatorId, token, emptyMap())
val originalInput = " 手動 Name 😀 "
assertName(
exchange(creatorId, HttpMethod.PUT, token, mapOf("displayName" to originalInput)),
creatorId,
"original",
originalInput
)
val savedId = rowId(creatorId)
jdbcTemplate.update(
"insert into creator_localized_name (member_id, lang, display_name, is_active, created_at, updated_at) " +
"values (?, 'EN', ?, true, current_timestamp, current_timestamp)",
creatorId,
"English"
)
assertListNames(creatorId, token, mapOf("ja" to originalInput, "en" to "English"))
assertEquals(
"JA",
jdbcTemplate.queryForObject("select lang from creator_localized_name where id = ?", String::class.java, savedId)
)
listOf("", " \t\n").forEach { blank ->
assertName(exchange(creatorId, HttpMethod.PUT, token, mapOf("displayName" to blank)), creatorId, "original", null)
assertListNames(creatorId, token, mapOf("en" to "English"))
assertEquals(savedId, rowId(creatorId))
assertEquals(
originalInput,
jdbcTemplate.queryForObject(
"select display_name from creator_localized_name where id = ?",
String::class.java,
savedId
)
)
assertFalse(
requireNotNull(
jdbcTemplate.queryForObject(
"select is_active from creator_localized_name where id = ?",
Boolean::class.java,
savedId
)
)
)
assertName(
exchange(creatorId, HttpMethod.PUT, token, mapOf("displayName" to originalInput)),
creatorId,
"original",
originalInput
)
assertListNames(creatorId, token, mapOf("ja" to originalInput, "en" to "English"))
}
assertEquals(savedId, rowId(creatorId))
assertEquals(
2L,
jdbcTemplate.queryForObject(
"select count(*) from creator_localized_name where member_id = ?",
Long::class.java,
creatorId
)
)
assertEquals("original", memberRepository.findById(creatorId).get().nickname)
}
@Test
@DisplayName("실제 HTTP에서 AI CREATOR는 허용하고 USER 및 AI USER는 저장을 거부한다")
fun shouldRejectNonCreatorIncludingAiUser() {
val token = tokenFor(saveMember(MemberRole.ADMIN))
val aiCreator = saveMember(MemberRole.CREATOR, MemberKind.AI_CHARACTER)
val creatorId = requireNotNull(aiCreator.id)
assertName(exchange(creatorId, HttpMethod.PUT, token, mapOf("displayName" to "AI 名")), creatorId, "original", "AI 名")
listOf(MemberKind.HUMAN, MemberKind.AI_CHARACTER).forEach { kind ->
val userId = requireNotNull(saveMember(MemberRole.USER, kind).id)
val response = exchange(userId, HttpMethod.PUT, token, mapOf("displayName" to "denied"))
assertFalse(response.path("success").asBoolean())
assertEquals("Please select a valid creator.", response.path("message").asText())
assertEquals(
0L,
jdbcTemplate.queryForObject(
"select count(*) from creator_localized_name where member_id = ?",
Long::class.java,
userId
)
)
}
}
@Test
@DisplayName("실제 JWT HTTP에서 모든 비관리자 역할의 회원 목록·이름 PUT을 거부하고 비회원은 401이다")
fun shouldRejectNonAdminAndAnonymous() {
val creatorId = requireNotNull(saveMember(MemberRole.CREATOR).id)
MemberRole.values().filter { it != MemberRole.ADMIN }.forEach { role ->
val token = tokenFor(saveMember(role))
listOf(
memberList(token, "en"),
exchange(creatorId, HttpMethod.PUT, token, mapOf("displayName" to "denied"))
).forEach { response ->
assertFalse(response.path("success").asBoolean())
assertEquals("You do not have permission.", response.path("message").asText())
}
}
listOf(HttpMethod.GET to memberListUrl(), HttpMethod.PUT to url(creatorId)).forEach { (method, endpoint) ->
val headers = HttpHeaders().apply { contentType = MediaType.APPLICATION_JSON }
val response = restTemplate.exchange(
endpoint,
method,
HttpEntity(mapOf("displayName" to "denied"), headers),
String::class.java
)
assertEquals(HttpStatus.UNAUTHORIZED, response.statusCode)
}
assertEquals(0L, jdbcTemplate.queryForObject("select count(*) from creator_localized_name", Long::class.java))
}
@Test
@DisplayName("실제 HTTP에서 누락 대상·필수 입력 누락·null·256 code point 입력은 데이터를 변경하지 않는다")
fun shouldRejectMissingTargetAndInvalidInputWithoutMutation() {
val creatorId = requireNotNull(saveMember(MemberRole.CREATOR).id)
val token = tokenFor(saveMember(MemberRole.ADMIN))
val response = exchange(Long.MAX_VALUE, HttpMethod.PUT, token, mapOf("displayName" to "name"))
assertFalse(response.path("success").asBoolean())
assertEquals("User not found.", response.path("message").asText())
val accepted = "😀".repeat(255)
assertName(exchange(creatorId, HttpMethod.PUT, token, mapOf("displayName" to accepted)), creatorId, "original", accepted)
val savedId = rowId(creatorId)
listOf(emptyMap(), mapOf("displayName" to null), mapOf("displayName" to "😀".repeat(256))).forEach { body ->
assertFalse(exchange(creatorId, HttpMethod.PUT, token, body).path("success").asBoolean())
assertListNames(creatorId, token, mapOf("ja" to accepted))
assertEquals(savedId, rowId(creatorId))
}
}
private fun assertListNames(creatorId: Long, token: String, expected: Map<String, String>) {
listOf(null, "ko", "ja", "en").forEach { language ->
val response = memberList(token, language)
assertTrue(response.path("success").asBoolean(), response.toString())
val item = response.path("data").path("items").single { it.path("id").asLong() == creatorId }
assertEquals("original", item.path("nickname").asText())
assertEquals(objectMapper.valueToTree<JsonNode>(expected), item.path("localizedNames"))
}
}
private fun memberList(token: String, language: String?): JsonNode {
val headers = HttpHeaders().apply {
setBearerAuth(token)
language?.let { set(HttpHeaders.ACCEPT_LANGUAGE, it) }
}
val response = restTemplate.exchange(
memberListUrl(),
HttpMethod.GET,
HttpEntity<Void>(headers),
String::class.java
)
assertEquals(HttpStatus.OK, response.statusCode, response.body)
return objectMapper.readTree(response.body)
}
private fun exchange(creatorId: Long, method: HttpMethod, token: String, body: Map<String, String?>? = null): JsonNode {
val headers = HttpHeaders().apply {
setBearerAuth(token)
contentType = MediaType.APPLICATION_JSON
set(HttpHeaders.ACCEPT_LANGUAGE, "en")
}
val response = restTemplate.exchange(
url(creatorId),
method,
HttpEntity(body?.let { objectMapper.writeValueAsString(it) }, headers),
String::class.java
)
assertEquals(HttpStatus.OK, response.statusCode, response.body)
return objectMapper.readTree(response.body)
}
private fun assertName(response: JsonNode, creatorId: Long, original: String, displayName: String?) {
assertTrue(response.path("success").asBoolean(), response.toString())
val data = response.path("data")
assertEquals(creatorId, data.path("creatorId").asLong())
assertEquals("ja", data.path("locale").asText())
assertEquals(original, data.path("originalNickname").asText())
assertTrue(data.has("displayName"))
if (displayName == null) {
assertTrue(data.path("displayName").isNull)
} else {
assertEquals(displayName, data.path("displayName").asText())
}
}
private fun tokenFor(member: Member): String = tokenProvider.createToken(
UsernamePasswordAuthenticationToken(member.email, "", listOf(SimpleGrantedAuthority("ROLE_${member.role.name}"))),
requireNotNull(member.id)
)
private fun saveMember(role: MemberRole, kind: MemberKind = MemberKind.HUMAN): Member = memberRepository.saveAndFlush(
Member(
email = "${UUID.randomUUID()}@test.com",
password = "password",
nickname = "original",
role = role,
memberKind = kind
)
).also { memberIds.add(requireNotNull(it.id)) }
private fun rowId(creatorId: Long): Long = requireNotNull(
jdbcTemplate.queryForObject(
"select id from creator_localized_name where member_id = ? and lang = 'JA'",
Long::class.java,
creatorId
)
)
private fun url(creatorId: Long): String = "http://127.0.0.1:$port/api/v2/admin/creators/$creatorId/localized-names/ja"
private fun memberListUrl(): String = "http://127.0.0.1:$port/admin/member/creator/list?page=0&size=20"
}
@@ -0,0 +1,148 @@
package kr.co.vividnext.sodalive.v2.creatorname.adapter.out.persistence
import kr.co.vividnext.sodalive.configs.QueryDslConfig
import kr.co.vividnext.sodalive.i18n.Lang
import kr.co.vividnext.sodalive.member.Member
import kr.co.vividnext.sodalive.member.MemberRole
import kr.co.vividnext.sodalive.support.LegacyCreatorNameTestConfiguration
import kr.co.vividnext.sodalive.v2.creatorname.domain.CreatorLocalizedName
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertFalse
import org.junit.jupiter.api.Assertions.assertNotNull
import org.junit.jupiter.api.Assertions.assertNull
import org.junit.jupiter.api.Assertions.assertThrows
import org.junit.jupiter.api.DisplayName
import org.junit.jupiter.api.Test
import org.springframework.beans.factory.annotation.Autowired
import org.springframework.boot.test.autoconfigure.jdbc.AutoConfigureTestDatabase
import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest
import org.springframework.context.annotation.Import
import org.springframework.jdbc.core.JdbcTemplate
import java.util.UUID
import javax.persistence.EntityManager
import javax.persistence.PersistenceException
@DataJpaTest(properties = ["spring.cache.type=none"])
@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE)
@Import(QueryDslConfig::class, LegacyCreatorNameTestConfiguration::class)
class CreatorLocalizedNamePersistenceTest @Autowired constructor(
private val jdbcTemplate: JdbcTemplate,
private val entityManager: EntityManager,
private val repository: DefaultCreatorLocalizedNameRepository
) {
@Test
@DisplayName("별칭 저장 테이블은 표시 이름과 Lang 컬럼 길이를 승인 스키마와 일치시킨다")
fun shouldMapApprovedStorageLengths() {
assertEquals(
1L,
jdbcTemplate.queryForObject(
"select count(*) from INFORMATION_SCHEMA.TABLES where TABLE_NAME = 'creator_localized_name'",
Long::class.java
),
"Creator localized-name persistence must exist"
)
assertEquals(255L, columnLength("display_name"))
assertEquals(10L, columnLength("lang"))
}
@Test
@DisplayName("비활성 저장·재저장 반복도 동일 ID와 값·생성 시각을 보존하고 원본과 다른 언어는 변경하지 않는다")
fun shouldPersistSoftLifecycleOnSameRow() {
val memberId = saveMember()
val english = repository.save(CreatorLocalizedName(memberId, Lang.EN, "English"))
var saved = repository.save(CreatorLocalizedName(memberId, Lang.JA, "first"))
flushAndClear()
val id = requireNotNull(saved.id)
val createdAt = entityManager.find(CreatorLocalizedNameEntity::class.java, id).createdAt
assertNotNull(createdAt)
repeat(3) { index ->
repository.save(saved.copy(isActive = false))
flushAndClear()
val inactive = requireNotNull(repository.findByMemberIdAndLang(memberId, Lang.JA))
assertFalse(inactive.isActive)
assertEquals(saved.displayName, inactive.displayName)
assertEquals(id, inactive.id)
assertEquals(emptyMap<Long, String>(), repository.findActiveLocalizedNames(listOf(memberId), Lang.JA))
saved = repository.save(inactive.copy(displayName = "next-$index", isActive = true))
flushAndClear()
assertEquals(id, saved.id)
assertEquals(mapOf(memberId to "next-$index"), repository.findActiveLocalizedNames(listOf(memberId), Lang.JA))
}
assertEquals(english, repository.findByMemberIdAndLang(memberId, Lang.EN))
assertEquals(
2L,
jdbcTemplate.queryForObject(
"select count(*) from creator_localized_name where member_id = ?",
Long::class.java,
memberId
)
)
val entity = entityManager.find(CreatorLocalizedNameEntity::class.java, id)
assertEquals(createdAt, entity.createdAt)
assertNotNull(entity.updatedAt)
assertEquals(
"JA",
jdbcTemplate.queryForObject("select lang from creator_localized_name where id = ?", String::class.java, id)
)
assertEquals("original", entityManager.find(Member::class.java, memberId).nickname)
assertNull(repository.findByMemberIdAndLang(memberId, Lang.KO))
}
@Test
@DisplayName("동일 표시 이름은 서로 다른 크리에이터에 중복 저장할 수 있다")
fun shouldAllowDuplicateDisplayNamesAcrossMembers() {
val first = saveMember()
val second = saveMember()
repository.save(CreatorLocalizedName(first, Lang.JA, "同じ名前"))
repository.save(CreatorLocalizedName(second, Lang.JA, "同じ名前"))
flushAndClear()
assertEquals(
mapOf(first to "同じ名前", second to "同じ名前"),
repository.findActiveLocalizedNames(listOf(first, second), Lang.JA)
)
}
@Test
@DisplayName("member+lang 고유 제약은 비활성 행과 활성 행 사이의 중복도 거부한다")
fun shouldEnforceUniqueMemberAndLangAcrossActiveStates() {
val memberId = saveMember()
repository.save(CreatorLocalizedName(memberId, Lang.JA, "inactive", false))
flushAndClear()
assertThrows(PersistenceException::class.java) {
repository.save(CreatorLocalizedName(memberId, Lang.JA, "duplicate", true))
entityManager.flush()
}
}
@Test
@DisplayName("회원 외래키는 없는 회원의 별칭 저장을 거부한다")
fun shouldEnforceMemberForeignKey() {
assertThrows(PersistenceException::class.java) {
repository.save(CreatorLocalizedName(Long.MAX_VALUE, Lang.JA, "orphan"))
entityManager.flush()
}
}
private fun saveMember(): Long {
val member = Member(
email = "${UUID.randomUUID()}@test.com",
password = "password",
nickname = "original",
role = MemberRole.CREATOR
)
entityManager.persist(member)
return requireNotNull(member.id)
}
private fun flushAndClear() {
entityManager.flush()
entityManager.clear()
}
private fun columnLength(column: String): Long? = jdbcTemplate.queryForObject(
"select CHARACTER_MAXIMUM_LENGTH from INFORMATION_SCHEMA.COLUMNS " +
"where TABLE_NAME = 'creator_localized_name' and COLUMN_NAME = ?",
Long::class.java,
column
)
}
@@ -0,0 +1,151 @@
package kr.co.vividnext.sodalive.v2.creatorname.adapter.out.persistence
import com.querydsl.jpa.impl.JPAQueryFactory
import kr.co.vividnext.sodalive.configs.QueryDslConfig
import kr.co.vividnext.sodalive.i18n.Lang
import kr.co.vividnext.sodalive.member.Member
import kr.co.vividnext.sodalive.member.MemberRole
import kr.co.vividnext.sodalive.member.QMember
import kr.co.vividnext.sodalive.support.LegacyCreatorNameTestConfiguration
import kr.co.vividnext.sodalive.v2.creatorname.domain.CreatorLocalizedName
import org.hibernate.SessionFactory
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.DisplayName
import org.junit.jupiter.api.Test
import org.junit.jupiter.params.ParameterizedTest
import org.junit.jupiter.params.provider.ValueSource
import org.springframework.beans.factory.annotation.Autowired
import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest
import org.springframework.context.annotation.Import
import javax.persistence.EntityManager
@DataJpaTest(properties = ["spring.cache.type=none", "spring.jpa.properties.hibernate.generate_statistics=true"])
@Import(QueryDslConfig::class, LegacyCreatorNameTestConfiguration::class)
class CreatorNameJoinTest @Autowired constructor(
private val entityManager: EntityManager,
private val queryFactory: JPAQueryFactory,
private val repository: DefaultCreatorLocalizedNameRepository
) {
@ParameterizedTest
@ValueSource(strings = ["하늘", "さくら", "Alice"])
@DisplayName("직접 LEFT JOIN은 원본 언어와 무관하게 요청 Lang 별칭과 원본 fallback을 한 SELECT로 반환하며 페이지 순서를 유지한다")
fun shouldProjectNamesWithoutDroppingFallbackRowsOrDuplicatingPages(originalNickname: String) {
val creator = saveMember(originalNickname)
val user = saveMember(originalNickname, MemberRole.USER)
val inactiveAlias = saveMember(originalNickname)
val fallback = saveMember(originalNickname)
val deletedUser = saveMember(originalNickname, MemberRole.USER).apply { isActive = false }
val aliases = mapOf(Lang.KO to "한국어 별칭", Lang.JA to "別名", Lang.EN to "English alias")
aliases.forEach { (aliasLang, displayName) ->
repository.save(CreatorLocalizedName(creator.id!!, aliasLang, displayName))
repository.save(CreatorLocalizedName(user.id!!, aliasLang, "must not replace USER"))
repository.save(CreatorLocalizedName(inactiveAlias.id!!, aliasLang, "inactive alias", false))
repository.save(CreatorLocalizedName(deletedUser.id!!, aliasLang, "must not replace inactive USER"))
}
entityManager.flush()
entityManager.clear()
val owner = QMember("displayOwner")
val ids = listOf(creator.id!!, user.id!!, inactiveAlias.id!!, fallback.id!!, deletedUser.id!!, Long.MAX_VALUE)
val statistics = entityManager.entityManagerFactory.unwrap(SessionFactory::class.java).statistics
Lang.values().forEach { lang ->
val join = CreatorNameJoin(owner, lang, "displayOwnerName")
val query = queryFactory.select(owner.id, join.displayName)
.from(owner)
.leftJoin(join.name).on(join.condition)
.where(owner.id.`in`(ids))
.orderBy(owner.id.desc())
statistics.clear()
val rows = query.fetch().map { row -> row[owner.id] to row[join.displayName] }
assertEquals(
listOf(
deletedUser.id to originalNickname,
fallback.id to originalNickname,
inactiveAlias.id to originalNickname,
user.id to originalNickname,
creator.id to aliases.getValue(lang)
),
rows
)
assertEquals(1L, statistics.prepareStatementCount)
assertEquals(0L, statistics.entityLoadCount)
statistics.clear()
val page = query.clone().offset(1).limit(2).fetch().map { row -> row[owner.id] to row[join.displayName] }
assertEquals(listOf(fallback.id to originalNickname, inactiveAlias.id to originalNickname), page)
assertEquals(1L, statistics.prepareStatementCount)
assertEquals(0L, statistics.entityLoadCount)
}
}
@Test
@DisplayName("하나의 쿼리에서 서로 다른 owner와 이름 alias를 독립적으로 조인한다")
fun shouldJoinSeparateOwnerAliasesInOneQuery() {
val sender = saveMember("join-sender")
val recipient = saveMember("join-recipient")
repository.save(CreatorLocalizedName(sender.id!!, Lang.EN, "English sender"))
repository.save(CreatorLocalizedName(recipient.id!!, Lang.EN, "English recipient"))
entityManager.flush()
entityManager.clear()
val senderOwner = QMember("senderOwner")
val recipientOwner = QMember("recipientOwner")
val senderName = CreatorNameJoin(senderOwner, Lang.EN, "senderName")
val recipientName = CreatorNameJoin(recipientOwner, Lang.EN, "recipientName")
val statistics = entityManager.entityManagerFactory.unwrap(SessionFactory::class.java).statistics
statistics.clear()
val row = requireNotNull(
queryFactory.select(senderOwner.id, senderName.displayName, recipientOwner.id, recipientName.displayName)
.from(senderOwner)
.innerJoin(recipientOwner).on(recipientOwner.id.eq(recipient.id))
.leftJoin(senderName.name).on(senderName.condition)
.leftJoin(recipientName.name).on(recipientName.condition)
.where(senderOwner.id.eq(sender.id))
.fetchOne()
)
assertEquals(sender.id, row[senderOwner.id])
assertEquals("English sender", row[senderName.displayName])
assertEquals(recipient.id, row[recipientOwner.id])
assertEquals("English recipient", row[recipientName.displayName])
assertEquals(1L, statistics.prepareStatementCount)
assertEquals(0L, statistics.entityLoadCount)
}
@ParameterizedTest
@ValueSource(strings = ["하늘", "さくら", "Alice"])
@DisplayName("sparse 저장소 읽기는 원본 언어와 무관하게 요청 Lang의 활성 현재 CREATOR만 한 SQL로 반환한다")
fun shouldReadSparseActiveCreatorOverridesForEveryLanguage(originalNickname: String) {
val creator = saveMember(originalNickname)
val user = saveMember(originalNickname)
val inactiveAlias = saveMember(originalNickname)
val fallback = saveMember(originalNickname)
val aliases = mapOf(Lang.KO to "한국어 별칭", Lang.JA to "別名", Lang.EN to "English alias")
aliases.forEach { (aliasLang, displayName) ->
repository.save(CreatorLocalizedName(creator.id!!, aliasLang, displayName))
repository.save(CreatorLocalizedName(user.id!!, aliasLang, "must not replace former creator"))
repository.save(CreatorLocalizedName(inactiveAlias.id!!, aliasLang, "inactive", false))
}
user.role = MemberRole.USER
entityManager.flush()
entityManager.clear()
val statistics = entityManager.entityManagerFactory.unwrap(SessionFactory::class.java).statistics
Lang.values().forEach { lang ->
statistics.clear()
assertEquals(
mapOf(creator.id to aliases.getValue(lang)),
repository.findActiveLocalizedNames(
listOf(creator.id!!, user.id!!, inactiveAlias.id!!, fallback.id!!, Long.MAX_VALUE, creator.id!!),
lang
)
)
assertEquals(1L, statistics.prepareStatementCount)
assertEquals(0L, statistics.entityLoadCount)
}
}
private fun saveMember(nickname: String, role: MemberRole = MemberRole.CREATOR): Member =
Member(email = "$nickname@test.com", password = "password", nickname = nickname, role = role)
.also { entityManager.persist(it) }
}
@@ -0,0 +1,404 @@
package kr.co.vividnext.sodalive.v2.creatorname.application
import kr.co.vividnext.sodalive.common.SodaException
import kr.co.vividnext.sodalive.i18n.Lang
import kr.co.vividnext.sodalive.member.Member
import kr.co.vividnext.sodalive.member.MemberKind
import kr.co.vividnext.sodalive.member.MemberRepository
import kr.co.vividnext.sodalive.member.MemberRole
import kr.co.vividnext.sodalive.support.EmbeddedRedisInitializer
import kr.co.vividnext.sodalive.v2.creatorname.domain.CreatorLocalizedName
import kr.co.vividnext.sodalive.v2.creatorname.port.out.CreatorLocalizedNameRepository
import kr.co.vividnext.sodalive.v2.creatorname.support.CreatorLocalizedNameH2Schema
import org.hibernate.SessionFactory
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertFalse
import org.junit.jupiter.api.Assertions.assertNull
import org.junit.jupiter.api.Assertions.assertThrows
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.DisplayName
import org.junit.jupiter.api.Test
import org.junit.jupiter.params.ParameterizedTest
import org.junit.jupiter.params.provider.ValueSource
import org.springframework.beans.factory.annotation.Autowired
import org.springframework.boot.test.context.SpringBootTest
import org.springframework.jdbc.core.JdbcTemplate
import org.springframework.test.context.ContextConfiguration
import org.springframework.transaction.annotation.Propagation
import org.springframework.transaction.annotation.Transactional
import java.util.UUID
import java.util.concurrent.CountDownLatch
import java.util.concurrent.Executors
import java.util.concurrent.TimeUnit
import javax.persistence.EntityManager
@SpringBootTest
@ContextConfiguration(initializers = [EmbeddedRedisInitializer::class])
@Transactional
class CreatorLocalizedNameServiceTest @Autowired constructor(
private val service: CreatorLocalizedNameService,
private val repository: CreatorLocalizedNameRepository,
private val memberRepository: MemberRepository,
private val entityManager: EntityManager,
private val jdbcTemplate: JdbcTemplate
) {
@BeforeEach
fun prepareCodePointStorage() {
CreatorLocalizedNameH2Schema.prepare(jdbcTemplate)
}
@Test
@DisplayName("공유 서비스는 명시 Lang과 빈 회원 ID로 빈 별칭 map을 반환한다")
fun shouldReturnEmptyMapForEmptyMemberIds() {
val statistics = entityManager.entityManagerFactory.unwrap(SessionFactory::class.java).statistics
statistics.isStatisticsEnabled = true
statistics.clear()
try {
assertEquals(emptyMap<Long, Map<String, String>>(), service.findAllLocalizedNames(emptyList()))
assertEquals(emptyMap<Long, Map<String, String>>(), repository.findAllActiveLocalizedNames(emptyList()))
Lang.values().forEach { lang ->
assertEquals(emptyMap<Long, String>(), service.findLocalizedNames(emptyList(), lang))
assertEquals(emptyMap<Long, String>(), repository.findActiveLocalizedNames(emptyList(), lang))
assertEquals(emptyMap<Long, String>(), service.findCurrentNames(emptyList(), lang))
assertEquals(emptyMap<Long, String>(), repository.findCurrentNames(emptyList(), lang))
}
assertEquals(0L, statistics.prepareStatementCount)
assertEquals(0L, statistics.entityLoadCount)
} finally {
statistics.isStatisticsEnabled = false
}
}
@Test
@DisplayName("관리자 batch는 모든 활성 언어를 원문으로 한 SQL에 조회하고 USER·해제 이름과 원본 fallback은 제외한다")
fun shouldFindAllActiveCreatorLanguagesInOneScalarBatchQuery() {
val creatorId = saveMember()
val aiCreatorId = saveMember(kind = MemberKind.AI_CHARACTER)
val inactiveAliasId = saveMember()
val missingId = saveMember()
val userId = saveMember(MemberRole.USER)
val aiUserId = saveMember(MemberRole.USER, MemberKind.AI_CHARACTER)
memberRepository.findById(aiCreatorId).get().isActive = false
repository.save(CreatorLocalizedName(creatorId, Lang.JA, " 日本 Name 😀 "))
repository.save(CreatorLocalizedName(creatorId, Lang.EN, "English"))
repository.save(CreatorLocalizedName(creatorId, Lang.KO, "해제됨", false))
repository.save(CreatorLocalizedName(aiCreatorId, Lang.JA, "AI 日本"))
repository.save(CreatorLocalizedName(aiCreatorId, Lang.KO, "AI 한국어"))
repository.save(CreatorLocalizedName(inactiveAliasId, Lang.JA, "inactive", false))
repository.save(CreatorLocalizedName(userId, Lang.JA, "must not expose USER"))
repository.save(CreatorLocalizedName(aiUserId, Lang.EN, "must not expose AI USER"))
entityManager.flush()
entityManager.clear()
val statistics = entityManager.entityManagerFactory.unwrap(SessionFactory::class.java).statistics
statistics.isStatisticsEnabled = true
val ids = listOf(creatorId, aiCreatorId, inactiveAliasId, missingId, userId, aiUserId, Long.MAX_VALUE, creatorId)
val expected = mapOf(
creatorId to mapOf("ja" to " 日本 Name 😀 ", "en" to "English"),
aiCreatorId to mapOf("ja" to "AI 日本", "ko" to "AI 한국어")
)
try {
statistics.clear()
assertEquals(expected, service.findAllLocalizedNames(ids))
assertEquals(1L, statistics.prepareStatementCount)
assertEquals(0L, statistics.entityLoadCount)
statistics.clear()
assertEquals(expected, repository.findAllActiveLocalizedNames(ids))
assertEquals(1L, statistics.prepareStatementCount)
assertEquals(0L, statistics.entityLoadCount)
} finally {
statistics.isStatisticsEnabled = false
}
}
@Test
@DisplayName("JA 배치 조회는 활성 CREATOR만 포함하고 AI kind로 USER를 허용하지 않으며 한 번만 조회한다")
fun shouldFindOnlyActiveJapaneseCreatorAliasesInOneBatchQuery() {
val creatorId = saveMember()
val aiCreatorId = saveMember(kind = MemberKind.AI_CHARACTER)
val inactiveId = saveMember()
val missingId = saveMember()
val userId = saveMember(MemberRole.USER)
val aiUserId = saveMember(MemberRole.USER, MemberKind.AI_CHARACTER)
val otherLanguageId = saveMember()
repository.save(CreatorLocalizedName(creatorId, Lang.JA, "同じ名前"))
repository.save(CreatorLocalizedName(aiCreatorId, Lang.JA, "同じ名前"))
repository.save(CreatorLocalizedName(inactiveId, Lang.JA, "inactive", false))
repository.save(CreatorLocalizedName(userId, Lang.JA, "user"))
repository.save(CreatorLocalizedName(aiUserId, Lang.JA, "ai-user"))
repository.save(CreatorLocalizedName(otherLanguageId, Lang.EN, "english"))
entityManager.flush()
entityManager.clear()
val statistics = entityManager.entityManagerFactory.unwrap(SessionFactory::class.java).statistics
statistics.isStatisticsEnabled = true
statistics.clear()
try {
val ids = listOf(
creatorId, aiCreatorId, inactiveId, missingId, userId, aiUserId, otherLanguageId, Long.MAX_VALUE, creatorId
)
assertEquals(mapOf(creatorId to "同じ名前", aiCreatorId to "同じ名前"), service.findLocalizedNames(ids, Lang.JA))
assertEquals(1L, statistics.prepareStatementCount)
assertEquals(0L, statistics.entityLoadCount)
statistics.clear()
assertEquals(emptyMap<Long, String>(), service.findLocalizedNames(ids, Lang.KO))
assertEquals(1L, statistics.prepareStatementCount)
statistics.clear()
assertEquals(mapOf(otherLanguageId to "english"), service.findLocalizedNames(ids, Lang.EN))
assertEquals(1L, statistics.prepareStatementCount)
assertEquals(0L, statistics.entityLoadCount)
} finally {
statistics.isStatisticsEnabled = false
}
}
@ParameterizedTest
@ValueSource(strings = ["하늘", "さくら", "Alice"])
@DisplayName("현재 이름 batch는 원본 언어와 무관하게 요청 Lang 별칭을 한 SQL로 반환하고 USER·미등록·비활성은 원본이다")
fun shouldFindCurrentNamesWithOneScalarQueryForEveryLanguage(originalNickname: String) {
val creatorId = saveMember(nickname = originalNickname)
val fallbackId = saveMember(nickname = originalNickname)
val inactiveAliasId = saveMember(nickname = originalNickname)
val userId = saveMember(MemberRole.USER, nickname = originalNickname)
val deletedUserId = saveMember(MemberRole.USER, nickname = originalNickname)
memberRepository.findById(deletedUserId).get().apply {
isActive = false
nickname = "_deleted_$originalNickname"
}
val aliases = mapOf(Lang.KO to "한국어 별칭", Lang.JA to "別名", Lang.EN to "English alias")
aliases.forEach { (aliasLang, displayName) ->
repository.save(CreatorLocalizedName(creatorId, aliasLang, displayName))
repository.save(CreatorLocalizedName(inactiveAliasId, aliasLang, "inactive", false))
repository.save(CreatorLocalizedName(userId, aliasLang, "must not replace USER"))
repository.save(CreatorLocalizedName(deletedUserId, aliasLang, "must not replace inactive USER"))
}
entityManager.flush()
entityManager.clear()
val statistics = entityManager.entityManagerFactory.unwrap(SessionFactory::class.java).statistics
statistics.isStatisticsEnabled = true
val ids = listOf(creatorId, fallbackId, inactiveAliasId, userId, deletedUserId, Long.MAX_VALUE, creatorId)
try {
Lang.values().forEach { lang ->
val expectedCreatorName = aliases.getValue(lang)
val expected = mapOf(
creatorId to expectedCreatorName,
fallbackId to originalNickname,
inactiveAliasId to originalNickname,
userId to originalNickname,
deletedUserId to "_deleted_$originalNickname"
)
statistics.clear()
assertEquals(mapOf(creatorId to expectedCreatorName), service.findCurrentNames(listOf(creatorId), lang))
assertEquals(1L, statistics.prepareStatementCount)
assertEquals(0L, statistics.entityLoadCount)
statistics.clear()
assertEquals(expected, service.findCurrentNames(ids, lang))
assertEquals(1L, statistics.prepareStatementCount)
assertEquals(0L, statistics.entityLoadCount)
statistics.clear()
assertEquals(expected, repository.findCurrentNames(ids, lang))
assertEquals(1L, statistics.prepareStatementCount)
assertEquals(0L, statistics.entityLoadCount)
}
} finally {
statistics.isStatisticsEnabled = false
}
}
@Test
@DisplayName("입력은 원문 그대로 저장하고 원본 닉네임 변경 후에도 별칭을 유지하며 해제하면 현재 원본만 노출한다")
fun shouldPreserveAliasAcrossOriginalNicknameChanges() {
val creatorId = saveMember(nickname = "さくら")
val rawName = " Name 日本 😀 ! "
service.saveLocalizedName(creatorId, Lang.JA, rawName)
memberRepository.findById(creatorId).get().nickname = "Alice"
entityManager.flush()
entityManager.clear()
val active = requireNotNull(repository.findByMemberIdAndLang(creatorId, Lang.JA))
assertTrue(active.isActive)
assertEquals("Alice", memberRepository.findById(creatorId).get().nickname)
assertEquals(rawName, active.displayName)
assertEquals(mapOf(creatorId to rawName), service.findLocalizedNames(listOf(creatorId), Lang.JA))
assertEquals(mapOf(creatorId to mapOf("ja" to rawName)), service.findAllLocalizedNames(listOf(creatorId)))
Lang.values().forEach { lang ->
val expected = if (lang == Lang.JA) rawName else "Alice"
assertEquals(mapOf(creatorId to expected), service.findCurrentNames(listOf(creatorId), lang))
}
val cleared = service.saveLocalizedName(creatorId, Lang.JA, "")
assertEquals("Alice", cleared.originalNickname)
assertNull(cleared.displayName)
assertEquals(emptyMap<Long, String>(), service.findLocalizedNames(listOf(creatorId), Lang.JA))
assertEquals(emptyMap<Long, Map<String, String>>(), service.findAllLocalizedNames(listOf(creatorId)))
entityManager.flush()
entityManager.clear()
val inactive = requireNotNull(repository.findByMemberIdAndLang(creatorId, Lang.JA))
assertEquals(active.id, inactive.id)
assertEquals(rawName, inactive.displayName)
assertFalse(inactive.isActive)
Lang.values().forEach { lang ->
assertEquals(mapOf(creatorId to "Alice"), service.findCurrentNames(listOf(creatorId), lang))
}
assertEquals("Alice", memberRepository.findById(creatorId).get().nickname)
}
@Test
@DisplayName("save·clear 반복은 동일 행 ID와 마지막 값을 보존하고 다른 언어 행은 변경하지 않는다")
fun shouldSoftClearAndReactivateSameRowWithoutChangingOtherLanguages() {
val creatorId = saveMember()
val english = repository.save(CreatorLocalizedName(creatorId, Lang.EN, "english"))
service.saveLocalizedName(creatorId, Lang.JA, "first")
val id = requireNotNull(repository.findByMemberIdAndLang(creatorId, Lang.JA)).id
listOf("second", "third", "fourth").forEach { name ->
service.saveLocalizedName(creatorId, Lang.JA, " \t\n")
entityManager.flush()
entityManager.clear()
val cleared = requireNotNull(repository.findByMemberIdAndLang(creatorId, Lang.JA))
assertFalse(cleared.isActive)
assertEquals(id, cleared.id)
assertTrue(cleared.displayName.isNotBlank())
service.saveLocalizedName(creatorId, Lang.JA, name)
entityManager.flush()
entityManager.clear()
val active = requireNotNull(repository.findByMemberIdAndLang(creatorId, Lang.JA))
assertEquals(id, active.id)
assertEquals(name, active.displayName)
assertTrue(active.isActive)
}
assertEquals(english, repository.findByMemberIdAndLang(creatorId, Lang.EN))
assertEquals(
2L,
jdbcTemplate.queryForObject(
"select count(*) from creator_localized_name where member_id = ?",
Long::class.java,
creatorId
)
)
assertEquals("original", memberRepository.findById(creatorId).get().nickname)
}
@Test
@DisplayName("없는 별칭을 빈 값 또는 공백으로 해제해도 행을 생성하지 않는다")
fun shouldNotInsertWhenClearingAbsentAlias() {
val creatorId = saveMember()
listOf("", " \t\n").forEach { blank ->
assertNull(service.saveLocalizedName(creatorId, Lang.JA, blank).displayName)
assertNull(repository.findByMemberIdAndLang(creatorId, Lang.JA))
}
assertEquals(
0L,
jdbcTemplate.queryForObject(
"select count(*) from creator_localized_name where member_id = ?",
Long::class.java,
creatorId
)
)
}
@Test
@DisplayName("255 code point BMP·이모지는 저장하고 256 code point 입력은 기존 행과 원본을 변경하지 않는다")
fun shouldValidateUnicodeCodePointsBeforeMutation() {
val creatorId = saveMember()
listOf("名".repeat(255), "😀".repeat(255)).forEach { accepted ->
assertEquals(accepted, service.saveLocalizedName(creatorId, Lang.JA, accepted).displayName)
entityManager.flush()
entityManager.clear()
assertEquals(accepted, repository.findByMemberIdAndLang(creatorId, Lang.JA)?.displayName)
}
val before = repository.findByMemberIdAndLang(creatorId, Lang.JA)
listOf("名".repeat(256), "😀".repeat(256), " ".repeat(256)).forEach { rejected ->
val error = assertThrows(SodaException::class.java) { service.saveLocalizedName(creatorId, Lang.JA, rejected) }
assertEquals("displayName", error.errorProperty)
assertEquals(before, repository.findByMemberIdAndLang(creatorId, Lang.JA))
}
assertEquals("original", memberRepository.findById(creatorId).get().nickname)
}
@Test
@DisplayName("CREATOR의 AI kind는 허용하지만 그 외 역할·USER AI·누락 대상·KO/EN 쓰기는 거부한다")
fun shouldRejectInvalidTargetsAndNonJapaneseWrites() {
val aiCreatorId = saveMember(kind = MemberKind.AI_CHARACTER)
assertEquals("AI", service.saveLocalizedName(aiCreatorId, Lang.JA, "AI").displayName)
MemberRole.values().filter { it != MemberRole.CREATOR }.forEach { role ->
val memberId = saveMember(role)
assertThrows(SodaException::class.java) { service.saveLocalizedName(memberId, Lang.JA, "denied") }
assertNull(repository.findByMemberIdAndLang(memberId, Lang.JA))
}
val aiUserId = saveMember(MemberRole.USER, MemberKind.AI_CHARACTER)
assertThrows(SodaException::class.java) { service.saveLocalizedName(aiUserId, Lang.JA, "denied") }
assertNull(repository.findByMemberIdAndLang(aiUserId, Lang.JA))
assertThrows(SodaException::class.java) { service.saveLocalizedName(Long.MAX_VALUE, Lang.JA, "name") }
listOf(Lang.KO, Lang.EN).forEach { lang ->
assertThrows(SodaException::class.java) { service.saveLocalizedName(aiCreatorId, lang, "denied") }
assertNull(repository.findByMemberIdAndLang(aiCreatorId, lang))
}
}
@Test
@Transactional(propagation = Propagation.NOT_SUPPORTED)
@DisplayName("동시 최초 저장은 회원 행 잠금으로 직렬화되어 하나의 별칭 행만 생성한다")
fun shouldSerializeConcurrentInitialSaves() {
val creatorId = saveMember()
val executor = Executors.newFixedThreadPool(4)
val ready = CountDownLatch(4)
val start = CountDownLatch(1)
try {
val names = listOf("first", "second", "third", "fourth")
val futures = names.map { name ->
executor.submit<String?> {
ready.countDown()
check(start.await(10, TimeUnit.SECONDS))
service.saveLocalizedName(creatorId, Lang.JA, name).displayName
}
}
assertTrue(ready.await(10, TimeUnit.SECONDS))
start.countDown()
assertEquals(names, futures.map { it.get(10, TimeUnit.SECONDS) })
assertEquals(
1L,
jdbcTemplate.queryForObject(
"select count(*) from creator_localized_name where member_id = ?",
Long::class.java,
creatorId
)
)
val saved = requireNotNull(repository.findByMemberIdAndLang(creatorId, Lang.JA))
assertTrue(saved.isActive)
assertTrue(names.contains(saved.displayName))
assertEquals("original", memberRepository.findById(creatorId).get().nickname)
} finally {
start.countDown()
executor.shutdownNow()
assertTrue(executor.awaitTermination(10, TimeUnit.SECONDS))
jdbcTemplate.update("delete from creator_localized_name where member_id = ?", creatorId)
memberRepository.deleteById(creatorId)
}
}
@Test
@DisplayName("테스트 H2 저장 제약도 256 code point를 거부해 MySQL 저장 한도를 약화하지 않는다")
fun shouldKeepCodePointStorageLimitInH2Fixture() {
val creatorId = saveMember()
assertThrows(org.springframework.dao.DataIntegrityViolationException::class.java) {
jdbcTemplate.update(
"insert into creator_localized_name (member_id, lang, display_name, is_active) values (?, 'JA', ?, true)",
creatorId,
"😀".repeat(256)
)
}
}
private fun saveMember(
role: MemberRole = MemberRole.CREATOR,
kind: MemberKind = MemberKind.HUMAN,
nickname: String = "original"
): Long = requireNotNull(
memberRepository.saveAndFlush(
Member(
email = "${UUID.randomUUID()}@test.com",
password = "password",
nickname = nickname,
role = role,
memberKind = kind
)
).id
)
}
@@ -0,0 +1,20 @@
package kr.co.vividnext.sodalive.v2.creatorname.support
import org.springframework.jdbc.core.JdbcTemplate
object CreatorLocalizedNameH2Schema {
fun prepare(jdbcTemplate: JdbcTemplate) {
jdbcTemplate.execute(
"CREATE ALIAS IF NOT EXISTS creator_name_code_point_count FOR " +
"'kr.co.vividnext.sodalive.v2.creatorname.support.CreatorLocalizedNameH2Schema.codePointCount'"
)
jdbcTemplate.execute("ALTER TABLE creator_localized_name ALTER COLUMN display_name VARCHAR(510) NOT NULL")
jdbcTemplate.execute(
"ALTER TABLE creator_localized_name ADD CONSTRAINT IF NOT EXISTS ck_creator_name_code_points " +
"CHECK (creator_name_code_point_count(display_name) <= 255)"
)
}
@JvmStatic
fun codePointCount(value: String): Int = value.codePointCount(0, value.length)
}