From 2506105c56de5976bd8d75470ca7b07d786f7ca7 Mon Sep 17 00:00:00 2001 From: pricelees Date: Sat, 27 Sep 2025 18:53:44 +0900 Subject: [PATCH] =?UTF-8?q?refactor:=20JacksonConfig=20=EB=AA=A8=EB=93=88?= =?UTF-8?q?=20=EB=B6=84=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- common/config/build.gradle.kts | 11 + .../sangdol}/common/config/JacksonConfig.kt | 9 +- .../common/config/JacksonConfigTest.kt | 14 +- service/build.gradle.kts | 4 +- .../roomescape/RoomescapeApplication.kt | 4 +- .../log/RoomescapeLogMaskingConverter.kt | 2 +- .../common/log/ApiLogMessageConverterTest.kt | 8 +- .../roomescape/data/DefaultDataInitializer.kt | 1822 ++++++++--------- .../roomescape/supports/RestAssuredUtils.kt | 9 +- 9 files changed, 952 insertions(+), 931 deletions(-) create mode 100644 common/config/build.gradle.kts rename {service/src/main/kotlin/com/sangdol/roomescape => common/config/src/main/kotlin/com/sangdol}/common/config/JacksonConfig.kt (92%) rename {service/src/test/kotlin/com/sangdol/roomescape => common/config/src/test/kotlin/com/sangdol}/common/config/JacksonConfigTest.kt (91%) diff --git a/common/config/build.gradle.kts b/common/config/build.gradle.kts new file mode 100644 index 00000000..e96cfeba --- /dev/null +++ b/common/config/build.gradle.kts @@ -0,0 +1,11 @@ +plugins { + id("org.springframework.boot") + kotlin("plugin.spring") +} + +dependencies { + implementation("org.springframework.boot:spring-boot-starter-web") + implementation("com.fasterxml.jackson.module:jackson-module-kotlin") + + testImplementation("io.kotest:kotest-runner-junit5:5.9.1") +} diff --git a/service/src/main/kotlin/com/sangdol/roomescape/common/config/JacksonConfig.kt b/common/config/src/main/kotlin/com/sangdol/common/config/JacksonConfig.kt similarity index 92% rename from service/src/main/kotlin/com/sangdol/roomescape/common/config/JacksonConfig.kt rename to common/config/src/main/kotlin/com/sangdol/common/config/JacksonConfig.kt index b5662a33..4dc73d80 100644 --- a/service/src/main/kotlin/com/sangdol/roomescape/common/config/JacksonConfig.kt +++ b/common/config/src/main/kotlin/com/sangdol/common/config/JacksonConfig.kt @@ -1,4 +1,4 @@ -package com.sangdol.roomescape.common.config +package com.sangdol.common.config import com.fasterxml.jackson.core.JsonGenerator import com.fasterxml.jackson.databind.DeserializationFeature @@ -23,6 +23,9 @@ class JacksonConfig { companion object { private val ISO_OFFSET_DATE_TIME_FORMATTER: DateTimeFormatter = DateTimeFormatter.ofPattern("yyyy-MM-dd'T'HH:mm:ssXXX") + + private val LOCAL_TIME_FORMATTER: DateTimeFormatter = + DateTimeFormatter.ofPattern("HH:mm") } @Bean @@ -43,11 +46,11 @@ class JacksonConfig { ) .addSerializer( LocalTime::class.java, - LocalTimeSerializer(DateTimeFormatter.ofPattern("HH:mm")) + LocalTimeSerializer(LOCAL_TIME_FORMATTER) ) .addDeserializer( LocalTime::class.java, - LocalTimeDeserializer(DateTimeFormatter.ofPattern("HH:mm")) + LocalTimeDeserializer(LOCAL_TIME_FORMATTER) ) as JavaTimeModule private fun dateTimeModule(): SimpleModule { diff --git a/service/src/test/kotlin/com/sangdol/roomescape/common/config/JacksonConfigTest.kt b/common/config/src/test/kotlin/com/sangdol/common/config/JacksonConfigTest.kt similarity index 91% rename from service/src/test/kotlin/com/sangdol/roomescape/common/config/JacksonConfigTest.kt rename to common/config/src/test/kotlin/com/sangdol/common/config/JacksonConfigTest.kt index 8ebb7557..b98b02fa 100644 --- a/service/src/test/kotlin/com/sangdol/roomescape/common/config/JacksonConfigTest.kt +++ b/common/config/src/test/kotlin/com/sangdol/common/config/JacksonConfigTest.kt @@ -1,4 +1,4 @@ -package com.sangdol.roomescape.common.config +package com.sangdol.common.config import com.fasterxml.jackson.databind.ObjectMapper import com.fasterxml.jackson.databind.exc.InvalidFormatException @@ -6,11 +6,15 @@ import io.kotest.assertions.throwables.shouldThrow import io.kotest.core.spec.style.FunSpec import io.kotest.matchers.shouldBe import io.kotest.matchers.string.shouldContain -import java.time.* +import java.time.LocalDate +import java.time.LocalDateTime +import java.time.LocalTime +import java.time.OffsetDateTime +import java.time.ZoneOffset -class JacksonConfigTest( - private val objectMapper: ObjectMapper = JacksonConfig().objectMapper() -) : FunSpec({ +class JacksonConfigTest : FunSpec({ + + val objectMapper: ObjectMapper = JacksonConfig().objectMapper() context("날짜는 yyyy-mm-dd 형식이다.") { val date = "2025-07-14" diff --git a/service/build.gradle.kts b/service/build.gradle.kts index db40bee3..2d95de4b 100644 --- a/service/build.gradle.kts +++ b/service/build.gradle.kts @@ -35,7 +35,6 @@ dependencies { // Kotlin implementation("org.jetbrains.kotlin:kotlin-reflect") - implementation("com.fasterxml.jackson.module:jackson-module-kotlin") implementation("io.github.oshai:kotlin-logging-jvm:7.0.3") // Test @@ -53,6 +52,9 @@ dependencies { // etc implementation("org.apache.poi:poi-ooxml:5.2.3") + + // submodules + implementation(project(":common:config")) } tasks.jar { diff --git a/service/src/main/kotlin/com/sangdol/roomescape/RoomescapeApplication.kt b/service/src/main/kotlin/com/sangdol/roomescape/RoomescapeApplication.kt index 0e420eda..c8b5682f 100644 --- a/service/src/main/kotlin/com/sangdol/roomescape/RoomescapeApplication.kt +++ b/service/src/main/kotlin/com/sangdol/roomescape/RoomescapeApplication.kt @@ -4,7 +4,9 @@ import org.springframework.boot.Banner import org.springframework.boot.SpringApplication import org.springframework.boot.autoconfigure.SpringBootApplication -@SpringBootApplication +@SpringBootApplication( + scanBasePackages = ["com.sangdol.roomescape", "com.sangdol.common"] +) class RoomescapeApplication fun main(args: Array) { diff --git a/service/src/main/kotlin/com/sangdol/roomescape/common/log/RoomescapeLogMaskingConverter.kt b/service/src/main/kotlin/com/sangdol/roomescape/common/log/RoomescapeLogMaskingConverter.kt index 714cb70b..70abcc43 100644 --- a/service/src/main/kotlin/com/sangdol/roomescape/common/log/RoomescapeLogMaskingConverter.kt +++ b/service/src/main/kotlin/com/sangdol/roomescape/common/log/RoomescapeLogMaskingConverter.kt @@ -7,7 +7,7 @@ import com.fasterxml.jackson.databind.ObjectMapper import com.fasterxml.jackson.databind.node.ArrayNode import com.fasterxml.jackson.databind.node.ObjectNode import com.fasterxml.jackson.databind.node.TextNode -import com.sangdol.roomescape.common.config.JacksonConfig +import com.sangdol.common.config.JacksonConfig private const val MASK: String = "****" private val SENSITIVE_KEYS = setOf("password", "accessToken", "phone") diff --git a/service/src/test/kotlin/com/sangdol/roomescape/common/log/ApiLogMessageConverterTest.kt b/service/src/test/kotlin/com/sangdol/roomescape/common/log/ApiLogMessageConverterTest.kt index c420d817..cc8eec20 100644 --- a/service/src/test/kotlin/com/sangdol/roomescape/common/log/ApiLogMessageConverterTest.kt +++ b/service/src/test/kotlin/com/sangdol/roomescape/common/log/ApiLogMessageConverterTest.kt @@ -1,17 +1,17 @@ package com.sangdol.roomescape.common.log -import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper +import com.sangdol.common.config.JacksonConfig +import com.sangdol.roomescape.auth.exception.AuthErrorCode +import com.sangdol.roomescape.auth.exception.AuthException import io.kotest.core.spec.style.StringSpec import io.kotest.matchers.shouldBe import io.mockk.every import io.mockk.mockk import jakarta.servlet.http.HttpServletRequest import org.slf4j.MDC -import com.sangdol.roomescape.auth.exception.AuthErrorCode -import com.sangdol.roomescape.auth.exception.AuthException class ApiLogMessageConverterTest : StringSpec({ - val converter = ApiLogMessageConverter(jacksonObjectMapper()) + val converter = ApiLogMessageConverter(JacksonConfig().objectMapper()) val request: HttpServletRequest = mockk() beforeTest { diff --git a/service/src/test/kotlin/com/sangdol/roomescape/data/DefaultDataInitializer.kt b/service/src/test/kotlin/com/sangdol/roomescape/data/DefaultDataInitializer.kt index 06785315..6dd76aa4 100644 --- a/service/src/test/kotlin/com/sangdol/roomescape/data/DefaultDataInitializer.kt +++ b/service/src/test/kotlin/com/sangdol/roomescape/data/DefaultDataInitializer.kt @@ -1,911 +1,911 @@ -package com.sangdol.roomescape.data - -import com.github.f4b6a3.tsid.TsidFactory -import io.kotest.core.test.TestCaseOrder -import jakarta.persistence.EntityManager -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.coroutineScope -import kotlinx.coroutines.joinAll -import kotlinx.coroutines.launch -import kotlinx.coroutines.sync.Semaphore -import org.springframework.beans.factory.annotation.Autowired -import org.springframework.jdbc.core.JdbcTemplate -import org.springframework.test.context.ActiveProfiles -import com.sangdol.roomescape.admin.infrastructure.persistence.AdminEntity -import com.sangdol.roomescape.admin.infrastructure.persistence.AdminPermissionLevel -import com.sangdol.roomescape.admin.infrastructure.persistence.AdminType -import com.sangdol.roomescape.common.config.next -import com.sangdol.roomescape.common.util.TransactionExecutionUtil -import com.sangdol.roomescape.payment.infrastructure.common.* -import com.sangdol.roomescape.reservation.infrastructure.persistence.ReservationStatus -import com.sangdol.roomescape.schedule.infrastructure.persistence.ScheduleStatus -import com.sangdol.roomescape.supports.AdminFixture -import com.sangdol.roomescape.supports.FunSpecSpringbootTest -import com.sangdol.roomescape.supports.randomPhoneNumber -import com.sangdol.roomescape.supports.randomString -import com.sangdol.roomescape.theme.infrastructure.persistence.Difficulty -import com.sangdol.roomescape.user.business.SIGNUP -import com.sangdol.roomescape.user.infrastructure.persistence.UserEntity -import com.sangdol.roomescape.user.infrastructure.persistence.UserStatus -import com.sangdol.roomescape.user.web.UserContactResponse -import java.sql.Timestamp -import java.time.LocalDateTime -import java.time.LocalTime -import java.time.OffsetDateTime - -@ActiveProfiles("test", "test-mysql") -abstract class AbstractDataInitializer( - val semaphore: Semaphore = Semaphore(permits = 10), -) : FunSpecSpringbootTest( - enableCleanerExtension = false -) { - @Autowired - lateinit var entityManager: EntityManager - - @Autowired - lateinit var jdbcTemplate: JdbcTemplate - - @Autowired - lateinit var transactionExecutionUtil: TransactionExecutionUtil - - @Autowired - lateinit var tsidFactory: TsidFactory - - override fun testCaseOrder(): TestCaseOrder? = TestCaseOrder.Sequential - - suspend fun initialize() { - transactionExecutionUtil.withNewTransaction(isReadOnly = false) { - jdbcTemplate.execute("SET FOREIGN_KEY_CHECKS = 0") - - jdbcTemplate.query("SHOW TABLES") { rs, _ -> - rs.getString(1).lowercase() - }.forEach { - jdbcTemplate.execute("TRUNCATE TABLE $it") - } - - jdbcTemplate.execute("SET FOREIGN_KEY_CHECKS = 1") - - this::class.java.getResource("/schema/region-data.sql")?.readText()?.let { sql -> - jdbcTemplate.execute(sql) - } - } - } - - suspend fun executeBatch(sql: String, batchArgs: List>) { - semaphore.acquire() - - transactionExecutionUtil.withNewTransaction(isReadOnly = false) { - jdbcTemplate.batchUpdate(sql, batchArgs) - } - - semaphore.release() - } -} - -class DefaultDataInitializer : AbstractDataInitializer() { - - // 1. HQ Admin 추가 - // 2. Store 추가 -> CreatedBy / UpdatedBy는 HQ Admin 중 한명으로 고정 - // 3. Store Admin 추가 -> CreatedBy / UpdatedBy는 HQ Admin 본인으로 고정 - init { - lateinit var superHQAdmin: AdminEntity - - // 모든 테이블 초기화 + 지역 데이터 삽입 + HQ 관리자 1명 생성 - beforeSpec { - initialize() - - transactionExecutionUtil.withNewTransaction(isReadOnly = false) { - superHQAdmin = testAuthUtil.createAdmin(AdminFixture.hqDefault.apply { - this.createdBy = this.id - this.updatedBy = this.id - }) - } - } - - context("관리자, 매장, 테마 초기 데이터 생성") { - test("각 PermissionLevel 마다 20명의 HQ 관리자 생성") { - transactionExecutionUtil.withNewTransaction(isReadOnly = false) { - AdminPermissionLevel.entries.forEach { - repeat(20) { index -> - AdminFixture.create( - account = "hq_${it.name.lowercase()}_$index", - name = randomKoreanName(), - phone = randomPhoneNumber(), - type = AdminType.HQ, - permissionLevel = it - ).apply { - this.createdBy = superHQAdmin.id - this.updatedBy = superHQAdmin.id - }.also { - entityManager.persist(it) - } - } - } - } - } - - test("전체 매장 생성") { - val storeDataInitializer = StoreDataInitializer() - val creatableAdminIds: List = transactionExecutionUtil.withNewTransaction(isReadOnly = true) { - entityManager.createQuery( - "SELECT a.id FROM AdminEntity a WHERE a.type = :type AND a.permissionLevel IN (:permissionLevels)", - Long::class.java - ).setParameter("type", AdminType.HQ) - .setParameter( - "permissionLevels", - listOf(AdminPermissionLevel.FULL_ACCESS, AdminPermissionLevel.WRITABLE) - ) - .resultList - }.map { it.toString() } - - val sqlFile = storeDataInitializer.createStoreDataSqlFile(creatableAdminIds) - - transactionExecutionUtil.withNewTransaction(isReadOnly = false) { - jdbcTemplate.execute(sqlFile.readText()) - } - } - - test("각 매장당 1명의 ${AdminPermissionLevel.FULL_ACCESS} 권한의 StoreManager 1명 + ${AdminPermissionLevel.WRITABLE}의 2명 + 나머지 권한은 3명씩 생성") { - val storeAdminCountsByPermissionLevel = mapOf( - AdminPermissionLevel.WRITABLE to 2, - AdminPermissionLevel.READ_ALL to 3, - AdminPermissionLevel.READ_SUMMARY to 3 - ) - - val storeIds: List = transactionExecutionUtil.withNewTransaction(isReadOnly = true) { - entityManager.createQuery( - "SELECT s.id FROM StoreEntity s", - Long::class.java - ).resultList - }.map { it as Long } - - transactionExecutionUtil.withNewTransaction(isReadOnly = false) { - storeIds.forEach { storeId -> - // StoreManager 1명 생성 - val storeManager = AdminFixture.create( - account = "$storeId", - name = randomKoreanName(), - phone = randomPhoneNumber(), - type = AdminType.STORE, - storeId = storeId, - permissionLevel = AdminPermissionLevel.FULL_ACCESS - ).apply { - this.createdBy = superHQAdmin.id - this.updatedBy = superHQAdmin.id - }.also { - entityManager.persist(it) - } - - storeAdminCountsByPermissionLevel.forEach { (permissionLevel, count) -> - repeat(count) { index -> - AdminFixture.create( - account = randomString(), - name = randomKoreanName(), - phone = randomPhoneNumber(), - type = AdminType.STORE, - storeId = storeId, - permissionLevel = permissionLevel - ).apply { - this.createdBy = storeManager.id - this.updatedBy = storeManager.id - }.also { - entityManager.persist(it) - } - } - } - entityManager.flush() - entityManager.clear() - } - } - } - - test("총 500개의 테마 생성: 지난 2년 전 부터 지금까지의 랜덤 테마 + active 상태인 1달 이내 생성 테마 10개") { - val creatableAdminIds: List = transactionExecutionUtil.withNewTransaction(isReadOnly = true) { - entityManager.createQuery( - "SELECT a.id FROM AdminEntity a WHERE a.type = :type AND a.permissionLevel IN (:permissionLevels)", - Long::class.java - ).setParameter("type", AdminType.HQ) - .setParameter( - "permissionLevels", - listOf(AdminPermissionLevel.FULL_ACCESS, AdminPermissionLevel.WRITABLE) - ) - .resultList - } - val sql = - "INSERT INTO theme (id, name, description, thumbnail_url, is_active, available_minutes, expected_minutes_from, expected_minutes_to, price, difficulty, min_participants, max_participants, created_at, created_by, updated_at, updated_by) VALUES (?," + - "?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)" - val batchSize = 100 - val batchArgs = mutableListOf>() - - repeat(500) { i -> - val randomDay = if (i <= 9) (1..30).random() else (1..365 * 2).random() - val randomCreatedAt: LocalDateTime = LocalDateTime.now().minusDays(randomDay.toLong()) - val randomThemeName = - (1..7).random().let { repeat -> (1..repeat).joinToString("") { randomKoreanName() } } - val availableMinutes = (6..20).random() * 10 - val expectedMinutesTo = availableMinutes - ((1..3).random() * 10) - val expectedMinutesFrom = expectedMinutesTo - ((1..2).random() * 10) - val randomPrice = (0..40).random() * 500 - val minParticipant = (1..10).random() - val maxParticipant = minParticipant + (1..10).random() - val createdBy = creatableAdminIds.random() - - batchArgs.add( - arrayOf( - tsidFactory.next(), - randomThemeName, - "$randomThemeName 설명이에요!!", - "https://encrypted-tbn0.gstatic.com/images?q=tbn:ANd9GcRFiuCdwdz88l6pdfsRy1nFl0IHUVI7JMTQHg&s", - if (randomDay <= 30) true else false, - availableMinutes.toShort(), - expectedMinutesFrom.toShort(), - expectedMinutesTo.toShort(), - randomPrice, - Difficulty.entries.random().name, - minParticipant.toShort(), - maxParticipant.toShort(), - Timestamp.valueOf(randomCreatedAt), - createdBy, - Timestamp.valueOf(randomCreatedAt), - createdBy - ) - ) - } - - transactionExecutionUtil.withNewTransaction(isReadOnly = false) { - jdbcTemplate.batchUpdate(sql, batchArgs) - } - } - } - } -} - -class UserDataInitializer : AbstractDataInitializer() { - val userCount = 1_000_000 - - init { - context("유저 초기 데이터 생성") { - test("$userCount 명의 회원 생성") { - val regions: List = transactionExecutionUtil.withNewTransaction(isReadOnly = true) { - entityManager.createQuery( - "SELECT r.code FROM RegionEntity r", - String::class.java - ).resultList - } - - val chunkSize = 10_000 - val chunks = userCount / chunkSize - - coroutineScope { - (1..chunks).map { - launch(Dispatchers.IO) { - processUsers(chunkSize, regions) - } - }.joinAll() - } - } - - test("휴대폰 번호가 중복된 유저들에게 재배정") { - val duplicatePhoneUsers: List = - transactionExecutionUtil.withNewTransaction(isReadOnly = true) { - entityManager.createQuery( - """ - SELECT u FROM UserEntity u - WHERE u.phone IN ( - SELECT u2.phone FROM UserEntity u2 - GROUP BY u2.phone - HAVING COUNT(u2.id) > 1 - ) - ORDER BY u.phone, u.id - """.trimIndent(), - UserEntity::class.java - ).resultList - } - - jdbcTemplate.execute("CREATE INDEX idx_users__phone ON users (phone)") - - transactionExecutionUtil.withNewTransaction(isReadOnly = false) { - var currentPhone: String? = null - duplicatePhoneUsers.forEach { user -> - if (user.phone != currentPhone) { - currentPhone = user.phone - } else { - var newPhone: String - - do { - newPhone = randomPhoneNumber() - val count: Long = entityManager.createQuery( - "SELECT COUNT(u.id) FROM UserEntity u WHERE u.phone = :phone", - Long::class.java - ).setParameter("phone", newPhone) - .singleResult - - if (count == 0L) break - } while (true) - - user.phone = newPhone - user.updatedAt = LocalDateTime.now() - entityManager.merge(user) - } - } - } - - jdbcTemplate.execute("DROP INDEX idx_users__phone ON users") - } - - test("회원 상태 변경 이력 저장") { - val userId: List = transactionExecutionUtil.withNewTransaction(isReadOnly = true) { - entityManager.createQuery( - "SELECT u.id FROM UserEntity u", - Long::class.java - ).resultList - } - - coroutineScope { - userId.chunked(10_000).map { chunk -> - launch(Dispatchers.IO) { - processStatus(chunk) - } - }.joinAll() - } - } - } - } - - private suspend fun processStatus(userIds: List) { - val sql = """ - INSERT INTO user_status_history ( - id, user_id, reason, status, - created_by, updated_by, created_at, updated_at - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?) - """.trimIndent() - val batchArgs = mutableListOf>() - val now = LocalDateTime.now() - - userIds.forEach { userId -> - batchArgs.add( - arrayOf( - tsidFactory.next(), - userId, - SIGNUP, - UserStatus.ACTIVE.name, - userId, - userId, - Timestamp.valueOf(now), - Timestamp.valueOf(now) - ) - ) - } - - executeBatch(sql, batchArgs).also { batchArgs.clear() } - } - - private suspend fun processUsers(chunkSize: Int, regions: List) { - val sql = """ - INSERT INTO users ( - id, name, email, password, phone, region_code, status, - created_by, updated_by, created_at, updated_at - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) - """.trimIndent() - val batchArgs = mutableListOf>() - - repeat(chunkSize) { - val id: Long = tsidFactory.next() - batchArgs.add( - arrayOf( - id, - randomKoreanName(), - "${randomString()}@sangdol.com", - randomString(), - randomPhoneNumber(), - regions.random(), - UserStatus.ACTIVE.name, - id, - id, - Timestamp.valueOf(LocalDateTime.now()), - Timestamp.valueOf(LocalDateTime.now()) - ) - ) - if (batchArgs.size >= 1_000) { - executeBatch(sql, batchArgs).also { batchArgs.clear() } - } - } - - if (batchArgs.isNotEmpty()) executeBatch(sql, batchArgs).also { batchArgs.clear() } - } -} - -class ScheduleDataInitializer : AbstractDataInitializer() { - init { - context("일정 초기 데이터 생성") { - test("테마 생성일 기준으로 다음 3일차, 매일 5개의 일정을 모든 매장에 생성") { - val stores: List> = getStoreWithManagers() - val themes: List> = getThemes() - val maxAvailableMinutes = themes.maxOf { it.second.toInt() } - val scheduleCountPerDay = 5 - - val startTime = LocalTime.of(10, 0) - var lastTime = startTime - val times = mutableListOf() - - repeat(scheduleCountPerDay) { - times.add(lastTime) - lastTime = lastTime.plusMinutes(maxAvailableMinutes.toLong() + 10L) - } - - coroutineScope { - themes.forEach { theme -> - launch(Dispatchers.IO) { - processTheme(theme, stores, times) - } - } - } - } - } - } - - private suspend fun processTheme( - theme: Triple, - stores: List>, - times: List - ) { - val sql = """ - INSERT INTO schedule ( - id, store_id, theme_id, date, time, status, - created_by, updated_by, created_at, updated_at - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) - """.trimIndent() - - val batchArgs = mutableListOf>() - - val now = LocalDateTime.now() - stores.forEach { (storeId, adminId) -> - (1..3).forEach { dayOffset -> - val date = theme.third.toLocalDate().plusDays(dayOffset.toLong()) - - times.forEach { time -> - val scheduledAt = LocalDateTime.of(date, time) - val status = - if (scheduledAt.isAfter(now)) ScheduleStatus.AVAILABLE.name else ScheduleStatus.RESERVED.name - - batchArgs.add( - arrayOf( - tsidFactory.next(), storeId, theme.first, date, time, - status, adminId, adminId, Timestamp.valueOf(now), Timestamp.valueOf(now) - ) - ) - - if (batchArgs.size >= 500) { - executeBatch(sql, batchArgs).also { batchArgs.clear() } - } - } - } - } - - if (batchArgs.isNotEmpty()) { - executeBatch(sql, batchArgs).also { batchArgs.clear() } - } - } - - private fun getStoreWithManagers(): List> { - return transactionExecutionUtil.withNewTransaction(isReadOnly = true) { - entityManager.createQuery( - "SELECT a.storeId, a.id FROM AdminEntity a WHERE a.type = :type AND a.permissionLevel = :permissionLevel", - List::class.java - ).setParameter("type", AdminType.STORE) - .setParameter("permissionLevel", AdminPermissionLevel.FULL_ACCESS) - .resultList - }.map { - val array = it as List<*> - Pair(array[0] as Long, array[1] as Long) - } - } - - private fun getThemes(): List> { - return transactionExecutionUtil.withNewTransaction(isReadOnly = true) { - entityManager.createQuery( - "SELECT t._id, t.availableMinutes, t.createdAt FROM ThemeEntity t", - List::class.java - ) - .resultList - }.map { - val array = it as List<*> - Triple(array[0] as Long, array[1] as Short, array[2] as LocalDateTime) - } - } -} - -/** - * 아래의 ReservationDataInitializer 에서 사용할 임시 DTO 클래스 - */ -data class ScheduleWithThemeParticipants( - val scheduleId: Long, - val themeMinParticipants: Short, - val themeMaxParticipants: Short, -) - -class ReservationDataInitializer : AbstractDataInitializer() { - - init { - context("예약 초기 데이터 생성") { - test("${ScheduleStatus.RESERVED}인 모든 일정에 예약을 1개씩 배정한다.") { - val chunkSize = 10_000 - - val chunkedSchedules: List> = entityManager.createQuery( - "SELECT new com.sangdol.roomescape.data.ScheduleWithThemeParticipants(s._id, t.minParticipants, t.maxParticipants) FROM ScheduleEntity s JOIN ThemeEntity t ON s.themeId = t.id WHERE s.status = :status", - ScheduleWithThemeParticipants::class.java - ).setParameter("status", ScheduleStatus.RESERVED).resultList.chunked(chunkSize) - - val chunkedUsers: List> = entityManager.createQuery( - "SELECT new com.sangdol.roomescape.user.web.UserContactResponse(u._id, u.name, u.phone) FROM UserEntity u", - UserContactResponse::class.java - ).resultList.chunked(chunkSize) - - - coroutineScope { - chunkedSchedules.forEachIndexed { idx, schedules -> - launch(Dispatchers.IO) { - processReservation(chunkedUsers[idx % chunkedUsers.size], schedules) - } - } - } - } - } - } - - private suspend fun processReservation( - users: List, - schedules: List - ) { - val sql = """ - INSERT INTO reservation ( - id, user_id, schedule_id, - reserver_name, reserver_contact, participant_count, requirement, - status, created_at, created_by, updated_at, updated_by - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) - """.trimIndent() - - val batchArgs = mutableListOf>() - - val createdAt = LocalDateTime.now() - - schedules.forEachIndexed { idx, schedule -> - val user: UserContactResponse = users[idx % users.size] - - batchArgs.add( - arrayOf( - tsidFactory.next(), - user.id, - schedule.scheduleId, - user.name, - user.phone, - (schedule.themeMinParticipants..schedule.themeMaxParticipants).random(), - randomKoreanWords(length = (20..100).random()), - ReservationStatus.CONFIRMED.name, - Timestamp.valueOf(createdAt), - user.id, - Timestamp.valueOf(createdAt), - user.id, - ) - ) - - if (batchArgs.size >= 1_000) { - executeBatch(sql, batchArgs).also { batchArgs.clear() } - } - } - - if (batchArgs.isNotEmpty()) executeBatch(sql, batchArgs).also { batchArgs.clear() } - } -} - -class ReservationWithPrice( - themePrice: Int, - participantCount: Short, - - val reservationId: Long, - val totalPrice: Int = (themePrice * participantCount), -) - -data class PaymentWithMethods( - val id: Long, - val totalPrice: Int, - val method: PaymentMethod -) - -class PaymentDataInitializer : AbstractDataInitializer() { - companion object { - val requestedAtCache: Timestamp = Timestamp.valueOf(OffsetDateTime.now().toLocalDateTime()) - val approvedAtCache: Timestamp = Timestamp.valueOf(OffsetDateTime.now().plusSeconds(5).toLocalDateTime()) - val supportedPaymentMethods = listOf(PaymentMethod.TRANSFER, PaymentMethod.EASY_PAY, PaymentMethod.CARD) - val supportedCardType = listOf(CardType.CREDIT, CardType.CHECK) - - val settlementStatus = "COMPLETED" - - val paymentSql: String = """ - INSERT INTO payment( - id, reservation_id, type, method, - payment_key, order_id, total_amount, status, - requested_at, approved_at - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) - """.trimIndent() - - val paymentDetailSql: String = """ - INSERT INTO payment_detail( - id, payment_id, supplied_amount, vat - ) VALUES (?, ?, ?, ?) - """.trimIndent() - - val paymentCardDetailSql: String = """ - INSERT INTO payment_card_detail( - id, issuer_code, card_type, owner_type, - amount, card_number, approval_number, installment_plan_months, - is_interest_free, easypay_provider_code, easypay_discount_amount - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) - """.trimIndent() - - val paymentEasypayPrepaidDetailSql: String = """ - INSERT INTO payment_easypay_prepaid_detail( - id, easypay_provider_code, amount, discount_amount - ) VALUES (?, ?, ?, ?) - """.trimIndent() - - val paymentBankTransferDetailSql: String = """ - INSERT INTO payment_bank_transfer_detail( - id, bank_code, settlement_status - ) VALUES (?, ?, ?) - """.trimIndent() - } - - init { - context("결제 데이터 초기화") { - test("모든 예약에 맞춰 1:1로 결제 및 결제 상세 데이터를 생성한다.") { - val allReservations: List = entityManager.createQuery( - "SELECT t.price, r.participantCount, r._id FROM ReservationEntity r JOIN ScheduleEntity s ON s._id = r.scheduleId JOIN ThemeEntity t ON t.id = s.themeId", - List::class.java - ).resultList.map { - val items = it as List<*> - ReservationWithPrice( - themePrice = items[0] as Int, - participantCount = items[1] as Short, - reservationId = items[2] as Long - ) - } - - coroutineScope { - allReservations.chunked(10_000).forEach { reservations -> - launch(Dispatchers.IO) { - processPaymentAndDefaultDetail(reservations) - } - } - } - } - - test("기존 결제 데이터에 상세 정보(계좌이체, 카드, 간편결제) 데이터를 생성한다.") { - val allPayments: List = entityManager.createQuery( - "SELECT new com.sangdol.roomescape.data.PaymentWithMethods(pd._id, p.totalAmount, p.method) FROM PaymentEntity p JOIN PaymentDetailEntity pd ON p._id = pd.paymentId", - PaymentWithMethods::class.java - ).resultList - - coroutineScope { - allPayments.chunked(10_000).forEach { payments -> - launch(Dispatchers.IO) { - processPaymentDetail(payments) - } - } - } - } - - test("null 컴파일 에러를 피하기 위해 문자열 null로 임시 지정한 컬럼을 변경한다.") { - jdbcTemplate.execute("CREATE INDEX idx_payment_card_detail_easypay ON payment_card_detail (easypay_provider_code)") - - transactionExecutionUtil.withNewTransaction(isReadOnly = false) { - jdbcTemplate.update( - "UPDATE payment_card_detail SET easypay_provider_code = ? WHERE easypay_provider_code = ?", - null, - "null" - ) - } - - jdbcTemplate.execute("DROP INDEX idx_payment_card_detail_easypay ON payment_card_detail") - } - } - } - - private suspend fun processPaymentAndDefaultDetail(reservations: List) { - val paymentBatchArgs = mutableListOf>() - val detailBatchArgs = mutableListOf>() - - reservations.forEachIndexed { idx, reservations -> - val id = tsidFactory.next() - val totalPrice = reservations.totalPrice - paymentBatchArgs.add( - arrayOf( - id, - reservations.reservationId, - PaymentType.NORMAL.name, - randomPaymentMethod(), - randomString(length = 64), - randomString(length = 20), - totalPrice, - PaymentStatus.DONE.name, - requestedAtCache, - approvedAtCache, - ) - ) - if (paymentBatchArgs.size >= 1_000) { - executeBatch(paymentSql, paymentBatchArgs).also { paymentBatchArgs.clear() } - } - - val suppliedAmount: Int = (totalPrice * 0.9).toInt() - val vat: Int = (totalPrice - suppliedAmount) - - detailBatchArgs.add( - arrayOf( - tsidFactory.next(), - id, - suppliedAmount, - vat - ) - ) - - if (detailBatchArgs.size >= 1_000) { - executeBatch(paymentDetailSql, detailBatchArgs).also { detailBatchArgs.clear() } - } - } - - if (paymentBatchArgs.isNotEmpty()) { - executeBatch(paymentSql, paymentBatchArgs).also { paymentBatchArgs.clear() } - } - if (detailBatchArgs.isNotEmpty()) { - executeBatch(paymentDetailSql, detailBatchArgs).also { detailBatchArgs.clear() } - } - } - - private suspend fun processPaymentDetail(payments: List) { - val transferBatchArgs = mutableListOf>() - val cardBatchArgs = mutableListOf>() - val easypayPrepaidBatchArgs = mutableListOf>() - - payments.forEach { payment -> - val totalPrice = payment.totalPrice - val randomDiscountAmount = - if (totalPrice < 100 || Math.random() < 0.8) 0 else ((100..totalPrice).random() / 100) * 100 - val amount = totalPrice - randomDiscountAmount - - when (payment.method) { - PaymentMethod.TRANSFER -> { - transferBatchArgs.add( - arrayOf( - payment.id, - BankCode.entries.random().name, - settlementStatus - ) - ) - if (transferBatchArgs.size >= 1_000) { - executeBatch(paymentBankTransferDetailSql, transferBatchArgs).also { transferBatchArgs.clear() } - } - } - - PaymentMethod.EASY_PAY -> { - if (Math.random() <= 0.7) { - cardBatchArgs.add( - arrayOf( - payment.id, - CardIssuerCode.entries.random().name, - supportedCardType.random().name, - CardOwnerType.PERSONAL.name, - amount, - randomCardNumber(), - randomApprovalNumber(), - randomInstallmentPlanMonths(amount), - true, - EasyPayCompanyCode.entries.random().name, - randomDiscountAmount - ) - ) - - if (cardBatchArgs.size >= 1_000) { - executeBatch(paymentCardDetailSql, cardBatchArgs).also { cardBatchArgs.clear() } - } - - } else { - easypayPrepaidBatchArgs.add( - arrayOf( - payment.id, - EasyPayCompanyCode.entries.random().name, - amount, - randomDiscountAmount, - ) - ) - - if (easypayPrepaidBatchArgs.size >= 1_000) { - executeBatch(paymentEasypayPrepaidDetailSql, easypayPrepaidBatchArgs).also { easypayPrepaidBatchArgs.clear() } - } - } - } - - PaymentMethod.CARD -> { - cardBatchArgs.add( - arrayOf( - payment.id, - CardIssuerCode.entries.random().name, - supportedCardType.random().name, - CardOwnerType.PERSONAL.name, - totalPrice, - randomCardNumber(), - randomApprovalNumber(), - randomInstallmentPlanMonths(totalPrice), - true, - "null", - 0, - ) - ) - - if (cardBatchArgs.size >= 1_000) { - executeBatch(paymentCardDetailSql, cardBatchArgs).also { cardBatchArgs.clear() } - } - } - - else -> return@forEach - } - } - if (transferBatchArgs.isNotEmpty()) { - executeBatch(paymentBankTransferDetailSql, transferBatchArgs).also { transferBatchArgs.clear() } - } - if (cardBatchArgs.isNotEmpty()) { - executeBatch(paymentCardDetailSql, cardBatchArgs).also { cardBatchArgs.clear() } - } - if (easypayPrepaidBatchArgs.isNotEmpty()) { - executeBatch(paymentEasypayPrepaidDetailSql, easypayPrepaidBatchArgs).also { easypayPrepaidBatchArgs.clear() } - } - } - - private suspend fun randomPaymentMethod(): String { - val random = Math.random() - - return if (random <= 0.5) { - PaymentMethod.EASY_PAY.name - } else if (random <= 0.9) { - PaymentMethod.CARD.name - } else { - PaymentMethod.TRANSFER.name - } - } - - private suspend fun randomCardNumber(): String { - return "${(10000000..99999999).random()}****${(100..999).random()}*" - } - - private suspend fun randomApprovalNumber(): String { - return "${(10000000..99999999).random()}" - } - - private suspend fun randomInstallmentPlanMonths(amount: Int): Int { - return if (amount < 50_000 || Math.random() < 0.9) { - 0 - } else { - (1..6).random() - } - } -} - -fun randomKoreanName(): String { - val lastNames = listOf( - "김", "이", "박", "최", "정", "강", "조", "윤", "장", "임", - "오", "서", "신", "권", "황", "안", "송", "류", "홍", "전", "고", "문", "양", "손", "배", "백", "허", "유", "남", "심", "노" - ) - - return "${lastNames.random()}${if (Math.random() < 0.1) randomKoreanWords(1) else randomKoreanWords(2)}" -} - -fun randomKoreanWords(length: Int = 1): String { - val words = listOf( - "가", "나", "다", "라", "마", "바", "사", "아", "자", "차", - "카", "타", "파", "하", "강", "민", "서", "윤", "우", "진", - "현", "지", "은", "혜", "수", "영", "주", "원", "희", "경", - "선", "아", "나", "다", "라", "마", "바", "사", "아", "자", - "차", "카", "타", "파", "하", - ) - - return (1..length).joinToString("") { words.random() } -} +//package com.sangdol.roomescape.data +// +//import com.github.f4b6a3.tsid.TsidFactory +//import io.kotest.core.test.TestCaseOrder +//import jakarta.persistence.EntityManager +//import kotlinx.coroutines.Dispatchers +//import kotlinx.coroutines.coroutineScope +//import kotlinx.coroutines.joinAll +//import kotlinx.coroutines.launch +//import kotlinx.coroutines.sync.Semaphore +//import org.springframework.beans.factory.annotation.Autowired +//import org.springframework.jdbc.core.JdbcTemplate +//import org.springframework.test.context.ActiveProfiles +//import com.sangdol.roomescape.admin.infrastructure.persistence.AdminEntity +//import com.sangdol.roomescape.admin.infrastructure.persistence.AdminPermissionLevel +//import com.sangdol.roomescape.admin.infrastructure.persistence.AdminType +//import com.sangdol.roomescape.common.config.next +//import com.sangdol.roomescape.common.util.TransactionExecutionUtil +//import com.sangdol.roomescape.payment.infrastructure.common.* +//import com.sangdol.roomescape.reservation.infrastructure.persistence.ReservationStatus +//import com.sangdol.roomescape.schedule.infrastructure.persistence.ScheduleStatus +//import com.sangdol.roomescape.supports.AdminFixture +//import com.sangdol.roomescape.supports.FunSpecSpringbootTest +//import com.sangdol.roomescape.supports.randomPhoneNumber +//import com.sangdol.roomescape.supports.randomString +//import com.sangdol.roomescape.theme.infrastructure.persistence.Difficulty +//import com.sangdol.roomescape.user.business.SIGNUP +//import com.sangdol.roomescape.user.infrastructure.persistence.UserEntity +//import com.sangdol.roomescape.user.infrastructure.persistence.UserStatus +//import com.sangdol.roomescape.user.web.UserContactResponse +//import java.sql.Timestamp +//import java.time.LocalDateTime +//import java.time.LocalTime +//import java.time.OffsetDateTime +// +//@ActiveProfiles("test", "test-mysql") +//abstract class AbstractDataInitializer( +// val semaphore: Semaphore = Semaphore(permits = 10), +//) : FunSpecSpringbootTest( +// enableCleanerExtension = false +//) { +// @Autowired +// lateinit var entityManager: EntityManager +// +// @Autowired +// lateinit var jdbcTemplate: JdbcTemplate +// +// @Autowired +// lateinit var transactionExecutionUtil: TransactionExecutionUtil +// +// @Autowired +// lateinit var tsidFactory: TsidFactory +// +// override fun testCaseOrder(): TestCaseOrder? = TestCaseOrder.Sequential +// +// suspend fun initialize() { +// transactionExecutionUtil.withNewTransaction(isReadOnly = false) { +// jdbcTemplate.execute("SET FOREIGN_KEY_CHECKS = 0") +// +// jdbcTemplate.query("SHOW TABLES") { rs, _ -> +// rs.getString(1).lowercase() +// }.forEach { +// jdbcTemplate.execute("TRUNCATE TABLE $it") +// } +// +// jdbcTemplate.execute("SET FOREIGN_KEY_CHECKS = 1") +// +// this::class.java.getResource("/schema/region-data.sql")?.readText()?.let { sql -> +// jdbcTemplate.execute(sql) +// } +// } +// } +// +// suspend fun executeBatch(sql: String, batchArgs: List>) { +// semaphore.acquire() +// +// transactionExecutionUtil.withNewTransaction(isReadOnly = false) { +// jdbcTemplate.batchUpdate(sql, batchArgs) +// } +// +// semaphore.release() +// } +//} +// +//class DefaultDataInitializer : AbstractDataInitializer() { +// +// // 1. HQ Admin 추가 +// // 2. Store 추가 -> CreatedBy / UpdatedBy는 HQ Admin 중 한명으로 고정 +// // 3. Store Admin 추가 -> CreatedBy / UpdatedBy는 HQ Admin 본인으로 고정 +// init { +// lateinit var superHQAdmin: AdminEntity +// +// // 모든 테이블 초기화 + 지역 데이터 삽입 + HQ 관리자 1명 생성 +// beforeSpec { +// initialize() +// +// transactionExecutionUtil.withNewTransaction(isReadOnly = false) { +// superHQAdmin = testAuthUtil.createAdmin(AdminFixture.hqDefault.apply { +// this.createdBy = this.id +// this.updatedBy = this.id +// }) +// } +// } +// +// context("관리자, 매장, 테마 초기 데이터 생성") { +// test("각 PermissionLevel 마다 20명의 HQ 관리자 생성") { +// transactionExecutionUtil.withNewTransaction(isReadOnly = false) { +// AdminPermissionLevel.entries.forEach { +// repeat(20) { index -> +// AdminFixture.create( +// account = "hq_${it.name.lowercase()}_$index", +// name = randomKoreanName(), +// phone = randomPhoneNumber(), +// type = AdminType.HQ, +// permissionLevel = it +// ).apply { +// this.createdBy = superHQAdmin.id +// this.updatedBy = superHQAdmin.id +// }.also { +// entityManager.persist(it) +// } +// } +// } +// } +// } +// +// test("전체 매장 생성") { +// val storeDataInitializer = StoreDataInitializer() +// val creatableAdminIds: List = transactionExecutionUtil.withNewTransaction(isReadOnly = true) { +// entityManager.createQuery( +// "SELECT a.id FROM AdminEntity a WHERE a.type = :type AND a.permissionLevel IN (:permissionLevels)", +// Long::class.java +// ).setParameter("type", AdminType.HQ) +// .setParameter( +// "permissionLevels", +// listOf(AdminPermissionLevel.FULL_ACCESS, AdminPermissionLevel.WRITABLE) +// ) +// .resultList +// }.map { it.toString() } +// +// val sqlFile = storeDataInitializer.createStoreDataSqlFile(creatableAdminIds) +// +// transactionExecutionUtil.withNewTransaction(isReadOnly = false) { +// jdbcTemplate.execute(sqlFile.readText()) +// } +// } +// +// test("각 매장당 1명의 ${AdminPermissionLevel.FULL_ACCESS} 권한의 StoreManager 1명 + ${AdminPermissionLevel.WRITABLE}의 2명 + 나머지 권한은 3명씩 생성") { +// val storeAdminCountsByPermissionLevel = mapOf( +// AdminPermissionLevel.WRITABLE to 2, +// AdminPermissionLevel.READ_ALL to 3, +// AdminPermissionLevel.READ_SUMMARY to 3 +// ) +// +// val storeIds: List = transactionExecutionUtil.withNewTransaction(isReadOnly = true) { +// entityManager.createQuery( +// "SELECT s.id FROM StoreEntity s", +// Long::class.java +// ).resultList +// }.map { it as Long } +// +// transactionExecutionUtil.withNewTransaction(isReadOnly = false) { +// storeIds.forEach { storeId -> +// // StoreManager 1명 생성 +// val storeManager = AdminFixture.create( +// account = "$storeId", +// name = randomKoreanName(), +// phone = randomPhoneNumber(), +// type = AdminType.STORE, +// storeId = storeId, +// permissionLevel = AdminPermissionLevel.FULL_ACCESS +// ).apply { +// this.createdBy = superHQAdmin.id +// this.updatedBy = superHQAdmin.id +// }.also { +// entityManager.persist(it) +// } +// +// storeAdminCountsByPermissionLevel.forEach { (permissionLevel, count) -> +// repeat(count) { index -> +// AdminFixture.create( +// account = randomString(), +// name = randomKoreanName(), +// phone = randomPhoneNumber(), +// type = AdminType.STORE, +// storeId = storeId, +// permissionLevel = permissionLevel +// ).apply { +// this.createdBy = storeManager.id +// this.updatedBy = storeManager.id +// }.also { +// entityManager.persist(it) +// } +// } +// } +// entityManager.flush() +// entityManager.clear() +// } +// } +// } +// +// test("총 500개의 테마 생성: 지난 2년 전 부터 지금까지의 랜덤 테마 + active 상태인 1달 이내 생성 테마 10개") { +// val creatableAdminIds: List = transactionExecutionUtil.withNewTransaction(isReadOnly = true) { +// entityManager.createQuery( +// "SELECT a.id FROM AdminEntity a WHERE a.type = :type AND a.permissionLevel IN (:permissionLevels)", +// Long::class.java +// ).setParameter("type", AdminType.HQ) +// .setParameter( +// "permissionLevels", +// listOf(AdminPermissionLevel.FULL_ACCESS, AdminPermissionLevel.WRITABLE) +// ) +// .resultList +// } +// val sql = +// "INSERT INTO theme (id, name, description, thumbnail_url, is_active, available_minutes, expected_minutes_from, expected_minutes_to, price, difficulty, min_participants, max_participants, created_at, created_by, updated_at, updated_by) VALUES (?," + +// "?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)" +// val batchSize = 100 +// val batchArgs = mutableListOf>() +// +// repeat(500) { i -> +// val randomDay = if (i <= 9) (1..30).random() else (1..365 * 2).random() +// val randomCreatedAt: LocalDateTime = LocalDateTime.now().minusDays(randomDay.toLong()) +// val randomThemeName = +// (1..7).random().let { repeat -> (1..repeat).joinToString("") { randomKoreanName() } } +// val availableMinutes = (6..20).random() * 10 +// val expectedMinutesTo = availableMinutes - ((1..3).random() * 10) +// val expectedMinutesFrom = expectedMinutesTo - ((1..2).random() * 10) +// val randomPrice = (0..40).random() * 500 +// val minParticipant = (1..10).random() +// val maxParticipant = minParticipant + (1..10).random() +// val createdBy = creatableAdminIds.random() +// +// batchArgs.add( +// arrayOf( +// tsidFactory.next(), +// randomThemeName, +// "$randomThemeName 설명이에요!!", +// "https://encrypted-tbn0.gstatic.com/images?q=tbn:ANd9GcRFiuCdwdz88l6pdfsRy1nFl0IHUVI7JMTQHg&s", +// if (randomDay <= 30) true else false, +// availableMinutes.toShort(), +// expectedMinutesFrom.toShort(), +// expectedMinutesTo.toShort(), +// randomPrice, +// Difficulty.entries.random().name, +// minParticipant.toShort(), +// maxParticipant.toShort(), +// Timestamp.valueOf(randomCreatedAt), +// createdBy, +// Timestamp.valueOf(randomCreatedAt), +// createdBy +// ) +// ) +// } +// +// transactionExecutionUtil.withNewTransaction(isReadOnly = false) { +// jdbcTemplate.batchUpdate(sql, batchArgs) +// } +// } +// } +// } +//} +// +//class UserDataInitializer : AbstractDataInitializer() { +// val userCount = 1_000_000 +// +// init { +// context("유저 초기 데이터 생성") { +// test("$userCount 명의 회원 생성") { +// val regions: List = transactionExecutionUtil.withNewTransaction(isReadOnly = true) { +// entityManager.createQuery( +// "SELECT r.code FROM RegionEntity r", +// String::class.java +// ).resultList +// } +// +// val chunkSize = 10_000 +// val chunks = userCount / chunkSize +// +// coroutineScope { +// (1..chunks).map { +// launch(Dispatchers.IO) { +// processUsers(chunkSize, regions) +// } +// }.joinAll() +// } +// } +// +// test("휴대폰 번호가 중복된 유저들에게 재배정") { +// val duplicatePhoneUsers: List = +// transactionExecutionUtil.withNewTransaction(isReadOnly = true) { +// entityManager.createQuery( +// """ +// SELECT u FROM UserEntity u +// WHERE u.phone IN ( +// SELECT u2.phone FROM UserEntity u2 +// GROUP BY u2.phone +// HAVING COUNT(u2.id) > 1 +// ) +// ORDER BY u.phone, u.id +// """.trimIndent(), +// UserEntity::class.java +// ).resultList +// } +// +// jdbcTemplate.execute("CREATE INDEX idx_users__phone ON users (phone)") +// +// transactionExecutionUtil.withNewTransaction(isReadOnly = false) { +// var currentPhone: String? = null +// duplicatePhoneUsers.forEach { user -> +// if (user.phone != currentPhone) { +// currentPhone = user.phone +// } else { +// var newPhone: String +// +// do { +// newPhone = randomPhoneNumber() +// val count: Long = entityManager.createQuery( +// "SELECT COUNT(u.id) FROM UserEntity u WHERE u.phone = :phone", +// Long::class.java +// ).setParameter("phone", newPhone) +// .singleResult +// +// if (count == 0L) break +// } while (true) +// +// user.phone = newPhone +// user.updatedAt = LocalDateTime.now() +// entityManager.merge(user) +// } +// } +// } +// +// jdbcTemplate.execute("DROP INDEX idx_users__phone ON users") +// } +// +// test("회원 상태 변경 이력 저장") { +// val userId: List = transactionExecutionUtil.withNewTransaction(isReadOnly = true) { +// entityManager.createQuery( +// "SELECT u.id FROM UserEntity u", +// Long::class.java +// ).resultList +// } +// +// coroutineScope { +// userId.chunked(10_000).map { chunk -> +// launch(Dispatchers.IO) { +// processStatus(chunk) +// } +// }.joinAll() +// } +// } +// } +// } +// +// private suspend fun processStatus(userIds: List) { +// val sql = """ +// INSERT INTO user_status_history ( +// id, user_id, reason, status, +// created_by, updated_by, created_at, updated_at +// ) VALUES (?, ?, ?, ?, ?, ?, ?, ?) +// """.trimIndent() +// val batchArgs = mutableListOf>() +// val now = LocalDateTime.now() +// +// userIds.forEach { userId -> +// batchArgs.add( +// arrayOf( +// tsidFactory.next(), +// userId, +// SIGNUP, +// UserStatus.ACTIVE.name, +// userId, +// userId, +// Timestamp.valueOf(now), +// Timestamp.valueOf(now) +// ) +// ) +// } +// +// executeBatch(sql, batchArgs).also { batchArgs.clear() } +// } +// +// private suspend fun processUsers(chunkSize: Int, regions: List) { +// val sql = """ +// INSERT INTO users ( +// id, name, email, password, phone, region_code, status, +// created_by, updated_by, created_at, updated_at +// ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) +// """.trimIndent() +// val batchArgs = mutableListOf>() +// +// repeat(chunkSize) { +// val id: Long = tsidFactory.next() +// batchArgs.add( +// arrayOf( +// id, +// randomKoreanName(), +// "${randomString()}@sangdol.com", +// randomString(), +// randomPhoneNumber(), +// regions.random(), +// UserStatus.ACTIVE.name, +// id, +// id, +// Timestamp.valueOf(LocalDateTime.now()), +// Timestamp.valueOf(LocalDateTime.now()) +// ) +// ) +// if (batchArgs.size >= 1_000) { +// executeBatch(sql, batchArgs).also { batchArgs.clear() } +// } +// } +// +// if (batchArgs.isNotEmpty()) executeBatch(sql, batchArgs).also { batchArgs.clear() } +// } +//} +// +//class ScheduleDataInitializer : AbstractDataInitializer() { +// init { +// context("일정 초기 데이터 생성") { +// test("테마 생성일 기준으로 다음 3일차, 매일 5개의 일정을 모든 매장에 생성") { +// val stores: List> = getStoreWithManagers() +// val themes: List> = getThemes() +// val maxAvailableMinutes = themes.maxOf { it.second.toInt() } +// val scheduleCountPerDay = 5 +// +// val startTime = LocalTime.of(10, 0) +// var lastTime = startTime +// val times = mutableListOf() +// +// repeat(scheduleCountPerDay) { +// times.add(lastTime) +// lastTime = lastTime.plusMinutes(maxAvailableMinutes.toLong() + 10L) +// } +// +// coroutineScope { +// themes.forEach { theme -> +// launch(Dispatchers.IO) { +// processTheme(theme, stores, times) +// } +// } +// } +// } +// } +// } +// +// private suspend fun processTheme( +// theme: Triple, +// stores: List>, +// times: List +// ) { +// val sql = """ +// INSERT INTO schedule ( +// id, store_id, theme_id, date, time, status, +// created_by, updated_by, created_at, updated_at +// ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) +// """.trimIndent() +// +// val batchArgs = mutableListOf>() +// +// val now = LocalDateTime.now() +// stores.forEach { (storeId, adminId) -> +// (1..3).forEach { dayOffset -> +// val date = theme.third.toLocalDate().plusDays(dayOffset.toLong()) +// +// times.forEach { time -> +// val scheduledAt = LocalDateTime.of(date, time) +// val status = +// if (scheduledAt.isAfter(now)) ScheduleStatus.AVAILABLE.name else ScheduleStatus.RESERVED.name +// +// batchArgs.add( +// arrayOf( +// tsidFactory.next(), storeId, theme.first, date, time, +// status, adminId, adminId, Timestamp.valueOf(now), Timestamp.valueOf(now) +// ) +// ) +// +// if (batchArgs.size >= 500) { +// executeBatch(sql, batchArgs).also { batchArgs.clear() } +// } +// } +// } +// } +// +// if (batchArgs.isNotEmpty()) { +// executeBatch(sql, batchArgs).also { batchArgs.clear() } +// } +// } +// +// private fun getStoreWithManagers(): List> { +// return transactionExecutionUtil.withNewTransaction(isReadOnly = true) { +// entityManager.createQuery( +// "SELECT a.storeId, a.id FROM AdminEntity a WHERE a.type = :type AND a.permissionLevel = :permissionLevel", +// List::class.java +// ).setParameter("type", AdminType.STORE) +// .setParameter("permissionLevel", AdminPermissionLevel.FULL_ACCESS) +// .resultList +// }.map { +// val array = it as List<*> +// Pair(array[0] as Long, array[1] as Long) +// } +// } +// +// private fun getThemes(): List> { +// return transactionExecutionUtil.withNewTransaction(isReadOnly = true) { +// entityManager.createQuery( +// "SELECT t._id, t.availableMinutes, t.createdAt FROM ThemeEntity t", +// List::class.java +// ) +// .resultList +// }.map { +// val array = it as List<*> +// Triple(array[0] as Long, array[1] as Short, array[2] as LocalDateTime) +// } +// } +//} +// +///** +// * 아래의 ReservationDataInitializer 에서 사용할 임시 DTO 클래스 +// */ +//data class ScheduleWithThemeParticipants( +// val scheduleId: Long, +// val themeMinParticipants: Short, +// val themeMaxParticipants: Short, +//) +// +//class ReservationDataInitializer : AbstractDataInitializer() { +// +// init { +// context("예약 초기 데이터 생성") { +// test("${ScheduleStatus.RESERVED}인 모든 일정에 예약을 1개씩 배정한다.") { +// val chunkSize = 10_000 +// +// val chunkedSchedules: List> = entityManager.createQuery( +// "SELECT new com.sangdol.roomescape.data.ScheduleWithThemeParticipants(s._id, t.minParticipants, t.maxParticipants) FROM ScheduleEntity s JOIN ThemeEntity t ON s.themeId = t.id WHERE s.status = :status", +// ScheduleWithThemeParticipants::class.java +// ).setParameter("status", ScheduleStatus.RESERVED).resultList.chunked(chunkSize) +// +// val chunkedUsers: List> = entityManager.createQuery( +// "SELECT new com.sangdol.roomescape.user.web.UserContactResponse(u._id, u.name, u.phone) FROM UserEntity u", +// UserContactResponse::class.java +// ).resultList.chunked(chunkSize) +// +// +// coroutineScope { +// chunkedSchedules.forEachIndexed { idx, schedules -> +// launch(Dispatchers.IO) { +// processReservation(chunkedUsers[idx % chunkedUsers.size], schedules) +// } +// } +// } +// } +// } +// } +// +// private suspend fun processReservation( +// users: List, +// schedules: List +// ) { +// val sql = """ +// INSERT INTO reservation ( +// id, user_id, schedule_id, +// reserver_name, reserver_contact, participant_count, requirement, +// status, created_at, created_by, updated_at, updated_by +// ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) +// """.trimIndent() +// +// val batchArgs = mutableListOf>() +// +// val createdAt = LocalDateTime.now() +// +// schedules.forEachIndexed { idx, schedule -> +// val user: UserContactResponse = users[idx % users.size] +// +// batchArgs.add( +// arrayOf( +// tsidFactory.next(), +// user.id, +// schedule.scheduleId, +// user.name, +// user.phone, +// (schedule.themeMinParticipants..schedule.themeMaxParticipants).random(), +// randomKoreanWords(length = (20..100).random()), +// ReservationStatus.CONFIRMED.name, +// Timestamp.valueOf(createdAt), +// user.id, +// Timestamp.valueOf(createdAt), +// user.id, +// ) +// ) +// +// if (batchArgs.size >= 1_000) { +// executeBatch(sql, batchArgs).also { batchArgs.clear() } +// } +// } +// +// if (batchArgs.isNotEmpty()) executeBatch(sql, batchArgs).also { batchArgs.clear() } +// } +//} +// +//class ReservationWithPrice( +// themePrice: Int, +// participantCount: Short, +// +// val reservationId: Long, +// val totalPrice: Int = (themePrice * participantCount), +//) +// +//data class PaymentWithMethods( +// val id: Long, +// val totalPrice: Int, +// val method: PaymentMethod +//) +// +//class PaymentDataInitializer : AbstractDataInitializer() { +// companion object { +// val requestedAtCache: Timestamp = Timestamp.valueOf(OffsetDateTime.now().toLocalDateTime()) +// val approvedAtCache: Timestamp = Timestamp.valueOf(OffsetDateTime.now().plusSeconds(5).toLocalDateTime()) +// val supportedPaymentMethods = listOf(PaymentMethod.TRANSFER, PaymentMethod.EASY_PAY, PaymentMethod.CARD) +// val supportedCardType = listOf(CardType.CREDIT, CardType.CHECK) +// +// val settlementStatus = "COMPLETED" +// +// val paymentSql: String = """ +// INSERT INTO payment( +// id, reservation_id, type, method, +// payment_key, order_id, total_amount, status, +// requested_at, approved_at +// ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) +// """.trimIndent() +// +// val paymentDetailSql: String = """ +// INSERT INTO payment_detail( +// id, payment_id, supplied_amount, vat +// ) VALUES (?, ?, ?, ?) +// """.trimIndent() +// +// val paymentCardDetailSql: String = """ +// INSERT INTO payment_card_detail( +// id, issuer_code, card_type, owner_type, +// amount, card_number, approval_number, installment_plan_months, +// is_interest_free, easypay_provider_code, easypay_discount_amount +// ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) +// """.trimIndent() +// +// val paymentEasypayPrepaidDetailSql: String = """ +// INSERT INTO payment_easypay_prepaid_detail( +// id, easypay_provider_code, amount, discount_amount +// ) VALUES (?, ?, ?, ?) +// """.trimIndent() +// +// val paymentBankTransferDetailSql: String = """ +// INSERT INTO payment_bank_transfer_detail( +// id, bank_code, settlement_status +// ) VALUES (?, ?, ?) +// """.trimIndent() +// } +// +// init { +// context("결제 데이터 초기화") { +// test("모든 예약에 맞춰 1:1로 결제 및 결제 상세 데이터를 생성한다.") { +// val allReservations: List = entityManager.createQuery( +// "SELECT t.price, r.participantCount, r._id FROM ReservationEntity r JOIN ScheduleEntity s ON s._id = r.scheduleId JOIN ThemeEntity t ON t.id = s.themeId", +// List::class.java +// ).resultList.map { +// val items = it as List<*> +// ReservationWithPrice( +// themePrice = items[0] as Int, +// participantCount = items[1] as Short, +// reservationId = items[2] as Long +// ) +// } +// +// coroutineScope { +// allReservations.chunked(10_000).forEach { reservations -> +// launch(Dispatchers.IO) { +// processPaymentAndDefaultDetail(reservations) +// } +// } +// } +// } +// +// test("기존 결제 데이터에 상세 정보(계좌이체, 카드, 간편결제) 데이터를 생성한다.") { +// val allPayments: List = entityManager.createQuery( +// "SELECT new com.sangdol.roomescape.data.PaymentWithMethods(pd._id, p.totalAmount, p.method) FROM PaymentEntity p JOIN PaymentDetailEntity pd ON p._id = pd.paymentId", +// PaymentWithMethods::class.java +// ).resultList +// +// coroutineScope { +// allPayments.chunked(10_000).forEach { payments -> +// launch(Dispatchers.IO) { +// processPaymentDetail(payments) +// } +// } +// } +// } +// +// test("null 컴파일 에러를 피하기 위해 문자열 null로 임시 지정한 컬럼을 변경한다.") { +// jdbcTemplate.execute("CREATE INDEX idx_payment_card_detail_easypay ON payment_card_detail (easypay_provider_code)") +// +// transactionExecutionUtil.withNewTransaction(isReadOnly = false) { +// jdbcTemplate.update( +// "UPDATE payment_card_detail SET easypay_provider_code = ? WHERE easypay_provider_code = ?", +// null, +// "null" +// ) +// } +// +// jdbcTemplate.execute("DROP INDEX idx_payment_card_detail_easypay ON payment_card_detail") +// } +// } +// } +// +// private suspend fun processPaymentAndDefaultDetail(reservations: List) { +// val paymentBatchArgs = mutableListOf>() +// val detailBatchArgs = mutableListOf>() +// +// reservations.forEachIndexed { idx, reservations -> +// val id = tsidFactory.next() +// val totalPrice = reservations.totalPrice +// paymentBatchArgs.add( +// arrayOf( +// id, +// reservations.reservationId, +// PaymentType.NORMAL.name, +// randomPaymentMethod(), +// randomString(length = 64), +// randomString(length = 20), +// totalPrice, +// PaymentStatus.DONE.name, +// requestedAtCache, +// approvedAtCache, +// ) +// ) +// if (paymentBatchArgs.size >= 1_000) { +// executeBatch(paymentSql, paymentBatchArgs).also { paymentBatchArgs.clear() } +// } +// +// val suppliedAmount: Int = (totalPrice * 0.9).toInt() +// val vat: Int = (totalPrice - suppliedAmount) +// +// detailBatchArgs.add( +// arrayOf( +// tsidFactory.next(), +// id, +// suppliedAmount, +// vat +// ) +// ) +// +// if (detailBatchArgs.size >= 1_000) { +// executeBatch(paymentDetailSql, detailBatchArgs).also { detailBatchArgs.clear() } +// } +// } +// +// if (paymentBatchArgs.isNotEmpty()) { +// executeBatch(paymentSql, paymentBatchArgs).also { paymentBatchArgs.clear() } +// } +// if (detailBatchArgs.isNotEmpty()) { +// executeBatch(paymentDetailSql, detailBatchArgs).also { detailBatchArgs.clear() } +// } +// } +// +// private suspend fun processPaymentDetail(payments: List) { +// val transferBatchArgs = mutableListOf>() +// val cardBatchArgs = mutableListOf>() +// val easypayPrepaidBatchArgs = mutableListOf>() +// +// payments.forEach { payment -> +// val totalPrice = payment.totalPrice +// val randomDiscountAmount = +// if (totalPrice < 100 || Math.random() < 0.8) 0 else ((100..totalPrice).random() / 100) * 100 +// val amount = totalPrice - randomDiscountAmount +// +// when (payment.method) { +// PaymentMethod.TRANSFER -> { +// transferBatchArgs.add( +// arrayOf( +// payment.id, +// BankCode.entries.random().name, +// settlementStatus +// ) +// ) +// if (transferBatchArgs.size >= 1_000) { +// executeBatch(paymentBankTransferDetailSql, transferBatchArgs).also { transferBatchArgs.clear() } +// } +// } +// +// PaymentMethod.EASY_PAY -> { +// if (Math.random() <= 0.7) { +// cardBatchArgs.add( +// arrayOf( +// payment.id, +// CardIssuerCode.entries.random().name, +// supportedCardType.random().name, +// CardOwnerType.PERSONAL.name, +// amount, +// randomCardNumber(), +// randomApprovalNumber(), +// randomInstallmentPlanMonths(amount), +// true, +// EasyPayCompanyCode.entries.random().name, +// randomDiscountAmount +// ) +// ) +// +// if (cardBatchArgs.size >= 1_000) { +// executeBatch(paymentCardDetailSql, cardBatchArgs).also { cardBatchArgs.clear() } +// } +// +// } else { +// easypayPrepaidBatchArgs.add( +// arrayOf( +// payment.id, +// EasyPayCompanyCode.entries.random().name, +// amount, +// randomDiscountAmount, +// ) +// ) +// +// if (easypayPrepaidBatchArgs.size >= 1_000) { +// executeBatch(paymentEasypayPrepaidDetailSql, easypayPrepaidBatchArgs).also { easypayPrepaidBatchArgs.clear() } +// } +// } +// } +// +// PaymentMethod.CARD -> { +// cardBatchArgs.add( +// arrayOf( +// payment.id, +// CardIssuerCode.entries.random().name, +// supportedCardType.random().name, +// CardOwnerType.PERSONAL.name, +// totalPrice, +// randomCardNumber(), +// randomApprovalNumber(), +// randomInstallmentPlanMonths(totalPrice), +// true, +// "null", +// 0, +// ) +// ) +// +// if (cardBatchArgs.size >= 1_000) { +// executeBatch(paymentCardDetailSql, cardBatchArgs).also { cardBatchArgs.clear() } +// } +// } +// +// else -> return@forEach +// } +// } +// if (transferBatchArgs.isNotEmpty()) { +// executeBatch(paymentBankTransferDetailSql, transferBatchArgs).also { transferBatchArgs.clear() } +// } +// if (cardBatchArgs.isNotEmpty()) { +// executeBatch(paymentCardDetailSql, cardBatchArgs).also { cardBatchArgs.clear() } +// } +// if (easypayPrepaidBatchArgs.isNotEmpty()) { +// executeBatch(paymentEasypayPrepaidDetailSql, easypayPrepaidBatchArgs).also { easypayPrepaidBatchArgs.clear() } +// } +// } +// +// private suspend fun randomPaymentMethod(): String { +// val random = Math.random() +// +// return if (random <= 0.5) { +// PaymentMethod.EASY_PAY.name +// } else if (random <= 0.9) { +// PaymentMethod.CARD.name +// } else { +// PaymentMethod.TRANSFER.name +// } +// } +// +// private suspend fun randomCardNumber(): String { +// return "${(10000000..99999999).random()}****${(100..999).random()}*" +// } +// +// private suspend fun randomApprovalNumber(): String { +// return "${(10000000..99999999).random()}" +// } +// +// private suspend fun randomInstallmentPlanMonths(amount: Int): Int { +// return if (amount < 50_000 || Math.random() < 0.9) { +// 0 +// } else { +// (1..6).random() +// } +// } +//} +// +//fun randomKoreanName(): String { +// val lastNames = listOf( +// "김", "이", "박", "최", "정", "강", "조", "윤", "장", "임", +// "오", "서", "신", "권", "황", "안", "송", "류", "홍", "전", "고", "문", "양", "손", "배", "백", "허", "유", "남", "심", "노" +// ) +// +// return "${lastNames.random()}${if (Math.random() < 0.1) randomKoreanWords(1) else randomKoreanWords(2)}" +//} +// +//fun randomKoreanWords(length: Int = 1): String { +// val words = listOf( +// "가", "나", "다", "라", "마", "바", "사", "아", "자", "차", +// "카", "타", "파", "하", "강", "민", "서", "윤", "우", "진", +// "현", "지", "은", "혜", "수", "영", "주", "원", "희", "경", +// "선", "아", "나", "다", "라", "마", "바", "사", "아", "자", +// "차", "카", "타", "파", "하", +// ) +// +// return (1..length).joinToString("") { words.random() } +//} diff --git a/service/src/test/kotlin/com/sangdol/roomescape/supports/RestAssuredUtils.kt b/service/src/test/kotlin/com/sangdol/roomescape/supports/RestAssuredUtils.kt index bf663229..d2515a70 100644 --- a/service/src/test/kotlin/com/sangdol/roomescape/supports/RestAssuredUtils.kt +++ b/service/src/test/kotlin/com/sangdol/roomescape/supports/RestAssuredUtils.kt @@ -1,6 +1,7 @@ package com.sangdol.roomescape.supports -import com.fasterxml.jackson.module.kotlin.convertValue +import com.sangdol.common.config.JacksonConfig +import com.sangdol.roomescape.common.exception.ErrorCode import io.restassured.module.kotlin.extensions.Given import io.restassured.module.kotlin.extensions.Then import io.restassured.module.kotlin.extensions.When @@ -10,8 +11,6 @@ import io.restassured.specification.RequestSpecification import org.hamcrest.CoreMatchers.equalTo import org.springframework.http.HttpMethod import org.springframework.http.MediaType -import com.sangdol.roomescape.common.config.JacksonConfig -import com.sangdol.roomescape.common.exception.ErrorCode fun runTest( token: String? = null, @@ -103,10 +102,10 @@ object ResponseParser { val objectMapper = JacksonConfig().objectMapper() inline fun parseListResponse(response: List>): List { - return response.map { objectMapper.convertValue(it) } + return response.map { objectMapper.convertValue(it, T::class.java) } } inline fun parseSingleResponse(response: LinkedHashMap): T { - return objectMapper.convertValue(response) + return objectMapper.convertValue(response, T::class.java) } }