diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/admin/member/AdminMemberService.kt b/src/main/kotlin/kr/co/vividnext/sodalive/admin/member/AdminMemberService.kt index c112b9cd..d72fbe91 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/admin/member/AdminMemberService.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/admin/member/AdminMemberService.kt @@ -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 ): List { + 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() diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/admin/member/GetAdminMemberListResponse.kt b/src/main/kotlin/kr/co/vividnext/sodalive/admin/member/GetAdminMemberListResponse.kt index 5830eef6..4ad89e66 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/admin/member/GetAdminMemberListResponse.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/admin/member/GetAdminMemberListResponse.kt @@ -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 = emptyMap() ) diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/v2/creatorname/adapter/in/web/AdminCreatorLocalizedNameController.kt b/src/main/kotlin/kr/co/vividnext/sodalive/v2/creatorname/adapter/in/web/AdminCreatorLocalizedNameController.kt new file mode 100644 index 00000000..c0d2414b --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/v2/creatorname/adapter/in/web/AdminCreatorLocalizedNameController.kt @@ -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) + ) +} diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/v2/creatorname/adapter/out/persistence/CreatorLocalizedNameEntity.kt b/src/main/kotlin/kr/co/vividnext/sodalive/v2/creatorname/adapter/out/persistence/CreatorLocalizedNameEntity.kt new file mode 100644 index 00000000..ac5c7754 --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/v2/creatorname/adapter/out/persistence/CreatorLocalizedNameEntity.kt @@ -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) +} diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/v2/creatorname/adapter/out/persistence/CreatorNameJoin.kt b/src/main/kotlin/kr/co/vividnext/sodalive/v2/creatorname/adapter/out/persistence/CreatorNameJoin.kt new file mode 100644 index 00000000..b9cb6b85 --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/v2/creatorname/adapter/out/persistence/CreatorNameJoin.kt @@ -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) +} diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/v2/creatorname/adapter/out/persistence/CreatorNamePredicates.kt b/src/main/kotlin/kr/co/vividnext/sodalive/v2/creatorname/adapter/out/persistence/CreatorNamePredicates.kt new file mode 100644 index 00000000..3400c9de --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/v2/creatorname/adapter/out/persistence/CreatorNamePredicates.kt @@ -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() + ) + ) +} diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/v2/creatorname/adapter/out/persistence/DefaultCreatorLocalizedNameRepository.kt b/src/main/kotlin/kr/co/vividnext/sodalive/v2/creatorname/adapter/out/persistence/DefaultCreatorLocalizedNameRepository.kt new file mode 100644 index 00000000..18fb4827 --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/v2/creatorname/adapter/out/persistence/DefaultCreatorLocalizedNameRepository.kt @@ -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, lang: Lang): Map { + 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): Map> { + 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, lang: Lang): Map { + 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() + } +} diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/v2/creatorname/application/CreatorLocalizedNameService.kt b/src/main/kotlin/kr/co/vividnext/sodalive/v2/creatorname/application/CreatorLocalizedNameService.kt new file mode 100644 index 00000000..6450e51b --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/v2/creatorname/application/CreatorLocalizedNameService.kt @@ -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, lang: Lang): Map { + if (memberIds.isEmpty()) return emptyMap() + return repository.findActiveLocalizedNames(memberIds, lang) + } + + fun findCurrentNames(memberIds: Collection, lang: Lang): Map { + if (memberIds.isEmpty()) return emptyMap() + return repository.findCurrentNames(memberIds, lang) + } + + fun findAllLocalizedNames(memberIds: Collection): Map> { + 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 + } +} diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/v2/creatorname/domain/CreatorLocalizedName.kt b/src/main/kotlin/kr/co/vividnext/sodalive/v2/creatorname/domain/CreatorLocalizedName.kt new file mode 100644 index 00000000..c8ab7ed0 --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/v2/creatorname/domain/CreatorLocalizedName.kt @@ -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 +) diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/v2/creatorname/dto/CreatorLocalizedNameRequest.kt b/src/main/kotlin/kr/co/vividnext/sodalive/v2/creatorname/dto/CreatorLocalizedNameRequest.kt new file mode 100644 index 00000000..f341c7d7 --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/v2/creatorname/dto/CreatorLocalizedNameRequest.kt @@ -0,0 +1,3 @@ +package kr.co.vividnext.sodalive.v2.creatorname.dto + +data class CreatorLocalizedNameRequest(val displayName: String) diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/v2/creatorname/dto/CreatorLocalizedNameResponse.kt b/src/main/kotlin/kr/co/vividnext/sodalive/v2/creatorname/dto/CreatorLocalizedNameResponse.kt new file mode 100644 index 00000000..3230604e --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/v2/creatorname/dto/CreatorLocalizedNameResponse.kt @@ -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? +) diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/v2/creatorname/port/out/CreatorLocalizedNameRepository.kt b/src/main/kotlin/kr/co/vividnext/sodalive/v2/creatorname/port/out/CreatorLocalizedNameRepository.kt new file mode 100644 index 00000000..8f5273fb --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/v2/creatorname/port/out/CreatorLocalizedNameRepository.kt @@ -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, lang: Lang): Map + fun findAllActiveLocalizedNames(memberIds: Collection): Map> + fun findCurrentNames(memberIds: Collection, lang: Lang): Map + fun save(name: CreatorLocalizedName): CreatorLocalizedName +} diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/admin/member/AdminMemberServiceTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/admin/member/AdminMemberServiceTest.kt index 5b5b41cc..656c7f52 100644 --- a/src/test/kotlin/kr/co/vividnext/sodalive/admin/member/AdminMemberServiceTest.kt +++ b/src/test/kotlin/kr/co/vividnext/sodalive/admin/member/AdminMemberServiceTest.kt @@ -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(expectedNames[member.id] ?: emptyMap()), + 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() { diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/support/LegacyCreatorNameTestConfiguration.kt b/src/test/kotlin/kr/co/vividnext/sodalive/support/LegacyCreatorNameTestConfiguration.kt new file mode 100644 index 00000000..c50424b0 --- /dev/null +++ b/src/test/kotlin/kr/co/vividnext/sodalive/support/LegacyCreatorNameTestConfiguration.kt @@ -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) diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/v2/creatorname/adapter/in/web/AdminCreatorLocalizedNameControllerTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/v2/creatorname/adapter/in/web/AdminCreatorLocalizedNameControllerTest.kt new file mode 100644 index 00000000..f20d6384 --- /dev/null +++ b/src/test/kotlin/kr/co/vividnext/sodalive/v2/creatorname/adapter/in/web/AdminCreatorLocalizedNameControllerTest.kt @@ -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() + + @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) { + 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(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(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? = 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" +} diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/v2/creatorname/adapter/out/persistence/CreatorLocalizedNamePersistenceTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/v2/creatorname/adapter/out/persistence/CreatorLocalizedNamePersistenceTest.kt new file mode 100644 index 00000000..22d9b01e --- /dev/null +++ b/src/test/kotlin/kr/co/vividnext/sodalive/v2/creatorname/adapter/out/persistence/CreatorLocalizedNamePersistenceTest.kt @@ -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(), 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 + ) +} diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/v2/creatorname/adapter/out/persistence/CreatorNameJoinTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/v2/creatorname/adapter/out/persistence/CreatorNameJoinTest.kt new file mode 100644 index 00000000..7209fdde --- /dev/null +++ b/src/test/kotlin/kr/co/vividnext/sodalive/v2/creatorname/adapter/out/persistence/CreatorNameJoinTest.kt @@ -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) } +} diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/v2/creatorname/application/CreatorLocalizedNameServiceTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/v2/creatorname/application/CreatorLocalizedNameServiceTest.kt new file mode 100644 index 00000000..f171bebe --- /dev/null +++ b/src/test/kotlin/kr/co/vividnext/sodalive/v2/creatorname/application/CreatorLocalizedNameServiceTest.kt @@ -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>(), service.findAllLocalizedNames(emptyList())) + assertEquals(emptyMap>(), repository.findAllActiveLocalizedNames(emptyList())) + Lang.values().forEach { lang -> + assertEquals(emptyMap(), service.findLocalizedNames(emptyList(), lang)) + assertEquals(emptyMap(), repository.findActiveLocalizedNames(emptyList(), lang)) + assertEquals(emptyMap(), service.findCurrentNames(emptyList(), lang)) + assertEquals(emptyMap(), 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(), 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(), service.findLocalizedNames(listOf(creatorId), Lang.JA)) + assertEquals(emptyMap>(), 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 { + 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 + ) +} diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/v2/creatorname/support/CreatorLocalizedNameH2Schema.kt b/src/test/kotlin/kr/co/vividnext/sodalive/v2/creatorname/support/CreatorLocalizedNameH2Schema.kt new file mode 100644 index 00000000..752656a5 --- /dev/null +++ b/src/test/kotlin/kr/co/vividnext/sodalive/v2/creatorname/support/CreatorLocalizedNameH2Schema.kt @@ -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) +}