From 186d6e118cb6a18bcbf39040bd4c83ed164fd41d Mon Sep 17 00:00:00 2001 From: pricelees Date: Sat, 4 Oct 2025 08:40:37 +0000 Subject: [PATCH] =?UTF-8?q?[#52]=20=EB=A7=8C=EB=A3=8C=20=EC=98=88=EC=95=BD?= =?UTF-8?q?=20/=20=EC=9D=BC=EC=A0=95=20=EC=8A=A4=EC=BC=80=EC=A5=B4?= =?UTF-8?q?=EB=A7=81=20=EC=9E=91=EC=97=85=20=EC=B6=94=EA=B0=80=20=EB=B0=8F?= =?UTF-8?q?=20=EB=8F=99=EC=8B=9C=EC=84=B1=20=EC=B2=98=EB=A6=AC=EB=A5=BC=20?= =?UTF-8?q?=EC=9C=84=ED=95=9C=20=EC=9D=BC=EB=B6=80=20=EC=BD=94=EB=93=9C=20?= =?UTF-8?q?=EC=88=98=EC=A0=95=20(#53)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 📝 관련 이슈 및 PR **PR과 관련된 이슈 번호** - #52 ## ✨ 작업 내용 - 예약 페이지에서 일정 조회시, 현 시간 이후부터 조회되도록 수정 - 사용자의 schedule 수정은 \@LastModified JPA Auditing이 적용되지 않도록 UPDATE 쿼리를 바로 전송하도록 수정 - 매 1분마다 Pending 예약이 되지 않은 일정, 결제가 되지 않은 Pending 예약 만료 처리 스케쥴링 작업 추가 - 스케쥴링 작업으로 인해 발생할 수 있는 'Pending 예약은 생성했으나 해당 일정이 재활성화' 되는 문제 해결을 위해 schedule 조회에 pessimistic lock 적용 ## 🧪 테스트 - LocalTime.plusHours()로 인해 특정 시간대 이후로는 실패하는 테스트 수정 - Pessimistic Lock 적용 후 해당 문제 상황 동시성 테스트 추가 - 하나의 일정에 대한 동시 HOLD 요청 상황 테스트 추가 ## 📚 참고 자료 및 기타 Reviewed-on: https://gitea.pricelees.me/pricelees/roomescape-refactored/pulls/53 Co-authored-by: pricelees Co-committed-by: pricelees --- docker/docker-compose.yaml | 14 +++ .../common/config/LocalDatabaseCleaner.kt | 33 +++++ .../business/ReservationService.kt | 18 +-- .../business/ReservationValidator.kt | 4 +- .../IncompletedReservationScheduler.kt | 43 +++++++ .../exception/ReservationErrorCode.kt | 2 +- .../persistence/ReservationRepository.kt | 20 +++ .../schedule/business/ScheduleService.kt | 49 ++++++-- .../schedule/business/ScheduleValidator.kt | 8 +- .../schedule/exception/ScheduleException.kt | 2 + .../persistence/ScheduleEntity.kt | 34 +---- .../persistence/ScheduleRepository.kt | 70 ++++++++++- .../src/main/resources/application-local.yaml | 14 +-- .../src/main/resources/schema/schema-h2.sql | 21 ++-- .../main/resources/schema/schema-mysql.sql | 29 +++-- .../sangdol/data/DefaultDataInitializer.kt | 4 +- .../IncompletedReservationSchedulerTest.kt | 73 +++++++++++ .../reservation/ReservationApiTest.kt | 4 +- .../reservation/ReservationConcurrencyTest.kt | 83 ++++++++++++ .../schedule/AdminScheduleApiTest.kt | 22 ++-- .../roomescape/schedule/ScheduleApiTest.kt | 60 +++++++-- .../schedule/ScheduleConcurrencyTest.kt | 59 +++++++++ .../schedule/ScheduleServiceTest.kt | 119 ++++++++++++++++++ .../roomescape/supports/TestDatabaseUtil.kt | 14 ++- ...-test-mysql.yaml => application-data.yaml} | 0 .../src/test/resources/application-test.yaml | 10 +- 26 files changed, 681 insertions(+), 128 deletions(-) create mode 100644 docker/docker-compose.yaml create mode 100644 service/src/main/kotlin/com/sangdol/roomescape/common/config/LocalDatabaseCleaner.kt create mode 100644 service/src/main/kotlin/com/sangdol/roomescape/reservation/business/scheduler/IncompletedReservationScheduler.kt create mode 100644 service/src/test/kotlin/com/sangdol/roomescape/reservation/IncompletedReservationSchedulerTest.kt create mode 100644 service/src/test/kotlin/com/sangdol/roomescape/reservation/ReservationConcurrencyTest.kt create mode 100644 service/src/test/kotlin/com/sangdol/roomescape/schedule/ScheduleConcurrencyTest.kt create mode 100644 service/src/test/kotlin/com/sangdol/roomescape/schedule/ScheduleServiceTest.kt rename service/src/test/resources/{application-test-mysql.yaml => application-data.yaml} (100%) diff --git a/docker/docker-compose.yaml b/docker/docker-compose.yaml new file mode 100644 index 00000000..83ce6703 --- /dev/null +++ b/docker/docker-compose.yaml @@ -0,0 +1,14 @@ +services: + mysql-local: + image: mysql:8.4 + container_name: mysql-local + restart: always + ports: + - "23306:3306" + environment: + MYSQL_ROOT_PASSWORD: init + MYSQL_DATABASE: roomescape_local + TZ: Asia/Seoul + command: + - --character-set-server=utf8mb4 + - --collation-server=utf8mb4_unicode_ci diff --git a/service/src/main/kotlin/com/sangdol/roomescape/common/config/LocalDatabaseCleaner.kt b/service/src/main/kotlin/com/sangdol/roomescape/common/config/LocalDatabaseCleaner.kt new file mode 100644 index 00000000..b3e30f88 --- /dev/null +++ b/service/src/main/kotlin/com/sangdol/roomescape/common/config/LocalDatabaseCleaner.kt @@ -0,0 +1,33 @@ +package com.sangdol.roomescape.common.config + +import io.github.oshai.kotlinlogging.KLogger +import io.github.oshai.kotlinlogging.KotlinLogging +import jakarta.annotation.PreDestroy +import jakarta.transaction.Transactional +import org.springframework.context.annotation.Profile +import org.springframework.jdbc.core.JdbcTemplate +import org.springframework.stereotype.Component + +private val log: KLogger = KotlinLogging.logger {} + +@Component +@Profile("local") +class LocalDatabaseCleaner( + private val jdbcTemplate: JdbcTemplate +) { + @PreDestroy + @Transactional + fun clearAll() { + log.info { "[LocalDatabaseCleaner] 데이터베이스 초기화 시작" } + 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") + log.info { "[LocalDatabaseCleaner] 데이터베이스 초기화 완료" } + } +} diff --git a/service/src/main/kotlin/com/sangdol/roomescape/reservation/business/ReservationService.kt b/service/src/main/kotlin/com/sangdol/roomescape/reservation/business/ReservationService.kt index f26baa16..61587cde 100644 --- a/service/src/main/kotlin/com/sangdol/roomescape/reservation/business/ReservationService.kt +++ b/service/src/main/kotlin/com/sangdol/roomescape/reservation/business/ReservationService.kt @@ -11,7 +11,6 @@ import com.sangdol.roomescape.reservation.web.* import com.sangdol.roomescape.schedule.business.ScheduleService import com.sangdol.roomescape.schedule.infrastructure.persistence.ScheduleStatus import com.sangdol.roomescape.schedule.web.ScheduleOverviewResponse -import com.sangdol.roomescape.schedule.web.ScheduleUpdateRequest import com.sangdol.roomescape.theme.business.ThemeService import com.sangdol.roomescape.user.business.UserService import com.sangdol.roomescape.user.web.UserContactResponse @@ -58,9 +57,10 @@ class ReservationService( run { reservation.confirm() - scheduleService.updateSchedule( - reservation.scheduleId, - ScheduleUpdateRequest(status = ScheduleStatus.RESERVED) + scheduleService.changeStatus( + scheduleId = reservation.scheduleId, + currentStatus = ScheduleStatus.HOLD, + changeStatus = ScheduleStatus.RESERVED ) }.also { log.info { "[ReservationService.confirmReservation] Pending 예약 확정 완료: reservationId=${id}" } @@ -74,9 +74,10 @@ class ReservationService( val reservation: ReservationEntity = findOrThrow(reservationId) run { - scheduleService.updateSchedule( - reservation.scheduleId, - ScheduleUpdateRequest(status = ScheduleStatus.AVAILABLE) + scheduleService.changeStatus( + scheduleId = reservation.scheduleId, + currentStatus = ScheduleStatus.RESERVED, + changeStatus = ScheduleStatus.AVAILABLE ) saveCanceledReservation(user, reservation, request.cancelReason) reservation.cancel() @@ -148,11 +149,12 @@ class ReservationService( status = CanceledReservationStatus.COMPLETED ).also { canceledReservationRepository.save(it) + } } private fun validateCanCreate(request: PendingReservationCreateRequest) { - val schedule = scheduleService.findSummaryById(request.scheduleId) + val schedule = scheduleService.findSummaryWithLock(request.scheduleId) val theme = themeService.findInfoById(schedule.themeId) reservationValidator.validateCanCreate(schedule, theme, request) diff --git a/service/src/main/kotlin/com/sangdol/roomescape/reservation/business/ReservationValidator.kt b/service/src/main/kotlin/com/sangdol/roomescape/reservation/business/ReservationValidator.kt index 28fdc5d6..d8211272 100644 --- a/service/src/main/kotlin/com/sangdol/roomescape/reservation/business/ReservationValidator.kt +++ b/service/src/main/kotlin/com/sangdol/roomescape/reservation/business/ReservationValidator.kt @@ -21,8 +21,8 @@ class ReservationValidator { request: PendingReservationCreateRequest ) { if (schedule.status != ScheduleStatus.HOLD) { - log.warn { "[ReservationValidator.validateCanCreate] ${schedule.status}인 일정으로 인한 예약 실패" } - throw ReservationException(ReservationErrorCode.SCHEDULE_NOT_HOLD) + log.warn { "[ReservationValidator.validateCanCreate] ${schedule.status}로의 일정 상태 변경에 따른 실패" } + throw ReservationException(ReservationErrorCode.EXPIRED_HELD_SCHEDULE) } if (theme.minParticipants > request.participantCount) { diff --git a/service/src/main/kotlin/com/sangdol/roomescape/reservation/business/scheduler/IncompletedReservationScheduler.kt b/service/src/main/kotlin/com/sangdol/roomescape/reservation/business/scheduler/IncompletedReservationScheduler.kt new file mode 100644 index 00000000..b15876b1 --- /dev/null +++ b/service/src/main/kotlin/com/sangdol/roomescape/reservation/business/scheduler/IncompletedReservationScheduler.kt @@ -0,0 +1,43 @@ +package com.sangdol.roomescape.reservation.business.scheduler + +import com.sangdol.roomescape.reservation.infrastructure.persistence.ReservationRepository +import com.sangdol.roomescape.schedule.infrastructure.persistence.ScheduleRepository +import com.sangdol.roomescape.schedule.infrastructure.persistence.ScheduleStatus +import io.github.oshai.kotlinlogging.KLogger +import io.github.oshai.kotlinlogging.KotlinLogging +import org.springframework.scheduling.annotation.EnableScheduling +import org.springframework.scheduling.annotation.Scheduled +import org.springframework.stereotype.Component +import org.springframework.transaction.annotation.Transactional +import java.time.LocalDateTime +import java.util.concurrent.TimeUnit + +private val log: KLogger = KotlinLogging.logger {} + +@Component +@EnableScheduling +class IncompletedReservationScheduler( + private val scheduleRepository: ScheduleRepository, + private val reservationRepository: ReservationRepository +) { + + @Scheduled(initialDelay = 1, fixedRate = 1, timeUnit = TimeUnit.MINUTES) + @Transactional + fun processExpiredHoldSchedule() { + log.info { "[IncompletedReservationScheduler] 만료 시간이 지난 ${ScheduleStatus.HOLD} 상태의 일정 재활성화 시작" } + + scheduleRepository.releaseExpiredHolds(LocalDateTime.now()).also { + log.info { "[IncompletedReservationScheduler] ${it}개의 일정 재활성화 완료" } + } + } + + @Scheduled(initialDelay = 1, fixedRate = 1, timeUnit = TimeUnit.MINUTES) + @Transactional + fun processExpiredReservation() { + log.info { "[IncompletedReservationScheduler] 결제되지 않은 예약 만료 처리 시작 " } + + reservationRepository.expirePendingReservations(LocalDateTime.now()).also { + log.info { "[IncompletedReservationScheduler] ${it}개의 예약 및 일정 처리 완료" } + } + } +} diff --git a/service/src/main/kotlin/com/sangdol/roomescape/reservation/exception/ReservationErrorCode.kt b/service/src/main/kotlin/com/sangdol/roomescape/reservation/exception/ReservationErrorCode.kt index a91594e7..1e0b3829 100644 --- a/service/src/main/kotlin/com/sangdol/roomescape/reservation/exception/ReservationErrorCode.kt +++ b/service/src/main/kotlin/com/sangdol/roomescape/reservation/exception/ReservationErrorCode.kt @@ -11,7 +11,7 @@ enum class ReservationErrorCode( RESERVATION_NOT_FOUND(HttpStatus.NOT_FOUND, "R001", "예약을 찾을 수 없어요."), NO_PERMISSION_TO_CANCEL_RESERVATION(HttpStatus.FORBIDDEN, "R002", "예약을 취소할 수 있는 권한이 없어요."), INVALID_SEARCH_DATE_RANGE(HttpStatus.BAD_REQUEST, "R003", "종료 날짜는 시작 날짜 이후여야 해요."), - SCHEDULE_NOT_HOLD(HttpStatus.BAD_REQUEST, "R004", "이미 예약되었거나 예약이 불가능한 일정이에요."), + EXPIRED_HELD_SCHEDULE(HttpStatus.CONFLICT, "R004", "예약 정보 입력 시간을 초과했어요. 일정 선택 후 다시 시도해주세요."), INVALID_PARTICIPANT_COUNT(HttpStatus.BAD_REQUEST, "R005", "참여 가능 인원 수를 확인해주세요.") ; } diff --git a/service/src/main/kotlin/com/sangdol/roomescape/reservation/infrastructure/persistence/ReservationRepository.kt b/service/src/main/kotlin/com/sangdol/roomescape/reservation/infrastructure/persistence/ReservationRepository.kt index e9dbdcde..9ef52754 100644 --- a/service/src/main/kotlin/com/sangdol/roomescape/reservation/infrastructure/persistence/ReservationRepository.kt +++ b/service/src/main/kotlin/com/sangdol/roomescape/reservation/infrastructure/persistence/ReservationRepository.kt @@ -1,8 +1,28 @@ package com.sangdol.roomescape.reservation.infrastructure.persistence import org.springframework.data.jpa.repository.JpaRepository +import org.springframework.data.jpa.repository.Modifying +import org.springframework.data.jpa.repository.Query +import org.springframework.data.repository.query.Param +import java.time.LocalDateTime interface ReservationRepository : JpaRepository { fun findAllByUserIdAndStatusIsIn(userId: Long, statuses: List): List + + @Modifying + @Query(""" + UPDATE + reservation r + JOIN + schedule s ON r.schedule_id = s.id AND s.status = 'HOLD' + SET + r.status = 'EXPIRED', + r.updated_at = :now, + s.status = 'AVAILABLE', + s.hold_expired_at = NULL + WHERE + r.status = 'PENDING' AND r.created_at <= DATE_SUB(CURRENT_TIMESTAMP, INTERVAL 5 MINUTE) + """, nativeQuery = true) + fun expirePendingReservations(@Param("now") now: LocalDateTime): Int } diff --git a/service/src/main/kotlin/com/sangdol/roomescape/schedule/business/ScheduleService.kt b/service/src/main/kotlin/com/sangdol/roomescape/schedule/business/ScheduleService.kt index 3adfedcb..d36ca16d 100644 --- a/service/src/main/kotlin/com/sangdol/roomescape/schedule/business/ScheduleService.kt +++ b/service/src/main/kotlin/com/sangdol/roomescape/schedule/business/ScheduleService.kt @@ -1,12 +1,12 @@ package com.sangdol.roomescape.schedule.business -import ScheduleException import com.sangdol.common.persistence.IDGenerator +import com.sangdol.roomescape.admin.business.AdminService import com.sangdol.roomescape.common.types.AuditingInfo import com.sangdol.roomescape.common.types.Auditor -import com.sangdol.roomescape.admin.business.AdminService import com.sangdol.roomescape.schedule.business.domain.ScheduleOverview import com.sangdol.roomescape.schedule.exception.ScheduleErrorCode +import com.sangdol.roomescape.schedule.exception.ScheduleException import com.sangdol.roomescape.schedule.infrastructure.persistence.ScheduleEntity import com.sangdol.roomescape.schedule.infrastructure.persistence.ScheduleEntityFactory import com.sangdol.roomescape.schedule.infrastructure.persistence.ScheduleRepository @@ -18,6 +18,7 @@ import org.springframework.data.repository.findByIdOrNull import org.springframework.stereotype.Service import org.springframework.transaction.annotation.Transactional import java.time.LocalDate +import java.time.LocalTime private val log: KLogger = KotlinLogging.logger {} @@ -43,9 +44,16 @@ class ScheduleService( @Transactional(readOnly = true) fun getStoreScheduleByDate(storeId: Long, date: LocalDate): ScheduleWithThemeListResponse { log.info { "[ScheduleService.getStoreScheduleByDate] 매장 일정 조회: storeId=${storeId}, date=$date" } + val currentDate = LocalDate.now() + + if (date.isBefore(currentDate)) { + log.warn { "[ScheduleService.getStoreScheduleByDate] 이전 날짜 선택으로 인한 실패: date=${date}" } + throw ScheduleException(ScheduleErrorCode.PAST_DATE_TIME) + } val schedules: List = scheduleRepository.findStoreSchedulesWithThemeByDate(storeId, date) + .filter { it.date.isAfter(date) || (it.date.isEqual(date) && it.time.isAfter(LocalTime.now())) } return schedules.toResponse() .also { @@ -58,14 +66,20 @@ class ScheduleService( // ======================================== @Transactional fun holdSchedule(id: Long) { - val schedule: ScheduleEntity = findOrThrow(id) - - if (schedule.status == ScheduleStatus.AVAILABLE) { - schedule.hold() - return + log.info { "[ScheduleService.holdSchedule] 일정 Holding 시작: id=$id" } + val result: Int = scheduleRepository.changeStatus( + id = id, + currentStatus = ScheduleStatus.AVAILABLE, + changeStatus = ScheduleStatus.HOLD + ).also { + log.info { "[ScheduleService.holdSchedule] $it 개의 row 변경 완료" } } - throw ScheduleException(ScheduleErrorCode.SCHEDULE_NOT_AVAILABLE) + if (result == 0) { + throw ScheduleException(ScheduleErrorCode.SCHEDULE_NOT_AVAILABLE) + } + + log.info { "[ScheduleService.holdSchedule] 일정 Holding 완료: id=$id" } } // ======================================== @@ -161,10 +175,16 @@ class ScheduleService( // Other-Service (API 없이 다른 서비스에서 호출) // ======================================== @Transactional(readOnly = true) - fun findSummaryById(id: Long): ScheduleSummaryResponse { + fun findSummaryWithLock(id: Long): ScheduleSummaryResponse { log.info { "[ScheduleService.findDateTimeById] 일정 개요 조회 시작 : id=$id" } - return findOrThrow(id).toSummaryResponse() + val schedule: ScheduleEntity = scheduleRepository.findByIdForUpdate(id) + ?: run { + log.warn { "[ScheduleService.updateSchedule] 일정 조회 실패. id=$id" } + throw ScheduleException(ScheduleErrorCode.SCHEDULE_NOT_FOUND) + } + + return schedule.toSummaryResponse() .also { log.info { "[ScheduleService.findDateTimeById] 일정 개요 조회 완료: id=$id" } } @@ -180,6 +200,15 @@ class ScheduleService( return overview.toOverviewResponse() } + @Transactional + fun changeStatus(scheduleId: Long, currentStatus: ScheduleStatus, changeStatus: ScheduleStatus) { + log.info { "[ScheduleService.reserveSchedule] 일정 상태 변경 시작: id=${scheduleId}, currentStatus=${currentStatus}, changeStatus=${changeStatus}" } + + scheduleRepository.changeStatus(scheduleId, currentStatus, changeStatus).also { + log.info { "[ScheduleService.reserveSchedule] 일정 상태 변경 완료: id=${scheduleId}, currentStatus=${currentStatus}, changeStatus=${changeStatus}" } + } + } + // ======================================== // Common (공통 메서드) // ======================================== diff --git a/service/src/main/kotlin/com/sangdol/roomescape/schedule/business/ScheduleValidator.kt b/service/src/main/kotlin/com/sangdol/roomescape/schedule/business/ScheduleValidator.kt index daab707d..4b79cd1b 100644 --- a/service/src/main/kotlin/com/sangdol/roomescape/schedule/business/ScheduleValidator.kt +++ b/service/src/main/kotlin/com/sangdol/roomescape/schedule/business/ScheduleValidator.kt @@ -1,15 +1,15 @@ package com.sangdol.roomescape.schedule.business -import ScheduleException -import io.github.oshai.kotlinlogging.KLogger -import io.github.oshai.kotlinlogging.KotlinLogging -import org.springframework.stereotype.Component import com.sangdol.roomescape.schedule.exception.ScheduleErrorCode +import com.sangdol.roomescape.schedule.exception.ScheduleException import com.sangdol.roomescape.schedule.infrastructure.persistence.ScheduleEntity import com.sangdol.roomescape.schedule.infrastructure.persistence.ScheduleRepository import com.sangdol.roomescape.schedule.infrastructure.persistence.ScheduleStatus import com.sangdol.roomescape.schedule.web.ScheduleCreateRequest import com.sangdol.roomescape.schedule.web.ScheduleUpdateRequest +import io.github.oshai.kotlinlogging.KLogger +import io.github.oshai.kotlinlogging.KotlinLogging +import org.springframework.stereotype.Component import java.time.LocalDate import java.time.LocalDateTime import java.time.LocalTime diff --git a/service/src/main/kotlin/com/sangdol/roomescape/schedule/exception/ScheduleException.kt b/service/src/main/kotlin/com/sangdol/roomescape/schedule/exception/ScheduleException.kt index 6133383a..3f935e94 100644 --- a/service/src/main/kotlin/com/sangdol/roomescape/schedule/exception/ScheduleException.kt +++ b/service/src/main/kotlin/com/sangdol/roomescape/schedule/exception/ScheduleException.kt @@ -1,3 +1,5 @@ +package com.sangdol.roomescape.schedule.exception + import com.sangdol.common.types.exception.ErrorCode import com.sangdol.common.types.exception.RoomescapeException diff --git a/service/src/main/kotlin/com/sangdol/roomescape/schedule/infrastructure/persistence/ScheduleEntity.kt b/service/src/main/kotlin/com/sangdol/roomescape/schedule/infrastructure/persistence/ScheduleEntity.kt index fde4b384..bd6f2a6c 100644 --- a/service/src/main/kotlin/com/sangdol/roomescape/schedule/infrastructure/persistence/ScheduleEntity.kt +++ b/service/src/main/kotlin/com/sangdol/roomescape/schedule/infrastructure/persistence/ScheduleEntity.kt @@ -1,11 +1,7 @@ package com.sangdol.roomescape.schedule.infrastructure.persistence -import com.sangdol.common.persistence.PersistableBaseEntity -import com.sangdol.common.utils.MdcPrincipalIdUtil +import com.sangdol.common.persistence.AuditingBaseEntity import jakarta.persistence.* -import org.springframework.data.annotation.CreatedBy -import org.springframework.data.annotation.CreatedDate -import org.springframework.data.annotation.LastModifiedDate import org.springframework.data.jpa.domain.support.AuditingEntityListener import java.time.LocalDate import java.time.LocalDateTime @@ -24,36 +20,14 @@ class ScheduleEntity( @Enumerated(value = EnumType.STRING) var status: ScheduleStatus, -) : PersistableBaseEntity(id) { - @Column(updatable = false) - @CreatedDate - lateinit var createdAt: LocalDateTime - - @Column(updatable = false) - @CreatedBy - var createdBy: Long = 0L - - @Column - @LastModifiedDate - lateinit var updatedAt: LocalDateTime - - var updatedBy: Long = 0L - + var holdExpiredAt: LocalDateTime? = null +) : AuditingBaseEntity(id) { fun modifyIfNotNull( time: LocalTime?, status: ScheduleStatus? ) { time?.let { this.time = it } status?.let { this.status = it } - updateLastModifiedBy() - } - - fun hold() { - this.status = ScheduleStatus.HOLD - } - - fun updateLastModifiedBy() { - MdcPrincipalIdUtil.extractAsLongOrNull()?.also { this.updatedBy = it } } } @@ -66,7 +40,7 @@ object ScheduleEntityFactory { storeId = storeId, themeId = themeId, status = ScheduleStatus.AVAILABLE - ).apply { this.updateLastModifiedBy() } + ) } } diff --git a/service/src/main/kotlin/com/sangdol/roomescape/schedule/infrastructure/persistence/ScheduleRepository.kt b/service/src/main/kotlin/com/sangdol/roomescape/schedule/infrastructure/persistence/ScheduleRepository.kt index 82080181..a30fbbed 100644 --- a/service/src/main/kotlin/com/sangdol/roomescape/schedule/infrastructure/persistence/ScheduleRepository.kt +++ b/service/src/main/kotlin/com/sangdol/roomescape/schedule/infrastructure/persistence/ScheduleRepository.kt @@ -1,13 +1,29 @@ package com.sangdol.roomescape.schedule.infrastructure.persistence -import org.springframework.data.jpa.repository.JpaRepository -import org.springframework.data.jpa.repository.Query import com.sangdol.roomescape.schedule.business.domain.ScheduleOverview +import jakarta.persistence.LockModeType +import org.springframework.data.jpa.repository.JpaRepository +import org.springframework.data.jpa.repository.Lock +import org.springframework.data.jpa.repository.Modifying +import org.springframework.data.jpa.repository.Query +import org.springframework.data.repository.query.Param import java.time.LocalDate +import java.time.LocalDateTime import java.time.LocalTime interface ScheduleRepository : JpaRepository { + @Lock(value = LockModeType.PESSIMISTIC_WRITE) + @Query(""" + SELECT + s + FROM + ScheduleEntity s + WHERE + s._id = :id + """) + fun findByIdForUpdate(id: Long): ScheduleEntity? + @Query( """ SELECT @@ -17,8 +33,8 @@ interface ScheduleRepository : JpaRepository { WHERE s.storeId = :storeId AND s.date = :date - AND s.themeId = :themeId AND s.time = :time + AND s.themeId = :themeId """ ) fun existsDuplicate(storeId: Long, date: LocalDate, themeId: Long, time: LocalTime): Boolean @@ -41,11 +57,13 @@ interface ScheduleRepository : JpaRepository { FROM ScheduleEntity s JOIN - ThemeEntity t ON t._id = s.themeId and (:themeId IS NULL OR t._id = :themeId) + ThemeEntity t ON t._id = s.themeId JOIN - StoreEntity st ON st._id = s.storeId and st._id = :storeId + StoreEntity st ON st._id = s.storeId WHERE - s.date = :date + s.storeId = :storeId + AND s.date = :date + AND (:themeId IS NULL OR s.themeId = :themeId) """ ) fun findStoreSchedulesWithThemeByDate( @@ -80,4 +98,44 @@ interface ScheduleRepository : JpaRepository { """ ) fun findOverviewByIdOrNull(id: Long): ScheduleOverview? + + @Modifying + @Query( + """ + UPDATE + ScheduleEntity s + SET + s.status = :changeStatus, + s.holdExpiredAt = CASE + WHEN :changeStatus = com.sangdol.roomescape.schedule.infrastructure.persistence.ScheduleStatus.HOLD + THEN CURRENT_TIMESTAMP + 5 MINUTE + ELSE NULL + END + WHERE + s._id = :id + AND + s.status = :currentStatus + """ + ) + fun changeStatus(id: Long, currentStatus: ScheduleStatus, changeStatus: ScheduleStatus): Int + + @Modifying + @Query( + """ + UPDATE + ScheduleEntity s + SET + s.status = com.sangdol.roomescape.schedule.infrastructure.persistence.ScheduleStatus.AVAILABLE, + s.holdExpiredAt = NULL + WHERE + s.status = com.sangdol.roomescape.schedule.infrastructure.persistence.ScheduleStatus.HOLD + AND s.holdExpiredAt <= :now + AND NOT EXISTS ( + SELECT 1 + FROM ReservationEntity r + WHERE r.scheduleId = s._id + ) + """ + ) + fun releaseExpiredHolds(@Param("now") now: LocalDateTime): Int } diff --git a/service/src/main/resources/application-local.yaml b/service/src/main/resources/application-local.yaml index 880a61c1..095736d2 100644 --- a/service/src/main/resources/application-local.yaml +++ b/service/src/main/resources/application-local.yaml @@ -5,20 +5,16 @@ spring: format_sql: true hibernate: ddl-auto: validate - h2: - console: - enabled: true - path: /h2-console datasource: hikari: - jdbc-url: jdbc:h2:mem:database - driver-class-name: org.h2.Driver - username: sa - password: + jdbc-url: jdbc:mysql://localhost:23306/roomescape_local + driver-class-name: com.mysql.cj.jdbc.Driver + username: root + password: init sql: init: mode: always - schema-locations: classpath:schema/schema-h2.sql + schema-locations: classpath:schema/schema-mysql.sql data-locations: classpath:schema/region-data.sql security: diff --git a/service/src/main/resources/schema/schema-h2.sql b/service/src/main/resources/schema/schema-h2.sql index 9f7b0e95..ee7218fb 100644 --- a/service/src/main/resources/schema/schema-h2.sql +++ b/service/src/main/resources/schema/schema-h2.sql @@ -114,16 +114,17 @@ create table if not exists theme ( ); create table if not exists schedule ( - id bigint primary key, - date date not null, - time time not null, - store_id bigint not null, - theme_id bigint not null, - status varchar(30) not null, - created_at timestamp not null, - created_by bigint not null, - updated_at timestamp not null, - updated_by bigint not null, + id bigint primary key, + date date not null, + time time not null, + store_id bigint not null, + theme_id bigint not null, + status varchar(30) not null, + created_at timestamp not null, + created_by bigint not null, + updated_at timestamp not null, + updated_by bigint not null, + hold_expired_at timestamp null, constraint uk_schedule__store_id_date_time_theme_id unique (store_id, date, time, theme_id), constraint fk_schedule__store_id foreign key (store_id) references store (id), diff --git a/service/src/main/resources/schema/schema-mysql.sql b/service/src/main/resources/schema/schema-mysql.sql index 56a8ee99..c3a4ab47 100644 --- a/service/src/main/resources/schema/schema-mysql.sql +++ b/service/src/main/resources/schema/schema-mysql.sql @@ -114,20 +114,23 @@ create table if not exists theme ( ); create table if not exists schedule ( - id bigint primary key, - date date not null, - time time not null, - store_id bigint not null, - theme_id bigint not null, - status varchar(30) not null, - created_at datetime(6) not null, - created_by bigint not null, - updated_at datetime(6) not null, - updated_by bigint not null, + id bigint primary key, + date date not null, + time time not null, + store_id bigint not null, + theme_id bigint not null, + status varchar(30) not null, + created_at datetime(6) not null, + created_by bigint not null, + updated_at datetime(6) not null, + updated_by bigint not null, + hold_expired_at datetime(6) null, constraint uk_schedule__store_id_date_time_theme_id unique (store_id, date, time, theme_id), constraint fk_schedule__store_id foreign key (store_id) references store (id), - constraint fk_schedule__theme_id foreign key (theme_id) references theme (id) + constraint fk_schedule__theme_id foreign key (theme_id) references theme (id), + index idx_schedule__status_hold_expired_at (status, hold_expired_at, id), + index idx_schedule__status_date_theme_id_id (status, date, theme_id, id) ); create table if not exists reservation ( @@ -145,7 +148,9 @@ create table if not exists reservation ( updated_by bigint not null, constraint fk_reservation__user_id foreign key (user_id) references users (id), - constraint fk_reservation__schedule_id foreign key (schedule_id) references schedule (id) + constraint fk_reservation__schedule_id foreign key (schedule_id) references schedule (id), + index idx_reservation__schedule_id_status (schedule_id, status), + index idx_reservation__status_schedule_id_created_at (status, schedule_id, created_at) ); create table if not exists canceled_reservation ( diff --git a/service/src/test/kotlin/com/sangdol/data/DefaultDataInitializer.kt b/service/src/test/kotlin/com/sangdol/data/DefaultDataInitializer.kt index 04f7ab1c..aa1ac10e 100644 --- a/service/src/test/kotlin/com/sangdol/data/DefaultDataInitializer.kt +++ b/service/src/test/kotlin/com/sangdol/data/DefaultDataInitializer.kt @@ -1,10 +1,10 @@ package com.sangdol.data import com.sangdol.common.persistence.IDGenerator +import com.sangdol.common.persistence.TransactionExecutionUtil 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.common.persistence.TransactionExecutionUtil import com.sangdol.roomescape.payment.infrastructure.common.* import com.sangdol.roomescape.reservation.infrastructure.persistence.ReservationStatus import com.sangdol.roomescape.schedule.infrastructure.persistence.ScheduleStatus @@ -32,7 +32,7 @@ import java.time.LocalDateTime import java.time.LocalTime import java.time.OffsetDateTime -@ActiveProfiles("test", "test-mysql") +@ActiveProfiles("test", "data") abstract class AbstractDataInitializer( val semaphore: Semaphore = Semaphore(permits = 10), ) : FunSpecSpringbootTest( diff --git a/service/src/test/kotlin/com/sangdol/roomescape/reservation/IncompletedReservationSchedulerTest.kt b/service/src/test/kotlin/com/sangdol/roomescape/reservation/IncompletedReservationSchedulerTest.kt new file mode 100644 index 00000000..f71a240c --- /dev/null +++ b/service/src/test/kotlin/com/sangdol/roomescape/reservation/IncompletedReservationSchedulerTest.kt @@ -0,0 +1,73 @@ +package com.sangdol.roomescape.reservation + +import com.sangdol.common.persistence.TransactionExecutionUtil +import com.sangdol.roomescape.reservation.business.scheduler.IncompletedReservationScheduler +import com.sangdol.roomescape.reservation.infrastructure.persistence.ReservationRepository +import com.sangdol.roomescape.reservation.infrastructure.persistence.ReservationStatus +import com.sangdol.roomescape.schedule.infrastructure.persistence.ScheduleEntity +import com.sangdol.roomescape.schedule.infrastructure.persistence.ScheduleRepository +import com.sangdol.roomescape.schedule.infrastructure.persistence.ScheduleStatus +import com.sangdol.roomescape.supports.FunSpecSpringbootTest +import com.sangdol.roomescape.user.infrastructure.persistence.UserEntity +import io.kotest.assertions.assertSoftly +import io.kotest.matchers.shouldBe +import org.springframework.data.repository.findByIdOrNull +import org.springframework.jdbc.core.JdbcTemplate +import java.time.LocalDateTime + +/** + * @see com.sangdol.roomescape.reservation.business.scheduler.IncompletedReservationScheduler + * Scheduling 시간은 테스트 하지 않고, 내부 로직의 정상 동작만 확인 + */ +class IncompletedReservationSchedulerTest( + private val incompletedReservationScheduler: IncompletedReservationScheduler, + private val transactionExecutionUtil: TransactionExecutionUtil, + private val jdbcTemplate: JdbcTemplate, + private val reservationRepository: ReservationRepository, + private val scheduleRepository: ScheduleRepository, +) : FunSpecSpringbootTest() { + + init { + test("예약이 없고, hold_expired_at 시간이 지난 ${ScheduleStatus.HOLD} 일정을 ${ScheduleStatus.AVAILABLE} 상태로 바꾼다.") { + val schedule: ScheduleEntity = dummyInitializer.createSchedule().apply { + this.status = ScheduleStatus.HOLD + this.holdExpiredAt = LocalDateTime.now().minusSeconds(1) + }.also { + scheduleRepository.saveAndFlush(it) + } + + transactionExecutionUtil.withNewTransaction(isReadOnly = false) { + incompletedReservationScheduler.processExpiredHoldSchedule() + } + + assertSoftly(scheduleRepository.findByIdOrNull(schedule.id)!!) { + this.status shouldBe ScheduleStatus.AVAILABLE + this.holdExpiredAt shouldBe null + } + } + + test("${ReservationStatus.PENDING} 상태로 일정 시간 완료되지 않은 예약을 ${ReservationStatus.EXPIRED} 상태로 바꾼다.") { + val user: UserEntity = testAuthUtil.defaultUserLogin().first + val reservation = dummyInitializer.createPendingReservation(user = user).also { + jdbcTemplate.execute("UPDATE reservation SET created_at = DATE_SUB(NOW(), INTERVAL 5 MINUTE) WHERE id = ${it.id}") + } + + val now = LocalDateTime.now() + + transactionExecutionUtil.withNewTransaction(isReadOnly = false) { + incompletedReservationScheduler.processExpiredReservation() + } + + assertSoftly(reservationRepository.findByIdOrNull(reservation.id)!!) { + this.status shouldBe ReservationStatus.EXPIRED + this.updatedAt.hour shouldBe now.hour + this.updatedAt.minute shouldBe now.minute + } + + assertSoftly(scheduleRepository.findByIdOrNull(reservation.scheduleId)!!) { + this.status shouldBe ScheduleStatus.AVAILABLE + this.holdExpiredAt shouldBe null + } + } + } +} \ No newline at end of file diff --git a/service/src/test/kotlin/com/sangdol/roomescape/reservation/ReservationApiTest.kt b/service/src/test/kotlin/com/sangdol/roomescape/reservation/ReservationApiTest.kt index 491365ae..49b97aee 100644 --- a/service/src/test/kotlin/com/sangdol/roomescape/reservation/ReservationApiTest.kt +++ b/service/src/test/kotlin/com/sangdol/roomescape/reservation/ReservationApiTest.kt @@ -100,8 +100,8 @@ class ReservationApiTest( post(endpoint) }, expect = { - statusCode(HttpStatus.BAD_REQUEST.value()) - body("code", equalTo(ReservationErrorCode.SCHEDULE_NOT_HOLD.errorCode)) + statusCode(HttpStatus.CONFLICT.value()) + body("code", equalTo(ReservationErrorCode.EXPIRED_HELD_SCHEDULE.errorCode)) } ) } diff --git a/service/src/test/kotlin/com/sangdol/roomescape/reservation/ReservationConcurrencyTest.kt b/service/src/test/kotlin/com/sangdol/roomescape/reservation/ReservationConcurrencyTest.kt new file mode 100644 index 00000000..111c14e1 --- /dev/null +++ b/service/src/test/kotlin/com/sangdol/roomescape/reservation/ReservationConcurrencyTest.kt @@ -0,0 +1,83 @@ +package com.sangdol.roomescape.reservation + +import com.sangdol.roomescape.common.types.CurrentUserContext +import com.sangdol.roomescape.reservation.business.ReservationService +import com.sangdol.roomescape.reservation.business.scheduler.IncompletedReservationScheduler +import com.sangdol.roomescape.reservation.infrastructure.persistence.ReservationRepository +import com.sangdol.roomescape.reservation.infrastructure.persistence.ReservationStatus +import com.sangdol.roomescape.reservation.web.PendingReservationCreateRequest +import com.sangdol.roomescape.reservation.web.PendingReservationCreateResponse +import com.sangdol.roomescape.schedule.infrastructure.persistence.ScheduleRepository +import com.sangdol.roomescape.schedule.infrastructure.persistence.ScheduleStatus +import com.sangdol.roomescape.supports.FunSpecSpringbootTest +import io.kotest.assertions.assertSoftly +import io.kotest.matchers.nulls.shouldNotBeNull +import io.kotest.matchers.shouldBe +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.async +import kotlinx.coroutines.awaitAll +import kotlinx.coroutines.withContext +import org.springframework.data.repository.findByIdOrNull +import org.springframework.transaction.PlatformTransactionManager +import org.springframework.transaction.support.TransactionTemplate +import java.time.LocalDateTime + +class ReservationConcurrencyTest( + private val transactionManager: PlatformTransactionManager, + private val incompletedReservationScheduler: IncompletedReservationScheduler, + private val reservationService: ReservationService, + private val reservationRepository: ReservationRepository, + private val scheduleRepository: ScheduleRepository +) : FunSpecSpringbootTest() { + + init { + test("Pending 예약 생성시, Schedule 상태 검증 이후부터 커밋 이전 사이에 시작된 schedule 처리 배치 작업은 반영되지 않는다.") { + val user = testAuthUtil.defaultUserLogin().first + val schedule = dummyInitializer.createSchedule().also { + it.status = ScheduleStatus.HOLD + it.holdExpiredAt = LocalDateTime.now().minusMinutes(1) + scheduleRepository.save(it) + } + lateinit var response: PendingReservationCreateResponse + + withContext(Dispatchers.IO) { + val createPendingReservationJob = async { + response = TransactionTemplate(transactionManager).execute { + val response = reservationService.createPendingReservation( + user = CurrentUserContext(id = user.id, name = user.name), + request = PendingReservationCreateRequest( + scheduleId = schedule.id, + reserverName = user.name, + reserverContact = user.phone, + participantCount = 3, + requirement = "없어요!" + ) + ) + + Thread.sleep(200) + response + }!! + } + + val updateScheduleJob = async { + TransactionTemplate(transactionManager).execute { + incompletedReservationScheduler.processExpiredHoldSchedule() + } + } + + listOf(createPendingReservationJob, updateScheduleJob).awaitAll() + } + + + assertSoftly(scheduleRepository.findByIdOrNull(schedule.id)!!) { + this.status shouldBe ScheduleStatus.HOLD + this.holdExpiredAt.shouldNotBeNull() + } + + assertSoftly(reservationRepository.findByIdOrNull(response.id)) { + this.shouldNotBeNull() + this.status shouldBe ReservationStatus.PENDING + } + } + } +} diff --git a/service/src/test/kotlin/com/sangdol/roomescape/schedule/AdminScheduleApiTest.kt b/service/src/test/kotlin/com/sangdol/roomescape/schedule/AdminScheduleApiTest.kt index f51663dc..1576bbca 100644 --- a/service/src/test/kotlin/com/sangdol/roomescape/schedule/AdminScheduleApiTest.kt +++ b/service/src/test/kotlin/com/sangdol/roomescape/schedule/AdminScheduleApiTest.kt @@ -95,7 +95,7 @@ class AdminScheduleApiTest( }, expect = { statusCode(HttpStatus.OK.value()) - body("data.schedules.size()", equalTo(schedules.filter { it.date == LocalDate.now() }.size)) + body("data.schedules.size()", equalTo(schedules.filter { it.date.isEqual(LocalDate.now()) }.size)) assertProperties( props = setOf("id", "themeName", "startFrom", "endAt", "status"), propsNameIfList = "schedules" @@ -114,7 +114,7 @@ class AdminScheduleApiTest( }, expect = { statusCode(HttpStatus.OK.value()) - body("data.schedules.size()", equalTo(schedules.filter { it.date == date }.size)) + body("data.schedules.size()", equalTo(schedules.filter { it.date.isEqual(date) }.size)) assertProperties( props = setOf("id", "themeName", "startFrom", "endAt", "status"), propsNameIfList = "schedules" @@ -153,7 +153,7 @@ class AdminScheduleApiTest( statusCode(HttpStatus.OK.value()) body( "data.schedules.size()", - equalTo(schedules.filter { it.date == date && it.themeId == themeId }.size) + equalTo(schedules.filter { it.date.isEqual(date) && it.themeId == themeId }.size) ) assertProperties( props = setOf("id", "themeName", "startFrom", "endAt", "status"), @@ -390,7 +390,7 @@ class AdminScheduleApiTest( val time = LocalTime.now().minusMinutes(1) val theme = dummyInitializer.createTheme() - val request = ScheduleFixture.createRequest.copy(date, time, theme.id) + val request = ScheduleFixture.createRequest.copy(date = date, time = time, themeId = theme.id) runExceptionTest( token = token, @@ -412,18 +412,18 @@ class AdminScheduleApiTest( dummyInitializer.createSchedule( storeId = admin.storeId!!, request = ScheduleFixture.createRequest.copy( - tomorrow, - LocalTime.of(14, 0), - theme.id + date = tomorrow, + time = LocalTime.of(14, 0), + themeId = theme.id ) ) } val request = initialize("내일 14:30에 시작하는 요청 객체") { ScheduleFixture.createRequest.copy( - tomorrow, - LocalTime.of(14, 30), - theme.id + date = tomorrow, + time = LocalTime.of(14, 30), + themeId = theme.id ) } @@ -636,7 +636,7 @@ class AdminScheduleApiTest( } } - (ScheduleStatus.entries - listOf(ScheduleStatus.AVAILABLE, ScheduleStatus.BLOCKED)).forEach { + (ScheduleStatus.entries - listOf(ScheduleStatus.AVAILABLE, ScheduleStatus.BLOCKED).toSet()).forEach { test("상태가 ${it}인 일정은 삭제할 수 없다.") { val (admin, token) = testAuthUtil.defaultStoreAdminLogin() val schedule = initialize("삭제를 위한 일정 생성") { diff --git a/service/src/test/kotlin/com/sangdol/roomescape/schedule/ScheduleApiTest.kt b/service/src/test/kotlin/com/sangdol/roomescape/schedule/ScheduleApiTest.kt index 1f8103c4..96453cff 100644 --- a/service/src/test/kotlin/com/sangdol/roomescape/schedule/ScheduleApiTest.kt +++ b/service/src/test/kotlin/com/sangdol/roomescape/schedule/ScheduleApiTest.kt @@ -1,9 +1,5 @@ package com.sangdol.roomescape.schedule -import io.kotest.matchers.shouldBe -import org.hamcrest.CoreMatchers.equalTo -import org.springframework.data.repository.findByIdOrNull -import org.springframework.http.HttpMethod import com.sangdol.common.types.web.HttpStatus import com.sangdol.roomescape.admin.infrastructure.persistence.AdminPermissionLevel import com.sangdol.roomescape.admin.infrastructure.persistence.AdminType @@ -12,6 +8,10 @@ import com.sangdol.roomescape.schedule.exception.ScheduleErrorCode import com.sangdol.roomescape.schedule.infrastructure.persistence.ScheduleRepository import com.sangdol.roomescape.schedule.infrastructure.persistence.ScheduleStatus import com.sangdol.roomescape.supports.* +import io.kotest.matchers.shouldBe +import org.hamcrest.CoreMatchers.equalTo +import org.springframework.data.repository.findByIdOrNull +import org.springframework.http.HttpMethod import java.time.LocalDate import java.time.LocalTime @@ -20,29 +20,42 @@ class ScheduleApiTest( ) : FunSpecSpringbootTest() { init { context("특정 매장 + 날짜의 일정 및 테마 정보를 조회한다.") { - test("정상 응답") { - val size = 2 - val date = LocalDate.now().plusDays(1) + test("날짜가 당일이면 현재 시간 이후의 정보만 조회된다.") { + val size = 3 + val date = LocalDate.now() val store = dummyInitializer.createStore() - initialize("조회를 위한 같은 날짜의 ${size}개의 일정 생성") { + + initialize("조회를 위한 오늘 날짜의 현재 시간 이후인 ${size}개의 일정, 현재 시간 이전인 1개의 일정 생성") { for (i in 1..size) { dummyInitializer.createSchedule( storeId = store.id, request = ScheduleFixture.createRequest.copy( date = date, - time = LocalTime.now().plusHours(i.toLong()) + time = LocalTime.now().plusMinutes(i.toLong()) ) ) } + + dummyInitializer.createSchedule( + storeId = store.id, + request = ScheduleFixture.createRequest.copy( + date = date, + time = LocalTime.now().minusMinutes(1) + ) + ) } + val expectedSize = scheduleRepository.findAll().takeIf { it.isNotEmpty() } + ?.let { it.count { schedule -> schedule.date.isEqual(date) && schedule.time.isAfter(LocalTime.now()) } } + ?: throw AssertionError("initialize 작업에서 레코드가 저장되지 않음.") + runTest( on = { get("/stores/${store.id}/schedules?date=${date}") }, expect = { statusCode(HttpStatus.OK.value()) - body("data.schedules.size()", equalTo(size)) + body("data.schedules.size()", equalTo(expectedSize)) assertProperties( props = setOf("id", "startFrom", "endAt", "themeId", "themeName", "themeDifficulty", "status"), propsNameIfList = "schedules" @@ -50,6 +63,22 @@ class ScheduleApiTest( } ) } + + test("날짜가 오늘 이전이면 실패한다.") { + val store = initialize("매장 생성") { + dummyInitializer.createStore() + } + + runTest( + on = { + get("/stores/${store.id}/schedules?date=${LocalDate.now().minusDays(1)}") + }, + expect = { + statusCode(HttpStatus.BAD_REQUEST.value()) + body("code", equalTo(ScheduleErrorCode.PAST_DATE_TIME.errorCode)) + } + ) + } } context("일정을 ${ScheduleStatus.HOLD} 상태로 변경한다.") { @@ -105,7 +134,7 @@ class ScheduleApiTest( context("일정이 ${ScheduleStatus.AVAILABLE}이 아니면 실패한다.") { (ScheduleStatus.entries - ScheduleStatus.AVAILABLE).forEach { - test("${it}") { + test("$it") { val schedule = dummyInitializer.createSchedule(status = it) runExceptionTest( @@ -117,6 +146,15 @@ class ScheduleApiTest( } } } + + test("일정이 없으면 실패한다.") { + runExceptionTest( + token = testAuthUtil.defaultUserLogin().second, + method = HttpMethod.POST, + endpoint = "/schedules/${INVALID_PK}/hold", + expectedErrorCode = ScheduleErrorCode.SCHEDULE_NOT_AVAILABLE + ) + } } } } diff --git a/service/src/test/kotlin/com/sangdol/roomescape/schedule/ScheduleConcurrencyTest.kt b/service/src/test/kotlin/com/sangdol/roomescape/schedule/ScheduleConcurrencyTest.kt new file mode 100644 index 00000000..c237d11e --- /dev/null +++ b/service/src/test/kotlin/com/sangdol/roomescape/schedule/ScheduleConcurrencyTest.kt @@ -0,0 +1,59 @@ +package com.sangdol.roomescape.schedule + +import com.sangdol.common.types.web.HttpStatus +import com.sangdol.roomescape.schedule.exception.ScheduleErrorCode +import com.sangdol.roomescape.schedule.infrastructure.persistence.ScheduleRepository +import com.sangdol.roomescape.schedule.infrastructure.persistence.ScheduleStatus +import com.sangdol.roomescape.supports.FunSpecSpringbootTest +import com.sangdol.roomescape.supports.runTest +import io.kotest.assertions.assertSoftly +import io.kotest.matchers.collections.shouldContainExactly +import io.kotest.matchers.collections.shouldContainExactlyInAnyOrder +import io.kotest.matchers.nulls.shouldNotBeNull +import io.kotest.matchers.shouldBe +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.async +import kotlinx.coroutines.awaitAll +import kotlinx.coroutines.withContext +import org.springframework.data.repository.findByIdOrNull +import java.util.concurrent.CountDownLatch + +class ScheduleConcurrencyTest( + private val scheduleRepository: ScheduleRepository +) : FunSpecSpringbootTest() { + init { + test("하나의 ${ScheduleStatus.AVAILABLE}인 일정에 대한 동시 HOLD 변경 요청이 들어오면, 가장 먼저 들어온 요청만 성공한다.") { + val user = testAuthUtil.defaultUserLogin() + val schedule = dummyInitializer.createSchedule() + + withContext(Dispatchers.IO) { + val jobSize = 64 + val latch = CountDownLatch(jobSize) + + (1..jobSize).map { + async { + latch.countDown() + latch.await() + + runTest( + token = user.second, + on = { + post("/schedules/${schedule.id}/hold") + }, + expect = { + } + ).extract().statusCode() + } + }.awaitAll().also { + it.count { statusCode -> statusCode == HttpStatus.OK.value() } shouldBe 1 + it.count { statusCode -> statusCode == ScheduleErrorCode.SCHEDULE_NOT_AVAILABLE.httpStatus.value() } shouldBe (jobSize - 1) + } + } + + assertSoftly(scheduleRepository.findByIdOrNull(schedule.id)!!) { + this.status shouldBe ScheduleStatus.HOLD + this.holdExpiredAt.shouldNotBeNull() + } + } + } +} diff --git a/service/src/test/kotlin/com/sangdol/roomescape/schedule/ScheduleServiceTest.kt b/service/src/test/kotlin/com/sangdol/roomescape/schedule/ScheduleServiceTest.kt new file mode 100644 index 00000000..1440e7fb --- /dev/null +++ b/service/src/test/kotlin/com/sangdol/roomescape/schedule/ScheduleServiceTest.kt @@ -0,0 +1,119 @@ +package com.sangdol.roomescape.schedule + +import com.sangdol.common.utils.MdcPrincipalIdUtil +import com.sangdol.roomescape.schedule.business.ScheduleService +import com.sangdol.roomescape.schedule.infrastructure.persistence.ScheduleRepository +import com.sangdol.roomescape.schedule.infrastructure.persistence.ScheduleStatus +import com.sangdol.roomescape.schedule.web.ScheduleCreateRequest +import com.sangdol.roomescape.schedule.web.ScheduleUpdateRequest +import com.sangdol.roomescape.supports.FunSpecSpringbootTest +import com.sangdol.roomescape.supports.IDGenerator +import com.sangdol.roomescape.supports.initialize +import io.kotest.assertions.assertSoftly +import io.kotest.matchers.nulls.shouldBeNull +import io.kotest.matchers.nulls.shouldNotBeNull +import io.kotest.matchers.shouldBe +import io.kotest.matchers.shouldNotBe +import org.springframework.data.repository.findByIdOrNull +import java.time.LocalDate +import java.time.LocalTime + +class ScheduleServiceTest( + private val scheduleService: ScheduleService, + private val scheduleRepository: ScheduleRepository +) : FunSpecSpringbootTest() { + init { + afterTest { + MdcPrincipalIdUtil.clear() + } + + test("관리자가 일정을 수정하면 updatedAt, updatedBy 컬럼이 변경된다. ") { + val createdScheduleId = createSchedule() + + initialize("updatedBy 변경 확인을 위한 MDC 재설정") { + MdcPrincipalIdUtil.clear() + IDGenerator.create().also { + MdcPrincipalIdUtil.set(it.toString()) + } + } + + scheduleService.updateSchedule( + createdScheduleId, + ScheduleUpdateRequest(status = ScheduleStatus.RESERVED) + ) + + assertSoftly(scheduleRepository.findByIdOrNull(createdScheduleId)) { + this.shouldNotBeNull() + this.updatedAt shouldNotBe this.createdAt + this.updatedBy shouldNotBe this.createdBy + } + } + + test("유저가 일정을 Holding 상태로 변경하는 경우에는 holdExpiredAt 컬럼이 5분 뒤로 지정되며, updatedAt, updatedBy 컬럼이 변경되지 않는다.") { + val createdScheduleId: Long = createSchedule() + + initialize("updatedBy 변경 확인을 위한 MDC 재설정") { + MdcPrincipalIdUtil.clear() + IDGenerator.create().also { + MdcPrincipalIdUtil.set(it.toString()) + } + } + + scheduleService.holdSchedule(createdScheduleId) + + assertSoftly(scheduleRepository.findByIdOrNull(createdScheduleId)) { + this.shouldNotBeNull() + this.updatedAt shouldBe this.createdAt + this.updatedBy shouldBe this.createdBy + this.holdExpiredAt.shouldNotBeNull() + } + } + + test("유저가 일정을 Holding이 아닌 다른 상태로 변경 경우에는 holdExpiredAt 컬럼이 null로 지정되며, updatedAt, updatedBy 컬럼이 변경되지 않는다.") { + val createdScheduleId: Long = createSchedule() + + initialize("updatedBy 변경 확인을 위한 MDC 재설정") { + MdcPrincipalIdUtil.clear() + IDGenerator.create().also { + MdcPrincipalIdUtil.set(it.toString()) + } + } + + scheduleService.holdSchedule(createdScheduleId).also { + scheduleRepository.findByIdOrNull(createdScheduleId)!!.holdExpiredAt.shouldNotBeNull() + } + + scheduleService.changeStatus(createdScheduleId, ScheduleStatus.HOLD, ScheduleStatus.RESERVED).also { + assertSoftly(scheduleRepository.findByIdOrNull(createdScheduleId)!!) { + this.updatedAt shouldBe this.createdAt + this.updatedBy shouldBe this.createdBy + this.holdExpiredAt.shouldBeNull() + } + } + } + } + + fun createSchedule(): Long { + initialize("createdBy, updatedBy 설정을 위한 MDC 설정") { + IDGenerator.create().also { + MdcPrincipalIdUtil.set(it.toString()) + } + } + + val (storeId, themeId) = initialize("FK 제약조건 해소를 위한 매장, 테마 생성") { + val store = dummyInitializer.createStore() + val theme = dummyInitializer.createTheme() + + store.id to theme.id + } + + return scheduleService.createSchedule( + storeId = storeId, + request = ScheduleCreateRequest( + date = LocalDate.now().plusDays(1), + time = LocalTime.now(), + themeId = themeId + ) + ).id + } +} diff --git a/service/src/test/kotlin/com/sangdol/roomescape/supports/TestDatabaseUtil.kt b/service/src/test/kotlin/com/sangdol/roomescape/supports/TestDatabaseUtil.kt index 60e4d851..74f9285e 100644 --- a/service/src/test/kotlin/com/sangdol/roomescape/supports/TestDatabaseUtil.kt +++ b/service/src/test/kotlin/com/sangdol/roomescape/supports/TestDatabaseUtil.kt @@ -23,22 +23,26 @@ class TestDatabaseUtil( } fun initializeRegion() { - this::class.java.getResource("/schema/region-data.sql")?.readText()?.let { - jdbcTemplate.execute(it) + jdbcTemplate.queryForObject("SELECT EXISTS (SELECT 1 FROM region LIMIT 1)", Boolean::class.java)!!.also { isRegionTableEmpty -> + if (!isRegionTableEmpty) { + this::class.java.getResource("/schema/region-data.sql")?.readText()?.let { regionInsertSql -> + jdbcTemplate.execute(regionInsertSql) + } + } } } fun clear(mode: CleanerMode) { entityManager.clear() - jdbcTemplate.execute("SET REFERENTIAL_INTEGRITY FALSE") + jdbcTemplate.execute("SET FOREIGN_KEY_CHECKS = 0") tables.forEach { if (mode == CleanerMode.EXCEPT_REGION && it == "region") { return@forEach } - jdbcTemplate.execute("TRUNCATE TABLE $it RESTART IDENTITY") + jdbcTemplate.execute("TRUNCATE TABLE $it") } - jdbcTemplate.execute("SET REFERENTIAL_INTEGRITY TRUE") + jdbcTemplate.execute("SET FOREIGN_KEY_CHECKS = 1") } } diff --git a/service/src/test/resources/application-test-mysql.yaml b/service/src/test/resources/application-data.yaml similarity index 100% rename from service/src/test/resources/application-test-mysql.yaml rename to service/src/test/resources/application-data.yaml diff --git a/service/src/test/resources/application-test.yaml b/service/src/test/resources/application-test.yaml index 914bb277..ae8ee583 100644 --- a/service/src/test/resources/application-test.yaml +++ b/service/src/test/resources/application-test.yaml @@ -10,14 +10,14 @@ spring: ddl-auto: validate datasource: hikari: - jdbc-url: jdbc:h2:mem:test - driver-class-name: org.h2.Driver - username: sa - password: + jdbc-url: jdbc:mysql://localhost:23306/roomescape_local + driver-class-name: com.mysql.cj.jdbc.Driver + username: root + password: init sql: init: mode: always - schema-locations: classpath:schema/schema-h2.sql + schema-locations: classpath:schema/schema-mysql.sql security: jwt: