Test #457
@@ -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()
|
||||
)
|
||||
|
||||
+22
@@ -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)
|
||||
)
|
||||
}
|
||||
+48
@@ -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)
|
||||
}
|
||||
+16
@@ -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)
|
||||
}
|
||||
+22
@@ -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()
|
||||
)
|
||||
)
|
||||
}
|
||||
+88
@@ -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()
|
||||
}
|
||||
}
|
||||
+61
@@ -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
|
||||
}
|
||||
}
|
||||
+11
@@ -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
|
||||
)
|
||||
+3
@@ -0,0 +1,3 @@
|
||||
package kr.co.vividnext.sodalive.v2.creatorname.dto
|
||||
|
||||
data class CreatorLocalizedNameRequest(val displayName: String)
|
||||
+8
@@ -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?
|
||||
)
|
||||
+12
@@ -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() {
|
||||
|
||||
+17
@@ -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)
|
||||
+283
@@ -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"
|
||||
}
|
||||
+148
@@ -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
|
||||
)
|
||||
}
|
||||
+151
@@ -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) }
|
||||
}
|
||||
+404
@@ -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
|
||||
)
|
||||
}
|
||||
+20
@@ -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)
|
||||
}
|
||||
Reference in New Issue
Block a user