[#16] Reservation 도메인 코드 코틀린 마이그레이션 #17

Merged
pricelees merged 40 commits from refactor/#16 into main 2025-07-21 12:08:56 +00:00
Showing only changes of commit 5c6c52cf41 - Show all commits

View File

@ -1,254 +1,306 @@
package roomescape.reservation.web;
package roomescape.reservation.web
import static org.hamcrest.Matchers.*;
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.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest
import org.springframework.context.annotation.Import
import org.springframework.data.repository.findByIdOrNull
import org.springframework.http.MediaType
import org.springframework.test.web.servlet.MockMvc
import roomescape.common.config.JacksonConfig
import roomescape.common.exception.ErrorType
import roomescape.reservation.business.ReservationTimeService
import roomescape.reservation.infrastructure.persistence.ReservationRepository
import roomescape.reservation.infrastructure.persistence.ReservationTimeEntity
import roomescape.reservation.infrastructure.persistence.ReservationTimeRepository
import roomescape.util.ReservationFixture
import roomescape.util.ReservationTimeFixture
import roomescape.util.RoomescapeApiTest
import roomescape.util.ThemeFixture
import java.time.LocalDate
import java.time.LocalTime
import java.time.LocalDate;
import java.time.LocalTime;
import java.util.Map;
import java.util.stream.Stream;
@WebMvcTest(ReservationTimeController::class)
@Import(JacksonConfig::class)
class ReservationTimeControllerTest(
val mockMvc: MockMvc,
) : RoomescapeApiTest() {
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.MethodSource;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.context.SpringBootTest.WebEnvironment;
import org.springframework.boot.test.web.server.LocalServerPort;
import org.springframework.test.context.jdbc.Sql;
import org.springframework.test.context.jdbc.Sql.ExecutionPhase;
@SpykBean
private lateinit var reservationTimeService: ReservationTimeService
import io.restassured.RestAssured;
import io.restassured.http.ContentType;
import io.restassured.http.Header;
import roomescape.member.infrastructure.persistence.MemberEntity;
import roomescape.member.infrastructure.persistence.MemberRepository;
import roomescape.member.infrastructure.persistence.Role;
import roomescape.reservation.infrastructure.persistence.ReservationEntity;
import roomescape.reservation.infrastructure.persistence.ReservationRepository;
import roomescape.reservation.infrastructure.persistence.ReservationStatus;
import roomescape.reservation.infrastructure.persistence.ReservationTimeEntity;
import roomescape.reservation.infrastructure.persistence.ReservationTimeRepository;
import roomescape.theme.infrastructure.persistence.ThemeEntity;
import roomescape.theme.infrastructure.persistence.ThemeRepository;
@MockkBean
private lateinit var reservationTimeRepository: ReservationTimeRepository
@SpringBootTest(webEnvironment = WebEnvironment.RANDOM_PORT)
@Sql(scripts = "/truncate.sql", executionPhase = ExecutionPhase.BEFORE_TEST_METHOD)
public class ReservationTimeControllerTest {
@MockkBean
private lateinit var reservationRepository: ReservationRepository
@Autowired
private ReservationTimeRepository reservationTimeRepository;
init {
Given("등록된 모든 시간을 조회할 때") {
val endpoint = "/times"
@Autowired
private ThemeRepository themeRepository;
@Autowired
private ReservationRepository reservationRepository;
@Autowired
private MemberRepository memberRepository;
@LocalServerPort
private int port;
private final Map<String, String> params = Map.of(
"startAt", "17:00"
);
@Test
@DisplayName("처음으로 등록하는 시간의 id는 1이다.")
void firstPost() {
String adminAccessTokenCookie = getAdminAccessTokenCookieByLogin("email@email.com", "password");
RestAssured.given().log().all()
.contentType(ContentType.JSON)
.port(port)
.header(new Header("Cookie", adminAccessTokenCookie))
.body(params)
.when().post("/times")
.then().log().all()
.statusCode(201)
.body("data.id", is(1))
.header("Location", "/times/1");
When("관리자인 경우") {
beforeTest {
loginAsAdmin()
}
@Test
@DisplayName("아무 시간도 등록 하지 않은 경우, 시간 목록 조회 결과 개수는 0개이다.")
void readEmptyTimes() {
String adminAccessTokenCookie = getAdminAccessTokenCookieByLogin("email@email.com", "password");
RestAssured.given().log().all()
.port(port)
.header(new Header("Cookie", adminAccessTokenCookie))
.when().get("/times")
.then().log().all()
.statusCode(200)
.body("data.times.size()", is(0));
}
@Test
@DisplayName("하나의 시간만 등록한 경우, 시간 목록 조회 결과 개수는 1개이다.")
void readTimesSizeAfterFirstPost() {
String adminAccessTokenCookie = getAdminAccessTokenCookieByLogin("email@email.com", "password");
RestAssured.given().log().all()
.contentType(ContentType.JSON)
.port(port)
.header(new Header("Cookie", adminAccessTokenCookie))
.body(params)
.when().post("/times")
.then().log().all()
.statusCode(201)
.body("data.id", is(1))
.header("Location", "/times/1");
RestAssured.given().log().all()
.port(port)
.header(new Header("Cookie", adminAccessTokenCookie))
.when().get("/times")
.then().log().all()
.statusCode(200)
.body("data.times.size()", is(1));
}
@Test
@DisplayName("하나의 시간만 등록한 경우, 시간 삭제 뒤 시간 목록 조회 결과 개수는 0개이다.")
void readTimesSizeAfterPostAndDelete() {
String adminAccessTokenCookie = getAdminAccessTokenCookieByLogin("email@email.com", "password");
RestAssured.given().log().all()
.contentType(ContentType.JSON)
.port(port)
.header(new Header("Cookie", adminAccessTokenCookie))
.body(params)
.when().post("/times")
.then().log().all()
.statusCode(201)
.body("data.id", is(1))
.header("Location", "/times/1");
RestAssured.given().log().all()
.port(port)
.header(new Header("Cookie", adminAccessTokenCookie))
.when().delete("/times/1")
.then().log().all()
.statusCode(204);
RestAssured.given().log().all()
.port(port)
.header(new Header("Cookie", adminAccessTokenCookie))
.when().get("/times")
.then().log().all()
.statusCode(200)
.body("data.times.size()", is(0));
}
@ParameterizedTest
@MethodSource("validateRequestDataFormatSource")
@DisplayName("예약 시간 생성 시, 시간 요청 데이터에 시간 포맷이 아닌 값이 입력되어오면 400 에러를 발생한다.")
void validateRequestDataFormat(Map<String, String> request) {
String adminAccessTokenCookie = getAdminAccessTokenCookieByLogin("email@email.com", "password");
RestAssured.given().log().all()
.contentType(ContentType.JSON)
.header(new Header("Cookie", adminAccessTokenCookie))
.port(port)
.body(request)
.when().post("/times")
.then().log().all()
.statusCode(400);
}
static Stream<Map<String, String>> validateRequestDataFormatSource() {
return Stream.of(
Map.of(
"startAt", "24:59"
),
Map.of(
"startAt", "hihi")
);
}
@ParameterizedTest
@MethodSource("validateBlankRequestSource")
@DisplayName("예약 시간 생성 시, 요청 값에 공백 또는 null이 포함되어 있으면 400 에러를 발생한다.")
void validateBlankRequest(Map<String, String> request) {
String adminAccessTokenCookie = getAdminAccessTokenCookieByLogin("email@email.com", "password");
RestAssured.given().log().all()
.contentType(ContentType.JSON)
.header(new Header("Cookie", adminAccessTokenCookie))
.port(port)
.body(request)
.when().post("/times")
.then().log().all()
.statusCode(400);
}
static Stream<Map<String, String>> validateBlankRequestSource() {
return Stream.of(
Map.of(
),
Map.of(
"startAt", ""
),
Map.of(
"startAt", " "
Then("정상 응답") {
every {
reservationTimeRepository.findAll()
} returns listOf(
ReservationTimeFixture.create(id = 1L),
ReservationTimeFixture.create(id = 2L)
)
);
runGetTest(
mockMvc = mockMvc,
endpoint = endpoint,
log = true
) {
status { isOk() }
content {
contentType(MediaType.APPLICATION_JSON)
jsonPath("$.data.times[0].id") { value(1) }
jsonPath("$.data.times[1].id") { value(2) }
}
}
}
}
private String getAdminAccessTokenCookieByLogin(String email, String password) {
memberRepository.save(new MemberEntity(null, "이름", email, password, Role.ADMIN));
When("관리자가 아닌 경우") {
loginAsUser()
Map<String, String> loginParams = Map.of(
"email", email,
"password", password
);
String accessToken = RestAssured.given().log().all()
.contentType(ContentType.JSON)
.port(port)
.body(loginParams)
.when().post("/login")
.then().log().all().extract().cookie("accessToken");
return "accessToken=" + accessToken;
Then("로그인 페이지로 이동") {
runGetTest(
mockMvc = mockMvc,
endpoint = endpoint,
log = true
) {
status { is3xxRedirection() }
header { string("Location", "/login") }
}
}
}
}
@Test
@DisplayName("특정 날짜의 특정 테마 예약 현황을 조회한다.")
void readReservationByDateAndThemeId() {
// given
LocalDate today = LocalDate.now();
ReservationTimeEntity reservationTime1 = reservationTimeRepository.save(
new ReservationTimeEntity(null, LocalTime.of(17, 0)));
ReservationTimeEntity reservationTime2 = reservationTimeRepository.save(
new ReservationTimeEntity(null, LocalTime.of(17, 30)));
ReservationTimeEntity reservationTime3 = reservationTimeRepository.save(
new ReservationTimeEntity(null, LocalTime.of(18, 30)));
ThemeEntity theme = themeRepository.save(new ThemeEntity(null, "테마명1", "설명", "썸네일URL"));
MemberEntity member = memberRepository.save(
new MemberEntity(null, "name", "email@email.com", "password", Role.MEMBER));
Given("시간을 추가할 때") {
val endpoint = "/times"
reservationRepository.save(
new ReservationEntity(null, today.plusDays(1), reservationTime1, theme, member,
ReservationStatus.CONFIRMED));
reservationRepository.save(
new ReservationEntity(null, today.plusDays(1), reservationTime2, theme, member,
ReservationStatus.CONFIRMED));
reservationRepository.save(
new ReservationEntity(null, today.plusDays(1), reservationTime3, theme, member,
ReservationStatus.CONFIRMED));
When("관리자인 경우") {
beforeTest {
loginAsAdmin()
}
val time = LocalTime.of(10, 0)
val request = ReservationTimeRequest(startAt = time)
// when & then
RestAssured.given().log().all()
.contentType(ContentType.JSON)
.port(port)
.header("Cookie", getAdminAccessTokenCookieByLogin("a@a.a", "a"))
.when().get("/times/filter?date={date}&themeId={themeId}", today.plusDays(1).toString(), theme.getId())
.then().log().all()
.statusCode(200)
.body("data.reservationTimes.size()", is(3));
Then("시간 형식이 HH:mm이 아니거나, 범위를 벗어나면 400 응답") {
listOf(
"{\"startAt\": \"23:30:30\"}",
"{\"startAt\": \"24:59\"}",
).forEach {
runPostTest(
mockMvc = mockMvc,
endpoint = endpoint,
body = it,
log = true
) {
status { isBadRequest() }
}
}
}
Then("정상 응답") {
every {
reservationTimeService.addTime(request)
} returns ReservationTimeResponse(id = 1, startAt = time)
runPostTest(
mockMvc = mockMvc,
endpoint = endpoint,
body = request,
log = true
) {
status { isCreated() }
content {
contentType(MediaType.APPLICATION_JSON)
jsonPath("$.data.id") { value(1) }
jsonPath("$.data.startAt") { value("10:00") }
}
}
}
Then("동일한 시간이 존재하면 409 응답") {
every {
reservationTimeRepository.existsByStartAt(time)
} returns true
runPostTest(
mockMvc = mockMvc,
endpoint = endpoint,
body = request,
log = true
) {
status { isConflict() }
content {
contentType(MediaType.APPLICATION_JSON)
jsonPath("$.errorType") { value(ErrorType.TIME_DUPLICATED.name) }
}
}
}
}
When("관리자가 아닌 경우") {
loginAsUser()
Then("로그인 페이지로 이동") {
runPostTest(
mockMvc = mockMvc,
endpoint = endpoint,
body = ReservationTimeFixture.create(),
log = true
) {
status { is3xxRedirection() }
header { string("Location", "/login") }
}
}
}
}
Given("시간을 삭제할 때") {
val endpoint = "/times/1"
When("관리자인 경우") {
beforeTest {
loginAsAdmin()
}
Then("정상 응답") {
every {
reservationTimeService.removeTimeById(1L)
} returns Unit
runDeleteTest(
mockMvc = mockMvc,
endpoint = endpoint,
log = true
) {
status { isNoContent() }
}
}
Then("없는 시간을 조회하면 400 응답") {
val id = 1L
every {
reservationTimeRepository.findByIdOrNull(id)
} returns null
runDeleteTest(
mockMvc = mockMvc,
endpoint = "/times/$id",
log = true
) {
status { isBadRequest() }
content {
contentType(MediaType.APPLICATION_JSON)
jsonPath("$.errorType") { value(ErrorType.RESERVATION_TIME_NOT_FOUND.name) }
}
}
}
Then("예약이 있는 시간을 삭제하면 409 응답") {
val id = 1L
every {
reservationTimeRepository.findByIdOrNull(id)
} returns ReservationTimeFixture.create(id = id)
every {
reservationRepository.findByReservationTime(any())
} returns listOf(ReservationFixture.create())
runDeleteTest(
mockMvc = mockMvc,
endpoint = "/times/$id",
log = true
) {
status { isConflict() }
content {
contentType(MediaType.APPLICATION_JSON)
jsonPath("$.errorType") { value(ErrorType.TIME_IS_USED_CONFLICT.name) }
}
}
}
}
When("관리자가 아닌 경우") {
loginAsUser()
Then("로그인 페이지로 이동") {
runDeleteTest(
mockMvc = mockMvc,
endpoint = endpoint,
log = true
) {
status { is3xxRedirection() }
header { string("Location", "/login") }
}
}
}
}
Given("날짜, 테마가 주어졌을 때") {
loginAsUser()
val date: LocalDate = LocalDate.now()
val themeId = 1L
When("저장된 예약 시간이 있으면") {
val times: List<ReservationTimeEntity> = listOf(
ReservationTimeFixture.create(id = 1L, startAt = LocalTime.of(10, 0)),
ReservationTimeFixture.create(id = 2L, startAt = LocalTime.of(11, 0))
)
every {
reservationTimeRepository.findAll()
} returns times
Then("그 시간과, 해당 날짜와 테마에 대한 예약 여부가 담긴 목록을 응답") {
every {
reservationRepository.findByDateAndThemeId(date, themeId)
} returns listOf(
ReservationFixture.create(
id = 1L,
date = date,
theme = ThemeFixture.create(id = themeId),
reservationTime = times[0]
)
)
val response = runGetTest(
mockMvc = mockMvc,
endpoint = "/times/filter?date=$date&themeId=$themeId",
log = true
) {
status { isOk() }
content {
contentType(MediaType.APPLICATION_JSON)
}
}.andReturn().readValue(ReservationTimeInfosResponse::class.java)
assertSoftly(response.times) {
this shouldHaveSize times.size
this[0].id shouldBe times[0].id
this[0].alreadyBooked shouldBe true
this[1].id shouldBe times[1].id
this[1].alreadyBooked shouldBe false
}
}
}
}
}
}