generated from pricelees/issue-pr-template
<!-- 제목 양식 --> <!-- [이슈번호] 작업 요약 (예시: [#10] Gitea 템플릿 생성) --> ## 📝 관련 이슈 및 PR **PR과 관련된 이슈 번호** - #52 ## ✨ 작업 내용 <!-- 어떤 작업을 했는지 알려주세요! --> - 예약 페이지에서 일정 조회시, 현 시간 이후부터 조회되도록 수정 - 사용자의 schedule 수정은 \@LastModified JPA Auditing이 적용되지 않도록 UPDATE 쿼리를 바로 전송하도록 수정 - 매 1분마다 Pending 예약이 되지 않은 일정, 결제가 되지 않은 Pending 예약 만료 처리 스케쥴링 작업 추가 - 스케쥴링 작업으로 인해 발생할 수 있는 'Pending 예약은 생성했으나 해당 일정이 재활성화' 되는 문제 해결을 위해 schedule 조회에 pessimistic lock 적용 ## 🧪 테스트 <!-- 어떤 테스트를 생각했고 진행했는지 알려주세요! --> - LocalTime.plusHours()로 인해 특정 시간대 이후로는 실패하는 테스트 수정 - Pessimistic Lock 적용 후 해당 문제 상황 동시성 테스트 추가 - 하나의 일정에 대한 동시 HOLD 요청 상황 테스트 추가 ## 📚 참고 자료 및 기타 <!-- 참고한 자료, 또는 논의할 사항이 있다면 알려주세요! --> Reviewed-on: #53 Co-authored-by: pricelees <priceelees@gmail.com> Co-committed-by: pricelees <priceelees@gmail.com>
This commit is contained in:
parent
d056e12278
commit
186d6e118c
14
docker/docker-compose.yaml
Normal file
14
docker/docker-compose.yaml
Normal file
@ -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
|
||||
@ -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] 데이터베이스 초기화 완료" }
|
||||
}
|
||||
}
|
||||
@ -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)
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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}개의 예약 및 일정 처리 완료" }
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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", "참여 가능 인원 수를 확인해주세요.")
|
||||
;
|
||||
}
|
||||
|
||||
@ -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<ReservationEntity, Long> {
|
||||
|
||||
fun findAllByUserIdAndStatusIsIn(userId: Long, statuses: List<ReservationStatus>): List<ReservationEntity>
|
||||
|
||||
@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
|
||||
}
|
||||
|
||||
@ -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<ScheduleOverview> =
|
||||
scheduleRepository.findStoreSchedulesWithThemeByDate(storeId, date)
|
||||
.filter { it.date.isAfter(date) || (it.date.isEqual(date) && it.time.isAfter(LocalTime.now())) }
|
||||
|
||||
return schedules.toResponse()
|
||||
.also {
|
||||
@ -58,16 +66,22 @@ 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 변경 완료" }
|
||||
}
|
||||
|
||||
if (result == 0) {
|
||||
throw ScheduleException(ScheduleErrorCode.SCHEDULE_NOT_AVAILABLE)
|
||||
}
|
||||
|
||||
log.info { "[ScheduleService.holdSchedule] 일정 Holding 완료: id=$id" }
|
||||
}
|
||||
|
||||
// ========================================
|
||||
// All-Admin (본사, 매장 모두 사용가능)
|
||||
// ========================================
|
||||
@ -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 (공통 메서드)
|
||||
// ========================================
|
||||
|
||||
@ -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
|
||||
|
||||
@ -1,3 +1,5 @@
|
||||
package com.sangdol.roomescape.schedule.exception
|
||||
|
||||
import com.sangdol.common.types.exception.ErrorCode
|
||||
import com.sangdol.common.types.exception.RoomescapeException
|
||||
|
||||
|
||||
@ -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() }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -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<ScheduleEntity, Long> {
|
||||
|
||||
@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<ScheduleEntity, Long> {
|
||||
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<ScheduleEntity, Long> {
|
||||
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<ScheduleEntity, Long> {
|
||||
"""
|
||||
)
|
||||
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
|
||||
}
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -124,6 +124,7 @@ create table if not exists schedule (
|
||||
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),
|
||||
|
||||
@ -124,10 +124,13 @@ create table if not exists schedule (
|
||||
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 (
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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))
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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("삭제를 위한 일정 생성") {
|
||||
|
||||
@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -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:
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user