Compare commits

...

18 Commits

Author SHA1 Message Date
7af7186341 chore: 패키지 선언 누락 수정 2025-10-03 16:24:27 +09:00
3748df9351 refactor: mysql 스키마 파일에 인덱스 추가 2025-10-03 16:22:12 +09:00
a50cbbe43e refactor: schedule/hold API에서의 로깅 수정 및 동시성 테스트 추가 2025-10-03 16:00:37 +09:00
10318cab33 chore: 일부 테스트 클래스 패키지 이동 2025-10-03 15:26:00 +09:00
11000f3f3d test: 스케쥴링 작업으로 인해 발생 가능한 문제 시나리오 테스트 2025-10-03 15:25:35 +09:00
dbc6847877 refactor: Pending 예약 미생성 일정 처리 스케쥴링 작업에서 발생할 수 있는 문제 해결을 위한 일정 조회시 Pessimistic Lock 처리 2025-10-03 15:24:26 +09:00
0599409612 refactor: 스케쥴링 작업은 애플리케이션 시작 후 1분 뒤 부터 시작되도록 수정 2025-10-03 15:23:36 +09:00
49fa800ee6 refactor: 배치 작업으로 인해 Pending 예약 생성시 발생하는 예외 코드 및 메시지 수정 2025-10-02 22:16:30 +09:00
267b93bdca fix: 일정 조회 쿼리에서의 오타 수정(결과는 정상이고 동일하나 인덱스 활용 불가능) 2025-10-02 22:16:05 +09:00
e9c8e612fa fix: 시간이 00시를 넘어가 특정 시간 이후에는 실패하는 테스트 수정 2025-10-02 22:15:17 +09:00
99917df600 feat: 로컬 및 테스트 데이터베이스 mysql 이전 및 애플리케이션 종료시 테이블 truncate 처리 추가 2025-10-02 21:02:20 +09:00
173467821b feat: 로컬 데이터베이스 mysql 전환을 위한 컨테이너 compose 추가 2025-10-02 21:00:09 +09:00
a1621e2f65 feat: 완료되지 않은 예약 및 일정 처리 스케쥴러 도입 및 테스트 추가 2025-10-02 20:59:36 +09:00
459fb331ae feat: Schedule 상태 변경시 holdExpiredAt 처리 추가 및 기존 코드 반영 & 테스트 2025-10-02 15:43:11 +09:00
a8ed0de625 feat: Schedule이 Hold 상태일 때 만료시간에 해당되는 holdExpiredAt 컬럼 추가 2025-10-02 15:22:38 +09:00
08b9920ee3 refactor: ScheduleEntity에 \@LastModifiedBy 추가 및 회원이 사용하는 hold API는 Update 쿼리를 바로 쓰도록 하여 업데이트 방지 2025-10-02 13:30:30 +09:00
86a2459d8b refactor: AdminScheduleApiTest 내 일부 문법 수정 2025-10-02 13:22:01 +09:00
599ac071d7 refactor: 매장의 특정 날짜 일정 조회 로직 수정
- 이전 날짜 선택시 실패
- 오늘 날짜인 경우 현재 시간 이후인 일정만 반환
2025-10-02 12:15:46 +09:00
26 changed files with 681 additions and 128 deletions

View 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

View File

@ -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] 데이터베이스 초기화 완료" }
}
}

View File

@ -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)

View File

@ -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) {

View File

@ -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}개의 예약 및 일정 처리 완료" }
}
}
}

View File

@ -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", "참여 가능 인원 수를 확인해주세요.")
;
}

View File

@ -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
}

View File

@ -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 (공통 메서드)
// ========================================

View File

@ -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

View File

@ -1,3 +1,5 @@
package com.sangdol.roomescape.schedule.exception
import com.sangdol.common.types.exception.ErrorCode
import com.sangdol.common.types.exception.RoomescapeException

View File

@ -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() }
)
}
}

View File

@ -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
}

View File

@ -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:

View File

@ -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),

View File

@ -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 (

View File

@ -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(

View File

@ -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
}
}
}
}

View File

@ -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))
}
)
}

View File

@ -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
}
}
}
}

View File

@ -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("삭제를 위한 일정 생성") {

View File

@ -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
)
}
}
}
}

View File

@ -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()
}
}
}
}

View File

@ -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
}
}

View File

@ -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")
}
}

View File

@ -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: