From acfe787d5f33245f258cb80c307f0360b8ef8fd6 Mon Sep 17 00:00:00 2001 From: pricelees Date: Thu, 4 Sep 2025 18:54:39 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20=EC=9D=BC=EC=A0=95=20=EC=84=A0=ED=83=9D?= =?UTF-8?q?=20=ED=9B=84=20=EC=98=88=EC=95=BD=20=ED=8E=98=EC=9D=B4=EC=A7=80?= =?UTF-8?q?=EB=A1=9C=20=EB=84=98=EC=96=B4=EA=B0=88=20=EB=95=8C=20=ED=95=B4?= =?UTF-8?q?=EB=8B=B9=20=EC=9D=BC=EC=A0=95=EC=9D=98=20=EC=83=81=ED=83=9C?= =?UTF-8?q?=EB=A5=BC=20=EB=B3=80=EA=B2=BD=ED=95=98=EB=8A=94=20API=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../schedule/business/ScheduleService.kt | 12 ++ .../roomescape/schedule/docs/ScheduleAPI.kt | 13 ++ .../schedule/exception/ScheduleErrorCode.kt | 1 + .../persistence/ScheduleEntity.kt | 6 +- .../schedule/web/ScheduleController.kt | 9 ++ .../roomescape/schedule/ScheduleApiTest.kt | 117 ++++++++++++++---- 6 files changed, 134 insertions(+), 24 deletions(-) diff --git a/src/main/kotlin/roomescape/schedule/business/ScheduleService.kt b/src/main/kotlin/roomescape/schedule/business/ScheduleService.kt index dd989d70..3637d42c 100644 --- a/src/main/kotlin/roomescape/schedule/business/ScheduleService.kt +++ b/src/main/kotlin/roomescape/schedule/business/ScheduleService.kt @@ -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 fun updateSchedule(id: Long, request: ScheduleUpdateRequest) { log.info { "[ScheduleService.updateSchedule] 일정 수정 시작: id=$id, request=${request}" } diff --git a/src/main/kotlin/roomescape/schedule/docs/ScheduleAPI.kt b/src/main/kotlin/roomescape/schedule/docs/ScheduleAPI.kt index 579c02a5..95e2cc2d 100644 --- a/src/main/kotlin/roomescape/schedule/docs/ScheduleAPI.kt +++ b/src/main/kotlin/roomescape/schedule/docs/ScheduleAPI.kt @@ -38,6 +38,19 @@ interface ScheduleAPI { @RequestParam("themeId") themeId: Long ): ResponseEntity> + @LoginRequired + @Operation(summary = "일정을 Hold 상태로 변경", tags = ["로그인이 필요한 API"]) + @ApiResponses( + ApiResponse( + responseCode = "200", + description = "일정을 Hold 상태로 변경하여 중복 예약 방지", + useReturnTypeSchema = true + ) + ) + fun holdSchedule( + @PathVariable("id") id: Long + ): ResponseEntity> + @Admin @Operation(summary = "일정 상세 조회", tags = ["관리자 로그인이 필요한 API"]) @ApiResponses(ApiResponse(responseCode = "200", description = "감사 정보를 포함하여 일정 상세 조회", useReturnTypeSchema = true)) diff --git a/src/main/kotlin/roomescape/schedule/exception/ScheduleErrorCode.kt b/src/main/kotlin/roomescape/schedule/exception/ScheduleErrorCode.kt index 4436ac96..096fce94 100644 --- a/src/main/kotlin/roomescape/schedule/exception/ScheduleErrorCode.kt +++ b/src/main/kotlin/roomescape/schedule/exception/ScheduleErrorCode.kt @@ -12,4 +12,5 @@ enum class ScheduleErrorCode( SCHEDULE_ALREADY_EXISTS(HttpStatus.CONFLICT, "S002", "이미 동일한 일정이 있어요."), PAST_DATE_TIME(HttpStatus.BAD_REQUEST, "S003", "과거 날짜와 시간은 선택할 수 없어요."), SCHEDULE_IN_USE(HttpStatus.CONFLICT, "S004", "예약이 진행중이거나 완료된 일정은 삭제할 수 없어요."), + SCHEDULE_NOT_AVAILABLE(HttpStatus.CONFLICT, "S005", "예약이 완료되었거나 예약할 수 없는 일정이에요.") } diff --git a/src/main/kotlin/roomescape/schedule/infrastructure/persistence/ScheduleEntity.kt b/src/main/kotlin/roomescape/schedule/infrastructure/persistence/ScheduleEntity.kt index 7da08a63..60cd394f 100644 --- a/src/main/kotlin/roomescape/schedule/infrastructure/persistence/ScheduleEntity.kt +++ b/src/main/kotlin/roomescape/schedule/infrastructure/persistence/ScheduleEntity.kt @@ -29,8 +29,12 @@ class ScheduleEntity( time?.let { this.time = it } status?.let { this.status = it } } + + fun hold() { + this.status = ScheduleStatus.HOLD + } } enum class ScheduleStatus { - AVAILABLE, PENDING, RESERVED, BLOCKED + AVAILABLE, HOLD, RESERVED, BLOCKED } diff --git a/src/main/kotlin/roomescape/schedule/web/ScheduleController.kt b/src/main/kotlin/roomescape/schedule/web/ScheduleController.kt index 6396fe40..e496c7db 100644 --- a/src/main/kotlin/roomescape/schedule/web/ScheduleController.kt +++ b/src/main/kotlin/roomescape/schedule/web/ScheduleController.kt @@ -50,6 +50,15 @@ class ScheduleController( return ResponseEntity.ok(CommonApiResponse(response)) } + @PatchMapping("/schedules/{id}/hold") + override fun holdSchedule( + @PathVariable("id") id: Long + ): ResponseEntity> { + scheduleService.holdSchedule(id) + + return ResponseEntity.ok(CommonApiResponse()) + } + @PatchMapping("/schedules/{id}") override fun updateSchedule( @PathVariable("id") id: Long, diff --git a/src/test/kotlin/roomescape/schedule/ScheduleApiTest.kt b/src/test/kotlin/roomescape/schedule/ScheduleApiTest.kt index 036060f3..25ae6bc0 100644 --- a/src/test/kotlin/roomescape/schedule/ScheduleApiTest.kt +++ b/src/test/kotlin/roomescape/schedule/ScheduleApiTest.kt @@ -22,6 +22,7 @@ import roomescape.schedule.infrastructure.persistence.ScheduleStatus import roomescape.schedule.web.ScheduleCreateRequest import roomescape.schedule.web.ScheduleUpdateRequest import roomescape.util.* +import roomescape.util.ScheduleFixture.createRequest import java.time.LocalDate import java.time.LocalTime @@ -59,7 +60,7 @@ class ScheduleApiTest( runTest( token = token, using = { - body(ScheduleFixture.createRequest) + body(createRequest) }, on = { get("/schedules/1") @@ -71,13 +72,13 @@ class ScheduleApiTest( test("일정 수정: PATCH /schedules/{id}") { val createdSchedule: ScheduleEntity = createDummySchedule( - ScheduleFixture.createRequest + createRequest ) runTest( token = token, using = { - body(ScheduleFixture.createRequest) + body(createRequest) }, on = { patch("/schedules/${createdSchedule.id}") @@ -89,7 +90,7 @@ class ScheduleApiTest( test("일정 삭제: DELETE /schedules/{id}") { val createdSchedule: ScheduleEntity = createDummySchedule( - ScheduleFixture.createRequest + createRequest ) runTest( @@ -114,7 +115,7 @@ class ScheduleApiTest( val time = LocalTime.now() val createdSchedule: ScheduleEntity = createDummySchedule( - ScheduleFixture.createRequest.copy( + createRequest.copy( date = date, time = time ) @@ -139,13 +140,13 @@ class ScheduleApiTest( val createdSchedule: ScheduleEntity = createDummySchedule( - ScheduleFixture.createRequest.copy( + createRequest.copy( date = date, time = time ) ) createDummySchedule( - ScheduleFixture.createRequest.copy( + createRequest.copy( date = date.plusDays(1L), time = time, themeId = createdSchedule.themeId @@ -175,7 +176,7 @@ class ScheduleApiTest( val date = LocalDate.now().plusDays(1) for (i in 1..10) { createDummySchedule( - ScheduleFixture.createRequest.copy( + createRequest.copy( date = date, time = LocalTime.now().plusMinutes(i.toLong()) ) @@ -199,14 +200,14 @@ class ScheduleApiTest( test("정상 응답") { val date = LocalDate.now().plusDays(1) val createdSchedule = createDummySchedule( - ScheduleFixture.createRequest.copy( + createRequest.copy( date = date, time = LocalTime.now() ) ) for (i in 1..10) { createDummySchedule( - ScheduleFixture.createRequest.copy( + createRequest.copy( date = date, time = LocalTime.now().plusMinutes(i.toLong()), themeId = createdSchedule.themeId @@ -233,7 +234,7 @@ class ScheduleApiTest( context("관리자 페이지에서 특정 일정의 감사 정보를 조회한다.") { test("정상 응답") { - val createdSchedule = createDummySchedule(ScheduleFixture.createRequest) + val createdSchedule = createDummySchedule(createRequest) runTest( token = loginUtil.loginAsAdmin(), @@ -302,7 +303,7 @@ class ScheduleApiTest( runTest( token = token, using = { - body(ScheduleFixture.createRequest.copy(themeId = themeId)) + body(createRequest.copy(themeId = themeId)) }, on = { post("/schedules") @@ -316,9 +317,9 @@ class ScheduleApiTest( val createdSchedule: ScheduleEntity = scheduleRepository.findByIdOrNull(createdScheduleId) ?: throw AssertionError("Unexpected Exception Occurred.") - createdSchedule.date shouldBe ScheduleFixture.createRequest.date - createdSchedule.time.hour shouldBe ScheduleFixture.createRequest.time.hour - createdSchedule.time.minute shouldBe ScheduleFixture.createRequest.time.minute + createdSchedule.date shouldBe createRequest.date + createdSchedule.time.hour shouldBe createRequest.time.hour + createdSchedule.time.minute shouldBe createRequest.time.minute createdSchedule.createdAt shouldNotBeNull {} createdSchedule.createdBy shouldNotBeNull {} createdSchedule.updatedAt shouldNotBeNull {} @@ -331,7 +332,7 @@ class ScheduleApiTest( val time = LocalTime.of(10, 0) val alreadyCreated: ScheduleEntity = createDummySchedule( - ScheduleFixture.createRequest.copy( + createRequest.copy( date = date, time = time ) ) @@ -340,7 +341,7 @@ class ScheduleApiTest( token = token, using = { body( - ScheduleFixture.createRequest.copy( + createRequest.copy( date = date, time = time, themeId = alreadyCreated.themeId ) ) @@ -360,7 +361,7 @@ class ScheduleApiTest( token = token, using = { body( - ScheduleFixture.createRequest.copy( + createRequest.copy( date = LocalDate.now(), 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("일정을 수정한다.") { val updateRequest = ScheduleUpdateRequest( time = LocalTime.now().plusHours(1), @@ -385,7 +456,7 @@ class ScheduleApiTest( test("정상 수정 및 감사 정보 변경 확인") { val createdSchedule: ScheduleEntity = createDummySchedule( - ScheduleFixture.createRequest.copy( + createRequest.copy( date = LocalDate.now().plusDays(1), time = LocalTime.now().plusMinutes(1), ) @@ -416,7 +487,7 @@ class ScheduleApiTest( } test("입력값이 없으면 수정하지 않는다.") { - val createdSchedule: ScheduleEntity = createDummySchedule(ScheduleFixture.createRequest) + val createdSchedule: ScheduleEntity = createDummySchedule(createRequest) runTest( token = loginUtil.loginAsAdmin(), @@ -455,7 +526,7 @@ class ScheduleApiTest( test("입력된 날짜 + 시간이 현재 시간 이전이면 실패한다.") { 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( @@ -480,7 +551,7 @@ class ScheduleApiTest( context("일정을 삭제한다.") { test("정상 삭제") { - val createdSchedule: ScheduleEntity = createDummySchedule(ScheduleFixture.createRequest) + val createdSchedule: ScheduleEntity = createDummySchedule(createRequest) runTest( token = loginUtil.loginAsAdmin(), @@ -496,7 +567,7 @@ class ScheduleApiTest( } test("예약 중이거나 예약이 완료된 일정이면 실패한다.") { - val createdSchedule: ScheduleEntity = createDummySchedule(ScheduleFixture.createRequest) + val createdSchedule: ScheduleEntity = createDummySchedule(createRequest) /* * 테스트를 위해 수정 API를 호출하여 상태를 예약 중 상태로 변경