[#41] 예약 스키마 재정의 #42

Merged
pricelees merged 41 commits from refactor/#41 into main 2025-09-09 00:43:39 +00:00
6 changed files with 134 additions and 24 deletions
Showing only changes of commit acfe787d5f - Show all commits

View File

@ -83,6 +83,18 @@ class ScheduleService(
} }
} }
@Transactional
fun holdSchedule(id: Long) {
val schedule: ScheduleEntity = findOrThrow(id)
if (schedule.status == ScheduleStatus.AVAILABLE) {
schedule.hold()
return
}
throw ScheduleException(ScheduleErrorCode.SCHEDULE_NOT_AVAILABLE)
}
@Transactional @Transactional
fun updateSchedule(id: Long, request: ScheduleUpdateRequest) { fun updateSchedule(id: Long, request: ScheduleUpdateRequest) {
log.info { "[ScheduleService.updateSchedule] 일정 수정 시작: id=$id, request=${request}" } log.info { "[ScheduleService.updateSchedule] 일정 수정 시작: id=$id, request=${request}" }

View File

@ -38,6 +38,19 @@ interface ScheduleAPI {
@RequestParam("themeId") themeId: Long @RequestParam("themeId") themeId: Long
): ResponseEntity<CommonApiResponse<ScheduleRetrieveListResponse>> ): ResponseEntity<CommonApiResponse<ScheduleRetrieveListResponse>>
@LoginRequired
@Operation(summary = "일정을 Hold 상태로 변경", tags = ["로그인이 필요한 API"])
@ApiResponses(
ApiResponse(
responseCode = "200",
description = "일정을 Hold 상태로 변경하여 중복 예약 방지",
useReturnTypeSchema = true
)
)
fun holdSchedule(
@PathVariable("id") id: Long
): ResponseEntity<CommonApiResponse<Unit>>
@Admin @Admin
@Operation(summary = "일정 상세 조회", tags = ["관리자 로그인이 필요한 API"]) @Operation(summary = "일정 상세 조회", tags = ["관리자 로그인이 필요한 API"])
@ApiResponses(ApiResponse(responseCode = "200", description = "감사 정보를 포함하여 일정 상세 조회", useReturnTypeSchema = true)) @ApiResponses(ApiResponse(responseCode = "200", description = "감사 정보를 포함하여 일정 상세 조회", useReturnTypeSchema = true))

View File

@ -12,4 +12,5 @@ enum class ScheduleErrorCode(
SCHEDULE_ALREADY_EXISTS(HttpStatus.CONFLICT, "S002", "이미 동일한 일정이 있어요."), SCHEDULE_ALREADY_EXISTS(HttpStatus.CONFLICT, "S002", "이미 동일한 일정이 있어요."),
PAST_DATE_TIME(HttpStatus.BAD_REQUEST, "S003", "과거 날짜와 시간은 선택할 수 없어요."), PAST_DATE_TIME(HttpStatus.BAD_REQUEST, "S003", "과거 날짜와 시간은 선택할 수 없어요."),
SCHEDULE_IN_USE(HttpStatus.CONFLICT, "S004", "예약이 진행중이거나 완료된 일정은 삭제할 수 없어요."), SCHEDULE_IN_USE(HttpStatus.CONFLICT, "S004", "예약이 진행중이거나 완료된 일정은 삭제할 수 없어요."),
SCHEDULE_NOT_AVAILABLE(HttpStatus.CONFLICT, "S005", "예약이 완료되었거나 예약할 수 없는 일정이에요.")
} }

View File

@ -29,8 +29,12 @@ class ScheduleEntity(
time?.let { this.time = it } time?.let { this.time = it }
status?.let { this.status = it } status?.let { this.status = it }
} }
fun hold() {
this.status = ScheduleStatus.HOLD
}
} }
enum class ScheduleStatus { enum class ScheduleStatus {
AVAILABLE, PENDING, RESERVED, BLOCKED AVAILABLE, HOLD, RESERVED, BLOCKED
} }

View File

@ -50,6 +50,15 @@ class ScheduleController(
return ResponseEntity.ok(CommonApiResponse(response)) return ResponseEntity.ok(CommonApiResponse(response))
} }
@PatchMapping("/schedules/{id}/hold")
override fun holdSchedule(
@PathVariable("id") id: Long
): ResponseEntity<CommonApiResponse<Unit>> {
scheduleService.holdSchedule(id)
return ResponseEntity.ok(CommonApiResponse())
}
@PatchMapping("/schedules/{id}") @PatchMapping("/schedules/{id}")
override fun updateSchedule( override fun updateSchedule(
@PathVariable("id") id: Long, @PathVariable("id") id: Long,

View File

@ -22,6 +22,7 @@ import roomescape.schedule.infrastructure.persistence.ScheduleStatus
import roomescape.schedule.web.ScheduleCreateRequest import roomescape.schedule.web.ScheduleCreateRequest
import roomescape.schedule.web.ScheduleUpdateRequest import roomescape.schedule.web.ScheduleUpdateRequest
import roomescape.util.* import roomescape.util.*
import roomescape.util.ScheduleFixture.createRequest
import java.time.LocalDate import java.time.LocalDate
import java.time.LocalTime import java.time.LocalTime
@ -59,7 +60,7 @@ class ScheduleApiTest(
runTest( runTest(
token = token, token = token,
using = { using = {
body(ScheduleFixture.createRequest) body(createRequest)
}, },
on = { on = {
get("/schedules/1") get("/schedules/1")
@ -71,13 +72,13 @@ class ScheduleApiTest(
test("일정 수정: PATCH /schedules/{id}") { test("일정 수정: PATCH /schedules/{id}") {
val createdSchedule: ScheduleEntity = val createdSchedule: ScheduleEntity =
createDummySchedule( createDummySchedule(
ScheduleFixture.createRequest createRequest
) )
runTest( runTest(
token = token, token = token,
using = { using = {
body(ScheduleFixture.createRequest) body(createRequest)
}, },
on = { on = {
patch("/schedules/${createdSchedule.id}") patch("/schedules/${createdSchedule.id}")
@ -89,7 +90,7 @@ class ScheduleApiTest(
test("일정 삭제: DELETE /schedules/{id}") { test("일정 삭제: DELETE /schedules/{id}") {
val createdSchedule: ScheduleEntity = val createdSchedule: ScheduleEntity =
createDummySchedule( createDummySchedule(
ScheduleFixture.createRequest createRequest
) )
runTest( runTest(
@ -114,7 +115,7 @@ class ScheduleApiTest(
val time = LocalTime.now() val time = LocalTime.now()
val createdSchedule: ScheduleEntity = val createdSchedule: ScheduleEntity =
createDummySchedule( createDummySchedule(
ScheduleFixture.createRequest.copy( createRequest.copy(
date = date, date = date,
time = time time = time
) )
@ -139,13 +140,13 @@ class ScheduleApiTest(
val createdSchedule: ScheduleEntity = val createdSchedule: ScheduleEntity =
createDummySchedule( createDummySchedule(
ScheduleFixture.createRequest.copy( createRequest.copy(
date = date, date = date,
time = time time = time
) )
) )
createDummySchedule( createDummySchedule(
ScheduleFixture.createRequest.copy( createRequest.copy(
date = date.plusDays(1L), date = date.plusDays(1L),
time = time, time = time,
themeId = createdSchedule.themeId themeId = createdSchedule.themeId
@ -175,7 +176,7 @@ class ScheduleApiTest(
val date = LocalDate.now().plusDays(1) val date = LocalDate.now().plusDays(1)
for (i in 1..10) { for (i in 1..10) {
createDummySchedule( createDummySchedule(
ScheduleFixture.createRequest.copy( createRequest.copy(
date = date, date = date,
time = LocalTime.now().plusMinutes(i.toLong()) time = LocalTime.now().plusMinutes(i.toLong())
) )
@ -199,14 +200,14 @@ class ScheduleApiTest(
test("정상 응답") { test("정상 응답") {
val date = LocalDate.now().plusDays(1) val date = LocalDate.now().plusDays(1)
val createdSchedule = createDummySchedule( val createdSchedule = createDummySchedule(
ScheduleFixture.createRequest.copy( createRequest.copy(
date = date, date = date,
time = LocalTime.now() time = LocalTime.now()
) )
) )
for (i in 1..10) { for (i in 1..10) {
createDummySchedule( createDummySchedule(
ScheduleFixture.createRequest.copy( createRequest.copy(
date = date, date = date,
time = LocalTime.now().plusMinutes(i.toLong()), time = LocalTime.now().plusMinutes(i.toLong()),
themeId = createdSchedule.themeId themeId = createdSchedule.themeId
@ -233,7 +234,7 @@ class ScheduleApiTest(
context("관리자 페이지에서 특정 일정의 감사 정보를 조회한다.") { context("관리자 페이지에서 특정 일정의 감사 정보를 조회한다.") {
test("정상 응답") { test("정상 응답") {
val createdSchedule = createDummySchedule(ScheduleFixture.createRequest) val createdSchedule = createDummySchedule(createRequest)
runTest( runTest(
token = loginUtil.loginAsAdmin(), token = loginUtil.loginAsAdmin(),
@ -302,7 +303,7 @@ class ScheduleApiTest(
runTest( runTest(
token = token, token = token,
using = { using = {
body(ScheduleFixture.createRequest.copy(themeId = themeId)) body(createRequest.copy(themeId = themeId))
}, },
on = { on = {
post("/schedules") post("/schedules")
@ -316,9 +317,9 @@ class ScheduleApiTest(
val createdSchedule: ScheduleEntity = scheduleRepository.findByIdOrNull(createdScheduleId) val createdSchedule: ScheduleEntity = scheduleRepository.findByIdOrNull(createdScheduleId)
?: throw AssertionError("Unexpected Exception Occurred.") ?: throw AssertionError("Unexpected Exception Occurred.")
createdSchedule.date shouldBe ScheduleFixture.createRequest.date createdSchedule.date shouldBe createRequest.date
createdSchedule.time.hour shouldBe ScheduleFixture.createRequest.time.hour createdSchedule.time.hour shouldBe createRequest.time.hour
createdSchedule.time.minute shouldBe ScheduleFixture.createRequest.time.minute createdSchedule.time.minute shouldBe createRequest.time.minute
createdSchedule.createdAt shouldNotBeNull {} createdSchedule.createdAt shouldNotBeNull {}
createdSchedule.createdBy shouldNotBeNull {} createdSchedule.createdBy shouldNotBeNull {}
createdSchedule.updatedAt shouldNotBeNull {} createdSchedule.updatedAt shouldNotBeNull {}
@ -331,7 +332,7 @@ class ScheduleApiTest(
val time = LocalTime.of(10, 0) val time = LocalTime.of(10, 0)
val alreadyCreated: ScheduleEntity = createDummySchedule( val alreadyCreated: ScheduleEntity = createDummySchedule(
ScheduleFixture.createRequest.copy( createRequest.copy(
date = date, time = time date = date, time = time
) )
) )
@ -340,7 +341,7 @@ class ScheduleApiTest(
token = token, token = token,
using = { using = {
body( body(
ScheduleFixture.createRequest.copy( createRequest.copy(
date = date, time = time, themeId = alreadyCreated.themeId date = date, time = time, themeId = alreadyCreated.themeId
) )
) )
@ -360,7 +361,7 @@ class ScheduleApiTest(
token = token, token = token,
using = { using = {
body( body(
ScheduleFixture.createRequest.copy( createRequest.copy(
date = LocalDate.now(), date = LocalDate.now(),
time = LocalTime.now().minusMinutes(1) time = LocalTime.now().minusMinutes(1)
) )
@ -377,6 +378,76 @@ class ScheduleApiTest(
} }
} }
context("일정을 잠시 Hold 상태로 변경하여 중복 예약을 방지한다.") {
test("해당 일정이 ${ScheduleStatus.AVAILABLE} 상태이면 정상 응답") {
val createdSchedule: ScheduleEntity = createDummySchedule(createRequest)
runTest(
token = loginUtil.loginAsUser(),
on = {
patch("/schedules/${createdSchedule.id}/hold")
},
expect = {
statusCode(HttpStatus.OK.value())
}
).also {
val updatedSchedule = scheduleRepository.findByIdOrNull(createdSchedule.id)
?: throw AssertionError("Unexpected Exception Occurred.")
updatedSchedule.status shouldBe ScheduleStatus.HOLD
}
}
test("예약이 없으면 실패한다.") {
runTest(
token = loginUtil.loginAsUser(),
on = {
patch("/schedules/1/hold")
},
expect = {
statusCode(HttpStatus.NOT_FOUND.value())
body("code", equalTo(ScheduleErrorCode.SCHEDULE_NOT_FOUND.errorCode))
}
)
}
test("해당 일정이 ${ScheduleStatus.AVAILABLE} 상태가 아니면 실패한다.") {
val createdSchedule: ScheduleEntity = createDummySchedule(createRequest)
/*
* 테스트를 위해 수정 API를 호출하여 상태를 HOLD 상태로 변경
* 생성 API에서는 일정 생성 AVAILABLE을 기본 상태로 지정하기 때문.
*/
runTest(
token = loginUtil.loginAsAdmin(),
using = {
body(
ScheduleUpdateRequest(
status = ScheduleStatus.HOLD
)
)
},
on = {
patch("/schedules/${createdSchedule.id}")
},
expect = {
statusCode(HttpStatus.OK.value())
}
)
runTest(
token = loginUtil.loginAsUser(),
on = {
patch("/schedules/${createdSchedule.id}/hold")
},
expect = {
statusCode(HttpStatus.CONFLICT.value())
body("code", equalTo(ScheduleErrorCode.SCHEDULE_NOT_AVAILABLE.errorCode))
}
)
}
}
context("일정을 수정한다.") { context("일정을 수정한다.") {
val updateRequest = ScheduleUpdateRequest( val updateRequest = ScheduleUpdateRequest(
time = LocalTime.now().plusHours(1), time = LocalTime.now().plusHours(1),
@ -385,7 +456,7 @@ class ScheduleApiTest(
test("정상 수정 및 감사 정보 변경 확인") { test("정상 수정 및 감사 정보 변경 확인") {
val createdSchedule: ScheduleEntity = createDummySchedule( val createdSchedule: ScheduleEntity = createDummySchedule(
ScheduleFixture.createRequest.copy( createRequest.copy(
date = LocalDate.now().plusDays(1), date = LocalDate.now().plusDays(1),
time = LocalTime.now().plusMinutes(1), time = LocalTime.now().plusMinutes(1),
) )
@ -416,7 +487,7 @@ class ScheduleApiTest(
} }
test("입력값이 없으면 수정하지 않는다.") { test("입력값이 없으면 수정하지 않는다.") {
val createdSchedule: ScheduleEntity = createDummySchedule(ScheduleFixture.createRequest) val createdSchedule: ScheduleEntity = createDummySchedule(createRequest)
runTest( runTest(
token = loginUtil.loginAsAdmin(), token = loginUtil.loginAsAdmin(),
@ -455,7 +526,7 @@ class ScheduleApiTest(
test("입력된 날짜 + 시간이 현재 시간 이전이면 실패한다.") { test("입력된 날짜 + 시간이 현재 시간 이전이면 실패한다.") {
val createdSchedule: ScheduleEntity = createDummySchedule( val createdSchedule: ScheduleEntity = createDummySchedule(
ScheduleFixture.createRequest.copy(date = LocalDate.now(), time = LocalTime.now().plusMinutes(1)) createRequest.copy(date = LocalDate.now(), time = LocalTime.now().plusMinutes(1))
) )
runTest( runTest(
@ -480,7 +551,7 @@ class ScheduleApiTest(
context("일정을 삭제한다.") { context("일정을 삭제한다.") {
test("정상 삭제") { test("정상 삭제") {
val createdSchedule: ScheduleEntity = createDummySchedule(ScheduleFixture.createRequest) val createdSchedule: ScheduleEntity = createDummySchedule(createRequest)
runTest( runTest(
token = loginUtil.loginAsAdmin(), token = loginUtil.loginAsAdmin(),
@ -496,7 +567,7 @@ class ScheduleApiTest(
} }
test("예약 중이거나 예약이 완료된 일정이면 실패한다.") { test("예약 중이거나 예약이 완료된 일정이면 실패한다.") {
val createdSchedule: ScheduleEntity = createDummySchedule(ScheduleFixture.createRequest) val createdSchedule: ScheduleEntity = createDummySchedule(createRequest)
/* /*
* 테스트를 위해 수정 API를 호출하여 상태를 예약 상태로 변경 * 테스트를 위해 수정 API를 호출하여 상태를 예약 상태로 변경