package roomescape.time.web import com.ninjasquad.springmockk.MockkBean import com.ninjasquad.springmockk.SpykBean import io.kotest.assertions.assertSoftly import io.kotest.matchers.collections.shouldHaveSize import io.kotest.matchers.shouldBe import io.mockk.every import org.hamcrest.Matchers.equalTo import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest import org.springframework.context.annotation.Import import org.springframework.data.jpa.mapping.JpaMetamodelMappingContext import org.springframework.data.repository.findByIdOrNull import org.springframework.http.MediaType import org.springframework.test.web.servlet.MockMvc import roomescape.auth.exception.AuthErrorCode import roomescape.common.config.JacksonConfig import roomescape.reservation.infrastructure.persistence.ReservationRepository import roomescape.time.business.TimeService import roomescape.time.exception.TimeErrorCode import roomescape.time.infrastructure.persistence.TimeEntity import roomescape.time.infrastructure.persistence.TimeRepository import roomescape.util.ReservationFixture import roomescape.util.RoomescapeApiTest import roomescape.util.ThemeFixture import roomescape.util.TimeFixture import java.time.LocalDate import java.time.LocalTime @WebMvcTest(TimeController::class) class TimeControllerTest( val mockMvc: MockMvc, ) : RoomescapeApiTest() { @SpykBean private lateinit var timeService: TimeService @MockkBean private lateinit var timeRepository: TimeRepository @MockkBean private lateinit var reservationRepository: ReservationRepository init { Given("등록된 모든 시간을 조회할 때") { val endpoint = "/times" When("관리자인 경우") { beforeTest { loginAsAdmin() } Then("정상 응답") { every { timeRepository.findAll() } returns listOf( TimeFixture.create(id = 1L), TimeFixture.create(id = 2L) ) runGetTest( mockMvc = mockMvc, endpoint = endpoint, ) { status { isOk() } content { contentType(MediaType.APPLICATION_JSON) jsonPath("$.data.times[0].id") { value(1) } jsonPath("$.data.times[1].id") { value(2) } } } } } When("관리자가 아닌 경우") { loginAsUser() val expectedError = AuthErrorCode.ACCESS_DENIED Then("에러 응답을 받는다.") { runGetTest( mockMvc = mockMvc, endpoint = endpoint, ) { status { isEqualTo(expectedError.httpStatus.value()) } }.andExpect { content { contentType(MediaType.APPLICATION_JSON) jsonPath("$.code") { value(expectedError.errorCode) } } } } } } Given("시간을 추가할 때") { val endpoint = "/times" When("관리자인 경우") { beforeTest { loginAsAdmin() } val time = LocalTime.of(10, 0) val request = TimeCreateRequest(startAt = time) Then("시간 형식이 HH:mm이 아니거나, 범위를 벗어나면 400 응답") { listOf( "{\"startAt\": \"23:30:30\"}", "{\"startAt\": \"24:59\"}", ).forEach { runPostTest( mockMvc = mockMvc, endpoint = endpoint, body = it, ) { status { isBadRequest() } } } } Then("정상 응답") { every { timeService.createTime(request) } returns TimeCreateResponse(id = 1, startAt = time) runPostTest( mockMvc = mockMvc, endpoint = endpoint, body = request, ) { status { isCreated() } content { contentType(MediaType.APPLICATION_JSON) jsonPath("$.data.id") { value(1) } jsonPath("$.data.startAt") { value("10:00") } } } } Then("동일한 시간이 존재하면 예외 응답") { val expectedError = TimeErrorCode.TIME_DUPLICATED every { timeRepository.existsByStartAt(time) } returns true runPostTest( mockMvc = mockMvc, endpoint = endpoint, body = request, ) { status { isEqualTo(expectedError.httpStatus.value()) } content { contentType(MediaType.APPLICATION_JSON) jsonPath("$.code") { value(expectedError.errorCode) } } } } } When("관리자가 아닌 경우") { loginAsUser() Then("에러 응답을 받는다.") { val expectedError = AuthErrorCode.ACCESS_DENIED runPostTest( mockMvc = mockMvc, endpoint = endpoint, body = TimeFixture.create(), ) { status { isEqualTo(expectedError.httpStatus.value()) } jsonPath("$.code", equalTo(expectedError.errorCode)) } } } } Given("시간을 삭제할 때") { val endpoint = "/times/1" When("관리자인 경우") { beforeTest { loginAsAdmin() } Then("정상 응답") { every { timeService.deleteTime(1L) } returns Unit runDeleteTest( mockMvc = mockMvc, endpoint = endpoint, ) { status { isNoContent() } } } Then("없는 시간을 조회하면 예외 응답") { val id = 1L val expectedError = TimeErrorCode.TIME_NOT_FOUND every { timeRepository.findByIdOrNull(id) } returns null runDeleteTest( mockMvc = mockMvc, endpoint = "/times/$id", ) { status { isEqualTo(expectedError.httpStatus.value()) } content { contentType(MediaType.APPLICATION_JSON) jsonPath("$.code") { value(expectedError.errorCode) } } } } Then("예약이 있는 시간을 삭제하면 예외 응답") { val id = 1L val expectedError = TimeErrorCode.TIME_ALREADY_RESERVED every { timeRepository.findByIdOrNull(id) } returns TimeFixture.create(id = id) every { reservationRepository.findAllByTime(any()) } returns listOf(ReservationFixture.create()) runDeleteTest( mockMvc = mockMvc, endpoint = "/times/$id", ) { status { isEqualTo(expectedError.httpStatus.value()) } content { contentType(MediaType.APPLICATION_JSON) jsonPath("$.code") { value(expectedError.errorCode) } } } } } When("관리자가 아닌 경우") { loginAsUser() Then("에러 응답을 받는다.") { val expectedError = AuthErrorCode.ACCESS_DENIED runDeleteTest( mockMvc = mockMvc, endpoint = endpoint, ) { status { isEqualTo(expectedError.httpStatus.value()) } jsonPath("$.code", equalTo(expectedError.errorCode)) } } } } Given("날짜, 테마가 주어졌을 때") { loginAsUser() val date: LocalDate = LocalDate.now() val themeId = 1L When("저장된 예약 시간이 있으면") { val times: List = listOf( TimeFixture.create(id = 1L, startAt = LocalTime.of(10, 0)), TimeFixture.create(id = 2L, startAt = LocalTime.of(11, 0)) ) every { timeRepository.findAll() } returns times Then("그 시간과, 해당 날짜와 테마에 대한 예약 여부가 담긴 목록을 응답") { every { reservationRepository.findByDateAndThemeId(date, themeId) } returns listOf( ReservationFixture.create( id = 1L, date = date, theme = ThemeFixture.create(id = themeId), time = times[0] ) ) val response = runGetTest( mockMvc = mockMvc, endpoint = "/times/search?date=$date&themeId=$themeId", ) { status { isOk() } content { contentType(MediaType.APPLICATION_JSON) } }.andReturn().readValue(TimeWithAvailabilityListResponse::class.java) assertSoftly(response.times) { this shouldHaveSize times.size this[0].id shouldBe times[0].id this[0].isAvailable shouldBe false this[1].id shouldBe times[1].id this[1].isAvailable shouldBe true } } } } } }