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.business.ScheduleService
|
||||||
import com.sangdol.roomescape.schedule.infrastructure.persistence.ScheduleStatus
|
import com.sangdol.roomescape.schedule.infrastructure.persistence.ScheduleStatus
|
||||||
import com.sangdol.roomescape.schedule.web.ScheduleOverviewResponse
|
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.theme.business.ThemeService
|
||||||
import com.sangdol.roomescape.user.business.UserService
|
import com.sangdol.roomescape.user.business.UserService
|
||||||
import com.sangdol.roomescape.user.web.UserContactResponse
|
import com.sangdol.roomescape.user.web.UserContactResponse
|
||||||
@ -58,9 +57,10 @@ class ReservationService(
|
|||||||
|
|
||||||
run {
|
run {
|
||||||
reservation.confirm()
|
reservation.confirm()
|
||||||
scheduleService.updateSchedule(
|
scheduleService.changeStatus(
|
||||||
reservation.scheduleId,
|
scheduleId = reservation.scheduleId,
|
||||||
ScheduleUpdateRequest(status = ScheduleStatus.RESERVED)
|
currentStatus = ScheduleStatus.HOLD,
|
||||||
|
changeStatus = ScheduleStatus.RESERVED
|
||||||
)
|
)
|
||||||
}.also {
|
}.also {
|
||||||
log.info { "[ReservationService.confirmReservation] Pending 예약 확정 완료: reservationId=${id}" }
|
log.info { "[ReservationService.confirmReservation] Pending 예약 확정 완료: reservationId=${id}" }
|
||||||
@ -74,9 +74,10 @@ class ReservationService(
|
|||||||
val reservation: ReservationEntity = findOrThrow(reservationId)
|
val reservation: ReservationEntity = findOrThrow(reservationId)
|
||||||
|
|
||||||
run {
|
run {
|
||||||
scheduleService.updateSchedule(
|
scheduleService.changeStatus(
|
||||||
reservation.scheduleId,
|
scheduleId = reservation.scheduleId,
|
||||||
ScheduleUpdateRequest(status = ScheduleStatus.AVAILABLE)
|
currentStatus = ScheduleStatus.RESERVED,
|
||||||
|
changeStatus = ScheduleStatus.AVAILABLE
|
||||||
)
|
)
|
||||||
saveCanceledReservation(user, reservation, request.cancelReason)
|
saveCanceledReservation(user, reservation, request.cancelReason)
|
||||||
reservation.cancel()
|
reservation.cancel()
|
||||||
@ -148,11 +149,12 @@ class ReservationService(
|
|||||||
status = CanceledReservationStatus.COMPLETED
|
status = CanceledReservationStatus.COMPLETED
|
||||||
).also {
|
).also {
|
||||||
canceledReservationRepository.save(it)
|
canceledReservationRepository.save(it)
|
||||||
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun validateCanCreate(request: PendingReservationCreateRequest) {
|
private fun validateCanCreate(request: PendingReservationCreateRequest) {
|
||||||
val schedule = scheduleService.findSummaryById(request.scheduleId)
|
val schedule = scheduleService.findSummaryWithLock(request.scheduleId)
|
||||||
val theme = themeService.findInfoById(schedule.themeId)
|
val theme = themeService.findInfoById(schedule.themeId)
|
||||||
|
|
||||||
reservationValidator.validateCanCreate(schedule, theme, request)
|
reservationValidator.validateCanCreate(schedule, theme, request)
|
||||||
|
|||||||
@ -21,8 +21,8 @@ class ReservationValidator {
|
|||||||
request: PendingReservationCreateRequest
|
request: PendingReservationCreateRequest
|
||||||
) {
|
) {
|
||||||
if (schedule.status != ScheduleStatus.HOLD) {
|
if (schedule.status != ScheduleStatus.HOLD) {
|
||||||
log.warn { "[ReservationValidator.validateCanCreate] ${schedule.status}인 일정으로 인한 예약 실패" }
|
log.warn { "[ReservationValidator.validateCanCreate] ${schedule.status}로의 일정 상태 변경에 따른 실패" }
|
||||||
throw ReservationException(ReservationErrorCode.SCHEDULE_NOT_HOLD)
|
throw ReservationException(ReservationErrorCode.EXPIRED_HELD_SCHEDULE)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (theme.minParticipants > request.participantCount) {
|
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", "예약을 찾을 수 없어요."),
|
RESERVATION_NOT_FOUND(HttpStatus.NOT_FOUND, "R001", "예약을 찾을 수 없어요."),
|
||||||
NO_PERMISSION_TO_CANCEL_RESERVATION(HttpStatus.FORBIDDEN, "R002", "예약을 취소할 수 있는 권한이 없어요."),
|
NO_PERMISSION_TO_CANCEL_RESERVATION(HttpStatus.FORBIDDEN, "R002", "예약을 취소할 수 있는 권한이 없어요."),
|
||||||
INVALID_SEARCH_DATE_RANGE(HttpStatus.BAD_REQUEST, "R003", "종료 날짜는 시작 날짜 이후여야 해요."),
|
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", "참여 가능 인원 수를 확인해주세요.")
|
INVALID_PARTICIPANT_COUNT(HttpStatus.BAD_REQUEST, "R005", "참여 가능 인원 수를 확인해주세요.")
|
||||||
;
|
;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,8 +1,28 @@
|
|||||||
package com.sangdol.roomescape.reservation.infrastructure.persistence
|
package com.sangdol.roomescape.reservation.infrastructure.persistence
|
||||||
|
|
||||||
import org.springframework.data.jpa.repository.JpaRepository
|
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> {
|
interface ReservationRepository : JpaRepository<ReservationEntity, Long> {
|
||||||
|
|
||||||
fun findAllByUserIdAndStatusIsIn(userId: Long, statuses: List<ReservationStatus>): List<ReservationEntity>
|
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
|
package com.sangdol.roomescape.schedule.business
|
||||||
|
|
||||||
import ScheduleException
|
|
||||||
import com.sangdol.common.persistence.IDGenerator
|
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.AuditingInfo
|
||||||
import com.sangdol.roomescape.common.types.Auditor
|
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.business.domain.ScheduleOverview
|
||||||
import com.sangdol.roomescape.schedule.exception.ScheduleErrorCode
|
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.ScheduleEntity
|
||||||
import com.sangdol.roomescape.schedule.infrastructure.persistence.ScheduleEntityFactory
|
import com.sangdol.roomescape.schedule.infrastructure.persistence.ScheduleEntityFactory
|
||||||
import com.sangdol.roomescape.schedule.infrastructure.persistence.ScheduleRepository
|
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.stereotype.Service
|
||||||
import org.springframework.transaction.annotation.Transactional
|
import org.springframework.transaction.annotation.Transactional
|
||||||
import java.time.LocalDate
|
import java.time.LocalDate
|
||||||
|
import java.time.LocalTime
|
||||||
|
|
||||||
private val log: KLogger = KotlinLogging.logger {}
|
private val log: KLogger = KotlinLogging.logger {}
|
||||||
|
|
||||||
@ -43,9 +44,16 @@ class ScheduleService(
|
|||||||
@Transactional(readOnly = true)
|
@Transactional(readOnly = true)
|
||||||
fun getStoreScheduleByDate(storeId: Long, date: LocalDate): ScheduleWithThemeListResponse {
|
fun getStoreScheduleByDate(storeId: Long, date: LocalDate): ScheduleWithThemeListResponse {
|
||||||
log.info { "[ScheduleService.getStoreScheduleByDate] 매장 일정 조회: storeId=${storeId}, date=$date" }
|
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> =
|
val schedules: List<ScheduleOverview> =
|
||||||
scheduleRepository.findStoreSchedulesWithThemeByDate(storeId, date)
|
scheduleRepository.findStoreSchedulesWithThemeByDate(storeId, date)
|
||||||
|
.filter { it.date.isAfter(date) || (it.date.isEqual(date) && it.time.isAfter(LocalTime.now())) }
|
||||||
|
|
||||||
return schedules.toResponse()
|
return schedules.toResponse()
|
||||||
.also {
|
.also {
|
||||||
@ -58,14 +66,20 @@ class ScheduleService(
|
|||||||
// ========================================
|
// ========================================
|
||||||
@Transactional
|
@Transactional
|
||||||
fun holdSchedule(id: Long) {
|
fun holdSchedule(id: Long) {
|
||||||
val schedule: ScheduleEntity = findOrThrow(id)
|
log.info { "[ScheduleService.holdSchedule] 일정 Holding 시작: id=$id" }
|
||||||
|
val result: Int = scheduleRepository.changeStatus(
|
||||||
if (schedule.status == ScheduleStatus.AVAILABLE) {
|
id = id,
|
||||||
schedule.hold()
|
currentStatus = ScheduleStatus.AVAILABLE,
|
||||||
return
|
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 없이 다른 서비스에서 호출)
|
// Other-Service (API 없이 다른 서비스에서 호출)
|
||||||
// ========================================
|
// ========================================
|
||||||
@Transactional(readOnly = true)
|
@Transactional(readOnly = true)
|
||||||
fun findSummaryById(id: Long): ScheduleSummaryResponse {
|
fun findSummaryWithLock(id: Long): ScheduleSummaryResponse {
|
||||||
log.info { "[ScheduleService.findDateTimeById] 일정 개요 조회 시작 : id=$id" }
|
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 {
|
.also {
|
||||||
log.info { "[ScheduleService.findDateTimeById] 일정 개요 조회 완료: id=$id" }
|
log.info { "[ScheduleService.findDateTimeById] 일정 개요 조회 완료: id=$id" }
|
||||||
}
|
}
|
||||||
@ -180,6 +200,15 @@ class ScheduleService(
|
|||||||
return overview.toOverviewResponse()
|
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 (공통 메서드)
|
// Common (공통 메서드)
|
||||||
// ========================================
|
// ========================================
|
||||||
|
|||||||
@ -1,15 +1,15 @@
|
|||||||
package com.sangdol.roomescape.schedule.business
|
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.ScheduleErrorCode
|
||||||
|
import com.sangdol.roomescape.schedule.exception.ScheduleException
|
||||||
import com.sangdol.roomescape.schedule.infrastructure.persistence.ScheduleEntity
|
import com.sangdol.roomescape.schedule.infrastructure.persistence.ScheduleEntity
|
||||||
import com.sangdol.roomescape.schedule.infrastructure.persistence.ScheduleRepository
|
import com.sangdol.roomescape.schedule.infrastructure.persistence.ScheduleRepository
|
||||||
import com.sangdol.roomescape.schedule.infrastructure.persistence.ScheduleStatus
|
import com.sangdol.roomescape.schedule.infrastructure.persistence.ScheduleStatus
|
||||||
import com.sangdol.roomescape.schedule.web.ScheduleCreateRequest
|
import com.sangdol.roomescape.schedule.web.ScheduleCreateRequest
|
||||||
import com.sangdol.roomescape.schedule.web.ScheduleUpdateRequest
|
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.LocalDate
|
||||||
import java.time.LocalDateTime
|
import java.time.LocalDateTime
|
||||||
import java.time.LocalTime
|
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.ErrorCode
|
||||||
import com.sangdol.common.types.exception.RoomescapeException
|
import com.sangdol.common.types.exception.RoomescapeException
|
||||||
|
|
||||||
|
|||||||
@ -1,11 +1,7 @@
|
|||||||
package com.sangdol.roomescape.schedule.infrastructure.persistence
|
package com.sangdol.roomescape.schedule.infrastructure.persistence
|
||||||
|
|
||||||
import com.sangdol.common.persistence.PersistableBaseEntity
|
import com.sangdol.common.persistence.AuditingBaseEntity
|
||||||
import com.sangdol.common.utils.MdcPrincipalIdUtil
|
|
||||||
import jakarta.persistence.*
|
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 org.springframework.data.jpa.domain.support.AuditingEntityListener
|
||||||
import java.time.LocalDate
|
import java.time.LocalDate
|
||||||
import java.time.LocalDateTime
|
import java.time.LocalDateTime
|
||||||
@ -24,36 +20,14 @@ class ScheduleEntity(
|
|||||||
|
|
||||||
@Enumerated(value = EnumType.STRING)
|
@Enumerated(value = EnumType.STRING)
|
||||||
var status: ScheduleStatus,
|
var status: ScheduleStatus,
|
||||||
) : PersistableBaseEntity(id) {
|
var holdExpiredAt: LocalDateTime? = null
|
||||||
@Column(updatable = false)
|
) : AuditingBaseEntity(id) {
|
||||||
@CreatedDate
|
|
||||||
lateinit var createdAt: LocalDateTime
|
|
||||||
|
|
||||||
@Column(updatable = false)
|
|
||||||
@CreatedBy
|
|
||||||
var createdBy: Long = 0L
|
|
||||||
|
|
||||||
@Column
|
|
||||||
@LastModifiedDate
|
|
||||||
lateinit var updatedAt: LocalDateTime
|
|
||||||
|
|
||||||
var updatedBy: Long = 0L
|
|
||||||
|
|
||||||
fun modifyIfNotNull(
|
fun modifyIfNotNull(
|
||||||
time: LocalTime?,
|
time: LocalTime?,
|
||||||
status: ScheduleStatus?
|
status: ScheduleStatus?
|
||||||
) {
|
) {
|
||||||
time?.let { this.time = it }
|
time?.let { this.time = it }
|
||||||
status?.let { this.status = 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,
|
storeId = storeId,
|
||||||
themeId = themeId,
|
themeId = themeId,
|
||||||
status = ScheduleStatus.AVAILABLE
|
status = ScheduleStatus.AVAILABLE
|
||||||
).apply { this.updateLastModifiedBy() }
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -1,13 +1,29 @@
|
|||||||
package com.sangdol.roomescape.schedule.infrastructure.persistence
|
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 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.LocalDate
|
||||||
|
import java.time.LocalDateTime
|
||||||
import java.time.LocalTime
|
import java.time.LocalTime
|
||||||
|
|
||||||
interface ScheduleRepository : JpaRepository<ScheduleEntity, Long> {
|
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(
|
@Query(
|
||||||
"""
|
"""
|
||||||
SELECT
|
SELECT
|
||||||
@ -17,8 +33,8 @@ interface ScheduleRepository : JpaRepository<ScheduleEntity, Long> {
|
|||||||
WHERE
|
WHERE
|
||||||
s.storeId = :storeId
|
s.storeId = :storeId
|
||||||
AND s.date = :date
|
AND s.date = :date
|
||||||
AND s.themeId = :themeId
|
|
||||||
AND s.time = :time
|
AND s.time = :time
|
||||||
|
AND s.themeId = :themeId
|
||||||
"""
|
"""
|
||||||
)
|
)
|
||||||
fun existsDuplicate(storeId: Long, date: LocalDate, themeId: Long, time: LocalTime): Boolean
|
fun existsDuplicate(storeId: Long, date: LocalDate, themeId: Long, time: LocalTime): Boolean
|
||||||
@ -41,11 +57,13 @@ interface ScheduleRepository : JpaRepository<ScheduleEntity, Long> {
|
|||||||
FROM
|
FROM
|
||||||
ScheduleEntity s
|
ScheduleEntity s
|
||||||
JOIN
|
JOIN
|
||||||
ThemeEntity t ON t._id = s.themeId and (:themeId IS NULL OR t._id = :themeId)
|
ThemeEntity t ON t._id = s.themeId
|
||||||
JOIN
|
JOIN
|
||||||
StoreEntity st ON st._id = s.storeId and st._id = :storeId
|
StoreEntity st ON st._id = s.storeId
|
||||||
WHERE
|
WHERE
|
||||||
s.date = :date
|
s.storeId = :storeId
|
||||||
|
AND s.date = :date
|
||||||
|
AND (:themeId IS NULL OR s.themeId = :themeId)
|
||||||
"""
|
"""
|
||||||
)
|
)
|
||||||
fun findStoreSchedulesWithThemeByDate(
|
fun findStoreSchedulesWithThemeByDate(
|
||||||
@ -80,4 +98,44 @@ interface ScheduleRepository : JpaRepository<ScheduleEntity, Long> {
|
|||||||
"""
|
"""
|
||||||
)
|
)
|
||||||
fun findOverviewByIdOrNull(id: Long): ScheduleOverview?
|
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
|
format_sql: true
|
||||||
hibernate:
|
hibernate:
|
||||||
ddl-auto: validate
|
ddl-auto: validate
|
||||||
h2:
|
|
||||||
console:
|
|
||||||
enabled: true
|
|
||||||
path: /h2-console
|
|
||||||
datasource:
|
datasource:
|
||||||
hikari:
|
hikari:
|
||||||
jdbc-url: jdbc:h2:mem:database
|
jdbc-url: jdbc:mysql://localhost:23306/roomescape_local
|
||||||
driver-class-name: org.h2.Driver
|
driver-class-name: com.mysql.cj.jdbc.Driver
|
||||||
username: sa
|
username: root
|
||||||
password:
|
password: init
|
||||||
sql:
|
sql:
|
||||||
init:
|
init:
|
||||||
mode: always
|
mode: always
|
||||||
schema-locations: classpath:schema/schema-h2.sql
|
schema-locations: classpath:schema/schema-mysql.sql
|
||||||
data-locations: classpath:schema/region-data.sql
|
data-locations: classpath:schema/region-data.sql
|
||||||
|
|
||||||
security:
|
security:
|
||||||
|
|||||||
@ -114,16 +114,17 @@ create table if not exists theme (
|
|||||||
);
|
);
|
||||||
|
|
||||||
create table if not exists schedule (
|
create table if not exists schedule (
|
||||||
id bigint primary key,
|
id bigint primary key,
|
||||||
date date not null,
|
date date not null,
|
||||||
time time not null,
|
time time not null,
|
||||||
store_id bigint not null,
|
store_id bigint not null,
|
||||||
theme_id bigint not null,
|
theme_id bigint not null,
|
||||||
status varchar(30) not null,
|
status varchar(30) not null,
|
||||||
created_at timestamp not null,
|
created_at timestamp not null,
|
||||||
created_by bigint not null,
|
created_by bigint not null,
|
||||||
updated_at timestamp not null,
|
updated_at timestamp not null,
|
||||||
updated_by bigint 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 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__store_id foreign key (store_id) references store (id),
|
||||||
|
|||||||
@ -114,20 +114,23 @@ create table if not exists theme (
|
|||||||
);
|
);
|
||||||
|
|
||||||
create table if not exists schedule (
|
create table if not exists schedule (
|
||||||
id bigint primary key,
|
id bigint primary key,
|
||||||
date date not null,
|
date date not null,
|
||||||
time time not null,
|
time time not null,
|
||||||
store_id bigint not null,
|
store_id bigint not null,
|
||||||
theme_id bigint not null,
|
theme_id bigint not null,
|
||||||
status varchar(30) not null,
|
status varchar(30) not null,
|
||||||
created_at datetime(6) not null,
|
created_at datetime(6) not null,
|
||||||
created_by bigint not null,
|
created_by bigint not null,
|
||||||
updated_at datetime(6) not null,
|
updated_at datetime(6) not null,
|
||||||
updated_by bigint 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 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__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 (
|
create table if not exists reservation (
|
||||||
@ -145,7 +148,9 @@ create table if not exists reservation (
|
|||||||
updated_by bigint not null,
|
updated_by bigint not null,
|
||||||
|
|
||||||
constraint fk_reservation__user_id foreign key (user_id) references users (id),
|
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 (
|
create table if not exists canceled_reservation (
|
||||||
|
|||||||
@ -1,10 +1,10 @@
|
|||||||
package com.sangdol.data
|
package com.sangdol.data
|
||||||
|
|
||||||
import com.sangdol.common.persistence.IDGenerator
|
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.AdminEntity
|
||||||
import com.sangdol.roomescape.admin.infrastructure.persistence.AdminPermissionLevel
|
import com.sangdol.roomescape.admin.infrastructure.persistence.AdminPermissionLevel
|
||||||
import com.sangdol.roomescape.admin.infrastructure.persistence.AdminType
|
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.payment.infrastructure.common.*
|
||||||
import com.sangdol.roomescape.reservation.infrastructure.persistence.ReservationStatus
|
import com.sangdol.roomescape.reservation.infrastructure.persistence.ReservationStatus
|
||||||
import com.sangdol.roomescape.schedule.infrastructure.persistence.ScheduleStatus
|
import com.sangdol.roomescape.schedule.infrastructure.persistence.ScheduleStatus
|
||||||
@ -32,7 +32,7 @@ import java.time.LocalDateTime
|
|||||||
import java.time.LocalTime
|
import java.time.LocalTime
|
||||||
import java.time.OffsetDateTime
|
import java.time.OffsetDateTime
|
||||||
|
|
||||||
@ActiveProfiles("test", "test-mysql")
|
@ActiveProfiles("test", "data")
|
||||||
abstract class AbstractDataInitializer(
|
abstract class AbstractDataInitializer(
|
||||||
val semaphore: Semaphore = Semaphore(permits = 10),
|
val semaphore: Semaphore = Semaphore(permits = 10),
|
||||||
) : FunSpecSpringbootTest(
|
) : 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)
|
post(endpoint)
|
||||||
},
|
},
|
||||||
expect = {
|
expect = {
|
||||||
statusCode(HttpStatus.BAD_REQUEST.value())
|
statusCode(HttpStatus.CONFLICT.value())
|
||||||
body("code", equalTo(ReservationErrorCode.SCHEDULE_NOT_HOLD.errorCode))
|
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 = {
|
expect = {
|
||||||
statusCode(HttpStatus.OK.value())
|
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(
|
assertProperties(
|
||||||
props = setOf("id", "themeName", "startFrom", "endAt", "status"),
|
props = setOf("id", "themeName", "startFrom", "endAt", "status"),
|
||||||
propsNameIfList = "schedules"
|
propsNameIfList = "schedules"
|
||||||
@ -114,7 +114,7 @@ class AdminScheduleApiTest(
|
|||||||
},
|
},
|
||||||
expect = {
|
expect = {
|
||||||
statusCode(HttpStatus.OK.value())
|
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(
|
assertProperties(
|
||||||
props = setOf("id", "themeName", "startFrom", "endAt", "status"),
|
props = setOf("id", "themeName", "startFrom", "endAt", "status"),
|
||||||
propsNameIfList = "schedules"
|
propsNameIfList = "schedules"
|
||||||
@ -153,7 +153,7 @@ class AdminScheduleApiTest(
|
|||||||
statusCode(HttpStatus.OK.value())
|
statusCode(HttpStatus.OK.value())
|
||||||
body(
|
body(
|
||||||
"data.schedules.size()",
|
"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(
|
assertProperties(
|
||||||
props = setOf("id", "themeName", "startFrom", "endAt", "status"),
|
props = setOf("id", "themeName", "startFrom", "endAt", "status"),
|
||||||
@ -390,7 +390,7 @@ class AdminScheduleApiTest(
|
|||||||
val time = LocalTime.now().minusMinutes(1)
|
val time = LocalTime.now().minusMinutes(1)
|
||||||
val theme = dummyInitializer.createTheme()
|
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(
|
runExceptionTest(
|
||||||
token = token,
|
token = token,
|
||||||
@ -412,18 +412,18 @@ class AdminScheduleApiTest(
|
|||||||
dummyInitializer.createSchedule(
|
dummyInitializer.createSchedule(
|
||||||
storeId = admin.storeId!!,
|
storeId = admin.storeId!!,
|
||||||
request = ScheduleFixture.createRequest.copy(
|
request = ScheduleFixture.createRequest.copy(
|
||||||
tomorrow,
|
date = tomorrow,
|
||||||
LocalTime.of(14, 0),
|
time = LocalTime.of(14, 0),
|
||||||
theme.id
|
themeId = theme.id
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
val request = initialize("내일 14:30에 시작하는 요청 객체") {
|
val request = initialize("내일 14:30에 시작하는 요청 객체") {
|
||||||
ScheduleFixture.createRequest.copy(
|
ScheduleFixture.createRequest.copy(
|
||||||
tomorrow,
|
date = tomorrow,
|
||||||
LocalTime.of(14, 30),
|
time = LocalTime.of(14, 30),
|
||||||
theme.id
|
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}인 일정은 삭제할 수 없다.") {
|
test("상태가 ${it}인 일정은 삭제할 수 없다.") {
|
||||||
val (admin, token) = testAuthUtil.defaultStoreAdminLogin()
|
val (admin, token) = testAuthUtil.defaultStoreAdminLogin()
|
||||||
val schedule = initialize("삭제를 위한 일정 생성") {
|
val schedule = initialize("삭제를 위한 일정 생성") {
|
||||||
|
|||||||
@ -1,9 +1,5 @@
|
|||||||
package com.sangdol.roomescape.schedule
|
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.common.types.web.HttpStatus
|
||||||
import com.sangdol.roomescape.admin.infrastructure.persistence.AdminPermissionLevel
|
import com.sangdol.roomescape.admin.infrastructure.persistence.AdminPermissionLevel
|
||||||
import com.sangdol.roomescape.admin.infrastructure.persistence.AdminType
|
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.ScheduleRepository
|
||||||
import com.sangdol.roomescape.schedule.infrastructure.persistence.ScheduleStatus
|
import com.sangdol.roomescape.schedule.infrastructure.persistence.ScheduleStatus
|
||||||
import com.sangdol.roomescape.supports.*
|
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.LocalDate
|
||||||
import java.time.LocalTime
|
import java.time.LocalTime
|
||||||
|
|
||||||
@ -20,29 +20,42 @@ class ScheduleApiTest(
|
|||||||
) : FunSpecSpringbootTest() {
|
) : FunSpecSpringbootTest() {
|
||||||
init {
|
init {
|
||||||
context("특정 매장 + 날짜의 일정 및 테마 정보를 조회한다.") {
|
context("특정 매장 + 날짜의 일정 및 테마 정보를 조회한다.") {
|
||||||
test("정상 응답") {
|
test("날짜가 당일이면 현재 시간 이후의 정보만 조회된다.") {
|
||||||
val size = 2
|
val size = 3
|
||||||
val date = LocalDate.now().plusDays(1)
|
val date = LocalDate.now()
|
||||||
val store = dummyInitializer.createStore()
|
val store = dummyInitializer.createStore()
|
||||||
initialize("조회를 위한 같은 날짜의 ${size}개의 일정 생성") {
|
|
||||||
|
initialize("조회를 위한 오늘 날짜의 현재 시간 이후인 ${size}개의 일정, 현재 시간 이전인 1개의 일정 생성") {
|
||||||
for (i in 1..size) {
|
for (i in 1..size) {
|
||||||
dummyInitializer.createSchedule(
|
dummyInitializer.createSchedule(
|
||||||
storeId = store.id,
|
storeId = store.id,
|
||||||
request = ScheduleFixture.createRequest.copy(
|
request = ScheduleFixture.createRequest.copy(
|
||||||
date = date,
|
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(
|
runTest(
|
||||||
on = {
|
on = {
|
||||||
get("/stores/${store.id}/schedules?date=${date}")
|
get("/stores/${store.id}/schedules?date=${date}")
|
||||||
},
|
},
|
||||||
expect = {
|
expect = {
|
||||||
statusCode(HttpStatus.OK.value())
|
statusCode(HttpStatus.OK.value())
|
||||||
body("data.schedules.size()", equalTo(size))
|
body("data.schedules.size()", equalTo(expectedSize))
|
||||||
assertProperties(
|
assertProperties(
|
||||||
props = setOf("id", "startFrom", "endAt", "themeId", "themeName", "themeDifficulty", "status"),
|
props = setOf("id", "startFrom", "endAt", "themeId", "themeName", "themeDifficulty", "status"),
|
||||||
propsNameIfList = "schedules"
|
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} 상태로 변경한다.") {
|
context("일정을 ${ScheduleStatus.HOLD} 상태로 변경한다.") {
|
||||||
@ -105,7 +134,7 @@ class ScheduleApiTest(
|
|||||||
|
|
||||||
context("일정이 ${ScheduleStatus.AVAILABLE}이 아니면 실패한다.") {
|
context("일정이 ${ScheduleStatus.AVAILABLE}이 아니면 실패한다.") {
|
||||||
(ScheduleStatus.entries - ScheduleStatus.AVAILABLE).forEach {
|
(ScheduleStatus.entries - ScheduleStatus.AVAILABLE).forEach {
|
||||||
test("${it}") {
|
test("$it") {
|
||||||
val schedule = dummyInitializer.createSchedule(status = it)
|
val schedule = dummyInitializer.createSchedule(status = it)
|
||||||
|
|
||||||
runExceptionTest(
|
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() {
|
fun initializeRegion() {
|
||||||
this::class.java.getResource("/schema/region-data.sql")?.readText()?.let {
|
jdbcTemplate.queryForObject("SELECT EXISTS (SELECT 1 FROM region LIMIT 1)", Boolean::class.java)!!.also { isRegionTableEmpty ->
|
||||||
jdbcTemplate.execute(it)
|
if (!isRegionTableEmpty) {
|
||||||
|
this::class.java.getResource("/schema/region-data.sql")?.readText()?.let { regionInsertSql ->
|
||||||
|
jdbcTemplate.execute(regionInsertSql)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun clear(mode: CleanerMode) {
|
fun clear(mode: CleanerMode) {
|
||||||
entityManager.clear()
|
entityManager.clear()
|
||||||
|
|
||||||
jdbcTemplate.execute("SET REFERENTIAL_INTEGRITY FALSE")
|
jdbcTemplate.execute("SET FOREIGN_KEY_CHECKS = 0")
|
||||||
tables.forEach {
|
tables.forEach {
|
||||||
if (mode == CleanerMode.EXCEPT_REGION && it == "region") {
|
if (mode == CleanerMode.EXCEPT_REGION && it == "region") {
|
||||||
return@forEach
|
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
|
ddl-auto: validate
|
||||||
datasource:
|
datasource:
|
||||||
hikari:
|
hikari:
|
||||||
jdbc-url: jdbc:h2:mem:test
|
jdbc-url: jdbc:mysql://localhost:23306/roomescape_local
|
||||||
driver-class-name: org.h2.Driver
|
driver-class-name: com.mysql.cj.jdbc.Driver
|
||||||
username: sa
|
username: root
|
||||||
password:
|
password: init
|
||||||
sql:
|
sql:
|
||||||
init:
|
init:
|
||||||
mode: always
|
mode: always
|
||||||
schema-locations: classpath:schema/schema-h2.sql
|
schema-locations: classpath:schema/schema-mysql.sql
|
||||||
|
|
||||||
security:
|
security:
|
||||||
jwt:
|
jwt:
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user