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 org.hamcrest.CoreMatchers.equalTo import org.hamcrest.CoreMatchers.notNullValue import org.springframework.data.repository.findByIdOrNull import org.springframework.http.HttpMethod import org.springframework.http.HttpStatus import roomescape.admin.infrastructure.persistence.AdminPermissionLevel import roomescape.auth.exception.AuthErrorCode 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.ScheduleUpdateRequest import roomescape.supports.* import roomescape.supports.ScheduleFixture.createRequest import java.time.LocalDate import java.time.LocalTime class ScheduleApiTest( private val scheduleRepository: ScheduleRepository ) : FunSpecSpringbootTest() { init { context("특정 날짜에 예약 가능한 테마 목록을 조회한다.") { val date = LocalDate.now().plusDays(1) val endpoint = "/schedules/themes?date=$date" test("정상 응답") { val adminToken = testAuthUtil.defaultStoreAdminLogin() for (i in 1..10) { dummyInitializer.createSchedule( adminToken = adminToken, request = createRequest.copy( date = date, time = LocalTime.now().plusMinutes(i.toLong()) ) ) } runTest( token = testAuthUtil.defaultUserLogin(), on = { get(endpoint) }, expect = { statusCode(HttpStatus.OK.value()) body("data.themeIds.size()", equalTo(10)) } ) } } context("동일한 날짜, 테마에 대한 모든 시간을 조회한다.") { test("정상 응답") { val date = LocalDate.now().plusDays(1) val adminToken = testAuthUtil.defaultStoreAdminLogin() val createdSchedule = dummyInitializer.createSchedule( adminToken = adminToken, request = createRequest.copy(date = date, time = LocalTime.now()) ) for (i in 1..10) { dummyInitializer.createSchedule( adminToken = adminToken, request = createRequest.copy( date = date, time = LocalTime.now().plusMinutes(i.toLong()), themeId = createdSchedule.themeId ) ) } runTest( token = testAuthUtil.defaultUserLogin(), 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("관리자 페이지에서 특정 일정의 감사 정보를 조회한다.") { context("권한이 없으면 접근할 수 없다.") { val endpoint = "/schedules/${INVALID_PK}" test("비회원") { runExceptionTest( method = HttpMethod.GET, endpoint = endpoint, expectedErrorCode = AuthErrorCode.TOKEN_NOT_FOUND ) } test("회원") { runExceptionTest( token = testAuthUtil.defaultUserLogin(), method = HttpMethod.GET, endpoint = endpoint, expectedErrorCode = AuthErrorCode.ACCESS_DENIED ) } test("권한이 ${AdminPermissionLevel.READ_SUMMARY}인 관리자") { val admin = AdminFixture.create(permissionLevel = AdminPermissionLevel.READ_SUMMARY) runExceptionTest( token = testAuthUtil.adminLogin(admin), method = HttpMethod.GET, endpoint = endpoint, expectedErrorCode = AuthErrorCode.ACCESS_DENIED ) } } test("정상 응답") { val token = testAuthUtil.defaultStoreAdminLogin() val createdSchedule = dummyInitializer.createSchedule( adminToken = token, request = createRequest ) runTest( token = token, 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.updatedAt") shouldNotBeNull {} it.extract().path>("data.updatedBy") shouldNotBeNull {} } } test("일정이 없으면 실패한다.") { runExceptionTest( token = testAuthUtil.defaultStoreAdminLogin(), method = HttpMethod.GET, endpoint = "/schedules/$INVALID_PK", expectedErrorCode = ScheduleErrorCode.SCHEDULE_NOT_FOUND ) } } context("일정을 생성한다.") { context("권한이 없으면 접근할 수 없다.") { val endpoint = "/schedules" test("비회원") { runExceptionTest( method = HttpMethod.POST, requestBody = createRequest, endpoint = endpoint, expectedErrorCode = AuthErrorCode.TOKEN_NOT_FOUND ) } test("회원") { runExceptionTest( token = testAuthUtil.defaultUserLogin(), method = HttpMethod.POST, requestBody = createRequest, endpoint = endpoint, expectedErrorCode = AuthErrorCode.ACCESS_DENIED ) } listOf(AdminPermissionLevel.READ_SUMMARY, AdminPermissionLevel.READ_ALL).forEach { test("권한이 ${it}인 관리자") { val admin = AdminFixture.create(permissionLevel = it) runExceptionTest( token = testAuthUtil.adminLogin(admin), method = HttpMethod.POST, requestBody = createRequest, endpoint = endpoint, expectedErrorCode = AuthErrorCode.ACCESS_DENIED ) } } } test("정상 생성 및 감사 정보 확인") { val token = testAuthUtil.defaultStoreAdminLogin() val themeId: Long = dummyInitializer.createTheme( adminToken = token, request = ThemeFixture.createRequest.copy(name = "theme-${System.currentTimeMillis()}") ).id /** * 생성 테스트 */ runTest( token = token, using = { body(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 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 {} createdSchedule.updatedBy shouldNotBeNull {} } } test("이미 동일한 날짜, 시간, 테마인 일정이 있으면 실패한다.") { val token = testAuthUtil.defaultStoreAdminLogin() val date = LocalDate.now().plusDays(1) val time = LocalTime.of(10, 0) val alreadyCreated: ScheduleEntity = dummyInitializer.createSchedule( adminToken = token, request = createRequest.copy(date = date, time = time) ) val body = createRequest.copy(date = date, time = time, themeId = alreadyCreated.themeId) runExceptionTest( token = token, method = HttpMethod.POST, endpoint = "/schedules", requestBody = body, expectedErrorCode = ScheduleErrorCode.SCHEDULE_ALREADY_EXISTS ) } test("입력된 날짜 + 시간이 현재 시간 이전이면 실패한다.") { val token = testAuthUtil.defaultStoreAdminLogin() val body = createRequest.copy(LocalDate.now(), LocalTime.now().minusMinutes(1)) runExceptionTest( token = token, method = HttpMethod.POST, endpoint = "/schedules", requestBody = body, expectedErrorCode = ScheduleErrorCode.PAST_DATE_TIME ) } } context("일정을 잠시 Hold 상태로 변경하여 중복 예약을 방지한다.") { context("권한이 없으면 접근할 수 없다.") { val endpoint = "/schedules/${INVALID_PK}/hold" test("비회원") { runExceptionTest( method = HttpMethod.PATCH, endpoint = endpoint, expectedErrorCode = AuthErrorCode.TOKEN_NOT_FOUND ) } test("관리자") { runExceptionTest( token = testAuthUtil.defaultStoreAdminLogin(), method = HttpMethod.PATCH, endpoint = endpoint, expectedErrorCode = AuthErrorCode.ACCESS_DENIED ) } } test("해당 일정이 ${ScheduleStatus.AVAILABLE} 상태이면 정상 응답") { val createdSchedule: ScheduleEntity = dummyInitializer.createSchedule( adminToken = testAuthUtil.defaultStoreAdminLogin(), request = createRequest ) runTest( token = testAuthUtil.defaultUserLogin(), 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("예약이 없으면 실패한다.") { runExceptionTest( token = testAuthUtil.defaultUserLogin(), method = HttpMethod.PATCH, endpoint = "/schedules/$INVALID_PK/hold", expectedErrorCode = ScheduleErrorCode.SCHEDULE_NOT_FOUND ) } test("해당 일정이 ${ScheduleStatus.AVAILABLE} 상태가 아니면 실패한다.") { val adminToken = testAuthUtil.defaultStoreAdminLogin() val createdSchedule: ScheduleEntity = dummyInitializer.createSchedule( adminToken = adminToken, request = createRequest ) /* * 테스트를 위해 수정 API를 호출하여 상태를 HOLD 상태로 변경 * 생성 API에서는 일정 생성 시 AVAILABLE을 기본 상태로 지정하기 때문. */ runTest( token = adminToken, using = { body( ScheduleUpdateRequest(status = ScheduleStatus.HOLD) ) }, on = { patch("/schedules/${createdSchedule.id}") }, expect = { statusCode(HttpStatus.OK.value()) } ) runExceptionTest( token = testAuthUtil.defaultUserLogin(), method = HttpMethod.PATCH, endpoint = "/schedules/${createdSchedule.id}/hold", expectedErrorCode = ScheduleErrorCode.SCHEDULE_NOT_AVAILABLE ) } } context("일정을 수정한다.") { val updateRequest = ScheduleUpdateRequest( time = LocalTime.now().plusHours(1), status = ScheduleStatus.BLOCKED ) context("권한이 없으면 접근할 수 없다.") { val endpoint = "/schedules/${INVALID_PK}" test("비회원") { runExceptionTest( method = HttpMethod.PATCH, requestBody = updateRequest, endpoint = endpoint, expectedErrorCode = AuthErrorCode.TOKEN_NOT_FOUND ) } test("회원") { runExceptionTest( token = testAuthUtil.defaultUserLogin(), method = HttpMethod.PATCH, requestBody = updateRequest, endpoint = endpoint, expectedErrorCode = AuthErrorCode.ACCESS_DENIED ) } listOf(AdminPermissionLevel.READ_SUMMARY, AdminPermissionLevel.READ_ALL).forEach { test("권한이 ${it}인 관리자") { val admin = AdminFixture.create(permissionLevel = it) runExceptionTest( token = testAuthUtil.adminLogin(admin), method = HttpMethod.PATCH, requestBody = updateRequest, endpoint = endpoint, expectedErrorCode = AuthErrorCode.ACCESS_DENIED ) } } } test("정상 수정 및 감사 정보 변경 확인") { val createdSchedule: ScheduleEntity = dummyInitializer.createSchedule( adminToken = testAuthUtil.defaultStoreAdminLogin(), request = createRequest.copy( date = LocalDate.now().plusDays(1), time = LocalTime.now().plusMinutes(1), ) ) val otherAdminToken = testAuthUtil.adminLogin( AdminFixture.create(account = "otherAdmin", phone = "01099999999") ) 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 token = testAuthUtil.defaultStoreAdminLogin() val createdSchedule: ScheduleEntity = dummyInitializer.createSchedule( adminToken = token, request = createRequest ) runTest( token = token, 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("일정이 없으면 실패한다.") { runExceptionTest( token = testAuthUtil.defaultStoreAdminLogin(), method = HttpMethod.PATCH, requestBody = updateRequest, endpoint = "/schedules/${INVALID_PK}", expectedErrorCode = ScheduleErrorCode.SCHEDULE_NOT_FOUND ) } test("입력된 날짜 + 시간이 현재 시간 이전이면 실패한다.") { val token = testAuthUtil.defaultStoreAdminLogin() val createdSchedule: ScheduleEntity = dummyInitializer.createSchedule( adminToken = token, request = createRequest.copy(date = LocalDate.now(), time = LocalTime.now().plusMinutes(1)) ) runExceptionTest( token = token, method = HttpMethod.PATCH, requestBody = updateRequest.copy(time = LocalTime.now().minusMinutes(1)), endpoint = "/schedules/${createdSchedule.id}", expectedErrorCode = ScheduleErrorCode.PAST_DATE_TIME ) } } context("일정을 삭제한다.") { context("권한이 없으면 접근할 수 없다.") { val endpoint = "/schedules/${INVALID_PK}" test("비회원") { runExceptionTest( method = HttpMethod.DELETE, endpoint = endpoint, expectedErrorCode = AuthErrorCode.TOKEN_NOT_FOUND ) } test("회원") { runExceptionTest( token = testAuthUtil.defaultUserLogin(), method = HttpMethod.DELETE, endpoint = endpoint, expectedErrorCode = AuthErrorCode.ACCESS_DENIED ) } listOf(AdminPermissionLevel.READ_SUMMARY, AdminPermissionLevel.READ_ALL).forEach { test("권한이 ${it}인 관리자") { val admin = AdminFixture.create(permissionLevel = it) runExceptionTest( token = testAuthUtil.adminLogin(admin), method = HttpMethod.DELETE, endpoint = endpoint, expectedErrorCode = AuthErrorCode.ACCESS_DENIED ) } } } test("정상 삭제") { val token = testAuthUtil.defaultStoreAdminLogin() val createdSchedule: ScheduleEntity = dummyInitializer.createSchedule( adminToken = token, request = createRequest ) runTest( token = token, on = { delete("/schedules/${createdSchedule.id}") }, expect = { statusCode(HttpStatus.NO_CONTENT.value()) } ).also { scheduleRepository.findByIdOrNull(createdSchedule.id) shouldBe null } } test("예약 중이거나 예약이 완료된 일정이면 실패한다.") { val token = testAuthUtil.defaultStoreAdminLogin() val createdSchedule: ScheduleEntity = dummyInitializer.createSchedule( adminToken = token, request = createRequest ) /* * 테스트를 위해 수정 API를 호출하여 상태를 예약 중 상태로 변경 * 생성 API에서는 일정 생성 시 AVAILABLE을 기본 상태로 지정하기 때문. */ runTest( token = token, using = { body( ScheduleUpdateRequest(status = ScheduleStatus.RESERVED) ) }, on = { patch("/schedules/${createdSchedule.id}") }, expect = { statusCode(HttpStatus.OK.value()) } ) /** * 삭제 테스트 */ runExceptionTest( token = token, method = HttpMethod.DELETE, endpoint = "/schedules/${createdSchedule.id}", expectedErrorCode = ScheduleErrorCode.SCHEDULE_IN_USE ) } } } }