From 5d23216e17160a274ac7bae90f755b164b11664c Mon Sep 17 00:00:00 2001 From: pricelees Date: Thu, 4 Sep 2025 11:47:48 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20=EB=AA=A8=EB=93=A0=20=EC=8A=A4=EC=BC=80?= =?UTF-8?q?=EC=A5=B4=20API=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../roomescape/schedule/ScheduleApiTest.kt | 569 ++++++++++++++++++ 1 file changed, 569 insertions(+) create mode 100644 src/test/kotlin/roomescape/schedule/ScheduleApiTest.kt diff --git a/src/test/kotlin/roomescape/schedule/ScheduleApiTest.kt b/src/test/kotlin/roomescape/schedule/ScheduleApiTest.kt new file mode 100644 index 00000000..036060f3 --- /dev/null +++ b/src/test/kotlin/roomescape/schedule/ScheduleApiTest.kt @@ -0,0 +1,569 @@ +package roomescape.schedule + +import io.kotest.matchers.date.shouldBeAfter +import io.kotest.matchers.nulls.shouldNotBeNull +import io.kotest.matchers.shouldBe +import io.kotest.matchers.shouldNotBe +import io.restassured.module.kotlin.extensions.Extract +import io.restassured.module.kotlin.extensions.Given +import io.restassured.module.kotlin.extensions.When +import io.restassured.response.ValidatableResponse +import org.hamcrest.CoreMatchers.equalTo +import org.hamcrest.CoreMatchers.notNullValue +import org.springframework.data.repository.findByIdOrNull +import org.springframework.http.HttpStatus +import org.springframework.http.MediaType +import roomescape.auth.exception.AuthErrorCode +import roomescape.member.infrastructure.persistence.Role +import roomescape.schedule.exception.ScheduleErrorCode +import roomescape.schedule.infrastructure.persistence.ScheduleEntity +import roomescape.schedule.infrastructure.persistence.ScheduleRepository +import roomescape.schedule.infrastructure.persistence.ScheduleStatus +import roomescape.schedule.web.ScheduleCreateRequest +import roomescape.schedule.web.ScheduleUpdateRequest +import roomescape.util.* +import java.time.LocalDate +import java.time.LocalTime + +class ScheduleApiTest( + private val scheduleRepository: ScheduleRepository +) : FunSpecSpringbootTest() { + + init { + context("관리자가 아니면 접근할 수 없다.") { + lateinit var token: String + + beforeTest { + token = loginUtil.loginAsUser() + } + + val commonAssertion: ValidatableResponse.() -> Unit = { + statusCode(HttpStatus.FORBIDDEN.value()) + body( + "code", + equalTo(AuthErrorCode.ACCESS_DENIED.errorCode) + ) + } + + test("일정 상세: GET /schedules/{id}") { + runTest( + token = token, + on = { + get("/schedules/1") + }, + expect = commonAssertion + ) + } + + test("일정 생성: POST /schedules") { + runTest( + token = token, + using = { + body(ScheduleFixture.createRequest) + }, + on = { + get("/schedules/1") + }, + expect = commonAssertion + ) + } + + test("일정 수정: PATCH /schedules/{id}") { + val createdSchedule: ScheduleEntity = + createDummySchedule( + ScheduleFixture.createRequest + ) + + runTest( + token = token, + using = { + body(ScheduleFixture.createRequest) + }, + on = { + patch("/schedules/${createdSchedule.id}") + }, + expect = commonAssertion + ) + } + + test("일정 삭제: DELETE /schedules/{id}") { + val createdSchedule: ScheduleEntity = + createDummySchedule( + ScheduleFixture.createRequest + ) + + runTest( + token = token, + on = { + delete("/schedules/${createdSchedule.id}") + }, + expect = commonAssertion + ) + } + } + + context("일반 회원도 접근할 수 있다.") { + lateinit var token: String + + beforeTest { + token = loginUtil.loginAsUser() + } + + test("예약 가능 테마 조회: GET /schedules/themes?date={date}") { + val date = LocalDate.now().plusDays(1) + val time = LocalTime.now() + val createdSchedule: ScheduleEntity = + createDummySchedule( + ScheduleFixture.createRequest.copy( + date = date, + time = time + ) + ) + + runTest( + token = token, + on = { + get("/schedules/themes?date=$date") + }, + expect = { + statusCode(HttpStatus.OK.value()) + body("data.themeIds.size()", equalTo(1)) + body("data.themeIds[0]", equalTo(createdSchedule.themeId)) + } + ) + } + + test("동일한 날짜, 테마에 대한 시간 조회: GET /schedules?date={date}&themeId={themeId}") { + val date = LocalDate.now().plusDays(1) + val time = LocalTime.now() + + val createdSchedule: ScheduleEntity = + createDummySchedule( + ScheduleFixture.createRequest.copy( + date = date, + time = time + ) + ) + createDummySchedule( + ScheduleFixture.createRequest.copy( + date = date.plusDays(1L), + time = time, + themeId = createdSchedule.themeId + ) + ) + + runTest( + token = token, + on = { + get("/schedules?date=$date&themeId=${createdSchedule.themeId}") + }, + expect = { + statusCode(HttpStatus.OK.value()) + body("data.schedules.size()", equalTo(1)) + body("data.schedules[0].id", equalTo(createdSchedule.id)) + assertProperties( + props = setOf("id", "time", "status"), + propsNameIfList = "schedules" + ) + } + ) + } + } + + context("특정 날짜에 예약 가능한 테마 목록을 조회한다.") { + test("정상 응답") { + val date = LocalDate.now().plusDays(1) + for (i in 1..10) { + createDummySchedule( + ScheduleFixture.createRequest.copy( + date = date, + time = LocalTime.now().plusMinutes(i.toLong()) + ) + ) + } + + runTest( + token = loginUtil.loginAsUser(), + on = { + get("/schedules/themes?date=$date") + }, + expect = { + statusCode(HttpStatus.OK.value()) + body("data.themeIds.size()", equalTo(10)) + } + ) + } + } + + context("동일한 날짜, 테마에 대한 모든 시간을 조회한다.") { + test("정상 응답") { + val date = LocalDate.now().plusDays(1) + val createdSchedule = createDummySchedule( + ScheduleFixture.createRequest.copy( + date = date, + time = LocalTime.now() + ) + ) + for (i in 1..10) { + createDummySchedule( + ScheduleFixture.createRequest.copy( + date = date, + time = LocalTime.now().plusMinutes(i.toLong()), + themeId = createdSchedule.themeId + ) + ) + } + + runTest( + token = loginUtil.loginAsUser(), + on = { + get("/schedules?date=$date&themeId=${createdSchedule.themeId}") + }, + expect = { + statusCode(HttpStatus.OK.value()) + body("data.schedules.size()", equalTo(11)) + assertProperties( + props = setOf("id", "time", "status"), + propsNameIfList = "schedules" + ) + } + ) + } + } + + context("관리자 페이지에서 특정 일정의 감사 정보를 조회한다.") { + test("정상 응답") { + val createdSchedule = createDummySchedule(ScheduleFixture.createRequest) + + runTest( + token = loginUtil.loginAsAdmin(), + on = { + get("/schedules/${createdSchedule.id}") + }, + expect = { + statusCode(HttpStatus.OK.value()) + body("data.id", equalTo(createdSchedule.id)) + assertProperties( + props = setOf( + "id", "date", "time", "status", + "createdAt", "createdBy", "updatedAt", "updatedBy", + ) + ) + } + ).also { + it.extract().path("data.createdAt") shouldNotBeNull {} + it.extract().path("data.createdBy") shouldNotBeNull {} + it.extract().path("data.createdAt") shouldNotBeNull {} + it.extract().path("data.createdAt") shouldNotBeNull {} + } + } + + test("일정이 없으면 실패한다.") { + runTest( + token = loginUtil.loginAsAdmin(), + on = { + get("/schedules/1") + }, + expect = { + statusCode(HttpStatus.NOT_FOUND.value()) + body( + "code", + equalTo(ScheduleErrorCode.SCHEDULE_NOT_FOUND.errorCode) + ) + } + ) + } + } + + context("일정을 생성한다.") { + lateinit var token: String + + beforeTest { + token = loginUtil.loginAsAdmin() + } + + test("정상 생성 및 감사 정보 확인") { + /** + * FK 제약조건 해소를 위한 테마 생성 API 호출 및 ID 획득 + */ + val themeId: Long = Given { + contentType(MediaType.APPLICATION_JSON_VALUE) + header("Authorization", "Bearer $token") + body(ThemeFixtureV2.createRequest.copy(name = "theme-${System.currentTimeMillis()}")) + } When { + post("/admin/themes") + } Extract { + path("data.id") + } + + /** + * 생성 테스트 + */ + runTest( + token = token, + using = { + body(ScheduleFixture.createRequest.copy(themeId = themeId)) + }, + on = { + post("/schedules") + }, + expect = { + statusCode(HttpStatus.OK.value()) + body("data.id", notNullValue()) + } + ).also { + val createdScheduleId: Long = it.extract().path("data.id") + 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.createdAt shouldNotBeNull {} + createdSchedule.createdBy shouldNotBeNull {} + createdSchedule.updatedAt shouldNotBeNull {} + createdSchedule.updatedBy shouldNotBeNull {} + } + } + + test("이미 동일한 날짜, 시간, 테마인 일정이 있으면 실패한다.") { + val date = LocalDate.now().plusDays(1) + val time = LocalTime.of(10, 0) + + val alreadyCreated: ScheduleEntity = createDummySchedule( + ScheduleFixture.createRequest.copy( + date = date, time = time + ) + ) + + runTest( + token = token, + using = { + body( + ScheduleFixture.createRequest.copy( + date = date, time = time, themeId = alreadyCreated.themeId + ) + ) + }, + on = { + post("/schedules") + }, + expect = { + statusCode(HttpStatus.CONFLICT.value()) + body("code", equalTo(ScheduleErrorCode.SCHEDULE_ALREADY_EXISTS.errorCode)) + } + ) + } + + test("입력된 날짜 + 시간이 현재 시간 이전이면 실패한다.") { + runTest( + token = token, + using = { + body( + ScheduleFixture.createRequest.copy( + date = LocalDate.now(), + time = LocalTime.now().minusMinutes(1) + ) + ) + }, + on = { + post("/schedules") + }, + expect = { + statusCode(HttpStatus.BAD_REQUEST.value()) + body("code", equalTo(ScheduleErrorCode.PAST_DATE_TIME.errorCode)) + } + ) + } + } + + context("일정을 수정한다.") { + val updateRequest = ScheduleUpdateRequest( + time = LocalTime.now().plusHours(1), + status = ScheduleStatus.BLOCKED + ) + + test("정상 수정 및 감사 정보 변경 확인") { + val createdSchedule: ScheduleEntity = createDummySchedule( + ScheduleFixture.createRequest.copy( + date = LocalDate.now().plusDays(1), + time = LocalTime.now().plusMinutes(1), + ) + ) + val otherAdminToken = loginUtil.login("admin1@admin.com", "admin1", Role.ADMIN) + + runTest( + token = otherAdminToken, + using = { + body(updateRequest) + }, + on = { + patch("/schedules/${createdSchedule.id}") + }, + expect = { + statusCode(HttpStatus.OK.value()) + } + ).also { + val updatedSchedule = scheduleRepository.findByIdOrNull(createdSchedule.id)!! + + updatedSchedule.id shouldBe createdSchedule.id + updatedSchedule.time.hour shouldBe updateRequest.time!!.hour + updatedSchedule.time.minute shouldBe updateRequest.time.minute + updatedSchedule.status shouldBe updateRequest.status + updatedSchedule.updatedBy shouldNotBe createdSchedule.updatedBy + updatedSchedule.updatedAt shouldBeAfter createdSchedule.updatedAt + } + } + + test("입력값이 없으면 수정하지 않는다.") { + val createdSchedule: ScheduleEntity = createDummySchedule(ScheduleFixture.createRequest) + + runTest( + token = loginUtil.loginAsAdmin(), + using = { + body(ScheduleUpdateRequest()) + }, + on = { + patch("/schedules/${createdSchedule.id}") + }, + expect = { + statusCode(HttpStatus.OK.value()) + } + ).also { + val updatedSchedule = scheduleRepository.findByIdOrNull(createdSchedule.id)!! + + updatedSchedule.id shouldBe createdSchedule.id + updatedSchedule.updatedAt shouldBe createdSchedule.updatedAt + } + } + + test("일정이 없으면 실패한다.") { + runTest( + token = loginUtil.loginAsAdmin(), + using = { + body(updateRequest) + }, + on = { + patch("/schedules/1") + }, + expect = { + statusCode(HttpStatus.NOT_FOUND.value()) + body("code", equalTo(ScheduleErrorCode.SCHEDULE_NOT_FOUND.errorCode)) + } + ) + } + + test("입력된 날짜 + 시간이 현재 시간 이전이면 실패한다.") { + val createdSchedule: ScheduleEntity = createDummySchedule( + ScheduleFixture.createRequest.copy(date = LocalDate.now(), time = LocalTime.now().plusMinutes(1)) + ) + + runTest( + token = loginUtil.loginAsAdmin(), + using = { + body( + updateRequest.copy( + time = LocalTime.now().minusMinutes(1) + ) + ) + }, + on = { + patch("/schedules/${createdSchedule.id}") + }, + expect = { + statusCode(HttpStatus.BAD_REQUEST.value()) + body("code", equalTo(ScheduleErrorCode.PAST_DATE_TIME.errorCode)) + } + ) + } + } + + context("일정을 삭제한다.") { + test("정상 삭제") { + val createdSchedule: ScheduleEntity = createDummySchedule(ScheduleFixture.createRequest) + + runTest( + token = loginUtil.loginAsAdmin(), + on = { + delete("/schedules/${createdSchedule.id}") + }, + expect = { + statusCode(HttpStatus.NO_CONTENT.value()) + } + ).also { + scheduleRepository.findByIdOrNull(createdSchedule.id) shouldBe null + } + } + + test("예약 중이거나 예약이 완료된 일정이면 실패한다.") { + val createdSchedule: ScheduleEntity = createDummySchedule(ScheduleFixture.createRequest) + + /* + * 테스트를 위해 수정 API를 호출하여 상태를 예약 중 상태로 변경 + * 생성 API에서는 일정 생성 시 AVAILABLE을 기본 상태로 지정하기 때문. + */ + runTest( + token = loginUtil.loginAsAdmin(), + using = { + body( + ScheduleUpdateRequest( + status = ScheduleStatus.RESERVED + ) + ) + }, + on = { + patch("/schedules/${createdSchedule.id}") + }, + expect = { + statusCode(HttpStatus.OK.value()) + } + ) + + /** + * 삭제 테스트 + */ + runTest( + token = loginUtil.loginAsAdmin(), + on = { + delete("/schedules/${createdSchedule.id}") + }, + expect = { + statusCode(HttpStatus.CONFLICT.value()) + body("code", equalTo(ScheduleErrorCode.SCHEDULE_IN_USE.errorCode)) + } + ) + } + } + } + + fun createDummySchedule(request: ScheduleCreateRequest): ScheduleEntity { + val token = loginUtil.loginAsAdmin() + + val themeId: Long = if (request.themeId > 1L) { + request.themeId + } else { + Given { + contentType(MediaType.APPLICATION_JSON_VALUE) + header("Authorization", "Bearer $token") + body(ThemeFixtureV2.createRequest.copy(name = "theme-${System.currentTimeMillis()}")) + } When { + post("/admin/themes") + } Extract { + path("data.id") + } + } + + val createdScheduleId: Long = Given { + contentType(MediaType.APPLICATION_JSON_VALUE) + header("Authorization", "Bearer $token") + body(request.copy(themeId = themeId)) + } When { + post("/schedules") + } Extract { + path("data.id") + } + + return scheduleRepository.findByIdOrNull(createdScheduleId) + ?: throw RuntimeException("unreachable line") + } +} \ No newline at end of file