diff --git a/service/src/test/kotlin/com/sangdol/roomescape/auth/JwtUtilsTest.kt b/service/src/test/kotlin/com/sangdol/roomescape/auth/JwtUtilsTest.kt index 02415b46..0ab97084 100644 --- a/service/src/test/kotlin/com/sangdol/roomescape/auth/JwtUtilsTest.kt +++ b/service/src/test/kotlin/com/sangdol/roomescape/auth/JwtUtilsTest.kt @@ -1,13 +1,12 @@ package com.sangdol.roomescape.auth -import io.kotest.assertions.throwables.shouldThrow -import io.kotest.core.spec.style.FunSpec -import io.kotest.matchers.shouldBe import com.sangdol.roomescape.auth.exception.AuthErrorCode import com.sangdol.roomescape.auth.exception.AuthException import com.sangdol.roomescape.auth.infrastructure.jwt.JwtUtils -import com.sangdol.roomescape.common.config.next -import com.sangdol.roomescape.supports.tsidFactory +import com.sangdol.roomescape.supports.IDGenerator +import io.kotest.assertions.throwables.shouldThrow +import io.kotest.core.spec.style.FunSpec +import io.kotest.matchers.shouldBe class JwtUtilsTest : FunSpec() { private val jwtUtils: JwtUtils = JwtUtils( @@ -18,7 +17,7 @@ class JwtUtilsTest : FunSpec() { init { context("종합 테스트") { test("Subject + Claim을 담아 토큰을 생성한 뒤 읽어온다.") { - val subject = "${tsidFactory.next()}" + val subject = "${IDGenerator.create()}" val claim = mapOf("name" to "sangdol") jwtUtils.createToken(subject, claim).also { token -> @@ -32,7 +31,7 @@ class JwtUtilsTest : FunSpec() { } context("실패 테스트") { - val subject = "${tsidFactory.next()}" + val subject = "${IDGenerator.create()}" val claim = mapOf("name" to "sangdol") val commonToken = jwtUtils.createToken(subject, claim) 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 6dd76aa4..6aa01e1d 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,910 @@ -//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.sangdol.common.persistence.IDGenerator +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.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 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 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 idGenerator: IDGenerator + + 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( + idGenerator.create(), + 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( + idGenerator.create(), + 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 = idGenerator.create() + 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( + idGenerator.create(), 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( + idGenerator.create(), + 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 = idGenerator.create() + 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( + idGenerator.create(), + 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/data/PopulationDataParser.kt b/service/src/test/kotlin/com/sangdol/roomescape/data/PopulationDataParser.kt index c1582e31..5ee16add 100644 --- a/service/src/test/kotlin/com/sangdol/roomescape/data/PopulationDataParser.kt +++ b/service/src/test/kotlin/com/sangdol/roomescape/data/PopulationDataParser.kt @@ -1,12 +1,10 @@ package com.sangdol.roomescape.data -import org.apache.poi.xssf.usermodel.XSSFWorkbook -import com.sangdol.roomescape.common.config.next import com.sangdol.roomescape.store.infrastructure.persistence.StoreStatus +import com.sangdol.roomescape.supports.IDGenerator import com.sangdol.roomescape.supports.randomPhoneNumber -import com.sangdol.roomescape.supports.tsidFactory +import org.apache.poi.xssf.usermodel.XSSFWorkbook import java.io.File -import java.nio.file.Paths import java.time.LocalDateTime import java.time.ZoneId import java.time.format.DateTimeFormatter @@ -146,7 +144,7 @@ class StoreDataInitializer { val createdAt = randomLocalDateTime() val updatedAt = createdAt - val id: Long = tsidFactory.next().also { storeIds.add(it) } + val id: Long = IDGenerator.create().also { storeIds.add(it) } val createdBy = creatableAdminIds.random() diff --git a/service/src/test/kotlin/com/sangdol/roomescape/supports/DummyInitializer.kt b/service/src/test/kotlin/com/sangdol/roomescape/supports/DummyInitializer.kt index b90f7136..f1252bc3 100644 --- a/service/src/test/kotlin/com/sangdol/roomescape/supports/DummyInitializer.kt +++ b/service/src/test/kotlin/com/sangdol/roomescape/supports/DummyInitializer.kt @@ -1,7 +1,5 @@ package com.sangdol.roomescape.supports -import org.springframework.data.repository.findByIdOrNull -import com.sangdol.roomescape.common.config.next import com.sangdol.roomescape.payment.business.PaymentWriter import com.sangdol.roomescape.payment.infrastructure.client.CardDetail import com.sangdol.roomescape.payment.infrastructure.client.EasyPayDetail @@ -31,6 +29,7 @@ import com.sangdol.roomescape.theme.infrastructure.persistence.ThemeRepository import com.sangdol.roomescape.theme.web.ThemeCreateRequest import com.sangdol.roomescape.theme.web.toEntity import com.sangdol.roomescape.user.infrastructure.persistence.UserEntity +import org.springframework.data.repository.findByIdOrNull import java.time.LocalDateTime class DummyInitializer( @@ -43,7 +42,7 @@ class DummyInitializer( ) { fun createStore( - id: Long = tsidFactory.next(), + id: Long = IDGenerator.create(), name: String = "행복${randomPhoneNumber()}호점", address: String = "강북구 행복로 $name", contact: String = randomPhoneNumber(), @@ -71,11 +70,11 @@ class DummyInitializer( fun createTheme( request: ThemeCreateRequest = ThemeFixture.createRequest ): ThemeEntity { - return request.toEntity(tsidFactory.next()).also { themeRepository.save(it) } + return request.toEntity(IDGenerator.create()).also { themeRepository.save(it) } } fun createSchedule( - storeId: Long = tsidFactory.next(), + storeId: Long = IDGenerator.create(), request: ScheduleCreateRequest = ScheduleFixture.createRequest, status: ScheduleStatus = ScheduleStatus.AVAILABLE ): ScheduleEntity { @@ -102,7 +101,7 @@ class DummyInitializer( fun createPendingReservation( user: UserEntity, - storeId: Long = tsidFactory.next(), + storeId: Long = IDGenerator.create(), themeRequest: ThemeCreateRequest = ThemeFixture.createRequest, scheduleRequest: ScheduleCreateRequest = ScheduleFixture.createRequest, reservationRequest: PendingReservationCreateRequest = ReservationFixture.pendingCreateRequest, @@ -129,14 +128,14 @@ class DummyInitializer( reserverContact = reservationRequest.reserverContact, participantCount = reservationRequest.participantCount, requirement = reservationRequest.requirement, - ).toEntity(id = tsidFactory.next(), userId = user.id) + ).toEntity(id = IDGenerator.create(), userId = user.id) return reservationRepository.save(reservation) } fun createConfirmReservation( user: UserEntity, - storeId: Long = tsidFactory.next(), + storeId: Long = IDGenerator.create(), themeRequest: ThemeCreateRequest = ThemeFixture.createRequest, scheduleRequest: ScheduleCreateRequest = ScheduleFixture.createRequest, reservationRequest: PendingReservationCreateRequest = ReservationFixture.pendingCreateRequest, diff --git a/service/src/test/kotlin/com/sangdol/roomescape/supports/Fixtures.kt b/service/src/test/kotlin/com/sangdol/roomescape/supports/Fixtures.kt index 7389a643..bf59d52b 100644 --- a/service/src/test/kotlin/com/sangdol/roomescape/supports/Fixtures.kt +++ b/service/src/test/kotlin/com/sangdol/roomescape/supports/Fixtures.kt @@ -1,10 +1,10 @@ package com.sangdol.roomescape.supports import com.github.f4b6a3.tsid.TsidFactory +import com.sangdol.common.persistence.TsidIDGenerator 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.payment.infrastructure.client.* import com.sangdol.roomescape.payment.infrastructure.common.* import com.sangdol.roomescape.payment.web.PaymentCancelRequest @@ -28,7 +28,7 @@ import java.time.LocalTime import java.time.OffsetDateTime const val INVALID_PK: Long = 9999L -val tsidFactory = TsidFactory(0) +val IDGenerator = TsidIDGenerator(TsidFactory(0)) object StoreFixture { val registerRequest = StoreRegisterRequest( @@ -40,7 +40,7 @@ object StoreFixture { ) fun create( - id: Long = tsidFactory.next(), + id: Long = IDGenerator.create(), name: String = "행복${randomPhoneNumber()}호점", address: String = "서울특별시 강북구 행복${randomPhoneNumber()}길", contact: String = randomPhoneNumber(), @@ -72,12 +72,12 @@ object AdminFixture { ) fun createStoreAdmin( - id: Long = tsidFactory.next(), + id: Long = IDGenerator.create(), account: String = randomString(), password: String = "adminPassword", name: String = "admin12345", phone: String = randomPhoneNumber(), - storeId: Long = tsidFactory.next(), + storeId: Long = IDGenerator.create(), permissionLevel: AdminPermissionLevel = AdminPermissionLevel.FULL_ACCESS ): AdminEntity { return create( @@ -93,7 +93,7 @@ object AdminFixture { } fun createHqAdmin( - id: Long = tsidFactory.next(), + id: Long = IDGenerator.create(), account: String = randomString(), password: String = "adminPassword", name: String = "admin12345", @@ -113,13 +113,13 @@ object AdminFixture { } fun create( - id: Long = tsidFactory.next(), + id: Long = IDGenerator.create(), account: String = randomString(), password: String = "adminPassword", name: String = "admin", phone: String = randomPhoneNumber(), type: AdminType = AdminType.STORE, - storeId: Long? = tsidFactory.next(), + storeId: Long? = IDGenerator.create(), permissionLevel: AdminPermissionLevel = AdminPermissionLevel.FULL_ACCESS ): AdminEntity { val storeId = if (type == AdminType.HQ) null else storeId @@ -144,7 +144,7 @@ object UserFixture { ) fun createUser( - id: Long = tsidFactory.next(), + id: Long = IDGenerator.create(), name: String = randomString(), email: String = randomEmail(), password: String = "a".repeat(MIN_PASSWORD_LENGTH), @@ -186,7 +186,7 @@ object ThemeFixture { ) fun create( - id: Long = tsidFactory.next(), + id: Long = IDGenerator.create(), name: String = randomString(), description: String = randomString(), thumbnailUrl: String = "http://www.bing.com/search?q=fugit", @@ -218,15 +218,15 @@ object ScheduleFixture { val createRequest: ScheduleCreateRequest = ScheduleCreateRequest( date = LocalDate.now().plusDays(1), time = LocalTime.now(), - themeId = tsidFactory.next() + themeId = IDGenerator.create() ) fun create( - id: Long = tsidFactory.next(), + id: Long = IDGenerator.create(), date: LocalDate = LocalDate.now().plusDays(1), time: LocalTime = LocalTime.now(), - storeId: Long = tsidFactory.next(), - themeId: Long = tsidFactory.next() + storeId: Long = IDGenerator.create(), + themeId: Long = IDGenerator.create() ): ScheduleEntity = ScheduleEntityFactory.create( id = id, date = date, @@ -245,7 +245,7 @@ object PaymentFixture { ) val cancelRequest: PaymentCancelRequest = PaymentCancelRequest( - reservationId = tsidFactory.next(), + reservationId = IDGenerator.create(), cancelReason = "cancelReason", ) @@ -322,7 +322,7 @@ object PaymentFixture { object ReservationFixture { val pendingCreateRequest: PendingReservationCreateRequest = PendingReservationCreateRequest( - scheduleId = tsidFactory.next(), + scheduleId = IDGenerator.create(), reserverName = "Wilbur Stuart", reserverContact = "wilbur@example.com", participantCount = 5, diff --git a/service/src/test/kotlin/com/sangdol/roomescape/theme/ThemeApiTest.kt b/service/src/test/kotlin/com/sangdol/roomescape/theme/ThemeApiTest.kt index 58d0446c..4146b1ed 100644 --- a/service/src/test/kotlin/com/sangdol/roomescape/theme/ThemeApiTest.kt +++ b/service/src/test/kotlin/com/sangdol/roomescape/theme/ThemeApiTest.kt @@ -1,11 +1,5 @@ package com.sangdol.roomescape.theme -import io.kotest.matchers.collections.shouldContainInOrder -import io.kotest.matchers.collections.shouldHaveSize -import org.hamcrest.CoreMatchers.equalTo -import org.springframework.http.HttpMethod -import org.springframework.http.HttpStatus -import com.sangdol.roomescape.common.config.next import com.sangdol.roomescape.common.util.DateUtils import com.sangdol.roomescape.supports.* import com.sangdol.roomescape.theme.exception.ThemeErrorCode @@ -14,6 +8,11 @@ import com.sangdol.roomescape.theme.infrastructure.persistence.ThemeRepository import com.sangdol.roomescape.theme.web.ThemeInfoResponse import com.sangdol.roomescape.theme.web.toEntity import com.sangdol.roomescape.user.infrastructure.persistence.UserEntity +import io.kotest.matchers.collections.shouldContainInOrder +import io.kotest.matchers.collections.shouldHaveSize +import org.hamcrest.CoreMatchers.equalTo +import org.springframework.http.HttpMethod +import org.springframework.http.HttpStatus import java.time.LocalDate class ThemeApiTest( @@ -78,7 +77,7 @@ class ThemeApiTest( val user: UserEntity = testAuthUtil.defaultUser() val themeIds: List = (1..5).map { - themeRepository.save(ThemeFixture.createRequest.copy().toEntity(id = tsidFactory.next())).id + themeRepository.save(ThemeFixture.createRequest.copy().toEntity(id = IDGenerator.create())).id } val store = dummyInitializer.createStore()